@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
@@ -18,6 +18,7 @@
18
18
  * Design doc: 24-fonts.md
19
19
  */
20
20
 
21
+ import { parseAst } from 'vite';
21
22
  import type { Plugin, ResolvedConfig, ViteDevServer } from 'vite';
22
23
  import type { PluginContext } from '../plugin-context.js';
23
24
  import {
@@ -224,6 +225,35 @@ export function timberFonts(ctx: PluginContext): Plugin {
224
225
  */
225
226
  buildStart() {},
226
227
 
228
+ /**
229
+ * Prune stale font entries when a module that previously imported fonts
230
+ * no longer contains any font import. The transform hook's `filter.code`
231
+ * skips such modules, so without this hook the old entries would persist
232
+ * until dev restart. See TIM-1087.
233
+ */
234
+ async hotUpdate({ type, file, read }) {
235
+ if (!pipeline.hasImporter(file)) return;
236
+ if (type === 'delete') {
237
+ pipeline.pruneImporter(file);
238
+ return;
239
+ }
240
+ const code = await read();
241
+ const hasFontImport =
242
+ code.includes('@timber/fonts/') ||
243
+ code.includes('@timber-js/app/fonts/') ||
244
+ code.includes('next/font/');
245
+ if (hasFontImport) return;
246
+ // Only prune if the new source parses successfully. A syntax error
247
+ // means the HMR update will fail and the old module stays active —
248
+ // pruning would leave it without font CSS until the next valid save.
249
+ try {
250
+ parseAst(code, { lang: 'tsx' });
251
+ } catch {
252
+ return;
253
+ }
254
+ pipeline.pruneImporter(file);
255
+ },
256
+
227
257
  /**
228
258
  * Scan source files for font function calls and extract static config.
229
259
  *
@@ -19,23 +19,15 @@ import type { Plugin } from 'vite';
19
19
  // Listing explicitly silences Vite's "Automatically externalized" warnings.
20
20
  const NODE_BUILTINS: string[] = builtinModules.flatMap((m) => [m, `node:${m}`]);
21
21
 
22
- // Build-time-only packages that must never be bundled into the runtime
23
- // server environments. With `noExternal: true` rolldown otherwise follows
24
- // the transitive graph through the user's `timber.config.ts` (loaded by the
25
- // `virtual:timber-cache-handler` dynamic import) into adapter code, dragging
26
- // build tooling into the prod server bundle. The most visible symptom is
27
- // rolldown trying to parse `fsevents.node` as JS and failing with
28
- // `[UNLOADABLE_DEPENDENCY] … stream did not contain valid UTF-8`.
22
+ // Packages that must never be bundled into the runtime server environments.
23
+ // With `noExternal: true`, rolldown follows all transitive imports. These
24
+ // are build-time-only tools that have no runtime use in a server bundle.
29
25
  //
30
- // - `nitro`, `@mdx-js/rollup` — build-time tooling reachable
31
- // transitively through adapter modules and timber's own MDX activation.
32
- // - `rollup` — pulled in by nitro and
33
- // @mdx-js/rollup; declares `fsevents` as an optional native dep.
34
- // - `fsevents`, `chokidar` — native/optional deps of
35
- // rollup/vite. They are macOS-only file watchers with no runtime use
36
- // in a server bundle.
26
+ // The primary leak path (TIM-988) — `virtual:timber-cache-handler` importing
27
+ // `timber.config.ts` and pulling in adapter/MDX deps — was eliminated by
28
+ // TIM-1211: the cache handler virtual module now imports `timber.cache.ts`
29
+ // instead. These externals are retained as defense-in-depth.
37
30
  //
38
- // Mirrored on both rsc and ssr environments. See TIM-988.
39
31
  // Cloudflare runtime modules (cloudflare:workers, cloudflare:sockets, etc.)
40
32
  // available at runtime on Workers but must not be bundled.
41
33
  const CLOUDFLARE_BUILTINS = [
@@ -232,6 +232,13 @@ function scanSegmentFiles(dirPath: string, node: SegmentNode, extSet: Set<string
232
232
  // See design/16-metadata.md §"Metadata Routes"
233
233
  const metaInfo = classifyMetadataRoute(entry);
234
234
  if (metaInfo) {
235
+ if (!metaInfo.nestable && node.segmentName !== '') {
236
+ throw new Error(
237
+ `Build error: '${name}' is a root-only metadata convention and must be in the app root directory.\n` +
238
+ ` File: ${fullPath}\n` +
239
+ ` Move this file to the app root (not a route group or nested segment).`
240
+ );
241
+ }
235
242
  if (!node.metadataRoutes) {
236
243
  node.metadataRoutes = {};
237
244
  }
@@ -8,7 +8,7 @@
8
8
  * 1. Direct call: action(input) → Promise<ActionResult>
9
9
  * 2. React useActionState: (prevState, formData) => Promise<ActionResult>
10
10
  *
11
- * See design/08-forms-and-actions.md §"Middleware for Server Actions"
11
+ * See design/08-forms-and-actions.md §"Middleware and Server Actions"
12
12
  */
13
13
 
14
14
  // ─── ActionError ─────────────────────────────────────────────────────────
@@ -127,6 +127,13 @@ export interface RequestContextStore {
127
127
  * See TIM-666.
128
128
  */
129
129
  denyStatus?: number;
130
+ /**
131
+ * Callback fired when setDenyStatus() is called. Used by shellSettled
132
+ * to short-circuit when any component catches a DenySignal in-tree,
133
+ * regardless of which component caught it (AccessGate, TracedLayout,
134
+ * or PageDenyBoundary). See TIM-1208.
135
+ */
136
+ onDenyStatus?: () => void;
130
137
  /**
131
138
  * Dev-only: getter for the current request's RSC debug components.
132
139
  * Set by renderRoute() so onPipelineError can include component tree
@@ -262,6 +262,7 @@ export async function PageDenyBoundary({
262
262
  Page,
263
263
  route,
264
264
  denyPages,
265
+ onSettled,
265
266
  }: {
266
267
  /** The page server component function. */
267
268
  Page: (...args: unknown[]) => unknown;
@@ -269,10 +270,10 @@ export async function PageDenyBoundary({
269
270
  route: string;
270
271
  /** Deny page fallback chain from the segment chain. */
271
272
  denyPages: DenyPageEntry[];
273
+ /** Called when the page component finishes (success or deny). See TIM-1045. */
274
+ onSettled?: () => void;
272
275
  }): Promise<React.ReactElement> {
273
276
  try {
274
- // Call the page as an async function — same as React Flight does.
275
- // Wrap in OTEL span for tracing (replaces the TracedPage wrapper).
276
277
  const result = await withSpan('timber.page', { 'timber.route': route }, () => Page({}));
277
278
  return result as React.ReactElement;
278
279
  } catch (error: unknown) {
@@ -283,8 +284,9 @@ export async function PageDenyBoundary({
283
284
  return denyElement;
284
285
  }
285
286
  }
286
- // Non-deny errors (RedirectSignal, runtime errors) propagate normally.
287
287
  throw error;
288
+ } finally {
289
+ onSettled?.();
288
290
  }
289
291
  }
290
292
 
@@ -299,6 +301,7 @@ export function setDenyStatus(status: number): void {
299
301
  const store = requestContextAls.getStore();
300
302
  if (store) {
301
303
  store.denyStatus = status;
304
+ store.onDenyStatus?.();
302
305
  }
303
306
  }
304
307
 
@@ -57,47 +57,3 @@ export async function runMiddlewareChain(
57
57
  }
58
58
  return undefined;
59
59
  }
60
-
61
- // ─── Per-Request Middleware Bypass ─────────────────────────────────────────
62
-
63
- /**
64
- * Per-request marker for synthetic re-render requests that should NOT
65
- * re-execute `middleware.ts`. The action-dispatch wrapper runs middleware
66
- * once on the inbound action POST; when validation fails on the no-JS
67
- * path, it builds a synthetic GET that flows through the normal pipeline
68
- * to render the page with `getFormFlash()` data. Without this marker, the
69
- * pipeline would run middleware a second time on that synthetic GET.
70
- *
71
- * The set is keyed by the synthetic Request object itself, so the entry
72
- * lives exactly as long as the request and is garbage-collected with it.
73
- * Cannot be set or detected by user code — there is no header, no URL
74
- * parameter, nothing on the wire that an attacker could spoof.
75
- *
76
- * See TIM-871.
77
- *
78
- * @internal — framework use only.
79
- */
80
- const middlewareBypassRequests = new WeakSet<Request>();
81
-
82
- /**
83
- * Mark a request so the pipeline skips its middleware phase.
84
- *
85
- * Used by `wrap-action-dispatch.ts` for the no-JS form-rerender path.
86
- *
87
- * @internal
88
- */
89
- export function markRequestBypassMiddleware(req: Request): void {
90
- middlewareBypassRequests.add(req);
91
- }
92
-
93
- /**
94
- * Check whether a request was marked to bypass middleware.
95
- *
96
- * Called by `handleRequest` in pipeline-phases.ts before invoking the
97
- * middleware phase. Returns false for any request not explicitly marked.
98
- *
99
- * @internal
100
- */
101
- export function shouldBypassMiddleware(req: Request): boolean {
102
- return middlewareBypassRequests.has(req);
103
- }
@@ -132,6 +132,7 @@ export async function coerceSegmentParams(match: RouteMatch): Promise<void> {
132
132
  if (!codec) continue; // no codec in schema → keep raw string
133
133
 
134
134
  const key = segment.paramName;
135
+ if (!(key in mergeTarget)) continue;
135
136
  try {
136
137
  mergeTarget[key] = sanitizeParamValue(codec.parse(mergeTarget[key] as string | string[]));
137
138
  } catch (err) {
@@ -14,7 +14,7 @@
14
14
 
15
15
  import { canonicalize } from './canonicalize.js';
16
16
  import { runProxy } from './proxy.js';
17
- import { runMiddlewareChain, shouldBypassMiddleware } from './middleware-runner.js';
17
+ import { runMiddlewareChain } from './middleware-runner.js';
18
18
  import { withTiming } from './server-timing.js';
19
19
  import {
20
20
  applyRequestHeaderOverlay,
@@ -66,8 +66,11 @@ function validateInterceptionHeader(raw: string, stripTrailingSlash: boolean): s
66
66
  // ─── Phase: Proxy ──────────────────────────────────────────────────────────
67
67
 
68
68
  /**
69
- * Run the proxy.ts phase. Calls user proxy code and uses `handleRequest` as
70
- * the inner `next()` continuation. The proxy resolver was picked at pipeline
69
+ * Run the proxy.ts phase. Calls user proxy code with a `next()` continuation.
70
+ *
71
+ * When `next` is provided, it replaces the default `handleRequest` as the
72
+ * continuation — this lets the pipeline inject action dispatch between proxy
73
+ * and route matching (TIM-1213). The proxy resolver was picked at pipeline
71
74
  * construction time so the hot path sees no per-request branching on the
72
75
  * `ProxyConfig` discriminant.
73
76
  */
@@ -76,13 +79,14 @@ export async function runProxyPhase(
76
79
  getProxy: ProxyResolver,
77
80
  req: Request,
78
81
  method: string,
79
- path: string
82
+ path: string,
83
+ next?: () => Promise<Response>
80
84
  ): Promise<PhaseOutcome> {
81
85
  const detailed = config.serverTiming === 'detailed';
82
86
  try {
83
87
  const proxyExport = await getProxy();
84
- const proxyFn = () =>
85
- runProxy(proxyExport, req, () => handleRequest(config, req, method, path, true));
88
+ const continuation = next ?? (() => handleRequest(config, req, method, path, true));
89
+ const proxyFn = () => runProxy(proxyExport, req, continuation);
86
90
  const response = await withSpan('timber.proxy', {}, () =>
87
91
  detailed ? withTiming('proxy', 'proxy.ts', proxyFn) : proxyFn()
88
92
  );
@@ -514,14 +518,8 @@ export async function handleRequest(
514
518
  .join('/') || '/';
515
519
  setMatchedSegmentPath(segmentPath.startsWith('/') ? segmentPath : `/${segmentPath}`);
516
520
 
517
- // Bypass middleware for synthetic re-render requests built by the
518
- // action-dispatch wrapper after a no-JS form validation failure.
519
- // The wrapper has already executed middleware once on the inbound POST;
520
- // running it again on the rerender GET would double-execute auth, rate
521
- // limiting, and request-header injection. See TIM-871.
522
- const skipMiddleware = shouldBypassMiddleware(req);
523
521
  const outcome =
524
- !skipMiddleware && match.middlewareChain.length > 0
522
+ match.middlewareChain.length > 0
525
523
  ? await runMiddlewarePhase(config, req, match, responseHeaders, requestHeaderOverlay, {
526
524
  canonicalPathname,
527
525
  interception,
@@ -238,6 +238,29 @@ export interface PipelineConfig {
238
238
  */
239
239
  match?: RouteMatch
240
240
  ) => Response | Promise<Response>;
241
+
242
+ /**
243
+ * Server action dispatch — called after proxy.ts runs for POST requests
244
+ * that look like server actions. Returns a Response if the request was
245
+ * handled as an action, or null to fall through to normal routing.
246
+ *
247
+ * `canonicalPath` is the already-decoded canonical pathname — the same
248
+ * value that `handleRequest` and `matchRoute` use. Do NOT re-derive it
249
+ * from `req.url` (which re-encodes).
250
+ *
251
+ * The `reenter` parameter is the full pipeline function (including proxy,
252
+ * ALS, tracing) for the no-JS validation rerender path — the synthetic
253
+ * GET flows through the complete pipeline.
254
+ *
255
+ * Moved into the pipeline (TIM-1213) so proxy.ts runs on action POSTs,
256
+ * matching the design doc contract: "proxy.ts runs on every request,
257
+ * no exclusions."
258
+ */
259
+ dispatchAction?: (
260
+ req: Request,
261
+ canonicalPath: string,
262
+ reenter: (req: Request) => Promise<Response>
263
+ ) => Promise<Response | null>;
241
264
  }
242
265
 
243
266
  // ─── Pipeline ──────────────────────────────────────────────────────────────
@@ -265,7 +288,11 @@ export function createPipeline(config: PipelineConfig): (req: Request) => Promis
265
288
  // Logged with each request for diagnosing resource contention.
266
289
  let activeRequests = 0;
267
290
 
268
- return async (req: Request): Promise<Response> => {
291
+ // Self-reference for re-entry — the no-JS validation rerender path
292
+ // passes a synthetic GET back through the full pipeline (including
293
+ // proxy, ALS, tracing). The inner closure captures `pipelineFn` by
294
+ // name; by the time it's invoked, the const binding is initialized.
295
+ const pipelineFn = async (req: Request): Promise<Response> => {
269
296
  const url = new URL(req.url);
270
297
  const method = req.method;
271
298
  const path = url.pathname;
@@ -325,6 +352,24 @@ export function createPipeline(config: PipelineConfig): (req: Request) => Promis
325
352
  canonicalReq = new Request(canonicalUrl.toString(), req);
326
353
  }
327
354
 
355
+ // Build the inner handler — runs after proxy, before route
356
+ // matching. Checks for server action POSTs and dispatches them
357
+ // without entering the route match → middleware → render chain.
358
+ // Falls through to handleRequest for normal route handling.
359
+ // TIM-1213: action dispatch is now INSIDE the pipeline so
360
+ // proxy.ts runs on every request, including action POSTs.
361
+ const innerHandler = async (): Promise<Response> => {
362
+ if (config.dispatchAction) {
363
+ const actionResult = await config.dispatchAction(
364
+ canonicalReq,
365
+ canonicalPath,
366
+ pipelineFn
367
+ );
368
+ if (actionResult) return actionResult;
369
+ }
370
+ return handleRequest(config, canonicalReq, method, canonicalPath, true);
371
+ };
372
+
328
373
  let result: Response;
329
374
  if (proxyResolver) {
330
375
  const outcome = await runProxyPhase(
@@ -332,7 +377,8 @@ export function createPipeline(config: PipelineConfig): (req: Request) => Promis
332
377
  proxyResolver,
333
378
  canonicalReq,
334
379
  method,
335
- canonicalPath
380
+ canonicalPath,
381
+ innerHandler
336
382
  );
337
383
  result = await outcomeToResponse(config, outcome, {
338
384
  req: canonicalReq,
@@ -340,7 +386,7 @@ export function createPipeline(config: PipelineConfig): (req: Request) => Promis
340
386
  path: canonicalPath,
341
387
  });
342
388
  } else {
343
- result = await handleRequest(config, canonicalReq, method, canonicalPath, true);
389
+ result = await innerHandler();
344
390
  }
345
391
 
346
392
  // Set response status on the root span before it ends —
@@ -427,4 +473,6 @@ export function createPipeline(config: PipelineConfig): (req: Request) => Promis
427
473
  });
428
474
  });
429
475
  };
476
+
477
+ return pipelineFn;
430
478
  }
@@ -34,6 +34,7 @@ function getDevNonce(): string {
34
34
  }
35
35
  import { DenySignal, RedirectSignal } from './primitives.js';
36
36
  import { AccessGate } from './access-gate.js';
37
+ import { requestContextAls } from './als-registry.js';
37
38
  import {
38
39
  PageDenyBoundary,
39
40
  buildDenyPageChain,
@@ -177,6 +178,15 @@ export interface RouteElementResult {
177
178
  * See design/19-client-navigation.md §"X-Timber-State-Tree Header"
178
179
  */
179
180
  skippedSegments: string[];
181
+ /**
182
+ * Resolves when every deny-capable shell component (PageDenyBoundary and
183
+ * each TracedLayout with a deny page chain) has finished executing, OR
184
+ * immediately when any of them catches a DenySignal in-tree (the HTTP
185
+ * status is known at that point; inner components may never run).
186
+ * Undefined when the route has no deny-capable server components.
187
+ * See TIM-1045 (pages), TIM-1208 (layouts).
188
+ */
189
+ shellSettled?: Promise<void>;
180
190
  }
181
191
 
182
192
  // ─── Module Processing Helpers ─────────────────────────────────────────────
@@ -398,6 +408,7 @@ export async function buildRouteElement(
398
408
  // Skip OG auto-linking when the user already declared images in metadata.
399
409
  // See design/16-metadata.md §"Auto-Linking"
400
410
  const hasUserOgImage = Boolean(resolvedMetadata.openGraph?.images);
411
+ const hasUserTwitterImage = Boolean(resolvedMetadata.twitter?.images);
401
412
  const requestUrl = new URL(req.url);
402
413
  const requestPathname = requestUrl.pathname;
403
414
  // In dev mode, use the request origin so OG URLs resolve to localhost.
@@ -417,7 +428,7 @@ export async function buildRouteElement(
417
428
  if (!convention) continue;
418
429
  // Non-nestable routes only auto-link from root
419
430
  if (!convention.nestable && segment.urlPath !== '/') continue;
420
- // Skip auto-linking if user already declared the image in metadata
431
+ // Skip auto-linking entirely if user declared openGraph.images (covers both og + twitter)
421
432
  if (convention.type === 'opengraph-image' && hasUserOgImage) continue;
422
433
  // Build the href using the actual request path (not the pattern with [param]).
423
434
  // For nestable routes, the metadata route sits under the same resolved path
@@ -440,6 +451,14 @@ export async function buildRouteElement(
440
451
  href = new URL(href, ogBase).toString();
441
452
  }
442
453
  for (const autoLink of getMetadataRouteAutoLink(convention.type, href)) {
454
+ // Skip auto-linked twitter:image when user declared twitter.images
455
+ if (
456
+ hasUserTwitterImage &&
457
+ autoLink.tag === 'meta' &&
458
+ 'name' in autoLink &&
459
+ autoLink.name === 'twitter:image'
460
+ )
461
+ continue;
443
462
  if (autoLink.tag === 'link') {
444
463
  const attrs: Record<string, string> = { rel: autoLink.rel, href: autoLink.href };
445
464
  if (autoLink.type) attrs.type = autoLink.type;
@@ -462,12 +481,24 @@ export async function buildRouteElement(
462
481
  // Build element tree: page wrapped in layouts (innermost to outermost)
463
482
  const h = createElement as (...args: unknown[]) => React.ReactElement;
464
483
 
465
- // Build the page element.
466
- // Client references ('use client' pages) must NOT be called as functions —
467
- // they are proxy objects that throw when invoked. They must go through
468
- // createElement only, which serializes them as client references in the
469
- // RSC Flight stream. OTEL tracing is skipped for client components.
470
- // See TIM-627 for the original bug.
484
+ // Shell settlement tracking (TIM-1045 pages, TIM-1208 layouts).
485
+ // ChildSegmentProvider is 'use client', so React Flight eagerly
486
+ // serializes both the layout and child content as independent subtrees —
487
+ // the page can settle before an outer layout. We need all deny-capable
488
+ // components to settle before committing the HTTP status.
489
+ //
490
+ // Each tracked component gets its own deferred. shellSettled resolves
491
+ // when ALL settle (Promise.all) OR when any in-tree deny fires
492
+ // (short-circuit via onDenyStatus — covers AccessGate, TracedLayout,
493
+ // and PageDenyBoundary without threading callbacks into each).
494
+ const shellPromises: Promise<void>[] = [];
495
+ let resolveShortCircuit: (() => void) | undefined;
496
+ const trackShellComponent = (): (() => void) => {
497
+ let resolve!: () => void;
498
+ shellPromises.push(new Promise<void>((r) => (resolve = r)));
499
+ return resolve;
500
+ };
501
+
471
502
  // Build the page element.
472
503
  // Server component pages are wrapped in PageDenyBoundary which calls
473
504
  // them as async functions and catches DenySignal — rendering the deny
@@ -482,11 +513,12 @@ export async function buildRouteElement(
482
513
  if (isClientReference(PageComponent)) {
483
514
  element = h(PageComponent, {});
484
515
  } else if (leafDenyPages && leafDenyPages.length > 0) {
485
- // Server component page WITH deny page chain — wrap in PageDenyBoundary
516
+ const onSettled = trackShellComponent();
486
517
  element = h(PageDenyBoundary, {
487
518
  Page: PageComponent,
488
519
  route: match.segments[leafIndex]?.urlPath ?? '/',
489
520
  denyPages: leafDenyPages,
521
+ onSettled,
490
522
  });
491
523
  } else {
492
524
  // Server component page WITHOUT deny page chain — trace only
@@ -694,6 +726,8 @@ export async function buildRouteElement(
694
726
  // fallback path. See TIM-668, design/04-authorization.md.
695
727
  const layoutComponentRef = layoutComponent;
696
728
  const layoutDenyPages = denyPageChains.get(i);
729
+ const hasLayoutDenyChain = !!layoutDenyPages && layoutDenyPages.length > 0;
730
+ const onLayoutSettled = hasLayoutDenyChain ? trackShellComponent() : undefined;
697
731
  const TracedLayout = async (props: Record<string, unknown>) => {
698
732
  try {
699
733
  return await withSpan('timber.layout', { 'timber.segment': segmentId }, () =>
@@ -707,8 +741,11 @@ export async function buildRouteElement(
707
741
  return denyElement;
708
742
  }
709
743
  }
710
- // Non-deny errors (RedirectSignal, runtime errors) propagate normally.
744
+ // Non-deny errors (RedirectSignal, runtime errors) propagate normally
745
+ // to onError — signalDetected / renderError handling covers those.
711
746
  throw error;
747
+ } finally {
748
+ onLayoutSettled?.();
712
749
  }
713
750
  };
714
751
  layoutElement = h(TracedLayout, {
@@ -776,11 +813,24 @@ export async function buildRouteElement(
776
813
  element = replaceOutermostSegmentOutlet(element, outermostSegmentProvider);
777
814
  }
778
815
 
816
+ // Compose shellSettled: resolves when ALL deny-capable components settle
817
+ // (Promise.all) OR immediately when any component calls setDenyStatus()
818
+ // (short-circuit via onDenyStatus — covers AccessGate, TracedLayout, and
819
+ // PageDenyBoundary). See TIM-1045, TIM-1208.
820
+ let shellSettled: Promise<void> | undefined;
821
+ if (shellPromises.length > 0) {
822
+ const shortCircuit = new Promise<void>((r) => (resolveShortCircuit = r));
823
+ const store = requestContextAls.getStore();
824
+ if (store) store.onDenyStatus = () => resolveShortCircuit?.();
825
+ shellSettled = Promise.race([Promise.all(shellPromises).then(() => {}), shortCircuit]);
826
+ }
827
+
779
828
  return {
780
829
  element,
781
830
  layoutComponents,
782
831
  segments,
783
832
  deferSuspenseFor,
784
833
  skippedSegments,
834
+ shellSettled,
785
835
  };
786
836
  }
@@ -1,21 +1,21 @@
1
1
  /**
2
- * Middleware-on-actions runner — runs the matched route's `middleware.ts`
3
- * chain for an action POST inside its own `runWithRequestContext` scope
4
- * and returns a `MiddlewareOutcome` value the dispatcher can translate.
2
+ * Middleware runner for revalidation — runs a route's `middleware.ts`
3
+ * chain inside its own `runWithRequestContext` scope and returns a
4
+ * `MiddlewareOutcome` value the caller can translate.
5
5
  *
6
- * Lifted out of `wrap-action-dispatch.ts` (TIM-853) so the dispatcher
7
- * can be read top-to-bottom without scrolling past the chain runner. The
8
- * function is intentionally a free function (not a closure over deps)
9
- * so it can be unit-tested in isolation. It does not import virtual
10
- * modules and does not call into the action handler — it owns exactly
11
- * the "run middleware once" step and nothing else.
6
+ * Used by the revalidation renderer (TIM-996) to run the TARGET route's
7
+ * middleware before building the revalidated element tree, ensuring that
8
+ * `revalidatePath('/admin')` from a public action honors admin's auth.
12
9
  *
13
- * Failure modes are encoded as outcome variants rather than thrown so the
14
- * caller never has to wrap this in try/catch. The only thrown errors are
15
- * truly unexpected (programmer bugs) and propagate naturally.
10
+ * NOT used for action dispatch itself — per-segment middleware does not
11
+ * run on action POSTs (TIM-1134). Actions are dispatched by action ID,
12
+ * not route segment, so `proxy.ts` and `createActionClient({ middleware })`
13
+ * are the correct auth mechanisms.
16
14
  *
17
- * See TIM-871 for the full design rationale (closing the
18
- * CVE-2025-29927-class bug where actions silently bypass middleware).
15
+ * The function is intentionally a free function (not a closure over deps)
16
+ * so it can be unit-tested in isolation. Failure modes are encoded as
17
+ * outcome variants rather than thrown so the caller never has to wrap
18
+ * this in try/catch.
19
19
  */
20
20
 
21
21
  import { getCookiesForSsr, getSetCookieHeaders } from '../cookie-context.js';
@@ -43,7 +43,7 @@ import { loadCdnPurgeHandler } from 'virtual:timber-cache-handler';
43
43
  // @ts-expect-error — virtual module provided by timber-routing plugin
44
44
  import globalSchemaCodecs from 'virtual:timber-schema';
45
45
 
46
- import { wrapPipelineWithActionDispatch } from './wrap-action-dispatch.js';
46
+ import { wrapPipelineWithActionDispatch, buildActionDispatcher } from './wrap-action-dispatch.js';
47
47
  import type { BodyLimitsConfig } from '../body-limits.js';
48
48
  import type { BuildManifest } from '../build-manifest.js';
49
49
  import { resolveLogMode } from '../../dev-tools/logger.js';
@@ -199,6 +199,8 @@ async function createRequestHandler(manifest: typeof routeManifest, runtimeConfi
199
199
  | { disabled: boolean; enableHMRInDev: boolean }
200
200
  | undefined;
201
201
  const clientJsDisabled = clientJsConfig?.disabled ?? false;
202
+ const clientSegmentCache =
203
+ ((runtimeConfig as Record<string, unknown>).clientSegmentCache as boolean) ?? false;
202
204
  const clientBootstrap = buildClientScripts({
203
205
  ...runtimeConfig,
204
206
  clientJavascript: clientJsConfig ?? { disabled: false, enableHMRInDev: false },
@@ -295,6 +297,7 @@ async function createRequestHandler(manifest: typeof routeManifest, runtimeConfi
295
297
  {
296
298
  clientBootstrap,
297
299
  clientJsDisabled,
300
+ clientSegmentCache,
298
301
  rootSegment: manifest.root,
299
302
  buildManifest: typedBuildManifest,
300
303
  globalError: manifest.globalError,
@@ -386,19 +389,36 @@ async function createRequestHandler(manifest: typeof routeManifest, runtimeConfi
386
389
  // and no user-authored sitemap exists at the app root.
387
390
  // See design/16-metadata.md §"Auto-generated Sitemap"
388
391
  autoSitemapHandler: createAutoSitemapHandler(manifest.root, runtimeConfig),
392
+
393
+ // Server action dispatch — runs after proxy.ts, before route matching
394
+ // (TIM-1213). Moved into the pipeline so proxy.ts runs on action POSTs,
395
+ // matching the design doc: "proxy.ts runs on every request, no exclusions."
396
+ // Per-segment `middleware.ts` does NOT run on action POSTs (TIM-1134).
397
+ dispatchAction: buildActionDispatcher({
398
+ csrfConfig: {
399
+ csrf: runtimeConfig.csrf,
400
+ allowedOrigins: (runtimeConfig as Record<string, unknown>).allowedOrigins as
401
+ | string[]
402
+ | undefined,
403
+ },
404
+ bodyLimits: (runtimeConfig as Record<string, unknown>).limits as BodyLimitsConfig['limits'],
405
+ sensitiveFields: formsConfig?.stripSensitiveFields,
406
+ matchRoute,
407
+ buildRevalidateRenderer: createRevalidateRendererFactory({
408
+ matchRoute,
409
+ coerceSegmentParams,
410
+ buildManifest: buildManifest as BuildManifest,
411
+ }),
412
+ }),
389
413
  };
390
414
 
391
415
  const pipeline = createPipeline(pipelineConfig);
392
416
 
393
- // Wrap the pipeline to enforce CSRF at the request boundary and intercept
394
- // server action requests. By default, the wrapper also runs the matched
395
- // route's `middleware.ts` chain on action POSTs, so authentication, rate
396
- // limiting, tenant isolation, and request-header injection apply to
397
- // actions just like they do to page renders. See TIM-871 and
398
- // design/08-forms-and-actions.md §"Middleware for Server Actions".
399
- //
400
- // CSRF validation lives in the wrapper (not inside the action handler) so
401
- // it covers route.ts API handlers as well as server actions. See LOCAL-773.
417
+ // Wrap the pipeline to enforce CSRF at the request boundary.
418
+ // CSRF validation lives in the wrapper (not inside the pipeline) so it
419
+ // runs before proxy.ts and covers every unsafe-method request. Server
420
+ // action dispatch has moved into the pipeline (TIM-1213) so proxy.ts
421
+ // runs on action POSTs. See LOCAL-773.
402
422
  const csrfConfig = {
403
423
  csrf: runtimeConfig.csrf,
404
424
  allowedOrigins: (runtimeConfig as Record<string, unknown>).allowedOrigins as
@@ -406,38 +426,8 @@ async function createRequestHandler(manifest: typeof routeManifest, runtimeConfi
406
426
  | undefined,
407
427
  };
408
428
 
409
- const actionsConfig = (runtimeConfig as Record<string, unknown>).actions as
410
- | { runMiddleware?: boolean }
411
- | undefined;
412
-
413
429
  const handler = wrapPipelineWithActionDispatch(pipeline, {
414
430
  csrfConfig,
415
- bodyLimits: (runtimeConfig as Record<string, unknown>).limits as BodyLimitsConfig['limits'],
416
- sensitiveFields: formsConfig?.stripSensitiveFields,
417
- matchRoute,
418
- coerceSegmentParams,
419
- renderDenyFallback: pipelineConfig.renderDenyFallback,
420
- runMiddleware: actionsConfig?.runMiddleware,
421
- // Thread the pipeline's `stripTrailingSlash` setting into the wrapper
422
- // so its canonicalization step (applied before `matchRoute` for
423
- // action POSTs) matches the page pipeline exactly. Defaults to `true`
424
- // both here and inside the wrapper.
425
- stripTrailingSlash: pipelineConfig.stripTrailingSlash,
426
- // Revalidation renderer — runs the target route's `middleware.ts`
427
- // chain BEFORE building the element tree so a server action that
428
- // revalidates a protected path honors that path's auth, tenant
429
- // isolation, and request-header injection. Closes the silent-bypass
430
- // bug where `revalidatePath('/admin')` from a public action would
431
- // serialize the admin RSC tree without ever consulting admin's
432
- // middleware. Also validates the user-supplied `path` as a local
433
- // root-relative URL to reject `revalidatePath('//evil.com/admin')`
434
- // and similar scheme/authority injections. See TIM-996 and
435
- // design/08-forms-and-actions.md §"revalidatePath()".
436
- buildRevalidateRenderer: createRevalidateRendererFactory({
437
- matchRoute,
438
- coerceSegmentParams,
439
- buildManifest: buildManifest as BuildManifest,
440
- }),
441
431
  });
442
432
 
443
433
  return { handler, pipelineConfig };