diff --git a/.changeset/nervous-crews-yawn.md b/.changeset/nervous-crews-yawn.md new file mode 100644 index 00000000..db003f2d --- /dev/null +++ b/.changeset/nervous-crews-yawn.md @@ -0,0 +1,5 @@ +--- +"@opennextjs/cloudflare": patch +--- + +Use kebab-case for the KV Cache. diff --git a/examples/e2e/app-router/open-next.config.ts b/examples/e2e/app-router/open-next.config.ts index f1f956ca..ef2e20a9 100644 --- a/examples/e2e/app-router/open-next.config.ts +++ b/examples/e2e/app-router/open-next.config.ts @@ -1,5 +1,5 @@ import type { OpenNextConfig } from "@opennextjs/aws/types/open-next.js"; -import cache from "@opennextjs/cloudflare/kvCache"; +import cache from "@opennextjs/cloudflare/kv-cache"; const config: OpenNextConfig = { default: { diff --git a/examples/vercel-blog-starter/open-next.config.ts b/examples/vercel-blog-starter/open-next.config.ts index b7a5e54c..71d02c63 100644 --- a/examples/vercel-blog-starter/open-next.config.ts +++ b/examples/vercel-blog-starter/open-next.config.ts @@ -1,5 +1,5 @@ import type { OpenNextConfig } from "@opennextjs/aws/types/open-next.js"; -import cache from "@opennextjs/cloudflare/kvCache"; +import cache from "@opennextjs/cloudflare/kv-cache"; const config: OpenNextConfig = { default: { diff --git a/packages/cloudflare/src/api/kv-cache.ts b/packages/cloudflare/src/api/kv-cache.ts new file mode 100644 index 00000000..4064fc4e --- /dev/null +++ b/packages/cloudflare/src/api/kv-cache.ts @@ -0,0 +1,161 @@ +import type { CacheValue, IncrementalCache, WithLastModified } from "@opennextjs/aws/types/overrides"; +import { IgnorableError, RecoverableError } 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; + +/** + * Open Next cache based on cloudflare KV and Assets. + * + * Note: The class is instantiated outside of the request context. + * The cloudflare context and process.env are not initialzed yet + * when the constructor is called. + */ +class Cache implements IncrementalCache { + readonly name = "cloudflare-kv"; + + async get( + key: string, + isFetch?: IsFetch + ): Promise> | null> { + const cfEnv = getCloudflareContext().env; + const kv = cfEnv.NEXT_CACHE_WORKERS_KV; + const assets = cfEnv.ASSETS; + + if (!(kv || assets)) { + throw new IgnorableError(`No KVNamespace nor Fetcher`); + } + + this.debug(`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; + } + } + + 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; + } + } + } + } + + this.debug(entry ? `-> hit` : `-> miss`); + return { value: entry?.value, lastModified: entry?.lastModified }; + } catch { + throw new RecoverableError(`Failed to get cache [${key}]`); + } + } + + async set( + key: string, + value: CacheValue, + isFetch?: IsFetch + ): Promise { + const kv = getCloudflareContext().env.NEXT_CACHE_WORKERS_KV; + + if (!kv) { + throw new IgnorableError(`No KVNamespace`); + } + + this.debug(`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, + 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(), + }) + ); + } catch { + throw new RecoverableError(`Failed to set cache [${key}]`); + } + } + + async delete(key: string): Promise { + const kv = getCloudflareContext().env.NEXT_CACHE_WORKERS_KV; + + if (!kv) { + throw new IgnorableError(`No KVNamespace`); + } + + this.debug(`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}]`); + } + } + + protected getKVKey(key: string, isFetch?: boolean): string { + return `${this.getBuildId()}/${key}.${isFetch ? "fetch" : "cache"}`; + } + + 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"; + } +} + +export default new Cache(); diff --git a/packages/cloudflare/src/api/kvCache.ts b/packages/cloudflare/src/api/kvCache.ts index 4064fc4e..e0e8e9bf 100644 --- a/packages/cloudflare/src/api/kvCache.ts +++ b/packages/cloudflare/src/api/kvCache.ts @@ -1,161 +1,6 @@ -import type { CacheValue, IncrementalCache, WithLastModified } from "@opennextjs/aws/types/overrides"; -import { IgnorableError, RecoverableError } 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 cache from "./kv-cache.js"; /** - * Open Next cache based on cloudflare KV and Assets. - * - * Note: The class is instantiated outside of the request context. - * The cloudflare context and process.env are not initialzed yet - * when the constructor is called. + * @deprecated Please import from `kv-cache` instead of `kvCache`. */ -class Cache implements IncrementalCache { - readonly name = "cloudflare-kv"; - - async get( - key: string, - isFetch?: IsFetch - ): Promise> | null> { - const cfEnv = getCloudflareContext().env; - const kv = cfEnv.NEXT_CACHE_WORKERS_KV; - const assets = cfEnv.ASSETS; - - if (!(kv || assets)) { - throw new IgnorableError(`No KVNamespace nor Fetcher`); - } - - this.debug(`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; - } - } - - 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; - } - } - } - } - - this.debug(entry ? `-> hit` : `-> miss`); - return { value: entry?.value, lastModified: entry?.lastModified }; - } catch { - throw new RecoverableError(`Failed to get cache [${key}]`); - } - } - - async set( - key: string, - value: CacheValue, - isFetch?: IsFetch - ): Promise { - const kv = getCloudflareContext().env.NEXT_CACHE_WORKERS_KV; - - if (!kv) { - throw new IgnorableError(`No KVNamespace`); - } - - this.debug(`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, - 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(), - }) - ); - } catch { - throw new RecoverableError(`Failed to set cache [${key}]`); - } - } - - async delete(key: string): Promise { - const kv = getCloudflareContext().env.NEXT_CACHE_WORKERS_KV; - - if (!kv) { - throw new IgnorableError(`No KVNamespace`); - } - - this.debug(`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}]`); - } - } - - protected getKVKey(key: string, isFetch?: boolean): string { - return `${this.getBuildId()}/${key}.${isFetch ? "fetch" : "cache"}`; - } - - 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"; - } -} - -export default new Cache(); +export default cache; diff --git a/packages/cloudflare/src/cli/build/open-next/copyCacheAssets.ts b/packages/cloudflare/src/cli/build/open-next/copyCacheAssets.ts index 50b752c1..ae67984b 100644 --- a/packages/cloudflare/src/cli/build/open-next/copyCacheAssets.ts +++ b/packages/cloudflare/src/cli/build/open-next/copyCacheAssets.ts @@ -3,7 +3,7 @@ import { join } from "node:path"; import * as buildHelper from "@opennextjs/aws/build/helper.js"; -import { CACHE_ASSET_DIR } from "../../../api/kvCache.js"; +import { CACHE_ASSET_DIR } from "../../../api/kv-cache.js"; export function copyCacheAssets(options: buildHelper.BuildOptions) { const { outputDir } = options; diff --git a/packages/cloudflare/templates/defaults/open-next.config.ts b/packages/cloudflare/templates/defaults/open-next.config.ts index 6710d452..9ca96384 100644 --- a/packages/cloudflare/templates/defaults/open-next.config.ts +++ b/packages/cloudflare/templates/defaults/open-next.config.ts @@ -1,6 +1,6 @@ // default open-next.config.ts file created by @opennextjs/cloudflare -import cache from "@opennextjs/cloudflare/kvCache"; +import cache from "@opennextjs/cloudflare/kv-cache"; const config = { default: {