@timber-js/app 0.2.0-alpha.200 → 0.2.0-alpha.202

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 (143) hide show
  1. package/bin/timber.mjs +15 -1
  2. package/dist/_chunks/{actions-d1hCqnU3.js → actions-HUdJADAD.js} +3 -3
  3. package/dist/_chunks/{actions-d1hCqnU3.js.map → actions-HUdJADAD.js.map} +1 -1
  4. package/dist/_chunks/{build-manifest-DTmSGLRz.js → build-manifest-DWppEdLB.js} +2 -51
  5. package/dist/_chunks/build-manifest-DWppEdLB.js.map +1 -0
  6. package/dist/_chunks/{cache-api-ByagcC-J.js → cache-api-CAPbZTga.js} +2 -2
  7. package/dist/_chunks/{cache-api-ByagcC-J.js.map → cache-api-CAPbZTga.js.map} +1 -1
  8. package/dist/_chunks/{chains-Bpb0W4ax.js → chains-CBNA0Ozj.js} +3 -3
  9. package/dist/_chunks/{chains-Bpb0W4ax.js.map → chains-CBNA0Ozj.js.map} +1 -1
  10. package/dist/_chunks/{cli-check-D6VolrDV.js → cli-check-BzMGuIH6.js} +3 -3
  11. package/dist/_chunks/{cli-check-D6VolrDV.js.map → cli-check-BzMGuIH6.js.map} +1 -1
  12. package/dist/_chunks/{cli-schema-sync-D6rO-VcS.js → cli-schema-sync-DnXqcIIj.js} +2 -2
  13. package/dist/_chunks/{cli-schema-sync-D6rO-VcS.js.map → cli-schema-sync-DnXqcIIj.js.map} +1 -1
  14. package/dist/_chunks/{cloudflare-BFb__LYG.js → cloudflare-DxX1SU0g.js} +3 -3
  15. package/dist/_chunks/{cloudflare-BFb__LYG.js.map → cloudflare-DxX1SU0g.js.map} +1 -1
  16. package/dist/_chunks/{convention-lint-fRkwVwEH.js → convention-lint-DHOFvX5s.js} +5 -95
  17. package/dist/_chunks/convention-lint-DHOFvX5s.js.map +1 -0
  18. package/dist/_chunks/csp-nonce-hOGniaG4.js +227 -0
  19. package/dist/_chunks/csp-nonce-hOGniaG4.js.map +1 -0
  20. package/dist/_chunks/dev-server-DioP7tkQ.js +2288 -0
  21. package/dist/_chunks/dev-server-DioP7tkQ.js.map +1 -0
  22. package/dist/_chunks/{error-boundary-BfPHZjm0.js → error-boundary-BQKxl6EX.js} +11 -17
  23. package/dist/_chunks/{error-boundary-BfPHZjm0.js.map → error-boundary-BQKxl6EX.js.map} +1 -1
  24. package/dist/_chunks/{graph-cache-CP4GEmf9.js → graph-cache-Cv3njEH8.js} +2 -2
  25. package/dist/_chunks/{graph-cache-CP4GEmf9.js.map → graph-cache-Cv3njEH8.js.map} +1 -1
  26. package/dist/_chunks/{live-graph-D_2D32Ad.js → live-graph-VjHFF5EV.js} +3 -3
  27. package/dist/_chunks/{live-graph-D_2D32Ad.js.map → live-graph-VjHFF5EV.js.map} +1 -1
  28. package/dist/_chunks/{logger-uLBuGKDI.js → logger-DqJ2VoAY.js} +450 -458
  29. package/dist/_chunks/logger-DqJ2VoAY.js.map +1 -0
  30. package/dist/_chunks/{segment-keys-lqtdookO.js → metadata-routes-DSDjM_hJ.js} +2 -61
  31. package/dist/_chunks/metadata-routes-DSDjM_hJ.js.map +1 -0
  32. package/dist/_chunks/{poison-scan-Bm9Yyqk9.js → poison-scan-lEbz4pQE.js} +2 -2
  33. package/dist/_chunks/{poison-scan-Bm9Yyqk9.js.map → poison-scan-lEbz4pQE.js.map} +1 -1
  34. package/dist/_chunks/{scanner-AiazgH_f.js → scanner-B_tnqFcF.js} +37 -137
  35. package/dist/_chunks/scanner-B_tnqFcF.js.map +1 -0
  36. package/dist/_chunks/segment-keys-D5hu1hz4.js +62 -0
  37. package/dist/_chunks/segment-keys-D5hu1hz4.js.map +1 -0
  38. package/dist/_chunks/tree-match-CdbvYTBz.js +122 -0
  39. package/dist/_chunks/tree-match-CdbvYTBz.js.map +1 -0
  40. package/dist/_chunks/{walkers-B6XUtmqK.js → walkers-Cm3PC5JT.js} +2 -2
  41. package/dist/_chunks/{walkers-B6XUtmqK.js.map → walkers-Cm3PC5JT.js.map} +1 -1
  42. package/dist/adapters/cloudflare-dev.js +1 -1
  43. package/dist/adapters/cloudflare-kv-cache.js +1 -1
  44. package/dist/adapters/cloudflare.js +1 -1
  45. package/dist/analyze/crawl-entry.js +2 -2
  46. package/dist/analyze/graph-command.js +3 -3
  47. package/dist/cache/index.js +1 -1
  48. package/dist/cli.d.ts +7 -2
  49. package/dist/cli.d.ts.map +1 -1
  50. package/dist/cli.js +44 -7
  51. package/dist/cli.js.map +1 -1
  52. package/dist/client/browser-dev.d.ts +5 -1
  53. package/dist/client/browser-dev.d.ts.map +1 -1
  54. package/dist/client/browser-entry/action-dispatch.d.ts.map +1 -1
  55. package/dist/client/browser-entry/action-queue.d.ts +20 -1
  56. package/dist/client/browser-entry/action-queue.d.ts.map +1 -1
  57. package/dist/client/browser-entry/rsc-stream.d.ts.map +1 -1
  58. package/dist/client/error-boundary.d.ts +3 -15
  59. package/dist/client/error-boundary.d.ts.map +1 -1
  60. package/dist/client/error-boundary.js +1 -1
  61. package/dist/client/error-reconstituter.d.ts +4 -4
  62. package/dist/client/error-reconstituter.d.ts.map +1 -1
  63. package/dist/client/internal.js +1 -1
  64. package/dist/dev-tools/debug-channel.d.ts +55 -0
  65. package/dist/dev-tools/debug-channel.d.ts.map +1 -0
  66. package/dist/index.d.ts.map +1 -1
  67. package/dist/index.js +302 -2245
  68. package/dist/index.js.map +1 -1
  69. package/dist/plugins/dev-server.d.ts +7 -0
  70. package/dist/plugins/dev-server.d.ts.map +1 -1
  71. package/dist/plugins/mdx.d.ts.map +1 -1
  72. package/dist/routing/convention-lint.d.ts.map +1 -1
  73. package/dist/routing/index.js +2 -2
  74. package/dist/routing/scanner.d.ts +4 -4
  75. package/dist/routing/scanner.d.ts.map +1 -1
  76. package/dist/server/access-gate.d.ts +2 -2
  77. package/dist/server/deny-boundary.d.ts +3 -5
  78. package/dist/server/deny-boundary.d.ts.map +1 -1
  79. package/dist/server/deny-renderer.d.ts +0 -1
  80. package/dist/server/deny-renderer.d.ts.map +1 -1
  81. package/dist/server/error-boundary-wrapper.d.ts +5 -12
  82. package/dist/server/error-boundary-wrapper.d.ts.map +1 -1
  83. package/dist/server/index.js +2 -2
  84. package/dist/server/internal.js +20 -195
  85. package/dist/server/internal.js.map +1 -1
  86. package/dist/server/pipeline.d.ts +8 -0
  87. package/dist/server/pipeline.d.ts.map +1 -1
  88. package/dist/server/primitives.d.ts +15 -13
  89. package/dist/server/primitives.d.ts.map +1 -1
  90. package/dist/server/rsc-entry/error-renderer.d.ts +3 -7
  91. package/dist/server/rsc-entry/error-renderer.d.ts.map +1 -1
  92. package/dist/server/rsc-entry/helpers.d.ts +6 -27
  93. package/dist/server/rsc-entry/helpers.d.ts.map +1 -1
  94. package/dist/server/rsc-entry/index.d.ts.map +1 -1
  95. package/dist/server/rsc-entry/render-route.d.ts +1 -0
  96. package/dist/server/rsc-entry/render-route.d.ts.map +1 -1
  97. package/dist/server/rsc-entry/rsc-stream.d.ts +4 -1
  98. package/dist/server/rsc-entry/rsc-stream.d.ts.map +1 -1
  99. package/dist/server/rsc-entry/ssr-renderer.d.ts +1 -0
  100. package/dist/server/rsc-entry/ssr-renderer.d.ts.map +1 -1
  101. package/dist/server/slot-resolver.d.ts.map +1 -1
  102. package/dist/server/slot-subtree-contain.d.ts +1 -1
  103. package/dist/server/slot-subtree-contain.d.ts.map +1 -1
  104. package/docs/api/30-api-server.mdx +5 -5
  105. package/docs/api/34-api-config.mdx +2 -2
  106. package/docs/api/36-cli.mdx +1 -1
  107. package/docs/learn/12-error-handling.mdx +7 -8
  108. package/package.json +2 -3
  109. package/src/cli.ts +62 -8
  110. package/src/client/browser-dev.ts +35 -3
  111. package/src/client/browser-entry/action-dispatch.ts +12 -0
  112. package/src/client/browser-entry/action-queue.ts +70 -8
  113. package/src/client/browser-entry/rsc-stream.ts +78 -22
  114. package/src/client/error-boundary.tsx +13 -39
  115. package/src/client/error-reconstituter.tsx +5 -5
  116. package/src/dev-tools/debug-channel.ts +151 -0
  117. package/src/index.ts +8 -12
  118. package/src/plugins/dev-server.ts +58 -7
  119. package/src/plugins/mdx.ts +2 -1
  120. package/src/routing/convention-lint.ts +4 -111
  121. package/src/routing/scanner.ts +33 -22
  122. package/src/server/access-gate.tsx +3 -3
  123. package/src/server/deny-boundary.ts +5 -23
  124. package/src/server/deny-renderer.ts +4 -7
  125. package/src/server/error-boundary-wrapper.ts +11 -35
  126. package/src/server/pipeline.ts +9 -0
  127. package/src/server/primitives.ts +20 -26
  128. package/src/server/rsc-entry/error-renderer.ts +15 -43
  129. package/src/server/rsc-entry/helpers.ts +10 -67
  130. package/src/server/rsc-entry/index.ts +1 -0
  131. package/src/server/rsc-entry/render-route.ts +12 -1
  132. package/src/server/rsc-entry/rsc-stream.ts +27 -24
  133. package/src/server/rsc-entry/ssr-renderer.ts +7 -1
  134. package/src/server/slot-resolver.ts +29 -7
  135. package/src/server/slot-subtree-contain.ts +13 -4
  136. package/dist/_chunks/build-manifest-DTmSGLRz.js.map +0 -1
  137. package/dist/_chunks/convention-lint-fRkwVwEH.js.map +0 -1
  138. package/dist/_chunks/logger-uLBuGKDI.js.map +0 -1
  139. package/dist/_chunks/scanner-AiazgH_f.js.map +0 -1
  140. package/dist/_chunks/segment-keys-lqtdookO.js.map +0 -1
  141. package/dist/server/utils/mdx-file.d.ts +0 -17
  142. package/dist/server/utils/mdx-file.d.ts.map +0 -1
  143. package/src/server/utils/mdx-file.ts +0 -22
