@timber-js/app 0.2.0-alpha.166 → 0.2.0-alpha.168

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 (188) hide show
  1. package/dist/_chunks/{actions-CDPfMp_I.js → actions-O_LsyCE4.js} +3 -3
  2. package/dist/_chunks/{actions-CDPfMp_I.js.map → actions-O_LsyCE4.js.map} +1 -1
  3. package/dist/_chunks/{cache-api-DygSeKCB.js → cache-api-B-lhk9p4.js} +107 -58
  4. package/dist/_chunks/cache-api-B-lhk9p4.js.map +1 -0
  5. package/dist/_chunks/{cli-schema-sync-EXGYPhI2.js → cli-schema-sync-B73L6pMq.js} +5 -3
  6. package/dist/_chunks/cli-schema-sync-B73L6pMq.js.map +1 -0
  7. package/dist/_chunks/cloudflare-AHoWYTYr.js +1188 -0
  8. package/dist/_chunks/cloudflare-AHoWYTYr.js.map +1 -0
  9. package/dist/_chunks/json-lossy-check-ClNvBM_3.js +63 -0
  10. package/dist/_chunks/json-lossy-check-ClNvBM_3.js.map +1 -0
  11. package/dist/_chunks/{logger-t3uxAmbX.js → logger-AWfuX-KJ.js} +2 -19
  12. package/dist/_chunks/logger-AWfuX-KJ.js.map +1 -0
  13. package/dist/_chunks/{walkers-DBVzXuWc.js → walkers-CoOC8Hga.js} +2 -2
  14. package/dist/_chunks/{walkers-DBVzXuWc.js.map → walkers-CoOC8Hga.js.map} +1 -1
  15. package/dist/adapters/cloudflare-dev.js +1 -1
  16. package/dist/adapters/cloudflare-kv-cache.d.ts.map +1 -1
  17. package/dist/adapters/cloudflare-kv-cache.js +10 -3
  18. package/dist/adapters/cloudflare-kv-cache.js.map +1 -1
  19. package/dist/adapters/cloudflare.d.ts +12 -1
  20. package/dist/adapters/cloudflare.d.ts.map +1 -1
  21. package/dist/adapters/cloudflare.js +2 -461
  22. package/dist/adapters/types.d.ts +2 -0
  23. package/dist/adapters/types.d.ts.map +1 -1
  24. package/dist/cache/cache-api.d.ts +33 -11
  25. package/dist/cache/cache-api.d.ts.map +1 -1
  26. package/dist/cache/index.d.ts +1 -1
  27. package/dist/cache/index.d.ts.map +1 -1
  28. package/dist/cache/index.js +1 -1
  29. package/dist/cache/json-lossy-check.d.ts +11 -0
  30. package/dist/cache/json-lossy-check.d.ts.map +1 -0
  31. package/dist/cache/redis-handler.d.ts +42 -0
  32. package/dist/cache/redis-handler.d.ts.map +1 -1
  33. package/dist/cache/singleflight.d.ts.map +1 -1
  34. package/dist/cache/stores/cloudflare-kv.d.ts +1 -1
  35. package/dist/cache/stores/cloudflare-kv.d.ts.map +1 -1
  36. package/dist/cache/stores/memory.d.ts +1 -1
  37. package/dist/cache/stores/memory.d.ts.map +1 -1
  38. package/dist/cache/stores/redis.d.ts +1 -1
  39. package/dist/cache/stores/redis.d.ts.map +1 -1
  40. package/dist/cache/stores/vercel.d.ts +1 -1
  41. package/dist/cache/stores/vercel.d.ts.map +1 -1
  42. package/dist/cache/tag-aware-handler.d.ts.map +1 -1
  43. package/dist/cache/timber-cache.d.ts.map +1 -1
  44. package/dist/cdn/cloudflare-purge.d.ts.map +1 -1
  45. package/dist/cdn/fastly-purge.d.ts.map +1 -1
  46. package/dist/cdn/workers-cache-purge.d.ts.map +1 -1
  47. package/dist/cli.d.ts +1 -1
  48. package/dist/cli.d.ts.map +1 -1
  49. package/dist/cli.js +3 -2
  50. package/dist/cli.js.map +1 -1
  51. package/dist/client/browser-entry/post-hydration.d.ts.map +1 -1
  52. package/dist/client/browser-entry/router-init.d.ts +1 -0
  53. package/dist/client/browser-entry/router-init.d.ts.map +1 -1
  54. package/dist/client/child-segment-context.d.ts +2 -2
  55. package/dist/client/child-segment-context.d.ts.map +1 -1
  56. package/dist/client/error-boundary.d.ts.map +1 -1
  57. package/dist/client/history.d.ts +10 -0
  58. package/dist/client/history.d.ts.map +1 -1
  59. package/dist/client/internal.js +141 -116
  60. package/dist/client/internal.js.map +1 -1
  61. package/dist/client/router.d.ts +6 -0
  62. package/dist/client/router.d.ts.map +1 -1
  63. package/dist/client/rsc-fetch.d.ts.map +1 -1
  64. package/dist/client/segment-cache.d.ts.map +1 -1
  65. package/dist/client/segment-update-context.d.ts +3 -3
  66. package/dist/client/segment-update-context.d.ts.map +1 -1
  67. package/dist/client/slot-outlet.d.ts +1 -1
  68. package/dist/codec.d.ts.map +1 -1
  69. package/dist/codec.js +2 -1
  70. package/dist/codec.js.map +1 -1
  71. package/dist/config-types.d.ts +16 -37
  72. package/dist/config-types.d.ts.map +1 -1
  73. package/dist/config-validation.d.ts.map +1 -1
  74. package/dist/dev-tools/instrumentation.d.ts.map +1 -1
  75. package/dist/fonts/pipeline.d.ts +19 -0
  76. package/dist/fonts/pipeline.d.ts.map +1 -1
  77. package/dist/fonts/transform.d.ts.map +1 -1
  78. package/dist/fonts/virtual-modules.d.ts.map +1 -1
  79. package/dist/index.d.ts.map +1 -1
  80. package/dist/index.js +378 -109
  81. package/dist/index.js.map +1 -1
  82. package/dist/plugin-context.d.ts.map +1 -1
  83. package/dist/plugins/adapter-build.d.ts +1 -0
  84. package/dist/plugins/adapter-build.d.ts.map +1 -1
  85. package/dist/plugins/cache.d.ts +8 -8
  86. package/dist/plugins/cache.d.ts.map +1 -1
  87. package/dist/plugins/entries.d.ts +27 -3
  88. package/dist/plugins/entries.d.ts.map +1 -1
  89. package/dist/plugins/fonts.d.ts.map +1 -1
  90. package/dist/plugins/server-bundle.d.ts.map +1 -1
  91. package/dist/routing/index.js +2 -2
  92. package/dist/server/action-client.d.ts +1 -1
  93. package/dist/server/action-client.d.ts.map +1 -1
  94. package/dist/server/als-registry.d.ts +7 -0
  95. package/dist/server/als-registry.d.ts.map +1 -1
  96. package/dist/server/body-limits.d.ts.map +1 -1
  97. package/dist/server/deny-boundary.d.ts +3 -1
  98. package/dist/server/deny-boundary.d.ts.map +1 -1
  99. package/dist/server/form-data.d.ts.map +1 -1
  100. package/dist/server/html-injector-core.d.ts.map +1 -1
  101. package/dist/server/index.js +20 -3
  102. package/dist/server/index.js.map +1 -1
  103. package/dist/server/internal.js +28 -41
  104. package/dist/server/internal.js.map +1 -1
  105. package/dist/server/middleware-runner.d.ts +0 -17
  106. package/dist/server/middleware-runner.d.ts.map +1 -1
  107. package/dist/server/param-coercion.d.ts.map +1 -1
  108. package/dist/server/pipeline-phases.d.ts +6 -3
  109. package/dist/server/pipeline-phases.d.ts.map +1 -1
  110. package/dist/server/pipeline.d.ts +18 -0
  111. package/dist/server/pipeline.d.ts.map +1 -1
  112. package/dist/server/prebuilt/capture-state.d.ts.map +1 -1
  113. package/dist/server/prebuilt/synthetic-store.d.ts.map +1 -1
  114. package/dist/server/primitives.d.ts.map +1 -1
  115. package/dist/server/render-timeout.d.ts.map +1 -1
  116. package/dist/server/route-element-builder.d.ts +9 -0
  117. package/dist/server/route-element-builder.d.ts.map +1 -1
  118. package/dist/server/rsc-entry/action-middleware-runner.d.ts +14 -14
  119. package/dist/server/rsc-entry/index.d.ts.map +1 -1
  120. package/dist/server/rsc-entry/render-route.d.ts +1 -0
  121. package/dist/server/rsc-entry/render-route.d.ts.map +1 -1
  122. package/dist/server/rsc-entry/revalidate-renderer.d.ts.map +1 -1
  123. package/dist/server/rsc-entry/rsc-payload.d.ts.map +1 -1
  124. package/dist/server/rsc-entry/rsc-stream.d.ts +8 -0
  125. package/dist/server/rsc-entry/rsc-stream.d.ts.map +1 -1
  126. package/dist/server/rsc-entry/wrap-action-dispatch.d.ts +44 -74
  127. package/dist/server/rsc-entry/wrap-action-dispatch.d.ts.map +1 -1
  128. package/dist/server/safe-load.d.ts.map +1 -1
  129. package/dist/server/ssr-entry.d.ts.map +1 -1
  130. package/dist/shared/redirect-type.d.ts +2 -2
  131. package/dist/shared/redirect-type.d.ts.map +1 -1
  132. package/dist/shims/font-google.d.ts.map +1 -1
  133. package/dist/shims/image.d.ts +120 -120
  134. package/dist/shims/image.d.ts.map +1 -1
  135. package/docs/api/32-api-cache.mdx +116 -4
  136. package/docs/api/34-api-config.mdx +0 -26
  137. package/docs/learn/09-caching.mdx +126 -10
  138. package/docs/learn/12-client-navigation.mdx +9 -1
  139. package/docs/learn/13-configuration.mdx +6 -8
  140. package/docs/learn/14-deploying.mdx +18 -18
  141. package/docs/more/03-coming-from-nextjs.mdx +2 -2
  142. package/docs/more/04-metadata-and-fonts.mdx +1 -1
  143. package/docs/more/50-ai-agent-instructions.mdx +5 -5
  144. package/package.json +6 -5
  145. package/src/adapters/cloudflare-kv-cache.ts +27 -6
  146. package/src/adapters/cloudflare.ts +63 -25
  147. package/src/adapters/types.ts +2 -0
  148. package/src/cache/cache-api.ts +84 -84
  149. package/src/cache/index.ts +1 -1
  150. package/src/cache/json-lossy-check.ts +75 -0
  151. package/src/cache/redis-handler.ts +65 -14
  152. package/src/cache/timber-cache.ts +10 -2
  153. package/src/client/browser-entry/index.ts +2 -0
  154. package/src/client/browser-entry/post-hydration.ts +16 -9
  155. package/src/client/browser-entry/router-init.ts +2 -0
  156. package/src/client/history.ts +11 -0
  157. package/src/client/router.ts +224 -208
  158. package/src/codec.ts +3 -1
  159. package/src/config-types.ts +16 -37
  160. package/src/config-validation.ts +7 -5
  161. package/src/fonts/pipeline.ts +32 -0
  162. package/src/fonts/transform.ts +30 -24
  163. package/src/plugins/adapter-build.ts +132 -13
  164. package/src/plugins/cache.ts +45 -30
  165. package/src/plugins/entries.ts +40 -43
  166. package/src/plugins/fonts.ts +30 -0
  167. package/src/plugins/server-bundle.ts +7 -15
  168. package/src/routing/scanner.ts +7 -0
  169. package/src/server/action-client.ts +1 -1
  170. package/src/server/als-registry.ts +7 -0
  171. package/src/server/deny-boundary.ts +6 -3
  172. package/src/server/middleware-runner.ts +0 -44
  173. package/src/server/param-coercion.ts +1 -0
  174. package/src/server/pipeline-phases.ts +11 -13
  175. package/src/server/pipeline.ts +51 -3
  176. package/src/server/route-element-builder.ts +59 -9
  177. package/src/server/rsc-entry/action-middleware-runner.ts +14 -14
  178. package/src/server/rsc-entry/index.ts +30 -40
  179. package/src/server/rsc-entry/render-route.ts +6 -2
  180. package/src/server/rsc-entry/rsc-payload.ts +21 -4
  181. package/src/server/rsc-entry/rsc-stream.ts +8 -0
  182. package/src/server/rsc-entry/wrap-action-dispatch.ts +109 -372
  183. package/dist/_chunks/cache-api-DygSeKCB.js.map +0 -1
  184. package/dist/_chunks/cli-schema-sync-EXGYPhI2.js.map +0 -1
  185. package/dist/_chunks/logger-t3uxAmbX.js.map +0 -1
  186. package/dist/_chunks/tree-match-D2l830j2.js +0 -102
  187. package/dist/_chunks/tree-match-D2l830j2.js.map +0 -1
  188. package/dist/adapters/cloudflare.js.map +0 -1
