/**
* "use cache" Runtime
*
* Provides the runtime wrapper for functions marked with "use cache" directive.
* The Vite transform plugin wraps exports with registerCachedFunction().
*
* On cache miss: executes the function, serializes the result via RSC Flight
* protocol, captures handle data if tainted ctx is detected, and stores in
* the SegmentCacheStore.
*
* On cache hit: deserializes the cached result, restores handle data if present.
*
* On stale hit: returns stale data immediately, triggers background
* re-execution via waitUntil().
*/
///
import {
encodeReply,
createClientTemporaryReferenceSet,
} from "@vitejs/plugin-rsc/rsc";
import { getRequestContext } from "../server/request-context.js";
import {
isTainted,
CACHED_FN_SYMBOL,
isCachedFunction,
stampCacheExec,
unstampCacheExec,
} from "./taint.js";
export { isCachedFunction };
import { serializeResult, deserializeResult } from "./segment-codec.js";
import { createHandleStore } from "../server/handle-store.js";
import { restoreHandles } from "./handle-snapshot.js";
import { startHandleCapture, type HandleCapture } from "./handle-capture.js";
import { sortedSearchString } from "./cache-key-utils.js";
import { runBackground } from "./background-task.js";
/**
* Convert encodeReply result to a stable string key.
* encodeReply may return string or FormData — normalize to string.
*/
async function replyToCacheKey(encoded: string | FormData): Promise {
if (typeof encoded === "string") return encoded;
// FormData: convert to Response body, then to string for deterministic key
const text = await new Response(encoded).text();
return text;
}
// ============================================================================
// Core: registerCachedFunction
// ============================================================================
/**
* Register a function as a cached function.
* Called by the Vite transform for each "use cache" function.
*
* @param fn - The original async function
* @param id - Stable identifier (module path + export name)
* @param profileName - Cache profile name (from "use cache: profileName" or "default")
*/
export function registerCachedFunction any>(
fn: T,
id: string,
profileName: string,
): T {
const wrapped = async function (this: any, ...args: any[]): Promise {
const requestCtx = getRequestContext();
const store = requestCtx?._cacheStore;
const resolvedProfileName = profileName || "default";
// Bypass: no store or no getItem support
if (!store?.getItem) {
return fn.apply(this, args);
}
// Resolve profile strictly from request-scoped config (set by the
// active router via createRequestContext). No global fallback —
// global profile state is only for DSL-time cache("profileName").
const profile = requestCtx?._cacheProfiles?.[resolvedProfileName];
if (!profile) {
throw new Error(
`[use cache] "${id}" uses unknown cache profile "${resolvedProfileName}". ` +
`Define it in createRouter({ cacheProfiles: { "${resolvedProfileName}": { ttl: ... } } }).`,
);
}
// Separate tainted args (ctx, env, req) from key-generating args.
// For tainted objects that carry route context (params, pathname,
// searchParams), extract serializable values into the key so
// different routes, param combinations, and query variants produce
// distinct cache entries.
const keyArgs: unknown[] = [];
let hasTaintedArgs = false;
for (const arg of args) {
if (isTainted(arg)) {
hasTaintedArgs = true;
const ctx = arg as any;
if (ctx.params && typeof ctx.params === "object") {
// Include host to prevent cross-host cache collisions (same
// pattern as route-level cache-scope.ts key generation).
if (ctx.url?.host) {
keyArgs.push(ctx.url.host);
}
// Include route name to prevent collisions when the same cached
// function is reused across routes with identical pathname/params
// but different local reverse() scope.
if (ctx._routeName) {
keyArgs.push(ctx._routeName);
}
keyArgs.push(ctx.pathname, ctx.params);
if (ctx._responseType) {
keyArgs.push(ctx._responseType);
}
// Include user-facing search params (exclude internal _rsc*/__ params)
if (ctx.searchParams instanceof URLSearchParams) {
const normalized = sortedSearchString(ctx.searchParams);
if (normalized) {
keyArgs.push(normalized);
}
}
}
} else {
keyArgs.push(arg);
}
}
// If tainted args are present, we need the handle store for capture/restore.
// During late streaming (Suspense boundary resolution), ALS context may be
// gone. Throw early rather than silently dropping handle side effects.
if (hasTaintedArgs && !requestCtx?._handleStore) {
throw new Error(
`[use cache] "${id}" receives a tainted argument (ctx/env/req) but the ` +
`HandleStore is not available. This typically happens when a "use cache" ` +
`function with ctx runs outside the request context (e.g., during late ` +
`streaming after AsyncLocalStorage context is lost). Move the "use cache" ` +
`directive to a function that does not receive request-scoped objects, or ` +
`use the route-level cache() DSL instead.`,
);
}
// Generate cache key
let cacheKey: string;
try {
if (keyArgs.length > 0) {
const tempRefs = createClientTemporaryReferenceSet();
const encoded = await encodeReply(keyArgs as unknown[], {
temporaryReferences: tempRefs,
});
const argsKey = await replyToCacheKey(encoded);
cacheKey = `use-cache:${id}:${argsKey}`;
} else {
cacheKey = `use-cache:${id}`;
}
} catch {
// Non-serializable args: run uncached
return fn.apply(this, args);
}
// Cache lookup
const cached = await store.getItem(cacheKey);
if (cached && !cached.shouldRevalidate) {
// Fresh hit: deserialize and return
try {
const result = await deserializeResult(cached.value);
// Restore handle data if present
if (cached.handles && hasTaintedArgs) {
const handleStore = requestCtx?._handleStore;
if (handleStore) {
restoreHandles(cached.handles, handleStore);
}
}
return result;
} catch {
// Deserialization failed, fall through to fresh execution
}
}
if (cached?.shouldRevalidate) {
// Stale hit: return stale value, revalidate in background
try {
const result = await deserializeResult(cached.value);
if (cached.handles && hasTaintedArgs) {
const handleStore = requestCtx?._handleStore;
if (handleStore) {
restoreHandles(cached.handles, handleStore);
}
}
// Background revalidation — must capture handles if tainted args present.
// Use an isolated handle store so background pushes don't pollute the
// live response or throw LateHandlePushError on the completed store.
// Same isolation pattern as route-level background-revalidation.ts.
runBackground(requestCtx, async () => {
// Reuse closure-captured requestCtx instead of calling
// getRequestContext() — ALS context may be gone inside waitUntil.
let originalHandleStore:
| ReturnType
| undefined;
if (hasTaintedArgs && requestCtx) {
originalHandleStore = requestCtx._handleStore;
requestCtx._handleStore = createHandleStore();
}
const bgHandleStore = hasTaintedArgs
? requestCtx?._handleStore
: undefined;
let bgCapture: HandleCapture | undefined;
let bgStopCapture: (() => void) | undefined;
if (bgHandleStore) {
const c = startHandleCapture(bgHandleStore);
bgCapture = c.capture;
bgStopCapture = c.stop;
}
// Stamp tainted ARGS only — not requestCtx. The args stamp guards
// direct ctx method calls (ctx.set, ctx.header, ctx.onResponse, etc.)
// which is sufficient for correctness.
//
// We intentionally skip stamping requestCtx here because:
// 1. runBackground starts the async task synchronously (before the
// first await), so stampCacheExec would pollute the shared
// requestCtx while the foreground pipeline is still running.
// This causes assertNotInsideCacheExec to fire when cache-store
// later calls requestCtx.onResponse().
// 2. requestCtx methods are closure-bound to the original ctx, so
// neither Object.create() nor a proxy can isolate the stamp.
// 3. The foreground miss path already stamps requestCtx and catches
// cookies()/headers() misuse on first execution. The background
// re-runs the same function with the same request.
const bgTaintedArgs: unknown[] = [];
for (const arg of args) {
if (isTainted(arg)) {
stampCacheExec(arg as object);
bgTaintedArgs.push(arg);
}
}
try {
const freshResult = await fn.apply(this, args);
bgStopCapture?.();
const serialized = await serializeResult(freshResult);
if (serialized !== null) {
await store.setItem!(cacheKey, serialized, {
handles: bgCapture?.data,
ttl: profile.ttl,
swr: profile.swr,
tags: profile.tags,
});
}
} catch (bgError) {
bgStopCapture?.();
requestCtx?._reportBackgroundError?.(bgError, "stale-revalidation");
} finally {
for (const arg of bgTaintedArgs) {
unstampCacheExec(arg as object);
}
// Restore original handle store
if (originalHandleStore && requestCtx) {
requestCtx._handleStore = originalHandleStore;
}
}
});
return result;
} catch {
// Deserialization of stale value failed, fall through
}
}
// Cache miss: execute, serialize, store
const handleStore = hasTaintedArgs ? requestCtx?._handleStore : undefined;
let capture: HandleCapture | undefined;
let stopCapture: (() => void) | undefined;
if (handleStore && hasTaintedArgs) {
const c = startHandleCapture(handleStore);
capture = c.capture;
stopCapture = c.stop;
}
// Stamp tainted args so ctx.set(), ctx.header(), etc. throw if called
// inside the cached function body (those side effects are lost on hit).
// Uses ref-counted stamp/unstamp so overlapping executions
// sharing the same ctx don't clear each other's guards.
const taintedArgs: unknown[] = [];
for (const arg of args) {
if (isTainted(arg)) {
stampCacheExec(arg as object);
taintedArgs.push(arg);
}
}
// Always stamp the ALS RequestContext so cookies()/headers() guards fire
// even when the cached function receives no tainted args. The guard in
// cookie-store.ts checks RequestContext, not function args.
if (requestCtx) {
stampCacheExec(requestCtx as object);
}
let result: any;
try {
result = await fn.apply(this, args);
} finally {
// Decrement ref count; symbol is deleted when it reaches zero
for (const arg of taintedArgs) {
unstampCacheExec(arg as object);
}
if (requestCtx) {
unstampCacheExec(requestCtx as object);
}
// Remove this capture token (order-independent, safe for concurrent use)
stopCapture?.();
}
// Serialize and store — fully non-blocking when waitUntil is available.
// The response does not need to wait for serialization or the store write.
const cacheWrite = async () => {
try {
const serialized = await serializeResult(result);
if (serialized !== null) {
await store.setItem!(cacheKey, serialized, {
handles: capture?.data,
ttl: profile.ttl,
swr: profile.swr,
tags: profile.tags,
});
}
} catch (writeError) {
requestCtx?._reportBackgroundError?.(writeError, "cache-write");
}
};
await runBackground(requestCtx, cacheWrite, true);
return result;
};
// Brand the wrapper so it can be detected at runtime (e.g., to prevent
// accidental use as middleware).
(wrapped as any)[CACHED_FN_SYMBOL] = true;
return wrapped as unknown as T;
}