@timber-js/app 0.2.0-alpha.145 → 0.2.0-alpha.146

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 (149) hide show
  1. package/dist/_chunks/{actions-DzFMnUAR.js → actions-OIZ8siQu.js} +28 -43
  2. package/dist/_chunks/actions-OIZ8siQu.js.map +1 -0
  3. package/dist/_chunks/{handler-store-DjISKYEB.js → handler-store-BVDgyTh7.js} +26 -2
  4. package/dist/_chunks/handler-store-BVDgyTh7.js.map +1 -0
  5. package/dist/_chunks/{href-validation-EZktELp8.js → href-validation-DyZIzfMJ.js} +18 -2
  6. package/dist/_chunks/{href-validation-EZktELp8.js.map → href-validation-DyZIzfMJ.js.map} +1 -1
  7. package/dist/_chunks/{navigation-root-DLc4s4ZC.js → navigation-root-bUDZLw74.js} +23 -2
  8. package/dist/_chunks/navigation-root-bUDZLw74.js.map +1 -0
  9. package/dist/_chunks/tracing-M_zKubhk.js.map +1 -1
  10. package/dist/adapters/cloudflare.d.ts +1 -15
  11. package/dist/adapters/cloudflare.d.ts.map +1 -1
  12. package/dist/adapters/cloudflare.js +10 -28
  13. package/dist/adapters/cloudflare.js.map +1 -1
  14. package/dist/adapters/compress-module.d.ts.map +1 -1
  15. package/dist/adapters/nitro.d.ts.map +1 -1
  16. package/dist/adapters/nitro.js +18 -5
  17. package/dist/adapters/nitro.js.map +1 -1
  18. package/dist/cache/index.js +39 -12
  19. package/dist/cache/index.js.map +1 -1
  20. package/dist/cache/redis-handler.d.ts +72 -8
  21. package/dist/cache/redis-handler.d.ts.map +1 -1
  22. package/dist/cache/stable-stringify.d.ts +9 -0
  23. package/dist/cache/stable-stringify.d.ts.map +1 -1
  24. package/dist/cache/timber-cache.d.ts +3 -1
  25. package/dist/cache/timber-cache.d.ts.map +1 -1
  26. package/dist/client/browser-entry/action-dispatch.d.ts.map +1 -1
  27. package/dist/client/browser-entry/router-init.d.ts.map +1 -1
  28. package/dist/client/browser-entry/scroll.d.ts +9 -0
  29. package/dist/client/browser-entry/scroll.d.ts.map +1 -1
  30. package/dist/client/error-boundary.d.ts.map +1 -1
  31. package/dist/client/error-boundary.js +11 -6
  32. package/dist/client/error-boundary.js.map +1 -1
  33. package/dist/client/index.js +3 -3
  34. package/dist/client/index.js.map +1 -1
  35. package/dist/client/internal.js +99 -24
  36. package/dist/client/internal.js.map +1 -1
  37. package/dist/client/link.d.ts.map +1 -1
  38. package/dist/client/navigation-api.d.ts.map +1 -1
  39. package/dist/client/navigation-root.d.ts +13 -0
  40. package/dist/client/navigation-root.d.ts.map +1 -1
  41. package/dist/client/router.d.ts +7 -0
  42. package/dist/client/router.d.ts.map +1 -1
  43. package/dist/client/rsc-fetch.d.ts.map +1 -1
  44. package/dist/dev-tools/404-page.d.ts +6 -2
  45. package/dist/dev-tools/404-page.d.ts.map +1 -1
  46. package/dist/dev-tools/dev-page-shell.d.ts +77 -0
  47. package/dist/dev-tools/dev-page-shell.d.ts.map +1 -0
  48. package/dist/dev-tools/error-page.d.ts +4 -30
  49. package/dist/dev-tools/error-page.d.ts.map +1 -1
  50. package/dist/dev-tools/index.d.ts +2 -1
  51. package/dist/dev-tools/index.d.ts.map +1 -1
  52. package/dist/dev-tools/instrumentation.d.ts +11 -1
  53. package/dist/dev-tools/instrumentation.d.ts.map +1 -1
  54. package/dist/index.d.ts.map +1 -1
  55. package/dist/index.js +430 -253
  56. package/dist/index.js.map +1 -1
  57. package/dist/plugins/cache.d.ts +43 -9
  58. package/dist/plugins/cache.d.ts.map +1 -1
  59. package/dist/plugins/cloudflare-wasm.d.ts +4 -1
  60. package/dist/plugins/cloudflare-wasm.d.ts.map +1 -1
  61. package/dist/plugins/dev-server.d.ts +16 -0
  62. package/dist/plugins/dev-server.d.ts.map +1 -1
  63. package/dist/plugins/server-bundle.d.ts.map +1 -1
  64. package/dist/plugins/shims.d.ts +23 -0
  65. package/dist/plugins/shims.d.ts.map +1 -1
  66. package/dist/server/action-handler.d.ts.map +1 -1
  67. package/dist/server/actions.d.ts +3 -2
  68. package/dist/server/actions.d.ts.map +1 -1
  69. package/dist/server/cookie-context.d.ts.map +1 -1
  70. package/dist/server/cookie-parsing.d.ts.map +1 -1
  71. package/dist/server/fallback-error.d.ts +2 -2
  72. package/dist/server/fallback-error.d.ts.map +1 -1
  73. package/dist/server/index.js +1 -1
  74. package/dist/server/internal.js +1 -1
  75. package/dist/server/internal.js.map +1 -1
  76. package/dist/server/node-stream-transforms.d.ts +24 -6
  77. package/dist/server/node-stream-transforms.d.ts.map +1 -1
  78. package/dist/server/pipeline.d.ts +11 -0
  79. package/dist/server/pipeline.d.ts.map +1 -1
  80. package/dist/server/primitives.d.ts +1 -11
  81. package/dist/server/primitives.d.ts.map +1 -1
  82. package/dist/server/rsc-entry/index.d.ts.map +1 -1
  83. package/dist/server/rsc-entry/rsc-payload.d.ts.map +1 -1
  84. package/dist/server/slot-resolver.d.ts +6 -4
  85. package/dist/server/slot-resolver.d.ts.map +1 -1
  86. package/dist/server/ssr-entry.d.ts.map +1 -1
  87. package/dist/server/tracing.d.ts +2 -1
  88. package/dist/server/tracing.d.ts.map +1 -1
  89. package/dist/shared/head-header.d.ts +54 -0
  90. package/dist/shared/head-header.d.ts.map +1 -0
  91. package/dist/shared/href-validation.d.ts +11 -0
  92. package/dist/shared/href-validation.d.ts.map +1 -1
  93. package/dist/shared/redirect-type.d.ts +17 -0
  94. package/dist/shared/redirect-type.d.ts.map +1 -0
  95. package/dist/shims/headers.d.ts +52 -1
  96. package/dist/shims/headers.d.ts.map +1 -1
  97. package/dist/shims/navigation-client.d.ts +33 -11
  98. package/dist/shims/navigation-client.d.ts.map +1 -1
  99. package/package.json +10 -9
  100. package/src/adapters/cloudflare.ts +13 -47
  101. package/src/adapters/compress-module.ts +9 -3
  102. package/src/adapters/nitro.ts +9 -2
  103. package/src/cache/redis-handler.ts +77 -13
  104. package/src/cache/stable-stringify.ts +65 -2
  105. package/src/cache/timber-cache.ts +25 -13
  106. package/src/cli.ts +0 -0
  107. package/src/client/browser-entry/action-dispatch.ts +7 -2
  108. package/src/client/browser-entry/router-init.ts +2 -1
  109. package/src/client/browser-entry/scroll.ts +23 -0
  110. package/src/client/error-boundary.tsx +19 -5
  111. package/src/client/link.tsx +4 -1
  112. package/src/client/navigation-api.ts +5 -1
  113. package/src/client/navigation-root.tsx +16 -0
  114. package/src/client/router.ts +107 -29
  115. package/src/client/rsc-fetch.ts +6 -6
  116. package/src/dev-tools/404-page.ts +22 -123
  117. package/src/dev-tools/dev-page-shell.ts +256 -0
  118. package/src/dev-tools/error-page.ts +27 -203
  119. package/src/dev-tools/index.ts +7 -1
  120. package/src/dev-tools/instrumentation.ts +39 -2
  121. package/src/index.ts +1 -0
  122. package/src/plugins/cache.ts +79 -21
  123. package/src/plugins/cloudflare-wasm.ts +48 -10
  124. package/src/plugins/dev-server.ts +32 -8
  125. package/src/plugins/server-bundle.ts +25 -9
  126. package/src/plugins/shims.ts +94 -52
  127. package/src/server/action-handler.ts +10 -2
  128. package/src/server/actions.ts +3 -2
  129. package/src/server/compress.ts +11 -2
  130. package/src/server/cookie-context.ts +11 -0
  131. package/src/server/cookie-parsing.ts +16 -4
  132. package/src/server/fallback-error.ts +9 -4
  133. package/src/server/node-stream-transforms.ts +61 -24
  134. package/src/server/pipeline.ts +12 -0
  135. package/src/server/primitives.ts +10 -31
  136. package/src/server/rsc-entry/index.ts +7 -2
  137. package/src/server/rsc-entry/rsc-payload.ts +4 -3
  138. package/src/server/slot-resolver.ts +27 -14
  139. package/src/server/ssr-entry.ts +17 -28
  140. package/src/server/tracing.ts +17 -1
  141. package/src/shared/head-header.ts +69 -0
  142. package/src/shared/href-validation.ts +24 -0
  143. package/src/shared/redirect-type.ts +17 -0
  144. package/src/shims/headers.ts +119 -2
  145. package/src/shims/navigation-client.ts +48 -27
  146. package/LICENSE +0 -8
  147. package/dist/_chunks/actions-DzFMnUAR.js.map +0 -1
  148. package/dist/_chunks/handler-store-DjISKYEB.js.map +0 -1
  149. package/dist/_chunks/navigation-root-DLc4s4ZC.js.map +0 -1
