diff --git a/.changeset/metal-lemons-sparkle.md b/.changeset/metal-lemons-sparkle.md new file mode 100644 index 00000000..bc5aaddf --- /dev/null +++ b/.changeset/metal-lemons-sparkle.md @@ -0,0 +1,5 @@ +--- +"@opennextjs/cloudflare": patch +--- + +feat: populate kv incremental cache diff --git a/examples/ssg-app/wrangler.jsonc b/examples/ssg-app/wrangler.jsonc index bb008505..0b9c532a 100644 --- a/examples/ssg-app/wrangler.jsonc +++ b/examples/ssg-app/wrangler.jsonc @@ -8,6 +8,12 @@ "directory": ".open-next/assets", "binding": "ASSETS" }, + "kv_namespaces": [ + { + "binding": "NEXT_INC_CACHE_KV", + "id": "" + } + ], "vars": { "APP_VERSION": "1.2.345" } diff --git a/packages/cloudflare/src/api/overrides/incremental-cache/internal.ts b/packages/cloudflare/src/api/overrides/incremental-cache/internal.ts deleted file mode 100644 index 2407fef8..00000000 --- a/packages/cloudflare/src/api/overrides/incremental-cache/internal.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { CacheValue } from "@opennextjs/aws/types/overrides.js"; - -export type IncrementalCacheEntry = { - value: CacheValue; - lastModified: number; -}; diff --git a/packages/cloudflare/src/api/overrides/incremental-cache/kv-incremental-cache.ts b/packages/cloudflare/src/api/overrides/incremental-cache/kv-incremental-cache.ts index 1335eeda..d3140e5b 100644 --- a/packages/cloudflare/src/api/overrides/incremental-cache/kv-incremental-cache.ts +++ b/packages/cloudflare/src/api/overrides/incremental-cache/kv-incremental-cache.ts @@ -1,16 +1,16 @@ -import type { CacheValue, IncrementalCache, WithLastModified } from "@opennextjs/aws/types/overrides"; -import { IgnorableError, RecoverableError } from "@opennextjs/aws/utils/error.js"; +import { 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"; - -export const CACHE_ASSET_DIR = "cdn-cgi/_next_cache"; - -export const STATUS_DELETED = 1; +import { debugCache, FALLBACK_BUILD_ID, IncrementalCacheEntry } from "../internal.js"; export const NAME = "cf-kv-incremental-cache"; +export const BINDING_NAME = "NEXT_INC_CACHE_KV"; + /** - * Open Next cache based on cloudflare KV and Assets. + * Open Next cache based on Cloudflare KV. * * Note: The class is instantiated outside of the request context. * The cloudflare context and process.env are not initialized yet @@ -23,69 +23,32 @@ class KVIncrementalCache implements IncrementalCache { key: string, isFetch?: IsFetch ): Promise> | null> { - const cfEnv = getCloudflareContext().env; - const kv = cfEnv.NEXT_INC_CACHE_KV; - const assets = cfEnv.ASSETS; - - if (!(kv || assets)) { - throw new IgnorableError(`No KVNamespace nor Fetcher`); - } + const kv = getCloudflareContext().env[BINDING_NAME]; + if (!kv) throw new IgnorableError("No KV Namespace"); - this.debug(`Get ${key}`); + debugCache(`Get ${key}`); try { - let entry: { - value?: CacheValue; - lastModified?: number; - status?: number; - } | null = null; - - if (kv) { - this.debug(`- From KV`); - const kvKey = this.getKVKey(key, isFetch); - entry = await kv.get(kvKey, "json"); - if (entry?.status === STATUS_DELETED) { - return null; - } - } + const entry = await kv.get | CacheValue>( + this.getKVKey(key, isFetch), + "json" + ); + + if (!entry) return null; - if (!entry && assets) { - this.debug(`- From Assets`); - const url = this.getAssetUrl(key, isFetch); - const response = await assets.fetch(url); - if (response.ok) { - // TODO: consider populating KV with the asset value if faster. - // This could be optional as KV writes are $$. - // See https://github.com/opennextjs/opennextjs-cloudflare/pull/194#discussion_r1893166026 - entry = { - value: await response.json(), - // __BUILD_TIMESTAMP_MS__ is injected by ESBuild. - lastModified: (globalThis as { __BUILD_TIMESTAMP_MS__?: number }).__BUILD_TIMESTAMP_MS__, - }; - } - if (!kv) { - // The cache can not be updated when there is no KV - // As we don't want to keep serving stale data for ever, - // we pretend the entry is not in cache - if ( - entry?.value && - "kind" in entry.value && - entry.value.kind === "FETCH" && - entry.value.data?.headers?.expires - ) { - const expiresTime = new Date(entry.value.data.headers.expires).getTime(); - if (!isNaN(expiresTime) && expiresTime <= Date.now()) { - this.debug(`found expired entry (expire time: ${entry.value.data.headers.expires})`); - return null; - } - } - } + if ("lastModified" in entry) { + return entry; } - this.debug(entry ? `-> hit` : `-> miss`); - return { value: entry?.value, lastModified: entry?.lastModified }; - } catch { - throw new RecoverableError(`Failed to get cache [${key}]`); + // if there is no lastModified property, the file was stored during build-time cache population. + return { + value: entry, + // __BUILD_TIMESTAMP_MS__ is injected by ESBuild. + lastModified: (globalThis as { __BUILD_TIMESTAMP_MS__?: number }).__BUILD_TIMESTAMP_MS__, + }; + } catch (e) { + error("Failed to get from cache", e); + return null; } } @@ -94,69 +57,44 @@ class KVIncrementalCache implements IncrementalCache { value: CacheValue, isFetch?: IsFetch ): Promise { - const kv = getCloudflareContext().env.NEXT_INC_CACHE_KV; - - if (!kv) { - throw new IgnorableError(`No KVNamespace`); - } + const kv = getCloudflareContext().env[BINDING_NAME]; + if (!kv) throw new IgnorableError("No KV Namespace"); - this.debug(`Set ${key}`); + debugCache(`Set ${key}`); try { - const kvKey = this.getKVKey(key, isFetch); - // Note: We can not set a TTL as we might fallback to assets, - // still removing old data (old BUILD_ID) could help avoiding - // the cache growing too big. await kv.put( - kvKey, + this.getKVKey(key, isFetch), JSON.stringify({ 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(), }) + // TODO: Figure out how to best leverage KV's TTL. + // NOTE: Ideally, the cache should operate in an SWR-like manner. ); - } catch { - throw new RecoverableError(`Failed to set cache [${key}]`); + } catch (e) { + error("Failed to set to cache", e); } } async delete(key: string): Promise { - const kv = getCloudflareContext().env.NEXT_INC_CACHE_KV; + const kv = getCloudflareContext().env[BINDING_NAME]; + if (!kv) throw new IgnorableError("No KV Namespace"); - if (!kv) { - throw new IgnorableError(`No KVNamespace`); - } - - this.debug(`Delete ${key}`); + debugCache(`Delete ${key}`); try { - const kvKey = this.getKVKey(key, /* isFetch= */ false); - // Do not delete the key as we would then fallback to the assets. - await kv.put(kvKey, JSON.stringify({ status: STATUS_DELETED })); - } catch { - throw new RecoverableError(`Failed to delete cache [${key}]`); + await kv.delete(this.getKVKey(key, /* isFetch= */ false)); + } catch (e) { + error("Failed to delete from cache", e); } } protected getKVKey(key: string, isFetch?: boolean): string { - return `${this.getBuildId()}/${key}.${isFetch ? "fetch" : "cache"}`.replace(/\/+/g, "/"); - } - - protected getAssetUrl(key: string, isFetch?: boolean): string { - return isFetch - ? `http://assets.local/${CACHE_ASSET_DIR}/__fetch/${this.getBuildId()}/${key}` - : `http://assets.local/${CACHE_ASSET_DIR}/${this.getBuildId()}/${key}.cache`; - } - - protected debug(...args: unknown[]) { - if (process.env.NEXT_PRIVATE_DEBUG_CACHE) { - console.log(`[Cache ${this.name}] `, ...args); - } - } - - protected getBuildId() { - return process.env.NEXT_BUILD_ID ?? "no-build-id"; + const buildId = process.env.NEXT_BUILD_ID ?? FALLBACK_BUILD_ID; + return `${buildId}/${key}.${isFetch ? "fetch" : "cache"}`.replace(/\/+/g, "/"); } } diff --git a/packages/cloudflare/src/api/overrides/incremental-cache/r2-incremental-cache.ts b/packages/cloudflare/src/api/overrides/incremental-cache/r2-incremental-cache.ts index 725321a4..2fdd32b9 100644 --- a/packages/cloudflare/src/api/overrides/incremental-cache/r2-incremental-cache.ts +++ b/packages/cloudflare/src/api/overrides/incremental-cache/r2-incremental-cache.ts @@ -1,11 +1,17 @@ -import { debug, error } from "@opennextjs/aws/adapters/logger.js"; +import { 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"; +import { debugCache, FALLBACK_BUILD_ID } from "../internal.js"; export const NAME = "cf-r2-incremental-cache"; +export const BINDING_NAME = "NEXT_INC_CACHE_R2_BUCKET"; + +export const PREFIX_ENV_NAME = "NEXT_INC_CACHE_R2_PREFIX"; +export const DEFAULT_PREFIX = "incremental-cache"; + /** * An instance of the Incremental Cache that uses an R2 bucket (`NEXT_INC_CACHE_R2_BUCKET`) as it's * underlying data store. @@ -20,10 +26,10 @@ class R2IncrementalCache implements IncrementalCache { key: string, isFetch?: IsFetch ): Promise> | null> { - const r2 = getCloudflareContext().env.NEXT_INC_CACHE_R2_BUCKET; + const r2 = getCloudflareContext().env[BINDING_NAME]; if (!r2) throw new IgnorableError("No R2 bucket"); - debug(`Get ${key}`); + debugCache(`Get ${key}`); try { const r2Object = await r2.get(this.getR2Key(key, isFetch)); @@ -44,10 +50,10 @@ class R2IncrementalCache implements IncrementalCache { value: CacheValue, isFetch?: IsFetch ): Promise { - const r2 = getCloudflareContext().env.NEXT_INC_CACHE_R2_BUCKET; + const r2 = getCloudflareContext().env[BINDING_NAME]; if (!r2) throw new IgnorableError("No R2 bucket"); - debug(`Set ${key}`); + debugCache(`Set ${key}`); try { await r2.put(this.getR2Key(key, isFetch), JSON.stringify(value)); @@ -57,10 +63,10 @@ class R2IncrementalCache implements IncrementalCache { } async delete(key: string): Promise { - const r2 = getCloudflareContext().env.NEXT_INC_CACHE_R2_BUCKET; + const r2 = getCloudflareContext().env[BINDING_NAME]; if (!r2) throw new IgnorableError("No R2 bucket"); - debug(`Delete ${key}`); + debugCache(`Delete ${key}`); try { await r2.delete(this.getR2Key(key)); @@ -70,9 +76,9 @@ class R2IncrementalCache implements IncrementalCache { } protected getR2Key(key: string, isFetch?: boolean): string { - const directory = getCloudflareContext().env.NEXT_INC_CACHE_R2_PREFIX ?? "incremental-cache"; + const directory = getCloudflareContext().env[PREFIX_ENV_NAME] ?? DEFAULT_PREFIX; - return `${directory}/${process.env.NEXT_BUILD_ID ?? "no-build-id"}/${key}.${isFetch ? "fetch" : "cache"}`.replace( + return `${directory}/${process.env.NEXT_BUILD_ID ?? FALLBACK_BUILD_ID}/${key}.${isFetch ? "fetch" : "cache"}`.replace( /\/+/g, "/" ); diff --git a/packages/cloudflare/src/api/overrides/incremental-cache/regional-cache.ts b/packages/cloudflare/src/api/overrides/incremental-cache/regional-cache.ts index 5c611c96..8c801413 100644 --- a/packages/cloudflare/src/api/overrides/incremental-cache/regional-cache.ts +++ b/packages/cloudflare/src/api/overrides/incremental-cache/regional-cache.ts @@ -1,8 +1,8 @@ -import { debug, error } from "@opennextjs/aws/adapters/logger.js"; +import { 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.js"; +import { debugCache, FALLBACK_BUILD_ID, IncrementalCacheEntry } from "../internal.js"; import { NAME as KV_CACHE_NAME } from "./kv-incremental-cache.js"; const ONE_MINUTE_IN_SECONDS = 60; @@ -57,7 +57,7 @@ class RegionalCache implements IncrementalCache { // 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"); + debugCache("Get - cached response"); // Re-fetch from the store and update the regional cache in the background if (this.opts.shouldLazilyUpdateOnCacheHit) { @@ -129,7 +129,7 @@ class RegionalCache implements IncrementalCache { protected getCacheKey(key: string, isFetch?: boolean) { return new Request( new URL( - `${process.env.NEXT_BUILD_ID ?? "no-build-id"}/${key}.${isFetch ? "fetch" : "cache"}`, + `${process.env.NEXT_BUILD_ID ?? FALLBACK_BUILD_ID}/${key}.${isFetch ? "fetch" : "cache"}`, "http://cache.local" ) ); diff --git a/packages/cloudflare/src/api/overrides/internal.ts b/packages/cloudflare/src/api/overrides/internal.ts new file mode 100644 index 00000000..7dd8c523 --- /dev/null +++ b/packages/cloudflare/src/api/overrides/internal.ts @@ -0,0 +1,14 @@ +import { CacheValue } from "@opennextjs/aws/types/overrides.js"; + +export type IncrementalCacheEntry = { + value: CacheValue; + lastModified: number; +}; + +export const debugCache = (name: string, ...args: unknown[]) => { + if (process.env.NEXT_PRIVATE_DEBUG_CACHE) { + console.log(`[${name}] `, ...args); + } +}; + +export const FALLBACK_BUILD_ID = "no-build-id"; diff --git a/packages/cloudflare/src/api/overrides/queue/memory-queue.ts b/packages/cloudflare/src/api/overrides/queue/memory-queue.ts index 60de7599..d8e4a7ce 100644 --- a/packages/cloudflare/src/api/overrides/queue/memory-queue.ts +++ b/packages/cloudflare/src/api/overrides/queue/memory-queue.ts @@ -1,8 +1,9 @@ -import { debug, error } from "@opennextjs/aws/adapters/logger.js"; +import { error } from "@opennextjs/aws/adapters/logger.js"; import type { Queue, QueueMessage } from "@opennextjs/aws/types/overrides.js"; import { IgnorableError } from "@opennextjs/aws/utils/error.js"; import { getCloudflareContext } from "../../cloudflare-context"; +import { debugCache } from "../internal"; export const DEFAULT_REVALIDATION_TIMEOUT_MS = 10_000; @@ -48,7 +49,7 @@ export class MemoryQueue implements Queue { if (response.status !== 200 || response.headers.get("x-nextjs-cache") !== "REVALIDATED") { error(`Revalidation failed for ${url} with status ${response.status}`); } - debug(`Revalidation successful for ${url}`); + debugCache(`Revalidation successful for ${url}`); } catch (e) { error(e); } finally { diff --git a/packages/cloudflare/src/api/overrides/tag-cache/d1-next-tag-cache.ts b/packages/cloudflare/src/api/overrides/tag-cache/d1-next-tag-cache.ts index 1503fcdd..2590a0fd 100644 --- a/packages/cloudflare/src/api/overrides/tag-cache/d1-next-tag-cache.ts +++ b/packages/cloudflare/src/api/overrides/tag-cache/d1-next-tag-cache.ts @@ -1,12 +1,15 @@ -import { debug, error } from "@opennextjs/aws/adapters/logger.js"; +import { error } from "@opennextjs/aws/adapters/logger.js"; import type { OpenNextConfig } from "@opennextjs/aws/types/open-next.js"; import type { NextModeTagCache } from "@opennextjs/aws/types/overrides.js"; import { RecoverableError } from "@opennextjs/aws/utils/error.js"; import { getCloudflareContext } from "../../cloudflare-context.js"; +import { debugCache, FALLBACK_BUILD_ID } from "../internal.js"; export const NAME = "d1-next-mode-tag-cache"; +export const BINDING_NAME = "NEXT_TAG_CACHE_D1"; + export class D1NextModeTagCache implements NextModeTagCache { readonly mode = "nextMode" as const; readonly name = NAME; @@ -45,10 +48,9 @@ export class D1NextModeTagCache implements NextModeTagCache { } private getConfig() { - const cfEnv = getCloudflareContext().env; - const db = cfEnv.NEXT_TAG_CACHE_D1; + const db = getCloudflareContext().env[BINDING_NAME]; - if (!db) debug("No D1 database found"); + if (!db) debugCache("No D1 database found"); const isDisabled = !!(globalThis as unknown as { openNextConfig: OpenNextConfig }).openNextConfig .dangerous?.disableTagCache; @@ -70,7 +72,7 @@ export class D1NextModeTagCache implements NextModeTagCache { } protected getBuildId() { - return process.env.NEXT_BUILD_ID ?? "no-build-id"; + return process.env.NEXT_BUILD_ID ?? FALLBACK_BUILD_ID; } } diff --git a/packages/cloudflare/src/api/overrides/tag-cache/do-sharded-tag-cache.ts b/packages/cloudflare/src/api/overrides/tag-cache/do-sharded-tag-cache.ts index 4f0edf5a..d0cd2e9c 100644 --- a/packages/cloudflare/src/api/overrides/tag-cache/do-sharded-tag-cache.ts +++ b/packages/cloudflare/src/api/overrides/tag-cache/do-sharded-tag-cache.ts @@ -1,10 +1,11 @@ -import { debug, error } from "@opennextjs/aws/adapters/logger.js"; +import { error } from "@opennextjs/aws/adapters/logger.js"; import { generateShardId } from "@opennextjs/aws/core/routing/queue.js"; import type { OpenNextConfig } from "@opennextjs/aws/types/open-next"; import type { NextModeTagCache } from "@opennextjs/aws/types/overrides.js"; import { IgnorableError } from "@opennextjs/aws/utils/error.js"; import { getCloudflareContext } from "../../cloudflare-context"; +import { debugCache } from "../internal"; export const DEFAULT_WRITE_RETRIES = 3; export const DEFAULT_NUM_SHARDS = 4; @@ -196,7 +197,7 @@ class ShardedDOTagCache implements NextModeTagCache { const cfEnv = getCloudflareContext().env; const db = cfEnv.NEXT_TAG_CACHE_DO_SHARDED; - if (!db) debug("No Durable object found"); + if (!db) debugCache("No Durable object found"); const isDisabled = !!(globalThis as unknown as { openNextConfig: OpenNextConfig }).openNextConfig .dangerous?.disableTagCache; @@ -334,7 +335,7 @@ class ShardedDOTagCache implements NextModeTagCache { const key = await this.getCacheKey(doId, tags); await cache.delete(key); } catch (e) { - debug("Error while deleting from regional cache", e); + debugCache("Error while deleting from regional cache", e); } } } diff --git a/packages/cloudflare/src/cli/build/build.ts b/packages/cloudflare/src/cli/build/build.ts index 312e1848..b0e857e2 100644 --- a/packages/cloudflare/src/cli/build/build.ts +++ b/packages/cloudflare/src/cli/build/build.ts @@ -13,7 +13,6 @@ import { bundleServer } from "./bundle-server.js"; import { compileCacheAssetsManifestSqlFile } from "./open-next/compile-cache-assets-manifest.js"; import { compileEnvFiles } from "./open-next/compile-env-files.js"; import { compileDurableObjects } from "./open-next/compileDurableObjects.js"; -import { copyCacheAssets } from "./open-next/copyCacheAssets.js"; import { createServerBundle } from "./open-next/createServerBundle.js"; import { createWranglerConfigIfNotExistent } from "./utils/index.js"; import { getVersion } from "./utils/version.js"; @@ -71,7 +70,6 @@ export async function build( if (config.dangerous?.disableIncrementalCache !== true) { const { useTagCache, metaFiles } = createCacheAssets(options); - copyCacheAssets(options); if (useTagCache) { compileCacheAssetsManifestSqlFile(options, metaFiles); diff --git a/packages/cloudflare/src/cli/build/open-next/copyCacheAssets.ts b/packages/cloudflare/src/cli/build/open-next/copyCacheAssets.ts deleted file mode 100644 index 9ea967a7..00000000 --- a/packages/cloudflare/src/cli/build/open-next/copyCacheAssets.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { cpSync, mkdirSync } from "node:fs"; -import { join } from "node:path"; - -import * as buildHelper from "@opennextjs/aws/build/helper.js"; - -import { CACHE_ASSET_DIR } from "../../../api/overrides/incremental-cache/kv-incremental-cache.js"; - -export function copyCacheAssets(options: buildHelper.BuildOptions) { - const { outputDir } = options; - const srcPath = join(outputDir, "cache"); - const dstPath = join(outputDir, "assets", CACHE_ASSET_DIR); - mkdirSync(dstPath, { recursive: true }); - cpSync(srcPath, dstPath, { recursive: true }); -} diff --git a/packages/cloudflare/src/cli/commands/populate-cache.ts b/packages/cloudflare/src/cli/commands/populate-cache.ts index 893fa0a3..6a9b6f28 100644 --- a/packages/cloudflare/src/cli/commands/populate-cache.ts +++ b/packages/cloudflare/src/cli/commands/populate-cache.ts @@ -14,8 +14,20 @@ import { globSync } from "glob"; import { tqdm } from "ts-tqdm"; import { unstable_readConfig } from "wrangler"; -import { NAME as R2_CACHE_NAME } from "../../api/overrides/incremental-cache/r2-incremental-cache.js"; -import { NAME as D1_TAG_NAME } from "../../api/overrides/tag-cache/d1-next-tag-cache.js"; +import { + BINDING_NAME as KV_CACHE_BINDING_NAME, + NAME as KV_CACHE_NAME, +} from "../../api/overrides/incremental-cache/kv-incremental-cache.js"; +import { + BINDING_NAME as R2_CACHE_BINDING_NAME, + DEFAULT_PREFIX as R2_CACHE_DEFAULT_PREFIX, + NAME as R2_CACHE_NAME, + PREFIX_ENV_NAME as R2_CACHE_PREFIX_ENV_NAME, +} from "../../api/overrides/incremental-cache/r2-incremental-cache.js"; +import { + BINDING_NAME as D1_TAG_BINDING_NAME, + NAME as D1_TAG_NAME, +} from "../../api/overrides/tag-cache/d1-next-tag-cache.js"; import type { WranglerTarget } from "../utils/run-wrangler.js"; import { runWrangler } from "../utils/run-wrangler.js"; @@ -47,6 +59,98 @@ function getCacheAssetPaths(opts: BuildOptions) { }); } +function populateR2IncrementalCache( + options: BuildOptions, + populateCacheOptions: { target: WranglerTarget; environment?: string } +) { + logger.info("\nPopulating R2 incremental cache..."); + + const config = unstable_readConfig({ env: populateCacheOptions.environment }); + + const binding = config.r2_buckets.find(({ binding }) => binding === R2_CACHE_BINDING_NAME); + if (!binding) { + throw new Error(`No R2 binding ${JSON.stringify(R2_CACHE_BINDING_NAME)} found!`); + } + + const bucket = binding.bucket_name; + if (!bucket) { + throw new Error(`R2 binding ${JSON.stringify(R2_CACHE_BINDING_NAME)} should have a 'bucket_name'`); + } + + const assets = getCacheAssetPaths(options); + for (const { fsPath, destPath } of tqdm(assets)) { + const fullDestPath = path.join( + bucket, + process.env[R2_CACHE_PREFIX_ENV_NAME] ?? R2_CACHE_DEFAULT_PREFIX, + destPath + ); + + runWrangler( + options, + ["r2 object put", JSON.stringify(fullDestPath), `--file ${JSON.stringify(fsPath)}`], + // NOTE: R2 does not support the environment flag and results in the following error: + // Incorrect type for the 'cacheExpiry' field on 'HttpMetadata': the provided value is not of type 'date'. + { target: populateCacheOptions.target, excludeRemoteFlag: true, logging: "error" } + ); + } + logger.info(`Successfully populated cache with ${assets.length} assets`); +} + +function populateKVIncrementalCache( + options: BuildOptions, + populateCacheOptions: { target: WranglerTarget; environment?: string } +) { + logger.info("\nPopulating KV incremental cache..."); + + const config = unstable_readConfig({ env: populateCacheOptions.environment }); + + const binding = config.kv_namespaces.find(({ binding }) => binding === KV_CACHE_BINDING_NAME); + if (!binding) { + throw new Error(`No KV binding ${JSON.stringify(KV_CACHE_BINDING_NAME)} found!`); + } + + const assets = getCacheAssetPaths(options); + for (const { fsPath, destPath } of tqdm(assets)) { + runWrangler( + options, + [ + "kv key put", + JSON.stringify(destPath), + `--binding ${JSON.stringify(KV_CACHE_BINDING_NAME)}`, + `--path ${JSON.stringify(fsPath)}`, + ], + { ...populateCacheOptions, logging: "error" } + ); + } + logger.info(`Successfully populated cache with ${assets.length} assets`); +} + +function populateD1TagCache( + options: BuildOptions, + populateCacheOptions: { target: WranglerTarget; environment?: string } +) { + logger.info("\nCreating D1 table if necessary..."); + + const config = unstable_readConfig({ env: populateCacheOptions.environment }); + + const binding = config.d1_databases.find(({ binding }) => binding === D1_TAG_BINDING_NAME); + if (!binding) { + throw new Error(`No D1 binding ${JSON.stringify(D1_TAG_BINDING_NAME)} found!`); + } + + runWrangler( + options, + [ + "d1 execute", + JSON.stringify(D1_TAG_BINDING_NAME), + `--command "CREATE TABLE IF NOT EXISTS revalidations (tag TEXT NOT NULL, revalidatedAt INTEGER NOT NULL, UNIQUE(tag) ON CONFLICT REPLACE);"`, + ], + { ...populateCacheOptions, logging: "error" } + ); + + logger.info("\nSuccessfully created D1 table"); +} + export async function populateCache( options: BuildOptions, config: OpenNextConfig, @@ -62,44 +166,12 @@ export async function populateCache( if (!config.dangerous?.disableIncrementalCache && incrementalCache) { const name = await resolveCacheName(incrementalCache); switch (name) { - case R2_CACHE_NAME: { - const config = unstable_readConfig({ env: populateCacheOptions.environment }); - - const binding = (config.r2_buckets ?? []).find( - ({ binding }) => binding === "NEXT_INC_CACHE_R2_BUCKET" - ); - - if (!binding) { - throw new Error("No R2 binding 'NEXT_INC_CACHE_R2_BUCKET' found!"); - } - - const bucket = binding.bucket_name; - - if (!bucket) { - throw new Error("R2 binding 'NEXT_INC_CACHE_R2_BUCKET' should have a 'bucket_name'"); - } - - logger.info("\nPopulating R2 incremental cache..."); - - const assets = getCacheAssetPaths(options); - for (const { fsPath, destPath } of tqdm(assets)) { - const fullDestPath = path.join( - bucket, - process.env.NEXT_INC_CACHE_R2_PREFIX ?? "incremental-cache", - destPath - ); - - runWrangler( - options, - ["r2 object put", JSON.stringify(fullDestPath), `--file ${JSON.stringify(fsPath)}`], - // NOTE: R2 does not support the environment flag and results in the following error: - // Incorrect type for the 'cacheExpiry' field on 'HttpMetadata': the provided value is not of type 'date'. - { target: populateCacheOptions.target, excludeRemoteFlag: true, logging: "error" } - ); - } - logger.info(`Successfully populated cache with ${assets.length} assets`); + case R2_CACHE_NAME: + populateR2IncrementalCache(options, populateCacheOptions); + break; + case KV_CACHE_NAME: + populateKVIncrementalCache(options, populateCacheOptions); break; - } default: logger.info("Incremental cache does not need populating"); } @@ -108,22 +180,9 @@ export async function populateCache( if (!config.dangerous?.disableTagCache && !config.dangerous?.disableIncrementalCache && tagCache) { const name = await resolveCacheName(tagCache); switch (name) { - case D1_TAG_NAME: { - logger.info("\nCreating D1 table if necessary..."); - - runWrangler( - options, - [ - "d1 execute", - "NEXT_TAG_CACHE_D1", - `--command "CREATE TABLE IF NOT EXISTS revalidations (tag TEXT NOT NULL, revalidatedAt INTEGER NOT NULL, UNIQUE(tag) ON CONFLICT REPLACE);"`, - ], - { ...populateCacheOptions, logging: "error" } - ); - - logger.info("\nSuccessfully created D1 table"); + case D1_TAG_NAME: + populateD1TagCache(options, populateCacheOptions); break; - } default: logger.info("Tag cache does not need populating"); }