@@ -26,7 +26,8 @@ import { join } from 'node:path';
26
26
  import { createRequire } from 'node:module';
27
27
  import { pathToFileURL } from 'node:url';
28
28
  import type { PluginContext } from '../plugin-context.ts';
29
- import { MDX_EXTENSIONS } from '../server/utils/mdx-file.ts';
29
+ /** MDX/markdown extensions (dotted) — the canonical list for the compiler plugin. */
30
+ const MDX_EXTENSIONS = new Set(['.mdx', '.md']);
30
31
 
31
32
  const MDX_EXTENSIONS_UNDOTTED = [...MDX_EXTENSIONS].map((ext) => ext.slice(1));
32
33
 
@@ -17,10 +17,9 @@
17
17
  * Design doc: 07-routing.md, 10-error-handling.md
18
18
  */
19
19
 
20
- import { existsSync, readFileSync } from 'node:fs';
20
+ import { existsSync } from 'node:fs';
21
21
  import type { RouteFile, RouteTree, SegmentNode } from './types.ts';
22
22
  import { swallow } from '../server/logger.ts';
23
- import { isMdxFilePath } from '../server/utils/mdx-file.ts';
24
23
  import {
25
24
  fileDefaultExportIsLocalValue,
26
25
  fileHasAnyExport,
@@ -80,11 +79,6 @@ export function lintConventions(tree: RouteTree, appDir: string): ConventionWarn
80
79
  checkFileUseClientDirective(tree.globalError, 'global-error', warnings);
81
80
  }
