Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/cloudflare/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,11 @@
},
"homepage": "https://github.com/opennextjs/opennextjs-cloudflare",
"devDependencies": {
"@cloudflare/workers-types": "catalog:",
"@types/node": "catalog:",
"esbuild": "catalog:",
"glob": "catalog:",
"next": "catalog:",
"tsup": "catalog:",
"typescript": "catalog:",
"vitest": "catalog:"
Expand Down
106 changes: 54 additions & 52 deletions packages/cloudflare/src/build/build-worker.ts
Original file line number Diff line number Diff line change
@@ -1,47 +1,66 @@
import { NextjsAppPaths } from "../nextjs-paths";
import { Config } from "../config";
import { build, Plugin } from "esbuild";
import { existsSync, readFileSync } from "node:fs";
import { existsSync, readFileSync, cpSync } from "node:fs";
import { cp, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";

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

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

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

/**
* Using the Next.js build output in the `.next` directory builds a workerd compatible output
*
* @param outputDir the directory where to save the output
* @param nextjsAppPaths
* @param config
*/
export async function buildWorker(
appDir: string,
outputDir: string,
nextjsAppPaths: NextjsAppPaths
): Promise<void> {
const templateDir = copyTemplates(templateSrcDir, nextjsAppPaths);

const workerEntrypoint = `${templateDir}/worker.ts`;
const workerOutputFile = `${outputDir}/index.mjs`;
export async function buildWorker(config: Config): Promise<void> {
console.log(`\x1b[35m⚙️ Copying files...\n\x1b[0m`);

// Copy over client-side generated files
await cp(
path.join(config.paths.dotNext, "static"),
path.join(config.paths.builderOutput, "assets", "_next", "static"),
{
recursive: true,
}
);

// Copy over any static files (e.g. images) from the source project
const publicDir = path.join(config.paths.nextApp, "public");
if (existsSync(publicDir)) {
await cp(publicDir, path.join(config.paths.builderOutput, "assets"), {
recursive: true,
});
}

copyPackage(packageDir, config);

const templateDir = path.join(config.paths.internalPackage, "templates");

const workerEntrypoint = path.join(templateDir, "worker.ts");
const workerOutputFile = path.join(config.paths.builderOutput, "index.mjs");

const nextConfigStr =
readFileSync(nextjsAppPaths.standaloneAppDir + "/server.js", "utf8")?.match(
readFileSync(path.join(config.paths.standaloneApp, "/server.js"), "utf8")?.match(
/const nextConfig = ({.+?})\n/
)?.[1] ?? {};

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

patchWranglerDeps(nextjsAppPaths);
updateWebpackChunksFile(nextjsAppPaths);
patchWranglerDeps(config);
updateWebpackChunksFile(config);

await build({
entryPoints: [workerEntrypoint],
Expand All @@ -55,15 +74,15 @@ export async function buildWorker(
// Note: we apply an empty shim to next/dist/compiled/ws because it generates two `eval`s:
// eval("require")("bufferutil");
// eval("require")("utf-8-validate");
"next/dist/compiled/ws": `${templateDir}/shims/empty.ts`,
"next/dist/compiled/ws": path.join(templateDir, "shims", "empty.ts"),
// Note: we apply an empty shim to next/dist/compiled/edge-runtime since (amongst others) it generated the following `eval`:
// eval(getModuleCode)(module, module.exports, throwingRequire, params.context, ...Object.values(params.scopedContext));
// which comes from https://github.com/vercel/edge-runtime/blob/6e96b55f/packages/primitives/src/primitives/load.js#L57-L63
// QUESTION: Why did I encountered this but mhart didn't?
"next/dist/compiled/edge-runtime": `${templateDir}/shims/empty.ts`,
"next/dist/compiled/edge-runtime": path.join(templateDir, "shims", "empty.ts"),
// `@next/env` is a library Next.js uses for loading dotenv files, for obvious reasons we need to stub it here
// source: https://github.com/vercel/next.js/tree/0ac10d79720/packages/next-env
"@next/env": `${templateDir}/shims/env.ts`,
"@next/env": path.join(templateDir, "shims", "env.ts"),
},
define: {
// config file used by Next.js, see: https://github.com/vercel/next.js/blob/68a7128/packages/next/src/build/utils.ts#L2137-L2139
Expand Down Expand Up @@ -98,22 +117,21 @@ export async function buildWorker(
// Do not crash on cache not supported
// https://github.com/cloudflare/workerd/pull/2434
// compatibility flag "cache_option_enabled" -> does not support "force-cache"
let isPatchedAlready = globalThis.fetch.__nextPatched;
const curFetch = globalThis.fetch;
globalThis.fetch = (input, init) => {
console.log("globalThis.fetch", input);
if (init) delete init.cache;
if (init) {
delete init.cache;
}
return curFetch(input, init);
};
import { Readable } from 'node:stream';
globalThis.fetch.__nextPatched = isPatchedAlready;
fetch = globalThis.fetch;
const CustomRequest = class extends globalThis.Request {
constructor(input, init) {
console.log("CustomRequest", input);
if (init) {
delete init.cache;
if (init.body?.__node_stream__ === true) {
// https://github.com/cloudflare/workerd/issues/2746
init.body = Readable.toWeb(init.body);
}
}
Expand All @@ -122,25 +140,11 @@ const CustomRequest = class extends globalThis.Request {
};
globalThis.Request = CustomRequest;
Request = globalThis.Request;
`,
`,
},
});

await updateWorkerBundledCode(workerOutputFile, nextjsAppPaths);

console.log(`\x1b[35m⚙️ Copying asset files...\n\x1b[0m`);

// Copy over client-side generated files
await cp(`${nextjsAppPaths.dotNextDir}/static`, `${outputDir}/assets/_next/static`, {
recursive: true,
});

// Copy over any static files (e.g. images) from the source project
if (existsSync(`${appDir}/public`)) {
await cp(`${appDir}/public`, `${outputDir}/assets`, {
recursive: true,
});
}
await updateWorkerBundledCode(workerOutputFile, config);

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

let patchedCode = originalCode;

patchedCode = patchRequire(patchedCode);
patchedCode = patchReadFile(patchedCode, nextjsAppPaths);
patchedCode = inlineNextRequire(patchedCode, nextjsAppPaths);
patchedCode = patchFindDir(patchedCode, nextjsAppPaths);
patchedCode = inlineEvalManifest(patchedCode, nextjsAppPaths);
patchedCode = patchReadFile(patchedCode, config);
patchedCode = inlineNextRequire(patchedCode, config);
patchedCode = patchFindDir(patchedCode, config);
patchedCode = inlineEvalManifest(patchedCode, config);
patchedCode = patchCache(patchedCode, config);

await writeFile(workerOutputFile, patchedCode);
}
Expand All @@ -176,10 +178,10 @@ function createFixRequiresESBuildPlugin(templateDir: string): Plugin {
setup(build) {
// Note: we (empty) shim require-hook modules as they generate problematic code that uses requires
build.onResolve({ filter: /^\.\/require-hook$/ }, (args) => ({
path: `${templateDir}/shims/empty.ts`,
path: path.join(templateDir, "shims", "empty.ts"),
}));
build.onResolve({ filter: /\.\/lib\/node-fs-methods$/ }, (args) => ({
path: `${templateDir}/shims/node-fs.ts`,
path: path.join(templateDir, "shims", "empty.ts"),
}));
},
};
Expand Down
17 changes: 11 additions & 6 deletions packages/cloudflare/src/build/build.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { rm } from "node:fs/promises";
import { buildNextjsApp } from "./build-next-app";
import { buildWorker } from "./build-worker";
import { getNextjsAppPaths } from "../nextjs-paths";
import { containsDotNextDir, getConfig } from "../config";
import { cpSync } from "node:fs";
import { resolve } from "node:path";
import path from "node:path";

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

if (!containsDotNextDir(appDir)) {
throw new Error(`.next folder not found in ${appDir}`);
}

// Create a clean output directory
const outputDir = resolve(opts.outputDir ?? appDir, ".worker-next");
const outputDir = path.resolve(opts.outputDir ?? appDir, ".worker-next");
await cleanDirectory(outputDir);

// Copy the .next directory to the output directory so it can be mutated.
cpSync(resolve(`${appDir}/.next`), resolve(`${outputDir}/.next`), { recursive: true });
const nextjsAppPaths = getNextjsAppPaths(outputDir);
cpSync(path.join(appDir, ".next"), path.join(outputDir, ".next"), { recursive: true });

const config = getConfig(appDir, outputDir);

await buildWorker(appDir, outputDir, nextjsAppPaths);
await buildWorker(config);
}

type BuildOptions = {
Expand Down
11 changes: 11 additions & 0 deletions packages/cloudflare/src/build/patches/investigated/copy-package.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import path from "node:path";
import { Config } from "../../../config";
import { cpSync } from "node:fs";

/**
* Copy the builder package in the standalone node_modules folder.
*/
export function copyPackage(srcDir: string, config: Config) {
console.log("# copyPackage");
cpSync(srcDir, config.paths.internalPackage, { recursive: true });
}

This file was deleted.

25 changes: 25 additions & 0 deletions packages/cloudflare/src/build/patches/investigated/patch-cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import path from "node:path";
import { Config } from "../../../config";

/**
* Install the cloudflare KV cache handler
*/
export function patchCache(code: string, config: Config): string {
console.log("# patchCached");

const cacheHandler = path.join(config.paths.internalPackage, "cache-handler.mjs");

const patchedCode = code.replace(
"const { cacheHandler } = this.nextConfig;",
`const cacheHandler = null;
CacheHandler = (await import('${cacheHandler}')).default;
CacheHandler.maybeKVNamespace = process.env["${config.cache.kvBindingName}"];
`
);

if (patchedCode === code) {
throw new Error("Cache patch not applied");
}

return patchedCode;
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { readdirSync, readFileSync, writeFileSync } from "node:fs";
import path from "node:path";

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

/**
Expand All @@ -9,13 +10,13 @@ import { getUpdatedWebpackChunksFileContent } from "./get-updated-webpack-chunks
* 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`
* so this shows that not everything that's needed to deploy the application is in the output directory...
*/
export async function updateWebpackChunksFile(nextjsAppPaths: NextjsAppPaths) {
export async function updateWebpackChunksFile(config: Config) {
console.log("# updateWebpackChunksFile");
const webpackRuntimeFile = `${nextjsAppPaths.standaloneAppServerDir}/webpack-runtime.js`;
const webpackRuntimeFile = path.join(config.paths.standaloneAppServer, "webpack-runtime.js");

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

const chunks = readdirSync(`${nextjsAppPaths.standaloneAppServerDir}/chunks`)
const chunks = readdirSync(path.join(config.paths.standaloneAppServer, "chunks"))
.filter((chunk) => /^\d+\.js$/.test(chunk))
.map((chunk) => {
console.log(` - chunk ${chunk}`);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { globSync } from "glob";
import { NextjsAppPaths } from "../../../nextjs-paths";
import path from "node:path";
import { Config } from "../../../config";

/**
* `evalManifest` relies on readFileSync so we need to patch the function so that it instead returns the content of the manifest files
Expand All @@ -8,19 +9,19 @@ import { NextjsAppPaths } from "../../../nextjs-paths";
* Note: we could/should probably just patch readFileSync here or something, but here the issue is that after the readFileSync call
* 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)
*/
export function inlineEvalManifest(code: string, nextjsAppPaths: NextjsAppPaths): string {
export function inlineEvalManifest(code: string, config: Config): string {
console.log("# inlineEvalManifest");
const manifestJss = globSync(
`${nextjsAppPaths.standaloneAppDotNextDir}/**/*_client-reference-manifest.js`
).map((file) => file.replace(`${nextjsAppPaths.standaloneAppDir}/`, ""));
path.join(config.paths.standaloneAppDotNext, "**", "*_client-reference-manifest.js")
).map((file) => file.replace(`${config.paths.standaloneApp}/`, ""));
return code.replace(
/function evalManifest\((.+?), .+?\) {/,
`$&
${manifestJss
.map(
(manifestJs) => `
if ($1.endsWith("${manifestJs}")) {
require("${nextjsAppPaths.standaloneAppDir}/${manifestJs}");
require("${path.join(config.paths.standaloneApp, manifestJs)}");
return {
__RSC_MANIFEST: {
"${manifestJs
Expand Down
Loading