@rangojs/router 0.23.0 → 0.24.0

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 (128) hide show
  1. package/dist/bin/rango.js +4 -3
  2. package/dist/types/browser/merge-segment-loaders.d.ts +5 -2
  3. package/dist/types/browser/partial-update.d.ts +6 -4
  4. package/dist/types/browser/react/NavigationProvider.d.ts +11 -7
  5. package/dist/types/browser/react/context.d.ts +3 -2
  6. package/dist/types/browser/types.d.ts +5 -1
  7. package/dist/types/build/route-types/scan-filter.d.ts +7 -1
  8. package/dist/types/cache/document-cache-policy.d.ts +24 -0
  9. package/dist/types/cache/document-cache.d.ts +2 -10
  10. package/dist/types/context-var.d.ts +12 -0
  11. package/dist/types/errors.d.ts +10 -0
  12. package/dist/types/handles/pushed-by.d.ts +8 -0
  13. package/dist/types/index.d.ts +4 -0
  14. package/dist/types/index.rsc.d.ts +2 -0
  15. package/dist/types/response-utils.d.ts +7 -0
  16. package/dist/types/reverse.d.ts +8 -8
  17. package/dist/types/route-definition/rewrite.d.ts +56 -0
  18. package/dist/types/route-map-builder.d.ts +7 -2
  19. package/dist/types/router/basename.d.ts +7 -0
  20. package/dist/types/router/handler-context.d.ts +1 -5
  21. package/dist/types/router/internal-params.d.ts +8 -0
  22. package/dist/types/router/middleware-types.d.ts +15 -0
  23. package/dist/types/router/revalidation.d.ts +1 -2
  24. package/dist/types/router/route-trie-builder.d.ts +10 -0
  25. package/dist/types/router/segment-resolution/mask-nested.d.ts +4 -2
  26. package/dist/types/router/segment-resolution/prefetch-deferral.d.ts +36 -13
  27. package/dist/types/rsc/full-payload.d.ts +11 -0
  28. package/dist/types/rsc/handler.d.ts +33 -0
  29. package/dist/types/rsc/helpers.d.ts +8 -0
  30. package/dist/types/rsc/loader-fetch.d.ts +16 -0
  31. package/dist/types/rsc/rewrite-passes.d.ts +74 -0
  32. package/dist/types/rsc/ssr-setup.d.ts +3 -1
  33. package/dist/types/server/request-context.d.ts +39 -2
  34. package/dist/types/server/rewrite-trail.d.ts +49 -0
  35. package/dist/types/server/slot-accessors.d.ts +29 -0
  36. package/dist/types/testing/index.d.ts +1 -1
  37. package/dist/types/testing/render-route.d.ts +8 -0
  38. package/dist/types/testing/run-middleware.d.ts +20 -0
  39. package/dist/types/testing/serve-shell-request.d.ts +2 -1
  40. package/dist/types/thenable-status.d.ts +6 -0
  41. package/dist/types/types/request-scope.d.ts +7 -0
  42. package/dist/types/vite/discovery/state.d.ts +12 -9
  43. package/dist/vite/index.js +164 -103
  44. package/package.json +1 -1
  45. package/skills/caching/SKILL.md +12 -0
  46. package/skills/catalog.json +1 -1
  47. package/skills/client-urls/SKILL.md +84 -0
  48. package/skills/hooks/SKILL.md +11 -6
  49. package/skills/hooks/data.md +3 -1
  50. package/skills/loader/SKILL.md +7 -1
  51. package/skills/middleware/SKILL.md +183 -2
  52. package/skills/ppr/SKILL.md +9 -0
  53. package/skills/rango/SKILL.md +1 -1
  54. package/skills/server-actions/SKILL.md +27 -0
  55. package/skills/testing/client-components.md +1 -0
  56. package/skills/testing/middleware.md +31 -10
  57. package/skills/testing/response-routes.md +1 -0
  58. package/skills/testing/serve-shell-request.md +19 -1
  59. package/skills/use-cache/SKILL.md +4 -2
  60. package/src/__augment-tests__/augmented.check.ts +20 -0
  61. package/src/browser/event-controller.ts +3 -0
  62. package/src/browser/merge-segment-loaders.ts +31 -18
  63. package/src/browser/navigation-bridge.ts +19 -3
  64. package/src/browser/partial-update.ts +27 -12
  65. package/src/browser/react/Link.tsx +7 -9
  66. package/src/browser/react/NavigationProvider.tsx +46 -26
  67. package/src/browser/react/context.ts +3 -2
  68. package/src/browser/react/use-router.ts +2 -4
  69. package/src/browser/server-action-bridge.ts +6 -0
  70. package/src/browser/types.ts +5 -1
  71. package/src/build/route-types/scan-filter.ts +11 -4
  72. package/src/cache/document-cache-policy.ts +131 -0
  73. package/src/cache/document-cache.ts +12 -119
  74. package/src/context-var.ts +32 -2
  75. package/src/errors.ts +19 -0
  76. package/src/handles/pushed-by.ts +26 -0
  77. package/src/index.rsc.ts +8 -0
  78. package/src/index.ts +7 -0
  79. package/src/response-utils.ts +19 -0
  80. package/src/reverse.ts +17 -14
  81. package/src/route-definition/redirect.ts +2 -15
  82. package/src/route-definition/rewrite.ts +146 -0
  83. package/src/route-map-builder.ts +14 -11
  84. package/src/router/basename.ts +20 -0
  85. package/src/router/handler-context.ts +3 -13
  86. package/src/router/intercept-resolution.ts +29 -12
  87. package/src/router/internal-params.ts +16 -0
  88. package/src/router/loader-resolution.ts +2 -0
  89. package/src/router/match-middleware/background-revalidation.ts +11 -9
  90. package/src/router/match-middleware/cache-lookup.ts +20 -15
  91. package/src/router/match-middleware/cache-store.ts +9 -11
  92. package/src/router/match-middleware/intercept-resolution.ts +4 -2
  93. package/src/router/middleware-types.ts +16 -0
  94. package/src/router/middleware.ts +127 -49
  95. package/src/router/revalidation.ts +1 -1
  96. package/src/router/route-trie-builder.ts +28 -1
  97. package/src/router/segment-resolution/loader-snapshot.ts +18 -4
  98. package/src/router/segment-resolution/mask-nested.ts +8 -4
  99. package/src/router/segment-resolution/prefetch-deferral.ts +57 -17
  100. package/src/router/segment-resolution/revalidation.ts +57 -33
  101. package/src/rsc/full-payload.ts +37 -5
  102. package/src/rsc/handler.ts +82 -55
  103. package/src/rsc/helpers.ts +24 -0
  104. package/src/rsc/loader-fetch.ts +79 -24
  105. package/src/rsc/response-cache-serve.ts +18 -2
  106. package/src/rsc/response-route-handler.ts +4 -7
  107. package/src/rsc/rewrite-passes.ts +203 -0
  108. package/src/rsc/rsc-rendering.ts +4 -2
  109. package/src/rsc/ssr-setup.ts +11 -5
  110. package/src/server/cookie-parse.ts +36 -12
  111. package/src/server/cookie-store.ts +7 -3
  112. package/src/server/handle-store.ts +48 -17
  113. package/src/server/request-context.ts +154 -63
  114. package/src/server/request-headers.ts +4 -14
  115. package/src/server/rewrite-trail.ts +190 -0
  116. package/src/server/slot-accessors.ts +57 -0
  117. package/src/ssr/index.tsx +118 -16
  118. package/src/testing/dispatch.ts +138 -67
  119. package/src/testing/index.ts +1 -0
  120. package/src/testing/render-route.tsx +16 -3
  121. package/src/testing/run-loader.ts +2 -0
  122. package/src/testing/run-middleware.ts +56 -11
  123. package/src/testing/serve-shell-request.ts +15 -2
  124. package/src/thenable-status.ts +11 -0
  125. package/src/types/request-scope.ts +8 -0
  126. package/src/vite/discovery/state.ts +21 -13
  127. package/src/vite/plugins/expose-internal-ids.ts +2 -3
  128. package/src/vite/router-discovery.ts +29 -27
