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

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 (183) 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/{cli-schema-sync-DH-M8Poy.js → cli-schema-sync-CBiMEtqa.js} +1 -1
  4. package/dist/_chunks/{cli-schema-sync-D2IpXicg.js → cli-schema-sync-CQaxupHv.js} +47 -54
  5. package/dist/_chunks/cli-schema-sync-CQaxupHv.js.map +1 -0
  6. package/dist/_chunks/{handler-store-DjISKYEB.js → handler-store-BVDgyTh7.js} +26 -2
  7. package/dist/_chunks/handler-store-BVDgyTh7.js.map +1 -0
  8. package/dist/_chunks/{href-validation-EZktELp8.js → href-validation-DyZIzfMJ.js} +18 -2
  9. package/dist/_chunks/{href-validation-EZktELp8.js.map → href-validation-DyZIzfMJ.js.map} +1 -1
  10. package/dist/_chunks/{navigation-root-DLc4s4ZC.js → navigation-root-bUDZLw74.js} +23 -2
  11. package/dist/_chunks/navigation-root-bUDZLw74.js.map +1 -0
  12. package/dist/_chunks/tracing-M_zKubhk.js.map +1 -1
  13. package/dist/_chunks/{walkers-7FBPM-hX.js → walkers-Ck4Y45XN.js} +49 -8
  14. package/dist/_chunks/walkers-Ck4Y45XN.js.map +1 -0
  15. package/dist/adapters/cloudflare.d.ts +1 -15
  16. package/dist/adapters/cloudflare.d.ts.map +1 -1
  17. package/dist/adapters/cloudflare.js +10 -28
  18. package/dist/adapters/cloudflare.js.map +1 -1
  19. package/dist/adapters/compress-module.d.ts.map +1 -1
  20. package/dist/adapters/nitro.d.ts.map +1 -1
  21. package/dist/adapters/nitro.js +18 -5
  22. package/dist/adapters/nitro.js.map +1 -1
  23. package/dist/cache/index.js +39 -12
  24. package/dist/cache/index.js.map +1 -1
  25. package/dist/cache/redis-handler.d.ts +72 -8
  26. package/dist/cache/redis-handler.d.ts.map +1 -1
  27. package/dist/cache/stable-stringify.d.ts +9 -0
  28. package/dist/cache/stable-stringify.d.ts.map +1 -1
  29. package/dist/cache/timber-cache.d.ts +3 -1
  30. package/dist/cache/timber-cache.d.ts.map +1 -1
  31. package/dist/cli.js +1 -1
  32. package/dist/client/browser-entry/action-dispatch.d.ts.map +1 -1
  33. package/dist/client/browser-entry/router-init.d.ts.map +1 -1
  34. package/dist/client/browser-entry/scroll.d.ts +9 -0
  35. package/dist/client/browser-entry/scroll.d.ts.map +1 -1
  36. package/dist/client/error-boundary.d.ts.map +1 -1
  37. package/dist/client/error-boundary.js +24 -10
  38. package/dist/client/error-boundary.js.map +1 -1
  39. package/dist/client/error-reconstituter.d.ts +8 -1
  40. package/dist/client/error-reconstituter.d.ts.map +1 -1
  41. package/dist/client/index.js +3 -3
  42. package/dist/client/index.js.map +1 -1
  43. package/dist/client/internal.js +99 -24
  44. package/dist/client/internal.js.map +1 -1
  45. package/dist/client/link.d.ts.map +1 -1
  46. package/dist/client/navigation-api.d.ts.map +1 -1
  47. package/dist/client/navigation-root.d.ts +13 -0
  48. package/dist/client/navigation-root.d.ts.map +1 -1
  49. package/dist/client/router.d.ts +7 -0
  50. package/dist/client/router.d.ts.map +1 -1
  51. package/dist/client/rsc-fetch.d.ts.map +1 -1
  52. package/dist/dev-tools/404-page.d.ts +6 -2
  53. package/dist/dev-tools/404-page.d.ts.map +1 -1
  54. package/dist/dev-tools/dev-page-shell.d.ts +77 -0
  55. package/dist/dev-tools/dev-page-shell.d.ts.map +1 -0
  56. package/dist/dev-tools/error-page.d.ts +4 -30
  57. package/dist/dev-tools/error-page.d.ts.map +1 -1
  58. package/dist/dev-tools/index.d.ts +2 -1
  59. package/dist/dev-tools/index.d.ts.map +1 -1
  60. package/dist/dev-tools/instrumentation.d.ts +11 -1
  61. package/dist/dev-tools/instrumentation.d.ts.map +1 -1
  62. package/dist/index.d.ts.map +1 -1
  63. package/dist/index.js +432 -255
  64. package/dist/index.js.map +1 -1
  65. package/dist/plugins/cache.d.ts +43 -9
  66. package/dist/plugins/cache.d.ts.map +1 -1
  67. package/dist/plugins/cloudflare-wasm.d.ts +4 -1
  68. package/dist/plugins/cloudflare-wasm.d.ts.map +1 -1
  69. package/dist/plugins/dev-server.d.ts +16 -0
  70. package/dist/plugins/dev-server.d.ts.map +1 -1
  71. package/dist/plugins/server-bundle.d.ts.map +1 -1
  72. package/dist/plugins/shims.d.ts +23 -0
  73. package/dist/plugins/shims.d.ts.map +1 -1
  74. package/dist/routing/codegen-types.d.ts +4 -0
  75. package/dist/routing/codegen-types.d.ts.map +1 -1
  76. package/dist/routing/index.js +2 -2
  77. package/dist/server/action-handler.d.ts.map +1 -1
  78. package/dist/server/actions.d.ts +3 -2
  79. package/dist/server/actions.d.ts.map +1 -1
  80. package/dist/server/cookie-context.d.ts.map +1 -1
  81. package/dist/server/cookie-parsing.d.ts.map +1 -1
  82. package/dist/server/deny-boundary.d.ts +27 -2
  83. package/dist/server/deny-boundary.d.ts.map +1 -1
  84. package/dist/server/deny-renderer.d.ts.map +1 -1
  85. package/dist/server/error-boundary-wrapper.d.ts.map +1 -1
  86. package/dist/server/fallback-error.d.ts +2 -2
  87. package/dist/server/fallback-error.d.ts.map +1 -1
  88. package/dist/server/index.js +1 -1
  89. package/dist/server/internal.js +227 -179
  90. package/dist/server/internal.js.map +1 -1
  91. package/dist/server/node-stream-transforms.d.ts +24 -6
  92. package/dist/server/node-stream-transforms.d.ts.map +1 -1
  93. package/dist/server/pipeline.d.ts +11 -0
  94. package/dist/server/pipeline.d.ts.map +1 -1
  95. package/dist/server/primitives.d.ts +1 -11
  96. package/dist/server/primitives.d.ts.map +1 -1
  97. package/dist/server/rsc-entry/error-renderer.d.ts.map +1 -1
  98. package/dist/server/rsc-entry/index.d.ts.map +1 -1
  99. package/dist/server/rsc-entry/rsc-payload.d.ts.map +1 -1
  100. package/dist/server/slot-resolver.d.ts +6 -4
  101. package/dist/server/slot-resolver.d.ts.map +1 -1
  102. package/dist/server/ssr-entry.d.ts.map +1 -1
  103. package/dist/server/status-code-resolver.d.ts +6 -0
  104. package/dist/server/status-code-resolver.d.ts.map +1 -1
  105. package/dist/server/tracing.d.ts +2 -1
  106. package/dist/server/tracing.d.ts.map +1 -1
  107. package/dist/server/utils/mdx-file.d.ts +15 -0
  108. package/dist/server/utils/mdx-file.d.ts.map +1 -0
  109. package/dist/shared/head-header.d.ts +54 -0
  110. package/dist/shared/head-header.d.ts.map +1 -0
  111. package/dist/shared/href-validation.d.ts +11 -0
  112. package/dist/shared/href-validation.d.ts.map +1 -1
  113. package/dist/shared/redirect-type.d.ts +17 -0
  114. package/dist/shared/redirect-type.d.ts.map +1 -0
  115. package/dist/shims/headers.d.ts +52 -1
  116. package/dist/shims/headers.d.ts.map +1 -1
  117. package/dist/shims/navigation-client.d.ts +33 -11
  118. package/dist/shims/navigation-client.d.ts.map +1 -1
  119. package/package.json +11 -10
  120. package/src/adapters/cloudflare.ts +13 -47
  121. package/src/adapters/compress-module.ts +9 -3
  122. package/src/adapters/nitro.ts +9 -2
  123. package/src/cache/redis-handler.ts +77 -13
  124. package/src/cache/stable-stringify.ts +65 -2
  125. package/src/cache/timber-cache.ts +25 -13
  126. package/src/cli.ts +0 -0
  127. package/src/client/browser-entry/action-dispatch.ts +7 -2
  128. package/src/client/browser-entry/router-init.ts +2 -1
  129. package/src/client/browser-entry/scroll.ts +23 -0
  130. package/src/client/error-boundary.tsx +33 -5
  131. package/src/client/error-reconstituter.tsx +15 -1
  132. package/src/client/link.tsx +4 -1
  133. package/src/client/navigation-api.ts +5 -1
  134. package/src/client/navigation-root.tsx +16 -0
  135. package/src/client/router.ts +107 -29
  136. package/src/client/rsc-fetch.ts +6 -6
  137. package/src/dev-tools/404-page.ts +22 -123
  138. package/src/dev-tools/dev-page-shell.ts +256 -0
  139. package/src/dev-tools/error-page.ts +27 -203
  140. package/src/dev-tools/index.ts +7 -1
  141. package/src/dev-tools/instrumentation.ts +39 -2
  142. package/src/index.ts +1 -0
  143. package/src/plugins/cache.ts +79 -21
  144. package/src/plugins/cloudflare-wasm.ts +48 -10
  145. package/src/plugins/dev-server.ts +32 -8
  146. package/src/plugins/server-bundle.ts +25 -9
  147. package/src/plugins/shims.ts +94 -52
  148. package/src/routing/codegen-types.ts +4 -0
  149. package/src/routing/codegen.ts +63 -6
  150. package/src/routing/scanner.ts +80 -63
  151. package/src/server/action-handler.ts +10 -2
  152. package/src/server/actions.ts +3 -2
  153. package/src/server/compress.ts +11 -2
  154. package/src/server/cookie-context.ts +11 -0
  155. package/src/server/cookie-parsing.ts +16 -4
  156. package/src/server/deny-boundary.ts +106 -13
  157. package/src/server/deny-renderer.ts +40 -10
  158. package/src/server/error-boundary-wrapper.ts +1 -14
  159. package/src/server/fallback-error.ts +9 -4
  160. package/src/server/node-stream-transforms.ts +61 -24
  161. package/src/server/pipeline.ts +12 -0
  162. package/src/server/primitives.ts +10 -31
  163. package/src/server/route-matcher.ts +22 -6
  164. package/src/server/rsc-entry/error-renderer.ts +1 -12
  165. package/src/server/rsc-entry/index.ts +7 -2
  166. package/src/server/rsc-entry/rsc-payload.ts +4 -3
  167. package/src/server/slot-resolver.ts +27 -14
  168. package/src/server/ssr-entry.ts +17 -28
  169. package/src/server/status-code-resolver.ts +3 -2
  170. package/src/server/tracing.ts +17 -1
  171. package/src/server/tree-match.ts +15 -5
  172. package/src/server/utils/mdx-file.ts +22 -0
  173. package/src/shared/head-header.ts +69 -0
  174. package/src/shared/href-validation.ts +24 -0
  175. package/src/shared/redirect-type.ts +17 -0
  176. package/src/shims/headers.ts +119 -2
  177. package/src/shims/navigation-client.ts +48 -27
  178. package/LICENSE +0 -8
  179. package/dist/_chunks/actions-DzFMnUAR.js.map +0 -1
  180. package/dist/_chunks/cli-schema-sync-D2IpXicg.js.map +0 -1
  181. package/dist/_chunks/handler-store-DjISKYEB.js.map +0 -1
  182. package/dist/_chunks/navigation-root-DLc4s4ZC.js.map +0 -1
  183. package/dist/_chunks/walkers-7FBPM-hX.js.map +0 -1
