diff --git a/.changeset/swr-tag-revalidation.md b/.changeset/swr-tag-revalidation.md new file mode 100644 index 00000000..a75522e5 --- /dev/null +++ b/.changeset/swr-tag-revalidation.md @@ -0,0 +1,17 @@ +--- +"@opennextjs/core": minor +"@opennextjs/aws": minor +--- + +Port stale-while-revalidate tag revalidation from AWS + +Ports [#1122](https://github.com/opennextjs/opennextjs-aws/pull/1122) and +[#1142](https://github.com/opennextjs/opennextjs-aws/pull/1142). + +Tags can now carry `stale` and `expire` durations, so an entry can be served stale while it +revalidates instead of being dropped outright. `writeTags` accepts either a tag name or a +`{ tag, stale, expire }` object, both tag cache flavours gained an optional `isStale`, and the +composable cache handler implements the `updateTags` method added in Next.js 16. + +Tag cache overrides that need to deduplicate work within a request can use the new per-request +`requestCache` on the OpenNext request context. diff --git a/examples-cloudflare/e2e/experimental/e2e/use-cache.test.ts b/examples-cloudflare/e2e/experimental/e2e/use-cache.test.ts index 3384f15d..8aac623e 100644 --- a/examples-cloudflare/e2e/experimental/e2e/use-cache.test.ts +++ b/examples-cloudflare/e2e/experimental/e2e/use-cache.test.ts @@ -26,21 +26,17 @@ test.describe("Composable Cache", () => { expect(fullyCachedText).toEqual(initialFullyCachedText); }); - test("revalidateTag should work for fullyCached component", async ({ page, request }) => { + test("revalidateTag with SWR should reject unsupported tag caches", async ({ page, request }) => { await page.goto("/use-cache/ssr"); const fullyCachedElt = page.getByTestId("fully-cached-with-tag"); await expect(fullyCachedElt).toBeVisible(); - const initialFullyCachedText = await fullyCachedElt.textContent(); const resp = await request.get("/api/revalidate"); expect(resp.status()).toEqual(200); - expect(await resp.text()).toEqual("DONE"); await page.reload(); - await expect(fullyCachedElt).toBeVisible(); - const newFullyCachedText = await fullyCachedElt.textContent(); - expect(newFullyCachedText).not.toEqual(initialFullyCachedText); + await expect(fullyCachedElt).toHaveText(initialFullyCachedText ?? ""); }); //TODO: figure out why it doesn't work in ISR (my guess is on our patch not working anymore in 16.1, but need investigation) diff --git a/packages/aws/src/overrides/tagCache/dynamodb-lite.ts b/packages/aws/src/overrides/tagCache/dynamodb-lite.ts index 902bc29c..6a8f3709 100644 --- a/packages/aws/src/overrides/tagCache/dynamodb-lite.ts +++ b/packages/aws/src/overrides/tagCache/dynamodb-lite.ts @@ -9,13 +9,38 @@ import { AwsClient } from "aws4fetch"; import { customFetchClient } from "../../utils/fetch.js"; import { MAX_DYNAMO_BATCH_WRITE_ITEM_COUNT, getDynamoBatchWriteCommandConcurrency } from "./constants.js"; +import { isStaleRecord } from "./stale-record.js"; type DynamoDBItem = { tag?: { S: string }; path?: { S: string }; revalidatedAt?: { N: string }; + stale?: { N: string }; + expire?: { N: string }; }; +/** + * Determines whether lightweight DynamoDB tag records require blocking regeneration. + * + * @param items Tag records newer than the cached entry. + * @param lastModified Cached entry timestamp. + * @param now Current timestamp. + * @return Whether a tag was hard-revalidated or its SWR window expired. + */ +export function hasHardRevalidation(items: DynamoDBItem[], lastModified: number, now: number): boolean { + return items.some((item) => { + const revalidatedAt = Number.parseInt(item.revalidatedAt?.N ?? "0"); + if (revalidatedAt <= lastModified) { + return false; + } + if (item.expire?.N) { + const expiry = Number.parseInt(item.expire.N); + return expiry <= now && expiry > lastModified; + } + return !item.stale?.N; + }); +} + type DynamoDBResponse = { Items?: DynamoDBItem[]; }; @@ -56,11 +81,19 @@ function buildDynamoKey(key: string) { return path.posix.join(NEXT_BUILD_ID ?? "", key); } -function buildDynamoObject(path: string, tags: string, revalidatedAt?: number) { +function buildDynamoObject( + path: string, + tags: string, + revalidatedAt?: number, + stale?: number, + expire?: number +) { return { path: { S: buildDynamoKey(path) }, tag: { S: buildDynamoKey(tags) }, revalidatedAt: { N: `${revalidatedAt ?? Date.now()}` }, + ...(stale !== undefined ? { stale: { N: `${stale}` } } : {}), + ...(expire !== undefined ? { expire: { N: `${expire}` } } : {}), }; } @@ -72,6 +105,11 @@ const tagCache: OriginalTagCache = { return []; } const { CACHE_DYNAMO_TABLE, NEXT_BUILD_ID } = process.env; + const store = globalThis.__openNextAls.getStore(); + const cache = store?.requestCache.getOrCreate("dynamoDb:getByPath"); + if (cache?.has(path)) { + return cache.get(path)!; + } const result = await awsFetch( JSON.stringify({ TableName: CACHE_DYNAMO_TABLE, @@ -93,7 +131,9 @@ const tagCache: OriginalTagCache = { const tags = Items?.map((item) => item.tag?.S ?? "") ?? []; debug("tags for path", path, tags); // We need to remove the buildId from the path - return tags.map((tag: string) => tag.replace(`${NEXT_BUILD_ID}/`, "")); + const resultTags = tags.map((tag: string) => tag.replace(`${NEXT_BUILD_ID}/`, "")); + cache?.set(path, resultTags); + return resultTags; } catch (e) { error("Failed to get tags by path", e); return []; @@ -105,6 +145,11 @@ const tagCache: OriginalTagCache = { return []; } const { CACHE_DYNAMO_TABLE, NEXT_BUILD_ID } = process.env; + const store = globalThis.__openNextAls.getStore(); + const cache = store?.requestCache.getOrCreate("dynamoDb:getByTag"); + if (cache?.has(tag)) { + return cache.get(tag)!; + } const result = await awsFetch( JSON.stringify({ TableName: CACHE_DYNAMO_TABLE, @@ -121,21 +166,35 @@ const tagCache: OriginalTagCache = { throw new RecoverableError(`Failed to get by tag: ${result.status}`); } const { Items } = (await result.json()) as DynamoDBResponse; - return ( - // We need to remove the buildId from the path - Items?.map((item) => item.path?.S?.replace(`${NEXT_BUILD_ID}/`, "") ?? "") ?? [] - ); + const paths = Items?.map((item) => item.path?.S?.replace(`${NEXT_BUILD_ID}/`, "") ?? "") ?? []; + cache?.set(tag, paths); + return paths; } catch (e) { error("Failed to get by tag", e); return []; } }, + /** + * Checks hard invalidation and shares queried records with subsequent stale checks. + * + * @param key Entry key. + * @param lastModified Entry generation time. + * @return -1 for hard invalidation, otherwise the generation time; preserves it on failure. + */ async getLastModified(key: string, lastModified?: number) { try { if (globalThis.openNextConfig.dangerous?.disableTagCache) { return lastModified ?? Date.now(); } const { CACHE_DYNAMO_TABLE } = process.env; + const store = globalThis.__openNextAls.getStore(); + const cache = store?.requestCache.getOrCreate("dynamoDb:revalidateQueryItems"); + const cacheKey = `${key}:${lastModified ?? 0}`; + if (cache?.has(cacheKey)) { + return hasHardRevalidation(cache.get(cacheKey)!, lastModified ?? 0, Date.now()) + ? -1 + : (lastModified ?? Date.now()); + } const result = await awsFetch( JSON.stringify({ TableName: CACHE_DYNAMO_TABLE, @@ -156,14 +215,70 @@ const tagCache: OriginalTagCache = { } const revalidatedTags = ((await result.json()) as DynamoDBResponse).Items ?? []; debug("revalidatedTags", revalidatedTags); - // If we have revalidated tags we return -1 to force revalidation - return revalidatedTags.length > 0 ? -1 : (lastModified ?? Date.now()); + + const resultValue = hasHardRevalidation(revalidatedTags, lastModified ?? 0, Date.now()) + ? -1 + : (lastModified ?? Date.now()); + cache?.set(cacheKey, revalidatedTags); + return resultValue; } catch (e) { error("Failed to get revalidated tags", e); return lastModified ?? Date.now(); } }, - async writeTags(tags: { tag: string; path: string; revalidatedAt?: number }[]) { + /** + * Checks applicable stale timestamps rather than treating every queried tag as stale. + * + * @param key Entry key. + * @param lastModified Entry generation time. + * @return Whether an active stale window applies; false on read failure. + */ + async isStale(key: string, lastModified?: number) { + try { + if (globalThis.openNextConfig.dangerous?.disableTagCache) { + return false; + } + const { CACHE_DYNAMO_TABLE } = process.env; + const store = globalThis.__openNextAls.getStore(); + const itemsCache = store?.requestCache.getOrCreate( + "dynamoDb:revalidateQueryItems" + ); + const cacheKey = `${key}:${lastModified ?? 0}`; + let items: DynamoDBItem[]; + if (itemsCache?.has(cacheKey)) { + items = itemsCache.get(cacheKey)!; + } else { + // Share the query with getLastModified, then evaluate stale timestamps separately. + const result = await awsFetch( + JSON.stringify({ + TableName: CACHE_DYNAMO_TABLE, + IndexName: "revalidate", + KeyConditionExpression: "#key = :key AND #revalidatedAt > :lastModified", + ExpressionAttributeNames: { + "#key": "path", + "#revalidatedAt": "revalidatedAt", + }, + ExpressionAttributeValues: { + ":key": { S: buildDynamoKey(key) }, + ":lastModified": { N: String(lastModified ?? 0) }, + }, + }) + ); + if (result.status !== 200) { + throw new RecoverableError(`Failed to check stale tags: ${result.status}`); + } + items = ((await result.json()) as DynamoDBResponse).Items ?? []; + itemsCache?.set(cacheKey, items); + } + debug("isStale items", key, items); + const now = Date.now(); + return items.some((item) => isStaleRecord(item, lastModified ?? 0, now)); + } catch (e) { + error("Failed to check stale tags", e); + return false; + } + }, + async writeTags(tags) { try { const { CACHE_DYNAMO_TABLE } = process.env; if (globalThis.openNextConfig.dangerous?.disableTagCache) { @@ -174,7 +289,7 @@ const tagCache: OriginalTagCache = { [CACHE_DYNAMO_TABLE ?? ""]: Items.map((Item) => ({ PutRequest: { Item: { - ...buildDynamoObject(Item.path, Item.tag, Item.revalidatedAt), + ...buildDynamoObject(Item.path, Item.tag, Item.revalidatedAt, Item.stale, Item.expire), }, }, })), diff --git a/packages/aws/src/overrides/tagCache/dynamodb-nextMode.ts b/packages/aws/src/overrides/tagCache/dynamodb-nextMode.ts index 88deb1a8..5b87639d 100644 --- a/packages/aws/src/overrides/tagCache/dynamodb-nextMode.ts +++ b/packages/aws/src/overrides/tagCache/dynamodb-nextMode.ts @@ -2,7 +2,7 @@ import path from "node:path"; import { debug, error } from "@opennextjs/core/adapters/logger.js"; import { chunk, parseNumberFromEnv } from "@opennextjs/core/adapters/util.js"; -import type { NextModeTagCache } from "@opennextjs/core/types/overrides.js"; +import type { NextModeTagCache, NextModeTagCacheWriteInput } from "@opennextjs/core/types/overrides.js"; import { RecoverableError } from "@opennextjs/core/utils/error.js"; import { AwsClient } from "aws4fetch"; @@ -10,16 +10,38 @@ import { customFetchClient } from "../../utils/fetch.js"; import { MAX_DYNAMO_BATCH_WRITE_ITEM_COUNT, getDynamoBatchWriteCommandConcurrency } from "./constants.js"; -type DynamoDBTagItem = { - revalidatedAt: { N: string }; - tag: { S: string }; -}; +let awsClient: AwsClient | null = null; -type DynamoDBBatchGetResponse = { - Responses?: Record; +export type DynamoDBItem = { + tag?: { S: string }; + path?: { S: string }; + revalidatedAt?: { N: string }; + stale?: { N: string }; + expire?: { N: string }; }; -let awsClient: AwsClient | null = null; +/** + * Determines whether a next-mode tag requires blocking regeneration. + * + * @param item Stored tag record. + * @param lastModified Cached entry timestamp. + * @param now Current timestamp. + * @return Whether the tag was hard-revalidated or its SWR window expired. + */ +export function hasHardRevalidation(item: DynamoDBItem, lastModified: number, now: number): boolean { + const revalidatedAt = Number.parseInt(item.revalidatedAt?.N ?? "0"); + if (revalidatedAt <= lastModified) { + return false; + } + if (item.expire?.N) { + const expiry = Number.parseInt(item.expire.N); + return expiry <= now && expiry > lastModified; + } + if (item.stale?.N) { + return false; + } + return true; +} const getAwsClient = () => { const { CACHE_BUCKET_REGION } = process.env; @@ -58,15 +80,121 @@ function buildDynamoKey(key: string) { // We use the same key for both path and tag // That's mostly for compatibility reason so that it's easier to use this with existing infra // FIXME: Allow a simpler object without an unnecessary path key -function buildDynamoObject(tag: string, revalidatedAt?: number) { +function buildDynamoObject(tag: string, revalidatedAt?: number, stale?: number, expire?: number) { return { path: { S: buildDynamoKey(tag) }, tag: { S: buildDynamoKey(tag) }, revalidatedAt: { N: `${revalidatedAt ?? Date.now()}` }, + ...(stale !== undefined ? { stale: { N: `${stale}` } } : {}), + ...(expire !== undefined ? { expire: { N: `${expire}` } } : {}), }; } // This implementation does not support automatic invalidation of paths by the cdn + +/** + * Checks the items cache for each tag. Returns tags not yet cached and whether + * a positive result was already found among the cached ones. + */ +function checkItemsCache( + tags: string[], + itemsCache: Map | undefined, + compute: (item: DynamoDBItem) => boolean +): { uncachedTags: string[]; hasMatch: boolean } { + const uncachedTags: string[] = []; + let hasMatch = false; + for (const tag of tags) { + if (itemsCache?.has(tag)) { + const item = itemsCache.get(tag); + if (item && compute(item)) hasMatch = true; + } else { + uncachedTags.push(tag); + } + } + return { uncachedTags, hasMatch }; +} + +/** + * Stores fetched DynamoDB tag records, including absent tags, in the request cache. + * + * @param tags Requested tag names. + * @param responseItems Records returned by DynamoDB. + * @param itemsCache Per-request tag cache. + * @param compute Predicate used to identify a matching record. + * @return Whether any returned record matched the predicate. + */ +export function cacheDynamoItems( + tags: string[], + responseItems: DynamoDBItem[], + itemsCache: Map | undefined, + compute: (item: DynamoDBItem) => boolean +): boolean { + const responseByKey = new Map(); + for (const item of responseItems) { + responseByKey.set(item.tag?.S ?? "", item); + } + + let hasMatch = false; + for (const tag of tags) { + const item = responseByKey.get(buildDynamoKey(tag)) ?? null; + itemsCache?.set(tag, item); + if (item && compute(item)) hasMatch = true; + } + return hasMatch; +} + +/** + * Fetches tag records, retrying partial batches before treating absent items as misses. + * + * @param uncachedTags Tags to query. + * @param itemsCache Request-local record cache. + * @param compute Predicate evaluated for processed records. + * @return Whether any processed record matches. + * @throws When DynamoDB fails or leaves keys unprocessed after three retries. + */ +async function fetchAndCacheItems( + uncachedTags: string[], + itemsCache: Map | undefined, + compute: (item: DynamoDBItem) => boolean +): Promise { + const { CACHE_DYNAMO_TABLE } = process.env; + const table = CACHE_DYNAMO_TABLE ?? ""; + let pending = [...new Set(uncachedTags)]; + let hasMatch = false; + for (let attempt = 0; attempt <= 3; attempt++) { + const response = await awsFetch( + JSON.stringify({ + RequestItems: { + [table]: { + Keys: pending.map((tag) => ({ + path: { S: buildDynamoKey(tag) }, + tag: { S: buildDynamoKey(tag) }, + })), + }, + }, + }), + "query" + ); + if (response.status !== 200) { + throw new RecoverableError(`Failed to query dynamo item: ${response.status}`); + } + const { Responses, UnprocessedKeys } = (await response.json()) as { + Responses?: Record; + UnprocessedKeys?: Record; + }; + const unprocessed = new Set((UnprocessedKeys?.[table]?.Keys ?? []).map((item) => item.tag?.S)); + const processed = pending.filter((tag) => !unprocessed.has(buildDynamoKey(tag))); + const matched = cacheDynamoItems(processed, Responses?.[table] ?? [], itemsCache, compute); + hasMatch ||= matched; + pending = pending.filter((tag) => unprocessed.has(buildDynamoKey(tag))); + if (pending.length === 0) return hasMatch; + if (attempt < 3) { + await new Promise((resolve) => setTimeout(resolve, 50 * 2 ** attempt)); + } + } + throw new RecoverableError("DynamoDB tag read still has unprocessed keys after three retries"); +} + export default { name: "ddb-nextMode", mode: "nextMode", @@ -83,38 +211,58 @@ export default { "Cannot query more than 100 tags at once. You should not be using this tagCache implementation for this amount of tags" ); } - const { CACHE_DYNAMO_TABLE } = process.env; + + const store = globalThis.__openNextAls.getStore(); + const itemsCache = store?.requestCache.getOrCreate("ddb-nextMode:tagItems"); + + const now = Date.now(); + const compute = (item: DynamoDBItem): boolean => { + if (!item) return false; + return hasHardRevalidation(item, lastModified ?? 0, now); + }; + + const { uncachedTags, hasMatch } = checkItemsCache(tags, itemsCache, compute); + if (hasMatch) return true; + if (uncachedTags.length === 0) return false; + // It's unlikely that we will have more than 100 items to query // If that's the case, you should not use this tagCache implementation - const response = await awsFetch( - JSON.stringify({ - RequestItems: { - [CACHE_DYNAMO_TABLE ?? ""]: { - Keys: tags.map((tag) => ({ - path: { S: buildDynamoKey(tag) }, - tag: { S: buildDynamoKey(tag) }, - })), - }, - }, - }), - "query" - ); - if (response.status !== 200) { - throw new RecoverableError(`Failed to query dynamo item: ${response.status}`); - } - // Now we need to check for every item if lastModified is greater than the revalidatedAt - const { Responses } = (await response.json()) as DynamoDBBatchGetResponse; - if (!Responses) { + const result = await fetchAndCacheItems(uncachedTags, itemsCache, compute); + debug("retrieved tags for hasBeenRevalidated", tags); + return result; + }, + isStale: async (tags: string[], lastModified?: number) => { + if (globalThis.openNextConfig.dangerous?.disableTagCache) { return false; } - const revalidatedTags = - Responses?.[CACHE_DYNAMO_TABLE ?? ""]?.filter( - (item) => Number.parseInt(item.revalidatedAt.N) > (lastModified ?? 0) - ) ?? []; - debug("retrieved tags", revalidatedTags); - return revalidatedTags.length > 0; + if (tags.length === 0) return false; + if (tags.length > 100) { + throw new RecoverableError( + "Cannot query more than 100 tags at once. You should not be using this tagCache implementation for this amount of tags" + ); + } + + const store = globalThis.__openNextAls.getStore(); + const itemsCache = store?.requestCache.getOrCreate("ddb-nextMode:tagItems"); + + const compute = (item: DynamoDBItem): boolean => { + if (!item?.stale?.N) return false; + const revalidatedAt = Number.parseInt(item.revalidatedAt?.N ?? "0"); + // A tag is stale when both its stale timestamp and its revalidatedAt are newer than the page. + // revalidatedAt > lastModified ensures the revalidation that set this stale window happened + // after the page was generated, preventing a stale signal from a previous ISR cycle. + return revalidatedAt > (lastModified ?? 0) && Number.parseInt(item.stale.N) >= (lastModified ?? 0); + }; + + const { uncachedTags, hasMatch } = checkItemsCache(tags, itemsCache, compute); + if (hasMatch) return true; + if (uncachedTags.length === 0) return false; + + const result = await fetchAndCacheItems(uncachedTags, itemsCache, compute); + debug("isStale result:", result); + return result; }, - writeTags: async (tags: string[]) => { + writeTags: async (tags) => { try { const { CACHE_DYNAMO_TABLE } = process.env; if (globalThis.openNextConfig.dangerous?.disableTagCache) { @@ -122,13 +270,18 @@ export default { } const dataChunks = chunk(tags, MAX_DYNAMO_BATCH_WRITE_ITEM_COUNT).map((Items) => ({ RequestItems: { - [CACHE_DYNAMO_TABLE ?? ""]: Items.map((tag) => ({ - PutRequest: { - Item: { - ...buildDynamoObject(tag), + [CACHE_DYNAMO_TABLE ?? ""]: Items.map((tag) => { + const tagStr = typeof tag === "string" ? tag : tag.tag; + const stale = typeof tag === "string" ? undefined : tag.stale; + const expiry = typeof tag === "string" ? undefined : tag.expire; + return { + PutRequest: { + Item: { + ...buildDynamoObject(tagStr, undefined, stale, expiry), + }, }, - }, - })), + }; + }), }, })); const toInsert = chunk(dataChunks, getDynamoBatchWriteCommandConcurrency()); diff --git a/packages/aws/src/overrides/tagCache/dynamodb.ts b/packages/aws/src/overrides/tagCache/dynamodb.ts index 09eed484..c599eee5 100644 --- a/packages/aws/src/overrides/tagCache/dynamodb.ts +++ b/packages/aws/src/overrides/tagCache/dynamodb.ts @@ -7,9 +7,43 @@ import { chunk, parseNumberFromEnv } from "@opennextjs/core/adapters/util.js"; import type { TagCache } from "@opennextjs/core/types/overrides.js"; import { MAX_DYNAMO_BATCH_WRITE_ITEM_COUNT, getDynamoBatchWriteCommandConcurrency } from "./constants"; +import { isStaleRecord } from "./stale-record.js"; const { CACHE_BUCKET_REGION, CACHE_DYNAMO_TABLE, NEXT_BUILD_ID } = process.env; +type DynamoDBItem = { + tag?: { S: string }; + path?: { S: string }; + revalidatedAt?: { N: string }; + stale?: { N: string }; + expire?: { N: string }; +}; + +/** + * Determines whether tag records require blocking cache regeneration. + * + * @param items Tag records newer than the cached entry. + * @param lastModified Cached entry timestamp. + * @param now Current timestamp. + * @return Whether a tag was hard-revalidated or its SWR window expired. + */ +export function hasHardRevalidation(items: DynamoDBItem[], lastModified: number, now: number): boolean { + return items.some((item) => { + const revalidatedAt = Number.parseInt(item.revalidatedAt?.N ?? "0"); + if (revalidatedAt <= lastModified) { + return false; + } + if (item.stale?.N && !item.expire?.N) { + return false; + } + if (!item.expire?.N) { + return true; + } + const expiry = Number.parseInt(item.expire.N); + return expiry <= now && expiry > lastModified; + }); +} + function parseDynamoClientConfigFromEnv(): DynamoDBClientConfig { return { region: CACHE_BUCKET_REGION, @@ -26,11 +60,19 @@ function buildDynamoKey(key: string) { return path.posix.join(NEXT_BUILD_ID ?? "", key); } -function buildDynamoObject(path: string, tags: string, revalidatedAt?: number) { +function buildDynamoObject( + path: string, + tags: string, + revalidatedAt?: number, + stale?: number, + expire?: number +) { return { path: { S: buildDynamoKey(path) }, tag: { S: buildDynamoKey(tags) }, revalidatedAt: { N: `${revalidatedAt ?? Date.now()}` }, + ...(stale !== undefined ? { stale: { N: `${stale}` } } : {}), + ...(expire !== undefined ? { expire: { N: `${expire}` } } : {}), }; } @@ -41,6 +83,11 @@ const tagCache: TagCache = { if (globalThis.openNextConfig.dangerous?.disableTagCache) { return []; } + const store = globalThis.__openNextAls.getStore(); + const cache = store?.requestCache.getOrCreate("dynamoDb:getByPath"); + if (cache?.has(path)) { + return cache.get(path)!; + } const result = await dynamoClient.send( new QueryCommand({ TableName: CACHE_DYNAMO_TABLE, @@ -57,7 +104,9 @@ const tagCache: TagCache = { const tags = result.Items?.map((item) => item.tag.S ?? "") ?? []; debug("tags for path", path, tags); // We need to remove the buildId from the path - return tags.map((tag) => tag.replace(`${NEXT_BUILD_ID}/`, "")); + const resultTags = tags.map((tag) => tag.replace(`${NEXT_BUILD_ID}/`, "")); + cache?.set(path, resultTags); + return resultTags; } catch (e) { error("Failed to get tags by path", e); return []; @@ -68,6 +117,11 @@ const tagCache: TagCache = { if (globalThis.openNextConfig.dangerous?.disableTagCache) { return []; } + const store = globalThis.__openNextAls.getStore(); + const cache = store?.requestCache.getOrCreate("dynamoDb:getByTag"); + if (cache?.has(tag)) { + return cache.get(tag)!; + } const { Items } = await dynamoClient.send( new QueryCommand({ TableName: CACHE_DYNAMO_TABLE, @@ -80,10 +134,10 @@ const tagCache: TagCache = { }, }) ); - return ( - // We need to remove the buildId from the path - Items?.map(({ path: { S: key } }) => key?.replace(`${NEXT_BUILD_ID}/`, "") ?? "") ?? [] - ); + // We need to remove the buildId from the path + const paths = Items?.map(({ path: { S: key } }) => key?.replace(`${NEXT_BUILD_ID}/`, "") ?? "") ?? []; + cache?.set(tag, paths); + return paths; } catch (e) { error("Failed to get by tag", e); return []; @@ -94,30 +148,90 @@ const tagCache: TagCache = { if (globalThis.openNextConfig.dangerous?.disableTagCache) { return lastModified ?? Date.now(); } - const result = await dynamoClient.send( - new QueryCommand({ - TableName: CACHE_DYNAMO_TABLE, - IndexName: "revalidate", - KeyConditionExpression: "#key = :key AND #revalidatedAt > :lastModified", - ExpressionAttributeNames: { - "#key": "path", - "#revalidatedAt": "revalidatedAt", - }, - ExpressionAttributeValues: { - ":key": { S: buildDynamoKey(key) }, - ":lastModified": { N: String(lastModified ?? 0) }, - }, - }) + const store = globalThis.__openNextAls.getStore(); + const itemsCache = store?.requestCache.getOrCreate( + "dynamoDb:revalidateQueryItems" ); - const revalidatedTags = result.Items ?? []; + const cacheKey = `${key}:${lastModified ?? 0}`; + let revalidatedTags: DynamoDBItem[]; + if (itemsCache?.has(cacheKey)) { + revalidatedTags = itemsCache.get(cacheKey)!; + } else { + const result = await dynamoClient.send( + new QueryCommand({ + TableName: CACHE_DYNAMO_TABLE, + IndexName: "revalidate", + KeyConditionExpression: "#key = :key AND #revalidatedAt > :lastModified", + ExpressionAttributeNames: { + "#key": "path", + "#revalidatedAt": "revalidatedAt", + }, + ExpressionAttributeValues: { + ":key": { S: buildDynamoKey(key) }, + ":lastModified": { N: String(lastModified ?? 0) }, + }, + }) + ); + revalidatedTags = result.Items ?? []; + itemsCache?.set(cacheKey, revalidatedTags); + } debug("revalidatedTags", revalidatedTags); - // If we have revalidated tags we return -1 to force revalidation - return revalidatedTags.length > 0 ? -1 : (lastModified ?? Date.now()); + + return hasHardRevalidation(revalidatedTags, lastModified ?? 0, Date.now()) + ? -1 + : (lastModified ?? Date.now()); } catch (e) { error("Failed to get revalidated tags", e); return lastModified ?? Date.now(); } }, + /** + * Checks stale metadata in the same records used for hard-invalidation checks. + * + * @param key Entry key. + * @param lastModified Entry generation time. + * @return Whether an active stale window applies; false on read failure. + */ + async isStale(key: string, lastModified?: number) { + try { + if (globalThis.openNextConfig.dangerous?.disableTagCache) { + return false; + } + const store = globalThis.__openNextAls.getStore(); + const itemsCache = store?.requestCache.getOrCreate( + "dynamoDb:revalidateQueryItems" + ); + const cacheKey = `${key}:${lastModified ?? 0}`; + let items: DynamoDBItem[]; + if (itemsCache?.has(cacheKey)) { + items = itemsCache.get(cacheKey)!; + } else { + const result = await dynamoClient.send( + new QueryCommand({ + TableName: CACHE_DYNAMO_TABLE, + IndexName: "revalidate", + KeyConditionExpression: "#key = :key AND #revalidatedAt > :lastModified", + ExpressionAttributeNames: { + "#key": "path", + "#revalidatedAt": "revalidatedAt", + }, + ExpressionAttributeValues: { + ":key": { S: buildDynamoKey(key) }, + ":lastModified": { N: String(lastModified ?? 0) }, + }, + }) + ); + items = result.Items ?? []; + itemsCache?.set(cacheKey, items); + } + debug("isStale items", key, items); + const now = Date.now(); + return items.some((item) => isStaleRecord(item, lastModified ?? 0, now)); + } catch (e) { + error("Failed to check stale tags", e); + return false; + } + }, async writeTags(tags) { try { if (globalThis.openNextConfig.dangerous?.disableTagCache) { @@ -128,7 +242,7 @@ const tagCache: TagCache = { [CACHE_DYNAMO_TABLE ?? ""]: Items.map((Item) => ({ PutRequest: { Item: { - ...buildDynamoObject(Item.path, Item.tag, Item.revalidatedAt), + ...buildDynamoObject(Item.path, Item.tag, Item.revalidatedAt, Item.stale, Item.expire), }, }, })), diff --git a/packages/aws/src/overrides/tagCache/stale-record.ts b/packages/aws/src/overrides/tagCache/stale-record.ts new file mode 100644 index 00000000..61c36f1d --- /dev/null +++ b/packages/aws/src/overrides/tagCache/stale-record.ts @@ -0,0 +1,21 @@ +type TagRecord = { + revalidatedAt?: { N: string }; + stale?: { N: string }; + expire?: { N: string }; +}; + +/** + * Checks whether a tag record defines an active stale window for a cached entry. + * + * @param item Persisted tag timestamps. + * @param lastModified Entry generation timestamp. + * @param now Current time. + * @return True only for a newer stale event whose expiry has not elapsed. + */ +export function isStaleRecord(item: TagRecord, lastModified: number, now: number): boolean { + return ( + Number(item.revalidatedAt?.N) > lastModified && + Number(item.stale?.N) > lastModified && + (item.expire === undefined || Number(item.expire.N) > now) + ); +} diff --git a/packages/cloudflare/README.md b/packages/cloudflare/README.md index cc1f5e72..27ad3ebb 100644 --- a/packages/cloudflare/README.md +++ b/packages/cloudflare/README.md @@ -55,3 +55,7 @@ Deploy your application to production with the following: # or bun opennextjs-cloudflare build && bun opennextjs-cloudflare deploy ``` + +## Cache compatibility + +The D1, KV, and sharded Durable Object tag-cache implementations support immediate tag invalidation. They do not support duration-aware stale-while-revalidate tag invalidation; calls such as `revalidateTag(tag, { expire })` fail explicitly instead of silently becoming immediate invalidations. diff --git a/packages/cloudflare/src/api/durable-objects/queue.spec.ts b/packages/cloudflare/src/api/durable-objects/queue.spec.ts index e5219d08..c154c03b 100644 --- a/packages/cloudflare/src/api/durable-objects/queue.spec.ts +++ b/packages/cloudflare/src/api/durable-objects/queue.spec.ts @@ -66,6 +66,17 @@ const createMessage = (dedupId: string, lastModified = Date.now()) => ({ }); describe("DurableObjectQueue", () => { + it("does not suppress a later stale entry after an earlier successful regeneration", () => { + const queue = createDurableObjectQueue({ fetchDuration: 0 }); + vi.mocked(queue.sql.exec).mockImplementation( + (_query, _id, timestamp) => + ({ + toArray: () => (90 > Number(timestamp) ? [{ found: 1 }] : []), + }) as never + ); + expect(queue.checkSyncTable(createMessage("later", 100_000))).toBe(false); + expect(queue.checkSyncTable(createMessage("old", 80_000))).toBe(true); + }); describe("successful revalidation", () => { it("should process a single revalidation", async () => { process.env.__NEXT_PREVIEW_MODE_ID = "test"; diff --git a/packages/cloudflare/src/api/overrides/cache/service-cache.spec.ts b/packages/cloudflare/src/api/overrides/cache/service-cache.spec.ts index 0964e2f8..bf677c5e 100644 --- a/packages/cloudflare/src/api/overrides/cache/service-cache.spec.ts +++ b/packages/cloudflare/src/api/overrides/cache/service-cache.spec.ts @@ -120,6 +120,13 @@ describe("serviceCache", () => { expect(JSON.parse(body as string)).toEqual({ tags: ["tag1", "tag2"] }); }); + it("forwards revalidation durations", async () => { + await serviceCache.revalidateTags(["tag1"], { expire: 30 }); + + const { body } = lastRequest(); + expect(JSON.parse(body as string)).toEqual({ tags: ["tag1"], durations: { expire: 30 } }); + }); + it("rejects an unsuccessful tag revalidation", async () => { fetchMock.mockResolvedValue(new Response(null, { status: 502 })); @@ -127,4 +134,13 @@ describe("serviceCache", () => { "Failed to revalidate cache tags: cache handler returned 502" ); }); + + it("surfaces an exposed cache-handler error", async () => { + fetchMock.mockResolvedValue( + new Response(null, { status: 501, headers: { "x-opennext-cache-error": "SWR is unsupported" } }) + ); + const operation = serviceCache.revalidateTags(["tag"], { expire: 30 }); + await expect(operation).rejects.toThrow("Failed to revalidate cache tags: SWR is unsupported"); + await expect(operation).rejects.toMatchObject({ __openNextUnsupportedOperation: true }); + }); }); diff --git a/packages/cloudflare/src/api/overrides/cache/service-cache.ts b/packages/cloudflare/src/api/overrides/cache/service-cache.ts index 8b1a840d..fa9f8ce0 100644 --- a/packages/cloudflare/src/api/overrides/cache/service-cache.ts +++ b/packages/cloudflare/src/api/overrides/cache/service-cache.ts @@ -12,6 +12,10 @@ export const BINDING_NAME = "NEXT_CACHE_SERVICE"; */ const CACHE_ORIGIN = "https://cache.opennext"; +class UnsupportedCacheServiceOperationError extends Error { + readonly __openNextUnsupportedOperation = true; +} + /** * Returns the cache handler bound to `NEXT_CACHE_SERVICE`. * @@ -56,9 +60,14 @@ function getCacheUrl(key: string, cacheType?: CacheEntryType, additionalTags?: s * @return Nothing. * @throws When the cache handler returns a non-success status. */ -function ensureResponseOk(response: Pick, operation: string): void { +function ensureResponseOk(response: Pick, operation: string): void { if (!response.ok) { - throw new Error(`Failed to ${operation}: cache handler returned ${response.status}`); + const exposedReason = response.headers.get("x-opennext-cache-error"); + if (exposedReason) { + throw new UnsupportedCacheServiceOperationError(`Failed to ${operation}: ${exposedReason}`); + } + const reason = `cache handler returned ${response.status}`; + throw new Error(`Failed to ${operation}: ${reason}`); } } @@ -98,11 +107,11 @@ const serviceCache = { ensureResponseOk(response, "delete cache entry"); }, - revalidateTags: async (tags) => { + revalidateTags: async (tags, durations) => { const response = await getCacheService().fetch(new URL("/cache/revalidate-tags", CACHE_ORIGIN).href, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ tags }), + body: JSON.stringify({ tags, durations }), }); ensureResponseOk(response, "revalidate cache tags"); }, diff --git a/packages/cloudflare/src/api/overrides/internal.ts b/packages/cloudflare/src/api/overrides/internal.ts index 0dfd492d..093ed629 100644 --- a/packages/cloudflare/src/api/overrides/internal.ts +++ b/packages/cloudflare/src/api/overrides/internal.ts @@ -1,7 +1,11 @@ import { createHash } from "node:crypto"; import { error } from "@opennextjs/core/adapters/logger.js"; -import type { CacheEntryType, CacheValue } from "@opennextjs/core/types/overrides.js"; +import type { + CacheEntryType, + CacheValue, + NextModeTagCacheWriteInput, +} from "@opennextjs/core/types/overrides.js"; import { getCloudflareContext } from "../cloudflare-context.js"; @@ -20,6 +24,10 @@ export const FALLBACK_BUILD_ID = "no-build-id"; export const DEFAULT_PREFIX = "incremental-cache"; +class UnsupportedTagCacheOperationError extends Error { + readonly __openNextUnsupportedOperation = true; +} + export type KeyOptions = { cacheType?: CacheEntryType; prefix: string | undefined; @@ -32,6 +40,25 @@ export function computeCacheKey(key: string, options: KeyOptions) { return `${prefix}/${buildId}/${hash}.${cacheType}`.replace(/\/+/g, "/"); } +/** + * Extracts tag names accepted by Cloudflare tag caches. + * + * Structured hard invalidations are accepted, but duration-aware SWR requires storage metadata + * that the D1, KV, and sharded Durable Object implementations do not persist. + * + * @param tags Plain or structured tag writes. + * @return Tag names for a hard invalidation. + * @throws When a write requests stale-while-revalidate behavior. + */ +export function toTagNames(tags: (string | NextModeTagCacheWriteInput)[]): string[] { + if (tags.some((tag) => typeof tag !== "string" && tag.stale !== undefined)) { + throw new UnsupportedTagCacheOperationError( + "Cloudflare D1, KV, and sharded Durable Object tag caches do not support stale-while-revalidate tag invalidation" + ); + } + return tags.map((tag) => (typeof tag === "string" ? tag : tag.tag)); +} + export function isPurgeCacheEnabled(): boolean { // The `?` is required at `openNextConfig?` or the Open Next build fails because of a type error // The cache handler function only has `cacheHandler` populated, the other functions only have `default`. diff --git a/packages/cloudflare/src/api/overrides/tag-cache/d1-next-tag-cache.spec.ts b/packages/cloudflare/src/api/overrides/tag-cache/d1-next-tag-cache.spec.ts index 87aae85f..37f78507 100644 --- a/packages/cloudflare/src/api/overrides/tag-cache/d1-next-tag-cache.spec.ts +++ b/packages/cloudflare/src/api/overrides/tag-cache/d1-next-tag-cache.spec.ts @@ -18,7 +18,8 @@ vi.mock("../../cloudflare-context.js", () => ({ getCloudflareContext: vi.fn(), })); -vi.mock("../internal.js", () => ({ +vi.mock("../internal.js", async (importOriginal) => ({ + ...(await importOriginal()), debugCache: vi.fn(), FALLBACK_BUILD_ID: "fallback-build-id", purgeCacheByTags: vi.fn(), @@ -226,6 +227,19 @@ describe("D1NextModeTagCache", () => { }); describe("writeTags", () => { + it("rejects SWR metadata before writing or purging", async () => { + await expect(tagCache.writeTags([{ tag: "tag1", stale: 100, expire: 200 }])).rejects.toThrow( + "do not support stale-while-revalidate" + ); + expect(mockBatch).not.toHaveBeenCalled(); + expect(purgeCacheByTags).not.toHaveBeenCalled(); + }); + + it("accepts structured hard invalidations", async () => { + await tagCache.writeTags([{ tag: "tag1", expire: 100 }]); + expect(mockBatch).toHaveBeenCalledOnce(); + }); + it("should do nothing when cache is disabled", async () => { ( globalThis as { openNextConfig?: { dangerous?: { disableTagCache?: boolean } } } diff --git a/packages/cloudflare/src/api/overrides/tag-cache/d1-next-tag-cache.ts b/packages/cloudflare/src/api/overrides/tag-cache/d1-next-tag-cache.ts index 018ef92e..ad3987f2 100644 --- a/packages/cloudflare/src/api/overrides/tag-cache/d1-next-tag-cache.ts +++ b/packages/cloudflare/src/api/overrides/tag-cache/d1-next-tag-cache.ts @@ -1,8 +1,14 @@ import { error } from "@opennextjs/core/adapters/logger.js"; -import type { NextModeTagCache } from "@opennextjs/core/types/overrides.js"; +import type { NextModeTagCache, NextModeTagCacheWriteInput } from "@opennextjs/core/types/overrides.js"; import { getCloudflareContext } from "../../cloudflare-context.js"; -import { debugCache, FALLBACK_BUILD_ID, isPurgeCacheEnabled, purgeCacheByTags } from "../internal.js"; +import { + debugCache, + FALLBACK_BUILD_ID, + isPurgeCacheEnabled, + purgeCacheByTags, + toTagNames, +} from "../internal.js"; export const NAME = "d1-next-mode-tag-cache"; @@ -63,7 +69,8 @@ export class D1NextModeTagCache implements NextModeTagCache { } } - async writeTags(tags: string[]): Promise { + async writeTags(tagsToWrite: (string | NextModeTagCacheWriteInput)[]): Promise { + const tags = toTagNames(tagsToWrite); const { isDisabled, db } = this.getConfig(); if (isDisabled || tags.length === 0) return Promise.resolve(); diff --git a/packages/cloudflare/src/api/overrides/tag-cache/do-sharded-tag-cache.spec.ts b/packages/cloudflare/src/api/overrides/tag-cache/do-sharded-tag-cache.spec.ts index 15ea2a49..d7c397f9 100644 --- a/packages/cloudflare/src/api/overrides/tag-cache/do-sharded-tag-cache.spec.ts +++ b/packages/cloudflare/src/api/overrides/tag-cache/do-sharded-tag-cache.spec.ts @@ -300,6 +300,21 @@ describe("DOShardedTagCache", () => { expect(writeTagsMock).not.toHaveBeenCalled(); }); + it("rejects SWR metadata before writing", async () => { + const cache = shardedDOTagCache(); + await expect(cache.writeTags([{ tag: "tag1", stale: 100, expire: 200 }])).rejects.toThrow( + "do not support stale-while-revalidate" + ); + expect(idFromNameMock).not.toHaveBeenCalled(); + expect(writeTagsMock).not.toHaveBeenCalled(); + }); + + it("accepts structured hard invalidations", async () => { + const cache = shardedDOTagCache(); + await cache.writeTags([{ tag: "tag1", expire: 100 }]); + expect(writeTagsMock).toHaveBeenCalled(); + }); + it("should write the tags to the cache", async () => { const cache = shardedDOTagCache(); await cache.writeTags(["tag1"]); diff --git a/packages/cloudflare/src/api/overrides/tag-cache/do-sharded-tag-cache.ts b/packages/cloudflare/src/api/overrides/tag-cache/do-sharded-tag-cache.ts index cf9462af..1c625e76 100644 --- a/packages/cloudflare/src/api/overrides/tag-cache/do-sharded-tag-cache.ts +++ b/packages/cloudflare/src/api/overrides/tag-cache/do-sharded-tag-cache.ts @@ -1,12 +1,12 @@ import { debug, error } from "@opennextjs/core/adapters/logger.js"; import { generateShardId } from "@opennextjs/core/core/routing/queue.js"; -import type { NextModeTagCache } from "@opennextjs/core/types/overrides.js"; +import type { NextModeTagCache, NextModeTagCacheWriteInput } from "@opennextjs/core/types/overrides.js"; import { IgnorableError } from "@opennextjs/core/utils/error.js"; import { getCloudflareContext } from "../../cloudflare-context.js"; import type { OpenNextConfig } from "../../config.js"; import { DOShardedTagCache } from "../../durable-objects/sharded-tag-cache.js"; -import { debugCache, isPurgeCacheEnabled, purgeCacheByTags } from "../internal.js"; +import { debugCache, isPurgeCacheEnabled, purgeCacheByTags, toTagNames } from "../internal.js"; export const DEFAULT_WRITE_RETRIES = 3; export const DEFAULT_NUM_SHARDS = 4; @@ -232,7 +232,8 @@ class ShardedDOTagCache implements NextModeTagCache { * @param tags * @returns */ - public async writeTags(tags: string[]): Promise { + public async writeTags(tagsToWrite: (string | NextModeTagCacheWriteInput)[]): Promise { + const tags = toTagNames(tagsToWrite); const { isDisabled } = this.getConfig(); if (isDisabled) return; diff --git a/packages/cloudflare/src/api/overrides/tag-cache/kv-next-tag-cache.spec.ts b/packages/cloudflare/src/api/overrides/tag-cache/kv-next-tag-cache.spec.ts index 7a01d1c6..6ebb91a1 100644 --- a/packages/cloudflare/src/api/overrides/tag-cache/kv-next-tag-cache.spec.ts +++ b/packages/cloudflare/src/api/overrides/tag-cache/kv-next-tag-cache.spec.ts @@ -15,7 +15,8 @@ vi.mock("../../cloudflare-context.js", () => ({ getCloudflareContext: vi.fn(), })); -vi.mock("../internal.js", () => ({ +vi.mock("../internal.js", async (importOriginal) => ({ + ...(await importOriginal()), debugCache: vi.fn(), FALLBACK_BUILD_ID: "fallback-build-id", purgeCacheByTags: vi.fn(), @@ -203,6 +204,19 @@ describe("KVNextModeTagCache", () => { }); describe("writeTags", () => { + it("rejects SWR metadata before writing or purging", async () => { + await expect(tagCache.writeTags([{ tag: "tag1", stale: 100, expire: 200 }])).rejects.toThrow( + "do not support stale-while-revalidate" + ); + expect(mockPut).not.toHaveBeenCalled(); + expect(purgeCacheByTags).not.toHaveBeenCalled(); + }); + + it("accepts structured hard invalidations", async () => { + await tagCache.writeTags([{ tag: "tag1", expire: 100 }]); + expect(mockPut).toHaveBeenCalledOnce(); + }); + it("should do nothing when cache is disabled", async () => { ( globalThis as { openNextConfig?: { dangerous?: { disableTagCache?: boolean } } } diff --git a/packages/cloudflare/src/api/overrides/tag-cache/kv-next-tag-cache.ts b/packages/cloudflare/src/api/overrides/tag-cache/kv-next-tag-cache.ts index 6c4a79ad..7bd2a116 100644 --- a/packages/cloudflare/src/api/overrides/tag-cache/kv-next-tag-cache.ts +++ b/packages/cloudflare/src/api/overrides/tag-cache/kv-next-tag-cache.ts @@ -1,8 +1,14 @@ import { error } from "@opennextjs/core/adapters/logger.js"; -import type { NextModeTagCache } from "@opennextjs/core/types/overrides.js"; +import type { NextModeTagCache, NextModeTagCacheWriteInput } from "@opennextjs/core/types/overrides.js"; import { getCloudflareContext } from "../../cloudflare-context.js"; -import { debugCache, FALLBACK_BUILD_ID, isPurgeCacheEnabled, purgeCacheByTags } from "../internal.js"; +import { + debugCache, + FALLBACK_BUILD_ID, + isPurgeCacheEnabled, + purgeCacheByTags, + toTagNames, +} from "../internal.js"; export const NAME = "kv-next-mode-tag-cache"; @@ -65,7 +71,8 @@ export class KVNextModeTagCache implements NextModeTagCache { return revalidated; } - async writeTags(tags: string[]): Promise { + async writeTags(tagsToWrite: (string | NextModeTagCacheWriteInput)[]): Promise { + const tags = toTagNames(tagsToWrite); const kv = this.getKv(); if (!kv || tags.length === 0) { return Promise.resolve(); diff --git a/packages/cloudflare/src/api/overrides/tag-cache/tag-cache-filter.spec.ts b/packages/cloudflare/src/api/overrides/tag-cache/tag-cache-filter.spec.ts index 2e6aebd8..19374497 100644 --- a/packages/cloudflare/src/api/overrides/tag-cache/tag-cache-filter.spec.ts +++ b/packages/cloudflare/src/api/overrides/tag-cache/tag-cache-filter.spec.ts @@ -1,4 +1,5 @@ import { NextModeTagCache } from "@opennextjs/core/types/overrides.js"; +import ts from "typescript"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { softTagFilter, withFilter } from "./tag-cache-filter.js"; @@ -15,6 +16,37 @@ const mockedTagCache = { const filterFn = (tag: string) => tag.startsWith("valid_"); describe("withFilter", () => { + it("forwards filtered stale checks with the original timestamp and receiver", async () => { + const underlying = { ...mockedTagCache, isStale: vi.fn().mockResolvedValue(true) }; + const cache = withFilter({ tagCache: underlying, filterFn }); + expect(await cache.isStale?.(["valid_tag", "invalid_tag"], 1000)).toBe(true); + expect(underlying.isStale).toHaveBeenCalledWith(["valid_tag"], 1000); + expect(underlying.isStale.mock.contexts[0]).toBe(underlying); + expect(await cache.isStale?.(["invalid_tag"], 1000)).toBe(false); + expect(underlying.isStale).toHaveBeenCalledTimes(1); + }); + + it("does not advertise stale checks when the underlying cache has none", () => { + expect(withFilter({ tagCache: mockedTagCache, filterFn }).isStale).toBeUndefined(); + }); + it("filters structured writes with existing string predicates without losing metadata", async () => { + const cache = withFilter({ tagCache: mockedTagCache, filterFn }); + const swr = { tag: "valid_swr", stale: 100, expire: 200 }; + const hard = { tag: "valid_hard", expire: 100 }; + await cache.writeTags(["valid_plain", swr, hard, { tag: "invalid_tag", stale: 100 }]); + expect(mockedTagCache.writeTags).toHaveBeenCalledWith(["valid_plain", swr, hard]); + }); + + it("type checks string callbacks under strictFunctionTypes", () => { + // Include the actual consumer tests: the package build intentionally excludes spec files. + const configPath = ts.findConfigFile(process.cwd(), ts.sys.fileExists, "tsconfig.json")!; + const config = ts.readConfigFile(configPath, ts.sys.readFile); + const parsed = ts.parseJsonConfigFileContent(config.config, ts.sys, process.cwd()); + const file = new URL("./tag-cache-filter.spec.ts", import.meta.url).pathname; + const program = ts.createProgram([file], { ...parsed.options, noEmit: true, strictFunctionTypes: true }); + const diagnostics = program.getSemanticDiagnostics(program.getSourceFile(file)); + expect(diagnostics.map((d) => ts.flattenDiagnosticMessageText(d.messageText, "\n"))).toEqual([]); + }); beforeEach(() => { vi.clearAllMocks(); }); diff --git a/packages/cloudflare/src/api/overrides/tag-cache/tag-cache-filter.ts b/packages/cloudflare/src/api/overrides/tag-cache/tag-cache-filter.ts index 05c11970..20bc3535 100644 --- a/packages/cloudflare/src/api/overrides/tag-cache/tag-cache-filter.ts +++ b/packages/cloudflare/src/api/overrides/tag-cache/tag-cache-filter.ts @@ -1,14 +1,13 @@ -import { NextModeTagCache } from "@opennextjs/core/types/overrides.js"; +import type { NextModeTagCache } from "@opennextjs/core/types/overrides.js"; interface WithFilterOptions { /** * The original tag cache. - * Call to this will receive only the filtered tags. */ tagCache: NextModeTagCache; + /** - * The function to filter tags. - * @param tag The tag to filter. + * Filter function that returns true if the tag should be forwarded to the underlying tag cache. * @returns true if the tag should be forwarded, false otherwise. */ filterFn: (tag: string) => boolean; @@ -16,12 +15,22 @@ interface WithFilterOptions { /** * Creates a new tag cache that filters tags based on the provided filter function. + * * This is useful to remove tags that are not used by the app, this could reduce the number of requests to the underlying tag cache. + * @param options Underlying cache and string-based tag predicate. + * @return A filtering cache preserving write metadata and optional stale checks. + * @throws When the predicate or delegated cache operation throws. */ export function withFilter({ tagCache, filterFn }: WithFilterOptions): NextModeTagCache { return { name: `filtered-${tagCache.name}`, mode: "nextMode", + isStale: tagCache.isStale + ? async (tags, lastModified) => { + const filteredTags = tags.filter(filterFn); + return filteredTags.length > 0 && tagCache.isStale!(filteredTags, lastModified); + } + : undefined, getLastRevalidated: async (tags) => { const filteredTags = tags.filter(filterFn); if (filteredTags.length === 0) { @@ -46,7 +55,7 @@ export function withFilter({ tagCache, filterFn }: WithFilterOptions): NextModeT return tagCache.hasBeenRevalidated(filteredTags, lastModified); }, writeTags: async (tags) => { - const filteredTags = tags.filter(filterFn); + const filteredTags = tags.filter((tag) => filterFn(typeof tag === "string" ? tag : tag.tag)); if (filteredTags.length === 0) { return; } @@ -60,6 +69,7 @@ export function withFilter({ tagCache, filterFn }: WithFilterOptions): NextModeT * This is used to filter out internal soft tags. * Can be used if `revalidatePath` is not used. */ -export function softTagFilter(tag: string): boolean { - return !tag.startsWith("_N_T_"); +export function softTagFilter(tag: string | { tag: string }): boolean { + const tagStr = typeof tag === "string" ? tag : tag.tag; + return !tagStr.startsWith("_N_T_"); } diff --git a/packages/core/src/adapters/cache-handler.ts b/packages/core/src/adapters/cache-handler.ts index 19b0426d..6ba05f2b 100644 --- a/packages/core/src/adapters/cache-handler.ts +++ b/packages/core/src/adapters/cache-handler.ts @@ -13,7 +13,8 @@ import type { } from "@/types/overrides"; import { resolveCdnInvalidation, resolveIncrementalCache, resolveTagCache } from "../core/resolve.js"; -import { getTagsFromValue, writeTags } from "../utils/cache.js"; +import { getTagsFromValue, isStale, writeTags } from "../utils/cache.js"; +import { isUnsupportedOperationError } from "../utils/error.js"; import { runWithOpenNextRequestContext } from "../utils/promise.js"; import { fromReadableStream, toReadableStream } from "../utils/stream.js"; @@ -48,6 +49,7 @@ async function initializeCaches() { /** * Handles an internal cache function request. * + * Standalone invocations establish a request context; local calls reuse their caller's context. * @param event Normalized cache request. * @param options Handler execution options. * @return The normalized cache response. @@ -57,6 +59,11 @@ export async function handler( options?: OpenNextHandlerOptions ): Promise { debug("cache handler event", event); + if (!globalThis.__openNextAls.getStore()) { + return runWithOpenNextRequestContext({ isISRRevalidation: false, waitUntil: options?.waitUntil }, () => + handler(event, options) + ); + } try { await initializeCaches(); @@ -108,6 +115,15 @@ export async function handler( // Route handlers // ////////////////////// +/** + * Reads a cache entry and attaches tag freshness without changing its timestamp. + * + * Untagged original-mode fetch entries also inherit their owning path's freshness. + * @param key Cache key. + * @param cacheType Entry type. + * @param additionalTags Tags supplied by the caller. + * @return The cache response, or an error response when the read fails. + */ async function handleGet( key: string, cacheType: CacheEntryType, @@ -148,6 +164,7 @@ async function handleGet( if (!result.shouldBypassTagCache) { let revalidated = await checkTagRevalidation(key, tags, result); + let owningPath: string | undefined; if (cacheType === "fetch" && globalThis.tagCache.mode === "original") { const hasHardTags = additionalTags.some((tag) => !tag.startsWith(SOFT_TAG_PREFIX)); @@ -156,7 +173,8 @@ async function handleGet( ); if (!revalidated && !hasHardTags && path) { - revalidated = await checkTagRevalidation(path.slice(SOFT_TAG_PREFIX.length), [], result); + owningPath = path.slice(SOFT_TAG_PREFIX.length); + revalidated = await checkTagRevalidation(owningPath, [], result); } } @@ -173,6 +191,14 @@ async function handleGet( }, }; } + + const lastModified = result.lastModified ?? Date.now(); + if ( + (await isStale(key, tags, lastModified)) || + (owningPath !== undefined && (await isStale(owningPath, [], lastModified))) + ) { + return buildCacheGetResponse({ ...result, isStale: true }); + } } return buildCacheGetResponse(result); @@ -311,28 +337,57 @@ async function handleRevalidateTags(body?: ReadableStream): Promise< return buildErrorResponse("Missing request body", 400); } - let tags: string[]; + let parsed: { tags?: unknown; durations?: unknown }; try { - const parsed = JSON.parse(bodyText) as { tags?: unknown }; - tags = - Array.isArray(parsed.tags) && - parsed.tags.every((tag: unknown): tag is string => typeof tag === "string") - ? parsed.tags - : []; + parsed = JSON.parse(bodyText) as { tags?: unknown; durations?: unknown }; } catch { return buildErrorResponse("Invalid JSON body", 400); } + const tags = + Array.isArray(parsed.tags) && parsed.tags.every((tag: unknown): tag is string => typeof tag === "string") + ? parsed.tags + : []; + if (tags.length === 0) { return buildErrorResponse("Missing 'tags' array in request body", 400); } + let durations: { expire?: number } | undefined; + if (parsed.durations !== undefined) { + if ( + typeof parsed.durations !== "object" || + parsed.durations === null || + Array.isArray(parsed.durations) + ) { + return buildErrorResponse("Invalid 'durations' object in request body", 400); + } + const expire = Reflect.get(parsed.durations, "expire"); + if (expire !== undefined && (typeof expire !== "number" || !Number.isFinite(expire) || expire < 0)) { + return buildErrorResponse("Invalid 'durations.expire' in request body", 400); + } + durations = expire === undefined ? {} : { expire }; + } + const isStaleWhileRevalidate = durations !== undefined && durations.expire !== 0; + try { await runWithOpenNextRequestContext({ isISRRevalidation: false }, async () => { + const now = Date.now(); + const swrExpire = + isStaleWhileRevalidate && durations?.expire !== undefined ? now + durations.expire * 1000 : undefined; if (globalThis.tagCache.mode === "nextMode") { const paths = (await globalThis.tagCache.getPathsByTags?.(tags)) ?? []; + const tagsToWrite = tags.map((tag) => + isStaleWhileRevalidate + ? { + tag, + stale: now, + expire: swrExpire, + } + : { tag, expire: now } + ); - await writeTags(tags); + await writeTags(tagsToWrite); if (paths.length > 0) { await globalThis.cdnInvalidationHandler.invalidatePaths( paths.map((path) => ({ @@ -355,10 +410,16 @@ async function handleRevalidateTags(body?: ReadableStream): Promise< debug("revalidateTag", tag); const paths = await globalThis.tagCache.getByTag(tag); debug("Items", paths); - const toInsert = paths.map((path) => ({ - path, - tag, - })); + const toInsert = paths.map((path) => + isStaleWhileRevalidate + ? { + path, + tag, + stale: now, + expire: swrExpire, + } + : { path, tag, expire: now } + ); if (tag.startsWith(SOFT_TAG_PREFIX)) { for (const path of paths) { @@ -368,10 +429,16 @@ async function handleRevalidateTags(body?: ReadableStream): Promise< const _paths = await globalThis.tagCache.getByTag(hardTag); debug({ hardTag, _paths }); toInsert.push( - ..._paths.map((path) => ({ - path, - tag: hardTag, - })) + ..._paths.map((path) => + isStaleWhileRevalidate + ? { + path, + tag: hardTag, + stale: now, + expire: swrExpire, + } + : { path, tag: hardTag, expire: now } + ) ); } } @@ -403,6 +470,9 @@ async function handleRevalidateTags(body?: ReadableStream): Promise< return buildJsonResponse({ revalidated: tags }, 200); } catch (e) { error("Failed to revalidate tags", e); + if (isUnsupportedOperationError(e)) { + return buildErrorResponse(e.message, 501, true); + } return buildErrorResponse("Failed to revalidate tags", 500); } } @@ -411,6 +481,12 @@ async function handleRevalidateTags(body?: ReadableStream): Promise< // Cache GET response builder // ///////////////////////////// +/** + * Serializes cache metadata and payload for all cache transports. + * + * @param result Cached entry and freshness metadata. + * @return The cache-service response. + */ function buildCacheGetResponse(result: WithLastModified>): InternalResult { const value = result.value!; @@ -425,6 +501,9 @@ function buildCacheGetResponse(result: WithLastModified { debug("get fetch cache", { key }); try { @@ -40,6 +48,7 @@ export default class Cache { return { lastModified: result.lastModified ?? Date.now(), + ...(result.isStale ? { isStale: true } : {}), value: result.value, } as CacheHandlerValue; } catch (e) { @@ -49,6 +58,12 @@ export default class Cache { } } + /** + * Reads route data, preserving the real timestamp for revalidation queue deduplication. + * + * @param key Route cache key. + * @return The Next cache entry, or null on a miss or read failure. + */ async getIncrementalCache(key: string): Promise { try { const cachedEntry = await globalThis.cache.get(key, "cache"); @@ -61,6 +76,7 @@ export default class Cache { const meta = cacheData.meta; const _lastModified = cachedEntry.lastModified ?? Date.now(); + const freshness = cachedEntry.isStale ? { isStale: true } : {}; const store = globalThis.__openNextAls.getStore(); if (store) { @@ -70,6 +86,7 @@ export default class Cache { if (cacheData?.type === "route") { return { lastModified: _lastModified, + ...freshness, value: { kind: "APP_ROUTE", body: Buffer.from( @@ -91,6 +108,7 @@ export default class Cache { } return { lastModified: _lastModified, + ...freshness, value: { kind: "APP_PAGE", html: cacheData.html, @@ -104,6 +122,7 @@ export default class Cache { } return { lastModified: _lastModified, + ...freshness, value: { kind: "PAGES", html: cacheData.html, @@ -116,6 +135,7 @@ export default class Cache { if (cacheData?.type === "redirect") { return { lastModified: _lastModified, + ...freshness, value: { kind: "REDIRECT", props: cacheData.props, @@ -256,7 +276,14 @@ export default class Cache { } } - public async revalidateTag(tags: string | string[]) { + /** + * Revalidates one or more cache tags. + * + * @param tags Tags to revalidate. + * @param durations Optional stale-while-revalidate durations. + * @return A promise that resolves when revalidation has been requested. + */ + public async revalidateTag(tags: string | string[], durations?: { expire?: number }): Promise { const config = globalThis.openNextConfig.dangerous; if (config?.disableTagCache || config?.disableIncrementalCache) { return; @@ -267,8 +294,15 @@ export default class Cache { } try { - await globalThis.cache.revalidateTags(_tags); + if (durations) { + await globalThis.cache.revalidateTags(_tags, durations); + } else { + await globalThis.cache.revalidateTags(_tags); + } } catch (e) { + if (isUnsupportedOperationError(e)) { + throw e; + } error("Failed to revalidate tag", e); } } diff --git a/packages/core/src/adapters/composable-cache.ts b/packages/core/src/adapters/composable-cache.ts index 820fbd41..73ba806a 100644 --- a/packages/core/src/adapters/composable-cache.ts +++ b/packages/core/src/adapters/composable-cache.ts @@ -1,5 +1,6 @@ import type { ComposableCacheEntry, ComposableCacheHandler } from "@/types/cache"; import type { CacheValue } from "@/types/overrides"; +import { isUnsupportedOperationError } from "@/utils/error"; import { fromReadableStream, toReadableStream } from "@/utils/stream"; import { debug } from "./logger"; @@ -7,6 +8,12 @@ import { debug } from "./logger"; const pendingWritePromiseMap = new Map>>(); export default { + /** + * Reads a composable entry, translating tag staleness into Next's revalidate interval. + * + * @param cacheKey Entry key. + * @return A streamed entry, or undefined on a miss or read failure. + */ async get(cacheKey: string) { try { // We first check if we have a pending write for this cache key @@ -26,9 +33,11 @@ export default { } debug("composable cache result", result); + const revalidate = result.isStale ? -1 : result.value.revalidate; return { ...result.value, + revalidate, value: toReadableStream(result.value.value), }; } catch (e) { @@ -83,6 +92,27 @@ export default { } }, + /** + * Updates tags with optional stale-while-revalidate durations. + * + * @param tags Tags to update. + * @param durations Optional stale-while-revalidate durations. + * @return A promise that resolves when the update has been requested. + */ + async updateTags(tags: string[], durations?: { expire?: number }): Promise { + if (tags.length === 0) { + return; + } + try { + await globalThis.cache.revalidateTags(tags, durations); + } catch (e) { + if (isUnsupportedOperationError(e)) { + throw e; + } + debug("Failed to update tags", e); + } + }, + // This one is necessary for older versions of next async receiveExpiredTags(...tags: string[]) { // This function does absolutely nothing diff --git a/packages/core/src/build/createServerBundle.ts b/packages/core/src/build/createServerBundle.ts index f7cd954c..272a7b0b 100644 --- a/packages/core/src/build/createServerBundle.ts +++ b/packages/core/src/build/createServerBundle.ts @@ -209,6 +209,7 @@ async function generateBundle( patches.patchNextServer, patches.getEnvVarsPatch(options), patches.patchBackgroundRevalidation, + patches.patchTagStaleness, patches.patchUseCacheForISR, patches.patchNodeEnvironment, ...additionalCodePatches, diff --git a/packages/core/src/build/generateOutput.ts b/packages/core/src/build/generateOutput.ts index 34d4a50d..99b0b520 100644 --- a/packages/core/src/build/generateOutput.ts +++ b/packages/core/src/build/generateOutput.ts @@ -172,6 +172,7 @@ async function extractOverrideFn(override?: DefaultOverrideOptions) { return { wrapper, converter }; } +//TODO: fix this, this is stupid async function extractCommonOverride(override?: OverrideOptions) { return { queue: await extractOverrideName("sqs", override?.queue) }; } diff --git a/packages/core/src/build/helper.ts b/packages/core/src/build/helper.ts index 159495b9..766ab69d 100644 --- a/packages/core/src/build/helper.ts +++ b/packages/core/src/build/helper.ts @@ -117,6 +117,7 @@ export function esbuildSync(esbuildOptions: ESBuildOptions, options: BuildOption esbuildOptions.banner?.js || "", `globalThis.openNextDebug = ${debug};`, `globalThis.openNextVersion = "${openNextVersion}";`, + `globalThis.nextVersion = "${options.nextVersion}";`, ].join(""), }, }); @@ -150,6 +151,7 @@ export async function esbuildAsync(esbuildOptions: ESBuildOptions, options: Buil esbuildOptions.banner?.js || "", `globalThis.openNextDebug = ${debug};`, `globalThis.openNextVersion = "${openNextVersion}";`, + `globalThis.nextVersion = "${options.nextVersion}";`, ].join(""), }, }); diff --git a/packages/core/src/build/patch/patches/index.ts b/packages/core/src/build/patch/patches/index.ts index a6bb2eba..dd0fbe49 100644 --- a/packages/core/src/build/patch/patches/index.ts +++ b/packages/core/src/build/patch/patches/index.ts @@ -7,5 +7,6 @@ export { } from "./patchFetchCacheISR.js"; export { patchFetchCacheSetMissingWaitUntil } from "./patchFetchCacheWaitUntil.js"; export { patchBackgroundRevalidation } from "./patchBackgroundRevalidation.js"; +export { patchTagStaleness } from "./patchTagStaleness.js"; export { patchNodeEnvironment } from "./patchNodeEnvironment.js"; export { patchOriginalNextConfig } from "./patchOriginalNextConfig.js"; diff --git a/packages/core/src/build/patch/patches/patchTagStaleness.ts b/packages/core/src/build/patch/patches/patchTagStaleness.ts new file mode 100644 index 00000000..55fcbb8a --- /dev/null +++ b/packages/core/src/build/patch/patches/patchTagStaleness.ts @@ -0,0 +1,31 @@ +import { getCrossPlatformPathRegex } from "@/utils/regex.js"; + +import { createPatchCode } from "../astCodePatcher.js"; +import type { CodePatcher } from "../codePatcher.js"; + +// Next derives staleness from time and does not consume custom cache-handler metadata. +// Preserve blocking invalidation (-1), and add our tag signal to both fetch and route results. +export const rule = ` +rule: + kind: shorthand_property_identifier + regex: ^isStale$ + inside: + kind: method_definition + stopBy: end + has: + kind: property_identifier + regex: ^get$ +fix: + 'isStale: isStale === -1 ? -1 : cacheData?.isStale || isStale' +`; + +export const patchTagStaleness = { + name: "patchTagStaleness", + patches: [ + { + versions: ">=14.1.0", + pathFilter: getCrossPlatformPathRegex("server/lib/incremental-cache/index.js"), + patchCode: createPatchCode(rule), + }, + ], +} satisfies CodePatcher; diff --git a/packages/core/src/core/routing/cacheInterceptor.ts b/packages/core/src/core/routing/cacheInterceptor.ts index e484e519..058a228a 100644 --- a/packages/core/src/core/routing/cacheInterceptor.ts +++ b/packages/core/src/core/routing/cacheInterceptor.ts @@ -34,7 +34,8 @@ async function computeCacheControl( body: string, host: string, revalidate?: number | false, - lastModified?: number + lastModified?: number, + isStaleFromTagCache = false ) { let finalRevalidate = CACHE_ONE_YEAR; @@ -59,19 +60,26 @@ async function computeCacheControl( etag, }; } - if (finalRevalidate !== CACHE_ONE_YEAR) { - const sMaxAge = Math.max(finalRevalidate - age, 1); + + // SSG uses one year cache + const isSSG = finalRevalidate === CACHE_ONE_YEAR; + const remainingTtl = Math.max(finalRevalidate - age, 1); + + const isStaleFromTime = !isSSG && remainingTtl === 1; + const isStale = isStaleFromTime || isStaleFromTagCache; + + if (!isSSG || isStaleFromTagCache) { + const sMaxAge = isStaleFromTagCache ? 1 : remainingTtl; debug("sMaxAge", { finalRevalidate, age, lastModified, revalidate, + isStaleFromTagCache, }); - const isStale = sMaxAge === 1; if (isStale) { let url = NextConfig.trailingSlash ? `${path}/` : path; if (NextConfig.basePath) { - // We need to add the basePath to the url url = `${NextConfig.basePath}${url}`; } await globalThis.queue.send({ @@ -164,7 +172,8 @@ async function generateResult( event: MiddlewareEvent, localizedPath: string, cachedValue: CacheValue<"cache">, - lastModified?: number + lastModified?: number, + isStaleFromTagCache = false ): Promise { debug("Returning result from experimental cache"); let body = ""; @@ -231,7 +240,8 @@ async function generateResult( body, event.headers.host, cachedValue.revalidate, - lastModified + lastModified, + isStaleFromTagCache ); return { type: "core", @@ -283,6 +293,12 @@ function decodePathParams(pathname: string): string { .join("/"); } +/** + * Serves eligible cached routes and schedules regeneration for stale entries. + * + * @param event Incoming middleware request. + * @return A cached response or the request to forward to Next.js. + */ export async function cacheInterceptor( event: MiddlewareEvent ): Promise { @@ -346,17 +362,25 @@ export async function cacheInterceptor( return event; } const host = event.headers.host; + const isStaleFromTagCache = cachedData.isStale === true; switch (cachedData?.value?.type) { case "app": case "page": - return generateResult(event, localizedPath, cachedData.value, cachedData.lastModified); + return generateResult( + event, + localizedPath, + cachedData.value, + cachedData.lastModified, + isStaleFromTagCache + ); case "redirect": { const cacheControl = await computeCacheControl( localizedPath, "", host, cachedData.value.revalidate, - cachedData.lastModified + cachedData.lastModified, + isStaleFromTagCache ); return { type: "core", @@ -375,7 +399,8 @@ export async function cacheInterceptor( cachedData.value.body, host, cachedData.value.revalidate, - cachedData.lastModified + cachedData.lastModified, + isStaleFromTagCache ); const isBinary = isBinaryContentType(String(cachedData.value.meta?.headers?.["content-type"])); diff --git a/packages/core/src/overrides/cache/fetch.ts b/packages/core/src/overrides/cache/fetch.ts index 352d97ee..993637ac 100644 --- a/packages/core/src/overrides/cache/fetch.ts +++ b/packages/core/src/overrides/cache/fetch.ts @@ -1,5 +1,6 @@ import type { Cache } from "@/types/overrides"; import { parseCacheGetResponse } from "@/utils/cache-get"; +import { UnsupportedOperationError } from "@/utils/error"; const CACHE_URL = process.env.OPEN_NEXT_CACHE_URL ?? ""; @@ -10,9 +11,14 @@ const CACHE_URL = process.env.OPEN_NEXT_CACHE_URL ?? ""; * @param operation Mutation being performed. * @throws When the cache handler returns a non-success status. */ -function ensureResponseOk(response: Pick, operation: string): void { +function ensureResponseOk(response: Pick, operation: string): void { if (!response.ok) { - throw new Error(`Failed to ${operation}: cache handler returned ${response.status}`); + const exposedReason = response.headers.get("x-opennext-cache-error"); + if (exposedReason) { + throw new UnsupportedOperationError(`Failed to ${operation}: ${exposedReason}`); + } + const reason = `cache handler returned ${response.status}`; + throw new Error(`Failed to ${operation}: ${reason}`); } } @@ -53,11 +59,11 @@ const fetchCache: Cache = { const response = await fetch(url, { method: "DELETE" }); ensureResponseOk(response, "delete cache entry"); }, - revalidateTags: async (tags) => { + revalidateTags: async (tags, durations) => { const response = await fetch(`${CACHE_URL}/cache/revalidate-tags`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ tags }), + body: JSON.stringify({ tags, durations }), }); ensureResponseOk(response, "revalidate cache tags"); }, diff --git a/packages/core/src/overrides/cache/local.ts b/packages/core/src/overrides/cache/local.ts index c906ab68..a4b1f20c 100644 --- a/packages/core/src/overrides/cache/local.ts +++ b/packages/core/src/overrides/cache/local.ts @@ -3,6 +3,7 @@ import path from "node:path"; import type { InternalEvent, InternalResult } from "@/types/open-next"; import type { Cache } from "@/types/overrides"; import { parseCacheGetResponse } from "@/utils/cache-get"; +import { UnsupportedOperationError } from "@/utils/error"; import { getMonorepoRelativePath } from "@/utils/normalize-path"; import { fromReadableStream, toReadableStream } from "@/utils/stream"; @@ -32,7 +33,12 @@ async function getHandler() { */ function ensureResultOk(result: InternalResult, operation: string): void { if (result.statusCode < 200 || result.statusCode >= 300) { - throw new Error(`Failed to ${operation}: cache handler returned ${result.statusCode}`); + const exposedReason = result.headers["x-opennext-cache-error"]; + if (exposedReason) { + throw new UnsupportedOperationError(`Failed to ${operation}: ${exposedReason}`); + } + const reason = `cache handler returned ${result.statusCode}`; + throw new Error(`Failed to ${operation}: ${reason}`); } } @@ -100,7 +106,7 @@ const localCache: Cache = { const result = await h(event); ensureResultOk(result, "delete cache entry"); }, - revalidateTags: async (tags) => { + revalidateTags: async (tags, durations) => { const h = (await getHandler())!; const url = `https://on/cache/revalidate-tags`; const event: InternalEvent = { @@ -112,7 +118,7 @@ const localCache: Cache = { query: {}, cookies: {}, remoteAddress: "127.0.0.1", - body: toReadableStream(JSON.stringify({ tags })), + body: toReadableStream(JSON.stringify({ tags, durations })), }; const result = await h(event); ensureResultOk(result, "revalidate cache tags"); diff --git a/packages/core/src/overrides/tagCache/dummy.ts b/packages/core/src/overrides/tagCache/dummy.ts index ac44b532..8a62dfa8 100644 --- a/packages/core/src/overrides/tagCache/dummy.ts +++ b/packages/core/src/overrides/tagCache/dummy.ts @@ -13,6 +13,9 @@ const dummyTagCache: TagCache = { getLastModified: async (_: string, lastModified) => { return lastModified ?? Date.now(); }, + isStale: async () => { + return false; + }, writeTags: async () => { return; }, diff --git a/packages/core/src/overrides/tagCache/fs-dev-nextMode.ts b/packages/core/src/overrides/tagCache/fs-dev-nextMode.ts index 49ccb498..b5369dc4 100644 --- a/packages/core/src/overrides/tagCache/fs-dev-nextMode.ts +++ b/packages/core/src/overrides/tagCache/fs-dev-nextMode.ts @@ -1,8 +1,14 @@ -import type { NextModeTagCache } from "@/types/overrides"; +import type { NextModeTagCache, NextModeTagCacheWriteInput } from "@/types/overrides"; import { debug } from "../../adapters/logger"; -const tagsMap = new Map(); +type TagData = { + revalidatedAt: number; + stale?: number; + expire?: number; +}; + +const tagsMap = new Map(); export default { name: "fs-dev-nextMode", @@ -15,7 +21,7 @@ export default { let lastRevalidated = 0; tags.forEach((tag) => { - const tagTime = tagsMap.get(tag); + const tagTime = tagsMap.get(tag)?.revalidatedAt; if (tagTime && tagTime > lastRevalidated) { lastRevalidated = tagTime; } @@ -30,22 +36,58 @@ export default { } const hasRevalidatedTag = tags.some((tag) => { - const tagRevalidatedAt = tagsMap.get(tag); - return tagRevalidatedAt ? tagRevalidatedAt > (lastModified ?? 0) : false; + const tagData = tagsMap.get(tag); + if (!tagData || tagData.revalidatedAt <= (lastModified ?? 0)) { + return false; + } + if (tagData.expire !== undefined) { + return tagData.expire <= Date.now() && tagData.expire > (lastModified ?? 0); + } + if (tagData.stale !== undefined) { + return false; + } + return true; }); debug("hasBeenRevalidated result:", hasRevalidatedTag); return hasRevalidatedTag; }, - writeTags: async (tags: string[]) => { + isStale: async (tags: string[], lastModified?: number) => { + if (globalThis.openNextConfig.dangerous?.disableTagCache) { + return false; + } + + const hasStaleTag = tags.some((tag) => { + const tagData = tagsMap.get(tag); + if (!tagData || typeof tagData.stale !== "number") { + return false; + } + // A tag is stale when both its stale timestamp and its revalidatedAt are newer than the page. + // revalidatedAt > lastModified ensures the revalidation that set this stale window happened + // after the page was generated, preventing a stale signal from a previous ISR cycle. + return tagData.revalidatedAt > (lastModified ?? 0) && tagData.stale >= (lastModified ?? 0); + }); + debug("isStale result:", hasStaleTag); + return hasStaleTag; + }, + writeTags: async (tags: (string | NextModeTagCacheWriteInput)[]) => { if (globalThis.openNextConfig.dangerous?.disableTagCache || tags.length === 0) { return; } - debug("writeTags", { tags: tags }); + debug("writeTags", { tags }); + const now = Date.now(); tags.forEach((tag) => { - tagsMap.set(tag, Date.now()); + if (typeof tag === "string") { + tagsMap.set(tag, { revalidatedAt: now }); + } else { + tagsMap.set(tag.tag, { + revalidatedAt: now, + ...(tag.stale !== undefined ? { stale: tag.stale } : {}), + ...(tag.expire !== undefined ? { expire: tag.expire } : {}), + }); + } }); debug("writeTags completed, written", tags.length, "tags"); diff --git a/packages/core/src/overrides/tagCache/fs-dev.ts b/packages/core/src/overrides/tagCache/fs-dev.ts index 932eb216..156fd530 100644 --- a/packages/core/src/overrides/tagCache/fs-dev.ts +++ b/packages/core/src/overrides/tagCache/fs-dev.ts @@ -1,17 +1,21 @@ import fs from "node:fs"; import path from "node:path"; -import type { TagCache } from "@/types/overrides"; +import type { OriginalTagCacheWriteInput, TagCache } from "@/types/overrides"; import { getMonorepoRelativePath } from "@/utils/normalize-path"; const tagFile = path.join(getMonorepoRelativePath(), "dynamodb-provider/dynamodb-cache.json"); const tagContent = fs.readFileSync(tagFile, "utf-8"); -let tags = JSON.parse(tagContent) as { +type TagEntry = { tag: { S: string }; path: { S: string }; revalidatedAt: { N: string }; -}[]; + stale?: { N: string }; + expire?: { N: string }; +}; + +let tags = JSON.parse(tagContent) as TagEntry[]; const { NEXT_BUILD_ID } = process.env; @@ -33,14 +37,32 @@ const tagCache: TagCache = { .map((tagEntry) => tagEntry.path.S.replace(`${NEXT_BUILD_ID}/`, "")); }, getLastModified: async (path: string, lastModified?: number) => { + const now = Date.now(); const revalidatedTags = tags.filter( (tagPathMapping) => tagPathMapping.path.S === buildKey(path) && - Number.parseInt(tagPathMapping.revalidatedAt.N) > (lastModified ?? 0) + Number.parseInt(tagPathMapping.revalidatedAt.N) > (lastModified ?? 0) && + (tagPathMapping.stale?.N === undefined || + (tagPathMapping.expire?.N !== undefined && + Number.parseInt(tagPathMapping.expire.N) <= now && + Number.parseInt(tagPathMapping.expire.N) > (lastModified ?? 0))) ); return revalidatedTags.length > 0 ? -1 : (lastModified ?? Date.now()); }, - writeTags: async (newTags) => { + isStale: async (path: string, lastModified?: number) => { + const matchingTags = tags.filter((tagPathMapping) => tagPathMapping.path.S === buildKey(path)); + return matchingTags.some((entry) => { + if (!entry.stale?.N) return false; + // A tag is stale when both its stale timestamp and its revalidatedAt are newer than the page. + // revalidatedAt > lastModified ensures the revalidation that set this stale window happened + // after the page was generated, preventing a stale signal from a previous ISR cycle. + return ( + Number.parseInt(entry.revalidatedAt.N) > (lastModified ?? 0) && + Number.parseInt(entry.stale.N) > (lastModified ?? 0) + ); + }); + }, + writeTags: async (newTags: OriginalTagCacheWriteInput[]) => { const newTagsSet = new Set(newTags.map(({ tag, path }) => `${buildKey(tag)}-${buildKey(path)}`)); const unchangedTags = tags.filter(({ tag, path }) => !newTagsSet.has(`${tag.S}-${path.S}`)); tags = unchangedTags.concat( @@ -48,6 +70,8 @@ const tagCache: TagCache = { tag: { S: buildKey(item.tag) }, path: { S: buildKey(item.path) }, revalidatedAt: { N: `${item.revalidatedAt ?? Date.now()}` }, + ...(item.stale !== undefined ? { stale: { N: `${item.stale}` } } : {}), + ...(item.expire !== undefined ? { expire: { N: `${item.expire}` } } : {}), })) ); }, diff --git a/packages/core/src/types/cache.ts b/packages/core/src/types/cache.ts index db5b63f1..d7bd46bd 100644 --- a/packages/core/src/types/cache.ts +++ b/packages/core/src/types/cache.ts @@ -76,6 +76,8 @@ export interface CacheHandlerContext { fetchCacheKeyPrefix?: string; } export interface CacheHandlerValue { + /** Consumed by the OpenNext incremental-cache patch, including permanently cached routes. */ + isStale?: boolean; lastModified?: number; age?: number; cacheState?: string; @@ -168,6 +170,10 @@ export interface ComposableCacheHandler { * Removed from Next.js 16 */ expireTags(...tags: string[]): Promise; + /** + * Added in Next.js 16. Updates tags with optional stale/expire durations. + */ + updateTags?(tags: string[], durations?: { expire?: number }): Promise; /** * This function is only there for older versions and do nothing */ diff --git a/packages/core/src/types/global.ts b/packages/core/src/types/global.ts index 552bc664..a55f25c4 100644 --- a/packages/core/src/types/global.ts +++ b/packages/core/src/types/global.ts @@ -12,6 +12,7 @@ import type { } from "@/types/overrides"; import type { DetachedPromiseRunner } from "../utils/promise"; +import type { RequestCache } from "../utils/requestCache"; import type { i18nConfig } from "./next-types.js"; import type { OpenNextConfig, WaitUntil } from "./open-next"; @@ -69,6 +70,8 @@ interface OpenNextRequestContext { waitUntil?: WaitUntil; /** We use this to deduplicate write of the tags*/ writtenTags: Set; + /** Per-request in-memory cache. Overrides can use this to store data scoped to the current request. */ + requestCache: RequestCache; } declare global { diff --git a/packages/core/src/types/overrides.ts b/packages/core/src/types/overrides.ts index 0de795e6..51375803 100644 --- a/packages/core/src/types/overrides.ts +++ b/packages/core/src/types/overrides.ts @@ -90,6 +90,8 @@ export type CachedFetchValue = { }; export type WithLastModified = { + /** Tag-triggered staleness, independent of the entry's generation timestamp. */ + isStale?: boolean; lastModified?: number; value?: T; /** @@ -153,12 +155,19 @@ Cons : - One page request (i.e. GET request) could require to check a lot of tags (And some of them multiple time when used with the fetch cache) - Almost impossible to do automatic cdn revalidation by itself */ +export interface NextModeTagCacheWriteInput { + tag: string; + stale?: number; + expire?: number; +} + export type NextModeTagCache = BaseTagCache & { mode: "nextMode"; // Necessary for the composable cache getLastRevalidated(tags: string[]): Promise; hasBeenRevalidated(tags: string[], lastModified?: number): Promise; - writeTags(tags: string[]): Promise; + isStale?(tags: string[], lastModified?: number): Promise; + writeTags(tags: (string | NextModeTagCacheWriteInput)[]): Promise; // Optional method to get paths by tags // It is used to automatically invalidate paths in the CDN getPathsByTags?: (tags: string[]) => Promise; @@ -168,6 +177,8 @@ export interface OriginalTagCacheWriteInput { tag: string; path: string; revalidatedAt?: number; + stale?: number; + expire?: number; } /** @@ -194,6 +205,7 @@ export type OriginalTagCache = BaseTagCache & { getByTag(tag: string): Promise; getByPath(path: string): Promise; getLastModified(path: string, lastModified?: number): Promise; + isStale?(path: string, lastModified?: number): Promise; writeTags(tags: OriginalTagCacheWriteInput[]): Promise; }; @@ -295,7 +307,7 @@ export type Cache = BaseOverride & { additionalTags?: string[] ): Promise; delete(key: string): Promise; - revalidateTags(tags: string[]): Promise; + revalidateTags(tags: string[], durations?: { expire?: number }): Promise; }; type CDNPath = { diff --git a/packages/core/src/utils/cache-get.ts b/packages/core/src/utils/cache-get.ts index 975c22b2..92304ee9 100644 --- a/packages/core/src/utils/cache-get.ts +++ b/packages/core/src/utils/cache-get.ts @@ -5,6 +5,7 @@ type HeadersMap = Record; type Base = { lastModified?: number; + isStale?: boolean; shouldBypassTagCache?: boolean; }; @@ -45,6 +46,14 @@ function collectPrefixedHeaders(headers: HeadersMap, prefix: string): Record { + if (globalThis.openNextConfig.dangerous?.disableTagCache) { + return false; + } + if (tagCache.mode === "nextMode") { + return tags.length > 0 && (await tagCache.isStale?.(tags, lastModified)) === true; + } + const isCacheStale = await tagCache.isStale?.(key, lastModified); + return isCacheStale === true; +} + export async function hasBeenRevalidated( key: string, tags: string[], @@ -48,18 +65,23 @@ export function getTagsFromValue(value?: CacheValue<"cache">) { } } -function getTagKey(tag: string | OriginalTagCacheWriteInput): string { +type WriteTagInput = string | NextModeTagCacheWriteInput | OriginalTagCacheWriteInput; + +function getTagKey(tag: WriteTagInput): string { if (typeof tag === "string") { return tag; } - return JSON.stringify({ - tag: tag.tag, - path: tag.path, - }); + if ("path" in tag) { + return JSON.stringify({ + tag: tag.tag, + path: tag.path, + }); + } + return JSON.stringify({ tag: tag.tag }); } export async function writeTags( - tags: (string | OriginalTagCacheWriteInput)[], + tags: WriteTagInput[], tagCache: TagCache = globalThis.tagCache ): Promise { const store = globalThis.__openNextAls.getStore(); diff --git a/packages/core/src/utils/error.ts b/packages/core/src/utils/error.ts index da22733b..e325074a 100644 --- a/packages/core/src/utils/error.ts +++ b/packages/core/src/utils/error.ts @@ -40,6 +40,37 @@ export class FatalError extends Error implements BaseOpenNextError { } } +/** + * Reports an operation that the configured adapter cannot implement. + * + * @param message Description of the unsupported operation. + * @return An error that must be surfaced to the caller. + */ +export class UnsupportedOperationError extends FatalError { + readonly __openNextUnsupportedOperation = true; + + constructor(message: string) { + super(message); + this.name = "UnsupportedOperationError"; + } +} + +/** + * Identifies unsupported-operation errors across package and bundle boundaries. + * + * @param error Value to inspect. + * @return Whether the value carries the OpenNext unsupported-operation marker. + */ +export function isUnsupportedOperationError( + error: unknown +): error is Error & { readonly __openNextUnsupportedOperation: true } { + return ( + error instanceof Error && + "__openNextUnsupportedOperation" in error && + error.__openNextUnsupportedOperation === true + ); +} + export function isOpenNextError(e: unknown): e is BaseOpenNextError & Error { try { return e !== null && typeof e === "object" && "__openNextInternal" in e; diff --git a/packages/core/src/utils/promise.ts b/packages/core/src/utils/promise.ts index 00602ce0..e3a2e158 100644 --- a/packages/core/src/utils/promise.ts +++ b/packages/core/src/utils/promise.ts @@ -2,6 +2,8 @@ import type { WaitUntil } from "@/types/open-next"; import { debug, error } from "../adapters/logger"; +import { RequestCache } from "./requestCache"; + /** * A `Promise.withResolvers` implementation that exposes the `resolve` and * `reject` functions on a `Promise`. @@ -120,6 +122,7 @@ export function runWithOpenNextRequestContext( isISRRevalidation, waitUntil, writtenTags: new Set(), + requestCache: new RequestCache(), }, async () => { provideNextAfterProvider(); diff --git a/packages/core/src/utils/requestCache.ts b/packages/core/src/utils/requestCache.ts new file mode 100644 index 00000000..58fe1d1c --- /dev/null +++ b/packages/core/src/utils/requestCache.ts @@ -0,0 +1,28 @@ +/** + * A per-request cache that provides named Map instances. + * Overrides can use this to store and share data within the scope of a single request + * without polluting global state. + * + * Retrieve it from the ALS context: + * ```ts + * const store = globalThis.__openNextAls.getStore(); + * const myMap = store?.requestCache.getOrCreate("my-override"); + * ``` + */ +export class RequestCache { + private _caches = new Map>(); + + /** + * Returns the Map registered under `key`. + * If no Map exists yet for that key, a new empty Map is created, stored, and returned. + * Repeated calls with the same key always return the **same** Map instance. + */ + getOrCreate(key: string): Map { + let cache = this._caches.get(key) as Map | undefined; + if (!cache) { + cache = new Map(); + this._caches.set(key, cache); + } + return cache; + } +} diff --git a/packages/tests-e2e/tests/experimental/use-cache.test.ts b/packages/tests-e2e/tests/experimental/use-cache.test.ts index c5a31679..a66ae92b 100644 --- a/packages/tests-e2e/tests/experimental/use-cache.test.ts +++ b/packages/tests-e2e/tests/experimental/use-cache.test.ts @@ -37,10 +37,13 @@ test.describe("Composable Cache", () => { expect(resp.status()).toEqual(200); expect(await resp.text()).toEqual("DONE"); - await page.reload(); - await expect(fullyCachedElt).toBeVisible(); - const newFullyCachedText = await fullyCachedElt.textContent(); - expect(newFullyCachedText).not.toEqual(initialFullyCachedText); + await expect + .poll(async () => { + await page.reload(); + await expect(fullyCachedElt).toBeVisible(); + return fullyCachedElt.textContent(); + }) + .not.toEqual(initialFullyCachedText); }); //TODO: figure out why it doesn't work in ISR (my guess is on our patch not working anymore in 16.1, but need investigation) diff --git a/packages/tests-unit/tests/adapters/cache-adapter.test.ts b/packages/tests-unit/tests/adapters/cache-adapter.test.ts index 12a13a8a..75510952 100644 --- a/packages/tests-unit/tests/adapters/cache-adapter.test.ts +++ b/packages/tests-unit/tests/adapters/cache-adapter.test.ts @@ -2,6 +2,9 @@ import { AsyncLocalStorage } from "node:async_hooks"; import { handler } from "@opennextjs/core/adapters/cache-handler"; import type { InternalEvent, InternalResult, OpenNextConfig } from "@opennextjs/core/types/open-next"; +import { UnsupportedOperationError } from "@opennextjs/core/utils/error"; +import { runWithOpenNextRequestContext } from "@opennextjs/core/utils/promise"; +import { RequestCache } from "@opennextjs/core/utils/requestCache"; import { fromReadableStream, toReadableStream } from "@opennextjs/core/utils/stream"; import { type Mock, vi, describe, expect, it, beforeEach } from "vitest"; @@ -22,6 +25,7 @@ const mockTagCache = vi.hoisted(() => ({ getByTag: vi.fn(), getByPath: vi.fn(), getLastModified: vi.fn(), + isStale: vi.fn(), writeTags: vi.fn(), hasBeenRevalidated: vi.fn(), getPathsByTags: undefined as Mock | undefined, @@ -64,6 +68,7 @@ async function runHandler(event: InternalEvent): Promise { }, isISRRevalidation: false, writtenTags: new Set(), + requestCache: new RequestCache(), }, () => handler(event) ); @@ -93,6 +98,33 @@ describe("cache-handler", () => { }); describe("routing", () => { + it("isolates concurrent standalone cache request contexts", async () => { + const contexts: unknown[] = []; + const read = async () => { + const store = globalThis.__openNextAls.getStore()!; + contexts.push(store.requestCache); + await Promise.resolve(); + expect(globalThis.__openNextAls.getStore()).toBe(store); + expect(store.requestCache).toBeInstanceOf(RequestCache); + return null; + }; + mockIncrementalCache.get.mockImplementationOnce(read).mockImplementationOnce(read); + await Promise.all([handler(createEvent()), handler(createEvent())]); + expect(contexts).toHaveLength(2); + expect(contexts[0]).not.toBe(contexts[1]); + expect(globalThis.__openNextAls.getStore()).toBeUndefined(); + }); + + it("reuses the caller context for a local cache read", async () => { + await runWithOpenNextRequestContext({ isISRRevalidation: false }, async () => { + const store = globalThis.__openNextAls.getStore(); + mockIncrementalCache.get.mockImplementationOnce(async () => { + expect(globalThis.__openNextAls.getStore()).toBe(store); + return null; + }); + await handler(createEvent()); + }); + }); it("should return 404 for non-cache paths", async () => { const event = createEvent({ rawPath: "/other/path" }); const result = await runHandler(event); @@ -360,6 +392,64 @@ describe("cache-handler", () => { expect(result.headers["x-opennext-cache-tag-status"]).toBe("revalidated"); }); + it("should mark stale entries in original mode", async () => { + mockTagCache.mode = "original"; + mockTagCache.getLastModified.mockResolvedValue(1000); + mockTagCache.isStale.mockResolvedValue(true); + mockIncrementalCache.get.mockResolvedValue({ + value: { type: "route", body: "data" }, + lastModified: 1000, + }); + + const result = await runHandler(createEvent()); + + expect(mockTagCache.isStale).toHaveBeenCalledWith("test-key", 1000); + expect(result.statusCode).toBe(200); + expect(result.headers["x-opennext-cache-last-modified"]).toBe("1000"); + expect(result.headers["x-opennext-cache-stale"]).toBe("true"); + }); + + it("should mark stale entries in nextMode", async () => { + mockTagCache.mode = "nextMode"; + mockTagCache.hasBeenRevalidated.mockResolvedValue(false); + mockTagCache.isStale.mockResolvedValue(true); + mockIncrementalCache.get.mockResolvedValue({ + value: { + type: "route", + body: "data", + meta: { headers: { "x-next-cache-tags": "tag1" } }, + }, + lastModified: 1000, + }); + + const result = await runHandler(createEvent()); + + expect(mockTagCache.isStale).toHaveBeenCalledWith(["tag1"], 1000); + expect(result.statusCode).toBe(200); + expect(result.headers["x-opennext-cache-last-modified"]).toBe("1000"); + expect(result.headers["x-opennext-cache-stale"]).toBe("true"); + }); + + it.each([false, true])( + "inherits owning-path fetch staleness only without hard tags (hard=%s)", + async (hasHardTags) => { + mockTagCache.mode = "original"; + mockTagCache.getLastModified.mockResolvedValue(1000); + mockTagCache.isStale.mockImplementation(async (key) => key === "some-path"); + mockIncrementalCache.get.mockResolvedValueOnce({ + value: { kind: "FETCH", data: { headers: {}, body: "data", url: "https://example.com" } }, + lastModified: 1000, + }); + const result = await runHandler( + createEvent({ query: { type: "fetch", tags: `_N_T_/some-path${hasHardTags ? ",hard-tag" : ""}` } }) + ); + expect(result.statusCode).toBe(200); + expect(result.headers["x-opennext-cache-last-modified"]).toBe("1000"); + expect(result.headers["x-opennext-cache-stale"]).toBe(hasHardTags ? undefined : "true"); + expect(mockTagCache.isStale).toHaveBeenCalledTimes(hasHardTags ? 1 : 2); + } + ); + it("should return 404 when a fetch entry's owning path has been revalidated", async () => { mockTagCache.mode = "original"; mockTagCache.getLastModified.mockResolvedValueOnce(1000).mockResolvedValueOnce(-1); @@ -650,6 +740,40 @@ describe("cache-handler", () => { expect(mockTagCache.writeTags).not.toHaveBeenCalled(); }); + it.each(["invalid", [], { expire: "30" }, { expire: -1 }])( + "should reject invalid durations: %j", + async (durations) => { + const event = createEvent({ + rawPath: "/cache/revalidate-tags", + method: "POST", + body: toReadableStream(JSON.stringify({ tags: ["tag1"], durations })), + }); + + const result = await runHandler(event); + + expect(result.statusCode).toBe(400); + expect(mockTagCache.writeTags).not.toHaveBeenCalled(); + } + ); + + it("should accept an SWR duration without an expiry", async () => { + mockTagCache.mode = "nextMode"; + vi.useFakeTimers().setSystemTime(100_000); + const event = createEvent({ + rawPath: "/cache/revalidate-tags", + method: "POST", + body: toReadableStream(JSON.stringify({ tags: ["tag1"], durations: {} })), + }); + + const result = await runHandler(event); + + expect(result.statusCode).toBe(200); + expect(mockTagCache.writeTags).toHaveBeenCalledWith([ + { tag: "tag1", stale: 100_000, expire: undefined }, + ]); + vi.useRealTimers(); + }); + it("should return 400 when body is invalid JSON", async () => { const event = createEvent({ rawPath: "/cache/revalidate-tags", @@ -742,5 +866,76 @@ describe("cache-handler", () => { expect(result.statusCode).toBe(500); }); + + it("should expose unsupported tag revalidation operations", async () => { + mockTagCache.mode = "nextMode"; + mockTagCache.writeTags.mockRejectedValueOnce( + new UnsupportedOperationError("Configured tag cache does not support SWR") + ); + const result = await runHandler( + createEvent({ + rawPath: "/cache/revalidate-tags", + method: "POST", + body: toReadableStream(JSON.stringify({ tags: ["tag1"], durations: { expire: 30 } })), + }) + ); + expect(result.statusCode).toBe(501); + expect(result.headers["x-opennext-cache-error"]).toBe("Configured tag cache does not support SWR"); + }); + + it("should convert nextMode durations to stale and expiry timestamps", async () => { + mockTagCache.mode = "nextMode"; + vi.useFakeTimers().setSystemTime(100_000); + const event = createEvent({ + rawPath: "/cache/revalidate-tags", + method: "POST", + body: toReadableStream(JSON.stringify({ tags: ["tag1"], durations: { expire: 30 } })), + }); + + await runHandler(event); + + expect(mockTagCache.writeTags).toHaveBeenCalledWith([{ tag: "tag1", stale: 100_000, expire: 130_000 }]); + vi.useRealTimers(); + }); + + it.each(["nextMode", "original"] as const)( + "should preserve zero-expiry hard invalidation in %s", + async (mode) => { + mockTagCache.mode = mode; + mockTagCache.getByTag.mockResolvedValue(["/path1"]); + vi.useFakeTimers().setSystemTime(100_000); + await runHandler( + createEvent({ + rawPath: "/cache/revalidate-tags", + method: "POST", + body: toReadableStream(JSON.stringify({ tags: ["tag1"], durations: { expire: 0 } })), + }) + ); + expect(mockTagCache.writeTags).toHaveBeenCalledWith( + mode === "nextMode" + ? [{ tag: "tag1", expire: 100_000 }] + : [{ path: "/path1", tag: "tag1", expire: 100_000 }] + ); + vi.useRealTimers(); + } + ); + + it("should convert original-mode durations to stale and expiry timestamps", async () => { + mockTagCache.mode = "original"; + mockTagCache.getByTag.mockResolvedValue(["/path1"]); + vi.useFakeTimers().setSystemTime(100_000); + const event = createEvent({ + rawPath: "/cache/revalidate-tags", + method: "POST", + body: toReadableStream(JSON.stringify({ tags: ["tag1"], durations: { expire: 30 } })), + }); + + await runHandler(event); + + expect(mockTagCache.writeTags).toHaveBeenCalledWith([ + { path: "/path1", tag: "tag1", stale: 100_000, expire: 130_000 }, + ]); + vi.useRealTimers(); + }); }); }); diff --git a/packages/tests-unit/tests/adapters/cache.test.ts b/packages/tests-unit/tests/adapters/cache.test.ts index e5b78f67..4195feca 100644 --- a/packages/tests-unit/tests/adapters/cache.test.ts +++ b/packages/tests-unit/tests/adapters/cache.test.ts @@ -54,6 +54,27 @@ describe("CacheHandler", () => { }); describe("get", () => { + it("preserves stale route timestamps in both Next results and the queue context", async () => { + cache.get.mockResolvedValueOnce({ + value: { type: "route", body: "data" }, + lastModified: 100_000, + isStale: true, + }); + expect(await instance.get("key")).toMatchObject({ lastModified: 100_000, isStale: true }); + expect(globalThis.__openNextAls.getStore()?.lastModified).toBe(100_000); + }); + + it("passes explicit fetch staleness to Next without changing the timestamp", async () => { + cache.get.mockResolvedValueOnce({ + value: { kind: "FETCH", data: { body: "data" } }, + lastModified: 100_000, + isStale: true, + }); + expect(await instance.get("key", { kind: "FETCH" })).toMatchObject({ + lastModified: 100_000, + isStale: true, + }); + }); it("Should return null for cache miss", async () => { cache.get.mockResolvedValueOnce({}); @@ -564,5 +585,13 @@ describe("CacheHandler", () => { await expect(instance.revalidateTag("tag")).resolves.not.toThrow(); }); + + it("Should surface unsupported revalidation operations", async () => { + const error = Object.assign(new Error("SWR is unsupported"), { + __openNextUnsupportedOperation: true as const, + }); + cache.revalidateTags.mockRejectedValueOnce(error); + await expect(instance.revalidateTag("tag", { expire: 30 })).rejects.toThrow("SWR is unsupported"); + }); }); }); diff --git a/packages/tests-unit/tests/adapters/composable-cache.test.ts b/packages/tests-unit/tests/adapters/composable-cache.test.ts index e126bcde..484cce40 100644 --- a/packages/tests-unit/tests/adapters/composable-cache.test.ts +++ b/packages/tests-unit/tests/adapters/composable-cache.test.ts @@ -1,4 +1,5 @@ import ComposableCache from "@opennextjs/core/adapters/composable-cache"; +import { UnsupportedOperationError } from "@opennextjs/core/utils/error"; import { fromReadableStream, toReadableStream } from "@opennextjs/core/utils/stream"; import { vi } from "vitest"; @@ -58,6 +59,25 @@ describe("Composable cache handler", () => { expect(result?.value).toBeInstanceOf(ReadableStream); }); + it("should trigger revalidation for an entry marked stale by the cache handler", async () => { + cache.get.mockResolvedValueOnce({ + value: { + value: "stale-value", + tags: ["tag1"], + stale: 0, + timestamp: Date.now(), + expire: Date.now() + 1000, + revalidate: 3600, + }, + lastModified: 1000, + isStale: true, + }); + + const result = await ComposableCache.get("stale-key"); + + expect(result?.revalidate).toBe(-1); + }); + it("should return undefined when cache entry does not exist", async () => { cache.get.mockResolvedValueOnce(null); @@ -296,4 +316,37 @@ describe("Composable cache handler", () => { expect(content2).toBe("concurrent-2"); }); }); + + describe("updateTags", () => { + it("should call cache.revalidateTags with tags and durations", async () => { + await ComposableCache.updateTags(["tag1", "tag2"], { expire: 30 }); + + expect(cache.revalidateTags).toHaveBeenCalledWith(["tag1", "tag2"], { expire: 30 }); + }); + + it("should not call cache.revalidateTags when tags are empty", async () => { + await ComposableCache.updateTags([]); + + expect(cache.revalidateTags).not.toHaveBeenCalled(); + }); + + it("should call cache.revalidateTags without durations when not provided", async () => { + await ComposableCache.updateTags(["tag1"]); + + expect(cache.revalidateTags).toHaveBeenCalledWith(["tag1"], undefined); + }); + + it("should not throw on cache error", async () => { + cache.revalidateTags.mockRejectedValueOnce(new Error("cache error")); + + await expect(ComposableCache.updateTags(["tag1"])).resolves.not.toThrow(); + }); + + it("should surface unsupported cache operations", async () => { + cache.revalidateTags.mockRejectedValueOnce(new UnsupportedOperationError("SWR is unsupported")); + await expect(ComposableCache.updateTags(["tag1"], { expire: 30 })).rejects.toThrow( + "SWR is unsupported" + ); + }); + }); }); diff --git a/packages/tests-unit/tests/build/patch/patches/patchTagStaleness.test.ts b/packages/tests-unit/tests/build/patch/patches/patchTagStaleness.test.ts new file mode 100644 index 00000000..5e563e3d --- /dev/null +++ b/packages/tests-unit/tests/build/patch/patches/patchTagStaleness.test.ts @@ -0,0 +1,76 @@ +import fs from "node:fs"; +import { createRequire } from "node:module"; + +import { patchCode } from "@opennextjs/core/build/patch/astCodePatcher.js"; +import { rule } from "@opennextjs/core/build/patch/patches/patchTagStaleness.js"; +import { describe, expect, it, vi } from "vitest"; + +const require = createRequire(new URL("../../../../../core/package.json", import.meta.url)); +const filename = require.resolve("next/dist/server/lib/incremental-cache/index.js"); +const source = fs.readFileSync(filename, "utf8"); +const patched = patchCode(source, rule); +const module = { + exports: + {} as typeof import("../../../../../core/node_modules/next/dist/server/lib/incremental-cache/index.js"), +}; +new Function("require", "exports", "module", patched)(createRequire(filename), module.exports, module); + +describe("tag staleness in the installed Next incremental cache", () => { + it("patches the real Next source", () => { + expect(patched).not.toBe(source); + }); + + it.each([false, 3600] as const)("marks a fresh route stale with revalidate=%s", async (revalidate) => { + const cache = new module.exports.IncrementalCache({ + dev: false, + requestHeaders: {}, + getPrerenderManifest: () => + ({ + routes: {}, + dynamicRoutes: {}, + notFoundRoutes: [], + preview: { previewModeId: "test" }, + version: 4, + }) as never, + }); + cache.cacheControls.get = () => ({ revalidate }); + const entry = { + lastModified: Date.now(), + isStale: true, + value: { kind: "APP_ROUTE", body: Buffer.from("data"), status: 200, headers: {} }, + }; + cache.cacheHandler = { get: vi.fn().mockResolvedValue(entry) } as never; + expect(await cache.get("/page", { kind: "APP_ROUTE" } as never)).toMatchObject({ isStale: true }); + entry.isStale = false; + expect((await cache.get("/page", { kind: "APP_ROUTE" } as never))?.isStale).not.toBe(true); + entry.lastModified = -1; + entry.isStale = true; + expect((await cache.get("/page", { kind: "APP_ROUTE" } as never))?.isStale).toBe(-1); + }); + + it("marks fresh fetch data stale and preserves misses", async () => { + const cache = new module.exports.IncrementalCache({ + dev: false, + requestHeaders: {}, + getPrerenderManifest: () => + ({ + routes: {}, + dynamicRoutes: {}, + notFoundRoutes: [], + preview: { previewModeId: "test" }, + version: 4, + }) as never, + }); + const get = vi.fn().mockResolvedValue({ + lastModified: Date.now(), + isStale: true, + value: { kind: "FETCH", data: { body: "data" }, revalidate: 3600 }, + }); + cache.cacheHandler = { get } as never; + expect(await cache.get("fetch-key", { kind: "FETCH", revalidate: 3600 } as never)).toMatchObject({ + isStale: true, + }); + get.mockResolvedValue(null); + expect(await cache.get("fetch-key", { kind: "FETCH" } as never)).toBeNull(); + }); +}); diff --git a/packages/tests-unit/tests/core/routing/cacheInterceptor.test.ts b/packages/tests-unit/tests/core/routing/cacheInterceptor.test.ts index 0dcfde57..e7c3280b 100644 --- a/packages/tests-unit/tests/core/routing/cacheInterceptor.test.ts +++ b/packages/tests-unit/tests/core/routing/cacheInterceptor.test.ts @@ -473,4 +473,87 @@ describe("cacheInterceptor", () => { const result = await cacheInterceptor(event); expect(result.statusCode).toBe(200); }); + + describe("isStaleFromTagCache", () => { + it("should serve stale SSG app content and queue its real timestamp", async () => { + const event = createEvent({ + url: "/albums", + }); + cache.get.mockResolvedValueOnce({ + value: { + type: "app", + html: "Hello, world!", + }, + lastModified: 100_000, + isStale: true, + }); + + const result = await cacheInterceptor(event); + expect(globalThis.queue.send).toHaveBeenCalledWith( + expect.objectContaining({ + MessageBody: expect.objectContaining({ lastModified: 100_000 }), + }) + ); + + expect(result).toEqual( + expect.objectContaining({ + type: "core", + headers: expect.objectContaining({ + "cache-control": "s-maxage=1, stale-while-revalidate=2592000", + "x-opennext-cache": "STALE", + }), + }) + ); + }); + + it("should serve SSG page content with explicit staleness", async () => { + const event = createEvent({ + url: "/albums", + }); + cache.get.mockResolvedValueOnce({ + value: { + type: "page", + html: "Hello, world!", + }, + lastModified: 100_000, + isStale: true, + }); + + const result = await cacheInterceptor(event); + + expect(result.type).toBe("core"); + expect((result as any).headers["cache-control"]).toBe("s-maxage=1, stale-while-revalidate=2592000"); + expect((result as any).headers["x-opennext-cache"]).toBe("STALE"); + }); + + it("should serve SSG route content with explicit staleness", async () => { + const event = createEvent({ + url: "/albums", + }); + cache.get.mockResolvedValueOnce({ + value: { + type: "route", + body: "API response", + meta: { + status: 200, + headers: { "content-type": "text/plain" }, + }, + }, + lastModified: 100_000, + isStale: true, + }); + + const result = await cacheInterceptor(event); + + expect(result).toEqual( + expect.objectContaining({ + type: "core", + headers: expect.objectContaining({ + "cache-control": "s-maxage=1, stale-while-revalidate=2592000", + "x-opennext-cache": "STALE", + }), + }) + ); + }); + }); }); diff --git a/packages/tests-unit/tests/overrides/cache/fetch.test.ts b/packages/tests-unit/tests/overrides/cache/fetch.test.ts index d03d3eaa..a9794bf8 100644 --- a/packages/tests-unit/tests/overrides/cache/fetch.test.ts +++ b/packages/tests-unit/tests/overrides/cache/fetch.test.ts @@ -191,6 +191,17 @@ describe("fetch cache", () => { "Failed to set cache entry: cache handler returned 500" ); }); + + it("should surface an exposed cache-handler error", async () => { + mockFetch({ + headers: { "x-opennext-cache-error": "SWR is unsupported" }, + body: "", + status: 501, + }); + const operation = fetchCache.revalidateTags(["tag"], { expire: 30 }); + await expect(operation).rejects.toThrow("Failed to revalidate cache tags: SWR is unsupported"); + await expect(operation).rejects.toMatchObject({ __openNextUnsupportedOperation: true }); + }); }); describe("delete", () => { @@ -220,6 +231,17 @@ describe("fetch cache", () => { }); }); + it("should include revalidation durations", async () => { + await fetchCache.revalidateTags(["tag1"], { expire: 30 }); + + expect(global.fetch).toHaveBeenCalledWith( + "/cache/revalidate-tags", + expect.objectContaining({ + body: JSON.stringify({ tags: ["tag1"], durations: { expire: 30 } }), + }) + ); + }); + it("should reject an unsuccessful response", async () => { mockFetch({ headers: {}, body: "", status: 500 }); diff --git a/packages/tests-unit/tests/overrides/cache/local.test.ts b/packages/tests-unit/tests/overrides/cache/local.test.ts index db8d559b..8c81718a 100644 --- a/packages/tests-unit/tests/overrides/cache/local.test.ts +++ b/packages/tests-unit/tests/overrides/cache/local.test.ts @@ -203,6 +203,15 @@ describe("local cache", () => { "Failed to set cache entry: cache handler returned 500" ); }); + + it("should surface an exposed cache-handler error", async () => { + mockHandler.mockResolvedValue( + createMockResult({ statusCode: 501, headers: { "x-opennext-cache-error": "SWR is unsupported" } }) + ); + const operation = localCache.revalidateTags(["tag"], { expire: 30 }); + await expect(operation).rejects.toThrow("Failed to revalidate cache tags: SWR is unsupported"); + await expect(operation).rejects.toMatchObject({ __openNextUnsupportedOperation: true }); + }); }); describe("delete", () => { @@ -237,6 +246,17 @@ describe("local cache", () => { expect(await fromReadableStream(event.body)).toBe(JSON.stringify({ tags: ["tag1", "tag2"] })); }); + it("should include revalidation durations", async () => { + mockHandler.mockResolvedValue(createMockResult()); + + await localCache.revalidateTags(["tag1"], { expire: 30 }); + + const event = mockHandler.mock.calls[0][0]; + expect(await fromReadableStream(event.body)).toBe( + JSON.stringify({ tags: ["tag1"], durations: { expire: 30 } }) + ); + }); + it("should reject an unsuccessful response", async () => { mockHandler.mockResolvedValue(createMockResult({ statusCode: 500 })); diff --git a/packages/tests-unit/tests/overrides/tag-cache/dynamodb-staleness.test.ts b/packages/tests-unit/tests/overrides/tag-cache/dynamodb-staleness.test.ts new file mode 100644 index 00000000..b9506195 --- /dev/null +++ b/packages/tests-unit/tests/overrides/tag-cache/dynamodb-staleness.test.ts @@ -0,0 +1,63 @@ +import { AsyncLocalStorage } from "node:async_hooks"; + +import lite from "@opennextjs/aws/overrides/tagCache/dynamodb-lite.js"; +import full from "@opennextjs/aws/overrides/tagCache/dynamodb.js"; +import { runWithOpenNextRequestContext } from "@opennextjs/core/utils/promise.js"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { send, fetchDynamo } = vi.hoisted(() => ({ send: vi.fn(), fetchDynamo: vi.fn() })); +vi.mock("../../../../aws/node_modules/@aws-sdk/client-dynamodb", () => ({ + DynamoDBClient: class { + send = send; + }, + QueryCommand: class { + constructor(public input: unknown) {} + }, + BatchWriteItemCommand: class {}, +})); +vi.mock("@opennextjs/aws/utils/fetch.js", () => ({ customFetchClient: () => fetchDynamo })); + +describe.each([ + ["full", full, send], + ["lite", lite, fetchDynamo], +] as const)("%s DynamoDB stale reads", (_name, cache, query) => { + beforeEach(() => { + vi.clearAllMocks(); + vi.stubEnv("AWS_ACCESS_KEY_ID", "test"); + vi.stubEnv("AWS_SECRET_ACCESS_KEY", "test"); + vi.useFakeTimers().setSystemTime(2_000); + globalThis.openNextConfig = { dangerous: {} } as never; + globalThis.__openNextAls = new AsyncLocalStorage(); + }); + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllEnvs(); + }); + + it.each([ + ["legacy hard invalidation", { revalidatedAt: { N: "1600" } }, false], + ["active SWR", { revalidatedAt: { N: "1600" }, stale: { N: "1600" }, expire: { N: "3000" } }, true], + ["indefinite SWR", { revalidatedAt: { N: "1600" }, stale: { N: "1600" } }, true], + [ + "stale event predates entry", + { revalidatedAt: { N: "1600" }, stale: { N: "1000" }, expire: { N: "3000" } }, + false, + ], + [ + "superseded expiry", + { revalidatedAt: { N: "1600" }, stale: { N: "1000" }, expire: { N: "1200" } }, + false, + ], + ["expired window", { revalidatedAt: { N: "1600" }, stale: { N: "1600" }, expire: { N: "1800" } }, false], + ["older revalidation", { revalidatedAt: { N: "1000" }, stale: { N: "1600" } }, false], + ["equal stale timestamp", { revalidatedAt: { N: "1600" }, stale: { N: "1500" } }, false], + ])("classifies %s and shares the hard-check query", async (_case, item, expected) => { + send.mockResolvedValue({ Items: [item] }); + fetchDynamo.mockImplementation(async () => new Response(JSON.stringify({ Items: [item] }))); + await runWithOpenNextRequestContext({ isISRRevalidation: false }, async () => { + await cache.getLastModified("page", 1500); + expect(await cache.isStale!("page", 1500)).toBe(expected); + expect(query).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/packages/tests-unit/tests/overrides/tag-cache/dynamodb.test.ts b/packages/tests-unit/tests/overrides/tag-cache/dynamodb.test.ts new file mode 100644 index 00000000..8086d8d9 --- /dev/null +++ b/packages/tests-unit/tests/overrides/tag-cache/dynamodb.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; + +import { hasHardRevalidation as hasLiteHardRevalidation } from "../../../../aws/src/overrides/tagCache/dynamodb-lite.js"; +import { hasHardRevalidation } from "../../../../aws/src/overrides/tagCache/dynamodb.js"; + +describe("DynamoDB tag cache", () => { + it("does not hard-invalidate a tag before its SWR expiry", () => { + const result = hasHardRevalidation( + [{ revalidatedAt: { N: "1500" }, stale: { N: "1500" }, expire: { N: "3000" } }], + 1_000, + 2_000 + ); + + expect(result).toBe(false); + }); + + it("hard-invalidates a tag after its SWR expiry", () => { + const result = hasHardRevalidation( + [{ revalidatedAt: { N: "1500" }, stale: { N: "1500" }, expire: { N: "1800" } }], + 1_000, + 2_000 + ); + + expect(result).toBe(true); + }); + + it("keeps a tag stale when its SWR window has no expiry", () => { + const result = hasHardRevalidation( + [{ revalidatedAt: { N: "1500" }, stale: { N: "1500" } }], + 1_000, + 2_000 + ); + + expect(result).toBe(false); + }); + + it("keeps a lightweight tag stale when its SWR window has no expiry", () => { + const result = hasLiteHardRevalidation( + [{ revalidatedAt: { N: "1500" }, stale: { N: "1500" } }], + 1_000, + 2_000 + ); + + expect(result).toBe(false); + }); +}); diff --git a/packages/tests-unit/tests/overrides/tag-cache/fs-dev.test.ts b/packages/tests-unit/tests/overrides/tag-cache/fs-dev.test.ts new file mode 100644 index 00000000..cfd9e3e4 --- /dev/null +++ b/packages/tests-unit/tests/overrides/tag-cache/fs-dev.test.ts @@ -0,0 +1,36 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("node:fs", () => ({ + default: { readFileSync: () => "[]" }, +})); + +globalThis.monorepoPackagePath = ""; +const fsDevTagCache = (await import("@opennextjs/core/overrides/tagCache/fs-dev")).default; + +describe("filesystem tag cache SWR revalidation", () => { + beforeEach(() => { + vi.useFakeTimers().setSystemTime(2_000); + }); + + it("serves a stale entry before its expiry", async () => { + await fsDevTagCache.writeTags([{ path: "future-page", tag: "future-tag", stale: 2_000, expire: 3_000 }]); + + expect(await fsDevTagCache.getLastModified("future-page", 1_000)).toBe(1_000); + expect(await fsDevTagCache.isStale?.("future-page", 1_000)).toBe(true); + }); + + it("hard-invalidates an entry after its expiry", async () => { + await fsDevTagCache.writeTags([ + { path: "expired-page", tag: "expired-tag", stale: 1_500, expire: 1_800 }, + ]); + + expect(await fsDevTagCache.getLastModified("expired-page", 1_000)).toBe(-1); + }); + + it("serves a stale entry indefinitely when no expiry is set", async () => { + await fsDevTagCache.writeTags([{ path: "indefinite-page", tag: "indefinite-tag", stale: 2_000 }]); + + expect(await fsDevTagCache.getLastModified("indefinite-page", 1_000)).toBe(1_000); + expect(await fsDevTagCache.isStale?.("indefinite-page", 1_000)).toBe(true); + }); +}); diff --git a/packages/tests-unit/tests/overrides/tag-cache/next-mode-batch.test.ts b/packages/tests-unit/tests/overrides/tag-cache/next-mode-batch.test.ts new file mode 100644 index 00000000..4f6fd4cf --- /dev/null +++ b/packages/tests-unit/tests/overrides/tag-cache/next-mode-batch.test.ts @@ -0,0 +1,77 @@ +import { AsyncLocalStorage } from "node:async_hooks"; + +import cache from "@opennextjs/aws/overrides/tagCache/dynamodb-nextMode.js"; +import { runWithOpenNextRequestContext } from "@opennextjs/core/utils/promise.js"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { fetchDynamo } = vi.hoisted(() => ({ fetchDynamo: vi.fn() })); +vi.mock("@opennextjs/aws/utils/fetch.js", () => ({ customFetchClient: () => fetchDynamo })); + +describe("next-mode partial DynamoDB batches", () => { + beforeEach(() => { + vi.resetAllMocks(); + vi.useFakeTimers().setSystemTime(2000); + vi.stubEnv("AWS_ACCESS_KEY_ID", "test"); + vi.stubEnv("AWS_SECRET_ACCESS_KEY", "test"); + vi.stubEnv("CACHE_DYNAMO_TABLE", "tags"); + vi.stubEnv("NEXT_BUILD_ID", ""); + globalThis.openNextConfig = { dangerous: {} } as never; + globalThis.__openNextAls = new AsyncLocalStorage(); + }); + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllEnvs(); + }); + + it("retries only unprocessed keys and shares processed hits and misses", async () => { + const retried = { + tag: { S: "_tag/retry" }, + revalidatedAt: { N: "1500" }, + stale: { N: "1500" }, + expire: { N: "3000" }, + }; + fetchDynamo + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + Responses: { tags: [{ tag: { S: "_tag/fresh" }, revalidatedAt: { N: "500" } }] }, + UnprocessedKeys: { tags: { Keys: [{ tag: { S: "_tag/retry" }, path: { S: "_tag/retry" } }] } }, + }) + ) + ) + .mockResolvedValueOnce(new Response(JSON.stringify({ Responses: { tags: [retried] } }))); + const pending = runWithOpenNextRequestContext({ isISRRevalidation: false }, async () => { + expect(await cache.hasBeenRevalidated(["fresh", "missing", "retry"], 1000)).toBe(false); + expect(await cache.isStale(["fresh", "missing", "retry"], 1000)).toBe(true); + const records = globalThis.__openNextAls.getStore()!.requestCache.getOrCreate("ddb-nextMode:tagItems"); + expect(records.get("missing")).toBeNull(); + expect(records.get("retry")).toEqual(retried); + }); + await vi.runAllTimersAsync(); + await pending; + expect(fetchDynamo).toHaveBeenCalledTimes(2); + expect(JSON.parse(fetchDynamo.mock.calls[1][1].body).RequestItems.tags.Keys).toEqual([ + { path: { S: "_tag/retry" }, tag: { S: "_tag/retry" } }, + ]); + }); + + it("fails bounded retries without negative-caching unread tags", async () => { + fetchDynamo.mockImplementation( + async () => + new Response( + JSON.stringify({ + UnprocessedKeys: { tags: { Keys: [{ tag: { S: "_tag/retry" } }] } }, + }) + ) + ); + const pending = runWithOpenNextRequestContext({ isISRRevalidation: false }, async () => { + await expect(cache.hasBeenRevalidated(["retry"], 1000)).rejects.toThrow("after three retries"); + expect( + globalThis.__openNextAls.getStore()!.requestCache.getOrCreate("ddb-nextMode:tagItems").has("retry") + ).toBe(false); + }); + await vi.runAllTimersAsync(); + await pending; + expect(fetchDynamo).toHaveBeenCalledTimes(4); + }); +}); diff --git a/packages/tests-unit/tests/overrides/tag-cache/next-mode.test.ts b/packages/tests-unit/tests/overrides/tag-cache/next-mode.test.ts new file mode 100644 index 00000000..17ee445b --- /dev/null +++ b/packages/tests-unit/tests/overrides/tag-cache/next-mode.test.ts @@ -0,0 +1,89 @@ +import fsDevTagCache from "@opennextjs/core/overrides/tagCache/fs-dev-nextMode"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { + cacheDynamoItems, + hasHardRevalidation, +} from "../../../../aws/src/overrides/tagCache/dynamodb-nextMode.js"; +import type { DynamoDBItem } from "../../../../aws/src/overrides/tagCache/dynamodb-nextMode.js"; + +describe("next-mode SWR tag revalidation", () => { + beforeEach(() => { + vi.useFakeTimers().setSystemTime(2_000); + globalThis.openNextConfig = { dangerous: {} }; + }); + + it("does not hard-invalidate DynamoDB records before their expiry", () => { + expect( + hasHardRevalidation( + { revalidatedAt: { N: "1500" }, stale: { N: "1500" }, expire: { N: "3000" } }, + 1_000, + 2_000 + ) + ).toBe(false); + }); + + it("hard-invalidates DynamoDB records after their expiry", () => { + expect( + hasHardRevalidation( + { revalidatedAt: { N: "1500" }, stale: { N: "1500" }, expire: { N: "1800" } }, + 1_000, + 2_000 + ) + ).toBe(true); + }); + + it("ignores an expired DynamoDB SWR window older than the cached entry", () => { + expect( + hasHardRevalidation( + { revalidatedAt: { N: "1000" }, stale: { N: "1000" }, expire: { N: "1800" } }, + 1_500, + 2_000 + ) + ).toBe(false); + }); + + it("keeps DynamoDB records stale when their SWR window has no expiry", () => { + expect(hasHardRevalidation({ revalidatedAt: { N: "1500" }, stale: { N: "1500" } }, 1_000, 2_000)).toBe( + false + ); + }); + + it("caches missing DynamoDB tags for the duration of the request", () => { + const itemsCache = new Map(); + + const hasMatch = cacheDynamoItems(["missing-tag"], [], itemsCache, () => true); + + expect(hasMatch).toBe(false); + expect(itemsCache.has("missing-tag")).toBe(true); + expect(itemsCache.get("missing-tag")).toBeNull(); + }); + + it("does not hard-invalidate filesystem records before their expiry", async () => { + await fsDevTagCache.writeTags([{ tag: "future-swr", stale: 2_000, expire: 3_000 }]); + + expect(await fsDevTagCache.hasBeenRevalidated(["future-swr"], 1_000)).toBe(false); + expect(await fsDevTagCache.isStale?.(["future-swr"], 1_000)).toBe(true); + }); + + it("hard-invalidates filesystem records after their expiry", async () => { + await fsDevTagCache.writeTags([{ tag: "expired-swr", stale: 1_500, expire: 1_800 }]); + + expect(await fsDevTagCache.hasBeenRevalidated(["expired-swr"], 1_000)).toBe(true); + }); + + it("ignores an expired filesystem SWR window older than the cached entry", async () => { + vi.setSystemTime(1_000); + await fsDevTagCache.writeTags([{ tag: "old-expired-swr", stale: 1_000, expire: 1_800 }]); + vi.setSystemTime(2_000); + + expect(await fsDevTagCache.hasBeenRevalidated(["old-expired-swr"], 1_500)).toBe(false); + }); + + it("keeps filesystem records stale when their SWR window has no expiry", async () => { + await fsDevTagCache.writeTags([{ tag: "indefinite-swr", stale: 2_000 }]); + + expect(await fsDevTagCache.hasBeenRevalidated(["indefinite-swr"], 1_000)).toBe(false); + expect(await fsDevTagCache.isStale?.(["indefinite-swr"], 1_000)).toBe(true); + }); +}); diff --git a/packages/tests-unit/tests/utils/cache-get.test.ts b/packages/tests-unit/tests/utils/cache-get.test.ts index b7fe39c2..e3aad880 100644 --- a/packages/tests-unit/tests/utils/cache-get.test.ts +++ b/packages/tests-unit/tests/utils/cache-get.test.ts @@ -2,6 +2,19 @@ import { parseCacheGetResponse } from "@opennextjs/core/utils/cache-get"; import { describe, expect, it } from "vitest"; describe("parseCacheGetResponse", () => { + it("preserves generation time separately from tag staleness", () => { + const result = parseCacheGetResponse( + { + "x-opennext-cache-found": "true", + "x-opennext-cache-type": "cache", + "x-opennext-cache-sub-type": "route", + "x-opennext-cache-last-modified": "100000", + "x-opennext-cache-stale": "true", + }, + "body" + ); + expect(result).toMatchObject({ lastModified: 100_000, isStale: true }); + }); it("should return null when x-opennext-cache-found is not 'true'", () => { const result = parseCacheGetResponse({ "x-opennext-cache-type": "cache" }, "body"); expect(result).toBeNull();