build failures say what went wrong

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 <noreply@anthropic.com>
This commit is contained in:
2026-08-16 23:36:49 +00:00
co-authored by Claude Opus 5
parent 997f7e7a87
commit df92a985d2
+31 -1
View File
@@ -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<BuildResult> {
const started = Date.now();
try {
@@ -126,6 +140,20 @@ export async function rebuildFrontend(): Promise<BuildResult> {
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<BuildResult> {
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