@rangojs/router 0.32.0 → 0.33.1

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 (181) 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 -10173
  11. package/dist/bin/runtime-EPUDEHO4.js +596 -0
  12. package/dist/bin/runtime-discovery-3HH6BP3K.js +352 -0
  13. package/dist/bin/state-ACD2MGKJ.js +16 -0
  14. package/dist/bin/temp-rsc-server-JALTIWLT.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/invalidate-client-cache.d.ts +8 -6
  19. package/dist/types/browser/navigation-bridge.d.ts +36 -1
  20. package/dist/types/browser/navigation-client.d.ts +19 -0
  21. package/dist/types/browser/navigation-store-handle.d.ts +37 -4
  22. package/dist/types/browser/navigation-store.d.ts +2 -2
  23. package/dist/types/browser/navigation-transaction.d.ts +0 -1
  24. package/dist/types/browser/partial-update.d.ts +13 -0
  25. package/dist/types/browser/prefetch/cache.d.ts +96 -8
  26. package/dist/types/browser/prefetch/fetch.d.ts +26 -2
  27. package/dist/types/browser/prefetch/queue.d.ts +3 -2
  28. package/dist/types/browser/rango-state.d.ts +34 -5
  29. package/dist/types/browser/react/Link.d.ts +15 -6
  30. package/dist/types/browser/types.d.ts +25 -0
  31. package/dist/types/build/generate-route-types.d.ts +1 -1
  32. package/dist/types/build/route-types/include-resolution.d.ts +9 -1
  33. package/dist/types/build/route-types/router-processing.d.ts +3 -1
  34. package/dist/types/cache/cache-key-utils.d.ts +12 -8
  35. package/dist/types/cache/cache-scope.d.ts +4 -2
  36. package/dist/types/cache/cf/cf-cache-types.d.ts +9 -5
  37. package/dist/types/cache/document-cache-policy.d.ts +9 -1
  38. package/dist/types/cache/memory-segment-store.d.ts +3 -2
  39. package/dist/types/cache/segment-codec.d.ts +2 -2
  40. package/dist/types/cache/shell-snapshot.d.ts +1 -1
  41. package/dist/types/cache/types.d.ts +12 -7
  42. package/dist/types/cache/vercel/vercel-cache-store.d.ts +2 -1
  43. package/dist/types/client-urls/types.d.ts +7 -5
  44. package/dist/types/prefetch-markers.d.ts +9 -0
  45. package/dist/types/prefetch-source-scope.d.ts +66 -0
  46. package/dist/types/route-definition/helpers-types.d.ts +8 -5
  47. package/dist/types/router/intercept-resolution.d.ts +25 -11
  48. package/dist/types/router/match-context.d.ts +1 -0
  49. package/dist/types/router/match-handlers.d.ts +1 -1
  50. package/dist/types/router/prefetch-cache-ttl.d.ts +11 -3
  51. package/dist/types/router/router-context.d.ts +1 -1
  52. package/dist/types/router/router-options.d.ts +6 -3
  53. package/dist/types/router/segment-resolution/loader-cache.d.ts +8 -0
  54. package/dist/types/router/segment-resolution/prefetch-deferral.d.ts +23 -7
  55. package/dist/types/router/segment-resolution/revalidation.d.ts +19 -0
  56. package/dist/types/router/source-reads.d.ts +53 -0
  57. package/dist/types/router/types.d.ts +1 -1
  58. package/dist/types/rsc/helpers.d.ts +5 -2
  59. package/dist/types/rsc/prefetch-render-error.d.ts +7 -2
  60. package/dist/types/rsc/request-stages.d.ts +2 -0
  61. package/dist/types/rsc/shell-serve.d.ts +6 -1
  62. package/dist/types/server/context.d.ts +23 -6
  63. package/dist/types/server/cookie-store.d.ts +15 -1
  64. package/dist/types/server/document-location-state.d.ts +5 -2
  65. package/dist/types/server/handle-store.d.ts +2 -0
  66. package/dist/types/server/request-context.d.ts +43 -7
  67. package/dist/types/testing/e2e/server.d.ts +16 -1
  68. package/dist/types/testing/render-route.d.ts +66 -2
  69. package/dist/types/testing/serve-shell-request.d.ts +6 -3
  70. package/dist/types/types/handler-context.d.ts +7 -5
  71. package/dist/types/types/loader-types.d.ts +7 -4
  72. package/dist/types/types/segments.d.ts +15 -4
  73. package/dist/types/urls/path-helper-types.d.ts +7 -5
  74. package/dist/types/vite/temp-rsc-server.d.ts +2 -2
  75. package/dist/vite/index.js +449 -189
  76. package/package.json +2 -2
  77. package/skills/app-structure/SKILL.md +81 -0
  78. package/skills/app-structure/e-commerce.md +10 -9
  79. package/skills/app-structure/large-app.md +18 -2
  80. package/skills/caching/SKILL.md +10 -5
  81. package/skills/client-urls/SKILL.md +29 -11
  82. package/skills/comparison/references/framework-comparison.md +13 -8
  83. package/skills/document-cache/SKILL.md +8 -0
  84. package/skills/hooks/navigation.md +8 -8
  85. package/skills/hooks/outlets.md +4 -0
  86. package/skills/hooks/state.md +24 -8
  87. package/skills/intercept/SKILL.md +33 -12
  88. package/skills/layout/SKILL.md +60 -0
  89. package/skills/links/SKILL.md +101 -4
  90. package/skills/loader/SKILL.md +49 -22
  91. package/skills/migrate-nextjs/SKILL.md +8 -2
  92. package/skills/migrate-react-router/data-and-actions.md +5 -0
  93. package/skills/ppr/SKILL.md +26 -5
  94. package/skills/prerender/SKILL.md +4 -1
  95. package/skills/route/SKILL.md +6 -6
  96. package/skills/router-setup/SKILL.md +2 -1
  97. package/skills/server-actions/SKILL.md +41 -8
  98. package/skills/testing/client-components.md +29 -13
  99. package/skills/testing/serve-shell-request.md +7 -7
  100. package/skills/testing/server-actions.md +8 -0
  101. package/skills/ux-architecture/SKILL.md +70 -19
  102. package/src/bin/rango.ts +39 -13
  103. package/src/browser/action-fence.ts +72 -0
  104. package/src/browser/cookie-name.ts +82 -14
  105. package/src/browser/invalidate-client-cache.ts +9 -7
  106. package/src/browser/navigation-bridge.ts +164 -32
  107. package/src/browser/navigation-client.ts +95 -20
  108. package/src/browser/navigation-store-handle.ts +67 -5
  109. package/src/browser/navigation-store.ts +86 -17
  110. package/src/browser/navigation-transaction.ts +0 -6
  111. package/src/browser/partial-update.ts +31 -12
  112. package/src/browser/prefetch/cache.ts +188 -11
  113. package/src/browser/prefetch/fetch.ts +211 -51
  114. package/src/browser/prefetch/queue.ts +3 -2
  115. package/src/browser/rango-state.ts +50 -8
  116. package/src/browser/react/Link.tsx +20 -9
  117. package/src/browser/react/use-navigation.ts +1 -1
  118. package/src/browser/rsc-router.tsx +8 -1
  119. package/src/browser/server-action-bridge.ts +16 -11
  120. package/src/browser/types.ts +25 -0
  121. package/src/build/generate-route-types.ts +1 -0
  122. package/src/build/route-types/include-resolution.ts +40 -3
  123. package/src/build/route-types/router-processing.ts +170 -25
  124. package/src/build/route-types/scan-filter.ts +1 -0
  125. package/src/cache/cache-key-utils.ts +12 -8
  126. package/src/cache/cache-runtime.ts +5 -1
  127. package/src/cache/cache-scope.ts +28 -7
  128. package/src/cache/cf/cf-cache-types.ts +9 -5
  129. package/src/cache/document-cache-policy.ts +27 -0
  130. package/src/cache/document-cache.ts +5 -0
  131. package/src/cache/memory-segment-store.ts +3 -2
  132. package/src/cache/segment-codec.ts +2 -2
  133. package/src/cache/shell-snapshot.ts +1 -1
  134. package/src/cache/types.ts +12 -7
  135. package/src/cache/vercel/vercel-cache-store.ts +2 -1
  136. package/src/client-urls/types.ts +7 -5
  137. package/src/prefetch-markers.ts +10 -0
  138. package/src/prefetch-source-scope.ts +137 -0
  139. package/src/route-definition/helpers-types.ts +8 -5
  140. package/src/router/intercept-resolution.ts +94 -50
  141. package/src/router/loader-resolution.ts +8 -6
  142. package/src/router/match-api.ts +33 -29
  143. package/src/router/match-context.ts +5 -0
  144. package/src/router/match-handlers.ts +2 -1
  145. package/src/router/match-middleware/cache-lookup.ts +21 -13
  146. package/src/router/match-result.ts +39 -87
  147. package/src/router/navigation-snapshot.ts +18 -3
  148. package/src/router/prefetch-cache-ttl.ts +15 -3
  149. package/src/router/revalidation.ts +53 -23
  150. package/src/router/router-context.ts +2 -1
  151. package/src/router/router-options.ts +6 -3
  152. package/src/router/segment-resolution/loader-cache.ts +21 -3
  153. package/src/router/segment-resolution/prefetch-deferral.ts +38 -31
  154. package/src/router/segment-resolution/revalidation.ts +40 -12
  155. package/src/router/source-reads.ts +122 -0
  156. package/src/router/types.ts +2 -1
  157. package/src/router.ts +11 -12
  158. package/src/rsc/helpers.ts +8 -3
  159. package/src/rsc/prefetch-render-error.ts +7 -2
  160. package/src/rsc/progressive-enhancement.ts +3 -2
  161. package/src/rsc/request-stages.ts +59 -0
  162. package/src/rsc/rsc-rendering.ts +49 -33
  163. package/src/rsc/server-action.ts +2 -1
  164. package/src/rsc/shell-capture.ts +4 -5
  165. package/src/rsc/shell-serve.ts +12 -1
  166. package/src/segment-system.tsx +1 -1
  167. package/src/server/context.ts +37 -6
  168. package/src/server/cookie-store.ts +22 -1
  169. package/src/server/document-location-state.ts +23 -2
  170. package/src/server/handle-store.ts +2 -0
  171. package/src/server/request-context.ts +113 -28
  172. package/src/testing/dispatch.ts +3 -0
  173. package/src/testing/e2e/fixture.ts +4 -12
  174. package/src/testing/e2e/server.ts +91 -4
  175. package/src/testing/render-route.tsx +269 -4
  176. package/src/testing/serve-shell-request.ts +6 -3
  177. package/src/types/handler-context.ts +7 -5
  178. package/src/types/loader-types.ts +7 -4
  179. package/src/types/segments.ts +15 -4
  180. package/src/urls/path-helper-types.ts +7 -5
  181. package/src/vite/temp-rsc-server.ts +2 -2
