@rangojs/router 0.0.0-experimental.18 → 0.0.0-experimental.19

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 (177) hide show
  1. package/README.md +46 -8
  2. package/dist/bin/rango.js +105 -18
  3. package/dist/vite/index.js +227 -93
  4. package/package.json +15 -14
  5. package/skills/hooks/SKILL.md +1 -1
  6. package/skills/intercept/SKILL.md +79 -0
  7. package/skills/layout/SKILL.md +62 -2
  8. package/skills/loader/SKILL.md +94 -1
  9. package/skills/middleware/SKILL.md +81 -0
  10. package/skills/parallel/SKILL.md +57 -2
  11. package/skills/prerender/SKILL.md +187 -17
  12. package/skills/route/SKILL.md +42 -1
  13. package/skills/router-setup/SKILL.md +77 -0
  14. package/src/__internal.ts +1 -1
  15. package/src/bin/rango.ts +38 -19
  16. package/src/browser/action-coordinator.ts +97 -0
  17. package/src/browser/event-controller.ts +25 -27
  18. package/src/browser/history-state.ts +80 -0
  19. package/src/browser/intercept-utils.ts +1 -1
  20. package/src/browser/link-interceptor.ts +0 -3
  21. package/src/browser/merge-segment-loaders.ts +9 -2
  22. package/src/browser/navigation-bridge.ts +46 -13
  23. package/src/browser/navigation-client.ts +32 -61
  24. package/src/browser/navigation-store.ts +1 -31
  25. package/src/browser/navigation-transaction.ts +46 -207
  26. package/src/browser/partial-update.ts +102 -150
  27. package/src/browser/{prefetch-cache.ts → prefetch/cache.ts} +23 -4
  28. package/src/browser/{prefetch-fetch.ts → prefetch/fetch.ts} +36 -8
  29. package/src/browser/prefetch/policy.ts +42 -0
  30. package/src/browser/{prefetch-queue.ts → prefetch/queue.ts} +10 -3
  31. package/src/browser/react/Link.tsx +28 -23
  32. package/src/browser/react/NavigationProvider.tsx +9 -1
  33. package/src/browser/react/index.ts +2 -6
  34. package/src/browser/react/location-state-shared.ts +1 -1
  35. package/src/browser/react/location-state.ts +2 -0
  36. package/src/browser/react/nonce-context.ts +23 -0
  37. package/src/browser/react/use-action.ts +9 -1
  38. package/src/browser/react/use-handle.ts +3 -25
  39. package/src/browser/react/use-params.ts +2 -4
  40. package/src/browser/react/use-pathname.ts +2 -3
  41. package/src/browser/react/use-router.ts +1 -1
  42. package/src/browser/react/use-search-params.ts +2 -1
  43. package/src/browser/react/use-segments.ts +7 -60
  44. package/src/browser/response-adapter.ts +73 -0
  45. package/src/browser/rsc-router.tsx +29 -23
  46. package/src/browser/scroll-restoration.ts +10 -7
  47. package/src/browser/server-action-bridge.ts +115 -96
  48. package/src/browser/types.ts +1 -31
  49. package/src/browser/validate-redirect-origin.ts +29 -0
  50. package/src/build/generate-manifest.ts +5 -0
  51. package/src/build/generate-route-types.ts +2 -0
  52. package/src/build/route-types/codegen.ts +13 -4
  53. package/src/build/route-types/include-resolution.ts +13 -0
  54. package/src/build/route-types/per-module-writer.ts +15 -3
  55. package/src/build/route-types/router-processing.ts +45 -3
  56. package/src/build/runtime-discovery.ts +13 -1
  57. package/src/cache/background-task.ts +34 -0
  58. package/src/cache/cache-key-utils.ts +44 -0
  59. package/src/cache/cache-policy.ts +125 -0
  60. package/src/cache/cache-runtime.ts +132 -96
  61. package/src/cache/cache-scope.ts +71 -73
  62. package/src/cache/cf/cf-cache-store.ts +9 -4
  63. package/src/cache/document-cache.ts +72 -47
  64. package/src/cache/handle-capture.ts +81 -0
  65. package/src/cache/memory-segment-store.ts +18 -7
  66. package/src/cache/profile-registry.ts +43 -8
  67. package/src/cache/read-through-swr.ts +134 -0
  68. package/src/cache/segment-codec.ts +101 -112
  69. package/src/cache/taint.ts +26 -0
  70. package/src/client.tsx +53 -30
  71. package/src/errors.ts +6 -1
  72. package/src/handle.ts +1 -1
  73. package/src/handles/MetaTags.tsx +5 -2
  74. package/src/host/cookie-handler.ts +8 -3
  75. package/src/host/router.ts +14 -1
  76. package/src/href-client.ts +3 -1
  77. package/src/index.rsc.ts +33 -1
  78. package/src/index.ts +27 -0
  79. package/src/loader.rsc.ts +12 -4
  80. package/src/loader.ts +8 -0
  81. package/src/prerender/store.ts +4 -3
  82. package/src/prerender.ts +76 -18
  83. package/src/reverse.ts +11 -7
  84. package/src/root-error-boundary.tsx +30 -26
  85. package/src/route-definition/dsl-helpers.ts +9 -6
  86. package/src/route-definition/redirect.ts +15 -3
  87. package/src/route-map-builder.ts +38 -2
  88. package/src/route-name.ts +53 -0
  89. package/src/route-types.ts +7 -0
  90. package/src/router/content-negotiation.ts +1 -1
  91. package/src/router/debug-manifest.ts +16 -3
  92. package/src/router/handler-context.ts +94 -15
  93. package/src/router/intercept-resolution.ts +6 -4
  94. package/src/router/lazy-includes.ts +4 -0
  95. package/src/router/loader-resolution.ts +1 -0
  96. package/src/router/logging.ts +100 -3
  97. package/src/router/manifest.ts +32 -3
  98. package/src/router/match-api.ts +61 -7
  99. package/src/router/match-context.ts +3 -0
  100. package/src/router/match-handlers.ts +185 -11
  101. package/src/router/match-middleware/background-revalidation.ts +65 -85
  102. package/src/router/match-middleware/cache-lookup.ts +69 -4
  103. package/src/router/match-middleware/cache-store.ts +2 -0
  104. package/src/router/match-pipelines.ts +8 -43
  105. package/src/router/middleware-types.ts +7 -0
  106. package/src/router/middleware.ts +93 -8
  107. package/src/router/pattern-matching.ts +41 -5
  108. package/src/router/prerender-match.ts +34 -6
  109. package/src/router/preview-match.ts +7 -1
  110. package/src/router/revalidation.ts +61 -2
  111. package/src/router/router-context.ts +15 -0
  112. package/src/router/router-interfaces.ts +34 -0
  113. package/src/router/router-options.ts +200 -0
  114. package/src/router/segment-resolution/fresh.ts +123 -30
  115. package/src/router/segment-resolution/helpers.ts +19 -0
  116. package/src/router/segment-resolution/loader-cache.ts +37 -146
  117. package/src/router/segment-resolution/revalidation.ts +358 -94
  118. package/src/router/segment-wrappers.ts +3 -0
  119. package/src/router/telemetry-otel.ts +299 -0
  120. package/src/router/telemetry.ts +300 -0
  121. package/src/router/timeout.ts +148 -0
  122. package/src/router/types.ts +7 -1
  123. package/src/router.ts +155 -11
  124. package/src/rsc/handler-context.ts +11 -0
  125. package/src/rsc/handler.ts +380 -88
  126. package/src/rsc/helpers.ts +25 -16
  127. package/src/rsc/loader-fetch.ts +84 -42
  128. package/src/rsc/origin-guard.ts +141 -0
  129. package/src/rsc/progressive-enhancement.ts +232 -19
  130. package/src/rsc/response-route-handler.ts +37 -26
  131. package/src/rsc/rsc-rendering.ts +12 -5
  132. package/src/rsc/runtime-warnings.ts +42 -0
  133. package/src/rsc/server-action.ts +134 -58
  134. package/src/rsc/types.ts +8 -0
  135. package/src/search-params.ts +22 -10
  136. package/src/server/context.ts +53 -5
  137. package/src/server/fetchable-loader-store.ts +11 -6
  138. package/src/server/handle-store.ts +66 -9
  139. package/src/server/loader-registry.ts +11 -46
  140. package/src/server/request-context.ts +90 -9
  141. package/src/ssr/index.tsx +63 -27
  142. package/src/static-handler.ts +7 -0
  143. package/src/theme/ThemeProvider.tsx +6 -1
  144. package/src/theme/index.ts +1 -6
  145. package/src/theme/theme-context.ts +1 -28
  146. package/src/theme/theme-script.ts +2 -1
  147. package/src/types/cache-types.ts +5 -0
  148. package/src/types/error-types.ts +3 -0
  149. package/src/types/global-namespace.ts +9 -0
  150. package/src/types/handler-context.ts +35 -13
  151. package/src/types/loader-types.ts +7 -0
  152. package/src/types/route-entry.ts +28 -0
  153. package/src/urls/include-helper.ts +49 -8
  154. package/src/urls/index.ts +1 -0
  155. package/src/urls/path-helper-types.ts +30 -12
  156. package/src/urls/path-helper.ts +17 -2
  157. package/src/urls/pattern-types.ts +21 -1
  158. package/src/urls/response-types.ts +27 -2
  159. package/src/urls/type-extraction.ts +23 -15
  160. package/src/use-loader.tsx +12 -4
  161. package/src/vite/discovery/bundle-postprocess.ts +12 -7
  162. package/src/vite/discovery/discover-routers.ts +30 -18
  163. package/src/vite/discovery/prerender-collection.ts +24 -27
  164. package/src/vite/discovery/route-types-writer.ts +7 -7
  165. package/src/vite/discovery/virtual-module-codegen.ts +5 -2
  166. package/src/vite/plugins/client-ref-hashing.ts +3 -3
  167. package/src/vite/plugins/use-cache-transform.ts +91 -3
  168. package/src/vite/rango.ts +3 -3
  169. package/src/vite/router-discovery.ts +99 -36
  170. package/src/vite/utils/prerender-utils.ts +21 -0
  171. package/src/vite/utils/shared-utils.ts +3 -1
  172. package/src/browser/request-controller.ts +0 -164
  173. package/src/href-context.ts +0 -33
  174. package/src/router.gen.ts +0 -6
  175. package/src/static-handler.gen.ts +0 -5
  176. package/src/urls.gen.ts +0 -8
  177. /package/src/browser/{prefetch-observer.ts → prefetch/observer.ts} +0 -0
