Skip to content

Commit 9adc3a3

Browse files
authored
feat: populate kv incremental cache (#546)
* delete original kv inc cache * add simplified new kv inc cache * add kv population * changeset * fix e2e * fix serving asset populated from build * remove ttl for now * shared cache debug function * share names between files * tweak error log * sort imports * move cache population to individual functions * check kv binding exists * check d1 binding exists * dont return from populate * Update packages/cloudflare/src/api/overrides/incremental-cache/kv-incremental-cache.ts * Update packages/cloudflare/src/api/overrides/incremental-cache/kv-incremental-cache.ts * run prettier
1 parent efc8f25 commit 9adc3a3

File tree

13 files changed

+214
-204
lines changed

13 files changed

+214
-204
lines changed

.changeset/metal-lemons-sparkle.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@opennextjs/cloudflare": patch
3+
---
4+
5+
feat: populate kv incremental cache

examples/ssg-app/wrangler.jsonc

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,12 @@
88
"directory": ".open-next/assets",
99
"binding": "ASSETS"
1010
},
11+
"kv_namespaces": [
12+
{
13+
"binding": "NEXT_INC_CACHE_KV",
14+
"id": "<BINDING_ID>"
15+
}
16+
],
1117
"vars": {
1218
"APP_VERSION": "1.2.345"
1319
}

packages/cloudflare/src/api/overrides/incremental-cache/internal.ts

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

packages/cloudflare/src/api/overrides/incremental-cache/kv-incremental-cache.ts

Lines changed: 43 additions & 105 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,16 @@
1-
import type { CacheValue, IncrementalCache, WithLastModified } from "@opennextjs/aws/types/overrides";
2-
import { IgnorableError, RecoverableError } from "@opennextjs/aws/utils/error.js";
1+
import { error } from "@opennextjs/aws/adapters/logger.js";
2+
import type { CacheValue, IncrementalCache, WithLastModified } from "@opennextjs/aws/types/overrides.js";
3+
import { IgnorableError } from "@opennextjs/aws/utils/error.js";
34

45
import { getCloudflareContext } from "../../cloudflare-context.js";
5-
6-
export const CACHE_ASSET_DIR = "cdn-cgi/_next_cache";
7-
8-
export const STATUS_DELETED = 1;
6+
import { debugCache, FALLBACK_BUILD_ID, IncrementalCacheEntry } from "../internal.js";
97

108
export const NAME = "cf-kv-incremental-cache";
119

