@rangojs/router 0.0.0-experimental.77 → 0.0.0-experimental.77ed8945

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 (239) hide show
  1. package/README.md +120 -25
  2. package/dist/bin/rango.js +147 -57
  3. package/dist/vite/index.js +2103 -861
  4. package/dist/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  5. package/package.json +13 -8
  6. package/skills/api-client/SKILL.md +211 -0
  7. package/skills/breadcrumbs/SKILL.md +3 -1
  8. package/skills/bundle-analysis/SKILL.md +159 -0
  9. package/skills/cache-guide/SKILL.md +220 -30
  10. package/skills/caching/SKILL.md +116 -8
  11. package/skills/composability/SKILL.md +27 -2
  12. package/skills/css/SKILL.md +76 -0
  13. package/skills/document-cache/SKILL.md +78 -55
  14. package/skills/handler-use/SKILL.md +3 -1
  15. package/skills/hooks/SKILL.md +229 -20
  16. package/skills/host-router/SKILL.md +66 -20
  17. package/skills/i18n/SKILL.md +276 -0
  18. package/skills/intercept/SKILL.md +26 -4
  19. package/skills/layout/SKILL.md +6 -7
  20. package/skills/links/SKILL.md +247 -17
  21. package/skills/loader/SKILL.md +219 -9
  22. package/skills/middleware/SKILL.md +47 -12
  23. package/skills/migrate-nextjs/SKILL.md +562 -0
  24. package/skills/migrate-react-router/SKILL.md +769 -0
  25. package/skills/mime-routes/SKILL.md +27 -0
  26. package/skills/observability/SKILL.md +137 -0
  27. package/skills/parallel/SKILL.md +12 -6
  28. package/skills/prerender/SKILL.md +14 -33
  29. package/skills/rango/SKILL.md +238 -22
  30. package/skills/react-compiler/SKILL.md +168 -0
  31. package/skills/response-routes/SKILL.md +122 -47
  32. package/skills/route/SKILL.md +33 -4
  33. package/skills/router-setup/SKILL.md +3 -3
  34. package/skills/server-actions/SKILL.md +751 -0
  35. package/skills/streams-and-websockets/SKILL.md +283 -0
  36. package/skills/tailwind/SKILL.md +27 -3
  37. package/skills/typesafety/SKILL.md +319 -27
  38. package/skills/use-cache/SKILL.md +34 -5
  39. package/skills/view-transitions/SKILL.md +294 -0
  40. package/src/__augment-tests__/augment.ts +81 -0
  41. package/src/__augment-tests__/augmented.check.ts +116 -0
  42. package/src/browser/action-coordinator.ts +53 -36
  43. package/src/browser/app-shell.ts +39 -0
  44. package/src/browser/event-controller.ts +86 -70
  45. package/src/browser/history-state.ts +21 -0
  46. package/src/browser/index.ts +3 -3
  47. package/src/browser/navigation-bridge.ts +29 -9
  48. package/src/browser/navigation-client.ts +99 -77
  49. package/src/browser/navigation-store.ts +7 -8
  50. package/src/browser/navigation-transaction.ts +10 -28
  51. package/src/browser/partial-update.ts +60 -40
  52. package/src/browser/prefetch/cache.ts +196 -49
  53. package/src/browser/prefetch/fetch.ts +203 -59
  54. package/src/browser/prefetch/queue.ts +36 -5
  55. package/src/browser/rango-state.ts +37 -13
  56. package/src/browser/react/Link.tsx +18 -13
  57. package/src/browser/react/NavigationProvider.tsx +75 -31
  58. package/src/browser/react/filter-segment-order.ts +51 -7
  59. package/src/browser/react/index.ts +3 -0
  60. package/src/browser/react/location-state-shared.ts +175 -4
  61. package/src/browser/react/location-state.ts +39 -13
  62. package/src/browser/react/use-handle.ts +17 -9
  63. package/src/browser/react/use-navigation.ts +22 -2
  64. package/src/browser/react/use-params.ts +20 -8
  65. package/src/browser/react/use-reverse.ts +106 -0
  66. package/src/browser/react/use-router.ts +23 -2
  67. package/src/browser/react/use-segments.ts +11 -8
  68. package/src/browser/response-adapter.ts +52 -1
  69. package/src/browser/rsc-router.tsx +71 -22
  70. package/src/browser/scroll-restoration.ts +22 -14
  71. package/src/browser/segment-reconciler.ts +10 -14
  72. package/src/browser/segment-structure-assert.ts +2 -2
  73. package/src/browser/server-action-bridge.ts +44 -30
  74. package/src/browser/types.ts +12 -2
  75. package/src/build/collect-fallback-refs.ts +107 -0
  76. package/src/build/generate-manifest.ts +60 -35
  77. package/src/build/generate-route-types.ts +2 -0
  78. package/src/build/index.ts +8 -1
  79. package/src/build/prefix-tree-utils.ts +123 -0
  80. package/src/build/route-trie.ts +45 -1
  81. package/src/build/route-types/codegen.ts +4 -4
  82. package/src/build/route-types/include-resolution.ts +1 -1
  83. package/src/build/route-types/per-module-writer.ts +7 -4
  84. package/src/build/route-types/router-processing.ts +55 -14
  85. package/src/build/route-types/scan-filter.ts +1 -1
  86. package/src/build/route-types/source-scan.ts +118 -0
  87. package/src/build/runtime-discovery.ts +9 -20
  88. package/src/cache/cache-runtime.ts +17 -5
  89. package/src/cache/cache-scope.ts +51 -49
  90. package/src/cache/cf/cf-cache-store.ts +502 -32
  91. package/src/cache/cf/index.ts +3 -0
  92. package/src/cache/handle-snapshot.ts +103 -0
  93. package/src/cache/index.ts +3 -0
  94. package/src/cache/memory-segment-store.ts +3 -2
  95. package/src/cache/types.ts +10 -6
  96. package/src/client.rsc.tsx +3 -0
  97. package/src/client.tsx +96 -205
  98. package/src/context-var.ts +5 -5
  99. package/src/decode-loader-results.ts +36 -0
  100. package/src/errors.ts +30 -4
  101. package/src/handle.ts +4 -6
  102. package/src/host/index.ts +2 -2
  103. package/src/host/router.ts +129 -57
  104. package/src/host/types.ts +31 -2
  105. package/src/host/utils.ts +1 -1
  106. package/src/href-client.ts +140 -21
  107. package/src/index.rsc.ts +10 -6
  108. package/src/index.ts +17 -8
  109. package/src/loader-store.ts +500 -0
  110. package/src/loader.rsc.ts +2 -5
  111. package/src/loader.ts +3 -10
  112. package/src/missing-id-error.ts +68 -0
  113. package/src/outlet-context.ts +1 -1
  114. package/src/prerender/store.ts +9 -7
  115. package/src/prerender.ts +4 -4
  116. package/src/response-utils.ts +37 -0
  117. package/src/reverse.ts +65 -39
  118. package/src/route-content-wrapper.tsx +6 -28
  119. package/src/route-definition/dsl-helpers.ts +253 -265
  120. package/src/route-definition/helper-factories.ts +29 -139
  121. package/src/route-definition/helpers-types.ts +43 -15
  122. package/src/route-definition/resolve-handler-use.ts +6 -0
  123. package/src/route-definition/use-item-types.ts +32 -0
  124. package/src/route-types.ts +26 -41
  125. package/src/router/content-negotiation.ts +15 -2
  126. package/src/router/error-handling.ts +1 -1
  127. package/src/router/find-match.ts +54 -6
  128. package/src/router/handler-context.ts +21 -41
  129. package/src/router/intercept-resolution.ts +4 -18
  130. package/src/router/lazy-includes.ts +41 -22
  131. package/src/router/loader-resolution.ts +82 -36
  132. package/src/router/manifest.ts +41 -19
  133. package/src/router/match-api.ts +4 -3
  134. package/src/router/match-handlers.ts +1 -0
  135. package/src/router/match-middleware/cache-lookup.ts +57 -95
  136. package/src/router/match-middleware/cache-store.ts +3 -2
  137. package/src/router/match-result.ts +53 -32
  138. package/src/router/metrics.ts +1 -1
  139. package/src/router/middleware-types.ts +15 -26
  140. package/src/router/middleware.ts +99 -84
  141. package/src/router/pattern-matching.ts +116 -19
  142. package/src/router/prerender-match.ts +40 -15
  143. package/src/router/preview-match.ts +3 -1
  144. package/src/router/request-classification.ts +40 -37
  145. package/src/router/revalidation.ts +58 -2
  146. package/src/router/router-interfaces.ts +51 -35
  147. package/src/router/router-options.ts +25 -1
  148. package/src/router/router-registry.ts +2 -5
  149. package/src/router/segment-resolution/fresh.ts +27 -6
  150. package/src/router/segment-resolution/revalidation.ts +147 -106
  151. package/src/router/segment-resolution/static-store.ts +19 -5
  152. package/src/router/segment-resolution/view-transition-default.ts +36 -0
  153. package/src/router/substitute-pattern-params.ts +56 -0
  154. package/src/router/trie-matching.ts +40 -16
  155. package/src/router/types.ts +8 -0
  156. package/src/router/url-params.ts +49 -0
  157. package/src/router.ts +37 -25
  158. package/src/rsc/handler-context.ts +2 -2
  159. package/src/rsc/handler.ts +58 -77
  160. package/src/rsc/helpers.ts +72 -43
  161. package/src/rsc/index.ts +1 -1
  162. package/src/rsc/manifest-init.ts +28 -41
  163. package/src/rsc/origin-guard.ts +30 -10
  164. package/src/rsc/progressive-enhancement.ts +4 -0
  165. package/src/rsc/response-error.ts +79 -12
  166. package/src/rsc/response-route-handler.ts +76 -61
  167. package/src/rsc/rsc-rendering.ts +45 -51
  168. package/src/rsc/runtime-warnings.ts +9 -10
  169. package/src/rsc/server-action.ts +33 -39
  170. package/src/rsc/ssr-setup.ts +16 -0
  171. package/src/rsc/types.ts +8 -2
  172. package/src/search-params.ts +4 -4
  173. package/src/segment-content-promise.ts +67 -0
  174. package/src/segment-loader-promise.ts +122 -0
  175. package/src/segment-system.tsx +132 -116
  176. package/src/serialize.ts +243 -0
  177. package/src/server/context.ts +175 -53
  178. package/src/server/cookie-store.ts +28 -4
  179. package/src/server/request-context.ts +57 -51
  180. package/src/ssr/index.tsx +5 -1
  181. package/src/static-handler.ts +1 -1
  182. package/src/types/global-namespace.ts +39 -26
  183. package/src/types/handler-context.ts +68 -50
  184. package/src/types/index.ts +1 -0
  185. package/src/types/loader-types.ts +11 -9
  186. package/src/types/request-scope.ts +126 -0
  187. package/src/types/route-entry.ts +11 -0
  188. package/src/types/segments.ts +35 -2
  189. package/src/urls/include-helper.ts +34 -67
  190. package/src/urls/index.ts +1 -5
  191. package/src/urls/path-helper-types.ts +17 -3
  192. package/src/urls/path-helper.ts +17 -52
  193. package/src/urls/pattern-types.ts +36 -19
  194. package/src/urls/response-types.ts +22 -29
  195. package/src/urls/type-extraction.ts +58 -139
  196. package/src/urls/urls-function.ts +1 -5
  197. package/src/use-loader.tsx +413 -42
  198. package/src/vite/debug.ts +185 -0
  199. package/src/vite/discovery/bundle-postprocess.ts +6 -6
  200. package/src/vite/discovery/discover-routers.ts +106 -75
  201. package/src/vite/discovery/discovery-errors.ts +194 -0
  202. package/src/vite/discovery/gate-state.ts +171 -0
  203. package/src/vite/discovery/prerender-collection.ts +72 -31
  204. package/src/vite/discovery/route-types-writer.ts +40 -84
  205. package/src/vite/discovery/self-gen-tracking.ts +27 -1
  206. package/src/vite/discovery/state.ts +33 -0
  207. package/src/vite/discovery/virtual-module-codegen.ts +13 -23
  208. package/src/vite/index.ts +2 -0
  209. package/src/vite/plugin-types.ts +67 -0
  210. package/src/vite/plugins/cjs-to-esm.ts +8 -7
  211. package/src/vite/plugins/client-ref-dedup.ts +16 -0
  212. package/src/vite/plugins/client-ref-hashing.ts +28 -5
  213. package/src/vite/plugins/cloudflare-protocol-loader-hook.d.mts +23 -0
  214. package/src/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  215. package/src/vite/plugins/cloudflare-protocol-stub.ts +214 -0
  216. package/src/vite/plugins/expose-action-id.ts +54 -30
  217. package/src/vite/plugins/expose-id-utils.ts +12 -8
  218. package/src/vite/plugins/expose-ids/export-analysis.ts +100 -20
  219. package/src/vite/plugins/expose-ids/handler-transform.ts +8 -61
  220. package/src/vite/plugins/expose-ids/loader-transform.ts +3 -5
  221. package/src/vite/plugins/expose-ids/router-transform.ts +20 -3
  222. package/src/vite/plugins/expose-internal-ids.ts +496 -486
  223. package/src/vite/plugins/performance-tracks.ts +29 -25
  224. package/src/vite/plugins/use-cache-transform.ts +65 -50
  225. package/src/vite/plugins/version-injector.ts +39 -23
  226. package/src/vite/plugins/version-plugin.ts +59 -2
  227. package/src/vite/plugins/virtual-entries.ts +2 -2
  228. package/src/vite/rango.ts +116 -29
  229. package/src/vite/router-discovery.ts +753 -104
  230. package/src/vite/utils/ast-handler-extract.ts +15 -15
  231. package/src/vite/utils/banner.ts +1 -1
  232. package/src/vite/utils/bundle-analysis.ts +4 -2
  233. package/src/vite/utils/client-chunks.ts +190 -0
  234. package/src/vite/utils/forward-user-plugins.ts +193 -0
  235. package/src/vite/utils/manifest-utils.ts +8 -59
  236. package/src/vite/utils/package-resolution.ts +41 -1
  237. package/src/vite/utils/prerender-utils.ts +5 -4
  238. package/src/vite/utils/shared-utils.ts +107 -26
  239. package/src/browser/action-response-classifier.ts +0 -99
