Skip to content

Commit 97e6e11

Browse files
committed
Add a basic KV cache handler
1 parent 42320e7 commit 97e6e11

20 files changed

+343
-218
lines changed

packages/cloudflare/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,11 @@
2929
},
3030
"homepage": "https://github.com/opennextjs/opennextjs-cloudflare",
3131
"devDependencies": {
32+
"@cloudflare/workers-types": "catalog:",
3233
"@types/node": "catalog:",
3334
"esbuild": "catalog:",
3435
"glob": "catalog:",
36+
"next": "catalog:",
3537
"tsup": "catalog:",
3638
"typescript": "catalog:",
3739
"vitest": "catalog:"

packages/cloudflare/src/build/build-worker.ts

Lines changed: 54 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,47 +1,66 @@
1-
import { NextjsAppPaths } from "../nextjs-paths";
1+
import { Config } from "../config";
22
import { build, Plugin } from "esbuild";
3-
import { existsSync, readFileSync } from "node:fs";
3+
import { existsSync, readFileSync, cpSync } from "node:fs";
44
import { cp, readFile, writeFile } from "node:fs/promises";
55
import path from "node:path";
66
import { fileURLToPath } from "node:url";
77

88
import { patchRequire } from "./patches/investigated/patch-require";
9-
import { copyTemplates } from "./patches/investigated/copy-templates";
9+
import { copyPackage } from "./patches/investigated/copy-package";
1010

1111
import { patchReadFile } from "./patches/to-investigate/patch-read-file";
1212
import { patchFindDir } from "./patches/to-investigate/patch-find-dir";
1313
import { inlineNextRequire } from "./patches/to-investigate/inline-next-require";
1414
import { inlineEvalManifest } from "./patches/to-investigate/inline-eval-manifest";
1515
import { patchWranglerDeps } from "./patches/to-investigate/wrangler-deps";
1616
import { updateWebpackChunksFile } from "./patches/investigated/update-webpack-chunks-file";
17+
import { patchCache } from "./patches/investigated/patch-cache";
1718

1819
/** The directory containing the Cloudflare template files. */
19-
const templateSrcDir = path.join(path.dirname(fileURLToPath(import.meta.url)), "templates");
20+
const packageDir = path.dirname(fileURLToPath(import.meta.url));
2021

2122
/**
2223
* Using the Next.js build output in the `.next` directory builds a workerd compatible output
2324
*
2425
* @param outputDir the directory where to save the output
25-
* @param nextjsAppPaths
26+
* @param config
2627
*/
27-
export async function buildWorker(
28-
appDir: string,
29-
outputDir: string,
30-
nextjsAppPaths: NextjsAppPaths
31-
): Promise<void> {
32-
const templateDir = copyTemplates(templateSrcDir, nextjsAppPaths);
33-
34-
const workerEntrypoint = `${templateDir}/worker.ts`;
35-
const workerOutputFile = `${outputDir}/index.mjs`;
28+
export async function buildWorker(config: Config): Promise<void> {
29+
console.log(`\x1b[35m⚙️ Copying files...\n\x1b[0m`);
30+
31+
// Copy over client-side generated files
32+
await cp(
33+
path.join(config.paths.dotNext, "static"),
34+
path.join(config.paths.builderOutput, "assets", "_next", "static"),
35+
{
36+
recursive: true,
37+
}
38+
);
39+
40+
// Copy over any static files (e.g. images) from the source project
41+
const publicDir = path.join(config.paths.nextApp, "public");
42+
if (existsSync(publicDir)) {
43+
await cp(publicDir, path.join(config.paths.builderOutput, "assets"), {
44+
recursive: true,
45+
});
46+
}
47+
48+
copyPackage(packageDir, config);
49+
50+
const templateDir = path.join(config.paths.internalPackage, "templates");
51+
52+
const workerEntrypoint = path.join(templateDir, "worker.ts");
53+
const workerOutputFile = path.join(config.paths.builderOutput, "index.mjs");
54+
3655
const nextConfigStr =
37-
readFileSync(nextjsAppPaths.standaloneAppDir + "/server.js", "utf8")?.match(
56+
readFileSync(path.join(config.paths.standaloneApp, "/server.js"), "utf8")?.match(
3857
/const nextConfig = ({.+?})\n/
3958
)?.[1] ?? {};
4059

4160
console.log(`\x1b[35m⚙️ Bundling the worker file...\n\x1b[0m`);
4261

43-
patchWranglerDeps(nextjsAppPaths);
44-
updateWebpackChunksFile(nextjsAppPaths);
62+
patchWranglerDeps(config);
63+
updateWebpackChunksFile(config);
4564

4665
await build({
4766
entryPoints: [workerEntrypoint],
@@ -55,15 +74,15 @@ export async function buildWorker(
5574
// Note: we apply an empty shim to next/dist/compiled/ws because it generates two `eval`s:
5675
// eval("require")("bufferutil");
5776
// eval("require")("utf-8-validate");
58-
"next/dist/compiled/ws": `${templateDir}/shims/empty.ts`,
77+
"next/dist/compiled/ws": path.join(templateDir, "shims", "empty.ts"),
5978
// Note: we apply an empty shim to next/dist/compiled/edge-runtime since (amongst others) it generated the following `eval`:
6079
// eval(getModuleCode)(module, module.exports, throwingRequire, params.context, ...Object.values(params.scopedContext));
6180
// which comes from https://github.com/vercel/edge-runtime/blob/6e96b55f/packages/primitives/src/primitives/load.js#L57-L63
6281
// QUESTION: Why did I encountered this but mhart didn't?
63-
"next/dist/compiled/edge-runtime": `${templateDir}/shims/empty.ts`,
82+
"next/dist/compiled/edge-runtime": path.join(templateDir, "shims", "empty.ts"),
6483
// `@next/env` is a library Next.js uses for loading dotenv files, for obvious reasons we need to stub it here
6584
// source: https://github.com/vercel/next.js/tree/0ac10d79720/packages/next-env
66-
"@next/env": `${templateDir}/shims/env.ts`,
85+
"@next/env": path.join(templateDir, "shims", "env.ts"),
6786
},
6887
define: {
6988
// config file used by Next.js, see: https://github.com/vercel/next.js/blob/68a7128/packages/next/src/build/utils.ts#L2137-L2139
@@ -98,22 +117,21 @@ export async function buildWorker(
98117
// Do not crash on cache not supported
99118
// https://github.com/cloudflare/workerd/pull/2434
100119
// compatibility flag "cache_option_enabled" -> does not support "force-cache"
101-
let isPatchedAlready = globalThis.fetch.__nextPatched;
102120
const curFetch = globalThis.fetch;
103121
globalThis.fetch = (input, init) => {
104-
console.log("globalThis.fetch", input);
105-
if (init) delete init.cache;
122+
if (init) {
123+
delete init.cache;
124+
}
106125
return curFetch(input, init);
107126
};
108127
import { Readable } from 'node:stream';
109-
globalThis.fetch.__nextPatched = isPatchedAlready;
110128
fetch = globalThis.fetch;
111129
const CustomRequest = class extends globalThis.Request {
112130
constructor(input, init) {
113-
console.log("CustomRequest", input);
114131
if (init) {
115132
delete init.cache;
116133
if (init.body?.__node_stream__ === true) {
134+
// https://github.com/cloudflare/workerd/issues/2746
117135
init.body = Readable.toWeb(init.body);
118136
}
119137
}
@@ -122,25 +140,11 @@ const CustomRequest = class extends globalThis.Request {
122140
};
123141
globalThis.Request = CustomRequest;
124142
Request = globalThis.Request;
125-
`,
143+
`,
126144
},
127145
});
128146

129-
await updateWorkerBundledCode(workerOutputFile, nextjsAppPaths);
130-
131-
console.log(`\x1b[35m⚙️ Copying asset files...\n\x1b[0m`);
132-
133-
// Copy over client-side generated files
134-
await cp(`${nextjsAppPaths.dotNextDir}/static`, `${outputDir}/assets/_next/static`, {
135-
recursive: true,
136-
});
137-
138-
// Copy over any static files (e.g. images) from the source project
139-
if (existsSync(`${appDir}/public`)) {
140-
await cp(`${appDir}/public`, `${outputDir}/assets`, {
141-
recursive: true,
142-
});
143-
}
147+
await updateWorkerBundledCode(workerOutputFile, config);
144148

145149
console.log(`\x1b[35mWorker saved in \`${workerOutputFile}\` 🚀\n\x1b[0m`);
146150
}
@@ -151,21 +155,19 @@ Request = globalThis.Request;
151155
* Needless to say all the logic in this function is something we should avoid as much as possible!
152156
*
153157
* @param workerOutputFile
154-
* @param nextjsAppPaths
158+
* @param config
155159
*/
156-
async function updateWorkerBundledCode(
157-
workerOutputFile: string,
158-
nextjsAppPaths: NextjsAppPaths
159-
): Promise<void> {
160+
async function updateWorkerBundledCode(workerOutputFile: string, config: Config): Promise<void> {
160161
const originalCode = await readFile(workerOutputFile, "utf8");
161162

162163
let patchedCode = originalCode;
163164

164165
patchedCode = patchRequire(patchedCode);
165-
patchedCode = patchReadFile(patchedCode, nextjsAppPaths);
166-
patchedCode = inlineNextRequire(patchedCode, nextjsAppPaths);
167-
patchedCode = patchFindDir(patchedCode, nextjsAppPaths);
168-
patchedCode = inlineEvalManifest(patchedCode, nextjsAppPaths);
166+
patchedCode = patchReadFile(patchedCode, config);
167+
patchedCode = inlineNextRequire(patchedCode, config);
168+
patchedCode = patchFindDir(patchedCode, config);
169+
patchedCode = inlineEvalManifest(patchedCode, config);
170+
patchedCode = patchCache(patchedCode, config);
169171

170172
await writeFile(workerOutputFile, patchedCode);
171173
}
@@ -176,10 +178,10 @@ function createFixRequiresESBuildPlugin(templateDir: string): Plugin {
176178
setup(build) {
177179
// Note: we (empty) shim require-hook modules as they generate problematic code that uses requires
178180
build.onResolve({ filter: /^\.\/require-hook$/ }, (args) => ({
179-
path: `${templateDir}/shims/empty.ts`,
181+
path: path.join(templateDir, "shims", "empty.ts"),
180182
}));
181183
build.onResolve({ filter: /\.\/lib\/node-fs-methods$/ }, (args) => ({
182-
path: `${templateDir}/shims/node-fs.ts`,
184+
path: path.join(templateDir, "shims", "empty.ts"),
183185
}));
184186
},
185187
};

packages/cloudflare/src/build/build.ts

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import { rm } from "node:fs/promises";
22
import { buildNextjsApp } from "./build-next-app";
33
import { buildWorker } from "./build-worker";
4-
import { getNextjsAppPaths } from "../nextjs-paths";
4+
import { containsDotNextDir, getConfig } from "../config";
55
import { cpSync } from "node:fs";
6-
import { resolve } from "node:path";
6+
import path from "node:path";
77

88
/**
99
* Builds the application in a format that can be passed to workerd
@@ -20,15 +20,20 @@ export async function build(appDir: string, opts: BuildOptions): Promise<void> {
2020
buildNextjsApp(appDir);
2121
}
2222

23+
if (!containsDotNextDir(appDir)) {
24+
throw new Error(`.next folder not found in ${appDir}`);
25+
}
26+
2327
// Create a clean output directory
24-
const outputDir = resolve(opts.outputDir ?? appDir, ".worker-next");
28+
const outputDir = path.resolve(opts.outputDir ?? appDir, ".worker-next");
2529
await cleanDirectory(outputDir);
2630

2731
// Copy the .next directory to the output directory so it can be mutated.
28-
cpSync(resolve(`${appDir}/.next`), resolve(`${outputDir}/.next`), { recursive: true });
29-
const nextjsAppPaths = getNextjsAppPaths(outputDir);
32+
cpSync(path.join(appDir, ".next"), path.join(outputDir, ".next"), { recursive: true });
33+
34+
const config = getConfig(appDir, outputDir);
3035

31-
await buildWorker(appDir, outputDir, nextjsAppPaths);
36+
await buildWorker(config);
3237
}
3338

3439
type BuildOptions = {
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import path from "node:path";
2+
import { Config } from "../../../config";
3+
import { cpSync } from "node:fs";
4+
5+
/**
6+
* Copy the builder package in the standalone node_modules folder.
7+
*/
8+
export function copyPackage(srcDir: string, config: Config) {
9+
console.log("# copyPackage");
10+
cpSync(srcDir, config.paths.internalPackage, { recursive: true });
11+
}

packages/cloudflare/src/build/patches/investigated/copy-templates.ts

Lines changed: 0 additions & 17 deletions
This file was deleted.
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import path from "node:path";
2+
import { Config } from "../../../config";
3+
4+
/**
5+
* Install the cloudflare KV cache handler
6+
*/
7+
export function patchCache(code: string, config: Config): string {
8+
console.log("# patchCached");
9+
10+
const cacheHandler = path.join(config.paths.internalPackage, "cache-handler.mjs");
11+
12+
const patchedCode = code.replace(
13+
"const { cacheHandler } = this.nextConfig;",
14+
`const cacheHandler = null;
15+
CacheHandler = (await import('${cacheHandler}')).default;
16+
CacheHandler.maybeKVNamespace = process.env["${config.cache.bindingName}"];
17+
`
18+
);
19+
20+
if (patchedCode === code) {
21+
throw new Error("Cache patch not applied");
22+
}
23+
24+
return patchedCode;
25+
}

packages/cloudflare/src/build/patches/investigated/update-webpack-chunks-file/index.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { readdirSync, readFileSync, writeFileSync } from "node:fs";
2+
import path from "node:path";
23

3-
import { NextjsAppPaths } from "../../../../nextjs-paths";
4+
import { Config } from "../../../../config";
45
import { getUpdatedWebpackChunksFileContent } from "./get-updated-webpack-chunks-file-content";
56

67
/**
@@ -9,13 +10,13 @@ import { getUpdatedWebpackChunksFileContent } from "./get-updated-webpack-chunks
910
* This hack is particularly bad as it indicates that files inside the output directory still get a hold of files from the outside: `${nextjsAppPaths.standaloneAppServerDir}/webpack-runtime.js`
1011
* so this shows that not everything that's needed to deploy the application is in the output directory...
1112
*/
12-
export async function updateWebpackChunksFile(nextjsAppPaths: NextjsAppPaths) {
13+
export async function updateWebpackChunksFile(config: Config) {
1314
console.log("# updateWebpackChunksFile");
14-
const webpackRuntimeFile = `${nextjsAppPaths.standaloneAppServerDir}/webpack-runtime.js`;
15+
const webpackRuntimeFile = path.join(config.paths.standaloneAppServer, "webpack-runtime.js");
1516

1617
const fileContent = readFileSync(webpackRuntimeFile, "utf-8");
1718

18-
const chunks = readdirSync(`${nextjsAppPaths.standaloneAppServerDir}/chunks`)
19+
const chunks = readdirSync(path.join(config.paths.standaloneAppServer, "chunks"))
1920
.filter((chunk) => /^\d+\.js$/.test(chunk))
2021
.map((chunk) => {
2122
console.log(` - chunk ${chunk}`);

packages/cloudflare/src/build/patches/to-investigate/inline-eval-manifest.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { globSync } from "glob";
2-
import { NextjsAppPaths } from "../../../nextjs-paths";
2+
import path from "node:path";
3+
import { Config } from "../../../config";
34

45
/**
56
* `evalManifest` relies on readFileSync so we need to patch the function so that it instead returns the content of the manifest files
@@ -8,19 +9,19 @@ import { NextjsAppPaths } from "../../../nextjs-paths";
89
* Note: we could/should probably just patch readFileSync here or something, but here the issue is that after the readFileSync call
910
* there is a vm `runInNewContext` call which we also don't support (source: https://github.com/vercel/next.js/blob/b1e32c5d1f/packages/next/src/server/load-manifest.ts#L88)
1011
*/
11-
export function inlineEvalManifest(code: string, nextjsAppPaths: NextjsAppPaths): string {
12+
export function inlineEvalManifest(code: string, config: Config): string {
1213
console.log("# inlineEvalManifest");
1314
const manifestJss = globSync(
14-
`${nextjsAppPaths.standaloneAppDotNextDir}/**/*_client-reference-manifest.js`
15-
).map((file) => file.replace(`${nextjsAppPaths.standaloneAppDir}/`, ""));
15+
path.join(config.paths.standaloneAppDotNext, "**", "*_client-reference-manifest.js")
16+
).map((file) => file.replace(`${config.paths.standaloneApp}/`, ""));
1617
return code.replace(
1718
/function evalManifest\((.+?), .+?\) {/,
1819
`$&
1920
${manifestJss
2021
.map(
2122
(manifestJs) => `
2223
if ($1.endsWith("${manifestJs}")) {
23-
require("${nextjsAppPaths.standaloneAppDir}/${manifestJs}");
24+
require("${path.join(config.paths.standaloneApp, manifestJs)}");
2425
return {
2526
__RSC_MANIFEST: {
2627
"${manifestJs

0 commit comments

Comments
 (0)