@rangojs/router 0.0.0-experimental.83 → 0.0.0-experimental.8332dbe4
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 +112 -17
- package/dist/vite/index.js +1197 -454
- package/package.json +4 -2
- package/skills/breadcrumbs/SKILL.md +3 -1
- package/skills/handler-use/SKILL.md +2 -0
- package/skills/hooks/SKILL.md +30 -2
- package/skills/i18n/SKILL.md +276 -0
- package/skills/intercept/SKILL.md +25 -0
- package/skills/layout/SKILL.md +2 -0
- package/skills/links/SKILL.md +234 -16
- package/skills/loader/SKILL.md +70 -3
- package/skills/middleware/SKILL.md +2 -0
- package/skills/migrate-nextjs/SKILL.md +3 -1
- package/skills/migrate-react-router/SKILL.md +4 -0
- package/skills/parallel/SKILL.md +9 -0
- package/skills/rango/SKILL.md +2 -0
- package/skills/response-routes/SKILL.md +8 -0
- package/skills/route/SKILL.md +24 -0
- package/skills/server-actions/SKILL.md +739 -0
- package/skills/streams-and-websockets/SKILL.md +283 -0
- package/skills/typesafety/SKILL.md +9 -1
- package/skills/view-transitions/SKILL.md +212 -0
- package/src/browser/app-shell.ts +52 -0
- package/src/browser/event-controller.ts +44 -4
- package/src/browser/navigation-bridge.ts +113 -6
- package/src/browser/navigation-store.ts +25 -1
- package/src/browser/partial-update.ts +44 -10
- package/src/browser/prefetch/cache.ts +16 -0
- package/src/browser/rango-state.ts +53 -13
- package/src/browser/react/NavigationProvider.tsx +64 -16
- package/src/browser/react/filter-segment-order.ts +51 -7
- package/src/browser/react/index.ts +3 -0
- package/src/browser/react/use-params.ts +8 -5
- package/src/browser/react/use-reverse.ts +99 -0
- package/src/browser/react/use-router.ts +8 -1
- package/src/browser/react/use-segments.ts +11 -8
- package/src/browser/rsc-router.tsx +34 -6
- package/src/browser/types.ts +19 -0
- package/src/build/route-trie.ts +2 -1
- package/src/cache/cf/cf-cache-store.ts +5 -7
- package/src/client.rsc.tsx +3 -0
- package/src/client.tsx +5 -1
- package/src/href-client.ts +4 -1
- package/src/index.rsc.ts +3 -0
- package/src/index.ts +3 -0
- package/src/outlet-context.ts +1 -1
- package/src/response-utils.ts +28 -0
- package/src/reverse.ts +62 -39
- package/src/route-definition/dsl-helpers.ts +16 -3
- package/src/route-definition/helpers-types.ts +6 -1
- package/src/route-definition/resolve-handler-use.ts +6 -0
- package/src/router/handler-context.ts +21 -41
- package/src/router/lazy-includes.ts +1 -1
- package/src/router/loader-resolution.ts +3 -0
- package/src/router/match-api.ts +4 -3
- package/src/router/match-handlers.ts +1 -0
- package/src/router/match-result.ts +21 -2
- package/src/router/middleware-types.ts +14 -25
- package/src/router/middleware.ts +54 -7
- package/src/router/pattern-matching.ts +101 -17
- package/src/router/revalidation.ts +15 -1
- package/src/router/segment-resolution/fresh.ts +8 -0
- package/src/router/segment-resolution/revalidation.ts +128 -100
- package/src/router/substitute-pattern-params.ts +56 -0
- package/src/router/trie-matching.ts +18 -13
- package/src/router/url-params.ts +49 -0
- package/src/router.ts +1 -2
- package/src/rsc/handler.ts +8 -4
- package/src/rsc/progressive-enhancement.ts +2 -0
- package/src/rsc/response-route-handler.ts +11 -10
- package/src/rsc/rsc-rendering.ts +3 -0
- package/src/rsc/server-action.ts +2 -0
- package/src/rsc/types.ts +6 -0
- package/src/segment-system.tsx +60 -9
- package/src/server/request-context.ts +10 -42
- package/src/ssr/index.tsx +5 -1
- package/src/types/handler-context.ts +12 -39
- package/src/types/loader-types.ts +5 -6
- package/src/types/request-scope.ts +126 -0
- package/src/types/segments.ts +17 -0
- package/src/urls/response-types.ts +2 -10
- package/src/vite/debug.ts +184 -0
- package/src/vite/discovery/discover-routers.ts +31 -3
- package/src/vite/discovery/gate-state.ts +171 -0
- package/src/vite/discovery/prerender-collection.ts +48 -1
- package/src/vite/discovery/self-gen-tracking.ts +27 -1
- package/src/vite/plugins/cjs-to-esm.ts +5 -0
- package/src/vite/plugins/client-ref-dedup.ts +16 -0
- package/src/vite/plugins/client-ref-hashing.ts +16 -4
- package/src/vite/plugins/expose-action-id.ts +52 -28
- package/src/vite/plugins/expose-ids/router-transform.ts +20 -3
- package/src/vite/plugins/expose-internal-ids.ts +516 -486
- package/src/vite/plugins/performance-tracks.ts +17 -9
- package/src/vite/plugins/use-cache-transform.ts +56 -43
- package/src/vite/plugins/version-injector.ts +37 -11
- package/src/vite/rango.ts +49 -14
- package/src/vite/router-discovery.ts +498 -52
- package/src/vite/utils/banner.ts +1 -1
- package/src/vite/utils/package-resolution.ts +41 -1
- package/src/vite/utils/prerender-utils.ts +5 -4
package/src/reverse.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ExtractParams } from "./types.js";
|
|
2
2
|
import type { SearchSchema, ResolveSearchSchema } from "./search-params.js";
|
|
3
3
|
import { serializeSearchParams } from "./search-params.js";
|
|
4
|
+
import { substitutePatternParams } from "./router/substitute-pattern-params.js";
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Sanitize prefix string by removing leading slash
|
|
@@ -218,6 +219,64 @@ export type ExtractLocalRoutes<TPatterns> = TPatterns extends {
|
|
|
218
219
|
? TPatterns
|
|
219
220
|
: Record<string, string>;
|
|
220
221
|
|
|
222
|
+
/**
|
|
223
|
+
* Params accepted by `useReverse(routes)`. The route's own params are
|
|
224
|
+
* required, and additional string keys are permitted so callers can
|
|
225
|
+
* override values that would otherwise be auto-filled from the matched
|
|
226
|
+
* route's `useParams()` (e.g. an enclosing `:tenantId` mount segment).
|
|
227
|
+
*/
|
|
228
|
+
export type LocalReverseParams<TPattern extends string> =
|
|
229
|
+
ExtractParams<TPattern> & {
|
|
230
|
+
readonly [extra: string]: string | undefined;
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Type-safe local reverse function with dot-prefixed names only.
|
|
235
|
+
*
|
|
236
|
+
* Returned by `useReverse(routes)` on the client. The route map is the
|
|
237
|
+
* exposure boundary (a generated `routes` from a `urls()` module) and the
|
|
238
|
+
* scope is implicit from that import — there is no global namespace, so
|
|
239
|
+
* names must be dot-prefixed to mirror `ctx.reverse(".name")`.
|
|
240
|
+
*
|
|
241
|
+
* @example
|
|
242
|
+
* ```typescript
|
|
243
|
+
* const reverse = useReverse(blogRoutes);
|
|
244
|
+
* reverse(".index"); // ✓ no params
|
|
245
|
+
* reverse(".post", { postId: "hello" }); // ✓ with params
|
|
246
|
+
* reverse(".search", {}, { q: "hi" }); // ✓ with search schema
|
|
247
|
+
* reverse(".typo"); // ✗ compile error
|
|
248
|
+
* ```
|
|
249
|
+
*/
|
|
250
|
+
export type LocalReverseFunction<TLocalRoutes> = {
|
|
251
|
+
/**
|
|
252
|
+
* Dot-prefixed local route without params
|
|
253
|
+
*/
|
|
254
|
+
<TName extends keyof TLocalRoutes & string>(
|
|
255
|
+
name: IsEmptyObject<
|
|
256
|
+
ExtractParams<RoutePatternFor<TLocalRoutes, TName>>
|
|
257
|
+
> extends true
|
|
258
|
+
? `.${TName}`
|
|
259
|
+
: never,
|
|
260
|
+
): string;
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Dot-prefixed local route with params
|
|
264
|
+
*/
|
|
265
|
+
<TName extends keyof TLocalRoutes & string>(
|
|
266
|
+
name: `.${TName}`,
|
|
267
|
+
params: LocalReverseParams<RoutePatternFor<TLocalRoutes, TName>>,
|
|
268
|
+
): string;
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Dot-prefixed local route with params and search
|
|
272
|
+
*/
|
|
273
|
+
<TName extends keyof TLocalRoutes & string>(
|
|
274
|
+
name: `.${TName}`,
|
|
275
|
+
params: LocalReverseParams<RoutePatternFor<TLocalRoutes, TName>>,
|
|
276
|
+
search: ResolveSearchSchema<ExtractSearchSchema<TLocalRoutes, TName>>,
|
|
277
|
+
): string;
|
|
278
|
+
};
|
|
279
|
+
|
|
221
280
|
/**
|
|
222
281
|
* Extract the response data type for a named route from a UrlPatterns instance.
|
|
223
282
|
* Re-exported from urls.ts for consumer convenience.
|
|
@@ -301,45 +360,9 @@ export function createReverse<TRoutes extends Record<string, string>>(
|
|
|
301
360
|
throw new Error(`Unknown route: ${name}`);
|
|
302
361
|
}
|
|
303
362
|
|
|
304
|
-
let result =
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
// Strip constraint syntax: :param(a|b) -> use "param" as key
|
|
308
|
-
// Optional params (:param?) are omitted when not provided
|
|
309
|
-
let hadOmittedOptional = false;
|
|
310
|
-
result = result.replace(
|
|
311
|
-
/:([a-zA-Z_][a-zA-Z0-9_]*)(\([^)]*\))?(\?)/g,
|
|
312
|
-
(_, key, _constraint, optional) => {
|
|
313
|
-
const value = params[key];
|
|
314
|
-
// Empty string is treated as omitted — the trie matcher fills
|
|
315
|
-
// unmatched optional params with "" (not undefined), so reverse
|
|
316
|
-
// must collapse those segments instead of leaving empty slots.
|
|
317
|
-
if (value === undefined || value === "") {
|
|
318
|
-
hadOmittedOptional = true;
|
|
319
|
-
return "";
|
|
320
|
-
}
|
|
321
|
-
return encodeURIComponent(value);
|
|
322
|
-
},
|
|
323
|
-
);
|
|
324
|
-
// Second pass: required params (no trailing ?)
|
|
325
|
-
result = result.replace(
|
|
326
|
-
/:([a-zA-Z_][a-zA-Z0-9_]*)(\([^)]*\))?(?!\?)/g,
|
|
327
|
-
(_, key) => {
|
|
328
|
-
const value = params[key];
|
|
329
|
-
if (value === undefined) {
|
|
330
|
-
throw new Error(`Missing param "${key}" for route "${name}"`);
|
|
331
|
-
}
|
|
332
|
-
return encodeURIComponent(value);
|
|
333
|
-
},
|
|
334
|
-
);
|
|
335
|
-
// Clean up slashes only when an optional param was actually omitted,
|
|
336
|
-
// so intentional trailing-slash patterns like "/blog/" are preserved.
|
|
337
|
-
if (hadOmittedOptional) {
|
|
338
|
-
const hadTrailingSlash = pattern.length > 1 && pattern.endsWith("/");
|
|
339
|
-
result = result.replace(/\/\/+/g, "/").replace(/\/+$/, "") || "/";
|
|
340
|
-
if (hadTrailingSlash && !result.endsWith("/")) result += "/";
|
|
341
|
-
}
|
|
342
|
-
}
|
|
363
|
+
let result = params
|
|
364
|
+
? substitutePatternParams(pattern, params, name)
|
|
365
|
+
: pattern;
|
|
343
366
|
|
|
344
367
|
// Append search params as query string
|
|
345
368
|
if (search) {
|
|
@@ -304,6 +304,15 @@ const cache: RouteHelpers<any, any>["cache"] = (
|
|
|
304
304
|
return { name: namespace, type: "cache" } as CacheItem;
|
|
305
305
|
}
|
|
306
306
|
|
|
307
|
+
// Inside a loader() use() callback, only the direct form — cache()/cache(opts)/
|
|
308
|
+
// cache("profile") — writes cache config to the loader entry. The wrapper
|
|
309
|
+
// form creates a structural cache boundary with its own children scope, which
|
|
310
|
+
// has no effect on the loader and would silently no-op.
|
|
311
|
+
invariant(
|
|
312
|
+
!(ctx.parent && (ctx.parent as any).type === "loader"),
|
|
313
|
+
"cache() wrapper form is not valid inside loader() use(). Use cache({...}) without children to configure the loader's cache.",
|
|
314
|
+
);
|
|
315
|
+
|
|
307
316
|
// With children: create a cache entry (like layout with caching semantics)
|
|
308
317
|
const namespace = `${ctx.namespace}.${cacheIndex}`;
|
|
309
318
|
const cacheShortCode = store.getShortCode("cache");
|
|
@@ -750,8 +759,12 @@ const loaderFn: RouteHelpers<any, any>["loader"] = (loaderDef, use) => {
|
|
|
750
759
|
revalidate: [] as ShouldRevalidateFn<any, any>[],
|
|
751
760
|
};
|
|
752
761
|
|
|
753
|
-
//
|
|
754
|
-
|
|
762
|
+
// Merge handler.use defaults (attached to the loader definition) with explicit use
|
|
763
|
+
const handlerUseFn = resolveHandlerUse(loaderDef);
|
|
764
|
+
const mergedUse = mergeHandlerUse(handlerUseFn, use, "loader");
|
|
765
|
+
|
|
766
|
+
// If any use callback is in effect, run it to collect revalidation rules and cache config
|
|
767
|
+
if (mergedUse) {
|
|
755
768
|
// Temporarily set context for revalidate()/cache() calls to target this loader
|
|
756
769
|
const originalParent = ctx.parent;
|
|
757
770
|
// Create a temporary "parent" with type "loader" so cache() can detect it.
|
|
@@ -764,7 +777,7 @@ const loaderFn: RouteHelpers<any, any>["loader"] = (loaderDef, use) => {
|
|
|
764
777
|
};
|
|
765
778
|
ctx.parent = tempParent as EntryData;
|
|
766
779
|
|
|
767
|
-
const result =
|
|
780
|
+
const result = mergedUse()?.flat(3);
|
|
768
781
|
|
|
769
782
|
// Copy cache config only if cache() was called during the use() callback.
|
|
770
783
|
// The spread from originalParent may carry an inherited .cache from
|
|
@@ -259,7 +259,12 @@ export type RouteHelpers<T extends RouteDefinition, TEnv> = {
|
|
|
259
259
|
* ({ defaultShouldRevalidate: true })
|
|
260
260
|
* )
|
|
261
261
|
* ```
|
|
262
|
-
* @param fn - Function
|
|
262
|
+
* @param fn - Function returning either:
|
|
263
|
+
* - `boolean` (hard decision — short-circuits the chain),
|
|
264
|
+
* - `{ defaultShouldRevalidate: boolean }` (soft — updates the suggestion
|
|
265
|
+
* for downstream revalidators),
|
|
266
|
+
* - or nothing / `null` / `undefined` (defer — leaves the suggestion
|
|
267
|
+
* unchanged and continues to the next revalidator).
|
|
263
268
|
*/
|
|
264
269
|
revalidate: (fn: ShouldRevalidateFn<any, TEnv>) => RevalidateItem;
|
|
265
270
|
/**
|
|
@@ -21,6 +21,10 @@ export function resolveHandlerUse(handler: unknown): (() => any[]) | undefined {
|
|
|
21
21
|
if (isStaticHandler(handler)) {
|
|
22
22
|
return (handler as any).use;
|
|
23
23
|
}
|
|
24
|
+
// Loader definitions from createLoader() — branded objects with optional .use
|
|
25
|
+
if (typeof handler === "object" && (handler as any).__brand === "loader") {
|
|
26
|
+
return (handler as any).use;
|
|
27
|
+
}
|
|
24
28
|
// Plain handler function
|
|
25
29
|
if (typeof handler === "function") {
|
|
26
30
|
return (handler as any).use;
|
|
@@ -99,6 +103,8 @@ const MOUNT_SITE_ALLOWED_TYPES: Record<string, Set<string>> = {
|
|
|
99
103
|
"when",
|
|
100
104
|
"transition",
|
|
101
105
|
]),
|
|
106
|
+
// LoaderUseItem — only revalidate + cache can attach to a loader entry
|
|
107
|
+
loader: new Set(["revalidate", "cache"]),
|
|
102
108
|
};
|
|
103
109
|
|
|
104
110
|
/**
|
|
@@ -18,6 +18,8 @@ import { isInsideCacheScope } from "../server/context.js";
|
|
|
18
18
|
import { NOCACHE_SYMBOL, assertNotInsideCacheExec } from "../cache/taint.js";
|
|
19
19
|
import { isAutoGeneratedRouteName } from "../route-name.js";
|
|
20
20
|
import { PRERENDER_PASSTHROUGH } from "../prerender.js";
|
|
21
|
+
import { substitutePatternParams } from "./substitute-pattern-params.js";
|
|
22
|
+
import { fireAndForgetWaitUntil } from "../types/request-scope.js";
|
|
21
23
|
|
|
22
24
|
/**
|
|
23
25
|
* Strip internal _rsc* query params from a URL.
|
|
@@ -158,51 +160,14 @@ export function createReverseFunction(
|
|
|
158
160
|
);
|
|
159
161
|
}
|
|
160
162
|
|
|
161
|
-
let result = pattern;
|
|
162
|
-
|
|
163
163
|
// Merge current request params as defaults, explicit params override
|
|
164
164
|
const effectiveParams = currentParams
|
|
165
165
|
? { ...currentParams, ...hrefParams }
|
|
166
166
|
: hrefParams;
|
|
167
167
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
let hadOmittedOptional = false;
|
|
172
|
-
// First pass: optional params (trailing ?)
|
|
173
|
-
result = result.replace(
|
|
174
|
-
/:([a-zA-Z_][a-zA-Z0-9_]*)(\([^)]*\))?(\?)/g,
|
|
175
|
-
(_, key) => {
|
|
176
|
-
const value = effectiveParams[key];
|
|
177
|
-
// Empty string is treated as omitted — the trie matcher fills
|
|
178
|
-
// unmatched optional params with "" (not undefined), so reverse
|
|
179
|
-
// must collapse those segments instead of leaving empty slots.
|
|
180
|
-
if (value === undefined || value === "") {
|
|
181
|
-
hadOmittedOptional = true;
|
|
182
|
-
return "";
|
|
183
|
-
}
|
|
184
|
-
return encodeURIComponent(value);
|
|
185
|
-
},
|
|
186
|
-
);
|
|
187
|
-
// Second pass: required params (no trailing ?)
|
|
188
|
-
result = result.replace(
|
|
189
|
-
/:([a-zA-Z_][a-zA-Z0-9_]*)(\([^)]*\))?(?!\?)/g,
|
|
190
|
-
(_, key) => {
|
|
191
|
-
const value = effectiveParams[key];
|
|
192
|
-
if (value === undefined) {
|
|
193
|
-
throw new Error(`Missing param "${key}" for route "${name}"`);
|
|
194
|
-
}
|
|
195
|
-
return encodeURIComponent(value);
|
|
196
|
-
},
|
|
197
|
-
);
|
|
198
|
-
// Clean up slashes only when an optional param was actually omitted,
|
|
199
|
-
// so intentional trailing-slash patterns like "/blog/" are preserved.
|
|
200
|
-
if (hadOmittedOptional) {
|
|
201
|
-
const hadTrailingSlash = pattern.length > 1 && pattern.endsWith("/");
|
|
202
|
-
result = result.replace(/\/\/+/g, "/").replace(/\/+$/, "") || "/";
|
|
203
|
-
if (hadTrailingSlash && !result.endsWith("/")) result += "/";
|
|
204
|
-
}
|
|
205
|
-
}
|
|
168
|
+
let result = effectiveParams
|
|
169
|
+
? substitutePatternParams(pattern, effectiveParams, name)
|
|
170
|
+
: pattern;
|
|
206
171
|
|
|
207
172
|
// Append search params as query string
|
|
208
173
|
if (search) {
|
|
@@ -281,8 +246,12 @@ export function createHandlerContext<TEnv>(
|
|
|
281
246
|
search: searchSchema ? resolvedSearchParams : {},
|
|
282
247
|
pathname,
|
|
283
248
|
url,
|
|
284
|
-
originalUrl: new URL(request.url),
|
|
249
|
+
originalUrl: requestContext?.originalUrl ?? new URL(request.url),
|
|
285
250
|
env: bindings,
|
|
251
|
+
waitUntil: requestContext
|
|
252
|
+
? requestContext.waitUntil.bind(requestContext)
|
|
253
|
+
: fireAndForgetWaitUntil,
|
|
254
|
+
executionContext: requestContext?.executionContext,
|
|
286
255
|
_variables: variables,
|
|
287
256
|
get: ((keyOrVar: any) => {
|
|
288
257
|
// Read-time guard: non-cacheable var inside cache() → throw.
|
|
@@ -387,6 +356,12 @@ export function createPrerenderContext<TEnv>(
|
|
|
387
356
|
"Configure buildEnv in your rango() plugin options to enable build-time env access.",
|
|
388
357
|
);
|
|
389
358
|
},
|
|
359
|
+
// Build-time prerender has no live request. waitUntil is a true no-op
|
|
360
|
+
// (running fn() here would fire side effects during build, which is
|
|
361
|
+
// incorrect — these are meant to outlive the live response).
|
|
362
|
+
// executionContext is absent for the same reason.
|
|
363
|
+
waitUntil: () => {},
|
|
364
|
+
executionContext: undefined,
|
|
390
365
|
_variables: variables,
|
|
391
366
|
get: ((keyOrVar: any) => contextGet(variables, keyOrVar)) as any,
|
|
392
367
|
set: ((keyOrVar: any, value: any) => {
|
|
@@ -476,6 +451,11 @@ export function createStaticContext<TEnv>(
|
|
|
476
451
|
"Configure buildEnv in your rango() plugin options to enable build-time env access.",
|
|
477
452
|
);
|
|
478
453
|
},
|
|
454
|
+
// Static() handlers have no live request. waitUntil is a true no-op
|
|
455
|
+
// (running fn() here would fire side effects during build, which is
|
|
456
|
+
// incorrect). executionContext is absent for the same reason.
|
|
457
|
+
waitUntil: () => {},
|
|
458
|
+
executionContext: undefined,
|
|
479
459
|
_variables: variables,
|
|
480
460
|
get: ((keyOrVar: any) => contextGet(variables, keyOrVar)) as any,
|
|
481
461
|
set: ((keyOrVar: any, value: any) => {
|
|
@@ -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>) => {
|
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";
|
|
@@ -550,6 +550,7 @@ export async function matchError<TEnv>(
|
|
|
550
550
|
segments: [errorSegment],
|
|
551
551
|
matched: matchedIds,
|
|
552
552
|
diff: [errorSegment.id],
|
|
553
|
+
resolvedIds: [errorSegment.id],
|
|
553
554
|
params: matched.params,
|
|
554
555
|
};
|
|
555
556
|
}
|
|
@@ -270,10 +270,29 @@ export function buildMatchResult<TEnv>(
|
|
|
270
270
|
const matchedIds =
|
|
271
271
|
removedIds.size > 0 ? allIds.filter((id) => !removedIds.has(id)) : allIds;
|
|
272
272
|
|
|
273
|
+
// resolvedIds: every segment whose handler actually ran this request.
|
|
274
|
+
// For full-match every segment is fresh; for partial-match we filter by
|
|
275
|
+
// the internal `_handlerRan` flag set in revalidation.ts. Drives the
|
|
276
|
+
// client's handle-bucket cleanup — a slot that re-resolved and pushed
|
|
277
|
+
// nothing must have its previous handle data cleared, but `diff` won't
|
|
278
|
+
// carry it because the segment payload skips null-component cached
|
|
279
|
+
// segments to save bytes.
|
|
280
|
+
const resolvedIds = ctx.isFullMatch
|
|
281
|
+
? allSegments.map((s) => s.id)
|
|
282
|
+
: allSegments.filter((s) => s._handlerRan).map((s) => s.id);
|
|
283
|
+
|
|
284
|
+
// Strip internal-only fields from the segments going on the wire.
|
|
285
|
+
const cleanedSegments = dedupedSegments.map((s) => {
|
|
286
|
+
if (s._handlerRan === undefined) return s;
|
|
287
|
+
const { _handlerRan: _drop, ...rest } = s;
|
|
288
|
+
return rest as ResolvedSegment;
|
|
289
|
+
});
|
|
290
|
+
|
|
273
291
|
return {
|
|
274
|
-
segments:
|
|
292
|
+
segments: cleanedSegments,
|
|
275
293
|
matched: matchedIds,
|
|
276
|
-
diff:
|
|
294
|
+
diff: cleanedSegments.map((s) => s.id),
|
|
295
|
+
resolvedIds,
|
|
277
296
|
params: ctx.matched.params,
|
|
278
297
|
routeName: ctx.routeKey,
|
|
279
298
|
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
|
|
@@ -52,33 +53,15 @@ export interface CookieOptions {
|
|
|
52
53
|
* Context passed to middleware
|
|
53
54
|
*
|
|
54
55
|
* @template TEnv - Environment type (bindings, variables) - defaults to any for internal flexibility
|
|
55
|
-
* @template TParams - URL params type (typed for route middleware,
|
|
56
|
+
* @template TParams - URL params type (typed for route middleware,
|
|
57
|
+
* `Record<string, string | undefined>` for global middleware — absent
|
|
58
|
+
* optional segments are omitted from the params record at runtime, so
|
|
59
|
+
* the index signature must include `undefined`)
|
|
56
60
|
*/
|
|
57
61
|
export interface MiddlewareContext<
|
|
58
62
|
TEnv = any,
|
|
59
|
-
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
|
-
|
|
63
|
+
TParams = Record<string, string | undefined>,
|
|
64
|
+
> extends RequestScope<TEnv> {
|
|
82
65
|
/** URL params extracted from route/middleware pattern */
|
|
83
66
|
params: TParams;
|
|
84
67
|
|
|
@@ -169,7 +152,10 @@ export interface MiddlewareContext<
|
|
|
169
152
|
* router.use((ctx, next) => {...}) // ctx is typed from router's TEnv
|
|
170
153
|
* ```
|
|
171
154
|
*/
|
|
172
|
-
export type MiddlewareFn<
|
|
155
|
+
export type MiddlewareFn<
|
|
156
|
+
TEnv = any,
|
|
157
|
+
TParams = Record<string, string | undefined>,
|
|
158
|
+
> = (
|
|
173
159
|
ctx: MiddlewareContext<TEnv, TParams>,
|
|
174
160
|
next: () => Promise<Response>,
|
|
175
161
|
) => Response | void | Promise<Response | void>;
|
|
@@ -216,5 +202,8 @@ export interface MiddlewareCollectableEntry {
|
|
|
216
202
|
*/
|
|
217
203
|
export interface CollectedMiddleware {
|
|
218
204
|
handler: MiddlewareFn<any, any>;
|
|
205
|
+
// Internal shape only. The user-facing `MiddlewareContext.params` is
|
|
206
|
+
// typed `Record<string, string | undefined>` to reflect that absent
|
|
207
|
+
// optional segments are omitted from the params record at runtime.
|
|
219
208
|
params: Record<string, string>;
|
|
220
209
|
}
|
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,
|
|
@@ -426,8 +447,16 @@ export async function executeMiddleware<TEnv>(
|
|
|
426
447
|
try {
|
|
427
448
|
result = await entry.handler(ctx, wrappedNext);
|
|
428
449
|
} catch (error) {
|
|
429
|
-
|
|
430
|
-
|
|
450
|
+
// Thrown Response is short-circuit control flow, not an error.
|
|
451
|
+
// Fall through to the `if (result instanceof Response)` branch below
|
|
452
|
+
// so stub headers and request-context cookies merge as they do for
|
|
453
|
+
// an explicit `return new Response(...)`. Real errors propagate.
|
|
454
|
+
if (error instanceof Response) {
|
|
455
|
+
result = error;
|
|
456
|
+
} else {
|
|
457
|
+
finishMiddleware();
|
|
458
|
+
throw error;
|
|
459
|
+
}
|
|
431
460
|
}
|
|
432
461
|
finishMiddleware();
|
|
433
462
|
|
|
@@ -451,6 +480,10 @@ export async function executeMiddleware<TEnv>(
|
|
|
451
480
|
// RequestContext stub headers (from ctx.setCookie) into the
|
|
452
481
|
// returned Response so they are not lost.
|
|
453
482
|
if (result instanceof Response) {
|
|
483
|
+
if (isWebSocketUpgradeResponse(result)) {
|
|
484
|
+
responseHolder.response = result;
|
|
485
|
+
return result;
|
|
486
|
+
}
|
|
454
487
|
const mergedHeaders = new Headers(result.headers);
|
|
455
488
|
stubResponse.headers.forEach((value, name) => {
|
|
456
489
|
if (name.toLowerCase() === "set-cookie") {
|
|
@@ -527,8 +560,11 @@ export async function executeMiddleware<TEnv>(
|
|
|
527
560
|
// last merge point (e.g. cookies().set() called after await next()).
|
|
528
561
|
// The reqCtx stub may have already been partially merged during finalHandler
|
|
529
562
|
// or early-return paths; only append *new* Set-Cookie entries to avoid dupes.
|
|
563
|
+
//
|
|
564
|
+
// Skip for upgrade responses: upgrade headers are semantically immutable and
|
|
565
|
+
// set-cookie on an upgrade is not meaningful.
|
|
530
566
|
const reqCtx = _getRequestContext();
|
|
531
|
-
if (reqCtx) {
|
|
567
|
+
if (reqCtx && !isWebSocketUpgradeResponse(finalResponse)) {
|
|
532
568
|
const stubCookies = reqCtx.res.headers.getSetCookie();
|
|
533
569
|
if (stubCookies.length > 0) {
|
|
534
570
|
const existingCookies = new Set(finalResponse.headers.getSetCookie());
|
|
@@ -613,7 +649,18 @@ export async function executeInterceptMiddleware<TEnv>(
|
|
|
613
649
|
return next();
|
|
614
650
|
};
|
|
615
651
|
|
|
616
|
-
|
|
652
|
+
let result: Response | void;
|
|
653
|
+
try {
|
|
654
|
+
result = await middleware(ctx, guardedNext);
|
|
655
|
+
} catch (error) {
|
|
656
|
+
// Thrown Response is short-circuit control flow, parity with the
|
|
657
|
+
// explicit-return path below. Real errors propagate.
|
|
658
|
+
if (error instanceof Response) {
|
|
659
|
+
result = error;
|
|
660
|
+
} else {
|
|
661
|
+
throw error;
|
|
662
|
+
}
|
|
663
|
+
}
|
|
617
664
|
|
|
618
665
|
if (result instanceof Response) {
|
|
619
666
|
earlyResponse = result;
|