@rangojs/router 0.0.0-experimental.e9c0b2f2 → 0.0.0-experimental.ea9f40f2

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 (222) hide show
  1. package/AGENTS.md +6 -10
  2. package/README.md +289 -938
  3. package/dist/bin/rango.js +271 -46
  4. package/dist/vite/index.js +673 -193
  5. package/package.json +10 -8
  6. package/skills/api-client/SKILL.md +1 -1
  7. package/skills/breadcrumbs/SKILL.md +31 -14
  8. package/skills/cache-guide/SKILL.md +5 -2
  9. package/skills/caching/SKILL.md +59 -4
  10. package/skills/catalog.json +271 -0
  11. package/skills/comparison/SKILL.md +50 -0
  12. package/skills/comparison/agents/openai.yaml +4 -0
  13. package/skills/comparison/references/framework-comparison.md +837 -0
  14. package/skills/composability/SKILL.md +83 -2
  15. package/skills/debug-manifest/SKILL.md +1 -1
  16. package/skills/defer-hydration/SKILL.md +235 -0
  17. package/skills/document-cache/SKILL.md +9 -1
  18. package/skills/fonts/SKILL.md +1 -1
  19. package/skills/handler-use/SKILL.md +8 -8
  20. package/skills/hooks/SKILL.md +54 -892
  21. package/skills/hooks/data.md +273 -0
  22. package/skills/hooks/handle-and-actions.md +103 -0
  23. package/skills/hooks/navigation.md +110 -0
  24. package/skills/hooks/outlets.md +41 -0
  25. package/skills/hooks/state.md +228 -0
  26. package/skills/hooks/urls.md +135 -0
  27. package/skills/host-router/SKILL.md +4 -4
  28. package/skills/i18n/SKILL.md +1 -1
  29. package/skills/intercept/SKILL.md +46 -14
  30. package/skills/layout/SKILL.md +27 -10
  31. package/skills/links/SKILL.md +1 -1
  32. package/skills/loader/SKILL.md +23 -1
  33. package/skills/middleware/SKILL.md +7 -3
  34. package/skills/migrate-nextjs/SKILL.md +167 -6
  35. package/skills/migrate-react-router/SKILL.md +59 -677
  36. package/skills/migrate-react-router/cloudflare-workers.md +129 -0
  37. package/skills/migrate-react-router/component-migration.md +196 -0
  38. package/skills/migrate-react-router/data-and-actions.md +225 -0
  39. package/skills/migrate-react-router/route-mapping.md +271 -0
  40. package/skills/mime-routes/SKILL.md +1 -1
  41. package/skills/observability/SKILL.md +9 -1
  42. package/skills/parallel/SKILL.md +23 -4
  43. package/skills/ppr/SKILL.md +622 -0
  44. package/skills/prerender/SKILL.md +28 -18
  45. package/skills/rango/SKILL.md +84 -25
  46. package/skills/response-routes/SKILL.md +15 -1
  47. package/skills/route/SKILL.md +71 -4
  48. package/skills/router-setup/SKILL.md +14 -3
  49. package/skills/scripts/SKILL.md +1 -1
  50. package/skills/server-actions/SKILL.md +3 -2
  51. package/skills/shell-manifest/SKILL.md +185 -0
  52. package/skills/streams-and-websockets/SKILL.md +1 -1
  53. package/skills/tailwind/SKILL.md +1 -1
  54. package/skills/testing/SKILL.md +2 -1
  55. package/skills/testing/handles.md +4 -2
  56. package/skills/testing/render-handler.md +15 -14
  57. package/skills/testing/reverse-and-types.md +8 -7
  58. package/skills/theme/SKILL.md +1 -1
  59. package/skills/typesafety/SKILL.md +45 -919
  60. package/skills/typesafety/env-and-bindings.md +254 -0
  61. package/skills/typesafety/generated-files-and-cli.md +335 -0
  62. package/skills/typesafety/params-and-search.md +153 -0
  63. package/skills/typesafety/route-types.md +209 -0
  64. package/skills/use-cache/SKILL.md +30 -3
  65. package/skills/vercel/SKILL.md +1 -1
  66. package/skills/view-transitions/SKILL.md +44 -1
  67. package/src/browser/event-controller.ts +62 -10
  68. package/src/browser/logging.ts +28 -0
  69. package/src/browser/merge-segment-loaders.ts +6 -4
  70. package/src/browser/navigation-bridge.ts +65 -16
  71. package/src/browser/navigation-client.ts +32 -2
  72. package/src/browser/navigation-store.ts +128 -14
  73. package/src/browser/network-error-handler.ts +34 -7
  74. package/src/browser/partial-update.ts +76 -17
  75. package/src/browser/prefetch/cache.ts +51 -11
  76. package/src/browser/prefetch/fetch.ts +59 -21
  77. package/src/browser/prefetch/queue.ts +19 -4
  78. package/src/browser/react/Link.tsx +13 -3
  79. package/src/browser/react/NavigationProvider.tsx +108 -4
  80. package/src/browser/response-adapter.ts +38 -9
  81. package/src/browser/rsc-router.tsx +54 -4
  82. package/src/browser/scroll-restoration.ts +7 -5
  83. package/src/browser/segment-reconciler.ts +31 -21
  84. package/src/browser/server-action-bridge.ts +22 -10
  85. package/src/browser/types.ts +54 -1
  86. package/src/build/generate-manifest.ts +155 -131
  87. package/src/build/index.ts +3 -1
  88. package/src/build/route-trie.ts +35 -7
  89. package/src/build/route-types/include-resolution.ts +347 -47
  90. package/src/build/runtime-discovery.ts +4 -1
  91. package/src/cache/cache-key-utils.ts +29 -0
  92. package/src/cache/cache-runtime.ts +262 -71
  93. package/src/cache/cache-scope.ts +2 -17
  94. package/src/cache/cache-tag.ts +60 -14
  95. package/src/cache/cf/cf-cache-store.ts +243 -20
  96. package/src/cache/document-cache.ts +54 -21
  97. package/src/cache/index.ts +1 -0
  98. package/src/cache/memory-segment-store.ts +110 -3
  99. package/src/cache/profile-registry.ts +15 -0
  100. package/src/cache/read-through-swr.ts +15 -1
  101. package/src/cache/segment-codec.ts +4 -4
  102. package/src/cache/shell-snapshot.ts +417 -0
  103. package/src/cache/types.ts +158 -0
  104. package/src/cache/vercel/vercel-cache-store.ts +401 -124
  105. package/src/client.rsc.tsx +0 -3
  106. package/src/client.tsx +0 -3
  107. package/src/cloudflare/tracing.ts +7 -8
  108. package/src/defer.ts +11 -22
  109. package/src/handle.ts +37 -15
  110. package/src/handles/MetaTags.tsx +16 -82
  111. package/src/handles/breadcrumbs.ts +12 -14
  112. package/src/handles/deferred-resolution.ts +127 -0
  113. package/src/handles/is-thenable.ts +7 -8
  114. package/src/handles/meta.ts +7 -44
  115. package/src/host/errors.ts +15 -0
  116. package/src/host/index.ts +1 -0
  117. package/src/index.rsc.ts +8 -2
  118. package/src/index.ts +19 -13
  119. package/src/internal-debug.ts +11 -8
  120. package/src/prerender.ts +17 -4
  121. package/src/redirect-origin.ts +14 -0
  122. package/src/render-error-thrower.tsx +20 -0
  123. package/src/route-content-wrapper.tsx +12 -5
  124. package/src/route-definition/dsl-helpers.ts +21 -32
  125. package/src/route-definition/helper-factories.ts +0 -2
  126. package/src/route-definition/helpers-types.ts +43 -43
  127. package/src/route-definition/index.ts +1 -2
  128. package/src/route-definition/resolve-handler-use.ts +0 -1
  129. package/src/route-definition/use-item-types.ts +3 -6
  130. package/src/route-map-builder.ts +41 -4
  131. package/src/route-types.ts +0 -5
  132. package/src/router/find-match.ts +86 -8
  133. package/src/router/instrument.ts +9 -4
  134. package/src/router/lazy-includes.ts +72 -12
  135. package/src/router/loader-resolution.ts +14 -2
  136. package/src/router/manifest.ts +56 -11
  137. package/src/router/match-api.ts +76 -32
  138. package/src/router/match-handlers.ts +181 -135
  139. package/src/router/match-middleware/background-revalidation.ts +40 -23
  140. package/src/router/match-middleware/cache-store.ts +39 -24
  141. package/src/router/match-result.ts +35 -15
  142. package/src/router/middleware.ts +64 -38
  143. package/src/router/navigation-snapshot.ts +7 -5
  144. package/src/router/parse-pattern.ts +115 -0
  145. package/src/router/pattern-matching.ts +53 -64
  146. package/src/router/prefetch-limits.ts +37 -0
  147. package/src/router/prerender-match.ts +11 -5
  148. package/src/router/preview-match.ts +3 -1
  149. package/src/router/request-classification.ts +23 -8
  150. package/src/router/route-snapshot.ts +14 -2
  151. package/src/router/router-context.ts +3 -1
  152. package/src/router/router-interfaces.ts +32 -1
  153. package/src/router/router-options.ts +30 -0
  154. package/src/router/segment-resolution/fresh.ts +39 -3
  155. package/src/router/segment-resolution/loader-cache.ts +93 -2
  156. package/src/router/segment-resolution/loader-mask.ts +60 -0
  157. package/src/router/segment-resolution/loader-snapshot.ts +259 -0
  158. package/src/router/segment-resolution/mask-nested.ts +83 -0
  159. package/src/router/segment-resolution/revalidation.ts +3 -0
  160. package/src/router/segment-resolution/view-transition-default.ts +35 -15
  161. package/src/router/substitute-pattern-params.ts +54 -35
  162. package/src/router/telemetry-otel.ts +6 -8
  163. package/src/router/telemetry.ts +9 -1
  164. package/src/router/tracing.ts +14 -5
  165. package/src/router/trie-matching.ts +19 -11
  166. package/src/router/url-params.ts +13 -0
  167. package/src/router.ts +47 -16
  168. package/src/rsc/full-payload.ts +70 -0
  169. package/src/rsc/handler.ts +60 -33
  170. package/src/rsc/manifest-init.ts +1 -1
  171. package/src/rsc/nonce.ts +10 -1
  172. package/src/rsc/progressive-enhancement.ts +61 -4
  173. package/src/rsc/redirect-guard.ts +2 -1
  174. package/src/rsc/rsc-rendering.ts +429 -37
  175. package/src/rsc/server-action.ts +25 -2
  176. package/src/rsc/shell-capture.ts +1190 -0
  177. package/src/rsc/shell-serve.ts +181 -0
  178. package/src/rsc/transition-gate.ts +89 -0
  179. package/src/rsc/types.ts +30 -0
  180. package/src/segment-loader-promise.ts +18 -0
  181. package/src/segment-system.tsx +149 -14
  182. package/src/server/context.ts +67 -9
  183. package/src/server/cookie-store.ts +73 -1
  184. package/src/server/loader-registry.ts +13 -1
  185. package/src/server/request-context.ts +169 -10
  186. package/src/ssr/index.tsx +462 -178
  187. package/src/ssr/inject-rsc-eager.ts +167 -0
  188. package/src/ssr/ssr-root.tsx +228 -0
  189. package/src/testing/collect-handle.ts +14 -8
  190. package/src/testing/dispatch.ts +152 -40
  191. package/src/testing/generated-routes.ts +27 -11
  192. package/src/testing/index.ts +6 -0
  193. package/src/testing/render-handler.ts +14 -0
  194. package/src/testing/render-route.tsx +13 -10
  195. package/src/testing/run-transition-when.ts +164 -0
  196. package/src/theme/ThemeProvider.tsx +36 -26
  197. package/src/types/handler-context.ts +1 -1
  198. package/src/types/index.ts +2 -0
  199. package/src/types/route-config.ts +19 -7
  200. package/src/types/segments.ts +100 -0
  201. package/src/urls/include-helper.ts +10 -8
  202. package/src/urls/include-provider.ts +71 -0
  203. package/src/urls/index.ts +1 -0
  204. package/src/urls/path-helper-types.ts +44 -12
  205. package/src/urls/path-helper.ts +5 -0
  206. package/src/urls/pattern-types.ts +36 -0
  207. package/src/urls/type-extraction.ts +43 -18
  208. package/src/urls/urls-function.ts +0 -1
  209. package/src/vercel/tracing.ts +7 -7
  210. package/src/vite/discovery/dev-prerender-cache.ts +117 -0
  211. package/src/vite/discovery/discover-routers.ts +1 -1
  212. package/src/vite/discovery/discovery-errors.ts +61 -0
  213. package/src/vite/index.ts +7 -0
  214. package/src/vite/inject-client-debug.ts +88 -0
  215. package/src/vite/plugins/vercel-output.ts +114 -25
  216. package/src/vite/plugins/version-injector.ts +22 -7
  217. package/src/vite/plugins/virtual-entries.ts +80 -22
  218. package/src/vite/rango.ts +29 -19
  219. package/src/vite/router-discovery.ts +171 -43
  220. package/src/vite/utils/prerender-utils.ts +17 -4
  221. package/src/vite/utils/shared-utils.ts +47 -0
  222. package/src/network-error-thrower.tsx +0 -18
