Skip to content

Commit 84d9eb8

Browse files
committed
Hash cache keys to limit their length
1 parent 25ade6f commit 84d9eb8

File tree

7 files changed

+192
-36
lines changed

7 files changed

+192
-36
lines changed

packages/cloudflare/src/api/config.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ export function defineCloudflareConfig(config: CloudflareOverrides = {}): OpenNe
4545
queue: resolveQueue(queue),
4646
},
4747
},
48+
// node:crypto is used to compute cache keys
49+
edgeExternals: ["node:crypto"],
4850
};
4951
}
5052

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

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,27 @@
1+
import { createHash, type Hash } from "node:crypto";
2+
13
import { error } from "@opennextjs/aws/adapters/logger.js";
24
import type { CacheValue, IncrementalCache, WithLastModified } from "@opennextjs/aws/types/overrides.js";
35
import { IgnorableError } from "@opennextjs/aws/utils/error.js";
46

57
import { getCloudflareContext } from "../../cloudflare-context.js";
6-
import { debugCache, FALLBACK_BUILD_ID, IncrementalCacheEntry } from "../internal.js";
8+
import { CACHE_KEY_HASH, debugCache, FALLBACK_BUILD_ID, IncrementalCacheEntry } from "../internal.js";
79

810
export const NAME = "cf-kv-incremental-cache";
911

1012
export const BINDING_NAME = "NEXT_INC_CACHE_KV";
1113

