From df92a985d2ad126958ab5d9b78c77788c85ae1cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Sun, 16 Aug 2026 23:36:49 +0000 Subject: [PATCH] build failures say what went wrong MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every failed frontend build has reported `Bundle failed` and nothing else, for as long as anyone remembers. The cause is one line that was never there: `Bun.build` THROWS on failure by default. It throws an `AggregateError` whose own message is the string "Bundle failed" and whose `errors` array holds the real diagnostics. So the carefully written `result.success` branch — with its comment about what a failed build must not do — was unreachable, and the catch dutifully reported `err.message`. `throw: false` makes the returned result carry `logs`, which is what the code below it always assumed. The catch keeps a fallback that unpacks `AggregateError.errors` anyway: the build should no longer throw, and the failure mode this hides is exactly the one that has been wasting people's evenings. Truncation went 500 -> 1000 characters. A single bundler diagnostic with a file, a line and a code frame routinely exceeds 500, and cutting mid-path leaves it unactionable. Proven by breaking the app on purpose: before: Bundle failed after: ResolveMessage: Could not resolve: "./this-module-does-not-exist" ── The first attempt at that proof was worthless ── An UNUSED import reported `ok: true`, which briefly looked like a much worse bug — a broken app building clean. It is not: an unused import is elided by the TypeScript transform before anything tries to resolve it, so there was no error to report. The break only becomes a break once the import is used. Worth recording because "I broke it and the build passed" is an alarming sentence to arrive at by accident. Also verified that a failed build leaves `build/` alone — the staging swap does what its comment claims, so a bad build cannot take the running app down with it. This one shows up in the UI too: the Rebuild button in Settings -> System already displays `error`, so it stops saying "Rebuild failed" and starts naming the file. Co-Authored-By: Claude Opus 5 --- src/servers/plugins/generate.ts | 32 +++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/src/servers/plugins/generate.ts b/src/servers/plugins/generate.ts index 41ab4492..f9f72174 100644 --- a/src/servers/plugins/generate.ts +++ b/src/servers/plugins/generate.ts @@ -108,6 +108,20 @@ export type BuildResult = { ok: boolean; ms: number; outputs: number; error?: st * the owner to restart. Failure is returned rather than thrown: a plugin whose frontend will not compile * must leave the previous build in place and serving, not take the UI down with it. */ +/** + * Everything a failed build knows, as one string. + * + * `AggregateError` — which is what a bundler failure is — keeps its diagnostics in `errors` and puts a + * generic summary in `message`. Reading `.message` gives you "Bundle failed", which is true and useless. + */ +function describeBuildFailure(err: unknown): string { + if (err && typeof err === 'object' && 'errors' in err && Array.isArray((err as AggregateError).errors)) { + const parts = (err as AggregateError).errors.map((e) => String(e)); + if (parts.length) return parts.join('; ').slice(0, 1000); + } + return err instanceof Error ? err.message : String(err); +} + export async function rebuildFrontend(): Promise { const started = Date.now(); try { @@ -126,6 +140,20 @@ export async function rebuildFrontend(): Promise { const result = await Bun.build({ entrypoints: [HTML_ENTRY], outdir: STAGING_DIR, + /** + * WITHOUT THIS, EVERY BUILD FAILURE READS `Bundle failed` AND NOTHING ELSE. + * + * `Bun.build` THROWS on failure by default — an `AggregateError` whose own message is the string + * "Bundle failed" and whose `errors` array holds the real diagnostics. So the `result.success` + * branch below was unreachable, the catch reported the wrapper's summary, and the actual reason + * (`ModuleNotFound resolving "./Plugins.gen"`, a CSS `url()` that cannot be resolved, a syntax + * error with a file and a line) was thrown away at the moment it was most needed. + * + * Diagnosed by building a file that does not exist and printing the error's shape, after it cost + * twenty minutes of bisecting a themes refactor by hand. `throw: false` makes the returned result + * carry the logs, which is what the code below always assumed. + */ + throw: false, // WITHOUT THIS THE APP RENDERS UNSTYLED. // // `bunfig.toml` declares `[serve.static] plugins = ["bun-plugin-tailwind"]`, and that applies to @@ -141,10 +169,12 @@ export async function rebuildFrontend(): Promise { ok: false, ms: Date.now() - started, outputs: 0, + // 1000 rather than 500: a single bundler diagnostic with a file, a line and a code frame is + // routinely longer than 500 characters, and truncating mid-path leaves it unactionable. error: result.logs .map((l) => String(l)) .join('; ') - .slice(0, 500), + .slice(0, 1000), }; } // The shell must exist before anything is swapped in — a build reporting success without one is how