From fa74e70de9637b83d79ff907769ba56a0d1eab48 Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Sat, 27 Jun 2026 17:17:49 +0200 Subject: [PATCH 01/44] 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 31d1feee..ee6bd626 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 33a6eff7d6b90bbb193e176790e60d5f5e87ed85 Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Sat, 27 Jun 2026 21:21:21 +0200 Subject: [PATCH 02/44] 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 fe002e1eb47beaf57cf69718aa5c08987aecb941 Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Sat, 27 Jun 2026 21:24:03 +0200 Subject: [PATCH 03/44] 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 9ee9515b9fa5169864fc42b6c711679b8833d3c8 Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Sat, 27 Jun 2026 21:27:14 +0200 Subject: [PATCH 04/44] 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 6fad0c6038ca26edbbda6f3fd3d8ef150063ba91 Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Sat, 27 Jun 2026 21:36:43 +0200 Subject: [PATCH 05/44] 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 84397ab682444a5ab1850d9eb7c34ec748fed067 Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Sat, 27 Jun 2026 21:54:50 +0200 Subject: [PATCH 06/44] 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 f22d37f2abb7510128fac4ad311df9289b95c045 Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Sat, 27 Jun 2026 23:49:46 +0200 Subject: [PATCH 07/44] 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 a3165f0186ff15a98aa237b341b6ed2505b77ad1 Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Sun, 28 Jun 2026 09:46:06 +0200 Subject: [PATCH 08/44] 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 6296e94f7089aa43b55262acc39d24527e9b9c3d Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Sun, 28 Jun 2026 09:46:10 +0200 Subject: [PATCH 09/44] 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 4e828212342bb0759d77f15b5e3b870b5b084888 Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Sun, 28 Jun 2026 09:51:58 +0200 Subject: [PATCH 10/44] 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 0af2e730b62596e34f57ba75485b0e2ee4db2b4e Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Sun, 28 Jun 2026 09:52:03 +0200 Subject: [PATCH 11/44] 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 bab47f16600a85d7f1a7fbbd019599a7e2f9b168 Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Sun, 28 Jun 2026 13:06:02 +0200 Subject: [PATCH 12/44] 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 51b5e3b92d5e646e439970a35afbdaf68be07d85 Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Sun, 28 Jun 2026 13:17:32 +0200 Subject: [PATCH 13/44] 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 a437de1b64575b3d156baad661160321bcab5734 Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Sat, 1 Aug 2026 11:54:29 +0200 Subject: [PATCH 14/44] feat(core): enforce mandatory externals in serverBundle and update related hooks --- packages/aws/src/adapter.ts | 1 + packages/cloudflare/src/cli/adapter.ts | 2 +- packages/core/src/build/adapter.spec.ts | 54 ++++++++++++------- packages/core/src/build/adapter.ts | 33 +++++++----- packages/core/src/build/createAssets.ts | 6 +-- packages/core/src/build/createServerBundle.ts | 24 +++++---- 6 files changed, 72 insertions(+), 48 deletions(-) diff --git a/packages/aws/src/adapter.ts b/packages/aws/src/adapter.ts index 606edcab..4496c30f 100644 --- a/packages/aws/src/adapter.ts +++ b/packages/aws/src/adapter.ts @@ -37,6 +37,7 @@ export default buildAdapter((_config, buildOpts: BuildOptions) => ({ }, }, serverBundle: { + externals: ["./middleware.mjs"], additionalPlugins: (updater: ContentUpdater, outputs: NextAdapterOutputs) => { const packagePath = buildHelper.getPackagePath(buildOpts); return [inlineRouteHandler(updater, outputs, packagePath), externalChunksPlugin(outputs, packagePath)]; diff --git a/packages/cloudflare/src/cli/adapter.ts b/packages/cloudflare/src/cli/adapter.ts index b3079449..bfb1e073 100644 --- a/packages/cloudflare/src/cli/adapter.ts +++ b/packages/cloudflare/src/cli/adapter.ts @@ -32,7 +32,7 @@ export default buildAdapter((config: OpenNextConfig, buildOpts: BuildOptions) => skipWarmer: true, skipGenerateOutput: true, middlewareOptions: { forceOnlyBuildOnce: true }, - beforeMiddleware: async (buildOpts, _config) => { + beforeServerBundle: async (buildOpts, _config) => { // Import edge-compiled config for skew protection const configPath = path.join( buildOpts.appBuildOutputPath, diff --git a/packages/core/src/build/adapter.spec.ts b/packages/core/src/build/adapter.spec.ts index bda407af..dfdb54f1 100644 --- a/packages/core/src/build/adapter.spec.ts +++ b/packages/core/src/build/adapter.spec.ts @@ -148,6 +148,10 @@ function createMockContext(): BuildCompleteContext { }; } +// serverBundle.externals is mandatory for every adapter, minimal value for tests +// that don't exercise the server bundle customization. +const serverBundle = { externals: [] }; + describe("buildAdapter", () => { beforeEach(() => { vi.clearAllMocks(); @@ -170,13 +174,13 @@ describe("buildAdapter", () => { }); vi.mocked(createCacheAssets).mockReturnValue({ - useTagCache: false, + shouldUseTagCache: false, metaFiles: [], }); }); test("returns an object with name, modifyConfig, and onBuildComplete", () => { - const adapter = buildAdapter(() => ({})); + const adapter = buildAdapter(() => ({ serverBundle })); expect(adapter.name).toBe("OpenNext"); expect(typeof adapter.modifyConfig).toBe("function"); @@ -184,7 +188,7 @@ describe("buildAdapter", () => { }); test("modifyConfig calls compileOpenNextConfig with compileEdge: true, then callback", async () => { - const mockCallback = vi.fn(() => ({})); + const mockCallback = vi.fn(() => ({ serverBundle })); const adapter = buildAdapter(mockCallback); const nextConfig = { experimental: {}, images: {} } as BuildCompleteContext["config"]; @@ -197,7 +201,7 @@ describe("buildAdapter", () => { }); test("modifyConfig returns nextConfig with cacheHandler, cacheHandlers, cacheMaxMemorySize, and trustHostHeader", async () => { - const adapter = buildAdapter(() => ({})); + const adapter = buildAdapter(() => ({ serverBundle })); const nextConfig = { experimental: { serverActions: true }, @@ -219,6 +223,7 @@ describe("buildAdapter", () => { test("onBuildComplete calls createMiddleware with influence.middlewareOptions", async () => { const adapter = buildAdapter(() => ({ + serverBundle, middlewareOptions: { forceOnlyBuildOnce: true }, })); @@ -236,6 +241,7 @@ describe("buildAdapter", () => { test("onBuildComplete skips createRevalidationBundle when skipRevalidation is true", async () => { const adapter = buildAdapter(() => ({ + serverBundle, skipRevalidation: true, })); @@ -248,12 +254,13 @@ describe("buildAdapter", () => { expect(createRevalidationBundle).not.toHaveBeenCalled(); }); - test("onBuildComplete calls influence.beforeMiddleware BEFORE createMiddleware", async () => { + test("onBuildComplete calls influence.beforeServerBundle BEFORE createMiddleware", async () => { const callOrder: string[] = []; const adapter = buildAdapter(() => ({ - beforeMiddleware: vi.fn(async () => { - callOrder.push("beforeMiddleware"); + serverBundle, + beforeServerBundle: vi.fn(async () => { + callOrder.push("beforeServerBundle"); }), })); @@ -267,13 +274,14 @@ describe("buildAdapter", () => { const ctx = createMockContext(); await adapter.onBuildComplete(ctx); - expect(callOrder).toEqual(["beforeMiddleware", "createMiddleware"]); + expect(callOrder).toEqual(["beforeServerBundle", "createMiddleware"]); }); test("onBuildComplete calls influence.afterServerBundle after createServerBundle but BEFORE createRevalidationBundle", async () => { const callOrder: string[] = []; const adapter = buildAdapter(() => ({ + serverBundle, afterServerBundle: vi.fn(async () => { callOrder.push("afterServerBundle"); }), @@ -306,7 +314,7 @@ describe("buildAdapter", () => { buildDir: "/tmp/open-next-tmp", }); - const adapter = buildAdapter(() => ({})); + const adapter = buildAdapter(() => ({ serverBundle })); const nextConfig = { experimental: {}, images: {} } as BuildCompleteContext["config"]; await adapter.modifyConfig(nextConfig, { phase: "production" }); @@ -322,6 +330,7 @@ describe("buildAdapter", () => { const mockTempCachePath = vi.fn(() => "/custom/temp/cache/path"); const adapter = buildAdapter(() => ({ + serverBundle, tempCachePath: mockTempCachePath, })); @@ -339,6 +348,7 @@ describe("buildAdapter", () => { test("onBuildComplete skips image optimization when skipImageOptimization is true", async () => { const adapter = buildAdapter(() => ({ + serverBundle, skipImageOptimization: true, })); @@ -353,6 +363,7 @@ describe("buildAdapter", () => { test("onBuildComplete skips warmer when skipWarmer is true", async () => { const adapter = buildAdapter(() => ({ + serverBundle, skipWarmer: true, })); @@ -367,6 +378,7 @@ describe("buildAdapter", () => { test("onBuildComplete skips generateOutput when skipGenerateOutput is true", async () => { const adapter = buildAdapter(() => ({ + serverBundle, skipGenerateOutput: true, })); @@ -382,7 +394,7 @@ describe("buildAdapter", () => { }); test("onBuildComplete calls addDebugFile with outputs.json", async () => { - const adapter = buildAdapter(() => ({})); + const adapter = buildAdapter(() => ({ serverBundle })); const nextConfig = { experimental: {}, images: {} } as BuildCompleteContext["config"]; await adapter.modifyConfig(nextConfig, { phase: "production" }); @@ -426,13 +438,13 @@ describe("buildAdapter", () => { ); }); - test("onBuildComplete compiles tag cache provider when useTagCache is true", async () => { + test("onBuildComplete compiles tag cache provider when shouldUseTagCache is true", async () => { vi.mocked(createCacheAssets).mockReturnValue({ - useTagCache: true, + shouldUseTagCache: true, metaFiles: [], }); - const adapter = buildAdapter(() => ({})); + const adapter = buildAdapter(() => ({ serverBundle })); const nextConfig = { experimental: {}, images: {} } as BuildCompleteContext["config"]; await adapter.modifyConfig(nextConfig, { phase: "production" }); @@ -448,7 +460,7 @@ describe("buildAdapter", () => { (mockBuildOpts.config as OpenNextConfig).dangerous = { disableIncrementalCache: true }; vi.mocked(buildHelper.normalizeOptions).mockReturnValue(mockBuildOpts); - const adapter = buildAdapter(() => ({})); + const adapter = buildAdapter(() => ({ serverBundle })); const nextConfig = { experimental: {}, images: {} } as BuildCompleteContext["config"]; await adapter.modifyConfig(nextConfig, { phase: "production" }); @@ -462,7 +474,7 @@ describe("buildAdapter", () => { 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 adapter = buildAdapter(() => ({ serverBundle, validateConfig: mockValidator })); const nextConfig = { experimental: {}, images: {} } as BuildCompleteContext["config"]; await expect(adapter.modifyConfig(nextConfig, { phase: "production" })).rejects.toThrow("nope"); @@ -471,7 +483,7 @@ describe("buildAdapter", () => { 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 adapter = buildAdapter(() => ({ serverBundle, validateConfig: mockValidator })); const nextConfig = { experimental: {}, images: {} } as BuildCompleteContext["config"]; await adapter.modifyConfig(nextConfig, { phase: "production" }); @@ -480,7 +492,7 @@ describe("buildAdapter", () => { 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 adapter = buildAdapter(() => ({ serverBundle, generateOutput: mockOutput })); const nextConfig = { experimental: {}, images: {} } as BuildCompleteContext["config"]; await adapter.modifyConfig(nextConfig, { phase: "production" }); const ctx = createMockContext(); @@ -497,7 +509,7 @@ describe("buildAdapter", () => { vi.mocked(buildOpenNextOutput).mockResolvedValue({ origins: { default: {} }, } as any); // oxlint-disable-line @typescript-eslint/no-explicit-any - const adapter = buildAdapter(() => ({})); + const adapter = buildAdapter(() => ({ serverBundle })); const nextConfig = { experimental: {}, images: {} } as BuildCompleteContext["config"]; await adapter.modifyConfig(nextConfig, { phase: "production" }); const ctx = createMockContext(); @@ -512,7 +524,11 @@ describe("buildAdapter", () => { test("onBuildComplete skipGenerateOutput skips generateOutput override and buildOpenNextOutput", async () => { const mockOutput = vi.fn(); - const adapter = buildAdapter(() => ({ skipGenerateOutput: true, generateOutput: mockOutput })); + const adapter = buildAdapter(() => ({ + serverBundle, + skipGenerateOutput: true, + generateOutput: mockOutput, + })); const nextConfig = { experimental: {}, images: {} } as BuildCompleteContext["config"]; await adapter.modifyConfig(nextConfig, { phase: "production" }); const ctx = createMockContext(); diff --git a/packages/core/src/build/adapter.ts b/packages/core/src/build/adapter.ts index 9c3488f7..6ca36b41 100644 --- a/packages/core/src/build/adapter.ts +++ b/packages/core/src/build/adapter.ts @@ -60,14 +60,18 @@ export type OpenNextAdapterOptions = { skipWarmer?: boolean; skipGenerateOutput?: boolean; middlewareOptions?: { forceOnlyBuildOnce?: boolean }; - serverBundle?: { + serverBundle: { additionalPlugins?: (updater: ContentUpdater, outputs: NextAdapterOutputs) => Plugin[]; additionalCodePatches?: CodePatcher[]; useEdgeConfig?: boolean; - externals?: string[]; + /** + * The esbuild externals for the server bundle. Every adapter has to declare + * them explicitly — there is no implicit default. + */ + externals: string[]; banner?: string[] | ((name: string) => string[]); }; - beforeMiddleware?: (buildOpts: buildHelper.BuildOptions, config: OpenNextConfig) => Promise; + beforeServerBundle?: (buildOpts: buildHelper.BuildOptions, config: OpenNextConfig) => Promise; afterServerBundle?: (buildOpts: buildHelper.BuildOptions, config: OpenNextConfig) => Promise; tempCachePath?: (buildOpts: buildHelper.BuildOptions, packagePath: string) => string; /** @@ -174,13 +178,13 @@ export function buildAdapter( }, async onBuildComplete(ctx) { - console.log("OpenNext build will start now"); + logger.info("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 2: Call beforeServerBundle hook + await adapterOptions.beforeServerBundle?.(buildOpts, config); const bundleDefaults = adapterOptions.defaultOverrides; @@ -197,17 +201,18 @@ export function buildAdapter( // Step 5: Cache assets if (buildOpts.config.dangerous?.disableIncrementalCache !== true) { - const { useTagCache } = createCacheAssets(buildOpts); + const { shouldUseTagCache } = createCacheAssets(buildOpts); console.log("Cache assets created"); - if (useTagCache) { + if (shouldUseTagCache) { await compileTagCacheProvider(buildOpts, bundleDefaults?.tagCache); console.log("Tag cache provider compiled"); } } // Step 6: Build wrapped additionalPlugins - const wrappedAdditionalPlugins = adapterOptions.serverBundle?.additionalPlugins - ? (updater: ContentUpdater) => adapterOptions.serverBundle!.additionalPlugins!(updater, ctx.outputs) + const serverBundle = adapterOptions.serverBundle; + const wrappedAdditionalPlugins = serverBundle.additionalPlugins + ? (updater: ContentUpdater) => serverBundle.additionalPlugins!(updater, ctx.outputs) : undefined; // Step 7: Create server bundle @@ -215,10 +220,10 @@ export function buildAdapter( buildOpts, { additionalPlugins: wrappedAdditionalPlugins, - additionalCodePatches: adapterOptions.serverBundle?.additionalCodePatches, - useEdgeConfig: adapterOptions.serverBundle?.useEdgeConfig, - externals: adapterOptions.serverBundle?.externals, - banner: adapterOptions.serverBundle?.banner, + additionalCodePatches: serverBundle.additionalCodePatches, + useEdgeConfig: serverBundle.useEdgeConfig, + externals: serverBundle.externals, + banner: serverBundle.banner, bundleDefaults, }, ctx.outputs diff --git a/packages/core/src/build/createAssets.ts b/packages/core/src/build/createAssets.ts index 4ad81bbd..5a369894 100644 --- a/packages/core/src/build/createAssets.ts +++ b/packages/core/src/build/createAssets.ts @@ -86,7 +86,7 @@ export function createCacheAssets(options: buildHelper.BuildOptions) { const { appBuildOutputPath, outputDir } = options; const packagePath = buildHelper.getPackagePath(options); const buildId = buildHelper.getBuildId(options); - let useTagCache = false; + let shouldUseTagCache = false; const dotNextPath = appBuildOutputPath; @@ -258,7 +258,7 @@ export function createCacheAssets(options: buildHelper.BuildOptions) { }); if (metaFiles.length > 0) { - useTagCache = true; + shouldUseTagCache = true; const providerPath = path.join(outputDir, "dynamodb-provider"); // Copy open-next.config.mjs into the bundle @@ -270,5 +270,5 @@ export function createCacheAssets(options: buildHelper.BuildOptions) { } } - return { useTagCache, metaFiles }; + return { shouldUseTagCache, metaFiles }; } diff --git a/packages/core/src/build/createServerBundle.ts b/packages/core/src/build/createServerBundle.ts index d22ba6cb..a68f3da9 100644 --- a/packages/core/src/build/createServerBundle.ts +++ b/packages/core/src/build/createServerBundle.ts @@ -31,14 +31,16 @@ interface CodeCustomization { // This will only apply to OpenNext code. additionalPlugins?: (contentUpdater: ContentUpdater) => Plugin[]; useEdgeConfig?: boolean; - externals?: string[]; + // The esbuild externals for the server bundle. Mandatory: every adapter has to + // declare what it keeps external instead of relying on an implicit default. + externals: string[]; banner?: string[] | ((name: string) => string[]); bundleDefaults?: BundleDefaults; } export async function createServerBundle( options: buildHelper.BuildOptions, - codeCustomization?: CodeCustomization, + codeCustomization: CodeCustomization, nextOutputs?: NextAdapterOutputs ) { const { config } = options; @@ -56,7 +58,7 @@ export async function createServerBundle( const routes = fnOptions.routes; routes.forEach((route) => foundRoutes.add(route)); if (fnOptions.runtime === "edge") { - await generateEdgeBundle(name, options, fnOptions, undefined, codeCustomization?.bundleDefaults?.edge); + await generateEdgeBundle(name, options, fnOptions, undefined, codeCustomization.bundleDefaults?.edge); } else { await generateBundle(name, options, fnOptions, codeCustomization, nextOutputs); } @@ -128,7 +130,7 @@ async function generateBundle( name: string, options: buildHelper.BuildOptions, fnOptions: SplittedFunctionOptions, - codeCustomization?: CodeCustomization, + codeCustomization: CodeCustomization, nextOutputs?: NextAdapterOutputs ) { const { appPath, appBuildOutputPath, config, outputDir, monorepoRoot } = options; @@ -173,7 +175,7 @@ async function generateBundle( } // Copy open-next.config.mjs - buildHelper.copyOpenNextConfig(options.buildDir, outPackagePath, codeCustomization?.useEdgeConfig ?? false); + buildHelper.copyOpenNextConfig(options.buildDir, outPackagePath, codeCustomization.useEdgeConfig ?? false); // Copy env files buildHelper.copyEnvFile(appBuildOutputPath, packagePath, outputPath); @@ -191,7 +193,7 @@ async function generateBundle( tracedFiles = await copyAdapterFiles(options, name, packagePath, nextOutputs); //TODO: we should load manifests here - const additionalCodePatches = codeCustomization?.additionalCodePatches ?? []; + const additionalCodePatches = codeCustomization.additionalCodePatches ?? []; await applyCodePatches(options, tracedFiles, manifests as ReturnType, [ patches.patchFetchCacheSetMissingWaitUntil, @@ -211,13 +213,13 @@ async function generateBundle( // Next.js app. const overrides = fnOptions.override ?? {}; - const defaultOverrides = codeCustomization?.bundleDefaults?.server; + const defaultOverrides = codeCustomization.bundleDefaults?.server; const disableRouting = config.middleware?.external; const updater = new ContentUpdater(options); - const additionalPlugins = codeCustomization?.additionalPlugins + const additionalPlugins = codeCustomization.additionalPlugins ? codeCustomization.additionalPlugins(updater) : []; @@ -251,14 +253,14 @@ async function generateBundle( name === "default" ? "" : `globalThis.fnName = "${name}";`, ]; const bannerLines = - typeof codeCustomization?.banner === "function" + typeof codeCustomization.banner === "function" ? codeCustomization.banner(name) - : (codeCustomization?.banner ?? defaultBanner); + : (codeCustomization.banner ?? defaultBanner); await buildHelper.esbuildAsync( { entryPoints: [path.join(options.openNextDistDir, "adapters", "server-adapter.js")], - external: codeCustomization?.externals ?? ["next", "./middleware.mjs", "./next-server.runtime.prod.js"], + external: codeCustomization.externals, outfile: path.join(outputPath, packagePath, `index.${outfileExt}`), banner: { js: bannerLines.join(""), From cd8f83e6d6af86efd5d6de6ce559bf16387034e9 Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Sat, 1 Aug 2026 12:25:50 +0200 Subject: [PATCH 15/44] refactor(core): enhance resolve plugin to support dynamic override resolution and improve path handling --- packages/core/src/plugins/resolve.spec.ts | 246 +++++++++++++++------- packages/core/src/plugins/resolve.ts | 189 +++++++---------- 2 files changed, 249 insertions(+), 186 deletions(-) diff --git a/packages/core/src/plugins/resolve.spec.ts b/packages/core/src/plugins/resolve.spec.ts index 034ac947..749e6e34 100644 --- a/packages/core/src/plugins/resolve.spec.ts +++ b/packages/core/src/plugins/resolve.spec.ts @@ -1,9 +1,9 @@ -import { mkdir, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; -import type { PluginBuild } from "esbuild"; -import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { build } from "esbuild"; +import { afterAll, beforeAll, describe, expect, test } from "vitest"; import { openNextResolvePlugin } from "./resolve.js"; @@ -62,73 +62,138 @@ export async function resolveCdnInvalidation(cdnInvalidation) { } `.trim(); -type OnLoadCallback = (args: { path: string }) => Promise<{ contents: string }>; +// The default overrides imported by the fixture above, and the alternatives the +// tests redirect to. +const OVERRIDE_MODULES = [ + "overrides/converters/node.js", + "overrides/converters/edge.js", + "overrides/wrappers/node.js", + "overrides/wrappers/cloudflare-edge.js", + "overrides/tagCache/fs-dev-nextMode.js", + "overrides/queue/direct.js", + "overrides/incrementalCache/fs-dev.js", + "overrides/imageLoader/fs-dev.js", + "overrides/originResolver/pattern-env.js", + "overrides/warmer/dummy.js", + "overrides/proxyExternalRequest/node.js", + "overrides/cdnInvalidation/dummy.js", +]; -function createStubBuild() { - let capturedCb: OnLoadCallback | undefined; - const stub = { - onLoad: (_opts: { filter: RegExp }, cb: OnLoadCallback) => { - capturedCb = cb; - }, - } as unknown as PluginBuild; - return { stub, getCallback: () => capturedCb! }; +// Packages resolved through node_modules for the full-path override cases. +const CORE_PKG_MODULES = [ + "overrides/converters/edge.js", + "overrides/converters/dummy.js", + "overrides/imageLoader/dummy.js", + "overrides/originResolver/dummy.js", + "overrides/proxyExternalRequest/fetch.js", +]; +const AWS_PKG_MODULES = [ + "overrides/wrappers/aws-lambda.js", + "overrides/wrappers/aws-lambda-streaming.js", + "overrides/converters/aws-apigw-v2.js", + "overrides/tagCache/dynamodb.js", + "overrides/queue/sqs.js", + "overrides/incrementalCache/s3.js", + "overrides/warmer/aws-lambda.js", + "overrides/cdnInvalidation/cloudfront.js", +]; + +let root: string; + +/** Writes a module exporting a marker identifying it by its path in the fixture. */ +async function writeModule(relPath: string) { + const fullPath = join(root, relPath); + await mkdir(dirname(fullPath), { recursive: true }); + await writeFile(fullPath, `export default "MARKER:${relPath}";`, "utf-8"); } -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"); +/** Marker bundled for an override living next to the fixture `resolve.js`. */ +function local(relPath: string) { + return `MARKER:overrides/${relPath}`; +} + +/** Marker bundled for an override coming from a package in `node_modules`. */ +function pkg(name: string, relPath: string) { + return `MARKER:node_modules/${name}/overrides/${relPath}`; +} + +/** Bundles the fixture with the plugin and returns the generated code. */ +async function bundleWithPlugin(opts: Parameters[0], entry = "entry.js") { + const result = await build({ + entryPoints: [join(root, entry)], + absWorkingDir: root, + bundle: true, + write: false, + format: "esm", + platform: "node", + outfile: join(root, "out.js"), + plugins: [openNextResolvePlugin(opts)], }); + return result.outputFiles[0].text; +} + +describe("openNextResolvePlugin", () => { + beforeAll(async () => { + root = await mkdtemp(join(tmpdir(), "resolve-test-")); + + await mkdir(join(root, "core"), { recursive: true }); + await writeFile(join(root, "core", "resolve.js"), FIXTURE_CONTENT, "utf-8"); + await writeFile(join(root, "entry.js"), `export * from "./core/resolve.js";`, "utf-8"); - afterEach(async () => { - // Clean up the temp directory (go up one level from "core") - await rm(join(fixtureDir, ".."), { recursive: true, force: true }); + for (const mod of OVERRIDE_MODULES) { + await writeModule(mod); + } + for (const [name, modules] of [ + ["@opennextjs/core", CORE_PKG_MODULES], + ["@opennextjs/aws", AWS_PKG_MODULES], + ] as const) { + await mkdir(join(root, "node_modules", name), { recursive: true }); + await writeFile( + join(root, "node_modules", name, "package.json"), + JSON.stringify({ name, type: "module" }), + "utf-8" + ); + for (const mod of modules) { + await writeModule(join("node_modules", name, mod)); + } + } }); - async function runPlugin(opts: Parameters[0]) { - const plugin = openNextResolvePlugin(opts); - const { stub, getCallback } = createStubBuild(); - plugin.setup(stub); - const cb = getCallback(); - return cb({ path: fixturePath }); - } + afterAll(async () => { + await rm(root, { recursive: true, force: true }); + }); test("A - full-path default verbatim: core full path default replaces anchor", async () => { - const result = await runPlugin({ + const contents = await bundleWithPlugin({ overrides: {}, 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(contents).toContain(pkg("@opennextjs/core", "converters/edge.js")); + expect(contents).not.toContain(local("converters/node.js")); }); test("B - cross-package user full aws path wins over core default", async () => { - const result = await runPlugin({ + const contents = await bundleWithPlugin({ 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(contents).toContain(pkg("@opennextjs/aws", "converters/aws-apigw-v2.js")); + expect(contents).not.toContain(pkg("@opennextjs/core", "converters/edge.js")); }); test("C - no-op anchor stays: no override no default keeps relative core path", async () => { - const result = await runPlugin({ + const contents = await bundleWithPlugin({ overrides: {}, defaultOverrides: {}, fnName: "test", }); - expect(result.contents).toContain("../overrides/converters/node.js"); + expect(contents).toContain(local("converters/node.js")); }); test("D - 10-key mixed aws+core full paths all rewritten", async () => { - const result = await runPlugin({ + const contents = await bundleWithPlugin({ overrides: {}, defaultOverrides: { wrapper: "@opennextjs/aws/overrides/wrappers/aws-lambda.js", @@ -144,42 +209,46 @@ describe("openNextResolvePlugin", () => { }, 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/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"); + expect(contents).toContain(pkg("@opennextjs/aws", "wrappers/aws-lambda.js")); + expect(contents).toContain(pkg("@opennextjs/core", "converters/edge.js")); + expect(contents).toContain(pkg("@opennextjs/aws", "tagCache/dynamodb.js")); + expect(contents).toContain(pkg("@opennextjs/aws", "queue/sqs.js")); + expect(contents).toContain(pkg("@opennextjs/aws", "incrementalCache/s3.js")); + expect(contents).toContain(pkg("@opennextjs/core", "imageLoader/dummy.js")); + expect(contents).toContain(pkg("@opennextjs/core", "originResolver/dummy.js")); + expect(contents).toContain(pkg("@opennextjs/aws", "warmer/aws-lambda.js")); + expect(contents).toContain(pkg("@opennextjs/core", "proxyExternalRequest/fetch.js")); + expect(contents).toContain(pkg("@opennextjs/aws", "cdnInvalidation/cloudfront.js")); + // None of the defaults are bundled anymore + expect(contents).not.toContain(local("wrappers/node.js")); + expect(contents).not.toContain(local("tagCache/fs-dev-nextMode.js")); + expect(contents).not.toContain(local("incrementalCache/fs-dev.js")); }); test("E - deprecated cloudflare bare name becomes legacy relative core path", async () => { - const result = await runPlugin({ + const contents = await bundleWithPlugin({ overrides: { wrapper: "cloudflare" }, defaultOverrides: {}, fnName: "test", }); - expect(result.contents).toContain("../overrides/wrappers/cloudflare-edge.js"); - expect(result.contents).not.toContain("cloudflare.js"); + expect(contents).toContain(local("wrappers/cloudflare-edge.js")); + expect(contents).not.toContain(local("wrappers/node.js")); }); 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({ + const contents = await bundleWithPlugin({ overrides: { converter: fnOverride }, defaultOverrides: { converter: "@opennextjs/core/overrides/converters/edge.js" }, fnName: "test", }); - expect(result.contents).toContain("@opennextjs/core/overrides/converters/dummy.js"); - expect(result.contents).not.toContain("@opennextjs/core/overrides/converters/edge.js"); + expect(contents).toContain(pkg("@opennextjs/core", "converters/dummy.js")); + expect(contents).not.toContain(pkg("@opennextjs/core", "converters/edge.js")); }); test("G - AWS server defaults produce aws full paths", async () => { - const result = await runPlugin({ + const contents = await bundleWithPlugin({ overrides: {}, defaultOverrides: { wrapper: "@opennextjs/aws/overrides/wrappers/aws-lambda-streaming.js", @@ -190,42 +259,67 @@ describe("openNextResolvePlugin", () => { }, fnName: "server", }); - 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"); + expect(contents).toContain(pkg("@opennextjs/aws", "wrappers/aws-lambda-streaming.js")); + expect(contents).toContain(pkg("@opennextjs/aws", "converters/aws-apigw-v2.js")); + expect(contents).toContain(pkg("@opennextjs/aws", "incrementalCache/s3.js")); + expect(contents).toContain(pkg("@opennextjs/aws", "tagCache/dynamodb.js")); + expect(contents).toContain(pkg("@opennextjs/aws", "queue/sqs.js")); + // Keys without an override keep their default + expect(contents).toContain(local("imageLoader/fs-dev.js")); }); test("H - bare-name user override becomes legacy relative core path", async () => { - const result = await runPlugin({ + const contents = await bundleWithPlugin({ 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"); + expect(contents).toContain(local("converters/edge.js")); + expect(contents).not.toContain(local("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 }); + test("I - resolvable package specifier is resolved through node_modules", async () => { + await mkdir(join(root, "node_modules", "@test-pkg", "wrapper"), { recursive: true }); await writeFile( - join(pkgDir, "package.json"), + join(root, "node_modules", "@test-pkg", "wrapper", "package.json"), JSON.stringify({ name: "@test-pkg/wrapper", main: "index.js" }), "utf-8" ); - await writeFile(join(pkgDir, "index.js"), "module.exports = {};", "utf-8"); + await writeModule(join("node_modules", "@test-pkg", "wrapper", "index.js")); - const result = await runPlugin({ + const contents = await bundleWithPlugin({ 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"/); + expect(contents).toContain("MARKER:node_modules/@test-pkg/wrapper/index.js"); + expect(contents).not.toContain(local("wrappers/node.js")); + }); + + test("J - overrides of other modules are left alone", async () => { + await writeFile( + join(root, "core", "other.js"), + `export const load = () => import("../overrides/converters/node.js");`, + "utf-8" + ); + await writeFile( + join(root, "entry-other.js"), + `export * from "./core/resolve.js";\nexport * from "./core/other.js";`, + "utf-8" + ); + + const contents = await bundleWithPlugin( + { + overrides: { converter: "edge" }, + defaultOverrides: {}, + fnName: "test", + }, + "entry-other.js" + ); + + // `resolve.js` gets the override, `other.js` keeps importing the default + expect(contents).toContain(local("converters/edge.js")); + expect(contents).toContain(local("converters/node.js")); }); }); diff --git a/packages/core/src/plugins/resolve.ts b/packages/core/src/plugins/resolve.ts index 997c4c53..65d0a3d0 100644 --- a/packages/core/src/plugins/resolve.ts +++ b/packages/core/src/plugins/resolve.ts @@ -1,8 +1,7 @@ import { readFile } from "node:fs/promises"; import { createRequire } from "node:module"; -import { dirname, relative } from "node:path"; +import { dirname, join, relative } from "node:path"; -import { type Edit, Lang, parse } from "@ast-grep/napi"; import chalk from "chalk"; import type { Plugin } from "esbuild"; @@ -55,33 +54,16 @@ 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", -}; +// `core/resolve.js` lazily imports every default override with a relative specifier +// of the form `../overrides//.js`, and each override key owns exactly +// one folder (see `nameToFolder`). Rewriting the specifier of a folder is therefore +// enough to swap in the configured override, whatever the default file is named. +const resolveModuleFilter = getCrossPlatformPathRegex("core/resolve.js"); -// 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", -}; +/** Matches the specifier of the override lazily imported from the given folder. */ +function getOverrideImportRegex(folder: string): RegExp { + return new RegExp(String.raw`(["'])\.\./overrides/${folder}/[^"']+\1`); +} export type BundleType = | "server" @@ -101,6 +83,28 @@ function isFullPath(s: string): boolean { return s.startsWith("@") || s.includes("/"); } +/** + * Turns a package specifier into a path relative to `core/resolve.js`, so that esbuild + * resolves the override itself - it is the only way for it to pick up the module type + * (`type: "module"`) of the package the override comes from. + * + * Overrides live in the adapter packages the app depends on, which are not necessarily + * reachable from `core` itself (they are not, with an isolated node_modules layout), so + * resolution is attempted from the app as well. The specifier is returned as-is when it + * cannot be resolved, leaving esbuild to report the failure. + */ +function toRelativePath(specifier: string, resolvePath: string, appDir: string): string { + for (const from of [resolvePath, join(appDir, "index.js")]) { + try { + const resolved = createRequire(from).resolve(specifier); + return `./${relative(dirname(resolvePath), resolved).replaceAll("\\", "/")}`; + } catch { + // Not resolvable from there, try the next one + } + } + return specifier; +} + /** * @param opts.overrides - The name of the overrides to use * @returns @@ -114,94 +118,59 @@ export function openNextResolvePlugin({ 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 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]; - let overrideValue = configValue ?? defaultValue; - if (!overrideValue) { - continue; - } - - const key = overrideName as OverrideKey; - const folder = nameToFolder[key]; - if (!folder) { - continue; - } - - if (overrideName === "wrapper" && overrideValue === "cloudflare") { - overrideValue = "cloudflare-edge"; - } - - let targetPath: string; - if (typeof overrideValue === "string") { - if (isFullPath(overrideValue)) { - try { - const resolved = createRequire(args.path).resolve(overrideValue); - targetPath = "./" + relative(dirname(args.path), resolved); - } catch { - 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 - } - fallbackKeys.push({ key, targetPath }); + + // Maps the overrides folder to the specifier that should be imported instead + // of the default one. Computed once, the config cannot change during a build. + const redirects = new Map(); + 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; } - // Commit all ast-grep edits at once (no interleaving). - if (edits.length > 0) { - contents = astRoot.commitEdits(edits); + const folder = nameToFolder[overrideName as OverrideKey]; + if (!folder) { + continue; } - // 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); - } + if (overrideName === "wrapper" && overrideValue === "cloudflare") { + overrideValue = "cloudflare-edge"; + } + + if (typeof overrideValue !== "string") { + // Lazy loaded overrides are inlined in the config, only the default + // import has to be dropped - the dummy is never actually used. + redirects.set(folder, `@opennextjs/core/overrides/${folder}/dummy.js`); + } else if (isFullPath(overrideValue)) { + redirects.set(folder, overrideValue); + } else { + redirects.set(folder, `../overrides/${folder}/${overrideValue}.js`); + } + } + + if (redirects.size === 0) { + return; + } + + const appDir = build.initialOptions.absWorkingDir ?? process.cwd(); + + build.onLoad({ filter: resolveModuleFilter }, async (args) => { + let contents = await readFile(args.path, "utf-8"); + + for (const [folder, specifier] of redirects) { + const targetPath = specifier.startsWith(".") + ? specifier + : toRelativePath(specifier, args.path, appDir); + logger.debug(chalk.blue(`Resolving the ${folder} override to "${targetPath}"`)); + // JSON encoded: the path can contain characters to escape (Windows separators) + contents = contents.replace(getOverrideImportRegex(folder), () => JSON.stringify(targetPath)); } - return { - contents, - }; + return { contents }; }); }, }; From a63c22b29d824bbde318b4d0ab78a1046587b301 Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Wed, 15 Jul 2026 23:38:48 +0200 Subject: [PATCH 16/44] wip --- create-cloudflare/next/package.json | 2 +- examples-cloudflare/playground16/package.json | 2 +- packages/aws/package.json | 2 +- packages/cloudflare/package.json | 4 +- packages/core/package.json | 3 +- packages/core/src/adapters/config/index.ts | 16 +- packages/core/src/adapters/config/util.ts | 110 +--- packages/core/src/build/adapter.spec.ts | 13 + packages/core/src/build/adapter.ts | 16 +- .../core/src/build/createRoutingConfig.ts | 41 ++ packages/core/src/build/createServerBundle.ts | 6 + .../build/middleware/buildNodeMiddleware.ts | 5 + packages/core/src/core/routing/matcher.ts | 430 ------------ packages/core/src/core/routing/middleware.ts | 20 +- .../core/src/core/routing/routeMatcher.ts | 84 --- packages/core/src/core/routingHandler.ts | 463 +++++++------ packages/core/src/plugins/edge.ts | 21 +- packages/core/src/types/adapter.ts | 19 + .../tests/build/createRoutingConfig.test.ts | 48 ++ .../tests/core/routing/matcher.test.ts | 622 ------------------ .../tests/core/routing/routeMatcher.test.ts | 197 ------ .../tests/core/routing/routingHandler.test.ts | 101 +++ pnpm-lock.yaml | 386 ++--------- 23 files changed, 627 insertions(+), 1984 deletions(-) create mode 100644 packages/core/src/build/createRoutingConfig.ts delete mode 100644 packages/core/src/core/routing/matcher.ts delete mode 100644 packages/core/src/core/routing/routeMatcher.ts create mode 100644 packages/tests-unit/tests/build/createRoutingConfig.test.ts delete mode 100644 packages/tests-unit/tests/core/routing/matcher.test.ts delete mode 100644 packages/tests-unit/tests/core/routing/routeMatcher.test.ts create mode 100644 packages/tests-unit/tests/core/routing/routingHandler.test.ts diff --git a/create-cloudflare/next/package.json b/create-cloudflare/next/package.json index 082d5397..7e8daff2 100644 --- a/create-cloudflare/next/package.json +++ b/create-cloudflare/next/package.json @@ -14,7 +14,7 @@ }, "dependencies": { "@opennextjs/cloudflare": "^1.17.1", - "next": "16.1.4", + "next": "16.2.1", "react": "19.1.4", "react-dom": "19.1.4" }, diff --git a/examples-cloudflare/playground16/package.json b/examples-cloudflare/playground16/package.json index 9373c10b..eb62dbb2 100644 --- a/examples-cloudflare/playground16/package.json +++ b/examples-cloudflare/playground16/package.json @@ -17,7 +17,7 @@ "cf-typegen": "wrangler types --env-interface CloudflareEnv" }, "dependencies": { - "next": "16.1.4", + "next": "16.2.1", "react": "19.2.3", "react-dom": "19.2.3" }, diff --git a/packages/aws/package.json b/packages/aws/package.json index 5a476c42..27ab43e6 100644 --- a/packages/aws/package.json +++ b/packages/aws/package.json @@ -75,6 +75,6 @@ "typescript": "catalog:" }, "peerDependencies": { - "next": "^16.0.10" + "next": "16.2.1" } } diff --git a/packages/cloudflare/package.json b/packages/cloudflare/package.json index 71bcd44b..781861da 100644 --- a/packages/cloudflare/package.json +++ b/packages/cloudflare/package.json @@ -72,14 +72,14 @@ "esbuild": "catalog:", "globals": "catalog:", "mock-fs": "catalog:", - "next": "catalog:", + "next": "catalog:aws", "picomatch": "^4.0.2", "rimraf": "catalog:", "typescript": "catalog:", "vitest": "catalog:" }, "peerDependencies": { - "next": "^16.0.10", + "next": "16.2.1", "wrangler": "catalog:" } } diff --git a/packages/core/package.json b/packages/core/package.json index 87e65e29..51fed7f9 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -48,6 +48,7 @@ }, "dependencies": { "@ast-grep/napi": "^0.40.5", + "@next/routing": "16.2.1", "@node-minify/core": "^8.0.6", "@node-minify/terser": "^8.0.6", "@tsconfig/node24": "^24.0.4", @@ -69,6 +70,6 @@ "vitest": "catalog:" }, "peerDependencies": { - "next": "^16.0.10" + "next": "16.2.1" } } diff --git a/packages/core/src/adapters/config/index.ts b/packages/core/src/adapters/config/index.ts index 15011af5..abb333e5 100644 --- a/packages/core/src/adapters/config/index.ts +++ b/packages/core/src/adapters/config/index.ts @@ -3,18 +3,13 @@ import path from "node:path"; import { debug } from "../logger"; import { - loadAppPathRoutesManifest, - loadAppPathsManifest, - loadAppPathsManifestKeys, loadBuildId, loadConfig, - loadConfigHeaders, loadFunctionsConfigManifest, loadHtmlPages, loadMiddlewareManifest, - loadPagesManifest, loadPrerenderManifest, - loadRoutesManifest, + loadRoutingConfig, } from "./util.js"; export const NEXT_DIR = path.join(__dirname, ".next"); @@ -24,17 +19,10 @@ debug({ NEXT_DIR, OPEN_NEXT_DIR }); export const NextConfig = /* @__PURE__ */ loadConfig(NEXT_DIR); export const BuildId = /* @__PURE__ */ loadBuildId(NEXT_DIR); +export const RoutingConfig = /* @__PURE__ */ loadRoutingConfig(NEXT_DIR); export const HtmlPages = /* @__PURE__ */ loadHtmlPages(NEXT_DIR); -// export const PublicAssets = loadPublicAssets(OPEN_NEXT_DIR); -export const RoutesManifest = /* @__PURE__ */ loadRoutesManifest(NEXT_DIR); -export const ConfigHeaders = /* @__PURE__ */ loadConfigHeaders(NEXT_DIR); export const PrerenderManifest = /* @__PURE__ */ loadPrerenderManifest(NEXT_DIR); -export const PagesManifest = /* @__PURE__ */ loadPagesManifest(NEXT_DIR); -export const AppPathsManifestKeys = /* @__PURE__ */ loadAppPathsManifestKeys(NEXT_DIR); export const MiddlewareManifest = /* @__PURE__ */ loadMiddlewareManifest(NEXT_DIR); -export const AppPathsManifest = /* @__PURE__ */ loadAppPathsManifest(NEXT_DIR); -export const AppPathRoutesManifest = /* @__PURE__ */ loadAppPathRoutesManifest(NEXT_DIR); - export const FunctionsConfigManifest = /* @__PURE__ */ loadFunctionsConfigManifest(NEXT_DIR); process.env.NEXT_BUILD_ID = BuildId; diff --git a/packages/core/src/adapters/config/util.ts b/packages/core/src/adapters/config/util.ts index 07907f22..52239552 100644 --- a/packages/core/src/adapters/config/util.ts +++ b/packages/core/src/adapters/config/util.ts @@ -1,13 +1,12 @@ import fs from "node:fs"; import path from "node:path"; -import type { PublicFiles } from "@/types/adapter"; +import type { PublicFiles, RuntimeRoutingConfig } from "@/types/adapter"; import type { FunctionsConfigManifest, MiddlewareManifest, NextConfig, PrerenderManifest, - RoutesManifest, } from "@/types/next-types"; export function loadConfig(nextDir: string) { @@ -16,64 +15,38 @@ export function loadConfig(nextDir: string) { const { config } = JSON.parse(json); return config as NextConfig; } + export function loadBuildId(nextDir: string) { - const filePath = path.join(nextDir, "BUILD_ID"); - return fs.readFileSync(filePath, "utf-8").trim(); + return fs.readFileSync(path.join(nextDir, "BUILD_ID"), "utf-8").trim(); +} + +export function loadRoutingConfig(nextDir: string): RuntimeRoutingConfig { + const json = fs.readFileSync(path.join(nextDir, "open-next-routing.json"), "utf-8"); + return JSON.parse(json) as RuntimeRoutingConfig; } export function loadPagesManifest(nextDir: string) { - const filePath = path.join(nextDir, "server/pages-manifest.json"); - const json = fs.readFileSync(filePath, "utf-8"); + const json = fs.readFileSync(path.join(nextDir, "server/pages-manifest.json"), "utf-8"); return JSON.parse(json) as Record; } export function loadHtmlPages(nextDir: string) { return Object.entries(loadPagesManifest(nextDir)) - .filter(([_, value]) => (value as string).endsWith(".html")) - .map(([key]) => key); -} - -export function loadPublicAssets(openNextDir: string) { - const filePath = path.join(openNextDir, "public-files.json"); - const json = fs.readFileSync(filePath, "utf-8"); - return JSON.parse(json) as PublicFiles; + .filter(([, value]) => value.endsWith(".html")) + .map(([pathname]) => pathname); } -export function loadRoutesManifest(nextDir: string) { - const filePath = path.join(nextDir, "routes-manifest.json"); - const json = fs.readFileSync(filePath, "utf-8"); - const routesManifest = JSON.parse(json) as RoutesManifest; - - const _dataRoutes = routesManifest.dataRoutes ?? []; - const dataRoutes = { - static: _dataRoutes.filter((r) => r.routeKeys === undefined), - dynamic: _dataRoutes.filter((r) => r.routeKeys !== undefined), - }; - - return { - basePath: routesManifest.basePath, - rewrites: Array.isArray(routesManifest.rewrites) - ? { beforeFiles: [], afterFiles: routesManifest.rewrites, fallback: [] } - : { - beforeFiles: routesManifest.rewrites.beforeFiles ?? [], - afterFiles: routesManifest.rewrites.afterFiles ?? [], - fallback: routesManifest.rewrites.fallback ?? [], - }, - redirects: routesManifest.redirects ?? [], - routes: { - static: routesManifest.staticRoutes ?? [], - dynamic: routesManifest.dynamicRoutes ?? [], - data: dataRoutes, - }, - locales: routesManifest.i18n?.locales ?? [], - }; +export function loadAppPathsManifest(nextDir: string) { + const filePath = path.join(nextDir, "server/app-paths-manifest.json"); + return JSON.parse(fs.existsSync(filePath) ? fs.readFileSync(filePath, "utf-8") : "{}") as Record< + string, + string + >; } -export function loadConfigHeaders(nextDir: string) { - const filePath = path.join(nextDir, "routes-manifest.json"); - const json = fs.readFileSync(filePath, "utf-8"); - const routesManifest = JSON.parse(json) as RoutesManifest; - return routesManifest.headers; +export function loadPublicAssets(openNextDir: string) { + const json = fs.readFileSync(path.join(openNextDir, "public-files.json"), "utf-8"); + return JSON.parse(json) as PublicFiles; } export function loadPrerenderManifest(nextDir: string): PrerenderManifest | undefined { @@ -81,54 +54,19 @@ export function loadPrerenderManifest(nextDir: string): PrerenderManifest | unde if (!fs.existsSync(filePath)) { return undefined; } - const json = fs.readFileSync(filePath, "utf-8"); - return JSON.parse(json); -} - -export function loadAppPathsManifest(nextDir: string) { - const appPathsManifestPath = path.join(nextDir, "server/app-paths-manifest.json"); - const appPathsManifestJson = fs.existsSync(appPathsManifestPath) - ? fs.readFileSync(appPathsManifestPath, "utf-8") - : "{}"; - return JSON.parse(appPathsManifestJson) as Record; -} - -export function loadAppPathRoutesManifest(nextDir: string): Record { - const appPathRoutesManifestPath = path.join(nextDir, "app-path-routes-manifest.json"); - if (fs.existsSync(appPathRoutesManifestPath)) { - return JSON.parse(fs.readFileSync(appPathRoutesManifestPath, "utf-8")); - } - return {}; -} - -export function loadAppPathsManifestKeys(nextDir: string) { - const appPathsManifest = loadAppPathsManifest(nextDir); - return Object.keys(appPathsManifest).map((key) => { - // Remove parallel route - let cleanedKey = key.replace(/\/@[^/]+/g, ""); - - // Remove group routes - cleanedKey = cleanedKey.replace(/\/\((?!\.)[^)]*\)/g, ""); - - // Remove /page suffix - cleanedKey = cleanedKey.replace(/\/page$/g, ""); - // We need to check if the cleaned key is empty because it means it's the root path - return cleanedKey === "" ? "/" : cleanedKey; - }); + return JSON.parse(fs.readFileSync(filePath, "utf-8")); } export function loadMiddlewareManifest(nextDir: string) { - const filePath = path.join(nextDir, "server/middleware-manifest.json"); - const json = fs.readFileSync(filePath, "utf-8"); + const json = fs.readFileSync(path.join(nextDir, "server/middleware-manifest.json"), "utf-8"); return JSON.parse(json) as MiddlewareManifest; } export function loadFunctionsConfigManifest(nextDir: string) { - const filePath = path.join(nextDir, "server/functions-config-manifest.json"); try { - const json = fs.readFileSync(filePath, "utf-8"); + const json = fs.readFileSync(path.join(nextDir, "server/functions-config-manifest.json"), "utf-8"); return JSON.parse(json) as FunctionsConfigManifest; - } catch (e) { + } catch { return { functions: {}, version: 1 }; } } diff --git a/packages/core/src/build/adapter.spec.ts b/packages/core/src/build/adapter.spec.ts index dfdb54f1..ddf788af 100644 --- a/packages/core/src/build/adapter.spec.ts +++ b/packages/core/src/build/adapter.spec.ts @@ -43,6 +43,10 @@ vi.mock("./createMiddleware.js", () => ({ createMiddleware: vi.fn(), })); +vi.mock("./createRoutingConfig.js", () => ({ + createRoutingConfig: vi.fn(), +})); + vi.mock("./createAssets.js", () => ({ createStaticAssets: vi.fn(), createCacheAssets: vi.fn(), @@ -187,6 +191,15 @@ describe("buildAdapter", () => { expect(typeof adapter.onBuildComplete).toBe("function"); }); + test("rejects a Next.js version that does not match the routing package", async () => { + const adapter = buildAdapter(() => ({})); + const nextConfig = { experimental: {}, images: {} } as BuildCompleteContext["config"]; + + await expect( + adapter.modifyConfig(nextConfig, { phase: "production", nextVersion: "16.1.4" }) + ).rejects.toThrow("OpenNext routing requires next@16.2.1"); + }); + test("modifyConfig calls compileOpenNextConfig with compileEdge: true, then callback", async () => { const mockCallback = vi.fn(() => ({ serverBundle })); const adapter = buildAdapter(mockCallback); diff --git a/packages/core/src/build/adapter.ts b/packages/core/src/build/adapter.ts index 6ca36b41..f1c7c0db 100644 --- a/packages/core/src/build/adapter.ts +++ b/packages/core/src/build/adapter.ts @@ -8,7 +8,7 @@ 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"; +import type { NextAdapterOutputs, NextAdapterRouting } from "../types/adapter.js"; import type { NextConfig } from "../types/next-types.js"; import type { OpenNextConfig } from "../types/open-next.js"; @@ -19,6 +19,7 @@ import { createCacheAssets, createStaticAssets } from "./createAssets.js"; import { createImageOptimizationBundle } from "./createImageOptimizationBundle.js"; import { createMiddleware } from "./createMiddleware.js"; import { createRevalidationBundle } from "./createRevalidationBundle.js"; +import { createRoutingConfig } from "./createRoutingConfig.js"; import { createServerBundle } from "./createServerBundle.js"; import { createWarmerBundle } from "./createWarmerBundle.js"; import { buildOpenNextOutput } from "./generateOutput.js"; @@ -33,13 +34,14 @@ const require = createRequire(import.meta.url); * The parameter type for onBuildComplete. */ export type BuildCompleteContext = { - routes: unknown; + routing: NextAdapterRouting; outputs: NextAdapterOutputs; projectDir: string; repoRoot: string; distDir: string; config: NextConfig; nextVersion: string; + buildId: string; }; /** @@ -47,7 +49,7 @@ export type BuildCompleteContext = { */ export type NextAdapter = { name: string; - modifyConfig: (config: NextConfig, { phase }: { phase: string }) => Promise; + modifyConfig: (config: NextConfig, context: { phase: string; nextVersion?: string }) => Promise; onBuildComplete: (props: BuildCompleteContext) => Promise; }; @@ -107,7 +109,12 @@ export function buildAdapter( return { name: "OpenNext", - async modifyConfig(nextConfig, { phase: _phase }) { + async modifyConfig(nextConfig, { phase: _phase, nextVersion }) { + if (nextVersion && nextVersion !== "16.2.1") { + throw new Error( + `OpenNext routing requires next@16.2.1 and @next/routing@16.2.1; received next@${nextVersion}.` + ); + } // Step 1: Compile OpenNext config with edge support, fallback on failure let result: { config: OpenNextConfig; buildDir: string }; try { @@ -182,6 +189,7 @@ export function buildAdapter( // Step 1: Save debug output addDebugFile(buildOpts, "outputs.json", ctx); + createRoutingConfig(buildOpts, ctx); // Step 2: Call beforeServerBundle hook await adapterOptions.beforeServerBundle?.(buildOpts, config); diff --git a/packages/core/src/build/createRoutingConfig.ts b/packages/core/src/build/createRoutingConfig.ts new file mode 100644 index 00000000..3031ec8b --- /dev/null +++ b/packages/core/src/build/createRoutingConfig.ts @@ -0,0 +1,41 @@ +import fs from "node:fs"; +import path from "node:path"; + +import type { RuntimeRoutingConfig } from "../types/adapter.js"; + +import type { BuildCompleteContext } from "./adapter.js"; +import type * as buildHelper from "./helper.js"; + +const EXECUTABLE_OUTPUT_TYPES = ["pages", "pagesApi", "appPages", "appRoutes"] as const; +const PATHNAME_OUTPUT_TYPES = [...EXECUTABLE_OUTPUT_TYPES, "staticFiles"] as const; + +export function createRoutingConfig( + options: buildHelper.BuildOptions, + context: BuildCompleteContext +): RuntimeRoutingConfig { + const routeIndex: RuntimeRoutingConfig["routeIndex"] = {}; + + for (const outputType of EXECUTABLE_OUTPUT_TYPES) { + for (const output of context.outputs[outputType]) { + routeIndex[output.pathname] = { + type: outputType === "appPages" ? "app" : outputType === "appRoutes" ? "route" : "page", + isFallback: false, + }; + } + } + + const pathnames = PATHNAME_OUTPUT_TYPES.flatMap((outputType) => + (context.outputs[outputType] ?? []).map((output) => output.pathname) + ); + const routingConfig: RuntimeRoutingConfig = { + buildId: context.buildId, + routes: context.routing, + pathnames, + routeIndex, + }; + + const routingConfigPath = path.join(options.appBuildOutputPath, ".next", "open-next-routing.json"); + fs.writeFileSync(routingConfigPath, JSON.stringify(routingConfig)); + + return routingConfig; +} diff --git a/packages/core/src/build/createServerBundle.ts b/packages/core/src/build/createServerBundle.ts index a68f3da9..fc14a5c7 100644 --- a/packages/core/src/build/createServerBundle.ts +++ b/packages/core/src/build/createServerBundle.ts @@ -153,6 +153,7 @@ async function generateBundle( // Normal cache fs.copyFileSync(path.join(options.buildDir, `cache.${ext}`), path.join(outPackagePath, "cache.cjs")); // Composable cache + fs.mkdirSync(path.join(outPackagePath, ".next"), { recursive: true }); fs.copyFileSync( path.join(options.buildDir, `composable-cache.${ext}`), path.join(outPackagePath, "composable-cache.cjs") @@ -174,6 +175,11 @@ async function generateBundle( copyMiddlewareResources(options, middlewareManifest.middleware["/"], outPackagePath); } + fs.copyFileSync( + path.join(options.appBuildOutputPath, ".next", "open-next-routing.json"), + path.join(outPackagePath, ".next", "open-next-routing.json") + ); + // Copy open-next.config.mjs buildHelper.copyOpenNextConfig(options.buildDir, outPackagePath, codeCustomization.useEdgeConfig ?? false); diff --git a/packages/core/src/build/middleware/buildNodeMiddleware.ts b/packages/core/src/build/middleware/buildNodeMiddleware.ts index 8f1957c4..f5e58bfb 100644 --- a/packages/core/src/build/middleware/buildNodeMiddleware.ts +++ b/packages/core/src/build/middleware/buildNodeMiddleware.ts @@ -27,6 +27,11 @@ export async function buildExternalNodeMiddleware( } const outputPath = path.join(outputDir, "middleware"); fs.mkdirSync(outputPath, { recursive: true }); + fs.mkdirSync(path.join(outputPath, ".next"), { recursive: true }); + fs.copyFileSync( + path.join(appBuildOutputPath, ".next", "open-next-routing.json"), + path.join(outputPath, ".next", "open-next-routing.json") + ); // Copy open-next.config.mjs buildHelper.copyOpenNextConfig( diff --git a/packages/core/src/core/routing/matcher.ts b/packages/core/src/core/routing/matcher.ts deleted file mode 100644 index f91816a1..00000000 --- a/packages/core/src/core/routing/matcher.ts +++ /dev/null @@ -1,430 +0,0 @@ -import type { Match, MatchFunction, PathFunction } from "path-to-regexp"; -import { compile, match } from "path-to-regexp"; - -import { NextConfig } from "@/config/index"; -import type { - Header, - PrerenderManifest, - RedirectDefinition, - RewriteDefinition, - RouteHas, -} from "@/types/next-types"; -import type { InternalEvent, InternalResult } from "@/types/open-next"; -import { normalizeRepeatedSlashes } from "@/utils/normalize-path"; -import { emptyReadableStream, toReadableStream } from "@/utils/stream"; - -import { debug } from "../../adapters/logger"; - -import { handleLocaleRedirect, localizePath } from "./i18n"; -import { dynamicRouteMatcher, staticRouteMatcher } from "./routeMatcher"; -import { - constructNextUrl, - convertFromQueryString, - convertToQueryString, - escapeRegex, - getUrlParts, - isExternal, - unescapeRegex, -} from "./util"; - -const routeHasMatcher = - ( - headers: Record, - cookies: Record, - query: Record - ) => - (redirect: RouteHas): boolean => { - switch (redirect.type) { - case "header": - return ( - !!headers?.[redirect.key.toLowerCase()] && - new RegExp(redirect.value ?? "").test(headers[redirect.key.toLowerCase()] ?? "") - ); - case "cookie": - return ( - !!cookies?.[redirect.key] && new RegExp(redirect.value ?? "").test(cookies[redirect.key] ?? "") - ); - case "query": - return query[redirect.key] && Array.isArray(redirect.value) - ? redirect.value.reduce( - (prev, current) => prev || new RegExp(current).test(query[redirect.key] as string), - false - ) - : new RegExp(redirect.value ?? "").test((query[redirect.key] as string | undefined) ?? ""); - case "host": - return headers?.host !== "" && new RegExp(redirect.value ?? "").test(headers.host); - default: - return false; - } - }; - -function checkHas(matcher: ReturnType, has?: RouteHas[], inverted = false) { - return has - ? has.reduce((acc, cur) => { - if (acc === false) return false; - return inverted ? !matcher(cur) : matcher(cur); - }, true) - : true; -} - -const getParamsFromSource = (source: MatchFunction) => (value: string) => { - debug("value", value); - const _match = source(value); - return _match ? _match.params : {}; -}; - -const computeParamHas = - ( - headers: Record, - cookies: Record, - query: Record - ) => - (has: RouteHas): object => { - if (!has.value) return {}; - const matcher = new RegExp(`^${has.value}$`); - const fromSource = (value: string) => { - const matches = value.match(matcher); - return matches?.groups ?? {}; - }; - switch (has.type) { - case "header": - return fromSource(headers[has.key.toLowerCase()] ?? ""); - case "cookie": - return fromSource(cookies[has.key] ?? ""); - case "query": - return Array.isArray(query[has.key]) - ? fromSource((query[has.key] as string[]).join(",")) - : fromSource((query[has.key] as string) ?? ""); - case "host": - return fromSource(headers.host ?? ""); - } - }; - -function convertMatch(match: Match, toDestination: PathFunction, destination: string) { - if (!match) { - return destination; - } - - const { params } = match; - const isUsingParams = Object.keys(params).length > 0; - return isUsingParams ? toDestination(params) : destination; -} - -export function getNextConfigHeaders( - event: InternalEvent, - configHeaders?: Header[] | undefined -): Record { - if (!configHeaders) { - return {}; - } - - const matcher = routeHasMatcher(event.headers, event.cookies, event.query); - - const requestHeaders: Record = {}; - const localizedRawPath = localizePath(event); - - for (const { headers, has, missing, regex, source, locale } of configHeaders) { - const path = locale === false ? event.rawPath : localizedRawPath; - if (new RegExp(regex).test(path) && checkHas(matcher, has) && checkHas(matcher, missing, true)) { - const fromSource = match(source); - const _match = fromSource(path); - headers.forEach((h) => { - try { - const key = convertMatch(_match, compile(h.key), h.key); - const value = convertMatch(_match, compile(h.value), h.value); - requestHeaders[key] = value; - } catch { - debug(`Error matching header ${h.key} with value ${h.value}`); - requestHeaders[h.key] = h.value; - } - }); - } - } - return requestHeaders; -} - -/** - * TODO: This method currently only check for the first match. - * It should check for all matches for `beforeFiles` and `afterFiles` rewrite - * See https://nextjs.org/docs/app/api-reference/config/next-config-js/rewrites - */ -export function handleRewrites(event: InternalEvent, rewrites: T[]) { - const { rawPath, headers, query, cookies, url } = event; - const localizedRawPath = localizePath(event); - const matcher = routeHasMatcher(headers, cookies, query); - const computeHas = computeParamHas(headers, cookies, query); - const rewrite = rewrites.find((route) => { - const path = route.locale === false ? rawPath : localizedRawPath; - return ( - new RegExp(route.regex).test(path) && - checkHas(matcher, route.has) && - checkHas(matcher, route.missing, true) - ); - }); - let finalQuery = query; - - let rewrittenUrl = url; - const isExternalRewrite = isExternal(rewrite?.destination); - debug("isExternalRewrite", isExternalRewrite); - if (rewrite) { - const { pathname, protocol, hostname, queryString } = getUrlParts(rewrite.destination, isExternalRewrite); - // We need to use a localized path if the rewrite is not locale specific - const pathToUse = rewrite.locale === false ? rawPath : localizedRawPath; - - debug("urlParts", { pathname, protocol, hostname, queryString }); - const toDestinationPath = compile(escapeRegex(pathname, { isPath: true })); - const toDestinationHost = compile(escapeRegex(hostname)); - const toDestinationQuery = compile(escapeRegex(queryString)); - const params = { - // params for the source - ...getParamsFromSource(match(escapeRegex(rewrite.source, { isPath: true })))(pathToUse), - // params for the has - ...rewrite.has?.reduce((acc, cur) => { - return Object.assign(acc, computeHas(cur)); - }, {}), - // params for the missing - ...rewrite.missing?.reduce((acc, cur) => { - return Object.assign(acc, computeHas(cur)); - }, {}), - }; - const isUsingParams = Object.keys(params).length > 0; - let rewrittenQuery = queryString; - let rewrittenHost = hostname; - let rewrittenPath = pathname; - if (isUsingParams) { - rewrittenPath = unescapeRegex(toDestinationPath(params)); - rewrittenHost = unescapeRegex(toDestinationHost(params)); - rewrittenQuery = unescapeRegex(toDestinationQuery(params)); - } - - // We need to strip the locale from the path if it's a local api route - if (NextConfig.i18n && !isExternalRewrite) { - const strippedPathLocale = rewrittenPath.replace( - new RegExp(`^/(${NextConfig.i18n.locales.join("|")})`), - "" - ); - if (strippedPathLocale.startsWith("/api/")) { - rewrittenPath = strippedPathLocale; - } - } - - rewrittenUrl = isExternalRewrite - ? `${protocol}//${rewrittenHost}${rewrittenPath}` - : new URL(rewrittenPath, event.url).href; - - // We merge query params from the source and the destination - finalQuery = { - ...query, - ...convertFromQueryString(rewrittenQuery), - }; - rewrittenUrl += convertToQueryString(finalQuery); - debug("rewrittenUrl", { rewrittenUrl, finalQuery, isUsingParams }); - } - - return { - internalEvent: { - ...event, - query: finalQuery, - rawPath: new URL(rewrittenUrl).pathname, - url: rewrittenUrl, - }, - __rewrite: rewrite, - isExternalRewrite, - }; -} - -// Normalizes repeated slashes in the path e.g. hello//world -> hello/world -// or backslashes to forward slashes. This prevents requests such as //domain -// from invoking the middleware with `request.url === "domain"`. -// See: https://github.com/vercel/next.js/blob/3ecf087f10fdfba4426daa02b459387bc9c3c54f/packages/next/src/server/base-server.ts#L1016-L1020 -function handleRepeatedSlashRedirect(event: InternalEvent): false | InternalResult { - // Redirect `https://example.com//foo` to `https://example.com/foo`. - if (event.rawPath.match(/(\\|\/\/)/)) { - return { - type: event.type, - statusCode: 308, - headers: { - Location: normalizeRepeatedSlashes(new URL(event.url)), - }, - body: emptyReadableStream(), - isBase64Encoded: false, - }; - } - - return false; -} - -function handleTrailingSlashRedirect(event: InternalEvent): false | InternalResult { - // When rawPath is `//domain`, `url.host` would be `domain`. - // https://github.com/opennextjs/opennextjs-aws/issues/355 - const url = new URL(event.rawPath, "http://localhost"); - - if ( - // Someone is trying to redirect to a different origin, let's not do that - url.host !== "localhost" || - NextConfig.skipTrailingSlashRedirect || - // We should not apply trailing slash redirect to API routes - event.rawPath.startsWith("/api/") - ) { - return false; - } - - const emptyBody = emptyReadableStream(); - - if ( - NextConfig.trailingSlash && - !event.headers["x-nextjs-data"] && - !event.rawPath.endsWith("/") && - !event.rawPath.match(/[\w-]+\.[\w]+$/g) - ) { - const headersLocation = event.url.split("?"); - return { - type: event.type, - statusCode: 308, - headers: { - Location: `${headersLocation[0]}/${headersLocation[1] ? `?${headersLocation[1]}` : ""}`, - }, - body: emptyBody, - isBase64Encoded: false, - }; - } - if (!NextConfig.trailingSlash && event.rawPath.endsWith("/") && event.rawPath !== "/") { - const headersLocation = event.url.split("?"); - return { - type: event.type, - statusCode: 308, - headers: { - Location: `${headersLocation[0].replace(/\/$/, "")}${ - headersLocation[1] ? `?${headersLocation[1]}` : "" - }`, - }, - body: emptyBody, - isBase64Encoded: false, - }; - } - return false; -} - -export function handleRedirects( - event: InternalEvent, - redirects: RedirectDefinition[] -): InternalResult | undefined { - const repeatedSlashRedirect = handleRepeatedSlashRedirect(event); - if (repeatedSlashRedirect) return repeatedSlashRedirect; - - const trailingSlashRedirect = handleTrailingSlashRedirect(event); - if (trailingSlashRedirect) return trailingSlashRedirect; - - const localeRedirect = handleLocaleRedirect(event); - if (localeRedirect) return localeRedirect; - - const { internalEvent, __rewrite } = handleRewrites( - event, - redirects.filter((r) => !r.internal) - ); - if (__rewrite && !__rewrite.internal) { - return { - type: event.type, - statusCode: __rewrite.statusCode ?? 308, - headers: { - Location: internalEvent.url, - }, - body: emptyReadableStream(), - isBase64Encoded: false, - }; - } -} - -export function fixDataPage(internalEvent: InternalEvent, buildId: string): InternalEvent | InternalResult { - const { rawPath, query } = internalEvent; - const basePath = NextConfig.basePath ?? ""; - const dataPattern = `${basePath}/_next/data/${buildId}`; - // Return 404 for data requests that don't match the buildId - if (rawPath.startsWith("/_next/data") && !rawPath.startsWith(dataPattern)) { - return { - type: internalEvent.type, - statusCode: 404, - body: toReadableStream("{}"), - headers: { - "Content-Type": "application/json", - }, - isBase64Encoded: false, - }; - } - - if (rawPath.startsWith(dataPattern) && rawPath.endsWith(".json")) { - const newPath = `${basePath}${rawPath - .slice(dataPattern.length, -".json".length) - .replace(/^\/index$/, "/")}`; - query.__nextDataReq = "1"; - - return { - ...internalEvent, - rawPath: newPath, - query, - url: new URL(`${newPath}${convertToQueryString(query)}`, internalEvent.url).href, - }; - } - return internalEvent; -} - -export function handleFallbackFalse( - internalEvent: InternalEvent, - prerenderManifest?: PrerenderManifest -): { event: InternalEvent; isISR: boolean } { - const { rawPath } = internalEvent; - const { dynamicRoutes = {}, routes = {} } = prerenderManifest ?? {}; - const prerenderedFallbackRoutes = Object.entries(dynamicRoutes).filter( - ([, { fallback }]) => fallback === false - ); - const routeFallback = prerenderedFallbackRoutes.some(([, { routeRegex }]) => { - const routeRegexExp = new RegExp(routeRegex); - return routeRegexExp.test(rawPath); - }); - const locales = NextConfig.i18n?.locales; - const routesAlreadyHaveLocale = - locales?.includes(rawPath.split("/")[1]) || - // If we don't use locales, we don't need to add the default locale - locales === undefined; - let localizedPath = routesAlreadyHaveLocale ? rawPath : `/${NextConfig.i18n?.defaultLocale}${rawPath}`; - // We need to remove the trailing slash if it exists - if ( - // Not if localizedPath is "/" tho, because that would not make it find `isPregenerated` below since it would be try to match an empty string. - localizedPath !== "/" && - NextConfig.trailingSlash && - localizedPath.endsWith("/") - ) { - localizedPath = localizedPath.slice(0, -1); - } - const matchedStaticRoute = staticRouteMatcher(localizedPath); - const prerenderedFallbackRoutesName = prerenderedFallbackRoutes.map(([name]) => name); - const matchedDynamicRoute = dynamicRouteMatcher(localizedPath).filter( - ({ route }) => !prerenderedFallbackRoutesName.includes(route) - ); - - const isPregenerated = Object.keys(routes).includes(localizedPath); - if ( - routeFallback && - !isPregenerated && - matchedStaticRoute.length === 0 && - matchedDynamicRoute.length === 0 - ) { - return { - event: { - ...internalEvent, - rawPath: "/404", - url: constructNextUrl(internalEvent.url, "/404"), - headers: { - ...internalEvent.headers, - "x-invoke-status": "404", - }, - }, - isISR: false, - }; - } - - return { - event: internalEvent, - isISR: routeFallback || isPregenerated, - }; -} diff --git a/packages/core/src/core/routing/middleware.ts b/packages/core/src/core/routing/middleware.ts index 78638c95..9993d913 100644 --- a/packages/core/src/core/routing/middleware.ts +++ b/packages/core/src/core/routing/middleware.ts @@ -34,6 +34,15 @@ function defaultMiddlewareLoader() { return import("./middleware.mjs"); } +export function shouldInvokeMiddleware(internalEvent: InternalEvent): boolean { + const headers = internalEvent.headers; + if (headers["x-isr"] && headers["x-prerender-revalidate"] === PrerenderManifest?.preview?.previewModeId) { + return false; + } + const normalizedPath = localizePath(internalEvent); + return middleMatch.some((route) => route.test(normalizedPath)); +} + /** * * @param internalEvent the internal event @@ -47,17 +56,8 @@ export async function handleMiddleware( middlewareLoader: MiddlewareLoader = defaultMiddlewareLoader ): Promise { const headers = internalEvent.headers; - - // We bypass the middleware if the request is internal - // We should only do that if the request has the correct `x-prerender-revalidate` header - // The `x-prerender-revalidate` header is set at build time and should be safe to trust - if (headers["x-isr"] && headers["x-prerender-revalidate"] === PrerenderManifest?.preview?.previewModeId) - return internalEvent; - - // We only need the normalizedPath to check if the middleware should run + if (!shouldInvokeMiddleware(internalEvent)) return internalEvent; const normalizedPath = localizePath(internalEvent); - const hasMatch = middleMatch.some((r) => r.test(normalizedPath)); - if (!hasMatch) return internalEvent; const initialUrl = new URL(normalizedPath, internalEvent.url); initialUrl.search = initialSearch; diff --git a/packages/core/src/core/routing/routeMatcher.ts b/packages/core/src/core/routing/routeMatcher.ts deleted file mode 100644 index 9a4d814e..00000000 --- a/packages/core/src/core/routing/routeMatcher.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { AppPathRoutesManifest, PagesManifest, PrerenderManifest, RoutesManifest } from "@/config/index"; -import type { RouteDefinition } from "@/types/next-types"; -import type { ResolvedRoute, RouteType } from "@/types/open-next"; - -// Add the locale prefix to the regex so we correctly match the rawPath -const optionalLocalePrefixRegex = `^/(?:${RoutesManifest.locales.map((locale) => `${locale}/?`).join("|")})?`; - -// Add the basepath prefix to the regex so we correctly match the rawPath -const optionalBasepathPrefixRegex = RoutesManifest.basePath ? `^${RoutesManifest.basePath}/?` : "^/"; - -const optionalPrefix = optionalLocalePrefixRegex.replace("^/", optionalBasepathPrefixRegex); - -function routeMatcher(routeDefinitions: RouteDefinition[]) { - const regexp = routeDefinitions.map((route) => ({ - page: route.page, - regexp: new RegExp(route.regex.replace("^/", optionalPrefix)), - })); - - const { dynamicRoutes = {} } = PrerenderManifest ?? {}; - const prerenderedFallbackRoutes = Object.entries(dynamicRoutes) - .filter(([, { fallback }]) => fallback === false) - .map(([route]) => route); - - const appPathsSet = new Set(); - const routePathsSet = new Set(); - // We need to use AppPathRoutesManifest here - for (const [k, v] of Object.entries(AppPathRoutesManifest)) { - if (k.endsWith("page")) { - appPathsSet.add(v); - } else if (k.endsWith("route")) { - routePathsSet.add(v); - } - } - - return function matchRoute(path: string): ResolvedRoute[] { - const foundRoutes = regexp.filter((route) => route.regexp.test(path)); - - return foundRoutes.map((foundRoute) => { - let routeType: RouteType = "page"; - // Check if the route is a prerendered fallback false route - const isFallback = prerenderedFallbackRoutes.includes(foundRoute.page); - - if (appPathsSet.has(foundRoute.page)) { - routeType = "app"; - } else if (routePathsSet.has(foundRoute.page)) { - routeType = "route"; - } - return { - route: foundRoute.page, - type: routeType, - isFallback, - }; - }); - }; -} - -export const staticRouteMatcher = routeMatcher([...RoutesManifest.routes.static, ...getStaticAPIRoutes()]); -export const dynamicRouteMatcher = routeMatcher(RoutesManifest.routes.dynamic); - -/** - * Returns static API routes for both app and pages router cause Next will filter them out in staticRoutes in `routes-manifest.json`. - * We also need to filter out page files that are under `app/api/*` as those would not be present in the routes manifest either. - * This line from Next.js skips it: - * https://github.com/vercel/next.js/blob/ded56f952154a40dcfe53bdb38c73174e9eca9e5/packages/next/src/build/index.ts#L1299 - * - * Without it handleFallbackFalse will 404 on static API routes if there is a catch-all route on root level. - */ -function getStaticAPIRoutes(): RouteDefinition[] { - const createRouteDefinition = (route: string) => ({ - page: route, - regex: `^${route}(?:/)?$`, - }); - const dynamicRoutePages = new Set(RoutesManifest.routes.dynamic.map(({ page }) => page)); - const pagesStaticAPIRoutes = Object.keys(PagesManifest) - .filter((route) => route.startsWith("/api/") && !dynamicRoutePages.has(route)) - .map(createRouteDefinition); - - // We filter out both static API and page routes from the app paths manifest - const appPathsStaticAPIRoutes = Object.values(AppPathRoutesManifest) - .filter((route) => (route.startsWith("/api/") || route === "/api") && !dynamicRoutePages.has(route)) - .map(createRouteDefinition); - - return [...pagesStaticAPIRoutes, ...appPathsStaticAPIRoutes]; -} diff --git a/packages/core/src/core/routingHandler.ts b/packages/core/src/core/routingHandler.ts index 232f1d67..dcafaaa7 100644 --- a/packages/core/src/core/routingHandler.ts +++ b/packages/core/src/core/routingHandler.ts @@ -1,4 +1,6 @@ -import { BuildId, ConfigHeaders, NextConfig, PrerenderManifest, RoutesManifest } from "@/config/index"; +import { resolveRoutes, responseToMiddlewareResult } from "@next/routing"; + +import { BuildId, NextConfig, RoutingConfig } from "@/config/index"; import type { InternalEvent, InternalResult, @@ -7,21 +9,14 @@ import type { RoutingResult, } from "@/types/open-next"; import type { AssetResolver } from "@/types/overrides"; +import { emptyReadableStream } from "@/utils/stream"; import { debug, error } from "../adapters/logger"; import { cacheInterceptor } from "./routing/cacheInterceptor"; import { detectLocale } from "./routing/i18n"; -import { - fixDataPage, - getNextConfigHeaders, - handleFallbackFalse, - handleRedirects, - handleRewrites, -} from "./routing/matcher"; -import { handleMiddleware } from "./routing/middleware"; -import { dynamicRouteMatcher, staticRouteMatcher } from "./routing/routeMatcher"; -import { constructNextUrl, normalizeLocationHeader } from "./routing/util"; +import { shouldInvokeMiddleware } from "./routing/middleware"; +import { convertBodyToReadableStream, constructNextUrl, normalizeLocationHeader } from "./routing/util"; export const MIDDLEWARE_HEADER_PREFIX = "x-middleware-response-"; export const MIDDLEWARE_HEADER_PREFIX_LEN = MIDDLEWARE_HEADER_PREFIX.length; @@ -32,8 +27,6 @@ export const INTERNAL_HEADER_RESOLVED_ROUTES = `${INTERNAL_HEADER_PREFIX}resolve export const INTERNAL_HEADER_REWRITE_STATUS_CODE = `${INTERNAL_HEADER_PREFIX}rewrite-status-code`; export const INTERNAL_EVENT_REQUEST_ID = `${INTERNAL_HEADER_PREFIX}request-id`; -// Geolocation headers starting from Nextjs 15 -// See https://github.com/vercel/vercel/blob/7714b1c/packages/functions/src/headers.ts const geoHeaderToNextHeader = { "x-open-next-city": "x-vercel-ip-city", "x-open-next-country": "x-vercel-ip-country", @@ -42,34 +35,117 @@ const geoHeaderToNextHeader = { "x-open-next-longitude": "x-vercel-ip-longitude", }; -/** - * Adds the middleware headers to an event or result. - * - * @param eventOrResult - * @param middlewareHeaders - */ -function applyMiddlewareHeaders( - eventOrResult: InternalEvent | InternalResult, - middlewareHeaders: Record -) { - // Use the `MIDDLEWARE_HEADER_PREFIX` prefix for events, they will be processed by the request handler later. - // Results do not go through the request handler and should not be prefixed. +type Middleware = (request: Request) => Response | Promise; +type MiddlewareLoader = () => Promise<{ default: Middleware }>; + +function defaultMiddlewareLoader() { + // @ts-expect-error - This is bundled with the runtime handler. + return import("./routing/middleware.mjs"); +} + +function headersToRecord(headers: Headers): Record { + const result: Record = {}; + headers.forEach((value, key) => { + if ( + key === "content-encoding" || + key === "x-middleware-rewrite" || + key === "x-middleware-next" || + key === "x-middleware-override-headers" + ) { + return; + } + if (key === "set-cookie") { + result[key] = result[key] + ? [...(Array.isArray(result[key]) ? result[key] : [result[key]]), value] + : [value]; + return; + } + result[key] = value; + }); + return result; +} + +function applyResponseHeaders(eventOrResult: InternalEvent | InternalResult, headers: Headers): void { const isResult = isInternalResult(eventOrResult); - const headers = eventOrResult.headers; const keyPrefix = isResult ? "" : MIDDLEWARE_HEADER_PREFIX; - Object.entries(middlewareHeaders).forEach(([key, value]) => { - if (value) { - headers[keyPrefix + key] = Array.isArray(value) ? value.join(",") : value; + for (const [key, value] of Object.entries(headersToRecord(headers))) { + eventOrResult.headers[keyPrefix + key] = value; + } +} + +function toInvocationUrl( + eventUrl: string, + pathname: string, + query: Record +): string { + const url = new URL(eventUrl); + url.pathname = pathname; + url.search = ""; + for (const [key, value] of Object.entries(query)) { + for (const item of Array.isArray(value) ? value : [value]) { + url.searchParams.append(key, item); } - }); + } + return url.toString(); +} + +function toInternalEvent( + event: InternalEvent, + url: string, + headers: Headers, + query: Record +): InternalEvent { + return { + ...event, + url, + rawPath: new URL(url).pathname, + query, + headers: { + ...event.headers, + ...headersToRecord(headers), + } as Record, + }; +} + +function getResolvedRoute(pathname: string | undefined): ResolvedRoute[] { + if (!pathname) { + return []; + } + const route = RoutingConfig.routeIndex[pathname]; + return route ? [{ route: pathname, ...route }] : []; +} + +function createRoutingResult( + event: InternalEvent, + resolvedRoutes: ResolvedRoute[], + options: { + isExternalRewrite?: boolean; + rewriteStatusCode?: number; + initialResponse?: InternalResult; + initialURL?: string; + } = {} +): RoutingResult { + return { + internalEvent: event, + isExternalRewrite: options.isExternalRewrite ?? false, + origin: false, + isISR: false, + resolvedRoutes, + initialURL: options.initialURL ?? event.url, + locale: NextConfig.i18n ? detectLocale(event, NextConfig.i18n) : undefined, + rewriteStatusCode: options.rewriteStatusCode, + initialResponse: options.initialResponse, + }; } export default async function routingHandler( event: InternalEvent, - { assetResolver }: { assetResolver?: AssetResolver } + { + assetResolver, + middlewareLoader = defaultMiddlewareLoader, + }: { assetResolver?: AssetResolver; middlewareLoader?: MiddlewareLoader } = {} ): Promise { try { - // Add Next geo headers for (const [openNextGeoName, nextGeoName] of Object.entries(geoHeaderToNextHeader)) { const value = event.headers[openNextGeoName]; if (value) { @@ -77,220 +153,217 @@ export default async function routingHandler( } } - // First we remove internal headers - // We don't want to allow users to set these headers for (const key of Object.keys(event.headers)) { if (key.startsWith(INTERNAL_HEADER_PREFIX) || key.startsWith(MIDDLEWARE_HEADER_PREFIX)) { delete event.headers[key]; } } - // Headers from the Next config and middleware (the later are applied further down). - let headers: Record = getNextConfigHeaders(event, ConfigHeaders); - - let eventOrResult = fixDataPage(event, BuildId); + let directMiddlewareResult: InternalResult | undefined; + let middlewareHeaders = new Headers(event.headers); + const routingResult = await resolveRoutes({ + url: new URL(event.url), + buildId: RoutingConfig.buildId || BuildId, + basePath: NextConfig.basePath ?? "", + i18n: NextConfig.i18n + ? { + ...NextConfig.i18n, + domains: NextConfig.i18n.domains?.map((domain) => ({ + ...domain, + locales: [...domain.locales], + })), + } + : undefined, + headers: middlewareHeaders, + requestBody: (convertBodyToReadableStream(event.method, event.body) ?? + emptyReadableStream()) as unknown as ReadableStream, + pathnames: RoutingConfig.pathnames, + routes: RoutingConfig.routes, + invokeMiddleware: async (context) => { + middlewareHeaders = context.headers; + if (!shouldInvokeMiddleware(event)) { + return { requestHeaders: context.headers }; + } - if (isInternalResult(eventOrResult)) { - return eventOrResult; - } + const middleware = await middlewareLoader(); + const response = await middleware.default({ + geo: { + city: decodeURIComponent(event.headers["x-open-next-city"]), + country: event.headers["x-open-next-country"], + region: event.headers["x-open-next-region"], + latitude: event.headers["x-open-next-latitude"], + longitude: event.headers["x-open-next-longitude"], + }, + headers: context.headers, + method: event.method || "GET", + nextConfig: { + basePath: NextConfig.basePath, + i18n: NextConfig.i18n, + trailingSlash: NextConfig.trailingSlash, + }, + url: context.url.toString(), + body: context.requestBody, + } as unknown as Request); + const result = responseToMiddlewareResult(response, context.headers, context.url); + if (result.bodySent) { + directMiddlewareResult = { + type: event.type, + statusCode: response.status, + headers: headersToRecord(response.headers), + body: (response.body ?? emptyReadableStream()) as unknown as InternalResult["body"], + isBase64Encoded: false, + }; + } + return result; + }, + }); - const redirect = handleRedirects(eventOrResult, RoutesManifest.redirects); - if (redirect) { - // We need to encode the value in the Location header to make sure it is valid according to RFC - redirect.headers.Location = normalizeLocationHeader( - redirect.headers.Location as string, - event.url, - true - ); - debug("redirect", redirect); - return redirect; - } - const middlewareEventOrResult = await handleMiddleware( - eventOrResult, - // We need to pass the initial search without any decoding - // TODO: we'd need to refactor InternalEvent to include the initial querystring directly - // Should be done in another PR because it is a breaking change - new URL(event.url).search - ); - if (isInternalResult(middlewareEventOrResult)) { - return middlewareEventOrResult; + if (routingResult.middlewareResponded && directMiddlewareResult) { + return directMiddlewareResult; } - const middlewareHeadersPrioritized = - globalThis.openNextConfig.dangerous?.middlewareHeadersOverrideNextConfigHeaders ?? false; - - if (middlewareHeadersPrioritized) { - headers = { - ...headers, - ...middlewareEventOrResult.responseHeaders, - }; - } else { - headers = { - ...middlewareEventOrResult.responseHeaders, - ...headers, + const responseHeaders = routingResult.resolvedHeaders ?? new Headers(); + if (routingResult.redirect) { + return { + type: event.type, + statusCode: routingResult.redirect.status, + headers: { + ...headersToRecord(responseHeaders), + Location: normalizeLocationHeader(routingResult.redirect.url.toString(), event.url, true), + }, + body: emptyReadableStream(), + isBase64Encoded: false, }; } - let isExternalRewrite = middlewareEventOrResult.isExternalRewrite ?? false; - eventOrResult = middlewareEventOrResult; - - if (!isExternalRewrite) { - // First rewrite to be applied - const beforeRewrite = handleRewrites(eventOrResult, RoutesManifest.rewrites.beforeFiles); - eventOrResult = beforeRewrite.internalEvent; - isExternalRewrite = beforeRewrite.isExternalRewrite; - // Check for matching public files after `beforeFiles` rewrites - // See: - // - https://nextjs.org/docs/app/api-reference/file-conventions/middleware#execution-order - // - https://nextjs.org/docs/app/api-reference/config/next-config-js/rewrites - if (!isExternalRewrite) { - const assetResult = await assetResolver?.maybeGetAssetResult?.(eventOrResult); - if (assetResult) { - applyMiddlewareHeaders(assetResult, headers); - return assetResult; - } - } + const location = responseHeaders.get("location"); + if (location && routingResult.status && routingResult.status >= 300 && routingResult.status < 400) { + return { + type: event.type, + statusCode: routingResult.status, + headers: headersToRecord(responseHeaders), + body: emptyReadableStream(), + isBase64Encoded: false, + }; } - let foundStaticRoute = staticRouteMatcher(eventOrResult.rawPath); - const isStaticRoute = !isExternalRewrite && foundStaticRoute.length > 0; - if (!(isStaticRoute || isExternalRewrite)) { - // Second rewrite to be applied - const afterRewrite = handleRewrites(eventOrResult, RoutesManifest.rewrites.afterFiles); - eventOrResult = afterRewrite.internalEvent; - isExternalRewrite = afterRewrite.isExternalRewrite; + if (routingResult.externalRewrite) { + const externalEvent = toInternalEvent( + event, + routingResult.externalRewrite.toString(), + middlewareHeaders, + routingResult.resolvedQuery ?? {} + ); + applyResponseHeaders(externalEvent, responseHeaders); + return createRoutingResult(externalEvent, [], { + isExternalRewrite: true, + rewriteStatusCode: routingResult.status, + initialURL: event.url, + }); } - let isISR = false; - // We want to run this just before the dynamic route check - // We can skip it if its an external rewrite - if (!isExternalRewrite) { - const fallbackResult = handleFallbackFalse(eventOrResult, PrerenderManifest); - eventOrResult = fallbackResult.event; - isISR = fallbackResult.isISR; + if (!routingResult.invocationTarget || !routingResult.resolvedPathname) { + const notFoundEvent = { + ...event, + rawPath: "/404", + url: constructNextUrl(event.url, "/404"), + headers: { + ...event.headers, + "x-middleware-response-cache-control": "private, no-cache, no-store, max-age=0, must-revalidate", + }, + }; + applyResponseHeaders(notFoundEvent, responseHeaders); + return createRoutingResult(notFoundEvent, [], { + rewriteStatusCode: routingResult.status, + initialURL: event.url, + }); } - let foundDynamicRoute = dynamicRouteMatcher(eventOrResult.rawPath); - const isDynamicRoute = !isExternalRewrite && foundDynamicRoute.length > 0; + const invocationUrl = toInvocationUrl( + event.url, + routingResult.invocationTarget.pathname, + routingResult.invocationTarget.query + ); + const resolvedEvent = { + ...toInternalEvent( + event, + invocationUrl, + middlewareHeaders, + routingResult.resolvedQuery ?? routingResult.invocationTarget.query + ), + rewriteStatusCode: routingResult.status, + }; + const resolvedRoutes = getResolvedRoute(routingResult.resolvedPathname); - if (!(isDynamicRoute || isStaticRoute || isExternalRewrite)) { - // Fallback rewrite to be applied - const fallbackRewrites = handleRewrites(eventOrResult, RoutesManifest.rewrites.fallback); - eventOrResult = fallbackRewrites.internalEvent; - isExternalRewrite = fallbackRewrites.isExternalRewrite; + const assetResult = await assetResolver?.maybeGetAssetResult?.(resolvedEvent); + if (assetResult) { + applyResponseHeaders(assetResult, responseHeaders); + return assetResult; } - const isNextImageRoute = eventOrResult.rawPath.startsWith("/_next/image"); - - const isRouteFoundBeforeAllRewrites = isStaticRoute || isDynamicRoute || isExternalRewrite; - - // We need to ensure that rewrites are applied before showing the 404 page - foundStaticRoute = staticRouteMatcher(eventOrResult.rawPath); - // We also want to remove dynamic routes that are fallback false - foundDynamicRoute = dynamicRouteMatcher(eventOrResult.rawPath).filter((route) => !route.isFallback); - - // If we still haven't found a route, we show the 404 page - if ( - !( - isRouteFoundBeforeAllRewrites || - isNextImageRoute || - // We need to check again once all rewrites have been applied - foundStaticRoute.length > 0 || - foundDynamicRoute.length > 0 - ) - ) { - eventOrResult = { - ...eventOrResult, + if (resolvedRoutes.length === 0) { + const notFoundEvent = { + ...resolvedEvent, rawPath: "/404", - url: constructNextUrl(eventOrResult.url, "/404"), + url: constructNextUrl(resolvedEvent.url, "/404"), headers: { - ...eventOrResult.headers, + ...resolvedEvent.headers, "x-middleware-response-cache-control": "private, no-cache, no-store, max-age=0, must-revalidate", }, }; + applyResponseHeaders(notFoundEvent, responseHeaders); + return createRoutingResult(notFoundEvent, [], { + rewriteStatusCode: routingResult.status, + initialURL: event.url, + }); } - const resolvedRoutes: ResolvedRoute[] = [...foundStaticRoute, ...foundDynamicRoute]; - - if (!isInternalResult(eventOrResult)) { - debug("Attempting cache interception"); - const cacheInterceptionResult = await cacheInterceptor(eventOrResult); - if (isInternalResult(cacheInterceptionResult)) { - applyMiddlewareHeaders(cacheInterceptionResult, headers); - return cacheInterceptionResult; - } else if (isPartialResult(cacheInterceptionResult)) { - // We need to apply the headers to both the result (the streamed response) and the resume request - applyMiddlewareHeaders(cacheInterceptionResult.result, headers); - applyMiddlewareHeaders(cacheInterceptionResult.resumeRequest, headers); - return { - internalEvent: cacheInterceptionResult.resumeRequest, - isExternalRewrite: false, - origin: false, - isISR: false, - resolvedRoutes, - initialURL: event.url, - locale: NextConfig.i18n ? detectLocale(eventOrResult, NextConfig.i18n) : undefined, - rewriteStatusCode: middlewareEventOrResult.rewriteStatusCode, - initialResponse: cacheInterceptionResult.result, - }; - } + debug("Attempting cache interception"); + const cacheInterceptionResult = await cacheInterceptor(resolvedEvent); + if (isInternalResult(cacheInterceptionResult)) { + applyResponseHeaders(cacheInterceptionResult, responseHeaders); + return cacheInterceptionResult; + } + if (isPartialResult(cacheInterceptionResult)) { + applyResponseHeaders(cacheInterceptionResult.result, responseHeaders); + applyResponseHeaders(cacheInterceptionResult.resumeRequest, responseHeaders); + return createRoutingResult(cacheInterceptionResult.resumeRequest, resolvedRoutes, { + rewriteStatusCode: routingResult.status, + initialResponse: cacheInterceptionResult.result, + initialURL: event.url, + }); } - // We apply the headers from the middleware response last - applyMiddlewareHeaders(eventOrResult, headers); - - debug("resolvedRoutes", resolvedRoutes); - - return { - internalEvent: eventOrResult, - isExternalRewrite, - origin: false, - isISR, - resolvedRoutes, + applyResponseHeaders(cacheInterceptionResult, responseHeaders); + return createRoutingResult(cacheInterceptionResult, resolvedRoutes, { + rewriteStatusCode: routingResult.status, initialURL: event.url, - locale: NextConfig.i18n ? detectLocale(eventOrResult, NextConfig.i18n) : undefined, - rewriteStatusCode: middlewareEventOrResult.rewriteStatusCode, - }; + }); } catch (e) { error("Error in routingHandler", e); - // In case of an error, we want to return the 500 page from Next.js - return { - internalEvent: { + return createRoutingResult( + { type: "core", method: "GET", rawPath: "/500", url: constructNextUrl(event.url, "/500"), - headers: { - ...event.headers, - }, + headers: { ...event.headers }, query: event.query, cookies: event.cookies, remoteAddress: event.remoteAddress, }, - isExternalRewrite: false, - origin: false, - isISR: false, - resolvedRoutes: [], - initialURL: event.url, - locale: NextConfig.i18n ? detectLocale(event, NextConfig.i18n) : undefined, - }; + [], + {} + ); } } -/** - * @param eventOrResult - * @returns Whether the event is an instance of `InternalResult` - */ export function isInternalResult( eventOrResult: InternalEvent | InternalResult | PartialResult ): eventOrResult is InternalResult { return eventOrResult != null && "statusCode" in eventOrResult; } -/** - * @param eventOrResult - * @returns Whether the event is an instance of `PartialResult` (i.e. for PPR responses) - */ export function isPartialResult( eventOrResult: InternalEvent | InternalResult | PartialResult ): eventOrResult is PartialResult { diff --git a/packages/core/src/plugins/edge.ts b/packages/core/src/plugins/edge.ts index 41dca4fb..b43d1ba8 100644 --- a/packages/core/src/plugins/edge.ts +++ b/packages/core/src/plugins/edge.ts @@ -7,18 +7,13 @@ import type { Plugin } from "esbuild"; import type { MiddlewareInfo } from "@/types/next-types.js"; import { - loadAppPathRoutesManifest, - loadAppPathsManifest, - loadAppPathsManifestKeys, loadBuildId, loadConfig, - loadConfigHeaders, loadFunctionsConfigManifest, loadHtmlPages, loadMiddlewareManifest, - loadPagesManifest, loadPrerenderManifest, - loadRoutesManifest, + loadRoutingConfig, } from "../adapters/config/util.js"; import logger from "../logger.js"; import { normalizePath } from "../utils/normalize-path.js"; @@ -143,16 +138,11 @@ ${contents} build.onLoad({ filter: getCrossPlatformPathRegex("adapters/config/index") }, async () => { const NextConfig = loadConfig(nextDir); const BuildId = loadBuildId(nextDir); + const RoutingConfig = loadRoutingConfig(nextDir); const HtmlPages = loadHtmlPages(nextDir); - const RoutesManifest = loadRoutesManifest(nextDir); - const ConfigHeaders = loadConfigHeaders(nextDir); const PrerenderManifest = loadPrerenderManifest(nextDir); - const AppPathsManifestKeys = loadAppPathsManifestKeys(nextDir); const MiddlewareManifest = loadMiddlewareManifest(nextDir); - const AppPathsManifest = loadAppPathsManifest(nextDir); - const AppPathRoutesManifest = loadAppPathRoutesManifest(nextDir); const FunctionsConfigManifest = loadFunctionsConfigManifest(nextDir); - const PagesManifest = loadPagesManifest(nextDir); const contents = ` import path from "node:path"; @@ -168,16 +158,11 @@ ${contents} export const NextConfig = ${JSON.stringify(NextConfig)}; export const BuildId = ${JSON.stringify(BuildId)}; + export const RoutingConfig = ${JSON.stringify(RoutingConfig)}; export const HtmlPages = ${JSON.stringify(HtmlPages)}; - export const RoutesManifest = ${JSON.stringify(RoutesManifest)}; - export const ConfigHeaders = ${JSON.stringify(ConfigHeaders)}; export const PrerenderManifest = ${JSON.stringify(PrerenderManifest)}; - export const AppPathsManifestKeys = ${JSON.stringify(AppPathsManifestKeys)}; export const MiddlewareManifest = ${JSON.stringify(MiddlewareManifest)}; - export const AppPathsManifest = ${JSON.stringify(AppPathsManifest)}; - export const AppPathRoutesManifest = ${JSON.stringify(AppPathRoutesManifest)}; export const FunctionsConfigManifest = ${JSON.stringify(FunctionsConfigManifest)}; - export const PagesManifest = ${JSON.stringify(PagesManifest)}; process.env.NEXT_BUILD_ID = BuildId; process.env.NEXT_PREVIEW_MODE_ID = PrerenderManifest?.preview?.previewModeId; diff --git a/packages/core/src/types/adapter.ts b/packages/core/src/types/adapter.ts index 1974e42e..0eeb1dde 100644 --- a/packages/core/src/types/adapter.ts +++ b/packages/core/src/types/adapter.ts @@ -1,3 +1,5 @@ +import type { ResolveRoutesParams } from "@next/routing"; + export type NextAdapterOutput = { pathname: string; filePath: string; @@ -9,9 +11,26 @@ export type NextAdapterOutputs = { pagesApi: NextAdapterOutput[]; appPages: NextAdapterOutput[]; appRoutes: NextAdapterOutput[]; + staticFiles?: NextAdapterOutput[]; + prerenders?: NextAdapterOutput[]; middleware?: NextAdapterOutput; }; +export type NextAdapterRouting = ResolveRoutesParams["routes"]; + +export type RuntimeRoutingConfig = { + buildId: string; + routes: NextAdapterRouting; + pathnames: string[]; + routeIndex: Record< + string, + { + type: "page" | "app" | "route"; + isFallback: boolean; + } + >; +}; + export type PublicFiles = { files: string[]; }; diff --git a/packages/tests-unit/tests/build/createRoutingConfig.test.ts b/packages/tests-unit/tests/build/createRoutingConfig.test.ts new file mode 100644 index 00000000..227c8c2d --- /dev/null +++ b/packages/tests-unit/tests/build/createRoutingConfig.test.ts @@ -0,0 +1,48 @@ +import fs from "node:fs"; + +import type { BuildCompleteContext } from "@opennextjs/core/build/adapter.js"; +import { createRoutingConfig } from "@opennextjs/core/build/createRoutingConfig.js"; +import { vi } from "vitest"; + +vi.mock("node:fs"); + +describe("createRoutingConfig", () => { + it("serializes routing metadata and executable route classifications", () => { + const context = { + buildId: "build-id", + routing: { + beforeMiddleware: [], + beforeFiles: [], + afterFiles: [], + dynamicRoutes: [], + onMatch: [], + fallback: [], + }, + outputs: { + pages: [{ pathname: "/pages", filePath: "/pages.js", assets: {} }], + pagesApi: [{ pathname: "/api/hello", filePath: "/api.js", assets: {} }], + appPages: [{ pathname: "/app", filePath: "/app.js", assets: {} }], + appRoutes: [{ pathname: "/route", filePath: "/route.js", assets: {} }], + staticFiles: [{ pathname: "/asset.js", filePath: "/asset.js", assets: {} }], + }, + } as BuildCompleteContext; + + const result = createRoutingConfig({ appBuildOutputPath: "/app" } as never, context); + + expect(result).toEqual({ + buildId: "build-id", + routes: context.routing, + pathnames: ["/pages", "/api/hello", "/app", "/route", "/asset.js"], + routeIndex: { + "/pages": { type: "page", isFallback: false }, + "/api/hello": { type: "page", isFallback: false }, + "/app": { type: "app", isFallback: false }, + "/route": { type: "route", isFallback: false }, + }, + }); + expect(fs.writeFileSync).toHaveBeenCalledWith( + "/app/.next/open-next-routing.json", + JSON.stringify(result) + ); + }); +}); diff --git a/packages/tests-unit/tests/core/routing/matcher.test.ts b/packages/tests-unit/tests/core/routing/matcher.test.ts deleted file mode 100644 index d205ad35..00000000 --- a/packages/tests-unit/tests/core/routing/matcher.test.ts +++ /dev/null @@ -1,622 +0,0 @@ -import { - fixDataPage, - getNextConfigHeaders, - handleRedirects, - handleRewrites, -} from "@opennextjs/core/core/routing/matcher.js"; -import { convertFromQueryString } from "@opennextjs/core/core/routing/util.js"; -import type { InternalEvent } from "@opennextjs/core/types/open-next.js"; -import { vi } from "vitest"; - -import { NextConfig } from "@/config/index.js"; - -vi.mock("@/config/index.js", () => ({ - NextConfig: {}, - PrerenderManifest: { - routes: {}, - dynamicRoutes: {}, - preview: { - previewModeId: "", - previewModeEncryptionKey: "", - previewModeSigningKey: "", - }, - }, - AppPathRoutesManifest: { - "/api/app/route": "/api/app", - "/app/page": "/app", - "/catchAll/[...slug]/page": "/catchAll/[...slug]", - }, - RoutesManifest: { - version: 3, - pages404: true, - caseSensitive: false, - basePath: "", - locales: [], - redirects: [], - headers: [], - routes: { - dynamic: [ - { - page: "/catchAll/[...slug]", - regex: "^/catchAll/(.+?)(?:/)?$", - routeKeys: { - nxtPslug: "nxtPslug", - }, - namedRegex: "^/catchAll/(?.+?)(?:/)?$", - }, - { - page: "/page/catchAll/[...slug]", - regex: "^/page/catchAll/(.+?)(?:/)?$", - routeKeys: { - nxtPslug: "nxtPslug", - }, - namedRegex: "^/page/catchAll/(?.+?)(?:/)?$", - }, - ], - static: [ - { - page: "/app", - regex: "^/app(?:/)?$", - routeKeys: {}, - namedRegex: "^/app(?:/)?$", - }, - { - page: "/page", - regex: "^/page(?:/)?$", - routeKeys: {}, - namedRegex: "^/page(?:/)?$", - }, - { - page: "/page/catchAll/static", - regex: "^/page/catchAll/static(?:/)?$", - routeKeys: {}, - namedRegex: "^/page/catchAll/static(?:/)?$", - }, - ], - }, - }, - PagesManifest: { - "/_app": "pages/_app.js", - "/_document": "pages/_document.js", - "/_error": "pages/_error.js", - "/404": "pages/404.html", - }, -})); - -vi.mock("@opennextjs/core/core/routing/i18n/index.js", () => ({ - localizePath: (event: InternalEvent) => event.rawPath, - handleLocaleRedirect: (_event: InternalEvent) => false, -})); - -type PartialEvent = Partial> & { body?: string }; - -function createEvent(event: PartialEvent): InternalEvent { - const url = event.url ?? "https://on/"; - const { pathname, search } = new URL(url); - return { - type: "core", - method: event.method ?? "GET", - rawPath: pathname, - url: event.url ?? "/", - body: Buffer.from(event.body ?? ""), - headers: event.headers ?? {}, - query: convertFromQueryString(search.slice(1)), - cookies: event.cookies ?? {}, - remoteAddress: event.remoteAddress ?? "::1", - }; -} - -beforeEach(() => { - vi.resetAllMocks(); -}); - -describe("getNextConfigHeaders", () => { - it("should return empty object for undefined configHeaders", () => { - const event = createEvent({}); - const result = getNextConfigHeaders(event); - - expect(result).toEqual({}); - }); - - it("should return empty object for empty configHeaders", () => { - const event = createEvent({}); - const result = getNextConfigHeaders(event, []); - - expect(result).toEqual({}); - }); - - it("should return request headers for matching / route", () => { - const event = createEvent({ - url: "https://on/", - }); - - const result = getNextConfigHeaders(event, [ - { - source: "/", - regex: "^/$", - headers: [ - { - key: "foo", - value: "bar", - }, - ], - }, - ]); - - expect(result).toEqual({ - foo: "bar", - }); - }); - - it("should return empty request headers for matching / route with empty headers", () => { - const event = createEvent({ - url: "https://on/", - }); - - const result = getNextConfigHeaders(event, [ - { - source: "/", - regex: "^/$", - headers: [], - }, - ]); - - expect(result).toEqual({}); - }); - - it("should return request headers for matching /* route", () => { - const event = createEvent({ - url: "https://on/hello-world", - }); - - const result = getNextConfigHeaders(event, [ - { - source: "/(.*)", - regex: "^(?:/(.*))(?:/)?$", - headers: [ - { - key: "foo", - value: "bar", - }, - { - key: "hello", - value: "world", - }, - ], - }, - ]); - - expect(result).toEqual({ - foo: "bar", - hello: "world", - }); - }); - - it("should return request headers for matching /* route with has condition", () => { - const event = createEvent({ - url: "https://on/hello-world", - cookies: { - match: "true", - }, - }); - - const result = getNextConfigHeaders(event, [ - { - source: "/(.*)", - regex: "^(?:/(.*))(?:/)?$", - headers: [ - { - key: "foo", - value: "bar", - }, - ], - has: [{ type: "cookie", key: "match" }], - }, - ]); - - expect(result).toEqual({ - foo: "bar", - }); - }); - - it("should return request headers for matching /* route with missing condition", () => { - const event = createEvent({ - url: "https://on/hello-world", - cookies: { - match: "true", - }, - }); - - const result = getNextConfigHeaders(event, [ - { - source: "/(.*)", - regex: "^(?:/(.*))(?:/)?$", - headers: [ - { - key: "foo", - value: "bar", - }, - ], - missing: [{ type: "cookie", key: "missing" }], - }, - ]); - - expect(result).toEqual({ - foo: "bar", - }); - }); - - it("should return request headers for matching /* route with has and missing condition", () => { - const event = createEvent({ - url: "https://on/hello-world", - cookies: { - match: "true", - }, - }); - - const result = getNextConfigHeaders(event, [ - { - source: "/(.*)", - regex: "^(?:/(.*))(?:/)?$", - headers: [ - { - key: "foo", - value: "bar", - }, - ], - has: [{ type: "cookie", key: "match" }], - missing: [{ type: "cookie", key: "missing" }], - }, - ]); - - expect(result).toEqual({ - foo: "bar", - }); - }); - - it.todo("should exercise the error scenario: 'Error matching header with value '"); -}); - -describe("handleRedirects", () => { - it("should redirect repeated slashes", () => { - const event = createEvent({ - url: "https://on/api-route//foo", - }); - - const result = handleRedirects(event, []); - - expect(result.statusCode).toEqual(308); - expect(result.headers.Location).toEqual("https://on/api-route/foo"); - }); - - it("should redirect trailing slash by default", () => { - const event = createEvent({ - url: "https://on/api-route/", - }); - - const result = handleRedirects(event, []); - - expect(result.statusCode).toEqual(308); - expect(result.headers.Location).toEqual("https://on/api-route"); - }); - - it("should not redirect trailing slash when skipTrailingSlashRedirect is true", () => { - const event = createEvent({ - url: "https://on/api-route/", - }); - - NextConfig.skipTrailingSlashRedirect = true; - const result = handleRedirects(event, []); - - expect(result).toBeUndefined(); - }); - - it("should redirect matching path", () => { - const event = createEvent({ - url: "https://on/api-route", - }); - - const result = handleRedirects(event, [ - { - source: "/:path+", - destination: "/new/:path+", - locale: false, - statusCode: 308, - regex: "^(?!/_next)(?:/((?:[^/]+?)(?:/(?:[^/]+?))*))(?:/)?$", - }, - ]); - - expect(result.headers.Location).toBe("https://on/new/api-route"); - }); - - it("should redirect matching nested path", () => { - const event = createEvent({ - url: "https://on/api-route/secret", - }); - - const result = handleRedirects(event, [ - { - source: "/:path+", - destination: "/new/:path+", - locale: false, - statusCode: 308, - regex: "^(?!/_next)(?:/((?:[^/]+?)(?:/(?:[^/]+?))*))(?:/)?$", - }, - ]); - - expect(result.headers.Location).toBe("https://on/new/api-route/secret"); - }); - - it("should not redirect unmatched path", () => { - const event = createEvent({ - url: "https://on/api-route", - }); - - const result = handleRedirects(event, [ - { - source: "/foo/", - destination: "/bar", - locale: false, - statusCode: 307, - regex: "^(?!/_next)/foo/(?:/)?$", - }, - ]); - - expect(result).toBeUndefined(); - }); - - it("should redirect with + character and query string", () => { - const event = createEvent({ - url: "https://on/foo", - }); - - const result = handleRedirects(event, [ - { - source: "/foo", - destination: "/search?bar=hello+world&baz=new%2C+earth", - locale: false, - statusCode: 308, - regex: "^(?!/_next)/foo(?:/)?$", - }, - ]); - - expect(result.statusCode).toEqual(308); - expect(result.headers.Location).toEqual("https://on/search?bar=hello+world&baz=new%2C+earth"); - }); -}); - -describe("handleRewrites", () => { - it("should not rewrite with empty rewrites", () => { - const event = createEvent({ - url: "https://on/foo?hello=world", - }); - - const result = handleRewrites(event, []); - - expect(result).toEqual({ - internalEvent: event, - isExternalRewrite: false, - }); - }); - - it("should rewrite with params", () => { - const event = createEvent({ - url: "https://on/albums/foo/bar", - }); - - const rewrites = [ - { - source: "/albums/:album", - destination: "/rewrite/albums/:album", - regex: "^/albums(?:/([^/]+?))(?:/)?$", - }, - { - source: "/albums/:album/:song", - destination: "/rewrite/albums/:album/:song", - regex: "^/albums(?:/([^/]+?))(?:/([^/]+?))(?:/)?$", - }, - ]; - const result = handleRewrites(event, rewrites); - - expect(result).toEqual({ - internalEvent: { - ...event, - rawPath: "/rewrite/albums/foo/bar", - url: "https://on/rewrite/albums/foo/bar", - }, - __rewrite: rewrites[1], - isExternalRewrite: false, - }); - }); - - it("should rewrite without params", () => { - const event = createEvent({ - url: "https://on/foo", - }); - - const rewrites = [ - { - source: "foo", - destination: "/bar", - regex: "^/foo(?:/)?$", - }, - ]; - const result = handleRewrites(event, rewrites); - - expect(result).toEqual({ - internalEvent: { - ...event, - rawPath: "/bar", - url: "https://on/bar", - }, - __rewrite: rewrites[0], - isExternalRewrite: false, - }); - }); - - it("should rewrite externally", () => { - const event = createEvent({ - url: "https://on/albums/foo/bar", - }); - - const rewrites = [ - { - source: "/albums/:album/:song", - destination: "https://external.com/search?album=:album&song=:song", - regex: "^/albums(?:/([^/]+?))(?:/([^/]+?))(?:/)?$", - }, - ]; - const result = handleRewrites(event, rewrites); - - expect(result).toEqual({ - internalEvent: { - ...event, - query: { - album: "foo", - song: "bar", - }, - rawPath: "/search", - url: "https://external.com/search?album=foo&song=bar", - }, - __rewrite: rewrites[0], - isExternalRewrite: true, - }); - }); - - it("should rewrite with matching path with has condition", () => { - const event = createEvent({ - url: "https://on/albums/foo?has=true", - }); - - const rewrites = [ - { - source: "/albums/:album", - destination: "/rewrite/albums/:album", - regex: "^/albums(?:/([^/]+?))(?:/)?$", - has: [ - { - type: "query", - key: "has", - value: "true", - }, - ], - }, - ]; - const result = handleRewrites(event, rewrites); - - expect(result).toEqual({ - internalEvent: { - ...event, - rawPath: "/rewrite/albums/foo", - url: "https://on/rewrite/albums/foo?has=true", - }, - __rewrite: rewrites[0], - isExternalRewrite: false, - }); - }); - - it("should rewrite with matching path with missing condition", () => { - const event = createEvent({ - url: "https://on/albums/foo", - headers: { - has: "true", - }, - }); - - const rewrites = [ - { - source: "/albums/:album", - destination: "/rewrite/albums/:album", - regex: "^/albums(?:/([^/]+?))(?:/)?$", - missing: [ - { - type: "header", - key: "missing", - }, - ], - }, - ]; - const result = handleRewrites(event, rewrites); - - expect(result).toEqual({ - internalEvent: { - ...event, - rawPath: "/rewrite/albums/foo", - url: "https://on/rewrite/albums/foo", - }, - __rewrite: rewrites[0], - isExternalRewrite: false, - }); - }); -}); - -describe("fixDataPage", () => { - it("should return 404 for data requests that don't match the buildId", () => { - const event = createEvent({ - url: "https://on/_next/data/xyz/test", - }); - - const response = fixDataPage(event, "abc"); - - expect(response.statusCode).toEqual(404); - }); - - it("should not return 404 for data requests that don't match the buildId", () => { - const event = createEvent({ - url: "https://on/_next/data/abc/test", - }); - - const response = fixDataPage(event, "abc"); - - expect(response.statusCode).not.toEqual(404); - expect(response).toEqual(event); - }); - - it("should not return 404 for data requests (with base path) that don't match the buildId", () => { - NextConfig.basePath = "/base"; - - const event = createEvent({ - url: "https://on/base/_next/data/abc/test", - }); - - const response = fixDataPage(event, "abc"); - - expect(response.statusCode).not.toEqual(404); - expect(response).toEqual(event); - - NextConfig.basePath = undefined; - }); - - it("should remove json extension from data requests and add __nextDataReq to query", () => { - const event = createEvent({ - url: "https://on/_next/data/abc/test/file.json?hello=world", - }); - - const response = fixDataPage(event, "abc"); - - expect(response).toEqual({ - ...event, - rawPath: "/test/file", - url: "https://on/test/file?hello=world&__nextDataReq=1", - }); - }); - - it("should remove json extension from data requests (with base path) and add __nextDataReq to query", () => { - const mockBasePath = "/base"; - NextConfig.basePath = mockBasePath; - - const event = createEvent({ - url: `https://on${mockBasePath}/_next/data/abc/test/file.json?hello=world`, - }); - - const response = fixDataPage(event, "abc"); - - expect(response).toEqual({ - ...event, - rawPath: `${mockBasePath}/test/file`, - url: `https://on${mockBasePath}/test/file?hello=world&__nextDataReq=1`, - }); - - NextConfig.basePath = undefined; - }); -}); diff --git a/packages/tests-unit/tests/core/routing/routeMatcher.test.ts b/packages/tests-unit/tests/core/routing/routeMatcher.test.ts deleted file mode 100644 index f7b86b00..00000000 --- a/packages/tests-unit/tests/core/routing/routeMatcher.test.ts +++ /dev/null @@ -1,197 +0,0 @@ -import { dynamicRouteMatcher, staticRouteMatcher } from "@opennextjs/core/core/routing/routeMatcher.js"; -import { vi } from "vitest"; - -vi.mock("@/config/index.js", () => ({ - PrerenderManifest: { - routes: {}, - dynamicRoutes: { - "/fallback/[...slug]": { fallback: false }, - }, - preview: { - previewModeId: "", - previewModeEncryptionKey: "", - previewModeSigningKey: "", - }, - }, - NextConfig: {}, - AppPathRoutesManifest: { - "/api/app/route": "/api/app", - "/app/page": "/app", - "/catchAll/[...slug]/page": "/catchAll/[...slug]", - "/fallback/[...slug]/page": "/fallback/[...slug]", - }, - RoutesManifest: { - version: 3, - pages404: true, - caseSensitive: false, - basePath: "", - locales: [], - redirects: [], - headers: [], - routes: { - dynamic: [ - { - page: "/catchAll/[...slug]", - regex: "^/catchAll/(.+?)(?:/)?$", - routeKeys: { - nxtPslug: "nxtPslug", - }, - namedRegex: "^/catchAll/(?.+?)(?:/)?$", - }, - { - page: "/page/catchAll/[...slug]", - regex: "^/page/catchAll/(.+?)(?:/)?$", - routeKeys: { - nxtPslug: "nxtPslug", - }, - namedRegex: "^/page/catchAll/(?.+?)(?:/)?$", - }, - { - page: "/fallback/[...slug]", - regex: "^/fallback/(.+?)(?:/)?$", - routeKeys: { - nxtPslug: "nxtPslug", - }, - namedRegex: "^/fallback/(?.+?)(?:/)?$", - }, - ], - static: [ - { - page: "/app", - regex: "^/app(?:/)?$", - routeKeys: {}, - namedRegex: "^/app(?:/)?$", - }, - { - page: "/page", - regex: "^/page(?:/)?$", - routeKeys: {}, - namedRegex: "^/page(?:/)?$", - }, - { - page: "/page/catchAll/static", - regex: "^/page/catchAll/static(?:/)?$", - routeKeys: {}, - namedRegex: "^/page/catchAll/static(?:/)?$", - }, - ], - }, - }, - PagesManifest: { - "/_app": "pages/_app.js", - "/_document": "pages/_document.js", - "/api/hello": "pages/api/hello.js", - "/_error": "pages/_error.js", - "/404": "pages/404.html", - }, -})); - -describe("routeMatcher", () => { - beforeEach(() => { - vi.resetAllMocks(); - }); - - describe("staticRouteMatcher", () => { - it("should match static app route", () => { - const routes = staticRouteMatcher("/app"); - expect(routes).toEqual([ - { - route: "/app", - type: "app", - isFallback: false, - }, - ]); - }); - - it("should match static api route", () => { - const routes = staticRouteMatcher("/api/app"); - expect(routes).toEqual([ - { - route: "/api/app", - type: "route", - isFallback: false, - }, - ]); - - const helloRoute = staticRouteMatcher("/api/hello"); - expect(helloRoute).toEqual([ - { - route: "/api/hello", - type: "page", - isFallback: false, - }, - ]); - }); - - it("should not match app dynamic route", () => { - const routes = staticRouteMatcher("/catchAll/slug"); - expect(routes).toEqual([]); - }); - - it("should not match page dynamic route", () => { - const routes = staticRouteMatcher("/page/catchAll/slug"); - expect(routes).toEqual([]); - }); - - it("should not match random route", () => { - const routes = staticRouteMatcher("/random"); - expect(routes).toEqual([]); - }); - }); - - describe("dynamicRouteMatcher", () => { - it("should match dynamic app page", () => { - const routes = dynamicRouteMatcher("/catchAll/slug/b"); - expect(routes).toEqual([ - { - route: "/catchAll/[...slug]", - type: "app", - isFallback: false, - }, - ]); - }); - - it("should match dynamic page router page", () => { - const routes = dynamicRouteMatcher("/page/catchAll/slug/b"); - expect(routes).toEqual([ - { - route: "/page/catchAll/[...slug]", - type: "page", - isFallback: false, - }, - ]); - }); - - it("should match fallback false dynamic route", () => { - const routes = dynamicRouteMatcher("/fallback/anything/here"); - expect(routes).toEqual([ - { - route: "/fallback/[...slug]", - type: "app", - isFallback: true, - }, - ]); - }); - - it("should match both the static and dynamic page", () => { - const pathToMatch = "/page/catchAll/static"; - const dynamicRoutes = dynamicRouteMatcher(pathToMatch); - expect(dynamicRoutes).toEqual([ - { - route: "/page/catchAll/[...slug]", - type: "page", - isFallback: false, - }, - ]); - - const staticRoutes = staticRouteMatcher(pathToMatch); - expect(staticRoutes).toEqual([ - { - route: "/page/catchAll/static", - type: "page", - isFallback: false, - }, - ]); - }); - }); -}); diff --git a/packages/tests-unit/tests/core/routing/routingHandler.test.ts b/packages/tests-unit/tests/core/routing/routingHandler.test.ts new file mode 100644 index 00000000..fb83edd3 --- /dev/null +++ b/packages/tests-unit/tests/core/routing/routingHandler.test.ts @@ -0,0 +1,101 @@ +import routingHandler from "@opennextjs/core/core/routingHandler.js"; +import type { InternalEvent } from "@opennextjs/core/types/open-next.js"; +import { vi } from "vitest"; + +vi.mock("@/config/index", () => ({ + BuildId: "build-id", + NextConfig: { experimental: {}, images: {} }, + RoutingConfig: { + buildId: "build-id", + pathnames: ["/about"], + routeIndex: { + "/about": { type: "app", isFallback: false }, + }, + routes: { + beforeMiddleware: [ + { + sourceRegex: "^/old$", + destination: "/about", + headers: { location: "/about" }, + status: 308, + }, + ], + beforeFiles: [], + afterFiles: [ + { sourceRegex: "^/rewrite$", destination: "/about?from=rewrite" }, + { sourceRegex: "^/external$", destination: "https://example.com/target" }, + ], + dynamicRoutes: [], + onMatch: [], + fallback: [], + }, + }, + PrerenderManifest: { routes: {}, dynamicRoutes: {}, preview: {} }, + MiddlewareManifest: { middleware: {}, functions: {}, version: 1 }, + FunctionsConfigManifest: { functions: {}, version: 1 }, +})); + +function event(pathname: string): InternalEvent { + return { + type: "core", + method: "GET", + rawPath: pathname, + url: `https://localhost${pathname}`, + headers: { host: "localhost" }, + query: {}, + cookies: {}, + remoteAddress: "127.0.0.1", + }; +} + +beforeEach(() => { + globalThis.openNextConfig = {}; +}); + +describe("routingHandler", () => { + it("uses the resolved pathname to select the executable route", async () => { + const result = await routingHandler(event("/about")); + + expect(result).toMatchObject({ + internalEvent: { + rawPath: "/about", + url: "https://localhost/about", + }, + resolvedRoutes: [{ route: "/about", type: "app", isFallback: false }], + }); + }); + + it("returns redirects directly without invoking an entrypoint", async () => { + const result = await routingHandler(event("/old")); + + expect(result).toMatchObject({ + statusCode: 308, + headers: { Location: "/about" }, + }); + }); + + it("uses the resolver invocation target for internal rewrites", async () => { + const result = await routingHandler(event("/rewrite")); + + expect(result).toMatchObject({ + internalEvent: { + rawPath: "/about", + url: "https://localhost/about?from=rewrite", + query: { from: "rewrite" }, + }, + resolvedRoutes: [{ route: "/about", type: "app", isFallback: false }], + }); + }); + + it("preserves external rewrites for the proxy layer", async () => { + const result = await routingHandler(event("/external")); + + expect(result).toMatchObject({ + isExternalRewrite: true, + internalEvent: { + rawPath: "/target", + url: "https://example.com/target", + }, + }); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ee6bd626..397f8a96 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -67,9 +67,6 @@ catalogs: mock-fs: specifier: ^5.4.1 version: 5.5.0 - next: - specifier: ~15.5.9 - version: 15.5.9 rimraf: specifier: ^6.0.1 version: 6.1.2 @@ -144,10 +141,10 @@ importers: dependencies: '@opennextjs/cloudflare': specifier: ^1.17.1 - version: 1.18.0(next@16.1.4(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(react-dom@19.1.4(react@19.1.4))(react@19.1.4))(wrangler@4.60.0(@cloudflare/workers-types@4.20260123.0)) + version: 1.18.0(next@16.2.1(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(react-dom@19.1.4(react@19.1.4))(react@19.1.4))(wrangler@4.60.0(@cloudflare/workers-types@4.20260123.0)) next: - specifier: 16.1.4 - version: 16.1.4(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(react-dom@19.1.4(react@19.1.4))(react@19.1.4) + specifier: 16.2.1 + version: 16.2.1(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(react-dom@19.1.4(react@19.1.4))(react@19.1.4) react: specifier: 19.1.4 version: 19.1.4 @@ -544,8 +541,8 @@ importers: examples-cloudflare/playground16: dependencies: next: - specifier: 16.1.4 - version: 16.1.4(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + specifier: 16.2.1 + version: 16.2.1(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) react: specifier: 19.2.3 version: 19.2.3 @@ -866,8 +863,8 @@ importers: specifier: ^5.2.1 version: 5.2.1 next: - specifier: ^16.0.10 - version: 16.1.4(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + specifier: 16.2.1 + version: 16.2.1(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) path-to-regexp: specifier: ^6.3.0 version: 6.3.0 @@ -964,8 +961,8 @@ importers: specifier: 'catalog:' version: 5.5.0 next: - specifier: 'catalog:' - version: 15.5.9(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + specifier: catalog:aws + version: 16.2.1(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) picomatch: specifier: ^4.0.2 version: 4.0.3 @@ -984,6 +981,9 @@ importers: '@ast-grep/napi': specifier: ^0.40.5 version: 0.40.5 + '@next/routing': + specifier: 16.2.1 + version: 16.2.1 '@node-minify/core': specifier: ^8.0.6 version: 8.0.6 @@ -1006,8 +1006,8 @@ importers: specifier: ^5.2.1 version: 5.2.1 next: - specifier: ^16.0.10 - version: 16.1.4(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + specifier: 16.2.1 + version: 16.2.1(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) path-to-regexp: specifier: ^6.3.0 version: 6.3.0 @@ -2826,29 +2826,14 @@ packages: '@manypkg/get-packages@1.1.3': resolution: {integrity: sha512-fo+QhuU3qE/2TQMQmbVMqaQ6EWbMhi4ABWP+O4AM1NqPBuy0OrApV5LO6BrrgnhtAHS2NH6RrVk9OL181tTi8A==} - '@next/env@15.5.9': - resolution: {integrity: sha512-4GlTZ+EJM7WaW2HEZcyU317tIQDjkQIyENDLxYJfSWlfqguN+dHkZgyQTV/7ykvobU7yEH5gKvreNrH4B6QgIg==} - - '@next/env@16.1.4': - resolution: {integrity: sha512-gkrXnZyxPUy0Gg6SrPQPccbNVLSP3vmW8LU5dwEttEEC1RwDivk8w4O+sZIjFvPrSICXyhQDCG+y3VmjlJf+9A==} - '@next/env@16.2.0-canary.45': resolution: {integrity: sha512-2dRZ3mA62gFMstPd3Z4iR7HSDs5S1Tvoh2ub6vRffA714NnTBQA75wQ2/v3MFjNHfFLS7zzoDsKXK7td+qWycg==} '@next/env@16.2.1': resolution: {integrity: sha512-n8P/HCkIWW+gVal2Z8XqXJ6aB3J0tuM29OcHpCsobWlChH/SITBs1DFBk/HajgrwDkqqBXPbuUuzgDvUekREPg==} - '@next/swc-darwin-arm64@15.5.7': - resolution: {integrity: sha512-IZwtxCEpI91HVU/rAUOOobWSZv4P2DeTtNaCdHqLcTJU4wdNXgAySvKa/qJCgR5m6KI8UsKDXtO2B31jcaw1Yw==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [darwin] - - '@next/swc-darwin-arm64@16.1.4': - resolution: {integrity: sha512-T8atLKuvk13XQUdVLCv1ZzMPgLPW0+DWWbHSQXs0/3TjPrKNxTmUIhOEaoEyl3Z82k8h/gEtqyuoZGv6+Ugawg==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [darwin] + '@next/routing@16.2.1': + resolution: {integrity: sha512-ZhbT1WTaNIFR8B6CV4dOYbXEcH7REIcvpMf4rfy5jkdS6UJ6GRC67EGH+i9lugfkMX2fa50JRb/OSdYV6QZGRQ==} '@next/swc-darwin-arm64@16.2.0-canary.45': resolution: {integrity: sha512-MVngKBnVVUARNmrvoYsFGvM+NynWB43hPoL+2Zl7Kdnr6gwWay9UynSavL8h+bDuPy407Uc+TUptluC9yR8yxw==} @@ -2862,18 +2847,6 @@ packages: cpu: [arm64] os: [darwin] - '@next/swc-darwin-x64@15.5.7': - resolution: {integrity: sha512-UP6CaDBcqaCBuiq/gfCEJw7sPEoX1aIjZHnBWN9v9qYHQdMKvCKcAVs4OX1vIjeE+tC5EIuwDTVIoXpUes29lg==} - engines: {node: '>= 10'} - cpu: [x64] - os: [darwin] - - '@next/swc-darwin-x64@16.1.4': - resolution: {integrity: sha512-AKC/qVjUGUQDSPI6gESTx0xOnOPQ5gttogNS3o6bA83yiaSZJek0Am5yXy82F1KcZCx3DdOwdGPZpQCluonuxg==} - engines: {node: '>= 10'} - cpu: [x64] - os: [darwin] - '@next/swc-darwin-x64@16.2.0-canary.45': resolution: {integrity: sha512-yb7RdctucBi0idq4+XD49RsjiQEi/DMsZhc9yI57ouFs9pNOeFQGWv3sheSSUO1eoINSf2zf2VOi2m42Bmr38w==} engines: {node: '>= 10'} @@ -2886,20 +2859,6 @@ packages: cpu: [x64] os: [darwin] - '@next/swc-linux-arm64-gnu@15.5.7': - resolution: {integrity: sha512-NCslw3GrNIw7OgmRBxHtdWFQYhexoUCq+0oS2ccjyYLtcn1SzGzeM54jpTFonIMUjNbHmpKpziXnpxhSWLcmBA==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@next/swc-linux-arm64-gnu@16.1.4': - resolution: {integrity: sha512-POQ65+pnYOkZNdngWfMEt7r53bzWiKkVNbjpmCt1Zb3V6lxJNXSsjwRuTQ8P/kguxDC8LRkqaL3vvsFrce4dMQ==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [linux] - libc: [glibc] - '@next/swc-linux-arm64-gnu@16.2.0-canary.45': resolution: {integrity: sha512-8bDK8c+0Kma0URJZ/aSbxH7wSOys5KXlYtE/iaf8fqGtJxIg6VTkUV5bEH4gPl7JFSokOYrKN6romMd6fezkTQ==} engines: {node: '>= 10'} @@ -2914,20 +2873,6 @@ packages: os: [linux] libc: [glibc] - '@next/swc-linux-arm64-musl@15.5.7': - resolution: {integrity: sha512-nfymt+SE5cvtTrG9u1wdoxBr9bVB7mtKTcj0ltRn6gkP/2Nu1zM5ei8rwP9qKQP0Y//umK+TtkKgNtfboBxRrw==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@next/swc-linux-arm64-musl@16.1.4': - resolution: {integrity: sha512-3Wm0zGYVCs6qDFAiSSDL+Z+r46EdtCv/2l+UlIdMbAq9hPJBvGu/rZOeuvCaIUjbArkmXac8HnTyQPJFzFWA0Q==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [linux] - libc: [musl] - '@next/swc-linux-arm64-musl@16.2.0-canary.45': resolution: {integrity: sha512-Wl1QdWPfmahXTKOrUcNQya/4BxpgjaQggwPNwWz0IREa8avntIJy6Anilg3CU/dUmA22WTDgm2S5Hs0eV4C+BQ==} engines: {node: '>= 10'} @@ -2942,20 +2887,6 @@ packages: os: [linux] libc: [musl] - '@next/swc-linux-x64-gnu@15.5.7': - resolution: {integrity: sha512-hvXcZvCaaEbCZcVzcY7E1uXN9xWZfFvkNHwbe/n4OkRhFWrs1J1QV+4U1BN06tXLdaS4DazEGXwgqnu/VMcmqw==} - engines: {node: '>= 10'} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@next/swc-linux-x64-gnu@16.1.4': - resolution: {integrity: sha512-lWAYAezFinaJiD5Gv8HDidtsZdT3CDaCeqoPoJjeB57OqzvMajpIhlZFce5sCAH6VuX4mdkxCRqecCJFwfm2nQ==} - engines: {node: '>= 10'} - cpu: [x64] - os: [linux] - libc: [glibc] - '@next/swc-linux-x64-gnu@16.2.0-canary.45': resolution: {integrity: sha512-69z0oDA2O/PnPRv/kBRD6oRK/AW5t+Cqkp5/8dBO+WZaEo+shcf9EZFD8uPCqrsu7ITiVcDKgUtJzxvouEqm/A==} engines: {node: '>= 10'} @@ -2970,20 +2901,6 @@ packages: os: [linux] libc: [glibc] - '@next/swc-linux-x64-musl@15.5.7': - resolution: {integrity: sha512-4IUO539b8FmF0odY6/SqANJdgwn1xs1GkPO5doZugwZ3ETF6JUdckk7RGmsfSf7ws8Qb2YB5It33mvNL/0acqA==} - engines: {node: '>= 10'} - cpu: [x64] - os: [linux] - libc: [musl] - - '@next/swc-linux-x64-musl@16.1.4': - resolution: {integrity: sha512-fHaIpT7x4gA6VQbdEpYUXRGyge/YbRrkG6DXM60XiBqDM2g2NcrsQaIuj375egnGFkJow4RHacgBOEsHfGbiUw==} - engines: {node: '>= 10'} - cpu: [x64] - os: [linux] - libc: [musl] - '@next/swc-linux-x64-musl@16.2.0-canary.45': resolution: {integrity: sha512-9f6p3JP+bUNuG0RWPF2cBBdAuSJo8VuWak9vxGJfLKA9Qqss/EdcNz0z3U+AoAfZ5DhpAxxKliKIuUWxHvUaeQ==} engines: {node: '>= 10'} @@ -2998,18 +2915,6 @@ packages: os: [linux] libc: [musl] - '@next/swc-win32-arm64-msvc@15.5.7': - resolution: {integrity: sha512-CpJVTkYI3ZajQkC5vajM7/ApKJUOlm6uP4BknM3XKvJ7VXAvCqSjSLmM0LKdYzn6nBJVSjdclx8nYJSa3xlTgQ==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [win32] - - '@next/swc-win32-arm64-msvc@16.1.4': - resolution: {integrity: sha512-MCrXxrTSE7jPN1NyXJr39E+aNFBrQZtO154LoCz7n99FuKqJDekgxipoodLNWdQP7/DZ5tKMc/efybx1l159hw==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [win32] - '@next/swc-win32-arm64-msvc@16.2.0-canary.45': resolution: {integrity: sha512-8ecF4wvdnaxdk7I3pjhb7q7s/UeM8ZjzeL79igcfJpPKjfrajFCg7ss+pmyHaIAH8R8PD47Z11jdTWeffkiWRQ==} engines: {node: '>= 10'} @@ -3022,18 +2927,6 @@ packages: cpu: [arm64] os: [win32] - '@next/swc-win32-x64-msvc@15.5.7': - resolution: {integrity: sha512-gMzgBX164I6DN+9/PGA+9dQiwmTkE4TloBNx8Kv9UiGARsr9Nba7IpcBRA1iTV9vwlYnrE3Uy6I7Aj6qLjQuqw==} - engines: {node: '>= 10'} - cpu: [x64] - os: [win32] - - '@next/swc-win32-x64-msvc@16.1.4': - resolution: {integrity: sha512-JSVlm9MDhmTXw/sO2PE/MRj+G6XOSMZB+BcZ0a7d6KwVFZVpkHcb2okyoYFBaco6LeiL53BBklRlOrDDbOeE5w==} - engines: {node: '>= 10'} - cpu: [x64] - os: [win32] - '@next/swc-win32-x64-msvc@16.2.0-canary.45': resolution: {integrity: sha512-/dHJZF2wNqKSIAU4HvOVXxk/Z5nDd95xmxGE3GaIkQbU9pS+M2CEDQqT5LCEDX6kKTC6CNYqOSzFJqUzygNm9Q==} engines: {node: '>= 10'} @@ -6191,48 +6084,6 @@ packages: resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} engines: {node: '>= 0.6'} - next@15.5.9: - resolution: {integrity: sha512-agNLK89seZEtC5zUHwtut0+tNrc0Xw4FT/Dg+B/VLEo9pAcS9rtTKpek3V6kVcVwsB2YlqMaHdfZL4eLEVYuCg==} - engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0} - hasBin: true - peerDependencies: - '@opentelemetry/api': ^1.1.0 - '@playwright/test': ^1.51.1 - babel-plugin-react-compiler: '*' - react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 - react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 - sass: ^1.3.0 - peerDependenciesMeta: - '@opentelemetry/api': - optional: true - '@playwright/test': - optional: true - babel-plugin-react-compiler: - optional: true - sass: - optional: true - - next@16.1.4: - resolution: {integrity: sha512-gKSecROqisnV7Buen5BfjmXAm7Xlpx9o2ueVQRo5DxQcjC8d330dOM1xiGWc2k3Dcnz0In3VybyRPOsudwgiqQ==} - engines: {node: '>=20.9.0'} - hasBin: true - peerDependencies: - '@opentelemetry/api': ^1.1.0 - '@playwright/test': ^1.51.1 - babel-plugin-react-compiler: '*' - react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 - react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 - sass: ^1.3.0 - peerDependenciesMeta: - '@opentelemetry/api': - optional: true - '@playwright/test': - optional: true - babel-plugin-react-compiler: - optional: true - sass: - optional: true - next@16.2.0-canary.45: resolution: {integrity: sha512-iGzgyRnxI4Buap78QMVpZa969i0wTZeKvkgSYjybeuho70GX45W7bRUe5/0uk0WKsg+4ezAdl0zhSx/mwfSE2A==} engines: {node: '>=20.9.0'} @@ -10772,19 +10623,11 @@ snapshots: globby: 11.1.0 read-yaml-file: 1.1.0 - '@next/env@15.5.9': {} - - '@next/env@16.1.4': {} - '@next/env@16.2.0-canary.45': {} '@next/env@16.2.1': {} - '@next/swc-darwin-arm64@15.5.7': - optional: true - - '@next/swc-darwin-arm64@16.1.4': - optional: true + '@next/routing@16.2.1': {} '@next/swc-darwin-arm64@16.2.0-canary.45': optional: true @@ -10792,84 +10635,42 @@ snapshots: '@next/swc-darwin-arm64@16.2.1': optional: true - '@next/swc-darwin-x64@15.5.7': - optional: true - - '@next/swc-darwin-x64@16.1.4': - optional: true - '@next/swc-darwin-x64@16.2.0-canary.45': optional: true '@next/swc-darwin-x64@16.2.1': optional: true - '@next/swc-linux-arm64-gnu@15.5.7': - optional: true - - '@next/swc-linux-arm64-gnu@16.1.4': - optional: true - '@next/swc-linux-arm64-gnu@16.2.0-canary.45': optional: true '@next/swc-linux-arm64-gnu@16.2.1': optional: true - '@next/swc-linux-arm64-musl@15.5.7': - optional: true - - '@next/swc-linux-arm64-musl@16.1.4': - optional: true - '@next/swc-linux-arm64-musl@16.2.0-canary.45': optional: true '@next/swc-linux-arm64-musl@16.2.1': optional: true - '@next/swc-linux-x64-gnu@15.5.7': - optional: true - - '@next/swc-linux-x64-gnu@16.1.4': - optional: true - '@next/swc-linux-x64-gnu@16.2.0-canary.45': optional: true '@next/swc-linux-x64-gnu@16.2.1': optional: true - '@next/swc-linux-x64-musl@15.5.7': - optional: true - - '@next/swc-linux-x64-musl@16.1.4': - optional: true - '@next/swc-linux-x64-musl@16.2.0-canary.45': optional: true '@next/swc-linux-x64-musl@16.2.1': optional: true - '@next/swc-win32-arm64-msvc@15.5.7': - optional: true - - '@next/swc-win32-arm64-msvc@16.1.4': - optional: true - '@next/swc-win32-arm64-msvc@16.2.0-canary.45': optional: true '@next/swc-win32-arm64-msvc@16.2.1': optional: true - '@next/swc-win32-x64-msvc@15.5.7': - optional: true - - '@next/swc-win32-x64-msvc@16.1.4': - optional: true - '@next/swc-win32-x64-msvc@16.2.0-canary.45': optional: true @@ -10911,7 +10712,7 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.17.1 - '@opennextjs/aws@3.9.16(next@16.1.4(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(react-dom@19.1.4(react@19.1.4))(react@19.1.4))': + '@opennextjs/aws@3.9.16(next@16.2.1(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(react-dom@19.1.4(react@19.1.4))(react@19.1.4))': dependencies: '@ast-grep/napi': 0.40.5 '@aws-sdk/client-cloudfront': 3.984.0(aws-crt@1.23.0) @@ -10927,7 +10728,7 @@ snapshots: cookie: 1.0.2 esbuild: 0.25.4 express: 5.2.1 - next: 16.1.4(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(react-dom@19.1.4(react@19.1.4))(react@19.1.4) + next: 16.2.1(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(react-dom@19.1.4(react@19.1.4))(react@19.1.4) path-to-regexp: 6.3.0 urlpattern-polyfill: 10.1.0 yaml: 2.8.1 @@ -10935,16 +10736,16 @@ snapshots: - aws-crt - supports-color - '@opennextjs/cloudflare@1.18.0(next@16.1.4(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(react-dom@19.1.4(react@19.1.4))(react@19.1.4))(wrangler@4.60.0(@cloudflare/workers-types@4.20260123.0))': + '@opennextjs/cloudflare@1.18.0(next@16.2.1(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(react-dom@19.1.4(react@19.1.4))(react@19.1.4))(wrangler@4.60.0(@cloudflare/workers-types@4.20260123.0))': dependencies: '@ast-grep/napi': 0.40.5 '@dotenvx/dotenvx': 1.31.0 - '@opennextjs/aws': 3.9.16(next@16.1.4(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(react-dom@19.1.4(react@19.1.4))(react@19.1.4)) + '@opennextjs/aws': 3.9.16(next@16.2.1(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(react-dom@19.1.4(react@19.1.4))(react@19.1.4)) cloudflare: 4.5.0 comment-json: 4.6.2 enquirer: 2.4.1 glob: 12.0.0 - next: 16.1.4(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(react-dom@19.1.4(react@19.1.4))(react@19.1.4) + next: 16.2.1(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(react-dom@19.1.4(react@19.1.4))(react@19.1.4) ts-tqdm: 0.8.6 wrangler: 4.60.0(@cloudflare/workers-types@4.20260123.0) yargs: 18.0.0 @@ -14573,76 +14374,25 @@ snapshots: negotiator@1.0.0: {} - next@15.5.9(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + next@16.2.0-canary.45(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: - '@next/env': 15.5.9 + '@next/env': 16.2.0-canary.45 '@swc/helpers': 0.5.15 + baseline-browser-mapping: 2.10.12 caniuse-lite: 1.0.30001766 postcss: 8.4.31 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) styled-jsx: 5.1.6(react@19.2.4) optionalDependencies: - '@next/swc-darwin-arm64': 15.5.7 - '@next/swc-darwin-x64': 15.5.7 - '@next/swc-linux-arm64-gnu': 15.5.7 - '@next/swc-linux-arm64-musl': 15.5.7 - '@next/swc-linux-x64-gnu': 15.5.7 - '@next/swc-linux-x64-musl': 15.5.7 - '@next/swc-win32-arm64-msvc': 15.5.7 - '@next/swc-win32-x64-msvc': 15.5.7 - '@opentelemetry/api': 1.9.0 - '@playwright/test': 1.61.1 - sharp: 0.34.5 - transitivePeerDependencies: - - '@babel/core' - - babel-plugin-macros - - next@16.1.4(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(react-dom@19.1.4(react@19.1.4))(react@19.1.4): - dependencies: - '@next/env': 16.1.4 - '@swc/helpers': 0.5.15 - baseline-browser-mapping: 2.9.11 - caniuse-lite: 1.0.30001766 - postcss: 8.4.31 - react: 19.1.4 - react-dom: 19.1.4(react@19.1.4) - styled-jsx: 5.1.6(react@19.1.4) - optionalDependencies: - '@next/swc-darwin-arm64': 16.1.4 - '@next/swc-darwin-x64': 16.1.4 - '@next/swc-linux-arm64-gnu': 16.1.4 - '@next/swc-linux-arm64-musl': 16.1.4 - '@next/swc-linux-x64-gnu': 16.1.4 - '@next/swc-linux-x64-musl': 16.1.4 - '@next/swc-win32-arm64-msvc': 16.1.4 - '@next/swc-win32-x64-msvc': 16.1.4 - '@opentelemetry/api': 1.9.0 - '@playwright/test': 1.61.1 - sharp: 0.34.5 - transitivePeerDependencies: - - '@babel/core' - - babel-plugin-macros - - next@16.1.4(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3): - dependencies: - '@next/env': 16.1.4 - '@swc/helpers': 0.5.15 - baseline-browser-mapping: 2.9.11 - caniuse-lite: 1.0.30001766 - postcss: 8.4.31 - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) - styled-jsx: 5.1.6(react@19.2.3) - optionalDependencies: - '@next/swc-darwin-arm64': 16.1.4 - '@next/swc-darwin-x64': 16.1.4 - '@next/swc-linux-arm64-gnu': 16.1.4 - '@next/swc-linux-arm64-musl': 16.1.4 - '@next/swc-linux-x64-gnu': 16.1.4 - '@next/swc-linux-x64-musl': 16.1.4 - '@next/swc-win32-arm64-msvc': 16.1.4 - '@next/swc-win32-x64-msvc': 16.1.4 + '@next/swc-darwin-arm64': 16.2.0-canary.45 + '@next/swc-darwin-x64': 16.2.0-canary.45 + '@next/swc-linux-arm64-gnu': 16.2.0-canary.45 + '@next/swc-linux-arm64-musl': 16.2.0-canary.45 + '@next/swc-linux-x64-gnu': 16.2.0-canary.45 + '@next/swc-linux-x64-musl': 16.2.0-canary.45 + '@next/swc-win32-arm64-msvc': 16.2.0-canary.45 + '@next/swc-win32-x64-msvc': 16.2.0-canary.45 '@opentelemetry/api': 1.9.0 '@playwright/test': 1.61.1 sharp: 0.34.5 @@ -14650,25 +14400,25 @@ snapshots: - '@babel/core' - babel-plugin-macros - next@16.1.4(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + next@16.2.1(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(react-dom@19.0.3(react@19.0.3))(react@19.0.3): dependencies: - '@next/env': 16.1.4 + '@next/env': 16.2.1 '@swc/helpers': 0.5.15 - baseline-browser-mapping: 2.9.11 + baseline-browser-mapping: 2.10.12 caniuse-lite: 1.0.30001766 postcss: 8.4.31 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - styled-jsx: 5.1.6(react@19.2.4) + react: 19.0.3 + react-dom: 19.0.3(react@19.0.3) + styled-jsx: 5.1.6(react@19.0.3) optionalDependencies: - '@next/swc-darwin-arm64': 16.1.4 - '@next/swc-darwin-x64': 16.1.4 - '@next/swc-linux-arm64-gnu': 16.1.4 - '@next/swc-linux-arm64-musl': 16.1.4 - '@next/swc-linux-x64-gnu': 16.1.4 - '@next/swc-linux-x64-musl': 16.1.4 - '@next/swc-win32-arm64-msvc': 16.1.4 - '@next/swc-win32-x64-msvc': 16.1.4 + '@next/swc-darwin-arm64': 16.2.1 + '@next/swc-darwin-x64': 16.2.1 + '@next/swc-linux-arm64-gnu': 16.2.1 + '@next/swc-linux-arm64-musl': 16.2.1 + '@next/swc-linux-x64-gnu': 16.2.1 + '@next/swc-linux-x64-musl': 16.2.1 + '@next/swc-win32-arm64-msvc': 16.2.1 + '@next/swc-win32-x64-msvc': 16.2.1 '@opentelemetry/api': 1.9.0 '@playwright/test': 1.61.1 sharp: 0.34.5 @@ -14676,25 +14426,25 @@ snapshots: - '@babel/core' - babel-plugin-macros - next@16.2.0-canary.45(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + next@16.2.1(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(react-dom@19.1.4(react@19.1.4))(react@19.1.4): dependencies: - '@next/env': 16.2.0-canary.45 + '@next/env': 16.2.1 '@swc/helpers': 0.5.15 baseline-browser-mapping: 2.10.12 caniuse-lite: 1.0.30001766 postcss: 8.4.31 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - styled-jsx: 5.1.6(react@19.2.4) + react: 19.1.4 + react-dom: 19.1.4(react@19.1.4) + styled-jsx: 5.1.6(react@19.1.4) optionalDependencies: - '@next/swc-darwin-arm64': 16.2.0-canary.45 - '@next/swc-darwin-x64': 16.2.0-canary.45 - '@next/swc-linux-arm64-gnu': 16.2.0-canary.45 - '@next/swc-linux-arm64-musl': 16.2.0-canary.45 - '@next/swc-linux-x64-gnu': 16.2.0-canary.45 - '@next/swc-linux-x64-musl': 16.2.0-canary.45 - '@next/swc-win32-arm64-msvc': 16.2.0-canary.45 - '@next/swc-win32-x64-msvc': 16.2.0-canary.45 + '@next/swc-darwin-arm64': 16.2.1 + '@next/swc-darwin-x64': 16.2.1 + '@next/swc-linux-arm64-gnu': 16.2.1 + '@next/swc-linux-arm64-musl': 16.2.1 + '@next/swc-linux-x64-gnu': 16.2.1 + '@next/swc-linux-x64-musl': 16.2.1 + '@next/swc-win32-arm64-msvc': 16.2.1 + '@next/swc-win32-x64-msvc': 16.2.1 '@opentelemetry/api': 1.9.0 '@playwright/test': 1.61.1 sharp: 0.34.5 @@ -14702,16 +14452,16 @@ snapshots: - '@babel/core' - babel-plugin-macros - next@16.2.1(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(react-dom@19.0.3(react@19.0.3))(react@19.0.3): + next@16.2.1(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3): dependencies: '@next/env': 16.2.1 '@swc/helpers': 0.5.15 baseline-browser-mapping: 2.10.12 caniuse-lite: 1.0.30001766 postcss: 8.4.31 - react: 19.0.3 - react-dom: 19.0.3(react@19.0.3) - styled-jsx: 5.1.6(react@19.0.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + styled-jsx: 5.1.6(react@19.2.3) optionalDependencies: '@next/swc-darwin-arm64': 16.2.1 '@next/swc-darwin-x64': 16.2.1 @@ -14728,16 +14478,16 @@ snapshots: - '@babel/core' - babel-plugin-macros - next@16.2.1(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + next@16.2.1(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: '@next/env': 16.2.1 '@swc/helpers': 0.5.15 baseline-browser-mapping: 2.10.12 caniuse-lite: 1.0.30001766 postcss: 8.4.31 - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) - styled-jsx: 5.1.6(react@19.2.3) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + styled-jsx: 5.1.6(react@19.2.4) optionalDependencies: '@next/swc-darwin-arm64': 16.2.1 '@next/swc-darwin-x64': 16.2.1 From 607ce7703278ee4fe95f77836e47030f2ad2dc6b Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Sat, 1 Aug 2026 10:34:03 +0200 Subject: [PATCH 17/44] fix issue --- packages/core/src/core/routingHandler.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/core/src/core/routingHandler.ts b/packages/core/src/core/routingHandler.ts index dcafaaa7..11122ed6 100644 --- a/packages/core/src/core/routingHandler.ts +++ b/packages/core/src/core/routingHandler.ts @@ -40,7 +40,7 @@ type MiddlewareLoader = () => Promise<{ default: Middleware }>; function defaultMiddlewareLoader() { // @ts-expect-error - This is bundled with the runtime handler. - return import("./routing/middleware.mjs"); + return import("./middleware.mjs"); } function headersToRecord(headers: Headers): Record { @@ -175,8 +175,8 @@ export default async function routingHandler( } : undefined, headers: middlewareHeaders, - requestBody: (convertBodyToReadableStream(event.method, event.body) ?? - emptyReadableStream()) as unknown as ReadableStream, + //@ts-expect-error + requestBody: convertBodyToReadableStream(event.method, event.body), pathnames: RoutingConfig.pathnames, routes: RoutingConfig.routes, invokeMiddleware: async (context) => { From 9dd1ad8587cb67486c16b7503aa274c368f27ba4 Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Sat, 1 Aug 2026 11:01:10 +0200 Subject: [PATCH 18/44] feat(routing): add restoreNullOrigin middleware to handle null host headers --- packages/core/src/core/routingHandler.ts | 30 +++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/packages/core/src/core/routingHandler.ts b/packages/core/src/core/routingHandler.ts index 11122ed6..ea30c083 100644 --- a/packages/core/src/core/routingHandler.ts +++ b/packages/core/src/core/routingHandler.ts @@ -1,4 +1,4 @@ -import { resolveRoutes, responseToMiddlewareResult } from "@next/routing"; +import { type MiddlewareResult, resolveRoutes, responseToMiddlewareResult } from "@next/routing"; import { BuildId, NextConfig, RoutingConfig } from "@/config/index"; import type { @@ -107,6 +107,33 @@ function toInternalEvent( }; } +/** + * Middleware that builds a destination from a missing `host` header - i.e. + * `new URL(path, `${protocol}://${host}`)` - ends up with a literal `null` origin + * (`https://null/path`). Such a destination would be treated as external and fetched + * from the `null` hostname, so we restore the origin of the incoming request instead. + */ +function restoreNullOrigin(result: MiddlewareResult, requestUrl: URL): void { + const restore = (url: URL): boolean => { + if (url.hostname !== "null") { + return false; + } + url.protocol = requestUrl.protocol; + url.host = requestUrl.host; + return true; + }; + + if (result.rewrite) { + restore(result.rewrite); + } + if (result.redirect && restore(result.redirect.url)) { + // The `location` header has already been derived from the redirect url. + const location = result.redirect.url.toString(); + result.responseHeaders?.set("location", location); + result.requestHeaders?.set("location", location); + } +} + function getResolvedRoute(pathname: string | undefined): ResolvedRoute[] { if (!pathname) { return []; @@ -205,6 +232,7 @@ export default async function routingHandler( body: context.requestBody, } as unknown as Request); const result = responseToMiddlewareResult(response, context.headers, context.url); + restoreNullOrigin(result, context.url); if (result.bodySent) { directMiddlewareResult = { type: event.type, From e9a508bc190687b8b5b5d9896a7014751374e3f9 Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Sat, 1 Aug 2026 12:57:53 +0200 Subject: [PATCH 19/44] feat(middleware): add getMiddlewareMatchPath function and update shouldInvokeMiddleware to use pathname test(middleware): add tests for getMiddlewareMatchPath function test(routingHandler): implement middleware matching tests for routingHandler --- packages/core/src/core/routing/middleware.ts | 35 ++++++- packages/core/src/core/routingHandler.ts | 14 ++- .../tests/core/routing/middleware.test.ts | 33 ++++++- .../routing/routingHandler.middleware.test.ts | 98 +++++++++++++++++++ 4 files changed, 172 insertions(+), 8 deletions(-) create mode 100644 packages/tests-unit/tests/core/routing/routingHandler.middleware.test.ts diff --git a/packages/core/src/core/routing/middleware.ts b/packages/core/src/core/routing/middleware.ts index 9993d913..cacbc502 100644 --- a/packages/core/src/core/routing/middleware.ts +++ b/packages/core/src/core/routing/middleware.ts @@ -34,13 +34,42 @@ function defaultMiddlewareLoader() { return import("./middleware.mjs"); } -export function shouldInvokeMiddleware(internalEvent: InternalEvent): boolean { +/** + * Returns the pathname the middleware matchers should be tested against. + * + * Next normalizes `_next/data` requests - i.e. pages router client side navigations - back to the + * user visible pathname before checking the matchers (the `middleware_next_data` route in Next + * `resolve-routes`), so that `/_next/data//foo.json` runs the same middleware as `/foo`. + * + * @param pathname the pathname resolved by the router, `basePath` included + * @param buildId the build id + * @param basePath the configured `basePath` + * @returns the pathname to match the middleware matchers against + */ +export function getMiddlewareMatchPath(pathname: string, buildId: string, basePath = ""): string { + const dataPrefix = `${basePath}/_next/data/${buildId}`; + if (!pathname.startsWith(`${dataPrefix}/`) || !pathname.endsWith(".json")) { + return pathname; + } + const normalizedPath = pathname.slice(dataPrefix.length, -".json".length).replace(/^\/index$/, "/"); + return `${basePath}${normalizedPath}`; +} + +/** + * @param internalEvent the internal event + * @param pathname the pathname to match the middleware matchers against, defaults to the localized + * path of the event. Callers routing through the resolver should pass the pathname resolved by the + * router (see `getMiddlewareMatchPath`) as the raw path of the event has not been normalized yet. + */ +export function shouldInvokeMiddleware( + internalEvent: InternalEvent, + pathname: string = localizePath(internalEvent) +): boolean { const headers = internalEvent.headers; if (headers["x-isr"] && headers["x-prerender-revalidate"] === PrerenderManifest?.preview?.previewModeId) { return false; } - const normalizedPath = localizePath(internalEvent); - return middleMatch.some((route) => route.test(normalizedPath)); + return middleMatch.some((route) => route.test(pathname)); } /** diff --git a/packages/core/src/core/routingHandler.ts b/packages/core/src/core/routingHandler.ts index ea30c083..4985c533 100644 --- a/packages/core/src/core/routingHandler.ts +++ b/packages/core/src/core/routingHandler.ts @@ -15,7 +15,7 @@ import { debug, error } from "../adapters/logger"; import { cacheInterceptor } from "./routing/cacheInterceptor"; import { detectLocale } from "./routing/i18n"; -import { shouldInvokeMiddleware } from "./routing/middleware"; +import { getMiddlewareMatchPath, shouldInvokeMiddleware } from "./routing/middleware"; import { convertBodyToReadableStream, constructNextUrl, normalizeLocationHeader } from "./routing/util"; export const MIDDLEWARE_HEADER_PREFIX = "x-middleware-response-"; @@ -188,10 +188,12 @@ export default async function routingHandler( let directMiddlewareResult: InternalResult | undefined; let middlewareHeaders = new Headers(event.headers); + const buildId = RoutingConfig.buildId || BuildId; + const basePath = NextConfig.basePath ?? ""; const routingResult = await resolveRoutes({ url: new URL(event.url), - buildId: RoutingConfig.buildId || BuildId, - basePath: NextConfig.basePath ?? "", + buildId, + basePath, i18n: NextConfig.i18n ? { ...NextConfig.i18n, @@ -208,7 +210,11 @@ export default async function routingHandler( routes: RoutingConfig.routes, invokeMiddleware: async (context) => { middlewareHeaders = context.headers; - if (!shouldInvokeMiddleware(event)) { + // The matchers must be tested against the pathname resolved by the router - the locale has + // been applied and rewrites matching before the middleware have run - and not against the + // path of the incoming request. + const matchPath = getMiddlewareMatchPath(context.url.pathname, buildId, basePath); + if (!shouldInvokeMiddleware(event, matchPath)) { return { requestHeaders: context.headers }; } diff --git a/packages/tests-unit/tests/core/routing/middleware.test.ts b/packages/tests-unit/tests/core/routing/middleware.test.ts index 4302e390..ef44ef10 100644 --- a/packages/tests-unit/tests/core/routing/middleware.test.ts +++ b/packages/tests-unit/tests/core/routing/middleware.test.ts @@ -1,4 +1,4 @@ -import { handleMiddleware } from "@opennextjs/core/core/routing/middleware.js"; +import { getMiddlewareMatchPath, handleMiddleware } from "@opennextjs/core/core/routing/middleware.js"; import { convertFromQueryString } from "@opennextjs/core/core/routing/util.js"; import type { InternalEvent } from "@opennextjs/core/types/open-next.js"; import { toReadableStream } from "@opennextjs/core/utils/stream.js"; @@ -353,3 +353,34 @@ describe("handleMiddleware", () => { ); }); }); + +describe("getMiddlewareMatchPath", () => { + it("should leave a regular pathname untouched", () => { + expect(getMiddlewareMatchPath("/foo", "build-id")).toBe("/foo"); + expect(getMiddlewareMatchPath("/base/foo", "build-id", "/base")).toBe("/base/foo"); + }); + + it("should normalize a `_next/data` pathname", () => { + expect(getMiddlewareMatchPath("/_next/data/build-id/foo.json", "build-id")).toBe("/foo"); + expect(getMiddlewareMatchPath("/_next/data/build-id/en/foo/bar.json", "build-id")).toBe("/en/foo/bar"); + }); + + it("should normalize the index `_next/data` pathname to the root", () => { + expect(getMiddlewareMatchPath("/_next/data/build-id/index.json", "build-id")).toBe("/"); + }); + + it("should keep the basePath when normalizing a `_next/data` pathname", () => { + expect(getMiddlewareMatchPath("/base/_next/data/build-id/foo.json", "build-id", "/base")).toBe( + "/base/foo" + ); + expect(getMiddlewareMatchPath("/base/_next/data/build-id/index.json", "build-id", "/base")).toBe( + "/base/" + ); + }); + + it("should not normalize a `_next/data` pathname of another build", () => { + expect(getMiddlewareMatchPath("/_next/data/other-id/foo.json", "build-id")).toBe( + "/_next/data/other-id/foo.json" + ); + }); +}); diff --git a/packages/tests-unit/tests/core/routing/routingHandler.middleware.test.ts b/packages/tests-unit/tests/core/routing/routingHandler.middleware.test.ts new file mode 100644 index 00000000..5fc63406 --- /dev/null +++ b/packages/tests-unit/tests/core/routing/routingHandler.middleware.test.ts @@ -0,0 +1,98 @@ +import routingHandler from "@opennextjs/core/core/routingHandler.js"; +import type { InternalEvent } from "@opennextjs/core/types/open-next.js"; +import { vi } from "vitest"; + +vi.mock("@/config/index", () => ({ + BuildId: "build-id", + NextConfig: { + experimental: {}, + images: {}, + i18n: { locales: ["en", "fr"], defaultLocale: "en" }, + }, + RoutingConfig: { + buildId: "build-id", + pathnames: ["/en/foo", "/en/bar"], + routeIndex: { + "/en/foo": { type: "page", isFallback: false }, + "/en/bar": { type: "page", isFallback: false }, + }, + routes: { + beforeMiddleware: [], + beforeFiles: [], + afterFiles: [], + dynamicRoutes: [], + onMatch: [], + fallback: [], + shouldNormalizeNextData: true, + }, + }, + PrerenderManifest: { routes: {}, dynamicRoutes: {}, preview: {} }, + MiddlewareManifest: { + middleware: { + // Matcher generated by Next for `matcher: "/foo"` on an i18n app - see `getMiddlewareMatchers`. + // The `_next/data` segment comes *before* the locale one. + "/": { + matchers: [ + { + regexp: "^(?:/(_next/data/[^/]{1,}))?(?:/((?!_next/)[^/.]{1,}))/foo(?:\\.json)?[/#?]?$", + originalSource: "/foo", + }, + ], + }, + }, + functions: {}, + version: 3, + }, + FunctionsConfigManifest: { functions: {}, version: 1 }, +})); + +const middleware = vi.fn().mockResolvedValue( + new Response(null, { + headers: { "x-middleware-next": "1" }, + }) +); +const middlewareLoader = vi.fn().mockResolvedValue({ default: middleware }); + +function event(pathname: string): InternalEvent { + return { + type: "core", + method: "GET", + rawPath: pathname, + url: `https://localhost${pathname}`, + headers: { host: "localhost" }, + query: {}, + cookies: {}, + remoteAddress: "127.0.0.1", + }; +} + +beforeEach(() => { + globalThis.openNextConfig = {}; + vi.clearAllMocks(); +}); + +describe("routingHandler middleware matching", () => { + it("invokes the middleware for a matching page request", async () => { + await routingHandler(event("/en/foo"), { middlewareLoader }); + + expect(middlewareLoader).toHaveBeenCalled(); + }); + + it("invokes the middleware for the `_next/data` request of a matching page", async () => { + await routingHandler(event("/_next/data/build-id/en/foo.json"), { middlewareLoader }); + + expect(middlewareLoader).toHaveBeenCalled(); + }); + + it("does not invoke the middleware for a page the matchers exclude", async () => { + await routingHandler(event("/en/bar"), { middlewareLoader }); + + expect(middlewareLoader).not.toHaveBeenCalled(); + }); + + it("does not invoke the middleware for the `_next/data` request of an excluded page", async () => { + await routingHandler(event("/_next/data/build-id/en/bar.json"), { middlewareLoader }); + + expect(middlewareLoader).not.toHaveBeenCalled(); + }); +}); From ab7a53e804ee35613c72abfffa2793a1a07900b2 Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Sat, 1 Aug 2026 13:09:13 +0200 Subject: [PATCH 20/44] feat(routing): add ISR support for prerendered routes and update related types and tests --- .../core/src/build/createRoutingConfig.ts | 14 +++++++ packages/core/src/core/routingHandler.ts | 4 +- packages/core/src/types/adapter.ts | 20 +++++++++- packages/core/src/types/open-next.ts | 5 +++ .../tests/build/createRoutingConfig.test.ts | 16 ++++++-- .../tests/core/routing/routingHandler.test.ts | 38 +++++++++++++++++-- 6 files changed, 88 insertions(+), 9 deletions(-) diff --git a/packages/core/src/build/createRoutingConfig.ts b/packages/core/src/build/createRoutingConfig.ts index 3031ec8b..928713e0 100644 --- a/packages/core/src/build/createRoutingConfig.ts +++ b/packages/core/src/build/createRoutingConfig.ts @@ -20,10 +20,24 @@ export function createRoutingConfig( routeIndex[output.pathname] = { type: outputType === "appPages" ? "app" : outputType === "appRoutes" ? "route" : "page", isFallback: false, + isISR: false, }; } } + // Prerender outputs are emitted both for the concrete pathname of every prerendered route and + // for the template pathname of every dynamic route with `getStaticPaths`/`generateStaticParams`. + // Only some of them match an executable route: the prerendered non dynamic routes and the + // dynamic templates - a request for a concrete path of a dynamic route resolves to its template. + // The remaining ones (concrete paths of dynamic routes, `.rsc` and `_next/data` variants) have + // no entry in the index and are simply ignored here. + for (const prerender of context.outputs.prerenders ?? []) { + const route = routeIndex[prerender.pathname]; + if (route) { + route.isISR = true; + } + } + const pathnames = PATHNAME_OUTPUT_TYPES.flatMap((outputType) => (context.outputs[outputType] ?? []).map((output) => output.pathname) ); diff --git a/packages/core/src/core/routingHandler.ts b/packages/core/src/core/routingHandler.ts index 4985c533..0c9cae58 100644 --- a/packages/core/src/core/routingHandler.ts +++ b/packages/core/src/core/routingHandler.ts @@ -156,7 +156,9 @@ function createRoutingResult( internalEvent: event, isExternalRewrite: options.isExternalRewrite ?? false, origin: false, - isISR: false, + // The route is only served from the cache when it is prerendered. Routes that could not be + // resolved - external rewrites, 404s - are never ISR. + isISR: resolvedRoutes.some((route) => route.isISR), resolvedRoutes, initialURL: options.initialURL ?? event.url, locale: NextConfig.i18n ? detectLocale(event, NextConfig.i18n) : undefined, diff --git a/packages/core/src/types/adapter.ts b/packages/core/src/types/adapter.ts index 0eeb1dde..c41f93c2 100644 --- a/packages/core/src/types/adapter.ts +++ b/packages/core/src/types/adapter.ts @@ -6,13 +6,26 @@ export type NextAdapterOutput = { assets: Record; }; +/** + * An ISR/prerendered output. + * + * Next emits one for every route that has a (possibly seeded) cache entry: + * - the concrete pathname of every prerendered route (i.e. `/blog/hello`), + * - the template pathname of every dynamic route with `getStaticPaths`/`generateStaticParams` + * (i.e. `/blog/[slug]`), whatever its fallback mode, + * - the data variants of both (`.rsc`, `/_next/data//....json`). + */ +export type NextAdapterPrerenderOutput = { + pathname: string; +}; + export type NextAdapterOutputs = { pages: NextAdapterOutput[]; pagesApi: NextAdapterOutput[]; appPages: NextAdapterOutput[]; appRoutes: NextAdapterOutput[]; staticFiles?: NextAdapterOutput[]; - prerenders?: NextAdapterOutput[]; + prerenders?: NextAdapterPrerenderOutput[]; middleware?: NextAdapterOutput; }; @@ -27,6 +40,11 @@ export type RuntimeRoutingConfig = { { type: "page" | "app" | "route"; isFallback: boolean; + /** + * Whether the route is prerendered - it either has a build time cache entry or is a + * dynamic route generating (and caching) its pages on demand. + */ + isISR: boolean; } >; }; diff --git a/packages/core/src/types/open-next.ts b/packages/core/src/types/open-next.ts index 8216e491..282800e3 100644 --- a/packages/core/src/types/open-next.ts +++ b/packages/core/src/types/open-next.ts @@ -161,6 +161,11 @@ export interface ResolvedRoute { * They shouldn't be used to serve the request directly. */ isFallback: boolean; + /** + * Indicates if the route is prerendered - it either has a build time cache entry or is a + * dynamic route generating (and caching) its pages on demand. + */ + isISR?: boolean; } /** diff --git a/packages/tests-unit/tests/build/createRoutingConfig.test.ts b/packages/tests-unit/tests/build/createRoutingConfig.test.ts index 227c8c2d..9545d4bc 100644 --- a/packages/tests-unit/tests/build/createRoutingConfig.test.ts +++ b/packages/tests-unit/tests/build/createRoutingConfig.test.ts @@ -24,6 +24,14 @@ describe("createRoutingConfig", () => { appPages: [{ pathname: "/app", filePath: "/app.js", assets: {} }], appRoutes: [{ pathname: "/route", filePath: "/route.js", assets: {} }], staticFiles: [{ pathname: "/asset.js", filePath: "/asset.js", assets: {} }], + prerenders: [ + // The template of a dynamic route and a prerendered non dynamic route. + { pathname: "/app" }, + { pathname: "/pages" }, + // Concrete paths and data variants do not match an executable route. + { pathname: "/pages/prerendered" }, + { pathname: "/app.rsc" }, + ], }, } as BuildCompleteContext; @@ -34,10 +42,10 @@ describe("createRoutingConfig", () => { routes: context.routing, pathnames: ["/pages", "/api/hello", "/app", "/route", "/asset.js"], routeIndex: { - "/pages": { type: "page", isFallback: false }, - "/api/hello": { type: "page", isFallback: false }, - "/app": { type: "app", isFallback: false }, - "/route": { type: "route", isFallback: false }, + "/pages": { type: "page", isFallback: false, isISR: true }, + "/api/hello": { type: "page", isFallback: false, isISR: false }, + "/app": { type: "app", isFallback: false, isISR: true }, + "/route": { type: "route", isFallback: false, isISR: false }, }, }); expect(fs.writeFileSync).toHaveBeenCalledWith( diff --git a/packages/tests-unit/tests/core/routing/routingHandler.test.ts b/packages/tests-unit/tests/core/routing/routingHandler.test.ts index fb83edd3..c7afe924 100644 --- a/packages/tests-unit/tests/core/routing/routingHandler.test.ts +++ b/packages/tests-unit/tests/core/routing/routingHandler.test.ts @@ -7,9 +7,11 @@ vi.mock("@/config/index", () => ({ NextConfig: { experimental: {}, images: {} }, RoutingConfig: { buildId: "build-id", - pathnames: ["/about"], + pathnames: ["/about", "/blog/[slug]", "/ssr"], routeIndex: { - "/about": { type: "app", isFallback: false }, + "/about": { type: "app", isFallback: false, isISR: false }, + "/blog/[slug]": { type: "app", isFallback: false, isISR: true }, + "/ssr": { type: "app", isFallback: false, isISR: false }, }, routes: { beforeMiddleware: [ @@ -25,7 +27,12 @@ vi.mock("@/config/index", () => ({ { sourceRegex: "^/rewrite$", destination: "/about?from=rewrite" }, { sourceRegex: "^/external$", destination: "https://example.com/target" }, ], - dynamicRoutes: [], + dynamicRoutes: [ + { + sourceRegex: "^/blog/(?[^/]+?)$", + destination: "/blog/[slug]?slug=$nxtPslug", + }, + ], onMatch: [], fallback: [], }, @@ -65,6 +72,31 @@ describe("routingHandler", () => { }); }); + it("flags requests resolving to a prerendered route as ISR", async () => { + const result = await routingHandler(event("/blog/hello")); + + expect(result).toMatchObject({ + isISR: true, + resolvedRoutes: [{ route: "/blog/[slug]", type: "app", isFallback: false, isISR: true }], + }); + }); + + it("does not flag requests resolving to a non prerendered route as ISR", async () => { + const result = await routingHandler(event("/ssr")); + + expect(result).toMatchObject({ isISR: false }); + }); + + it("does not flag unresolved routes as ISR", async () => { + const result = await routingHandler(event("/unknown")); + + expect(result).toMatchObject({ + isISR: false, + internalEvent: { rawPath: "/404" }, + resolvedRoutes: [], + }); + }); + it("returns redirects directly without invoking an entrypoint", async () => { const result = await routingHandler(event("/old")); From 16aae78efc794969b42928982375bc8359feb0c8 Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Sat, 1 Aug 2026 13:12:12 +0200 Subject: [PATCH 21/44] refactor(headers): simplify test descriptions and remove deprecated config references --- .../e2e/app-router/e2e/headers.test.ts | 2 +- examples-cloudflare/e2e/app-router/middleware.ts | 2 +- .../e2e/app-router/open-next.config.ts | 8 +------- examples/app-router/open-next.config.ts | 4 ---- examples/app-router/proxy.ts | 2 +- packages/core/src/types/open-next.ts | 11 ----------- packages/tests-e2e/tests/appRouter/headers.test.ts | 2 +- 7 files changed, 5 insertions(+), 26 deletions(-) diff --git a/examples-cloudflare/e2e/app-router/e2e/headers.test.ts b/examples-cloudflare/e2e/app-router/e2e/headers.test.ts index 4b4ed36d..ceed1325 100644 --- a/examples-cloudflare/e2e/app-router/e2e/headers.test.ts +++ b/examples-cloudflare/e2e/app-router/e2e/headers.test.ts @@ -29,7 +29,7 @@ test("Headers", async ({ page }) => { expect(headers["x-opennext-requestid"]).not.toBeFalsy(); }); /** - * Tests that the middleware headers are applied after next.config.js headers. Requires 'dangerous.middlewareHeadersOverrideNextConfigHeaders' to be set. + * Tests that the middleware headers are applied after next.config.js headers. */ test("Middleware headers override next.config.js headers", async ({ page }) => { const responsePromise = page.waitForResponse((response) => { diff --git a/examples-cloudflare/e2e/app-router/middleware.ts b/examples-cloudflare/e2e/app-router/middleware.ts index 93014807..a45e0544 100644 --- a/examples-cloudflare/e2e/app-router/middleware.ts +++ b/examples-cloudflare/e2e/app-router/middleware.ts @@ -47,7 +47,7 @@ export function middleware(request: NextRequest) { // Response headers should show up in the client's response headers responseHeaders.set("response-header", "response-header"); - // For dangerous.middlewareHeadersOverrideNextConfigHeaders we need to verify that middleware headers override next.config.js headers. + // We need to verify that middleware headers override next.config.js headers. if (path === "/headers/override-from-middleware") { responseHeaders.set("e2e-headers", "middleware"); return NextResponse.json({}, { headers: responseHeaders }); diff --git a/examples-cloudflare/e2e/app-router/open-next.config.ts b/examples-cloudflare/e2e/app-router/open-next.config.ts index ecd737e2..74e1fb1a 100644 --- a/examples-cloudflare/e2e/app-router/open-next.config.ts +++ b/examples-cloudflare/e2e/app-router/open-next.config.ts @@ -24,10 +24,4 @@ const baseConfig = defineCloudflareConfig({ queue: queueCache(doQueue), }); -export default { - ...baseConfig, - dangerous: { - ...baseConfig.dangerous, - middlewareHeadersOverrideNextConfigHeaders: true, - }, -}; +export default baseConfig; diff --git a/examples/app-router/open-next.config.ts b/examples/app-router/open-next.config.ts index e52dd92b..b08f90fe 100644 --- a/examples/app-router/open-next.config.ts +++ b/examples/app-router/open-next.config.ts @@ -11,10 +11,6 @@ export default { }, }, - dangerous: { - middlewareHeadersOverrideNextConfigHeaders: true, - }, - imageOptimization: { override: { wrapper: "dummy", diff --git a/examples/app-router/proxy.ts b/examples/app-router/proxy.ts index 05ce980e..f9d4b402 100644 --- a/examples/app-router/proxy.ts +++ b/examples/app-router/proxy.ts @@ -47,7 +47,7 @@ export default function proxy(request: NextRequest) { // Response headers should show up in the client's response headers responseHeaders.set("response-header", "response-header"); - // For dangerous.middlewareHeadersOverrideNextConfigHeaders we need to verify that middleware headers override next.config.js headers. + // We need to verify that middleware headers override next.config.js headers. if (path === "/headers/override-from-middleware") { responseHeaders.set("e2e-headers", "middleware"); return NextResponse.json({}, { headers: responseHeaders }); diff --git a/packages/core/src/types/open-next.ts b/packages/core/src/types/open-next.ts index 282800e3..661a4603 100644 --- a/packages/core/src/types/open-next.ts +++ b/packages/core/src/types/open-next.ts @@ -105,17 +105,6 @@ export interface DangerousOptions { * This is executed for every request and after next config headers and middleware has executed. */ headersAndCookiesPriority?: (event: InternalEvent) => "middleware" | "handler"; - - /** - * Configuration option to prioritize headers set via middleware over headers set via the option in the Next config. - * - * The default will change to 'true' in v4. - * - * See also {@link https://nextjs.org/docs/app/api-reference/file-conventions/middleware#execution-order} - * - * @default false - */ - middlewareHeadersOverrideNextConfigHeaders?: boolean; } export type BaseOverride = { diff --git a/packages/tests-e2e/tests/appRouter/headers.test.ts b/packages/tests-e2e/tests/appRouter/headers.test.ts index 7911dc11..159dccee 100644 --- a/packages/tests-e2e/tests/appRouter/headers.test.ts +++ b/packages/tests-e2e/tests/appRouter/headers.test.ts @@ -30,7 +30,7 @@ test("Headers", async ({ page }) => { }); /** - * Tests that the middleware headers are applied after next.config.js headers. Requires 'dangerous.middlewareHeadersOverrideNextConfigHeaders' to be set. + * Tests that the middleware headers are applied after next.config.js headers. */ test("Middleware headers override next.config.js headers", async ({ page }) => { const responsePromise = page.waitForResponse((response) => { From 5b562ef835586ba4581ed269c756a7af9f4da507 Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Sat, 1 Aug 2026 13:17:34 +0200 Subject: [PATCH 22/44] feat(routing): ensure single redirect target in response headers --- packages/core/src/core/routingHandler.ts | 11 ++++++---- .../tests/core/routing/routingHandler.test.ts | 22 +++++++++++++++++-- 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/packages/core/src/core/routingHandler.ts b/packages/core/src/core/routingHandler.ts index 0c9cae58..4485563d 100644 --- a/packages/core/src/core/routingHandler.ts +++ b/packages/core/src/core/routingHandler.ts @@ -260,13 +260,16 @@ export default async function routingHandler( const responseHeaders = routingResult.resolvedHeaders ?? new Headers(); if (routingResult.redirect) { + // The resolver already set a `location` from the matched route headers. It must be replaced + // - and not merely added to the record - so that the response has a single redirect target. + responseHeaders.set( + "location", + normalizeLocationHeader(routingResult.redirect.url.toString(), event.url, true) + ); return { type: event.type, statusCode: routingResult.redirect.status, - headers: { - ...headersToRecord(responseHeaders), - Location: normalizeLocationHeader(routingResult.redirect.url.toString(), event.url, true), - }, + headers: headersToRecord(responseHeaders), body: emptyReadableStream(), isBase64Encoded: false, }; diff --git a/packages/tests-unit/tests/core/routing/routingHandler.test.ts b/packages/tests-unit/tests/core/routing/routingHandler.test.ts index c7afe924..49132a6e 100644 --- a/packages/tests-unit/tests/core/routing/routingHandler.test.ts +++ b/packages/tests-unit/tests/core/routing/routingHandler.test.ts @@ -1,5 +1,5 @@ import routingHandler from "@opennextjs/core/core/routingHandler.js"; -import type { InternalEvent } from "@opennextjs/core/types/open-next.js"; +import type { InternalEvent, InternalResult } from "@opennextjs/core/types/open-next.js"; import { vi } from "vitest"; vi.mock("@/config/index", () => ({ @@ -21,6 +21,12 @@ vi.mock("@/config/index", () => ({ headers: { location: "/about" }, status: 308, }, + { + sourceRegex: "^/moved$", + destination: "/about?q=a b", + headers: { Location: "https://localhost/about?q=a b", "x-custom": "1" }, + status: 308, + }, ], beforeFiles: [], afterFiles: [ @@ -102,8 +108,20 @@ describe("routingHandler", () => { expect(result).toMatchObject({ statusCode: 308, - headers: { Location: "/about" }, + headers: { location: "/about" }, + }); + }); + + it("returns a single redirect target when the matched route already set a location", async () => { + const result = await routingHandler(event("/moved")); + + expect((result as InternalResult).headers).toMatchObject({ + location: "/about?q=a%20b", + "x-custom": "1", }); + expect( + Object.keys((result as InternalResult).headers).filter((key) => key.toLowerCase() === "location") + ).toEqual(["location"]); }); it("uses the resolver invocation target for internal rewrites", async () => { From 65a11530da90ad7e8a0297797f66f10a9e25a516 Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Sat, 1 Aug 2026 13:32:17 +0200 Subject: [PATCH 23/44] feat(routing): add support for trailing slash variants in routing configuration and tests --- .../core/src/build/createRoutingConfig.ts | 22 +++++++++- packages/core/src/core/routingHandler.ts | 7 +++- .../tests/build/createRoutingConfig.test.ts | 42 +++++++++++++++++++ .../tests/core/routing/routingHandler.test.ts | 15 ++++++- 4 files changed, 82 insertions(+), 4 deletions(-) diff --git a/packages/core/src/build/createRoutingConfig.ts b/packages/core/src/build/createRoutingConfig.ts index 928713e0..bf4138bc 100644 --- a/packages/core/src/build/createRoutingConfig.ts +++ b/packages/core/src/build/createRoutingConfig.ts @@ -9,6 +9,23 @@ import type * as buildHelper from "./helper.js"; const EXECUTABLE_OUTPUT_TYPES = ["pages", "pagesApi", "appPages", "appRoutes"] as const; const PATHNAME_OUTPUT_TYPES = [...EXECUTABLE_OUTPUT_TYPES, "staticFiles"] as const; +// Matches the pathnames Next considers to be files - i.e. whose last segment has an extension. +const FILE_PATHNAME_REGEX = /\.\w+$/; + +/** + * Adds the trailing slash variant of every pathname that Next canonicalizes with a trailing slash. + * + * The resolver matches pathnames by strict equality and has no notion of `trailingSlash`, so with + * the option enabled it would never match a request - they all carry a trailing slash. Both forms + * are needed: the slash free one is the one matching while the canonicalizing redirect is emitted. + */ +function withTrailingSlashVariants(pathnames: string[]): string[] { + return pathnames.flatMap((pathname) => + // Files are canonicalized the other way around - their trailing slash is stripped. + pathname.endsWith("/") || FILE_PATHNAME_REGEX.test(pathname) ? [pathname] : [pathname, `${pathname}/`] + ); +} + export function createRoutingConfig( options: buildHelper.BuildOptions, context: BuildCompleteContext @@ -38,9 +55,12 @@ export function createRoutingConfig( } } - const pathnames = PATHNAME_OUTPUT_TYPES.flatMap((outputType) => + const outputPathnames = PATHNAME_OUTPUT_TYPES.flatMap((outputType) => (context.outputs[outputType] ?? []).map((output) => output.pathname) ); + const pathnames = context.config.trailingSlash + ? withTrailingSlashVariants(outputPathnames) + : outputPathnames; const routingConfig: RuntimeRoutingConfig = { buildId: context.buildId, routes: context.routing, diff --git a/packages/core/src/core/routingHandler.ts b/packages/core/src/core/routingHandler.ts index 4485563d..6e861c01 100644 --- a/packages/core/src/core/routingHandler.ts +++ b/packages/core/src/core/routingHandler.ts @@ -138,8 +138,11 @@ function getResolvedRoute(pathname: string | undefined): ResolvedRoute[] { if (!pathname) { return []; } - const route = RoutingConfig.routeIndex[pathname]; - return route ? [{ route: pathname, ...route }] : []; + // When `trailingSlash` is enabled the resolver matches - and reports - the trailing slash variant + // of the pathname. The index is keyed by the route itself, which never has a trailing slash. + const route = pathname.length > 1 && pathname.endsWith("/") ? pathname.slice(0, -1) : pathname; + const indexedRoute = RoutingConfig.routeIndex[route]; + return indexedRoute ? [{ route, ...indexedRoute }] : []; } function createRoutingResult( diff --git a/packages/tests-unit/tests/build/createRoutingConfig.test.ts b/packages/tests-unit/tests/build/createRoutingConfig.test.ts index 9545d4bc..82f02563 100644 --- a/packages/tests-unit/tests/build/createRoutingConfig.test.ts +++ b/packages/tests-unit/tests/build/createRoutingConfig.test.ts @@ -10,6 +10,7 @@ describe("createRoutingConfig", () => { it("serializes routing metadata and executable route classifications", () => { const context = { buildId: "build-id", + config: {}, routing: { beforeMiddleware: [], beforeFiles: [], @@ -53,4 +54,45 @@ describe("createRoutingConfig", () => { JSON.stringify(result) ); }); + + it("emits a trailing slash variant of every non file pathname when `trailingSlash` is enabled", () => { + const context = { + buildId: "build-id", + config: { trailingSlash: true }, + routing: { + beforeMiddleware: [], + beforeFiles: [], + afterFiles: [], + dynamicRoutes: [], + onMatch: [], + fallback: [], + }, + outputs: { + pages: [ + { pathname: "/pages", filePath: "/pages.js", assets: {} }, + { pathname: "/blog/[slug]", filePath: "/blog.js", assets: {} }, + // Data variants are files - Next strips their trailing slash instead of adding one. + { pathname: "/_next/data/build-id/pages.json", filePath: "/pages.js", assets: {} }, + ], + pagesApi: [{ pathname: "/api/hello", filePath: "/api.js", assets: {} }], + appPages: [{ pathname: "/", filePath: "/index.js", assets: {} }], + appRoutes: [], + staticFiles: [{ pathname: "/asset.js", filePath: "/asset.js", assets: {} }], + }, + } as unknown as BuildCompleteContext; + + const result = createRoutingConfig({ appBuildOutputPath: "/app" } as never, context); + + expect(result.pathnames).toEqual([ + "/pages", + "/pages/", + "/blog/[slug]", + "/blog/[slug]/", + "/_next/data/build-id/pages.json", + "/api/hello", + "/api/hello/", + "/", + "/asset.js", + ]); + }); }); diff --git a/packages/tests-unit/tests/core/routing/routingHandler.test.ts b/packages/tests-unit/tests/core/routing/routingHandler.test.ts index 49132a6e..a47f3c46 100644 --- a/packages/tests-unit/tests/core/routing/routingHandler.test.ts +++ b/packages/tests-unit/tests/core/routing/routingHandler.test.ts @@ -7,7 +7,8 @@ vi.mock("@/config/index", () => ({ NextConfig: { experimental: {}, images: {} }, RoutingConfig: { buildId: "build-id", - pathnames: ["/about", "/blog/[slug]", "/ssr"], + // `/about/` is the trailing slash variant emitted for `trailingSlash` enabled apps. + pathnames: ["/about", "/about/", "/blog/[slug]", "/ssr"], routeIndex: { "/about": { type: "app", isFallback: false, isISR: false }, "/blog/[slug]": { type: "app", isFallback: false, isISR: true }, @@ -78,6 +79,18 @@ describe("routingHandler", () => { }); }); + it("selects the executable route of a pathname resolved with a trailing slash", async () => { + const result = await routingHandler(event("/about/")); + + expect(result).toMatchObject({ + internalEvent: { + rawPath: "/about/", + url: "https://localhost/about/", + }, + resolvedRoutes: [{ route: "/about", type: "app", isFallback: false }], + }); + }); + it("flags requests resolving to a prerendered route as ISR", async () => { const result = await routingHandler(event("/blog/hello")); From 76c9df5fb5aa3ec56d149f6ad9ed4451719d4a4a Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Sat, 1 Aug 2026 13:50:31 +0200 Subject: [PATCH 24/44] feat(routing): implement prerender route resolution and update related types and tests --- .../core/src/build/createRoutingConfig.ts | 111 ++++++++++++++-- packages/core/src/core/routingHandler.ts | 13 +- packages/core/src/types/adapter.ts | 8 ++ .../tests/build/createRoutingConfig.test.ts | 123 ++++++++++++++++++ .../tests/core/routing/routingHandler.test.ts | 19 ++- 5 files changed, 256 insertions(+), 18 deletions(-) diff --git a/packages/core/src/build/createRoutingConfig.ts b/packages/core/src/build/createRoutingConfig.ts index bf4138bc..628c5d95 100644 --- a/packages/core/src/build/createRoutingConfig.ts +++ b/packages/core/src/build/createRoutingConfig.ts @@ -26,6 +26,83 @@ function withTrailingSlashVariants(pathnames: string[]): string[] { ); } +/** + * Applies the captures of a `sourceRegex` match to the destination of a route. + * + * Mirrors the substitution the resolver performs at runtime. + */ +function applyCaptures(destination: string, match: RegExpMatchArray): string { + let resolved = destination; + for (let index = 1; index < match.length; index++) { + if (match[index] !== undefined) { + resolved = resolved.replaceAll(`$${index}`, match[index]); + } + } + for (const [name, value] of Object.entries(match.groups ?? {})) { + if (value !== undefined) { + resolved = resolved.replaceAll(`$${name}`, value); + } + } + return resolved; +} + +/** + * Creates the resolver of the executable route serving a prerendered pathname. + * + * Prerender outputs are emitted for the concrete pathname of every prerendered route + * (`/blog/hello`), for the template pathname of every dynamic route with `getStaticPaths`/ + * `generateStaticParams` (`/blog/[slug]`) and for the data variants of both. Only the templates are + * executable, so a concrete pathname has to be routed back to the template regenerating it when its + * cache entry is missing or stale. + */ +function createPrerenderRouteResolver(context: BuildCompleteContext, executableRoutes: Set) { + const basePath = context.config.basePath ?? ""; + const dataPrefix = `${basePath}/_next/data/${context.buildId}/`; + const dynamicRoutes = context.routing.dynamicRoutes.map((route) => ({ + regex: new RegExp(route.sourceRegex), + destination: route.destination, + })); + + /** + * The executable destination of the highest priority dynamic route matching the pathname. + * + * Only the first match is considered: a lower priority route is not the one Next would have used, + * even when the higher priority one has no executable destination. The `has`/`missing` conditions + * are ignored - they gate draft mode, not which route owns the pathname. + */ + function matchDynamicRoute(pathname: string): string | undefined { + for (const { regex, destination } of dynamicRoutes) { + const match = pathname.match(regex); + if (!match) { + continue; + } + if (!destination) { + return undefined; + } + const [target] = applyCaptures(destination, match).split("?"); + return executableRoutes.has(target) ? target : undefined; + } + return undefined; + } + + return function resolvePrerenderRoute(pathname: string): string | undefined { + if (executableRoutes.has(pathname)) { + return pathname; + } + const dynamicRoute = matchDynamicRoute(pathname); + if (dynamicRoute) { + return dynamicRoute; + } + // The data variant of a prerendered pages router route has no output of its own - it is served + // by the route itself, which Next reaches by normalizing the `/_next/data/` pathname. + if (!pathname.startsWith(dataPrefix) || !pathname.endsWith(".json")) { + return undefined; + } + const normalized = `${basePath}/${pathname.slice(dataPrefix.length, -".json".length)}`; + return executableRoutes.has(normalized) ? normalized : matchDynamicRoute(normalized); + }; +} + export function createRoutingConfig( options: buildHelper.BuildOptions, context: BuildCompleteContext @@ -42,25 +119,35 @@ export function createRoutingConfig( } } - // Prerender outputs are emitted both for the concrete pathname of every prerendered route and - // for the template pathname of every dynamic route with `getStaticPaths`/`generateStaticParams`. - // Only some of them match an executable route: the prerendered non dynamic routes and the - // dynamic templates - a request for a concrete path of a dynamic route resolves to its template. - // The remaining ones (concrete paths of dynamic routes, `.rsc` and `_next/data` variants) have - // no entry in the index and are simply ignored here. + // The set has to be snapshotted before the prerendered pathnames are indexed - they are servable + // but not executable, and may never be the resolution target of another prerendered pathname. + const executableRoutes = new Set(Object.keys(routeIndex)); + const resolvePrerenderRoute = createPrerenderRouteResolver(context, executableRoutes); + const prerenderPathnames: string[] = []; + for (const prerender of context.outputs.prerenders ?? []) { - const route = routeIndex[prerender.pathname]; - if (route) { - route.isISR = true; + const route = resolvePrerenderRoute(prerender.pathname); + if (!route) { + // Artifacts no route can regenerate - i.e. the PPR segment prefetches - are served from the + // cache only and are left out of the index. + continue; + } + routeIndex[route].isISR = true; + if (route !== prerender.pathname) { + routeIndex[prerender.pathname] = { ...routeIndex[route], route }; + prerenderPathnames.push(prerender.pathname); } } const outputPathnames = PATHNAME_OUTPUT_TYPES.flatMap((outputType) => (context.outputs[outputType] ?? []).map((output) => output.pathname) ); - const pathnames = context.config.trailingSlash - ? withTrailingSlashVariants(outputPathnames) - : outputPathnames; + const servablePathnames = [...outputPathnames, ...prerenderPathnames]; + const pathnames = [ + ...new Set( + context.config.trailingSlash ? withTrailingSlashVariants(servablePathnames) : servablePathnames + ), + ]; const routingConfig: RuntimeRoutingConfig = { buildId: context.buildId, routes: context.routing, diff --git a/packages/core/src/core/routingHandler.ts b/packages/core/src/core/routingHandler.ts index 6e861c01..3806c3ca 100644 --- a/packages/core/src/core/routingHandler.ts +++ b/packages/core/src/core/routingHandler.ts @@ -139,10 +139,15 @@ function getResolvedRoute(pathname: string | undefined): ResolvedRoute[] { return []; } // When `trailingSlash` is enabled the resolver matches - and reports - the trailing slash variant - // of the pathname. The index is keyed by the route itself, which never has a trailing slash. - const route = pathname.length > 1 && pathname.endsWith("/") ? pathname.slice(0, -1) : pathname; - const indexedRoute = RoutingConfig.routeIndex[route]; - return indexedRoute ? [{ route, ...indexedRoute }] : []; + // of the pathname. The index is keyed by the pathname itself, which never has a trailing slash. + const indexKey = pathname.length > 1 && pathname.endsWith("/") ? pathname.slice(0, -1) : pathname; + const indexedRoute = RoutingConfig.routeIndex[indexKey]; + if (!indexedRoute) { + return []; + } + // A prerendered pathname is served by the route that generated it, not by itself. + const { route = indexKey, ...routeMetadata } = indexedRoute; + return [{ route, ...routeMetadata }]; } function createRoutingResult( diff --git a/packages/core/src/types/adapter.ts b/packages/core/src/types/adapter.ts index c41f93c2..3f2da903 100644 --- a/packages/core/src/types/adapter.ts +++ b/packages/core/src/types/adapter.ts @@ -35,6 +35,9 @@ export type RuntimeRoutingConfig = { buildId: string; routes: NextAdapterRouting; pathnames: string[]; + /** + * The servable pathnames, mapped to the route serving them. + */ routeIndex: Record< string, { @@ -45,6 +48,11 @@ export type RuntimeRoutingConfig = { * dynamic route generating (and caching) its pages on demand. */ isISR: boolean; + /** + * The executable route serving the pathname when it is not the pathname itself - i.e. the + * template of the dynamic route that generated a prerendered pathname. + */ + route?: string; } >; }; diff --git a/packages/tests-unit/tests/build/createRoutingConfig.test.ts b/packages/tests-unit/tests/build/createRoutingConfig.test.ts index 82f02563..0cc78f5a 100644 --- a/packages/tests-unit/tests/build/createRoutingConfig.test.ts +++ b/packages/tests-unit/tests/build/createRoutingConfig.test.ts @@ -95,4 +95,127 @@ describe("createRoutingConfig", () => { "/asset.js", ]); }); + + it("serves prerendered pathnames from the route that generated them", () => { + const context = { + buildId: "build-id", + config: {}, + routing: { + beforeMiddleware: [], + beforeFiles: [], + afterFiles: [], + dynamicRoutes: [ + { + sourceRegex: String.raw`^/_next/data/build\-id/blog/(?[^/]+?)\.json$`, + destination: "/_next/data/build-id/blog/[slug].json?nxtPslug=$nxtPslug", + // Prerendered routes are only routed to their function in draft mode. + has: [{ type: "cookie", key: "__prerender_bypass" }], + }, + { + sourceRegex: String.raw`^/blog/(?[^/]+?)$`, + destination: "/blog/[slug]?nxtPslug=$nxtPslug", + has: [{ type: "cookie", key: "__prerender_bypass" }], + }, + ], + onMatch: [], + fallback: [], + }, + outputs: { + pages: [ + { pathname: "/blog/[slug]", filePath: "/blog.js", assets: {} }, + { pathname: "/isr", filePath: "/isr.js", assets: {} }, + ], + pagesApi: [], + appPages: [], + appRoutes: [], + prerenders: [ + // A concrete prerendered pathname and its data variant, neither of which is executable. + { pathname: "/blog/hello" }, + { pathname: "/_next/data/build-id/blog/hello.json" }, + // The template of the dynamic route generating them, and its data variant. + { pathname: "/blog/[slug]" }, + { pathname: "/_next/data/build-id/blog/[slug].json" }, + // A non dynamic prerendered route and its data variant. + { pathname: "/isr" }, + { pathname: "/_next/data/build-id/isr.json" }, + // A PPR segment prefetch - no route can regenerate it. + { pathname: "/isr.segments/_tree.segment.rsc" }, + ], + }, + } as unknown as BuildCompleteContext; + + const result = createRoutingConfig({ appBuildOutputPath: "/app" } as never, context); + + expect(result.pathnames).toEqual([ + "/blog/[slug]", + "/isr", + "/blog/hello", + "/_next/data/build-id/blog/hello.json", + "/_next/data/build-id/blog/[slug].json", + "/_next/data/build-id/isr.json", + ]); + expect(result.routeIndex).toEqual({ + "/blog/[slug]": { type: "page", isFallback: false, isISR: true }, + "/isr": { type: "page", isFallback: false, isISR: true }, + "/blog/hello": { type: "page", isFallback: false, isISR: true, route: "/blog/[slug]" }, + "/_next/data/build-id/blog/hello.json": { + type: "page", + isFallback: false, + isISR: true, + route: "/blog/[slug]", + }, + "/_next/data/build-id/blog/[slug].json": { + type: "page", + isFallback: false, + isISR: true, + route: "/blog/[slug]", + }, + "/_next/data/build-id/isr.json": { + type: "page", + isFallback: false, + isISR: true, + route: "/isr", + }, + }); + }); + + it("keeps the highest priority dynamic route when it has no executable destination", () => { + const context = { + buildId: "build-id", + config: {}, + routing: { + beforeMiddleware: [], + beforeFiles: [], + afterFiles: [], + dynamicRoutes: [ + // `/blog/[slug]` takes precedence over the catch all, but only the catch all is + // executable - the prerendered pathname must not fall through to it. + { + sourceRegex: String.raw`^/blog/(?[^/]+?)$`, + destination: "/blog/[slug]?nxtPslug=$nxtPslug", + }, + { + sourceRegex: String.raw`^/blog/(?.+?)$`, + destination: "/blog/[...slugs]?nxtPslugs=$nxtPslugs", + }, + ], + onMatch: [], + fallback: [], + }, + outputs: { + pages: [{ pathname: "/blog/[...slugs]", filePath: "/slugs.js", assets: {} }], + pagesApi: [], + appPages: [], + appRoutes: [], + prerenders: [{ pathname: "/blog/hello" }], + }, + } as unknown as BuildCompleteContext; + + const result = createRoutingConfig({ appBuildOutputPath: "/app" } as never, context); + + expect(result.pathnames).toEqual(["/blog/[...slugs]"]); + expect(result.routeIndex).toEqual({ + "/blog/[...slugs]": { type: "page", isFallback: false, isISR: false }, + }); + }); }); diff --git a/packages/tests-unit/tests/core/routing/routingHandler.test.ts b/packages/tests-unit/tests/core/routing/routingHandler.test.ts index a47f3c46..18611577 100644 --- a/packages/tests-unit/tests/core/routing/routingHandler.test.ts +++ b/packages/tests-unit/tests/core/routing/routingHandler.test.ts @@ -7,11 +7,13 @@ vi.mock("@/config/index", () => ({ NextConfig: { experimental: {}, images: {} }, RoutingConfig: { buildId: "build-id", - // `/about/` is the trailing slash variant emitted for `trailingSlash` enabled apps. - pathnames: ["/about", "/about/", "/blog/[slug]", "/ssr"], + // `/about/` is the trailing slash variant emitted for `trailingSlash` enabled apps and + // `/blog/prerendered` a prerendered pathname of the `/blog/[slug]` route. + pathnames: ["/about", "/about/", "/blog/[slug]", "/blog/prerendered", "/ssr"], routeIndex: { "/about": { type: "app", isFallback: false, isISR: false }, "/blog/[slug]": { type: "app", isFallback: false, isISR: true }, + "/blog/prerendered": { type: "app", isFallback: false, isISR: true, route: "/blog/[slug]" }, "/ssr": { type: "app", isFallback: false, isISR: false }, }, routes: { @@ -100,6 +102,19 @@ describe("routingHandler", () => { }); }); + it("serves a prerendered pathname from the route that generated it", async () => { + const result = await routingHandler(event("/blog/prerendered")); + + expect(result).toMatchObject({ + isISR: true, + internalEvent: { + rawPath: "/blog/prerendered", + url: "https://localhost/blog/prerendered?slug=prerendered", + }, + resolvedRoutes: [{ route: "/blog/[slug]", type: "app", isFallback: false, isISR: true }], + }); + }); + it("does not flag requests resolving to a non prerendered route as ISR", async () => { const result = await routingHandler(event("/ssr")); From 2da77c907adeaef32a140d04a5e84469d12a86be Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Sat, 1 Aug 2026 14:00:17 +0200 Subject: [PATCH 25/44] feat(routing): add priority route handling and related tests for redirect resolution --- .../core/src/core/routing/priorityRoutes.ts | 131 ++++++++++++++++++ packages/core/src/core/routingHandler.ts | 19 ++- .../tests/core/routing/priorityRoutes.test.ts | 106 ++++++++++++++ .../routingHandler.trailingSlash.test.ts | 126 +++++++++++++++++ 4 files changed, 380 insertions(+), 2 deletions(-) create mode 100644 packages/core/src/core/routing/priorityRoutes.ts create mode 100644 packages/tests-unit/tests/core/routing/priorityRoutes.test.ts create mode 100644 packages/tests-unit/tests/core/routing/routingHandler.trailingSlash.test.ts diff --git a/packages/core/src/core/routing/priorityRoutes.ts b/packages/core/src/core/routing/priorityRoutes.ts new file mode 100644 index 00000000..72595da5 --- /dev/null +++ b/packages/core/src/core/routing/priorityRoutes.ts @@ -0,0 +1,131 @@ +import type { NextAdapterRouting } from "@/types/adapter"; + +type ResolverRoute = NextAdapterRouting["beforeMiddleware"][number]; +type RouteCondition = NonNullable[number]; + +/** + * A route Next applies to the request as it came in. + * + * Next flags its internal canonicalizing redirects - the ones adding or stripping the trailing + * slash - as `priority` because it matches them before normalizing the request: before the + * `/_next/data/` pathname is rewritten to the page it carries and before the locale is resolved. + * The resolver knows no such phase and runs them with the rest of `beforeMiddleware`, i.e. against + * an already normalized pathname, which redirects every data request to its page. + */ +type PriorityRoute = ResolverRoute & { priority?: boolean }; + +/** + * A priority route we take over from the resolver. + * + * Only the redirects are: they are matched and applied here, on the incoming request. A priority + * route with a destination is a rewrite, which we have no reason to hoist out of the resolver. + */ +function isPriorityRedirect(route: PriorityRoute): boolean { + return Boolean(route.priority) && route.destination === undefined; +} + +function getConditionValue(condition: RouteCondition, url: URL, headers: Headers): string | undefined { + switch (condition.type) { + case "header": + return headers.get(condition.key) ?? undefined; + case "cookie": + return headers + .get("cookie") + ?.split(";") + .map((cookie) => cookie.trim().split("=")) + .find(([key]) => key === condition.key) + ?.slice(1) + .join("="); + case "query": + return url.searchParams.get(condition.key) ?? undefined; + case "host": + return url.hostname; + } +} + +function matchesCondition(value: string | undefined, expected?: string): boolean { + if (value === undefined) { + return false; + } + if (expected === undefined) { + return true; + } + try { + if (new RegExp(expected).test(value)) { + return true; + } + } catch { + // An unparsable condition falls back to an exact comparison, as the resolver does. + } + return value === expected; +} + +function matchesConditions(route: PriorityRoute, url: URL, headers: Headers): boolean { + const has = route.has?.every((condition) => + matchesCondition(getConditionValue(condition, url, headers), condition.value) + ); + const missing = route.missing?.every( + (condition) => !matchesCondition(getConditionValue(condition, url, headers), condition.value) + ); + return (has ?? true) && (missing ?? true); +} + +/** + * Splits the routes Next matches against the incoming request off the ones the resolver runs. + */ +export function splitPriorityRoutes(routes: NextAdapterRouting): { + priorityRoutes: PriorityRoute[]; + resolverRoutes: NextAdapterRouting; +} { + const priorityRoutes = routes.beforeMiddleware.filter(isPriorityRedirect); + if (priorityRoutes.length === 0) { + return { priorityRoutes, resolverRoutes: routes }; + } + return { + priorityRoutes, + resolverRoutes: { + ...routes, + beforeMiddleware: routes.beforeMiddleware.filter((route) => !isPriorityRedirect(route)), + }, + }; +} + +/** + * Resolves the redirect the priority routes produce for the request, if any. + */ +export function resolvePriorityRedirect( + priorityRoutes: PriorityRoute[], + url: URL, + requestHeaders: Headers +): { status: number; headers: Headers } | undefined { + const headers = new Headers(); + let status: number | undefined; + + for (const route of priorityRoutes) { + const match = url.pathname.match(new RegExp(route.sourceRegex)); + if (!match || !matchesConditions(route, url, requestHeaders)) { + continue; + } + for (const [key, value] of Object.entries(route.headers ?? {})) { + headers.set( + key, + value.replace(/\$(\d+)/g, (placeholder, index) => match[Number(index)] ?? placeholder) + ); + } + if (route.status) { + status = route.status; + } + } + + const location = headers.get("location"); + if (!location || !status || status < 300 || status >= 400) { + return undefined; + } + // The captures come from the pathname only, so the query of the request has to be carried over. + // The location stays a relative path - resolving it against the request would turn a `//host` + // pathname into an absolute URL to another origin. + if (url.search && !location.includes("?")) { + headers.set("location", `${location}${url.search}`); + } + return { status, headers }; +} diff --git a/packages/core/src/core/routingHandler.ts b/packages/core/src/core/routingHandler.ts index 3806c3ca..e326805b 100644 --- a/packages/core/src/core/routingHandler.ts +++ b/packages/core/src/core/routingHandler.ts @@ -16,6 +16,7 @@ import { debug, error } from "../adapters/logger"; import { cacheInterceptor } from "./routing/cacheInterceptor"; import { detectLocale } from "./routing/i18n"; import { getMiddlewareMatchPath, shouldInvokeMiddleware } from "./routing/middleware"; +import { resolvePriorityRedirect, splitPriorityRoutes } from "./routing/priorityRoutes"; import { convertBodyToReadableStream, constructNextUrl, normalizeLocationHeader } from "./routing/util"; export const MIDDLEWARE_HEADER_PREFIX = "x-middleware-response-"; @@ -27,6 +28,8 @@ export const INTERNAL_HEADER_RESOLVED_ROUTES = `${INTERNAL_HEADER_PREFIX}resolve export const INTERNAL_HEADER_REWRITE_STATUS_CODE = `${INTERNAL_HEADER_PREFIX}rewrite-status-code`; export const INTERNAL_EVENT_REQUEST_ID = `${INTERNAL_HEADER_PREFIX}request-id`; +const { priorityRoutes, resolverRoutes } = /* @__PURE__ */ splitPriorityRoutes(RoutingConfig.routes); + const geoHeaderToNextHeader = { "x-open-next-city": "x-vercel-ip-city", "x-open-next-country": "x-vercel-ip-country", @@ -196,12 +199,24 @@ export default async function routingHandler( } } + const requestUrl = new URL(event.url); + const priorityRedirect = resolvePriorityRedirect(priorityRoutes, requestUrl, new Headers(event.headers)); + if (priorityRedirect) { + return { + type: event.type, + statusCode: priorityRedirect.status, + headers: headersToRecord(priorityRedirect.headers), + body: emptyReadableStream(), + isBase64Encoded: false, + }; + } + let directMiddlewareResult: InternalResult | undefined; let middlewareHeaders = new Headers(event.headers); const buildId = RoutingConfig.buildId || BuildId; const basePath = NextConfig.basePath ?? ""; const routingResult = await resolveRoutes({ - url: new URL(event.url), + url: requestUrl, buildId, basePath, i18n: NextConfig.i18n @@ -217,7 +232,7 @@ export default async function routingHandler( //@ts-expect-error requestBody: convertBodyToReadableStream(event.method, event.body), pathnames: RoutingConfig.pathnames, - routes: RoutingConfig.routes, + routes: resolverRoutes, invokeMiddleware: async (context) => { middlewareHeaders = context.headers; // The matchers must be tested against the pathname resolved by the router - the locale has diff --git a/packages/tests-unit/tests/core/routing/priorityRoutes.test.ts b/packages/tests-unit/tests/core/routing/priorityRoutes.test.ts new file mode 100644 index 00000000..3fc24195 --- /dev/null +++ b/packages/tests-unit/tests/core/routing/priorityRoutes.test.ts @@ -0,0 +1,106 @@ +import { + resolvePriorityRedirect, + splitPriorityRoutes, +} from "@opennextjs/core/core/routing/priorityRoutes.js"; +import type { NextAdapterRouting } from "@opennextjs/core/types/adapter.js"; + +const ADD_TRAILING_SLASH = { + sourceRegex: String.raw`^(?:\/((?:[^/]+\/)*[^/\.]+))$`, + headers: { Location: "/$1/" }, + status: 308, + priority: true, +}; + +const STRIP_TRAILING_SLASH = { + sourceRegex: String.raw`^(?:\/((?:[^/]+\/)*[^/]+\.\w+))\/$`, + headers: { Location: "/$1" }, + status: 308, + missing: [{ type: "header" as const, key: "x-nextjs-data" }], + priority: true, +}; + +function routing(beforeMiddleware: unknown[]): NextAdapterRouting { + return { + beforeMiddleware, + beforeFiles: [], + afterFiles: [], + dynamicRoutes: [], + onMatch: [], + fallback: [], + } as unknown as NextAdapterRouting; +} + +function resolve(routes: unknown[], target: string, headers: Record = {}) { + const url = new URL(`https://localhost${target}`); + return resolvePriorityRedirect(routes as never, url, new Headers(headers)); +} + +describe("splitPriorityRoutes", () => { + it("takes the priority redirects out of the routes handed to the resolver", () => { + const redirect = { sourceRegex: "^/old$", headers: { Location: "/new" }, status: 308 }; + const routes = routing([ADD_TRAILING_SLASH, redirect]); + + const { priorityRoutes, resolverRoutes } = splitPriorityRoutes(routes); + + expect(priorityRoutes).toEqual([ADD_TRAILING_SLASH]); + expect(resolverRoutes.beforeMiddleware).toEqual([redirect]); + }); + + it("leaves a priority route that rewrites to the resolver", () => { + const rewrite = { sourceRegex: "^/old$", destination: "/new", priority: true }; + const routes = routing([rewrite]); + + const { priorityRoutes, resolverRoutes } = splitPriorityRoutes(routes); + + expect(priorityRoutes).toEqual([]); + expect(resolverRoutes).toBe(routes); + }); + + it("keeps the routes untouched when there is no priority route", () => { + const routes = routing([{ sourceRegex: "^/old$", headers: { Location: "/new" }, status: 308 }]); + + expect(splitPriorityRoutes(routes).resolverRoutes).toBe(routes); + }); +}); + +describe("resolvePriorityRedirect", () => { + it("substitutes the captures of the pathname into the location", () => { + expect(resolve([ADD_TRAILING_SLASH], "/blog/hello")).toMatchObject({ + status: 308, + }); + expect(resolve([ADD_TRAILING_SLASH], "/blog/hello")?.headers.get("location")).toBe("/blog/hello/"); + }); + + it("carries the query of the request over to the location", () => { + expect(resolve([ADD_TRAILING_SLASH], "/blog?happy=true")?.headers.get("location")).toBe( + "/blog/?happy=true" + ); + }); + + it("does not match a pathname already in its canonical form", () => { + expect(resolve([ADD_TRAILING_SLASH], "/blog/")).toBeUndefined(); + }); + + it("does not match a pathname that would resolve to another origin", () => { + expect(resolve([STRIP_TRAILING_SLASH], "//sst.dev/")).toBeUndefined(); + expect(resolve([ADD_TRAILING_SLASH], "//sst.dev")).toBeUndefined(); + }); + + it("honors a `missing` condition", () => { + expect(resolve([STRIP_TRAILING_SLASH], "/asset.js/")?.headers.get("location")).toBe("/asset.js"); + expect(resolve([STRIP_TRAILING_SLASH], "/asset.js/", { "x-nextjs-data": "1" })).toBeUndefined(); + }); + + it("honors a `has` condition", () => { + const route = { ...ADD_TRAILING_SLASH, has: [{ type: "header", key: "x-canonical" }] }; + + expect(resolve([route], "/blog")).toBeUndefined(); + expect(resolve([route], "/blog", { "x-canonical": "1" })?.status).toBe(308); + }); + + it("ignores a matching route that is not a redirect", () => { + const route = { sourceRegex: "^/blog$", headers: { "x-custom": "1" }, priority: true }; + + expect(resolve([route], "/blog")).toBeUndefined(); + }); +}); diff --git a/packages/tests-unit/tests/core/routing/routingHandler.trailingSlash.test.ts b/packages/tests-unit/tests/core/routing/routingHandler.trailingSlash.test.ts new file mode 100644 index 00000000..5b1e4b7e --- /dev/null +++ b/packages/tests-unit/tests/core/routing/routingHandler.trailingSlash.test.ts @@ -0,0 +1,126 @@ +import routingHandler from "@opennextjs/core/core/routingHandler.js"; +import type { InternalEvent, InternalResult, RoutingResult } from "@opennextjs/core/types/open-next.js"; +import { vi } from "vitest"; + +vi.mock("@/config/index", () => ({ + BuildId: "build-id", + NextConfig: { + experimental: {}, + images: {}, + trailingSlash: true, + i18n: { locales: ["en", "fr"], defaultLocale: "en" }, + }, + RoutingConfig: { + buildId: "build-id", + // `createRoutingConfig` emits the trailing slash variant of every non file pathname. + pathnames: [ + "/en/ssr", + "/en/ssr/", + "/_next/data/build-id/en/ssr.json", + "/en/isr", + "/en/isr/", + "/_next/data/build-id/en/isr.json", + ], + routeIndex: { + "/en/ssr": { type: "page", isFallback: false, isISR: false }, + "/_next/data/build-id/en/ssr.json": { type: "page", isFallback: false, isISR: false }, + "/en/isr": { type: "page", isFallback: false, isISR: true }, + // The data variant of a prerendered route is served by the route itself. + "/_next/data/build-id/en/isr.json": { + type: "page", + isFallback: false, + isISR: true, + route: "/en/isr", + }, + }, + routes: { + // The canonicalizing redirects Next emits for `trailingSlash: true`. + beforeMiddleware: [ + { + sourceRegex: String.raw`^(?:\/((?!\.well-known(?:\/.*)?)(?:[^/]+\/)*[^/]+\.\w+))\/$`, + headers: { Location: "/$1" }, + status: 308, + missing: [{ type: "header", key: "x-nextjs-data" }], + priority: true, + }, + { + sourceRegex: String.raw`^(?:\/((?!\.well-known(?:\/.*)?)(?:[^/]+\/)*[^/\.]+))$`, + headers: { Location: "/$1/" }, + status: 308, + priority: true, + }, + ], + beforeFiles: [], + afterFiles: [], + dynamicRoutes: [], + onMatch: [], + fallback: [], + shouldNormalizeNextData: true, + }, + }, + PrerenderManifest: { routes: {}, dynamicRoutes: {}, preview: {} }, + MiddlewareManifest: { middleware: {}, functions: {}, version: 1 }, + FunctionsConfigManifest: { functions: {}, version: 1 }, +})); + +function event(target: string): InternalEvent { + const [rawPath] = target.split("?"); + return { + type: "core", + method: "GET", + rawPath, + url: `https://localhost${target}`, + headers: { host: "localhost" }, + query: {}, + cookies: {}, + remoteAddress: "127.0.0.1", + }; +} + +beforeEach(() => { + globalThis.openNextConfig = {}; +}); + +describe("routingHandler trailing slash", () => { + it("redirects a pathname to its canonical trailing slash form", async () => { + const result = (await routingHandler(event("/ssr"))) as InternalResult; + + expect(result.statusCode).toBe(308); + expect(result.headers.location).toBe("/ssr/"); + }); + + it("carries the query of the request over to the redirect", async () => { + const result = (await routingHandler(event("/ssr?happy=true"))) as InternalResult; + + expect(result.statusCode).toBe(308); + expect(result.headers.location).toBe("/ssr/?happy=true"); + }); + + it("resolves a pathname already in its canonical form", async () => { + const result = (await routingHandler(event("/ssr/"))) as RoutingResult; + + expect(result.resolvedRoutes).toEqual([ + { route: "/en/ssr", type: "page", isFallback: false, isISR: false }, + ]); + }); + + // The resolver normalizes `/_next/data/` pathnames to the page they carry before running + // `beforeMiddleware`, which would have the canonicalizing redirect send every data request to + // its page. Next matches those redirects against the request as it came in, where the `.json` + // pathname never looks like a page. + it("does not redirect the `_next/data` request of a route with its own data output", async () => { + const result = (await routingHandler(event("/_next/data/build-id/en/ssr.json"))) as RoutingResult; + + expect(result.resolvedRoutes).toEqual([ + { route: "/_next/data/build-id/en/ssr.json", type: "page", isFallback: false, isISR: false }, + ]); + }); + + it("does not redirect the `_next/data` request of a prerendered route", async () => { + const result = (await routingHandler(event("/_next/data/build-id/en/isr.json"))) as RoutingResult; + + expect(result.resolvedRoutes).toEqual([ + { route: "/en/isr", type: "page", isFallback: false, isISR: true }, + ]); + }); +}); From c5833a39e8990acb4c1a05e12b0a9a13681155db Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Sun, 2 Aug 2026 11:09:58 +0200 Subject: [PATCH 26/44] feat(routing): implement routing normalization and add related tests --- .../core/src/build/createRoutingConfig.ts | 6 +- packages/core/src/build/normalizeRouting.ts | 240 +++++++++++++++++ packages/core/src/core/routingHandler.ts | 43 ++- .../tests/build/normalizeRouting.test.ts | 254 ++++++++++++++++++ 4 files changed, 534 insertions(+), 9 deletions(-) create mode 100644 packages/core/src/build/normalizeRouting.ts create mode 100644 packages/tests-unit/tests/build/normalizeRouting.test.ts diff --git a/packages/core/src/build/createRoutingConfig.ts b/packages/core/src/build/createRoutingConfig.ts index 628c5d95..01d112a2 100644 --- a/packages/core/src/build/createRoutingConfig.ts +++ b/packages/core/src/build/createRoutingConfig.ts @@ -5,6 +5,7 @@ import type { RuntimeRoutingConfig } from "../types/adapter.js"; import type { BuildCompleteContext } from "./adapter.js"; import type * as buildHelper from "./helper.js"; +import { normalizeRouting } from "./normalizeRouting.js"; const EXECUTABLE_OUTPUT_TYPES = ["pages", "pagesApi", "appPages", "appRoutes"] as const; const PATHNAME_OUTPUT_TYPES = [...EXECUTABLE_OUTPUT_TYPES, "staticFiles"] as const; @@ -150,7 +151,10 @@ export function createRoutingConfig( ]; const routingConfig: RuntimeRoutingConfig = { buildId: context.buildId, - routes: context.routing, + routes: normalizeRouting(context.routing, { + locales: context.config.i18n?.locales ?? [], + apiPathnames: new Set(context.outputs.pagesApi.map((output) => output.pathname)), + }), pathnames, routeIndex, }; diff --git a/packages/core/src/build/normalizeRouting.ts b/packages/core/src/build/normalizeRouting.ts new file mode 100644 index 00000000..97926155 --- /dev/null +++ b/packages/core/src/build/normalizeRouting.ts @@ -0,0 +1,240 @@ +import type { NextAdapterRouting } from "../types/adapter.js"; + +/** + * A route of `NextAdapterRouting`, with the fields Next emits on top of what the resolver reads. + */ +type AdapterRoute = NextAdapterRouting["beforeMiddleware"][number] & { + source?: string; + priority?: boolean; +}; + +type CustomRouteGroup = "beforeMiddleware" | "beforeFiles" | "afterFiles" | "fallback"; + +const CUSTOM_ROUTE_GROUPS: CustomRouteGroup[] = ["beforeMiddleware", "beforeFiles", "afterFiles", "fallback"]; + +/** The locale capture Next prefixes localized dynamic routes with. */ +const LOCALE_CAPTURE = "(?[^/]{1,})"; + +export type NormalizeRoutingOptions = { + /** The configured locales, empty when `i18n` is not configured. */ + locales: string[]; + /** The pathnames of the pages router API routes. */ + apiPathnames: Set; +}; + +function isExternalDestination(destination: string): boolean { + return destination.startsWith("http://") || destination.startsWith("https://"); +} + +function splitDestination(destination: string): [pathname: string, search: string] { + const separator = destination.indexOf("?"); + return separator === -1 + ? [destination, ""] + : [destination.slice(0, separator), destination.slice(separator)]; +} + +function isRedirect(route: AdapterRoute): boolean { + return ( + route.status !== undefined && + route.status >= 300 && + route.status < 400 && + Object.keys(route.headers ?? {}).some((key) => key.toLowerCase() === "location") + ); +} + +/** + * Whether the route is one Next already emitted for a locale. + * + * Next prefixes the source of every route it localizes, either with the locale group + * (`/:nextInternalLocale(en|nl)/...`) or with a concrete locale for the default locale variant. + * A route declared with `locale: false` keeps its source as authored. + */ +function isLocalized(route: AdapterRoute, locales: string[]): boolean { + const source = route.source ?? ""; + return ( + source.startsWith("/:nextInternalLocale") || + locales.some((locale) => source === `/${locale}` || source.startsWith(`/${locale}/`)) + ); +} + +/** + * Drops the locale a destination targeting a pages router API route carries. + * + * Next localizes every destination when `i18n` is configured, but never emits a localized output + * for an API route - and the resolver never localizes an `/api/` request either. The locale would + * only make the destination unresolvable, so it is stripped. + */ +function delocalizeApiDestination(destination: string, apiPathnames: Set): string { + const [pathname, search] = splitDestination(destination); + // The locale of a destination is a capture reference - `/$1/api/query` or `/$nextLocale/api/foo`. + const localePrefix = pathname.match(/^\/\$\w+(?\/.*)$/); + const rest = localePrefix?.groups?.["rest"]; + return rest !== undefined && apiPathnames.has(rest) ? `${rest}${search}` : destination; +} + +/** + * Prefixes an internal destination with a locale. + * + * API routes are the exception - see `delocalizeApiDestination`. + */ +function localizeDestination(destination: string, locale: string, apiPathnames: Set): string { + if (isExternalDestination(destination)) { + return destination; + } + const [pathname, search] = splitDestination(destination); + if (apiPathnames.has(pathname)) { + return destination; + } + // `/` is the pathname of the locale root itself, i.e. `/en` and not `/en/`. + return `/${locale}${pathname === "/" ? "" : pathname}${search}`; +} + +/** + * Creates the variant of a route matching the pathname of a locale. + * + * The resolver prefixes the pathname of the request with the detected locale before matching any + * route, so a route Next did not localize - i.e. one declared with `locale: false` - would never + * match. The variants restore the routes for every locale the request may have been resolved to. + */ +function localizeRoute(route: AdapterRoute, locale: string, apiPathnames: Set): AdapterRoute { + return { + ...route, + source: route.source === undefined ? undefined : `/${locale}${route.source}`, + sourceRegex: route.sourceRegex.replace(/^\^/, `^\\/${locale}`), + destination: + route.destination === undefined + ? undefined + : localizeDestination(route.destination, locale, apiPathnames), + }; +} + +/** + * Turns a redirect into a route the resolver stops at. + * + * The resolver only stops processing a group when the matched route has a destination, so a + * redirect - which carries its target in a `location` header - lets every route after it match and + * override that header. Next stops at the first matching redirect, and so must we: the destination + * makes the resolver return the redirect right away. + */ +function withRedirectDestination(route: AdapterRoute): AdapterRoute { + const location = Object.entries(route.headers ?? {}).find(([key]) => key.toLowerCase() === "location")?.[1]; + return location === undefined ? route : { ...route, destination: location }; +} + +/** + * The name of the capture group spanning the whole condition value, if there is one. + * + * `(?\w+)` captures the value as a whole, `foo-(?\d+)` only a part of it. + */ +function wholeValueCaptureName(value: string): string | undefined { + const match = value.match(/^\(\?<(?\w+)>(?.*)\)$/s); + const body = match?.groups?.["body"]; + if (body === undefined) { + return undefined; + } + // The group only spans the whole value when its opening parenthesis is the one closing at the end. + let depth = 0; + for (let index = 0; index < body.length; index++) { + const character = body[index]; + if (character === "\\") { + index++; + } else if (character === "(") { + depth++; + } else if (character === ")" && depth-- === 0) { + return undefined; + } + } + return depth === 0 ? match?.groups?.["name"] : undefined; +} + +/** + * Renames the capture references of the `has` conditions of a route to the names the resolver binds. + * + * Next names them after the capture group of the condition value - `$destination` for a + * `(?\w+)` query condition - while the resolver binds the value it matched to the key + * of the condition instead. Only a group spanning the whole value can be renamed: it is the one + * case where both are the same string. + */ +function alignHasCaptures(route: AdapterRoute): AdapterRoute { + const renames = (route.has ?? []).flatMap((condition) => { + // A `host` condition matches on the hostname and binds no capture. + if (condition.type === "host" || condition.value === undefined) { + return []; + } + const name = wholeValueCaptureName(condition.value); + // The resolver strips everything but the letters of the key it binds the value to. + const boundName = condition.key.replace(/[^a-zA-Z]/g, ""); + return name === undefined || name === boundName ? [] : [[name, boundName] as const]; + }); + if (renames.length === 0) { + return route; + } + const rename = (value: string) => + renames.reduce((current, [name, boundName]) => current.replaceAll(`$${name}`, `$${boundName}`), value); + return { + ...route, + destination: route.destination === undefined ? undefined : rename(route.destination), + headers: + route.headers === undefined + ? undefined + : Object.fromEntries(Object.entries(route.headers).map(([key, value]) => [key, rename(value)])), + }; +} + +/** + * Drops the locale Next prefixed a pages router API dynamic route with. + * + * The resolver leaves `/api/` requests unlocalized, so the locale capture would keep the route from + * ever matching one. + */ +function delocalizeApiDynamicRoute(route: AdapterRoute, apiPathnames: Set): AdapterRoute { + if (!route.source || !apiPathnames.has(route.source) || !route.sourceRegex.includes(LOCALE_CAPTURE)) { + return route; + } + return { + ...route, + sourceRegex: route.sourceRegex.replace(LOCALE_CAPTURE, ""), + destination: route.destination?.replace("/$nextLocale", ""), + }; +} + +/** + * Reconciles the routing Next emits with the pathnames the resolver actually matches. + * + * Next assumes a router that localizes every request and stops at the first matching redirect, + * neither of which the resolver does. This rewrites the routes so that both agree. + */ +export function normalizeRouting( + routing: NextAdapterRouting, + { locales, apiPathnames }: NormalizeRoutingOptions +): NextAdapterRouting { + const normalized: NextAdapterRouting = { + ...routing, + dynamicRoutes: routing.dynamicRoutes.map((route) => + delocalizeApiDynamicRoute(route as AdapterRoute, apiPathnames) + ), + }; + + for (const group of CUSTOM_ROUTE_GROUPS) { + normalized[group] = (routing[group] as AdapterRoute[]).flatMap((originalRoute) => { + const route = alignHasCaptures(originalRoute); + const withApiDestination: AdapterRoute = + route.destination === undefined + ? route + : { ...route, destination: delocalizeApiDestination(route.destination, apiPathnames) }; + // A priority route is matched against the request as it came in - i.e. before the resolver + // localizes it - so it needs no variant. + const variants = + locales.length === 0 || route.priority || isLocalized(route, locales) + ? [] + : locales.map((locale) => localizeRoute(withApiDestination, locale, apiPathnames)); + return [...variants, withApiDestination].map((variant) => + !variant.priority && isRedirect(variant) && variant.destination === undefined + ? withRedirectDestination(variant) + : variant + ); + }); + } + + return normalized; +} diff --git a/packages/core/src/core/routingHandler.ts b/packages/core/src/core/routingHandler.ts index e326805b..45aa01ae 100644 --- a/packages/core/src/core/routingHandler.ts +++ b/packages/core/src/core/routingHandler.ts @@ -68,6 +68,21 @@ function headersToRecord(headers: Headers): Record { return result; } +/** + * Converts the headers of the routed request to the record the middleware expects. + * + * Next builds the middleware `Request` from `Object.entries(request.headers)`, which yields nothing + * for a `Headers` instance - the middleware would see a request without a single header, and the + * headers it forwards with `NextResponse.next({ request })` would drop every one of them. + */ +function headersToRequestRecord(headers: Headers): Record { + const result: Record = {}; + headers.forEach((value, key) => { + result[key] = value; + }); + return result; +} + function applyResponseHeaders(eventOrResult: InternalEvent | InternalResult, headers: Headers): void { const isResult = isInternalResult(eventOrResult); const keyPrefix = isResult ? "" : MIDDLEWARE_HEADER_PREFIX; @@ -212,6 +227,7 @@ export default async function routingHandler( } let directMiddlewareResult: InternalResult | undefined; + let middlewareRewriteStatusCode: number | undefined; let middlewareHeaders = new Headers(event.headers); const buildId = RoutingConfig.buildId || BuildId; const basePath = NextConfig.basePath ?? ""; @@ -243,6 +259,11 @@ export default async function routingHandler( return { requestHeaders: context.headers }; } + // The middleware runs on the user visible pathname, so a `_next/data` request has to be + // invoked on the page it carries - the middleware has no route for the data pathname. + const middlewareUrl = new URL(context.url); + middlewareUrl.pathname = matchPath; + const middleware = await middlewareLoader(); const response = await middleware.default({ geo: { @@ -252,18 +273,23 @@ export default async function routingHandler( latitude: event.headers["x-open-next-latitude"], longitude: event.headers["x-open-next-longitude"], }, - headers: context.headers, + headers: headersToRequestRecord(context.headers), method: event.method || "GET", nextConfig: { basePath: NextConfig.basePath, i18n: NextConfig.i18n, trailingSlash: NextConfig.trailingSlash, }, - url: context.url.toString(), + url: middlewareUrl.toString(), body: context.requestBody, } as unknown as Request); const result = responseToMiddlewareResult(response, context.headers, context.url); restoreNullOrigin(result, context.url); + // The resolver has no notion of the status of a rewrite, but `NextResponse.rewrite(url, + // { status })` serves the destination with that status - it has to be carried over here. + if (result.rewrite && response.status !== 200) { + middlewareRewriteStatusCode = response.status; + } if (result.bodySent) { directMiddlewareResult = { type: event.type, @@ -282,6 +308,7 @@ export default async function routingHandler( } const responseHeaders = routingResult.resolvedHeaders ?? new Headers(); + const rewriteStatusCode = routingResult.status ?? middlewareRewriteStatusCode; if (routingResult.redirect) { // The resolver already set a `location` from the matched route headers. It must be replaced // - and not merely added to the record - so that the response has a single redirect target. @@ -319,7 +346,7 @@ export default async function routingHandler( applyResponseHeaders(externalEvent, responseHeaders); return createRoutingResult(externalEvent, [], { isExternalRewrite: true, - rewriteStatusCode: routingResult.status, + rewriteStatusCode, initialURL: event.url, }); } @@ -336,7 +363,7 @@ export default async function routingHandler( }; applyResponseHeaders(notFoundEvent, responseHeaders); return createRoutingResult(notFoundEvent, [], { - rewriteStatusCode: routingResult.status, + rewriteStatusCode, initialURL: event.url, }); } @@ -353,7 +380,7 @@ export default async function routingHandler( middlewareHeaders, routingResult.resolvedQuery ?? routingResult.invocationTarget.query ), - rewriteStatusCode: routingResult.status, + rewriteStatusCode, }; const resolvedRoutes = getResolvedRoute(routingResult.resolvedPathname); @@ -375,7 +402,7 @@ export default async function routingHandler( }; applyResponseHeaders(notFoundEvent, responseHeaders); return createRoutingResult(notFoundEvent, [], { - rewriteStatusCode: routingResult.status, + rewriteStatusCode, initialURL: event.url, }); } @@ -390,7 +417,7 @@ export default async function routingHandler( applyResponseHeaders(cacheInterceptionResult.result, responseHeaders); applyResponseHeaders(cacheInterceptionResult.resumeRequest, responseHeaders); return createRoutingResult(cacheInterceptionResult.resumeRequest, resolvedRoutes, { - rewriteStatusCode: routingResult.status, + rewriteStatusCode, initialResponse: cacheInterceptionResult.result, initialURL: event.url, }); @@ -398,7 +425,7 @@ export default async function routingHandler( applyResponseHeaders(cacheInterceptionResult, responseHeaders); return createRoutingResult(cacheInterceptionResult, resolvedRoutes, { - rewriteStatusCode: routingResult.status, + rewriteStatusCode, initialURL: event.url, }); } catch (e) { diff --git a/packages/tests-unit/tests/build/normalizeRouting.test.ts b/packages/tests-unit/tests/build/normalizeRouting.test.ts new file mode 100644 index 00000000..04bab498 --- /dev/null +++ b/packages/tests-unit/tests/build/normalizeRouting.test.ts @@ -0,0 +1,254 @@ +import { normalizeRouting } from "@opennextjs/core/build/normalizeRouting.js"; +import type { NextAdapterRouting } from "@opennextjs/core/types/adapter.js"; + +function routing(overrides: Partial = {}): NextAdapterRouting { + return { + beforeMiddleware: [], + beforeFiles: [], + afterFiles: [], + dynamicRoutes: [], + onMatch: [], + fallback: [], + ...overrides, + }; +} + +const NO_I18N = { locales: [], apiPathnames: new Set() }; + +describe("normalizeRouting", () => { + it("leaves the routing untouched when `i18n` is not configured", () => { + const routes = routing({ + afterFiles: [{ source: "/rewrite", sourceRegex: "^\\/rewrite$", destination: "/" } as never], + }); + + expect(normalizeRouting(routes, NO_I18N)).toEqual(routes); + }); + + describe("locales", () => { + const options = { locales: ["en", "nl"], apiPathnames: new Set() }; + + it("emits a variant of a non localized route for every locale", () => { + const result = normalizeRouting( + routing({ + afterFiles: [ + { source: "/rewrite", sourceRegex: "^\\/rewrite(?:\\/)?$", destination: "/ssr" } as never, + ], + }), + options + ); + + expect(result.afterFiles).toEqual([ + { source: "/en/rewrite", sourceRegex: "^\\/en\\/rewrite(?:\\/)?$", destination: "/en/ssr" }, + { source: "/nl/rewrite", sourceRegex: "^\\/nl\\/rewrite(?:\\/)?$", destination: "/nl/ssr" }, + { source: "/rewrite", sourceRegex: "^\\/rewrite(?:\\/)?$", destination: "/ssr" }, + ]); + }); + + it("resolves a destination of `/` to the root of the locale", () => { + const result = normalizeRouting( + routing({ + afterFiles: [{ source: "/rewrite", sourceRegex: "^\\/rewrite$", destination: "/" } as never], + }), + options + ); + + expect(result.afterFiles[0]?.destination).toBe("/en"); + }); + + it("does not localize an external destination", () => { + const result = normalizeRouting( + routing({ + afterFiles: [ + { + source: "/image", + sourceRegex: "^\\/image$", + destination: "https://opennext.js.org/i.png", + } as never, + ], + }), + options + ); + + expect(result.afterFiles[0]?.destination).toBe("https://opennext.js.org/i.png"); + }); + + it("leaves the routes Next already localized alone", () => { + const routes = routing({ + afterFiles: [ + { + source: "/:nextInternalLocale(en|nl)/rewrite/", + sourceRegex: "^(?:\\/(en|nl))\\/rewrite\\/$", + destination: "/$1/ssr/", + } as never, + { source: "/en/rewrite/", sourceRegex: "^\\/en\\/rewrite\\/$", destination: "/ssr/" } as never, + ], + }); + + expect(normalizeRouting(routes, options).afterFiles).toEqual(routes.afterFiles); + }); + + it("leaves a priority route alone - it is matched before the request is localized", () => { + const routes = routing({ + beforeMiddleware: [ + { + source: "/:notfile", + sourceRegex: "^(?:\\/([^/\\.]+))$", + headers: { Location: "/$1/" }, + status: 308, + priority: true, + } as never, + ], + }); + + expect(normalizeRouting(routes, options).beforeMiddleware).toEqual(routes.beforeMiddleware); + }); + }); + + describe("redirects", () => { + it("gives a redirect the destination the resolver stops at", () => { + const result = normalizeRouting( + routing({ + beforeMiddleware: [ + { + source: "/redirect/", + sourceRegex: "^\\/redirect\\/$", + headers: { Location: "/$1/ssr/" }, + status: 307, + } as never, + ], + }), + NO_I18N + ); + + expect(result.beforeMiddleware[0]?.destination).toBe("/$1/ssr/"); + }); + + it("leaves a route that only sets headers without a destination", () => { + const result = normalizeRouting( + routing({ + beforeMiddleware: [ + { source: "/", sourceRegex: "^\\/$", headers: { "x-custom": "value" } } as never, + ], + }), + NO_I18N + ); + + expect(result.beforeMiddleware[0]?.destination).toBeUndefined(); + }); + }); + + describe("api routes", () => { + const options = { locales: ["en"], apiPathnames: new Set(["/api/query", "/api/dynamic/[slug]"]) }; + + it("drops the locale of a dynamic route serving an API route", () => { + const result = normalizeRouting( + routing({ + dynamicRoutes: [ + { + source: "/api/dynamic/[slug]", + sourceRegex: "^[/]?(?[^/]{1,})/api/dynamic/(?[^/]+?)(?:/)?$", + destination: "/$nextLocale/api/dynamic/[slug]?nxtPslug=$nxtPslug", + } as never, + ], + }), + options + ); + + expect(result.dynamicRoutes[0]).toEqual({ + source: "/api/dynamic/[slug]", + sourceRegex: "^[/]?/api/dynamic/(?[^/]+?)(?:/)?$", + destination: "/api/dynamic/[slug]?nxtPslug=$nxtPslug", + }); + }); + + it("drops the locale of a destination targeting an API route", () => { + const result = normalizeRouting( + routing({ + afterFiles: [ + { + source: "/:nextInternalLocale(en)/rewriteWithQuery/", + sourceRegex: "^(?:\\/(en))\\/rewriteWithQuery\\/$", + destination: "/$1/api/query?q=1&nextInternalLocale=$1", + } as never, + ], + }), + options + ); + + expect(result.afterFiles[0]?.destination).toBe("/api/query?q=1&nextInternalLocale=$1"); + }); + + it("does not localize a variant destination targeting an API route", () => { + const result = normalizeRouting( + routing({ + afterFiles: [ + { source: "/rewrite", sourceRegex: "^\\/rewrite$", destination: "/api/query?q=1" } as never, + ], + }), + options + ); + + expect(result.afterFiles[0]).toEqual({ + source: "/en/rewrite", + sourceRegex: "^\\/en\\/rewrite$", + destination: "/api/query?q=1", + }); + }); + }); + + describe("`has` captures", () => { + it("renames a capture spanning the whole condition value to the key of the condition", () => { + const result = normalizeRouting( + routing({ + afterFiles: [ + { + source: "/rewriteUsingQuery", + sourceRegex: "^\\/rewriteUsingQuery$", + destination: "/$destination/", + has: [{ type: "query", key: "d", value: "(?\\w+)" }], + } as never, + ], + }), + NO_I18N + ); + + expect(result.afterFiles[0]?.destination).toBe("/$d/"); + }); + + it("leaves a capture that only spans a part of the condition value", () => { + const result = normalizeRouting( + routing({ + afterFiles: [ + { + source: "/rewriteUsingQuery", + sourceRegex: "^\\/rewriteUsingQuery$", + destination: "/$destination/", + has: [{ type: "query", key: "d", value: "page-(?\\w+)" }], + } as never, + ], + }), + NO_I18N + ); + + expect(result.afterFiles[0]?.destination).toBe("/$destination/"); + }); + + it("leaves a capture whose value nests a group it does not close", () => { + const result = normalizeRouting( + routing({ + afterFiles: [ + { + source: "/rewriteUsingQuery", + sourceRegex: "^\\/rewriteUsingQuery$", + destination: "/$destination/", + has: [{ type: "query", key: "d", value: "(?\\w+)|(other)" }], + } as never, + ], + }), + NO_I18N + ); + + expect(result.afterFiles[0]?.destination).toBe("/$destination/"); + }); + }); +}); From 3e90cbf4ff7ab75771d3dd192ffa63298a98e8ad Mon Sep 17 00:00:00 2001 From: Victor Berchet Date: Thu, 3 Sep 2026 08:41:27 +0200 Subject: [PATCH 27/44] test(e2e): await middleware rewrite status assertions Await the Playwright response assertion in both the shared and Cloudflare middleware suites. This prevents each test from finishing before the expected 403 rewrite status has actually been verified. --- .../e2e/app-router/e2e/middleware.rewrite.test.ts | 2 +- packages/tests-e2e/tests/appRouter/middleware.rewrite.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/examples-cloudflare/e2e/app-router/e2e/middleware.rewrite.test.ts b/examples-cloudflare/e2e/app-router/e2e/middleware.rewrite.test.ts index 94b683e0..bf7d4bd2 100644 --- a/examples-cloudflare/e2e/app-router/e2e/middleware.rewrite.test.ts +++ b/examples-cloudflare/e2e/app-router/e2e/middleware.rewrite.test.ts @@ -63,5 +63,5 @@ test("Middleware Rewrite Status Code", async ({ page }) => { await page.goto("/rewrite-status-code"); const el = page.getByText("Rewritten Destination", { exact: true }); await expect(el).toBeVisible(); - expect(statusPromise).resolves.toEqual(403); + await expect(statusPromise).resolves.toEqual(403); }); diff --git a/packages/tests-e2e/tests/appRouter/middleware.rewrite.test.ts b/packages/tests-e2e/tests/appRouter/middleware.rewrite.test.ts index a00adc9c..07b3a4c8 100644 --- a/packages/tests-e2e/tests/appRouter/middleware.rewrite.test.ts +++ b/packages/tests-e2e/tests/appRouter/middleware.rewrite.test.ts @@ -56,5 +56,5 @@ test("Middleware Rewrite Status Code", async ({ page }) => { await page.goto("/rewrite-status-code"); const el = page.getByText("Rewritten Destination", { exact: true }); await expect(el).toBeVisible(); - expect(statusPromise).resolves.toEqual(403); + await expect(statusPromise).resolves.toEqual(403); }); From 4754266f834618064d744b204d297843b8ae9fea Mon Sep 17 00:00:00 2001 From: Victor Berchet Date: Thu, 3 Sep 2026 08:41:31 +0200 Subject: [PATCH 28/44] fix(cloudflare): require the routing adapter contract Raise the minimum supported Next.js version to 16.2.1, where the adapter callback provides the routing metadata and build ID consumed by the Cloudflare build. Add boundary tests for the last unsupported and first supported versions. --- .../src/cli/utils/nextjs-support.spec.ts | 15 +++++++++++++++ .../cloudflare/src/cli/utils/nextjs-support.ts | 13 ++++++++++--- 2 files changed, 25 insertions(+), 3 deletions(-) create mode 100644 packages/cloudflare/src/cli/utils/nextjs-support.spec.ts diff --git a/packages/cloudflare/src/cli/utils/nextjs-support.spec.ts b/packages/cloudflare/src/cli/utils/nextjs-support.spec.ts new file mode 100644 index 00000000..4b674245 --- /dev/null +++ b/packages/cloudflare/src/cli/utils/nextjs-support.spec.ts @@ -0,0 +1,15 @@ +import { describe, expect, test } from "vitest"; + +import { ensureNextjsVersionSupported } from "./nextjs-support.js"; + +describe("ensureNextjsVersionSupported", () => { + test("rejects versions without the routing-aware adapter contract", async () => { + await expect(ensureNextjsVersionSupported({ nextVersion: "16.2.0" })).rejects.toThrow( + "please upgrade to version 16.2.1 or greater" + ); + }); + + test("accepts the version required by the adapter peer dependency", async () => { + await expect(ensureNextjsVersionSupported({ nextVersion: "16.2.1" })).resolves.toBeUndefined(); + }); +}); diff --git a/packages/cloudflare/src/cli/utils/nextjs-support.ts b/packages/cloudflare/src/cli/utils/nextjs-support.ts index c8e8fed8..3989c0e9 100644 --- a/packages/cloudflare/src/cli/utils/nextjs-support.ts +++ b/packages/cloudflare/src/cli/utils/nextjs-support.ts @@ -1,9 +1,16 @@ import { compareSemver } from "@opennextjs/core/build/helper.js"; import logger from "@opennextjs/core/logger.js"; -export async function ensureNextjsVersionSupported({ nextVersion }: { nextVersion: string }) { - if (compareSemver(nextVersion, "<", "14.2.0")) { - throw new Error("Next.js version unsupported, please upgrade to version 14.2 or greater."); +/** + * Verifies that Next.js and Wrangler support the adapter contract. + * + * @param options The installed Next.js version. + * @returns A promise that resolves when the versions are supported. + * @throws When Next.js predates the routing-aware adapter contract. + */ +export async function ensureNextjsVersionSupported({ nextVersion }: { nextVersion: string }): Promise { + if (compareSemver(nextVersion, "<", "16.2.1")) { + throw new Error("Next.js version unsupported, please upgrade to version 16.2.1 or greater."); } const { From 250a003b1e21b4ace2ed0823a7bf4ed97a048147 Mon Sep 17 00:00:00 2001 From: Victor Berchet Date: Thu, 3 Sep 2026 08:41:36 +0200 Subject: [PATCH 29/44] fix(routing): associate prerenders with adapter outputs Use Next.js output IDs and prerender parentOutputId metadata to identify the executable route that owns each prerender. This correctly indexes concrete prerenders and PPR segment payloads as ISR content while retaining pathname-based fallback resolution for unmatched metadata. --- .../core/src/build/createRoutingConfig.ts | 23 +++++++- packages/core/src/types/adapter.ts | 4 ++ .../tests/build/createRoutingConfig.test.ts | 56 ++++++++++++------- 3 files changed, 60 insertions(+), 23 deletions(-) diff --git a/packages/core/src/build/createRoutingConfig.ts b/packages/core/src/build/createRoutingConfig.ts index 01d112a2..b1bdde4b 100644 --- a/packages/core/src/build/createRoutingConfig.ts +++ b/packages/core/src/build/createRoutingConfig.ts @@ -59,6 +59,11 @@ function applyCaptures(destination: string, match: RegExpMatchArray): string { function createPrerenderRouteResolver(context: BuildCompleteContext, executableRoutes: Set) { const basePath = context.config.basePath ?? ""; const dataPrefix = `${basePath}/_next/data/${context.buildId}/`; + const executableRouteById = new Map( + EXECUTABLE_OUTPUT_TYPES.flatMap((outputType) => + context.outputs[outputType].map((output) => [output.id, output.pathname] as const) + ) + ); const dynamicRoutes = context.routing.dynamicRoutes.map((route) => ({ regex: new RegExp(route.sourceRegex), destination: route.destination, @@ -86,7 +91,14 @@ function createPrerenderRouteResolver(context: BuildCompleteContext, executableR return undefined; } - return function resolvePrerenderRoute(pathname: string): string | undefined { + return function resolvePrerenderRoute({ + pathname, + parentOutputId, + }: NonNullable[number]): string | undefined { + const parentRoute = executableRouteById.get(parentOutputId); + if (parentRoute) { + return parentRoute; + } if (executableRoutes.has(pathname)) { return pathname; } @@ -104,6 +116,13 @@ function createPrerenderRouteResolver(context: BuildCompleteContext, executableR }; } +/** + * Generates the runtime route index consumed by every provider bundle. + * + * @param options Normalized OpenNext build options. + * @param context Routing and output metadata emitted by Next.js. + * @returns The serialized runtime routing configuration. + */ export function createRoutingConfig( options: buildHelper.BuildOptions, context: BuildCompleteContext @@ -127,7 +146,7 @@ export function createRoutingConfig( const prerenderPathnames: string[] = []; for (const prerender of context.outputs.prerenders ?? []) { - const route = resolvePrerenderRoute(prerender.pathname); + const route = resolvePrerenderRoute(prerender); if (!route) { // Artifacts no route can regenerate - i.e. the PPR segment prefetches - are served from the // cache only and are left out of the index. diff --git a/packages/core/src/types/adapter.ts b/packages/core/src/types/adapter.ts index 3f2da903..15ec4fd8 100644 --- a/packages/core/src/types/adapter.ts +++ b/packages/core/src/types/adapter.ts @@ -1,6 +1,8 @@ import type { ResolveRoutesParams } from "@next/routing"; export type NextAdapterOutput = { + /** Next's unique identifier for this executable output. */ + id: string; pathname: string; filePath: string; assets: Record; @@ -17,6 +19,8 @@ export type NextAdapterOutput = { */ export type NextAdapterPrerenderOutput = { pathname: string; + /** The identifier of the executable output that generated this prerender. */ + parentOutputId: string; }; export type NextAdapterOutputs = { diff --git a/packages/tests-unit/tests/build/createRoutingConfig.test.ts b/packages/tests-unit/tests/build/createRoutingConfig.test.ts index 0cc78f5a..e3611e3d 100644 --- a/packages/tests-unit/tests/build/createRoutingConfig.test.ts +++ b/packages/tests-unit/tests/build/createRoutingConfig.test.ts @@ -20,18 +20,18 @@ describe("createRoutingConfig", () => { fallback: [], }, outputs: { - pages: [{ pathname: "/pages", filePath: "/pages.js", assets: {} }], - pagesApi: [{ pathname: "/api/hello", filePath: "/api.js", assets: {} }], - appPages: [{ pathname: "/app", filePath: "/app.js", assets: {} }], - appRoutes: [{ pathname: "/route", filePath: "/route.js", assets: {} }], - staticFiles: [{ pathname: "/asset.js", filePath: "/asset.js", assets: {} }], + pages: [{ id: "pages", pathname: "/pages", filePath: "/pages.js", assets: {} }], + pagesApi: [{ id: "api", pathname: "/api/hello", filePath: "/api.js", assets: {} }], + appPages: [{ id: "app", pathname: "/app", filePath: "/app.js", assets: {} }], + appRoutes: [{ id: "route", pathname: "/route", filePath: "/route.js", assets: {} }], + staticFiles: [{ id: "asset", pathname: "/asset.js", filePath: "/asset.js", assets: {} }], prerenders: [ // The template of a dynamic route and a prerendered non dynamic route. - { pathname: "/app" }, - { pathname: "/pages" }, + { pathname: "/app", parentOutputId: "app" }, + { pathname: "/pages", parentOutputId: "pages" }, // Concrete paths and data variants do not match an executable route. - { pathname: "/pages/prerendered" }, - { pathname: "/app.rsc" }, + { pathname: "/pages/prerendered", parentOutputId: "pages" }, + { pathname: "/app.rsc", parentOutputId: "app" }, ], }, } as BuildCompleteContext; @@ -41,12 +41,19 @@ describe("createRoutingConfig", () => { expect(result).toEqual({ buildId: "build-id", routes: context.routing, - pathnames: ["/pages", "/api/hello", "/app", "/route", "/asset.js"], + pathnames: ["/pages", "/api/hello", "/app", "/route", "/asset.js", "/pages/prerendered", "/app.rsc"], routeIndex: { "/pages": { type: "page", isFallback: false, isISR: true }, "/api/hello": { type: "page", isFallback: false, isISR: false }, "/app": { type: "app", isFallback: false, isISR: true }, "/route": { type: "route", isFallback: false, isISR: false }, + "/pages/prerendered": { + type: "page", + isFallback: false, + isISR: true, + route: "/pages", + }, + "/app.rsc": { type: "app", isFallback: false, isISR: true, route: "/app" }, }, }); expect(fs.writeFileSync).toHaveBeenCalledWith( @@ -122,24 +129,24 @@ describe("createRoutingConfig", () => { }, outputs: { pages: [ - { pathname: "/blog/[slug]", filePath: "/blog.js", assets: {} }, - { pathname: "/isr", filePath: "/isr.js", assets: {} }, + { id: "blog-output", pathname: "/blog/[slug]", filePath: "/blog.js", assets: {} }, + { id: "isr-output", pathname: "/isr", filePath: "/isr.js", assets: {} }, ], pagesApi: [], appPages: [], appRoutes: [], prerenders: [ // A concrete prerendered pathname and its data variant, neither of which is executable. - { pathname: "/blog/hello" }, - { pathname: "/_next/data/build-id/blog/hello.json" }, + { pathname: "/blog/hello", parentOutputId: "blog-output" }, + { pathname: "/_next/data/build-id/blog/hello.json", parentOutputId: "blog-output" }, // The template of the dynamic route generating them, and its data variant. - { pathname: "/blog/[slug]" }, - { pathname: "/_next/data/build-id/blog/[slug].json" }, + { pathname: "/blog/[slug]", parentOutputId: "blog-output" }, + { pathname: "/_next/data/build-id/blog/[slug].json", parentOutputId: "blog-output" }, // A non dynamic prerendered route and its data variant. - { pathname: "/isr" }, - { pathname: "/_next/data/build-id/isr.json" }, + { pathname: "/isr", parentOutputId: "isr-output" }, + { pathname: "/_next/data/build-id/isr.json", parentOutputId: "isr-output" }, // A PPR segment prefetch - no route can regenerate it. - { pathname: "/isr.segments/_tree.segment.rsc" }, + { pathname: "/isr.segments/_tree.segment.rsc", parentOutputId: "isr-output" }, ], }, } as unknown as BuildCompleteContext; @@ -153,6 +160,7 @@ describe("createRoutingConfig", () => { "/_next/data/build-id/blog/hello.json", "/_next/data/build-id/blog/[slug].json", "/_next/data/build-id/isr.json", + "/isr.segments/_tree.segment.rsc", ]); expect(result.routeIndex).toEqual({ "/blog/[slug]": { type: "page", isFallback: false, isISR: true }, @@ -176,6 +184,12 @@ describe("createRoutingConfig", () => { isISR: true, route: "/isr", }, + "/isr.segments/_tree.segment.rsc": { + type: "page", + isFallback: false, + isISR: true, + route: "/isr", + }, }); }); @@ -203,11 +217,11 @@ describe("createRoutingConfig", () => { fallback: [], }, outputs: { - pages: [{ pathname: "/blog/[...slugs]", filePath: "/slugs.js", assets: {} }], + pages: [{ id: "catch-all", pathname: "/blog/[...slugs]", filePath: "/slugs.js", assets: {} }], pagesApi: [], appPages: [], appRoutes: [], - prerenders: [{ pathname: "/blog/hello" }], + prerenders: [{ pathname: "/blog/hello", parentOutputId: "missing-specific-route" }], }, } as unknown as BuildCompleteContext; From 1440c4b43ed9da60802fa622993b7ed0bd7e37aa Mon Sep 17 00:00:00 2001 From: Victor Berchet Date: Thu, 3 Sep 2026 08:41:43 +0200 Subject: [PATCH 30/44] test(middleware): verify routing config packaging Assert that standalone Node middleware bundles receive open-next-routing.json beside their copied Next.js artifacts. Document the bundle builder contract so the packaging requirement and failure modes remain explicit. --- .../src/build/middleware/buildNodeMiddleware.spec.ts | 10 ++++++++++ .../core/src/build/middleware/buildNodeMiddleware.ts | 10 ++++++++++ 2 files changed, 20 insertions(+) diff --git a/packages/core/src/build/middleware/buildNodeMiddleware.spec.ts b/packages/core/src/build/middleware/buildNodeMiddleware.spec.ts index 7d8dae7b..060590db 100644 --- a/packages/core/src/build/middleware/buildNodeMiddleware.spec.ts +++ b/packages/core/src/build/middleware/buildNodeMiddleware.spec.ts @@ -47,10 +47,14 @@ vi.mock("../patch/patches/index.js", () => ({ vi.mock("node:fs", () => ({ default: { mkdirSync: vi.fn(), + copyFileSync: vi.fn(), }, mkdirSync: vi.fn(), + copyFileSync: vi.fn(), })); +import fs from "node:fs"; + import type { NextAdapterOutputs } from "../../types/adapter.js"; import type { BuildOptions } from "../helper.js"; import * as buildHelper from "../helper.js"; @@ -91,6 +95,7 @@ function createMockNextOutputs(): NextAdapterOutputs { appPages: [], appRoutes: [], middleware: { + id: "middleware", pathname: "/", filePath: "/app/.next/server/middleware.js", assets: { "asset.txt": "/app/.next/server/asset.txt" }, @@ -109,6 +114,11 @@ describe("buildExternalNodeMiddleware", () => { await buildExternalNodeMiddleware(options, undefined, nextOutputs); + expect(fs.copyFileSync).toHaveBeenCalledWith( + "/app/build/.next/open-next-routing.json", + "/app/.open-next/middleware/.next/open-next-routing.json" + ); + // copyOpenNextConfig called with isEdge false (default isEdgeRuntime) expect(buildHelper.copyOpenNextConfig).toHaveBeenCalledWith( options.buildDir, diff --git a/packages/core/src/build/middleware/buildNodeMiddleware.ts b/packages/core/src/build/middleware/buildNodeMiddleware.ts index f20c6f46..a20aa95f 100644 --- a/packages/core/src/build/middleware/buildNodeMiddleware.ts +++ b/packages/core/src/build/middleware/buildNodeMiddleware.ts @@ -23,6 +23,16 @@ type Override = OverrideOptions & { type MiddlewareBundle = NonNullable; +/** + * Builds a standalone Node.js middleware function. + * + * @param options Normalized OpenNext build options. + * @param defaultOverrides Provider-specific override defaults. + * @param nextOutputs Outputs emitted by the Next.js adapter build. + * @param middlewareBundle Provider-specific middleware bundle customization. + * @returns A promise that resolves when the bundle is written. + * @throws When external middleware is disabled or its adapter output is missing. + */ export async function buildExternalNodeMiddleware( options: buildHelper.BuildOptions, defaultOverrides?: DefaultOverrides, From def797e4609357ec1fbbff8f2ce24331cd3ddde3 Mon Sep 17 00:00:00 2001 From: Victor Berchet Date: Thu, 3 Sep 2026 08:41:50 +0200 Subject: [PATCH 31/44] fix(routing): delimit renamed capture references Rename condition captures only when the complete placeholder name matches. This prevents a capture such as from corrupting longer destination placeholders such as . --- packages/core/src/build/normalizeRouting.ts | 6 +++++- .../tests/build/normalizeRouting.test.ts | 18 ++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/packages/core/src/build/normalizeRouting.ts b/packages/core/src/build/normalizeRouting.ts index 97926155..0ff17833 100644 --- a/packages/core/src/build/normalizeRouting.ts +++ b/packages/core/src/build/normalizeRouting.ts @@ -170,7 +170,11 @@ function alignHasCaptures(route: AdapterRoute): AdapterRoute { return route; } const rename = (value: string) => - renames.reduce((current, [name, boundName]) => current.replaceAll(`$${name}`, `$${boundName}`), value); + renames.reduce( + (current, [name, boundName]) => + current.replace(new RegExp(`\\$${name}(?![a-zA-Z0-9_])`, "g"), `$${boundName}`), + value + ); return { ...route, destination: route.destination === undefined ? undefined : rename(route.destination), diff --git a/packages/tests-unit/tests/build/normalizeRouting.test.ts b/packages/tests-unit/tests/build/normalizeRouting.test.ts index 04bab498..ed83a41f 100644 --- a/packages/tests-unit/tests/build/normalizeRouting.test.ts +++ b/packages/tests-unit/tests/build/normalizeRouting.test.ts @@ -215,6 +215,24 @@ describe("normalizeRouting", () => { expect(result.afterFiles[0]?.destination).toBe("/$d/"); }); + it("does not rename a longer capture reference with the same prefix", () => { + const result = normalizeRouting( + routing({ + afterFiles: [ + { + source: "/rewrite", + sourceRegex: "^/rewrite$", + destination: "/$id/$identifier", + has: [{ type: "query", key: "value", value: "(?\\w+)" }], + } as never, + ], + }), + NO_I18N + ); + + expect(result.afterFiles[0]?.destination).toBe("/$value/$identifier"); + }); + it("leaves a capture that only spans a part of the condition value", () => { const result = normalizeRouting( routing({ From 2e4dcb5aaf089ac78152aa4c52c6310fdb4e39d6 Mon Sep 17 00:00:00 2001 From: Victor Berchet Date: Thu, 3 Sep 2026 08:41:54 +0200 Subject: [PATCH 32/44] fix(routing): isolate priority redirect matches Return the first complete matching priority redirect and allocate headers per candidate. This preserves route order and prevents status or headers from incomplete earlier candidates leaking into a later redirect. --- .../core/src/core/routing/priorityRoutes.ts | 28 ++++++++----------- .../tests/core/routing/priorityRoutes.test.ts | 14 ++++++++++ 2 files changed, 25 insertions(+), 17 deletions(-) diff --git a/packages/core/src/core/routing/priorityRoutes.ts b/packages/core/src/core/routing/priorityRoutes.ts index 72595da5..73a0bea6 100644 --- a/packages/core/src/core/routing/priorityRoutes.ts +++ b/packages/core/src/core/routing/priorityRoutes.ts @@ -98,34 +98,28 @@ export function resolvePriorityRedirect( url: URL, requestHeaders: Headers ): { status: number; headers: Headers } | undefined { - const headers = new Headers(); - let status: number | undefined; - for (const route of priorityRoutes) { const match = url.pathname.match(new RegExp(route.sourceRegex)); if (!match || !matchesConditions(route, url, requestHeaders)) { continue; } + const headers = new Headers(); for (const [key, value] of Object.entries(route.headers ?? {})) { headers.set( key, value.replace(/\$(\d+)/g, (placeholder, index) => match[Number(index)] ?? placeholder) ); } - if (route.status) { - status = route.status; + const status = route.status; + const location = headers.get("location"); + if (location && status && status >= 300 && status < 400) { + // The captures come from the pathname only, so the query of the request has to be carried over. + // The location stays relative to avoid treating a `//host` pathname as another origin. + if (url.search && !location.includes("?")) { + headers.set("location", `${location}${url.search}`); + } + return { status, headers }; } } - - const location = headers.get("location"); - if (!location || !status || status < 300 || status >= 400) { - return undefined; - } - // The captures come from the pathname only, so the query of the request has to be carried over. - // The location stays a relative path - resolving it against the request would turn a `//host` - // pathname into an absolute URL to another origin. - if (url.search && !location.includes("?")) { - headers.set("location", `${location}${url.search}`); - } - return { status, headers }; + return undefined; } diff --git a/packages/tests-unit/tests/core/routing/priorityRoutes.test.ts b/packages/tests-unit/tests/core/routing/priorityRoutes.test.ts index 3fc24195..c619f1a0 100644 --- a/packages/tests-unit/tests/core/routing/priorityRoutes.test.ts +++ b/packages/tests-unit/tests/core/routing/priorityRoutes.test.ts @@ -103,4 +103,18 @@ describe("resolvePriorityRedirect", () => { expect(resolve([route], "/blog")).toBeUndefined(); }); + + it("uses the first matching redirect", () => { + const first = { ...ADD_TRAILING_SLASH, headers: { Location: "/first" } }; + const second = { ...ADD_TRAILING_SLASH, headers: { Location: "/second" } }; + + expect(resolve([first, second], "/blog")?.headers.get("location")).toBe("/first"); + }); + + it("does not carry headers from an incomplete route into a later redirect", () => { + const incomplete = { ...ADD_TRAILING_SLASH, status: undefined, headers: { "x-first": "1" } }; + const redirect = { ...ADD_TRAILING_SLASH, headers: { Location: "/second" } }; + + expect(resolve([incomplete, redirect], "/blog")?.headers.get("x-first")).toBeNull(); + }); }); From a37889a529306cd42a9389fae267ebcb5ae91a69 Mon Sep 17 00:00:00 2001 From: Victor Berchet Date: Thu, 3 Sep 2026 08:43:23 +0200 Subject: [PATCH 33/44] fix(routing): reject stale Next data requests Return Next-compatible JSON 404 responses for _next/data requests whose build ID does not match the active build. Rejecting them before route and middleware resolution prevents stale client requests from being treated as page navigation. --- packages/core/src/core/routingHandler.ts | 15 ++++++++++++++- .../tests/core/routing/routingHandler.test.ts | 9 +++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/packages/core/src/core/routingHandler.ts b/packages/core/src/core/routingHandler.ts index 45aa01ae..ae2fd4ca 100644 --- a/packages/core/src/core/routingHandler.ts +++ b/packages/core/src/core/routingHandler.ts @@ -9,7 +9,7 @@ import type { RoutingResult, } from "@/types/open-next"; import type { AssetResolver } from "@/types/overrides"; -import { emptyReadableStream } from "@/utils/stream"; +import { emptyReadableStream, toReadableStream } from "@/utils/stream"; import { debug, error } from "../adapters/logger"; @@ -231,6 +231,19 @@ export default async function routingHandler( let middlewareHeaders = new Headers(event.headers); const buildId = RoutingConfig.buildId || BuildId; const basePath = NextConfig.basePath ?? ""; + const dataPrefix = `${basePath}/_next/data/`; + if ( + requestUrl.pathname.startsWith(dataPrefix) && + !requestUrl.pathname.startsWith(`${dataPrefix}${buildId}/`) + ) { + return { + type: event.type, + statusCode: 404, + body: toReadableStream("{}"), + headers: { "content-type": "application/json" }, + isBase64Encoded: false, + }; + } const routingResult = await resolveRoutes({ url: requestUrl, buildId, diff --git a/packages/tests-unit/tests/core/routing/routingHandler.test.ts b/packages/tests-unit/tests/core/routing/routingHandler.test.ts index 18611577..98f5d728 100644 --- a/packages/tests-unit/tests/core/routing/routingHandler.test.ts +++ b/packages/tests-unit/tests/core/routing/routingHandler.test.ts @@ -176,4 +176,13 @@ describe("routingHandler", () => { }, }); }); + + it("returns a JSON 404 for a data request from another build", async () => { + const result = (await routingHandler(event("/_next/data/old-build/about.json"))) as InternalResult; + + expect(result).toMatchObject({ + statusCode: 404, + headers: { "content-type": "application/json" }, + }); + }); }); From 436c730fd0b83df142fa3e41fb9a21b079fbc242 Mon Sep 17 00:00:00 2001 From: Victor Berchet Date: Thu, 3 Sep 2026 08:44:44 +0200 Subject: [PATCH 34/44] fix(routing): preserve external rewrite queries Merge incoming request query parameters with the external rewrite destination, giving destination values precedence on conflicts. Rebuild the invocation URL and normalized event query together so record-based proxy adapters do not drop either source or destination parameters. --- packages/core/src/core/routingHandler.ts | 14 ++++++++++---- .../tests/core/routing/routingHandler.test.ts | 19 ++++++++++++------- 2 files changed, 22 insertions(+), 11 deletions(-) diff --git a/packages/core/src/core/routingHandler.ts b/packages/core/src/core/routingHandler.ts index ae2fd4ca..06e24734 100644 --- a/packages/core/src/core/routingHandler.ts +++ b/packages/core/src/core/routingHandler.ts @@ -12,6 +12,7 @@ import type { AssetResolver } from "@/types/overrides"; import { emptyReadableStream, toReadableStream } from "@/utils/stream"; import { debug, error } from "../adapters/logger"; +import { getQueryFromSearchParams } from "../overrides/converters/utils.js"; import { cacheInterceptor } from "./routing/cacheInterceptor"; import { detectLocale } from "./routing/i18n"; @@ -350,12 +351,17 @@ export default async function routingHandler( } if (routingResult.externalRewrite) { - const externalEvent = toInternalEvent( - event, + const externalQuery = { + ...event.query, + ...(routingResult.resolvedQuery ?? + getQueryFromSearchParams(routingResult.externalRewrite.searchParams)), + }; + const externalUrl = toInvocationUrl( routingResult.externalRewrite.toString(), - middlewareHeaders, - routingResult.resolvedQuery ?? {} + routingResult.externalRewrite.pathname, + externalQuery ); + const externalEvent = toInternalEvent(event, externalUrl, middlewareHeaders, externalQuery); applyResponseHeaders(externalEvent, responseHeaders); return createRoutingResult(externalEvent, [], { isExternalRewrite: true, diff --git a/packages/tests-unit/tests/core/routing/routingHandler.test.ts b/packages/tests-unit/tests/core/routing/routingHandler.test.ts index 98f5d728..2ddd0af0 100644 --- a/packages/tests-unit/tests/core/routing/routingHandler.test.ts +++ b/packages/tests-unit/tests/core/routing/routingHandler.test.ts @@ -34,7 +34,10 @@ vi.mock("@/config/index", () => ({ beforeFiles: [], afterFiles: [ { sourceRegex: "^/rewrite$", destination: "/about?from=rewrite" }, - { sourceRegex: "^/external$", destination: "https://example.com/target" }, + { + sourceRegex: "^/external$", + destination: "https://example.com/target?destination=1", + }, ], dynamicRoutes: [ { @@ -51,14 +54,15 @@ vi.mock("@/config/index", () => ({ FunctionsConfigManifest: { functions: {}, version: 1 }, })); -function event(pathname: string): InternalEvent { +function event(target: string): InternalEvent { + const url = new URL(target, "https://localhost"); return { type: "core", method: "GET", - rawPath: pathname, - url: `https://localhost${pathname}`, + rawPath: url.pathname, + url: url.toString(), headers: { host: "localhost" }, - query: {}, + query: Object.fromEntries(url.searchParams), cookies: {}, remoteAddress: "127.0.0.1", }; @@ -166,13 +170,14 @@ describe("routingHandler", () => { }); it("preserves external rewrites for the proxy layer", async () => { - const result = await routingHandler(event("/external")); + const result = await routingHandler(event("/external?source=1&destination=source")); expect(result).toMatchObject({ isExternalRewrite: true, internalEvent: { rawPath: "/target", - url: "https://example.com/target", + url: "https://example.com/target?source=1&destination=1", + query: { destination: "1", source: "1" }, }, }); }); From caf9237483c46a5a88b810be82198023ef22e352 Mon Sep 17 00:00:00 2001 From: Victor Berchet Date: Thu, 3 Sep 2026 08:44:59 +0200 Subject: [PATCH 35/44] test(routing): verify middleware data request URL Assert that middleware receives the user-visible page pathname when routing a matching _next/data request. This protects the URL normalization behavior in addition to merely checking that middleware was invoked. --- .../tests/core/routing/routingHandler.middleware.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/tests-unit/tests/core/routing/routingHandler.middleware.test.ts b/packages/tests-unit/tests/core/routing/routingHandler.middleware.test.ts index 5fc63406..80966be0 100644 --- a/packages/tests-unit/tests/core/routing/routingHandler.middleware.test.ts +++ b/packages/tests-unit/tests/core/routing/routingHandler.middleware.test.ts @@ -82,6 +82,7 @@ describe("routingHandler middleware matching", () => { await routingHandler(event("/_next/data/build-id/en/foo.json"), { middlewareLoader }); expect(middlewareLoader).toHaveBeenCalled(); + expect(middleware).toHaveBeenCalledWith(expect.objectContaining({ url: "https://localhost/en/foo" })); }); it("does not invoke the middleware for a page the matchers exclude", async () => { From 5f7efb3baebc17e620c2156b9fe2ed5344b7d542 Mon Sep 17 00:00:00 2001 From: Victor Berchet Date: Thu, 3 Sep 2026 08:45:08 +0200 Subject: [PATCH 36/44] fix(routing): preserve bodies after middleware Tee request streams before invoking middleware so downstream internal routes, external rewrites, and 404 handlers receive an unread body branch. Cancel the unused branch for terminal middleware, redirect, asset, and cache responses to avoid retaining stream resources. --- packages/core/src/core/routingHandler.ts | 29 ++++++++++++++-- .../routing/routingHandler.middleware.test.ts | 34 ++++++++++++++++++- 2 files changed, 60 insertions(+), 3 deletions(-) diff --git a/packages/core/src/core/routingHandler.ts b/packages/core/src/core/routingHandler.ts index 06e24734..fab0285e 100644 --- a/packages/core/src/core/routingHandler.ts +++ b/packages/core/src/core/routingHandler.ts @@ -194,6 +194,13 @@ function createRoutingResult( }; } +/** + * Resolves an incoming request to an OpenNext route or direct response. + * + * @param event The normalized provider request. + * @param options Optional asset and middleware loaders. + * @returns The direct response or routing metadata for the selected handler. + */ export default async function routingHandler( event: InternalEvent, { @@ -230,6 +237,12 @@ export default async function routingHandler( let directMiddlewareResult: InternalResult | undefined; let middlewareRewriteStatusCode: number | undefined; let middlewareHeaders = new Headers(event.headers); + let forwardedBody = event.body; + const cancelForwardedBody = (): void => { + if (forwardedBody !== event.body) { + void forwardedBody?.cancel().catch(() => {}); + } + }; const buildId = RoutingConfig.buildId || BuildId; const basePath = NextConfig.basePath ?? ""; const dataPrefix = `${basePath}/_next/data/`; @@ -277,6 +290,8 @@ export default async function routingHandler( // invoked on the page it carries - the middleware has no route for the data pathname. const middlewareUrl = new URL(context.url); middlewareUrl.pathname = matchPath; + const [bodyForMiddleware, bodyForForward] = context.requestBody?.tee() ?? [undefined, undefined]; + forwardedBody = bodyForForward as InternalEvent["body"]; const middleware = await middlewareLoader(); const response = await middleware.default({ @@ -295,7 +310,7 @@ export default async function routingHandler( trailingSlash: NextConfig.trailingSlash, }, url: middlewareUrl.toString(), - body: context.requestBody, + body: bodyForMiddleware, } as unknown as Request); const result = responseToMiddlewareResult(response, context.headers, context.url); restoreNullOrigin(result, context.url); @@ -318,6 +333,7 @@ export default async function routingHandler( }); if (routingResult.middlewareResponded && directMiddlewareResult) { + cancelForwardedBody(); return directMiddlewareResult; } @@ -330,6 +346,7 @@ export default async function routingHandler( "location", normalizeLocationHeader(routingResult.redirect.url.toString(), event.url, true) ); + cancelForwardedBody(); return { type: event.type, statusCode: routingResult.redirect.status, @@ -341,6 +358,7 @@ export default async function routingHandler( const location = responseHeaders.get("location"); if (location && routingResult.status && routingResult.status >= 300 && routingResult.status < 400) { + cancelForwardedBody(); return { type: event.type, statusCode: routingResult.status, @@ -361,7 +379,10 @@ export default async function routingHandler( routingResult.externalRewrite.pathname, externalQuery ); - const externalEvent = toInternalEvent(event, externalUrl, middlewareHeaders, externalQuery); + const externalEvent = { + ...toInternalEvent(event, externalUrl, middlewareHeaders, externalQuery), + body: forwardedBody, + }; applyResponseHeaders(externalEvent, responseHeaders); return createRoutingResult(externalEvent, [], { isExternalRewrite: true, @@ -373,6 +394,7 @@ export default async function routingHandler( if (!routingResult.invocationTarget || !routingResult.resolvedPathname) { const notFoundEvent = { ...event, + body: forwardedBody, rawPath: "/404", url: constructNextUrl(event.url, "/404"), headers: { @@ -399,12 +421,14 @@ export default async function routingHandler( middlewareHeaders, routingResult.resolvedQuery ?? routingResult.invocationTarget.query ), + body: forwardedBody, rewriteStatusCode, }; const resolvedRoutes = getResolvedRoute(routingResult.resolvedPathname); const assetResult = await assetResolver?.maybeGetAssetResult?.(resolvedEvent); if (assetResult) { + cancelForwardedBody(); applyResponseHeaders(assetResult, responseHeaders); return assetResult; } @@ -429,6 +453,7 @@ export default async function routingHandler( debug("Attempting cache interception"); const cacheInterceptionResult = await cacheInterceptor(resolvedEvent); if (isInternalResult(cacheInterceptionResult)) { + cancelForwardedBody(); applyResponseHeaders(cacheInterceptionResult, responseHeaders); return cacheInterceptionResult; } diff --git a/packages/tests-unit/tests/core/routing/routingHandler.middleware.test.ts b/packages/tests-unit/tests/core/routing/routingHandler.middleware.test.ts index 80966be0..9163cdde 100644 --- a/packages/tests-unit/tests/core/routing/routingHandler.middleware.test.ts +++ b/packages/tests-unit/tests/core/routing/routingHandler.middleware.test.ts @@ -1,5 +1,6 @@ import routingHandler from "@opennextjs/core/core/routingHandler.js"; -import type { InternalEvent } from "@opennextjs/core/types/open-next.js"; +import type { InternalEvent, RoutingResult } from "@opennextjs/core/types/open-next.js"; +import { fromReadableStream, toReadableStream } from "@opennextjs/core/utils/stream.js"; import { vi } from "vitest"; vi.mock("@/config/index", () => ({ @@ -85,6 +86,37 @@ describe("routingHandler middleware matching", () => { expect(middleware).toHaveBeenCalledWith(expect.objectContaining({ url: "https://localhost/en/foo" })); }); + it("forwards the request body after middleware reads it", async () => { + middleware.mockImplementationOnce(async (request: { body?: InternalEvent["body"] }) => { + expect(await fromReadableStream(request.body!)).toBe("request body"); + return new Response(null, { headers: { "x-middleware-next": "1" } }); + }); + const request = event("/en/foo"); + request.method = "POST"; + request.body = toReadableStream("request body"); + + const result = (await routingHandler(request, { middlewareLoader })) as RoutingResult; + + expect(await fromReadableStream(result.internalEvent.body!)).toBe("request body"); + }); + + it("forwards the request body when middleware runs before a 404", async () => { + middleware.mockImplementationOnce(async (request: { body?: InternalEvent["body"] }) => { + expect(await fromReadableStream(request.body!)).toBe("request body"); + return new Response(null, { + headers: { "x-middleware-rewrite": "https://localhost/en/missing" }, + }); + }); + const request = event("/en/foo"); + request.method = "POST"; + request.body = toReadableStream("request body"); + + const result = (await routingHandler(request, { middlewareLoader })) as RoutingResult; + + expect(result.internalEvent.rawPath).toBe("/404"); + expect(await fromReadableStream(result.internalEvent.body!)).toBe("request body"); + }); + it("does not invoke the middleware for a page the matchers exclude", async () => { await routingHandler(event("/en/bar"), { middlewareLoader }); From 08f82134f76fc06f734d649b4b6258779d352367 Mon Sep 17 00:00:00 2001 From: Victor Berchet Date: Thu, 3 Sep 2026 09:07:30 +0200 Subject: [PATCH 37/44] test(core): use the resolver routing shape Replace the obsolete redirects fixture key with the beforeMiddleware and onMatch groups required by @next/routing. This keeps the adapter build test representative of the routing contract emitted by Next.js. --- packages/core/src/build/adapter.spec.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/core/src/build/adapter.spec.ts b/packages/core/src/build/adapter.spec.ts index 1349fdbc..fb720d67 100644 --- a/packages/core/src/build/adapter.spec.ts +++ b/packages/core/src/build/adapter.spec.ts @@ -136,11 +136,12 @@ function createMockBuildOpts(): BuildOptions { function createMockContext(): BuildCompleteContext { return { routing: { + beforeMiddleware: [], beforeFiles: [], afterFiles: [], fallback: [], - redirects: [], dynamicRoutes: [], + onMatch: [], }, outputs: { pages: [], From 44a04aa3ec53a9c0806f4d5c04be639644699983 Mon Sep 17 00:00:00 2001 From: Victor Berchet Date: Thu, 3 Sep 2026 09:07:54 +0200 Subject: [PATCH 38/44] fix(middleware): omit an absent geo city Only decode the OpenNext city header when it is present. This prevents middleware requests without geo metadata from receiving the literal string 'undefined' as their city. --- packages/core/src/core/routingHandler.ts | 3 ++- .../tests/core/routing/routingHandler.middleware.test.ts | 8 ++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/core/src/core/routingHandler.ts b/packages/core/src/core/routingHandler.ts index fab0285e..0933dbb3 100644 --- a/packages/core/src/core/routingHandler.ts +++ b/packages/core/src/core/routingHandler.ts @@ -294,9 +294,10 @@ export default async function routingHandler( forwardedBody = bodyForForward as InternalEvent["body"]; const middleware = await middlewareLoader(); + const encodedCity = event.headers["x-open-next-city"]; const response = await middleware.default({ geo: { - city: decodeURIComponent(event.headers["x-open-next-city"]), + city: encodedCity === undefined ? undefined : decodeURIComponent(encodedCity), country: event.headers["x-open-next-country"], region: event.headers["x-open-next-region"], latitude: event.headers["x-open-next-latitude"], diff --git a/packages/tests-unit/tests/core/routing/routingHandler.middleware.test.ts b/packages/tests-unit/tests/core/routing/routingHandler.middleware.test.ts index 9163cdde..d80883d8 100644 --- a/packages/tests-unit/tests/core/routing/routingHandler.middleware.test.ts +++ b/packages/tests-unit/tests/core/routing/routingHandler.middleware.test.ts @@ -79,6 +79,14 @@ describe("routingHandler middleware matching", () => { expect(middlewareLoader).toHaveBeenCalled(); }); + it("leaves an absent geo city undefined", async () => { + await routingHandler(event("/en/foo"), { middlewareLoader }); + + expect(middleware).toHaveBeenCalledWith( + expect.objectContaining({ geo: expect.objectContaining({ city: undefined }) }) + ); + }); + it("invokes the middleware for the `_next/data` request of a matching page", async () => { await routingHandler(event("/_next/data/build-id/en/foo.json"), { middlewareLoader }); From ad552f905a85fc6276599ea1b738a2fc656b9f6c Mon Sep 17 00:00:00 2001 From: Victor Berchet Date: Thu, 3 Sep 2026 09:08:34 +0200 Subject: [PATCH 39/44] fix(routing): serialize headers on forwarded events Keep array-valued headers on direct InternalResult responses, but serialize them when response headers are prefixed onto an InternalEvent. This preserves the request event's Record contract while retaining multi-value response support. --- packages/core/src/core/routingHandler.ts | 18 ++++++++++++++---- .../routing/routingHandler.middleware.test.ts | 11 +++++++++++ 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/packages/core/src/core/routingHandler.ts b/packages/core/src/core/routingHandler.ts index 0933dbb3..f2a5be00 100644 --- a/packages/core/src/core/routingHandler.ts +++ b/packages/core/src/core/routingHandler.ts @@ -84,11 +84,21 @@ function headersToRequestRecord(headers: Headers): Record { return result; } +/** + * Applies routed response headers to a response or forwarded request. + * + * @param eventOrResult The response or request that receives the headers. + * @param headers The routed response headers. + * @returns Nothing. + */ function applyResponseHeaders(eventOrResult: InternalEvent | InternalResult, headers: Headers): void { - const isResult = isInternalResult(eventOrResult); - const keyPrefix = isResult ? "" : MIDDLEWARE_HEADER_PREFIX; - for (const [key, value] of Object.entries(headersToRecord(headers))) { - eventOrResult.headers[keyPrefix + key] = value; + const responseHeaders = headersToRecord(headers); + if (isInternalResult(eventOrResult)) { + Object.assign(eventOrResult.headers, responseHeaders); + return; + } + for (const [key, value] of Object.entries(responseHeaders)) { + eventOrResult.headers[MIDDLEWARE_HEADER_PREFIX + key] = Array.isArray(value) ? value.join(",") : value; } } diff --git a/packages/tests-unit/tests/core/routing/routingHandler.middleware.test.ts b/packages/tests-unit/tests/core/routing/routingHandler.middleware.test.ts index d80883d8..3986b03c 100644 --- a/packages/tests-unit/tests/core/routing/routingHandler.middleware.test.ts +++ b/packages/tests-unit/tests/core/routing/routingHandler.middleware.test.ts @@ -87,6 +87,17 @@ describe("routingHandler middleware matching", () => { ); }); + it("serializes response header arrays on forwarded requests", async () => { + const headers = new Headers({ "x-middleware-next": "1" }); + headers.append("set-cookie", "first=1"); + headers.append("set-cookie", "second=2"); + middleware.mockResolvedValueOnce(new Response(null, { headers })); + + const result = (await routingHandler(event("/en/foo"), { middlewareLoader })) as RoutingResult; + + expect(result.internalEvent.headers["x-middleware-response-set-cookie"]).toBe("first=1,second=2"); + }); + it("invokes the middleware for the `_next/data` request of a matching page", async () => { await routingHandler(event("/_next/data/build-id/en/foo.json"), { middlewareLoader }); From a4779a80c377a47e8c62ef414e7d6d8ac7b3d176 Mon Sep 17 00:00:00 2001 From: Victor Berchet Date: Thu, 3 Sep 2026 11:27:18 +0200 Subject: [PATCH 40/44] move @next/routing to peer dep --- packages/core/package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core/package.json b/packages/core/package.json index 51fed7f9..950dc9aa 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -48,7 +48,6 @@ }, "dependencies": { "@ast-grep/napi": "^0.40.5", - "@next/routing": "16.2.1", "@node-minify/core": "^8.0.6", "@node-minify/terser": "^8.0.6", "@tsconfig/node24": "^24.0.4", @@ -70,6 +69,7 @@ "vitest": "catalog:" }, "peerDependencies": { + "@next/routing": "16.2.1", "next": "16.2.1" } -} +} \ No newline at end of file From c0a5752a8656bdeece29cd6ad6c2f2ed7956013e Mon Sep 17 00:00:00 2001 From: Victor Berchet Date: Thu, 3 Sep 2026 11:39:58 +0200 Subject: [PATCH 41/44] use pnpm 11 --- .github/actions/setup/action.yml | 4 ++-- .github/workflows/e2e.yml | 4 ++-- package.json | 1 + pnpm-workspace.yaml | 12 ++++++++++++ 4 files changed, 17 insertions(+), 4 deletions(-) diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index f3139581..e460ab10 100644 --- a/.github/actions/setup/action.yml +++ b/.github/actions/setup/action.yml @@ -10,9 +10,9 @@ runs: using: composite steps: - name: Install pnpm - uses: pnpm/action-setup@v6 + uses: pnpm/setup@v2 with: - version: 10 + install: false - name: Install Node.js ${{ inputs.node-version }} uses: actions/setup-node@v7 diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 6ec188c6..39165c47 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -92,9 +92,9 @@ jobs: aws-region: us-east-1 - name: Setup pnpm - uses: pnpm/action-setup@v6 + uses: pnpm/setup@v2 with: - version: 10 + install: false - name: Set up NodeJS uses: actions/setup-node@v7 diff --git a/package.json b/package.json index 55e934bf..a6baa28e 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,7 @@ "name": "monorepo", "version": "0.0.0", "private": true, + "packageManager": "pnpm@11.25.0", "license": "MIT", "type": "module", "scripts": { diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index e794b3dc..56d5c82e 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -7,6 +7,18 @@ packages: - "!examples-cloudflare/common" - "packages/*" +allowBuilds: + '@prisma/client': true + '@prisma/engines': true + aws-crt: true + aws-sdk: true + better-sqlite3: true + esbuild: true + prisma: true + sharp: true + turbo: true + workerd: true + catalog: "@cloudflare/workers-types": ^4.20260114.0 "@dotenvx/dotenvx": 1.31.0 From 1cd0e886554a027c221520d24da5209df4d6d1dd Mon Sep 17 00:00:00 2001 From: Victor Berchet Date: Thu, 3 Sep 2026 11:43:47 +0200 Subject: [PATCH 42/44] fixup! fmt --- package.json | 4 ++-- packages/core/package.json | 2 +- pnpm-workspace.yaml | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index a6baa28e..77587488 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,6 @@ "name": "monorepo", "version": "0.0.0", "private": true, - "packageManager": "pnpm@11.25.0", "license": "MIT", "type": "module", "scripts": { @@ -39,5 +38,6 @@ "engines": { "node": ">=24", "pnpm": ">=10" - } + }, + "packageManager": "pnpm@11.25.0" } diff --git a/packages/core/package.json b/packages/core/package.json index 950dc9aa..3a75b71b 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -72,4 +72,4 @@ "@next/routing": "16.2.1", "next": "16.2.1" } -} \ No newline at end of file +} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 56d5c82e..f6c505a0 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -8,8 +8,8 @@ packages: - "packages/*" allowBuilds: - '@prisma/client': true - '@prisma/engines': true + "@prisma/client": true + "@prisma/engines": true aws-crt: true aws-sdk: true better-sqlite3: true From 4bdcc407342e42d20b6fa339d04bbd36a5f560b8 Mon Sep 17 00:00:00 2001 From: Victor Berchet Date: Thu, 3 Sep 2026 12:00:08 +0200 Subject: [PATCH 43/44] fixup! bin with pnpm 11 --- packages/aws/bin/open-next.js | 3 +++ packages/aws/package.json | 3 ++- packages/cloudflare/bin/opennextjs-cloudflare.js | 3 +++ packages/cloudflare/package.json | 3 ++- 4 files changed, 10 insertions(+), 2 deletions(-) create mode 100644 packages/aws/bin/open-next.js create mode 100644 packages/cloudflare/bin/opennextjs-cloudflare.js diff --git a/packages/aws/bin/open-next.js b/packages/aws/bin/open-next.js new file mode 100644 index 00000000..c74d2a04 --- /dev/null +++ b/packages/aws/bin/open-next.js @@ -0,0 +1,3 @@ +#!/usr/bin/env node + +import "../dist/index.js"; diff --git a/packages/aws/package.json b/packages/aws/package.json index 27ab43e6..97844f2f 100644 --- a/packages/aws/package.json +++ b/packages/aws/package.json @@ -15,10 +15,11 @@ "directory": "packages/open-next" }, "bin": { - "open-next": "./dist/index.js" + "open-next": "./bin/open-next.js" }, "files": [ "dist", + "bin", "assets", "README.md" ], diff --git a/packages/cloudflare/bin/opennextjs-cloudflare.js b/packages/cloudflare/bin/opennextjs-cloudflare.js new file mode 100644 index 00000000..35b00ed4 --- /dev/null +++ b/packages/cloudflare/bin/opennextjs-cloudflare.js @@ -0,0 +1,3 @@ +#!/usr/bin/env node + +import "../dist/cli/index.js"; diff --git a/packages/cloudflare/package.json b/packages/cloudflare/package.json index 781861da..af374c19 100644 --- a/packages/cloudflare/package.json +++ b/packages/cloudflare/package.json @@ -19,10 +19,11 @@ "directory": "packages/cloudflare" }, "bin": { - "opennextjs-cloudflare": "dist/cli/index.js" + "opennextjs-cloudflare": "bin/opennextjs-cloudflare.js" }, "files": [ "README.md", + "bin", "dist", "templates" ], From 9d58df4b63d556da34f65ec805329825053d2c2a Mon Sep 17 00:00:00 2001 From: Victor Berchet Date: Thu, 3 Sep 2026 12:01:41 +0200 Subject: [PATCH 44/44] oxlint: disable eslint(no-param-reassign) --- .oxlintrc.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.oxlintrc.json b/.oxlintrc.json index 53d0e7e5..39ff729f 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -14,7 +14,7 @@ "@typescript-eslint/no-redeclare": "error", "no-unused-vars": "off", "no-delete-var": "off", - "no-param-reassign": "warn", + "no-param-reassign": "off", "no-sequences": "warn", "unicorn/prefer-node-protocol": "error", "import/first": "error",