@rangojs/router 0.0.0-experimental.132 → 0.0.0-experimental.133

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 (141) hide show
  1. package/AGENTS.md +8 -0
  2. package/README.md +43 -2
  3. package/dist/bin/rango.js +92 -16
  4. package/dist/vite/index.js +166 -70
  5. package/package.json +19 -18
  6. package/skills/breadcrumbs/SKILL.md +1 -1
  7. package/skills/bundle-analysis/SKILL.md +2 -2
  8. package/skills/cache-guide/SKILL.md +2 -2
  9. package/skills/caching/SKILL.md +16 -9
  10. package/skills/debug-manifest/SKILL.md +4 -2
  11. package/skills/document-cache/SKILL.md +2 -2
  12. package/skills/handler-use/SKILL.md +1 -1
  13. package/skills/hooks/SKILL.md +2 -2
  14. package/skills/host-router/SKILL.md +1 -1
  15. package/skills/intercept/SKILL.md +1 -1
  16. package/skills/loader/SKILL.md +2 -0
  17. package/skills/migrate-react-router/SKILL.md +4 -2
  18. package/skills/mime-routes/SKILL.md +1 -1
  19. package/skills/prerender/SKILL.md +2 -0
  20. package/skills/rango/SKILL.md +12 -11
  21. package/skills/response-routes/SKILL.md +2 -2
  22. package/skills/route/SKILL.md +4 -0
  23. package/skills/router-setup/SKILL.md +3 -0
  24. package/skills/scripts/SKILL.md +179 -0
  25. package/skills/testing/SKILL.md +1 -1
  26. package/skills/testing/bindings.md +20 -6
  27. package/skills/testing/cache-prerender.md +5 -2
  28. package/skills/testing/client-components.md +2 -0
  29. package/skills/testing/e2e-parity.md +1 -1
  30. package/skills/testing/flight.md +8 -9
  31. package/skills/testing/render-handler.md +1 -1
  32. package/skills/testing/response-routes.md +1 -1
  33. package/skills/testing/server-actions.md +11 -11
  34. package/skills/testing/setup.md +3 -0
  35. package/skills/typesafety/SKILL.md +3 -2
  36. package/skills/use-cache/SKILL.md +10 -9
  37. package/src/browser/event-controller.ts +109 -2
  38. package/src/browser/partial-update.ts +12 -0
  39. package/src/browser/prefetch/cache.ts +17 -0
  40. package/src/browser/prefetch/fetch.ts +69 -2
  41. package/src/browser/react/Link.tsx +30 -5
  42. package/src/browser/react/NavigationProvider.tsx +12 -2
  43. package/src/browser/react/location-state-shared.ts +14 -2
  44. package/src/browser/react/use-href.tsx +8 -1
  45. package/src/browser/react/use-link-status.ts +23 -2
  46. package/src/browser/response-adapter.ts +14 -3
  47. package/src/browser/rsc-router.tsx +3 -0
  48. package/src/browser/scroll-restoration.ts +8 -3
  49. package/src/browser/server-action-bridge.ts +46 -11
  50. package/src/browser/types.ts +6 -0
  51. package/src/build/generate-route-types.ts +0 -1
  52. package/src/build/route-trie.ts +33 -9
  53. package/src/build/route-types/include-resolution.ts +7 -1
  54. package/src/build/route-types/router-processing.ts +0 -6
  55. package/src/build/route-types/source-scan.ts +105 -7
  56. package/src/cache/cache-policy.ts +42 -8
  57. package/src/cache/cache-runtime.ts +65 -5
  58. package/src/cache/cache-scope.ts +71 -11
  59. package/src/cache/cache-tag.ts +7 -2
  60. package/src/cache/cf/cf-base64.ts +33 -0
  61. package/src/cache/cf/cf-cache-constants.ts +127 -0
  62. package/src/cache/cf/cf-cache-store.ts +85 -613
  63. package/src/cache/cf/cf-cache-types.ts +349 -0
  64. package/src/cache/cf/cf-kv-utils.ts +46 -0
  65. package/src/cache/cf/cf-tag-marker-memo.ts +105 -0
  66. package/src/cache/document-cache.ts +11 -0
  67. package/src/cache/handle-snapshot.ts +8 -1
  68. package/src/cache/profile-registry.ts +25 -1
  69. package/src/cache/segment-codec.ts +9 -1
  70. package/src/cache/types.ts +4 -0
  71. package/src/client.rsc.tsx +38 -0
  72. package/src/client.tsx +11 -0
  73. package/src/components/DefaultDocument.tsx +8 -2
  74. package/src/context-var.ts +1 -1
  75. package/src/decode-loader-results.ts +7 -1
  76. package/src/escape-script.ts +52 -0
  77. package/src/handles/MetaTags.tsx +56 -5
  78. package/src/handles/Scripts.tsx +183 -0
  79. package/src/handles/breadcrumbs.ts +29 -11
  80. package/src/handles/is-thenable.ts +19 -0
  81. package/src/handles/meta.ts +46 -0
  82. package/src/handles/script.ts +244 -0
  83. package/src/host/cookie-handler.ts +7 -3
  84. package/src/host/pattern-matcher.ts +16 -2
  85. package/src/index.rsc.ts +5 -0
  86. package/src/index.ts +5 -0
  87. package/src/response-utils.ts +25 -0
  88. package/src/route-definition/dsl-helpers.ts +7 -0
  89. package/src/route-definition/redirect.ts +1 -2
  90. package/src/router/content-negotiation.ts +58 -10
  91. package/src/router/intercept-resolution.ts +9 -0
  92. package/src/router/match-middleware/cache-store.ts +10 -1
  93. package/src/router/middleware.ts +10 -3
  94. package/src/router/pattern-matching.ts +25 -23
  95. package/src/router/prefetch-cache-ttl.ts +51 -0
  96. package/src/router/router-interfaces.ts +7 -0
  97. package/src/router/router-options.ts +23 -0
  98. package/src/router/segment-resolution/fresh.ts +10 -0
  99. package/src/router/segment-resolution/helpers.ts +35 -1
  100. package/src/router/segment-resolution/loader-cache.ts +10 -6
  101. package/src/router/segment-resolution/revalidation.ts +6 -0
  102. package/src/router/segment-resolution.ts +1 -0
  103. package/src/router/trie-matching.ts +14 -9
  104. package/src/router.ts +18 -10
  105. package/src/rsc/handler.ts +52 -13
  106. package/src/rsc/helpers.ts +7 -1
  107. package/src/rsc/index.ts +1 -4
  108. package/src/rsc/loader-fetch.ts +107 -37
  109. package/src/rsc/progressive-enhancement.ts +18 -6
  110. package/src/rsc/response-cache-serve.ts +238 -0
  111. package/src/rsc/response-route-handler.ts +16 -133
  112. package/src/rsc/rsc-rendering.ts +13 -4
  113. package/src/rsc/server-action.ts +52 -6
  114. package/src/rsc/types.ts +7 -0
  115. package/src/search-params.ts +24 -5
  116. package/src/segment-loader-promise.ts +17 -2
  117. package/src/server/loader-registry.ts +16 -18
  118. package/src/server/request-context.ts +47 -20
  119. package/src/testing/dispatch.ts +108 -25
  120. package/src/testing/flight.ts +25 -0
  121. package/src/testing/internal/context.ts +25 -2
  122. package/src/testing/render-handler.ts +3 -1
  123. package/src/testing/render-route.tsx +15 -0
  124. package/src/testing/run-loader.ts +10 -3
  125. package/src/theme/ThemeProvider.tsx +20 -6
  126. package/src/theme/ThemeScript.tsx +7 -3
  127. package/src/theme/constants.ts +54 -3
  128. package/src/theme/theme-script.ts +22 -7
  129. package/src/types/request-scope.ts +8 -3
  130. package/src/vite/plugins/cjs-to-esm.ts +8 -1
  131. package/src/vite/plugins/expose-id-utils.ts +10 -1
  132. package/src/vite/plugins/expose-ids/handler-transform.ts +5 -16
  133. package/src/vite/plugins/expose-ids/loader-transform.ts +12 -5
  134. package/src/vite/plugins/expose-ids/router-transform.ts +6 -1
  135. package/src/vite/plugins/expose-internal-ids.ts +0 -1
  136. package/src/vite/plugins/version-plugin.ts +5 -17
  137. package/src/vite/plugins/virtual-entries.ts +12 -2
  138. package/src/vite/rango.ts +15 -6
  139. package/src/vite/utils/ast-handler-extract.ts +11 -4
  140. package/src/vite/utils/directive-prologue.ts +40 -0
  141. package/src/vite/utils/prerender-utils.ts +17 -2
