Files
fofos-admin/api/static-site.ts
T
pastilhas 89adbe37ce the build needs a working directory, and its absence was silent
`bun run build:site` resolves the script from the package.json in the current
directory. The helper had no cwd parameter, so it ran in the platform's own
directory, which has no such script — and the error, "Script not found
build:site", reads as the site missing a script rather than as it being asked in
the wrong place.

Second time today a spawn helper quietly lacked something the caller needed: the
first was the environment for pm2. Both had the same shape — assembled correctly
and then not passed.

Verified by running the real build with the same environment the plugin builds:
29 entries in dist and an index.html with the site in it.
2026-08-29 19:42:09 +01:00

214 lines
8.6 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,
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'),
// 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}` };
}