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