From fa74e70de9637b83d79ff907769ba56a0d1eab48 Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Sat, 27 Jun 2026 17:17:49 +0200 Subject: [PATCH 01/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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 c200d7acd1d299614140726b88c79fe681e81a94 Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Sun, 16 Aug 2026 15:01:03 +0200 Subject: [PATCH 16/28] feat(core): run the cache in a dedicated handler function Add a standalone cache handler function that owns the incremental cache, the tag cache and CDN invalidation, and a `cache` override that other functions use to reach it. - `adapters/cache-adapter.ts` exposes `GET/PUT/DELETE /cache/*` and `POST /cache/revalidate-tags` through `createGenericHandler`. - `build/createCacheBundle.ts` bundles it to `.open-next/cache-function`, wired into `buildAdapter` behind the new `skipCache` option and reported as `cacheFunction` in the OpenNext output. - New top level `cacheHandler` config option carries the incremental cache, tag cache and cdn invalidation overrides for that function. - New `cache` override with `fetch`, `local` and `dummy` implementations, and a shared wire format in `utils/cache-get.ts` that splits the entry metadata into headers and the payload into the body. Nothing is removed here: the existing in-process `incrementalCache` and `tagCache` overrides keep working unchanged. --- .changeset/cache-handler-function.md | 31 ++ packages/core/src/adapters/cache-adapter.ts | 465 ++++++++++++++++++ packages/core/src/adapters/middleware.ts | 3 + packages/core/src/build/adapter.spec.ts | 22 +- packages/core/src/build/adapter.ts | 14 +- packages/core/src/build/createCacheBundle.ts | 53 ++ packages/core/src/build/generateOutput.ts | 13 +- .../core/src/core/createGenericHandler.ts | 20 +- packages/core/src/core/createMainHandler.ts | 3 + packages/core/src/core/resolve.ts | 13 +- packages/core/src/overrides/cache/dummy.ts | 20 + packages/core/src/overrides/cache/fetch.ts | 46 ++ packages/core/src/overrides/cache/local.ts | 96 ++++ packages/core/src/plugins/resolve.ts | 3 + packages/core/src/types/global.ts | 8 + packages/core/src/types/open-next.ts | 33 ++ packages/core/src/types/overrides.ts | 14 + packages/core/src/utils/cache-get.ts | 184 +++++++ .../tests/overrides/cache/fetch.test.ts | 199 ++++++++ .../tests/overrides/cache/local.test.ts | 220 +++++++++ .../tests-unit/tests/utils/cache-get.test.ts | 359 ++++++++++++++ 21 files changed, 1811 insertions(+), 8 deletions(-) create mode 100644 .changeset/cache-handler-function.md create mode 100644 packages/core/src/adapters/cache-adapter.ts create mode 100644 packages/core/src/build/createCacheBundle.ts create mode 100644 packages/core/src/overrides/cache/dummy.ts create mode 100644 packages/core/src/overrides/cache/fetch.ts create mode 100644 packages/core/src/overrides/cache/local.ts create mode 100644 packages/core/src/utils/cache-get.ts create mode 100644 packages/tests-unit/tests/overrides/cache/fetch.test.ts create mode 100644 packages/tests-unit/tests/overrides/cache/local.test.ts create mode 100644 packages/tests-unit/tests/utils/cache-get.test.ts diff --git a/.changeset/cache-handler-function.md b/.changeset/cache-handler-function.md new file mode 100644 index 00000000..1d0659d6 --- /dev/null +++ b/.changeset/cache-handler-function.md @@ -0,0 +1,31 @@ +--- +"@opennextjs/core": minor +--- + +Add a dedicated cache handler function + +The incremental cache, the tag cache and CDN invalidation can now run in their own function, bundled +to `.open-next/cache-function` and reachable over the new `cache` override. + +The overrides for that function are configured through a new top level `cacheHandler` option: + +```ts +export default { + default: { + override: { + cache: "local", + }, + }, + cacheHandler: { + incrementalCache: "s3", + tagCache: "dynamodb", + }, +} satisfies OpenNextConfig; +``` + +Three `cache` implementations ship with core: `fetch` (HTTP), `local` (in-process, imports the cache +bundle) and `dummy`. Adapters that do not want the bundle can opt out with `skipCache` in +`buildAdapter`. + +This is additive: the existing `default.override.incrementalCache` and `default.override.tagCache` +keep working unchanged. diff --git a/packages/core/src/adapters/cache-adapter.ts b/packages/core/src/adapters/cache-adapter.ts new file mode 100644 index 00000000..662c98c4 --- /dev/null +++ b/packages/core/src/adapters/cache-adapter.ts @@ -0,0 +1,465 @@ +import { AsyncLocalStorage } from "node:async_hooks"; + +import type { StoredComposableCacheEntry } from "@/types/cache"; +import type { InternalEvent, InternalResult } from "@/types/open-next"; +import type { + CacheEntryType, + CachedFile, + CachedFetchValue, + CacheValue, + OpenNextHandlerOptions, + WithLastModified, +} from "@/types/overrides"; + +import { createGenericHandler } from "../core/createGenericHandler.js"; +import { resolveCdnInvalidation, resolveIncrementalCache, resolveTagCache } from "../core/resolve.js"; +import { writeTags } from "../utils/cache.js"; +import { runWithOpenNextRequestContext } from "../utils/promise.js"; +import { toReadableStream } from "../utils/stream.js"; + +import { debug, error } from "./logger.js"; + +globalThis.__openNextAls = new AsyncLocalStorage(); + +const SOFT_TAG_PREFIX = "_N_T_/"; + +// Whether caches have been initialized +let initialized = false; + +async function initializeCaches() { + if (initialized) return; + const config = globalThis.openNextConfig; + + globalThis.incrementalCache = await resolveIncrementalCache( + config.cacheHandler?.incrementalCache ?? config.default?.override?.incrementalCache + ); + + globalThis.tagCache = await resolveTagCache( + config.cacheHandler?.tagCache ?? config.default?.override?.tagCache + ); + + globalThis.cdnInvalidationHandler = await resolveCdnInvalidation( + config.cacheHandler?.cdnInvalidation ?? config.default?.override?.cdnInvalidation + ); + + initialized = true; +} + +///////////// +// Handler // +///////////// + +export const handler = await createGenericHandler({ + handler: defaultHandler, + type: "cache", +}); + +async function defaultHandler( + event: InternalEvent, + options?: OpenNextHandlerOptions +): Promise { + debug("cache handler event", event); + + try { + await initializeCaches(); + } catch (e) { + error("Failed to initialize caches", e); + return buildErrorResponse("Internal server error", 500); + } + + const { method, rawPath, query, body } = event; + + try { + // POST /cache/revalidate-tags + if (method === "POST" && rawPath === "/cache/revalidate-tags") { + return await handleRevalidateTags(body); + } + + // All other operations must be on /cache/* + if (!rawPath.startsWith("/cache/")) { + return buildErrorResponse("Not Found", 404); + } + + const key = decodeURIComponent(rawPath.slice("/cache/".length)); + + if (!key) { + return buildErrorResponse("Missing cache key", 400); + } + + const rawType = typeof query?.type === "string" ? query.type : undefined; + const cacheType: CacheEntryType = rawType === "fetch" || rawType === "composable" ? rawType : "cache"; + + switch (method) { + case "GET": + return await handleGet(key, cacheType); + case "PUT": + return await handleSet(key, cacheType, body); + case "DELETE": + return await handleDelete(key); + default: + return buildErrorResponse("Method Not Allowed", 405); + } + } catch (e) { + error("Failed to handle cache request", e); + return buildErrorResponse("Internal server error", 500); + } +} + +////////////////////// +// Route handlers // +////////////////////// + +async function handleGet(key: string, cacheType: CacheEntryType): Promise { + debug("get", { key, cacheType }); + + try { + const result = await globalThis.incrementalCache.get(key, cacheType); + + if (!result?.value) { + return { + type: "core", + statusCode: 404, + body: toReadableStream(""), + isBase64Encoded: false, + headers: { + "x-opennext-cache-found": "false", + "Cache-Control": "no-store", + }, + }; + } + + return buildCacheGetResponse(result); + } catch (e) { + error("Failed to get cache entry", e); + return buildErrorResponse("Failed to get cache entry", 500); + } +} + +async function handleSet(key: string, cacheType: CacheEntryType, body?: Buffer): Promise { + debug("set", { key, cacheType }); + + let payload: { + value?: Record; + } = {}; + + if (body && body.length > 0) { + try { + payload = JSON.parse(body.toString("utf-8")); + } catch { + return buildErrorResponse("Invalid JSON body", 400); + } + } + + if (!payload.value) { + return buildErrorResponse("Missing 'value' in request body", 400); + } + + try { + await globalThis.incrementalCache.set(key, payload.value as CacheValue, cacheType); + return buildJsonResponse({ ok: true }, 200); + } catch (e) { + error("Failed to set cache entry", e); + return buildErrorResponse("Failed to set cache entry", 500); + } +} + +async function handleDelete(key: string): Promise { + debug("delete", { key }); + + try { + await globalThis.incrementalCache.delete(key); + return buildJsonResponse({ ok: true }, 200); + } catch (e) { + error("Failed to delete cache entry", e); + return buildErrorResponse("Failed to delete cache entry", 500); + } +} + +async function handleRevalidateTags(body?: Buffer): Promise { + debug("revalidateTags"); + + if (!body || body.length === 0) { + return buildErrorResponse("Missing request body", 400); + } + + let tags: string[]; + try { + const parsed = JSON.parse(body.toString("utf-8")); + tags = Array.isArray(parsed.tags) ? parsed.tags : []; + } catch { + return buildErrorResponse("Invalid JSON body", 400); + } + + if (tags.length === 0) { + return buildErrorResponse("Missing 'tags' array in request body", 400); + } + + try { + await runWithOpenNextRequestContext({ isISRRevalidation: false }, async () => { + if (globalThis.tagCache.mode === "nextMode") { + const paths = (await globalThis.tagCache.getPathsByTags?.(tags)) ?? []; + + await writeTags(tags); + if (paths.length > 0) { + await globalThis.cdnInvalidationHandler.invalidatePaths( + paths.map((path) => ({ + initialPath: path, + rawPath: path, + resolvedRoutes: [ + { + route: path, + type: "app", + isFallback: false, + }, + ], + })) + ); + } + return; + } + + for (const tag of tags) { + debug("revalidateTag", tag); + const paths = await globalThis.tagCache.getByTag(tag); + debug("Items", paths); + const toInsert = paths.map((path) => ({ + path, + tag, + })); + + if (tag.startsWith(SOFT_TAG_PREFIX)) { + for (const path of paths) { + const _tags = await globalThis.tagCache.getByPath(path); + const hardTags = _tags.filter((t) => !t.startsWith(SOFT_TAG_PREFIX)); + for (const hardTag of hardTags) { + const _paths = await globalThis.tagCache.getByTag(hardTag); + debug({ hardTag, _paths }); + toInsert.push( + ..._paths.map((path) => ({ + path, + tag: hardTag, + })) + ); + } + } + } + + await writeTags(toInsert); + + const uniquePaths = Array.from( + new Set(toInsert.filter((t) => t.tag.startsWith(SOFT_TAG_PREFIX)).map((t) => `/${t.path}`)) + ); + if (uniquePaths.length > 0) { + await globalThis.cdnInvalidationHandler.invalidatePaths( + uniquePaths.map((path) => ({ + initialPath: path, + rawPath: path, + resolvedRoutes: [ + { + route: path, + type: "app", + isFallback: false, + }, + ], + })) + ); + } + } + }); + + return buildJsonResponse({ revalidated: tags }, 200); + } catch (e) { + error("Failed to revalidate tags", e); + return buildErrorResponse("Failed to revalidate tags", 500); + } +} + +///////////////////////////// +// Cache GET response builder // +///////////////////////////// + +function buildCacheGetResponse(result: WithLastModified>): InternalResult { + const value = result.value!; + + const headers: Record = { + "x-opennext-cache-found": "true", + "Cache-Control": "no-store", + }; + + if (result.lastModified !== undefined) { + headers["x-opennext-cache-last-modified"] = String(result.lastModified); + } + if (result.shouldBypassTagCache) { + headers["x-opennext-cache-should-bypass"] = "true"; + } + + if ("kind" in value && value.kind === "FETCH") { + return buildFetchResponse(value as CachedFetchValue, headers); + } + + if ("type" in value) { + return buildCachedFileResponse(value as CachedFile, headers); + } + + return buildComposableResponse(value as StoredComposableCacheEntry, headers); +} + +function buildFetchResponse( + value: CacheValue<"fetch">, + headers: Record +): InternalResult { + headers["x-opennext-cache-type"] = "fetch"; + headers["x-opennext-cache-fetch-kind"] = "FETCH"; + headers["x-opennext-cache-fetch-data-url"] = value.data.url; + + if (value.revalidate !== undefined) { + headers["x-opennext-cache-revalidate"] = String(value.revalidate); + } + + if (value.data.status !== undefined) { + headers["x-opennext-cache-fetch-data-status"] = String(value.data.status); + } + if (value.data.tags) { + headers["x-opennext-cache-fetch-data-tags"] = JSON.stringify(value.data.tags); + } + if (value.tags) { + headers["x-opennext-cache-fetch-tags"] = JSON.stringify(value.tags); + } + + for (const [key, val] of Object.entries(value.data.headers)) { + headers[`x-opennext-cache-header-${key}`] = val; + } + + const body = value.data.body; + return { + type: "core", + statusCode: 200, + body: toReadableStream(body), + isBase64Encoded: false, + headers: { ...headers, "Content-Type": "text/plain" }, + }; +} + +function buildCachedFileResponse( + value: CacheValue<"cache">, + headers: Record +): InternalResult { + headers["x-opennext-cache-type"] = "cache"; + headers["x-opennext-cache-sub-type"] = value.type; + + if (value.revalidate !== undefined) { + headers["x-opennext-cache-revalidate"] = String(value.revalidate); + } + + if (value.meta?.status !== undefined) { + headers["x-opennext-cache-meta-status"] = String(value.meta.status); + } + if (value.meta?.postponed !== undefined) { + headers["x-opennext-cache-meta-postponed"] = value.meta.postponed; + } + if (value.meta?.headers) { + for (const [key, val] of Object.entries(value.meta.headers)) { + if (val !== undefined) { + headers[`x-opennext-cache-header-${key}`] = val; + } + } + } + + switch (value.type) { + case "route": { + return { + type: "core", + statusCode: 200, + body: toReadableStream(value.body), + isBase64Encoded: false, + headers: { ...headers, "Content-Type": "text/plain" }, + }; + } + case "page": { + return { + type: "core", + statusCode: 200, + body: toReadableStream(JSON.stringify({ json: value.json, html: value.html })), + isBase64Encoded: false, + headers: { ...headers, "Content-Type": "application/json" }, + }; + } + case "app": { + return { + type: "core", + statusCode: 200, + body: toReadableStream( + JSON.stringify({ + html: value.html, + rsc: value.rsc, + segmentData: value.segmentData, + }) + ), + isBase64Encoded: false, + headers: { ...headers, "Content-Type": "application/json" }, + }; + } + case "redirect": { + return { + type: "core", + statusCode: 200, + body: toReadableStream(JSON.stringify(value.props ?? {})), + isBase64Encoded: false, + headers: { ...headers, "Content-Type": "application/json" }, + }; + } + } +} + +function buildComposableResponse( + value: StoredComposableCacheEntry, + headers: Record +): InternalResult { + headers["x-opennext-cache-type"] = "composable"; + headers["x-opennext-cache-composable-stale"] = String(value.stale); + headers["x-opennext-cache-composable-expire"] = String(value.expire); + headers["x-opennext-cache-composable-timestamp"] = String(value.timestamp); + headers["x-opennext-cache-composable-revalidate"] = String(value.revalidate); + headers["x-opennext-cache-composable-tags"] = JSON.stringify(value.tags); + + return { + type: "core", + statusCode: 200, + body: toReadableStream(value.value), + isBase64Encoded: false, + headers: { ...headers, "Content-Type": "text/plain" }, + }; +} + +////////////////////////// +// Response builders // +////////////////////////// + +function buildJsonResponse(data: unknown, statusCode: number): InternalResult { + const body = JSON.stringify(data); + return { + type: "core", + statusCode, + body: toReadableStream(body), + isBase64Encoded: false, + headers: { + "Content-Type": "application/json", + "Cache-Control": "no-store", + }, + }; +} + +function buildErrorResponse(message: string, statusCode: number): InternalResult { + debug(message, statusCode); + const body = JSON.stringify({ error: message }); + return { + type: "core", + statusCode, + body: toReadableStream(body), + isBase64Encoded: false, + headers: { + "Content-Type": "application/json", + "Cache-Control": "no-store", + }, + }; +} diff --git a/packages/core/src/adapters/middleware.ts b/packages/core/src/adapters/middleware.ts index 295ec809..43038f8d 100644 --- a/packages/core/src/adapters/middleware.ts +++ b/packages/core/src/adapters/middleware.ts @@ -11,6 +11,7 @@ import { debug, error } from "../adapters/logger"; import { createGenericHandler } from "../core/createGenericHandler"; import { resolveAssetResolver, + resolveCache, resolveIncrementalCache, resolveOriginResolver, resolveProxyRequest, @@ -46,6 +47,8 @@ const defaultHandler = async ( globalThis.incrementalCache = await resolveIncrementalCache(middlewareConfig?.override?.incrementalCache); + globalThis.cache = await resolveCache(middlewareConfig?.override?.cache); + const requestId = Math.random().toString(36); // We run everything in the async local storage context so that it is available in the external middleware diff --git a/packages/core/src/build/adapter.spec.ts b/packages/core/src/build/adapter.spec.ts index dfdb54f1..28368f59 100644 --- a/packages/core/src/build/adapter.spec.ts +++ b/packages/core/src/build/adapter.spec.ts @@ -64,6 +64,10 @@ vi.mock("./createImageOptimizationBundle.js", () => ({ createImageOptimizationBundle: vi.fn(), })); +vi.mock("./createCacheBundle.js", () => ({ + createCacheBundle: vi.fn(), +})); + vi.mock("./createWarmerBundle.js", () => ({ createWarmerBundle: vi.fn(), })); @@ -94,6 +98,7 @@ import { compileCache } from "./compileCache.js"; import { compileOpenNextConfig } from "./compileConfig.js"; import { compileTagCacheProvider } from "./compileTagCacheProvider.js"; import { createStaticAssets, createCacheAssets } from "./createAssets.js"; +import { createCacheBundle } from "./createCacheBundle.js"; import { createImageOptimizationBundle } from "./createImageOptimizationBundle.js"; import { createMiddleware } from "./createMiddleware.js"; import { createRevalidationBundle } from "./createRevalidationBundle.js"; @@ -254,6 +259,21 @@ describe("buildAdapter", () => { expect(createRevalidationBundle).not.toHaveBeenCalled(); }); + test("onBuildComplete skips createCacheBundle when skipCache is true", async () => { + const adapter = buildAdapter(() => ({ + serverBundle, + skipCache: true, + })); + + const nextConfig = { experimental: {}, images: {} } as BuildCompleteContext["config"]; + await adapter.modifyConfig(nextConfig, { phase: "production" }); + + const ctx = createMockContext(); + await adapter.onBuildComplete(ctx); + + expect(createCacheBundle).not.toHaveBeenCalled(); + }); + test("onBuildComplete calls influence.beforeServerBundle BEFORE createMiddleware", async () => { const callOrder: string[] = []; @@ -514,7 +534,7 @@ describe("buildAdapter", () => { await adapter.modifyConfig(nextConfig, { phase: "production" }); const ctx = createMockContext(); await adapter.onBuildComplete(ctx); - expect(buildOpenNextOutput).toHaveBeenCalledWith(expect.any(Object)); + expect(buildOpenNextOutput).toHaveBeenCalledWith(expect.any(Object), { skipCache: undefined }); const fs = await import("node:fs"); expect(fs.default.writeFileSync).toHaveBeenCalledWith( expect.stringMatching(/\/\.open-next\/open-next\.output\.json$/), diff --git a/packages/core/src/build/adapter.ts b/packages/core/src/build/adapter.ts index 6ca36b41..ec0fdf1b 100644 --- a/packages/core/src/build/adapter.ts +++ b/packages/core/src/build/adapter.ts @@ -16,6 +16,7 @@ import { compileCache } from "./compileCache.js"; import { compileOpenNextConfig } from "./compileConfig.js"; import { compileTagCacheProvider } from "./compileTagCacheProvider.js"; import { createCacheAssets, createStaticAssets } from "./createAssets.js"; +import { createCacheBundle } from "./createCacheBundle.js"; import { createImageOptimizationBundle } from "./createImageOptimizationBundle.js"; import { createMiddleware } from "./createMiddleware.js"; import { createRevalidationBundle } from "./createRevalidationBundle.js"; @@ -58,6 +59,7 @@ export type OpenNextAdapterOptions = { skipRevalidation?: boolean; skipImageOptimization?: boolean; skipWarmer?: boolean; + skipCache?: boolean; skipGenerateOutput?: boolean; middlewareOptions?: { forceOnlyBuildOnce?: boolean }; serverBundle: { @@ -245,17 +247,23 @@ export function buildAdapter( console.log("Image optimization bundle created"); } - // Step 11: Warmer bundle + // Step 11: Cache bundle + if (!adapterOptions.skipCache && config.dangerous?.disableIncrementalCache !== true) { + await createCacheBundle(buildOpts, bundleDefaults?.cache); + console.log("Cache bundle created"); + } + + // Step 12: Warmer bundle if (!adapterOptions.skipWarmer) { await createWarmerBundle(buildOpts, bundleDefaults?.warmer); console.log("Warmer bundle created"); } - // Step 12: Generate output + // Step 13: Generate output if (!adapterOptions.skipGenerateOutput) { const output = adapterOptions.generateOutput ? await adapterOptions.generateOutput(buildOpts) - : await buildOpenNextOutput(buildOpts); + : await buildOpenNextOutput(buildOpts, { skipCache: adapterOptions.skipCache }); fs.writeFileSync( path.join(buildOpts.appBuildOutputPath, ".open-next", "open-next.output.json"), JSON.stringify(output) diff --git a/packages/core/src/build/createCacheBundle.ts b/packages/core/src/build/createCacheBundle.ts new file mode 100644 index 00000000..e61b9b4f --- /dev/null +++ b/packages/core/src/build/createCacheBundle.ts @@ -0,0 +1,53 @@ +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"; + +export async function createCacheBundle( + options: buildHelper.BuildOptions, + defaultOverrides?: DefaultOverrides +) { + logger.info("Bundling cache function..."); + + const { config, outputDir } = options; + + // Create output folder + const outputPath = path.join(outputDir, "cache-function"); + fs.mkdirSync(outputPath, { recursive: true }); + + // Copy open-next.config.mjs into the bundle + buildHelper.copyOpenNextConfig(options.buildDir, outputPath); + + // Build Lambda code + await buildHelper.esbuildAsync( + { + external: ["next"], + entryPoints: [path.join(options.openNextDistDir, "adapters", "cache-adapter.js")], + outfile: path.join(outputPath, "index.mjs"), + plugins: [ + openNextResolvePlugin({ + fnName: "cache", + overrides: { + converter: config.cacheHandler?.override?.converter, + wrapper: config.cacheHandler?.override?.wrapper, + incrementalCache: config.cacheHandler?.incrementalCache, + tagCache: config.cacheHandler?.tagCache, + cdnInvalidation: config.cacheHandler?.cdnInvalidation, + }, + defaultOverrides: { + converter: defaultOverrides?.converter ?? "node", + wrapper: defaultOverrides?.wrapper, + incrementalCache: defaultOverrides?.incrementalCache, + tagCache: defaultOverrides?.tagCache, + cdnInvalidation: defaultOverrides?.cdnInvalidation, + }, + }), + ], + }, + options + ); +} diff --git a/packages/core/src/build/generateOutput.ts b/packages/core/src/build/generateOutput.ts index e9915da6..56e39186 100644 --- a/packages/core/src/build/generateOutput.ts +++ b/packages/core/src/build/generateOutput.ts @@ -86,6 +86,7 @@ export interface OpenNextOutput { initializationFunction?: BaseFunction; warmer?: BaseFunction; revalidationFunction?: BaseFunction; + cacheFunction?: BaseFunction; }; } @@ -163,7 +164,10 @@ function prefixPattern(basePath: string) { }; } -export async function buildOpenNextOutput(options: BuildOptions): Promise { +export async function buildOpenNextOutput( + options: BuildOptions, + { skipCache = false }: { skipCache?: boolean } = {} +): Promise { const { appBuildOutputPath, config } = options; const edgeFunctions: OpenNextOutput["edgeFunctions"] = {}; const isExternalMiddleware = config.middleware?.external ?? false; @@ -345,6 +349,13 @@ export async function buildOpenNextOutput(options: BuildOptions): Promise = { + imageOptimization: "imageOptimization", + revalidate: "revalidate", + warmer: "warmer", + middleware: "middleware", + initializationFunction: "initializationFunction", + cache: "cacheHandler", +}; type GenericHandler< Type extends HandlerType, @@ -31,7 +46,8 @@ export async function createGenericHandler< const config: OpenNextConfig = await import("./open-next.config.mjs").then((m) => m.default); globalThis.openNextConfig = config; - const handlerConfig = config[handler.type]; + const configKey = handlerTypeToConfigKey[handler.type]; + const handlerConfig = config[configKey]; const override = handlerConfig && "override" in handlerConfig ? (handlerConfig.override as DefaultOverrideOptions) diff --git a/packages/core/src/core/createMainHandler.ts b/packages/core/src/core/createMainHandler.ts index 480aa21b..eb6129b3 100644 --- a/packages/core/src/core/createMainHandler.ts +++ b/packages/core/src/core/createMainHandler.ts @@ -6,6 +6,7 @@ import { generateUniqueId } from "../adapters/util"; import { openNextHandler } from "./requestHandler"; import { resolveAssetResolver, + resolveCache, resolveCdnInvalidation, resolveConverter, resolveIncrementalCache, @@ -41,6 +42,8 @@ export async function createMainHandler() { ); } + globalThis.cache = await resolveCache(thisFunction.override?.cache); + globalThis.proxyExternalRequest = await resolveProxyRequest(thisFunction.override?.proxyExternalRequest); globalThis.cdnInvalidationHandler = await resolveCdnInvalidation(thisFunction.override?.cdnInvalidation); diff --git a/packages/core/src/core/resolve.ts b/packages/core/src/core/resolve.ts index 8bb13557..2bea8501 100644 --- a/packages/core/src/core/resolve.ts +++ b/packages/core/src/core/resolve.ts @@ -7,7 +7,7 @@ import type { OpenNextConfig, OverrideOptions, } from "@/types/open-next"; -import type { Converter, TagCache, Wrapper } from "@/types/overrides"; +import type { Cache, Converter, TagCache, Wrapper } from "@/types/overrides"; // Just a little utility type to remove undefined from a type type RemoveUndefined = T extends undefined ? never : T; @@ -157,3 +157,14 @@ export async function resolveCdnInvalidation(cdnInvalidation: OverrideOptions["c const m_1 = await import("../overrides/cdnInvalidation/dummy.js"); return m_1.default; } + +/** + * @__PURE__ + */ +export async function resolveCache(cache: OverrideOptions["cache"]): Promise { + if (typeof cache === "function") { + return cache(); + } + const m_1 = await import("../overrides/cache/dummy.js"); + return m_1.default; +} diff --git a/packages/core/src/overrides/cache/dummy.ts b/packages/core/src/overrides/cache/dummy.ts new file mode 100644 index 00000000..f48cb92b --- /dev/null +++ b/packages/core/src/overrides/cache/dummy.ts @@ -0,0 +1,20 @@ +import type { Cache } from "@/types/overrides"; +import { IgnorableError } from "@/utils/error"; + +const dummyCache: Cache = { + name: "dummy", + get: async () => { + throw new IgnorableError('"Dummy" cache does not cache anything'); + }, + set: async () => { + throw new IgnorableError('"Dummy" cache does not cache anything'); + }, + delete: async () => { + throw new IgnorableError('"Dummy" cache does not cache anything'); + }, + revalidateTags: async () => { + throw new IgnorableError('"Dummy" cache does not cache anything'); + }, +}; + +export default dummyCache; diff --git a/packages/core/src/overrides/cache/fetch.ts b/packages/core/src/overrides/cache/fetch.ts new file mode 100644 index 00000000..dee2ec18 --- /dev/null +++ b/packages/core/src/overrides/cache/fetch.ts @@ -0,0 +1,46 @@ +import type { Cache } from "@/types/overrides"; +import { parseCacheGetResponse } from "@/utils/cache-get"; + +const CACHE_URL = process.env.OPEN_NEXT_CACHE_URL ?? ""; + +const fetchCache: Cache = { + name: "fetch-cache", + get: async (key, cacheType) => { + const query: Record = {}; + if (cacheType) query.type = cacheType; + const queryString = Object.keys(query).length > 0 ? `?${new URLSearchParams(query).toString()}` : ""; + const url = `${CACHE_URL}/cache/${encodeURIComponent(key)}${queryString}`; + const response = await fetch(url, { method: "GET" }); + const bodyText = await response.text(); + const headers: Record = {}; + response.headers.forEach((v, k) => { + headers[k] = v; + }); + // oxlint-disable-next-line @typescript-eslint/no-explicit-any + return parseCacheGetResponse(headers, bodyText) as any; + }, + set: async (key, value, cacheType) => { + // The cache type has to be forwarded: incremental caches may key entries on it, + // writing without it would store the entry where `get` does not look for it. + const queryString = cacheType ? `?type=${cacheType}` : ""; + const url = `${CACHE_URL}/cache/${encodeURIComponent(key)}${queryString}`; + await fetch(url, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ value }), + }); + }, + delete: async (key) => { + const url = `${CACHE_URL}/cache/${encodeURIComponent(key)}`; + await fetch(url, { method: "DELETE" }); + }, + revalidateTags: async (tags) => { + await fetch(`${CACHE_URL}/cache/revalidate-tags`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ tags }), + }); + }, +}; + +export default fetchCache; diff --git a/packages/core/src/overrides/cache/local.ts b/packages/core/src/overrides/cache/local.ts new file mode 100644 index 00000000..d0e063ae --- /dev/null +++ b/packages/core/src/overrides/cache/local.ts @@ -0,0 +1,96 @@ +import path from "node:path"; + +import type { InternalEvent, InternalResult } from "@/types/open-next"; +import type { Cache } from "@/types/overrides"; +import { parseCacheGetResponse } from "@/utils/cache-get"; +import { getMonorepoRelativePath } from "@/utils/normalize-path"; +import { fromReadableStream } from "@/utils/stream"; + +let handler: ((event: InternalEvent) => Promise) | null = null; + +async function getHandler() { + if (!handler) { + const cacheHandlerPath = path.join(getMonorepoRelativePath(), "cache-function/index.mjs"); + const m = await import(cacheHandlerPath); + handler = m.handler; + } + return handler; +} + +const localCache: Cache = { + name: "local-cache", + get: async (key, cacheType) => { + const h = (await getHandler())!; + const encodedKey = encodeURIComponent(key); + const url = `https://on/cache/${encodedKey}`; + const event: InternalEvent = { + type: "core", + method: "GET", + rawPath: `/cache/${encodedKey}`, + url, + headers: {}, + query: cacheType ? { type: cacheType } : {}, + cookies: {}, + remoteAddress: "127.0.0.1", + }; + const result = await h(event); + const bodyText = await fromReadableStream(result.body); + // oxlint-disable-next-line @typescript-eslint/no-explicit-any + return parseCacheGetResponse(result.headers, bodyText) as any; + }, + set: async (key, value, cacheType) => { + const h = (await getHandler())!; + const encodedKey = encodeURIComponent(key); + const url = `https://on/cache/${encodedKey}`; + // The cache type has to be forwarded: incremental caches may key entries on it, + // writing without it would store the entry where `get` does not look for it. + const query: Record = {}; + if (cacheType) query.type = cacheType; + const event: InternalEvent = { + type: "core", + method: "PUT", + rawPath: `/cache/${encodedKey}`, + url, + headers: { "Content-Type": "application/json" }, + query, + cookies: {}, + remoteAddress: "127.0.0.1", + body: Buffer.from(JSON.stringify({ value })), + }; + await h(event); + }, + delete: async (key) => { + const h = (await getHandler())!; + const encodedKey = encodeURIComponent(key); + const url = `https://on/cache/${encodedKey}`; + const event: InternalEvent = { + type: "core", + method: "DELETE", + rawPath: `/cache/${encodedKey}`, + url, + headers: {}, + query: {}, + cookies: {}, + remoteAddress: "127.0.0.1", + }; + await h(event); + }, + revalidateTags: async (tags) => { + const h = (await getHandler())!; + const url = `https://on/cache/revalidate-tags`; + const event: InternalEvent = { + type: "core", + method: "POST", + rawPath: `/cache/revalidate-tags`, + url, + headers: { "Content-Type": "application/json" }, + query: {}, + cookies: {}, + remoteAddress: "127.0.0.1", + body: Buffer.from(JSON.stringify({ tags })), + }; + await h(event); + }, +}; + +export default localCache; diff --git a/packages/core/src/plugins/resolve.ts b/packages/core/src/plugins/resolve.ts index 65d0a3d0..87ed3f61 100644 --- a/packages/core/src/plugins/resolve.ts +++ b/packages/core/src/plugins/resolve.ts @@ -32,6 +32,7 @@ export interface IPluginSettings { warmer?: LazyLoadedOverride | IncludedWarmer; proxyExternalRequest?: OverrideOptions["proxyExternalRequest"]; cdnInvalidation?: OverrideOptions["cdnInvalidation"]; + cache?: OverrideOptions["cache"]; }; defaultOverrides?: DefaultOverrides; fnName?: string; @@ -49,6 +50,7 @@ const nameToFolder = { warmer: "warmer", proxyExternalRequest: "proxyExternalRequest", cdnInvalidation: "cdnInvalidation", + cache: "cache", }; export type OverrideKey = keyof typeof nameToFolder; @@ -72,6 +74,7 @@ export type BundleType = | "imageOptimization" | "revalidation" | "warmer" + | "cache" | "tagCache"; export type BundleDefaults = Partial>; diff --git a/packages/core/src/types/global.ts b/packages/core/src/types/global.ts index 289fe144..65e03701 100644 --- a/packages/core/src/types/global.ts +++ b/packages/core/src/types/global.ts @@ -4,6 +4,7 @@ import type { OutgoingHttpHeaders } from "node:http"; import type { AssetResolver, CDNInvalidationHandler, + Cache, IncrementalCache, ProxyExternalRequest, Queue, @@ -195,6 +196,13 @@ declare global { */ var openNextVersion: string; + /** + * The cache client used to communicate with the cache handler function. + * Only available in main functions. + * Defined in `createMainHandler`. + */ + var cache: Cache; + /** * The function that is used when resolving external rewrite requests. * Only available in main functions diff --git a/packages/core/src/types/open-next.ts b/packages/core/src/types/open-next.ts index 8216e491..46e472f1 100644 --- a/packages/core/src/types/open-next.ts +++ b/packages/core/src/types/open-next.ts @@ -6,6 +6,7 @@ import type { WarmerEvent, WarmerResponse } from "../adapters/warmer-function"; import type { AssetResolver, CDNInvalidationHandler, + Cache, Converter, ImageLoader, IncrementalCache, @@ -221,6 +222,8 @@ export type IncludedOriginResolver = "pattern-env" | "dummy"; export type IncludedWarmer = "aws-lambda" | "dummy"; +export type IncludedCache = "fetch" | "local" | "dummy"; + export type IncludedProxyExternalRequest = "node" | "fetch" | "dummy"; export type IncludedCDNInvalidationHandler = "cloudfront" | "dummy"; @@ -280,6 +283,13 @@ export interface OverrideOptions extends DefaultOverrideOptions { * @default "dummy" */ cdnInvalidation?: IncludedCDNInvalidationHandler | LazyLoadedOverride; + + /** + * Add possibility to override the default cache client used to communicate with the cache handler function. + * Can be used to connect to a remote cache handler via HTTP fetch or to use a direct in-process cache. + * @default undefined - Falls back to using the incremental cache directly. + */ + cache?: IncludedCache | LazyLoadedOverride; } export interface InstallOptions { @@ -482,6 +492,29 @@ export interface OpenNextConfig { tagCache?: IncludedTagCache | LazyLoadedOverride; }; + /** + * Override the default cache handler function. + * By default, works on lambda. + * Supports only node runtime + */ + cacheHandler?: DefaultFunctionOptions & { + /** + * Override the default incremental cache. + * @default "s3" + */ + incrementalCache?: IncludedIncrementalCache | LazyLoadedOverride; + /** + * Override the default tag cache. + * @default "dynamodb" + */ + tagCache?: IncludedTagCache | LazyLoadedOverride; + /** + * Override the default cdn invalidation handler for on demand revalidation. + * @default "dummy" + */ + cdnInvalidation?: IncludedCDNInvalidationHandler | LazyLoadedOverride; + }; + /** * Dangerous options. This break some functionnality but can be useful in some cases. */ diff --git a/packages/core/src/types/overrides.ts b/packages/core/src/types/overrides.ts index 84589959..9c4f980c 100644 --- a/packages/core/src/types/overrides.ts +++ b/packages/core/src/types/overrides.ts @@ -253,6 +253,20 @@ export type ProxyExternalRequest = BaseOverride & { proxy: (event: InternalEvent) => Promise; }; +export type Cache = BaseOverride & { + get( + key: string, + cacheType?: CacheType + ): Promise> | null>; + set( + key: string, + value: CacheValue, + isFetch?: CacheType + ): Promise; + delete(key: string): Promise; + revalidateTags(tags: string[]): Promise; +}; + type CDNPath = { initialPath: string; rawPath: string; diff --git a/packages/core/src/utils/cache-get.ts b/packages/core/src/utils/cache-get.ts new file mode 100644 index 00000000..572a9a42 --- /dev/null +++ b/packages/core/src/utils/cache-get.ts @@ -0,0 +1,184 @@ +import type { StoredComposableCacheEntry } from "@/types/cache"; +import type { CachedFile, CachedFetchValue, WithLastModified } from "@/types/overrides"; + +type HeadersMap = Record; + +type Base = { + lastModified?: number; + shouldBypassTagCache?: boolean; +}; + +function getHeaderValue(headers: HeadersMap, name: string): string | undefined { + const v = headers[name]; + if (typeof v === "string") return v; + if (Array.isArray(v) && v.length > 0) return v[0]; + return undefined; +} + +function getHeaderNumber(headers: HeadersMap, name: string): number | undefined { + const v = getHeaderValue(headers, name); + if (v === undefined) return undefined; + const n = Number(v); + return Number.isNaN(n) ? undefined : n; +} + +function collectPrefixedHeaders(headers: HeadersMap, prefix: string): Record { + const result: Record = {}; + for (const [key, value] of Object.entries(headers)) { + if (key.startsWith(prefix)) { + const originalName = key.slice(prefix.length); + result[originalName] = value; + } + } + return result; +} + +export function parseCacheGetResponse( + headers: HeadersMap, + bodyText: string +): WithLastModified< + | (CachedFile & { revalidate?: number | false }) + | (CachedFetchValue & { revalidate?: number | false }) + | (StoredComposableCacheEntry & { revalidate?: number | false }) +> | null { + const found = getHeaderValue(headers, "x-opennext-cache-found"); + if (found !== "true") return null; + + const cacheType = getHeaderValue(headers, "x-opennext-cache-type"); + const lastModified = getHeaderNumber(headers, "x-opennext-cache-last-modified"); + const shouldBypass = getHeaderValue(headers, "x-opennext-cache-should-bypass") === "true"; + + const base: Base = { + ...(lastModified !== undefined ? { lastModified } : {}), + ...(shouldBypass ? { shouldBypassTagCache: true as const } : {}), + }; + + if (cacheType === "composable") { + return reconstructComposable(headers, bodyText, base); + } + + if (cacheType === "fetch") { + return reconstructFetch(headers, bodyText, base); + } + + return reconstructCachedFile(headers, bodyText, base); +} + +function reconstructComposable(headers: HeadersMap, bodyText: string, base: Base) { + const stale = getHeaderNumber(headers, "x-opennext-cache-composable-stale"); + const expire = getHeaderNumber(headers, "x-opennext-cache-composable-expire"); + const timestamp = getHeaderNumber(headers, "x-opennext-cache-composable-timestamp"); + const revalidate = getHeaderNumber(headers, "x-opennext-cache-composable-revalidate"); + const tagsStr = getHeaderValue(headers, "x-opennext-cache-composable-tags"); + const tags = tagsStr ? JSON.parse(tagsStr) : []; + + if (stale === undefined || expire === undefined || timestamp === undefined || revalidate === undefined) { + return null; + } + + return { + value: { + value: bodyText, + tags, + stale, + expire, + timestamp, + revalidate, + } satisfies StoredComposableCacheEntry, + ...base, + }; +} + +function reconstructFetch(headers: HeadersMap, bodyText: string, base: Base) { + const kind = getHeaderValue(headers, "x-opennext-cache-fetch-kind"); + if (kind !== "FETCH") return null; + + const url = getHeaderValue(headers, "x-opennext-cache-fetch-data-url") ?? ""; + const status = getHeaderNumber(headers, "x-opennext-cache-fetch-data-status"); + const dataTagsStr = getHeaderValue(headers, "x-opennext-cache-fetch-data-tags"); + const dataTags = dataTagsStr ? JSON.parse(dataTagsStr) : undefined; + const fetchTagsStr = getHeaderValue(headers, "x-opennext-cache-fetch-tags"); + const fetchTags = fetchTagsStr ? JSON.parse(fetchTagsStr) : undefined; + const revalidate = getHeaderNumber(headers, "x-opennext-cache-revalidate"); + + const dataHeaders = collectPrefixedHeaders(headers, "x-opennext-cache-header-") as Record; + + const value: CachedFetchValue & { revalidate?: number | false } = { + kind: "FETCH", + data: { + headers: dataHeaders, + body: bodyText, + url, + ...(status !== undefined ? { status } : {}), + ...(dataTags !== undefined ? { tags: dataTags } : {}), + }, + ...(fetchTags !== undefined ? { tags: fetchTags } : {}), + ...(revalidate !== undefined ? { revalidate } : {}), + }; + + return { value, ...base }; +} + +function reconstructCachedFile(headers: HeadersMap, bodyText: string, base: Base) { + const subType = getHeaderValue(headers, "x-opennext-cache-sub-type"); + const metaStatus = getHeaderNumber(headers, "x-opennext-cache-meta-status"); + const metaPostponed = getHeaderValue(headers, "x-opennext-cache-meta-postponed"); + const revalidate = getHeaderNumber(headers, "x-opennext-cache-revalidate"); + + const metaHeaders = collectPrefixedHeaders(headers, "x-opennext-cache-header-"); + const hasMetaHeaders = Object.keys(metaHeaders).length > 0; + + const meta: Record = {}; + if (metaStatus !== undefined) meta.status = metaStatus; + if (metaPostponed !== undefined) meta.postponed = metaPostponed; + if (hasMetaHeaders) meta.headers = metaHeaders; + + const hasMeta = metaStatus !== undefined || metaPostponed !== undefined || hasMetaHeaders; + + // oxlint-disable-next-line @typescript-eslint/no-explicit-any + const extra: Record = {}; + if (hasMeta) extra.meta = meta; + if (revalidate !== undefined) extra.revalidate = revalidate; + + switch (subType) { + case "route": { + return { + value: { type: "route", body: bodyText, ...extra } satisfies CachedFile, + ...base, + }; + } + case "page": { + const parsed = JSON.parse(bodyText) as { json: object; html: string }; + return { + value: { type: "page", html: parsed.html, json: parsed.json, ...extra } satisfies CachedFile, + ...base, + }; + } + case "app": { + const parsed = JSON.parse(bodyText) as { + html: string; + rsc: string; + segmentData?: Record; + }; + return { + value: { + type: "app", + html: parsed.html, + rsc: parsed.rsc, + ...(parsed.segmentData ? { segmentData: parsed.segmentData } : {}), + ...extra, + } satisfies CachedFile, + ...base, + }; + } + case "redirect": { + const props = JSON.parse(bodyText); + return { + value: { type: "redirect", props, ...extra } satisfies CachedFile, + ...base, + }; + } + default: + return null; + } +} diff --git a/packages/tests-unit/tests/overrides/cache/fetch.test.ts b/packages/tests-unit/tests/overrides/cache/fetch.test.ts new file mode 100644 index 00000000..0a18ce8b --- /dev/null +++ b/packages/tests-unit/tests/overrides/cache/fetch.test.ts @@ -0,0 +1,199 @@ +import fetchCache from "@opennextjs/core/overrides/cache/fetch"; +import { vi, describe, expect, it, beforeEach, afterEach } from "vitest"; + +// Helper: convert a plain headers object to a Map (mimics Headers API) +function toHeadersMap(headers: Record): Map { + const map = new Map(); + for (const [key, value] of Object.entries(headers)) { + map.set(key, value); + } + return map; +} + +function mockFetch(resp: { headers: Record; body: string; status?: number }) { + const response = { + ok: true, + status: resp.status ?? 200, + text: vi.fn().mockResolvedValue(resp.body), + headers: toHeadersMap(resp.headers), + }; + global.fetch = vi.fn().mockResolvedValue(response); +} + +describe("fetch cache", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("should have name 'fetch-cache'", () => { + expect(fetchCache.name).toBe("fetch-cache"); + }); + + describe("get", () => { + it("should make a GET request to the correct URL", async () => { + mockFetch({ headers: {} as Record, body: "" }); + + await fetchCache.get("my-key"); + + expect(global.fetch).toHaveBeenCalledWith("/cache/my-key", { method: "GET" }); + }); + + it("should encode the key in the URL", async () => { + mockFetch({ headers: {} as Record, body: "" }); + + await fetchCache.get("special/key"); + + expect(global.fetch).toHaveBeenCalledWith("/cache/special%2Fkey", { method: "GET" }); + }); + + it("should add type query param when cacheType is provided", async () => { + mockFetch({ headers: {} as Record, body: "" }); + + await fetchCache.get("key", "fetch"); + + expect(global.fetch).toHaveBeenCalledWith("/cache/key?type=fetch", { method: "GET" }); + }); + + it("should return null when x-opennext-cache-found is not true", async () => { + // No x-opennext-cache-found header → parseCacheGetResponse returns null + mockFetch({ headers: { "content-type": "text/plain" }, body: "" }); + + const result = await fetchCache.get("key"); + + expect(result).toBeNull(); + }); + + it("should return null for cache miss (found = false)", async () => { + mockFetch({ + headers: { + "x-opennext-cache-found": "false", + "x-opennext-cache-type": "cache", + "Cache-Control": "no-store", + }, + body: "", + }); + + const result = await fetchCache.get("key"); + + expect(result).toBeNull(); + }); + + it("should reconstruct a route cache entry from the response", async () => { + mockFetch({ + headers: { + "x-opennext-cache-found": "true", + "x-opennext-cache-type": "cache", + "x-opennext-cache-sub-type": "route", + "x-opennext-cache-last-modified": "1000", + "Cache-Control": "no-store", + }, + body: "route-body-content", + }); + + const result = await fetchCache.get("key"); + + expect(result).not.toBeNull(); + expect(result!.value).toEqual({ + type: "route", + body: "route-body-content", + }); + expect(result!.lastModified).toBe(1000); + }); + + it("should reconstruct a fetch cache entry from the response", async () => { + mockFetch({ + headers: { + "x-opennext-cache-found": "true", + "x-opennext-cache-type": "fetch", + "x-opennext-cache-fetch-kind": "FETCH", + "x-opennext-cache-fetch-data-url": "https://example.com", + "x-opennext-cache-fetch-data-status": "200", + "Cache-Control": "no-store", + }, + body: '{"data":"value"}', + }); + + const result = await fetchCache.get("key"); + + expect(result).not.toBeNull(); + expect(result!.value).toMatchObject({ + kind: "FETCH", + data: { + url: "https://example.com", + status: 200, + body: '{"data":"value"}', + }, + }); + }); + + it("should pass response headers (as plain object) and body text to parseCacheGetResponse", async () => { + mockFetch({ + headers: { + "x-opennext-cache-found": "true", + "x-opennext-cache-type": "cache", + "x-opennext-cache-sub-type": "route", + "Content-Type": "text/plain", + }, + body: "hello", + }); + + const result = await fetchCache.get("key"); + + // parseCacheGetResponse receives the headers as a plain Record + // and the body text, then returns the parsed result + expect(result).not.toBeNull(); + expect(result!.value).toMatchObject({ type: "route", body: "hello" }); + }); + }); + + describe("set", () => { + it("should make a PUT request with JSON body", async () => { + const value = { type: "route", body: "content" }; + await fetchCache.set("key", value); + + expect(global.fetch).toHaveBeenCalledWith("/cache/key", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ value }), + }); + }); + + it("should encode the key in the URL", async () => { + await fetchCache.set("special/key", {}); + + expect(global.fetch).toHaveBeenCalledWith("/cache/special%2Fkey", expect.any(Object)); + }); + + // Incremental caches may key entries on the cache type, writing without it would store + // the entry where `get` does not look for it. + it("should forward the cache type", async () => { + await fetchCache.set("key", {}, "composable"); + + expect(global.fetch).toHaveBeenCalledWith("/cache/key?type=composable", expect.any(Object)); + }); + }); + + describe("delete", () => { + it("should make a DELETE request", async () => { + await fetchCache.delete("key"); + + expect(global.fetch).toHaveBeenCalledWith("/cache/key", { method: "DELETE" }); + }); + }); + + describe("revalidateTags", () => { + it("should make a POST request with tags body", async () => { + await fetchCache.revalidateTags(["tag1", "tag2"]); + + expect(global.fetch).toHaveBeenCalledWith("/cache/revalidate-tags", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ tags: ["tag1", "tag2"] }), + }); + }); + }); +}); diff --git a/packages/tests-unit/tests/overrides/cache/local.test.ts b/packages/tests-unit/tests/overrides/cache/local.test.ts new file mode 100644 index 00000000..9bc5b23a --- /dev/null +++ b/packages/tests-unit/tests/overrides/cache/local.test.ts @@ -0,0 +1,220 @@ +import localCache from "@opennextjs/core/overrides/cache/local"; +import type { InternalResult } from "@opennextjs/core/types/open-next"; +import { toReadableStream } from "@opennextjs/core/utils/stream"; +import { vi, describe, expect, it, beforeEach } from "vitest"; + +vi.mock("@opennextjs/core/utils/normalize-path", () => ({ + getMonorepoRelativePath: vi.fn().mockReturnValue("/mock/root"), +})); + +const mockHandler = vi.fn(); + +vi.mock("/mock/root/cache-function/index.mjs", () => ({ + handler: mockHandler, +})); + +function createMockResult(overrides: Partial & { bodyText?: string } = {}): InternalResult { + const { bodyText, ...rest } = overrides; + return { + type: "core", + statusCode: 200, + body: toReadableStream(bodyText ?? ""), + isBase64Encoded: false, + headers: {}, + ...rest, + }; +} + +describe("local cache", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should have name 'local-cache'", () => { + expect(localCache.name).toBe("local-cache"); + }); + + describe("get", () => { + it("should construct a GET InternalEvent with the correct rawPath", async () => { + mockHandler.mockResolvedValue( + createMockResult({ bodyText: "", headers: {} as Record }) + ); + + await localCache.get("my-key"); + + const event = mockHandler.mock.calls[0][0]; + expect(event.method).toBe("GET"); + expect(event.rawPath).toBe("/cache/my-key"); + expect(event.url).toBe("https://on/cache/my-key"); + }); + + it("should encode the key in rawPath and url", async () => { + mockHandler.mockResolvedValue( + createMockResult({ bodyText: "", headers: {} as Record }) + ); + + await localCache.get("special/key"); + + const event = mockHandler.mock.calls[0][0]; + expect(event.rawPath).toBe("/cache/special%2Fkey"); + expect(event.url).toBe("https://on/cache/special%2Fkey"); + }); + + it("should add type query param when cacheType is provided", async () => { + mockHandler.mockResolvedValue( + createMockResult({ bodyText: "", headers: {} as Record }) + ); + + await localCache.get("key", "fetch"); + + const event = mockHandler.mock.calls[0][0]; + expect(event.query).toEqual({ type: "fetch" }); + }); + + it("should return null when x-opennext-cache-found is missing", async () => { + mockHandler.mockResolvedValue( + createMockResult({ bodyText: "", headers: { "content-type": "text/plain" } }) + ); + + const result = await localCache.get("key"); + + expect(result).toBeNull(); + }); + + it("should return null for cache miss (found = false)", async () => { + mockHandler.mockResolvedValue( + createMockResult({ + bodyText: "", + headers: { "x-opennext-cache-found": "false", "Cache-Control": "no-store" }, + }) + ); + + const result = await localCache.get("key"); + + expect(result).toBeNull(); + }); + + it("should reconstruct a route cache entry from handler result", async () => { + mockHandler.mockResolvedValue( + createMockResult({ + bodyText: "route-body", + headers: { + "x-opennext-cache-found": "true", + "x-opennext-cache-type": "cache", + "x-opennext-cache-sub-type": "route", + "x-opennext-cache-last-modified": "1000", + "Cache-Control": "no-store", + "Content-Type": "text/plain", + }, + }) + ); + + const result = await localCache.get("key"); + + expect(result).not.toBeNull(); + expect(result!.value).toEqual({ type: "route", body: "route-body" }); + expect(result!.lastModified).toBe(1000); + }); + + it("should reconstruct a fetch cache entry from handler result", async () => { + mockHandler.mockResolvedValue( + createMockResult({ + bodyText: '{"data":"value"}', + headers: { + "x-opennext-cache-found": "true", + "x-opennext-cache-type": "fetch", + "x-opennext-cache-fetch-kind": "FETCH", + "x-opennext-cache-fetch-data-url": "https://example.com", + "x-opennext-cache-fetch-data-status": "200", + "Cache-Control": "no-store", + }, + }) + ); + + const result = await localCache.get("key"); + + expect(result).not.toBeNull(); + expect(result!.value).toMatchObject({ + kind: "FETCH", + data: { + url: "https://example.com", + status: 200, + body: '{"data":"value"}', + }, + }); + }); + }); + + describe("set", () => { + it("should construct a PUT InternalEvent with JSON body", async () => { + mockHandler.mockResolvedValue(createMockResult()); + const value = { type: "route", body: "content" }; + + await localCache.set("key", value); + + const event = mockHandler.mock.calls[0][0]; + expect(event.method).toBe("PUT"); + expect(event.rawPath).toBe("/cache/key"); + expect(event.headers).toEqual({ "Content-Type": "application/json" }); + expect(event.body).toEqual(Buffer.from(JSON.stringify({ value }))); + }); + + it("should encode the key", async () => { + mockHandler.mockResolvedValue(createMockResult()); + + await localCache.set("special/key", {}); + + const event = mockHandler.mock.calls[0][0]; + expect(event.rawPath).toBe("/cache/special%2Fkey"); + }); + + // Incremental caches may key entries on the cache type, writing without it would store + // the entry where `get` does not look for it. + it("should forward the cache type", async () => { + mockHandler.mockResolvedValue(createMockResult()); + + await localCache.set("key", {}, "composable"); + + const event = mockHandler.mock.calls[0][0]; + expect(event.query).toEqual({ type: "composable" }); + }); + }); + + describe("delete", () => { + it("should construct a DELETE InternalEvent", async () => { + mockHandler.mockResolvedValue(createMockResult()); + + await localCache.delete("key"); + + const event = mockHandler.mock.calls[0][0]; + expect(event.method).toBe("DELETE"); + expect(event.rawPath).toBe("/cache/key"); + }); + }); + + describe("revalidateTags", () => { + it("should construct a POST InternalEvent with tags body", async () => { + mockHandler.mockResolvedValue(createMockResult()); + + await localCache.revalidateTags(["tag1", "tag2"]); + + const event = mockHandler.mock.calls[0][0]; + expect(event.method).toBe("POST"); + expect(event.rawPath).toBe("/cache/revalidate-tags"); + expect(event.body).toEqual(Buffer.from(JSON.stringify({ tags: ["tag1", "tag2"] }))); + }); + }); + + describe("handler caching", () => { + it("should reuse the handler across multiple calls", async () => { + mockHandler.mockResolvedValue( + createMockResult({ bodyText: "", headers: {} as Record }) + ); + + await localCache.get("key1"); + await localCache.get("key2"); + + expect(mockHandler).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/packages/tests-unit/tests/utils/cache-get.test.ts b/packages/tests-unit/tests/utils/cache-get.test.ts new file mode 100644 index 00000000..d15c65f2 --- /dev/null +++ b/packages/tests-unit/tests/utils/cache-get.test.ts @@ -0,0 +1,359 @@ +import { parseCacheGetResponse } from "@opennextjs/core/utils/cache-get"; +import { describe, expect, it } from "vitest"; + +describe("parseCacheGetResponse", () => { + it("should return null when x-opennext-cache-found is not 'true'", () => { + const result = parseCacheGetResponse({ "x-opennext-cache-type": "cache" }, "body"); + expect(result).toBeNull(); + }); + + it("should return null when x-opennext-cache-found is 'false'", () => { + const result = parseCacheGetResponse( + { "x-opennext-cache-found": "false", "x-opennext-cache-type": "cache" }, + "body" + ); + expect(result).toBeNull(); + }); + + describe("composable", () => { + it("should reconstruct a composable cache entry", () => { + const headers = { + "x-opennext-cache-found": "true", + "x-opennext-cache-type": "composable", + "x-opennext-cache-composable-stale": "100", + "x-opennext-cache-composable-expire": "200", + "x-opennext-cache-composable-timestamp": "300", + "x-opennext-cache-composable-revalidate": "400", + "x-opennext-cache-composable-tags": '["tag1","tag2"]', + }; + const result = parseCacheGetResponse(headers, "test-value"); + + expect(result).not.toBeNull(); + expect(result!.value).toEqual({ + value: "test-value", + tags: ["tag1", "tag2"], + stale: 100, + expire: 200, + timestamp: 300, + revalidate: 400, + }); + }); + + it("should return null when required composable fields are missing", () => { + const headers = { + "x-opennext-cache-found": "true", + "x-opennext-cache-type": "composable", + "x-opennext-cache-composable-stale": "100", + }; + const result = parseCacheGetResponse(headers, "test-value"); + + expect(result).toBeNull(); + }); + + it("should handle empty tags array in composable entry", () => { + const headers = { + "x-opennext-cache-found": "true", + "x-opennext-cache-type": "composable", + "x-opennext-cache-composable-stale": "100", + "x-opennext-cache-composable-expire": "200", + "x-opennext-cache-composable-timestamp": "300", + "x-opennext-cache-composable-revalidate": "400", + }; + const result = parseCacheGetResponse(headers, "test-value"); + + expect(result).not.toBeNull(); + expect(result!.value).toEqual({ + value: "test-value", + tags: [], + stale: 100, + expire: 200, + timestamp: 300, + revalidate: 400, + }); + }); + }); + + describe("fetch", () => { + it("should reconstruct a fetch cache entry", () => { + const headers = { + "x-opennext-cache-found": "true", + "x-opennext-cache-type": "fetch", + "x-opennext-cache-fetch-kind": "FETCH", + "x-opennext-cache-fetch-data-url": "https://example.com", + "x-opennext-cache-fetch-data-status": "200", + "x-opennext-cache-fetch-data-tags": '["tag1"]', + "x-opennext-cache-fetch-tags": '["tag2"]', + "x-opennext-cache-revalidate": "60", + "x-opennext-cache-header-content-type": "application/json", + }; + const result = parseCacheGetResponse(headers, '{"data":"value"}'); + + expect(result).not.toBeNull(); + expect(result!.value).toEqual({ + kind: "FETCH", + data: { + headers: { "content-type": "application/json" }, + body: '{"data":"value"}', + url: "https://example.com", + status: 200, + tags: ["tag1"], + }, + tags: ["tag2"], + revalidate: 60, + }); + }); + + it("should return null when fetch kind is not FETCH", () => { + const headers = { + "x-opennext-cache-found": "true", + "x-opennext-cache-type": "fetch", + "x-opennext-cache-fetch-kind": "OTHER", + }; + const result = parseCacheGetResponse(headers, "body"); + + expect(result).toBeNull(); + }); + + it("should handle fetch entry without optional fields", () => { + const headers = { + "x-opennext-cache-found": "true", + "x-opennext-cache-type": "fetch", + "x-opennext-cache-fetch-kind": "FETCH", + }; + const result = parseCacheGetResponse(headers, "body"); + + expect(result).not.toBeNull(); + expect(result!.value).toEqual({ + kind: "FETCH", + data: { + headers: {}, + body: "body", + url: "", + }, + }); + }); + + it("should collect prefixed headers", () => { + const headers = { + "x-opennext-cache-found": "true", + "x-opennext-cache-type": "fetch", + "x-opennext-cache-fetch-kind": "FETCH", + "x-opennext-cache-header-content-type": "text/plain", + "x-opennext-cache-header-x-custom": "custom-value", + }; + const result = parseCacheGetResponse(headers, "body"); + + expect(result).not.toBeNull(); + const value = result!.value as { data: { headers: Record } }; + expect(value.data.headers).toEqual({ + "content-type": "text/plain", + "x-custom": "custom-value", + }); + }); + + it("should handle array-valued headers", () => { + const headers: Record = { + "x-opennext-cache-found": "true", + "x-opennext-cache-type": "fetch", + "x-opennext-cache-fetch-kind": "FETCH", + "x-opennext-cache-header-set-cookie": ["cookie1", "cookie2"], + }; + const result = parseCacheGetResponse(headers, "body"); + + expect(result).not.toBeNull(); + const value = result!.value as { data: { headers: Record } }; + expect(value.data.headers["set-cookie"]).toEqual(["cookie1", "cookie2"]); + }); + }); + + describe("cached file", () => { + it("should reconstruct a route cache entry", () => { + const headers = { + "x-opennext-cache-found": "true", + "x-opennext-cache-type": "cache", + "x-opennext-cache-sub-type": "route", + "x-opennext-cache-meta-status": "200", + "x-opennext-cache-revalidate": "300", + }; + const result = parseCacheGetResponse(headers, "route body"); + + expect(result).not.toBeNull(); + expect(result!.value).toEqual({ + type: "route", + body: "route body", + meta: { status: 200 }, + revalidate: 300, + }); + }); + + it("should reconstruct a page cache entry", () => { + const headers = { + "x-opennext-cache-found": "true", + "x-opennext-cache-type": "cache", + "x-opennext-cache-sub-type": "page", + }; + const body = JSON.stringify({ html: "", json: { data: "value" } }); + const result = parseCacheGetResponse(headers, body); + + expect(result).not.toBeNull(); + expect(result!.value).toEqual({ + type: "page", + html: "", + json: { data: "value" }, + }); + }); + + it("should reconstruct an app cache entry", () => { + const headers = { + "x-opennext-cache-found": "true", + "x-opennext-cache-type": "cache", + "x-opennext-cache-sub-type": "app", + }; + const body = JSON.stringify({ + html: "", + rsc: "rsc-data", + segmentData: { seg1: "data1" }, + }); + const result = parseCacheGetResponse(headers, body); + + expect(result).not.toBeNull(); + expect(result!.value).toEqual({ + type: "app", + html: "", + rsc: "rsc-data", + segmentData: { seg1: "data1" }, + }); + }); + + it("should reconstruct an app cache entry without segmentData", () => { + const headers = { + "x-opennext-cache-found": "true", + "x-opennext-cache-type": "cache", + "x-opennext-cache-sub-type": "app", + }; + const body = JSON.stringify({ html: "", rsc: "rsc-data" }); + const result = parseCacheGetResponse(headers, body); + + expect(result).not.toBeNull(); + expect(result!.value).toEqual({ + type: "app", + html: "", + rsc: "rsc-data", + }); + }); + + it("should reconstruct a redirect cache entry", () => { + const headers = { + "x-opennext-cache-found": "true", + "x-opennext-cache-type": "cache", + "x-opennext-cache-sub-type": "redirect", + }; + const body = JSON.stringify({ destination: "/new-path" }); + const result = parseCacheGetResponse(headers, body); + + expect(result).not.toBeNull(); + expect(result!.value).toEqual({ + type: "redirect", + props: { destination: "/new-path" }, + }); + }); + + it("should return null for unknown sub-type", () => { + const headers = { + "x-opennext-cache-found": "true", + "x-opennext-cache-type": "cache", + "x-opennext-cache-sub-type": "unknown-type", + }; + const result = parseCacheGetResponse(headers, "body"); + + expect(result).toBeNull(); + }); + + it("should handle meta with postponed field", () => { + const headers = { + "x-opennext-cache-found": "true", + "x-opennext-cache-type": "cache", + "x-opennext-cache-sub-type": "route", + "x-opennext-cache-meta-postponed": "postponed-data", + "x-opennext-cache-header-content-type": "text/html", + }; + const result = parseCacheGetResponse(headers, "body"); + + expect(result).not.toBeNull(); + expect(result!.value).toEqual({ + type: "route", + body: "body", + meta: { + postponed: "postponed-data", + headers: { "content-type": "text/html" }, + }, + }); + }); + }); + + describe("base metadata", () => { + it("should include lastModified when x-opennext-cache-last-modified is present", () => { + const headers = { + "x-opennext-cache-found": "true", + "x-opennext-cache-type": "cache", + "x-opennext-cache-sub-type": "route", + "x-opennext-cache-last-modified": "1234567890", + }; + const result = parseCacheGetResponse(headers, "body"); + + expect(result).not.toBeNull(); + expect(result!.lastModified).toBe(1234567890); + }); + + it("should include shouldBypassTagCache when header is true", () => { + const headers = { + "x-opennext-cache-found": "true", + "x-opennext-cache-type": "cache", + "x-opennext-cache-sub-type": "route", + "x-opennext-cache-should-bypass": "true", + }; + const result = parseCacheGetResponse(headers, "body"); + + expect(result).not.toBeNull(); + expect(result!.shouldBypassTagCache).toBe(true); + }); + + it("should not include shouldBypassTagCache when header is not 'true'", () => { + const headers = { + "x-opennext-cache-found": "true", + "x-opennext-cache-type": "cache", + "x-opennext-cache-sub-type": "route", + "x-opennext-cache-should-bypass": "false", + }; + const result = parseCacheGetResponse(headers, "body"); + + expect(result).not.toBeNull(); + expect(result!.shouldBypassTagCache).toBeUndefined(); + }); + + it("should handle missing lastModified gracefully", () => { + const headers = { + "x-opennext-cache-found": "true", + "x-opennext-cache-type": "cache", + "x-opennext-cache-sub-type": "route", + }; + const result = parseCacheGetResponse(headers, "body"); + + expect(result).not.toBeNull(); + expect(result!.lastModified).toBeUndefined(); + }); + + it("should handle invalid lastModified number gracefully", () => { + const headers = { + "x-opennext-cache-found": "true", + "x-opennext-cache-type": "cache", + "x-opennext-cache-sub-type": "route", + "x-opennext-cache-last-modified": "not-a-number", + }; + const result = parseCacheGetResponse(headers, "body"); + + expect(result).not.toBeNull(); + expect(result!.lastModified).toBeUndefined(); + }); + }); +}); From c11ae26baa393f2344ad662025442c3d16d1d66b Mon Sep 17 00:00:00 2001 From: Victor Berchet Date: Fri, 18 Sep 2026 09:45:11 +0200 Subject: [PATCH 17/28] fix(core): support streamed cache request bodies Consume cache PUT and tag-revalidation payloads through the shared ReadableStream helpers, and emit streamed request bodies from the local cache transport. Handle bodyless cache responses as empty payloads instead of passing undefined to the stream reader. Preserve the cache bundle, output metadata, and resolver integration while reconciling PR #43 with the latest adapter APIs. Restore the cache bundle mock and the three-argument output assertion in adapter tests. Tests: pnpm --filter @opennextjs/core ts:check; focused core build tests (38); focused cache adapter and transport tests (53). --- packages/core/src/adapters/cache-adapter.ts | 37 ++++++++-- packages/core/src/build/adapter.spec.ts | 9 ++- packages/core/src/build/adapter.ts | 16 +++- packages/core/src/build/generateOutput.ts | 4 +- packages/core/src/overrides/cache/local.ts | 8 +- packages/core/src/plugins/resolve.ts | 1 + .../tests/adapters/cache-adapter.test.ts | 73 +++++++++++++++++++ .../tests/overrides/cache/local.test.ts | 14 +++- 8 files changed, 143 insertions(+), 19 deletions(-) create mode 100644 packages/tests-unit/tests/adapters/cache-adapter.test.ts diff --git a/packages/core/src/adapters/cache-adapter.ts b/packages/core/src/adapters/cache-adapter.ts index 662c98c4..ecb0d138 100644 --- a/packages/core/src/adapters/cache-adapter.ts +++ b/packages/core/src/adapters/cache-adapter.ts @@ -1,4 +1,5 @@ import { AsyncLocalStorage } from "node:async_hooks"; +import type { ReadableStream } from "node:stream/web"; import type { StoredComposableCacheEntry } from "@/types/cache"; import type { InternalEvent, InternalResult } from "@/types/open-next"; @@ -15,7 +16,7 @@ import { createGenericHandler } from "../core/createGenericHandler.js"; import { resolveCdnInvalidation, resolveIncrementalCache, resolveTagCache } from "../core/resolve.js"; import { writeTags } from "../utils/cache.js"; import { runWithOpenNextRequestContext } from "../utils/promise.js"; -import { toReadableStream } from "../utils/stream.js"; +import { fromReadableStream, toReadableStream } from "../utils/stream.js"; import { debug, error } from "./logger.js"; @@ -135,16 +136,30 @@ async function handleGet(key: string, cacheType: CacheEntryType): Promise { +/** + * Stores a cache entry from a streamed JSON request body. + * + * @param key Cache key to update. + * @param cacheType Type of cache entry being stored. + * @param body Stream containing the serialized cache value. + * @return The cache operation response. + * @throws When reading the request stream fails. + */ +async function handleSet( + key: string, + cacheType: CacheEntryType, + body?: ReadableStream +): Promise { debug("set", { key, cacheType }); + const bodyText = body ? await fromReadableStream(body) : ""; let payload: { value?: Record; } = {}; - if (body && body.length > 0) { + if (bodyText.length > 0) { try { - payload = JSON.parse(body.toString("utf-8")); + payload = JSON.parse(bodyText); } catch { return buildErrorResponse("Invalid JSON body", 400); } @@ -175,16 +190,24 @@ async function handleDelete(key: string): Promise { } } -async function handleRevalidateTags(body?: Buffer): Promise { +/** + * Revalidates cache tags from a streamed JSON request body. + * + * @param body Stream containing the tags to revalidate. + * @return The cache operation response. + * @throws When reading the request stream fails. + */ +async function handleRevalidateTags(body?: ReadableStream): Promise { debug("revalidateTags"); + const bodyText = body ? await fromReadableStream(body) : ""; - if (!body || body.length === 0) { + if (bodyText.length === 0) { return buildErrorResponse("Missing request body", 400); } let tags: string[]; try { - const parsed = JSON.parse(body.toString("utf-8")); + const parsed = JSON.parse(bodyText); tags = Array.isArray(parsed.tags) ? parsed.tags : []; } catch { return buildErrorResponse("Invalid JSON body", 400); diff --git a/packages/core/src/build/adapter.spec.ts b/packages/core/src/build/adapter.spec.ts index edfcd2db..a9e6b95c 100644 --- a/packages/core/src/build/adapter.spec.ts +++ b/packages/core/src/build/adapter.spec.ts @@ -68,6 +68,10 @@ vi.mock("./createImageOptimizationBundle.js", () => ({ createImageOptimizationBundle: vi.fn(), })); +vi.mock("./createCacheBundle.js", () => ({ + createCacheBundle: vi.fn(), +})); + vi.mock("./createWarmerBundle.js", () => ({ createWarmerBundle: vi.fn(), })); @@ -98,6 +102,7 @@ import { compileCache } from "./compileCache.js"; import { compileOpenNextConfig } from "./compileConfig.js"; import { compileTagCacheProvider } from "./compileTagCacheProvider.js"; import { createStaticAssets, createCacheAssets } from "./createAssets.js"; +import { createCacheBundle } from "./createCacheBundle.js"; import { createImageOptimizationBundle } from "./createImageOptimizationBundle.js"; import { createMiddleware } from "./createMiddleware.js"; import { createRevalidationBundle } from "./createRevalidationBundle.js"; @@ -604,7 +609,9 @@ describe("buildAdapter", () => { await adapter.modifyConfig(nextConfig, { phase: "production" }); const ctx = createMockContext(); await adapter.onBuildComplete(ctx); - expect(buildOpenNextOutput).toHaveBeenCalledWith(expect.any(Object), undefined); + expect(buildOpenNextOutput).toHaveBeenCalledWith(expect.any(Object), undefined, { + skipCache: undefined, + }); const fs = await import("node:fs"); expect(fs.default.writeFileSync).toHaveBeenCalledWith( expect.stringMatching(/\/\.open-next\/open-next\.output\.json$/), diff --git a/packages/core/src/build/adapter.ts b/packages/core/src/build/adapter.ts index 19829f91..00a61a5a 100644 --- a/packages/core/src/build/adapter.ts +++ b/packages/core/src/build/adapter.ts @@ -16,6 +16,7 @@ import { compileCache } from "./compileCache.js"; import { compileOpenNextConfig } from "./compileConfig.js"; import { compileTagCacheProvider } from "./compileTagCacheProvider.js"; import { createCacheAssets, createStaticAssets } from "./createAssets.js"; +import { createCacheBundle } from "./createCacheBundle.js"; import { createImageOptimizationBundle } from "./createImageOptimizationBundle.js"; import { createMiddleware } from "./createMiddleware.js"; import { createRevalidationBundle } from "./createRevalidationBundle.js"; @@ -60,6 +61,7 @@ export type OpenNextAdapterOptions = { skipRevalidation?: boolean; skipImageOptimization?: boolean; skipWarmer?: boolean; + skipCache?: boolean; skipGenerateOutput?: boolean; middlewareOptions?: { forceOnlyBuildOnce?: boolean }; middlewareBundle?: { @@ -262,17 +264,25 @@ export function buildAdapter( logger.info("Image optimization bundle created"); } - // Step 11: Warmer bundle + // Step 11: Cache bundle + if (!adapterOptions.skipCache && config.dangerous?.disableIncrementalCache !== true) { + await createCacheBundle(buildOpts, bundleDefaults?.cache); + logger.info("Cache bundle created"); + } + + // Step 12: Warmer bundle if (!adapterOptions.skipWarmer) { await createWarmerBundle(buildOpts, bundleDefaults?.warmer); logger.info("Warmer bundle created"); } - // Step 12: Generate output + // Step 13: Generate output if (!adapterOptions.skipGenerateOutput) { const output = adapterOptions.generateOutput ? await adapterOptions.generateOutput(buildOpts) - : await buildOpenNextOutput(buildOpts, bundleDefaults); + : await buildOpenNextOutput(buildOpts, bundleDefaults, { + skipCache: adapterOptions.skipCache, + }); fs.writeFileSync( path.join(buildOpts.appBuildOutputPath, ".open-next", "open-next.output.json"), JSON.stringify(output) diff --git a/packages/core/src/build/generateOutput.ts b/packages/core/src/build/generateOutput.ts index 8ae35014..f5d0b45c 100644 --- a/packages/core/src/build/generateOutput.ts +++ b/packages/core/src/build/generateOutput.ts @@ -194,11 +194,13 @@ function prefixPattern(basePath: string) { * * @param options Normalized build options. * @param bundleDefaults Adapter defaults used to build each bundle type. + * @param outputOptions Output-generation options. * @return Deployment metadata matching the effective bundle overrides. */ export async function buildOpenNextOutput( options: BuildOptions, - bundleDefaults?: BundleDefaults + bundleDefaults?: BundleDefaults, + { skipCache = false }: { skipCache?: boolean } = {} ): Promise { const { appBuildOutputPath, config } = options; const edgeFunctions: OpenNextOutput["edgeFunctions"] = {}; diff --git a/packages/core/src/overrides/cache/local.ts b/packages/core/src/overrides/cache/local.ts index d0e063ae..f1394bee 100644 --- a/packages/core/src/overrides/cache/local.ts +++ b/packages/core/src/overrides/cache/local.ts @@ -4,7 +4,7 @@ import type { InternalEvent, InternalResult } from "@/types/open-next"; import type { Cache } from "@/types/overrides"; import { parseCacheGetResponse } from "@/utils/cache-get"; import { getMonorepoRelativePath } from "@/utils/normalize-path"; -import { fromReadableStream } from "@/utils/stream"; +import { fromReadableStream, toReadableStream } from "@/utils/stream"; let handler: ((event: InternalEvent) => Promise) | null = null; @@ -34,7 +34,7 @@ const localCache: Cache = { remoteAddress: "127.0.0.1", }; const result = await h(event); - const bodyText = await fromReadableStream(result.body); + const bodyText = result.body ? await fromReadableStream(result.body) : ""; // oxlint-disable-next-line @typescript-eslint/no-explicit-any return parseCacheGetResponse(result.headers, bodyText) as any; }, @@ -55,7 +55,7 @@ const localCache: Cache = { query, cookies: {}, remoteAddress: "127.0.0.1", - body: Buffer.from(JSON.stringify({ value })), + body: toReadableStream(JSON.stringify({ value })), }; await h(event); }, @@ -87,7 +87,7 @@ const localCache: Cache = { query: {}, cookies: {}, remoteAddress: "127.0.0.1", - body: Buffer.from(JSON.stringify({ tags })), + body: toReadableStream(JSON.stringify({ tags })), }; await h(event); }, diff --git a/packages/core/src/plugins/resolve.ts b/packages/core/src/plugins/resolve.ts index b25f0e0e..87ed3f61 100644 --- a/packages/core/src/plugins/resolve.ts +++ b/packages/core/src/plugins/resolve.ts @@ -74,6 +74,7 @@ export type BundleType = | "imageOptimization" | "revalidation" | "warmer" + | "cache" | "tagCache"; export type BundleDefaults = Partial>; diff --git a/packages/tests-unit/tests/adapters/cache-adapter.test.ts b/packages/tests-unit/tests/adapters/cache-adapter.test.ts new file mode 100644 index 00000000..f32e60c2 --- /dev/null +++ b/packages/tests-unit/tests/adapters/cache-adapter.test.ts @@ -0,0 +1,73 @@ +import type { InternalEvent } from "@opennextjs/core/types/open-next"; +import { toReadableStream } from "@opennextjs/core/utils/stream"; +import { beforeEach, describe, expect, test, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + incrementalCache: { + get: vi.fn(), + set: vi.fn(), + delete: vi.fn(), + }, + tagCache: { + mode: "nextMode" as const, + getPathsByTags: vi.fn().mockResolvedValue([]), + writeTags: vi.fn(), + }, + cdnInvalidationHandler: { + invalidatePaths: vi.fn(), + }, +})); + +vi.mock("@opennextjs/core/core/createGenericHandler", () => ({ + createGenericHandler: vi.fn(async ({ handler }) => handler), +})); + +vi.mock("@opennextjs/core/core/resolve", () => ({ + resolveIncrementalCache: vi.fn().mockResolvedValue(mocks.incrementalCache), + resolveTagCache: vi.fn().mockResolvedValue(mocks.tagCache), + resolveCdnInvalidation: vi.fn().mockResolvedValue(mocks.cdnInvalidationHandler), +})); + +vi.mock("@opennextjs/core/utils/cache", () => ({ + writeTags: vi.fn(), +})); + +import { handler } from "@opennextjs/core/adapters/cache-adapter"; + +function createEvent(method: string, rawPath: string, body?: string): InternalEvent { + return { + type: "core", + method, + rawPath, + url: `https://cache.test${rawPath}`, + headers: {}, + query: {}, + cookies: {}, + remoteAddress: "127.0.0.1", + body: body === undefined ? undefined : toReadableStream(body), + }; +} + +describe("cache adapter stream bodies", () => { + beforeEach(() => { + vi.clearAllMocks(); + globalThis.openNextConfig = { default: {} }; + }); + + test("stores a value from a streamed request body", async () => { + const value = { type: "route", body: "content" }; + const result = await handler(createEvent("PUT", "/cache/key", JSON.stringify({ value }))); + + expect(result.statusCode).toBe(200); + expect(mocks.incrementalCache.set).toHaveBeenCalledWith("key", value, "cache"); + }); + + test("revalidates tags from a streamed request body", async () => { + const result = await handler( + createEvent("POST", "/cache/revalidate-tags", JSON.stringify({ tags: ["one", "two"] })) + ); + + expect(result.statusCode).toBe(200); + expect(mocks.tagCache.getPathsByTags).toHaveBeenCalledWith(["one", "two"]); + }); +}); diff --git a/packages/tests-unit/tests/overrides/cache/local.test.ts b/packages/tests-unit/tests/overrides/cache/local.test.ts index 9bc5b23a..06646e68 100644 --- a/packages/tests-unit/tests/overrides/cache/local.test.ts +++ b/packages/tests-unit/tests/overrides/cache/local.test.ts @@ -1,6 +1,6 @@ import localCache from "@opennextjs/core/overrides/cache/local"; import type { InternalResult } from "@opennextjs/core/types/open-next"; -import { toReadableStream } from "@opennextjs/core/utils/stream"; +import { fromReadableStream, toReadableStream } from "@opennextjs/core/utils/stream"; import { vi, describe, expect, it, beforeEach } from "vitest"; vi.mock("@opennextjs/core/utils/normalize-path", () => ({ @@ -81,6 +81,14 @@ describe("local cache", () => { expect(result).toBeNull(); }); + it("should return null when a cache miss has no body", async () => { + mockHandler.mockResolvedValue( + createMockResult({ body: undefined, headers: { "x-opennext-cache-found": "false" } }) + ); + + await expect(localCache.get("key")).resolves.toBeNull(); + }); + it("should return null for cache miss (found = false)", async () => { mockHandler.mockResolvedValue( createMockResult({ @@ -156,7 +164,7 @@ describe("local cache", () => { expect(event.method).toBe("PUT"); expect(event.rawPath).toBe("/cache/key"); expect(event.headers).toEqual({ "Content-Type": "application/json" }); - expect(event.body).toEqual(Buffer.from(JSON.stringify({ value }))); + expect(await fromReadableStream(event.body)).toBe(JSON.stringify({ value })); }); it("should encode the key", async () => { @@ -201,7 +209,7 @@ describe("local cache", () => { const event = mockHandler.mock.calls[0][0]; expect(event.method).toBe("POST"); expect(event.rawPath).toBe("/cache/revalidate-tags"); - expect(event.body).toEqual(Buffer.from(JSON.stringify({ tags: ["tag1", "tag2"] }))); + expect(await fromReadableStream(event.body)).toBe(JSON.stringify({ tags: ["tag1", "tag2"] })); }); }); From 8df264ec28ac90155101e6eee71e3baab47ab273 Mon Sep 17 00:00:00 2001 From: Victor Berchet Date: Fri, 18 Sep 2026 09:47:34 +0200 Subject: [PATCH 18/28] fix(core): route cache operations through the cache transport Use the configured cache override for incremental, fetch, composable, and routing cache storage operations. Delegate tag revalidation to that transport so the dedicated handler receives invalidation requests. Keep the transport optional and retain the existing incremental and tag cache behavior when no cache override is configured. This preserves compatibility for applications that have not opted into the dedicated cache function. Tests cover transport reads, writes, deletes, tag revalidation, composable entries, routing interception, and legacy fallback. Verified with the core type-check and 105 focused tests. --- packages/core/src/adapters/cache.ts | 32 ++++++++--- .../core/src/adapters/composable-cache.ts | 16 +++++- packages/core/src/core/resolve.ts | 10 +++- .../core/src/core/routing/cacheInterceptor.ts | 2 +- packages/core/src/types/global.ts | 2 +- .../tests-unit/tests/adapters/cache.test.ts | 47 ++++++++++++++++ .../tests/adapters/composable-cache.test.ts | 56 +++++++++++++++++++ .../core/routing/cacheInterceptor.test.ts | 25 +++++++++ 8 files changed, 175 insertions(+), 15 deletions(-) diff --git a/packages/core/src/adapters/cache.ts b/packages/core/src/adapters/cache.ts index 9cd66847..cb8a65f6 100644 --- a/packages/core/src/adapters/cache.ts +++ b/packages/core/src/adapters/cache.ts @@ -7,6 +7,15 @@ import { debug, error, warn } from "./logger"; export const SOFT_TAG_PREFIX = "_N_T_/"; +/** + * Selects the dedicated cache transport when one is configured. + * + * @return The effective cache storage implementation. + */ +function getCacheStorage() { + return globalThis.cache ?? globalThis.incrementalCache; +} + function isFetchCache(options?: { kindHint?: "app" | "pages" | "fetch"; kind?: "FETCH" }): boolean { if (typeof options === "object") { return options.kindHint === "fetch" || options.kind === "FETCH"; @@ -37,7 +46,7 @@ export default class Cache { async getFetchCache(key: string, softTags?: string[], tags?: string[]) { debug("get fetch cache", { key, softTags, tags }); try { - const cachedEntry = await globalThis.incrementalCache.get(key, "fetch"); + const cachedEntry = await getCacheStorage().get(key, "fetch"); if (cachedEntry?.value === undefined) return null; @@ -80,7 +89,7 @@ export default class Cache { async getIncrementalCache(key: string): Promise { try { - const cachedEntry = await globalThis.incrementalCache.get(key, "cache"); + const cachedEntry = await getCacheStorage().get(key, "cache"); if (!cachedEntry?.value) { return null; @@ -174,14 +183,14 @@ export default class Cache { const detachedPromise = globalThis.__openNextAls.getStore()?.pendingPromiseRunner.withResolvers(); try { if (data === null || data === undefined) { - await globalThis.incrementalCache.delete(key); + await getCacheStorage().delete(key); } else { const revalidate = this.extractRevalidateForSet(ctx); switch (data.kind) { case "ROUTE": case "APP_ROUTE": { const { body, status, headers } = data; - await globalThis.incrementalCache.set( + await getCacheStorage().set( key, { type: "route", @@ -201,7 +210,7 @@ export default class Cache { const { html, pageData, status, headers } = data; const isAppPath = typeof pageData === "string"; if (isAppPath) { - await globalThis.incrementalCache.set( + await getCacheStorage().set( key, { type: "app", @@ -216,7 +225,7 @@ export default class Cache { "cache" ); } else { - await globalThis.incrementalCache.set( + await getCacheStorage().set( key, { type: "page", @@ -237,7 +246,7 @@ export default class Cache { segmentToWrite[segmentPath] = segmentContent.toString("utf8"); } } - await globalThis.incrementalCache.set( + await getCacheStorage().set( key, { type: "app", @@ -256,10 +265,10 @@ export default class Cache { break; } case "FETCH": - await globalThis.incrementalCache.set(key, data, "fetch"); + await getCacheStorage().set(key, data, "fetch"); break; case "REDIRECT": - await globalThis.incrementalCache.set( + await getCacheStorage().set( key, { type: "redirect", @@ -296,6 +305,11 @@ export default class Cache { } try { + if (globalThis.cache) { + await globalThis.cache.revalidateTags(_tags); + return; + } + if (globalThis.tagCache.mode === "nextMode") { const paths = (await globalThis.tagCache.getPathsByTags?.(_tags)) ?? []; diff --git a/packages/core/src/adapters/composable-cache.ts b/packages/core/src/adapters/composable-cache.ts index a6fb19c3..40a81f21 100644 --- a/packages/core/src/adapters/composable-cache.ts +++ b/packages/core/src/adapters/composable-cache.ts @@ -7,6 +7,15 @@ import { debug } from "./logger"; const pendingWritePromiseMap = new Map>>(); +/** + * Selects the dedicated cache transport when one is configured. + * + * @return The effective cache storage implementation. + */ +function getCacheStorage() { + return globalThis.cache ?? globalThis.incrementalCache; +} + export default { async get(cacheKey: string) { try { @@ -21,7 +30,7 @@ export default { })); } } - const result = await globalThis.incrementalCache.get(cacheKey, "composable"); + const result = await getCacheStorage().get(cacheKey, "composable"); if (!result?.value?.value) { return undefined; } @@ -61,7 +70,7 @@ export default { const entry = await promiseEntry.finally(() => { pendingWritePromiseMap.delete(cacheKey); }); - await globalThis.incrementalCache.set( + await getCacheStorage().set( cacheKey, { ...entry, @@ -102,6 +111,9 @@ export default { * This method is only used before Next.js 16 */ async expireTags(...tags: string[]) { + if (globalThis.cache) { + return globalThis.cache.revalidateTags(tags); + } if (globalThis.tagCache.mode === "nextMode") { return writeTags(tags); } diff --git a/packages/core/src/core/resolve.ts b/packages/core/src/core/resolve.ts index 2bea8501..9027033f 100644 --- a/packages/core/src/core/resolve.ts +++ b/packages/core/src/core/resolve.ts @@ -159,9 +159,15 @@ export async function resolveCdnInvalidation(cdnInvalidation: OverrideOptions["c } /** - * @__PURE__ + * Resolves the configured dedicated cache transport. + * + * @param cache Cache transport override. + * @return The configured transport, or undefined when dedicated caching is not enabled. */ -export async function resolveCache(cache: OverrideOptions["cache"]): Promise { +export async function resolveCache(cache: OverrideOptions["cache"]): Promise { + if (cache === undefined) { + return undefined; + } if (typeof cache === "function") { return cache(); } diff --git a/packages/core/src/core/routing/cacheInterceptor.ts b/packages/core/src/core/routing/cacheInterceptor.ts index 53f6edb4..fa340e29 100644 --- a/packages/core/src/core/routing/cacheInterceptor.ts +++ b/packages/core/src/core/routing/cacheInterceptor.ts @@ -340,7 +340,7 @@ export async function cacheInterceptor( } else if (localizedPath === "") { pathToUse = "/index"; } - const cachedData = await globalThis.incrementalCache.get(pathToUse); + const cachedData = await (globalThis.cache ?? globalThis.incrementalCache).get(pathToUse); debug("cached data in interceptor", cachedData); if (!cachedData?.value) { diff --git a/packages/core/src/types/global.ts b/packages/core/src/types/global.ts index 65e03701..4694e9ff 100644 --- a/packages/core/src/types/global.ts +++ b/packages/core/src/types/global.ts @@ -201,7 +201,7 @@ declare global { * Only available in main functions. * Defined in `createMainHandler`. */ - var cache: Cache; + var cache: Cache | undefined; /** * The function that is used when resolving external rewrite requests. diff --git a/packages/tests-unit/tests/adapters/cache.test.ts b/packages/tests-unit/tests/adapters/cache.test.ts index 347f35c1..c79589db 100644 --- a/packages/tests-unit/tests/adapters/cache.test.ts +++ b/packages/tests-unit/tests/adapters/cache.test.ts @@ -28,6 +28,13 @@ describe("CacheHandler", () => { delete: vi.fn(), }; globalThis.incrementalCache = incrementalCache; + const cacheTransport = { + name: "transport", + get: vi.fn(), + set: vi.fn(), + delete: vi.fn(), + revalidateTags: vi.fn(), + }; const tagCache = { name: "mock", @@ -69,10 +76,50 @@ describe("CacheHandler", () => { }, }; globalThis.isNextAfter15 = false; + globalThis.cache = undefined; tagCache.mode = "original"; tagCache.getPathsByTags = undefined; }); + describe("dedicated cache transport", () => { + beforeEach(() => { + globalThis.cache = cacheTransport; + }); + + it("uses the transport for reads", async () => { + cacheTransport.get.mockResolvedValueOnce({ + value: { type: "route", body: "{}" }, + lastModified: Date.now(), + }); + + await cache.get("key"); + + expect(cacheTransport.get).toHaveBeenCalledWith("key", "cache"); + expect(incrementalCache.get).not.toHaveBeenCalled(); + }); + + it("uses the transport for writes and deletes", async () => { + await cache.set("key", { kind: "REDIRECT", props: {} }); + await cache.set("deleted", undefined); + + expect(cacheTransport.set).toHaveBeenCalledWith( + "key", + expect.objectContaining({ type: "redirect" }), + "cache" + ); + expect(cacheTransport.delete).toHaveBeenCalledWith("deleted"); + expect(incrementalCache.set).not.toHaveBeenCalled(); + expect(incrementalCache.delete).not.toHaveBeenCalled(); + }); + + it("uses the transport for tag revalidation", async () => { + await cache.revalidateTag(["one", "two"]); + + expect(cacheTransport.revalidateTags).toHaveBeenCalledWith(["one", "two"]); + expect(tagCache.getByTag).not.toHaveBeenCalled(); + }); + }); + describe("get", () => { it("Should return null for cache miss", async () => { incrementalCache.get.mockResolvedValueOnce({}); diff --git a/packages/tests-unit/tests/adapters/composable-cache.test.ts b/packages/tests-unit/tests/adapters/composable-cache.test.ts index 35b723b4..6e0ce2b2 100644 --- a/packages/tests-unit/tests/adapters/composable-cache.test.ts +++ b/packages/tests-unit/tests/adapters/composable-cache.test.ts @@ -24,6 +24,13 @@ describe("Composable cache handler", () => { delete: vi.fn(), }; globalThis.incrementalCache = incrementalCache; + const cacheTransport = { + name: "transport", + get: vi.fn(), + set: vi.fn(), + delete: vi.fn(), + revalidateTags: vi.fn(), + }; const tagCache = { name: "mock", @@ -64,6 +71,55 @@ describe("Composable cache handler", () => { disableTagCache: false, }, }; + globalThis.cache = undefined; + }); + + describe("dedicated cache transport", () => { + beforeEach(() => { + globalThis.cache = cacheTransport; + }); + + it("uses the transport for reads", async () => { + cacheTransport.get.mockResolvedValueOnce({ + value: { + value: "transport-value", + tags: [], + stale: 0, + timestamp: Date.now(), + expire: Date.now() + 1000, + revalidate: 3600, + }, + }); + + const result = await ComposableCache.get("key"); + + expect(cacheTransport.get).toHaveBeenCalledWith("key", "composable"); + expect(incrementalCache.get).not.toHaveBeenCalled(); + expect(await fromReadableStream(result!.value)).toBe("transport-value"); + }); + + it("uses the transport for writes and tag expiry", async () => { + await ComposableCache.set( + "key", + Promise.resolve({ + value: toReadableStream("value"), + tags: [], + stale: 0, + timestamp: Date.now(), + expire: Date.now() + 1000, + revalidate: 3600, + }) + ); + await ComposableCache.expireTags("tag"); + + expect(cacheTransport.set).toHaveBeenCalledWith( + "key", + expect.objectContaining({ value: "value" }), + "composable" + ); + expect(cacheTransport.revalidateTags).toHaveBeenCalledWith(["tag"]); + expect(incrementalCache.set).not.toHaveBeenCalled(); + }); }); describe("get", () => { diff --git a/packages/tests-unit/tests/core/routing/cacheInterceptor.test.ts b/packages/tests-unit/tests/core/routing/cacheInterceptor.test.ts index f545406b..7d7086ef 100644 --- a/packages/tests-unit/tests/core/routing/cacheInterceptor.test.ts +++ b/packages/tests-unit/tests/core/routing/cacheInterceptor.test.ts @@ -53,6 +53,14 @@ const incrementalCache = { delete: vi.fn(), }; +const cacheTransport = { + name: "transport", + get: vi.fn(), + set: vi.fn(), + delete: vi.fn(), + revalidateTags: vi.fn(), +}; + const tagCache = { name: "mock", getByTag: vi.fn(), @@ -85,6 +93,7 @@ beforeEach(() => { disableIncrementalCache: false, }, }; + globalThis.cache = undefined; }); describe("cacheInterceptor", () => { @@ -151,6 +160,22 @@ describe("cacheInterceptor", () => { ); }); + it("should retrieve content through the dedicated cache transport", async () => { + globalThis.cache = cacheTransport; + cacheTransport.get.mockResolvedValueOnce({ + value: { + type: "app", + html: "From transport", + }, + }); + + const result = await cacheInterceptor(createEvent({ url: "/albums" })); + + expect(cacheTransport.get).toHaveBeenCalledWith("/albums"); + expect(incrementalCache.get).not.toHaveBeenCalled(); + expect(await fromReadableStream(result.body)).toBe("From transport"); + }); + it("should take no action when tagCache lasModified is -1 for app type", async () => { const event = createEvent({ url: "/albums", From f22f401c61ce94dd5b4aa2f574e8a4ede6861212 Mon Sep 17 00:00:00 2001 From: Victor Berchet Date: Fri, 18 Sep 2026 09:48:50 +0200 Subject: [PATCH 19/28] fix(aws): configure the dedicated cache Lambda Provide cache-bundle defaults that use the AWS Lambda wrapper and API Gateway v2 converter instead of the core Node HTTP server. Back the function with the same S3 incremental cache and DynamoDB tag cache used by AWS server functions. Add adapter-level coverage for forwarding cache bundle defaults and an AWS-specific test that locks down the complete cache function profile. Tests: @opennextjs/aws type-check; 25 core adapter tests; AWS adapter cache-default test. --- packages/aws/src/adapter.ts | 6 ++++ packages/core/src/build/adapter.spec.ts | 18 ++++++++++++ .../tests/build/aws-adapter.test.ts | 29 +++++++++++++++++++ 3 files changed, 53 insertions(+) create mode 100644 packages/tests-unit/tests/build/aws-adapter.test.ts diff --git a/packages/aws/src/adapter.ts b/packages/aws/src/adapter.ts index 7eab334c..4fca803a 100644 --- a/packages/aws/src/adapter.ts +++ b/packages/aws/src/adapter.ts @@ -24,6 +24,12 @@ export default buildAdapter((_config, buildOpts: BuildOptions) => ({ imageLoader: "@opennextjs/aws/overrides/imageLoader/s3.js", }, warmer: { wrapper: "@opennextjs/aws/overrides/wrappers/aws-lambda.js" }, + cache: { + wrapper: "@opennextjs/aws/overrides/wrappers/aws-lambda.js", + converter: "@opennextjs/aws/overrides/converters/aws-apigw-v2.js", + incrementalCache: "@opennextjs/aws/overrides/incrementalCache/s3.js", + tagCache: "@opennextjs/aws/overrides/tagCache/dynamodb.js", + }, tagCache: { wrapper: "@opennextjs/aws/overrides/wrappers/aws-lambda.js", tagCache: "@opennextjs/aws/overrides/tagCache/dynamodb.js", diff --git a/packages/core/src/build/adapter.spec.ts b/packages/core/src/build/adapter.spec.ts index a9e6b95c..d2c80db0 100644 --- a/packages/core/src/build/adapter.spec.ts +++ b/packages/core/src/build/adapter.spec.ts @@ -407,6 +407,24 @@ describe("buildAdapter", () => { expect(createImageOptimizationBundle).not.toHaveBeenCalled(); }); + test("onBuildComplete forwards cache bundle defaults", async () => { + const cacheDefaults = { + wrapper: "platform-cache-wrapper", + incrementalCache: "platform-incremental-cache", + }; + const adapter = buildAdapter(() => ({ + serverBundle, + defaultOverrides: { cache: cacheDefaults }, + })); + + await adapter.modifyConfig({ experimental: {}, images: {} } as BuildCompleteContext["config"], { + phase: "production", + }); + await adapter.onBuildComplete(createMockContext()); + + expect(createCacheBundle).toHaveBeenCalledWith(expect.any(Object), cacheDefaults); + }); + test("onBuildComplete skips warmer when skipWarmer is true", async () => { const adapter = buildAdapter(() => ({ serverBundle, diff --git a/packages/tests-unit/tests/build/aws-adapter.test.ts b/packages/tests-unit/tests/build/aws-adapter.test.ts new file mode 100644 index 00000000..078574bc --- /dev/null +++ b/packages/tests-unit/tests/build/aws-adapter.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, test, vi } from "vitest"; + +const buildAdapter = vi.hoisted(() => vi.fn((callback) => callback)); + +vi.mock("@opennextjs/core/build/adapter.js", () => ({ + buildAdapter, +})); + +import adapterCallback from "@opennextjs/aws/adapter.js"; + +describe("AWS adapter cache defaults", () => { + test("builds the cache handler as an AWS Lambda backed by shared caches", () => { + const options = ( + adapterCallback as unknown as ( + _config: unknown, + buildOptions: unknown + ) => { + defaultOverrides: { cache: Record }; + } + )({}, {}); + + expect(options.defaultOverrides.cache).toEqual({ + wrapper: "@opennextjs/aws/overrides/wrappers/aws-lambda.js", + converter: "@opennextjs/aws/overrides/converters/aws-apigw-v2.js", + incrementalCache: "@opennextjs/aws/overrides/incrementalCache/s3.js", + tagCache: "@opennextjs/aws/overrides/tagCache/dynamodb.js", + }); + }); +}); From 269eb10618ff046a6c52d4ff049b1ab616bcc4ee Mon Sep 17 00:00:00 2001 From: Victor Berchet Date: Fri, 18 Sep 2026 09:50:33 +0200 Subject: [PATCH 20/28] fix(core): separate the raw local cache handler Split the internal cache request handler from the platform-wrapped deployment adapter. Bundle index.mjs for provider invocation and handler.mjs for direct in-process calls. Point the local cache transport at the raw handler so importing it cannot start the Node HTTP wrapper or pass InternalEvent objects through a platform converter. Keep cache initialization shared by both entrypoints. Tests assert both bundle outputs and exercise the raw handler and local transport. Verified with the core type-check, 26 build tests, and 17 cache handler tests. --- packages/core/src/adapters/cache-adapter.ts | 484 +---------------- packages/core/src/adapters/cache-handler.ts | 489 ++++++++++++++++++ .../core/src/build/createCacheBundle.spec.ts | 51 ++ packages/core/src/build/createCacheBundle.ts | 17 +- packages/core/src/overrides/cache/local.ts | 8 +- .../tests/adapters/cache-adapter.test.ts | 2 +- .../tests/overrides/cache/local.test.ts | 2 +- 7 files changed, 565 insertions(+), 488 deletions(-) create mode 100644 packages/core/src/adapters/cache-handler.ts create mode 100644 packages/core/src/build/createCacheBundle.spec.ts diff --git a/packages/core/src/adapters/cache-adapter.ts b/packages/core/src/adapters/cache-adapter.ts index ecb0d138..d04abe1b 100644 --- a/packages/core/src/adapters/cache-adapter.ts +++ b/packages/core/src/adapters/cache-adapter.ts @@ -1,488 +1,8 @@ -import { AsyncLocalStorage } from "node:async_hooks"; -import type { ReadableStream } from "node:stream/web"; - -import type { StoredComposableCacheEntry } from "@/types/cache"; -import type { InternalEvent, InternalResult } from "@/types/open-next"; -import type { - CacheEntryType, - CachedFile, - CachedFetchValue, - CacheValue, - OpenNextHandlerOptions, - WithLastModified, -} from "@/types/overrides"; - import { createGenericHandler } from "../core/createGenericHandler.js"; -import { resolveCdnInvalidation, resolveIncrementalCache, resolveTagCache } from "../core/resolve.js"; -import { writeTags } from "../utils/cache.js"; -import { runWithOpenNextRequestContext } from "../utils/promise.js"; -import { fromReadableStream, toReadableStream } from "../utils/stream.js"; - -import { debug, error } from "./logger.js"; - -globalThis.__openNextAls = new AsyncLocalStorage(); - -const SOFT_TAG_PREFIX = "_N_T_/"; - -// Whether caches have been initialized -let initialized = false; - -async function initializeCaches() { - if (initialized) return; - const config = globalThis.openNextConfig; - - globalThis.incrementalCache = await resolveIncrementalCache( - config.cacheHandler?.incrementalCache ?? config.default?.override?.incrementalCache - ); - globalThis.tagCache = await resolveTagCache( - config.cacheHandler?.tagCache ?? config.default?.override?.tagCache - ); - - globalThis.cdnInvalidationHandler = await resolveCdnInvalidation( - config.cacheHandler?.cdnInvalidation ?? config.default?.override?.cdnInvalidation - ); - - initialized = true; -} - -///////////// -// Handler // -///////////// +import { handler as cacheHandler } from "./cache-handler.js"; export const handler = await createGenericHandler({ - handler: defaultHandler, + handler: cacheHandler, type: "cache", }); - -async function defaultHandler( - event: InternalEvent, - options?: OpenNextHandlerOptions -): Promise { - debug("cache handler event", event); - - try { - await initializeCaches(); - } catch (e) { - error("Failed to initialize caches", e); - return buildErrorResponse("Internal server error", 500); - } - - const { method, rawPath, query, body } = event; - - try { - // POST /cache/revalidate-tags - if (method === "POST" && rawPath === "/cache/revalidate-tags") { - return await handleRevalidateTags(body); - } - - // All other operations must be on /cache/* - if (!rawPath.startsWith("/cache/")) { - return buildErrorResponse("Not Found", 404); - } - - const key = decodeURIComponent(rawPath.slice("/cache/".length)); - - if (!key) { - return buildErrorResponse("Missing cache key", 400); - } - - const rawType = typeof query?.type === "string" ? query.type : undefined; - const cacheType: CacheEntryType = rawType === "fetch" || rawType === "composable" ? rawType : "cache"; - - switch (method) { - case "GET": - return await handleGet(key, cacheType); - case "PUT": - return await handleSet(key, cacheType, body); - case "DELETE": - return await handleDelete(key); - default: - return buildErrorResponse("Method Not Allowed", 405); - } - } catch (e) { - error("Failed to handle cache request", e); - return buildErrorResponse("Internal server error", 500); - } -} - -////////////////////// -// Route handlers // -////////////////////// - -async function handleGet(key: string, cacheType: CacheEntryType): Promise { - debug("get", { key, cacheType }); - - try { - const result = await globalThis.incrementalCache.get(key, cacheType); - - if (!result?.value) { - return { - type: "core", - statusCode: 404, - body: toReadableStream(""), - isBase64Encoded: false, - headers: { - "x-opennext-cache-found": "false", - "Cache-Control": "no-store", - }, - }; - } - - return buildCacheGetResponse(result); - } catch (e) { - error("Failed to get cache entry", e); - return buildErrorResponse("Failed to get cache entry", 500); - } -} - -/** - * Stores a cache entry from a streamed JSON request body. - * - * @param key Cache key to update. - * @param cacheType Type of cache entry being stored. - * @param body Stream containing the serialized cache value. - * @return The cache operation response. - * @throws When reading the request stream fails. - */ -async function handleSet( - key: string, - cacheType: CacheEntryType, - body?: ReadableStream -): Promise { - debug("set", { key, cacheType }); - const bodyText = body ? await fromReadableStream(body) : ""; - - let payload: { - value?: Record; - } = {}; - - if (bodyText.length > 0) { - try { - payload = JSON.parse(bodyText); - } catch { - return buildErrorResponse("Invalid JSON body", 400); - } - } - - if (!payload.value) { - return buildErrorResponse("Missing 'value' in request body", 400); - } - - try { - await globalThis.incrementalCache.set(key, payload.value as CacheValue, cacheType); - return buildJsonResponse({ ok: true }, 200); - } catch (e) { - error("Failed to set cache entry", e); - return buildErrorResponse("Failed to set cache entry", 500); - } -} - -async function handleDelete(key: string): Promise { - debug("delete", { key }); - - try { - await globalThis.incrementalCache.delete(key); - return buildJsonResponse({ ok: true }, 200); - } catch (e) { - error("Failed to delete cache entry", e); - return buildErrorResponse("Failed to delete cache entry", 500); - } -} - -/** - * Revalidates cache tags from a streamed JSON request body. - * - * @param body Stream containing the tags to revalidate. - * @return The cache operation response. - * @throws When reading the request stream fails. - */ -async function handleRevalidateTags(body?: ReadableStream): Promise { - debug("revalidateTags"); - const bodyText = body ? await fromReadableStream(body) : ""; - - if (bodyText.length === 0) { - return buildErrorResponse("Missing request body", 400); - } - - let tags: string[]; - try { - const parsed = JSON.parse(bodyText); - tags = Array.isArray(parsed.tags) ? parsed.tags : []; - } catch { - return buildErrorResponse("Invalid JSON body", 400); - } - - if (tags.length === 0) { - return buildErrorResponse("Missing 'tags' array in request body", 400); - } - - try { - await runWithOpenNextRequestContext({ isISRRevalidation: false }, async () => { - if (globalThis.tagCache.mode === "nextMode") { - const paths = (await globalThis.tagCache.getPathsByTags?.(tags)) ?? []; - - await writeTags(tags); - if (paths.length > 0) { - await globalThis.cdnInvalidationHandler.invalidatePaths( - paths.map((path) => ({ - initialPath: path, - rawPath: path, - resolvedRoutes: [ - { - route: path, - type: "app", - isFallback: false, - }, - ], - })) - ); - } - return; - } - - for (const tag of tags) { - debug("revalidateTag", tag); - const paths = await globalThis.tagCache.getByTag(tag); - debug("Items", paths); - const toInsert = paths.map((path) => ({ - path, - tag, - })); - - if (tag.startsWith(SOFT_TAG_PREFIX)) { - for (const path of paths) { - const _tags = await globalThis.tagCache.getByPath(path); - const hardTags = _tags.filter((t) => !t.startsWith(SOFT_TAG_PREFIX)); - for (const hardTag of hardTags) { - const _paths = await globalThis.tagCache.getByTag(hardTag); - debug({ hardTag, _paths }); - toInsert.push( - ..._paths.map((path) => ({ - path, - tag: hardTag, - })) - ); - } - } - } - - await writeTags(toInsert); - - const uniquePaths = Array.from( - new Set(toInsert.filter((t) => t.tag.startsWith(SOFT_TAG_PREFIX)).map((t) => `/${t.path}`)) - ); - if (uniquePaths.length > 0) { - await globalThis.cdnInvalidationHandler.invalidatePaths( - uniquePaths.map((path) => ({ - initialPath: path, - rawPath: path, - resolvedRoutes: [ - { - route: path, - type: "app", - isFallback: false, - }, - ], - })) - ); - } - } - }); - - return buildJsonResponse({ revalidated: tags }, 200); - } catch (e) { - error("Failed to revalidate tags", e); - return buildErrorResponse("Failed to revalidate tags", 500); - } -} - -///////////////////////////// -// Cache GET response builder // -///////////////////////////// - -function buildCacheGetResponse(result: WithLastModified>): InternalResult { - const value = result.value!; - - const headers: Record = { - "x-opennext-cache-found": "true", - "Cache-Control": "no-store", - }; - - if (result.lastModified !== undefined) { - headers["x-opennext-cache-last-modified"] = String(result.lastModified); - } - if (result.shouldBypassTagCache) { - headers["x-opennext-cache-should-bypass"] = "true"; - } - - if ("kind" in value && value.kind === "FETCH") { - return buildFetchResponse(value as CachedFetchValue, headers); - } - - if ("type" in value) { - return buildCachedFileResponse(value as CachedFile, headers); - } - - return buildComposableResponse(value as StoredComposableCacheEntry, headers); -} - -function buildFetchResponse( - value: CacheValue<"fetch">, - headers: Record -): InternalResult { - headers["x-opennext-cache-type"] = "fetch"; - headers["x-opennext-cache-fetch-kind"] = "FETCH"; - headers["x-opennext-cache-fetch-data-url"] = value.data.url; - - if (value.revalidate !== undefined) { - headers["x-opennext-cache-revalidate"] = String(value.revalidate); - } - - if (value.data.status !== undefined) { - headers["x-opennext-cache-fetch-data-status"] = String(value.data.status); - } - if (value.data.tags) { - headers["x-opennext-cache-fetch-data-tags"] = JSON.stringify(value.data.tags); - } - if (value.tags) { - headers["x-opennext-cache-fetch-tags"] = JSON.stringify(value.tags); - } - - for (const [key, val] of Object.entries(value.data.headers)) { - headers[`x-opennext-cache-header-${key}`] = val; - } - - const body = value.data.body; - return { - type: "core", - statusCode: 200, - body: toReadableStream(body), - isBase64Encoded: false, - headers: { ...headers, "Content-Type": "text/plain" }, - }; -} - -function buildCachedFileResponse( - value: CacheValue<"cache">, - headers: Record -): InternalResult { - headers["x-opennext-cache-type"] = "cache"; - headers["x-opennext-cache-sub-type"] = value.type; - - if (value.revalidate !== undefined) { - headers["x-opennext-cache-revalidate"] = String(value.revalidate); - } - - if (value.meta?.status !== undefined) { - headers["x-opennext-cache-meta-status"] = String(value.meta.status); - } - if (value.meta?.postponed !== undefined) { - headers["x-opennext-cache-meta-postponed"] = value.meta.postponed; - } - if (value.meta?.headers) { - for (const [key, val] of Object.entries(value.meta.headers)) { - if (val !== undefined) { - headers[`x-opennext-cache-header-${key}`] = val; - } - } - } - - switch (value.type) { - case "route": { - return { - type: "core", - statusCode: 200, - body: toReadableStream(value.body), - isBase64Encoded: false, - headers: { ...headers, "Content-Type": "text/plain" }, - }; - } - case "page": { - return { - type: "core", - statusCode: 200, - body: toReadableStream(JSON.stringify({ json: value.json, html: value.html })), - isBase64Encoded: false, - headers: { ...headers, "Content-Type": "application/json" }, - }; - } - case "app": { - return { - type: "core", - statusCode: 200, - body: toReadableStream( - JSON.stringify({ - html: value.html, - rsc: value.rsc, - segmentData: value.segmentData, - }) - ), - isBase64Encoded: false, - headers: { ...headers, "Content-Type": "application/json" }, - }; - } - case "redirect": { - return { - type: "core", - statusCode: 200, - body: toReadableStream(JSON.stringify(value.props ?? {})), - isBase64Encoded: false, - headers: { ...headers, "Content-Type": "application/json" }, - }; - } - } -} - -function buildComposableResponse( - value: StoredComposableCacheEntry, - headers: Record -): InternalResult { - headers["x-opennext-cache-type"] = "composable"; - headers["x-opennext-cache-composable-stale"] = String(value.stale); - headers["x-opennext-cache-composable-expire"] = String(value.expire); - headers["x-opennext-cache-composable-timestamp"] = String(value.timestamp); - headers["x-opennext-cache-composable-revalidate"] = String(value.revalidate); - headers["x-opennext-cache-composable-tags"] = JSON.stringify(value.tags); - - return { - type: "core", - statusCode: 200, - body: toReadableStream(value.value), - isBase64Encoded: false, - headers: { ...headers, "Content-Type": "text/plain" }, - }; -} - -////////////////////////// -// Response builders // -////////////////////////// - -function buildJsonResponse(data: unknown, statusCode: number): InternalResult { - const body = JSON.stringify(data); - return { - type: "core", - statusCode, - body: toReadableStream(body), - isBase64Encoded: false, - headers: { - "Content-Type": "application/json", - "Cache-Control": "no-store", - }, - }; -} - -function buildErrorResponse(message: string, statusCode: number): InternalResult { - debug(message, statusCode); - const body = JSON.stringify({ error: message }); - return { - type: "core", - statusCode, - body: toReadableStream(body), - isBase64Encoded: false, - headers: { - "Content-Type": "application/json", - "Cache-Control": "no-store", - }, - }; -} diff --git a/packages/core/src/adapters/cache-handler.ts b/packages/core/src/adapters/cache-handler.ts new file mode 100644 index 00000000..05f13d45 --- /dev/null +++ b/packages/core/src/adapters/cache-handler.ts @@ -0,0 +1,489 @@ +import { AsyncLocalStorage } from "node:async_hooks"; +import type { ReadableStream } from "node:stream/web"; + +import type { StoredComposableCacheEntry } from "@/types/cache"; +import type { InternalEvent, InternalResult } from "@/types/open-next"; +import type { + CacheEntryType, + CachedFile, + CachedFetchValue, + CacheValue, + OpenNextHandlerOptions, + WithLastModified, +} from "@/types/overrides"; + +import { resolveCdnInvalidation, resolveIncrementalCache, resolveTagCache } from "../core/resolve.js"; +import { writeTags } from "../utils/cache.js"; +import { runWithOpenNextRequestContext } from "../utils/promise.js"; +import { fromReadableStream, toReadableStream } from "../utils/stream.js"; + +import { debug, error } from "./logger.js"; + +globalThis.__openNextAls = new AsyncLocalStorage(); + +const SOFT_TAG_PREFIX = "_N_T_/"; + +// Whether caches have been initialized +let initialized = false; + +async function initializeCaches() { + if (initialized) return; + const config = globalThis.openNextConfig; + + globalThis.incrementalCache = await resolveIncrementalCache( + config.cacheHandler?.incrementalCache ?? config.default?.override?.incrementalCache + ); + + globalThis.tagCache = await resolveTagCache( + config.cacheHandler?.tagCache ?? config.default?.override?.tagCache + ); + + globalThis.cdnInvalidationHandler = await resolveCdnInvalidation( + config.cacheHandler?.cdnInvalidation ?? config.default?.override?.cdnInvalidation + ); + + initialized = true; +} + +///////////// +// Handler // +///////////// + +/** + * Handles an internal cache function request. + * + * @param event Normalized cache request. + * @param options Handler execution options. + * @return The normalized cache response. + */ +export async function handler( + event: InternalEvent, + options?: OpenNextHandlerOptions +): Promise { + debug("cache handler event", event); + + try { + await initializeCaches(); + } catch (e) { + error("Failed to initialize caches", e); + return buildErrorResponse("Internal server error", 500); + } + + const { method, rawPath, query, body } = event; + + try { + // POST /cache/revalidate-tags + if (method === "POST" && rawPath === "/cache/revalidate-tags") { + return await handleRevalidateTags(body); + } + + // All other operations must be on /cache/* + if (!rawPath.startsWith("/cache/")) { + return buildErrorResponse("Not Found", 404); + } + + const key = decodeURIComponent(rawPath.slice("/cache/".length)); + + if (!key) { + return buildErrorResponse("Missing cache key", 400); + } + + const rawType = typeof query?.type === "string" ? query.type : undefined; + const cacheType: CacheEntryType = rawType === "fetch" || rawType === "composable" ? rawType : "cache"; + + switch (method) { + case "GET": + return await handleGet(key, cacheType); + case "PUT": + return await handleSet(key, cacheType, body); + case "DELETE": + return await handleDelete(key); + default: + return buildErrorResponse("Method Not Allowed", 405); + } + } catch (e) { + error("Failed to handle cache request", e); + return buildErrorResponse("Internal server error", 500); + } +} + +////////////////////// +// Route handlers // +////////////////////// + +async function handleGet(key: string, cacheType: CacheEntryType): Promise { + debug("get", { key, cacheType }); + + try { + const result = await globalThis.incrementalCache.get(key, cacheType); + + if (!result?.value) { + return { + type: "core", + statusCode: 404, + body: toReadableStream(""), + isBase64Encoded: false, + headers: { + "x-opennext-cache-found": "false", + "Cache-Control": "no-store", + }, + }; + } + + return buildCacheGetResponse(result); + } catch (e) { + error("Failed to get cache entry", e); + return buildErrorResponse("Failed to get cache entry", 500); + } +} + +/** + * Stores a cache entry from a streamed JSON request body. + * + * @param key Cache key to update. + * @param cacheType Type of cache entry being stored. + * @param body Stream containing the serialized cache value. + * @return The cache operation response. + * @throws When reading the request stream fails. + */ +async function handleSet( + key: string, + cacheType: CacheEntryType, + body?: ReadableStream +): Promise { + debug("set", { key, cacheType }); + const bodyText = body ? await fromReadableStream(body) : ""; + + let payload: { + value?: Record; + } = {}; + + if (bodyText.length > 0) { + try { + payload = JSON.parse(bodyText); + } catch { + return buildErrorResponse("Invalid JSON body", 400); + } + } + + if (!payload.value) { + return buildErrorResponse("Missing 'value' in request body", 400); + } + + try { + await globalThis.incrementalCache.set(key, payload.value as CacheValue, cacheType); + return buildJsonResponse({ ok: true }, 200); + } catch (e) { + error("Failed to set cache entry", e); + return buildErrorResponse("Failed to set cache entry", 500); + } +} + +async function handleDelete(key: string): Promise { + debug("delete", { key }); + + try { + await globalThis.incrementalCache.delete(key); + return buildJsonResponse({ ok: true }, 200); + } catch (e) { + error("Failed to delete cache entry", e); + return buildErrorResponse("Failed to delete cache entry", 500); + } +} + +/** + * Revalidates cache tags from a streamed JSON request body. + * + * @param body Stream containing the tags to revalidate. + * @return The cache operation response. + * @throws When reading the request stream fails. + */ +async function handleRevalidateTags(body?: ReadableStream): Promise { + debug("revalidateTags"); + const bodyText = body ? await fromReadableStream(body) : ""; + + if (bodyText.length === 0) { + return buildErrorResponse("Missing request body", 400); + } + + let tags: string[]; + try { + const parsed = JSON.parse(bodyText); + tags = Array.isArray(parsed.tags) ? parsed.tags : []; + } catch { + return buildErrorResponse("Invalid JSON body", 400); + } + + if (tags.length === 0) { + return buildErrorResponse("Missing 'tags' array in request body", 400); + } + + try { + await runWithOpenNextRequestContext({ isISRRevalidation: false }, async () => { + if (globalThis.tagCache.mode === "nextMode") { + const paths = (await globalThis.tagCache.getPathsByTags?.(tags)) ?? []; + + await writeTags(tags); + if (paths.length > 0) { + await globalThis.cdnInvalidationHandler.invalidatePaths( + paths.map((path) => ({ + initialPath: path, + rawPath: path, + resolvedRoutes: [ + { + route: path, + type: "app", + isFallback: false, + }, + ], + })) + ); + } + return; + } + + for (const tag of tags) { + debug("revalidateTag", tag); + const paths = await globalThis.tagCache.getByTag(tag); + debug("Items", paths); + const toInsert = paths.map((path) => ({ + path, + tag, + })); + + if (tag.startsWith(SOFT_TAG_PREFIX)) { + for (const path of paths) { + const _tags = await globalThis.tagCache.getByPath(path); + const hardTags = _tags.filter((t) => !t.startsWith(SOFT_TAG_PREFIX)); + for (const hardTag of hardTags) { + const _paths = await globalThis.tagCache.getByTag(hardTag); + debug({ hardTag, _paths }); + toInsert.push( + ..._paths.map((path) => ({ + path, + tag: hardTag, + })) + ); + } + } + } + + await writeTags(toInsert); + + const uniquePaths = Array.from( + new Set(toInsert.filter((t) => t.tag.startsWith(SOFT_TAG_PREFIX)).map((t) => `/${t.path}`)) + ); + if (uniquePaths.length > 0) { + await globalThis.cdnInvalidationHandler.invalidatePaths( + uniquePaths.map((path) => ({ + initialPath: path, + rawPath: path, + resolvedRoutes: [ + { + route: path, + type: "app", + isFallback: false, + }, + ], + })) + ); + } + } + }); + + return buildJsonResponse({ revalidated: tags }, 200); + } catch (e) { + error("Failed to revalidate tags", e); + return buildErrorResponse("Failed to revalidate tags", 500); + } +} + +///////////////////////////// +// Cache GET response builder // +///////////////////////////// + +function buildCacheGetResponse(result: WithLastModified>): InternalResult { + const value = result.value!; + + const headers: Record = { + "x-opennext-cache-found": "true", + "Cache-Control": "no-store", + }; + + if (result.lastModified !== undefined) { + headers["x-opennext-cache-last-modified"] = String(result.lastModified); + } + if (result.shouldBypassTagCache) { + headers["x-opennext-cache-should-bypass"] = "true"; + } + + if ("kind" in value && value.kind === "FETCH") { + return buildFetchResponse(value as CachedFetchValue, headers); + } + + if ("type" in value) { + return buildCachedFileResponse(value as CachedFile, headers); + } + + return buildComposableResponse(value as StoredComposableCacheEntry, headers); +} + +function buildFetchResponse( + value: CacheValue<"fetch">, + headers: Record +): InternalResult { + headers["x-opennext-cache-type"] = "fetch"; + headers["x-opennext-cache-fetch-kind"] = "FETCH"; + headers["x-opennext-cache-fetch-data-url"] = value.data.url; + + if (value.revalidate !== undefined) { + headers["x-opennext-cache-revalidate"] = String(value.revalidate); + } + + if (value.data.status !== undefined) { + headers["x-opennext-cache-fetch-data-status"] = String(value.data.status); + } + if (value.data.tags) { + headers["x-opennext-cache-fetch-data-tags"] = JSON.stringify(value.data.tags); + } + if (value.tags) { + headers["x-opennext-cache-fetch-tags"] = JSON.stringify(value.tags); + } + + for (const [key, val] of Object.entries(value.data.headers)) { + headers[`x-opennext-cache-header-${key}`] = val; + } + + const body = value.data.body; + return { + type: "core", + statusCode: 200, + body: toReadableStream(body), + isBase64Encoded: false, + headers: { ...headers, "Content-Type": "text/plain" }, + }; +} + +function buildCachedFileResponse( + value: CacheValue<"cache">, + headers: Record +): InternalResult { + headers["x-opennext-cache-type"] = "cache"; + headers["x-opennext-cache-sub-type"] = value.type; + + if (value.revalidate !== undefined) { + headers["x-opennext-cache-revalidate"] = String(value.revalidate); + } + + if (value.meta?.status !== undefined) { + headers["x-opennext-cache-meta-status"] = String(value.meta.status); + } + if (value.meta?.postponed !== undefined) { + headers["x-opennext-cache-meta-postponed"] = value.meta.postponed; + } + if (value.meta?.headers) { + for (const [key, val] of Object.entries(value.meta.headers)) { + if (val !== undefined) { + headers[`x-opennext-cache-header-${key}`] = val; + } + } + } + + switch (value.type) { + case "route": { + return { + type: "core", + statusCode: 200, + body: toReadableStream(value.body), + isBase64Encoded: false, + headers: { ...headers, "Content-Type": "text/plain" }, + }; + } + case "page": { + return { + type: "core", + statusCode: 200, + body: toReadableStream(JSON.stringify({ json: value.json, html: value.html })), + isBase64Encoded: false, + headers: { ...headers, "Content-Type": "application/json" }, + }; + } + case "app": { + return { + type: "core", + statusCode: 200, + body: toReadableStream( + JSON.stringify({ + html: value.html, + rsc: value.rsc, + segmentData: value.segmentData, + }) + ), + isBase64Encoded: false, + headers: { ...headers, "Content-Type": "application/json" }, + }; + } + case "redirect": { + return { + type: "core", + statusCode: 200, + body: toReadableStream(JSON.stringify(value.props ?? {})), + isBase64Encoded: false, + headers: { ...headers, "Content-Type": "application/json" }, + }; + } + } +} + +function buildComposableResponse( + value: StoredComposableCacheEntry, + headers: Record +): InternalResult { + headers["x-opennext-cache-type"] = "composable"; + headers["x-opennext-cache-composable-stale"] = String(value.stale); + headers["x-opennext-cache-composable-expire"] = String(value.expire); + headers["x-opennext-cache-composable-timestamp"] = String(value.timestamp); + headers["x-opennext-cache-composable-revalidate"] = String(value.revalidate); + headers["x-opennext-cache-composable-tags"] = JSON.stringify(value.tags); + + return { + type: "core", + statusCode: 200, + body: toReadableStream(value.value), + isBase64Encoded: false, + headers: { ...headers, "Content-Type": "text/plain" }, + }; +} + +////////////////////////// +// Response builders // +////////////////////////// + +function buildJsonResponse(data: unknown, statusCode: number): InternalResult { + const body = JSON.stringify(data); + return { + type: "core", + statusCode, + body: toReadableStream(body), + isBase64Encoded: false, + headers: { + "Content-Type": "application/json", + "Cache-Control": "no-store", + }, + }; +} + +function buildErrorResponse(message: string, statusCode: number): InternalResult { + debug(message, statusCode); + const body = JSON.stringify({ error: message }); + return { + type: "core", + statusCode, + body: toReadableStream(body), + isBase64Encoded: false, + headers: { + "Content-Type": "application/json", + "Cache-Control": "no-store", + }, + }; +} diff --git a/packages/core/src/build/createCacheBundle.spec.ts b/packages/core/src/build/createCacheBundle.spec.ts new file mode 100644 index 00000000..a5f7b48c --- /dev/null +++ b/packages/core/src/build/createCacheBundle.spec.ts @@ -0,0 +1,51 @@ +import path from "node:path"; + +import { beforeEach, describe, expect, test, vi } from "vitest"; + +vi.mock("node:fs", () => ({ + default: { mkdirSync: vi.fn() }, +})); + +vi.mock("../plugins/resolve.js", () => ({ + openNextResolvePlugin: vi.fn(() => ({ + name: "opennext-resolve", + setup: vi.fn(), + })), +})); + +vi.mock("./helper.js", () => ({ + copyOpenNextConfig: vi.fn(), + esbuildAsync: vi.fn(), +})); + +import { createCacheBundle } from "./createCacheBundle.js"; +import * as buildHelper from "./helper.js"; + +describe("createCacheBundle", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test("builds wrapped and raw cache handler entrypoints", async () => { + const options = { + buildDir: "/app/.open-next/.build", + config: { default: {} }, + openNextDistDir: "/core/dist", + outputDir: "/app/.open-next", + }; + + await createCacheBundle(options as never); + + expect(buildHelper.esbuildAsync).toHaveBeenCalledWith( + expect.objectContaining({ + entryPoints: { + index: path.join(options.openNextDistDir, "adapters", "cache-adapter.js"), + handler: path.join(options.openNextDistDir, "adapters", "cache-handler.js"), + }, + outdir: path.join(options.outputDir, "cache-function"), + outExtension: { ".js": ".mjs" }, + }), + options + ); + }); +}); diff --git a/packages/core/src/build/createCacheBundle.ts b/packages/core/src/build/createCacheBundle.ts index e61b9b4f..5b9c099c 100644 --- a/packages/core/src/build/createCacheBundle.ts +++ b/packages/core/src/build/createCacheBundle.ts @@ -7,6 +7,13 @@ import { openNextResolvePlugin } from "../plugins/resolve.js"; import * as buildHelper from "./helper.js"; +/** + * Builds the deployable and in-process cache handler entrypoints. + * + * @param options Normalized build options. + * @param defaultOverrides Adapter defaults for the cache function. + * @return A promise that resolves when both entrypoints are bundled. + */ export async function createCacheBundle( options: buildHelper.BuildOptions, defaultOverrides?: DefaultOverrides @@ -22,12 +29,16 @@ export async function createCacheBundle( // Copy open-next.config.mjs into the bundle buildHelper.copyOpenNextConfig(options.buildDir, outputPath); - // Build Lambda code + // Build the wrapped deployment entrypoint and raw in-process handler. await buildHelper.esbuildAsync( { external: ["next"], - entryPoints: [path.join(options.openNextDistDir, "adapters", "cache-adapter.js")], - outfile: path.join(outputPath, "index.mjs"), + entryPoints: { + index: path.join(options.openNextDistDir, "adapters", "cache-adapter.js"), + handler: path.join(options.openNextDistDir, "adapters", "cache-handler.js"), + }, + outdir: outputPath, + outExtension: { ".js": ".mjs" }, plugins: [ openNextResolvePlugin({ fnName: "cache", diff --git a/packages/core/src/overrides/cache/local.ts b/packages/core/src/overrides/cache/local.ts index f1394bee..3802efae 100644 --- a/packages/core/src/overrides/cache/local.ts +++ b/packages/core/src/overrides/cache/local.ts @@ -8,9 +8,15 @@ import { fromReadableStream, toReadableStream } from "@/utils/stream"; let handler: ((event: InternalEvent) => Promise) | null = null; +/** + * Loads the raw in-process cache handler. + * + * @return The cached internal cache handler. + * @throws When the cache handler bundle cannot be imported. + */ async function getHandler() { if (!handler) { - const cacheHandlerPath = path.join(getMonorepoRelativePath(), "cache-function/index.mjs"); + const cacheHandlerPath = path.join(getMonorepoRelativePath(), "cache-function/handler.mjs"); const m = await import(cacheHandlerPath); handler = m.handler; } diff --git a/packages/tests-unit/tests/adapters/cache-adapter.test.ts b/packages/tests-unit/tests/adapters/cache-adapter.test.ts index f32e60c2..997ee7cf 100644 --- a/packages/tests-unit/tests/adapters/cache-adapter.test.ts +++ b/packages/tests-unit/tests/adapters/cache-adapter.test.ts @@ -32,7 +32,7 @@ vi.mock("@opennextjs/core/utils/cache", () => ({ writeTags: vi.fn(), })); -import { handler } from "@opennextjs/core/adapters/cache-adapter"; +import { handler } from "@opennextjs/core/adapters/cache-handler"; function createEvent(method: string, rawPath: string, body?: string): InternalEvent { return { diff --git a/packages/tests-unit/tests/overrides/cache/local.test.ts b/packages/tests-unit/tests/overrides/cache/local.test.ts index 06646e68..f50b6cc9 100644 --- a/packages/tests-unit/tests/overrides/cache/local.test.ts +++ b/packages/tests-unit/tests/overrides/cache/local.test.ts @@ -9,7 +9,7 @@ vi.mock("@opennextjs/core/utils/normalize-path", () => ({ const mockHandler = vi.fn(); -vi.mock("/mock/root/cache-function/index.mjs", () => ({ +vi.mock("/mock/root/cache-function/handler.mjs", () => ({ handler: mockHandler, })); From 3dccab95ead565fb014f70d05cde5d0e96d8f3a9 Mon Sep 17 00:00:00 2001 From: Victor Berchet Date: Fri, 18 Sep 2026 09:51:57 +0200 Subject: [PATCH 21/28] fix(core): preserve legacy cache provider overrides Apply default-function incremental cache, tag cache, and CDN invalidation overrides when the dedicated cacheHandler does not specify replacements. This keeps existing configurations working when they opt into the new cache transport. Maintain explicit precedence: cacheHandler settings override legacy default-function providers, which in turn override adapter and core defaults. Wrapper and converter settings remain scoped to the cache handler itself. Tests cover both legacy fallback and cacheHandler precedence. Verified with the core type-check and the cache bundle test suite. --- .../core/src/build/createCacheBundle.spec.ts | 71 ++++++++++++++++++- packages/core/src/build/createCacheBundle.ts | 7 +- 2 files changed, 72 insertions(+), 6 deletions(-) diff --git a/packages/core/src/build/createCacheBundle.spec.ts b/packages/core/src/build/createCacheBundle.spec.ts index a5f7b48c..8b9d07d4 100644 --- a/packages/core/src/build/createCacheBundle.spec.ts +++ b/packages/core/src/build/createCacheBundle.spec.ts @@ -2,6 +2,11 @@ import path from "node:path"; import { beforeEach, describe, expect, test, vi } from "vitest"; +import { openNextResolvePlugin } from "../plugins/resolve.js"; + +import { createCacheBundle } from "./createCacheBundle.js"; +import * as buildHelper from "./helper.js"; + vi.mock("node:fs", () => ({ default: { mkdirSync: vi.fn() }, })); @@ -18,9 +23,6 @@ vi.mock("./helper.js", () => ({ esbuildAsync: vi.fn(), })); -import { createCacheBundle } from "./createCacheBundle.js"; -import * as buildHelper from "./helper.js"; - describe("createCacheBundle", () => { beforeEach(() => { vi.clearAllMocks(); @@ -48,4 +50,67 @@ describe("createCacheBundle", () => { options ); }); + + test("falls back to legacy default function cache providers", async () => { + const options = { + buildDir: "/app/.open-next/.build", + config: { + default: { + override: { + incrementalCache: "legacy-incremental", + tagCache: "legacy-tags", + cdnInvalidation: "legacy-cdn", + }, + }, + }, + openNextDistDir: "/core/dist", + outputDir: "/app/.open-next", + }; + + await createCacheBundle(options as never); + + expect(openNextResolvePlugin).toHaveBeenCalledWith( + expect.objectContaining({ + overrides: expect.objectContaining({ + incrementalCache: "legacy-incremental", + tagCache: "legacy-tags", + cdnInvalidation: "legacy-cdn", + }), + }) + ); + }); + + test("prefers cache handler providers over legacy defaults", async () => { + const options = { + buildDir: "/app/.open-next/.build", + config: { + default: { + override: { + incrementalCache: "legacy-incremental", + tagCache: "legacy-tags", + cdnInvalidation: "legacy-cdn", + }, + }, + cacheHandler: { + incrementalCache: "handler-incremental", + tagCache: "handler-tags", + cdnInvalidation: "handler-cdn", + }, + }, + openNextDistDir: "/core/dist", + outputDir: "/app/.open-next", + }; + + await createCacheBundle(options as never); + + expect(openNextResolvePlugin).toHaveBeenCalledWith( + expect.objectContaining({ + overrides: expect.objectContaining({ + incrementalCache: "handler-incremental", + tagCache: "handler-tags", + cdnInvalidation: "handler-cdn", + }), + }) + ); + }); }); diff --git a/packages/core/src/build/createCacheBundle.ts b/packages/core/src/build/createCacheBundle.ts index 5b9c099c..375cc4b7 100644 --- a/packages/core/src/build/createCacheBundle.ts +++ b/packages/core/src/build/createCacheBundle.ts @@ -45,9 +45,10 @@ export async function createCacheBundle( overrides: { converter: config.cacheHandler?.override?.converter, wrapper: config.cacheHandler?.override?.wrapper, - incrementalCache: config.cacheHandler?.incrementalCache, - tagCache: config.cacheHandler?.tagCache, - cdnInvalidation: config.cacheHandler?.cdnInvalidation, + incrementalCache: + config.cacheHandler?.incrementalCache ?? config.default.override?.incrementalCache, + tagCache: config.cacheHandler?.tagCache ?? config.default.override?.tagCache, + cdnInvalidation: config.cacheHandler?.cdnInvalidation ?? config.default.override?.cdnInvalidation, }, defaultOverrides: { converter: defaultOverrides?.converter ?? "node", From 653c9632a19a84679b5142363caf4a1949691bc8 Mon Sep 17 00:00:00 2001 From: Victor Berchet Date: Fri, 18 Sep 2026 09:52:55 +0200 Subject: [PATCH 22/28] fix(core): surface cache mutation failures Validate cache handler responses for set, delete, and tag-revalidation operations in both fetch and local transports. Reject non-2xx results with operation-specific errors instead of reporting failed mutations as successful. Make fetch tests install a fresh response mock for every test and restore the global afterward, removing their previous ordering dependency. Tests cover successful requests and 500 responses for every mutation. Verified with the core type-check and 35 transport tests. --- packages/core/src/overrides/cache/fetch.ts | 22 ++++++++++-- packages/core/src/overrides/cache/local.ts | 22 ++++++++++-- .../tests/overrides/cache/fetch.test.ts | 34 ++++++++++++++++--- .../tests/overrides/cache/local.test.ts | 24 +++++++++++++ 4 files changed, 92 insertions(+), 10 deletions(-) diff --git a/packages/core/src/overrides/cache/fetch.ts b/packages/core/src/overrides/cache/fetch.ts index dee2ec18..77c2f954 100644 --- a/packages/core/src/overrides/cache/fetch.ts +++ b/packages/core/src/overrides/cache/fetch.ts @@ -3,6 +3,19 @@ import { parseCacheGetResponse } from "@/utils/cache-get"; const CACHE_URL = process.env.OPEN_NEXT_CACHE_URL ?? ""; +/** + * Rejects unsuccessful cache mutation responses. + * + * @param response Cache handler response. + * @param operation Mutation being performed. + * @throws When the cache handler returns a non-success status. + */ +function ensureResponseOk(response: Pick, operation: string): void { + if (!response.ok) { + throw new Error(`Failed to ${operation}: cache handler returned ${response.status}`); + } +} + const fetchCache: Cache = { name: "fetch-cache", get: async (key, cacheType) => { @@ -24,22 +37,25 @@ const fetchCache: Cache = { // writing without it would store the entry where `get` does not look for it. const queryString = cacheType ? `?type=${cacheType}` : ""; const url = `${CACHE_URL}/cache/${encodeURIComponent(key)}${queryString}`; - await fetch(url, { + const response = await fetch(url, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ value }), }); + ensureResponseOk(response, "set cache entry"); }, delete: async (key) => { const url = `${CACHE_URL}/cache/${encodeURIComponent(key)}`; - await fetch(url, { method: "DELETE" }); + const response = await fetch(url, { method: "DELETE" }); + ensureResponseOk(response, "delete cache entry"); }, revalidateTags: async (tags) => { - await fetch(`${CACHE_URL}/cache/revalidate-tags`, { + const response = await fetch(`${CACHE_URL}/cache/revalidate-tags`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ tags }), }); + ensureResponseOk(response, "revalidate cache tags"); }, }; diff --git a/packages/core/src/overrides/cache/local.ts b/packages/core/src/overrides/cache/local.ts index 3802efae..167e2751 100644 --- a/packages/core/src/overrides/cache/local.ts +++ b/packages/core/src/overrides/cache/local.ts @@ -23,6 +23,19 @@ async function getHandler() { return handler; } +/** + * Rejects unsuccessful in-process cache mutations. + * + * @param result Internal cache handler response. + * @param operation Mutation being performed. + * @throws When the cache handler returns a non-success status. + */ +function ensureResultOk(result: InternalResult, operation: string): void { + if (result.statusCode < 200 || result.statusCode >= 300) { + throw new Error(`Failed to ${operation}: cache handler returned ${result.statusCode}`); + } +} + const localCache: Cache = { name: "local-cache", get: async (key, cacheType) => { @@ -63,7 +76,8 @@ const localCache: Cache = { remoteAddress: "127.0.0.1", body: toReadableStream(JSON.stringify({ value })), }; - await h(event); + const result = await h(event); + ensureResultOk(result, "set cache entry"); }, delete: async (key) => { const h = (await getHandler())!; @@ -79,7 +93,8 @@ const localCache: Cache = { cookies: {}, remoteAddress: "127.0.0.1", }; - await h(event); + const result = await h(event); + ensureResultOk(result, "delete cache entry"); }, revalidateTags: async (tags) => { const h = (await getHandler())!; @@ -95,7 +110,8 @@ const localCache: Cache = { remoteAddress: "127.0.0.1", body: toReadableStream(JSON.stringify({ tags })), }; - await h(event); + const result = await h(event); + ensureResultOk(result, "revalidate cache tags"); }, }; diff --git a/packages/tests-unit/tests/overrides/cache/fetch.test.ts b/packages/tests-unit/tests/overrides/cache/fetch.test.ts index 0a18ce8b..8da313b6 100644 --- a/packages/tests-unit/tests/overrides/cache/fetch.test.ts +++ b/packages/tests-unit/tests/overrides/cache/fetch.test.ts @@ -11,22 +11,24 @@ function toHeadersMap(headers: Record): Map { } function mockFetch(resp: { headers: Record; body: string; status?: number }) { + const status = resp.status ?? 200; const response = { - ok: true, - status: resp.status ?? 200, + ok: status >= 200 && status < 300, + status, text: vi.fn().mockResolvedValue(resp.body), headers: toHeadersMap(resp.headers), }; - global.fetch = vi.fn().mockResolvedValue(response); + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(response)); } describe("fetch cache", () => { beforeEach(() => { vi.clearAllMocks(); + mockFetch({ headers: {}, body: "" }); }); afterEach(() => { - vi.restoreAllMocks(); + vi.unstubAllGlobals(); }); it("should have name 'fetch-cache'", () => { @@ -175,6 +177,14 @@ describe("fetch cache", () => { expect(global.fetch).toHaveBeenCalledWith("/cache/key?type=composable", expect.any(Object)); }); + + it("should reject an unsuccessful response", async () => { + mockFetch({ headers: {}, body: "", status: 500 }); + + await expect(fetchCache.set("key", {})).rejects.toThrow( + "Failed to set cache entry: cache handler returned 500" + ); + }); }); describe("delete", () => { @@ -183,6 +193,14 @@ describe("fetch cache", () => { expect(global.fetch).toHaveBeenCalledWith("/cache/key", { method: "DELETE" }); }); + + it("should reject an unsuccessful response", async () => { + mockFetch({ headers: {}, body: "", status: 500 }); + + await expect(fetchCache.delete("key")).rejects.toThrow( + "Failed to delete cache entry: cache handler returned 500" + ); + }); }); describe("revalidateTags", () => { @@ -195,5 +213,13 @@ describe("fetch cache", () => { body: JSON.stringify({ tags: ["tag1", "tag2"] }), }); }); + + it("should reject an unsuccessful response", async () => { + mockFetch({ headers: {}, body: "", status: 500 }); + + await expect(fetchCache.revalidateTags(["tag"])).rejects.toThrow( + "Failed to revalidate cache tags: cache handler returned 500" + ); + }); }); }); diff --git a/packages/tests-unit/tests/overrides/cache/local.test.ts b/packages/tests-unit/tests/overrides/cache/local.test.ts index f50b6cc9..45eb1f8a 100644 --- a/packages/tests-unit/tests/overrides/cache/local.test.ts +++ b/packages/tests-unit/tests/overrides/cache/local.test.ts @@ -186,6 +186,14 @@ describe("local cache", () => { const event = mockHandler.mock.calls[0][0]; expect(event.query).toEqual({ type: "composable" }); }); + + it("should reject an unsuccessful response", async () => { + mockHandler.mockResolvedValue(createMockResult({ statusCode: 500 })); + + await expect(localCache.set("key", {})).rejects.toThrow( + "Failed to set cache entry: cache handler returned 500" + ); + }); }); describe("delete", () => { @@ -198,6 +206,14 @@ describe("local cache", () => { expect(event.method).toBe("DELETE"); expect(event.rawPath).toBe("/cache/key"); }); + + it("should reject an unsuccessful response", async () => { + mockHandler.mockResolvedValue(createMockResult({ statusCode: 500 })); + + await expect(localCache.delete("key")).rejects.toThrow( + "Failed to delete cache entry: cache handler returned 500" + ); + }); }); describe("revalidateTags", () => { @@ -211,6 +227,14 @@ describe("local cache", () => { expect(event.rawPath).toBe("/cache/revalidate-tags"); expect(await fromReadableStream(event.body)).toBe(JSON.stringify({ tags: ["tag1", "tag2"] })); }); + + it("should reject an unsuccessful response", async () => { + mockHandler.mockResolvedValue(createMockResult({ statusCode: 500 })); + + await expect(localCache.revalidateTags(["tag"])).rejects.toThrow( + "Failed to revalidate cache tags: cache handler returned 500" + ); + }); }); describe("handler caching", () => { From f3db8a4824ae73b8415ba7d5a1747dad141d0b47 Mon Sep 17 00:00:00 2001 From: Victor Berchet Date: Fri, 18 Sep 2026 09:53:41 +0200 Subject: [PATCH 23/28] fix(core): preserve permanent cache revalidation Parse the literal false revalidation header as a boolean rather than feeding it through numeric conversion. Keep invalid values undefined and preserve existing numeric interval handling. Apply the parser to both fetch and cached-file responses so permanent entries retain their semantics across local and HTTP cache transports. Tests cover false values for both response types. Verified with the core type-check and 59 parser and transport tests. --- packages/core/src/utils/cache-get.ts | 16 +++++++++-- .../tests-unit/tests/utils/cache-get.test.ts | 28 +++++++++++++++++++ 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/packages/core/src/utils/cache-get.ts b/packages/core/src/utils/cache-get.ts index 572a9a42..975c22b2 100644 --- a/packages/core/src/utils/cache-get.ts +++ b/packages/core/src/utils/cache-get.ts @@ -22,6 +22,18 @@ function getHeaderNumber(headers: HeadersMap, name: string): number | undefined return Number.isNaN(n) ? undefined : n; } +/** + * Parses a cache revalidation header. + * + * @param headers Cache response headers. + * @param name Revalidation header name. + * @return A numeric interval, false for permanent entries, or undefined for invalid values. + */ +function getHeaderRevalidate(headers: HeadersMap, name: string): number | false | undefined { + const value = getHeaderValue(headers, name); + return value === "false" ? false : getHeaderNumber(headers, name); +} + function collectPrefixedHeaders(headers: HeadersMap, prefix: string): Record { const result: Record = {}; for (const [key, value] of Object.entries(headers)) { @@ -99,7 +111,7 @@ function reconstructFetch(headers: HeadersMap, bodyText: string, base: Base) { const dataTags = dataTagsStr ? JSON.parse(dataTagsStr) : undefined; const fetchTagsStr = getHeaderValue(headers, "x-opennext-cache-fetch-tags"); const fetchTags = fetchTagsStr ? JSON.parse(fetchTagsStr) : undefined; - const revalidate = getHeaderNumber(headers, "x-opennext-cache-revalidate"); + const revalidate = getHeaderRevalidate(headers, "x-opennext-cache-revalidate"); const dataHeaders = collectPrefixedHeaders(headers, "x-opennext-cache-header-") as Record; @@ -123,7 +135,7 @@ function reconstructCachedFile(headers: HeadersMap, bodyText: string, base: Base const subType = getHeaderValue(headers, "x-opennext-cache-sub-type"); const metaStatus = getHeaderNumber(headers, "x-opennext-cache-meta-status"); const metaPostponed = getHeaderValue(headers, "x-opennext-cache-meta-postponed"); - const revalidate = getHeaderNumber(headers, "x-opennext-cache-revalidate"); + const revalidate = getHeaderRevalidate(headers, "x-opennext-cache-revalidate"); const metaHeaders = collectPrefixedHeaders(headers, "x-opennext-cache-header-"); const hasMetaHeaders = Object.keys(metaHeaders).length > 0; diff --git a/packages/tests-unit/tests/utils/cache-get.test.ts b/packages/tests-unit/tests/utils/cache-get.test.ts index d15c65f2..b7fe39c2 100644 --- a/packages/tests-unit/tests/utils/cache-get.test.ts +++ b/packages/tests-unit/tests/utils/cache-get.test.ts @@ -133,6 +133,20 @@ describe("parseCacheGetResponse", () => { }); }); + it("should preserve a false revalidation value", () => { + const result = parseCacheGetResponse( + { + "x-opennext-cache-found": "true", + "x-opennext-cache-type": "fetch", + "x-opennext-cache-fetch-kind": "FETCH", + "x-opennext-cache-revalidate": "false", + }, + "body" + ); + + expect(result?.value).toMatchObject({ revalidate: false }); + }); + it("should collect prefixed headers", () => { const headers = { "x-opennext-cache-found": "true", @@ -186,6 +200,20 @@ describe("parseCacheGetResponse", () => { }); }); + it("should preserve a false revalidation value", () => { + const result = parseCacheGetResponse( + { + "x-opennext-cache-found": "true", + "x-opennext-cache-type": "cache", + "x-opennext-cache-sub-type": "route", + "x-opennext-cache-revalidate": "false", + }, + "route body" + ); + + expect(result?.value).toMatchObject({ revalidate: false }); + }); + it("should reconstruct a page cache entry", () => { const headers = { "x-opennext-cache-found": "true", From 5ec68564b1ff3ef42b7f42e0e9fe3ae8b74ca69d Mon Sep 17 00:00:00 2001 From: Victor Berchet Date: Fri, 18 Sep 2026 09:54:54 +0200 Subject: [PATCH 24/28] test: keep cache integration imports lintable Move the production imports ahead of Vitest mock declarations in the two new integration tests. Vitest continues to hoist the mocks while the files now satisfy the repository import ordering rule. Tests: repository lint; focused cache handler and AWS adapter tests. --- packages/tests-unit/tests/adapters/cache-adapter.test.ts | 3 +-- packages/tests-unit/tests/build/aws-adapter.test.ts | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/tests-unit/tests/adapters/cache-adapter.test.ts b/packages/tests-unit/tests/adapters/cache-adapter.test.ts index 997ee7cf..b3c44752 100644 --- a/packages/tests-unit/tests/adapters/cache-adapter.test.ts +++ b/packages/tests-unit/tests/adapters/cache-adapter.test.ts @@ -1,3 +1,4 @@ +import { handler } from "@opennextjs/core/adapters/cache-handler"; import type { InternalEvent } from "@opennextjs/core/types/open-next"; import { toReadableStream } from "@opennextjs/core/utils/stream"; import { beforeEach, describe, expect, test, vi } from "vitest"; @@ -32,8 +33,6 @@ vi.mock("@opennextjs/core/utils/cache", () => ({ writeTags: vi.fn(), })); -import { handler } from "@opennextjs/core/adapters/cache-handler"; - function createEvent(method: string, rawPath: string, body?: string): InternalEvent { return { type: "core", diff --git a/packages/tests-unit/tests/build/aws-adapter.test.ts b/packages/tests-unit/tests/build/aws-adapter.test.ts index 078574bc..c7199863 100644 --- a/packages/tests-unit/tests/build/aws-adapter.test.ts +++ b/packages/tests-unit/tests/build/aws-adapter.test.ts @@ -1,3 +1,4 @@ +import adapterCallback from "@opennextjs/aws/adapter.js"; import { describe, expect, test, vi } from "vitest"; const buildAdapter = vi.hoisted(() => vi.fn((callback) => callback)); @@ -6,8 +7,6 @@ vi.mock("@opennextjs/core/build/adapter.js", () => ({ buildAdapter, })); -import adapterCallback from "@opennextjs/aws/adapter.js"; - describe("AWS adapter cache defaults", () => { test("builds the cache handler as an AWS Lambda backed by shared caches", () => { const options = ( From 3395f735aa9759ce60c547dc3cc84dc6c62cb9fa Mon Sep 17 00:00:00 2001 From: Victor Berchet Date: Fri, 18 Sep 2026 10:01:38 +0200 Subject: [PATCH 25/28] fix(core): preserve cache request context storage Reuse an existing OpenNext AsyncLocalStorage instance when the raw cache handler is imported in-process. This prevents the local cache transport from replacing an active request context and losing pending work or tag-write state. Add a regression test that resets the module graph, imports the raw handler with pre-existing request storage, and verifies the same instance remains installed. Tests: core type-check; repository lint; focused cache adapter tests. --- packages/core/src/adapters/cache-handler.ts | 2 +- .../tests-unit/tests/adapters/cache-adapter.test.ts | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/core/src/adapters/cache-handler.ts b/packages/core/src/adapters/cache-handler.ts index 05f13d45..4b430911 100644 --- a/packages/core/src/adapters/cache-handler.ts +++ b/packages/core/src/adapters/cache-handler.ts @@ -19,7 +19,7 @@ import { fromReadableStream, toReadableStream } from "../utils/stream.js"; import { debug, error } from "./logger.js"; -globalThis.__openNextAls = new AsyncLocalStorage(); +globalThis.__openNextAls ??= new AsyncLocalStorage(); const SOFT_TAG_PREFIX = "_N_T_/"; diff --git a/packages/tests-unit/tests/adapters/cache-adapter.test.ts b/packages/tests-unit/tests/adapters/cache-adapter.test.ts index b3c44752..95e59f68 100644 --- a/packages/tests-unit/tests/adapters/cache-adapter.test.ts +++ b/packages/tests-unit/tests/adapters/cache-adapter.test.ts @@ -1,3 +1,5 @@ +import { AsyncLocalStorage } from "node:async_hooks"; + import { handler } from "@opennextjs/core/adapters/cache-handler"; import type { InternalEvent } from "@opennextjs/core/types/open-next"; import { toReadableStream } from "@opennextjs/core/utils/stream"; @@ -53,6 +55,16 @@ describe("cache adapter stream bodies", () => { globalThis.openNextConfig = { default: {} }; }); + test("preserves existing request context storage", async () => { + const requestStorage = new AsyncLocalStorage(); + globalThis.__openNextAls = requestStorage; + vi.resetModules(); + + await import("@opennextjs/core/adapters/cache-handler"); + + expect(globalThis.__openNextAls).toBe(requestStorage); + }); + test("stores a value from a streamed request body", async () => { const value = { type: "route", body: "content" }; const result = await handler(createEvent("PUT", "/cache/key", JSON.stringify({ value }))); From 9a31b2db3c0b4ef0829409941697cf19467b97aa Mon Sep 17 00:00:00 2001 From: Victor Berchet Date: Fri, 18 Sep 2026 12:08:25 +0200 Subject: [PATCH 26/28] fix(core): validate cache revalidation tags Reject revalidation payloads containing non-string tag values before they reach tag cache providers. Add coverage confirming malformed arrays return a 400 response without triggering tag or CDN operations. --- packages/core/src/adapters/cache-handler.ts | 8 ++++++-- .../tests-unit/tests/adapters/cache-adapter.test.ts | 11 +++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/packages/core/src/adapters/cache-handler.ts b/packages/core/src/adapters/cache-handler.ts index 4b430911..79b2013c 100644 --- a/packages/core/src/adapters/cache-handler.ts +++ b/packages/core/src/adapters/cache-handler.ts @@ -208,8 +208,12 @@ async function handleRevalidateTags(body?: ReadableStream): Promise< let tags: string[]; try { - const parsed = JSON.parse(bodyText); - tags = Array.isArray(parsed.tags) ? parsed.tags : []; + const parsed = JSON.parse(bodyText) as { tags?: unknown }; + tags = + Array.isArray(parsed.tags) && + parsed.tags.every((tag: unknown): tag is string => typeof tag === "string") + ? parsed.tags + : []; } catch { return buildErrorResponse("Invalid JSON body", 400); } diff --git a/packages/tests-unit/tests/adapters/cache-adapter.test.ts b/packages/tests-unit/tests/adapters/cache-adapter.test.ts index 95e59f68..4780e7ad 100644 --- a/packages/tests-unit/tests/adapters/cache-adapter.test.ts +++ b/packages/tests-unit/tests/adapters/cache-adapter.test.ts @@ -81,4 +81,15 @@ describe("cache adapter stream bodies", () => { expect(result.statusCode).toBe(200); expect(mocks.tagCache.getPathsByTags).toHaveBeenCalledWith(["one", "two"]); }); + + test("rejects non-string tags", async () => { + const result = await handler( + createEvent("POST", "/cache/revalidate-tags", JSON.stringify({ tags: ["one", 2] })) + ); + + expect(result.statusCode).toBe(400); + expect(mocks.tagCache.getPathsByTags).not.toHaveBeenCalled(); + expect(mocks.tagCache.writeTags).not.toHaveBeenCalled(); + expect(mocks.cdnInvalidationHandler.invalidatePaths).not.toHaveBeenCalled(); + }); }); From 76e2c649911d7a9e1878e2c7eeb385c63ca396d7 Mon Sep 17 00:00:00 2001 From: Victor Berchet Date: Fri, 18 Sep 2026 12:09:40 +0200 Subject: [PATCH 27/28] fix(core): bundle middleware cache overrides Forward configured cache transports and adapter cache defaults to the resolve plugin for Node and edge middleware bundles. Add coverage proving the Node middleware builder includes both override sources. --- .../core/src/build/edge/createEdgeBundle.ts | 2 ++ .../middleware/buildNodeMiddleware.spec.ts | 20 +++++++++++++++++++ .../build/middleware/buildNodeMiddleware.ts | 2 ++ 3 files changed, 24 insertions(+) diff --git a/packages/core/src/build/edge/createEdgeBundle.ts b/packages/core/src/build/edge/createEdgeBundle.ts index 9cd63616..2e090ccf 100644 --- a/packages/core/src/build/edge/createEdgeBundle.ts +++ b/packages/core/src/build/edge/createEdgeBundle.ts @@ -79,6 +79,7 @@ export async function buildEdgeBundle({ converter: override("converter"), tagCache: override("tagCache"), incrementalCache: override("incrementalCache"), + cache: override("cache"), queue: override("queue"), originResolver: override("originResolver"), proxyExternalRequest: override("proxyExternalRequest"), @@ -89,6 +90,7 @@ export async function buildEdgeBundle({ tagCache: defaultOverrides?.tagCache ?? "@opennextjs/core/overrides/tagCache/dummy.js", incrementalCache: defaultOverrides?.incrementalCache ?? "@opennextjs/core/overrides/incrementalCache/dummy.js", + cache: defaultOverrides?.cache ?? "@opennextjs/core/overrides/cache/dummy.js", queue: defaultOverrides?.queue ?? "@opennextjs/core/overrides/queue/direct.js", originResolver: defaultOverrides?.originResolver ?? "@opennextjs/core/overrides/originResolver/pattern-env.js", diff --git a/packages/core/src/build/middleware/buildNodeMiddleware.spec.ts b/packages/core/src/build/middleware/buildNodeMiddleware.spec.ts index 060590db..3cdba4bc 100644 --- a/packages/core/src/build/middleware/buildNodeMiddleware.spec.ts +++ b/packages/core/src/build/middleware/buildNodeMiddleware.spec.ts @@ -55,6 +55,7 @@ vi.mock("node:fs", () => ({ import fs from "node:fs"; +import { openNextResolvePlugin } from "../../plugins/resolve.js"; import type { NextAdapterOutputs } from "../../types/adapter.js"; import type { BuildOptions } from "../helper.js"; import * as buildHelper from "../helper.js"; @@ -221,4 +222,23 @@ describe("buildExternalNodeMiddleware", () => { const esbuildOptions = esbuildCall[0]; expect(esbuildOptions.banner?.js).toContain("// banner for middleware"); }); + + test("forwards cache overrides to the resolve plugin", async () => { + const options = createMockBuildOpts({ + config: { + default: {}, + dangerous: {}, + middleware: { external: true, override: { cache: "fetch" } }, + } as BuildOptions["config"], + }); + + await buildExternalNodeMiddleware(options, { cache: "platform-cache" }, createMockNextOutputs()); + + expect(openNextResolvePlugin).toHaveBeenCalledWith( + expect.objectContaining({ + overrides: expect.objectContaining({ cache: "fetch" }), + defaultOverrides: expect.objectContaining({ cache: "platform-cache" }), + }) + ); + }); }); diff --git a/packages/core/src/build/middleware/buildNodeMiddleware.ts b/packages/core/src/build/middleware/buildNodeMiddleware.ts index a20aa95f..6fe5e094 100644 --- a/packages/core/src/build/middleware/buildNodeMiddleware.ts +++ b/packages/core/src/build/middleware/buildNodeMiddleware.ts @@ -130,6 +130,7 @@ export async function buildExternalNodeMiddleware( converter: override("converter"), tagCache: override("tagCache"), incrementalCache: override("incrementalCache"), + cache: override("cache"), queue: override("queue"), originResolver: override("originResolver"), proxyExternalRequest: override("proxyExternalRequest"), @@ -140,6 +141,7 @@ export async function buildExternalNodeMiddleware( tagCache: defaultOverrides?.tagCache ?? "@opennextjs/core/overrides/tagCache/dummy.js", incrementalCache: defaultOverrides?.incrementalCache ?? "@opennextjs/core/overrides/incrementalCache/dummy.js", + cache: defaultOverrides?.cache ?? "@opennextjs/core/overrides/cache/dummy.js", queue: defaultOverrides?.queue ?? "@opennextjs/core/overrides/queue/direct.js", originResolver: defaultOverrides?.originResolver ?? "@opennextjs/core/overrides/originResolver/pattern-env.js", From 53d1226355b507c8212e28cd54e36f8b5c1365ba Mon Sep 17 00:00:00 2001 From: Victor Berchet Date: Mon, 21 Sep 2026 09:12:18 +0200 Subject: [PATCH 28/28] Revert "fix(aws): configure the dedicated cache Lambda" This reverts commit f22f401c61ce94dd5b4aa2f574e8a4ede6861212. --- packages/aws/src/adapter.ts | 6 ---- packages/core/src/build/adapter.spec.ts | 18 ------------ .../tests/build/aws-adapter.test.ts | 28 ------------------- 3 files changed, 52 deletions(-) delete mode 100644 packages/tests-unit/tests/build/aws-adapter.test.ts diff --git a/packages/aws/src/adapter.ts b/packages/aws/src/adapter.ts index 4fca803a..7eab334c 100644 --- a/packages/aws/src/adapter.ts +++ b/packages/aws/src/adapter.ts @@ -24,12 +24,6 @@ export default buildAdapter((_config, buildOpts: BuildOptions) => ({ imageLoader: "@opennextjs/aws/overrides/imageLoader/s3.js", }, warmer: { wrapper: "@opennextjs/aws/overrides/wrappers/aws-lambda.js" }, - cache: { - wrapper: "@opennextjs/aws/overrides/wrappers/aws-lambda.js", - converter: "@opennextjs/aws/overrides/converters/aws-apigw-v2.js", - incrementalCache: "@opennextjs/aws/overrides/incrementalCache/s3.js", - tagCache: "@opennextjs/aws/overrides/tagCache/dynamodb.js", - }, tagCache: { wrapper: "@opennextjs/aws/overrides/wrappers/aws-lambda.js", tagCache: "@opennextjs/aws/overrides/tagCache/dynamodb.js", diff --git a/packages/core/src/build/adapter.spec.ts b/packages/core/src/build/adapter.spec.ts index d2c80db0..a9e6b95c 100644 --- a/packages/core/src/build/adapter.spec.ts +++ b/packages/core/src/build/adapter.spec.ts @@ -407,24 +407,6 @@ describe("buildAdapter", () => { expect(createImageOptimizationBundle).not.toHaveBeenCalled(); }); - test("onBuildComplete forwards cache bundle defaults", async () => { - const cacheDefaults = { - wrapper: "platform-cache-wrapper", - incrementalCache: "platform-incremental-cache", - }; - const adapter = buildAdapter(() => ({ - serverBundle, - defaultOverrides: { cache: cacheDefaults }, - })); - - await adapter.modifyConfig({ experimental: {}, images: {} } as BuildCompleteContext["config"], { - phase: "production", - }); - await adapter.onBuildComplete(createMockContext()); - - expect(createCacheBundle).toHaveBeenCalledWith(expect.any(Object), cacheDefaults); - }); - test("onBuildComplete skips warmer when skipWarmer is true", async () => { const adapter = buildAdapter(() => ({ serverBundle, diff --git a/packages/tests-unit/tests/build/aws-adapter.test.ts b/packages/tests-unit/tests/build/aws-adapter.test.ts deleted file mode 100644 index c7199863..00000000 --- a/packages/tests-unit/tests/build/aws-adapter.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import adapterCallback from "@opennextjs/aws/adapter.js"; -import { describe, expect, test, vi } from "vitest"; - -const buildAdapter = vi.hoisted(() => vi.fn((callback) => callback)); - -vi.mock("@opennextjs/core/build/adapter.js", () => ({ - buildAdapter, -})); - -describe("AWS adapter cache defaults", () => { - test("builds the cache handler as an AWS Lambda backed by shared caches", () => { - const options = ( - adapterCallback as unknown as ( - _config: unknown, - buildOptions: unknown - ) => { - defaultOverrides: { cache: Record }; - } - )({}, {}); - - expect(options.defaultOverrides.cache).toEqual({ - wrapper: "@opennextjs/aws/overrides/wrappers/aws-lambda.js", - converter: "@opennextjs/aws/overrides/converters/aws-apigw-v2.js", - incrementalCache: "@opennextjs/aws/overrides/incrementalCache/s3.js", - tagCache: "@opennextjs/aws/overrides/tagCache/dynamodb.js", - }); - }); -});