14+
export type KeyOptions = {
15+
isFetch: boolean;
16+
buildId?: string;
17+
hash: Hash;
18+
};
19+
20+
export function computeCacheKey(key: string, options: KeyOptions) {
21+
const { isFetch, buildId, hash } = options;
22+
return `${buildId}/${hash.update(key).digest("hex")}.${isFetch ? "fetch" : "cache"}`.replace(/\/+/g, "/");
23+
}
24+
1225
/**
1326
* Open Next cache based on Cloudflare KV.
1427
*
@@ -93,8 +106,11 @@ class KVIncrementalCache implements IncrementalCache {
93106
}
94107

95108
protected getKVKey(key: string, isFetch?: boolean): string {
96-
const buildId = process.env.NEXT_BUILD_ID ?? FALLBACK_BUILD_ID;
97-
return `${buildId}/${key}.${isFetch ? "fetch" : "cache"}`.replace(/\/+/g, "/");
109+
return computeCacheKey(key, {
110+
buildId: process.env.NEXT_BUILD_ID ?? FALLBACK_BUILD_ID,
111+
isFetch: Boolean(isFetch),
112+
hash: createHash(CACHE_KEY_HASH),
113+
});
98114
}
99115
}
100116

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

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
1+
import { createHash, type Hash } from "node:crypto";
2+
13
import { error } from "@opennextjs/aws/adapters/logger.js";
24
import type { CacheValue, IncrementalCache, WithLastModified } from "@opennextjs/aws/types/overrides.js";
35
import { IgnorableError } from "@opennextjs/aws/utils/error.js";
46

57
import { getCloudflareContext } from "../../cloudflare-context.js";
6-
import { debugCache, FALLBACK_BUILD_ID } from "../internal.js";
8+
import { CACHE_KEY_HASH, debugCache, FALLBACK_BUILD_ID } from "../internal.js";
79

810
export const NAME = "cf-r2-incremental-cache";
911

@@ -12,6 +14,21 @@ export const BINDING_NAME = "NEXT_INC_CACHE_R2_BUCKET";
1214
export const PREFIX_ENV_NAME = "NEXT_INC_CACHE_R2_PREFIX";
1315
export const DEFAULT_PREFIX = "incremental-cache";
1416

17+
export type KeyOptions = {
18+
isFetch: boolean;
19+
directory?: string;
20+
buildId?: string;
21+
hash: Hash;
22+
};
23+
24+
export function computeCacheKey(key: string, options: KeyOptions) {
25+
const { isFetch, directory, buildId, hash } = options;
26+
return `${directory}/${buildId}/${hash.update(key).digest("hex")}.${isFetch ? "fetch" : "cache"}`.replace(
27+
/\/+/g,
28+
"/"
29+
);
30+
}
31+
1532
/**
1633
* An instance of the Incremental Cache that uses an R2 bucket (`NEXT_INC_CACHE_R2_BUCKET`) as it's
1734
* underlying data store.
@@ -76,12 +93,12 @@ class R2IncrementalCache implements IncrementalCache {
7693
}
7794

7895
protected getR2Key(key: string, isFetch?: boolean): string {
79-
const directory = getCloudflareContext().env[PREFIX_ENV_NAME] ?? DEFAULT_PREFIX;
80-
81-
return `${directory}/${process.env.NEXT_BUILD_ID ?? FALLBACK_BUILD_ID}/${key}.${isFetch ? "fetch" : "cache"}`.replace(
82-
/\/+/g,
83-
"/"
84-
);
96+
return computeCacheKey(key, {
97+
directory: getCloudflareContext().env[PREFIX_ENV_NAME] ?? DEFAULT_PREFIX,
98+
buildId: process.env.NEXT_BUILD_ID ?? FALLBACK_BUILD_ID,
99+
isFetch: Boolean(isFetch),
100+
hash: createHash(CACHE_KEY_HASH),
101+
});
85102
}
86103
}
87104

packages/cloudflare/src/api/overrides/internal.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,8 @@ export const debugCache = (name: string, ...args: unknown[]) => {
1111
}
1212
};
1313

14+
// Hash the keys to limit their length.
15+
// KV has a limit of 512B, R2 has a limit of 1024B.
16+
export const CACHE_KEY_HASH = "sha256";
17+
1418
export const FALLBACK_BUILD_ID = "no-build-id";

packages/cloudflare/src/cli/build/utils/ensure-cf-config.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export function ensureCloudflareConfig(config: OpenNextConfig) {
2222
config.default?.override?.queue === "direct" ||
2323
typeof config.default?.override?.queue === "function",
2424
mwIsMiddlewareIntegrated: config.middleware === undefined,
25+
hasCryptoExternal: config.edgeExternals?.includes("node:crypto"),
2526
};
2627

2728
if (config.default?.override?.queue === "direct") {
@@ -42,6 +43,7 @@ export function ensureCloudflareConfig(config: OpenNextConfig) {
4243
queue: "dummy" | "direct" | function,
4344
},
4445
},
46+
edgeExternals: ["node:crypto"],
4547
}\n\n`.replace(/^ {8}/gm, "")
4648
);
4749
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { mkdirSync, writeFileSync } from "node:fs";
2+
import path from "node:path";
3+
4+
import type { BuildOptions } from "@opennextjs/aws/build/helper";
5+
import mockFs from "mock-fs";
6+
import { afterAll, beforeAll, describe, expect, test } from "vitest";
7+
8+
import { getCacheAssets } from "./populate-cache";
9+
10+
describe("getCacheAssets", () => {
11+
beforeAll(() => {
12+
mockFs();
13+
14+
const fetchBaseDir = "/base/path/cache/__fetch/buildID";
15+
const cacheDir = "/base/path/cache/buildID/path/to";
16+
17+
mkdirSync(fetchBaseDir, { recursive: true });
18+
mkdirSync(cacheDir, { recursive: true });
19+
20+
for (let i = 0; i < 3; i++) {
21+
writeFileSync(path.join(fetchBaseDir, `${i}`), "", { encoding: "utf-8" });
22+
writeFileSync(path.join(cacheDir, `${i}.cache`), "", { encoding: "utf-8" });
23+
}
24+
});
25+
26+
afterAll(() => mockFs.restore());
27+
28+
test("list cache assets", () => {
29+
expect(getCacheAssets({ outputDir: "/base/path" } as BuildOptions)).toMatchInlineSnapshot(`
30+
[
31+
{
32+
"buildId": "buildID",
33+
"fullPath": "/base/path/cache/buildID/path/to/2.cache",
34+
"isFetch": false,
35+
"key": "/path/to/2",
36+
},
37+
{
38+
"buildId": "buildID",
39+
"fullPath": "/base/path/cache/buildID/path/to/1.cache",
40+
"isFetch": false,
41+
"key": "/path/to/1",
42+
},
43+
{
44+
"buildId": "buildID",
45+
"fullPath": "/base/path/cache/buildID/path/to/0.cache",
46+
"isFetch": false,
47+
"key": "/path/to/0",
48+
},
49+
{
50+
"buildId": "buildID",
51+
"fullPath": "/base/path/cache/__fetch/buildID/2",
52+
"isFetch": true,
53+
"key": "/2",
54+
},
55+
{
56+
"buildId": "buildID",
57+
"fullPath": "/base/path/cache/__fetch/buildID/1",
58+
"isFetch": true,
59+
"key": "/1",
60+
},
61+
{
62+
"buildId": "buildID",
63+
"fullPath": "/base/path/cache/__fetch/buildID/0",
64+
"isFetch": true,
65+
"key": "/0",
66+
},
67+
]
68+
`);
69+
});
70+
});

packages/cloudflare/src/cli/commands/populate-cache.ts

Lines changed: 71 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { createHash } from "node:crypto";
12
import { cpSync, existsSync } from "node:fs";
23
import path from "node:path";
34

@@ -16,10 +17,12 @@ import { unstable_readConfig } from "wrangler";
1617

1718
import {
1819
BINDING_NAME as KV_CACHE_BINDING_NAME,
20+
computeCacheKey as computeKVCacheKey,
1921
NAME as KV_CACHE_NAME,
2022
} from "../../api/overrides/incremental-cache/kv-incremental-cache.js";
2123
import {
2224
BINDING_NAME as R2_CACHE_BINDING_NAME,
25+
computeCacheKey as computeR2CacheKey,
2326
DEFAULT_PREFIX as R2_CACHE_DEFAULT_PREFIX,
2427
NAME as R2_CACHE_NAME,
2528
PREFIX_ENV_NAME as R2_CACHE_PREFIX_ENV_NAME,
@@ -28,6 +31,7 @@ import {
2831
CACHE_DIR as STATIC_ASSETS_CACHE_DIR,
2932
NAME as STATIC_ASSETS_CACHE_NAME,
3033
} from "../../api/overrides/incremental-cache/static-assets-incremental-cache.js";
34+
import { CACHE_KEY_HASH } from "../../api/overrides/internal.js";
3135
import {
3236
BINDING_NAME as D1_TAG_BINDING_NAME,
3337
NAME as D1_TAG_NAME,
@@ -45,22 +49,52 @@ async function resolveCacheName(
4549
return typeof value === "function" ? (await value()).name : value;
4650
}
4751

48-
function getCacheAssetPaths(opts: BuildOptions) {
49-
return globSync(path.join(opts.outputDir, "cache/**/*"), {
52+
export type CacheAsset = { isFetch: boolean; fullPath: string; key: string; buildId: string };
53+
54+
export function getCacheAssets(opts: BuildOptions): CacheAsset[] {
55+
const allFiles = globSync(path.join(opts.outputDir, "cache/**/*"), {
5056
withFileTypes: true,
5157
windowsPathsNoEscape: true,
52-
})
53-
.filter((f) => f.isFile())
54-
.map((f) => {
55-
const relativePath = path.relative(path.join(opts.outputDir, "cache"), f.fullpathPosix());
56-
57-
return {
58-
fsPath: f.fullpathPosix(),
59-
destPath: relativePath.startsWith("__fetch")
60-
? `${relativePath.replace("__fetch/", "")}.fetch`
61-
: relativePath,
62-
};
63-
});
58+
}).filter((f) => f.isFile());
59+
60+
const assets: CacheAsset[] = [];
61+
62+
for (const file of allFiles) {
63+
const fullPath = file.fullpathPosix();
64+
const relativePath = path.relative(path.join(opts.outputDir, "cache"), fullPath);
65+
66+
if (relativePath.startsWith("__fetch")) {
67+
const [__fetch, buildId, ...keyParts] = relativePath.split("/");
68+
69+
if (__fetch !== "__fetch" || buildId === undefined || keyParts.length === 0) {
70+
throw new Error(`Invalid path for a Cache Asset file: ${relativePath}`);
71+
}
72+
73+
assets.push({
74+
isFetch: true,
75+
fullPath,
76+
key: `/${keyParts.join("/")}`,
77+
buildId,
78+
});
79+
} else {
80+
const [buildId, ...keyParts] = relativePath.slice(0, -".cache".length).split("/");
81+
82+
if (!relativePath.endsWith(".cache") || buildId === undefined || keyParts.length === 0) {
83+
throw new Error(
84+
`Invalid path for a Cache Asset file: ${[relativePath, relativePath.substring(0, -".cache".length), buildId, keyParts]}`
85+
);
86+
}
87+
88+
assets.push({
89+
isFetch: false,
90+
fullPath,
91+
key: `/${keyParts.join("/")}`,
92+
buildId,
93+
});
94+
}
95+
}
96+
97+
return assets;
6498
}
6599

