@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.
@@ -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
- if (result.length === 0)
17
- return 0;
18
- // We only care about the most recent revalidation
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
- return (this.sql
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(`Get ${key}`);
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(`Set ${key}`);
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(`Delete ${key}`);
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(`Get ${key}`);
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(`Set ${key}`);
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(`Delete ${key}`);
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 this.#hasAutomaticCachePurging;
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("Get - cached response");
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(`Get ${key}`);
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("Failed to set to read-only cache");
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
- if (result.results.length === 0)
19
- return 0;
20
- // We only care about the most recent revalidation
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
- return result.length > 0;
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), Date.now())));
56
- await purgeCacheByTags(tags);
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 = !!globalThis.openNextConfig
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
- if (tags.length === 0)
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
- return Math.max(...cachedValue.map((item) => item.time));
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 result = Math.max(...cachedValue.map((item) => item.time), lastRevalidated);
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
- return result;
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 currentTime = Date.now();
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, currentTime);
122
+ await this.performWriteTagsWithRetry(doId, tags, nowMs);
117
123
  }));
118
- await purgeCacheByTags(tags);
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.8.5",
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.7.7",
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.20250224.0",
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",