diff --git a/.gitignore b/.gitignore index caa030b9..60d6bb36 100644 --- a/.gitignore +++ b/.gitignore @@ -67,4 +67,5 @@ ecosystem.config.cjs # The built SPA and the generated plugin module — both describe THIS install's plugin set and are # rewritten on every install. See servers/plugins/generate.ts. build/ +build.next/ src/apps/officer-web/Plugins.gen.tsx diff --git a/src/server.tsx b/src/server.tsx index 2439ade3..25db114a 100644 --- a/src/server.tsx +++ b/src/server.tsx @@ -225,6 +225,18 @@ const serveBuilt = async (req: Request): Promise => { const isProduction = process.env.NODE_ENV === 'production'; const spaRoutes = isProduction ? { '/': serveBuilt, '/*': serveBuilt } : { '/': officerWeb, '/*': officerWeb }; +// Said out loud, because "is it serving the build I just made, or the one bundled at import?" is otherwise +// answerable only by hiding the shell and watching for a 503 — which is how it was answered once. +// +// It matters because the two behave differently in exactly the case that matters: the HTML import is +// bundled when the module graph loads and can never change, so installing a plugin would rebuild `build/` +// and serve something else entirely. +console.log( + isProduction + ? `[web] serving the built SPA from ${BUILD_DIR} — a plugin install rebuilds it` + : '[web] serving via the HTML import (dev) — HMR is live, and `build/` is not used', +); + async function upgradeWs( req: Request, server: any, diff --git a/src/servers/plugins/generate.ts b/src/servers/plugins/generate.ts index f0bb4dcb..5cea23b6 100644 --- a/src/servers/plugins/generate.ts +++ b/src/servers/plugins/generate.ts @@ -1,5 +1,6 @@ -import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'; import { dirname, join, relative } from 'node:path'; +import tailwind from 'bun-plugin-tailwind'; import { PLATFORM_DIR } from '../data-path'; import { mountPrefix } from './manifest'; import type { PluginState } from './mount'; @@ -23,6 +24,8 @@ import type { PluginState } from './mount'; const GENERATED_PATH = join(PLATFORM_DIR, 'src/apps/officer-web/Plugins.gen.tsx'); const BUILD_DIR = join(PLATFORM_DIR, 'build'); +/** Where a build lands before it is swapped into place. Never served. */ +const STAGING_DIR = join(PLATFORM_DIR, 'build.next'); const HTML_ENTRY = join(PLATFORM_DIR, 'src/apps/officer-web/index.gen.html'); /** @@ -115,9 +118,28 @@ export type BuildResult = { ok: boolean; ms: number; outputs: number; error?: st export async function rebuildFrontend(): Promise { const started = Date.now(); try { + // Built into a staging directory and swapped, never in place. + // + // Two reasons, one of which was learned the hard way. Chunks are content-hashed, so building over the + // old ones leaves them behind and `build/` grows ~20MB an install — but clearing FIRST means a failed + // build leaves nothing at all, which is the opposite of what this function promises. And clearing + // first is a race: two builds overlapping had one process's `rm` delete the other's HTML, leaving a + // JS and a CSS file with no shell and a 503 that looked like a build failure when the build had + // succeeded. + // + // Staging fixes both. `build/` is only ever replaced by a complete, successful build. + if (existsSync(STAGING_DIR)) rmSync(STAGING_DIR, { recursive: true, force: true }); + const result = await Bun.build({ entrypoints: [HTML_ENTRY], - outdir: BUILD_DIR, + outdir: STAGING_DIR, + // WITHOUT THIS THE APP RENDERS UNSTYLED. + // + // `bunfig.toml` declares `[serve.static] plugins = ["bun-plugin-tailwind"]`, and that applies to + // Bun's static SERVING — the HTML-import path — not to a programmatic `Bun.build()`. So the first + // build produced the xterm CSS and no Tailwind at all: layout intact, every utility class missing. + // A plugin list is not inherited from bunfig; it has to be passed here. + plugins: [tailwind], minify: process.env.NODE_ENV === 'production', sourcemap: 'none', }); @@ -132,6 +154,15 @@ export async function rebuildFrontend(): Promise { .slice(0, 500), }; } + // The shell must exist before anything is swapped in — a build reporting success without one is how + // the 503 above happened, and serving a directory with no HTML is worse than serving the old one. + if (!existsSync(join(STAGING_DIR, SHELL_FILE))) { + return { ok: false, ms: Date.now() - started, outputs: 0, error: `build produced no ${SHELL_FILE}` }; + } + + if (existsSync(BUILD_DIR)) rmSync(BUILD_DIR, { recursive: true, force: true }); + renameSync(STAGING_DIR, BUILD_DIR); + return { ok: true, ms: Date.now() - started, outputs: result.outputs.length }; } catch (err) { return {