@@ -51,172 +51,60 @@ import {
51
51
  } from "../cache-policy.js";
52
52
  import { reportCacheError, reportingAsync } from "../cache-error.js";
53
53
  import type { CacheErrorCategory } from "../cache-error.js";
54
+ import { bufferToBase64, base64ToBuffer } from "./cf-base64.js";
55
+ import {
56
+ KV_MAX_KEY_BYTES,
57
+ KV_MIN_EXPIRATION_TTL,
58
+ kvKeyByteLength,
59
+ remainingCacheControl,
60
+ } from "./cf-kv-utils.js";
61
+ import {
62
+ TAG_MARKER_CACHE_PREFIX,
63
+ TAG_MARKER_ABSENT,
64
+ getTagMarkerMemo,
65
+ getTagMarkerInflight,
66
+ } from "./cf-tag-marker-memo.js";
54
67
 
55
68
  // ============================================================================
56
69
  // Constants
57
70
  // ============================================================================
58
-
59
- /** Header storing timestamp when entry becomes stale */
60
- export const CACHE_STALE_AT_HEADER = "x-edge-cache-stale-at";
61
-
62
- /** Header storing cache status: HIT | REVALIDATING */
63
- export const CACHE_STATUS_HEADER = "x-edge-cache-status";
64
-
65
- /**
66
- * Header storing this entry's cache tags as a JSON array. JSON-encoded (not the
67
- * comma-delimited CF `Cache-Tag` format) so tags containing commas round-trip
68
- * safely; the read paths parse this to run the tag-invalidation check.
69
- */
70
- export const CACHE_TAGS_HEADER = "x-edge-cache-tags";
71
-
72
- /** Header storing the ms-epoch timestamp when this entry's tags were attached. */
73
- export const CACHE_TAGGED_AT_HEADER = "x-edge-cache-tagged-at";
74
-
75
- /**
76
- * KV key prefix for tag-invalidation markers. A marker stores the ms-epoch
77
- * timestamp of the most recent invalidation of a tag; reads treat any entry
78
- * whose taggedAt is older than its tags' latest marker as invalidated. Markers
79
- * live in the SAME KV namespace as the cached entries - there is no separate
80
- * tag-invalidation store.
81
- */
82
- export const TAG_MARKER_PREFIX = "__tag__/";
83
-
84
- /**
85
- * Cache-API path prefix for the optional per-colo L1 cache of tag-invalidation
86
- * markers (enabled by tagCacheTtl). Distinct from data keys (doc:/fn:/segment)
87
- * and from the KV marker prefix so the two never collide.
88
- */
89
- const TAG_MARKER_CACHE_PREFIX = "__tagmarker__/";
90
-
91
- /**
92
- * Sentinel body for an L1-cached marker meaning "this tag has no invalidation
93
- * marker." Distinct from any real ms-epoch timestamp (always a large positive
94
- * integer). A Cache API miss (match() === undefined) always means "re-read KV",
95
- * never "no marker" - absence is only ever represented by this cached sentinel.
96
- */
97
- const TAG_MARKER_ABSENT = "none";
98
-
99
- /**
100
- * Header storing the epoch-ms timestamp when an entry was marked REVALIDATING.
101
- * The SWR thundering-herd guard reads this to decide whether the in-flight
102
- * revalidation is still recent. It replaces a prior reliance on the HTTP `Age`
103
- * header: CF's Cache API does not populate `Age` reliably per-colo (and our own
104
- * unit MockCache never set it), so an absent `Age` defaulted to 0 and made every
105
- * REVALIDATING entry look "just revalidated" forever -- a dropped/never-finished
106
- * background revalidation could then pin an entry stale until hard expiry. An
107
- * explicit timestamp we write ourselves (same pattern as CACHE_STALE_AT_HEADER)
108
- * is reliable and lets the MAX_REVALIDATION_INTERVAL re-arm actually fire.
109
- */
110
- export const CACHE_REVALIDATING_AT_HEADER = "x-edge-cache-revalidating-at";
111
-
112
- /**
113
- * Header storing the absolute epoch-ms hard-expiry deadline (staleAt +
114
- * swrWindow*1000) of an L1 entry. The stale-path REVALIDATING re-put reads this
115
- * to recompute a SHRINKING Cache-Control max-age instead of copying set()'s
116
- * original full-window max-age. Without it, every MAX_REVALIDATION_INTERVAL
117
- * re-arm re-puts the full window and restarts CF's retention clock, pinning a
118
- * perpetually-stale entry (one whose background revalidation keeps failing) past
119
- * its intended hard-expiry indefinitely. Mirrors the KVSegmentEnvelope `e`
120
- * field and the remaining-ttl math in promoteSegmentToL1/promoteItemToL1.
121
- * @internal
122
- */
123
- const CACHE_EXPIRES_AT_HEADER = "x-edge-cache-expires-at";
124
-
125
- /**
126
- * Header stashing the route author's original Cache-Control on L1 document
127
- * entries. putResponse/promoteResponseToL1 overwrite Cache-Control with a long
128
- * `max-age` so the CF Cache API retains the entry across the whole SWR window;
129
- * getResponse restores this original value before serving so the client and any
130
- * upstream CDN see the author's intended directive, not the internal edge TTL.
131
- */
132
- const CACHE_ORIG_CC_HEADER = "x-edge-cache-orig-cc";
133
-
134
- /**
135
- * Maximum age in seconds for REVALIDATING status before allowing new revalidation.
136
- * After this period, a stale entry in REVALIDATING status will trigger revalidation again.
137
- * @internal
138
- */
139
- export const MAX_REVALIDATION_INTERVAL = 30;
140
-
141
- /**
142
- * Per-request memo of tag-invalidation markers (tag -> latest invalidatedAt, or
143
- * null when no marker exists). Keyed first by the request context object (so it
144
- * is naturally request-scoped and garbage-collected with the request) and then
145
- * by the store INSTANCE.
146
- *
147
- * The per-store nesting matters because a single request can run more than one
148
- * CFCacheStore - the app-level store plus a route's `cache({ store })` override,
149
- * which may point at a DIFFERENT KV binding or version. A module-level map keyed
150
- * by request alone (the inner map keyed by the raw tag name) would let store B's
151
- * memoized marker for a tag mask store A's own KV marker, so A could serve an
152
- * entry A's own KV says is invalidated. Keying by the instance isolates them;
153
- * two reads through the SAME store still share the memo. A read through one
154
- * store never populates another's memo, so each store always consults its own KV
155
- * binding. Markers are read only through isGloballyInvalidated(), which already
156
- * short-circuits when a store has no KV, so a store without KV never allocates.
157
- *
158
- * Without the memo, isGloballyInvalidated() issues a KV read per tag on every
159
- * tagged cache read, so a page composed of many segments/items sharing a tag
160
- * pays that cost N times. The memo collapses it to one KV read per distinct tag
161
- * per (request, store). invalidateTags() writes through so a same-request
162
- * updateTag() stays read-your-own-writes consistent (the action's own re-render
163
- * sees its own invalidation from the memo, without a re-read).
164
- *
165
- * It does NOT span requests, so a hot single-entry route still pays one KV read
166
- * per request; that read hits Cloudflare KV's own edge read cache for hot keys.
167
- */
168
- const tagMarkerMemo = new WeakMap<
169
- object,
170
- WeakMap<object, Map<string, number | null>>
171
- >();
172
-
173
- function getTagMarkerMemo(
174
- ctx: object,
175
- store: object,
176
- ): Map<string, number | null> {
177
- let byStore = tagMarkerMemo.get(ctx);
178
- if (!byStore) {
179
- byStore = new WeakMap();
180
- tagMarkerMemo.set(ctx, byStore);
181
- }
182
- let memo = byStore.get(store);
183
- if (!memo) {
184
- memo = new Map();
185
- byStore.set(store, memo);
186
- }
187
- return memo;
188
- }
189
-
190
- /**
191
- * Per-request map of IN-FLIGHT marker reads (tag -> the pending read promise).
192
- * The resolved-value memo above only collapses SEQUENTIAL reads of a tag; the
193
- * router resolves sibling segments in PARALLEL, so without this several
194
- * concurrently-resolving segments sharing a tag would each issue their own KV
195
- * read before any of them populates the memo. Sharing the in-flight promise
196
- * collapses those to a single KV read. Entries are dropped once resolved (the
197
- * value is then in the memo), so this only spans the concurrent read window.
198
- */
199
- const tagMarkerInflight = new WeakMap<
200
- object,
201
- WeakMap<object, Map<string, Promise<number | null>>>
202
- >();
203
-
204
- function getTagMarkerInflight(
205
- ctx: object,
206
- store: object,
207
- ): Map<string, Promise<number | null>> {
208
- let byStore = tagMarkerInflight.get(ctx);
209
- if (!byStore) {
210
- byStore = new WeakMap();
211
- tagMarkerInflight.set(ctx, byStore);
212
- }
213
- let inflight = byStore.get(store);
214
- if (!inflight) {
215
- inflight = new Map();
216
- byStore.set(store, inflight);
217
- }
218
- return inflight;
219
- }
71
+ //
72
+ // Header names, KV prefixes, and timeout/interval defaults live in
73
+ // cf-cache-constants.ts so collaborator modules can share them without a
74
+ // circular import back to this class. They are re-exported below so existing
75
+ // import paths (`../cf-cache-store`, `./cf-cache-store.js`) still resolve.
76
+ import {
77
+ CACHE_STALE_AT_HEADER,
78
+ CACHE_STATUS_HEADER,
79
+ CACHE_TAGS_HEADER,
80
+ CACHE_TAGGED_AT_HEADER,
81
+ TAG_MARKER_PREFIX,
82
+ CACHE_REVALIDATING_AT_HEADER,
83
+ CACHE_EXPIRES_AT_HEADER,
84
+ CACHE_ORIG_CC_HEADER,
85
+ MAX_REVALIDATION_INTERVAL,
86
+ EDGE_LOOKUP_TIMEOUT_MS,
87
+ EDGE_READ_TIMEOUT_MS,
88
+ KV_READ_TIMEOUT_MS,
89
+ } from "./cf-cache-constants.js";
90
+
91
+ // Re-export the public constants so consumers/tests importing them from
92
+ // cf-cache-store keep working after the move.
93
+ export {
94
+ CACHE_STALE_AT_HEADER,
95
+ CACHE_STATUS_HEADER,
96
+ CACHE_TAGS_HEADER,
97
+ CACHE_TAGGED_AT_HEADER,
98
+ TAG_MARKER_PREFIX,
99
+ CACHE_REVALIDATING_AT_HEADER,
100
+ MAX_REVALIDATION_INTERVAL,
101
+ EDGE_LOOKUP_TIMEOUT_MS,
102
+ EDGE_READ_TIMEOUT_MS,
103
+ KV_READ_TIMEOUT_MS,
104
+ };
105
+
106
+ // The tag-marker prefix/sentinel and per-request memo helpers (with their
107
+ // module-singleton WeakMaps) live in cf-tag-marker-memo.ts; imported above.
220
108
 
