@timber-js/app 0.1.20 → 0.1.22

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 (137) hide show
  1. package/dist/_chunks/als-registry-c0AGnbqS.js +39 -0
  2. package/dist/_chunks/als-registry-c0AGnbqS.js.map +1 -0
  3. package/dist/_chunks/{interception-c-a3uODY.js → interception-DGDIjDbR.js} +10 -3
  4. package/dist/_chunks/interception-DGDIjDbR.js.map +1 -0
  5. package/dist/_chunks/{metadata-routes-BDnswgRO.js → metadata-routes-CQCnF4VK.js} +14 -2
  6. package/dist/_chunks/metadata-routes-CQCnF4VK.js.map +1 -0
  7. package/dist/_chunks/{request-context-BzES06i1.js → request-context-C69VW4xS.js} +2 -4
  8. package/dist/_chunks/request-context-C69VW4xS.js.map +1 -0
  9. package/dist/_chunks/ssr-data-B2yikEEB.js +90 -0
  10. package/dist/_chunks/ssr-data-B2yikEEB.js.map +1 -0
  11. package/dist/_chunks/{tracing-BtOwb8O6.js → tracing-tIvqStk8.js} +2 -3
  12. package/dist/_chunks/tracing-tIvqStk8.js.map +1 -0
  13. package/dist/_chunks/{use-cookie-D2cZu0jK.js → use-cookie-D5aS4slY.js} +2 -2
  14. package/dist/_chunks/{use-cookie-D2cZu0jK.js.map → use-cookie-D5aS4slY.js.map} +1 -1
  15. package/dist/_chunks/{use-query-states-wEXY2JQB.js → use-query-states-DAhgj8Gx.js} +1 -1
  16. package/dist/_chunks/{use-query-states-wEXY2JQB.js.map → use-query-states-DAhgj8Gx.js.map} +1 -1
  17. package/dist/cache/index.js +2 -1
  18. package/dist/cache/index.js.map +1 -1
  19. package/dist/client/error-boundary.js +1 -1
  20. package/dist/client/index.d.ts +1 -1
  21. package/dist/client/index.d.ts.map +1 -1
  22. package/dist/client/index.js +40 -26
  23. package/dist/client/index.js.map +1 -1
  24. package/dist/client/router-ref.d.ts.map +1 -1
  25. package/dist/client/router.d.ts.map +1 -1
  26. package/dist/client/ssr-data.d.ts +3 -0
  27. package/dist/client/ssr-data.d.ts.map +1 -1
  28. package/dist/client/state.d.ts +47 -0
  29. package/dist/client/state.d.ts.map +1 -0
  30. package/dist/client/types.d.ts +10 -1
  31. package/dist/client/types.d.ts.map +1 -1
  32. package/dist/client/unload-guard.d.ts +3 -0
  33. package/dist/client/unload-guard.d.ts.map +1 -1
  34. package/dist/client/use-params.d.ts +19 -6
  35. package/dist/client/use-params.d.ts.map +1 -1
  36. package/dist/client/use-search-params.d.ts +3 -0
  37. package/dist/client/use-search-params.d.ts.map +1 -1
  38. package/dist/cookies/index.js +4 -2
  39. package/dist/cookies/index.js.map +1 -1
  40. package/dist/index.js +4 -1
  41. package/dist/index.js.map +1 -1
  42. package/dist/plugins/shims.d.ts.map +1 -1
  43. package/dist/routing/index.js +1 -1
  44. package/dist/routing/scanner.d.ts.map +1 -1
  45. package/dist/rsc-runtime/browser.d.ts +13 -0
  46. package/dist/rsc-runtime/browser.d.ts.map +1 -0
  47. package/dist/rsc-runtime/rsc.d.ts +14 -0
  48. package/dist/rsc-runtime/rsc.d.ts.map +1 -0
  49. package/dist/rsc-runtime/ssr.d.ts +13 -0
  50. package/dist/rsc-runtime/ssr.d.ts.map +1 -0
  51. package/dist/search-params/builtin-codecs.d.ts +105 -0
  52. package/dist/search-params/builtin-codecs.d.ts.map +1 -0
  53. package/dist/search-params/index.d.ts +1 -0
  54. package/dist/search-params/index.d.ts.map +1 -1
  55. package/dist/search-params/index.js +167 -2
  56. package/dist/search-params/index.js.map +1 -1
  57. package/dist/server/actions.d.ts +2 -7
  58. package/dist/server/actions.d.ts.map +1 -1
  59. package/dist/server/als-registry.d.ts +80 -0
  60. package/dist/server/als-registry.d.ts.map +1 -0
  61. package/dist/server/early-hints-sender.d.ts.map +1 -1
  62. package/dist/server/form-flash.d.ts.map +1 -1
  63. package/dist/server/index.d.ts +1 -0
  64. package/dist/server/index.d.ts.map +1 -1
  65. package/dist/server/index.js +242 -76
  66. package/dist/server/index.js.map +1 -1
  67. package/dist/server/metadata-routes.d.ts +27 -0
  68. package/dist/server/metadata-routes.d.ts.map +1 -1
  69. package/dist/server/pipeline.d.ts +7 -0
  70. package/dist/server/pipeline.d.ts.map +1 -1
  71. package/dist/server/primitives.d.ts +14 -6
  72. package/dist/server/primitives.d.ts.map +1 -1
  73. package/dist/server/request-context.d.ts +2 -32
  74. package/dist/server/request-context.d.ts.map +1 -1
  75. package/dist/server/route-matcher.d.ts +5 -0
  76. package/dist/server/route-matcher.d.ts.map +1 -1
  77. package/dist/server/rsc-entry/index.d.ts.map +1 -1
  78. package/dist/server/rsc-entry/rsc-payload.d.ts +25 -0
  79. package/dist/server/rsc-entry/rsc-payload.d.ts.map +1 -0
  80. package/dist/server/rsc-entry/rsc-stream.d.ts +43 -0
  81. package/dist/server/rsc-entry/rsc-stream.d.ts.map +1 -0
  82. package/dist/server/rsc-entry/ssr-renderer.d.ts +52 -0
  83. package/dist/server/rsc-entry/ssr-renderer.d.ts.map +1 -0
  84. package/dist/server/rsc-prop-warnings.d.ts +53 -0
  85. package/dist/server/rsc-prop-warnings.d.ts.map +1 -0
  86. package/dist/server/server-timing.d.ts +49 -0
  87. package/dist/server/server-timing.d.ts.map +1 -0
  88. package/dist/server/tracing.d.ts +2 -6
  89. package/dist/server/tracing.d.ts.map +1 -1
  90. package/dist/server/types.d.ts +11 -0
  91. package/dist/server/types.d.ts.map +1 -1
  92. package/package.json +1 -1
  93. package/src/client/browser-entry.ts +1 -1
  94. package/src/client/index.ts +1 -1
  95. package/src/client/router-ref.ts +6 -12
  96. package/src/client/router.ts +14 -4
  97. package/src/client/ssr-data.ts +25 -9
  98. package/src/client/state.ts +83 -0
  99. package/src/client/types.ts +18 -1
  100. package/src/client/unload-guard.ts +6 -3
  101. package/src/client/use-params.ts +42 -32
  102. package/src/client/use-search-params.ts +9 -5
  103. package/src/plugins/shims.ts +26 -2
  104. package/src/routing/scanner.ts +18 -2
  105. package/src/rsc-runtime/browser.ts +18 -0
  106. package/src/rsc-runtime/rsc.ts +19 -0
  107. package/src/rsc-runtime/ssr.ts +13 -0
  108. package/src/search-params/builtin-codecs.ts +228 -0
  109. package/src/search-params/index.ts +11 -0
  110. package/src/server/action-handler.ts +1 -1
  111. package/src/server/actions.ts +4 -10
  112. package/src/server/als-registry.ts +116 -0
  113. package/src/server/deny-renderer.ts +1 -1
  114. package/src/server/early-hints-sender.ts +1 -3
  115. package/src/server/form-flash.ts +1 -5
  116. package/src/server/index.ts +1 -0
  117. package/src/server/metadata-routes.ts +61 -0
  118. package/src/server/pipeline.ts +164 -38
  119. package/src/server/primitives.ts +110 -6
  120. package/src/server/request-context.ts +8 -36
  121. package/src/server/route-matcher.ts +25 -2
  122. package/src/server/rsc-entry/error-renderer.ts +1 -1
  123. package/src/server/rsc-entry/index.ts +42 -380
  124. package/src/server/rsc-entry/rsc-payload.ts +126 -0
  125. package/src/server/rsc-entry/rsc-stream.ts +162 -0
  126. package/src/server/rsc-entry/ssr-renderer.ts +228 -0
  127. package/src/server/rsc-prop-warnings.ts +187 -0
  128. package/src/server/server-timing.ts +132 -0
  129. package/src/server/ssr-entry.ts +1 -1
  130. package/src/server/tracing.ts +3 -11
  131. package/src/server/types.ts +16 -0
  132. package/dist/_chunks/interception-c-a3uODY.js.map +0 -1
  133. package/dist/_chunks/metadata-routes-BDnswgRO.js.map +0 -1
  134. package/dist/_chunks/request-context-BzES06i1.js.map +0 -1
  135. package/dist/_chunks/ssr-data-BgSwMbN9.js +0 -38
  136. package/dist/_chunks/ssr-data-BgSwMbN9.js.map +0 -1
  137. package/dist/_chunks/tracing-BtOwb8O6.js.map +0 -1
