@opennextjs/cloudflare 1.8.5 → 1.9.1
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/cloudflare-context.d.ts +1 -0
- package/dist/api/durable-objects/sharded-tag-cache.js +9 -6
- package/dist/api/overrides/incremental-cache/kv-incremental-cache.js +3 -3
- package/dist/api/overrides/incremental-cache/r2-incremental-cache.js +3 -3
- package/dist/api/overrides/incremental-cache/regional-cache.js +7 -10
- package/dist/api/overrides/incremental-cache/static-assets-incremental-cache.d.ts +1 -1
- package/dist/api/overrides/incremental-cache/static-assets-incremental-cache.js +4 -4
- package/dist/api/overrides/internal.d.ts +1 -0
- package/dist/api/overrides/internal.js +5 -0
- package/dist/api/overrides/tag-cache/d1-next-tag-cache.js +20 -14
- package/dist/api/overrides/tag-cache/do-sharded-tag-cache.js +21 -12
- package/dist/api/overrides/tag-cache/kv-next-tag-cache.d.ts +33 -0
- package/dist/api/overrides/tag-cache/kv-next-tag-cache.js +91 -0
- package/dist/cli/templates/worker.d.ts +1 -1
- package/package.json +3 -3
|
@@ -14,6 +14,7 @@ declare global {
|
|
|
14
14
|
NEXT_INC_CACHE_R2_BUCKET?: R2Bucket;
|
|
15
15
|
[R2_CACHE_PREFIX_ENV_NAME]?: string;
|
|
16
16
|
NEXT_TAG_CACHE_D1?: D1Database;
|
|
17
|
+
NEXT_TAG_CACHE_KV?: KVNamespace;
|
|
17
18
|
NEXT_TAG_CACHE_DO_SHARDED?: DurableObjectNamespace<DOShardedTagCache>;
|
|
18
19
|
NEXT_TAG_CACHE_DO_SHARDED_DLQ?: Queue;
|
|
19
20
|
NEXT_CACHE_DO_QUEUE?: DurableObjectNamespace<DOQueueHandler>;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { DurableObject } from "cloudflare:workers";
|
|
2
|
+
import { debugCache } from "../overrides/internal.js";
|
|
2
3
|
export class DOShardedTagCache extends DurableObject {
|
|
3
4
|
sql;
|
|
4
5
|
constructor(state, env) {
|
|
@@ -13,10 +14,9 @@ export class DOShardedTagCache extends DurableObject {
|
|
|
13
14
|
const result = this.sql
|
|
14
15
|
.exec(`SELECT MAX(revalidatedAt) AS time FROM revalidations WHERE tag IN (${tags.map(() => "?").join(", ")})`, ...tags)
|
|
15
16
|
.toArray();
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
return result[0]?.time;
|
|
17
|
+
const timeMs = (result[0]?.time ?? 0);
|
|
18
|
+
debugCache("DOShardedTagCache", `getLastRevalidated tags=${tags} -> time=${timeMs}`);
|
|
19
|
+
return timeMs;
|
|
20
20
|
}
|
|
21
21
|
catch (e) {
|
|
22
22
|
console.error(e);
|
|
@@ -25,11 +25,14 @@ export class DOShardedTagCache extends DurableObject {
|
|
|
25
25
|
}
|
|
26
26
|
}
|
|
27
27
|
async hasBeenRevalidated(tags, lastModified) {
|
|
28
|
-
|
|
28
|
+
const revalidated = this.sql
|
|
29
29
|
.exec(`SELECT 1 FROM revalidations WHERE tag IN (${tags.map(() => "?").join(", ")}) AND revalidatedAt > ? LIMIT 1`, ...tags, lastModified ?? Date.now())
|
|
30
|
-
.toArray().length > 0
|
|
30
|
+
.toArray().length > 0;
|
|
31
|
+
debugCache("DOShardedTagCache", `hasBeenRevalidated tags=${tags} -> revalidated=${revalidated}`);
|
|
32
|
+
return revalidated;
|
|
31
33
|
}
|
|
32
34
|
async writeTags(tags, lastModified) {
|
|
35
|
+
debugCache("DOShardedTagCache", `writeTags tags=${tags} time=${lastModified}`);
|
|
33
36
|
tags.forEach((tag) => {
|
|
34
37
|
this.sql.exec(`INSERT OR REPLACE INTO revalidations (tag, revalidatedAt) VALUES (?, ?)`, tag, lastModified);
|
|
35
38
|
});
|
|
@@ -21,7 +21,7 @@ class KVIncrementalCache {
|
|
|
21
21
|
const kv = getCloudflareContext().env[BINDING_NAME];
|
|
22
22
|
if (!kv)
|
|
23
23
|
throw new IgnorableError("No KV Namespace");
|
|
24
|
-
debugCache(`
|
|
24
|
+
debugCache("KVIncrementalCache", `get ${key}`);
|
|
25
25
|
try {
|
|
26
26
|
const entry = await kv.get(this.getKVKey(key, cacheType), "json");
|
|
27
27
|
if (!entry)
|
|
@@ -44,7 +44,7 @@ class KVIncrementalCache {
|
|
|
44
44
|
const kv = getCloudflareContext().env[BINDING_NAME];
|
|
45
45
|
if (!kv)
|
|
46
46
|
throw new IgnorableError("No KV Namespace");
|
|
47
|
-
debugCache(`
|
|
47
|
+
debugCache("KVIncrementalCache", `set ${key}`);
|
|
48
48
|
try {
|
|
49
49
|
await kv.put(this.getKVKey(key, cacheType), JSON.stringify({
|
|
50
50
|
value,
|
|
@@ -64,7 +64,7 @@ class KVIncrementalCache {
|
|
|
64
64
|
const kv = getCloudflareContext().env[BINDING_NAME];
|
|
65
65
|
if (!kv)
|
|
66
66
|
throw new IgnorableError("No KV Namespace");
|
|
67
|
-
debugCache(`
|
|
67
|
+
debugCache("KVIncrementalCache", `delete ${key}`);
|
|
68
68
|
try {
|
|
69
69
|
// Only cache that gets deleted is the ISR/SSG cache.
|
|
70
70
|
await kv.delete(this.getKVKey(key, "cache"));
|
|
@@ -18,7 +18,7 @@ class R2IncrementalCache {
|
|
|
18
18
|
const r2 = getCloudflareContext().env[BINDING_NAME];
|
|
19
19
|
if (!r2)
|
|
20
20
|
throw new IgnorableError("No R2 bucket");
|
|
21
|
-
debugCache(`
|
|
21
|
+
debugCache("R2IncrementalCache", `get ${key}`);
|
|
22
22
|
try {
|
|
23
23
|
const r2Object = await r2.get(this.getR2Key(key, cacheType));
|
|
24
24
|
if (!r2Object)
|
|
@@ -37,7 +37,7 @@ class R2IncrementalCache {
|
|
|
37
37
|
const r2 = getCloudflareContext().env[BINDING_NAME];
|
|
38
38
|
if (!r2)
|
|
39
39
|
throw new IgnorableError("No R2 bucket");
|
|
40
|
-
debugCache(`
|
|
40
|
+
debugCache("R2IncrementalCache", `set ${key}`);
|
|
41
41
|
try {
|
|
42
42
|
await r2.put(this.getR2Key(key, cacheType), JSON.stringify(value));
|
|
43
43
|
}
|
|
@@ -49,7 +49,7 @@ class R2IncrementalCache {
|
|
|
49
49
|
const r2 = getCloudflareContext().env[BINDING_NAME];
|
|
50
50
|
if (!r2)
|
|
51
51
|
throw new IgnorableError("No R2 bucket");
|
|
52
|
-
debugCache(`
|
|
52
|
+
debugCache("R2IncrementalCache", `delete ${key}`);
|
|
53
53
|
try {
|
|
54
54
|
await r2.delete(this.getR2Key(key));
|
|
55
55
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { error } from "@opennextjs/aws/adapters/logger.js";
|
|
2
2
|
import { getCloudflareContext } from "../../cloudflare-context.js";
|
|
3
|
-
import { debugCache, FALLBACK_BUILD_ID } from "../internal.js";
|
|
3
|
+
import { debugCache, FALLBACK_BUILD_ID, isPurgeCacheEnabled } from "../internal.js";
|
|
4
4
|
import { NAME as KV_CACHE_NAME } from "./kv-incremental-cache.js";
|
|
5
5
|
const ONE_MINUTE_IN_SECONDS = 60;
|
|
6
6
|
const THIRTY_MINUTES_IN_SECONDS = ONE_MINUTE_IN_SECONDS * 30;
|
|
@@ -28,8 +28,7 @@ class RegionalCache {
|
|
|
28
28
|
throw new Error("The KV incremental cache does not need a regional cache.");
|
|
29
29
|
}
|
|
30
30
|
this.name = this.store.name;
|
|
31
|
-
this.opts.shouldLazilyUpdateOnCacheHit ??=
|
|
32
|
-
this.opts.mode === "long-lived" && !this.#hasAutomaticCachePurging;
|
|
31
|
+
this.opts.shouldLazilyUpdateOnCacheHit ??= this.opts.mode === "long-lived" && !isPurgeCacheEnabled();
|
|
33
32
|
}
|
|
34
33
|
get #bypassTagCacheOnCacheHit() {
|
|
35
34
|
if (this.opts.bypassTagCacheOnCacheHit !== undefined) {
|
|
@@ -37,12 +36,7 @@ class RegionalCache {
|
|
|
37
36
|
return this.opts.bypassTagCacheOnCacheHit;
|
|
38
37
|
}
|
|
39
38
|
// Otherwise we default to whether the automatic cache purging is enabled or not
|
|
40
|
-
return
|
|
41
|
-
}
|
|
42
|
-
get #hasAutomaticCachePurging() {
|
|
43
|
-
// The `?` is required at `openNextConfig?` or the Open Next build fails because of a type error
|
|
44
|
-
const cdnInvalidation = globalThis.openNextConfig?.default?.override?.cdnInvalidation;
|
|
45
|
-
return cdnInvalidation !== undefined && cdnInvalidation !== "dummy";
|
|
39
|
+
return isPurgeCacheEnabled();
|
|
46
40
|
}
|
|
47
41
|
async get(key, cacheType) {
|
|
48
42
|
try {
|
|
@@ -51,7 +45,7 @@ class RegionalCache {
|
|
|
51
45
|
// Check for a cached entry as this will be faster than the store response.
|
|
52
46
|
const cachedResponse = await cache.match(urlKey);
|
|
53
47
|
if (cachedResponse) {
|
|
54
|
-
debugCache("
|
|
48
|
+
debugCache("RegionalCache", `get ${key} -> cached response`);
|
|
55
49
|
// Re-fetch from the store and update the regional cache in the background.
|
|
56
50
|
// Note: this is only useful when the Cache API is not purged automatically.
|
|
57
51
|
if (this.opts.shouldLazilyUpdateOnCacheHit) {
|
|
@@ -72,6 +66,7 @@ class RegionalCache {
|
|
|
72
66
|
const { value, lastModified } = rawEntry ?? {};
|
|
73
67
|
if (!value || typeof lastModified !== "number")
|
|
74
68
|
return null;
|
|
69
|
+
debugCache("RegionalCache", `get ${key} -> put to cache`);
|
|
75
70
|
// Update the locale cache after retrieving from the store.
|
|
76
71
|
getCloudflareContext().ctx.waitUntil(this.putToCache({ key, cacheType, entry: { value, lastModified } }));
|
|
77
72
|
return { value, lastModified };
|
|
@@ -83,6 +78,7 @@ class RegionalCache {
|
|
|
83
78
|
}
|
|
84
79
|
async set(key, value, cacheType) {
|
|
85
80
|
try {
|
|
81
|
+
debugCache("RegionalCache", `set ${key}`);
|
|
86
82
|
await this.store.set(key, value, cacheType);
|
|
87
83
|
await this.putToCache({
|
|
88
84
|
key,
|
|
@@ -100,6 +96,7 @@ class RegionalCache {
|
|
|
100
96
|
}
|
|
101
97
|
}
|
|
102
98
|
async delete(key) {
|
|
99
|
+
debugCache("RegionalCache", `delete ${key}`);
|
|
103
100
|
try {
|
|
104
101
|
await this.store.delete(key);
|
|
105
102
|
const cache = await this.getCacheInstance();
|
|
@@ -9,7 +9,7 @@ export declare const NAME = "cf-static-assets-incremental-cache";
|
|
|
9
9
|
declare class StaticAssetsIncrementalCache implements IncrementalCache {
|
|
10
10
|
readonly name = "cf-static-assets-incremental-cache";
|
|
11
11
|
get<CacheType extends CacheEntryType = "cache">(key: string, cacheType?: CacheType): Promise<WithLastModified<CacheValue<CacheType>> | null>;
|
|
12
|
-
set(): Promise<void>;
|
|
12
|
+
set<CacheType extends CacheEntryType = "cache">(key: string, _value: CacheValue<CacheType>, cacheType?: CacheType): Promise<void>;
|
|
13
13
|
delete(): Promise<void>;
|
|
14
14
|
protected getAssetUrl(key: string, cacheType?: CacheEntryType): string;
|
|
15
15
|
}
|
|
@@ -16,7 +16,7 @@ class StaticAssetsIncrementalCache {
|
|
|
16
16
|
const assets = getCloudflareContext().env.ASSETS;
|
|
17
17
|
if (!assets)
|
|
18
18
|
throw new IgnorableError("No Static Assets");
|
|
19
|
-
debugCache(`
|
|
19
|
+
debugCache("StaticAssetsIncrementalCache", `get ${key}`);
|
|
20
20
|
try {
|
|
21
21
|
const response = await assets.fetch(this.getAssetUrl(key, cacheType));
|
|
22
22
|
if (!response.ok) {
|
|
@@ -33,11 +33,11 @@ class StaticAssetsIncrementalCache {
|
|
|
33
33
|
return null;
|
|
34
34
|
}
|
|
35
35
|
}
|
|
36
|
-
async set() {
|
|
37
|
-
error(
|
|
36
|
+
async set(key, _value, cacheType) {
|
|
37
|
+
error(`StaticAssetsIncrementalCache: Failed to set to read-only cache key=${key} type=${cacheType}`);
|
|
38
38
|
}
|
|
39
39
|
async delete() {
|
|
40
|
-
error("Failed to delete from read-only cache");
|
|
40
|
+
error("StaticAssetsIncrementalCache: Failed to delete from read-only cache");
|
|
41
41
|
}
|
|
42
42
|
getAssetUrl(key, cacheType) {
|
|
43
43
|
if (cacheType === "composable") {
|
|
@@ -12,5 +12,6 @@ export type KeyOptions = {
|
|
|
12
12
|
buildId: string | undefined;
|
|
13
13
|
};
|
|
14
14
|
export declare function computeCacheKey(key: string, options: KeyOptions): string;
|
|
15
|
+
export declare function isPurgeCacheEnabled(): boolean;
|
|
15
16
|
export declare function purgeCacheByTags(tags: string[]): Promise<void>;
|
|
16
17
|
export declare function internalPurgeCacheByTags(env: CloudflareEnv, tags: string[]): Promise<"missing-credentials" | "rate-limit-exceeded" | "purge-failed" | "purge-success">;
|
|
@@ -13,6 +13,11 @@ export function computeCacheKey(key, options) {
|
|
|
13
13
|
const hash = createHash("sha256").update(key).digest("hex");
|
|
14
14
|
return `${prefix}/${buildId}/${hash}.${cacheType}`.replace(/\/+/g, "/");
|
|
15
15
|
}
|
|
16
|
+
export function isPurgeCacheEnabled() {
|
|
17
|
+
// The `?` is required at `openNextConfig?` or the Open Next build fails because of a type error
|
|
18
|
+
const cdnInvalidation = globalThis.openNextConfig?.default?.override?.cdnInvalidation;
|
|
19
|
+
return cdnInvalidation !== undefined && cdnInvalidation !== "dummy";
|
|
20
|
+
}
|
|
16
21
|
export async function purgeCacheByTags(tags) {
|
|
17
22
|
const { env } = getCloudflareContext();
|
|
18
23
|
// We have a durable object for purging cache
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { error } from "@opennextjs/aws/adapters/logger.js";
|
|
2
2
|
import { getCloudflareContext } from "../../cloudflare-context.js";
|
|
3
|
-
import { debugCache, FALLBACK_BUILD_ID, purgeCacheByTags } from "../internal.js";
|
|
3
|
+
import { debugCache, FALLBACK_BUILD_ID, isPurgeCacheEnabled, purgeCacheByTags } from "../internal.js";
|
|
4
4
|
export const NAME = "d1-next-mode-tag-cache";
|
|
5
5
|
export const BINDING_NAME = "NEXT_TAG_CACHE_D1";
|
|
6
6
|
export class D1NextModeTagCache {
|
|
@@ -8,35 +8,38 @@ export class D1NextModeTagCache {
|
|
|
8
8
|
name = NAME;
|
|
9
9
|
async getLastRevalidated(tags) {
|
|
10
10
|
const { isDisabled, db } = this.getConfig();
|
|
11
|
-
if (isDisabled)
|
|
11
|
+
if (isDisabled || tags.length === 0) {
|
|
12
12
|
return 0;
|
|
13
|
+
}
|
|
13
14
|
try {
|
|
14
15
|
const result = await db
|
|
15
16
|
.prepare(`SELECT MAX(revalidatedAt) AS time FROM revalidations WHERE tag IN (${tags.map(() => "?").join(", ")})`)
|
|
16
17
|
.bind(...tags.map((tag) => this.getCacheKey(tag)))
|
|
17
18
|
.run();
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
return (result.results[0]?.time ?? 0);
|
|
19
|
+
const timeMs = (result.results[0]?.time ?? 0);
|
|
20
|
+
debugCache("D1NextModeTagCache", `getLastRevalidated tags=${tags} -> ${timeMs}`);
|
|
21
|
+
return timeMs;
|
|
22
22
|
}
|
|
23
23
|
catch (e) {
|
|
24
|
-
error(e);
|
|
25
24
|
// By default we don't want to crash here, so we return false
|
|
26
25
|
// We still log the error though so we can debug it
|
|
26
|
+
error(e);
|
|
27
27
|
return 0;
|
|
28
28
|
}
|
|
29
29
|
}
|
|
30
30
|
async hasBeenRevalidated(tags, lastModified) {
|
|
31
31
|
const { isDisabled, db } = this.getConfig();
|
|
32
|
-
if (isDisabled)
|
|
32
|
+
if (isDisabled || tags.length === 0) {
|
|
33
33
|
return false;
|
|
34
|
+
}
|
|
34
35
|
try {
|
|
35
36
|
const result = await db
|
|
36
37
|
.prepare(`SELECT 1 FROM revalidations WHERE tag IN (${tags.map(() => "?").join(", ")}) AND revalidatedAt > ? LIMIT 1`)
|
|
37
38
|
.bind(...tags.map((tag) => this.getCacheKey(tag)), lastModified ?? Date.now())
|
|
38
39
|
.raw();
|
|
39
|
-
|
|
40
|
+
const revalidated = result.length > 0;
|
|
41
|
+
debugCache("D1NextModeTagCache", `hasBeenRevalidated tags=${tags} at=${lastModified} -> ${revalidated}`);
|
|
42
|
+
return revalidated;
|
|
40
43
|
}
|
|
41
44
|
catch (e) {
|
|
42
45
|
error(e);
|
|
@@ -47,20 +50,23 @@ export class D1NextModeTagCache {
|
|
|
47
50
|
}
|
|
48
51
|
async writeTags(tags) {
|
|
49
52
|
const { isDisabled, db } = this.getConfig();
|
|
50
|
-
// TODO: Remove `tags.length === 0` when https://github.com/opennextjs/opennextjs-aws/pull/828 is used
|
|
51
53
|
if (isDisabled || tags.length === 0)
|
|
52
54
|
return Promise.resolve();
|
|
55
|
+
const nowMs = Date.now();
|
|
53
56
|
await db.batch(tags.map((tag) => db
|
|
54
57
|
.prepare(`INSERT INTO revalidations (tag, revalidatedAt) VALUES (?, ?)`)
|
|
55
|
-
.bind(this.getCacheKey(tag),
|
|
56
|
-
|
|
58
|
+
.bind(this.getCacheKey(tag), nowMs)));
|
|
59
|
+
debugCache("D1NextModeTagCache", `writeTags tags=${tags} time=${nowMs}`);
|
|
60
|
+
// TODO: See https://github.com/opennextjs/opennextjs-aws/issues/986
|
|
61
|
+
if (isPurgeCacheEnabled()) {
|
|
62
|
+
await purgeCacheByTags(tags);
|
|
63
|
+
}
|
|
57
64
|
}
|
|
58
65
|
getConfig() {
|
|
59
66
|
const db = getCloudflareContext().env[BINDING_NAME];
|
|
60
67
|
if (!db)
|
|
61
68
|
debugCache("No D1 database found");
|
|
62
|
-
const isDisabled =
|
|
63
|
-
.dangerous?.disableTagCache;
|
|
69
|
+
const isDisabled = Boolean(globalThis.openNextConfig.dangerous?.disableTagCache);
|
|
64
70
|
return !db || isDisabled
|
|
65
71
|
? { isDisabled: true }
|
|
66
72
|
: {
|
|
@@ -2,7 +2,7 @@ import { debug, error } from "@opennextjs/aws/adapters/logger.js";
|
|
|
2
2
|
import { generateShardId } from "@opennextjs/aws/core/routing/queue.js";
|
|
3
3
|
import { IgnorableError } from "@opennextjs/aws/utils/error.js";
|
|
4
4
|
import { getCloudflareContext } from "../../cloudflare-context.js";
|
|
5
|
-
import { debugCache, purgeCacheByTags } from "../internal.js";
|
|
5
|
+
import { debugCache, isPurgeCacheEnabled, purgeCacheByTags } from "../internal.js";
|
|
6
6
|
export const DEFAULT_WRITE_RETRIES = 3;
|
|
7
7
|
export const DEFAULT_NUM_SHARDS = 4;
|
|
8
8
|
export const NAME = "do-sharded-tag-cache";
|
|
@@ -32,10 +32,9 @@ class ShardedDOTagCache {
|
|
|
32
32
|
*/
|
|
33
33
|
async getLastRevalidated(tags) {
|
|
34
34
|
const { isDisabled } = this.getConfig();
|
|
35
|
-
if (isDisabled)
|
|
35
|
+
if (isDisabled || tags.length === 0) {
|
|
36
36
|
return 0;
|
|
37
|
-
|
|
38
|
-
return 0; // No tags to check
|
|
37
|
+
}
|
|
39
38
|
const deduplicatedTags = Array.from(new Set(tags)); // We deduplicate the tags to avoid unnecessary requests
|
|
40
39
|
try {
|
|
41
40
|
const shardedTagGroups = this.groupTagsByDO({ tags: deduplicatedTags });
|
|
@@ -43,16 +42,19 @@ class ShardedDOTagCache {
|
|
|
43
42
|
const cachedValue = await this.getFromRegionalCache({ doId, tags });
|
|
44
43
|
// If all the value were found in the regional cache, we can just return the max value
|
|
45
44
|
if (cachedValue.length === tags.length) {
|
|
46
|
-
|
|
45
|
+
const timeMs = Math.max(...cachedValue.map((item) => item.time));
|
|
46
|
+
debugCache("ShardedDOTagCache", `getLastRevalidated tags=${tags} -> ${timeMs} (regional cache)`);
|
|
47
|
+
return timeMs;
|
|
47
48
|
}
|
|
48
49
|
// Otherwise we need to check the durable object on the ones that were not found in the cache
|
|
49
50
|
const filteredTags = deduplicatedTags.filter((tag) => !cachedValue.some((item) => item.tag === tag));
|
|
50
51
|
const stub = this.getDurableObjectStub(doId);
|
|
51
52
|
const lastRevalidated = await stub.getLastRevalidated(filteredTags);
|
|
52
|
-
const
|
|
53
|
+
const timeMs = Math.max(...cachedValue.map((item) => item.time), lastRevalidated);
|
|
53
54
|
// We then need to populate the regional cache with the missing tags
|
|
54
55
|
getCloudflareContext().ctx.waitUntil(this.putToRegionalCache({ doId, tags }, stub));
|
|
55
|
-
|
|
56
|
+
debugCache("ShardedDOTagCache", `getLastRevalidated tags=${tags} -> ${timeMs}`);
|
|
57
|
+
return timeMs;
|
|
56
58
|
}));
|
|
57
59
|
return Math.max(...shardedTagRevalidationOutcomes);
|
|
58
60
|
}
|
|
@@ -70,8 +72,9 @@ class ShardedDOTagCache {
|
|
|
70
72
|
*/
|
|
71
73
|
async hasBeenRevalidated(tags, lastModified) {
|
|
72
74
|
const { isDisabled } = this.getConfig();
|
|
73
|
-
if (isDisabled)
|
|
75
|
+
if (isDisabled || tags.length === 0) {
|
|
74
76
|
return false;
|
|
77
|
+
}
|
|
75
78
|
try {
|
|
76
79
|
const shardedTagGroups = this.groupTagsByDO({ tags });
|
|
77
80
|
const shardedTagRevalidationOutcomes = await Promise.all(shardedTagGroups.map(async ({ doId, tags }) => {
|
|
@@ -81,6 +84,7 @@ class ShardedDOTagCache {
|
|
|
81
84
|
return (cachedValue.time ?? 0) > (lastModified ?? Date.now());
|
|
82
85
|
});
|
|
83
86
|
if (cacheHasBeenRevalidated) {
|
|
87
|
+
debugCache("ShardedDOTagCache", `hasBeenRevalidated tags=${tags} at=${lastModified} -> true (regional cache)`);
|
|
84
88
|
return true;
|
|
85
89
|
}
|
|
86
90
|
const stub = this.getDurableObjectStub(doId);
|
|
@@ -90,6 +94,7 @@ class ShardedDOTagCache {
|
|
|
90
94
|
// We need to put the missing tags in the regional cache
|
|
91
95
|
getCloudflareContext().ctx.waitUntil(this.putToRegionalCache({ doId, tags: remainingTags }, stub));
|
|
92
96
|
}
|
|
97
|
+
debugCache("ShardedDOTagCache", `hasBeenRevalidated tags=${tags} at=${lastModified} -> ${_hasBeenRevalidated}`);
|
|
93
98
|
return _hasBeenRevalidated;
|
|
94
99
|
}));
|
|
95
100
|
return shardedTagRevalidationOutcomes.some((result) => result);
|
|
@@ -109,13 +114,17 @@ class ShardedDOTagCache {
|
|
|
109
114
|
const { isDisabled } = this.getConfig();
|
|
110
115
|
if (isDisabled)
|
|
111
116
|
return;
|
|
112
|
-
const shardedTagGroups = this.groupTagsByDO({ tags, generateAllReplicas: true });
|
|
113
117
|
// We want to use the same revalidation time for all tags
|
|
114
|
-
const
|
|
118
|
+
const nowMs = Date.now();
|
|
119
|
+
debugCache("ShardedDOTagCache", `writeTags tags=${tags} time=${nowMs}`);
|
|
120
|
+
const shardedTagGroups = this.groupTagsByDO({ tags, generateAllReplicas: true });
|
|
115
121
|
await Promise.all(shardedTagGroups.map(async ({ doId, tags }) => {
|
|
116
|
-
await this.performWriteTagsWithRetry(doId, tags,
|
|
122
|
+
await this.performWriteTagsWithRetry(doId, tags, nowMs);
|
|
117
123
|
}));
|
|
118
|
-
|
|
124
|
+
// TODO: See https://github.com/opennextjs/opennextjs-aws/issues/986
|
|
125
|
+
if (isPurgeCacheEnabled()) {
|
|
126
|
+
await purgeCacheByTags(tags);
|
|
127
|
+
}
|
|
119
128
|
}
|
|
120
129
|
/**
|
|
121
130
|
* The following methods are public only because they are accessed from the tests
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { NextModeTagCache } from "@opennextjs/aws/types/overrides.js";
|
|
2
|
+
export declare const NAME = "kv-next-mode-tag-cache";
|
|
3
|
+
export declare const BINDING_NAME = "NEXT_TAG_CACHE_KV";
|
|
4
|
+
/**
|
|
5
|
+
* Tag Cache based on a KV namespace
|
|
6
|
+
*
|
|
7
|
+
* Warning:
|
|
8
|
+
* This implementation is considered experimental for now.
|
|
9
|
+
* KV is eventually consistent and can take up to 60s to reflect the last write.
|
|
10
|
+
* This means that:
|
|
11
|
+
* - revalidations can take up to 60s to apply
|
|
12
|
+
* - when a page depends on multiple tags they can be inconsistent for up to 60s.
|
|
13
|
+
* It also means that cached data could be outdated for one tag when other tags
|
|
14
|
+
* are revalidated resulting in the page being generated based on outdated data.
|
|
15
|
+
*/
|
|
16
|
+
export declare class KVNextModeTagCache implements NextModeTagCache {
|
|
17
|
+
#private;
|
|
18
|
+
readonly mode: "nextMode";
|
|
19
|
+
readonly name = "kv-next-mode-tag-cache";
|
|
20
|
+
getLastRevalidated(tags: string[]): Promise<number>;
|
|
21
|
+
hasBeenRevalidated(tags: string[], lastModified?: number): Promise<boolean>;
|
|
22
|
+
writeTags(tags: string[]): Promise<void>;
|
|
23
|
+
/**
|
|
24
|
+
* Returns the KV namespace when it exists and tag cache is not disabled.
|
|
25
|
+
*
|
|
26
|
+
* @returns KV namespace or undefined
|
|
27
|
+
*/
|
|
28
|
+
private getKv;
|
|
29
|
+
protected getCacheKey(key: string): string;
|
|
30
|
+
protected getBuildId(): string;
|
|
31
|
+
}
|
|
32
|
+
declare const _default: KVNextModeTagCache;
|
|
33
|
+
export default _default;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { error } from "@opennextjs/aws/adapters/logger.js";
|
|
2
|
+
import { getCloudflareContext } from "../../cloudflare-context.js";
|
|
3
|
+
import { debugCache, FALLBACK_BUILD_ID, isPurgeCacheEnabled, purgeCacheByTags } from "../internal.js";
|
|
4
|
+
export const NAME = "kv-next-mode-tag-cache";
|
|
5
|
+
export const BINDING_NAME = "NEXT_TAG_CACHE_KV";
|
|
6
|
+
/**
|
|
7
|
+
* Tag Cache based on a KV namespace
|
|
8
|
+
*
|
|
9
|
+
* Warning:
|
|
10
|
+
* This implementation is considered experimental for now.
|
|
11
|
+
* KV is eventually consistent and can take up to 60s to reflect the last write.
|
|
12
|
+
* This means that:
|
|
13
|
+
* - revalidations can take up to 60s to apply
|
|
14
|
+
* - when a page depends on multiple tags they can be inconsistent for up to 60s.
|
|
15
|
+
* It also means that cached data could be outdated for one tag when other tags
|
|
16
|
+
* are revalidated resulting in the page being generated based on outdated data.
|
|
17
|
+
*/
|
|
18
|
+
export class KVNextModeTagCache {
|
|
19
|
+
mode = "nextMode";
|
|
20
|
+
name = NAME;
|
|
21
|
+
async getLastRevalidated(tags) {
|
|
22
|
+
const timeMs = await this.#getLastRevalidated(tags);
|
|
23
|
+
debugCache("KVNextModeTagCache", `getLastRevalidated tags=${tags} -> time=${timeMs}`);
|
|
24
|
+
return timeMs;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Implementation of `getLastRevalidated`.
|
|
28
|
+
*
|
|
29
|
+
* This implementation is separated so that `hasBeenRevalidated` do not include logs from `getLastRevalidated`.
|
|
30
|
+
*/
|
|
31
|
+
async #getLastRevalidated(tags) {
|
|
32
|
+
const kv = this.getKv();
|
|
33
|
+
if (!kv || tags.length === 0) {
|
|
34
|
+
return 0;
|
|
35
|
+
}
|
|
36
|
+
try {
|
|
37
|
+
const keys = tags.map((tag) => this.getCacheKey(tag));
|
|
38
|
+
// Use the `json` type to get back numbers/null
|
|
39
|
+
const result = await kv.get(keys, { type: "json" });
|
|
40
|
+
const revalidations = [...result.values()].filter((v) => v != null);
|
|
41
|
+
return revalidations.length === 0 ? 0 : Math.max(...revalidations);
|
|
42
|
+
}
|
|
43
|
+
catch (e) {
|
|
44
|
+
// By default we don't want to crash here, so we return false
|
|
45
|
+
// We still log the error though so we can debug it
|
|
46
|
+
error(e);
|
|
47
|
+
return 0;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
async hasBeenRevalidated(tags, lastModified) {
|
|
51
|
+
const revalidated = (await this.#getLastRevalidated(tags)) > (lastModified ?? Date.now());
|
|
52
|
+
debugCache("KVNextModeTagCache", `hasBeenRevalidated tags=${tags} lastModified=${lastModified} -> ${revalidated}`);
|
|
53
|
+
return revalidated;
|
|
54
|
+
}
|
|
55
|
+
async writeTags(tags) {
|
|
56
|
+
const kv = this.getKv();
|
|
57
|
+
if (!kv || tags.length === 0) {
|
|
58
|
+
return Promise.resolve();
|
|
59
|
+
}
|
|
60
|
+
const nowMs = Date.now();
|
|
61
|
+
await Promise.all(tags.map(async (tag) => {
|
|
62
|
+
await kv.put(this.getCacheKey(tag), String(nowMs));
|
|
63
|
+
}));
|
|
64
|
+
debugCache("KVNextModeTagCache", `writeTags tags=${tags} time=${nowMs}`);
|
|
65
|
+
// TODO: See https://github.com/opennextjs/opennextjs-aws/issues/986
|
|
66
|
+
if (isPurgeCacheEnabled()) {
|
|
67
|
+
await purgeCacheByTags(tags);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Returns the KV namespace when it exists and tag cache is not disabled.
|
|
72
|
+
*
|
|
73
|
+
* @returns KV namespace or undefined
|
|
74
|
+
*/
|
|
75
|
+
getKv() {
|
|
76
|
+
const kv = getCloudflareContext().env[BINDING_NAME];
|
|
77
|
+
if (!kv) {
|
|
78
|
+
error(`No KV binding ${BINDING_NAME} found`);
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
81
|
+
const isDisabled = Boolean(globalThis.openNextConfig.dangerous?.disableTagCache);
|
|
82
|
+
return isDisabled ? undefined : kv;
|
|
83
|
+
}
|
|
84
|
+
getCacheKey(key) {
|
|
85
|
+
return `${this.getBuildId()}/${key}`.replaceAll("//", "/");
|
|
86
|
+
}
|
|
87
|
+
getBuildId() {
|
|
88
|
+
return process.env.NEXT_BUILD_ID ?? FALLBACK_BUILD_ID;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
export default new KVNextModeTagCache();
|
|
@@ -2,6 +2,6 @@ export { DOQueueHandler } from "./.build/durable-objects/queue.js";
|
|
|
2
2
|
export { DOShardedTagCache } from "./.build/durable-objects/sharded-tag-cache.js";
|
|
3
3
|
export { BucketCachePurge } from "./.build/durable-objects/bucket-cache-purge.js";
|
|
4
4
|
declare const _default: {
|
|
5
|
-
fetch(request: Request<unknown, IncomingRequestCfProperties<unknown>>, env: CloudflareEnv, ctx: ExecutionContext): Promise<any>;
|
|
5
|
+
fetch(request: Request<unknown, IncomingRequestCfProperties<unknown>>, env: CloudflareEnv, ctx: ExecutionContext<unknown>): Promise<any>;
|
|
6
6
|
};
|
|
7
7
|
export default _default;
|
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.9.1",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"opennextjs-cloudflare": "dist/cli/index.js"
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
"homepage": "https://github.com/opennextjs/opennextjs-cloudflare",
|
|
44
44
|
"dependencies": {
|
|
45
45
|
"@dotenvx/dotenvx": "1.31.0",
|
|
46
|
-
"@opennextjs/aws": "3.
|
|
46
|
+
"@opennextjs/aws": "3.8.0",
|
|
47
47
|
"cloudflare": "^4.4.1",
|
|
48
48
|
"enquirer": "^2.4.1",
|
|
49
49
|
"glob": "^11.0.0",
|
|
@@ -51,7 +51,7 @@
|
|
|
51
51
|
"yargs": "^18.0.0"
|
|
52
52
|
},
|
|
53
53
|
"devDependencies": {
|
|
54
|
-
"@cloudflare/workers-types": "^4.
|
|
54
|
+
"@cloudflare/workers-types": "^4.20250917.0",
|
|
55
55
|
"@eslint/js": "^9.11.1",
|
|
56
56
|
"@tsconfig/strictest": "^2.0.5",
|
|
57
57
|
"@types/mock-fs": "^4.13.4",
|