From 6ccda7567e35ab7a19dce589a18c62b684fec744 Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Sat, 27 Jun 2026 17:17:49 +0200 Subject: [PATCH 01/13] refactor: remove createServerBundle.ts and integrate its functionality into core adapter feat: enhance build process with additional server bundle customization options chore: update package.json scripts for improved build and testing workflow test: add unit tests for adapter build process and server bundle generation fix: ensure proper handling of external dependencies and edge configuration in server bundle --- packages/aws/src/adapter.ts | 154 +----- packages/aws/src/build.ts | 10 - packages/cloudflare/src/cli/adapter.ts | 208 +++----- .../cli/build/open-next/createServerBundle.ts | 342 -------------- packages/core/package.json | 11 +- packages/core/src/build/adapter.spec.ts | 443 ++++++++++++++++++ packages/core/src/build/adapter.ts | 224 +++++++++ packages/core/src/build/createServerBundle.ts | 35 +- packages/core/tsconfig.json | 3 +- pnpm-lock.yaml | 6 + 10 files changed, 777 insertions(+), 659 deletions(-) delete mode 100644 packages/cloudflare/src/cli/build/open-next/createServerBundle.ts create mode 100644 packages/core/src/build/adapter.spec.ts create mode 100644 packages/core/src/build/adapter.ts diff --git a/packages/aws/src/adapter.ts b/packages/aws/src/adapter.ts index a89d527e..48cbc9b1 100644 --- a/packages/aws/src/adapter.ts +++ b/packages/aws/src/adapter.ts @@ -1,147 +1,15 @@ -import fs from "node:fs"; -import { createRequire } from "node:module"; -import path from "node:path"; - -import { compileCache } from "@opennextjs/core/build/compileCache.js"; -import { compileOpenNextConfig } from "@opennextjs/core/build/compileConfig.js"; -import { compileTagCacheProvider } from "@opennextjs/core/build/compileTagCacheProvider.js"; -import { createCacheAssets, createStaticAssets } from "@opennextjs/core/build/createAssets.js"; -import { createImageOptimizationBundle } from "@opennextjs/core/build/createImageOptimizationBundle.js"; -import { createMiddleware } from "@opennextjs/core/build/createMiddleware.js"; -import { createRevalidationBundle } from "@opennextjs/core/build/createRevalidationBundle.js"; -import { createServerBundle } from "@opennextjs/core/build/createServerBundle.js"; -import { createWarmerBundle } from "@opennextjs/core/build/createWarmerBundle.js"; -import { generateOutput } from "@opennextjs/core/build/generateOutput.js"; +import { buildAdapter } from "@opennextjs/core/build/adapter.js"; +import type { BuildOptions } from "@opennextjs/core/build/helper.js"; import * as buildHelper from "@opennextjs/core/build/helper.js"; -import { addDebugFile } from "@opennextjs/core/debug.js"; import type { ContentUpdater } from "@opennextjs/core/plugins/content-updater.js"; import { externalChunksPlugin, inlineRouteHandler } from "@opennextjs/core/plugins/inlineRouteHandlers.js"; -import type { NextConfig } from "@opennextjs/core/types/next-types.js"; - -export type NextAdapterOutput = { - pathname: string; - filePath: string; - assets: Record; -}; - -export type NextAdapterOutputs = { - pages: NextAdapterOutput[]; - pagesApi: NextAdapterOutput[]; - appPages: NextAdapterOutput[]; - appRoutes: NextAdapterOutput[]; - middleware?: NextAdapterOutput; -}; - -type NextAdapter = { - name: string; - modifyConfig: (config: NextConfig, { phase }: { phase: string }) => Promise; - onBuildComplete: (props: { - routes: unknown; - outputs: NextAdapterOutputs; - projectDir: string; - repoRoot: string; - distDir: string; - config: NextConfig; - nextVersion: string; - }) => Promise; -}; //TODO: use the one provided by Next - -let buildOpts: buildHelper.BuildOptions; - -export default { - name: "OpenNext", - async modifyConfig(nextConfig, { phase }) { - // We have to precompile the cache here, probably compile OpenNext config as well - const { config, buildDir } = await compileOpenNextConfig("open-next.config.ts", { - nodeExternals: undefined, - }); - - const require = createRequire(import.meta.url); - //TODO: change that - const openNextDistDir = path.dirname(require.resolve("@opennextjs/core/debug.js")); - - buildOpts = buildHelper.normalizeOptions(config, openNextDistDir, buildDir); - - buildHelper.initOutputDir(buildOpts); - - const cache = compileCache(buildOpts); - - const packagePath = buildHelper.getPackagePath(buildOpts); - - // We then have to copy the cache files to the .next dir so that they are available at runtime - //TODO: use a better path, this one is temporary just to make it work - const tempCachePath = path.join( - buildOpts.outputDir, - "server-functions/default", - packagePath, - ".open-next/.build" - ); - fs.mkdirSync(tempCachePath, { recursive: true }); - fs.copyFileSync(cache.cache, path.join(tempCachePath, "cache.cjs")); - fs.copyFileSync(cache.composableCache, path.join(tempCachePath, "composable-cache.cjs")); - - //TODO: We should check the version of Next here, below 16 we'd throw or show a warning - return { - ...nextConfig, - cacheHandler: cache.cache, //TODO: compute that here, - cacheHandlers: { - default: cache.composableCache, - remote: cache.composableCache, - }, - cacheMaxMemorySize: 0, - experimental: { - ...nextConfig.experimental, - trustHostHeader: true, - }, - }; - }, - async onBuildComplete(outputs) { - console.log("OpenNext build will start now"); - - // TODO(vicb): save outputs - addDebugFile(buildOpts, "outputs.json", outputs); - - // Compile middleware - await createMiddleware(buildOpts); - console.log("Middleware created"); - - createStaticAssets(buildOpts); - console.log("Static assets created"); - - if (buildOpts.config.dangerous?.disableIncrementalCache !== true) { - const { useTagCache } = createCacheAssets(buildOpts); - console.log("Cache assets created"); - if (useTagCache) { - await compileTagCacheProvider(buildOpts); - console.log("Tag cache provider compiled"); - } - } - - await createServerBundle( - buildOpts, - { - additionalPlugins: getAdditionalPluginsFactory(buildOpts, outputs.outputs), - }, - outputs.outputs - ); - - console.log("Server bundle created"); - await createRevalidationBundle(buildOpts); - console.log("Revalidation bundle created"); - await createImageOptimizationBundle(buildOpts); - console.log("Image optimization bundle created"); - await createWarmerBundle(buildOpts); - console.log("Warmer bundle created"); - await generateOutput(buildOpts); - console.log("Output generated"); +import type { NextAdapterOutputs } from "@opennextjs/core/types/adapter.js"; + +export default buildAdapter((_config, buildOpts: BuildOptions) => ({ + serverBundle: { + additionalPlugins: (updater: ContentUpdater, outputs: NextAdapterOutputs) => { + const packagePath = buildHelper.getPackagePath(buildOpts); + return [inlineRouteHandler(updater, outputs, packagePath), externalChunksPlugin(outputs, packagePath)]; + }, }, -} satisfies NextAdapter; - -function getAdditionalPluginsFactory(buildOpts: buildHelper.BuildOptions, outputs: NextAdapterOutputs) { - //TODO: we should make this a property of buildOpts - const packagePath = buildHelper.getPackagePath(buildOpts); - return (updater: ContentUpdater) => [ - inlineRouteHandler(updater, outputs, packagePath), - externalChunksPlugin(outputs, packagePath), - ]; -} +})); diff --git a/packages/aws/src/build.ts b/packages/aws/src/build.ts index d74befcd..ade47ee8 100755 --- a/packages/aws/src/build.ts +++ b/packages/aws/src/build.ts @@ -3,18 +3,8 @@ import path from "node:path"; import url from "node:url"; import { buildNextjsApp, setStandaloneBuildMode } from "@opennextjs/core/build/buildNextApp.js"; -import { compileCache } from "@opennextjs/core/build/compileCache.js"; import { compileOpenNextConfig } from "@opennextjs/core/build/compileConfig.js"; -import { compileTagCacheProvider } from "@opennextjs/core/build/compileTagCacheProvider.js"; -import { createCacheAssets, createStaticAssets } from "@opennextjs/core/build/createAssets.js"; -import { createImageOptimizationBundle } from "@opennextjs/core/build/createImageOptimizationBundle.js"; -import { createMiddleware } from "@opennextjs/core/build/createMiddleware.js"; -import { createRevalidationBundle } from "@opennextjs/core/build/createRevalidationBundle.js"; -import { createServerBundle } from "@opennextjs/core/build/createServerBundle.js"; -import { createWarmerBundle } from "@opennextjs/core/build/createWarmerBundle.js"; -import { generateOutput } from "@opennextjs/core/build/generateOutput.js"; import * as buildHelper from "@opennextjs/core/build/helper.js"; -import { patchOriginalNextConfig } from "@opennextjs/core/build/patch/patches/index.js"; import { printHeader, showWarningOnWindows } from "@opennextjs/core/build/utils.js"; import logger from "@opennextjs/core/logger.js"; diff --git a/packages/cloudflare/src/cli/adapter.ts b/packages/cloudflare/src/cli/adapter.ts index ee452c1f..b3079449 100644 --- a/packages/cloudflare/src/cli/adapter.ts +++ b/packages/cloudflare/src/cli/adapter.ts @@ -1,18 +1,17 @@ /* oxlint-disable @typescript-eslint/no-explicit-any */ import fs from "node:fs"; -import { createRequire } from "node:module"; import path from "node:path"; -import { compileCache } from "@opennextjs/core/build/compileCache.js"; -import { compileOpenNextConfig } from "@opennextjs/core/build/compileConfig.js"; -import { compileTagCacheProvider } from "@opennextjs/core/build/compileTagCacheProvider.js"; -import { createCacheAssets, createStaticAssets } from "@opennextjs/core/build/createAssets.js"; -import { createMiddleware } from "@opennextjs/core/build/createMiddleware.js"; +import { buildAdapter } from "@opennextjs/core/build/adapter.js"; +import type { BuildOptions } from "@opennextjs/core/build/helper.js"; import * as buildHelper from "@opennextjs/core/build/helper.js"; -import { addDebugFile } from "@opennextjs/core/debug.js"; import type { ContentUpdater } from "@opennextjs/core/plugins/content-updater.js"; +import { openNextEdgePlugins } from "@opennextjs/core/plugins/edge.js"; +import { openNextExternalMiddlewarePlugin } from "@opennextjs/core/plugins/externalMiddleware.js"; import { inlineRouteHandler } from "@opennextjs/core/plugins/inlineRouteHandlers.js"; -import type { NextConfig } from "@opennextjs/core/types/next-types.js"; +import type { NextAdapterOutputs } from "@opennextjs/core/types/adapter.js"; +import type { OpenNextConfig } from "@opennextjs/core/types/open-next.js"; +import { normalizePath } from "@opennextjs/core/utils/normalize-path.js"; import { bundleServer } from "./build/bundle-server.js"; import { compileEnvFiles } from "./build/open-next/compile-env-files.js"; @@ -20,145 +19,60 @@ 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 { createServerBundle } from "./build/open-next/createServerBundle.js"; import { inlineLoadManifest } from "./build/patches/plugins/load-manifest.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"; -export type NextAdapterOutputs = { - pages: any[]; - pagesApi: any[]; - appPages: any[]; - appRoutes: any[]; -}; - -export type BuildCompleteCtx = { - routes: any; - outputs: NextAdapterOutputs; - projectDir: string; - repoRoot: string; - distDir: string; - config: NextConfig; - nextVersion: string; -}; - -type NextAdapter = { - name: string; - modifyConfig: (config: NextConfig, { phase }: { phase: string }) => Promise; - onBuildComplete: (ctx: BuildCompleteCtx) => Promise; -}; //TODO: use the one provided by Next - -let buildOpts: buildHelper.BuildOptions; - -export default { - name: "OpenNext", - - async modifyConfig(nextConfig) { - // We have to precompile the cache here, probably compile OpenNext config as well - const { config, buildDir } = await compileOpenNextConfig("open-next.config.ts", { - // TODO(vicb): do we need edge compile - compileEdge: true, - }); - - const require = createRequire(import.meta.url); - const openNextDistDir = path.dirname(require.resolve("@opennextjs/core/debug.js")); - - buildOpts = buildHelper.normalizeOptions(config, openNextDistDir, buildDir); - - buildHelper.initOutputDir(buildOpts); - - const cache = compileCache(buildOpts); - - // We then have to copy the cache files to the .next dir so that they are available at runtime - // TODO: use a better path, this one is temporary just to make it work - const tempCachePath = `${buildOpts.outputDir}/server-functions/default/.open-next/.build`; - fs.mkdirSync(tempCachePath, { recursive: true }); - fs.copyFileSync(cache.cache, path.join(tempCachePath, "cache.cjs")); - fs.copyFileSync(cache.composableCache, path.join(tempCachePath, "composable-cache.cjs")); - - //TODO: We should check the version of Next here, below 16 we'd throw or show a warning - return { - ...nextConfig, - cacheHandler: cache.cache, //TODO: compute that here, - cacheMaxMemorySize: 0, - cacheHandlers: { - default: cache.composableCache, - remote: cache.composableCache, - }, - experimental: { - ...nextConfig.experimental, - trustHostHeader: true, - }, - }; - }, - - async onBuildComplete(ctx: BuildCompleteCtx) { - console.log("OpenNext build will start now"); - - const configPath = path.join(buildOpts.appBuildOutputPath, ".open-next/.build/open-next.config.edge.mjs"); - if (!fs.existsSync(configPath)) { - throw new Error("Could not find compiled Open Next config, did you run the build command?"); - } - const openNextConfig = await import(configPath).then((mod) => mod.default); - - // TODO(vicb): save outputs - addDebugFile(buildOpts, "outputs.json", ctx); - - // Cloudflare specific - compileEnvFiles(buildOpts); - /* TODO(vicb): pass the wrangler config*/ - await compileInit(buildOpts, {} as any); - await compileImages(buildOpts); - await compileSkewProtection(buildOpts, openNextConfig); - - // Compile middleware - // TODO(vicb): `forceOnlyBuildOnce` is cloudflare specific - await createMiddleware(buildOpts, { forceOnlyBuildOnce: true }); - console.log("Middleware created"); - - createStaticAssets(buildOpts); - console.log("Static assets created"); - - if (buildOpts.config.dangerous?.disableIncrementalCache !== true) { - const { useTagCache } = createCacheAssets(buildOpts); - console.log("Cache assets created"); - if (useTagCache) { - await compileTagCacheProvider(buildOpts); - console.log("Tag cache provider compiled"); - } - } - - await createServerBundle( - buildOpts, - { - additionalPlugins: getAdditionalPluginsFactory(buildOpts, ctx), - }, - ctx - ); - - await compileDurableObjects(buildOpts); - - // TODO(vicb): pass minify `projectOpts` - await bundleServer(buildOpts, { minify: false } as any); - - console.log("OpenNext build complete."); - - // TODO(vicb): not needed on cloudflare - // console.log("Server bundle created"); - // await createRevalidationBundle(buildOpts); - // console.log("Revalidation bundle created"); - // await createImageOptimizationBundle(buildOpts); - // console.log("Image optimization bundle created"); - // await createWarmerBundle(buildOpts); - // console.log("Warmer bundle created"); - // await generateOutput(buildOpts); - // console.log("Output generated"); - }, -} satisfies NextAdapter; - -function getAdditionalPluginsFactory(buildOpts: buildHelper.BuildOptions, ctx: BuildCompleteCtx) { +export default buildAdapter((config: OpenNextConfig, buildOpts: BuildOptions) => { const packagePath = buildHelper.getPackagePath(buildOpts); - return (updater: ContentUpdater) => [ - inlineRouteHandler(updater, ctx.outputs, packagePath), - //externalChunksPlugin(outputs), - inlineLoadManifest(updater, buildOpts), - ]; -} + return { + skipRevalidation: true, + skipImageOptimization: true, + skipWarmer: true, + skipGenerateOutput: true, + middlewareOptions: { forceOnlyBuildOnce: true }, + beforeMiddleware: async (buildOpts, _config) => { + // Import edge-compiled config for skew protection + const configPath = path.join( + buildOpts.appBuildOutputPath, + ".open-next/.build/open-next.config.edge.mjs" + ); + const openNextConfig = fs.existsSync(configPath) + ? await import(configPath).then((mod) => mod.default) + : config; // fallback to node config + compileEnvFiles(buildOpts); + await compileInit(buildOpts, {} as any); + await compileImages(buildOpts); + await compileSkewProtection(buildOpts, openNextConfig); + }, + serverBundle: { + useEdgeConfig: true, + externals: ["./middleware.mjs"], + banner: (name: string) => [ + `globalThis.monorepoPackagePath = "${normalizePath(packagePath)}";`, + name === "default" ? "" : `globalThis.fnName = "${name}";`, + ], + 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, patchTurbopackRuntime], + }, + afterServerBundle: async (buildOpts, _config) => { + compileDurableObjects(buildOpts); + await bundleServer(buildOpts, { minify: false } as any); + }, + }; +}); diff --git a/packages/cloudflare/src/cli/build/open-next/createServerBundle.ts b/packages/cloudflare/src/cli/build/open-next/createServerBundle.ts deleted file mode 100644 index d5508952..00000000 --- a/packages/cloudflare/src/cli/build/open-next/createServerBundle.ts +++ /dev/null @@ -1,342 +0,0 @@ -// Copy-Edit of @opennextjs/core packages/open-next/src/build/createServerBundle.ts -// Adapted for cloudflare workers - -import fs from "node:fs"; -import path from "node:path"; - -import { loadMiddlewareManifest } from "@opennextjs/core/adapters/config/util.js"; -import { compileCache } from "@opennextjs/core/build/compileCache.js"; -import { copyAdapterFiles } from "@opennextjs/core/build/copyAdapterFiles.js"; -import { copyMiddlewareResources, generateEdgeBundle } from "@opennextjs/core/build/edge/createEdgeBundle.js"; -import * as buildHelper from "@opennextjs/core/build/helper.js"; -import { installDependencies } from "@opennextjs/core/build/installDeps.js"; -import type { CodePatcher } from "@opennextjs/core/build/patch/codePatcher.js"; -import { applyCodePatches } from "@opennextjs/core/build/patch/codePatcher.js"; -import * as awsPatches from "@opennextjs/core/build/patch/patches/index.js"; -import logger from "@opennextjs/core/logger.js"; -import { minifyAll } from "@opennextjs/core/minimize-js.js"; -import { ContentUpdater } from "@opennextjs/core/plugins/content-updater.js"; -import { openNextEdgePlugins } from "@opennextjs/core/plugins/edge.js"; -import { openNextExternalMiddlewarePlugin } from "@opennextjs/core/plugins/externalMiddleware.js"; -import { openNextReplacementPlugin } from "@opennextjs/core/plugins/replacement.js"; -import { openNextResolvePlugin } from "@opennextjs/core/plugins/resolve.js"; -import type { FunctionOptions, SplittedFunctionOptions } from "@opennextjs/core/types/open-next.js"; -import { getCrossPlatformPathRegex } from "@opennextjs/core/utils/regex.js"; -import type { Plugin } from "esbuild"; - -import type { BuildCompleteCtx } from "../../adapter.js"; -import { normalizePath } from "../../utils/normalize-path.js"; -import { patchResRevalidate } from "../patches/plugins/res-revalidate.js"; -import { patchTurbopackRuntime } from "../patches/plugins/turbopack.js"; -import { patchUseCacheIO } from "../patches/plugins/use-cache.js"; - -interface CodeCustomization { - // These patches are meant to apply on user and next generated code - additionalCodePatches?: CodePatcher[]; - // These plugins are meant to apply during the esbuild bundling process. - // This will only apply to OpenNext code. - additionalPlugins?: (contentUpdater: ContentUpdater) => Plugin[]; -} - -export async function createServerBundle( - options: buildHelper.BuildOptions, - codeCustomization?: CodeCustomization, - /* TODO(vicb): optional to be backward compatible */ - buildCtx?: BuildCompleteCtx -) { - const { config } = options; - const foundRoutes = new Set(); - // Get all functions to build - const defaultFn = config.default; - const functions = Object.entries(config.functions ?? {}); - - // Recompile cache.ts as ESM if any function is using Deno runtime - if (defaultFn.runtime === "deno" || functions.some(([, fn]) => fn.runtime === "deno")) { - compileCache(options, "esm"); - } - - const promises = functions.map(async ([name, fnOptions]) => { - const routes = fnOptions.routes; - routes.forEach((route) => foundRoutes.add(route)); - if (fnOptions.runtime === "edge") { - await generateEdgeBundle(name, options, fnOptions); - } else { - await generateBundle(name, options, fnOptions, codeCustomization, buildCtx); - } - }); - - //TODO: throw an error if not all edge runtime routes has been bundled in a separate function - - // We build every other function than default before so we know which route there is left - await Promise.all(promises); - - const remainingRoutes = new Set(); - - const { appBuildOutputPath } = options; - - // Find remaining routes - const serverPath = path.join( - appBuildOutputPath, - ".next/standalone", - buildHelper.getPackagePath(options), - ".next/server" - ); - - // Find app dir routes - if (fs.existsSync(path.join(serverPath, "app"))) { - const appPath = path.join(serverPath, "app"); - buildHelper.traverseFiles( - appPath, - ({ relativePath }) => relativePath.endsWith("page.js") || relativePath.endsWith("route.js"), - ({ relativePath }) => { - const route = `app/${relativePath.replace(/\.js$/, "")}`; - if (!foundRoutes.has(route)) { - remainingRoutes.add(route); - } - } - ); - } - - // Find pages dir routes - if (fs.existsSync(path.join(serverPath, "pages"))) { - const pagePath = path.join(serverPath, "pages"); - buildHelper.traverseFiles( - pagePath, - ({ relativePath }) => relativePath.endsWith(".js"), - ({ relativePath }) => { - const route = `pages/${relativePath.replace(/\.js$/, "")}`; - if (!foundRoutes.has(route)) { - remainingRoutes.add(route); - } - } - ); - } - - // Generate default function - await generateBundle( - "default", - options, - { - ...defaultFn, - // @ts-expect-error - Those string are RouteTemplate - routes: Array.from(remainingRoutes), - patterns: ["*"], - }, - codeCustomization, - buildCtx - ); -} - -async function generateBundle( - name: string, - options: buildHelper.BuildOptions, - fnOptions: SplittedFunctionOptions, - codeCustomization?: CodeCustomization, - buildCtx?: BuildCompleteCtx -) { - const { appPath, appBuildOutputPath, config, outputDir, monorepoRoot } = options; - logger.info(`Building server function: ${name}...`); - - // Create output folder - const outputPath = path.join(outputDir, "server-functions", name); - - // Resolve path to the Next.js app if inside the monorepo - // note: if user's app is inside a monorepo, standalone mode places - // `node_modules` inside `.next/standalone`, and others inside - // `.next/standalone/package/path` (ie. `.next`, `server.js`). - // We need to output the handler file inside the package path. - const packagePath = buildHelper.getPackagePath(options); - const outPackagePath = path.join(outputPath, packagePath); - fs.mkdirSync(outPackagePath, { recursive: true }); - - const ext = fnOptions.runtime === "deno" ? "mjs" : "cjs"; - // Normal cache - fs.copyFileSync(path.join(options.buildDir, `cache.${ext}`), path.join(outPackagePath, "cache.cjs")); - - // Composable cache - fs.copyFileSync( - path.join(options.buildDir, `composable-cache.${ext}`), - path.join(outPackagePath, "composable-cache.cjs") - ); - - if (fnOptions.runtime === "deno") { - addDenoJson(outputPath, packagePath); - } - - // Copy middleware - if (!config.middleware?.external) { - fs.copyFileSync( - path.join(options.buildDir, "middleware.mjs"), - path.join(outPackagePath, "middleware.mjs") - ); - - const middlewareManifest = loadMiddlewareManifest(path.join(options.appBuildOutputPath, ".next")); - - copyMiddlewareResources(options, middlewareManifest.middleware["/"], outPackagePath); - } - - // Copy open-next.config.mjs - buildHelper.copyOpenNextConfig(options.buildDir, outPackagePath, true); - - // Copy env files - buildHelper.copyEnvFile(appBuildOutputPath, packagePath, outputPath); - - let tracedFiles: string[] = []; - // oxlint-disable-next-line @typescript-eslint/no-explicit-any - let manifests: any = {}; - - // Copy all necessary traced files - if (!buildCtx) { - throw new Error("should not happen"); - } - tracedFiles = await copyAdapterFiles(options, name, packagePath, buildCtx.outputs); - //TODO: we should load manifests here - - // TODO(vicb): what should `nodePackages` be for the adapter - // if (getOpenNextConfig(options).cloudflare?.useWorkerdCondition !== false) { - // // Next does not trace the "workerd" build condition - // // So we need to copy the whole packages using the condition - // await copyWorkerdPackages(options, nodePackages); - // } - - const additionalCodePatches = codeCustomization?.additionalCodePatches ?? []; - - await applyCodePatches(options, tracedFiles, manifests, [ - awsPatches.patchFetchCacheSetMissingWaitUntil, - awsPatches.patchFetchCacheForISR, - awsPatches.patchUnstableCacheForISR, - awsPatches.patchUseCacheForISR, - awsPatches.patchNextServer, - awsPatches.getEnvVarsPatch(options), - awsPatches.patchBackgroundRevalidation, - awsPatches.patchNodeEnvironment, - // Cloudflare specific patches - patchResRevalidate, - patchUseCacheIO, - patchTurbopackRuntime, - ...additionalCodePatches, - ]); - - // Build Lambda code - // note: bundle in OpenNext package b/c the adapter relies on the - // "serverless-http" package which is not a dependency in user's - // Next.js app. - - const overrides = fnOptions.override ?? {}; - - const disableRouting = config.middleware?.external; - - const updater = new ContentUpdater(options); - - const additionalPlugins = codeCustomization?.additionalPlugins - ? codeCustomization.additionalPlugins(updater) - : []; - - const plugins = [ - openNextReplacementPlugin({ - name: `requestHandlerOverride ${name}`, - target: getCrossPlatformPathRegex("core/requestHandler.js"), - deletes: disableRouting ? ["withRouting"] : [], - }), - - openNextResolvePlugin({ - fnName: name, - overrides, - }), - - // `openNextExternalMiddlewarePlugin` should only be used with an external middleware - ...(config.middleware?.external - ? [openNextExternalMiddlewarePlugin(path.join(options.openNextDistDir, "core/edgeFunctionHandler.js"))] - : []), - - openNextEdgePlugins({ - nextDir: path.join(options.appBuildOutputPath, ".next"), - isInCloudflare: true, - }), - ...additionalPlugins, - // The content updater plugin must be the last plugin - updater.plugin, - ]; - - const outfileExt = fnOptions.runtime === "deno" ? "ts" : "mjs"; - await buildHelper.esbuildAsync( - { - entryPoints: [path.join(options.openNextDistDir, "adapters", "server-adapter.js")], - outfile: path.join(outputPath, packagePath, `index.${outfileExt}`), - external: ["./middleware.mjs"], - banner: { - js: [ - `globalThis.monorepoPackagePath = "${normalizePath(packagePath)}";`, - name === "default" ? "" : `globalThis.fnName = "${name}";`, - ].join(""), - }, - plugins, - }, - options - ); - - const isMonorepo = monorepoRoot !== appPath; - if (isMonorepo) { - addMonorepoEntrypoint(outputPath, packagePath); - } - - installDependencies(outputPath, fnOptions.install); - - if (fnOptions.minify) { - await minifyServerBundle(outputPath); - } - - const shouldGenerateDocker = shouldGenerateDockerfile(fnOptions); - if (shouldGenerateDocker) { - fs.writeFileSync( - path.join(outputPath, "Dockerfile"), - typeof shouldGenerateDocker === "string" - ? shouldGenerateDocker - : ` -FROM node:18-alpine -WORKDIR /app -COPY . /app -EXPOSE 3000 -CMD ["node", "index.mjs"] - ` - ); - } -} - -function shouldGenerateDockerfile(options: FunctionOptions) { - return options.override?.generateDockerfile ?? false; -} - -// Add deno.json file to enable "bring your own node_modules" mode. -// TODO: this won't be necessary in Deno 2. See https://github.com/denoland/deno/issues/23151 -function addDenoJson(outputPath: string, packagePath: string) { - const config = { - // Enable "bring your own node_modules" mode - // and allow `__proto__` - unstable: ["byonm", "fs", "unsafe-proto"], - }; - fs.writeFileSync(path.join(outputPath, packagePath, "deno.json"), JSON.stringify(config, null, 2)); -} - -//TODO: check if this PR is still necessary https://github.com/opennextjs/opennextjs-aws/pull/341 -function addMonorepoEntrypoint(outputPath: string, packagePath: string) { - // Note: in the monorepo case, the handler file is output to - // `.next/standalone/package/path/index.mjs`, but we want - // the Lambda function to be able to find the handler at - // the root of the bundle. We will create a dummy `index.mjs` - // that re-exports the real handler. - - fs.writeFileSync( - path.join(outputPath, "index.mjs"), - `export { handler } from "./${normalizePath(packagePath)}/index.mjs";` - ); -} - -async function minifyServerBundle(outputDir: string) { - logger.info("Minimizing server function..."); - - await minifyAll(outputDir, { - compress_json: true, - mangle: true, - }); -} diff --git a/packages/core/package.json b/packages/core/package.json index de13a740..87e65e29 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -39,9 +39,12 @@ "access": "public" }, "scripts": { - "build": "tsc && tsc-alias", + "clean": "rimraf dist", + "build": "pnpm clean && tsc && tsc-alias", "dev": "concurrently \"tsc -w\" \"tsc-alias -w\"", - "ts:check": "tsc --noEmit" + "ts:check": "tsc --noEmit", + "test": "vitest --run", + "test:watch": "vitest" }, "dependencies": { "@ast-grep/napi": "^0.40.5", @@ -60,8 +63,10 @@ "@types/express": "5.0.6", "@types/node": "catalog:", "concurrently": "^9.2.1", + "rimraf": "catalog:", "tsc-alias": "^1.8.16", - "typescript": "catalog:" + "typescript": "catalog:", + "vitest": "catalog:" }, "peerDependencies": { "next": "^16.0.10" diff --git a/packages/core/src/build/adapter.spec.ts b/packages/core/src/build/adapter.spec.ts new file mode 100644 index 00000000..690c787c --- /dev/null +++ b/packages/core/src/build/adapter.spec.ts @@ -0,0 +1,443 @@ +/* eslint-disable import/first */ +import { beforeEach, describe, expect, test, vi } from "vitest"; + +// Mock node:fs to prevent actual file operations +vi.mock("node:fs", () => ({ + default: { + mkdirSync: vi.fn(), + copyFileSync: vi.fn(), + }, + mkdirSync: vi.fn(), + copyFileSync: vi.fn(), +})); + +// Mock node:module to control createRequire +vi.mock("node:module", () => ({ + createRequire: vi.fn(() => ({ + resolve: vi.fn(() => "/fake/opennext/dist/debug.js"), + })), +})); + +// Mock all build functions +vi.mock("./compileConfig.js", () => ({ + compileOpenNextConfig: vi.fn(), +})); + +vi.mock("./compileCache.js", () => ({ + compileCache: vi.fn(), +})); + +vi.mock("./createMiddleware.js", () => ({ + createMiddleware: vi.fn(), +})); + +vi.mock("./createAssets.js", () => ({ + createStaticAssets: vi.fn(), + createCacheAssets: vi.fn(), +})); + +vi.mock("./compileTagCacheProvider.js", () => ({ + compileTagCacheProvider: vi.fn(), +})); + +vi.mock("./createServerBundle.js", () => ({ + createServerBundle: vi.fn(), +})); + +vi.mock("./createRevalidationBundle.js", () => ({ + createRevalidationBundle: vi.fn(), +})); + +vi.mock("./createImageOptimizationBundle.js", () => ({ + createImageOptimizationBundle: vi.fn(), +})); + +vi.mock("./createWarmerBundle.js", () => ({ + createWarmerBundle: vi.fn(), +})); + +vi.mock("./generateOutput.js", () => ({ + generateOutput: vi.fn(), +})); + +vi.mock("../debug.js", () => ({ + addDebugFile: vi.fn(), +})); + +vi.mock("./helper.js", () => ({ + normalizeOptions: vi.fn(), + initOutputDir: vi.fn(), + getPackagePath: vi.fn(), +})); + +import { addDebugFile } from "../debug.js"; +import type { OpenNextConfig } from "../types/open-next.js"; + +import { buildAdapter } from "./adapter.js"; +import type { OpenNextAdapterOptions, BuildCompleteContext, NextAdapter } from "./adapter.js"; +import { compileCache } from "./compileCache.js"; +// Import mocked modules after vi.mock declarations +import { compileOpenNextConfig } from "./compileConfig.js"; +import { compileTagCacheProvider } from "./compileTagCacheProvider.js"; +import { createStaticAssets, createCacheAssets } from "./createAssets.js"; +import { createImageOptimizationBundle } from "./createImageOptimizationBundle.js"; +import { createMiddleware } from "./createMiddleware.js"; +import { createRevalidationBundle } from "./createRevalidationBundle.js"; +import { createServerBundle } from "./createServerBundle.js"; +import { createWarmerBundle } from "./createWarmerBundle.js"; +import { generateOutput } from "./generateOutput.js"; +import * as buildHelper from "./helper.js"; +import type { BuildOptions } from "./helper.js"; + +// Helper to create mock build options +function createMockBuildOpts(): 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 OpenNextConfig, + 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", + } as BuildOptions; +} + +// Helper to create mock BuildCompleteContext +function createMockContext(): BuildCompleteContext { + return { + routes: [], + outputs: { + pages: [], + pagesApi: [], + appPages: [], + appRoutes: [], + }, + projectDir: "/app", + repoRoot: "/app", + distDir: "/app/.next", + config: { + experimental: {}, + images: {}, + } as BuildCompleteContext["config"], + nextVersion: "16.0.0", + }; +} + +describe("buildAdapter", () => { + beforeEach(() => { + vi.clearAllMocks(); + + // Set up default mock implementations + const mockBuildOpts = createMockBuildOpts(); + + vi.mocked(compileOpenNextConfig).mockResolvedValue({ + config: { default: {}, dangerous: {} } as unknown as OpenNextConfig, + buildDir: "/tmp/open-next-tmp", + }); + + vi.mocked(buildHelper.normalizeOptions).mockReturnValue(mockBuildOpts); + vi.mocked(buildHelper.initOutputDir).mockImplementation(() => {}); + vi.mocked(buildHelper.getPackagePath).mockReturnValue(""); + + vi.mocked(compileCache).mockReturnValue({ + cache: "/tmp/cache.cjs", + composableCache: "/tmp/composable-cache.cjs", + }); + + vi.mocked(createCacheAssets).mockReturnValue({ + useTagCache: false, + metaFiles: [], + }); + }); + + test("returns an object with name, modifyConfig, and onBuildComplete", () => { + const adapter = buildAdapter(() => ({})); + + expect(adapter.name).toBe("OpenNext"); + expect(typeof adapter.modifyConfig).toBe("function"); + expect(typeof adapter.onBuildComplete).toBe("function"); + }); + + test("modifyConfig calls compileOpenNextConfig with compileEdge: true, then callback", async () => { + const mockCallback = vi.fn(() => ({})); + const adapter = buildAdapter(mockCallback); + + const nextConfig = { experimental: {}, images: {} } as BuildCompleteContext["config"]; + await adapter.modifyConfig(nextConfig, { phase: "production" }); + + expect(compileOpenNextConfig).toHaveBeenCalledWith("open-next.config.ts", { compileEdge: true }); + expect(mockCallback).toHaveBeenCalledOnce(); + // The callback receives (config, buildOpts) + expect(mockCallback).toHaveBeenCalledWith(expect.objectContaining({ default: {} }), expect.any(Object)); + }); + + test("modifyConfig returns nextConfig with cacheHandler, cacheHandlers, cacheMaxMemorySize, and trustHostHeader", async () => { + const adapter = buildAdapter(() => ({})); + + const nextConfig = { + experimental: { serverActions: true }, + images: {}, + } as unknown as BuildCompleteContext["config"]; + + const result = await adapter.modifyConfig(nextConfig, { phase: "production" }); + + expect(result.cacheHandler).toBe("/tmp/cache.cjs"); + expect(result.cacheHandlers).toEqual({ + default: "/tmp/composable-cache.cjs", + remote: "/tmp/composable-cache.cjs", + }); + expect(result.cacheMaxMemorySize).toBe(0); + expect(result.experimental.trustHostHeader).toBe(true); + // Original experimental properties preserved + expect(result.experimental.serverActions).toBe(true); + }); + + test("onBuildComplete calls createMiddleware with influence.middlewareOptions", async () => { + const adapter = buildAdapter(() => ({ + middlewareOptions: { forceOnlyBuildOnce: true }, + })); + + 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), { forceOnlyBuildOnce: true }); + }); + + test("onBuildComplete skips createRevalidationBundle when skipRevalidation is true", async () => { + const adapter = buildAdapter(() => ({ + skipRevalidation: true, + })); + + const nextConfig = { experimental: {}, images: {} } as BuildCompleteContext["config"]; + await adapter.modifyConfig(nextConfig, { phase: "production" }); + + const ctx = createMockContext(); + await adapter.onBuildComplete(ctx); + + expect(createRevalidationBundle).not.toHaveBeenCalled(); + }); + + test("onBuildComplete calls influence.beforeMiddleware BEFORE createMiddleware", async () => { + const callOrder: string[] = []; + + const adapter = buildAdapter(() => ({ + beforeMiddleware: vi.fn(async () => { + callOrder.push("beforeMiddleware"); + }), + })); + + vi.mocked(createMiddleware).mockImplementation(async () => { + callOrder.push("createMiddleware"); + }); + + const nextConfig = { experimental: {}, images: {} } as BuildCompleteContext["config"]; + await adapter.modifyConfig(nextConfig, { phase: "production" }); + + const ctx = createMockContext(); + await adapter.onBuildComplete(ctx); + + expect(callOrder).toEqual(["beforeMiddleware", "createMiddleware"]); + }); + + test("onBuildComplete calls influence.afterServerBundle after createServerBundle but BEFORE createRevalidationBundle", async () => { + const callOrder: string[] = []; + + const adapter = buildAdapter(() => ({ + afterServerBundle: vi.fn(async () => { + callOrder.push("afterServerBundle"); + }), + })); + + vi.mocked(createServerBundle).mockImplementation(async () => { + callOrder.push("createServerBundle"); + }); + + vi.mocked(createRevalidationBundle).mockImplementation(async () => { + callOrder.push("createRevalidationBundle"); + }); + + const nextConfig = { experimental: {}, images: {} } as BuildCompleteContext["config"]; + await adapter.modifyConfig(nextConfig, { phase: "production" }); + + const ctx = createMockContext(); + await adapter.onBuildComplete(ctx); + + expect(callOrder).toEqual(["createServerBundle", "afterServerBundle", "createRevalidationBundle"]); + }); + + test("edge compilation failure retries with compileEdge: false and logs a warning", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + vi.mocked(compileOpenNextConfig) + .mockRejectedValueOnce(new Error("Edge compilation failed: cannot resolve node:fs")) + .mockResolvedValueOnce({ + config: { default: {}, dangerous: {} } as unknown as OpenNextConfig, + buildDir: "/tmp/open-next-tmp", + }); + + const adapter = buildAdapter(() => ({})); + const nextConfig = { experimental: {}, images: {} } as BuildCompleteContext["config"]; + await adapter.modifyConfig(nextConfig, { phase: "production" }); + + expect(compileOpenNextConfig).toHaveBeenCalledTimes(2); + expect(compileOpenNextConfig).toHaveBeenNthCalledWith(1, "open-next.config.ts", { compileEdge: true }); + expect(compileOpenNextConfig).toHaveBeenNthCalledWith(2, "open-next.config.ts", { compileEdge: false }); + expect(warnSpy).toHaveBeenCalledOnce(); + + warnSpy.mockRestore(); + }); + + test("influence.tempCachePath override is called with (buildOpts, packagePath)", async () => { + const mockTempCachePath = vi.fn(() => "/custom/temp/cache/path"); + + const adapter = buildAdapter(() => ({ + tempCachePath: mockTempCachePath, + })); + + vi.mocked(buildHelper.getPackagePath).mockReturnValue("packages/my-app"); + + const nextConfig = { experimental: {}, images: {} } as BuildCompleteContext["config"]; + await adapter.modifyConfig(nextConfig, { phase: "production" }); + + expect(mockTempCachePath).toHaveBeenCalledWith(expect.any(Object), "packages/my-app"); + + // Verify the custom path was used for mkdirSync + const fs = await import("node:fs"); + expect(fs.default.mkdirSync).toHaveBeenCalledWith("/custom/temp/cache/path", { recursive: true }); + }); + + test("onBuildComplete skips image optimization when skipImageOptimization is true", async () => { + const adapter = buildAdapter(() => ({ + skipImageOptimization: true, + })); + + const nextConfig = { experimental: {}, images: {} } as BuildCompleteContext["config"]; + await adapter.modifyConfig(nextConfig, { phase: "production" }); + + const ctx = createMockContext(); + await adapter.onBuildComplete(ctx); + + expect(createImageOptimizationBundle).not.toHaveBeenCalled(); + }); + + test("onBuildComplete skips warmer when skipWarmer is true", async () => { + const adapter = buildAdapter(() => ({ + skipWarmer: true, + })); + + const nextConfig = { experimental: {}, images: {} } as BuildCompleteContext["config"]; + await adapter.modifyConfig(nextConfig, { phase: "production" }); + + const ctx = createMockContext(); + await adapter.onBuildComplete(ctx); + + expect(createWarmerBundle).not.toHaveBeenCalled(); + }); + + test("onBuildComplete skips generateOutput when skipGenerateOutput is true", async () => { + const adapter = buildAdapter(() => ({ + skipGenerateOutput: true, + })); + + const nextConfig = { experimental: {}, images: {} } as BuildCompleteContext["config"]; + await adapter.modifyConfig(nextConfig, { phase: "production" }); + + const ctx = createMockContext(); + await adapter.onBuildComplete(ctx); + + expect(generateOutput).not.toHaveBeenCalled(); + }); + + test("onBuildComplete calls addDebugFile with outputs.json", async () => { + const adapter = buildAdapter(() => ({})); + + const nextConfig = { experimental: {}, images: {} } as BuildCompleteContext["config"]; + await adapter.modifyConfig(nextConfig, { phase: "production" }); + + const ctx = createMockContext(); + await adapter.onBuildComplete(ctx); + + expect(addDebugFile).toHaveBeenCalledWith(expect.any(Object), "outputs.json", ctx); + }); + + test("onBuildComplete passes serverBundle customization to createServerBundle", async () => { + const mockPlugins = vi.fn(() => []); + const mockPatches = [{ name: "test-patch", patches: [] }]; + + const adapter = buildAdapter(() => ({ + serverBundle: { + additionalPlugins: mockPlugins, + additionalCodePatches: mockPatches, + useEdgeConfig: true, + externals: ["some-external"], + banner: ["// custom banner"], + }, + })); + + const nextConfig = { experimental: {}, images: {} } as BuildCompleteContext["config"]; + await adapter.modifyConfig(nextConfig, { phase: "production" }); + + const ctx = createMockContext(); + await adapter.onBuildComplete(ctx); + + expect(createServerBundle).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ + additionalPlugins: expect.any(Function), + additionalCodePatches: mockPatches, + useEdgeConfig: true, + externals: ["some-external"], + banner: ["// custom banner"], + }), + ctx.outputs + ); + }); + + test("onBuildComplete compiles tag cache provider when useTagCache is true", async () => { + vi.mocked(createCacheAssets).mockReturnValue({ + useTagCache: true, + metaFiles: [], + }); + + const adapter = buildAdapter(() => ({})); + + const nextConfig = { experimental: {}, images: {} } as BuildCompleteContext["config"]; + await adapter.modifyConfig(nextConfig, { phase: "production" }); + + const ctx = createMockContext(); + await adapter.onBuildComplete(ctx); + + expect(compileTagCacheProvider).toHaveBeenCalledWith(expect.any(Object)); + }); + + test("onBuildComplete skips cache assets when disableIncrementalCache is true", async () => { + const mockBuildOpts = createMockBuildOpts(); + (mockBuildOpts.config as OpenNextConfig).dangerous = { disableIncrementalCache: true }; + vi.mocked(buildHelper.normalizeOptions).mockReturnValue(mockBuildOpts); + + const adapter = buildAdapter(() => ({})); + + const nextConfig = { experimental: {}, images: {} } as BuildCompleteContext["config"]; + await adapter.modifyConfig(nextConfig, { phase: "production" }); + + const ctx = createMockContext(); + await adapter.onBuildComplete(ctx); + + expect(createCacheAssets).not.toHaveBeenCalled(); + expect(compileTagCacheProvider).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/core/src/build/adapter.ts b/packages/core/src/build/adapter.ts new file mode 100644 index 00000000..c68ebb94 --- /dev/null +++ b/packages/core/src/build/adapter.ts @@ -0,0 +1,224 @@ +import fs from "node:fs"; +import { createRequire } from "node:module"; +import path from "node:path"; + +import type { Plugin } from "esbuild"; + +import { addDebugFile } from "../debug.js"; +import type { ContentUpdater } from "../plugins/content-updater.js"; +import type { NextAdapterOutputs } from "../types/adapter.js"; +import type { NextConfig } from "../types/next-types.js"; +import type { OpenNextConfig } from "../types/open-next.js"; + +import { compileCache } from "./compileCache.js"; +import { compileOpenNextConfig } from "./compileConfig.js"; +import { compileTagCacheProvider } from "./compileTagCacheProvider.js"; +import { createCacheAssets, createStaticAssets } from "./createAssets.js"; +import { createImageOptimizationBundle } from "./createImageOptimizationBundle.js"; +import { createMiddleware } from "./createMiddleware.js"; +import { createRevalidationBundle } from "./createRevalidationBundle.js"; +import { createServerBundle } from "./createServerBundle.js"; +import { createWarmerBundle } from "./createWarmerBundle.js"; +import { generateOutput } from "./generateOutput.js"; +import * as buildHelper from "./helper.js"; +import type { CodePatcher } from "./patch/codePatcher.js"; + +const require = createRequire(import.meta.url); + +/** + * The parameter type for onBuildComplete. + */ +export type BuildCompleteContext = { + routes: unknown; + outputs: NextAdapterOutputs; + projectDir: string; + repoRoot: string; + distDir: string; + config: NextConfig; + nextVersion: string; +}; + +/** + * The return type of buildAdapter — the adapter interface that Next.js consumes. + */ +export type NextAdapter = { + name: string; + modifyConfig: (config: NextConfig, { phase }: { phase: string }) => Promise; + onBuildComplete: (props: BuildCompleteContext) => Promise; +}; + +/** + * The influence an adapter can exert on the build process, returned by the callback. + */ +export type OpenNextAdapterOptions = { + skipRevalidation?: boolean; + skipImageOptimization?: boolean; + skipWarmer?: boolean; + skipGenerateOutput?: boolean; + middlewareOptions?: { forceOnlyBuildOnce?: boolean }; + serverBundle?: { + additionalPlugins?: (updater: ContentUpdater, outputs: NextAdapterOutputs) => Plugin[]; + additionalCodePatches?: CodePatcher[]; + useEdgeConfig?: boolean; + externals?: string[]; + banner?: string[] | ((name: string) => string[]); + }; + beforeMiddleware?: (buildOpts: buildHelper.BuildOptions, config: OpenNextConfig) => Promise; + afterServerBundle?: (buildOpts: buildHelper.BuildOptions, config: OpenNextConfig) => Promise; + tempCachePath?: (buildOpts: buildHelper.BuildOptions, packagePath: string) => string; +}; + +/** + * Creates a NextAdapter that orchestrates the OpenNext build pipeline. + * + * This function eliminates duplicated build logic across platform-specific adapters + * (AWS, Cloudflare, etc.) by centralizing the build orchestration in core. + * + * @param callback - A function that receives the OpenNext config and build options, + * returning adapter-specific influence over the build process. + * @returns A NextAdapter with modifyConfig and onBuildComplete hooks. + */ +export function buildAdapter( + callback: (config: OpenNextConfig, buildOpts: buildHelper.BuildOptions) => OpenNextAdapterOptions +): NextAdapter { + // Closure-scoped state — no module-level mutable variables + let buildOpts: buildHelper.BuildOptions; + let config: OpenNextConfig; + let adapterOptions: OpenNextAdapterOptions; + + return { + name: "OpenNext", + + async modifyConfig(nextConfig, { phase: _phase }) { + // Step 1: Compile OpenNext config with edge support, fallback on failure + let result: { config: OpenNextConfig; buildDir: string }; + try { + result = await compileOpenNextConfig("open-next.config.ts", { compileEdge: true }); + } catch (error) { + console.warn( + "Failed to compile open-next.config.ts for edge runtime, falling back to node-only compilation.", + error instanceof Error ? error.message : error + ); + result = await compileOpenNextConfig("open-next.config.ts", { compileEdge: false }); + } + + config = result.config; + const buildDir = result.buildDir; + + // Step 2: Resolve openNextDistDir + const openNextDistDir = path.dirname(require.resolve("@opennextjs/core/debug.js")); + + // Step 3: Normalize options + buildOpts = buildHelper.normalizeOptions(config, openNextDistDir, buildDir); + + // Step 4: Initialize output directory + buildHelper.initOutputDir(buildOpts); + + // Step 5: Compile cache + const cache = compileCache(buildOpts); + + // Step 6: Call the adapter callback to get influence + adapterOptions = callback(config, buildOpts); + + // Step 7: Build tempCachePath + const packagePath = buildHelper.getPackagePath(buildOpts); + const tempCachePath = + adapterOptions.tempCachePath?.(buildOpts, packagePath) ?? + path.join(buildOpts.outputDir, "server-functions/default", packagePath, ".open-next/.build"); + + // Step 8: Copy cache files + fs.mkdirSync(tempCachePath, { recursive: true }); + fs.copyFileSync(cache.cache, path.join(tempCachePath, "cache.cjs")); + fs.copyFileSync(cache.composableCache, path.join(tempCachePath, "composable-cache.cjs")); + + // Step 10: Return modified nextConfig + return { + ...nextConfig, + cacheHandler: cache.cache, + cacheHandlers: { + default: cache.composableCache, + remote: cache.composableCache, + }, + cacheMaxMemorySize: 0, + experimental: { + ...nextConfig.experimental, + trustHostHeader: true, + }, + }; + }, + + async onBuildComplete(ctx) { + console.log("OpenNext build will start now"); + + // Step 1: Save debug output + addDebugFile(buildOpts, "outputs.json", ctx); + + // Step 2: Call beforeMiddleware hook + await adapterOptions.beforeMiddleware?.(buildOpts, config); + + // Step 3: Create middleware + await createMiddleware(buildOpts, adapterOptions.middlewareOptions ?? {}); + console.log("Middleware created"); + + // Step 4: Create static assets + createStaticAssets(buildOpts); + console.log("Static assets created"); + + // Step 5: Cache assets + if (buildOpts.config.dangerous?.disableIncrementalCache !== true) { + const { useTagCache } = createCacheAssets(buildOpts); + console.log("Cache assets created"); + if (useTagCache) { + await compileTagCacheProvider(buildOpts); + console.log("Tag cache provider compiled"); + } + } + + // Step 6: Build wrapped additionalPlugins + const wrappedAdditionalPlugins = adapterOptions.serverBundle?.additionalPlugins + ? (updater: ContentUpdater) => adapterOptions.serverBundle!.additionalPlugins!(updater, ctx.outputs) + : undefined; + + // Step 7: Create server bundle + await createServerBundle( + buildOpts, + { + additionalPlugins: wrappedAdditionalPlugins, + additionalCodePatches: adapterOptions.serverBundle?.additionalCodePatches, + useEdgeConfig: adapterOptions.serverBundle?.useEdgeConfig, + externals: adapterOptions.serverBundle?.externals, + banner: adapterOptions.serverBundle?.banner, + }, + ctx.outputs + ); + console.log("Server bundle created"); + + // Step 8: Call afterServerBundle hook + await adapterOptions.afterServerBundle?.(buildOpts, config); + + // Step 9: Revalidation bundle + if (!adapterOptions.skipRevalidation) { + await createRevalidationBundle(buildOpts); + console.log("Revalidation bundle created"); + } + + // Step 10: Image optimization bundle + if (!adapterOptions.skipImageOptimization) { + await createImageOptimizationBundle(buildOpts); + console.log("Image optimization bundle created"); + } + + // Step 11: Warmer bundle + if (!adapterOptions.skipWarmer) { + await createWarmerBundle(buildOpts); + console.log("Warmer bundle created"); + } + + // Step 12: Generate output + if (!adapterOptions.skipGenerateOutput) { + await generateOutput(buildOpts); + console.log("Output generated"); + } + }, + }; +} diff --git a/packages/core/src/build/createServerBundle.ts b/packages/core/src/build/createServerBundle.ts index a4e7dd37..b99aa85d 100644 --- a/packages/core/src/build/createServerBundle.ts +++ b/packages/core/src/build/createServerBundle.ts @@ -29,6 +29,9 @@ interface CodeCustomization { // These plugins are meant to apply during the esbuild bundling process. // This will only apply to OpenNext code. additionalPlugins?: (contentUpdater: ContentUpdater) => Plugin[]; + useEdgeConfig?: boolean; + externals?: string[]; + banner?: string[] | ((name: string) => string[]); } export async function createServerBundle( @@ -168,7 +171,7 @@ async function generateBundle( } // Copy open-next.config.mjs - buildHelper.copyOpenNextConfig(options.buildDir, outPackagePath); + buildHelper.copyOpenNextConfig(options.buildDir, outPackagePath, codeCustomization?.useEdgeConfig ?? false); // Copy env files buildHelper.copyEnvFile(appBuildOutputPath, packagePath, outputPath); @@ -232,23 +235,29 @@ async function generateBundle( ]; const outfileExt = fnOptions.runtime === "deno" ? "ts" : "mjs"; + const defaultBanner = [ + `globalThis.monorepoPackagePath = "${packagePath}";`, + "import process from 'node:process';", + "import { Buffer } from 'node:buffer';", + "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 __filename = bannerUrl.fileURLToPath(import.meta.url);", + name === "default" ? "" : `globalThis.fnName = "${name}";`, + ]; + const bannerLines = + typeof codeCustomization?.banner === "function" + ? codeCustomization.banner(name) + : (codeCustomization?.banner ?? defaultBanner); + await buildHelper.esbuildAsync( { entryPoints: [path.join(options.openNextDistDir, "adapters", "server-adapter.js")], - external: ["next", "./middleware.mjs", "./next-server.runtime.prod.js"], + external: codeCustomization?.externals ?? ["next", "./middleware.mjs", "./next-server.runtime.prod.js"], outfile: path.join(outputPath, packagePath, `index.${outfileExt}`), banner: { - js: [ - `globalThis.monorepoPackagePath = "${packagePath}";`, - "import process from 'node:process';", - "import { Buffer } from 'node:buffer';", - "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 __filename = bannerUrl.fileURLToPath(import.meta.url);", - name === "default" ? "" : `globalThis.fnName = "${name}";`, - ].join(""), + js: bannerLines.join(""), }, plugins, }, diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json index 02984337..302ca86f 100644 --- a/packages/core/tsconfig.json +++ b/packages/core/tsconfig.json @@ -15,5 +15,6 @@ "@/utils/*": ["./src/utils/*"] }, "ignoreDeprecations": "6.0" - } + }, + "exclude": ["src/**/*.spec.ts"] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 790c7c28..cb571a6d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1027,12 +1027,18 @@ importers: concurrently: specifier: ^9.2.1 version: 9.2.1 + rimraf: + specifier: 'catalog:' + version: 6.1.2 tsc-alias: specifier: ^1.8.16 version: 1.8.16 typescript: specifier: 'catalog:' version: 6.0.3 + vitest: + specifier: 'catalog:' + version: 2.1.3(@edge-runtime/vm@3.2.0)(@types/node@24.13.2)(jsdom@22.1.0)(lightningcss@1.30.2)(terser@5.16.9) packages/tests-e2e: devDependencies: From 8de81a409fd91ee96e72900592a15e568fad84ad Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Sat, 27 Jun 2026 21:21:21 +0200 Subject: [PATCH 02/13] feat: implement default overrides for bundle configurations and add tests for resolve plugin --- packages/core/src/build/adapter.spec.ts | 7 +- packages/core/src/build/adapter.ts | 22 ++- .../core/src/build/compileTagCacheProvider.ts | 13 +- .../build/createImageOptimizationBundle.ts | 11 +- packages/core/src/build/createMiddleware.ts | 10 +- .../src/build/createRevalidationBundle.ts | 12 +- packages/core/src/build/createServerBundle.ts | 6 +- packages/core/src/build/createWarmerBundle.ts | 12 +- .../core/src/build/edge/createEdgeBundle.ts | 30 +++- .../build/middleware/buildNodeMiddleware.ts | 29 +++- packages/core/src/plugins/resolve.spec.ts | 139 ++++++++++++++++++ packages/core/src/plugins/resolve.ts | 37 ++++- 12 files changed, 288 insertions(+), 40 deletions(-) create mode 100644 packages/core/src/plugins/resolve.spec.ts diff --git a/packages/core/src/build/adapter.spec.ts b/packages/core/src/build/adapter.spec.ts index 690c787c..82c4fc28 100644 --- a/packages/core/src/build/adapter.spec.ts +++ b/packages/core/src/build/adapter.spec.ts @@ -214,7 +214,10 @@ describe("buildAdapter", () => { const ctx = createMockContext(); await adapter.onBuildComplete(ctx); - expect(createMiddleware).toHaveBeenCalledWith(expect.any(Object), { forceOnlyBuildOnce: true }); + expect(createMiddleware).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ forceOnlyBuildOnce: true }) + ); }); test("onBuildComplete skips createRevalidationBundle when skipRevalidation is true", async () => { @@ -421,7 +424,7 @@ describe("buildAdapter", () => { const ctx = createMockContext(); await adapter.onBuildComplete(ctx); - expect(compileTagCacheProvider).toHaveBeenCalledWith(expect.any(Object)); + expect(compileTagCacheProvider).toHaveBeenCalledWith(expect.any(Object), undefined); }); test("onBuildComplete skips cache assets when disableIncrementalCache is true", async () => { diff --git a/packages/core/src/build/adapter.ts b/packages/core/src/build/adapter.ts index c68ebb94..5db40c98 100644 --- a/packages/core/src/build/adapter.ts +++ b/packages/core/src/build/adapter.ts @@ -6,6 +6,7 @@ import type { Plugin } from "esbuild"; import { addDebugFile } from "../debug.js"; import type { ContentUpdater } from "../plugins/content-updater.js"; +import type { BundleDefaults } from "../plugins/resolve.js"; import type { NextAdapterOutputs } from "../types/adapter.js"; import type { NextConfig } from "../types/next-types.js"; import type { OpenNextConfig } from "../types/open-next.js"; @@ -66,6 +67,14 @@ export type OpenNextAdapterOptions = { beforeMiddleware?: (buildOpts: buildHelper.BuildOptions, config: OpenNextConfig) => Promise; afterServerBundle?: (buildOpts: buildHelper.BuildOptions, config: OpenNextConfig) => Promise; tempCachePath?: (buildOpts: buildHelper.BuildOptions, packagePath: string) => string; + /** + * Bundle-specific default override names applied when the user's + * open-next.config.ts does not specify an override for a given key. + * Each bundle type (server, middleware, edge, imageOptimization, + * revalidation, warmer, tagCache) can have its own separate defaults map. + * Precedence: config override > platform default > core node default. + */ + defaultOverrides?: BundleDefaults; }; /** @@ -156,8 +165,10 @@ export function buildAdapter( // Step 2: Call beforeMiddleware hook await adapterOptions.beforeMiddleware?.(buildOpts, config); + const bundleDefaults = adapterOptions.defaultOverrides; + // Step 3: Create middleware - await createMiddleware(buildOpts, adapterOptions.middlewareOptions ?? {}); + await createMiddleware(buildOpts, { ...adapterOptions.middlewareOptions, defaultOverrides: bundleDefaults?.middleware }); console.log("Middleware created"); // Step 4: Create static assets @@ -169,7 +180,7 @@ export function buildAdapter( const { useTagCache } = createCacheAssets(buildOpts); console.log("Cache assets created"); if (useTagCache) { - await compileTagCacheProvider(buildOpts); + await compileTagCacheProvider(buildOpts, bundleDefaults?.tagCache); console.log("Tag cache provider compiled"); } } @@ -188,6 +199,7 @@ export function buildAdapter( useEdgeConfig: adapterOptions.serverBundle?.useEdgeConfig, externals: adapterOptions.serverBundle?.externals, banner: adapterOptions.serverBundle?.banner, + bundleDefaults, }, ctx.outputs ); @@ -198,19 +210,19 @@ export function buildAdapter( // Step 9: Revalidation bundle if (!adapterOptions.skipRevalidation) { - await createRevalidationBundle(buildOpts); + await createRevalidationBundle(buildOpts, bundleDefaults?.revalidation); console.log("Revalidation bundle created"); } // Step 10: Image optimization bundle if (!adapterOptions.skipImageOptimization) { - await createImageOptimizationBundle(buildOpts); + await createImageOptimizationBundle(buildOpts, bundleDefaults?.imageOptimization); console.log("Image optimization bundle created"); } // Step 11: Warmer bundle if (!adapterOptions.skipWarmer) { - await createWarmerBundle(buildOpts); + await createWarmerBundle(buildOpts, bundleDefaults?.warmer); console.log("Warmer bundle created"); } diff --git a/packages/core/src/build/compileTagCacheProvider.ts b/packages/core/src/build/compileTagCacheProvider.ts index 4cddb783..5bda559b 100644 --- a/packages/core/src/build/compileTagCacheProvider.ts +++ b/packages/core/src/build/compileTagCacheProvider.ts @@ -1,11 +1,15 @@ import path from "node:path"; +import type { DefaultOverrides } from "../plugins/resolve.js"; import { openNextResolvePlugin } from "../plugins/resolve.js"; import * as buildHelper from "./helper.js"; import { installDependencies } from "./installDeps.js"; -export async function compileTagCacheProvider(options: buildHelper.BuildOptions) { +export async function compileTagCacheProvider( + options: buildHelper.BuildOptions, + defaultOverrides?: DefaultOverrides +) { const providerPath = path.join(options.outputDir, "dynamodb-provider"); const overrides = options.config.initializationFunction?.override; @@ -20,10 +24,15 @@ export async function compileTagCacheProvider(options: buildHelper.BuildOptions) openNextResolvePlugin({ fnName: "initializationFunction", overrides: { - converter: overrides?.converter ?? "dummy", + converter: overrides?.converter, wrapper: overrides?.wrapper, tagCache: options.config.initializationFunction?.tagCache, }, + defaultOverrides: { + converter: defaultOverrides?.converter ?? "dummy", + wrapper: defaultOverrides?.wrapper, + tagCache: defaultOverrides?.tagCache, + }, }), ], }, diff --git a/packages/core/src/build/createImageOptimizationBundle.ts b/packages/core/src/build/createImageOptimizationBundle.ts index 73e08c86..c33339d5 100644 --- a/packages/core/src/build/createImageOptimizationBundle.ts +++ b/packages/core/src/build/createImageOptimizationBundle.ts @@ -3,12 +3,16 @@ import os from "node:os"; import path from "node:path"; import logger from "../logger.js"; +import type { DefaultOverrides } from "../plugins/resolve.js"; import { openNextResolvePlugin } from "../plugins/resolve.js"; import * as buildHelper from "./helper.js"; import { installDependencies } from "./installDeps.js"; -export async function createImageOptimizationBundle(options: buildHelper.BuildOptions) { +export async function createImageOptimizationBundle( + options: buildHelper.BuildOptions, + defaultOverrides?: DefaultOverrides +) { logger.info("Bundling image optimization function..."); const { appBuildOutputPath, config, outputDir } = options; @@ -28,6 +32,11 @@ export async function createImageOptimizationBundle(options: buildHelper.BuildOp wrapper: config.imageOptimization?.override?.wrapper, imageLoader: config.imageOptimization?.loader, }, + defaultOverrides: { + converter: defaultOverrides?.converter, + wrapper: defaultOverrides?.wrapper, + imageLoader: defaultOverrides?.imageLoader, + }, }), ]; diff --git a/packages/core/src/build/createMiddleware.ts b/packages/core/src/build/createMiddleware.ts index 6c1de5e8..3e50cfe7 100644 --- a/packages/core/src/build/createMiddleware.ts +++ b/packages/core/src/build/createMiddleware.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { loadFunctionsConfigManifest, loadMiddlewareManifest } from "@/config/util.js"; import logger from "../logger.js"; +import type { DefaultOverrides } from "../plugins/resolve.js"; import type { MiddlewareInfo } from "../types/next-types.js"; import { buildEdgeBundle, copyMiddlewareResources } from "./edge/createEdgeBundle.js"; @@ -19,7 +20,10 @@ import { buildBundledNodeMiddleware, buildExternalNodeMiddleware } from "./middl */ export async function createMiddleware( options: buildHelper.BuildOptions, - { forceOnlyBuildOnce = false } = {} + { + forceOnlyBuildOnce = false, + defaultOverrides, + }: { forceOnlyBuildOnce?: boolean; defaultOverrides?: DefaultOverrides } = {} ) { logger.info("Bundling middleware function..."); @@ -37,7 +41,7 @@ export async function createMiddleware( if (functionsConfigManifest?.functions["/_middleware"]) { await (config.middleware?.external - ? buildExternalNodeMiddleware(options) + ? buildExternalNodeMiddleware(options, defaultOverrides) : buildBundledNodeMiddleware(options)); return; } @@ -70,6 +74,7 @@ export async function createMiddleware( additionalExternals: config.edgeExternals, onlyBuildOnce: forceOnlyBuildOnce === true, name: "middleware", + defaultOverrides, }); installDependencies(outputPath, config.middleware?.install); @@ -82,6 +87,7 @@ export async function createMiddleware( overrides: config.default.override, onlyBuildOnce: true, name: "middleware", + defaultOverrides, }); } } diff --git a/packages/core/src/build/createRevalidationBundle.ts b/packages/core/src/build/createRevalidationBundle.ts index fe8b50b2..f6dc5d0d 100644 --- a/packages/core/src/build/createRevalidationBundle.ts +++ b/packages/core/src/build/createRevalidationBundle.ts @@ -2,12 +2,16 @@ import fs from "node:fs"; import path from "node:path"; import logger from "../logger.js"; +import type { DefaultOverrides } from "../plugins/resolve.js"; import { openNextResolvePlugin } from "../plugins/resolve.js"; import * as buildHelper from "./helper.js"; import { installDependencies } from "./installDeps.js"; -export async function createRevalidationBundle(options: buildHelper.BuildOptions) { +export async function createRevalidationBundle( + options: buildHelper.BuildOptions, + defaultOverrides?: DefaultOverrides +) { logger.info("Bundling revalidation function..."); const { appBuildOutputPath, config, outputDir } = options; @@ -29,9 +33,13 @@ export async function createRevalidationBundle(options: buildHelper.BuildOptions openNextResolvePlugin({ fnName: "revalidate", overrides: { - converter: config.revalidate?.override?.converter ?? "node", + converter: config.revalidate?.override?.converter, wrapper: config.revalidate?.override?.wrapper, }, + defaultOverrides: { + converter: defaultOverrides?.converter ?? "node", + wrapper: defaultOverrides?.wrapper, + }, }), ], }, diff --git a/packages/core/src/build/createServerBundle.ts b/packages/core/src/build/createServerBundle.ts index b99aa85d..d22ba6cb 100644 --- a/packages/core/src/build/createServerBundle.ts +++ b/packages/core/src/build/createServerBundle.ts @@ -11,6 +11,7 @@ import logger from "../logger.js"; import { minifyAll } from "../minimize-js.js"; import { ContentUpdater } from "../plugins/content-updater.js"; import { openNextReplacementPlugin } from "../plugins/replacement.js"; +import type { BundleDefaults } from "../plugins/resolve.js"; import { openNextResolvePlugin } from "../plugins/resolve.js"; import { getCrossPlatformPathRegex } from "../utils/regex.js"; @@ -32,6 +33,7 @@ interface CodeCustomization { useEdgeConfig?: boolean; externals?: string[]; banner?: string[] | ((name: string) => string[]); + bundleDefaults?: BundleDefaults; } export async function createServerBundle( @@ -54,7 +56,7 @@ export async function createServerBundle( const routes = fnOptions.routes; routes.forEach((route) => foundRoutes.add(route)); if (fnOptions.runtime === "edge") { - await generateEdgeBundle(name, options, fnOptions); + await generateEdgeBundle(name, options, fnOptions, undefined, codeCustomization?.bundleDefaults?.edge); } else { await generateBundle(name, options, fnOptions, codeCustomization, nextOutputs); } @@ -209,6 +211,7 @@ async function generateBundle( // Next.js app. const overrides = fnOptions.override ?? {}; + const defaultOverrides = codeCustomization?.bundleDefaults?.server; const disableRouting = config.middleware?.external; @@ -228,6 +231,7 @@ async function generateBundle( openNextResolvePlugin({ fnName: name, overrides, + defaultOverrides, }), ...additionalPlugins, // The content updater plugin must be the last plugin diff --git a/packages/core/src/build/createWarmerBundle.ts b/packages/core/src/build/createWarmerBundle.ts index a0ed877a..f8ee943f 100644 --- a/packages/core/src/build/createWarmerBundle.ts +++ b/packages/core/src/build/createWarmerBundle.ts @@ -2,12 +2,16 @@ import fs from "node:fs"; import path from "node:path"; import logger from "../logger.js"; +import type { DefaultOverrides } from "../plugins/resolve.js"; import { openNextResolvePlugin } from "../plugins/resolve.js"; import * as buildHelper from "./helper.js"; import { installDependencies } from "./installDeps.js"; -export async function createWarmerBundle(options: buildHelper.BuildOptions) { +export async function createWarmerBundle( + options: buildHelper.BuildOptions, + defaultOverrides?: DefaultOverrides +) { logger.info("Bundling warmer function..."); const { config, outputDir } = options; @@ -31,9 +35,13 @@ export async function createWarmerBundle(options: buildHelper.BuildOptions) { plugins: [ openNextResolvePlugin({ overrides: { - converter: config.warmer?.override?.converter ?? "dummy", + converter: config.warmer?.override?.converter, wrapper: config.warmer?.override?.wrapper, }, + defaultOverrides: { + converter: defaultOverrides?.converter ?? "dummy", + wrapper: defaultOverrides?.wrapper, + }, fnName: "warmer", }), ], diff --git a/packages/core/src/build/edge/createEdgeBundle.ts b/packages/core/src/build/edge/createEdgeBundle.ts index 5d7370aa..f67ed51d 100644 --- a/packages/core/src/build/edge/createEdgeBundle.ts +++ b/packages/core/src/build/edge/createEdgeBundle.ts @@ -20,6 +20,7 @@ import { ContentUpdater } from "../../plugins/content-updater.js"; import { openNextEdgePlugins } from "../../plugins/edge.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 { getCrossPlatformPathRegex } from "../../utils/regex.js"; import { type BuildOptions, isEdgeRuntime, copyOpenNextConfig, esbuildAsync } from "../helper.js"; @@ -39,6 +40,7 @@ interface BuildEdgeBundleOptions { onlyBuildOnce?: boolean; name: string; additionalPlugins?: (contentUpdater: ContentUpdater) => Plugin[]; + defaultOverrides?: DefaultOverrides; } export async function buildEdgeBundle({ @@ -53,6 +55,7 @@ export async function buildEdgeBundle({ onlyBuildOnce, name, additionalPlugins: additionalPluginsFn, + defaultOverrides, }: BuildEdgeBundleOptions) { const isInCloudflare = await isEdgeRuntime(overrides); function override(target: T) { @@ -73,13 +76,22 @@ export async function buildEdgeBundle({ plugins: [ openNextResolvePlugin({ overrides: { - wrapper: override("wrapper") ?? "aws-lambda", - converter: override("converter") ?? defaultConverter, - tagCache: override("tagCache") ?? "dynamodb-lite", - incrementalCache: override("incrementalCache") ?? "s3-lite", - queue: override("queue") ?? "sqs-lite", - originResolver: override("originResolver") ?? "pattern-env", - proxyExternalRequest: override("proxyExternalRequest") ?? "node", + wrapper: override("wrapper"), + converter: override("converter"), + tagCache: override("tagCache"), + incrementalCache: override("incrementalCache"), + queue: override("queue"), + originResolver: override("originResolver"), + proxyExternalRequest: override("proxyExternalRequest"), + }, + defaultOverrides: { + wrapper: defaultOverrides?.wrapper ?? "aws-lambda", + converter: defaultOverrides?.converter ?? defaultConverter, + tagCache: defaultOverrides?.tagCache ?? "dynamodb-lite", + incrementalCache: defaultOverrides?.incrementalCache ?? "s3-lite", + queue: defaultOverrides?.queue ?? "sqs-lite", + originResolver: defaultOverrides?.originResolver ?? "pattern-env", + proxyExternalRequest: defaultOverrides?.proxyExternalRequest ?? "node", }, fnName: name, }), @@ -167,7 +179,8 @@ export async function generateEdgeBundle( name: string, options: BuildOptions, fnOptions: SplittedFunctionOptions, - additionalPlugins: (contentUpdater: ContentUpdater) => Plugin[] = () => [] + additionalPlugins: (contentUpdater: ContentUpdater) => Plugin[] = () => [], + defaultOverrides?: DefaultOverrides ) { logger.info(`Generating edge bundle for: ${name}`); @@ -204,6 +217,7 @@ export async function generateEdgeBundle( additionalExternals: options.config.edgeExternals, name, additionalPlugins, + defaultOverrides, }); } diff --git a/packages/core/src/build/middleware/buildNodeMiddleware.ts b/packages/core/src/build/middleware/buildNodeMiddleware.ts index 8b4a2c74..1cbbb557 100644 --- a/packages/core/src/build/middleware/buildNodeMiddleware.ts +++ b/packages/core/src/build/middleware/buildNodeMiddleware.ts @@ -7,6 +7,7 @@ import { getCrossPlatformPathRegex } from "@/utils/regex.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 * as buildHelper from "../helper.js"; @@ -16,7 +17,10 @@ type Override = OverrideOptions & { originResolver?: LazyLoadedOverride | IncludedOriginResolver; }; -export async function buildExternalNodeMiddleware(options: buildHelper.BuildOptions) { +export async function buildExternalNodeMiddleware( + options: buildHelper.BuildOptions, + defaultOverrides?: DefaultOverrides +) { const { appBuildOutputPath, config, outputDir } = options; if (!config.middleware?.external) { throw new Error("This function should only be called for external middleware"); @@ -59,13 +63,22 @@ export async function buildExternalNodeMiddleware(options: buildHelper.BuildOpti plugins: [ openNextResolvePlugin({ overrides: { - wrapper: override("wrapper") ?? "aws-lambda", - converter: override("converter") ?? "aws-cloudfront", - tagCache: override("tagCache") ?? "dynamodb-lite", - incrementalCache: override("incrementalCache") ?? "s3-lite", - queue: override("queue") ?? "sqs-lite", - originResolver: override("originResolver") ?? "pattern-env", - proxyExternalRequest: override("proxyExternalRequest") ?? "node", + wrapper: override("wrapper"), + converter: override("converter"), + tagCache: override("tagCache"), + incrementalCache: override("incrementalCache"), + queue: override("queue"), + originResolver: override("originResolver"), + proxyExternalRequest: override("proxyExternalRequest"), + }, + defaultOverrides: { + wrapper: defaultOverrides?.wrapper ?? "aws-lambda", + converter: defaultOverrides?.converter ?? "aws-cloudfront", + tagCache: defaultOverrides?.tagCache ?? "dynamodb-lite", + incrementalCache: defaultOverrides?.incrementalCache ?? "s3-lite", + queue: defaultOverrides?.queue ?? "sqs-lite", + originResolver: defaultOverrides?.originResolver ?? "pattern-env", + proxyExternalRequest: defaultOverrides?.proxyExternalRequest ?? "node", }, fnName: "middleware", }), diff --git a/packages/core/src/plugins/resolve.spec.ts b/packages/core/src/plugins/resolve.spec.ts new file mode 100644 index 00000000..b8b6ff1e --- /dev/null +++ b/packages/core/src/plugins/resolve.spec.ts @@ -0,0 +1,139 @@ +import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import type { PluginBuild } from "esbuild"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; + +import { openNextResolvePlugin } from "./resolve.js"; + +const FIXTURE_CONTENT = [ + 'await import("../overrides/converters/node.js")', + 'await import("../overrides/wrappers/node.js")', + 'await import("../overrides/tagCache/fs-dev-nextMode.js")', + 'await import("../overrides/queue/direct.js")', + 'await import("../overrides/incrementalCache/fs-dev.js")', + 'await import("../overrides/imageLoader/fs-dev.js")', + 'await import("../overrides/originResolver/pattern-env.js")', + 'await import("../overrides/assetResolver/dummy.js")', + 'await import("../overrides/warmer/dummy.js")', + 'await import("../overrides/proxyExternalRequest/node.js")', + 'await import("../overrides/cdnInvalidation/dummy.js")', +].join("\n"); + +type OnLoadCallback = (args: { path: string }) => Promise<{ contents: string }>; + +function createStubBuild() { + let capturedCb: OnLoadCallback | undefined; + const stub = { + onLoad: (_opts: { filter: RegExp }, cb: OnLoadCallback) => { + capturedCb = cb; + }, + } as unknown as PluginBuild; + return { stub, getCallback: () => capturedCb! }; +} + +describe("openNextResolvePlugin", () => { + let fixturePath: string; + let fixtureDir: string; + + beforeEach(async () => { + fixtureDir = join(tmpdir(), `resolve-test-${Date.now()}`, "core"); + await mkdir(fixtureDir, { recursive: true }); + fixturePath = join(fixtureDir, "resolve.js"); + await writeFile(fixturePath, FIXTURE_CONTENT, "utf-8"); + }); + + afterEach(async () => { + // Clean up the temp directory (go up one level from "core") + await rm(join(fixtureDir, ".."), { recursive: true, force: true }); + }); + + async function runPlugin(opts: Parameters[0]) { + const plugin = openNextResolvePlugin(opts); + const { stub, getCallback } = createStubBuild(); + plugin.setup(stub); + const cb = getCallback(); + return cb({ path: fixturePath }); + } + + test("A - platform default applied when no config override", async () => { + const result = await runPlugin({ + overrides: {}, + defaultOverrides: { converter: "edge" }, + fnName: "test", + }); + expect(result.contents).toContain("../overrides/converters/edge.js"); + expect(result.contents).not.toContain("../overrides/converters/node.js"); + }); + + test("B - config override wins over platform default", async () => { + const result = await runPlugin({ + overrides: { converter: "aws-apigw-v2" }, + defaultOverrides: { converter: "edge" }, + fnName: "test", + }); + expect(result.contents).toContain("../overrides/converters/aws-apigw-v2.js"); + expect(result.contents).not.toContain("../overrides/converters/edge.js"); + }); + + test("C - no rewrite when neither provided", async () => { + const result = await runPlugin({ + overrides: {}, + defaultOverrides: {}, + fnName: "test", + }); + expect(result.contents).toContain("../overrides/converters/node.js"); + }); + + test("D - all 10 keys covered", async () => { + const result = await runPlugin({ + overrides: {}, + defaultOverrides: { + wrapper: "aws-lambda", + converter: "edge", + tagCache: "dynamodb", + queue: "sqs", + incrementalCache: "s3", + imageLoader: "host", + originResolver: "dummy", + warmer: "aws-lambda", + proxyExternalRequest: "fetch", + cdnInvalidation: "cloudfront", + }, + fnName: "test", + }); + expect(result.contents).toContain("../overrides/wrappers/aws-lambda.js"); + expect(result.contents).toContain("../overrides/converters/edge.js"); + expect(result.contents).toContain("../overrides/tagCache/dynamodb.js"); + expect(result.contents).toContain("../overrides/queue/sqs.js"); + expect(result.contents).toContain("../overrides/incrementalCache/s3.js"); + expect(result.contents).toContain("../overrides/imageLoader/host.js"); + expect(result.contents).toContain("../overrides/originResolver/dummy.js"); + expect(result.contents).toContain("../overrides/warmer/aws-lambda.js"); + expect(result.contents).toContain("../overrides/proxyExternalRequest/fetch.js"); + expect(result.contents).toContain("../overrides/cdnInvalidation/cloudfront.js"); + }); + + test("E - cloudflare to cloudflare-edge deprecation with platform defaults", async () => { + const result = await runPlugin({ + overrides: {}, + defaultOverrides: { wrapper: "cloudflare" }, + fnName: "test", + }); + expect(result.contents).toContain("../overrides/wrappers/cloudflare-edge.js"); + expect(result.contents).not.toContain("../overrides/wrappers/cloudflare.js"); + }); + + test("F - function config override preserved over platform default", async () => { + // oxlint-disable-next-line @typescript-eslint/no-explicit-any - testing function override + const fnOverride = (() => ({})) as any; + const result = await runPlugin({ + overrides: { converter: fnOverride }, + defaultOverrides: { converter: "edge" }, + fnName: "test", + }); + expect(result.contents).toContain("../overrides/converters/dummy.js"); + expect(result.contents).not.toContain("../overrides/converters/edge.js"); + }); +}); diff --git a/packages/core/src/plugins/resolve.ts b/packages/core/src/plugins/resolve.ts index 1a8521a5..1723fb95 100644 --- a/packages/core/src/plugins/resolve.ts +++ b/packages/core/src/plugins/resolve.ts @@ -32,6 +32,7 @@ export interface IPluginSettings { proxyExternalRequest?: OverrideOptions["proxyExternalRequest"]; cdnInvalidation?: OverrideOptions["cdnInvalidation"]; }; + defaultOverrides?: DefaultOverrides; fnName?: string; } @@ -57,7 +58,20 @@ const nameToFolder = { cdnInvalidation: "cdnInvalidation", }; -const defaultOverrides = { +export type OverrideKey = keyof typeof nameToFolder; +export type DefaultOverrides = Partial>; + +export type BundleType = + | "server" + | "middleware" + | "edge" + | "imageOptimization" + | "revalidation" + | "warmer" + | "tagCache"; +export type BundleDefaults = Partial>; + +const coreResolveDefaults = { wrapper: "node", converter: "node", tagCache: "fs-dev-nextMode", @@ -74,15 +88,22 @@ const defaultOverrides = { * @param opts.overrides - The name of the overrides to use * @returns */ -export function openNextResolvePlugin({ overrides, fnName }: IPluginSettings): Plugin { +export function openNextResolvePlugin({ + overrides, + defaultOverrides: defaultValues, + fnName, +}: IPluginSettings): Plugin { return { name: "opennext-resolve", setup(build) { logger.debug(chalk.blue("OpenNext Resolve plugin"), fnName ? `for ${fnName}` : ""); build.onLoad({ filter: getCrossPlatformPathRegex("core/resolve.js") }, async (args) => { let contents = await readFile(args.path, "utf-8"); - const overridesEntries = Object.entries(overrides ?? {}); - for (let [overrideName, overrideValue] of overridesEntries) { + const allKeys = new Set([...Object.keys(overrides ?? {}), ...Object.keys(defaultValues ?? {})]); + for (const overrideName of allKeys) { + const configValue = overrides?.[overrideName as keyof typeof overrides]; + const defaultValue = defaultValues?.[overrideName as keyof typeof defaultValues]; + let overrideValue = configValue ?? defaultValue; if (!overrideValue) { continue; } @@ -91,10 +112,12 @@ export function openNextResolvePlugin({ overrides, fnName }: IPluginSettings): P overrideValue = "cloudflare-edge"; } const folder = nameToFolder[overrideName as keyof typeof nameToFolder]; - const defaultOverride = defaultOverrides[overrideName as keyof typeof defaultOverrides]; - + const searchTarget = coreResolveDefaults[overrideName as keyof typeof coreResolveDefaults]; + if (!folder || !searchTarget) { + continue; + } contents = contents.replace( - `../overrides/${folder}/${defaultOverride}.js`, + `../overrides/${folder}/${searchTarget}.js`, `../overrides/${folder}/${getOverrideOrDummy(overrideValue)}.js` ); } From 53b4ccb944f3476233a453550024af3ca412c6a0 Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Sat, 27 Jun 2026 21:24:03 +0200 Subject: [PATCH 03/13] format --- packages/core/src/build/adapter.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/core/src/build/adapter.ts b/packages/core/src/build/adapter.ts index 5db40c98..9fa82d0c 100644 --- a/packages/core/src/build/adapter.ts +++ b/packages/core/src/build/adapter.ts @@ -168,7 +168,10 @@ 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, + }); console.log("Middleware created"); // Step 4: Create static assets From e9f8a3c8ed799f3ee7f0b951e0c1912fbece72f3 Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Sat, 27 Jun 2026 21:27:14 +0200 Subject: [PATCH 04/13] fix ts issue --- packages/core/src/adapters/cache.ts | 4 ++-- packages/core/tsconfig.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/core/src/adapters/cache.ts b/packages/core/src/adapters/cache.ts index 0ac93ae6..9cd66847 100644 --- a/packages/core/src/adapters/cache.ts +++ b/packages/core/src/adapters/cache.ts @@ -45,7 +45,7 @@ export default class Cache { const _lastModified = cachedEntry.lastModified ?? Date.now(); const _hasBeenRevalidated = cachedEntry.shouldBypassTagCache ? false - : await hasBeenRevalidated(key, _tags, cachedEntry); + : await hasBeenRevalidated<"fetch">(key, _tags, cachedEntry); if (_hasBeenRevalidated) return null; @@ -59,7 +59,7 @@ export default class Cache { if (path) { const hasPathBeenUpdated = cachedEntry.shouldBypassTagCache ? false - : await hasBeenRevalidated(path.replace(SOFT_TAG_PREFIX, ""), [], cachedEntry); + : await hasBeenRevalidated<"fetch">(path.replace(SOFT_TAG_PREFIX, ""), [], cachedEntry); if (hasPathBeenUpdated) { // In case the path has been revalidated, we don't want to use the fetch cache return null; diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json index 302ca86f..c2ed56d4 100644 --- a/packages/core/tsconfig.json +++ b/packages/core/tsconfig.json @@ -16,5 +16,5 @@ }, "ignoreDeprecations": "6.0" }, - "exclude": ["src/**/*.spec.ts"] + "exclude": ["src/**/*.spec.ts", "dist"] } From 37c4d90d8468e8a6ba14a7956dd412f10c105d00 Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Sat, 27 Jun 2026 21:36:43 +0200 Subject: [PATCH 05/13] feat: add default overrides for AWS adapter and integrate Cloudflare specific overrides --- packages/aws/src/adapter.ts | 25 +++++++++++++++++++++++++ packages/cloudflare/src/api/config.ts | 11 ++++++++++- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/packages/aws/src/adapter.ts b/packages/aws/src/adapter.ts index 48cbc9b1..6554c7c7 100644 --- a/packages/aws/src/adapter.ts +++ b/packages/aws/src/adapter.ts @@ -12,4 +12,29 @@ export default buildAdapter((_config, buildOpts: BuildOptions) => ({ return [inlineRouteHandler(updater, outputs, packagePath), externalChunksPlugin(outputs, packagePath)]; }, }, + defaultOverrides: { + server: { + wrapper: "aws-lambda-streaming", + converter: "aws-apigw-v2", + incrementalCache: "s3", + tagCache: "dynamodb", + queue: "sqs", + }, + revalidation: { + wrapper: "aws-lambda", + converter: "sqs-revalidate", + }, + imageOptimization: { + wrapper: "aws-lambda", + converter: "aws-apigw-v2", + imageLoader: "s3", + }, + warmer: { + wrapper: "aws-lambda", + }, + tagCache: { + wrapper: "aws-lambda", + tagCache: "dynamodb", + }, + }, })); diff --git a/packages/cloudflare/src/api/config.ts b/packages/cloudflare/src/api/config.ts index 32d9be73..f14d624c 100644 --- a/packages/cloudflare/src/api/config.ts +++ b/packages/cloudflare/src/api/config.ts @@ -13,6 +13,9 @@ import type { } from "@opennextjs/core/types/overrides.js"; import assetResolver from "./overrides/asset-resolver/index.js"; +import r2IncrementalCache from "./overrides/incremental-cache/r2-incremental-cache.js"; +import doQueue from "./overrides/queue/do-queue.js"; +import d1NextTagCache from "./overrides/tag-cache/d1-next-tag-cache.js"; export type Override = "dummy" | T | LazyLoadedOverride; @@ -57,7 +60,13 @@ export type CloudflareOverrides = { * @returns the OpenNext configuration object */ export function defineCloudflareConfig(config: CloudflareOverrides = {}): OpenNextConfig { - const { incrementalCache, tagCache, queue, cachePurge, routePreloadingBehavior = "none" } = config; + const { + incrementalCache = r2IncrementalCache, + tagCache = d1NextTagCache, + queue = doQueue, + cachePurge, + routePreloadingBehavior = "none", + } = config; return { default: { From 14bf0cdcaeebd0f3abca10de61b68c4fb9264383 Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Sat, 27 Jun 2026 21:54:50 +0200 Subject: [PATCH 06/13] Revert "feat: add default overrides for AWS adapter and integrate Cloudflare specific overrides" This reverts commit 37c4d90d8468e8a6ba14a7956dd412f10c105d00. --- packages/aws/src/adapter.ts | 25 ------------------------- packages/cloudflare/src/api/config.ts | 11 +---------- 2 files changed, 1 insertion(+), 35 deletions(-) diff --git a/packages/aws/src/adapter.ts b/packages/aws/src/adapter.ts index 6554c7c7..48cbc9b1 100644 --- a/packages/aws/src/adapter.ts +++ b/packages/aws/src/adapter.ts @@ -12,29 +12,4 @@ export default buildAdapter((_config, buildOpts: BuildOptions) => ({ return [inlineRouteHandler(updater, outputs, packagePath), externalChunksPlugin(outputs, packagePath)]; }, }, - defaultOverrides: { - server: { - wrapper: "aws-lambda-streaming", - converter: "aws-apigw-v2", - incrementalCache: "s3", - tagCache: "dynamodb", - queue: "sqs", - }, - revalidation: { - wrapper: "aws-lambda", - converter: "sqs-revalidate", - }, - imageOptimization: { - wrapper: "aws-lambda", - converter: "aws-apigw-v2", - imageLoader: "s3", - }, - warmer: { - wrapper: "aws-lambda", - }, - tagCache: { - wrapper: "aws-lambda", - tagCache: "dynamodb", - }, - }, })); diff --git a/packages/cloudflare/src/api/config.ts b/packages/cloudflare/src/api/config.ts index f14d624c..32d9be73 100644 --- a/packages/cloudflare/src/api/config.ts +++ b/packages/cloudflare/src/api/config.ts @@ -13,9 +13,6 @@ import type { } from "@opennextjs/core/types/overrides.js"; import assetResolver from "./overrides/asset-resolver/index.js"; -import r2IncrementalCache from "./overrides/incremental-cache/r2-incremental-cache.js"; -import doQueue from "./overrides/queue/do-queue.js"; -import d1NextTagCache from "./overrides/tag-cache/d1-next-tag-cache.js"; export type Override = "dummy" | T | LazyLoadedOverride; @@ -60,13 +57,7 @@ export type CloudflareOverrides = { * @returns the OpenNext configuration object */ export function defineCloudflareConfig(config: CloudflareOverrides = {}): OpenNextConfig { - const { - incrementalCache = r2IncrementalCache, - tagCache = d1NextTagCache, - queue = doQueue, - cachePurge, - routePreloadingBehavior = "none", - } = config; + const { incrementalCache, tagCache, queue, cachePurge, routePreloadingBehavior = "none" } = config; return { default: { From a101e18201b52a9e44d573893f83e99ab7f33dd7 Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Sat, 27 Jun 2026 23:49:46 +0200 Subject: [PATCH 07/13] feat: enhance AWS adapter with default overrides and improve middleware configuration --- packages/aws/src/adapter.ts | 30 +++ packages/core/src/build/createMiddleware.ts | 2 +- .../core/src/build/edge/createEdgeBundle.ts | 19 +- packages/core/src/build/generateOutput.ts | 16 +- .../build/middleware/buildNodeMiddleware.ts | 18 +- packages/core/src/build/validateConfig.ts | 1 + packages/core/src/core/resolve.ts | 8 +- packages/core/src/plugins/resolve.spec.ts | 176 ++++++++++++------ packages/core/src/plugins/resolve.ts | 131 ++++++++++--- 9 files changed, 299 insertions(+), 102 deletions(-) diff --git a/packages/aws/src/adapter.ts b/packages/aws/src/adapter.ts index 48cbc9b1..606edcab 100644 --- a/packages/aws/src/adapter.ts +++ b/packages/aws/src/adapter.ts @@ -6,6 +6,36 @@ import { externalChunksPlugin, inlineRouteHandler } from "@opennextjs/core/plugi import type { NextAdapterOutputs } from "@opennextjs/core/types/adapter.js"; export default buildAdapter((_config, buildOpts: BuildOptions) => ({ + defaultOverrides: { + server: { + wrapper: "@opennextjs/aws/overrides/wrappers/aws-lambda-streaming.js", + converter: "@opennextjs/aws/overrides/converters/aws-apigw-v2.js", + incrementalCache: "@opennextjs/aws/overrides/incrementalCache/s3.js", + tagCache: "@opennextjs/aws/overrides/tagCache/dynamodb.js", + queue: "@opennextjs/aws/overrides/queue/sqs.js", + }, + revalidation: { + wrapper: "@opennextjs/aws/overrides/wrappers/aws-lambda.js", + converter: "@opennextjs/aws/overrides/converters/sqs-revalidate.js", + }, + imageOptimization: { + wrapper: "@opennextjs/aws/overrides/wrappers/aws-lambda.js", + converter: "@opennextjs/aws/overrides/converters/aws-apigw-v2.js", + imageLoader: "@opennextjs/aws/overrides/imageLoader/s3.js", + }, + warmer: { wrapper: "@opennextjs/aws/overrides/wrappers/aws-lambda.js" }, + tagCache: { + wrapper: "@opennextjs/aws/overrides/wrappers/aws-lambda.js", + tagCache: "@opennextjs/aws/overrides/tagCache/dynamodb.js", + }, + middleware: { + wrapper: "@opennextjs/aws/overrides/wrappers/aws-lambda.js", + converter: "@opennextjs/aws/overrides/converters/aws-cloudfront.js", + incrementalCache: "@opennextjs/aws/overrides/incrementalCache/s3-lite.js", + tagCache: "@opennextjs/aws/overrides/tagCache/dynamodb-lite.js", + queue: "@opennextjs/aws/overrides/queue/sqs-lite.js", + }, + }, serverBundle: { additionalPlugins: (updater: ContentUpdater, outputs: NextAdapterOutputs) => { const packagePath = buildHelper.getPackagePath(buildOpts); diff --git a/packages/core/src/build/createMiddleware.ts b/packages/core/src/build/createMiddleware.ts index 3e50cfe7..963a3568 100644 --- a/packages/core/src/build/createMiddleware.ts +++ b/packages/core/src/build/createMiddleware.ts @@ -70,7 +70,7 @@ export async function createMiddleware( ...config.middleware.override, originResolver: config.middleware.originResolver, }, - defaultConverter: "aws-cloudfront", + defaultConverter: "@opennextjs/core/overrides/converters/edge.js", additionalExternals: config.edgeExternals, onlyBuildOnce: forceOnlyBuildOnce === true, name: "middleware", diff --git a/packages/core/src/build/edge/createEdgeBundle.ts b/packages/core/src/build/edge/createEdgeBundle.ts index f67ed51d..9cd63616 100644 --- a/packages/core/src/build/edge/createEdgeBundle.ts +++ b/packages/core/src/build/edge/createEdgeBundle.ts @@ -6,7 +6,6 @@ import { type Plugin, build } from "esbuild"; import { loadMiddlewareManifest } from "@/config/util.js"; import type { MiddlewareInfo } from "@/types/next-types"; import type { - IncludedConverter, IncludedOriginResolver, LazyLoadedOverride, OverrideOptions, @@ -34,7 +33,7 @@ interface BuildEdgeBundleOptions { outfile: string; options: BuildOptions; overrides?: Override; - defaultConverter?: IncludedConverter; + defaultConverter?: string; additionalInject?: string; additionalExternals?: string[]; onlyBuildOnce?: boolean; @@ -85,13 +84,17 @@ export async function buildEdgeBundle({ proxyExternalRequest: override("proxyExternalRequest"), }, defaultOverrides: { - wrapper: defaultOverrides?.wrapper ?? "aws-lambda", + wrapper: defaultOverrides?.wrapper ?? "@opennextjs/core/overrides/wrappers/dummy.js", converter: defaultOverrides?.converter ?? defaultConverter, - tagCache: defaultOverrides?.tagCache ?? "dynamodb-lite", - incrementalCache: defaultOverrides?.incrementalCache ?? "s3-lite", - queue: defaultOverrides?.queue ?? "sqs-lite", - originResolver: defaultOverrides?.originResolver ?? "pattern-env", - proxyExternalRequest: defaultOverrides?.proxyExternalRequest ?? "node", + tagCache: defaultOverrides?.tagCache ?? "@opennextjs/core/overrides/tagCache/dummy.js", + incrementalCache: + defaultOverrides?.incrementalCache ?? "@opennextjs/core/overrides/incrementalCache/dummy.js", + queue: defaultOverrides?.queue ?? "@opennextjs/core/overrides/queue/direct.js", + originResolver: + defaultOverrides?.originResolver ?? "@opennextjs/core/overrides/originResolver/pattern-env.js", + proxyExternalRequest: + defaultOverrides?.proxyExternalRequest ?? + "@opennextjs/core/overrides/proxyExternalRequest/node.js", }, fnName: name, }), diff --git a/packages/core/src/build/generateOutput.ts b/packages/core/src/build/generateOutput.ts index 79716cdc..bc0795e2 100644 --- a/packages/core/src/build/generateOutput.ts +++ b/packages/core/src/build/generateOutput.ts @@ -102,6 +102,20 @@ async function canStream(opts: FunctionOptions) { return wrapper.supportStreaming; } +/** + * Extracts the bare name from a full-path override string. + * Full paths like "@opennextjs/aws/overrides/wrappers/aws-lambda.js" → "aws-lambda". + * Bare names like "edge" pass through unchanged. + */ +function bare(s: string): string { + if (s.startsWith("@") || s.includes("/")) { + const lastSlash = s.lastIndexOf("/"); + const filename = lastSlash >= 0 ? s.slice(lastSlash + 1) : s; + return filename.replace(/\.js$/, ""); + } + return s; +} + async function extractOverrideName( defaultName: string, override?: LazyLoadedOverride | string @@ -110,7 +124,7 @@ async function extractOverrideName( return defaultName; } if (typeof override === "string") { - return override; + return bare(override); } const overrideModule = await override(); return overrideModule.name; diff --git a/packages/core/src/build/middleware/buildNodeMiddleware.ts b/packages/core/src/build/middleware/buildNodeMiddleware.ts index 1cbbb557..8f1957c4 100644 --- a/packages/core/src/build/middleware/buildNodeMiddleware.ts +++ b/packages/core/src/build/middleware/buildNodeMiddleware.ts @@ -72,13 +72,17 @@ export async function buildExternalNodeMiddleware( proxyExternalRequest: override("proxyExternalRequest"), }, defaultOverrides: { - wrapper: defaultOverrides?.wrapper ?? "aws-lambda", - converter: defaultOverrides?.converter ?? "aws-cloudfront", - tagCache: defaultOverrides?.tagCache ?? "dynamodb-lite", - incrementalCache: defaultOverrides?.incrementalCache ?? "s3-lite", - queue: defaultOverrides?.queue ?? "sqs-lite", - originResolver: defaultOverrides?.originResolver ?? "pattern-env", - proxyExternalRequest: defaultOverrides?.proxyExternalRequest ?? "node", + wrapper: defaultOverrides?.wrapper ?? "@opennextjs/core/overrides/wrappers/node.js", + converter: defaultOverrides?.converter ?? "@opennextjs/core/overrides/converters/node.js", + tagCache: defaultOverrides?.tagCache ?? "@opennextjs/core/overrides/tagCache/dummy.js", + incrementalCache: + defaultOverrides?.incrementalCache ?? "@opennextjs/core/overrides/incrementalCache/dummy.js", + queue: defaultOverrides?.queue ?? "@opennextjs/core/overrides/queue/direct.js", + originResolver: + defaultOverrides?.originResolver ?? "@opennextjs/core/overrides/originResolver/pattern-env.js", + proxyExternalRequest: + defaultOverrides?.proxyExternalRequest ?? + "@opennextjs/core/overrides/proxyExternalRequest/node.js", }, fnName: "middleware", }), diff --git a/packages/core/src/build/validateConfig.ts b/packages/core/src/build/validateConfig.ts index 22779d08..672e295a 100644 --- a/packages/core/src/build/validateConfig.ts +++ b/packages/core/src/build/validateConfig.ts @@ -21,6 +21,7 @@ const compatibilityMatrix: Record = { }; function validateFunctionOptions(fnOptions: FunctionOptions) { + // TODO: validateConfig needs to be updated to normalize full-path override strings to bare names before the compatibilityMatrix lookup (full-path user overrides currently crash L41) const wrapper = typeof fnOptions.override?.wrapper === "string" ? fnOptions.override.wrapper : "aws-lambda"; const converter = typeof fnOptions.override?.converter === "string" ? fnOptions.override.converter : "aws-apigw-v2"; diff --git a/packages/core/src/core/resolve.ts b/packages/core/src/core/resolve.ts index 49d117e7..8bb13557 100644 --- a/packages/core/src/core/resolve.ts +++ b/packages/core/src/core/resolve.ts @@ -19,7 +19,9 @@ export async function resolveConverter< if (typeof converter === "function") { return converter(); } - const m_1 = (await import("../overrides/converters/node.js")) as unknown as { default: Converter }; + const m_1 = (await import("../overrides/converters/node.js")) as unknown as { + default: Converter; + }; return m_1.default; } @@ -30,7 +32,9 @@ export async function resolveWrapper< if (typeof wrapper === "function") { return wrapper(); } - const m_1 = (await import("../overrides/wrappers/node.js")) as unknown as { default: Wrapper }; + const m_1 = (await import("../overrides/wrappers/node.js")) as unknown as { + default: Wrapper; + }; return m_1.default; } diff --git a/packages/core/src/plugins/resolve.spec.ts b/packages/core/src/plugins/resolve.spec.ts index b8b6ff1e..2d30f31a 100644 --- a/packages/core/src/plugins/resolve.spec.ts +++ b/packages/core/src/plugins/resolve.spec.ts @@ -1,4 +1,4 @@ -import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { mkdir, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -7,19 +7,60 @@ import { afterEach, beforeEach, describe, expect, test } from "vitest"; import { openNextResolvePlugin } from "./resolve.js"; -const FIXTURE_CONTENT = [ - 'await import("../overrides/converters/node.js")', - 'await import("../overrides/wrappers/node.js")', - 'await import("../overrides/tagCache/fs-dev-nextMode.js")', - 'await import("../overrides/queue/direct.js")', - 'await import("../overrides/incrementalCache/fs-dev.js")', - 'await import("../overrides/imageLoader/fs-dev.js")', - 'await import("../overrides/originResolver/pattern-env.js")', - 'await import("../overrides/assetResolver/dummy.js")', - 'await import("../overrides/warmer/dummy.js")', - 'await import("../overrides/proxyExternalRequest/node.js")', - 'await import("../overrides/cdnInvalidation/dummy.js")', -].join("\n"); +// Synthetic resolve.js module body mirroring compiled output with relative-path imports. +// Each function has exactly ONE await import with a relative ../overrides/ path. +const FIXTURE_CONTENT = ` +export async function resolveConverter(converter) { + if (typeof converter === "function") return converter(); + const m_1 = await import("../overrides/converters/node.js"); + return m_1.default; +} +export async function resolveWrapper(wrapper) { + if (typeof wrapper === "function") return wrapper(); + const m_1 = await import("../overrides/wrappers/node.js"); + return m_1.default; +} +export async function resolveTagCache(tagCache) { + if (typeof tagCache === "function") return tagCache(); + const m_1 = await import("../overrides/tagCache/fs-dev-nextMode.js"); + return m_1.default; +} +export async function resolveQueue(queue) { + if (typeof queue === "function") return queue(); + const m_1 = await import("../overrides/queue/direct.js"); + return m_1.default; +} +export async function resolveIncrementalCache(incrementalCache) { + if (typeof incrementalCache === "function") return incrementalCache(); + const m_1 = await import("../overrides/incrementalCache/fs-dev.js"); + return m_1.default; +} +export async function resolveImageLoader(imageLoader) { + if (typeof imageLoader === "function") return imageLoader(); + const m_1 = await import("../overrides/imageLoader/fs-dev.js"); + return m_1.default; +} +export async function resolveOriginResolver(originResolver) { + if (typeof originResolver === "function") return originResolver(); + const m_1 = await import("../overrides/originResolver/pattern-env.js"); + return m_1.default; +} +export async function resolveWarmerInvoke(warmer) { + if (typeof warmer === "function") return warmer(); + const m_1 = await import("../overrides/warmer/dummy.js"); + return m_1.default; +} +export async function resolveProxyRequest(proxyRequest) { + if (typeof proxyRequest === "function") return proxyRequest(); + const m_1 = await import("../overrides/proxyExternalRequest/node.js"); + return m_1.default; +} +export async function resolveCdnInvalidation(cdnInvalidation) { + if (typeof cdnInvalidation === "function") return cdnInvalidation(); + const m_1 = await import("../overrides/cdnInvalidation/dummy.js"); + return m_1.default; +} +`.trim(); type OnLoadCallback = (args: { path: string }) => Promise<{ contents: string }>; @@ -57,27 +98,27 @@ describe("openNextResolvePlugin", () => { return cb({ path: fixturePath }); } - test("A - platform default applied when no config override", async () => { + test("A - full-path default verbatim: core full path default replaces anchor", async () => { const result = await runPlugin({ overrides: {}, - defaultOverrides: { converter: "edge" }, + defaultOverrides: { converter: "@opennextjs/core/overrides/converters/edge.js" }, fnName: "test", }); - expect(result.contents).toContain("../overrides/converters/edge.js"); - expect(result.contents).not.toContain("../overrides/converters/node.js"); + expect(result.contents).toContain("@opennextjs/core/overrides/converters/edge.js"); + expect(result.contents).not.toContain("@opennextjs/core/overrides/converters/node.js"); }); - test("B - config override wins over platform default", async () => { + test("B - cross-package user full aws path wins over core default", async () => { const result = await runPlugin({ - overrides: { converter: "aws-apigw-v2" }, - defaultOverrides: { converter: "edge" }, + overrides: { converter: "@opennextjs/aws/overrides/converters/aws-apigw-v2.js" }, + defaultOverrides: { converter: "@opennextjs/core/overrides/converters/edge.js" }, fnName: "test", }); - expect(result.contents).toContain("../overrides/converters/aws-apigw-v2.js"); - expect(result.contents).not.toContain("../overrides/converters/edge.js"); + expect(result.contents).toContain("@opennextjs/aws/overrides/converters/aws-apigw-v2.js"); + expect(result.contents).not.toContain("@opennextjs/core/overrides/converters/edge.js"); }); - test("C - no rewrite when neither provided", async () => { + test("C - no-op anchor stays: no override no default keeps relative core path", async () => { const result = await runPlugin({ overrides: {}, defaultOverrides: {}, @@ -86,54 +127,83 @@ describe("openNextResolvePlugin", () => { expect(result.contents).toContain("../overrides/converters/node.js"); }); - test("D - all 10 keys covered", async () => { + test("D - 10-key mixed aws+core full paths all rewritten", async () => { const result = await runPlugin({ overrides: {}, defaultOverrides: { - wrapper: "aws-lambda", - converter: "edge", - tagCache: "dynamodb", - queue: "sqs", - incrementalCache: "s3", - imageLoader: "host", - originResolver: "dummy", - warmer: "aws-lambda", - proxyExternalRequest: "fetch", - cdnInvalidation: "cloudfront", + wrapper: "@opennextjs/aws/overrides/wrappers/aws-lambda.js", + converter: "@opennextjs/core/overrides/converters/edge.js", + tagCache: "@opennextjs/aws/overrides/tagCache/dynamodb.js", + queue: "@opennextjs/aws/overrides/queue/sqs.js", + incrementalCache: "@opennextjs/aws/overrides/incrementalCache/s3.js", + imageLoader: "@opennextjs/core/overrides/imageLoader/dummy.js", + originResolver: "@opennextjs/core/overrides/originResolver/dummy.js", + warmer: "@opennextjs/aws/overrides/warmer/aws-lambda.js", + proxyExternalRequest: "@opennextjs/core/overrides/proxyExternalRequest/fetch.js", + cdnInvalidation: "@opennextjs/aws/overrides/cdnInvalidation/cloudfront.js", }, fnName: "test", }); - expect(result.contents).toContain("../overrides/wrappers/aws-lambda.js"); - expect(result.contents).toContain("../overrides/converters/edge.js"); - expect(result.contents).toContain("../overrides/tagCache/dynamodb.js"); - expect(result.contents).toContain("../overrides/queue/sqs.js"); - expect(result.contents).toContain("../overrides/incrementalCache/s3.js"); - expect(result.contents).toContain("../overrides/imageLoader/host.js"); - expect(result.contents).toContain("../overrides/originResolver/dummy.js"); - expect(result.contents).toContain("../overrides/warmer/aws-lambda.js"); - expect(result.contents).toContain("../overrides/proxyExternalRequest/fetch.js"); - expect(result.contents).toContain("../overrides/cdnInvalidation/cloudfront.js"); + expect(result.contents).toContain("@opennextjs/aws/overrides/wrappers/aws-lambda.js"); + expect(result.contents).toContain("@opennextjs/core/overrides/converters/edge.js"); + expect(result.contents).toContain("@opennextjs/aws/overrides/tagCache/dynamodb.js"); + expect(result.contents).toContain("@opennextjs/aws/overrides/queue/sqs.js"); + expect(result.contents).toContain("@opennextjs/aws/overrides/incrementalCache/s3.js"); + expect(result.contents).toContain("@opennextjs/core/overrides/imageLoader/dummy.js"); + expect(result.contents).toContain("@opennextjs/core/overrides/originResolver/dummy.js"); + expect(result.contents).toContain("@opennextjs/aws/overrides/warmer/aws-lambda.js"); + expect(result.contents).toContain("@opennextjs/core/overrides/proxyExternalRequest/fetch.js"); + expect(result.contents).toContain("@opennextjs/aws/overrides/cdnInvalidation/cloudfront.js"); }); - test("E - cloudflare to cloudflare-edge deprecation with platform defaults", async () => { + test("E - deprecated cloudflare bare name becomes legacy relative core path", async () => { const result = await runPlugin({ - overrides: {}, - defaultOverrides: { wrapper: "cloudflare" }, + overrides: { wrapper: "cloudflare" }, + defaultOverrides: {}, fnName: "test", }); expect(result.contents).toContain("../overrides/wrappers/cloudflare-edge.js"); - expect(result.contents).not.toContain("../overrides/wrappers/cloudflare.js"); + expect(result.contents).not.toContain("cloudflare.js"); }); - test("F - function config override preserved over platform default", async () => { + test("F - function override becomes full dummy core path", async () => { // oxlint-disable-next-line @typescript-eslint/no-explicit-any - testing function override const fnOverride = (() => ({})) as any; const result = await runPlugin({ overrides: { converter: fnOverride }, - defaultOverrides: { converter: "edge" }, + defaultOverrides: { converter: "@opennextjs/core/overrides/converters/edge.js" }, fnName: "test", }); - expect(result.contents).toContain("../overrides/converters/dummy.js"); - expect(result.contents).not.toContain("../overrides/converters/edge.js"); + expect(result.contents).toContain("@opennextjs/core/overrides/converters/dummy.js"); + expect(result.contents).not.toContain("@opennextjs/core/overrides/converters/edge.js"); + }); + + test("G - AWS server defaults produce aws full paths", async () => { + const result = await runPlugin({ + overrides: {}, + defaultOverrides: { + wrapper: "@opennextjs/aws/overrides/wrappers/aws-lambda-streaming.js", + converter: "@opennextjs/aws/overrides/converters/aws-apigw-v2.js", + incrementalCache: "@opennextjs/aws/overrides/incrementalCache/s3.js", + tagCache: "@opennextjs/aws/overrides/tagCache/dynamodb.js", + queue: "@opennextjs/aws/overrides/queue/sqs.js", + }, + fnName: "server", + }); + expect(result.contents).toContain("@opennextjs/aws/overrides/wrappers/aws-lambda-streaming.js"); + expect(result.contents).toContain("@opennextjs/aws/overrides/converters/aws-apigw-v2.js"); + expect(result.contents).toContain("@opennextjs/aws/overrides/incrementalCache/s3.js"); + expect(result.contents).toContain("@opennextjs/aws/overrides/tagCache/dynamodb.js"); + expect(result.contents).toContain("@opennextjs/aws/overrides/queue/sqs.js"); + }); + + test("H - bare-name user override becomes legacy relative core path", async () => { + const result = await runPlugin({ + overrides: { converter: "edge" }, + defaultOverrides: {}, + fnName: "test", + }); + expect(result.contents).toContain("../overrides/converters/edge.js"); + expect(result.contents).not.toContain("@opennextjs/core/overrides/converters/node.js"); }); }); diff --git a/packages/core/src/plugins/resolve.ts b/packages/core/src/plugins/resolve.ts index 1723fb95..0c64aef7 100644 --- a/packages/core/src/plugins/resolve.ts +++ b/packages/core/src/plugins/resolve.ts @@ -1,10 +1,10 @@ import { readFile } from "node:fs/promises"; +import { type Edit, Lang, parse } from "@ast-grep/napi"; import chalk from "chalk"; import type { Plugin } from "esbuild"; import type { - BaseOverride, DefaultOverrideOptions, IncludedImageLoader, IncludedOriginResolver, @@ -36,14 +36,6 @@ export interface IPluginSettings { fnName?: string; } -function getOverrideOrDummy>(override: Override) { - if (typeof override === "string") { - return override; - } - // We can return dummy here because if it's not a string, it's a LazyLoadedOverride - return "dummy"; -} - // This could be useful in the future to map overrides to nested folders const nameToFolder = { wrapper: "wrappers", @@ -61,6 +53,34 @@ const nameToFolder = { export type OverrideKey = keyof typeof nameToFolder; export type DefaultOverrides = Partial>; +// Maps override key to resolve function name (docs / future ast-grep use) +const resolveFunctionName: Record = { + wrapper: "resolveWrapper", + converter: "resolveConverter", + tagCache: "resolveTagCache", + queue: "resolveQueue", + incrementalCache: "resolveIncrementalCache", + imageLoader: "resolveImageLoader", + originResolver: "resolveOriginResolver", + warmer: "resolveWarmerInvoke", + proxyExternalRequest: "resolveProxyRequest", + cdnInvalidation: "resolveCdnInvalidation", +}; + +// Relative-path fallback anchors matching the compiled resolve.js imports. +const resolveAnchors: Record = { + wrapper: "../overrides/wrappers/node.js", + converter: "../overrides/converters/node.js", + tagCache: "../overrides/tagCache/fs-dev-nextMode.js", + queue: "../overrides/queue/direct.js", + incrementalCache: "../overrides/incrementalCache/fs-dev.js", + imageLoader: "../overrides/imageLoader/fs-dev.js", + originResolver: "../overrides/originResolver/pattern-env.js", + warmer: "../overrides/warmer/dummy.js", + proxyExternalRequest: "../overrides/proxyExternalRequest/node.js", + cdnInvalidation: "../overrides/cdnInvalidation/dummy.js", +}; + export type BundleType = | "server" | "middleware" @@ -71,18 +91,13 @@ export type BundleType = | "tagCache"; export type BundleDefaults = Partial>; -const coreResolveDefaults = { - wrapper: "node", - converter: "node", - tagCache: "fs-dev-nextMode", - queue: "direct", - incrementalCache: "fs-dev", - imageLoader: "fs-dev", - originResolver: "pattern-env", - warmer: "dummy", - proxyExternalRequest: "node", - cdnInvalidation: "dummy", -}; +/** + * Checks if a string is a full package-specifier path (starts with @ or contains /). + * Bare names like "node", "edge", "aws-lambda" return false. + */ +function isFullPath(s: string): boolean { + return s.startsWith("@") || s.includes("/"); +} /** * @param opts.overrides - The name of the overrides to use @@ -100,6 +115,12 @@ export function openNextResolvePlugin({ build.onLoad({ filter: getCrossPlatformPathRegex("core/resolve.js") }, async (args) => { let contents = await readFile(args.path, "utf-8"); const allKeys = new Set([...Object.keys(overrides ?? {}), ...Object.keys(defaultValues ?? {})]); + + // Primary: ast-grep edits. Fallback: string-replace anchors (post-commit). + const edits: Edit[] = []; + const fallbackKeys: Array<{ key: OverrideKey; targetPath: string }> = []; + const astRoot = parse(Lang.JavaScript, contents).root(); + for (const overrideName of allKeys) { const configValue = overrides?.[overrideName as keyof typeof overrides]; const defaultValue = defaultValues?.[overrideName as keyof typeof defaultValues]; @@ -107,20 +128,70 @@ export function openNextResolvePlugin({ if (!overrideValue) { continue; } + + const key = overrideName as OverrideKey; + const folder = nameToFolder[key]; + if (!folder) { + continue; + } + if (overrideName === "wrapper" && overrideValue === "cloudflare") { - // "cloudflare" is deprecated and replaced by "cloudflare-edge". overrideValue = "cloudflare-edge"; } - const folder = nameToFolder[overrideName as keyof typeof nameToFolder]; - const searchTarget = coreResolveDefaults[overrideName as keyof typeof coreResolveDefaults]; - if (!folder || !searchTarget) { - continue; + + let targetPath: string; + if (typeof overrideValue === "string") { + if (isFullPath(overrideValue)) { + targetPath = overrideValue; + } else { + targetPath = `../overrides/${folder}/${overrideValue}.js`; + } + } else { + targetPath = `@opennextjs/core/overrides/${folder}/dummy.js`; + } + + // Primary: use ast-grep to find the resolve function by name + // and replace the string inside `await import($PATH)`. + const fnName_ = resolveFunctionName[key]; + try { + const fnNode = astRoot.find({ + rule: { + kind: "function_declaration", + has: { kind: "identifier", pattern: fnName_ }, + }, + }); + if (fnNode) { + const importNode = fnNode.find({ + rule: { + kind: "string", + inside: { kind: "await_expression", stopBy: "end" }, + }, + }); + if (importNode) { + edits.push(importNode.replace('"' + targetPath + '"')); + continue; + } + } + } catch { + // ast-grep lookup failed — fall through to fallback } - contents = contents.replace( - `../overrides/${folder}/${searchTarget}.js`, - `../overrides/${folder}/${getOverrideOrDummy(overrideValue)}.js` - ); + fallbackKeys.push({ key, targetPath }); } + + // Commit all ast-grep edits at once (no interleaving). + if (edits.length > 0) { + contents = astRoot.commitEdits(edits); + } + + // Fallback: string-replace on post-commitEdits contents for any + // keys ast-grep didn't handle. + for (const fb of fallbackKeys) { + const anchor = resolveAnchors[fb.key]; + if (anchor) { + contents = contents.replace(anchor, fb.targetPath); + } + } + return { contents, }; From 939fbe1a25f9c2171f6432411dd07932b2b38cd8 Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Sun, 28 Jun 2026 09:46:06 +0200 Subject: [PATCH 08/13] refactor(core): return ValidateConfigResult from validateConfig instead of throwing - Extract ValidateConfigResult type with success/message/shouldThrow/level - Convert validateFunctionOptions and validateSplittedFunctionOptions to return result objects - Remove logger dependency from validateConfig.ts - Preserve compatibilityMatrix, TODO comment, @ts-expect-error pragmas - Add 5 characterization tests in validateConfig.spec.ts - No caller impact: compileConfig.ts is the sole importer (updated in T3) --- .../core/src/build/validateConfig.spec.ts | 60 +++++++++ packages/core/src/build/validateConfig.ts | 121 ++++++++++++------ 2 files changed, 145 insertions(+), 36 deletions(-) create mode 100644 packages/core/src/build/validateConfig.spec.ts diff --git a/packages/core/src/build/validateConfig.spec.ts b/packages/core/src/build/validateConfig.spec.ts new file mode 100644 index 00000000..7a886e64 --- /dev/null +++ b/packages/core/src/build/validateConfig.spec.ts @@ -0,0 +1,60 @@ +import { describe, test, expect } from "vitest"; + +import type { OpenNextConfig } from "../types/open-next.js"; + +import { validateConfig } from "./validateConfig.js"; + +describe("validateConfig", () => { + test("returns success for minimal valid config", () => { + const result = validateConfig({ default: {} } as OpenNextConfig); + expect(result.success).toBe(true); + }); + + test("returns shouldThrow:true for splitted function with no routes", () => { + const config = { + default: {}, + functions: { + broken: { routes: [], runtime: "edge" }, + }, + } as unknown as OpenNextConfig; + const result = validateConfig(config); + expect(result.success).toBe(false); + expect(result.shouldThrow).toBe(true); + expect(result.message).toMatch(/Splitted function broken must have at least one route/); + }); + + test("returns shouldThrow:false for incompatible wrapper and converter", () => { + const config = { + default: { override: { wrapper: "aws-lambda", converter: "edge" } }, + } as unknown as OpenNextConfig; + const result = validateConfig(config); + expect(result.success).toBe(false); + expect(result.shouldThrow).toBe(false); + expect(result.level).toBe("error"); + expect(result.message).toMatch(/not compatible/); + }); + + test("returns shouldThrow:false for disabled incremental cache warning", () => { + const config = { + default: {}, + dangerous: { disableIncrementalCache: true }, + } as unknown as OpenNextConfig; + const result = validateConfig(config); + expect(result.success).toBe(false); + expect(result.shouldThrow).toBe(false); + expect(result.level).toBe("warn"); + expect(result.message).toMatch(/disabled incremental cache/); + }); + + test("returns shouldThrow:false for disabled tag cache warning", () => { + const config = { + default: {}, + dangerous: { disableTagCache: true }, + } as unknown as OpenNextConfig; + const result = validateConfig(config); + expect(result.success).toBe(false); + expect(result.shouldThrow).toBe(false); + expect(result.level).toBe("warn"); + expect(result.message).toMatch(/disabled tag cache/); + }); +}); diff --git a/packages/core/src/build/validateConfig.ts b/packages/core/src/build/validateConfig.ts index 672e295a..b8847e09 100644 --- a/packages/core/src/build/validateConfig.ts +++ b/packages/core/src/build/validateConfig.ts @@ -6,7 +6,13 @@ import type { SplittedFunctionOptions, } from "@/types/open-next"; -import logger from "../logger.js"; +export type ValidateConfigResult = { + success: boolean; + message?: string; + shouldThrow?: boolean; + /** Logging level the caller should use when shouldThrow is false. Defaults to "warn". */ + level?: "warn" | "error"; +}; const compatibilityMatrix: Record = { "aws-lambda": ["aws-apigw-v1", "aws-apigw-v2", "aws-cloudfront", "sqs-revalidate"], @@ -20,74 +26,117 @@ const compatibilityMatrix: Record = { dummy: ["dummy"], }; -function validateFunctionOptions(fnOptions: FunctionOptions) { +function validateFunctionOptions(fnOptions: FunctionOptions): ValidateConfigResult { // TODO: validateConfig needs to be updated to normalize full-path override strings to bare names before the compatibilityMatrix lookup (full-path user overrides currently crash L41) const wrapper = typeof fnOptions.override?.wrapper === "string" ? fnOptions.override.wrapper : "aws-lambda"; const converter = typeof fnOptions.override?.converter === "string" ? fnOptions.override.converter : "aws-apigw-v2"; if (fnOptions.override?.generateDockerfile && converter !== "node" && wrapper !== "node") { - logger.warn( - "You've specified generateDockerfile without node converter and wrapper. Without custom converter and wrapper the dockerfile will not work" - ); + return { + success: false, + shouldThrow: false, + level: "warn", + message: + "You've specified generateDockerfile without node converter and wrapper. Without custom converter and wrapper the dockerfile will not work", + }; } if (converter === "aws-cloudfront" && fnOptions.placement !== "global") { - logger.warn( - "You've specified aws-cloudfront converter without global placement. This may not generate the correct output" - ); + return { + success: false, + shouldThrow: false, + level: "warn", + message: + "You've specified aws-cloudfront converter without global placement. This may not generate the correct output", + }; } const isCustomWrapper = typeof fnOptions.override?.wrapper === "function"; const isCustomConverter = typeof fnOptions.override?.converter === "function"; // Check if the wrapper and converter are compatible // Only check if using one of the included converters or wrapper if (!compatibilityMatrix[wrapper].includes(converter) && !isCustomWrapper && !isCustomConverter) { - logger.error( - `Wrapper ${wrapper} and converter ${converter} are not compatible. For the wrapper ${wrapper} you should only use the following converters: ${compatibilityMatrix[ + return { + success: false, + shouldThrow: false, + level: "error", + message: `Wrapper ${wrapper} and converter ${converter} are not compatible. For the wrapper ${wrapper} you should only use the following converters: ${compatibilityMatrix[ wrapper - ].join(", ")}` - ); + ].join(", ")}`, + }; } + return { success: true }; } -function validateSplittedFunctionOptions(fnOptions: SplittedFunctionOptions, name: string) { - validateFunctionOptions(fnOptions); +function validateSplittedFunctionOptions( + fnOptions: SplittedFunctionOptions, + name: string +): ValidateConfigResult { + const fnResult = validateFunctionOptions(fnOptions); + if (!fnResult.success) return fnResult; if (fnOptions.routes.length === 0) { - throw new Error(`Splitted function ${name} must have at least one route`); + return { + success: false, + shouldThrow: true, + message: `Splitted function ${name} must have at least one route`, + }; } // Check if the routes are properly formated - fnOptions.routes.forEach((route) => { + for (const route of fnOptions.routes) { if (!route.startsWith("app/") && !route.startsWith("pages/")) { - throw new Error( - `Route ${route} in function ${name} is not a valid route. It should starts with app/ or pages/ depending on if you use page or app router` - ); + return { + success: false, + shouldThrow: true, + message: `Route ${route} in function ${name} is not a valid route. It should starts with app/ or pages/ depending on if you use page or app router`, + }; } - }); + } if (fnOptions.runtime === "edge" && fnOptions.routes.length > 1) { - throw new Error(`Edge function ${name} can only have one route`); + return { + success: false, + shouldThrow: true, + message: `Edge function ${name} can only have one route`, + }; } + return { success: true }; } -export function validateConfig(config: OpenNextConfig) { - validateFunctionOptions(config.default); - Object.entries(config.functions ?? {}).forEach(([name, fnOptions]) => { - validateSplittedFunctionOptions(fnOptions, name); - }); +export function validateConfig(config: OpenNextConfig): ValidateConfigResult { + const defaultResult = validateFunctionOptions(config.default); + if (!defaultResult.success) return defaultResult; + for (const [name, fnOptions] of Object.entries(config.functions ?? {})) { + const splittedResult = validateSplittedFunctionOptions(fnOptions, name); + if (!splittedResult.success) return splittedResult; + } if (config.dangerous?.disableIncrementalCache) { - logger.warn("You've disabled incremental cache. This means that ISR and SSG will not work."); + return { + success: false, + shouldThrow: false, + level: "warn", + message: "You've disabled incremental cache. This means that ISR and SSG will not work.", + }; } if (config.dangerous?.disableTagCache) { - logger.warn( - `You've disabled tag cache. + return { + success: false, + shouldThrow: false, + level: "warn", + message: `You've disabled tag cache. This means that revalidatePath and revalidateTag from next/cache will not work. - It is safe to disable if you only use page router` - ); + It is safe to disable if you only use page router`, + }; } - validateFunctionOptions(config.imageOptimization ?? {}); + const imageOptimizationResult = validateFunctionOptions(config.imageOptimization ?? {}); + if (!imageOptimizationResult.success) return imageOptimizationResult; if (config.middleware?.external === true) { - validateFunctionOptions(config.middleware ?? {}); + const middlewareResult = validateFunctionOptions(config.middleware ?? {}); + if (!middlewareResult.success) return middlewareResult; } //@ts-expect-error - Revalidate custom wrapper type is different - validateFunctionOptions(config.revalidate ?? {}); + const revalidateResult = validateFunctionOptions(config.revalidate ?? {}); + if (!revalidateResult.success) return revalidateResult; //@ts-expect-error - Warmer custom wrapper type is different - validateFunctionOptions(config.warmer ?? {}); - validateFunctionOptions(config.initializationFunction ?? {}); + const warmerResult = validateFunctionOptions(config.warmer ?? {}); + if (!warmerResult.success) return warmerResult; + const initResult = validateFunctionOptions(config.initializationFunction ?? {}); + if (!initResult.success) return initResult; + return { success: true }; } From 531269e5ffe9dacf73bdb777d3f0f5ae6d2b23f9 Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Sun, 28 Jun 2026 09:46:10 +0200 Subject: [PATCH 09/13] refactor(core): extract buildOpenNextOutput, export OpenNextOutput type - Export OpenNextOutput interface (was internal) - Extract buildOpenNextOutput(buildOpts) for construction-only (no fs write) - Keep legacy generateOutput as thin wrapper (construction + file write) - Preserve all construction logic verbatim, including @ts-expect-error - Add 3 characterization tests in generateOutput.spec.ts - Backward compatible: byte-equivalent output to today --- .../core/src/build/generateOutput.spec.ts | 84 +++++++++++++++++++ packages/core/src/build/generateOutput.ts | 11 ++- 2 files changed, 92 insertions(+), 3 deletions(-) create mode 100644 packages/core/src/build/generateOutput.spec.ts diff --git a/packages/core/src/build/generateOutput.spec.ts b/packages/core/src/build/generateOutput.spec.ts new file mode 100644 index 00000000..0030820d --- /dev/null +++ b/packages/core/src/build/generateOutput.spec.ts @@ -0,0 +1,84 @@ +import * as fs from "node:fs"; + +import { describe, test, expect, vi } from "vitest"; + +import type { OpenNextConfig } from "../types/open-next.js"; + +import { buildOpenNextOutput, generateOutput } from "./generateOutput.js"; +import type { BuildOptions } from "./helper.js"; + +// We need to mock fs and the loadConfig import to avoid touching real files. +// The file imports { loadConfig } from "@/config/util.js" and uses fs directly. + +vi.mock("node:fs", () => ({ + default: { + readdirSync: vi.fn(() => []), + statSync: vi.fn(() => ({ isDirectory: () => false })), + writeFileSync: vi.fn(), + existsSync: vi.fn(() => false), + }, + readdirSync: vi.fn(() => []), + statSync: vi.fn(() => ({ isDirectory: () => false })), + writeFileSync: vi.fn(), + existsSync: vi.fn(() => false), +})); + +vi.mock("@/config/util.js", () => ({ + loadConfig: vi.fn(() => ({ basePath: "" })), +})); + +function createMockBuildOpts(): 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 OpenNextConfig, + 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", + }; +} + +describe("buildOpenNextOutput", () => { + test("returns an OpenNextOutput with expected keys (no fs writes)", async () => { + const opts = createMockBuildOpts(); + const output = await buildOpenNextOutput(opts); + expect(output).toHaveProperty("edgeFunctions"); + expect(output).toHaveProperty("origins"); + expect(output).toHaveProperty("behaviors"); + expect(output).toHaveProperty("additionalProps"); + // fs.writeFileSync must NOT be called by buildOpenNextOutput + expect(fs.writeFileSync).not.toHaveBeenCalled(); + }); + + test("returns undefined revalidationFunction when disableIncrementalCache is true", async () => { + const opts = createMockBuildOpts(); + (opts.config as OpenNextConfig).dangerous = { disableIncrementalCache: true }; + const output = await buildOpenNextOutput(opts); + expect(output.additionalProps?.revalidationFunction).toBeUndefined(); + }); +}); + +describe("generateOutput (legacy wrapper)", () => { + test("calls buildOpenNextOutput then writes the file", async () => { + const opts = createMockBuildOpts(); + await generateOutput(opts); + expect(fs.writeFileSync).toHaveBeenCalledTimes(1); + const [filePath, content] = vi.mocked(fs.writeFileSync).mock.calls[0] as [string, string]; + expect(filePath).toMatch(/\/\.open-next\/open-next\.output\.json$/); + const parsed = JSON.parse(content); + expect(parsed).toHaveProperty("behaviors"); + expect(parsed).toHaveProperty("origins"); + }); +}); diff --git a/packages/core/src/build/generateOutput.ts b/packages/core/src/build/generateOutput.ts index bc0795e2..e9915da6 100644 --- a/packages/core/src/build/generateOutput.ts +++ b/packages/core/src/build/generateOutput.ts @@ -66,7 +66,7 @@ type DefaultOrigins = { imageOptimizer: ImageOrigins; }; -interface OpenNextOutput { +export interface OpenNextOutput { edgeFunctions: { [key: string]: BaseFunction; } & { @@ -163,7 +163,7 @@ function prefixPattern(basePath: string) { }; } -export async function generateOutput(options: BuildOptions) { +export async function buildOpenNextOutput(options: BuildOptions): Promise { const { appBuildOutputPath, config } = options; const edgeFunctions: OpenNextOutput["edgeFunctions"] = {}; const isExternalMiddleware = config.middleware?.external ?? false; @@ -347,8 +347,13 @@ export async function generateOutput(options: BuildOptions) { }, }, }; + return output; +} + +export async function generateOutput(options: BuildOptions) { + const output = await buildOpenNextOutput(options); fs.writeFileSync( - path.join(appBuildOutputPath, ".open-next", "open-next.output.json"), + path.join(options.appBuildOutputPath, ".open-next", "open-next.output.json"), JSON.stringify(output) ); } From 12237780028df3079d4df5f12e394c9bc0718f3f Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Sun, 28 Jun 2026 09:51:58 +0200 Subject: [PATCH 10/13] refactor(core): branch on ValidateConfigResult in compileOpenNextConfig MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace bare validateConfig(config) call with result-handling block - Throw on shouldThrow:true (bad routes — preserves existing behavior) - Log at appropriate level on shouldThrow:false (level field from T1) - All 3 export signatures and edge-runtime detection block unchanged - Direct callers (aws/build.ts, cloudflare/utils.ts) unaffected --- packages/core/src/build/compileConfig.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/core/src/build/compileConfig.ts b/packages/core/src/build/compileConfig.ts index d4da6813..fe87932a 100644 --- a/packages/core/src/build/compileConfig.ts +++ b/packages/core/src/build/compileConfig.ts @@ -38,7 +38,14 @@ export async function compileOpenNextConfig( process.exit(1); } - validateConfig(config); + const validateResult = validateConfig(config); + if (!validateResult.success) { + if (validateResult.shouldThrow) { + throw new Error(validateResult.message); + } + const level = validateResult.level ?? "warn"; + logger[level](validateResult.message); + } // We need to check if the config uses the edge runtime at any point // If it does, we need to compile it with the edge runtime From a79ca6b864d6982399b82477b30dd7f6a0092cbe Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Sun, 28 Jun 2026 09:52:03 +0200 Subject: [PATCH 11/13] feat(core): overridable validateConfig and generic generateOutput on OpenNextAdapterOptions - Make OpenNextAdapterOptions and buildAdapter generic - Add validateConfig override hook (runs after callback in modifyConfig) - Add generateOutput override hook (returns T, gated by skipGenerateOutput) - buildAdapter serializes override return via fs.writeFileSync (override never touches fs) - Default path uses buildOpenNextOutput (extracted in T2) - Add 5 new tests covering override behaviors + default path + skipGenerateOutput - All 16 existing adapter tests preserved; AWS/Cloudflare adapters compile with default T --- packages/core/src/build/adapter.spec.ts | 83 ++++++++++++++++++++++++- packages/core/src/build/adapter.ts | 35 +++++++++-- 2 files changed, 110 insertions(+), 8 deletions(-) diff --git a/packages/core/src/build/adapter.spec.ts b/packages/core/src/build/adapter.spec.ts index 82c4fc28..bda407af 100644 --- a/packages/core/src/build/adapter.spec.ts +++ b/packages/core/src/build/adapter.spec.ts @@ -6,9 +6,11 @@ vi.mock("node:fs", () => ({ default: { mkdirSync: vi.fn(), copyFileSync: vi.fn(), + writeFileSync: vi.fn(), }, mkdirSync: vi.fn(), copyFileSync: vi.fn(), + writeFileSync: vi.fn(), })); // Mock node:module to control createRequire @@ -18,6 +20,16 @@ vi.mock("node:module", () => ({ })), })); +// Mock logger to capture log calls +vi.mock("../logger.js", () => ({ + default: { + warn: vi.fn(), + error: vi.fn(), + info: vi.fn(), + debug: vi.fn(), + }, +})); + // Mock all build functions vi.mock("./compileConfig.js", () => ({ compileOpenNextConfig: vi.fn(), @@ -57,6 +69,7 @@ vi.mock("./createWarmerBundle.js", () => ({ })); vi.mock("./generateOutput.js", () => ({ + buildOpenNextOutput: vi.fn(), generateOutput: vi.fn(), })); @@ -71,6 +84,7 @@ vi.mock("./helper.js", () => ({ })); import { addDebugFile } from "../debug.js"; +import logger from "../logger.js"; import type { OpenNextConfig } from "../types/open-next.js"; import { buildAdapter } from "./adapter.js"; @@ -85,7 +99,7 @@ import { createMiddleware } from "./createMiddleware.js"; import { createRevalidationBundle } from "./createRevalidationBundle.js"; import { createServerBundle } from "./createServerBundle.js"; import { createWarmerBundle } from "./createWarmerBundle.js"; -import { generateOutput } from "./generateOutput.js"; +import { buildOpenNextOutput } from "./generateOutput.js"; import * as buildHelper from "./helper.js"; import type { BuildOptions } from "./helper.js"; @@ -362,7 +376,9 @@ describe("buildAdapter", () => { const ctx = createMockContext(); await adapter.onBuildComplete(ctx); - expect(generateOutput).not.toHaveBeenCalled(); + expect(buildOpenNextOutput).not.toHaveBeenCalled(); + const fs = await import("node:fs"); + expect(fs.default.writeFileSync).not.toHaveBeenCalled(); }); test("onBuildComplete calls addDebugFile with outputs.json", async () => { @@ -443,4 +459,67 @@ describe("buildAdapter", () => { expect(createCacheAssets).not.toHaveBeenCalled(); expect(compileTagCacheProvider).not.toHaveBeenCalled(); }); + + test("validateConfig override is called after callback in modifyConfig and halts build on shouldThrow:true", async () => { + const mockValidator = vi.fn(() => ({ success: false, shouldThrow: true, message: "nope" })); + const adapter = buildAdapter(() => ({ validateConfig: mockValidator })); + + const nextConfig = { experimental: {}, images: {} } as BuildCompleteContext["config"]; + await expect(adapter.modifyConfig(nextConfig, { phase: "production" })).rejects.toThrow("nope"); + expect(mockValidator).toHaveBeenCalledOnce(); + }); + + test("validateConfig override with shouldThrow:false logs warn and continues", async () => { + const mockValidator = vi.fn(() => ({ success: false, message: "heads up" })); + const adapter = buildAdapter(() => ({ validateConfig: mockValidator })); + + const nextConfig = { experimental: {}, images: {} } as BuildCompleteContext["config"]; + await adapter.modifyConfig(nextConfig, { phase: "production" }); + expect(logger.warn).toHaveBeenCalledWith("heads up"); + }); + + test("onBuildComplete calls generateOutput override and writes its return via buildAdapter", async () => { + const mockOutput = vi.fn(async () => ({ custom: "shape" })); + const adapter = buildAdapter(() => ({ generateOutput: mockOutput })); + const nextConfig = { experimental: {}, images: {} } as BuildCompleteContext["config"]; + await adapter.modifyConfig(nextConfig, { phase: "production" }); + const ctx = createMockContext(); + await adapter.onBuildComplete(ctx); + expect(mockOutput).toHaveBeenCalledWith(expect.any(Object)); + const fs = await import("node:fs"); + expect(fs.default.writeFileSync).toHaveBeenCalledWith( + expect.stringMatching(/\/\.open-next\/open-next\.output\.json$/), + JSON.stringify({ custom: "shape" }) + ); + }); + + test("onBuildComplete with default generateOutput calls buildOpenNextOutput and writes result", async () => { + vi.mocked(buildOpenNextOutput).mockResolvedValue({ + origins: { default: {} }, + } as any); // oxlint-disable-line @typescript-eslint/no-explicit-any + const adapter = buildAdapter(() => ({})); + const nextConfig = { experimental: {}, images: {} } as BuildCompleteContext["config"]; + await adapter.modifyConfig(nextConfig, { phase: "production" }); + const ctx = createMockContext(); + await adapter.onBuildComplete(ctx); + expect(buildOpenNextOutput).toHaveBeenCalledWith(expect.any(Object)); + const fs = await import("node:fs"); + expect(fs.default.writeFileSync).toHaveBeenCalledWith( + expect.stringMatching(/\/\.open-next\/open-next\.output\.json$/), + JSON.stringify({ origins: { default: {} } }) + ); + }); + + test("onBuildComplete skipGenerateOutput skips generateOutput override and buildOpenNextOutput", async () => { + const mockOutput = vi.fn(); + const adapter = buildAdapter(() => ({ skipGenerateOutput: true, generateOutput: mockOutput })); + const nextConfig = { experimental: {}, images: {} } as BuildCompleteContext["config"]; + await adapter.modifyConfig(nextConfig, { phase: "production" }); + const ctx = createMockContext(); + await adapter.onBuildComplete(ctx); + expect(buildOpenNextOutput).not.toHaveBeenCalled(); + expect(mockOutput).not.toHaveBeenCalled(); + const fs = await import("node:fs"); + expect(fs.default.writeFileSync).not.toHaveBeenCalled(); + }); }); diff --git a/packages/core/src/build/adapter.ts b/packages/core/src/build/adapter.ts index 9fa82d0c..9c3488f7 100644 --- a/packages/core/src/build/adapter.ts +++ b/packages/core/src/build/adapter.ts @@ -5,6 +5,7 @@ import path from "node:path"; import type { Plugin } from "esbuild"; import { addDebugFile } from "../debug.js"; +import logger from "../logger.js"; import type { ContentUpdater } from "../plugins/content-updater.js"; import type { BundleDefaults } from "../plugins/resolve.js"; import type { NextAdapterOutputs } from "../types/adapter.js"; @@ -20,9 +21,11 @@ import { createMiddleware } from "./createMiddleware.js"; import { createRevalidationBundle } from "./createRevalidationBundle.js"; import { createServerBundle } from "./createServerBundle.js"; import { createWarmerBundle } from "./createWarmerBundle.js"; -import { generateOutput } from "./generateOutput.js"; +import { buildOpenNextOutput } from "./generateOutput.js"; +import type { OpenNextOutput } from "./generateOutput.js"; import * as buildHelper from "./helper.js"; import type { CodePatcher } from "./patch/codePatcher.js"; +import type { ValidateConfigResult } from "./validateConfig.js"; const require = createRequire(import.meta.url); @@ -51,7 +54,7 @@ export type NextAdapter = { /** * The influence an adapter can exert on the build process, returned by the callback. */ -export type OpenNextAdapterOptions = { +export type OpenNextAdapterOptions = { skipRevalidation?: boolean; skipImageOptimization?: boolean; skipWarmer?: boolean; @@ -75,6 +78,8 @@ export type OpenNextAdapterOptions = { * Precedence: config override > platform default > core node default. */ defaultOverrides?: BundleDefaults; + validateConfig?: (config: OpenNextConfig) => ValidateConfigResult | Promise; + generateOutput?: (buildOpts: buildHelper.BuildOptions) => Promise; }; /** @@ -87,13 +92,13 @@ export type OpenNextAdapterOptions = { * returning adapter-specific influence over the build process. * @returns A NextAdapter with modifyConfig and onBuildComplete hooks. */ -export function buildAdapter( - callback: (config: OpenNextConfig, buildOpts: buildHelper.BuildOptions) => OpenNextAdapterOptions +export function buildAdapter( + callback: (config: OpenNextConfig, buildOpts: buildHelper.BuildOptions) => OpenNextAdapterOptions ): NextAdapter { // Closure-scoped state — no module-level mutable variables let buildOpts: buildHelper.BuildOptions; let config: OpenNextConfig; - let adapterOptions: OpenNextAdapterOptions; + let adapterOptions: OpenNextAdapterOptions; return { name: "OpenNext", @@ -129,6 +134,18 @@ export function buildAdapter( // Step 6: Call the adapter callback to get influence adapterOptions = callback(config, buildOpts); + // Run adapter-level validate override (additional check; default already ran in compileOpenNextConfig) + if (adapterOptions.validateConfig) { + const result = await adapterOptions.validateConfig(config); + if (!result.success) { + if (result.shouldThrow) { + throw new Error(result.message); + } + const level = result.level ?? "warn"; + logger[level](result.message); + } + } + // Step 7: Build tempCachePath const packagePath = buildHelper.getPackagePath(buildOpts); const tempCachePath = @@ -231,7 +248,13 @@ export function buildAdapter( // Step 12: Generate output if (!adapterOptions.skipGenerateOutput) { - await generateOutput(buildOpts); + const output = adapterOptions.generateOutput + ? await adapterOptions.generateOutput(buildOpts) + : await buildOpenNextOutput(buildOpts); + fs.writeFileSync( + path.join(buildOpts.appBuildOutputPath, ".open-next", "open-next.output.json"), + JSON.stringify(output) + ); console.log("Output generated"); } }, From f51635d8d41341e1b13e33b3658768526a8726bb Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Sun, 28 Jun 2026 13:06:02 +0200 Subject: [PATCH 12/13] fix(core): use require.resolve for full-path overrides in resolve plugin When an adapter config specifies full package-specifier paths (e.g., @opennextjs/aws/overrides/wrappers/aws-lambda.js), esbuild cannot resolve them during bundling. Use createRequire(args.path).resolve() in the openNextResolvePlugin to convert package specifiers to filesystem-relative paths at build time, falling back to the original value if resolution fails. This fixes the openbuild:local build error: ERROR: Could not resolve "@opennextjs/aws/overrides/wrappers/aws-lambda.js" ERROR: Could not resolve "@opennextjs/aws/overrides/tagCache/dynamodb.js" Added test I verifying resolution of a mock package in node_modules. --- packages/core/src/plugins/resolve.spec.ts | 60 ++++++++++++++++------- packages/core/src/plugins/resolve.ts | 9 +++- 2 files changed, 49 insertions(+), 20 deletions(-) diff --git a/packages/core/src/plugins/resolve.spec.ts b/packages/core/src/plugins/resolve.spec.ts index 2d30f31a..351f2f53 100644 --- a/packages/core/src/plugins/resolve.spec.ts +++ b/packages/core/src/plugins/resolve.spec.ts @@ -104,8 +104,8 @@ describe("openNextResolvePlugin", () => { defaultOverrides: { converter: "@opennextjs/core/overrides/converters/edge.js" }, fnName: "test", }); - expect(result.contents).toContain("@opennextjs/core/overrides/converters/edge.js"); - expect(result.contents).not.toContain("@opennextjs/core/overrides/converters/node.js"); + expect(result.contents).toContain("overrides/converters/edge.js"); + expect(result.contents).not.toContain('"../overrides/converters/node.js"'); }); test("B - cross-package user full aws path wins over core default", async () => { @@ -114,8 +114,8 @@ describe("openNextResolvePlugin", () => { defaultOverrides: { converter: "@opennextjs/core/overrides/converters/edge.js" }, fnName: "test", }); - expect(result.contents).toContain("@opennextjs/aws/overrides/converters/aws-apigw-v2.js"); - expect(result.contents).not.toContain("@opennextjs/core/overrides/converters/edge.js"); + expect(result.contents).toContain("overrides/converters/aws-apigw-v2.js"); + expect(result.contents).not.toContain("overrides/converters/edge.js"); }); test("C - no-op anchor stays: no override no default keeps relative core path", async () => { @@ -144,16 +144,16 @@ describe("openNextResolvePlugin", () => { }, fnName: "test", }); - expect(result.contents).toContain("@opennextjs/aws/overrides/wrappers/aws-lambda.js"); - expect(result.contents).toContain("@opennextjs/core/overrides/converters/edge.js"); - expect(result.contents).toContain("@opennextjs/aws/overrides/tagCache/dynamodb.js"); - expect(result.contents).toContain("@opennextjs/aws/overrides/queue/sqs.js"); - expect(result.contents).toContain("@opennextjs/aws/overrides/incrementalCache/s3.js"); - expect(result.contents).toContain("@opennextjs/core/overrides/imageLoader/dummy.js"); - expect(result.contents).toContain("@opennextjs/core/overrides/originResolver/dummy.js"); - expect(result.contents).toContain("@opennextjs/aws/overrides/warmer/aws-lambda.js"); - expect(result.contents).toContain("@opennextjs/core/overrides/proxyExternalRequest/fetch.js"); - expect(result.contents).toContain("@opennextjs/aws/overrides/cdnInvalidation/cloudfront.js"); + expect(result.contents).toContain("overrides/wrappers/aws-lambda.js"); + expect(result.contents).toContain("overrides/converters/edge.js"); + expect(result.contents).toContain("overrides/tagCache/dynamodb.js"); + expect(result.contents).toContain("overrides/queue/sqs.js"); + expect(result.contents).toContain("overrides/incrementalCache/s3.js"); + expect(result.contents).toContain("overrides/imageLoader/dummy.js"); + expect(result.contents).toContain("overrides/originResolver/dummy.js"); + expect(result.contents).toContain("overrides/warmer/aws-lambda.js"); + expect(result.contents).toContain("overrides/proxyExternalRequest/fetch.js"); + expect(result.contents).toContain("overrides/cdnInvalidation/cloudfront.js"); }); test("E - deprecated cloudflare bare name becomes legacy relative core path", async () => { @@ -190,11 +190,11 @@ describe("openNextResolvePlugin", () => { }, fnName: "server", }); - expect(result.contents).toContain("@opennextjs/aws/overrides/wrappers/aws-lambda-streaming.js"); - expect(result.contents).toContain("@opennextjs/aws/overrides/converters/aws-apigw-v2.js"); - expect(result.contents).toContain("@opennextjs/aws/overrides/incrementalCache/s3.js"); - expect(result.contents).toContain("@opennextjs/aws/overrides/tagCache/dynamodb.js"); - expect(result.contents).toContain("@opennextjs/aws/overrides/queue/sqs.js"); + expect(result.contents).toContain("overrides/wrappers/aws-lambda-streaming.js"); + expect(result.contents).toContain("overrides/converters/aws-apigw-v2.js"); + expect(result.contents).toContain("overrides/incrementalCache/s3.js"); + expect(result.contents).toContain("overrides/tagCache/dynamodb.js"); + expect(result.contents).toContain("overrides/queue/sqs.js"); }); test("H - bare-name user override becomes legacy relative core path", async () => { @@ -206,4 +206,26 @@ describe("openNextResolvePlugin", () => { expect(result.contents).toContain("../overrides/converters/edge.js"); expect(result.contents).not.toContain("@opennextjs/core/overrides/converters/node.js"); }); + + test("I - resolvable package specifier is converted to relative filesystem path", async () => { + const rootDir = join(fixtureDir, ".."); + const pkgDir = join(rootDir, "node_modules", "@test-pkg", "wrapper"); + await mkdir(pkgDir, { recursive: true }); + await writeFile( + join(pkgDir, "package.json"), + JSON.stringify({ name: "@test-pkg/wrapper", main: "index.js" }), + "utf-8", + ); + await writeFile(join(pkgDir, "index.js"), "module.exports = {};", "utf-8"); + + const result = await runPlugin({ + overrides: { wrapper: "@test-pkg/wrapper" }, + defaultOverrides: {}, + fnName: "test", + }); + + expect(result.contents).not.toContain('"@test-pkg/wrapper"'); + expect(result.contents).toContain("node_modules/@test-pkg/wrapper/index.js"); + expect(result.contents).toMatch(/"\.\/.*node_modules\/@test-pkg\/wrapper\/index\.js"/); + }); }); diff --git a/packages/core/src/plugins/resolve.ts b/packages/core/src/plugins/resolve.ts index 0c64aef7..997c4c53 100644 --- a/packages/core/src/plugins/resolve.ts +++ b/packages/core/src/plugins/resolve.ts @@ -1,4 +1,6 @@ import { readFile } from "node:fs/promises"; +import { createRequire } from "node:module"; +import { dirname, relative } from "node:path"; import { type Edit, Lang, parse } from "@ast-grep/napi"; import chalk from "chalk"; @@ -142,7 +144,12 @@ export function openNextResolvePlugin({ let targetPath: string; if (typeof overrideValue === "string") { if (isFullPath(overrideValue)) { - targetPath = overrideValue; + try { + const resolved = createRequire(args.path).resolve(overrideValue); + targetPath = "./" + relative(dirname(args.path), resolved); + } catch { + targetPath = overrideValue; + } } else { targetPath = `../overrides/${folder}/${overrideValue}.js`; } From d1b99ee797888f835cf9197045926a85f16310f2 Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Sun, 28 Jun 2026 13:17:32 +0200 Subject: [PATCH 13/13] format --- packages/core/src/plugins/resolve.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/plugins/resolve.spec.ts b/packages/core/src/plugins/resolve.spec.ts index 351f2f53..034ac947 100644 --- a/packages/core/src/plugins/resolve.spec.ts +++ b/packages/core/src/plugins/resolve.spec.ts @@ -214,7 +214,7 @@ describe("openNextResolvePlugin", () => { await writeFile( join(pkgDir, "package.json"), JSON.stringify({ name: "@test-pkg/wrapper", main: "index.js" }), - "utf-8", + "utf-8" ); await writeFile(join(pkgDir, "index.js"), "module.exports = {};", "utf-8");