@timber-js/app 0.2.0-alpha.97 → 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.
- package/dist/_chunks/{metadata-routes-DS3eKNmf.js → metadata-routes-BU684ls2.js} +1 -1
- package/dist/_chunks/{metadata-routes-DS3eKNmf.js.map → metadata-routes-BU684ls2.js.map} +1 -1
- package/dist/_chunks/segment-classify-BjfuctV2.js +137 -0
- package/dist/_chunks/segment-classify-BjfuctV2.js.map +1 -0
- package/dist/_chunks/{interception-BbqMCVXa.js → walkers-VOXgavMF.js} +61 -85
- package/dist/_chunks/walkers-VOXgavMF.js.map +1 -0
- package/dist/adapters/nitro.d.ts.map +1 -1
- package/dist/adapters/nitro.js +55 -5
- package/dist/adapters/nitro.js.map +1 -1
- package/dist/client/index.js +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +189 -62
- package/dist/index.js.map +1 -1
- package/dist/plugins/build-report.d.ts +6 -4
- package/dist/plugins/build-report.d.ts.map +1 -1
- package/dist/plugins/dev-404-page.d.ts +8 -18
- package/dist/plugins/dev-404-page.d.ts.map +1 -1
- package/dist/routing/index.d.ts +5 -3
- package/dist/routing/index.d.ts.map +1 -1
- package/dist/routing/index.js +3 -3
- package/dist/routing/scanner.d.ts +1 -10
- package/dist/routing/scanner.d.ts.map +1 -1
- package/dist/routing/segment-classify.d.ts +37 -8
- package/dist/routing/segment-classify.d.ts.map +1 -1
- package/dist/routing/types.d.ts +63 -23
- package/dist/routing/types.d.ts.map +1 -1
- package/dist/routing/walkers.d.ts +51 -0
- package/dist/routing/walkers.d.ts.map +1 -0
- package/dist/server/action-handler.d.ts.map +1 -1
- package/dist/server/dev-holding-server.d.ts +4 -2
- package/dist/server/dev-holding-server.d.ts.map +1 -1
- package/dist/server/html-injector-core.d.ts +212 -0
- package/dist/server/html-injector-core.d.ts.map +1 -0
- package/dist/server/html-injectors.d.ts +59 -59
- package/dist/server/html-injectors.d.ts.map +1 -1
- package/dist/server/internal.js +710 -563
- package/dist/server/internal.js.map +1 -1
- package/dist/server/node-stream-transforms.d.ts +46 -49
- package/dist/server/node-stream-transforms.d.ts.map +1 -1
- package/dist/server/pipeline-helpers.d.ts +88 -0
- package/dist/server/pipeline-helpers.d.ts.map +1 -0
- package/dist/server/pipeline-phases.d.ts +97 -0
- package/dist/server/pipeline-phases.d.ts.map +1 -0
- package/dist/server/pipeline.d.ts +53 -32
- package/dist/server/pipeline.d.ts.map +1 -1
- package/dist/server/port-resolution.d.ts +117 -0
- package/dist/server/port-resolution.d.ts.map +1 -0
- package/dist/server/route-matcher.d.ts +20 -47
- package/dist/server/route-matcher.d.ts.map +1 -1
- package/dist/server/rsc-entry/index.d.ts.map +1 -1
- package/dist/server/rsc-entry/wrap-action-dispatch.d.ts +74 -0
- package/dist/server/rsc-entry/wrap-action-dispatch.d.ts.map +1 -0
- package/dist/server/status-code-resolver.d.ts +16 -11
- package/dist/server/status-code-resolver.d.ts.map +1 -1
- package/dist/server/tree-builder.d.ts.map +1 -1
- package/dist/utils/directive-parser.d.ts +0 -45
- package/dist/utils/directive-parser.d.ts.map +1 -1
- package/package.json +7 -6
- package/src/adapters/nitro.ts +55 -5
- package/src/cli.ts +0 -0
- package/src/index.ts +84 -31
- package/src/plugins/build-report.ts +13 -22
- package/src/plugins/dev-404-page.ts +15 -41
- package/src/plugins/routing.ts +14 -12
- package/src/routing/codegen.ts +1 -1
- package/src/routing/convention-lint.ts +4 -4
- package/src/routing/index.ts +5 -3
- package/src/routing/interception.ts +1 -1
- package/src/routing/scanner.ts +17 -93
- package/src/routing/segment-classify.ts +107 -8
- package/src/routing/status-file-lint.ts +3 -3
- package/src/routing/types.ts +63 -23
- package/src/routing/walkers.ts +90 -0
- package/src/server/action-handler.ts +6 -0
- package/src/server/deny-renderer.ts +5 -5
- package/src/server/dev-holding-server.ts +4 -2
- package/src/server/fallback-error.ts +1 -1
- package/src/server/html-injector-core.ts +403 -0
- package/src/server/html-injectors.ts +158 -297
- package/src/server/node-stream-transforms.ts +108 -248
- package/src/server/pipeline-helpers.ts +180 -0
- package/src/server/pipeline-phases.ts +591 -0
- package/src/server/pipeline.ts +76 -539
- package/src/server/port-resolution.ts +215 -0
- package/src/server/route-element-builder.ts +1 -1
- package/src/server/route-matcher.ts +28 -60
- package/src/server/rsc-entry/api-handler.ts +2 -2
- package/src/server/rsc-entry/error-renderer.ts +1 -1
- package/src/server/rsc-entry/index.ts +52 -98
- package/src/server/rsc-entry/wrap-action-dispatch.ts +156 -0
- package/src/server/sitemap-generator.ts +1 -1
- package/src/server/slot-resolver.ts +1 -1
- package/src/server/status-code-resolver.ts +112 -128
- package/src/server/tree-builder.ts +6 -4
- package/src/utils/directive-parser.ts +0 -392
- package/LICENSE +0 -8
- package/dist/_chunks/interception-BbqMCVXa.js.map +0 -1
- package/dist/_chunks/segment-classify-BDNn6EzD.js +0 -65
- package/dist/_chunks/segment-classify-BDNn6EzD.js.map +0 -1
- package/dist/server/manifest-status-resolver.d.ts +0 -58
- package/dist/server/manifest-status-resolver.d.ts.map +0 -1
- 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
|
|
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
|
-
//
|
|
22
|
-
//
|
|
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
|
-
/**
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
/**
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
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 {
|
|
92
|
+
const { resolveStatusFile } = await import('../status-code-resolver.js');
|
|
93
93
|
|
|
94
|
-
const resolution =
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
425
|
-
//
|
|
426
|
-
//
|
|
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
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
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
|
-
|
|
472
|
-
|
|
473
|
-
|
|
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
|
-
|
|
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
|
|
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.
|