the built SPA had no tailwind, and the build could destroy itself

three fixes to last night's build switch, all found by using it.

THE CSS. bunfig.toml declares the tailwind plugin under [serve.static], which
applies to bun's static SERVING — the html-import path the app used until
yesterday — and not to a programmatic Bun.build(). so the first build emitted
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. css
goes 110KB to 278KB, 1127 --tw- variables, .flex present, --color-duck present.

THE BUILD DIRECTORY. clearing it before building was meant to stop 20MB of
content-hashed chunks accumulating per install, and instead meant a FAILED build
left nothing — the exact opposite of the promise in the comment directly above
it. it was also a race: two builds overlapping had one process's rm delete the
other's shell, leaving js and css with no html and a 503 that read as a build
failure when the build had succeeded.

now it builds into build.next/ and swaps only a complete, successful build into
place, and refuses to swap one that produced no shell at all — a build can
report success and emit no html, and serving that is worse than serving the
previous one.

AND IT SAYS WHICH PATH IS SERVING. "is it serving the build I just made, or the
one bundled at import?" was answerable only by hiding the shell and watching for
a 503, which is how it got answered once. the distinction matters precisely
where it is hardest to see: the html import is fixed when the module graph
loads, so an install would rebuild build/ and serve something else entirely.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-14 23:26:28 +00:00
co-authored by Claude Opus 5
parent 7b4137ccca
commit 2e3c935da6
3 changed files with 46 additions and 2 deletions
+1
View File
@@ -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
+12
View File
@@ -225,6 +225,18 @@ const serveBuilt = async (req: Request): Promise<Response> => {
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,
+33 -2
View File
@@ -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<BuildResult> {
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<BuildResult> {
.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 {