@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
@@ -0,0 +1,349 @@
1
+ // ============================================================================
2
+ // Types
3
+ // ============================================================================
4
+ //
5
+ // Shared public types for the CF cache store: the minimal KVNamespace shape, the
6
+ // debug-event surface, and the store options. Extracted from cf-cache-store.ts
7
+ // so they can be referenced without importing the class; re-exported from
8
+ // cf-cache-store.ts so existing import paths still resolve. The private KV
9
+ // envelope interfaces stay in cf-cache-store.ts with the methods that use them.
10
+
11
+ // Imported from the canonical home (also publicly exported from src/index.ts /
12
+ // src/index.rsc.ts) so this module shares the one interface rather than
13
+ // declaring a second that could drift.
14
+ import type { ExecutionContext } from "../../types/request-scope.js";
15
+ import type { CacheDefaults } from "../types.js";
16
+ import type { RequestContext } from "../../server/request-context.js";
17
+
18
+ /**
19
+ * Minimal Cloudflare KV Namespace interface.
20
+ * Avoids hard dependency on @cloudflare/workers-types.
21
+ */
22
+ export interface KVNamespace {
23
+ get(key: string, options?: { type?: string }): Promise<any>;
24
+ put(
25
+ key: string,
26
+ value: string,
27
+ options?: { expirationTtl?: number },
28
+ ): Promise<void>;
29
+ delete(key: string): Promise<void>;
30
+ }
31
+
32
+ /**
33
+ * One L1 read decision, surfaced when `debug` is enabled. Lets an operator
34
+ * confirm on a real deployment (e.g. via `wrangler tail`) that the store's
35
+ * observed inputs match its decision: which tier answered, the entry's status,
36
+ * the stale/revalidating timestamps, the raw CF `Age` header (so its
37
+ * unreliability can be seen next to the explicit revalidating-at stamp), and
38
+ * the measured match/body-read durations (where the latency tail shows up).
39
+ */
40
+ export interface CFCacheReadDebugEvent {
41
+ /**
42
+ * Which read method produced this event. Only the JSON read paths (segment
43
+ * `get` and function `getItem`) participate in debug; the document
44
+ * `getResponse` path streams its body and is intentionally out of scope.
45
+ */
46
+ op: "get" | "getItem";
47
+ /** Cache key (without the internal fn:/doc: prefix or version path). */
48
+ key: string;
49
+ /**
50
+ * What the read resolved to:
51
+ * - l1-fresh / l1-stale-revalidate / l1-revalidating-guarded: L1 hit outcomes
52
+ * - match-timeout / body-timeout: the L1 latency budgets fired
53
+ * - match-error: the L1 match() itself rejected (a transient Cache API infra
54
+ * error) -- a miss that falls through to L2/KV and is reported cache-read,
55
+ * distinct from a genuine l1-miss (absence) so the two are separable
56
+ * - body-error: the L1 body read failed fast (corrupt/non-JSON body) -- a miss
57
+ * that falls through to L2/KV, distinct from a body-timeout
58
+ * - non-200: L1 returned a non-200 (treated as a miss)
59
+ * - l1-miss: no L1 entry
60
+ * - kv-fresh / kv-stale / kv-miss: L2 fallback outcomes
61
+ * - kv-stale-suppressed: a stale L2 hit served WITHOUT revalidation because
62
+ * the L1 fall-through was degraded (body-timeout / non-200) -- the herd
63
+ * mitigation, distinct from kv-stale so the suppression is visible
64
+ * - kv-timeout: the L2/KV read budget fired (read abandoned, NOT a genuine
65
+ * absence -- distinct from kv-miss so a degradation signal is separable)
66
+ * - tag-invalidated: a live L1/KV entry whose cache tags were invalidated
67
+ * after it was written -- treated as a miss so the next render re-populates
68
+ * it (the tag-invalidation read path, distinct from a plain miss)
69
+ * - error: the read threw
70
+ */
71
+ outcome:
72
+ | "l1-fresh"
73
+ | "l1-stale-revalidate"
74
+ | "l1-revalidating-guarded"
75
+ | "match-timeout"
76
+ | "match-error"
77
+ | "body-timeout"
78
+ | "body-error"
79
+ | "non-200"
80
+ | "tag-invalidated"
81
+ | "l1-miss"
82
+ | "kv-fresh"
83
+ | "kv-stale"
84
+ | "kv-stale-suppressed"
85
+ | "kv-miss"
86
+ | "kv-timeout"
87
+ | "error";
88
+ /** HTTP status of the matched L1 response, when one was returned. */
89
+ status?: number;
90
+ /**
91
+ * Stored cache status header (CACHE_STATUS_HEADER): "HIT" or "REVALIDATING".
92
+ * Distinct from `isRevalidating`, which also factors in stamp recency -- this
93
+ * is the raw stored value, so a REVALIDATING entry whose stamp aged out (so
94
+ * `isRevalidating` is false) is still distinguishable from a plain HIT.
95
+ */
96
+ cacheStatus?: string | null;
97
+ /** Epoch-ms when the entry goes stale (from CACHE_STALE_AT_HEADER). */
98
+ staleAt?: number;
99
+ /** Epoch-ms the entry was marked REVALIDATING (from the explicit stamp). */
100
+ revalidatingAt?: number;
101
+ /** Raw CF `Age` header, for comparison against revalidatingAt (may be null). */
102
+ ageHeader?: string | null;
103
+ isStale?: boolean;
104
+ isRevalidating?: boolean;
105
+ shouldRevalidate?: boolean;
106
+ /** Wall-clock ms spent in cache.match (bounded by edgeLookupTimeoutMs). */
107
+ matchMs?: number;
108
+ /**
109
+ * Wall-clock ms spent resolving the entry's tag-invalidation markers (the
110
+ * per-request memo -> optional per-colo L1 marker cache -> KV cascade), for a
111
+ * tagged entry. 0/absent for an untagged entry or a memo hit; a non-trivial
112
+ * value is the serial marker-read tail that sits between matchMs and
113
+ * bodyReadMs. Only measured when debug is enabled.
114
+ */
115
+ markerMs?: number;
116
+ /** Wall-clock ms spent reading the body (bounded by edgeReadTimeoutMs). */
117
+ bodyReadMs?: number;
118
+ }
119
+
120
+ /**
121
+ * Debug sink. `true` logs each {@link CFCacheReadDebugEvent} to console; a
122
+ * function receives the events for programmatic capture.
123
+ */
124
+ export type CFCacheDebug = boolean | ((event: CFCacheReadDebugEvent) => void);
125
+
126
+ export interface CFCacheStoreOptions<TEnv = unknown> {
127
+ /**
128
+ * Cache namespace. If not provided, uses caches.default (recommended).
129
+ * Only set this if you need isolated cache storage.
130
+ */
131
+ namespace?: string;
132
+
133
+ /**
134
+ * Base URL for cache keys.
135
+ *
136
+ * If not provided, derives from request hostname via requestContext:
137
+ * - Production domains → uses `https://{hostname}/`
138
+ * - Dev/preview (localhost, workers.dev, pages.dev) → uses internal fallback URL
139
+ */
140
+ baseUrl?: string;
141
+
142
+ /** Default cache options */
143
+ defaults?: CacheDefaults;
144
+
145
+ /**
146
+ * Cloudflare ExecutionContext for non-blocking cache writes.
147
+ * Pass the `ctx` from your worker's fetch handler.
148
+ *
149
+ * @example
150
+ * ```typescript
151
+ * new CFCacheStore({ ctx: env.ctx })
152
+ * ```
153
+ */
154
+ ctx: ExecutionContext;
155
+
156
+ /**
157
+ * Optional KV namespace for L2 cache persistence.
158
+ *
159
+ * When provided, KV acts as a global fallback behind the per-colo Cache API.
160
+ * On L1 miss, KV is checked and hits are promoted back to L1.
161
+ * On writes, data is persisted to both L1 and KV.
162
+ *
163
+ * @example
164
+ * ```typescript
165
+ * new CFCacheStore({ ctx: env.ctx, kv: env.CACHE_KV })
166
+ * ```
167
+ *
168
+ * Tag-based invalidation (updateTag/revalidateTag) requires KV: the
169
+ * tag-invalidation markers are stored in this same namespace. There is no
170
+ * separate tag-invalidation store to configure.
171
+ */
172
+ kv?: KVNamespace;
173
+
174
+ /**
175
+ * Optional eager-purge hook, called ONCE per updateTag()/revalidateTag() with
176
+ * the namespaced Cloudflare Cache-Tags to purge (one batched call for the
177
+ * whole invalidation, not one per tag). These exactly match the `Cache-Tag`
178
+ * header this store writes on its tag-lookup marker entries
179
+ * (`rg:{namespace}:lk:{encodedTag}`), so forwarding them to Cloudflare's
180
+ * purge-by-tag API evicts the cached lookups in every colo - making
181
+ * cross-colo invalidation prompt instead of waiting out `tagCacheTtl`.
182
+ *
183
+ * Only meaningful with `tagCacheTtl > 0` (otherwise there are no cached
184
+ * lookups to purge). The values are pre-encoded, so commas in tag names are
185
+ * safe to pass straight to the purge API.
186
+ *
187
+ * @example
188
+ * ```ts
189
+ * onRevalidateTag: async (cacheTags) => {
190
+ * await fetch(`https://api.cloudflare.com/client/v4/zones/${ZONE}/purge_cache`, {
191
+ * method: "POST",
192
+ * headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json" },
193
+ * body: JSON.stringify({ tags: cacheTags }),
194
+ * });
195
+ * }
196
+ * ```
197
+ */
198
+ onRevalidateTag?: (cacheTags: string[]) => Promise<void>;
199
+
200
+ /**
201
+ * Optional expiration (seconds) for tag-invalidation markers in KV. A marker
202
+ * must outlive every entry tagged before the invalidation, so this MUST
203
+ * exceed your largest entry TTL+SWR. Defaults to no expiration (markers
204
+ * persist; they are tiny - one timestamp per distinct invalidated tag).
205
+ *
206
+ * Note the opposite sizing from `tagCacheTtl` below: `tagInvalidationTtl` must
207
+ * be LARGE (outlive data); `tagCacheTtl` should be SMALL (a staleness ceiling).
208
+ *
209
+ * Cardinality matters: each DISTINCT invalidated tag writes one permanent KV
210
+ * marker (with the no-expiry default). Keep tags LOW-cardinality and never
211
+ * derive an invalidation tag from untrusted input (e.g.
212
+ * `revalidateTag(req.query.tag)`) - an attacker could otherwise grow your KV
213
+ * namespace without bound. Set a `tagInvalidationTtl` only if your tags are
214
+ * unavoidably high-cardinality AND it can still safely exceed your max entry
215
+ * TTL+SWR.
216
+ */
217
+ tagInvalidationTtl?: number;
218
+
219
+ /**
220
+ * Optional TTL (seconds) for caching tag-invalidation markers in the per-colo
221
+ * Cache API (L1), to avoid a KV marker read on every tagged cache read.
222
+ *
223
+ * Default `0` = disabled: the marker is read from KV on every tagged read
224
+ * (today's behavior), giving the strongest cross-colo invalidation latency
225
+ * (~KV consistency). A positive value caches each marker (including the
226
+ * "no marker yet" state) in L1 for that many seconds, so within the window a
227
+ * colo answers from L1 with no KV read.
228
+ *
229
+ * The colo that runs `updateTag`/`revalidateTag` writes the fresh marker
230
+ * straight into its own L1 (write-through), so the invalidating request and
231
+ * later reads in that colo observe the invalidation immediately. One caveat: a
232
+ * read already in flight when the invalidation lands (one that began its KV
233
+ * marker fetch first) can re-cache the PRIOR marker into L1 after the
234
+ * write-through, so a racing concurrent reader in the same colo may miss the
235
+ * invalidation for up to `tagCacheTtl` -- the Cache API exposes no
236
+ * compare-and-set to close this fully. `tagCacheTtl` is therefore a staleness
237
+ * CEILING, not a promise of zero same-colo latency; keep it small (or wire
238
+ * `onRevalidateTag`) when that matters. By default OTHER colos only converge
239
+ * when their cached marker expires, so `tagCacheTtl` is the MAXIMUM extra
240
+ * cross-colo invalidation latency for them. Recommended 30-60 for high-read,
241
+ * low-mutation tags; leave at 0 when prompt global invalidation matters and
242
+ * you cannot wire a purge.
243
+ *
244
+ * To make other colos prompt WITHOUT a short TTL, wire `onRevalidateTag` to a
245
+ * Cloudflare purge-by-tag call: each marker entry carries a namespaced
246
+ * `Cache-Tag`, and `onRevalidateTag` is handed exactly those tags to purge, so
247
+ * the cached lookups are evicted everywhere on invalidation. With a purge
248
+ * wired, `tagCacheTtl` becomes purely a read-cost reducer + fallback window
249
+ * (safe to set large) rather than the invalidation-latency ceiling.
250
+ */
251
+ tagCacheTtl?: number;
252
+
253
+ /**
254
+ * Cache version string override. When this changes, all cached entries are
255
+ * effectively invalidated (new keys won't match old entries).
256
+ *
257
+ * Defaults to the auto-generated VERSION from the `@rangojs/router:version` virtual module.
258
+ * Only set this if you need a custom versioning strategy.
259
+ */
260
+ version?: string;
261
+
262
+ /**
263
+ * Latency budget (ms) for an L1 edge cache (CF Cache API) read. A `match`
264
+ * slower than this is abandoned and treated as a miss, so a degraded colo
265
+ * cannot stall the request; the read then falls through to its normal miss
266
+ * path (L2/KV or render).
267
+ *
268
+ * Defaults to {@link EDGE_LOOKUP_TIMEOUT_MS} (10). Set to 0 (or any value
269
+ * <= 0) to disable the budget and always await `match`.
270
+ */
271
+ edgeLookupTimeoutMs?: number;
272
+
273
+ /**
274
+ * Latency budget (ms) for reading the BODY of a matched L1 entry
275
+ * (response.json()). CF streams the cache body lazily, so the multi-second
276
+ * tail can appear after `match` already resolved; this bounds it. On timeout
277
+ * the read is treated as a miss and falls through to L2/KV or render.
278
+ *
279
+ * Separate from {@link edgeLookupTimeoutMs} because a healthy body read
280
+ * (fetch + JSON parse of a potentially large Flight payload) takes a little
281
+ * longer than a `match`. Defaults to {@link EDGE_READ_TIMEOUT_MS} (20), which
282
+ * clears a healthy per-colo read yet fails fast on a degraded one. Set to 0
283
+ * (or any value <= 0) to disable and always await the body.
284
+ */
285
+ edgeReadTimeoutMs?: number;
286
+
287
+ /**
288
+ * Latency budget (ms) for an L2 (KV) read. KV is the last cache tier before a
289
+ * full render and is a global store (~50ms healthy, seconds when degraded);
290
+ * this bounds it so a slow namespace cannot pin the request. On timeout the
291
+ * read is treated as a miss (no L1 promote) and falls through to render.
292
+ *
293
+ * Defaults to {@link KV_READ_TIMEOUT_MS} (170) -- a few multiples above the
294
+ * ~50ms healthy read, with headroom for legitimate tails (large payloads / far
295
+ * regions) yet still well under a degraded namespace's multi-second tail.
296
+ * Lower it for a tighter SLA, raise it if your healthy KV p99 runs higher; it
297
+ * is a degradation guard-rail, not a tuning lever. Set to 0 (or any value
298
+ * <= 0) to disable and always await KV.
299
+ */
300
+ kvReadTimeoutMs?: number;
301
+
302
+ /**
303
+ * Emit a {@link CFCacheReadDebugEvent} per L1 read. `true` logs to console
304
+ * (visible via `wrangler tail`); pass a function to capture events directly.
305
+ * Off by default. Intended for validating cache behavior on a real
306
+ * deployment before relying on it; not for steady-state production.
307
+ */
308
+ debug?: CFCacheDebug;
309
+
310
+ /**
311
+ * Custom key generator applied to all cache operations.
312
+ * Receives the full RequestContext (including env) and the default-generated key.
313
+ * Return value becomes the final cache key (unless route overrides with `key` option).
314
+ *
315
+ * Reserved prefixes: tag-invalidation markers live in the SAME KV namespace as
316
+ * data, keyed `__tag__/<tag>` (and `__tagmarker__/<tag>` for the L1 cache). A
317
+ * returned key must NOT begin with `__tag__/` or `__tagmarker__/`, or it can
318
+ * collide with a tag marker and corrupt invalidation. The documented
319
+ * prepend-style generators below are safe.
320
+ *
321
+ * @example Using headers for user segmentation
322
+ * ```typescript
323
+ * keyGenerator: (ctx, defaultKey) => {
324
+ * const segment = ctx.request.headers.get('x-user-segment') || 'default';
325
+ * return `${segment}:${defaultKey}`;
326
+ * }
327
+ * ```
328
+ *
329
+ * @example Using env bindings for multi-region
330
+ * ```typescript
331
+ * keyGenerator: (ctx, defaultKey) => {
332
+ * const region = ctx.env.REGION || 'us';
333
+ * return `${region}:${defaultKey}`;
334
+ * }
335
+ * ```
336
+ *
337
+ * @example Using cookies for locale-aware caching
338
+ * ```typescript
339
+ * keyGenerator: (ctx, defaultKey) => {
340
+ * const locale = cookies().get('locale')?.value || 'en';
341
+ * return `${locale}:${defaultKey}`;
342
+ * }
343
+ * ```
344
+ */
345
+ keyGenerator?: (
346
+ ctx: RequestContext<TEnv>,
347
+ defaultKey: string,
348
+ ) => string | Promise<string>;
349
+ }
@@ -0,0 +1,46 @@
1
+ // ============================================================================
2
+ // KV utilities
3
+ // ============================================================================
4
+ //
5
+ // Pure helpers for the CF cache store's KV (L2) tier: key byte-length limits,
6
+ // the expirationTtl floor, and the stale-path Cache-Control recompute. None of
7
+ // these reference the store instance, so they live here as standalone functions.
8
+
9
+ import { CACHE_EXPIRES_AT_HEADER } from "./cf-cache-constants.js";
10
+
11
+ /** KV key byte-length ceiling. Cloudflare KV rejects keys larger than this. */
12
+ export const KV_MAX_KEY_BYTES = 512;
13
+
14
+ /**
15
+ * Cloudflare KV's minimum `expirationTtl` (seconds). A `put` with a smaller
16
+ * expirationTtl is rejected outright. Tag-invalidation markers (the only writes
17
+ * that take a consumer-supplied TTL via tagInvalidationTtl) are floored to this
18
+ * so a too-small value cannot make EVERY updateTag/revalidateTag throw.
19
+ */
20
+ export const KV_MIN_EXPIRATION_TTL = 60;
21
+
22
+ const kvKeyEncoder = new TextEncoder();
23
+
24
+ /** UTF-8 byte length of a KV key (multibyte tags can exceed the char count). */
25
+ export function kvKeyByteLength(key: string): number {
26
+ return kvKeyEncoder.encode(key).length;
27
+ }
28
+
29
+ /**
30
+ * Compute the Cache-Control directive for a stale-path REVALIDATING re-put from
31
+ * the entry's stored hard-expiry deadline (CACHE_EXPIRES_AT_HEADER). Returns the
32
+ * REMAINING ttl so the re-put preserves the original retention deadline instead
33
+ * of restarting it -- copying set()'s original full-window max-age would reset
34
+ * CF's retention clock on every re-arm and pin a perpetually-stale entry forever.
35
+ * An entry lacking a valid deadline (legacy/tampered) floors to max-age=1, so it
36
+ * hard-expires in ~1s and self-heals via KV. Mirrors promoteSegmentToL1's math.
37
+ * @internal
38
+ */
39
+ export function remainingCacheControl(headers: Headers, now: number): string {
40
+ const expiresAt = Number(headers.get(CACHE_EXPIRES_AT_HEADER));
41
+ const remainingTtl =
42
+ Number.isFinite(expiresAt) && expiresAt > 0
43
+ ? Math.max(1, Math.floor((expiresAt - now) / 1000))
44
+ : 1;
45
+ return `public, max-age=${remainingTtl}`;
46
+ }
@@ -0,0 +1,105 @@
1
+ // ============================================================================
2
+ // Tag-marker memo
3
+ // ============================================================================
4
+ //
5
+ // Per-request memoization of tag-invalidation marker reads, plus the prefix and
6
+ // sentinel used by the optional per-colo L1 marker cache. Extracted from
7
+ // cf-cache-store.ts; the WeakMaps stay MODULE SINGLETONS here (the module is
8
+ // evaluated once), so binding-keyed memo semantics are unchanged: the maps are
9
+ // keyed by the request-context object then the store INSTANCE, identical to
10
+ // before the move.
11
+
12
+ /**
13
+ * Cache-API path prefix for the optional per-colo L1 cache of tag-invalidation
14
+ * markers (enabled by tagCacheTtl). Distinct from data keys (doc:/fn:/segment)
15
+ * and from the KV marker prefix so the two never collide.
16
+ */
17
+ export const TAG_MARKER_CACHE_PREFIX = "__tagmarker__/";
18
+
19
+ /**
20
+ * Sentinel body for an L1-cached marker meaning "this tag has no invalidation
21
+ * marker." Distinct from any real ms-epoch timestamp (always a large positive
22
+ * integer). A Cache API miss (match() === undefined) always means "re-read KV",
23
+ * never "no marker" - absence is only ever represented by this cached sentinel.
24
+ */
25
+ export const TAG_MARKER_ABSENT = "none";
26
+
27
+ /**
28
+ * Per-request memo of tag-invalidation markers (tag -> latest invalidatedAt, or
29
+ * null when no marker exists). Keyed first by the request context object (so it
30
+ * is naturally request-scoped and garbage-collected with the request) and then
31
+ * by the store INSTANCE.
32
+ *
33
+ * The per-store nesting matters because a single request can run more than one
34
+ * CFCacheStore - the app-level store plus a route's `cache({ store })` override,
35
+ * which may point at a DIFFERENT KV binding or version. A module-level map keyed
36
+ * by request alone (the inner map keyed by the raw tag name) would let store B's
37
+ * memoized marker for a tag mask store A's own KV marker, so A could serve an
38
+ * entry A's own KV says is invalidated. Keying by the instance isolates them;
39
+ * two reads through the SAME store still share the memo. A read through one
40
+ * store never populates another's memo, so each store always consults its own KV
41
+ * binding. Markers are read only through isGloballyInvalidated(), which already
42
+ * short-circuits when a store has no KV, so a store without KV never allocates.
43
+ *
44
+ * Without the memo, isGloballyInvalidated() issues a KV read per tag on every
45
+ * tagged cache read, so a page composed of many segments/items sharing a tag
46
+ * pays that cost N times. The memo collapses it to one KV read per distinct tag
47
+ * per (request, store). invalidateTags() writes through so a same-request
48
+ * updateTag() stays read-your-own-writes consistent (the action's own re-render
49
+ * sees its own invalidation from the memo, without a re-read).
50
+ *
51
+ * It does NOT span requests, so a hot single-entry route still pays one KV read
52
+ * per request; that read hits Cloudflare KV's own edge read cache for hot keys.
53
+ */
54
+ const tagMarkerMemo = new WeakMap<
55
+ object,
56
+ WeakMap<object, Map<string, number | null>>
57
+ >();
58
+
59
+ export function getTagMarkerMemo(
60
+ ctx: object,
61
+ store: object,
62
+ ): Map<string, number | null> {
63
+ let byStore = tagMarkerMemo.get(ctx);
64
+ if (!byStore) {
65
+ byStore = new WeakMap();
66
+ tagMarkerMemo.set(ctx, byStore);
67
+ }
68
+ let memo = byStore.get(store);
69
+ if (!memo) {
70
+ memo = new Map();
71
+ byStore.set(store, memo);
72
+ }
73
+ return memo;
74
+ }
75
+
76
+ /**
77
+ * Per-request map of IN-FLIGHT marker reads (tag -> the pending read promise).
78
+ * The resolved-value memo above only collapses SEQUENTIAL reads of a tag; the
79
+ * router resolves sibling segments in PARALLEL, so without this several
80
+ * concurrently-resolving segments sharing a tag would each issue their own KV
81
+ * read before any of them populates the memo. Sharing the in-flight promise
82
+ * collapses those to a single KV read. Entries are dropped once resolved (the
83
+ * value is then in the memo), so this only spans the concurrent read window.
84
+ */
85
+ const tagMarkerInflight = new WeakMap<
86
+ object,
87
+ WeakMap<object, Map<string, Promise<number | null>>>
88
+ >();
89
+
90
+ export function getTagMarkerInflight(
91
+ ctx: object,
92
+ store: object,
93
+ ): Map<string, Promise<number | null>> {
94
+ let byStore = tagMarkerInflight.get(ctx);
95
+ if (!byStore) {
96
+ byStore = new WeakMap();
97
+ tagMarkerInflight.set(ctx, byStore);
98
+ }
99
+ let inflight = byStore.get(store);
100
+ if (!inflight) {
101
+ inflight = new Map();
102
+ byStore.set(store, inflight);
103
+ }
104
+ return inflight;
105
+ }
@@ -65,6 +65,17 @@ function parseCacheControl(header: string | null): CacheDirectives | null {
65
65
  return null;
66
66
  }