82
81
 
83
- // Check 8: Wildcard MDX status files (4xx.mdx, 5xx.mdx, error.mdx) that
84
- // reference `props` — props resolve on deny and throw paths but are empty
85
- // on the boundary path. See design/10-error-handling.md §"MDX Props by Path".
86
- checkWildcardMdxProps(tree.root, warnings);
87
-
88
82
  return warnings;
89
83
  }
90
84
 
@@ -335,10 +329,6 @@ function checkFileDefaultExport(
335
329
  * error path. The raw message ("Functions cannot be passed directly to Client
336
330
  * Components") names neither the file nor the fix (TIM-1329).
337
331
  *
338
- * MDX status files are exempt: they are server components by design and the
339
- * renderer pre-renders them into `fallbackElement` instead. See TIM-503 and
340
- * `server/error-boundary-wrapper.ts`.
341
- *
342
332
  * `.json` status files never reach React at all, and are not in `statusFiles`.
343
333
  */
344
334
  function checkClientBoundaryFiles(node: SegmentNode, warnings: ConventionWarning[]): void {
@@ -392,17 +382,9 @@ function checkFileUseClientDirective(
392
382
  convention: string,
393
383
  warnings: ConventionWarning[]
394
384
  ): void {
395
- // MDX/markdown status and error files are server components by design — the
396
- // renderer pre-renders them into `fallbackElement`, so they never cross the
397
- // boundary as a function.
398
- //
399
- // The exemption is the *complement* of the renderer's own test, using the
400
- // renderer's own helper: everything `error-boundary-wrapper.ts` and
401
- // `error-renderer.ts` do not treat as MDX, they pass as a component. An
402
- // independent "is this a script extension?" list here would be a second
403
- // expression of that fact, free to drift into a gap where a file is a
404
- // component to the renderer and exempt to the linter.
405
- if (isMdxFilePath(file.filePath)) return;
385
+ // Only script extensions need the 'use client' check — .json status files
386
+ // never reach React at all, and are not in `statusFiles`.
387
+ if (!isScriptExtension(file.extension)) return;
406
388
 
407
389
  const filePath = file.filePath;
408
390
  const label = `${convention}.${file.extension}`;
@@ -449,95 +431,6 @@ function checkFileUseClientDirective(
449
431
  });
450
432
  }
451
433
 
452
- // ─── Check: Wildcard MDX Props ──────────────────────────────────────────────
453
-
454
- /**
455
- * Wildcard status file keys in `statusFiles` whose props differ by path.
456
- * `error` is not here — the scanner stores it in `node.error`, not
457
- * `node.statusFiles`, and the check handles it separately below.
458
- */
459
- const WILDCARD_MDX_STATUS_KEYS = new Set(['4xx', '5xx']);
460
-
461
- /**
462
- * Warn when a wildcard MDX status file (`4xx.mdx`, `5xx.mdx`, `error.mdx`)
463
- * appears to reference `props` in its source.
464
- *
465
- * These files are pre-rendered into `fallbackElement` on the error-boundary
466
- * path, before the error happens, so they receive empty or partial props.
467
- * On the deny and error-renderer paths they receive the real props. An author
468
- * writing `{props.status}` gets the number on some paths and `undefined` on
469
- * others — same source, different output.
470
- *
471
- * A text scan for `props` is acceptable here because the check is `level:
472
- * 'warn'` — a false positive costs a printed line, not a refused build. A
473
- * sound check would need the MDX compiler, which is optional, async, and
474
- * scoped to the RSC environment. See TIM-1333.
475
- *
476
- * Exact-code MDX files (e.g. `503.mdx`) are unaffected — all four paths
477
- * agree on their status.
478
- */
479
- function checkWildcardMdxProps(node: SegmentNode, warnings: ConventionWarning[]): void {
480
- if (node.statusFiles) {
481
- for (const [key, file] of Object.entries(node.statusFiles)) {
482
- if (WILDCARD_MDX_STATUS_KEYS.has(key) && isMdxFilePath(file.filePath)) {
483
- checkMdxFileForProps(file.filePath, key, warnings);
484
- }
485
- }
486
- }
487
- if (node.error && isMdxFilePath(node.error.filePath)) {
488
- checkMdxFileForProps(node.error.filePath, 'error', warnings);
489
- }
490
-
491
- for (const child of node.children) {
492
- checkWildcardMdxProps(child, warnings);
493
- }
494
- for (const slot of Object.values(node.slots)) {
495
- checkWildcardMdxProps(slot, warnings);
496
- }
497
- }
498
-
499
- /**
500
- * Check if an MDX file's source appears to reference `props`.
501
- *
502
- * MDX passes a `props` object to the default export function. A reference to
503
- * `props.status` or `props.dangerouslyPassData` in a wildcard file works on
504
- * the deny/throw paths but renders `undefined` on the boundary path.
505
- */
506
- function checkMdxFileForProps(
507
- filePath: string,
508
- convention: string,
509
- warnings: ConventionWarning[]
510
- ): void {
511
- let source: string;
512
- try {
513
- source = readFileSync(filePath, 'utf-8');
514
- } catch {
515
- return;
516
- }
517
-
518
- // MDX exposes props via the default export: export default function(props) { ... }
519
- // and via JSX expressions: {props.status}. A text scan is intentionally
520
- // broad — false positives are cheap at warn level.
521
- if (!/\bprops\b/.test(source)) return;
522
-
523
- const label = `${convention}.${filePath.endsWith('.md') ? 'md' : 'mdx'}`;
524
- warnings.push({
525
- id: `MDX_WILDCARD_PROPS:${filePath}`,
526
- summary: `${label} references props, which are empty on the error boundary path: ${filePath}`,
527
- details:
528
- ` File: ${filePath}\n\n` +
529
- ` Wildcard MDX status files (${convention}.mdx) are pre-rendered into\n` +
530
- ' fallbackElement before the error occurs. On that path, props are\n' +
531
- ` empty${convention === 'error' ? '' : ' (no status, no dangerouslyPassData)'}. On the deny() and error-renderer\n` +
532
- ' paths, props.status resolves correctly.\n\n' +
533
- ' If this file uses only static content and "props" appears in prose,\n' +
534
- ' this warning is a false positive — ignore it.\n\n' +
535
- ' To fix: Use only static content in wildcard MDX status files, or\n' +
536
- ' use an exact-code file (e.g. 404.mdx) where all paths agree.\n',
537
- level: 'warn',
538
- });
539
- }
540
-
541
434
  // ─── Check: App Directory Exists ────────────────────────────────────────────
542
435
 
543
436
  /**
@@ -29,8 +29,9 @@ import { ENCODED_SEPARATOR_RE, NULL_BYTE_RE } from '../server/canonicalize.ts';
29
29
 
30
30
  /**
31
31
  * File convention names that use pageExtensions (can be .tsx, .ts, .jsx, .js, .mdx, etc.)
32
+ * `error` is excluded — it only accepts script extensions (TIM-1505).
32
33
  */
33
- const PAGE_EXT_CONVENTIONS = new Set(['page', 'layout', 'error', 'default', 'denied']);
34
+ const PAGE_EXT_CONVENTIONS = new Set(['page', 'layout', 'default', 'denied']);
34
35
 
35
36
  /**
36
37
  * File convention names that are always .ts/.tsx (never .mdx etc.)
@@ -46,10 +47,10 @@ const STATUS_CODE_PATTERN = /^(\d{3}|[45]xx)$/;
46
47
 
47
48
  /**
48
49
  * Whether a basename + extension pair is a route-convention file the
49
- * framework itself imports: page/layout/error/default/denied and
50
- * global-error on pageExtensions, middleware/access/route/proxy on
51
- * .ts/.tsx/.js/.jsx only, and status-code files (404, 4xx, …) on
52
- * pageExtensions or .json — mirroring this scanner's own recognition
50
+ * framework itself imports: page/layout/default/denied on pageExtensions,
51
+ * error/global-error and status-code files on script extensions only,
52
+ * middleware/access/route/proxy on .ts/.tsx/.js/.jsx only, and
53
+ * status-code .json files — mirroring this scanner's own recognition
53
54
  * rules per family.
54
55
  *
55
56
  * Exported for the analyze classifier so its provisional classification
@@ -62,9 +63,11 @@ export function isRouteConventionFile(
62
63
  pageExtensions: readonly string[] = DEFAULT_PAGE_EXTENSIONS
63
64
  ): boolean {
64
65
  const isPageExt = pageExtensions.includes(ext);
65
- if ((PAGE_EXT_CONVENTIONS.has(base) || base === 'global-error') && isPageExt) return true;
66
- if ((FIXED_CONVENTIONS.has(base) || base === 'proxy') && /^[jt]sx?$/.test(ext)) return true;
67
- if (STATUS_CODE_PATTERN.test(base) && (isPageExt || ext === 'json')) return true;
66
+ const isScriptExt = /^[jt]sx?$/.test(ext);
67
+ if (PAGE_EXT_CONVENTIONS.has(base) && isPageExt) return true;
68
+ if ((base === 'error' || base === 'global-error') && isScriptExt) return true;
69
+ if ((FIXED_CONVENTIONS.has(base) || base === 'proxy') && isScriptExt) return true;
70
+ if (STATUS_CODE_PATTERN.test(base) && (isScriptExt || ext === 'json')) return true;
68
71
  return false;
69
72
  }
70
73
 
@@ -91,8 +94,9 @@ export function scanRoutes(appDir: string, config: ScannerConfig = {}): RouteTre
91
94
 
92
95
  // Check for global-error.{tsx,ts,jsx,js} at app root.
93
96
  // Tier 2 error page — renders standalone (no layouts) when no segment-level
94
- // error file is found. See design/10-error-handling.md §"Tier 2".
95
- const globalErrorFile = findPageExtFile(appDir, 'global-error', extSet);
97
+ // error file is found. Script extensions only — MDX not supported (TIM-1505).
98
+ // See design/10-error-handling.md §"Tier 2".
99
+ const globalErrorFile = findScriptExtFile(appDir, 'global-error');
96
100
  if (globalErrorFile) {
97
101
  tree.globalError = globalErrorFile;
98
102
  }
@@ -197,9 +201,9 @@ function scanSegmentFiles(dirPath: string, node: SegmentNode, extSet: Set<string
197
201
  const ext = extname(entry).slice(1); // remove leading dot
198
202
  const name = basename(entry, `.${ext}`);
199
203
 
200
- // Page-extension conventions (page, layout, error, default, denied)
204
+ // Page-extension conventions (page, layout, default, denied)
201
205
  if (PAGE_EXT_CONVENTIONS.has(name) && extSet.has(ext)) {
202
- const key = name as 'page' | 'layout' | 'error' | 'default' | 'denied';
206
+ const key = name as 'page' | 'layout' | 'default' | 'denied';
203
207
  const existing = node[key];
204
208
  if (existing) {
205
209
  throwDuplicateConvention(name, existing.filePath, fullPath);
@@ -208,6 +212,16 @@ function scanSegmentFiles(dirPath: string, node: SegmentNode, extSet: Set<string
208
212
  continue;
209
213
  }
210
214
 
215
+ // error.tsx — script extensions only, not MDX/MD (TIM-1505).
216
+ if (name === 'error' && /^[jt]sx?$/.test(ext)) {
217
+ const existing = node.error;
218
+ if (existing) {
219
+ throwDuplicateConvention('error', existing.filePath, fullPath);
220
+ }
221
+ node.error = { filePath: fullPath, extension: ext };
222
+ continue;
223
+ }
224
+
211
225
  // Fixed conventions (middleware, access, route) — only
212
226
  // .ts/.tsx/.js/.jsx, never pageExtensions like .mdx. The regex is
213
227
  // anchored: mts/cts/mjs/cjs (and stray tail matches like `.foots`)
@@ -232,8 +246,8 @@ function scanSegmentFiles(dirPath: string, node: SegmentNode, extSet: Set<string
232
246
  continue;
233
247
  }
234
248
 
235
- // Status-code files (401.tsx, 4xx.tsx, 503.tsx, 5xx.tsx)
236
- if (STATUS_CODE_PATTERN.test(name) && extSet.has(ext)) {
249
+ // Status-code files (401.tsx, 4xx.tsx, 503.tsx, 5xx.tsx) — script extensions only (TIM-1505)
250
+ if (STATUS_CODE_PATTERN.test(name) && /^[jt]sx?$/.test(ext)) {
237
251
  if (!node.statusFiles) {
238
252
  node.statusFiles = {};
239
253
  }
@@ -652,15 +666,12 @@ function findFixedFile(dirPath: string, name: string): RouteFile | undefined {
652
666
  }
653
667
 
654
668
  /**
655
- * Find a file using the configured page extensions (tsx, ts, jsx, js, mdx, etc.).
656
- * Used for app-root conventions like global-error that aren't per-segment.
669
+ * Find a file with script extensions (tsx, ts, jsx, js) in a directory.
670
+ * Used for app-root conventions like global-error that only accept script
671
+ * extensions (TIM-1505).
657
672
  */
658
- function findPageExtFile(
659
- dirPath: string,
660
- name: string,
661
- extSet: Set<string>
662
- ): RouteFile | undefined {
663
- for (const ext of extSet) {
673
+ function findScriptExtFile(dirPath: string, name: string): RouteFile | undefined {
674
+ for (const ext of ['tsx', 'ts', 'jsx', 'js']) {
664
675
  const fullPath = join(dirPath, `${name}.${ext}`);
665
676
  try {
666
677
  if (statSync(fullPath).isFile()) {
@@ -86,7 +86,7 @@ export interface AccessGateProps {
86
86
  * On denial, renders denied.tsx → default.tsx → null instead of failing the page.
87
87
  *
88
88
  * DeniedComponent is passed instead of a pre-built element so that
89
- * SlotAccessGate can forward DenySignal.data as dangerouslyPassData
89
+ * SlotAccessGate can forward DenySignal.data as the data prop
90
90
  * and slotName as the slot prop after catching the signal.
91
91
  */
92
92
  export interface SlotAccessGateProps {
@@ -205,7 +205,7 @@ async function accessGateFallback(
205
205
  * a protocol concern. The parent layout and sibling slots still render.
206
206
  *
207
207
  * DeniedComponent is passed instead of a pre-built element so that
208
- * DenySignal.data can be forwarded as the dangerouslyPassData prop
208
+ * DenySignal.data can be forwarded as the data prop
209
209
  * and the slot name can be passed as the slot prop. See TIM-488.
210
210
  *
211
211
  * redirect() in slot access.ts is a dev-mode error — redirecting from a
@@ -276,6 +276,6 @@ function buildDeniedFallback(
276
276
  if (!DeniedComponent) return null;
277
277
  return createElement(DeniedComponent, {
278
278
  slot: slotName,
279
- dangerouslyPassData: data,
279
+ data,
280
280
  });
281
281
  }
@@ -44,7 +44,6 @@ import { loadModule } from './safe-load.ts';
44
44
  import type { ManifestLoader } from './safe-load.ts';
45
45
  import { withSpan } from './tracing.ts';
46
46
  import { isValidElementType, type LoadedComponent } from './utils/element-type.ts';
47
- import { isMdxFilePath } from './utils/mdx-file.ts';
48
47
  import type { ManifestSegmentNode } from './route-matcher.ts';
49
48
 
50
49
  // ─── Types ────────────────────────────────────────────────────────────────
@@ -61,8 +60,6 @@ export interface DenyPageEntry {
61
60
  * { error, digest, reset } contract, not bare deny props. See TIM-1081.
62
61
  */
63
62
  kind: 'status' | 'error';
64
- /** MDX files are server components — rendered with plain props, never ErrorReconstituter. */
65
- isMdx: boolean;
66
63
  /**
67
64
  * Position in the segment chain (0 = root, ascending toward leaf).
68
65
  * Used by `matchDenyEntry` to reconstruct per-segment ordering for 5xx
@@ -105,7 +102,7 @@ export interface DenyPageEntry {
105
102
  * resolution that already carries the position, and would otherwise have to
106
103
  * invent an owner it never reads.
107
104
  */
108
- export type DenyEntryShape = Pick<DenyPageEntry, 'component' | 'kind' | 'isMdx'>;
105
+ export type DenyEntryShape = Pick<DenyPageEntry, 'component' | 'kind'>;
109
106
 
110
107
  /**
111
108
  * Where a matched deny page must render, relative to the catcher that
@@ -185,7 +182,6 @@ export async function buildDenyPageChain(
185
182
  status,
186
183
  component,
187
184
  kind: 'status',
188
- isMdx: isMdxFilePath(file.filePath),
189
185
  segmentIndex: i,
190
186
  ownerKey: segmentKeys[i],
191
187
  });
@@ -204,7 +200,6 @@ export async function buildDenyPageChain(
204
200
  status: categoryStatus,
205
201
  component,
206
202
  kind: 'status',
207
- isMdx: isMdxFilePath(file.filePath),
208
203
  segmentIndex: i,
209
204
  ownerKey: segmentKeys[i],
210
205
  });
@@ -225,7 +220,6 @@ export async function buildDenyPageChain(
225
220
  status: null,
226
221
  component,
227
222
  kind: 'error',
228
- isMdx: isMdxFilePath(segment.error.filePath),
229
223
  segmentIndex: i,
230
224
  ownerKey: segmentKeys[i],
231
225
  });
@@ -333,13 +327,6 @@ export function placeDenyPage(
333
327
  if (!entry) return null;
334
328
 
335
329
  if (entry.ownerKey !== selfKey) {
336
- // An MDX owner cannot be addressed. Its boundary holds a `fallbackElement`
337
- // pre-rendered at tree-build time — before this status or its data
338
- // existed — so hoisting to it would render a page that never learns what
339
- // was denied (a category `4xx.mdx` gets no `status` prop at all). Leave it
340
- // unaddressed so the re-render fallback serves it, which is the documented
341
- // MDX path and resolves the position correctly on its own. See TIM-503.
342
- if (entry.isMdx) return null;
343
330
  return { kind: 'hoist', ownerKey: entry.ownerKey };
344
331
  }
345
332
 
@@ -352,9 +339,9 @@ export function placeDenyPage(
352
339
  * Build the element for a matched deny chain entry with the props contract
353
340
  * the file's convention documents:
354
341
  *
355
- * - Status-code files: { status, dangerouslyPassData }
342
+ * - Status-code files: { status, data }
356
343
  * - error.tsx (TSX): { error, digest, reset } via ErrorReconstituter, plus
357
- * { status, dangerouslyPassData } for dual-shape implementations. Without
344
+ * { status, data } for dual-shape implementations. Without
358
345
  * the Error prop, any error.tsx written per the docs (reading
359
346
  * error.message) crashes — turning a clean deny(404) into a 500. TIM-1081.
360
347
  * - error.mdx: plain { status } — server component, static content.
@@ -379,12 +366,7 @@ function renderDenyPage(
379
366
  h: (...args: unknown[]) => React.ReactElement
380
367
  ): React.ReactElement {
381
368
  if (entry.kind !== 'error') {
382
- return h(entry.component, { status, dangerouslyPassData: data });
383
- }
384
-
385
- // MDX error pages receive plain props — no Error serialization possible.
386
- if (entry.isMdx) {
387
- return h(entry.component, { status });
369
+ return h(entry.component, { status, data });
388
370
  }
389
371
 
390
372
  // The message is derived solely from the status code (already on the
@@ -400,7 +382,7 @@ function renderDenyPage(
400
382
  reset: undefined,
401
383
  component: entry.component,
402
384
  status,
403
- dangerouslyPassData: data,
385
+ data,
404
386
  });
405
387
  }
406
388
 
@@ -26,7 +26,6 @@ import { renderDenyEntry, type DenyEntryShape } from './deny-boundary.ts';
26
26
  import { getCookiesForSsr } from './cookie-context.ts';
27
27
  import { logRenderError } from './logger.ts';
28
28
  import { loadModule } from './safe-load.ts';
29
- import { isMdxFilePath } from './utils/mdx-file.ts';
30
29
  import { isDebug } from './debug.ts';
31
30
  import { resolveMetadata, renderMetadataToElements } from './metadata.ts';
32
31
  import { headElementsToReact } from './metadata.ts';
@@ -51,7 +50,6 @@ export interface LayoutEntry {
51
50
 
52
51
  /** Callback to create a debug channel sink for RSC rendering. */
53
52
  export type DebugChannelFactory = () => {
54
- readable: ReadableStream;
55
53
  writable: WritableStream;
56
54
  };
57
55
 
@@ -86,7 +84,6 @@ function buildDenyElement(
86
84
  const entry: DenyEntryShape = {
87
85
  component,
88
86
  kind: resolutionKind === 'error' ? 'error' : 'status',
89
- isMdx: isMdxFilePath(filePath),
90
87
  };
91
88
  return renderDenyEntry(entry, deny.status, deny.data);
92
89
  }
@@ -183,7 +180,7 @@ export async function renderDenyPage(
183
180
  const ErrorPageComponent = mod.default as (...args: unknown[]) => unknown;
184
181
  const h = createElement as (...args: unknown[]) => React.ReactElement;
185
182
 
186
- // 4xx status-code pages receive { status, dangerouslyPassData }; an
183
+ // 4xx status-code pages receive { status, data }; an
187
184
  // error.tsx match gets { error, digest, reset } via ErrorReconstituter.
188
185
  // See design/10-error-handling.md §"Status-Code File Props" and TIM-1081.
189
186
  let element = buildDenyElement(
@@ -365,9 +362,9 @@ async function renderDenyPageJson(
365
362
  // A .json status file that fails to load is not a usable match — return
366
363
  // null so the caller continues to the framework default rather than
367
364
  // rejecting out of the chain.
368
- // Dev warning: deny(status, data) carries data but JSON status files are
369
- // static bodies — the data is silently dropped. The same deny() would
370
- // deliver the data to a .tsx file's dangerouslyPassData prop. See
365
+ // Dev warning: deny(status, { dangerouslyPassData }) carries data but JSON
366
+ // status files are static bodies — the data is silently dropped. The same
367
+ // deny() would deliver the data to a .tsx file's data prop. See
371
368
  // design/10-error-handling.md §"Status-Code File Variants".
372
369
  if (isDebug() && deny.data !== undefined) {
373
370
  console.warn(
@@ -2,11 +2,10 @@
2
2
  * Error boundary wrapper — wraps a React element in the error boundaries
3
3
  * declared by a route segment (`{status}.tsx`, `4xx.tsx`/`5xx.tsx`, `error.tsx`).
4
4
  *
5
- * This is the single implementation of that walk. It is driven by both
6
- * construction sites:
5
+ * This is the single implementation of that walk. It is driven by the
6
+ * request-time renderer walking the runtime manifest (`SegmentNode<ManifestFile>`):
7
7
  *
8
- * - `route-element-builder.ts` / `slot-resolver.ts` — the request-time
9
- * renderer, walking the runtime manifest (`SegmentNode<ManifestFile>`).
8
+ * - `route-element-builder.ts` / `slot-resolver.ts`
10
9
  *
11
10
  * `errorBoundaryComponent` is injected rather than imported: `TimberErrorBoundary`
12
11
  * is a `'use client'` module, and importing it here would pull client code into
@@ -19,12 +18,6 @@
19
18
  import type { ReactNode } from 'react';
20
19
  import type { SegmentNode } from '../routing/types.ts';
21
20
  import { isValidElementType, type LoadedComponent } from './utils/element-type.ts';
22
- import { isMdxFilePath } from './utils/mdx-file.ts';
23
-
24
- /** The minimum a route file must carry for the walk: a path to classify as MDX. */
25
- interface BoundaryFile {
26
- filePath: string;
27
- }
28
21
 
29
22
  /**
30
23
  * Framework-injected error boundary wrapper.
@@ -36,10 +29,8 @@ interface BoundaryFile {
36
29
  * construction site and may not import types from a 'use client' module.
37
30
  */
38
31
  export interface ErrorBoundaryProps {
39
- /** The component to render when an error is caught (TSX status files). */
32
+ /** The component to render when an error is caught. */
40
33
  fallbackComponent?: LoadedComponent;
41
- /** Pre-rendered fallback element (MDX status files — see TIM-503). */
42
- fallbackElement?: ReactNode;
43
34
  /** Status code filter: 400 = any 4xx, 500 = any 5xx, specific number = exact match. */
44
35
  status?: number;
45
36
  /**
@@ -119,24 +110,15 @@ async function loadBoundaryComponent<TFile>(
119
110
  /**
120
111
  * Wrap `element` in one error boundary rendering `Component` on failure.
121
112
  *
122
- * MDX status files are server components and cannot be passed as function
123
- * props to `TimberErrorBoundary` (a 'use client' component). They are
124
- * pre-rendered as elements and passed as `fallbackElement` instead. See TIM-503.
125
- *
126
- * @param status boundary status filter — omitted entirely for `error.tsx`
127
- * @param fallbackProps props handed to an MDX fallback when it is pre-rendered
113
+ * @param status boundary status filter — omitted entirely for `error.tsx`
128
114
  */
129
- function wrapInBoundary<TFile extends BoundaryFile, TElement extends ReactNode>(
115
+ function wrapInBoundary<TFile, TElement extends ReactNode>(
130
116
  element: TElement,
131
117
  Component: LoadedComponent,
132
- file: TFile,
133
118
  status: number | undefined,
134
- fallbackProps: object,
135
119
  config: ErrorBoundaryWrapConfig<TFile, TElement>
136
120
  ): TElement {
137
- const props: ErrorBoundaryProps = isMdxFilePath(file.filePath)
138
- ? { fallbackElement: config.createElement(Component, fallbackProps), children: element }
139
- : { fallbackComponent: Component, children: element };
121
+ const props: ErrorBoundaryProps = { fallbackComponent: Component, children: element };
140
122
  if (status !== undefined) props.status = status;
141
123
  if (config.segmentKey !== undefined) props.segmentKey = config.segmentKey;
142
124
  if (config.denyOnly) props.denyOnly = true;
@@ -158,10 +140,7 @@ function wrapInBoundary<TFile extends BoundaryFile, TElement extends ReactNode>(
158
140
  *
159
141
  * See design/10-error-handling.md §"Format-Aware Fallback Chains".
160
142
  */
161
- export async function wrapSegmentWithErrorBoundaries<
162
- TFile extends BoundaryFile,
163
- TElement extends ReactNode,
164
- >(
143
+ export async function wrapSegmentWithErrorBoundaries<TFile, TElement extends ReactNode>(
165
144
  segment: SegmentNode<TFile>,
166
145
  element: TElement,
167
146
  config: ErrorBoundaryWrapConfig<TFile, TElement>
@@ -176,7 +155,7 @@ export async function wrapSegmentWithErrorBoundaries<
176
155
  if (isNaN(status)) continue;
177
156
  const Component = await loadBoundaryComponent(file, loadModule);
178
157
  if (Component) {
179
- element = wrapInBoundary(element, Component, file, status, { status }, config);
158
+ element = wrapInBoundary(element, Component, status, config);
180
159
  }
181
160
  }
182
161
 
@@ -186,19 +165,16 @@ export async function wrapSegmentWithErrorBoundaries<
186
165
  const Component = await loadBoundaryComponent(file, loadModule);
187
166
  if (Component) {
188
167
  const categoryStatus = key === '4xx' ? 400 : 500;
189
- element = wrapInBoundary(element, Component, file, categoryStatus, {}, config);
168
+ element = wrapInBoundary(element, Component, categoryStatus, config);
190
169
  }
191
170
  }
192
171
  }
193
172
 
194
173
  // error.tsx (outermost — catches anything not matched by status files).
195
- // error.tsx/error.mdx receives { error, digest, reset } props from the
196
- // boundary at catch time; MDX error files are pre-rendered without them
197
- // (they're static content).
198
174
  if (segment.error) {
199
175
  const Component = await loadBoundaryComponent(segment.error, loadModule);
200
176
  if (Component) {
201
- element = wrapInBoundary(element, Component, segment.error, undefined, {}, config);
177
+ element = wrapInBoundary(element, Component, undefined, config);
202
178
  }
203
179
  }
204
180
 
@@ -215,6 +215,15 @@ export interface PipelineConfig {
215
215
  */
216
216
  devHmrOptions?: import('../dev-tools/dev-page-shell.ts').DevErrorHmrOptions;
217
217
 
218
+ /**
219
+ * Dev debug channel transport — sends RSC Flight debug data to the
220
+ * browser via HMR WebSocket for owner stack resolution in console
221
+ * warnings. Set by the dev server. Undefined in production.
222
+ *
223
+ * See TIM-1507, design/13-security.md §7.
224
+ */
225
+ devDebugRegistry?: import('../dev-tools/debug-channel.ts').DebugChannelRegistry;
226
+
218
227
  /**
219
228
  * Fallback error renderer — called when a catastrophic error escapes the
220
229
  * render phase. Produces an HTML Response instead of a bare empty 500.
@@ -228,14 +228,17 @@ export class DenySignal extends Error {
228
228
  }
229
229
  }
230
230
 
231
- /** Options for deny() when using the object form. */
231
+ /** Options for deny(). */
232
232
  export interface DenyOptions {
233
- /** HTTP status code (4xx or 5xx). Default: 403. */
234
- status?: number;
235
233
  /** Human-readable message (logged server-side, not sent to client). */
236
234
  message?: string;
237
- /** JSON-serializable data passed as `dangerouslyPassData` prop to status-code files. */
238
- data?: JsonSerializable;
235
+ /**
236
+ * JSON-serializable data forwarded as the `data` prop to status-code files
237
+ * and `denied.tsx`. Named `dangerouslyPassData` because this data crosses
238
+ * the RSC → client serialization boundary — do not pass sensitive server
239
+ * state (tokens, internal IDs, database rows).
240
+ */
241
+ dangerouslyPassData?: JsonSerializable;
239
242
  }
240
243
 
241
244
  /**
@@ -246,38 +249,29 @@ export interface DenyOptions {
246
249
  * - Inside Suspense (hold window): promoted to pre-flush behavior
247
250
  * - Inside Suspense (after flush): error boundary + noindex meta
248
251
  *
249
- * Supports both positional and object signatures:
250
252
  * ```ts
251
- * deny() // 403 (default)
252
- * deny(404) // 404
253
- * deny(503, { retry: true }) // 503 with data
254
- * deny({ status: 503, message: 'Maintenance' }) // object form
253
+ * deny() // 403 (default)
254
+ * deny(404) // 404
255
+ * deny(503, { message: 'Maintenance' }) // server-only log message
256
+ * deny(404, { dangerouslyPassData: { resourceId: params.id } }) // explicit client opt-in
255
257
  * ```
256
258
  *
257
259
  * Accepts any 4xx or 5xx status code. This replaces the need for
258
260
  * `throw new RenderError(...)` in user code — RenderError is now an
259
261
  * internal pipeline detail.
260
262
  *
261
- * @param statusOrOptions - Status code (number) or options object. Default: 403.
262
- * @param data - Optional JSON-serializable data (positional form only).
263
+ * @param status - HTTP status code (4xx or 5xx). Default: 403.
264
+ * @param options - Optional message and/or data to pass to the client.
263
265
  */
264
- export function deny(statusOrOptions?: number | DenyOptions, data?: JsonSerializable): never {
265
- let status: number;
266
- let resolvedData: JsonSerializable | undefined;
267
-
268
- if (typeof statusOrOptions === 'object' && statusOrOptions !== null) {
269
- status = statusOrOptions.status ?? 403;
270
- resolvedData = statusOrOptions.data;
271
- } else {
272
- status = statusOrOptions ?? 403;
273
- resolvedData = data;
274
- }
266
+ export function deny(status?: number, options?: DenyOptions): never {
267
+ const resolvedStatus = status ?? 403;
268
+ const resolvedData = options?.dangerouslyPassData;
275
269
 
276
- if (status < 400 || status > 599) {
277
- throw new Error(`deny() requires a 4xx or 5xx status code, got ${status}.`);
270
+ if (resolvedStatus < 400 || resolvedStatus > 599) {
271
+ throw new Error(`deny() requires a 4xx or 5xx status code, got ${resolvedStatus}.`);
278
272
  }
279
273
  warnIfNotSerializable(resolvedData, 'deny()');
280
- throw new DenySignal(status, resolvedData);
274
+ throw new DenySignal(resolvedStatus, resolvedData);
281
275
  }
282
276
 
283
277
  // Single source of truth shared with the client next/navigation shim —