@@ -56,6 +56,28 @@ export const CACHE_STALE_AT_HEADER = "x-edge-cache-stale-at";
56
56
  /** Header storing cache status: HIT | REVALIDATING */
57
57
  export const CACHE_STATUS_HEADER = "x-edge-cache-status";
58
58
 
59
+ /**
60
+ * Header storing the epoch-ms timestamp when an entry was marked REVALIDATING.
61
+ * The SWR thundering-herd guard reads this to decide whether the in-flight
62
+ * revalidation is still recent. It replaces a prior reliance on the HTTP `Age`
63
+ * header: CF's Cache API does not populate `Age` reliably per-colo (and our own
64
+ * unit MockCache never set it), so an absent `Age` defaulted to 0 and made every
65
+ * REVALIDATING entry look "just revalidated" forever -- a dropped/never-finished
66
+ * background revalidation could then pin an entry stale until hard expiry. An
67
+ * explicit timestamp we write ourselves (same pattern as CACHE_STALE_AT_HEADER)
68
+ * is reliable and lets the MAX_REVALIDATION_INTERVAL re-arm actually fire.
69
+ */
70
+ export const CACHE_REVALIDATING_AT_HEADER = "x-edge-cache-revalidating-at";
71
+
72
+ /**
73
+ * Header stashing the route author's original Cache-Control on L1 document
74
+ * entries. putResponse/promoteResponseToL1 overwrite Cache-Control with a long
75
+ * `max-age` so the CF Cache API retains the entry across the whole SWR window;
76
+ * getResponse restores this original value before serving so the client and any
77
+ * upstream CDN see the author's intended directive, not the internal edge TTL.
78
+ */
79
+ const CACHE_ORIG_CC_HEADER = "x-edge-cache-orig-cc";
80
+
59
81
  /**
60
82
  * Maximum age in seconds for REVALIDATING status before allowing new revalidation.
61
83
  * After this period, a stale entry in REVALIDATING status will trigger revalidation again.
@@ -63,17 +85,44 @@ export const CACHE_STATUS_HEADER = "x-edge-cache-status";
63
85
  */
