Skip to content

Commit ff9c04e

Browse files
committed
feat: d1 adapter for the tag cache
1 parent 46a5944 commit ff9c04e

File tree

13 files changed

+237
-14
lines changed

13 files changed

+237
-14
lines changed

.changeset/five-balloons-walk.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@opennextjs/cloudflare": minor
3+
---
4+
5+
feat: d1 adapter for the tag cache

examples/e2e/app-router/e2e/after.test.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
import { expect, test } from "@playwright/test";
22

3-
// Cache is currently not supported: https://github.com/opennextjs/opennextjs-cloudflare/issues/105
4-
// (Note: specifically this test relied on `unstable_cache`: https://github.com/opennextjs/opennextjs-cloudflare/issues/105#issuecomment-2627074820)
5-
test.skip("Next after", async ({ request }) => {
3+
test("Next after", async ({ request }) => {
64
const initialSSG = await request.get("/api/after/ssg");
75
expect(initialSSG.status()).toEqual(200);
86
const initialSSGJson = await initialSSG.json();

examples/e2e/app-router/e2e/revalidateTag.test.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { expect, test } from "@playwright/test";
22

3-
// Cache (and revalidateTag) is currently not supported: https://github.com/opennextjs/opennextjs-cloudflare/issues/105
4-
test.skip("Revalidate tag", async ({ page, request }) => {
3+
test("Revalidate tag", async ({ page, request }) => {
54
test.setTimeout(45000);
65
// We need to hit the page twice to make sure it's properly cached
76
// Turbo might cache next build result, resulting in the tag being newer than the page
@@ -69,8 +68,7 @@ test.skip("Revalidate tag", async ({ page, request }) => {
6968
expect(nextCacheHeaderNested).toEqual("HIT");
7069
});
7170

72-
// Cache (and revalidatePath) is currently not supported: https://github.com/opennextjs/opennextjs-cloudflare/issues/105
73-
test.skip("Revalidate path", async ({ page, request }) => {
71+
test("Revalidate path", async ({ page, request }) => {
7472
await page.goto("/revalidate-path");
7573

7674
let elLayout = page.getByText("RequestID:");

examples/e2e/app-router/open-next.config.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
11
import type { OpenNextConfig } from "@opennextjs/aws/types/open-next.js";
2-
import cache from "@opennextjs/cloudflare/kvCache";
2+
import incrementalCache from "@opennextjs/cloudflare/kvCache";
3+
import tagCache from "@opennextjs/cloudflare/d1-tag-cache";
34

45
const config: OpenNextConfig = {
56
default: {
67
override: {
78
wrapper: "cloudflare-node",
89
converter: "edge",
9-
incrementalCache: async () => cache,
10+
incrementalCache: () => incrementalCache,
11+
tagCache: () => tagCache,
1012
queue: "direct",
11-
// Unused implementation
12-
tagCache: "dummy",
1313
},
1414
},
1515

examples/e2e/app-router/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,10 @@
99
"start": "next start --port 3001",
1010
"lint": "next lint",
1111
"clean": "rm -rf .turbo node_modules .next .open-next",
12+
"setup:d1": "wrangler d1 execute NEXT_CACHE_D1 --command \"CREATE TABLE IF NOT EXISTS tags (tag TEXT NOT NULL, path TEXT NOT NULL, revalidatedAt INTEGER NOT NULL, UNIQUE(tag, path) ON CONFLICT REPLACE)\"",
1213
"build:worker": "pnpm opennextjs-cloudflare",
1314
"preview": "pnpm build:worker && pnpm wrangler dev",
14-
"e2e": "playwright test -c e2e/playwright.config.ts"
15+
"e2e": "pnpm setup:d1 && playwright test -c e2e/playwright.config.ts"
1516
},
1617
"dependencies": {
1718
"@opennextjs/cloudflare": "workspace:*",

examples/e2e/app-router/wrangler.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,5 +13,12 @@
1313
"binding": "NEXT_CACHE_WORKERS_KV",
1414
"id": "<BINDING_ID>"
1515
}
16+
],
17+
"d1_databases": [
18+
{
19+
"binding": "NEXT_CACHE_D1",
20+
"database_id": "NEXT_CACHE_D1",
21+
"database_name": "NEXT_CACHE_D1"
22+
}
1623
]
1724
}

packages/cloudflare/env.d.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { CacheAssetsManifest } from "./src/cli/build/open-next/create-cache-assets-manifest.js";
2+
13
declare global {
24
namespace NodeJS {
35
interface ProcessEnv {
@@ -7,6 +9,7 @@ declare global {
79
NEXT_PRIVATE_DEBUG_CACHE?: string;
810
OPEN_NEXT_ORIGIN: string;
911
NODE_ENV?: string;
12+
__OPENNEXT_CACHE_TAGS_MANIFEST: CacheAssetsManifest;
1013
}
1114
}
1215
}

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import type { Context, RunningCodeOptions } from "node:vm";
33
declare global {
44
interface CloudflareEnv {
55
NEXT_CACHE_WORKERS_KV?: KVNamespace;
6+
NEXT_CACHE_D1?: D1Database;
7+
NEXT_CACHE_D1_TABLE?: string;
68
ASSETS?: Fetcher;
79
}
810
}
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
import { debug, error } from "@opennextjs/aws/adapters/logger.js";
2+
import type { OpenNextConfig } from "@opennextjs/aws/types/open-next.js";
3+
import type { TagCache } from "@opennextjs/aws/types/overrides.js";
4+
5+
import { getCloudflareContext } from "./cloudflare-context.js";
6+
7+
// inlined during build
8+
const manifest = process.env.__OPENNEXT_CACHE_TAGS_MANIFEST;
9+
10+
class D1TagCache implements TagCache {
11+
public name = "d1-tag-cache";
12+
13+
public async getByPath(rawPath: string): Promise<string[]> {
14+
const { isDisabled, db, table } = this.getConfig();
15+
if (isDisabled) return [];
16+
17+
const path = this.getCacheKey(rawPath);
18+
19+
try {
20+
const { success, results } = await db
21+
.prepare(`SELECT tag FROM ${table} WHERE path = ?`)
22+
.bind(path)
23+
.all<{ tag: string }>();
24+
25+
if (!success) throw new Error(`D1 select failed for ${path}`);
26+
27+
const tags = this.mergeTagArrays(
28+
manifest.paths[path],
29+
results?.map((item) => item.tag)
30+
);
31+
32+
debug("tags for path", path, tags);
33+
return tags;
34+
} catch (e) {
35+
error("Failed to get tags by path", e);
36+
return [];
37+
}
38+
}
39+
40+
public async getByTag(rawTag: string): Promise<string[]> {
41+
const { isDisabled, db, table } = this.getConfig();
42+
if (isDisabled) return [];
43+
44+
const tag = this.getCacheKey(rawTag);
45+
46+
try {
47+
const { success, results } = await db
48+
.prepare(`SELECT path FROM ${table} WHERE tag = ?`)
49+
.bind(tag)
50+
.all<{ path: string }>();
51+
52+
if (!success) throw new Error(`D1 select failed for ${tag}`);
53+
54+
const paths = this.mergeTagArrays(
55+
manifest.tags[tag],
56+
results?.map((item) => item.path)
57+
);
58+
59+
debug("paths for tag", tag, paths);
60+
return paths;
61+
} catch (e) {
62+
error("Failed to get by tag", e);
63+
return [];
64+
}
65+
}
66+
67+
public async getLastModified(path: string, lastModified?: number): Promise<number> {
68+
const { isDisabled, db, table } = this.getConfig();
69+
if (isDisabled) return lastModified ?? Date.now();
70+
71+
try {
72+
const { success, results } = await db
73+
.prepare(`SELECT tag FROM ${table} WHERE path = ? AND revalidatedAt > ?`)
74+
.bind(this.getCacheKey(path), lastModified ?? 0)
75+
.all<{ tag: string }>();
76+
77+
if (!success) throw new Error(`D1 select failed for ${path} - ${lastModified ?? 0}`);
78+
79+
const tags = results?.map((item) => this.removeBuildId(item.tag)) ?? [];
80+
debug("revalidatedTags", tags);
81+
return tags.length > 0 ? -1 : (lastModified ?? Date.now());
82+
} catch (e) {
83+
error("Failed to get revalidated tags", e);
84+
return lastModified ?? Date.now();
85+
}
86+
}
87+
88+
public async writeTags(tags: { tag: string; path: string; revalidatedAt?: number }[]): Promise<void> {
89+
const { isDisabled, db, table } = this.getConfig();
90+
if (isDisabled || tags.length === 0) return;
91+
92+
try {
93+
const results = await db.batch(
94+
tags.map(({ tag, path, revalidatedAt }) =>
95+
db
96+
.prepare(`INSERT INTO ${table} (tag, path, revalidatedAt) VALUES(?, ?, ?)`)
97+
.bind(this.getCacheKey(tag), this.getCacheKey(path), revalidatedAt ?? Date.now())
98+
)
99+
);
100+
101+
const failedResults = results.filter((res) => !res.success);
102+
103+
if (failedResults.length > 0) {
104+
throw new Error(`${failedResults.length} tags failed to write`);
105+
}
106+
} catch (e) {
107+
error("Failed to batch write tags", e);
108+
}
109+
}
110+
111+
private getConfig() {
112+
const cfEnv = getCloudflareContext().env;
113+
const db = cfEnv.NEXT_CACHE_D1;
114+
const table = cfEnv.NEXT_CACHE_D1_TABLE ?? "tags";
115+
116+
if (!db) debug("No D1 database found");
117+
118+
const isDisabled = !!(globalThis as unknown as { openNextConfig: OpenNextConfig }).openNextConfig
119+
.dangerous?.disableTagCache;
120+
121+
if (!db || isDisabled) {
122+
return { isDisabled: true as const };
123+
}
124+
125+
return { isDisabled: false as const, db, table };
126+
}
127+
128+
protected removeBuildId(key: string) {
129+
return key.replace(`${this.getBuildId()}/`, "");
130+
}
131+
132+
protected getCacheKey(key: string) {
133+
return `${this.getBuildId()}/${key}`.replaceAll("//", "/");
134+
}
135+
136+
protected getBuildId() {
137+
return process.env.NEXT_BUILD_ID ?? "no-build-id";
138+
}
139+
140+
protected mergeTagArrays(...arrays: (string[] | undefined)[]) {
141+
const set = new Set<string>();
142+
143+
for (const arr of arrays) {
144+
arr?.forEach((v) => set.add(this.removeBuildId(v)));
145+
}
146+
147+
return [...set.values()];
148+
}
149+
}
150+
151+
export default new D1TagCache();

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { handleOptionalDependencies } from "./patches/plugins/optional-deps.js";
1414
import { fixRequire } from "./patches/plugins/require.js";
1515
import { inlineRequirePagePlugin } from "./patches/plugins/require-page.js";
1616
import { setWranglerExternal } from "./patches/plugins/wrangler-external.js";
17+
import { extractCacheAssetsManifest } from "./utils/extract-cache-assets-manifest.js";
1718
import { normalizePath, patchCodeWithValidations } from "./utils/index.js";
1819

1920
/** The dist directory of the Cloudflare adapter package */
@@ -113,6 +114,7 @@ export async function bundleServer(buildOpts: BuildOptions): Promise<void> {
113114
"process.env.NEXT_RUNTIME": '"nodejs"',
114115
"process.env.NODE_ENV": '"production"',
115116
"process.env.NEXT_MINIMAL": "true",
117+
"process.env.__OPENNEXT_CACHE_TAGS_MANIFEST": `${JSON.stringify(extractCacheAssetsManifest(buildOpts))}`,
116118
},
117119
platform: "node",
118120
banner: {

0 commit comments

Comments
 (0)