@rangojs/router 0.21.0 → 0.22.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 (243) hide show
  1. package/dist/bin/rango.js +13 -1
  2. package/dist/types/browser/event-controller.d.ts +30 -13
  3. package/dist/types/browser/navigation-store.d.ts +9 -0
  4. package/dist/types/browser/partial-update.d.ts +2 -1
  5. package/dist/types/browser/prefetch/default-strategy.d.ts +16 -20
  6. package/dist/types/browser/react/NavigationProvider.d.ts +14 -1
  7. package/dist/types/browser/react/context.d.ts +14 -0
  8. package/dist/types/browser/rsc-router.d.ts +13 -2
  9. package/dist/types/browser/types.d.ts +10 -0
  10. package/dist/types/build/generate-manifest.d.ts +2 -0
  11. package/dist/types/cache/background-task.d.ts +22 -0
  12. package/dist/types/cache/cache-key-utils.d.ts +50 -5
  13. package/dist/types/cache/cache-policy.d.ts +17 -0
  14. package/dist/types/cache/cache-scope.d.ts +1 -1
  15. package/dist/types/cache/cf/cf-cache-store.d.ts +7 -1
  16. package/dist/types/cache/document-cache.d.ts +8 -5
  17. package/dist/types/cache/handle-snapshot.d.ts +23 -0
  18. package/dist/types/cache/index.d.ts +1 -1
  19. package/dist/types/cache/invalidation-order.d.ts +14 -0
  20. package/dist/types/cache/memory-segment-store.d.ts +33 -1
  21. package/dist/types/cache/shell-snapshot.d.ts +10 -1
  22. package/dist/types/cache/store-scope.d.ts +22 -0
  23. package/dist/types/cache/tag-invalidation.d.ts +7 -6
  24. package/dist/types/cache/types.d.ts +63 -14
  25. package/dist/types/cache/vercel/vercel-cache-store.d.ts +7 -1
  26. package/dist/types/errors.d.ts +6 -0
  27. package/dist/types/handles/deferred-resolution.d.ts +7 -0
  28. package/dist/types/internal-suspense-audit.d.ts +1 -0
  29. package/dist/types/loader-store.d.ts +4 -8
  30. package/dist/types/outlet-provider.d.ts +2 -1
  31. package/dist/types/prerender/cloudflare.d.ts +49 -0
  32. package/dist/types/prerender/create-prerender-trigger.d.ts +121 -0
  33. package/dist/types/prerender/index.d.ts +12 -0
  34. package/dist/types/prerender/memory-prerender-store.d.ts +29 -0
  35. package/dist/types/prerender/on-demand.d.ts +272 -0
  36. package/dist/types/prerender/producer-guard.d.ts +33 -0
  37. package/dist/types/prerender/shell-manifest-key.d.ts +1 -18
  38. package/dist/types/prerender/warm-request.d.ts +65 -0
  39. package/dist/types/prerender/warm.d.ts +65 -0
  40. package/dist/types/prerender/writable-store.d.ts +170 -0
  41. package/dist/types/prerender.d.ts +27 -0
  42. package/dist/types/reverse.d.ts +1 -1
  43. package/dist/types/route-content-wrapper.d.ts +3 -1
  44. package/dist/types/router/error-handling.d.ts +2 -0
  45. package/dist/types/router/handler-context.d.ts +1 -1
  46. package/dist/types/router/match-middleware/cache-lookup.d.ts +14 -0
  47. package/dist/types/router/pattern-matching.d.ts +2 -0
  48. package/dist/types/router/prefetch-default.d.ts +4 -4
  49. package/dist/types/router/prerender-match.d.ts +23 -1
  50. package/dist/types/router/route-trie-builder.d.ts +8 -1
  51. package/dist/types/router/router-context.d.ts +1 -0
  52. package/dist/types/router/router-interfaces.d.ts +30 -0
  53. package/dist/types/router/router-options.d.ts +24 -0
  54. package/dist/types/router/segment-resolution/fresh.d.ts +7 -0
  55. package/dist/types/router/segment-resolution/loader-cache.d.ts +2 -1
  56. package/dist/types/router/segment-resolution/revalidation.d.ts +5 -1
  57. package/dist/types/router/segment-resolution/streamed-handler-boundary.d.ts +43 -0
  58. package/dist/types/router/trie-matching.d.ts +2 -0
  59. package/dist/types/rsc/shell-build-manifest.d.ts +6 -4
  60. package/dist/types/rsc/shell-capture-constants.d.ts +20 -5
  61. package/dist/types/rsc/shell-capture.d.ts +14 -1
  62. package/dist/types/rsc/shell-serve.d.ts +2 -2
  63. package/dist/types/rsc/ssr-module-loader.d.ts +15 -0
  64. package/dist/types/rsc/types.d.ts +8 -0
  65. package/dist/types/segment-boundary-content.d.ts +17 -0
  66. package/dist/types/segment-loader-promise.d.ts +2 -6
  67. package/dist/types/server/build-version-table.d.ts +9 -0
  68. package/dist/types/server/context.d.ts +12 -4
  69. package/dist/types/server/handle-store.d.ts +2 -0
  70. package/dist/types/server/request-context.d.ts +63 -2
  71. package/dist/types/suspense-audit-react.d.ts +26 -0
  72. package/dist/types/suspense-audit.d.ts +157 -0
  73. package/dist/types/testing/action-form.d.ts +34 -0
  74. package/dist/types/testing/flight.entry.d.ts +2 -0
  75. package/dist/types/testing/index.d.ts +4 -0
  76. package/dist/types/testing/internal/server-modules.d.ts +8 -0
  77. package/dist/types/testing/serve-shell-request.d.ts +34 -4
  78. package/dist/types/testing/shell-status.d.ts +14 -6
  79. package/dist/types/testing/vitest-stubs/plugin-rsc.d.ts +9 -6
  80. package/dist/types/thenable-status.d.ts +6 -0
  81. package/dist/types/types/handler-context.d.ts +7 -0
  82. package/dist/types/types/route-entry.d.ts +5 -0
  83. package/dist/types/urls/path-helper.d.ts +18 -0
  84. package/dist/types/vite/discovery/state.d.ts +11 -0
  85. package/dist/types/vite/inject-client-debug.d.ts +6 -25
  86. package/dist/types/vite/utils/bundle-analysis.d.ts +6 -2
  87. package/dist/vite/index.js +179 -496
  88. package/package.json +12 -2
  89. package/skills/cache-guide/SKILL.md +18 -17
  90. package/skills/caching/SKILL.md +40 -4
  91. package/skills/catalog.json +1 -1
  92. package/skills/cloudflare/SKILL.md +131 -0
  93. package/skills/comparison/references/framework-comparison.md +1 -1
  94. package/skills/deployment-caching/SKILL.md +33 -12
  95. package/skills/document-cache/SKILL.md +16 -14
  96. package/skills/hooks/SKILL.md +22 -0
  97. package/skills/hooks/handle-and-actions.md +3 -4
  98. package/skills/hooks/navigation.md +10 -0
  99. package/skills/hooks/state.md +5 -4
  100. package/skills/host-router/SKILL.md +18 -0
  101. package/skills/loader/SKILL.md +6 -4
  102. package/skills/middleware/SKILL.md +1 -1
  103. package/skills/migrate-nextjs/SKILL.md +82 -9
  104. package/skills/ppr/SKILL.md +31 -8
  105. package/skills/prerender/SKILL.md +645 -8
  106. package/skills/rango/SKILL.md +1 -0
  107. package/skills/response-routes/SKILL.md +5 -5
  108. package/skills/route/SKILL.md +29 -0
  109. package/skills/testing/SKILL.md +2 -1
  110. package/skills/testing/cache-prerender.md +18 -17
  111. package/skills/testing/serve-shell-request.md +104 -8
  112. package/skills/use-cache/SKILL.md +9 -5
  113. package/skills/vercel/SKILL.md +2 -0
  114. package/skills/view-transitions/SKILL.md +9 -4
  115. package/src/browser/event-controller.ts +69 -25
  116. package/src/browser/link-interceptor.ts +2 -2
  117. package/src/browser/navigation-bridge.ts +75 -35
  118. package/src/browser/navigation-store.ts +26 -0
  119. package/src/browser/navigation-transaction.ts +9 -2
  120. package/src/browser/network-error-handler.ts +2 -0
  121. package/src/browser/partial-update.ts +48 -12
  122. package/src/browser/prefetch/default-strategy.ts +22 -42
  123. package/src/browser/react/Link.tsx +37 -42
  124. package/src/browser/react/NavigationProvider.tsx +63 -3
  125. package/src/browser/react/context.ts +17 -0
  126. package/src/browser/react/location-state.ts +5 -19
  127. package/src/browser/react/use-action.ts +4 -1
  128. package/src/browser/react/use-handle.ts +4 -20
  129. package/src/browser/react/use-link-status.ts +4 -3
  130. package/src/browser/react/use-navigation.ts +4 -1
  131. package/src/browser/react/use-params.ts +4 -1
  132. package/src/browser/react/use-pathname.ts +4 -1
  133. package/src/browser/react/use-search-params.ts +5 -3
  134. package/src/browser/react/use-segments.ts +36 -54
  135. package/src/browser/rsc-router.tsx +79 -39
  136. package/src/browser/segment-reconciler.ts +3 -3
  137. package/src/browser/server-action-bridge.ts +26 -6
  138. package/src/browser/types.ts +11 -0
  139. package/src/build/generate-manifest.ts +17 -0
  140. package/src/build/merge-full-manifests.ts +5 -0
  141. package/src/build/route-trie.ts +1 -0
  142. package/src/cache/background-task.ts +61 -0
  143. package/src/cache/cache-key-utils.ts +91 -12
  144. package/src/cache/cache-policy.ts +11 -2
  145. package/src/cache/cache-runtime.ts +23 -23
  146. package/src/cache/cache-scope.ts +38 -36
  147. package/src/cache/cf/cf-cache-store.ts +16 -11
  148. package/src/cache/document-cache.ts +35 -14
  149. package/src/cache/handle-snapshot.ts +50 -0
  150. package/src/cache/index.ts +1 -0
  151. package/src/cache/invalidation-order.ts +53 -0
  152. package/src/cache/memory-segment-store.ts +52 -1
  153. package/src/cache/shell-snapshot.ts +17 -0
  154. package/src/cache/store-scope.ts +36 -0
  155. package/src/cache/tag-invalidation.ts +7 -5
  156. package/src/cache/types.ts +69 -14
  157. package/src/cache/vercel/vercel-cache-store.ts +20 -6
  158. package/src/client.tsx +40 -3
  159. package/src/errors.ts +13 -6
  160. package/src/handles/deferred-resolution.ts +7 -2
  161. package/src/internal-suspense-audit.ts +12 -0
  162. package/src/loader-store.ts +4 -9
  163. package/src/outlet-provider.tsx +25 -1
  164. package/src/prerender/cloudflare.ts +130 -0
  165. package/src/prerender/create-prerender-trigger.ts +780 -0
  166. package/src/prerender/index.ts +39 -0
  167. package/src/prerender/memory-prerender-store.ts +115 -0
  168. package/src/prerender/on-demand.ts +339 -0
  169. package/src/prerender/producer-guard.ts +66 -0
  170. package/src/prerender/shell-manifest-key.ts +12 -17
  171. package/src/prerender/warm-request.ts +154 -0
  172. package/src/prerender/warm.ts +193 -0
  173. package/src/prerender/writable-store.ts +278 -0
  174. package/src/prerender.ts +29 -0
  175. package/src/reverse.ts +1 -1
  176. package/src/route-content-wrapper.tsx +107 -13
  177. package/src/router/error-handling.ts +12 -12
  178. package/src/router/find-match.ts +1 -0
  179. package/src/router/handler-context.ts +2 -0
  180. package/src/router/intercept-resolution.ts +15 -8
  181. package/src/router/loader-resolution.ts +2 -10
  182. package/src/router/match-middleware/background-revalidation.ts +2 -0
  183. package/src/router/match-middleware/cache-lookup.ts +230 -38
  184. package/src/router/pattern-matching.ts +14 -0
  185. package/src/router/prefetch-default.ts +4 -4
  186. package/src/router/prerender-match.ts +460 -352
  187. package/src/router/route-trie-builder.ts +9 -0
  188. package/src/router/router-context.ts +1 -0
  189. package/src/router/router-interfaces.ts +33 -0
  190. package/src/router/router-options.ts +30 -0
  191. package/src/router/segment-resolution/fresh.ts +20 -19
  192. package/src/router/segment-resolution/loader-cache.ts +15 -17
  193. package/src/router/segment-resolution/revalidation.ts +34 -24
  194. package/src/router/segment-resolution/streamed-handler-boundary.ts +133 -0
  195. package/src/router/trie-matching.ts +3 -0
  196. package/src/router.ts +107 -1
  197. package/src/rsc/handler.ts +51 -4
  198. package/src/rsc/progressive-enhancement.ts +5 -1
  199. package/src/rsc/response-cache-serve.ts +10 -4
  200. package/src/rsc/rsc-rendering.ts +53 -11
  201. package/src/rsc/shell-build-manifest.ts +10 -8
  202. package/src/rsc/shell-capture-constants.ts +39 -7
  203. package/src/rsc/shell-capture.ts +108 -19
  204. package/src/rsc/shell-serve.ts +12 -6
  205. package/src/rsc/ssr-module-loader.ts +26 -0
  206. package/src/rsc/types.ts +8 -0
  207. package/src/segment-boundary-content.ts +25 -0
  208. package/src/segment-loader-promise.ts +28 -38
  209. package/src/segment-system.tsx +117 -57
  210. package/src/server/build-version-table.ts +15 -0
  211. package/src/server/context.ts +40 -17
  212. package/src/server/cookie-store.ts +5 -0
  213. package/src/server/handle-store.ts +13 -0
  214. package/src/server/request-context.ts +87 -1
  215. package/src/ssr/ssr-root.tsx +20 -13
  216. package/src/suspense-audit-react.tsx +98 -0
  217. package/src/suspense-audit.ts +1030 -0
  218. package/src/testing/action-form.ts +66 -0
  219. package/src/testing/flight-runtime.d.ts +27 -0
  220. package/src/testing/flight.entry.ts +3 -0
  221. package/src/testing/index.ts +19 -0
  222. package/src/testing/internal/server-modules.ts +32 -0
  223. package/src/testing/render-route.tsx +25 -12
  224. package/src/testing/serve-shell-request.ts +190 -34
  225. package/src/testing/shell-status.ts +17 -6
  226. package/src/testing/vitest-stubs/plugin-rsc.ts +37 -11
  227. package/src/thenable-status.ts +10 -0
  228. package/src/types/handler-context.ts +7 -0
  229. package/src/types/route-entry.ts +6 -0
  230. package/src/urls/path-helper.ts +69 -9
  231. package/src/use-loader.tsx +57 -30
  232. package/src/vite/discovery/bundle-postprocess.ts +4 -0
  233. package/src/vite/discovery/discover-routers.ts +15 -0
  234. package/src/vite/discovery/shell-prerender-phase.ts +5 -5
  235. package/src/vite/discovery/state.ts +17 -1
  236. package/src/vite/inject-client-debug.ts +42 -20
  237. package/src/vite/plugins/virtual-entries.ts +8 -1
  238. package/src/vite/router-discovery.ts +14 -0
  239. package/src/vite/utils/bundle-analysis.ts +39 -7
  240. package/dist/types/browser/react/subscribe-to-nothing.d.ts +0 -7
  241. package/dist/types/segment-content-promise.d.ts +0 -13
  242. package/src/browser/react/subscribe-to-nothing.ts +0 -7
  243. package/src/segment-content-promise.ts +0 -67