221
109
  /**
222
110
  * Per-request memo of the derived cache-key base URL.
@@ -231,23 +119,8 @@ function getTagMarkerInflight(
231
119
  */
232
120
  const derivedBaseUrlMemo = new WeakMap<object, string>();
233
121
 
234
- /** KV key byte-length ceiling. Cloudflare KV rejects keys larger than this. */
235
- const KV_MAX_KEY_BYTES = 512;
236
-
237
- /**
238
- * Cloudflare KV's minimum `expirationTtl` (seconds). A `put` with a smaller
239
- * expirationTtl is rejected outright. Tag-invalidation markers (the only writes
240
- * that take a consumer-supplied TTL via tagInvalidationTtl) are floored to this
241
- * so a too-small value cannot make EVERY updateTag/revalidateTag throw.
242
- */
243
- const KV_MIN_EXPIRATION_TTL = 60;
244
-
245
- const kvKeyEncoder = new TextEncoder();
246
-
247
- /** UTF-8 byte length of a KV key (multibyte tags can exceed the char count). */
248
- function kvKeyByteLength(key: string): number {
249
- return kvKeyEncoder.encode(key).length;
250
- }
122
+ // Pure KV helpers (key byte-length limits, expirationTtl floor, stale-path
123
+ // Cache-Control recompute) live in cf-kv-utils.ts; imported above.
251
124
 
