Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 77 additions & 52 deletions packages/vinext/src/cloudflare/kv-cache-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,24 @@ function validateTag(tag: string): string | null {
return tag;
}

function readStringArrayField(ctx: Record<string, unknown> | undefined, field: string): string[] {
const value = ctx?.[field];
if (!Array.isArray(value)) return [];
return value.filter((item): item is string => typeof item === "string");
}

function validUniqueTags(tags: string[]): string[] {
const result: string[] = [];
const seen = new Set<string>();
for (const tag of tags) {
const validTag = validateTag(tag);
if (!validTag || seen.has(validTag)) continue;
seen.add(validTag);
result.push(validTag);
}
return result;
}

/**
* Segment-aware path prefix check. Returns true if `path` is equal to
* `prefix` or is a child route (next char after prefix is `/`).
Expand Down Expand Up @@ -192,59 +210,14 @@ export class KVCacheHandler implements CacheHandler {
}
}

// Check tag-based invalidation.
// Uses a local in-memory cache to avoid redundant KV reads for recently-seen tags.
if (entry.tags.length > 0) {
const now = Date.now();
const uncachedTags: string[] = [];

// First pass: check local cache for each tag.
// Delete expired entries to prevent unbounded Map growth in long-lived isolates.
for (const tag of entry.tags) {
const cached = this._tagCache.get(tag);
if (cached && now - cached.fetchedAt < this._tagCacheTtl) {
// Local cache hit — check invalidation inline
if (Number.isNaN(cached.timestamp) || cached.timestamp >= entry.lastModified) {
this._deleteInBackground(kvKey);
return null;
}
} else {
// Expired or absent — evict stale entry and re-fetch from KV
if (cached) this._tagCache.delete(tag);
uncachedTags.push(tag);
}
}

// Second pass: fetch uncached tags from KV in parallel.
// Populate the local cache for ALL fetched tags before checking invalidation,
// so that KV round-trips are not wasted when an earlier tag triggers an
// early return — subsequent get() calls benefit from the already-fetched results.
if (uncachedTags.length > 0) {
const tagResults = await Promise.all(
uncachedTags.map((tag) => this.kv.get(this.prefix + TAG_PREFIX + tag)),
);

// Populate cache for all results first, then check for invalidation.
// Two-loop structure ensures all tag results are cached even when an
// earlier tag would cause an early return — so subsequent get() calls
// for entries sharing those tags don't redundantly re-fetch from KV.
for (let i = 0; i < uncachedTags.length; i++) {
const tagTime = tagResults[i];
const tagTimestamp = tagTime ? Number(tagTime) : 0;
this._tagCache.set(uncachedTags[i], { timestamp: tagTimestamp, fetchedAt: now });
}
if (await this._hasRevalidatedTag(validUniqueTags(entry.tags), entry.lastModified)) {
this._deleteInBackground(kvKey);
return null;
}

// Then check for invalidation using the now-cached timestamps
for (const tag of uncachedTags) {
const cached = this._tagCache.get(tag)!;
if (cached.timestamp !== 0) {
if (Number.isNaN(cached.timestamp) || cached.timestamp >= entry.lastModified) {
this._deleteInBackground(kvKey);
return null;
}
}
}
}
const softTags = validUniqueTags(readStringArrayField(_ctx, "softTags"));
if (await this._hasRevalidatedTag(softTags, entry.lastModified)) {
return null;
}

// Check time-based expiry — return stale with cacheState
Expand All @@ -262,6 +235,58 @@ export class KVCacheHandler implements CacheHandler {
};
}

/**
* Check tag invalidation markers for stored tags or read-time soft tags.
* Uses a local in-memory cache to avoid redundant KV reads for recently-seen tags.
*/
private async _hasRevalidatedTag(tags: string[], lastModified: number): Promise<boolean> {
if (tags.length === 0) return false;

const now = Date.now();
const uncachedTags: string[] = [];

// First pass: check local cache for each tag.
// Delete expired entries to prevent unbounded Map growth in long-lived isolates.
for (const tag of tags) {
const cached = this._tagCache.get(tag);
if (cached && now - cached.fetchedAt < this._tagCacheTtl) {
// Local cache hit — check invalidation inline
if (Number.isNaN(cached.timestamp) || cached.timestamp >= lastModified) {
return true;
}
} else {
// Expired or absent — evict stale entry and re-fetch from KV
if (cached) this._tagCache.delete(tag);
uncachedTags.push(tag);
}
}

// Second pass: fetch uncached tags from KV in parallel.
// Populate the local cache for ALL fetched tags before checking invalidation,
// so subsequent get() calls benefit from the already-fetched results.
if (uncachedTags.length > 0) {
const tagResults = await Promise.all(
uncachedTags.map((tag) => this.kv.get(this.prefix + TAG_PREFIX + tag)),
);

for (let i = 0; i < uncachedTags.length; i++) {
const tagTime = tagResults[i];
const tagTimestamp = tagTime ? Number(tagTime) : 0;
this._tagCache.set(uncachedTags[i], { timestamp: tagTimestamp, fetchedAt: now });
}

for (const tag of uncachedTags) {
const cached = this._tagCache.get(tag);
if (!cached || cached.timestamp === 0) continue;
if (Number.isNaN(cached.timestamp) || cached.timestamp >= lastModified) {
return true;
}
}
}

return false;
}

