ReviewOS

stacks/stx

A <script client> bundle failure is a console.warn: unbundled source ships, build exits 0, and the dev server has no error channel

#1884
Closed glennmichael123 opened this 11 days ago ·
11 days ago

When a <script client> block fails to bundle, stx logs a console.warn, ships the unbundled source to the browser, and the build exits 0. On the dev server this app actually runs, the failure is invisible in the browser by construction.

The fallback

src/client-script-bundler.ts:619-621:

console.warn(`[stx:bundler] error: ${formatBuildFailure(error, filePath || undefined)}`)
return code   // fall back to the original, unbundled source

Nothing increments a failure counter. ssg.ts:1165 result.failedCount++ is reachable only from the catch at :1164, and bin/cli.ts:1139-1141 exits 1 only when result.ssg.failedCount > 0. So stx build reports success.

The warning is also suppressed by default — shouldLogBundlerDiagnostics (:27-31) gates on STX_DEBUG === 'true' | '1'.

There's no config switch to make it fatal: StrictModeConfig (types/config-types.ts:393-400) has only enabled, failOnViolation, allowPatterns, and its sole consumer is validateClientScript for prohibited DOM patterns (process.ts:1799).

In the browser it's worse

The error overlay in hot-reload.ts:292-321 is a connection-status overlay. Its one error branch (:370-373, case 'error': showOverlay('Build Error: ' + msg.content, true)) has exactly one producer anywhere — dev-server/serve-file.ts:569, hmrServer.error('Build failed - check console for details') — a generic string with no file, line, column or code frame, on the single-file server.

But a Stacks app doesn't run that server. It runs bun-plugin-stx (@stacksjs/actions/dist/dev/views.js:60 → import('bun-plugin-stx/serve')), whose HMR client (bun-plugin-stx/src/serve.ts:987) handles only m.type === 'reload' and m.type === 'css'. There is no error channel at all.

So the end-to-end experience is: the page loads, the script is present but unbundled, imports don't resolve, bindings silently do nothing, the terminal is quiet unless STX_DEBUG is set, and the build exits 0.

Why it matters

This class of silent degradation costs a lot of time to diagnose, because every symptom points somewhere else. Chasing an unrelated "component renders nothing" problem in this app took roughly thirty probes before the cause turned out to be in the build path rather than the template — the page rendered, the markup was correct, and nothing anywhere said a build step had failed.

To stx's credit the diagnostic data is already in hand: formatBuildFailure (client-script-bundler.ts:610-618) recovers line and column and rewrites the .stx/bundle-tmp path back to the source view. Only the transport and the fatal-exit path are missing.

Ask

  1. stx build: a bundler failure should increment failedCount and exit non-zero. Shipping unbundled source as a success is the wrong default.
  2. stx dev: a full-screen overlay with file, line, column, code frame and the original error, mapped back to the .stx source — and an error channel in bun-plugin-stx/serve, which currently has none.
✖ resources/views/composer.stx:412:9  Could not resolve "./missing"
  410 |   const x = 1
  411 |
> 412 |   import { thing } from './missing'
  1. A strict: { bundlerFallback: 'error' } switch so silent degradation is opt-in rather than the default.

Maps to Vite's full-screen HMR error overlay plus a non-zero vite build exit.

Environment

@stacksjs/stx 0.2.153, bun-plugin-stx dev server, Bun 1.3.1.

11 days ago

Claiming this one — working on it now.

11 days ago

Build half is fixed, landed in d02d42a51d (the commit message names a different fix — the two changes were committed together by accident; the relevant files are packages/stx/src/client-script-bundler.ts, packages/stx/bin/cli.ts and packages/stx/test/build/bundle-failure-registry.test.ts).

What changed. The fallback stays — it is right for a dev server — but the build now asks. Bundle failures go into a registry in the bundler; bin/cli.ts clears it before the build, then prints each failing file with its message and exits 1. Keeping the check in the CLI rather than ssg.ts means the bundler doesn't need to know whether it's running under a dev server or a build.

One correction to the report: the error itself was not suppressed. The console.warn at client-script-bundler.ts:619 is unconditional — shouldLogBundlerDiagnostics gates logBundlerDiagnostic, which is a different function. The terminal did say something; it just said it while exiting 0, which is the part that mattered.

Still open (leaving this issue open for it): the dev-server half — an overlay with file, line, column and code frame, and an error channel in bun-plugin-stx/serve, which currently has none. formatBuildFailure already recovers the location data, so that half is transport, not diagnostics.

11 days ago

Picking up the remaining half (dev-server overlay + error channel). No collision with the build half you landed — the transport is the HMR SSE channel from #1877 (broadcastHmr / HMR_CLIENT_SCRIPT in bun-plugin/src/serve.ts), so this is an error event on an existing stream plus an overlay, and it consumes your getBundleFailures() registry rather than duplicating it. Will also do ask 3 (strict.bundlerFallback). Shout if you'd rather keep it.

11 days ago

Dev half done in 8a224bee25 — asks 2 and 3. With the build half (c33162a8f1) that closes this out.

Ask 2 — overlay + error channel. A build-error event rides the HMR SSE channel from #1877, and the injected client draws a full-screen overlay with file, position and code frame, in the format the issue asked for. An empty errors array clears it, so a render that bundles cleanly takes the overlay down — an overlay you have to dismiss is one you stop trusting.

Two things here were easy to get wrong, and both are pinned by tests that fail under sabotage:

  • It has to reach the browser that connects after the failing render. On a full page load the response is sent before the new EventSource exists, so broadcasting alone reaches only the previous page's connection — the overlay would never appear on the very load that failed. Current errors are replayed on connect; removing that fails four tests.

  • The line number must not be Bun's. The bundler compiles a temp entry under .stx/bundle-tmp, so position.line counts lines in that file. For a block halfway down a page it is off by however many lines precede it, and for an unresolved import it comes back as -1. Pointing an overlay at that line is worse than pointing at none — it sends the reader to code that is fine. locateFailureLine corroborates against the real source instead: lineText is the one thing that survives the move, so if it occurs exactly once in the file, that is the line. Ambiguous or absent yields no line and no frame, and the message still names what could not be resolved.

    The test asserts the .stx line (10) and records the temp-entry line (3) as the value that must not appear.

Ask 3 — strict: { bundlerFallback: 'error' }. Refuses instead of falling back. The default stays 'warn': a dev server you cannot load is worse than one showing you the error, which is what the overlay is now for. Read per call rather than captured at import time — config loads after that module is first imported, so a captured value would always be the default and the setting would silently never apply.

On your correction re: the console.warn — confirmed, and it shaped the fix. The warn was never suppressed; it was unmissable in the wrong place. So this is a transport change, not a diagnostics one, exactly as you called it.

formatBuildFailure now renders from a new describeBuildFailure that keeps the position structured. The terminal wants a string, the overlay wants fields; deriving both from one function is what stops them drifting into disagreeing about where the error is. Existing output is unchanged (its 11 tests pass untouched).


Unrelated, but it turned up in the full-suite run and it's yours: packages/stx/test/unresolved-identifiers.test.ts → "SERVER_ONLY_COMPOSABLES is current > lists exactly the composable exports the runtime does not provide" is failing on main. It looks like c9597e5b35 (#1846) added defineForm to the runtime but left it in SERVER_ONLY_COMPOSABLES, so the list has one entry too many. Not touching it — flagging so it doesn't sit red.

Sign in to comment on this issue.