252
125
  /**
253
126
  * Stores (by namespace) already warned about tag machinery configured without a
@@ -263,97 +136,26 @@ const warnedNoKvReadInvalidation = new Set<string>();
263
136
  */
264
137
  const warnedTagInvalidationTtlFloor = new Set<string>();
265
138
 
266
- /**
267
- * Maximum time (ms) to wait for an L1 edge cache (CF Cache API) read before
268
- * giving up and treating it as a miss. The Cache API is normally sub-millisecond
269
- * per-colo, so a slow `match` signals a degraded colo; we don't want it adding
270
- * latency to the request. On timeout the lookup is abandoned, a warning is
271
- * logged, and the read falls through to its normal miss path (L2/KV or render).
272
- *
273
- * This is the default; override per store via
274
- * `CFCacheStoreOptions.edgeLookupTimeoutMs` (<= 0 disables the budget).
275
- */
276
- export const EDGE_LOOKUP_TIMEOUT_MS = 10;
277
-
278
- /**
279
- * Maximum time (ms) to wait for the BODY of a matched L1 entry to be read
280
- * (response.json()) before treating the read as a miss.
281
- *
282
- * This is separate from {@link EDGE_LOOKUP_TIMEOUT_MS} on purpose. CF's Cache
283
- * API resolves `match()` with a lazily-streamed body, so a fast `match` can be
284
- * followed by a multi-second stall while the body bytes are fetched -- the
285
- * latency tail lives here, after the match budget has already passed. The
286
- * default bounds that tail aggressively: a healthy per-colo body read (fetch +
287
- * JSON parse) settles in low single-digit milliseconds, so 20ms clears a
288
- * healthy read while still failing fast to L2/KV (or render) on a degraded colo
289
- * instead of pinning the request behind a seconds-long read. Raise it per store
290
- * if large Flight payloads legitimately need longer.
291
- *
292
- * Override per store via `CFCacheStoreOptions.edgeReadTimeoutMs` (<= 0 disables).
293
- */
294
- export const EDGE_READ_TIMEOUT_MS = 20;
295
-
296
- /**
297
- * Maximum time (ms) to wait for an L2 (KV) read (`kv.get(key, {type:"json"})`)
298
- * before treating it as a miss. Unlike the L1 budgets, KV is a GLOBAL store: the
299
- * file header documents ~50ms healthy reads, and a degraded namespace can tail
300
- * to seconds. KV is the LAST cache tier before a full render, so an unbounded
301
- * read here pins the whole request behind a degraded global lookup.
302
- *
303
- * The default (170ms) sits a few multiples above the documented ~50ms healthy
304
- * read, leaving headroom for legitimate latency tails (larger payloads,
305
- * far-from-colo regions) so a healthy-but-slow read does not false-miss into a
306
- * render, while still abandoning a genuinely degraded namespace well before its
307
- * multi-second tail can pin the request. A deployment with a tighter SLA can
308
- * lower it, and one whose healthy p99 runs higher should raise it: measure the
309
- * KV read p99 (Workers Analytics) and add margin. It is a degradation
310
- * guard-rail, not a tuning lever for "slow KV is normal here".
311
- *
312
- * Override per store via `CFCacheStoreOptions.kvReadTimeoutMs` (<= 0 disables).
313
- */
314
- export const KV_READ_TIMEOUT_MS = 170;
315
-
316
- /**
317
- * Compute the Cache-Control directive for a stale-path REVALIDATING re-put from
318
- * the entry's stored hard-expiry deadline (CACHE_EXPIRES_AT_HEADER). Returns the
319
- * REMAINING ttl so the re-put preserves the original retention deadline instead
320
- * of restarting it -- copying set()'s original full-window max-age would reset
321
- * CF's retention clock on every re-arm and pin a perpetually-stale entry forever.
322
- * An entry lacking a valid deadline (legacy/tampered) floors to max-age=1, so it
323
- * hard-expires in ~1s and self-heals via KV. Mirrors promoteSegmentToL1's math.
324
- * @internal
325
- */
326
- function remainingCacheControl(headers: Headers, now: number): string {
327
- const expiresAt = Number(headers.get(CACHE_EXPIRES_AT_HEADER));
328
- const remainingTtl =
329
- Number.isFinite(expiresAt) && expiresAt > 0
330
- ? Math.max(1, Math.floor((expiresAt - now) / 1000))
331
- : 1;
332
- return `public, max-age=${remainingTtl}`;
333
- }
334
-
335
139
  // ============================================================================