@@ -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.
688
+ */
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).
669
696
  */
670
- _requestKind?: "prefetch" | "fill";
697
+ _requestKind?: RequestKind;
671
698
 
672
699
  /**
673
700
  * @internal Set when a render shows the visitor's URL of a rewritten
@@ -691,6 +718,11 @@ export interface RequestContext<
691
718
  * `Cache-Control: private, no-store` and never stored by the document cache
692
719
  * or as a PPR shell.
693
720
  *
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
+ *
694
726
  * Multiple calls accumulate entries across definitions. For one definition
695
727
  * the last write wins: two entries of the same definition share its slot
696
728
  * in history.state, so a later call (or a later entry in one array)
@@ -715,6 +747,16 @@ export interface RequestContext<
715
747
  */
716
748
  _documentLocationState?: boolean;
717
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
+
718
760
  /**
719
761
  * The matched route name, if the route has an explicit name.
720
762
  * Undefined before route matching or for unnamed routes.
@@ -1021,10 +1063,13 @@ export type PublicRequestContext<
1021
1063
  | "_documentCacheRender"
1022
1064
  | "_payloadVisitorTheme"
1023
1065
  | "_payloadDeferred"
1066
+ | "_payloadSourceScoped"
1067
+ | "_servedFromStorage"
1024
1068
  | "_requestKind"
1025
1069
  | "_payloadVisitorLocation"
1026
1070
  | "_locationState"
1027
1071
  | "_documentLocationState"
1072
+ | "_carriedLocationStateCookie"
1028
1073
  | "_routeName"
1029
1074
  | "_prevRouteKey"
1030
1075
  | "_visitorMoved"
@@ -1377,7 +1422,12 @@ export function createRequestContext<TEnv>(
1377
1422
  // of this request's headers goes through requestHeaders().
1378
1423
  guardRequestHeaders(request);
1379
1424
  const cookieHeader = requestHeaders(request).get("Cookie");
1380
- 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;
1381
1431
  const freshCookie = stateCookieName
1382
1432
  ? freshReadsCookieName(stateCookieName)
1383
1433
  : undefined;
@@ -1457,6 +1507,17 @@ export function createRequestContext<TEnv>(
1457
1507
  rawStubHeaders = stubResponse.headers;
1458
1508
  shadowStubHeaders(stubResponse);
1459
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
+ };
1460
1521
 
1461
1522
  const handleStore = createHandleStore();
1462
1523
  const loaderPromises = new Map<string, Promise<any>>();
@@ -1632,16 +1693,29 @@ export function createRequestContext<TEnv>(
1632
1693
  stubResponse.headers.set(name, value);
1633
1694
  },
1634
1695
 
1635
- // Rotate the rango state cookie for the responding client (the server seat
1636
- // of invalidateClientCache). Writes ONE Set-Cookie per request with the
1637
- // 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
1638
1698
  // serializer), not the URL-encoded form serializeCookieValue would produce.
1639
- // The timestamp is strictly greater than the client's current one (inbound
1640
- // X-Rango-State), so a same-millisecond server rotation still differs from
1641
- // the client value and the divergence observer fires.
1642
- _rotateStateCookie(): void {
1643
- if (rangoStateRotated) return;
1644
- 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;
1645
1719
  if (!stateCookieName) return;
1646
1720
  // The client's current value, for the monotonic guard: prefer the
1647
1721
  // X-Rango-State header (router navigation/prefetch fetches send it), but
@@ -1656,22 +1730,38 @@ export function createRequestContext<TEnv>(
1656
1730
  const prevRaw =
1657
1731
  (requestHeaders(request).get("x-rango-state") || null) ??
1658
1732
  getRawCookieValue(cookieHeader, stateCookieName);
1659
- 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;
1660
1739
  // rawStubHeaders: guard-exempt internal writer — invalidateClientCache()
1661
1740
  // is documented callable from loaders and during shell capture.
1662
- rawStubHeaders.append(
1663
- "Set-Cookie",
1664
- serializeStateCookie(stateCookieName, value, url.protocol === "https:"),
1741
+ if (stateCookieLine !== null) dropSetCookieLine(stateCookieLine);
1742
+ stateCookieLine = serializeStateCookie(
1743
+ stateCookieName,
1744
+ value,
1745
+ url.protocol === "https:",
1665
1746
  );
1747
+ rawStubHeaders.append("Set-Cookie", stateCookieLine);
1666
1748
  invalidateResponseCookieCache();
1667
1749
  },
1668
1750
 
1669
1751
  // Set the keepClientCache() directive header. The action bridge reads it on
1670
1752
  // the response and suppresses its automatic invalidation. `.set` makes this
1671
1753
  // idempotent (one header regardless of call count). rawStubHeaders:
1672
- // 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.
1673
1757
  _setKeepCacheDirective(): void {
1674
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
+ }
1675
1765
  },
1676
1766
 
1677
1767
  // The fresh-reads cookie (browser/cookie-name.ts): one Set-Cookie per
@@ -1704,12 +1794,7 @@ export function createRequestContext<TEnv>(
1704
1794
  `${freshCookie}=1; Max-Age=${age}; Path=/; HttpOnly; SameSite=Lax` +
1705
1795
  (url.protocol === "https:" ? "; Secure" : "");
1706
1796
  if (freshCookieMaxAge > 0) {
1707
- const prior = serialized(freshCookieMaxAge);
1708
- const kept = rawStubHeaders
1709
- .getSetCookie()
1710
- .filter((cookie) => cookie !== prior);
1711
- rawStubHeaders.delete("Set-Cookie");
1712
- for (const cookie of kept) rawStubHeaders.append("Set-Cookie", cookie);
1797
+ dropSetCookieLine(serialized(freshCookieMaxAge));
1713
1798
  }
1714
1799
  freshCookieMaxAge = maxAge;
1715
1800
  rawStubHeaders.append("Set-Cookie", serialized(maxAge));
@@ -179,6 +179,8 @@ interface DispatchableRouter<TEnv> {
179
179
  basename?: string;
180
180
  /** The rango state cookie name (RangoInternal field). */
181
181
  resolvedStateCookieName: string;
182
+ /** The in-memory prefetch TTL in ms (RangoInternal field). */
183
+ prefetchCacheTTL: number;
182
184
  cache?:
183
185
  | {
184
186
  enabled?: boolean;
@@ -295,6 +297,7 @@ export async function dispatch<TEnv = any>(
295
297
  timeouts: router.timeouts,
296
298
  onTimeout: router.onTimeout,
297
299
  resolvedStateCookieName: router.resolvedStateCookieName,
300
+ prefetchCacheTTL: router.prefetchCacheTTL,
298
301
  },
299
302
  callOnError: stages.createErrorReporter<TEnv>(router),
300
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;