10+
export const BINDING_NAME = "NEXT_INC_CACHE_KV";
11+
1212
/**
13-
* Open Next cache based on cloudflare KV and Assets.
13+
* Open Next cache based on Cloudflare KV.
1414
*
1515
* Note: The class is instantiated outside of the request context.
1616
* The cloudflare context and process.env are not initialized yet
@@ -23,69 +23,32 @@ class KVIncrementalCache implements IncrementalCache {
2323
key: string,
2424
isFetch?: IsFetch
2525
): Promise<WithLastModified<CacheValue<IsFetch>> | null> {
26-
const cfEnv = getCloudflareContext().env;
27-
const kv = cfEnv.NEXT_INC_CACHE_KV;
28-
const assets = cfEnv.ASSETS;
29-
30-
if (!(kv || assets)) {
31-
throw new IgnorableError(`No KVNamespace nor Fetcher`);
32-
}
26+
const kv = getCloudflareContext().env[BINDING_NAME];
27+
if (!kv) throw new IgnorableError("No KV Namespace");
3328

34-
this.debug(`Get ${key}`);
29+
debugCache(`Get ${key}`);
3530

3631
try {
37-
let entry: {
38-
value?: CacheValue<IsFetch>;
39-
lastModified?: number;
40-
status?: number;
41-
} | null = null;
42-
43-
if (kv) {
44-
this.debug(`- From KV`);
45-
const kvKey = this.getKVKey(key, isFetch);
46-
entry = await kv.get(kvKey, "json");
47-
if (entry?.status === STATUS_DELETED) {
48-
return null;
49-
}
50-
}
32+
const entry = await kv.get<IncrementalCacheEntry<IsFetch> | CacheValue<IsFetch>>(
33+
this.getKVKey(key, isFetch),
34+
"json"
35+
);
36+
37+
if (!entry) return null;
5138

52-
if (!entry && assets) {
53-
this.debug(`- From Assets`);
54-
const url = this.getAssetUrl(key, isFetch);
55-
const response = await assets.fetch(url);
56-
if (response.ok) {
57-
// TODO: consider populating KV with the asset value if faster.
58-
// This could be optional as KV writes are $$.
59-
// See https://github.com/opennextjs/opennextjs-cloudflare/pull/194#discussion_r1893166026
60-
entry = {
61-
value: await response.json(),
62-
// __BUILD_TIMESTAMP_MS__ is injected by ESBuild.
63-
lastModified: (globalThis as { __BUILD_TIMESTAMP_MS__?: number }).__BUILD_TIMESTAMP_MS__,
64-
};
65-
}
66-
if (!kv) {
67-
// The cache can not be updated when there is no KV
68-
// As we don't want to keep serving stale data for ever,
69-
// we pretend the entry is not in cache
70-
if (
71-
entry?.value &&
72-
"kind" in entry.value &&
73-
entry.value.kind === "FETCH" &&
74-
entry.value.data?.headers?.expires
75-
) {
76-
const expiresTime = new Date(entry.value.data.headers.expires).getTime();
77-
if (!isNaN(expiresTime) && expiresTime <= Date.now()) {
78-
this.debug(`found expired entry (expire time: ${entry.value.data.headers.expires})`);
79-
return null;
80-
}
81-
}
82-
}
39+
if ("lastModified" in entry) {
40+
return entry;
8341
}
8442

85-
this.debug(entry ? `-> hit` : `-> miss`);
86-
return { value: entry?.value, lastModified: entry?.lastModified };
87-
} catch {
88-
throw new RecoverableError(`Failed to get cache [${key}]`);
43+
// if there is no lastModified property, the file was stored during build-time cache population.
44+
return {
45+
value: entry,
46+
// __BUILD_TIMESTAMP_MS__ is injected by ESBuild.
47+
lastModified: (globalThis as { __BUILD_TIMESTAMP_MS__?: number }).__BUILD_TIMESTAMP_MS__,
48+
};
49+
} catch (e) {
50+
error("Failed to get from cache", e);
51+
return null;
8952
}
9053
}
9154

@@ -94,69 +57,44 @@ class KVIncrementalCache implements IncrementalCache {
9457
value: CacheValue<IsFetch>,
9558
isFetch?: IsFetch
9659
): Promise<void> {
97-
const kv = getCloudflareContext().env.NEXT_INC_CACHE_KV;
98-
99-
if (!kv) {
100-
throw new IgnorableError(`No KVNamespace`);
101-
}
60+
const kv = getCloudflareContext().env[BINDING_NAME];
61+
if (!kv) throw new IgnorableError("No KV Namespace");
10262

103-
this.debug(`Set ${key}`);
63+
debugCache(`Set ${key}`);
10464

10565
try {
106-
const kvKey = this.getKVKey(key, isFetch);
107-
// Note: We can not set a TTL as we might fallback to assets,
108-
// still removing old data (old BUILD_ID) could help avoiding
109-
// the cache growing too big.
11066
await kv.put(
111-
kvKey,
67+
this.getKVKey(key, isFetch),
11268
JSON.stringify({
11369
value,
11470
// Note: `Date.now()` returns the time of the last IO rather than the actual time.
11571
// See https://developers.cloudflare.com/workers/reference/security-model/
11672
lastModified: Date.now(),
11773
})
74+
// TODO: Figure out how to best leverage KV's TTL.
75+
// NOTE: Ideally, the cache should operate in an SWR-like manner.
11876
);
119-
} catch {
120-
throw new RecoverableError(`Failed to set cache [${key}]`);
77+
} catch (e) {
78+
error("Failed to set to cache", e);
12179
}
12280
}
12381

12482
async delete(key: string): Promise<void> {
125-
const kv = getCloudflareContext().env.NEXT_INC_CACHE_KV;
83+
const kv = getCloudflareContext().env[BINDING_NAME];
84+
if (!kv) throw new IgnorableError("No KV Namespace");
12685

127-
if (!kv) {
128-
throw new IgnorableError(`No KVNamespace`);
129-
}
130-
131-
this.debug(`Delete ${key}`);
86+
debugCache(`Delete ${key}`);
13287

13388
try {
134-
const kvKey = this.getKVKey(key, /* isFetch= */ false);
135-
// Do not delete the key as we would then fallback to the assets.
136-
await kv.put(kvKey, JSON.stringify({ status: STATUS_DELETED }));
137-
} catch {
138-
throw new RecoverableError(`Failed to delete cache [${key}]`);
89+
await kv.delete(this.getKVKey(key, /* isFetch= */ false));
90+
} catch (e) {
91+
error("Failed to delete from cache", e);
13992
}
14093
}
14194

