Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/nervous-crews-yawn.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@opennextjs/cloudflare": patch
---

Use kebab-case for the KV Cache.
2 changes: 1 addition & 1 deletion examples/e2e/app-router/open-next.config.ts
Original file line number Diff line number Diff line change
@@ -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: {
Expand Down
2 changes: 1 addition & 1 deletion examples/vercel-blog-starter/open-next.config.ts
Original file line number Diff line number Diff line change
@@ -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: {
Expand Down
161 changes: 161 additions & 0 deletions packages/cloudflare/src/api/kv-cache.ts
Original file line number Diff line number Diff line change
@@ -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<IsFetch extends boolean = false>(
key: string,
isFetch?: IsFetch
): Promise<WithLastModified<CacheValue<IsFetch>> | 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<IsFetch>;
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<IsFetch extends boolean = false>(
key: string,
value: CacheValue<IsFetch>,
isFetch?: IsFetch
): Promise<void> {
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<void> {
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();
161 changes: 3 additions & 158 deletions packages/cloudflare/src/api/kvCache.ts
Original file line number Diff line number Diff line change
@@ -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<IsFetch extends boolean = false>(
key: string,
isFetch?: IsFetch
): Promise<WithLastModified<CacheValue<IsFetch>> | 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<IsFetch>;
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<IsFetch extends boolean = false>(
key: string,
value: CacheValue<IsFetch>,
isFetch?: IsFetch
): Promise<void> {
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<void> {
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;
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion packages/cloudflare/templates/defaults/open-next.config.ts
Original file line number Diff line number Diff line change
@@ -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: {
Expand Down