Files
fofos-admin/api/static-site.ts
T
pastilhasandClaude Opus 5 27f93d6e14 production is a static build behind nginx, not a process
The site's own build script says it outright: "The public site is not served by
Bun in production — it is a directory of files behind nginx. Output: dist/."
So production has no port, nothing to restart, and nothing that can crash at
three in the morning. It is built or stale, and served or not served.

Showing it as a pm2 process was showing something that does not exist, and its
Start button could only ever have failed. It now has Build, Serve and Stop, and
the two states are reported separately — a build can succeed while nothing
serves it, and a container can serve a directory built weeks ago. They fail
independently and are fixed differently.

The container exists because nginx could not see the files. NPM mounts only its
own /data and /etc/letsencrypt, and there is no other nginx on the box. The
alternatives were putting site files inside NPM's own volume, or editing NPM's
compose and restarting it — which takes every site on this machine down for a
moment. So: one nginx:alpine, dist mounted READ-ONLY, on NPM's own network with
NO published port. NPM reaches it by name, and nothing about the site is exposed
except through the proxy.

Recreated rather than restarted, for the same reason pm2 is: a container keeps
the mounts it was created with, so a root location changed here would be saved,
displayed, and still serving the old directory.

Domains follows. Production's forward target is a container NAME, not the docker
gateway that reaches a host process — and matching what already points at it
uses the full target rather than the port, since every other site behind this
NPM on port 80 would otherwise be listed as fofos's.

The compiler found the dead branches this left in startServer once production
could no longer reach it. Removed rather than left to read as though the case
were still possible.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 19:36:29 +01:00

203 lines
8.2 KiB
TypeScript

import { stat, readdir } 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, env?: Record<string, string>) => {
const proc = Bun.spawn(argv, {
stdout: 'pipe',
stderr: 'pipe',
stdin: 'ignore',
...(env ? { env: { ...process.env, ...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,
{
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'),
// 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.
*/
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.');
// 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`,
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}` };
}