@rangojs/router 0.31.0 → 0.33.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 (192) hide show
  1. package/README.md +3 -2
  2. package/dist/bin/browser-7KIJ5HSV.js +15 -0
  3. package/dist/bin/chunk-3B7CKBRO.js +463 -0
  4. package/dist/bin/chunk-524VACCG.js +71 -0
  5. package/dist/bin/chunk-LD7UBHT4.js +196 -0
  6. package/dist/bin/chunk-TO6CRWLN.js +1429 -0
  7. package/dist/bin/chunk-TTXC2U3G.js +2477 -0
  8. package/dist/bin/client-urls-projection-DQLWPDAT.js +28 -0
  9. package/dist/bin/forward-user-plugins-URBZKJP4.js +62 -0
  10. package/dist/bin/rango.js +42 -10143
  11. package/dist/bin/runtime-EPUDEHO4.js +596 -0
  12. package/dist/bin/runtime-discovery-CZ2QHT4Q.js +352 -0
  13. package/dist/bin/state-ACD2MGKJ.js +16 -0
  14. package/dist/bin/temp-rsc-server-QRR6HD6T.js +3190 -0
  15. package/dist/types/bin/rango.d.ts +1 -0
  16. package/dist/types/browser/action-fence.d.ts +20 -0
  17. package/dist/types/browser/cookie-name.d.ts +51 -10
  18. package/dist/types/browser/history-state.d.ts +13 -2
  19. package/dist/types/browser/invalidate-client-cache.d.ts +8 -6
  20. package/dist/types/browser/navigation-bridge.d.ts +36 -1
  21. package/dist/types/browser/navigation-client.d.ts +19 -0
  22. package/dist/types/browser/navigation-store-handle.d.ts +37 -4
  23. package/dist/types/browser/navigation-store.d.ts +2 -2
  24. package/dist/types/browser/navigation-transaction.d.ts +0 -1
  25. package/dist/types/browser/partial-update.d.ts +13 -0
  26. package/dist/types/browser/prefetch/cache.d.ts +96 -8
  27. package/dist/types/browser/prefetch/fetch.d.ts +26 -2
  28. package/dist/types/browser/prefetch/queue.d.ts +3 -2
  29. package/dist/types/browser/rango-state.d.ts +34 -5
  30. package/dist/types/browser/react/Link.d.ts +19 -13
  31. package/dist/types/browser/react/location-state-shared.d.ts +2 -1
  32. package/dist/types/browser/types.d.ts +25 -0
  33. package/dist/types/build/generate-route-types.d.ts +1 -1
  34. package/dist/types/build/route-types/include-resolution.d.ts +9 -1
  35. package/dist/types/build/route-types/router-processing.d.ts +3 -1
  36. package/dist/types/cache/cache-key-utils.d.ts +12 -8
  37. package/dist/types/cache/cache-scope.d.ts +4 -2
  38. package/dist/types/cache/cf/cf-cache-types.d.ts +9 -5
  39. package/dist/types/cache/document-cache-policy.d.ts +9 -1
  40. package/dist/types/cache/memory-segment-store.d.ts +3 -2
  41. package/dist/types/cache/segment-codec.d.ts +2 -2
  42. package/dist/types/cache/shell-snapshot.d.ts +1 -1
  43. package/dist/types/cache/types.d.ts +12 -7
  44. package/dist/types/cache/vercel/vercel-cache-store.d.ts +2 -1
  45. package/dist/types/client-urls/types.d.ts +7 -5
  46. package/dist/types/prefetch-markers.d.ts +9 -0
  47. package/dist/types/prefetch-source-scope.d.ts +66 -0
  48. package/dist/types/route-definition/helpers-types.d.ts +8 -5
  49. package/dist/types/route-definition/redirect.d.ts +5 -0
  50. package/dist/types/router/intercept-resolution.d.ts +25 -11
  51. package/dist/types/router/match-context.d.ts +1 -0
  52. package/dist/types/router/match-handlers.d.ts +1 -1
  53. package/dist/types/router/middleware-types.d.ts +6 -0
  54. package/dist/types/router/prefetch-cache-ttl.d.ts +11 -3
  55. package/dist/types/router/router-context.d.ts +1 -1
  56. package/dist/types/router/router-options.d.ts +6 -3
  57. package/dist/types/router/segment-resolution/loader-cache.d.ts +8 -0
  58. package/dist/types/router/segment-resolution/prefetch-deferral.d.ts +23 -7
  59. package/dist/types/router/segment-resolution/revalidation.d.ts +19 -0
  60. package/dist/types/router/source-reads.d.ts +53 -0
  61. package/dist/types/router/types.d.ts +1 -1
  62. package/dist/types/rsc/helpers.d.ts +8 -3
  63. package/dist/types/rsc/prefetch-render-error.d.ts +7 -2
  64. package/dist/types/rsc/request-stages.d.ts +4 -0
  65. package/dist/types/rsc/shell-serve.d.ts +6 -1
  66. package/dist/types/server/context.d.ts +23 -6
  67. package/dist/types/server/cookie-store.d.ts +15 -1
  68. package/dist/types/server/document-location-state.d.ts +59 -0
  69. package/dist/types/server/handle-store.d.ts +2 -0
  70. package/dist/types/server/request-context.d.ts +65 -12
  71. package/dist/types/testing/e2e/server.d.ts +16 -1
  72. package/dist/types/testing/render-route.d.ts +83 -2
  73. package/dist/types/testing/serve-shell-request.d.ts +15 -3
  74. package/dist/types/types/handler-context.d.ts +21 -4
  75. package/dist/types/types/loader-types.d.ts +7 -4
  76. package/dist/types/types/segments.d.ts +18 -1
  77. package/dist/types/urls/path-helper-types.d.ts +7 -5
  78. package/dist/types/vite/temp-rsc-server.d.ts +2 -2
  79. package/dist/types/vite/utils/forward-user-plugins.d.ts +32 -2
  80. package/dist/vite/index.js +451 -191
  81. package/package.json +2 -2
  82. package/skills/app-structure/SKILL.md +74 -0
  83. package/skills/app-structure/e-commerce.md +10 -9
  84. package/skills/app-structure/large-app.md +15 -2
  85. package/skills/caching/SKILL.md +10 -5
  86. package/skills/client-urls/SKILL.md +18 -11
  87. package/skills/comparison/references/framework-comparison.md +16 -10
  88. package/skills/document-cache/SKILL.md +13 -0
  89. package/skills/hooks/navigation.md +8 -8
  90. package/skills/hooks/state.md +132 -12
  91. package/skills/intercept/SKILL.md +33 -11
  92. package/skills/layout/SKILL.md +11 -0
  93. package/skills/links/SKILL.md +101 -4
  94. package/skills/loader/SKILL.md +49 -22
  95. package/skills/migrate-nextjs/SKILL.md +8 -2
  96. package/skills/migrate-react-router/data-and-actions.md +5 -0
  97. package/skills/ppr/SKILL.md +38 -5
  98. package/skills/prerender/SKILL.md +4 -1
  99. package/skills/route/SKILL.md +6 -6
  100. package/skills/router-setup/SKILL.md +2 -1
  101. package/skills/server-actions/SKILL.md +49 -8
  102. package/skills/testing/client-components.md +73 -32
  103. package/skills/testing/serve-shell-request.md +45 -20
  104. package/skills/testing/server-actions.md +8 -0
  105. package/skills/ux-architecture/SKILL.md +67 -15
  106. package/src/bin/rango.ts +39 -13
  107. package/src/browser/action-fence.ts +72 -0
  108. package/src/browser/cookie-name.ts +82 -14
  109. package/src/browser/history-state.ts +24 -2
  110. package/src/browser/invalidate-client-cache.ts +9 -7
  111. package/src/browser/navigation-bridge.ts +164 -32
  112. package/src/browser/navigation-client.ts +95 -20
  113. package/src/browser/navigation-store-handle.ts +67 -5
  114. package/src/browser/navigation-store.ts +86 -17
  115. package/src/browser/navigation-transaction.ts +0 -6
  116. package/src/browser/partial-update.ts +31 -12
  117. package/src/browser/prefetch/cache.ts +188 -11
  118. package/src/browser/prefetch/fetch.ts +211 -51
  119. package/src/browser/prefetch/queue.ts +3 -2
  120. package/src/browser/rango-state.ts +50 -8
  121. package/src/browser/react/Link.tsx +24 -16
  122. package/src/browser/react/location-state-shared.ts +8 -1
  123. package/src/browser/react/use-navigation.ts +1 -1
  124. package/src/browser/rsc-router.tsx +13 -3
  125. package/src/browser/server-action-bridge.ts +3 -3
  126. package/src/browser/types.ts +25 -0
  127. package/src/build/generate-route-types.ts +1 -0
  128. package/src/build/route-types/include-resolution.ts +40 -3
  129. package/src/build/route-types/router-processing.ts +170 -25
  130. package/src/build/route-types/scan-filter.ts +1 -0
  131. package/src/build/runtime-discovery.ts +46 -8
  132. package/src/cache/cache-key-utils.ts +12 -8
  133. package/src/cache/cache-runtime.ts +5 -1
  134. package/src/cache/cache-scope.ts +28 -7
  135. package/src/cache/cf/cf-cache-types.ts +9 -5
  136. package/src/cache/document-cache-policy.ts +37 -1
  137. package/src/cache/document-cache.ts +12 -0
  138. package/src/cache/memory-segment-store.ts +3 -2
  139. package/src/cache/segment-codec.ts +2 -2
  140. package/src/cache/shell-snapshot.ts +1 -1
  141. package/src/cache/types.ts +12 -7
  142. package/src/cache/vercel/vercel-cache-store.ts +2 -1
  143. package/src/client-urls/types.ts +7 -5
  144. package/src/prefetch-markers.ts +10 -0
  145. package/src/prefetch-source-scope.ts +137 -0
  146. package/src/route-definition/helpers-types.ts +8 -5
  147. package/src/route-definition/redirect.ts +5 -0
  148. package/src/router/intercept-resolution.ts +94 -50
  149. package/src/router/loader-resolution.ts +8 -6
  150. package/src/router/match-api.ts +33 -29
  151. package/src/router/match-context.ts +5 -0
  152. package/src/router/match-handlers.ts +2 -1
  153. package/src/router/match-middleware/cache-lookup.ts +39 -14
  154. package/src/router/match-result.ts +45 -85
  155. package/src/router/middleware-types.ts +6 -0
  156. package/src/router/navigation-snapshot.ts +18 -3
  157. package/src/router/prefetch-cache-ttl.ts +15 -3
  158. package/src/router/revalidation.ts +63 -13
  159. package/src/router/router-context.ts +2 -1
  160. package/src/router/router-options.ts +6 -3
  161. package/src/router/segment-resolution/loader-cache.ts +21 -3
  162. package/src/router/segment-resolution/prefetch-deferral.ts +38 -31
  163. package/src/router/segment-resolution/revalidation.ts +40 -12
  164. package/src/router/source-reads.ts +122 -0
  165. package/src/router/types.ts +2 -1
  166. package/src/router.ts +11 -12
  167. package/src/rsc/helpers.ts +20 -6
  168. package/src/rsc/prefetch-render-error.ts +7 -2
  169. package/src/rsc/progressive-enhancement.ts +8 -2
  170. package/src/rsc/request-stages.ts +87 -1
  171. package/src/rsc/rsc-rendering.ts +78 -32
  172. package/src/rsc/server-action.ts +2 -1
  173. package/src/rsc/shell-capture.ts +4 -5
  174. package/src/rsc/shell-serve.ts +12 -1
  175. package/src/segment-system.tsx +1 -1
  176. package/src/server/context.ts +37 -6
  177. package/src/server/cookie-store.ts +22 -1
  178. package/src/server/document-location-state.ts +334 -0
  179. package/src/server/handle-store.ts +2 -0
  180. package/src/server/request-context.ts +137 -33
  181. package/src/testing/dispatch.ts +6 -0
  182. package/src/testing/e2e/fixture.ts +4 -12
  183. package/src/testing/e2e/server.ts +91 -4
  184. package/src/testing/render-route.tsx +309 -10
  185. package/src/testing/run-revalidate.ts +11 -1
  186. package/src/testing/serve-shell-request.ts +22 -3
  187. package/src/types/handler-context.ts +21 -4
  188. package/src/types/loader-types.ts +7 -4
  189. package/src/types/segments.ts +18 -1
  190. package/src/urls/path-helper-types.ts +7 -5
  191. package/src/vite/temp-rsc-server.ts +2 -2
  192. package/src/vite/utils/forward-user-plugins.ts +13 -5
