From b716b58fec0dfb42940e27588f9bd24054d5216a Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Sat, 27 Jun 2026 17:17:49 +0200 Subject: [PATCH 01/24] 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 a1a8214dfd7ceb0d5b6b6b0e108af8edbb3485f6 Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Sat, 27 Jun 2026 21:21:21 +0200 Subject: [PATCH 02/24] 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 e17f1933223b26c17a9854347dfcb3b419c8f480 Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Sat, 27 Jun 2026 21:24:03 +0200 Subject: [PATCH 03/24] 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 140971ee0d723c44dc5bdd09ddfe4cdc16cc6d69 Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Sat, 27 Jun 2026 21:27:14 +0200 Subject: [PATCH 04/24] 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 d257b836af45f2b4591484ae5bf93b9a938b3307 Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Sat, 27 Jun 2026 21:36:43 +0200 Subject: [PATCH 05/24] 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 a7f0c0e8e3c9d54792c3c2fd2e3c621e3d3dc0f3 Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Sat, 27 Jun 2026 21:54:50 +0200 Subject: [PATCH 06/24] 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 01aedb3bd29c72233adad06537a9b82a39271e59 Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Sat, 27 Jun 2026 23:49:46 +0200 Subject: [PATCH 07/24] 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 6a122114db8b2e97b88d50c287ffe75852c7aa73 Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Sun, 28 Jun 2026 09:46:06 +0200 Subject: [PATCH 08/24] 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 1cc6c5b409456080b1d87a4dde801237bdea7344 Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Sun, 28 Jun 2026 09:46:10 +0200 Subject: [PATCH 09/24] 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 4aa963851947d3fd89b3b0584b319b6fab924a26 Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Sun, 28 Jun 2026 09:51:58 +0200 Subject: [PATCH 10/24] 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 72b7ac1554f21a2beae0c07012888bbfeeb999ca Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Sun, 28 Jun 2026 09:52:03 +0200 Subject: [PATCH 11/24] 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 330133a8508c59ee904bee6ebdeec30f2c9c6c4d Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Sun, 28 Jun 2026 13:06:02 +0200 Subject: [PATCH 12/24] 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 f2de8e2ef844b6a9450fadd0c7250a7c3c78be73 Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Sun, 28 Jun 2026 13:17:32 +0200 Subject: [PATCH 13/24] 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"); From 40eb364cb029d8c67e49ebc3b457d2a91ce1b985 Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Wed, 15 Jul 2026 21:53:03 +0200 Subject: [PATCH 14/24] feat(cloudflare): add support for Cloudflare Containers with new configuration and E2E tests --- .github/workflows/local.yml | 6 ++ examples-cloudflare/common/config-e2e.ts | 33 +++++++--- .../e2e/app-router/e2e/container.test.ts | 14 +++++ .../e2e/playwright.container.config.ts | 8 +++ .../app-router/open-next.container.config.ts | 5 ++ .../e2e/app-router/package.json | 3 + .../e2e/app-router/wrangler.container.jsonc | 48 ++++++++++++++ packages/cloudflare/package.json | 1 + packages/cloudflare/src/api/config.spec.ts | 47 ++++++++++++++ packages/cloudflare/src/api/config.ts | 61 +++++++++++++++--- packages/cloudflare/src/api/container.ts | 18 ++++++ packages/cloudflare/src/cli/adapter.ts | 41 ++++++++---- .../utils/copy-package-cli-files.spec.ts | 29 +++++++++ .../cli/build/utils/copy-package-cli-files.ts | 14 ++++- .../cli/build/utils/ensure-cf-config.spec.ts | 18 ++++++ .../src/cli/build/utils/ensure-cf-config.ts | 62 ++++++++++++++++++- .../cloudflare/src/cli/templates/container.ts | 53 ++++++++++++++++ packages/core/src/build/createServerBundle.ts | 2 +- pnpm-lock.yaml | 8 +++ 19 files changed, 437 insertions(+), 34 deletions(-) create mode 100644 examples-cloudflare/e2e/app-router/e2e/container.test.ts create mode 100644 examples-cloudflare/e2e/app-router/e2e/playwright.container.config.ts create mode 100644 examples-cloudflare/e2e/app-router/open-next.container.config.ts create mode 100644 examples-cloudflare/e2e/app-router/wrangler.container.jsonc create mode 100644 packages/cloudflare/src/api/config.spec.ts create mode 100644 packages/cloudflare/src/api/container.ts create mode 100644 packages/cloudflare/src/cli/build/utils/copy-package-cli-files.spec.ts create mode 100644 packages/cloudflare/src/cli/build/utils/ensure-cf-config.spec.ts create mode 100644 packages/cloudflare/src/cli/templates/container.ts diff --git a/.github/workflows/local.yml b/.github/workflows/local.yml index 62a97855..b26b7fb3 100644 --- a/.github/workflows/local.yml +++ b/.github/workflows/local.yml @@ -91,3 +91,9 @@ jobs: - name: Run E2E Test in Cloudflare Environment shell: bash run: pnpm turbo e2e:cf + + - name: Run Cloudflare Container E2E Test + shell: bash + run: | + pnpm --filter examples-cloudflare/e2e-app-router build:worker:container + pnpm --filter examples-cloudflare/e2e-app-router e2e:container diff --git a/examples-cloudflare/common/config-e2e.ts b/examples-cloudflare/common/config-e2e.ts index 1a02c65d..9ecc095d 100644 --- a/examples-cloudflare/common/config-e2e.ts +++ b/examples-cloudflare/common/config-e2e.ts @@ -4,9 +4,21 @@ import { getAppPort, getInspectorPort, type AppName } from "./apps"; declare const process: typeof nodeProcess; -export function configurePlaywright( - app: AppName, - { +type ConfigurePlaywrightOptions = { + /** Whether the Playwright run is executing in CI. */ + isCI?: boolean; + /** Whether the app runs in a Worker instead of through `next dev`. */ + isWorker?: boolean; + multipleBrowsers?: boolean; + parallel?: boolean; + useTurbopack?: boolean; + workerBuildScript?: string; + workerPreviewScript?: string; + testMatch?: string | string[]; +}; + +export function configurePlaywright(app: AppName, options: ConfigurePlaywrightOptions = {}) { + const { // Do we run on CI? isCI = Boolean(process.env.CI), // Do we run on workers (`wrangler dev`) or on Node (`next dev`) @@ -17,8 +29,13 @@ export function configurePlaywright( parallel = true, // Use the turbopack runtime useTurbopack = false, - } = {} -) { + // Script used to build the Worker before starting it + workerBuildScript = "build:worker", + // Script used to start the Worker + workerPreviewScript = "preview:worker", + // Test file or glob to run + testMatch, + } = options; const port = getAppPort(app, { isWorker }); const inspectorPort = getInspectorPort(app); const baseURL = `http://localhost:${port}`; @@ -26,10 +43,11 @@ export function configurePlaywright( let timeout: number; if (isWorker) { // Do not build on CI - there is a preceding build step - command = isCI ? "" : `pnpm ${useTurbopack ? "build:worker-turbopack" : "build:worker"} && `; + const buildScript = useTurbopack ? "build:worker-turbopack" : workerBuildScript; + command = isCI ? "" : `pnpm ${buildScript} && `; const env = app === "r2-incremental-cache" ? "--env e2e" : ""; - command += `pnpm preview:worker -- --port ${port} --inspector-port ${inspectorPort} ${env}`; + command += `pnpm ${workerPreviewScript} -- --port ${port} --inspector-port ${inspectorPort} ${env}`; timeout = 800_000; } else { timeout = 100_000; @@ -63,6 +81,7 @@ export function configurePlaywright( testIgnore: isWorker ? "*next.spec.ts" : "*cloudflare.spec.ts", /* Run tests in files in parallel */ fullyParallel: parallel, + ...(testMatch ? { testMatch } : {}), /* Fail the build on CI if you accidentally left test.only in the source code. */ forbidOnly: isCI, /* Retry on CI only */ diff --git a/examples-cloudflare/e2e/app-router/e2e/container.test.ts b/examples-cloudflare/e2e/app-router/e2e/container.test.ts new file mode 100644 index 00000000..29719773 --- /dev/null +++ b/examples-cloudflare/e2e/app-router/e2e/container.test.ts @@ -0,0 +1,14 @@ +import { expect, test } from "@playwright/test"; + +test("serves SSR from the Node.js container", async ({ page }) => { + await page.goto("/ssr"); + + await expect(page.getByText("Time:")).toBeVisible(); +}); + +test("runs external middleware before forwarding to the container", async ({ page }) => { + await page.goto("/rewrite"); + + await expect(page).toHaveURL(/\/rewrite$/); + await expect(page.getByText("Rewritten Destination", { exact: true })).toBeVisible(); +}); diff --git a/examples-cloudflare/e2e/app-router/e2e/playwright.container.config.ts b/examples-cloudflare/e2e/app-router/e2e/playwright.container.config.ts new file mode 100644 index 00000000..c441e1bf --- /dev/null +++ b/examples-cloudflare/e2e/app-router/e2e/playwright.container.config.ts @@ -0,0 +1,8 @@ +import { configurePlaywright } from "../../../common/config-e2e"; + +export default configurePlaywright("app-router", { + useTurbopack: false, + workerBuildScript: "build:worker:container", + workerPreviewScript: "preview:container", + testMatch: "container.test.ts", +}); diff --git a/examples-cloudflare/e2e/app-router/open-next.container.config.ts b/examples-cloudflare/e2e/app-router/open-next.container.config.ts new file mode 100644 index 00000000..891cb65b --- /dev/null +++ b/examples-cloudflare/e2e/app-router/open-next.container.config.ts @@ -0,0 +1,5 @@ +import { defineCloudflareConfig } from "@opennextjs/cloudflare"; + +export default defineCloudflareConfig({ + container: true, +}); diff --git a/examples-cloudflare/e2e/app-router/package.json b/examples-cloudflare/e2e/app-router/package.json index d1924fbb..8358cfb0 100644 --- a/examples-cloudflare/e2e/app-router/package.json +++ b/examples-cloudflare/e2e/app-router/package.json @@ -10,9 +10,12 @@ "lint": "next lint", "clean": "rm -rf .turbo node_modules .next .open-next", "build:worker:cf": "pnpm opennextjs-cloudflare build", + "build:worker:container": "pnpm opennextjs-cloudflare build --openNextConfigPath open-next.container.config.ts --config wrangler.container.jsonc", "preview:worker": "pnpm opennextjs-cloudflare preview", + "preview:container": "pnpm opennextjs-cloudflare preview --config wrangler.container.jsonc", "preview": "pnpm build:worker && pnpm preview:worker", "e2e:cf": "playwright test -c e2e/playwright.config.ts", + "e2e:container": "playwright test -c e2e/playwright.container.config.ts", "build:worker-turbopack": "pnpm build:worker --openNextConfigPath open-next.turbopack.config.ts", "e2e-turbopack": "playwright test -c e2e/playwright.turbopack.config.ts" }, diff --git a/examples-cloudflare/e2e/app-router/wrangler.container.jsonc b/examples-cloudflare/e2e/app-router/wrangler.container.jsonc new file mode 100644 index 00000000..dd38797f --- /dev/null +++ b/examples-cloudflare/e2e/app-router/wrangler.container.jsonc @@ -0,0 +1,48 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "main": ".open-next/worker.js", + "name": "app-router-container", + "compatibility_date": "2024-12-30", + "compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"], + "assets": { + "directory": ".open-next/assets", + "binding": "ASSETS", + }, + "containers": [ + { + "class_name": "OpenNextContainer", + "image": ".open-next/server-functions/default/Dockerfile", + "max_instances": 1, + "instance_type": "lite", + }, + ], + "durable_objects": { + "bindings": [ + { + "name": "OPEN_NEXT_CONTAINER", + "class_name": "OpenNextContainer", + }, + ], + }, + "r2_buckets": [ + { + "binding": "NEXT_INC_CACHE_R2_BUCKET", + "bucket_name": "cache", + }, + ], + "services": [ + { + "binding": "WORKER_SELF_REFERENCE", + "service": "app-router", + }, + ], + "vars": { + "OPEN_NEXT_REQUEST_ID_HEADER": "true", + }, + "migrations": [ + { + "tag": "v1", + "new_sqlite_classes": ["OpenNextContainer", "DOQueueHandler", "DOShardedTagCache", "BucketCachePurge"], + }, + ], +} diff --git a/packages/cloudflare/package.json b/packages/cloudflare/package.json index 71bcd44b..a24f2c05 100644 --- a/packages/cloudflare/package.json +++ b/packages/cloudflare/package.json @@ -51,6 +51,7 @@ }, "dependencies": { "@ast-grep/napi": "0.40.5", + "@cloudflare/containers": "^0.3.7", "@dotenvx/dotenvx": "catalog:", "@opennextjs/core": "workspace:*", "cloudflare": "^4.4.1", diff --git a/packages/cloudflare/src/api/config.spec.ts b/packages/cloudflare/src/api/config.spec.ts new file mode 100644 index 00000000..a571874c --- /dev/null +++ b/packages/cloudflare/src/api/config.spec.ts @@ -0,0 +1,47 @@ +import { describe, expect, test } from "vitest"; + +import { defineCloudflareConfig } from "./config.js"; + +describe("defineCloudflareConfig", () => { + test("uses the Worker defaults by default", () => { + const config = defineCloudflareConfig(); + + expect(config.cloudflare?.container).toBe(false); + expect(config.default.override).toMatchObject({ + wrapper: "cloudflare-node", + converter: "edge", + proxyExternalRequest: "fetch", + }); + }); + + test("configures a Node.js container default function", () => { + const config = defineCloudflareConfig({ container: true }); + + expect(config.cloudflare?.container).toBe(true); + expect(config.default.override).toEqual({ + wrapper: "node", + converter: "node", + generateDockerfile: true, + incrementalCache: "dummy", + tagCache: "dummy", + queue: "dummy", + }); + expect(config.middleware).toMatchObject({ + external: true, + override: { + wrapper: "cloudflare-edge", + converter: "edge", + proxyExternalRequest: "fetch", + }, + }); + }); + + test("rejects Cloudflare binding-backed overrides for containers", () => { + expect(() => + defineCloudflareConfig({ + container: true, + incrementalCache: () => ({ name: "unsupported" }) as never, + }) + ).toThrow("Container mode"); + }); +}); diff --git a/packages/cloudflare/src/api/config.ts b/packages/cloudflare/src/api/config.ts index 32d9be73..9fe26773 100644 --- a/packages/cloudflare/src/api/config.ts +++ b/packages/cloudflare/src/api/config.ts @@ -22,6 +22,17 @@ export type Override = "dummy" | T | LazyLoadedOverride< * See the [Caching documentation](https://opennext.js.org/cloudflare/caching)) */ export type CloudflareOverrides = { + /** + * Run the default Next.js server in a Cloudflare Container. + * + * The Cloudflare Worker continues to run the external middleware and forwards + * requests that reach the default server to one container instance. + * + * Cloudflare binding-backed caches are not available from the Node.js + * container in this mode. + */ + container?: true; + /** * Sets the incremental cache implementation. */ @@ -57,25 +68,51 @@ export type CloudflareOverrides = { * @returns the OpenNext configuration object */ export function defineCloudflareConfig(config: CloudflareOverrides = {}): OpenNextConfig { - const { incrementalCache, tagCache, queue, cachePurge, routePreloadingBehavior = "none" } = config; - - return { - default: { - override: { - wrapper: "cloudflare-node", - converter: "edge", - proxyExternalRequest: "fetch", + const { + container = false, + incrementalCache, + tagCache, + queue, + cachePurge, + routePreloadingBehavior = "none", + } = config; + if ( + container && + [incrementalCache, tagCache, queue, cachePurge].some((value) => value !== undefined && value !== "dummy") + ) { + throw new Error( + "Cloudflare Container mode only supports the default dummy cache, tag cache, queue, and cache purge overrides." + ); + } + const defaultOverride = container + ? { + wrapper: "node" as const, + converter: "node" as const, + generateDockerfile: true, + incrementalCache: "dummy" as const, + tagCache: "dummy" as const, + queue: "dummy" as const, + } + : { + wrapper: "cloudflare-node" as const, + converter: "edge" as const, + proxyExternalRequest: "fetch" as const, incrementalCache: resolveIncrementalCache(incrementalCache), tagCache: resolveTagCache(tagCache), queue: resolveQueue(queue), cdnInvalidation: resolveCdnInvalidation(cachePurge), - }, + }; + + return { + default: { + override: defaultOverride, routePreloadingBehavior, }, // node:crypto is used to compute cache keys edgeExternals: ["node:crypto"], cloudflare: { useWorkerdCondition: true, + container, }, middleware: { external: true, @@ -126,6 +163,12 @@ function resolveCdnInvalidation(value: CloudflareOverrides["cachePurge"] = "dumm interface OpenNextConfig extends AwsOpenNextConfig { cloudflare?: { + /** + * Whether the default function runs in a Cloudflare Container. + * @default false + */ + container?: boolean; + /** * Whether to use the "workerd" build conditions when bundling the server. * It is recommended to set it to `true` so that code specifically targeted to the diff --git a/packages/cloudflare/src/api/container.ts b/packages/cloudflare/src/api/container.ts new file mode 100644 index 00000000..c61d4b90 --- /dev/null +++ b/packages/cloudflare/src/api/container.ts @@ -0,0 +1,18 @@ +import { Container, getContainer } from "@cloudflare/containers"; + +export const OPEN_NEXT_CONTAINER_BINDING = "OPEN_NEXT_CONTAINER"; +export const OPEN_NEXT_CONTAINER_NAME = "default"; + +/** + * The Durable Object controller for the Node.js OpenNext server container. + * + * The matching Wrangler configuration must declare this class as a container + * and bind it as `OPEN_NEXT_CONTAINER`. + */ +export class OpenNextContainer extends Container { + override defaultPort = 3000; +} + +export function getOpenNextContainer(containerNamespace: DurableObjectNamespace) { + return getContainer(containerNamespace, OPEN_NEXT_CONTAINER_NAME); +} diff --git a/packages/cloudflare/src/cli/adapter.ts b/packages/cloudflare/src/cli/adapter.ts index b3079449..18550376 100644 --- a/packages/cloudflare/src/cli/adapter.ts +++ b/packages/cloudflare/src/cli/adapter.ts @@ -1,6 +1,7 @@ /* oxlint-disable @typescript-eslint/no-explicit-any */ import fs from "node:fs"; import path from "node:path"; +import { fileURLToPath } from "node:url"; import { buildAdapter } from "@opennextjs/core/build/adapter.js"; import type { BuildOptions } from "@opennextjs/core/build/helper.js"; @@ -23,8 +24,11 @@ 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"; +import { copyPackageCliFiles } from "./build/utils/copy-package-cli-files.js"; export default buildAdapter((config: OpenNextConfig, buildOpts: BuildOptions) => { + const isContainer = + (config as OpenNextConfig & { cloudflare?: { container?: boolean } }).cloudflare?.container === true; const packagePath = buildHelper.getPackagePath(buildOpts); return { skipRevalidation: true, @@ -47,7 +51,7 @@ export default buildAdapter((config: OpenNextConfig, buildOpts: BuildOptions) => await compileSkewProtection(buildOpts, openNextConfig); }, serverBundle: { - useEdgeConfig: true, + useEdgeConfig: !isContainer, externals: ["./middleware.mjs"], banner: (name: string) => [ `globalThis.monorepoPackagePath = "${normalizePath(packagePath)}";`, @@ -56,21 +60,32 @@ export default buildAdapter((config: OpenNextConfig, buildOpts: BuildOptions) => 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, - }), + ...(isContainer + ? [] + : [ + ...(config.middleware?.external + ? [ + openNextExternalMiddlewarePlugin( + path.join(buildOpts.openNextDistDir, "core/edgeFunctionHandler.js") + ), + ] + : []), + openNextEdgePlugins({ + nextDir: path.join(buildOpts.appBuildOutputPath, ".next"), + isInCloudflare: true, + }), + ]), ], - additionalCodePatches: [patchResRevalidate, patchUseCacheIO, patchTurbopackRuntime], + additionalCodePatches: isContainer + ? [patchUseCacheIO, patchTurbopackRuntime] + : [patchResRevalidate, patchUseCacheIO, patchTurbopackRuntime], }, afterServerBundle: async (buildOpts, _config) => { + if (isContainer) { + const packageDistDir = path.join(path.dirname(fileURLToPath(import.meta.url)), ".."); + copyPackageCliFiles(packageDistDir, buildOpts, "container"); + return; + } compileDurableObjects(buildOpts); await bundleServer(buildOpts, { minify: false } as any); }, diff --git a/packages/cloudflare/src/cli/build/utils/copy-package-cli-files.spec.ts b/packages/cloudflare/src/cli/build/utils/copy-package-cli-files.spec.ts new file mode 100644 index 00000000..ec095a9a --- /dev/null +++ b/packages/cloudflare/src/cli/build/utils/copy-package-cli-files.spec.ts @@ -0,0 +1,29 @@ +import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { afterEach, describe, expect, test } from "vitest"; + +import { copyPackageCliFiles } from "./copy-package-cli-files.js"; + +const tempDirectories: string[] = []; + +afterEach(async () => { + await Promise.all(tempDirectories.splice(0).map((dir) => rm(dir, { force: true, recursive: true }))); +}); + +describe("copyPackageCliFiles", () => { + test("writes the container Worker entrypoint when requested", async () => { + const root = await mkdtemp(path.join(tmpdir(), "opennext-container-")); + tempDirectories.push(root); + const templatesDir = path.join(root, "cli", "templates"); + const outputDir = path.join(root, "output"); + await mkdir(templatesDir, { recursive: true }); + await writeFile(path.join(templatesDir, "container.js"), "container worker"); + await writeFile(path.join(templatesDir, "worker.js"), "standard worker"); + + copyPackageCliFiles(root, { outputDir } as never, "container"); + + expect(await readFile(path.join(outputDir, "worker.container.js"), "utf8")).toBe("container worker"); + }); +}); diff --git a/packages/cloudflare/src/cli/build/utils/copy-package-cli-files.ts b/packages/cloudflare/src/cli/build/utils/copy-package-cli-files.ts index 10b57618..e6dabdb6 100644 --- a/packages/cloudflare/src/cli/build/utils/copy-package-cli-files.ts +++ b/packages/cloudflare/src/cli/build/utils/copy-package-cli-files.ts @@ -5,12 +5,18 @@ import type { BuildOptions } from "@opennextjs/core/build/helper.js"; import { getOutputWorkerPath } from "../bundle-server.js"; +type WorkerTemplate = "container" | "worker"; + /** * Copies * - the template files present in the cloudflare adapter package to `.open-next/cloudflare-templates` * - `worker.js` to `.open-next/` */ -export function copyPackageCliFiles(packageDistDir: string, buildOpts: BuildOptions) { +export function copyPackageCliFiles( + packageDistDir: string, + buildOpts: BuildOptions, + workerTemplate: WorkerTemplate = "worker" +) { console.log("# copyPackageTemplateFiles"); const sourceDir = path.join(packageDistDir, "cli/templates"); @@ -19,5 +25,9 @@ export function copyPackageCliFiles(packageDistDir: string, buildOpts: BuildOpti fs.mkdirSync(destinationDir, { recursive: true }); fs.cpSync(sourceDir, destinationDir, { recursive: true }); - fs.copyFileSync(path.join(packageDistDir, "cli/templates/worker.js"), getOutputWorkerPath(buildOpts)); + const outputPath = + workerTemplate === "container" + ? path.join(buildOpts.outputDir, "worker.container.js") + : getOutputWorkerPath(buildOpts); + fs.copyFileSync(path.join(packageDistDir, `cli/templates/${workerTemplate}.js`), outputPath); } diff --git a/packages/cloudflare/src/cli/build/utils/ensure-cf-config.spec.ts b/packages/cloudflare/src/cli/build/utils/ensure-cf-config.spec.ts new file mode 100644 index 00000000..b90f5432 --- /dev/null +++ b/packages/cloudflare/src/cli/build/utils/ensure-cf-config.spec.ts @@ -0,0 +1,18 @@ +import { describe, expect, test } from "vitest"; + +import { defineCloudflareConfig } from "../../../api/config.js"; + +import { ensureCloudflareConfig } from "./ensure-cf-config.js"; + +describe("ensureCloudflareConfig", () => { + test("accepts the container topology", () => { + expect(() => ensureCloudflareConfig(defineCloudflareConfig({ container: true }))).not.toThrow(); + }); + + test("rejects binding-backed caches in container mode", () => { + const config = defineCloudflareConfig({ container: true }); + config.default.override!.incrementalCache = () => ({ name: "unsupported" }) as never; + + expect(() => ensureCloudflareConfig(config)).toThrow("Cloudflare Containers"); + }); +}); diff --git a/packages/cloudflare/src/cli/build/utils/ensure-cf-config.ts b/packages/cloudflare/src/cli/build/utils/ensure-cf-config.ts index fb461018..d3756aba 100644 --- a/packages/cloudflare/src/cli/build/utils/ensure-cf-config.ts +++ b/packages/cloudflare/src/cli/build/utils/ensure-cf-config.ts @@ -11,6 +11,7 @@ import type { OpenNextConfig } from "../../../api/config.js"; export function ensureCloudflareConfig(config: OpenNextConfig) { const mwIsMiddlewareExternal = config.middleware?.external === true; const mwConfig = mwIsMiddlewareExternal ? (config.middleware as ExternalMiddlewareConfig) : undefined; + const isContainer = config.cloudflare?.container === true; const requirements = { // Check for the default function @@ -27,6 +28,12 @@ export function ensureCloudflareConfig(config: OpenNextConfig) { config.default?.override?.queue === "dummy" || config.default?.override?.queue === "direct" || typeof config.default?.override?.queue === "function", + dftUseNodeWrapper: config.default?.override?.wrapper === "node", + dftUseNodeConverter: config.default?.override?.converter === "node", + dftGenerateDockerfile: config.default?.override?.generateDockerfile === true, + dftUseDummyCache: config.default?.override?.incrementalCache === "dummy", + dftUseDummyTagCache: config.default?.override?.tagCache === "dummy", + dftUseDummyQueue: config.default?.override?.queue === "dummy", // Check for the middleware function mwIsMiddlewareExternal, mwUseCloudflareWrapper: mwConfig?.override?.wrapper === "cloudflare-edge", @@ -39,8 +46,34 @@ export function ensureCloudflareConfig(config: OpenNextConfig) { logger.warn("The direct mode queue is not recommended for use in production."); } - if (Object.values(requirements).some((satisfied) => !satisfied)) { - const errorMessage = + const workerRequirements = [ + requirements.dftUseCloudflareWrapper, + requirements.dftUseEdgeConverter, + requirements.dftUseFetchProxy, + requirements.dftMaybeUseCache, + requirements.dftMaybeUseTagCache, + requirements.dftMaybeUseQueue, + ]; + const containerRequirements = [ + requirements.dftUseNodeWrapper, + requirements.dftUseNodeConverter, + requirements.dftGenerateDockerfile, + requirements.dftUseDummyCache, + requirements.dftUseDummyTagCache, + requirements.dftUseDummyQueue, + ]; + const commonRequirements = [ + requirements.mwIsMiddlewareExternal, + requirements.mwUseCloudflareWrapper, + requirements.mwUseEdgeConverter, + requirements.mwUseFetchProxy, + requirements.hasCryptoExternal, + ]; + + if ( + ![...(isContainer ? containerRequirements : workerRequirements), ...commonRequirements].every(Boolean) + ) { + const workerErrorMessage = "The `open-next.config.ts` should have a default export like this:\n\n" + `{ default: { @@ -66,6 +99,31 @@ export function ensureCloudflareConfig(config: OpenNextConfig) { }, }, }\n\n`.replace(/^ {8}/gm, ""); + const containerErrorMessage = + "The `open-next.config.ts` should use this configuration for Cloudflare Containers:\n\n" + + `{ + default: { + override: { + wrapper: "node", + converter: "node", + generateDockerfile: true, + incrementalCache: "dummy", + tagCache: "dummy", + queue: "dummy", + }, + }, + edgeExternals: ["node:crypto"], + cloudflare: { container: true }, + middleware: { + external: true, + override: { + wrapper: "cloudflare-edge", + converter: "edge", + proxyExternalRequest: "fetch", + }, + }, + }\n\n`.replace(/^ {8}/gm, ""); + const errorMessage = isContainer ? containerErrorMessage : workerErrorMessage; if (config.cloudflare?.dangerousDisableConfigValidation) { logger.warn(errorMessage); return; diff --git a/packages/cloudflare/src/cli/templates/container.ts b/packages/cloudflare/src/cli/templates/container.ts new file mode 100644 index 00000000..427555f8 --- /dev/null +++ b/packages/cloudflare/src/cli/templates/container.ts @@ -0,0 +1,53 @@ +// @ts-expect-error: This public package entrypoint is resolved by Wrangler from the application. +import { getOpenNextContainer, OpenNextContainer } from "@opennextjs/cloudflare/container"; + +//@ts-expect-error: Will be resolved by wrangler build +import { handleCdnCgiImageRequest, handleImageRequest } from "./cloudflare/images.js"; +//@ts-expect-error: Will be resolved by wrangler build +import { runWithCloudflareRequestContext } from "./cloudflare/init.js"; +//@ts-expect-error: Will be resolved by wrangler build +import { maybeGetSkewProtectionResponse } from "./cloudflare/skew-protection.js"; +// @ts-expect-error: Will be resolved by wrangler build +import { handler as middlewareHandler } from "./middleware/handler.mjs"; + +export { OpenNextContainer }; + +type ContainerEnv = CloudflareEnv & { + OPEN_NEXT_CONTAINER: DurableObjectNamespace; +}; + +export default { + async fetch(request, env, ctx) { + return runWithCloudflareRequestContext(request, env, ctx, async () => { + const response = maybeGetSkewProtectionResponse(request); + if (response) { + return response; + } + + const url = new URL(request.url); + if (url.pathname.startsWith("/cdn-cgi/image/")) { + return handleCdnCgiImageRequest(url, env); + } + + if ( + url.pathname === + `${globalThis.__NEXT_BASE_PATH__}/_next/image${globalThis.__TRAILING_SLASH__ ? "/" : ""}` + ) { + return await handleImageRequest(url, request.headers, env); + } + + const reqOrResp = await middlewareHandler(request, env, ctx); + if (reqOrResp instanceof Response) { + return reqOrResp; + } + + if ("initialResponse" in reqOrResp) { + return new Response("Partial prerendering is not supported in Cloudflare container mode.", { + status: 501, + }); + } + + return getOpenNextContainer(env.OPEN_NEXT_CONTAINER).fetch(reqOrResp); + }); + }, +} satisfies ExportedHandler; diff --git a/packages/core/src/build/createServerBundle.ts b/packages/core/src/build/createServerBundle.ts index d22ba6cb..acb1e13a 100644 --- a/packages/core/src/build/createServerBundle.ts +++ b/packages/core/src/build/createServerBundle.ts @@ -286,7 +286,7 @@ async function generateBundle( typeof shouldGenerateDocker === "string" ? shouldGenerateDocker : ` -FROM node:18-alpine +FROM node:24-alpine WORKDIR /app COPY . /app EXPOSE 3000 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cb571a6d..b8b65e23 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -902,6 +902,9 @@ importers: '@ast-grep/napi': specifier: 0.40.5 version: 0.40.5 + '@cloudflare/containers': + specifier: ^0.3.7 + version: 0.3.7 '@dotenvx/dotenvx': specifier: 'catalog:' version: 1.31.0 @@ -1899,6 +1902,9 @@ packages: '@changesets/write@0.4.0': resolution: {integrity: sha512-CdTLvIOPiCNuH71pyDu3rA+Q0n65cmAbXnwWH84rKGiFumFzkmHNT8KHTMEchcxN+Kl8I54xGUhJ7l3E7X396Q==} + '@cloudflare/containers@0.3.7': + resolution: {integrity: sha512-DM9dm3FnIBSyiSJ1FLavKwl/lk3oAmTaynCzZQ9pZR0ncRPquSxkxd8Nu2MFILxmDDsPkxKsSNEh9mHHMty4Fw==} + '@cloudflare/kv-asset-handler@0.4.2': resolution: {integrity: sha512-SIOD2DxrRRwQ+jgzlXCqoEFiKOFqaPjhnNTGKXSRLvp1HiOvapLaFG2kEr9dYQTYe8rKrd9uvDUzmAITeNyaHQ==} engines: {node: '>=18.0.0'} @@ -10324,6 +10330,8 @@ snapshots: human-id: 4.1.2 prettier: 2.8.8 + '@cloudflare/containers@0.3.7': {} + '@cloudflare/kv-asset-handler@0.4.2': {} '@cloudflare/unenv-preset@2.11.0(unenv@2.0.0-rc.24)(workerd@1.20260120.0)': From d145804da97ae224830116884f53a24039a4bad3 Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Wed, 15 Jul 2026 22:08:03 +0200 Subject: [PATCH 15/24] feat(cloudflare): implement container support with configuration updates and new compile logic --- .../e2e/app-router/wrangler.container.jsonc | 17 +------------- packages/cloudflare/src/cli/adapter.ts | 2 ++ .../cli/build/open-next/compileContainer.ts | 22 +++++++++++++++++++ .../utils/copy-package-cli-files.spec.ts | 2 +- .../cli/build/utils/copy-package-cli-files.ts | 11 +++++----- packages/cloudflare/src/cli/commands/build.ts | 8 ++++++- .../cloudflare/src/cli/templates/container.ts | 5 ++--- packages/core/src/build/adapter.spec.ts | 21 ++++++++++++++++++ packages/core/src/build/adapter.ts | 7 +++--- 9 files changed, 65 insertions(+), 30 deletions(-) create mode 100644 packages/cloudflare/src/cli/build/open-next/compileContainer.ts diff --git a/examples-cloudflare/e2e/app-router/wrangler.container.jsonc b/examples-cloudflare/e2e/app-router/wrangler.container.jsonc index dd38797f..5fb02a69 100644 --- a/examples-cloudflare/e2e/app-router/wrangler.container.jsonc +++ b/examples-cloudflare/e2e/app-router/wrangler.container.jsonc @@ -24,25 +24,10 @@ }, ], }, - "r2_buckets": [ - { - "binding": "NEXT_INC_CACHE_R2_BUCKET", - "bucket_name": "cache", - }, - ], - "services": [ - { - "binding": "WORKER_SELF_REFERENCE", - "service": "app-router", - }, - ], - "vars": { - "OPEN_NEXT_REQUEST_ID_HEADER": "true", - }, "migrations": [ { "tag": "v1", - "new_sqlite_classes": ["OpenNextContainer", "DOQueueHandler", "DOShardedTagCache", "BucketCachePurge"], + "new_sqlite_classes": ["OpenNextContainer"], }, ], } diff --git a/packages/cloudflare/src/cli/adapter.ts b/packages/cloudflare/src/cli/adapter.ts index 18550376..d6c23b50 100644 --- a/packages/cloudflare/src/cli/adapter.ts +++ b/packages/cloudflare/src/cli/adapter.ts @@ -19,6 +19,7 @@ import { compileEnvFiles } from "./build/open-next/compile-env-files.js"; 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 { compileContainer } from "./build/open-next/compileContainer.js"; import { compileDurableObjects } from "./build/open-next/compileDurableObjects.js"; import { inlineLoadManifest } from "./build/patches/plugins/load-manifest.js"; import { patchResRevalidate } from "./build/patches/plugins/res-revalidate.js"; @@ -83,6 +84,7 @@ export default buildAdapter((config: OpenNextConfig, buildOpts: BuildOptions) => afterServerBundle: async (buildOpts, _config) => { if (isContainer) { const packageDistDir = path.join(path.dirname(fileURLToPath(import.meta.url)), ".."); + compileContainer(buildOpts); copyPackageCliFiles(packageDistDir, buildOpts, "container"); return; } diff --git a/packages/cloudflare/src/cli/build/open-next/compileContainer.ts b/packages/cloudflare/src/cli/build/open-next/compileContainer.ts new file mode 100644 index 00000000..54f2e932 --- /dev/null +++ b/packages/cloudflare/src/cli/build/open-next/compileContainer.ts @@ -0,0 +1,22 @@ +import { createRequire } from "node:module"; +import path from "node:path"; + +import { type BuildOptions, esbuildSync } from "@opennextjs/core/build/helper.js"; + +/** Compiles the Container Durable Object used by the generated Worker entrypoint. */ +export function compileContainer(buildOpts: BuildOptions) { + const require = createRequire(import.meta.url); + const entryPoint = require.resolve("@opennextjs/cloudflare/container"); + + return esbuildSync( + { + entryPoints: [entryPoint], + bundle: true, + platform: "node", + format: "esm", + outfile: path.join(buildOpts.buildDir, "open-next-container.js"), + external: ["cloudflare:workers"], + }, + buildOpts + ); +} diff --git a/packages/cloudflare/src/cli/build/utils/copy-package-cli-files.spec.ts b/packages/cloudflare/src/cli/build/utils/copy-package-cli-files.spec.ts index ec095a9a..d44586e9 100644 --- a/packages/cloudflare/src/cli/build/utils/copy-package-cli-files.spec.ts +++ b/packages/cloudflare/src/cli/build/utils/copy-package-cli-files.spec.ts @@ -24,6 +24,6 @@ describe("copyPackageCliFiles", () => { copyPackageCliFiles(root, { outputDir } as never, "container"); - expect(await readFile(path.join(outputDir, "worker.container.js"), "utf8")).toBe("container worker"); + expect(await readFile(path.join(outputDir, "worker.js"), "utf8")).toBe("container worker"); }); }); diff --git a/packages/cloudflare/src/cli/build/utils/copy-package-cli-files.ts b/packages/cloudflare/src/cli/build/utils/copy-package-cli-files.ts index e6dabdb6..eb1bd865 100644 --- a/packages/cloudflare/src/cli/build/utils/copy-package-cli-files.ts +++ b/packages/cloudflare/src/cli/build/utils/copy-package-cli-files.ts @@ -10,7 +10,7 @@ type WorkerTemplate = "container" | "worker"; /** * Copies * - the template files present in the cloudflare adapter package to `.open-next/cloudflare-templates` - * - `worker.js` to `.open-next/` + * - the selected Worker template as `.open-next/worker.js` */ export function copyPackageCliFiles( packageDistDir: string, @@ -25,9 +25,8 @@ export function copyPackageCliFiles( fs.mkdirSync(destinationDir, { recursive: true }); fs.cpSync(sourceDir, destinationDir, { recursive: true }); - const outputPath = - workerTemplate === "container" - ? path.join(buildOpts.outputDir, "worker.container.js") - : getOutputWorkerPath(buildOpts); - fs.copyFileSync(path.join(packageDistDir, `cli/templates/${workerTemplate}.js`), outputPath); + fs.copyFileSync( + path.join(packageDistDir, `cli/templates/${workerTemplate}.js`), + getOutputWorkerPath(buildOpts) + ); } diff --git a/packages/cloudflare/src/cli/commands/build.ts b/packages/cloudflare/src/cli/commands/build.ts index c7b76fc5..3b2a4e84 100644 --- a/packages/cloudflare/src/cli/commands/build.ts +++ b/packages/cloudflare/src/cli/commands/build.ts @@ -1,4 +1,5 @@ import { createRequire } from "node:module"; +import path from "node:path"; import logger from "@opennextjs/core/logger.js"; import type yargs from "yargs"; @@ -39,6 +40,7 @@ export async function buildCommand( const require = createRequire(import.meta.url); process.env.NEXT_ADAPTER_PATH = require.resolve("../adapter.js"); + process.env.OPEN_NEXT_CONFIG_PATH = path.resolve(args.openNextConfigPath ?? "open-next.config.ts"); // Ask whether a `wrangler.jsonc` should be created when no config file exists. // Note: We don't ask when a custom config file is specified via `--config` @@ -56,7 +58,11 @@ export async function buildCommand( } } - await buildImpl(options, projectOpts); + try { + await buildImpl(options, projectOpts); + } finally { + delete process.env.OPEN_NEXT_CONFIG_PATH; + } } /** diff --git a/packages/cloudflare/src/cli/templates/container.ts b/packages/cloudflare/src/cli/templates/container.ts index 427555f8..ede5d438 100644 --- a/packages/cloudflare/src/cli/templates/container.ts +++ b/packages/cloudflare/src/cli/templates/container.ts @@ -1,6 +1,5 @@ -// @ts-expect-error: This public package entrypoint is resolved by Wrangler from the application. -import { getOpenNextContainer, OpenNextContainer } from "@opennextjs/cloudflare/container"; - +// @ts-expect-error: Generated by the Cloudflare adapter before this Worker is bundled by Wrangler. +import { getOpenNextContainer, OpenNextContainer } from "./.build/open-next-container.js"; //@ts-expect-error: Will be resolved by wrangler build import { handleCdnCgiImageRequest, handleImageRequest } from "./cloudflare/images.js"; //@ts-expect-error: Will be resolved by wrangler build diff --git a/packages/core/src/build/adapter.spec.ts b/packages/core/src/build/adapter.spec.ts index bda407af..00c9ca6a 100644 --- a/packages/core/src/build/adapter.spec.ts +++ b/packages/core/src/build/adapter.spec.ts @@ -196,6 +196,27 @@ describe("buildAdapter", () => { expect(mockCallback).toHaveBeenCalledWith(expect.objectContaining({ default: {} }), expect.any(Object)); }); + test("modifyConfig uses the config path selected by the build command", async () => { + const previousConfigPath = process.env.OPEN_NEXT_CONFIG_PATH; + process.env.OPEN_NEXT_CONFIG_PATH = "open-next.container.config.ts"; + + try { + const adapter = buildAdapter(() => ({})); + const nextConfig = { experimental: {}, images: {} } as BuildCompleteContext["config"]; + await adapter.modifyConfig(nextConfig, { phase: "production" }); + + expect(compileOpenNextConfig).toHaveBeenCalledWith("open-next.container.config.ts", { + compileEdge: true, + }); + } finally { + if (previousConfigPath === undefined) { + delete process.env.OPEN_NEXT_CONFIG_PATH; + } else { + process.env.OPEN_NEXT_CONFIG_PATH = previousConfigPath; + } + } + }); + test("modifyConfig returns nextConfig with cacheHandler, cacheHandlers, cacheMaxMemorySize, and trustHostHeader", async () => { const adapter = buildAdapter(() => ({})); diff --git a/packages/core/src/build/adapter.ts b/packages/core/src/build/adapter.ts index 9c3488f7..2af941e0 100644 --- a/packages/core/src/build/adapter.ts +++ b/packages/core/src/build/adapter.ts @@ -104,16 +104,17 @@ export function buildAdapter( name: "OpenNext", async modifyConfig(nextConfig, { phase: _phase }) { + const openNextConfigPath = process.env.OPEN_NEXT_CONFIG_PATH ?? "open-next.config.ts"; // 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 }); + result = await compileOpenNextConfig(openNextConfigPath, { compileEdge: true }); } catch (error) { console.warn( - "Failed to compile open-next.config.ts for edge runtime, falling back to node-only compilation.", + `Failed to compile ${openNextConfigPath} for edge runtime, falling back to node-only compilation.`, error instanceof Error ? error.message : error ); - result = await compileOpenNextConfig("open-next.config.ts", { compileEdge: false }); + result = await compileOpenNextConfig(openNextConfigPath, { compileEdge: false }); } config = result.config; From 4d813aa2e1dce4fc62d348cbf8c0054b0c4d54d1 Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Wed, 15 Jul 2026 22:17:15 +0200 Subject: [PATCH 16/24] feat(cloudflare): enhance container support with request normalization and updated banner logic --- packages/cloudflare/src/api/container.ts | 19 +++++++++++++++++++ packages/cloudflare/src/cli/adapter.ts | 22 ++++++++++++++++++---- 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/packages/cloudflare/src/api/container.ts b/packages/cloudflare/src/api/container.ts index c61d4b90..7aa79048 100644 --- a/packages/cloudflare/src/api/container.ts +++ b/packages/cloudflare/src/api/container.ts @@ -11,6 +11,25 @@ export const OPEN_NEXT_CONTAINER_NAME = "default"; */ export class OpenNextContainer extends Container { override defaultPort = 3000; + + /** + * Normalize the Durable Object request before forwarding it to the Container. + * + * In local workerd, the request received by a Durable Object can originate + * from another runtime realm. The Container base class checks it with + * `instanceof Request`, which then fails and coerces it to "[object Request]". + */ + override fetch(request: Request): Promise { + return this.containerFetch( + request.url, + { + method: request.method, + headers: request.headers, + body: request.method === "GET" || request.method === "HEAD" ? undefined : request.body, + }, + this.defaultPort + ); + } } export function getOpenNextContainer(containerNamespace: DurableObjectNamespace) { diff --git a/packages/cloudflare/src/cli/adapter.ts b/packages/cloudflare/src/cli/adapter.ts index d6c23b50..7eef247b 100644 --- a/packages/cloudflare/src/cli/adapter.ts +++ b/packages/cloudflare/src/cli/adapter.ts @@ -54,10 +54,24 @@ export default buildAdapter((config: OpenNextConfig, buildOpts: BuildOptions) => serverBundle: { useEdgeConfig: !isContainer, externals: ["./middleware.mjs"], - banner: (name: string) => [ - `globalThis.monorepoPackagePath = "${normalizePath(packagePath)}";`, - name === "default" ? "" : `globalThis.fnName = "${name}";`, - ], + banner: (name: string) => { + const cloudflareBanner = [`globalThis.monorepoPackagePath = "${normalizePath(packagePath)}";`]; + + if (isContainer) { + cloudflareBanner.push( + "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);" + ); + } + + cloudflareBanner.push(name === "default" ? "" : `globalThis.fnName = "${name}";`); + return cloudflareBanner; + }, additionalPlugins: (updater: ContentUpdater, outputs: NextAdapterOutputs) => [ inlineRouteHandler(updater, outputs, packagePath), inlineLoadManifest(updater, buildOpts), From 52e856987ace382fa54555b509e55fc9e17b6b9b Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Wed, 15 Jul 2026 22:17:23 +0200 Subject: [PATCH 17/24] chore: update wrangler dependency to version 4.110.0 --- pnpm-lock.yaml | 439 ++++++++++++++++++++++++++++++++++++++++++-- pnpm-workspace.yaml | 2 +- 2 files changed, 426 insertions(+), 15 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b8b65e23..c7ae39e5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -80,8 +80,8 @@ catalogs: specifier: ^2.1.1 version: 2.1.3 wrangler: - specifier: ^4.59.2 - version: 4.60.0 + specifier: ^4.110.0 + version: 4.111.0 yargs: specifier: ^18.0.0 version: 18.0.0 @@ -224,7 +224,7 @@ importers: version: 6.0.3 wrangler: specifier: 'catalog:' - version: 4.60.0(@cloudflare/workers-types@4.20260123.0) + version: 4.111.0(@cloudflare/workers-types@4.20260123.0) examples-cloudflare/e2e/app-router: dependencies: @@ -270,7 +270,7 @@ importers: version: 6.0.3 wrangler: specifier: 'catalog:' - version: 4.60.0(@cloudflare/workers-types@4.20260123.0) + version: 4.111.0(@cloudflare/workers-types@4.20260123.0) examples-cloudflare/e2e/experimental: dependencies: @@ -304,7 +304,7 @@ importers: version: 6.0.3 wrangler: specifier: 'catalog:' - version: 4.60.0(@cloudflare/workers-types@4.20260123.0) + version: 4.111.0(@cloudflare/workers-types@4.20260123.0) examples-cloudflare/e2e/pages-router: dependencies: @@ -350,7 +350,7 @@ importers: version: 6.0.3 wrangler: specifier: 'catalog:' - version: 4.60.0(@cloudflare/workers-types@4.20260123.0) + version: 4.111.0(@cloudflare/workers-types@4.20260123.0) examples-cloudflare/e2e/shared: dependencies: @@ -403,7 +403,7 @@ importers: version: 6.0.3 wrangler: specifier: 'catalog:' - version: 4.60.0(@cloudflare/workers-types@4.20260123.0) + version: 4.111.0(@cloudflare/workers-types@4.20260123.0) examples-cloudflare/overrides/kv-tag-next: dependencies: @@ -437,7 +437,7 @@ importers: version: 6.0.3 wrangler: specifier: 'catalog:' - version: 4.60.0(@cloudflare/workers-types@4.20260123.0) + version: 4.111.0(@cloudflare/workers-types@4.20260123.0) examples-cloudflare/overrides/memory-queue: dependencies: @@ -471,7 +471,7 @@ importers: version: 6.0.3 wrangler: specifier: 'catalog:' - version: 4.60.0(@cloudflare/workers-types@4.20260123.0) + version: 4.111.0(@cloudflare/workers-types@4.20260123.0) examples-cloudflare/overrides/r2-incremental-cache: dependencies: @@ -505,7 +505,7 @@ importers: version: 6.0.3 wrangler: specifier: 'catalog:' - version: 4.60.0(@cloudflare/workers-types@4.20260123.0) + version: 4.111.0(@cloudflare/workers-types@4.20260123.0) examples-cloudflare/overrides/static-assets-incremental-cache: dependencies: @@ -539,7 +539,7 @@ importers: version: 6.0.3 wrangler: specifier: 'catalog:' - version: 4.60.0(@cloudflare/workers-types@4.20260123.0) + version: 4.111.0(@cloudflare/workers-types@4.20260123.0) examples-cloudflare/playground16: dependencies: @@ -576,7 +576,7 @@ importers: version: 4.1.18 wrangler: specifier: 'catalog:' - version: 4.60.0(@cloudflare/workers-types@4.20260123.0) + version: 4.111.0(@cloudflare/workers-types@4.20260123.0) examples-cloudflare/prisma: dependencies: @@ -616,7 +616,7 @@ importers: version: 6.0.3 wrangler: specifier: 'catalog:' - version: 4.60.0(@cloudflare/workers-types@4.20260123.0) + version: 4.111.0(@cloudflare/workers-types@4.20260123.0) examples/app-pages-router: dependencies: @@ -928,7 +928,7 @@ importers: version: 0.8.6 wrangler: specifier: 'catalog:' - version: 4.60.0(@cloudflare/workers-types@4.20260123.0) + version: 4.111.0(@cloudflare/workers-types@4.20260123.0) yargs: specifier: 'catalog:' version: 18.0.0 @@ -1909,6 +1909,10 @@ packages: resolution: {integrity: sha512-SIOD2DxrRRwQ+jgzlXCqoEFiKOFqaPjhnNTGKXSRLvp1HiOvapLaFG2kEr9dYQTYe8rKrd9uvDUzmAITeNyaHQ==} engines: {node: '>=18.0.0'} + '@cloudflare/kv-asset-handler@0.5.0': + resolution: {integrity: sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==} + engines: {node: '>=22.0.0'} + '@cloudflare/unenv-preset@2.11.0': resolution: {integrity: sha512-z3hxFajL765VniNPGV0JRStZolNz63gU3B3AktwoGdDlnQvz5nP+Ah4RL04PONlZQjwmDdGHowEStJ94+RsaJg==} peerDependencies: @@ -1918,36 +1922,75 @@ packages: workerd: optional: true + '@cloudflare/unenv-preset@2.16.1': + resolution: {integrity: sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==} + peerDependencies: + unenv: 2.0.0-rc.24 + workerd: '>1.20260305.0 <2.0.0-0' + peerDependenciesMeta: + workerd: + optional: true + '@cloudflare/workerd-darwin-64@1.20260120.0': resolution: {integrity: sha512-JLHx3p5dpwz4wjVSis45YNReftttnI3ndhdMh5BUbbpdreN/g0jgxNt5Qp9tDFqEKl++N63qv+hxJiIIvSLR+Q==} engines: {node: '>=16'} cpu: [x64] os: [darwin] + '@cloudflare/workerd-darwin-64@1.20260710.1': + resolution: {integrity: sha512-OqJl2eWF5+y9jarMm3YqqCTUe7Hd4ihogX5jyRU8iaAgOVyDr/Bk6aXpPCVUi1/MHzO93a18R/TmSTtzmB0sQw==} + engines: {node: '>=16'} + cpu: [x64] + os: [darwin] + '@cloudflare/workerd-darwin-arm64@1.20260120.0': resolution: {integrity: sha512-1Md2tCRhZjwajsZNOiBeOVGiS3zbpLPzUDjHr4+XGTXWOA6FzzwScJwQZLa0Doc28Cp4Nr1n7xGL0Dwiz1XuOA==} engines: {node: '>=16'} cpu: [arm64] os: [darwin] + '@cloudflare/workerd-darwin-arm64@1.20260710.1': + resolution: {integrity: sha512-MYBqWgUblO+VlGvO73zYsH3hB9tdRj+yLyt5IHDFWryipb2l1efmNiWtAOkIhSRfypqLYGFrfpaDm2Hg00XVKw==} + engines: {node: '>=16'} + cpu: [arm64] + os: [darwin] + '@cloudflare/workerd-linux-64@1.20260120.0': resolution: {integrity: sha512-O0mIfJfvU7F8N5siCoRDaVDuI12wkz2xlG4zK6/Ct7U9c9FiE0ViXNFWXFQm5PPj+qbkNRyhjUwhP+GCKTk5EQ==} engines: {node: '>=16'} cpu: [x64] os: [linux] + '@cloudflare/workerd-linux-64@1.20260710.1': + resolution: {integrity: sha512-lVWUgqI8qrkqvaCBGElu1kdaUFdAvaS2RD8K4qkCFP9hI3f5TCXumEs5qWSeZkvKum0+X/uJZ5hBFWsYI5SmoQ==} + engines: {node: '>=16'} + cpu: [x64] + os: [linux] + '@cloudflare/workerd-linux-arm64@1.20260120.0': resolution: {integrity: sha512-aRHO/7bjxVpjZEmVVcpmhbzpN6ITbFCxuLLZSW0H9O0C0w40cDCClWSi19T87Ax/PQcYjFNT22pTewKsupkckA==} engines: {node: '>=16'} cpu: [arm64] os: [linux] + '@cloudflare/workerd-linux-arm64@1.20260710.1': + resolution: {integrity: sha512-kDwDPItBjAI4JL0df9Fma2N+Qggbm77IB/DnroAkEGQ79fpR80sYMyuB/ZQKyjEk9f48Ocq7HCCLq59qVSyNqA==} + engines: {node: '>=16'} + cpu: [arm64] + os: [linux] + '@cloudflare/workerd-windows-64@1.20260120.0': resolution: {integrity: sha512-ASZIz1E8sqZQqQCgcfY1PJbBpUDrxPt8NZ+lqNil0qxnO4qX38hbCsdDF2/TDAuq0Txh7nu8ztgTelfNDlb4EA==} engines: {node: '>=16'} cpu: [x64] os: [win32] + '@cloudflare/workerd-windows-64@1.20260710.1': + resolution: {integrity: sha512-GcLHy1oN1dfK6g1Z7UDV9f5xMGyTfPwcjWQ0sfWKH31IsoEVCRapnj3IC0PoIrDbnoo6irGPP0CwVs3WzdTajw==} + engines: {node: '>=16'} + cpu: [x64] + os: [win32] + '@cloudflare/workers-types@4.20250214.0': resolution: {integrity: sha512-+M8oOFVbyXT5GeJrYLWMUGyPf5wGB4+k59PPqdedtOig7NjZ5r4S79wMdaZ/EV5IV8JPtZBSNjTKpDnNmfxjaQ==} @@ -2009,6 +2052,12 @@ packages: cpu: [ppc64] os: [aix] + '@esbuild/aix-ppc64@0.28.1': + resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + '@esbuild/android-arm64@0.18.13': resolution: {integrity: sha512-j7NhycJUoUAG5kAzGf4fPWfd17N6SM3o1X6MlXVqfHvs2buFraCJzos9vbeWjLxOyBKHyPOnuCuipbhvbYtTAg==} engines: {node: '>=12'} @@ -2033,6 +2082,12 @@ packages: cpu: [arm64] os: [android] + '@esbuild/android-arm64@0.28.1': + resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm@0.18.13': resolution: {integrity: sha512-KwqFhxRFMKZINHzCqf8eKxE0XqWlAVPRxwy6rc7CbVFxzUWB2sA/s3hbMZeemPdhN3fKBkqOaFhTbS8xJXYIWQ==} engines: {node: '>=12'} @@ -2057,6 +2112,12 @@ packages: cpu: [arm] os: [android] + '@esbuild/android-arm@0.28.1': + resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + '@esbuild/android-x64@0.18.13': resolution: {integrity: sha512-M2eZkRxR6WnWfVELHmv6MUoHbOqnzoTVSIxgtsyhm/NsgmL+uTmag/VVzdXvmahak1I6sOb1K/2movco5ikDJg==} engines: {node: '>=12'} @@ -2081,6 +2142,12 @@ packages: cpu: [x64] os: [android] + '@esbuild/android-x64@0.28.1': + resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + '@esbuild/darwin-arm64@0.18.13': resolution: {integrity: sha512-f5goG30YgR1GU+fxtaBRdSW3SBG9pZW834Mmhxa6terzcboz7P2R0k4lDxlkP7NYRIIdBbWp+VgwQbmMH4yV7w==} engines: {node: '>=12'} @@ -2105,6 +2172,12 @@ packages: cpu: [arm64] os: [darwin] + '@esbuild/darwin-arm64@0.28.1': + resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-x64@0.18.13': resolution: {integrity: sha512-RIrxoKH5Eo+yE5BtaAIMZaiKutPhZjw+j0OCh8WdvKEKJQteacq0myZvBDLU+hOzQOZWJeDnuQ2xgSScKf1Ovw==} engines: {node: '>=12'} @@ -2129,6 +2202,12 @@ packages: cpu: [x64] os: [darwin] + '@esbuild/darwin-x64@0.28.1': + resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + '@esbuild/freebsd-arm64@0.18.13': resolution: {integrity: sha512-AfRPhHWmj9jGyLgW/2FkYERKmYR+IjYxf2rtSLmhOrPGFh0KCETFzSjx/JX/HJnvIqHt/DRQD/KAaVsUKoI3Xg==} engines: {node: '>=12'} @@ -2153,6 +2232,12 @@ packages: cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-arm64@0.28.1': + resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-x64@0.18.13': resolution: {integrity: sha512-pGzWWZJBInhIgdEwzn8VHUBang8UvFKsvjDkeJ2oyY5gZtAM6BaxK0QLCuZY+qoj/nx/lIaItH425rm/hloETA==} engines: {node: '>=12'} @@ -2177,6 +2262,12 @@ packages: cpu: [x64] os: [freebsd] + '@esbuild/freebsd-x64@0.28.1': + resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + '@esbuild/linux-arm64@0.18.13': resolution: {integrity: sha512-hCzZbVJEHV7QM77fHPv2qgBcWxgglGFGCxk6KfQx6PsVIdi1u09X7IvgE9QKqm38OpkzaAkPnnPqwRsltvLkIQ==} engines: {node: '>=12'} @@ -2201,6 +2292,12 @@ packages: cpu: [arm64] os: [linux] + '@esbuild/linux-arm64@0.28.1': + resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm@0.18.13': resolution: {integrity: sha512-4iMxLRMCxGyk7lEvkkvrxw4aJeC93YIIrfbBlUJ062kilUUnAiMb81eEkVvCVoh3ON283ans7+OQkuy1uHW+Hw==} engines: {node: '>=12'} @@ -2225,6 +2322,12 @@ packages: cpu: [arm] os: [linux] + '@esbuild/linux-arm@0.28.1': + resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + '@esbuild/linux-ia32@0.18.13': resolution: {integrity: sha512-I3OKGbynl3AAIO6onXNrup/ttToE6Rv2XYfFgLK/wnr2J+1g+7k4asLrE+n7VMhaqX+BUnyWkCu27rl+62Adug==} engines: {node: '>=12'} @@ -2249,6 +2352,12 @@ packages: cpu: [ia32] os: [linux] + '@esbuild/linux-ia32@0.28.1': + resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-loong64@0.18.13': resolution: {integrity: sha512-8pcKDApAsKc6WW51ZEVidSGwGbebYw2qKnO1VyD8xd6JN0RN6EUXfhXmDk9Vc4/U3Y4AoFTexQewQDJGsBXBpg==} engines: {node: '>=12'} @@ -2273,6 +2382,12 @@ packages: cpu: [loong64] os: [linux] + '@esbuild/linux-loong64@0.28.1': + resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-mips64el@0.18.13': resolution: {integrity: sha512-6GU+J1PLiVqWx8yoCK4Z0GnfKyCGIH5L2KQipxOtbNPBs+qNDcMJr9euxnyJ6FkRPyMwaSkjejzPSISD9hb+gg==} engines: {node: '>=12'} @@ -2297,6 +2412,12 @@ packages: cpu: [mips64el] os: [linux] + '@esbuild/linux-mips64el@0.28.1': + resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-ppc64@0.18.13': resolution: {integrity: sha512-pfn/OGZ8tyR8YCV7MlLl5hAit2cmS+j/ZZg9DdH0uxdCoJpV7+5DbuXrR+es4ayRVKIcfS9TTMCs60vqQDmh+w==} engines: {node: '>=12'} @@ -2321,6 +2442,12 @@ packages: cpu: [ppc64] os: [linux] + '@esbuild/linux-ppc64@0.28.1': + resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-riscv64@0.18.13': resolution: {integrity: sha512-aIbhU3LPg0lOSCfVeGHbmGYIqOtW6+yzO+Nfv57YblEK01oj0mFMtvDJlOaeAZ6z0FZ9D13oahi5aIl9JFphGg==} engines: {node: '>=12'} @@ -2345,6 +2472,12 @@ packages: cpu: [riscv64] os: [linux] + '@esbuild/linux-riscv64@0.28.1': + resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-s390x@0.18.13': resolution: {integrity: sha512-Pct1QwF2sp+5LVi4Iu5Y+6JsGaV2Z2vm4O9Dd7XZ5tKYxEHjFtb140fiMcl5HM1iuv6xXO8O1Vrb1iJxHlv8UA==} engines: {node: '>=12'} @@ -2369,6 +2502,12 @@ packages: cpu: [s390x] os: [linux] + '@esbuild/linux-s390x@0.28.1': + resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-x64@0.18.13': resolution: {integrity: sha512-zTrIP0KzYP7O0+3ZnmzvUKgGtUvf4+piY8PIO3V8/GfmVd3ZyHJGz7Ht0np3P1wz+I8qJ4rjwJKqqEAbIEPngA==} engines: {node: '>=12'} @@ -2393,6 +2532,12 @@ packages: cpu: [x64] os: [linux] + '@esbuild/linux-x64@0.28.1': + resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + '@esbuild/netbsd-arm64@0.25.4': resolution: {integrity: sha512-vUnkBYxZW4hL/ie91hSqaSNjulOnYXE1VSLusnvHg2u3jewJBz3YzB9+oCw8DABeVqZGg94t9tyZFoHma8gWZQ==} engines: {node: '>=18'} @@ -2405,6 +2550,12 @@ packages: cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-arm64@0.28.1': + resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + '@esbuild/netbsd-x64@0.18.13': resolution: {integrity: sha512-I6zs10TZeaHDYoGxENuksxE1sxqZpCp+agYeW039yqFwh3MgVvdmXL5NMveImOC6AtpLvE4xG5ujVic4NWFIDQ==} engines: {node: '>=12'} @@ -2429,6 +2580,12 @@ packages: cpu: [x64] os: [netbsd] + '@esbuild/netbsd-x64@0.28.1': + resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + '@esbuild/openbsd-arm64@0.25.4': resolution: {integrity: sha512-Ct2WcFEANlFDtp1nVAXSNBPDxyU+j7+tId//iHXU2f/lN5AmO4zLyhDcpR5Cz1r08mVxzt3Jpyt4PmXQ1O6+7A==} engines: {node: '>=18'} @@ -2441,6 +2598,12 @@ packages: cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-arm64@0.28.1': + resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-x64@0.18.13': resolution: {integrity: sha512-W5C5nczhrt1y1xPG5bV+0M12p2vetOGlvs43LH8SopQ3z2AseIROu09VgRqydx5qFN7y9qCbpgHLx0kb0TcW7g==} engines: {node: '>=12'} @@ -2465,12 +2628,24 @@ packages: cpu: [x64] os: [openbsd] + '@esbuild/openbsd-x64@0.28.1': + resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + '@esbuild/openharmony-arm64@0.27.0': resolution: {integrity: sha512-nyvsBccxNAsNYz2jVFYwEGuRRomqZ149A39SHWk4hV0jWxKM0hjBPm3AmdxcbHiFLbBSwG6SbpIcUbXjgyECfA==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] + '@esbuild/openharmony-arm64@0.28.1': + resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + '@esbuild/sunos-x64@0.18.13': resolution: {integrity: sha512-X/xzuw4Hzpo/yq3YsfBbIsipNgmsm8mE/QeWbdGdTTeZ77fjxI2K0KP3AlhZ6gU3zKTw1bKoZTuKLnqcJ537qw==} engines: {node: '>=12'} @@ -2495,6 +2670,12 @@ packages: cpu: [x64] os: [sunos] + '@esbuild/sunos-x64@0.28.1': + resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + '@esbuild/win32-arm64@0.18.13': resolution: {integrity: sha512-4CGYdRQT/ILd+yLLE5i4VApMPfGE0RPc/wFQhlluDQCK09+b4JDbxzzjpgQqTPrdnP7r5KUtGVGZYclYiPuHrw==} engines: {node: '>=12'} @@ -2519,6 +2700,12 @@ packages: cpu: [arm64] os: [win32] + '@esbuild/win32-arm64@0.28.1': + resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-ia32@0.18.13': resolution: {integrity: sha512-D+wKZaRhQI+MUGMH+DbEr4owC2D7XnF+uyGiZk38QbgzLcofFqIOwFs7ELmIeU45CQgfHNy9Q+LKW3cE8g37Kg==} engines: {node: '>=12'} @@ -2543,6 +2730,12 @@ packages: cpu: [ia32] os: [win32] + '@esbuild/win32-ia32@0.28.1': + resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-x64@0.18.13': resolution: {integrity: sha512-iVl6lehAfJS+VmpF3exKpNQ8b0eucf5VWfzR8S7xFve64NBNz2jPUgx1X93/kfnkfgP737O+i1k54SVQS7uVZA==} engines: {node: '>=12'} @@ -2567,6 +2760,12 @@ packages: cpu: [x64] os: [win32] + '@esbuild/win32-x64@0.28.1': + resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@fastify/busboy@2.1.1': resolution: {integrity: sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==} engines: {node: '>=14'} @@ -5228,6 +5427,11 @@ packages: engines: {node: '>=18'} hasBin: true + esbuild@0.28.1: + resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} + engines: {node: '>=18'} + hasBin: true + escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} @@ -6185,6 +6389,11 @@ packages: engines: {node: '>=18.0.0'} hasBin: true + miniflare@4.20260710.0: + resolution: {integrity: sha512-x1LLRkU6o1p7hiKrB0TRnL0MJn6xFOT+/vrlEQINz5cRDKLP8ru4hBqWTIvXAetzr1acKAnmAaG84pQ4W/K14g==} + engines: {node: '>=22.0.0'} + hasBin: true + minimalistic-assert@1.0.1: resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==} @@ -7503,6 +7712,10 @@ packages: resolution: {integrity: sha512-y+8YjDFzWdQlSE9N5nzKMT3g4a5UBX1HKowfdXh0uvAnTaqqwqB92Jt4UXBAeKekDs5IaDKyJFR4X1gYVCgXcw==} engines: {node: '>=20.18.1'} + undici@7.28.0: + resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==} + engines: {node: '>=20.18.1'} + unenv@2.0.0-rc.24: resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==} @@ -7736,6 +7949,21 @@ packages: engines: {node: '>=16'} hasBin: true + workerd@1.20260710.1: + resolution: {integrity: sha512-U2sBPPrb9U97sBKnnMN6Kv8p65903P35nwMkPE9vSH/bRuRqkZ3a1EjUw3jV28RhiyXpkLF77Evzw8XimFxyTw==} + engines: {node: '>=16'} + hasBin: true + + wrangler@4.111.0: + resolution: {integrity: sha512-bffpI9EyrnpKkF/1S+RaIv8oRD93GtbsA7TlfWwOsGJGB7VO3jVbdGzpC9TU7Bqom3z7jUxcte4Z9MPhaQ4HoQ==} + engines: {node: '>=22.0.0'} + hasBin: true + peerDependencies: + '@cloudflare/workers-types': ^5.20260710.1 + peerDependenciesMeta: + '@cloudflare/workers-types': + optional: true + wrangler@4.60.0: resolution: {integrity: sha512-n4kibm/xY0Qd5G2K/CbAQeVeOIlwPNVglmFjlDRCCYk3hZh8IggO/rg8AXt/vByK2Sxsugl5Z7yvgWxrUbmS6g==} engines: {node: '>=20.0.0'} @@ -7785,6 +8013,18 @@ packages: utf-8-validate: optional: true + ws@8.21.0: + resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + xml-name-validator@4.0.0: resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==} engines: {node: '>=12'} @@ -10334,27 +10574,50 @@ snapshots: '@cloudflare/kv-asset-handler@0.4.2': {} + '@cloudflare/kv-asset-handler@0.5.0': {} + '@cloudflare/unenv-preset@2.11.0(unenv@2.0.0-rc.24)(workerd@1.20260120.0)': dependencies: unenv: 2.0.0-rc.24 optionalDependencies: workerd: 1.20260120.0 + '@cloudflare/unenv-preset@2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260710.1)': + dependencies: + unenv: 2.0.0-rc.24 + optionalDependencies: + workerd: 1.20260710.1 + '@cloudflare/workerd-darwin-64@1.20260120.0': optional: true + '@cloudflare/workerd-darwin-64@1.20260710.1': + optional: true + '@cloudflare/workerd-darwin-arm64@1.20260120.0': optional: true + '@cloudflare/workerd-darwin-arm64@1.20260710.1': + optional: true + '@cloudflare/workerd-linux-64@1.20260120.0': optional: true + '@cloudflare/workerd-linux-64@1.20260710.1': + optional: true + '@cloudflare/workerd-linux-arm64@1.20260120.0': optional: true + '@cloudflare/workerd-linux-arm64@1.20260710.1': + optional: true + '@cloudflare/workerd-windows-64@1.20260120.0': optional: true + '@cloudflare/workerd-windows-64@1.20260710.1': + optional: true + '@cloudflare/workers-types@4.20250214.0': {} '@cloudflare/workers-types@4.20260123.0': {} @@ -10418,6 +10681,9 @@ snapshots: '@esbuild/aix-ppc64@0.27.0': optional: true + '@esbuild/aix-ppc64@0.28.1': + optional: true + '@esbuild/android-arm64@0.18.13': optional: true @@ -10430,6 +10696,9 @@ snapshots: '@esbuild/android-arm64@0.27.0': optional: true + '@esbuild/android-arm64@0.28.1': + optional: true + '@esbuild/android-arm@0.18.13': optional: true @@ -10442,6 +10711,9 @@ snapshots: '@esbuild/android-arm@0.27.0': optional: true + '@esbuild/android-arm@0.28.1': + optional: true + '@esbuild/android-x64@0.18.13': optional: true @@ -10454,6 +10726,9 @@ snapshots: '@esbuild/android-x64@0.27.0': optional: true + '@esbuild/android-x64@0.28.1': + optional: true + '@esbuild/darwin-arm64@0.18.13': optional: true @@ -10466,6 +10741,9 @@ snapshots: '@esbuild/darwin-arm64@0.27.0': optional: true + '@esbuild/darwin-arm64@0.28.1': + optional: true + '@esbuild/darwin-x64@0.18.13': optional: true @@ -10478,6 +10756,9 @@ snapshots: '@esbuild/darwin-x64@0.27.0': optional: true + '@esbuild/darwin-x64@0.28.1': + optional: true + '@esbuild/freebsd-arm64@0.18.13': optional: true @@ -10490,6 +10771,9 @@ snapshots: '@esbuild/freebsd-arm64@0.27.0': optional: true + '@esbuild/freebsd-arm64@0.28.1': + optional: true + '@esbuild/freebsd-x64@0.18.13': optional: true @@ -10502,6 +10786,9 @@ snapshots: '@esbuild/freebsd-x64@0.27.0': optional: true + '@esbuild/freebsd-x64@0.28.1': + optional: true + '@esbuild/linux-arm64@0.18.13': optional: true @@ -10514,6 +10801,9 @@ snapshots: '@esbuild/linux-arm64@0.27.0': optional: true + '@esbuild/linux-arm64@0.28.1': + optional: true + '@esbuild/linux-arm@0.18.13': optional: true @@ -10526,6 +10816,9 @@ snapshots: '@esbuild/linux-arm@0.27.0': optional: true + '@esbuild/linux-arm@0.28.1': + optional: true + '@esbuild/linux-ia32@0.18.13': optional: true @@ -10538,6 +10831,9 @@ snapshots: '@esbuild/linux-ia32@0.27.0': optional: true + '@esbuild/linux-ia32@0.28.1': + optional: true + '@esbuild/linux-loong64@0.18.13': optional: true @@ -10550,6 +10846,9 @@ snapshots: '@esbuild/linux-loong64@0.27.0': optional: true + '@esbuild/linux-loong64@0.28.1': + optional: true + '@esbuild/linux-mips64el@0.18.13': optional: true @@ -10562,6 +10861,9 @@ snapshots: '@esbuild/linux-mips64el@0.27.0': optional: true + '@esbuild/linux-mips64el@0.28.1': + optional: true + '@esbuild/linux-ppc64@0.18.13': optional: true @@ -10574,6 +10876,9 @@ snapshots: '@esbuild/linux-ppc64@0.27.0': optional: true + '@esbuild/linux-ppc64@0.28.1': + optional: true + '@esbuild/linux-riscv64@0.18.13': optional: true @@ -10586,6 +10891,9 @@ snapshots: '@esbuild/linux-riscv64@0.27.0': optional: true + '@esbuild/linux-riscv64@0.28.1': + optional: true + '@esbuild/linux-s390x@0.18.13': optional: true @@ -10598,6 +10906,9 @@ snapshots: '@esbuild/linux-s390x@0.27.0': optional: true + '@esbuild/linux-s390x@0.28.1': + optional: true + '@esbuild/linux-x64@0.18.13': optional: true @@ -10610,12 +10921,18 @@ snapshots: '@esbuild/linux-x64@0.27.0': optional: true + '@esbuild/linux-x64@0.28.1': + optional: true + '@esbuild/netbsd-arm64@0.25.4': optional: true '@esbuild/netbsd-arm64@0.27.0': optional: true + '@esbuild/netbsd-arm64@0.28.1': + optional: true + '@esbuild/netbsd-x64@0.18.13': optional: true @@ -10628,12 +10945,18 @@ snapshots: '@esbuild/netbsd-x64@0.27.0': optional: true + '@esbuild/netbsd-x64@0.28.1': + optional: true + '@esbuild/openbsd-arm64@0.25.4': optional: true '@esbuild/openbsd-arm64@0.27.0': optional: true + '@esbuild/openbsd-arm64@0.28.1': + optional: true + '@esbuild/openbsd-x64@0.18.13': optional: true @@ -10646,9 +10969,15 @@ snapshots: '@esbuild/openbsd-x64@0.27.0': optional: true + '@esbuild/openbsd-x64@0.28.1': + optional: true + '@esbuild/openharmony-arm64@0.27.0': optional: true + '@esbuild/openharmony-arm64@0.28.1': + optional: true + '@esbuild/sunos-x64@0.18.13': optional: true @@ -10661,6 +10990,9 @@ snapshots: '@esbuild/sunos-x64@0.27.0': optional: true + '@esbuild/sunos-x64@0.28.1': + optional: true + '@esbuild/win32-arm64@0.18.13': optional: true @@ -10673,6 +11005,9 @@ snapshots: '@esbuild/win32-arm64@0.27.0': optional: true + '@esbuild/win32-arm64@0.28.1': + optional: true + '@esbuild/win32-ia32@0.18.13': optional: true @@ -10685,6 +11020,9 @@ snapshots: '@esbuild/win32-ia32@0.27.0': optional: true + '@esbuild/win32-ia32@0.28.1': + optional: true + '@esbuild/win32-x64@0.18.13': optional: true @@ -10697,6 +11035,9 @@ snapshots: '@esbuild/win32-x64@0.27.0': optional: true + '@esbuild/win32-x64@0.28.1': + optional: true + '@fastify/busboy@2.1.1': {} '@graphql-tools/executor@0.0.18(graphql@16.9.0)': @@ -13697,6 +14038,35 @@ snapshots: '@esbuild/win32-ia32': 0.27.0 '@esbuild/win32-x64': 0.27.0 + esbuild@0.28.1: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.1 + '@esbuild/android-arm': 0.28.1 + '@esbuild/android-arm64': 0.28.1 + '@esbuild/android-x64': 0.28.1 + '@esbuild/darwin-arm64': 0.28.1 + '@esbuild/darwin-x64': 0.28.1 + '@esbuild/freebsd-arm64': 0.28.1 + '@esbuild/freebsd-x64': 0.28.1 + '@esbuild/linux-arm': 0.28.1 + '@esbuild/linux-arm64': 0.28.1 + '@esbuild/linux-ia32': 0.28.1 + '@esbuild/linux-loong64': 0.28.1 + '@esbuild/linux-mips64el': 0.28.1 + '@esbuild/linux-ppc64': 0.28.1 + '@esbuild/linux-riscv64': 0.28.1 + '@esbuild/linux-s390x': 0.28.1 + '@esbuild/linux-x64': 0.28.1 + '@esbuild/netbsd-arm64': 0.28.1 + '@esbuild/netbsd-x64': 0.28.1 + '@esbuild/openbsd-arm64': 0.28.1 + '@esbuild/openbsd-x64': 0.28.1 + '@esbuild/openharmony-arm64': 0.28.1 + '@esbuild/sunos-x64': 0.28.1 + '@esbuild/win32-arm64': 0.28.1 + '@esbuild/win32-ia32': 0.28.1 + '@esbuild/win32-x64': 0.28.1 + escalade@3.2.0: {} escape-html@1.0.3: {} @@ -14717,6 +15087,18 @@ snapshots: - bufferutil - utf-8-validate + miniflare@4.20260710.0: + dependencies: + '@cspotcode/source-map-support': 0.8.1 + sharp: 0.34.5 + undici: 7.28.0 + workerd: 1.20260710.1 + ws: 8.21.0 + youch: 4.1.0-beta.10 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + minimalistic-assert@1.0.1: {} minimatch@10.1.1: @@ -16358,6 +16740,8 @@ snapshots: undici@7.18.2: {} + undici@7.28.0: {} + unenv@2.0.0-rc.24: dependencies: pathe: 2.0.3 @@ -16597,6 +16981,31 @@ snapshots: '@cloudflare/workerd-linux-arm64': 1.20260120.0 '@cloudflare/workerd-windows-64': 1.20260120.0 + workerd@1.20260710.1: + optionalDependencies: + '@cloudflare/workerd-darwin-64': 1.20260710.1 + '@cloudflare/workerd-darwin-arm64': 1.20260710.1 + '@cloudflare/workerd-linux-64': 1.20260710.1 + '@cloudflare/workerd-linux-arm64': 1.20260710.1 + '@cloudflare/workerd-windows-64': 1.20260710.1 + + wrangler@4.111.0(@cloudflare/workers-types@4.20260123.0): + dependencies: + '@cloudflare/kv-asset-handler': 0.5.0 + '@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260710.1) + blake3-wasm: 2.1.5 + esbuild: 0.28.1 + miniflare: 4.20260710.0 + path-to-regexp: 6.3.0 + unenv: 2.0.0-rc.24 + workerd: 1.20260710.1 + optionalDependencies: + '@cloudflare/workers-types': 4.20260123.0 + fsevents: 2.3.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + wrangler@4.60.0(@cloudflare/workers-types@4.20260123.0): dependencies: '@cloudflare/kv-asset-handler': 0.4.2 @@ -16638,6 +17047,8 @@ snapshots: ws@8.18.0: {} + ws@8.21.0: {} + xml-name-validator@4.0.0: {} xml2js@0.6.2: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 41ba1627..fa1376c9 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -30,7 +30,7 @@ catalog: tsx: ^4.19.2 typescript: ^6.0.3 vitest: ^2.1.1 - wrangler: ^4.59.2 + wrangler: ^4.110.0 yargs: ^18.0.0 catalogs: From ca6b7d712c0624479198f3ff3355bed6dd67b25d Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Wed, 15 Jul 2026 22:26:05 +0200 Subject: [PATCH 18/24] feat(cloudflare): add AsyncLocalStorage import for improved context management --- packages/cloudflare/src/cli/adapter.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/cloudflare/src/cli/adapter.ts b/packages/cloudflare/src/cli/adapter.ts index 7eef247b..bab34717 100644 --- a/packages/cloudflare/src/cli/adapter.ts +++ b/packages/cloudflare/src/cli/adapter.ts @@ -61,6 +61,8 @@ export default buildAdapter((config: OpenNextConfig, buildOpts: BuildOptions) => cloudflareBanner.push( "import process from 'node:process';", "import { Buffer } from 'node:buffer';", + "import { AsyncLocalStorage as NodeAsyncLocalStorage } from 'node:async_hooks';", + "globalThis.AsyncLocalStorage = NodeAsyncLocalStorage;", "import { createRequire as topLevelCreateRequire } from 'module';", "const require = topLevelCreateRequire(import.meta.url);", "import bannerUrl from 'url';", From 3afd94e1f60927919b169c71f7861a84cd072137 Mon Sep 17 00:00:00 2001 From: Victor Berchet Date: Fri, 18 Sep 2026 07:35:46 +0200 Subject: [PATCH 19/24] fix(cloudflare): pass runtime env to containers Forward string-valued Worker bindings, including vars and secrets, through the Container SDK envVars field while excluding object service bindings. Add focused tests for binding filtering and the OpenNextContainer runtime environment. --- packages/cloudflare/src/api/container.spec.ts | 44 +++++++++++++++++++ packages/cloudflare/src/api/container.ts | 17 +++++++ 2 files changed, 61 insertions(+) create mode 100644 packages/cloudflare/src/api/container.spec.ts diff --git a/packages/cloudflare/src/api/container.spec.ts b/packages/cloudflare/src/api/container.spec.ts new file mode 100644 index 00000000..2bc6d523 --- /dev/null +++ b/packages/cloudflare/src/api/container.spec.ts @@ -0,0 +1,44 @@ +import { describe, expect, test, vi } from "vitest"; + +vi.mock("cloudflare:workers", () => ({ + env: { + API_SECRET: "secret", + APP_ENV: "production", + ASSETS: { fetch: vi.fn() }, + OPEN_NEXT_CONTAINER: { get: vi.fn() }, + }, +})); + +vi.mock("@cloudflare/containers", () => ({ + Container: class {}, + getContainer: vi.fn(), +})); + +const { getContainerEnvVars, OpenNextContainer } = await import("./container.js"); + +describe("getContainerEnvVars", () => { + test("keeps vars and secrets while excluding object bindings", () => { + expect( + getContainerEnvVars({ + API_SECRET: "secret", + APP_ENV: "production", + ASSETS: { fetch: vi.fn() }, + OPTIONAL_VALUE: undefined, + }) + ).toEqual({ + API_SECRET: "secret", + APP_ENV: "production", + }); + }); +}); + +describe("OpenNextContainer", () => { + test("passes string Worker bindings to the container process", () => { + const container = new OpenNextContainer({} as never, {} as never); + + expect(container.envVars).toEqual({ + API_SECRET: "secret", + APP_ENV: "production", + }); + }); +}); diff --git a/packages/cloudflare/src/api/container.ts b/packages/cloudflare/src/api/container.ts index 7aa79048..286c2ed8 100644 --- a/packages/cloudflare/src/api/container.ts +++ b/packages/cloudflare/src/api/container.ts @@ -1,8 +1,24 @@ import { Container, getContainer } from "@cloudflare/containers"; +import { env } from "cloudflare:workers"; export const OPEN_NEXT_CONTAINER_BINDING = "OPEN_NEXT_CONTAINER"; export const OPEN_NEXT_CONTAINER_NAME = "default"; +/** + * Selects Worker variables that can be passed to the container process. + * + * Worker vars and secrets are strings, while service bindings are objects and + * must remain in the Worker runtime. + * + * @param bindings Worker environment bindings. + * @returns String-valued bindings suitable for Container `envVars`. + */ +export function getContainerEnvVars(bindings: Record): Record { + return Object.fromEntries( + Object.entries(bindings).filter((entry): entry is [string, string] => typeof entry[1] === "string") + ); +} + /** * The Durable Object controller for the Node.js OpenNext server container. * @@ -11,6 +27,7 @@ export const OPEN_NEXT_CONTAINER_NAME = "default"; */ export class OpenNextContainer extends Container { override defaultPort = 3000; + override envVars = getContainerEnvVars(env as Record); /** * Normalize the Durable Object request before forwarding it to the Container. From 503d7f6aa5061d59107293e1bfbb61f7c4010e14 Mon Sep 17 00:00:00 2001 From: Victor Berchet Date: Fri, 18 Sep 2026 07:37:07 +0200 Subject: [PATCH 20/24] fix(core): rebuild native modules in container images Generate the default server image from a glibc Node 24 base and run npm rebuild after copying the traced application so native dependencies target the container platform rather than the build host. Extract and test the Dockerfile generator, including command ordering. --- packages/core/src/build/createServerBundle.ts | 11 ++-------- packages/core/src/build/dockerfile.spec.ts | 20 ++++++++++++++++++ packages/core/src/build/dockerfile.ts | 21 +++++++++++++++++++ 3 files changed, 43 insertions(+), 9 deletions(-) create mode 100644 packages/core/src/build/dockerfile.spec.ts create mode 100644 packages/core/src/build/dockerfile.ts diff --git a/packages/core/src/build/createServerBundle.ts b/packages/core/src/build/createServerBundle.ts index 9ca6db8d..f7cd954c 100644 --- a/packages/core/src/build/createServerBundle.ts +++ b/packages/core/src/build/createServerBundle.ts @@ -18,6 +18,7 @@ import { getCrossPlatformPathRegex } from "../utils/regex.js"; import { compileCache } from "./compileCache.js"; import { copyAdapterFiles } from "./copyAdapterFiles.js"; import { getManifests } from "./copyTracedFiles.js"; +import { getDefaultDockerfile } from "./dockerfile.js"; import { copyMiddlewareResources, generateEdgeBundle } from "./edge/createEdgeBundle.js"; import * as buildHelper from "./helper.js"; import { installDependencies } from "./installDeps.js"; @@ -291,15 +292,7 @@ async function generateBundle( if (shouldGenerateDocker) { fs.writeFileSync( path.join(outputPath, "Dockerfile"), - typeof shouldGenerateDocker === "string" - ? shouldGenerateDocker - : ` -FROM node:24-alpine -WORKDIR /app -COPY . /app -EXPOSE 3000 -CMD ["node", "index.mjs"] - ` + typeof shouldGenerateDocker === "string" ? shouldGenerateDocker : getDefaultDockerfile() ); } } diff --git a/packages/core/src/build/dockerfile.spec.ts b/packages/core/src/build/dockerfile.spec.ts new file mode 100644 index 00000000..20ac720e --- /dev/null +++ b/packages/core/src/build/dockerfile.spec.ts @@ -0,0 +1,20 @@ +import { describe, expect, test } from "vitest"; + +import { getDefaultDockerfile } from "./dockerfile.js"; + +describe("getDefaultDockerfile", () => { + test("rebuilds native dependencies in a glibc Linux image", () => { + const dockerfile = getDefaultDockerfile(); + + expect(dockerfile).toContain("FROM node:24-bookworm-slim"); + expect(dockerfile).toContain( + "npm pkg delete scripts.preinstall scripts.install scripts.postinstall scripts.prepare" + ); + expect(dockerfile).toContain("npm rebuild --foreground-scripts"); + expect(dockerfile.indexOf("COPY . /app")).toBeLessThan(dockerfile.indexOf("npm rebuild")); + expect(dockerfile.indexOf("npm pkg delete")).toBeLessThan(dockerfile.indexOf("npm rebuild")); + expect(dockerfile.indexOf("npm rebuild")).toBeLessThan( + dockerfile.indexOf("mv /tmp/package.json package.json") + ); + }); +}); diff --git a/packages/core/src/build/dockerfile.ts b/packages/core/src/build/dockerfile.ts new file mode 100644 index 00000000..b7219988 --- /dev/null +++ b/packages/core/src/build/dockerfile.ts @@ -0,0 +1,21 @@ +/** + * Generates the default Dockerfile for Node.js server bundles. + * + * Native dependencies are rebuilt inside the target Linux image so copied + * artifacts from the build host are not used unchanged. + * + * @returns The default Dockerfile contents. + */ +export function getDefaultDockerfile(): string { + return ` +FROM node:24-bookworm-slim +WORKDIR /app +COPY . /app +RUN cp package.json /tmp/package.json \ + && npm pkg delete scripts.preinstall scripts.install scripts.postinstall scripts.prepare \ + && npm rebuild --foreground-scripts \ + && mv /tmp/package.json package.json +EXPOSE 3000 +CMD ["node", "index.mjs"] + `; +} From 2343cfd2c37249c502042fc7aca4b537b21d8936 Mon Sep 17 00:00:00 2001 From: Victor Berchet Date: Fri, 18 Sep 2026 07:37:44 +0200 Subject: [PATCH 21/24] fix(cloudflare): reject container split functions Fail configuration validation when container mode declares split functions because the generated Worker only routes to the default container. Add focused validation coverage so unsupported bundles cannot be silently generated and ignored. --- .../src/cli/build/utils/ensure-cf-config.spec.ts | 13 +++++++++++++ .../src/cli/build/utils/ensure-cf-config.ts | 5 +++++ 2 files changed, 18 insertions(+) diff --git a/packages/cloudflare/src/cli/build/utils/ensure-cf-config.spec.ts b/packages/cloudflare/src/cli/build/utils/ensure-cf-config.spec.ts index b90f5432..464d6b48 100644 --- a/packages/cloudflare/src/cli/build/utils/ensure-cf-config.spec.ts +++ b/packages/cloudflare/src/cli/build/utils/ensure-cf-config.spec.ts @@ -15,4 +15,17 @@ describe("ensureCloudflareConfig", () => { expect(() => ensureCloudflareConfig(config)).toThrow("Cloudflare Containers"); }); + + test("rejects split functions in container mode", () => { + const config = defineCloudflareConfig({ container: true }); + config.functions = { + api: { + routes: ["app/api/route"], + }, + }; + + expect(() => ensureCloudflareConfig(config)).toThrow( + "Cloudflare Container mode does not support split functions." + ); + }); }); diff --git a/packages/cloudflare/src/cli/build/utils/ensure-cf-config.ts b/packages/cloudflare/src/cli/build/utils/ensure-cf-config.ts index d3756aba..7740469a 100644 --- a/packages/cloudflare/src/cli/build/utils/ensure-cf-config.ts +++ b/packages/cloudflare/src/cli/build/utils/ensure-cf-config.ts @@ -7,11 +7,16 @@ import type { OpenNextConfig } from "../../../api/config.js"; * Ensures open next is configured for cloudflare. * * @param config OpenNext configuration. + * @returns Nothing. + * @throws When the configuration is unsupported by the Cloudflare adapter. */ export function ensureCloudflareConfig(config: OpenNextConfig) { const mwIsMiddlewareExternal = config.middleware?.external === true; const mwConfig = mwIsMiddlewareExternal ? (config.middleware as ExternalMiddlewareConfig) : undefined; const isContainer = config.cloudflare?.container === true; + if (isContainer && Object.keys(config.functions ?? {}).length > 0) { + throw new Error("Cloudflare Container mode does not support split functions."); + } const requirements = { // Check for the default function From 518ad0943acd9306ff0d1cbbd49de5920f1320f2 Mon Sep 17 00:00:00 2001 From: Victor Berchet Date: Fri, 18 Sep 2026 07:39:00 +0200 Subject: [PATCH 22/24] fix(cloudflare): preserve container request protocol Overwrite x-forwarded-proto from the public request URL at the trusted Container Durable Object boundary and use that header when the Node converter reconstructs the request URL. Cover spoofed incoming headers and HTTPS conversion in Cloudflare and core converter tests. --- packages/cloudflare/src/api/container.spec.ts | 17 ++++++++++++++++- packages/cloudflare/src/api/container.ts | 9 ++++++++- packages/core/src/overrides/converters/node.ts | 6 +++--- .../tests-unit/tests/converters/node.test.ts | 15 +++++++++++++++ 4 files changed, 42 insertions(+), 5 deletions(-) diff --git a/packages/cloudflare/src/api/container.spec.ts b/packages/cloudflare/src/api/container.spec.ts index 2bc6d523..05c220ea 100644 --- a/packages/cloudflare/src/api/container.spec.ts +++ b/packages/cloudflare/src/api/container.spec.ts @@ -10,7 +10,9 @@ vi.mock("cloudflare:workers", () => ({ })); vi.mock("@cloudflare/containers", () => ({ - Container: class {}, + Container: class { + containerFetch = vi.fn(() => Promise.resolve(new Response())); + }, getContainer: vi.fn(), })); @@ -41,4 +43,17 @@ describe("OpenNextContainer", () => { APP_ENV: "production", }); }); + + test("forwards the public request protocol to the Node.js server", async () => { + const container = new OpenNextContainer({} as never, {} as never); + + await container.fetch( + new Request("https://example.com/path", { + headers: { "x-forwarded-proto": "http" }, + }) + ); + + const init = vi.mocked(container.containerFetch).mock.calls[0]?.[1] as RequestInit; + expect(new Headers(init.headers).get("x-forwarded-proto")).toBe("https"); + }); }); diff --git a/packages/cloudflare/src/api/container.ts b/packages/cloudflare/src/api/container.ts index 286c2ed8..bee133d6 100644 --- a/packages/cloudflare/src/api/container.ts +++ b/packages/cloudflare/src/api/container.ts @@ -35,13 +35,20 @@ export class OpenNextContainer extends Container { * In local workerd, the request received by a Durable Object can originate * from another runtime realm. The Container base class checks it with * `instanceof Request`, which then fails and coerces it to "[object Request]". + * The public protocol is also recorded at this trusted boundary because the + * container transport itself uses HTTP. + * + * @param request The request to forward to the container. + * @returns The response produced by the container. */ override fetch(request: Request): Promise { + const headers = new Headers(request.headers); + headers.set("x-forwarded-proto", new URL(request.url).protocol.slice(0, -1)); return this.containerFetch( request.url, { method: request.method, - headers: request.headers, + headers, body: request.method === "GET" || request.method === "HEAD" ? undefined : request.body, }, this.defaultPort diff --git a/packages/core/src/overrides/converters/node.ts b/packages/core/src/overrides/converters/node.ts index 93979994..eeb8523f 100644 --- a/packages/core/src/overrides/converters/node.ts +++ b/packages/core/src/overrides/converters/node.ts @@ -20,10 +20,10 @@ const converter: Converter = { .map(([key, value]) => [key.toLowerCase(), Array.isArray(value) ? value.join(",") : value]) .filter(([key]) => key) ); + const forwardedProtocol = headers["x-forwarded-proto"]?.split(",", 1)[0]?.trim(); + const protocol = req.protocol ?? (forwardedProtocol === "https" ? "https" : "http"); // https://nodejs.org/api/http.html#messageurl - const url = new URL( - `${req.protocol ? req.protocol : "http"}://${extractHostFromHeaders(headers)}${req.url}` - ); + const url = new URL(`${protocol}://${extractHostFromHeaders(headers)}${req.url}`); const query = getQueryFromSearchParams(url.searchParams); const cookieHeader = req.headers.cookie; diff --git a/packages/tests-unit/tests/converters/node.test.ts b/packages/tests-unit/tests/converters/node.test.ts index c22537d0..83b6fd95 100644 --- a/packages/tests-unit/tests/converters/node.test.ts +++ b/packages/tests-unit/tests/converters/node.test.ts @@ -59,6 +59,21 @@ describe("convertFrom", () => { }); }); + it("should preserve HTTPS from the forwarded protocol header", async () => { + const result = await converter.convertFrom( + new IncomingMessage({ + url: "/path", + method: "GET", + headers: { + host: "example.com", + "x-forwarded-proto": "https", + }, + }) + ); + + expect(result.url).toBe("https://example.com/path"); + }); + it("should convert GET request with default remoteAddress", async () => { const result = await converter.convertFrom( new IncomingMessage({ From c916621ff73ac84f2b39f5e7b5e5e454e719ae53 Mon Sep 17 00:00:00 2001 From: Victor Berchet Date: Fri, 18 Sep 2026 07:40:47 +0200 Subject: [PATCH 23/24] fix(cloudflare): propagate container request aborts Reattach the original client signal after middleware request conversion and pass the Durable Object request signal into the Container SDK reconstruction path. Add tests proving both forwarding boundaries retain cancellation behavior and keep the generated template type-safe. --- packages/cloudflare/src/api/container.spec.ts | 23 ++++++++++++++++++- packages/cloudflare/src/api/container.ts | 12 ++++++++++ .../cloudflare/src/cli/templates/container.ts | 6 +++-- 3 files changed, 38 insertions(+), 3 deletions(-) diff --git a/packages/cloudflare/src/api/container.spec.ts b/packages/cloudflare/src/api/container.spec.ts index 05c220ea..e0a16b25 100644 --- a/packages/cloudflare/src/api/container.spec.ts +++ b/packages/cloudflare/src/api/container.spec.ts @@ -16,7 +16,7 @@ vi.mock("@cloudflare/containers", () => ({ getContainer: vi.fn(), })); -const { getContainerEnvVars, OpenNextContainer } = await import("./container.js"); +const { getContainerEnvVars, OpenNextContainer, withRequestSignal } = await import("./container.js"); describe("getContainerEnvVars", () => { test("keeps vars and secrets while excluding object bindings", () => { @@ -56,4 +56,25 @@ describe("OpenNextContainer", () => { const init = vi.mocked(container.containerFetch).mock.calls[0]?.[1] as RequestInit; expect(new Headers(init.headers).get("x-forwarded-proto")).toBe("https"); }); + + test("preserves the request signal when reconstructing the SDK request", async () => { + const container = new OpenNextContainer({} as never, {} as never); + const request = new Request("https://example.com/path"); + + await container.fetch(request); + + const init = vi.mocked(container.containerFetch).mock.calls[0]?.[1] as RequestInit; + expect(init.signal).toBe(request.signal); + }); +}); + +describe("withRequestSignal", () => { + test("makes a middleware request follow the original client signal", () => { + const controller = new AbortController(); + const request = withRequestSignal(new Request("https://example.com/path"), controller.signal); + + controller.abort(); + + expect(request.signal.aborted).toBe(true); + }); }); diff --git a/packages/cloudflare/src/api/container.ts b/packages/cloudflare/src/api/container.ts index bee133d6..10549dad 100644 --- a/packages/cloudflare/src/api/container.ts +++ b/packages/cloudflare/src/api/container.ts @@ -19,6 +19,17 @@ export function getContainerEnvVars(bindings: Record): Record; From 8b264bb4054e4b2d3744906bdf72857bec8f8a58 Mon Sep 17 00:00:00 2001 From: Victor Berchet Date: Fri, 18 Sep 2026 07:41:26 +0200 Subject: [PATCH 24/24] chore: add Cloudflare containers changeset Record the new Cloudflare Containers configuration and runtime support as a minor Cloudflare release, and the target-platform Docker dependency rebuild as a core patch. --- .changeset/cloudflare-containers.md | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 .changeset/cloudflare-containers.md diff --git a/.changeset/cloudflare-containers.md b/.changeset/cloudflare-containers.md new file mode 100644 index 00000000..fcdbfb62 --- /dev/null +++ b/.changeset/cloudflare-containers.md @@ -0,0 +1,8 @@ +--- +"@opennextjs/cloudflare": minor +"@opennextjs/core": patch +--- + +Add Cloudflare Containers support for the default Next.js server, including Worker middleware forwarding, runtime environment propagation, request protocol and cancellation handling, and container E2E coverage. + +Generate Node.js container images from a glibc base and rebuild native dependencies for the target Linux platform.