@rangojs/router 0.0.0-experimental.259 → 0.0.0-experimental.26

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 (225) hide show
  1. package/README.md +294 -28
  2. package/dist/bin/rango.js +355 -47
  3. package/dist/vite/index.js +1658 -1239
  4. package/package.json +3 -3
  5. package/skills/cache-guide/SKILL.md +9 -5
  6. package/skills/caching/SKILL.md +4 -4
  7. package/skills/document-cache/SKILL.md +2 -2
  8. package/skills/hooks/SKILL.md +40 -29
  9. package/skills/host-router/SKILL.md +218 -0
  10. package/skills/intercept/SKILL.md +79 -0
  11. package/skills/layout/SKILL.md +62 -2
  12. package/skills/loader/SKILL.md +229 -15
  13. package/skills/middleware/SKILL.md +109 -30
  14. package/skills/parallel/SKILL.md +57 -2
  15. package/skills/prerender/SKILL.md +189 -19
  16. package/skills/rango/SKILL.md +1 -2
  17. package/skills/response-routes/SKILL.md +3 -3
  18. package/skills/route/SKILL.md +44 -3
  19. package/skills/router-setup/SKILL.md +80 -3
  20. package/skills/theme/SKILL.md +5 -4
  21. package/skills/typesafety/SKILL.md +59 -16
  22. package/skills/use-cache/SKILL.md +16 -2
  23. package/src/__internal.ts +1 -1
  24. package/src/bin/rango.ts +56 -19
  25. package/src/browser/action-coordinator.ts +97 -0
  26. package/src/browser/event-controller.ts +29 -48
  27. package/src/browser/history-state.ts +80 -0
  28. package/src/browser/intercept-utils.ts +1 -1
  29. package/src/browser/link-interceptor.ts +19 -3
  30. package/src/browser/merge-segment-loaders.ts +9 -2
  31. package/src/browser/navigation-bridge.ts +66 -443
  32. package/src/browser/navigation-client.ts +34 -62
  33. package/src/browser/navigation-store.ts +4 -33
  34. package/src/browser/navigation-transaction.ts +295 -0
  35. package/src/browser/partial-update.ts +103 -151
  36. package/src/browser/prefetch/cache.ts +67 -0
  37. package/src/browser/prefetch/fetch.ts +137 -0
  38. package/src/browser/prefetch/observer.ts +65 -0
  39. package/src/browser/prefetch/policy.ts +42 -0
  40. package/src/browser/prefetch/queue.ts +88 -0
  41. package/src/browser/rango-state.ts +112 -0
  42. package/src/browser/react/Link.tsx +154 -44
  43. package/src/browser/react/NavigationProvider.tsx +32 -0
  44. package/src/browser/react/context.ts +6 -0
  45. package/src/browser/react/filter-segment-order.ts +11 -0
  46. package/src/browser/react/index.ts +2 -6
  47. package/src/browser/react/location-state-shared.ts +29 -11
  48. package/src/browser/react/location-state.ts +6 -4
  49. package/src/browser/react/nonce-context.ts +23 -0
  50. package/src/browser/react/shallow-equal.ts +27 -0
  51. package/src/browser/react/use-action.ts +23 -45
  52. package/src/browser/react/use-client-cache.ts +5 -3
  53. package/src/browser/react/use-handle.ts +21 -64
  54. package/src/browser/react/use-navigation.ts +7 -32
  55. package/src/browser/react/use-params.ts +5 -34
  56. package/src/browser/react/use-pathname.ts +2 -3
  57. package/src/browser/react/use-router.ts +3 -6
  58. package/src/browser/react/use-search-params.ts +2 -1
  59. package/src/browser/react/use-segments.ts +75 -114
  60. package/src/browser/response-adapter.ts +73 -0
  61. package/src/browser/rsc-router.tsx +46 -22
  62. package/src/browser/scroll-restoration.ts +10 -7
  63. package/src/browser/server-action-bridge.ts +458 -405
  64. package/src/browser/types.ts +21 -35
  65. package/src/browser/validate-redirect-origin.ts +29 -0
  66. package/src/build/generate-manifest.ts +38 -13
  67. package/src/build/generate-route-types.ts +4 -0
  68. package/src/build/index.ts +1 -0
  69. package/src/build/route-trie.ts +19 -3
  70. package/src/build/route-types/codegen.ts +13 -4
  71. package/src/build/route-types/include-resolution.ts +13 -0
  72. package/src/build/route-types/per-module-writer.ts +15 -3
  73. package/src/build/route-types/router-processing.ts +170 -18
  74. package/src/build/runtime-discovery.ts +13 -1
  75. package/src/cache/background-task.ts +34 -0
  76. package/src/cache/cache-key-utils.ts +44 -0
  77. package/src/cache/cache-policy.ts +125 -0
  78. package/src/cache/cache-runtime.ts +136 -123
  79. package/src/cache/cache-scope.ts +76 -83
  80. package/src/cache/cf/cf-cache-store.ts +12 -7
  81. package/src/cache/document-cache.ts +93 -69
  82. package/src/cache/handle-capture.ts +81 -0
  83. package/src/cache/index.ts +0 -15
  84. package/src/cache/memory-segment-store.ts +43 -69
  85. package/src/cache/profile-registry.ts +43 -8
  86. package/src/cache/read-through-swr.ts +134 -0
  87. package/src/cache/segment-codec.ts +140 -117
  88. package/src/cache/taint.ts +30 -3
  89. package/src/cache/types.ts +1 -115
  90. package/src/client.rsc.tsx +0 -1
  91. package/src/client.tsx +53 -76
  92. package/src/errors.ts +6 -1
  93. package/src/handle.ts +1 -1
  94. package/src/handles/MetaTags.tsx +5 -2
  95. package/src/host/cookie-handler.ts +8 -3
  96. package/src/host/index.ts +0 -3
  97. package/src/host/router.ts +14 -1
  98. package/src/href-client.ts +3 -1
  99. package/src/index.rsc.ts +53 -10
  100. package/src/index.ts +73 -43
  101. package/src/loader.rsc.ts +12 -4
  102. package/src/loader.ts +8 -0
  103. package/src/prerender/store.ts +60 -18
  104. package/src/prerender.ts +76 -18
  105. package/src/reverse.ts +11 -7
  106. package/src/root-error-boundary.tsx +30 -26
  107. package/src/route-definition/dsl-helpers.ts +9 -6
  108. package/src/route-definition/index.ts +0 -3
  109. package/src/route-definition/redirect.ts +15 -3
  110. package/src/route-map-builder.ts +38 -2
  111. package/src/route-name.ts +53 -0
  112. package/src/route-types.ts +7 -0
  113. package/src/router/content-negotiation.ts +1 -1
  114. package/src/router/debug-manifest.ts +16 -3
  115. package/src/router/handler-context.ts +96 -17
  116. package/src/router/intercept-resolution.ts +6 -4
  117. package/src/router/lazy-includes.ts +4 -0
  118. package/src/router/loader-resolution.ts +6 -11
  119. package/src/router/logging.ts +100 -3
  120. package/src/router/manifest.ts +32 -3
  121. package/src/router/match-api.ts +62 -54
  122. package/src/router/match-context.ts +3 -0
  123. package/src/router/match-handlers.ts +185 -11
  124. package/src/router/match-middleware/background-revalidation.ts +65 -85
  125. package/src/router/match-middleware/cache-lookup.ts +78 -10
  126. package/src/router/match-middleware/cache-store.ts +2 -0
  127. package/src/router/match-pipelines.ts +8 -43
  128. package/src/router/match-result.ts +0 -9
  129. package/src/router/metrics.ts +233 -13
  130. package/src/router/middleware-types.ts +34 -39
  131. package/src/router/middleware.ts +290 -130
  132. package/src/router/pattern-matching.ts +61 -10
  133. package/src/router/prerender-match.ts +36 -6
  134. package/src/router/preview-match.ts +7 -1
  135. package/src/router/revalidation.ts +61 -2
  136. package/src/router/router-context.ts +15 -0
  137. package/src/router/router-interfaces.ts +158 -40
  138. package/src/router/router-options.ts +223 -1
  139. package/src/router/router-registry.ts +5 -2
  140. package/src/router/segment-resolution/fresh.ts +165 -242
  141. package/src/router/segment-resolution/helpers.ts +263 -0
  142. package/src/router/segment-resolution/loader-cache.ts +102 -98
  143. package/src/router/segment-resolution/revalidation.ts +394 -272
  144. package/src/router/segment-resolution/static-store.ts +2 -2
  145. package/src/router/segment-resolution.ts +1 -3
  146. package/src/router/segment-wrappers.ts +3 -0
  147. package/src/router/telemetry-otel.ts +299 -0
  148. package/src/router/telemetry.ts +300 -0
  149. package/src/router/timeout.ts +148 -0
  150. package/src/router/trie-matching.ts +20 -2
  151. package/src/router/types.ts +7 -1
  152. package/src/router.ts +203 -18
  153. package/src/rsc/handler-context.ts +13 -2
  154. package/src/rsc/handler.ts +489 -438
  155. package/src/rsc/helpers.ts +125 -5
  156. package/src/rsc/index.ts +0 -20
  157. package/src/rsc/loader-fetch.ts +84 -42
  158. package/src/rsc/manifest-init.ts +3 -2
  159. package/src/rsc/origin-guard.ts +141 -0
  160. package/src/rsc/progressive-enhancement.ts +245 -19
  161. package/src/rsc/response-route-handler.ts +347 -0
  162. package/src/rsc/rsc-rendering.ts +47 -43
  163. package/src/rsc/runtime-warnings.ts +42 -0
  164. package/src/rsc/server-action.ts +166 -66
  165. package/src/rsc/ssr-setup.ts +128 -0
  166. package/src/rsc/types.ts +20 -2
  167. package/src/search-params.ts +38 -23
  168. package/src/server/context.ts +61 -7
  169. package/src/server/cookie-store.ts +190 -0
  170. package/src/server/fetchable-loader-store.ts +11 -6
  171. package/src/server/handle-store.ts +84 -12
  172. package/src/server/loader-registry.ts +11 -46
  173. package/src/server/request-context.ts +275 -49
  174. package/src/server.ts +6 -0
  175. package/src/ssr/index.tsx +67 -28
  176. package/src/static-handler.ts +7 -0
  177. package/src/theme/ThemeProvider.tsx +6 -1
  178. package/src/theme/index.ts +4 -18
  179. package/src/theme/theme-context.ts +1 -28
  180. package/src/theme/theme-script.ts +2 -1
  181. package/src/types/cache-types.ts +6 -1
  182. package/src/types/error-types.ts +3 -0
  183. package/src/types/global-namespace.ts +22 -0
  184. package/src/types/handler-context.ts +103 -16
  185. package/src/types/index.ts +1 -1
  186. package/src/types/loader-types.ts +9 -6
  187. package/src/types/route-config.ts +17 -26
  188. package/src/types/route-entry.ts +28 -0
  189. package/src/types/segments.ts +0 -5
  190. package/src/urls/include-helper.ts +49 -8
  191. package/src/urls/index.ts +1 -0
  192. package/src/urls/path-helper-types.ts +30 -12
  193. package/src/urls/path-helper.ts +17 -2
  194. package/src/urls/pattern-types.ts +21 -1
  195. package/src/urls/response-types.ts +29 -7
  196. package/src/urls/type-extraction.ts +23 -15
  197. package/src/use-loader.tsx +27 -9
  198. package/src/vite/discovery/bundle-postprocess.ts +32 -52
  199. package/src/vite/discovery/discover-routers.ts +52 -26
  200. package/src/vite/discovery/prerender-collection.ts +58 -41
  201. package/src/vite/discovery/route-types-writer.ts +7 -7
  202. package/src/vite/discovery/state.ts +7 -7
  203. package/src/vite/discovery/virtual-module-codegen.ts +5 -2
  204. package/src/vite/index.ts +10 -51
  205. package/src/vite/plugins/client-ref-dedup.ts +115 -0
  206. package/src/vite/plugins/client-ref-hashing.ts +3 -3
  207. package/src/vite/plugins/expose-internal-ids.ts +4 -3
  208. package/src/vite/plugins/refresh-cmd.ts +65 -0
  209. package/src/vite/plugins/use-cache-transform.ts +91 -3
  210. package/src/vite/plugins/version-plugin.ts +188 -18
  211. package/src/vite/rango.ts +61 -36
  212. package/src/vite/router-discovery.ts +173 -100
  213. package/src/vite/utils/prerender-utils.ts +81 -0
  214. package/src/vite/utils/shared-utils.ts +19 -9
  215. package/skills/testing/SKILL.md +0 -226
  216. package/src/browser/lru-cache.ts +0 -61
  217. package/src/browser/react/prefetch.ts +0 -27
  218. package/src/browser/request-controller.ts +0 -164
  219. package/src/cache/memory-store.ts +0 -253
  220. package/src/href-context.ts +0 -33
  221. package/src/route-definition/route-function.ts +0 -119
  222. package/src/router.gen.ts +0 -6
  223. package/src/static-handler.gen.ts +0 -5
  224. package/src/urls.gen.ts +0 -8
  225. /package/{CLAUDE.md → AGENTS.md} +0 -0
