nginx gets a configuration instead of the stock one
`nginx:alpine` ships a config that serves the directory and does nothing else: no compression, and no cache headers at all. Every asset on the live site carried a TTL of zero, so a returning visitor re-downloaded all of it — 1.1 MB by Lighthouse's count. Written out on every start rather than kept as a file somebody edits, so what nginx does is decided in this file and cannot drift from it. 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, or a re-crop stays invisible for a year to everyone who saw the old one. Bundled assets change only when a build runs, and a build changes the HTML that names them: a week. HTML revalidates every time, because anything longer makes a publish invisible to whoever already visited, which is the one failure that makes the whole panel look broken. `^~` on the prefix locations. A regex location beats a prefix location in nginx, so the extension rule would otherwise capture /derived/…/1280.webp and give it the wrong lifetime. Validated with `nginx -t` in the real image rather than by reading it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+73
-1
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user