@@ -99,11 +99,8 @@ export interface PrefetchDeferral {
99
99
  * not stored and not held. Empty outside a prefetch.
100
100
  */
101
101
  readonly skipped: ReadonlySet<string>;
102
- /**
103
- * One of `skipped` is a chain entry: nothing below it runs, so the response
104
- * has no content for it, a route cache() record included.
105
- */
106
- readonly skipsChain: boolean;
102
+ /** How this request may use the route's cache() record (recordUse). */
103
+ readonly record: RecordUse;
107
104
  /** Segment id of the handler unit this prefetch skipped. */
108
105
  deferredUnit?: string;
109
106
  /**
@@ -125,6 +122,31 @@ export interface PrefetchDeferral {
125
122
  deferredLoaders?: Map<string, DeferredLoader>;
126
123
  }
127
124
 
125
+ /**
126
+ * How a request may use the route's cache() record, decided once per match
127
+ * (planPrefetchDeferral) for the cache middleware.
128
+ */
129
+ export interface RecordUse {
130
+ /**
131
+ * withCacheLookup reads it. False when this prefetch skips a chain unit
132
+ * (PrefetchDeferral.skipped): the unit takes everything below it, so the
133
+ * read would replay the record's handle pushes for segments the response
134
+ * does not carry. Decided from what the client holds, never per tree: a
135
+ * prefetch that holds the flagged layout reads and writes the record like
136
+ * any other request.
137
+ */
138
+ readonly read: boolean;
139
+ /** withCacheStore writes it. False where `read` is: the record would be incomplete. */
140
+ readonly write: boolean;
141
+ /**
142
+ * A background re-render of the whole route may write it: the stale-hit
143
+ * refresh (withBackgroundRevalidation) and the proactive write a partial
144
+ * response needs (withCacheStore). False for a fill: the re-render runs
145
+ * the handlers of segments the client holds, which a fill does not run.
146
+ */
147
+ readonly refresh: boolean;
148
+ }
149
+
128
150
  /** A loader this prefetch deferred: PrefetchDeferral.deferredLoaders. */
129
151
  export interface DeferredLoader {
130
152
  /**
@@ -329,7 +351,12 @@ export function planPrefetchDeferral(
329
351
  }
330
352
  }
331
353
  }
332
- return { scope, mode, storedFrom, held, skipped, skipsChain };
354
+ const record: RecordUse = {
355
+ read: !skipsChain,
356
+ write: !skipsChain,
357
+ refresh: mode !== "fill",
358
+ };
359
+ return { scope, mode, storedFrom, held, skipped, record };
333
360
  }
334
361
 
335
362
  export function getPrefetchDeferral(
@@ -344,6 +371,30 @@ export function isFillRequest(ctx: HandlerContext<any, any>): boolean {
344
371
  return getPrefetchDeferral(ctx)?.mode === "fill";
345
372
  }
346
373
 
374
+ /**
375
+ * A fill's whole revalidation rule (R5): send a segment the client does not
376
+ * hold, keep one it holds, and consult nothing else (no revalidate()
377
+ * predicate, no params or search comparison, no trace entry). Undefined on
378
+ * any other request, where revalidation decides. `held` is the set the
379
+ * deciding site resolves with.
380
+ */
381
+ export function fillSends(
382
+ ctx: HandlerContext<any, any>,
383
+ segmentId: string,
384
+ held: ReadonlySet<string>,
385
+ ): boolean | undefined {
386
+ return isFillRequest(ctx) ? !held.has(segmentId) : undefined;
387
+ }
388
+
389
+ const RECORD_OPEN: RecordUse = { read: true, write: true, refresh: true };
390
+
391
+ /** PrefetchDeferral.record, or all of it open when the match has no plan. */
392
+ export function recordUse(
393
+ ctx: HandlerContext<any, any> | undefined,
394
+ ): RecordUse {
395
+ return getPrefetchDeferral(ctx)?.record ?? RECORD_OPEN;
396
+ }
397
+
347
398
  /**
348
399
  * Register a loader this prefetch deferred, with the segment that registers
349
400
  * it and how to start it there (PrefetchDeferral.deferredLoaders). The first
@@ -481,17 +532,6 @@ export function firstUnitCandidate(
481
532
  return undefined;
482
533
  }
483
534
 
484
- /**
485
- * This prefetch skips a layout above the route cache's boundary, and with it
486
- * everything the record below holds: withCacheLookup and withCacheStore leave
487
- * the record alone for it. Decided per request, from what the client holds:
488
- * a prefetch of the same tree that holds the layout reads and writes the
489
- * record like any other request.
490
- */
491
- export function defersAboveRecord(ctx: HandlerContext<any, any>): boolean {
492
- return getPrefetchDeferral(ctx)?.skipsChain === true;
493
- }
494
-
495
535
  /**
496
536
  * Record the handler unit this prefetch skipped. A `chain` entry takes what
497
537
  * the walk reaches after it (PrefetchDeferral.below); a slot is skipped
@@ -9,7 +9,10 @@
9
9
  import type { ReactNode } from "react";
10
10
  import { invariant } from "../../errors";
11
11
  import { revalidate } from "../loader-resolution.js";
12
- import { evaluateRevalidation } from "../revalidation.js";
12
+ import {
13
+ evaluateRevalidation,
14
+ type EvaluateRevalidationOptions,
15
+ } from "../revalidation.js";
13
16
  import {
14
17
  getParallelEntries,
15
18
  getParallelSlotEntries,
@@ -38,8 +41,8 @@ import { entryLoadingMasksLoaders } from "./loader-mask.js";
38
41
  import {
39
42
  defersLoader,
40
43
  deferUnit,
44
+ fillSends,
41
45
  getPrefetchDeferral,
42
- isFillRequest,
43
46
  registerDeferredLoader,
44
47
  } from "./prefetch-deferral.js";
45
48
  import {
@@ -110,6 +113,28 @@ function emitRevalidationDecision(
110
113
  });
111
114
  }
112
115
 
116
+ /**
117
+ * Whether a segment the client holds (`held`, the set resolution works on)
118
+ * renders again. A fill keeps it (prefetch-deferral.ts fillSends): no
119
+ * revalidate() predicate runs and nothing is traced or observed. Any other
120
+ * request asks the segment's revalidate() chain, and `onEvaluated` sees that
121
+ * answer.
122
+ */
123
+ async function revalidateHeld<TEnv>({
124
+ held,
125
+ onEvaluated,
126
+ ...options
127
+ }: EvaluateRevalidationOptions<TEnv> & {
128
+ held: ReadonlySet<string>;
129
+ onEvaluated?: (shouldRevalidate: boolean) => void;
130
+ }): Promise<boolean> {
131
+ const fill = fillSends(options.context, options.segment.id, held);
132
+ if (fill !== undefined) return fill;
133
+ const shouldRevalidate = await evaluateRevalidation(options);
134
+ onEvaluated?.(shouldRevalidate);
135
+ return shouldRevalidate;
136
+ }
137
+
113
138
  // ---------------------------------------------------------------------------
114
139
  // prefetch: false (prefetch-deferral.ts)
115
140
  // ---------------------------------------------------------------------------
@@ -194,7 +219,6 @@ export async function resolveLoadersWithRevalidation<TEnv>(
194
219
  }));
