@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
@@ -26,6 +26,7 @@ import type { MatchContext } from "./match-context.js";
26
26
  import type { MatchApiDeps, ActionContext } from "./types.js";
27
27
  import {
28
28
  getRequestContext,
29
+ _getRequestContext,
29
30
  setRequestContextPrevRouteKey,
30
31
  } from "../server/request-context.js";
31
32
  import { isAutoGeneratedRouteName } from "../route-name.js";
@@ -47,31 +48,67 @@ export async function createMatchContextForFull<TEnv>(
47
48
  deps: MatchApiDeps<TEnv>,
48
49
  findInterceptForRoute: MatchApiDeps<TEnv>["findInterceptForRoute"],
49
50
  ): Promise<MatchContext<TEnv> | { type: "redirect"; redirectUrl: string }> {
50
- const url = new URL(request.url);
51
- const pathname = url.pathname;
51
+ const reqCtx = _getRequestContext();
52
+ const cleanUrl = reqCtx?.url ?? stripInternalParams(new URL(request.url));
53
+ const pathname = cleanUrl.pathname;
52
54
 
53
55
  const metricsStore = deps.getMetricsStore();
54
56
 
55
- const result = await resolveRoute<TEnv>(pathname, {
56
- findMatch: (p) => deps.findMatch(p, metricsStore),
57
- metricsStore,
58
- isSSR: true,
59
- });
57
+ const isHmr = !!request.headers.get("X-RSC-HMR");
58
+ // HMR: clear the manifest cache so stale handler references are discarded.
59
+ if (isHmr) {
60
+ clearManifestCache();
61
+ }
62
+
63
+ // Reuse the snapshot classifyRequest already resolved when it is a full-render
64
+ // snapshot (isSSR:true) and HMR has not invalidated it — mirrors the partial
65
+ // path. A partial-mode snapshot (isSSR:false) carries the wrong loading()
66
+ // entries and lives in a different manifest partition, so it is NOT reusable.
67
+ const classified = reqCtx?._classifiedRoute;
68
+ const classifiedRoute =
69
+ !isHmr && classified?.isSSR === true ? classified : undefined;
70
+
71
+ // On the reuse path, findMatch + manifest-loading already ran during
72
+ // classifyRequest (which carries no metricsStore), so only the cheap
73
+ // entries/cacheScope materialization is timed here under a distinct label. On
74
+ // the fresh path, resolveRoute emits route-matching + manifest-loading as usual.
75
+ const routeMatchStart = metricsStore ? performance.now() : 0;
60
76
 
61
- if (!result) {
62
- throw new RouteNotFoundError(`No route matched for ${pathname}`, {
63
- cause: { pathname, method: request.method },
77
+ let snapshot: RouteSnapshot<TEnv>;
78
+ if (classifiedRoute && classifiedRoute.manifestEntry) {
79
+ snapshot = ensureFullRouteSnapshot(classifiedRoute);
80
+ if (metricsStore) {
81
+ metricsStore.metrics.push({
82
+ label: "route-matching:reuse",
83
+ duration: performance.now() - routeMatchStart,
84
+ startTime: routeMatchStart - metricsStore.requestStart,
85
+ });
86
+ }
87
+ } else {
88
+ const result = await resolveRoute<TEnv>(pathname, {
89
+ findMatch: (p) => deps.findMatch(p, metricsStore),
90
+ metricsStore,
91
+ isSSR: true,
64
92
  });
65
- }
66
93
 
67
- if (result.type === "redirect") {
68
- return {
69
- type: "redirect",
70
- redirectUrl: result.redirectTo + url.search,
71
- };
72
- }
94
+ if (!result) {
95
+ throw new RouteNotFoundError(`No route matched for ${pathname}`, {
96
+ cause: { pathname, method: request.method },
97
+ });
98
+ }
99
+
100
+ if (result.type === "redirect") {
101
+ // Preserve the raw query string (including internal _rsc* params) on the
102
+ // redirect target, matching the pre-reuse behavior of `url.search`.
103
+ const rawSearch = (reqCtx?.originalUrl ?? new URL(request.url)).search;
104
+ return {
105
+ type: "redirect",
106
+ redirectUrl: result.redirectTo + rawSearch,
107
+ };
108
+ }
73
109
 
74
- const snapshot = result.snapshot;
110
+ snapshot = result.snapshot;
111
+ }
75
112
 
76
113
  const { matched } = snapshot;
77
114
 
@@ -79,8 +116,6 @@ export async function createMatchContextForFull<TEnv>(
79
116
  matched.pt = true;
80
117
  }
81
118
 
82
- const cleanUrl = stripInternalParams(url);
83
-
84
119
  const handlerContext = createHandlerContext(
85
120
  matched.params,
86
121
  request,
@@ -164,8 +199,13 @@ export async function createMatchContextForPartial<TEnv>(
164
199
  findInterceptForRoute: MatchApiDeps<TEnv>["findInterceptForRoute"],
165
200
  actionContext?: ActionContext,
166
201
  ): Promise<MatchContext<TEnv> | null> {
167
- const url = new URL(request.url);
168
- const pathname = url.pathname;
202
+ const reqCtx = _getRequestContext();
203
+ // resolveNavigation needs the raw URL (reads internal _rsc_segments /
204
+ // _rsc_stale params that stripInternalParams removes); the clean URL feeds the
205
+ // handler context. Both come off the request context when present.
206
+ const rawUrl = reqCtx?.originalUrl ?? new URL(request.url);
207
+ const cleanUrl = reqCtx?.url ?? stripInternalParams(rawUrl);
208
+ const pathname = cleanUrl.pathname;
169
209
 
170
210
  const metricsStore = deps.getMetricsStore();
171
211
 
@@ -177,11 +217,13 @@ export async function createMatchContextForPartial<TEnv>(
177
217
  }
178
218
 
179
219
  // Reuse the classified snapshot when available and not invalidated by HMR.
180
- // classifyRequest already called resolveRoute(lite) with isSSR=false, which
181
- // matches the partial path. On HMR, discard to pick up manifest changes.
182
- const classifiedRoute = isHmr
183
- ? undefined
184
- : getRequestContext()?._classifiedRoute;
220
+ // classifyRequest resolves a partial request with isSSR=false, matching this
221
+ // path. Never reuse an isSSR:true (full-render) snapshot its loading()
222
+ // entries and manifest partition are wrong here. On HMR, discard to pick up
223
+ // manifest changes.
224
+ const classified = reqCtx?._classifiedRoute;
225
+ const classifiedRoute =
226
+ !isHmr && classified?.isSSR !== true ? classified : undefined;
185
227
 
186
228
  // Time route matching. On the reuse path, only nav findMatch calls are new
187
229
  // (current-route findMatch and manifest-loading were already timed during
@@ -220,7 +262,7 @@ export async function createMatchContextForPartial<TEnv>(
220
262
  matched.pt = true;
221
263
  }
222
264
 
223
- const nav = resolveNavigation(request, url, matched.routeKey, {
265
+ const nav = await resolveNavigation(request, rawUrl, matched.routeKey, {
224
266
  findMatch: deps.findMatch,
225
267
  });
226
268
  if (!nav) {
@@ -243,8 +285,6 @@ export async function createMatchContextForPartial<TEnv>(
243
285
  });
244
286
  }
245
287
 
246
- const cleanUrl = stripInternalParams(url);
247
-
248
288
  const handlerContext = createHandlerContext(
249
289
  matched.params,
250
290
  request,
@@ -287,7 +327,11 @@ export async function createMatchContextForPartial<TEnv>(
287
327
  });
288
328
  }
289
329
 
290
- setRequestContextPrevRouteKey(nav.effectiveFromMatch?.routeKey);
330
+ setRequestContextPrevRouteKey(
331
+ nav.effectiveFromMatch?.routeKey,
332
+ nav.effectiveFromUrl,
333
+ nav.effectiveFromMatch?.params ?? nav.prevParams,
334
+ );
291
335
 
292
336
  const interceptSelectorContext: InterceptSelectorContext = {
293
337
  from: nav.effectiveFromUrl,
@@ -400,7 +444,7 @@ export async function matchError<TEnv>(
400
444
 
401
445
  debugLog("matchError", "matching error", { pathname });
402
446
 
403
- const matched = deps.findMatch(pathname);
447
+ const matched = await deps.findMatch(pathname);
404
448
  if (!matched) {
405
449
  debugWarn("matchError", "no route matched", { pathname });
406
450
  return null;
@@ -41,6 +41,109 @@ import {
41
41
  } from "./telemetry.js";
42
42
  import { _getRequestContext } from "../server/request-context.js";
43
43
 
44
+ /**
45
+ * Per-call telemetry lifecycle emitter for match()/matchPartial(). Each method
46
+ * reproduces the exact event object the two functions used to emit inline and is
47
+ * gated on the same per-call flag (`enabled` = the request's `emitTelemetry`), so
48
+ * a PPR shell-capture run (enabled=false) emits nothing while a foreground run
49
+ * emits byte-identical events. Extracted so the two transactions can't drift;
50
+ * pinned by thrown-response-telemetry.test.ts and
51
+ * shell-capture-telemetry-suppression.test.ts.
52
+ */
53
+ interface LifecycleEmitter {
54
+ start(): void;
55
+ end(segmentCount: number, cacheHit: boolean, status?: number): void;
56
+ cacheDecision(
57
+ routeKey: string,
58
+ state: {
59
+ cacheHit: boolean;
60
+ cacheSource?: "runtime" | "prerender";
61
+ shouldRevalidate?: boolean;
62
+ },
63
+ segments: CacheSegmentSignal[],
64
+ ): void;
65
+ error(error: Error, phase: string): void;
66
+ }
67
+
68
+ function createLifecycleEmitter(args: {
69
+ enabled: boolean;
70
+ sink: TelemetrySink;
71
+ requestId: string | undefined;
72
+ method: string;
73
+ pathname: string;
74
+ transaction: "match" | "matchPartial";
75
+ isPartial: boolean;
76
+ matchStart: number;
77
+ }): LifecycleEmitter {
78
+ return {
79
+ start(): void {
80
+ if (!args.enabled) return;
81
+ safeEmit(args.sink, {
82
+ type: "request.start",
83
+ timestamp: args.matchStart,
84
+ requestId: args.requestId,
85
+ method: args.method,
86
+ pathname: args.pathname,
87
+ transaction: args.transaction,
88
+ isPartial: args.isPartial,
89
+ });
90
+ },
91
+ end(segmentCount: number, cacheHit: boolean, status?: number): void {
92
+ if (!args.enabled) return;
93
+ safeEmit(args.sink, {
94
+ type: "request.end",
95
+ timestamp: performance.now(),
96
+ requestId: args.requestId,
97
+ method: args.method,
98
+ pathname: args.pathname,
99
+ transaction: args.transaction,
100
+ durationMs: performance.now() - args.matchStart,
101
+ segmentCount,
102
+ cacheHit,
103
+ // Only a thrown-Response short-circuit passes a status; a normal render
104
+ // completion omits it (the Response is built after match()).
105
+ ...(status !== undefined && { status }),
106
+ });
107
+ },
108
+ cacheDecision(
109
+ routeKey: string,
110
+ state: {
111
+ cacheHit: boolean;
112
+ cacheSource?: "runtime" | "prerender";
113
+ shouldRevalidate?: boolean;
114
+ },
115
+ segments: CacheSegmentSignal[],
116
+ ): void {
117
+ if (!args.enabled) return;
118
+ safeEmit(args.sink, {
119
+ type: "cache.decision",
120
+ timestamp: performance.now(),
121
+ requestId: args.requestId,
122
+ pathname: args.pathname,
123
+ routeKey,
124
+ hit: state.cacheHit,
125
+ shouldRevalidate: !!state.shouldRevalidate,
126
+ source: state.cacheSource,
127
+ segments,
128
+ });
129
+ },
130
+ error(error: Error, phase: string): void {
131
+ if (!args.enabled) return;
132
+ safeEmit(args.sink, {
133
+ type: "request.error",
134
+ timestamp: performance.now(),
135
+ requestId: args.requestId,
136
+ method: args.method,
137
+ pathname: args.pathname,
138
+ transaction: args.transaction,
139
+ error,
140
+ phase,
141
+ durationMs: performance.now() - args.matchStart,
142
+ });
143
+ },
144
+ };
145
+ }
146
+
44
147
  export interface MatchHandlerDeps<TEnv = any> {
45
148
  buildRouterContext: () => RouterContext<TEnv>;
46
149
  callOnError: (error: unknown, phase: ErrorPhase, context: any) => void;
@@ -155,42 +258,43 @@ export function createMatchHandlers<TEnv = any>(
155
258
  }
156
259
 
157
260
  async function match(request: Request, env: TEnv): Promise<MatchResult> {
158
- const requestId = hasTelemetry ? getRequestId(request) : undefined;
261
+ // Silence telemetry for the PPR background shell capture: it re-runs match()
262
+ // under a derived request context flagged _shellCaptureRun (shell-capture.ts
263
+ // attemptCapture), re-using the foreground Request — a second request.start/
264
+ // cache.decision/request.end stamped with the same WeakMap-keyed requestId
265
+ // would double-count dashboards. Derived here (inside the capture's active
266
+ // request-context ALS) so the read sees the derived context, not module state.
267
+ const emitTelemetry =
268
+ hasTelemetry && !_getRequestContext()?._shellCaptureRun;
269
+ const requestId = emitTelemetry ? getRequestId(request) : undefined;
159
270
  return runWithRouterLogContext({ request, transaction: "match" }, () => {
160
271
  const routerCtx = buildRouterContext();
272
+ // Also mute in-pipeline observeEvent emitters (revalidation.decision,
273
+ // cache-lookup's cache.decision) which read routerCtx.telemetry.
274
+ if (!emitTelemetry) routerCtx.telemetry = undefined;
161
275
  routerCtx.requestId = requestId;
162
276
  return runWithRouterContext(routerCtx, async () =>
163
277
  withRouterLogScope("match", async () => {
164
278
  const matchStart = performance.now();
165
- const pathname = new URL(request.url).pathname;
166
- if (hasTelemetry) {
167
- safeEmit(telemetry, {
168
- type: "request.start",
169
- timestamp: matchStart,
170
- requestId,
171
- method: request.method,
172
- pathname,
173
- transaction: "match",
174
- isPartial: false,
175
- });
176
- }
279
+ const pathname =
280
+ _getRequestContext()?.url?.pathname ??
281
+ new URL(request.url).pathname;
282
+ const emitter = createLifecycleEmitter({
283
+ enabled: emitTelemetry,
284
+ sink: telemetry,
285
+ requestId,
286
+ method: request.method,
287
+ pathname,
288
+ transaction: "match",
289
+ isPartial: false,
290
+ matchStart,
291
+ });
292
+ emitter.start();
177
293
 
178
294
  const result = await createMatchContextForFull(request, env);
179
295
 
180
296
  if ("type" in result && result.type === "redirect") {
181
- if (hasTelemetry) {
182
- safeEmit(telemetry, {
183
- type: "request.end",
184
- timestamp: performance.now(),
185
- requestId,
186
- method: request.method,
187
- pathname,
188
- transaction: "match",
189
- durationMs: performance.now() - matchStart,
190
- segmentCount: 0,
191
- cacheHit: false,
192
- });
193
- }
297
+ emitter.end(0, false);
194
298
  return {
195
299
  segments: [],
196
300
  matched: [],
@@ -210,51 +314,26 @@ export function createMatchHandlers<TEnv = any>(
210
314
  if (hasTelemetry || cacheSignalEnabled) {
211
315
  const signalSegments = buildSignal(ctx.routeKey, state);
212
316
  recordSignalIfEnabled(signalSegments);
213
- if (hasTelemetry) {
214
- safeEmit(telemetry, {
215
- type: "cache.decision",
216
- timestamp: performance.now(),
217
- requestId,
218
- pathname,
219
- routeKey: ctx.routeKey,
220
- hit: state.cacheHit,
221
- shouldRevalidate: !!state.shouldRevalidate,
222
- source: state.cacheSource,
223
- segments: signalSegments,
224
- });
225
- }
226
- }
227
- if (hasTelemetry) {
228
- safeEmit(telemetry, {
229
- type: "request.end",
230
- timestamp: performance.now(),
231
- requestId,
232
- method: request.method,
233
- pathname,
234
- transaction: "match",
235
- durationMs: performance.now() - matchStart,
236
- segmentCount: matchResult.segments.length,
237
- cacheHit: state.cacheHit,
238
- });
317
+ emitter.cacheDecision(ctx.routeKey, state, signalSegments);
239
318
  }
319
+ emitter.end(matchResult.segments.length, state.cacheHit);
240
320
  return matchResult;
241
321
  } catch (error) {
242
- if (hasTelemetry) {
243
- const errorObj =
244
- error instanceof Error ? error : new Error(String(error));
245
- safeEmit(telemetry, {
246
- type: "request.error",
247
- timestamp: performance.now(),
248
- requestId,
249
- method: request.method,
250
- pathname,
251
- transaction: "match",
252
- error: errorObj,
253
- phase: error instanceof Response ? "redirect" : "routing",
254
- durationMs: performance.now() - matchStart,
255
- });
322
+ if (error instanceof Response) {
323
+ // A thrown Response (middleware short-circuit — redirect / auth
324
+ // gate) is a COMPLETED request from the consumer's seat, not an
325
+ // error: emit request.end (the same shape the non-thrown redirect
326
+ // result above already emits), never request.error with a
327
+ // synthetic "[object Response]" error. Rethrow so the caller
328
+ // drives the redirect. Carry the Response's status so a sink can
329
+ // tell a 3xx short-circuit from a 2xx completion.
330
+ emitter.end(0, false, error.status);
331
+ throw error;
256
332
  }
257
- if (error instanceof Response) throw error;
333
+ emitter.error(
334
+ error instanceof Error ? error : new Error(String(error)),
335
+ "routing",
336
+ );
258
337
  callOnError(error, "routing", {
259
338
  request,
260
339
  url: ctx.url,
@@ -294,27 +373,34 @@ export function createMatchHandlers<TEnv = any>(
294
373
  context: TEnv,
295
374
  actionContext?: ActionContext,
296
375
  ): Promise<MatchResult | null> {
297
- const partialRequestId = hasTelemetry ? getRequestId(request) : undefined;
376
+ // See match() above: the PPR shell capture re-runs matchPartial() under a
377
+ // _shellCaptureRun context and must stay invisible to the sink.
378
+ const emitTelemetry =
379
+ hasTelemetry && !_getRequestContext()?._shellCaptureRun;
380
+ const partialRequestId = emitTelemetry ? getRequestId(request) : undefined;
298
381
  return runWithRouterLogContext(
299
382
  { request, transaction: "matchPartial" },
300
383
  () => {
301
384
  const routerCtx = buildRouterContext();
385
+ if (!emitTelemetry) routerCtx.telemetry = undefined;
302
386
  routerCtx.requestId = partialRequestId;
303
387
  return runWithRouterContext(routerCtx, async () =>
304
388
  withRouterLogScope("matchPartial", async () => {
305
389
  const matchStart = performance.now();
306
- const pathname = new URL(request.url).pathname;
307
- if (hasTelemetry) {
308
- safeEmit(telemetry, {
309
- type: "request.start",
310
- timestamp: matchStart,
311
- requestId: partialRequestId,
312
- method: request.method,
313
- pathname,
314
- transaction: "matchPartial",
315
- isPartial: true,
316
- });
317
- }
390
+ const pathname =
391
+ _getRequestContext()?.url?.pathname ??
392
+ new URL(request.url).pathname;
393
+ const emitter = createLifecycleEmitter({
394
+ enabled: emitTelemetry,
395
+ sink: telemetry,
396
+ requestId: partialRequestId,
397
+ method: request.method,
398
+ pathname,
399
+ transaction: "matchPartial",
400
+ isPartial: true,
401
+ matchStart,
402
+ });
403
+ emitter.start();
318
404
 
319
405
  const ctx = await createMatchContextForPartial(
320
406
  request,
@@ -322,19 +408,7 @@ export function createMatchHandlers<TEnv = any>(
322
408
  actionContext,
323
409
  );
324
410
  if (!ctx) {
325
- if (hasTelemetry) {
326
- safeEmit(telemetry, {
327
- type: "request.end",
328
- timestamp: performance.now(),
329
- requestId: partialRequestId,
330
- method: request.method,
331
- pathname,
332
- transaction: "matchPartial",
333
- durationMs: performance.now() - matchStart,
334
- segmentCount: 0,
335
- cacheHit: false,
336
- });
337
- }
411
+ emitter.end(0, false);
338
412
  return null;
339
413
  }
340
414
 
@@ -361,53 +435,25 @@ export function createMatchHandlers<TEnv = any>(
361
435
  if (hasTelemetry || cacheSignalEnabled) {
362
436
  const signalSegments = buildSignal(ctx.routeKey, state);
363
437
  recordSignalIfEnabled(signalSegments);
364
- if (hasTelemetry) {
365
- safeEmit(telemetry, {
366
- type: "cache.decision",
367
- timestamp: performance.now(),
368
- requestId: partialRequestId,
369
- pathname,
370
- routeKey: ctx.routeKey,
371
- hit: state.cacheHit,
372
- shouldRevalidate: !!state.shouldRevalidate,
373
- source: state.cacheSource,
374
- segments: signalSegments,
375
- });
376
- }
377
- }
378
- if (hasTelemetry) {
379
- safeEmit(telemetry, {
380
- type: "request.end",
381
- timestamp: performance.now(),
382
- requestId: partialRequestId,
383
- method: request.method,
384
- pathname,
385
- transaction: "matchPartial",
386
- durationMs: performance.now() - matchStart,
387
- segmentCount: matchResult.segments.length,
388
- cacheHit: state.cacheHit,
389
- });
438
+ emitter.cacheDecision(ctx.routeKey, state, signalSegments);
390
439
  }
440
+ emitter.end(matchResult.segments.length, state.cacheHit);
391
441
  return matchResult;
392
442
  } catch (error) {
393
443
  flushRevalidationTrace();
394
- if (hasTelemetry) {
395
- const errorObj =
396
- error instanceof Error ? error : new Error(String(error));
397
- const phase = actionContext ? "action" : "revalidation";
398
- safeEmit(telemetry, {
399
- type: "request.error",
400
- timestamp: performance.now(),
401
- requestId: partialRequestId,
402
- method: request.method,
403
- pathname,
404
- transaction: "matchPartial",
405
- error: errorObj,
406
- phase: error instanceof Response ? "redirect" : phase,
407
- durationMs: performance.now() - matchStart,
408
- });
444
+ if (error instanceof Response) {
445
+ // A thrown Response (middleware short-circuit — redirect / auth
446
+ // gate) is a COMPLETED request, not an error: emit request.end
447
+ // (parity with match()), never request.error. Rethrow so the
448
+ // caller drives the redirect. Carry the Response's status so a
449
+ // sink can tell a 3xx short-circuit from a 2xx completion.
450
+ emitter.end(0, false, error.status);
451
+ throw error;
409
452
  }
410
- if (error instanceof Response) throw error;
453
+ emitter.error(
454
+ error instanceof Error ? error : new Error(String(error)),
455
+ actionContext ? "action" : "revalidation",
456
+ );
411
457
  callOnError(error, actionContext ? "action" : "revalidation", {
412
458
  request,
413
459
  url: ctx.url,
@@ -105,6 +105,10 @@ import { getRouterContext } from "../router-context.js";
105
105
  import type { GeneratorMiddleware } from "./cache-lookup.js";
106
106
  import { debugLog, debugWarn, getOrCreateRequestId } from "../logging.js";
107
107
  import { INTERNAL_RANGO_DEBUG } from "../../internal-debug.js";
108
+ import {
109
+ runWithRequestContext,
110
+ type RequestContext,
111
+ } from "../../server/request-context.js";
108
112
 
109
113
  /**
110
114
  * Creates background revalidation middleware
@@ -183,33 +187,46 @@ export function withBackgroundRevalidation<TEnv>(
183
187
  const freshLoaderPromises = new Map<string, Promise<any>>();
184
188
  setupLoaderAccess(freshHandlerContext, freshLoaderPromises);
185
189
 
186
- const freshSegments = await ctx.Store.run(() =>
187
- resolveAllSegments(
188
- ctx.entries,
189
- ctx.routeKey,
190
- ctx.matched.params,
191
- freshHandlerContext,
192
- freshLoaderPromises,
193
- { skipLoaders: true },
194
- ),
190
+ // Re-establish the request-context ALS around the re-render. ctx.Store
191
+ // is a different ALS (DSL build context); on workerd a waitUntil task
192
+ // runs detached from the request's I/O context, so a handler/component
193
+ // that reads the ambient getRequestContext() during this background
194
+ // re-render would otherwise throw "called outside of a request context".
195
+ const freshSegments = await runWithRequestContext(
196
+ requestCtx as RequestContext<TEnv>,
197
+ () =>
198
+ ctx.Store.run(() =>
199
+ resolveAllSegments(
200
+ ctx.entries,
201
+ ctx.routeKey,
202
+ ctx.matched.params,
203
+ freshHandlerContext,
204
+ freshLoaderPromises,
205
+ { skipLoaders: true },
206
+ ),
207
+ ),
195
208
  );
196
209
 
197
210
  let freshInterceptSegments: ResolvedSegment[] = [];
198
211
  if (ctx.interceptResult) {
199
- freshInterceptSegments = await ctx.Store.run(() =>
200
- resolveInterceptEntry(
201
- ctx.interceptResult!.intercept,
202
- ctx.interceptResult!.entry,
203
- ctx.matched.params,
204
- freshHandlerContext,
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 },
212
- ),
212
+ freshInterceptSegments = await runWithRequestContext(
213
+ requestCtx as RequestContext<TEnv>,
214
+ () =>
215
+ ctx.Store.run(() =>
216
+ resolveInterceptEntry(
217
+ ctx.interceptResult!.intercept,
218
+ ctx.interceptResult!.entry,
219
+ ctx.matched.params,
220
+ freshHandlerContext,
221
+ true,
222
+ undefined,
223
+ // Skip intercept middleware: this is a post-response background
224
+ // re-render to refresh a stale cached route. The foreground
225
+ // already ran the middleware; re-running it would double its
226
+ // side effects and a short-circuit Response would abort the write.
227
+ { skipMiddleware: true },
228
+ ),
229
+ ),
213
230
  );
214
231
  }
215
232