import fastifyStatic from "@fastify/static"; import Fastify from "fastify"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { getConfig } from "./config.js"; import { closeMongoConnection, getMongoClient, hasMongoConfig } from "./db.js"; import { aboutRoutes, warmAboutContent } from "./about.js"; import { dailyMetricsRoutes } from "./dailyMetrics.js"; import { deathStatsRoutes } from "./deathStats.js"; import { visitorRoutes } from "./visitors.js"; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const distPath = path.join(root, "dist"); const isProduction = process.env.NODE_ENV === "production" || process.argv.includes("--production"); const appConfig = getConfig(); const port = appConfig.SERVER_PORT; const host = appConfig.SERVER_HOST; let viteDevServer; const app = Fastify({ bodyLimit: 16 * 1024, }); app.addContentTypeParser("*", { parseAs: "string" }, (request, body, done) => { done(null, body); }); app.get("/api/health", async () => { return { ok: true, dbConfigured: hasMongoConfig(), }; }); if (!isProduction) { const { createServer: createViteServer } = await import("vite"); viteDevServer = await createViteServer({ root, server: { middlewareMode: true, hmr: { server: app.server, }, }, appType: "spa", }); } await app.register(visitorRoutes, { prefix: "/api/visitors" }); await app.register(aboutRoutes, { prefix: "/api" }); await app.register(deathStatsRoutes, { prefix: "/api/death-stats" }); await app.register(dailyMetricsRoutes, { prefix: "/api/daily-metrics" }); if (isProduction) { await app.register(fastifyStatic, { root: distPath, prefix: "/", cacheControl: true, maxAge: 3600000 * 24 * 7, // 7일간 캐시 유지 immutable: true, lastModified: true, etag: true, }); app.setNotFoundHandler((request, reply) => { const acceptsHtml = String(request.headers.accept || "").includes("text/html"); if (request.method !== "GET" || request.url.startsWith("/api/") || !acceptsHtml) { reply.code(404).send({ error: "not_found" }); return; } reply.sendFile("index.html"); }); } else { app.setNotFoundHandler((request, reply) => { if (request.url.startsWith("/api/")) { reply.code(404).send({ error: "not_found" }); return; } reply.hijack(); viteDevServer.middlewares(request.raw, reply.raw, (error) => { if (error) { viteDevServer.ssrFixStacktrace(error); console.error(error); if (!reply.raw.headersSent) { reply.raw.statusCode = 500; reply.raw.end(error.stack); } return; } if (!reply.raw.headersSent && !reply.raw.writableEnded) { reply.raw.statusCode = 404; reply.raw.end("Not found"); } }); }); } app.setErrorHandler((error, request, reply) => { const isMissingMongoConfig = error.message.includes("MongoDB configuration"); const status = isMissingMongoConfig ? 503 : 500; console.error(error); reply.code(status).send({ error: isMissingMongoConfig ? "mongodb_not_configured" : "internal_server_error", message: isProduction ? "Arena tracking is unavailable." : error.message, }); }); await app.listen({ port, host }); console.log(`Arena Picker listening on http://localhost:${port}`); if (hasMongoConfig()) { getMongoClient() .then(async () => { console.log("MongoDB connection pool is ready."); try { await warmAboutContent(); console.log("About content cache is ready."); } catch (error) { console.error("About content cache warmup failed. API route will retry on request.", error); } }) .catch((error) => { console.error("MongoDB connection failed. API routes will retry on request.", error); }); } ["SIGINT", "SIGTERM"].forEach((signal) => { process.on(signal, () => { shutdown(signal); }); }); function shutdown(signal) { console.log(`${signal} received. Closing server.`); app.close() .then(closeMongoConnection) .finally(() => { process.exit(0); }); }