@timber-js/app 0.2.0-alpha.96 → 0.2.0-alpha.98

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 (104) hide show
  1. package/dist/_chunks/{metadata-routes-DS3eKNmf.js → metadata-routes-BU684ls2.js} +1 -1
  2. package/dist/_chunks/{metadata-routes-DS3eKNmf.js.map → metadata-routes-BU684ls2.js.map} +1 -1
  3. package/dist/_chunks/segment-classify-BjfuctV2.js +137 -0
  4. package/dist/_chunks/segment-classify-BjfuctV2.js.map +1 -0
  5. package/dist/_chunks/{interception-BsLCA9gk.js → walkers-VOXgavMF.js} +66 -92
  6. package/dist/_chunks/walkers-VOXgavMF.js.map +1 -0
  7. package/dist/adapters/nitro.d.ts.map +1 -1
  8. package/dist/adapters/nitro.js +55 -5
  9. package/dist/adapters/nitro.js.map +1 -1
  10. package/dist/client/index.js +1 -1
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +189 -62
  13. package/dist/index.js.map +1 -1
  14. package/dist/plugins/build-report.d.ts +6 -4
  15. package/dist/plugins/build-report.d.ts.map +1 -1
  16. package/dist/plugins/dev-404-page.d.ts +8 -18
  17. package/dist/plugins/dev-404-page.d.ts.map +1 -1
  18. package/dist/routing/index.d.ts +5 -3
  19. package/dist/routing/index.d.ts.map +1 -1
  20. package/dist/routing/index.js +3 -3
  21. package/dist/routing/link-codegen.d.ts.map +1 -1
  22. package/dist/routing/scanner.d.ts +1 -10
  23. package/dist/routing/scanner.d.ts.map +1 -1
  24. package/dist/routing/segment-classify.d.ts +37 -8
  25. package/dist/routing/segment-classify.d.ts.map +1 -1
  26. package/dist/routing/types.d.ts +63 -23
  27. package/dist/routing/types.d.ts.map +1 -1
  28. package/dist/routing/walkers.d.ts +51 -0
  29. package/dist/routing/walkers.d.ts.map +1 -0
  30. package/dist/server/action-handler.d.ts.map +1 -1
  31. package/dist/server/dev-holding-server.d.ts +4 -2
  32. package/dist/server/dev-holding-server.d.ts.map +1 -1
  33. package/dist/server/html-injector-core.d.ts +212 -0
  34. package/dist/server/html-injector-core.d.ts.map +1 -0
  35. package/dist/server/html-injectors.d.ts +59 -59
  36. package/dist/server/html-injectors.d.ts.map +1 -1
  37. package/dist/server/internal.js +710 -563
  38. package/dist/server/internal.js.map +1 -1
  39. package/dist/server/node-stream-transforms.d.ts +46 -49
  40. package/dist/server/node-stream-transforms.d.ts.map +1 -1
  41. package/dist/server/pipeline-helpers.d.ts +88 -0
  42. package/dist/server/pipeline-helpers.d.ts.map +1 -0
  43. package/dist/server/pipeline-phases.d.ts +97 -0
  44. package/dist/server/pipeline-phases.d.ts.map +1 -0
  45. package/dist/server/pipeline.d.ts +53 -32
  46. package/dist/server/pipeline.d.ts.map +1 -1
  47. package/dist/server/port-resolution.d.ts +117 -0
  48. package/dist/server/port-resolution.d.ts.map +1 -0
  49. package/dist/server/route-matcher.d.ts +20 -47
  50. package/dist/server/route-matcher.d.ts.map +1 -1
  51. package/dist/server/rsc-entry/index.d.ts.map +1 -1
  52. package/dist/server/rsc-entry/wrap-action-dispatch.d.ts +74 -0
  53. package/dist/server/rsc-entry/wrap-action-dispatch.d.ts.map +1 -0
  54. package/dist/server/status-code-resolver.d.ts +16 -11
  55. package/dist/server/status-code-resolver.d.ts.map +1 -1
  56. package/dist/server/tree-builder.d.ts.map +1 -1
  57. package/dist/utils/directive-parser.d.ts +0 -45
  58. package/dist/utils/directive-parser.d.ts.map +1 -1
  59. package/package.json +7 -6
  60. package/src/adapters/nitro.ts +55 -5
  61. package/src/cli.ts +0 -0
  62. package/src/index.ts +84 -31
  63. package/src/plugins/build-report.ts +13 -22
  64. package/src/plugins/dev-404-page.ts +15 -41
  65. package/src/plugins/routing.ts +14 -12
  66. package/src/routing/codegen.ts +1 -1
  67. package/src/routing/convention-lint.ts +4 -4
  68. package/src/routing/index.ts +5 -3
  69. package/src/routing/interception.ts +1 -1
  70. package/src/routing/link-codegen.ts +25 -13
  71. package/src/routing/scanner.ts +17 -93
  72. package/src/routing/segment-classify.ts +107 -8
  73. package/src/routing/status-file-lint.ts +3 -3
  74. package/src/routing/types.ts +63 -23
  75. package/src/routing/walkers.ts +90 -0
  76. package/src/server/action-handler.ts +6 -0
  77. package/src/server/deny-renderer.ts +5 -5
  78. package/src/server/dev-holding-server.ts +4 -2
  79. package/src/server/fallback-error.ts +1 -1
  80. package/src/server/html-injector-core.ts +403 -0
  81. package/src/server/html-injectors.ts +158 -297
  82. package/src/server/node-stream-transforms.ts +108 -248
  83. package/src/server/pipeline-helpers.ts +180 -0
  84. package/src/server/pipeline-phases.ts +591 -0
  85. package/src/server/pipeline.ts +76 -539
  86. package/src/server/port-resolution.ts +215 -0
  87. package/src/server/route-element-builder.ts +1 -1
  88. package/src/server/route-matcher.ts +28 -60
  89. package/src/server/rsc-entry/api-handler.ts +2 -2
  90. package/src/server/rsc-entry/error-renderer.ts +1 -1
  91. package/src/server/rsc-entry/index.ts +52 -98
  92. package/src/server/rsc-entry/wrap-action-dispatch.ts +156 -0
  93. package/src/server/sitemap-generator.ts +1 -1
  94. package/src/server/slot-resolver.ts +1 -1
  95. package/src/server/status-code-resolver.ts +112 -128
  96. package/src/server/tree-builder.ts +6 -4
  97. package/src/utils/directive-parser.ts +0 -392
  98. package/LICENSE +0 -8
  99. package/dist/_chunks/interception-BsLCA9gk.js.map +0 -1
  100. package/dist/_chunks/segment-classify-BDNn6EzD.js +0 -65
  101. package/dist/_chunks/segment-classify-BDNn6EzD.js.map +0 -1
  102. package/dist/server/manifest-status-resolver.d.ts +0 -58
  103. package/dist/server/manifest-status-resolver.d.ts.map +0 -1
  104. package/src/server/manifest-status-resolver.ts +0 -215