64
86
  export const MAX_REVALIDATION_INTERVAL = 30;
65
87
 
88
+ /**
89
+ * Maximum time (ms) to wait for an L1 edge cache (CF Cache API) read before
90
+ * giving up and treating it as a miss. The Cache API is normally sub-millisecond
91
+ * per-colo, so a slow `match` signals a degraded colo; we don't want it adding
92
+ * latency to the request. On timeout the lookup is abandoned, a warning is
93
+ * logged, and the read falls through to its normal miss path (L2/KV or render).
94
+ *
95
+ * This is the default; override per store via
96
+ * `CFCacheStoreOptions.edgeLookupTimeoutMs` (<= 0 disables the budget).
97
+ */
98
+ export const EDGE_LOOKUP_TIMEOUT_MS = 10;
99
+
100
+ /**
101
+ * Maximum time (ms) to wait for the BODY of a matched L1 entry to be read
102
+ * (response.json()) before treating the read as a miss.
103
+ *
104
+ * This is separate from {@link EDGE_LOOKUP_TIMEOUT_MS} on purpose. CF's Cache
105
+ * API resolves `match()` with a lazily-streamed body, so a fast `match` can be
106
+ * followed by a multi-second stall while the body bytes are fetched -- the
107
+ * latency tail lives here, after the match budget has already passed. The body
108
+ * read also includes JSON parsing of a potentially large Flight payload, so a
109
+ * healthy read legitimately takes longer than a `match`; a 10ms budget here
110
+ * would false-miss large entries. The default is generous enough to clear a
111
+ * healthy fetch+parse yet still bound the seconds-long degraded tail.
112
+ *
113
+ * Override per store via `CFCacheStoreOptions.edgeReadTimeoutMs` (<= 0 disables).
114
+ */
115
+ export const EDGE_READ_TIMEOUT_MS = 100;
116
+
66
117
  // ============================================================================
