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/packages/aws/src/overrides/tagCache/dynamodb-lite.ts b/packages/aws/src/overrides/tagCache/dynamodb-lite.ts index 902bc29c..417afc38 100644 --- a/packages/aws/src/overrides/tagCache/dynamodb-lite.ts +++ b/packages/aws/src/overrides/tagCache/dynamodb-lite.ts @@ -14,6 +14,8 @@ type DynamoDBItem = { tag?: { S: string }; path?: { S: string }; revalidatedAt?: { N: string }; + stale?: { N: string }; + expire?: { N: string }; }; type DynamoDBResponse = { @@ -56,11 +58,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 +82,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 +108,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 +122,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,10 +143,9 @@ 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 []; @@ -136,6 +157,12 @@ const tagCache: OriginalTagCache = { return lastModified ?? Date.now(); } const { CACHE_DYNAMO_TABLE } = process.env; + const store = globalThis.__openNextAls.getStore(); + const cache = store?.requestCache.getOrCreate("dynamoDb:getLastModified"); + const cacheKey = `${key}:${lastModified ?? 0}`; + if (cache?.has(cacheKey)) { + return cache.get(cacheKey)!; + } const result = await awsFetch( JSON.stringify({ TableName: CACHE_DYNAMO_TABLE, @@ -156,14 +183,80 @@ 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()); + + // Check if any tag has expired + const now = Date.now(); + + const hasExpiredTag = revalidatedTags.some((item) => { + if (item.expire?.N) { + const expiry = Number.parseInt(item.expire.N); + return expiry <= now && expiry > (lastModified ?? 0); + } + return false; + }); + // Exclude expired tags from the revalidated count — they are handled + // separately via hasExpiredTag above. + const nonExpiredRevalidatedTags = revalidatedTags.filter((item) => { + if (item.expire?.N) { + return Number.parseInt(item.expire.N) === Number.parseInt(item.revalidatedAt?.N ?? "0"); + } + return true; + }); + // If we have revalidated tags or expired tags we return -1 to force revalidation + const resultValue = + nonExpiredRevalidatedTags.length > 0 || hasExpiredTag ? -1 : (lastModified ?? Date.now()); + cache?.set(cacheKey, resultValue); + return resultValue; } catch (e) { error("Failed to get revalidated tags", e); return lastModified ?? Date.now(); } }, - async writeTags(tags: { tag: string; path: string; revalidatedAt?: number }[]) { + 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 { + // We can reuse the same query as getLastModified since it already checks for revalidatedAt > lastModified as revalidatedAt and stale have the same value + 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); + return items.length > 0; + } 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 +267,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..2b72d5c8 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,17 +10,16 @@ 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; +type DynamoDBItem = { + tag?: { S: string }; + path?: { S: string }; + revalidatedAt?: { N: string }; + stale?: { N: string }; + expire?: { N: string }; }; -let awsClient: AwsClient | null = null; - const getAwsClient = () => { const { CACHE_BUCKET_REGION } = process.env; if (awsClient) { @@ -58,15 +57,84 @@ 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)) { + if (compute(itemsCache.get(tag)!)) hasMatch = true; + } else { + uncachedTags.push(tag); + } + } + return { uncachedTags, hasMatch }; +} + +/** + * Fetches uncached tags from DynamoDB via BatchGetItem, populates the items + * cache (storing null for absent tags), and returns whether any tag matched. + */ +async function fetchAndCacheItems( + uncachedTags: string[], + itemsCache: Map | undefined, + compute: (item: DynamoDBItem) => boolean +): Promise { + const { CACHE_DYNAMO_TABLE } = process.env; + const response = await awsFetch( + JSON.stringify({ + RequestItems: { + [CACHE_DYNAMO_TABLE ?? ""]: { + Keys: uncachedTags.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 } = await response.json(); + const responseItems: DynamoDBItem[] = Responses?.[CACHE_DYNAMO_TABLE ?? ""] ?? []; + + // Build a lookup map: DynamoDB key → item + const responseByKey = new Map(); + for (const item of responseItems) { + responseByKey.set(item.tag?.S ?? "", item); + } + + let hasMatch = false; + for (const tag of uncachedTags) { + const item = responseByKey.get(buildDynamoKey(tag)) ?? null; + if (!item) continue; + itemsCache?.set(tag, item); + if (compute(item)) hasMatch = true; + } + return hasMatch; +} + export default { name: "ddb-nextMode", mode: "nextMode", @@ -83,38 +151,62 @@ 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; + if (item.expire?.N) { + const expiry = Number.parseInt(item.expire.N); + if (expiry <= now && expiry > (lastModified ?? 0)) return true; + } + return Number.parseInt(item.revalidatedAt?.N ?? "0") > (lastModified ?? 0); + }; + + 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 +214,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..2203dbc0 100644 --- a/packages/aws/src/overrides/tagCache/dynamodb.ts +++ b/packages/aws/src/overrides/tagCache/dynamodb.ts @@ -10,6 +10,14 @@ import { MAX_DYNAMO_BATCH_WRITE_ITEM_COUNT, getDynamoBatchWriteCommandConcurrenc 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 }; +}; + function parseDynamoClientConfigFromEnv(): DynamoDBClientConfig { return { region: CACHE_BUCKET_REGION, @@ -26,11 +34,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 +57,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 +78,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 +91,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 +108,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 +122,99 @@ 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()); + + // Check if any tag has expired + const now = Date.now(); + const hasExpiredTag = revalidatedTags.some((item) => { + if (item.expire?.N) { + const expiry = Number.parseInt(item.expire.N); + return expiry <= now && expiry > (lastModified ?? 0); + } + return false; + }); + // Exclude expired tags from the revalidated count — they are handled + // separately via hasExpiredTag above. + const nonExpiredRevalidatedTags = revalidatedTags.filter((item) => { + if (item.expire?.N) { + return Number.parseInt(item.expire.N) > now; + } + return true; + }); + + // If we have revalidated tags or expired tags we return -1 to force revalidation + return nonExpiredRevalidatedTags.length > 0 || hasExpiredTag ? -1 : (lastModified ?? Date.now()); } catch (e) { error("Failed to get revalidated tags", e); return lastModified ?? Date.now(); } }, + 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); + return items.length > 0; + } catch (e) { + error("Failed to check stale tags", e); + return false; + } + }, async writeTags(tags) { try { if (globalThis.openNextConfig.dangerous?.disableTagCache) { @@ -128,7 +225,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/cloudflare/src/api/overrides/cache/service-cache.spec.ts b/packages/cloudflare/src/api/overrides/cache/service-cache.spec.ts index 7787963d..2ad47c58 100644 --- a/packages/cloudflare/src/api/overrides/cache/service-cache.spec.ts +++ b/packages/cloudflare/src/api/overrides/cache/service-cache.spec.ts @@ -92,11 +92,11 @@ describe("serviceCache", () => { }); it("revalidates tags", async () => { - await serviceCache.revalidateTags(["tag1", "tag2"]); + await serviceCache.revalidateTags(["tag1", "tag2"], { expire: 10 }); const { url, method, body } = lastRequest(); expect(method).toBe("POST"); expect(url.pathname).toBe("/cache/revalidate-tags"); - expect(JSON.parse(body as string)).toEqual({ tags: ["tag1", "tag2"] }); + expect(JSON.parse(body as string)).toEqual({ tags: ["tag1", "tag2"], durations: { expire: 10 } }); }); }); diff --git a/packages/cloudflare/src/api/overrides/cache/service-cache.ts b/packages/cloudflare/src/api/overrides/cache/service-cache.ts index d7808cc1..02b3174e 100644 --- a/packages/cloudflare/src/api/overrides/cache/service-cache.ts +++ b/packages/cloudflare/src/api/overrides/cache/service-cache.ts @@ -82,11 +82,11 @@ const serviceCache = { await getCacheService().fetch(getCacheUrl(key), { method: "DELETE" }); }, - revalidateTags: async (tags) => { + revalidateTags: async (tags, durations) => { 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 }), }); }, } satisfies Cache; diff --git a/packages/cloudflare/src/api/overrides/internal.ts b/packages/cloudflare/src/api/overrides/internal.ts index 0dfd492d..9b6c6d70 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"; @@ -32,6 +36,14 @@ export function computeCacheKey(key: string, options: KeyOptions) { return `${prefix}/${buildId}/${hash}.${cacheType}`.replace(/\/+/g, "/"); } +/** + * `writeTags` accepts either plain tag names or objects carrying the `stale`/`expire` durations. + * The Cloudflare tag caches do not support durations, they only need the names. + */ +export function toTagNames(tags: (string | NextModeTagCacheWriteInput)[]): string[] { + 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..9bb43721 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(), 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.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..7fd8836e 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(), 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.ts b/packages/cloudflare/src/api/overrides/tag-cache/tag-cache-filter.ts index 05c11970..49efb5e3 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,17 +1,16 @@ -import { NextModeTagCache } from "@opennextjs/core/types/overrides.js"; +import { NextModeTagCache, NextModeTagCacheWriteInput } 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; + filterFn: (tag: string | NextModeTagCacheWriteInput) => boolean; } /** @@ -60,6 +59,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-adapter.ts b/packages/core/src/adapters/cache-adapter.ts index c690b172..8e62268d 100644 --- a/packages/core/src/adapters/cache-adapter.ts +++ b/packages/core/src/adapters/cache-adapter.ts @@ -13,7 +13,7 @@ import type { import { createGenericHandler } from "../core/createGenericHandler.js"; import { resolveCdnInvalidation, resolveIncrementalCache, resolveTagCache } from "../core/resolve.js"; -import { getTagsFromValue, writeTags } from "../utils/cache.js"; +import { getTagsFromValue, isStale, writeTags } from "../utils/cache.js"; import { runWithOpenNextRequestContext } from "../utils/promise.js"; import { toReadableStream } from "../utils/stream.js"; @@ -146,6 +146,8 @@ async function handleGet( } if (!result.shouldBypassTagCache) { + const lastModified = result.lastModified ?? Date.now(); + if (tags.length > 0) { const revalidated = await checkTagRevalidation(key, tags, result); if (revalidated) { @@ -162,6 +164,12 @@ async function handleGet( }; } } + + // Check if the cache entry is stale (valid but needs background revalidation) + const _isStale = tags.length > 0 ? await isStale(key, tags, lastModified) : false; + if (_isStale) { + result.lastModified = 1; + } } return buildCacheGetResponse(result); @@ -234,6 +242,9 @@ async function handleSet(key: string, cacheType: CacheEntryType, body?: Buffer): const storedTags = await tagCache.getByPath(key); const tagsToWrite = derivedTags.filter((tag) => !storedTags.includes(tag)); if (tagsToWrite.length > 0) { + // `1` marks a row that was never revalidated: it can never be greater than a + // real `lastModified`, so the entry being written is not immediately treated + // as outdated by the next read. await writeTags( tagsToWrite.map((tag) => ({ path: key, @@ -273,24 +284,38 @@ async function handleRevalidateTags(body?: Buffer): Promise { return buildErrorResponse("Missing request body", 400); } - let tags: string[]; + let parsed: { tags?: string[]; durations?: { expire?: number } }; try { - const parsed = JSON.parse(body.toString("utf-8")); - tags = Array.isArray(parsed.tags) ? parsed.tags : []; + parsed = JSON.parse(body.toString("utf-8")); } catch { return buildErrorResponse("Invalid JSON body", 400); } + const tags = Array.isArray(parsed.tags) ? parsed.tags : []; if (tags.length === 0) { return buildErrorResponse("Missing 'tags' array in request body", 400); } + const { durations } = parsed; + try { await runWithOpenNextRequestContext({ isISRRevalidation: false }, async () => { if (globalThis.tagCache.mode === "nextMode") { const paths = (await globalThis.tagCache.getPathsByTags?.(tags)) ?? []; - await writeTags(tags); + const now = Date.now(); + const tagsToWrite = tags.map((tag) => { + if (durations) { + return { + tag, + stale: now, + expire: durations.expire !== undefined ? now + durations.expire * 1000 : undefined, + }; + } + return { tag, expire: now }; + }); + + await writeTags(tagsToWrite); if (paths.length > 0) { await globalThis.cdnInvalidationHandler.invalidatePaths( paths.map((path) => ({ @@ -309,14 +334,22 @@ async function handleRevalidateTags(body?: Buffer): Promise { return; } + const now = Date.now(); for (const tag of tags) { 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) => { + const baseEntry = { path, tag }; + if (durations) { + return { + ...baseEntry, + stale: now, + expire: durations.expire !== undefined ? now + durations.expire * 1000 : undefined, + }; + } + return { ...baseEntry, expire: now }; + }); if (tag.startsWith(SOFT_TAG_PREFIX)) { for (const path of paths) { @@ -326,10 +359,17 @@ async function handleRevalidateTags(body?: Buffer): Promise { const _paths = await globalThis.tagCache.getByTag(hardTag); debug({ hardTag, _paths }); toInsert.push( - ..._paths.map((path) => ({ - path, - tag: hardTag, - })) + ..._paths.map((path) => { + const baseEntry = { path, tag: hardTag }; + if (durations) { + return { + ...baseEntry, + stale: now, + expire: durations.expire !== undefined ? now + durations.expire * 1000 : undefined, + }; + } + return { ...baseEntry, expire: now }; + }) ); } } diff --git a/packages/core/src/adapters/cache.ts b/packages/core/src/adapters/cache.ts index 25378bf9..6ad0b0f5 100644 --- a/packages/core/src/adapters/cache.ts +++ b/packages/core/src/adapters/cache.ts @@ -250,7 +250,7 @@ export default class Cache { } } - public async revalidateTag(tags: string | string[]) { + public async revalidateTag(tags: string | string[], durations?: { expire?: number }) { const config = globalThis.openNextConfig.dangerous; if (config?.disableTagCache || config?.disableIncrementalCache) { return; @@ -261,7 +261,7 @@ export default class Cache { } try { - await globalThis.cache.revalidateTags(_tags); + await globalThis.cache.revalidateTags(_tags, durations); } catch (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..f3377b3e 100644 --- a/packages/core/src/adapters/composable-cache.ts +++ b/packages/core/src/adapters/composable-cache.ts @@ -27,8 +27,15 @@ export default { debug("composable cache result", result); + let revalidate = result.value.revalidate; + // If the cache adapter signaled staleness via lastModified=1, trigger SWR + if (result.lastModified === 1) { + revalidate = -1; + } + return { ...result.value, + revalidate, value: toReadableStream(result.value.value), }; } catch (e) { @@ -83,6 +90,24 @@ export default { } }, + /** + * Added in Next.js 16. Updates tags with optional stale/expire durations. + * Mirrors the revalidateTag logic but without CDN invalidation + * since composable cache keys are not URL paths. + */ + async updateTags(tags: string[], durations?: { expire?: number }) { + if (tags.length === 0) { + return; + } + try { + // `durations.expire` is a delay in seconds, it is turned into a timestamp by the cache + // handler function - it should not be converted here as well. + await globalThis.cache.revalidateTags(tags, durations); + } catch (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/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/core/routing/cacheInterceptor.ts b/packages/core/src/core/routing/cacheInterceptor.ts index d54b5781..0e711802 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", @@ -346,17 +356,27 @@ export async function cacheInterceptor( return event; } const host = event.headers.host; + //TODO: change returned type to provide staleness as a prop + // Detect staleness signaled by the cache adapter (sets lastModified to 1) + const isStaleFromTagCache = cachedData.lastModified === 1; 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 +395,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 9d8825c4..f708e0d6 100644 --- a/packages/core/src/overrides/cache/fetch.ts +++ b/packages/core/src/overrides/cache/fetch.ts @@ -35,11 +35,11 @@ const fetchCache: Cache = { const url = `${CACHE_URL}/cache/${encodeURIComponent(key)}`; await fetch(url, { method: "DELETE" }); }, - revalidateTags: async (tags) => { + revalidateTags: async (tags, durations) => { await fetch(`${CACHE_URL}/cache/revalidate-tags`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ tags }), + body: JSON.stringify({ tags, durations }), }); }, }; diff --git a/packages/core/src/overrides/cache/local.ts b/packages/core/src/overrides/cache/local.ts index 33cc78f9..d501dc91 100644 --- a/packages/core/src/overrides/cache/local.ts +++ b/packages/core/src/overrides/cache/local.ts @@ -78,7 +78,7 @@ const localCache: Cache = { }; await h(event); }, - revalidateTags: async (tags) => { + revalidateTags: async (tags, durations) => { const h = (await getHandler())!; const url = `https://on/cache/revalidate-tags`; const event: InternalEvent = { @@ -90,7 +90,7 @@ const localCache: Cache = { query: {}, cookies: {}, remoteAddress: "127.0.0.1", - body: Buffer.from(JSON.stringify({ tags })), + body: Buffer.from(JSON.stringify({ tags, durations })), }; await h(event); }, 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..08b6ddb9 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,49 @@ export default { } const hasRevalidatedTag = tags.some((tag) => { - const tagRevalidatedAt = tagsMap.get(tag); - return tagRevalidatedAt ? tagRevalidatedAt > (lastModified ?? 0) : false; + const tagData = tagsMap.get(tag); + return tagData ? tagData.revalidatedAt > (lastModified ?? 0) : false; }); 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..1313b620 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; @@ -40,7 +44,20 @@ const tagCache: TagCache = { ); 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 +65,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..1737d0c4 100644 --- a/packages/core/src/types/cache.ts +++ b/packages/core/src/types/cache.ts @@ -168,6 +168,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..6027798d 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 { @@ -196,6 +199,12 @@ declare global { */ var openNextVersion: string; + /** + * The version of Next.js used in this build. + * Available in the cache function (defined in the esbuild banner of the cache bundle). + */ + var nextVersion: string; + /** * The cache client used to communicate with the cache handler function. * Only available in main functions. diff --git a/packages/core/src/types/overrides.ts b/packages/core/src/types/overrides.ts index fd767559..ae4d079f 100644 --- a/packages/core/src/types/overrides.ts +++ b/packages/core/src/types/overrides.ts @@ -153,12 +153,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 +175,8 @@ export interface OriginalTagCacheWriteInput { tag: string; path: string; revalidatedAt?: number; + stale?: number; + expire?: number; } /** @@ -194,6 +203,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; }; @@ -265,7 +275,7 @@ export type Cache = BaseOverride & { isFetch?: CacheType ): 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.ts b/packages/core/src/utils/cache.ts index 98f14441..6af52bf3 100644 --- a/packages/core/src/utils/cache.ts +++ b/packages/core/src/utils/cache.ts @@ -1,6 +1,7 @@ import type { CacheEntryType, CacheValue, + NextModeTagCacheWriteInput, OriginalTagCacheWriteInput, TagCache, WithLastModified, @@ -8,6 +9,22 @@ import type { import { debug } from "../adapters/logger"; +export async function isStale( + key: string, + tags: string[], + lastModified: number, + tagCache: TagCache = globalThis.tagCache +): Promise { + 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/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-unit/tests/adapters/cache-adapter.test.ts b/packages/tests-unit/tests/adapters/cache-adapter.test.ts index 1fd3ba53..d7bf68fa 100644 --- a/packages/tests-unit/tests/adapters/cache-adapter.test.ts +++ b/packages/tests-unit/tests/adapters/cache-adapter.test.ts @@ -22,6 +22,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, @@ -381,6 +382,57 @@ describe("cache-adapter", () => { expect(mockTagCache.getLastModified).not.toHaveBeenCalled(); expect(mockTagCache.hasBeenRevalidated).not.toHaveBeenCalled(); }); + + it("should set lastModified to 1 when tags are stale", async () => { + mockTagCache.mode = "original"; + mockTagCache.getLastModified.mockResolvedValue(1000); + 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(result.statusCode).toBe(200); + expect(result.headers["x-opennext-cache-last-modified"]).toBe("1"); + }); + + it("should keep original lastModified when tags are not stale", async () => { + mockTagCache.mode = "original"; + mockTagCache.getLastModified.mockResolvedValue(1000); + mockTagCache.isStale.mockResolvedValue(false); + mockIncrementalCache.get.mockResolvedValue({ + value: { + type: "route", + body: "data", + meta: { headers: { "x-next-cache-tags": "tag1" } }, + }, + lastModified: 1000, + }); + + const result = await runHandler(createEvent()); + + expect(result.statusCode).toBe(200); + expect(result.headers["x-opennext-cache-last-modified"]).toBe("1000"); + }); + + it("should skip isStale when shouldBypassTagCache is true", async () => { + mockIncrementalCache.get.mockResolvedValue({ + value: { type: "route", body: "data" }, + lastModified: 1000, + shouldBypassTagCache: true, + }); + + const result = await runHandler(createEvent()); + + expect(result.statusCode).toBe(200); + expect(mockTagCache.isStale).not.toHaveBeenCalled(); + }); }); describe("PUT /cache/:key", () => { @@ -676,5 +728,67 @@ describe("cache-adapter", () => { expect(result.statusCode).toBe(500); }); + + it("should accept durations and pass stale/expire - nextMode", async () => { + mockTagCache.mode = "nextMode"; + vi.useFakeTimers().setSystemTime(100000); + const event = createEvent({ + rawPath: "/cache/revalidate-tags", + method: "POST", + body: Buffer.from(JSON.stringify({ tags: ["tag1"], durations: { expire: 30 } })), + }); + + await runHandler(event); + + expect(mockTagCache.writeTags).toHaveBeenCalledWith([ + { tag: "tag1", stale: 100000, expire: 100000 + 30 * 1000 }, + ]); + }); + + it("should accept durations and pass stale/expire - original mode", async () => { + mockTagCache.mode = "original"; + mockTagCache.getByTag.mockResolvedValue(["/path1"]); + vi.useFakeTimers().setSystemTime(100000); + const event = createEvent({ + rawPath: "/cache/revalidate-tags", + method: "POST", + body: Buffer.from(JSON.stringify({ tags: ["tag1"], durations: { expire: 30 } })), + }); + + await runHandler(event); + + expect(mockTagCache.writeTags).toHaveBeenCalledWith([ + { path: "/path1", tag: "tag1", stale: 100000, expire: 100000 + 30 * 1000 }, + ]); + }); + + it("should use immediate expiration when no durations provided - nextMode", async () => { + mockTagCache.mode = "nextMode"; + vi.useFakeTimers().setSystemTime(100000); + const event = createEvent({ + rawPath: "/cache/revalidate-tags", + method: "POST", + body: Buffer.from(JSON.stringify({ tags: ["tag1"] })), + }); + + await runHandler(event); + + expect(mockTagCache.writeTags).toHaveBeenCalledWith([{ tag: "tag1", expire: 100000 }]); + }); + + it("should use immediate expiration when no durations provided - original mode", async () => { + mockTagCache.mode = "original"; + mockTagCache.getByTag.mockResolvedValue(["/path1"]); + vi.useFakeTimers().setSystemTime(100000); + const event = createEvent({ + rawPath: "/cache/revalidate-tags", + method: "POST", + body: Buffer.from(JSON.stringify({ tags: ["tag1"] })), + }); + + await runHandler(event); + + expect(mockTagCache.writeTags).toHaveBeenCalledWith([{ path: "/path1", tag: "tag1", expire: 100000 }]); + }); }); }); diff --git a/packages/tests-unit/tests/adapters/cache.test.ts b/packages/tests-unit/tests/adapters/cache.test.ts index ef6f8e46..1d28ed49 100644 --- a/packages/tests-unit/tests/adapters/cache.test.ts +++ b/packages/tests-unit/tests/adapters/cache.test.ts @@ -535,13 +535,13 @@ describe("CacheHandler", () => { it("Should call cache.revalidateTags with single tag", async () => { await instance.revalidateTag("tag"); - expect(cache.revalidateTags).toHaveBeenCalledWith(["tag"]); + expect(cache.revalidateTags).toHaveBeenCalledWith(["tag"], undefined); }); it("Should call cache.revalidateTags with array of tags", async () => { await instance.revalidateTag(["tag1", "tag2"]); - expect(cache.revalidateTags).toHaveBeenCalledWith(["tag1", "tag2"]); + expect(cache.revalidateTags).toHaveBeenCalledWith(["tag1", "tag2"], undefined); }); it("Should not call cache.revalidateTags when tags array is empty", async () => { diff --git a/packages/tests-unit/tests/adapters/composable-cache.test.ts b/packages/tests-unit/tests/adapters/composable-cache.test.ts index e126bcde..884f75bb 100644 --- a/packages/tests-unit/tests/adapters/composable-cache.test.ts +++ b/packages/tests-unit/tests/adapters/composable-cache.test.ts @@ -85,6 +85,44 @@ describe("Composable cache handler", () => { expect(result).toBeUndefined(); }); + it("should set revalidate=-1 when lastModified is 1 (stale from cache adapter)", async () => { + cache.get.mockResolvedValueOnce({ + value: { + value: "stale-value", + tags: ["tag1"], + stale: 0, + timestamp: 1000, + expire: 2000, + revalidate: 3600, + }, + lastModified: 1, + }); + + const result = await ComposableCache.get("stale-key"); + + expect(result).toBeDefined(); + expect(result?.revalidate).toBe(-1); + }); + + it("should keep original revalidate when lastModified is not 1", async () => { + cache.get.mockResolvedValueOnce({ + value: { + value: "fresh-value", + tags: ["tag1"], + stale: 0, + timestamp: 1000, + expire: 2000, + revalidate: 3600, + }, + lastModified: 1000, + }); + + const result = await ComposableCache.get("fresh-key"); + + expect(result).toBeDefined(); + expect(result?.revalidate).toBe(3600); + }); + it("should return pending write promise if available", async () => { const pendingEntry = Promise.resolve({ value: toReadableStream("pending-value"), @@ -296,4 +334,30 @@ 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(); + }); + }); }); diff --git a/packages/tests-unit/tests/core/routing/cacheInterceptor.test.ts b/packages/tests-unit/tests/core/routing/cacheInterceptor.test.ts index 0dcfde57..e995f5f3 100644 --- a/packages/tests-unit/tests/core/routing/cacheInterceptor.test.ts +++ b/packages/tests-unit/tests/core/routing/cacheInterceptor.test.ts @@ -473,4 +473,79 @@ describe("cacheInterceptor", () => { const result = await cacheInterceptor(event); expect(result.statusCode).toBe(200); }); + + describe("isStaleFromTagCache", () => { + it("should serve SSG app content with STALE when lastModified is 1", async () => { + const event = createEvent({ + url: "/albums", + }); + cache.get.mockResolvedValueOnce({ + value: { + type: "app", + html: "Hello, world!", + }, + lastModified: 1, + }); + + 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", + }), + }) + ); + }); + + it("should serve SSG page content with STALE when lastModified is 1", async () => { + const event = createEvent({ + url: "/albums", + }); + cache.get.mockResolvedValueOnce({ + value: { + type: "page", + html: "Hello, world!", + }, + lastModified: 1, + }); + + 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 STALE when lastModified is 1", async () => { + const event = createEvent({ + url: "/albums", + }); + cache.get.mockResolvedValueOnce({ + value: { + type: "route", + body: "API response", + meta: { + status: 200, + headers: { "content-type": "text/plain" }, + }, + }, + lastModified: 1, + }); + + 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", + }), + }) + ); + }); + }); });