Skip to content

Commit

Permalink
chore: use kebab-case for kv cache import (#353)
Browse files Browse the repository at this point in the history
* rename file

* add deprecated export

* rename a couple imports
  • Loading branch information
james-elicx authored Feb 7, 2025
1 parent a19b34d commit 6791cea
Show file tree
Hide file tree
Showing 7 changed files with 173 additions and 162 deletions.
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

0 comments on commit 6791cea

Please sign in to comment.