@rangojs/router 0.0.0-experimental.131 → 0.0.0-experimental.132

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 (51) hide show
  1. package/dist/bin/rango.js +69 -24
  2. package/dist/vite/index.js +182 -41
  3. package/package.json +6 -3
  4. package/src/browser/connection-warmup.ts +134 -0
  5. package/src/browser/event-controller.ts +5 -4
  6. package/src/browser/partial-update.ts +32 -16
  7. package/src/browser/react/NavigationProvider.tsx +6 -83
  8. package/src/browser/react/filter-segment-order.ts +17 -0
  9. package/src/browser/react/use-link-status.ts +10 -2
  10. package/src/browser/react/use-navigation.ts +10 -2
  11. package/src/build/route-types/ast-route-extraction.ts +15 -8
  12. package/src/build/route-types/include-resolution.ts +109 -21
  13. package/src/build/route-types/per-module-writer.ts +15 -2
  14. package/src/cache/cache-key-utils.ts +29 -13
  15. package/src/cache/cf/cf-cache-store.ts +129 -5
  16. package/src/decode-loader-results.ts +11 -1
  17. package/src/encode-kv.ts +49 -0
  18. package/src/handles/meta.ts +5 -1
  19. package/src/host/cookie-handler.ts +2 -21
  20. package/src/prerender/param-hash.ts +6 -5
  21. package/src/regex-escape.ts +8 -0
  22. package/src/route-definition/dsl-helpers.ts +6 -2
  23. package/src/router/error-handling.ts +32 -1
  24. package/src/router/handler-context.ts +6 -1
  25. package/src/router/instrument.ts +14 -10
  26. package/src/router/intercept-resolution.ts +16 -1
  27. package/src/router/loader-resolution.ts +49 -19
  28. package/src/router/match-middleware/background-revalidation.ts +6 -0
  29. package/src/router/match-middleware/cache-store.ts +6 -0
  30. package/src/router/middleware.ts +67 -27
  31. package/src/router/pattern-matching.ts +3 -9
  32. package/src/router/revalidation.ts +65 -23
  33. package/src/router/router-context.ts +1 -0
  34. package/src/router/router-options.ts +3 -3
  35. package/src/router/segment-resolution/loader-cache.ts +13 -0
  36. package/src/router/segment-wrappers.ts +3 -0
  37. package/src/router/trie-matching.ts +74 -20
  38. package/src/router.ts +2 -2
  39. package/src/rsc/progressive-enhancement.ts +20 -0
  40. package/src/rsc/server-action.ts +124 -47
  41. package/src/search-params.ts +8 -6
  42. package/src/segment-system.tsx +7 -1
  43. package/src/server/cookie-parse.ts +32 -0
  44. package/src/server/handle-store.ts +14 -14
  45. package/src/server/request-context.ts +5 -26
  46. package/src/ssr/index.tsx +5 -4
  47. package/src/testing/render-handler.ts +11 -0
  48. package/src/vite/plugins/expose-id-utils.ts +77 -2
  49. package/src/vite/plugins/expose-ids/export-analysis.ts +30 -5
  50. package/src/vite/plugins/expose-ids/router-transform.ts +82 -12
  51. package/src/vite/utils/prerender-utils.ts +1 -3
@@ -109,16 +109,40 @@ export function wrapLoaderWithErrorHandling<T>(
109
109
  };
110
110
  }
111
111
 
112
- // Render fallback on server
112
+ // Render fallback on server. The user ErrorBoundaryHandler may throw
113
+ // synchronously; if it does we must NOT let that rejection escape — the
114
+ // wrapped LoaderDataResult promise is contracted to never reject (see
115
+ // segment-resolution/fresh.ts `await Promise.all(...wrapped)`), and a
116
+ // rejection here would collapse the whole entry and discard healthy
117
+ // sibling loader data. On a fallback-render throw, fall back to the
118
+ // no-boundary result (fallback: null) so the client throws the ORIGINAL
119
+ // error, and the wrapped promise still resolves to a LoaderDataResult.
113
120
  let renderedFallback: ReactNode;
