@timber-js/app 0.2.0-alpha.209 → 0.2.0-alpha.210

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 (170) hide show
  1. package/dist/_chunks/{actions-BerlqoXA.js → actions-Rjk4htmA.js} +19 -25
  2. package/dist/_chunks/{actions-BerlqoXA.js.map → actions-Rjk4htmA.js.map} +1 -1
  3. package/dist/_chunks/als-registry-DaxkVjt5.js.map +1 -1
  4. package/dist/_chunks/{cache-api-CR23J_NC.js → cache-api-DGdYfNJn.js} +4 -4
  5. package/dist/_chunks/{cache-api-CR23J_NC.js.map → cache-api-DGdYfNJn.js.map} +1 -1
  6. package/dist/_chunks/{chains-CpFg56UB.js → chains-BoO51joc.js} +2 -2
  7. package/dist/_chunks/{chains-CpFg56UB.js.map → chains-BoO51joc.js.map} +1 -1
  8. package/dist/_chunks/{cli-check-C6Ev6wBO.js → cli-check-ajNY3B2e.js} +3 -3
  9. package/dist/_chunks/{cli-check-C6Ev6wBO.js.map → cli-check-ajNY3B2e.js.map} +1 -1
  10. package/dist/_chunks/{cli-schema-sync-CbT2AUUI.js → cli-schema-sync-D2eI8jEg.js} +2 -2
  11. package/dist/_chunks/{cli-schema-sync-CbT2AUUI.js.map → cli-schema-sync-D2eI8jEg.js.map} +1 -1
  12. package/dist/_chunks/{file-cache-Dw6BJPG7.js → codegen-Bps1sLKJ.js} +3 -29
  13. package/dist/_chunks/codegen-Bps1sLKJ.js.map +1 -0
  14. package/dist/_chunks/{convention-lint-jKTwKwPe.js → convention-lint-DLmhGsRS.js} +54 -316
  15. package/dist/_chunks/convention-lint-DLmhGsRS.js.map +1 -0
  16. package/dist/_chunks/{dev-server-C4WZdB7L.js → dev-server-v97rQH4b.js} +99 -9
  17. package/dist/_chunks/dev-server-v97rQH4b.js.map +1 -0
  18. package/dist/_chunks/{error-boundary-tA7kVfs4.js → error-boundary-DsNScGRM.js} +4 -4
  19. package/dist/_chunks/{error-boundary-tA7kVfs4.js.map → error-boundary-DsNScGRM.js.map} +1 -1
  20. package/dist/_chunks/{json-lossy-check-ip0Qi0MT.js → json-lossy-check-CVuRs2hG.js} +2 -2
  21. package/dist/_chunks/{json-lossy-check-ip0Qi0MT.js.map → json-lossy-check-CVuRs2hG.js.map} +1 -1
  22. package/dist/_chunks/{live-graph-Dv-JJCZw.js → live-graph-9cSnn_h9.js} +3 -3
  23. package/dist/_chunks/{live-graph-Dv-JJCZw.js.map → live-graph-9cSnn_h9.js.map} +1 -1
  24. package/dist/_chunks/{logger-DiDt5ppH.js → logger-BP0LN6vP.js} +17 -2
  25. package/dist/_chunks/{logger-DiDt5ppH.js.map → logger-BP0LN6vP.js.map} +1 -1
  26. package/dist/_chunks/metadata-routes-DSDjM_hJ.js.map +1 -1
  27. package/dist/_chunks/navigation-root-BQfo1-kG.js.map +1 -1
  28. package/dist/_chunks/{poison-scan-CpeT6_OJ.js → poison-scan-vGV7Re0B.js} +2 -2
  29. package/dist/_chunks/{poison-scan-CpeT6_OJ.js.map → poison-scan-vGV7Re0B.js.map} +1 -1
  30. package/dist/_chunks/{scanner-Bw0oq1HB.js → scanner-DmqdxzbW.js} +392 -7
  31. package/dist/_chunks/scanner-DmqdxzbW.js.map +1 -0
  32. package/dist/_chunks/segment-classify-C539Pa2O.js.map +1 -1
  33. package/dist/_chunks/{sizeof-UwzwB1uM.js → sizeof-BM1409x2.js} +2 -2
  34. package/dist/_chunks/{sizeof-UwzwB1uM.js.map → sizeof-BM1409x2.js.map} +1 -1
  35. package/dist/_chunks/{status-page-marker-gaihi0KZ.js → status-page-marker-BRX9Ib-d.js} +1 -45
  36. package/dist/_chunks/status-page-marker-BRX9Ib-d.js.map +1 -0
  37. package/dist/_chunks/{walkers-BXExhzzk.js → walkers-Czu2jXFq.js} +3 -3
  38. package/dist/_chunks/{walkers-BXExhzzk.js.map → walkers-Czu2jXFq.js.map} +1 -1
  39. package/dist/adapters/cloudflare-kv-cache.js +1 -1
  40. package/dist/analyze/crawl-entry.js +2 -2
  41. package/dist/analyze/graph-command.js +2 -2
  42. package/dist/cache/index.js +2 -2
  43. package/dist/cache/stores/memory.js +1 -1
  44. package/dist/cli.js +3 -3
  45. package/dist/client/browser-entry/post-hydration.d.ts.map +1 -1
  46. package/dist/client/browser-entry/router-init.d.ts.map +1 -1
  47. package/dist/client/error-boundary.js +1 -1
  48. package/dist/client/history.d.ts +0 -9
  49. package/dist/client/history.d.ts.map +1 -1
  50. package/dist/client/index.d.ts +2 -1
  51. package/dist/client/index.d.ts.map +1 -1
  52. package/dist/client/index.js +9 -3
  53. package/dist/client/index.js.map +1 -1
  54. package/dist/client/internal.js +25 -38
  55. package/dist/client/internal.js.map +1 -1
  56. package/dist/client/link.d.ts +22 -0
  57. package/dist/client/link.d.ts.map +1 -1
  58. package/dist/client/navigation-api.d.ts +14 -2
  59. package/dist/client/navigation-api.d.ts.map +1 -1
  60. package/dist/client/navigation-root.d.ts +9 -1
  61. package/dist/client/navigation-root.d.ts.map +1 -1
  62. package/dist/client/navigation-transition.d.ts +6 -1
  63. package/dist/client/navigation-transition.d.ts.map +1 -1
  64. package/dist/client/react-root.d.ts.map +1 -1
  65. package/dist/client/router-effects.d.ts +10 -3
  66. package/dist/client/router-effects.d.ts.map +1 -1
  67. package/dist/client/router-pipeline.d.ts +3 -1
  68. package/dist/client/router-pipeline.d.ts.map +1 -1
  69. package/dist/client/router-types.d.ts +65 -9
  70. package/dist/client/router-types.d.ts.map +1 -1
  71. package/dist/client/router.d.ts.map +1 -1
  72. package/dist/client/segment-cache.d.ts +0 -15
  73. package/dist/client/segment-cache.d.ts.map +1 -1
  74. package/dist/client/use-router.d.ts +13 -6
  75. package/dist/client/use-router.d.ts.map +1 -1
  76. package/dist/config-validation.d.ts +19 -2
  77. package/dist/config-validation.d.ts.map +1 -1
  78. package/dist/index.d.ts.map +1 -1
  79. package/dist/index.js +7 -7
  80. package/dist/index.js.map +1 -1
  81. package/dist/routing/convention-lint.d.ts.map +1 -1
  82. package/dist/routing/export-detect.d.ts +19 -0
  83. package/dist/routing/export-detect.d.ts.map +1 -1
  84. package/dist/routing/index.js +3 -3
  85. package/dist/routing/manifest-codegen.d.ts.map +1 -1
  86. package/dist/routing/scanner.d.ts.map +1 -1
  87. package/dist/routing/types.d.ts +7 -0
  88. package/dist/routing/types.d.ts.map +1 -1
  89. package/dist/server/action-handler.d.ts +1 -1
  90. package/dist/server/action-handler.d.ts.map +1 -1
  91. package/dist/server/actions.d.ts +9 -4
  92. package/dist/server/actions.d.ts.map +1 -1
  93. package/dist/server/csrf.d.ts +38 -19
  94. package/dist/server/csrf.d.ts.map +1 -1
  95. package/dist/server/error-boundary-wrapper.d.ts +8 -4
  96. package/dist/server/error-boundary-wrapper.d.ts.map +1 -1
  97. package/dist/server/fallback-error.d.ts.map +1 -1
  98. package/dist/server/form-flash.d.ts +1 -1
  99. package/dist/server/index.js +2 -2
  100. package/dist/server/index.js.map +1 -1
  101. package/dist/server/internal.js +249 -16
  102. package/dist/server/internal.js.map +1 -1
  103. package/dist/server/logger.d.ts +16 -3
  104. package/dist/server/logger.d.ts.map +1 -1
  105. package/dist/server/metadata-collector.d.ts +2 -0
  106. package/dist/server/metadata-collector.d.ts.map +1 -1
  107. package/dist/server/metadata-routes.d.ts +12 -1
  108. package/dist/server/metadata-routes.d.ts.map +1 -1
  109. package/dist/server/pipeline-helpers.d.ts +29 -1
  110. package/dist/server/pipeline-helpers.d.ts.map +1 -1
  111. package/dist/server/pipeline.d.ts +8 -1
  112. package/dist/server/pipeline.d.ts.map +1 -1
  113. package/dist/server/route-element-builder.d.ts.map +1 -1
  114. package/dist/server/route-matcher.d.ts +8 -0
  115. package/dist/server/route-matcher.d.ts.map +1 -1
  116. package/dist/server/rsc-entry/{wrap-action-dispatch.d.ts → action-dispatcher.d.ts} +18 -40
  117. package/dist/server/rsc-entry/action-dispatcher.d.ts.map +1 -0
  118. package/dist/server/rsc-entry/index.d.ts.map +1 -1
  119. package/dist/server/safe-load.d.ts +5 -12
  120. package/dist/server/safe-load.d.ts.map +1 -1
  121. package/docs/api/30-api-server.mdx +1 -1
  122. package/docs/api/31-api-client.mdx +23 -20
  123. package/docs/api/34-api-config.mdx +4 -2
  124. package/package.json +1 -1
  125. package/src/client/browser-entry/action-dispatch.ts +28 -28
  126. package/src/client/browser-entry/post-hydration.ts +11 -1
  127. package/src/client/browser-entry/router-init.ts +10 -4
  128. package/src/client/history.ts +0 -22
  129. package/src/client/index.ts +2 -1
  130. package/src/client/link.tsx +30 -1
  131. package/src/client/navigation-api.ts +36 -3
  132. package/src/client/navigation-root.tsx +13 -1
  133. package/src/client/navigation-transition.ts +17 -7
  134. package/src/client/react-root.ts +11 -2
  135. package/src/client/router-effects.ts +11 -4
  136. package/src/client/router-pipeline.ts +4 -0
  137. package/src/client/router-types.ts +80 -6
  138. package/src/client/router.ts +36 -24
  139. package/src/client/segment-cache.ts +0 -65
  140. package/src/client/use-router.ts +25 -6
  141. package/src/config-validation.ts +121 -5
  142. package/src/index.ts +5 -8
  143. package/src/routing/convention-lint.ts +75 -0
  144. package/src/routing/export-detect.ts +88 -0
  145. package/src/routing/manifest-codegen.ts +6 -0
  146. package/src/routing/scanner.ts +9 -0
  147. package/src/routing/types.ts +7 -0
  148. package/src/server/action-handler.ts +14 -11
  149. package/src/server/actions.ts +37 -42
  150. package/src/server/als-registry.ts +1 -1
  151. package/src/server/csrf.ts +100 -72
  152. package/src/server/error-boundary-wrapper.ts +11 -12
  153. package/src/server/fallback-error.ts +13 -21
  154. package/src/server/form-flash.ts +1 -1
  155. package/src/server/logger.ts +19 -3
  156. package/src/server/metadata-collector.ts +4 -1
  157. package/src/server/metadata-routes.ts +23 -8
  158. package/src/server/pipeline-helpers.ts +89 -8
  159. package/src/server/pipeline.ts +27 -2
  160. package/src/server/route-element-builder.ts +1 -0
  161. package/src/server/route-matcher.ts +11 -0
  162. package/src/server/rsc-entry/{wrap-action-dispatch.ts → action-dispatcher.ts} +20 -62
  163. package/src/server/rsc-entry/index.ts +14 -27
  164. package/src/server/safe-load.ts +5 -12
  165. package/dist/_chunks/convention-lint-jKTwKwPe.js.map +0 -1
  166. package/dist/_chunks/dev-server-C4WZdB7L.js.map +0 -1
  167. package/dist/_chunks/file-cache-Dw6BJPG7.js.map +0 -1
  168. package/dist/_chunks/scanner-Bw0oq1HB.js.map +0 -1
  169. package/dist/_chunks/status-page-marker-gaihi0KZ.js.map +0 -1
  170. package/dist/server/rsc-entry/wrap-action-dispatch.d.ts.map +0 -1
