`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.
214 lines
8.6 KiB
TypeScript
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}` };
|
|
}
|