@ivogt/rsc-router 0.0.0-experimental.1

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 (123) hide show
  1. package/README.md +19 -0
  2. package/package.json +131 -0
  3. package/src/__mocks__/version.ts +6 -0
  4. package/src/__tests__/route-definition.test.ts +63 -0
  5. package/src/browser/event-controller.ts +876 -0
  6. package/src/browser/index.ts +18 -0
  7. package/src/browser/link-interceptor.ts +121 -0
  8. package/src/browser/lru-cache.ts +69 -0
  9. package/src/browser/merge-segment-loaders.ts +126 -0
  10. package/src/browser/navigation-bridge.ts +891 -0
  11. package/src/browser/navigation-client.ts +155 -0
  12. package/src/browser/navigation-store.ts +823 -0
  13. package/src/browser/partial-update.ts +545 -0
  14. package/src/browser/react/Link.tsx +248 -0
  15. package/src/browser/react/NavigationProvider.tsx +228 -0
  16. package/src/browser/react/ScrollRestoration.tsx +94 -0
  17. package/src/browser/react/context.ts +53 -0
  18. package/src/browser/react/index.ts +52 -0
  19. package/src/browser/react/location-state-shared.ts +120 -0
  20. package/src/browser/react/location-state.ts +62 -0
  21. package/src/browser/react/use-action.ts +240 -0
  22. package/src/browser/react/use-client-cache.ts +56 -0
  23. package/src/browser/react/use-handle.ts +178 -0
  24. package/src/browser/react/use-link-status.ts +134 -0
  25. package/src/browser/react/use-navigation.ts +150 -0
  26. package/src/browser/react/use-segments.ts +188 -0
  27. package/src/browser/request-controller.ts +149 -0
  28. package/src/browser/rsc-router.tsx +310 -0
  29. package/src/browser/scroll-restoration.ts +324 -0
  30. package/src/browser/server-action-bridge.ts +747 -0
  31. package/src/browser/shallow.ts +35 -0
  32. package/src/browser/types.ts +443 -0
  33. package/src/cache/__tests__/memory-segment-store.test.ts +487 -0
  34. package/src/cache/__tests__/memory-store.test.ts +484 -0
  35. package/src/cache/cache-scope.ts +565 -0
  36. package/src/cache/cf/__tests__/cf-cache-store.test.ts +361 -0
  37. package/src/cache/cf/cf-cache-store.ts +274 -0
  38. package/src/cache/cf/index.ts +19 -0
  39. package/src/cache/index.ts +52 -0
  40. package/src/cache/memory-segment-store.ts +150 -0
  41. package/src/cache/memory-store.ts +253 -0
  42. package/src/cache/types.ts +366 -0
  43. package/src/client.rsc.tsx +88 -0
  44. package/src/client.tsx +609 -0
  45. package/src/components/DefaultDocument.tsx +20 -0
  46. package/src/default-error-boundary.tsx +88 -0
  47. package/src/deps/browser.ts +8 -0
  48. package/src/deps/html-stream-client.ts +2 -0
  49. package/src/deps/html-stream-server.ts +2 -0
  50. package/src/deps/rsc.ts +10 -0
  51. package/src/deps/ssr.ts +2 -0
  52. package/src/errors.ts +259 -0
  53. package/src/handle.ts +120 -0
  54. package/src/handles/MetaTags.tsx +178 -0
  55. package/src/handles/index.ts +6 -0
  56. package/src/handles/meta.ts +247 -0
  57. package/src/href-client.ts +128 -0
  58. package/src/href.ts +139 -0
  59. package/src/index.rsc.ts +69 -0
  60. package/src/index.ts +84 -0
  61. package/src/loader.rsc.ts +204 -0
  62. package/src/loader.ts +47 -0
  63. package/src/network-error-thrower.tsx +21 -0
  64. package/src/outlet-context.ts +15 -0
  65. package/src/root-error-boundary.tsx +277 -0
  66. package/src/route-content-wrapper.tsx +198 -0
  67. package/src/route-definition.ts +1333 -0
  68. package/src/route-map-builder.ts +140 -0
  69. package/src/route-types.ts +148 -0
  70. package/src/route-utils.ts +89 -0
  71. package/src/router/__tests__/match-context.test.ts +104 -0
  72. package/src/router/__tests__/match-pipelines.test.ts +537 -0
  73. package/src/router/__tests__/match-result.test.ts +566 -0
  74. package/src/router/__tests__/on-error.test.ts +935 -0
  75. package/src/router/__tests__/pattern-matching.test.ts +577 -0
  76. package/src/router/error-handling.ts +287 -0
  77. package/src/router/handler-context.ts +60 -0
  78. package/src/router/loader-resolution.ts +326 -0
  79. package/src/router/manifest.ts +116 -0
  80. package/src/router/match-context.ts +261 -0
  81. package/src/router/match-middleware/background-revalidation.ts +236 -0
  82. package/src/router/match-middleware/cache-lookup.ts +261 -0
  83. package/src/router/match-middleware/cache-store.ts +250 -0
  84. package/src/router/match-middleware/index.ts +81 -0
  85. package/src/router/match-middleware/intercept-resolution.ts +268 -0
  86. package/src/router/match-middleware/segment-resolution.ts +174 -0
  87. package/src/router/match-pipelines.ts +214 -0
  88. package/src/router/match-result.ts +212 -0
  89. package/src/router/metrics.ts +62 -0
  90. package/src/router/middleware.test.ts +1355 -0
  91. package/src/router/middleware.ts +748 -0
  92. package/src/router/pattern-matching.ts +271 -0
  93. package/src/router/revalidation.ts +190 -0
  94. package/src/router/router-context.ts +299 -0
  95. package/src/router/types.ts +96 -0
  96. package/src/router.ts +3484 -0
  97. package/src/rsc/__tests__/helpers.test.ts +175 -0
  98. package/src/rsc/handler.ts +942 -0
  99. package/src/rsc/helpers.ts +64 -0
  100. package/src/rsc/index.ts +56 -0
  101. package/src/rsc/nonce.ts +18 -0
  102. package/src/rsc/types.ts +225 -0
  103. package/src/segment-system.tsx +405 -0
  104. package/src/server/__tests__/request-context.test.ts +171 -0
  105. package/src/server/context.ts +340 -0
  106. package/src/server/handle-store.ts +230 -0
  107. package/src/server/loader-registry.ts +174 -0
  108. package/src/server/request-context.ts +470 -0
  109. package/src/server/root-layout.tsx +10 -0
  110. package/src/server/tsconfig.json +14 -0
  111. package/src/server.ts +126 -0
  112. package/src/ssr/__tests__/ssr-handler.test.tsx +188 -0
  113. package/src/ssr/index.tsx +215 -0
  114. package/src/types.ts +1473 -0
  115. package/src/use-loader.tsx +346 -0
  116. package/src/vite/__tests__/expose-loader-id.test.ts +117 -0
  117. package/src/vite/expose-action-id.ts +344 -0
  118. package/src/vite/expose-handle-id.ts +209 -0
  119. package/src/vite/expose-loader-id.ts +357 -0
  120. package/src/vite/expose-location-state-id.ts +177 -0
  121. package/src/vite/index.ts +608 -0
  122. package/src/vite/version.d.ts +12 -0
  123. package/src/vite/virtual-entries.ts +109 -0