67
118
  // Types
68
119
  // ============================================================================
69
120
 
70
- /**
71
- * Cloudflare Workers ExecutionContext (subset we need)
72
- */
73
- export interface ExecutionContext {
74
- waitUntil(promise: Promise<any>): void;
75
- passThroughOnException(): void;
76
- }
121
+ // Re-exported from the canonical home so cf-cache-store consumers keep
122
+ // importing `ExecutionContext` from this module without a second interface
123
+ // drifting over time.
124
+ export type { ExecutionContext } from "../../types/request-scope.js";
125
+ import type { ExecutionContext } from "../../types/request-scope.js";
77
126
 
78
127
  /**
79
128
  * Minimal Cloudflare KV Namespace interface.
@@ -109,8 +158,8 @@ interface KVSegmentEnvelope {
109
158
  interface KVItemEnvelope {
110
159
  /** RSC-serialized return value */
111
160
  v: string;
112
- /** Handle data */
113
- h?: Record<string, Record<string, unknown[]>>;
161
+ /** RSC-encoded handle data (see handle-snapshot.ts encodeHandles) */
162
+ h?: string;
114
163
  /** When entry becomes stale (ms epoch) */
115
164
  s: number;
116
165
  /** When entry hard-expires (ms epoch) */
@@ -136,6 +185,63 @@ interface KVResponseEnvelope {
136
185
  e: number;
137
186
  }
138
187
 
