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 = { /** `/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; 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 { 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}` }; }