diff --git a/.changeset/silver-walls-cover.md b/.changeset/silver-walls-cover.md
new file mode 100644
index 00000000..14461fad
--- /dev/null
+++ b/.changeset/silver-walls-cover.md
@@ -0,0 +1,5 @@
+---
+"@opennextjs/cloudflare": minor
+---
+
+feat: add an experimental KV based tag cache
diff --git a/examples/common/apps.ts b/examples/common/apps.ts
index 07fa3c7f..811daa65 100644
--- a/examples/common/apps.ts
+++ b/examples/common/apps.ts
@@ -16,6 +16,7 @@ const apps = [
"experimental",
// overrides
"d1-tag-next",
+ "kv-tag-next",
"memory-queue",
"r2-incremental-cache",
"static-assets-incremental-cache",
diff --git a/examples/overrides/kv-tag-next/.gitignore b/examples/overrides/kv-tag-next/.gitignore
new file mode 100644
index 00000000..3f753f29
--- /dev/null
+++ b/examples/overrides/kv-tag-next/.gitignore
@@ -0,0 +1,47 @@
+# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
+
+# dependencies
+/node_modules
+/.pnp
+.pnp.*
+.yarn/*
+!.yarn/patches
+!.yarn/plugins
+!.yarn/releases
+!.yarn/versions
+
+# testing
+/coverage
+
+# next.js
+/.next/
+/out/
+
+# production
+/build
+
+# misc
+.DS_Store
+*.pem
+
+# debug
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+.pnpm-debug.log*
+
+# env files (can opt-in for committing if needed)
+.env*
+
+# vercel
+.vercel
+
+# typescript
+*.tsbuildinfo
+next-env.d.ts
+
+# playwright
+/test-results/
+/playwright-report/
+/blob-report/
+/playwright/.cache/
diff --git a/examples/overrides/kv-tag-next/app/action.ts b/examples/overrides/kv-tag-next/app/action.ts
new file mode 100644
index 00000000..2a21534e
--- /dev/null
+++ b/examples/overrides/kv-tag-next/app/action.ts
@@ -0,0 +1,11 @@
+"use server";
+
+import { revalidatePath, revalidateTag } from "next/cache";
+
+export async function revalidateTagAction() {
+ revalidateTag("date");
+}
+
+export async function revalidatePathAction() {
+ revalidatePath("/");
+}
diff --git a/examples/overrides/kv-tag-next/app/components/revalidationButtons.tsx b/examples/overrides/kv-tag-next/app/components/revalidationButtons.tsx
new file mode 100644
index 00000000..0b42b016
--- /dev/null
+++ b/examples/overrides/kv-tag-next/app/components/revalidationButtons.tsx
@@ -0,0 +1,27 @@
+"use client";
+
+import { revalidateTagAction, revalidatePathAction } from "../action";
+
+export default function RevalidationButtons() {
+ return (
+
+
+
+
+
+ );
+}
diff --git a/examples/overrides/kv-tag-next/app/favicon.ico b/examples/overrides/kv-tag-next/app/favicon.ico
new file mode 100644
index 00000000..718d6fea
Binary files /dev/null and b/examples/overrides/kv-tag-next/app/favicon.ico differ
diff --git a/examples/overrides/kv-tag-next/app/globals.css b/examples/overrides/kv-tag-next/app/globals.css
new file mode 100644
index 00000000..64152de8
--- /dev/null
+++ b/examples/overrides/kv-tag-next/app/globals.css
@@ -0,0 +1,14 @@
+html,
+body {
+ max-width: 100vw;
+ overflow-x: hidden;
+ height: 100vh;
+ display: flex;
+ flex-direction: column;
+}
+
+footer {
+ padding: 1rem;
+ display: flex;
+ justify-content: end;
+}
diff --git a/examples/overrides/kv-tag-next/app/layout.tsx b/examples/overrides/kv-tag-next/app/layout.tsx
new file mode 100644
index 00000000..1189b7ef
--- /dev/null
+++ b/examples/overrides/kv-tag-next/app/layout.tsx
@@ -0,0 +1,28 @@
+import type { Metadata } from "next";
+import "./globals.css";
+
+import { getCloudflareContext } from "@opennextjs/cloudflare";
+
+export const metadata: Metadata = {
+ title: "SSG App",
+ description: "An app in which all the routes are SSG'd",
+};
+
+export default async function RootLayout({
+ children,
+}: Readonly<{
+ children: React.ReactNode;
+}>) {
+ const cloudflareContext = await getCloudflareContext({
+ async: true,
+ });
+
+ return (
+
+
+ {children}
+
+
+
+ );
+}
diff --git a/examples/overrides/kv-tag-next/app/page.module.css b/examples/overrides/kv-tag-next/app/page.module.css
new file mode 100644
index 00000000..aad95c19
--- /dev/null
+++ b/examples/overrides/kv-tag-next/app/page.module.css
@@ -0,0 +1,17 @@
+.page {
+ display: grid;
+ grid-template-rows: 20px 1fr 20px;
+ align-items: center;
+ justify-items: center;
+ flex: 1;
+ border: 3px solid gray;
+ margin: 1rem;
+ margin-block-end: 0;
+}
+
+.main {
+ display: flex;
+ flex-direction: column;
+ gap: 32px;
+ grid-row-start: 2;
+}
diff --git a/examples/overrides/kv-tag-next/app/page.tsx b/examples/overrides/kv-tag-next/app/page.tsx
new file mode 100644
index 00000000..863cddce
--- /dev/null
+++ b/examples/overrides/kv-tag-next/app/page.tsx
@@ -0,0 +1,26 @@
+import { unstable_cache } from "next/cache";
+import styles from "./page.module.css";
+import RevalidationButtons from "./components/revalidationButtons";
+
+const fetchedDateCb = unstable_cache(
+ async () => {
+ return Date.now();
+ },
+ ["date"],
+ { tags: ["date"] }
+);
+
+export default async function Home() {
+ const fetchedDate = await fetchedDateCb();
+ return (
+
+
+ Hello from a Statically generated page
+ {Date.now()}
+ {fetchedDate}
+
+
+
+
+ );
+}
diff --git a/examples/overrides/kv-tag-next/e2e/base.spec.ts b/examples/overrides/kv-tag-next/e2e/base.spec.ts
new file mode 100644
index 00000000..e6fef12a
--- /dev/null
+++ b/examples/overrides/kv-tag-next/e2e/base.spec.ts
@@ -0,0 +1,47 @@
+import { test, expect } from "@playwright/test";
+
+test.describe("kv-tag-next", () => {
+ test("the index page should work", async ({ page }) => {
+ await page.goto("/");
+ await expect(page.getByText("Hello from a Statically generated page")).toBeVisible();
+ });
+
+ test("the index page should keep the same date on reload", async ({ page }) => {
+ await page.goto("/");
+ const date = await page.getByTestId("date-local").textContent();
+ expect(date).not.toBeNull();
+ await page.reload();
+ const newDate = await page.getByTestId("date-local").textContent();
+ expect(date).toEqual(newDate);
+ });
+
+ test("the index page should revalidate the date on click on revalidateTag", async ({ page }) => {
+ await page.goto("/");
+ const date = await page.getByTestId("date-fetched").textContent();
+ await page.getByTestId("revalidate-tag").click();
+ await page.waitForTimeout(100);
+ const newDate = await page.getByTestId("date-fetched").textContent();
+ expect(date).not.toEqual(newDate);
+ });
+
+ test("the index page should revalidate the date on click on revalidatePath", async ({ page }) => {
+ await page.goto("/");
+ const date = await page.getByTestId("date-fetched").textContent();
+ await page.getByTestId("revalidate-path").click();
+ await page.waitForTimeout(100);
+ const newDate = await page.getByTestId("date-fetched").textContent();
+ expect(date).not.toEqual(newDate);
+ });
+
+ test("the index page should keep the same date on reload after revalidation", async ({ page }) => {
+ await page.goto("/");
+ const initialDate = await page.getByTestId("date-fetched").textContent();
+ await page.getByTestId("revalidate-tag").click();
+ await page.waitForTimeout(100);
+ const date = await page.getByTestId("date-fetched").textContent();
+ expect(initialDate).not.toEqual(date);
+ await page.reload();
+ const newDate = await page.getByTestId("date-fetched").textContent();
+ expect(date).toEqual(newDate);
+ });
+});
diff --git a/examples/overrides/kv-tag-next/e2e/playwright.config.ts b/examples/overrides/kv-tag-next/e2e/playwright.config.ts
new file mode 100644
index 00000000..76dba7ee
--- /dev/null
+++ b/examples/overrides/kv-tag-next/e2e/playwright.config.ts
@@ -0,0 +1,4 @@
+import { configurePlaywright } from "../../../common/config-e2e";
+
+// Here we don't want to run the tests in parallel
+export default configurePlaywright("kv-tag-next", { parallel: false });
diff --git a/examples/overrides/kv-tag-next/next.config.ts b/examples/overrides/kv-tag-next/next.config.ts
new file mode 100644
index 00000000..0fe271b3
--- /dev/null
+++ b/examples/overrides/kv-tag-next/next.config.ts
@@ -0,0 +1,11 @@
+import type { NextConfig } from "next";
+import { initOpenNextCloudflareForDev } from "@opennextjs/cloudflare";
+
+initOpenNextCloudflareForDev();
+
+const nextConfig: NextConfig = {
+ typescript: { ignoreBuildErrors: true },
+ eslint: { ignoreDuringBuilds: true },
+};
+
+export default nextConfig;
diff --git a/examples/overrides/kv-tag-next/open-next.config.ts b/examples/overrides/kv-tag-next/open-next.config.ts
new file mode 100644
index 00000000..6315815f
--- /dev/null
+++ b/examples/overrides/kv-tag-next/open-next.config.ts
@@ -0,0 +1,8 @@
+import { defineCloudflareConfig } from "@opennextjs/cloudflare";
+import kvIncrementalCache from "@opennextjs/cloudflare/overrides/incremental-cache/kv-incremental-cache";
+import kvNextTagCache from "@opennextjs/cloudflare/overrides/tag-cache/kv-next-tag-cache";
+
+export default defineCloudflareConfig({
+ incrementalCache: kvIncrementalCache,
+ tagCache: kvNextTagCache,
+});
diff --git a/examples/overrides/kv-tag-next/package.json b/examples/overrides/kv-tag-next/package.json
new file mode 100644
index 00000000..0906798b
--- /dev/null
+++ b/examples/overrides/kv-tag-next/package.json
@@ -0,0 +1,29 @@
+{
+ "name": "kv-tag-next",
+ "version": "0.1.0",
+ "private": true,
+ "scripts": {
+ "dev": "next dev",
+ "build": "next build",
+ "start": "next start",
+ "lint": "next lint",
+ "build:worker": "pnpm opennextjs-cloudflare build --skipWranglerConfigCheck",
+ "preview:worker": "pnpm opennextjs-cloudflare preview --config wrangler.e2e.jsonc",
+ "preview": "pnpm build:worker && pnpm preview:worker",
+ "e2e": "playwright test -c e2e/playwright.config.ts"
+ },
+ "dependencies": {
+ "react": "catalog:e2e",
+ "react-dom": "catalog:e2e",
+ "next": "catalog:e2e"
+ },
+ "devDependencies": {
+ "@opennextjs/cloudflare": "workspace:*",
+ "@playwright/test": "catalog:",
+ "@types/node": "catalog:",
+ "@types/react": "catalog:e2e",
+ "@types/react-dom": "catalog:e2e",
+ "typescript": "catalog:",
+ "wrangler": "catalog:"
+ }
+}
diff --git a/examples/overrides/kv-tag-next/tsconfig.json b/examples/overrides/kv-tag-next/tsconfig.json
new file mode 100644
index 00000000..96f8e1b6
--- /dev/null
+++ b/examples/overrides/kv-tag-next/tsconfig.json
@@ -0,0 +1,27 @@
+{
+ "compilerOptions": {
+ "target": "ES2017",
+ "lib": ["dom", "dom.iterable", "esnext"],
+ "allowJs": true,
+ "skipLibCheck": true,
+ "strict": true,
+ "noEmit": true,
+ "esModuleInterop": true,
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "jsx": "preserve",
+ "incremental": true,
+ "plugins": [
+ {
+ "name": "next"
+ }
+ ],
+ "paths": {
+ "@/*": ["./*"]
+ }
+ },
+ "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
+ "exclude": ["node_modules"]
+}
diff --git a/examples/overrides/kv-tag-next/wrangler.e2e.jsonc b/examples/overrides/kv-tag-next/wrangler.e2e.jsonc
new file mode 100644
index 00000000..0f8b5557
--- /dev/null
+++ b/examples/overrides/kv-tag-next/wrangler.e2e.jsonc
@@ -0,0 +1,25 @@
+{
+ "$schema": "node_modules/wrangler/config-schema.json",
+ "main": ".open-next/worker.js",
+ "name": "ssg-app",
+ "compatibility_date": "2025-02-04",
+ "compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"],
+ "assets": {
+ "directory": ".open-next/assets",
+ "binding": "ASSETS",
+ },
+ "vars": {
+ "APP_VERSION": "1.2.345",
+ },
+ "kv_namespaces": [
+ {
+ "binding": "NEXT_INC_CACHE_KV",
+ "id": "INC-CACHE",
+ "preview_id": "",
+ },
+ {
+ "binding": "NEXT_TAG_CACHE_KV",
+ "id": "TAG-CACHE",
+ },
+ ],
+}
diff --git a/packages/cloudflare/src/api/cloudflare-context.ts b/packages/cloudflare/src/api/cloudflare-context.ts
index d10d79d3..7fce98bd 100644
--- a/packages/cloudflare/src/api/cloudflare-context.ts
+++ b/packages/cloudflare/src/api/cloudflare-context.ts
@@ -33,6 +33,9 @@ declare global {
// D1 db used for the tag cache
NEXT_TAG_CACHE_D1?: D1Database;
+ // KV used for the tag cache
+ NEXT_TAG_CACHE_KV?: KVNamespace;
+
// Durables object namespace to use for the sharded tag cache
NEXT_TAG_CACHE_DO_SHARDED?: DurableObjectNamespace;
// Queue of failed tag write
diff --git a/packages/cloudflare/src/api/overrides/tag-cache/d1-next-tag-cache.spec.ts b/packages/cloudflare/src/api/overrides/tag-cache/d1-next-tag-cache.spec.ts
index f70be6dd..7d73619a 100644
--- a/packages/cloudflare/src/api/overrides/tag-cache/d1-next-tag-cache.spec.ts
+++ b/packages/cloudflare/src/api/overrides/tag-cache/d1-next-tag-cache.spec.ts
@@ -59,7 +59,7 @@ describe("D1NextModeTagCache", () => {
env: {
[BINDING_NAME]: mockDb,
},
- } as ReturnType);
+ } as unknown as ReturnType);
// Reset global config
(globalThis as { openNextConfig?: { dangerous?: { disableTagCache?: boolean } } }).openNextConfig = {
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 040032e4..b554de78 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
@@ -1,7 +1,6 @@
import { error } from "@opennextjs/aws/adapters/logger.js";
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, purgeCacheByTags } from "../internal.js";
@@ -28,9 +27,9 @@ export class D1NextModeTagCache implements NextModeTagCache {
// We only care about the most recent revalidation
return (result.results[0]?.time ?? 0) as number;
} catch (e) {
- error(e);
// By default we don't want to crash here, so we return false
// We still log the error though so we can debug it
+ error(e);
return 0;
}
}
@@ -57,7 +56,6 @@ export class D1NextModeTagCache implements NextModeTagCache {
async writeTags(tags: string[]): Promise {
const { isDisabled, db } = this.getConfig();
- // TODO: Remove `tags.length === 0` when https://github.com/opennextjs/opennextjs-aws/pull/828 is used
if (isDisabled || tags.length === 0) return Promise.resolve();
await db.batch(
@@ -67,6 +65,8 @@ export class D1NextModeTagCache implements NextModeTagCache {
.bind(this.getCacheKey(tag), Date.now())
)
);
+
+ // TODO: See https://github.com/opennextjs/opennextjs-aws/issues/986
await purgeCacheByTags(tags);
}
@@ -75,8 +75,7 @@ export class D1NextModeTagCache implements NextModeTagCache {
if (!db) debugCache("No D1 database found");
- const isDisabled = !!(globalThis as unknown as { openNextConfig: OpenNextConfig }).openNextConfig
- .dangerous?.disableTagCache;
+ const isDisabled = Boolean(globalThis.openNextConfig.dangerous?.disableTagCache);
return !db || isDisabled
? { isDisabled: true as const }
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 cfbeacfb..30f9c9fb 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
@@ -3,8 +3,8 @@ import { generateShardId } from "@opennextjs/aws/core/routing/queue.js";
import type { NextModeTagCache } from "@opennextjs/aws/types/overrides.js";
import { IgnorableError } from "@opennextjs/aws/utils/error.js";
-import type { OpenNextConfig } from "../../../api/config.js";
import { getCloudflareContext } from "../../cloudflare-context.js";
+import type { OpenNextConfig } from "../../config.js";
import { DOShardedTagCache } from "../../durable-objects/sharded-tag-cache.js";
import { debugCache, purgeCacheByTags } from "../internal.js";
@@ -227,6 +227,8 @@ class ShardedDOTagCache implements NextModeTagCache {
await this.performWriteTagsWithRetry(doId, tags, currentTime);
})
);
+
+ // TODO: See https://github.com/opennextjs/opennextjs-aws/issues/986
await purgeCacheByTags(tags);
}
diff --git a/packages/cloudflare/src/api/overrides/tag-cache/kv-next-tag-cache.spec.ts b/packages/cloudflare/src/api/overrides/tag-cache/kv-next-tag-cache.spec.ts
new file mode 100644
index 00000000..0c7ae180
--- /dev/null
+++ b/packages/cloudflare/src/api/overrides/tag-cache/kv-next-tag-cache.spec.ts
@@ -0,0 +1,306 @@
+import { error } from "@opennextjs/aws/adapters/logger.js";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import { getCloudflareContext } from "../../cloudflare-context.js";
+import { FALLBACK_BUILD_ID, purgeCacheByTags } from "../internal.js";
+import { BINDING_NAME, KVNextModeTagCache, NAME } from "./kv-next-tag-cache.js";
+
+// Mock dependencies
+vi.mock("@opennextjs/aws/adapters/logger.js", () => ({
+ error: vi.fn(),
+}));
+
+vi.mock("../../cloudflare-context.js", () => ({
+ getCloudflareContext: vi.fn(),
+}));
+
+vi.mock("../internal.js", () => ({
+ debugCache: vi.fn(),
+ FALLBACK_BUILD_ID: "fallback-build-id",
+ purgeCacheByTags: vi.fn(),
+}));
+
+describe("KVNextModeTagCache", () => {
+ let tagCache: KVNextModeTagCache;
+ let mockKv: {
+ put: ReturnType;
+ get: ReturnType;
+ };
+ let mockGet: ReturnType;
+ let mockPut: ReturnType;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+
+ // Setup mock database
+ mockGet = vi.fn();
+ mockPut = vi.fn();
+
+ mockKv = {
+ get: mockGet,
+ put: mockPut,
+ };
+
+ // Setup cloudflare context mock
+ vi.mocked(getCloudflareContext).mockReturnValue({
+ env: {
+ [BINDING_NAME]: mockKv,
+ },
+ } as unknown as ReturnType);
+
+ // Reset global config
+ (globalThis as { openNextConfig?: { dangerous?: { disableTagCache?: boolean } } }).openNextConfig = {
+ dangerous: {
+ disableTagCache: false,
+ },
+ };
+
+ // Reset environment variables
+ vi.unstubAllEnvs();
+
+ tagCache = new KVNextModeTagCache();
+ });
+
+ describe("constructor and properties", () => {
+ it("should have correct mode and name", () => {
+ expect(tagCache.mode).toBe("nextMode");
+ expect(tagCache.name).toBe(NAME);
+ });
+ });
+
+ describe("getLastRevalidated", () => {
+ it("should return 0 when cache is disabled", async () => {
+ (
+ globalThis as { openNextConfig?: { dangerous?: { disableTagCache?: boolean } } }
+ ).openNextConfig!.dangerous!.disableTagCache = true;
+
+ const result = await tagCache.getLastRevalidated(["tag1", "tag2"]);
+
+ expect(result).toBe(0);
+ expect(mockGet).not.toHaveBeenCalled();
+ });
+
+ it("should return 0 when no KV is available", async () => {
+ vi.mocked(getCloudflareContext).mockReturnValue({
+ env: {},
+ } as ReturnType);
+
+ const result = await tagCache.getLastRevalidated(["tag1", "tag2"]);
+
+ expect(result).toBe(0);
+ expect(error).toHaveBeenCalledWith("No KV binding NEXT_TAG_CACHE_KV found");
+ });
+
+ it("should return the maximum revalidation time for given tags", async () => {
+ const mockTime = 1234567890;
+ mockGet.mockResolvedValue(
+ new Map([
+ ["tag1", mockTime],
+ ["tag2", mockTime - 100],
+ ])
+ );
+
+ const tags = ["tag1", "tag2"];
+ const result = await tagCache.getLastRevalidated(tags);
+
+ expect(result).toBe(mockTime);
+ expect(mockGet).toHaveBeenCalledWith([`${FALLBACK_BUILD_ID}/tag1`, `${FALLBACK_BUILD_ID}/tag2`], {
+ type: "json",
+ });
+ });
+
+ it("should return 0 when no results are found", async () => {
+ mockGet.mockResolvedValue(new Map([["tag1", null]]));
+
+ const result = await tagCache.getLastRevalidated(["tag1"]);
+
+ expect(result).toBe(0);
+ });
+
+ it("should return 0 when KV get throws an error", async () => {
+ const mockError = new Error("Database error");
+ mockGet.mockRejectedValue(mockError);
+
+ const result = await tagCache.getLastRevalidated(["tag1"]);
+
+ expect(result).toBe(0);
+ expect(error).toHaveBeenCalledWith(mockError);
+ });
+
+ it("should use custom build ID when NEXT_BUILD_ID is set", async () => {
+ const customBuildId = "custom-build-id";
+ vi.stubEnv("NEXT_BUILD_ID", customBuildId);
+
+ mockGet.mockResolvedValue(new Map([["tag1", null]]));
+
+ await tagCache.getLastRevalidated(["tag1"]);
+
+ expect(mockGet).toHaveBeenCalledWith([`${customBuildId}/tag1`], { type: "json" });
+ });
+ });
+
+ describe("hasBeenRevalidated", () => {
+ it("should return false when cache is disabled", async () => {
+ (
+ globalThis as { openNextConfig?: { dangerous?: { disableTagCache?: boolean } } }
+ ).openNextConfig!.dangerous!.disableTagCache = true;
+
+ const result = await tagCache.hasBeenRevalidated(["tag1"], 1000);
+
+ expect(result).toBe(false);
+ expect(mockGet).not.toHaveBeenCalled();
+ });
+
+ it("should return false when no KV is available", async () => {
+ vi.mocked(getCloudflareContext).mockReturnValue({
+ env: {},
+ } as ReturnType);
+
+ const result = await tagCache.hasBeenRevalidated(["tag1"], 1000);
+
+ expect(result).toBe(false);
+ });
+
+ it("should return true when tags have been revalidated after lastModified", async () => {
+ mockGet.mockResolvedValue(
+ new Map([
+ ["tag1", 1000],
+ ["tag2", null],
+ ])
+ );
+
+ const tags = ["tag1", "tag2"];
+ const lastModified = 500;
+ const result = await tagCache.hasBeenRevalidated(tags, lastModified);
+
+ expect(result).toBe(true);
+ });
+
+ it("should return false when no tags have been revalidated", async () => {
+ mockGet.mockResolvedValue(
+ new Map([
+ ["tag1", null],
+ ["tag2", null],
+ ])
+ );
+
+ const result = await tagCache.hasBeenRevalidated(["tag1", "tag2"], 1000);
+
+ expect(result).toBe(false);
+ });
+
+ it("should return false when KV get throws an error", async () => {
+ const mockError = new Error("Database error");
+ mockGet.mockRejectedValue(mockError);
+
+ const result = await tagCache.hasBeenRevalidated(["tag1"], 1000);
+
+ expect(result).toBe(false);
+ expect(error).toHaveBeenCalledWith(mockError);
+ });
+ });
+
+ describe("writeTags", () => {
+ it("should do nothing when cache is disabled", async () => {
+ (
+ globalThis as { openNextConfig?: { dangerous?: { disableTagCache?: boolean } } }
+ ).openNextConfig!.dangerous!.disableTagCache = true;
+
+ await tagCache.writeTags(["tag1", "tag2"]);
+
+ expect(mockPut).not.toHaveBeenCalled();
+ expect(purgeCacheByTags).not.toHaveBeenCalled();
+ });
+
+ it("should do nothing when no KV is available", async () => {
+ vi.mocked(getCloudflareContext).mockReturnValue({
+ env: {},
+ } as ReturnType);
+
+ await tagCache.writeTags(["tag1", "tag2"]);
+
+ expect(mockPut).not.toHaveBeenCalled();
+ expect(purgeCacheByTags).not.toHaveBeenCalled();
+ });
+
+ it("should do nothing when tags array is empty", async () => {
+ await tagCache.writeTags([]);
+
+ expect(mockPut).not.toHaveBeenCalled();
+ expect(purgeCacheByTags).not.toHaveBeenCalled();
+ });
+
+ it("should write tags to KV and purge cache", async () => {
+ const currentTime = Date.now();
+ vi.spyOn(Date, "now").mockReturnValue(currentTime);
+
+ const tags = ["tag1", "tag2"];
+ await tagCache.writeTags(tags);
+
+ expect(mockPut).toHaveBeenCalledTimes(2);
+ expect(mockPut).toHaveBeenCalledWith("fallback-build-id/tag1", String(currentTime));
+ expect(mockPut).toHaveBeenCalledWith("fallback-build-id/tag2", String(currentTime));
+
+ expect(purgeCacheByTags).toHaveBeenCalledWith(tags);
+ });
+
+ it("should handle single tag", async () => {
+ const currentTime = Date.now();
+ vi.spyOn(Date, "now").mockReturnValue(currentTime);
+
+ await tagCache.writeTags(["single-tag"]);
+
+ expect(mockPut).toHaveBeenCalledTimes(1);
+ expect(mockPut).toHaveBeenCalledWith("fallback-build-id/single-tag", String(currentTime));
+
+ expect(purgeCacheByTags).toHaveBeenCalledWith(["single-tag"]);
+ });
+ });
+
+ describe("getCacheKey", () => {
+ it("should generate cache key with build ID and tag", () => {
+ const key = "test-tag";
+ const cacheKey = (tagCache as unknown as { getCacheKey: (key: string) => string }).getCacheKey(key);
+
+ expect(cacheKey).toBe(`${FALLBACK_BUILD_ID}/${key}`);
+ });
+
+ it("should use custom build ID when NEXT_BUILD_ID is set", () => {
+ const customBuildId = "custom-build-id";
+ vi.stubEnv("NEXT_BUILD_ID", customBuildId);
+
+ const key = "test-tag";
+ const cacheKey = (tagCache as unknown as { getCacheKey: (key: string) => string }).getCacheKey(key);
+
+ expect(cacheKey).toBe(`${customBuildId}/${key}`);
+ });
+
+ it("should handle double slashes by replacing them with single slash", () => {
+ vi.stubEnv("NEXT_BUILD_ID", "build//id");
+
+ const key = "test-tag";
+ const cacheKey = (tagCache as unknown as { getCacheKey: (key: string) => string }).getCacheKey(key);
+
+ expect(cacheKey).toBe("build/id/test-tag");
+ });
+ });
+
+ describe("getBuildId", () => {
+ it("should return NEXT_BUILD_ID when set", () => {
+ const customBuildId = "custom-build-id";
+ vi.stubEnv("NEXT_BUILD_ID", customBuildId);
+
+ const buildId = (tagCache as unknown as { getBuildId: () => string }).getBuildId();
+
+ expect(buildId).toBe(customBuildId);
+ });
+
+ it("should return fallback build ID when NEXT_BUILD_ID is not set", () => {
+ // Environment variables are cleared by vi.unstubAllEnvs() in beforeEach
+
+ const buildId = (tagCache as unknown as { getBuildId: () => string }).getBuildId();
+
+ expect(buildId).toBe(FALLBACK_BUILD_ID);
+ });
+ });
+});
diff --git a/packages/cloudflare/src/api/overrides/tag-cache/kv-next-tag-cache.ts b/packages/cloudflare/src/api/overrides/tag-cache/kv-next-tag-cache.ts
new file mode 100644
index 00000000..5628e949
--- /dev/null
+++ b/packages/cloudflare/src/api/overrides/tag-cache/kv-next-tag-cache.ts
@@ -0,0 +1,98 @@
+import { error } from "@opennextjs/aws/adapters/logger.js";
+import type { NextModeTagCache } from "@opennextjs/aws/types/overrides.js";
+
+import { getCloudflareContext } from "../../cloudflare-context.js";
+import { FALLBACK_BUILD_ID, purgeCacheByTags } from "../internal.js";
+
+export const NAME = "kv-next-mode-tag-cache";
+
+export const BINDING_NAME = "NEXT_TAG_CACHE_KV";
+
+/**
+ * Tag Cache based on a KV namespace
+ *
+ * Warning:
+ * This implementation is considered experimental for now.
+ * KV is eventually consistent and can take up to 60s to reflect the last write.
+ * This means that:
+ * - revalidations can take up to 60s to apply
+ * - when a page depends on multiple tags they can be inconsistent for up to 60s.
+ * It also means that cached data could be outdated for one tag when other tags
+ * are revalidated resulting in the page being generated based on outdated data.
+ */
+export class KVNextModeTagCache implements NextModeTagCache {
+ readonly mode = "nextMode" as const;
+ readonly name = NAME;
+
+ async getLastRevalidated(tags: string[]): Promise {
+ const kv = this.getKv();
+ if (!kv) {
+ return 0;
+ }
+
+ try {
+ const keys = tags.map((tag) => this.getCacheKey(tag));
+ // Use the `json` type to get back numbers/null
+ const result: Map = await kv.get(keys, { type: "json" });
+
+ const revalidations = [...result.values()].filter((v) => v != null);
+
+ return revalidations.length === 0 ? 0 : Math.max(...revalidations);
+ } catch (e) {
+ // By default we don't want to crash here, so we return false
+ // We still log the error though so we can debug it
+ error(e);
+ return 0;
+ }
+ }
+
+ async hasBeenRevalidated(tags: string[], lastModified?: number): Promise {
+ return (await this.getLastRevalidated(tags)) > (lastModified ?? Date.now());
+ }
+
+ async writeTags(tags: string[]): Promise {
+ const kv = this.getKv();
+ if (!kv || tags.length === 0) {
+ return Promise.resolve();
+ }
+
+ const timeMs = String(Date.now());
+
+ await Promise.all(
+ tags.map(async (tag) => {
+ await kv.put(this.getCacheKey(tag), timeMs);
+ })
+ );
+
+ // TODO: See https://github.com/opennextjs/opennextjs-aws/issues/986
+ await purgeCacheByTags(tags);
+ }
+
+ /**
+ * Returns the KV namespace when it exists and tag cache is not disabled.
+ *
+ * @returns KV namespace or undefined
+ */
+ private getKv(): KVNamespace | undefined {
+ const kv = getCloudflareContext().env[BINDING_NAME];
+
+ if (!kv) {
+ error(`No KV binding ${BINDING_NAME} found`);
+ return undefined;
+ }
+
+ const isDisabled = Boolean(globalThis.openNextConfig.dangerous?.disableTagCache);
+
+ return isDisabled ? undefined : kv;
+ }
+
+ protected getCacheKey(key: string) {
+ return `${this.getBuildId()}/${key}`.replaceAll("//", "/");
+ }
+
+ protected getBuildId() {
+ return process.env.NEXT_BUILD_ID ?? FALLBACK_BUILD_ID;
+ }
+}
+
+export default new KVNextModeTagCache();
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 442a2240..6a98ff50 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -7,8 +7,8 @@ settings:
catalogs:
default:
'@cloudflare/workers-types':
- specifier: ^4.20250224.0
- version: 4.20250224.0
+ specifier: ^4.20250917.0
+ version: 4.20250924.0
'@dotenvx/dotenvx':
specifier: 1.31.0
version: 1.31.0
@@ -198,7 +198,7 @@ importers:
version: 5.7.3
wrangler:
specifier: 'catalog:'
- version: 4.38.0(@cloudflare/workers-types@4.20250224.0)
+ version: 4.38.0(@cloudflare/workers-types@4.20250924.0)
examples/bugs/gh-219:
dependencies:
@@ -426,7 +426,7 @@ importers:
version: 5.7.3
wrangler:
specifier: 'catalog:'
- version: 4.38.0(@cloudflare/workers-types@4.20250224.0)
+ version: 4.38.0(@cloudflare/workers-types@4.20250924.0)
examples/e2e/app-pages-router:
dependencies:
@@ -472,7 +472,7 @@ importers:
version: 5.7.3
wrangler:
specifier: 'catalog:'
- version: 4.38.0(@cloudflare/workers-types@4.20250224.0)
+ version: 4.38.0(@cloudflare/workers-types@4.20250924.0)
examples/e2e/app-router:
dependencies:
@@ -518,7 +518,7 @@ importers:
version: 5.7.3
wrangler:
specifier: 'catalog:'
- version: 4.38.0(@cloudflare/workers-types@4.20250224.0)
+ version: 4.38.0(@cloudflare/workers-types@4.20250924.0)
examples/e2e/experimental:
dependencies:
@@ -552,7 +552,7 @@ importers:
version: 5.7.3
wrangler:
specifier: 'catalog:'
- version: 4.38.0(@cloudflare/workers-types@4.20250224.0)
+ version: 4.38.0(@cloudflare/workers-types@4.20250924.0)
examples/e2e/pages-router:
dependencies:
@@ -598,7 +598,7 @@ importers:
version: 5.7.3
wrangler:
specifier: 'catalog:'
- version: 4.38.0(@cloudflare/workers-types@4.20250224.0)
+ version: 4.38.0(@cloudflare/workers-types@4.20250924.0)
examples/e2e/shared:
dependencies:
@@ -657,7 +657,7 @@ importers:
version: 5.7.3
wrangler:
specifier: 'catalog:'
- version: 4.38.0(@cloudflare/workers-types@4.20250224.0)
+ version: 4.38.0(@cloudflare/workers-types@4.20250924.0)
examples/next-partial-prerendering:
dependencies:
@@ -718,7 +718,7 @@ importers:
version: 5.5.3
wrangler:
specifier: 'catalog:'
- version: 4.38.0(@cloudflare/workers-types@4.20250224.0)
+ version: 4.38.0(@cloudflare/workers-types@4.20250924.0)
examples/overrides/d1-tag-next:
dependencies:
@@ -752,7 +752,41 @@ importers:
version: 5.7.3
wrangler:
specifier: 'catalog:'
- version: 4.38.0(@cloudflare/workers-types@4.20250224.0)
+ version: 4.38.0(@cloudflare/workers-types@4.20250924.0)
+
+ examples/overrides/kv-tag-next:
+ dependencies:
+ next:
+ specifier: catalog:e2e
+ version: 15.4.5(@opentelemetry/api@1.9.0)(@playwright/test@1.51.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
+ react:
+ specifier: catalog:e2e
+ version: 19.0.0
+ react-dom:
+ specifier: catalog:e2e
+ version: 19.0.0(react@19.0.0)
+ devDependencies:
+ '@opennextjs/cloudflare':
+ specifier: workspace:*
+ version: link:../../../packages/cloudflare
+ '@playwright/test':
+ specifier: 'catalog:'
+ version: 1.51.1
+ '@types/node':
+ specifier: 'catalog:'
+ version: 22.2.0
+ '@types/react':
+ specifier: catalog:e2e
+ version: 19.0.0
+ '@types/react-dom':
+ specifier: catalog:e2e
+ version: 19.0.0
+ typescript:
+ specifier: 'catalog:'
+ version: 5.7.3
+ wrangler:
+ specifier: 'catalog:'
+ version: 4.38.0(@cloudflare/workers-types@4.20250924.0)
examples/overrides/memory-queue:
dependencies:
@@ -786,7 +820,7 @@ importers:
version: 5.7.3
wrangler:
specifier: 'catalog:'
- version: 4.38.0(@cloudflare/workers-types@4.20250224.0)
+ version: 4.38.0(@cloudflare/workers-types@4.20250924.0)
examples/overrides/r2-incremental-cache:
dependencies:
@@ -820,7 +854,7 @@ importers:
version: 5.7.3
wrangler:
specifier: 'catalog:'
- version: 4.38.0(@cloudflare/workers-types@4.20250224.0)
+ version: 4.38.0(@cloudflare/workers-types@4.20250924.0)
examples/overrides/static-assets-incremental-cache:
dependencies:
@@ -854,7 +888,7 @@ importers:
version: 5.7.3
wrangler:
specifier: 'catalog:'
- version: 4.38.0(@cloudflare/workers-types@4.20250224.0)
+ version: 4.38.0(@cloudflare/workers-types@4.20250924.0)
examples/playground14:
dependencies:
@@ -879,7 +913,7 @@ importers:
version: 22.2.0
wrangler:
specifier: 'catalog:'
- version: 4.38.0(@cloudflare/workers-types@4.20250224.0)
+ version: 4.38.0(@cloudflare/workers-types@4.20250924.0)
examples/playground15:
dependencies:
@@ -904,7 +938,7 @@ importers:
version: 22.2.0
wrangler:
specifier: 'catalog:'
- version: 4.38.0(@cloudflare/workers-types@4.20250224.0)
+ version: 4.38.0(@cloudflare/workers-types@4.20250924.0)
examples/prisma:
dependencies:
@@ -944,7 +978,7 @@ importers:
version: 5.7.3
wrangler:
specifier: 'catalog:'
- version: 4.38.0(@cloudflare/workers-types@4.20250224.0)
+ version: 4.38.0(@cloudflare/workers-types@4.20250924.0)
examples/ssg-app:
dependencies:
@@ -978,7 +1012,7 @@ importers:
version: 5.7.3
wrangler:
specifier: 'catalog:'
- version: 4.38.0(@cloudflare/workers-types@4.20250224.0)
+ version: 4.38.0(@cloudflare/workers-types@4.20250924.0)
examples/vercel-blog-starter:
dependencies:
@@ -1033,7 +1067,7 @@ importers:
version: 5.7.3
wrangler:
specifier: 'catalog:'
- version: 4.38.0(@cloudflare/workers-types@4.20250224.0)
+ version: 4.38.0(@cloudflare/workers-types@4.20250924.0)
packages/cloudflare:
dependencies:
@@ -1057,14 +1091,14 @@ importers:
version: 0.8.6
wrangler:
specifier: 'catalog:'
- version: 4.38.0(@cloudflare/workers-types@4.20250224.0)
+ version: 4.38.0(@cloudflare/workers-types@4.20250924.0)
yargs:
specifier: 'catalog:'
version: 18.0.0
devDependencies:
'@cloudflare/workers-types':
specifier: 'catalog:'
- version: 4.20250224.0
+ version: 4.20250924.0
'@eslint/js':
specifier: 'catalog:'
version: 9.11.1
@@ -1756,8 +1790,8 @@ packages:
'@cloudflare/workers-types@4.20250214.0':
resolution: {integrity: sha512-+M8oOFVbyXT5GeJrYLWMUGyPf5wGB4+k59PPqdedtOig7NjZ5r4S79wMdaZ/EV5IV8JPtZBSNjTKpDnNmfxjaQ==}
- '@cloudflare/workers-types@4.20250224.0':
- resolution: {integrity: sha512-j6ZwQ5G2moQRaEtGI2u5TBQhVXv/XwOS5jfBAheZHcpCM07zm8j0i8jZHHLq/6VA8e6VRjKohOyj5j6tZ1KHLQ==}
+ '@cloudflare/workers-types@4.20250924.0':
+ resolution: {integrity: sha512-pi/OYCroYdwjFWbkciC5oYzlyimDF4ymNotDK0zpLNq91Ogz1IXnVBAYV7fCFAJ/zIxU0RiIBrJIOll/C0pR9Q==}
'@cspotcode/source-map-support@0.8.1':
resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==}
@@ -11266,7 +11300,7 @@ snapshots:
'@cloudflare/workers-types@4.20250214.0': {}
- '@cloudflare/workers-types@4.20250224.0': {}
+ '@cloudflare/workers-types@4.20250924.0': {}
'@cspotcode/source-map-support@0.8.1':
dependencies:
@@ -14098,7 +14132,7 @@ snapshots:
'@types/mock-fs@4.13.4':
dependencies:
- '@types/node': 22.2.0
+ '@types/node': 20.14.10
'@types/ms@0.7.34': {}
@@ -20611,7 +20645,7 @@ snapshots:
- bufferutil
- utf-8-validate
- wrangler@4.38.0(@cloudflare/workers-types@4.20250224.0):
+ wrangler@4.38.0(@cloudflare/workers-types@4.20250924.0):
dependencies:
'@cloudflare/kv-asset-handler': 0.4.0
'@cloudflare/unenv-preset': 2.7.4(unenv@2.0.0-rc.21)(workerd@1.20250917.0)
@@ -20622,7 +20656,7 @@ snapshots:
unenv: 2.0.0-rc.21
workerd: 1.20250917.0
optionalDependencies:
- '@cloudflare/workers-types': 4.20250224.0
+ '@cloudflare/workers-types': 4.20250924.0
fsevents: 2.3.3
transitivePeerDependencies:
- bufferutil
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index e3bff209..eefb9344 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -8,7 +8,7 @@ packages:
- benchmarking
catalog:
- "@cloudflare/workers-types": ^4.20250224.0
+ "@cloudflare/workers-types": ^4.20250917.0
"@dotenvx/dotenvx": 1.31.0
"@eslint/js": ^9.11.1
"@playwright/test": ^1.51.1