// apps/api/src/server.ts // Fastify server entry. Hosts: // - /api/health // - /api/analysis/* (REST endpoints in routes/analysis.ts) // - / (static legacy SPA from apps/web-legacy/, with SPA fallback) import Fastify, { type FastifyInstance } from "fastify"; import fastifyStatic from "@fastify/static"; import fastifyMultipart from "@fastify/multipart"; import { fileURLToPath } from "node:url"; import { dirname, resolve } from "node:path"; import { existsSync, mkdirSync } from "node:fs"; import { loadConfig } from "./config.js"; import { createLogger } from "./log.js"; import { createStore } from "./queue/store-factory.js"; import { registerHealthRoutes } from "./routes/health.js"; import { registerAnalysisRoutes } from "./routes/analysis.js"; import { registerPolishRoutes } from "./routes/polish.js"; import { QueueRunner } from "./queue/runner.js"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); // Inside the container the layout is: // /app/apps/api/dist/server.js ← this file // /app/apps/web-legacy/dist/index.html // In dev (tsx) it's the source path with the same parent layout. function resolveWebLegacyRoot(): string { const webRoot = resolve(__dirname, "../../web-legacy/dist"); if (!existsSync(resolve(webRoot, "index.html"))) { throw new Error("前端静态产物不存在,请先运行 pnpm --filter @task-optimizer/web-legacy build 或 pnpm build"); } return webRoot; } async function buildServer(): Promise<{ app: FastifyInstance; runner: QueueRunner }> { const config = loadConfig(); const logger = createLogger(config.logLevel); // Ensure storage directories exist (volume-mounted in Docker) for (const dir of [config.uploadDir, config.exportDir]) { try { mkdirSync(dir, { recursive: true }); } catch (err) { logger.error({ err, dir }, "Failed to create storage directory"); throw err; } } const store = await createStore(config); const runner = new QueueRunner({ store, config, logger }); // Recover any jobs left in `running` state across restarts (mark queued items // as queued is a no-op; the runner picks them back up on demand). for (const job of await store.listResumableJobs()) { logger.info({ jobId: job.id }, "Recovering resumable job from previous run"); await store.recoverInterruptedJob(job.id); runner.scheduleJob(job.id); } const app = Fastify({ // Fastify 5 requires `loggerInstance` (not `logger`) when passing an // already-constructed pino instance. loggerInstance: logger, bodyLimit: config.maxUploadMB * 1024 * 1024, disableRequestLogging: false, trustProxy: true, }); await app.register(fastifyMultipart, { limits: { fileSize: config.maxUploadMB * 1024 * 1024, files: config.maxBatchFiles, }, }); // ── /api routes ───────────────────────────────────────────────────────── await registerHealthRoutes(app); await registerPolishRoutes(app, { config }); await registerAnalysisRoutes(app, { store, config, runner }); app.addHook("onClose", async () => { await store.close(); }); // ── static SPA ────────────────────────────────────────────────────────── const webRoot = resolveWebLegacyRoot(); await app.register(fastifyStatic, { root: webRoot, prefix: "/", decorateReply: false, }); // SPA fallback — anything that isn't /api/* and isn't a real file falls back // to index.html so the legacy frontend can handle its own routes. app.setNotFoundHandler((req, reply) => { if (req.raw.url && req.raw.url.startsWith("/api/")) { reply.code(404).send({ code: 404, msg: "Not Found" }); return; } reply.sendFile("index.html", webRoot); }); return { app, runner }; } async function main() { const { app, runner } = await buildServer(); const config = loadConfig(); const shutdown = async (signal: string) => { app.log.info({ signal }, "Shutdown signal received, closing server"); runner.requestShutdown(); try { await app.close(); } catch (err) { app.log.error({ err }, "Error closing Fastify"); } process.exit(0); }; process.once("SIGINT", () => void shutdown("SIGINT")); process.once("SIGTERM", () => void shutdown("SIGTERM")); try { await app.listen({ port: config.port, host: "0.0.0.0" }); app.log.info({ port: config.port }, "Server listening"); } catch (err) { app.log.error({ err }, "Server failed to start"); process.exit(1); } } main().catch((err) => { console.error("Fatal startup error:", err); process.exit(1); });