@@ -17,7 +17,6 @@
17
17
  /// <reference types="@vitejs/plugin-rsc/types" />
18
18
 
19
19
  import {
20
- createTemporaryReferenceSet,
21
20
  encodeReply,
22
21
  createClientTemporaryReferenceSet,
23
22
  } from "@vitejs/plugin-rsc/rsc";
@@ -26,19 +25,17 @@ import {
26
25
  isTainted,
27
26
  CACHED_FN_SYMBOL,
28
27
  isCachedFunction,
29
- INSIDE_CACHE_EXEC,
28
+ stampCacheExec,
29
+ unstampCacheExec,
30
30
  } from "./taint.js";
31
31
 
32
32
  export { isCachedFunction };
33
- import { getCacheProfile } from "./profile-registry.js";
34
33
  import { serializeResult, deserializeResult } from "./segment-codec.js";
35
- import type { SegmentHandleData } from "./types.js";
36
- import type { HandleStore } from "../server/handle-store.js";
34
+ import { createHandleStore } from "../server/handle-store.js";
37
35
  import { restoreHandles } from "./handle-snapshot.js";
38
-
39
- // ============================================================================
40
- // Cache Key Generation
41
- // ============================================================================
36
+ import { startHandleCapture, type HandleCapture } from "./handle-capture.js";
37
+ import { sortedSearchString } from "./cache-key-utils.js";
38
+ import { runBackground } from "./background-task.js";
42
39
 