@@ -48,7 +48,7 @@ export function generateRouteMap(tree: RouteTree, options: CodegenOptions = {}):
48
48
  const hasSchema = options.hasSchema ?? detectSchema(options.appDir);
49
49
 
50
50
  const routes: RouteEntry[] = [];
51
- collectRoutes(tree.root, [], [], '', routes, hasSchema);
51
+ collectRoutes(tree.root, [], [], '', routes, hasSchema, false);
52
52
 
53
53
  // Sort routes alphabetically for deterministic output
54
54
  routes.sort((a, b) => a.urlPath.localeCompare(b.urlPath));
@@ -82,7 +82,8 @@ function collectRoutes(
82
82
  ancestorParamsFiles: string[],
83
83
  parentTreePath: string,
84
84
  routes: RouteEntry[],
85
- hasSchema: boolean
85
+ hasSchema: boolean,
86
+ insideSlot: boolean
86
87
  ): void {
87
88
  // Build the tree path (includes groups, slots — uniquely identifies this segment).
88
89
  // Root node has empty segmentName, producing treePath = ''.
@@ -156,6 +157,7 @@ function collectRoutes(
156
157
  params: resolvedParams,
157
158
  hasSearchParams: false,
158
159
  isApiRoute,
160
+ isSlotRoute: insideSlot,
159
161
  hasSchema,
160
162
  };
161
163
 
@@ -175,15 +177,63 @@ function collectRoutes(
175
177
 
176
178
  // Recurse into children
177
179
  for (const child of node.children) {
178
- collectRoutes(child, params, nextAncestorFiles, treePath, routes, hasSchema);
180
+ collectRoutes(child, params, nextAncestorFiles, treePath, routes, hasSchema, insideSlot);
179
181
  }
180
182
 
181
183
  // Recurse into slots (they share the parent's URL path, but may have their own pages)
182
184
  for (const slot of Object.values(node.slots)) {
183
- collectRoutes(slot, params, nextAncestorFiles, treePath, routes, hasSchema);
185
+ collectRoutes(slot, params, nextAncestorFiles, treePath, routes, hasSchema, true);
184
186
  }
185
187
  }
186
188
 
189
+ /**
190
+ * Dedupe route entries by urlPath for urlPath-keyed emissions (the Routes
191
+ * interface, useQueryStates overloads, and typed Link variants).
192
+ *
193
+ * Parallel-slot pages share their parent's urlPath; emitting one entry per
194
+ * slot page produced duplicate interface keys (TS2300/TS2717 under
195
+ * skipLibCheck:false) and duplicate overloads. The non-slot entry is the
196
+ * canonical one; when only slot entries exist (parent has no page), the
197
+ * lexicographically smallest segmentPath wins so output is independent of
198
+ * slot scan order. A slot's searchParams definition is carried onto the
199
+ * merged entry when the canonical entry has none — never silently dropped.
200
+ *
201
+ * segmentPath-keyed emissions (getSegmentParams, useSegmentParams,
202
+ * TimberSegmentParams) must NOT use this — segmentPaths are already unique
203
+ * and slot entries feed those APIs directly.
204
+ */
205
+ function dedupeRoutesByUrlPath(routes: RouteEntry[]): RouteEntry[] {
206
+ const byPath = new Map<string, RouteEntry>();
207
+ for (const route of routes) {
208
+ const existing = byPath.get(route.urlPath);
209
+ if (!existing) {
210
+ byPath.set(route.urlPath, route);
211
+ continue;
212
+ }
213
+
214
+ let winner = existing;
215
+ let loser = route;
216
+ const routeWins =
217
+ (existing.isSlotRoute && !route.isSlotRoute) ||
218
+ (!!existing.isSlotRoute === !!route.isSlotRoute &&
219
+ route.segmentPath.localeCompare(existing.segmentPath) < 0);
220
+ if (routeWins) {
221
+ winner = route;
222
+ loser = existing;
223
+ }
224
+
225
+ if (!winner.hasSearchParams && loser.hasSearchParams) {
226
+ winner = {
227
+ ...winner,
228
+ hasSearchParams: true,
229
+ searchParamsPagePath: loser.searchParamsPagePath,
230
+ };
231
+ }
232
+ byPath.set(route.urlPath, winner);
233
+ }
234
+ return [...byPath.values()];
235
+ }
236
+
187
237
  /**
188
238
  * Determine the TypeScript type for a segment's param.
189
239
  */
@@ -366,10 +416,14 @@ function formatDeclarationFile(
366
416
  lines.push('');
367
417
  }
368
418
 
419
+ // urlPath-keyed emissions use the deduped list (slot pages share their
420
+ // parent's urlPath); segmentPath-keyed emissions below keep all entries.
421
+ const uniqueByUrlPath = dedupeRoutesByUrlPath(routes);
422
+
369
423
  lines.push("declare module '@timber-js/app' {");
370
424
  lines.push(' interface Routes {');
371
425
 
372
- for (const route of routes) {
426
+ for (const route of uniqueByUrlPath) {
373
427
  const paramsType = formatParamsType(route.params, importBase, hasSchema);
374
428
  const searchParamsType = formatSearchParamsType(route, importBase);
375
429
 
@@ -393,7 +447,10 @@ function formatDeclarationFile(
393
447
  lines.push('}');
394
448
  lines.push('');
395
449
 
396
- const pageRoutes = routes.filter((r) => !r.isApiRoute);
450
+ // useQueryStates + typed Link are urlPath-keyed → deduped list.
451
+ const pageRoutes = uniqueByUrlPath.filter((r) => !r.isApiRoute);
452
+ // getSegmentParams / useSegmentParams are segmentPath-keyed → full list,
453
+ // so slot segments keep their own typed overloads.
397
454
  const dynamicRoutes = routes.filter((r) => r.params.length > 0);
398
455
 
399
456
  // Generate @timber-js/app/server augmentation — typed getSegmentParams
@@ -177,44 +177,26 @@ function scanSegmentFiles(dirPath: string, node: SegmentNode, extSet: Set<string
177
177
 
178
178
  // Page-extension conventions (page, layout, error, default, denied)
179
179
  if (PAGE_EXT_CONVENTIONS.has(name) && extSet.has(ext)) {
180
- const file: RouteFile = { filePath: fullPath, extension: ext };
181
- switch (name) {
182
- case 'page':
183
- node.page = file;
184
- break;
185
- case 'layout':
186
- node.layout = file;
187
- break;
188
- case 'error':
189
- node.error = file;
190
- break;
191
- case 'default':
192
- node.default = file;
193
- break;
194
- case 'denied':
195
- node.denied = file;
196
- break;
180
+ const key = name as 'page' | 'layout' | 'error' | 'default' | 'denied';
181
+ const existing = node[key];
182
+ if (existing) {
183
+ throwDuplicateConvention(name, existing.filePath, fullPath);
197
184
  }
185
+ node[key] = { filePath: fullPath, extension: ext };
198
186
  continue;
199
187
  }
200
188
 
201
- // Fixed conventions (middleware, access, route) — always .ts or .tsx
202
- if (FIXED_CONVENTIONS.has(name) && /\.?[jt]sx?$/.test(ext)) {
203
- const file: RouteFile = { filePath: fullPath, extension: ext };
204
- switch (name) {
205
- case 'middleware':
206
- node.middleware = file;
207
- break;
208
- case 'access':
209
- node.access = file;
210
- break;
211
- case 'route':
212
- node.route = file;
213
- break;
214
- case 'params':
215
- node.params = file;
216
- break;
189
+ // Fixed conventions (middleware, access, route, params) — only
190
+ // .ts/.tsx/.js/.jsx, never pageExtensions like .mdx. The regex is
191
+ // anchored: mts/cts/mjs/cjs (and stray tail matches like `.foots`)
192
+ // are NOT recognized. See design/07-routing.md §"Page Extensions".
193
+ if (FIXED_CONVENTIONS.has(name) && /^[jt]sx?$/.test(ext)) {
194
+ const key = name as 'middleware' | 'access' | 'route' | 'params';
195
+ const existing = node[key];
196
+ if (existing) {
197
+ throwDuplicateConvention(name, existing.filePath, fullPath);
217
198
  }
199
+ node[key] = { filePath: fullPath, extension: ext };
218
200
  continue;
219
201
  }
220
202
 
@@ -233,6 +215,10 @@ function scanSegmentFiles(dirPath: string, node: SegmentNode, extSet: Set<string
233
215
  if (!node.statusFiles) {
234
216
  node.statusFiles = {};
235
217
  }
218
+ const existing = node.statusFiles[name];
219
+ if (existing) {
220
+ throwDuplicateConvention(name, existing.filePath, fullPath);
221
+ }
236
222
  node.statusFiles[name] = { filePath: fullPath, extension: ext };
237
223
  continue;
238
224
  }
@@ -242,6 +228,10 @@ function scanSegmentFiles(dirPath: string, node: SegmentNode, extSet: Set<string
242
228
  if (!node.legacyStatusFiles) {
243
229
  node.legacyStatusFiles = {};
244
230
  }
231
+ const existing = node.legacyStatusFiles[name];
232
+ if (existing) {
233
+ throwDuplicateConvention(name, existing.filePath, fullPath);
234
+ }
245
235
  node.legacyStatusFiles[name] = { filePath: fullPath, extension: ext };
246
236
  continue;
247
237
  }
@@ -249,6 +239,8 @@ function scanSegmentFiles(dirPath: string, node: SegmentNode, extSet: Set<string
249
239
  // Metadata route files (sitemap.ts, robots.ts, icon.tsx, opengraph-image.tsx, etc.)
250
240
  // Both static (.xml, .txt, .png, .ico, etc.) and dynamic (.ts, .tsx) files are recognized.
251
241
  // When both exist for the same base name, dynamic takes precedence.
242
+ // Two files of the SAME kind (static+static or dynamic+dynamic) are a
243
+ // build error — picking one by readdir order is platform-dependent.
252
244
  // See design/16-metadata.md §"Metadata Routes"
253
245
  const metaInfo = classifyMetadataRoute(entry);
254
246
  if (metaInfo) {
@@ -257,11 +249,13 @@ function scanSegmentFiles(dirPath: string, node: SegmentNode, extSet: Set<string
257
249
  }
258
250
  const existing = node.metadataRoutes[name];
259
251
  if (existing) {
260
- // Dynamic > static precedence: only overwrite if the new file is dynamic
261
- // or the existing file is static (dynamic always wins).
262
252
  const existingIsDynamic = isDynamicMetadataExtension(name, existing.extension);
263
253
  const newIsDynamic = isDynamicMetadataExtension(name, ext);
264
- if (newIsDynamic || !existingIsDynamic) {
254
+ if (existingIsDynamic === newIsDynamic) {
255
+ throwDuplicateConvention(name, existing.filePath, fullPath);
256
+ }
257
+ // Dynamic > static precedence: overwrite only when the new file is dynamic.
258
+ if (newIsDynamic) {
265
259
  node.metadataRoutes[name] = { filePath: fullPath, extension: ext };
266
260
  }
267
261
  } else {
@@ -281,6 +275,26 @@ function scanSegmentFiles(dirPath: string, node: SegmentNode, extSet: Set<string
281
275
  }
282
276
  }
283
277
 
278
+ /**
279
+ * Throw a build error for two files claiming the same convention slot in
280
+ * one segment (e.g. page.tsx + page.jsx). Which file readdir returns first
281
+ * is platform-dependent (APFS sorts entries, ext4 does not) — silently
282
+ * picking one would let macOS dev and Linux CI serve different
283
+ * implementations of the same route.
284
+ */
285
+ function throwDuplicateConvention(
286
+ convention: string,
287
+ firstPath: string,
288
+ secondPath: string
289
+ ): never {
290
+ throw new Error(
291
+ `Build error: duplicate '${convention}' files in the same route segment.\n` +
292
+ ` File 1: ${firstPath}\n` +
293
+ ` File 2: ${secondPath}\n` +
294
+ `Each segment may define '${convention}' at most once. Remove one of the files.`
295
+ );
296
+ }
297
+
284
298
  /**
285
299
  * Recursively scan child directories and build the segment tree.
286
300
  */
@@ -359,57 +373,60 @@ function scanChildren(dirPath: string, parentNode: SegmentNode, extSet: Set<stri
359
373
  * Two route groups like (auth)/login/page.tsx and (marketing)/login/page.tsx both claim
360
374
  * /login — the scanner must detect and reject this at build time.
361
375
  *
362
- * Parallel slots are excluded from collision detection — they intentionally coexist at
363
- * the same URL path as their parent (that's the whole point of parallel routes).
376
+ * Each parallel slot is validated as its OWN collision space: a slot's pages
377
+ * intentionally share URL paths with the parent tree (that's the point of
378
+ * parallel routes), but two route groups INSIDE one slot claiming the same
379
+ * URL — e.g. @side/(a)/x/page.tsx + @side/(b)/x/page.tsx — still collide.
364
380
  */
365
381
  function validateRouteGroupCollisions(root: SegmentNode): void {
366
382
  // Map from urlPath → { filePath, source } for the first page/route seen at that path
367
- const seen = new Map<string, { filePath: string; segmentPath: string }>();
368
- collectRoutableLeaves(root, seen, '', false);
383
+ collectRoutableLeaves(root, new Map(), '');
369
384
  }
370
385
 
371
386
  /**
372
387
  * Walk the segment tree and collect all routable leaves (page or route files),
373
- * throwing on collision. Slots are tracked in their own collision space since
374
- * they are parallel routes that intentionally share URL paths with their parent.
388
+ * throwing on collision. Each slot recurses with a FRESH collision map —
389
+ * slots are parallel routes that intentionally share URL paths with their
390
+ * parent tree, but collisions within a single slot are real errors.
375
391
  */
376
392
  function collectRoutableLeaves(
377
393
  node: SegmentNode,
378
394
  seen: Map<string, { filePath: string; segmentPath: string }>,
379
- segmentPath: string,
380
- insideSlot: boolean
395
+ segmentPath: string
381
396
  ): void {
397
+ // Intercepting routes are resolved by the slot resolver, not URL matching,
398
+ // and their computed urlPaths intentionally mirror other routes (e.g. two
399
+ // interceptors in one slot both produce /gallery/[id]). Skip them entirely.
400
+ if (node.segmentType === 'intercepting') return;
401
+
382
402
  const currentPath = segmentPath
383
403
  ? `${segmentPath}/${node.segmentName}`
384
404
  : node.segmentName || '(root)';
385
405
 
386
- // Only check collisions for non-slot pages — slots intentionally share URL paths
387
- if (!insideSlot) {
388
- const routableFile = node.page ?? node.route;
389
- if (routableFile) {
390
- const existing = seen.get(node.urlPath);
391
- if (existing) {
392
- throw new Error(
393
- `Build error: route collision — multiple route groups produce a page/route at the same URL path.\n` +
394
- ` URL path: ${node.urlPath}\n` +
395
- ` File 1: ${existing.filePath} (via ${existing.segmentPath})\n` +
396
- ` File 2: ${routableFile.filePath} (via ${currentPath})\n` +
397
- `Each URL path must map to exactly one page or route handler. ` +
398
- `Rename or move one of the conflicting files.`
399
- );
400
- }
401
- seen.set(node.urlPath, { filePath: routableFile.filePath, segmentPath: currentPath });
406
+ const routableFile = node.page ?? node.route;
407
+ if (routableFile) {
408
+ const existing = seen.get(node.urlPath);
409
+ if (existing) {
410
+ throw new Error(
411
+ `Build error: route collision — multiple route groups produce a page/route at the same URL path.\n` +
412
+ ` URL path: ${node.urlPath}\n` +
413
+ ` File 1: ${existing.filePath} (via ${existing.segmentPath})\n` +
414
+ ` File 2: ${routableFile.filePath} (via ${currentPath})\n` +
415
+ `Each URL path must map to exactly one page or route handler. ` +
416
+ `Rename or move one of the conflicting files.`
417
+ );
402
418
  }
419
+ seen.set(node.urlPath, { filePath: routableFile.filePath, segmentPath: currentPath });
403
420
  }
404
421
 
405
422
  // Recurse into children
406
423
  for (const child of node.children) {
407
- collectRoutableLeaves(child, seen, currentPath, insideSlot);
424
+ collectRoutableLeaves(child, seen, currentPath);
408
425
  }
409
426
 
410
- // Recurse into slots — each slot is its own parallel route space
427
+ // Recurse into slots — each slot gets a fresh map (its own collision space)
411
428
  for (const slotNode of Object.values(node.slots)) {
412
- collectRoutableLeaves(slotNode, seen, currentPath, true);
429
+ collectRoutableLeaves(slotNode, new Map(), currentPath);
413
430
  }
414
431
  }
415
432
 
@@ -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;
@@ -31,10 +31,14 @@
31
31
 
32
32
  import { createElement } from 'react';
33
33
 
34
+ import { ErrorReconstituter } from '../client/error-reconstituter.js';
35
+ import type { SerializableError } from '../client/error-reconstituter.js';
34
36
  import { requestContextAls } from './als-registry.js';
35
37
  import { DenySignal } from './primitives.js';
36
38
  import { loadModule } from './safe-load.js';
39
+ import { LEGACY_FILE_TO_STATUS } from './status-code-resolver.js';
37
40
  import { withSpan } from './tracing.js';
41
+ import { isMdxFilePath } from './utils/mdx-file.js';
38
42
  import type { ManifestSegmentNode } from './route-matcher.js';
39
43
 
40
44
  // ─── Types ────────────────────────────────────────────────────────────────
@@ -45,6 +49,15 @@ export interface DenyPageEntry {
45
49
  status: number | null;
46
50
  /** The component to render (server or client — both work). */
47
51
  component: (...args: unknown[]) => unknown;
52
+ /**
53
+ * How the entry matched: a status-code file (404.tsx/4xx.tsx), a legacy
54
+ * compat file (not-found.tsx/forbidden.tsx/unauthorized.tsx), or the
55
+ * error.tsx catch-all. error.tsx entries render with their documented
56
+ * { error, digest, reset } contract, not bare deny props. See TIM-1081.
57
+ */
58
+ kind: 'status' | 'legacy' | 'error';
59
+ /** MDX files are server components — rendered with plain props, never ErrorReconstituter. */
60
+ isMdx: boolean;
48
61
  }
49
62
 
50
63
  // ─── Chain Construction ──────────────────────────────────────────────────
@@ -56,10 +69,14 @@ export interface DenyPageEntry {
56
69
  * status-code file components in fallback order:
57
70
  * 1. Specific status files (403.tsx, 404.tsx) — exact match
58
71
  * 2. Category catch-alls (4xx.tsx) — matches any 4xx
59
- * 3. error.tsx — catches everything
72
+ * 3. Legacy compat files (not-found.tsx → 404, forbidden.tsx → 403,
73
+ * unauthorized.tsx → 401) — exact match
74
+ * 4. error.tsx — catches everything
60
75
  *
61
76
  * Each segment is checked in this order. The chain is ordered so the
62
- * FIRST match wins at catch time.
77
+ * FIRST match wins at catch time. This mirrors resolveStatusFile's 4xx
78
+ * component chain (status-code-resolver.ts) — the two walks must stay in
79
+ * sync or in-tree and re-render deny paths resolve different files.
63
80
  */
64
81
  export async function buildDenyPageChain(
65
82
  segments: ManifestSegmentNode[],
@@ -82,7 +99,12 @@ export async function buildDenyPageChain(
82
99
  if (!isNaN(status)) {
83
100
  const mod = await loadModule(file).catch(() => null);
84
101
  if (mod?.default) {
85
- chain.push({ status, component: mod.default as (...args: unknown[]) => unknown });
102
+ chain.push({
103
+ status,
104
+ component: mod.default as (...args: unknown[]) => unknown,
105
+ kind: 'status',
106
+ isMdx: isMdxFilePath(file.filePath),
107
+ });
86
108
  }
87
109
  }
88
110
  }
@@ -97,21 +119,50 @@ export async function buildDenyPageChain(
97
119
  chain.push({
98
120
  status: categoryStatus,
99
121
  component: mod.default as (...args: unknown[]) => unknown,
122
+ kind: 'status',
123
+ isMdx: isMdxFilePath(file.filePath),
100
124
  });
101
125
  }
102
126
  }
103
127
  }
104
128
  }
105
129
 
106
- // Pass 2: error.tsx files — lowest priority catch-all.
107
- // Only added AFTER all status files so they never shadow a specific
108
- // or category status file from an ancestor segment.
130
+ // Pass 2: legacy compat files (not-found.tsx → 404, forbidden.tsx → 403,
131
+ // unauthorized.tsx → 401). Lower priority than status files (a root
132
+ // 4xx.tsx beats a leaf not-found.tsx) but higher than error.tsx — the
133
+ // same ordering as resolveStatusFile's resolve4xx. See TIM-1081.
134
+ for (let i = startIndex; i >= 0; i--) {
135
+ const segment = segments[i];
136
+ if (!segment.legacyStatusFiles) continue;
137
+ for (const [name, status] of Object.entries(LEGACY_FILE_TO_STATUS)) {
138
+ const file = segment.legacyStatusFiles[name];
139
+ if (!file) continue;
140
+ const mod = await loadModule(file).catch(() => null);
141
+ if (mod?.default) {
142
+ chain.push({
143
+ status,
144
+ component: mod.default as (...args: unknown[]) => unknown,
145
+ kind: 'legacy',
146
+ isMdx: isMdxFilePath(file.filePath),
147
+ });
148
+ }
149
+ }
150
+ }
151
+
152
+ // Pass 3: error.tsx files — lowest priority catch-all.
153
+ // Only added AFTER all status and legacy files so they never shadow a
154
+ // more specific file from an ancestor segment.
109
155
  for (let i = startIndex; i >= 0; i--) {
110
156
  const segment = segments[i];
111
157
  if (segment.error) {
112
158
  const mod = await loadModule(segment.error).catch(() => null);
113
159
  if (mod?.default) {
114
- chain.push({ status: null, component: mod.default as (...args: unknown[]) => unknown });
160
+ chain.push({
161
+ status: null,
162
+ component: mod.default as (...args: unknown[]) => unknown,
163
+ kind: 'error',
164
+ isMdx: isMdxFilePath(segment.error.filePath),
165
+ });
115
166
  }
116
167
  }
117
168
  }
@@ -130,25 +181,67 @@ export function renderMatchingDenyPage(
130
181
  status: number,
131
182
  data: unknown
132
183
  ): React.ReactElement | null {
133
- const h = createElement as (...args: unknown[]) => React.ReactElement;
134
-
135
184
  for (const entry of chain) {
136
185
  if (entry.status === status) {
137
- return h(entry.component, { status, dangerouslyPassData: data });
186
+ return renderDenyEntry(entry, status, data);
138
187
  }
139
188
  if (entry.status === 400 && status >= 400 && status <= 499) {
140
- return h(entry.component, { status, dangerouslyPassData: data });
189
+ return renderDenyEntry(entry, status, data);
141
190
  }
142
191
  if (entry.status === 500 && status >= 500 && status <= 599) {
143
- return h(entry.component, { status, dangerouslyPassData: data });
192
+ return renderDenyEntry(entry, status, data);
144
193
  }
145
194
  if (entry.status === null) {
146
- return h(entry.component, { status, dangerouslyPassData: data });
195
+ return renderDenyEntry(entry, status, data);
147
196
  }
148
197
  }
149
198
  return null;
150
199
  }
151
200
 
201
+ /**
202
+ * Build the element for a matched deny chain entry with the props contract
203
+ * the file's convention documents:
204
+ *
205
+ * - Status-code and legacy files: { status, dangerouslyPassData }
206
+ * - error.tsx (TSX): { error, digest, reset } via ErrorReconstituter, plus
207
+ * { status, dangerouslyPassData } for dual-shape implementations. Without
208
+ * the Error prop, any error.tsx written per the docs (reading
209
+ * error.message) crashes — turning a clean deny(404) into a 500. TIM-1081.
210
+ * - error.mdx: plain { status } — server component, static content.
211
+ */
212
+ export function renderDenyEntry(
213
+ entry: DenyPageEntry,
214
+ status: number,
215
+ data: unknown
216
+ ): React.ReactElement {
217
+ const h = createElement as (...args: unknown[]) => React.ReactElement;
218
+
219
+ if (entry.kind !== 'error') {
220
+ return h(entry.component, { status, dangerouslyPassData: data });
221
+ }
222
+
223
+ // MDX error pages receive plain props — no Error serialization possible.
224
+ if (entry.isMdx) {
225
+ return h(entry.component, { status });
226
+ }
227
+
228
+ // The message is derived solely from the status code (already on the
229
+ // wire) — safe to cross the RSC→client boundary in production. No stack,
230
+ // no user data. See design/13-security.md §"Errors don't leak".
231
+ const serializableDeny: SerializableError = {
232
+ message: `Access denied with status ${status}`,
233
+ name: 'DenySignal',
234
+ };
235
+ return h(ErrorReconstituter, {
236
+ error: serializableDeny,
237
+ digest: null,
238
+ reset: undefined,
239
+ component: entry.component,
240
+ status,
241
+ dangerouslyPassData: data,
242
+ });
243
+ }
244
+
152
245
  // ─── Page Boundary ────────────────────────────────────────────────────────
153
246
 
154
247
  /**