@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
@@ -17,6 +17,7 @@ import type { CacheErrorCategory } from "../cache/cache-error.js";
17
17
  import type { CookieOptions } from "../router/middleware.js";
18
18
  import {
19
19
  KEEP_CACHE_HEADER,
20
+ bumpStateWrite,
20
21
  getRawCookieValue,
21
22
  freshReadsCookieName,
22
23
  mintStateValue,
@@ -83,7 +84,10 @@ import {
83
84
  readGuardedTheme,
84
85
  } from "./cookie-store.js";
85
86
  import { requestHeaders } from "./request-headers.js";
86
- import { requestKind } from "../router/segment-resolution/prefetch-deferral.js";
87
+ import {
88
+ requestKind,
89
+ type RequestKind,
90
+ } from "../router/segment-resolution/prefetch-deferral.js";
87
91
  import {
88
92
  createReverseFunction,
89
93
  stripInternalParams,
@@ -203,8 +207,12 @@ export interface RequestContext<
203
207
  setStatus(status: number): void;
204
208
  /** @internal Set status bypassing cache-exec guard (for framework error handling) */
205
209
  _setStatus(status: number): void;
206
- /** @internal Rotate the rango state cookie (server seat of invalidateClientCache). */
207
- _rotateStateCookie(): void;
210
+ /**
211
+ * @internal Rotate the rango state cookie: its timestamp (the server seat of
212
+ * invalidateClientCache), or with `"write"` only its write part (a server
213
+ * action without a directive, cookie-store.ts runActionBody).
214
+ */
215
+ _rotateStateCookie(part?: "write"): void;
208
216
  /** @internal Set the keepClientCache() directive header on the response. */
209
217
  _setKeepCacheDirective(): void;
210
218
  /**
@@ -663,11 +671,30 @@ export interface RequestContext<
663
671
  _payloadDeferred?: boolean;
664
672
 
665
673
  /**
666
- * @internal What the request says it is: a `<Link>` prefetch or a fill
667
- * (`prefetch: false`), read once at creation
668
- * (segment-resolution/prefetch-deferral.ts requestKind).
674
+ * @internal Set by the RSC handler when a partial response carries an
675
+ * `x-rsc-prefetch-scope` (router/source-reads.ts prefetchScopeOf): its diff
676
+ * is right for its source page, or a group of them, only. The document
677
+ * cache keys a partial without its source, so it does not store that
678
+ * response, whatever Cache-Control it ends up with.
679
+ */
680
+ _payloadSourceScoped?: boolean;
681
+
682
+ /**
683
+ * @internal Set when the answer has nothing live in it, so nothing in it can
684
+ * fail after the 200's headers left: the document cache middleware on a
685
+ * fresh HIT, and the RSC handler when the partial match came from storage
686
+ * (MatchResult.fromStorage). finalizeRequestResponse (rsc/request-stages.ts)
687
+ * sends such a prefetch the router's prefetch Cache-Control.
669
688
  */
670
- _requestKind?: "prefetch" | "fill";
689
+ _servedFromStorage?: boolean;
690
+
691
+ /**
692
+ * @internal What the request says it is: a speculative `<Link>` prefetch,
693
+ * an intent prefetch (hover, `prefetch()`) or a fill (`prefetch: false`),
694
+ * read once at creation (segment-resolution/prefetch-deferral.ts
695
+ * requestKind).
696
+ */
697
+ _requestKind?: RequestKind;
671
698
 
672
699
  /**
673
700
  * @internal Set when a render shows the visitor's URL of a rewritten
@@ -680,12 +707,27 @@ export interface RequestContext<
680
707
  /**
681
708
  * Attach location state entries to the current response.
682
709
  *
683
- * For partial (SPA) requests, the state is included in the RSC payload
684
- * metadata and merged into history.pushState on the client. For redirect
685
- * responses, the state travels through the redirect payload so the target
686
- * page can read it via useLocationState.
710
+ * For a client navigation or an action, the state is included in the RSC
711
+ * payload metadata and merged into the entry's history.state on the
712
+ * client. For a document request (a page load, a no-JS form post), it rides
713
+ * the document's bootstrap payload and is merged into the entry when the
714
+ * client starts; readers get it once the page has hydrated. For a redirect,
715
+ * the state travels with the redirect: in its Flight payload on a
716
+ * navigation or action, in a one-shot cookie the target's document request
717
+ * reads on a document request. A document that carries state is sent
718
+ * `Cache-Control: private, no-store` and never stored by the document cache
719
+ * or as a PPR shell.
687
720
  *
688
- * Multiple calls accumulate entries.
721
+ * Call it from middleware, a handler or an action. A loader's call
722
+ * (through `getRequestContext()`) is not supported: the payload takes the
723
+ * state before loaders settle, so the write may be dropped, and a PPR
724
+ * shell HIT never sends it.
725
+ *
726
+ * Multiple calls accumulate entries across definitions. For one definition
727
+ * the last write wins: two entries of the same definition share its slot
728
+ * in history.state, so a later call (or a later entry in one array)
729
+ * replaces an earlier one. To combine writes, make the state an array and
730
+ * write it once.
689
731
  *
690
732
  * @example
691
733
  * ```typescript
@@ -697,6 +739,24 @@ export interface RequestContext<
697
739
  /** @internal Accumulated location state entries */
698
740
  _locationState?: LocationStateEntry[];
699
741
 
742
+ /**
743
+ * @internal Set when a document's payload carries location state
744
+ * (rsc/rsc-rendering.ts, rsc/progressive-enhancement.ts), a shell HIT's
745
+ * included. The document is per visitor: the document cache refuses it,
746
+ * and it leaves `private, no-store` (server/document-location-state.ts).
747
+ */
748
+ _documentLocationState?: boolean;
749
+
750
+ /**
751
+ * @internal The `document.cookie` line that re-creates the location state
752
+ * cookie this request consumed (server/document-location-state.ts). The
753
+ * consuming response clears the cookie in its headers; a shell HIT that
754
+ * degrades into a reload after those headers left writes this line first
755
+ * (rsc/shell-serve.ts shellReloadScript), so the reload reads the state
756
+ * again.
757
+ */
758
+ _carriedLocationStateCookie?: string;
759
+
700
760
  /**
701
761
  * The matched route name, if the route has an explicit name.
702
762
  * Undefined before route matching or for unnamed routes.
@@ -1003,9 +1063,13 @@ export type PublicRequestContext<
1003
1063
  | "_documentCacheRender"
1004
1064
  | "_payloadVisitorTheme"
1005
1065
  | "_payloadDeferred"
1066
+ | "_payloadSourceScoped"
1067
+ | "_servedFromStorage"
1006
1068
  | "_requestKind"
1007
1069
  | "_payloadVisitorLocation"
1008
1070
  | "_locationState"
1071
+ | "_documentLocationState"
1072
+ | "_carriedLocationStateCookie"
1009
1073
  | "_routeName"
1010
1074
  | "_prevRouteKey"
1011
1075
  | "_visitorMoved"
@@ -1358,7 +1422,12 @@ export function createRequestContext<TEnv>(
1358
1422
  // of this request's headers goes through requestHeaders().
1359
1423
  guardRequestHeaders(request);
1360
1424
  const cookieHeader = requestHeaders(request).get("Cookie");
1361
- let rangoStateRotated = false;
1425
+ // What this response's state Set-Cookie changed: nothing yet, the write
1426
+ // part only, or the timestamp (which covers a write).
1427
+ let rangoStateRotated: "write" | "timestamp" | null = null;
1428
+ // The state Set-Cookie line written, replaced when a timestamp rotation
1429
+ // follows a write bump in the same request.
1430
+ let stateCookieLine: string | null = null;
1362
1431
  const freshCookie = stateCookieName
1363
1432
  ? freshReadsCookieName(stateCookieName)
1364
1433
  : undefined;
@@ -1438,6 +1507,17 @@ export function createRequestContext<TEnv>(
1438
1507
  rawStubHeaders = stubResponse.headers;
1439
1508
  shadowStubHeaders(stubResponse);
1440
1509
  };
1510
+ // Remove one Set-Cookie line an internal writer appended earlier in this
1511
+ // request (Headers has no per-value delete): the state cookie when a later
1512
+ // write replaces or withdraws it, the fresh-reads cookie when a longer
1513
+ // Max-Age replaces it.
1514
+ const dropSetCookieLine = (line: string): void => {
1515
+ const kept = rawStubHeaders
1516
+ .getSetCookie()
1517
+ .filter((cookie) => cookie !== line);
1518
+ rawStubHeaders.delete("Set-Cookie");
1519
+ for (const cookie of kept) rawStubHeaders.append("Set-Cookie", cookie);
1520
+ };
1441
1521
 
1442
1522
  const handleStore = createHandleStore();
1443
1523
  const loaderPromises = new Map<string, Promise<any>>();
@@ -1613,16 +1693,29 @@ export function createRequestContext<TEnv>(
1613
1693
  stubResponse.headers.set(name, value);
1614
1694
  },
1615
1695
 
1616
- // Rotate the rango state cookie for the responding client (the server seat
1617
- // of invalidateClientCache). Writes ONE Set-Cookie per request with the
1618
- // value {version}:{timestamp}; the `:` stays raw (the cookie-name.ts
1696
+ // Rotate the rango state cookie for the responding client. Writes ONE
1697
+ // Set-Cookie per request; the `:` stays raw (the cookie-name.ts
1619
1698
  // serializer), not the URL-encoded form serializeCookieValue would produce.
1620
- // The timestamp is strictly greater than the client's current one (inbound
1621
- // X-Rango-State), so a same-millisecond server rotation still differs from
1622
- // the client value and the divergence observer fires.
1623
- _rotateStateCookie(): void {
1624
- if (rangoStateRotated) return;
1625
- rangoStateRotated = true;
1699
+ // - No part (the server seat of invalidateClientCache): a new timestamp,
1700
+ // {version}:{timestamp}. It is strictly greater than the client's current
1701
+ // one (inbound X-Rango-State), so a same-millisecond server rotation
1702
+ // still differs from the client value and the divergence observer fires.
1703
+ // - "write" (a server action without a directive, after its body ran): the
1704
+ // client's version and timestamp kept and the write part bumped,
1705
+ // {version}:{timestamp}:{write}, so the browser HTTP cache misses on
1706
+ // every response it holds from before the write while the client's
1707
+ // in-memory caches (keyed by {version}:{timestamp}) only go stale.
1708
+ // Skipped after keepClientCache() (and withdrawn by a later one, below),
1709
+ // and when the request carries no state.
1710
+ // A timestamp rotation covers a write bump: the later one of the two
1711
+ // replaces the bump's line, and a bump after a rotation adds nothing.
1712
+ _rotateStateCookie(part?: "write"): void {
1713
+ const kind = part === "write" ? "write" : "timestamp";
1714
+ if (rangoStateRotated === "timestamp" || rangoStateRotated === kind) {
1715
+ return;
1716
+ }
1717
+ if (kind === "write" && rawStubHeaders.has(KEEP_CACHE_HEADER)) return;
1718
+ rangoStateRotated = kind;
1626
1719
  if (!stateCookieName) return;
1627
1720
  // The client's current value, for the monotonic guard: prefer the
1628
1721
  // X-Rango-State header (router navigation/prefetch fetches send it), but
@@ -1637,22 +1730,38 @@ export function createRequestContext<TEnv>(
1637
1730
  const prevRaw =
1638
1731
  (requestHeaders(request).get("x-rango-state") || null) ??
1639
1732
  getRawCookieValue(cookieHeader, stateCookieName);
1640
- const value = mintStateValue(versions?.document ?? "0", prevRaw);
1733
+ const value =
1734
+ kind === "write"
1735
+ ? bumpStateWrite(prevRaw)
1736
+ : mintStateValue(versions?.document ?? "0", prevRaw);
1737
+ // No state on the request: nothing is cached under one to miss.
1738
+ if (value === null) return;
1641
1739
  // rawStubHeaders: guard-exempt internal writer — invalidateClientCache()
1642
1740
  // is documented callable from loaders and during shell capture.
1643
- rawStubHeaders.append(
1644
- "Set-Cookie",
1645
- serializeStateCookie(stateCookieName, value, url.protocol === "https:"),
1741
+ if (stateCookieLine !== null) dropSetCookieLine(stateCookieLine);
1742
+ stateCookieLine = serializeStateCookie(
1743
+ stateCookieName,
1744
+ value,
1745
+ url.protocol === "https:",
1646
1746
  );
1747
+ rawStubHeaders.append("Set-Cookie", stateCookieLine);
1647
1748
  invalidateResponseCookieCache();
1648
1749
  },
1649
1750
 
1650
1751
  // Set the keepClientCache() directive header. The action bridge reads it on
1651
1752
  // the response and suppresses its automatic invalidation. `.set` makes this
1652
1753
  // idempotent (one header regardless of call count). rawStubHeaders:
1653
- // guard-exempt internal writer.
1754
+ // guard-exempt internal writer. A write bump already written (the action
1755
+ // body ended, then global middleware after next() or a loader of the
1756
+ // revalidation called it) is withdrawn: keepClientCache() bumps nothing.
1654
1757
  _setKeepCacheDirective(): void {
1655
1758
  rawStubHeaders.set(KEEP_CACHE_HEADER, "1");
1759
+ if (rangoStateRotated === "write" && stateCookieLine !== null) {
1760
+ dropSetCookieLine(stateCookieLine);
1761
+ stateCookieLine = null;
1762
+ rangoStateRotated = null;
1763
+ invalidateResponseCookieCache();
1764
+ }
1656
1765
  },
1657
1766
 
1658
1767
  // The fresh-reads cookie (browser/cookie-name.ts): one Set-Cookie per
@@ -1685,12 +1794,7 @@ export function createRequestContext<TEnv>(
1685
1794
  `${freshCookie}=1; Max-Age=${age}; Path=/; HttpOnly; SameSite=Lax` +
1686
1795
  (url.protocol === "https:" ? "; Secure" : "");
1687
1796
  if (freshCookieMaxAge > 0) {
1688
- const prior = serialized(freshCookieMaxAge);
1689
- const kept = rawStubHeaders
1690
- .getSetCookie()
1691
- .filter((cookie) => cookie !== prior);
1692
- rawStubHeaders.delete("Set-Cookie");
1693
- for (const cookie of kept) rawStubHeaders.append("Set-Cookie", cookie);
1797
+ dropSetCookieLine(serialized(freshCookieMaxAge));
1694
1798
  }
1695
1799
  freshCookieMaxAge = maxAge;
1696
1800
  rawStubHeaders.append("Set-Cookie", serialized(maxAge));
@@ -177,6 +177,10 @@ interface DispatchableRouter<TEnv> {
177
177
  onTimeout?: OnTimeoutCallback<TEnv>;
178
178
  findMatch: ClassifyRequestDeps<TEnv>["findMatch"];
179
179
  basename?: string;
180
+ /** The rango state cookie name (RangoInternal field). */
181
+ resolvedStateCookieName: string;
182
+ /** The in-memory prefetch TTL in ms (RangoInternal field). */
183
+ prefetchCacheTTL: number;
180
184
  cache?:
181
185
  | {
182
186
  enabled?: boolean;
@@ -292,6 +296,8 @@ export async function dispatch<TEnv = any>(
292
296
  telemetry: router.telemetry,
293
297
  timeouts: router.timeouts,
294
298
  onTimeout: router.onTimeout,
299
+ resolvedStateCookieName: router.resolvedStateCookieName,
300
+ prefetchCacheTTL: router.prefetchCacheTTL,
295
301
  },
296
302
  callOnError: stages.createErrorReporter<TEnv>(router),
297
303
  // A response-route handler's own throw is serialized into the same typed
@@ -10,6 +10,7 @@ import {
10
10
  runCli,
11
11
  type RunCliHandle,
12
12
  type SpawnOptions,
13
+ stopServer,
13
14
  waitForReady,
14
15
  warmupDevServer,
15
16
  } from "./server.js";
@@ -93,10 +94,7 @@ export function createUseFixture(
93
94
  // throw (findPort/waitForReady). Otherwise a beforeAll failure leaves
94
95
  // afterAll's cleanup a no-op and orphans the dev server (plus workerd
95
96
  // children) for the rest of the run.
96
- cleanup = async () => {
97
- proc!.kill();
98
- await proc!.done;
99
- };
97
+ cleanup = () => stopServer(proc!);
100
98
  const port = await proc.findPort();
101
99
  baseURL = `http://localhost:${port}`;
102
100
  const readyUrl = options.readyPath
@@ -128,10 +126,7 @@ export function createUseFixture(
128
126
  });
129
127
  // The build is a finite process, but assign cleanup before the await
130
128
  // so a hung build is still killed if beforeAll is torn down.
131
- cleanup = async () => {
132
- buildProc.kill();
133
- await buildProc.done;
134
- };
129
+ cleanup = () => stopServer(buildProc);
135
130
  // Fail loudly on a nonzero build exit. Without this a failed build is
136
131
  // swallowed and preview serves the previous stale dist/ (green
137
132
  // production tests against old code), or times out with "Server not
@@ -155,10 +150,7 @@ export function createUseFixture(
155
150
  });
156
151
  // Switch cleanup to the preview process immediately after spawn, before
157
152
  // findPort/waitForReady can throw and orphan it.
158
- cleanup = async () => {
159
- proc!.kill();
160
- await proc!.done;
161
- };
153
+ cleanup = () => stopServer(proc!);
162
154
  const port = await proc.findPort();
163
155
  baseURL = `http://localhost:${port}`;
164
156
  const buildReadyUrl = options.readyPath
@@ -19,7 +19,13 @@ export interface RunCliHandle {
19
19
  */
20
20
  exitCode: Promise<number | null>;
21
21
  findPort: (timeoutMs?: number) => Promise<number>;
22
- kill: () => void;
22
+ /**
23
+ * Stop the process and what it spawned (the whole process group). SIGTERM by
24
+ * default; `stopServer` escalates to SIGKILL for a process that outlives the
25
+ * wait.
26
+ */
27
+ kill: (signal?: NodeJS.Signals) => void;
28
+ readonly pid: number | undefined;
23
29
  stdout: () => string;
24
30
  stderr: () => string;
25
31
  }
@@ -34,9 +40,16 @@ export function runCli(
34
40
  // hits EOF immediately, so without CI=true the dev/preview server shuts itself
35
41
  // down before it finishes starting. Real CI runners set CI=true; mirror that for
36
42
  // locally-spawned servers so they stay alive for the duration of the tests.
43
+ // detached makes the child a process-group leader (pgid === pid), so kill()
44
+ // can signal the whole tree. `pnpm dev` spawns vite as a grandchild; without
45
+ // it process.kill(-pid) throws ESRCH, the fallback child.kill() stops only
46
+ // pnpm, and vite keeps running after the suite. Not on Windows: there
47
+ // detached opens a console window per server, and kill() already ends the
48
+ // tree with `taskkill /t`.
37
49
  const child = x(name!, args, {
38
50
  nodeOptions: {
39
51
  ...options,
52
+ detached: process.platform !== "win32",
40
53
  env: { ...process.env, CI: "true", ...options.env },
41
54
  },
42
55
  }).process!;
@@ -100,16 +113,16 @@ export function runCli(
100
113
  });
101
114
  }
102
115
 
103
- function kill() {
116
+ function kill(signal: NodeJS.Signals = "SIGTERM") {
104
117
  if (process.platform === "win32") {
105
118
  spawn("taskkill", ["/pid", String(child.pid), "/t", "/f"]);
106
119
  } else {
107
120
  // Kill entire process group (Vite spawns child processes like workerd).
108
121
  // Falls back to direct kill if process group kill fails.
109
122
  try {
110
- process.kill(-child.pid!, "SIGTERM");
123
+ process.kill(-child.pid!, signal);
111
124
  } catch {
112
- child.kill();
125
+ child.kill(signal);
113
126
  }
114
127
  }
115
128
  }
@@ -120,11 +133,85 @@ export function runCli(
120
133
  exitCode,
121
134
  findPort,
122
135
  kill,
136
+ get pid() {
137
+ return child.pid;
138
+ },
123
139
  stdout: () => stdout,
124
140
  stderr: () => stderr,
125
141
  };
126
142
  }
127
143
 
144
+ /** How long `stopServer` waits for SIGTERM to take effect before SIGKILL. */
145
+ const STOP_WAIT_MS = 10_000;
146
+ /** How long `stopServer` waits for the SIGKILL to take effect. */
147
+ const KILL_WAIT_MS = 2_000;
148
+
149
+ /** Resolves true when `done` settled within `ms`, false when the timer won. */
150
+ async function exitedWithin(done: Promise<unknown>, ms: number) {
151
+ let timer: NodeJS.Timeout | undefined;
152
+ const timedOut = new Promise<false>((resolve) => {
153
+ timer = setTimeout(() => resolve(false), ms);
154
+ });
155
+ try {
156
+ return await Promise.race([done.then(() => true as const), timedOut]);
157
+ } finally {
158
+ clearTimeout(timer);
159
+ }
160
+ }
161
+
162
+ /** True while a process of the group led by `pgid` exists (not on Windows). */
163
+ function groupAlive(pgid: number): boolean {
164
+ if (process.platform === "win32") return false;
165
+ try {
166
+ process.kill(-pgid, 0);
167
+ return true;
168
+ } catch (error) {
169
+ // EPERM: the group exists but belongs to someone else. Only ESRCH is gone.
170
+ return (error as NodeJS.ErrnoException).code === "EPERM";
171
+ }
172
+ }
173
+
174
+ /**
175
+ * Resolves true once `server.done` settled and no process of its group is left,
176
+ * false when `ms` ran out first. The leader (`pnpm`) exiting says nothing about
177
+ * the vite it spawned, which can outlive it, so the group is polled too.
178
+ */
179
+ async function groupExitedWithin(
180
+ server: RunCliHandle,
181
+ ms: number,
182
+ ): Promise<boolean> {
183
+ const deadline = Date.now() + ms;
184
+ if (!(await exitedWithin(server.done, ms))) return false;
185
+ const pgid = server.pid;
186
+ if (pgid === undefined) return true;
187
+ while (groupAlive(pgid)) {
188
+ if (Date.now() >= deadline) return false;
189
+ await new Promise((resolve) => setTimeout(resolve, 25));
190
+ }
191
+ return true;
192
+ }
193
+
194
+ /**
195
+ * Stop a server started by `runCli` and wait for its whole process group to
196
+ * exit: SIGTERM to the group, then SIGKILL (and a log naming the pid) for a
197
+ * process that outlives the wait, so a leaked server is visible rather than
198
+ * left holding its port.
199
+ */
200
+ export async function stopServer(
201
+ server: RunCliHandle,
202
+ options: { exitWaitMs?: number } = {},
203
+ ): Promise<void> {
204
+ const exitWaitMs = options.exitWaitMs ?? STOP_WAIT_MS;
205
+ server.kill();
206
+ if (await groupExitedWithin(server, exitWaitMs)) return;
207
+ console.error(
208
+ `[rango e2e] server (pid ${server.pid ?? "unknown"}) ignored SIGTERM for ` +
209
+ `${exitWaitMs} ms and was sent SIGKILL`,
210
+ );
211
+ server.kill("SIGKILL");
212
+ await groupExitedWithin(server, KILL_WAIT_MS);
213
+ }
214
+
128
215
  export function tailOutput(text: string, maxChars = 4000): string {
129
216
  if (!text) return "(empty)";
130
217
  if (text.length <= maxChars) return text;