43
40
  /**
44
41
  * Convert encodeReply result to a stable string key.
@@ -51,47 +48,6 @@ async function replyToCacheKey(encoded: string | FormData): Promise<string> {
51
48
  return text;
52
49
  }
53
50
 
54
- // ============================================================================
55
- // Handle Capture
56
- // ============================================================================
57
-
58
- interface HandleCapture {
59
- data: Record<string, SegmentHandleData>;
60
- }
61
-
62
- function startHandleCapture(handleStore: HandleStore): HandleCapture {
63
- const capture: HandleCapture = { data: {} };
64
- const originalPush = handleStore.push.bind(handleStore);
65
-
66
- // Intercept push() calls to record them
67
- handleStore.push = (
68
- handleName: string,
69
- segmentId: string,
70
- value: unknown,
71
- ) => {
72
- if (!capture.data[segmentId]) {
73
- capture.data[segmentId] = {};
74
- }
75
- if (!capture.data[segmentId][handleName]) {
76
- capture.data[segmentId][handleName] = [];
77
- }
78
- capture.data[segmentId][handleName].push(value);
79
- // Still call the original so the data flows through normally
80
- originalPush(handleName, segmentId, value);
81
- };
82
-
83
- return capture;
84
- }
85
-
86
- function stopHandleCapture(
87
- handleStore: HandleStore,
88
- _capture: HandleCapture,
89
- ): void {
90
- // Restore original push by deleting the override
91
- // (the original is on the prototype/closure, our override is an own property)
92
- delete (handleStore as any).push;
93
- }
94
-
95
51
  // ============================================================================
96
52
  // Core: registerCachedFunction
97
53
  // ============================================================================
@@ -112,17 +68,30 @@ export function registerCachedFunction<T extends (...args: any[]) => any>(
112
68
  const wrapped = async function (this: any, ...args: any[]): Promise<any> {
113
69
  const requestCtx = getRequestContext();
114
70
  const store = requestCtx?._cacheStore;
115
- const profile = getCacheProfile(profileName || "default");
71
+ const resolvedProfileName = profileName || "default";
116
72
 
117
- // Bypass: no store, no getItem support, or no profile configured
118
- if (!store?.getItem || !profile) {
73
+ // Bypass: no store or no getItem support
74
+ if (!store?.getItem) {
119
75
  return fn.apply(this, args);
120
76
  }
121
77
 
78
+ // Resolve profile strictly from request-scoped config (set by the
79
+ // active router via createRequestContext). No global fallback —
80
+ // global profile state is only for DSL-time cache("profileName").
81
+ const profile = requestCtx?._cacheProfiles?.[resolvedProfileName];
82
+
83
+ if (!profile) {
84
+ throw new Error(
85
+ `[use cache] "${id}" uses unknown cache profile "${resolvedProfileName}". ` +
86
+ `Define it in createRouter({ cacheProfiles: { "${resolvedProfileName}": { ttl: ... } } }).`,
87
+ );
88
+ }
89
+
122
90
  // Separate tainted args (ctx, env, req) from key-generating args.
123
- // For tainted objects that carry route context (params, pathname),
124
- // extract those serializable values into the key so different routes
125
- // and param combinations produce distinct cache entries.
91
+ // For tainted objects that carry route context (params, pathname,
92
+ // searchParams), extract serializable values into the key so
93
+ // different routes, param combinations, and query variants produce
94
+ // distinct cache entries.
126
95
  const keyArgs: unknown[] = [];
127
96
  let hasTaintedArgs = false;
128
97
  for (const arg of args) {
@@ -130,10 +99,28 @@ export function registerCachedFunction<T extends (...args: any[]) => any>(
130
99
  hasTaintedArgs = true;
131
100
  const ctx = arg as any;
132
101
  if (ctx.params && typeof ctx.params === "object") {
102
+ // Include host to prevent cross-host cache collisions (same
103
+ // pattern as route-level cache-scope.ts key generation).
104
+ if (ctx.url?.host) {
105
+ keyArgs.push(ctx.url.host);
106
+ }
107
+ // Include route name to prevent collisions when the same cached
108
+ // function is reused across routes with identical pathname/params
109
+ // but different local reverse() scope.
110
+ if (ctx._routeName) {
111
+ keyArgs.push(ctx._routeName);
112
+ }
133
113
  keyArgs.push(ctx.pathname, ctx.params);
134
114
  if (ctx._responseType) {
135
115
  keyArgs.push(ctx._responseType);
136
116
  }
117
+ // Include user-facing search params (exclude internal _rsc*/__ params)
118
+ if (ctx.searchParams instanceof URLSearchParams) {
119
+ const normalized = sortedSearchString(ctx.searchParams);
120
+ if (normalized) {
121
+ keyArgs.push(normalized);
122
+ }
123
+ }
137
124
  }