@@ -0,0 +1,154 @@
1
+ /**
2
+ * The mark and record of a `router.prerender()` warm request
3
+ * (docs/design/prerender-every-route.md, "The synthetic request"). The
4
+ * trigger marks the `Request` it dispatches, the handler puts the record on
5
+ * the request context (`RequestContext._prerenderWarm`), and each cache layer
6
+ * reads the context where it reads its store. No runtime imports: the cache
7
+ * layers import this without pulling the trigger.
8
+ */
9
+
10
+ import type { HandlerCacheConfig } from "../rsc/types.js";
11
+ import type { ShellCaptureDebugEvent } from "../rsc/shell-capture.js";
12
+ import type { PrerenderWarmCaches } from "./on-demand.js";
13
+
14
+ /** What one warm request did: written by the layers, read by the trigger. */
15
+ export interface PrerenderWarmRecord extends PrerenderWarmCaches {
16
+ /**
17
+ * `replace`: every runtime cache read misses and the writes replace the
18
+ * entries. `fill` (`onlyIfStale`): reads are normal; the record only reports.
19
+ */
20
+ mode: "replace" | "fill";
21
+ /**
22
+ * The `createRouter({ cache })` config the trigger gated on its scope. The
23
+ * handler uses it instead of resolving the option again, so the store that
24
+ * was checked is the store that is written.
25
+ */
26
+ cacheConfig: HandlerCacheConfig;
27
+ /** The surface an identity guard refused (server/context.ts guardIdentityRead). */
28
+ identity?: string;
29
+ /**
30
+ * The request's render errors: the handler adopts this list as
31
+ * `RequestContext._renderErrors`, so what the layers push lands here.
32
+ */
33
+ renderErrors: unknown[];
34
+ }
35
+
36
+ /** What the helpers read; a derived capture context inherits the record. */
37
+ export interface WarmContext {
38
+ _prerenderWarm?: PrerenderWarmRecord;
39
+ _shellCaptureRun?: boolean;
40
+ }
41
+
42
+ /**
43
+ * In process on purpose: "miss every cache and overwrite it" reachable from a
44
+ * URL parameter, header or cookie would be a cache-busting amplifier. A client
45
+ * controls the bytes of a request, never this map's keys.
46
+ */
47
+ const warmRequests = new WeakMap<Request, PrerenderWarmRecord>();
48
+
49
+ export function createWarmRecord(
50
+ mode: PrerenderWarmRecord["mode"],
51
+ cacheConfig: HandlerCacheConfig,
52
+ ): PrerenderWarmRecord {
53
+ return {
54
+ mode,
55
+ cacheConfig,
56
+ renderErrors: [],
57
+ writes: { record: 0, item: 0, response: 0, shell: 0 },
58
+ };
59
+ }
60
+
61
+ /** The mark is the object: a clone or a rebuilt request carries none. */
62
+ export function markWarmRequest(
63
+ request: Request,
64
+ record: PrerenderWarmRecord,
65
+ ): void {
66
+ warmRequests.set(request, record);
67
+ }
68
+
69
+ export function readWarmMark(
70
+ request: Request,
71
+ ): PrerenderWarmRecord | undefined {
72
+ return warmRequests.get(request);
73
+ }
74
+
75
+ /**
76
+ * True when this request must read every runtime cache as a miss. A flag read
77
+ * at each read point, not a store wrapper: a route's or loader's own
78
+ * `cache({ store })` bypasses the app store (CacheScope.getStore), and the
79
+ * "use cache" in-flight dedup lives outside the store, so a wrapper would
80
+ * miss both.
81
+ *
82
+ * False in the warm's own shell capture (`_shellCaptureRun`): it runs after
83
+ * the warm's writes settled and must replay them, or it would render every
84
+ * handler a second time and could bake another generation than the one just
85
+ * written.
86
+ */
87
+ export function isWarmReplace(ctx: WarmContext | null | undefined): boolean {
88
+ return (
89
+ ctx?._prerenderWarm?.mode === "replace" && ctx._shellCaptureRun !== true
90
+ );
91
+ }
92
+
93
+ /** Count a store write that landed. No-op outside a warm. */
94
+ export function noteWarmWrite(
95
+ ctx: WarmContext | null | undefined,
96
+ family: keyof PrerenderWarmCaches["writes"],
97
+ ): void {
98
+ const record = ctx?._prerenderWarm;
99
+ if (record) record.writes[family] += 1;
100
+ }
101
+
102
+ /** Record the document cache's outcome; its store write counts through noteWarmWrite. */
103
+ export function noteWarmDocument(
104
+ ctx: WarmContext | null | undefined,
105
+ outcome: NonNullable<PrerenderWarmCaches["document"]>,
106
+ ): void {
107
+ const record = ctx?._prerenderWarm;
108
+ if (!record) return;
109
+ record.document = outcome;
110
+ }
111
+
112
+ /** Record the surface an identity guard (guardIdentityRead) is about to refuse. */
113
+ export function noteWarmIdentityRead(
114
+ ctx: WarmContext | null | undefined,
115
+ surface: string,
116
+ ): void {
117
+ const record = ctx?._prerenderWarm;
118
+ if (record) record.identity ??= surface;
119
+ }
120
+
121
+ const SHELL_EVENT_OUTCOMES: Partial<
122
+ Record<
123
+ ShellCaptureDebugEvent["outcome"],
124
+ NonNullable<PrerenderWarmCaches["shell"]>
125
+ >
126
+ > = {
127
+ stored: "stored",
128
+ refused: "refused",
129
+ "no-shell": "no-shell",
130
+ expired: "no-shell",
131
+ redirect: "no-shell",
132
+ error: "error",
133
+ "skip-capacity": "skipped-capacity",
134
+ "skip-queue-timeout": "skipped-queue-timeout",
135
+ "skip-inert-store": "not-eligible",
136
+ };
137
+
138
+ /**
139
+ * Fold one capture event into the record: the last attempt's outcome stands.
140
+ * The skips a forced capture never takes and the `backoff` notice leave it.
141
+ */
142
+ export function noteWarmShellEvent(
143
+ record: PrerenderWarmRecord,
144
+ event: ShellCaptureDebugEvent,
145
+ ): void {
146
+ let shell = SHELL_EVENT_OUTCOMES[event.outcome];
147
+ if (shell === undefined) return;
148
+ // A putShell that threw still ends the attempt `stored` (the capture
149
+ // worked; shell-capture.ts reports the I/O error), but counted no write.
150
+ if (shell === "stored" && record.writes.shell === 0) shell = "error";
151
+ record.shell = shell;
152
+ if (shell === "stored") delete record.refusal;
153
+ else if (event.refusal !== undefined) record.refusal = event.refusal;
154
+ }
@@ -0,0 +1,193 @@
1
+ /**
2
+ * One `router.prerender()` warm request: build the synthetic visitor request,
3
+ * dispatch it through the router's own handler, wait for everything it
4
+ * scheduled, and read back what it wrote
5
+ * (docs/design/prerender-every-route.md, "The synthetic request"). The handler
6
+ * is an injected `fetch`, so unit tests drive this with a fake.
7
+ */
8
+
9
+ import {
10
+ awaitLaterMillisecond,
11
+ raceDeadline,
12
+ settleGrowing,
13
+ type CollectingExecutionContext,
14
+ } from "../cache/background-task.js";
15
+ import { CAPTURE_QUEUE_WAIT_BUDGET_MS } from "../rsc/capture-queue.js";
16
+ import { SHELL_CAPTURE_TASK_HARD_CAP_MS } from "../rsc/shell-capture-constants.js";
17
+ import type { HandlerCacheConfig } from "../rsc/types.js";
18
+ import type { ExecutionContext } from "../types/request-scope.js";
19
+ import type { PrerenderResult, PrerenderWarmCaches } from "./on-demand.js";
20
+ import {
21
+ createWarmRecord,
22
+ markWarmRequest,
23
+ type PrerenderWarmRecord,
24
+ } from "./warm-request.js";
25
+
26
+ /**
27
+ * How long a warm waits for its response body and background work: a capture
28
+ * that waited out the queue budget, then ran to its hard cap.
29
+ */
30
+ export const WARM_SETTLE_BUDGET_MS: number =
31
+ SHELL_CAPTURE_TASK_HARD_CAP_MS + CAPTURE_QUEUE_WAIT_BUDGET_MS;
32
+
33
+ export interface WarmRequestInput<TEnv> {
34
+ /** The absolute URL a visitor requests. */
35
+ url: URL;
36
+ mode: PrerenderWarmRecord["mode"];
37
+ /** The gated `createRouter({ cache })` config; the handler writes to its store. */
38
+ cacheConfig: HandlerCacheConfig;
39
+ env: TEnv;
40
+ /** The context `cacheConfig` was resolved with. */
41
+ ctx: CollectingExecutionContext;
42
+ /** `router.fetch`. */
43
+ fetch: (
44
+ request: Request,
45
+ input: { env: TEnv; ctx: ExecutionContext },
46
+ ) => Promise<Response>;
47
+ /** Tests shorten the wait. */
48
+ settleBudgetMs?: number;
49
+ }
50
+
51
+ export interface WarmRequestOutcome {
52
+ record: PrerenderWarmRecord;
53
+ /** Absent when the handler threw before answering. */
54
+ responseStatus?: number;
55
+ /** The handler's throw, a body that failed mid-stream, or a wait that ran out. */
56
+ error?: unknown;
57
+ }
58
+
59
+ /**
60
+ * Dispatch one warm: a GET with `accept: text/html` and nothing else, so it
61
+ * lands in the partition a header-less visitor lands in. The response is
62
+ * drained and dropped: what it rendered reaches a visitor only through a
63
+ * store.
64
+ */
65
+ export async function runWarmRequest<TEnv>(
66
+ input: WarmRequestInput<TEnv>,
67
+ ): Promise<WarmRequestOutcome> {
68
+ // `await updateTag(tag)` then a warm is the content-refresh pattern, and a
69
+ // store refuses a shell whose capture started in the millisecond of an
70
+ // invalidation of one of its tags (putShell's gate compares whole
71
+ // milliseconds). Start in a later one, within the helper's bound.
72
+ await awaitLaterMillisecond(Date.now());
73
+ const record = createWarmRecord(input.mode, input.cacheConfig);
74
+ const request = new Request(input.url, { headers: { accept: "text/html" } });
75
+ markWarmRequest(request, record);
76
+ const deadline = Date.now() + (input.settleBudgetMs ?? WARM_SETTLE_BUDGET_MS);
77
+
78
+ let responseStatus: number | undefined;
79
+ let error: unknown;
80
+ try {
81
+ const response = await input.fetch(request, {
82
+ env: input.env,
83
+ ctx: input.ctx,
84
+ });
85
+ responseStatus = response.status;
86
+ // The render, and the tags and writes it records, finish with the body.
87
+ const drained = await raceDeadline(drainBody(response), deadline);
88
+ if (!drained.done) {
89
+ error = new Error(
90
+ `router.prerender(): the response for ${input.url.href} did not finish within the warm's wait`,
91
+ );
92
+ }
93
+ } catch (err) {
94
+ error = err ?? new Error("router.prerender(): the warm request failed");
95
+ }
96
+ await settleGrowing(input.ctx.tasks, deadline);
97
+ return { record, responseStatus, error };
98
+ }
99
+
100
+ /**
101
+ * Read the body to its end without keeping it. A non-200 is a failed warm
102
+ * whatever its body says, so it is cancelled instead.
103
+ */
104
+ async function drainBody(response: Response): Promise<void> {
105
+ const { body } = response;
106
+ if (!body) return;
107
+ if (response.status !== 200) {
108
+ await body.cancel().catch(() => {});
109
+ return;
110
+ }
111
+ const reader = body.getReader();
112
+ while (!(await reader.read()).done);
113
+ }
114
+
115
+ /** The per-cache detail a result carries. */
116
+ export function warmCaches(record: PrerenderWarmRecord): PrerenderWarmCaches {
117
+ return {
118
+ writes: { ...record.writes },
119
+ ...(record.shell !== undefined ? { shell: record.shell } : {}),
120
+ ...(record.refusal !== undefined ? { refusal: record.refusal } : {}),
121
+ ...(record.document !== undefined ? { document: record.document } : {}),
122
+ };
123
+ }
124
+
125
+ /**
126
+ * A warm's status from what it recorded, first match wins. An identity
127
+ * refusal comes ahead of the render failure it usually also causes (the guard
128
+ * throws). `record.shell` is set only for a `ppr` route (shellServePlan).
129
+ */
130
+ export function warmStatus(outcome: WarmRequestOutcome): WarmStatus {
131
+ const { record, responseStatus, error } = outcome;
132
+ if (record.identity !== undefined) return "skipped-personalized";
133
+ if (
134
+ error !== undefined ||
135
+ responseStatus !== 200 ||
136
+ record.renderErrors.length > 0
137
+ ) {
138
+ return "render-failed";
139
+ }
140
+ if (
141
+ record.shell !== undefined &&
142
+ record.shell !== "stored" &&
143
+ record.shell !== "fresh"
144
+ ) {
145
+ return "shell-not-stored";
146
+ }
147
+ const { writes } = record;
148
+ if (writes.record + writes.item + writes.response + writes.shell === 0) {
149
+ return record.mode === "fill" ? "already-fresh" : "skipped-uncached";
150
+ }
151
+ return "warmed";
152
+ }
153
+
154
+ type WarmStatus =
155
+ | Extract<PrerenderResult, { ok: true; path: "warm" }>["status"]
156
+ | Extract<PrerenderResult, { ok: false }>["status"];
157
+
158
+ /** The `PrerenderResult` of a finished warm. */
159
+ export function composeWarmResult(
160
+ outcome: WarmRequestOutcome,
161
+ target: string,
162
+ routeName: string,
163
+ ): PrerenderResult {
164
+ const status = warmStatus(outcome);
165
+ const caches = warmCaches(outcome.record);
166
+ if (status === "warmed" || status === "already-fresh") {
167
+ // Both are answered only for a 200 (warmStatus).
168
+ return {
169
+ ok: true,
170
+ path: "warm",
171
+ status,
172
+ target,
173
+ routeName,
174
+ responseStatus: outcome.responseStatus!,
175
+ caches,
176
+ };
177
+ }
178
+ const error =
179
+ outcome.error ??
180
+ (status === "render-failed" ? outcome.record.renderErrors[0] : undefined);
181
+ return {
182
+ ok: false,
183
+ path: "warm",
184
+ status,
185
+ target,
186
+ routeName,
187
+ ...(outcome.responseStatus !== undefined
188
+ ? { responseStatus: outcome.responseStatus }
189
+ : {}),
190
+ caches,
191
+ ...(error !== undefined ? { error } : {}),
192
+ };
193
+ }
@@ -0,0 +1,278 @@
1
+ /**
2
+ * Writable prerender store contract for on-demand (ISR-style) prerender refresh.
3
+ *
4
+ * The bundled build manifest (store.ts) is immutable and read-only. On-demand
5
+ * prerender adds a writable durable overlay in front of it: `router.prerender()`
6
+ * renders a route and writes a versioned envelope here; the serve path reads the
7
+ * overlay before the bundled manifest.
8
+ *
9
+ * This module is types + pure helpers only — no React/RSC/plugin-rsc imports —
10
+ * so it can be imported by the testing barrel and platform adapters alike.
11
+ */
12
+
13
+ import type { PrerenderEntry } from "./store.js";
14
+ import { paramsEqual } from "../router/params-util.js";
15
+
16
+ /**
17
+ * Structured durable-overlay key. Includes the router's cache version so a
18
+ * deploy that changed the router's server code never reads Flight payloads
19
+ * that reference its previous build, and so Cloudflare gradual deployments
20
+ * stay correct (old and new worker versions each read their own namespace).
21
+ */
22
+ export interface PrerenderKey {
23
+ routerId: string;
24
+ /**
25
+ * The owning router's cache version (its data version, or
26
+ * `createRouter({ version })`). Opaque: never set or read by app code; a
27
+ * store keys off it as given.
28
+ */
29
+ version: string;
30
+ routeName: string;
31
+ /** DJB2 8-hex hash of the canonical params (see param-hash.ts). */
32
+ paramHash: string;
33
+ }
34
+
35
+ /**
36
+ * @internal What the trigger stamps on a write. TTL is soft staleness metadata
37
+ * (`staleAt`), never a hard store expiry: hard expiry would delete the entry
38
+ * a stale-while-revalidate serve needs, and an expired overlay would fall back
39
+ * to older bundled-manifest content.
40
+ */
41
+ export interface StoredEntryOptions {
42
+ /** Seconds until the entry is considered stale. Absent = never stale. */
43
+ ttl?: number;
44
+ tags: string[];
45
+ /** Canonical params, stored for verify-on-read. */
46
+ params: Record<string, string>;
47
+ }
48
+
49
+ /** What a page and a "removed" marker both carry: a store reads either alike. */
50
+ interface StoredEnvelope {
51
+ v: 1;
52
+ meta: {
53
+ /**
54
+ * When the render started, not when the entry was written: a `markStale()`
55
+ * at or after it (strictly after in an in-process store) covers the entry.
56
+ * A `remove()` marker renders nothing and carries its write time.
57
+ */
58
+ storedAt: number;
59
+ /**
60
+ * Absent = never stale. Soft metadata only; controls `onRevalidate`
61
+ * scheduling, not serving. Counts the ttl from the write. A store may
62
+ * lower it (tag markers do).
63
+ */
64
+ staleAt?: number;
65
+ /** The configured ttl in seconds, as a result reports it. Absent = none. */
66
+ ttl?: number;
67
+ tags: string[];
68
+ /** The key's `version` at write time; verified on read. */
69
+ version: string;
70
+ /** Verified against the request's params on read (DJB2 collision guard). */
71
+ params: Record<string, string>;
72
+ };
73
+ }
74
+
75
+ type StoredPage = StoredEnvelope & { entry: PrerenderEntry; removed?: never };
76
+
77
+ /**
78
+ * The "removed" marker (tombstone) stored in place of a page, by
79
+ * `prerender.remove()` or by a refresh whose render hit `notFound()` (or
80
+ * declined with `ctx.passthrough()`). A request that finds it is not served
81
+ * the build-time entry below the overlay. No `entry`: a router from before the
82
+ * marker rejects it as malformed (a miss) instead of serving an empty page.
83
+ * `meta` is stamped like a page's for a render outcome, so that marker goes
84
+ * stale and is rechecked; a `remove()` marker has no `staleAt` and no tags.
85
+ */
86
+ type StoredTombstone = StoredEnvelope & { removed: true; entry?: never };
87
+
88
+ /**
89
+ * Versioned envelope the router composes on a refresh or a removal and
90
+ * verifies on every read: a page, or the marker that the page was removed. A
91
+ * store persists either as given and returns it as stored.
92
+ */
93
+ export type PrerenderStoredEntry = StoredPage | StoredTombstone;
94
+
95
+ /**
96
+ * The writable durable overlay: plain get/set. Backed in production by a
97
+ * platform adapter (Cloudflare KV, …) and in dev/tests by an in-memory store.
98
+ * The router composes the envelope before `set()` and verifies whatever `get()`
99
+ * returns (shape, `version`, params collision guard), so a store holds no
100
+ * policy of its own.
101
+ *
102
+ * Key everything off the `key` argument (`serializePrerenderKey(key)` carries
103
+ * `key.version`). Never call `getCacheVersions()` here: the trigger's `set()`
104
+ * runs outside the producer's request context, where it returns the
105
+ * whole-build fallback instead of the owning router's version.
106
+ */
107
+ export interface WritablePrerenderStore {
108
+ /**
109
+ * The envelope stored under `key`, or null. Never memoize a miss: a refresh
110
+ * can write the key after a request missed it. May lower `meta.staleAt` (a
111
+ * tag marked stale after the entry was written).
112
+ */
113
+ get(key: PrerenderKey): Promise<PrerenderStoredEntry | null>;
114
+
115
+ /**
116
+ * Persist `stored` (a page, or the marker that the page was removed) under
117
+ * `key`, replacing any previous entry. No expiry.
118
+ */
119
+ set(key: PrerenderKey, stored: PrerenderStoredEntry): Promise<void>;
120
+
121
+ delete?(key: PrerenderKey): Promise<void>;
122
+
123
+ /**
124
+ * Mark the entries of router `routerId` (`PrerenderKey.routerId`) carrying
125
+ * any of `tags` stale (they keep serving). A mark covers an entry whose
126
+ * `meta.storedAt` it is at or after (strictly after for an in-process
127
+ * store), including one `set()` after the mark: a store that marks held
128
+ * entries in place has to remember its marks. Scope it to that router: routers
129
+ * behind a host router share one store, and a tag is only meaningful inside
130
+ * the router whose routes declared it. Optional: without it,
131
+ * `router.prerender().markStale()` is a no-op for this store.
132
+ */
133
+ markStale?(routerId: string, tags: string[]): Promise<void>;
134
+ }
135
+
136
+ /**
137
+ * Serialize a {@link PrerenderKey} to the design's string form:
138
+ * `prerender:{routerId}:{version}:{routeName}:{paramHash}`
139
+ */
140
+ export function serializePrerenderKey(key: PrerenderKey): string {
141
+ return `prerender:${key.routerId}:${key.version}:${key.routeName}:${key.paramHash}`;
142
+ }
143
+
144
+ /**
145
+ * @internal Compose the versioned envelope the trigger hands to `store.set()`.
146
+ * `now` is `meta.storedAt`; `staleFrom` (default `now`) is where the ttl
147
+ * starts counting. Both are passed explicitly so tests stay deterministic.
148
+ */
149
+ export function composeStoredEntry(
150
+ key: PrerenderKey,
151
+ entry: PrerenderEntry,
152
+ options: StoredEntryOptions,
153
+ now: number,
154
+ staleFrom: number = now,
155
+ ): StoredPage {
156
+ // Only a finite, non-negative ttl produces a staleAt: NaN would never go
157
+ // stale and a negative ttl would be stale on every request.
158
+ const hasTtl =
159
+ options.ttl != null &&
160
+ Number.isFinite(options.ttl) &&
161
+ (options.ttl as number) >= 0;
162
+ return {
163
+ v: 1,
164
+ entry,
165
+ meta: {
166
+ storedAt: now,
167
+ ...(hasTtl
168
+ ? {
169
+ staleAt: staleFrom + (options.ttl as number) * 1000,
170
+ ttl: options.ttl as number,
171
+ }
172
+ : {}),
173
+ tags: options.tags,
174
+ version: key.version,
175
+ params: options.params,
176
+ },
177
+ };
178
+ }
179
+
180
+ /**
181
+ * @internal Compose the "removed" marker stored in place of a page. Its meta
182
+ * is a page's (composeStoredEntry), so `ttl` and `tags` mean the same: the
183
+ * marker of a render outcome is given the route's and is rechecked like a
184
+ * page; `prerender.remove()` gives neither, and its marker never goes stale.
185
+ */
186
+ export function composeStoredTombstone(
187
+ key: PrerenderKey,
188
+ options: StoredEntryOptions,
189
+ now: number,
190
+ staleFrom: number = now,
191
+ ): StoredTombstone {
192
+ const { v, meta } = composeStoredEntry(
193
+ key,
194
+ { segments: [], handles: "" },
195
+ options,
196
+ now,
197
+ staleFrom,
198
+ );
199
+ return { v, removed: true, meta };
200
+ }
201
+
202
+ /**
203
+ * @internal Verify a store's answer is safe to serve for this key: the
204
+ * envelope shape and version tag, the key version, and the stored params
205
+ * against the request's (the 8-hex DJB2 collision guard, param-hash.ts: a
206
+ * runtime write keyed off webhook-supplied params must not serve one page's
207
+ * content under another's URL, nor remove another's page). False means treat
208
+ * it as a miss. Run by the router on every read, so a third-party store
209
+ * cannot skip it.
210
+ */
211
+ export function isStoredEntryValidFor(
212
+ stored: unknown,
213
+ key: PrerenderKey,
214
+ params: Record<string, string>,
215
+ ): stored is PrerenderStoredEntry {
216
+ // A durable store can hold a parseable-but-malformed value (`"null"`,
217
+ // `{"v":1}`): a miss, never a TypeError on the serve path.
218
+ if (stored == null || typeof stored !== "object") return false;
219
+ const candidate = stored as Partial<PrerenderStoredEntry>;
220
+ const entry = candidate.entry;
221
+ const meta = candidate.meta;
222
+ if (
223
+ candidate.v !== 1 ||
224
+ // The "removed" marker has no entry; a page must have a whole one.
225
+ (candidate.removed !== true &&
226
+ (entry == null ||
227
+ typeof entry !== "object" ||
228
+ !Array.isArray(entry.segments) ||
229
+ typeof entry.handles !== "string")) ||
230
+ meta == null ||
231
+ typeof meta !== "object" ||
232
+ !Array.isArray(meta.tags) ||
233
+ typeof meta.storedAt !== "number"
234
+ ) {
235
+ return false;
236
+ }
237
+ if (meta.version !== key.version) return false;
238
+ return paramsEqual(meta.params ?? {}, params);
239
+ }
240
+
241
+ /** True once `now` has passed the entry's soft `staleAt`. Never-stale entries never go stale. */
242
+ export function isStoredEntryStale(
243
+ stored: PrerenderStoredEntry,
244
+ now: number,
245
+ ): boolean {
246
+ return stored.meta.staleAt != null && now >= stored.meta.staleAt;
247
+ }
248
+
249
+ /**
250
+ * @internal Lower `meta.staleAt` to `at` when it is unset or later. Shared by
251
+ * the stores' tag marking so the rule cannot drift between them.
252
+ */
253
+ export function lowerStoredEntryStaleAt(
254
+ stored: PrerenderStoredEntry,
255
+ at: number,
256
+ ): void {
257
+ if (stored.meta.staleAt == null || stored.meta.staleAt > at) {
258
+ stored.meta.staleAt = at;
259
+ }
260
+ }
261
+
262
+ /**
263
+ * @internal `store.get(key)` verified for serving (isStoredEntryValidFor). A
264
+ * store error (e.g. a KV outage) reads as a miss: the serve path falls back to
265
+ * the bundled manifest or live handler, and the trigger's stale check renders.
266
+ */
267
+ export async function readVerifiedStoredEntry(
268
+ store: WritablePrerenderStore,
269
+ key: PrerenderKey,
270
+ params: Record<string, string>,
271
+ ): Promise<PrerenderStoredEntry | null> {
272
+ try {
273
+ const read = await store.get(key);
274
+ return isStoredEntryValidFor(read, key, params) ? read : null;
275
+ } catch {
276
+ return null;
277
+ }
278
+ }
package/src/prerender.ts CHANGED
@@ -123,6 +123,27 @@ export interface PrerenderOptions {
123
123
  * ```
124
124
  */
125
125
  concurrency?: number;
126
+
127
+ /**
128
+ * Opt this route into on-demand (ISR-style) prerender refresh via
129
+ * `router.prerender()`. `true` uses the router-level `prerender` defaults; an
130
+ * object overrides them per route.
131
+ *
132
+ * Any truthy value opts in — a literal, a spread, or an imported const:
133
+ * producer retention in the production bundle is driven by the evaluated
134
+ * route manifest (the same source that sets the runtime od flag), not a
135
+ * textual scan of the call.
136
+ *
137
+ * @example
138
+ * ```typescript
139
+ * export const ProductPage = Prerender(
140
+ * async () => [{ id: "featured" }],
141
+ * async (ctx) => <Product data={await ctx.env.PRODUCTS.get(ctx.params.id)} />,
142
+ * { onDemand: { ttl: 3600, tags: ({ params }) => [`product:${params.id}`] } },
143
+ * );
144
+ * ```
145
+ */
146
+ onDemand?: import("./prerender/on-demand.js").OnDemandOption;
126
147
  }
127
148
 
128
149
  /**
@@ -144,6 +165,14 @@ export interface BuildContext<TParams> {
144
165
  */
145
166
  dev: boolean;
146
167
 
168
+ /**
169
+ * True when this render is a requestless on-demand refresh driven by
170
+ * `router.prerender()` (rather than a build-time or dev prerender). The
171
+ * producer is still requestless — cookies/headers are unavailable — but
172
+ * `ctx.env` is the live trigger binding, not the shared build env.
173
+ */
174
+ onDemand: boolean;
175
+
147
176
  /**
148
177
  * Build-time environment bindings (KV, D1, etc.) supplied by the Vite plugin.
149
178
  * Only available when `buildEnv` is configured in rango() options.
package/src/reverse.ts CHANGED
@@ -47,7 +47,7 @@ export type ParamsFor<TRoutes, TName extends keyof TRoutes> = ExtractParams<
47
47
  /**
48
48
  * Check if an object type has any keys
49
49
  */
50
- type IsEmptyObject<T> = keyof T extends never ? true : false;
50
+ export type IsEmptyObject<T> = keyof T extends never ? true : false;
51
51
 
52
52
  /**
53
53
  * Extract search schema from a route entry.