Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/production-error-boundary.md
Original file line number Diff line number Diff line change
@@ -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.
8 changes: 8 additions & 0 deletions examples/turnkey/test/run.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion examples/turnkey/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
93 changes: 81 additions & 12 deletions src/ssr/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
? [
` <DefaultErrorBoundary>`,
` <Document>`,
` <DefaultErrorBoundary>`,
` <${root} />`,
` </DefaultErrorBoundary>`,
` </Document>`,
` </DefaultErrorBoundary>`,
]
: [` <Document>`, ` <${root} />`, ` </Document>`];
}

function generatedEntryServerCode(): string {
if (clientMode) {
// The client-mode shell: the document without the app. Rendered per
Expand All @@ -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(() => <Document />, { manifest });`,
` return renderToStream(() => (`,
...(isBuild && errorBoundary
? [
` <DefaultErrorBoundary>`,
` <Document />`,
` </DefaultErrorBoundary>`,
]
: [` <Document />`]),
` ), { manifest });`,
`}`,
].join('\n');
}
Expand All @@ -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
Expand Down Expand Up @@ -546,18 +586,14 @@ export function startServe(
``,
`function renderApp(Root) {`,
` return renderToStream(() => (`,
` <Document>`,
` <Root />`,
` </Document>`,
...documentTree('Root'),
` ), ${streamOptions});`,
`}`,
]
: [
`export function render(request, context) {`,
` return renderToStream(() => (`,
` <Document>`,
` <App />`,
` </Document>`,
...documentTree('App'),
` ), ${streamOptions});`,
`}`,
]),
Expand All @@ -574,16 +610,22 @@ export function startServe(
// complete when this runs.
return [
`import { render } from '@solidjs/web';`,
...errorBoundaryImport(),
`import App from ${JSON.stringify(app)};`,
``,
`render(() => <App />, document.body);`,
`render(() => ${
isBuild && errorBoundary
? '<DefaultErrorBoundary><App /></DefaultErrorBoundary>'
: '<App />'
}, document.body);`,
].join('\n');
}
return [
`import { hydrate } from '@solidjs/web';`,
...(serverComponents
? [`import { installServerComponents } from '@solidjs/web/frames';`]
: []),
...errorBoundaryImport(),
`import Document from ${JSON.stringify(documentSpec())};`,
`import App from ${JSON.stringify(app)};`,
``,
Expand All @@ -597,9 +639,7 @@ export function startServe(
]
: []),
`hydrate(() => (`,
` <Document>`,
` <App />`,
` </Document>`,
...documentTree('App'),
`), document);`,
].join('\n');
}
Expand Down Expand Up @@ -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 (`,
` <span style="font-size:1.5em;text-align:center;position:fixed;left:0;bottom:55%;width:100%">`,
` {isServer ? '500 | Internal Server Error' : 'Error | Uncaught Client Exception'}`,
` </span>`,
` );`,
`}`,
``,
`export function DefaultErrorBoundary(props) {`,
` return (`,
` <Errored fallback={(error) => <ErrorFallback error={error} />}>`,
` {props.children}`,
` </Errored>`,
` );`,
`}`,
].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 <head> (Vite client + style
Expand Down Expand Up @@ -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;
Expand All @@ -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) {
Expand Down
Loading