import { initTRPC } from "@trpc/server"; import { z } from "zod"; import EventEmitter, { on } from "node:events"; import { readFileSync } from "node:fs"; import path from "node:path"; import { getConfig } from "./config"; import { modrinthClient, curseforgeClient } from "./clients"; import { createVersion } from "@/lib/modrinth"; import { uploadFile } from "@/lib/curseforge"; import { Template } from "@/lib/utils/template"; import type { CreatableVersion, UploadMetadata } from "@/types"; // ── Zod schemas ──────────────────────────────────────── const PublishInputSchema = z.object({ configName: z.string(), version: z.string(), mcVersions: z.object({ modrinth: z.array(z.object({ mc_version: z.string() })), curseforge: z.array(z.object({ mc_version: z.string() })), }), changelog: z.string(), versionType: z.enum(["release", "beta", "alpha"]), }); const ProgressEventSchema = z.object({ platform: z.enum(["modrinth", "curseforge"]), current: z.number().int().min(0), total: z.number().int().min(0), status: z.enum(["running", "completed", "failed"]), errors: z.array(z.string()), }); const PlatformResultSchema = z.object({ success: z.number().int().min(0), fail: z.number().int().min(0), errors: z.array(z.string()), }); const PublishResultSchema = z.object({ modrinth: PlatformResultSchema, curseforge: PlatformResultSchema, }); // ── Inferred types ───────────────────────────────────── type PublishInput = z.infer; type ProgressEvent = z.infer; type PlatformResult = z.infer; type PublishResult = z.infer; // ── tRPC init ────────────────────────────────────────── const t = initTRPC.create(); // ── Shared EventEmitter for progress ─────────────────── const ee = new EventEmitter(); // ── Helpers ──────────────────────────────────────────── function resolveVersionName( template: string, templates: Record | undefined, values: Record, ): string { const tmpl = new Template(template, templates); return tmpl.format(values); } function buildJarPath( projectDir: string, format: string, version: string, mcVersion: string, ): string { const tmpl = new Template(format); const filename = tmpl.format({ version, mc_version: mcVersion }); return path.join(projectDir, "build", "libs", filename); } function readJarBlob(filePath: string): Blob { try { const buffer = readFileSync(filePath); return new Blob([buffer]); } catch (err) { const msg = err instanceof Error ? err.message : String(err); throw new Error(`Failed to read file "${filePath}": ${msg}`); } } function errorMessage(err: unknown): string { return err instanceof Error ? err.message : String(err); } // ── Platform publish logic ───────────────────────────── interface PlatformContext { input: PublishInput; config: Awaited>; } async function publishModrinthVersion( ctx: PlatformContext, mcVersion: string, ): Promise { const { input, config } = ctx; const versionNumber = resolveVersionName( config.modrinth.version, undefined, { version: input.version, mc_version: mcVersion }, ); const versionName = resolveVersionName( config.modrinth.version_name, undefined, { version: input.version, mc_version: mcVersion, mc_version_range: mcVersion, }, ); const primaryPath = buildJarPath( config.project_dir, config.filename_format, input.version, mcVersion, ); const sourcePath = buildJarPath( config.project_dir, config.source_filename_format, input.version, mcVersion, ); const primaryBlob = readJarBlob(primaryPath); const sourceBlob = readJarBlob(sourcePath); const data: CreatableVersion = { project_id: config.modrinth.project_id, name: versionName, version_number: versionNumber, game_versions: [mcVersion], version_type: input.versionType, loaders: config.modrinth.loaders, featured: false, dependencies: config.modrinth.dependencies.map((d) => ({ version_id: null, project_id: d.project_id, file_name: null, dependency_type: d.dependency_type, })), file_parts: ["primary", "sources"], primary_file: "primary", file_types: { sources: "sources-jar" }, environment: config.modrinth.environment, }; await createVersion(modrinthClient, data, { primary: primaryBlob, sources: sourceBlob, }); } async function publishCurseForgeVersion( ctx: PlatformContext, mcVersion: string, ): Promise { const { input, config } = ctx; const displayName = resolveVersionName( config.curseforge.version_name, { filename_format: config.filename_format }, { version: input.version, mc_version: mcVersion }, ); const primaryPath = buildJarPath( config.project_dir, config.filename_format, input.version, mcVersion, ); const primaryBlob = readJarBlob(primaryPath); const metadata: UploadMetadata = { changelog: input.changelog, changelogType: "markdown", displayName, releaseType: input.versionType, relations: config.curseforge.relations, }; await uploadFile( curseforgeClient, config.curseforge.project_id, metadata, primaryBlob, ); } async function runPlatform( platform: "modrinth" | "curseforge", mcVersions: { mc_version: string }[], ctx: PlatformContext, ): Promise { 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 = platform === "modrinth" ? publishModrinthVersion : publishCurseForgeVersion; for (let i = 0; i < mcVersions.length; i++) { const { mc_version } = mcVersions[i]; try { await publishFn(ctx, mc_version); 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 = `${mc_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 => { const config = await getConfig(input.configName); const ctx: PlatformContext = { input, config }; const [modrinthSettled, curseforgeSettled] = await Promise.allSettled([ runPlatform("modrinth", input.mcVersions.modrinth, ctx), runPlatform("curseforge", input.mcVersions.curseforge, ctx), ]); const modrinth: PlatformResult = modrinthSettled.status === "fulfilled" ? modrinthSettled.value : { success: 0, fail: 0, errors: [errorMessage(modrinthSettled.reason)] }; const curseforge: PlatformResult = curseforgeSettled.status === "fulfilled" ? curseforgeSettled.value : { success: 0, fail: 0, errors: [errorMessage(curseforgeSettled.reason)] }; return { modrinth, curseforge }; }), progress: t.procedure.subscription(async function* (opts) { for await (const [data] of on(ee, "progress", { signal: opts.signal, })) { yield data as ProgressEvent; } }), }); export type AppRouter = typeof appRouter;