Skip to content

Commit 7c10d3b

Browse files
authored
feat: add production error boundary (#315)
1 parent c8615ed commit 7c10d3b

4 files changed

Lines changed: 97 additions & 13 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@solidjs/vite-plugin': patch
3+
---
4+
5+
Add a generic production error boundary to generated Start entries. It returns a 500 response for uncaught SSR errors and provides a fallback for uncaught client errors. Set `start.errorBoundary` to `false` when application middleware owns error handling.

examples/turnkey/test/run.mjs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1024,6 +1024,14 @@ async function runProdMode() {
10241024
);
10251025
record(mode, 'prod', 'no dev injections leaked', !html.includes('/@vite/client'));
10261026

1027+
const boom = await fetchStreamed(origin + '/boom');
1028+
record(
1029+
mode,
1030+
'errors',
1031+
'uncaught render errors return the production fallback',
1032+
boom.status === 500 && boom.html.includes('500 | Internal Server Error'),
1033+
`status ${boom.status}`,
1034+
);
10271035
// clientOnly preload contract (compiler 0.50.0-next.35 + @solidjs/web
10281036
// 2.0): the module-URL pass annotates the clientOnly() call,
10291037
// the server half resolves the chunk through the client manifest and

examples/turnkey/vite.config.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,9 @@ export default defineConfig({
112112
// SSR_MIDDLEWARE=1 (middleware/preview modes): a fetch-style
113113
// chain fronting every dispatch path — page SSR, /_server,
114114
// preview — with getRequestEvent() live inside it.
115-
...(process.env.SSR_MIDDLEWARE ? { middleware: './src/middleware.ts' } : {}),
115+
...(process.env.SSR_MIDDLEWARE
116+
? { middleware: './src/middleware.ts', errorBoundary: false }
117+
: {}),
116118
// SSR_SETUP=1 (middleware mode): the per-request app-setup
117119
// hook — src/setup.tsx runs between the middleware chain and
118120
// renderToStream, receiving the event and returning the

src/ssr/index.ts

Lines changed: 81 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,14 @@ export interface StartOptions {
193193
* @default undefined (probe env.ts / env.js; off when absent)
194194
*/
195195
env?: boolean | string;
196+
/**
197+
* Add the default production error boundary to generated entries.
198+
* Disable this when application middleware owns error handling. Authored
199+
* entries are unaffected.
200+
*
201+
* @default true
202+
*/
203+
errorBoundary?: boolean;
196204
/**
197205
* Let a host integration own the server environment — build wiring and
198206
* HTTP serving alike. The plugin skips its start-mode server-build config and
@@ -248,6 +256,7 @@ const RESOLVED_DEV_STYLES_ID = '\0' + DEV_STYLES_ID;
248256
const ENTRY_SERVER_ID = 'virtual:solid-ssr-entry-server.tsx';
249257
const ENTRY_CLIENT_ID = 'virtual:solid-ssr-entry-client.tsx';
250258
const DOCUMENT_ID = 'virtual:solid-ssr-document.tsx';
259+
const ERROR_BOUNDARY_ID = 'virtual:solid-ssr-error-boundary.tsx';
251260

252261
const MANIFEST_ID = 'virtual:solid-manifest';
253262
const SERVER_FUNCTION_HANDLER_ID = 'virtual:solid-server-function-handler';
@@ -401,6 +410,7 @@ export function startServe(
401410
// the server-function handler module either way). Everything is gated
402411
// codegen: with the option off, none of these imports exist anywhere.
403412
const serverComponents = !!internal.serverComponents;
413+
const errorBoundary = options.errorBoundary !== false;
404414
// `external` is server-mode-only (documented no-op in client mode, so a
405415
// host-integrated config survives the `ssr` boolean flip untouched).
406416
const externalServer = !clientMode && !!options.external;
@@ -476,6 +486,26 @@ export function startServe(
476486
].join('\n');
477487
}
478488

489+
function errorBoundaryImport(): string[] {
490+
return isBuild && errorBoundary
491+
? [`import { DefaultErrorBoundary } from ${JSON.stringify(ERROR_BOUNDARY_ID)};`]
492+
: [];
493+
}
494+
495+
function documentTree(root: string): string[] {
496+
return isBuild && errorBoundary
497+
? [
498+
` <DefaultErrorBoundary>`,
499+
` <Document>`,
500+
` <DefaultErrorBoundary>`,
501+
` <${root} />`,
502+
` </DefaultErrorBoundary>`,
503+
` </Document>`,
504+
` </DefaultErrorBoundary>`,
505+
]
506+
: [` <Document>`, ` <${root} />`, ` </Document>`];
507+
}
508+
479509
function generatedEntryServerCode(): string {
480510
if (clientMode) {
481511
// The client-mode shell: the document without the app. Rendered per
@@ -486,9 +516,18 @@ export function startServe(
486516
`import { renderToStream } from '@solidjs/web';`,
487517
`import manifest from ${JSON.stringify(MANIFEST_ID)};`,
488518
`import Document from ${JSON.stringify(documentSpec())};`,
519+
...errorBoundaryImport(),
489520
``,
490521
`export function render(request, context) {`,
491-
` return renderToStream(() => <Document />, { manifest });`,
522+
` return renderToStream(() => (`,
523+
...(isBuild && errorBoundary
524+
? [
525+
` <DefaultErrorBoundary>`,
526+
` <Document />`,
527+
` </DefaultErrorBoundary>`,
528+
]
529+
: [` <Document />`]),
530+
` ), { manifest });`,
492531
`}`,
493532
].join('\n');
494533
}
@@ -505,6 +544,7 @@ export function startServe(
505544
`import manifest from ${JSON.stringify(MANIFEST_ID)};`,
506545
`import Document from ${JSON.stringify(documentSpec())};`,
507546
`import App from ${JSON.stringify(app)};`,
547+
...errorBoundaryImport(),
508548
...(setupPath ? [`import setup from ${JSON.stringify(setupPath)};`] : []),
509549
``,
510550
...(setupPath
@@ -546,18 +586,14 @@ export function startServe(
546586
``,
547587
`function renderApp(Root) {`,
548588
` return renderToStream(() => (`,
549-
` <Document>`,
550-
` <Root />`,
551-
` </Document>`,
589+
...documentTree('Root'),
552590
` ), ${streamOptions});`,
553591
`}`,
554592
]
555593
: [
556594
`export function render(request, context) {`,
557595
` return renderToStream(() => (`,
558-
` <Document>`,
559-
` <App />`,
560-
` </Document>`,
596+
...documentTree('App'),
561597
` ), ${streamOptions});`,
562598
`}`,
563599
]),
@@ -574,16 +610,22 @@ export function startServe(
574610
// complete when this runs.
575611
return [
576612
`import { render } from '@solidjs/web';`,
613+
...errorBoundaryImport(),
577614
`import App from ${JSON.stringify(app)};`,
578615
``,
579-
`render(() => <App />, document.body);`,
616+
`render(() => ${
617+
isBuild && errorBoundary
618+
? '<DefaultErrorBoundary><App /></DefaultErrorBoundary>'
619+
: '<App />'
620+
}, document.body);`,
580621
].join('\n');
581622
}
582623
return [
583624
`import { hydrate } from '@solidjs/web';`,
584625
...(serverComponents
585626
? [`import { installServerComponents } from '@solidjs/web/frames';`]
586627
: []),
628+
...errorBoundaryImport(),
587629
`import Document from ${JSON.stringify(documentSpec())};`,
588630
`import App from ${JSON.stringify(app)};`,
589631
``,
@@ -597,9 +639,7 @@ export function startServe(
597639
]
598640
: []),
599641
`hydrate(() => (`,
600-
` <Document>`,
601-
` <App />`,
602-
` </Document>`,
642+
...documentTree('App'),
603643
`), document);`,
604644
].join('\n');
605645
}
@@ -627,6 +667,29 @@ export function startServe(
627667
`}`,
628668
].join('\n');
629669

670+
const errorBoundaryCode = [
671+
`import { Errored } from 'solid-js';`,
672+
`import { httpStatus, isServer } from '@solidjs/web';`,
673+
``,
674+
`function ErrorFallback(props) {`,
675+
` console.error(props.error());`,
676+
` httpStatus(500);`,
677+
` return (`,
678+
` <span style="font-size:1.5em;text-align:center;position:fixed;left:0;bottom:55%;width:100%">`,
679+
` {isServer ? '500 | Internal Server Error' : 'Error | Uncaught Client Exception'}`,
680+
` </span>`,
681+
` );`,
682+
`}`,
683+
``,
684+
`export function DefaultErrorBoundary(props) {`,
685+
` return (`,
686+
` <Errored fallback={(error) => <ErrorFallback error={error} />}>`,
687+
` {props.children}`,
688+
` </Errored>`,
689+
` );`,
690+
`}`,
691+
].join('\n');
692+
630693
// The handler module: dev and prod share the render/response plumbing;
631694
// they differ in how the client entry URL is known (baked dev URL vs a
632695
// manifest scan) and what gets injected into <head> (Vite client + style
@@ -1034,7 +1097,12 @@ export function startServe(
10341097
if (source === DEV_STYLES_ID) {
10351098
return { id: RESOLVED_DEV_STYLES_ID, moduleSideEffects: true };
10361099
}
1037-
if (source === ENTRY_SERVER_ID || source === ENTRY_CLIENT_ID || source === DOCUMENT_ID) {
1100+
if (
1101+
source === ENTRY_SERVER_ID ||
1102+
source === ENTRY_CLIENT_ID ||
1103+
source === DOCUMENT_ID ||
1104+
source === ERROR_BOUNDARY_ID
1105+
) {
10381106
return { id: source, moduleSideEffects: source === ENTRY_CLIENT_ID };
10391107
}
10401108
return null;
@@ -1060,6 +1128,7 @@ export function startServe(
10601128
if (id === ENTRY_SERVER_ID) return generatedEntryServerCode();
10611129
if (id === ENTRY_CLIENT_ID) return generatedEntryClientCode();
10621130
if (id === DOCUMENT_ID) return documentShellCode;
1131+
if (id === ERROR_BOUNDARY_ID) return errorBoundaryCode;
10631132
return null;
10641133
},
10651134
configurePreviewServer(server: PreviewServer) {

0 commit comments

Comments
 (0)