Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
fa74e70
refactor: remove createServerBundle.ts and integrate its functionalit…
conico974 Jun 27, 2026
33a6eff
feat: implement default overrides for bundle configurations and add t…
conico974 Jun 27, 2026
fe002e1
format
conico974 Jun 27, 2026
9ee9515
fix ts issue
conico974 Jun 27, 2026
6fad0c6
feat: add default overrides for AWS adapter and integrate Cloudflare …
conico974 Jun 27, 2026
84397ab
Revert "feat: add default overrides for AWS adapter and integrate Clo…
conico974 Jun 27, 2026
f22d37f
feat: enhance AWS adapter with default overrides and improve middlewa…
conico974 Jun 27, 2026
a3165f0
refactor(core): return ValidateConfigResult from validateConfig inste…
conico974 Jun 28, 2026
6296e94
refactor(core): extract buildOpenNextOutput, export OpenNextOutput type
conico974 Jun 28, 2026
4e82821
refactor(core): branch on ValidateConfigResult in compileOpenNextConfig
conico974 Jun 28, 2026
0af2e73
feat(core): overridable validateConfig and generic generateOutput on …
conico974 Jun 28, 2026
bab47f1
fix(core): use require.resolve for full-path overrides in resolve plugin
conico974 Jun 28, 2026
51b5e3b
format
conico974 Jun 28, 2026
a437de1
feat(core): enforce mandatory externals in serverBundle and update re…
conico974 Aug 1, 2026
cd8f83e
refactor(core): enhance resolve plugin to support dynamic override re…
conico974 Aug 1, 2026
c200d7a
feat(core): run the cache in a dedicated handler function
conico974 Aug 16, 2026
8b63860
feat(cloudflare): add the OpenNextCache entrypoint
conico974 Aug 16, 2026
dc444fc
refactor(core)!: route all caching through the cache override
conico974 Aug 16, 2026
0799572
feat: port stale-while-revalidate tag revalidation from AWS
conico974 Aug 16, 2026
a5bdec3
Merge remote-tracking branch 'origin/main' into vicb/review-pr-46
vicb Sep 24, 2026
f4c250a
fix(core): restore composable cache stale handling
vicb Sep 24, 2026
e1b99d6
fix(core): restore stale tag cache reads
vicb Sep 24, 2026
621c25b
fix(core): propagate tag revalidation durations
vicb Sep 24, 2026
9c73819
fix(aws): preserve DynamoDB SWR entries before expiry
vicb Sep 24, 2026
f4c03c2
fix(cache): preserve next-mode SWR entries before expiry
vicb Sep 24, 2026
7570c31
fix(core): preserve filesystem SWR entries before expiry
vicb Sep 24, 2026
08b80e1
perf(aws): cache missing next-mode tags per request
vicb Sep 24, 2026
3becbc0
fix(cache): support SWR tags without expiry
vicb Sep 24, 2026
82d483d
fix(core): validate tag revalidation durations
vicb Sep 24, 2026
07d8931
style: format cache review fixes
vicb Sep 24, 2026
1d60cf8
fix(aws): preserve lightweight SWR entries without expiry
vicb Sep 24, 2026
cfae0bf
fix(cache): ignore superseded SWR expirations
vicb Sep 24, 2026
89a0704
fix(cache): preserve timestamps when serving stale entries
vicb Sep 28, 2026
966bbec
fix(aws): classify stale tag records explicitly
vicb Sep 28, 2026
fadd91f
fix(cloudflare): preserve string tag filter callbacks
vicb Sep 28, 2026
43ede0e
fix(cloudflare): forward filtered tag staleness checks
vicb Sep 28, 2026
938b9f7
fix(core): scope standalone cache reads to a request
vicb Sep 28, 2026
65bde83
fix(aws): retry unprocessed tag cache reads
vicb Sep 28, 2026
f698072
fix(core): inherit owning-path staleness for fetch entries
vicb Sep 28, 2026
6f7de8d
fix(cloudflare): reject unsupported tag SWR
vicb Oct 1, 2026
cd54cf3
style: format tag staleness regression test
vicb Oct 1, 2026
979123e
fix(cache): surface unsupported SWR through Next
vicb Oct 1, 2026
3f25aac
fix(cache): keep zero-expiry invalidations hard
vicb Oct 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .changeset/swr-tag-revalidation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
"@opennextjs/core": minor
"@opennextjs/aws": minor
Comment thread
vicb marked this conversation as resolved.
---

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.
8 changes: 2 additions & 6 deletions examples-cloudflare/e2e/experimental/e2e/use-cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
135 changes: 125 additions & 10 deletions packages/aws/src/overrides/tagCache/dynamodb-lite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
};
Expand Down Expand Up @@ -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}` } } : {}),
};
}

Expand All @@ -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<string, string[]>("dynamoDb:getByPath");
if (cache?.has(path)) {
return cache.get(path)!;
}
const result = await awsFetch(
JSON.stringify({
TableName: CACHE_DYNAMO_TABLE,
Expand All @@ -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 [];
Expand All @@ -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<string, string[]>("dynamoDb:getByTag");
if (cache?.has(tag)) {
return cache.get(tag)!;
}
const result = await awsFetch(
JSON.stringify({
TableName: CACHE_DYNAMO_TABLE,
Expand All @@ -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<string, DynamoDBItem[]>("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,
Expand All @@ -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<string, DynamoDBItem[]>(
"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) {
Expand All @@ -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),
},
},
})),
Expand Down
Loading
Loading