@@ -35,6 +35,7 @@ import {
35
35
  } from './sensitive-fields.js';
36
36
  import type { FormFlashData } from './form-flash.js';
37
37
  import { checkVersionSkew, applyReloadHeaders } from './version-skew.js';
38
+ import { encodeHeadHeader, HEAD_HEADER } from '../shared/head-header.js';
38
39
  import { logActionError } from './logger.js';
39
40
 
40
41
  // ─── Types ────────────────────────────────────────────────────────────────
@@ -299,9 +300,16 @@ async function handleRscAction(
299
300
  _tree: result.revalidation.element,
300
301
  };
301
302
  headers['X-Timber-Revalidation'] = '1';
302
- // Forward head elements as JSON so the client can update <head>.
303
+ // Forward head elements (percent-encoded JSON) so the client can update
304
+ // <head>. Raw JSON.stringify is NOT safe here: header values must be
305
+ // ByteStrings, so a title with an em dash or emoji would make
306
+ // `new Response(...)` throw — a 500 after the action's mutation already
307
+ // committed, dropping Set-Cookie too. See TIM-1058.
303
308
  if (result.revalidation.headElements.length > 0) {
304
- headers['X-Timber-Head'] = JSON.stringify(result.revalidation.headElements);
309
+ const encodedHead = encodeHeadHeader(result.revalidation.headElements);
310
+ if (encodedHead !== null) {
311
+ headers[HEAD_HEADER] = encodedHead;
312
+ }
305
313
  }
