feat: give the frontend a deployment artifact, and show its logo
The repository had no container image and no production-shaped serving configuration. `dist/server.mjs` is a preview server that applies neither the security headers nor the cache policy `config/hosting/` declares, so a deployment had nothing correct to run. `scripts/generate-nginx-config.ts` derives the server block from `dist/tech-log-serving-contract.json` plus the two hosting policy files, so the served headers and cache lifetimes cannot drift from what the contract declares. It emits no TLS and no proxy blocks: the edge terminates TLS and routes /api, and baking a backend address into the image would tie the bundle to one deployment. Static surfaces use `alias` because a base-path build serves /dev/assets/... out of dist/assets/..., which `root` plus URI would look for one directory too deep. The image copies that config next to the bundle and normalises permissions: the build writes config.json 0600, which nginx cannot read, so the container came up healthy and answered 403 for the one file the SPA needs to boot. index.html never referenced public/favicon.svg. The file shipped and nginx served it, but browsers asked for /favicon.ico, got a 404, and fell back to the default icon. `%BASE_URL%` rather than an absolute path so a prefixed deployment points at its own copy. development.json moves to the HTTP Studio source; the mock source has no backend to authenticate against, which is the whole point of that profile.
This commit is contained in:
@@ -0,0 +1,228 @@
|
||||
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<{
|
||||
publicSpaPaths: 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();
|
||||
|
||||
const publicLocations = contract.publicSpaPaths
|
||||
.map(
|
||||
(pathname) => ` location = ${basePath}${exactLocation(pathname)} {
|
||||
${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.publicSpaPaths.length} public routes, ` +
|
||||
`${contract.studioSpaPathPatterns.length} studio patterns)\n`,
|
||||
);
|
||||
}
|
||||
|
||||
await main();
|
||||
Reference in New Issue
Block a user