@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.
- package/README.md +19 -0
- package/package.json +131 -0
- package/src/__mocks__/version.ts +6 -0
- package/src/__tests__/route-definition.test.ts +63 -0
- package/src/browser/event-controller.ts +876 -0
- package/src/browser/index.ts +18 -0
- package/src/browser/link-interceptor.ts +121 -0
- package/src/browser/lru-cache.ts +69 -0
- package/src/browser/merge-segment-loaders.ts +126 -0
- package/src/browser/navigation-bridge.ts +891 -0
- package/src/browser/navigation-client.ts +155 -0
- package/src/browser/navigation-store.ts +823 -0
- package/src/browser/partial-update.ts +545 -0
- package/src/browser/react/Link.tsx +248 -0
- package/src/browser/react/NavigationProvider.tsx +228 -0
- package/src/browser/react/ScrollRestoration.tsx +94 -0
- package/src/browser/react/context.ts +53 -0
- package/src/browser/react/index.ts +52 -0
- package/src/browser/react/location-state-shared.ts +120 -0
- package/src/browser/react/location-state.ts +62 -0
- package/src/browser/react/use-action.ts +240 -0
- package/src/browser/react/use-client-cache.ts +56 -0
- package/src/browser/react/use-handle.ts +178 -0
- package/src/browser/react/use-link-status.ts +134 -0
- package/src/browser/react/use-navigation.ts +150 -0
- package/src/browser/react/use-segments.ts +188 -0
- package/src/browser/request-controller.ts +149 -0
- package/src/browser/rsc-router.tsx +310 -0
- package/src/browser/scroll-restoration.ts +324 -0
- package/src/browser/server-action-bridge.ts +747 -0
- package/src/browser/shallow.ts +35 -0
- package/src/browser/types.ts +443 -0
- package/src/cache/__tests__/memory-segment-store.test.ts +487 -0
- package/src/cache/__tests__/memory-store.test.ts +484 -0
- package/src/cache/cache-scope.ts +565 -0
- package/src/cache/cf/__tests__/cf-cache-store.test.ts +361 -0
- package/src/cache/cf/cf-cache-store.ts +274 -0
- package/src/cache/cf/index.ts +19 -0
- package/src/cache/index.ts +52 -0
- package/src/cache/memory-segment-store.ts +150 -0
- package/src/cache/memory-store.ts +253 -0
- package/src/cache/types.ts +366 -0
- package/src/client.rsc.tsx +88 -0
- package/src/client.tsx +609 -0
- package/src/components/DefaultDocument.tsx +20 -0
- package/src/default-error-boundary.tsx +88 -0
- package/src/deps/browser.ts +8 -0
- package/src/deps/html-stream-client.ts +2 -0
- package/src/deps/html-stream-server.ts +2 -0
- package/src/deps/rsc.ts +10 -0
- package/src/deps/ssr.ts +2 -0
- package/src/errors.ts +259 -0
- package/src/handle.ts +120 -0
- package/src/handles/MetaTags.tsx +178 -0
- package/src/handles/index.ts +6 -0
- package/src/handles/meta.ts +247 -0
- package/src/href-client.ts +128 -0
- package/src/href.ts +139 -0
- package/src/index.rsc.ts +69 -0
- package/src/index.ts +84 -0
- package/src/loader.rsc.ts +204 -0
- package/src/loader.ts +47 -0
- package/src/network-error-thrower.tsx +21 -0
- package/src/outlet-context.ts +15 -0
- package/src/root-error-boundary.tsx +277 -0
- package/src/route-content-wrapper.tsx +198 -0
- package/src/route-definition.ts +1333 -0
- package/src/route-map-builder.ts +140 -0
- package/src/route-types.ts +148 -0
- package/src/route-utils.ts +89 -0
- package/src/router/__tests__/match-context.test.ts +104 -0
- package/src/router/__tests__/match-pipelines.test.ts +537 -0
- package/src/router/__tests__/match-result.test.ts +566 -0
- package/src/router/__tests__/on-error.test.ts +935 -0
- package/src/router/__tests__/pattern-matching.test.ts +577 -0
- package/src/router/error-handling.ts +287 -0
- package/src/router/handler-context.ts +60 -0
- package/src/router/loader-resolution.ts +326 -0
- package/src/router/manifest.ts +116 -0
- package/src/router/match-context.ts +261 -0
- package/src/router/match-middleware/background-revalidation.ts +236 -0
- package/src/router/match-middleware/cache-lookup.ts +261 -0
- package/src/router/match-middleware/cache-store.ts +250 -0
- package/src/router/match-middleware/index.ts +81 -0
- package/src/router/match-middleware/intercept-resolution.ts +268 -0
- package/src/router/match-middleware/segment-resolution.ts +174 -0
- package/src/router/match-pipelines.ts +214 -0
- package/src/router/match-result.ts +212 -0
- package/src/router/metrics.ts +62 -0
- package/src/router/middleware.test.ts +1355 -0
- package/src/router/middleware.ts +748 -0
- package/src/router/pattern-matching.ts +271 -0
- package/src/router/revalidation.ts +190 -0
- package/src/router/router-context.ts +299 -0
- package/src/router/types.ts +96 -0
- package/src/router.ts +3484 -0
- package/src/rsc/__tests__/helpers.test.ts +175 -0
- package/src/rsc/handler.ts +942 -0
- package/src/rsc/helpers.ts +64 -0
- package/src/rsc/index.ts +56 -0
- package/src/rsc/nonce.ts +18 -0
- package/src/rsc/types.ts +225 -0
- package/src/segment-system.tsx +405 -0
- package/src/server/__tests__/request-context.test.ts +171 -0
- package/src/server/context.ts +340 -0
- package/src/server/handle-store.ts +230 -0
- package/src/server/loader-registry.ts +174 -0
- package/src/server/request-context.ts +470 -0
- package/src/server/root-layout.tsx +10 -0
- package/src/server/tsconfig.json +14 -0
- package/src/server.ts +126 -0
- package/src/ssr/__tests__/ssr-handler.test.tsx +188 -0
- package/src/ssr/index.tsx +215 -0
- package/src/types.ts +1473 -0
- package/src/use-loader.tsx +346 -0
- package/src/vite/__tests__/expose-loader-id.test.ts +117 -0
- package/src/vite/expose-action-id.ts +344 -0
- package/src/vite/expose-handle-id.ts +209 -0
- package/src/vite/expose-loader-id.ts +357 -0
- package/src/vite/expose-location-state-id.ts +177 -0
- package/src/vite/index.ts +608 -0
- package/src/vite/version.d.ts +12 -0
- package/src/vite/virtual-entries.ts +109 -0
|
@@ -0,0 +1,565 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CacheScope - Runtime cache scope for iterator-based caching
|
|
3
|
+
*
|
|
4
|
+
* Each cache() boundary in the route tree creates a new CacheScope.
|
|
5
|
+
* The scope owns: config, serialization, and storage operations.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/// <reference types="@vitejs/plugin-rsc/types" />
|
|
9
|
+
|
|
10
|
+
import type { PartialCacheOptions } from "../types.js";
|
|
11
|
+
import type { ResolvedSegment } from "../types.js";
|
|
12
|
+
import type {
|
|
13
|
+
SegmentCacheStore,
|
|
14
|
+
SegmentHandleData,
|
|
15
|
+
CachedEntryData,
|
|
16
|
+
SerializedSegmentData,
|
|
17
|
+
} from "./types.js";
|
|
18
|
+
import { getRequestContext } from "../server/request-context.js";
|
|
19
|
+
import {
|
|
20
|
+
renderToReadableStream,
|
|
21
|
+
createTemporaryReferenceSet,
|
|
22
|
+
} from "@vitejs/plugin-rsc/rsc";
|
|
23
|
+
import { createFromReadableStream } from "@vitejs/plugin-rsc/rsc";
|
|
24
|
+
|
|
25
|
+
// ============================================================================
|
|
26
|
+
// Constants
|
|
27
|
+
// ============================================================================
|
|
28
|
+
|
|
29
|
+
/** Default TTL when no explicit value or store defaults are configured */
|
|
30
|
+
const DEFAULT_TTL_SECONDS = 60;
|
|
31
|
+
|
|
32
|
+
// ============================================================================
|
|
33
|
+
// Serialization Utilities (internal)
|
|
34
|
+
// ============================================================================
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Generate cache key base from pathname and params.
|
|
38
|
+
* Params are sorted alphabetically for consistent key generation.
|
|
39
|
+
* @internal
|
|
40
|
+
*/
|
|
41
|
+
function getCacheKeyBase(
|
|
42
|
+
pathname: string,
|
|
43
|
+
params?: Record<string, string>
|
|
44
|
+
): string {
|
|
45
|
+
const paramStr = params
|
|
46
|
+
? Object.entries(params)
|
|
47
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
48
|
+
.map(([k, v]) => `${k}=${v}`)
|
|
49
|
+
.join("&")
|
|
50
|
+
: "";
|
|
51
|
+
|
|
52
|
+
return paramStr ? `${pathname}:${paramStr}` : pathname;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Generate default cache key for a route request.
|
|
57
|
+
* Single cache entry per route - uses pathname as the key.
|
|
58
|
+
* Includes request type prefix since they produce different segment sets:
|
|
59
|
+
* - doc: document requests (full page load)
|
|
60
|
+
* - partial: navigation requests (client-side navigation)
|
|
61
|
+
* - intercept: intercept navigation (modal/overlay routes)
|
|
62
|
+
* @internal
|
|
63
|
+
*/
|
|
64
|
+
function getDefaultRouteCacheKey(
|
|
65
|
+
pathname: string,
|
|
66
|
+
params?: Record<string, string>,
|
|
67
|
+
isIntercept?: boolean
|
|
68
|
+
): string {
|
|
69
|
+
const ctx = getRequestContext();
|
|
70
|
+
const isPartial = ctx?.url.searchParams.has("_rsc_partial") ?? false;
|
|
71
|
+
|
|
72
|
+
// Intercept navigations get their own cache namespace
|
|
73
|
+
const prefix = isIntercept ? "intercept" : isPartial ? "partial" : "doc";
|
|
74
|
+
|
|
75
|
+
return `${prefix}:${getCacheKeyBase(pathname, params)}`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Convert a ReadableStream to a string.
|
|
80
|
+
* @internal
|
|
81
|
+
*/
|
|
82
|
+
async function streamToString(
|
|
83
|
+
stream: ReadableStream<Uint8Array>
|
|
84
|
+
): Promise<string> {
|
|
85
|
+
const reader = stream.getReader();
|
|
86
|
+
const decoder = new TextDecoder();
|
|
87
|
+
let result = "";
|
|
88
|
+
|
|
89
|
+
while (true) {
|
|
90
|
+
const { done, value } = await reader.read();
|
|
91
|
+
if (done) break;
|
|
92
|
+
result += decoder.decode(value, { stream: true });
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
result += decoder.decode(); // flush
|
|
96
|
+
return result;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Convert a string to a ReadableStream.
|
|
101
|
+
* @internal
|
|
102
|
+
*/
|
|
103
|
+
function stringToStream(str: string): ReadableStream<Uint8Array> {
|
|
104
|
+
const encoder = new TextEncoder();
|
|
105
|
+
const uint8 = encoder.encode(str);
|
|
106
|
+
|
|
107
|
+
return new ReadableStream({
|
|
108
|
+
start(controller) {
|
|
109
|
+
controller.enqueue(uint8);
|
|
110
|
+
controller.close();
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* RSC-serialize a value using React Server Components stream.
|
|
117
|
+
* Used for serializing loaderData, layout, loading components etc.
|
|
118
|
+
* @internal
|
|
119
|
+
*/
|
|
120
|
+
async function rscSerialize(value: unknown): Promise<string | undefined> {
|
|
121
|
+
if (value === undefined || value === null) return undefined;
|
|
122
|
+
|
|
123
|
+
const temporaryReferences = createTemporaryReferenceSet();
|
|
124
|
+
const stream = renderToReadableStream(value, { temporaryReferences });
|
|
125
|
+
return streamToString(stream);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* RSC-deserialize a value from a stored string.
|
|
130
|
+
* @internal
|
|
131
|
+
*/
|
|
132
|
+
async function rscDeserialize<T>(
|
|
133
|
+
encoded: string | undefined
|
|
134
|
+
): Promise<T | undefined> {
|
|
135
|
+
if (!encoded) return undefined;
|
|
136
|
+
|
|
137
|
+
const temporaryReferences = createTemporaryReferenceSet();
|
|
138
|
+
const stream = stringToStream(encoded);
|
|
139
|
+
return createFromReadableStream<T>(stream, { temporaryReferences });
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Serialize segments for storage.
|
|
144
|
+
* Each segment's component, layout, loading, and loaderData are RSC-serialized.
|
|
145
|
+
* Metadata is preserved as-is.
|
|
146
|
+
* @internal
|
|
147
|
+
*/
|
|
148
|
+
async function serializeSegments(
|
|
149
|
+
segments: ResolvedSegment[]
|
|
150
|
+
): Promise<SerializedSegmentData[]> {
|
|
151
|
+
const serialized: SerializedSegmentData[] = [];
|
|
152
|
+
|
|
153
|
+
for (const segment of segments) {
|
|
154
|
+
const temporaryReferences = createTemporaryReferenceSet();
|
|
155
|
+
|
|
156
|
+
// Await component if it's a Promise (intercepts with loading keep component as Promise)
|
|
157
|
+
const componentResolved =
|
|
158
|
+
segment.component instanceof Promise
|
|
159
|
+
? await segment.component
|
|
160
|
+
: segment.component;
|
|
161
|
+
|
|
162
|
+
// Serialize the component to RSC stream
|
|
163
|
+
const stream = renderToReadableStream(componentResolved, {
|
|
164
|
+
temporaryReferences,
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
// Convert stream to string
|
|
168
|
+
const encoded = await streamToString(stream);
|
|
169
|
+
|
|
170
|
+
// RSC-serialize layout if present (ReactNode)
|
|
171
|
+
const encodedLayout = segment.layout
|
|
172
|
+
? await rscSerialize(segment.layout)
|
|
173
|
+
: undefined;
|
|
174
|
+
|
|
175
|
+
// RSC-serialize loading if present (ReactNode) - preserves tree structure
|
|
176
|
+
// Use "null" string to distinguish explicit null from undefined
|
|
177
|
+
const encodedLoading =
|
|
178
|
+
segment.loading !== undefined
|
|
179
|
+
? segment.loading === null
|
|
180
|
+
? "null"
|
|
181
|
+
: await rscSerialize(segment.loading)
|
|
182
|
+
: undefined;
|
|
183
|
+
|
|
184
|
+
// Await and RSC-serialize loaderData if present
|
|
185
|
+
const loaderDataResolved =
|
|
186
|
+
segment.loaderData instanceof Promise
|
|
187
|
+
? await segment.loaderData
|
|
188
|
+
: segment.loaderData;
|
|
189
|
+
const encodedLoaderData = await rscSerialize(loaderDataResolved);
|
|
190
|
+
|
|
191
|
+
// Await and RSC-serialize loaderDataPromise if present
|
|
192
|
+
const loaderDataPromiseResolved =
|
|
193
|
+
segment.loaderDataPromise instanceof Promise
|
|
194
|
+
? await segment.loaderDataPromise
|
|
195
|
+
: segment.loaderDataPromise;
|
|
196
|
+
const encodedLoaderDataPromise = await rscSerialize(
|
|
197
|
+
loaderDataPromiseResolved
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
serialized.push({
|
|
201
|
+
encoded,
|
|
202
|
+
encodedLayout,
|
|
203
|
+
encodedLoading,
|
|
204
|
+
encodedLoaderData,
|
|
205
|
+
encodedLoaderDataPromise,
|
|
206
|
+
metadata: {
|
|
207
|
+
id: segment.id,
|
|
208
|
+
type: segment.type,
|
|
209
|
+
namespace: segment.namespace,
|
|
210
|
+
index: segment.index,
|
|
211
|
+
params: segment.params,
|
|
212
|
+
slot: segment.slot,
|
|
213
|
+
belongsToRoute: segment.belongsToRoute,
|
|
214
|
+
layoutName: segment.layoutName,
|
|
215
|
+
parallelName: segment.parallelName,
|
|
216
|
+
loaderId: segment.loaderId,
|
|
217
|
+
loaderIds: segment.loaderIds,
|
|
218
|
+
},
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return serialized;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Deserialize segments from storage.
|
|
227
|
+
* Reconstructs ResolvedSegment objects from RSC-serialized data.
|
|
228
|
+
* @internal
|
|
229
|
+
*/
|
|
230
|
+
async function deserializeSegments(
|
|
231
|
+
data: SerializedSegmentData[]
|
|
232
|
+
): Promise<ResolvedSegment[]> {
|
|
233
|
+
const segments: ResolvedSegment[] = [];
|
|
234
|
+
|
|
235
|
+
for (const item of data) {
|
|
236
|
+
const temporaryReferences = createTemporaryReferenceSet();
|
|
237
|
+
|
|
238
|
+
// Revive the component from cached string
|
|
239
|
+
const stream = stringToStream(item.encoded);
|
|
240
|
+
const component = await createFromReadableStream(stream, {
|
|
241
|
+
temporaryReferences,
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
// RSC-deserialize layout, loaderData, loaderDataPromise in parallel
|
|
245
|
+
const [layout, loaderData, loaderDataPromise, loadingData] =
|
|
246
|
+
await Promise.all([
|
|
247
|
+
rscDeserialize(item.encodedLayout),
|
|
248
|
+
rscDeserialize(item.encodedLoaderData),
|
|
249
|
+
rscDeserialize(item.encodedLoaderDataPromise),
|
|
250
|
+
rscDeserialize(item.encodedLoading),
|
|
251
|
+
]);
|
|
252
|
+
|
|
253
|
+
segments.push({
|
|
254
|
+
...item.metadata,
|
|
255
|
+
component: await component,
|
|
256
|
+
layout,
|
|
257
|
+
loading: loadingData,
|
|
258
|
+
loaderData,
|
|
259
|
+
loaderDataPromise,
|
|
260
|
+
} as ResolvedSegment);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return segments;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// ============================================================================
|
|
267
|
+
// CacheScope
|
|
268
|
+
// ============================================================================
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* CacheScope represents a cache boundary in the route tree.
|
|
272
|
+
*
|
|
273
|
+
* When withCache encounters an entry with cache config, it creates
|
|
274
|
+
* a new CacheScope. The scope owns serialization, storage, and TTL.
|
|
275
|
+
*
|
|
276
|
+
* Store resolution priority:
|
|
277
|
+
* 1. Explicit store in cache() options
|
|
278
|
+
* 2. App-level store from handler config
|
|
279
|
+
*
|
|
280
|
+
* TTL resolution priority:
|
|
281
|
+
* 1. Explicit value in cache() options
|
|
282
|
+
* 2. Explicit store's defaults (if store specified)
|
|
283
|
+
* 3. App-level store's defaults
|
|
284
|
+
* 4. Hardcoded fallback (60 seconds)
|
|
285
|
+
*/
|
|
286
|
+
export class CacheScope {
|
|
287
|
+
readonly config: PartialCacheOptions | false;
|
|
288
|
+
readonly parent: CacheScope | null;
|
|
289
|
+
/** Explicit store from cache() options, if specified */
|
|
290
|
+
private readonly explicitStore: SegmentCacheStore | undefined;
|
|
291
|
+
|
|
292
|
+
constructor(
|
|
293
|
+
config: PartialCacheOptions | false,
|
|
294
|
+
parent: CacheScope | null = null
|
|
295
|
+
) {
|
|
296
|
+
this.config = config;
|
|
297
|
+
this.parent = parent;
|
|
298
|
+
// Extract and store explicit store reference
|
|
299
|
+
this.explicitStore = config !== false ? config.store : undefined;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Whether caching is enabled for this scope
|
|
304
|
+
*/
|
|
305
|
+
get enabled(): boolean {
|
|
306
|
+
return this.config !== false;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Get effective TTL from config or store defaults
|
|
311
|
+
*/
|
|
312
|
+
get ttl(): number {
|
|
313
|
+
if (this.config === false) return 0;
|
|
314
|
+
|
|
315
|
+
// Explicit TTL in cache() options
|
|
316
|
+
if (this.config.ttl !== undefined) {
|
|
317
|
+
return this.config.ttl;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Fall back to store defaults (explicit store first, then app-level)
|
|
321
|
+
const store = this.getStore();
|
|
322
|
+
if (store?.defaults?.ttl !== undefined) {
|
|
323
|
+
return store.defaults.ttl;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Hardcoded fallback
|
|
327
|
+
return DEFAULT_TTL_SECONDS;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Get SWR window from config or store defaults
|
|
332
|
+
*/
|
|
333
|
+
get swr(): number | undefined {
|
|
334
|
+
if (this.config === false) return undefined;
|
|
335
|
+
|
|
336
|
+
// Explicit SWR in cache() options
|
|
337
|
+
if (this.config.swr !== undefined) {
|
|
338
|
+
return this.config.swr;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// Fall back to store defaults
|
|
342
|
+
const store = this.getStore();
|
|
343
|
+
return store?.defaults?.swr;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Get the cache store - resolution priority:
|
|
348
|
+
* 1. Explicit store from cache() options
|
|
349
|
+
* 2. App-level store from request context
|
|
350
|
+
*/
|
|
351
|
+
private getStore(): SegmentCacheStore | null {
|
|
352
|
+
// Explicit store from cache() options takes precedence
|
|
353
|
+
if (this.explicitStore) {
|
|
354
|
+
return this.explicitStore;
|
|
355
|
+
}
|
|
356
|
+
// Fall back to app-level store from request context
|
|
357
|
+
const ctx = getRequestContext();
|
|
358
|
+
return ctx?._cacheStore ?? null;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Resolve the cache key using custom key functions or default generation.
|
|
363
|
+
*
|
|
364
|
+
* Resolution priority:
|
|
365
|
+
* 1. Route-level `key` function (full override)
|
|
366
|
+
* 2. Store-level `keyGenerator` (modifies default key)
|
|
367
|
+
* 3. Default key generation (prefix:pathname:params)
|
|
368
|
+
*
|
|
369
|
+
* @internal
|
|
370
|
+
*/
|
|
371
|
+
private async resolveKey(
|
|
372
|
+
pathname: string,
|
|
373
|
+
params: Record<string, string>,
|
|
374
|
+
isIntercept?: boolean
|
|
375
|
+
): Promise<string> {
|
|
376
|
+
const requestCtx = getRequestContext();
|
|
377
|
+
if (!requestCtx) {
|
|
378
|
+
// Fallback to default key if no request context
|
|
379
|
+
return getDefaultRouteCacheKey(pathname, params, isIntercept);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// Priority 1: Route-level key function (full override)
|
|
383
|
+
if (this.config !== false && this.config.key) {
|
|
384
|
+
try {
|
|
385
|
+
const customKey = await this.config.key(requestCtx);
|
|
386
|
+
return customKey;
|
|
387
|
+
} catch (error) {
|
|
388
|
+
console.error(`[CacheScope] Custom key function failed, using default:`, error);
|
|
389
|
+
return getDefaultRouteCacheKey(pathname, params, isIntercept);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// Generate default key
|
|
394
|
+
const defaultKey = getDefaultRouteCacheKey(pathname, params, isIntercept);
|
|
395
|
+
|
|
396
|
+
// Priority 2: Store-level keyGenerator (modifies default key)
|
|
397
|
+
const store = this.getStore();
|
|
398
|
+
if (store?.keyGenerator) {
|
|
399
|
+
try {
|
|
400
|
+
const modifiedKey = await store.keyGenerator(requestCtx, defaultKey);
|
|
401
|
+
return modifiedKey;
|
|
402
|
+
} catch (error) {
|
|
403
|
+
console.error(`[CacheScope] Store keyGenerator failed, using default:`, error);
|
|
404
|
+
return defaultKey;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// Priority 3: Default key
|
|
409
|
+
return defaultKey;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Lookup cached segments for a route (single cache entry per request).
|
|
414
|
+
* Returns { segments, shouldRevalidate } or null if cache miss.
|
|
415
|
+
*
|
|
416
|
+
* @param pathname - URL pathname for cache key generation
|
|
417
|
+
* @param params - Route params for cache key generation
|
|
418
|
+
* @param isIntercept - Whether this is an intercept navigation (uses different cache key)
|
|
419
|
+
*/
|
|
420
|
+
async lookupRoute(
|
|
421
|
+
pathname: string,
|
|
422
|
+
params: Record<string, string>,
|
|
423
|
+
isIntercept?: boolean
|
|
424
|
+
): Promise<{
|
|
425
|
+
segments: ResolvedSegment[];
|
|
426
|
+
shouldRevalidate: boolean;
|
|
427
|
+
} | null> {
|
|
428
|
+
if (!this.enabled) return null;
|
|
429
|
+
|
|
430
|
+
const store = this.getStore();
|
|
431
|
+
if (!store) return null;
|
|
432
|
+
|
|
433
|
+
// Resolve cache key (may use custom key functions)
|
|
434
|
+
const key = await this.resolveKey(pathname, params, isIntercept);
|
|
435
|
+
|
|
436
|
+
try {
|
|
437
|
+
const result = await store.get(key);
|
|
438
|
+
|
|
439
|
+
if (!result) {
|
|
440
|
+
console.log(`[CacheScope] MISS: ${key}`);
|
|
441
|
+
return null;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
const { data: cached, shouldRevalidate } = result;
|
|
445
|
+
|
|
446
|
+
// Deserialize segments
|
|
447
|
+
const segments = await deserializeSegments(cached.segments);
|
|
448
|
+
|
|
449
|
+
// Replay handle data
|
|
450
|
+
const handleStore = getRequestContext()?._handleStore;
|
|
451
|
+
if (handleStore) {
|
|
452
|
+
for (const [segId, segHandles] of Object.entries(cached.handles)) {
|
|
453
|
+
if (Object.keys(segHandles).length > 0) {
|
|
454
|
+
handleStore.replaySegmentData(segId, segHandles);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const segmentTypes = segments.map((s) =>
|
|
460
|
+
s.type === "parallel" ? s.slot : s.type
|
|
461
|
+
);
|
|
462
|
+
console.log(
|
|
463
|
+
`[CacheScope] ${shouldRevalidate ? "STALE" : "HIT"}: ${key} (${segmentTypes.join(", ")})`
|
|
464
|
+
);
|
|
465
|
+
|
|
466
|
+
return { segments, shouldRevalidate };
|
|
467
|
+
} catch (error) {
|
|
468
|
+
console.error(`[CacheScope] Failed to lookup ${key}:`, error);
|
|
469
|
+
return null;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* Cache all segments for a route (non-blocking via waitUntil)
|
|
475
|
+
* Single cache entry per route request.
|
|
476
|
+
* Loaders are excluded - they're always fresh unless they have their own cache() config.
|
|
477
|
+
*
|
|
478
|
+
* @param pathname - URL pathname for cache key generation
|
|
479
|
+
* @param params - Route params for cache key generation
|
|
480
|
+
* @param segments - All resolved segments to cache
|
|
481
|
+
* @param isIntercept - Whether this is an intercept navigation (uses different cache key)
|
|
482
|
+
*/
|
|
483
|
+
async cacheRoute(
|
|
484
|
+
pathname: string,
|
|
485
|
+
params: Record<string, string>,
|
|
486
|
+
segments: ResolvedSegment[],
|
|
487
|
+
isIntercept?: boolean
|
|
488
|
+
): Promise<void> {
|
|
489
|
+
if (!this.enabled || segments.length === 0) return;
|
|
490
|
+
|
|
491
|
+
const store = this.getStore();
|
|
492
|
+
if (!store) return;
|
|
493
|
+
|
|
494
|
+
const requestCtx = getRequestContext();
|
|
495
|
+
const handleStore = requestCtx?._handleStore;
|
|
496
|
+
|
|
497
|
+
if (!handleStore || !requestCtx) return;
|
|
498
|
+
|
|
499
|
+
// Exclude loader segments - loaders are always fresh by default
|
|
500
|
+
// Loaders can opt-in to caching with their own cache() config
|
|
501
|
+
const nonLoaderSegments = segments.filter((s) => s.type !== "loader");
|
|
502
|
+
if (nonLoaderSegments.length === 0) return;
|
|
503
|
+
|
|
504
|
+
const ttl = this.ttl;
|
|
505
|
+
const swr = this.swr;
|
|
506
|
+
|
|
507
|
+
// Resolve cache key early (while request context is available)
|
|
508
|
+
const key = await this.resolveKey(pathname, params, isIntercept);
|
|
509
|
+
|
|
510
|
+
// Check if this is a partial request (navigation) vs document request
|
|
511
|
+
const isPartial = requestCtx.url.searchParams.has("_rsc_partial");
|
|
512
|
+
|
|
513
|
+
requestCtx.waitUntil(async () => {
|
|
514
|
+
await handleStore.settled;
|
|
515
|
+
|
|
516
|
+
// For document requests: only cache if ALL segments have components (complete render)
|
|
517
|
+
// For partial requests: null components are expected (client already has them)
|
|
518
|
+
if (!isPartial) {
|
|
519
|
+
const hasAllComponents = nonLoaderSegments.every(
|
|
520
|
+
(s) => s.component !== null
|
|
521
|
+
);
|
|
522
|
+
if (!hasAllComponents) return;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// Collect handle data for non-loader segments only
|
|
526
|
+
const handles: Record<string, SegmentHandleData> = {};
|
|
527
|
+
for (const seg of nonLoaderSegments) {
|
|
528
|
+
handles[seg.id] = handleStore.getDataForSegment(seg.id);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
try {
|
|
532
|
+
// Serialize non-loader segments only
|
|
533
|
+
const serializedSegments = await serializeSegments(nonLoaderSegments);
|
|
534
|
+
|
|
535
|
+
const data: CachedEntryData = {
|
|
536
|
+
segments: serializedSegments,
|
|
537
|
+
handles,
|
|
538
|
+
expiresAt: Date.now() + ttl * 1000,
|
|
539
|
+
};
|
|
540
|
+
|
|
541
|
+
await store.set(key, data, ttl, swr);
|
|
542
|
+
|
|
543
|
+
const segmentTypes = nonLoaderSegments.map((s) =>
|
|
544
|
+
s.type === "parallel" ? s.slot : s.type
|
|
545
|
+
);
|
|
546
|
+
console.log(
|
|
547
|
+
`[CacheScope] Cached: ${key} (${segmentTypes.join(", ")}) ttl=${ttl}s [loaders excluded]`
|
|
548
|
+
);
|
|
549
|
+
} catch (error) {
|
|
550
|
+
console.error(`[CacheScope] Failed to cache ${key}:`, error);
|
|
551
|
+
}
|
|
552
|
+
});
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/**
|
|
557
|
+
* Create a cache scope from entry's cache config
|
|
558
|
+
*/
|
|
559
|
+
export function createCacheScope(
|
|
560
|
+
config: { options: PartialCacheOptions | false } | undefined,
|
|
561
|
+
parent: CacheScope | null = null
|
|
562
|
+
): CacheScope | null {
|
|
563
|
+
if (!config) return parent; // No config, inherit parent
|
|
564
|
+
return new CacheScope(config.options, parent);
|
|
565
|
+
}
|