@@ -0,0 +1,361 @@
1
+ import { describe, it, expect, beforeEach, vi } from "vitest";
2
+ import {
3
+ CFCacheStore,
4
+ CACHE_STALE_AT_HEADER,
5
+ CACHE_STATUS_HEADER,
6
+ } from "../cf-cache-store";
7
+ import type { CachedEntryData } from "../../types";
8
+
9
+ // ============================================================================
10
+ // Mock Cloudflare Cache API
11
+ // ============================================================================
12
+
13
+ class MockCache {
14
+ private store = new Map<string, Response>();
15
+
16
+ async match(request: Request): Promise<Response | undefined> {
17
+ return this.store.get(request.url)?.clone();
18
+ }
19
+
20
+ async put(request: Request, response: Response): Promise<void> {
21
+ this.store.set(request.url, response.clone());
22
+ }
23
+
24
+ async delete(request: Request): Promise<boolean> {
25
+ return this.store.delete(request.url);
26
+ }
27
+
28
+ clear(): void {
29
+ this.store.clear();
30
+ }
31
+ }
32
+
33
+ class MockCaches {
34
+ private caches = new Map<string, MockCache>();
35
+ private _default = new MockCache();
36
+
37
+ async open(name: string): Promise<MockCache> {
38
+ if (!this.caches.has(name)) {
39
+ this.caches.set(name, new MockCache());
40
+ }
41
+ return this.caches.get(name)!;
42
+ }
43
+
44
+ get default(): MockCache {
45
+ return this._default;
46
+ }
47
+
48
+ clear(): void {
49
+ this._default.clear();
50
+ this.caches.forEach((cache) => cache.clear());
51
+ this.caches.clear();
52
+ }
53
+ }
54
+
55
+ // Install mock globally
56
+ const mockCaches = new MockCaches();
57
+ (globalThis as any).caches = mockCaches;
58
+
59
+ // ============================================================================
60
+ // Test Data
61
+ // ============================================================================
62
+
63
+ const createTestData = (): CachedEntryData => ({
64
+ segments: [
65
+ {
66
+ encoded: "test-component",
67
+ metadata: {
68
+ id: "test-segment",
69
+ type: "route",
70
+ namespace: "test",
71
+ index: 0,
72
+ params: {},
73
+ },
74
+ },
75
+ ],
76
+ handles: {},
77
+ expiresAt: Date.now() + 60000,
78
+ });
79
+
80
+ // ============================================================================
81
+ // Tests
82
+ // ============================================================================
83
+
84
+ describe("CFCacheStore", () => {
85
+ beforeEach(() => {
86
+ mockCaches.clear();
87
+ vi.useFakeTimers();
88
+ });
89
+
90
+ describe("constructor", () => {
91
+ it("should use default namespace and baseUrl", () => {
92
+ const store = new CFCacheStore();
93
+ expect(store).toBeDefined();
94
+ });
95
+
96
+ it("should accept custom options", () => {
97
+ const store = new CFCacheStore({
98
+ namespace: "custom-cache",
99
+ baseUrl: "https://custom.internal/",
100
+ defaults: { ttl: 120, swr: 600 },
101
+ });
102
+ expect(store.defaults).toEqual({ ttl: 120, swr: 600 });
103
+ });
104
+
105
+ it("should accept waitUntil function", () => {
106
+ const waitUntil = vi.fn();
107
+ const store = new CFCacheStore({ waitUntil });
108
+ expect(store).toBeDefined();
109
+ });
110
+ });
111
+
112
+ describe("get/set", () => {
113
+ it("should return null for missing key", async () => {
114
+ const store = new CFCacheStore();
115
+ const result = await store.get("missing-key");
116
+ expect(result).toBeNull();
117
+ });
118
+
119
+ it("should store and retrieve data", async () => {
120
+ const store = new CFCacheStore();
121
+ const data = createTestData();
122
+
123
+ await store.set("test-key", data, 60);
124
+ const result = await store.get("test-key");
125
+
126
+ expect(result).not.toBeNull();
127
+ expect(result!.data).toEqual(data);
128
+ expect(result!.shouldRevalidate).toBe(false);
129
+ });
130
+
131
+ it("should set Cache-Control header with TTL", async () => {
132
+ const store = new CFCacheStore();
133
+ const data = createTestData();
134
+
135
+ await store.set("test-key", data, 60);
136
+
137
+ // Uses caches.default by default
138
+ const cache = mockCaches.default;
139
+ const request = new Request("https://rsc-cache.internal.com/test-key");
140
+ const response = await cache.match(request);
141
+
142
+ expect(response?.headers.get("Cache-Control")).toBe("public, max-age=60");
143
+ });
144
+
145
+ it("should extend TTL with SWR window", async () => {
146
+ const store = new CFCacheStore();
147
+ const data = createTestData();
148
+
149
+ await store.set("test-key", data, 60, 300);
150
+
151
+ const cache = mockCaches.default;
152
+ const request = new Request("https://rsc-cache.internal.com/test-key");
153
+ const response = await cache.match(request);
154
+
155
+ expect(response?.headers.get("Cache-Control")).toBe("public, max-age=360");
156
+ });
157
+
158
+ it("should use store defaults for SWR if not provided", async () => {
159
+ const store = new CFCacheStore({ defaults: { swr: 120 } });
160
+ const data = createTestData();
161
+
162
+ await store.set("test-key", data, 60);
163
+
164
+ const cache = mockCaches.default;
165
+ const request = new Request("https://rsc-cache.internal.com/test-key");
166
+ const response = await cache.match(request);
167
+
168
+ expect(response?.headers.get("Cache-Control")).toBe("public, max-age=180");
169
+ });
170
+
171
+ it("should use named cache when namespace is provided", async () => {
172
+ const store = new CFCacheStore({ namespace: "custom-cache" });
173
+ const data = createTestData();
174
+
175
+ await store.set("test-key", data, 60);
176
+
177
+ const cache = await mockCaches.open("custom-cache");
178
+ const request = new Request("https://rsc-cache.internal.com/test-key");
179
+ const response = await cache.match(request);
180
+
181
+ expect(response?.headers.get("Cache-Control")).toBe("public, max-age=60");
182
+ });
183
+
184
+ it("should use waitUntil for non-blocking writes when provided", async () => {
185
+ const waitUntil = vi.fn();
186
+ const store = new CFCacheStore({ waitUntil });
187
+ const data = createTestData();
188
+
189
+ await store.set("test-key", data, 60);
190
+
191
+ expect(waitUntil).toHaveBeenCalledTimes(1);
192
+ expect(waitUntil).toHaveBeenCalledWith(expect.any(Function));
193
+
194
+ // Execute the waitUntil callback
195
+ const callback = waitUntil.mock.calls[0][0];
196
+ await callback();
197
+
198
+ // Now the entry should be in cache
199
+ const result = await store.get("test-key");
200
+ expect(result).not.toBeNull();
201
+ });
202
+ });
203
+
204
+ describe("staleness headers", () => {
205
+ it("should set stale-at header based on TTL", async () => {
206
+ vi.setSystemTime(new Date("2024-01-01T00:00:00Z"));
207
+
208
+ const store = new CFCacheStore();
209
+ const data = createTestData();
210
+
211
+ await store.set("test-key", data, 60);
212
+
213
+ const cache = mockCaches.default;
214
+ const request = new Request("https://rsc-cache.internal.com/test-key");
215
+ const response = await cache.match(request);
216
+
217
+ const staleAt = Number(response?.headers.get(CACHE_STALE_AT_HEADER));
218
+ const expectedStaleAt = Date.now() + 60 * 1000;
219
+
220
+ expect(staleAt).toBe(expectedStaleAt);
221
+ });
222
+
223
+ it("should set status header to HIT", async () => {
224
+ const store = new CFCacheStore();
225
+ const data = createTestData();
226
+
227
+ await store.set("test-key", data, 60);
228
+
229
+ const cache = mockCaches.default;
230
+ const request = new Request("https://rsc-cache.internal.com/test-key");
231
+ const response = await cache.match(request);
232
+
233
+ expect(response?.headers.get(CACHE_STATUS_HEADER)).toBe("HIT");
234
+ });
235
+ });
236
+
237
+ describe("staleness detection and atomic revalidation", () => {
238
+ it("should return shouldRevalidate=false for fresh entries", async () => {
239
+ vi.setSystemTime(new Date("2024-01-01T00:00:00Z"));
240
+
241
+ const store = new CFCacheStore();
242
+ const data = createTestData();
243
+
244
+ await store.set("test-key", data, 60);
245
+
246
+ // Still fresh
247
+ vi.advanceTimersByTime(30 * 1000);
248
+
249
+ const result = await store.get("test-key");
250
+ expect(result?.shouldRevalidate).toBe(false);
251
+ });
252
+
253
+ it("should return shouldRevalidate=true and atomically mark REVALIDATING for stale entries", async () => {
254
+ vi.setSystemTime(new Date("2024-01-01T00:00:00Z"));
255
+
256
+ const store = new CFCacheStore();
257
+ const data = createTestData();
258
+
259
+ await store.set("test-key", data, 60, 300);
260
+
261
+ // Past TTL but within SWR window
262
+ vi.advanceTimersByTime(120 * 1000);
263
+
264
+ // First get should return shouldRevalidate=true and mark as REVALIDATING
265
+ const result = await store.get("test-key");
266
+ expect(result?.shouldRevalidate).toBe(true);
267
+
268
+ // Verify the entry is now marked as REVALIDATING
269
+ const cache = mockCaches.default;
270
+ const request = new Request(
271
+ "https://rsc-cache.internal.com/" + encodeURIComponent("test-key")
272
+ );
273
+ const response = await cache.match(request);
274
+ expect(response?.headers.get(CACHE_STATUS_HEADER)).toBe("REVALIDATING");
275
+ });
276
+
277
+ it("should return shouldRevalidate=false when already REVALIDATING", async () => {
278
+ vi.setSystemTime(new Date("2024-01-01T00:00:00Z"));
279
+
280
+ const store = new CFCacheStore();
281
+ const data = createTestData();
282
+
283
+ await store.set("test-key", data, 60, 300);
284
+
285
+ // Make it stale
286
+ vi.advanceTimersByTime(120 * 1000);
287
+
288
+ // First get - atomically marks as REVALIDATING
289
+ const result1 = await store.get("test-key");
290
+ expect(result1?.shouldRevalidate).toBe(true);
291
+
292
+ // Second get - already REVALIDATING, should not trigger again
293
+ const result2 = await store.get("test-key");
294
+ expect(result2?.shouldRevalidate).toBe(false);
295
+ });
296
+
297
+ it("should prevent thundering herd with sequential requests", async () => {
298
+ // Note: Real thundering herd prevention relies on CF Cache API's atomic semantics.
299
+ // This test verifies sequential requests work correctly - first triggers revalidation,
300
+ // subsequent ones see REVALIDATING status and don't trigger again.
301
+ vi.setSystemTime(new Date("2024-01-01T00:00:00Z"));
302
+
303
+ const store = new CFCacheStore();
304
+ const data = createTestData();
305
+
306
+ await store.set("test-key", data, 60, 300);
307
+
308
+ // Make it stale
309
+ vi.advanceTimersByTime(120 * 1000);
310
+
311
+ // Sequential requests - first triggers revalidation
312
+ const result1 = await store.get("test-key");
313
+ expect(result1?.shouldRevalidate).toBe(true);
314
+ expect(result1?.data).toBeDefined();
315
+
316
+ // Subsequent requests see REVALIDATING status
317
+ const result2 = await store.get("test-key");
318
+ expect(result2?.shouldRevalidate).toBe(false);
319
+ expect(result2?.data).toBeDefined();
320
+
321
+ const result3 = await store.get("test-key");
322
+ expect(result3?.shouldRevalidate).toBe(false);
323
+ expect(result3?.data).toBeDefined();
324
+ });
325
+ });
326
+
327
+ describe("delete", () => {
328
+ it("should delete existing entry", async () => {
329
+ const store = new CFCacheStore();
330
+ const data = createTestData();
331
+
332
+ await store.set("test-key", data, 60);
333
+ const deleted = await store.delete("test-key");
334
+
335
+ expect(deleted).toBe(true);
336
+
337
+ const result = await store.get("test-key");
338
+ expect(result).toBeNull();
339
+ });
340
+
341
+ it("should return false for non-existent entry", async () => {
342
+ const store = new CFCacheStore();
343
+ const deleted = await store.delete("missing-key");
344
+ expect(deleted).toBe(false);
345
+ });
346
+ });
347
+
348
+ describe("key encoding", () => {
349
+ it("should handle special characters in keys", async () => {
350
+ const store = new CFCacheStore();
351
+ const data = createTestData();
352
+
353
+ const key = "route:products/category=electronics&page=1";
354
+ await store.set(key, data, 60);
355
+
356
+ const result = await store.get(key);
357
+ expect(result).not.toBeNull();
358
+ expect(result!.data).toEqual(data);
359
+ });
360
+ });
361
+ });
@@ -0,0 +1,274 @@
1
+ /// <reference path="../../vite/version.d.ts" />
2
+ /**
3
+ * Cloudflare Edge Cache Store
4
+ *
5
+ * Production cache store using Cloudflare's Cache API.
6
+ * Handles SWR atomically - get() checks staleness and marks REVALIDATING in one operation.
7
+ *
8
+ * Features:
9
+ * - Extended TTL for SWR window (max-age = ttl + swr)
10
+ * - Staleness via x-edge-cache-stale-at header
11
+ * - Atomic REVALIDATING status for thundering herd prevention
12
+ * - Non-blocking writes via waitUntil
13
+ */
14
+
15
+ import type {
16
+ SegmentCacheStore,
17
+ CachedEntryData,
18
+ CacheDefaults,
19
+ CacheGetResult,
20
+ } from "../types.js";
21
+ import type { RequestContext } from "../../server/request-context.js";
22
+ import { VERSION } from "rsc-router:version";
23
+
24
+ // ============================================================================
25
+ // Constants
26
+ // ============================================================================
27
+
28
+ /** Header storing timestamp when entry becomes stale */
29
+ export const CACHE_STALE_AT_HEADER = "x-edge-cache-stale-at";
30
+
31
+ /** Header storing cache status: HIT | REVALIDATING */
32
+ export const CACHE_STATUS_HEADER = "x-edge-cache-status";
33
+
34
+ /**
35
+ * Maximum age in seconds for REVALIDATING status before allowing new revalidation.
36
+ * After this period, a stale entry in REVALIDATING status will trigger revalidation again.
37
+ * @internal
38
+ */
39
+ export const MAX_REVALIDATION_INTERVAL = 30;
40
+
41
+ // ============================================================================
42
+ // Types
43
+ // ============================================================================
44
+
45
+ export interface CFCacheStoreOptions<TEnv = unknown> {
46
+ /**
47
+ * Cache namespace. If not provided, uses caches.default (recommended).
48
+ * Only set this if you need isolated cache storage.
49
+ */
50
+ namespace?: string;
51
+
52
+ /** Base URL for cache keys (default: 'https://rsc-cache.internal.com/') */
53
+ baseUrl?: string;
54
+
55
+ /** Default cache options */
56
+ defaults?: CacheDefaults;
57
+
58
+ /**
59
+ * waitUntil function from request's ExecutionContext.
60
+ * Used for non-blocking cache writes.
61
+ */
62
+ waitUntil?: (fn: () => Promise<void>) => void;
63
+
64
+ /**
65
+ * Cache version string override. When this changes, all cached entries are
66
+ * effectively invalidated (new keys won't match old entries).
67
+ *
68
+ * Defaults to the auto-generated VERSION from `rsc-router:version` virtual module.
69
+ * Only set this if you need a custom versioning strategy.
70
+ */
71
+ version?: string;
72
+
73
+ /**
74
+ * Custom key generator applied to all cache operations.
75
+ * Receives the full RequestContext (including env) and the default-generated key.
76
+ * Return value becomes the final cache key (unless route overrides with `key` option).
77
+ *
78
+ * @example Using headers for user segmentation
79
+ * ```typescript
80
+ * keyGenerator: (ctx, defaultKey) => {
81
+ * const segment = ctx.request.headers.get('x-user-segment') || 'default';
82
+ * return `${segment}:${defaultKey}`;
83
+ * }
84
+ * ```
85
+ *
86
+ * @example Using env bindings for multi-region
87
+ * ```typescript
88
+ * keyGenerator: (ctx, defaultKey) => {
89
+ * const region = ctx.env.REGION || 'us';
90
+ * return `${region}:${defaultKey}`;
91
+ * }
92
+ * ```
93
+ *
94
+ * @example Using cookies for locale-aware caching
95
+ * ```typescript
96
+ * keyGenerator: (ctx, defaultKey) => {
97
+ * const locale = ctx.cookie('locale') || 'en';
98
+ * return `${locale}:${defaultKey}`;
99
+ * }
100
+ * ```
101
+ */
102
+ keyGenerator?: (
103
+ ctx: RequestContext<TEnv>,
104
+ defaultKey: string
105
+ ) => string | Promise<string>;
106
+ }
107
+
108
+ /**
109
+ * Cache status values for the x-edge-cache-status header.
110
+ * @internal
111
+ */
112
+ export type CacheStatus = "HIT" | "REVALIDATING";
113
+
114
+ // ============================================================================
115
+ // CFCacheStore Implementation
116
+ // ============================================================================
117
+
118
+ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
119
+ readonly defaults?: CacheDefaults;
120
+ readonly keyGenerator?: (
121
+ ctx: RequestContext<TEnv>,
122
+ defaultKey: string
123
+ ) => string | Promise<string>;
124
+
125
+ private readonly namespace?: string;
126
+ private readonly baseUrl: string;
127
+ private readonly waitUntil?: (fn: () => Promise<void>) => void;
128
+ private readonly version?: string;
129
+
130
+ constructor(options: CFCacheStoreOptions<TEnv> = {}) {
131
+ this.namespace = options.namespace;
132
+ this.baseUrl = options.baseUrl ?? "https://rsc-cache.internal.com/";
133
+ this.defaults = options.defaults;
134
+ this.waitUntil = options.waitUntil;
135
+ this.version = options.version ?? VERSION;
136
+ this.keyGenerator = options.keyGenerator;
137
+ }
138
+
139
+ /**
140
+ * Get the cache instance - uses caches.default unless namespace is specified.
141
+ * @internal
142
+ */
143
+ private getCache(): Cache | Promise<Cache> {
144
+ if (this.namespace) {
145
+ return caches.open(this.namespace);
146
+ }
147
+ return caches.default;
148
+ }
149
+
150
+ /**
151
+ * Get cached entry data by key.
152
+ *
153
+ * Handles SWR atomically:
154
+ * - If stale and not already revalidating, marks as REVALIDATING and returns shouldRevalidate: true
155
+ * - If already REVALIDATING (and recent), returns shouldRevalidate: false
156
+ * - If fresh, returns shouldRevalidate: false
157
+ *
158
+ * The atomic mark prevents thundering herd - only first request triggers revalidation.
159
+ */
160
+ async get(key: string): Promise<CacheGetResult | null> {
161
+ try {
162
+ const cache = await this.getCache();
163
+ const request = this.keyToRequest(key);
164
+ const response = await cache.match(request);
165
+
166
+ if (!response) {
167
+ return null;
168
+ }
169
+
170
+ // Read status headers
171
+ const status = response.headers.get(CACHE_STATUS_HEADER);
172
+ const age = Number(response.headers.get("age") ?? "0");
173
+ const staleAt = Number(response.headers.get(CACHE_STALE_AT_HEADER) ?? "0");
174
+
175
+ const isStale = staleAt > 0 && Date.now() > staleAt;
176
+ const isRevalidating = status === "REVALIDATING" && age < MAX_REVALIDATION_INTERVAL;
177
+
178
+ // Case 1: Fresh or already being revalidated - just return data
179
+ if (!isStale || isRevalidating) {
180
+ const data = (await response.json()) as CachedEntryData;
181
+ return { data, shouldRevalidate: false };
182
+ }
183
+
184
+ // Case 2: Stale and needs revalidation - atomically mark REVALIDATING
185
+ const [b1, b2] = response.body!.tee();
186
+
187
+ const headers = new Headers(response.headers);
188
+ headers.set(CACHE_STATUS_HEADER, "REVALIDATING");
189
+
190
+ // Blocking write - must complete before returning to prevent race
191
+ await cache.put(
192
+ request,
193
+ new Response(b1, { status: response.status, headers })
194
+ );
195
+
196
+ const data = (await new Response(b2).json()) as CachedEntryData;
197
+ return { data, shouldRevalidate: true };
198
+ } catch (error) {
199
+ console.error("[CFCacheStore] get failed:", error);
200
+ return null;
201
+ }
202
+ }
203
+
204
+ /**
205
+ * Store entry data with TTL and optional SWR window.
206
+ * Uses waitUntil for non-blocking write when available.
207
+ */
208
+ async set(
209
+ key: string,
210
+ data: CachedEntryData,
211
+ ttl: number,
212
+ swr?: number
213
+ ): Promise<void> {
214
+ try {
215
+ const cache = await this.getCache();
216
+ const request = this.keyToRequest(key);
217
+
218
+ // Extended TTL covers SWR window
219
+ const swrWindow = swr ?? this.defaults?.swr ?? 0;
220
+ const totalTtl = ttl + swrWindow;
221
+ const staleAt = Date.now() + ttl * 1000;
222
+
223
+ const response = new Response(JSON.stringify(data), {
224
+ headers: {
225
+ "Content-Type": "application/json",
226
+ "Cache-Control": `public, max-age=${totalTtl}`,
227
+ [CACHE_STALE_AT_HEADER]: String(staleAt),
228
+ [CACHE_STATUS_HEADER]: "HIT",
229
+ },
230
+ });
231
+
232
+ const putPromise = cache.put(request, response);
233
+
234
+ if (this.waitUntil) {
235
+ // Non-blocking write
236
+ this.waitUntil(async () => {
237
+ await putPromise;
238
+ });
239
+ } else {
240
+ // Blocking fallback
241
+ await putPromise;
242
+ }
243
+ } catch (error) {
244
+ console.error("[CFCacheStore] set failed:", error);
245
+ }
246
+ }
247
+
248
+ /**
249
+ * Delete a cached entry
250
+ */
251
+ async delete(key: string): Promise<boolean> {
252
+ try {
253
+ const cache = await this.getCache();
254
+ return await cache.delete(this.keyToRequest(key));
255
+ } catch (error) {
256
+ console.error("[CFCacheStore] delete failed:", error);
257
+ return false;
258
+ }
259
+ }
260
+
261
+ /**
262
+ * Convert string key to Request object for CF Cache API.
263
+ * Includes version in URL if specified (for cache invalidation on code changes).
264
+ * @internal
265
+ */
266
+ private keyToRequest(key: string): Request {
267
+ const encodedKey = encodeURIComponent(key);
268
+ // Include version in URL path to invalidate cache when version changes
269
+ const versionPath = this.version ? `v/${this.version}/` : "";
270
+ return new Request(`${this.baseUrl}${versionPath}${encodedKey}`, {
271
+ method: "GET",
272
+ });
273
+ }
274
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Cloudflare Cache Store Exports
3
+ *
4
+ * Main export:
5
+ * - CFCacheStore - Production cache store using Cloudflare's Cache API
6
+ *
7
+ * Header constants (for inspection/debugging):
8
+ * - CACHE_STALE_AT_HEADER - Header containing staleness timestamp
9
+ * - CACHE_STATUS_HEADER - Header containing HIT/REVALIDATING status
10
+ */
11
+
12
+ // Public API
13
+ export { CFCacheStore, type CFCacheStoreOptions } from "./cf-cache-store.js";
14
+
15
+ // Header constants for debugging and inspection
16
+ export { CACHE_STALE_AT_HEADER, CACHE_STATUS_HEADER } from "./cf-cache-store.js";
17
+
18
+ // Internal exports (re-exported for backwards compatibility, marked @internal in source)
19
+ export { type CacheStatus, MAX_REVALIDATION_INTERVAL } from "./cf-cache-store.js";
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Cache Store
3
+ *
4
+ * Server-side caching for RSC segments and loader data.
5
+ *
6
+ * Main exports for users:
7
+ * - SegmentCacheStore - Interface for implementing custom cache stores
8
+ * - MemorySegmentCacheStore - In-memory cache for development/testing
9
+ * - CFCacheStore - Cloudflare edge cache store for production
10
+ * - CacheScope / createCacheScope - Request-scoped cache provider
11
+ */
12
+
13
+ // Generic cache store types (reserved for future extensibility)
14
+ // These types support caching arbitrary values like Response, Stream, etc.
15
+ // Currently unused - segment caching uses SegmentCacheStore directly.
16
+ export type {
17
+ CacheStore,
18
+ CacheEntry,
19
+ CacheValue,
20
+ CacheValueType,
21
+ CachePutOptions,
22
+ CacheMetadata,
23
+ } from "./types.js";
24
+
25
+ // Generic memory cache (reserved for future extensibility)
26
+ export { MemoryCacheStore } from "./memory-store.js";
27
+
28
+ // Segment cache store types and implementations
29
+ export type {
30
+ SegmentCacheStore,
31
+ SegmentCacheProvider,
32
+ CachedEntryData,
33
+ CachedEntryResult,
34
+ CacheGetResult,
35
+ SerializedSegmentData,
36
+ SegmentHandleData,
37
+ CacheConfig,
38
+ CacheConfigOrFactory,
39
+ } from "./types.js";
40
+
41
+ export { MemorySegmentCacheStore } from "./memory-segment-store.js";
42
+
43
+ // Cloudflare cache store
44
+ export {
45
+ CFCacheStore,
46
+ type CFCacheStoreOptions,
47
+ CACHE_STALE_AT_HEADER,
48
+ CACHE_STATUS_HEADER,
49
+ } from "./cf/index.js";
50
+
51
+ // Cache scope
52
+ export { CacheScope, createCacheScope } from "./cache-scope.js";