195
220
 
196
221
  const matchedIds = loaderMeta.map((m) => m.segmentId);
197
- const fill = isFillRequest(ctx);
198
222
 
199
223
  const revalidationChecks = await Promise.all(
200
224
  loaderMeta.map(
@@ -221,8 +245,6 @@ export async function resolveLoadersWithRevalidation<TEnv>(
221
245
  }
222
246
  return true;
223
247
  }
224
- // Fill: a held segment is skipped outright, no predicate runs.
225
- if (fill) return false;
226
248
 
227
249
  const dummySegment: ResolvedSegment = {
228
250
  id: segmentId,
@@ -235,7 +257,7 @@ export async function resolveLoadersWithRevalidation<TEnv>(
235
257
  belongsToRoute,
236
258
  };
237
259
 
238
- return await evaluateRevalidation({
260
+ return await revalidateHeld({
239
261
  segment: dummySegment,
240
262
  prevParams,
241
263
  getPrevSegment: null,
@@ -251,6 +273,7 @@ export async function resolveLoadersWithRevalidation<TEnv>(
251
273
  actionContext,
252
274
  stale,
253
275
  traceSource: "loader",
276
+ held: clientSegmentIds,
254
277
  });
255
278
  },
256
279
  async () => true,
@@ -704,10 +727,12 @@ export async function resolveParallelSegmentsWithRevalidation<TEnv>(
704
727
  // runs: a prefetch sends what the slot declares instead of running its
705
728
  // handler.
706
729
  let deferred = deferUnit(context, parallelId, "below");
730
+ // A fill sends exactly the slots the client lacks, ahead of every rule
731
+ // below (fillSends).
732
+ const fill = fillSends(context, parallelId, clientSegmentIds);
707
733
  let shouldResolve: boolean;
708
- if (isFillRequest(context)) {
709
- // Fill: only what the client does not hold renders, no predicate runs.
710
- shouldResolve = !clientSegmentIds.has(parallelId);
734
+ if (fill !== undefined) {
735
+ shouldResolve = fill;
711
736
  } else if (deferred) {
712
737
  shouldResolve = true;
713
738
  } else if (isFullRefetch) {
@@ -880,8 +905,6 @@ export async function resolveEntryHandlerWithRevalidation<TEnv>(
880
905
  }
881
906
  return true;
882
907
  }
883
- // Fill: a held segment is skipped outright, no predicate runs.
884
- if (isFillRequest(context)) return false;
885
908
 
886
909
  const dummySegment: ResolvedSegment = {
887
910
  id: entry.shortCode,
@@ -900,7 +923,7 @@ export async function resolveEntryHandlerWithRevalidation<TEnv>(
900
923
  ...(entry.mountPath ? { mountPath: entry.mountPath } : {}),
901
924
  };
902
925
 
903
- const shouldRevalidate = await evaluateRevalidation({
926
+ return revalidateHeld({
904
927
  segment: dummySegment,
905
928
  prevParams,
906
929
  getPrevSegment: null,
@@ -917,18 +940,20 @@ export async function resolveEntryHandlerWithRevalidation<TEnv>(
917
940
  stale,
918
941
  traceSource:
919
942
  entry.type === "route" ? "route-handler" : "layout-handler",
943
+ held: clientSegmentIds,
944
+ onEvaluated: (shouldRevalidate) => {
945
+ emitRevalidationDecision(
946
+ entry.shortCode,
947
+ context.pathname,
948
+ routeKey,
949
+ shouldRevalidate,
950
+ );
951
+ debugLog("segment.revalidate", "entry revalidation decision", {
952
+ segmentId: entry.shortCode,
953
+ shouldRevalidate,
954
+ });
955
+ },
920
956
  });
921
- emitRevalidationDecision(
922
- entry.shortCode,
923
- context.pathname,
924
- routeKey,
925
- shouldRevalidate,
926
- );
927
- debugLog("segment.revalidate", "entry revalidation decision", {
928
- segmentId: entry.shortCode,
929
- shouldRevalidate,
930
- });
931
- return shouldRevalidate;
932
957
  },
933
958
  async () => {
934
959
  // Only an entry the client lacks (defersUnit). Its handler would
@@ -1308,8 +1333,6 @@ export async function resolveOrphanLayoutWithRevalidation<TEnv>(
1308
1333
  }
1309
1334
  return true;
1310
1335
  }
1311
- // Fill: a held segment is skipped outright, no predicate runs.
1312
- if (isFillRequest(context)) return false;
1313
1336
 
1314
1337
  const dummySegment: ResolvedSegment = {
1315
1338
  id: orphan.shortCode,
@@ -1323,7 +1346,7 @@ export async function resolveOrphanLayoutWithRevalidation<TEnv>(
1323
1346
  ...(orphan.mountPath ? { mountPath: orphan.mountPath } : {}),
1324
1347
  };
1325
1348
 
1326
- const shouldRevalidate = await evaluateRevalidation({
1349
+ return revalidateHeld({
1327
1350
  segment: dummySegment,
1328
1351
  prevParams,
1329
1352
  getPrevSegment: null,
@@ -1339,14 +1362,15 @@ export async function resolveOrphanLayoutWithRevalidation<TEnv>(
1339
1362
  actionContext,
1340
1363
  stale,
1341
1364
  traceSource: "orphan-layout",
1365
+ held: clientSegmentIds,
1366
+ onEvaluated: (shouldRevalidate) =>
1367
+ emitRevalidationDecision(
1368
+ orphan.shortCode,
1369
+ context.pathname,
1370
+ routeKey,
1371
+ shouldRevalidate,
1372
+ ),
1342
1373
  });
1343
- emitRevalidationDecision(
1344
- orphan.shortCode,
1345
- context.pathname,
1346
- routeKey,
1347
- shouldRevalidate,
1348
- );
1349
- return shouldRevalidate;
1350
1374
  },
1351
1375
  async () => {
1352
1376
  // Below a unit a prefetch skipped: the orphan goes with it.
@@ -14,7 +14,11 @@ import type { HandlerContext } from "./handler-context.js";
14
14
  import type { RequestContext } from "../server/request-context.js";
15
15
  import type { HandleStore } from "../server/handle-store.js";
16
16
  import type { Theme } from "../theme/types.js";
17
- import { documentCacheStoresRender } from "../cache/document-cache.js";
17
+ import {
18
+ rendersForSharedAudience,
19
+ rewriteTrail,
20
+ visitorUrl,
21
+ } from "../server/rewrite-trail.js";
18
22
  import { attachTransitionWhen } from "./attach-transition-when.js";
19
23
  import { publicRouteName } from "../route-name.js";
20
24
  import { resolvedHandleStream } from "../handles/deferred-resolution.js";
@@ -38,7 +42,7 @@ export function buildFullPayload(
38
42
  ): RscPayload {
39
43
  return {
40
44
  metadata: {
41
- pathname: url.pathname,
45
+ pathname: renderedLocation(reqCtx, url).pathname,
42
46
  routerId: ctx.router.id,
43
47
  basename: ctx.router.basename,
44
48
  segments: attachTransitionWhen(m.segments, reqCtx),
@@ -84,6 +88,36 @@ export function buildFullPayload(
84
88
  };
85
89
  }
86
90
 
91
+ /**
92
+ * The URL a render shows as the page's location (payload `pathname`, the SSR
93
+ * search and origin seed). For a request middleware rewrote, the visitor's
94
+ * URL, so the server markup and the hydrating client (which reads the address
95
+ * bar) agree on `usePathname()` and `useSearchParams()`. A render other
96
+ * visitors may be served shows `url`, the target, as a direct visit does: a
97
+ * shell capture, and a render the document cache stores (its key is the
98
+ * target). A render that shows the visitor's URL marks
99
+ * `_payloadVisitorLocation`, and the document cache does not store it.
100
+ */
101
+ export function renderedLocation(
102
+ reqCtx:
103
+ | Pick<
104
+ RequestContext<any>,
105
+ | "_variables"
106
+ | "_shellCaptureRun"
107
+ | "_documentCacheRender"
108
+ | "_payloadVisitorLocation"
109
+ | "res"
110
+ >
111
+ | undefined,
112
+ url: URL,
113
+ ): URL {
114
+ if (!reqCtx || !rewriteTrail(reqCtx) || rendersForSharedAudience(reqCtx)) {
115
+ return url;
116
+ }
117
+ reqCtx._payloadVisitorLocation = true;
118
+ return visitorUrl(reqCtx, url);
119
+ }
120
+
87
121
  /**
88
122
  * The theme a document payload carries as `metadata.initialTheme` (the
89
123
  * ThemeProvider's first state), read with no identity guard: it is the
@@ -116,9 +150,7 @@ export function payloadInitialTheme(
116
150
  ): Theme | undefined {
117
151
  const config = reqCtx._themeConfig;
118
152
  if (!config) return undefined;
119
- if (reqCtx._shellCaptureRun || documentCacheStoresRender(reqCtx)) {
120
- return config.defaultTheme;
121
- }
153
+ if (rendersForSharedAudience(reqCtx)) return config.defaultTheme;
122
154
  const theme = reqCtx._readTheme();
123
155
  if (theme !== config.defaultTheme) reqCtx._payloadVisitorTheme = true;
124
156
  return theme;
@@ -17,6 +17,8 @@ import {
17
17
  createRequestContext,
18
18
  captureRequestEntryContext,
19
19
  } from "../server/request-context.js";
20
+ import { REWRITE_HEADER, runRewritePasses } from "./rewrite-passes.js";
21
+ import { renderedLocation } from "./full-payload.js";
20
22
  import * as rscDeps from "@vitejs/plugin-rsc/rsc/server";
21
23
  import type {
22
24
  RscPayload,
@@ -25,6 +27,7 @@ import type {
25
27
  SSRModule,
26
28
  } from "./types.js";
27
29
  import {
30
+ createReloadResponse,
28
31
  createResponseWithMergedHeaders,
29
32
  finalizeResponse,
30
33
  interceptRedirectForPartial,
@@ -41,6 +44,7 @@ import { resolvedHandleStream } from "../handles/deferred-resolution.js";
41
44
  import {
42
45
  isWebSocketUpgradeResponse,
43
46
  appendVaryAccept,
47
+ trySetHeader,
44
48
  } from "../response-utils.js";
45
49
  import {
46
50
  handleResponseRoute,
@@ -57,10 +61,7 @@ import {
57
61
  invokeOnError,
58
62
  resolveDefaultNotFound,
59
63
  } from "../router/error-handling.js";
60
- import {
61
- createReverseFunction,
62
- stripInternalParams,
63
- } from "../router/handler-context.js";
64
+ import { createReverseFunction } from "../router/handler-context.js";
64
65
  import { contextSet } from "../context-var.js";
65
66
  import {
66
67
  hasCachedManifest,
@@ -82,7 +83,7 @@ import {
82
83
  revalidateAfterAction,
83
84
  type ActionContinuation,
84
85
  } from "./server-action.js";
85
- import { handleLoaderFetch } from "./loader-fetch.js";
86
+ import { handleLoaderFetch, renderLoaderError } from "./loader-fetch.js";
86
87
  import { applyStreamIdleTimeout } from "./stream-idle.js";
87
88
  import {
88
89
  checkRequestOrigin,
@@ -113,6 +114,7 @@ import {
113
114
  mayNeedSSR,
114
115
  isRscRequest,
115
116
  SSR_SETUP_VAR,
117
+ type SSRSetup,
116
118
  } from "./ssr-setup.js";
117
119
  import {
118
120
  classifyRequest,
@@ -155,21 +157,6 @@ import { INTERNAL_RANGO_DEBUG } from "../internal-debug.js";
155
157
  * ```
156
158
  */
157
159
 
158
- /**
159
- * Response that tells the client to do a full document navigation. Shared by
160
- * the terminal reload plans (version-mismatch and app-switch): an empty 200
161
- * carrying X-RSC-Reload, which the client turns into window.location.href.
162
- */
163
- function createReloadResponse(reloadUrl: string) {
164
- return createResponseWithMergedHeaders(null, {
165
- status: 200,
166
- headers: {
167
- "X-RSC-Reload": reloadUrl,
168
- "content-type": "text/x-component;charset=utf-8",
169
- },
170
- });
171
- }
172
-
173
160
  export function createRSCHandler<
174
161
  TEnv = unknown,
175
162
  TRoutes extends Record<string, string> = Record<string, string>,
@@ -703,24 +690,70 @@ export function createRSCHandler<
703
690
  // latter is reserved for the low-cardinality template (OTel convention).
704
691
  span.setAttribute("url.path", url.pathname);
705
692
 
706
- // Core handler logic (wrapped by middleware)
707
- const coreHandler = async (): Promise<Response> => {
708
- return coreRequestHandler(request, env, url, variables, nonce);
709
- };
710
-
711
- // Execute middleware chain if any, otherwise call core handler
712
- // directly; the response is finalized below, inside the response span.
713
- const hasMiddleware = matchedMiddleware.length > 0;
714
- const downstream = hasMiddleware
715
- ? await executeMiddleware(
716
- matchedMiddleware,
717
- request,
693
+ // One pass per URL: the global middleware chain matched on the pass's
694
+ // path, wrapping the core handler. A `throw rewrite()` from global or
695
+ // route middleware unwinds the pass, and the next one runs the same
696
+ // request as the target from the top, as a direct visit would
697
+ // (rsc/rewrite-passes.ts). The response is finalized below, inside the
698
+ // response span.
699
+ let passIndex = 0;
700
+ let hasMiddleware = false;
701
+ const { response: downstream, rewrittenTo } = await runRewritePasses({
702
+ reqCtx: requestContext,
703
+ request,
704
+ url,
705
+ routeMap: getRequiredRouteMap,
706
+ basename: router.basename,
707
+ runPass: (passRequest, passUrl) => {
708
+ const passMiddleware =
709
+ passIndex++ === 0
710
+ ? matchedMiddleware
711
+ : matchMiddleware(
712
+ requestContext.url.pathname,
713
+ router.middleware,
714
+ );
715
+ hasMiddleware = passMiddleware.length > 0;
716
+ const coreHandler = (): Promise<Response> =>
717
+ coreRequestHandler(passRequest, env, passUrl, variables, nonce);
718
+ return hasMiddleware
719
+ ? executeMiddleware(
720
+ passMiddleware,
721
+ passRequest,
722
+ env,
723
+ variables,
724
+ coreHandler,
725
+ createReverseFunction(getRequiredRouteMap()),
726
+ )
727
+ : coreHandler();
728
+ },
729
+ onHop: (pass) => {
730
+ span.setAttribute("rango.rewrite.target", pass.target.pathname);
731
+ span.setAttribute("rango.rewrite.hops", pass.hops);
732
+ },
733
+ report: (error, at) => {
734
+ callOnError(error, "routing", {
735
+ request: at.request,
736
+ url: at.url,
718
737
  env,
719
- variables,
720
- coreHandler,
721
- createReverseFunction(getRequiredRouteMap()),
722
- )
723
- : await coreHandler();
738
+ handledByBoundary: false,
739
+ });
740
+ },
741
+ // A loader fetch answers with the error `load()` rejects with.
742
+ refuse: (error) =>
743
+ url.searchParams.has("_rsc_loader")
744
+ ? renderLoaderError(
745
+ handlerCtx,
746
+ { message: (error as Error).message, name: "Error" },
747
+ 500,
748
+ {
749
+ request,
750
+ url,
751
+ env,
752
+ loaderId: url.searchParams.get("_rsc_loader") ?? "",
753
+ },
754
+ )
755
+ : undefined,
756
+ });
724
757
 
725
758
  // Final response construction + host handoff, wrapped in rango.response
726
759
  // — the explicit stream-handoff marker. The callback is synchronous, so
@@ -787,14 +820,9 @@ export function createRSCHandler<
787
820
  }
788
821
 
789
822
  const fullTiming = timingParts.join(", ");
790
- if (fullTiming && !isWebSocketUpgradeResponse(response)) {
791
- try {
792
- response.headers.set("Server-Timing", fullTiming);
793
- } catch {
794
- // Immutable headers (e.g. a passed-through platform Response) — drop
795
- // the timing header, never the response. Instrumentation must not
796
- // 500 a request.
797
- }
823
+ if (fullTiming) trySetHeader(response, "Server-Timing", fullTiming);
824
+ if (rewrittenTo !== undefined) {
825
+ trySetHeader(response, REWRITE_HEADER, rewrittenTo);
798
826
  }
799
827
 
800
828
  // Single open-redirect chokepoint: every response (PE, full-page,
@@ -956,18 +984,19 @@ export function createRSCHandler<
956
984
  // which returns { redirect: url }. Fall through to full-render.
957
985
  }
958
986
 
987
+ // A reload points at the visitor's URL, never a rewrite target's.
959
988
  if (plan.mode === "version-mismatch") {
960
989
  console.log(
961
990
  `[RSC] Version mismatch: client=${url.searchParams.get("_rsc_v")}, server=${version}. Forcing reload.`,
962
991
  );
963
- return createReloadResponse(plan.reloadUrl);
992
+ return createReloadResponse(getRequestContext(), plan.reloadUrl);
964
993
  }
965
994
 
966
995
  if (plan.mode === "app-switch") {
967
996
  // Cross-app SPA navigation crossed a host-router app boundary. Force a
968
997
  // real document navigation so the target app's document is re-established
969
998
  // (stylesheets, theme, warmup, prefetch-TTL). See request-classification.
970
- return createReloadResponse(plan.reloadUrl);
999
+ return createReloadResponse(getRequestContext(), plan.reloadUrl);
971
1000
  }
972
1001
 
973
1002
  // ---- 3. Origin guard (gate for action/loader/PE modes) ----
@@ -1137,6 +1166,9 @@ export function createRSCHandler<
1137
1166
  (mayNeedSSR(request, url) &&
1138
1167
  !isRscRequest(request, url, plan.mode === "partial-render"));
1139
1168
  if (plan.mode !== "loader" && willRenderHtml) {
1169
+ // A rewritten pass keeps the SSR module the earlier pass started
1170
+ // loading; the stream mode is resolved for the request it renders.
1171
+ const earlier = variables[SSR_SETUP_VAR] as Promise<SSRSetup> | undefined;
1140
1172
  const ssrSetup = startSSRSetup(
1141
1173
  handlerCtx,
1142
1174
  request,
@@ -1145,6 +1177,7 @@ export function createRSCHandler<
1145
1177
  router.debugPerformance
1146
1178
  ? () => getRequestContext()._metricsStore
1147
1179
  : undefined,
1180
+ earlier?.then(([ssrModule]) => ssrModule),
1148
1181
  );
1149
1182
  variables[SSR_SETUP_VAR] = ssrSetup;
1150
1183
 
@@ -1348,13 +1381,7 @@ export function createRSCHandler<
1348
1381
  `[RSC] Route handler at ${url.pathname} returned a Response during client-side navigation. ` +
1349
1382
  `Falling back to hard navigation. Use data-external on the <Link> to avoid the extra round-trip.`,
1350
1383
  );
1351
- return createResponseWithMergedHeaders(null, {
1352
- status: 200,
1353
- headers: {
1354
- "X-RSC-Reload": stripInternalParams(url).toString(),
1355
- "content-type": "text/x-component;charset=utf-8",
1356
- },
1357
- });
1384
+ return createReloadResponse(getRequestContext(), url);
1358
1385
  }
1359
1386
 
1360
1387
  if (treatAsPartial) {
@@ -1396,7 +1423,7 @@ export function createRSCHandler<
1396
1423
 
1397
1424
  const payload: RscPayload = {
1398
1425
  metadata: {
1399
- pathname: url.pathname,
1426
+ pathname: renderedLocation(getRequestContext(), url).pathname,
1400
1427
  routerId: router.id,
1401
1428
  basename: router.basename,
1402
1429
  segments: [notFoundSegment],
@@ -24,6 +24,7 @@ import {
24
24
  import type { MiddlewareEntry, MiddlewareFn } from "../router/middleware.js";
25
25
  import { formatCacheSignalHeader } from "../router/telemetry.js";
26
26
  import type { RscPayload } from "./types.js";
27
+ import { rewriteTrail, visitorUrl } from "../server/rewrite-trail.js";
27
28
  import type { HandlerContext } from "./handler-context.js";
28
29
 
29
30
  /**
@@ -157,6 +158,29 @@ export function createResponseWithMergedHeaders(
157
158
  return drainOnResponseCallbacks(ctx, response);
158
159
  }
159
160
 
161
+ /**
162
+ * An empty 200 carrying X-RSC-Reload: the client turns it into a document
163
+ * navigation (window.location.href). It points at the visitor's URL: the
164
+ * first URL of a rewritten request (server/rewrite-trail.ts visitorUrl),
165
+ * else `url` with the internal `_rsc*` params stripped, or `url` as given
166
+ * when it is a string the caller resolved (an action's referer).
167
+ */
168
+ export function createReloadResponse(
169
+ reqCtx: RequestContext<any> | undefined,
170
+ url: URL | string,
171
+ ): Response {
172
+ const target =
173
+ rewriteTrail(reqCtx)?.[0] ??
174
+ (typeof url === "string" ? url : visitorUrl(undefined, url).href);
175
+ return createResponseWithMergedHeaders(null, {
176
+ status: 200,
177
+ headers: {
178
+ "X-RSC-Reload": target,
179
+ "content-type": "text/x-component;charset=utf-8",
180
+ },
181
+ });
182
+ }
183
+
160
184
  /**
161
185
  * Rebuild a Response returned or thrown by a response-route handler so request
162
186
  * effects merge consistently in production and the public dispatch primitive.