@@ -101,7 +101,10 @@
101
101
  * - Non-GET request (only GET requests are cacheable)
102
102
  */
103
103
  import type { ResolvedSegment } from "../../types.js";
104
- import { getRequestContext } from "../../server/request-context.js";
104
+ import {
105
+ getRequestContext,
106
+ runWithRequestContext,
107
+ } from "../../server/request-context.js";
105
108
  import type { MatchContext, MatchPipelineState } from "../match-context.js";
106
109
  import { getRouterContext } from "../router-context.js";
107
110
  import { debugLog, debugWarn, getOrCreateRequestId } from "../logging.js";
@@ -231,34 +234,46 @@ export function withCacheStore<TEnv>(
231
234
  setupLoaderAccess(proactiveHandlerContext, proactiveLoaderPromises);
232
235
 
233
236
  const Store = ctx.Store;
234
- const freshSegments = await Store.run(() =>
235
- resolveAllSegments(
236
- ctx.entries,
237
- ctx.routeKey,
238
- ctx.matched.params,
239
- proactiveHandlerContext,
240
- proactiveLoaderPromises,
241
- { skipLoaders: true },
237
+ // Re-establish the request-context ALS around the re-render. Store
238
+ // is a different ALS (DSL build context); on workerd a waitUntil
239
+ // task runs detached from the request's I/O context, so a handler/
240
+ // component that reads the ambient getRequestContext() during this
241
+ // background re-render would otherwise throw "called outside of a
242
+ // request context".
243
+ const freshSegments = await runWithRequestContext(requestCtx, () =>
244
+ Store.run(() =>
245
+ resolveAllSegments(
246
+ ctx.entries,
247
+ ctx.routeKey,
248
+ ctx.matched.params,
249
+ proactiveHandlerContext,
250
+ proactiveLoaderPromises,
251
+ { skipLoaders: true },
252
+ ),
242
253
  ),
243
254
  );
244
255
 
245
256
  let freshInterceptSegments: ResolvedSegment[] = [];
246
257
  if (ctx.interceptResult) {
247
- freshInterceptSegments = await Store.run(() =>
248
- resolveInterceptEntry(
249
- ctx.interceptResult!.intercept,
250
- ctx.interceptResult!.entry,
251
- ctx.matched.params,
252
- proactiveHandlerContext,
253
- true, // belongsToRoute
254
- // No revalidationContext = render fresh
255
- undefined,
256
- // Skip intercept middleware: the foreground already ran it
257
- // before the response was sent. Re-running here (post-response,
258
- // background) would fire side effects twice and a short-circuit
259
- // Response would silently abort this cache write.
260
- { skipMiddleware: true },
261
- ),
258
+ freshInterceptSegments = await runWithRequestContext(
259
+ requestCtx,
260
+ () =>
261
+ Store.run(() =>
262
+ resolveInterceptEntry(
263
+ ctx.interceptResult!.intercept,
264
+ ctx.interceptResult!.entry,
265
+ ctx.matched.params,
266
+ proactiveHandlerContext,
267
+ true, // belongsToRoute
268
+ // No revalidationContext = render fresh
269
+ undefined,
270
+ // Skip intercept middleware: the foreground already ran it
271
+ // before the response was sent. Re-running here (post-
272
+ // response, background) would fire side effects twice and a
273
+ // short-circuit Response would silently abort this write.
274
+ { skipMiddleware: true },
275
+ ),
276
+ ),
262
277
  );
263
278
  }
264
279
 
@@ -201,17 +201,23 @@ export function buildMatchResult<TEnv>(
201
201
 
202
202
  let allIds: string[];
203
203
  let segmentsToRender: ResolvedSegment[];
204
+ let resolvedIds: string[];
204
205
 
205
206
  if (ctx.isFullMatch) {
207
+ // One pass over allSegments: dedup by id (segmentsToRender + allIds) while
208
+ // collecting every id for resolvedIds, which keeps duplicates unlike allIds.
206
209
  const seen = new Set<string>();
207
210
  segmentsToRender = [];
211
+ allIds = [];
212
+ resolvedIds = [];
208
213
  for (const s of allSegments) {
214
+ resolvedIds.push(s.id);
209
215
  if (!seen.has(s.id)) {
210
216
  seen.add(s.id);
211
217
  segmentsToRender.push(s);
218
+ allIds.push(s.id);
212
219
  }
213
220
  }
214
- allIds = segmentsToRender.map((s) => s.id);
215
221
  } else {
216
222
  allIds = ctx.interceptResult
217
223
  ? ctx.clientSegmentIds.length > 0
@@ -221,11 +227,21 @@ export function buildMatchResult<TEnv>(
221
227
 
222
228
  allIds = [...new Set(allIds)];
223
229
 
230
+ // One pass over allSegments: keep renderable segments and collect the
231
+ // handler-ran ids (resolvedIds) together.
224
232
  const clientIdSet = new Set(ctx.clientSegmentIds);
225
- segmentsToRender = allSegments.filter(
226
- (s) =>
227
- s.component !== null || s.type === "loader" || !clientIdSet.has(s.id),
228
- );
233
+ segmentsToRender = [];
234
+ resolvedIds = [];
235
+ for (const s of allSegments) {
236
+ if (s._handlerRan) resolvedIds.push(s.id);
237
+ if (
238
+ s.component !== null ||
239
+ s.type === "loader" ||
240
+ !clientIdSet.has(s.id)
241
+ ) {
242
+ segmentsToRender.push(s);
243
+ }
244
+ }
229
245
  }
230
246
 
231
247
  const { segments: dedupedSegments, removedIds } = deduplicateLoaderSegments(
@@ -236,20 +252,24 @@ export function buildMatchResult<TEnv>(
236
252
  const matchedIds =
237
253
  removedIds.size > 0 ? allIds.filter((id) => !removedIds.has(id)) : allIds;
238
254
 
239
- const resolvedIds = ctx.isFullMatch
240
- ? allSegments.map((s) => s.id)
241
- : allSegments.filter((s) => s._handlerRan).map((s) => s.id);
242
-
243
- const cleanedSegments = dedupedSegments.map((s) => {
244
- if (s._handlerRan === undefined) return s;
245
- const { _handlerRan: _drop, ...rest } = s;
246
- return rest as ResolvedSegment;
247
- });
255
+ // One pass over dedupedSegments: strip the internal _handlerRan marker and
256
+ // collect the diff ids (id is unchanged by the strip) together.
257
+ const cleanedSegments: ResolvedSegment[] = [];
258
+ const diff: string[] = [];
259
+ for (const s of dedupedSegments) {
260
+ if (s._handlerRan === undefined) {
261
+ cleanedSegments.push(s);
262
+ } else {
263
+ const { _handlerRan: _drop, ...rest } = s;
264
+ cleanedSegments.push(rest as ResolvedSegment);
265
+ }
266
+ diff.push(s.id);
267
+ }
248
268
 
249
269
  return {
250
270
  segments: cleanedSegments,
251
271
  matched: matchedIds,
252
- diff: cleanedSegments.map((s) => s.id),
272
+ diff,
253
273
  resolvedIds,
254
274
  params: ctx.matched.params,
255
275
  routeName: ctx.routeKey,
@@ -104,11 +104,20 @@ export function compileMiddlewarePattern(pattern: string): {
104
104
  const segment = segments[i];
105
105
 
106
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`.
111
- regexStr += "(?:/.*)?";
107
+ if (segment.value === "*") {
108
+ // Bare `*`: optional subtree match, no capture (parity with the original
109
+ // middleware parser). A trailing `*` matches the subtree; a non-trailing
110
+ // `*` matches zero-or-more intermediate segments, so `/a/<star>/b` still
111
+ // matches `/a/b`.
112
+ regexStr += "(?:/.*)?";
113
+ } else {
114
+ // Named catch-all `:name+` / `:name*`: capture the remainder under the
115
+ // name so a scoping middleware sees `ctx.params.<name>`, and respect the
116
+ // one-or-more arity (`+` must not match the bare prefix), mirroring the
117
+ // route matcher instead of collapsing to the bare-`*` subtree.
118
+ paramNames.push(segment.value);
119
+ regexStr += segment.oneOrMore ? "/(.+)" : "(?:/(.*))?";
120
+ }
112
121
  if (i === segments.length - 1) {
113
122
  hasTrailingWildcard = true;
114
123
  }
@@ -177,7 +186,15 @@ export function createMiddlewareContext<TEnv>(
177
186
  search?: Record<string, unknown>,
178
187
  ) => string,
179
188
  ): MiddlewareContext<TEnv> {
180
- const url = stripInternalParams(new URL(request.url));
189
+ // Reuse the request context's clean URL when available. Each middleware still
190
+ // gets its OWN URL clone (a middleware may mutate ctx.url / ctx.searchParams),
191
+ // but cloning the already-clean URL is cheaper than re-parsing + re-stripping
192
+ // request.url per middleware. Fall back to parsing when no ALS context.
193
+ const reqCtx = _getRequestContext();
194
+ const ctxUrl = reqCtx?.url;
195
+ const url = ctxUrl
196
+ ? new URL(ctxUrl)
197
+ : stripInternalParams(new URL(request.url));
181
198
 
182
199
  // Track the initial response to detect pre/post-next() phase.
183
200
  // Before next(): responseHolder.response === initialResponse (the stub).
@@ -208,12 +225,11 @@ export function createMiddlewareContext<TEnv>(
208
225
  return responseHolder.response;
209
226
  };
210
227
 
211
- // Capture reqCtx once: the request-scoped platform fields
228
+ // reqCtx captured once above: the request-scoped platform fields
212
229
  // (originalUrl, executionContext, waitUntil) are immutable per request,
213
230
  // so snapshotting beats re-reading ALS on every access. The lazy getters
214
231
  // below (routeName, theme, setTheme) stay lazy because those can change
215
232
  // during `await next()`.
216
- const reqCtx = _getRequestContext();
217
233
  return {
218
234
  request,
219
235
  url,
@@ -296,7 +312,10 @@ export function createMiddlewareContext<TEnv>(
296
312
  const reqCtx = _getRequestContext();
297
313
  if (reqCtx) {
298
314
  reqCtx._debugPerformance = true;
299
- reqCtx._metricsStore ??= createMetricsStore(true);
315
+ // Anchor to the true request entry (reqCtx._handlerStart) so phases that
316
+ // began before this opt-in report non-negative offsets; undefined falls
317
+ // back to performance.now() in createMetricsStore.
318
+ reqCtx._metricsStore ??= createMetricsStore(true, reqCtx._handlerStart);
300
319
  }
301
320
  },
302
321
  };
@@ -534,22 +553,28 @@ export async function executeMiddleware<TEnv>(
534
553
  // when neither surface is active.
535
554
  let result: Response | void;
536
555
  try {
537
- result = await observePhase(PHASES.middleware(metricLabel), () =>
538
- entry.handler(ctx, wrappedNext),
539
- );
540
- } catch (error) {
541
- // Thrown Response is short-circuit control flow, not an error.
542
- // Fall through to the `if (result instanceof Response)` branch below
543
- // so stub headers and request-context cookies merge as they do for
544
- // an explicit `return new Response(...)`. Real errors propagate.
545
- if (error instanceof Response) {
546
- result = error;
547
- } else {
548
- finishMiddleware();
549
- throw error;
550
- }
556
+ result = await observePhase(PHASES.middleware(metricLabel), async () => {
557
+ try {
558
+ return await entry.handler(ctx, wrappedNext);
559
+ } catch (error) {
560
+ // Thrown Response is short-circuit control flow, not an error
561
+ // absorb it INSIDE the span so the tracing runner settles the
562
+ // rango.middleware span as success, not STATUS_ERROR (every auth
563
+ // redirect would otherwise inflate trace error rates). Returning it
564
+ // routes through the `if (result instanceof Response)` branch below,
565
+ // so stub headers and request-context cookies merge identically to an
566
+ // explicit `return new Response(...)`. Segment handlers already follow
567
+ // this convention (segment-resolution/helpers.ts keeps result handling
568
+ // outside the span). Real errors propagate past the span.
569
+ if (error instanceof Response) return error;
570
+ throw error;
571
+ }
572
+ });
573
+ } finally {
574
+ // Settle the middleware own-time metric once on both the success and
575
+ // error paths (idempotent guard in finishMiddleware).
576
+ finishMiddleware();
551
577
  }
552
- finishMiddleware();
553
578
 
554
579
  // Record post-next() processing time when middleware did work after
555
580
  // the downstream chain resolved (e.g. adding headers, logging).
@@ -709,20 +734,21 @@ export async function executeInterceptMiddleware<TEnv>(
709
734
  ordinal,
710
735
  );
711
736
 
712
- let result: Response | void;
713
- try {
714
- result = await observePhase(PHASES.middleware(label), () =>
715
- middleware(ctx, guardedNext),
716
- );
717
- } catch (error) {
718
- // Thrown Response is short-circuit control flow, parity with the
719
- // explicit-return path below. Real errors propagate.
720
- if (error instanceof Response) {
721
- result = error;
722
- } else {
723
- throw error;
724
- }
725
- }
737
+ const result: Response | void = await observePhase(
738
+ PHASES.middleware(label),
739
+ async () => {
740
+ try {
741
+ return await middleware(ctx, guardedNext);
742
+ } catch (error) {
743
+ // Thrown Response is short-circuit control flow, parity with the
744
+ // explicit-return path below. Absorb it INSIDE the span so the tracing
745
+ // runner settles rango.middleware as success, not STATUS_ERROR (same
746
+ // reasoning as executeMiddleware's main chain). Real errors propagate.
747
+ if (error instanceof Response) return error;
748
+ throw error;
749
+ }
750
+ },
751
+ );
726
752
 
727
753
  if (result instanceof Response) {
728
754
  earlyResponse = result;
@@ -25,15 +25,17 @@ export interface NavigationSnapshot {
25
25
  }
26
26
 
27
27
  export interface ResolveNavigationDeps {
28
- findMatch: (pathname: string) => RouteMatchResult | null;
28
+ findMatch: (
29
+ pathname: string,
30
+ ) => RouteMatchResult | null | Promise<RouteMatchResult | null>;
29
31
  }
30
32
 
31
- export function resolveNavigation(
33
+ export async function resolveNavigation(
32
34
  request: Request,
33
35
  url: URL,
34
36
  currentRouteKey: string,
35
37
  deps: ResolveNavigationDeps,
36
- ): NavigationSnapshot | null {
38
+ ): Promise<NavigationSnapshot | null> {
37
39
  const clientSegmentIds =
38
40
  url.searchParams.get("_rsc_segments")?.split(",").filter(Boolean) || [];
39
41
  const stale = url.searchParams.get("_rsc_stale") === "true";
@@ -65,10 +67,10 @@ export function resolveNavigation(
65
67
  interceptContextUrl = prevUrl;
66
68
  }
67
69
 
68
- const prevMatch = deps.findMatch(prevUrl.pathname);
70
+ const prevMatch = await deps.findMatch(prevUrl.pathname);
69
71
  const prevParams = prevMatch?.params || {};
70
72
  const interceptContextMatch = interceptSourceUrl
71
- ? deps.findMatch(interceptContextUrl.pathname)
73
+ ? await deps.findMatch(interceptContextUrl.pathname)
72
74
  : prevMatch;
73
75
 
74
76
  const isSameRouteNavigation = !!(
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Route pattern parsing (grammar only).
3
+ *
4
+ * Deliberately dependency-free so it is safe to bundle into the CLIENT — the
5
+ * reverse helper (`substitute-pattern-params.ts` -> `use-reverse`) needs it, and
6
+ * pulling it from `pattern-matching.ts` would drag that module's server-only
7
+ * transitive imports (`node:async_hooks` via `logging.ts`) into the browser.
8
+ * `pattern-matching.ts` re-exports these so existing importers are unaffected.
9
+ */
10
+
11
+ /**
12
+ * Parsed segment info
13
+ */
14
+ export interface ParsedSegment {
15
+ type: "static" | "param" | "wildcard";
16
+ value: string; // static text, param name, or "*"
17
+ optional: boolean;
18
+ constraint?: string[]; // enum values like ["en", "gb"]
19
+ suffix?: string; // literal text after param in same segment (e.g., ".html")
20
+ /**
21
+ * Named catch-all repeat modifier. On a `wildcard` segment whose `value` is a
22
+ * param name (`:name+` / `:name*`), `true` marks one-or-more (`+`, rejects the
23
+ * zero-segment case); absent/false marks zero-or-more (`*`, and the bare `/*`).
24
+ */
25
+ oneOrMore?: boolean;
26
+ }
27
+
28
+ /**
29
+ * Parse a route pattern into segments
30
+ *
31
+ * Supports:
32
+ * - Static: /blog, /about
33
+ * - Params: /:slug, /:id
34
+ * - Optional: /:locale?, /:page?
35
+ * - Constrained: /:locale(en|gb), /:type(post|page)
36
+ * - Optional + Constrained: /:locale(en|gb)?
37
+ * - Wildcard: /*
38
+ * - Named catch-all: /:slug* (zero-or-more), /:path+ (one-or-more)
39
+ */
40
+ export function parsePattern(pattern: string): ParsedSegment[] {
41
+ const segments: ParsedSegment[] = [];
42
+ // The `([+*])?` group peels a trailing `+`/`*` off a `:name` BEFORE the
43
+ // literal-suffix group `([^/]*)` so it can be inspected. Whether it is a
44
+ // catch-all MODIFIER or a literal suffix character is decided below — a bare
45
+ // trailing `+`/`*` is the named catch-all of issue #634; any other combination
46
+ // is folded back into the literal suffix so previously-valid patterns are
47
+ // unaffected. It sits after `(\?)?` so `:name?*` is seen as `?` + suffix `*`.
48
+ const segmentRegex =
49
+ /\/(:([a-zA-Z_][a-zA-Z0-9_]*)(\(([^)]+)\))?(\?)?([+*])?([^/]*)|(\*)|([^/]+))/g;
50
+
51
+ let match;
52
+ while ((match = segmentRegex.exec(pattern)) !== null) {
53
+ const [
54
+ ,
55
+ ,
56
+ paramName,
57
+ ,
58
+ constraint,
59
+ optional,
60
+ repeat,
61
+ suffix,
62
+ wildcard,
63
+ staticText,
64
+ ] = match;
65
+
66
+ if (wildcard) {
67
+ // Bare `/*`: zero-or-more, captured under "*".
68
+ segments.push({ type: "wildcard", value: "*", optional: false });
69
+ } else if (paramName) {
70
+ // A trailing `+`/`*` is a named catch-all ONLY when it stands alone on the
71
+ // param — no `?`, no constraint, no literal suffix after it. In any other
72
+ // combination it is the start of a literal suffix, exactly as before this
73
+ // feature existed, so `:version+build` still matches `/…/v1+build` and
74
+ // never throws at registration.
75
+ if (repeat && !suffix && optional !== "?" && !constraint) {
76
+ segments.push({
77
+ type: "wildcard",
78
+ value: paramName,
79
+ optional: false,
80
+ oneOrMore: repeat === "+",
81
+ });
82
+ } else {
83
+ segments.push({
84
+ type: "param",
85
+ value: paramName,
86
+ optional: optional === "?",
87
+ constraint: constraint ? constraint.split("|") : undefined,
88
+ // Fold a non-modifier `+`/`*` back into the literal suffix.
89
+ suffix: (repeat ?? "") + (suffix ?? "") || undefined,
90
+ });
91
+ }
92
+ } else if (staticText) {
93
+ segments.push({ type: "static", value: staticText, optional: false });
94
+ }
95
+ }
96
+
97
+ // A named catch-all consumes the remainder, so it only makes sense as the final
98
+ // segment. If it isn't last, it isn't really a catch-all: restore the literal
99
+ // parse (`:name` + literal `+`/`*` suffix) rather than error, so a pattern like
100
+ // `/docs/:slug+/edit` keeps its pre-feature behavior (matches `/docs/x+/edit`).
101
+ // Bare `/*` keeps its historical mid-pattern leniency and is left untouched.
102
+ for (let i = 0; i < segments.length - 1; i++) {
103
+ const s = segments[i];
104
+ if (s.type === "wildcard" && s.value !== "*") {
105
+ segments[i] = {
106
+ type: "param",
107
+ value: s.value,
108
+ optional: false,
109
+ suffix: s.oneOrMore ? "+" : "*",
110
+ };
111
+ }
112
+ }
113
+
114
+ return segments;
115
+ }
@@ -9,65 +9,13 @@ import type { EntryData } from "../server/context";
9
9
  import { debugLog, isRouterDebugEnabled } from "./logging.js";
10
10
  import { escapeRegExp } from "../regex-escape.js";
11
11
  import { safeDecodeURIComponent } from "./url-params.js";
12
+ import { parsePattern, type ParsedSegment } from "./parse-pattern.js";
12
13
 
13
- /**
14
- * Parsed segment info
15
- */
16
- export interface ParsedSegment {
17
- type: "static" | "param" | "wildcard";
18
- value: string; // static text, param name, or "*"
19
- optional: boolean;
20
- constraint?: string[]; // enum values like ["en", "gb"]
21
- suffix?: string; // literal text after param in same segment (e.g., ".html")
22
- }
23
-
24
- /**
25
- * Parse a route pattern into segments
26
- *
27
- * Supports:
28
- * - Static: /blog, /about
29
- * - Params: /:slug, /:id
30
- * - Optional: /:locale?, /:page?
31
- * - Constrained: /:locale(en|gb), /:type(post|page)
32
- * - Optional + Constrained: /:locale(en|gb)?
33
- * - Wildcard: /*
34
- */
35
- export function parsePattern(pattern: string): ParsedSegment[] {
36
- const segments: ParsedSegment[] = [];
37
- const segmentRegex =
38
- /\/(:([a-zA-Z_][a-zA-Z0-9_]*)(\(([^)]+)\))?(\?)?([^/]*)|(\*)|([^/]+))/g;
39
-
40
- let match;
41
- while ((match = segmentRegex.exec(pattern)) !== null) {
42
- const [
43
- ,
44
- ,
45
- paramName,
46
- ,
47
- constraint,
48
- optional,
49
- suffix,
50
- wildcard,
51
- staticText,
52
- ] = match;
53
-
54
- if (wildcard) {
55
- segments.push({ type: "wildcard", value: "*", optional: false });
56
- } else if (paramName) {
57
- segments.push({
58
- type: "param",
59
- value: paramName,
60
- optional: optional === "?",
61
- constraint: constraint ? constraint.split("|") : undefined,
62
- suffix: suffix || undefined,
63
- });
64
- } else if (staticText) {
65
- segments.push({ type: "static", value: staticText, optional: false });
66
- }
67
- }
68
-
69
- return segments;
70
- }
14
+ // `parsePattern`/`ParsedSegment` live in the dependency-free `parse-pattern.ts`
15
+ // so the client reverse helper can import them without dragging this module's
16
+ // server-only deps into the browser bundle. Re-exported here for existing
17
+ // importers (build/route-trie, middleware, tests).
18
+ export { parsePattern, type ParsedSegment };
71
19
 
72
20
  /**
73
21
  * Compiled pattern result containing regex, param metadata, and trailing slash info.
@@ -83,6 +31,14 @@ export interface CompiledPattern {
83
31
  * path's behavior (trie-matching.ts:validateAndBuild).
84
32
  */
85
33
  constraints?: Record<string, string[]>;
34
+ /**
35
+ * The pattern's catch-all param, if any (`*` for bare `/*`, the name for a
36
+ * named `:name+`/`:name*`). A zero-or-more catch-all (`oneOrMore: false`)
37
+ * whose optional group is absent binds "" rather than being omitted — so
38
+ * `/docs` matches `/docs/:slug*` with `slug === ""`. `oneOrMore` keeps the
39
+ * same polarity as `ParsedSegment.oneOrMore` and the trie's `w1`.
40
+ */
41
+ catchAll?: { name: string; oneOrMore: boolean };
86
42
  }
87
43
 
88
44
  // Module-level cache for compiled patterns. Route patterns are a finite set
@@ -143,13 +99,32 @@ export function compilePattern(pattern: string): CompiledPattern {
143
99
  const segments = parsePattern(normalizedPattern);
144
100
  const paramNames: string[] = [];
145
101
  let constraints: Record<string, string[]> | undefined;
102
+ let catchAll: { name: string; oneOrMore: boolean } | undefined;
146
103
 
147
104
  let regexPattern = "";
148
105
 
149
106
  for (const segment of segments) {
150
107
  if (segment.type === "wildcard") {
151
- paramNames.push("*");
152
- regexPattern += "/(.*)";
108
+ // Wildcards capture the remainder under `segment.value` ("*" for the bare
109
+ // form, the param name for a named catch-all).
110
+ paramNames.push(segment.value);
111
+ catchAll = { name: segment.value, oneOrMore: Boolean(segment.oneOrMore) };
112
+ if (segment.oneOrMore) {
113
+ // `:name+` — one-or-more, rejects the zero-segment (bare-prefix) case.
114
+ regexPattern += "/(.+)";
115
+ } else {
116
+ // Zero-or-more catch-all: named `:name*` OR the bare `/*` (both parse to
117
+ // `oneOrMore: false`). The whole `/segment` is optional so the bare
118
+ // prefix matches directly, aligning the regex fallback with the trie
119
+ // (which already matches the bare prefix binding "" — trie-matching.ts);
120
+ // buildParamsFromMatch binds "" when the optional group is absent.
121
+ //
122
+ // The bare `/*` previously used a required `/(.*)`, so `/files/*` failed
123
+ // to match `/files` and fell through to trailing-slash normalization,
124
+ // emitting a corrupt `/file` redirect instead of a match (issue #636,
125
+ // parity row C1). It is the same alignment #635 made for named `:name*`.
126
+ regexPattern += "(?:/(.*))?";
127
+ }
153
128
  } else if (segment.type === "param") {
154
129
  paramNames.push(segment.value);
155
130
  const suffixPattern = segment.suffix ? escapeRegExp(segment.suffix) : "";
@@ -202,6 +177,7 @@ export function compilePattern(pattern: string): CompiledPattern {
202
177
  paramNames,
203
178
  hasTrailingSlash,
204
179
  ...(constraints ? { constraints } : {}),
180
+ ...(catchAll ? { catchAll } : {}),
205
181
  };
206
182
  }
207
183
 
@@ -236,16 +212,29 @@ function satisfiesConstraints(
236
212
  * keys so `ctx.params.<name>` reads as `undefined` rather than `""`. This
237
213
  * keeps the runtime aligned with the `ExtractParams` type and matches the
238
214
  * trie matcher's contract (see `trie-matching.ts:validateAndBuild`).
215
+ *
216
+ * A zero-or-more catch-all (`compiled.catchAll`, `oneOrMore: false`) whose
217
+ * optional group didn't capture binds "" instead of being omitted, so `/docs`
218
+ * matches `/docs/:slug*` with `slug === ""`. Exported so the `renderRoute`
219
+ * testing harness (`matchLeaf`) shares this exact logic instead of forking it.
239
220
  */
240
- function buildParamsFromMatch(
221
+ export function buildParamsFromMatch(
241
222
  match: RegExpExecArray,
242
223
  paramNames: string[],
224
+ catchAll?: { name: string; oneOrMore: boolean },
243
225
  ): Record<string, string> {
244
226
  const params: Record<string, string> = {};
245
227
  paramNames.forEach((name, index) => {
246
228
  const captured = match[index + 1];
247
229
  if (captured !== undefined) {
230
+ // A catch-all remainder decodes identically whether split-per-segment or
231
+ // whole-string (a literal `/` never lives inside a `%XX` escape), so a
232
+ // single decode is correct and cheapest.
248
233
  params[name] = safeDecodeURIComponent(captured);
234
+ } else if (catchAll && name === catchAll.name && !catchAll.oneOrMore) {
235
+ // A zero-or-more catch-all (`:name*` or the bare `/*`) whose optional
236
+ // group was absent binds "" rather than being omitted.
237
+ params[name] = "";
249
238
  }
250
239
  });
251
240
  return params;
@@ -454,7 +443,7 @@ export function findMatch<TEnv>(
454
443
  fullPattern = entry.prefix + pattern;
455
444
  }
456
445
 
457
- const { regex, paramNames, hasTrailingSlash, constraints } =
446
+ const { regex, paramNames, hasTrailingSlash, constraints, catchAll } =
458
447
  getCompiledPattern(fullPattern);
459
448
 
460
449
  const trailingSlashMode: TrailingSlashMode | undefined =
@@ -469,7 +458,7 @@ export function findMatch<TEnv>(
469
458
 
470
459
  const match = regex.exec(pathname);
471
460
  if (match) {
472
- const params = buildParamsFromMatch(match, paramNames);
461
+ const params = buildParamsFromMatch(match, paramNames, catchAll);
473
462
 
474
463
  if (!satisfiesConstraints(params, constraints)) {
475
464
  continue;
@@ -518,7 +507,7 @@ export function findMatch<TEnv>(
518
507
 
519
508
  const altMatch = regex.exec(alternatePathname);
520
509
  if (altMatch) {
521
- const params = buildParamsFromMatch(altMatch, paramNames);
510
+ const params = buildParamsFromMatch(altMatch, paramNames, catchAll);
522
511
 
523
512
  if (!satisfiesConstraints(params, constraints)) {
524
513
  continue;