188
+ /**
189
+ * One L1 read decision, surfaced when `debug` is enabled. Lets an operator
190
+ * confirm on a real deployment (e.g. via `wrangler tail`) that the store's
191
+ * observed inputs match its decision: which tier answered, the entry's status,
192
+ * the stale/revalidating timestamps, the raw CF `Age` header (so its
193
+ * unreliability can be seen next to the explicit revalidating-at stamp), and
194
+ * the measured match/body-read durations (where the latency tail shows up).
195
+ */
196
+ export interface CFCacheReadDebugEvent {
197
+ /** Which read method produced this event. */
198
+ op: "get" | "getItem" | "getResponse";
199
+ /** Cache key (without the internal fn:/doc: prefix or version path). */
200
+ key: string;
201
+ /**
202
+ * What the read resolved to:
203
+ * - l1-fresh / l1-stale-revalidate / l1-revalidating-guarded: L1 hit outcomes
204
+ * - match-timeout / body-timeout: the latency budgets fired
205
+ * - non-200: L1 returned a non-200 (treated as a miss)
206
+ * - l1-miss: no L1 entry
207
+ * - kv-fresh / kv-stale / kv-miss: L2 fallback outcomes
208
+ * - error: the read threw
209
+ */
210
+ outcome:
211
+ | "l1-fresh"
212
+ | "l1-stale-revalidate"
213
+ | "l1-revalidating-guarded"
214
+ | "match-timeout"
215
+ | "body-timeout"
216
+ | "non-200"
217
+ | "l1-miss"
218
+ | "kv-fresh"
219
+ | "kv-stale"
220
+ | "kv-miss"
221
+ | "error";
222
+ /** HTTP status of the matched L1 response, when one was returned. */
223
+ status?: number;
224
+ /** Epoch-ms when the entry goes stale (from CACHE_STALE_AT_HEADER). */
225
+ staleAt?: number;
226
+ /** Epoch-ms the entry was marked REVALIDATING (from the explicit stamp). */
227
+ revalidatingAt?: number;
228
+ /** Raw CF `Age` header, for comparison against revalidatingAt (may be null). */
229
+ ageHeader?: string | null;
230
+ isStale?: boolean;
231
+ isRevalidating?: boolean;
232
+ shouldRevalidate?: boolean;
233
+ /** Wall-clock ms spent in cache.match (bounded by edgeLookupTimeoutMs). */
234
+ matchMs?: number;
235
+ /** Wall-clock ms spent reading the body (bounded by edgeReadTimeoutMs). */
236
+ bodyReadMs?: number;
237
+ }
238
+
239
+ /**
240
+ * Debug sink. `true` logs each {@link CFCacheReadDebugEvent} to console; a
241
+ * function receives the events for programmatic capture.
242
+ */
243
+ export type CFCacheDebug = boolean | ((event: CFCacheReadDebugEvent) => void);
244
+
139
245
  export interface CFCacheStoreOptions<TEnv = unknown> {
140
246
  /**
141
247
  * Cache namespace. If not provided, uses caches.default (recommended).
@@ -184,11 +290,43 @@ export interface CFCacheStoreOptions<TEnv = unknown> {
184
290
  * Cache version string override. When this changes, all cached entries are
185
291
  * effectively invalidated (new keys won't match old entries).
186
292
  *
187
- * Defaults to the auto-generated VERSION from `rsc-router:version` virtual module.
293
+ * Defaults to the auto-generated VERSION from the `@rangojs/router:version` virtual module.
188
294
  * Only set this if you need a custom versioning strategy.
189
295
  */
190
296
  version?: string;
191
297
 
298
+ /**
299
+ * Latency budget (ms) for an L1 edge cache (CF Cache API) read. A `match`
300
+ * slower than this is abandoned and treated as a miss, so a degraded colo
301
+ * cannot stall the request; the read then falls through to its normal miss
302
+ * path (L2/KV or render).
303
+ *
304
+ * Defaults to {@link EDGE_LOOKUP_TIMEOUT_MS} (10). Set to 0 (or any value
305
+ * <= 0) to disable the budget and always await `match`.
306
+ */
307
+ edgeLookupTimeoutMs?: number;
308
+
309
+ /**
310
+ * Latency budget (ms) for reading the BODY of a matched L1 entry
311
+ * (response.json()). CF streams the cache body lazily, so the multi-second
312
+ * tail can appear after `match` already resolved; this bounds it. On timeout
313
+ * the read is treated as a miss and falls through to L2/KV or render.
314
+ *
315
+ * Separate from {@link edgeLookupTimeoutMs} because a healthy body read
316
+ * (fetch + JSON parse of a potentially large Flight payload) legitimately
317
+ * takes longer than a `match`. Defaults to {@link EDGE_READ_TIMEOUT_MS} (100).
318
+ * Set to 0 (or any value <= 0) to disable and always await the body.
319
+ */
320
+ edgeReadTimeoutMs?: number;
321
+
322
+ /**
323
+ * Emit a {@link CFCacheReadDebugEvent} per L1 read. `true` logs to console
324
+ * (visible via `wrangler tail`); pass a function to capture events directly.
325
+ * Off by default. Intended for validating cache behavior on a real
326
+ * deployment before relying on it; not for steady-state production.
327
+ */
328
+ debug?: CFCacheDebug;
329
+
192
330
  /**
193
331
  * Custom key generator applied to all cache operations.
194
332
  * Receives the full RequestContext (including env) and the default-generated key.
@@ -242,9 +380,12 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
242
380
  ) => string | Promise<string>;
243
381
 
244
382
  private readonly namespace?: string;
245
- private readonly baseUrl: string;
383
+ private readonly explicitBaseUrl?: string;
246
384
  private readonly waitUntil?: (fn: () => Promise<void>) => void;
247
385
  private readonly version?: string;
386
+ private readonly edgeLookupTimeoutMs: number;
387
+ private readonly edgeReadTimeoutMs: number;
388
+ private readonly debug?: (event: CFCacheReadDebugEvent) => void;
248
389
  private readonly kv?: KVNamespace;
249
390
 
250
391
  constructor(options: CFCacheStoreOptions<TEnv>) {
@@ -257,21 +398,63 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
257
398
  }
258
399
 
259
400
  this.namespace = options.namespace;
260
- this.baseUrl = options.baseUrl ?? this.deriveBaseUrl();
401
+ // Base URL is resolved lazily per cache operation (see resolveBaseUrl).
402
+ // The store is constructed before the per-request context ALS is entered
403
+ // (the cache factory runs ahead of runWithRequestContext in the handler),
404
+ // so deriving the host here would always miss the request and fall back to
405
+ // the internal host. Only the explicit override can be captured eagerly.
406
+ this.explicitBaseUrl = options.baseUrl;
261
407
  this.defaults = options.defaults;
262
408
  this.version = options.version ?? VERSION;
409
+ this.edgeLookupTimeoutMs =
410
+ options.edgeLookupTimeoutMs ?? EDGE_LOOKUP_TIMEOUT_MS;
411
+ this.edgeReadTimeoutMs = options.edgeReadTimeoutMs ?? EDGE_READ_TIMEOUT_MS;
412
+ this.debug =
413
+ options.debug === true
414
+ ? (event) =>
415
+ console.log(`[CFCacheStore:debug] ${JSON.stringify(event)}`)
416
+ : typeof options.debug === "function"
417
+ ? options.debug
418
+ : undefined;
263
419
  this.keyGenerator = options.keyGenerator;
264
420
  this.waitUntil = (fn) => options.ctx.waitUntil(fn());
265
421
  this.kv = options.kv;
266
422
  }
267
423
 
424
+ /**
425
+ * Emit a debug event if `debug` is enabled. Swallows sink errors so a faulty
426
+ * debug callback can never break a cache read.
427
+ * @internal
428
+ */
429
+ private emitDebug(event: CFCacheReadDebugEvent): void {
430
+ if (!this.debug) return;
431
+ try {
432
+ this.debug(event);
433
+ } catch {
434
+ // A broken debug sink must not affect the request.
435
+ }
436
+ }
437
+
438
+ /**
439
+ * Resolve the cache-key base URL for the current cache operation.
440
+ * Prefers an explicit `baseUrl` option; otherwise derives it from the live
441
+ * request. Called per operation (from keyToRequest), which runs inside the
442
+ * request-context ALS, so deriveBaseUrl sees the request and can use the
443
+ * production host instead of the internal fallback.
444
+ * @internal
445
+ */
446
+ private resolveBaseUrl(): string {
447
+ return this.explicitBaseUrl ?? this.deriveBaseUrl();
448
+ }
449
+
268
450
  /**
269
451
  * Derive base URL from request hostname via requestContext.
270
452
  * Uses internal fallback for dev/preview environments and untrusted hostnames.
453
+ * Must run inside the request context (invoked lazily via resolveBaseUrl).
271
454
  * @internal
272
455
  */
273
456
  private deriveBaseUrl(): string {
274
- const fallback = "https://rsc-cache.internal.com/";
457
+ const fallback = "https://rsc-dummy-host-1.com/";
275
458
 
276
459
  const ctx = _getRequestContext();
277
460
  if (!ctx?.request) {
@@ -316,6 +499,92 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
316
499
  return caches.default;
317
500
  }
318
501
 
502
+ /**
503
+ * Read from the L1 edge cache with a latency budget. A `match` that takes
504
+ * longer than the configured budget (edgeLookupTimeoutMs, default
505
+ * EDGE_LOOKUP_TIMEOUT_MS) is abandoned and reported as a miss (undefined) so a
506
+ * degraded colo cannot stall the request; callers then fall through to their
507
+ * normal miss path (L2/KV or render). The slow `match` is left to settle in
508
+ * the background (errors swallowed) rather than aborted, since the Cache API
509
+ * exposes no cancellation. A budget <= 0 disables the timeout entirely and
510
+ * awaits `match` directly.
511
+ * @internal
512
+ */
513
+ private async matchWithTimeout(
514
+ cache: Cache,
515
+ request: Request,
516
+ ): Promise<Response | undefined> {
517
+ const budget = this.edgeLookupTimeoutMs;
518
+ if (budget <= 0) {
519
+ return cache.match(request);
520
+ }
521
+ let timer: ReturnType<typeof setTimeout> | undefined;
522
+ const timeout = new Promise<{ timedOut: true }>((resolve) => {
523
+ timer = setTimeout(() => resolve({ timedOut: true }), budget);
524
+ });
525
+ try {
526
+ const matchPromise = cache.match(request);
527
+ // The losing branch keeps running; ensure a late rejection can't surface
528
+ // as an unhandled rejection once we've stopped awaiting it.
529
+ matchPromise.catch(() => {});
530
+ const result = await Promise.race([
531
+ matchPromise.then((response) => ({
532
+ timedOut: false as const,
533
+ response,
534
+ })),
535
+ timeout,
536
+ ]);
537
+ if (result.timedOut) {
538
+ console.warn(
539
+ `[CFCacheStore] edge cache lookup exceeded ${budget}ms; treating as miss`,
540
+ );
541
+ return undefined;
542
+ }
543
+ return result.response;
544
+ } finally {
545
+ if (timer) clearTimeout(timer);
546
+ }
547
+ }
548
+
549
+ /**
550
+ * Read and JSON-parse a matched L1 Response's body under the edgeReadTimeoutMs
551
+ * budget. CF resolves `match()` with a lazily-streamed body, so the latency
552
+ * tail surfaces here -- after matchWithTimeout has already passed -- not in the
553
+ * match itself. On timeout the read is abandoned (the dangling body read is
554
+ * left to settle, errors swallowed, since the stream exposes no cancellation)
555
+ * and `undefined` is returned so the caller falls through to L2/KV or render.
556
+ * A budget <= 0 disables the bound and awaits the body directly.
557
+ * @internal
558
+ */
559
+ private async readJsonWithTimeout<T>(
560
+ response: Response,
561
+ ): Promise<T | undefined> {
562
+ const budget = this.edgeReadTimeoutMs;
563
+ if (budget <= 0) return (await response.json()) as T;
564
+
565
+ let timer: ReturnType<typeof setTimeout> | undefined;
566
+ const timeout = new Promise<{ timedOut: true }>((resolve) => {
567
+ timer = setTimeout(() => resolve({ timedOut: true }), budget);
568
+ });
569
+ try {
570
+ const readPromise = response.json() as Promise<T>;
571
+ readPromise.catch(() => {});
572
+ const result = await Promise.race([
573
+ readPromise.then((value) => ({ timedOut: false as const, value })),
574
+ timeout,
575
+ ]);
576
+ if (result.timedOut) {
577
+ console.warn(
578
+ `[CFCacheStore] edge cache body read exceeded ${budget}ms; treating as miss`,
579
+ );
580
+ return undefined;
581
+ }
582
+ return result.value;
583
+ } finally {
584
+ if (timer) clearTimeout(timer);
585
+ }
586
+ }
587
+
319
588
  // ============================================================================
320
589
  // Segment Cache Methods
321
590
  // ============================================================================
@@ -335,26 +604,84 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
335
604
  try {
336
605
  const cache = await this.getCache();
337
606
  const request = this.keyToRequest(key);
338
- const response = await cache.match(request);
607
+ const matchStart = Date.now();
608
+ const response = await this.matchWithTimeout(cache, request);
609
+ const matchMs = Date.now() - matchStart;
339
610
 
340
611
  if (!response) {
612
+ // Genuine L1 miss, or matchWithTimeout abandoned a slow match (it warns).
613
+ this.emitDebug({ op: "get", key, outcome: "l1-miss", matchMs });
614
+ return this.kvGetSegment(key);
615
+ }
616
+
617
+ // A non-200 entry (a cached error response, or a foreign response that
618
+ // landed on this key) is not valid segment data; treat it as a miss
619
+ // rather than JSON-parsing garbage and serving it as a hit.
620
+ if (response.status !== 200) {
621
+ this.emitDebug({
622
+ op: "get",
623
+ key,
624
+ outcome: "non-200",
625
+ status: response.status,
626
+ matchMs,
627
+ });
341
628
  return this.kvGetSegment(key);
342
629
  }
343
630
 
344
631
  // Read status headers
345
632
  const status = response.headers.get(CACHE_STATUS_HEADER);
346
- const age = Number(response.headers.get("age") ?? "0");
633
+ const ageHeader = response.headers.get("age");
347
634
  const staleAt = Number(
348
635
  response.headers.get(CACHE_STALE_AT_HEADER) ?? "0",
349
636
  );
637
+ const revalidatingAt = Number(
638
+ response.headers.get(CACHE_REVALIDATING_AT_HEADER) ?? "0",
639
+ );
350
640
 
351
641
  const isStale = staleAt > 0 && Date.now() > staleAt;
642
+ // Recency comes from our explicit revalidating-at stamp, not CF's `Age`
643
+ // header (see CACHE_REVALIDATING_AT_HEADER). An absent/zero stamp counts
644
+ // as "not recent" so a dropped revalidation re-arms instead of pinning.
352
645
  const isRevalidating =
353
- status === "REVALIDATING" && age < MAX_REVALIDATION_INTERVAL;
646
+ status === "REVALIDATING" &&
647
+ revalidatingAt > 0 &&
648
+ Date.now() - revalidatingAt < MAX_REVALIDATION_INTERVAL * 1000;
354
649
 
355
650
  // Case 1: Fresh or already being revalidated - just return data
356
651
  if (!isStale || isRevalidating) {
357
- const data = (await response.json()) as CachedEntryData;
652
+ const bodyStart = Date.now();
653
+ const data = await this.readJsonWithTimeout<CachedEntryData>(response);
654
+ const bodyReadMs = Date.now() - bodyStart;
655
+ if (data === undefined) {
656
+ this.emitDebug({
657
+ op: "get",
658
+ key,
659
+ outcome: "body-timeout",
660
+ status: response.status,
661
+ staleAt,
662
+ revalidatingAt,
663
+ ageHeader,
664
+ isStale,
665
+ isRevalidating,
666
+ matchMs,
667
+ bodyReadMs,
668
+ });
669
+ return this.kvGetSegment(key);
670
+ }
671
+ this.emitDebug({
672
+ op: "get",
673
+ key,
674
+ outcome: isRevalidating ? "l1-revalidating-guarded" : "l1-fresh",
675
+ status: response.status,
676
+ staleAt,
677
+ revalidatingAt,
678
+ ageHeader,
679
+ isStale,
680
+ isRevalidating,
681
+ shouldRevalidate: false,
682
+ matchMs,
683
+ bodyReadMs,
684
+ });
358
685
  return { data, shouldRevalidate: false };
359
686
  }
360
687
 
@@ -363,6 +690,8 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
363
690
 
364
691
  const headers = new Headers(response.headers);
365
692
  headers.set(CACHE_STATUS_HEADER, "REVALIDATING");
693
+ // Stamp when we marked it so the herd guard / re-arm reads a reliable time.
694
+ headers.set(CACHE_REVALIDATING_AT_HEADER, String(Date.now()));
366
695
 
367
696
  // Blocking write - must complete before returning to prevent race
368
697
  await cache.put(
@@ -370,10 +699,45 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
370
699
  new Response(b1, { status: response.status, headers }),
371
700
  );
372
701
 
373
- const data = (await new Response(b2).json()) as CachedEntryData;
702
+ const bodyStart = Date.now();
703
+ const data = await this.readJsonWithTimeout<CachedEntryData>(
704
+ new Response(b2),
705
+ );
706
+ const bodyReadMs = Date.now() - bodyStart;
707
+ if (data === undefined) {
708
+ this.emitDebug({
709
+ op: "get",
710
+ key,
711
+ outcome: "body-timeout",
712
+ status: response.status,
713
+ staleAt,
714
+ revalidatingAt,
715
+ ageHeader,
716
+ isStale,
717
+ isRevalidating,
718
+ matchMs,
719
+ bodyReadMs,
720
+ });
721
+ return this.kvGetSegment(key);
722
+ }
723
+ this.emitDebug({
724
+ op: "get",
725
+ key,
726
+ outcome: "l1-stale-revalidate",
727
+ status: response.status,
728
+ staleAt,
729
+ revalidatingAt,
730
+ ageHeader,
731
+ isStale,
732
+ isRevalidating,
733
+ shouldRevalidate: true,
734
+ matchMs,
735
+ bodyReadMs,
736
+ });
374
737
  return { data, shouldRevalidate: true };
375
738
  } catch (error) {
376
739
  console.error("[CFCacheStore] get failed:", error);
740
+ this.emitDebug({ op: "get", key, outcome: "error" });
377
741
  return null;
378
742
  }
379
743
  }
@@ -421,7 +785,7 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
421
785
  }