336
140
  // Types
337
141
  // ============================================================================
338
-
339
- // Imported from the canonical home (also publicly exported from src/index.ts /
340
- // src/index.rsc.ts) so this module shares the one interface rather than
341
- // declaring a second that could drift.
342
- import type { ExecutionContext } from "../../types/request-scope.js";
343
-
344
- /**
345
- * Minimal Cloudflare KV Namespace interface.
346
- * Avoids hard dependency on @cloudflare/workers-types.
347
- */
348
- export interface KVNamespace {
349
- get(key: string, options?: { type?: string }): Promise<any>;
350
- put(
351
- key: string,
352
- value: string,
353
- options?: { expirationTtl?: number },
354
- ): Promise<void>;
355
- delete(key: string): Promise<void>;
356
- }
142
+ //
143
+ // The shared public types (KVNamespace, CFCacheReadDebugEvent, CFCacheDebug,
144
+ // CFCacheStoreOptions) live in cf-cache-types.ts; imported and re-exported below
145
+ // so existing import paths still resolve. The private KV envelope interfaces
146
+ // stay here with the methods that read/write them.
147
+ import type {
148
+ KVNamespace,
149
+ CFCacheReadDebugEvent,
150
+ CFCacheDebug,
151
+ CFCacheStoreOptions,
152
+ } from "./cf-cache-types.js";
153
+ export type {
154
+ KVNamespace,
155
+ CFCacheReadDebugEvent,
156
+ CFCacheDebug,
157
+ CFCacheStoreOptions,
158
+ };
357
159
 
