@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.
Files changed (100) hide show
  1. package/README.md +112 -17
  2. package/dist/vite/index.js +1197 -454
  3. package/package.json +4 -2
  4. package/skills/breadcrumbs/SKILL.md +3 -1
  5. package/skills/handler-use/SKILL.md +2 -0
  6. package/skills/hooks/SKILL.md +30 -2
  7. package/skills/i18n/SKILL.md +276 -0
  8. package/skills/intercept/SKILL.md +25 -0
  9. package/skills/layout/SKILL.md +2 -0
  10. package/skills/links/SKILL.md +234 -16
  11. package/skills/loader/SKILL.md +70 -3
  12. package/skills/middleware/SKILL.md +2 -0
  13. package/skills/migrate-nextjs/SKILL.md +3 -1
  14. package/skills/migrate-react-router/SKILL.md +4 -0
  15. package/skills/parallel/SKILL.md +9 -0
  16. package/skills/rango/SKILL.md +2 -0
  17. package/skills/response-routes/SKILL.md +8 -0
  18. package/skills/route/SKILL.md +24 -0
  19. package/skills/server-actions/SKILL.md +739 -0
  20. package/skills/streams-and-websockets/SKILL.md +283 -0
  21. package/skills/typesafety/SKILL.md +9 -1
  22. package/skills/view-transitions/SKILL.md +212 -0
  23. package/src/browser/app-shell.ts +52 -0
  24. package/src/browser/event-controller.ts +44 -4
  25. package/src/browser/navigation-bridge.ts +113 -6
  26. package/src/browser/navigation-store.ts +25 -1
  27. package/src/browser/partial-update.ts +44 -10
  28. package/src/browser/prefetch/cache.ts +16 -0
  29. package/src/browser/rango-state.ts +53 -13
  30. package/src/browser/react/NavigationProvider.tsx +64 -16
  31. package/src/browser/react/filter-segment-order.ts +51 -7
  32. package/src/browser/react/index.ts +3 -0
  33. package/src/browser/react/use-params.ts +8 -5
  34. package/src/browser/react/use-reverse.ts +99 -0
  35. package/src/browser/react/use-router.ts +8 -1
  36. package/src/browser/react/use-segments.ts +11 -8
  37. package/src/browser/rsc-router.tsx +34 -6
  38. package/src/browser/types.ts +19 -0
  39. package/src/build/route-trie.ts +2 -1
  40. package/src/cache/cf/cf-cache-store.ts +5 -7
  41. package/src/client.rsc.tsx +3 -0
  42. package/src/client.tsx +5 -1
  43. package/src/href-client.ts +4 -1
  44. package/src/index.rsc.ts +3 -0
  45. package/src/index.ts +3 -0
  46. package/src/outlet-context.ts +1 -1
  47. package/src/response-utils.ts +28 -0
  48. package/src/reverse.ts +62 -39
  49. package/src/route-definition/dsl-helpers.ts +16 -3
  50. package/src/route-definition/helpers-types.ts +6 -1
  51. package/src/route-definition/resolve-handler-use.ts +6 -0
  52. package/src/router/handler-context.ts +21 -41
  53. package/src/router/lazy-includes.ts +1 -1
  54. package/src/router/loader-resolution.ts +3 -0
  55. package/src/router/match-api.ts +4 -3
  56. package/src/router/match-handlers.ts +1 -0
  57. package/src/router/match-result.ts +21 -2
  58. package/src/router/middleware-types.ts +14 -25
  59. package/src/router/middleware.ts +54 -7
  60. package/src/router/pattern-matching.ts +101 -17
  61. package/src/router/revalidation.ts +15 -1
  62. package/src/router/segment-resolution/fresh.ts +8 -0
  63. package/src/router/segment-resolution/revalidation.ts +128 -100
  64. package/src/router/substitute-pattern-params.ts +56 -0
  65. package/src/router/trie-matching.ts +18 -13
  66. package/src/router/url-params.ts +49 -0
  67. package/src/router.ts +1 -2
  68. package/src/rsc/handler.ts +8 -4
  69. package/src/rsc/progressive-enhancement.ts +2 -0
  70. package/src/rsc/response-route-handler.ts +11 -10
  71. package/src/rsc/rsc-rendering.ts +3 -0
  72. package/src/rsc/server-action.ts +2 -0
  73. package/src/rsc/types.ts +6 -0
  74. package/src/segment-system.tsx +60 -9
  75. package/src/server/request-context.ts +10 -42
  76. package/src/ssr/index.tsx +5 -1
  77. package/src/types/handler-context.ts +12 -39
  78. package/src/types/loader-types.ts +5 -6
  79. package/src/types/request-scope.ts +126 -0
  80. package/src/types/segments.ts +17 -0
  81. package/src/urls/response-types.ts +2 -10
  82. package/src/vite/debug.ts +184 -0
  83. package/src/vite/discovery/discover-routers.ts +31 -3
  84. package/src/vite/discovery/gate-state.ts +171 -0
  85. package/src/vite/discovery/prerender-collection.ts +48 -1
  86. package/src/vite/discovery/self-gen-tracking.ts +27 -1
  87. package/src/vite/plugins/cjs-to-esm.ts +5 -0
  88. package/src/vite/plugins/client-ref-dedup.ts +16 -0
  89. package/src/vite/plugins/client-ref-hashing.ts +16 -4
  90. package/src/vite/plugins/expose-action-id.ts +52 -28
  91. package/src/vite/plugins/expose-ids/router-transform.ts +20 -3
  92. package/src/vite/plugins/expose-internal-ids.ts +516 -486
  93. package/src/vite/plugins/performance-tracks.ts +17 -9
  94. package/src/vite/plugins/use-cache-transform.ts +56 -43
  95. package/src/vite/plugins/version-injector.ts +37 -11
  96. package/src/vite/rango.ts +49 -14
  97. package/src/vite/router-discovery.ts +498 -52
  98. package/src/vite/utils/banner.ts +1 -1
  99. package/src/vite/utils/package-resolution.ts +41 -1
  100. 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 = pattern;
305
- if (params) {
306
- // Replace :param placeholders with actual values
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
- // If use() callback provided, run it to collect revalidation rules and cache config
754
- if (use && typeof use === "function") {
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 = use()?.flat(3);
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 that returns boolean (hard) or { defaultShouldRevalidate } (soft)
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
- // Substitute params (strip constraint and optional syntax: :param(a|b)? -> value)
169
- // Optional params (:param?) are omitted when not provided
170
- if (effectiveParams) {
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) => {
@@ -1,7 +1,7 @@
1
1
  import { registerRouteMap } from "../route-map-builder.js";
2
2
  import { extractStaticPrefix } from "./pattern-matching.js";
3
3
  import {
4
- EntryData,
4
+ type EntryData,
5
5
  RSCRouterContext,
6
6
  runWithPrefixes,
7
7
  getIsolatedLazyParent,
@@ -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>) => {
@@ -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
  }
@@ -196,6 +196,7 @@ export function createMatchHandlers<TEnv = any>(
196
196
  segments: [],
197
197
  matched: [],
198
198
  diff: [],
199
+ resolvedIds: [],
199
200
  params: {},
200
201
  redirect: result.redirectUrl,
201
202
  };
@@ -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: dedupedSegments,
292
+ segments: cleanedSegments,
275
293
  matched: matchedIds,
276
- diff: dedupedSegments.map((s) => s.id),
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, Record<string, string> for global 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<TEnv = any, TParams = Record<string, string>> = (
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
  }
@@ -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
- finishMiddleware();
430
- throw error;
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
- const result = await middleware(ctx, guardedNext);
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;