diff --git a/.changeset/production-error-boundary.md b/.changeset/production-error-boundary.md new file mode 100644 index 0000000..8f7be38 --- /dev/null +++ b/.changeset/production-error-boundary.md @@ -0,0 +1,5 @@ +--- +'@solidjs/vite-plugin': patch +--- + +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. diff --git a/examples/turnkey/test/run.mjs b/examples/turnkey/test/run.mjs index 659b3e1..57d735a 100644 --- a/examples/turnkey/test/run.mjs +++ b/examples/turnkey/test/run.mjs @@ -1024,6 +1024,14 @@ async function runProdMode() { ); record(mode, 'prod', 'no dev injections leaked', !html.includes('/@vite/client')); + const boom = await fetchStreamed(origin + '/boom'); + record( + mode, + 'errors', + 'uncaught render errors return the production fallback', + boom.status === 500 && boom.html.includes('500 | Internal Server Error'), + `status ${boom.status}`, + ); // clientOnly preload contract (compiler 0.50.0-next.35 + @solidjs/web // 2.0): the module-URL pass annotates the clientOnly() call, // the server half resolves the chunk through the client manifest and diff --git a/examples/turnkey/vite.config.ts b/examples/turnkey/vite.config.ts index 0651708..8a594d8 100644 --- a/examples/turnkey/vite.config.ts +++ b/examples/turnkey/vite.config.ts @@ -112,7 +112,9 @@ export default defineConfig({ // SSR_MIDDLEWARE=1 (middleware/preview modes): a fetch-style // chain fronting every dispatch path — page SSR, /_server, // preview — with getRequestEvent() live inside it. - ...(process.env.SSR_MIDDLEWARE ? { middleware: './src/middleware.ts' } : {}), + ...(process.env.SSR_MIDDLEWARE + ? { middleware: './src/middleware.ts', errorBoundary: false } + : {}), // SSR_SETUP=1 (middleware mode): the per-request app-setup // hook — src/setup.tsx runs between the middleware chain and // renderToStream, receiving the event and returning the diff --git a/src/ssr/index.ts b/src/ssr/index.ts index 2a80694..0ff3d3c 100644 --- a/src/ssr/index.ts +++ b/src/ssr/index.ts @@ -193,6 +193,14 @@ export interface StartOptions { * @default undefined (probe env.ts / env.js; off when absent) */ env?: boolean | string; + /** + * Add the default production error boundary to generated entries. + * Disable this when application middleware owns error handling. Authored + * entries are unaffected. + * + * @default true + */ + errorBoundary?: boolean; /** * Let a host integration own the server environment — build wiring and * 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; const ENTRY_SERVER_ID = 'virtual:solid-ssr-entry-server.tsx'; const ENTRY_CLIENT_ID = 'virtual:solid-ssr-entry-client.tsx'; const DOCUMENT_ID = 'virtual:solid-ssr-document.tsx'; +const ERROR_BOUNDARY_ID = 'virtual:solid-ssr-error-boundary.tsx'; const MANIFEST_ID = 'virtual:solid-manifest'; const SERVER_FUNCTION_HANDLER_ID = 'virtual:solid-server-function-handler'; @@ -401,6 +410,7 @@ export function startServe( // the server-function handler module either way). Everything is gated // codegen: with the option off, none of these imports exist anywhere. const serverComponents = !!internal.serverComponents; + const errorBoundary = options.errorBoundary !== false; // `external` is server-mode-only (documented no-op in client mode, so a // host-integrated config survives the `ssr` boolean flip untouched). const externalServer = !clientMode && !!options.external; @@ -476,6 +486,26 @@ export function startServe( ].join('\n'); } + function errorBoundaryImport(): string[] { + return isBuild && errorBoundary + ? [`import { DefaultErrorBoundary } from ${JSON.stringify(ERROR_BOUNDARY_ID)};`] + : []; + } + + function documentTree(root: string): string[] { + return isBuild && errorBoundary + ? [ + ` `, + ` `, + ` `, + ` <${root} />`, + ` `, + ` `, + ` `, + ] + : [` `, ` <${root} />`, ` `]; + } + function generatedEntryServerCode(): string { if (clientMode) { // The client-mode shell: the document without the app. Rendered per @@ -486,9 +516,18 @@ export function startServe( `import { renderToStream } from '@solidjs/web';`, `import manifest from ${JSON.stringify(MANIFEST_ID)};`, `import Document from ${JSON.stringify(documentSpec())};`, + ...errorBoundaryImport(), ``, `export function render(request, context) {`, - ` return renderToStream(() => , { manifest });`, + ` return renderToStream(() => (`, + ...(isBuild && errorBoundary + ? [ + ` `, + ` `, + ` `, + ] + : [` `]), + ` ), { manifest });`, `}`, ].join('\n'); } @@ -505,6 +544,7 @@ export function startServe( `import manifest from ${JSON.stringify(MANIFEST_ID)};`, `import Document from ${JSON.stringify(documentSpec())};`, `import App from ${JSON.stringify(app)};`, + ...errorBoundaryImport(), ...(setupPath ? [`import setup from ${JSON.stringify(setupPath)};`] : []), ``, ...(setupPath @@ -546,18 +586,14 @@ export function startServe( ``, `function renderApp(Root) {`, ` return renderToStream(() => (`, - ` `, - ` `, - ` `, + ...documentTree('Root'), ` ), ${streamOptions});`, `}`, ] : [ `export function render(request, context) {`, ` return renderToStream(() => (`, - ` `, - ` `, - ` `, + ...documentTree('App'), ` ), ${streamOptions});`, `}`, ]), @@ -574,9 +610,14 @@ export function startServe( // complete when this runs. return [ `import { render } from '@solidjs/web';`, + ...errorBoundaryImport(), `import App from ${JSON.stringify(app)};`, ``, - `render(() => , document.body);`, + `render(() => ${ + isBuild && errorBoundary + ? '' + : '' + }, document.body);`, ].join('\n'); } return [ @@ -584,6 +625,7 @@ export function startServe( ...(serverComponents ? [`import { installServerComponents } from '@solidjs/web/frames';`] : []), + ...errorBoundaryImport(), `import Document from ${JSON.stringify(documentSpec())};`, `import App from ${JSON.stringify(app)};`, ``, @@ -597,9 +639,7 @@ export function startServe( ] : []), `hydrate(() => (`, - ` `, - ` `, - ` `, + ...documentTree('App'), `), document);`, ].join('\n'); } @@ -627,6 +667,29 @@ export function startServe( `}`, ].join('\n'); + const errorBoundaryCode = [ + `import { Errored } from 'solid-js';`, + `import { httpStatus, isServer } from '@solidjs/web';`, + ``, + `function ErrorFallback(props) {`, + ` console.error(props.error());`, + ` httpStatus(500);`, + ` return (`, + ` `, + ` {isServer ? '500 | Internal Server Error' : 'Error | Uncaught Client Exception'}`, + ` `, + ` );`, + `}`, + ``, + `export function DefaultErrorBoundary(props) {`, + ` return (`, + ` }>`, + ` {props.children}`, + ` `, + ` );`, + `}`, + ].join('\n'); + // The handler module: dev and prod share the render/response plumbing; // they differ in how the client entry URL is known (baked dev URL vs a // manifest scan) and what gets injected into (Vite client + style @@ -1034,7 +1097,12 @@ export function startServe( if (source === DEV_STYLES_ID) { return { id: RESOLVED_DEV_STYLES_ID, moduleSideEffects: true }; } - if (source === ENTRY_SERVER_ID || source === ENTRY_CLIENT_ID || source === DOCUMENT_ID) { + if ( + source === ENTRY_SERVER_ID || + source === ENTRY_CLIENT_ID || + source === DOCUMENT_ID || + source === ERROR_BOUNDARY_ID + ) { return { id: source, moduleSideEffects: source === ENTRY_CLIENT_ID }; } return null; @@ -1060,6 +1128,7 @@ export function startServe( if (id === ENTRY_SERVER_ID) return generatedEntryServerCode(); if (id === ENTRY_CLIENT_ID) return generatedEntryClientCode(); if (id === DOCUMENT_ID) return documentShellCode; + if (id === ERROR_BOUNDARY_ID) return errorBoundaryCode; return null; }, configurePreviewServer(server: PreviewServer) {