67
67
 
68
+ // RFC 7234 §5.2.2.2: a shared cache MUST NOT serve a stored `no-cache`
69
+ // response without successful origin validation. This store's hit path has no
70
+ // validation step, so serving a stored no-cache response within s-maxage
71
+ // would hand the client content the origin marked must-revalidate. Refuse to
72
+ // store it. Only UNqualified `no-cache` (no `=`) vetoes — the field-name-
73
+ // scoped `no-cache="set-cookie"` form IS storable per the RFC, so the `=`
74
+ // boundary is excluded from the lookahead (unlike private/no-store above).
75
+ if (/(^|[\s,;])no-cache(?=$|[\s,;])/i.test(header)) {
76
+ return null;
77
+ }
78
+
68
79
  const directives: CacheDirectives = {};
69
80
 
70
81
  // Parse s-maxage
@@ -9,7 +9,12 @@
9
9
  import type { ResolvedSegment } from "../types.js";
10
10
  import type { HandleStore } from "../server/handle-store.js";
11
11
  import type { SegmentHandleData } from "./types.js";
12
- import { serializeResult, deserializeResult } from "./segment-codec.js";
12
+ // segment-codec eagerly pulls @vitejs/plugin-rsc (a virtual: module unresolvable
13
+ // in plain node/vitest). It is imported LAZILY inside the two async encode/decode
14
+ // helpers below so that modules which import handle-snapshot only for the
15
+ // plugin-rsc-free captureHandles/restoreHandles (e.g. cache-scope, on dispatch's
16
+ // lazy response-route cache path) do not pull plugin-rsc at module load. Behavior
17
+ // is unchanged: both helpers are async and already awaited the codec.
13
18
 
