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 { join } from 'node:path';
|
||||||
import { db } from 'officerdb/db';
|
import { db } from 'officerdb/db';
|
||||||
import { fofosAdminSettings } from '../db/schema';
|
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 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 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 }> {
|
export async function startStatic(): Promise<{ ok: boolean; output: string }> {
|
||||||
const row = await readRow();
|
const row = await readRow();
|
||||||
if (!row?.rootPath) throw new Error('No root location is set.');
|
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);
|
const dist = distPath(row.rootPath);
|
||||||
if (!(await stat(dist).catch(() => null))) throw new Error('There is no dist directory yet — build first.');
|
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.
|
// Expected to fail the first time; the usual case is that it was never there.
|
||||||
await run([DOCKER, 'rm', '-f', CONTAINER], 30_000);
|
await run([DOCKER, 'rm', '-f', CONTAINER], 30_000);
|
||||||
|
|
||||||
@@ -199,6 +270,7 @@ export async function startStatic(): Promise<{ ok: boolean; output: string }> {
|
|||||||
'--network', NETWORK,
|
'--network', NETWORK,
|
||||||
// Read-only: nginx serves this and has no business writing to somebody's build output.
|
// Read-only: nginx serves this and has no business writing to somebody's build output.
|
||||||
'-v', `${dist}:/usr/share/nginx/html:ro`,
|
'-v', `${dist}:/usr/share/nginx/html:ro`,
|
||||||
|
'-v', `${conf}:/etc/nginx/conf.d/default.conf:ro`,
|
||||||
IMAGE,
|
IMAGE,
|
||||||
],
|
],
|
||||||
120_000,
|
120_000,
|
||||||
|
|||||||
Reference in New Issue
Block a user