Files
tech-log-frontend/scripts/generate-nginx-config.ts
DongHyeonka 6784eb1ce6 fix: serve the public routes the router declares, not the slugs the build saw
The serving contract enumerated every public path the bundled fixture happened
to contain, and the generated nginx published exactly those as `location =`
blocks. A record published after the build — the entire point of having a
backend — answered 404 at the edge before the SPA was ever asked, and no amount
of correct routing inside the bundle could recover it. Twenty-seven frozen
paths, and any twenty-eighth was unreachable.

The route contract already declares which paths exist; the catalog only decides
which of them currently resolve, and that is the SPA's call rather than the web
server's. So the contract now emits one regex per registered Public route,
derived from the router, the way the Studio half has always worked.

A parameter matches one segment and never a slash, so /cases/a/b stays a 404
instead of quietly rendering a case page. The catch-all route is dropped rather
than translated: serving index.html for every unmatched URL would turn an edge
404 into a soft 200 and hide broken links from crawlers and from us.

Verified against a built image: /cases/a-brand-new-slug now answers 200 while
/nope and /cases/a/b still answer 404.

schemaVersion goes to 2 because the field changed shape, not just contents —
a consumer reading publicSpaPaths would otherwise see an absent key rather than
a version it can refuse.
2026-08-20 17:48:39 +09:00

232 lines
8.2 KiB
TypeScript

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<Record<string, string>> }>;
type CachePolicy = Readonly<{
surfaces: Readonly<
Record<
string,
Readonly<{
path?: string;
pathPattern?: string;
cacheControl?: string;
securityHeaders?: boolean;
}>
>
>;
}>;
async function readJson<T>(file: string): Promise<T> {
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<Record<string, string>>,
indent: string,
): string {
return Object.entries(headers)
.map(([name, value]) => `${indent}add_header ${name} "${value}" always;`)
.join("\n");
}
async function main(): Promise<void> {
const contract = await readJson<ServingContract>(
path.join(DIST, "tech-log-serving-contract.json"),
);
const security = await readJson<HostingHeaders>(
"config/hosting/security-headers.json",
);
const cache = await readJson<CachePolicy>("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();