@@ -22,6 +22,7 @@ import {
22
22
  setRequestContextParams,
23
23
  } from "../server/request-context.js";
24
24
  import { printServerError } from "../server/print-server-error.js";
25
+ import { runActionBody } from "../server/cookie-store.js";
25
26
  import { observePhase, PHASES } from "../router/instrument.js";
26
27
  import { resolveActionRefId } from "../router/is-action.js";
27
28
  import { getInterceptSourceHeader } from "../router/navigation-snapshot.js";
@@ -203,7 +204,7 @@ export async function executeServerAction<TEnv>(
203
204
 
204
205
  try {
205
206
  loadedAction = await ctx.loadServerAction(actionId);
206
- let data = await loadedAction!.apply(null, args);
207
+ let data = await runActionBody(() => loadedAction!.apply(null, args));
207
208
 
208
209
  // Intercept redirect Responses: serializing one as the action returnValue
209
210
  // would fail, and revalidation would run needlessly.
@@ -552,7 +552,7 @@ const SNAPSHOT_BYTE_ENCODER = new TextEncoder();
552
552
 
553
553
  /**
554
554
  * Warn once per key that the capture's loader pins exceeded the route's
555
- * `maxSnapshotBytes` cap, so no document shell was stored (dc-t89). Once per
555
+ * `maxSnapshotBytes` cap, so no document shell was stored. Once per
556
556
  * key: the route recaptures every backoff window.
557
557
  */
558
558
  function warnSnapshotOverCapOnce(
@@ -2603,8 +2603,7 @@ async function captureAndStoreShell(
2603
2603
  throw new Error(`[rango] no shell pin for "${segmentKey}"`);
2604
2604
  }
2605
2605
  } catch (error) {
2606
- // Unpinned, the loader would run on every HIT over this prelude
2607
- // (dc-t89): refuse like an encode error.
2606
+ // Unpinned, the loader would run on every HIT over this prelude: refuse like an encode error.
2608
2607
  reqCtx._renderErrors?.push(error);
2609
2608
  }
2610
2609
  }
@@ -2650,7 +2649,7 @@ async function captureAndStoreShell(
2650
2649
  }
2651
2650
 
2652
2651
  // Snapshot size guard: `maxSnapshotBytes` bounds the loader pins, and a
2653
- // document HIT needs every pin its prelude baked (dc-t89). Over the cap
2652
+ // document HIT needs every pin its prelude baked. Over the cap
2654
2653
  // the entry keeps the navigation half only (the doc record, or nothing:
2655
2654
  // a tombstone that also shadows a build shell) and the attempt refuses.
2656
2655
  // The whole entry is bounded separately below, by the store's limit.
@@ -2897,7 +2896,7 @@ export {
2897
2896
 
2898
2897
  // Exported for unit tests that pin the refused-capture backoff policy directly
2899
2898
  // (dev cap vs production exponential growth, stored-clears, cold-start re-probe),
2900
- // and markCaptureBackoff for serveShellHit (a pin that failed to decode, dc-t89).
2899
+ // and markCaptureBackoff for serveShellHit (a pin that failed to decode).
2901
2900
  // These are the same module-level functions the schedule path uses; a test that
2902
2901
  // drove them through a real capture round-trip could not assert the exact window
2903
2902
  // arithmetic without a full cold render.
@@ -38,6 +38,7 @@ import {
38
38
  import { routerKeyPrefix } from "../cache/cache-key-utils.js";
39
39
  import { reportCacheError } from "../cache/cache-error.js";
40
40
  import { appendMetric } from "../router/metrics.js";
41
+ import { escapeJsonForScript } from "../escape-script.js";
41
42
  import type { MetricsStore } from "../server/context.js";
42
43
  import { WIRE } from "../wire-params.js";
43
44
 
@@ -162,12 +163,22 @@ export function withoutShellMissMarker(request: Request): Request | undefined {
162
163
  * the page. window.stop() runs only when it reloads: it keeps the half-sent
163
164
  * Flight stream from being closed by DOMContentLoaded, which would throw
164
165
  * React's "Connection closed" (#412) before the reload lands.
166
+ *
167
+ * `restoreCookie` is the request's `_carriedLocationStateCookie`: the HIT's
168
+ * headers already cleared the location state cookie a document redirect left
169
+ * (server/document-location-state.ts), so the script sets it again before it
170
+ * reloads, and the reload carries the state.
165
171
  */
166
- export function shellReloadScript(): string {
172
+ export function shellReloadScript(restoreCookie?: string): string {
173
+ const restore =
174
+ restoreCookie === undefined
175
+ ? ""
176
+ : `document.cookie=${escapeJsonForScript(JSON.stringify(restoreCookie))};`;
167
177
  return inlineShellScript(
168
178
  "(function(){var u=new URL(location.href);" +
169
179
  `if(u.searchParams.has(${JSON.stringify(WIRE.shellMiss)}))return;` +
170
180
  `u.searchParams.set(${JSON.stringify(WIRE.shellMiss)},"miss");` +
181
+ restore +
171
182
  "try{window.stop()}catch(e){}location.replace(u.href)})()",
172
183
  );
173
184
  }
@@ -318,7 +318,7 @@ function wrapDefaultOutletContent(
318
318
  * is settled like a flagged one, so the commit shows no fallback. A loader
319
319
  * error is left to its read site there, its errorBoundary() fallback riding
320
320
  * the throw, as on a navigation: the nearest boundary to the read catches it
321
- * (#1129, dc-t65). notFound()/redirect() keep their build-time node.
321
+ * (#1129). notFound()/redirect() keep their build-time node.
322
322
  */
323
323
  async function buildLoaderStreams(
324
324
  loaders: ResolvedSegment[],
@@ -126,9 +126,10 @@ export type EntryPropRender = {
126
126
  loading?: ReactNode | false;
127
127
  /**
128
128
  * loading(fallback, { prefetch: false }) with a renderable fallback: a
129
- * prefetch defers what the fallback covers while the entry's segment is
130
- * new to the client (router/segment-resolution/prefetch-deferral.ts). Set
131
- * on both DSL evaluations, whatever `ssr` did to `loading`.
129
+ * speculative prefetch defers what the fallback covers while the entry's
130
+ * segment is new to the client
131
+ * (router/segment-resolution/prefetch-deferral.ts). Set on both DSL
132
+ * evaluations, whatever `ssr` did to `loading`.
132
133
  */
133
134
  loadingPrefetch?: false;
134
135
  /**
@@ -171,8 +172,8 @@ export type LoaderEntry = {
171
172
  */
172
173
  bake?: true;
173
174
  /**
174
- * loader(Def, { prefetch: false }): a prefetch defers this registration
175
- * while its segment is new to the client
175
+ * loader(Def, { prefetch: false }): a speculative prefetch defers this
176
+ * registration while its segment is new to the client
176
177
  * (router/segment-resolution/prefetch-deferral.ts).
177
178
  */
178
179
  prefetch?: false;
@@ -211,7 +212,10 @@ export type InterceptSegmentsState = {
211
212
  * @internal This type is an implementation detail and may change without notice.
212
213
  */
213
214
  export type InterceptSelectorContext<TEnv = any> = {
214
- /** Where the navigation comes from: the intercept source while one is open. */
215
+ /**
216
+ * Where the navigation comes from: the intercept source while one is open.
217
+ * Its `url` never carries a #fragment.
218
+ */
215
219
  from: ServerRouteLocation;
216
220
  /** The navigation target. */
217
221
  to: ServerRouteLocation;
@@ -1719,6 +1723,33 @@ export function isInsideLoaderBody(loaderId: string): boolean {
1719
1723
  return false;
1720
1724
  }
1721
1725
 
1726
+ // The segment of the streamed handler or loader body running here; the
1727
+ // walker moves the shared `_currentSegmentId` on without awaiting either.
1728
+ const segmentPinALS: AsyncLocalStorage<string | undefined> = ((
1729
+ globalThis as any
1730
+ )[Symbol.for("rangojs-router:segment-pin")] ??= new AsyncLocalStorage<
1731
+ string | undefined
1732
+ >());
1733
+
1734
+ /**
1735
+ * Run `fn` as segment `segmentId`'s code: a handle push, a loader kickoff or
1736
+ * a "use cache" call it makes, before or after an await, files under
1737
+ * `segmentId` (segmentPinOf).
1738
+ */
1739
+ export function runAsSegment<R>(segmentId: string | undefined, fn: () => R): R {
1740
+ return segmentPinALS.run(segmentId, fn);
1741
+ }
1742
+
1743
+ /**
1744
+ * The segment code here runs for: the innermost runAsSegment scope, else
1745
+ * `ctx`'s pin (a handler the walker awaits).
1746
+ */
1747
+ export function segmentPinOf(ctx: {
1748
+ _currentSegmentId?: string;
1749
+ }): string | undefined {
1750
+ return segmentPinALS.getStore() ?? ctx._currentSegmentId;
1751
+ }
1752
+
1722
1753
  // Scope for handle PUSH CALLBACKS (push(() => ...), including async ones).
1723
1754
  // A push callback's value is stored as-is; if it is a promise it is NOT tracked
1724
1755
  // by handleStore.settled and does not block segment resolution, so a
@@ -330,7 +330,8 @@ export function invalidateClientCache(): void {
330
330
  *
331
331
  * Sets an internal response header the bridge reads. Idempotent within a
332
332
  * request. Inert (a dev warning) outside a request context — there is no
333
- * automatic invalidation to suppress.
333
+ * automatic invalidation to suppress. It also keeps the state's write part
334
+ * (runActionBody).
334
335
  */
335
336
  export function keepClientCache(): void {
336
337
  const ctx = _getRequestContext();
@@ -347,6 +348,26 @@ export function keepClientCache(): void {
347
348
  ctx._setKeepCacheDirective();
348
349
  }
349
350
 
351
+ /**
352
+ * Run a server action's body (the JS call or a no-JS form post), then bump the
353
+ * rango state's write part for the client that called it: the response's
354
+ * state `Set-Cookie` keeps the client's timestamp and carries a new write part
355
+ * (browser/cookie-name.ts). Every later request of every tab and reload sends
356
+ * the new `X-Rango-State`, so a prefetch or navigation the browser HTTP cache
357
+ * holds from before the write (a response's own `Cache-Control: max-age`)
358
+ * misses, while the client's in-memory caches only go stale, as the action
359
+ * bridge marks them. Bumped however the body ends (returned, threw, or
360
+ * redirected), since the write may have committed either way, and not after
361
+ * keepClientCache() or invalidateClientCache() (whose rotation covers it).
362
+ */
363
+ export async function runActionBody<T>(body: () => T | Promise<T>): Promise<T> {
364
+ try {
365
+ return await body();
366
+ } finally {
367
+ _getRequestContext()?._rotateStateCookie("write");
368
+ }
369
+ }
370
+
350
371
  /**
351
372
  * Create a CookieStore backed by a RequestContext. Reads go through the
352
373
  * context's unguarded `_readCookie()` / `_readCookies()`: cookies() ran the
@@ -0,0 +1,334 @@
1
+ /**
2
+ * Location state on document responses (#1330).
3
+ *
4
+ * A client navigation or an action carries server-set location state in its
5
+ * Flight payload, and a redirect on either becomes a Flight redirect that
6
+ * carries it too. A document request has no such channel for a 3xx: the
7
+ * browser follows it with a plain GET. This module carries the state across
8
+ * that one hop in a short-lived cookie:
9
+ *
10
+ * - `carryLocationStateAcrossRedirect` (rsc/request-stages.ts
11
+ * finalizeRequestResponse) writes it on a document request's same-origin
12
+ * 3xx when the request set location state;
13
+ * - `consumeDocumentLocationState` (rsc/request-stages.ts runRequestPasses)
14
+ * reads it on the next GET document request that navigates to the target
15
+ * pathname (`Sec-Fetch-Dest` a browsing context, where sent), seeds
16
+ * `_locationState` with its entries (so the render attaches them to the
17
+ * bootstrap payload, rsc/rsc-rendering.ts) and clears it. A shell HIT that
18
+ * degrades into a reload after its headers cleared the cookie sets it
19
+ * again from script first (`_carriedLocationStateCookie`), so the reload
20
+ * carries the state.
21
+ *
22
+ * A document that carries location state is per visitor: it is marked
23
+ * `_documentLocationState`, never stored by the document cache or as a PPR
24
+ * shell, and leaves with `Cache-Control: private, no-store`
25
+ * (`markDocumentResponsePrivate`).
26
+ *
27
+ * The value is JSON, not Flight: a cookie holds text, and the client merges
28
+ * it as `history.state` would hold it. `Path=/` because the `__Host-` prefix
29
+ * (https) requires it, which keeps a sibling subdomain from planting state;
30
+ * the target pathname inside the value decides who consumes it.
31
+ */
32
+
33
+ import {
34
+ isLocationStateKey,
35
+ resolveLocationStateEntries,
36
+ type LocationStateEntry,
37
+ } from "../browser/react/location-state-shared.js";
38
+ import { getRawCookieValue } from "../browser/cookie-name.js";
39
+ import { isExternalRedirect } from "../redirect-origin.js";
40
+ import { isWebSocketUpgradeResponse } from "../response-utils.js";
41
+ import { mayNeedSSR } from "../rsc/ssr-setup.js";
42
+ import { requestHeaders } from "./request-headers.js";
43
+ import {
44
+ serializeCookieValue,
45
+ type RequestContext,
46
+ } from "./request-context.js";
47
+
48
+ /** Seconds the cookie lives: one redirect hop, never a later visit. */
49
+ const DOCUMENT_LOCATION_STATE_MAX_AGE = 60;
50
+
51
+ /**
52
+ * Upper bound of one serialized `Set-Cookie` (name, value and attributes).
53
+ * Browsers keep at least 4096 bytes per cookie; the margin covers the
54
+ * attributes some add.
55
+ */
56
+ const DOCUMENT_LOCATION_STATE_MAX_BYTES = 4000;
57
+
58
+ const COOKIE_SUFFIX = "-ls";
59
+
60
+ const PRIVATE_NO_STORE = "private, no-store";
61
+
62
+ interface CarriedState {
63
+ /** The pathname of the redirect target, the only request that reads it. */
64
+ p: string;
65
+ /** The resolved entries: slot key to value. */
66
+ s: Record<string, unknown>;
67
+ }
68
+
69
+ /** The cookie's name for a router: `__Host-` on https. */
70
+ export function documentLocationStateCookieName(
71
+ stateCookieName: string,
72
+ secure: boolean,
73
+ ): string {
74
+ return `${secure ? "__Host-" : ""}${stateCookieName}${COOKIE_SUFFIX}`;
75
+ }
76
+
77
+ function cookieAttributes(secure: boolean): {
78
+ path: string;
79
+ httpOnly: boolean;
80
+ secure: boolean;
81
+ sameSite: "lax";
82
+ } {
83
+ return { path: "/", httpOnly: true, secure, sameSite: "lax" };
84
+ }
85
+
86
+ /**
87
+ * A document request: one that renders HTML (mayNeedSSR), not a navigation,
88
+ * an action, a loader fetch or a Flight fetch. Those carry state in their
89
+ * Flight payload, or are not what the browser shows.
90
+ */
91
+ function isDocumentRequest(request: Request, url: URL): boolean {
92
+ return mayNeedSSR(request, url);
93
+ }
94
+
95
+ function parseCarriedState(raw: string): CarriedState | undefined {
96
+ try {
97
+ const parsed: unknown = JSON.parse(decodeURIComponent(raw));
98
+ if (parsed === null || typeof parsed !== "object") return undefined;
99
+ const { p, s } = parsed as Partial<CarriedState>;
100
+ if (typeof p !== "string" || s === null || typeof s !== "object") {
101
+ return undefined;
102
+ }
103
+ return { p, s };
104
+ } catch {
105
+ return undefined;
106
+ }
107
+ }
108
+
109
+ /**
110
+ * `Sec-Fetch-Dest` values of a request that navigates a browsing context: the
111
+ * one a browser makes when it follows a document redirect.
112
+ */
113
+ const NAVIGATION_FETCH_DESTS: ReadonlySet<string> = new Set([
114
+ "document",
115
+ "iframe",
116
+ "frame",
117
+ ]);
118
+
119
+ /**
120
+ * A request that navigates: the browser shows its response. Where the browser
121
+ * sends `Sec-Fetch-Dest`, it must name a browsing context. A same-path
122
+ * `fetch()` with a wildcard `Accept` or a `<link rel=prefetch>` passes
123
+ * mayNeedSSR but sends `empty`, and must not eat the cookie before the
124
+ * navigation it was left for. A request without the header (an older
125
+ * browser, a non-browser client) keeps the mayNeedSSR rule alone.
126
+ */
127
+ function isNavigationRequest(request: Request): boolean {
128
+ const dest = requestHeaders(request).get("sec-fetch-dest");
129
+ return dest === null || NAVIGATION_FETCH_DESTS.has(dest);
130
+ }
131
+
132
+ /**
133
+ * Read the cookie a document redirect left for this request: seed
134
+ * `_locationState` with its slots and clear it. Only a GET document request
135
+ * of the target pathname that navigates consumes it (the request the browser
136
+ * makes for a 301/302/303, see isNavigationRequest); any other request, a
137
+ * form POST to that path or a prefetch of it included, leaves it to its
138
+ * expiry. A malformed value is cleared and ignored, and only `__rsc_ls_*`
139
+ * slots are taken from it (the value comes from the browser).
140
+ */
141
+ export function consumeDocumentLocationState(
142
+ reqCtx: Pick<
143
+ RequestContext,
144
+ "_locationState" | "_carriedLocationStateCookie" | "setCookie"
145
+ >,
146
+ request: Request,
147
+ url: URL,
148
+ stateCookieName: string,
149
+ ): void {
150
+ if (request.method !== "GET") return;
151
+ if (!isDocumentRequest(request, url)) return;
152
+ if (!isNavigationRequest(request)) return;
153
+ const secure = url.protocol === "https:";
154
+ const name = documentLocationStateCookieName(stateCookieName, secure);
155
+ const raw = getRawCookieValue(requestHeaders(request).get("cookie"), name);
156
+ if (raw === null) return;
157
+ const carried = parseCarriedState(raw);
158
+ if (carried && carried.p !== url.pathname) return;
159
+ reqCtx.setCookie(name, "", { ...cookieAttributes(secure), maxAge: 0 });
160
+ if (!carried) return;
161
+ const entries: LocationStateEntry[] = [];
162
+ for (const key of Object.keys(carried.s)) {
163
+ if (!isLocationStateKey(key)) continue;
164
+ entries.push({ __rsc_ls_key: key, __rsc_ls_value: carried.s[key] });
165
+ }
166
+ if (entries.length === 0) return;
167
+ reqCtx._locationState = entries;
168
+ reqCtx._carriedLocationStateCookie = restoreCookieLine(name, raw, secure);
169
+ }
170
+
171
+ /**
172
+ * The `document.cookie` line that sets the consumed cookie again, for a
173
+ * shell HIT that reloads after its headers cleared it (rsc/rsc-rendering.ts
174
+ * serveShellHit). Script cannot write an `HttpOnly` cookie, so this one is
175
+ * readable by the page's script for its short life; it carries nothing the
176
+ * page's payload does not already hold.
177
+ */
178
+ function restoreCookieLine(name: string, raw: string, secure: boolean): string {
179
+ return (
180
+ `${name}=${raw}; Path=/; Max-Age=${DOCUMENT_LOCATION_STATE_MAX_AGE}; ` +
181
+ `SameSite=Lax${secure ? "; Secure" : ""}`
182
+ );
183
+ }
184
+
185
+ /** JSON keeps it as it is: what the cookie can carry without change. */
186
+ function isJsonPlain(value: unknown): boolean {
187
+ if (value === null) return true;
188
+ switch (typeof value) {
189
+ case "string":
190
+ case "boolean":
191
+ return true;
192
+ case "number":
193
+ return Number.isFinite(value);
194
+ case "object": {
195
+ if (Array.isArray(value)) return value.every(isJsonPlain);
196
+ const proto = Object.getPrototypeOf(value);
197
+ if (proto !== Object.prototype && proto !== null) return false;
198
+ return Object.values(value as Record<string, unknown>).every(
199
+ (member) => member !== undefined && isJsonPlain(member),
200
+ );
201
+ }
202
+ default:
203
+ return false;
204
+ }
205
+ }
206
+
207
+ function slotNames(state: Record<string, unknown>): string {
208
+ return Object.keys(state).join(", ");
209
+ }
210
+
211
+ /**
212
+ * Write the cookie on a document request's redirect that set location state,
213
+ * and mark the redirect private. Not for a partial or action request (their
214
+ * redirect is a Flight payload), an external or cross-origin target (the
215
+ * cookie would never reach it) or a response that is not a 3xx with a
216
+ * Location. Returns the response to send: a new one when its headers are
217
+ * immutable.
218
+ */
219
+ export function carryLocationStateAcrossRedirect(
220
+ response: Response,
221
+ reqCtx: Pick<RequestContext, "_locationState">,
222
+ request: Request,
223
+ url: URL,
224
+ stateCookieName: string,
225
+ ): Response {
226
+ const entries = reqCtx._locationState;
227
+ if (!entries || entries.length === 0) return response;
228
+ if (!isDocumentRequest(request, url)) return response;
229
+ if (response.status < 300 || response.status >= 400) return response;
230
+ if (isExternalRedirect(response)) return response;
231
+ const location = response.headers.get("Location");
232
+ if (!location) return response;
233
+ let target: URL;
234
+ try {
235
+ target = new URL(location, url);
236
+ } catch {
237
+ return response;
238
+ }
239
+ if (target.origin !== url.origin) return response;
240
+
241
+ let state: Record<string, unknown>;
242
+ try {
243
+ state = resolveLocationStateEntries(entries);
244
+ } catch (error) {
245
+ // A lazy entry's getter threw: the redirect still leaves, without state.
246
+ console.warn(
247
+ "[rango] Location state was dropped from a document redirect: " +
248
+ `resolving it threw (${String(error)}).`,
249
+ );
250
+ return response;
251
+ }
252
+ if (process.env.NODE_ENV !== "production") {
253
+ for (const [key, value] of Object.entries(state)) {
254
+ if (!isJsonPlain(value)) {
255
+ console.warn(
256
+ `[rango] Location state "${key}" crosses a document redirect as JSON, ` +
257
+ "which does not keep this value as it is (a Date, Map, Set, bigint, " +
258
+ "undefined or class instance). Store plain JSON data in state that a " +
259
+ "document request redirects with.",
260
+ );
261
+ }
262
+ }
263
+ }
264
+
265
+ const secure = url.protocol === "https:";
266
+ const name = documentLocationStateCookieName(stateCookieName, secure);
267
+ let cookie: string;
268
+ try {
269
+ cookie = serializeCookieValue(
270
+ name,
271
+ JSON.stringify({ p: target.pathname, s: state } satisfies CarriedState),
272
+ { ...cookieAttributes(secure), maxAge: DOCUMENT_LOCATION_STATE_MAX_AGE },
273
+ );
274
+ } catch {
275
+ console.warn(
276
+ `[rango] Location state (${slotNames(state)}) was dropped from a document ` +
277
+ "redirect: it cannot be serialized as JSON.",
278
+ );
279
+ return response;
280
+ }
281
+ if (cookie.length > DOCUMENT_LOCATION_STATE_MAX_BYTES) {
282
+ console.warn(
283
+ `[rango] Location state (${slotNames(state)}) was dropped from a document ` +
284
+ `redirect: its cookie is ${cookie.length} bytes, over the ` +
285
+ `${DOCUMENT_LOCATION_STATE_MAX_BYTES}-byte limit. Carry less state, or ` +
286
+ "an id the target page loads the rest by.",
287
+ );
288
+ return response;
289
+ }
290
+
291
+ return withHeaders(response, (headers) => {
292
+ headers.append("Set-Cookie", cookie);
293
+ headers.set("Cache-Control", PRIVATE_NO_STORE);
294
+ });
295
+ }
296
+
297
+ /**
298
+ * Apply `write` to the response's headers, or to a copy of the response when
299
+ * they are immutable (a `Response.redirect()`). A WebSocket upgrade is never
300
+ * rebuilt and never carries location state.
301
+ */
302
+ function withHeaders(
303
+ response: Response,
304
+ write: (headers: Headers) => void,
305
+ ): Response {
306
+ if (isWebSocketUpgradeResponse(response)) return response;
307
+ try {
308
+ write(response.headers);
309
+ return response;
310
+ } catch {
311
+ const copy = new Response(response.body, {
312
+ status: response.status,
313
+ statusText: response.statusText,
314
+ headers: new Headers(response.headers),
315
+ });
316
+ write(copy.headers);
317
+ return copy;
318
+ }
319
+ }
320
+
321
+ /**
322
+ * `Cache-Control: private, no-store` on a document that carries location
323
+ * state, after every middleware: a shared cache must never store or replay
324
+ * one visitor's state. Returns the response to send.
325
+ */
326
+ export function markDocumentResponsePrivate(
327
+ response: Response,
328
+ reqCtx: Pick<RequestContext, "_documentLocationState">,
329
+ ): Response {
330
+ if (!reqCtx._documentLocationState) return response;
331
+ return withHeaders(response, (headers) =>
332
+ headers.set("Cache-Control", PRIVATE_NO_STORE),
333
+ );
334
+ }
@@ -302,6 +302,8 @@ export interface HandleStore {
302
302
  * so a loader that throws or skips its push shows none rather than the
303
303
  * record's. A hole's replays go with them. Any other loader's replays stay
304
304
  * (a dependency another entry's HIT replayed), and so do restored copies.
305
+ * A prefetch that defers the loader calls it too (revalidation.ts
306
+ * resolveOwnerLoaders): no run is made, so none of its placeholders stand.
305
307
  */
306
308
  settleLoaderRun(loaderId: string): void;
307
309