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
5 changes: 5 additions & 0 deletions .changeset/eleven-pumas-film.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@opennextjs/cloudflare": patch
---

perf: reduce CPU and memory usage by limiting code to AST parsing
1 change: 0 additions & 1 deletion packages/cloudflare/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,6 @@
"@opennextjs/aws": "https://pkg.pr.new/@opennextjs/aws@727",
"enquirer": "^2.4.1",
"glob": "catalog:",
"ts-morph": "catalog:",
"yaml": "^2.7.0"
},
"peerDependencies": {
Expand Down
33 changes: 12 additions & 21 deletions packages/cloudflare/src/cli/build/bundle-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,16 @@ import path from "node:path";
import { fileURLToPath } from "node:url";

import { type BuildOptions, getPackagePath } from "@opennextjs/aws/build/helper.js";
import { getCrossPlatformPathRegex } from "@opennextjs/aws/utils/regex.js";
import { build, Plugin } from "esbuild";
import { build } from "esbuild";

import { patchVercelOgLibrary } from "./patches/ast/patch-vercel-og-library.js";
import { patchWebpackRuntime } from "./patches/ast/webpack-runtime.js";
import * as patches from "./patches/index.js";
import { ContentUpdater } from "./patches/plugins/content-updater.js";
import { patchLoadInstrumentation } from "./patches/plugins/load-instrumentation.js";
import { handleOptionalDependencies } from "./patches/plugins/optional-deps.js";
import { fixRequire } from "./patches/plugins/require.js";
import { shimRequireHook } from "./patches/plugins/require-hook.js";
import { inlineRequirePagePlugin } from "./patches/plugins/require-page.js";
import { setWranglerExternal } from "./patches/plugins/wrangler-external.js";
import { normalizePath, patchCodeWithValidations } from "./utils/index.js";
Expand Down Expand Up @@ -58,6 +60,8 @@ export async function bundleServer(buildOpts: BuildOptions): Promise<void> {
const openNextServer = path.join(outputPath, packagePath, `index.mjs`);
const openNextServerBundle = path.join(outputPath, packagePath, `handler.mjs`);

const updater = new ContentUpdater();

const result = await build({
entryPoints: [openNextServer],
bundle: true,
Expand All @@ -77,11 +81,14 @@ export async function bundleServer(buildOpts: BuildOptions): Promise<void> {
// - ESBuild `node` platform: https://esbuild.github.io/api/#platform
conditions: [],
plugins: [
createFixRequiresESBuildPlugin(buildOpts),
inlineRequirePagePlugin(buildOpts),
shimRequireHook(buildOpts),
inlineRequirePagePlugin(updater, buildOpts),
setWranglerExternal(),
fixRequire(),
fixRequire(updater),
handleOptionalDependencies(optionalDependencies),
patchLoadInstrumentation(updater),
// Apply updater updaters, must be the last plugin
updater.plugin,
],
external: ["./middleware/handler.mjs"],
alias: {
Expand Down Expand Up @@ -192,7 +199,6 @@ export async function updateWorkerBundledCode(
(code) => patches.inlineMiddlewareManifestRequire(code, buildOpts),
],
["exception bubbling", patches.patchExceptionBubbling],
["`loadInstrumentationModule` function", patches.patchLoadInstrumentationModule],
[
"`patchAsyncStorage` call",
(code) =>
Expand All @@ -210,21 +216,6 @@ export async function updateWorkerBundledCode(
await writeFile(workerOutputFile, patchedCode);
}

function createFixRequiresESBuildPlugin(options: BuildOptions): Plugin {
return {
name: "replaceRelative",
setup(build) {
// Note: we (empty) shim require-hook modules as they generate problematic code that uses requires
build.onResolve(
{ filter: getCrossPlatformPathRegex(String.raw`^\./require-hook$`, { escape: false }) },
() => ({
path: path.join(options.outputDir, "cloudflare-templates/shims/empty.js"),
})
);
},
};
}

/**
* Gets the path of the worker.js file generated by the build process
*
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/**
* ESBuild stops calling `onLoad` hooks after the first hook returns an updated content.
*
* The updater allows multiple plugins to update the content.
*/

import { readFile } from "node:fs/promises";

import { type OnLoadArgs, type OnLoadOptions, type Plugin, type PluginBuild } from "esbuild";

export type Callback = (args: {
contents: string;
path: string;
}) => string | undefined | Promise<string | undefined>;
export type Updater = OnLoadOptions & { callback: Callback };

export class ContentUpdater {
updaters = new Map<string, Updater>();

/**
* Register a callback to update the file content.
*
* The callbacks are called in order of registration.
*
* @param name The name of the plugin (must be unique).
* @param options Same options as the `onLoad` hook to restrict updates.
* @param callback The callback updating the content.
* @returns A noop ESBuild plugin.
*/
updateContent(name: string, options: OnLoadOptions, callback: Callback): Plugin {
if (this.updaters.has(name)) {
throw new Error(`Plugin "${name}" already registered`);
}
this.updaters.set(name, { ...options, callback });
return {
name,
setup() {},
};
}

/**
* Returns an ESBuild plugin applying the registered updates.
*/
get plugin() {
return {
name: "aggregate-on-load",

setup: async (build: PluginBuild) => {
build.onLoad({ filter: /\.(js|mjs|cjs|jsx|ts|tsx)$/ }, async (args: OnLoadArgs) => {
let contents = await readFile(args.path, "utf-8");
for (const { filter, namespace, callback } of this.updaters.values()) {
if (namespace !== undefined && args.namespace !== namespace) {
continue;
}
if (!filter.test(args.path)) {
continue;
}
contents = (await callback({ contents, path: args.path })) ?? contents;
}
return { contents };
});
},
};
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { describe, expect, test } from "vitest";

import { patchCode } from "../ast/util.js";
import { instrumentationRule } from "./load-instrumentation.js";

describe("LoadInstrumentationModule", () => {
test("patch", () => {
const code = `
export default class NextNodeServer extends BaseServer<
Options,
NodeNextRequest,
NodeNextResponse
> {
protected async loadInstrumentationModule() {
if (!this.serverOptions.dev) {
try {
this.instrumentation = await dynamicRequire(
resolve(
this.serverOptions.dir || '.',
this.serverOptions.conf.distDir!,
'server',
INSTRUMENTATION_HOOK_FILENAME
)
)
} catch (err: any) {
if (err.code !== 'MODULE_NOT_FOUND') {
throw new Error(
'An error occurred while loading the instrumentation hook',
{ cause: err }
)
}
}
}
return this.instrumentation
}
}`;

expect(patchCode(code, instrumentationRule)).toMatchInlineSnapshot(`
"export default class NextNodeServer extends BaseServer<
Options,
NodeNextRequest,
NodeNextResponse
> {
async loadInstrumentationModule() { }
}"
`);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/**
* `loadInstrumentationModule` uses a dynamic require which is not supported.
*/

import { patchCode } from "../ast/util.js";
import type { ContentUpdater } from "./content-updater.js";

export const instrumentationRule = `
rule:
kind: method_definition
all:
- has: {field: name, regex: ^loadInstrumentationModule$}
- has: {pattern: dynamicRequire, stopBy: end}

fix: async loadInstrumentationModule() { }
`;

export function patchLoadInstrumentation(updater: ContentUpdater) {
return updater.updateContent(
"patch-load-instrumentation",
{ filter: /\.(js|mjs|cjs|jsx|ts|tsx)$/ },
({ contents }) => {
if (/async loadInstrumentationModule\(/.test(contents)) {
return patchCode(contents, instrumentationRule);
}
}
);
}
20 changes: 20 additions & 0 deletions packages/cloudflare/src/cli/build/patches/plugins/require-hook.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { join } from "node:path";

import type { BuildOptions } from "@opennextjs/aws/build/helper.js";
import { getCrossPlatformPathRegex } from "@opennextjs/aws/utils/regex.js";
import type { Plugin } from "esbuild";

export function shimRequireHook(options: BuildOptions): Plugin {
return {
name: "replaceRelative",
setup(build) {
// Note: we (empty) shim require-hook modules as they generate problematic code that uses requires
build.onResolve(
{ filter: getCrossPlatformPathRegex(String.raw`^\./require-hook$`, { escape: false }) },
() => ({
path: join(options.outputDir, "cloudflare-templates/shims/empty.js"),
})
);
},
};
}
30 changes: 12 additions & 18 deletions packages/cloudflare/src/cli/build/patches/plugins/require-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,28 +3,22 @@ import { join } from "node:path";

import { type BuildOptions, getPackagePath } from "@opennextjs/aws/build/helper.js";
import { getCrossPlatformPathRegex } from "@opennextjs/aws/utils/regex.js";
import type { PluginBuild } from "esbuild";

import { patchCode, type RuleConfig } from "../ast/util.js";
import type { ContentUpdater } from "./content-updater.js";

export function inlineRequirePagePlugin(buildOpts: BuildOptions) {
return {
name: "inline-require-page",

setup: async (build: PluginBuild) => {
build.onLoad(
{
filter: getCrossPlatformPathRegex(String.raw`/next/dist/server/require\.js$`, { escape: false }),
},
async ({ path }) => {
const jsCode = await readFile(path, "utf8");
if (/function requirePage\(/.test(jsCode)) {
return { contents: patchCode(jsCode, await getRule(buildOpts)) };
}
}
);
export function inlineRequirePagePlugin(updater: ContentUpdater, buildOpts: BuildOptions) {
return updater.updateContent(
"inline-require-page",
{
filter: getCrossPlatformPathRegex(String.raw`/next/dist/server/require\.js$`, { escape: false }),
},
};
async ({ contents }) => {
if (/function requirePage\(/.test(contents)) {
return patchCode(contents, await getRule(buildOpts));
}
}
);
}

async function getRule(buildOpts: BuildOptions) {
Expand Down
90 changes: 40 additions & 50 deletions packages/cloudflare/src/cli/build/patches/plugins/require.ts
Original file line number Diff line number Diff line change
@@ -1,54 +1,44 @@
import fs from "node:fs/promises";
import type { ContentUpdater } from "./content-updater";

import type { PluginBuild } from "esbuild";
export function fixRequire(updater: ContentUpdater) {
return updater.updateContent("fix-require", { filter: /\.(js|mjs|cjs|jsx|ts|tsx)$/ }, ({ contents }) => {
// `eval(...)` is not supported by workerd.
contents = contents.replaceAll(`eval("require")`, "require");

export function fixRequire() {
return {
name: "fix-require",
// `@opentelemetry` has a few issues.
//
// Next.js has the following code in `next/dist/server/lib/trace/tracer.js`:
//
// try {
// api = require('@opentelemetry/api');
// } catch (err) {
// api = require('next/dist/compiled/@opentelemetry/api');
// }
//
// The intent is to allow users to install their own version of `@opentelemetry/api`.
//
// The problem is that even when users do not explicitely install `@opentelemetry/api`,
// `require('@opentelemetry/api')` resolves to the package which is a dependency
// of Next.
//
// The second problem is that when Next traces files, it would not copy the `api/build/esm`
// folder (used by the `module` conditions in package.json) it would only copy `api/build/src`.
// This could be solved by updating the next config:
//
// const nextConfig: NextConfig = {
// // ...
// outputFileTracingIncludes: {
// "*": ["./node_modules/@opentelemetry/api/build/**/*"],
// },
// };
//
// We can consider doing that when we want to enable users to install their own version
// of `@opentelemetry/api`. For now we simply use the pre-compiled version.
contents = contents.replace(
/require\(.@opentelemetry\/api.\)/g,
`require("next/dist/compiled/@opentelemetry/api")`
);

setup: async (build: PluginBuild) => {
build.onLoad({ filter: /\.(js|mjs|cjs|jsx|ts|tsx)$/ }, async ({ path }) => {
let contents = await fs.readFile(path, "utf-8");

// `eval(...)` is not supported by workerd.
contents = contents.replaceAll(`eval("require")`, "require");

// `@opentelemetry` has a few issues.
//
// Next.js has the following code in `next/dist/server/lib/trace/tracer.js`:
//
// try {
// api = require('@opentelemetry/api');
// } catch (err) {
// api = require('next/dist/compiled/@opentelemetry/api');
// }
//
// The intent is to allow users to install their own version of `@opentelemetry/api`.
//
// The problem is that even when users do not explicitely install `@opentelemetry/api`,
// `require('@opentelemetry/api')` resolves to the package which is a dependency
// of Next.
//
// The second problem is that when Next traces files, it would not copy the `api/build/esm`
// folder (used by the `module` conditions in package.json) it would only copy `api/build/src`.
// This could be solved by updating the next config:
//
// const nextConfig: NextConfig = {
// // ...
// outputFileTracingIncludes: {
// "*": ["./node_modules/@opentelemetry/api/build/**/*"],
// },
// };
//
// We can consider doing that when we want to enable users to install their own version
// of `@opentelemetry/api`. For now we simply use the pre-compiled version.
contents = contents.replace(
/require\(.@opentelemetry\/api.\)/g,
`require("next/dist/compiled/@opentelemetry/api")`
);

return { contents };
});
},
};
return contents;
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,4 @@ export * from "./inline-eval-manifest.js";
export * from "./inline-middleware-manifest-require.js";
export * from "./patch-exception-bubbling.js";
export * from "./patch-find-dir.js";
export * from "./patch-load-instrumentation-module.js";
export * from "./patch-read-file.js";
Loading