Summary
When processDirectives fails, it swallows the error and returns an HTML comment as the entire page. stx serve (the bun-plugin server) has no 500 handling: it treats that comment only as a hint not to cache, and sends it with a 200. A production visitor gets a blank white page; a crawler gets an empty 200; an uptime check gets a pass. The two other stx servers already do the right thing — packages/stx/src/production-server.ts looks up the user's /500 page (#1722) and dev-server/serve-app.ts does too — so this is an unfilled hole in the one server path a Stacks app actually runs on.
Impact (real app)
analyticshq had to pin a validation flag permanently off to avoid the failure mode rather than fix it. config/ui.ts:93-95:
failOnViolation MUST stay false. The throw at script-validation.js:154 is a plain Error that processDirectives catches and replaces the ENTIRE page with
<!-- Template Processing failed: ... -->served at HTTP 200 — a blank page, not a 500.
That is a strict-mode DOM validator we would otherwise want on in CI, disabled forever because turning it on converts a lint violation into a silent blank production page. And because the failure is a 200 with an empty-looking body, nothing in the deployment pipeline — health gate, uptime monitor, crawler — reports it. We found this by reading the framework source, not from an alert.
Evidence in stx
packages/stx/src/process.ts:662-688:
catch (error: unknown) {
// Validation errors are ALWAYS fatal …
if (error instanceof StxValidationError) {
throw error
}
…
if (options.debug) {
throw enhancedError
}
// In production, try to recover
return errorRecovery.createFallbackContent('Template Processing', enhancedError) // :687
}packages/stx/src/errors/logger.ts:90-95 — what "recover" means:
createFallbackContent(sectionType: string, error: Error): string {
if (isDevelopment()) {
return `<!-- ${sectionType} failed: ${error.message} -->`
}
return `<!-- stx rendering error -->`
}The StxValidationError guard does not cover the strict-mode validator, which throws a plain Error — packages/stx/src/script-validation.ts:253-254:
if (strictConfig.failOnViolation) {
throw new Error(baseMessage)
}And nothing in the serve path acts on the marker. Its only consumer is packages/bun-plugin/src/serve.ts:696-699:
export function isRenderableCacheCandidate(html: string): boolean {
return !html.includes('<!-- Template Processing failed:')
&& !html.includes('<!-- stx rendering error -->')
}called once, at :1909, to decide whether to populate the html cache. grep -n "'/500'\|errors/500\|500.stx" packages/bun-plugin/src/serve.ts returns nothing.
Both other servers already handle it:
packages/stx/src/production-server.ts:283-298— "Try the user's 500 error page first (mirrors the /404 lookup …)",exactRoutes.get('/500'), thenstatus: 500. Landed for #1722.packages/stx/src/dev-server/serve-app.ts:980-985—renderErrorPage(500)thenstatus: 500.
stx serve is what Stacks runs in dev (storage/framework/core/actions/src/dev/views.ts) and in production (storage/framework/core/buddy/src/production-server.ts:304), so an app on Stacks gets the 200 behaviour on every environment.
Prior art
| framework | render failure | error page contract |
|---|---|---|
| Nuxt | 500 | error.vue + showError() |
| Next | 500 | error.tsx, global-error.tsx |
| Remix | 500 | ErrorBoundary export per route |
| Laravel | 500 | resources/views/errors/500.blade.php + exception handler |
| SvelteKit | 500 | +error.svelte |
In every one of them, a template that throws produces a 5xx. None of them return a 200 with the error commented out.
Ask
- In
stx serve, detect a failed render (either by keepingisRenderableCacheCandidate's marker test or, better, by lettingprocessDirectivesrethrow and catching at the request boundary) and respond 500, never 200. - On that path, do the
/500→/errors/500page lookup throughgetRoute(), exactly as the 404 path already does atserve.ts:3222-3245, and fall back to a built-in error page. This is the #1722 fix applied to the second server. - Stop returning an HTML comment as a whole document. In development render the existing detailed error page; in production render the user's 500 page or a minimal branded one — an empty body is the worst possible output for monitoring and for search engines.
- Do not silently swallow a strict-mode validation failure:
script-validation.ts:254should throwStxValidationError(whichprocess.ts:664already treats as fatal) rather than a plainError, sofailOnViolation: truebecomes usable instead of a footgun. - Add a test that renders a deliberately broken template through
stx serveand assertsstatus === 500and a non-empty body.