diff --git a/.changeset/weak-houses-divide.md b/.changeset/weak-houses-divide.md
new file mode 100644
index 00000000..ec7f9db4
--- /dev/null
+++ b/.changeset/weak-houses-divide.md
@@ -0,0 +1,5 @@
+---
+"@opennextjs/cloudflare": patch
+---
+
+feat: r2 adapter for the incremental cache
diff --git a/examples/common/apps.ts b/examples/common/apps.ts
index 46325bc9..939e395f 100644
--- a/examples/common/apps.ts
+++ b/examples/common/apps.ts
@@ -15,6 +15,7 @@ const apps = [
// overrides
"d1-tag-next",
"memory-queue",
+ "r2-incremental-cache",
// bugs
"gh-119",
"gh-219",
diff --git a/examples/e2e/app-router/open-next.config.ts b/examples/e2e/app-router/open-next.config.ts
index c442f8fc..bab54383 100644
--- a/examples/e2e/app-router/open-next.config.ts
+++ b/examples/e2e/app-router/open-next.config.ts
@@ -1,8 +1,7 @@
import { defineCloudflareConfig } from "@opennextjs/cloudflare";
-import d1TagCache from "@opennextjs/cloudflare/d1-tag-cache";
import kvIncrementalCache from "@opennextjs/cloudflare/kv-cache";
-import doQueue from "@opennextjs/cloudflare/durable-queue";
import shardedTagCache from "@opennextjs/cloudflare/do-sharded-tag-cache";
+import doQueue from "@opennextjs/cloudflare/durable-queue";
export default defineCloudflareConfig({
incrementalCache: kvIncrementalCache,
diff --git a/examples/overrides/r2-incremental-cache/.gitignore b/examples/overrides/r2-incremental-cache/.gitignore
new file mode 100644
index 00000000..3f753f29
--- /dev/null
+++ b/examples/overrides/r2-incremental-cache/.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/r2-incremental-cache/app/favicon.ico b/examples/overrides/r2-incremental-cache/app/favicon.ico
new file mode 100644
index 00000000..718d6fea
Binary files /dev/null and b/examples/overrides/r2-incremental-cache/app/favicon.ico differ
diff --git a/examples/overrides/r2-incremental-cache/app/globals.css b/examples/overrides/r2-incremental-cache/app/globals.css
new file mode 100644
index 00000000..6e6f12f3
--- /dev/null
+++ b/examples/overrides/r2-incremental-cache/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/r2-incremental-cache/app/layout.tsx b/examples/overrides/r2-incremental-cache/app/layout.tsx
new file mode 100644
index 00000000..e878f82a
--- /dev/null
+++ b/examples/overrides/r2-incremental-cache/app/layout.tsx
@@ -0,0 +1,25 @@
+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/r2-incremental-cache/app/page.module.css b/examples/overrides/r2-incremental-cache/app/page.module.css
new file mode 100644
index 00000000..1217984e
--- /dev/null
+++ b/examples/overrides/r2-incremental-cache/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/r2-incremental-cache/app/page.tsx b/examples/overrides/r2-incremental-cache/app/page.tsx
new file mode 100644
index 00000000..de3385c2
--- /dev/null
+++ b/examples/overrides/r2-incremental-cache/app/page.tsx
@@ -0,0 +1,16 @@
+import styles from "./page.module.css";
+
+export const revalidate = 5;
+
+export default async function Home() {
+ // We purposefully wait for 2 seconds to allow deduplication to occur
+ await new Promise((resolve) => setTimeout(resolve, 2000));
+ return (
+
+
+ Hello from a Statically generated page
+ {Date.now()}
+
+
+ );
+}
diff --git a/examples/overrides/r2-incremental-cache/e2e/base.spec.ts b/examples/overrides/r2-incremental-cache/e2e/base.spec.ts
new file mode 100644
index 00000000..2f546e82
--- /dev/null
+++ b/examples/overrides/r2-incremental-cache/e2e/base.spec.ts
@@ -0,0 +1,36 @@
+import { test, expect } from "@playwright/test";
+
+test.describe("r2-incremental-cache", () => {
+ 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 revalidate", async ({ page, request }) => {
+ // We need to make sure the page is loaded and is a HIT
+ // If it is STALE, the next hit may have an updated date and thus fail the test
+ let cacheHeaders = "";
+ do {
+ const req = await request.get("/");
+ cacheHeaders = req.headers()["x-nextjs-cache"];
+ await page.waitForTimeout(500);
+ } while (cacheHeaders !== "HIT");
+
+ await page.goto("/");
+ const firstDate = await page.getByTestId("date-local").textContent();
+
+ await page.reload();
+ let newDate = await page.getByTestId("date-local").textContent();
+ expect(newDate).toBe(firstDate);
+
+ await page.waitForTimeout(5000);
+
+ do {
+ await page.reload();
+ newDate = await page.getByTestId("date-local").textContent();
+ await page.waitForTimeout(1000);
+ } while (newDate === firstDate);
+
+ expect(newDate).not.toBe(firstDate);
+ });
+});
diff --git a/examples/overrides/r2-incremental-cache/e2e/playwright.config.ts b/examples/overrides/r2-incremental-cache/e2e/playwright.config.ts
new file mode 100644
index 00000000..77b2b021
--- /dev/null
+++ b/examples/overrides/r2-incremental-cache/e2e/playwright.config.ts
@@ -0,0 +1,8 @@
+import { configurePlaywright } from "../../../common/config-e2e";
+
+// Here we don't want to run the tests in parallel
+export default configurePlaywright("r2-incremental-cache", {
+ isCI: !!process.env.CI,
+ parallel: false,
+ multipleBrowsers: false,
+});
diff --git a/examples/overrides/r2-incremental-cache/next.config.ts b/examples/overrides/r2-incremental-cache/next.config.ts
new file mode 100644
index 00000000..4b075e44
--- /dev/null
+++ b/examples/overrides/r2-incremental-cache/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/r2-incremental-cache/open-next.config.ts b/examples/overrides/r2-incremental-cache/open-next.config.ts
new file mode 100644
index 00000000..2d73a2dd
--- /dev/null
+++ b/examples/overrides/r2-incremental-cache/open-next.config.ts
@@ -0,0 +1,14 @@
+import { defineCloudflareConfig } from "@opennextjs/cloudflare";
+import d1TagCache from "@opennextjs/cloudflare/d1-tag-cache";
+import memoryQueue from "@opennextjs/cloudflare/memory-queue";
+import r2IncrementalCache from "@opennextjs/cloudflare/r2-incremental-cache";
+import { withRegionalCache } from "@opennextjs/cloudflare/regional-cache";
+
+export default defineCloudflareConfig({
+ incrementalCache: withRegionalCache(r2IncrementalCache, {
+ mode: "long-lived",
+ shouldLazilyUpdateOnCacheHit: true,
+ }),
+ tagCache: d1TagCache,
+ queue: memoryQueue,
+});
diff --git a/examples/overrides/r2-incremental-cache/package.json b/examples/overrides/r2-incremental-cache/package.json
new file mode 100644
index 00000000..8eda7f82
--- /dev/null
+++ b/examples/overrides/r2-incremental-cache/package.json
@@ -0,0 +1,29 @@
+{
+ "name": "r2-incremental-cache",
+ "version": "0.1.0",
+ "private": true,
+ "scripts": {
+ "dev": "next dev",
+ "build": "next build",
+ "start": "next start",
+ "lint": "next lint",
+ "d1:clean": "wrangler d1 execute NEXT_CACHE_D1 --command \"DROP TABLE IF EXISTS tags; DROP TABLE IF EXISTS revalidations\"",
+ "build:worker": "pnpm d1:clean && pnpm opennextjs-cloudflare --populateCache=local",
+ "preview": "pnpm build:worker && pnpm wrangler dev",
+ "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/r2-incremental-cache/tsconfig.json b/examples/overrides/r2-incremental-cache/tsconfig.json
new file mode 100644
index 00000000..d8b93235
--- /dev/null
+++ b/examples/overrides/r2-incremental-cache/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/r2-incremental-cache/wrangler.jsonc b/examples/overrides/r2-incremental-cache/wrangler.jsonc
new file mode 100644
index 00000000..c0e4413e
--- /dev/null
+++ b/examples/overrides/r2-incremental-cache/wrangler.jsonc
@@ -0,0 +1,31 @@
+{
+ "$schema": "node_modules/wrangler/config-schema.json",
+ "main": ".open-next/worker.js",
+ "name": "r2-incremental-cache",
+ "compatibility_date": "2025-02-04",
+ "compatibility_flags": ["nodejs_compat"],
+ "assets": {
+ "directory": ".open-next/assets",
+ "binding": "ASSETS"
+ },
+ "d1_databases": [
+ {
+ "binding": "NEXT_CACHE_D1",
+ "database_id": "NEXT_CACHE_D1",
+ "database_name": "NEXT_CACHE_D1"
+ }
+ ],
+ "services": [
+ {
+ "binding": "NEXT_CACHE_REVALIDATION_WORKER",
+ "service": "r2-incremental-cache"
+ }
+ ],
+ "r2_buckets": [
+ {
+ "binding": "NEXT_CACHE_R2_BUCKET",
+ "bucket_name": "NEXT_CACHE_R2_BUCKET",
+ "preview_bucket_name": "NEXT_CACHE_R2_BUCKET"
+ }
+ ]
+}
diff --git a/packages/cloudflare/src/api/cloudflare-context.ts b/packages/cloudflare/src/api/cloudflare-context.ts
index b95e16fc..b7c34e9c 100644
--- a/packages/cloudflare/src/api/cloudflare-context.ts
+++ b/packages/cloudflare/src/api/cloudflare-context.ts
@@ -15,6 +15,10 @@ declare global {
NEXT_CACHE_D1_REVALIDATIONS_TABLE?: string;
// Service binding for the worker itself to be able to call itself from within the worker
NEXT_CACHE_REVALIDATION_WORKER?: Service;
+ // R2 bucket used for the incremental cache
+ NEXT_CACHE_R2_BUCKET?: R2Bucket;
+ // Prefix used for the R2 incremental cache bucket
+ NEXT_CACHE_R2_PREFIX?: string;
// Durable Object namespace to use for the durable object queue handler
NEXT_CACHE_REVALIDATION_DURABLE_OBJECT?: DurableObjectNamespace;
// Durables object namespace to use for the sharded tag cache
diff --git a/packages/cloudflare/src/api/internal/incremental-cache.ts b/packages/cloudflare/src/api/internal/incremental-cache.ts
new file mode 100644
index 00000000..2407fef8
--- /dev/null
+++ b/packages/cloudflare/src/api/internal/incremental-cache.ts
@@ -0,0 +1,6 @@
+import { CacheValue } from "@opennextjs/aws/types/overrides.js";
+
+export type IncrementalCacheEntry = {
+ value: CacheValue;
+ lastModified: number;
+};
diff --git a/packages/cloudflare/src/api/r2-incremental-cache.ts b/packages/cloudflare/src/api/r2-incremental-cache.ts
new file mode 100644
index 00000000..de216d70
--- /dev/null
+++ b/packages/cloudflare/src/api/r2-incremental-cache.ts
@@ -0,0 +1,80 @@
+import { debug, error } from "@opennextjs/aws/adapters/logger.js";
+import type { CacheValue, IncrementalCache, WithLastModified } from "@opennextjs/aws/types/overrides.js";
+import { IgnorableError } from "@opennextjs/aws/utils/error.js";
+
+import { getCloudflareContext } from "./cloudflare-context.js";
+
+/**
+ * An instance of the Incremental Cache that uses an R2 bucket (`NEXT_CACHE_R2_BUCKET`) as it's
+ * underlying data store.
+ *
+ * The directory that the cache entries are stored in can be configured with the `NEXT_CACHE_R2_PREFIX`
+ * environment variable, and defaults to `incremental-cache`.
+ *
+ * The cache uses an instance of the Cache API (`incremental-cache`) to store a local version of the
+ * R2 cache entry to enable fast retrieval, with the cache being updated from R2 in the background.
+ */
+class R2IncrementalCache implements IncrementalCache {
+ readonly name = "r2-incremental-cache";
+
+ async get(
+ key: string,
+ isFetch?: IsFetch
+ ): Promise> | null> {
+ const r2 = getCloudflareContext().env.NEXT_CACHE_R2_BUCKET;
+ if (!r2) throw new IgnorableError("No R2 bucket");
+
+ debug(`Get ${key}`);
+
+ try {
+ const r2Object = await r2.get(this.getR2Key(key, isFetch));
+ if (!r2Object) return null;
+
+ return {
+ value: await r2Object.json(),
+ lastModified: r2Object.uploaded.getTime(),
+ };
+ } catch (e) {
+ error("Failed to get from cache", e);
+ return null;
+ }
+ }
+
+ async set(
+ key: string,
+ value: CacheValue,
+ isFetch?: IsFetch
+ ): Promise {
+ const r2 = getCloudflareContext().env.NEXT_CACHE_R2_BUCKET;
+ if (!r2) throw new IgnorableError("No R2 bucket");
+
+ debug(`Set ${key}`);
+
+ try {
+ await r2.put(this.getR2Key(key, isFetch), JSON.stringify(value));
+ } catch (e) {
+ error("Failed to set to cache", e);
+ }
+ }
+
+ async delete(key: string): Promise {
+ const r2 = getCloudflareContext().env.NEXT_CACHE_R2_BUCKET;
+ if (!r2) throw new IgnorableError("No R2 bucket");
+
+ debug(`Delete ${key}`);
+
+ try {
+ await r2.delete(this.getR2Key(key));
+ } catch (e) {
+ error("Failed to delete from cache", e);
+ }
+ }
+
+ protected getR2Key(key: string, isFetch?: boolean): string {
+ const directory = getCloudflareContext().env.NEXT_CACHE_R2_PREFIX ?? "incremental-cache";
+
+ return `${directory}/${process.env.NEXT_BUILD_ID ?? "no-build-id"}/${key}.${isFetch ? "fetch" : "cache"}`;
+ }
+}
+
+export default new R2IncrementalCache();
diff --git a/packages/cloudflare/src/api/regional-cache.ts b/packages/cloudflare/src/api/regional-cache.ts
new file mode 100644
index 00000000..f526aa4e
--- /dev/null
+++ b/packages/cloudflare/src/api/regional-cache.ts
@@ -0,0 +1,167 @@
+import { debug, error } from "@opennextjs/aws/adapters/logger.js";
+import { CacheValue, IncrementalCache, WithLastModified } from "@opennextjs/aws/types/overrides.js";
+
+import { getCloudflareContext } from "./cloudflare-context.js";
+import { IncrementalCacheEntry } from "./internal/incremental-cache.js";
+
+const ONE_MINUTE_IN_SECONDS = 60;
+const THIRTY_MINUTES_IN_SECONDS = ONE_MINUTE_IN_SECONDS * 30;
+
+type Options = {
+ /**
+ * The mode to use for the regional cache.
+ *
+ * - `short-lived`: Re-use a cache entry for up to a minute after it has been retrieved.
+ * - `long-lived`: Re-use a fetch cache entry until it is revalidated (per-region), or an ISR/SSG entry for up to 30 minutes.
+ */
+ mode: "short-lived" | "long-lived";
+ /**
+ * Whether the regional cache entry should be updated in the background or not when it experiences
+ * a cache hit.
+ *
+ * Defaults to `false` for the `short-lived` mode, and `true` for the `long-lived` mode.
+ */
+ shouldLazilyUpdateOnCacheHit?: boolean;
+};
+
+class RegionalCache implements IncrementalCache {
+ public name: string;
+
+ protected localCache: Cache | undefined;
+
+ constructor(
+ private store: IncrementalCache,
+ private opts: Options
+ ) {
+ this.name = this.store.name;
+
+ this.opts.shouldLazilyUpdateOnCacheHit ??= this.opts.mode === "long-lived";
+ }
+
+ async get(
+ key: string,
+ isFetch?: IsFetch
+ ): Promise> | null> {
+ try {
+ const cache = await this.getCacheInstance();
+ const localCacheKey = this.getCacheKey(key, isFetch);
+
+ // Check for a cached entry as this will be faster than the store response.
+ const cachedResponse = await cache.match(localCacheKey);
+ if (cachedResponse) {
+ debug("Get - cached response");
+
+ // Re-fetch from the store and update the regional cache in the background
+ if (this.opts.shouldLazilyUpdateOnCacheHit) {
+ getCloudflareContext().ctx.waitUntil(
+ this.store.get(key, isFetch).then(async (rawEntry) => {
+ const { value, lastModified } = rawEntry ?? {};
+
+ if (value && typeof lastModified === "number") {
+ await this.putToCache(localCacheKey, { value, lastModified });
+ }
+ })
+ );
+ }
+
+ return cachedResponse.json();
+ }
+
+ const rawEntry = await this.store.get(key, isFetch);
+ const { value, lastModified } = rawEntry ?? {};
+ if (!value || typeof lastModified !== "number") return null;
+
+ // Update the locale cache after retrieving from the store.
+ getCloudflareContext().ctx.waitUntil(this.putToCache(localCacheKey, { value, lastModified }));
+
+ return { value, lastModified };
+ } catch (e) {
+ error("Failed to get from regional cache", e);
+ return null;
+ }
+ }
+
+ async set(
+ key: string,
+ value: CacheValue,
+ isFetch?: IsFetch
+ ): Promise {
+ try {
+ await this.store.set(key, value, isFetch);
+
+ await this.putToCache(this.getCacheKey(key, isFetch), {
+ value,
+ // Note: `Date.now()` returns the time of the last IO rather than the actual time.
+ // See https://developers.cloudflare.com/workers/reference/security-model/
+ lastModified: Date.now(),
+ });
+ } catch (e) {
+ error(`Failed to get from regional cache`, e);
+ }
+ }
+
+ async delete(key: string): Promise {
+ try {
+ await this.store.delete(key);
+
+ const cache = await this.getCacheInstance();
+ await cache.delete(this.getCacheKey(key));
+ } catch (e) {
+ error("Failed to delete from regional cache", e);
+ }
+ }
+
+ protected async getCacheInstance(): Promise {
+ if (this.localCache) return this.localCache;
+
+ this.localCache = await caches.open("incremental-cache");
+ return this.localCache;
+ }
+
+ protected getCacheKey(key: string, isFetch?: boolean) {
+ return new Request(
+ new URL(
+ `${process.env.NEXT_BUILD_ID ?? "no-build-id"}/${key}.${isFetch ? "fetch" : "cache"}`,
+ "http://cache.local"
+ )
+ );
+ }
+
+ protected async putToCache(key: Request, entry: IncrementalCacheEntry): Promise {
+ const cache = await this.getCacheInstance();
+
+ const age =
+ this.opts.mode === "short-lived"
+ ? ONE_MINUTE_IN_SECONDS
+ : entry.value.revalidate || THIRTY_MINUTES_IN_SECONDS;
+
+ await cache.put(
+ key,
+ new Response(JSON.stringify(entry), {
+ headers: new Headers({ "cache-control": `max-age=${age}` }),
+ })
+ );
+ }
+}
+
+/**
+ * A regional cache will wrap an incremental cache and provide faster cache lookups for an entry
+ * when making requests within the region.
+ *
+ * The regional cache uses the Cache API.
+ *
+ * **WARNING:** If an entry is revalidated in one region, it will trigger an additional revalidation if
+ * a request is made to another region that has an entry stored in its regional cache.
+ *
+ * @param cache - Incremental cache instance.
+ * @param opts.mode - The mode to use for the regional cache.
+ * - `short-lived`: Re-use a cache entry for up to a minute after it has been retrieved.
+ * - `long-lived`: Re-use a fetch cache entry until it is revalidated (per-region), or an ISR/SSG entry for up to 30 minutes.
+ * @param opts.shouldLazilyUpdateOnCacheHit - Whether the regional cache entry should be updated in
+ * the background or not when it experiences a cache hit.
+ *
+ * Defaults to `false` for the `short-lived` mode, and `true` for the `long-lived` mode.
+ */
+export function withRegionalCache(cache: IncrementalCache, opts: Options) {
+ return new RegionalCache(cache, opts);
+}
diff --git a/packages/cloudflare/src/cli/build/utils/populate-cache.ts b/packages/cloudflare/src/cli/build/utils/populate-cache.ts
index ad38ede7..cc6ea02e 100644
--- a/packages/cloudflare/src/cli/build/utils/populate-cache.ts
+++ b/packages/cloudflare/src/cli/build/utils/populate-cache.ts
@@ -11,6 +11,7 @@ import type {
OpenNextConfig,
} from "@opennextjs/aws/types/open-next.js";
import type { IncrementalCache, TagCache } from "@opennextjs/aws/types/overrides.js";
+import { globSync } from "glob";
export type CacheBindingMode = "local" | "remote";
@@ -24,10 +25,20 @@ async function resolveCacheName(
return typeof value === "function" ? (await value()).name : value;
}
-function runWrangler(opts: BuildOptions, mode: CacheBindingMode, args: string[]) {
+function runWrangler(
+ opts: BuildOptions,
+ wranglerOpts: { mode: CacheBindingMode; excludeRemoteFlag?: boolean },
+ args: string[]
+) {
const result = spawnSync(
opts.packager,
- ["exec", "wrangler", ...args, mode === "remote" && "--remote"].filter((v): v is string => !!v),
+ [
+ "exec",
+ "wrangler",
+ ...args,
+ wranglerOpts.mode === "remote" && !wranglerOpts.excludeRemoteFlag && "--remote",
+ wranglerOpts.mode === "local" && "--local",
+ ].filter((v): v is string => !!v),
{
shell: true,
stdio: ["ignore", "ignore", "inherit"],
@@ -37,11 +48,27 @@ function runWrangler(opts: BuildOptions, mode: CacheBindingMode, args: string[])
if (result.status !== 0) {
logger.error("Failed to populate cache");
process.exit(1);
- } else {
- logger.info("Successfully populated cache");
}
}
+function getCacheAssetPaths(opts: BuildOptions) {
+ return globSync(path.join(opts.outputDir, "cache/**/*"), {
+ withFileTypes: true,
+ windowsPathsNoEscape: true,
+ })
+ .filter((f) => f.isFile())
+ .map((f) => {
+ const relativePath = path.relative(path.join(opts.outputDir, "cache"), f.fullpathPosix());
+
+ return {
+ fsPath: f.fullpathPosix(),
+ destPath: relativePath.startsWith("__fetch")
+ ? `${relativePath.replace("__fetch/", "")}.fetch`
+ : relativePath,
+ };
+ });
+}
+
export async function populateCache(opts: BuildOptions, config: OpenNextConfig, mode: CacheBindingMode) {
const { incrementalCache, tagCache } = config.default.override ?? {};
@@ -51,7 +78,31 @@ export async function populateCache(opts: BuildOptions, config: OpenNextConfig,
}
if (!config.dangerous?.disableIncrementalCache && incrementalCache) {
- logger.info("Incremental cache does not need populating");
+ const name = await resolveCacheName(incrementalCache);
+ switch (name) {
+ case "r2-incremental-cache": {
+ logger.info("\nPopulating R2 incremental cache...");
+
+ const assets = getCacheAssetPaths(opts);
+ assets.forEach(({ fsPath, destPath }) => {
+ const fullDestPath = path.join(
+ "NEXT_CACHE_R2_BUCKET",
+ process.env.NEXT_CACHE_R2_PREFIX ?? "incremental-cache",
+ destPath
+ );
+
+ runWrangler(opts, { mode, excludeRemoteFlag: true }, [
+ "r2 object put",
+ JSON.stringify(fullDestPath),
+ `--file ${JSON.stringify(fsPath)}`,
+ ]);
+ });
+ logger.info(`Successfully populated cache with ${assets.length} assets`);
+ break;
+ }
+ default:
+ logger.info("Incremental cache does not need populating");
+ }
}
if (!config.dangerous?.disableTagCache && !config.dangerous?.disableIncrementalCache && tagCache) {
@@ -60,11 +111,12 @@ export async function populateCache(opts: BuildOptions, config: OpenNextConfig,
case "d1-tag-cache": {
logger.info("\nPopulating D1 tag cache...");
- runWrangler(opts, mode, [
+ runWrangler(opts, { mode }, [
"d1 execute",
"NEXT_CACHE_D1",
`--file ${JSON.stringify(path.join(opts.outputDir, "cloudflare/cache-assets-manifest.sql"))}`,
]);
+ logger.info("Successfully populated cache");
break;
}
default:
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 5b14f927..72d00f86 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -751,6 +751,40 @@ importers:
specifier: 'catalog:'
version: 3.114.1(@cloudflare/workers-types@4.20250224.0)
+ examples/overrides/r2-incremental-cache:
+ dependencies:
+ next:
+ specifier: catalog:e2e
+ version: 15.2.2(@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: 3.114.1(@cloudflare/workers-types@4.20250224.0)
+
examples/playground14:
dependencies:
next:
@@ -16214,7 +16248,7 @@ snapshots:
debug: 4.3.6
enhanced-resolve: 5.17.1
eslint: 8.57.1
- eslint-module-utils: 2.11.0(@typescript-eslint/parser@8.7.0(eslint@8.57.1)(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@8.7.0(eslint@8.57.1)(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.30.0)(eslint@8.57.1))(eslint@8.57.1)
+ eslint-module-utils: 2.11.0(@typescript-eslint/parser@8.7.0(eslint@8.57.1)(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3)(eslint@8.57.1)
fast-glob: 3.3.2
get-tsconfig: 4.8.0
is-bun-module: 1.2.1
@@ -16233,7 +16267,7 @@ snapshots:
debug: 4.3.6
enhanced-resolve: 5.17.1
eslint: 8.57.1
- eslint-module-utils: 2.11.0(@typescript-eslint/parser@8.7.0(eslint@8.57.1)(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@8.7.0(eslint@8.57.1)(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0)(eslint@8.57.1))(eslint@8.57.1)
+ eslint-module-utils: 2.11.0(@typescript-eslint/parser@8.7.0(eslint@8.57.1)(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3)(eslint@8.57.1)
fast-glob: 3.3.2
get-tsconfig: 4.8.0
is-bun-module: 1.2.1
@@ -16252,7 +16286,7 @@ snapshots:
debug: 4.3.6
enhanced-resolve: 5.17.1
eslint: 9.11.1(jiti@1.21.6)
- eslint-module-utils: 2.11.0(@typescript-eslint/parser@8.7.0(eslint@9.11.1(jiti@1.21.6))(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@8.7.0(eslint@9.11.1(jiti@1.21.6))(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0)(eslint@9.11.1(jiti@1.21.6)))(eslint@9.11.1(jiti@1.21.6))
+ eslint-module-utils: 2.11.0(@typescript-eslint/parser@8.7.0(eslint@9.11.1(jiti@1.21.6))(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3)(eslint@9.11.1(jiti@1.21.6))
fast-glob: 3.3.2
get-tsconfig: 4.8.0
is-bun-module: 1.2.1
@@ -16271,7 +16305,7 @@ snapshots:
debug: 4.3.6
enhanced-resolve: 5.17.1
eslint: 9.19.0(jiti@1.21.6)
- eslint-module-utils: 2.11.0(@typescript-eslint/parser@8.7.0(eslint@9.19.0(jiti@1.21.6))(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@8.7.0(eslint@9.19.0(jiti@1.21.6))(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0)(eslint@9.19.0(jiti@1.21.6)))(eslint@9.19.0(jiti@1.21.6))
+ eslint-module-utils: 2.11.0(@typescript-eslint/parser@8.7.0(eslint@9.19.0(jiti@1.21.6))(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3)(eslint@9.19.0(jiti@1.21.6))
fast-glob: 3.3.2
get-tsconfig: 4.8.0
is-bun-module: 1.2.1
@@ -16284,18 +16318,7 @@ snapshots:
- eslint-import-resolver-webpack
- supports-color
- eslint-module-utils@2.11.0(@typescript-eslint/parser@8.7.0(eslint@8.57.1)(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@8.7.0(eslint@8.57.1)(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.30.0)(eslint@8.57.1))(eslint@8.57.1):
- dependencies:
- debug: 3.2.7
- optionalDependencies:
- '@typescript-eslint/parser': 8.7.0(eslint@8.57.1)(typescript@5.7.3)
- eslint: 8.57.1
- eslint-import-resolver-node: 0.3.9
- eslint-import-resolver-typescript: 3.6.3(@typescript-eslint/parser@8.7.0(eslint@8.57.1)(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.30.0)(eslint@8.57.1)
- transitivePeerDependencies:
- - supports-color
-
- eslint-module-utils@2.11.0(@typescript-eslint/parser@8.7.0(eslint@8.57.1)(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@8.7.0(eslint@8.57.1)(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0)(eslint@8.57.1))(eslint@8.57.1):
+ eslint-module-utils@2.11.0(@typescript-eslint/parser@8.7.0(eslint@8.57.1)(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3)(eslint@8.57.1):
dependencies:
debug: 3.2.7
optionalDependencies:
@@ -16306,7 +16329,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
- eslint-module-utils@2.11.0(@typescript-eslint/parser@8.7.0(eslint@9.11.1(jiti@1.21.6))(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@8.7.0(eslint@9.11.1(jiti@1.21.6))(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0)(eslint@9.11.1(jiti@1.21.6)))(eslint@9.11.1(jiti@1.21.6)):
+ eslint-module-utils@2.11.0(@typescript-eslint/parser@8.7.0(eslint@9.11.1(jiti@1.21.6))(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3)(eslint@9.11.1(jiti@1.21.6)):
dependencies:
debug: 3.2.7
optionalDependencies:
@@ -16317,7 +16340,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
- eslint-module-utils@2.11.0(@typescript-eslint/parser@8.7.0(eslint@9.19.0(jiti@1.21.6))(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@8.7.0(eslint@9.19.0(jiti@1.21.6))(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0)(eslint@9.19.0(jiti@1.21.6)))(eslint@9.19.0(jiti@1.21.6)):
+ eslint-module-utils@2.11.0(@typescript-eslint/parser@8.7.0(eslint@9.19.0(jiti@1.21.6))(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3)(eslint@9.19.0(jiti@1.21.6)):
dependencies:
debug: 3.2.7
optionalDependencies:
@@ -16328,7 +16351,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
- eslint-module-utils@2.12.0(@typescript-eslint/parser@8.7.0(eslint@8.57.1)(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@8.7.0(eslint@8.57.1)(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0)(eslint@8.57.1))(eslint@8.57.1):
+ eslint-module-utils@2.12.0(@typescript-eslint/parser@8.7.0(eslint@8.57.1)(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3)(eslint@8.57.1):
dependencies:
debug: 3.2.7
optionalDependencies:
@@ -16339,7 +16362,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
- eslint-module-utils@2.12.0(@typescript-eslint/parser@8.7.0(eslint@9.11.1(jiti@1.21.6))(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@8.7.0(eslint@9.11.1(jiti@1.21.6))(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0)(eslint@9.11.1(jiti@1.21.6)))(eslint@9.11.1(jiti@1.21.6)):
+ eslint-module-utils@2.12.0(@typescript-eslint/parser@8.7.0(eslint@9.11.1(jiti@1.21.6))(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3)(eslint@9.11.1(jiti@1.21.6)):
dependencies:
debug: 3.2.7
optionalDependencies:
@@ -16350,7 +16373,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
- eslint-module-utils@2.12.0(@typescript-eslint/parser@8.7.0(eslint@9.19.0(jiti@1.21.6))(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@8.7.0(eslint@9.19.0(jiti@1.21.6))(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0)(eslint@9.19.0(jiti@1.21.6)))(eslint@9.19.0(jiti@1.21.6)):
+ eslint-module-utils@2.12.0(@typescript-eslint/parser@8.7.0(eslint@9.19.0(jiti@1.21.6))(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3)(eslint@9.19.0(jiti@1.21.6)):
dependencies:
debug: 3.2.7
optionalDependencies:
@@ -16372,7 +16395,7 @@ snapshots:
doctrine: 2.1.0
eslint: 8.57.1
eslint-import-resolver-node: 0.3.9
- eslint-module-utils: 2.11.0(@typescript-eslint/parser@8.7.0(eslint@8.57.1)(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@8.7.0(eslint@8.57.1)(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.30.0)(eslint@8.57.1))(eslint@8.57.1)
+ eslint-module-utils: 2.11.0(@typescript-eslint/parser@8.7.0(eslint@8.57.1)(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3)(eslint@8.57.1)
hasown: 2.0.2
is-core-module: 2.15.1
is-glob: 4.0.3
@@ -16400,7 +16423,7 @@ snapshots:
doctrine: 2.1.0
eslint: 8.57.1
eslint-import-resolver-node: 0.3.9
- eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.7.0(eslint@8.57.1)(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@8.7.0(eslint@8.57.1)(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0)(eslint@8.57.1))(eslint@8.57.1)
+ eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.7.0(eslint@8.57.1)(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3)(eslint@8.57.1)
hasown: 2.0.2
is-core-module: 2.15.1
is-glob: 4.0.3
@@ -16429,7 +16452,7 @@ snapshots:
doctrine: 2.1.0
eslint: 9.11.1(jiti@1.21.6)
eslint-import-resolver-node: 0.3.9
- eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.7.0(eslint@9.11.1(jiti@1.21.6))(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@8.7.0(eslint@9.11.1(jiti@1.21.6))(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0)(eslint@9.11.1(jiti@1.21.6)))(eslint@9.11.1(jiti@1.21.6))
+ eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.7.0(eslint@9.11.1(jiti@1.21.6))(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3)(eslint@9.11.1(jiti@1.21.6))
hasown: 2.0.2
is-core-module: 2.15.1
is-glob: 4.0.3
@@ -16458,7 +16481,7 @@ snapshots:
doctrine: 2.1.0
eslint: 9.19.0(jiti@1.21.6)
eslint-import-resolver-node: 0.3.9
- eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.7.0(eslint@9.19.0(jiti@1.21.6))(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@8.7.0(eslint@9.19.0(jiti@1.21.6))(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0)(eslint@9.19.0(jiti@1.21.6)))(eslint@9.19.0(jiti@1.21.6))
+ eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.7.0(eslint@9.19.0(jiti@1.21.6))(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3)(eslint@9.19.0(jiti@1.21.6))
hasown: 2.0.2
is-core-module: 2.15.1
is-glob: 4.0.3