set(
key: string,
data: IncrementalCacheValue | null,
Expand Down
5 changes: 4 additions & 1 deletion packages/vinext/src/entries/app-rsc-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -425,7 +425,7 @@ import {
} from ${JSON.stringify(appRouteHandlerResponsePath)};
import { _consumeRequestScopedCacheLife, getCacheHandler } from "next/cache";
import { getRequestExecutionContext as _getRequestExecutionContext } from ${JSON.stringify(requestContextShimPath)};
import { ensureFetchPatch as _ensureFetchPatch, getCollectedFetchTags } from "vinext/fetch-cache";
import { ensureFetchPatch as _ensureFetchPatch, getCollectedFetchTags, setCurrentFetchSoftTags } from "vinext/fetch-cache";
import { buildRouteTrie as _buildRouteTrie, trieMatch as _trieMatch } from ${JSON.stringify(routeTriePath)};
// Import server-only state module to register ALS-backed accessors.
import "vinext/navigation-state";
Expand Down Expand Up @@ -2092,6 +2092,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
}

const { route, params } = match;
setCurrentFetchSoftTags(__pageCacheTags(cleanPathname, []));

// Update navigation context with matched params
setNavigationContext({
Expand Down Expand Up @@ -2178,6 +2179,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
});
await _runWithUnifiedCtx(__revalUCtx, async () => {
_ensureFetchPatch();
setCurrentFetchSoftTags(__pageCacheTags(cleanPathname, []));
await renderFn();
});
},
Expand Down Expand Up @@ -2328,6 +2330,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
});
return _runWithUnifiedCtx(__revalUCtx, async () => {
_ensureFetchPatch();
setCurrentFetchSoftTags(__pageCacheTags(cleanPathname, []));
setNavigationContext({ pathname: cleanPathname, searchParams: new URLSearchParams(), params });
// Slot context (X-Vinext-Mounted-Slots) is inherited from the
// triggering request so the regen result is cached under the
Expand Down
13 changes: 13 additions & 0 deletions packages/vinext/src/shims/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,12 @@ type MemoryEntry = {
revalidateAt: number | null;
};

function readStringArrayField(ctx: Record<string, unknown> | undefined, field: string): string[] {
const value = ctx?.[field];
if (!Array.isArray(value)) return [];
return value.filter((item): item is string => typeof item === "string");
}

/**
* Shape of the optional `ctx` argument passed to `CacheHandler.set()`.
* Covers both the older `{ revalidate: number }` shape and the newer
Expand Down Expand Up @@ -208,6 +214,13 @@ export class MemoryCacheHandler implements CacheHandler {
}
}

for (const tag of readStringArrayField(_ctx, "softTags")) {
const revalidatedAt = this.tagRevalidatedAt.get(tag);
if (revalidatedAt && revalidatedAt >= entry.lastModified) {
return null;
}
}

// Check time-based expiry — return stale entry with cacheState="stale"
// instead of deleting, so ISR can serve stale-while-revalidate
if (entry.revalidateAt !== null && Date.now() > entry.revalidateAt) {
Expand Down
20 changes: 18 additions & 2 deletions packages/vinext/src/shims/fetch-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -468,6 +468,7 @@ const originalFetch: typeof globalThis.fetch = (_gFetch[_ORIG_FETCH_KEY] ??=
// ---------------------------------------------------------------------------
export type FetchCacheState = {
currentRequestTags: string[];
currentFetchSoftTags: string[];
};

const _ALS_KEY = Symbol.for("vinext.fetchCache.als");
Expand All @@ -478,6 +479,7 @@ const _als = (_g[_ALS_KEY] ??=

const _fallbackState = (_g[_FALLBACK_KEY] ??= {
currentRequestTags: [],
currentFetchSoftTags: [],
} satisfies FetchCacheState) as FetchCacheState;

function _getState(): FetchCacheState {
Expand All @@ -493,6 +495,7 @@ function _getState(): FetchCacheState {
*/
function _resetFallbackState(): void {
_fallbackState.currentRequestTags = [];
_fallbackState.currentFetchSoftTags = [];
}

/**
Expand All @@ -504,6 +507,17 @@ export function getCollectedFetchTags(): string[] {
return [..._getState().currentRequestTags];
}

/**
* Set path-derived implicit tags for fetch cache reads in the current render.
*
* These are intentionally not persisted on fetch entries. They mirror Next.js
* `softTags`: `revalidatePath()` should make a fetch miss while rendering the
* affected route, without permanently coupling a shared fetch entry to one path.
*/
export function setCurrentFetchSoftTags(tags: string[]): void {
_getState().currentFetchSoftTags = [...tags];
}

/**
* Create a patched fetch function with Next.js caching semantics.
*
Expand Down Expand Up @@ -586,6 +600,7 @@ function createPatchedFetch(): typeof globalThis.fetch {
}

const tags = nextOpts?.tags ?? [];
const softTags = _getState().currentFetchSoftTags;
let cacheKey: string;
try {
cacheKey = await buildFetchCacheKey(input, init);
Expand Down Expand Up @@ -613,7 +628,7 @@ function createPatchedFetch(): typeof globalThis.fetch {

// Try cache first
try {
const cached = await handler.get(cacheKey, { kind: "FETCH", tags });
const cached = await handler.get(cacheKey, { kind: "FETCH", tags, softTags });
if (cached?.value && cached.value.kind === "FETCH" && cached.cacheState !== "stale") {
const cachedData = cached.value.data;
// Reconstruct a Response from the cached data
Expand Down Expand Up @@ -826,9 +841,10 @@ export async function runWithFetchCache<T>(fn: () => Promise<T>): Promise<T> {
if (isInsideUnifiedScope()) {
return await runWithUnifiedStateMutation((uCtx) => {
uCtx.currentRequestTags = [];
uCtx.currentFetchSoftTags = [];
}, fn);
}
return _als.run({ currentRequestTags: [] }, fn);
return _als.run({ currentRequestTags: [], currentFetchSoftTags: [] }, fn);
}

/**
Expand Down
1 change: 1 addition & 0 deletions packages/vinext/src/shims/unified-request-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ export function createRequestContext(opts?: Partial<UnifiedRequestContext>): Uni
requestScopedCacheLife: null,
_privateCache: null,
currentRequestTags: [],
currentFetchSoftTags: [],
executionContext: _getInheritedExecutionContext(), // inherits from standalone ALS if present
requestCache: new WeakMap(),
ssrContext: null,
Expand Down
Loading
Loading