422
786
 
423
787
  // L2: persist to KV
424
- this.kvSetSegment(key, data, staleAt, totalTtl);
788
+ this.kvSetSegment(key, data, staleAt, totalTtl, swrWindow);
425
789
  } catch (error) {
426
790
  console.error("[CFCacheStore] set failed:", error);
427
791
  }
@@ -469,7 +833,7 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
469
833
  try {
470
834
  const cache = await this.getCache();
471
835
  const request = this.keyToRequest(`doc:${key}`);
472
- const response = await cache.match(request);
836
+ const response = await this.matchWithTimeout(cache, request);
473
837
 
474
838
  if (!response || response.status !== 200) {
475
839
  return this.kvGetResponse(key);
@@ -480,7 +844,7 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
480
844
  const isStale = staleAt > 0 && Date.now() > staleAt;
481
845
 
482
846
  return {
483
- response,
847
+ response: this.toClientResponse(response),
484
848
  shouldRevalidate: isStale,
485
849
  };
486
850
  } catch (error) {
@@ -489,6 +853,30 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
489
853
  }
490
854
  }
491
855
 
856
+ /**
857
+ * Strip internal edge headers and restore the author's Cache-Control before a
858
+ * cached document Response is served to a client. L1 entries carry the
859
+ * internal staleness/status headers and a rewritten Cache-Control; none of
860
+ * those should reach the browser or an upstream CDN.
861
+ */
862
+ private toClientResponse(response: Response): Response {
863
+ const headers = new Headers(response.headers);
864
+ const originalCacheControl = headers.get(CACHE_ORIG_CC_HEADER);
865
+ if (originalCacheControl !== null) {
866
+ headers.set("Cache-Control", originalCacheControl);
867
+ } else {
868
+ headers.delete("Cache-Control");
869
+ }
870
+ headers.delete(CACHE_ORIG_CC_HEADER);
871
+ headers.delete(CACHE_STALE_AT_HEADER);
872
+ headers.delete(CACHE_STATUS_HEADER);
873
+ return new Response(response.body, {
874
+ status: response.status,
875
+ statusText: response.statusText,
876
+ headers,
877
+ });
878
+ }
879
+
492
880
  /**
493
881
  * Store a Response with TTL and optional SWR window (for document-level caching).
494
882
  * When KV is configured, also persists to L2.
@@ -515,8 +903,14 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
515
903
  : [null, null]
516
904
  : [response.body, null];
517
905
 
518
- // Clone and add cache headers
906
+ // Clone and add cache headers. The author's Cache-Control is stashed and
907
+ // replaced with a long max-age so the CF Cache API holds the entry across
908
+ // the SWR window; getResponse restores the original before serving.
519
909
  const headers = new Headers(response.headers);
910
+ const originalCacheControl = response.headers.get("Cache-Control");
911
+ if (originalCacheControl !== null) {
912
+ headers.set(CACHE_ORIG_CC_HEADER, originalCacheControl);
913
+ }
520
914
  headers.set("Cache-Control", `public, max-age=${totalTtl}`);
521
915
  headers.set(CACHE_STALE_AT_HEADER, String(staleAt));
522
916
 
@@ -585,26 +979,81 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
585
979
  try {
586
980
  const cache = await this.getCache();
587
981
  const request = this.keyToRequest(`fn:${key}`);
588
- const response = await cache.match(request);
982
+ const matchStart = Date.now();
983
+ const response = await this.matchWithTimeout(cache, request);
984
+ const matchMs = Date.now() - matchStart;
985
+
986
+ if (!response) {
987
+ this.emitDebug({ op: "getItem", key, outcome: "l1-miss", matchMs });
988
+ return this.kvGetItem(key);
989
+ }
589
990
 
590
- if (!response) return this.kvGetItem(key);
991
+ // Non-200 entry is not a valid cached function result; treat as a miss.
992
+ if (response.status !== 200) {
993
+ this.emitDebug({
994
+ op: "getItem",
995
+ key,
996
+ outcome: "non-200",
997
+ status: response.status,
998
+ matchMs,
999
+ });
1000
+ return this.kvGetItem(key);
1001
+ }
591
1002
 
592
1003
  const staleAt = Number(
593
1004
  response.headers.get(CACHE_STALE_AT_HEADER) ?? "0",
594
1005
  );
595
1006
  const status = response.headers.get(CACHE_STATUS_HEADER);
596
- const age = Number(response.headers.get("age") ?? "0");
1007
+ const ageHeader = response.headers.get("age");
1008
+ const revalidatingAt = Number(
1009
+ response.headers.get(CACHE_REVALIDATING_AT_HEADER) ?? "0",
1010
+ );
597
1011
 
598
1012
  const isStale = staleAt > 0 && Date.now() > staleAt;
1013
+ // Recency from our explicit stamp, not CF's `Age` header (see get()).
599
1014
  const isRevalidating =
600
- status === "REVALIDATING" && age < MAX_REVALIDATION_INTERVAL;
1015
+ status === "REVALIDATING" &&
1016
+ revalidatingAt > 0 &&
1017
+ Date.now() - revalidatingAt < MAX_REVALIDATION_INTERVAL * 1000;
601
1018
 
602
- const data = (await response.json()) as {
1019
+ const bodyStart = Date.now();
1020
+ const data = await this.readJsonWithTimeout<{
603
1021
  value: string;
604
- handles?: Record<string, Record<string, unknown[]>>;
605
- };
1022
+ handles?: string;
1023
+ }>(response);
1024
+ const bodyReadMs = Date.now() - bodyStart;
1025
+ if (data === undefined) {
1026
+ this.emitDebug({
1027
+ op: "getItem",
1028
+ key,
1029
+ outcome: "body-timeout",
1030
+ status: response.status,
1031
+ staleAt,
1032
+ revalidatingAt,
1033
+ ageHeader,
1034
+ isStale,
1035
+ isRevalidating,
1036
+ matchMs,
1037
+ bodyReadMs,
1038
+ });
1039
+ return this.kvGetItem(key);
1040
+ }
606
1041
 
607
1042
  if (!isStale || isRevalidating) {
1043
+ this.emitDebug({
1044
+ op: "getItem",
1045
+ key,
1046
+ outcome: isRevalidating ? "l1-revalidating-guarded" : "l1-fresh",
1047
+ status: response.status,
1048
+ staleAt,
1049
+ revalidatingAt,
1050
+ ageHeader,
1051
+ isStale,
1052
+ isRevalidating,
1053
+ shouldRevalidate: false,
1054
+ matchMs,
1055
+ bodyReadMs,
1056
+ });
608
1057
  return {
609
1058
  value: data.value,
610
1059
  handles: data.handles,
@@ -615,11 +1064,27 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
615
1064
  // Stale and needs revalidation — mark REVALIDATING atomically
616
1065
  const headers = new Headers(response.headers);
617
1066
  headers.set(CACHE_STATUS_HEADER, "REVALIDATING");
1067
+ // Stamp when we marked it so the herd guard / re-arm reads a reliable time.
1068
+ headers.set(CACHE_REVALIDATING_AT_HEADER, String(Date.now()));
618
1069
  await cache.put(
619
1070
  request,
620
1071
  new Response(JSON.stringify(data), { status: 200, headers }),
621
1072
  );
622
1073
 
1074
+ this.emitDebug({
1075
+ op: "getItem",
1076
+ key,
1077
+ outcome: "l1-stale-revalidate",
1078
+ status: response.status,
1079
+ staleAt,
1080
+ revalidatingAt,
1081
+ ageHeader,
1082
+ isStale,
1083
+ isRevalidating,
1084
+ shouldRevalidate: true,
1085
+ matchMs,
1086
+ bodyReadMs,
1087
+ });
623
1088
  return {
624
1089
  value: data.value,
625
1090
  handles: data.handles,
@@ -627,6 +1092,7 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
627
1092
  };
628
1093
  } catch (error) {
629
1094
  console.error("[CFCacheStore] getItem failed:", error);
1095
+ this.emitDebug({ op: "getItem", key, outcome: "error" });
630
1096
  return null;
631
1097
  }
632
1098
  }
@@ -706,7 +1172,7 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
706
1172
  const encodedKey = encodeURIComponent(key);
707
1173
  // Include version in URL path to invalidate cache when version changes
708
1174
  const versionPath = this.version ? `v/${this.version}/` : "";
709
- return new Request(`${this.baseUrl}${versionPath}${encodedKey}`, {
1175
+ return new Request(`${this.resolveBaseUrl()}${versionPath}${encodedKey}`, {
710
1176
  method: "GET",
711
1177
  });
712
1178
  }
@@ -766,13 +1232,13 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
766
1232
  data: CachedEntryData,
767
1233
  staleAt: number,
768
1234
  totalTtl: number,
1235
+ swrWindow: number,
769
1236
  ): void {
770
1237
  // KV requires expirationTtl >= 60s. Skip write for short-lived entries.
771
1238
  if (!this.kv || !this.waitUntil || totalTtl < 60) return;
772
1239
 
773
1240
  const kvKey = this.toKVKey(key);
774
- const swrWindow = totalTtl * 1000 - (staleAt - Date.now());
775
- const expiresAt = staleAt + swrWindow;
1241
+ const expiresAt = staleAt + swrWindow * 1000;
776
1242
 
777
1243
  this.waitUntil(async () => {
778
1244
  try {
@@ -939,6 +1405,10 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
939
1405
  const request = this.keyToRequest(`doc:${key}`);
940
1406
 
941
1407
  const headers = new Headers(envelope.hd);
1408
+ const originalCacheControl = headers.get("Cache-Control");
1409
+ if (originalCacheControl !== null) {
1410
+ headers.set(CACHE_ORIG_CC_HEADER, originalCacheControl);
1411
+ }
942
1412
  headers.set("Cache-Control", `public, max-age=${remainingTtl}`);
943
1413
  headers.set(CACHE_STALE_AT_HEADER, String(envelope.s));
944
1414