14295
protected getKVKey(key: string, isFetch?: boolean): string {
143-
return `${this.getBuildId()}/${key}.${isFetch ? "fetch" : "cache"}`.replace(/\/+/g, "/");
144-
}
145-
146-
protected getAssetUrl(key: string, isFetch?: boolean): string {
147-
return isFetch
148-
? `http://assets.local/${CACHE_ASSET_DIR}/__fetch/${this.getBuildId()}/${key}`
149-
: `http://assets.local/${CACHE_ASSET_DIR}/${this.getBuildId()}/${key}.cache`;
150-
}
151-
152-
protected debug(...args: unknown[]) {
153-
if (process.env.NEXT_PRIVATE_DEBUG_CACHE) {
154-
console.log(`[Cache ${this.name}] `, ...args);
155-
}
156-
}
157-
158-
protected getBuildId() {
159-
return process.env.NEXT_BUILD_ID ?? "no-build-id";
96+
const buildId = process.env.NEXT_BUILD_ID ?? FALLBACK_BUILD_ID;
97+
return `${buildId}/${key}.${isFetch ? "fetch" : "cache"}`.replace(/\/+/g, "/");
16098
}
16199
}
162100

packages/cloudflare/src/api/overrides/incremental-cache/r2-incremental-cache.ts

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,17 @@
1-
import { debug, error } from "@opennextjs/aws/adapters/logger.js";
1+
import { error } from "@opennextjs/aws/adapters/logger.js";
22
import type { CacheValue, IncrementalCache, WithLastModified } from "@opennextjs/aws/types/overrides.js";
33
import { IgnorableError } from "@opennextjs/aws/utils/error.js";
44

55
import { getCloudflareContext } from "../../cloudflare-context.js";
6+
import { debugCache, FALLBACK_BUILD_ID } from "../internal.js";
67

78
export const NAME = "cf-r2-incremental-cache";
89

