From fa74e70de9637b83d79ff907769ba56a0d1eab48 Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Sat, 27 Jun 2026 17:17:49 +0200 Subject: [PATCH 01/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/"); + }); + }); +});