import { readFile, writeFile } from "node:fs/promises"; import path from "node:path"; /** * Emits the nginx configuration the deployed frontend is served with. * * Generated rather than hand-written because three files already decide what it * must say, and a copy of them would drift: `dist/tech-log-serving-contract.json` * (which paths are SPA routes and what a miss answers with), * `config/hosting/security-headers.json`, and `config/hosting/cache-policy.json`. * The repository had no frontend deployment artifact at all — no Dockerfile, no * server config — so those two hosting files described a contract nothing * fulfilled: `dist/server.mjs` applies neither, answering `no-cache` for hashed * assets and sending no security headers. * * This serves static files only. TLS and the BFF paths belong to the edge: the * deployment's own nginx terminates HTTPS and sends `/api`, the OIDC redirect * chain and the identity provider to the backend directly (in Kubernetes, * Traefik does). A second proxy hop here would only add a place for the two * routing tables to disagree. * * The base path comes from `VITE_ROUTER_BASE_PATH`, the same value the bundle is * built with: served under a prefix, every route and asset lives under it too. */ const DIST = "dist"; const OUT = path.join(DIST, "nginx.conf"); type ServingContract = Readonly<{ publicSpaPathPatterns: readonly string[]; studioPathPrefix: string; studioSpaPathPatterns: readonly string[]; notFound: Readonly<{ status: number; contentType: string; body: string }>; }>; type HostingHeaders = Readonly<{ headers: Readonly> }>; type CachePolicy = Readonly<{ surfaces: Readonly< Record< string, Readonly<{ path?: string; pathPattern?: string; cacheControl?: string; securityHeaders?: boolean; }> > >; }>; async function readJson(file: string): Promise { return JSON.parse(await readFile(file, "utf8")) as T; } /** nginx location matching is not regex-escaped for us; only `=` exact paths are literal. */ function exactLocation(pathname: string): string { return pathname; } /** * A JS regex from the contract translated for nginx. Both use PCRE-ish syntax * for what the contract uses (`^`, `$`, `[^/]+`, alternation), so the pattern * carries over unchanged — asserted rather than assumed, because a pattern that * silently failed to translate would open a Studio route to the 404 branch. */ function studioRegex(pattern: string): string { // nginx uses PCRE, so anchors, character classes, alternation and plain groups // carry over as written. Lookaround and backreferences do not translate the // same way and would silently change which paths match, so they are refused. if (/\(\?[=!<]|\\[1-9]/.test(pattern)) { throw new Error( `studio SPA pattern uses a construct this generator does not translate: ${pattern}`, ); } return pattern; } function headerDirectives( headers: Readonly>, indent: string, ): string { return Object.entries(headers) .map(([name, value]) => `${indent}add_header ${name} "${value}" always;`) .join("\n"); } async function main(): Promise { const contract = await readJson( path.join(DIST, "tech-log-serving-contract.json"), ); const security = await readJson( "config/hosting/security-headers.json", ); const cache = await readJson("config/hosting/cache-policy.json"); const surfaces = cache.surfaces; const indexCache = surfaces["index"]?.cacheControl ?? "no-cache"; const configCache = surfaces["runtimeConfig"]?.cacheControl ?? "no-store"; const manifestCache = surfaces["releaseManifest"]?.cacheControl ?? "no-store"; const assetCache = surfaces["hashedAsset"]?.cacheControl ?? "no-cache"; const secure = headerDirectives(security.headers, " "); // The bundle's own base path. `/` for a deployment at the domain root, `/dev/` // for one served under a prefix — the routes below have to carry it or nginx // matches paths the browser never asks for. const rawBase = process.env["VITE_ROUTER_BASE_PATH"] ?? "/"; const basePath = rawBase.endsWith("/") ? rawBase.slice(0, -1) : rawBase; const [notFoundType, notFoundCharsetParam] = contract.notFound.contentType .split(";") .map((part) => part.trim()); const notFoundCharset = (notFoundCharsetParam ?? "charset=utf-8") .replace(/^charset=/i, "") .toLowerCase(); // Regex locations now, matching the Studio half: the contract declares which // paths the router serves, not which ones the fixture happened to contain, so // a record published after this build is served instead of 404ed at the edge. const publicLocations = contract.publicSpaPathPatterns .map( (pattern: string) => ` location ~ ^${basePath}${studioRegex(pattern).slice(1)} { ${secure} add_header Cache-Control "${indexCache}" always; try_files /index.html =404; }`, ) .join("\n\n"); const studioLocations = contract.studioSpaPathPatterns .map( (pattern) => ` location ~ ^${basePath}${studioRegex(pattern).slice(1)} { ${secure} add_header Cache-Control "${indexCache}" always; try_files /index.html =404; }`, ) .join("\n\n"); const conf = `# Generated by scripts/generate-nginx-config.ts — do not edit. # Sources: dist/tech-log-serving-contract.json, config/hosting/security-headers.json, # config/hosting/cache-policy.json # # Plain HTTP on purpose: the edge terminates TLS and this container is only ever # reached from inside the deployment network. server { listen 80; server_name _; root /usr/share/nginx/html; index index.html; # The bundle is small and already compressed at rest by the build; gzip here # covers the JSON surfaces and index.html. gzip on; gzip_types text/css application/javascript text/javascript application/json; gzip_min_length 1024; # Static surfaces, per config/hosting/cache-policy.json # --------------------------------------------------------------------------- location = ${basePath}/config.json { alias /usr/share/nginx/html/config.json; ${secure} add_header Cache-Control "${configCache}" always; } location = ${basePath}/release-manifest.json { alias /usr/share/nginx/html/release-manifest.json; ${secure} add_header Cache-Control "${manifestCache}" always; } # Content-hashed filenames, so the long TTL is safe and revalidation is waste. location ${basePath}/assets/ { # alias, not root + URI: under a base path the request is /dev/assets/x.js # while the file is dist/assets/x.js, so root would look for # dist/dev/assets/x.js and answer 404 for every script on the page. alias /usr/share/nginx/html/assets/; add_header Cache-Control "${assetCache}" always; } # Source maps are not published (cache-policy sourceMap.public = false). location ~ \\.map$ { return 404; } # --------------------------------------------------------------------------- # SPA routes. Enumerated from the serving contract rather than a catch-all: # a path that is not a real route answers 404 instead of a 200 shell, which is # what tells a crawler the difference. # --------------------------------------------------------------------------- ${publicLocations} ${studioLocations} location = ${basePath}/favicon.svg { alias /usr/share/nginx/html/favicon.svg; add_header Cache-Control "${assetCache}" always; } location = ${basePath}/media/ { return 404; } location ${basePath}/media/ { alias /usr/share/nginx/html/media/; add_header Cache-Control "${assetCache}" always; } # Anything else is not a route this deployment serves. location / { # The contract states the content type with its charset attached # (text/plain;charset=UTF-8), but nginx takes the two separately — # default_type rejects a parameter outright. default_type ${notFoundType}; charset ${notFoundCharset}; return ${contract.notFound.status} "${contract.notFound.body}"; } } `; await writeFile(OUT, conf, "utf8"); process.stdout.write( `nginx config: ${OUT} (${contract.publicSpaPathPatterns.length} public routes, ` + `${contract.studioSpaPathPatterns.length} studio patterns)\n`, ); } await main();