@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
@@ -15,24 +15,35 @@ 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__";
21
27
  const ITEM_CACHE_REGISTRY_KEY = "__rsc_router_item_cache_registry__";
22
28
 
23
29
  /**
24
- * Returns the globalThis-backed registry of named cache Maps.
25
- * The registry itself survives HMR; individual stores are keyed by name.
30
+ * Get or create a named Map from a globalThis-backed registry.
31
+ * The registry survives HMR; individual stores are keyed by name.
26
32
  */
27
- function getGlobalRegistry(): Map<string, Map<string, CachedEntryData>> {
28
- let registry = (globalThis as any)[CACHE_REGISTRY_KEY] as
29
- | Map<string, Map<string, CachedEntryData>>
33
+ function getNamedMap<V>(registryKey: string, name: string): Map<string, V> {
34
+ let registry = (globalThis as any)[registryKey] as
35
+ | Map<string, Map<string, V>>
30
36
  | undefined;
31
37
  if (!registry) {
32
38
  registry = new Map();
33
- (globalThis as any)[CACHE_REGISTRY_KEY] = registry;
39
+ (globalThis as any)[registryKey] = registry;
34
40
  }
35
- return registry;
41
+ let map = registry.get(name);
42
+ if (!map) {
43
+ map = new Map<string, V>();
44
+ registry.set(name, map);
45
+ }
46
+ return map;
36
47
  }
37
48
 
38
49
  interface CachedResponseEntry {
@@ -47,37 +58,7 @@ interface CachedItemEntry {
47
58
  value: string;
48
59
  handles?: Record<string, SegmentHandleData>;
49
60
  expiresAt: number;
50
- }
51
-
52
- /**
53
- * Returns the globalThis-backed registry of named item cache Maps (for "use cache").
54
- */
55
- function getItemCacheRegistry(): Map<string, Map<string, CachedItemEntry>> {
56
- let registry = (globalThis as any)[ITEM_CACHE_REGISTRY_KEY] as
57
- | Map<string, Map<string, CachedItemEntry>>
58
- | undefined;
59
- if (!registry) {
60
- registry = new Map();
61
- (globalThis as any)[ITEM_CACHE_REGISTRY_KEY] = registry;
62
- }
63
- return registry;
64
- }
65
-
66
- /**
67
- * Returns the globalThis-backed registry of named response cache Maps.
68
- */
69
- function getResponseCacheRegistry(): Map<
70
- string,
71
- Map<string, CachedResponseEntry>
72
- > {
73
- let registry = (globalThis as any)[RESPONSE_CACHE_REGISTRY_KEY] as
74
- | Map<string, Map<string, CachedResponseEntry>>
75
- | undefined;
76
- if (!registry) {
77
- registry = new Map();
78
- (globalThis as any)[RESPONSE_CACHE_REGISTRY_KEY] = registry;
79
- }
80
- return registry;
61
+ staleAt: number;
81
62
  }
82
63
 
83
64
  /**
@@ -122,7 +103,7 @@ export interface MemorySegmentCacheStoreOptions<TEnv = unknown> {
122
103
  * @example
123
104
  * ```typescript
124
105
  * keyGenerator: (ctx, defaultKey) => {
125
- * const locale = ctx.cookie('locale') || 'en';
106
+ * const locale = cookies().get('locale')?.value || 'en';
126
107
  * return `${locale}:${defaultKey}`;
127
108
  * }
128
109
  * ```
@@ -172,29 +153,18 @@ export class MemorySegmentCacheStore<
172
153
  if (options?.name != null) {
173
154
  // Named stores use the globalThis registry so data survives HMR.
174
155
  // Each name gets its own isolated Map.
175
- const registry = getGlobalRegistry();
176
- let map = registry.get(options.name);
177
- if (!map) {
178
- map = new Map<string, CachedEntryData>();
179
- registry.set(options.name, map);
180
- }
181
- this.cache = map;
182
-
183
- const responseRegistry = getResponseCacheRegistry();
184
- let responseMap = responseRegistry.get(options.name);
185
- if (!responseMap) {
186
- responseMap = new Map<string, CachedResponseEntry>();
187
- responseRegistry.set(options.name, responseMap);
188
- }
189
- this.responseCache = responseMap;
190
-
191
- const itemRegistry = getItemCacheRegistry();
192
- let itemMap = itemRegistry.get(options.name);
193
- if (!itemMap) {
194
- itemMap = new Map<string, CachedItemEntry>();
195
- itemRegistry.set(options.name, itemMap);
196
- }
197
- this.itemCache = itemMap;
156
+ this.cache = getNamedMap<CachedEntryData>(
157
+ CACHE_REGISTRY_KEY,
158
+ options.name,
159
+ );
160
+ this.responseCache = getNamedMap<CachedResponseEntry>(
161
+ RESPONSE_CACHE_REGISTRY_KEY,
162
+ options.name,
163
+ );
164
+ this.itemCache = getNamedMap<CachedItemEntry>(
165
+ ITEM_CACHE_REGISTRY_KEY,
166
+ options.name,
167
+ );
198
168
  } else {
199
169
  // Unnamed stores get a plain instance-level Map (no globalThis sharing).
200
170
  this.cache = new Map<string, CachedEntryData>();
@@ -281,9 +251,8 @@ export class MemorySegmentCacheStore<
281
251
  headers.push([name, value]);
282
252
  });
283
253
 
284
- const swrWindow = swr ?? this.defaults?.swr ?? 0;
285
- const staleAt = Date.now() + ttl * 1000;
286
- const expiresAt = staleAt + swrWindow * 1000;
254
+ const swrWindow = resolveSwrWindow(swr, this.defaults);
255
+ const { staleAt, expiresAt } = computeExpiration(ttl, swrWindow);
287
256
 
288
257
  this.responseCache.set(key, {
289
258
  body,
@@ -298,15 +267,17 @@ export class MemorySegmentCacheStore<
298
267
  const cached = this.itemCache.get(key);
299
268
  if (!cached) return null;
300
269
 
301
- if (Date.now() > cached.expiresAt) {
270
+ const now = Date.now();
271
+ if (now > cached.expiresAt) {
302
272
  this.itemCache.delete(key);
303
273
  return null;
304
274
  }
305
275
 
276
+ const isStale = now > cached.staleAt;
306
277
  return {
307
278
  value: cached.value,
308
279
  handles: cached.handles,
309
- shouldRevalidate: false,
280
+ shouldRevalidate: isStale,
310
281
  };
311
282
  }
312
283
 
@@ -315,11 +286,14 @@ export class MemorySegmentCacheStore<
315
286
  value: string,
316
287
  options?: CacheItemOptions,
317
288
  ): Promise<void> {
318
- 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);
319
292
  this.itemCache.set(key, {
320
293
  value,
321
294
  handles: options?.handles,
322
- expiresAt: Date.now() + ttl * 1000,
295
+ expiresAt,
296
+ staleAt,
323
297
  });
324
298
  }
325
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
+ }
@@ -62,6 +62,10 @@ export function stringToStream(str: string): ReadableStream<Uint8Array> {
62
62
  /**
63
63
  * RSC-serialize a value using React Server Components stream.
64
64
  * Used for serializing loaderData, layout, loading components etc.
65
+ *
66
+ * Returns undefined for null/undefined inputs (component fields that are absent).
67
+ * For contexts where null is a valid result (loader caching, "use cache"),
68
+ * use serializeResult() instead which preserves null through RSC Flight.
65
69
  */
66
70
  export async function rscSerialize(
67
71
  value: unknown,
@@ -87,19 +91,49 @@ export async function rscDeserialize<T>(
87
91
  }
88
92
 
89
93
  // ============================================================================
90
- // Public API
94
+ // Null-Preserving RSC Serialization (for caching)
91
95
  // ============================================================================
92
96
 
93
97
  /**
94
- * RSC-deserialize a single encoded component string back to a React element.
95
- * Used by the static handler runtime to revive pre-rendered components.
98
+ * RSC-serialize any value including null.
99
+ * Unlike rscSerialize(), this does NOT skip null it serializes it through
100
+ * RSC Flight so that a loader returning null produces a valid cached entry
101
+ * rather than a permanent cache miss.
102
+ *
103
+ * Returns null only on serialization failure.
96
104
  */
97
- export async function deserializeComponent(encoded: string): Promise<unknown> {
105
+ export async function serializeResult(value: unknown): Promise<string | null> {
106
+ try {
107
+ const temporaryReferences = createTemporaryReferenceSet();
108
+ const stream = renderToReadableStream(value, { temporaryReferences });
109
+ return await streamToString(stream);
110
+ } catch {
111
+ return null;
112
+ }
113
+ }
114
+
115
+ /**
116
+ * RSC-deserialize a cached result string.
117
+ * Counterpart to serializeResult() — always receives a non-empty string.
118
+ */
119
+ export async function deserializeResult<T>(encoded: string): Promise<T> {
98
120
  const temporaryReferences = createTemporaryReferenceSet();
99
121
  const stream = stringToStream(encoded);
100
- return createFromReadableStream(stream, { temporaryReferences });
122
+ return createFromReadableStream<T>(stream, { temporaryReferences });
101
123
  }
102
124
 
125
+ // ============================================================================
126
+ // Public API
127
+ // ============================================================================
128
+
129
+ /**
130
+ * RSC-deserialize a single encoded component string back to a React element.
131
+ * Used by the static handler runtime to revive pre-rendered components.
132
+ * Identical to deserializeResult<unknown>.
133
+ */
134
+ export const deserializeComponent: (encoded: string) => Promise<unknown> =
135
+ deserializeResult;
136
+
103
137
  /**
104
138
  * Serialize segments for storage.
105
139
  * Each segment's component, layout, loading, and loaderData are RSC-serialized.
@@ -108,79 +142,76 @@ export async function deserializeComponent(encoded: string): Promise<unknown> {
108
142
  export async function serializeSegments(
109
143
  segments: ResolvedSegment[],
110
144
  ): Promise<SerializedSegmentData[]> {
111
- const serialized: SerializedSegmentData[] = [];
112
-
113
- for (const segment of segments) {
114
- const temporaryReferences = createTemporaryReferenceSet();
115
-
116
- // Await component if it's a Promise (intercepts with loading keep component as Promise)
117
- const componentResolved =
118
- segment.component instanceof Promise
119
- ? await segment.component
120
- : segment.component;
121
-
122
- // Serialize the component to RSC stream
123
- const stream = renderToReadableStream(componentResolved, {
124
- temporaryReferences,
125
- });
126
-
127
- // Convert stream to string
128
- const encoded = await streamToString(stream);
129
-
130
- // RSC-serialize layout if present (ReactNode)
131
- const encodedLayout = segment.layout
132
- ? await rscSerialize(segment.layout)
133
- : undefined;
134
-
135
- // RSC-serialize loading if present (ReactNode) - preserves tree structure
136
- // Use "null" string to distinguish explicit null from undefined
137
- const encodedLoading =
138
- segment.loading !== undefined
139
- ? segment.loading === null
140
- ? "null"
141
- : await rscSerialize(segment.loading)
142
- : undefined;
143
-
144
- // Await and RSC-serialize loaderData if present
145
- const loaderDataResolved =
146
- segment.loaderData instanceof Promise
147
- ? await segment.loaderData
148
- : segment.loaderData;
149
- const encodedLoaderData = await rscSerialize(loaderDataResolved);
150
-
151
- // Await and RSC-serialize loaderDataPromise if present
152
- const loaderDataPromiseResolved =
153
- segment.loaderDataPromise instanceof Promise
154
- ? await segment.loaderDataPromise
155
- : segment.loaderDataPromise;
156
- const encodedLoaderDataPromise = await rscSerialize(
157
- loaderDataPromiseResolved,
158
- );
159
-
160
- serialized.push({
161
- encoded,
162
- encodedLayout,
163
- encodedLoading,
164
- encodedLoaderData,
165
- encodedLoaderDataPromise,
166
- metadata: {
167
- id: segment.id,
168
- type: segment.type,
169
- namespace: segment.namespace,
170
- index: segment.index,
171
- params: segment.params,
172
- slot: segment.slot,
173
- belongsToRoute: segment.belongsToRoute,
174
- layoutName: segment.layoutName,
175
- parallelName: segment.parallelName,
176
- loaderId: segment.loaderId,
177
- loaderIds: segment.loaderIds,
178
- transition: segment.transition,
179
- },
180
- });
181
- }
145
+ return Promise.all(
146
+ segments.map(async (segment): Promise<SerializedSegmentData> => {
147
+ const temporaryReferences = createTemporaryReferenceSet();
148
+
149
+ // Await component if it's a Promise (intercepts with loading keep component as Promise)
150
+ const componentResolved =
151
+ segment.component instanceof Promise
152
+ ? await segment.component
153
+ : segment.component;
154
+
155
+ // Serialize the component to RSC stream
156
+ const stream = renderToReadableStream(componentResolved, {
157
+ temporaryReferences,
158
+ });
159
+
160
+ // RSC-serialize loading: "null" string distinguishes explicit null from undefined
161
+ const encodedLoading =
162
+ segment.loading !== undefined
163
+ ? segment.loading === null
164
+ ? "null"
165
+ : await rscSerialize(segment.loading)
166
+ : undefined;
167
+
168
+ // Await loaderData / loaderDataPromise if they're Promises
169
+ const loaderDataResolved =
170
+ segment.loaderData instanceof Promise
171
+ ? await segment.loaderData
172
+ : segment.loaderData;
173
+ const loaderDataPromiseResolved =
174
+ segment.loaderDataPromise instanceof Promise
175
+ ? await segment.loaderDataPromise
176
+ : segment.loaderDataPromise;
177
+
178
+ // Parallelize stream-to-string and RSC serialization of sub-fields
179
+ const [
180
+ encoded,
181
+ encodedLayout,
182
+ encodedLoaderData,
183
+ encodedLoaderDataPromise,
184
+ ] = await Promise.all([
185
+ streamToString(stream),
186
+ segment.layout ? rscSerialize(segment.layout) : undefined,
187
+ rscSerialize(loaderDataResolved),
188
+ rscSerialize(loaderDataPromiseResolved),
189
+ ]);
182
190
 
183
- return serialized;
191
+ return {
192
+ encoded,
193
+ encodedLayout,
194
+ encodedLoading,
195
+ encodedLoaderData,
196
+ encodedLoaderDataPromise,
197
+ metadata: {
198
+ id: segment.id,
199
+ type: segment.type,
200
+ namespace: segment.namespace,
201
+ index: segment.index,
202
+ params: segment.params,
203
+ slot: segment.slot,
204
+ belongsToRoute: segment.belongsToRoute,
205
+ layoutName: segment.layoutName,
206
+ parallelName: segment.parallelName,
207
+ loaderId: segment.loaderId,
208
+ loaderIds: segment.loaderIds,
209
+ transition: segment.transition,
210
+ mountPath: segment.mountPath,
211
+ },
212
+ };
213
+ }),
214
+ );
184
215
  }
185
216
 
186
217
  /**
@@ -190,44 +221,36 @@ export async function serializeSegments(
190
221
  export async function deserializeSegments(
191
222
  data: SerializedSegmentData[],
192
223
  ): Promise<ResolvedSegment[]> {
193
- const segments: ResolvedSegment[] = [];
194
-
195
- for (const item of data) {
196
- const temporaryReferences = createTemporaryReferenceSet();
197
-
198
- // Revive the component from cached string
199
- const stream = stringToStream(item.encoded);
200
- const component = await createFromReadableStream(stream, {
201
- temporaryReferences,
202
- });
203
-
204
- // RSC-deserialize layout, loaderData, loaderDataPromise in parallel.
205
- // Handle the "null" sentinel for loading before RSC deserialization.
206
- // During serialization, loading: null is stored as the string "null" to
207
- // distinguish it from undefined. This sentinel must be intercepted here
208
- // rather than passed to rscDeserialize, which would try to decode it as
209
- // an RSC Flight payload.
210
- const loadingIsNullSentinel = item.encodedLoading === "null";
211
-
212
- const [layout, loaderData, loaderDataPromise, loadingData] =
213
- await Promise.all([
214
- rscDeserialize(item.encodedLayout),
215
- rscDeserialize(item.encodedLoaderData),
216
- rscDeserialize(item.encodedLoaderDataPromise),
217
- loadingIsNullSentinel
218
- ? (null as any)
219
- : rscDeserialize(item.encodedLoading),
220
- ]);
221
-
222
- segments.push({
223
- ...item.metadata,
224
- component,
225
- layout,
226
- loading: loadingData,
227
- loaderData,
228
- loaderDataPromise,
229
- } as ResolvedSegment);
230
- }
231
-
232
- return segments;
224
+ return Promise.all(
225
+ data.map(async (item): Promise<ResolvedSegment> => {
226
+ const temporaryReferences = createTemporaryReferenceSet();
227
+
228
+ // Handle the "null" sentinel for loading before RSC deserialization.
229
+ // During serialization, loading: null is stored as the string "null" to
230
+ // distinguish it from undefined.
231
+ const loadingIsNullSentinel = item.encodedLoading === "null";
232
+
233
+ const [component, layout, loaderData, loaderDataPromise, loadingData] =
234
+ await Promise.all([
235
+ createFromReadableStream(stringToStream(item.encoded), {
236
+ temporaryReferences,
237
+ }),
238
+ rscDeserialize(item.encodedLayout),
239
+ rscDeserialize(item.encodedLoaderData),
240
+ rscDeserialize(item.encodedLoaderDataPromise),
241
+ loadingIsNullSentinel
242
+ ? (null as any)
243
+ : rscDeserialize(item.encodedLoading),
244
+ ]);
245
+
246
+ return {
247
+ ...item.metadata,
248
+ component,
249
+ layout,
250
+ loading: loadingData,
251
+ loaderData,
252
+ loaderDataPromise,
253
+ } as ResolvedSegment;
254
+ }),
255
+ );
233
256
  }