@@ -12,24 +12,18 @@ import type { PartialCacheOptions } from "../types.js";
12
12
  import type { ResolvedSegment } from "../types.js";
13
13
  import type { SegmentCacheStore, CachedEntryData } from "./types.js";
14
14
  import { INTERNAL_RANGO_DEBUG } from "../internal-debug.js";
15
- import { getRequestContext } from "../server/request-context.js";
15
+ import {
16
+ getRequestContext,
17
+ _getRequestContext,
18
+ } from "../server/request-context.js";
16
19
  import { serializeSegments, deserializeSegments } from "./segment-codec.js";
17
20
  import { captureHandles, restoreHandles } from "./handle-snapshot.js";
18
-
19
- // Re-export codec functions for backwards compatibility.
20
- // Existing call sites import these from cache-scope.ts via dynamic import.
21
- export {
22
- deserializeComponent,
23
- serializeSegments,
24
- deserializeSegments,
25
- } from "./segment-codec.js";
26
-
27
- // ============================================================================
28
- // Constants
29
- // ============================================================================
30
-
31
- /** Default TTL when no explicit value or store defaults are configured */
32
- const DEFAULT_TTL_SECONDS = 60;
21
+ import { sortedSearchString, sortedRouteParams } from "./cache-key-utils.js";
22
+ import {
23
+ DEFAULT_ROUTE_TTL,
24
+ resolveCacheKey,
25
+ resolveCacheStore,
26
+ } from "./cache-policy.js";
33
27
 