@@ -57,6 +57,7 @@ import { callSsr } from './ssr-bridge.js';
57
57
  export interface RenderRouteDeps {
58
58
  clientBootstrap: ClientBootstrapConfig;
59
59
  clientJsDisabled: boolean;
60
+ clientSegmentCache: boolean;
60
61
  rootSegment: ManifestSegmentNode;
61
62
  buildManifest: BuildManifest;
62
63
  globalError?: { load: () => Promise<unknown>; filePath: string };
@@ -95,7 +96,8 @@ export async function renderRoute(
95
96
  // skips re-rendering those layouts for a smaller, faster RSC payload.
96
97
  // Only used for RSC requests — HTML requests always get a full render.
97
98
  // See design/19-client-navigation.md §"X-Timber-State-Tree Header"
98
- const clientStateTree = isRscPayloadRequest(req) ? parseClientStateTree(req) : null;
99
+ const clientStateTree =
100
+ deps.clientSegmentCache && isRscPayloadRequest(req) ? parseClientStateTree(req) : null;
99
101
 
100
102
  // Build the React element tree — runs access checks eagerly to gate
101
103
  // metadata resolution (TIM-1027), then loads modules and resolves
@@ -137,7 +139,8 @@ export async function renderRoute(
137
139
  desc: 'build element tree',
138
140
  });
139
141
 
140
- const { element, layoutComponents, deferSuspenseFor, skippedSegments } = routeResult;
142
+ const { element, layoutComponents, deferSuspenseFor, skippedSegments, shellSettled } =
143
+ routeResult;
141
144
 
142
145
  // Build head HTML for injection into the SSR output.
143
146
  // Collects CSS, fonts, and modulepreload from the build manifest for matched segments.
@@ -191,6 +194,7 @@ export async function renderRoute(
191
194
  // Render to RSC Flight stream with signal tracking.
192
195
  const _rscStart = performance.now();
193
196
  const { rscStream, signals, getDebugComponents } = renderRscStream(element, req);
197
+ if (shellSettled) signals.shellSettled = shellSettled;
194
198
 
195
199
  // Store the debug components getter in ALS so onPipelineError can
196
200
  // include component tree context for render-phase errors (dev mode only).
@@ -88,11 +88,28 @@ export async function buildRscPayloadResponse(
88
88
  }
89
89
 
90
90
  // If data arrived first, still check signals — they may have fired
91
- // concurrently. Also do a final ceiling timeout check for edge cases
92
- // where the signal fires just after the first read resolves.
91
+ // concurrently. Wait for all deny-capable shell components (layouts
92
+ // with deny chains + page) to settle so async deny() calls preceded
93
+ // by I/O are captured. See TIM-1045, TIM-1208.
93
94
  if (first.type === 'data' && !signals.redirectSignal && !signals.denySignal) {
94
- // Brief yield to let any in-flight microtask rejections complete.
95
- await new Promise<void>((r) => setTimeout(r, 0));
95
+ // Sync deny (AccessGate pre-pass replay) sets getDenyStatus() during
96
+ // renderToReadableStream — before the first chunk is read. Skip the
97
+ // wait entirely: components below the denied gate never execute.
98
+ const { getDenyStatus: earlyDenyCheck } = await import('../deny-boundary.js');
99
+ if (signals.shellSettled && !earlyDenyCheck()) {
100
+ // Wait for all deny-capable shell components to finish, or for an
101
+ // in-tree layout deny to short-circuit (setDenyStatus already
102
+ // called), or for a signal to escape to onError. TIM-1045, TIM-1208.
103
+ await Promise.race([signals.shellSettled, signalDetected]);
104
+ // Shell components' finally blocks resolve before React Flight's
105
+ // onError fires. Yield one macrotask so onError can set
106
+ // signals.redirectSignal / signals.renderError before we check.
107
+ await new Promise<void>((r) => setTimeout(r, 0));
108
+ } else if (!earlyDenyCheck()) {
109
+ // No deny-capable shell components (client component page, no deny
110
+ // chain) — brief yield to let in-flight microtask rejections complete.
111
+ await new Promise<void>((r) => setTimeout(r, 0));
112
+ }
96
113
  }
97
114
 
98
115
  // Detach the callback — no longer needed after this point.
@@ -53,6 +53,14 @@ export interface RenderSignals {
53
53
  lastUnhandledError: unknown | null;
54
54
  /** Callback fired when a redirect or deny signal is captured in onError. */
55
55
  onSignal?: () => void;
56
+ /**
57
+ * Resolves when every deny-capable shell component (PageDenyBoundary +
58
+ * TracedLayouts with deny chains) has settled, OR immediately when any
59
+ * catches a DenySignal in-tree (status is known). Set by renderRoute
60
+ * after element-tree construction. Awaited by buildRscPayloadResponse
61
+ * before committing HTTP status. See TIM-1045, TIM-1208.
62
+ */
63
+ shellSettled?: Promise<void>;
56
64
  /** Callback fired when an unhandled error (non-signal) is captured. Used
57
65
  * to halt Flight data injection into the inline stream. */
58
66
  onUnhandledError?: () => void;
@@ -2,41 +2,29 @@
2
2
  * Action-dispatch wrapper around the route pipeline.
3
3
  *
4
4
  * Extracted from rsc-entry/index.ts so the wiring can be unit-tested in
5
- * isolation from Vite's virtual modules. The wrapper is responsible for
6
- * four things, in order:
5
+ * isolation from Vite's virtual modules.
7
6
  *
8
- * 1. **Pipeline-boundary CSRF validation** — runs on EVERY unsafe-method
9
- * request, before any dispatch decision. This is the only line of
10
- * defense for `route.ts` API handlers, which never see the action
11
- * handler. See LOCAL-773.
7
+ * ## Architecture (TIM-1213)
12
8
  *
13
- * 2. **Middleware-on-actions execution** — when the request is a server
14
- * action POST and `actions.runMiddleware !== false`, the matched
15
- * route's `middleware.ts` chain runs BEFORE the action body. This
16
- * closes the Next.js CVE-2025-29927 class of bug, where developers
17
- * reasonably believe `middleware.ts` runs on every request and find
18
- * out (the hard way) that actions silently bypass it. Middleware can
19
- * short-circuit with a `Response`, `redirect()`, or `deny()`; can
20
- * mutate cookies; and can inject request headers visible to the
21
- * action body via `getHeaders()`. See TIM-871.
9
+ * The wrapper is responsible for ONE thing:
22
10
  *
23
- * 3. **Server action interception** — POST requests carrying an
24
- * `x-rsc-action` header or React's `$ACTION_REF` form fields are
25
- * handed to `handleActionRequest`, which executes the action and
26
- * returns either an RSC response or a no-JS rerender signal.
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.
27
14
  *
28
- * 4. **No-JS validation rerender** — when an action returns flash data
29
- * instead of a redirect, the wrapper re-runs the page render via the
30
- * pipeline with the post-action cookie state and `runWithFormFlash`
31
- * so server components can read the flash. The synthetic GET is
32
- * marked via `markRequestBypassMiddleware` so the pipeline does not
33
- * double-execute middleware on it. See TIM-836 / TIM-837 / TIM-871.
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."
34
18
  *
35
- * Anything else falls through to `pipeline(req)` for normal route handling.
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
22
+ * dispatch, and the no-JS validation rerender path.
36
23
  *
37
- * The wrapper takes its dependencies as parameters (no module-level
38
- * imports of virtual modules) so tests can construct it with stub
39
- * pipelines, stub revalidate renderers, and stub route matchers.
24
+ * 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 })`.
40
28
  */
41
29
 
42
30
  import type { FormRerender } from '../action-handler.js';
@@ -45,19 +33,9 @@ import type { BodyLimitsConfig } from '../body-limits.js';
45
33
  import { validateCsrf, type CsrfConfig } from '../csrf.js';
46
34
  import { runWithFormFlash } from '../form-flash.js';
47
35
  import { seedRequestCookies } from '../cookie-context.js';
48
- import { markRequestBypassMiddleware } from '../middleware-runner.js';
49
- import { DenySignal } from '../primitives.js';
50
- import { canonicalize } from '../canonicalize.js';
51
- import {
52
- buildRedirectResponse,
53
- cloneWithMutableHeaders,
54
- fireOnRequestError,
55
- } from '../pipeline-helpers.js';
56
- import { logRenderError } from '../logger.js';
57
- import type { RouteMatch, RouteMatcher } from '../pipeline.js';
36
+ import type { RouteMatcher } from '../pipeline.js';
58
37
  import type { SensitiveFieldsOption } from '../sensitive-fields.js';
59
38
  import type { RevalidateRenderer } from '../actions.js';
60
- import { runMiddlewareForAction, type CoerceSegmentParamsFn } from './action-middleware-runner.js';
61
39
 
62
40
  // ─── Types ────────────────────────────────────────────────────────────────
63
41
 
@@ -72,18 +50,16 @@ import { runMiddlewareForAction, type CoerceSegmentParamsFn } from './action-mid
72
50
  */
73
51
  export type RevalidateRendererFactory = (req: Request) => RevalidateRenderer;
74
52
 
75
- /** Optional renderer for fallback deny pages — mirrors `PipelineConfig.renderDenyFallback`. */
76
- export type RenderDenyFallbackFn = (
77
- deny: DenySignal,
78
- req: Request,
79
- responseHeaders: Headers,
80
- match?: RouteMatch
81
- ) => Response | Promise<Response>;
82
-
83
- /** Dependencies for the action-dispatch wrapper. */
53
+ /** Dependencies for the CSRF wrapper. */
84
54
  export interface ActionDispatchDeps {
85
55
  /** CSRF configuration (Origin allow-list, on/off switch). */
86
56
  csrfConfig: CsrfConfig;
57
+ }
58
+
59
+ /** Dependencies for the action dispatcher (used inside the pipeline). */
60
+ export interface ActionDispatcherDeps {
61
+ /** CSRF configuration — defense-in-depth check inside the action handler. */
62
+ csrfConfig: CsrfConfig;
87
63
  /** Body size limits forwarded to `handleActionRequest`. */
88
64
  bodyLimits?: BodyLimitsConfig['limits'];
89
65
  /** Sensitive-field deny-list forwarded to `handleActionRequest`. */
@@ -91,359 +67,120 @@ export interface ActionDispatchDeps {
91
67
  /** Per-request factory that builds a `RevalidateRenderer`. */
92
68
  buildRevalidateRenderer: RevalidateRendererFactory;
93
69
  /**
94
- * Route matcher — when present, enables middleware-on-actions execution.
95
- * Mirrors `PipelineConfig.matchRoute`. Tests that don't exercise the
96
- * middleware path may omit this; the wrapper falls back to the legacy
97
- * "actions skip middleware" behavior.
70
+ * Route matcher — used for the route-type check that skips action
71
+ * detection on `route.ts` API handlers (TIM-870).
98
72
  */
99
73
  matchRoute?: RouteMatcher;
100
- /**
101
- * Segment-param coercer — runs the matched route's `params.ts` codecs
102
- * so the middleware context sees typed `segmentParams`. Required when
103
- * `matchRoute` is provided.
104
- */
105
- coerceSegmentParams?: CoerceSegmentParamsFn;
106
- /**
107
- * Renderer for fallback deny pages — used when middleware throws a
108
- * `DenySignal` and the framework wants to render `403.tsx` / `404.tsx`
109
- * instead of returning a bare empty Response. Optional — when omitted
110
- * the wrapper falls back to a bare status response.
111
- */
112
- renderDenyFallback?: RenderDenyFallbackFn;
113
- /**
114
- * Whether to run `middleware.ts` on server action requests. Defaults
115
- * to `true` (the safe default). Controlled by
116
- * `actions.runMiddleware` in `timber.config.ts`. See TIM-871.
117
- */
118
- runMiddleware?: boolean;
119
- /**
120
- * Whether to strip trailing slashes during pathname canonicalization
121
- * for route matching. Mirrors `PipelineConfig.stripTrailingSlash`;
122
- * defaults to `true` when omitted, matching the page pipeline. The
123
- * wrapper MUST canonicalize before matching so non-canonical action
124
- * POSTs (`/admin/`, `/admin//`, encoded separators) cannot bypass the
125
- * middleware gate by missing the route match and falling through to
126
- * the legacy path. See TIM-871 / codex review.
127
- */
128
- stripTrailingSlash?: boolean;
129
74
  }
