this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add an experimental KV based tag cache (#906)

Co-authored-by: conico974 <nicodorseuil@yahoo.fr>

authored by

Victor Berchet
conico974
and committed by
GitHub
dcc864d4 2529c201

+804 -35
+5
.changeset/silver-walls-cover.md
··· 1 + --- 2 + "@opennextjs/cloudflare": minor 3 + --- 4 + 5 + feat: add an experimental KV based tag cache
+1
examples/common/apps.ts
··· 16 16 "experimental", 17 17 // overrides 18 18 "d1-tag-next", 19 + "kv-tag-next", 19 20 "memory-queue", 20 21 "r2-incremental-cache", 21 22 "static-assets-incremental-cache",
+47
examples/overrides/kv-tag-next/.gitignore
··· 1 + # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 + 3 + # dependencies 4 + /node_modules 5 + /.pnp 6 + .pnp.* 7 + .yarn/* 8 + !.yarn/patches 9 + !.yarn/plugins 10 + !.yarn/releases 11 + !.yarn/versions 12 + 13 + # testing 14 + /coverage 15 + 16 + # next.js 17 + /.next/ 18 + /out/ 19 + 20 + # production 21 + /build 22 + 23 + # misc 24 + .DS_Store 25 + *.pem 26 + 27 + # debug 28 + npm-debug.log* 29 + yarn-debug.log* 30 + yarn-error.log* 31 + .pnpm-debug.log* 32 + 33 + # env files (can opt-in for committing if needed) 34 + .env* 35 + 36 + # vercel 37 + .vercel 38 + 39 + # typescript 40 + *.tsbuildinfo 41 + next-env.d.ts 42 + 43 + # playwright 44 + /test-results/ 45 + /playwright-report/ 46 + /blob-report/ 47 + /playwright/.cache/
+11
examples/overrides/kv-tag-next/app/action.ts
··· 1 + "use server"; 2 + 3 + import { revalidatePath, revalidateTag } from "next/cache"; 4 + 5 + export async function revalidateTagAction() { 6 + revalidateTag("date"); 7 + } 8 + 9 + export async function revalidatePathAction() { 10 + revalidatePath("/"); 11 + }
+27
examples/overrides/kv-tag-next/app/components/revalidationButtons.tsx
··· 1 + "use client"; 2 + 3 + import { revalidateTagAction, revalidatePathAction } from "../action"; 4 + 5 + export default function RevalidationButtons() { 6 + return ( 7 + <div> 8 + <button 9 + data-testid="revalidate-tag" 10 + onClick={async () => { 11 + await revalidateTagAction(); 12 + }} 13 + > 14 + Invalidate tag 15 + </button> 16 + 17 + <button 18 + data-testid="revalidate-path" 19 + onClick={async () => { 20 + await revalidatePathAction(); 21 + }} 22 + > 23 + Invalidate Path 24 + </button> 25 + </div> 26 + ); 27 + }
examples/overrides/kv-tag-next/app/favicon.ico

This is a binary file and will not be displayed.

+14
examples/overrides/kv-tag-next/app/globals.css
··· 1 + html, 2 + body { 3 + max-width: 100vw; 4 + overflow-x: hidden; 5 + height: 100vh; 6 + display: flex; 7 + flex-direction: column; 8 + } 9 + 10 + footer { 11 + padding: 1rem; 12 + display: flex; 13 + justify-content: end; 14 + }
+28
examples/overrides/kv-tag-next/app/layout.tsx
··· 1 + import type { Metadata } from "next"; 2 + import "./globals.css"; 3 + 4 + import { getCloudflareContext } from "@opennextjs/cloudflare"; 5 + 6 + export const metadata: Metadata = { 7 + title: "SSG App", 8 + description: "An app in which all the routes are SSG'd", 9 + }; 10 + 11 + export default async function RootLayout({ 12 + children, 13 + }: Readonly<{ 14 + children: React.ReactNode; 15 + }>) { 16 + const cloudflareContext = await getCloudflareContext({ 17 + async: true, 18 + }); 19 + 20 + return ( 21 + <html lang="en"> 22 + <body> 23 + {children} 24 + <footer data-testid="app-version">{cloudflareContext.env.APP_VERSION}</footer> 25 + </body> 26 + </html> 27 + ); 28 + }
+17
examples/overrides/kv-tag-next/app/page.module.css
··· 1 + .page { 2 + display: grid; 3 + grid-template-rows: 20px 1fr 20px; 4 + align-items: center; 5 + justify-items: center; 6 + flex: 1; 7 + border: 3px solid gray; 8 + margin: 1rem; 9 + margin-block-end: 0; 10 + } 11 + 12 + .main { 13 + display: flex; 14 + flex-direction: column; 15 + gap: 32px; 16 + grid-row-start: 2; 17 + }
+26
examples/overrides/kv-tag-next/app/page.tsx
··· 1 + import { unstable_cache } from "next/cache"; 2 + import styles from "./page.module.css"; 3 + import RevalidationButtons from "./components/revalidationButtons"; 4 + 5 + const fetchedDateCb = unstable_cache( 6 + async () => { 7 + return Date.now(); 8 + }, 9 + ["date"], 10 + { tags: ["date"] } 11 + ); 12 + 13 + export default async function Home() { 14 + const fetchedDate = await fetchedDateCb(); 15 + return ( 16 + <div className={styles.page}> 17 + <main className={styles.main}> 18 + <h1>Hello from a Statically generated page</h1> 19 + <p data-testid="date-local">{Date.now()}</p> 20 + <p data-testid="date-fetched">{fetchedDate}</p> 21 + 22 + <RevalidationButtons /> 23 + </main> 24 + </div> 25 + ); 26 + }
+47
examples/overrides/kv-tag-next/e2e/base.spec.ts
··· 1 + import { test, expect } from "@playwright/test"; 2 + 3 + test.describe("kv-tag-next", () => { 4 + test("the index page should work", async ({ page }) => { 5 + await page.goto("/"); 6 + await expect(page.getByText("Hello from a Statically generated page")).toBeVisible(); 7 + }); 8 + 9 + test("the index page should keep the same date on reload", async ({ page }) => { 10 + await page.goto("/"); 11 + const date = await page.getByTestId("date-local").textContent(); 12 + expect(date).not.toBeNull(); 13 + await page.reload(); 14 + const newDate = await page.getByTestId("date-local").textContent(); 15 + expect(date).toEqual(newDate); 16 + }); 17 + 18 + test("the index page should revalidate the date on click on revalidateTag", async ({ page }) => { 19 + await page.goto("/"); 20 + const date = await page.getByTestId("date-fetched").textContent(); 21 + await page.getByTestId("revalidate-tag").click(); 22 + await page.waitForTimeout(100); 23 + const newDate = await page.getByTestId("date-fetched").textContent(); 24 + expect(date).not.toEqual(newDate); 25 + }); 26 + 27 + test("the index page should revalidate the date on click on revalidatePath", async ({ page }) => { 28 + await page.goto("/"); 29 + const date = await page.getByTestId("date-fetched").textContent(); 30 + await page.getByTestId("revalidate-path").click(); 31 + await page.waitForTimeout(100); 32 + const newDate = await page.getByTestId("date-fetched").textContent(); 33 + expect(date).not.toEqual(newDate); 34 + }); 35 + 36 + test("the index page should keep the same date on reload after revalidation", async ({ page }) => { 37 + await page.goto("/"); 38 + const initialDate = await page.getByTestId("date-fetched").textContent(); 39 + await page.getByTestId("revalidate-tag").click(); 40 + await page.waitForTimeout(100); 41 + const date = await page.getByTestId("date-fetched").textContent(); 42 + expect(initialDate).not.toEqual(date); 43 + await page.reload(); 44 + const newDate = await page.getByTestId("date-fetched").textContent(); 45 + expect(date).toEqual(newDate); 46 + }); 47 + });
+4
examples/overrides/kv-tag-next/e2e/playwright.config.ts
··· 1 + import { configurePlaywright } from "../../../common/config-e2e"; 2 + 3 + // Here we don't want to run the tests in parallel 4 + export default configurePlaywright("kv-tag-next", { parallel: false });
+11
examples/overrides/kv-tag-next/next.config.ts
··· 1 + import type { NextConfig } from "next"; 2 + import { initOpenNextCloudflareForDev } from "@opennextjs/cloudflare"; 3 + 4 + initOpenNextCloudflareForDev(); 5 + 6 + const nextConfig: NextConfig = { 7 + typescript: { ignoreBuildErrors: true }, 8 + eslint: { ignoreDuringBuilds: true }, 9 + }; 10 + 11 + export default nextConfig;
+8
examples/overrides/kv-tag-next/open-next.config.ts
··· 1 + import { defineCloudflareConfig } from "@opennextjs/cloudflare"; 2 + import kvIncrementalCache from "@opennextjs/cloudflare/overrides/incremental-cache/kv-incremental-cache"; 3 + import kvNextTagCache from "@opennextjs/cloudflare/overrides/tag-cache/kv-next-tag-cache"; 4 + 5 + export default defineCloudflareConfig({ 6 + incrementalCache: kvIncrementalCache, 7 + tagCache: kvNextTagCache, 8 + });
+29
examples/overrides/kv-tag-next/package.json
··· 1 + { 2 + "name": "kv-tag-next", 3 + "version": "0.1.0", 4 + "private": true, 5 + "scripts": { 6 + "dev": "next dev", 7 + "build": "next build", 8 + "start": "next start", 9 + "lint": "next lint", 10 + "build:worker": "pnpm opennextjs-cloudflare build --skipWranglerConfigCheck", 11 + "preview:worker": "pnpm opennextjs-cloudflare preview --config wrangler.e2e.jsonc", 12 + "preview": "pnpm build:worker && pnpm preview:worker", 13 + "e2e": "playwright test -c e2e/playwright.config.ts" 14 + }, 15 + "dependencies": { 16 + "react": "catalog:e2e", 17 + "react-dom": "catalog:e2e", 18 + "next": "catalog:e2e" 19 + }, 20 + "devDependencies": { 21 + "@opennextjs/cloudflare": "workspace:*", 22 + "@playwright/test": "catalog:", 23 + "@types/node": "catalog:", 24 + "@types/react": "catalog:e2e", 25 + "@types/react-dom": "catalog:e2e", 26 + "typescript": "catalog:", 27 + "wrangler": "catalog:" 28 + } 29 + }
+27
examples/overrides/kv-tag-next/tsconfig.json
··· 1 + { 2 + "compilerOptions": { 3 + "target": "ES2017", 4 + "lib": ["dom", "dom.iterable", "esnext"], 5 + "allowJs": true, 6 + "skipLibCheck": true, 7 + "strict": true, 8 + "noEmit": true, 9 + "esModuleInterop": true, 10 + "module": "esnext", 11 + "moduleResolution": "bundler", 12 + "resolveJsonModule": true, 13 + "isolatedModules": true, 14 + "jsx": "preserve", 15 + "incremental": true, 16 + "plugins": [ 17 + { 18 + "name": "next" 19 + } 20 + ], 21 + "paths": { 22 + "@/*": ["./*"] 23 + } 24 + }, 25 + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 26 + "exclude": ["node_modules"] 27 + }
+25
examples/overrides/kv-tag-next/wrangler.e2e.jsonc
··· 1 + { 2 + "$schema": "node_modules/wrangler/config-schema.json", 3 + "main": ".open-next/worker.js", 4 + "name": "ssg-app", 5 + "compatibility_date": "2025-02-04", 6 + "compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"], 7 + "assets": { 8 + "directory": ".open-next/assets", 9 + "binding": "ASSETS", 10 + }, 11 + "vars": { 12 + "APP_VERSION": "1.2.345", 13 + }, 14 + "kv_namespaces": [ 15 + { 16 + "binding": "NEXT_INC_CACHE_KV", 17 + "id": "INC-CACHE", 18 + "preview_id": "<BINDING_ID>", 19 + }, 20 + { 21 + "binding": "NEXT_TAG_CACHE_KV", 22 + "id": "TAG-CACHE", 23 + }, 24 + ], 25 + }
+3
packages/cloudflare/src/api/cloudflare-context.ts
··· 33 33 // D1 db used for the tag cache 34 34 NEXT_TAG_CACHE_D1?: D1Database; 35 35 36 + // KV used for the tag cache 37 + NEXT_TAG_CACHE_KV?: KVNamespace; 38 + 36 39 // Durables object namespace to use for the sharded tag cache 37 40 NEXT_TAG_CACHE_DO_SHARDED?: DurableObjectNamespace<DOShardedTagCache>; 38 41 // Queue of failed tag write
+1 -1
packages/cloudflare/src/api/overrides/tag-cache/d1-next-tag-cache.spec.ts
··· 59 59 env: { 60 60 [BINDING_NAME]: mockDb, 61 61 }, 62 - } as ReturnType<typeof getCloudflareContext>); 62 + } as unknown as ReturnType<typeof getCloudflareContext>); 63 63 64 64 // Reset global config 65 65 (globalThis as { openNextConfig?: { dangerous?: { disableTagCache?: boolean } } }).openNextConfig = {
+4 -5
packages/cloudflare/src/api/overrides/tag-cache/d1-next-tag-cache.ts
··· 1 1 import { error } from "@opennextjs/aws/adapters/logger.js"; 2 2 import type { NextModeTagCache } from "@opennextjs/aws/types/overrides.js"; 3 3 4 - import type { OpenNextConfig } from "../../../api/config.js"; 5 4 import { getCloudflareContext } from "../../cloudflare-context.js"; 6 5 import { debugCache, FALLBACK_BUILD_ID, purgeCacheByTags } from "../internal.js"; 7 6 ··· 28 27 // We only care about the most recent revalidation 29 28 return (result.results[0]?.time ?? 0) as number; 30 29 } catch (e) { 31 - error(e); 32 30 // By default we don't want to crash here, so we return false 33 31 // We still log the error though so we can debug it 32 + error(e); 34 33 return 0; 35 34 } 36 35 } ··· 57 56 58 57 async writeTags(tags: string[]): Promise<void> { 59 58 const { isDisabled, db } = this.getConfig(); 60 - // TODO: Remove `tags.length === 0` when https://github.com/opennextjs/opennextjs-aws/pull/828 is used 61 59 if (isDisabled || tags.length === 0) return Promise.resolve(); 62 60 63 61 await db.batch( ··· 67 65 .bind(this.getCacheKey(tag), Date.now()) 68 66 ) 69 67 ); 68 + 69 + // TODO: See https://github.com/opennextjs/opennextjs-aws/issues/986 70 70 await purgeCacheByTags(tags); 71 71 } 72 72 ··· 75 75 76 76 if (!db) debugCache("No D1 database found"); 77 77 78 - const isDisabled = !!(globalThis as unknown as { openNextConfig: OpenNextConfig }).openNextConfig 79 - .dangerous?.disableTagCache; 78 + const isDisabled = Boolean(globalThis.openNextConfig.dangerous?.disableTagCache); 80 79 81 80 return !db || isDisabled 82 81 ? { isDisabled: true as const }
+3 -1
packages/cloudflare/src/api/overrides/tag-cache/do-sharded-tag-cache.ts
··· 3 3 import type { NextModeTagCache } from "@opennextjs/aws/types/overrides.js"; 4 4 import { IgnorableError } from "@opennextjs/aws/utils/error.js"; 5 5 6 - import type { OpenNextConfig } from "../../../api/config.js"; 7 6 import { getCloudflareContext } from "../../cloudflare-context.js"; 7 + import type { OpenNextConfig } from "../../config.js"; 8 8 import { DOShardedTagCache } from "../../durable-objects/sharded-tag-cache.js"; 9 9 import { debugCache, purgeCacheByTags } from "../internal.js"; 10 10 ··· 227 227 await this.performWriteTagsWithRetry(doId, tags, currentTime); 228 228 }) 229 229 ); 230 + 231 + // TODO: See https://github.com/opennextjs/opennextjs-aws/issues/986 230 232 await purgeCacheByTags(tags); 231 233 } 232 234
+306
packages/cloudflare/src/api/overrides/tag-cache/kv-next-tag-cache.spec.ts
··· 1 + import { error } from "@opennextjs/aws/adapters/logger.js"; 2 + import { beforeEach, describe, expect, it, vi } from "vitest"; 3 + 4 + import { getCloudflareContext } from "../../cloudflare-context.js"; 5 + import { FALLBACK_BUILD_ID, purgeCacheByTags } from "../internal.js"; 6 + import { BINDING_NAME, KVNextModeTagCache, NAME } from "./kv-next-tag-cache.js"; 7 + 8 + // Mock dependencies 9 + vi.mock("@opennextjs/aws/adapters/logger.js", () => ({ 10 + error: vi.fn(), 11 + })); 12 + 13 + vi.mock("../../cloudflare-context.js", () => ({ 14 + getCloudflareContext: vi.fn(), 15 + })); 16 + 17 + vi.mock("../internal.js", () => ({ 18 + debugCache: vi.fn(), 19 + FALLBACK_BUILD_ID: "fallback-build-id", 20 + purgeCacheByTags: vi.fn(), 21 + })); 22 + 23 + describe("KVNextModeTagCache", () => { 24 + let tagCache: KVNextModeTagCache; 25 + let mockKv: { 26 + put: ReturnType<typeof vi.fn>; 27 + get: ReturnType<typeof vi.fn>; 28 + }; 29 + let mockGet: ReturnType<typeof vi.fn>; 30 + let mockPut: ReturnType<typeof vi.fn>; 31 + 32 + beforeEach(() => { 33 + vi.clearAllMocks(); 34 + 35 + // Setup mock database 36 + mockGet = vi.fn(); 37 + mockPut = vi.fn(); 38 + 39 + mockKv = { 40 + get: mockGet, 41 + put: mockPut, 42 + }; 43 + 44 + // Setup cloudflare context mock 45 + vi.mocked(getCloudflareContext).mockReturnValue({ 46 + env: { 47 + [BINDING_NAME]: mockKv, 48 + }, 49 + } as unknown as ReturnType<typeof getCloudflareContext>); 50 + 51 + // Reset global config 52 + (globalThis as { openNextConfig?: { dangerous?: { disableTagCache?: boolean } } }).openNextConfig = { 53 + dangerous: { 54 + disableTagCache: false, 55 + }, 56 + }; 57 + 58 + // Reset environment variables 59 + vi.unstubAllEnvs(); 60 + 61 + tagCache = new KVNextModeTagCache(); 62 + }); 63 + 64 + describe("constructor and properties", () => { 65 + it("should have correct mode and name", () => { 66 + expect(tagCache.mode).toBe("nextMode"); 67 + expect(tagCache.name).toBe(NAME); 68 + }); 69 + }); 70 + 71 + describe("getLastRevalidated", () => { 72 + it("should return 0 when cache is disabled", async () => { 73 + ( 74 + globalThis as { openNextConfig?: { dangerous?: { disableTagCache?: boolean } } } 75 + ).openNextConfig!.dangerous!.disableTagCache = true; 76 + 77 + const result = await tagCache.getLastRevalidated(["tag1", "tag2"]); 78 + 79 + expect(result).toBe(0); 80 + expect(mockGet).not.toHaveBeenCalled(); 81 + }); 82 + 83 + it("should return 0 when no KV is available", async () => { 84 + vi.mocked(getCloudflareContext).mockReturnValue({ 85 + env: {}, 86 + } as ReturnType<typeof getCloudflareContext>); 87 + 88 + const result = await tagCache.getLastRevalidated(["tag1", "tag2"]); 89 + 90 + expect(result).toBe(0); 91 + expect(error).toHaveBeenCalledWith("No KV binding NEXT_TAG_CACHE_KV found"); 92 + }); 93 + 94 + it("should return the maximum revalidation time for given tags", async () => { 95 + const mockTime = 1234567890; 96 + mockGet.mockResolvedValue( 97 + new Map([ 98 + ["tag1", mockTime], 99 + ["tag2", mockTime - 100], 100 + ]) 101 + ); 102 + 103 + const tags = ["tag1", "tag2"]; 104 + const result = await tagCache.getLastRevalidated(tags); 105 + 106 + expect(result).toBe(mockTime); 107 + expect(mockGet).toHaveBeenCalledWith([`${FALLBACK_BUILD_ID}/tag1`, `${FALLBACK_BUILD_ID}/tag2`], { 108 + type: "json", 109 + }); 110 + }); 111 + 112 + it("should return 0 when no results are found", async () => { 113 + mockGet.mockResolvedValue(new Map([["tag1", null]])); 114 + 115 + const result = await tagCache.getLastRevalidated(["tag1"]); 116 + 117 + expect(result).toBe(0); 118 + }); 119 + 120 + it("should return 0 when KV get throws an error", async () => { 121 + const mockError = new Error("Database error"); 122 + mockGet.mockRejectedValue(mockError); 123 + 124 + const result = await tagCache.getLastRevalidated(["tag1"]); 125 + 126 + expect(result).toBe(0); 127 + expect(error).toHaveBeenCalledWith(mockError); 128 + }); 129 + 130 + it("should use custom build ID when NEXT_BUILD_ID is set", async () => { 131 + const customBuildId = "custom-build-id"; 132 + vi.stubEnv("NEXT_BUILD_ID", customBuildId); 133 + 134 + mockGet.mockResolvedValue(new Map([["tag1", null]])); 135 + 136 + await tagCache.getLastRevalidated(["tag1"]); 137 + 138 + expect(mockGet).toHaveBeenCalledWith([`${customBuildId}/tag1`], { type: "json" }); 139 + }); 140 + }); 141 + 142 + describe("hasBeenRevalidated", () => { 143 + it("should return false when cache is disabled", async () => { 144 + ( 145 + globalThis as { openNextConfig?: { dangerous?: { disableTagCache?: boolean } } } 146 + ).openNextConfig!.dangerous!.disableTagCache = true; 147 + 148 + const result = await tagCache.hasBeenRevalidated(["tag1"], 1000); 149 + 150 + expect(result).toBe(false); 151 + expect(mockGet).not.toHaveBeenCalled(); 152 + }); 153 + 154 + it("should return false when no KV is available", async () => { 155 + vi.mocked(getCloudflareContext).mockReturnValue({ 156 + env: {}, 157 + } as ReturnType<typeof getCloudflareContext>); 158 + 159 + const result = await tagCache.hasBeenRevalidated(["tag1"], 1000); 160 + 161 + expect(result).toBe(false); 162 + }); 163 + 164 + it("should return true when tags have been revalidated after lastModified", async () => { 165 + mockGet.mockResolvedValue( 166 + new Map([ 167 + ["tag1", 1000], 168 + ["tag2", null], 169 + ]) 170 + ); 171 + 172 + const tags = ["tag1", "tag2"]; 173 + const lastModified = 500; 174 + const result = await tagCache.hasBeenRevalidated(tags, lastModified); 175 + 176 + expect(result).toBe(true); 177 + }); 178 + 179 + it("should return false when no tags have been revalidated", async () => { 180 + mockGet.mockResolvedValue( 181 + new Map([ 182 + ["tag1", null], 183 + ["tag2", null], 184 + ]) 185 + ); 186 + 187 + const result = await tagCache.hasBeenRevalidated(["tag1", "tag2"], 1000); 188 + 189 + expect(result).toBe(false); 190 + }); 191 + 192 + it("should return false when KV get throws an error", async () => { 193 + const mockError = new Error("Database error"); 194 + mockGet.mockRejectedValue(mockError); 195 + 196 + const result = await tagCache.hasBeenRevalidated(["tag1"], 1000); 197 + 198 + expect(result).toBe(false); 199 + expect(error).toHaveBeenCalledWith(mockError); 200 + }); 201 + }); 202 + 203 + describe("writeTags", () => { 204 + it("should do nothing when cache is disabled", async () => { 205 + ( 206 + globalThis as { openNextConfig?: { dangerous?: { disableTagCache?: boolean } } } 207 + ).openNextConfig!.dangerous!.disableTagCache = true; 208 + 209 + await tagCache.writeTags(["tag1", "tag2"]); 210 + 211 + expect(mockPut).not.toHaveBeenCalled(); 212 + expect(purgeCacheByTags).not.toHaveBeenCalled(); 213 + }); 214 + 215 + it("should do nothing when no KV is available", async () => { 216 + vi.mocked(getCloudflareContext).mockReturnValue({ 217 + env: {}, 218 + } as ReturnType<typeof getCloudflareContext>); 219 + 220 + await tagCache.writeTags(["tag1", "tag2"]); 221 + 222 + expect(mockPut).not.toHaveBeenCalled(); 223 + expect(purgeCacheByTags).not.toHaveBeenCalled(); 224 + }); 225 + 226 + it("should do nothing when tags array is empty", async () => { 227 + await tagCache.writeTags([]); 228 + 229 + expect(mockPut).not.toHaveBeenCalled(); 230 + expect(purgeCacheByTags).not.toHaveBeenCalled(); 231 + }); 232 + 233 + it("should write tags to KV and purge cache", async () => { 234 + const currentTime = Date.now(); 235 + vi.spyOn(Date, "now").mockReturnValue(currentTime); 236 + 237 + const tags = ["tag1", "tag2"]; 238 + await tagCache.writeTags(tags); 239 + 240 + expect(mockPut).toHaveBeenCalledTimes(2); 241 + expect(mockPut).toHaveBeenCalledWith("fallback-build-id/tag1", String(currentTime)); 242 + expect(mockPut).toHaveBeenCalledWith("fallback-build-id/tag2", String(currentTime)); 243 + 244 + expect(purgeCacheByTags).toHaveBeenCalledWith(tags); 245 + }); 246 + 247 + it("should handle single tag", async () => { 248 + const currentTime = Date.now(); 249 + vi.spyOn(Date, "now").mockReturnValue(currentTime); 250 + 251 + await tagCache.writeTags(["single-tag"]); 252 + 253 + expect(mockPut).toHaveBeenCalledTimes(1); 254 + expect(mockPut).toHaveBeenCalledWith("fallback-build-id/single-tag", String(currentTime)); 255 + 256 + expect(purgeCacheByTags).toHaveBeenCalledWith(["single-tag"]); 257 + }); 258 + }); 259 + 260 + describe("getCacheKey", () => { 261 + it("should generate cache key with build ID and tag", () => { 262 + const key = "test-tag"; 263 + const cacheKey = (tagCache as unknown as { getCacheKey: (key: string) => string }).getCacheKey(key); 264 + 265 + expect(cacheKey).toBe(`${FALLBACK_BUILD_ID}/${key}`); 266 + }); 267 + 268 + it("should use custom build ID when NEXT_BUILD_ID is set", () => { 269 + const customBuildId = "custom-build-id"; 270 + vi.stubEnv("NEXT_BUILD_ID", customBuildId); 271 + 272 + const key = "test-tag"; 273 + const cacheKey = (tagCache as unknown as { getCacheKey: (key: string) => string }).getCacheKey(key); 274 + 275 + expect(cacheKey).toBe(`${customBuildId}/${key}`); 276 + }); 277 + 278 + it("should handle double slashes by replacing them with single slash", () => { 279 + vi.stubEnv("NEXT_BUILD_ID", "build//id"); 280 + 281 + const key = "test-tag"; 282 + const cacheKey = (tagCache as unknown as { getCacheKey: (key: string) => string }).getCacheKey(key); 283 + 284 + expect(cacheKey).toBe("build/id/test-tag"); 285 + }); 286 + }); 287 + 288 + describe("getBuildId", () => { 289 + it("should return NEXT_BUILD_ID when set", () => { 290 + const customBuildId = "custom-build-id"; 291 + vi.stubEnv("NEXT_BUILD_ID", customBuildId); 292 + 293 + const buildId = (tagCache as unknown as { getBuildId: () => string }).getBuildId(); 294 + 295 + expect(buildId).toBe(customBuildId); 296 + }); 297 + 298 + it("should return fallback build ID when NEXT_BUILD_ID is not set", () => { 299 + // Environment variables are cleared by vi.unstubAllEnvs() in beforeEach 300 + 301 + const buildId = (tagCache as unknown as { getBuildId: () => string }).getBuildId(); 302 + 303 + expect(buildId).toBe(FALLBACK_BUILD_ID); 304 + }); 305 + }); 306 + });
+98
packages/cloudflare/src/api/overrides/tag-cache/kv-next-tag-cache.ts
··· 1 + import { error } from "@opennextjs/aws/adapters/logger.js"; 2 + import type { NextModeTagCache } from "@opennextjs/aws/types/overrides.js"; 3 + 4 + import { getCloudflareContext } from "../../cloudflare-context.js"; 5 + import { FALLBACK_BUILD_ID, purgeCacheByTags } from "../internal.js"; 6 + 7 + export const NAME = "kv-next-mode-tag-cache"; 8 + 9 + export const BINDING_NAME = "NEXT_TAG_CACHE_KV"; 10 + 11 + /** 12 + * Tag Cache based on a KV namespace 13 + * 14 + * Warning: 15 + * This implementation is considered experimental for now. 16 + * KV is eventually consistent and can take up to 60s to reflect the last write. 17 + * This means that: 18 + * - revalidations can take up to 60s to apply 19 + * - when a page depends on multiple tags they can be inconsistent for up to 60s. 20 + * It also means that cached data could be outdated for one tag when other tags 21 + * are revalidated resulting in the page being generated based on outdated data. 22 + */ 23 + export class KVNextModeTagCache implements NextModeTagCache { 24 + readonly mode = "nextMode" as const; 25 + readonly name = NAME; 26 + 27 + async getLastRevalidated(tags: string[]): Promise<number> { 28 + const kv = this.getKv(); 29 + if (!kv) { 30 + return 0; 31 + } 32 + 33 + try { 34 + const keys = tags.map((tag) => this.getCacheKey(tag)); 35 + // Use the `json` type to get back numbers/null 36 + const result: Map<string, number | null> = await kv.get(keys, { type: "json" }); 37 + 38 + const revalidations = [...result.values()].filter((v) => v != null); 39 + 40 + return revalidations.length === 0 ? 0 : Math.max(...revalidations); 41 + } catch (e) { 42 + // By default we don't want to crash here, so we return false 43 + // We still log the error though so we can debug it 44 + error(e); 45 + return 0; 46 + } 47 + } 48 + 49 + async hasBeenRevalidated(tags: string[], lastModified?: number): Promise<boolean> { 50 + return (await this.getLastRevalidated(tags)) > (lastModified ?? Date.now()); 51 + } 52 + 53 + async writeTags(tags: string[]): Promise<void> { 54 + const kv = this.getKv(); 55 + if (!kv || tags.length === 0) { 56 + return Promise.resolve(); 57 + } 58 + 59 + const timeMs = String(Date.now()); 60 + 61 + await Promise.all( 62 + tags.map(async (tag) => { 63 + await kv.put(this.getCacheKey(tag), timeMs); 64 + }) 65 + ); 66 + 67 + // TODO: See https://github.com/opennextjs/opennextjs-aws/issues/986 68 + await purgeCacheByTags(tags); 69 + } 70 + 71 + /** 72 + * Returns the KV namespace when it exists and tag cache is not disabled. 73 + * 74 + * @returns KV namespace or undefined 75 + */ 76 + private getKv(): KVNamespace | undefined { 77 + const kv = getCloudflareContext().env[BINDING_NAME]; 78 + 79 + if (!kv) { 80 + error(`No KV binding ${BINDING_NAME} found`); 81 + return undefined; 82 + } 83 + 84 + const isDisabled = Boolean(globalThis.openNextConfig.dangerous?.disableTagCache); 85 + 86 + return isDisabled ? undefined : kv; 87 + } 88 + 89 + protected getCacheKey(key: string) { 90 + return `${this.getBuildId()}/${key}`.replaceAll("//", "/"); 91 + } 92 + 93 + protected getBuildId() { 94 + return process.env.NEXT_BUILD_ID ?? FALLBACK_BUILD_ID; 95 + } 96 + } 97 + 98 + export default new KVNextModeTagCache();
+61 -27
pnpm-lock.yaml
··· 7 7 catalogs: 8 8 default: 9 9 '@cloudflare/workers-types': 10 - specifier: ^4.20250224.0 11 - version: 4.20250224.0 10 + specifier: ^4.20250917.0 11 + version: 4.20250924.0 12 12 '@dotenvx/dotenvx': 13 13 specifier: 1.31.0 14 14 version: 1.31.0 ··· 198 198 version: 5.7.3 199 199 wrangler: 200 200 specifier: 'catalog:' 201 - version: 4.38.0(@cloudflare/workers-types@4.20250224.0) 201 + version: 4.38.0(@cloudflare/workers-types@4.20250924.0) 202 202 203 203 examples/bugs/gh-219: 204 204 dependencies: ··· 426 426 version: 5.7.3 427 427 wrangler: 428 428 specifier: 'catalog:' 429 - version: 4.38.0(@cloudflare/workers-types@4.20250224.0) 429 + version: 4.38.0(@cloudflare/workers-types@4.20250924.0) 430 430 431 431 examples/e2e/app-pages-router: 432 432 dependencies: ··· 472 472 version: 5.7.3 473 473 wrangler: 474 474 specifier: 'catalog:' 475 - version: 4.38.0(@cloudflare/workers-types@4.20250224.0) 475 + version: 4.38.0(@cloudflare/workers-types@4.20250924.0) 476 476 477 477 examples/e2e/app-router: 478 478 dependencies: ··· 518 518 version: 5.7.3 519 519 wrangler: 520 520 specifier: 'catalog:' 521 - version: 4.38.0(@cloudflare/workers-types@4.20250224.0) 521 + version: 4.38.0(@cloudflare/workers-types@4.20250924.0) 522 522 523 523 examples/e2e/experimental: 524 524 dependencies: ··· 552 552 version: 5.7.3 553 553 wrangler: 554 554 specifier: 'catalog:' 555 - version: 4.38.0(@cloudflare/workers-types@4.20250224.0) 555 + version: 4.38.0(@cloudflare/workers-types@4.20250924.0) 556 556 557 557 examples/e2e/pages-router: 558 558 dependencies: ··· 598 598 version: 5.7.3 599 599 wrangler: 600 600 specifier: 'catalog:' 601 - version: 4.38.0(@cloudflare/workers-types@4.20250224.0) 601 + version: 4.38.0(@cloudflare/workers-types@4.20250924.0) 602 602 603 603 examples/e2e/shared: 604 604 dependencies: ··· 657 657 version: 5.7.3 658 658 wrangler: 659 659 specifier: 'catalog:' 660 - version: 4.38.0(@cloudflare/workers-types@4.20250224.0) 660 + version: 4.38.0(@cloudflare/workers-types@4.20250924.0) 661 661 662 662 examples/next-partial-prerendering: 663 663 dependencies: ··· 718 718 version: 5.5.3 719 719 wrangler: 720 720 specifier: 'catalog:' 721 - version: 4.38.0(@cloudflare/workers-types@4.20250224.0) 721 + version: 4.38.0(@cloudflare/workers-types@4.20250924.0) 722 722 723 723 examples/overrides/d1-tag-next: 724 724 dependencies: ··· 752 752 version: 5.7.3 753 753 wrangler: 754 754 specifier: 'catalog:' 755 - version: 4.38.0(@cloudflare/workers-types@4.20250224.0) 755 + version: 4.38.0(@cloudflare/workers-types@4.20250924.0) 756 + 757 + examples/overrides/kv-tag-next: 758 + dependencies: 759 + next: 760 + specifier: catalog:e2e 761 + 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) 762 + react: 763 + specifier: catalog:e2e 764 + version: 19.0.0 765 + react-dom: 766 + specifier: catalog:e2e 767 + version: 19.0.0(react@19.0.0) 768 + devDependencies: 769 + '@opennextjs/cloudflare': 770 + specifier: workspace:* 771 + version: link:../../../packages/cloudflare 772 + '@playwright/test': 773 + specifier: 'catalog:' 774 + version: 1.51.1 775 + '@types/node': 776 + specifier: 'catalog:' 777 + version: 22.2.0 778 + '@types/react': 779 + specifier: catalog:e2e 780 + version: 19.0.0 781 + '@types/react-dom': 782 + specifier: catalog:e2e 783 + version: 19.0.0 784 + typescript: 785 + specifier: 'catalog:' 786 + version: 5.7.3 787 + wrangler: 788 + specifier: 'catalog:' 789 + version: 4.38.0(@cloudflare/workers-types@4.20250924.0) 756 790 757 791 examples/overrides/memory-queue: 758 792 dependencies: ··· 786 820 version: 5.7.3 787 821 wrangler: 788 822 specifier: 'catalog:' 789 - version: 4.38.0(@cloudflare/workers-types@4.20250224.0) 823 + version: 4.38.0(@cloudflare/workers-types@4.20250924.0) 790 824 791 825 examples/overrides/r2-incremental-cache: 792 826 dependencies: ··· 820 854 version: 5.7.3 821 855 wrangler: 822 856 specifier: 'catalog:' 823 - version: 4.38.0(@cloudflare/workers-types@4.20250224.0) 857 + version: 4.38.0(@cloudflare/workers-types@4.20250924.0) 824 858 825 859 examples/overrides/static-assets-incremental-cache: 826 860 dependencies: ··· 854 888 version: 5.7.3 855 889 wrangler: 856 890 specifier: 'catalog:' 857 - version: 4.38.0(@cloudflare/workers-types@4.20250224.0) 891 + version: 4.38.0(@cloudflare/workers-types@4.20250924.0) 858 892 859 893 examples/playground14: 860 894 dependencies: ··· 879 913 version: 22.2.0 880 914 wrangler: 881 915 specifier: 'catalog:' 882 - version: 4.38.0(@cloudflare/workers-types@4.20250224.0) 916 + version: 4.38.0(@cloudflare/workers-types@4.20250924.0) 883 917 884 918 examples/playground15: 885 919 dependencies: ··· 904 938 version: 22.2.0 905 939 wrangler: 906 940 specifier: 'catalog:' 907 - version: 4.38.0(@cloudflare/workers-types@4.20250224.0) 941 + version: 4.38.0(@cloudflare/workers-types@4.20250924.0) 908 942 909 943 examples/prisma: 910 944 dependencies: ··· 944 978 version: 5.7.3 945 979 wrangler: 946 980 specifier: 'catalog:' 947 - version: 4.38.0(@cloudflare/workers-types@4.20250224.0) 981 + version: 4.38.0(@cloudflare/workers-types@4.20250924.0) 948 982 949 983 examples/ssg-app: 950 984 dependencies: ··· 978 1012 version: 5.7.3 979 1013 wrangler: 980 1014 specifier: 'catalog:' 981 - version: 4.38.0(@cloudflare/workers-types@4.20250224.0) 1015 + version: 4.38.0(@cloudflare/workers-types@4.20250924.0) 982 1016 983 1017 examples/vercel-blog-starter: 984 1018 dependencies: ··· 1033 1067 version: 5.7.3 1034 1068 wrangler: 1035 1069 specifier: 'catalog:' 1036 - version: 4.38.0(@cloudflare/workers-types@4.20250224.0) 1070 + version: 4.38.0(@cloudflare/workers-types@4.20250924.0) 1037 1071 1038 1072 packages/cloudflare: 1039 1073 dependencies: ··· 1057 1091 version: 0.8.6 1058 1092 wrangler: 1059 1093 specifier: 'catalog:' 1060 - version: 4.38.0(@cloudflare/workers-types@4.20250224.0) 1094 + version: 4.38.0(@cloudflare/workers-types@4.20250924.0) 1061 1095 yargs: 1062 1096 specifier: 'catalog:' 1063 1097 version: 18.0.0 1064 1098 devDependencies: 1065 1099 '@cloudflare/workers-types': 1066 1100 specifier: 'catalog:' 1067 - version: 4.20250224.0 1101 + version: 4.20250924.0 1068 1102 '@eslint/js': 1069 1103 specifier: 'catalog:' 1070 1104 version: 9.11.1 ··· 1756 1790 '@cloudflare/workers-types@4.20250214.0': 1757 1791 resolution: {integrity: sha512-+M8oOFVbyXT5GeJrYLWMUGyPf5wGB4+k59PPqdedtOig7NjZ5r4S79wMdaZ/EV5IV8JPtZBSNjTKpDnNmfxjaQ==} 1758 1792 1759 - '@cloudflare/workers-types@4.20250224.0': 1760 - resolution: {integrity: sha512-j6ZwQ5G2moQRaEtGI2u5TBQhVXv/XwOS5jfBAheZHcpCM07zm8j0i8jZHHLq/6VA8e6VRjKohOyj5j6tZ1KHLQ==} 1793 + '@cloudflare/workers-types@4.20250924.0': 1794 + resolution: {integrity: sha512-pi/OYCroYdwjFWbkciC5oYzlyimDF4ymNotDK0zpLNq91Ogz1IXnVBAYV7fCFAJ/zIxU0RiIBrJIOll/C0pR9Q==} 1761 1795 1762 1796 '@cspotcode/source-map-support@0.8.1': 1763 1797 resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} ··· 11266 11300 11267 11301 '@cloudflare/workers-types@4.20250214.0': {} 11268 11302 11269 - '@cloudflare/workers-types@4.20250224.0': {} 11303 + '@cloudflare/workers-types@4.20250924.0': {} 11270 11304 11271 11305 '@cspotcode/source-map-support@0.8.1': 11272 11306 dependencies: ··· 14098 14132 14099 14133 '@types/mock-fs@4.13.4': 14100 14134 dependencies: 14101 - '@types/node': 22.2.0 14135 + '@types/node': 20.14.10 14102 14136 14103 14137 '@types/ms@0.7.34': {} 14104 14138 ··· 20611 20645 - bufferutil 20612 20646 - utf-8-validate 20613 20647 20614 - wrangler@4.38.0(@cloudflare/workers-types@4.20250224.0): 20648 + wrangler@4.38.0(@cloudflare/workers-types@4.20250924.0): 20615 20649 dependencies: 20616 20650 '@cloudflare/kv-asset-handler': 0.4.0 20617 20651 '@cloudflare/unenv-preset': 2.7.4(unenv@2.0.0-rc.21)(workerd@1.20250917.0) ··· 20622 20656 unenv: 2.0.0-rc.21 20623 20657 workerd: 1.20250917.0 20624 20658 optionalDependencies: 20625 - '@cloudflare/workers-types': 4.20250224.0 20659 + '@cloudflare/workers-types': 4.20250924.0 20626 20660 fsevents: 2.3.3 20627 20661 transitivePeerDependencies: 20628 20662 - bufferutil
+1 -1
pnpm-workspace.yaml
··· 8 8 - benchmarking 9 9 10 10 catalog: 11 - "@cloudflare/workers-types": ^4.20250224.0 11 + "@cloudflare/workers-types": ^4.20250917.0 12 12 "@dotenvx/dotenvx": 1.31.0 13 13 "@eslint/js": ^9.11.1 14 14 "@playwright/test": ^1.51.1