@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
@@ -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
@@ -202,6 +204,11 @@ export function createDocumentCacheMiddleware<TEnv = any>(
202
204
  ): Promise<Response> {
203
205
  const url = ctx.url;
204
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
+
205
212
  // Skip RSC action requests (mutations shouldn't be cached)
206
213
  if (url.searchParams.has("_rsc_action")) {
207
214
  return next();
@@ -238,18 +245,31 @@ export function createDocumentCacheMiddleware<TEnv = any>(
238
245
  const isPartial = url.searchParams.has("_rsc_partial");
239
246
  const typeLabel = isPartial ? "RSC" : "HTML";
240
247
 
241
- // Generate cache key
242
- // For partial requests, include hash of client segments to prevent serving
243
- // wrong cached response when navigating from different pages with different layouts
244
- const clientSegments = url.searchParams.get("_rsc_segments") || "";
245
- const segmentHash =
246
- isPartial && clientSegments ? `:${hashSegmentIds(clientSegments)}` : "";
247
- const typeSuffix = isPartial ? ":rsc" : ":html";
248
- const cacheKey = keyGenerator
249
- ? keyGenerator(url) + segmentHash + typeSuffix
250
- : `${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;
251
251
 
252
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}`;
253
273
  // 1. Check cache
254
274
  const cached = await store.getResponse(cacheKey);
255
275
 
@@ -268,28 +288,24 @@ export function createDocumentCacheMiddleware<TEnv = any>(
268
288
  `[DocumentCache] STALE ${typeLabel}: ${url.pathname} (revalidating)`,
269
289
  );
270
290
 
271
- if (requestCtx) {
272
- requestCtx.waitUntil(async () => {
273
- try {
274
- const fresh = await next();
275
- const directives = shouldCacheResponse(fresh);
276
-
277
- if (directives) {
278
- await store.putResponse!(
279
- cacheKey,
280
- fresh,
281
- directives.sMaxAge!,
282
- directives.staleWhileRevalidate,
283
- );
284
- log(
285
- `[DocumentCache] REVALIDATED ${typeLabel}: ${url.pathname}`,
286
- );
287
- }
288
- } catch (error) {
289
- 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}`);
290
304
  }
291
- });
292
- }
305
+ } catch (error) {
306
+ console.error(`[DocumentCache] Revalidation failed:`, error);
307
+ }
308
+ });
293
309
 
294
310
  return drainOnResponseCallbacks(
295
311
  addCacheStatusHeader(cached.response, "STALE"),
@@ -298,6 +314,7 @@ export function createDocumentCacheMiddleware<TEnv = any>(
298
314
  }
299
315
 
300
316
  // 2. Cache miss - run handler
317
+ handlerCalled = true;
301
318
  const originalResponse = await next();
302
319
 
303
320
  // 3. Cache if response has appropriate headers
@@ -308,24 +325,27 @@ export function createDocumentCacheMiddleware<TEnv = any>(
308
325
  `[DocumentCache] MISS ${typeLabel}: ${url.pathname} (caching with s-maxage=${directives.sMaxAge})`,
309
326
  );
310
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
+
311
333
  // Tee the body so we can return one stream and cache the other
312
- const [returnStream, cacheStream] = originalResponse.body!.tee();
334
+ const [returnStream, cacheStream] = originalResponse.body.tee();
313
335
 
314
336
  // Clone response for caching (non-blocking)
315
- if (requestCtx) {
316
- requestCtx.waitUntil(async () => {
317
- try {
318
- await store.putResponse!(
319
- cacheKey,
320
- new Response(cacheStream, originalResponse),
321
- directives.sMaxAge!,
322
- directives.staleWhileRevalidate,
323
- );
324
- } catch (error) {
325
- console.error(`[DocumentCache] Cache write failed:`, error);
326
- }
327
- });
328
- }
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
+ });
329
349
 
330
350
  return addCacheStatusHeader(
331
351
  new Response(returnStream, originalResponse),
@@ -337,7 +357,12 @@ export function createDocumentCacheMiddleware<TEnv = any>(
337
357
  return originalResponse;
338
358
  } catch (error) {
339
359
  console.error(`[DocumentCache] Error:`, error);
340
- // 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
341
366
  return next();
342
367
  }
343
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
+ }
@@ -15,6 +15,12 @@ import type {
15
15
  SegmentHandleData,
16
16
  } from "./types.js";
17
17
  import type { RequestContext } from "../server/request-context.js";
18
+ import {
19
+ resolveTtl,
20
+ resolveSwrWindow,
21
+ computeExpiration,
22
+ DEFAULT_FUNCTION_TTL,
23
+ } from "./cache-policy.js";
18
24
 
19
25
  const CACHE_REGISTRY_KEY = "__rsc_router_segment_cache_registry__";
20
26
  const RESPONSE_CACHE_REGISTRY_KEY = "__rsc_router_response_cache_registry__";
@@ -52,6 +58,7 @@ interface CachedItemEntry {
52
58
  value: string;
53
59
  handles?: Record<string, SegmentHandleData>;
54
60
  expiresAt: number;
61
+ staleAt: number;
55
62
  }
56
63
 
57
64
  /**
@@ -244,9 +251,8 @@ export class MemorySegmentCacheStore<
244
251
  headers.push([name, value]);
245
252
  });
246
253
 
247
- const swrWindow = swr ?? this.defaults?.swr ?? 0;
248
- const staleAt = Date.now() + ttl * 1000;
249
- const expiresAt = staleAt + swrWindow * 1000;
254
+ const swrWindow = resolveSwrWindow(swr, this.defaults);
255
+ const { staleAt, expiresAt } = computeExpiration(ttl, swrWindow);
250
256
 
251
257
  this.responseCache.set(key, {
252
258
  body,
@@ -261,15 +267,17 @@ export class MemorySegmentCacheStore<
261
267
  const cached = this.itemCache.get(key);
262
268
  if (!cached) return null;
263
269
 
264
- if (Date.now() > cached.expiresAt) {
270
+ const now = Date.now();
271
+ if (now > cached.expiresAt) {
265
272
  this.itemCache.delete(key);
266
273
  return null;
267
274
  }
268
275
 
276
+ const isStale = now > cached.staleAt;
269
277
  return {
270
278
  value: cached.value,
271
279
  handles: cached.handles,
272
- shouldRevalidate: false,
280
+ shouldRevalidate: isStale,
273
281
  };
274
282
  }
275
283
 
@@ -278,11 +286,14 @@ export class MemorySegmentCacheStore<
278
286
  value: string,
279
287
  options?: CacheItemOptions,
280
288
  ): Promise<void> {
281
- const ttl = options?.ttl ?? this.defaults?.ttl ?? 900;
289
+ const ttl = resolveTtl(options?.ttl, this.defaults, DEFAULT_FUNCTION_TTL);
290
+ const swrWindow = resolveSwrWindow(options?.swr, this.defaults);
291
+ const { staleAt, expiresAt } = computeExpiration(ttl, swrWindow);
282
292
  this.itemCache.set(key, {
283
293
  value,
284
294
  handles: options?.handles,
285
- expiresAt: Date.now() + ttl * 1000,
295
+ expiresAt,
296
+ staleAt,
286
297
  });
287
298
  }
288
299
 
@@ -15,23 +15,58 @@ export interface CacheProfile {
15
15
  tags?: string[];
16
16
  }
17
17
 
18
+ const DEFAULT_PROFILE: CacheProfile = { ttl: 900, swr: 1800 };
19
+
18
20
  let _profiles: Record<string, CacheProfile> = {
19
- default: { ttl: 900, swr: 1800 },
21
+ default: DEFAULT_PROFILE,
20
22
  };
21
23
 
24
+ const PROFILE_NAME_RE = /^[a-zA-Z0-9_-]+$/;
25
+
22
26
  /**
23
- * Set all cache profiles. Called by createRouter() at startup.
27
+ * Validate and merge user profiles with the default profile.
28
+ * Returns a new object suitable for both DSL-time and request-scoped use.
29
+ *
30
+ * Used by createRouter() to compute the resolved profile map once,
31
+ * stored on the router instance and passed to every request context.
24
32
  */
25
- export function setCacheProfiles(profiles: Record<string, CacheProfile>): void {
26
- _profiles = { ...profiles };
27
- // Ensure a default profile always exists
28
- if (!_profiles.default) {
29
- _profiles.default = { ttl: 900, swr: 1800 };
33
+ export function resolveCacheProfiles(
34
+ profiles?: Record<string, CacheProfile>,
35
+ ): Record<string, CacheProfile> {
36
+ const merged: Record<string, CacheProfile> = {
37
+ default: DEFAULT_PROFILE,
38
+ };
39
+ if (profiles) {
40
+ for (const name of Object.keys(profiles)) {
41
+ if (!PROFILE_NAME_RE.test(name)) {
42
+ throw new Error(
43
+ `Invalid cache profile name "${name}". ` +
44
+ `Profile names must match [a-zA-Z0-9_-]+.`,
45
+ );
46
+ }
47
+ merged[name] = profiles[name];
48
+ }
30
49
  }
50
+ return merged;
51
+ }
52
+
53
+ /**
54
+ * Set all cache profiles in the global registry.
55
+ * Called by createRouter() at startup for DSL-time resolution
56
+ * (cache("profileName") reads from this during route definition).
57
+ *
58
+ * WARNING: This is global mutable state. It exists only for DSL-time
59
+ * reads. Runtime resolution (registerCachedFunction) uses request-scoped
60
+ * profiles and does NOT read from this registry.
61
+ */
62
+ export function setCacheProfiles(profiles: Record<string, CacheProfile>): void {
63
+ _profiles = resolveCacheProfiles(profiles);
31
64
  }
32
65
 
33
66
  /**
34
- * Get a cache profile by name. Returns undefined for unknown profiles.
67
+ * Get a cache profile by name from the global registry.
68
+ * Used only at DSL-time (cache("profileName") inside urls() evaluation).
69
+ * Runtime code uses request-scoped profiles instead.
35
70
  */
36
71
  export function getCacheProfile(name: string): CacheProfile | undefined {
37
72
  return _profiles[name];
@@ -0,0 +1,134 @@
1
+ /**
2
+ * SWR Read-Through Engine
3
+ *
4
+ * Generic read-through cache with stale-while-revalidate support
5
+ * for item-level caching (getItem/setItem).
6
+ *
7
+ * Flow:
8
+ * 1. Lookup cached item by key
9
+ * 2. Fresh hit → deserialize, return
10
+ * 3. Stale hit → deserialize, return, revalidate in background
11
+ * 4. Miss → execute, cache write (blocking when no waitUntil), return
12
+ */
13
+
14
+ import type { CacheItemResult, CacheItemOptions } from "./types.js";
15
+ import { runBackground } from "./background-task.js";
16
+
17
+ interface WaitUntilHost {
18
+ waitUntil?: (fn: () => Promise<void>) => void;
19
+ }
20
+
21
+ export interface ReadThroughItemConfig<T> {
22
+ /** Retrieve a cached item by key */
23
+ getItem: (key: string) => Promise<CacheItemResult | null>;
24
+ /** Store a serialized item by key */
25
+ setItem: (
26
+ key: string,
27
+ value: string,
28
+ options?: CacheItemOptions,
29
+ ) => Promise<void>;
30
+ /** Cache key */
31
+ key: string;
32
+ /** Execute the underlying function/loader on miss or revalidation */
33
+ execute: () => Promise<T>;
34
+ /** Serialize result for storage. Return null to skip caching. */
35
+ serialize: (data: T) => Promise<string | null>;
36
+ /** Deserialize cached value back to the original type */
37
+ deserialize: (value: string) => Promise<T>;
38
+ /** Options passed to setItem on cache write */
39
+ storeOptions: CacheItemOptions;
40
+ /** Called on fresh cache hit (before returning data) */
41
+ onHit?: (cached: CacheItemResult) => void;
42
+ /** Called on stale cache hit (before scheduling background revalidation) */
43
+ onStale?: (cached: CacheItemResult) => void;
44
+ /** Called on cache miss (before executing) */
45
+ onMiss?: () => void;
46
+ /** Called after successful cache write */
47
+ onCached?: () => void;
48
+ /** Host with optional waitUntil for background tasks */
49
+ host?: WaitUntilHost | null;
50
+ }
51
+
52
+ /**
53
+ * Read-through cache with SWR support for item-level caching.
54
+ *
55
+ * On fresh hit: returns deserialized cached data.
56
+ * On stale hit: returns stale data, schedules background revalidation.
57
+ * On miss: executes, writes to cache (blocking when no waitUntil), returns.
58
+ */
59
+ export async function readThroughItem<T>(
60
+ config: ReadThroughItemConfig<T>,
61
+ ): Promise<T> {
62
+ const {
63
+ getItem,
64
+ setItem,
65
+ key,
66
+ execute,
67
+ serialize,
68
+ deserialize,
69
+ storeOptions,
70
+ onHit,
71
+ onStale,
72
+ onMiss,
73
+ onCached,
74
+ host,
75
+ } = config;
76
+
77
+ // Cache lookup
78
+ try {
79
+ const cached = await getItem(key);
80
+
81
+ if (cached) {
82
+ const data = await deserialize(cached.value);
83
+
84
+ if (!cached.shouldRevalidate) {
85
+ onHit?.(cached);
86
+ return data;
87
+ }
88
+
89
+ // Stale hit — return stale data, revalidate in background
90
+ onStale?.(cached);
91
+ runBackground(
92
+ host,
93
+ async () => {
94
+ try {
95
+ const fresh = await execute();
96
+ const serialized = await serialize(fresh);
97
+ if (serialized !== null) {
98
+ await setItem(key, serialized, storeOptions);
99
+ }
100
+ } catch {
101
+ // Background revalidation failed silently
102
+ }
103
+ },
104
+ true,
105
+ );
106
+ return data;
107
+ }
108
+ } catch {
109
+ // Cache lookup failed, fall through to fresh execution
110
+ }
111
+
112
+ // Cache miss
113
+ onMiss?.();
114
+ const data = await execute();
115
+
116
+ // Non-blocking cache write (blocks when no waitUntil)
117
+ await runBackground(
118
+ host,
119
+ async () => {
120
+ try {
121
+ const serialized = await serialize(data);
122
+ if (serialized !== null) {
123
+ await setItem(key, serialized, storeOptions);
124
+ onCached?.();
125
+ }
126
+ } catch {
127
+ // Cache write failed silently
128
+ }
129
+ },
130
+ true,
131
+ );
132
+
133
+ return data;
134
+ }