Extend existing releases' title and compatible MC versions without
re-uploading files, via Modrinth PATCH /version/{id} and CurseForge
update-file. Adds an UpdateModal with range/title diff preview, an
update tRPC mutation sharing the progress subscription with publish, and
dry-run interception for Modrinth PATCH requests.
353 lines
10 KiB
TypeScript
353 lines
10 KiB
TypeScript
import { initTRPC } from "@trpc/server";
|
|
import { EventEmitter, on } from "node:events";
|
|
import { getConfig } from "./config";
|
|
import { errorMessage } from "@/lib/utils/error";
|
|
import {
|
|
PublishInputSchema,
|
|
UpdateInputSchema,
|
|
type ProgressEvent,
|
|
type PlatformResult,
|
|
type PublishResult,
|
|
platforms,
|
|
type PlatformContext,
|
|
type PlatformAdaptor,
|
|
type PublishInput,
|
|
type UpdateInput,
|
|
type ExistingRelease,
|
|
PublishResultSchema,
|
|
ProgressEventSchema,
|
|
} from "./publish.schemas";
|
|
import {
|
|
publishModrinthVersion,
|
|
updateModrinthVersion,
|
|
listExistingModrinth,
|
|
modrinthMatchKey,
|
|
} from "./platforms/modrinth";
|
|
import {
|
|
publishCurseForgeVersion,
|
|
updateCurseForgeVersion,
|
|
listExistingCurseForge,
|
|
curseForgeMatchKey,
|
|
} from "./platforms/curseforge";
|
|
import { computeVersionRanges, type McVersionEntry } from "@/lib/utils/mcVersion";
|
|
import { getCurseForgeMcVersions, getModrinthMcVersions } from "./meta";
|
|
import { readAsFile } from "@/lib/utils/file";
|
|
import { Template } from "@/lib/utils/template";
|
|
import { buildJarPath } from "@/lib/utils/format";
|
|
|
|
export const platformAdaptors: {
|
|
[key in typeof platforms[number]]: PlatformAdaptor
|
|
} = {
|
|
modrinth: {
|
|
publish: publishModrinthVersion,
|
|
update: updateModrinthVersion,
|
|
listExisting: listExistingModrinth,
|
|
matchKey: modrinthMatchKey,
|
|
getGameVersions: getModrinthMcVersions,
|
|
},
|
|
curseforge: {
|
|
publish: publishCurseForgeVersion,
|
|
update: updateCurseForgeVersion,
|
|
listExisting: listExistingCurseForge,
|
|
matchKey: curseForgeMatchKey,
|
|
getGameVersions: getCurseForgeMcVersions,
|
|
},
|
|
};
|
|
|
|
// ── tRPC init ──────────────────────────────────────────
|
|
|
|
const t = initTRPC.create();
|
|
|
|
// ── Shared EventEmitter for progress ───────────────────
|
|
|
|
const ee = new EventEmitter();
|
|
|
|
// ── Platform orchestrator ──────────────────────────────
|
|
|
|
/**
|
|
* 计算勾选版本的兼容范围条目(发布与更新流程共用)。
|
|
* 范围必须基于完整项目版本列表计算,勾选只决定处理哪些条目。
|
|
*/
|
|
async function computeSelectedEntries(
|
|
platform: typeof platforms[number],
|
|
selectedMcVersions: string[],
|
|
ctx: PlatformContext<UpdateInput>,
|
|
): Promise<McVersionEntry[]> {
|
|
const selected = new Set(selectedMcVersions);
|
|
const entries = computeVersionRanges(
|
|
ctx.input.allMcVersions,
|
|
await platformAdaptors[platform].getGameVersions(),
|
|
ctx.input.cutoffMcVersion
|
|
).filter((e) => selected.has(e.version));
|
|
if (entries.length !== selected.size) {
|
|
throw new Error("勾选版本与项目版本列表不一致,请刷新后重试");
|
|
}
|
|
return entries;
|
|
}
|
|
|
|
/** 平台准备阶段失败的兜底:记为该平台全部失败并发出 failed 事件,
|
|
* 否则前端进度条会停在 running 假象里,错误也被吞掉。 */
|
|
function failPlatformPreparation(
|
|
platform: typeof platforms[number],
|
|
total: number,
|
|
result: PlatformResult,
|
|
err: unknown,
|
|
logPrefix: string,
|
|
): void {
|
|
const msg = errorMessage(err);
|
|
console.error(`[${logPrefix}] ${platform} 准备阶段失败:`, err);
|
|
result.fail = total;
|
|
result.errors.push(msg);
|
|
ee.emit("progress", {
|
|
platform,
|
|
current: 0,
|
|
total,
|
|
status: "failed",
|
|
errors: result.errors,
|
|
} satisfies ProgressEvent);
|
|
}
|
|
|
|
async function runPlatform(
|
|
platform: typeof platforms[number],
|
|
mcVersions: PublishInput["mcVersions"][keyof PublishInput["mcVersions"]],
|
|
ctx: PlatformContext,
|
|
): Promise<PlatformResult> {
|
|
const result: PlatformResult = { success: 0, fail: 0, errors: [] };
|
|
const total = mcVersions.length;
|
|
|
|
if (total === 0) {
|
|
ee.emit("progress", {
|
|
platform,
|
|
current: 0,
|
|
total: 0,
|
|
status: "completed",
|
|
errors: [],
|
|
} satisfies ProgressEvent);
|
|
return result;
|
|
}
|
|
|
|
// Emit initial running state
|
|
ee.emit("progress", {
|
|
platform,
|
|
current: 0,
|
|
total,
|
|
status: "running",
|
|
errors: [],
|
|
} satisfies ProgressEvent);
|
|
|
|
const publishFn = platformAdaptors[platform].publish;
|
|
|
|
// 平台准备阶段(元数据拉取 + 版本范围计算)。此处异常不在逐版本的
|
|
// try/catch 内,必须单独兜底。
|
|
let mcVersionEntries: McVersionEntry[];
|
|
try {
|
|
mcVersionEntries = await computeSelectedEntries(
|
|
platform,
|
|
mcVersions.map((v) => v.mc_version),
|
|
ctx,
|
|
);
|
|
} catch (err) {
|
|
failPlatformPreparation(platform, total, result, err, "publish");
|
|
return result;
|
|
}
|
|
|
|
const primaryFileTemplate = new Template(ctx.config.filename_format);
|
|
const sourceFileTemplate = new Template(ctx.config.source_filename_format);
|
|
|
|
for (let i = 0; i < mcVersionEntries.length; i++) {
|
|
const mcVersionEntry = mcVersionEntries[i];
|
|
|
|
try {
|
|
const primaryFile = await readAsFile(buildJarPath(ctx.config.project_dir, primaryFileTemplate, ctx.input.version, mcVersionEntry.version));
|
|
const sourceFile = await readAsFile(buildJarPath(ctx.config.project_dir, sourceFileTemplate, ctx.input.version, mcVersionEntry.version));
|
|
|
|
await publishFn(ctx, mcVersionEntry, {
|
|
primary: primaryFile,
|
|
source: sourceFile,
|
|
});
|
|
result.success++;
|
|
|
|
const isLast = i + 1 === total;
|
|
ee.emit("progress", {
|
|
platform,
|
|
current: i + 1,
|
|
total,
|
|
status: isLast ? "completed" : "running",
|
|
errors: result.errors,
|
|
} satisfies ProgressEvent);
|
|
} catch (err) {
|
|
result.fail++;
|
|
const msg = `${mcVersionEntry.version}: ${errorMessage(err)}`;
|
|
result.errors.push(msg);
|
|
|
|
ee.emit("progress", {
|
|
platform,
|
|
current: i,
|
|
total,
|
|
status: "failed",
|
|
errors: result.errors,
|
|
} satisfies ProgressEvent);
|
|
|
|
// Cancel remaining for this platform only
|
|
break;
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
// ── Update orchestrator ────────────────────────────────
|
|
|
|
async function runPlatformUpdate(
|
|
platform: typeof platforms[number],
|
|
mcVersions: UpdateInput["mcVersions"][keyof UpdateInput["mcVersions"]],
|
|
ctx: PlatformContext<UpdateInput>,
|
|
): Promise<PlatformResult> {
|
|
const result: PlatformResult = { success: 0, fail: 0, errors: [] };
|
|
const total = mcVersions.length;
|
|
|
|
if (total === 0) {
|
|
ee.emit("progress", {
|
|
platform,
|
|
current: 0,
|
|
total: 0,
|
|
status: "completed",
|
|
errors: [],
|
|
} satisfies ProgressEvent);
|
|
return result;
|
|
}
|
|
|
|
ee.emit("progress", {
|
|
platform,
|
|
current: 0,
|
|
total,
|
|
status: "running",
|
|
errors: [],
|
|
} satisfies ProgressEvent);
|
|
|
|
const adaptor = platformAdaptors[platform];
|
|
|
|
// 准备阶段(范围计算 + 已有版本列表拉取),异常处理同 runPlatform
|
|
let mcVersionEntries: McVersionEntry[];
|
|
let existingReleases: ExistingRelease[];
|
|
try {
|
|
mcVersionEntries = await computeSelectedEntries(
|
|
platform,
|
|
mcVersions.map((v) => v.mc_version),
|
|
ctx,
|
|
);
|
|
existingReleases = await adaptor.listExisting(ctx.config);
|
|
} catch (err) {
|
|
failPlatformPreparation(platform, total, result, err, "update");
|
|
return result;
|
|
}
|
|
|
|
for (let i = 0; i < mcVersionEntries.length; i++) {
|
|
const mcVersionEntry = mcVersionEntries[i];
|
|
|
|
try {
|
|
// 服务端按模板重新匹配已有版本,不信任前端传入的任何 ID
|
|
const key = adaptor.matchKey(ctx.config, ctx.input.version, mcVersionEntry.version);
|
|
const existing = existingReleases.find((e) => e.matchKey === key);
|
|
if (!existing) {
|
|
throw new Error(`平台上未找到版本 "${key}",请改用发布流程`);
|
|
}
|
|
|
|
await adaptor.update(ctx, mcVersionEntry, existing);
|
|
result.success++;
|
|
|
|
const isLast = i + 1 === total;
|
|
ee.emit("progress", {
|
|
platform,
|
|
current: i + 1,
|
|
total,
|
|
status: isLast ? "completed" : "running",
|
|
errors: result.errors,
|
|
} satisfies ProgressEvent);
|
|
} catch (err) {
|
|
result.fail++;
|
|
const msg = `${mcVersionEntry.version}: ${errorMessage(err)}`;
|
|
result.errors.push(msg);
|
|
|
|
ee.emit("progress", {
|
|
platform,
|
|
current: i,
|
|
total,
|
|
status: "failed",
|
|
errors: result.errors,
|
|
} satisfies ProgressEvent);
|
|
|
|
// Cancel remaining for this platform only
|
|
break;
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
// ── Router ─────────────────────────────────────────────
|
|
|
|
export const appRouter = t.router({
|
|
publish: t.procedure
|
|
.input(PublishInputSchema)
|
|
.mutation(async ({ input }): Promise<PublishResult> => {
|
|
const config = await getConfig(input.configFile);
|
|
|
|
const ctx: PlatformContext = { input, config };
|
|
|
|
const results = await Promise.all(platforms.map(async (platform) => {
|
|
try {
|
|
const result = await runPlatform(platform, input.mcVersions[platform], ctx);
|
|
return {platform, result};
|
|
} catch (err) {
|
|
console.error(`[publish] ${platform} 执行异常:`, err);
|
|
return {
|
|
platform,
|
|
result: { success: 0, fail: 0, errors: [errorMessage(err)] }
|
|
}
|
|
}
|
|
}));
|
|
|
|
return PublishResultSchema.parse(Object.fromEntries(results.map(result => [result.platform, result.result])));
|
|
}),
|
|
|
|
update: t.procedure
|
|
.input(UpdateInputSchema)
|
|
.mutation(async ({ input }): Promise<PublishResult> => {
|
|
const config = await getConfig(input.configFile);
|
|
|
|
const ctx: PlatformContext<UpdateInput> = { input, config };
|
|
|
|
const results = await Promise.all(platforms.map(async (platform) => {
|
|
try {
|
|
const result = await runPlatformUpdate(platform, input.mcVersions[platform], ctx);
|
|
return {platform, result};
|
|
} catch (err) {
|
|
console.error(`[update] ${platform} 执行异常:`, err);
|
|
return {
|
|
platform,
|
|
result: { success: 0, fail: 0, errors: [errorMessage(err)] }
|
|
}
|
|
}
|
|
}));
|
|
|
|
return PublishResultSchema.parse(Object.fromEntries(results.map(result => [result.platform, result.result])));
|
|
}),
|
|
|
|
progress: t.procedure.subscription(async function* (opts) {
|
|
for await (const [data] of on(ee, "progress", {
|
|
signal: opts.signal,
|
|
})) {
|
|
const parsed = ProgressEventSchema.safeParse(data);
|
|
if (!parsed.success) {
|
|
// 坏事件不应中断订阅(客户端没有重连机制),跳过并记录
|
|
console.error("[publish] invalid progress event:", parsed.error);
|
|
continue;
|
|
}
|
|
yield parsed.data;
|
|
}
|
|
}),
|
|
});
|
|
|
|
export type AppRouter = typeof appRouter;
|