138
125
  } else {
139
126
  keyArgs.push(arg);
@@ -202,24 +189,75 @@ export function registerCachedFunction<T extends (...args: any[]) => any>(
202
189
  restoreHandles(cached.handles, handleStore);
203
190
  }
204
191
  }
205
- // Background revalidation
206
- if (requestCtx?.waitUntil) {
207
- requestCtx.waitUntil(async () => {
208
- try {
209
- const freshResult = await fn.apply(this, args);
210
- const serialized = await serializeResult(freshResult);
211
- if (serialized !== null) {
212
- await store.setItem!(cacheKey, serialized, {
213
- ttl: profile.ttl,
214
- swr: profile.swr,
215
- tags: profile.tags,
216
- });
217
- }
218
- } catch {
219
- // Background revalidation failed silently
192
+ // Background revalidation — must capture handles if tainted args present.
193
+ // Use an isolated handle store so background pushes don't pollute the
194
+ // live response or throw LateHandlePushError on the completed store.
195
+ // Same isolation pattern as route-level background-revalidation.ts.
196
+ runBackground(requestCtx, async () => {
197
+ // Reuse closure-captured requestCtx instead of calling
198
+ // getRequestContext() ALS context may be gone inside waitUntil.
199
+ let originalHandleStore:
200
+ | ReturnType<typeof createHandleStore>
201
+ | undefined;
202
+ if (hasTaintedArgs && requestCtx) {
203
+ originalHandleStore = requestCtx._handleStore;
204
+ requestCtx._handleStore = createHandleStore();
205
+ }
206
+ const bgHandleStore = hasTaintedArgs
207
+ ? requestCtx?._handleStore
208
+ : undefined;
209
+ let bgCapture: HandleCapture | undefined;
210
+ let bgStopCapture: (() => void) | undefined;
211
+ if (bgHandleStore) {
212
+ const c = startHandleCapture(bgHandleStore);
213
+ bgCapture = c.capture;
214
+ bgStopCapture = c.stop;
215
+ }
216
+
217
+ // Stamp tainted args and RequestContext so request-scoped
218
+ // reads (cookies, headers) and side effects (ctx.set, etc.)
219
+ // throw inside background revalidation, same as the miss path.
220
+ // Uses ref-counted stamp/unstamp so overlapping executions
221
+ // sharing the same ctx don't clear each other's guards.
222
+ const bgTaintedArgs: unknown[] = [];
223
+ for (const arg of args) {
224
+ if (isTainted(arg)) {
225
+ stampCacheExec(arg as object);
226
+ bgTaintedArgs.push(arg);
220
227
  }
221
- });
222
- }
228
+ }
229
+ if (requestCtx) {
230
+ stampCacheExec(requestCtx as object);
231
+ }
232
+
233
+ try {
234
+ const freshResult = await fn.apply(this, args);
235
+ bgStopCapture?.();
236
+ const serialized = await serializeResult(freshResult);
237
+ if (serialized !== null) {
238
+ await store.setItem!(cacheKey, serialized, {
239
+ handles: bgCapture?.data,
240
+ ttl: profile.ttl,
241
+ swr: profile.swr,
242
+ tags: profile.tags,
243
+ });
244
+ }
245
+ } catch (bgError) {
246
+ bgStopCapture?.();
247
+ requestCtx?._reportBackgroundError?.(bgError, "stale-revalidation");
248
+ } finally {
249
+ for (const arg of bgTaintedArgs) {
250
+ unstampCacheExec(arg as object);
251
+ }
252
+ if (requestCtx) {
253
+ unstampCacheExec(requestCtx as object);
254
+ }
255
+ // Restore original handle store
256
+ if (originalHandleStore && requestCtx) {
257
+ requestCtx._handleStore = originalHandleStore;
258
+ }
259
+ }
260
+ });
223
261
  return result;
224
262
  } catch {
225
263
  // Deserialization of stale value failed, fall through
@@ -229,41 +267,44 @@ export function registerCachedFunction<T extends (...args: any[]) => any>(
229
267
  // Cache miss: execute, serialize, store
230
268
  const handleStore = hasTaintedArgs ? requestCtx?._handleStore : undefined;
231
269
  let capture: HandleCapture | undefined;
270
+ let stopCapture: (() => void) | undefined;
232
271
  if (handleStore && hasTaintedArgs) {
233
- capture = startHandleCapture(handleStore);
272
+ const c = startHandleCapture(handleStore);
273
+ capture = c.capture;
274
+ stopCapture = c.stop;
234
275
  }
235
276
 
236
277
  // Stamp tainted args so ctx.set(), ctx.header(), etc. throw if called
237
278
  // inside the cached function body (those side effects are lost on hit).
238
- // Also stamp the ALS RequestContext so cookies()/headers() guards fire
239
- // (they read from getRequestContext(), which is a different object from
240
- // the HandlerContext/ResponseHandlerContext passed as args).
279
+ // Uses ref-counted stamp/unstamp so overlapping executions
280
+ // sharing the same ctx don't clear each other's guards.
241
281
  const taintedArgs: unknown[] = [];
242
282
  for (const arg of args) {
243
283
  if (isTainted(arg)) {
244
- (arg as any)[INSIDE_CACHE_EXEC] = true;
284
+ stampCacheExec(arg as object);
245
285
  taintedArgs.push(arg);
246
286
  }
247
287
  }
248
- if (hasTaintedArgs && requestCtx) {
249
- (requestCtx as any)[INSIDE_CACHE_EXEC] = true;
288
+ // Always stamp the ALS RequestContext so cookies()/headers() guards fire
289
+ // even when the cached function receives no tainted args. The guard in
290
+ // cookie-store.ts checks RequestContext, not function args.
291
+ if (requestCtx) {
292
+ stampCacheExec(requestCtx as object);
250
293
  }
251
294
 
252
295
  let result: any;
253
296
  try {
254
297
  result = await fn.apply(this, args);
255
298
  } finally {
256
- // Always remove the flag, even if the function throws
299
+ // Decrement ref count; symbol is deleted when it reaches zero
257
300
  for (const arg of taintedArgs) {
258
- delete (arg as any)[INSIDE_CACHE_EXEC];
301
+ unstampCacheExec(arg as object);
259
302
  }
260
- if (hasTaintedArgs && requestCtx) {
261
- delete (requestCtx as any)[INSIDE_CACHE_EXEC];
303
+ if (requestCtx) {
304
+ unstampCacheExec(requestCtx as object);
262
305
  }
263
- }
264
-
265
- if (capture && handleStore) {
266
- stopHandleCapture(handleStore, capture);
306
+ // Remove this capture token (order-independent, safe for concurrent use)
307
+ stopCapture?.();
267
308
  }
268
309
 
269
310
  // Serialize and store — fully non-blocking when waitUntil is available.
@@ -279,17 +320,12 @@ export function registerCachedFunction<T extends (...args: any[]) => any>(
279
320
  tags: profile.tags,
280
321
  });
281
322
  }
282
- } catch {
283
- // Serialization or store write failed silently
323
+ } catch (writeError) {
324
+ requestCtx?._reportBackgroundError?.(writeError, "cache-write");
284
325
  }
285
326
  };