@@ -0,0 +1,215 @@
1
+ /**
2
+ * Port resolution for the dev server, Vite preview, and the Node
3
+ * production preview server.
4
+ *
5
+ * Behavior (see TIM-842):
6
+ *
7
+ * 1. Default port is **3000** for both dev and prod.
8
+ * 2. If the user did NOT set an explicit port, auto-bump from 3000
9
+ * until a free port is found (3000 → 3001 → 3002 → …).
10
+ * 3. If the user DID set an explicit port (via `--port`, `PORT` env
11
+ * var, or `vite.config.ts` `server.port`), use it as-is and let
12
+ * the bind fail loudly on conflict (`strictPort: true`).
13
+ *
14
+ * The port-bump probe is performed by binding the **actual** server
15
+ * (e.g. the dev holding server) — not a throwaway probe — so there is
16
+ * no time-of-check / time-of-use race between probing and listening.
17
+ *
18
+ * Design doc: 21-dev-server.md §"Default Port and Auto-Bump".
19
+ */
20
+
21
+ /** Default port used by `timber dev` and `timber preview`. */
22
+ export const DEFAULT_PORT = 3000;
23
+
24
+ /**
25
+ * A function that attempts to bind to a given port.
26
+ *
27
+ * Resolves with the bound port on success. Rejects with an error
28
+ * (typically with `code === 'EADDRINUSE'`) on failure.
29
+ */
30
+ export type ListenFn = (port: number) => Promise<number>;
31
+
32
+ // ── Pure resolution ────────────────────────────────────────────────────
33
+
34
+ export interface ResolvePortInput {
35
+ /**
36
+ * Port read from `vite.config.ts` `server.port` or the `--port` CLI
37
+ * flag (Vite merges `--port` into `userConfig.server.port` before
38
+ * plugins see it).
39
+ */
40
+ configPort?: number | undefined;
41
+ /** Raw value of `process.env.PORT`. */
42
+ envPort?: string | undefined;
43
+ /** Default port to use when neither `configPort` nor `envPort` is set. */
44
+ defaultPort?: number;
45
+ }
46
+
47
+ export interface ResolvedPortInput {
48
+ /** Port to start binding from. */
49
+ port: number;
50
+ /**
51
+ * `true` if the port came from an explicit user override (config or
52
+ * env). When `true`, callers must NOT auto-bump and must surface
53
+ * `EADDRINUSE` to the user.
54
+ */
55
+ explicit: boolean;
56
+ }
57
+
58
+ /**
59
+ * Pure: compute the starting port from config / env / defaults.
60
+ *
61
+ * Performs no I/O — pair with {@link bindWithBump} to actually listen.
62
+ */
63
+ export function resolveStartPort(input: ResolvePortInput): ResolvedPortInput {
64
+ const defaultPort = input.defaultPort ?? DEFAULT_PORT;
65
+
66
+ // `configPort` wins over `envPort`. This matches Vite's behavior:
67
+ // `vite --port 4000` overrides `PORT=5000` in the environment.
68
+ if (typeof input.configPort === 'number' && Number.isFinite(input.configPort)) {
69
+ return { port: input.configPort, explicit: true };
70
+ }
71
+
72
+ if (input.envPort != null && input.envPort !== '') {
73
+ const parsed = Number(input.envPort);
74
+ if (Number.isFinite(parsed) && parsed > 0) {
75
+ return { port: parsed, explicit: true };
76
+ }
77
+ }
78
+
79
+ return { port: defaultPort, explicit: false };
80
+ }
81
+
82
+ // ── Bind with auto-bump ────────────────────────────────────────────────
83
+
84
+ export interface BindWithBumpOptions {
85
+ /** First port to attempt. */
86
+ startPort: number;
87
+ /**
88
+ * If `true`, increment the port and retry on `EADDRINUSE`. If
89
+ * `false`, attempt once and let the error propagate.
90
+ */
91
+ autoBump: boolean;
92
+ /** Maximum number of port attempts when `autoBump` is `true`. */
93
+ maxAttempts?: number;
94
+ }
95
+
96
+ export interface BindWithBumpResult {
97
+ /** Port that was actually bound. */
98
+ port: number;
99
+ /** `true` if the bound port differs from `startPort`. */
100
+ bumped: boolean;
101
+ }
102
+
103
+ /**
104
+ * Bind a server starting at `startPort`, optionally bumping the port
105
+ * on `EADDRINUSE` until a free one is found.
106
+ *
107
+ * Use this with the actual server you intend to keep listening (e.g.
108
+ * the dev holding server). Pairing the probe with the real listen
109
+ * eliminates the TOCTOU race that a throwaway probe would introduce.
110
+ */
111
+ export async function bindWithBump(
112
+ listen: ListenFn,
113
+ options: BindWithBumpOptions
114
+ ): Promise<BindWithBumpResult> {
115
+ const maxAttempts = options.autoBump ? (options.maxAttempts ?? 100) : 1;
116
+ let lastErr: unknown = null;
117
+
118
+ for (let i = 0; i < maxAttempts; i++) {
119
+ const port = options.startPort + i;
120
+ try {
121
+ const bound = await listen(port);
122
+ return { port: bound, bumped: i > 0 };
123
+ } catch (err) {
124
+ lastErr = err;
125
+ // Only retry on EADDRINUSE — other errors (EACCES, etc.) are
126
+ // permanent and must surface immediately.
127
+ if (!isAddrInUse(err)) throw err;
128
+ }
129
+ }
130
+
131
+ throw lastErr ?? new Error(`Could not bind to a free port starting at ${options.startPort}`);
132
+ }
133
+
134
+ /** True if `err` is a Node `EADDRINUSE` error from `server.listen()`. */
135
+ export function isAddrInUse(err: unknown): boolean {
136
+ return (
137
+ typeof err === 'object' && err !== null && (err as { code?: string }).code === 'EADDRINUSE'
138
+ );
139
+ }
140
+
141
+ // ── High-level helper used by the rootSync plugin ────────────────────
142
+
143
+ export interface StartDevServerPortInput {
144
+ /** Resolved port from `userConfig.server?.port` (or `--port`), or undefined. */
145
+ configPort: number | undefined;
146
+ /** Raw `process.env.PORT` value. */
147
+ envPort: string | undefined;
148
+ /** ListenFn for the holding server (or any pre-bind probe target). */
149
+ listen: ListenFn;
150
+ /** Logger — defaults to `console`. Injected for tests. */
151
+ log?: (msg: string) => void;
152
+ warn?: (msg: string) => void;
153
+ }
154
+
155
+ export interface StartDevServerPortResult {
156
+ /** Port chosen for both the holding server and Vite's dev server. */
157
+ port: number;
158
+ /** True if the user explicitly set the port (=> Vite must `strictPort: true`). */
159
+ explicit: boolean;
160
+ /** True if the holding server actually bound the port. */
161
+ bound: boolean;
162
+ }
163
+
164
+ /**
165
+ * Run the full dev-server port resolution + holding-server bind sequence.
166
+ *
167
+ * Resolves the port from config / env / default, attempts to bind the
168
+ * holding server (auto-bumping when the port came from the default),
169
+ * and logs the chosen URL. On a clean failure for an explicit port, it
170
+ * warns and falls back to the requested port so Vite can surface the
171
+ * conflict via `strictPort: true`.
172
+ *
173
+ * Extracted from `index.ts` so the rootSync `config()` hook stays
174
+ * focused on plugin assembly.
175
+ */
176
+ export async function startDevServerPort(
177
+ input: StartDevServerPortInput
178
+ ): Promise<StartDevServerPortResult> {
179
+ const log = input.log ?? ((msg: string) => console.log(msg));
180
+ const warn = input.warn ?? ((msg: string) => console.warn(msg));
181
+
182
+ const start = resolveStartPort({
183
+ configPort: input.configPort,
184
+ envPort: input.envPort,
185
+ defaultPort: DEFAULT_PORT,
186
+ });
187
+
188
+ try {
189
+ const result = await bindWithBump(input.listen, {
190
+ startPort: start.port,
191
+ autoBump: !start.explicit,
192
+ });
193
+ if (result.bumped) {
194
+ log(`\n \x1b[33m[timber]\x1b[0m Port ${start.port} in use, using ${result.port}\n`);
195
+ }
196
+ log(
197
+ `\n \x1b[2m\u{1FAB5} timber.js dev server starting at\x1b[0m ` +
198
+ `\x1b[36mhttp://localhost:${result.port}\x1b[0m\n`
199
+ );
200
+ return { port: result.port, explicit: start.explicit, bound: true };
201
+ } catch (err) {
202
+ // Holding server failed to bind. For explicit ports we leave the
203
+ // bound state false and let Vite fail loudly via strictPort: true.
204
+ // For implicit ports this is essentially unreachable (auto-bump
205
+ // tries 100 ports), but if we hit it we still want Vite to surface
206
+ // the error.
207
+ if (start.explicit && isAddrInUse(err)) {
208
+ warn(
209
+ `\n \x1b[33m[timber]\x1b[0m Port ${start.port} is already in use. ` +
210
+ `Set PORT (or remove the override) to pick another port.\n`
211
+ );
212
+ }
213
+ return { port: start.port, explicit: start.explicit, bound: false };
214
+ }
215
+ }
@@ -213,7 +213,7 @@ export async function buildRouteElement(
213
213
  interception?: InterceptionContext,
214
214
  clientStateTree?: Set<string> | null
215
215
  ): Promise<RouteElementResult> {
216
- const segments = match.segments as unknown as ManifestSegmentNode[];
216
+ const segments = match.segments;
217
217
 
218
218
  // Load all modules along the segment chain
219
219
  const metadataEntries: Array<{ metadata: Metadata; isPage: boolean }> = [];
@@ -10,6 +10,7 @@
10
10
 
11
11
  import type { RouteMatch } from './pipeline.js';
12
12
  import type { MiddlewareFn } from './middleware-runner.js';
13
+ import type { SegmentNode, RouteTree } from '../routing/types.js';
13
14
  import {
14
15
  METADATA_ROUTE_CONVENTIONS,
15
16
  isStaticMetadataExtension,
@@ -18,71 +19,40 @@ import {
18
19
  } from './metadata-routes.js';
19
20
 
20
21
  // ─── Manifest Types ───────────────────────────────────────────────────────
21
- // The virtual module manifest has a slightly different shape than SegmentNode:
22
- // file references are { load, filePath } instead of RouteFile.
22
+ //
23
+ // TIM-848: the runtime route manifest re-uses the same `SegmentNode<TFile>`
24
+ // / `RouteTree<TFile>` interfaces from `routing/types.ts`. The only
25
+ // difference between build-time and runtime is the file reference payload
26
+ // — build-time has `{ filePath, extension }`, runtime has `{ filePath, load }`.
27
+ // Walkers parameterized over `TFile` work on either; there is no separate
28
+ // "manifest" tree shape.
23
29
 
24
30
  /** A file reference in the manifest (lazy import + path). */
25
- interface ManifestFile {
31
+ export interface ManifestFile {
26
32
  load: () => Promise<unknown>;
27
33
  filePath: string;
28
34
  }
29
35
 
30
- /** A segment node as it appears in the virtual:timber-route-manifest module. */
31
- export interface ManifestSegmentNode {
32
- segmentName: string;
33
- segmentType:
34
- | 'static'
35
- | 'dynamic'
36
- | 'catch-all'
37
- | 'optional-catch-all'
38
- | 'group'
39
- | 'slot'
40
- | 'intercepting';
41
- urlPath: string;
42
- paramName?: string;
43
- /** For intercepting segments: the marker used, e.g. "(.)". */
44
- interceptionMarker?: '(.)' | '(..)' | '(...)' | '(..)(..)';
45
- /** For intercepting segments: the segment name after stripping the marker. */
46
- interceptedSegmentName?: string;
47
-
48
- page?: ManifestFile;
49
- layout?: ManifestFile;
50
- middleware?: ManifestFile;
51
- access?: ManifestFile;
52
- route?: ManifestFile;
53
- /** params.ts — isomorphic convention file for segmentParams + searchParams definitions. */
54
- params?: ManifestFile;
55
- error?: ManifestFile;
56
- default?: ManifestFile;
57
- denied?: ManifestFile;
58
- statusFiles?: Record<string, ManifestFile>;
59
- jsonStatusFiles?: Record<string, ManifestFile>;
60
- legacyStatusFiles?: Record<string, ManifestFile>;
61
- /** Metadata route files (sitemap.ts, robots.ts, icon.tsx, etc.) keyed by base name */
62
- metadataRoutes?: Record<string, ManifestFile>;
63
-
64
- children: ManifestSegmentNode[];
65
- slots: Record<string, ManifestSegmentNode>;
66
- }
36
+ /**
37
+ * A segment node as it appears in the virtual:timber-route-manifest module.
38
+ *
39
+ * Type alias for `SegmentNode<ManifestFile>` — the same shape used by the
40
+ * scanner, only with lazy `load` functions instead of build-time extension
41
+ * metadata.
42
+ */
43
+ export type ManifestSegmentNode = SegmentNode<ManifestFile>;
67
44
 
68
- /** The manifest shape from virtual:timber-route-manifest. */
69
- export interface ManifestRoot {
70
- root: ManifestSegmentNode;
71
- /**
72
- * Absolute path to the Vite project root, captured at build/load time.
73
- * Used by dev-only features (e.g., dev error page frame classification)
74
- * that need a correct project root even when CWD differs (e.g., monorepo
75
- * custom root). See TIM-807 / TIM-808.
76
- */
45
+ /**
46
+ * The manifest shape from virtual:timber-route-manifest.
47
+ *
48
+ * Extends `RouteTree<ManifestFile>` with `viteRoot`, the absolute path
49
+ * to the Vite project root captured at build/load time. Used by dev-only
50
+ * features (e.g., dev error page frame classification) that need a
51
+ * correct project root even when CWD differs (e.g., monorepo custom root).
52
+ * See TIM-807 / TIM-808.
53
+ */
54
+ export interface ManifestRoot extends RouteTree<ManifestFile> {
77
55
  viteRoot: string;
78
- proxy?: ManifestFile;
79
- /**
80
- * Global error page: app/global-error.{tsx,ts,jsx,js}
81
- * Tier 2 — standalone full-page replacement (no layouts) when no
82
- * segment-level error file is found. SSR-only render.
83
- * See design/10-error-handling.md §"Tier 2"
84
- */
85
- globalError?: ManifestFile;
86
56
  }
87
57
 
88
58
  // ─── Matcher ──────────────────────────────────────────────────────────────
@@ -132,9 +102,7 @@ function matchPathname(root: ManifestSegmentNode, pathname: string): RouteMatch
132
102
  }
133
103
 
134
104
  return {
135
- // The pipeline uses segments as opaque objects passed to the renderer.
136
- // Cast is safe — the renderer receives what the manifest provides.
137
- segments: segments as unknown as RouteMatch['segments'],
105
+ segments,
138
106
  segmentParams: params,
139
107
  middlewareChain,
140
108
  };
@@ -89,9 +89,9 @@ async function renderApiDeny(
89
89
  segments: ManifestSegmentNode[],
90
90
  responseHeaders: Headers
91
91
  ): Promise<Response> {
92
- const { resolveManifestStatusFile } = await import('../manifest-status-resolver.js');
92
+ const { resolveStatusFile } = await import('../status-code-resolver.js');
93
93
 
94
- const resolution = resolveManifestStatusFile(deny.status, segments, 'json');
94
+ const resolution = resolveStatusFile(deny.status, segments, 'json');
95
95
  if (resolution) {
96
96
  const mod = await loadModule(resolution.file);
97
97
  const jsonContent = mod.default ?? mod;
@@ -377,7 +377,7 @@ export async function renderNoMatchPage(
377
377
  }
378
378
 
379
379
  const deny = new DenySignal(404);
380
- const match: RouteMatch = { segments: segments as never, segmentParams: {}, middlewareChain: [] };
380
+ const match: RouteMatch = { segments, segmentParams: {}, middlewareChain: [] };
381
381
 
382
382
  return renderDenyPage(
383
383
  deny,
@@ -31,8 +31,7 @@ import loadUserInstrumentation from 'virtual:timber-instrumentation';
31
31
  // @ts-expect-error — virtual module provided by timber-entries plugin
32
32
  import loadCacheHandler from 'virtual:timber-cache-handler';
33
33
 
34
- import type { FormRerender } from '../action-handler.js';
35
- import { handleActionRequest, isActionRequest } from '../action-handler.js';
34
+ import { wrapPipelineWithActionDispatch } from './wrap-action-dispatch.js';
36
35
  import type { BodyLimitsConfig } from '../body-limits.js';
37
36
  import type { BuildManifest } from '../build-manifest.js';
38
37
  import {
@@ -46,7 +45,6 @@ import { renderDenyPage, renderDenyPageAsRsc } from '../deny-renderer.js';
46
45
  import { resolveLogMode } from '../dev-logger.js';
47
46
  import { sendEarlyHints103 } from '../early-hints-sender.js';
48
47
  import { collectEarlyHintHeaders } from '../early-hints.js';
49
- import { runWithFormFlash } from '../form-flash.js';
50
48
  import type { ClientBootstrapConfig } from '../html-injectors.js';
51
49
  import { buildClientScripts } from '../html-injectors.js';
52
50
  import type { InterceptionContext, PipelineConfig, RouteMatch } from '../pipeline.js';
@@ -239,8 +237,15 @@ async function createRequestHandler(manifest: typeof routeManifest, runtimeConfi
239
237
 
240
238
  const typedBuildManifest = buildManifest as BuildManifest;
241
239
 
240
+ // The routing plugin emits `manifest.proxy = { load, filePath }` only when
241
+ // the app has an `app/proxy.ts`. Convert that to the pipeline's lazy variant
242
+ // so HMR re-imports per request; leave it undefined when there's no proxy.
243
+ const proxyConfig: PipelineConfig['proxy'] = manifest.proxy?.load
244
+ ? { kind: 'lazy', loader: manifest.proxy.load }
245
+ : undefined;
246
+
242
247
  const pipelineConfig: PipelineConfig = {
243
- proxyLoader: manifest.proxy?.load,
248
+ proxy: proxyConfig,
244
249
  matchRoute,
245
250
  matchMetadataRoute,
246
251
  // 103 Early Hints — fires after route match, before middleware.
@@ -248,11 +253,7 @@ async function createRequestHandler(manifest: typeof routeManifest, runtimeConfi
248
253
  // so the browser starts fetching critical resources while the server renders.
249
254
  // In dev mode the manifest is empty — no hints are sent.
250
255
  earlyHints: (match: RouteMatch, _req: Request, responseHeaders: Headers) => {
251
- const segments = match.segments as unknown as Array<{
252
- layout?: { filePath: string };
253
- page?: { filePath: string };
254
- }>;
255
- const headers = collectEarlyHintHeaders(segments, typedBuildManifest, {
256
+ const headers = collectEarlyHintHeaders(match.segments, typedBuildManifest, {
256
257
  skipJs: clientJsDisabled,
257
258
  });
258
259
  for (const h of headers) {
@@ -358,10 +359,7 @@ async function createRequestHandler(manifest: typeof routeManifest, runtimeConfi
358
359
  // route's `401.json` are picked up. Fall back to the root chain when no
359
360
  // match is available (e.g. proxy-stage deny before route matching).
360
361
  // See TIM-822, design/04-authorization.md, design/10-error-handling.md.
361
- type SegNode = import('../route-matcher.js').ManifestSegmentNode;
362
- const chain = matchedRoute
363
- ? (matchedRoute.segments as unknown as SegNode[])
364
- : ([manifest.root] as unknown as SegNode[]);
362
+ const chain: ManifestSegmentNode[] = matchedRoute ? matchedRoute.segments : [manifest.root];
365
363
  const layoutComponents: LayoutEntry[] = [];
366
364
  try {
367
365
  for (const segment of chain) {
@@ -382,8 +380,8 @@ async function createRequestHandler(manifest: typeof routeManifest, runtimeConfi
382
380
  // Reuse the matched route's params/middleware metadata when present so
383
381
  // downstream rendering sees the real route shape. Otherwise synthesise
384
382
  // a root-only stub (no params, no middleware).
385
- const match = matchedRoute ?? {
386
- segments: chain as never,
383
+ const match: RouteMatch = matchedRoute ?? {
384
+ segments: chain,
387
385
  segmentParams: {},
388
386
  middlewareChain: [],
389
387
  };
@@ -421,9 +419,13 @@ async function createRequestHandler(manifest: typeof routeManifest, runtimeConfi
421
419
 
422
420
  const pipeline = createPipeline(pipelineConfig);
423
421
 
424
- // Wrap the pipeline to intercept server action requests before rendering.
425
- // Actions bypass the normal pipeline (no route matching, no middleware)
426
- // per design/08-forms-and-actions.md §"Middleware for Server Actions".
422
+ // Wrap the pipeline to enforce CSRF at the request boundary and intercept
423
+ // server action requests before rendering. Actions bypass the normal
424
+ // pipeline (no route matching, no middleware) per
425
+ // design/08-forms-and-actions.md §"Middleware for Server Actions".
426
+ //
427
+ // CSRF validation lives in the wrapper (not inside the action handler) so
428
+ // it covers route.ts API handlers as well as server actions. See LOCAL-773.
427
429
  const csrfConfig = {
428
430
  csrf: runtimeConfig.csrf,
429
431
  allowedOrigins: (runtimeConfig as Record<string, unknown>).allowedOrigins as
@@ -431,87 +433,39 @@ async function createRequestHandler(manifest: typeof routeManifest, runtimeConfi
431
433
  | undefined,
432
434
  };
433
435
 
434
- return async (req: Request): Promise<Response> => {
435
- if (isActionRequest(req)) {
436
- const actionResponse = await handleActionRequest(req, {
437
- csrf: csrfConfig,
438
- bodyLimits: {
439
- limits: (runtimeConfig as Record<string, unknown>).limits as BodyLimitsConfig['limits'],
440
- },
441
- sensitiveFields: formsConfig?.stripSensitiveFields,
442
- revalidateRenderer: async (path: string) => {
443
- // Build the React element tree for the route at `path`.
444
- // Returns the element tree (not serialized) so the action handler can
445
- // combine it with the action result in a single renderToReadableStream call.
446
- // Forward original request headers (cookies, session IDs, etc.).
447
- const revalidateHeaders = new Headers(req.headers);
448
- revalidateHeaders.set('Accept', 'text/x-component');
449
- const revalidateReq = new Request(new URL(path, req.url), {
450
- headers: revalidateHeaders,
451
- });
452
- const revalidateMatch = matchRoute(new URL(revalidateReq.url).pathname);
453
- if (!revalidateMatch) {
454
- throw new Error(`revalidatePath('${path}') — no matching route`);
455
- }
456
- // Coerce segment params (params.ts) before building the element tree.
457
- // Without this, components receive raw strings instead of typed values.
458
- await coerceSegmentParams(revalidateMatch);
459
- // Set coerced params in ALS so getSegmentParams() works during
460
- // the revalidation render (AccessGate reads params via ALS).
461
- // Without this, AccessGate → getSegmentParams() throws because
462
- // segmentParamsPromise is never set. See TIM-667.
463
- setSegmentParams(revalidateMatch.segmentParams);
464
- const routeResult = await buildRouteElement(revalidateReq, revalidateMatch);
465
- return {
466
- element: routeResult.element,
467
- headElements: routeResult.headElements,
468
- };
469
- },
436
+ return wrapPipelineWithActionDispatch(pipeline, {
437
+ csrfConfig,
438
+ bodyLimits: (runtimeConfig as Record<string, unknown>).limits as BodyLimitsConfig['limits'],
439
+ sensitiveFields: formsConfig?.stripSensitiveFields,
440
+ buildRevalidateRenderer: (req) => async (path: string) => {
441
+ // Build the React element tree for the route at `path`.
442
+ // Returns the element tree (not serialized) so the action handler can
443
+ // combine it with the action result in a single renderToReadableStream call.
444
+ // Forward original request headers (cookies, session IDs, etc.).
445
+ const revalidateHeaders = new Headers(req.headers);
446
+ revalidateHeaders.set('Accept', 'text/x-component');
447
+ const revalidateReq = new Request(new URL(path, req.url), {
448
+ headers: revalidateHeaders,
470
449
  });
471
- if (actionResponse) {
472
- // Check if this is a re-render signal (no-JS validation failure)
473
- if ('rerender' in actionResponse) {
474
- const formRerender = actionResponse as FormRerender;
475
- // Re-render the page with the action result as flash data.
476
- // Server components read it via getFormFlash() and pass it to
477
- // client form components as the initial useActionState value.
478
- //
479
- // Build a synthetic GET request for the rerender pipeline:
480
- // - Same URL (so route matching lands on the same page)
481
- // - Cookie header replaced with the post-action RYW snapshot
482
- // so server components see the action's writes (TIM-837)
483
- // - Method GET because the rerender is conceptually a page
484
- // render, not a re-POST. The pipeline doesn't branch on
485
- // method for page rendering, and constructing a POST without
486
- // a body is awkward across Request implementations.
487
- const rerenderHeaders = new Headers(req.headers);
488
- if (formRerender.cookieHeader) {
489
- rerenderHeaders.set('cookie', formRerender.cookieHeader);
490
- } else {
491
- rerenderHeaders.delete('cookie');
492
- }
493
- const rerenderReq = new Request(req.url, {
494
- method: 'GET',
495
- headers: rerenderHeaders,
496
- });
497
- const response = await runWithFormFlash(formRerender.rerender, () =>
498
- pipeline(rerenderReq)
499
- );
500
- // Apply Set-Cookie headers snapshotted from the action's ALS scope.
501
- // The pipeline above runs in its own request context with a fresh
502
- // cookie jar, so cookies set inside the action would otherwise be
503
- // silently dropped on the no-JS rerender path. See TIM-836
504
- // (LOCAL-740).
505
- for (const value of formRerender.setCookieHeaders) {
506
- response.headers.append('Set-Cookie', value);
507
- }
508
- return response;
509
- }
510
- return actionResponse;
450
+ const revalidateMatch = matchRoute(new URL(revalidateReq.url).pathname);
451
+ if (!revalidateMatch) {
452
+ throw new Error(`revalidatePath('${path}') no matching route`);
511
453
  }
512
- }
513
- return pipeline(req);
514
- };
454
+ // Coerce segment params (params.ts) before building the element tree.
455
+ // Without this, components receive raw strings instead of typed values.
456
+ await coerceSegmentParams(revalidateMatch);
457
+ // Set coerced params in ALS so getSegmentParams() works during the
458
+ // revalidation render (AccessGate reads params via ALS). Without this,
459
+ // AccessGate → getSegmentParams() throws because segmentParamsPromise
460
+ // is never set. See TIM-667.
461
+ setSegmentParams(revalidateMatch.segmentParams);
462
+ const routeResult = await buildRouteElement(revalidateReq, revalidateMatch);
463
+ return {
464
+ element: routeResult.element,
465
+ headElements: routeResult.headElements,
466
+ };
467
+ },
468
+ });
515
469
  }
516
470
 
517
471
  /**
@@ -534,7 +488,7 @@ async function renderRoute(
534
488
  rootSegment?: ManifestSegmentNode,
535
489
  globalError?: { load: () => Promise<unknown>; filePath: string }
536
490
  ): Promise<Response> {
537
- const segments = match.segments as unknown as ManifestSegmentNode[];
491
+ const segments = match.segments;
538
492
  const leaf = segments[segments.length - 1];
539
493
 
540
494
  // API routes (route.ts) — run access.ts standalone then dispatch to handler.