diff --git a/server.mjs b/server.mjs new file mode 100644 index 0000000..ec3560f --- /dev/null +++ b/server.mjs @@ -0,0 +1,180 @@ +/** + * Mozimo production server. + * + * Serves the static build output from dist/client (immutable caching for + * hashed assets, gzip for text) and hands every other request to the + * TanStack Start SSR handler exported by dist/server/server.js. + * + * Env: PORT (default 3000), HOST (default 0.0.0.0) + */ +import { createServer } from "node:http"; +import { createReadStream, existsSync, statSync } from "node:fs"; +import { createGzip } from "node:zlib"; +import { extname, join, resolve, sep } from "node:path"; +import { Readable } from "node:stream"; +import handler from "./dist/server/server.js"; + +const HOST = process.env.HOST ?? "0.0.0.0"; +const PORT = Number(process.env.PORT ?? 3000); +const CLIENT_ROOT = resolve("dist/client"); + +const MIME = { + ".html": "text/html; charset=utf-8", + ".js": "text/javascript; charset=utf-8", + ".mjs": "text/javascript; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".txt": "text/plain; charset=utf-8", + ".xml": "application/xml; charset=utf-8", + ".map": "application/json; charset=utf-8", + ".svg": "image/svg+xml", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".png": "image/png", + ".webp": "image/webp", + ".avif": "image/avif", + ".gif": "image/gif", + ".ico": "image/x-icon", + ".woff": "font/woff", + ".woff2": "font/woff2", + ".ttf": "font/ttf", + ".otf": "font/otf", + ".mp4": "video/mp4", + ".webm": "video/webm", + ".pdf": "application/pdf", + ".webmanifest": "application/manifest+json", +}; + +const COMPRESSIBLE = new Set([ + ".html", ".js", ".mjs", ".css", ".json", ".txt", ".xml", ".map", ".svg", + ".webmanifest", +]); + +function isHashedAsset(pathname) { + // Vite emits content-hashed filenames under /assets/ + return pathname.startsWith("/assets/") && /\-[A-Za-z0-9_-]{8}\.[^.]+$/.test(pathname); +} + +function sendError(res, status, message) { + if (!res.headersSent) { + res.writeHead(status, { "content-type": "text/plain; charset=utf-8" }); + } + res.end(message); +} + +/** Serve a static file from dist/client, or return null to fall through to SSR. */ +function tryStatic(req, res, pathname) { + if (req.method !== "GET" && req.method !== "HEAD") return false; + + const rel = pathname.slice(1); + const filePath = resolve(CLIENT_ROOT, rel); + if (filePath !== CLIENT_ROOT && !filePath.startsWith(CLIENT_ROOT + sep)) return false; + if (!existsSync(filePath) || !statSync(filePath).isFile()) return false; + + const ext = extname(filePath).toLowerCase(); + const type = MIME[ext] ?? "application/octet-stream"; + const headers = { + "content-type": type, + "x-content-type-options": "nosniff", + "cache-control": isHashedAsset(pathname) + ? "public, max-age=31536000, immutable" + : "public, max-age=86400", + }; + + const acceptsGzip = + COMPRESSIBLE.has(ext) && + statSync(filePath).size > 1024 && + /\bgzip\b/.test(req.headers["accept-encoding"] ?? ""); + + if (acceptsGzip) { + headers["content-encoding"] = "gzip"; + headers.vary = "accept-encoding"; + } + + res.writeHead(200, headers); + if (req.method === "HEAD") { + res.end(); + return true; + } + const stream = createReadStream(filePath); + stream.on("error", () => sendError(res, 500, "Failed to read asset")); + stream.on("open", () => { + if (acceptsGzip) { + stream.pipe(createGzip()).pipe(res); + } else { + stream.pipe(res); + } + }); + return true; +} + +async function toWebRequest(req, url) { + const headers = new Headers(); + for (const [key, value] of Object.entries(req.headers)) { + if (value === undefined) continue; + for (const v of Array.isArray(value) ? value : [value]) headers.append(key, v); + } + const hasBody = req.method !== "GET" && req.method !== "HEAD"; + return new Request(url, { + method: req.method, + headers, + body: hasBody ? Readable.toWeb(req) : undefined, + ...(hasBody ? { duplex: "half" } : {}), + }); +} + +async function sendFromHandler(req, res) { + const url = `http://${req.headers.host ?? `${HOST}:${PORT}`}${req.url}`; + const request = await toWebRequest(req, url); + const response = await handler.fetch(request); + + const responseHeaders = {}; + for (const [key, value] of response.headers) responseHeaders[key] = value; + if (typeof response.headers.getSetCookie === "function") { + const cookies = response.headers.getSetCookie(); + if (cookies.length) responseHeaders["set-cookie"] = cookies; + } + + res.writeHead(response.status, responseHeaders); + if (req.method === "HEAD" || !response.body) { + res.end(); + return; + } + Readable.fromWeb(response.body).pipe(res); +} + +const server = createServer(async (req, res) => { + let pathname = "/"; + try { + pathname = decodeURIComponent(new URL(req.url ?? "/", "http://localhost").pathname); + } catch { + return sendError(res, 400, "Bad request"); + } + + try { + if (tryStatic(req, res, pathname)) return; + if (extname(pathname)) return sendError(res, 404, "Not found"); + await sendFromHandler(req, res); + } catch (error) { + console.error("[mozimo] request failed:", error); + sendError(res, 500, "Internal server error"); + } +}); + +// Graceful shutdown so `docker stop` is fast and clean. +let shuttingDown = false; +function shutdown(signal) { + if (shuttingDown) return; + shuttingDown = true; + console.log(`[mozimo] ${signal} received — closing server`); + server.close(() => process.exit(0)); + // Drop keep-alive sockets so close() isn't held open by idle clients. + server.closeAllConnections?.(); + setTimeout(() => process.exit(0), 3000).unref(); +} +process.on("SIGTERM", () => shutdown("SIGTERM")); +process.on("SIGINT", () => shutdown("SIGINT")); + +server.listen(PORT, HOST, () => { + console.log(`[mozimo] serving on http://${HOST}:${PORT}`); +});