286
327
 
287
- if (requestCtx?.waitUntil) {
288
- requestCtx.waitUntil(cacheWrite);
289
- } else {
290
- // No waitUntil (e.g. Node.js dev server): run inline as best-effort
291
- await cacheWrite();
292
- }
328
+ await runBackground(requestCtx, cacheWrite, true);
293
329
 
294
330
  return result;
295
331
  };
@@ -18,13 +18,12 @@ import {
18
18
  } from "../server/request-context.js";
19
19
  import { serializeSegments, deserializeSegments } from "./segment-codec.js";
20
20
  import { captureHandles, restoreHandles } from "./handle-snapshot.js";
21
-
22
- // ============================================================================
23
- // Constants
24
- // ============================================================================
25
-
26
- /** Default TTL when no explicit value or store defaults are configured */
27
- 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";
28
27
 
29
28
  function debugCacheLog(message: string): void {
30
29
  if (INTERNAL_RANGO_DEBUG) {
@@ -37,27 +36,31 @@ function debugCacheLog(message: string): void {
37
36
  // ============================================================================
38
37
 
39
38
  /**
40
- * Generate cache key base from pathname and params.
41
- * 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.
42
43
  * @internal
43
44
  */
44
45
  function getCacheKeyBase(
46
+ host: string,
45
47
  pathname: string,
46
48
  params?: Record<string, string>,
49
+ searchParams?: URLSearchParams,
47
50
  ): string {
48
- const paramStr = params
49
- ? Object.entries(params)
50
- .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
51
- .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
52
- .join("&")
53
- : "";
54
-
55
- 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;
56
58
  }
57
59
 
58
60
  /**
59
61
  * Generate default cache key for a route request.
60
- * 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.
61
64
  * Includes request type prefix since they produce different segment sets:
62
65
  * - doc: document requests (full page load)
63
66
  * - partial: navigation requests (client-side navigation)
@@ -71,11 +74,13 @@ function getDefaultRouteCacheKey(
71
74
  ): string {
72
75
  const ctx = getRequestContext();
73
76
  const isPartial = ctx?.url.searchParams.has("_rsc_partial") ?? false;
77
+ const searchParams = ctx?.url.searchParams;
78
+ const host = ctx?.url.host ?? "localhost";
74
79
 
75
80
  // Intercept navigations get their own cache namespace
76
81
  const prefix = isIntercept ? "intercept" : isPartial ? "partial" : "doc";
77
82
 
78
- return `${prefix}:${getCacheKeyBase(pathname, params)}`;
83
+ return `${prefix}:${getCacheKeyBase(host, pathname, params, searchParams)}`;
79
84
  }
80
85
 
81
86
  // ============================================================================
@@ -140,7 +145,7 @@ export class CacheScope {
140
145
  }
141
146
 
142
147
  // Hardcoded fallback
143
- return DEFAULT_TTL_SECONDS;
148
+ return DEFAULT_ROUTE_TTL;
144
149
  }
145
150
 
146
151
  /**
@@ -165,23 +170,11 @@ export class CacheScope {
165
170
  * 2. App-level store from request context
166
171
  */
167
172
  getStore(): SegmentCacheStore | null {
168
- // Explicit store from cache() options takes precedence
169
- if (this.explicitStore) {
170
- return this.explicitStore;
171
- }
172
- // Fall back to app-level store from request context
173
- const ctx = getRequestContext();
174
- return ctx?._cacheStore ?? null;
173
+ return resolveCacheStore(this.explicitStore);
175
174
  }
176
175
 
177
176
  /**
178
- * Resolve the cache key using custom key functions or default generation.
179
- *
180
- * Resolution priority:
181
- * 1. Route-level `key` function (full override)
182
- * 2. Store-level `keyGenerator` (modifies default key)
183
- * 3. Default key generation (prefix:pathname:params)
184
- *
177
+ * Resolve the cache key using the shared 3-tier priority.
185
178
  * @internal
186
179
  */
187
180
  private async resolveKey(
@@ -189,46 +182,9 @@ export class CacheScope {
189
182
  params: Record<string, string>,
190
183
  isIntercept?: boolean,
191
184
  ): Promise<string> {
192
- const requestCtx = getRequestContext();
193
- if (!requestCtx) {
194
- // Fallback to default key if no request context
195
- return getDefaultRouteCacheKey(pathname, params, isIntercept);
196
- }
197
-
198
- // Priority 1: Route-level key function (full override)
199
- if (this.config !== false && this.config.key) {
200
- try {
201
- const customKey = await this.config.key(requestCtx);
202
- return customKey;
203
- } catch (error) {
204
- console.error(
205
- `[CacheScope] Custom key function failed, using default:`,
206
- error,
207
- );
208
- return getDefaultRouteCacheKey(pathname, params, isIntercept);
209
- }
210
- }
211
-
212
- // Generate default key
213
185
  const defaultKey = getDefaultRouteCacheKey(pathname, params, isIntercept);
214
-
215
- // Priority 2: Store-level keyGenerator (modifies default key)
216
- const store = this.getStore();
217
- if (store?.keyGenerator) {
218
- try {
219
- const modifiedKey = await store.keyGenerator(requestCtx, defaultKey);
220
- return modifiedKey;
221
- } catch (error) {
222
- console.error(
223
- `[CacheScope] Store keyGenerator failed, using default:`,
224
- error,
225
- );
226
- return defaultKey;
227
- }
228
- }
229
-
230
- // Priority 3: Default key
231
- return defaultKey;
186
+ const keyFn = this.config !== false ? this.config.key : undefined;
187
+ return resolveCacheKey(keyFn, this.getStore(), defaultKey, "CacheScope");
232
188
  }
233
189
 
234
190
  /**
@@ -249,6 +205,27 @@ export class CacheScope {
249
205
  } | null> {
250
206
  if (!this.enabled) return null;
251
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
+
252
229
  const store = this.getStore();
253
230
  if (!store) return null;
254
231
 
@@ -308,6 +285,27 @@ export class CacheScope {
308
285
  ): Promise<void> {
309
286
  if (!this.enabled || segments.length === 0) return;
310
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
+
311
309
  const store = this.getStore();
312
310
  if (!store) return;
313
311
 
@@ -33,6 +33,11 @@ import {
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
@@ -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