The comment I added used backticks around ?v= and no-cache, inside a template literal. That closed the string early and made the file unparseable — committed and deployed before the typecheck was read. Caught by tsgo immediately afterwards; officer stayed online because pm2 kept the previous module loaded, but the next restart would not have come back. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
302 lines
12 KiB
TypeScript
302 lines
12 KiB
TypeScript
import { stat, readdir, writeFile } from 'node:fs/promises';
|
|
import { join } from 'node:path';
|
|
import { db } from 'officerdb/db';
|
|
import { fofosAdminSettings } from '../db/schema';
|
|
import { readDatabases } from './databases';
|
|
|
|
/**
|
|
* Production: a directory of files behind nginx, not a running server.
|
|
*
|
|
* ── Why production is not a process ──
|
|
*
|
|
* The site's own build script says it plainly: *"The public site is not served by Bun in production —
|
|
* it is a directory of files behind nginx."* `bun run build:site` walks every route, renders it through
|
|
* the same Hono app that serves development, and writes `dist/`. So production has no port, nothing to
|
|
* restart, and nothing that can crash at three in the morning — it is a build that either succeeded or
|
|
* did not, and a web server with no application in it.
|
|
*
|
|
* Development stays a pm2 process, because that is what `bun --hot` is for.
|
|
*
|
|
* ── The container exists because nginx could not see the files ──
|
|
*
|
|
* Nginx Proxy Manager mounts only its own `/data` and `/etc/letsencrypt`. It cannot read `dist`, and
|
|
* there is no other nginx on the machine. The alternatives were putting site files inside NPM's own
|
|
* volume, or editing NPM's compose and restarting it — which takes every site on the box down for a
|
|
* moment.
|
|
*
|
|
* So: one `nginx:alpine`, `dist` mounted READ-ONLY, on the same docker network as NPM and with **no
|
|
* published port**. NPM reaches it by container name. Nothing about the site is exposed except through
|
|
* the proxy, which is the same property every other part of this plugin depends on.
|
|
*
|
|
* ── This plugin owns exactly one container name ──
|
|
*
|
|
* `fofos-web`. Every command here is built from that constant and from nothing a caller supplies, so no
|
|
* request can stop or remove another container on this machine — the same rule the pm2 side follows,
|
|
* and it matters for the same reason: Officer's own services share this daemon.
|
|
*/
|
|
|
|
const CONTAINER = 'fofos-web';
|
|
const IMAGE = 'nginx:alpine';
|
|
|
|
/** The network NPM is on, so it can reach the container by name without a published port. */
|
|
const NETWORK = 'fofos';
|
|
|
|
const DOCKER = '/usr/bin/docker';
|
|
|
|
export type StaticStatus = {
|
|
/** `<root>/website/dist`. */
|
|
path: string;
|
|
/** The build has produced something. */
|
|
built: boolean;
|
|
/** Pages written — top-level entries, which is close enough to be useful and cheap to count. */
|
|
entries: number | null;
|
|
/** When the build last wrote. */
|
|
builtAt: string | null;
|
|
container: {
|
|
name: string;
|
|
/** docker's own word: `running`, `exited`, or null when it does not exist. */
|
|
state: string | null;
|
|
/** What NPM should forward to. Only meaningful while it is running. */
|
|
forward: string;
|
|
};
|
|
};
|
|
|
|
const readRow = async () => (await db.select().from(fofosAdminSettings).limit(1))[0] ?? null;
|
|
|
|
export const distPath = (root: string): string => join(root, 'website', 'dist');
|
|
|
|
const run = async (
|
|
argv: string[],
|
|
timeoutMs = 60_000,
|
|
options: { env?: Record<string, string>; cwd?: string } = {},
|
|
) => {
|
|
/*
|
|
`cwd` matters and its absence was silent.
|
|
|
|
`bun run build:site` resolves the script from the package.json in the CURRENT directory. Without a
|
|
cwd it ran in the platform's own, which has no such script, and the error — "Script not found" —
|
|
reads like the site is missing one rather than like it was asked in the wrong place.
|
|
*/
|
|
const proc = Bun.spawn(argv, {
|
|
stdout: 'pipe',
|
|
stderr: 'pipe',
|
|
stdin: 'ignore',
|
|
...(options.cwd ? { cwd: options.cwd } : {}),
|
|
...(options.env ? { env: { ...process.env, ...options.env } } : {}),
|
|
});
|
|
const timer = setTimeout(() => proc.kill(), timeoutMs);
|
|
try {
|
|
const [stdout, stderr, code] = await Promise.all([
|
|
new Response(proc.stdout).text(),
|
|
new Response(proc.stderr).text(),
|
|
proc.exited,
|
|
]);
|
|
return { ok: code === 0, output: `${stdout}${stderr}`.trim() };
|
|
} finally {
|
|
clearTimeout(timer);
|
|
}
|
|
};
|
|
|
|
export async function readStatic(): Promise<StaticStatus> {
|
|
const row = await readRow();
|
|
const path = row?.rootPath ? distPath(row.rootPath) : '';
|
|
|
|
const info = path ? await stat(path).catch(() => null) : null;
|
|
const entries = info?.isDirectory() ? await readdir(path).catch(() => null) : null;
|
|
|
|
const inspected = await run([DOCKER, 'inspect', '-f', '{{.State.Status}}', CONTAINER], 20_000);
|
|
|
|
return {
|
|
path,
|
|
built: Boolean(info?.isDirectory() && entries?.length),
|
|
entries: entries?.length ?? null,
|
|
builtAt: info?.mtime.toISOString() ?? null,
|
|
container: {
|
|
name: CONTAINER,
|
|
// `docker inspect` fails when there is no such container, which is "not created" rather than an
|
|
// error — a state to report, not a fault to raise.
|
|
state: inspected.ok ? inspected.output.trim() : null,
|
|
forward: `${CONTAINER}:80`,
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Build the site.
|
|
*
|
|
* `FOFOS_ENV=production`, so the site's own guard reads the production database and refuses if the
|
|
* name disagrees — the same check the schema push goes through, and for the same reason. The build
|
|
* reads content, so pointing it at the wrong database would publish the wrong site rather than fail.
|
|
*
|
|
* It renders every route through the real app, so it needs everything the server needs: the database,
|
|
* the media directory, and the secrets. Assembled here for the same reason the dev server's are.
|
|
*/
|
|
export async function buildSite(secrets: {
|
|
apiToken: string;
|
|
sessionSecret: string;
|
|
}): Promise<{ ok: boolean; output: string }> {
|
|
const row = await readRow();
|
|
if (!row?.rootPath) throw new Error('No root location is set.');
|
|
|
|
const websiteDir = join(row.rootPath, 'website');
|
|
const databases = await readDatabases();
|
|
if (!databases.production.name || !databases.production.exists) {
|
|
throw new Error('The production database does not exist yet.');
|
|
}
|
|
|
|
const raw = process.env.OFFICERDEV_POSTGRES_URL ?? process.env.POSTGRES_URL;
|
|
if (!raw) throw new Error('The platform has no Postgres connection configured.');
|
|
const url = new URL(raw);
|
|
url.pathname = `/${databases.production.name}`;
|
|
|
|
const result = await run(['bun', 'run', 'build:site'], 15 * 60 * 1000, {
|
|
cwd: websiteDir,
|
|
env: {
|
|
NODE_ENV: 'production',
|
|
FOFOS_ENV: 'production',
|
|
POSTGRES_URL: url.toString(),
|
|
...(databases.production.name ? { FOFOS_DB_PRODUCTION: databases.production.name } : {}),
|
|
...(databases.development.name ? { FOFOS_DB_DEVELOPMENT: databases.development.name } : {}),
|
|
FOFOS_API_TOKEN: secrets.apiToken,
|
|
ADMIN_SESSION_SECRET: secrets.sessionSecret,
|
|
FOFOS_MEDIA_DIR: join(row.rootPath, 'media'),
|
|
// TEMPORARY — passed through so a bisect can rebuild one page instead of all 48. The site's
|
|
// build script leaves every other page alone when this is set. Unset, nothing changes.
|
|
...(process.env.FOFOS_BUILD_ONLY ? { FOFOS_BUILD_ONLY: process.env.FOFOS_BUILD_ONLY } : {}),
|
|
// Only used to build absolute URLs in the output — og:image, canonical links. The real domain
|
|
// belongs in the checkout's own env, where it is a deployment fact rather than one this invented.
|
|
PUBLIC_URL: process.env.FOFOS_PUBLIC_URL ?? 'http://127.0.0.1',
|
|
API_URL: process.env.FOFOS_PUBLIC_URL ?? 'http://127.0.0.1',
|
|
},
|
|
});
|
|
|
|
return { ok: result.ok, output: `$ bun run build:site (FOFOS_ENV=production)\n${result.output}` };
|
|
}
|
|
|
|
/**
|
|
* Create or replace the web server.
|
|
*
|
|
* Removed and recreated rather than restarted, for the same reason the pm2 side does it: a container
|
|
* keeps the mounts and the network it was created with, so a root location changed here would be saved,
|
|
* shown, and still serving the old directory.
|
|
*
|
|
* `--restart unless-stopped`, so it comes back with the machine. No published port: NPM reaches it on
|
|
* 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";
|
|
}
|
|
|
|
# The typefaces. A year, because a face is not edited — it is replaced, and a replacement is a new
|
|
# name. This is the one directory here where a long life carries no risk of pinning something stale.
|
|
location ^~ /fonts/ {
|
|
expires 1y;
|
|
add_header Cache-Control "public, immutable";
|
|
}
|
|
|
|
# Everything a build produces. A year, and immutable — earned, not assumed: every asset the layout
|
|
# links carries a ?v= stamp of the build id, so a build that changes a file changes its URL, and
|
|
# the HTML carrying the new URLs is no-cache and revalidated every visit. They cannot disagree.
|
|
#
|
|
# It was seven days while the URLs were bare, which was correct then: a stable name with a long life
|
|
# means somebody running last week's script against this week's markup.
|
|
location ~* \\.(css|js|webp|png|jpg|jpeg|gif|svg|ico|mp4)$ {
|
|
expires 1y;
|
|
add_header Cache-Control "public, immutable";
|
|
}
|
|
|
|
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.');
|
|
|
|
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);
|
|
|
|
const created = await run(
|
|
[
|
|
DOCKER, 'run', '-d',
|
|
'--name', CONTAINER,
|
|
'--restart', 'unless-stopped',
|
|
'--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,
|
|
);
|
|
|
|
return { ok: created.ok, output: `$ docker run ${CONTAINER} (${dist} -> /usr/share/nginx/html, network ${NETWORK})\n${created.output}` };
|
|
}
|
|
|
|
export async function stopStatic(): Promise<{ ok: boolean; output: string }> {
|
|
const result = await run([DOCKER, 'rm', '-f', CONTAINER], 60_000);
|
|
return { ok: result.ok, output: `$ docker rm -f ${CONTAINER}\n${result.output}` };
|
|
}
|