306
314
  } else {
307
315
  payload = result.actionResult;
@@ -18,6 +18,7 @@ import { getCacheHandler } from '../cache/handler-store';
18
18
  import { DenySignal, RedirectSignal } from './primitives';
19
19
  import { withSpan } from './tracing';
20
20
  import { revalidationAls, type RevalidationState } from './als-registry.js';
21
+ import type { HeadElementPayload } from '../shared/head-header.js';
21
22
 
22
23
  // ─── Types ───────────────────────────────────────────────────────────────
23
24
 
@@ -25,8 +26,8 @@ import { revalidationAls, type RevalidationState } from './als-registry.js';
25
26
  export interface RevalidationResult {
26
27
  /** React element tree (pre-serialization — passed to renderToReadableStream). */
27
28
  element: unknown;
28
- /** Resolved head elements for metadata. */
29
- headElements: unknown[];
29
+ /** Resolved head elements for metadata (forwarded via X-Timber-Head). */
30
+ headElements: HeadElementPayload[];
30
31
  }
31
32
 
32
33
  /** Renderer function that builds a React element tree for a given path. */
@@ -174,7 +174,7 @@ export function compressResponse(request: Request, response: Response): Response
174
174
  // progressively.
175
175
 
176
176
  import { createGzip, constants } from 'node:zlib';
177
- import { Readable } from 'node:stream';
177
+ import { Readable, pipeline } from 'node:stream';
178
178
 
179
179
  /**
180
180
  * Compress a ReadableStream with gzip, flushing each chunk immediately.
@@ -186,7 +186,16 @@ import { Readable } from 'node:stream';
186
186
  function compressWithGzip(body: ReadableStream<Uint8Array>): ReadableStream<Uint8Array> {
187
187
  const gzip = createGzip({ flush: constants.Z_SYNC_FLUSH });
188
188
  const nodeInput = Readable.fromWeb(body as import('stream/web').ReadableStream);
189
- nodeInput.pipe(gzip);
189
+
190
+ // pipeline() instead of bare pipe(): pipe() does not forward source errors,
191
+ // so an erroring body (streaming route handler, dropped upstream fetch)
192
+ // would emit an unhandled 'error' event and crash the process. pipeline()
193
+ // destroys gzip on source error — the returned web stream rejects — and
194
+ // tears down nodeInput when the consumer cancels the web stream, which
195
+ // cancels the source body via fromWeb. The error reaches the consumer
196
+ // through the web stream; the callback only marks it handled at the Node
197
+ // layer.
198
+ pipeline(nodeInput, gzip, () => {});
190
199
 
191
200
  return Readable.toWeb(gzip) as ReadableStream<Uint8Array>;
192
201
  }
@@ -195,6 +195,11 @@ export function getCookieJar(): RequestCookies {
195
195
  maxAge: 0,
196
196
  expires: new Date(0),
197
197
  };
198
+ // delete() is a jar entry point like set() — caller-supplied
199
+ // path/domain are serialized verbatim into the Set-Cookie header,
200
+ // so they must pass the same attribute-injection guard. See
201
+ // design/29-cookies.md §"Cookie Attribute Validation" (TIM-1026).
202
+ assertValidCookieOptions(opts);
198
203
  store.cookieJar.set(name, { name, value: '', options: opts });
199
204
  // Remove from read view
200
205
  map.delete(name);
@@ -460,6 +465,12 @@ function setRaw(
460
465
  // the cookie jar / RYW map.
461
466
  assertValidCookieName(name);
462
467
  assertValidCookieValue(name, value);
468
+ // Options get the same entry-point guard as set()/delete(). For the
469
+ // setFromHeaders path this can only fire on attributes parseSetCookie
470
+ // cannot produce (it splits on `;` and normalizes Max-Age/SameSite),
471
+ // but setRaw is the jar entry point — the invariant lives here, not in
472
+ // the caller. See design/29-cookies.md §"Cookie Attribute Validation".
473
+ assertValidCookieOptions(options);
463
474
  store.cookieJar.set(name, { name, value, options });
464
475
  // Deletion cookies (Max-Age=0) should not appear in the read map.
465
476
  if (options.maxAge === 0) {
@@ -110,15 +110,27 @@ export function parseSetCookie(
110
110
  case 'domain':
111
111
  options.domain = val;
112
112
  break;
113
- case 'max-age':
114
- options.maxAge = Number(val);
113
+ case 'max-age': {
114
+ // RFC 6265 §5.2.2: a non-numeric Max-Age is ignored, not fatal.
115
+ // Dropping NaN/Infinity here keeps this parser total and keeps
116
+ // `Max-Age=NaN` out of the serialized Set-Cookie header.
117
+ const maxAge = Number(val);
118
+ if (Number.isFinite(maxAge)) options.maxAge = maxAge;
115
119
  break;
120
+ }
116
121
  case 'expires':
117
122
  options.expires = new Date(val);
118
123
  break;
119
- case 'samesite':
120
- options.sameSite = val.toLowerCase() as 'strict' | 'lax' | 'none';
124
+ case 'samesite': {
125
+ // Unknown SameSite values are ignored (browsers treat them as
126
+ // unspecified) so forwarding a quirky upstream header via
127
+ // setFromHeaders never trips the entry-point validator.
128
+ const sameSite = val.toLowerCase();
129
+ if (sameSite === 'strict' || sameSite === 'lax' || sameSite === 'none') {
130
+ options.sameSite = sameSite;
131
+ }
121
132
  break;
133
+ }
122
134
  case 'secure':
123
135
  options.secure = true;
124
136
  break;
@@ -34,14 +34,15 @@ export async function renderFallbackError(
34
34
  rootSegment: ManifestSegmentNode,
35
35
  clientBootstrap: ClientBootstrapConfig,
36
36
  globalError?: GlobalErrorFile,
37
- projectRoot?: string
37
+ projectRoot?: string,
38
+ hmrOptions?: import('../dev-tools/dev-page-shell.js').DevErrorHmrOptions
38
39
  ): Promise<Response> {
39
40
  // Source-map the error's stack trace in dev mode so the error page
40
41
  // shows original source positions. See TIM-811.
41
42
  sourceMapError(error);
42
43
 
43
44
  if (isDev) {
44
- return renderDevErrorPage(error, projectRoot);
45
+ return renderDevErrorPage(error, projectRoot, hmrOptions);
45
46
  }
46
47
  // Lazy import to avoid loading error-renderer in the pipeline module
47
48
  const { renderErrorPage } = await import('./rsc-entry/error-renderer.js');
@@ -91,7 +92,11 @@ export async function renderFallbackError(
91
92
  *
92
93
  * Dev-only — the dynamic import has zero production cost.
93
94
  */
94
- export async function renderDevErrorPage(error: unknown, projectRoot?: string): Promise<Response> {
95
+ export async function renderDevErrorPage(
96
+ error: unknown,
97
+ projectRoot?: string,
98
+ hmrOptions?: import('../dev-tools/dev-page-shell.js').DevErrorHmrOptions
99
+ ): Promise<Response> {
95
100
  const err = error instanceof Error ? error : new Error(String(error));
96
101
  const root = projectRoot ?? process.cwd();
97
102
 
@@ -100,7 +105,7 @@ export async function renderDevErrorPage(error: unknown, projectRoot?: string):
100
105
  // Dynamic import — keeps dev-error-page.ts and its transitive deps
101
106
  // (dev-error-overlay.ts, @jridgewell/trace-mapping) out of production bundles.
102
107
  const { generateDevErrorPage } = await import('../dev-tools/error-page.js');
103
- html = generateDevErrorPage(err, 'render', root);
108
+ html = generateDevErrorPage(err, 'render', root, hmrOptions);
104
109
  // Inject Vite client script so the error overlay fires when HMR connects.
105
110
  html = html.replace(
106
111
  '</head>',
@@ -18,7 +18,8 @@
18
18
  * Readable.toWeb() conversion for the Response body.
19
19
  */
20
20
 
21
- import { Transform } from 'node:stream';
21
+ import { PassThrough, Transform, type Readable } from 'node:stream';
22
+ import { pipeline } from 'node:stream/promises';
22
23
  import { createGzip, constants } from 'node:zlib';
23
24
 
24
25
  import {
@@ -265,37 +266,73 @@ export function createNodeFlightInjector(
265
266
  const NOINDEX_SCRIPT =
266
267
  '<script>document.head.appendChild(Object.assign(document.createElement("meta"),{name:"robots",content:"noindex"}))</script>';
267
268
 
269
+ function isAbortError(error: unknown): boolean {
270
+ if (error instanceof DOMException && error.name === 'AbortError') return true;
271
+ if (error instanceof Error && error.name === 'AbortError') return true;
272
+ return false;
273
+ }
274
+
268
275
  /**
269
- * Node.js Transform that catches post-shell streaming errors.
276
+ * Run the Node SSR post-processing pipeline with post-shell error handling.
277
+ *
278
+ * Equivalent to wrapStreamWithErrorHandling() in ssr-render.ts: when the
279
+ * stream errors after the shell has flushed (deny/throw inside Suspense,
280
+ * an RSC read timeout in the flight injector, or an upstream Fizz error),
281
+ * the response closes cleanly with a trailing noindex script instead of
282
+ * aborting the connection mid-body. Abort errors (client disconnect,
283
+ * render-timeout abort) close the output silently — no noindex, no log.
270
284
  *
271
- * Equivalent to wrapStreamWithErrorHandling() in ssr-render.ts.
272
- * Catches errors from React's streaming phase (deny/throw inside Suspense
273
- * after the shell has flushed) and closes the stream cleanly.
285
+ * Why this is a pump function and not a Transform stage (TIM-1019):
286
+ * stream.promises.pipeline() destroys every stage in the chain when any
287
+ * stage errors, so a Transform *inside* the pipeline can never append
288
+ * bytes after a failure — by the time its 'error' listener fires, the
289
+ * transform and everything downstream are already destroyed and push()/
290
+ * end() are no-ops. The output PassThrough therefore lives OUTSIDE the
291
+ * pipeline() call: the last stage pipes into it (pipe() ends the output
292
+ * on upstream success but leaves it open on upstream error), and the
293
+ * pipeline's rejection handler appends the noindex script.
294
+ *
295
+ * Consumer cancellation (e.g. Readable.toWeb() cancel on client
296
+ * disconnect) is wired back into the pipeline via an AbortSignal so the
297
+ * source and all stages are torn down — the teardown that membership in
298
+ * pipeline() used to provide.
274
299
  */
275
- export function createNodeErrorHandler(signal?: AbortSignal): Transform {
276
- const transform = new Transform({
277
- transform(chunk, _encoding, callback) {
278
- callback(null, chunk);
279
- },
280
- });
281
-
282
- transform.on('error', (error) => {
283
- const isAbort =
284
- (error instanceof DOMException && error.name === 'AbortError') ||
285
- (error instanceof Error && error.name === 'AbortError') ||
286
- signal?.aborted;
287
-
288
- if (isAbort) {
289
- transform.end();
300
+ export function runNodePipelineWithErrorHandling(
301
+ source: Readable,
302
+ transforms: readonly [Transform, ...Transform[]],
303
+ signal?: AbortSignal
304
+ ): PassThrough {
305
+ const output = new PassThrough();
306
+ const last = transforms[transforms.length - 1]!;
307
+ last.pipe(output);
308
+
309
+ // If the consumer destroys the output (client disconnect), abort the
310
+ // pipeline so the Fizz source and flight injector release their
311
+ // resources. On normal completion the pipeline has already settled and
312
+ // the abort is a no-op.
313
+ const consumerGone = new AbortController();
314
+ output.once('close', () => consumerGone.abort());
315
+
316
+ // The cast satisfies pipeline()'s [source, ...transforms, destination]
317
+ // tuple overload — TS can't prove the shape across a spread, but the
318
+ // signature above guarantees a non-empty transform list at compile time.
319
+ const stages = [source, ...transforms] as unknown as readonly [
320
+ Readable,
321
+ ...Transform[],
322
+ Transform,
323
+ ];
324
+
325
+ pipeline(stages, { signal: consumerGone.signal }).catch((error: unknown) => {
326
+ if (output.writableEnded || output.destroyed) return;
327
+ if (isAbortError(error) || signal?.aborted || consumerGone.signal.aborted) {
328
+ output.end();
290
329
  return;
291
330
  }
292
-
293
331
  logStreamingError({ error });
294
- transform.push(Buffer.from(NOINDEX_SCRIPT, 'utf-8'));
295
- transform.end();
332
+ output.end(Buffer.from(NOINDEX_SCRIPT, 'utf-8'));
296
333
  });
297
334
 
298
- return transform;
335
+ return output;
299
336
  }
300
337
 
301
338
  // ─── Compression ─────────────────────────────────────────────────────────────
@@ -187,6 +187,18 @@ export interface PipelineConfig {
187
187
  debugComponents?: Array<{ name: string; env: string | null; stack: unknown[] | null }>
188
188
  ) => void;
189
189
 
190
+ /**
191
+ * HMR connection options for dev-only HTML pages (dev 404 page, fallback
192
+ * error page) — set by the dev server after the RSC entry module is
193
+ * imported, like `devPipelineErrorHandler`. The RSC environment can't
194
+ * read the Vite server config (separate module graph), so the dev server
195
+ * plumbs the resolved HMR endpoint (protocol/host/port/path/token) here
196
+ * for the pages' auto-reload WebSocket. See TIM-1067.
197
+ *
198
+ * Undefined in production — zero overhead.
199
+ */
200
+ devHmrOptions?: import('../dev-tools/dev-page-shell.js').DevErrorHmrOptions;
201
+
190
202
  /**
191
203
  * Fallback error renderer — called when a catastrophic error escapes the
192
204
  * render phase. Produces an HTML Response instead of a bare empty 500.
@@ -8,7 +8,10 @@ import { getWaitUntil as _getWaitUntil } from './waituntil-bridge.js';
8
8
  import { isDebug } from './debug.js';
9
9
  import { getRequestSearchString } from './request-context.js';
10
10
  import { mergePreservedSearchParams } from '../shared/merge-search-params.js';
11
- import { validateExternalRedirectUrl } from '../shared/href-validation.js';
11
+ import {
12
+ assertRelativeRedirectPath,
13
+ validateExternalRedirectUrl,
14
+ } from '../shared/href-validation.js';
12
15
 
13
16
  // ─── Dev-mode validation ────────────────────────────────────────────────────
14
17
 
@@ -207,17 +210,9 @@ export function notFound(): never {
207
210
  deny(404);
208
211
  }
209
212
 
210
- /**
211
- * Next.js redirect type discriminator.
212
- *
213
- * Provided for API compatibility with libraries that import `RedirectType`
214
- * from `next/navigation`. In timber, `redirect()` always uses `replace`
215
- * semantics (no history entry for the redirect itself).
216
- */
217
- export const RedirectType = {
218
- push: 'push',
219
- replace: 'replace',
220
- } as const;
213
+ // Single source of truth shared with the client next/navigation shim —
214
+ // see shared/redirect-type.ts.
215
+ export { RedirectType } from '../shared/redirect-type.js';
221
216
 
222
217
  // ─── RedirectSignal ─────────────────────────────────────────────────────────
223
218
 
@@ -237,12 +232,6 @@ export class RedirectSignal extends Error {
237
232
  }
238
233
  }
239
234
 
240
- /** Pattern matching absolute URLs: http(s):// or protocol-relative // */
241
- const ABSOLUTE_URL_RE = /^(?:[a-zA-Z][a-zA-Z\d+\-.]*:|\/\/)/;
242
-
243
- // eslint-disable-next-line no-control-regex -- must match all C0 controls to prevent bypass
244
- const REDIRECT_CONTROL_CHARS_RE = /[\x00-\x1f]/g;
245
-
246
235
  /**
247
236
  * Options for redirect() — alternative to passing a bare status code.
248
237
  */
@@ -303,19 +292,9 @@ export function redirect(path: string, statusOrOptions?: number | RedirectOption
303
292
  if (status < 300 || status > 399) {
304
293
  throw new Error(`redirect() requires a 3xx status code, got ${status}.`);
305
294
  }
306
- // Strip C0 control characters and trim whitespace before checking — browsers
307
- // do this during URL resolution, so " \thttp://evil.com" resolves as absolute.
308
- // Normalize for validation only: strip C0 controls, trim, then replace backslashes
309
- // with forward slashes. Browsers normalize \ → / during URL resolution, so
310
- // "/\evil.com" becomes "//evil.com" (protocol-relative open redirect).
311
- const cleaned = path.replace(REDIRECT_CONTROL_CHARS_RE, '').trimStart().replaceAll('\\', '/');
312
-
313
- if (ABSOLUTE_URL_RE.test(cleaned)) {
314
- throw new Error(
315
- `redirect() only accepts relative URLs. Got absolute URL: "${path}". ` +
316
- 'Use redirectExternal(url, allowList) for external redirects.'
317
- );
318
- }
295
+ // Relative-only validation shared with the client next/navigation shim —
296
+ // strips C0 controls and normalizes backslashes before the scheme check.
297
+ assertRelativeRedirectPath(path);
319
298
 
320
299
  let resolvedPath = path;
321
300
  if (preserveSearchParams) {
@@ -268,7 +268,9 @@ async function createRequestHandler(manifest: typeof routeManifest, runtimeConfi
268
268
  const { generateDev404Page, collectRoutes } = await import('../../dev-tools/404-page.js');
269
269
  const routes = collectRoutes(manifest.root);
270
270
  const pathname = new URL(req.url).pathname;
271
- const html = generateDev404Page(pathname, routes);
271
+ // devHmrOptions is set by the dev server after this module is
272
+ // imported (read at request time, not closure-capture time).
273
+ const html = generateDev404Page(pathname, routes, pipelineConfig.devHmrOptions);
272
274
  return new Response(html, {
273
275
  status: 404,
274
276
  headers: {
@@ -316,7 +318,10 @@ async function createRequestHandler(manifest: typeof routeManifest, runtimeConfi
316
318
  // (TIM-807). Passing manifest.root (segment tree object) here would
317
319
  // stringify to "[object Object]" and misclassify app frames as
318
320
  // internal, dropping source locations from the dev error page (TIM-808).
319
- isDev ? manifest.viteRoot : undefined
321
+ isDev ? manifest.viteRoot : undefined,
322
+ // HMR options for the dev error page's auto-reload WebSocket —
323
+ // set by the dev server after import, read at request time.
324
+ isDev ? pipelineConfig.devHmrOptions : undefined
320
325
  ),
321
326
  renderDenyFallback: async (deny, req, responseHeaders, matchedRoute) => {
322
327
  // Render the deny page (403.tsx, 404.tsx, etc.) for DenySignals
@@ -12,6 +12,7 @@
12
12
  import type { LayoutEntry } from '../deny-renderer.js';
13
13
  import { renderDenyPageAsRsc } from '../deny-renderer.js';
14
14
  import type { RouteMatch } from '../pipeline.js';
15
+ import { encodeHeadHeader, HEAD_HEADER } from '../../shared/head-header.js';
15
16
  import type { RedirectSignal } from '../primitives.js';
16
17
  import type { HeadElement, LayoutComponentEntry } from '../route-element-builder.js';
17
18
  import type { ManifestSegmentNode } from '../route-matcher.js';
@@ -175,9 +176,9 @@ export async function buildRscPayloadResponse(
175
176
 
176
177
  // Send resolved head elements so the client can update document.title
177
178
  // and <meta> tags after SPA navigation. See design/16-metadata.md.
178
- const encoded = encodeURIComponent(JSON.stringify(headElements));
179
- if (encoded.length <= 4096) {
180
- responseHeaders.set('X-Timber-Head', encoded);
179
+ const encodedHead = encodeHeadHeader(headElements);
180
+ if (encodedHead !== null) {
181
+ responseHeaders.set(HEAD_HEADER, encodedHead);
181
182
  }
182
183
 
183
184
  // Send segment metadata so the client can populate its segment cache
@@ -68,10 +68,12 @@ async function renderDefaultFallback(
68
68
  * Finds a matching page in the slot's sub-tree for the current route.
69
69
  * Falls back to default.tsx if no match, or null if no default.
70
70
  *
71
- * When a match is found, the element is wrapped with:
72
- * 1. Error boundaries from each segment in the slot's matched chain
73
- * 2. Layouts from each segment in the slot's matched chain
74
- * 3. SlotAccessGate if the slot root has access.ts
71
+ * When a match is found, the element is wrapped per segment in the slot's
72
+ * matched chain with (innermost → outermost):
73
+ * 1. Error boundaries
74
+ * 2. Layout
75
+ * 3. SlotAccessGate if the segment has access.ts (gate OUTSIDE the layout,
76
+ * so a denied layout never executes — see TIM-1074)
75
77
  */
76
78
  export async function resolveSlotElement(
77
79
  slotNode: ManifestSegmentNode,
@@ -155,20 +157,25 @@ export async function resolveSlotElement(
155
157
  element = h(SafeSlotPage, {});
156
158
  }
157
159
 
158
- // Wrap with error boundaries and layouts from intermediate slot segments
159
- // (everything between slot root and leaf). Process innermost-first, same
160
- // order as route-element-builder.ts handles main segments. The slot root
161
- // (index 0) is handled separately after the access gate below.
160
+ // Wrap with error boundaries, layouts, and access gates from
161
+ // intermediate slot segments (everything between slot root and leaf).
162
+ // Process innermost-first, same order as route-element-builder.ts
163
+ // handles main segments. The slot root (index 0) is handled below.
162
164
  element = await wrapWithIntermediateSegments(slotMatch.chain, element, h);
163
165
 
164
- // Wrap in SlotAccessGate if slot root has access.ts.
165
- // On denial: denied.tsx → default.tsx → null (graceful degradation).
166
+ // Wrap with slot root's layout — INSIDE the access gate, so the layout
167
+ // server component never executes when access.ts denies. See TIM-1074.
168
+ element = await wrapWithLayout(slotNode, element, h);
169
+
170
+ // Wrap in SlotAccessGate if slot root has access.ts. The gate is
171
+ // OUTSIDE the layout, matching the segment model in
172
+ // route-element-builder.ts: a denied layout must not render, or it
173
+ // would fetch/serialize protected data into the RSC Flight stream.
174
+ // On denial: denied.tsx → default.tsx → null (graceful degradation),
175
+ // rendered WITHOUT the denied slot's own layout.
166
176
  // See design/04-authorization.md §"Slot-Level Auth".
167
177
  element = await wrapWithAccessGate(slotNode, element, h);
168
178
 
169
- // Wrap with slot root's layout (outermost, outside access gate)
170
- element = await wrapWithLayout(slotNode, element, h);
171
-
172
179
  // Wrap with slot root's error boundaries (outermost)
173
180
  element = await wrapSegmentWithErrorBoundaries(slotNode, element, h);
174
181
 
@@ -204,6 +211,12 @@ export async function resolveSlotElement(
204
211
  * not just the slot root. Without this, nested slot children (e.g.,
205
212
  * @admin/reports/access.ts) and intercepting route children with access.ts
206
213
  * would bypass authorization. See TIM-999.
214
+ *
215
+ * Wrap order per segment (innermost → outermost): error boundaries → layout
216
+ * → access gate. The gate must be OUTSIDE the layout so a denying access.ts
217
+ * prevents the layout server component from executing — otherwise the layout
218
+ * would fetch/serialize protected data into the RSC Flight stream. Matches
219
+ * route-element-builder.ts. See TIM-1074.
207
220
  */
208
221
  async function wrapWithIntermediateSegments(
209
222
  chain: ManifestSegmentNode[],
@@ -213,8 +226,8 @@ async function wrapWithIntermediateSegments(
213
226
  for (let i = chain.length - 1; i > 0; i--) {
214
227
  const seg = chain[i];
215
228
  element = await wrapSegmentWithErrorBoundaries(seg, element, h);
216
- element = await wrapWithAccessGate(seg, element, h);
217
229
  element = await wrapWithLayout(seg, element, h);
230
+ element = await wrapWithAccessGate(seg, element, h);
218
231
  }
219
232
  return element;
220
233
  }
@@ -55,9 +55,7 @@ let _nodeStreamImports: {
55
55
  createNodeHeadInjector: typeof import('./node-stream-transforms.js').createNodeHeadInjector;
56
56
  createNodeMoveSuffixTransform: typeof import('./node-stream-transforms.js').createNodeMoveSuffixTransform;
57
57
  createNodeFlightInjector: typeof import('./node-stream-transforms.js').createNodeFlightInjector;
58
- createNodeErrorHandler: typeof import('./node-stream-transforms.js').createNodeErrorHandler;
59
- pipeline: typeof import('node:stream/promises').pipeline;
60
- PassThrough: typeof import('node:stream').PassThrough;
58
+ runNodePipelineWithErrorHandling: typeof import('./node-stream-transforms.js').runNodePipelineWithErrorHandling;
61
59
  ReadableFromWeb: (
62
60
  webStream: import('stream/web').ReadableStream
63
61
  ) => import('node:stream').Readable;
@@ -65,9 +63,8 @@ let _nodeStreamImports: {
65
63
 
66
64
  if (useNodeStreams) {
67
65
  try {
68
- const [transforms, streamPromises, stream] = await Promise.all([
66
+ const [transforms, stream] = await Promise.all([
69
67
  import('./node-stream-transforms.js'),
70
- import('node:stream/promises'),
71
68
  import('node:stream'),
72
69
  ]);
73
70
  _nodeStreamImports = {
@@ -75,9 +72,7 @@ if (useNodeStreams) {
75
72
  createNodeHeadInjector: transforms.createNodeHeadInjector,
76
73
  createNodeMoveSuffixTransform: transforms.createNodeMoveSuffixTransform,
77
74
  createNodeFlightInjector: transforms.createNodeFlightInjector,
78
- createNodeErrorHandler: transforms.createNodeErrorHandler,
79
- pipeline: streamPromises.pipeline,
80
- PassThrough: stream.PassThrough,
75
+ runNodePipelineWithErrorHandling: transforms.runNodePipelineWithErrorHandling,
81
76
  ReadableFromWeb: (webStream: import('stream/web').ReadableStream) =>
82
77
  stream.Readable.fromWeb(webStream) as import('node:stream').Readable,
83
78
  };
@@ -192,9 +187,7 @@ export async function handleSsr(
192
187
  createNodeHeadInjector,
193
188
  createNodeMoveSuffixTransform,
194
189
  createNodeFlightInjector,
195
- createNodeErrorHandler,
196
- pipeline,
197
- PassThrough,
190
+ runNodePipelineWithErrorHandling,
198
191
  } = _nodeStreamImports;
199
192
 
200
193
  const renderTimeoutMs = _runtimeConfig.renderTimeoutMs ?? undefined;
@@ -224,15 +217,18 @@ export async function handleSsr(
224
217
  // React 19.3+ emits <!DOCTYPE html> automatically when the root
225
218
  // element is <html>, so no framework-level doctype prepend needed.
226
219
  //
227
- // Pipeline: buffer → errorHandler → headInjector → flightInjector → moveSuffix → output
220
+ // Pipeline: buffer → headInjector → flightInjector → moveSuffix → output
228
221
  //
229
222
  // 1. buffer: coalesces Fizz micro-chunks into coherent fragments (TIM-528)
230
- // 2. errorHandler: catches post-shell streaming errors
231
- // 3. headInjector: injects metadata before </head>
232
- // 4. flightInjector: interleaves RSC scripts between HTML chunks (TIM-529)
233
- // 5. moveSuffix: strips </body></html>, re-emits at end (TIM-530)
223
+ // 2. headInjector: injects metadata before </head>
224
+ // 3. flightInjector: interleaves RSC scripts between HTML chunks (TIM-529)
225
+ // 4. moveSuffix: strips </body></html>, re-emits at end (TIM-530)
226
+ //
227
+ // Post-shell error handling lives in the pump, not in a pipeline
228
+ // stage: pipeline() destroys every stage on failure, so only an
229
+ // output kept outside the pipeline can append the noindex script
230
+ // and close cleanly (TIM-1019).
234
231
  const bufferedTransform = createNodeBufferedTransform();
235
- const errorHandler = createNodeErrorHandler(navContext.signal);
236
232
  const headInjector = createNodeHeadInjector(navContext.headHtml);
237
233
  const flightInjector = createNodeFlightInjector(navContext.rscStream, {
238
234
  renderTimeoutMs,
@@ -240,18 +236,11 @@ export async function handleSsr(
240
236
  });
241
237
  const moveSuffix = createNodeMoveSuffixTransform();
242
238
 
243
- const output = new PassThrough();
244
- pipeline(
239
+ const output = runNodePipelineWithErrorHandling(
245
240
  nodeHtmlStream,
246
- bufferedTransform,
247
- errorHandler,
248
- headInjector,
249
- flightInjector,
250
- moveSuffix,
251
- output
252
- ).catch(() => {
253
- // Pipeline errors are handled by errorHandler transform
254
- });
241
+ [bufferedTransform, headInjector, flightInjector, moveSuffix],
242
+ navContext.signal
243
+ );
255
244
  const _pipelineEnd = performance.now();
256
245
 
257
246
  // Record SSR sub-timings for Server-Timing header (detailed mode).
@@ -94,6 +94,16 @@ export function getTraceStore(): TraceStore | undefined {
94
94
 
95
95
  // ─── Dev-Mode OTEL Auto-Init ─────────────────────────────────────────────
96
96
 
97
+ /**
98
+ * Well-known key marking dev tracing as initialized.
99
+ *
100
+ * The RSC entry module re-evaluates on every HMR invalidation and calls
101
+ * initDevTracing() again — without this guard, each call would register a
102
+ * fresh provider/processor and duplicate every span's output. Symbol.for()
103
+ * survives module re-evaluation. See TIM-1067, B49.
104
+ */
105
+ const DEV_TRACING_INIT_KEY = Symbol.for('timber.dev.tracing-initialized');
106
+
97
107
  /**
98
108
  * Initialize a minimal OTEL SDK in dev mode so spans are recorded and
99
109
  * fed to the DevSpanProcessor for dev log output.
@@ -102,11 +112,15 @@ export function getTraceStore(): TraceStore | undefined {
102
112
  * our DevSpanProcessor alongside theirs. If no SDK is configured,
103
113
  * we create a BasicTracerProvider with our processor.
104
114
  *
105
- * Only called in dev mode — zero overhead in production.
115
+ * Idempotent across module re-evaluations (HMR) — only the first call
116
+ * registers. Only called in dev mode — zero overhead in production.
106
117
  */
107
118
  export async function initDevTracing(
108
119
  config: import('../dev-tools/logger.js').DevLoggerConfig
109
120
  ): Promise<void> {
121
+ const globals = globalThis as Record<symbol, unknown>;
122
+ if (globals[DEV_TRACING_INIT_KEY]) return;
123
+
110
124
  const api = await getOtelApi();
111
125
  if (!api) return;
112
126
 
@@ -145,6 +159,8 @@ export async function initDevTracing(
145
159
 
146
160
  // Reset cached tracer so next getTracer() picks up the new provider
147
161
  _tracer = undefined;
162
+
163
+ globals[DEV_TRACING_INIT_KEY] = true;
148
164
  }
149
165
 
150
166
  // ─── OTEL Span Helpers ───────────────────────────────────────────────────