@opennextjs/cloudflare 1.18.0 → 1.19.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.
- package/dist/api/durable-objects/sharded-tag-cache.d.ts +35 -1
- package/dist/api/durable-objects/sharded-tag-cache.js +85 -21
- package/dist/api/overrides/tag-cache/d1-next-tag-cache.d.ts +20 -2
- package/dist/api/overrides/tag-cache/d1-next-tag-cache.js +101 -19
- package/dist/api/overrides/tag-cache/do-sharded-tag-cache.d.ts +15 -7
- package/dist/api/overrides/tag-cache/do-sharded-tag-cache.js +134 -62
- package/dist/api/overrides/tag-cache/kv-next-tag-cache.d.ts +15 -2
- package/dist/api/overrides/tag-cache/kv-next-tag-cache.js +107 -10
- package/dist/api/overrides/tag-cache/tag-cache-filter.d.ts +3 -3
- package/dist/api/overrides/tag-cache/tag-cache-filter.js +4 -1
- package/dist/cli/build/patches/ast/patch-vercel-og-library.js +5 -0
- package/dist/cli/build/patches/plugins/dynamic-requires.d.ts +1 -0
- package/dist/cli/build/patches/plugins/dynamic-requires.js +9 -3
- package/dist/cli/build/patches/plugins/load-manifest.js +4 -1
- package/dist/cli/build/patches/plugins/next-server.js +1 -0
- package/dist/cli/build/patches/plugins/route-module.js +4 -1
- package/dist/cli/commands/populate-cache.js +20 -1
- package/package.json +6 -6
|
@@ -1,9 +1,43 @@
|
|
|
1
1
|
import { DurableObject } from "cloudflare:workers";
|
|
2
|
+
export type TagData = {
|
|
3
|
+
revalidatedAt: number;
|
|
4
|
+
stale: number | null;
|
|
5
|
+
expire: number | null;
|
|
6
|
+
};
|
|
2
7
|
export declare class DOShardedTagCache extends DurableObject<CloudflareEnv> {
|
|
3
8
|
sql: SqlStorage;
|
|
4
9
|
constructor(state: DurableObjectState, env: CloudflareEnv);
|
|
10
|
+
getTagData(tags: string[]): Promise<Record<string, TagData>>;
|
|
11
|
+
/**
|
|
12
|
+
* @deprecated since v1.19.
|
|
13
|
+
*
|
|
14
|
+
* Use `getTagData` instead - no processing should be done in the DO ao allow using the regional cache to cache all the values
|
|
15
|
+
* for a given tag using a single key.
|
|
16
|
+
*
|
|
17
|
+
* Kept for backward compatibility during rolling deploys.
|
|
18
|
+
*/
|
|
5
19
|
getLastRevalidated(tags: string[]): Promise<number>;
|
|
20
|
+
/**
|
|
21
|
+
* @deprecated since v1.19.
|
|
22
|
+
*
|
|
23
|
+
* Use `getTagData` instead - no processing should be done in the DO ao allow using the regional cache to cache all the values
|
|
24
|
+
* for a given tag using a single key.
|
|
25
|
+
*
|
|
26
|
+
* Kept for backward compatibility during rolling deploys.
|
|
27
|
+
*/
|
|
6
28
|
hasBeenRevalidated(tags: string[], lastModified?: number): Promise<boolean>;
|
|
7
|
-
|
|
29
|
+
/**
|
|
30
|
+
* @deprecated since v1.19.
|
|
31
|
+
*
|
|
32
|
+
* Use `getTagData` instead - no processing should be done in the DO ao allow using the regional cache to cache all the values
|
|
33
|
+
* for a given tag using a single key.
|
|
34
|
+
*
|
|
35
|
+
* Kept for backward compatibility during rolling deploys.
|
|
36
|
+
*/
|
|
8
37
|
getRevalidationTimes(tags: string[]): Promise<Record<string, number>>;
|
|
38
|
+
writeTags(tags: Array<string | {
|
|
39
|
+
tag: string;
|
|
40
|
+
stale?: number;
|
|
41
|
+
expire?: number | null;
|
|
42
|
+
}>, lastModified?: number): Promise<void>;
|
|
9
43
|
}
|
|
@@ -6,41 +6,105 @@ export class DOShardedTagCache extends DurableObject {
|
|
|
6
6
|
super(state, env);
|
|
7
7
|
this.sql = state.storage.sql;
|
|
8
8
|
state.blockConcurrencyWhile(async () => {
|
|
9
|
-
|
|
9
|
+
// Columns:
|
|
10
|
+
// tag - The cache tag.
|
|
11
|
+
// revalidatedAt - Timestamp (ms) when the tag was last revalidated.
|
|
12
|
+
// stale - Timestamp (ms) when the cached entry becomes stale. Added in v1.19.
|
|
13
|
+
// expire - Timestamp (ms) when the cached entry expires. NULL means no expire. Added in v1.19.
|
|
14
|
+
this.sql.exec(`CREATE TABLE IF NOT EXISTS revalidations (tag TEXT PRIMARY KEY, revalidatedAt INTEGER, stale INTEGER, expire INTEGER DEFAULT NULL)`);
|
|
15
|
+
// Schema migration: Add `stale` and `expire` columns for existing DO - those have been introduced to support SWR in v1.19
|
|
16
|
+
try {
|
|
17
|
+
// SQLite does not support adding multiple columns in a single ALTER TABLE statement.
|
|
18
|
+
this.sql.exec(`ALTER TABLE revalidations ADD COLUMN stale INTEGER; ALTER TABLE revalidations ADD COLUMN expire INTEGER DEFAULT NULL`);
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
// The ALTER TABLE statement fails if the columns already exist.
|
|
22
|
+
// It only means the DO has already been migrated.
|
|
23
|
+
}
|
|
10
24
|
});
|
|
11
25
|
}
|
|
12
|
-
async
|
|
26
|
+
async getTagData(tags) {
|
|
27
|
+
if (tags.length === 0)
|
|
28
|
+
return {};
|
|
13
29
|
try {
|
|
14
30
|
const result = this.sql
|
|
15
|
-
.exec(`SELECT
|
|
31
|
+
.exec(`SELECT tag, revalidatedAt, stale, expire FROM revalidations WHERE tag IN (${tags.map(() => "?").join(", ")})`, ...tags)
|
|
16
32
|
.toArray();
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
33
|
+
debugCache("DOShardedTagCache", `getTagData tags=${tags} -> ${result.length} results`);
|
|
34
|
+
return Object.fromEntries(result.map((row) => [
|
|
35
|
+
row.tag,
|
|
36
|
+
{
|
|
37
|
+
revalidatedAt: (row.revalidatedAt ?? 0),
|
|
38
|
+
stale: (row.stale ?? null),
|
|
39
|
+
expire: (row.expire ?? null),
|
|
40
|
+
},
|
|
41
|
+
]));
|
|
20
42
|
}
|
|
21
43
|
catch (e) {
|
|
22
44
|
console.error(e);
|
|
23
|
-
|
|
24
|
-
return 0;
|
|
45
|
+
return {};
|
|
25
46
|
}
|
|
26
47
|
}
|
|
48
|
+
/**
|
|
49
|
+
* @deprecated since v1.19.
|
|
50
|
+
*
|
|
51
|
+
* Use `getTagData` instead - no processing should be done in the DO ao allow using the regional cache to cache all the values
|
|
52
|
+
* for a given tag using a single key.
|
|
53
|
+
*
|
|
54
|
+
* Kept for backward compatibility during rolling deploys.
|
|
55
|
+
*/
|
|
56
|
+
async getLastRevalidated(tags) {
|
|
57
|
+
const data = await this.getTagData(tags);
|
|
58
|
+
const values = Object.values(data);
|
|
59
|
+
const timeMs = values.length === 0 ? 0 : Math.max(...values.map(({ revalidatedAt }) => revalidatedAt));
|
|
60
|
+
debugCache("DOShardedTagCache", `getLastRevalidated tags=${tags} -> time=${timeMs}`);
|
|
61
|
+
return timeMs;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* @deprecated since v1.19.
|
|
65
|
+
*
|
|
66
|
+
* Use `getTagData` instead - no processing should be done in the DO ao allow using the regional cache to cache all the values
|
|
67
|
+
* for a given tag using a single key.
|
|
68
|
+
*
|
|
69
|
+
* Kept for backward compatibility during rolling deploys.
|
|
70
|
+
*/
|
|
27
71
|
async hasBeenRevalidated(tags, lastModified) {
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
72
|
+
const data = await this.getTagData(tags);
|
|
73
|
+
const lastModifiedOrNowMs = lastModified ?? Date.now();
|
|
74
|
+
const revalidated = Object.values(data).some(({ revalidatedAt }) => revalidatedAt > lastModifiedOrNowMs);
|
|
31
75
|
debugCache("DOShardedTagCache", `hasBeenRevalidated tags=${tags} -> revalidated=${revalidated}`);
|
|
32
76
|
return revalidated;
|
|
33
77
|
}
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
78
|
+
/**
|
|
79
|
+
* @deprecated since v1.19.
|
|
80
|
+
*
|
|
81
|
+
* Use `getTagData` instead - no processing should be done in the DO ao allow using the regional cache to cache all the values
|
|
82
|
+
* for a given tag using a single key.
|
|
83
|
+
*
|
|
84
|
+
* Kept for backward compatibility during rolling deploys.
|
|
85
|
+
*/
|
|
40
86
|
async getRevalidationTimes(tags) {
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
87
|
+
const data = await this.getTagData(tags);
|
|
88
|
+
return Object.fromEntries(Object.entries(data).map(([tag, { revalidatedAt }]) => [tag, revalidatedAt]));
|
|
89
|
+
}
|
|
90
|
+
async writeTags(tags, lastModified) {
|
|
91
|
+
if (tags.length === 0)
|
|
92
|
+
return;
|
|
93
|
+
const nowMs = lastModified ?? Date.now();
|
|
94
|
+
debugCache("DOShardedTagCache", `writeTags tags=${JSON.stringify(tags)} time=${nowMs}`);
|
|
95
|
+
if (typeof tags[0] === "string") {
|
|
96
|
+
// Old call format: writeTags(tags: string[], lastModified: number)
|
|
97
|
+
for (const tag of tags) {
|
|
98
|
+
// `expire` defaults to `NULL`
|
|
99
|
+
this.sql.exec(`INSERT OR REPLACE INTO revalidations (tag, revalidatedAt, stale) VALUES (?, ?, ?)`, tag, nowMs, nowMs);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
// New call format: writeTags(tags: Array<{ tag, stale?, expire? }>)
|
|
104
|
+
for (const entry of tags) {
|
|
105
|
+
const staleValue = entry.stale ?? nowMs;
|
|
106
|
+
this.sql.exec(`INSERT OR REPLACE INTO revalidations (tag, revalidatedAt, stale, expire) VALUES (?, ?, ?, ?)`, entry.tag, staleValue, staleValue, entry.expire ?? null);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
45
109
|
}
|
|
46
110
|
}
|
|
@@ -1,15 +1,33 @@
|
|
|
1
|
-
import type { NextModeTagCache } from "@opennextjs/aws/types/overrides.js";
|
|
1
|
+
import type { NextModeTagCache, NextModeTagCacheWriteInput } from "@opennextjs/aws/types/overrides.js";
|
|
2
2
|
export declare const NAME = "d1-next-mode-tag-cache";
|
|
3
3
|
export declare const BINDING_NAME = "NEXT_TAG_CACHE_D1";
|
|
4
|
+
/**
|
|
5
|
+
* Stored value shape for D1 tag entries.
|
|
6
|
+
*
|
|
7
|
+
* - revalidatedAt: timestamp in ms of the last revalidation
|
|
8
|
+
* - stale: timestamp in ms when the tag becomes stale
|
|
9
|
+
* - expire: timestamp in ms when the tag expires (null means no expiry)
|
|
10
|
+
*/
|
|
11
|
+
type D1TagValue = {
|
|
12
|
+
revalidatedAt: number;
|
|
13
|
+
stale: number | null;
|
|
14
|
+
expire: number | null;
|
|
15
|
+
};
|
|
4
16
|
export declare class D1NextModeTagCache implements NextModeTagCache {
|
|
17
|
+
#private;
|
|
5
18
|
readonly mode: "nextMode";
|
|
6
19
|
readonly name = "d1-next-mode-tag-cache";
|
|
7
20
|
getLastRevalidated(tags: string[]): Promise<number>;
|
|
8
21
|
hasBeenRevalidated(tags: string[], lastModified?: number): Promise<boolean>;
|
|
9
|
-
writeTags(tags:
|
|
22
|
+
writeTags(tags: NextModeTagCacheWriteInput[]): Promise<void>;
|
|
23
|
+
isStale(tags: string[], lastModified?: number): Promise<boolean>;
|
|
10
24
|
private getConfig;
|
|
11
25
|
protected getCacheKey(key: string): string;
|
|
12
26
|
protected getBuildId(): string;
|
|
27
|
+
/**
|
|
28
|
+
* @returns request scoped in-memory cache for tag values, or undefined if ALS is not available.
|
|
29
|
+
*/
|
|
30
|
+
protected getItemsCache(): Map<string, D1TagValue | null> | undefined;
|
|
13
31
|
}
|
|
14
32
|
declare const _default: D1NextModeTagCache;
|
|
15
33
|
export default _default;
|
|
@@ -12,17 +12,15 @@ export class D1NextModeTagCache {
|
|
|
12
12
|
return 0;
|
|
13
13
|
}
|
|
14
14
|
try {
|
|
15
|
-
const result = await db
|
|
16
|
-
|
|
17
|
-
.
|
|
18
|
-
.
|
|
19
|
-
const timeMs =
|
|
15
|
+
const result = await this.#resolveTagValues(tags, db);
|
|
16
|
+
const revalidations = [...result.values()]
|
|
17
|
+
.filter((v) => v != null)
|
|
18
|
+
.map((v) => v.revalidatedAt);
|
|
19
|
+
const timeMs = revalidations.length === 0 ? 0 : Math.max(...revalidations);
|
|
20
20
|
debugCache("D1NextModeTagCache", `getLastRevalidated tags=${tags} -> ${timeMs}`);
|
|
21
21
|
return timeMs;
|
|
22
22
|
}
|
|
23
23
|
catch (e) {
|
|
24
|
-
// By default we don't want to crash here, so we return false
|
|
25
|
-
// We still log the error though so we can debug it
|
|
26
24
|
error(e);
|
|
27
25
|
return 0;
|
|
28
26
|
}
|
|
@@ -33,18 +31,21 @@ export class D1NextModeTagCache {
|
|
|
33
31
|
return false;
|
|
34
32
|
}
|
|
35
33
|
try {
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
34
|
+
const now = Date.now();
|
|
35
|
+
const result = await this.#resolveTagValues(tags, db);
|
|
36
|
+
const revalidated = [...result.values()].some((v) => {
|
|
37
|
+
if (v == null)
|
|
38
|
+
return false;
|
|
39
|
+
const { revalidatedAt, expire } = v;
|
|
40
|
+
if (expire != null)
|
|
41
|
+
return expire <= now && expire > (lastModified ?? 0);
|
|
42
|
+
return revalidatedAt > (lastModified ?? now);
|
|
43
|
+
});
|
|
41
44
|
debugCache("D1NextModeTagCache", `hasBeenRevalidated tags=${tags} at=${lastModified} -> ${revalidated}`);
|
|
42
45
|
return revalidated;
|
|
43
46
|
}
|
|
44
47
|
catch (e) {
|
|
45
48
|
error(e);
|
|
46
|
-
// By default we don't want to crash here, so we return false
|
|
47
|
-
// We still log the error though so we can debug it
|
|
48
49
|
return false;
|
|
49
50
|
}
|
|
50
51
|
}
|
|
@@ -53,15 +54,89 @@ export class D1NextModeTagCache {
|
|
|
53
54
|
if (isDisabled || tags.length === 0)
|
|
54
55
|
return Promise.resolve();
|
|
55
56
|
const nowMs = Date.now();
|
|
56
|
-
await db.batch(tags.map((tag) =>
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
57
|
+
await db.batch(tags.map((tag) => {
|
|
58
|
+
const tagStr = typeof tag === "string" ? tag : tag.tag;
|
|
59
|
+
const stale = typeof tag === "string" ? nowMs : (tag.stale ?? nowMs);
|
|
60
|
+
const expire = typeof tag === "string" ? null : (tag.expire ?? null);
|
|
61
|
+
return db
|
|
62
|
+
.prepare(`INSERT INTO revalidations (tag, revalidatedAt, stale, expire) VALUES (?, ?, ?, ?)`)
|
|
63
|
+
.bind(this.getCacheKey(tagStr), stale, stale, expire);
|
|
64
|
+
}));
|
|
65
|
+
const tagStrings = tags.map((t) => (typeof t === "string" ? t : t.tag));
|
|
66
|
+
debugCache("D1NextModeTagCache", `writeTags tags=${tagStrings} time=${nowMs}`);
|
|
60
67
|
// TODO: See https://github.com/opennextjs/opennextjs-aws/issues/986
|
|
61
68
|
if (isPurgeCacheEnabled()) {
|
|
62
|
-
await purgeCacheByTags(
|
|
69
|
+
await purgeCacheByTags(tagStrings);
|
|
63
70
|
}
|
|
64
71
|
}
|
|
72
|
+
async isStale(tags, lastModified) {
|
|
73
|
+
const { isDisabled, db } = this.getConfig();
|
|
74
|
+
if (isDisabled || tags.length === 0) {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
try {
|
|
78
|
+
const now = Date.now();
|
|
79
|
+
const result = await this.#resolveTagValues(tags, db);
|
|
80
|
+
const isStale = [...result.values()].some((v) => {
|
|
81
|
+
if (v == null)
|
|
82
|
+
return false;
|
|
83
|
+
const { stale, expire } = v;
|
|
84
|
+
if (stale == null || stale <= (lastModified ?? now))
|
|
85
|
+
return false;
|
|
86
|
+
return expire == null || expire > now;
|
|
87
|
+
});
|
|
88
|
+
debugCache("D1NextModeTagCache", `isStale tags=${tags} at=${lastModified} -> ${isStale}`);
|
|
89
|
+
return isStale;
|
|
90
|
+
}
|
|
91
|
+
catch (e) {
|
|
92
|
+
error(e);
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Resolves tag values from the per-request in-memory cache, falling back to D1 for any misses.
|
|
98
|
+
*
|
|
99
|
+
* Results are stored back into the request cache so repeated calls within the same request
|
|
100
|
+
* avoid duplicate D1 queries.
|
|
101
|
+
*
|
|
102
|
+
* @param tags - The tag names to resolve.
|
|
103
|
+
* @param db - The D1 database binding.
|
|
104
|
+
* @returns A map of tag name to its D1TagValue (or null if the tag was not found).
|
|
105
|
+
*/
|
|
106
|
+
async #resolveTagValues(tags, db) {
|
|
107
|
+
const result = new Map();
|
|
108
|
+
const uncachedTags = [];
|
|
109
|
+
const itemsCache = this.getItemsCache();
|
|
110
|
+
for (const tag of tags) {
|
|
111
|
+
if (itemsCache?.has(tag)) {
|
|
112
|
+
result.set(tag, itemsCache.get(tag) ?? null);
|
|
113
|
+
}
|
|
114
|
+
else {
|
|
115
|
+
uncachedTags.push(tag);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
if (uncachedTags.length > 0) {
|
|
119
|
+
const rows = await db
|
|
120
|
+
.prepare(`SELECT tag, revalidatedAt, stale, expire FROM revalidations WHERE tag IN (${uncachedTags.map(() => "?").join(", ")})`)
|
|
121
|
+
.bind(...uncachedTags.map((tag) => this.getCacheKey(tag)))
|
|
122
|
+
.raw();
|
|
123
|
+
// Index rows by cache key for lookup.
|
|
124
|
+
const rowsByKey = new Map(rows.map((row) => [row[0], row]));
|
|
125
|
+
for (const tag of uncachedTags) {
|
|
126
|
+
const row = rowsByKey.get(this.getCacheKey(tag));
|
|
127
|
+
const value = row
|
|
128
|
+
? {
|
|
129
|
+
revalidatedAt: row[1] ?? 0,
|
|
130
|
+
stale: row[2] ?? null,
|
|
131
|
+
expire: row[3] ?? null,
|
|
132
|
+
}
|
|
133
|
+
: null;
|
|
134
|
+
itemsCache?.set(tag, value);
|
|
135
|
+
result.set(tag, value);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return result;
|
|
139
|
+
}
|
|
65
140
|
getConfig() {
|
|
66
141
|
const db = getCloudflareContext().env[BINDING_NAME];
|
|
67
142
|
if (!db)
|
|
@@ -80,5 +155,12 @@ export class D1NextModeTagCache {
|
|
|
80
155
|
getBuildId() {
|
|
81
156
|
return process.env.NEXT_BUILD_ID ?? FALLBACK_BUILD_ID;
|
|
82
157
|
}
|
|
158
|
+
/**
|
|
159
|
+
* @returns request scoped in-memory cache for tag values, or undefined if ALS is not available.
|
|
160
|
+
*/
|
|
161
|
+
getItemsCache() {
|
|
162
|
+
const store = globalThis.__openNextAls?.getStore();
|
|
163
|
+
return store?.requestCache.getOrCreate("d1-nextMode:tagItems");
|
|
164
|
+
}
|
|
83
165
|
}
|
|
84
166
|
export default new D1NextModeTagCache();
|
|
@@ -1,5 +1,14 @@
|
|
|
1
|
-
import type { NextModeTagCache } from "@opennextjs/aws/types/overrides.js";
|
|
1
|
+
import type { NextModeTagCache, NextModeTagCacheWriteInput } from "@opennextjs/aws/types/overrides.js";
|
|
2
|
+
import type { TagData } from "../../durable-objects/sharded-tag-cache.js";
|
|
2
3
|
import { DOShardedTagCache } from "../../durable-objects/sharded-tag-cache.js";
|
|
4
|
+
type NormalizedTagInput = {
|
|
5
|
+
tag: string;
|
|
6
|
+
stale?: number;
|
|
7
|
+
expire?: number | null;
|
|
8
|
+
};
|
|
9
|
+
type CachedTagValue = {
|
|
10
|
+
tag: string;
|
|
11
|
+
} & TagData;
|
|
3
12
|
export declare const DEFAULT_WRITE_RETRIES = 3;
|
|
4
13
|
export declare const DEFAULT_NUM_SHARDS = 4;
|
|
5
14
|
export declare const NAME = "do-sharded-tag-cache";
|
|
@@ -88,6 +97,7 @@ interface DOIdOptions {
|
|
|
88
97
|
region?: DurableObjectLocationHint;
|
|
89
98
|
}
|
|
90
99
|
declare class ShardedDOTagCache implements NextModeTagCache {
|
|
100
|
+
#private;
|
|
91
101
|
private opts;
|
|
92
102
|
readonly mode: "nextMode";
|
|
93
103
|
readonly name = "do-sharded-tag-cache";
|
|
@@ -110,17 +120,18 @@ declare class ShardedDOTagCache implements NextModeTagCache {
|
|
|
110
120
|
* @returns
|
|
111
121
|
*/
|
|
112
122
|
hasBeenRevalidated(tags: string[], lastModified?: number): Promise<boolean>;
|
|
123
|
+
isStale(tags: string[], lastModified?: number): Promise<boolean>;
|
|
113
124
|
/**
|
|
114
125
|
* This function writes the tags to the cache
|
|
115
126
|
* Due to the way shards and regional cache are implemented, the regional cache may not be properly invalidated
|
|
116
127
|
* @param tags
|
|
117
128
|
* @returns
|
|
118
129
|
*/
|
|
119
|
-
writeTags(tags:
|
|
130
|
+
writeTags(tags: NextModeTagCacheWriteInput[]): Promise<void>;
|
|
120
131
|
/**
|
|
121
132
|
* The following methods are public only because they are accessed from the tests
|
|
122
133
|
*/
|
|
123
|
-
performWriteTagsWithRetry(doId: DOId, tags:
|
|
134
|
+
performWriteTagsWithRetry(doId: DOId, tags: NormalizedTagInput[], retryNumber?: number): Promise<void>;
|
|
124
135
|
getCacheUrlKey(doId: DOId, tag: string): string;
|
|
125
136
|
getCacheInstance(): Promise<Cache | undefined>;
|
|
126
137
|
/**
|
|
@@ -128,10 +139,7 @@ declare class ShardedDOTagCache implements NextModeTagCache {
|
|
|
128
139
|
* If the cache is not enabled, it will return an empty array
|
|
129
140
|
* @returns An array of objects with the tag and the last revalidation time
|
|
130
141
|
*/
|
|
131
|
-
getFromRegionalCache(opts: CacheTagKeyOptions): Promise<
|
|
132
|
-
tag: string;
|
|
133
|
-
time: number;
|
|
134
|
-
}[]>;
|
|
142
|
+
getFromRegionalCache(opts: CacheTagKeyOptions): Promise<CachedTagValue[]>;
|
|
135
143
|
putToRegionalCache(optsKey: CacheTagKeyOptions, stub: DurableObjectStub<DOShardedTagCache>): Promise<void>;
|
|
136
144
|
/**
|
|
137
145
|
* Deletes the regional cache for the given tags
|
|
@@ -37,26 +37,10 @@ class ShardedDOTagCache {
|
|
|
37
37
|
}
|
|
38
38
|
const deduplicatedTags = Array.from(new Set(tags)); // We deduplicate the tags to avoid unnecessary requests
|
|
39
39
|
try {
|
|
40
|
-
const
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
if (cachedValue.length === tags.length) {
|
|
45
|
-
const timeMs = Math.max(...cachedValue.map((item) => item.time));
|
|
46
|
-
debugCache("ShardedDOTagCache", `getLastRevalidated tags=${tags} -> ${timeMs} (regional cache)`);
|
|
47
|
-
return timeMs;
|
|
48
|
-
}
|
|
49
|
-
// Otherwise we need to check the durable object on the ones that were not found in the cache
|
|
50
|
-
const filteredTags = deduplicatedTags.filter((tag) => !cachedValue.some((item) => item.tag === tag));
|
|
51
|
-
const stub = this.getDurableObjectStub(doId);
|
|
52
|
-
const lastRevalidated = await stub.getLastRevalidated(filteredTags);
|
|
53
|
-
const timeMs = Math.max(...cachedValue.map((item) => item.time), lastRevalidated);
|
|
54
|
-
// We then need to populate the regional cache with the missing tags
|
|
55
|
-
getCloudflareContext().ctx.waitUntil(this.putToRegionalCache({ doId, tags }, stub));
|
|
56
|
-
debugCache("ShardedDOTagCache", `getLastRevalidated tags=${tags} -> ${timeMs}`);
|
|
57
|
-
return timeMs;
|
|
58
|
-
}));
|
|
59
|
-
return Math.max(...shardedTagRevalidationOutcomes);
|
|
40
|
+
const tagData = await this.#resolveTagData(deduplicatedTags);
|
|
41
|
+
const timeMs = Math.max(0, ...[...tagData.values()].filter((d) => d != null).map((d) => d.revalidatedAt));
|
|
42
|
+
debugCache("ShardedDOTagCache", `getLastRevalidated tags=${tags} -> ${timeMs}`);
|
|
43
|
+
return timeMs;
|
|
60
44
|
}
|
|
61
45
|
catch (e) {
|
|
62
46
|
error("Error while checking revalidation", e);
|
|
@@ -76,34 +60,48 @@ class ShardedDOTagCache {
|
|
|
76
60
|
return false;
|
|
77
61
|
}
|
|
78
62
|
try {
|
|
79
|
-
const
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
const _hasBeenRevalidated = await stub.hasBeenRevalidated(tags, lastModified);
|
|
92
|
-
const remainingTags = tags.filter((tag) => !cachedValue.some((item) => item.tag === tag));
|
|
93
|
-
if (remainingTags.length > 0) {
|
|
94
|
-
// We need to put the missing tags in the regional cache
|
|
95
|
-
getCloudflareContext().ctx.waitUntil(this.putToRegionalCache({ doId, tags: remainingTags }, stub));
|
|
96
|
-
}
|
|
97
|
-
debugCache("ShardedDOTagCache", `hasBeenRevalidated tags=${tags} at=${lastModified} -> ${_hasBeenRevalidated}`);
|
|
98
|
-
return _hasBeenRevalidated;
|
|
99
|
-
}));
|
|
100
|
-
return shardedTagRevalidationOutcomes.some((result) => result);
|
|
63
|
+
const now = Date.now();
|
|
64
|
+
const tagData = await this.#resolveTagData(tags);
|
|
65
|
+
const result = [...tagData.values()].some((data) => {
|
|
66
|
+
if (data == null)
|
|
67
|
+
return false;
|
|
68
|
+
const { revalidatedAt, expire } = data;
|
|
69
|
+
if (expire != null)
|
|
70
|
+
return expire <= now && expire > (lastModified ?? 0);
|
|
71
|
+
return revalidatedAt > (lastModified ?? now);
|
|
72
|
+
});
|
|
73
|
+
debugCache("ShardedDOTagCache", `hasBeenRevalidated tags=${tags} at=${lastModified} -> ${result}`);
|
|
74
|
+
return result;
|
|
101
75
|
}
|
|
102
76
|
catch (e) {
|
|
103
77
|
error("Error while checking revalidation", e);
|
|
104
78
|
return false;
|
|
105
79
|
}
|
|
106
80
|
}
|
|
81
|
+
async isStale(tags, lastModified) {
|
|
82
|
+
const { isDisabled } = this.getConfig();
|
|
83
|
+
if (isDisabled || tags.length === 0) {
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
try {
|
|
87
|
+
const now = Date.now();
|
|
88
|
+
const tagData = await this.#resolveTagData(tags);
|
|
89
|
+
const result = [...tagData.values()].some((data) => {
|
|
90
|
+
if (data == null)
|
|
91
|
+
return false;
|
|
92
|
+
const { stale, expire } = data;
|
|
93
|
+
if (stale == null || stale <= (lastModified ?? now))
|
|
94
|
+
return false;
|
|
95
|
+
return expire == null || expire > now;
|
|
96
|
+
});
|
|
97
|
+
debugCache("ShardedDOTagCache", `isStale tags=${tags} at=${lastModified} -> ${result}`);
|
|
98
|
+
return result;
|
|
99
|
+
}
|
|
100
|
+
catch (e) {
|
|
101
|
+
error("Error while checking stale", e);
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
107
105
|
/**
|
|
108
106
|
* This function writes the tags to the cache
|
|
109
107
|
* Due to the way shards and regional cache are implemented, the regional cache may not be properly invalidated
|
|
@@ -114,28 +112,33 @@ class ShardedDOTagCache {
|
|
|
114
112
|
const { isDisabled } = this.getConfig();
|
|
115
113
|
if (isDisabled)
|
|
116
114
|
return;
|
|
117
|
-
// We want to use the same revalidation time for all tags
|
|
118
115
|
const nowMs = Date.now();
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
116
|
+
const normalized = tags.map((tag) => typeof tag === "string"
|
|
117
|
+
? { tag, stale: nowMs, expire: undefined }
|
|
118
|
+
: { tag: tag.tag, stale: tag.stale ?? nowMs, expire: tag.expire });
|
|
119
|
+
const tagStrings = normalized.map((t) => t.tag);
|
|
120
|
+
debugCache("ShardedDOTagCache", `writeTags tags=${tagStrings} time=${nowMs}`);
|
|
121
|
+
const tagMap = new Map(normalized.map((t) => [t.tag, t]));
|
|
122
|
+
const shardedTagGroups = this.groupTagsByDO({ tags: tagStrings, generateAllReplicas: true });
|
|
123
|
+
await Promise.all(shardedTagGroups.map(async ({ doId, tags: shardTags }) => {
|
|
124
|
+
const shardNormalized = shardTags.map((t) => tagMap.get(t));
|
|
125
|
+
await this.performWriteTagsWithRetry(doId, shardNormalized);
|
|
123
126
|
}));
|
|
124
127
|
// TODO: See https://github.com/opennextjs/opennextjs-aws/issues/986
|
|
125
128
|
if (isPurgeCacheEnabled()) {
|
|
126
|
-
await purgeCacheByTags(
|
|
129
|
+
await purgeCacheByTags(tagStrings);
|
|
127
130
|
}
|
|
128
131
|
}
|
|
129
132
|
/**
|
|
130
133
|
* The following methods are public only because they are accessed from the tests
|
|
131
134
|
*/
|
|
132
|
-
async performWriteTagsWithRetry(doId, tags,
|
|
135
|
+
async performWriteTagsWithRetry(doId, tags, retryNumber = 0) {
|
|
133
136
|
try {
|
|
134
137
|
const stub = this.getDurableObjectStub(doId);
|
|
135
|
-
await stub.writeTags(tags
|
|
138
|
+
await stub.writeTags(tags);
|
|
136
139
|
// Depending on the shards and the tags, deleting from the regional cache will not work for every tag
|
|
137
140
|
// We also need to delete both cache
|
|
138
|
-
await Promise.all([this.deleteRegionalCache({ doId, tags })]);
|
|
141
|
+
await Promise.all([this.deleteRegionalCache({ doId, tags: tags.map((t) => t.tag) })]);
|
|
139
142
|
}
|
|
140
143
|
catch (e) {
|
|
141
144
|
error("Error while writing tags", e);
|
|
@@ -145,11 +148,10 @@ class ShardedDOTagCache {
|
|
|
145
148
|
await getCloudflareContext().env.NEXT_TAG_CACHE_DO_SHARDED_DLQ?.send({
|
|
146
149
|
failingShardId: doId.key,
|
|
147
150
|
failingTags: tags,
|
|
148
|
-
lastModified,
|
|
149
151
|
});
|
|
150
152
|
return;
|
|
151
153
|
}
|
|
152
|
-
await this.performWriteTagsWithRetry(doId, tags,
|
|
154
|
+
await this.performWriteTagsWithRetry(doId, tags, retryNumber + 1);
|
|
153
155
|
}
|
|
154
156
|
}
|
|
155
157
|
getCacheUrlKey(doId, tag) {
|
|
@@ -179,7 +181,23 @@ class ShardedDOTagCache {
|
|
|
179
181
|
return null;
|
|
180
182
|
const cachedText = await cachedResponse.text();
|
|
181
183
|
try {
|
|
182
|
-
|
|
184
|
+
const parsed = JSON.parse(cachedText);
|
|
185
|
+
if (typeof parsed === "number") {
|
|
186
|
+
// Backward compat: old format stored a plain revalidatedAt number
|
|
187
|
+
return {
|
|
188
|
+
tag,
|
|
189
|
+
revalidatedAt: parsed,
|
|
190
|
+
stale: parsed,
|
|
191
|
+
expire: null,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
const data = parsed;
|
|
195
|
+
return {
|
|
196
|
+
tag,
|
|
197
|
+
revalidatedAt: data.revalidatedAt ?? 0,
|
|
198
|
+
stale: data.stale ?? null,
|
|
199
|
+
expire: data.expire ?? null,
|
|
200
|
+
};
|
|
183
201
|
}
|
|
184
202
|
catch (e) {
|
|
185
203
|
debugCache("Error while parsing cached value", e);
|
|
@@ -200,21 +218,22 @@ class ShardedDOTagCache {
|
|
|
200
218
|
if (!cache)
|
|
201
219
|
return;
|
|
202
220
|
const tags = optsKey.tags;
|
|
203
|
-
const
|
|
221
|
+
const tagData = await stub.getTagData(tags);
|
|
204
222
|
await Promise.all(tags.map(async (tag) => {
|
|
205
|
-
let
|
|
206
|
-
if (
|
|
223
|
+
let data = tagData[tag];
|
|
224
|
+
if (data === undefined) {
|
|
207
225
|
if (this.opts.regionalCacheDangerouslyPersistMissingTags) {
|
|
208
|
-
|
|
226
|
+
// Tag not found: store a sentinel (never revalidated)
|
|
227
|
+
data = { revalidatedAt: 0, stale: null, expire: null };
|
|
209
228
|
}
|
|
210
229
|
else {
|
|
211
|
-
debugCache("Tag not found in
|
|
212
|
-
return;
|
|
230
|
+
debugCache("Tag not found in tag data", { tag, optsKey });
|
|
231
|
+
return;
|
|
213
232
|
}
|
|
214
233
|
}
|
|
215
234
|
const cacheKey = this.getCacheUrlKey(optsKey.doId, tag);
|
|
216
|
-
debugCache("Putting to regional cache", { cacheKey,
|
|
217
|
-
await cache.put(cacheKey, new Response(
|
|
235
|
+
debugCache("Putting to regional cache", { cacheKey, data });
|
|
236
|
+
await cache.put(cacheKey, new Response(JSON.stringify(data), {
|
|
218
237
|
status: 200,
|
|
219
238
|
headers: {
|
|
220
239
|
"cache-control": `max-age=${this.opts.regionalCacheTtlSec ?? 5}`,
|
|
@@ -276,6 +295,59 @@ class ShardedDOTagCache {
|
|
|
276
295
|
return result;
|
|
277
296
|
}
|
|
278
297
|
// Private methods
|
|
298
|
+
/**
|
|
299
|
+
* Fetches tag data for the given tags by consulting the regional cache first and falling back
|
|
300
|
+
* to Durable Object stubs for any misses. Returns a map of tag → TagData (null for tags not found).
|
|
301
|
+
*/
|
|
302
|
+
async #fetchTagDataFromShards(tags) {
|
|
303
|
+
const result = new Map();
|
|
304
|
+
const shardedTagGroups = this.groupTagsByDO({ tags });
|
|
305
|
+
await Promise.all(shardedTagGroups.map(async ({ doId, tags: shardTags }) => {
|
|
306
|
+
const cachedValues = await this.getFromRegionalCache({ doId, tags: shardTags });
|
|
307
|
+
for (const { tag, revalidatedAt, stale, expire } of cachedValues) {
|
|
308
|
+
result.set(tag, { revalidatedAt, stale, expire });
|
|
309
|
+
}
|
|
310
|
+
const cachedTagNames = new Set(cachedValues.map(({ tag }) => tag));
|
|
311
|
+
const remainingTags = shardTags.filter((tag) => !cachedTagNames.has(tag));
|
|
312
|
+
if (remainingTags.length === 0)
|
|
313
|
+
return;
|
|
314
|
+
const stub = this.getDurableObjectStub(doId);
|
|
315
|
+
const tagData = await stub.getTagData(remainingTags);
|
|
316
|
+
for (const tag of remainingTags) {
|
|
317
|
+
result.set(tag, tagData[tag] ?? null);
|
|
318
|
+
}
|
|
319
|
+
getCloudflareContext().ctx.waitUntil(this.putToRegionalCache({ doId, tags: remainingTags }, stub));
|
|
320
|
+
}));
|
|
321
|
+
return result;
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* Resolves tag data from the per-request in-memory cache, falling back to
|
|
325
|
+
* `#fetchTagDataFromShards` for any misses. Results are stored back so repeated
|
|
326
|
+
* calls within the same request avoid duplicate shard fetches.
|
|
327
|
+
*/
|
|
328
|
+
async #resolveTagData(tags) {
|
|
329
|
+
const store = globalThis.__openNextAls?.getStore();
|
|
330
|
+
const itemsCache = store?.requestCache.getOrCreate("do-sharded:tagItems");
|
|
331
|
+
const result = new Map();
|
|
332
|
+
const uncachedTags = [];
|
|
333
|
+
for (const tag of tags) {
|
|
334
|
+
if (itemsCache?.has(tag)) {
|
|
335
|
+
result.set(tag, itemsCache.get(tag) ?? null);
|
|
336
|
+
}
|
|
337
|
+
else {
|
|
338
|
+
uncachedTags.push(tag);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
if (uncachedTags.length > 0) {
|
|
342
|
+
const fetched = await this.#fetchTagDataFromShards(uncachedTags);
|
|
343
|
+
for (const tag of uncachedTags) {
|
|
344
|
+
const value = fetched.get(tag) ?? null;
|
|
345
|
+
itemsCache?.set(tag, value);
|
|
346
|
+
result.set(tag, value);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
return result;
|
|
350
|
+
}
|
|
279
351
|
getDurableObjectStub(doId) {
|
|
280
352
|
const durableObject = getCloudflareContext().env.NEXT_TAG_CACHE_DO_SHARDED;
|
|
281
353
|
if (!durableObject)
|
|
@@ -1,6 +1,14 @@
|
|
|
1
|
-
import type { NextModeTagCache } from "@opennextjs/aws/types/overrides.js";
|
|
1
|
+
import type { NextModeTagCache, NextModeTagCacheWriteInput } from "@opennextjs/aws/types/overrides.js";
|
|
2
2
|
export declare const NAME = "kv-next-mode-tag-cache";
|
|
3
3
|
export declare const BINDING_NAME = "NEXT_TAG_CACHE_KV";
|
|
4
|
+
/**
|
|
5
|
+
* Stored value shape for KV entries.
|
|
6
|
+
*/
|
|
7
|
+
type KVTagValue = number | {
|
|
8
|
+
revalidatedAt: number;
|
|
9
|
+
stale?: number | null;
|
|
10
|
+
expire?: number | null;
|
|
11
|
+
};
|
|
4
12
|
/**
|
|
5
13
|
* Tag Cache based on a KV namespace
|
|
6
14
|
*
|
|
@@ -19,7 +27,8 @@ export declare class KVNextModeTagCache implements NextModeTagCache {
|
|
|
19
27
|
readonly name = "kv-next-mode-tag-cache";
|
|
20
28
|
getLastRevalidated(tags: string[]): Promise<number>;
|
|
21
29
|
hasBeenRevalidated(tags: string[], lastModified?: number): Promise<boolean>;
|
|
22
|
-
writeTags(tags:
|
|
30
|
+
writeTags(tags: NextModeTagCacheWriteInput[]): Promise<void>;
|
|
31
|
+
isStale(tags: string[], lastModified?: number): Promise<boolean>;
|
|
23
32
|
/**
|
|
24
33
|
* Returns the KV namespace when it exists and tag cache is not disabled.
|
|
25
34
|
*
|
|
@@ -28,6 +37,10 @@ export declare class KVNextModeTagCache implements NextModeTagCache {
|
|
|
28
37
|
private getKv;
|
|
29
38
|
protected getCacheKey(key: string): string;
|
|
30
39
|
protected getBuildId(): string;
|
|
40
|
+
/**
|
|
41
|
+
* @returns request scoped in-memory cache for tag values, or undefined if ALS is not available.
|
|
42
|
+
*/
|
|
43
|
+
protected getItemsCache(): Map<string, KVTagValue | null> | undefined;
|
|
31
44
|
}
|
|
32
45
|
declare const _default: KVNextModeTagCache;
|
|
33
46
|
export default _default;
|
|
@@ -3,6 +3,16 @@ import { getCloudflareContext } from "../../cloudflare-context.js";
|
|
|
3
3
|
import { debugCache, FALLBACK_BUILD_ID, isPurgeCacheEnabled, purgeCacheByTags } from "../internal.js";
|
|
4
4
|
export const NAME = "kv-next-mode-tag-cache";
|
|
5
5
|
export const BINDING_NAME = "NEXT_TAG_CACHE_KV";
|
|
6
|
+
function getRevalidatedAt(value) {
|
|
7
|
+
return typeof value === "number" ? value : (value.revalidatedAt ?? 0);
|
|
8
|
+
}
|
|
9
|
+
function getStale(value) {
|
|
10
|
+
// Backward compat: old format stored a plain number meaning revalidatedAt = stale
|
|
11
|
+
return typeof value === "number" ? value : (value.stale ?? null);
|
|
12
|
+
}
|
|
13
|
+
function getExpire(value) {
|
|
14
|
+
return typeof value === "number" ? null : (value.expire ?? null);
|
|
15
|
+
}
|
|
6
16
|
/**
|
|
7
17
|
* Tag Cache based on a KV namespace
|
|
8
18
|
*
|
|
@@ -34,10 +44,10 @@ export class KVNextModeTagCache {
|
|
|
34
44
|
return 0;
|
|
35
45
|
}
|
|
36
46
|
try {
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
47
|
+
const result = await this.#resolveTagValues(tags, kv);
|
|
48
|
+
const revalidations = [...result.values()]
|
|
49
|
+
.filter((v) => v != null)
|
|
50
|
+
.map(getRevalidatedAt);
|
|
41
51
|
return revalidations.length === 0 ? 0 : Math.max(...revalidations);
|
|
42
52
|
}
|
|
43
53
|
catch (e) {
|
|
@@ -47,10 +57,57 @@ export class KVNextModeTagCache {
|
|
|
47
57
|
return 0;
|
|
48
58
|
}
|
|
49
59
|
}
|
|
60
|
+
/**
|
|
61
|
+
* Resolves tag values from the per-request in-memory cache, falling back to KV for any misses.
|
|
62
|
+
* Results are stored back into the request cache so repeated calls within the same request
|
|
63
|
+
* avoid duplicate KV fetches.
|
|
64
|
+
*/
|
|
65
|
+
async #resolveTagValues(tags, kv) {
|
|
66
|
+
const result = new Map();
|
|
67
|
+
const uncachedTags = [];
|
|
68
|
+
const itemsCache = this.getItemsCache();
|
|
69
|
+
for (const tag of tags) {
|
|
70
|
+
if (itemsCache?.has(tag)) {
|
|
71
|
+
result.set(tag, itemsCache.get(tag) ?? null);
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
uncachedTags.push(tag);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (uncachedTags.length > 0) {
|
|
78
|
+
const kvKeys = uncachedTags.map((tag) => this.getCacheKey(tag));
|
|
79
|
+
const fetched = await kv.get(kvKeys, { type: "json" });
|
|
80
|
+
for (const tag of uncachedTags) {
|
|
81
|
+
const value = fetched.get(this.getCacheKey(tag)) ?? null;
|
|
82
|
+
itemsCache?.set(tag, value);
|
|
83
|
+
result.set(tag, value);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return result;
|
|
87
|
+
}
|
|
50
88
|
async hasBeenRevalidated(tags, lastModified) {
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
|
|
89
|
+
const kv = this.getKv();
|
|
90
|
+
if (!kv || tags.length === 0) {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
try {
|
|
94
|
+
const now = Date.now();
|
|
95
|
+
const result = await this.#resolveTagValues(tags, kv);
|
|
96
|
+
const revalidated = [...result.values()].some((v) => {
|
|
97
|
+
if (v == null)
|
|
98
|
+
return false;
|
|
99
|
+
const expire = getExpire(v);
|
|
100
|
+
if (expire != null)
|
|
101
|
+
return expire <= now && expire > (lastModified ?? 0);
|
|
102
|
+
return getRevalidatedAt(v) > (lastModified ?? now);
|
|
103
|
+
});
|
|
104
|
+
debugCache("KVNextModeTagCache", `hasBeenRevalidated tags=${tags} lastModified=${lastModified} -> ${revalidated}`);
|
|
105
|
+
return revalidated;
|
|
106
|
+
}
|
|
107
|
+
catch (e) {
|
|
108
|
+
error(e);
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
54
111
|
}
|
|
55
112
|
async writeTags(tags) {
|
|
56
113
|
const kv = this.getKv();
|
|
@@ -59,12 +116,45 @@ export class KVNextModeTagCache {
|
|
|
59
116
|
}
|
|
60
117
|
const nowMs = Date.now();
|
|
61
118
|
await Promise.all(tags.map(async (tag) => {
|
|
62
|
-
|
|
119
|
+
if (typeof tag === "string") {
|
|
120
|
+
// Old format: store plain number string for backward compat
|
|
121
|
+
await kv.put(this.getCacheKey(tag), String(nowMs));
|
|
122
|
+
}
|
|
123
|
+
else {
|
|
124
|
+
const stale = tag.stale ?? nowMs;
|
|
125
|
+
const value = { revalidatedAt: stale, stale, expire: tag.expire ?? null };
|
|
126
|
+
await kv.put(this.getCacheKey(tag.tag), JSON.stringify(value));
|
|
127
|
+
}
|
|
63
128
|
}));
|
|
64
|
-
|
|
129
|
+
const tagStrings = tags.map((t) => (typeof t === "string" ? t : t.tag));
|
|
130
|
+
debugCache("KVNextModeTagCache", `writeTags tags=${tagStrings} time=${nowMs}`);
|
|
65
131
|
// TODO: See https://github.com/opennextjs/opennextjs-aws/issues/986
|
|
66
132
|
if (isPurgeCacheEnabled()) {
|
|
67
|
-
await purgeCacheByTags(
|
|
133
|
+
await purgeCacheByTags(tagStrings);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
async isStale(tags, lastModified) {
|
|
137
|
+
const kv = this.getKv();
|
|
138
|
+
if (!kv || tags.length === 0)
|
|
139
|
+
return false;
|
|
140
|
+
try {
|
|
141
|
+
const now = Date.now();
|
|
142
|
+
const result = await this.#resolveTagValues(tags, kv);
|
|
143
|
+
const isStale = [...result.values()].some((v) => {
|
|
144
|
+
if (v == null)
|
|
145
|
+
return false;
|
|
146
|
+
const stale = getStale(v);
|
|
147
|
+
if (stale == null || stale <= (lastModified ?? now))
|
|
148
|
+
return false;
|
|
149
|
+
const expire = getExpire(v);
|
|
150
|
+
return expire == null || expire > now;
|
|
151
|
+
});
|
|
152
|
+
debugCache("KVNextModeTagCache", `isStale tags=${tags} lastModified=${lastModified} -> ${isStale}`);
|
|
153
|
+
return isStale;
|
|
154
|
+
}
|
|
155
|
+
catch (e) {
|
|
156
|
+
error(e);
|
|
157
|
+
return false;
|
|
68
158
|
}
|
|
69
159
|
}
|
|
70
160
|
/**
|
|
@@ -87,5 +177,12 @@ export class KVNextModeTagCache {
|
|
|
87
177
|
getBuildId() {
|
|
88
178
|
return process.env.NEXT_BUILD_ID ?? FALLBACK_BUILD_ID;
|
|
89
179
|
}
|
|
180
|
+
/**
|
|
181
|
+
* @returns request scoped in-memory cache for tag values, or undefined if ALS is not available.
|
|
182
|
+
*/
|
|
183
|
+
getItemsCache() {
|
|
184
|
+
const store = globalThis.__openNextAls?.getStore();
|
|
185
|
+
return store?.requestCache.getOrCreate("kv-nextMode:tagItems");
|
|
186
|
+
}
|
|
90
187
|
}
|
|
91
188
|
export default new KVNextModeTagCache();
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { NextModeTagCache } from "@opennextjs/aws/types/overrides.js";
|
|
1
|
+
import type { NextModeTagCache, NextModeTagCacheWriteInput } from "@opennextjs/aws/types/overrides.js";
|
|
2
2
|
interface WithFilterOptions {
|
|
3
3
|
/**
|
|
4
4
|
* The original tag cache.
|
|
@@ -10,7 +10,7 @@ interface WithFilterOptions {
|
|
|
10
10
|
* @param tag The tag to filter.
|
|
11
11
|
* @returns true if the tag should be forwarded, false otherwise.
|
|
12
12
|
*/
|
|
13
|
-
filterFn: (tag: string) => boolean;
|
|
13
|
+
filterFn: (tag: string | NextModeTagCacheWriteInput) => boolean;
|
|
14
14
|
}
|
|
15
15
|
/**
|
|
16
16
|
* Creates a new tag cache that filters tags based on the provided filter function.
|
|
@@ -22,5 +22,5 @@ export declare function withFilter({ tagCache, filterFn }: WithFilterOptions): N
|
|
|
22
22
|
* This is used to filter out internal soft tags.
|
|
23
23
|
* Can be used if `revalidatePath` is not used.
|
|
24
24
|
*/
|
|
25
|
-
export declare function softTagFilter(tag: string): boolean;
|
|
25
|
+
export declare function softTagFilter(tag: string | NextModeTagCacheWriteInput): boolean;
|
|
26
26
|
export {};
|
|
@@ -44,5 +44,8 @@ export function withFilter({ tagCache, filterFn }) {
|
|
|
44
44
|
* Can be used if `revalidatePath` is not used.
|
|
45
45
|
*/
|
|
46
46
|
export function softTagFilter(tag) {
|
|
47
|
-
|
|
47
|
+
if (typeof tag === "string") {
|
|
48
|
+
return !tag.startsWith("_N_T_");
|
|
49
|
+
}
|
|
50
|
+
return !tag.tag.startsWith("_N_T_");
|
|
48
51
|
}
|
|
@@ -32,6 +32,11 @@ export function patchVercelOgLibrary(buildOpts) {
|
|
|
32
32
|
if (!existsSync(outputEdgePath)) {
|
|
33
33
|
const tracedEdgePath = path.join(path.dirname(traceInfoPath), tracedNodePath.replace("index.node.js", "index.edge.js"));
|
|
34
34
|
copyFileSync(tracedEdgePath, outputEdgePath);
|
|
35
|
+
// On Next 16.2 and above, we also need to copy the yoga.wasm file used by the library.
|
|
36
|
+
const tracedWasmPath = path.join(path.dirname(traceInfoPath), tracedNodePath.replace("index.node.js", "yoga.wasm"));
|
|
37
|
+
if (existsSync(tracedWasmPath)) {
|
|
38
|
+
copyFileSync(tracedWasmPath, path.join(outputDir, "yoga.wasm"));
|
|
39
|
+
}
|
|
35
40
|
}
|
|
36
41
|
// Change font fetches in the library to use imports.
|
|
37
42
|
{
|
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
import { type BuildOptions } from "@opennextjs/aws/build/helper.js";
|
|
2
2
|
import type { ContentUpdater, Plugin } from "@opennextjs/aws/plugins/content-updater.js";
|
|
3
|
+
export declare function getRequires(idVariable: string, files: string[], serverDir: string): string;
|
|
3
4
|
export declare function inlineDynamicRequires(updater: ContentUpdater, buildOpts: BuildOptions): Plugin;
|
|
@@ -25,9 +25,13 @@ async function getAppPathsManifests(serverDir) {
|
|
|
25
25
|
function getServerDir(buildOpts) {
|
|
26
26
|
return join(buildOpts.outputDir, "server-functions/default", getPackagePath(buildOpts), ".next/server");
|
|
27
27
|
}
|
|
28
|
-
function getRequires(idVariable, files, serverDir) {
|
|
28
|
+
export function getRequires(idVariable, files, serverDir) {
|
|
29
29
|
// Inline fs access and dynamic requires that are not supported by workerd.
|
|
30
|
-
|
|
30
|
+
// Sort by path length descending so longer (more specific) paths match first.
|
|
31
|
+
// Without this, `/test/app/page.js` could match the `.endsWith("app/page.js")`
|
|
32
|
+
// check for `/` before reaching the correct `.endsWith("test/app/page.js")` check.
|
|
33
|
+
const sorted = [...files].sort((a, b) => b.length - a.length);
|
|
34
|
+
return sorted
|
|
31
35
|
.map((file) => `
|
|
32
36
|
if (${idVariable}.replaceAll(${JSON.stringify(sep)}, ${JSON.stringify(posix.sep)}).endsWith(${JSON.stringify(normalizePath(file))})) {
|
|
33
37
|
return require(${JSON.stringify(join(serverDir, file))});
|
|
@@ -93,7 +97,9 @@ async function getRequirePageRule(buildOpts) {
|
|
|
93
97
|
const pagesManifests = await getPagesManifests(serverDir);
|
|
94
98
|
const appPathsManifests = await getAppPathsManifests(serverDir);
|
|
95
99
|
const manifests = pagesManifests.concat(appPathsManifests);
|
|
96
|
-
|
|
100
|
+
// Sort html files by path length descending so longer (more specific) paths
|
|
101
|
+
// match first, preventing suffix collisions in the `.endsWith()` chain (see #1156).
|
|
102
|
+
const htmlFiles = manifests.filter((file) => file.endsWith(".html")).sort((a, b) => b.length - a.length);
|
|
97
103
|
const jsFiles = manifests.filter((file) => file.endsWith(".js"));
|
|
98
104
|
return {
|
|
99
105
|
rule: {
|
|
@@ -78,7 +78,10 @@ async function getEvalManifestRule(buildOpts) {
|
|
|
78
78
|
const manifests = await glob(join(baseDir, "**/*_client-reference-manifest.js"), {
|
|
79
79
|
windowsPathsNoEscape: true,
|
|
80
80
|
});
|
|
81
|
-
|
|
81
|
+
// Sort by path length descending so longer (more specific) paths match first,
|
|
82
|
+
// preventing suffix collisions in the `.endsWith()` chain (see #1156).
|
|
83
|
+
const sortedManifests = [...manifests].sort((a, b) => b.length - a.length);
|
|
84
|
+
const returnManifests = sortedManifests
|
|
82
85
|
.map((manifest) => {
|
|
83
86
|
const endsWith = normalizePath(relative(baseDir, manifest));
|
|
84
87
|
const key = normalizePath("/" + relative(appDir, manifest)).replace("_client-reference-manifest.js", "");
|
|
@@ -102,6 +102,7 @@ fix: |-
|
|
|
102
102
|
const handlersSetSymbol = Symbol.for('@next/cache-handlers-set');
|
|
103
103
|
globalThis[handlersMapSymbol] = new Map();
|
|
104
104
|
globalThis[handlersMapSymbol].set("default", require('${normalizePath(handlerPath)}').default);
|
|
105
|
+
globalThis[handlersMapSymbol].set("remote", require('${normalizePath(handlerPath)}').default);
|
|
105
106
|
globalThis[handlersSetSymbol] = new Set(globalThis[handlersMapSymbol].values());
|
|
106
107
|
`;
|
|
107
108
|
}
|
|
@@ -10,6 +10,7 @@ import { getPackagePath } from "@opennextjs/aws/build/helper.js";
|
|
|
10
10
|
import { patchCode } from "@opennextjs/aws/build/patch/astCodePatcher.js";
|
|
11
11
|
import { getCrossPlatformPathRegex } from "@opennextjs/aws/utils/regex.js";
|
|
12
12
|
import { normalizePath } from "../../../utils/normalize-path.js";
|
|
13
|
+
import { createComposableCacheHandlersRule } from "./next-server.js";
|
|
13
14
|
export function patchRouteModules(updater, buildOpts) {
|
|
14
15
|
return updater.updateContent("route-module", [
|
|
15
16
|
{
|
|
@@ -17,13 +18,15 @@ export function patchRouteModules(updater, buildOpts) {
|
|
|
17
18
|
escape: false,
|
|
18
19
|
}),
|
|
19
20
|
versions: ">=15.4.0",
|
|
20
|
-
|
|
21
|
+
// app route doesn't have getIncrementalCache, but we still need to patch the composable cache handlers there
|
|
22
|
+
contentFilter: /(getIncrementalCache\(|loadCustomCacheHandlers\()/,
|
|
21
23
|
callback: async ({ contents }) => {
|
|
22
24
|
const { outputDir } = buildOpts;
|
|
23
25
|
const outputPath = path.join(outputDir, "server-functions/default");
|
|
24
26
|
const cacheHandler = path.join(outputPath, getPackagePath(buildOpts), "cache.cjs");
|
|
25
27
|
contents = patchCode(contents, getIncrementalCacheRule(cacheHandler));
|
|
26
28
|
contents = patchCode(contents, forceTrustHostHeader);
|
|
29
|
+
contents = patchCode(contents, createComposableCacheHandlersRule(path.join(outputPath, getPackagePath(buildOpts), "composable-cache.cjs")));
|
|
27
30
|
return contents;
|
|
28
31
|
},
|
|
29
32
|
},
|
|
@@ -373,7 +373,12 @@ function populateD1TagCache(buildOpts, config, populateCacheOptions) {
|
|
|
373
373
|
const result = runWrangler(buildOpts, [
|
|
374
374
|
"d1 execute",
|
|
375
375
|
D1_TAG_BINDING_NAME,
|
|
376
|
-
|
|
376
|
+
// Columns:
|
|
377
|
+
// tag - The cache tag.
|
|
378
|
+
// revalidatedAt - Timestamp (ms) when the tag was last revalidated.
|
|
379
|
+
// stale - Timestamp (ms) when the cached entry becomes stale. Added in v1.19.
|
|
380
|
+
// expire - Timestamp (ms) when the cached entry expires. NULL means no expire. Added in v1.19.
|
|
381
|
+
`--command "CREATE TABLE IF NOT EXISTS revalidations (tag TEXT NOT NULL, revalidatedAt INTEGER NOT NULL, stale INTEGER, expire INTEGER default NULL, UNIQUE(tag) ON CONFLICT REPLACE);"`,
|
|
377
382
|
`--preview ${populateCacheOptions.shouldUsePreviewId}`,
|
|
378
383
|
], {
|
|
379
384
|
target: populateCacheOptions.target,
|
|
@@ -384,6 +389,20 @@ function populateD1TagCache(buildOpts, config, populateCacheOptions) {
|
|
|
384
389
|
if (!result.success) {
|
|
385
390
|
throw new Error(`Wrangler d1 execute command failed${result.stderr ? `:\n${result.stderr}` : ""}`);
|
|
386
391
|
}
|
|
392
|
+
// Schema migration: add `stale` and `expire` columns (idempotent, safe for existing deployments).
|
|
393
|
+
// The columns were added in v1.19 to support SWR.
|
|
394
|
+
// These commands are intentionally non-throwing — they fail harmlessly if the columns already exist.
|
|
395
|
+
runWrangler(buildOpts, [
|
|
396
|
+
"d1 execute",
|
|
397
|
+
D1_TAG_BINDING_NAME,
|
|
398
|
+
`--command "ALTER TABLE revalidations ADD COLUMN stale INTEGER; ALTER TABLE revalidations ADD COLUMN expire INTEGER default NULL"`,
|
|
399
|
+
`--preview ${populateCacheOptions.shouldUsePreviewId}`,
|
|
400
|
+
], {
|
|
401
|
+
target: populateCacheOptions.target,
|
|
402
|
+
environment: populateCacheOptions.environment,
|
|
403
|
+
configPath: populateCacheOptions.wranglerConfigPath,
|
|
404
|
+
logging: "error",
|
|
405
|
+
});
|
|
387
406
|
logger.info("\nSuccessfully created D1 table");
|
|
388
407
|
}
|
|
389
408
|
function populateStaticAssetsIncrementalCache(options) {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opennextjs/cloudflare",
|
|
3
3
|
"description": "Cloudflare builder for next apps",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.19.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"opennextjs-cloudflare": "dist/cli/index.js"
|
|
@@ -44,11 +44,11 @@
|
|
|
44
44
|
"dependencies": {
|
|
45
45
|
"@ast-grep/napi": "^0.40.5",
|
|
46
46
|
"@dotenvx/dotenvx": "1.31.0",
|
|
47
|
-
"@opennextjs/aws": "3.
|
|
47
|
+
"@opennextjs/aws": "3.10.1",
|
|
48
48
|
"cloudflare": "^4.4.1",
|
|
49
|
+
"comment-json": "^4.5.1",
|
|
49
50
|
"enquirer": "^2.4.1",
|
|
50
51
|
"glob": "^12.0.0",
|
|
51
|
-
"comment-json": "^4.5.1",
|
|
52
52
|
"ts-tqdm": "^0.8.6",
|
|
53
53
|
"yargs": "^18.0.0"
|
|
54
54
|
},
|
|
@@ -68,7 +68,7 @@
|
|
|
68
68
|
"eslint-plugin-unicorn": "^55.0.0",
|
|
69
69
|
"globals": "^15.9.0",
|
|
70
70
|
"mock-fs": "^5.4.1",
|
|
71
|
-
"next": "~15.5.
|
|
71
|
+
"next": "~15.5.15",
|
|
72
72
|
"picomatch": "^4.0.2",
|
|
73
73
|
"rimraf": "^6.0.1",
|
|
74
74
|
"typescript": "^5.9.3",
|
|
@@ -76,8 +76,8 @@
|
|
|
76
76
|
"vitest": "^2.1.1"
|
|
77
77
|
},
|
|
78
78
|
"peerDependencies": {
|
|
79
|
-
"
|
|
80
|
-
"
|
|
79
|
+
"next": ">=15.5.15 || >=16.2.3",
|
|
80
|
+
"wrangler": "^4.65.0"
|
|
81
81
|
},
|
|
82
82
|
"scripts": {
|
|
83
83
|
"clean": "rimraf dist",
|