14
19
  const HANDLE_ENCODE_TIMEOUT_MS = 5000;
15
20
 
@@ -52,6 +57,7 @@ export function decodeHandles(encoded: string): Promise<HandleRecord | null> {
52
57
  }
53
58
 
54
59
  export async function encodeHandleValue(value: unknown): Promise<string> {
60
+ const { serializeResult } = await import("./segment-codec.js");
55
61
  const encoded = await withTimeout(
56
62
  serializeResult(value),
57
63
  HANDLE_ENCODE_TIMEOUT_MS,
@@ -67,6 +73,7 @@ export async function encodeHandleValue(value: unknown): Promise<string> {
67
73
  */
68
74
  export async function decodeHandleValue<T>(encoded: string): Promise<T | null> {
69
75
  try {
76
+ const { deserializeResult } = await import("./segment-codec.js");
70
77
  return await deserializeResult<T>(encoded);
71
78
  } catch {
72
79
  return null;
@@ -42,7 +42,31 @@ export function resolveCacheProfiles(
42
42
  `Profile names must match [a-zA-Z0-9_-]+.`,
43
43
  );
44
44
  }
45
- merged[name] = profiles[name];
45
+ const profile = profiles[name];
46
+ // Validate ttl/swr VALUES, not just the name. An unvalidated NaN/Infinity
47
+ // ttl flows into computeExpiration -> staleAt/expiresAt = NaN, and every
48
+ // expiry check (`now > NaN`) is false, so the entry never evicts and never
49
+ // revalidates: it is served fresh forever and accumulates unbounded. A
50
+ // negative ttl makes every read a guaranteed miss. This guard's policy is
51
+ // to FAIL FAST at config time on any non-finite/negative ttl — distinct
52
+ // from prefetch-cache-ttl.ts (falls back to the default) and defer.ts
53
+ // (treats Infinity as an intentional disable). Do not conflate them.
54
+ if (!Number.isFinite(profile.ttl) || profile.ttl < 0) {
55
+ throw new Error(
56
+ `Invalid cache profile "${name}": ttl must be a finite non-negative ` +
57
+ `number (got ${profile.ttl}).`,
58
+ );
59
+ }
60
+ if (
61
+ profile.swr !== undefined &&
62
+ (!Number.isFinite(profile.swr) || profile.swr < 0)
63
+ ) {
64
+ throw new Error(
65
+ `Invalid cache profile "${name}": swr must be a finite non-negative ` +
66
+ `number (got ${profile.swr}).`,
67
+ );
68
+ }
69
+ merged[name] = profile;
46
70
  }
47
71
  }
48
72
  return merged;
@@ -10,6 +10,7 @@
10
10
 
11
11
  import type { ResolvedSegment } from "../types.js";
12
12
  import type { SerializedSegmentData } from "./types.js";
13
+ import { INTERNAL_RANGO_DEBUG } from "../internal-debug.js";
13
14
  import {
14
15
  renderToReadableStream,
15
16
  createTemporaryReferenceSet,
@@ -95,7 +96,14 @@ export async function serializeResult(value: unknown): Promise<string | null> {
95
96
  const temporaryReferences = createTemporaryReferenceSet();
96
97
  const stream = renderToReadableStream(value, { temporaryReferences });
97
98
  return await streamToString(stream);
98
- } catch {
99
+ } catch (error) {
100
+ // Returning null silently turns a non-serializable cache value into a
101
+ // permanent miss with no trace. Surface it on the internal debug channel so
102
+ // a failed serialize is diagnosable on wrangler tail, but keep returning
103
+ // null so the caller falls through to an uncached render rather than throws.
104
+ if (INTERNAL_RANGO_DEBUG) {
105
+ console.warn("[segment-codec] serializeResult failed:", error);
106
+ }
99
107
  return null;
100
108
  }
101
109
  }
@@ -259,11 +259,15 @@ export interface CacheDefaults {
259
259
  /**
260
260
  * Default time-to-live in seconds.
261
261
  * After TTL expires, cached entry is considered stale.
262
+ * Must be a finite, non-negative number; an invalid value (NaN/Infinity/
263
+ * negative) falls back to the default at read time.
262
264
  */
263
265
  ttl?: number;
264
266
  /**
265
267
  * Default stale-while-revalidate window in seconds.
266
268
  * During SWR window, stale content is served while revalidating in background.
269
+ * Must be a finite, non-negative number; an invalid value (NaN/Infinity/
270
+ * negative) falls back to the default at read time.
267
271
  */
268
272
  swr?: number;
269
273
  }
@@ -20,8 +20,40 @@ export {
20
20
  type ErrorBoundaryProps,
21
21
  } from "./client.js";
22
22
 
23
+ export {
24
+ useFetchLoader,
25
+ useRefreshLoaders,
26
+ type LoadFunction,
27
+ type UseLoaderResult,
28
+ type UseFetchLoaderResult,
29
+ type UseLoaderOptions,
30
+ } from "./use-loader.js";
31
+
23
32
  export { createLoader } from "./route-definition.js";
24
33
 
34
+ // "use client" hooks the default ./client entry exports. They are client
35
+ // references in the RSC graph, identical in kind to useHref/useReverse/
36
+ // useHandle already forwarded below; forward them so the RSC client entry's
37
+ // hook surface matches the default entry. useNavigation/useAction stay omitted
38
+ // (they drive client-only navigation/action state — see note below).
39
+ export { useRouter } from "./browser/react/use-router.js";
40
+ export { usePathname } from "./browser/react/use-pathname.js";
41
+ export { useSearchParams } from "./browser/react/use-search-params.js";
42
+ export { useParams } from "./browser/react/use-params.js";
43
+ // CSP nonce for userland head-script components (analytics/GTM/inline init);
44
+ // forwarded so the RSC client entry's hook surface matches the default entry.
45
+ export { useNonce } from "./browser/react/nonce-context.js";
46
+ export { useMount } from "./browser/react/use-mount.js";
47
+ export {
48
+ useSegments,
49
+ type SegmentsState,
50
+ } from "./browser/react/use-segments.js";
51
+ export {
52
+ useLinkStatus,
53
+ type LinkStatus,
54
+ } from "./browser/react/use-link-status.js";
55
+ export { useScrollRestoration } from "./browser/react/ScrollRestoration.js";
56
+
25
57
  export {
26
58
  Link,
27
59
  type LinkProps,
@@ -49,6 +81,12 @@ export { createHandle, isHandle, type Handle } from "./handle.js";
49
81
  export { Meta } from "./handles/meta.js";
50
82
  export { MetaTags } from "./handles/MetaTags.js";
51
83
  export type { MetaDescriptor, MetaDescriptorBase } from "./router/types.js";
84
+ export {
85
+ Script,
86
+ type ScriptConfig,
87
+ type ScriptAttributes,
88
+ } from "./handles/script.js";
89
+ export { Scripts } from "./handles/Scripts.js";
52
90
  export { Breadcrumbs, type BreadcrumbItem } from "./handles/breadcrumbs.js";
53
91
 
54
92
  export {