34
28
  function debugCacheLog(message: string): void {
35
29
  if (INTERNAL_RANGO_DEBUG) {
@@ -42,27 +36,31 @@ function debugCacheLog(message: string): void {
42
36
  // ============================================================================
43
37
 
44
38
  /**
45
- * Generate cache key base from pathname and params.
46
- * Params are sorted alphabetically for consistent key generation.
39
+ * Generate cache key base from host, pathname, route params, and search params.
40
+ * Host is included to prevent cross-host cache collisions on shared stores.
41
+ * Route params and search params are sorted alphabetically for deterministic keys.
42
+ * Internal _rsc* and __* query params are excluded.
47
43
  * @internal
48
44
  */
49
45
  function getCacheKeyBase(
46
+ host: string,
50
47
  pathname: string,
51
48
  params?: Record<string, string>,
49
+ searchParams?: URLSearchParams,
52
50
  ): string {
53
- const paramStr = params
54
- ? Object.entries(params)
55
- .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
56
- .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
57
- .join("&")
58
- : "";
59
-
60
- return paramStr ? `${pathname}:${paramStr}` : pathname;
51
+ const paramStr = sortedRouteParams(params);
52
+ const searchStr = searchParams ? sortedSearchString(searchParams) : "";
53
+
54
+ let key = `${host}${pathname}`;
55
+ if (paramStr) key += `:${paramStr}`;
56
+ if (searchStr) key += `?${searchStr}`;
57
+ return key;
61
58
  }
62
59
 
63
60
  /**
64
61
  * Generate default cache key for a route request.
65
- * Single cache entry per route - uses pathname as the key.
62
+ * Includes pathname, route params, and user-facing search params for
63
+ * correct scoping. Internal _rsc* params are excluded.
66
64
  * Includes request type prefix since they produce different segment sets:
67
65
  * - doc: document requests (full page load)
68
66
  * - partial: navigation requests (client-side navigation)
@@ -76,11 +74,13 @@ function getDefaultRouteCacheKey(
76
74
  ): string {
77
75
  const ctx = getRequestContext();
78
76
  const isPartial = ctx?.url.searchParams.has("_rsc_partial") ?? false;
77
+ const searchParams = ctx?.url.searchParams;
78
+ const host = ctx?.url.host ?? "localhost";
79
79
 
80
80
  // Intercept navigations get their own cache namespace
81
81
  const prefix = isIntercept ? "intercept" : isPartial ? "partial" : "doc";
82
82
 
83
- return `${prefix}:${getCacheKeyBase(pathname, params)}`;
83
+ return `${prefix}:${getCacheKeyBase(host, pathname, params, searchParams)}`;
84
84
  }
85
85
 
86
86
  // ============================================================================
@@ -145,7 +145,7 @@ export class CacheScope {
145
145
  }
146
146
 
147
147
  // Hardcoded fallback
148
- return DEFAULT_TTL_SECONDS;
148
+ return DEFAULT_ROUTE_TTL;
149
149
  }
150
150
 
151
151
  /**
@@ -170,23 +170,11 @@ export class CacheScope {
170
170
  * 2. App-level store from request context
171
171
  */
172
172
  getStore(): SegmentCacheStore | null {
173
- // Explicit store from cache() options takes precedence
174
- if (this.explicitStore) {
175
- return this.explicitStore;
176
- }
177
- // Fall back to app-level store from request context
178
- const ctx = getRequestContext();
179
- return ctx?._cacheStore ?? null;
173
+ return resolveCacheStore(this.explicitStore);
180
174
  }
181
175
 
182
176
  /**
183
- * Resolve the cache key using custom key functions or default generation.
184
- *
185
- * Resolution priority:
186
- * 1. Route-level `key` function (full override)
187
- * 2. Store-level `keyGenerator` (modifies default key)
188
- * 3. Default key generation (prefix:pathname:params)
189
- *
177
+ * Resolve the cache key using the shared 3-tier priority.
190
178
  * @internal
191
179
  */
192
180
  private async resolveKey(
@@ -194,46 +182,9 @@ export class CacheScope {
194
182
  params: Record<string, string>,
195
183
  isIntercept?: boolean,
196
184
  ): Promise<string> {
197
- const requestCtx = getRequestContext();
198
- if (!requestCtx) {
199
- // Fallback to default key if no request context
200
- return getDefaultRouteCacheKey(pathname, params, isIntercept);
201
- }
202
-
203
- // Priority 1: Route-level key function (full override)
204
- if (this.config !== false && this.config.key) {
205
- try {
206
- const customKey = await this.config.key(requestCtx);
207
- return customKey;
208
- } catch (error) {
209
- console.error(
210
- `[CacheScope] Custom key function failed, using default:`,
211
- error,
212
- );
213
- return getDefaultRouteCacheKey(pathname, params, isIntercept);
214
- }
215
- }
216
-
217
- // Generate default key
218
185
  const defaultKey = getDefaultRouteCacheKey(pathname, params, isIntercept);
219
-
220
- // Priority 2: Store-level keyGenerator (modifies default key)
221
- const store = this.getStore();
222
- if (store?.keyGenerator) {
223
- try {
224
- const modifiedKey = await store.keyGenerator(requestCtx, defaultKey);
225
- return modifiedKey;
226
- } catch (error) {
227
- console.error(
228
- `[CacheScope] Store keyGenerator failed, using default:`,
229
- error,
230
- );
231
- return defaultKey;
232
- }
233
- }
234
-
235
- // Priority 3: Default key
236
- return defaultKey;
186
+ const keyFn = this.config !== false ? this.config.key : undefined;
187
+ return resolveCacheKey(keyFn, this.getStore(), defaultKey, "CacheScope");
237
188
  }
238
189
 
239
190
  /**
@@ -254,6 +205,27 @@ export class CacheScope {
254
205
  } | null> {
255
206
  if (!this.enabled) return null;
256
207
 
208
+ // Evaluate condition — skip cache read when condition returns false
209
+ if (this.config !== false && this.config.condition) {
210
+ const requestCtx = getRequestContext();
211
+ if (requestCtx) {
212
+ try {
213
+ if (!this.config.condition(requestCtx)) {
214
+ debugCacheLog(
215
+ `[CacheScope] condition returned false, skipping cache read`,
216
+ );
217
+ return null;
218
+ }
219
+ } catch (error) {
220
+ console.error(
221
+ `[CacheScope] condition function threw, skipping cache read:`,
222
+ error,
223
+ );
224
+ return null;
225
+ }
226
+ }
227
+ }
228
+
257
229
  const store = this.getStore();
258
230
  if (!store) return null;
259
231
 
@@ -274,7 +246,7 @@ export class CacheScope {
274
246
  const segments = await deserializeSegments(cached.segments);
275
247
 
276
248
  // Replay handle data
277
- const handleStore = getRequestContext()?._handleStore;
249
+ const handleStore = _getRequestContext()?._handleStore;
278
250
  if (handleStore) {
279
251
  restoreHandles(cached.handles, handleStore);
280
252
  }
@@ -313,6 +285,27 @@ export class CacheScope {
313
285
  ): Promise<void> {
314
286
  if (!this.enabled || segments.length === 0) return;
315
287
 
288
+ // Evaluate condition — skip cache write when condition returns false
289
+ if (this.config !== false && this.config.condition) {
290
+ const conditionCtx = getRequestContext();
291
+ if (conditionCtx) {
292
+ try {
293
+ if (!this.config.condition(conditionCtx)) {
294
+ debugCacheLog(
295
+ `[CacheScope] condition returned false, skipping cache write`,
296
+ );
297
+ return;
298
+ }
299
+ } catch (error) {
300
+ console.error(
301
+ `[CacheScope] condition function threw, skipping cache write:`,
302
+ error,
303
+ );
304
+ return;
305
+ }
306
+ }
307
+ }
308
+
316
309
  const store = this.getStore();
317
310
  if (!store) return;
318
311
 
@@ -29,10 +29,15 @@ import type {
29
29
  CacheItemOptions,
30
30
  } from "../types.js";
31
31
  import {
32
- getRequestContext,
32
+ _getRequestContext,
33
33
  type RequestContext,
34
34
  } from "../../server/request-context.js";
35
35
  import { VERSION } from "@rangojs/router:version";
36
+ import {
37
+ resolveTtl,
38
+ resolveSwrWindow,
39
+ DEFAULT_FUNCTION_TTL,
40
+ } from "../cache-policy.js";
36
41
 
37
42
  // ============================================================================
38
43
  // Constants
@@ -126,7 +131,7 @@ export interface CFCacheStoreOptions<TEnv = unknown> {
126
131
  * @example Using cookies for locale-aware caching
127
132
  * ```typescript
128
133
  * keyGenerator: (ctx, defaultKey) => {
129
- * const locale = ctx.cookie('locale') || 'en';
134
+ * const locale = cookies().get('locale')?.value || 'en';
130
135
  * return `${locale}:${defaultKey}`;
131
136
  * }
132
137
  * ```
@@ -184,7 +189,7 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
184
189
  private deriveBaseUrl(): string {
185
190
  const fallback = "https://rsc-cache.internal.com/";
186
191
 
187
- const ctx = getRequestContext();
192
+ const ctx = _getRequestContext();
188
193
  if (!ctx?.request) {
189
194
  return fallback;
190
195
  }
@@ -299,7 +304,7 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
299
304
  const request = this.keyToRequest(key);
300
305
 
301
306
  // Extended TTL covers SWR window
302
- const swrWindow = swr ?? this.defaults?.swr ?? 0;
307
+ const swrWindow = resolveSwrWindow(swr, this.defaults);
303
308
  const totalTtl = ttl + swrWindow;
304
309
  const staleAt = Date.now() + ttl * 1000;
305
310
 
@@ -389,7 +394,7 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
389
394
  const request = this.keyToRequest(`doc:${key}`);
390
395
 
391
396
  // Extended TTL covers SWR window
392
- const swrWindow = swr ?? this.defaults?.swr ?? 0;
397
+ const swrWindow = resolveSwrWindow(swr, this.defaults);
393
398
  const totalTtl = ttl + swrWindow;
394
399
  const staleAt = Date.now() + ttl * 1000;
395
400
 
@@ -490,8 +495,8 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
490
495
  const cache = await this.getCache();
491
496
  const request = this.keyToRequest(`fn:${key}`);
492
497
 
493
- const ttl = options?.ttl ?? this.defaults?.ttl ?? 900;
494
- const swrWindow = options?.swr ?? this.defaults?.swr ?? 0;
498
+ const ttl = resolveTtl(options?.ttl, this.defaults, DEFAULT_FUNCTION_TTL);
499
+ const swrWindow = resolveSwrWindow(options?.swr, this.defaults);
495
500
  const totalTtl = ttl + swrWindow;
496
501
  const staleAt = Date.now() + ttl * 1000;
497
502
 
@@ -13,6 +13,8 @@
13
13
 
14
14
  import type { MiddlewareFn, MiddlewareContext } from "../router/middleware.js";
15
15
  import { getRequestContext } from "../server/request-context.js";
16
+ import { sortedSearchString } from "./cache-key-utils.js";
17
+ import { runBackground } from "./background-task.js";
16
18
 
17
19
  // ============================================================================
18
20
  // Constants
@@ -110,15 +112,24 @@ function addCacheStatusHeader(
110
112
  }
111
113
 
112
114
  /**
113
- * Run onResponse callbacks registered on the request context
115
+ * Drain and run onResponse callbacks registered on the request context.
116
+ * Mirrors the drain semantics of finalizeResponse() in rsc/helpers.ts:
117
+ * callbacks are spliced out so they fire at most once per request.
114
118
  */
115
- function runOnResponseCallbacks(
119
+ function drainOnResponseCallbacks(
116
120
  response: Response,
117
- callbacks: Array<(response: Response) => Response>,
121
+ requestCtx:
122
+ | { _onResponseCallbacks: Array<(r: Response) => Response> }
123
+ | undefined,
118
124
  ): Response {
125
+ if (!requestCtx || requestCtx._onResponseCallbacks.length === 0) {
126
+ return response;
127
+ }
128
+ const callbacks = requestCtx._onResponseCallbacks;
129
+ requestCtx._onResponseCallbacks = [];
119
130
  let result = response;
120
131
  for (const callback of callbacks) {
121
- result = callback(result);
132
+ result = callback(result) ?? result;
122
133
  }
123
134
  return result;
124
135
  }
@@ -193,6 +204,11 @@ export function createDocumentCacheMiddleware<TEnv = any>(
193
204
  ): Promise<Response> {
194
205
  const url = ctx.url;
195
206
 
207
+ // Only cache GET requests — mutations and other methods must not be cached
208
+ if (ctx.request.method !== "GET") {
209
+ return next();
210
+ }
211
+
196
212
  // Skip RSC action requests (mutations shouldn't be cached)
197
213
  if (url.searchParams.has("_rsc_action")) {
198
214
  return next();
@@ -229,18 +245,31 @@ export function createDocumentCacheMiddleware<TEnv = any>(
229
245
  const isPartial = url.searchParams.has("_rsc_partial");
230
246
  const typeLabel = isPartial ? "RSC" : "HTML";
231
247
 
232
- // Generate cache key
233
- // For partial requests, include hash of client segments to prevent serving
234
- // wrong cached response when navigating from different pages with different layouts
235
- const clientSegments = url.searchParams.get("_rsc_segments") || "";
236
- const segmentHash =
237
- isPartial && clientSegments ? `:${hashSegmentIds(clientSegments)}` : "";
238
- const typeSuffix = isPartial ? ":rsc" : ":html";
239
- const cacheKey = keyGenerator
240
- ? keyGenerator(url) + segmentHash + typeSuffix
241
- : `${url.pathname}${segmentHash}${typeSuffix}`;
248
+ // Track whether next() has been called so the catch block knows
249
+ // whether it is safe to fall through to the handler.
250
+ let handlerCalled = false;
242
251
 
243
252
  try {
253
+ // Generate cache key inside try so a throwing keyGenerator degrades
254
+ // gracefully to the origin handler instead of rejecting the request.
255
+ // This is a deliberate fail-open-to-origin policy: the fallback is
256
+ // "serve uncached from origin", not "use a different cache key".
257
+ const clientSegments = url.searchParams.get("_rsc_segments") || "";
258
+ const segmentHash =
259
+ isPartial && clientSegments ? `:${hashSegmentIds(clientSegments)}` : "";
260
+ const typeSuffix = isPartial ? ":rsc" : ":html";
261
+
262
+ let searchSuffix = "";
263
+ if (!keyGenerator) {
264
+ const sorted = sortedSearchString(url.searchParams);
265
+ if (sorted) {
266
+ searchSuffix = `?${sorted}`;
267
+ }
268
+ }
269
+
270
+ const cacheKey = keyGenerator
271
+ ? keyGenerator(url) + segmentHash + typeSuffix
272
+ : `${url.pathname}${searchSuffix}${segmentHash}${typeSuffix}`;
244
273
  // 1. Check cache
245
274
  const cached = await store.getResponse(cacheKey);
246
275
 
@@ -248,15 +277,10 @@ export function createDocumentCacheMiddleware<TEnv = any>(
248
277
  if (!cached.shouldRevalidate) {
249
278
  // Fresh hit - return immediately
250
279
  log(`[DocumentCache] HIT ${typeLabel}: ${url.pathname}`);
251
- let response = addCacheStatusHeader(cached.response, "HIT");
252
- // Run onResponse callbacks even for cache hits
253
- if (requestCtx && requestCtx._onResponseCallbacks.length > 0) {
254
- response = runOnResponseCallbacks(
255
- response,
256
- requestCtx._onResponseCallbacks,
257
- );
258
- }
259
- return response;
280
+ return drainOnResponseCallbacks(
281
+ addCacheStatusHeader(cached.response, "HIT"),
282
+ requestCtx,
283
+ );
260
284
  }
261
285
 
262
286
  // Stale hit - return cached response, revalidate in background
@@ -264,41 +288,33 @@ export function createDocumentCacheMiddleware<TEnv = any>(
264
288
  `[DocumentCache] STALE ${typeLabel}: ${url.pathname} (revalidating)`,
265
289
  );
266
290
 
267
- if (requestCtx) {
268
- requestCtx.waitUntil(async () => {
269
- try {
270
- const fresh = await next();
271
- const directives = shouldCacheResponse(fresh);
272
-
273
- if (directives) {
274
- await store.putResponse!(
275
- cacheKey,
276
- fresh,
277
- directives.sMaxAge!,
278
- directives.staleWhileRevalidate,
279
- );
280
- log(
281
- `[DocumentCache] REVALIDATED ${typeLabel}: ${url.pathname}`,
282
- );
283
- }
284
- } catch (error) {
285
- console.error(`[DocumentCache] Revalidation failed:`, error);
291
+ runBackground(requestCtx, async () => {
292
+ try {
293
+ const fresh = await next();
294
+ const directives = shouldCacheResponse(fresh);
295
+
296
+ if (directives) {
297
+ await store.putResponse!(
298
+ cacheKey,
299
+ fresh,
300
+ directives.sMaxAge!,
301
+ directives.staleWhileRevalidate,
302
+ );
303
+ log(`[DocumentCache] REVALIDATED ${typeLabel}: ${url.pathname}`);
286
304
  }
287
- });
288
- }
305
+ } catch (error) {
306
+ console.error(`[DocumentCache] Revalidation failed:`, error);
307
+ }
308
+ });
289
309
 
290
- let response = addCacheStatusHeader(cached.response, "STALE");
291
- // Run onResponse callbacks even for stale cache hits
292
- if (requestCtx && requestCtx._onResponseCallbacks.length > 0) {
293
- response = runOnResponseCallbacks(
294
- response,
295
- requestCtx._onResponseCallbacks,
296
- );
297
- }
298
- return response;
310
+ return drainOnResponseCallbacks(
311
+ addCacheStatusHeader(cached.response, "STALE"),
312
+ requestCtx,
313
+ );
299
314
  }
300
315
 
301
316
  // 2. Cache miss - run handler
317
+ handlerCalled = true;
302
318
  const originalResponse = await next();
303
319
 
304
320
  // 3. Cache if response has appropriate headers
@@ -309,24 +325,27 @@ export function createDocumentCacheMiddleware<TEnv = any>(
309
325
  `[DocumentCache] MISS ${typeLabel}: ${url.pathname} (caching with s-maxage=${directives.sMaxAge})`,
310
326
  );
311
327
 
328
+ // If the response has no body (e.g., 200 with empty body), skip caching
329
+ if (!originalResponse.body) {
330
+ return originalResponse;
331
+ }
332
+
312
333
  // Tee the body so we can return one stream and cache the other
313
- const [returnStream, cacheStream] = originalResponse.body!.tee();
334
+ const [returnStream, cacheStream] = originalResponse.body.tee();
314
335
 
315
336
  // Clone response for caching (non-blocking)
316
- if (requestCtx) {
317
- requestCtx.waitUntil(async () => {
318
- try {
319
- await store.putResponse!(
320
- cacheKey,
321
- new Response(cacheStream, originalResponse),
322
- directives.sMaxAge!,
323
- directives.staleWhileRevalidate,
324
- );
325
- } catch (error) {
326
- console.error(`[DocumentCache] Cache write failed:`, error);
327
- }
328
- });
329
- }
337
+ runBackground(requestCtx, async () => {
338
+ try {
339
+ await store.putResponse!(
340
+ cacheKey,
341
+ new Response(cacheStream, originalResponse),
342
+ directives.sMaxAge!,
343
+ directives.staleWhileRevalidate,
344
+ );
345
+ } catch (error) {
346
+ console.error(`[DocumentCache] Cache write failed:`, error);
347
+ }
348
+ });
330
349
 
331
350
  return addCacheStatusHeader(
332
351
  new Response(returnStream, originalResponse),
@@ -338,7 +357,12 @@ export function createDocumentCacheMiddleware<TEnv = any>(
338
357
  return originalResponse;
339
358
  } catch (error) {
340
359
  console.error(`[DocumentCache] Error:`, error);
341
- // On any cache error, fall through to handler
360
+ if (handlerCalled) {
361
+ // Post-handler failure (e.g. body.tee()): do not call next() again
362
+ // as that would re-run handler side effects.
363
+ throw error;
364
+ }
365
+ // Pre-handler failure (cache lookup): degrade gracefully to origin
342
366
  return next();
343
367
  }
344
368
  };
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Handle Capture
3
+ *
4
+ * Captures handle pushes during cached function execution.
5
+ * Extracted from cache-runtime.ts so tests can import without
6
+ * pulling in @vitejs/plugin-rsc/rsc dependencies.
7
+ */
8
+
9
+ import type { HandleStore } from "../server/handle-store.js";
10
+ import type { SegmentHandleData } from "./types.js";
11
+
12
+ export interface HandleCapture {
13
+ data: Record<string, SegmentHandleData>;
14
+ }
15
+
16
+ /**
17
+ * Active capture tokens per HandleStore.
18
+ *
19
+ * Instead of mutating handleStore.push (which breaks when overlapping
20
+ * captures finish out of order), we install a single interceptor on
21
+ * first use and manage a set of active capture tokens. Each push fans
22
+ * out to every active token. Stopping a capture simply removes the
23
+ * token — order does not matter.
24
+ */
25
+ const activeCapturesMap = new WeakMap<HandleStore, Set<HandleCapture>>();
26
+
27
+ /**
28
+ * One-time interceptor installation. Wraps the original push so every
29
+ * call fans out to all active capture tokens. Installed once per
30
+ * HandleStore instance; subsequent startHandleCapture calls on the
31
+ * same store just add tokens to the Set.
32
+ */
33
+ function ensureInterceptorInstalled(handleStore: HandleStore): void {
34
+ if (activeCapturesMap.has(handleStore)) return;
35
+
36
+ const captures = new Set<HandleCapture>();
37
+ activeCapturesMap.set(handleStore, captures);
38
+
39
+ const originalPush = handleStore.push.bind(handleStore);
40
+ handleStore.push = (
41
+ handleName: string,
42
+ segmentId: string,
43
+ value: unknown,
44
+ ) => {
45
+ for (const capture of captures) {
46
+ if (!capture.data[segmentId]) {
47
+ capture.data[segmentId] = {};
48
+ }
49
+ if (!capture.data[segmentId][handleName]) {
50
+ capture.data[segmentId][handleName] = [];
51
+ }
52
+ capture.data[segmentId][handleName].push(value);
53
+ }
54
+ originalPush(handleName, segmentId, value);
55
+ };
56
+ }
57
+
58
+ /**
59
+ * Start capturing handle pushes for a cached function execution.
60
+ *
61
+ * Concurrency-safe: multiple overlapping captures on the same
62
+ * HandleStore are independent. Each capture registers a token in a
63
+ * Set; stopping removes it. No ordering requirement (LIFO not needed).
64
+ */
65
+ export function startHandleCapture(handleStore: HandleStore): {
66
+ capture: HandleCapture;
67
+ stop: () => void;
68
+ } {
69
+ ensureInterceptorInstalled(handleStore);
70
+
71
+ const capture: HandleCapture = { data: {} };
72
+ const captures = activeCapturesMap.get(handleStore)!;
73
+ captures.add(capture);
74
+
75
+ return {
76
+ capture,
77
+ stop() {
78
+ captures.delete(capture);
79
+ },
80
+ };
81
+ }
@@ -10,21 +10,6 @@
10
10
  * - CacheScope / createCacheScope - Request-scoped cache provider
11
11
  */
12
12
 
13
- // Generic cache store types (reserved for future extensibility)
14
- // These types support caching arbitrary values like Response, Stream, etc.
15
- // Currently unused - segment caching uses SegmentCacheStore directly.
16
- export type {
17
- CacheStore,
18
- CacheEntry,
19
- CacheValue,
20
- CacheValueType,
21
- CachePutOptions,
22
- CacheMetadata,
23
- } from "./types.js";
24
-
25
- // Generic memory cache (reserved for future extensibility)
26
- export { MemoryCacheStore } from "./memory-store.js";
27
-
28
13
  // Segment cache store types and implementations
29
14
  export type {
30
15
  SegmentCacheStore,