@@ -11,9 +11,15 @@
11
11
  * and design/17-logging.md §"Production Logging"
12
12
  */
13
13
 
14
+ import { readFile } from 'node:fs/promises';
14
15
  import { canonicalize } from './canonicalize.js';
15
16
  import { runProxy, type ProxyExport } from './proxy.js';
16
17
  import { runMiddleware, type MiddlewareFn } from './middleware-runner.js';
18
+ import {
19
+ runWithTimingCollector,
20
+ withTiming,
21
+ getServerTimingHeader,
22
+ } from './server-timing.js';
17
23
  import {
18
24
  runWithRequestContext,
19
25
  applyRequestHeaderOverlay,
@@ -113,6 +119,13 @@ export interface PipelineConfig {
113
119
  * See design/07-routing.md §"Intercepting Routes"
114
120
  */
115
121
  interceptionRewrites?: import('#/routing/interception.js').InterceptionRewrite[];
122
+ /**
123
+ * Emit Server-Timing header on responses for Chrome DevTools visibility.
124
+ * Only enable in dev mode — exposes internal timing data.
125
+ *
126
+ * Default: false (production-safe).
127
+ */
128
+ enableServerTiming?: boolean;
116
129
  /**
117
130
  * Dev pipeline error callback — called when a pipeline phase (proxy,
118
131
  * middleware, render) catches an unhandled error. Used to wire the error
@@ -155,6 +168,7 @@ export function createPipeline(config: PipelineConfig): (req: Request) => Promis
155
168
  earlyHints,
156
169
  stripTrailingSlash = true,
157
170
  slowRequestMs = 3000,
171
+ enableServerTiming = false,
158
172
  onPipelineError,
159
173
  } = config;
160
174
 
@@ -173,43 +187,65 @@ export function createPipeline(config: PipelineConfig): (req: Request) => Promis
173
187
  // Establish request context ALS scope so headers() and cookies() work
174
188
  // throughout the entire request lifecycle (proxy, middleware, render).
175
189
  return runWithRequestContext(req, async () => {
176
- logRequestReceived({ method, path });
177
-
178
- const response = await withSpan(
179
- 'http.server.request',
180
- { 'http.request.method': method, 'url.path': path },
181
- async () => {
182
- // If OTEL is active, the root span now exists — replace the UUID
183
- // fallback with the real OTEL trace ID for log–trace correlation.
184
- const otelIds = await getOtelTraceId();
185
- if (otelIds) {
186
- replaceTraceId(otelIds.traceId, otelIds.spanId);
190
+ // In dev mode, wrap with timing collector for Server-Timing header.
191
+ // The collector uses ALS so timing entries are per-request.
192
+ const runRequest = async () => {
193
+ logRequestReceived({ method, path });
194
+
195
+ const response = await withSpan(
196
+ 'http.server.request',
197
+ { 'http.request.method': method, 'url.path': path },
198
+ async () => {
199
+ // If OTEL is active, the root span now exists — replace the UUID
200
+ // fallback with the real OTEL trace ID for log–trace correlation.
201
+ const otelIds = await getOtelTraceId();
202
+ if (otelIds) {
203
+ replaceTraceId(otelIds.traceId, otelIds.spanId);
204
+ }
205
+
206
+ let result: Response;
207
+ if (proxy || config.proxyLoader) {
208
+ result = await runProxyPhase(req, method, path);
209
+ } else {
210
+ result = await handleRequest(req, method, path);
211
+ }
212
+
213
+ // Set response status on the root span before it ends —
214
+ // DevSpanProcessor reads this for tree/summary output.
215
+ await setSpanAttribute('http.response.status_code', result.status);
216
+
217
+ // Append Server-Timing header in dev mode.
218
+ // At this point, pre-flush phases (proxy, middleware, render)
219
+ // have completed and their timing entries are collected.
220
+ // Response.redirect() creates immutable headers, so we must
221
+ // ensure mutability before writing Server-Timing.
222
+ if (enableServerTiming) {
223
+ const serverTiming = getServerTimingHeader();
224
+ if (serverTiming) {
225
+ result = ensureMutableResponse(result);
226
+ result.headers.set('Server-Timing', serverTiming);
227
+ }
228
+ }
229
+
230
+ return result;
187
231
  }
232
+ );
188
233
 
189
- let result: Response;
190
- if (proxy || config.proxyLoader) {
191
- result = await runProxyPhase(req, method, path);
192
- } else {
193
- result = await handleRequest(req, method, path);
194
- }
234
+ // Post-span: structured production logging
235
+ const durationMs = Math.round(performance.now() - startTime);
236
+ const status = response.status;
237
+ logRequestCompleted({ method, path, status, durationMs });
195
238
 
196
- // Set response status on the root span before it ends —
197
- // DevSpanProcessor reads this for tree/summary output.
198
- await setSpanAttribute('http.response.status_code', result.status);
199
- return result;
239
+ if (slowRequestMs > 0 && durationMs > slowRequestMs) {
240
+ logSlowRequest({ method, path, durationMs, threshold: slowRequestMs });
200
241
  }
201
- );
202
242
 
203
- // Post-span: structured production logging
204
- const durationMs = Math.round(performance.now() - startTime);
205
- const status = response.status;
206
- logRequestCompleted({ method, path, status, durationMs });
207
-
208
- if (slowRequestMs > 0 && durationMs > slowRequestMs) {
209
- logSlowRequest({ method, path, durationMs, threshold: slowRequestMs });
210
- }
243
+ return response;
244
+ };
211
245
 
212
- return response;
246
+ return enableServerTiming
247
+ ? runWithTimingCollector(runRequest)
248
+ : runRequest();
213
249
  });
214
250
  });
215
251
  };
@@ -225,8 +261,12 @@ export function createPipeline(config: PipelineConfig): (req: Request) => Promis
225
261
  } else {
226
262
  proxyExport = config.proxy!;
227
263
  }
264
+ const proxyFn = () =>
265
+ runProxy(proxyExport, req, () => handleRequest(req, method, path));
228
266
  return await withSpan('timber.proxy', {}, () =>
229
- runProxy(proxyExport, req, () => handleRequest(req, method, path))
267
+ enableServerTiming
268
+ ? withTiming('proxy', 'proxy.ts', proxyFn)
269
+ : proxyFn()
230
270
  );
231
271
  } catch (error) {
232
272
  // Uncaught proxy.ts error → bare HTTP 500
@@ -253,6 +293,13 @@ export function createPipeline(config: PipelineConfig): (req: Request) => Promis
253
293
  const metaMatch = config.matchMetadataRoute(canonicalPathname);
254
294
  if (metaMatch) {
255
295
  try {
296
+ // Static metadata files (.xml, .txt, .png, .ico, etc.) are served
297
+ // directly from disk. Dynamic metadata routes (.ts, .tsx) export a
298
+ // handler function that generates the response.
299
+ if (metaMatch.isStatic) {
300
+ return await serveStaticMetadataFile(metaMatch);
301
+ }
302
+
256
303
  const mod = (await metaMatch.file.load()) as { default?: Function };
257
304
  if (typeof mod.default !== 'function') {
258
305
  return new Response('Metadata route must export a default function', { status: 500 });
@@ -360,15 +407,21 @@ export function createPipeline(config: PipelineConfig): (req: Request) => Promis
360
407
  try {
361
408
  // Enable cookie mutation during middleware (design/29-cookies.md §"Context Tracking")
362
409
  setMutableCookieContext(true);
410
+ const middlewareFn = () => runMiddleware(match.middleware!, ctx);
363
411
  const middlewareResponse = await withSpan('timber.middleware', {}, () =>
364
- runMiddleware(match.middleware!, ctx)
412
+ enableServerTiming
413
+ ? withTiming('mw', 'middleware.ts', middlewareFn)
414
+ : middlewareFn()
365
415
  );
366
416
  setMutableCookieContext(false);
367
417
  if (middlewareResponse) {
368
- // Apply cookie jar to short-circuit response
369
- applyCookieJar(middlewareResponse.headers);
370
- logMiddlewareShortCircuit({ method, path, status: middlewareResponse.status });
371
- return middlewareResponse;
418
+ // Apply cookie jar to short-circuit response.
419
+ // Response.redirect() creates immutable headers, so ensure
420
+ // mutability before appending Set-Cookie entries.
421
+ const finalResponse = ensureMutableResponse(middlewareResponse);
422
+ applyCookieJar(finalResponse.headers);
423
+ logMiddlewareShortCircuit({ method, path, status: finalResponse.status });
424
+ return finalResponse;
372
425
  }
373
426
  // Middleware succeeded without short-circuiting — apply any
374
427
  // injected request headers so headers() returns them downstream.
@@ -410,8 +463,12 @@ export function createPipeline(config: PipelineConfig): (req: Request) => Promis
410
463
 
411
464
  // Stage 4: Render (access gates + element tree + renderToReadableStream)
412
465
  try {
466
+ const renderFn = () =>
467
+ render(req, match, responseHeaders, requestHeaderOverlay, interception);
413
468
  const response = await withSpan('timber.render', { 'http.route': canonicalPathname }, () =>
414
- render(req, match, responseHeaders, requestHeaderOverlay, interception)
469
+ enableServerTiming
470
+ ? withTiming('render', 'RSC + SSR render', renderFn)
471
+ : renderFn()
415
472
  );
416
473
  markResponseFlushed();
417
474
  return response;
@@ -533,6 +590,36 @@ function applyCookieJar(headers: Headers): void {
533
590
  }
534
591
  }
535
592
 
593
+ // ─── Immutable Response Helpers ──────────────────────────────────────────
594
+
595
+ /**
596
+ * Ensure a Response has mutable headers so the pipeline can safely append
597
+ * Set-Cookie and Server-Timing entries.
598
+ *
599
+ * `Response.redirect()` and some platform-level responses return objects
600
+ * with immutable headers. Calling `.set()` or `.append()` on them throws
601
+ * `TypeError: immutable`. This helper detects the immutable case by
602
+ * attempting a no-op write and, on failure, clones into a fresh Response
603
+ * with mutable headers.
604
+ */
605
+ function ensureMutableResponse(response: Response): Response {
606
+ try {
607
+ // Probe mutability with a benign operation that we immediately undo.
608
+ // We pick a header name that is extremely unlikely to collide with
609
+ // anything meaningful and delete it right away.
610
+ response.headers.set('X-Timber-Probe', '1');
611
+ response.headers.delete('X-Timber-Probe');
612
+ return response;
613
+ } catch {
614
+ // Headers are immutable — rebuild with mutable headers.
615
+ return new Response(response.body, {
616
+ status: response.status,
617
+ statusText: response.statusText,
618
+ headers: new Headers(response.headers),
619
+ });
620
+ }
621
+ }
622
+
536
623
  // ─── Metadata Route Helpers ──────────────────────────────────────────────
537
624
 
538
625
  /**
@@ -577,3 +664,42 @@ function escapeXml(str: string): string {
577
664
  .replace(/"/g, '"')
578
665
  .replace(/'/g, ''');
579
666
  }
667
+
668
+ // ─── Static Metadata File Serving ────────────────────────────────────────
669
+
670
+ /**
671
+ * Content types that are text-based and should include charset=utf-8.
672
+ * Binary formats (images) should not include charset.
673
+ */
674
+ const TEXT_CONTENT_TYPES = new Set([
675
+ 'application/xml',
676
+ 'text/plain',
677
+ 'application/json',
678
+ 'application/manifest+json',
679
+ 'image/svg+xml',
680
+ ]);
681
+
682
+ /**
683
+ * Serve a static metadata file by reading it from disk.
684
+ *
685
+ * Static metadata route files (.xml, .txt, .json, .png, .ico, .svg, etc.)
686
+ * are served as-is with the appropriate Content-Type header.
687
+ * Text files include charset=utf-8; binary files do not.
688
+ *
689
+ * See design/16-metadata.md §"Metadata Routes"
690
+ */
691
+ async function serveStaticMetadataFile(
692
+ metaMatch: import('./route-matcher.js').MetadataRouteMatch
693
+ ): Promise<Response> {
694
+ const { contentType, file } = metaMatch;
695
+ const isText = TEXT_CONTENT_TYPES.has(contentType);
696
+
697
+ const body = await readFile(file.filePath);
698
+
699
+ const headers: Record<string, string> = {
700
+ 'Content-Type': isText ? `${contentType}; charset=utf-8` : contentType,
701
+ 'Content-Length': String(body.byteLength),
702
+ };
703
+
704
+ return new Response(body, { status: 200, headers });
705
+ }
@@ -3,6 +3,101 @@
3
3
  // These are the core runtime signals that components, middleware, and access gates
4
4
  // use to control request flow. See design/10-error-handling.md.
5
5
 
6
+ import type { JsonSerializable } from './types.js';
7
+
8
+ // ─── Dev-mode validation ────────────────────────────────────────────────────
9
+
10
+ /**
11
+ * Check if a value is JSON-serializable without data loss.
12
+ * Returns a description of the first non-serializable value found, or null if OK.
13
+ *
14
+ * @internal Exported for testing only.
15
+ */
16
+ export function findNonSerializable(value: unknown, path = 'data'): string | null {
17
+ if (value === null || value === undefined) return null;
18
+
19
+ switch (typeof value) {
20
+ case 'string':
21
+ case 'number':
22
+ case 'boolean':
23
+ return null;
24
+ case 'bigint':
25
+ return `${path} contains a BigInt — BigInt throws in JSON.stringify`;
26
+ case 'function':
27
+ return `${path} is a function — functions are not JSON-serializable`;
28
+ case 'symbol':
29
+ return `${path} is a symbol — symbols are not JSON-serializable`;
30
+ case 'object':
31
+ break;
32
+ default:
33
+ return `${path} has unsupported type "${typeof value}"`;
34
+ }
35
+
36
+ if (value instanceof Date) {
37
+ return `${path} is a Date — Dates silently coerce to strings in JSON.stringify`;
38
+ }
39
+ if (value instanceof Map) {
40
+ return `${path} is a Map — Maps serialize as {} in JSON.stringify (data loss)`;
41
+ }
42
+ if (value instanceof Set) {
43
+ return `${path} is a Set — Sets serialize as {} in JSON.stringify (data loss)`;
44
+ }
45
+ if (value instanceof RegExp) {
46
+ return `${path} is a RegExp — RegExps serialize as {} in JSON.stringify`;
47
+ }
48
+ if (value instanceof Error) {
49
+ return `${path} is an Error — Errors serialize as {} in JSON.stringify`;
50
+ }
51
+
52
+ if (Array.isArray(value)) {
53
+ for (let i = 0; i < value.length; i++) {
54
+ const result = findNonSerializable(value[i], `${path}[${i}]`);
55
+ if (result) return result;
56
+ }
57
+ return null;
58
+ }
59
+
60
+ // Plain object — only Object.prototype is safe. Null-prototype objects
61
+ // (Object.create(null)) survive JSON.stringify but React Flight rejects
62
+ // them with "Classes or null prototypes are not supported", so the
63
+ // pre-flush deny path (renderDenyPage → renderToReadableStream) would throw.
64
+ const proto = Object.getPrototypeOf(value);
65
+ if (proto === null) {
66
+ return `${path} is a null-prototype object — React Flight rejects null prototypes`;
67
+ }
68
+ if (proto !== Object.prototype) {
69
+ const name = (value as object).constructor?.name ?? 'unknown';
70
+ return `${path} is a ${name} instance — class instances may lose data in JSON.stringify`;
71
+ }
72
+
73
+ for (const key of Object.keys(value as Record<string, unknown>)) {
74
+ const result = findNonSerializable(
75
+ (value as Record<string, unknown>)[key],
76
+ `${path}.${key}`
77
+ );
78
+ if (result) return result;
79
+ }
80
+ return null;
81
+ }
82
+
83
+ /**
84
+ * Emit a dev-mode warning if data is not JSON-serializable.
85
+ * No-op in production.
86
+ */
87
+ function warnIfNotSerializable(data: unknown, callerName: string): void {
88
+ if (process.env.NODE_ENV === 'production') return;
89
+ if (data === undefined) return;
90
+
91
+ const issue = findNonSerializable(data);
92
+ if (issue) {
93
+ console.warn(
94
+ `[timber] ${callerName}: ${issue}. ` +
95
+ 'Data passed to deny() or RenderError must be JSON-serializable because ' +
96
+ 'the post-flush path uses JSON.stringify, not React Flight.'
97
+ );
98
+ }
99
+ }
100
+
6
101
  // ─── DenySignal ─────────────────────────────────────────────────────────────
7
102
 
8
103
  /**
@@ -11,9 +106,9 @@
11
106
  */
12
107
  export class DenySignal extends Error {
13
108
  readonly status: number;
14
- readonly data: unknown;
109
+ readonly data: JsonSerializable | undefined;
15
110
 
16
- constructor(status: number, data?: unknown) {
111
+ constructor(status: number, data?: JsonSerializable) {
17
112
  super(`Access denied with status ${status}`);
18
113
  this.name = 'DenySignal';
19
114
  this.status = status;
@@ -58,15 +153,16 @@ export class DenySignal extends Error {
58
153
  * - Inside Suspense (after flush): error boundary + noindex meta
59
154
  *
60
155
  * @param status - Any 4xx HTTP status code. Defaults to 403.
61
- * @param data - Optional data passed as `dangerouslyPassData` prop to status-code files.
156
+ * @param data - Optional JSON-serializable data passed as `dangerouslyPassData` prop to status-code files.
62
157
  */
63
- export function deny(status: number = 403, data?: unknown): never {
158
+ export function deny(status: number = 403, data?: JsonSerializable): never {
64
159
  if (status < 400 || status > 499) {
65
160
  throw new Error(
66
161
  `deny() requires a 4xx status code, got ${status}. ` +
67
162
  'For 5xx errors, throw a RenderError instead.'
68
163
  );
69
164
  }
165
+ warnIfNotSerializable(data, 'deny()');
70
166
  throw new DenySignal(status, data);
71
167
  }
72
168
 
@@ -181,7 +277,10 @@ export function redirectExternal(url: string, allowList: string[], status: numbe
181
277
  * Typed digest that crosses the RSC → client boundary.
182
278
  * The `code` identifies the error class; `data` carries JSON-serializable context.
183
279
  */
184
- export interface RenderErrorDigest<TCode extends string = string, TData = unknown> {
280
+ export interface RenderErrorDigest<
281
+ TCode extends string = string,
282
+ TData extends JsonSerializable = JsonSerializable,
283
+ > {
185
284
  code: TCode;
186
285
  data: TData;
187
286
  }
@@ -200,7 +299,10 @@ export interface RenderErrorDigest<TCode extends string = string, TData = unknow
200
299
  * })
201
300
  * ```
202
301
  */
203
- export class RenderError<TCode extends string = string, TData = unknown> extends Error {
302
+ export class RenderError<
303
+ TCode extends string = string,
304
+ TData extends JsonSerializable = JsonSerializable,
305
+ > extends Error {
204
306
  readonly code: TCode;
205
307
  readonly digest: RenderErrorDigest<TCode, TData>;
206
308
  readonly status: number;
@@ -211,6 +313,8 @@ export class RenderError<TCode extends string = string, TData = unknown> extends
211
313
  this.code = code;
212
314
  this.digest = { code, data };
213
315
 
316
+ warnIfNotSerializable(data, 'RenderError');
317
+
214
318
  const status = options?.status ?? 500;
215
319
  if (status < 400 || status > 599) {
216
320
  throw new Error(`RenderError status must be 4xx or 5xx, got ${status}.`);
@@ -10,44 +10,16 @@
10
10
  * See design/29-cookies.md for cookie mutation semantics.
11
11
  */
12
12
 
13
- import { AsyncLocalStorage } from 'node:async_hooks';
14
13
  import { createHmac, timingSafeEqual } from 'node:crypto';
15
14
  import type { Routes } from '#/index.js';
16
-
17
- // ─── ALS Store ────────────────────────────────────────────────────────────
18
-
19
- interface RequestContextStore {
20
- /** Incoming request headers (read-only view). */
21
- headers: Headers;
22
- /** Raw cookie header string, parsed lazily into a Map on first access. */
23
- cookieHeader: string;
24
- /** Lazily-parsed cookie map (mutable — reflects write-overlay from set()). */
25
- parsedCookies?: Map<string, string>;
26
- /** Original (pre-overlay) frozen headers, kept for overlay merging. */
27
- originalHeaders: Headers;
28
- /**
29
- * Promise resolving to the route's typed search params (when search-params.ts
30
- * exists) or to the raw URLSearchParams. Stored as a Promise so the framework
31
- * can later support partial pre-rendering where param resolution is deferred.
32
- */
33
- searchParamsPromise: Promise<URLSearchParams | Record<string, unknown>>;
34
- /** Outgoing Set-Cookie entries (name → serialized value + options). Last write wins. */
35
- cookieJar: Map<string, CookieEntry>;
36
- /** Whether the response has flushed (headers committed). */
37
- flushed: boolean;
38
- /** Whether the current context allows cookie mutation. */
39
- mutableContext: boolean;
40
- }
41
-
42
- /** A single outgoing cookie entry in the cookie jar. */
43
- interface CookieEntry {
44
- name: string;
45
- value: string;
46
- options: CookieOptions;
47
- }
48
-
49
- /** @internal */
50
- export const requestContextAls = new AsyncLocalStorage<RequestContextStore>();
15
+ import {
16
+ requestContextAls,
17
+ type RequestContextStore,
18
+ type CookieEntry,
19
+ } from './als-registry.js';
20
+
21
+ // Re-export the ALS for framework-internal consumers that need direct access.
22
+ export { requestContextAls };
51
23
 
52
24
  // No fallback needed — we use enterWith() instead of run() to ensure
53
25
  // the ALS context persists for the entire request lifecycle including
@@ -10,7 +10,12 @@
10
10
 
11
11
  import type { RouteMatch } from './pipeline.js';
12
12
  import type { MiddlewareFn } from './middleware-runner.js';
13
- import { METADATA_ROUTE_CONVENTIONS, type MetadataRouteType } from './metadata-routes.js';
13
+ import {
14
+ METADATA_ROUTE_CONVENTIONS,
15
+ isStaticMetadataExtension,
16
+ resolveStaticContentType,
17
+ type MetadataRouteType,
18
+ } from './metadata-routes.js';
14
19
 
15
20
  // ─── Manifest Types ───────────────────────────────────────────────────────
16
21
  // The virtual module manifest has a slightly different shape than SegmentNode:
@@ -292,6 +297,11 @@ export interface MetadataRouteMatch {
292
297
  file: ManifestFile;
293
298
  /** The matched segment (for context/params if needed). */
294
299
  segment: ManifestSegmentNode;
300
+ /**
301
+ * Whether this is a static file (e.g. sitemap.xml, favicon.ico, icon.png)
302
+ * that should be served from disk rather than executed as a handler.
303
+ */
304
+ isStatic: boolean;
295
305
  }
296
306
 
297
307
  /**
@@ -331,11 +341,24 @@ function collectMetadataRoutes(
331
341
  const prefix = node.urlPath === '/' ? '' : node.urlPath;
332
342
  const pathname = `${prefix}/${convention.servePath}`;
333
343
 
344
+ // Determine if this is a static file based on its extension.
345
+ // Static files (.xml, .txt, .png, .ico, etc.) are served from disk.
346
+ // Dynamic files (.ts, .tsx) export a handler function.
347
+ const ext = file.filePath.slice(file.filePath.lastIndexOf('.') + 1);
348
+ const isStatic = isStaticMetadataExtension(baseName, ext);
349
+
350
+ // Resolve generic content types (e.g. image/*) to concrete MIME types
351
+ // based on the file extension for static files.
352
+ const contentType = isStatic
353
+ ? resolveStaticContentType(convention.contentType, ext)
354
+ : convention.contentType;
355
+
334
356
  map.set(pathname, {
335
357
  type: convention.type,
336
- contentType: convention.contentType,
358
+ contentType,
337
359
  file,
338
360
  segment: node,
361
+ isStatic,
339
362
  });
340
363
  }
341
364
  }
@@ -5,7 +5,7 @@
5
5
  */
6
6
 
7
7
  import { createElement } from 'react';
8
- import { renderToReadableStream } from '@vitejs/plugin-rsc/rsc';
8
+ import { renderToReadableStream } from '#/rsc-runtime/rsc.js';
9
9
 
10
10
  import type { RouteMatch } from '#/server/pipeline.js';
11
11
  import { logRenderError } from '#/server/logger.js';