358
160
  /**
359
161
  * KV envelope for segment cache entries.
@@ -410,325 +212,6 @@ interface KVResponseEnvelope {
410
212
  ta?: number;
411
213
  }
412
214
 
413
- /**
414
- * One L1 read decision, surfaced when `debug` is enabled. Lets an operator
415
- * confirm on a real deployment (e.g. via `wrangler tail`) that the store's
416
- * observed inputs match its decision: which tier answered, the entry's status,
417
- * the stale/revalidating timestamps, the raw CF `Age` header (so its
418
- * unreliability can be seen next to the explicit revalidating-at stamp), and
419
- * the measured match/body-read durations (where the latency tail shows up).
420
- */
421
- export interface CFCacheReadDebugEvent {
422
- /**
423
- * Which read method produced this event. Only the JSON read paths (segment
424
- * `get` and function `getItem`) participate in debug; the document
425
- * `getResponse` path streams its body and is intentionally out of scope.
426
- */
427
- op: "get" | "getItem";
428
- /** Cache key (without the internal fn:/doc: prefix or version path). */
429
- key: string;
430
- /**
431
- * What the read resolved to:
432
- * - l1-fresh / l1-stale-revalidate / l1-revalidating-guarded: L1 hit outcomes
433
- * - match-timeout / body-timeout: the L1 latency budgets fired
434
- * - match-error: the L1 match() itself rejected (a transient Cache API infra
435
- * error) -- a miss that falls through to L2/KV and is reported cache-read,
436
- * distinct from a genuine l1-miss (absence) so the two are separable
437
- * - body-error: the L1 body read failed fast (corrupt/non-JSON body) -- a miss
438
- * that falls through to L2/KV, distinct from a body-timeout
439
- * - non-200: L1 returned a non-200 (treated as a miss)
440
- * - l1-miss: no L1 entry
441
- * - kv-fresh / kv-stale / kv-miss: L2 fallback outcomes
442
- * - kv-stale-suppressed: a stale L2 hit served WITHOUT revalidation because
443
- * the L1 fall-through was degraded (body-timeout / non-200) -- the herd
444
- * mitigation, distinct from kv-stale so the suppression is visible
445
- * - kv-timeout: the L2/KV read budget fired (read abandoned, NOT a genuine
446
- * absence -- distinct from kv-miss so a degradation signal is separable)
447
- * - tag-invalidated: a live L1/KV entry whose cache tags were invalidated
448
- * after it was written -- treated as a miss so the next render re-populates
449
- * it (the tag-invalidation read path, distinct from a plain miss)
450
- * - error: the read threw
451
- */
452
- outcome:
453
- | "l1-fresh"
454
- | "l1-stale-revalidate"
455
- | "l1-revalidating-guarded"
456
- | "match-timeout"
457
- | "match-error"
458
- | "body-timeout"
459
- | "body-error"
460
- | "non-200"
461
- | "tag-invalidated"
462
- | "l1-miss"
463
- | "kv-fresh"
464
- | "kv-stale"
465
- | "kv-stale-suppressed"
466
- | "kv-miss"
467
- | "kv-timeout"
468
- | "error";
469
- /** HTTP status of the matched L1 response, when one was returned. */
470
- status?: number;
471
- /**
472
- * Stored cache status header (CACHE_STATUS_HEADER): "HIT" or "REVALIDATING".
473
- * Distinct from `isRevalidating`, which also factors in stamp recency -- this
474
- * is the raw stored value, so a REVALIDATING entry whose stamp aged out (so
475
- * `isRevalidating` is false) is still distinguishable from a plain HIT.
476
- */
477
- cacheStatus?: string | null;
478
- /** Epoch-ms when the entry goes stale (from CACHE_STALE_AT_HEADER). */
479
- staleAt?: number;
480
- /** Epoch-ms the entry was marked REVALIDATING (from the explicit stamp). */
481
- revalidatingAt?: number;
482
- /** Raw CF `Age` header, for comparison against revalidatingAt (may be null). */
483
- ageHeader?: string | null;
484
- isStale?: boolean;
485
- isRevalidating?: boolean;
486
- shouldRevalidate?: boolean;
487
- /** Wall-clock ms spent in cache.match (bounded by edgeLookupTimeoutMs). */
488
- matchMs?: number;
489
- /**
490
- * Wall-clock ms spent resolving the entry's tag-invalidation markers (the
491
- * per-request memo -> optional per-colo L1 marker cache -> KV cascade), for a
492
- * tagged entry. 0/absent for an untagged entry or a memo hit; a non-trivial
493
- * value is the serial marker-read tail that sits between matchMs and
494
- * bodyReadMs. Only measured when debug is enabled.
495
- */
496
- markerMs?: number;
497
- /** Wall-clock ms spent reading the body (bounded by edgeReadTimeoutMs). */
498
- bodyReadMs?: number;
499
- }
500
-
501
- /**
502
- * Debug sink. `true` logs each {@link CFCacheReadDebugEvent} to console; a
503
- * function receives the events for programmatic capture.
504
- */
505
- export type CFCacheDebug = boolean | ((event: CFCacheReadDebugEvent) => void);
506
-
507
- export interface CFCacheStoreOptions<TEnv = unknown> {
508
- /**
509
- * Cache namespace. If not provided, uses caches.default (recommended).
510
- * Only set this if you need isolated cache storage.
511
- */
512
- namespace?: string;
513
-
514
- /**
515
- * Base URL for cache keys.
516
- *
517
- * If not provided, derives from request hostname via requestContext:
518
- * - Production domains → uses `https://{hostname}/`
519
- * - Dev/preview (localhost, workers.dev, pages.dev) → uses internal fallback URL
520
- */
521
- baseUrl?: string;
522
-
523
- /** Default cache options */
524
- defaults?: CacheDefaults;
525
-
526
- /**
527
- * Cloudflare ExecutionContext for non-blocking cache writes.
528
- * Pass the `ctx` from your worker's fetch handler.
529
- *
530
- * @example
531
- * ```typescript
532
- * new CFCacheStore({ ctx: env.ctx })
533
- * ```
534
- */
535
- ctx: ExecutionContext;
536
-
537
- /**
538
- * Optional KV namespace for L2 cache persistence.
539
- *
540
- * When provided, KV acts as a global fallback behind the per-colo Cache API.
541
- * On L1 miss, KV is checked and hits are promoted back to L1.
542
- * On writes, data is persisted to both L1 and KV.
543
- *
544
- * @example
545
- * ```typescript
546
- * new CFCacheStore({ ctx: env.ctx, kv: env.CACHE_KV })
547
- * ```
548
- *
549
- * Tag-based invalidation (updateTag/revalidateTag) requires KV: the
550
- * tag-invalidation markers are stored in this same namespace. There is no
551
- * separate tag-invalidation store to configure.
552
- */
553
- kv?: KVNamespace;
554
-
555
- /**
556
- * Optional eager-purge hook, called ONCE per updateTag()/revalidateTag() with
557
- * the namespaced Cloudflare Cache-Tags to purge (one batched call for the
558
- * whole invalidation, not one per tag). These exactly match the `Cache-Tag`
559
- * header this store writes on its tag-lookup marker entries
560
- * (`rg:{namespace}:lk:{encodedTag}`), so forwarding them to Cloudflare's
561
- * purge-by-tag API evicts the cached lookups in every colo - making
562
- * cross-colo invalidation prompt instead of waiting out `tagCacheTtl`.
563
- *
564
- * Only meaningful with `tagCacheTtl > 0` (otherwise there are no cached
565
- * lookups to purge). The values are pre-encoded, so commas in tag names are
566
- * safe to pass straight to the purge API.
567
- *
568
- * @example
569
- * ```ts
570
- * onRevalidateTag: async (cacheTags) => {
571
- * await fetch(`https://api.cloudflare.com/client/v4/zones/${ZONE}/purge_cache`, {
572
- * method: "POST",
573
- * headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json" },
574
- * body: JSON.stringify({ tags: cacheTags }),
575
- * });
576
- * }
577
- * ```
578
- */
579
- onRevalidateTag?: (cacheTags: string[]) => Promise<void>;
580
-
581
- /**
582
- * Optional expiration (seconds) for tag-invalidation markers in KV. A marker
583
- * must outlive every entry tagged before the invalidation, so this MUST
584
- * exceed your largest entry TTL+SWR. Defaults to no expiration (markers
585
- * persist; they are tiny - one timestamp per distinct invalidated tag).
586
- *
587
- * Note the opposite sizing from `tagCacheTtl` below: `tagInvalidationTtl` must
588
- * be LARGE (outlive data); `tagCacheTtl` should be SMALL (a staleness ceiling).
589
- *
590
- * Cardinality matters: each DISTINCT invalidated tag writes one permanent KV
591
- * marker (with the no-expiry default). Keep tags LOW-cardinality and never
592
- * derive an invalidation tag from untrusted input (e.g.
593
- * `revalidateTag(req.query.tag)`) - an attacker could otherwise grow your KV
594
- * namespace without bound. Set a `tagInvalidationTtl` only if your tags are
595
- * unavoidably high-cardinality AND it can still safely exceed your max entry
596
- * TTL+SWR.
597
- */
598
- tagInvalidationTtl?: number;
599
-
600
- /**
601
- * Optional TTL (seconds) for caching tag-invalidation markers in the per-colo
602
- * Cache API (L1), to avoid a KV marker read on every tagged cache read.
603
- *
604
- * Default `0` = disabled: the marker is read from KV on every tagged read
605
- * (today's behavior), giving the strongest cross-colo invalidation latency
606
- * (~KV consistency). A positive value caches each marker (including the
607
- * "no marker yet" state) in L1 for that many seconds, so within the window a
608
- * colo answers from L1 with no KV read.
609
- *
610
- * The colo that runs `updateTag`/`revalidateTag` writes the fresh marker
611
- * straight into its own L1 (write-through), so the invalidating request and
612
- * later reads in that colo observe the invalidation immediately. One caveat: a
613
- * read already in flight when the invalidation lands (one that began its KV
614
- * marker fetch first) can re-cache the PRIOR marker into L1 after the
615
- * write-through, so a racing concurrent reader in the same colo may miss the
616
- * invalidation for up to `tagCacheTtl` -- the Cache API exposes no
617
- * compare-and-set to close this fully. `tagCacheTtl` is therefore a staleness
618
- * CEILING, not a promise of zero same-colo latency; keep it small (or wire
619
- * `onRevalidateTag`) when that matters. By default OTHER colos only converge
620
- * when their cached marker expires, so `tagCacheTtl` is the MAXIMUM extra
621
- * cross-colo invalidation latency for them. Recommended 30-60 for high-read,
622
- * low-mutation tags; leave at 0 when prompt global invalidation matters and
623
- * you cannot wire a purge.
624
- *
625
- * To make other colos prompt WITHOUT a short TTL, wire `onRevalidateTag` to a
626
- * Cloudflare purge-by-tag call: each marker entry carries a namespaced
627
- * `Cache-Tag`, and `onRevalidateTag` is handed exactly those tags to purge, so
628
- * the cached lookups are evicted everywhere on invalidation. With a purge
629
- * wired, `tagCacheTtl` becomes purely a read-cost reducer + fallback window
630
- * (safe to set large) rather than the invalidation-latency ceiling.
631
- */
632
- tagCacheTtl?: number;
633
-
634
- /**
635
- * Cache version string override. When this changes, all cached entries are
636
- * effectively invalidated (new keys won't match old entries).
637
- *
638
- * Defaults to the auto-generated VERSION from the `@rangojs/router:version` virtual module.
639
- * Only set this if you need a custom versioning strategy.
640
- */
641
- version?: string;
642
-
643
- /**
644
- * Latency budget (ms) for an L1 edge cache (CF Cache API) read. A `match`
645
- * slower than this is abandoned and treated as a miss, so a degraded colo
646
- * cannot stall the request; the read then falls through to its normal miss
647
- * path (L2/KV or render).
648
- *
649
- * Defaults to {@link EDGE_LOOKUP_TIMEOUT_MS} (10). Set to 0 (or any value
650
- * <= 0) to disable the budget and always await `match`.
651
- */
652
- edgeLookupTimeoutMs?: number;
653
-
654
- /**
655
- * Latency budget (ms) for reading the BODY of a matched L1 entry
656
- * (response.json()). CF streams the cache body lazily, so the multi-second
657
- * tail can appear after `match` already resolved; this bounds it. On timeout
658
- * the read is treated as a miss and falls through to L2/KV or render.
659
- *
660
- * Separate from {@link edgeLookupTimeoutMs} because a healthy body read
661
- * (fetch + JSON parse of a potentially large Flight payload) takes a little
662
- * longer than a `match`. Defaults to {@link EDGE_READ_TIMEOUT_MS} (20), which
663
- * clears a healthy per-colo read yet fails fast on a degraded one. Set to 0
664
- * (or any value <= 0) to disable and always await the body.
665
- */
666
- edgeReadTimeoutMs?: number;
667
-
668
- /**
669
- * Latency budget (ms) for an L2 (KV) read. KV is the last cache tier before a
670
- * full render and is a global store (~50ms healthy, seconds when degraded);
671
- * this bounds it so a slow namespace cannot pin the request. On timeout the
672
- * read is treated as a miss (no L1 promote) and falls through to render.
673
- *
674
- * Defaults to {@link KV_READ_TIMEOUT_MS} (170) -- a few multiples above the
675
- * ~50ms healthy read, with headroom for legitimate tails (large payloads / far
676
- * regions) yet still well under a degraded namespace's multi-second tail.
677
- * Lower it for a tighter SLA, raise it if your healthy KV p99 runs higher; it
678
- * is a degradation guard-rail, not a tuning lever. Set to 0 (or any value
679
- * <= 0) to disable and always await KV.
680
- */
681
- kvReadTimeoutMs?: number;
682
-
683
- /**
684
- * Emit a {@link CFCacheReadDebugEvent} per L1 read. `true` logs to console
685
- * (visible via `wrangler tail`); pass a function to capture events directly.
686
- * Off by default. Intended for validating cache behavior on a real
687
- * deployment before relying on it; not for steady-state production.
688
- */
689
- debug?: CFCacheDebug;
690
-
691
- /**
692
- * Custom key generator applied to all cache operations.
693
- * Receives the full RequestContext (including env) and the default-generated key.
694
- * Return value becomes the final cache key (unless route overrides with `key` option).
695
- *
696
- * Reserved prefixes: tag-invalidation markers live in the SAME KV namespace as
697
- * data, keyed `__tag__/<tag>` (and `__tagmarker__/<tag>` for the L1 cache). A
698
- * returned key must NOT begin with `__tag__/` or `__tagmarker__/`, or it can
699
- * collide with a tag marker and corrupt invalidation. The documented
700
- * prepend-style generators below are safe.
701
- *
702
- * @example Using headers for user segmentation
703
- * ```typescript
704
- * keyGenerator: (ctx, defaultKey) => {
705
- * const segment = ctx.request.headers.get('x-user-segment') || 'default';
706
- * return `${segment}:${defaultKey}`;
707
- * }
708
- * ```
709
- *
710
- * @example Using env bindings for multi-region
711
- * ```typescript
712
- * keyGenerator: (ctx, defaultKey) => {
713
- * const region = ctx.env.REGION || 'us';
714
- * return `${region}:${defaultKey}`;
715
- * }
716
- * ```
717
- *
718
- * @example Using cookies for locale-aware caching
719
- * ```typescript
720
- * keyGenerator: (ctx, defaultKey) => {
721
- * const locale = cookies().get('locale')?.value || 'en';
722
- * return `${locale}:${defaultKey}`;
723
- * }
724
- * ```
725
- */
726
- keyGenerator?: (
727
- ctx: RequestContext<TEnv>,
728
- defaultKey: string,
729
- ) => string | Promise<string>;
730
- }
731
-
732
215
  // ============================================================================
