@opennextjs/aws 3.9.16 → 3.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/dist/adapters/cache.d.ts +3 -1
  2. package/dist/adapters/cache.js +75 -17
  3. package/dist/adapters/composable-cache.d.ts +12 -0
  4. package/dist/adapters/composable-cache.js +82 -2
  5. package/dist/adapters/dynamo-provider.js +2 -0
  6. package/dist/adapters/revalidate.js +2 -1
  7. package/dist/build/compileCache.js +2 -3
  8. package/dist/build/copyTracedFiles.d.ts +1 -0
  9. package/dist/build/copyTracedFiles.js +20 -0
  10. package/dist/build/createAssets.js +4 -1
  11. package/dist/build/helper.d.ts +2 -10
  12. package/dist/build/helper.js +1 -55
  13. package/dist/build/patch/patches/patchNextServer.d.ts +1 -0
  14. package/dist/build/patch/patches/patchNextServer.js +19 -0
  15. package/dist/core/routing/cacheInterceptor.js +2 -1
  16. package/dist/core/routing/i18n/index.js +4 -2
  17. package/dist/overrides/proxyExternalRequest/fetch.js +10 -1
  18. package/dist/overrides/tagCache/dummy.js +3 -0
  19. package/dist/overrides/tagCache/dynamodb-lite.js +94 -8
  20. package/dist/overrides/tagCache/dynamodb-nextMode.d.ts +2 -1
  21. package/dist/overrides/tagCache/dynamodb-nextMode.js +112 -27
  22. package/dist/overrides/tagCache/dynamodb.js +104 -22
  23. package/dist/overrides/tagCache/fs-dev-nextMode.d.ts +2 -1
  24. package/dist/overrides/tagCache/fs-dev-nextMode.js +37 -6
  25. package/dist/overrides/tagCache/fs-dev.js +40 -8
  26. package/dist/types/cache.d.ts +20 -0
  27. package/dist/types/global.d.ts +5 -2
  28. package/dist/types/next-types.d.ts +1 -0
  29. package/dist/types/overrides.d.ts +42 -1
  30. package/dist/utils/cache.d.ts +22 -2
  31. package/dist/utils/cache.js +44 -4
  32. package/dist/utils/promise.js +2 -0
  33. package/dist/utils/requestCache.d.ts +20 -0
  34. package/dist/utils/requestCache.js +27 -0
  35. package/dist/utils/semver.d.ts +10 -0
  36. package/dist/utils/semver.js +55 -0
  37. package/package.json +1 -1
@@ -39,12 +39,15 @@ function buildDynamoKey(key) {
39
39
  // this could transform some fetch cache key into a valid path
40
40
  return path.posix.join(NEXT_BUILD_ID ?? "", key);
41
41
  }