130
75
 
131
- // ─── Implementation ───────────────────────────────────────────────────────
76
+ // ─── CSRF Wrapper ─────────────────────────────────────────────────────────
132
77
 
133
78
  /**
134
- * Wrap a pipeline function with CSRF validation and server-action dispatch.
79
+ * Wrap a pipeline function with pipeline-boundary CSRF validation.
135
80
  *
136
81
  * The returned handler is the framework's outermost request entry point.
137
- * Its responsibilities are documented in the file header.
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.
138
85
  *
139
- * The duplicate `validateCsrf` call inside `handleActionRequest` is left in
140
- * place as defense-in-depth (no-op on the happy path) so the action handler
141
- * remains safe to call from any future entry point that bypasses this
142
- * wrapper. See LOCAL-773.
86
+ * Action dispatch has moved inside the pipeline (TIM-1213) so proxy.ts
87
+ * runs on every request. This wrapper now only handles CSRF.
143
88
  */
144
89
  export function wrapPipelineWithActionDispatch(
145
90
  pipeline: (req: Request) => Promise<Response>,
146
91
  deps: ActionDispatchDeps
147
92
  ): (req: Request) => Promise<Response> {
148
- const middlewareEnabled = deps.runMiddleware !== false;
149
- const stripTrailingSlash = deps.stripTrailingSlash ?? true;
150
-
151
93
  return async (req: Request): Promise<Response> => {
152
- // ─── 1. Pipeline-boundary CSRF validation (LOCAL-773) ─────────────
153
- //
94
+ // Pipeline-boundary CSRF validation (LOCAL-773).
154
95
  // Runs on EVERY unsafe-method request, before any dispatch decision.
155
- // Without this, `route.ts` PUT/PATCH/DELETE handlers and POSTs with
156
- // `Content-Type: application/json` or `text/plain` would reach the
157
- // route handler with no Origin check at all — `isActionRequest` only
158
- // matches POST + form/multipart/x-rsc-action.
159
- //
160
- // `text/plain` POST is a CORS-simple request, so a cross-site
161
- // `<form enctype="text/plain">` submission carries `SameSite=Lax`
162
- // cookies (the framework's own default).
163
96
  const csrfResult = validateCsrf(req, deps.csrfConfig);
164
97
  if (!csrfResult.ok) {
165
98
  return new Response(null, { status: csrfResult.status });
166
99
  }
167
100
 
168
- // ─── 2. Server action interception ────────────────────────────────
169
- if (isActionRequest(req)) {
170
- // ─── 2a. Route-type check + canonicalize (TIM-870) ──────────────
171
- //
172
- // Canonicalize and match the route BEFORE any body parsing. If the
173
- // matched route is an API route (route.ts), skip action detection
174
- // entirely and dispatch straight to the pipeline. Server actions
175
- // only live on page routes; POSTs to route.ts are API requests
176
- // whose body must reach the handler untouched — not pre-parsed
177
- // looking for $ACTION_REF fields.
178
- //
179
- // This also avoids allocating a full formData() parse over large
180
- // multipart uploads (e.g. 50 MB file uploads to /api/upload) that
181
- // would otherwise be buffered and discarded by handleFormAction.
182
- //
183
- // The same canonicalized match is reused for the middleware-on-
184
- // actions path below, avoiding a redundant match.
185
- let match: RouteMatch | null = null;
101
+ return pipeline(req);
102
+ };
103
+ }
186
104
 
187
- if (deps.matchRoute) {
188
- // Canonicalize the pathname BEFORE matching, with the exact same
189
- // rules the page pipeline uses (`canonicalize()` in
190
- // `pipeline-phases.ts` stage 1). Without this, an attacker could
191
- // POST to a non-canonical variant of an authenticated route —
192
- // `/admin/` with a trailing slash, `/admin//` with a doubled
193
- // slash, `/adm%69n` with percent-escapes, `/admin%2fnested` with
194
- // an encoded separator — fail the match here, fall through to
195
- // the legacy "no middleware" path, and still reach the action
196
- // handler. The canonicalization step closes that bypass and
197
- // guarantees the wrapper matches EXACTLY the same route the page
198
- // pipeline would match. A canonicalize failure (encoded
199
- // separator, null byte, malformed escape, `..` escaping root)
200
- // returns the canonicalizer's status directly — the request is
201
- // malformed, never dispatched. See TIM-871 (codex review).
202
- const url = new URL(req.url);
203
- const canonical = canonicalize(url.pathname, stripTrailingSlash);
204
- if (!canonical.ok) {
205
- return new Response(null, { status: canonical.status });
206
- }
207
- match = deps.matchRoute(canonical.pathname);
105
+ // ─── Action Dispatcher ────────────────────────────────────────────────────
208
106
 
209
- // Skip action detection for route.ts API handlers (TIM-870).
210
- // Server actions only target page routes. A POST to a route.ts
211
- // path is an API request — the full body should reach the route
212
- // handler without being pre-parsed by handleFormAction.
213
- if (match) {
214
- const leaf = match.segments[match.segments.length - 1];
215
- if (leaf?.route) {
216
- return pipeline(req);
217
- }
107
+ /**
108
+ * Build the `dispatchAction` callback for `PipelineConfig`.
109
+ *
110
+ * Called after proxy.ts runs (inside the pipeline) for every request.
111
+ * Checks if the request is a server action POST, handles the route-type
112
+ * check (TIM-870), dispatches to the action handler, and handles the
113
+ * no-JS validation rerender path.
114
+ *
115
+ * Returns `Response` if the request was handled as an action, `null` to
116
+ * fall through to normal routing.
117
+ */
118
+ export function buildActionDispatcher(
119
+ deps: ActionDispatcherDeps
120
+ ): (
121
+ req: Request,
122
+ canonicalPath: string,
123
+ reenter: (req: Request) => Promise<Response>
124
+ ) => Promise<Response | null> {
125
+ return async (
126
+ req: Request,
127
+ canonicalPath: string,
128
+ reenter: (req: Request) => Promise<Response>
129
+ ): Promise<Response | null> => {
130
+ if (!isActionRequest(req)) return null;
131
+
132
+ // Route-type check (TIM-870): match the route BEFORE any body parsing.
133
+ // If the matched route is an API route (route.ts), skip action detection
134
+ // entirely — server actions only live on page routes; POSTs to route.ts
135
+ // are API requests whose body must reach the handler untouched.
136
+ //
137
+ // Use `canonicalPath` from the pipeline — NOT `new URL(req.url).pathname`,
138
+ // 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) {
142
+ const leaf = match.segments[match.segments.length - 1];
143
+ if (leaf?.route) {
144
+ return null;
218
145
  }
219
146
  }
147
+ }
220
148
 
221
- // ─── 2b. Middleware-on-actions (TIM-871) ────────────────────────
222
- //
223
- // When middleware execution is enabled and the request matches a
224
- // known route, run the matched chain BEFORE the action handler.
225
- // The middleware run happens inside its own `runWithRequestContext`
226
- // scope so middleware can read/write cookies and inject request
227
- // headers via the standard ALS APIs. Mutations are captured at the
228
- // end of the scope and threaded into the action handler's own ALS
229
- // scope via `seedRequestCookies` + a request rebuilt with overlay
230
- // headers merged in.
231
- let downstreamReq = req;
232
- let middlewareSetCookieHeaders: string[] = [];
233
-
234
- if (
235
- middlewareEnabled &&
236
- deps.coerceSegmentParams &&
237
- match &&
238
- match.middlewareChain.length > 0
239
- ) {
240
- const outcome = await runMiddlewareForAction(req, match, deps.coerceSegmentParams);
241
-
242
- // ─── Translate middleware outcome ─────────────────────────
243
- if (outcome.kind === 'param-coercion-error') {
244
- // Bad segment params → 404. Matches the page pipeline.
245
- return new Response(null, { status: 404 });
246
- }
247
- if (outcome.kind === 'error') {
248
- // Unhandled middleware error → 500.
249
- return new Response(null, { status: 500 });
250
- }
251
- if (outcome.kind === 'short-circuit') {
252
- // Middleware returned a Response. Apply its Set-Cookie
253
- // snapshot before returning. Clone unconditionally so the
254
- // header bag is mutable.
255
- const finalResponse = cloneWithMutableHeaders(outcome.response);
256
- for (const value of outcome.setCookieHeaders) {
257
- finalResponse.headers.append('Set-Cookie', value);
258
- }
259
- return finalResponse;
260
- }
261
- if (outcome.kind === 'redirect') {
262
- // RedirectSignal from middleware → standard redirect Response.
263
- // Use `buildRedirectResponse` so the with-JS path produces a
264
- // 204 + X-Timber-Redirect (SPA navigation) and the no-JS path
265
- // produces a real HTTP 30x — exactly the same translation
266
- // the page pipeline applies in `outcomeToResponse`.
267
- const headers = new Headers();
268
- for (const value of outcome.setCookieHeaders) {
269
- headers.append('Set-Cookie', value);
270
- }
271
- return buildRedirectResponse(outcome.signal, req, headers);
272
- }
273
- if (outcome.kind === 'deny') {
274
- // DenySignal from middleware → render the matched route's
275
- // colocated deny page if available, otherwise a bare empty
276
- // status response. Mirrors the page pipeline's deny handling.
277
- const headers = new Headers();
278
- for (const value of outcome.setCookieHeaders) {
279
- headers.append('Set-Cookie', value);
280
- }
281
- if (deps.renderDenyFallback) {
282
- try {
283
- return cloneWithMutableHeaders(
284
- await deps.renderDenyFallback(outcome.signal, req, headers, outcome.match)
285
- );
286
- } catch (denyRenderError) {
287
- // Deny page rendering failed — log before falling through to bare
288
- // response. Without this, a crashing deny page on the action path
289
- // produces a blank response with zero server-side signal. See TIM-876.
290
- const url = new URL(req.url);
291
- logRenderError({ method: req.method, path: url.pathname, error: denyRenderError });
292
- await fireOnRequestError(denyRenderError, req, 'render');
293
- }
294
- }
295
- return new Response(null, { status: outcome.signal.status, headers });
296
- }
297
-
298
- // ─── Continue: middleware passed ──────────────────────────
299
- //
300
- // Build a downstream Request that the action handler will see:
301
- // - Original headers + middleware's request-header overlay
302
- // merged on top, so `getHeaders()` inside the action body
303
- // observes the injected values.
304
- // - Cookie header dropped; the post-middleware RYW cookie
305
- // state is threaded directly into the action handler's
306
- // request context via `seedRequestCookies`. This avoids
307
- // a Cookie-header round-trip that would re-parse via
308
- // `parseCookieHeader` — the same H-3 smuggling primitive
309
- // mitigated by TIM-868. The action's own cookie reads
310
- // therefore observe both the original cookies and any
311
- // middleware mutations, with no cleartext encoding step
312
- // in between.
313
- // - Body forwarded as-is so the action handler can decode it.
314
- // - Method preserved (POST).
315
- //
316
- // We hold the middleware Set-Cookie snapshot to prepend to the
317
- // final response below — middleware writes precede action
318
- // writes in the response order so the browser's last-wins
319
- // resolution lets the action override middleware on conflict.
320
- const mergedHeaders = new Headers(req.headers);
321
- outcome.overlay.forEach((value, key) => {
322
- mergedHeaders.set(key, value);
323
- });
324
- mergedHeaders.delete('cookie');
325
- downstreamReq = new Request(req.url, {
326
- method: req.method,
327
- headers: mergedHeaders,
328
- body: req.body,
329
- // Required by undici when constructing a Request with a
330
- // streaming body — `req.body` is a ReadableStream and the
331
- // fetch spec needs an explicit half-duplex declaration.
332
- // @ts-expect-error — `duplex` is not in the standard Request init type yet.
333
- duplex: 'half',
149
+ // Action handler dispatch
150
+ const actionResponse = await handleActionRequest(req, {
151
+ csrf: deps.csrfConfig,
152
+ bodyLimits: { limits: deps.bodyLimits },
153
+ sensitiveFields: deps.sensitiveFields,
154
+ revalidateRenderer: deps.buildRevalidateRenderer(req),
155
+ });
156
+
157
+ if (actionResponse) {
158
+ // No-JS validation rerender
159
+ if ('rerender' in actionResponse) {
160
+ const formRerender = actionResponse as FormRerender;
161
+ // Build a synthetic GET request for the rerender pipeline:
162
+ // - Same URL (so route matching lands on the same page)
163
+ // - Cookie header DROPPED entirely. The post-action RYW state
164
+ // is threaded into the rerender request context as a parsed
165
+ // `Map<string, string>` via `seedRequestCookies` below.
166
+ // - Method GET because the rerender is conceptually a page render.
167
+ const rerenderHeaders = new Headers(req.headers);
168
+ rerenderHeaders.delete('cookie');
169
+ const rerenderReq = new Request(req.url, {
170
+ method: 'GET',
171
+ headers: rerenderHeaders,
334
172
  });
335
- seedRequestCookies(downstreamReq, outcome.cookies);
336
- middlewareSetCookieHeaders = outcome.setCookieHeaders;
337
- }
338
-
339
- // ─── 2c. Action handler dispatch ────────────────────────────────
340
- //
341
- // The revalidate renderer is built from the ORIGINAL request, not
342
- // `downstreamReq`. The downstream request had its `cookie` header
343
- // stripped (the post-middleware RYW cookie state is threaded
344
- // through ALS via `seedRequestCookies` instead, to preserve the
345
- // H-3 smuggling invariant from TIM-868). But the revalidate
346
- // renderer in `rsc-entry/index.ts` forwards `req.headers` onto the
347
- // synthetic revalidation Request it builds for the target path —
348
- // if we passed `downstreamReq` here, that synthetic request would
349
- // carry no cookies, and an action that calls `revalidatePath()`
350
- // would re-render the target route with no session / tenant / auth
351
- // context. Using `req` (the unmodified inbound request) preserves
352
- // the original cookie header for the revalidation side channel
353
- // while `seedRequestCookies` handles the middleware RYW state for
354
- // the action body itself. See TIM-871 (codex review).
355
- const actionResponse = await handleActionRequest(downstreamReq, {
356
- csrf: deps.csrfConfig,
357
- bodyLimits: { limits: deps.bodyLimits },
358
- sensitiveFields: deps.sensitiveFields,
359
- revalidateRenderer: deps.buildRevalidateRenderer(req),
360
- });
361
-
362
- if (actionResponse) {
363
- // ─── 3. No-JS validation rerender ─────────────────────────────
364
- if ('rerender' in actionResponse) {
365
- const formRerender = actionResponse as FormRerender;
366
- // Build a synthetic GET request for the rerender pipeline:
367
- // - Same URL (so route matching lands on the same page)
368
- // - Cookie header DROPPED entirely. The post-action RYW state
369
- // is threaded into the rerender request context as a parsed
370
- // `Map<string, string>` via `seedRequestCookies` below, so
371
- // `parseCookieHeader` is never called for this request.
372
- // This eliminates the value-smuggling primitive that the
373
- // previous string round-trip exposed: a `;`-laden cookie
374
- // value would otherwise split into sibling cookies during
375
- // re-parse and let an attacker inject `role=admin` /
376
- // forged session cookies into the rerender response. See
377
- // ONGOING_SECURITY.md H-3 (TIM-868) and TIM-837.
378
- // - Method GET because the rerender is conceptually a page
379
- // render, not a re-POST. The pipeline doesn't branch on
380
- // method for page rendering, and constructing a POST without
381
- // a body is awkward across Request implementations.
382
- // - Marked via `markRequestBypassMiddleware` so the pipeline
383
- // skips its middleware phase on this synthetic request.
384
- // Middleware already ran once on the inbound POST (above);
385
- // letting the pipeline run it again would double-execute
386
- // auth, rate limiting, and request-header injection. See
387
- // TIM-871.
388
- const rerenderHeaders = new Headers(req.headers);
389
- rerenderHeaders.delete('cookie');
390
- const rerenderReq = new Request(req.url, {
391
- method: 'GET',
392
- headers: rerenderHeaders,
393
- });
394
- // Seed BEFORE pipeline() runs — runWithRequestContext consumes
395
- // the seed when it constructs the per-request store.
396
- seedRequestCookies(rerenderReq, formRerender.cookies);
397
- markRequestBypassMiddleware(rerenderReq);
398
- const response = await runWithFormFlash(formRerender.rerender, () =>
399
- pipeline(rerenderReq)
400
- );
401
- // Apply Set-Cookie headers snapshotted from the action's ALS scope.
402
- // The pipeline above runs in its own request context with a fresh
403
- // cookie jar, so cookies set inside the action would otherwise be
404
- // silently dropped on the no-JS rerender path. See TIM-836
405
- // (LOCAL-740). Middleware-set cookies are prepended first so
406
- // browser last-wins still lets action writes override.
407
- for (const value of middlewareSetCookieHeaders) {
408
- response.headers.append('Set-Cookie', value);
409
- }
410
- for (const value of formRerender.setCookieHeaders) {
411
- response.headers.append('Set-Cookie', value);
412
- }
413
- return response;
173
+ seedRequestCookies(rerenderReq, formRerender.cookies);
174
+ const response = await runWithFormFlash(formRerender.rerender, () => reenter(rerenderReq));
175
+ // Apply Set-Cookie headers snapshotted from the action's ALS scope.
176
+ for (const value of formRerender.setCookieHeaders) {
177
+ response.headers.append('Set-Cookie', value);
414
178
  }
415
- // Apply middleware Set-Cookie snapshot to the action's RSC
416
- // response. Action's own cookies were already appended inside
417
- // `handleActionRequest` via `getSetCookieHeaders()` before the
418
- // action ALS scope exited; we prepend middleware writes so
419
- // browser last-wins lets action writes take precedence on
420
- // conflicting names.
421
- if (middlewareSetCookieHeaders.length > 0) {
422
- // Middleware writes go FIRST in the response order, so we
423
- // build a fresh Headers and rebuild the Response. Cloning the
424
- // Response keeps the body stream intact.
425
- const mergedHeaders = new Headers();
426
- for (const value of middlewareSetCookieHeaders) {
427
- mergedHeaders.append('Set-Cookie', value);
428
- }
429
- actionResponse.headers.forEach((value, key) => {
430
- if (key.toLowerCase() === 'set-cookie') {
431
- mergedHeaders.append('Set-Cookie', value);
432
- } else {
433
- mergedHeaders.set(key, value);
434
- }
435
- });
436
- return new Response(actionResponse.body, {
437
- status: actionResponse.status,
438
- statusText: actionResponse.statusText,
439
- headers: mergedHeaders,
440
- });
441
- }
442
- return actionResponse;
179
+ return response;
443
180
  }
181
+ return actionResponse;
444
182
  }
445
183
 
446
- // ─── 4. Normal route dispatch ─────────────────────────────────────
447
- return pipeline(req);
184
+ return null;
448
185
  };
449
186
  }