diff --git a/.changeset/tame-icons-shave.md b/.changeset/tame-icons-shave.md new file mode 100644 index 00000000..8dfbe787 --- /dev/null +++ b/.changeset/tame-icons-shave.md @@ -0,0 +1,5 @@ +--- +"@opennextjs/cloudflare": patch +--- + +feat: auto-populating d1 cache data 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/e2e/app-router/open-next.config.ts b/examples/e2e/app-router/open-next.config.ts index 00db5428..2f905479 100644 --- a/examples/e2e/app-router/open-next.config.ts +++ b/examples/e2e/app-router/open-next.config.ts @@ -1,10 +1,15 @@ import { defineCloudflareConfig } from "@opennextjs/cloudflare"; import d1TagCache from "@opennextjs/cloudflare/d1-tag-cache"; -import kvIncrementalCache from "@opennextjs/cloudflare/kv-cache"; +// import kvIncrementalCache from "@opennextjs/cloudflare/kv-cache"; +import r2IncrementalCache from "@opennextjs/cloudflare/r2-incremental-cache"; import memoryQueue from "@opennextjs/cloudflare/memory-queue"; +import { withRegionalCache } from "@opennextjs/cloudflare/regional-cache"; export default defineCloudflareConfig({ - incrementalCache: kvIncrementalCache, + incrementalCache: withRegionalCache(r2IncrementalCache, { + mode: "long-lived", + shouldLazilyUpdateOnCacheHit: true, + }), tagCache: d1TagCache, queue: memoryQueue, }); diff --git a/examples/e2e/app-router/package.json b/examples/e2e/app-router/package.json index 3d1037d1..9d26a4c6 100644 --- a/examples/e2e/app-router/package.json +++ b/examples/e2e/app-router/package.json @@ -10,8 +10,7 @@ "lint": "next lint", "clean": "rm -rf .turbo node_modules .next .open-next", "d1:clean": "wrangler d1 execute NEXT_CACHE_D1 --command \"DROP TABLE IF EXISTS tags; DROP TABLE IF EXISTS revalidations\"", - "d1:setup": "wrangler d1 execute NEXT_CACHE_D1 --file .open-next/cloudflare/cache-assets-manifest.sql", - "build:worker": "pnpm opennextjs-cloudflare && pnpm d1:clean && pnpm d1:setup", + "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" }, diff --git a/examples/e2e/app-router/wrangler.jsonc b/examples/e2e/app-router/wrangler.jsonc index 25be2dde..f7de1ca3 100644 --- a/examples/e2e/app-router/wrangler.jsonc +++ b/examples/e2e/app-router/wrangler.jsonc @@ -26,5 +26,12 @@ "binding": "NEXT_CACHE_REVALIDATION_WORKER", "service": "app-router" } + ], + "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 f13a6a5b..3a0328ca 100644 --- a/packages/cloudflare/src/api/cloudflare-context.ts +++ b/packages/cloudflare/src/api/cloudflare-context.ts @@ -7,6 +7,10 @@ declare global { NEXT_CACHE_D1_TAGS_TABLE?: string; NEXT_CACHE_D1_REVALIDATIONS_TABLE?: string; 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; ASSETS?: Fetcher; } } 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..4ab45198 --- /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 confused 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..bf8056b1 --- /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_YEAR_IN_SECONDS = 31536000; +const ONE_MINUTE_IN_SECONDS = 60; + +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 cache entry until it is revalidated. + */ + 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 || ONE_YEAR_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 cache entry until it is revalidated. + * @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/args.ts b/packages/cloudflare/src/cli/args.ts index f64d0d73..5a546ad9 100644 --- a/packages/cloudflare/src/cli/args.ts +++ b/packages/cloudflare/src/cli/args.ts @@ -2,34 +2,46 @@ import { mkdirSync, type Stats, statSync } from "node:fs"; import { resolve } from "node:path"; import { parseArgs } from "node:util"; +import type { CacheBindingMode } from "./build/utils/index.js"; +import { isCacheBindingMode } from "./build/utils/index.js"; + export function getArgs(): { skipNextBuild: boolean; skipWranglerConfigCheck: boolean; outputDir?: string; minify: boolean; + populateCache?: { mode: CacheBindingMode; onlyPopulateWithoutBuilding: boolean }; } { - const { skipBuild, skipWranglerConfigCheck, output, noMinify } = parseArgs({ - options: { - skipBuild: { - type: "boolean", - short: "s", - default: false, - }, - output: { - type: "string", - short: "o", - }, - noMinify: { - type: "boolean", - default: false, + const { skipBuild, skipWranglerConfigCheck, output, noMinify, populateCache, onlyPopulateCache } = + parseArgs({ + options: { + skipBuild: { + type: "boolean", + short: "s", + default: false, + }, + output: { + type: "string", + short: "o", + }, + noMinify: { + type: "boolean", + default: false, + }, + skipWranglerConfigCheck: { + type: "boolean", + default: false, + }, + populateCache: { + type: "string", + }, + onlyPopulateCache: { + type: "boolean", + default: false, + }, }, - skipWranglerConfigCheck: { - type: "boolean", - default: false, - }, - }, - allowPositionals: false, - }).values; + allowPositionals: false, + }).values; const outputDir = output ? resolve(output) : undefined; @@ -37,6 +49,13 @@ export function getArgs(): { assertDirArg(outputDir, "output", true); } + if ( + (populateCache !== undefined || onlyPopulateCache) && + (!populateCache?.length || !isCacheBindingMode(populateCache)) + ) { + throw new Error(`Error: missing mode for populate cache flag, expected 'local' | 'remote'`); + } + return { outputDir, skipNextBuild: skipBuild || ["1", "true", "yes"].includes(String(process.env.SKIP_NEXT_APP_BUILD)), @@ -44,6 +63,9 @@ export function getArgs(): { skipWranglerConfigCheck || ["1", "true", "yes"].includes(String(process.env.SKIP_WRANGLER_CONFIG_CHECK)), minify: !noMinify, + populateCache: populateCache + ? { mode: populateCache, onlyPopulateWithoutBuilding: !!onlyPopulateCache } + : undefined, }; } diff --git a/packages/cloudflare/src/cli/build/build.ts b/packages/cloudflare/src/cli/build/build.ts index 110f563b..c2b8de99 100644 --- a/packages/cloudflare/src/cli/build/build.ts +++ b/packages/cloudflare/src/cli/build/build.ts @@ -20,6 +20,7 @@ import { createOpenNextConfigIfNotExistent, createWranglerConfigIfNotExistent, ensureCloudflareConfig, + populateCache, } from "./utils/index.js"; import { getVersion } from "./utils/version.js"; @@ -62,6 +63,11 @@ export async function build(projectOpts: ProjectOptions): Promise { logger.info(`@opennextjs/cloudflare version: ${cloudflare}`); logger.info(`@opennextjs/aws version: ${aws}`); + if (projectOpts.populateCache?.onlyPopulateWithoutBuilding) { + populateCache(options, config, projectOpts.populateCache.mode); + return; + } + if (projectOpts.skipNextBuild) { logger.warn("Skipping Next.js build"); } else { @@ -103,6 +109,10 @@ export async function build(projectOpts: ProjectOptions): Promise { await createWranglerConfigIfNotExistent(projectOpts); } + if (projectOpts.populateCache) { + populateCache(options, config, projectOpts.populateCache.mode); + } + logger.info("OpenNext build complete."); } diff --git a/packages/cloudflare/src/cli/build/utils/index.ts b/packages/cloudflare/src/cli/build/utils/index.ts index cca97f02..e7fb383b 100644 --- a/packages/cloudflare/src/cli/build/utils/index.ts +++ b/packages/cloudflare/src/cli/build/utils/index.ts @@ -4,3 +4,4 @@ export * from "./ensure-cf-config.js"; export * from "./extract-project-env-vars.js"; export * from "./needs-experimental-react.js"; export * from "./normalize-path.js"; +export * from "./populate-cache.js"; diff --git a/packages/cloudflare/src/cli/build/utils/populate-cache.ts b/packages/cloudflare/src/cli/build/utils/populate-cache.ts new file mode 100644 index 00000000..d886d975 --- /dev/null +++ b/packages/cloudflare/src/cli/build/utils/populate-cache.ts @@ -0,0 +1,127 @@ +import { spawnSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import path from "node:path"; + +import type { BuildOptions } from "@opennextjs/aws/build/helper.js"; +import logger from "@opennextjs/aws/logger.js"; +import type { + IncludedIncrementalCache, + IncludedTagCache, + LazyLoadedOverride, + 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"; + +async function resolveCacheName( + value: + | IncludedIncrementalCache + | IncludedTagCache + | LazyLoadedOverride + | LazyLoadedOverride +) { + return typeof value === "function" ? (await value()).name : value; +} + +function runWrangler( + opts: BuildOptions, + wranglerOpts: { mode: CacheBindingMode; excludeRemoteFlag?: boolean }, + args: string[] +) { + const result = spawnSync( + opts.packager, + [ + "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"], + } + ); + + if (result.status !== 0) { + logger.error("Failed to populate cache"); + process.exit(1); + } +} + +function getCacheAssetPaths(opts: BuildOptions) { + return globSync(path.join(opts.outputDir, "cache/**/*"), { withFileTypes: 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 ?? {}; + + if (!existsSync(opts.outputDir)) { + logger.error("Unable to populate cache: Open Next build not found"); + process.exit(1); + } + + if (!config.dangerous?.disableIncrementalCache && incrementalCache) { + 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) { + const name = await resolveCacheName(tagCache); + switch (name) { + case "d1-tag-cache": { + logger.info("\nPopulating D1 tag cache..."); + + 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: + logger.info("Tag cache does not need populating"); + } + } +} + +export function isCacheBindingMode(v: string | undefined): v is CacheBindingMode { + return !!v && ["local", "remote"].includes(v); +} diff --git a/packages/cloudflare/src/cli/index.ts b/packages/cloudflare/src/cli/index.ts index 2a6c654e..be3c1e76 100644 --- a/packages/cloudflare/src/cli/index.ts +++ b/packages/cloudflare/src/cli/index.ts @@ -6,7 +6,7 @@ import { build } from "./build/build.js"; const nextAppDir = process.cwd(); -const { skipNextBuild, skipWranglerConfigCheck, outputDir, minify } = getArgs(); +const { skipNextBuild, skipWranglerConfigCheck, outputDir, minify, populateCache } = getArgs(); await build({ sourceDir: nextAppDir, @@ -14,4 +14,5 @@ await build({ skipNextBuild, skipWranglerConfigCheck, minify, + populateCache, }); diff --git a/packages/cloudflare/src/cli/project-options.ts b/packages/cloudflare/src/cli/project-options.ts index 0ceb3d96..15b23259 100644 --- a/packages/cloudflare/src/cli/project-options.ts +++ b/packages/cloudflare/src/cli/project-options.ts @@ -1,3 +1,5 @@ +import type { CacheBindingMode } from "./build/utils/index.js"; + export type ProjectOptions = { // Next app root folder sourceDir: string; @@ -9,4 +11,5 @@ export type ProjectOptions = { skipWranglerConfigCheck: boolean; // Whether minification of the worker should be enabled minify: boolean; + populateCache?: { mode: CacheBindingMode; onlyPopulateWithoutBuilding: boolean }; };