@rangojs/router 0.0.0-experimental.88a3b2f7 → 0.0.0-experimental.8bcfea43
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/README.md +50 -20
- package/dist/vite/index.js +647 -176
- package/dist/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
- package/package.json +7 -5
- package/skills/breadcrumbs/SKILL.md +3 -1
- package/skills/handler-use/SKILL.md +362 -0
- package/skills/hooks/SKILL.md +28 -20
- package/skills/intercept/SKILL.md +20 -0
- package/skills/layout/SKILL.md +22 -0
- package/skills/links/SKILL.md +88 -16
- package/skills/loader/SKILL.md +35 -2
- package/skills/middleware/SKILL.md +32 -3
- package/skills/migrate-nextjs/SKILL.md +560 -0
- package/skills/migrate-react-router/SKILL.md +765 -0
- package/skills/parallel/SKILL.md +59 -0
- package/skills/rango/SKILL.md +24 -22
- package/skills/response-routes/SKILL.md +8 -0
- package/skills/route/SKILL.md +24 -0
- package/skills/streams-and-websockets/SKILL.md +283 -0
- package/skills/typesafety/SKILL.md +3 -1
- package/src/browser/app-shell.ts +52 -0
- package/src/browser/navigation-bridge.ts +72 -4
- package/src/browser/navigation-client.ts +64 -13
- package/src/browser/navigation-store.ts +25 -1
- package/src/browser/partial-update.ts +34 -3
- package/src/browser/prefetch/cache.ts +129 -21
- package/src/browser/prefetch/fetch.ts +148 -16
- package/src/browser/prefetch/queue.ts +36 -5
- package/src/browser/rango-state.ts +53 -13
- package/src/browser/react/Link.tsx +30 -2
- package/src/browser/react/NavigationProvider.tsx +50 -11
- package/src/browser/react/use-navigation.ts +22 -2
- package/src/browser/react/use-params.ts +11 -1
- package/src/browser/react/use-router.ts +8 -1
- package/src/browser/rsc-router.tsx +34 -6
- package/src/browser/segment-reconciler.ts +36 -14
- package/src/browser/types.ts +13 -0
- package/src/build/route-trie.ts +50 -24
- package/src/cache/cf/cf-cache-store.ts +5 -7
- package/src/client.tsx +84 -230
- package/src/index.rsc.ts +3 -0
- package/src/index.ts +44 -9
- package/src/outlet-context.ts +1 -1
- package/src/response-utils.ts +28 -0
- package/src/reverse.ts +7 -3
- package/src/route-definition/dsl-helpers.ts +180 -24
- package/src/route-definition/helpers-types.ts +61 -14
- package/src/route-definition/resolve-handler-use.ts +6 -0
- package/src/route-types.ts +7 -0
- package/src/router/handler-context.ts +24 -4
- package/src/router/lazy-includes.ts +6 -6
- package/src/router/loader-resolution.ts +73 -46
- package/src/router/manifest.ts +22 -13
- package/src/router/match-api.ts +3 -3
- package/src/router/match-middleware/cache-lookup.ts +10 -5
- package/src/router/match-middleware/segment-resolution.ts +1 -1
- package/src/router/match-result.ts +82 -4
- package/src/router/middleware-types.ts +2 -22
- package/src/router/middleware.ts +32 -4
- package/src/router/pattern-matching.ts +60 -9
- package/src/router/segment-resolution/fresh.ts +52 -0
- package/src/router/segment-resolution/revalidation.ts +69 -1
- package/src/router/trie-matching.ts +10 -4
- package/src/router/url-params.ts +49 -0
- package/src/router.ts +1 -2
- package/src/rsc/handler.ts +21 -9
- package/src/rsc/helpers.ts +69 -41
- package/src/rsc/loader-fetch.ts +23 -3
- package/src/rsc/progressive-enhancement.ts +12 -2
- package/src/rsc/response-route-handler.ts +14 -1
- package/src/rsc/rsc-rendering.ts +12 -1
- package/src/rsc/server-action.ts +8 -0
- package/src/rsc/types.ts +1 -0
- package/src/segment-content-promise.ts +67 -0
- package/src/segment-loader-promise.ts +122 -0
- package/src/segment-system.tsx +11 -61
- package/src/server/context.ts +26 -3
- package/src/server/handle-store.ts +19 -0
- package/src/server/request-context.ts +64 -56
- package/src/types/handler-context.ts +2 -34
- package/src/types/loader-types.ts +5 -6
- package/src/types/request-scope.ts +126 -0
- package/src/types/route-entry.ts +11 -0
- package/src/types/segments.ts +1 -1
- package/src/urls/include-helper.ts +24 -14
- package/src/urls/path-helper-types.ts +34 -5
- package/src/urls/response-types.ts +2 -10
- package/src/use-loader.tsx +77 -5
- package/src/vite/debug.ts +55 -0
- package/src/vite/discovery/prerender-collection.ts +124 -83
- package/src/vite/plugins/cloudflare-protocol-loader-hook.d.mts +23 -0
- package/src/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
- package/src/vite/plugins/cloudflare-protocol-stub.ts +214 -0
- package/src/vite/plugins/expose-id-utils.ts +12 -0
- package/src/vite/plugins/expose-ids/handler-transform.ts +30 -0
- package/src/vite/plugins/expose-internal-ids.ts +257 -40
- package/src/vite/plugins/performance-tracks.ts +4 -6
- package/src/vite/rango.ts +49 -14
- package/src/vite/router-discovery.ts +186 -26
- package/src/vite/utils/banner.ts +1 -1
- package/src/vite/utils/package-resolution.ts +41 -1
- package/src/vite/utils/prerender-utils.ts +20 -6
|
@@ -21,7 +21,7 @@ import type {
|
|
|
21
21
|
} from "../types";
|
|
22
22
|
import type { LoaderRevalidationResult, ActionContext } from "./types";
|
|
23
23
|
import { isHandle, collectHandleData, type Handle } from "../handle.js";
|
|
24
|
-
import
|
|
24
|
+
import { buildHandleSnapshot } from "../server/handle-store.js";
|
|
25
25
|
import { getFetchableLoader } from "../server/fetchable-loader-store.js";
|
|
26
26
|
import { _getRequestContext } from "../server/request-context.js";
|
|
27
27
|
import { isInsideLoaderScope } from "../server/context.js";
|
|
@@ -266,7 +266,10 @@ function createLoaderExecutor<TEnv>(
|
|
|
266
266
|
search: (ctx as any).search,
|
|
267
267
|
pathname: ctx.pathname,
|
|
268
268
|
url: ctx.url,
|
|
269
|
+
originalUrl: ctx.originalUrl,
|
|
269
270
|
env: ctx.env,
|
|
271
|
+
waitUntil: ctx.waitUntil.bind(ctx),
|
|
272
|
+
executionContext: ctx.executionContext,
|
|
270
273
|
get: ((keyOrVar: any) =>
|
|
271
274
|
contextGet(variables, keyOrVar)) as typeof ctx.get,
|
|
272
275
|
use: ((item: LoaderDefinition<any, any> | Handle<any, any>) => {
|
|
@@ -284,10 +287,9 @@ function createLoaderExecutor<TEnv>(
|
|
|
284
287
|
);
|
|
285
288
|
}
|
|
286
289
|
const segmentOrder = reqCtx._renderBarrierSegmentOrder ?? [];
|
|
287
|
-
const snapshot =
|
|
288
|
-
reqCtx.
|
|
289
|
-
segmentOrder
|
|
290
|
-
);
|
|
290
|
+
const snapshot =
|
|
291
|
+
reqCtx._renderBarrierHandleSnapshot ??
|
|
292
|
+
buildHandleSnapshot(reqCtx._handleStore, segmentOrder);
|
|
291
293
|
return collectHandleData(item, snapshot, segmentOrder);
|
|
292
294
|
}
|
|
293
295
|
|
|
@@ -324,6 +326,17 @@ function createLoaderExecutor<TEnv>(
|
|
|
324
326
|
);
|
|
325
327
|
}
|
|
326
328
|
|
|
329
|
+
// Bidirectional deadlock check: if a handler already started
|
|
330
|
+
// awaiting this loader, calling rendered() would deadlock.
|
|
331
|
+
if (reqCtx._handlerLoaderDeps?.has(currentLoaderId)) {
|
|
332
|
+
throw new Error(
|
|
333
|
+
`Deadlock: loader "${currentLoaderId}" called ctx.rendered() but a handler ` +
|
|
334
|
+
`is already awaiting this loader via ctx.use(). The handler blocks ` +
|
|
335
|
+
`segment resolution, which blocks the barrier, which blocks this loader. ` +
|
|
336
|
+
`Move the data dependency to a loader-to-loader pattern instead.`,
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
|
|
327
340
|
// Register this loader as waiting for the barrier so that
|
|
328
341
|
// setupLoaderAccess can detect deadlocks when a handler
|
|
329
342
|
// tries to await the same loader via ctx.use().
|
|
@@ -354,25 +367,6 @@ function createLoaderExecutor<TEnv>(
|
|
|
354
367
|
return useLoader;
|
|
355
368
|
}
|
|
356
369
|
|
|
357
|
-
/**
|
|
358
|
-
* Build a HandleData snapshot from the HandleStore using segment ordering.
|
|
359
|
-
* Reads data directly from the store for each segment in order.
|
|
360
|
-
*/
|
|
361
|
-
function buildHandleSnapshot(
|
|
362
|
-
handleStore: HandleStore,
|
|
363
|
-
segmentOrder: string[],
|
|
364
|
-
): HandleData {
|
|
365
|
-
const data: HandleData = {};
|
|
366
|
-
for (const segmentId of segmentOrder) {
|
|
367
|
-
const segData = handleStore.getDataForSegment(segmentId);
|
|
368
|
-
for (const handleName in segData) {
|
|
369
|
-
if (!data[handleName]) data[handleName] = {};
|
|
370
|
-
data[handleName][segmentId] = segData[handleName];
|
|
371
|
-
}
|
|
372
|
-
}
|
|
373
|
-
return data;
|
|
374
|
-
}
|
|
375
|
-
|
|
376
370
|
/**
|
|
377
371
|
* Set up the use() method on handler context to access loaders and handles.
|
|
378
372
|
*
|
|
@@ -386,15 +380,22 @@ export function setupLoaderAccess<TEnv>(
|
|
|
386
380
|
ctx: HandlerContext<any, TEnv>,
|
|
387
381
|
loaderPromises: Map<string, Promise<any>>,
|
|
388
382
|
): void {
|
|
389
|
-
// Eagerly capture the HandleStore at setup time
|
|
390
|
-
// In workerd/Cloudflare, dynamic imports and
|
|
391
|
-
// can disrupt AsyncLocalStorage, causing
|
|
392
|
-
// undefined when handlers later call
|
|
393
|
-
//
|
|
394
|
-
const
|
|
383
|
+
// Eagerly capture the request context and HandleStore at setup time
|
|
384
|
+
// (before pipeline async ops). In workerd/Cloudflare, dynamic imports and
|
|
385
|
+
// fetch() in the match pipeline can disrupt AsyncLocalStorage, causing
|
|
386
|
+
// getRequestContext() to return undefined when handlers later call
|
|
387
|
+
// ctx.use(handle). Capturing early ensures references survive ALS disruption.
|
|
388
|
+
const reqCtxRef = _getRequestContext();
|
|
389
|
+
const handleStoreRef = reqCtxRef?._handleStore;
|
|
395
390
|
|
|
396
391
|
const useLoader = createLoaderExecutor(ctx, loaderPromises);
|
|
397
392
|
|
|
393
|
+
// Track whether we're inside a handle push callback. Loaders started
|
|
394
|
+
// from push callbacks (e.g. push(async () => ctx.use(Loader))) do NOT
|
|
395
|
+
// block segment resolution, so they must not be registered as handler
|
|
396
|
+
// dependencies for deadlock detection.
|
|
397
|
+
let insideHandlePush = false;
|
|
398
|
+
|
|
398
399
|
ctx.use = ((item: LoaderDefinition<any, any> | Handle<any, any>) => {
|
|
399
400
|
if (isHandle(item)) {
|
|
400
401
|
const handle = item;
|
|
@@ -414,27 +415,53 @@ export function setupLoaderAccess<TEnv>(
|
|
|
414
415
|
) => {
|
|
415
416
|
if (!store) return;
|
|
416
417
|
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
418
|
+
if (typeof dataOrFn === "function") {
|
|
419
|
+
// Mark scope so ctx.use(loader) calls inside the callback
|
|
420
|
+
// are not registered as handler-to-loader deps.
|
|
421
|
+
insideHandlePush = true;
|
|
422
|
+
try {
|
|
423
|
+
const result = (dataOrFn as () => Promise<unknown>)();
|
|
424
|
+
store.push(handle.$$id, segmentId, result);
|
|
425
|
+
} finally {
|
|
426
|
+
insideHandlePush = false;
|
|
427
|
+
}
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
421
430
|
|
|
422
|
-
store.push(handle.$$id, segmentId,
|
|
431
|
+
store.push(handle.$$id, segmentId, dataOrFn);
|
|
423
432
|
};
|
|
424
433
|
}
|
|
425
434
|
|
|
426
|
-
// Deadlock guard
|
|
427
|
-
//
|
|
428
|
-
//
|
|
435
|
+
// Deadlock guard and handler-to-loader dependency tracking.
|
|
436
|
+
// Skip when inside a DSL loader scope (resolveLoaderData also calls
|
|
437
|
+
// ctx.use() but that's DSL-to-DSL, not handler-to-loader) or when
|
|
438
|
+
// inside a handle push callback (push callbacks don't block segment
|
|
439
|
+
// resolution so they can't cause rendered() deadlocks).
|
|
429
440
|
const loader = item as LoaderDefinition<any, any>;
|
|
430
|
-
if (
|
|
431
|
-
const reqCtx = _getRequestContext();
|
|
432
|
-
if (reqCtx
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
)
|
|
441
|
+
if (!isInsideLoaderScope() && !insideHandlePush) {
|
|
442
|
+
const reqCtx = reqCtxRef ?? _getRequestContext();
|
|
443
|
+
if (reqCtx) {
|
|
444
|
+
// Direction 1: handler awaits loader that already called rendered()
|
|
445
|
+
if (
|
|
446
|
+
loaderPromises.has(loader.$$id) &&
|
|
447
|
+
reqCtx._renderBarrierWaiters?.has(loader.$$id)
|
|
448
|
+
) {
|
|
449
|
+
throw new Error(
|
|
450
|
+
`Deadlock: handler is awaiting loader "${loader.$$id}" which called ctx.rendered(). ` +
|
|
451
|
+
`The loader is waiting for segment resolution, but the handler blocks resolution. ` +
|
|
452
|
+
`Move the data dependency to a loader-to-loader pattern instead.`,
|
|
453
|
+
);
|
|
454
|
+
}
|
|
455
|
+
// Direction 2: track dep so rendered() can detect the deadlock
|
|
456
|
+
// if the loader calls it later. Skip when the barrier has already
|
|
457
|
+
// resolved — no deadlock is possible (rendered() resolves immediately).
|
|
458
|
+
// _renderBarrierSegmentOrder is undefined before resolution, string[]
|
|
459
|
+
// after. This also prevents false positives from handle push callbacks
|
|
460
|
+
// that resume after their first await (post-barrier-resolution).
|
|
461
|
+
if (reqCtx._renderBarrierSegmentOrder === undefined) {
|
|
462
|
+
if (!reqCtx._handlerLoaderDeps) reqCtx._handlerLoaderDeps = new Set();
|
|
463
|
+
reqCtx._handlerLoaderDeps.add(loader.$$id);
|
|
464
|
+
}
|
|
438
465
|
}
|
|
439
466
|
}
|
|
440
467
|
|
package/src/router/manifest.ts
CHANGED
|
@@ -126,28 +126,37 @@ export async function loadManifest(
|
|
|
126
126
|
// were created during pattern extraction. This prevents shortCode
|
|
127
127
|
// collisions between lazy and non-lazy entries under the same parent
|
|
128
128
|
// (e.g., ArticlesLayout and BlogLayout both under NavLayout).
|
|
129
|
-
if (lazyContext
|
|
130
|
-
const
|
|
131
|
-
for (const [key, value] of Object.entries(captured)) {
|
|
129
|
+
if (lazyContext?.counters) {
|
|
130
|
+
for (const [key, value] of Object.entries(lazyContext.counters)) {
|
|
132
131
|
Store.counters[key] = Math.max(Store.counters[key] ?? 0, value);
|
|
133
132
|
}
|
|
134
133
|
}
|
|
135
134
|
|
|
136
135
|
// Propagate cache profiles for DSL-time cache("profileName") resolution.
|
|
137
136
|
// Non-lazy entries carry profiles directly; lazy entries carry them
|
|
138
|
-
// in the captured lazyContext from include() time.
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
137
|
+
// in the captured lazyContext from include() time. Always write
|
|
138
|
+
// (including clearing to undefined) so a prior lazy build's profile
|
|
139
|
+
// map cannot leak into a later non-lazy build on the same ALS-backed
|
|
140
|
+
// Store — which would otherwise let cache("name") resolve a profile
|
|
141
|
+
// from an unrelated entry.
|
|
142
|
+
Store.cacheProfiles = entry.cacheProfiles ?? lazyContext?.cacheProfiles;
|
|
144
143
|
|
|
145
144
|
// Propagate rootScoped from lazyContext so that routes inside
|
|
146
145
|
// nested { name: "sub" } under { name: "" } keep inherited root scope
|
|
147
|
-
// when the manifest is rebuilt on each request.
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
146
|
+
// when the manifest is rebuilt on each request. Always write
|
|
147
|
+
// (including clearing to undefined, which makes getRootScoped()
|
|
148
|
+
// return its true default) so a prior lazy build's scope cannot leak
|
|
149
|
+
// into a later non-lazy build on the same ALS-backed Store — which
|
|
150
|
+
// would otherwise mis-register plain routes as non-root-scoped and
|
|
151
|
+
// break dot-local reverse resolution.
|
|
152
|
+
Store.rootScoped = lazyContext?.rootScoped;
|
|
153
|
+
|
|
154
|
+
// Propagate includeScope from lazyContext so that direct-descendant
|
|
155
|
+
// shortCodes of this include use the correct scoped counter namespace
|
|
156
|
+
// on every manifest rebuild. Always write (including clearing to
|
|
157
|
+
// undefined) so a prior lazy build's scope cannot leak into a later
|
|
158
|
+
// non-lazy build on the same ALS-backed Store.
|
|
159
|
+
Store.includeScope = lazyContext?.includeScope;
|
|
151
160
|
|
|
152
161
|
const handlerExecStart = performance.now();
|
|
153
162
|
const useItems = await getContext().runWithStore(
|
package/src/router/match-api.ts
CHANGED
|
@@ -22,10 +22,10 @@ import { collectRouteMiddleware } from "./middleware.js";
|
|
|
22
22
|
import { traverseBack } from "./pattern-matching.js";
|
|
23
23
|
import { DefaultErrorFallback } from "../default-error-boundary.js";
|
|
24
24
|
import {
|
|
25
|
-
EntryData,
|
|
26
|
-
LoaderEntry,
|
|
25
|
+
type EntryData,
|
|
26
|
+
type LoaderEntry,
|
|
27
27
|
getContext,
|
|
28
|
-
InterceptSelectorContext,
|
|
28
|
+
type InterceptSelectorContext,
|
|
29
29
|
} from "../server/context";
|
|
30
30
|
import type { ErrorBoundaryHandler, ErrorInfo, MatchResult } from "../types";
|
|
31
31
|
import type { ReactNode } from "react";
|
|
@@ -194,11 +194,13 @@ async function* yieldFromStore<TEnv>(
|
|
|
194
194
|
state.cachedSegments = segments;
|
|
195
195
|
state.cachedMatchedIds = segments.map((s) => s.id);
|
|
196
196
|
|
|
197
|
-
// Set streaming flag and resolve render barrier.
|
|
197
|
+
// Set streaming flag (once) and resolve render barrier.
|
|
198
198
|
const reqCtx = handleStoreRef ? undefined : _lazyGetRequestContext?.();
|
|
199
199
|
const barrierReqCtx = reqCtx ?? _getRequestContext();
|
|
200
200
|
if (barrierReqCtx) {
|
|
201
|
-
barrierReqCtx._treeHasStreaming
|
|
201
|
+
if (barrierReqCtx._treeHasStreaming === undefined) {
|
|
202
|
+
barrierReqCtx._treeHasStreaming = treeHasStreaming(ctx.entries);
|
|
203
|
+
}
|
|
202
204
|
barrierReqCtx._resolveRenderBarrier(segments);
|
|
203
205
|
}
|
|
204
206
|
|
|
@@ -249,6 +251,7 @@ async function* yieldFromStore<TEnv>(
|
|
|
249
251
|
ctx.url,
|
|
250
252
|
ctx.routeKey,
|
|
251
253
|
ctx.actionContext,
|
|
254
|
+
ctx.stale || undefined,
|
|
252
255
|
),
|
|
253
256
|
);
|
|
254
257
|
state.matchedIds = [
|
|
@@ -596,7 +599,7 @@ export function withCacheLookup<TEnv>(
|
|
|
596
599
|
routeKey: ctx.routeKey,
|
|
597
600
|
context: ctx.handlerContext,
|
|
598
601
|
actionContext: ctx.actionContext,
|
|
599
|
-
stale: cacheResult.shouldRevalidate || undefined,
|
|
602
|
+
stale: cacheResult.shouldRevalidate || ctx.stale || undefined,
|
|
600
603
|
traceSource: "cache-hit",
|
|
601
604
|
});
|
|
602
605
|
|
|
@@ -623,10 +626,12 @@ export function withCacheLookup<TEnv>(
|
|
|
623
626
|
yield segment;
|
|
624
627
|
}
|
|
625
628
|
|
|
626
|
-
// Set streaming flag and resolve render barrier.
|
|
629
|
+
// Set streaming flag (once) and resolve render barrier.
|
|
627
630
|
const barrierReqCtx = _getRequestContext();
|
|
628
631
|
if (barrierReqCtx) {
|
|
629
|
-
barrierReqCtx._treeHasStreaming
|
|
632
|
+
if (barrierReqCtx._treeHasStreaming === undefined) {
|
|
633
|
+
barrierReqCtx._treeHasStreaming = treeHasStreaming(ctx.entries);
|
|
634
|
+
}
|
|
630
635
|
barrierReqCtx._resolveRenderBarrier(cacheResult.segments);
|
|
631
636
|
}
|
|
632
637
|
|
|
@@ -125,6 +125,69 @@ export async function collectSegments(
|
|
|
125
125
|
return segments;
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
+
/**
|
|
129
|
+
* Deduplicate inherited loader segments by loaderId.
|
|
130
|
+
*
|
|
131
|
+
* When a route has loaders and a child layout has parallel slots, the same
|
|
132
|
+
* loader is resolved twice: once for the route and once inherited into the
|
|
133
|
+
* layout (tagged with `_inherited`). The inherited copy is only needed when
|
|
134
|
+
* the route uses `loading()` — in that case, the loader data is inside a
|
|
135
|
+
* LoaderBoundary/Suspense that parallel slots can't reach through. Without
|
|
136
|
+
* loading(), useLoader() traverses parent contexts and finds the data.
|
|
137
|
+
*/
|
|
138
|
+
function deduplicateLoaderSegments(
|
|
139
|
+
segments: ResolvedSegment[],
|
|
140
|
+
logPrefix: string,
|
|
141
|
+
): ResolvedSegment[] {
|
|
142
|
+
// First pass: collect loaderIds of original (non-inherited) segments
|
|
143
|
+
// and whether their parent entry uses loading()
|
|
144
|
+
const originalLoaders = new Set<string>();
|
|
145
|
+
const loadersWithLoading = new Set<string>();
|
|
146
|
+
for (const s of segments) {
|
|
147
|
+
if (s.type === "loader" && s.loaderId && !s._inherited) {
|
|
148
|
+
originalLoaders.add(s.loaderId);
|
|
149
|
+
// If the segment has a sibling with loading, the parent uses loading()
|
|
150
|
+
// We detect this by checking if any non-loader segment in the same
|
|
151
|
+
// namespace has loading defined
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
// Check if any layout/route segment has loading — if a loader's namespace
|
|
155
|
+
// matches a segment with loading, the inherited copy is needed
|
|
156
|
+
for (const s of segments) {
|
|
157
|
+
if (s.type !== "loader" && s.loading !== undefined && s.loading !== false) {
|
|
158
|
+
// Find loaders in this namespace
|
|
159
|
+
for (const l of segments) {
|
|
160
|
+
if (l.type === "loader" && l.namespace === s.namespace && l.loaderId) {
|
|
161
|
+
loadersWithLoading.add(l.loaderId);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const result: ResolvedSegment[] = [];
|
|
168
|
+
let dedupCount = 0;
|
|
169
|
+
|
|
170
|
+
for (const s of segments) {
|
|
171
|
+
if (
|
|
172
|
+
s.type === "loader" &&
|
|
173
|
+
s.loaderId &&
|
|
174
|
+
s._inherited &&
|
|
175
|
+
originalLoaders.has(s.loaderId) &&
|
|
176
|
+
!loadersWithLoading.has(s.loaderId)
|
|
177
|
+
) {
|
|
178
|
+
dedupCount++;
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
result.push(s);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (dedupCount > 0) {
|
|
185
|
+
debugLog(logPrefix, `deduped ${dedupCount} inherited loader segment(s)`);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return result;
|
|
189
|
+
}
|
|
190
|
+
|
|
128
191
|
/**
|
|
129
192
|
* Build the final MatchResult from collected segments and context
|
|
130
193
|
*/
|
|
@@ -181,6 +244,11 @@ export function buildMatchResult<TEnv>(
|
|
|
181
244
|
);
|
|
182
245
|
}
|
|
183
246
|
|
|
247
|
+
const dedupedSegments = deduplicateLoaderSegments(
|
|
248
|
+
segmentsToRender,
|
|
249
|
+
logPrefix,
|
|
250
|
+
);
|
|
251
|
+
|
|
184
252
|
debugLog(logPrefix, "all segments", {
|
|
185
253
|
segments: allSegments.map((s) => ({
|
|
186
254
|
id: s.id,
|
|
@@ -189,13 +257,23 @@ export function buildMatchResult<TEnv>(
|
|
|
189
257
|
})),
|
|
190
258
|
});
|
|
191
259
|
debugLog(logPrefix, "segments to render", {
|
|
192
|
-
segmentIds:
|
|
260
|
+
segmentIds: dedupedSegments.map((s) => s.id),
|
|
193
261
|
});
|
|
194
262
|
|
|
263
|
+
// Remove deduped loader IDs from matched so the client doesn't treat
|
|
264
|
+
// them as missing segments and trigger a fallback refetch.
|
|
265
|
+
const removedIds = new Set(
|
|
266
|
+
segmentsToRender
|
|
267
|
+
.filter((s) => !dedupedSegments.includes(s))
|
|
268
|
+
.map((s) => s.id),
|
|
269
|
+
);
|
|
270
|
+
const matchedIds =
|
|
271
|
+
removedIds.size > 0 ? allIds.filter((id) => !removedIds.has(id)) : allIds;
|
|
272
|
+
|
|
195
273
|
return {
|
|
196
|
-
segments:
|
|
197
|
-
matched:
|
|
198
|
-
diff:
|
|
274
|
+
segments: dedupedSegments,
|
|
275
|
+
matched: matchedIds,
|
|
276
|
+
diff: dedupedSegments.map((s) => s.id),
|
|
199
277
|
params: ctx.matched.params,
|
|
200
278
|
routeName: ctx.routeKey,
|
|
201
279
|
slots: Object.keys(state.slots).length > 0 ? state.slots : undefined,
|
|
@@ -14,6 +14,7 @@ import type {
|
|
|
14
14
|
import type { ScopedReverseFunction } from "../reverse.js";
|
|
15
15
|
import type { Theme } from "../theme/types.js";
|
|
16
16
|
import type { LocationStateEntry } from "../browser/react/location-state-shared.js";
|
|
17
|
+
import type { RequestScope } from "../types/request-scope.js";
|
|
17
18
|
|
|
18
19
|
/**
|
|
19
20
|
* Get variable function type
|
|
@@ -57,28 +58,7 @@ export interface CookieOptions {
|
|
|
57
58
|
export interface MiddlewareContext<
|
|
58
59
|
TEnv = any,
|
|
59
60
|
TParams = Record<string, string>,
|
|
60
|
-
> {
|
|
61
|
-
/** Original request */
|
|
62
|
-
request: Request;
|
|
63
|
-
|
|
64
|
-
/** Parsed URL (with internal `_rsc*` params stripped) */
|
|
65
|
-
url: URL;
|
|
66
|
-
|
|
67
|
-
/**
|
|
68
|
-
* The original request URL with all parameters intact, including
|
|
69
|
-
* internal `_rsc*` transport params.
|
|
70
|
-
*/
|
|
71
|
-
originalUrl: URL;
|
|
72
|
-
|
|
73
|
-
/** URL pathname */
|
|
74
|
-
pathname: string;
|
|
75
|
-
|
|
76
|
-
/** URL search params */
|
|
77
|
-
searchParams: URLSearchParams;
|
|
78
|
-
|
|
79
|
-
/** Platform bindings (Cloudflare, etc.) */
|
|
80
|
-
env: TEnv;
|
|
81
|
-
|
|
61
|
+
> extends RequestScope<TEnv> {
|
|
82
62
|
/** URL params extracted from route/middleware pattern */
|
|
83
63
|
params: TParams;
|
|
84
64
|
|
package/src/router/middleware.ts
CHANGED
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import { contextGet, contextSet } from "../context-var.js";
|
|
13
|
+
import { safeDecodeURIComponent } from "./url-params.js";
|
|
14
|
+
import { fireAndForgetWaitUntil } from "../types/request-scope.js";
|
|
13
15
|
import type {
|
|
14
16
|
CollectedMiddleware,
|
|
15
17
|
MiddlewareCollectableEntry,
|
|
@@ -22,6 +24,7 @@ import { _getRequestContext } from "../server/request-context.js";
|
|
|
22
24
|
import { isAutoGeneratedRouteName } from "../route-name.js";
|
|
23
25
|
import { appendMetric, createMetricsStore } from "./metrics.js";
|
|
24
26
|
import { stripInternalParams } from "./handler-context.js";
|
|
27
|
+
import { isWebSocketUpgradeResponse } from "../response-utils.js";
|
|
25
28
|
|
|
26
29
|
// Re-export types and cookie utilities for backward compatibility
|
|
27
30
|
export type {
|
|
@@ -112,7 +115,12 @@ function escapeRegex(str: string): string {
|
|
|
112
115
|
}
|
|
113
116
|
|
|
114
117
|
/**
|
|
115
|
-
* Extract params from a pathname using a pattern's regex and param names
|
|
118
|
+
* Extract params from a pathname using a pattern's regex and param names.
|
|
119
|
+
*
|
|
120
|
+
* Values are URL-decoded so apps see the raw string (e.g. "ivo@example.com")
|
|
121
|
+
* instead of the percent-encoded form ("ivo%40example.com"). This matches the
|
|
122
|
+
* contract assumed by ctx.reverse (which re-encodes) and aligns with
|
|
123
|
+
* Express/React Router/Fastify/Koa.
|
|
116
124
|
*/
|
|
117
125
|
export function extractParams(
|
|
118
126
|
pathname: string,
|
|
@@ -124,7 +132,7 @@ export function extractParams(
|
|
|
124
132
|
|
|
125
133
|
const params: Record<string, string> = {};
|
|
126
134
|
for (let i = 0; i < paramNames.length; i++) {
|
|
127
|
-
params[paramNames[i]] = match[i + 1] || "";
|
|
135
|
+
params[paramNames[i]] = safeDecodeURIComponent(match[i + 1] || "");
|
|
128
136
|
}
|
|
129
137
|
return params;
|
|
130
138
|
}
|
|
@@ -179,14 +187,22 @@ export function createMiddlewareContext<TEnv>(
|
|
|
179
187
|
return responseHolder.response;
|
|
180
188
|
};
|
|
181
189
|
|
|
190
|
+
// Capture reqCtx once: the request-scoped platform fields
|
|
191
|
+
// (originalUrl, executionContext, waitUntil) are immutable per request,
|
|
192
|
+
// so snapshotting beats re-reading ALS on every access. The lazy getters
|
|
193
|
+
// below (routeName, theme, setTheme) stay lazy because those can change
|
|
194
|
+
// during `await next()`.
|
|
195
|
+
const reqCtx = _getRequestContext();
|
|
182
196
|
return {
|
|
183
197
|
request,
|
|
184
198
|
url,
|
|
185
|
-
originalUrl: new URL(request.url),
|
|
199
|
+
originalUrl: reqCtx?.originalUrl ?? new URL(request.url),
|
|
186
200
|
pathname: url.pathname,
|
|
187
201
|
searchParams: url.searchParams,
|
|
188
202
|
env: env as MiddlewareContext<TEnv>["env"],
|
|
189
203
|
params,
|
|
204
|
+
executionContext: reqCtx?.executionContext,
|
|
205
|
+
waitUntil: reqCtx ? reqCtx.waitUntil.bind(reqCtx) : fireAndForgetWaitUntil,
|
|
190
206
|
// Getter: re-derives from request context on each access so that global
|
|
191
207
|
// middleware sees the matched route name after await next().
|
|
192
208
|
get routeName(): MiddlewareContext<TEnv>["routeName"] {
|
|
@@ -360,6 +376,11 @@ export async function executeMiddleware<TEnv>(
|
|
|
360
376
|
});
|
|
361
377
|
}
|
|
362
378
|
|
|
379
|
+
if (isWebSocketUpgradeResponse(response)) {
|
|
380
|
+
responseHolder.response = response;
|
|
381
|
+
return response;
|
|
382
|
+
}
|
|
383
|
+
|
|
363
384
|
// Clone response with merged headers (mutable for post-next() modifications)
|
|
364
385
|
responseHolder.response = new Response(response.body, {
|
|
365
386
|
status: response.status,
|
|
@@ -451,6 +472,10 @@ export async function executeMiddleware<TEnv>(
|
|
|
451
472
|
// RequestContext stub headers (from ctx.setCookie) into the
|
|
452
473
|
// returned Response so they are not lost.
|
|
453
474
|
if (result instanceof Response) {
|
|
475
|
+
if (isWebSocketUpgradeResponse(result)) {
|
|
476
|
+
responseHolder.response = result;
|
|
477
|
+
return result;
|
|
478
|
+
}
|
|
454
479
|
const mergedHeaders = new Headers(result.headers);
|
|
455
480
|
stubResponse.headers.forEach((value, name) => {
|
|
456
481
|
if (name.toLowerCase() === "set-cookie") {
|
|
@@ -527,8 +552,11 @@ export async function executeMiddleware<TEnv>(
|
|
|
527
552
|
// last merge point (e.g. cookies().set() called after await next()).
|
|
528
553
|
// The reqCtx stub may have already been partially merged during finalHandler
|
|
529
554
|
// or early-return paths; only append *new* Set-Cookie entries to avoid dupes.
|
|
555
|
+
//
|
|
556
|
+
// Skip for upgrade responses: upgrade headers are semantically immutable and
|
|
557
|
+
// set-cookie on an upgrade is not meaningful.
|
|
530
558
|
const reqCtx = _getRequestContext();
|
|
531
|
-
if (reqCtx) {
|
|
559
|
+
if (reqCtx && !isWebSocketUpgradeResponse(finalResponse)) {
|
|
532
560
|
const stubCookies = reqCtx.res.headers.getSetCookie();
|
|
533
561
|
if (stubCookies.length > 0) {
|
|
534
562
|
const existingCookies = new Set(finalResponse.headers.getSetCookie());
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import type { RouteEntry, TrailingSlashMode } from "../types";
|
|
8
8
|
import type { EntryData } from "../server/context";
|
|
9
9
|
import { debugLog, isRouterDebugEnabled } from "./logging.js";
|
|
10
|
+
import { safeDecodeURIComponent } from "./url-params.js";
|
|
10
11
|
|
|
11
12
|
/**
|
|
12
13
|
* Parsed segment info
|
|
@@ -82,6 +83,13 @@ export interface CompiledPattern {
|
|
|
82
83
|
paramNames: string[];
|
|
83
84
|
optionalParams: Set<string>;
|
|
84
85
|
hasTrailingSlash: boolean;
|
|
86
|
+
/**
|
|
87
|
+
* Param-name → allowed values for constrained params (e.g. `:lang(en|gb)`).
|
|
88
|
+
* Validated against the **decoded** param value after regex extraction so
|
|
89
|
+
* a URL like `/en%20GB` still matches `:lang(en GB)` — matching the trie
|
|
90
|
+
* path's behavior (trie-matching.ts:validateAndBuild).
|
|
91
|
+
*/
|
|
92
|
+
constraints?: Record<string, string[]>;
|
|
85
93
|
}
|
|
86
94
|
|
|
87
95
|
// Module-level cache for compiled patterns. Route patterns are a finite set
|
|
@@ -142,6 +150,7 @@ export function compilePattern(pattern: string): CompiledPattern {
|
|
|
142
150
|
const segments = parsePattern(normalizedPattern);
|
|
143
151
|
const paramNames: string[] = [];
|
|
144
152
|
const optionalParams = new Set<string>();
|
|
153
|
+
let constraints: Record<string, string[]> | undefined;
|
|
145
154
|
|
|
146
155
|
let regexPattern = "";
|
|
147
156
|
|
|
@@ -152,11 +161,14 @@ export function compilePattern(pattern: string): CompiledPattern {
|
|
|
152
161
|
} else if (segment.type === "param") {
|
|
153
162
|
paramNames.push(segment.value);
|
|
154
163
|
const suffixPattern = segment.suffix ? escapeRegex(segment.suffix) : "";
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
164
|
+
// Constrained params capture anything here; the allowed values are
|
|
165
|
+
// checked post-decode in findMatch so URL-encoded constraint values
|
|
166
|
+
// (e.g. `:lang(en GB)` via `/en%20GB`) still match.
|
|
167
|
+
const valuePattern = segment.suffix ? "([^/]+?)" : "([^/]+)";
|
|
168
|
+
|
|
169
|
+
if (segment.constraint) {
|
|
170
|
+
(constraints ??= {})[segment.value] = segment.constraint;
|
|
171
|
+
}
|
|
160
172
|
|
|
161
173
|
if (segment.optional) {
|
|
162
174
|
optionalParams.add(segment.value);
|
|
@@ -186,9 +198,33 @@ export function compilePattern(pattern: string): CompiledPattern {
|
|
|
186
198
|
paramNames,
|
|
187
199
|
optionalParams,
|
|
188
200
|
hasTrailingSlash,
|
|
201
|
+
...(constraints ? { constraints } : {}),
|
|
189
202
|
};
|
|
190
203
|
}
|
|
191
204
|
|
|
205
|
+
/**
|
|
206
|
+
* Validate decoded params against a compiled pattern's constraints.
|
|
207
|
+
* Returns false if any constrained param has a non-empty value not in the
|
|
208
|
+
* allowed list (empty-string = absent optional, which is allowed).
|
|
209
|
+
*/
|
|
210
|
+
function satisfiesConstraints(
|
|
211
|
+
params: Record<string, string>,
|
|
212
|
+
constraints: Record<string, string[]> | undefined,
|
|
213
|
+
): boolean {
|
|
214
|
+
if (!constraints) return true;
|
|
215
|
+
for (const name in constraints) {
|
|
216
|
+
const value = params[name];
|
|
217
|
+
if (
|
|
218
|
+
value !== undefined &&
|
|
219
|
+
value !== "" &&
|
|
220
|
+
!constraints[name].includes(value)
|
|
221
|
+
) {
|
|
222
|
+
return false;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return true;
|
|
226
|
+
}
|
|
227
|
+
|
|
192
228
|
/**
|
|
193
229
|
* Escape special regex characters in a string
|
|
194
230
|
*/
|
|
@@ -392,8 +428,13 @@ export function findMatch<TEnv>(
|
|
|
392
428
|
fullPattern = entry.prefix + pattern;
|
|
393
429
|
}
|
|
394
430
|
|
|
395
|
-
const {
|
|
396
|
-
|
|
431
|
+
const {
|
|
432
|
+
regex,
|
|
433
|
+
paramNames,
|
|
434
|
+
optionalParams,
|
|
435
|
+
hasTrailingSlash,
|
|
436
|
+
constraints,
|
|
437
|
+
} = getCompiledPattern(fullPattern);
|
|
397
438
|
|
|
398
439
|
// Get trailing slash mode for this route (per-route config or pattern-based)
|
|
399
440
|
const trailingSlashMode: TrailingSlashMode | undefined =
|
|
@@ -412,9 +453,15 @@ export function findMatch<TEnv>(
|
|
|
412
453
|
if (match) {
|
|
413
454
|
const params: Record<string, string> = {};
|
|
414
455
|
paramNames.forEach((name, index) => {
|
|
415
|
-
params[name] = match[index + 1] ?? "";
|
|
456
|
+
params[name] = safeDecodeURIComponent(match[index + 1] ?? "");
|
|
416
457
|
});
|
|
417
458
|
|
|
459
|
+
// Validate constraints against decoded values; a failure falls
|
|
460
|
+
// through to the next route so other patterns can still match.
|
|
461
|
+
if (!satisfiesConstraints(params, constraints)) {
|
|
462
|
+
continue;
|
|
463
|
+
}
|
|
464
|
+
|
|
418
465
|
if (effectiveDebug) {
|
|
419
466
|
debugLog("findMatch", "matched route", {
|
|
420
467
|
routeKey,
|
|
@@ -467,9 +514,13 @@ export function findMatch<TEnv>(
|
|
|
467
514
|
if (altMatch) {
|
|
468
515
|
const params: Record<string, string> = {};
|
|
469
516
|
paramNames.forEach((name, index) => {
|
|
470
|
-
params[name] = altMatch[index + 1] ?? "";
|
|
517
|
+
params[name] = safeDecodeURIComponent(altMatch[index + 1] ?? "");
|
|
471
518
|
});
|
|
472
519
|
|
|
520
|
+
if (!satisfiesConstraints(params, constraints)) {
|
|
521
|
+
continue;
|
|
522
|
+
}
|
|
523
|
+
|
|
473
524
|
// Determine redirect behavior based on mode
|
|
474
525
|
if (trailingSlashMode === "ignore") {
|
|
475
526
|
// Match without redirect
|