10+
export const BINDING_NAME = "NEXT_INC_CACHE_R2_BUCKET";
11+
12+
export const PREFIX_ENV_NAME = "NEXT_INC_CACHE_R2_PREFIX";
13+
export const DEFAULT_PREFIX = "incremental-cache";
14+
915
/**
1016
* An instance of the Incremental Cache that uses an R2 bucket (`NEXT_INC_CACHE_R2_BUCKET`) as it's
1117
* underlying data store.
@@ -20,10 +26,10 @@ class R2IncrementalCache implements IncrementalCache {
2026
key: string,
2127
isFetch?: IsFetch
2228
): Promise<WithLastModified<CacheValue<IsFetch>> | null> {
23-
const r2 = getCloudflareContext().env.NEXT_INC_CACHE_R2_BUCKET;
29+
const r2 = getCloudflareContext().env[BINDING_NAME];
2430
if (!r2) throw new IgnorableError("No R2 bucket");
2531

26-
debug(`Get ${key}`);
32+
debugCache(`Get ${key}`);
2733

2834
try {
2935
const r2Object = await r2.get(this.getR2Key(key, isFetch));
@@ -44,10 +50,10 @@ class R2IncrementalCache implements IncrementalCache {
4450
value: CacheValue<IsFetch>,
4551
isFetch?: IsFetch
4652
): Promise<void> {
47-
const r2 = getCloudflareContext().env.NEXT_INC_CACHE_R2_BUCKET;
53+
const r2 = getCloudflareContext().env[BINDING_NAME];
4854
if (!r2) throw new IgnorableError("No R2 bucket");
4955

50-
debug(`Set ${key}`);
56+
debugCache(`Set ${key}`);
5157

5258
try {
5359
await r2.put(this.getR2Key(key, isFetch), JSON.stringify(value));
@@ -57,10 +63,10 @@ class R2IncrementalCache implements IncrementalCache {
5763
}
5864

5965
async delete(key: string): Promise<void> {
60-
const r2 = getCloudflareContext().env.NEXT_INC_CACHE_R2_BUCKET;
66+
const r2 = getCloudflareContext().env[BINDING_NAME];
6167
if (!r2) throw new IgnorableError("No R2 bucket");
6268

63-
debug(`Delete ${key}`);
69+
debugCache(`Delete ${key}`);
6470

6571
try {
6672
await r2.delete(this.getR2Key(key));
@@ -70,9 +76,9 @@ class R2IncrementalCache implements IncrementalCache {
7076
}
7177

7278
protected getR2Key(key: string, isFetch?: boolean): string {
73-
const directory = getCloudflareContext().env.NEXT_INC_CACHE_R2_PREFIX ?? "incremental-cache";
79+
const directory = getCloudflareContext().env[PREFIX_ENV_NAME] ?? DEFAULT_PREFIX;
7480

75-
return `${directory}/${process.env.NEXT_BUILD_ID ?? "no-build-id"}/${key}.${isFetch ? "fetch" : "cache"}`.replace(
81+
return `${directory}/${process.env.NEXT_BUILD_ID ?? FALLBACK_BUILD_ID}/${key}.${isFetch ? "fetch" : "cache"}`.replace(
7682
/\/+/g,
7783
"/"
7884
);

packages/cloudflare/src/api/overrides/incremental-cache/regional-cache.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
import { debug, error } from "@opennextjs/aws/adapters/logger.js";
1+
import { error } from "@opennextjs/aws/adapters/logger.js";
22
import { CacheValue, IncrementalCache, WithLastModified } from "@opennextjs/aws/types/overrides.js";
33

44
import { getCloudflareContext } from "../../cloudflare-context.js";
5-
import { IncrementalCacheEntry } from "./internal.js";
5+
import { debugCache, FALLBACK_BUILD_ID, IncrementalCacheEntry } from "../internal.js";
66
import { NAME as KV_CACHE_NAME } from "./kv-incremental-cache.js";
77

88
const ONE_MINUTE_IN_SECONDS = 60;
@@ -57,7 +57,7 @@ class RegionalCache implements IncrementalCache {
5757
// Check for a cached entry as this will be faster than the store response.
5858
const cachedResponse = await cache.match(localCacheKey);
5959
if (cachedResponse) {
60-
debug("Get - cached response");
60+
debugCache("Get - cached response");
6161

6262
// Re-fetch from the store and update the regional cache in the background
6363
if (this.opts.shouldLazilyUpdateOnCacheHit) {
@@ -129,7 +129,7 @@ class RegionalCache implements IncrementalCache {
129129
protected getCacheKey(key: string, isFetch?: boolean) {
130130
return new Request(
131131
new URL(
132-
`${process.env.NEXT_BUILD_ID ?? "no-build-id"}/${key}.${isFetch ? "fetch" : "cache"}`,
132+
`${process.env.NEXT_BUILD_ID ?? FALLBACK_BUILD_ID}/${key}.${isFetch ? "fetch" : "cache"}`,
133133
"http://cache.local"
134134
)
135135
);
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { CacheValue } from "@opennextjs/aws/types/overrides.js";
2+
3+
export type IncrementalCacheEntry<IsFetch extends boolean> = {
4+
value: CacheValue<IsFetch>;
5+
lastModified: number;
6+
};
7+
8+
export const debugCache = (name: string, ...args: unknown[]) => {
9+
if (process.env.NEXT_PRIVATE_DEBUG_CACHE) {
10+
console.log(`[${name}] `, ...args);
11+
}
12+
};
13+
14+
export const FALLBACK_BUILD_ID = "no-build-id";

packages/cloudflare/src/api/overrides/queue/memory-queue.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1-
import { debug, error } from "@opennextjs/aws/adapters/logger.js";
1+
import { error } from "@opennextjs/aws/adapters/logger.js";
22
import type { Queue, QueueMessage } from "@opennextjs/aws/types/overrides.js";
33
import { IgnorableError } from "@opennextjs/aws/utils/error.js";
44

55
import { getCloudflareContext } from "../../cloudflare-context";
6+
import { debugCache } from "../internal";
67

78
export const DEFAULT_REVALIDATION_TIMEOUT_MS = 10_000;
89

@@ -48,7 +49,7 @@ export class MemoryQueue implements Queue {
4849
if (response.status !== 200 || response.headers.get("x-nextjs-cache") !== "REVALIDATED") {
4950
error(`Revalidation failed for ${url} with status ${response.status}`);
5051
}
51-
debug(`Revalidation successful for ${url}`);
52+
debugCache(`Revalidation successful for ${url}`);
5253
} catch (e) {
5354
error(e);
5455
} finally {

packages/cloudflare/src/api/overrides/tag-cache/d1-next-tag-cache.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
1-
import { debug, error } from "@opennextjs/aws/adapters/logger.js";
1+
import { error } from "@opennextjs/aws/adapters/logger.js";
22
import type { OpenNextConfig } from "@opennextjs/aws/types/open-next.js";
33
import type { NextModeTagCache } from "@opennextjs/aws/types/overrides.js";
44
import { RecoverableError } from "@opennextjs/aws/utils/error.js";
55

66
import { getCloudflareContext } from "../../cloudflare-context.js";
7+
import { debugCache, FALLBACK_BUILD_ID } from "../internal.js";
78

89
export const NAME = "d1-next-mode-tag-cache";
910

11+
export const BINDING_NAME = "NEXT_TAG_CACHE_D1";
12+
1013
export class D1NextModeTagCache implements NextModeTagCache {
1114
readonly mode = "nextMode" as const;
1215
readonly name = NAME;
@@ -45,10 +48,9 @@ export class D1NextModeTagCache implements NextModeTagCache {
4548
}
4649

4750
private getConfig() {
48-
const cfEnv = getCloudflareContext().env;
49-
const db = cfEnv.NEXT_TAG_CACHE_D1;
51+
const db = getCloudflareContext().env[BINDING_NAME];
5052

51-
if (!db) debug("No D1 database found");
53+
if (!db) debugCache("No D1 database found");
5254

5355
const isDisabled = !!(globalThis as unknown as { openNextConfig: OpenNextConfig }).openNextConfig
5456
.dangerous?.disableTagCache;
@@ -70,7 +72,7 @@ export class D1NextModeTagCache implements NextModeTagCache {
7072
}
7173

7274
protected getBuildId() {
73-
return process.env.NEXT_BUILD_ID ?? "no-build-id";
75+
return process.env.NEXT_BUILD_ID ?? FALLBACK_BUILD_ID;
7476
}
7577
}
7678

0 commit comments

Comments
 (0)