42
- function buildDynamoObject(path, tags, revalidatedAt) {
43
- return {
42
+ function buildDynamoObject(path, tags, revalidatedAt, stale, expire) {
43
+ const obj = {
44
44
  path: { S: buildDynamoKey(path) },
45
45
  tag: { S: buildDynamoKey(tags) },
46
46
  revalidatedAt: { N: `${revalidatedAt ?? Date.now()}` },
47
+ ...(stale !== undefined ? { stale: { N: `${stale}` } } : {}),
48
+ ...(expire !== undefined ? { expire: { N: `${expire}` } } : {}),
47
49
  };
50
+ return obj;
48
51
  }
49
52
  const tagCache = {
50
53
  mode: "original",
@@ -54,6 +57,11 @@ const tagCache = {
54
57
  return [];
55
58
  }
56
59
  const { CACHE_DYNAMO_TABLE, NEXT_BUILD_ID } = process.env;
60
+ const store = globalThis.__openNextAls.getStore();
61
+ const cache = store?.requestCache.getOrCreate("dynamoDb:getByPath");
62
+ if (cache?.has(path)) {
63
+ return cache.get(path);
64
+ }
57
65
  const result = await awsFetch(JSON.stringify({
58
66
  TableName: CACHE_DYNAMO_TABLE,
59
67
  IndexName: "revalidate",
@@ -72,7 +80,9 @@ const tagCache = {
72
80
  const tags = Items?.map((item) => item.tag.S ?? "") ?? [];
73
81
  debug("tags for path", path, tags);
74
82
  // We need to remove the buildId from the path
75
- return tags.map((tag) => tag.replace(`${NEXT_BUILD_ID}/`, ""));
83
+ const resultTags = tags.map((tag) => tag.replace(`${NEXT_BUILD_ID}/`, ""));
84
+ cache?.set(path, resultTags);
85
+ return resultTags;
76
86
  }
77
87
  catch (e) {
78
88
  error("Failed to get tags by path", e);
@@ -85,6 +95,11 @@ const tagCache = {
85
95
  return [];
86
96
  }
87
97
  const { CACHE_DYNAMO_TABLE, NEXT_BUILD_ID } = process.env;
98
+ const store = globalThis.__openNextAls.getStore();
99
+ const cache = store?.requestCache.getOrCreate("dynamoDb:getByTag");
100
+ if (cache?.has(tag)) {
101
+ return cache.get(tag);
102
+ }
88
103
  const result = await awsFetch(JSON.stringify({
89
104
  TableName: CACHE_DYNAMO_TABLE,
90
105
  KeyConditionExpression: "#tag = :tag",
@@ -99,9 +114,10 @@ const tagCache = {
99
114
  throw new RecoverableError(`Failed to get by tag: ${result.status}`);
100
115
  }
101
116
  const { Items } = (await result.json());
102
- return (
103
117
  // We need to remove the buildId from the path
104
- Items?.map(({ path: { S: key } }) => key?.replace(`${NEXT_BUILD_ID}/`, "") ?? "") ?? []);
118
+ const paths = Items?.map(({ path: { S: key } }) => key?.replace(`${NEXT_BUILD_ID}/`, "") ?? "") ?? [];
119
+ cache?.set(tag, paths);
120
+ return paths;
105
121
  }
106
122
  catch (e) {
107
123
  error("Failed to get by tag", e);
@@ -114,6 +130,12 @@ const tagCache = {
114
130
  return lastModified ?? Date.now();
115
131
  }
116
132
  const { CACHE_DYNAMO_TABLE } = process.env;
133
+ const store = globalThis.__openNextAls.getStore();
134
+ const cache = store?.requestCache.getOrCreate("dynamoDb:getLastModified");
135
+ const cacheKey = `${key}:${lastModified ?? 0}`;
136
+ if (cache?.has(cacheKey)) {
137
+ return cache.get(cacheKey);
138
+ }
117
139
  const result = await awsFetch(JSON.stringify({
118
140
  TableName: CACHE_DYNAMO_TABLE,
119
141
  IndexName: "revalidate",
@@ -132,14 +154,78 @@ const tagCache = {
132
154
  }
133
155
  const revalidatedTags = (await result.json()).Items ?? [];
134
156
  debug("revalidatedTags", revalidatedTags);
135
- // If we have revalidated tags we return -1 to force revalidation
136
- return revalidatedTags.length > 0 ? -1 : (lastModified ?? Date.now());
157
+ // Check if any tag has expired
158
+ const now = Date.now();
159
+ const hasExpiredTag = revalidatedTags.some((item) => {
160
+ if (item.expire?.N) {
161
+ const expiry = Number.parseInt(item.expire.N);
162
+ return expiry <= now && expiry > (lastModified ?? 0);
163
+ }
164
+ return false;
165
+ });
166
+ // Exclude expired tags from the revalidated count — they are handled
167
+ // separately via hasExpiredTag above.
168
+ const nonExpiredRevalidatedTags = revalidatedTags.filter((item) => {
169
+ if (item.expire?.N) {
170
+ return (Number.parseInt(item.expire.N) ===
171
+ Number.parseInt(item.revalidatedAt.N));
172
+ }
173
+ return true;
174
+ });
175
+ // If we have revalidated tags or expired tags we return -1 to force revalidation
176
+ const resultValue = nonExpiredRevalidatedTags.length > 0 || hasExpiredTag
177
+ ? -1
178
+ : (lastModified ?? Date.now());
179
+ cache?.set(cacheKey, resultValue);
180
+ return resultValue;
137
181
  }
138
182
  catch (e) {
139
183
  error("Failed to get revalidated tags", e);
140
184
  return lastModified ?? Date.now();
141
185
  }
142
186
  },
187
+ async isStale(key, lastModified) {
188
+ try {
189
+ if (globalThis.openNextConfig.dangerous?.disableTagCache) {
190
+ return false;
191
+ }
192
+ const { CACHE_DYNAMO_TABLE } = process.env;
193
+ const store = globalThis.__openNextAls.getStore();
194
+ const itemsCache = store?.requestCache.getOrCreate("dynamoDb:revalidateQueryItems");
195
+ const cacheKey = `${key}:${lastModified ?? 0}`;
196
+ let items;
197
+ if (itemsCache?.has(cacheKey)) {
198
+ items = itemsCache.get(cacheKey);
199
+ }
200
+ else {
201
+ // We can reuse the same query as getLastModified since it already checks for revalidatedAt > lastModified as revalidatedAt and stale have the same value
202
+ const result = await awsFetch(JSON.stringify({
203
+ TableName: CACHE_DYNAMO_TABLE,
204
+ IndexName: "revalidate",
205
+ KeyConditionExpression: "#key = :key AND #revalidatedAt > :lastModified",
206
+ ExpressionAttributeNames: {
207
+ "#key": "path",
208
+ "#revalidatedAt": "revalidatedAt",
209
+ },
210
+ ExpressionAttributeValues: {
211
+ ":key": { S: buildDynamoKey(key) },
212
+ ":lastModified": { N: String(lastModified ?? 0) },
213
+ },
214
+ }));
215
+ if (result.status !== 200) {
216
+ throw new RecoverableError(`Failed to check stale tags: ${result.status}`);
217
+ }
218
+ items = (await result.json()).Items ?? [];
219
+ itemsCache?.set(cacheKey, items);
220
+ }
221
+ debug("isStale items", key, items);
222
+ return items.length > 0;
223
+ }
224
+ catch (e) {
225
+ error("Failed to check stale tags", e);
226
+ return false;
227
+ }
228
+ },
143
229
  async writeTags(tags) {
144
230
  try {
145
231
  const { CACHE_DYNAMO_TABLE } = process.env;
@@ -151,7 +237,7 @@ const tagCache = {
151
237
  [CACHE_DYNAMO_TABLE ?? ""]: Items.map((Item) => ({
152
238
  PutRequest: {
153
239
  Item: {
154
- ...buildDynamoObject(Item.path, Item.tag, Item.revalidatedAt),
240
+ ...buildDynamoObject(Item.path, Item.tag, Item.revalidatedAt, Item.stale, Item.expire),
155
241
  },
156
242
  },
157
243
  })),
@@ -3,6 +3,7 @@ declare const _default: {
3
3
  mode: "nextMode";
4
4
  getLastRevalidated: (tags: string[]) => Promise<number>;
5
5
  hasBeenRevalidated: (tags: string[], lastModified?: number) => Promise<boolean>;
6
- writeTags: (tags: string[]) => Promise<void>;
6
+ isStale: (tags: string[], lastModified?: number) => Promise<boolean>;
7
+ writeTags: (tags: import("../../types/overrides").NextModeTagCacheWriteInput[]) => Promise<void>;
7
8
  };
8
9
  export default _default;
@@ -41,14 +41,70 @@ function buildDynamoKey(key) {
41
41
  // We use the same key for both path and tag
42
42
  // That's mostly for compatibility reason so that it's easier to use this with existing infra
43
43
  // FIXME: Allow a simpler object without an unnecessary path key
44
- function buildDynamoObject(tag, revalidatedAt) {
45
- return {
44
+ function buildDynamoObject(tag, revalidatedAt, stale, expire) {
45
+ const obj = {
46
46
  path: { S: buildDynamoKey(tag) },
47
47
  tag: { S: buildDynamoKey(tag) },
48
48
  revalidatedAt: { N: `${revalidatedAt ?? Date.now()}` },
49
+ ...(stale !== undefined ? { stale: { N: `${stale}` } } : {}),
50
+ ...(expire !== undefined ? { expire: { N: `${expire}` } } : {}),
49
51
  };
52
+ return obj;
50
53
  }
51
54
  // This implementation does not support automatic invalidation of paths by the cdn
55
+ /**
56
+ * Checks the items cache for each tag. Returns tags not yet cached and whether
57
+ * a positive result was already found among the cached ones.
58
+ */
59
+ function checkItemsCache(tags, itemsCache, compute) {
60
+ const uncachedTags = [];
61
+ let hasMatch = false;
62
+ for (const tag of tags) {
63
+ if (itemsCache?.has(tag)) {
64
+ if (compute(itemsCache.get(tag)))
65
+ hasMatch = true;
66
+ }
67
+ else {
68
+ uncachedTags.push(tag);
69
+ }
70
+ }
71
+ return { uncachedTags, hasMatch };
72
+ }
73
+ /**
74
+ * Fetches uncached tags from DynamoDB via BatchGetItem, populates the items
75
+ * cache (storing null for absent tags), and returns whether any tag matched.
76
+ */
77
+ async function fetchAndCacheItems(uncachedTags, itemsCache, compute) {
78
+ const { CACHE_DYNAMO_TABLE } = process.env;
79
+ const response = await awsFetch(JSON.stringify({
80
+ RequestItems: {
81
+ [CACHE_DYNAMO_TABLE ?? ""]: {
82
+ Keys: uncachedTags.map((tag) => ({
83
+ path: { S: buildDynamoKey(tag) },
84
+ tag: { S: buildDynamoKey(tag) },
85
+ })),
86
+ },
87
+ },
88
+ }), "query");
89
+ if (response.status !== 200) {
90
+ throw new RecoverableError(`Failed to query dynamo item: ${response.status}`);
91
+ }
92
+ const { Responses } = await response.json();
93
+ const responseItems = Responses?.[CACHE_DYNAMO_TABLE ?? ""] ?? [];
94
+ // Build a lookup map: DynamoDB key → item
95
+ const responseByKey = new Map();
96
+ for (const item of responseItems) {
97
+ responseByKey.set(item.tag.S, item);
98
+ }
99
+ let hasMatch = false;
100
+ for (const tag of uncachedTags) {
101
+ const item = responseByKey.get(buildDynamoKey(tag)) ?? null;
102
+ itemsCache?.set(tag, item);
103
+ if (compute(item))
104
+ hasMatch = true;
105
+ }
106
+ return hasMatch;
107
+ }
52
108
  export default {
53
109
  name: "ddb-nextMode",
54
110
  mode: "nextMode",
@@ -63,30 +119,54 @@ export default {
63
119
  if (tags.length > 100) {
64
120
  throw new RecoverableError("Cannot query more than 100 tags at once. You should not be using this tagCache implementation for this amount of tags");
65
121
  }
66
- const { CACHE_DYNAMO_TABLE } = process.env;
122
+ const store = globalThis.__openNextAls.getStore();
123
+ const itemsCache = store?.requestCache.getOrCreate("ddb-nextMode:tagItems");
124
+ const now = Date.now();
125
+ const compute = (item) => {
126
+ if (!item)
127
+ return false;
128
+ if (item.expire?.N) {
129
+ const expiry = Number.parseInt(item.expire.N);
130
+ if (expiry <= now && expiry > (lastModified ?? 0))
131
+ return true;
132
+ }
133
+ return Number.parseInt(item.revalidatedAt.N) > (lastModified ?? 0);
134
+ };
135
+ const { uncachedTags, hasMatch } = checkItemsCache(tags, itemsCache, compute);
136
+ if (hasMatch)
137
+ return true;
138
+ if (uncachedTags.length === 0)
139
+ return false;
67
140
  // It's unlikely that we will have more than 100 items to query
68
141
  // If that's the case, you should not use this tagCache implementation
69
- const response = await awsFetch(JSON.stringify({
70
- RequestItems: {
71
- [CACHE_DYNAMO_TABLE ?? ""]: {
72
- Keys: tags.map((tag) => ({
73
- path: { S: buildDynamoKey(tag) },
74
- tag: { S: buildDynamoKey(tag) },
75
- })),
76
- },
77
- },
78
- }), "query");
79
- if (response.status !== 200) {
80
- throw new RecoverableError(`Failed to query dynamo item: ${response.status}`);
142
+ const result = await fetchAndCacheItems(uncachedTags, itemsCache, compute);
143
+ debug("retrieved tags for hasBeenRevalidated", tags);
144
+ return result;
145
+ },
146
+ isStale: async (tags, lastModified) => {
147
+ if (globalThis.openNextConfig.dangerous?.disableTagCache) {
148
+ return false;
81
149
  }
82
- // Now we need to check for every item if lastModified is greater than the revalidatedAt
83
- const { Responses } = await response.json();
84
- if (!Responses) {
150
+ if (tags.length === 0)
85
151
  return false;
152
+ if (tags.length > 100) {
153
+ throw new RecoverableError("Cannot query more than 100 tags at once. You should not be using this tagCache implementation for this amount of tags");
86
154
  }
87
- const revalidatedTags = Responses[CACHE_DYNAMO_TABLE ?? ""].filter((item) => Number.parseInt(item.revalidatedAt.N) > (lastModified ?? 0));
88
- debug("retrieved tags", revalidatedTags);
89
- return revalidatedTags.length > 0;
155
+ const store = globalThis.__openNextAls.getStore();
156
+ const itemsCache = store?.requestCache.getOrCreate("ddb-nextMode:tagItems");
157
+ const compute = (item) => {
158
+ if (!item?.stale?.N)
159
+ return false;
160
+ return Number.parseInt(item.stale.N) > (lastModified ?? 0);
161
+ };
162
+ const { uncachedTags, hasMatch } = checkItemsCache(tags, itemsCache, compute);
163
+ if (hasMatch)
164
+ return true;
165
+ if (uncachedTags.length === 0)
166
+ return false;
167
+ const result = await fetchAndCacheItems(uncachedTags, itemsCache, compute);
168
+ debug("isStale result:", result);
169
+ return result;
90
170
  },
91
171
  writeTags: async (tags) => {
92
172
  try {
@@ -96,13 +176,18 @@ export default {
96
176
  }
97
177
  const dataChunks = chunk(tags, MAX_DYNAMO_BATCH_WRITE_ITEM_COUNT).map((Items) => ({
98
178
  RequestItems: {
99
- [CACHE_DYNAMO_TABLE ?? ""]: Items.map((tag) => ({
100
- PutRequest: {
101
- Item: {
102
- ...buildDynamoObject(tag),
179
+ [CACHE_DYNAMO_TABLE ?? ""]: Items.map((tag) => {
180
+ const tagStr = typeof tag === "string" ? tag : tag.tag;
181
+ const stale = typeof tag === "string" ? undefined : tag.stale;
182
+ const expiry = typeof tag === "string" ? undefined : tag.expire;
183
+ return {
184
+ PutRequest: {
185
+ Item: {
186
+ ...buildDynamoObject(tagStr, undefined, stale, expiry),
187
+ },
103
188
  },
104
- },
105
- })),
189
+ };
190
+ }),
106
191
  },
107
192
  }));
108
193
  const toInsert = chunk(dataChunks, getDynamoBatchWriteCommandConcurrency());
@@ -17,12 +17,15 @@ function buildDynamoKey(key) {
17
17
  // this could transform some fetch cache key into a valid path
18
18
  return path.posix.join(NEXT_BUILD_ID ?? "", key);
19
19
  }
20
- function buildDynamoObject(path, tags, revalidatedAt) {
21
- return {
20
+ function buildDynamoObject(path, tags, revalidatedAt, stale, expire) {
21
+ const obj = {
22
22
  path: { S: buildDynamoKey(path) },
23
23
  tag: { S: buildDynamoKey(tags) },
24
24
  revalidatedAt: { N: `${revalidatedAt ?? Date.now()}` },
25
+ ...(stale !== undefined ? { stale: { N: `${stale}` } } : {}),
26
+ ...(expire !== undefined ? { expire: { N: `${expire}` } } : {}),
25
27
  };
28
+ return obj;
26
29
  }
27
30
  const tagCache = {
28
31
  mode: "original",
@@ -31,6 +34,11 @@ const tagCache = {
31
34
  if (globalThis.openNextConfig.dangerous?.disableTagCache) {
32
35
  return [];
33
36
  }
37
+ const store = globalThis.__openNextAls.getStore();
38
+ const cache = store?.requestCache.getOrCreate("dynamoDb:getByPath");
39
+ if (cache?.has(path)) {
40
+ return cache.get(path);
41
+ }
34
42
  const result = await dynamoClient.send(new QueryCommand({
35
43
  TableName: CACHE_DYNAMO_TABLE,
36
44
  IndexName: "revalidate",
@@ -45,7 +53,9 @@ const tagCache = {
45
53
  const tags = result.Items?.map((item) => item.tag.S ?? "") ?? [];
46
54
  debug("tags for path", path, tags);
47
55
  // We need to remove the buildId from the path
48
- return tags.map((tag) => tag.replace(`${NEXT_BUILD_ID}/`, ""));
56
+ const resultTags = tags.map((tag) => tag.replace(`${NEXT_BUILD_ID}/`, ""));
57
+ cache?.set(path, resultTags);
58
+ return resultTags;
49
59
  }
50
60
  catch (e) {
51
61
  error("Failed to get tags by path", e);
@@ -57,6 +67,11 @@ const tagCache = {
57
67
  if (globalThis.openNextConfig.dangerous?.disableTagCache) {
58
68
  return [];
59
69
  }
70
+ const store = globalThis.__openNextAls.getStore();
71
+ const cache = store?.requestCache.getOrCreate("dynamoDb:getByTag");
72
+ if (cache?.has(tag)) {
73
+ return cache.get(tag);
74
+ }
60
75
  const { Items } = await dynamoClient.send(new QueryCommand({
61
76
  TableName: CACHE_DYNAMO_TABLE,
62
77
  KeyConditionExpression: "#tag = :tag",
@@ -67,9 +82,10 @@ const tagCache = {
67
82
  ":tag": { S: buildDynamoKey(tag) },
68
83
  },
69
84
  }));
70
- return (
71
85
  // We need to remove the buildId from the path
72
- Items?.map(({ path: { S: key } }) => key?.replace(`${NEXT_BUILD_ID}/`, "") ?? "") ?? []);
86
+ const paths = Items?.map(({ path: { S: key } }) => key?.replace(`${NEXT_BUILD_ID}/`, "") ?? "") ?? [];
87
+ cache?.set(tag, paths);
88
+ return paths;
73
89
  }
74
90
  catch (e) {
75
91
  error("Failed to get by tag", e);
@@ -81,29 +97,95 @@ const tagCache = {
81
97
  if (globalThis.openNextConfig.dangerous?.disableTagCache) {
82
98
  return lastModified ?? Date.now();
83
99
  }
84
- const result = await dynamoClient.send(new QueryCommand({
85
- TableName: CACHE_DYNAMO_TABLE,
86
- IndexName: "revalidate",
87
- KeyConditionExpression: "#key = :key AND #revalidatedAt > :lastModified",
88
- ExpressionAttributeNames: {
89
- "#key": "path",
90
- "#revalidatedAt": "revalidatedAt",
91
- },
92
- ExpressionAttributeValues: {
93
- ":key": { S: buildDynamoKey(key) },
94
- ":lastModified": { N: String(lastModified ?? 0) },
95
- },
96
- }));
97
- const revalidatedTags = result.Items ?? [];
100
+ const store = globalThis.__openNextAls.getStore();
101
+ const itemsCache = store?.requestCache.getOrCreate("dynamoDb:revalidateQueryItems");
102
+ const cacheKey = `${key}:${lastModified ?? 0}`;
103
+ let revalidatedTags;
104
+ if (itemsCache?.has(cacheKey)) {
105
+ revalidatedTags = itemsCache.get(cacheKey);
106
+ }
107
+ else {
108
+ const result = await dynamoClient.send(new QueryCommand({
109
+ TableName: CACHE_DYNAMO_TABLE,
110
+ IndexName: "revalidate",
111
+ KeyConditionExpression: "#key = :key AND #revalidatedAt > :lastModified",
112
+ ExpressionAttributeNames: {
113
+ "#key": "path",
114
+ "#revalidatedAt": "revalidatedAt",
115
+ },
116
+ ExpressionAttributeValues: {
117
+ ":key": { S: buildDynamoKey(key) },
118
+ ":lastModified": { N: String(lastModified ?? 0) },
119
+ },
120
+ }));
121
+ revalidatedTags = result.Items ?? [];
122
+ itemsCache?.set(cacheKey, revalidatedTags);
123
+ }
98
124
  debug("revalidatedTags", revalidatedTags);
99
- // If we have revalidated tags we return -1 to force revalidation
100
- return revalidatedTags.length > 0 ? -1 : (lastModified ?? Date.now());
125
+ // Check if any tag has expired
126
+ const now = Date.now();
127
+ const hasExpiredTag = revalidatedTags.some((item) => {
128
+ if (item.expire?.N) {
129
+ const expiry = Number.parseInt(item.expire.N);
130
+ return expiry <= now && expiry > (lastModified ?? 0);
131
+ }
132
+ return false;
133
+ });
134
+ // Exclude expired tags from the revalidated count — they are handled
135
+ // separately via hasExpiredTag above.
136
+ const nonExpiredRevalidatedTags = revalidatedTags.filter((item) => {
137
+ if (item.expire?.N) {
138
+ return Number.parseInt(item.expire.N) > now;
139
+ }
140
+ return true;
141
+ });
142
+ // If we have revalidated tags or expired tags we return -1 to force revalidation
143
+ return nonExpiredRevalidatedTags.length > 0 || hasExpiredTag
144
+ ? -1
145
+ : (lastModified ?? Date.now());
101
146
  }
102
147
  catch (e) {
103
148
  error("Failed to get revalidated tags", e);
104
149
  return lastModified ?? Date.now();
105
150
  }
106
151
  },
152
+ async isStale(key, lastModified) {
153
+ try {
154
+ if (globalThis.openNextConfig.dangerous?.disableTagCache) {
155
+ return false;
156
+ }
157
+ const store = globalThis.__openNextAls.getStore();
158
+ const itemsCache = store?.requestCache.getOrCreate("dynamoDb:revalidateQueryItems");
159
+ const cacheKey = `${key}:${lastModified ?? 0}`;
160
+ let items;
161
+ if (itemsCache?.has(cacheKey)) {
162
+ items = itemsCache.get(cacheKey);
163
+ }
164
+ else {
165
+ const result = await dynamoClient.send(new QueryCommand({
166
+ TableName: CACHE_DYNAMO_TABLE,
167
+ IndexName: "revalidate",
168
+ KeyConditionExpression: "#key = :key AND #revalidatedAt > :lastModified",
169
+ ExpressionAttributeNames: {
170
+ "#key": "path",
171
+ "#revalidatedAt": "revalidatedAt",
172
+ },
173
+ ExpressionAttributeValues: {
174
+ ":key": { S: buildDynamoKey(key) },
175
+ ":lastModified": { N: String(lastModified ?? 0) },
176
+ },
177
+ }));
178
+ items = result.Items ?? [];
179
+ itemsCache?.set(cacheKey, items);
180
+ }
181
+ debug("isStale items", key, items);
182
+ return items.length > 0;
183
+ }
184
+ catch (e) {
185
+ error("Failed to check stale tags", e);
186
+ return false;
187
+ }
188
+ },
107
189
  async writeTags(tags) {
108
190
  try {
109
191
  if (globalThis.openNextConfig.dangerous?.disableTagCache) {
@@ -114,7 +196,7 @@ const tagCache = {
114
196
  [CACHE_DYNAMO_TABLE ?? ""]: Items.map((Item) => ({
115
197
  PutRequest: {
116
198
  Item: {
117
- ...buildDynamoObject(Item.path, Item.tag, Item.revalidatedAt),
199
+ ...buildDynamoObject(Item.path, Item.tag, Item.revalidatedAt, Item.stale, Item.expire),
118
200
  },
119
201
  },
120
202
  })),
@@ -3,6 +3,7 @@ declare const _default: {
3
3
  mode: "nextMode";
4
4
  getLastRevalidated: (tags: string[]) => Promise<number>;
5
5
  hasBeenRevalidated: (tags: string[], lastModified?: number) => Promise<boolean>;
6
- writeTags: (tags: string[]) => Promise<void>;
6
+ isStale: (tags: string[], lastModified?: number) => Promise<boolean>;
7
+ writeTags: (tags: import("../../types/overrides").NextModeTagCacheWriteInput[]) => Promise<void>;
7
8
  };
8
9
  export default _default;
@@ -9,9 +9,9 @@ export default {
9
9
  }
10
10
  let lastRevalidated = 0;
11
11
  tags.forEach((tag) => {
12
- const tagTime = tagsMap.get(tag);
13
- if (tagTime && tagTime > lastRevalidated) {
14
- lastRevalidated = tagTime;
12
+ const tagData = tagsMap.get(tag);
13
+ if (tagData && tagData.revalidatedAt > lastRevalidated) {
14
+ lastRevalidated = tagData.revalidatedAt;
15
15
  }
16
16
  });
17
17
  debug("getLastRevalidated result:", lastRevalidated);
@@ -21,13 +21,37 @@ export default {
21
21
  if (globalThis.openNextConfig.dangerous?.disableTagCache) {
22
22
  return false;
23
23
  }
24
+ const now = Date.now();
24
25
  const hasRevalidatedTag = tags.some((tag) => {
25
- const tagRevalidatedAt = tagsMap.get(tag);
26
- return tagRevalidatedAt ? tagRevalidatedAt > (lastModified ?? 0) : false;
26
+ const tagData = tagsMap.get(tag);
27
+ if (!tagData) {
28
+ return false;
29
+ }
30
+ // Check if tag has expired
31
+ if (typeof tagData.expire === "number") {
32
+ const isExpired = tagData.expire <= now && tagData.expire > (lastModified ?? 0);
33
+ return isExpired;
34
+ }
35
+ // Check if tag has been revalidated
36
+ return tagData.revalidatedAt > (lastModified ?? 0);
27
37
  });
28
38
  debug("hasBeenRevalidated result:", hasRevalidatedTag);
29
39
  return hasRevalidatedTag;
30
40
  },
41
+ isStale: async (tags, lastModified) => {
42
+ if (globalThis.openNextConfig.dangerous?.disableTagCache) {
43
+ return false;
44
+ }
45
+ const hasStaleTag = tags.some((tag) => {
46
+ const tagData = tagsMap.get(tag);
47
+ if (!tagData || typeof tagData.stale !== "number") {
48
+ return false;
49
+ }
50
+ return tagData.stale > (lastModified ?? 0);
51
+ });
52
+ debug("isStale result:", hasStaleTag);
53
+ return hasStaleTag;
54
+ },
31
55
  writeTags: async (tags) => {
32
56
  if (globalThis.openNextConfig.dangerous?.disableTagCache ||
33
57
  tags.length === 0) {
@@ -35,7 +59,14 @@ export default {
35
59
  }
36
60
  debug("writeTags", { tags: tags });
37
61
  tags.forEach((tag) => {
38
- tagsMap.set(tag, Date.now());
62
+ const tagStr = typeof tag === "string" ? tag : tag.tag;
63
+ const stale = typeof tag === "string" ? undefined : tag.stale;
64
+ const expire = typeof tag === "string" ? undefined : tag.expire;
65
+ tagsMap.set(tagStr, {
66
+ revalidatedAt: Date.now(),
67
+ stale,
68
+ expire,
69
+ });
39
70
  });
40
71
  debug("writeTags completed, written", tags.length, "tags");
41
72
  },