Skip to content

Commit 6183c4a

Browse files
vicbconico974
andcommitted
feat: kv cache (#194)
Co-authored-by: conico974 <[email protected]>
1 parent 337c45b commit 6183c4a

File tree

15 files changed

+244
-120
lines changed

15 files changed

+244
-120
lines changed

examples/vercel-blog-starter/open-next.config.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import type { OpenNextConfig } from "@opennextjs/aws/types/open-next";
2+
import cache from "@opennextjs/cloudflare/kvCache";
23

34
const config: OpenNextConfig = {
45
default: {
56
override: {
67
wrapper: "cloudflare-node",
78
converter: "edge",
8-
// Unused implementation
9-
incrementalCache: "dummy",
9+
incrementalCache: async () => cache,
10+
// Unused implementations
1011
tagCache: "dummy",
1112
queue: "dummy",
1213
},

packages/cloudflare/README.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,6 @@ export default config;
6868

6969
## Known issues
7070

71-
- Next cache is not supported in the experimental branch yet
7271
- `▲ [WARNING] Suspicious assignment to defined constant "process.env.NODE_ENV" [assign-to-define]` can safely be ignored
7372
- Maybe more, still experimental...
7473

packages/cloudflare/env.d.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,13 @@
11
declare global {
22
namespace NodeJS {
33
interface ProcessEnv {
4-
ASSETS: Fetcher;
54
__NEXT_PRIVATE_STANDALONE_CONFIG?: string;
65
SKIP_NEXT_APP_BUILD?: string;
76
NEXT_PRIVATE_DEBUG_CACHE?: string;
8-
__OPENNEXT_KV_BINDING_NAME: string;
97
OPEN_NEXT_ORIGIN: string;
108
NODE_ENV?: string;
11-
__OPENNEXT_PROCESSED_ENV?: string;
9+
// Whether process.env has been populated (on first request).
10+
__PROCESS_ENV_POPULATED?: string;
1211
}
1312
}
1413
}

packages/cloudflare/package.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,11 @@
2323
"import": "./dist/api/index.js",
2424
"types": "./dist/api/index.d.ts",
2525
"default": "./dist/api/index.js"
26+
},
27+
"./*": {
28+
"import": "./dist/api/*.js",
29+
"types": "./dist/api/*.d.ts",
30+
"default": "./dist/api/*.js"
2631
}
2732
},
2833
"files": [
@@ -66,7 +71,7 @@
6671
"@types/mock-fs": "catalog:"
6772
},
6873
"dependencies": {
69-
"@opennextjs/aws": "https://pkg.pr.new/@opennextjs/aws@683",
74+
"@opennextjs/aws": "https://pkg.pr.new/@opennextjs/aws@684",
7075
"ts-morph": "catalog:",
7176
"@dotenvx/dotenvx": "catalog:"
7277
},

packages/cloudflare/src/api/get-cloudflare-context.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
import "server-only";
2-
31
declare global {
4-
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
5-
interface CloudflareEnv {}
2+
interface CloudflareEnv {
3+
NEXT_CACHE_WORKERS_KV?: KVNamespace;
4+
ASSETS?: Fetcher;
5+
}
66
}
77

88
export type CloudflareContext<
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import type { KVNamespace } from "@cloudflare/workers-types";
2+
import type { CacheValue, IncrementalCache, WithLastModified } from "@opennextjs/aws/types/overrides";
3+
import { IgnorableError, RecoverableError } from "@opennextjs/aws/utils/error.js";
4+
5+
import { getCloudflareContext } from "./get-cloudflare-context.js";
6+
7+
export const CACHE_ASSET_DIR = "cnd-cgi/_next_cache";
8+
9+
export const STATUS_DELETED = 1;
10+
11+
/**
12+
* Open Next cache based on cloudflare KV and Assets.
13+
*
14+
* Note: The class is instantiated outside of the request context.
15+
* The cloudflare context and process.env are not initialzed yet
16+
* when the constructor is called.
17+
*/
18+
class Cache implements IncrementalCache {
19+
readonly name = "cloudflare-kv";
20+
protected initialized = false;
21+
protected kv: KVNamespace | undefined;
22+
protected assets: Fetcher | undefined;
23+
24+
async get<IsFetch extends boolean = false>(
25+
key: string,
26+
isFetch?: IsFetch
27+
): Promise<WithLastModified<CacheValue<IsFetch>>> {
28+
if (!this.initialized) {
29+
await this.init();
30+
}
31+
32+
if (!(this.kv || this.assets)) {
33+
throw new IgnorableError(`No KVNamespace nor Fetcher`);
34+
}
35+
36+
this.debug(`Get ${key}`);
37+
38+
try {
39+
let entry: {
40+
value?: CacheValue<IsFetch>;
41+
lastModified?: number;
42+
status?: number;
43+
} | null = null;
44+
45+
if (this.kv) {
46+
this.debug(`- From KV`);
47+
const kvKey = this.getKVKey(key, isFetch);
48+
entry = await this.kv.get(kvKey, "json");
49+
if (entry?.status === STATUS_DELETED) {
50+
return {};
51+
}
52+
}
53+
54+
if (!entry && this.assets) {
55+
this.debug(`- From Assets`);
56+
const url = this.getAssetUrl(key, isFetch);
57+
const response = await this.assets.fetch(url);
58+
if (response.ok) {
59+
// TODO: consider populating KV with the asset value if faster.
60+
// This could be optional as KV writes are $$.
61+
// See https://github.com/opennextjs/opennextjs-cloudflare/pull/194#discussion_r1893166026
62+
entry = {
63+
value: await response.json(),
64+
// __BUILD_TIMESTAMP_MS__ is injected by ESBuild.
65+
lastModified: (globalThis as { __BUILD_TIMESTAMP_MS__?: number }).__BUILD_TIMESTAMP_MS__,
66+
};
67+
}
68+
}
69+
this.debug(entry ? `-> hit` : `-> miss`);
70+
return { value: entry?.value, lastModified: entry?.lastModified };
71+
} catch {
72+
throw new RecoverableError(`Failed to get cache [${key}]`);
73+
}
74+
}
75+
76+
async set<IsFetch extends boolean = false>(
77+
key: string,
78+
value: CacheValue<IsFetch>,
79+
isFetch?: IsFetch
80+
): Promise<void> {
81+
if (!this.initialized) {
82+
await this.init();
83+
}
84+
if (!this.kv) {
85+
throw new IgnorableError(`No KVNamespace`);
86+
}
87+
this.debug(`Set ${key}`);
88+
try {
89+
const kvKey = this.getKVKey(key, isFetch);
90+
// Note: We can not set a TTL as we might fallback to assets,
91+
// still removing old data (old BUILD_ID) could help avoiding
92+
// the cache growing too big.
93+
await this.kv.put(
94+
kvKey,
95+
JSON.stringify({
96+
value,
97+
// Note: `Date.now()` returns the time of the last IO rather than the actual time.
98+
// See https://developers.cloudflare.com/workers/reference/security-model/
99+
lastModified: Date.now(),
100+
})
101+
);
102+
} catch {
103+
throw new RecoverableError(`Failed to set cache [${key}]`);
104+
}
105+
}
106+
107+
async delete(key: string): Promise<void> {
108+
if (!this.initialized) {
109+
await this.init();
110+
}
111+
if (!this.kv) {
112+
throw new IgnorableError(`No KVNamespace`);
113+
}
114+
this.debug(`Delete ${key}`);
115+
try {
116+
const kvKey = this.getKVKey(key, /* isFetch= */ false);
117+
// Do not delete the key as we would then fallback to the assets.
118+
await this.kv.put(kvKey, JSON.stringify({ status: STATUS_DELETED }));
119+
} catch {
120+
throw new RecoverableError(`Failed to delete cache [${key}]`);
121+
}
122+
}
123+
124+
protected getKVKey(key: string, isFetch?: boolean): string {
125+
return `${this.getBuildId()}/${key}.${isFetch ? "fetch" : "cache"}`;
126+
}
127+
128+
protected getAssetUrl(key: string, isFetch?: boolean): string {
129+
return isFetch
130+
? `http://assets.local/${CACHE_ASSET_DIR}/__fetch/${this.getBuildId()}/${key}`
131+
: `http://assets.local/${CACHE_ASSET_DIR}/${this.getBuildId()}/${key}.cache`;
132+
}
133+
134+
protected debug(...args: unknown[]) {
135+
if (process.env.NEXT_PRIVATE_DEBUG_CACHE) {
136+
console.log(`[Cache ${this.name}] `, ...args);
137+
}
138+
}
139+
140+
protected getBuildId() {
141+
return process.env.NEXT_BUILD_ID ?? "no-build-id";
142+
}
143+
144+
protected async init() {
145+
const env = (await getCloudflareContext()).env;
146+
this.kv = env.NEXT_CACHE_WORKERS_KV;
147+
this.assets = env.ASSETS;
148+
this.initialized = true;
149+
}
150+
}
151+
152+
export default new Cache();

packages/cloudflare/src/cli/build/bundle-server.ts

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import { build, Plugin } from "esbuild";
88

99
import { Config } from "../config.js";
1010
import * as patches from "./patches/index.js";
11-
import { copyPrerenderedRoutes } from "./utils/index.js";
1211

1312
/** The dist directory of the Cloudflare adapter package */
1413
const packageDistDir = path.join(path.dirname(fileURLToPath(import.meta.url)), "../..");
@@ -17,9 +16,6 @@ const packageDistDir = path.join(path.dirname(fileURLToPath(import.meta.url)), "
1716
* Bundle the Open Next server.
1817
*/
1918
export async function bundleServer(config: Config, openNextOptions: BuildOptions): Promise<void> {
20-
// Copy over prerendered assets (e.g. SSG routes)
21-
copyPrerenderedRoutes(config);
22-
2319
patches.copyPackageCliFiles(packageDistDir, config, openNextOptions);
2420

2521
const nextConfigStr =
@@ -113,6 +109,7 @@ globalThis.Request = CustomRequest;
113109
Request = globalThis.Request;
114110
// Makes the edge converter returns either a Response or a Request.
115111
globalThis.__dangerous_ON_edge_converter_returns_request = true;
112+
globalThis.__BUILD_TIMESTAMP_MS__ = ${Date.now()};
116113
`,
117114
},
118115
});

packages/cloudflare/src/cli/build/index.ts

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { dirname, join } from "node:path";
55
import { buildNextjsApp, setStandaloneBuildMode } from "@opennextjs/aws/build/buildNextApp.js";
66
import { compileCache } from "@opennextjs/aws/build/compileCache.js";
77
import { compileOpenNextConfig } from "@opennextjs/aws/build/compileConfig.js";
8-
import { createStaticAssets } from "@opennextjs/aws/build/createAssets.js";
8+
import { createCacheAssets, createStaticAssets } from "@opennextjs/aws/build/createAssets.js";
99
import { createMiddleware } from "@opennextjs/aws/build/createMiddleware.js";
1010
import * as buildHelper from "@opennextjs/aws/build/helper.js";
1111
import { printHeader, showWarningOnWindows } from "@opennextjs/aws/build/utils.js";
@@ -16,6 +16,7 @@ import type { ProjectOptions } from "../config.js";
1616
import { containsDotNextDir, getConfig } from "../config.js";
1717
import { bundleServer } from "./bundle-server.js";
1818
import { compileEnvFiles } from "./open-next/compile-env-files.js";
19+
import { copyCacheAssets } from "./open-next/copyCacheAssets.js";
1920
import { createServerBundle } from "./open-next/createServerBundle.js";
2021

2122
/**
@@ -80,6 +81,11 @@ export async function build(projectOpts: ProjectOptions): Promise<void> {
8081

8182
createStaticAssets(options);
8283

84+
if (config.dangerous?.disableIncrementalCache !== true) {
85+
createCacheAssets(options);
86+
copyCacheAssets(options);
87+
}
88+
8389
await createServerBundle(options);
8490

8591
// TODO: drop this copy.
@@ -103,10 +109,11 @@ function ensureCloudflareConfig(config: OpenNextConfig) {
103109
const requirements = {
104110
dftUseCloudflareWrapper: config.default?.override?.wrapper === "cloudflare-node",
105111
dftUseEdgeConverter: config.default?.override?.converter === "edge",
106-
dftUseDummyCache:
107-
config.default?.override?.incrementalCache === "dummy" &&
108-
config.default?.override?.tagCache === "dummy" &&
109-
config.default?.override?.queue === "dummy",
112+
dftMaybeUseCache:
113+
config.default?.override?.incrementalCache === "dummy" ||
114+
typeof config.default?.override?.incrementalCache === "function",
115+
dftUseDummyTagCacheAndQueue:
116+
config.default?.override?.tagCache === "dummy" && config.default?.override?.queue === "dummy",
110117
disableCacheInterception: config.dangerous?.enableCacheInterception !== true,
111118
mwIsMiddlewareExternal: config.middleware?.external == true,
112119
mwUseCloudflareWrapper: config.middleware?.override?.wrapper === "cloudflare-edge",
@@ -121,7 +128,7 @@ function ensureCloudflareConfig(config: OpenNextConfig) {
121128
override: {
122129
wrapper: "cloudflare-node",
123130
converter: "edge",
124-
incrementalCache: "dummy",
131+
incrementalCache: "dummy" | function,
125132
tagCache: "dummy",
126133
queue: "dummy",
127134
},
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { cpSync, mkdirSync } from "node:fs";
2+
import { join } from "node:path";
3+
4+
import * as buildHelper from "@opennextjs/aws/build/helper.js";
5+
6+
import { CACHE_ASSET_DIR } from "../../../api/kvCache.js";
7+
8+
export function copyCacheAssets(options: buildHelper.BuildOptions) {
9+
const { outputDir } = options;
10+
const srcPath = join(outputDir, "cache");
11+
const dstPath = join(outputDir, "assets", CACHE_ASSET_DIR);
12+
mkdirSync(dstPath, { recursive: true });
13+
cpSync(srcPath, dstPath, { recursive: true });
14+
}

packages/cloudflare/src/cli/build/utils/copy-prerendered-routes.ts

Lines changed: 0 additions & 48 deletions
This file was deleted.

0 commit comments

Comments
 (0)