// Custom production server for the React Router build. // // Replaces `react-router-serve` so we can: // - prefix every console line with an ISO timestamp, // - use a richer morgan format (with timestamp + content-length), // - skip access logs for successful /healthz probes (they would otherwise // drown out everything useful — Docker/Caddy poll them every couple of // seconds). // // Behaviour is otherwise intentionally identical to `@react-router/serve`'s // CLI (compression, /assets immutable cache, public/ static, SIGTERM/SIGINT // handling). import { createRequestHandler } from "@react-router/express"; import compression from "compression"; import express from "express"; import morgan from "morgan"; import rateLimit from "express-rate-limit"; // --------------------------------------------------------------------------- // Console timestamps — patch BEFORE anything else logs. // --------------------------------------------------------------------------- const ts = () => `[${new Date().toISOString()}]`; for (const level of ["log", "info", "warn", "error", "debug"]) { const original = console[level].bind(console); console[level] = (...args) => original(ts(), ...args); } const PORT = Number(process.env.PORT || 3000); const HOST = process.env.HOST; const buildModule = await import("./build/server/index.js"); const build = buildModule.default ?? buildModule; const app = express(); app.disable("x-powered-by"); // The app runs behind a single reverse proxy (Caddy) that sets // X-Forwarded-For. Trust ONLY the first proxy hop so req.ip reflects the real // client IP for rate limiting, without trusting arbitrary client-supplied // forwarding headers (which `trust proxy: true` would). app.set("trust proxy", 1); app.use(compression()); // Static assets emitted by the React Router build. app.use( "/assets", express.static("build/client/assets", { immutable: true, maxAge: "1y" }), ); app.use(express.static("build/client", { maxAge: "1h" })); app.use(express.static("public", { maxAge: "1h" })); // Access log: ISO timestamp + standard request info; suppress healthy // /healthz polls so real traffic stays visible. morgan.token("isotime", () => new Date().toISOString()); // Redacted URL: strip sensitive query parameters (the GiroCode HMAC `sig` // and any `token`) from the logged URL so signed-URL secrets / bearer tokens // never land in access logs. Other query params are preserved. morgan.token("safeurl", (req) => { const raw = req.originalUrl || req.url || ""; const qIdx = raw.indexOf("?"); if (qIdx === -1) return raw; const path = raw.slice(0, qIdx); const params = new URLSearchParams(raw.slice(qIdx + 1)); for (const key of ["sig", "token"]) { if (params.has(key)) params.set(key, "REDACTED"); } const qs = params.toString(); return qs ? `${path}?${qs}` : path; }); app.use( morgan( ":isotime :method :safeurl :status :res[content-length] - :response-time ms", { skip: (req, res) => req.url === "/healthz" && res.statusCode < 400, }, ), ); // Per-IP rate limiting for the public, unauthenticated-at-the-edge API // surface only (/api/public/*). Shopify webhooks (/webhooks/*) and the // embedded admin are intentionally NOT rate limited here — webhooks can burst // legitimately and are already HMAC-verified. const publicApiLimiter = rateLimit({ windowMs: 60 * 1000, // 1 minute limit: 60, // 60 requests / minute / IP standardHeaders: true, // RateLimit-* headers legacyHeaders: false, message: { error: "rate-limited" }, }); app.use("/api/public", publicApiLimiter); app.all( "*", createRequestHandler({ build, mode: process.env.NODE_ENV }), ); const onListen = () => { console.log( `[server] listening on http://${HOST ?? "localhost"}:${PORT}`, ); }; const server = HOST ? app.listen(PORT, HOST, onListen) : app.listen(PORT, onListen); for (const signal of ["SIGTERM", "SIGINT"]) { process.once(signal, async () => { console.log(`[server] received ${signal}, shutting down`); // Stop accepting new connections. server.close((err) => { if (err) console.error("[server] close error:", err); }); // Drain in-flight background webhook work (PDF render / SMTP send) before // exiting so a container stop doesn't lose invoice work mid-send. The // background queue exposes this bridge because server.js loads only the // bundled build and can't import the module directly. try { const drain = globalThis.__linumiqWebhookDrain; if (typeof drain === "function") await drain(); } catch (err) { console.error("[server] webhook drain error:", err); } process.exit(0); }); }