diff --git a/examples-cloudflare/e2e/app-router/middleware.ts b/examples-cloudflare/e2e/app-router/proxy.ts similarity index 98% rename from examples-cloudflare/e2e/app-router/middleware.ts rename to examples-cloudflare/e2e/app-router/proxy.ts index 93014807..53f32412 100644 --- a/examples-cloudflare/e2e/app-router/middleware.ts +++ b/examples-cloudflare/e2e/app-router/proxy.ts @@ -1,7 +1,7 @@ import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; -export function middleware(request: NextRequest) { +export default function middleware(request: NextRequest) { const path = request.nextUrl.pathname; //new URL(request.url).pathname; const host = request.headers.get("host"); diff --git a/packages/cloudflare/src/cli/adapter.ts b/packages/cloudflare/src/cli/adapter.ts index b3079449..b361f22a 100644 --- a/packages/cloudflare/src/cli/adapter.ts +++ b/packages/cloudflare/src/cli/adapter.ts @@ -19,7 +19,9 @@ import { compileImages } from "./build/open-next/compile-images.js"; import { compileInit } from "./build/open-next/compile-init.js"; import { compileSkewProtection } from "./build/open-next/compile-skew-protection.js"; import { compileDurableObjects } from "./build/open-next/compileDurableObjects.js"; +import { patchWebpackMiddlewareRuntime } from "./build/patches/ast/webpack-runtime.js"; import { inlineLoadManifest } from "./build/patches/plugins/load-manifest.js"; +import { patchOpenTelemetryGlobalUtils } from "./build/patches/plugins/opentelemetry.js"; import { patchResRevalidate } from "./build/patches/plugins/res-revalidate.js"; import { patchTurbopackRuntime } from "./build/patches/plugins/turbopack.js"; import { patchUseCacheIO } from "./build/patches/plugins/use-cache.js"; @@ -68,7 +70,47 @@ export default buildAdapter((config: OpenNextConfig, buildOpts: BuildOptions) => isInCloudflare: true, }), ], - additionalCodePatches: [patchResRevalidate, patchUseCacheIO, patchTurbopackRuntime], + additionalCodePatches: [ + patchResRevalidate, + patchUseCacheIO, + patchOpenTelemetryGlobalUtils, + patchTurbopackRuntime, + ], + }, + middlewareBundle: { + useEdgeConfig: true, + banner: (_name: string) => [ + `globalThis.monorepoPackagePath = "${normalizePath(packagePath)}";`, + `import { Buffer } from "node:buffer"; +globalThis.Buffer = Buffer; + +import { AsyncLocalStorage } from "node:async_hooks"; +globalThis.AsyncLocalStorage = AsyncLocalStorage; + +`, + ], + additionalPlugins: (updater: ContentUpdater, outputs: NextAdapterOutputs) => [ + inlineRouteHandler(updater, outputs, packagePath), + inlineLoadManifest(updater, buildOpts), + ...(config.middleware?.external + ? [ + openNextExternalMiddlewarePlugin( + path.join(buildOpts.openNextDistDir, "core/edgeFunctionHandler.js") + ), + ] + : []), + openNextEdgePlugins({ + nextDir: path.join(buildOpts.appBuildOutputPath, ".next"), + isInCloudflare: true, + }), + ], + additionalCodePatches: [ + patchResRevalidate, + patchUseCacheIO, + patchOpenTelemetryGlobalUtils, + patchWebpackMiddlewareRuntime, + patchTurbopackRuntime, + ], }, afterServerBundle: async (buildOpts, _config) => { compileDurableObjects(buildOpts); diff --git a/packages/cloudflare/src/cli/build/patches/ast/webpack-runtime.spec.ts b/packages/cloudflare/src/cli/build/patches/ast/webpack-runtime.spec.ts index 263da79d..f25ea0ed 100644 --- a/packages/cloudflare/src/cli/build/patches/ast/webpack-runtime.spec.ts +++ b/packages/cloudflare/src/cli/build/patches/ast/webpack-runtime.spec.ts @@ -1,7 +1,11 @@ import { patchCode } from "@opennextjs/core/build/patch/astCodePatcher.js"; import { describe, expect, test } from "vitest"; -import { buildMultipleChunksRule, singleChunkRule } from "./webpack-runtime.js"; +import { + buildMultipleChunksRule, + patchWebpackMiddlewareRuntime, + singleChunkRule, +} from "./webpack-runtime.js"; describe("webpack runtime", () => { describe("multiple chunks", () => { @@ -110,4 +114,33 @@ describe("webpack runtime", () => { `); }); }); + + describe("middleware runtime", () => { + const runtimeCode = ` + t.f.require=(o,n)=>{e[o]||(658!=o?r(require("./chunks/"+t.u(o))):e[o]=1)} + `; + + test("uses the middleware traced chunks", async () => { + const patch = patchWebpackMiddlewareRuntime.patches[0]!; + const result = await patch.patchCode({ + code: runtimeCode, + tracedFiles: ["/app/.open-next/middleware/app/.next/server/chunks/123.js"], + } as never); + + expect(result).toContain('case 123: r(require("./chunks/123.js")); break;'); + expect(result).not.toContain('require("./chunks/" +'); + }); + + test("matches minified runtime code", () => { + expect(patchWebpackMiddlewareRuntime.patches[0]!.contentFilter?.test(runtimeCode)).toBe(true); + }); + + test("supports middleware with no chunks", async () => { + const patch = patchWebpackMiddlewareRuntime.patches[0]!; + const result = await patch.patchCode({ code: runtimeCode, tracedFiles: [] } as never); + + expect(result).toContain("case 658: e[o] = 1; break;"); + expect(result).not.toContain('require("./chunks/" +'); + }); + }); }); diff --git a/packages/cloudflare/src/cli/build/patches/ast/webpack-runtime.ts b/packages/cloudflare/src/cli/build/patches/ast/webpack-runtime.ts index 5914e840..767522cf 100644 --- a/packages/cloudflare/src/cli/build/patches/ast/webpack-runtime.ts +++ b/packages/cloudflare/src/cli/build/patches/ast/webpack-runtime.ts @@ -25,6 +25,8 @@ import { join } from "node:path"; import { type BuildOptions, getPackagePath } from "@opennextjs/core/build/helper.js"; import { patchCode } from "@opennextjs/core/build/patch/astCodePatcher.js"; +import type { CodePatcher } from "@opennextjs/core/build/patch/codePatcher.js"; +import { getCrossPlatformPathRegex } from "@opennextjs/core/utils/regex.js"; // Inline the code when there are multiple chunks export function buildMultipleChunksRule(chunks: number[]) { @@ -69,6 +71,44 @@ fix: | } `; +export function patchWebpackRuntimeCode(code: string, chunks: number[]): string { + let patched = patchCode(code, buildMultipleChunksRule(chunks)); + patched = patchCode(patched, singleChunkRule); + return patched; +} + +function getWebpackChunks(tracedFiles: string[]): number[] { + const chunks = new Set(); + for (const file of tracedFiles) { + const match = file.match(/[\\/]chunks[\\/](\d+)\.js$/); + if (match) { + chunks.add(Number(match[1])); + } + } + return Array.from(chunks); +} + +/** + * Rewrites webpack runtime chunk loading in an external middleware bundle. + * + * The middleware trace already contains every required chunk. Using the traced + * paths keeps the generated requires visible to the Worker bundler and also + * supports middleware bundles with no chunks. + */ +export const patchWebpackMiddlewareRuntime: CodePatcher = { + name: "inline-webpack-chunks", + patches: [ + { + pathFilter: getCrossPlatformPathRegex(String.raw`webpack(?:-api)?-runtime\.js$`, { + escape: false, + }), + contentFilter: /require\("\.\/chunks\/"\s*\+/, + patchCode: async ({ code, tracedFiles }) => + patchWebpackRuntimeCode(code, getWebpackChunks(tracedFiles)), + }, + ], +}; + /** * Fixes the webpack-runtime.js and webpack-api-runtime.js files by inlining * the webpack dynamic requires. @@ -84,11 +124,12 @@ export async function patchWebpackRuntime(buildOpts: BuildOptions) { ); // Look for all the chunks. - const chunks = readdirSync(join(dotNextServerDir, "chunks")) - .filter((chunk) => /^\d+\.js$/.test(chunk)) - .map((chunk) => { - return Number(chunk.replace(/\.js$/, "")); - }); + const chunksDir = join(dotNextServerDir, "chunks"); + const chunks = existsSync(chunksDir) + ? readdirSync(chunksDir) + .filter((chunk) => /^\d+\.js$/.test(chunk)) + .map((chunk) => Number(chunk.replace(/\.js$/, ""))) + : []; patchFile(join(dotNextServerDir, "webpack-runtime.js"), chunks); patchFile(join(dotNextServerDir, "webpack-api-runtime.js"), chunks); @@ -103,8 +144,7 @@ export async function patchWebpackRuntime(buildOpts: BuildOptions) { function patchFile(filename: string, chunks: number[]) { if (existsSync(filename)) { let code = readFileSync(filename, "utf-8"); - code = patchCode(code, buildMultipleChunksRule(chunks)); - code = patchCode(code, singleChunkRule); + code = patchWebpackRuntimeCode(code, chunks); writeFileSync(filename, code); } } diff --git a/packages/cloudflare/src/cli/build/patches/plugins/opentelemetry.spec.ts b/packages/cloudflare/src/cli/build/patches/plugins/opentelemetry.spec.ts new file mode 100644 index 00000000..45448c07 --- /dev/null +++ b/packages/cloudflare/src/cli/build/patches/plugins/opentelemetry.spec.ts @@ -0,0 +1,42 @@ +import { expect, test } from "vitest"; + +import { patchOpenTelemetryGlobalUtilsCode } from "./opentelemetry.js"; + +const code = `"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.unregisterGlobal = exports.getGlobal = exports.registerGlobal = void 0; +const platform_1 = require("../platform"); +const version_1 = require("../version"); +const semver_1 = require("./semver"); +const major = version_1.VERSION.split('.')[0]; +const GLOBAL_OPENTELEMETRY_API_KEY = Symbol.for(\`opentelemetry.js.api.\${major}\`); +const _global = platform_1._globalThis; +function registerGlobal(type, instance, diag, allowOverride = false) { + const api = (_global[GLOBAL_OPENTELEMETRY_API_KEY] = { + version: version_1.VERSION, + }); + return semver_1.isCompatible(api.version); +} +`; + +test("patches OpenTelemetry to use the Cloudflare global", () => { + const patched = patchOpenTelemetryGlobalUtilsCode(code); + + expect(patched).toContain('// const platform_1 = require("../platform");'); + expect(patched).toContain("const _global = globalThis;"); + expect(patched).toContain('const version_1 = require("../version");'); + expect(patched).toContain("return semver_1.isCompatible(api.version);"); + expect(patched).not.toContain("platform_1._globalThis"); +}); + +test("captures the platform and global variable names", () => { + const patched = patchOpenTelemetryGlobalUtilsCode(` +const nodePlatform = require("../platform"); +const globalStore = nodePlatform._globalThis; +const unrelatedStore = otherPlatform._globalThis; +`); + + expect(patched).toContain('// const nodePlatform = require("../platform");'); + expect(patched).toContain("const globalStore = globalThis;"); + expect(patched).toContain("const unrelatedStore = otherPlatform._globalThis;"); +}); diff --git a/packages/cloudflare/src/cli/build/patches/plugins/opentelemetry.ts b/packages/cloudflare/src/cli/build/patches/plugins/opentelemetry.ts new file mode 100644 index 00000000..c8cca35e --- /dev/null +++ b/packages/cloudflare/src/cli/build/patches/plugins/opentelemetry.ts @@ -0,0 +1,41 @@ +import { patchCode } from "@opennextjs/core/build/patch/astCodePatcher.js"; +import type { CodePatcher } from "@opennextjs/core/build/patch/codePatcher.js"; +import { getCrossPlatformPathRegex } from "@opennextjs/core/utils/regex.js"; + +export const commentPlatformImportRule = ` +rule: + pattern: const $PLATFORM = require("../platform"); +fix: // const $PLATFORM = require("../platform"); +`; + +export const replaceGlobalRule = ` +rule: + all: + - pattern: const $GLOBAL = $PLATFORM._globalThis; + - inside: + kind: program + stopBy: end + has: + pattern: const $PLATFORM = require("../platform"); +fix: const $GLOBAL = globalThis; +`; + +export function patchOpenTelemetryGlobalUtilsCode(code: string): string { + let patchedCode = patchCode(code, replaceGlobalRule); + patchedCode = patchCode(patchedCode, commentPlatformImportRule); + return patchedCode; +} + +export const patchOpenTelemetryGlobalUtils: CodePatcher = { + name: "patch-opentelemetry-global-utils", + patches: [ + { + pathFilter: getCrossPlatformPathRegex( + String.raw`@opentelemetry/api/build/src/internal/global-utils\.js$`, + { escape: false } + ), + contentFilter: /require\(["']\.\.\/platform["']\)/, + patchCode: async ({ code }) => patchOpenTelemetryGlobalUtilsCode(code), + }, + ], +}; diff --git a/packages/cloudflare/src/cli/build/patches/plugins/turbopack.spec.ts b/packages/cloudflare/src/cli/build/patches/plugins/turbopack.spec.ts new file mode 100644 index 00000000..cd40b21a --- /dev/null +++ b/packages/cloudflare/src/cli/build/patches/plugins/turbopack.spec.ts @@ -0,0 +1,33 @@ +import { describe, expect, test } from "vitest"; + +import { patchTurbopackRuntime } from "./turbopack.js"; + +describe("turbopack runtime", () => { + const runtimeCode = ` +function loadRuntimeChunkPath(chunkPath) { + const resolved = chunkPath; + return require(resolved); +} +`; + + test("uses the middleware traced chunks", async () => { + const patch = patchTurbopackRuntime.patches[0]!; + const result = await patch.patchCode({ + code: runtimeCode, + tracedFiles: ["/app/.open-next/middleware/app/.next/server/chunks/ssr/chunk.js"], + } as never); + + expect(result).toContain('case "server/chunks/ssr/chunk.js"'); + expect(result).toContain( + 'return require("/app/.open-next/middleware/app/.next/server/chunks/ssr/chunk.js")' + ); + }); + + test("supports middleware with no chunks", async () => { + const patch = patchTurbopackRuntime.patches[0]!; + const result = await patch.patchCode({ code: runtimeCode, tracedFiles: [] } as never); + + expect(result).toContain("function requireChunk(chunkPath)"); + expect(result).toContain("default:"); + }); +}); diff --git a/packages/core/src/build/adapter.spec.ts b/packages/core/src/build/adapter.spec.ts index bda407af..6e39a72f 100644 --- a/packages/core/src/build/adapter.spec.ts +++ b/packages/core/src/build/adapter.spec.ts @@ -230,7 +230,14 @@ describe("buildAdapter", () => { expect(createMiddleware).toHaveBeenCalledWith( expect.any(Object), - expect.objectContaining({ forceOnlyBuildOnce: true }) + expect.objectContaining({ forceOnlyBuildOnce: true }), + expect.objectContaining({ + pages: expect.any(Array), + appPages: expect.any(Array), + appRoutes: expect.any(Array), + pagesApi: expect.any(Array), + }), + undefined ); }); @@ -426,6 +433,42 @@ describe("buildAdapter", () => { ); }); + test("onBuildComplete forwards middlewareBundle to createMiddleware intact", async () => { + const mockPlugin = { name: "mock-plugin", setup: vi.fn() }; + const additionalPlugins = vi.fn(() => [mockPlugin]); + const additionalCodePatches = [{ name: "test-middleware-patch", patches: [] }]; + const middlewareBundle = { + additionalPlugins, + additionalCodePatches, + useEdgeConfig: true, + externals: ["./externals-test"], + banner: ["// test banner"], + }; + + const adapter = buildAdapter(() => ({ + middlewareBundle, + })); + + const nextConfig = { experimental: {}, images: {} } as BuildCompleteContext["config"]; + await adapter.modifyConfig(nextConfig, { phase: "production" }); + + const ctx = createMockContext(); + await adapter.onBuildComplete(ctx); + + expect(createMiddleware).toHaveBeenCalledWith( + expect.any(Object), + expect.any(Object), + ctx.outputs, + middlewareBundle + ); + + const calls = vi.mocked(createMiddleware).mock.calls; + expect(calls).toHaveLength(1); + expect(calls[0]).toHaveLength(4); + expect(calls[0][3]).toEqual(middlewareBundle); + expect(calls[0][3]).toBe(middlewareBundle); + }); + test("onBuildComplete compiles tag cache provider when useTagCache is true", async () => { vi.mocked(createCacheAssets).mockReturnValue({ useTagCache: true, diff --git a/packages/core/src/build/adapter.ts b/packages/core/src/build/adapter.ts index 9c3488f7..68f1c32c 100644 --- a/packages/core/src/build/adapter.ts +++ b/packages/core/src/build/adapter.ts @@ -60,6 +60,13 @@ export type OpenNextAdapterOptions = { skipWarmer?: boolean; skipGenerateOutput?: boolean; middlewareOptions?: { forceOnlyBuildOnce?: boolean }; + middlewareBundle?: { + additionalPlugins?: (updater: ContentUpdater, outputs: NextAdapterOutputs) => Plugin[]; + additionalCodePatches?: CodePatcher[]; + useEdgeConfig?: boolean; + externals?: string[]; + banner?: string[] | ((name: string) => string[]); + }; serverBundle?: { additionalPlugins?: (updater: ContentUpdater, outputs: NextAdapterOutputs) => Plugin[]; additionalCodePatches?: CodePatcher[]; @@ -185,10 +192,15 @@ export function buildAdapter( const bundleDefaults = adapterOptions.defaultOverrides; // Step 3: Create middleware - await createMiddleware(buildOpts, { - ...adapterOptions.middlewareOptions, - defaultOverrides: bundleDefaults?.middleware, - }); + await createMiddleware( + buildOpts, + { + ...adapterOptions.middlewareOptions, + defaultOverrides: bundleDefaults?.middleware, + }, + ctx.outputs, + adapterOptions.middlewareBundle + ); console.log("Middleware created"); // Step 4: Create static assets diff --git a/packages/core/src/build/copyAdapterFiles.ts b/packages/core/src/build/copyAdapterFiles.ts index 9cde9e96..b249395f 100644 --- a/packages/core/src/build/copyAdapterFiles.ts +++ b/packages/core/src/build/copyAdapterFiles.ts @@ -11,8 +11,10 @@ export async function copyAdapterFiles( options: buildHelper.BuildOptions, fnName: string, packagePath: string, - outputs: NextAdapterOutputs + outputs: NextAdapterOutputs, + destDir?: string ) { + const destRoot = destDir ?? `${options.outputDir}/server-functions/${fnName}`; const filesToCopy = new Map(); // Copying the files from outputs to the output dir @@ -22,14 +24,11 @@ export async function copyAdapterFiles( const assets = route.assets; // We need to copy the filepaths to the output dir const relativeFilePath = path.join(packagePath, path.relative(options.appPath, route.filePath)); - filesToCopy.set( - route.filePath, - `${options.outputDir}/server-functions/${fnName}/${relativeFilePath}` - ); + filesToCopy.set(route.filePath, `${destRoot}/${relativeFilePath}`); for (const [relative, from] of Object.entries(assets || {})) { // console.log("route.assets", from, relative, packagePath); - filesToCopy.set(from as string, `${options.outputDir}/server-functions/${fnName}/${relative}`); + filesToCopy.set(from as string, `${destRoot}/${relative}`); } }; if (key === "middleware") { diff --git a/packages/core/src/build/createMiddleware.ts b/packages/core/src/build/createMiddleware.ts index 963a3568..b3e2978b 100644 --- a/packages/core/src/build/createMiddleware.ts +++ b/packages/core/src/build/createMiddleware.ts @@ -2,16 +2,20 @@ import fs from "node:fs"; import path from "node:path"; import { loadFunctionsConfigManifest, loadMiddlewareManifest } from "@/config/util.js"; +import type { NextAdapterOutputs } from "@/types/adapter.js"; import logger from "../logger.js"; import type { DefaultOverrides } from "../plugins/resolve.js"; import type { MiddlewareInfo } from "../types/next-types.js"; +import type { OpenNextAdapterOptions } from "./adapter.js"; import { buildEdgeBundle, copyMiddlewareResources } from "./edge/createEdgeBundle.js"; import * as buildHelper from "./helper.js"; import { installDependencies } from "./installDeps.js"; import { buildBundledNodeMiddleware, buildExternalNodeMiddleware } from "./middleware/buildNodeMiddleware.js"; +type MiddlewareBundle = NonNullable; + /** * Compiles the middleware bundle. * @@ -23,7 +27,9 @@ export async function createMiddleware( { forceOnlyBuildOnce = false, defaultOverrides, - }: { forceOnlyBuildOnce?: boolean; defaultOverrides?: DefaultOverrides } = {} + }: { forceOnlyBuildOnce?: boolean; defaultOverrides?: DefaultOverrides } = {}, + nextOutputs?: NextAdapterOutputs, + middlewareBundle?: MiddlewareBundle ) { logger.info("Bundling middleware function..."); @@ -41,7 +47,7 @@ export async function createMiddleware( if (functionsConfigManifest?.functions["/_middleware"]) { await (config.middleware?.external - ? buildExternalNodeMiddleware(options, defaultOverrides) + ? buildExternalNodeMiddleware(options, defaultOverrides, nextOutputs, middlewareBundle) : buildBundledNodeMiddleware(options)); return; } diff --git a/packages/core/src/build/middleware/buildNodeMiddleware.spec.ts b/packages/core/src/build/middleware/buildNodeMiddleware.spec.ts new file mode 100644 index 00000000..7d8dae7b --- /dev/null +++ b/packages/core/src/build/middleware/buildNodeMiddleware.spec.ts @@ -0,0 +1,214 @@ +/* eslint-disable import/first */ +import { beforeEach, describe, expect, test, vi } from "vitest"; + +vi.mock("../../plugins/content-updater.js", () => { + const ContentUpdater = vi.fn().mockImplementation(() => ({ + plugin: { name: "content-updater-mock", setup: vi.fn() }, + })); + return { ContentUpdater }; +}); + +vi.mock("../../plugins/externalMiddleware.js", () => ({ + openNextExternalMiddlewarePlugin: vi.fn(() => ({ name: "external-middleware-mock", setup: vi.fn() })), +})); + +vi.mock("../../plugins/replacement.js", () => ({ + openNextReplacementPlugin: vi.fn(() => ({ name: "replacement-mock", setup: vi.fn() })), +})); + +vi.mock("../../plugins/resolve.js", () => ({ + openNextResolvePlugin: vi.fn(() => ({ name: "resolve-mock", setup: vi.fn() })), +})); + +vi.mock("../copyAdapterFiles.js", () => ({ + copyAdapterFiles: vi.fn(async () => ["/app/.open-next/middleware/middleware.js"]), +})); + +vi.mock("../helper.js", () => ({ + esbuildAsync: vi.fn(async () => {}), + copyOpenNextConfig: vi.fn(), + isEdgeRuntime: vi.fn(async () => false), + getPackagePath: vi.fn(() => "build"), +})); + +vi.mock("../installDeps.js", () => ({ + installDependencies: vi.fn(), +})); + +vi.mock("../patch/codePatcher.js", () => ({ + applyCodePatches: vi.fn(async () => {}), +})); + +vi.mock("../patch/patches/index.js", () => ({ + getEnvVarsPatch: vi.fn(() => ({ name: "env-vars", patches: [] })), + patchNodeEnvironment: { name: "node-environment", patches: [] }, +})); + +vi.mock("node:fs", () => ({ + default: { + mkdirSync: vi.fn(), + }, + mkdirSync: vi.fn(), +})); + +import type { NextAdapterOutputs } from "../../types/adapter.js"; +import type { BuildOptions } from "../helper.js"; +import * as buildHelper from "../helper.js"; +import { installDependencies } from "../installDeps.js"; +import { applyCodePatches } from "../patch/codePatcher.js"; + +import { buildExternalNodeMiddleware } from "./buildNodeMiddleware.js"; + +function createMockBuildOpts(overrides: Partial = {}): BuildOptions { + return { + appBuildOutputPath: "/app/build", + appPackageJsonPath: "/app/package.json", + appPath: "/app", + appPublicPath: "/app/public", + buildDir: "/app/.open-next/.build", + config: { + default: {}, + dangerous: {}, + middleware: { external: true }, + } as unknown as BuildOptions["config"], + debug: false, + minify: true, + monorepoRoot: "/app", + nextVersion: "16.0.0", + openNextVersion: "0.1.0", + openNextDistDir: "/fake/opennext/dist", + outputDir: "/app/.open-next", + packager: "npm" as const, + tempBuildDir: "/tmp/open-next-tmp", + ...overrides, + } as BuildOptions; +} + +function createMockNextOutputs(): NextAdapterOutputs { + return { + pages: [], + pagesApi: [], + appPages: [], + appRoutes: [], + middleware: { + pathname: "/", + filePath: "/app/.next/server/middleware.js", + assets: { "asset.txt": "/app/.next/server/asset.txt" }, + }, + }; +} + +describe("buildExternalNodeMiddleware", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test("Case A: applies all default values when middlewareBundle is undefined", async () => { + const options = createMockBuildOpts(); + const nextOutputs = createMockNextOutputs(); + + await buildExternalNodeMiddleware(options, undefined, nextOutputs); + + // copyOpenNextConfig called with isEdge false (default isEdgeRuntime) + expect(buildHelper.copyOpenNextConfig).toHaveBeenCalledWith( + options.buildDir, + "/app/.open-next/middleware", + false + ); + + // esbuildAsync called with default external, banner, and static middleware import + expect(buildHelper.esbuildAsync).toHaveBeenCalledTimes(1); + const esbuildCall = vi.mocked(buildHelper.esbuildAsync).mock.calls[0]; + const esbuildOptions = esbuildCall[0]; + expect(esbuildOptions.external).toEqual(["./.next/*", "./build/.next/server/*"]); + expect(esbuildOptions.define).toEqual({ + __OPEN_NEXT_NODE_MIDDLEWARE_PATH__: '"./build/.next/server/middleware.js"', + }); + expect(esbuildOptions.banner?.js).toContain("globalThis.monorepoPackagePath"); + expect(esbuildOptions.banner?.js).not.toContain("globalThis.__middlewarePackagePath"); + expect(esbuildOptions.banner?.js).toContain("topLevelCreateRequire"); + + // Core patches are applied once before the middleware is bundled. + expect(applyCodePatches).toHaveBeenCalledTimes(1); + expect(applyCodePatches).toHaveBeenCalledWith( + options, + ["/app/.open-next/middleware/middleware.js"], + {}, + expect.arrayContaining([expect.any(Object), expect.any(Object)]) + ); + + // installDependencies called once + expect(installDependencies).toHaveBeenCalledTimes(1); + }); + + test("Case B: forwards useEdgeConfig, externals, banner, additionalPlugins, additionalCodePatches", async () => { + const options = createMockBuildOpts(); + const nextOutputs = createMockNextOutputs(); + const mockPlugin = { name: "user-supplied-plugin", setup: vi.fn() }; + const additionalPlugins = vi.fn(() => [mockPlugin]); + const additionalCodePatches = [{ name: "user-patch", patches: [] }]; + + await buildExternalNodeMiddleware(options, undefined, nextOutputs, { + useEdgeConfig: true, + externals: ["./something-else"], + banner: ["// test banner"], + additionalPlugins, + additionalCodePatches, + }); + + // copyOpenNextConfig received useEdgeConfig=true (3rd arg) + expect(buildHelper.copyOpenNextConfig).toHaveBeenCalledWith( + options.buildDir, + "/app/.open-next/middleware", + true + ); + + // esbuildAsync called with user-supplied external and banner + const esbuildCall = vi.mocked(buildHelper.esbuildAsync).mock.calls[0]; + const esbuildOptions = esbuildCall[0]; + expect(esbuildOptions.external).toEqual(["./something-else", "./build/.next/server/*"]); + expect(esbuildOptions.banner?.js).toContain("// test banner"); + + // additionalPlugins was invoked with (updater, nextOutputs) + expect(additionalPlugins).toHaveBeenCalledTimes(1); + expect(additionalPlugins).toHaveBeenCalledWith(expect.any(Object), nextOutputs); + + // plugins array contains mockPlugin and ends with updater.plugin + const plugins = esbuildOptions.plugins as Array<{ name: string }>; + expect(plugins).toContain(mockPlugin); + expect(plugins[plugins.length - 1]).toEqual({ + name: "content-updater-mock", + setup: expect.any(Function), + }); + + // Additional patches are added after the core pre-bundle patches. + expect(applyCodePatches).toHaveBeenCalledTimes(1); + const appliedPatches = vi.mocked(applyCodePatches).mock.calls[0][3]; + expect(appliedPatches).toEqual(expect.arrayContaining(additionalCodePatches)); + expect(appliedPatches).toHaveLength(3); + }); + + test("Case C: throws when nextOutputs has no middleware", async () => { + const options = createMockBuildOpts(); + + await expect(buildExternalNodeMiddleware(options, undefined, {})).rejects.toThrow( + /without adapter outputs\.middleware/ + ); + }); + + test("Case D: invokes banner function with 'middleware' name when banner is a function", async () => { + const options = createMockBuildOpts(); + const nextOutputs = createMockNextOutputs(); + const bannerFn = vi.fn((name: string) => [`// banner for ${name}`]); + + await buildExternalNodeMiddleware(options, undefined, nextOutputs, { + banner: bannerFn, + }); + + expect(bannerFn).toHaveBeenCalledWith("middleware"); + + const esbuildCall = vi.mocked(buildHelper.esbuildAsync).mock.calls[0]; + const esbuildOptions = esbuildCall[0]; + expect(esbuildOptions.banner?.js).toContain("// banner for middleware"); + }); +}); diff --git a/packages/core/src/build/middleware/buildNodeMiddleware.ts b/packages/core/src/build/middleware/buildNodeMiddleware.ts index 8f1957c4..884377e3 100644 --- a/packages/core/src/build/middleware/buildNodeMiddleware.ts +++ b/packages/core/src/build/middleware/buildNodeMiddleware.ts @@ -1,27 +1,35 @@ import fs from "node:fs"; import path from "node:path"; +import type { NextAdapterOutputs } from "@/types/adapter.js"; import type { IncludedOriginResolver, LazyLoadedOverride, OverrideOptions } from "@/types/open-next.js"; import type { OriginResolver } from "@/types/overrides.js"; -import { getCrossPlatformPathRegex } from "@/utils/regex.js"; +import { ContentUpdater } from "../../plugins/content-updater.js"; import { openNextExternalMiddlewarePlugin } from "../../plugins/externalMiddleware.js"; -import { openNextReplacementPlugin } from "../../plugins/replacement.js"; import type { DefaultOverrides } from "../../plugins/resolve.js"; import { openNextResolvePlugin } from "../../plugins/resolve.js"; -import { copyTracedFiles } from "../copyTracedFiles.js"; +import { normalizePath } from "../../utils/normalize-path.js"; +import type { OpenNextAdapterOptions } from "../adapter.js"; +import { copyAdapterFiles } from "../copyAdapterFiles.js"; import * as buildHelper from "../helper.js"; import { installDependencies } from "../installDeps.js"; +import { applyCodePatches } from "../patch/codePatcher.js"; +import * as patches from "../patch/patches/index.js"; type Override = OverrideOptions & { originResolver?: LazyLoadedOverride | IncludedOriginResolver; }; +type MiddlewareBundle = NonNullable; + export async function buildExternalNodeMiddleware( options: buildHelper.BuildOptions, - defaultOverrides?: DefaultOverrides + defaultOverrides?: DefaultOverrides, + nextOutputs?: NextAdapterOutputs, + middlewareBundle?: MiddlewareBundle ) { - const { appBuildOutputPath, config, outputDir } = options; + const { config, outputDir } = options; if (!config.middleware?.external) { throw new Error("This function should only be called for external middleware"); } @@ -29,36 +37,76 @@ export async function buildExternalNodeMiddleware( fs.mkdirSync(outputPath, { recursive: true }); // Copy open-next.config.mjs - buildHelper.copyOpenNextConfig( - options.buildDir, - outputPath, - await buildHelper.isEdgeRuntime(config.middleware.override) - ); + const useEdgeConfig = + middlewareBundle?.useEdgeConfig ?? (await buildHelper.isEdgeRuntime(config.middleware.override)); + buildHelper.copyOpenNextConfig(options.buildDir, outputPath, useEdgeConfig); const overrides = { ...config.middleware.override, originResolver: config.middleware.originResolver, }; const packagePath = buildHelper.getPackagePath(options); + const middlewareImportPath = `./${packagePath ? `${normalizePath(packagePath)}/` : ""}.next/server/middleware.js`; + const middlewareExternal = `${middlewareImportPath.slice(0, -"middleware.js".length)}*`; + + if (!nextOutputs?.middleware) { + throw new Error( + "buildExternalNodeMiddleware was called without adapter outputs.middleware. " + + "Ensure the adapter provides NextAdapterOutputs.middleware." + ); + } - // TODO: change this so that we don't copy unnecessary files - await copyTracedFiles({ - buildOutputPath: appBuildOutputPath, + console.log("Copying adapter files for external middleware..."); + const tracedFiles = await copyAdapterFiles( + options, + "middleware", packagePath, - outputDir: outputPath, - routes: [], - skipServerFiles: true, - }); + { pages: [], pagesApi: [], appPages: [], appRoutes: [], middleware: nextOutputs.middleware }, + outputPath + ); function override(target: T) { return typeof overrides?.[target] === "string" ? overrides[target] : undefined; } + await applyCodePatches( + options, + tracedFiles, + {} as ReturnType, + [ + patches.getEnvVarsPatch(options), + patches.patchNodeEnvironment, + ...(middlewareBundle?.additionalCodePatches ?? []), + ] + ); + + const updater = new ContentUpdater(options); + const additionalPlugins = middlewareBundle?.additionalPlugins + ? middlewareBundle.additionalPlugins(updater, nextOutputs) + : []; + const defaultBanner = [ + `globalThis.monorepoPackagePath = '${normalizePath(packagePath)}';`, + "import process from 'node:process';", + "import { Buffer } from 'node:buffer';", + "import { AsyncLocalStorage } from 'node:async_hooks';", + "import { createRequire as topLevelCreateRequire } from 'module';", + "const require = topLevelCreateRequire(import.meta.url);", + "import bannerUrl from 'url';", + "const __dirname = bannerUrl.fileURLToPath(new URL('.', import.meta.url));", + ]; + const bannerLines = + typeof middlewareBundle?.banner === "function" + ? middlewareBundle.banner("middleware") + : (middlewareBundle?.banner ?? defaultBanner); + // Bundle middleware await buildHelper.esbuildAsync( { entryPoints: [path.join(options.openNextDistDir, "adapters", "middleware.js")], outfile: path.join(outputPath, "handler.mjs"), - external: ["./.next/*"], + external: [...(middlewareBundle?.externals ?? ["./.next/*"]), middlewareExternal], + define: { + __OPEN_NEXT_NODE_MIDDLEWARE_PATH__: JSON.stringify(middlewareImportPath), + }, platform: "node", plugins: [ openNextResolvePlugin({ @@ -89,18 +137,12 @@ export async function buildExternalNodeMiddleware( openNextExternalMiddlewarePlugin( path.join(options.openNextDistDir, "core", "nodeMiddlewareHandler.js") ), + ...additionalPlugins, + // The content updater plugin must be the last plugin + updater.plugin, ], banner: { - js: [ - `globalThis.monorepoPackagePath = '${packagePath}';`, - "import process from 'node:process';", - "import { Buffer } from 'node:buffer';", - "import { AsyncLocalStorage } from 'node:async_hooks';", - "import { createRequire as topLevelCreateRequire } from 'module';", - "const require = topLevelCreateRequire(import.meta.url);", - "import bannerUrl from 'url';", - "const __dirname = bannerUrl.fileURLToPath(new URL('.', import.meta.url));", - ].join(""), + js: bannerLines.join(""), }, }, options @@ -116,6 +158,9 @@ export async function buildBundledNodeMiddleware(options: buildHelper.BuildOptio { entryPoints: [path.join(options.openNextDistDir, "core/nodeMiddlewareHandler.js")], external: ["./.next/*"], + define: { + __OPEN_NEXT_NODE_MIDDLEWARE_PATH__: JSON.stringify("./.next/server/middleware.js"), + }, outfile: path.join(options.buildDir, "middleware.mjs"), bundle: true, platform: "node", diff --git a/packages/core/src/core/nodeMiddlewareHandler.ts b/packages/core/src/core/nodeMiddlewareHandler.ts index 3e8095b0..a56a5d00 100644 --- a/packages/core/src/core/nodeMiddlewareHandler.ts +++ b/packages/core/src/core/nodeMiddlewareHandler.ts @@ -2,6 +2,8 @@ import type { RequestData } from "@/types/global"; type EdgeRequest = Omit; +declare const __OPEN_NEXT_NODE_MIDDLEWARE_PATH__: string; + // Do we need Buffer here? // oxlint-disable-next-line import/first import { Buffer } from "node:buffer"; @@ -26,8 +28,8 @@ export default async function middlewareHandler(request: EdgeRequest): Promise ({ + addDebugFile: vi.fn(), +})); + +vi.mock("node:fs", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + default: { + ...actual, + mkdirSync: vi.fn(), + copyFileSync: vi.fn(), + readlinkSync: vi.fn(() => ""), + symlinkSync: vi.fn(), + }, + mkdirSync: vi.fn(), + copyFileSync: vi.fn(), + readlinkSync: vi.fn(() => ""), + symlinkSync: vi.fn(), + }; +}); + +function createMockBuildOpts(overrides: Partial = {}): BuildOptions { + return { + appBuildOutputPath: "/app/build", + appPackageJsonPath: "/app/package.json", + appPath: "/app", + appPublicPath: "/app/public", + buildDir: "/app/.open-next/.build", + config: { default: {}, dangerous: {} } as unknown as BuildOptions["config"], + debug: false, + minify: true, + monorepoRoot: "/app", + nextVersion: "16.0.0", + openNextVersion: "0.1.0", + openNextDistDir: "/fake/opennext/dist", + outputDir: "/app/.open-next", + packager: "npm" as const, + tempBuildDir: "/tmp/open-next-tmp", + ...overrides, + } as BuildOptions; +} + +function createMockOutputs(): NextAdapterOutputs { + return { + middleware: { + pathname: "/", + filePath: "/app/middleware.js", + assets: { "asset.txt": "/app/asset.txt" }, + }, + pages: [{ pathname: "/", filePath: "/app/page.js", assets: {} }], + appPages: [{ pathname: "/foo", filePath: "/app/foo.js", assets: {} }], + pagesApi: [], + appRoutes: [], + }; +} + +function getCopyFileSyncDestinations(): string[] { + const calls = vi.mocked(fs.copyFileSync).mock.calls; + return calls.map(([, to]) => to as string); +} + +describe("copyAdapterFiles", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(fs.readlinkSync).mockReturnValue(""); + }); + + test("iterates all keys and copies middleware + pages + appPages when destDir is omitted", async () => { + const options = createMockBuildOpts(); + const outputs = createMockOutputs(); + + await copyAdapterFiles(options, "fn-name", "", outputs); + + const dests = getCopyFileSyncDestinations(); + expect(dests).toEqual( + expect.arrayContaining([ + expect.stringContaining("/middleware.js"), + expect.stringContaining("/asset.txt"), + expect.stringContaining("/page.js"), + expect.stringContaining("/foo.js"), + ]) + ); + expect(dests).toHaveLength(4); + }); + + test("destDir overrides the default server-functions/fn-name destination", async () => { + const options = createMockBuildOpts(); + const outputs = createMockOutputs(); + + await copyAdapterFiles(options, "fn-name", "", outputs, "/custom/dest"); + + const dests = getCopyFileSyncDestinations(); + expect(dests).toHaveLength(4); + for (const dest of dests) { + expect(dest.startsWith("/custom/dest/")).toBe(true); + expect(dest.includes("server-functions")).toBe(false); + expect(dest.includes("fn-name")).toBe(false); + } + }); + + test("copies only middleware (and its asset) when caller passes a stripped outputs with empty page arrays", async () => { + const options = createMockBuildOpts(); + const middlewareOnly: NextAdapterOutputs = { + middleware: { + pathname: "/", + filePath: "/app/middleware.js", + assets: { "asset.txt": "/app/asset.txt" }, + }, + pages: [], + pagesApi: [], + appPages: [], + appRoutes: [], + }; + + await copyAdapterFiles(options, "fn-name", "", middlewareOnly, "/app/.open-next/middleware"); + + const dests = getCopyFileSyncDestinations(); + expect(dests).toHaveLength(2); + expect(dests).toEqual( + expect.arrayContaining([ + expect.stringContaining("/middleware.js"), + expect.stringContaining("/asset.txt"), + ]) + ); + for (const dest of dests) { + expect(dest.includes("page.js")).toBe(false); + expect(dest.includes("foo.js")).toBe(false); + } + }); +});