114
- if (typeof fallback === "function") {
115
- // ErrorBoundaryHandler - call with error info
116
- const props: ErrorBoundaryFallbackProps = {
121
+ try {
122
+ if (typeof fallback === "function") {
123
+ // ErrorBoundaryHandler - call with error info
124
+ const props: ErrorBoundaryFallbackProps = {
125
+ error: errorInfo,
126
+ };
127
+ renderedFallback = fallback(props);
128
+ } else {
129
+ renderedFallback = fallback;
130
+ }
131
+ } catch (fallbackError) {
132
+ debugLog("loader", "error boundary fallback render threw", {
133
+ segmentId,
134
+ message: errorInfo.message,
135
+ fallbackError:
136
+ fallbackError instanceof Error
137
+ ? fallbackError.message
138
+ : String(fallbackError),
139
+ });
140
+ return {
141
+ __loaderResult: true,
142
+ ok: false,
117
143
  error: errorInfo,
144
+ fallback: null,
118
145
  };
119
- renderedFallback = fallback(props);
120
- } else {
121
- renderedFallback = fallback;
122
146
  }
123
147
 
124
148
  debugLog("loader", "loader error wrapped with boundary fallback", {
@@ -542,18 +566,21 @@ export function setupBuildUse<TEnv>(ctx: HandlerContext<any, TEnv>): void {
542
566
  );
543
567
  }
544
568
 
545
- return (
546
- dataOrFn: unknown | Promise<unknown> | (() => Promise<unknown>),
547
- ) => {
548
- if (!store) return;
569
+ // Wrap with withDefer so ctx.use(Handle).defer(...) works on the build /
570
+ // prerender path, matching production setupLoaderAccess. Without it a
571
+ // prerender handler calling .defer() throws "defer is not a function".
572
+ return withDefer(
573
+ (dataOrFn: unknown | Promise<unknown> | (() => Promise<unknown>)) => {
574
+ if (!store) return;
549
575
 
550
- const valueOrPromise =
551
- typeof dataOrFn === "function"
552
- ? (dataOrFn as () => Promise<unknown>)()
553
- : dataOrFn;
576
+ const valueOrPromise =
577
+ typeof dataOrFn === "function"
578
+ ? (dataOrFn as () => Promise<unknown>)()
579
+ : dataOrFn;
554
580
 
555
- store.push(handle.$$id, segmentId, valueOrPromise);
556
- };
581
+ store.push(handle.$$id, segmentId, valueOrPromise);
582
+ },
583
+ );
557
584
  }
558
585
 
559
586
  // Loader case: not available during pre-rendering
@@ -581,8 +608,11 @@ export function setupLoaderAccessSilent<TEnv>(
581
608
 
582
609
  ctx.use = ((item: LoaderDefinition<any, any> | Handle<any, any>) => {
583
610
  if (isHandle(item)) {
584
- // Silent mode - return a no-op so handle data is not pushed during caching
585
- return (_dataOrFn: unknown) => {};
611
+ // Silent mode - return a no-op so handle data is not pushed during caching.
612
+ // Wrap with withDefer so ctx.use(Handle).defer(...) still resolves to a
613
+ // callable resolver (also a no-op here), matching production's push shape
614
+ // instead of throwing "defer is not a function".
615
+ return withDefer((_dataOrFn: unknown) => {});
586
616
  }
587
617
 
588
618
  return useLoader(item as LoaderDefinition<any, any>, null);
@@ -203,6 +203,12 @@ export function withBackgroundRevalidation<TEnv>(
203
203
  ctx.matched.params,
204
204
  freshHandlerContext,
205
205
  true,
206
+ undefined,
207
+ // Skip intercept middleware: this is a post-response background
208
+ // re-render to refresh a stale cached route. The foreground
209
+ // already ran the middleware; re-running it would double its side
210
+ // effects and a short-circuit Response would abort the write.
211
+ { skipMiddleware: true },
206
212
  ),
207
213
  );
208
214
  }
@@ -243,6 +243,12 @@ export function withCacheStore<TEnv>(
243
243
  proactiveHandlerContext,
244
244
  true, // belongsToRoute
245
245
  // No revalidationContext = render fresh
246
+ undefined,
247
+ // Skip intercept middleware: the foreground already ran it
248
+ // before the response was sent. Re-running here (post-response,
249
+ // background) would fire side effects twice and a short-circuit
250
+ // Response would silently abort this cache write.
251
+ { skipMiddleware: true },
246
252
  ),
247
253
  );
248
254
  }
@@ -1,6 +1,8 @@
1
1
  /// <reference types="vite/types/importMeta.d.ts" />
2
2
 
3
3
  import { contextGet, contextSet } from "../context-var.js";
4
+ import { escapeRegExp } from "../regex-escape.js";
5
+ import { parsePattern as parseRoutePattern } from "./pattern-matching.js";
4
6
  import { safeDecodeURIComponent } from "./url-params.js";
5
7
  import { fireAndForgetWaitUntil } from "../types/request-scope.js";
6
8
  import type {
@@ -48,7 +50,43 @@ function getMiddlewareMetricLabel<TEnv>(
48
50
  return `middleware:${scope}#${ordinal + 1}`;
49
51
  }
50
52
 
51
- export function parsePattern(pattern: string): {
53
+ /**
54
+ * Compile a middleware scope pattern to a regex + param names.
55
+ *
56
+ * Middleware scopes reuse the route pattern parser (`parsePattern` from
57
+ * pattern-matching.ts), so they support the same param forms as routes —
58
+ * optional (`:x?`), constrained (`:x(en|gb)`), and suffix (`:x.html`) — in
59
+ * addition to the trailing-`*` wildcard middleware relies on. Before this
60
+ * unification the middleware-side parser handled only static, bare `:param`,
61
+ * and trailing `*`, so e.g. `router.use("/:locale(en|gb)/*", mw)` silently
62
+ * named the param "locale(en|gb)" and never enforced the constraint.
63
+ *
64
+ * Middleware matching semantics deliberately differ from route matching, so we
65
+ * emit the regex here rather than route through `compilePattern`:
66
+ * - `*` alone matches every path (`/^.*$/`).
67
+ * - A trailing `*` segment is an OPTIONAL subtree match (`(?:/.*)?`): `/admin/*`
68
+ * matches `/admin`, `/admin/`, and `/admin/users`. It contributes no param
69
+ * name (unlike route wildcards, which capture `*`).
70
+ * - A NON-trailing `*` is also OPTIONAL (`(?:/.*)?`), matching zero-or-more
71
+ * intermediate segments: `/a/<star>/b` matches both `/a/b` and `/a/x/b`. This
72
+ * mirrors the pre-unification parser, which compiled every `*` part as
73
+ * optional regardless of position.
74
+ * - A pattern without a trailing `*` tolerates a trailing slash (`/?$`).
75
+ * - Constraints are baked into the regex as an alternation so `matchMiddleware`
76
+ * (a bare `regex.test`) enforces them without extra validation. Constraint
77
+ * values are matched against the raw (still URL-encoded) path segment, which
78
+ * matches the pre-unification middleware behavior (it never decoded for
79
+ * matching); the constraint string is regex-escaped so values like `en.gb`
80
+ * are treated literally.
81
+ *
82
+ * The route segment parser only recognizes `/`-prefixed segments, but the
83
+ * pre-unification middleware parser split on `/` and dropped empty parts, so a
84
+ * leading slash was irrelevant: `use("admin/*")` and `use("/admin/*")` scoped
85
+ * identically. Normalize a non-`*` pattern to have a leading slash before
86
+ * parsing so that behavior is preserved (without it, `parseRoutePattern("admin/*")`
87
+ * drops the static `admin` and the scope explodes to every path).
88
+ */
89
+ export function compileMiddlewarePattern(pattern: string): {
52
90
  regex: RegExp;
53
91
  paramNames: string[];
54
92
  } {
@@ -56,45 +94,47 @@ export function parsePattern(pattern: string): {
56
94
  return { regex: /^.*$/, paramNames: [] };
57
95
  }
58
96
 
97
+ const normalizedPattern = pattern.startsWith("/") ? pattern : `/${pattern}`;
98
+ const segments = parseRoutePattern(normalizedPattern);
59
99
  const paramNames: string[] = [];
60
100
  let regexStr = "^";
101
+ let hasTrailingWildcard = false;
61
102
 
62
- const parts = pattern.split("/").filter(Boolean);
63
-
64
- for (let i = 0; i < parts.length; i++) {
65
- const part = parts[i];
103
+ for (let i = 0; i < segments.length; i++) {
104
+ const segment = segments[i];
66
105
 
67
- if (part === "*") {
68
- // Wildcard - match rest of path
106
+ if (segment.type === "wildcard") {
107
+ // Optional subtree match (parity with the original middleware parser,
108
+ // which compiled every `*` as `(?:/.*)?`). A trailing `*` matches the
109
+ // subtree; a non-trailing `*` matches zero-or-more intermediate segments,
110
+ // so `/a/<star>/b` still matches `/a/b`.
69
111
  regexStr += "(?:/.*)?";
70
- } else if (part.startsWith(":")) {
71
- // Param
72
- const paramName = part.slice(1);
73
- paramNames.push(paramName);
74
- regexStr += "/([^/]+)";
112
+ if (i === segments.length - 1) {
113
+ hasTrailingWildcard = true;
114
+ }
115
+ } else if (segment.type === "param") {
116
+ paramNames.push(segment.value);
117
+ const suffixPattern = segment.suffix ? escapeRegExp(segment.suffix) : "";
118
+ const valuePattern = segment.constraint
119
+ ? `(${segment.constraint.map(escapeRegExp).join("|")})`
120
+ : "([^/]+)";
121
+ if (segment.optional) {
122
+ regexStr += `(?:/${valuePattern}${suffixPattern})?`;
123
+ } else {
124
+ regexStr += `/${valuePattern}${suffixPattern}`;
125
+ }
75
126
  } else {
76
- // Literal
77
- regexStr += "/" + escapeRegex(part);
127
+ // Static literal
128
+ regexStr += "/" + escapeRegExp(segment.value);
78
129
  }
79
130
  }
80
131
 
81
- // If pattern doesn't end with *, match exact or with trailing segments
82
- if (!pattern.endsWith("*")) {
83
- regexStr += "/?$";
84
- } else {
85
- regexStr += "$";
86
- }
132
+ // Without a trailing `*`, match exactly with an optional trailing slash.
133
+ regexStr += hasTrailingWildcard ? "$" : "/?$";
87
134
 
88
135
  return { regex: new RegExp(regexStr), paramNames };
89
136
  }
90
137
 
91
- /**
92
- * Escape special regex characters
93
- */
94
- function escapeRegex(str: string): string {
95
- return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
96
- }
97
-
98
138
  /**
99
139
  * Extract params from a pathname using a pattern's regex and param names.
100
140
  *
@@ -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 { escapeRegExp } from "../regex-escape.js";
10
11
  import { safeDecodeURIComponent } from "./url-params.js";
11
12
 
12
13
  /**
@@ -151,7 +152,7 @@ export function compilePattern(pattern: string): CompiledPattern {
151
152
  regexPattern += "/(.*)";
152
153
  } else if (segment.type === "param") {
153
154
  paramNames.push(segment.value);
154
- const suffixPattern = segment.suffix ? escapeRegex(segment.suffix) : "";
155
+ const suffixPattern = segment.suffix ? escapeRegExp(segment.suffix) : "";
155
156
  // Constrained params capture anything here; the allowed values are
156
157
  // checked post-decode in findMatch so URL-encoded constraint values
157
158
  // (e.g. `:lang(en GB)` via `/en%20GB`) still match.
@@ -169,7 +170,7 @@ export function compilePattern(pattern: string): CompiledPattern {
169
170
  }
170
171
  } else {
171
172
  // Static segment
172
- regexPattern += `/${escapeRegex(segment.value)}`;
173
+ regexPattern += `/${escapeRegExp(segment.value)}`;
173
174
  }
174
175
  }
175
176
 
@@ -229,13 +230,6 @@ function satisfiesConstraints(
229
230
  return true;
230
231
  }
231
232
 
232
- /**
233
- * Escape special regex characters in a string
234
- */
235
- function escapeRegex(str: string): string {
236
- return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
237
- }
238
-
239
233
  /**
240
234
  * Build the named-params record from a regex match. Optional segments that
241
235
  * didn't capture leave the corresponding group `undefined`; we skip those
@@ -231,29 +231,71 @@ export async function evaluateRevalidation<TEnv>(
231
231
  : undefined;
232
232
 
233
233
  for (const { name, fn } of revalidations) {
234
- const result = fn({
235
- currentParams: prevSegment?.params || prevParams, // Use segment params if available, else route params
236
- currentUrl: prevUrl,
237
- nextParams,
238
- nextUrl,
239
- defaultShouldRevalidate: currentSuggestion,
240
- context,
241
- // Segment metadata (which segment is being evaluated)
242
- segmentType: segment.type,
243
- layoutName: segment.layoutName,
244
- slotName: segment.slot,
245
- // Action context (only populated when triggered by server action)
246
- actionId: actionContext?.actionId,
247
- isAction: makeIsAction(actionContext?.actionId),
248
- actionUrl: actionContext?.actionUrl,
249
- actionResult: actionContext?.actionResult,
250
- formData: actionContext?.formData,
251
- method: request.method,
252
- routeName: toRouteName,
253
- fromRouteName,
254
- toRouteName,
255
- stale,
256
- });
234
+ let result: any;
235
+ try {
236
+ result = fn({
237
+ currentParams: prevSegment?.params || prevParams, // Use segment params if available, else route params
238
+ currentUrl: prevUrl,
239
+ nextParams,
240
+ nextUrl,
241
+ defaultShouldRevalidate: currentSuggestion,
242
+ context,
243
+ // Segment metadata (which segment is being evaluated)
244
+ segmentType: segment.type,
245
+ layoutName: segment.layoutName,
246
+ slotName: segment.slot,
247
+ // Action context (only populated when triggered by server action)
248
+ actionId: actionContext?.actionId,
249
+ isAction: makeIsAction(actionContext?.actionId),
250
+ actionUrl: actionContext?.actionUrl,
251
+ actionResult: actionContext?.actionResult,
252
+ formData: actionContext?.formData,
253
+ method: request.method,
254
+ routeName: toRouteName,
255
+ fromRouteName,
256
+ toRouteName,
257
+ stale,
258
+ });
259
+ } catch (error) {
260
+ // A thrown Response is control flow (e.g. `throw redirect(...)`), not a
261
+ // failure: re-throw it so the handler chokepoint (match-handlers.ts)
262
+ // turns it into the intended redirect/response. This mirrors how that
263
+ // catch special-cases `error instanceof Response`.
264
+ if (error instanceof Response) throw error;
265
+ // Fail open for genuine errors: a buggy user revalidate fn must not
266
+ // collapse the whole entry's loader batch into a failed partial render.
267
+ // Mirror the dynamic-tags fail-open in cache/cache-policy.ts: log and
268
+ // defer to the current default decision, leaving currentSuggestion
269
+ // unchanged. TODO: route through callOnError(phase "revalidation") once
270
+ // evaluateRevalidation is given the onError seam (today the error only
271
+ // reaches onError via the entry-collapse path in match-handlers.ts).
272
+ console.error(
273
+ `[revalidate] "${name}" threw for segment "${segment.id}"; using default decision:`,
274
+ error,
275
+ );
276
+ continue;
277
+ }
278
+
279
+ // The revalidate fn contract (handler-context.ts) is SYNCHRONOUS: it must
280
+ // return a boolean, a { defaultShouldRevalidate } object, or null/undefined.
281
+ // A Promise-returning (async) fn matches none of the decision branches below
282
+ // and silently falls through keeping the current default — a hard-to-find
283
+ // misuse. We do NOT await it (that would change the sync contract); instead
284
+ // we surface it as a dev-mode warning so the silent drop is diagnosable.
285
+ // Mirrors defer.ts: gated to dev, stripped from production builds.
286
+ if (
287
+ process.env.NODE_ENV !== "production" &&
288
+ result != null &&
289
+ typeof (result as { then?: unknown }).then === "function"
290
+ ) {
291
+ console.warn(
292
+ `[rango] revalidate fn "${name}" returned a Promise; revalidate ` +
293
+ `functions must be synchronous (return a boolean, ` +
294
+ `{ defaultShouldRevalidate }, or null/undefined). The async result ` +
295
+ `was IGNORED and the default (${currentSuggestion}) was kept. ` +
296
+ `Move async work into a loader instead.`,
297
+ );
298
+ }
257
299
 
258
300
  if (typeof result === "boolean") {
259
301
  debugLog("revalidation", "hard decision", {
@@ -154,6 +154,7 @@ export interface RouterContext<TEnv = any> {
154
154
  handlerContext: HandlerContext<any, TEnv>,
155
155
  belongsToRoute: boolean,
156
156
  revalidationContext?: RevalidationContext,
157
+ options?: { skipMiddleware?: boolean },
157
158
  ) => Promise<ResolvedSegment[]>;
158
159
 
159
160
  collectWithMarkers?: <T>(
@@ -579,9 +579,9 @@ export interface RangoOptions<TEnv = any> {
579
579
  * start/end/error, loader start/end/error, handler errors, cache decisions,
580
580
  * revalidation decisions, timeouts, origin rejections.
581
581
  *
582
- * This is the EVENT surface. Phase-duration SPANS (request/loader/render/ssr
583
- * timing wired into a tracing backend) come from the separate `tracing`
584
- * option below — a sink does not emit them, because async-context nesting
582
+ * This is the EVENT surface. Phase-duration SPANS (request/middleware/action/
583
+ * handler/loader/render/ssr timing wired into a tracing backend) come from the
584
+ * separate `tracing` option below — a sink does not emit them, because async-context nesting
585
585
  * cannot be faithfully reconstructed from after-the-fact start/end events.
586
586
  *
587
587
  * No-op when not configured (zero overhead).
@@ -175,6 +175,19 @@ export function resolveLoaderData<TEnv>(
175
175
  }
176
176
  const runMiss = internal._loaderCacheOriginalUse!;
177
177
 
178
+ // Dedup the cache read-through across repeated resolutions of the SAME
179
+ // loaderId in one request. An orphan layout with parallel slots inherits its
180
+ // parent route's loaders, so resolveOrphanLayout (fresh.ts) re-resolves the
181
+ // parent's loaders under a different shortCode — calling resolveLoaderData
182
+ // again for the same loaderId. The cache key (loader:{loaderId}:{host}
183
+ // {pathname}:{sortedParams}) does not include the shortCode and ctx/params
184
+ // are identical, so both resolutions produce the same data. Reuse the already
185
+ // in-flight dataPromise instead of issuing a second getItem/setItem (e.g. a
186
+ // second KV round-trip) for one logical cached loader. The shortCode only
187
+ // affects the emitted segmentId in resolveLoaders, not the cached value.
188
+ const existing = overrides.get(loaderId);
189
+ if (existing) return existing;
190
+
178
191
  const dataPromise = (async () => {
179
192
  const codec = await getCodec();
180
193
  const key = await resolveLoaderKey(
@@ -94,6 +94,7 @@ export interface SegmentWrappers<TEnv = any> {
94
94
  context: HandlerContext<any, TEnv>,
95
95
  belongsToRoute?: boolean,
96
96
  revalidationContext?: any,
97
+ options?: { skipMiddleware?: boolean },
97
98
  ) => Promise<ResolvedSegment[]>;
98
99
  resolveInterceptLoadersOnly: (
99
100
  interceptEntry: InterceptEntry,
@@ -245,6 +246,7 @@ export function createSegmentWrappers<TEnv = any>(
245
246
  context: HandlerContext<any, TEnv>,
246
247
  belongsToRoute: boolean = true,
247
248
  revalidationContext?: any,
249
+ options?: { skipMiddleware?: boolean },
248
250
  ): ReturnType<typeof _resolveInterceptEntry> {
249
251
  return _resolveInterceptEntry(
250
252
  interceptEntry,
@@ -254,6 +256,7 @@ export function createSegmentWrappers<TEnv = any>(
254
256
  belongsToRoute,
255
257
  segmentDeps,
256
258
  revalidationContext,
259
+ options,
257
260
  );
258
261
  }
259
262
 
@@ -81,6 +81,7 @@ export function tryTrieMatch(
81
81
  result.wildcardValue,
82
82
  pathname,
83
83
  pathnameHasTrailingSlash,
84
+ result.validatedParams,
84
85
  );
85
86
  }
86
87
 
@@ -91,6 +92,14 @@ interface WalkResult {
91
92
  leaf: TrieLeaf;
92
93
  paramValues: string[];
93
94
  wildcardValue?: string;
95
+ /**
96
+ * For a constraint-bearing leaf (leaf.cv set), the params map that
97
+ * leafConstraintsPass already decoded AND validated during the walk. Carried
98
+ * forward so validateAndBuild reuses it instead of re-decoding paramValues and
99
+ * re-running constraintsSatisfied a second time on the winning leaf. Undefined
100
+ * for unconstrained leaves (no decode/validate happened in the walk).
101
+ */
102
+ validatedParams?: Record<string, string>;
94
103
  }
95
104
 
96
105
  /**
@@ -115,17 +124,23 @@ function constraintsSatisfied(
115
124
  /**
116
125
  * Constraint check for a candidate terminal DURING the walk. Builds the named
117
126
  * params from positional walk values (decoded the same way validateAndBuild
118
- * does) and validates leaf.cv. Returning false lets walkTrie unwind to a
127
+ * does) and validates leaf.cv. Returning null lets walkTrie unwind to a
119
128
  * lower-priority sibling instead of committing to a leaf that would only be
120
129
  * rejected post-walk — that post-walk rejection is what forced the regex
121
130
  * fallback (and its false "trie gap" R3 warning) for perfectly valid configs.
131
+ *
132
+ * On success returns the built+validated params for a constraint-bearing leaf so
133
+ * walkTrie can carry them to validateAndBuild (avoiding a second decode + a
134
+ * second constraintsSatisfied pass on the winner); returns the shared EMPTY_PASS
135
+ * sentinel for an unconstrained leaf (no work was done, nothing to carry).
122
136
  */
137
+ const EMPTY_PASS: Record<string, string> = {};
123
138
  function leafConstraintsPass(
124
139
  leaf: TrieLeaf,
125
140
  paramValues: string[],
126
141
  wildcardValue: string | undefined,
127
- ): boolean {
128
- if (!leaf.cv) return true;
142
+ ): Record<string, string> | null {
143
+ if (!leaf.cv) return EMPTY_PASS;
129
144
  const params: Record<string, string> = {};
130
145
  if (leaf.pa) {
131
146
  for (let i = 0; i < leaf.pa.length && i < paramValues.length; i++) {
@@ -136,7 +151,7 @@ function leafConstraintsPass(
136
151
  params[(leaf as TrieLeaf & { pn: string }).pn] =
137
152
  safeDecodeURIComponent(wildcardValue);
138
153
  }
139
- return constraintsSatisfied(leaf, params);
154
+ return constraintsSatisfied(leaf, params) ? params : null;
140
155
  }
141
156
 
142
157
  /**
@@ -154,8 +169,19 @@ function walkTrie(
154
169
  paramValues: string[],
155
170
  ): WalkResult | null {
156
171
  if (index === segments.length) {
157
- if (node.r && leafConstraintsPass(node.r, paramValues, undefined)) {
158
- return { leaf: node.r, paramValues: [...paramValues] };
172
+ if (node.r) {
173
+ const validatedParams = leafConstraintsPass(
174
+ node.r,
175
+ paramValues,
176
+ undefined,
177
+ );
178
+ if (validatedParams) {
179
+ return {
180
+ leaf: node.r,
181
+ paramValues: [...paramValues],
182
+ validatedParams,
183
+ };
184
+ }
159
185
  }
160
186
  // A wildcard at this node matches the bare prefix with an empty remainder
161
187
  // (e.g. "/files" against "/files/*"), mirroring the regex matcher's `*=""`.
@@ -163,8 +189,16 @@ function walkTrie(
163
189
  // so without this a request to the wildcard's own prefix misses the trie
164
190
  // and the regex fallback emits a corrupt redirect. A static terminal
165
191
  // (node.r) still wins.
166
- if (node.w && leafConstraintsPass(node.w, paramValues, "")) {
167
- return { leaf: node.w, paramValues: [...paramValues], wildcardValue: "" };
192
+ if (node.w) {
193
+ const validatedParams = leafConstraintsPass(node.w, paramValues, "");
194
+ if (validatedParams) {
195
+ return {
196
+ leaf: node.w,
197
+ paramValues: [...paramValues],
198
+ wildcardValue: "",
199
+ validatedParams,
200
+ };
201
+ }
168
202
  }
169
203
  return null;
170
204
  }
@@ -206,11 +240,13 @@ function walkTrie(
206
240
 
207
241
  if (node.w) {
208
242
  const rest = joinRemainingSegments(segments, index);
209
- if (leafConstraintsPass(node.w, paramValues, rest)) {
243
+ const validatedParams = leafConstraintsPass(node.w, paramValues, rest);
244
+ if (validatedParams) {
210
245
  return {
211
246
  leaf: node.w,
212
247
  paramValues: [...paramValues],
213
248
  wildcardValue: rest,
249
+ validatedParams,
214
250
  };
215
251
  }
216
252
  }
@@ -230,6 +266,15 @@ function joinRemainingSegments(segments: string[], start: number): string {
230
266
 
231
267
  /**
232
268
  * Post-match: validate constraints and handle trailing slash logic.
269
+ *
270
+ * `validatedParams` is the params map walkTrie already decoded AND validated via
271
+ * leafConstraintsPass for a constraint-bearing winning leaf. When present (and
272
+ * non-empty) we reuse it verbatim and SKIP the second decode + the second
273
+ * constraintsSatisfied pass — both are byte-identical to the walk-time work.
274
+ * When absent (unconstrained leaf, or the root-path call sites that never walk)
275
+ * we still BUILD the params here (that is not redundant — they must be returned)
276
+ * and run constraintsSatisfied for safety; an unconstrained leaf's check is a
277
+ * cheap early `!leaf.cv` return.
233
278
  */
234
279
  function validateAndBuild(
235
280
  leaf: TrieLeaf,
@@ -237,21 +282,30 @@ function validateAndBuild(
237
282
  wildcardValue: string | undefined,
238
283
  originalPathname: string,
239
284
  pathnameHasTrailingSlash: boolean,
285
+ validatedParams?: Record<string, string>,
240
286
  ): TrieMatchResult | null {
241
- const params: Record<string, string> = {};
242
- if (leaf.pa) {
243
- for (let i = 0; i < leaf.pa.length && i < paramValues.length; i++) {
244
- params[leaf.pa[i]] = safeDecodeURIComponent(paramValues[i]);
287
+ let params: Record<string, string>;
288
+ // EMPTY_PASS (the unconstrained sentinel) and undefined both mean "nothing was
289
+ // pre-validated"; only a populated map carried from a constraint-bearing leaf
290
+ // lets us skip the rebuild + re-check.
291
+ if (validatedParams && validatedParams !== EMPTY_PASS) {
292
+ params = validatedParams;
293
+ } else {
294
+ params = {};
295
+ if (leaf.pa) {
296
+ for (let i = 0; i < leaf.pa.length && i < paramValues.length; i++) {
297
+ params[leaf.pa[i]] = safeDecodeURIComponent(paramValues[i]);
298
+ }
245
299
  }
246
- }
247
300
 
248
- if (wildcardValue !== undefined && "pn" in leaf) {
249
- params[(leaf as TrieLeaf & { pn: string }).pn] =
250
- safeDecodeURIComponent(wildcardValue);
251
- }
301
+ if (wildcardValue !== undefined && "pn" in leaf) {
302
+ params[(leaf as TrieLeaf & { pn: string }).pn] =
303
+ safeDecodeURIComponent(wildcardValue);
304
+ }
252
305
 
253
- if (!constraintsSatisfied(leaf, params)) {
254
- return null;
306
+ if (!constraintsSatisfied(leaf, params)) {
307
+ return null;
308
+ }
255
309
  }
256
310
 
257
311
  const tsMode = leaf.ts as "never" | "always" | "ignore" | undefined;
package/src/router.ts CHANGED
@@ -63,7 +63,7 @@ import {
63
63
  import { loadManifest } from "./router/manifest.js";
64
64
  import { createMetricsStore } from "./router/metrics.js";
65
65
  import {
66
- parsePattern,
66
+ compileMiddlewarePattern,
67
67
  type MiddlewareEntry,
68
68
  type MiddlewareFn,
69
69
  } from "./router/middleware.js";
@@ -347,7 +347,7 @@ export function createRouter<TEnv = any>(
347
347
  let regex: RegExp | null = null;
348
348
  let paramNames: string[] = [];
349
349
  if (fullPattern) {
350
- const parsed = parsePattern(fullPattern);
350
+ const parsed = compileMiddlewarePattern(fullPattern);
351
351
  regex = parsed.regex;
352
352
  paramNames = parsed.paramNames;
353
353
  }