@rangojs/router 0.0.0-experimental.18 → 0.0.0-experimental.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +46 -8
- package/dist/bin/rango.js +105 -18
- package/dist/vite/index.js +227 -93
- package/package.json +15 -14
- package/skills/hooks/SKILL.md +1 -1
- package/skills/intercept/SKILL.md +79 -0
- package/skills/layout/SKILL.md +62 -2
- package/skills/loader/SKILL.md +94 -1
- package/skills/middleware/SKILL.md +81 -0
- package/skills/parallel/SKILL.md +57 -2
- package/skills/prerender/SKILL.md +187 -17
- package/skills/route/SKILL.md +42 -1
- package/skills/router-setup/SKILL.md +77 -0
- package/src/__internal.ts +1 -1
- package/src/bin/rango.ts +38 -19
- package/src/browser/action-coordinator.ts +97 -0
- package/src/browser/event-controller.ts +25 -27
- package/src/browser/history-state.ts +80 -0
- package/src/browser/intercept-utils.ts +1 -1
- package/src/browser/link-interceptor.ts +0 -3
- package/src/browser/merge-segment-loaders.ts +9 -2
- package/src/browser/navigation-bridge.ts +46 -13
- package/src/browser/navigation-client.ts +32 -61
- package/src/browser/navigation-store.ts +1 -31
- package/src/browser/navigation-transaction.ts +46 -207
- package/src/browser/partial-update.ts +102 -150
- package/src/browser/{prefetch-cache.ts → prefetch/cache.ts} +23 -4
- package/src/browser/{prefetch-fetch.ts → prefetch/fetch.ts} +36 -8
- package/src/browser/prefetch/policy.ts +42 -0
- package/src/browser/{prefetch-queue.ts → prefetch/queue.ts} +10 -3
- package/src/browser/react/Link.tsx +28 -23
- package/src/browser/react/NavigationProvider.tsx +9 -1
- package/src/browser/react/index.ts +2 -6
- package/src/browser/react/location-state-shared.ts +1 -1
- package/src/browser/react/location-state.ts +2 -0
- package/src/browser/react/nonce-context.ts +23 -0
- package/src/browser/react/use-action.ts +9 -1
- package/src/browser/react/use-handle.ts +3 -25
- package/src/browser/react/use-params.ts +2 -4
- package/src/browser/react/use-pathname.ts +2 -3
- package/src/browser/react/use-router.ts +1 -1
- package/src/browser/react/use-search-params.ts +2 -1
- package/src/browser/react/use-segments.ts +7 -60
- package/src/browser/response-adapter.ts +73 -0
- package/src/browser/rsc-router.tsx +29 -23
- package/src/browser/scroll-restoration.ts +10 -7
- package/src/browser/server-action-bridge.ts +115 -96
- package/src/browser/types.ts +1 -31
- package/src/browser/validate-redirect-origin.ts +29 -0
- package/src/build/generate-manifest.ts +5 -0
- package/src/build/generate-route-types.ts +2 -0
- package/src/build/route-types/codegen.ts +13 -4
- package/src/build/route-types/include-resolution.ts +13 -0
- package/src/build/route-types/per-module-writer.ts +15 -3
- package/src/build/route-types/router-processing.ts +45 -3
- package/src/build/runtime-discovery.ts +13 -1
- package/src/cache/background-task.ts +34 -0
- package/src/cache/cache-key-utils.ts +44 -0
- package/src/cache/cache-policy.ts +125 -0
- package/src/cache/cache-runtime.ts +132 -96
- package/src/cache/cache-scope.ts +71 -73
- package/src/cache/cf/cf-cache-store.ts +9 -4
- package/src/cache/document-cache.ts +72 -47
- package/src/cache/handle-capture.ts +81 -0
- package/src/cache/memory-segment-store.ts +18 -7
- package/src/cache/profile-registry.ts +43 -8
- package/src/cache/read-through-swr.ts +134 -0
- package/src/cache/segment-codec.ts +101 -112
- package/src/cache/taint.ts +26 -0
- package/src/client.tsx +53 -30
- package/src/errors.ts +6 -1
- package/src/handle.ts +1 -1
- package/src/handles/MetaTags.tsx +5 -2
- package/src/host/cookie-handler.ts +8 -3
- package/src/host/router.ts +14 -1
- package/src/href-client.ts +3 -1
- package/src/index.rsc.ts +33 -1
- package/src/index.ts +27 -0
- package/src/loader.rsc.ts +12 -4
- package/src/loader.ts +8 -0
- package/src/prerender/store.ts +4 -3
- package/src/prerender.ts +76 -18
- package/src/reverse.ts +11 -7
- package/src/root-error-boundary.tsx +30 -26
- package/src/route-definition/dsl-helpers.ts +9 -6
- package/src/route-definition/redirect.ts +15 -3
- package/src/route-map-builder.ts +38 -2
- package/src/route-name.ts +53 -0
- package/src/route-types.ts +7 -0
- package/src/router/content-negotiation.ts +1 -1
- package/src/router/debug-manifest.ts +16 -3
- package/src/router/handler-context.ts +94 -15
- package/src/router/intercept-resolution.ts +6 -4
- package/src/router/lazy-includes.ts +4 -0
- package/src/router/loader-resolution.ts +1 -0
- package/src/router/logging.ts +100 -3
- package/src/router/manifest.ts +32 -3
- package/src/router/match-api.ts +61 -7
- package/src/router/match-context.ts +3 -0
- package/src/router/match-handlers.ts +185 -11
- package/src/router/match-middleware/background-revalidation.ts +65 -85
- package/src/router/match-middleware/cache-lookup.ts +69 -4
- package/src/router/match-middleware/cache-store.ts +2 -0
- package/src/router/match-pipelines.ts +8 -43
- package/src/router/middleware-types.ts +7 -0
- package/src/router/middleware.ts +93 -8
- package/src/router/pattern-matching.ts +41 -5
- package/src/router/prerender-match.ts +34 -6
- package/src/router/preview-match.ts +7 -1
- package/src/router/revalidation.ts +61 -2
- package/src/router/router-context.ts +15 -0
- package/src/router/router-interfaces.ts +34 -0
- package/src/router/router-options.ts +200 -0
- package/src/router/segment-resolution/fresh.ts +123 -30
- package/src/router/segment-resolution/helpers.ts +19 -0
- package/src/router/segment-resolution/loader-cache.ts +37 -146
- package/src/router/segment-resolution/revalidation.ts +358 -94
- package/src/router/segment-wrappers.ts +3 -0
- package/src/router/telemetry-otel.ts +299 -0
- package/src/router/telemetry.ts +300 -0
- package/src/router/timeout.ts +148 -0
- package/src/router/types.ts +7 -1
- package/src/router.ts +155 -11
- package/src/rsc/handler-context.ts +11 -0
- package/src/rsc/handler.ts +380 -88
- package/src/rsc/helpers.ts +25 -16
- package/src/rsc/loader-fetch.ts +84 -42
- package/src/rsc/origin-guard.ts +141 -0
- package/src/rsc/progressive-enhancement.ts +232 -19
- package/src/rsc/response-route-handler.ts +37 -26
- package/src/rsc/rsc-rendering.ts +12 -5
- package/src/rsc/runtime-warnings.ts +42 -0
- package/src/rsc/server-action.ts +134 -58
- package/src/rsc/types.ts +8 -0
- package/src/search-params.ts +22 -10
- package/src/server/context.ts +53 -5
- package/src/server/fetchable-loader-store.ts +11 -6
- package/src/server/handle-store.ts +66 -9
- package/src/server/loader-registry.ts +11 -46
- package/src/server/request-context.ts +90 -9
- package/src/ssr/index.tsx +63 -27
- package/src/static-handler.ts +7 -0
- package/src/theme/ThemeProvider.tsx +6 -1
- package/src/theme/index.ts +1 -6
- package/src/theme/theme-context.ts +1 -28
- package/src/theme/theme-script.ts +2 -1
- package/src/types/cache-types.ts +5 -0
- package/src/types/error-types.ts +3 -0
- package/src/types/global-namespace.ts +9 -0
- package/src/types/handler-context.ts +35 -13
- package/src/types/loader-types.ts +7 -0
- package/src/types/route-entry.ts +28 -0
- package/src/urls/include-helper.ts +49 -8
- package/src/urls/index.ts +1 -0
- package/src/urls/path-helper-types.ts +30 -12
- package/src/urls/path-helper.ts +17 -2
- package/src/urls/pattern-types.ts +21 -1
- package/src/urls/response-types.ts +27 -2
- package/src/urls/type-extraction.ts +23 -15
- package/src/use-loader.tsx +12 -4
- package/src/vite/discovery/bundle-postprocess.ts +12 -7
- package/src/vite/discovery/discover-routers.ts +30 -18
- package/src/vite/discovery/prerender-collection.ts +24 -27
- package/src/vite/discovery/route-types-writer.ts +7 -7
- package/src/vite/discovery/virtual-module-codegen.ts +5 -2
- package/src/vite/plugins/client-ref-hashing.ts +3 -3
- package/src/vite/plugins/use-cache-transform.ts +91 -3
- package/src/vite/rango.ts +3 -3
- package/src/vite/router-discovery.ts +99 -36
- package/src/vite/utils/prerender-utils.ts +21 -0
- package/src/vite/utils/shared-utils.ts +3 -1
- package/src/browser/request-controller.ts +0 -164
- package/src/href-context.ts +0 -33
- package/src/router.gen.ts +0 -6
- package/src/static-handler.gen.ts +0 -5
- package/src/urls.gen.ts +0 -8
- /package/src/browser/{prefetch-observer.ts → prefetch/observer.ts} +0 -0
|
@@ -13,6 +13,8 @@
|
|
|
13
13
|
|
|
14
14
|
import type { MiddlewareFn, MiddlewareContext } from "../router/middleware.js";
|
|
15
15
|
import { getRequestContext } from "../server/request-context.js";
|
|
16
|
+
import { sortedSearchString } from "./cache-key-utils.js";
|
|
17
|
+
import { runBackground } from "./background-task.js";
|
|
16
18
|
|
|
17
19
|
// ============================================================================
|
|
18
20
|
// Constants
|
|
@@ -202,6 +204,11 @@ export function createDocumentCacheMiddleware<TEnv = any>(
|
|
|
202
204
|
): Promise<Response> {
|
|
203
205
|
const url = ctx.url;
|
|
204
206
|
|
|
207
|
+
// Only cache GET requests — mutations and other methods must not be cached
|
|
208
|
+
if (ctx.request.method !== "GET") {
|
|
209
|
+
return next();
|
|
210
|
+
}
|
|
211
|
+
|
|
205
212
|
// Skip RSC action requests (mutations shouldn't be cached)
|
|
206
213
|
if (url.searchParams.has("_rsc_action")) {
|
|
207
214
|
return next();
|
|
@@ -238,18 +245,31 @@ export function createDocumentCacheMiddleware<TEnv = any>(
|
|
|
238
245
|
const isPartial = url.searchParams.has("_rsc_partial");
|
|
239
246
|
const typeLabel = isPartial ? "RSC" : "HTML";
|
|
240
247
|
|
|
241
|
-
//
|
|
242
|
-
//
|
|
243
|
-
|
|
244
|
-
const clientSegments = url.searchParams.get("_rsc_segments") || "";
|
|
245
|
-
const segmentHash =
|
|
246
|
-
isPartial && clientSegments ? `:${hashSegmentIds(clientSegments)}` : "";
|
|
247
|
-
const typeSuffix = isPartial ? ":rsc" : ":html";
|
|
248
|
-
const cacheKey = keyGenerator
|
|
249
|
-
? keyGenerator(url) + segmentHash + typeSuffix
|
|
250
|
-
: `${url.pathname}${segmentHash}${typeSuffix}`;
|
|
248
|
+
// Track whether next() has been called so the catch block knows
|
|
249
|
+
// whether it is safe to fall through to the handler.
|
|
250
|
+
let handlerCalled = false;
|
|
251
251
|
|
|
252
252
|
try {
|
|
253
|
+
// Generate cache key inside try so a throwing keyGenerator degrades
|
|
254
|
+
// gracefully to the origin handler instead of rejecting the request.
|
|
255
|
+
// This is a deliberate fail-open-to-origin policy: the fallback is
|
|
256
|
+
// "serve uncached from origin", not "use a different cache key".
|
|
257
|
+
const clientSegments = url.searchParams.get("_rsc_segments") || "";
|
|
258
|
+
const segmentHash =
|
|
259
|
+
isPartial && clientSegments ? `:${hashSegmentIds(clientSegments)}` : "";
|
|
260
|
+
const typeSuffix = isPartial ? ":rsc" : ":html";
|
|
261
|
+
|
|
262
|
+
let searchSuffix = "";
|
|
263
|
+
if (!keyGenerator) {
|
|
264
|
+
const sorted = sortedSearchString(url.searchParams);
|
|
265
|
+
if (sorted) {
|
|
266
|
+
searchSuffix = `?${sorted}`;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const cacheKey = keyGenerator
|
|
271
|
+
? keyGenerator(url) + segmentHash + typeSuffix
|
|
272
|
+
: `${url.pathname}${searchSuffix}${segmentHash}${typeSuffix}`;
|
|
253
273
|
// 1. Check cache
|
|
254
274
|
const cached = await store.getResponse(cacheKey);
|
|
255
275
|
|
|
@@ -268,28 +288,24 @@ export function createDocumentCacheMiddleware<TEnv = any>(
|
|
|
268
288
|
`[DocumentCache] STALE ${typeLabel}: ${url.pathname} (revalidating)`,
|
|
269
289
|
);
|
|
270
290
|
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
log(
|
|
285
|
-
`[DocumentCache] REVALIDATED ${typeLabel}: ${url.pathname}`,
|
|
286
|
-
);
|
|
287
|
-
}
|
|
288
|
-
} catch (error) {
|
|
289
|
-
console.error(`[DocumentCache] Revalidation failed:`, error);
|
|
291
|
+
runBackground(requestCtx, async () => {
|
|
292
|
+
try {
|
|
293
|
+
const fresh = await next();
|
|
294
|
+
const directives = shouldCacheResponse(fresh);
|
|
295
|
+
|
|
296
|
+
if (directives) {
|
|
297
|
+
await store.putResponse!(
|
|
298
|
+
cacheKey,
|
|
299
|
+
fresh,
|
|
300
|
+
directives.sMaxAge!,
|
|
301
|
+
directives.staleWhileRevalidate,
|
|
302
|
+
);
|
|
303
|
+
log(`[DocumentCache] REVALIDATED ${typeLabel}: ${url.pathname}`);
|
|
290
304
|
}
|
|
291
|
-
})
|
|
292
|
-
|
|
305
|
+
} catch (error) {
|
|
306
|
+
console.error(`[DocumentCache] Revalidation failed:`, error);
|
|
307
|
+
}
|
|
308
|
+
});
|
|
293
309
|
|
|
294
310
|
return drainOnResponseCallbacks(
|
|
295
311
|
addCacheStatusHeader(cached.response, "STALE"),
|
|
@@ -298,6 +314,7 @@ export function createDocumentCacheMiddleware<TEnv = any>(
|
|
|
298
314
|
}
|
|
299
315
|
|
|
300
316
|
// 2. Cache miss - run handler
|
|
317
|
+
handlerCalled = true;
|
|
301
318
|
const originalResponse = await next();
|
|
302
319
|
|
|
303
320
|
// 3. Cache if response has appropriate headers
|
|
@@ -308,24 +325,27 @@ export function createDocumentCacheMiddleware<TEnv = any>(
|
|
|
308
325
|
`[DocumentCache] MISS ${typeLabel}: ${url.pathname} (caching with s-maxage=${directives.sMaxAge})`,
|
|
309
326
|
);
|
|
310
327
|
|
|
328
|
+
// If the response has no body (e.g., 200 with empty body), skip caching
|
|
329
|
+
if (!originalResponse.body) {
|
|
330
|
+
return originalResponse;
|
|
331
|
+
}
|
|
332
|
+
|
|
311
333
|
// Tee the body so we can return one stream and cache the other
|
|
312
|
-
const [returnStream, cacheStream] = originalResponse.body
|
|
334
|
+
const [returnStream, cacheStream] = originalResponse.body.tee();
|
|
313
335
|
|
|
314
336
|
// Clone response for caching (non-blocking)
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
});
|
|
328
|
-
}
|
|
337
|
+
runBackground(requestCtx, async () => {
|
|
338
|
+
try {
|
|
339
|
+
await store.putResponse!(
|
|
340
|
+
cacheKey,
|
|
341
|
+
new Response(cacheStream, originalResponse),
|
|
342
|
+
directives.sMaxAge!,
|
|
343
|
+
directives.staleWhileRevalidate,
|
|
344
|
+
);
|
|
345
|
+
} catch (error) {
|
|
346
|
+
console.error(`[DocumentCache] Cache write failed:`, error);
|
|
347
|
+
}
|
|
348
|
+
});
|
|
329
349
|
|
|
330
350
|
return addCacheStatusHeader(
|
|
331
351
|
new Response(returnStream, originalResponse),
|
|
@@ -337,7 +357,12 @@ export function createDocumentCacheMiddleware<TEnv = any>(
|
|
|
337
357
|
return originalResponse;
|
|
338
358
|
} catch (error) {
|
|
339
359
|
console.error(`[DocumentCache] Error:`, error);
|
|
340
|
-
|
|
360
|
+
if (handlerCalled) {
|
|
361
|
+
// Post-handler failure (e.g. body.tee()): do not call next() again
|
|
362
|
+
// as that would re-run handler side effects.
|
|
363
|
+
throw error;
|
|
364
|
+
}
|
|
365
|
+
// Pre-handler failure (cache lookup): degrade gracefully to origin
|
|
341
366
|
return next();
|
|
342
367
|
}
|
|
343
368
|
};
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Handle Capture
|
|
3
|
+
*
|
|
4
|
+
* Captures handle pushes during cached function execution.
|
|
5
|
+
* Extracted from cache-runtime.ts so tests can import without
|
|
6
|
+
* pulling in @vitejs/plugin-rsc/rsc dependencies.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { HandleStore } from "../server/handle-store.js";
|
|
10
|
+
import type { SegmentHandleData } from "./types.js";
|
|
11
|
+
|
|
12
|
+
export interface HandleCapture {
|
|
13
|
+
data: Record<string, SegmentHandleData>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Active capture tokens per HandleStore.
|
|
18
|
+
*
|
|
19
|
+
* Instead of mutating handleStore.push (which breaks when overlapping
|
|
20
|
+
* captures finish out of order), we install a single interceptor on
|
|
21
|
+
* first use and manage a set of active capture tokens. Each push fans
|
|
22
|
+
* out to every active token. Stopping a capture simply removes the
|
|
23
|
+
* token — order does not matter.
|
|
24
|
+
*/
|
|
25
|
+
const activeCapturesMap = new WeakMap<HandleStore, Set<HandleCapture>>();
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* One-time interceptor installation. Wraps the original push so every
|
|
29
|
+
* call fans out to all active capture tokens. Installed once per
|
|
30
|
+
* HandleStore instance; subsequent startHandleCapture calls on the
|
|
31
|
+
* same store just add tokens to the Set.
|
|
32
|
+
*/
|
|
33
|
+
function ensureInterceptorInstalled(handleStore: HandleStore): void {
|
|
34
|
+
if (activeCapturesMap.has(handleStore)) return;
|
|
35
|
+
|
|
36
|
+
const captures = new Set<HandleCapture>();
|
|
37
|
+
activeCapturesMap.set(handleStore, captures);
|
|
38
|
+
|
|
39
|
+
const originalPush = handleStore.push.bind(handleStore);
|
|
40
|
+
handleStore.push = (
|
|
41
|
+
handleName: string,
|
|
42
|
+
segmentId: string,
|
|
43
|
+
value: unknown,
|
|
44
|
+
) => {
|
|
45
|
+
for (const capture of captures) {
|
|
46
|
+
if (!capture.data[segmentId]) {
|
|
47
|
+
capture.data[segmentId] = {};
|
|
48
|
+
}
|
|
49
|
+
if (!capture.data[segmentId][handleName]) {
|
|
50
|
+
capture.data[segmentId][handleName] = [];
|
|
51
|
+
}
|
|
52
|
+
capture.data[segmentId][handleName].push(value);
|
|
53
|
+
}
|
|
54
|
+
originalPush(handleName, segmentId, value);
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Start capturing handle pushes for a cached function execution.
|
|
60
|
+
*
|
|
61
|
+
* Concurrency-safe: multiple overlapping captures on the same
|
|
62
|
+
* HandleStore are independent. Each capture registers a token in a
|
|
63
|
+
* Set; stopping removes it. No ordering requirement (LIFO not needed).
|
|
64
|
+
*/
|
|
65
|
+
export function startHandleCapture(handleStore: HandleStore): {
|
|
66
|
+
capture: HandleCapture;
|
|
67
|
+
stop: () => void;
|
|
68
|
+
} {
|
|
69
|
+
ensureInterceptorInstalled(handleStore);
|
|
70
|
+
|
|
71
|
+
const capture: HandleCapture = { data: {} };
|
|
72
|
+
const captures = activeCapturesMap.get(handleStore)!;
|
|
73
|
+
captures.add(capture);
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
capture,
|
|
77
|
+
stop() {
|
|
78
|
+
captures.delete(capture);
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
@@ -15,6 +15,12 @@ import type {
|
|
|
15
15
|
SegmentHandleData,
|
|
16
16
|
} from "./types.js";
|
|
17
17
|
import type { RequestContext } from "../server/request-context.js";
|
|
18
|
+
import {
|
|
19
|
+
resolveTtl,
|
|
20
|
+
resolveSwrWindow,
|
|
21
|
+
computeExpiration,
|
|
22
|
+
DEFAULT_FUNCTION_TTL,
|
|
23
|
+
} from "./cache-policy.js";
|
|
18
24
|
|
|
19
25
|
const CACHE_REGISTRY_KEY = "__rsc_router_segment_cache_registry__";
|
|
20
26
|
const RESPONSE_CACHE_REGISTRY_KEY = "__rsc_router_response_cache_registry__";
|
|
@@ -52,6 +58,7 @@ interface CachedItemEntry {
|
|
|
52
58
|
value: string;
|
|
53
59
|
handles?: Record<string, SegmentHandleData>;
|
|
54
60
|
expiresAt: number;
|
|
61
|
+
staleAt: number;
|
|
55
62
|
}
|
|
56
63
|
|
|
57
64
|
/**
|
|
@@ -244,9 +251,8 @@ export class MemorySegmentCacheStore<
|
|
|
244
251
|
headers.push([name, value]);
|
|
245
252
|
});
|
|
246
253
|
|
|
247
|
-
const swrWindow = swr
|
|
248
|
-
const staleAt =
|
|
249
|
-
const expiresAt = staleAt + swrWindow * 1000;
|
|
254
|
+
const swrWindow = resolveSwrWindow(swr, this.defaults);
|
|
255
|
+
const { staleAt, expiresAt } = computeExpiration(ttl, swrWindow);
|
|
250
256
|
|
|
251
257
|
this.responseCache.set(key, {
|
|
252
258
|
body,
|
|
@@ -261,15 +267,17 @@ export class MemorySegmentCacheStore<
|
|
|
261
267
|
const cached = this.itemCache.get(key);
|
|
262
268
|
if (!cached) return null;
|
|
263
269
|
|
|
264
|
-
|
|
270
|
+
const now = Date.now();
|
|
271
|
+
if (now > cached.expiresAt) {
|
|
265
272
|
this.itemCache.delete(key);
|
|
266
273
|
return null;
|
|
267
274
|
}
|
|
268
275
|
|
|
276
|
+
const isStale = now > cached.staleAt;
|
|
269
277
|
return {
|
|
270
278
|
value: cached.value,
|
|
271
279
|
handles: cached.handles,
|
|
272
|
-
shouldRevalidate:
|
|
280
|
+
shouldRevalidate: isStale,
|
|
273
281
|
};
|
|
274
282
|
}
|
|
275
283
|
|
|
@@ -278,11 +286,14 @@ export class MemorySegmentCacheStore<
|
|
|
278
286
|
value: string,
|
|
279
287
|
options?: CacheItemOptions,
|
|
280
288
|
): Promise<void> {
|
|
281
|
-
const ttl = options?.ttl
|
|
289
|
+
const ttl = resolveTtl(options?.ttl, this.defaults, DEFAULT_FUNCTION_TTL);
|
|
290
|
+
const swrWindow = resolveSwrWindow(options?.swr, this.defaults);
|
|
291
|
+
const { staleAt, expiresAt } = computeExpiration(ttl, swrWindow);
|
|
282
292
|
this.itemCache.set(key, {
|
|
283
293
|
value,
|
|
284
294
|
handles: options?.handles,
|
|
285
|
-
expiresAt
|
|
295
|
+
expiresAt,
|
|
296
|
+
staleAt,
|
|
286
297
|
});
|
|
287
298
|
}
|
|
288
299
|
|
|
@@ -15,23 +15,58 @@ export interface CacheProfile {
|
|
|
15
15
|
tags?: string[];
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
const DEFAULT_PROFILE: CacheProfile = { ttl: 900, swr: 1800 };
|
|
19
|
+
|
|
18
20
|
let _profiles: Record<string, CacheProfile> = {
|
|
19
|
-
default:
|
|
21
|
+
default: DEFAULT_PROFILE,
|
|
20
22
|
};
|
|
21
23
|
|
|
24
|
+
const PROFILE_NAME_RE = /^[a-zA-Z0-9_-]+$/;
|
|
25
|
+
|
|
22
26
|
/**
|
|
23
|
-
*
|
|
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
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
|
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
|
+
}
|