diff --git a/api/static-site.ts b/api/static-site.ts index d89af2d..5873f46 100644 --- a/api/static-site.ts +++ b/api/static-site.ts @@ -1,4 +1,4 @@ -import { stat, readdir } from 'node:fs/promises'; +import { stat, readdir, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import { db } from 'officerdb/db'; import { fofosAdminSettings } from '../db/schema'; @@ -181,6 +181,73 @@ export async function buildSite(secrets: { * the shared network by name, and a port on the host would be the site available unauthenticated beside * the proxy that exists to gate it. */ +/** + * The nginx configuration the container runs. + * + * Written out on every start rather than kept as a file somebody edits, so what nginx does is decided + * here and cannot drift. The stock `nginx:alpine` config serves the directory and nothing else: no + * compression, and no cache headers at all, so every asset carried a TTL of zero and a returning + * visitor re-downloaded the whole site. + * + * ── Three lifetimes, because there are three kinds of file ── + * + * `derived/` renditions are rebuilt IN PLACE — av-club replaces the contents of `1280.webp` rather + * than writing a new name. So they cannot be `immutable`, however much they look like they could be: + * a re-crop would be invisible to everyone who had seen the old one, for as long as the header said. + * Thirty days is the compromise, and it is the reason this is spelled out rather than copied from the + * usual recipe. + * + * Bundled assets — the stylesheet, the scripts, the fonts — change only when a build runs, and a build + * is also the only thing that changes the HTML that references them. A week, revalidated after. + * + * HTML is `no-cache`: revalidate every time. Not `no-store` — the file is still cached, it is just + * never used without asking. Anything longer and a publish is invisible to whoever already visited, + * which is the one failure that makes the whole panel look broken. + * + * ── Why `^~` on the prefixes ── + * + * A regex location beats a prefix location in nginx, so the extension rule below would otherwise + * capture `/derived/…/1280.webp` and hand it the wrong lifetime. `^~` stops the regex from being + * considered at all. This is the kind of thing that is correct or silently wrong with no middle. + */ +const NGINX_CONF = `server { + listen 80; + listen [::]:80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + # Text compresses; the images and video here are already compressed formats and would only burn CPU. + gzip on; + gzip_vary on; + gzip_min_length 1024; + gzip_types text/css text/plain application/javascript application/json image/svg+xml; + + # Rebuilt in place by av-club, so a long life would pin a stale crop. See the note above. + location ^~ /derived/ { + expires 30d; + add_header Cache-Control "public"; + } + + location ^~ /media/ { + expires 30d; + add_header Cache-Control "public"; + } + + location ~* \\.(css|js|woff2|webp|png|jpg|jpeg|gif|svg|ico|mp4)$ { + expires 7d; + add_header Cache-Control "public"; + } + + location / { + # The build writes about/index.html, so a directory match is what serves /about. + try_files $uri $uri/ =404; + add_header Cache-Control "no-cache"; + } +} +`; + export async function startStatic(): Promise<{ ok: boolean; output: string }> { const row = await readRow(); if (!row?.rootPath) throw new Error('No root location is set.'); @@ -188,6 +255,10 @@ export async function startStatic(): Promise<{ ok: boolean; output: string }> { const dist = distPath(row.rootPath); if (!(await stat(dist).catch(() => null))) throw new Error('There is no dist directory yet — build first.'); + // Beside the site rather than inside dist/, which the build empties and replaces. + const conf = join(row.rootPath, 'nginx.conf'); + await writeFile(conf, NGINX_CONF); + // Expected to fail the first time; the usual case is that it was never there. await run([DOCKER, 'rm', '-f', CONTAINER], 30_000); @@ -199,6 +270,7 @@ export async function startStatic(): Promise<{ ok: boolean; output: string }> { '--network', NETWORK, // Read-only: nginx serves this and has no business writing to somebody's build output. '-v', `${dist}:/usr/share/nginx/html:ro`, + '-v', `${conf}:/etc/nginx/conf.d/default.conf:ro`, IMAGE, ], 120_000,