@rangojs/router 0.0.0-experimental.19 → 0.0.0-experimental.1fa245e2
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/{CLAUDE.md → AGENTS.md} +4 -0
- package/README.md +122 -30
- package/dist/bin/rango.js +245 -63
- package/dist/vite/index.js +859 -418
- package/package.json +3 -3
- package/skills/breadcrumbs/SKILL.md +250 -0
- package/skills/cache-guide/SKILL.md +32 -0
- package/skills/caching/SKILL.md +49 -8
- package/skills/document-cache/SKILL.md +2 -2
- package/skills/hooks/SKILL.md +33 -31
- package/skills/host-router/SKILL.md +218 -0
- package/skills/links/SKILL.md +3 -1
- package/skills/loader/SKILL.md +72 -22
- package/skills/middleware/SKILL.md +2 -0
- package/skills/parallel/SKILL.md +126 -0
- package/skills/prerender/SKILL.md +112 -70
- package/skills/rango/SKILL.md +0 -1
- package/skills/route/SKILL.md +34 -4
- package/skills/router-setup/SKILL.md +95 -5
- package/skills/typesafety/SKILL.md +35 -23
- package/src/__internal.ts +92 -0
- package/src/bin/rango.ts +18 -0
- package/src/browser/app-version.ts +14 -0
- package/src/browser/event-controller.ts +5 -0
- package/src/browser/link-interceptor.ts +4 -0
- package/src/browser/navigation-bridge.ts +114 -18
- package/src/browser/navigation-client.ts +126 -44
- package/src/browser/navigation-store.ts +43 -8
- package/src/browser/navigation-transaction.ts +11 -9
- package/src/browser/partial-update.ts +80 -15
- package/src/browser/prefetch/cache.ts +166 -27
- package/src/browser/prefetch/fetch.ts +52 -39
- package/src/browser/prefetch/policy.ts +6 -0
- package/src/browser/prefetch/queue.ts +92 -20
- package/src/browser/prefetch/resource-ready.ts +77 -0
- package/src/browser/react/Link.tsx +70 -14
- package/src/browser/react/NavigationProvider.tsx +40 -4
- package/src/browser/react/context.ts +7 -2
- package/src/browser/react/use-handle.ts +9 -58
- package/src/browser/react/use-router.ts +21 -8
- package/src/browser/rsc-router.tsx +143 -59
- package/src/browser/scroll-restoration.ts +41 -42
- package/src/browser/segment-reconciler.ts +6 -1
- package/src/browser/server-action-bridge.ts +454 -436
- package/src/browser/types.ts +60 -5
- package/src/build/generate-manifest.ts +6 -6
- package/src/build/generate-route-types.ts +5 -0
- package/src/build/route-trie.ts +19 -3
- package/src/build/route-types/include-resolution.ts +8 -1
- package/src/build/route-types/router-processing.ts +346 -87
- package/src/build/route-types/scan-filter.ts +8 -1
- package/src/cache/cache-runtime.ts +15 -11
- package/src/cache/cache-scope.ts +48 -7
- package/src/cache/cf/cf-cache-store.ts +453 -11
- package/src/cache/cf/index.ts +5 -1
- package/src/cache/document-cache.ts +17 -7
- package/src/cache/index.ts +1 -0
- package/src/cache/taint.ts +55 -0
- package/src/client.rsc.tsx +2 -1
- package/src/client.tsx +3 -102
- package/src/context-var.ts +72 -2
- package/src/debug.ts +2 -2
- package/src/handle.ts +40 -0
- package/src/handles/breadcrumbs.ts +66 -0
- package/src/handles/index.ts +1 -0
- package/src/host/index.ts +0 -3
- package/src/index.rsc.ts +8 -37
- package/src/index.ts +40 -66
- package/src/prerender/store.ts +57 -15
- package/src/prerender.ts +138 -77
- package/src/reverse.ts +22 -1
- package/src/route-definition/dsl-helpers.ts +73 -25
- package/src/route-definition/helpers-types.ts +10 -6
- package/src/route-definition/index.ts +3 -3
- package/src/route-definition/redirect.ts +11 -3
- package/src/route-definition/resolve-handler-use.ts +149 -0
- package/src/route-map-builder.ts +7 -1
- package/src/route-types.ts +11 -0
- package/src/router/content-negotiation.ts +100 -1
- package/src/router/find-match.ts +4 -2
- package/src/router/handler-context.ts +108 -25
- package/src/router/intercept-resolution.ts +11 -4
- package/src/router/lazy-includes.ts +4 -1
- package/src/router/loader-resolution.ts +123 -11
- package/src/router/logging.ts +5 -2
- package/src/router/manifest.ts +9 -3
- package/src/router/match-api.ts +125 -190
- package/src/router/match-middleware/background-revalidation.ts +30 -2
- package/src/router/match-middleware/cache-lookup.ts +88 -16
- package/src/router/match-middleware/cache-store.ts +53 -10
- package/src/router/match-middleware/intercept-resolution.ts +9 -7
- package/src/router/match-middleware/segment-resolution.ts +61 -5
- package/src/router/match-result.ts +22 -15
- package/src/router/metrics.ts +238 -13
- package/src/router/middleware-types.ts +53 -12
- package/src/router/middleware.ts +172 -85
- package/src/router/navigation-snapshot.ts +182 -0
- package/src/router/pattern-matching.ts +20 -5
- package/src/router/prerender-match.ts +114 -10
- package/src/router/preview-match.ts +30 -102
- package/src/router/request-classification.ts +310 -0
- package/src/router/revalidation.ts +27 -7
- package/src/router/route-snapshot.ts +245 -0
- package/src/router/router-context.ts +6 -1
- package/src/router/router-interfaces.ts +50 -5
- package/src/router/router-options.ts +50 -19
- package/src/router/segment-resolution/fresh.ts +200 -19
- package/src/router/segment-resolution/helpers.ts +30 -25
- package/src/router/segment-resolution/loader-cache.ts +1 -0
- package/src/router/segment-resolution/revalidation.ts +429 -301
- package/src/router/segment-wrappers.ts +2 -0
- package/src/router/trie-matching.ts +20 -2
- package/src/router/types.ts +1 -0
- package/src/router.ts +88 -15
- package/src/rsc/handler.ts +546 -359
- package/src/rsc/index.ts +0 -20
- package/src/rsc/manifest-init.ts +5 -1
- package/src/rsc/progressive-enhancement.ts +25 -8
- package/src/rsc/rsc-rendering.ts +35 -43
- package/src/rsc/server-action.ts +16 -10
- package/src/rsc/ssr-setup.ts +128 -0
- package/src/rsc/types.ts +10 -1
- package/src/search-params.ts +16 -13
- package/src/segment-system.tsx +140 -4
- package/src/server/context.ts +148 -16
- package/src/server/loader-registry.ts +9 -8
- package/src/server/request-context.ts +182 -34
- package/src/server.ts +6 -0
- package/src/ssr/index.tsx +4 -0
- package/src/static-handler.ts +18 -6
- package/src/theme/index.ts +4 -13
- package/src/types/cache-types.ts +4 -4
- package/src/types/handler-context.ts +149 -49
- package/src/types/loader-types.ts +36 -9
- package/src/types/route-config.ts +17 -8
- package/src/types/route-entry.ts +8 -1
- package/src/types/segments.ts +2 -5
- package/src/urls/path-helper-types.ts +9 -2
- package/src/urls/path-helper.ts +48 -13
- package/src/urls/pattern-types.ts +12 -0
- package/src/urls/response-types.ts +16 -6
- package/src/use-loader.tsx +73 -4
- package/src/vite/discovery/bundle-postprocess.ts +61 -89
- package/src/vite/discovery/discover-routers.ts +23 -5
- package/src/vite/discovery/prerender-collection.ts +48 -15
- package/src/vite/discovery/state.ts +17 -13
- package/src/vite/index.ts +8 -3
- package/src/vite/plugin-types.ts +51 -79
- package/src/vite/plugins/client-ref-dedup.ts +115 -0
- package/src/vite/plugins/expose-action-id.ts +1 -3
- package/src/vite/plugins/performance-tracks.ts +88 -0
- package/src/vite/plugins/refresh-cmd.ts +127 -0
- package/src/vite/plugins/version-plugin.ts +13 -1
- package/src/vite/rango.ts +174 -211
- package/src/vite/router-discovery.ts +169 -42
- package/src/vite/utils/banner.ts +3 -3
- package/src/vite/utils/prerender-utils.ts +78 -0
- package/src/vite/utils/shared-utils.ts +3 -2
- package/skills/testing/SKILL.md +0 -226
- package/src/route-definition/route-function.ts +0 -119
package/src/router/middleware.ts
CHANGED
|
@@ -20,6 +20,8 @@ import type {
|
|
|
20
20
|
} from "./middleware-types.js";
|
|
21
21
|
import { _getRequestContext } from "../server/request-context.js";
|
|
22
22
|
import { isAutoGeneratedRouteName } from "../route-name.js";
|
|
23
|
+
import { appendMetric, createMetricsStore } from "./metrics.js";
|
|
24
|
+
import { stripInternalParams } from "./handler-context.js";
|
|
23
25
|
|
|
24
26
|
// Re-export types and cookie utilities for backward compatibility
|
|
25
27
|
export type {
|
|
@@ -33,25 +35,29 @@ export type {
|
|
|
33
35
|
} from "./middleware-types.js";
|
|
34
36
|
export { parseCookies, serializeCookie } from "./middleware-cookies.js";
|
|
35
37
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
function
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
38
|
+
const MIDDLEWARE_METRIC_DEPTH = 1;
|
|
39
|
+
/** Ignore post-next() durations below this threshold (measurement noise). */
|
|
40
|
+
const POST_METRIC_MIN_DURATION_MS = 0.01;
|
|
41
|
+
|
|
42
|
+
function getMiddlewareMetricBase<TEnv>(
|
|
43
|
+
entry: MiddlewareEntry<TEnv>,
|
|
44
|
+
ordinal: number,
|
|
45
|
+
): string {
|
|
46
|
+
const handlerName = entry.handler.name?.trim();
|
|
47
|
+
const scope = entry.pattern ?? "*";
|
|
48
|
+
|
|
49
|
+
if (handlerName) {
|
|
50
|
+
return `${handlerName}@${scope}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return `${scope}#${ordinal + 1}`;
|
|
50
54
|
}
|
|
51
55
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
56
|
+
function getMiddlewareMetricLabel<TEnv>(
|
|
57
|
+
entry: MiddlewareEntry<TEnv>,
|
|
58
|
+
ordinal: number,
|
|
59
|
+
): string {
|
|
60
|
+
return `middleware:${getMiddlewareMetricBase(entry, ordinal)}`;
|
|
55
61
|
}
|
|
56
62
|
|
|
57
63
|
/**
|
|
@@ -142,7 +148,7 @@ export function createMiddlewareContext<TEnv>(
|
|
|
142
148
|
search?: Record<string, unknown>,
|
|
143
149
|
) => string,
|
|
144
150
|
): MiddlewareContext<TEnv> {
|
|
145
|
-
const url = new URL(request.url);
|
|
151
|
+
const url = stripInternalParams(new URL(request.url));
|
|
146
152
|
|
|
147
153
|
// Track the initial response to detect pre/post-next() phase.
|
|
148
154
|
// Before next(): responseHolder.response === initialResponse (the stub).
|
|
@@ -158,9 +164,25 @@ export function createMiddlewareContext<TEnv>(
|
|
|
158
164
|
// Cookie operations are handled by the standalone cookies() function which
|
|
159
165
|
// delegates to the shared RequestContext internally.
|
|
160
166
|
// The runtime implementation - types are enforced at call sites via MiddlewareContext<TEnv>
|
|
167
|
+
// Internal helper: resolve the current response (stub before next(), real after).
|
|
168
|
+
// Not exposed on the public MiddlewareContext type — use ctx.headers instead.
|
|
169
|
+
const getResponse = (): Response => {
|
|
170
|
+
if (isPreNext()) {
|
|
171
|
+
const reqCtx = _getRequestContext();
|
|
172
|
+
if (reqCtx) return reqCtx.res;
|
|
173
|
+
}
|
|
174
|
+
if (!responseHolder.response) {
|
|
175
|
+
throw new Error(
|
|
176
|
+
"Response is not available - responseHolder was not initialized",
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
return responseHolder.response;
|
|
180
|
+
};
|
|
181
|
+
|
|
161
182
|
return {
|
|
162
183
|
request,
|
|
163
184
|
url,
|
|
185
|
+
originalUrl: new URL(request.url),
|
|
164
186
|
pathname: url.pathname,
|
|
165
187
|
searchParams: url.searchParams,
|
|
166
188
|
env: env as MiddlewareContext<TEnv>["env"],
|
|
@@ -175,33 +197,16 @@ export function createMiddlewareContext<TEnv>(
|
|
|
175
197
|
) as MiddlewareContext<TEnv>["routeName"];
|
|
176
198
|
},
|
|
177
199
|
|
|
178
|
-
get
|
|
179
|
-
|
|
180
|
-
// set via ctx.header() are visible on ctx.res.
|
|
181
|
-
if (isPreNext()) {
|
|
182
|
-
const reqCtx = _getRequestContext();
|
|
183
|
-
if (reqCtx) return reqCtx.res;
|
|
184
|
-
}
|
|
185
|
-
if (!responseHolder.response) {
|
|
186
|
-
throw new Error(
|
|
187
|
-
"ctx.res is not available - responseHolder was not initialized",
|
|
188
|
-
);
|
|
189
|
-
}
|
|
190
|
-
return responseHolder.response;
|
|
191
|
-
},
|
|
192
|
-
set res(_: Response) {
|
|
193
|
-
throw new Error(
|
|
194
|
-
"ctx.res is read-only. Use ctx.header() to set response headers, or cookies() for cookie mutations.",
|
|
195
|
-
);
|
|
200
|
+
get headers(): Headers {
|
|
201
|
+
return getResponse().headers;
|
|
196
202
|
},
|
|
197
203
|
|
|
198
204
|
get: ((keyOrVar: any) =>
|
|
199
205
|
contextGet(variables, keyOrVar)) as MiddlewareContext<TEnv>["get"],
|
|
200
206
|
|
|
201
|
-
set: ((keyOrVar: any, value: unknown) => {
|
|
202
|
-
contextSet(variables, keyOrVar, value);
|
|
207
|
+
set: ((keyOrVar: any, value: unknown, options?: any) => {
|
|
208
|
+
contextSet(variables, keyOrVar, value, options);
|
|
203
209
|
}) as MiddlewareContext<TEnv>["set"],
|
|
204
|
-
|
|
205
210
|
header(name: string, value: string): void {
|
|
206
211
|
// Before next(): delegate to shared RequestContext stub
|
|
207
212
|
if (isPreNext()) {
|
|
@@ -220,6 +225,24 @@ export function createMiddlewareContext<TEnv>(
|
|
|
220
225
|
responseHolder.response.headers.set(name, value);
|
|
221
226
|
},
|
|
222
227
|
|
|
228
|
+
get theme(): MiddlewareContext<TEnv>["theme"] {
|
|
229
|
+
return _getRequestContext()?.theme;
|
|
230
|
+
},
|
|
231
|
+
|
|
232
|
+
get setTheme(): MiddlewareContext<TEnv>["setTheme"] {
|
|
233
|
+
return _getRequestContext()?.setTheme;
|
|
234
|
+
},
|
|
235
|
+
|
|
236
|
+
setLocationState(entries) {
|
|
237
|
+
const reqCtx = _getRequestContext();
|
|
238
|
+
if (!reqCtx) {
|
|
239
|
+
throw new Error(
|
|
240
|
+
"setLocationState() is not available outside a request context",
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
reqCtx.setLocationState(entries);
|
|
244
|
+
},
|
|
245
|
+
|
|
223
246
|
reverse:
|
|
224
247
|
reverse ??
|
|
225
248
|
((name: string) => {
|
|
@@ -227,6 +250,14 @@ export function createMiddlewareContext<TEnv>(
|
|
|
227
250
|
`ctx.reverse() is not available - route map was not provided to middleware context`,
|
|
228
251
|
);
|
|
229
252
|
}),
|
|
253
|
+
|
|
254
|
+
debugPerformance(): void {
|
|
255
|
+
const reqCtx = _getRequestContext();
|
|
256
|
+
if (reqCtx) {
|
|
257
|
+
reqCtx._debugPerformance = true;
|
|
258
|
+
reqCtx._metricsStore ??= createMetricsStore(true);
|
|
259
|
+
}
|
|
260
|
+
},
|
|
230
261
|
};
|
|
231
262
|
}
|
|
232
263
|
|
|
@@ -265,9 +296,9 @@ export function matchMiddleware<TEnv>(
|
|
|
265
296
|
*
|
|
266
297
|
* Features:
|
|
267
298
|
* - `await next()` returns actual Response
|
|
268
|
-
* - `ctx.
|
|
269
|
-
* - `ctx.header()` shorthand for setting
|
|
270
|
-
* - Forgiving: if middleware doesn't return, uses
|
|
299
|
+
* - `ctx.headers` available before and after `await next()`
|
|
300
|
+
* - `ctx.header()` shorthand for setting a single header
|
|
301
|
+
* - Forgiving: if middleware doesn't return, uses the downstream response
|
|
271
302
|
* - Short-circuit: return Response to stop chain
|
|
272
303
|
* - Error catching: try/catch around `next()` works
|
|
273
304
|
*/
|
|
@@ -309,14 +340,21 @@ export async function executeMiddleware<TEnv>(
|
|
|
309
340
|
}
|
|
310
341
|
});
|
|
311
342
|
// Also merge shared RequestContext stub (cookies written via cookies().set()).
|
|
312
|
-
// Set-Cookie
|
|
313
|
-
//
|
|
343
|
+
// Dedup Set-Cookie: an inner executeMiddleware (route-level middleware)
|
|
344
|
+
// may have already merged the same reqCtx cookies into the response.
|
|
314
345
|
const reqCtx = _getRequestContext();
|
|
315
346
|
if (reqCtx) {
|
|
347
|
+
const stubCookies = reqCtx.res.headers.getSetCookie();
|
|
348
|
+
if (stubCookies.length > 0) {
|
|
349
|
+
const existing = new Set(mergedHeaders.getSetCookie());
|
|
350
|
+
for (const cookie of stubCookies) {
|
|
351
|
+
if (!existing.has(cookie)) {
|
|
352
|
+
mergedHeaders.append("set-cookie", cookie);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
316
356
|
reqCtx.res.headers.forEach((value, name) => {
|
|
317
|
-
if (name
|
|
318
|
-
mergedHeaders.append(name, value);
|
|
319
|
-
} else if (!mergedHeaders.has(name)) {
|
|
357
|
+
if (name !== "set-cookie" && !mergedHeaders.has(name)) {
|
|
320
358
|
mergedHeaders.set(name, value);
|
|
321
359
|
}
|
|
322
360
|
});
|
|
@@ -332,6 +370,7 @@ export async function executeMiddleware<TEnv>(
|
|
|
332
370
|
return responseHolder.response;
|
|
333
371
|
}
|
|
334
372
|
|
|
373
|
+
const middlewareOrdinal = index;
|
|
335
374
|
const { entry, params } = middlewares[index++];
|
|
336
375
|
const ctx = createMiddlewareContext(
|
|
337
376
|
request,
|
|
@@ -341,48 +380,77 @@ export async function executeMiddleware<TEnv>(
|
|
|
341
380
|
responseHolder,
|
|
342
381
|
reverse,
|
|
343
382
|
);
|
|
383
|
+
const metricStart = performance.now();
|
|
384
|
+
const metricLabel = getMiddlewareMetricLabel(entry, middlewareOrdinal);
|
|
385
|
+
let middlewareFinished = false;
|
|
386
|
+
const finishMiddleware = () => {
|
|
387
|
+
if (!middlewareFinished) {
|
|
388
|
+
middlewareFinished = true;
|
|
389
|
+
appendMetric(
|
|
390
|
+
_getRequestContext()?._metricsStore,
|
|
391
|
+
`${metricLabel}:pre`,
|
|
392
|
+
metricStart,
|
|
393
|
+
performance.now() - metricStart,
|
|
394
|
+
MIDDLEWARE_METRIC_DEPTH,
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
};
|
|
344
398
|
|
|
345
399
|
// Track if next() was called and capture its Promise.
|
|
346
400
|
// Guard against double-calling: a second call would re-enter the
|
|
347
401
|
// downstream chain and overwrite responseHolder.response.
|
|
348
402
|
let nextPromise: Promise<Response> | null = null;
|
|
403
|
+
let nextResolvedAt: number | undefined;
|
|
349
404
|
const wrappedNext = (): Promise<Response> => {
|
|
350
405
|
if (nextPromise) {
|
|
351
406
|
throw new Error(
|
|
352
407
|
`[@rangojs/router] Middleware called next() more than once.`,
|
|
353
408
|
);
|
|
354
409
|
}
|
|
355
|
-
|
|
410
|
+
finishMiddleware();
|
|
411
|
+
const downstream = next();
|
|
412
|
+
nextPromise = downstream.then(
|
|
413
|
+
(res) => {
|
|
414
|
+
nextResolvedAt = performance.now();
|
|
415
|
+
return res;
|
|
416
|
+
},
|
|
417
|
+
(err) => {
|
|
418
|
+
nextResolvedAt = performance.now();
|
|
419
|
+
throw err;
|
|
420
|
+
},
|
|
421
|
+
);
|
|
356
422
|
return nextPromise;
|
|
357
423
|
};
|
|
358
424
|
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
425
|
+
let result: Response | void;
|
|
426
|
+
try {
|
|
427
|
+
result = await entry.handler(ctx, wrappedNext);
|
|
428
|
+
} catch (error) {
|
|
429
|
+
finishMiddleware();
|
|
430
|
+
throw error;
|
|
431
|
+
}
|
|
432
|
+
finishMiddleware();
|
|
433
|
+
|
|
434
|
+
// Record post-next() processing time when middleware did work after
|
|
435
|
+
// the downstream chain resolved (e.g. adding headers, logging).
|
|
436
|
+
if (nextResolvedAt !== undefined) {
|
|
437
|
+
const postDur = performance.now() - nextResolvedAt;
|
|
438
|
+
if (postDur > POST_METRIC_MIN_DURATION_MS) {
|
|
439
|
+
appendMetric(
|
|
440
|
+
_getRequestContext()?._metricsStore,
|
|
441
|
+
`${metricLabel}:post`,
|
|
442
|
+
nextResolvedAt,
|
|
443
|
+
postDur,
|
|
444
|
+
MIDDLEWARE_METRIC_DEPTH,
|
|
445
|
+
);
|
|
446
|
+
}
|
|
367
447
|
}
|
|
368
|
-
|
|
369
|
-
const result = await entry.handler(ctx, wrappedNext);
|
|
370
448
|
|
|
371
449
|
// Explicit return takes precedence (middleware short-circuit).
|
|
372
450
|
// Merge stub headers (from ctx.header before this point) and
|
|
373
451
|
// RequestContext stub headers (from ctx.setCookie) into the
|
|
374
452
|
// returned Response so they are not lost.
|
|
375
453
|
if (result instanceof Response) {
|
|
376
|
-
// W5: warn if ctx.set() was called but middleware returned a redirect
|
|
377
|
-
if (
|
|
378
|
-
process.env.NODE_ENV !== "production" &&
|
|
379
|
-
ctxSetCalled &&
|
|
380
|
-
result.status >= 300 &&
|
|
381
|
-
result.status < 400
|
|
382
|
-
) {
|
|
383
|
-
warnCtxSetBeforeRedirect(entry.handler);
|
|
384
|
-
}
|
|
385
|
-
|
|
386
454
|
const mergedHeaders = new Headers(result.headers);
|
|
387
455
|
stubResponse.headers.forEach((value, name) => {
|
|
388
456
|
if (name.toLowerCase() === "set-cookie") {
|
|
@@ -391,13 +459,22 @@ export async function executeMiddleware<TEnv>(
|
|
|
391
459
|
mergedHeaders.set(name, value);
|
|
392
460
|
}
|
|
393
461
|
});
|
|
394
|
-
// Also merge shared RequestContext stub (cookies written via setCookie)
|
|
462
|
+
// Also merge shared RequestContext stub (cookies written via setCookie).
|
|
463
|
+
// Dedup Set-Cookie: an inner executeMiddleware (route-level middleware)
|
|
464
|
+
// may have already merged the same reqCtx cookies into the response.
|
|
395
465
|
const reqCtx = _getRequestContext();
|
|
396
466
|
if (reqCtx) {
|
|
467
|
+
const stubCookies = reqCtx.res.headers.getSetCookie();
|
|
468
|
+
if (stubCookies.length > 0) {
|
|
469
|
+
const existing = new Set(mergedHeaders.getSetCookie());
|
|
470
|
+
for (const cookie of stubCookies) {
|
|
471
|
+
if (!existing.has(cookie)) {
|
|
472
|
+
mergedHeaders.append("set-cookie", cookie);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
}
|
|
397
476
|
reqCtx.res.headers.forEach((value, name) => {
|
|
398
|
-
if (name
|
|
399
|
-
mergedHeaders.append(name, value);
|
|
400
|
-
} else if (!mergedHeaders.has(name)) {
|
|
477
|
+
if (name !== "set-cookie" && !mergedHeaders.has(name)) {
|
|
401
478
|
mergedHeaders.set(name, value);
|
|
402
479
|
}
|
|
403
480
|
});
|
|
@@ -424,19 +501,6 @@ export async function executeMiddleware<TEnv>(
|
|
|
424
501
|
// If middleware called next(), await it and return the response
|
|
425
502
|
if (nextPromise) {
|
|
426
503
|
await nextPromise;
|
|
427
|
-
|
|
428
|
-
// W5: warn if ctx.set() was called but the downstream response is a redirect.
|
|
429
|
-
// The ctx.set() values will be lost because the redirect navigates away.
|
|
430
|
-
if (
|
|
431
|
-
process.env.NODE_ENV !== "production" &&
|
|
432
|
-
ctxSetCalled &&
|
|
433
|
-
responseHolder.response &&
|
|
434
|
-
responseHolder.response.status >= 300 &&
|
|
435
|
-
responseHolder.response.status < 400
|
|
436
|
-
) {
|
|
437
|
-
warnCtxSetBeforeRedirect(entry.handler);
|
|
438
|
-
}
|
|
439
|
-
|
|
440
504
|
return responseHolder.response!;
|
|
441
505
|
}
|
|
442
506
|
|
|
@@ -459,6 +523,29 @@ export async function executeMiddleware<TEnv>(
|
|
|
459
523
|
throw new Error("No response generated by middleware chain");
|
|
460
524
|
}
|
|
461
525
|
|
|
526
|
+
// Final re-merge: capture any RequestContext stub headers added after the
|
|
527
|
+
// last merge point (e.g. cookies().set() called after await next()).
|
|
528
|
+
// The reqCtx stub may have already been partially merged during finalHandler
|
|
529
|
+
// or early-return paths; only append *new* Set-Cookie entries to avoid dupes.
|
|
530
|
+
const reqCtx = _getRequestContext();
|
|
531
|
+
if (reqCtx) {
|
|
532
|
+
const stubCookies = reqCtx.res.headers.getSetCookie();
|
|
533
|
+
if (stubCookies.length > 0) {
|
|
534
|
+
const existingCookies = new Set(finalResponse.headers.getSetCookie());
|
|
535
|
+
for (const cookie of stubCookies) {
|
|
536
|
+
if (!existingCookies.has(cookie)) {
|
|
537
|
+
finalResponse.headers.append("set-cookie", cookie);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
// Fill in non-cookie headers that aren't already on the response
|
|
542
|
+
reqCtx.res.headers.forEach((value, name) => {
|
|
543
|
+
if (name !== "set-cookie" && !finalResponse.headers.has(name)) {
|
|
544
|
+
finalResponse.headers.set(name, value);
|
|
545
|
+
}
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
|
|
462
549
|
return finalResponse;
|
|
463
550
|
}
|
|
464
551
|
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Navigation Snapshot
|
|
3
|
+
*
|
|
4
|
+
* Pure data type representing the navigation-specific state for partial requests.
|
|
5
|
+
* Consolidates the header parsing, previous-route matching, intercept-context
|
|
6
|
+
* detection, and segment ID filtering that previously lived inline in
|
|
7
|
+
* createMatchContextForPartial (match-api.ts).
|
|
8
|
+
*
|
|
9
|
+
* resolveNavigation() is the factory: given a request + URL + current route key,
|
|
10
|
+
* it returns a NavigationSnapshot (or null if no previous URL).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { RouteMatchResult } from "./pattern-matching.js";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Snapshot of navigation state for a partial (navigation/action) request.
|
|
17
|
+
*
|
|
18
|
+
* Contains the "where are we coming from?" data: previous route, intercept
|
|
19
|
+
* source, client segment state, and derived flags.
|
|
20
|
+
*/
|
|
21
|
+
export interface NavigationSnapshot {
|
|
22
|
+
/** Previous page URL (from X-RSC-Router-Client-Path or Referer) */
|
|
23
|
+
prevUrl: URL;
|
|
24
|
+
/** Params from the previous route match */
|
|
25
|
+
prevParams: Record<string, string>;
|
|
26
|
+
/** Previous route match result (null if prev URL doesn't match any route) */
|
|
27
|
+
prevMatch: RouteMatchResult | null;
|
|
28
|
+
|
|
29
|
+
/** URL used as intercept context source */
|
|
30
|
+
interceptContextUrl: URL;
|
|
31
|
+
/** Route match for the intercept context URL */
|
|
32
|
+
interceptContextMatch: RouteMatchResult | null;
|
|
33
|
+
|
|
34
|
+
/** Raw segment IDs the client currently has */
|
|
35
|
+
clientSegmentIds: string[];
|
|
36
|
+
/** Set version for O(1) lookup */
|
|
37
|
+
clientSegmentSet: Set<string>;
|
|
38
|
+
/** Segment IDs filtered to remove parallel (.@) and loader (D\d+.) entries */
|
|
39
|
+
filteredSegmentIds: string[];
|
|
40
|
+
|
|
41
|
+
/** Whether client considers its cache stale */
|
|
42
|
+
stale: boolean;
|
|
43
|
+
|
|
44
|
+
/** Whether the intercept context route is the same as the current route */
|
|
45
|
+
isSameRouteNavigation: boolean;
|
|
46
|
+
|
|
47
|
+
/** Effective "from" URL (intercept source URL when present, else prevUrl) */
|
|
48
|
+
effectiveFromUrl: URL;
|
|
49
|
+
/** Effective "from" match (intercept source match when present, else prevMatch) */
|
|
50
|
+
effectiveFromMatch: RouteMatchResult | null;
|
|
51
|
+
|
|
52
|
+
/** Whether an intercept source header was present */
|
|
53
|
+
hasInterceptSource: boolean;
|
|
54
|
+
|
|
55
|
+
/** Whether an HMR request header was present */
|
|
56
|
+
isHmr: boolean;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface ResolveNavigationDeps {
|
|
60
|
+
findMatch: (pathname: string) => RouteMatchResult | null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Resolve navigation state from a partial request.
|
|
65
|
+
*
|
|
66
|
+
* Returns null if no previous URL is available (required for partial navigation).
|
|
67
|
+
*
|
|
68
|
+
* @param request - The incoming HTTP request
|
|
69
|
+
* @param url - Parsed URL of the request
|
|
70
|
+
* @param currentRouteKey - Route key of the current (target) route match
|
|
71
|
+
* @param deps - Dependencies (findMatch)
|
|
72
|
+
*/
|
|
73
|
+
export function resolveNavigation(
|
|
74
|
+
request: Request,
|
|
75
|
+
url: URL,
|
|
76
|
+
currentRouteKey: string,
|
|
77
|
+
deps: ResolveNavigationDeps,
|
|
78
|
+
): NavigationSnapshot | null {
|
|
79
|
+
// Parse client state from RSC request params/headers
|
|
80
|
+
const clientSegmentIds =
|
|
81
|
+
url.searchParams.get("_rsc_segments")?.split(",").filter(Boolean) || [];
|
|
82
|
+
const stale = url.searchParams.get("_rsc_stale") === "true";
|
|
83
|
+
const previousUrl =
|
|
84
|
+
request.headers.get("X-RSC-Router-Client-Path") ||
|
|
85
|
+
request.headers.get("Referer");
|
|
86
|
+
const interceptSourceUrl = request.headers.get(
|
|
87
|
+
"X-RSC-Router-Intercept-Source",
|
|
88
|
+
);
|
|
89
|
+
const isHmr = !!request.headers.get("X-RSC-HMR");
|
|
90
|
+
|
|
91
|
+
if (!previousUrl) {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Parse previous URL
|
|
96
|
+
let prevUrl: URL;
|
|
97
|
+
try {
|
|
98
|
+
prevUrl = new URL(previousUrl, url.origin);
|
|
99
|
+
} catch {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Parse intercept context URL
|
|
104
|
+
let interceptContextUrl: URL;
|
|
105
|
+
try {
|
|
106
|
+
interceptContextUrl = interceptSourceUrl
|
|
107
|
+
? new URL(interceptSourceUrl, url.origin)
|
|
108
|
+
: prevUrl;
|
|
109
|
+
} catch {
|
|
110
|
+
interceptContextUrl = prevUrl;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Match previous and intercept context routes
|
|
114
|
+
const prevMatch = deps.findMatch(prevUrl.pathname);
|
|
115
|
+
const prevParams = prevMatch?.params || {};
|
|
116
|
+
const interceptContextMatch = interceptSourceUrl
|
|
117
|
+
? deps.findMatch(interceptContextUrl.pathname)
|
|
118
|
+
: prevMatch;
|
|
119
|
+
|
|
120
|
+
// Derived state
|
|
121
|
+
const isSameRouteNavigation = !!(
|
|
122
|
+
interceptContextMatch && interceptContextMatch.routeKey === currentRouteKey
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
const hasInterceptSource = !!interceptSourceUrl;
|
|
126
|
+
const effectiveFromUrl = hasInterceptSource ? interceptContextUrl : prevUrl;
|
|
127
|
+
const effectiveFromMatch = hasInterceptSource
|
|
128
|
+
? interceptContextMatch
|
|
129
|
+
: prevMatch;
|
|
130
|
+
|
|
131
|
+
// Filter segment IDs: remove parallel (.@) and loader (D\d+.) entries
|
|
132
|
+
const filteredSegmentIds = clientSegmentIds.filter((id) => {
|
|
133
|
+
if (id.includes(".@")) return false;
|
|
134
|
+
if (/D\d+\./.test(id)) return false;
|
|
135
|
+
return true;
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
const clientSegmentSet = new Set(clientSegmentIds);
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
prevUrl,
|
|
142
|
+
prevParams,
|
|
143
|
+
prevMatch,
|
|
144
|
+
interceptContextUrl,
|
|
145
|
+
interceptContextMatch,
|
|
146
|
+
clientSegmentIds,
|
|
147
|
+
clientSegmentSet,
|
|
148
|
+
filteredSegmentIds,
|
|
149
|
+
stale,
|
|
150
|
+
isSameRouteNavigation,
|
|
151
|
+
effectiveFromUrl,
|
|
152
|
+
effectiveFromMatch,
|
|
153
|
+
hasInterceptSource,
|
|
154
|
+
isHmr,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Test helper: create a NavigationSnapshot with sensible defaults and overrides.
|
|
160
|
+
*/
|
|
161
|
+
export function createNavigationSnapshot(
|
|
162
|
+
overrides?: Partial<NavigationSnapshot>,
|
|
163
|
+
): NavigationSnapshot {
|
|
164
|
+
const defaultUrl = new URL("http://localhost/");
|
|
165
|
+
return {
|
|
166
|
+
prevUrl: defaultUrl,
|
|
167
|
+
prevParams: {},
|
|
168
|
+
prevMatch: null,
|
|
169
|
+
interceptContextUrl: defaultUrl,
|
|
170
|
+
interceptContextMatch: null,
|
|
171
|
+
clientSegmentIds: [],
|
|
172
|
+
clientSegmentSet: new Set(),
|
|
173
|
+
filteredSegmentIds: [],
|
|
174
|
+
stale: false,
|
|
175
|
+
isSameRouteNavigation: false,
|
|
176
|
+
effectiveFromUrl: defaultUrl,
|
|
177
|
+
effectiveFromMatch: null,
|
|
178
|
+
hasInterceptSource: false,
|
|
179
|
+
isHmr: false,
|
|
180
|
+
...overrides,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
@@ -16,6 +16,7 @@ export interface ParsedSegment {
|
|
|
16
16
|
value: string; // static text, param name, or "*"
|
|
17
17
|
optional: boolean;
|
|
18
18
|
constraint?: string[]; // enum values like ["en", "gb"]
|
|
19
|
+
suffix?: string; // literal text after param in same segment (e.g., ".html")
|
|
19
20
|
}
|
|
20
21
|
|
|
21
22
|
/**
|
|
@@ -39,11 +40,21 @@ export function parsePattern(pattern: string): ParsedSegment[] {
|
|
|
39
40
|
// - :param(a|b)?
|
|
40
41
|
// - *
|
|
41
42
|
const segmentRegex =
|
|
42
|
-
/\/(:([a-zA-Z_][a-zA-Z0-9_]*)(\(([^)]+)\))?(\?)
|
|
43
|
+
/\/(:([a-zA-Z_][a-zA-Z0-9_]*)(\(([^)]+)\))?(\?)?([^/]*)|(\*)|([^/]+))/g;
|
|
43
44
|
|
|
44
45
|
let match;
|
|
45
46
|
while ((match = segmentRegex.exec(pattern)) !== null) {
|
|
46
|
-
const [
|
|
47
|
+
const [
|
|
48
|
+
,
|
|
49
|
+
,
|
|
50
|
+
paramName,
|
|
51
|
+
,
|
|
52
|
+
constraint,
|
|
53
|
+
optional,
|
|
54
|
+
suffix,
|
|
55
|
+
wildcard,
|
|
56
|
+
staticText,
|
|
57
|
+
] = match;
|
|
47
58
|
|
|
48
59
|
if (wildcard) {
|
|
49
60
|
segments.push({ type: "wildcard", value: "*", optional: false });
|
|
@@ -53,6 +64,7 @@ export function parsePattern(pattern: string): ParsedSegment[] {
|
|
|
53
64
|
value: paramName,
|
|
54
65
|
optional: optional === "?",
|
|
55
66
|
constraint: constraint ? constraint.split("|") : undefined,
|
|
67
|
+
suffix: suffix || undefined,
|
|
56
68
|
});
|
|
57
69
|
} else if (staticText) {
|
|
58
70
|
segments.push({ type: "static", value: staticText, optional: false });
|
|
@@ -139,16 +151,19 @@ export function compilePattern(pattern: string): CompiledPattern {
|
|
|
139
151
|
regexPattern += "/(.*)";
|
|
140
152
|
} else if (segment.type === "param") {
|
|
141
153
|
paramNames.push(segment.value);
|
|
154
|
+
const suffixPattern = segment.suffix ? escapeRegex(segment.suffix) : "";
|
|
142
155
|
const valuePattern = segment.constraint
|
|
143
156
|
? `(${segment.constraint.map(escapeRegex).join("|")})`
|
|
144
|
-
:
|
|
157
|
+
: segment.suffix
|
|
158
|
+
? "([^/]+?)"
|
|
159
|
+
: "([^/]+)";
|
|
145
160
|
|
|
146
161
|
if (segment.optional) {
|
|
147
162
|
optionalParams.add(segment.value);
|
|
148
163
|
// Optional: make the whole /segment optional
|
|
149
|
-
regexPattern += `(?:/${valuePattern})?`;
|
|
164
|
+
regexPattern += `(?:/${valuePattern}${suffixPattern})?`;
|
|
150
165
|
} else {
|
|
151
|
-
regexPattern += `/${valuePattern}`;
|
|
166
|
+
regexPattern += `/${valuePattern}${suffixPattern}`;
|
|
152
167
|
}
|
|
153
168
|
} else {
|
|
154
169
|
// Static segment
|