From 5409b51497d3cb0948daa96593cd0fd9c4e9dc9d Mon Sep 17 00:00:00 2001 From: Dorseuil Nicolas Date: Thu, 8 May 2025 14:30:58 +0200 Subject: [PATCH 01/11] basic purge cache --- examples/e2e/app-router/open-next.config.ts | 5 ++- .../cloudflare/src/api/cloudflare-context.ts | 4 ++ packages/cloudflare/src/api/config.ts | 18 +++++++- .../src/api/overrides/cache-purge/index.ts | 13 ++++++ .../cloudflare/src/api/overrides/internal.ts | 43 +++++++++++++++++++ .../overrides/tag-cache/d1-next-tag-cache.ts | 3 +- .../tag-cache/do-sharded-tag-cache.ts | 3 +- 7 files changed, 84 insertions(+), 5 deletions(-) create mode 100644 packages/cloudflare/src/api/overrides/cache-purge/index.ts diff --git a/examples/e2e/app-router/open-next.config.ts b/examples/e2e/app-router/open-next.config.ts index 5a7cd67e..9cd0fd1b 100644 --- a/examples/e2e/app-router/open-next.config.ts +++ b/examples/e2e/app-router/open-next.config.ts @@ -1,11 +1,13 @@ import { defineCloudflareConfig } from "@opennextjs/cloudflare"; import r2IncrementalCache from "@opennextjs/cloudflare/overrides/incremental-cache/r2-incremental-cache"; +import {withRegionalCache} from "@opennextjs/cloudflare/overrides/incremental-cache/regional-cache"; import shardedTagCache from "@opennextjs/cloudflare/overrides/tag-cache/do-sharded-tag-cache"; import doQueue from "@opennextjs/cloudflare/overrides/queue/do-queue"; import queueCache from "@opennextjs/cloudflare/overrides/queue/queue-cache"; +import cachePurge from "@opennextjs/cloudflare/overrides/cache-purge/index"; export default defineCloudflareConfig({ - incrementalCache: r2IncrementalCache, + incrementalCache: withRegionalCache(r2IncrementalCache, {mode: "long-lived"}), // With such a configuration, we could have up to 12 * (8 + 2) = 120 Durable Objects instances tagCache: shardedTagCache({ baseShardSize: 12, @@ -14,6 +16,7 @@ export default defineCloudflareConfig({ numberOfHardReplicas: 2, }, }), + cachePurge: cachePurge, enableCacheInterception: true, queue: queueCache(doQueue), }); diff --git a/packages/cloudflare/src/api/cloudflare-context.ts b/packages/cloudflare/src/api/cloudflare-context.ts index 45bf5fde..18bc4911 100644 --- a/packages/cloudflare/src/api/cloudflare-context.ts +++ b/packages/cloudflare/src/api/cloudflare-context.ts @@ -54,6 +54,10 @@ declare global { // Disable SQLite for the durable object queue handler // This can be safely used if you don't use an eventually consistent incremental cache (i.e. R2 without the regional cache for example) NEXT_CACHE_DO_QUEUE_DISABLE_SQLITE?: string; + + // Below are the optional env variables for purging the cache + CACHE_ZONE_ID?: string; + CACHE_API_TOKEN?: string; } } diff --git a/packages/cloudflare/src/api/config.ts b/packages/cloudflare/src/api/config.ts index 5b48b913..1d2c715e 100644 --- a/packages/cloudflare/src/api/config.ts +++ b/packages/cloudflare/src/api/config.ts @@ -4,7 +4,7 @@ import { LazyLoadedOverride, OpenNextConfig as AwsOpenNextConfig, } from "@opennextjs/aws/types/open-next"; -import type { IncrementalCache, Queue, TagCache } from "@opennextjs/aws/types/overrides"; +import type { CDNInvalidationHandler, IncrementalCache, Queue, TagCache } from "@opennextjs/aws/types/overrides"; export type Override = "dummy" | T | LazyLoadedOverride; @@ -29,6 +29,11 @@ export type CloudflareOverrides = { */ queue?: "direct" | Override; + /** + * Sets the automatic cache purge implementation + */ + cachePurge?: Override + /** * Enable cache interception * Should be `false` when PPR is used @@ -44,7 +49,7 @@ export type CloudflareOverrides = { * @returns the OpenNext configuration object */ export function defineCloudflareConfig(config: CloudflareOverrides = {}): OpenNextConfig { - const { incrementalCache, tagCache, queue, enableCacheInterception = false } = config; + const { incrementalCache, tagCache, queue, cachePurge, enableCacheInterception = false } = config; return { default: { @@ -55,6 +60,7 @@ export function defineCloudflareConfig(config: CloudflareOverrides = {}): OpenNe incrementalCache: resolveIncrementalCache(incrementalCache), tagCache: resolveTagCache(tagCache), queue: resolveQueue(queue), + cdnInvalidation: resolveCdnInvalidation(cachePurge), }, routePreloadingBehavior: "withWaitUntil", }, @@ -104,6 +110,14 @@ function resolveQueue(value: CloudflareOverrides["queue"] = "dummy") { return typeof value === "function" ? value : () => value; } +function resolveCdnInvalidation(value: CloudflareOverrides["cachePurge"] = "dummy") { + if (typeof value === "string") { + return value; + } + + return typeof value === "function" ? value : () => value; +} + interface OpenNextConfig extends AwsOpenNextConfig { cloudflare?: { /** diff --git a/packages/cloudflare/src/api/overrides/cache-purge/index.ts b/packages/cloudflare/src/api/overrides/cache-purge/index.ts new file mode 100644 index 00000000..34cf823b --- /dev/null +++ b/packages/cloudflare/src/api/overrides/cache-purge/index.ts @@ -0,0 +1,13 @@ +import type { CDNInvalidationHandler } from "@opennextjs/aws/types/overrides"; + +import { debugCache, purgeCacheByTags } from "../internal.js"; + +export default { + name: "cloudflare", + async invalidatePaths(paths) { + const tags = paths.map((path) => `_N_T_${path.rawPath}`); + debugCache("cdnInvalidation", "Invalidating paths:", tags); + await purgeCacheByTags(tags); + debugCache("cdnInvalidation", "Invalidated paths:", tags); + } +} satisfies CDNInvalidationHandler; \ No newline at end of file diff --git a/packages/cloudflare/src/api/overrides/internal.ts b/packages/cloudflare/src/api/overrides/internal.ts index cb40e019..a1fa2ea3 100644 --- a/packages/cloudflare/src/api/overrides/internal.ts +++ b/packages/cloudflare/src/api/overrides/internal.ts @@ -2,6 +2,8 @@ import { createHash } from "node:crypto"; import type { CacheEntryType, CacheValue } from "@opennextjs/aws/types/overrides.js"; +import { getCloudflareContext } from "../cloudflare-context.js"; + export type IncrementalCacheEntry = { value: CacheValue; lastModified: number; @@ -28,3 +30,44 @@ export function computeCacheKey(key: string, options: KeyOptions) { const hash = createHash("sha256").update(key).digest("hex"); return `${prefix}/${buildId}/${hash}.${cacheType}`.replace(/\/+/g, "/"); } + + +export async function purgeCacheByTags(tags: string[]) { + const {env} = getCloudflareContext() + + if(!env.CACHE_ZONE_ID && !env.CACHE_API_TOKEN) { + // THIS IS A NO-OP + debugCache("purgeCacheByTags", "No cache zone ID or API token provided. Skipping cache purge."); + return; + } + + try { + const response = await fetch( + `https://api.cloudflare.com/client/v4/zones/${env.CACHE_ZONE_ID}/purge_cache`, + { + headers: { + "Authorization": `Bearer ${env.CACHE_API_TOKEN}`, + "Content-Type": "application/json", + }, + method: "POST", + body: JSON.stringify({ + tags, + }), + }) + if (!response.ok) { + const text = await response.text(); + throw new Error(`Failed to purge cache: ${response.status} ${text}`); + } + const bodyResponse = await response.json() as { + success: boolean; + errors: Array<{ code: number; message: string }>; + messages: Array<{ code: number; message: string }>; + } + if (!bodyResponse.success) { + throw new Error(`Failed to purge cache: ${JSON.stringify(bodyResponse.errors)}`); + } + debugCache("purgeCacheByTags", "Cache purged successfully for tags:", tags); + }catch (error) { + console.error("Error purging cache by tags:", error); + } +} \ No newline at end of file diff --git a/packages/cloudflare/src/api/overrides/tag-cache/d1-next-tag-cache.ts b/packages/cloudflare/src/api/overrides/tag-cache/d1-next-tag-cache.ts index b76f31ce..76ff11af 100644 --- a/packages/cloudflare/src/api/overrides/tag-cache/d1-next-tag-cache.ts +++ b/packages/cloudflare/src/api/overrides/tag-cache/d1-next-tag-cache.ts @@ -3,7 +3,7 @@ import type { NextModeTagCache } from "@opennextjs/aws/types/overrides.js"; import type { OpenNextConfig } from "../../../api/config.js"; import { getCloudflareContext } from "../../cloudflare-context.js"; -import { debugCache, FALLBACK_BUILD_ID } from "../internal.js"; +import { debugCache, FALLBACK_BUILD_ID, purgeCacheByTags } from "../internal.js"; export const NAME = "d1-next-mode-tag-cache"; @@ -66,6 +66,7 @@ export class D1NextModeTagCache implements NextModeTagCache { .bind(this.getCacheKey(tag), Date.now()) ) ); + await purgeCacheByTags(tags); } private getConfig() { diff --git a/packages/cloudflare/src/api/overrides/tag-cache/do-sharded-tag-cache.ts b/packages/cloudflare/src/api/overrides/tag-cache/do-sharded-tag-cache.ts index b5ab92c1..9c9a543f 100644 --- a/packages/cloudflare/src/api/overrides/tag-cache/do-sharded-tag-cache.ts +++ b/packages/cloudflare/src/api/overrides/tag-cache/do-sharded-tag-cache.ts @@ -5,7 +5,7 @@ import { IgnorableError } from "@opennextjs/aws/utils/error.js"; import type { OpenNextConfig } from "../../../api/config.js"; import { getCloudflareContext } from "../../cloudflare-context"; -import { debugCache } from "../internal"; +import { debugCache, purgeCacheByTags } from "../internal"; export const DEFAULT_WRITE_RETRIES = 3; export const DEFAULT_NUM_SHARDS = 4; @@ -304,6 +304,7 @@ class ShardedDOTagCache implements NextModeTagCache { await this.performWriteTagsWithRetry(doId, tags, currentTime); }) ); + await purgeCacheByTags(tags); } async performWriteTagsWithRetry(doId: DOId, tags: string[], lastModified: number, retryNumber = 0) { From b04e5eaf0cb709b267513d67065671f384abfad3 Mon Sep 17 00:00:00 2001 From: Dorseuil Nicolas Date: Thu, 8 May 2025 18:03:09 +0200 Subject: [PATCH 02/11] lint and fix e2e --- examples/e2e/app-router/open-next.config.ts | 3 +- packages/cloudflare/src/api/config.ts | 9 +++-- .../src/api/overrides/cache-purge/index.ts | 4 +-- .../cloudflare/src/api/overrides/internal.ts | 34 +++++++++---------- 4 files changed, 27 insertions(+), 23 deletions(-) diff --git a/examples/e2e/app-router/open-next.config.ts b/examples/e2e/app-router/open-next.config.ts index 9cd0fd1b..33235324 100644 --- a/examples/e2e/app-router/open-next.config.ts +++ b/examples/e2e/app-router/open-next.config.ts @@ -1,13 +1,12 @@ import { defineCloudflareConfig } from "@opennextjs/cloudflare"; import r2IncrementalCache from "@opennextjs/cloudflare/overrides/incremental-cache/r2-incremental-cache"; -import {withRegionalCache} from "@opennextjs/cloudflare/overrides/incremental-cache/regional-cache"; import shardedTagCache from "@opennextjs/cloudflare/overrides/tag-cache/do-sharded-tag-cache"; import doQueue from "@opennextjs/cloudflare/overrides/queue/do-queue"; import queueCache from "@opennextjs/cloudflare/overrides/queue/queue-cache"; import cachePurge from "@opennextjs/cloudflare/overrides/cache-purge/index"; export default defineCloudflareConfig({ - incrementalCache: withRegionalCache(r2IncrementalCache, {mode: "long-lived"}), + incrementalCache: r2IncrementalCache, // With such a configuration, we could have up to 12 * (8 + 2) = 120 Durable Objects instances tagCache: shardedTagCache({ baseShardSize: 12, diff --git a/packages/cloudflare/src/api/config.ts b/packages/cloudflare/src/api/config.ts index 1d2c715e..aadc50d2 100644 --- a/packages/cloudflare/src/api/config.ts +++ b/packages/cloudflare/src/api/config.ts @@ -4,7 +4,12 @@ import { LazyLoadedOverride, OpenNextConfig as AwsOpenNextConfig, } from "@opennextjs/aws/types/open-next"; -import type { CDNInvalidationHandler, IncrementalCache, Queue, TagCache } from "@opennextjs/aws/types/overrides"; +import type { + CDNInvalidationHandler, + IncrementalCache, + Queue, + TagCache, +} from "@opennextjs/aws/types/overrides"; export type Override = "dummy" | T | LazyLoadedOverride; @@ -32,7 +37,7 @@ export type CloudflareOverrides = { /** * Sets the automatic cache purge implementation */ - cachePurge?: Override + cachePurge?: Override; /** * Enable cache interception diff --git a/packages/cloudflare/src/api/overrides/cache-purge/index.ts b/packages/cloudflare/src/api/overrides/cache-purge/index.ts index 34cf823b..f0a7dcf8 100644 --- a/packages/cloudflare/src/api/overrides/cache-purge/index.ts +++ b/packages/cloudflare/src/api/overrides/cache-purge/index.ts @@ -9,5 +9,5 @@ export default { debugCache("cdnInvalidation", "Invalidating paths:", tags); await purgeCacheByTags(tags); debugCache("cdnInvalidation", "Invalidated paths:", tags); - } -} satisfies CDNInvalidationHandler; \ No newline at end of file + }, +} satisfies CDNInvalidationHandler; diff --git a/packages/cloudflare/src/api/overrides/internal.ts b/packages/cloudflare/src/api/overrides/internal.ts index a1fa2ea3..e719497e 100644 --- a/packages/cloudflare/src/api/overrides/internal.ts +++ b/packages/cloudflare/src/api/overrides/internal.ts @@ -31,11 +31,10 @@ export function computeCacheKey(key: string, options: KeyOptions) { return `${prefix}/${buildId}/${hash}.${cacheType}`.replace(/\/+/g, "/"); } - export async function purgeCacheByTags(tags: string[]) { - const {env} = getCloudflareContext() + const { env } = getCloudflareContext(); - if(!env.CACHE_ZONE_ID && !env.CACHE_API_TOKEN) { + if (!env.CACHE_ZONE_ID && !env.CACHE_API_TOKEN) { // THIS IS A NO-OP debugCache("purgeCacheByTags", "No cache zone ID or API token provided. Skipping cache purge."); return; @@ -44,30 +43,31 @@ export async function purgeCacheByTags(tags: string[]) { try { const response = await fetch( `https://api.cloudflare.com/client/v4/zones/${env.CACHE_ZONE_ID}/purge_cache`, - { - headers: { - "Authorization": `Bearer ${env.CACHE_API_TOKEN}`, - "Content-Type": "application/json", - }, - method: "POST", - body: JSON.stringify({ - tags, - }), - }) + { + headers: { + Authorization: `Bearer ${env.CACHE_API_TOKEN}`, + "Content-Type": "application/json", + }, + method: "POST", + body: JSON.stringify({ + tags, + }), + } + ); if (!response.ok) { const text = await response.text(); throw new Error(`Failed to purge cache: ${response.status} ${text}`); } - const bodyResponse = await response.json() as { + const bodyResponse = (await response.json()) as { success: boolean; errors: Array<{ code: number; message: string }>; messages: Array<{ code: number; message: string }>; - } + }; if (!bodyResponse.success) { throw new Error(`Failed to purge cache: ${JSON.stringify(bodyResponse.errors)}`); } debugCache("purgeCacheByTags", "Cache purged successfully for tags:", tags); - }catch (error) { + } catch (error) { console.error("Error purging cache by tags:", error); } -} \ No newline at end of file +} From f7a9246e76326a48ae7a2419583edc24c0066204 Mon Sep 17 00:00:00 2001 From: Dorseuil Nicolas Date: Fri, 9 May 2025 14:35:18 +0200 Subject: [PATCH 03/11] DO cache purge --- examples/e2e/app-router/open-next.config.ts | 4 +- examples/e2e/app-router/wrangler.jsonc | 6 +- .../cloudflare/src/api/cloudflare-context.ts | 4 + .../api/durable-objects/bucket-cache-purge.ts | 78 +++++++++++++++++++ .../src/api/overrides/cache-purge/index.ts | 40 +++++++--- .../cloudflare/src/api/overrides/internal.ts | 21 +++++ .../build/open-next/compileDurableObjects.ts | 1 + .../cloudflare/src/cli/templates/worker.ts | 2 + 8 files changed, 143 insertions(+), 13 deletions(-) create mode 100644 packages/cloudflare/src/api/durable-objects/bucket-cache-purge.ts diff --git a/examples/e2e/app-router/open-next.config.ts b/examples/e2e/app-router/open-next.config.ts index 33235324..f2803ca6 100644 --- a/examples/e2e/app-router/open-next.config.ts +++ b/examples/e2e/app-router/open-next.config.ts @@ -3,7 +3,7 @@ import r2IncrementalCache from "@opennextjs/cloudflare/overrides/incremental-cac import shardedTagCache from "@opennextjs/cloudflare/overrides/tag-cache/do-sharded-tag-cache"; import doQueue from "@opennextjs/cloudflare/overrides/queue/do-queue"; import queueCache from "@opennextjs/cloudflare/overrides/queue/queue-cache"; -import cachePurge from "@opennextjs/cloudflare/overrides/cache-purge/index"; +import { purgeCache } from "@opennextjs/cloudflare/overrides/cache-purge/index"; export default defineCloudflareConfig({ incrementalCache: r2IncrementalCache, @@ -15,7 +15,7 @@ export default defineCloudflareConfig({ numberOfHardReplicas: 2, }, }), - cachePurge: cachePurge, + cachePurge: purgeCache({type: "durableObject"}), enableCacheInterception: true, queue: queueCache(doQueue), }); diff --git a/examples/e2e/app-router/wrangler.jsonc b/examples/e2e/app-router/wrangler.jsonc index d3575257..89fa87fb 100644 --- a/examples/e2e/app-router/wrangler.jsonc +++ b/examples/e2e/app-router/wrangler.jsonc @@ -17,13 +17,17 @@ { "name": "NEXT_TAG_CACHE_DO_SHARDED", "class_name": "DOShardedTagCache" + }, + { + "name": "NEXT_CACHE_DO_PURGE", + "class_name": "BucketCachePurge" } ] }, "migrations": [ { "tag": "v1", - "new_sqlite_classes": ["DOQueueHandler", "DOShardedTagCache"] + "new_sqlite_classes": ["DOQueueHandler", "DOShardedTagCache", "BucketCachePurge"] } ], "r2_buckets": [ diff --git a/packages/cloudflare/src/api/cloudflare-context.ts b/packages/cloudflare/src/api/cloudflare-context.ts index 18bc4911..08917249 100644 --- a/packages/cloudflare/src/api/cloudflare-context.ts +++ b/packages/cloudflare/src/api/cloudflare-context.ts @@ -2,6 +2,7 @@ import type { Context, RunningCodeOptions } from "node:vm"; import type { GetPlatformProxyOptions } from "wrangler"; +import type { BucketCachePurge } from "./durable-objects/bucket-cache-purge.js"; import type { DOQueueHandler } from "./durable-objects/queue.js"; import type { DOShardedTagCache } from "./durable-objects/sharded-tag-cache.js"; import type { PREFIX_ENV_NAME as KV_CACHE_PREFIX_ENV_NAME } from "./overrides/incremental-cache/kv-incremental-cache.js"; @@ -56,8 +57,11 @@ declare global { NEXT_CACHE_DO_QUEUE_DISABLE_SQLITE?: string; // Below are the optional env variables for purging the cache + // Durable Object namespace to use for the durable object queue + NEXT_CACHE_DO_PURGE?: DurableObjectNamespace; CACHE_ZONE_ID?: string; CACHE_API_TOKEN?: string; + CACHE_BUFFER_TIME_IN_SECONDS?: string; } } diff --git a/packages/cloudflare/src/api/durable-objects/bucket-cache-purge.ts b/packages/cloudflare/src/api/durable-objects/bucket-cache-purge.ts new file mode 100644 index 00000000..a999c488 --- /dev/null +++ b/packages/cloudflare/src/api/durable-objects/bucket-cache-purge.ts @@ -0,0 +1,78 @@ +import { DurableObject } from "cloudflare:workers"; + +import { internalPurgeCacheByTags } from "../overrides/internal"; + +const DEFAULT_BUFFER_TIME = 5; // seconds + +export class BucketCachePurge extends DurableObject { + bufferTimeInSeconds: number; + + constructor(state: DurableObjectState, env: CloudflareEnv) { + super(state, env); + this.bufferTimeInSeconds = env.CACHE_BUFFER_TIME_IN_SECONDS + ? parseInt(env.CACHE_BUFFER_TIME_IN_SECONDS) + : DEFAULT_BUFFER_TIME; // Default buffer time + + // Initialize the sql table if it doesn't exist + state.storage.sql.exec(` + CREATE TABLE IF NOT EXISTS cache_purge ( + tag TEXT NOT NULL + ); + CREATE UNIQUE INDEX IF NOT EXISTS tag_index ON cache_purge (tag); + `); + } + + async purgeCacheByTags(tags: string[]) { + for (const tag of tags) { + // Insert the tag into the sql table + this.ctx.storage.sql.exec( + ` + INSERT OR REPLACE INTO cache_purge (tag) + VALUES (?)`, + [tag] + ); + } + const nextAlarm = await this.ctx.storage.getAlarm(); + if (!nextAlarm) { + // Set an alarm to trigger the cache purge + this.ctx.storage.setAlarm(Date.now() + this.bufferTimeInSeconds * 1000); + } + } + + override async alarm() { + let tags = this.ctx.storage.sql + .exec<{ tag: string }>( + ` + SELECT * FROM cache_purge LIMIT 100 + ` + ) + .toArray(); + do { + await internalPurgeCacheByTags( + this.env, + tags.map((row) => row.tag) + ); + // Delete the tags from the sql table + this.ctx.storage.sql.exec( + ` + DELETE FROM cache_purge + WHERE tag IN (${tags.map(() => "?").join(",")}) + `, + tags.map((row) => row.tag) + ); + if (tags.length < 100) { + // If we have less than 100 tags, we can stop + tags = []; + } else { + // Otherwise, we need to get the next 100 tags + tags = this.ctx.storage.sql + .exec<{ tag: string }>( + ` + SELECT * FROM cache_purge LIMIT 100 + ` + ) + .toArray(); + } + } while (tags.length > 0); + } +} diff --git a/packages/cloudflare/src/api/overrides/cache-purge/index.ts b/packages/cloudflare/src/api/overrides/cache-purge/index.ts index f0a7dcf8..f1a43473 100644 --- a/packages/cloudflare/src/api/overrides/cache-purge/index.ts +++ b/packages/cloudflare/src/api/overrides/cache-purge/index.ts @@ -1,13 +1,33 @@ import type { CDNInvalidationHandler } from "@opennextjs/aws/types/overrides"; -import { debugCache, purgeCacheByTags } from "../internal.js"; +import { getCloudflareContext } from "../../cloudflare-context"; +import { debugCache, internalPurgeCacheByTags } from "../internal.js"; -export default { - name: "cloudflare", - async invalidatePaths(paths) { - const tags = paths.map((path) => `_N_T_${path.rawPath}`); - debugCache("cdnInvalidation", "Invalidating paths:", tags); - await purgeCacheByTags(tags); - debugCache("cdnInvalidation", "Invalidated paths:", tags); - }, -} satisfies CDNInvalidationHandler; +interface PurgeOptions { + type: "durableObject" | "direct"; +} + +export const purgeCache = ({ type = "direct" }: PurgeOptions) => { + return { + name: "cloudflare", + async invalidatePaths(paths) { + const { env } = getCloudflareContext(); + const tags = paths.map((path) => `_N_T_${path.rawPath}`); + debugCache("cdnInvalidation", "Invalidating paths:", tags); + if (type === "direct") { + await internalPurgeCacheByTags(env, tags); + } else { + console.log("purgeCacheByTags DO", tags); + const durableObject = env.NEXT_CACHE_DO_PURGE; + if (!durableObject) { + debugCache("cdnInvalidation", "No durable object found. Skipping cache purge."); + return; + } + const id = durableObject.idFromName("cache-purge"); + const obj = durableObject.get(id); + await obj.purgeCacheByTags(tags); + } + debugCache("cdnInvalidation", "Invalidated paths:", tags); + }, + } satisfies CDNInvalidationHandler; +}; diff --git a/packages/cloudflare/src/api/overrides/internal.ts b/packages/cloudflare/src/api/overrides/internal.ts index e719497e..7bd04648 100644 --- a/packages/cloudflare/src/api/overrides/internal.ts +++ b/packages/cloudflare/src/api/overrides/internal.ts @@ -33,6 +33,27 @@ export function computeCacheKey(key: string, options: KeyOptions) { export async function purgeCacheByTags(tags: string[]) { const { env } = getCloudflareContext(); + // We have a durable object for purging cache + // We should use it + if (env.NEXT_CACHE_DO_PURGE) { + const durableObject = env.NEXT_CACHE_DO_PURGE; + if (!durableObject) { + debugCache("purgeCacheByTags", "No durable object found. Skipping cache purge."); + return; + } + const id = durableObject.idFromName("cache-purge"); + const obj = durableObject.get(id); + await obj.purgeCacheByTags(tags); + } else { + // We don't have a durable object for purging cache + // We should use the API directly + await internalPurgeCacheByTags(env, tags); + } +} + +export async function internalPurgeCacheByTags(env: CloudflareEnv, tags: string[]) { + //TODO: Remove this before commit + console.log("purgeCacheByTags", tags); if (!env.CACHE_ZONE_ID && !env.CACHE_API_TOKEN) { // THIS IS A NO-OP diff --git a/packages/cloudflare/src/cli/build/open-next/compileDurableObjects.ts b/packages/cloudflare/src/cli/build/open-next/compileDurableObjects.ts index 7a9b13ad..f28be4ab 100644 --- a/packages/cloudflare/src/cli/build/open-next/compileDurableObjects.ts +++ b/packages/cloudflare/src/cli/build/open-next/compileDurableObjects.ts @@ -9,6 +9,7 @@ export function compileDurableObjects(buildOpts: BuildOptions) { const entryPoints = [ _require.resolve("@opennextjs/cloudflare/durable-objects/queue"), _require.resolve("@opennextjs/cloudflare/durable-objects/sharded-tag-cache"), + _require.resolve("@opennextjs/cloudflare/durable-objects/bucket-cache-purge"), ]; const { outputDir } = buildOpts; diff --git a/packages/cloudflare/src/cli/templates/worker.ts b/packages/cloudflare/src/cli/templates/worker.ts index d3be4227..2af514af 100644 --- a/packages/cloudflare/src/cli/templates/worker.ts +++ b/packages/cloudflare/src/cli/templates/worker.ts @@ -7,6 +7,8 @@ import { handler as middlewareHandler } from "./middleware/handler.mjs"; export { DOQueueHandler } from "./.build/durable-objects/queue.js"; //@ts-expect-error: Will be resolved by wrangler build export { DOShardedTagCache } from "./.build/durable-objects/sharded-tag-cache.js"; +//@ts-expect-error: Will be resolved by wrangler build +export { BucketCachePurge } from "./.build/durable-objects/bucket-cache-purge.js"; export default { async fetch(request, env, ctx) { From 7015a9dac485bc3057a767bbddaead93e66570e0 Mon Sep 17 00:00:00 2001 From: Dorseuil Nicolas Date: Fri, 9 May 2025 16:10:43 +0200 Subject: [PATCH 04/11] added unit test --- .../bucket-cache-purge.spec.ts | 152 ++++++++++++++++++ .../api/durable-objects/bucket-cache-purge.ts | 9 +- 2 files changed, 160 insertions(+), 1 deletion(-) create mode 100644 packages/cloudflare/src/api/durable-objects/bucket-cache-purge.spec.ts diff --git a/packages/cloudflare/src/api/durable-objects/bucket-cache-purge.spec.ts b/packages/cloudflare/src/api/durable-objects/bucket-cache-purge.spec.ts new file mode 100644 index 00000000..6aff368b --- /dev/null +++ b/packages/cloudflare/src/api/durable-objects/bucket-cache-purge.spec.ts @@ -0,0 +1,152 @@ +import { describe, expect, it, vi } from "vitest"; + +import * as internal from "../overrides/internal"; +import { BucketCachePurge } from "./bucket-cache-purge"; + +vi.mock("cloudflare:workers", () => ({ + DurableObject: class { + constructor( + public ctx: DurableObjectState, + public env: CloudflareEnv + ) {} + }, +})); + + +const createBucketCachePurge = () => { + const mockState = { + waitUntil: vi.fn(), + blockConcurrencyWhile: vi.fn().mockImplementation(async (fn) => fn()), + storage: { + setAlarm: vi.fn(), + getAlarm: vi.fn(), + sql: { + exec: vi.fn().mockImplementation(() => ({ + one: vi.fn(), + toArray: vi.fn().mockReturnValue([]), + })), + }, + }, + }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return new BucketCachePurge(mockState as any, {}); +}; + +describe("BucketCachePurge", () => { + it("should block concurrency while creating the table", async () => { + const cache = createBucketCachePurge(); + // @ts-expect-error - testing private method + expect(cache.ctx.blockConcurrencyWhile).toHaveBeenCalled(); + // @ts-expect-error - testing private method + expect(cache.ctx.storage.sql.exec).toHaveBeenCalledWith( + expect.stringContaining("CREATE TABLE IF NOT EXISTS cache_purge"), + ); + }); + + describe("purgeCacheByTags", () => { + it("should insert tags into the sql table", async () => { + const cache = createBucketCachePurge(); + const tags = ["tag1", "tag2"]; + await cache.purgeCacheByTags(tags); + // @ts-expect-error - testing private method + expect(cache.ctx.storage.sql.exec).toHaveBeenCalledWith( + expect.stringContaining("INSERT OR REPLACE INTO cache_purge"), + [tags[0]], + ); + // @ts-expect-error - testing private method + expect(cache.ctx.storage.sql.exec).toHaveBeenCalledWith( + expect.stringContaining("INSERT OR REPLACE INTO cache_purge"), + [tags[1]], + ); + }); + + it("should set an alarm if no alarm is set", async () => { + const cache = createBucketCachePurge(); + // @ts-expect-error - testing private method + cache.ctx.storage.getAlarm.mockResolvedValueOnce(null); + await cache.purgeCacheByTags(["tag"]); + // @ts-expect-error - testing private method + expect(cache.ctx.storage.setAlarm).toHaveBeenCalled(); + }); + + it("should not set an alarm if one is already set", async () => { + const cache = createBucketCachePurge(); + // @ts-expect-error - testing private method + cache.ctx.storage.getAlarm.mockResolvedValueOnce(true); + await cache.purgeCacheByTags(["tag"]); + // @ts-expect-error - testing private method + expect(cache.ctx.storage.setAlarm).not.toHaveBeenCalled(); + }); + }) + + describe("alarm", () => { + it("should purge cache by tags and delete them from the sql table", async () => { + const cache = createBucketCachePurge(); + // @ts-expect-error - testing private method + cache.ctx.storage.sql.exec.mockReturnValueOnce({ + toArray: () => [{ tag: "tag1" }, { tag: "tag2" }], + }); + await cache.alarm(); + // @ts-expect-error - testing private method + expect(cache.ctx.storage.sql.exec).toHaveBeenCalledWith( + expect.stringContaining("DELETE FROM cache_purge"), + ["tag1", "tag2"], + ); + }); + it("should not purge cache if no tags are found", async () => { + const cache = createBucketCachePurge(); + // @ts-expect-error - testing private method + cache.ctx.storage.sql.exec.mockReturnValueOnce({ + toArray: () => [], + }); + await cache.alarm(); + // @ts-expect-error - testing private method + expect(cache.ctx.storage.sql.exec).not.toHaveBeenCalledWith( + expect.stringContaining("DELETE FROM cache_purge"), + [], + ); + }); + + it("should call internalPurgeCacheByTags with the correct tags", async () => { + const cache = createBucketCachePurge(); + const tags = ["tag1", "tag2"]; + // @ts-expect-error - testing private method + cache.ctx.storage.sql.exec.mockReturnValueOnce({ + toArray: () => tags.map((tag) => ({ tag })), + }); + const internalPurgeCacheByTagsSpy = vi.spyOn(internal, "internalPurgeCacheByTags"); + await cache.alarm(); + expect(internalPurgeCacheByTagsSpy).toHaveBeenCalledWith( + // @ts-expect-error - testing private method + cache.env, + tags, + ); + // @ts-expect-error - testing private method 1st is constructor, 2nd is to get the tags and 3rd is to delete them + expect(cache.ctx.storage.sql.exec).toHaveBeenCalledTimes(3); + }); + + it("should continue until all tags are purged", async () => { + const cache = createBucketCachePurge(); + const tags = Array.from({ length: 100 }, (_, i) => `tag${i}`); + // @ts-expect-error - testing private method + cache.ctx.storage.sql.exec.mockReturnValueOnce({ + toArray: () => tags.map((tag) => ({ tag })), + }); + const internalPurgeCacheByTagsSpy = vi.spyOn(internal, "internalPurgeCacheByTags"); + await cache.alarm(); + expect(internalPurgeCacheByTagsSpy).toHaveBeenCalledWith( + // @ts-expect-error - testing private method + cache.env, + tags, + ); + // @ts-expect-error - testing private method 1st is constructor, 2nd is to get the tags and 3rd is to delete them, 4th is to get the next 100 tags + expect(cache.ctx.storage.sql.exec).toHaveBeenCalledTimes(4); + // @ts-expect-error - testing private method + expect(cache.ctx.storage.sql.exec).toHaveBeenLastCalledWith( + expect.stringContaining("SELECT * FROM cache_purge LIMIT 100"), + ); + }); + + + }) +}); \ No newline at end of file diff --git a/packages/cloudflare/src/api/durable-objects/bucket-cache-purge.ts b/packages/cloudflare/src/api/durable-objects/bucket-cache-purge.ts index a999c488..1b3edecf 100644 --- a/packages/cloudflare/src/api/durable-objects/bucket-cache-purge.ts +++ b/packages/cloudflare/src/api/durable-objects/bucket-cache-purge.ts @@ -14,12 +14,14 @@ export class BucketCachePurge extends DurableObject { : DEFAULT_BUFFER_TIME; // Default buffer time // Initialize the sql table if it doesn't exist - state.storage.sql.exec(` + state.blockConcurrencyWhile(async () => { + state.storage.sql.exec(` CREATE TABLE IF NOT EXISTS cache_purge ( tag TEXT NOT NULL ); CREATE UNIQUE INDEX IF NOT EXISTS tag_index ON cache_purge (tag); `); + }) } async purgeCacheByTags(tags: string[]) { @@ -47,6 +49,11 @@ export class BucketCachePurge extends DurableObject { ` ) .toArray(); + if (tags.length === 0) { + // No tags to purge, we can stop + // It shouldn't happen, but just in case + return; + } do { await internalPurgeCacheByTags( this.env, From 103d45b666ac31c7661ad8d25780f443c6bc295f Mon Sep 17 00:00:00 2001 From: Dorseuil Nicolas Date: Fri, 9 May 2025 16:23:11 +0200 Subject: [PATCH 05/11] refactor and lint fix --- .../cloudflare/src/api/cloudflare-context.ts | 11 +++++--- .../bucket-cache-purge.spec.ts | 25 ++++++++----------- .../api/durable-objects/bucket-cache-purge.ts | 6 ++--- .../src/api/overrides/cache-purge/index.ts | 1 - .../cloudflare/src/api/overrides/internal.ts | 10 +++----- 5 files changed, 24 insertions(+), 29 deletions(-) diff --git a/packages/cloudflare/src/api/cloudflare-context.ts b/packages/cloudflare/src/api/cloudflare-context.ts index 08917249..575c64f2 100644 --- a/packages/cloudflare/src/api/cloudflare-context.ts +++ b/packages/cloudflare/src/api/cloudflare-context.ts @@ -57,11 +57,14 @@ declare global { NEXT_CACHE_DO_QUEUE_DISABLE_SQLITE?: string; // Below are the optional env variables for purging the cache - // Durable Object namespace to use for the durable object queue + // Durable Object namespace to use for the durable object cache purge NEXT_CACHE_DO_PURGE?: DurableObjectNamespace; - CACHE_ZONE_ID?: string; - CACHE_API_TOKEN?: string; - CACHE_BUFFER_TIME_IN_SECONDS?: string; + // The amount of time in seconds that the cache purge will wait before purging the cache + NEXT_CACHE_DO_PURGE_BUFFER_TIME_IN_SECONDS?: string; + // The zone ID to use for the cache purge https://developers.cloudflare.com/fundamentals/setup/find-account-and-zone-ids/ + CACHE_PURGE_ZONE_ID?: string; + // The API token to use for the cache purge. It should have the `Cache Purge` permission + CACHE_PURGE_API_TOKEN?: string; } } diff --git a/packages/cloudflare/src/api/durable-objects/bucket-cache-purge.spec.ts b/packages/cloudflare/src/api/durable-objects/bucket-cache-purge.spec.ts index 6aff368b..ae6524de 100644 --- a/packages/cloudflare/src/api/durable-objects/bucket-cache-purge.spec.ts +++ b/packages/cloudflare/src/api/durable-objects/bucket-cache-purge.spec.ts @@ -12,7 +12,6 @@ vi.mock("cloudflare:workers", () => ({ }, })); - const createBucketCachePurge = () => { const mockState = { waitUntil: vi.fn(), @@ -39,7 +38,7 @@ describe("BucketCachePurge", () => { expect(cache.ctx.blockConcurrencyWhile).toHaveBeenCalled(); // @ts-expect-error - testing private method expect(cache.ctx.storage.sql.exec).toHaveBeenCalledWith( - expect.stringContaining("CREATE TABLE IF NOT EXISTS cache_purge"), + expect.stringContaining("CREATE TABLE IF NOT EXISTS cache_purge") ); }); @@ -51,12 +50,12 @@ describe("BucketCachePurge", () => { // @ts-expect-error - testing private method expect(cache.ctx.storage.sql.exec).toHaveBeenCalledWith( expect.stringContaining("INSERT OR REPLACE INTO cache_purge"), - [tags[0]], + [tags[0]] ); // @ts-expect-error - testing private method expect(cache.ctx.storage.sql.exec).toHaveBeenCalledWith( expect.stringContaining("INSERT OR REPLACE INTO cache_purge"), - [tags[1]], + [tags[1]] ); }); @@ -77,7 +76,7 @@ describe("BucketCachePurge", () => { // @ts-expect-error - testing private method expect(cache.ctx.storage.setAlarm).not.toHaveBeenCalled(); }); - }) + }); describe("alarm", () => { it("should purge cache by tags and delete them from the sql table", async () => { @@ -90,7 +89,7 @@ describe("BucketCachePurge", () => { // @ts-expect-error - testing private method expect(cache.ctx.storage.sql.exec).toHaveBeenCalledWith( expect.stringContaining("DELETE FROM cache_purge"), - ["tag1", "tag2"], + ["tag1", "tag2"] ); }); it("should not purge cache if no tags are found", async () => { @@ -103,7 +102,7 @@ describe("BucketCachePurge", () => { // @ts-expect-error - testing private method expect(cache.ctx.storage.sql.exec).not.toHaveBeenCalledWith( expect.stringContaining("DELETE FROM cache_purge"), - [], + [] ); }); @@ -119,7 +118,7 @@ describe("BucketCachePurge", () => { expect(internalPurgeCacheByTagsSpy).toHaveBeenCalledWith( // @ts-expect-error - testing private method cache.env, - tags, + tags ); // @ts-expect-error - testing private method 1st is constructor, 2nd is to get the tags and 3rd is to delete them expect(cache.ctx.storage.sql.exec).toHaveBeenCalledTimes(3); @@ -137,16 +136,14 @@ describe("BucketCachePurge", () => { expect(internalPurgeCacheByTagsSpy).toHaveBeenCalledWith( // @ts-expect-error - testing private method cache.env, - tags, + tags ); // @ts-expect-error - testing private method 1st is constructor, 2nd is to get the tags and 3rd is to delete them, 4th is to get the next 100 tags expect(cache.ctx.storage.sql.exec).toHaveBeenCalledTimes(4); // @ts-expect-error - testing private method expect(cache.ctx.storage.sql.exec).toHaveBeenLastCalledWith( - expect.stringContaining("SELECT * FROM cache_purge LIMIT 100"), + expect.stringContaining("SELECT * FROM cache_purge LIMIT 100") ); }); - - - }) -}); \ No newline at end of file + }); +}); diff --git a/packages/cloudflare/src/api/durable-objects/bucket-cache-purge.ts b/packages/cloudflare/src/api/durable-objects/bucket-cache-purge.ts index 1b3edecf..eb17114d 100644 --- a/packages/cloudflare/src/api/durable-objects/bucket-cache-purge.ts +++ b/packages/cloudflare/src/api/durable-objects/bucket-cache-purge.ts @@ -9,8 +9,8 @@ export class BucketCachePurge extends DurableObject { constructor(state: DurableObjectState, env: CloudflareEnv) { super(state, env); - this.bufferTimeInSeconds = env.CACHE_BUFFER_TIME_IN_SECONDS - ? parseInt(env.CACHE_BUFFER_TIME_IN_SECONDS) + this.bufferTimeInSeconds = env.NEXT_CACHE_DO_PURGE_BUFFER_TIME_IN_SECONDS + ? parseInt(env.NEXT_CACHE_DO_PURGE_BUFFER_TIME_IN_SECONDS) : DEFAULT_BUFFER_TIME; // Default buffer time // Initialize the sql table if it doesn't exist @@ -21,7 +21,7 @@ export class BucketCachePurge extends DurableObject { ); CREATE UNIQUE INDEX IF NOT EXISTS tag_index ON cache_purge (tag); `); - }) + }); } async purgeCacheByTags(tags: string[]) { diff --git a/packages/cloudflare/src/api/overrides/cache-purge/index.ts b/packages/cloudflare/src/api/overrides/cache-purge/index.ts index f1a43473..7fa9a56b 100644 --- a/packages/cloudflare/src/api/overrides/cache-purge/index.ts +++ b/packages/cloudflare/src/api/overrides/cache-purge/index.ts @@ -17,7 +17,6 @@ export const purgeCache = ({ type = "direct" }: PurgeOptions) => { if (type === "direct") { await internalPurgeCacheByTags(env, tags); } else { - console.log("purgeCacheByTags DO", tags); const durableObject = env.NEXT_CACHE_DO_PURGE; if (!durableObject) { debugCache("cdnInvalidation", "No durable object found. Skipping cache purge."); diff --git a/packages/cloudflare/src/api/overrides/internal.ts b/packages/cloudflare/src/api/overrides/internal.ts index 7bd04648..a1764e00 100644 --- a/packages/cloudflare/src/api/overrides/internal.ts +++ b/packages/cloudflare/src/api/overrides/internal.ts @@ -52,10 +52,7 @@ export async function purgeCacheByTags(tags: string[]) { } export async function internalPurgeCacheByTags(env: CloudflareEnv, tags: string[]) { - //TODO: Remove this before commit - console.log("purgeCacheByTags", tags); - - if (!env.CACHE_ZONE_ID && !env.CACHE_API_TOKEN) { + if (!env.CACHE_PURGE_ZONE_ID && !env.CACHE_PURGE_API_TOKEN) { // THIS IS A NO-OP debugCache("purgeCacheByTags", "No cache zone ID or API token provided. Skipping cache purge."); return; @@ -63,10 +60,10 @@ export async function internalPurgeCacheByTags(env: CloudflareEnv, tags: string[ try { const response = await fetch( - `https://api.cloudflare.com/client/v4/zones/${env.CACHE_ZONE_ID}/purge_cache`, + `https://api.cloudflare.com/client/v4/zones/${env.CACHE_PURGE_ZONE_ID}/purge_cache`, { headers: { - Authorization: `Bearer ${env.CACHE_API_TOKEN}`, + Authorization: `Bearer ${env.CACHE_PURGE_ZONE_ID}`, "Content-Type": "application/json", }, method: "POST", @@ -82,7 +79,6 @@ export async function internalPurgeCacheByTags(env: CloudflareEnv, tags: string[ const bodyResponse = (await response.json()) as { success: boolean; errors: Array<{ code: number; message: string }>; - messages: Array<{ code: number; message: string }>; }; if (!bodyResponse.success) { throw new Error(`Failed to purge cache: ${JSON.stringify(bodyResponse.errors)}`); From 29b8b9a7c6a822d631d5bda68ef2f6910159d7ff Mon Sep 17 00:00:00 2001 From: Dorseuil Nicolas Date: Mon, 12 May 2025 11:01:00 +0200 Subject: [PATCH 06/11] review fix --- .../src/api/durable-objects/bucket-cache-purge.ts | 9 +++++---- packages/cloudflare/src/api/overrides/internal.ts | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/cloudflare/src/api/durable-objects/bucket-cache-purge.ts b/packages/cloudflare/src/api/durable-objects/bucket-cache-purge.ts index eb17114d..a25a6044 100644 --- a/packages/cloudflare/src/api/durable-objects/bucket-cache-purge.ts +++ b/packages/cloudflare/src/api/durable-objects/bucket-cache-purge.ts @@ -2,7 +2,8 @@ import { DurableObject } from "cloudflare:workers"; import { internalPurgeCacheByTags } from "../overrides/internal"; -const DEFAULT_BUFFER_TIME = 5; // seconds +const DEFAULT_BUFFER_TIME_IN_SECONDS = 5; +const MAX_NUMBER_OF_TAGS_PER_PURGE = 100; export class BucketCachePurge extends DurableObject { bufferTimeInSeconds: number; @@ -11,7 +12,7 @@ export class BucketCachePurge extends DurableObject { super(state, env); this.bufferTimeInSeconds = env.NEXT_CACHE_DO_PURGE_BUFFER_TIME_IN_SECONDS ? parseInt(env.NEXT_CACHE_DO_PURGE_BUFFER_TIME_IN_SECONDS) - : DEFAULT_BUFFER_TIME; // Default buffer time + : DEFAULT_BUFFER_TIME_IN_SECONDS; // Default buffer time // Initialize the sql table if it doesn't exist state.blockConcurrencyWhile(async () => { @@ -45,7 +46,7 @@ export class BucketCachePurge extends DurableObject { let tags = this.ctx.storage.sql .exec<{ tag: string }>( ` - SELECT * FROM cache_purge LIMIT 100 + SELECT * FROM cache_purge LIMIT ${MAX_NUMBER_OF_TAGS_PER_PURGE} ` ) .toArray(); @@ -75,7 +76,7 @@ export class BucketCachePurge extends DurableObject { tags = this.ctx.storage.sql .exec<{ tag: string }>( ` - SELECT * FROM cache_purge LIMIT 100 + SELECT * FROM cache_purge LIMIT ${MAX_NUMBER_OF_TAGS_PER_PURGE} ` ) .toArray(); diff --git a/packages/cloudflare/src/api/overrides/internal.ts b/packages/cloudflare/src/api/overrides/internal.ts index a1764e00..31193729 100644 --- a/packages/cloudflare/src/api/overrides/internal.ts +++ b/packages/cloudflare/src/api/overrides/internal.ts @@ -63,7 +63,7 @@ export async function internalPurgeCacheByTags(env: CloudflareEnv, tags: string[ `https://api.cloudflare.com/client/v4/zones/${env.CACHE_PURGE_ZONE_ID}/purge_cache`, { headers: { - Authorization: `Bearer ${env.CACHE_PURGE_ZONE_ID}`, + Authorization: `Bearer ${env.CACHE_PURGE_API_TOKEN}`, "Content-Type": "application/json", }, method: "POST", From 5d5b8cf686e832561c1346d849de135849449723 Mon Sep 17 00:00:00 2001 From: Dorseuil Nicolas Date: Mon, 12 May 2025 13:30:09 +0200 Subject: [PATCH 07/11] review --- .../api/durable-objects/bucket-cache-purge.ts | 17 +++++++++++------ .../cloudflare/src/api/overrides/internal.ts | 4 ---- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/packages/cloudflare/src/api/durable-objects/bucket-cache-purge.ts b/packages/cloudflare/src/api/durable-objects/bucket-cache-purge.ts index a25a6044..611f047e 100644 --- a/packages/cloudflare/src/api/durable-objects/bucket-cache-purge.ts +++ b/packages/cloudflare/src/api/durable-objects/bucket-cache-purge.ts @@ -3,6 +3,7 @@ import { DurableObject } from "cloudflare:workers"; import { internalPurgeCacheByTags } from "../overrides/internal"; const DEFAULT_BUFFER_TIME_IN_SECONDS = 5; +// https://developers.cloudflare.com/cache/how-to/purge-cache/#hostname-tag-prefix-url-and-purge-everything-limits const MAX_NUMBER_OF_TAGS_PER_PURGE = 100; export class BucketCachePurge extends DurableObject { @@ -50,17 +51,19 @@ export class BucketCachePurge extends DurableObject { ` ) .toArray(); - if (tags.length === 0) { + do { + if (tags.length === 0) { // No tags to purge, we can stop - // It shouldn't happen, but just in case return; } - do { await internalPurgeCacheByTags( this.env, tags.map((row) => row.tag) ); // Delete the tags from the sql table + // We always delete even if the purge fails + // because we don't want to keep the tags in the table + // and we don't want to keep retrying the purge this.ctx.storage.sql.exec( ` DELETE FROM cache_purge @@ -68,8 +71,10 @@ export class BucketCachePurge extends DurableObject { `, tags.map((row) => row.tag) ); - if (tags.length < 100) { - // If we have less than 100 tags, we can stop + if (tags.length < MAX_NUMBER_OF_TAGS_PER_PURGE + + ) { + // If we have less than MAX_NUMBER_OF_TAGS_PER_PURGE tags, we can stop tags = []; } else { // Otherwise, we need to get the next 100 tags @@ -81,6 +86,6 @@ export class BucketCachePurge extends DurableObject { ) .toArray(); } - } while (tags.length > 0); + } while (tags.length >= 0); } } diff --git a/packages/cloudflare/src/api/overrides/internal.ts b/packages/cloudflare/src/api/overrides/internal.ts index 31193729..e72c21bd 100644 --- a/packages/cloudflare/src/api/overrides/internal.ts +++ b/packages/cloudflare/src/api/overrides/internal.ts @@ -37,10 +37,6 @@ export async function purgeCacheByTags(tags: string[]) { // We should use it if (env.NEXT_CACHE_DO_PURGE) { const durableObject = env.NEXT_CACHE_DO_PURGE; - if (!durableObject) { - debugCache("purgeCacheByTags", "No durable object found. Skipping cache purge."); - return; - } const id = durableObject.idFromName("cache-purge"); const obj = durableObject.get(id); await obj.purgeCacheByTags(tags); From 220feb45b1297d6deb26c3370824b883d7c8c3f1 Mon Sep 17 00:00:00 2001 From: Dorseuil Nicolas Date: Mon, 12 May 2025 16:24:51 +0200 Subject: [PATCH 08/11] lint fix --- .../src/api/durable-objects/bucket-cache-purge.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/packages/cloudflare/src/api/durable-objects/bucket-cache-purge.ts b/packages/cloudflare/src/api/durable-objects/bucket-cache-purge.ts index 611f047e..f64cf058 100644 --- a/packages/cloudflare/src/api/durable-objects/bucket-cache-purge.ts +++ b/packages/cloudflare/src/api/durable-objects/bucket-cache-purge.ts @@ -53,9 +53,9 @@ export class BucketCachePurge extends DurableObject { .toArray(); do { if (tags.length === 0) { - // No tags to purge, we can stop - return; - } + // No tags to purge, we can stop + return; + } await internalPurgeCacheByTags( this.env, tags.map((row) => row.tag) @@ -71,9 +71,7 @@ export class BucketCachePurge extends DurableObject { `, tags.map((row) => row.tag) ); - if (tags.length < MAX_NUMBER_OF_TAGS_PER_PURGE - - ) { + if (tags.length < MAX_NUMBER_OF_TAGS_PER_PURGE) { // If we have less than MAX_NUMBER_OF_TAGS_PER_PURGE tags, we can stop tags = []; } else { From a4143e7d7ee35211fedd8b71489f3075d9f28142 Mon Sep 17 00:00:00 2001 From: Dorseuil Nicolas Date: Mon, 12 May 2025 16:45:55 +0200 Subject: [PATCH 09/11] basic error handling --- .../api/durable-objects/bucket-cache-purge.ts | 14 ++++++++++---- .../cloudflare/src/api/overrides/internal.ts | 18 +++++++++++++----- 2 files changed, 23 insertions(+), 9 deletions(-) diff --git a/packages/cloudflare/src/api/durable-objects/bucket-cache-purge.ts b/packages/cloudflare/src/api/durable-objects/bucket-cache-purge.ts index f64cf058..75960bb9 100644 --- a/packages/cloudflare/src/api/durable-objects/bucket-cache-purge.ts +++ b/packages/cloudflare/src/api/durable-objects/bucket-cache-purge.ts @@ -56,14 +56,20 @@ export class BucketCachePurge extends DurableObject { // No tags to purge, we can stop return; } - await internalPurgeCacheByTags( + const result = await internalPurgeCacheByTags( this.env, tags.map((row) => row.tag) ); + // For every other error, we just remove the tags from the sql table + // and continue + if (result === "rate-limit-exceeded") { + // Rate limit exceeded, we need to wait for the next alarm + // and try again + // We throw here to take advantage of the built-in retry + throw new Error("Rate limit exceeded"); + } + // Delete the tags from the sql table - // We always delete even if the purge fails - // because we don't want to keep the tags in the table - // and we don't want to keep retrying the purge this.ctx.storage.sql.exec( ` DELETE FROM cache_purge diff --git a/packages/cloudflare/src/api/overrides/internal.ts b/packages/cloudflare/src/api/overrides/internal.ts index e72c21bd..06927d2a 100644 --- a/packages/cloudflare/src/api/overrides/internal.ts +++ b/packages/cloudflare/src/api/overrides/internal.ts @@ -51,7 +51,7 @@ export async function internalPurgeCacheByTags(env: CloudflareEnv, tags: string[ if (!env.CACHE_PURGE_ZONE_ID && !env.CACHE_PURGE_API_TOKEN) { // THIS IS A NO-OP debugCache("purgeCacheByTags", "No cache zone ID or API token provided. Skipping cache purge."); - return; + return "missing-credentials"; } try { @@ -68,19 +68,27 @@ export async function internalPurgeCacheByTags(env: CloudflareEnv, tags: string[ }), } ); - if (!response.ok) { - const text = await response.text(); - throw new Error(`Failed to purge cache: ${response.status} ${text}`); + if (response.status === 429) { + // Rate limit exceeded + debugCache("purgeCacheByTags", "Rate limit exceeded. Skipping cache purge."); + return "rate-limit-exceeded"; } const bodyResponse = (await response.json()) as { success: boolean; errors: Array<{ code: number; message: string }>; }; if (!bodyResponse.success) { - throw new Error(`Failed to purge cache: ${JSON.stringify(bodyResponse.errors)}`); + debugCache( + "purgeCacheByTags", + "Cache purge failed. Errors:", + bodyResponse.errors.map((error) => `${error.code}: ${error.message}`) + ); + return "purge-failed"; } debugCache("purgeCacheByTags", "Cache purged successfully for tags:", tags); + return "purge-success"; } catch (error) { console.error("Error purging cache by tags:", error); + return "purge-failed"; } } From 373162d39fde74101f5ee3c4e96ec64e075c5b38 Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Mon, 26 May 2025 13:57:35 +0200 Subject: [PATCH 10/11] changeset --- .changeset/deep-mails-throw.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/deep-mails-throw.md diff --git a/.changeset/deep-mails-throw.md b/.changeset/deep-mails-throw.md new file mode 100644 index 00000000..7e93e0a3 --- /dev/null +++ b/.changeset/deep-mails-throw.md @@ -0,0 +1,5 @@ +--- +"@opennextjs/cloudflare": minor +--- + +Automatic Cache API purge From 779e539a49260c697e821b1c034cb2ffbcd4e407 Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Mon, 26 May 2025 20:55:21 +0200 Subject: [PATCH 11/11] fix linting --- examples/e2e/app-router/open-next.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/e2e/app-router/open-next.config.ts b/examples/e2e/app-router/open-next.config.ts index f2803ca6..36de9239 100644 --- a/examples/e2e/app-router/open-next.config.ts +++ b/examples/e2e/app-router/open-next.config.ts @@ -15,7 +15,7 @@ export default defineCloudflareConfig({ numberOfHardReplicas: 2, }, }), - cachePurge: purgeCache({type: "durableObject"}), + cachePurge: purgeCache({ type: "durableObject" }), enableCacheInterception: true, queue: queueCache(doQueue), });