66100
function populateR2IncrementalCache(
@@ -81,17 +115,21 @@ function populateR2IncrementalCache(
81115
throw new Error(`R2 binding ${JSON.stringify(R2_CACHE_BINDING_NAME)} should have a 'bucket_name'`);
82116
}
83117

84-
const assets = getCacheAssetPaths(options);
85-
for (const { fsPath, destPath } of tqdm(assets)) {
86-
const fullDestPath = path.join(
87-
bucket,
88-
process.env[R2_CACHE_PREFIX_ENV_NAME] ?? R2_CACHE_DEFAULT_PREFIX,
89-
destPath
90-
);
118+
const assets = getCacheAssets(options);
119+
120+
for (const { fullPath, key, buildId, isFetch } of tqdm(assets)) {
121+
const cacheKey = computeR2CacheKey(key, {
122+
directory: process.env[R2_CACHE_PREFIX_ENV_NAME] ?? R2_CACHE_DEFAULT_PREFIX,
123+
buildId,
124+
isFetch,
125+
hash: createHash(CACHE_KEY_HASH),
126+
});
127+
128+
console.error({ cacheKey, key });
91129

92130
runWrangler(
93131
options,
94-
["r2 object put", JSON.stringify(fullDestPath), `--file ${JSON.stringify(fsPath)}`],
132+
["r2 object put", JSON.stringify(path.join(bucket, cacheKey)), `--file ${JSON.stringify(fullPath)}`],
95133
// NOTE: R2 does not support the environment flag and results in the following error:
96134
// Incorrect type for the 'cacheExpiry' field on 'HttpMetadata': the provided value is not of type 'date'.
97135
{ target: populateCacheOptions.target, excludeRemoteFlag: true, logging: "error" }
@@ -113,15 +151,22 @@ function populateKVIncrementalCache(
113151
throw new Error(`No KV binding ${JSON.stringify(KV_CACHE_BINDING_NAME)} found!`);
114152
}
115153

116-
const assets = getCacheAssetPaths(options);
117-
for (const { fsPath, destPath } of tqdm(assets)) {
154+
const assets = getCacheAssets(options);
155+
156+
for (const { fullPath, key, buildId, isFetch } of tqdm(assets)) {
157+
const cacheKey = computeKVCacheKey(key, {
158+
buildId,
159+
isFetch,
160+
hash: createHash(CACHE_KEY_HASH),
161+
});
162+
118163
runWrangler(
119164
options,
120165
[
121166
"kv key put",
122-
JSON.stringify(destPath),
167+
JSON.stringify(cacheKey),
123168
`--binding ${JSON.stringify(KV_CACHE_BINDING_NAME)}`,
124-
`--path ${JSON.stringify(fsPath)}`,
169+
`--path ${JSON.stringify(fullPath)}`,
125170
],
126171
{ ...populateCacheOptions, logging: "error" }
127172
);

0 commit comments

Comments
 (0)