733
216
  // CFCacheStore Implementation
734
217
  // ============================================================================
@@ -2611,11 +2094,12 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
2611
2094
  await Promise.all(
2612
2095
  tags.map(async (tag) => {
2613
2096
  const markerKey = this.tagMarkerKey(tag);
2614
- if (kvKeyByteLength(markerKey) > KV_MAX_KEY_BYTES) {
2097
+ const markerKeyBytes = kvKeyByteLength(markerKey);
2098
+ if (markerKeyBytes > KV_MAX_KEY_BYTES) {
2615
2099
  failedTags.add(tag);
2616
2100
  errors.push(
2617
2101
  new Error(
2618
- `tag "${tag}" produces a ${kvKeyByteLength(markerKey)}-byte KV ` +
2102
+ `tag "${tag}" produces a ${markerKeyBytes}-byte KV ` +
2619
2103
  `marker key, over the ${KV_MAX_KEY_BYTES}-byte limit`,
2620
2104
  ),
2621
2105
  );
@@ -2990,7 +2474,19 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
2990
2474
  typeof e.st === "number" &&
2991
2475
  typeof e.e === "number" &&
2992
2476
  typeof e.s === "number" &&
2993
- Array.isArray(e.hd),
2477
+ // stx is optional but, if present, must be a string (feeds Response).
2478
+ (e.stx === undefined || typeof e.stx === "string") &&
2479
+ // hd must be an array of [name, value] string tuples; a malformed
2480
+ // shape would otherwise throw in `new Headers(hd)`. Validate it here
2481
+ // so a faulty envelope is a fail-open MISS, never a thrown read.
2482
+ Array.isArray(e.hd) &&
2483
+ e.hd.every(
2484
+ (entry) =>
2485
+ Array.isArray(entry) &&
2486
+ entry.length === 2 &&
2487
+ typeof entry[0] === "string" &&
2488
+ typeof entry[1] === "string",
2489
+ ),
2994
2490
  "kvGetResponse",
2995
2491
  );
2996
2492
  if (!envelope) return null;
@@ -3095,27 +2591,3 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
3095
2591
  );
3096
2592
  }
3097
2593
  }
3098
-
3099
- // ============================================================================
3100
- // Base64 Helpers (binary-safe response body encoding for KV)
3101
- // ============================================================================
3102
-
3103
- /** Encode ArrayBuffer to base64 string. */
3104
- function bufferToBase64(buffer: ArrayBuffer): string {
3105
- const bytes = new Uint8Array(buffer);
3106
- let binary = "";
3107
- for (let i = 0; i < bytes.length; i++) {
3108
- binary += String.fromCharCode(bytes[i]!);
3109
- }
3110
- return btoa(binary);
3111
- }
3112
-
3113
- /** Decode base64 string to ArrayBuffer. */
3114
- function base64ToBuffer(base64: string): ArrayBuffer {
3115
- const binary = atob(base64);
3116
- const bytes = new Uint8Array(binary.length);
3117
- for (let i = 0; i < binary.length; i++) {
3118
- bytes[i] = binary.charCodeAt(i);
3119
- }
3120
- return bytes.buffer;
3121
- }