// The client sites' server: static files, an SPA fallback, and env-config.js from the // environment. Node built-ins only — the runtime base already carries node for the API, so a // second web server (and the shell its startup script needed) would be two more moving parts to // serve files the first one serves fine. // // What the nginx arrangement this replaces did, it does: every unknown path without an extension // falls back to index.html (client-side routing), nothing is cached (the site is tiny and a stale // index.html after a deploy is the only caching bug this app has ever had), and env-config.js is // regenerated from the container's environment at start — which is how one built image serves // under any API URL. import { createServer } from "node:http"; import { readFileSync, writeFileSync, createReadStream, statSync } from "node:fs"; import { join, resolve, extname } from "node:path"; const root = resolve(process.env.HTML_ROOT ?? "/site"); const port = Number(process.env.PORT ?? 80); // .env names the variables the site reads; the environment overrides their values. Written once // at start, exactly as the env.sh this replaces did. const pairs = []; for (const line of readFileSync(join(root, ".env"), "utf8").split("\n")) { const at = line.indexOf("="); if (at < 1) continue; const name = line.slice(0, at).trim(); pairs.push(` ${name}: ${JSON.stringify(process.env[name] ?? line.slice(at + 1).trim())},`); } writeFileSync(join(root, "env-config.js"), `window._env_ = {\n${pairs.join("\n")}\n}\n`); const types = { ".html": "text/html; charset=utf-8", ".js": "application/javascript", ".mjs": "application/javascript", ".css": "text/css", ".json": "application/json", ".map": "application/json", ".svg": "image/svg+xml", ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp", ".ico": "image/x-icon", ".txt": "text/plain; charset=utf-8", ".woff": "font/woff", ".woff2": "font/woff2", ".ttf": "font/ttf", }; createServer((request, response) => { const asked = decodeURIComponent(new URL(request.url, "http://x").pathname); // resolve() collapses any ".." before the prefix check, so a path cannot escape the root. let path = resolve(join(root, asked)); if (!path.startsWith(root)) { response.writeHead(403).end(); return; } let served; try { served = statSync(path); if (served.isDirectory()) { path = join(path, "index.html"); served = statSync(path); } } catch { // Not a file: a client-side route, answered by the app itself — unless it asked for a file // by extension, where index.html would be a wrong answer dressed as a right one. if (extname(asked) !== "") { response.writeHead(404, { "Content-Type": "text/plain" }).end("not found\n"); return; } path = join(root, "index.html"); served = statSync(path); } response.writeHead(200, { "Content-Type": types[extname(path)] ?? "application/octet-stream", "Content-Length": served.size, "Cache-Control": "no-cache", }); createReadStream(path).pipe(response); }).listen(port, () => console.log(`serving ${root} on :${port}`));