From 2e3c935da6c023d6471f03b1e28d74e8c41eb002 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Fri, 14 Aug 2026 23:26:28 +0000 Subject: [PATCH] the built SPA had no tailwind, and the build could destroy itself MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .gitignore | 1 + src/server.tsx | 12 +++++++++++ src/servers/plugins/generate.ts | 35 +++++++++++++++++++++++++++++++-- 3 files changed, 46 insertions(+), 2 deletions(-) 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 {