@rangojs/router 0.0.0-experimental.e9c0b2f2 → 0.0.0-experimental.ea9f40f2
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/AGENTS.md +6 -10
- package/README.md +289 -938
- package/dist/bin/rango.js +271 -46
- package/dist/vite/index.js +673 -193
- package/package.json +10 -8
- package/skills/api-client/SKILL.md +1 -1
- package/skills/breadcrumbs/SKILL.md +31 -14
- package/skills/cache-guide/SKILL.md +5 -2
- package/skills/caching/SKILL.md +59 -4
- package/skills/catalog.json +271 -0
- package/skills/comparison/SKILL.md +50 -0
- package/skills/comparison/agents/openai.yaml +4 -0
- package/skills/comparison/references/framework-comparison.md +837 -0
- package/skills/composability/SKILL.md +83 -2
- package/skills/debug-manifest/SKILL.md +1 -1
- package/skills/defer-hydration/SKILL.md +235 -0
- package/skills/document-cache/SKILL.md +9 -1
- package/skills/fonts/SKILL.md +1 -1
- package/skills/handler-use/SKILL.md +8 -8
- package/skills/hooks/SKILL.md +54 -892
- package/skills/hooks/data.md +273 -0
- package/skills/hooks/handle-and-actions.md +103 -0
- package/skills/hooks/navigation.md +110 -0
- package/skills/hooks/outlets.md +41 -0
- package/skills/hooks/state.md +228 -0
- package/skills/hooks/urls.md +135 -0
- package/skills/host-router/SKILL.md +4 -4
- package/skills/i18n/SKILL.md +1 -1
- package/skills/intercept/SKILL.md +46 -14
- package/skills/layout/SKILL.md +27 -10
- package/skills/links/SKILL.md +1 -1
- package/skills/loader/SKILL.md +23 -1
- package/skills/middleware/SKILL.md +7 -3
- package/skills/migrate-nextjs/SKILL.md +167 -6
- package/skills/migrate-react-router/SKILL.md +59 -677
- package/skills/migrate-react-router/cloudflare-workers.md +129 -0
- package/skills/migrate-react-router/component-migration.md +196 -0
- package/skills/migrate-react-router/data-and-actions.md +225 -0
- package/skills/migrate-react-router/route-mapping.md +271 -0
- package/skills/mime-routes/SKILL.md +1 -1
- package/skills/observability/SKILL.md +9 -1
- package/skills/parallel/SKILL.md +23 -4
- package/skills/ppr/SKILL.md +622 -0
- package/skills/prerender/SKILL.md +28 -18
- package/skills/rango/SKILL.md +84 -25
- package/skills/response-routes/SKILL.md +15 -1
- package/skills/route/SKILL.md +71 -4
- package/skills/router-setup/SKILL.md +14 -3
- package/skills/scripts/SKILL.md +1 -1
- package/skills/server-actions/SKILL.md +3 -2
- package/skills/shell-manifest/SKILL.md +185 -0
- package/skills/streams-and-websockets/SKILL.md +1 -1
- package/skills/tailwind/SKILL.md +1 -1
- package/skills/testing/SKILL.md +2 -1
- package/skills/testing/handles.md +4 -2
- package/skills/testing/render-handler.md +15 -14
- package/skills/testing/reverse-and-types.md +8 -7
- package/skills/theme/SKILL.md +1 -1
- package/skills/typesafety/SKILL.md +45 -919
- package/skills/typesafety/env-and-bindings.md +254 -0
- package/skills/typesafety/generated-files-and-cli.md +335 -0
- package/skills/typesafety/params-and-search.md +153 -0
- package/skills/typesafety/route-types.md +209 -0
- package/skills/use-cache/SKILL.md +30 -3
- package/skills/vercel/SKILL.md +1 -1
- package/skills/view-transitions/SKILL.md +44 -1
- package/src/browser/event-controller.ts +62 -10
- package/src/browser/logging.ts +28 -0
- package/src/browser/merge-segment-loaders.ts +6 -4
- package/src/browser/navigation-bridge.ts +65 -16
- package/src/browser/navigation-client.ts +32 -2
- package/src/browser/navigation-store.ts +128 -14
- package/src/browser/network-error-handler.ts +34 -7
- package/src/browser/partial-update.ts +76 -17
- package/src/browser/prefetch/cache.ts +51 -11
- package/src/browser/prefetch/fetch.ts +59 -21
- package/src/browser/prefetch/queue.ts +19 -4
- package/src/browser/react/Link.tsx +13 -3
- package/src/browser/react/NavigationProvider.tsx +108 -4
- package/src/browser/response-adapter.ts +38 -9
- package/src/browser/rsc-router.tsx +54 -4
- package/src/browser/scroll-restoration.ts +7 -5
- package/src/browser/segment-reconciler.ts +31 -21
- package/src/browser/server-action-bridge.ts +22 -10
- package/src/browser/types.ts +54 -1
- package/src/build/generate-manifest.ts +155 -131
- package/src/build/index.ts +3 -1
- package/src/build/route-trie.ts +35 -7
- package/src/build/route-types/include-resolution.ts +347 -47
- package/src/build/runtime-discovery.ts +4 -1
- package/src/cache/cache-key-utils.ts +29 -0
- package/src/cache/cache-runtime.ts +262 -71
- package/src/cache/cache-scope.ts +2 -17
- package/src/cache/cache-tag.ts +60 -14
- package/src/cache/cf/cf-cache-store.ts +243 -20
- package/src/cache/document-cache.ts +54 -21
- package/src/cache/index.ts +1 -0
- package/src/cache/memory-segment-store.ts +110 -3
- package/src/cache/profile-registry.ts +15 -0
- package/src/cache/read-through-swr.ts +15 -1
- package/src/cache/segment-codec.ts +4 -4
- package/src/cache/shell-snapshot.ts +417 -0
- package/src/cache/types.ts +158 -0
- package/src/cache/vercel/vercel-cache-store.ts +401 -124
- package/src/client.rsc.tsx +0 -3
- package/src/client.tsx +0 -3
- package/src/cloudflare/tracing.ts +7 -8
- package/src/defer.ts +11 -22
- package/src/handle.ts +37 -15
- package/src/handles/MetaTags.tsx +16 -82
- package/src/handles/breadcrumbs.ts +12 -14
- package/src/handles/deferred-resolution.ts +127 -0
- package/src/handles/is-thenable.ts +7 -8
- package/src/handles/meta.ts +7 -44
- package/src/host/errors.ts +15 -0
- package/src/host/index.ts +1 -0
- package/src/index.rsc.ts +8 -2
- package/src/index.ts +19 -13
- package/src/internal-debug.ts +11 -8
- package/src/prerender.ts +17 -4
- package/src/redirect-origin.ts +14 -0
- package/src/render-error-thrower.tsx +20 -0
- package/src/route-content-wrapper.tsx +12 -5
- package/src/route-definition/dsl-helpers.ts +21 -32
- package/src/route-definition/helper-factories.ts +0 -2
- package/src/route-definition/helpers-types.ts +43 -43
- package/src/route-definition/index.ts +1 -2
- package/src/route-definition/resolve-handler-use.ts +0 -1
- package/src/route-definition/use-item-types.ts +3 -6
- package/src/route-map-builder.ts +41 -4
- package/src/route-types.ts +0 -5
- package/src/router/find-match.ts +86 -8
- package/src/router/instrument.ts +9 -4
- package/src/router/lazy-includes.ts +72 -12
- package/src/router/loader-resolution.ts +14 -2
- package/src/router/manifest.ts +56 -11
- package/src/router/match-api.ts +76 -32
- package/src/router/match-handlers.ts +181 -135
- package/src/router/match-middleware/background-revalidation.ts +40 -23
- package/src/router/match-middleware/cache-store.ts +39 -24
- package/src/router/match-result.ts +35 -15
- package/src/router/middleware.ts +64 -38
- package/src/router/navigation-snapshot.ts +7 -5
- package/src/router/parse-pattern.ts +115 -0
- package/src/router/pattern-matching.ts +53 -64
- package/src/router/prefetch-limits.ts +37 -0
- package/src/router/prerender-match.ts +11 -5
- package/src/router/preview-match.ts +3 -1
- package/src/router/request-classification.ts +23 -8
- package/src/router/route-snapshot.ts +14 -2
- package/src/router/router-context.ts +3 -1
- package/src/router/router-interfaces.ts +32 -1
- package/src/router/router-options.ts +30 -0
- package/src/router/segment-resolution/fresh.ts +39 -3
- package/src/router/segment-resolution/loader-cache.ts +93 -2
- package/src/router/segment-resolution/loader-mask.ts +60 -0
- package/src/router/segment-resolution/loader-snapshot.ts +259 -0
- package/src/router/segment-resolution/mask-nested.ts +83 -0
- package/src/router/segment-resolution/revalidation.ts +3 -0
- package/src/router/segment-resolution/view-transition-default.ts +35 -15
- package/src/router/substitute-pattern-params.ts +54 -35
- package/src/router/telemetry-otel.ts +6 -8
- package/src/router/telemetry.ts +9 -1
- package/src/router/tracing.ts +14 -5
- package/src/router/trie-matching.ts +19 -11
- package/src/router/url-params.ts +13 -0
- package/src/router.ts +47 -16
- package/src/rsc/full-payload.ts +70 -0
- package/src/rsc/handler.ts +60 -33
- package/src/rsc/manifest-init.ts +1 -1
- package/src/rsc/nonce.ts +10 -1
- package/src/rsc/progressive-enhancement.ts +61 -4
- package/src/rsc/redirect-guard.ts +2 -1
- package/src/rsc/rsc-rendering.ts +429 -37
- package/src/rsc/server-action.ts +25 -2
- package/src/rsc/shell-capture.ts +1190 -0
- package/src/rsc/shell-serve.ts +181 -0
- package/src/rsc/transition-gate.ts +89 -0
- package/src/rsc/types.ts +30 -0
- package/src/segment-loader-promise.ts +18 -0
- package/src/segment-system.tsx +149 -14
- package/src/server/context.ts +67 -9
- package/src/server/cookie-store.ts +73 -1
- package/src/server/loader-registry.ts +13 -1
- package/src/server/request-context.ts +169 -10
- package/src/ssr/index.tsx +462 -178
- package/src/ssr/inject-rsc-eager.ts +167 -0
- package/src/ssr/ssr-root.tsx +228 -0
- package/src/testing/collect-handle.ts +14 -8
- package/src/testing/dispatch.ts +152 -40
- package/src/testing/generated-routes.ts +27 -11
- package/src/testing/index.ts +6 -0
- package/src/testing/render-handler.ts +14 -0
- package/src/testing/render-route.tsx +13 -10
- package/src/testing/run-transition-when.ts +164 -0
- package/src/theme/ThemeProvider.tsx +36 -26
- package/src/types/handler-context.ts +1 -1
- package/src/types/index.ts +2 -0
- package/src/types/route-config.ts +19 -7
- package/src/types/segments.ts +100 -0
- package/src/urls/include-helper.ts +10 -8
- package/src/urls/include-provider.ts +71 -0
- package/src/urls/index.ts +1 -0
- package/src/urls/path-helper-types.ts +44 -12
- package/src/urls/path-helper.ts +5 -0
- package/src/urls/pattern-types.ts +36 -0
- package/src/urls/type-extraction.ts +43 -18
- package/src/urls/urls-function.ts +0 -1
- package/src/vercel/tracing.ts +7 -7
- package/src/vite/discovery/dev-prerender-cache.ts +117 -0
- package/src/vite/discovery/discover-routers.ts +1 -1
- package/src/vite/discovery/discovery-errors.ts +61 -0
- package/src/vite/index.ts +7 -0
- package/src/vite/inject-client-debug.ts +88 -0
- package/src/vite/plugins/vercel-output.ts +114 -25
- package/src/vite/plugins/version-injector.ts +22 -7
- package/src/vite/plugins/virtual-entries.ts +80 -22
- package/src/vite/rango.ts +29 -19
- package/src/vite/router-discovery.ts +171 -43
- package/src/vite/utils/prerender-utils.ts +17 -4
- package/src/vite/utils/shared-utils.ts +47 -0
- package/src/network-error-thrower.tsx +0 -18
|
@@ -20,7 +20,10 @@ import {
|
|
|
20
20
|
encodeReply,
|
|
21
21
|
createClientTemporaryReferenceSet,
|
|
22
22
|
} from "@vitejs/plugin-rsc/rsc";
|
|
23
|
-
import {
|
|
23
|
+
import {
|
|
24
|
+
getRequestContext,
|
|
25
|
+
runWithRequestContext,
|
|
26
|
+
} from "../server/request-context.js";
|
|
24
27
|
import { isUnderTestRunner } from "../runtime-env.js";
|
|
25
28
|
import {
|
|
26
29
|
isTainted,
|
|
@@ -124,6 +127,86 @@ export async function replyToCacheKey(
|
|
|
124
127
|
// the test-ergonomics warning fires once per fn rather than once per call.
|
|
125
128
|
const warnedUncachedUnderTest = new Set<string>();
|
|
126
129
|
|
|
130
|
+
/**
|
|
131
|
+
* Fast-path cache-key builder for JSON-safe key args. Returns a deterministic
|
|
132
|
+
* string (object keys recursively sorted so insertion order can't change the
|
|
133
|
+
* key) when EVERY part is a primitive or a plain object/array of the same, and
|
|
134
|
+
* `undefined` otherwise so the caller falls back to the Flight reply encoder.
|
|
135
|
+
*
|
|
136
|
+
* Strings are always quoted via JSON.stringify, so they can never collide with
|
|
137
|
+
* the bareword encodings of null/true/false/undefined/numbers. Anything the
|
|
138
|
+
* reply encoder must handle instead — functions, symbols, bigint, non-finite
|
|
139
|
+
* numbers, Dates, Maps, class instances, promises, and React elements (whose
|
|
140
|
+
* `$$typeof` symbol value trips the symbol reject) — yields `undefined`.
|
|
141
|
+
*/
|
|
142
|
+
function jsonSafeKeyPart(value: unknown): string | undefined {
|
|
143
|
+
if (value === null) return "null";
|
|
144
|
+
switch (typeof value) {
|
|
145
|
+
case "string":
|
|
146
|
+
return JSON.stringify(value);
|
|
147
|
+
case "boolean":
|
|
148
|
+
return value ? "true" : "false";
|
|
149
|
+
case "number":
|
|
150
|
+
return Number.isFinite(value) ? String(value) : undefined;
|
|
151
|
+
case "undefined":
|
|
152
|
+
return "undefined";
|
|
153
|
+
case "object": {
|
|
154
|
+
if (Array.isArray(value)) {
|
|
155
|
+
const parts: string[] = [];
|
|
156
|
+
for (const item of value) {
|
|
157
|
+
const encoded = jsonSafeKeyPart(item);
|
|
158
|
+
if (encoded === undefined) return undefined;
|
|
159
|
+
parts.push(encoded);
|
|
160
|
+
}
|
|
161
|
+
return `[${parts.join(",")}]`;
|
|
162
|
+
}
|
|
163
|
+
// Only plain objects (Object.prototype or null proto) are fast-path safe.
|
|
164
|
+
// Dates, Maps, class instances, promises, etc. carry a different prototype
|
|
165
|
+
// and fall back to the encoder.
|
|
166
|
+
const proto = Object.getPrototypeOf(value);
|
|
167
|
+
if (proto !== Object.prototype && proto !== null) return undefined;
|
|
168
|
+
const obj = value as Record<string, unknown>;
|
|
169
|
+
const parts: string[] = [];
|
|
170
|
+
for (const key of Object.keys(obj).sort()) {
|
|
171
|
+
const encoded = jsonSafeKeyPart(obj[key]);
|
|
172
|
+
if (encoded === undefined) return undefined;
|
|
173
|
+
parts.push(`${JSON.stringify(key)}:${encoded}`);
|
|
174
|
+
}
|
|
175
|
+
return `{${parts.join(",")}}`;
|
|
176
|
+
}
|
|
177
|
+
default:
|
|
178
|
+
// bigint, symbol, function
|
|
179
|
+
return undefined;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* The serialized product of one "use cache" execution: exactly what the store
|
|
185
|
+
* write persists. Followers that dedup onto an in-flight execution (see
|
|
186
|
+
* inFlightExecutions) await this and serve it as a synthetic cache hit — each
|
|
187
|
+
* deserializing its OWN copy of `serialized` and replaying `handles`/`tags`
|
|
188
|
+
* against its OWN request. A deserialized result object is never shared.
|
|
189
|
+
*/
|
|
190
|
+
interface CacheEnvelope {
|
|
191
|
+
/** RSC-serialized return value (never null — a null serialize rejects). */
|
|
192
|
+
serialized: string;
|
|
193
|
+
/** Merged profile/DSL + runtime cacheTag() tags. */
|
|
194
|
+
tags: string[];
|
|
195
|
+
/** RSC-encoded handle blob captured during execution, if any. */
|
|
196
|
+
handles?: string;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* In-flight "use cache" executions keyed by cache key. When N concurrent calls
|
|
201
|
+
* miss on the same key, only the first (the leader) runs the function; the rest
|
|
202
|
+
* await its {@link CacheEnvelope} and serve it as a synthetic hit rather than
|
|
203
|
+
* re-running the function and re-writing the store. Isolate-scoped (module
|
|
204
|
+
* singleton), and cleared for a key as soon as the leader settles: a rejected
|
|
205
|
+
* leader (function threw, or the result was not serializable) propagates to
|
|
206
|
+
* current waiters, which then retry fresh.
|
|
207
|
+
*/
|
|
208
|
+
const inFlightExecutions = new Map<string, Promise<CacheEnvelope>>();
|
|
209
|
+
|
|
127
210
|
// ============================================================================
|
|
128
211
|
// Core: registerCachedFunction
|
|
129
212
|
// ============================================================================
|
|
@@ -250,12 +333,23 @@ export function registerCachedFunction<T extends (...args: any[]) => any>(
|
|
|
250
333
|
let cacheKey: string;
|
|
251
334
|
try {
|
|
252
335
|
if (keyArgs.length > 0) {
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
336
|
+
// Fast path: when every key arg is JSON-safe, build the key with a
|
|
337
|
+
// deterministic stable-stringify and skip encodeReply (the Flight reply
|
|
338
|
+
// encoder runs on EVERY call, including hits). The `:j:` namespace keeps
|
|
339
|
+
// these keys disjoint from the encoder path so the two can never collide
|
|
340
|
+
// for one fn id. Entries cached under the old encoder key for JSON-safe
|
|
341
|
+
// args cold-start once after this upgrade.
|
|
342
|
+
const fastKey = jsonSafeKeyPart(keyArgs);
|
|
343
|
+
if (fastKey !== undefined) {
|
|
344
|
+
cacheKey = `use-cache:${id}:j:${fastKey}`;
|
|
345
|
+
} else {
|
|
346
|
+
const tempRefs = createClientTemporaryReferenceSet();
|
|
347
|
+
const encoded = await encodeReply(keyArgs as unknown[], {
|
|
348
|
+
temporaryReferences: tempRefs,
|
|
349
|
+
});
|
|
350
|
+
const argsKey = await replyToCacheKey(encoded);
|
|
351
|
+
cacheKey = `use-cache:${id}:${argsKey}`;
|
|
352
|
+
}
|
|
259
353
|
} else {
|
|
260
354
|
cacheKey = `use-cache:${id}`;
|
|
261
355
|
}
|
|
@@ -307,27 +401,37 @@ export function registerCachedFunction<T extends (...args: any[]) => any>(
|
|
|
307
401
|
}
|
|
308
402
|
}
|
|
309
403
|
|
|
310
|
-
|
|
404
|
+
// foregroundOnAction (opt-in; see CacheProfile.foregroundOnAction): during an
|
|
405
|
+
// action's revalidation render, a stale entry falls through to the foreground
|
|
406
|
+
// miss path below instead of SWR. The flag is set by revalidateAfterAction.
|
|
407
|
+
const foregroundOnActionRevalidation =
|
|
408
|
+
requestCtx?._inActionRevalidation === true &&
|
|
409
|
+
profile.foregroundOnAction === true;
|
|
410
|
+
if (cached?.shouldRevalidate && !foregroundOnActionRevalidation) {
|
|
311
411
|
// Stale hit: return stale value, revalidate in background
|
|
312
412
|
try {
|
|
313
413
|
const result = await serveCached(cached);
|
|
314
414
|
// Background revalidation — must capture handles if tainted args present.
|
|
315
|
-
// Use an isolated handle store so background pushes don't pollute the
|
|
316
|
-
// live response or throw LateHandlePushError on the completed store.
|
|
317
|
-
// Same isolation pattern as route-level background-revalidation.ts.
|
|
318
415
|
runBackground(requestCtx, async () => {
|
|
319
|
-
//
|
|
320
|
-
//
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
416
|
+
// The background body runs under a DERIVED context with an OWN
|
|
417
|
+
// _handleStore (the shell-capture isolation pattern —
|
|
418
|
+
// shell-capture.ts attemptCapture): its handle pushes land in the
|
|
419
|
+
// isolated store (captured below, persisted with the entry) while
|
|
420
|
+
// the foreground keeps pushing into the ORIGINAL store, untouched.
|
|
421
|
+
// Derivation matters because the foreground is STILL RENDERING here
|
|
422
|
+
// — runBackground/waitUntil starts the task on the next microtask,
|
|
423
|
+
// not after the response. The previous shape swapped
|
|
424
|
+
// requestCtx._handleStore in place (restore in finally), which
|
|
425
|
+
// routed the whole overlap window's foreground pushes into the
|
|
426
|
+
// background store: lost from the live document AND persisted into
|
|
427
|
+
// the revalidated entry (issue #684, plan 010).
|
|
428
|
+
const bgHandleStore =
|
|
429
|
+
hasTaintedArgs && requestCtx ? createHandleStore() : undefined;
|
|
430
|
+
const bgCtx: typeof requestCtx = bgHandleStore
|
|
431
|
+
? Object.assign(Object.create(requestCtx), {
|
|
432
|
+
_handleStore: bgHandleStore,
|
|
433
|
+
})
|
|
434
|
+
: requestCtx;
|
|
331
435
|
let bgCapture: HandleCapture | undefined;
|
|
332
436
|
let bgStopCapture: (() => void) | undefined;
|
|
333
437
|
if (bgHandleStore) {
|
|
@@ -336,31 +440,28 @@ export function registerCachedFunction<T extends (...args: any[]) => any>(
|
|
|
336
440
|
bgStopCapture = c.stop;
|
|
337
441
|
}
|
|
338
442
|
|
|
339
|
-
//
|
|
340
|
-
//
|
|
341
|
-
//
|
|
342
|
-
//
|
|
343
|
-
//
|
|
344
|
-
//
|
|
345
|
-
//
|
|
346
|
-
//
|
|
347
|
-
//
|
|
348
|
-
// later calls requestCtx.onResponse().
|
|
349
|
-
// 2. requestCtx methods are closure-bound to the original ctx, so
|
|
350
|
-
// neither Object.create() nor a proxy can isolate the stamp.
|
|
351
|
-
// 3. The foreground miss path already stamps requestCtx and catches
|
|
352
|
-
// cookies()/headers() misuse on first execution. The background
|
|
353
|
-
// re-runs the same function with the same request.
|
|
354
|
-
const bgTaintedArgs: unknown[] = [];
|
|
355
|
-
for (const arg of args) {
|
|
356
|
-
if (isTainted(arg)) {
|
|
357
|
-
stampCacheExec(arg as object);
|
|
358
|
-
bgTaintedArgs.push(arg);
|
|
359
|
-
}
|
|
360
|
-
}
|
|
443
|
+
// Tainted args are NOT stamped here, in contrast to the foreground
|
|
444
|
+
// miss path below. The args include the live HandlerContext the
|
|
445
|
+
// still-rendering foreground holds, and INSIDE_CACHE_EXEC is a
|
|
446
|
+
// property stamped onto that SHARED object — so for the whole
|
|
447
|
+
// revalidation window a concurrent foreground ctx.set() /
|
|
448
|
+
// ctx.headers.*() would throw (issue #684, plan 010). requestCtx is
|
|
449
|
+
// not stamped for the same reason. In-fn misuse is already caught
|
|
450
|
+
// by the miss path's stamps on the function's FIRST execution — the
|
|
451
|
+
// background re-runs the same function with the same request.
|
|
361
452
|
|
|
362
453
|
try {
|
|
363
|
-
|
|
454
|
+
// Re-establish the request-context ALS so a "use cache" body that
|
|
455
|
+
// reads the ambient getRequestContext() (e.g.
|
|
456
|
+
// getRequestContext().env.ApiKey) resolves during the background
|
|
457
|
+
// revalidation instead of throwing "called outside of a request
|
|
458
|
+
// context". runWithRequestContext sets the store for fn's
|
|
459
|
+
// synchronous kickoff; its async continuations inherit it. The
|
|
460
|
+
// DERIVED context goes in, so ambient _handleStore reads inside
|
|
461
|
+
// the body resolve to the isolated store.
|
|
462
|
+
const scoped = runWithRequestContext(bgCtx, () =>
|
|
463
|
+
runWithCacheTagScope(() => fn.apply(this, args)),
|
|
464
|
+
);
|
|
364
465
|
const freshResult = await scoped.result;
|
|
365
466
|
bgStopCapture?.();
|
|
366
467
|
// Merge profile/DSL tags with runtime cacheTag() tags, read after
|
|
@@ -395,15 +496,9 @@ export function registerCachedFunction<T extends (...args: any[]) => any>(
|
|
|
395
496
|
"[use cache] background revalidation failed",
|
|
396
497
|
requestCtx,
|
|
397
498
|
);
|
|
398
|
-
} finally {
|
|
399
|
-
for (const arg of bgTaintedArgs) {
|
|
400
|
-
unstampCacheExec(arg as object);
|
|
401
|
-
}
|
|
402
|
-
// Restore original handle store
|
|
403
|
-
if (originalHandleStore && requestCtx) {
|
|
404
|
-
requestCtx._handleStore = originalHandleStore;
|
|
405
|
-
}
|
|
406
499
|
}
|
|
500
|
+
// No finally: nothing shared was mutated — the derived context and
|
|
501
|
+
// its handle store are garbage after the task settles.
|
|
407
502
|
});
|
|
408
503
|
return result;
|
|
409
504
|
} catch (error) {
|
|
@@ -417,7 +512,65 @@ export function registerCachedFunction<T extends (...args: any[]) => any>(
|
|
|
417
512
|
}
|
|
418
513
|
}
|
|
419
514
|
|
|
420
|
-
// Cache miss
|
|
515
|
+
// Cache miss.
|
|
516
|
+
//
|
|
517
|
+
// In-flight dedup: if a concurrent call for this key is already executing,
|
|
518
|
+
// await its envelope and serve it as a synthetic hit rather than re-running
|
|
519
|
+
// the function. Each follower deserializes its OWN copy, replays handles
|
|
520
|
+
// against its OWN handle store (gated on ITS hasTaintedArgs), and records
|
|
521
|
+
// tags into its OWN request — no deserialized result is shared across
|
|
522
|
+
// requests. The store write stays exactly once (the leader's).
|
|
523
|
+
const existing = inFlightExecutions.get(cacheKey);
|
|
524
|
+
if (existing) {
|
|
525
|
+
let envelope: CacheEnvelope | undefined;
|
|
526
|
+
try {
|
|
527
|
+
envelope = await existing;
|
|
528
|
+
} catch {
|
|
529
|
+
// Leader rejected (function threw or its result was not serializable);
|
|
530
|
+
// its map entry is already cleared, so fall through to a fresh run.
|
|
531
|
+
envelope = undefined;
|
|
532
|
+
}
|
|
533
|
+
if (envelope) {
|
|
534
|
+
try {
|
|
535
|
+
return await serveCached({
|
|
536
|
+
value: envelope.serialized,
|
|
537
|
+
handles: envelope.handles,
|
|
538
|
+
tags: envelope.tags,
|
|
539
|
+
shouldRevalidate: false,
|
|
540
|
+
});
|
|
541
|
+
} catch (error) {
|
|
542
|
+
reportCacheError(
|
|
543
|
+
error,
|
|
544
|
+
"cache-corrupt",
|
|
545
|
+
`[use cache] "${id}" inflight-hit`,
|
|
546
|
+
);
|
|
547
|
+
// Fall through to a fresh execution below.
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// This call becomes the leader. Register a deferred envelope so concurrent
|
|
553
|
+
// callers dedup onto it; it is resolved/rejected exactly once below (or on a
|
|
554
|
+
// function throw). clearSelf only deletes the map slot if it still holds
|
|
555
|
+
// THIS promise, so a fall-through retry (rare rejected-leader path) can't
|
|
556
|
+
// evict a newer leader's entry.
|
|
557
|
+
let resolveEnvelope!: (env: CacheEnvelope) => void;
|
|
558
|
+
let rejectEnvelope!: (err: unknown) => void;
|
|
559
|
+
const envelopePromise = new Promise<CacheEnvelope>((res, rej) => {
|
|
560
|
+
resolveEnvelope = res;
|
|
561
|
+
rejectEnvelope = rej;
|
|
562
|
+
});
|
|
563
|
+
// Followers attach their own catch; guard the map's own reference so a
|
|
564
|
+
// rejected envelope with no waiter is not an unhandled rejection.
|
|
565
|
+
envelopePromise.catch(() => {});
|
|
566
|
+
inFlightExecutions.set(cacheKey, envelopePromise);
|
|
567
|
+
const clearSelf = (): void => {
|
|
568
|
+
if (inFlightExecutions.get(cacheKey) === envelopePromise) {
|
|
569
|
+
inFlightExecutions.delete(cacheKey);
|
|
570
|
+
}
|
|
571
|
+
};
|
|
572
|
+
|
|
573
|
+
// execute, serialize, store
|
|
421
574
|
const handleStore = hasTaintedArgs ? requestCtx?._handleStore : undefined;
|
|
422
575
|
let capture: HandleCapture | undefined;
|
|
423
576
|
let stopCapture: (() => void) | undefined;
|
|
@@ -431,6 +584,13 @@ export function registerCachedFunction<T extends (...args: any[]) => any>(
|
|
|
431
584
|
// inside the cached function body (those side effects are lost on hit).
|
|
432
585
|
// Uses ref-counted stamp/unstamp so overlapping executions
|
|
433
586
|
// sharing the same ctx don't clear each other's guards.
|
|
587
|
+
//
|
|
588
|
+
// LOAD-BEARING for the stale-revalidation path above: the background
|
|
589
|
+
// re-execution deliberately does NOT re-stamp (the objects are live
|
|
590
|
+
// foreground state mid-render), relying on THIS stamp having caught in-fn
|
|
591
|
+
// misuse on the function's first execution — an entry only becomes
|
|
592
|
+
// stale-revalidatable because a stamped miss ran clean and stored it. Do
|
|
593
|
+
// not create a "use cache" entry via any path that skips this stamp.
|
|
434
594
|
const taintedArgs: unknown[] = [];
|
|
435
595
|
for (const arg of args) {
|
|
436
596
|
if (isTainted(arg)) {
|
|
@@ -450,6 +610,12 @@ export function registerCachedFunction<T extends (...args: any[]) => any>(
|
|
|
450
610
|
try {
|
|
451
611
|
scoped = runWithCacheTagScope(() => fn.apply(this, args));
|
|
452
612
|
result = await scoped.result;
|
|
613
|
+
} catch (execError) {
|
|
614
|
+
// The function threw: drop the in-flight entry and reject any waiters so
|
|
615
|
+
// they retry fresh, then propagate to this caller.
|
|
616
|
+
clearSelf();
|
|
617
|
+
rejectEnvelope(execError);
|
|
618
|
+
throw execError;
|
|
453
619
|
} finally {
|
|
454
620
|
// Decrement ref count; symbol is deleted when it reaches zero
|
|
455
621
|
for (const arg of taintedArgs) {
|
|
@@ -470,28 +636,53 @@ export function registerCachedFunction<T extends (...args: any[]) => any>(
|
|
|
470
636
|
];
|
|
471
637
|
recordRequestTags(allTags, requestCtx);
|
|
472
638
|
|
|
473
|
-
// Serialize
|
|
474
|
-
//
|
|
475
|
-
|
|
639
|
+
// Serialize + encode handles ONCE, resolve the in-flight envelope so any
|
|
640
|
+
// concurrent followers can serve a synthetic hit, then persist to the store.
|
|
641
|
+
// Fully non-blocking when waitUntil is available — the leader's response
|
|
642
|
+
// waits on neither serialization nor the store write.
|
|
643
|
+
const finalizeAndWrite = async (): Promise<void> => {
|
|
644
|
+
let serialized: string | null;
|
|
645
|
+
let encodedHandles: string | undefined;
|
|
476
646
|
try {
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
647
|
+
serialized = await serializeResult(result);
|
|
648
|
+
encodedHandles = capture?.data
|
|
649
|
+
? await encodeHandles(capture.data)
|
|
650
|
+
: undefined;
|
|
651
|
+
} catch (buildError) {
|
|
652
|
+
// Serialize/handle-encode failed: no envelope for followers (they run
|
|
653
|
+
// fresh) and nothing to write.
|
|
654
|
+
clearSelf();
|
|
655
|
+
rejectEnvelope(buildError);
|
|
656
|
+
requestCtx?._reportBackgroundError?.(buildError, "cache-write");
|
|
657
|
+
return;
|
|
658
|
+
}
|
|
659
|
+
clearSelf();
|
|
660
|
+
if (serialized === null) {
|
|
661
|
+
// Non-serializable result: no store write (matches the prior silent
|
|
662
|
+
// skip); reject so any waiter falls through to a fresh execution.
|
|
663
|
+
rejectEnvelope(
|
|
664
|
+
new Error(
|
|
665
|
+
`[use cache] "${id}" result is not serializable; not cached`,
|
|
666
|
+
),
|
|
667
|
+
);
|
|
668
|
+
return;
|
|
669
|
+
}
|
|
670
|
+
// Hand followers the envelope before the store write so a slow/failed
|
|
671
|
+
// write never stalls them.
|
|
672
|
+
resolveEnvelope({ serialized, tags: allTags, handles: encodedHandles });
|
|
673
|
+
try {
|
|
674
|
+
await store.setItem!(cacheKey, serialized, {
|
|
675
|
+
handles: encodedHandles,
|
|
676
|
+
ttl: profile.ttl,
|
|
677
|
+
swr: profile.swr,
|
|
678
|
+
tags: allTags.length > 0 ? allTags : undefined,
|
|
679
|
+
});
|
|
489
680
|
} catch (writeError) {
|
|
490
681
|
requestCtx?._reportBackgroundError?.(writeError, "cache-write");
|
|
491
682
|
}
|
|
492
683
|
};
|
|
493
684
|
|
|
494
|
-
await runBackground(requestCtx,
|
|
685
|
+
await runBackground(requestCtx, finalizeAndWrite, true);
|
|
495
686
|
|
|
496
687
|
return result;
|
|
497
688
|
};
|
package/src/cache/cache-scope.ts
CHANGED
|
@@ -31,7 +31,7 @@ import {
|
|
|
31
31
|
encodeHandles,
|
|
32
32
|
decodeHandles,
|
|
33
33
|
} from "./handle-snapshot.js";
|
|
34
|
-
import {
|
|
34
|
+
import { cacheKeyBase } from "./cache-key-utils.js";
|
|
35
35
|
import {
|
|
36
36
|
DEFAULT_ROUTE_TTL,
|
|
37
37
|
isFiniteNonNegativeSeconds,
|
|
@@ -85,21 +85,6 @@ function validatedSwr(value: number | undefined): number | undefined {
|
|
|
85
85
|
return isValidCacheSeconds(value, "swr") ? value : undefined;
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
-
function getCacheKeyBase(
|
|
89
|
-
host: string,
|
|
90
|
-
pathname: string,
|
|
91
|
-
params?: Record<string, string>,
|
|
92
|
-
searchParams?: URLSearchParams,
|
|
93
|
-
): string {
|
|
94
|
-
const paramStr = sortedRouteParams(params);
|
|
95
|
-
const searchStr = searchParams ? sortedSearchString(searchParams) : "";
|
|
96
|
-
|
|
97
|
-
let key = `${host}${pathname}`;
|
|
98
|
-
if (paramStr) key += `:${paramStr}`;
|
|
99
|
-
if (searchStr) key += `?${searchStr}`;
|
|
100
|
-
return key;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
88
|
function getDefaultRouteCacheKey(
|
|
104
89
|
pathname: string,
|
|
105
90
|
params?: Record<string, string>,
|
|
@@ -113,7 +98,7 @@ function getDefaultRouteCacheKey(
|
|
|
113
98
|
// Intercept navigations get their own cache namespace
|
|
114
99
|
const prefix = isIntercept ? "intercept" : isPartial ? "partial" : "doc";
|
|
115
100
|
|
|
116
|
-
return `${prefix}:${
|
|
101
|
+
return `${prefix}:${cacheKeyBase(host, pathname, searchParams, params)}`;
|
|
117
102
|
}
|
|
118
103
|
|
|
119
104
|
// ============================================================================
|
package/src/cache/cache-tag.ts
CHANGED
|
@@ -38,36 +38,82 @@ export function normalizeTags(tags: Iterable<string>): string[] {
|
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
/**
|
|
41
|
-
* Tag
|
|
42
|
-
* updateTag() / revalidateTag().
|
|
41
|
+
* Tag content for later invalidation via updateTag() / revalidateTag().
|
|
43
42
|
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
43
|
+
* cacheTag() serves two forms depending on what is active when it runs:
|
|
44
|
+
*
|
|
45
|
+
* 1. Inside a "use cache" function — the DEFAULT. The tags go to the current
|
|
46
|
+
* cache entry; `revalidateTag(tag)` drops that entry. Tags are additive
|
|
47
|
+
* (multiple calls accumulate), and normalizeTag() is the single chokepoint so
|
|
48
|
+
* a padded write matches an unpadded invalidate.
|
|
49
|
+
*
|
|
50
|
+
* 2. Render-callable (#648) — no "use cache" scope active, but a request context
|
|
51
|
+
* is present. The tags record onto the request's DOCUMENT artifact
|
|
52
|
+
* (ctx._requestTags) instead of throwing. The collection layers already exist:
|
|
53
|
+
* PPR shell capture unions _requestTags into the shell entry, the document
|
|
54
|
+
* cache tags the full-page entry with it, and prerender build contexts seed
|
|
55
|
+
* their own set — so a server component that renders into a shell makes
|
|
56
|
+
* `revalidateTag("campaign:spring")` evict that shell with ZERO cache()/"use
|
|
57
|
+
* cache" in its tree. This is PPR's DERIVATIVE invalidation: PPR is
|
|
58
|
+
* execution-PRESERVING (everything still runs underneath; only document bytes
|
|
59
|
+
* are shortcut), so its tags ride this existing instrument rather than a
|
|
60
|
+
* first-class ppr key/tag API. The shell-expiry invariant holds by
|
|
61
|
+
* construction — baked ⇒ evicts (bake-lane loaders execute during capture and
|
|
62
|
+
* record here), hole ⇒ fresh (masked loaders behind a renderable loading()
|
|
63
|
+
* never execute during capture, so nothing under a hole can tag the shell).
|
|
64
|
+
*
|
|
65
|
+
* Inside a cache() DSL segment the render-callable form records at the DOCUMENT
|
|
66
|
+
* level, not the segment (only the "use cache" runtime enters the tag scope) — a
|
|
67
|
+
* documented semantic, not a filtered one. An empty/whitespace-only tag is
|
|
68
|
+
* dropped in both forms (the render-callable form silently, via normalizeTags in
|
|
69
|
+
* recordRequestTags; the scope form with a dev warning).
|
|
70
|
+
*
|
|
71
|
+
* With neither a scope nor a request context, cacheTag() throws.
|
|
46
72
|
*
|
|
47
73
|
* @example
|
|
48
74
|
* ```typescript
|
|
75
|
+
* // Form 1 — inside "use cache":
|
|
49
76
|
* async function getProduct(ctx) {
|
|
50
77
|
* "use cache";
|
|
51
78
|
* cacheTag(`product:${ctx.params.id}`, "products");
|
|
52
79
|
* return db.getProduct(ctx.params.id);
|
|
53
80
|
* }
|
|
81
|
+
*
|
|
82
|
+
* // Form 2 — render-callable, tags the shell/document from a server component:
|
|
83
|
+
* function CampaignBanner() {
|
|
84
|
+
* cacheTag("campaign:spring");
|
|
85
|
+
* return <aside>Spring sale</aside>;
|
|
86
|
+
* }
|
|
54
87
|
* ```
|
|
55
88
|
*/
|
|
56
89
|
export function cacheTag(...tags: string[]): void {
|
|
57
90
|
const store = cacheTagStorage.getStore();
|
|
58
|
-
if (
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
91
|
+
if (store) {
|
|
92
|
+
// Form 1: "use cache" scope wins — tag the cache entry (unchanged).
|
|
93
|
+
for (const tag of tags) {
|
|
94
|
+
const normalized = normalizeTag(tag);
|
|
95
|
+
if (normalized === null) {
|
|
96
|
+
if (process.env.NODE_ENV !== "production") {
|
|
97
|
+
console.warn(`[cacheTag] Ignoring empty or whitespace-only tag.`);
|
|
98
|
+
}
|
|
99
|
+
continue;
|
|
66
100
|
}
|
|
67
|
-
|
|
101
|
+
store.add(normalized);
|
|
68
102
|
}
|
|
69
|
-
|
|
103
|
+
return;
|
|
70
104
|
}
|
|
105
|
+
|
|
106
|
+
const reqCtx = _getRequestContext();
|
|
107
|
+
if (reqCtx?._requestTags) {
|
|
108
|
+
// Form 2: render-callable — tag the request's document artifact. See the
|
|
109
|
+
// JSDoc above for the composition doctrine and the baked/hole invariant.
|
|
110
|
+
recordRequestTags(tags, reqCtx);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
throw new Error(
|
|
115
|
+
'cacheTag() must be called inside a "use cache" function or during a request render.',
|
|
116
|
+
);
|
|
71
117
|
}
|
|
72
118
|
|
|
73
119
|
export function recordRequestTags(
|