@@ -62,9 +62,13 @@ export interface ErrorBoundaryProps {
62
62
  }
63
63
 
64
64
  /**
65
- * How to load a route file's module. Only `default` is read. The server
66
- * injects `loadFallbackModule`, which reports a failed load and returns
67
- * `null`, so a broken status or error page is never skipped silently.
65
+ * How to load a route file's module. Only `default` is read. A module that
66
+ * fails to load resolves to `null` — the loader must not reject, because
67
+ * building an error boundary must not itself throw (TIM-584). The server
68
+ * injects `loadFallbackModule`, which logs and reports the failed load before
69
+ * returning `null`, so a broken status or error page is never skipped
70
+ * silently (TIM-1458). The walk deliberately does not catch a rejection:
71
+ * a catch here would be the silent skip.
68
72
  */
69
73
  type BoundaryModuleLoader<TFile> = (
70
74
  file: TFile
@@ -74,7 +78,7 @@ type BoundaryModuleLoader<TFile> = (
74
78
  export interface ErrorBoundaryWrapConfig<TFile, TElement> {
75
79
  /** `React.createElement` or an equivalent element factory. */
76
80
  createElement: (type: unknown, props: object) => TElement;
77
- /** Loads a route file's module. Failures skip the boundary — see below. */
81
+ /** Loads a route file's module; resolves to `null` when it fails. See `BoundaryModuleLoader`. */
78
82
  loadModule: BoundaryModuleLoader<TFile>;
79
83
  /**
80
84
  * The error boundary component to instantiate (`TimberErrorBoundary`, or a
@@ -93,8 +97,8 @@ export interface ErrorBoundaryWrapConfig<TFile, TElement> {
93
97
  * Load a status/error file's component, or `null` if the boundary should be skipped.
94
98
  *
95
99
  * Two ways to get `null`, both deliberate:
96
- * - the module fails to load (syntax error in the error page) — building an
97
- * error boundary must not itself throw, so the boundary is skipped (TIM-584);
100
+ * - the module fails to load (syntax error in the error page) — the loader
101
+ * has already logged and reported it and resolved to `null`;
98
102
  * - the default export is not a valid React component type, which would
99
103
  * otherwise crash inside `createElement`.
100
104
  */
@@ -102,12 +106,7 @@ async function loadBoundaryComponent<TFile>(
102
106
  file: TFile,
103
107
  loadModule: BoundaryModuleLoader<TFile>
104
108
  ): Promise<LoadedComponent | null> {
105
- let mod: { default?: unknown } | null;
106
- try {
107
- mod = await loadModule(file);
108
- } catch {
109
- return null;
110
- }
109
+ const mod = await loadModule(file);
111
110
  return isValidElementType(mod?.default) ? mod.default : null;
112
111
  }
113
112
 
@@ -21,8 +21,7 @@ import type { ClientBootstrapConfig } from './html-injectors.ts';
21
21
  import type { LayoutEntry } from './deny-renderer.ts';
22
22
  import type { GlobalErrorFile } from './rsc-entry/error-renderer.ts';
23
23
  import { swallow } from './logger.ts';
24
- import { reportRenderError } from './pipeline-helpers.ts';
25
- import { loadModule } from './safe-load.ts';
24
+ import { loadFallbackModule } from './pipeline-helpers.ts';
26
25
 
27
26
  /**
28
27
  * Render a fallback error page when the render pipeline throws.
@@ -58,25 +57,18 @@ export async function renderFallbackError(
58
57
  const { renderErrorPage } = await import('./rsc-entry/error-renderer.ts');
59
58
  const segments = [rootSegment];
60
59
  const layoutComponents: LayoutEntry[] = [];
61
- // Wrap layout loading in try/catch — if the root layout module itself
62
- // crashes (evaluation failure, syntax error, etc.), we still want to
63
- // reach renderErrorPage so it can fall through to global-error.tsx
64
- // (Tier 2), which renders without any layout wrapping.
65
- try {
66
- if (rootSegment.layout) {
67
- const mod = await loadModule(rootSegment.layout);
68
- if (mod.default) {
69
- layoutComponents.push({
70
- component: mod.default as (...args: unknown[]) => unknown,
71
- segment: rootSegment,
72
- });
73
- }
74
- }
75
- } catch (layoutError) {
76
- // Layout failed to load — proceed without it. renderErrorPage will
77
- // attempt segment-level error pages (without layout wrapping) and
78
- // then fall through to global-error.tsx if those also fail.
79
- reportRenderError(layoutError, req, { segments: match?.segments });
60
+ // If the root layout module itself fails to load (evaluation failure,
61
+ // syntax error), proceed without it: renderErrorPage still tries the
62
+ // segment-level error pages (without layout wrapping) and then falls
63
+ // through to global-error.tsx. loadFallbackModule logs and reports it.
64
+ const mod = rootSegment.layout
65
+ ? await loadFallbackModule(rootSegment.layout, req, match?.segments)
66
+ : null;
67
+ if (mod?.default) {
68
+ layoutComponents.push({
69
+ component: mod.default as (...args: unknown[]) => unknown,
70
+ segment: rootSegment,
71
+ });
80
72
  }
81
73
  const rootMatch: RouteMatch = {
82
74
  segments,
@@ -11,7 +11,7 @@
11
11
  *
12
12
  * The flash data is server-side only — never serialized to cookies or headers.
13
13
  *
14
- * See design/08-forms-and-actions.md §"No-JS Error Round-Trip"
14
+ * See design/08-forms-and-actions.md §"No-JS Result Round-Trip"
15
15
  */
16
16
 
17
17
  import type { ValidationErrors } from './action-client.ts';
@@ -72,9 +72,10 @@ function withTraceContext(data?: Record<string, unknown>): Record<string, unknow
72
72
  * so this line is the only place the rejected origin is visible (TIM-1459).
73
73
  * `detail` names the observed origin and, where one exists, the expected one.
74
74
  *
75
- * The CSRF gate runs at the pipeline boundary, before the pipeline opens its
76
- * trace scope, so a rejection mints its own `trace_id` — the field is always
77
- * present (design/17-logging.md §"trace_id is Always Set").
75
+ * The gate runs inside the pipeline's trace scope, so the line carries the
76
+ * request's `trace_id`. A caller outside any scope still gets one minted
77
+ * here — the field is always present (design/17-logging.md §"trace_id is
78
+ * Always Set").
78
79
  */
79
80
  export function logCsrfRejected(
80
81
  detail: string,
@@ -146,6 +147,21 @@ export function logRenderError(data: {
146
147
  _logger.error('unhandled render-phase error', withTraceContext(data));
147
148
  }
148
149
 
150
+ /**
151
+ * Log a status page, error page, `global-error.tsx` or fallback layout that
152
+ * failed to load and was skipped: the next fallback tier answers in its place.
153
+ * Level: error — a boundary that vanished is a bug in the app, even though the
154
+ * request still gets a page. The caller writes it once per file and failure.
155
+ */
156
+ export function logSkippedModule(data: {
157
+ method: string;
158
+ path: string;
159
+ file: string;
160
+ error: unknown;
161
+ }): void {
162
+ _logger.error('route file failed to load and was skipped', withTraceContext(data));
163
+ }
164
+
149
165
  /** Log proxy.ts uncaught error. Level: error. */
150
166
  export function logProxyError(data: { error: unknown }): void {
151
167
  _logger.error('proxy.ts threw uncaught error', withTraceContext(data));
@@ -116,6 +116,8 @@ export function MetadataCollector(props: MetadataCollectorProps): ReactNode {
116
116
 
117
117
  export interface MetadataHeadProps {
118
118
  segments: ManifestSegmentNode[];
119
+ /** The matcher's raw (pre-coercion) params — fill the owning segment's URL for auto-links. */
120
+ rawSegmentParams: Record<string, string | string[]>;
119
121
  requestUrl: string;
120
122
  metadataRouteHashes?: Record<string, string>;
121
123
  }
@@ -139,7 +141,7 @@ export interface MetadataHeadProps {
139
141
  * See TIM-1367 for the concurrent resolution design.
140
142
  */
141
143
  export async function MetadataHead(props: MetadataHeadProps): Promise<ReactNode> {
142
- const { segments, requestUrl, metadataRouteHashes } = props;
144
+ const { segments, rawSegmentParams, requestUrl, metadataRouteHashes } = props;
143
145
  const deferredEntries = getDeferredMetadataEntries();
144
146
 
145
147
  // Resolve all deferred metadata concurrently. Rejected entries are
@@ -166,6 +168,7 @@ export async function MetadataHead(props: MetadataHeadProps): Promise<ReactNode>
166
168
  Infinity,
167
169
  resolved,
168
170
  new URL(requestUrl),
171
+ rawSegmentParams,
169
172
  metadataRouteHashes
170
173
  );
171
174
  headElements.push(...autoLinked);
@@ -14,6 +14,7 @@ import { randomUUID } from 'node:crypto';
14
14
  import type { HeadElement } from './metadata.ts';
15
15
  import type { Metadata } from './types.ts';
16
16
  import type { ManifestSegmentNode } from './route-matcher.ts';
17
+ import { extractUrlParts } from './chain-url-parts.ts';
17
18
 
18
19
  // ─── Types ───────────────────────────────────────────────────────────────────
19
20
 
@@ -357,6 +358,17 @@ function getDevNonce(): string {
357
358
  * emits HeadElement descriptors for <link> and <meta> tags that React Float
358
359
  * hoists into <head>.
359
360
  *
361
+ * A metadata file is served under the URL of the segment that owns it, not
362
+ * under every descendant URL: a root `app/icon.tsx` rendered on `/map` links
363
+ * `/icon.png`, and `app/posts/[id]/opengraph-image.tsx` on `/posts/42/comments`
364
+ * links `/posts/42/opengraph-image.png`. The owner's prefix is the URL parts
365
+ * the chain has consumed through that segment (`extractUrlParts`, the same
366
+ * walk slot matching uses), filled with the request's raw param values — the
367
+ * parts the metadata route matcher walks back to the same owner.
368
+ *
369
+ * Segments at or below an intercepting segment are skipped: the metadata
370
+ * matcher only walks the canonical tree, so nothing serves their files.
371
+ *
360
372
  * See design/16-metadata.md §"Auto-Linking"
361
373
  */
362
374
  export function collectMetadataRouteHeadElements(
@@ -364,12 +376,12 @@ export function collectMetadataRouteHeadElements(
364
376
  firstDeniedIndex: number,
365
377
  resolvedMetadata: Metadata,
366
378
  requestUrl: URL,
379
+ rawSegmentParams: Record<string, string | string[]>,
367
380
  metadataRouteHashes?: Record<string, string>
368
381
  ): HeadElement[] {
369
382
  const elements: HeadElement[] = [];
370
383
  const hasUserOgImage = Boolean(resolvedMetadata.openGraph?.images);
371
384
  const hasUserTwitterImage = Boolean(resolvedMetadata.twitter?.images);
372
- const requestPathname = requestUrl.pathname;
373
385
  // In dev mode, use the request origin so OG URLs resolve to localhost.
374
386
  // In production, use metadataBase (the canonical domain).
375
387
  const ogBase =
@@ -377,27 +389,30 @@ export function collectMetadataRouteHeadElements(
377
389
  ? new URL(requestUrl.origin)
378
390
  : resolvedMetadata.metadataBase;
379
391
 
380
- for (let si = 0; si < segments.length; si++) {
392
+ const interceptingIndex = segments.findIndex((s) => s.segmentType === 'intercepting');
393
+ const servedDepth = interceptingIndex === -1 ? segments.length : interceptingIndex;
394
+
395
+ for (let si = 0; si < servedDepth; si++) {
381
396
  const segment = segments[si];
382
397
  if (!segment.metadataRoutes) continue;
383
398
  if (si >= firstDeniedIndex) continue;
399
+ // Params are canonical (decoded) values; encode each part so the href
400
+ // canonicalizes back to the pathname the matcher resolves to this owner.
401
+ const segmentPrefix = extractUrlParts(segments.slice(0, si + 1), rawSegmentParams)
402
+ .map((part) => `/${encodeURIComponent(part)}`)
403
+ .join('');
384
404
  for (const baseName of Object.keys(segment.metadataRoutes)) {
385
405
  const convention = METADATA_ROUTE_CONVENTIONS[baseName];
386
406
  if (!convention) continue;
387
407
  if (!convention.nestable && segment.urlPath !== '/') continue;
388
408
  if (convention.type === 'opengraph-image' && hasUserOgImage) continue;
389
- const resolvedPrefix = convention.nestable
390
- ? requestPathname === '/'
391
- ? ''
392
- : requestPathname
393
- : '';
394
409
  const metaFile = segment.metadataRoutes[baseName];
395
410
  const fileServePath = metaFile?.filePath
396
411
  ? resolveServePathForFile(baseName, metaFile.filePath)
397
412
  : convention.serveExtension
398
413
  ? `${convention.servePath}.${convention.serveExtension}`
399
414
  : convention.servePath;
400
- let href = `${resolvedPrefix}/${fileServePath}`;
415
+ let href = `${segmentPrefix}/${fileServePath}`;
401
416
  if (convention.type === 'opengraph-image') {
402
417
  const fileHash = metaFile?.filePath ? metadataRouteHashes?.[metaFile.filePath] : undefined;
403
418
  const cacheBust = fileHash ?? getDevNonce();
@@ -20,16 +20,52 @@ import { getTraceId } from './tracing.ts';
20
20
  import { requestContextAls } from './als-registry.ts';
21
21
  import { RedirectSignal } from './primitives.ts';
22
22
  import { isControlFlowSignal } from './signal-identity.ts';
23
- import { logRenderError, swallow } from './logger.ts';
23
+ import { logRenderError, logSkippedModule, swallow } from './logger.ts';
24
24
  import { isAbortError } from './render-utils.ts';
25
25
  import { loadModule, ModuleLoadError, type ManifestLoader } from './safe-load.ts';
26
26
  import { getWaitUntil } from './waituntil-bridge.ts';
27
- import { isApiRouteChain, type ManifestSegmentNode } from './route-matcher.ts';
28
- import type { ProxyConfig } from './pipeline.ts';
27
+ import { isApiRouteChain, isCsrfExemptRoute, type ManifestSegmentNode } from './route-matcher.ts';
28
+ import type { ProxyConfig, RouteMatcher } from './pipeline.ts';
29
+ import { csrfRejectionResponse, validateCsrf, type CsrfConfig } from './csrf.ts';
29
30
  import type { PhaseName } from './pipeline-outcome.ts';
30
31
 
31
32
  // ─── Prototype-Pollution-Safe Sanitizer ────────────────────────────────────
32
33
 
34
+ // ─── CSRF Gate ─────────────────────────────────────────────────────────────
35
+
36
+ /**
37
+ * The pipeline-boundary CSRF gate: the 403 to answer with, or null to go on.
38
+ *
39
+ * Runs on every request after canonicalization and before proxy.ts, so it
40
+ * covers route.ts handlers and server actions alike, and no user code has
41
+ * run when it decides. The per-route exemption is looked up only for a
42
+ * request the check would reject — the common accepted request never pays
43
+ * for a match. The lookup uses the pipeline's own matcher on the canonical
44
+ * path, the same match `handleRequest` dispatches on, and reads a flag the
45
+ * scanner took from route.ts source, so deciding it loads no module.
46
+ *
47
+ * A matcher that throws leaves the request unexempted: fail closed.
48
+ *
49
+ * See design/08-forms-and-actions.md §"CSRF Protection".
50
+ */
51
+ export function csrfGate(
52
+ req: Request,
53
+ canonicalPath: string,
54
+ csrf: CsrfConfig,
55
+ matchRoute: RouteMatcher
56
+ ): Response | null {
57
+ const result = validateCsrf(req, csrf);
58
+ if (result.ok) return null;
59
+ let exempt = false;
60
+ try {
61
+ const match = matchRoute(canonicalPath);
62
+ exempt = match !== null && isCsrfExemptRoute(match.segments);
63
+ } catch (err) {
64
+ swallow(err, 'csrf gate: route match for exemption lookup threw');
65
+ }
66
+ return exempt ? null : csrfRejectionResponse(req, result);
67
+ }
68
+
33
69
  // ─── Proxy Resolver ────────────────────────────────────────────────────────
34
70
 
35
71
  /**
@@ -338,18 +374,63 @@ export function reportRenderError(
338
374
  * taking the page down. Every such load goes through here, whether it builds
339
375
  * a boundary for a normal render or a fallback page, so a broken module is
340
376
  * never skipped silently.
377
+ *
378
+ * Logged and reported once per broken file, not once per request: every
379
+ * request that renders under a broken `error.tsx` loads it again (TIM-1458).
380
+ * See `skippedFiles` for how "the same failure" is recognised. The first
381
+ * request to hit a failure is the one that reports it; if that request has
382
+ * already aborted, `fireOnRequestError()` drops the report and the failure is
383
+ * only logged.
384
+ *
385
+ * Never rejects, including when a user logger throws: building an error
386
+ * boundary must not itself throw (TIM-584), and the boundary walk does not
387
+ * catch for it.
341
388
  */
342
- export function loadFallbackModule(
389
+ export async function loadFallbackModule(
343
390
  file: ManifestLoader,
344
391
  req: Request,
345
392
  segments?: readonly ManifestSegmentNode[]
346
393
  ): Promise<Record<string, unknown> | null> {
347
- return loadModule(file).catch((loadError: unknown) => {
348
- reportRenderError(loadError, req, { segments });
349
- return null;
350
- });
394
+ let loadError: unknown;
395
+ try {
396
+ const mod = await loadModule(file);
397
+ skippedFiles.delete(file.filePath);
398
+ return mod;
399
+ } catch (error) {
400
+ loadError = error;
401
+ }
402
+ const failure = loadError instanceof Error ? loadError.message : String(loadError);
403
+ if (skippedFiles.get(file.filePath) === failure) return null;
404
+ skippedFiles.set(file.filePath, failure);
405
+ try {
406
+ logSkippedModule({
407
+ method: req.method,
408
+ path: new URL(req.url).pathname,
409
+ file: file.filePath,
410
+ error: loadError,
411
+ });
412
+ } catch (logError) {
413
+ swallow(logError, 'logger.error threw while logging a skipped route file');
414
+ }
415
+ fireInBackground(fireOnRequestError(loadError, req, 'render', segments));
416
+ return null;
351
417
  }
352
418
 
419
+ /**
420
+ * The failure each broken fallback file was last logged with, keyed by the
421
+ * file and compared by message — not by error identity. In Vite dev a file
422
+ * that fails to transform (a syntax error) is fetched again on every import,
423
+ * and the module runner revives the server's error as a new `Error` each
424
+ * time (`reviveInvokeError`), so identity never repeats; the message does.
425
+ * Keyed by file so two files skipped over one broken import each log.
426
+ *
427
+ * A successful load clears the file's entry, so fixing a file and breaking
428
+ * it again — even with the same message — logs again. An edit that changes
429
+ * the error changes the message and logs too. It grows with the number of
430
+ * broken files, not with requests.
431
+ */
432
+ const skippedFiles = new Map<string, string>();
433
+
353
434
  /**
354
435
  * Run a `cache.component` stale-while-revalidate re-render as a singleflight
355
436
  * body, and log and report its failure once for the flight. The stale
@@ -2,7 +2,7 @@
2
2
  * Request pipeline — the central dispatch for all timber.js requests.
3
3
  *
4
4
  * Pipeline stages (in order):
5
- * proxy.ts → canonicalize → route match → 103 Early Hints → middleware.ts → render
5
+ * canonicalize → CSRF gate → proxy.ts → route match → 103 Early Hints → middleware.ts → render
6
6
  *
7
7
  * The phase functions live in `pipeline-phases.ts` so each phase can be
8
8
  * tested in isolation. The terminal `outcomeToResponse` translator and
@@ -32,7 +32,8 @@ import {
32
32
  import { logRequestReceived, logRequestCompleted, logSlowRequest } from './logger.ts';
33
33
  import { DenySignal } from './primitives.ts';
34
34
  import type { ManifestSegmentNode } from './route-matcher.ts';
35
- import { makeProxyResolver } from './pipeline-helpers.ts';
35
+ import { csrfGate, makeProxyResolver } from './pipeline-helpers.ts';
36
+ import type { CsrfConfig } from './csrf.ts';
36
37
  import { handleRequest, runProxyPhase } from './pipeline-phases.ts';
37
38
  import { outcomeToResponse } from './pipeline-outcome.ts';
38
39
  import { canonicalize } from './canonicalize.ts';
@@ -131,6 +132,12 @@ export interface PipelineConfig {
131
132
  proxy?: ProxyConfig | ProxyExport;
132
133
  /** Route matcher — resolves a canonical pathname to a RouteMatch. */
133
134
  matchRoute: RouteMatcher;
135
+ /**
136
+ * CSRF configuration (`allowedOrigins`, the `csrf: false` switch). The
137
+ * gate is on when this is omitted — protection is never opt-in. See
138
+ * design/08-forms-and-actions.md §"CSRF Protection".
139
+ */
140
+ csrf?: CsrfConfig;
134
141
  /** Metadata route matcher — resolves metadata route pathnames (sitemap.xml, robots.txt, etc.) */
135
142
  matchMetadataRoute?: MetadataRouteMatcher;
136
143
  /** Renderer — produces the final Response for a matched route. */
@@ -341,6 +348,10 @@ export function createPipeline(config: PipelineConfig): (req: Request) => Promis
341
348
  ` null bytes (%00), path traversal (..), or malformed percent-encoding.`
342
349
  );
343
350
  }
351
+ // Early returns set the span status themselves: the shared
352
+ // assignment below never runs for them, and a span with
353
+ // none reads as 200 in DevSpanProcessor.
354
+ await setSpanAttribute('http.response.status_code', canonResult.status);
344
355
  return new Response(null, { status: canonResult.status });
345
356
  }
346
357
  const canonicalPath = canonResult.pathname;
@@ -355,6 +366,20 @@ export function createPipeline(config: PipelineConfig): (req: Request) => Promis
355
366
  canonicalReq = new Request(canonicalUrl.toString(), req);
356
367
  }
357
368
 
369
+ // Stage 0b: CSRF gate — every unsafe-method request, before
370
+ // proxy.ts or any other user code, and before action
371
+ // dispatch, so route.ts handlers are covered too (LOCAL-773).
372
+ const csrfRejection = csrfGate(
373
+ canonicalReq,
374
+ canonicalPath,
375
+ config.csrf ?? {},
376
+ config.matchRoute
377
+ );
378
+ if (csrfRejection) {
379
+ await setSpanAttribute('http.response.status_code', csrfRejection.status);
380
+ return csrfRejection;
381
+ }
382
+
358
383
  // Build the inner handler — runs after proxy, before route
359
384
  // matching. Checks for server action POSTs and dispatches them
360
385
  // without entering the route match → middleware → render chain.
@@ -299,6 +299,7 @@ export async function buildRouteElement(
299
299
  null,
300
300
  h(MetadataHead, {
301
301
  segments,
302
+ rawSegmentParams: match.rawSegmentParams,
302
303
  requestUrl: req.url,
303
304
  metadataRouteHashes,
304
305
  }),
@@ -131,6 +131,17 @@ export function isApiRouteChain(segments: readonly ManifestSegmentNode[]): boole
131
131
  return !!leaf?.route && !leaf.page;
132
132
  }
133
133
 
134
+ /**
135
+ * Whether a matched chain is a route.ts handler that opted out of the CSRF
136
+ * check with `export const csrf = false`. Keyed on the same leaf
137
+ * `isApiRouteChain` dispatches on, so the exemption can only ever apply to
138
+ * the route.ts that will actually run — never to a page, and so never to a
139
+ * server action. See design/08-forms-and-actions.md §"Per-route exemption".
140
+ */
141
+ export function isCsrfExemptRoute(segments: readonly ManifestSegmentNode[]): boolean {
142
+ return isApiRouteChain(segments) && segments[segments.length - 1]?.csrfExempt === true;
143
+ }
144
+
134
145
  // ─── Metadata Route Matcher ─────────────────────────────────────────────
135
146
 
136
147
  /** Result of matching a metadata route. */
@@ -1,36 +1,28 @@
1
1
  /**
2
- * Action-dispatch wrapper around the route pipeline.
2
+ * Server action dispatcher — the `dispatchAction` callback for
3
+ * `PipelineConfig`.
3
4
  *
4
5
  * Extracted from rsc-entry/index.ts so the wiring can be unit-tested in
5
6
  * isolation from Vite's virtual modules.
6
7
  *
7
- * ## Architecture (TIM-1213)
8
- *
9
- * The wrapper is responsible for ONE thing:
10
- *
11
- * **Pipeline-boundary CSRF validation** — runs on EVERY unsafe-method
12
- * request, before any dispatch decision. This is the only line of
13
- * defense for `route.ts` API handlers and server actions. See LOCAL-773.
14
- *
15
- * Server action dispatch has moved INTO the pipeline (`PipelineConfig.
16
- * dispatchAction`) so that `proxy.ts` runs on action POSTs — matching the
17
- * design doc contract: "proxy.ts runs on every request, no exclusions."
18
- *
19
- * The `buildActionDispatcher` function constructs the `dispatchAction`
20
- * callback that `createPipeline` calls after proxy.ts runs. It
21
- * encapsulates action detection, route-type check, action handler
8
+ * `createPipeline` calls it after proxy.ts runs (TIM-1213), so proxy.ts
9
+ * sees action POSTs — "proxy.ts runs on every request, no exclusions." It
10
+ * encapsulates action detection, the route-type check, action handler
22
11
  * dispatch, and the no-JS validation rerender path.
23
12
  *
13
+ * The CSRF gate is not here: it runs in `createPipeline` before proxy.ts
14
+ * (`csrfGate` in pipeline-helpers.ts). The action handler repeats the
15
+ * check as defense-in-depth, with no per-route exemption.
16
+ *
24
17
  * Per-segment `middleware.ts` does NOT run on action POSTs (TIM-1134).
25
- * Actions are dispatched by action ID, not by route segment. CSRF
26
- * validation runs at the pipeline boundary. Per-action auth/validation
27
- * belongs in `createActionClient({ middleware })`.
18
+ * Actions are dispatched by action ID, not by route segment. Per-action
19
+ * auth/validation belongs in `createActionClient({ middleware })`.
28
20
  */
29
21
 
30
22
  import type { FormRerender } from '../action-handler.ts';
31
23
  import { handleActionRequest, isActionRequest } from '../action-handler.ts';
32
24
  import type { BodyLimitsConfig } from '../body-limits.ts';
33
- import { validateCsrf, csrfRejectionResponse, type CsrfConfig } from '../csrf.ts';
25
+ import type { CsrfConfig } from '../csrf.ts';
34
26
  import { runWithFormFlash } from '../form-flash.ts';
35
27
  import type { RouteMatcher } from '../pipeline.ts';
36
28
  import { isApiRouteChain } from '../route-matcher.ts';
@@ -50,12 +42,6 @@ import type { RevalidateRenderer } from '../actions.ts';
50
42
  */
51
43
  export type RevalidateRendererFactory = (req: Request) => RevalidateRenderer;
52
44
 
53
- /** Dependencies for the CSRF wrapper. */
54
- export interface ActionDispatchDeps {
55
- /** CSRF configuration (Origin allow-list, on/off switch). */
56
- csrfConfig: CsrfConfig;
57
- }
58
-
59
45
  /** Dependencies for the action dispatcher (used inside the pipeline). */
60
46
  export interface ActionDispatcherDeps {
61
47
  /** CSRF configuration — defense-in-depth check inside the action handler. */
@@ -68,38 +54,12 @@ export interface ActionDispatcherDeps {
68
54
  buildRevalidateRenderer: RevalidateRendererFactory;
69
55
  /**
70
56
  * Route matcher — used for the route-type check that skips action
71
- * detection on `route.ts` API handlers (TIM-870).
57
+ * detection on `route.ts` API handlers (TIM-870). Required: without it,
58
+ * every form POST to a route.ts would enter the action handler, whose
59
+ * CSRF check has no per-route exemption, and a `csrf = false` route
60
+ * would 403 its cross-site callbacks.
72
61
  */
73
- matchRoute?: RouteMatcher;
74
- }
75
-
76
- // ─── CSRF Wrapper ─────────────────────────────────────────────────────────
77
-
78
- /**
79
- * Wrap a pipeline function with pipeline-boundary CSRF validation.
80
- *
81
- * The returned handler is the framework's outermost request entry point.
82
- * It validates the `Origin` header on every unsafe-method request before
83
- * any dispatch decision — this is the ONLY line of defense for `route.ts`
84
- * API handlers, which never see the action handler.
85
- *
86
- * Action dispatch has moved inside the pipeline (TIM-1213) so proxy.ts
87
- * runs on every request. This wrapper now only handles CSRF.
88
- */
89
- export function wrapPipelineWithActionDispatch(
90
- pipeline: (req: Request) => Promise<Response>,
91
- deps: ActionDispatchDeps
92
- ): (req: Request) => Promise<Response> {
93
- return async (req: Request): Promise<Response> => {
94
- // Pipeline-boundary CSRF validation (LOCAL-773).
95
- // Runs on EVERY unsafe-method request, before any dispatch decision.
96
- const csrfResult = validateCsrf(req, deps.csrfConfig);
97
- if (!csrfResult.ok) {
98
- return csrfRejectionResponse(req, csrfResult);
99
- }
100
-
101
- return pipeline(req);
102
- };
62
+ matchRoute: RouteMatcher;
103
63
  }
104
64
 
105
65
  // ─── Action Dispatcher ────────────────────────────────────────────────────
@@ -136,11 +96,9 @@ export function buildActionDispatcher(
136
96
  //
137
97
  // Use `canonicalPath` from the pipeline — NOT `new URL(req.url).pathname`,
138
98
  // which re-encodes the path. The matcher expects the decoded canonical form.
139
- if (deps.matchRoute) {
140
- const match = deps.matchRoute(canonicalPath);
141
- if (match && isApiRouteChain(match.segments)) {
142
- return null;
143
- }
99
+ const match = deps.matchRoute(canonicalPath);
100
+ if (match && isApiRouteChain(match.segments)) {
101
+ return null;
144
102
  }
145
103
 
146
104
  // Action handler dispatch