File size: 4,855 Bytes
c4ae742
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
// 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);
});