@timber-js/app 0.2.0-alpha.84 → 0.2.0-alpha.86
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +8 -0
- package/dist/_chunks/{actions-YHRCboUO.js → actions-DLnUaR65.js} +2 -2
- package/dist/_chunks/{actions-YHRCboUO.js.map → actions-DLnUaR65.js.map} +1 -1
- package/dist/_chunks/{chunk-DYhsFzuS.js → chunk-BYIpzuS7.js} +7 -1
- package/dist/_chunks/{define-cookie-C9pquwOg.js → define-cookie-BowvzoP0.js} +4 -4
- package/dist/_chunks/{define-cookie-C9pquwOg.js.map → define-cookie-BowvzoP0.js.map} +1 -1
- package/dist/_chunks/{request-context-Dl0hXED3.js → request-context-CK5tZqIP.js} +2 -2
- package/dist/_chunks/{request-context-Dl0hXED3.js.map → request-context-CK5tZqIP.js.map} +1 -1
- package/dist/client/form.d.ts +4 -1
- package/dist/client/form.d.ts.map +1 -1
- package/dist/client/index.js +2 -2
- package/dist/client/index.js.map +1 -1
- package/dist/config-validation.d.ts +51 -0
- package/dist/config-validation.d.ts.map +1 -0
- package/dist/cookies/index.js +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1185 -51
- package/dist/index.js.map +1 -1
- package/dist/plugins/dev-404-page.d.ts +56 -0
- package/dist/plugins/dev-404-page.d.ts.map +1 -0
- package/dist/plugins/dev-error-overlay.d.ts +25 -11
- package/dist/plugins/dev-error-overlay.d.ts.map +1 -1
- package/dist/plugins/dev-error-page.d.ts +58 -0
- package/dist/plugins/dev-error-page.d.ts.map +1 -0
- package/dist/plugins/dev-server.d.ts.map +1 -1
- package/dist/plugins/dev-terminal-error.d.ts +28 -0
- package/dist/plugins/dev-terminal-error.d.ts.map +1 -0
- package/dist/plugins/entries.d.ts.map +1 -1
- package/dist/plugins/fonts.d.ts +4 -0
- package/dist/plugins/fonts.d.ts.map +1 -1
- package/dist/plugins/routing.d.ts.map +1 -1
- package/dist/routing/convention-lint.d.ts +41 -0
- package/dist/routing/convention-lint.d.ts.map +1 -0
- package/dist/server/action-client.d.ts +13 -5
- package/dist/server/action-client.d.ts.map +1 -1
- package/dist/server/dev-source-map.d.ts +22 -0
- package/dist/server/dev-source-map.d.ts.map +1 -0
- package/dist/server/fallback-error.d.ts +9 -5
- package/dist/server/fallback-error.d.ts.map +1 -1
- package/dist/server/index.js +2 -2
- package/dist/server/index.js.map +1 -1
- package/dist/server/internal.js +21 -4
- package/dist/server/internal.js.map +1 -1
- package/dist/server/pipeline.d.ts +10 -0
- package/dist/server/pipeline.d.ts.map +1 -1
- package/dist/server/rsc-entry/error-renderer.d.ts.map +1 -1
- package/dist/server/rsc-entry/index.d.ts +11 -0
- package/dist/server/rsc-entry/index.d.ts.map +1 -1
- package/dist/server/rsc-entry/rsc-stream.d.ts +10 -0
- package/dist/server/rsc-entry/rsc-stream.d.ts.map +1 -1
- package/dist/server/rsc-entry/ssr-renderer.d.ts.map +1 -1
- package/package.json +6 -7
- package/src/cli.ts +0 -0
- package/src/client/form.tsx +10 -5
- package/src/config-validation.ts +299 -0
- package/src/index.ts +17 -0
- package/src/plugins/dev-404-page.ts +418 -0
- package/src/plugins/dev-error-overlay.ts +185 -54
- package/src/plugins/dev-error-page.ts +536 -0
- package/src/plugins/dev-server.ts +76 -10
- package/src/plugins/dev-terminal-error.ts +217 -0
- package/src/plugins/entries.ts +3 -0
- package/src/plugins/fonts.ts +3 -2
- package/src/plugins/routing.ts +37 -5
- package/src/routing/convention-lint.ts +356 -0
- package/src/server/action-client.ts +17 -9
- package/src/server/dev-source-map.ts +31 -0
- package/src/server/fallback-error.ts +44 -88
- package/src/server/pipeline.ts +34 -4
- package/src/server/rsc-entry/error-renderer.ts +5 -0
- package/src/server/rsc-entry/index.ts +88 -2
- package/src/server/rsc-entry/rsc-stream.ts +16 -0
- package/src/server/rsc-entry/ssr-renderer.ts +6 -3
|
@@ -140,13 +140,13 @@ export interface ActionBuilder<TCtx> {
|
|
|
140
140
|
/** Define the action body without input validation. */
|
|
141
141
|
action<TData>(
|
|
142
142
|
fn: (ctx: ActionContext<TCtx, undefined>) => Promise<TData>
|
|
143
|
-
): ActionFn<
|
|
143
|
+
): ActionFn<TData, undefined>;
|
|
144
144
|
}
|
|
145
145
|
|
|
146
146
|
/** Builder after .schema() has been called. */
|
|
147
147
|
export interface ActionBuilderWithSchema<TCtx, TInput> {
|
|
148
148
|
/** Define the action body with validated input. */
|
|
149
|
-
action<TData>(fn: (ctx: ActionContext<TCtx, TInput>) => Promise<TData>): ActionFn<
|
|
149
|
+
action<TData>(fn: (ctx: ActionContext<TCtx, TInput>) => Promise<TData>): ActionFn<TData, TInput>;
|
|
150
150
|
}
|
|
151
151
|
|
|
152
152
|
/**
|
|
@@ -169,10 +169,18 @@ export interface ActionBuilderWithSchema<TCtx, TInput> {
|
|
|
169
169
|
export type InputHint<T> =
|
|
170
170
|
T extends Record<string, unknown> ? { [K in keyof T]: string | undefined } : T;
|
|
171
171
|
|
|
172
|
-
|
|
172
|
+
/**
|
|
173
|
+
* ActionFn — the callable returned by `createActionClient().action()`.
|
|
174
|
+
*
|
|
175
|
+
* Generic order: `<TData, TInput>` — TData first for backward compatibility.
|
|
176
|
+
* Previously ActionFn had a single `<TData>` generic, so existing code like
|
|
177
|
+
* `ActionFn<MyResult>` must still work with TData in the first position.
|
|
178
|
+
* See TIM-797.
|
|
179
|
+
*/
|
|
180
|
+
export type ActionFn<TData = unknown, TInput = unknown> = {
|
|
173
181
|
/** <form action={fn}> compatibility — React discards the return value. */
|
|
174
182
|
(formData: FormData): void;
|
|
175
|
-
/** Direct call: action(input) — optional when TInput is undefined (no-schema actions). */
|
|
183
|
+
/** Direct call: action(input) — optional when TInput is undefined/unknown (no-schema actions). */
|
|
176
184
|
(
|
|
177
185
|
...args: undefined extends TInput ? [input?: TInput] : [input: TInput]
|
|
178
186
|
): Promise<ActionResult<TData>>;
|
|
@@ -310,7 +318,7 @@ export function createActionClient<TCtx = Record<string, never>>(
|
|
|
310
318
|
function buildAction<TInput, TData>(
|
|
311
319
|
schema: ActionSchema<TInput> | undefined,
|
|
312
320
|
fn: (ctx: ActionContext<TCtx, TInput>) => Promise<TData>
|
|
313
|
-
): ActionFn<
|
|
321
|
+
): ActionFn<TData, TInput> {
|
|
314
322
|
async function actionHandler(...args: unknown[]): Promise<ActionResult<TData>> {
|
|
315
323
|
try {
|
|
316
324
|
// Run middleware
|
|
@@ -401,7 +409,7 @@ export function createActionClient<TCtx = Record<string, never>>(
|
|
|
401
409
|
}
|
|
402
410
|
}
|
|
403
411
|
|
|
404
|
-
return actionHandler as ActionFn<
|
|
412
|
+
return actionHandler as ActionFn<TData, TInput>;
|
|
405
413
|
}
|
|
406
414
|
|
|
407
415
|
return {
|
|
@@ -409,14 +417,14 @@ export function createActionClient<TCtx = Record<string, never>>(
|
|
|
409
417
|
return {
|
|
410
418
|
action<TData>(
|
|
411
419
|
fn: (ctx: ActionContext<TCtx, TInput>) => Promise<TData>
|
|
412
|
-
): ActionFn<
|
|
420
|
+
): ActionFn<TData, TInput> {
|
|
413
421
|
return buildAction(schema, fn);
|
|
414
422
|
},
|
|
415
423
|
};
|
|
416
424
|
},
|
|
417
425
|
action<TData>(
|
|
418
426
|
fn: (ctx: ActionContext<TCtx, undefined>) => Promise<TData>
|
|
419
|
-
): ActionFn<
|
|
427
|
+
): ActionFn<TData, undefined> {
|
|
420
428
|
return buildAction(undefined, fn as (ctx: ActionContext<TCtx, unknown>) => Promise<TData>);
|
|
421
429
|
},
|
|
422
430
|
};
|
|
@@ -445,7 +453,7 @@ export function createActionClient<TCtx = Record<string, never>>(
|
|
|
445
453
|
export function validated<TInput, TData>(
|
|
446
454
|
schema: ActionSchema<TInput>,
|
|
447
455
|
handler: (input: TInput) => Promise<TData>
|
|
448
|
-
): ActionFn<
|
|
456
|
+
): ActionFn<TData, TInput> {
|
|
449
457
|
return createActionClient()
|
|
450
458
|
.schema(schema)
|
|
451
459
|
.action(async ({ input }) => handler(input));
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dev-only error source-mapping bridge.
|
|
3
|
+
*
|
|
4
|
+
* Stores a callback that rewrites an error's stack trace using the Vite
|
|
5
|
+
* dev server's module graph. Set by the RSC entry on startup (via
|
|
6
|
+
* setDevSourceMapHandler), consumed by error renderers before generating
|
|
7
|
+
* error pages.
|
|
8
|
+
*
|
|
9
|
+
* In production, the callback is never set — all calls are no-ops.
|
|
10
|
+
*
|
|
11
|
+
* See TIM-811.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
let _sourceMapError: ((error: Error) => void) | undefined;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Set the source-map callback. Called once during dev server initialization.
|
|
18
|
+
*/
|
|
19
|
+
export function setSourceMapCallback(fn: (error: Error) => void): void {
|
|
20
|
+
_sourceMapError = fn;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Source-map an error's stack trace in-place if the callback is available.
|
|
25
|
+
* No-op in production or before the dev server initializes.
|
|
26
|
+
*/
|
|
27
|
+
export function sourceMapError(error: unknown): void {
|
|
28
|
+
if (_sourceMapError && error instanceof Error) {
|
|
29
|
+
_sourceMapError(error);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -17,6 +17,7 @@ import type { LayoutEntry } from './deny-renderer.js';
|
|
|
17
17
|
import type { GlobalErrorFile } from './rsc-entry/error-renderer.js';
|
|
18
18
|
import { logRenderError } from './logger.js';
|
|
19
19
|
import { loadModule } from './safe-load.js';
|
|
20
|
+
import { sourceMapError } from './dev-source-map.js';
|
|
20
21
|
|
|
21
22
|
/**
|
|
22
23
|
* Render a fallback error page when the render pipeline throws.
|
|
@@ -31,10 +32,15 @@ export async function renderFallbackError(
|
|
|
31
32
|
isDev: boolean,
|
|
32
33
|
rootSegment: ManifestSegmentNode,
|
|
33
34
|
clientBootstrap: ClientBootstrapConfig,
|
|
34
|
-
globalError?: GlobalErrorFile
|
|
35
|
+
globalError?: GlobalErrorFile,
|
|
36
|
+
projectRoot?: string
|
|
35
37
|
): Promise<Response> {
|
|
38
|
+
// Source-map the error's stack trace in dev mode so the error page
|
|
39
|
+
// shows original source positions. See TIM-811.
|
|
40
|
+
sourceMapError(error);
|
|
41
|
+
|
|
36
42
|
if (isDev) {
|
|
37
|
-
return renderDevErrorPage(error);
|
|
43
|
+
return renderDevErrorPage(error, projectRoot);
|
|
38
44
|
}
|
|
39
45
|
// Lazy import to avoid loading error-renderer in the pipeline module
|
|
40
46
|
const { renderErrorPage } = await import('./rsc-entry/error-renderer.js');
|
|
@@ -75,89 +81,35 @@ export async function renderFallbackError(
|
|
|
75
81
|
}
|
|
76
82
|
|
|
77
83
|
/**
|
|
78
|
-
* Render a dev-mode 500 error page with error
|
|
84
|
+
* Render a dev-mode 500 error page with error details, source context,
|
|
85
|
+
* classified stack trace, and copy button.
|
|
86
|
+
*
|
|
87
|
+
* Dynamically imports the shared template from `plugins/dev-error-page.ts`
|
|
88
|
+
* so it is NOT pulled into production server bundles. The Vite client script
|
|
89
|
+
* is injected so the error overlay fires when the HMR WebSocket connects.
|
|
79
90
|
*
|
|
80
|
-
*
|
|
81
|
-
* The Vite HMR client script is included so the error overlay still fires.
|
|
91
|
+
* Dev-only — the dynamic import has zero production cost.
|
|
82
92
|
*/
|
|
83
|
-
export function renderDevErrorPage(error: unknown): Response {
|
|
93
|
+
export async function renderDevErrorPage(error: unknown, projectRoot?: string): Promise<Response> {
|
|
84
94
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
85
|
-
const
|
|
86
|
-
const message = escapeHtml(err.message);
|
|
87
|
-
const stack = err.stack ? escapeHtml(err.stack) : '';
|
|
95
|
+
const root = projectRoot ?? process.cwd();
|
|
88
96
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
.container { max-width: 800px; margin: 0 auto; }
|
|
106
|
-
.badge {
|
|
107
|
-
display: inline-block;
|
|
108
|
-
background: #e74c3c;
|
|
109
|
-
color: white;
|
|
110
|
-
font-size: 0.75rem;
|
|
111
|
-
font-weight: 700;
|
|
112
|
-
padding: 0.2rem 0.6rem;
|
|
113
|
-
border-radius: 4px;
|
|
114
|
-
text-transform: uppercase;
|
|
115
|
-
letter-spacing: 0.05em;
|
|
116
|
-
margin-bottom: 1rem;
|
|
117
|
-
}
|
|
118
|
-
h1 {
|
|
119
|
-
font-size: 1.5rem;
|
|
120
|
-
color: #ff6b6b;
|
|
121
|
-
margin-bottom: 0.5rem;
|
|
122
|
-
word-break: break-word;
|
|
123
|
-
}
|
|
124
|
-
.message {
|
|
125
|
-
font-size: 1.1rem;
|
|
126
|
-
color: #ccc;
|
|
127
|
-
margin-bottom: 1.5rem;
|
|
128
|
-
word-break: break-word;
|
|
129
|
-
}
|
|
130
|
-
.stack-container {
|
|
131
|
-
background: #16213e;
|
|
132
|
-
border: 1px solid #2a2a4a;
|
|
133
|
-
border-radius: 8px;
|
|
134
|
-
padding: 1rem;
|
|
135
|
-
overflow-x: auto;
|
|
136
|
-
}
|
|
137
|
-
.stack {
|
|
138
|
-
font-family: 'SF Mono', 'Fira Code', 'Fira Mono', Menlo, Consolas, monospace;
|
|
139
|
-
font-size: 0.8rem;
|
|
140
|
-
color: #a0a0c0;
|
|
141
|
-
white-space: pre-wrap;
|
|
142
|
-
word-break: break-all;
|
|
143
|
-
}
|
|
144
|
-
.hint {
|
|
145
|
-
margin-top: 1.5rem;
|
|
146
|
-
font-size: 0.85rem;
|
|
147
|
-
color: #666;
|
|
148
|
-
}
|
|
149
|
-
</style>
|
|
150
|
-
</head>
|
|
151
|
-
<body>
|
|
152
|
-
<div class="container">
|
|
153
|
-
<span class="badge">500 Internal Server Error</span>
|
|
154
|
-
<h1>${escapeHtml(title)}</h1>
|
|
155
|
-
<p class="message">${message}</p>
|
|
156
|
-
${stack ? `<div class="stack-container"><pre class="stack">${stack}</pre></div>` : ''}
|
|
157
|
-
<p class="hint">This error page is only shown in development.</p>
|
|
158
|
-
</div>
|
|
159
|
-
</body>
|
|
160
|
-
</html>`;
|
|
97
|
+
let html: string;
|
|
98
|
+
try {
|
|
99
|
+
// Dynamic import — keeps dev-error-page.ts and its transitive deps
|
|
100
|
+
// (dev-error-overlay.ts, @jridgewell/trace-mapping) out of production bundles.
|
|
101
|
+
const { generateDevErrorPage } = await import('../plugins/dev-error-page.js');
|
|
102
|
+
html = generateDevErrorPage(err, 'render', root);
|
|
103
|
+
// Inject Vite client script so the error overlay fires when HMR connects.
|
|
104
|
+
html = html.replace(
|
|
105
|
+
'</head>',
|
|
106
|
+
' <script type="module" src="/@vite/client"></script>\n</head>'
|
|
107
|
+
);
|
|
108
|
+
} catch {
|
|
109
|
+
// If the shared template fails (e.g., circular dep, missing module),
|
|
110
|
+
// fall back to a minimal error page.
|
|
111
|
+
html = minimalErrorPage(err);
|
|
112
|
+
}
|
|
161
113
|
|
|
162
114
|
return new Response(html, {
|
|
163
115
|
status: 500,
|
|
@@ -165,11 +117,15 @@ export function renderDevErrorPage(error: unknown): Response {
|
|
|
165
117
|
});
|
|
166
118
|
}
|
|
167
119
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
120
|
+
/**
|
|
121
|
+
* Minimal fallback error page — used only if the shared template fails to load.
|
|
122
|
+
*/
|
|
123
|
+
function minimalErrorPage(err: Error): string {
|
|
124
|
+
const esc = (s: string) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
125
|
+
return `<!DOCTYPE html><html><head><meta charset="utf-8"><title>500</title>
|
|
126
|
+
<script type="module" src="/@vite/client"></script></head>
|
|
127
|
+
<body style="font-family:system-ui;padding:2rem;background:#1a1a2e;color:#e0e0e0">
|
|
128
|
+
<h1 style="color:#ff6b6b">500 Internal Server Error</h1>
|
|
129
|
+
<pre style="color:#a0a0c0;white-space:pre-wrap">${esc(err.stack ?? err.message)}</pre>
|
|
130
|
+
</body></html>`;
|
|
175
131
|
}
|
package/src/server/pipeline.ts
CHANGED
|
@@ -168,6 +168,7 @@ export interface PipelineConfig {
|
|
|
168
168
|
* Undefined in production — zero overhead.
|
|
169
169
|
*/
|
|
170
170
|
onPipelineError?: (error: Error, phase: string) => void;
|
|
171
|
+
|
|
171
172
|
/**
|
|
172
173
|
* Fallback error renderer — called when a catastrophic error escapes the
|
|
173
174
|
* render phase. Produces an HTML Response instead of a bare empty 500.
|
|
@@ -184,6 +185,19 @@ export interface PipelineConfig {
|
|
|
184
185
|
req: Request,
|
|
185
186
|
responseHeaders: Headers
|
|
186
187
|
) => Response | Promise<Response>;
|
|
188
|
+
/**
|
|
189
|
+
* Fallback deny page renderer — called when a DenySignal escapes from
|
|
190
|
+
* middleware or the render phase. Renders the appropriate status-code
|
|
191
|
+
* page (403.tsx, 404.tsx, etc.) instead of returning a bare empty response.
|
|
192
|
+
*
|
|
193
|
+
* If this function throws, the pipeline falls back to a bare
|
|
194
|
+
* `new Response(null, { status: denyStatus })`.
|
|
195
|
+
*/
|
|
196
|
+
renderDenyFallback?: (
|
|
197
|
+
deny: DenySignal,
|
|
198
|
+
req: Request,
|
|
199
|
+
responseHeaders: Headers
|
|
200
|
+
) => Response | Promise<Response>;
|
|
187
201
|
}
|
|
188
202
|
|
|
189
203
|
// ─── Param Coercion ────────────────────────────────────────────────────────
|
|
@@ -624,9 +638,18 @@ export function createPipeline(config: PipelineConfig): (req: Request) => Promis
|
|
|
624
638
|
applyCookieJar(responseHeaders);
|
|
625
639
|
return buildRedirectResponse(error, req, responseHeaders);
|
|
626
640
|
}
|
|
627
|
-
// DenySignal from middleware →
|
|
641
|
+
// DenySignal from middleware → render deny page with correct status code.
|
|
642
|
+
// Previously returned bare Response(null) — now renders 403.tsx etc.
|
|
628
643
|
if (error instanceof DenySignal) {
|
|
629
|
-
|
|
644
|
+
applyCookieJar(responseHeaders);
|
|
645
|
+
if (config.renderDenyFallback) {
|
|
646
|
+
try {
|
|
647
|
+
return await config.renderDenyFallback(error, req, responseHeaders);
|
|
648
|
+
} catch {
|
|
649
|
+
// Deny page rendering failed — fall through to bare response
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
return new Response(null, { status: error.status, headers: responseHeaders });
|
|
630
653
|
}
|
|
631
654
|
// Middleware throw → HTTP 500 (middleware runs before rendering,
|
|
632
655
|
// no error boundary to catch it)
|
|
@@ -655,9 +678,16 @@ export function createPipeline(config: PipelineConfig): (req: Request) => Promis
|
|
|
655
678
|
return response;
|
|
656
679
|
} catch (error) {
|
|
657
680
|
// DenySignal leaked from render (e.g. notFound() in metadata()).
|
|
658
|
-
//
|
|
681
|
+
// Render the deny page with the correct status code.
|
|
659
682
|
if (error instanceof DenySignal) {
|
|
660
|
-
|
|
683
|
+
if (config.renderDenyFallback) {
|
|
684
|
+
try {
|
|
685
|
+
return await config.renderDenyFallback(error, req, responseHeaders);
|
|
686
|
+
} catch {
|
|
687
|
+
// Deny page rendering failed — fall through to bare response
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
return new Response(null, { status: error.status, headers: responseHeaders });
|
|
661
691
|
}
|
|
662
692
|
// RedirectSignal leaked from render — honour the redirect
|
|
663
693
|
if (error instanceof RedirectSignal) {
|
|
@@ -32,6 +32,7 @@ import { isDevMode } from '../debug.js';
|
|
|
32
32
|
import { ErrorReconstituter } from '../../client/error-reconstituter.js';
|
|
33
33
|
import type { SerializableError } from '../../client/error-reconstituter.js';
|
|
34
34
|
import { loadModule } from '../safe-load.js';
|
|
35
|
+
import { sourceMapError } from '../dev-source-map.js';
|
|
35
36
|
|
|
36
37
|
/**
|
|
37
38
|
* A manifest file reference with lazy import and path.
|
|
@@ -176,6 +177,10 @@ export async function renderErrorPage(
|
|
|
176
177
|
clientBootstrap: ClientBootstrapConfig,
|
|
177
178
|
globalError?: GlobalErrorFile
|
|
178
179
|
): Promise<Response> {
|
|
180
|
+
// Source-map the error's stack trace in dev mode so the error page
|
|
181
|
+
// shows original source positions instead of transpiled/bundled ones.
|
|
182
|
+
// See TIM-811.
|
|
183
|
+
sourceMapError(error);
|
|
179
184
|
// Walk segments from leaf to root to find the error component
|
|
180
185
|
const resolution = await resolveErrorFile(status, segments);
|
|
181
186
|
|
|
@@ -54,6 +54,8 @@ import { initDevTracing } from '../tracing.js';
|
|
|
54
54
|
|
|
55
55
|
import { renderFallbackError as renderFallback } from '../fallback-error.js';
|
|
56
56
|
import { loadInstrumentation } from '../instrumentation.js';
|
|
57
|
+
import { loadModule } from '../safe-load.js';
|
|
58
|
+
import { logRenderError } from '../logger.js';
|
|
57
59
|
import { handleApiRoute } from './api-handler.js';
|
|
58
60
|
import { renderErrorPage, renderNoMatchPage } from './error-renderer.js';
|
|
59
61
|
import {
|
|
@@ -69,6 +71,7 @@ import { renderRscStream } from './rsc-stream.js';
|
|
|
69
71
|
import { renderSsrResponse } from './ssr-renderer.js';
|
|
70
72
|
import { callSsr } from './ssr-bridge.js';
|
|
71
73
|
import { isDebug, isDevMode, setDebugFromConfig } from '../debug.js';
|
|
74
|
+
import { setSourceMapCallback } from '../dev-source-map.js';
|
|
72
75
|
import { recordTiming } from '../server-timing.js';
|
|
73
76
|
import { requestContextAls } from '../als-registry.js';
|
|
74
77
|
import { createAutoSitemapHandler } from '../sitemap-handler.js';
|
|
@@ -113,6 +116,20 @@ export function setDevPipelineErrorHandler(
|
|
|
113
116
|
_devPipelineErrorHandler = handler;
|
|
114
117
|
}
|
|
115
118
|
|
|
119
|
+
/**
|
|
120
|
+
* Set the dev source-map handler.
|
|
121
|
+
*
|
|
122
|
+
* Called by the dev server after importing this module to wire Vite's
|
|
123
|
+
* module graph source-mapping into the error rendering pipeline. Errors
|
|
124
|
+
* rendered by renderErrorPage / renderFallbackError will have their
|
|
125
|
+
* stack traces rewritten to show original source positions.
|
|
126
|
+
*
|
|
127
|
+
* No-op in production.
|
|
128
|
+
*/
|
|
129
|
+
export function setDevSourceMapHandler(handler: (error: Error) => void): void {
|
|
130
|
+
setSourceMapCallback(handler);
|
|
131
|
+
}
|
|
132
|
+
|
|
116
133
|
// Dev-only: debug components getter is stored per-request in ALS
|
|
117
134
|
// (RequestContextStore.debugComponentsGetter) to avoid cross-request
|
|
118
135
|
// race conditions. See TIM-557.
|
|
@@ -247,7 +264,34 @@ async function createRequestHandler(manifest: typeof routeManifest, runtimeConfi
|
|
|
247
264
|
);
|
|
248
265
|
},
|
|
249
266
|
renderNoMatch: async (req: Request, responseHeaders: Headers) => {
|
|
250
|
-
|
|
267
|
+
const response = await renderNoMatchPage(
|
|
268
|
+
req,
|
|
269
|
+
manifest.root,
|
|
270
|
+
responseHeaders,
|
|
271
|
+
clientBootstrap
|
|
272
|
+
);
|
|
273
|
+
|
|
274
|
+
// In dev mode, if the pipeline returned a bare 404 (no body — meaning
|
|
275
|
+
// no user-defined 404.tsx was found), replace it with a helpful dev
|
|
276
|
+
// page that lists available routes and suggests similar paths.
|
|
277
|
+
// Only for GET requests that accept HTML — HEAD/API/RSC clients
|
|
278
|
+
// should receive the bare 404 without an HTML body (TIM-793).
|
|
279
|
+
const acceptsHtml = (req.headers.get('accept') ?? '').includes('text/html');
|
|
280
|
+
if (isDev && !response.body && req.method === 'GET' && acceptsHtml) {
|
|
281
|
+
const { generateDev404Page, collectRoutes } = await import('../../plugins/dev-404-page.js');
|
|
282
|
+
const routes = collectRoutes(manifest.root);
|
|
283
|
+
const pathname = new URL(req.url).pathname;
|
|
284
|
+
const html = generateDev404Page(pathname, routes);
|
|
285
|
+
return new Response(html, {
|
|
286
|
+
status: 404,
|
|
287
|
+
headers: {
|
|
288
|
+
...Object.fromEntries(responseHeaders.entries()),
|
|
289
|
+
'content-type': 'text/html; charset=utf-8',
|
|
290
|
+
},
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return response;
|
|
251
295
|
},
|
|
252
296
|
interceptionRewrites: manifest.interceptionRewrites,
|
|
253
297
|
// Slow request threshold from timber.config.ts. Default 3000ms, 0 to disable.
|
|
@@ -268,6 +312,7 @@ async function createRequestHandler(manifest: typeof routeManifest, runtimeConfi
|
|
|
268
312
|
}
|
|
269
313
|
}
|
|
270
314
|
: undefined,
|
|
315
|
+
|
|
271
316
|
renderFallbackError: (error, req, responseHeaders) =>
|
|
272
317
|
renderFallback(
|
|
273
318
|
error,
|
|
@@ -276,8 +321,49 @@ async function createRequestHandler(manifest: typeof routeManifest, runtimeConfi
|
|
|
276
321
|
isDev,
|
|
277
322
|
manifest.root,
|
|
278
323
|
clientBootstrap,
|
|
279
|
-
manifest.globalError
|
|
324
|
+
manifest.globalError,
|
|
325
|
+
// Project root for dev error page frame classification.
|
|
326
|
+
// Not in runtimeConfig (TIM-787: leaked to client bundles).
|
|
327
|
+
// manifest.root is the resolved Vite root — correct even when
|
|
328
|
+
// CWD differs from project root (e.g., monorepo custom root) (TIM-807).
|
|
329
|
+
isDev ? manifest.root : undefined
|
|
280
330
|
),
|
|
331
|
+
renderDenyFallback: async (deny, req, responseHeaders) => {
|
|
332
|
+
// Render the deny page (403.tsx, 404.tsx, etc.) for DenySignals
|
|
333
|
+
// that escape from middleware or the render phase. Uses the root
|
|
334
|
+
// segment to resolve status-code files and layout wrapping.
|
|
335
|
+
const segments = [
|
|
336
|
+
manifest.root,
|
|
337
|
+
] as unknown as import('../route-matcher.js').ManifestSegmentNode[];
|
|
338
|
+
const layoutComponents: LayoutEntry[] = [];
|
|
339
|
+
try {
|
|
340
|
+
if (manifest.root.layout) {
|
|
341
|
+
const mod = await loadModule(manifest.root.layout);
|
|
342
|
+
if (mod.default) {
|
|
343
|
+
layoutComponents.push({
|
|
344
|
+
component: mod.default as (...args: unknown[]) => unknown,
|
|
345
|
+
segment:
|
|
346
|
+
manifest.root as unknown as import('../route-matcher.js').ManifestSegmentNode,
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
} catch (layoutError) {
|
|
351
|
+
// Layout failed to load — proceed without it.
|
|
352
|
+
logRenderError({ method: req.method, path: new URL(req.url).pathname, error: layoutError });
|
|
353
|
+
}
|
|
354
|
+
const match = { segments: segments as never, segmentParams: {}, middlewareChain: [] };
|
|
355
|
+
return renderDenyPage(
|
|
356
|
+
deny,
|
|
357
|
+
segments,
|
|
358
|
+
layoutComponents,
|
|
359
|
+
req,
|
|
360
|
+
match,
|
|
361
|
+
responseHeaders,
|
|
362
|
+
clientBootstrap,
|
|
363
|
+
createDebugChannelSink,
|
|
364
|
+
callSsr
|
|
365
|
+
);
|
|
366
|
+
},
|
|
281
367
|
// Auto-generated sitemap handler — enabled when sitemap.enabled is true
|
|
282
368
|
// and no user-authored sitemap exists at the app root.
|
|
283
369
|
// See design/16-metadata.md §"Auto-generated Sitemap"
|
|
@@ -41,6 +41,16 @@ export interface RenderSignals {
|
|
|
41
41
|
denySignal: DenySignal | null;
|
|
42
42
|
redirectSignal: RedirectSignal | null;
|
|
43
43
|
renderError: { error: unknown; status: number } | null;
|
|
44
|
+
/**
|
|
45
|
+
* The last unhandled error seen by RSC onError that isn't a signal.
|
|
46
|
+
* Used as a fallback when SSR fails (SsrStreamError) but no structured
|
|
47
|
+
* signal was captured — provides the original error for the error page
|
|
48
|
+
* instead of relying on SsrStreamError.cause extraction.
|
|
49
|
+
*
|
|
50
|
+
* NOT used for page-level error detection (that would break Suspense
|
|
51
|
+
* error isolation). Only consumed when SSR actually fails.
|
|
52
|
+
*/
|
|
53
|
+
lastUnhandledError: unknown | null;
|
|
44
54
|
/** Callback fired when a redirect or deny signal is captured in onError. */
|
|
45
55
|
onSignal?: () => void;
|
|
46
56
|
}
|
|
@@ -68,6 +78,7 @@ export function renderRscStream(element: React.ReactElement, req: Request): RscS
|
|
|
68
78
|
denySignal: null,
|
|
69
79
|
redirectSignal: null,
|
|
70
80
|
renderError: null,
|
|
81
|
+
lastUnhandledError: null,
|
|
71
82
|
};
|
|
72
83
|
|
|
73
84
|
let rscStream: ReadableStream<Uint8Array> | undefined;
|
|
@@ -150,6 +161,11 @@ export function renderRscStream(element: React.ReactElement, req: Request): RscS
|
|
|
150
161
|
// Only track as renderError if no Suspense boundary contains it —
|
|
151
162
|
// React will call onShellError for truly unrecoverable errors.
|
|
152
163
|
|
|
164
|
+
// Track the last unhandled error so the pipeline can use it
|
|
165
|
+
// if SSR fails (SsrStreamError). This is NOT used for page-level
|
|
166
|
+
// error detection — only as a fallback when SSR actually fails.
|
|
167
|
+
signals.lastUnhandledError = error;
|
|
168
|
+
|
|
153
169
|
// Return a digest so React emits a per-row error in the Flight
|
|
154
170
|
// stream instead of leaving the lazy reference unresolved.
|
|
155
171
|
//
|
|
@@ -337,10 +337,13 @@ export async function renderSsrResponse(opts: SsrRenderOptions): Promise<Respons
|
|
|
337
337
|
);
|
|
338
338
|
}
|
|
339
339
|
// No captured signal — unhandled error in the RSC stream.
|
|
340
|
-
//
|
|
341
|
-
|
|
340
|
+
// Use the lastUnhandledError from RSC onError if available (more
|
|
341
|
+
// reliable than extracting SsrStreamError.cause). Falls back to
|
|
342
|
+
// cause extraction for backward compatibility.
|
|
343
|
+
const originalError =
|
|
344
|
+
signals.lastUnhandledError ?? (ssrError as { cause?: unknown }).cause ?? ssrError;
|
|
342
345
|
return renderErrorPage(
|
|
343
|
-
|
|
346
|
+
originalError,
|
|
344
347
|
500,
|
|
345
348
|
segments,
|
|
346
349
|
layoutComponents as LayoutEntry[],
|