@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.
Files changed (73) hide show
  1. package/LICENSE +8 -0
  2. package/dist/_chunks/{actions-YHRCboUO.js → actions-DLnUaR65.js} +2 -2
  3. package/dist/_chunks/{actions-YHRCboUO.js.map → actions-DLnUaR65.js.map} +1 -1
  4. package/dist/_chunks/{chunk-DYhsFzuS.js → chunk-BYIpzuS7.js} +7 -1
  5. package/dist/_chunks/{define-cookie-C9pquwOg.js → define-cookie-BowvzoP0.js} +4 -4
  6. package/dist/_chunks/{define-cookie-C9pquwOg.js.map → define-cookie-BowvzoP0.js.map} +1 -1
  7. package/dist/_chunks/{request-context-Dl0hXED3.js → request-context-CK5tZqIP.js} +2 -2
  8. package/dist/_chunks/{request-context-Dl0hXED3.js.map → request-context-CK5tZqIP.js.map} +1 -1
  9. package/dist/client/form.d.ts +4 -1
  10. package/dist/client/form.d.ts.map +1 -1
  11. package/dist/client/index.js +2 -2
  12. package/dist/client/index.js.map +1 -1
  13. package/dist/config-validation.d.ts +51 -0
  14. package/dist/config-validation.d.ts.map +1 -0
  15. package/dist/cookies/index.js +1 -1
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +1185 -51
  18. package/dist/index.js.map +1 -1
  19. package/dist/plugins/dev-404-page.d.ts +56 -0
  20. package/dist/plugins/dev-404-page.d.ts.map +1 -0
  21. package/dist/plugins/dev-error-overlay.d.ts +25 -11
  22. package/dist/plugins/dev-error-overlay.d.ts.map +1 -1
  23. package/dist/plugins/dev-error-page.d.ts +58 -0
  24. package/dist/plugins/dev-error-page.d.ts.map +1 -0
  25. package/dist/plugins/dev-server.d.ts.map +1 -1
  26. package/dist/plugins/dev-terminal-error.d.ts +28 -0
  27. package/dist/plugins/dev-terminal-error.d.ts.map +1 -0
  28. package/dist/plugins/entries.d.ts.map +1 -1
  29. package/dist/plugins/fonts.d.ts +4 -0
  30. package/dist/plugins/fonts.d.ts.map +1 -1
  31. package/dist/plugins/routing.d.ts.map +1 -1
  32. package/dist/routing/convention-lint.d.ts +41 -0
  33. package/dist/routing/convention-lint.d.ts.map +1 -0
  34. package/dist/server/action-client.d.ts +13 -5
  35. package/dist/server/action-client.d.ts.map +1 -1
  36. package/dist/server/dev-source-map.d.ts +22 -0
  37. package/dist/server/dev-source-map.d.ts.map +1 -0
  38. package/dist/server/fallback-error.d.ts +9 -5
  39. package/dist/server/fallback-error.d.ts.map +1 -1
  40. package/dist/server/index.js +2 -2
  41. package/dist/server/index.js.map +1 -1
  42. package/dist/server/internal.js +21 -4
  43. package/dist/server/internal.js.map +1 -1
  44. package/dist/server/pipeline.d.ts +10 -0
  45. package/dist/server/pipeline.d.ts.map +1 -1
  46. package/dist/server/rsc-entry/error-renderer.d.ts.map +1 -1
  47. package/dist/server/rsc-entry/index.d.ts +11 -0
  48. package/dist/server/rsc-entry/index.d.ts.map +1 -1
  49. package/dist/server/rsc-entry/rsc-stream.d.ts +10 -0
  50. package/dist/server/rsc-entry/rsc-stream.d.ts.map +1 -1
  51. package/dist/server/rsc-entry/ssr-renderer.d.ts.map +1 -1
  52. package/package.json +6 -7
  53. package/src/cli.ts +0 -0
  54. package/src/client/form.tsx +10 -5
  55. package/src/config-validation.ts +299 -0
  56. package/src/index.ts +17 -0
  57. package/src/plugins/dev-404-page.ts +418 -0
  58. package/src/plugins/dev-error-overlay.ts +185 -54
  59. package/src/plugins/dev-error-page.ts +536 -0
  60. package/src/plugins/dev-server.ts +76 -10
  61. package/src/plugins/dev-terminal-error.ts +217 -0
  62. package/src/plugins/entries.ts +3 -0
  63. package/src/plugins/fonts.ts +3 -2
  64. package/src/plugins/routing.ts +37 -5
  65. package/src/routing/convention-lint.ts +356 -0
  66. package/src/server/action-client.ts +17 -9
  67. package/src/server/dev-source-map.ts +31 -0
  68. package/src/server/fallback-error.ts +44 -88
  69. package/src/server/pipeline.ts +34 -4
  70. package/src/server/rsc-entry/error-renderer.ts +5 -0
  71. package/src/server/rsc-entry/index.ts +88 -2
  72. package/src/server/rsc-entry/rsc-stream.ts +16 -0
  73. 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<undefined, TData>;
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<TInput, TData>;
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
- export type ActionFn<TInput = unknown, TData = unknown> = {
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<TInput, TData> {
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<TInput, TData>;
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<TInput, TData> {
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<undefined, TData> {
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<TInput, TData> {
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 message and stack trace.
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
- * Returns an HTML Response that displays the error in a styled page.
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 title = err.name || 'Error';
86
- const message = escapeHtml(err.message);
87
- const stack = err.stack ? escapeHtml(err.stack) : '';
95
+ const root = projectRoot ?? process.cwd();
88
96
 
89
- const html = `<!DOCTYPE html>
90
- <html lang="en">
91
- <head>
92
- <meta charset="utf-8">
93
- <meta name="viewport" content="width=device-width, initial-scale=1">
94
- <title>500 ${escapeHtml(title)}</title>
95
- <script type="module" src="/@vite/client"></script>
96
- <style>
97
- *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
98
- body {
99
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
100
- background: #1a1a2e;
101
- color: #e0e0e0;
102
- padding: 2rem;
103
- line-height: 1.6;
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
- function escapeHtml(str: string): string {
169
- return str
170
- .replace(/&/g, '&amp;')
171
- .replace(/</g, '&lt;')
172
- .replace(/>/g, '&gt;')
173
- .replace(/"/g, '&quot;')
174
- .replace(/'/g, '&#x27;');
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
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
  }
@@ -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 → HTTP deny status
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
- return new Response(null, { status: error.status });
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
- // Return the deny status code instead of 500.
681
+ // Render the deny page with the correct status code.
659
682
  if (error instanceof DenySignal) {
660
- return new Response(null, { status: error.status });
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
- return renderNoMatchPage(req, manifest.root, responseHeaders, clientBootstrap);
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
- // Render the error page using the original error (cause).
341
- const cause = (ssrError as { cause?: unknown }).cause ?? ssrError;
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
- cause,
346
+ originalError,
344
347
  500,
345
348
  segments,
346
349
  layoutComponents as LayoutEntry[],