Skip to content
Draft
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
5 changes: 5 additions & 0 deletions .changeset/thin-oranges-serve.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@opennextjs/aws": minor
---

Add a getTagCacheResult method for the incremental cache
30 changes: 28 additions & 2 deletions packages/open-next/src/adapters/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,14 @@ export default class Cache {
? false
: await hasBeenRevalidated(key, _tags, cachedEntry);

if (_hasBeenRevalidated) return null;
if (_hasBeenRevalidated) {
await globalThis.incrementalCache.getTagCacheResult?.({
key,
hasBeenRevalidated: true,
isStaleFromTag: false,
});
return null;
}

// For cases where we don't have tags, we need to ensure that the soft tags are not being revalidated
// We only need to check for the path as it should already contain all the tags
Expand Down Expand Up @@ -106,6 +113,12 @@ export default class Cache {
? false
: await isStale(key, _tags, _lastModified);

await globalThis.incrementalCache.getTagCacheResult?.({
key,
hasBeenRevalidated: false,
isStaleFromTag: _isStale,
});

return {
lastModified: _isStale ? 1 : _lastModified,
value: cachedEntry.value,
Expand Down Expand Up @@ -133,12 +146,25 @@ export default class Cache {
const _hasBeenRevalidated = cachedEntry.shouldBypassTagCache
? false
: await hasBeenRevalidated(key, tags, cachedEntry);
if (_hasBeenRevalidated) return null;
if (_hasBeenRevalidated) {
await globalThis.incrementalCache.getTagCacheResult?.({
key,
hasBeenRevalidated: true,
isStaleFromTag: false,
});
return null;
}

const _isStale = cachedEntry.shouldBypassTagCache
? false
: await isStale(key, tags, _lastModified);

await globalThis.incrementalCache.getTagCacheResult?.({
key,
hasBeenRevalidated: false,
isStaleFromTag: _isStale,
});

const store = globalThis.__openNextAls.getStore();
if (store) {
store.lastModified = _isStale ? 1 : _lastModified;
Expand Down
34 changes: 26 additions & 8 deletions packages/open-next/src/adapters/composable-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,37 +39,55 @@ export default {
result.value.tags.length > 0
) {
// We need to check if the tags associated with this entry has been revalidated
const hasBeenRevalidated = result.shouldBypassTagCache
const _hasBeenRevalidated = result.shouldBypassTagCache
? false
: await globalThis.tagCache.hasBeenRevalidated(
result.value.tags,
result.lastModified,
);
if (hasBeenRevalidated) return undefined;

// Check if tags are stale – entry is valid but needs background revalidation
const isCacheStale = result.shouldBypassTagCache
const isCacheStale = _hasBeenRevalidated
? false
: await isStale(cacheKey, result.value.tags, result.lastModified);
: result.shouldBypassTagCache
? false
: await isStale(cacheKey, result.value.tags, result.lastModified);

await globalThis.incrementalCache.getTagCacheResult?.({
key: cacheKey,
hasBeenRevalidated: _hasBeenRevalidated,
isStaleFromTag: isCacheStale,
});

if (_hasBeenRevalidated) return undefined;
if (isCacheStale) {
revalidate = -1;
}
} else if (
globalThis.tagCache.mode === "original" ||
globalThis.tagCache.mode === undefined
) {
const hasBeenRevalidated = result.shouldBypassTagCache
const _hasBeenRevalidated = result.shouldBypassTagCache
? false
: (await globalThis.tagCache.getLastModified(
cacheKey,
result.lastModified,
)) === -1;
if (hasBeenRevalidated) return undefined;

// Check if tags are stale – entry is valid but needs background revalidation
const isCacheStale = result.shouldBypassTagCache
const isCacheStale = _hasBeenRevalidated
? false
: await isStale(cacheKey, result.value.tags, result.lastModified);
: result.shouldBypassTagCache
? false
: await isStale(cacheKey, result.value.tags, result.lastModified);

await globalThis.incrementalCache.getTagCacheResult?.({
key: cacheKey,
hasBeenRevalidated: _hasBeenRevalidated,
isStaleFromTag: isCacheStale,
});

if (_hasBeenRevalidated) return undefined;
if (isCacheStale) {
revalidate = -1;
}
Expand Down
80 changes: 66 additions & 14 deletions packages/open-next/src/core/routing/cacheInterceptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,47 @@ import { generateMessageGroupId } from "./queue";
const CACHE_ONE_YEAR = 60 * 60 * 24 * 365;
const CACHE_ONE_MONTH = 60 * 60 * 24 * 30;

/**
* Determines the effective revalidate duration for a given path/value combination,
* consulting the prerender manifest when `revalidate` is not explicitly set.
*/
function computeFinalRevalidate(
path: string,
revalidate?: number | false,
): number {
let finalRevalidate = CACHE_ONE_YEAR;
const existingRoute = Object.entries(PrerenderManifest?.routes ?? {}).find(
(p) => p[0] === path,
)?.[1];
if (revalidate === undefined && existingRoute) {
finalRevalidate =
existingRoute.initialRevalidateSeconds === false
? CACHE_ONE_YEAR
: existingRoute.initialRevalidateSeconds;
// eslint-disable-next-line sonarjs/elseif-without-else
} else if (revalidate !== undefined) {
finalRevalidate = revalidate === false ? CACHE_ONE_YEAR : revalidate;
}
return finalRevalidate;
}

/**
* Returns whether a cache entry is stale purely due to time (TTL expiry),
* independent of any tag-based revalidation.
*/
function computeIsStaleFromTime(
path: string,
revalidate: number | false | undefined,
lastModified: number | undefined,
): boolean {
const finalRevalidate = computeFinalRevalidate(path, revalidate);
const isSSG = finalRevalidate === CACHE_ONE_YEAR;
if (isSSG) return false;
const age = Math.round((Date.now() - (lastModified ?? 0)) / 1000);
const remainingTtl = Math.max(finalRevalidate - age, 1);
return remainingTtl === 1;
}

/*
* We use this header to prevent Firefox (and possibly some CDNs) from incorrectly reusing the RSC responses during caching.
* This can especially happen when there's a redirect in the middleware as the `_rsc` query parameter is not visible there.
Expand All @@ -41,20 +82,7 @@ async function computeCacheControl(
lastModified?: number,
isStaleFromTagCache = false,
) {
let finalRevalidate = CACHE_ONE_YEAR;

const existingRoute = Object.entries(PrerenderManifest?.routes ?? {}).find(
(p) => p[0] === path,
)?.[1];
if (revalidate === undefined && existingRoute) {
finalRevalidate =
existingRoute.initialRevalidateSeconds === false
? CACHE_ONE_YEAR
: existingRoute.initialRevalidateSeconds;
// eslint-disable-next-line sonarjs/elseif-without-else
} else if (revalidate !== undefined) {
finalRevalidate = revalidate === false ? CACHE_ONE_YEAR : revalidate;
}
const finalRevalidate = computeFinalRevalidate(path, revalidate);
// calculate age
const age = Math.round((Date.now() - (lastModified ?? 0)) / 1000);
const hash = (str: string) => createHash("md5").update(str).digest("hex");
Expand Down Expand Up @@ -298,6 +326,17 @@ export async function cacheInterceptor(
: await hasBeenRevalidated(cacheKey, tags, cachedData);

if (_hasBeenRevalidated) {
const isStaleFromTime = computeIsStaleFromTime(
localizedPath,
cachedData.value.revalidate,
cachedData.lastModified,
);
await globalThis.incrementalCache.getTagCacheResult?.({
key: cacheKey,
hasBeenRevalidated: true,
isStaleFromTag: false,
isStaleFromTime,
});
return event;
}
}
Expand All @@ -307,6 +346,19 @@ export async function cacheInterceptor(
? false
: await isStale(cacheKey, tags, cachedData.lastModified ?? Date.now());

const isStaleFromTime = computeIsStaleFromTime(
localizedPath,
cachedData.value.revalidate,
cachedData.lastModified,
);

await globalThis.incrementalCache.getTagCacheResult?.({
key: cacheKey,
hasBeenRevalidated: false,
isStaleFromTag: _isStale,
isStaleFromTime,
});

const host = event.headers.host;
switch (cachedData?.value?.type) {
case "app":
Expand Down
11 changes: 11 additions & 0 deletions packages/open-next/src/types/overrides.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,17 @@ export type IncrementalCache = {
): Promise<void>;
delete(key: string): Promise<void>;
name: string;
/**
* Optional observer hook called after the tag cache has been consulted.
* Receives the combined tag-cache verdict for the given cache key.
* `isStaleFromTime` is only provided by the cache interceptor.
*/
getTagCacheResult?: (params: {
key: string;
hasBeenRevalidated: boolean;
isStaleFromTag: boolean;
isStaleFromTime?: boolean;
}) => Promise<void>;
};

// Tag cache
Expand Down
1 change: 0 additions & 1 deletion packages/open-next/src/utils/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,6 @@ export function getTagsFromValue(value?: CacheValue<"cache">) {
try {
const cacheTags =
value.meta?.headers?.["x-next-cache-tags"]?.split(",") ?? [];
delete value.meta?.headers?.["x-next-cache-tags"];
return cacheTags;
} catch (e) {
return [];
Expand Down
111 changes: 111 additions & 0 deletions packages/tests-unit/tests/adapters/cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ describe("CacheHandler", () => {
}),
set: vi.fn(),
delete: vi.fn(),
getTagCacheResult: vi.fn().mockResolvedValue(undefined),
};
globalThis.incrementalCache = incrementalCache;

Expand Down Expand Up @@ -255,6 +256,57 @@ describe("CacheHandler", () => {
expect(tagCache.isStale).not.toHaveBeenCalled();
});
});

describe("getTagCacheResult", () => {
it("Should call getTagCacheResult with hasBeenRevalidated: false and isStaleFromTag: false on cache hit", async () => {
await cache.get("key", { fetchCache: true });

expect(incrementalCache.getTagCacheResult).toHaveBeenCalledWith({
key: "key",
hasBeenRevalidated: false,
isStaleFromTag: false,
});
});

it("Should call getTagCacheResult with hasBeenRevalidated: true when fetch cache is expired", async () => {
tagCache.getLastModified.mockResolvedValueOnce(-1);

await cache.get("key", { fetchCache: true });

expect(incrementalCache.getTagCacheResult).toHaveBeenCalledWith({
key: "key",
hasBeenRevalidated: true,
isStaleFromTag: false,
});
});

it("Should call getTagCacheResult with isStaleFromTag: true when fetch cache tag is stale (next16)", async () => {
globalThis.nextVersion = "16.0.0";
tagCache.mode = "nextMode";
tagCache.hasBeenRevalidated.mockResolvedValueOnce(false);
tagCache.isStale.mockResolvedValueOnce(true);
incrementalCache.get.mockResolvedValueOnce({
value: {
kind: "FETCH",
data: {
headers: {},
body: "{}",
url: "https://example.com",
status: 200,
},
},
lastModified: Date.now(),
});

await cache.get("key", { kind: "FETCH", tags: ["tag1"] });

expect(incrementalCache.getTagCacheResult).toHaveBeenCalledWith({
key: "key",
hasBeenRevalidated: false,
isStaleFromTag: true,
});
});
});
});

describe("incremental cache", () => {
Expand Down Expand Up @@ -536,6 +588,65 @@ describe("CacheHandler", () => {
expect(tagCache.isStale).not.toHaveBeenCalled();
});
});

describe("getTagCacheResult", () => {
it("Should call getTagCacheResult with hasBeenRevalidated: false and isStaleFromTag: false on cache hit", async () => {
await cache.get("key", { kindHint: "app" });

expect(incrementalCache.getTagCacheResult).toHaveBeenCalledWith({
key: "key",
hasBeenRevalidated: false,
isStaleFromTag: false,
});
});

it("Should call getTagCacheResult with hasBeenRevalidated: true when cache is expired", async () => {
tagCache.getLastModified.mockResolvedValueOnce(-1);
incrementalCache.get.mockResolvedValueOnce({
value: { type: "route", body: "{}" },
lastModified: Date.now(),
});

await cache.get("key", { kindHint: "app" });

expect(incrementalCache.getTagCacheResult).toHaveBeenCalledWith({
key: "key",
hasBeenRevalidated: true,
isStaleFromTag: false,
});
});

it("Should call getTagCacheResult with isStaleFromTag: true when cache tag is stale (next16)", async () => {
globalThis.nextVersion = "16.0.0";
tagCache.isStale.mockResolvedValueOnce(true);
incrementalCache.get.mockResolvedValueOnce({
value: { type: "route", body: "{}" },
lastModified: Date.now(),
});

await cache.get("key", { kindHint: "app" });

expect(incrementalCache.getTagCacheResult).toHaveBeenCalledWith({
key: "key",
hasBeenRevalidated: false,
isStaleFromTag: true,
});
});

it("Should not throw when getTagCacheResult is not defined", async () => {
const prevIncrementalCache = globalThis.incrementalCache;
globalThis.incrementalCache = {
...prevIncrementalCache,
getTagCacheResult: undefined,
};

await expect(
cache.get("key", { kindHint: "app" }),
).resolves.not.toThrow();

globalThis.incrementalCache = prevIncrementalCache;
});
});
});
});

Expand Down
Loading
Loading