From fa74e70de9637b83d79ff907769ba56a0d1eab48 Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Sat, 27 Jun 2026 17:17:49 +0200 Subject: [PATCH 01/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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 8b63860bb00485d843a2659694e0fab0d45ac076 Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Sun, 16 Aug 2026 15:04:22 +0200 Subject: [PATCH 17/42] feat(cloudflare): add the OpenNextCache entrypoint Build the OpenNext cache handler function as part of the worker and expose it through a named entrypoint, so the cache can later run behind a service binding - in this worker or in one of its own. - `service-cache.ts` is a `cache` override that speaks the cache handler HTTP API over the `NEXT_CACHE_SERVICE` binding. - `buildCacheFunction` emits the bundle from `beforeServerBundle`, because the worker imports the entrypoint; core's own cache step is skipped. - `runWithCloudflareContext` initialises the Cloudflare context outside of a request, for entrypoints invoked over RPC. The origin is now populated separately, on the first call coming from the fetch handler. - `withoutSelfEntrypointServices` strips the self referencing binding before handing the config to `getPlatformProxy`, which cannot resolve a named entrypoint of the worker it is configuring. - Drop `compile-cache-assets-manifest.ts`, unreferenced. `defineCloudflareConfig` is untouched: the entrypoint is built and exported but nothing routes to it yet. --- .changeset/cloudflare-cache-entrypoint.md | 27 ++++++ create-cloudflare/next/wrangler.jsonc | 8 ++ .../e2e/app-pages-router/wrangler.jsonc | 5 + .../e2e/app-router/package.json | 2 +- .../e2e/app-router/wrangler.jsonc | 5 + .../e2e/experimental/wrangler.jsonc | 5 + .../e2e/pages-router/wrangler.jsonc | 5 + .../overrides/d1-tag-next/wrangler.e2e.jsonc | 7 ++ .../overrides/kv-tag-next/wrangler.e2e.jsonc | 7 ++ .../overrides/memory-queue/wrangler.jsonc | 5 + .../r2-incremental-cache/wrangler.jsonc | 15 +++ .../wrangler.jsonc | 9 +- .../playground16/wrangler.jsonc | 5 + examples-cloudflare/prisma/wrangler.jsonc | 7 ++ .../cloudflare/src/api/cloudflare-context.ts | 51 +++++++--- .../api/overrides/cache/service-cache.spec.ts | 95 +++++++++++++++++++ .../src/api/overrides/cache/service-cache.ts | 90 ++++++++++++++++++ packages/cloudflare/src/cli/adapter.ts | 33 +++++++ .../compile-cache-assets-manifest.ts | 25 ----- .../open-next/compile-cache-entrypoint.ts | 29 ++++++ .../src/cli/commands/utils/helpers.ts | 34 ++++--- .../src/cli/templates/cache-entrypoint.ts | 27 ++++++ packages/cloudflare/src/cli/templates/init.ts | 63 ++++++++---- .../cloudflare/src/cli/templates/worker.ts | 2 + .../cloudflare/src/utils/wrangler-config.ts | 54 +++++++++++ packages/cloudflare/templates/wrangler.jsonc | 8 ++ 26 files changed, 553 insertions(+), 70 deletions(-) create mode 100644 .changeset/cloudflare-cache-entrypoint.md create mode 100644 packages/cloudflare/src/api/overrides/cache/service-cache.spec.ts create mode 100644 packages/cloudflare/src/api/overrides/cache/service-cache.ts delete mode 100644 packages/cloudflare/src/cli/build/open-next/compile-cache-assets-manifest.ts create mode 100644 packages/cloudflare/src/cli/build/open-next/compile-cache-entrypoint.ts create mode 100644 packages/cloudflare/src/cli/templates/cache-entrypoint.ts create mode 100644 packages/cloudflare/src/utils/wrangler-config.ts diff --git a/.changeset/cloudflare-cache-entrypoint.md b/.changeset/cloudflare-cache-entrypoint.md new file mode 100644 index 00000000..2230059d --- /dev/null +++ b/.changeset/cloudflare-cache-entrypoint.md @@ -0,0 +1,27 @@ +--- +"@opennextjs/cloudflare": patch +--- + +Add the `OpenNextCache` named entrypoint + +The build now emits the OpenNext cache handler function and exposes it from the worker as the +`OpenNextCache` named entrypoint, together with a `service-cache` override that reaches it over a +service binding. + +This requires a self referencing service binding in the wrangler configuration: + +```jsonc +"services": [ + { + "binding": "NEXT_CACHE_SERVICE", + "service": "", + "entrypoint": "OpenNextCache" + } +] +``` + +The cache runs in the same worker by default. Pointing the binding at another worker is enough to +run the cache as a service of its own. + +Nothing selects this override yet - `defineCloudflareConfig` still runs the cache in the server +function. diff --git a/create-cloudflare/next/wrangler.jsonc b/create-cloudflare/next/wrangler.jsonc index 1c90bdb4..0819a091 100644 --- a/create-cloudflare/next/wrangler.jsonc +++ b/create-cloudflare/next/wrangler.jsonc @@ -23,6 +23,14 @@ // see https://opennext.js.org/cloudflare/caching "binding": "WORKER_SELF_REFERENCE", "service": "worker_name" + }, + { + // The OpenNext cache runs behind a named entrypoint of this worker. + // The service name must match the worker name. + // see https://opennext.js.org/cloudflare/caching + "binding": "NEXT_CACHE_SERVICE", + "service": "worker_name", + "entrypoint": "OpenNextCache" } ], "observability": { diff --git a/examples-cloudflare/e2e/app-pages-router/wrangler.jsonc b/examples-cloudflare/e2e/app-pages-router/wrangler.jsonc index b636b43a..1ab5ec7f 100644 --- a/examples-cloudflare/e2e/app-pages-router/wrangler.jsonc +++ b/examples-cloudflare/e2e/app-pages-router/wrangler.jsonc @@ -18,6 +18,11 @@ { "binding": "WORKER_SELF_REFERENCE", "service": "app-pages-router" + }, + { + "binding": "NEXT_CACHE_SERVICE", + "service": "app-pages-router", + "entrypoint": "OpenNextCache" } ], "vars": { diff --git a/examples-cloudflare/e2e/app-router/package.json b/examples-cloudflare/e2e/app-router/package.json index d1924fbb..3bab5883 100644 --- a/examples-cloudflare/e2e/app-router/package.json +++ b/examples-cloudflare/e2e/app-router/package.json @@ -11,7 +11,7 @@ "clean": "rm -rf .turbo node_modules .next .open-next", "build:worker:cf": "pnpm opennextjs-cloudflare build", "preview:worker": "pnpm opennextjs-cloudflare preview", - "preview": "pnpm build:worker && pnpm preview:worker", + "preview": "pnpm build:worker:cf && pnpm preview:worker", "e2e:cf": "playwright test -c e2e/playwright.config.ts", "build:worker-turbopack": "pnpm build:worker --openNextConfigPath open-next.turbopack.config.ts", "e2e-turbopack": "playwright test -c e2e/playwright.turbopack.config.ts" diff --git a/examples-cloudflare/e2e/app-router/wrangler.jsonc b/examples-cloudflare/e2e/app-router/wrangler.jsonc index 6ec6cc0d..eaf0fced 100644 --- a/examples-cloudflare/e2e/app-router/wrangler.jsonc +++ b/examples-cloudflare/e2e/app-router/wrangler.jsonc @@ -40,6 +40,11 @@ { "binding": "WORKER_SELF_REFERENCE", "service": "app-router" + }, + { + "binding": "NEXT_CACHE_SERVICE", + "service": "app-router", + "entrypoint": "OpenNextCache" } ], "vars": { diff --git a/examples-cloudflare/e2e/experimental/wrangler.jsonc b/examples-cloudflare/e2e/experimental/wrangler.jsonc index 4348c424..0ca406a9 100644 --- a/examples-cloudflare/e2e/experimental/wrangler.jsonc +++ b/examples-cloudflare/e2e/experimental/wrangler.jsonc @@ -39,6 +39,11 @@ { "binding": "WORKER_SELF_REFERENCE", "service": "experimental" + }, + { + "binding": "NEXT_CACHE_SERVICE", + "service": "experimental", + "entrypoint": "OpenNextCache" } ] } diff --git a/examples-cloudflare/e2e/pages-router/wrangler.jsonc b/examples-cloudflare/e2e/pages-router/wrangler.jsonc index 80f9372b..92f56c40 100644 --- a/examples-cloudflare/e2e/pages-router/wrangler.jsonc +++ b/examples-cloudflare/e2e/pages-router/wrangler.jsonc @@ -18,6 +18,11 @@ { "binding": "WORKER_SELF_REFERENCE", "service": "pages-router" + }, + { + "binding": "NEXT_CACHE_SERVICE", + "service": "pages-router", + "entrypoint": "OpenNextCache" } ], "vars": { diff --git a/examples-cloudflare/overrides/d1-tag-next/wrangler.e2e.jsonc b/examples-cloudflare/overrides/d1-tag-next/wrangler.e2e.jsonc index 1fe491ce..5d53c198 100644 --- a/examples-cloudflare/overrides/d1-tag-next/wrangler.e2e.jsonc +++ b/examples-cloudflare/overrides/d1-tag-next/wrangler.e2e.jsonc @@ -8,6 +8,13 @@ "directory": ".open-next/assets", "binding": "ASSETS", }, + "services": [ + { + "binding": "NEXT_CACHE_SERVICE", + "service": "ssg-app", + "entrypoint": "OpenNextCache", + }, + ], "vars": { "APP_VERSION": "1.2.345", }, diff --git a/examples-cloudflare/overrides/kv-tag-next/wrangler.e2e.jsonc b/examples-cloudflare/overrides/kv-tag-next/wrangler.e2e.jsonc index 0f8b5557..bbc50d20 100644 --- a/examples-cloudflare/overrides/kv-tag-next/wrangler.e2e.jsonc +++ b/examples-cloudflare/overrides/kv-tag-next/wrangler.e2e.jsonc @@ -8,6 +8,13 @@ "directory": ".open-next/assets", "binding": "ASSETS", }, + "services": [ + { + "binding": "NEXT_CACHE_SERVICE", + "service": "ssg-app", + "entrypoint": "OpenNextCache", + }, + ], "vars": { "APP_VERSION": "1.2.345", }, diff --git a/examples-cloudflare/overrides/memory-queue/wrangler.jsonc b/examples-cloudflare/overrides/memory-queue/wrangler.jsonc index ccb375df..1cd31f32 100644 --- a/examples-cloudflare/overrides/memory-queue/wrangler.jsonc +++ b/examples-cloudflare/overrides/memory-queue/wrangler.jsonc @@ -18,6 +18,11 @@ { "binding": "WORKER_SELF_REFERENCE", "service": "memory-queue" + }, + { + "binding": "NEXT_CACHE_SERVICE", + "service": "memory-queue", + "entrypoint": "OpenNextCache" } ] } diff --git a/examples-cloudflare/overrides/r2-incremental-cache/wrangler.jsonc b/examples-cloudflare/overrides/r2-incremental-cache/wrangler.jsonc index 3fb880df..bf3bb70b 100644 --- a/examples-cloudflare/overrides/r2-incremental-cache/wrangler.jsonc +++ b/examples-cloudflare/overrides/r2-incremental-cache/wrangler.jsonc @@ -8,6 +8,13 @@ "directory": ".open-next/assets", "binding": "ASSETS" }, + "services": [ + { + "binding": "NEXT_CACHE_SERVICE", + "service": "r2-incremental-cache", + "entrypoint": "OpenNextCache" + } + ], "env": { "e2e": { "d1_databases": [ @@ -21,6 +28,14 @@ { "binding": "WORKER_SELF_REFERENCE", "service": "r2-incremental-cache-e2e" + }, + { + // Binding lists are not inherited: an environment declaring its own `services` + // replaces the top level one, so the cache binding has to be repeated here with + // the environment suffixed worker name. + "binding": "NEXT_CACHE_SERVICE", + "service": "r2-incremental-cache-e2e", + "entrypoint": "OpenNextCache" } ], "r2_buckets": [ diff --git a/examples-cloudflare/overrides/static-assets-incremental-cache/wrangler.jsonc b/examples-cloudflare/overrides/static-assets-incremental-cache/wrangler.jsonc index 9adf243a..817baad5 100644 --- a/examples-cloudflare/overrides/static-assets-incremental-cache/wrangler.jsonc +++ b/examples-cloudflare/overrides/static-assets-incremental-cache/wrangler.jsonc @@ -7,5 +7,12 @@ "assets": { "directory": ".open-next/assets", "binding": "ASSETS" - } + }, + "services": [ + { + "binding": "NEXT_CACHE_SERVICE", + "service": "static-assets-incremental-cache", + "entrypoint": "OpenNextCache" + } + ] } diff --git a/examples-cloudflare/playground16/wrangler.jsonc b/examples-cloudflare/playground16/wrangler.jsonc index e8506d3c..3dfe7a54 100644 --- a/examples-cloudflare/playground16/wrangler.jsonc +++ b/examples-cloudflare/playground16/wrangler.jsonc @@ -21,6 +21,11 @@ { "binding": "WORKER_SELF_REFERENCE", "service": "playground16" + }, + { + "binding": "NEXT_CACHE_SERVICE", + "service": "playground16", + "entrypoint": "OpenNextCache" } ], "durable_objects": { diff --git a/examples-cloudflare/prisma/wrangler.jsonc b/examples-cloudflare/prisma/wrangler.jsonc index 5c242191..7f9dd8c1 100644 --- a/examples-cloudflare/prisma/wrangler.jsonc +++ b/examples-cloudflare/prisma/wrangler.jsonc @@ -8,6 +8,13 @@ "directory": ".open-next/assets", "binding": "ASSETS" }, + "services": [ + { + "binding": "NEXT_CACHE_SERVICE", + "service": "api", + "entrypoint": "OpenNextCache" + } + ], "d1_databases": [ { "binding": "DB", diff --git a/packages/cloudflare/src/api/cloudflare-context.ts b/packages/cloudflare/src/api/cloudflare-context.ts index d48e4a50..3c1c14c1 100644 --- a/packages/cloudflare/src/api/cloudflare-context.ts +++ b/packages/cloudflare/src/api/cloudflare-context.ts @@ -24,6 +24,12 @@ declare global { // Service binding for the worker itself to be able to call itself from within the worker WORKER_SELF_REFERENCE?: Service; + // Optional service binding to a worker where the OpenNext cache is deployed on its own, + // exposed by its `OpenNextCache` named entrypoint. When unset, the cache runs in this worker. + // Note: it can not reference the worker itself, wrangler can not resolve a named entrypoint + // of the worker being configured. + NEXT_CACHE_SERVICE?: Service; + // KV used for the incremental cache NEXT_INC_CACHE_KV?: KVNamespace; // Prefix used for the KV incremental cache key @@ -337,24 +343,41 @@ async function getCloudflareContextFromWrangler< Context = ExecutionContext, >(options?: GetPlatformProxyOptions): Promise> { // Note: we never want wrangler to be bundled in the Next.js app, that's why the import below looks like it does - const { getPlatformProxy } = await import(/* webpackIgnore: true */ `${"__wrangler".replaceAll("_", "")}`); + const { getPlatformProxy, unstable_readConfig } = await import( + /* webpackIgnore: true */ `${"__wrangler".replaceAll("_", "")}` + ); + + // Same as above: this helper uses node builtins and is only ever needed when running `next dev`. + const { withoutSelfEntrypointServices } = await import( + /* webpackIgnore: true */ `${"../utils/wrangler__config.js".replaceAll("__", "-")}` + ); // This allows the selection of a wrangler environment while running in next dev mode const environment = options?.environment ?? process.env.NEXT_DEV_WRANGLER_ENV; - const { env, cf, ctx } = await getPlatformProxy({ - ...options, - // The `env` passed to the fetch handler does not contain variables from `.env*` files. - // because we invoke wrangler with `CLOUDFLARE_LOAD_DEV_VARS_FROM_DOT_ENV`=`"false"`. - // Initializing `envFiles` with an empty list is the equivalent for this API call. - envFiles: [], - environment, - }); - return { - env, - cf: cf as unknown as CfProperties, - ctx: ctx as Context, - }; + const { configPath, isFlattened, cleanup } = withoutSelfEntrypointServices( + unstable_readConfig({ env: environment, config: options?.configPath }) + ); + + try { + const { env, cf, ctx } = await getPlatformProxy({ + ...options, + configPath, + // The `env` passed to the fetch handler does not contain variables from `.env*` files. + // because we invoke wrangler with `CLOUDFLARE_LOAD_DEV_VARS_FROM_DOT_ENV`=`"false"`. + // Initializing `envFiles` with an empty list is the equivalent for this API call. + envFiles: [], + // The rewritten configuration is already flattened to `environment`. + environment: isFlattened ? undefined : environment, + }); + return { + env, + cf: cf as unknown as CfProperties, + ctx: ctx as Context, + }; + } finally { + cleanup(); + } } // In production the cloudflare context is initialized by the worker so it is always available. diff --git a/packages/cloudflare/src/api/overrides/cache/service-cache.spec.ts b/packages/cloudflare/src/api/overrides/cache/service-cache.spec.ts new file mode 100644 index 00000000..6006df2a --- /dev/null +++ b/packages/cloudflare/src/api/overrides/cache/service-cache.spec.ts @@ -0,0 +1,95 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import serviceCache, { BINDING_NAME } from "./service-cache.js"; + +const fetchMock = vi.fn<(input: string, init?: RequestInit) => Promise>(); +const env: Record = {}; + +vi.mock("../../cloudflare-context.js", () => ({ + getCloudflareContext: () => ({ env }), +})); + +function lastRequest() { + const [url, init] = fetchMock.mock.calls.at(-1)!; + return { url: new URL(url), method: init?.method ?? "GET", body: init?.body }; +} + +describe("serviceCache", () => { + beforeEach(() => { + fetchMock.mockReset(); + fetchMock.mockResolvedValue(new Response("", { headers: { "x-opennext-cache-found": "false" } })); + env[BINDING_NAME] = { fetch: fetchMock }; + }); + + it("throws when the service is not bound", async () => { + delete env[BINDING_NAME]; + + await expect(serviceCache.get("key")).rejects.toThrow(BINDING_NAME); + }); + + describe("get", () => { + it("requests the key and the cache type", async () => { + await serviceCache.get("key/with/slashes", "fetch"); + + const { url, method } = lastRequest(); + expect(method).toBe("GET"); + expect(url.pathname).toBe(`/cache/${encodeURIComponent("key/with/slashes")}`); + expect(url.searchParams.get("type")).toBe("fetch"); + }); + + it("returns null on a cache miss", async () => { + await expect(serviceCache.get("key")).resolves.toBeNull(); + }); + + it("parses a cache hit", async () => { + fetchMock.mockResolvedValue( + new Response("body", { + headers: { + "x-opennext-cache-found": "true", + "x-opennext-cache-type": "cache", + "x-opennext-cache-sub-type": "route", + "x-opennext-cache-last-modified": "1234", + }, + }) + ); + + await expect(serviceCache.get("key")).resolves.toEqual({ + lastModified: 1234, + value: expect.objectContaining({ type: "route", body: "body" }), + }); + }); + }); + + describe("set", () => { + // The cache type is part of the key for the incremental caches, it has to be forwarded + // or entries would be written where they are not read from. + it("sends the value and the cache type", async () => { + await serviceCache.set("key", { kind: "FETCH", data: { headers: {}, body: "b", url: "u" } }, "fetch"); + + const { url, method, body } = lastRequest(); + expect(method).toBe("PUT"); + expect(url.pathname).toBe("/cache/key"); + expect(url.searchParams.get("type")).toBe("fetch"); + expect(JSON.parse(body as string)).toEqual({ + value: { kind: "FETCH", data: { headers: {}, body: "b", url: "u" } }, + }); + }); + }); + + it("deletes a key", async () => { + await serviceCache.delete("key"); + + const { url, method } = lastRequest(); + expect(method).toBe("DELETE"); + expect(url.pathname).toBe("/cache/key"); + }); + + it("revalidates tags", async () => { + await serviceCache.revalidateTags(["tag1", "tag2"]); + + const { url, method, body } = lastRequest(); + expect(method).toBe("POST"); + expect(url.pathname).toBe("/cache/revalidate-tags"); + expect(JSON.parse(body as string)).toEqual({ tags: ["tag1", "tag2"] }); + }); +}); diff --git a/packages/cloudflare/src/api/overrides/cache/service-cache.ts b/packages/cloudflare/src/api/overrides/cache/service-cache.ts new file mode 100644 index 00000000..2f7931aa --- /dev/null +++ b/packages/cloudflare/src/api/overrides/cache/service-cache.ts @@ -0,0 +1,90 @@ +import type { Cache, CacheEntryType } from "@opennextjs/core/types/overrides.js"; +import { parseCacheGetResponse } from "@opennextjs/core/utils/cache-get.js"; + +import { getCloudflareContext } from "../../cloudflare-context.js"; + +export const NAME = "cf-service-cache"; + +export const BINDING_NAME = "NEXT_CACHE_SERVICE"; + +/** + * The origin is irrelevant: the requests are sent to the service binding, they never hit the network. + */ +const CACHE_ORIGIN = "https://cache.opennext"; + +/** + * Returns the cache handler bound to `NEXT_CACHE_SERVICE`. + * + * The binding points at the worker itself by default: the cache handler runs in the same worker, + * behind the `OpenNextCache` named entrypoint. It can be pointed at another worker to run the + * cache as a service of its own. + */ +function getCacheService(): Service { + const service = getCloudflareContext().env[BINDING_NAME]; + + if (!service) { + throw new Error( + `No \`${BINDING_NAME}\` service binding for the OpenNext cache.\n\n` + + `Add the following to your wrangler configuration:\n\n` + + ` "services": [\n` + + ` { "binding": "${BINDING_NAME}", "service": "", "entrypoint": "OpenNextCache" }\n` + + ` ]\n` + ); + } + + return service; +} + +function getCacheUrl(key: string, cacheType?: CacheEntryType) { + const url = new URL(`/cache/${encodeURIComponent(key)}`, CACHE_ORIGIN); + + if (cacheType) { + url.searchParams.set("type", cacheType); + } + return url.href; +} + +/** + * Cache client for the cache handler function. + * + * It talks to the `OpenNextCache` entrypoint over the service binding, using the HTTP API of + * the cache handler function. + */ +const serviceCache = { + name: NAME, + + get: async (key, cacheType) => { + const response = await getCacheService().fetch(getCacheUrl(key, cacheType)); + + const body = await response.text(); + const headers: Record = {}; + response.headers.forEach((value, name) => { + headers[name] = value; + }); + + // oxlint-disable-next-line @typescript-eslint/no-explicit-any + return parseCacheGetResponse(headers, body) as any; + }, + + set: async (key, value, cacheType) => { + await getCacheService().fetch(getCacheUrl(key, cacheType), { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ value }), + }); + }, + + delete: async (key) => { + await getCacheService().fetch(getCacheUrl(key), { method: "DELETE" }); + }, + + revalidateTags: async (tags) => { + await getCacheService().fetch(new URL("/cache/revalidate-tags", CACHE_ORIGIN).href, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ tags }), + }); + }, +} satisfies Cache; + +export default serviceCache; diff --git a/packages/cloudflare/src/cli/adapter.ts b/packages/cloudflare/src/cli/adapter.ts index bfb1e073..baabc2cb 100644 --- a/packages/cloudflare/src/cli/adapter.ts +++ b/packages/cloudflare/src/cli/adapter.ts @@ -3,6 +3,7 @@ import fs from "node:fs"; import path from "node:path"; import { buildAdapter } from "@opennextjs/core/build/adapter.js"; +import { createCacheBundle } from "@opennextjs/core/build/createCacheBundle.js"; import type { BuildOptions } from "@opennextjs/core/build/helper.js"; import * as buildHelper from "@opennextjs/core/build/helper.js"; import type { ContentUpdater } from "@opennextjs/core/plugins/content-updater.js"; @@ -14,6 +15,7 @@ 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 { compileCacheEntrypoint } from "./build/open-next/compile-cache-entrypoint.js"; import { compileEnvFiles } from "./build/open-next/compile-env-files.js"; import { compileImages } from "./build/open-next/compile-images.js"; import { compileInit } from "./build/open-next/compile-init.js"; @@ -31,6 +33,9 @@ export default buildAdapter((config: OpenNextConfig, buildOpts: BuildOptions) => skipImageOptimization: true, skipWarmer: true, skipGenerateOutput: true, + // The cache function is bundled by `beforeServerBundle` instead: it has to be emitted + // before the worker is bundled, and unconditionally as the worker always imports it. + skipCache: true, middlewareOptions: { forceOnlyBuildOnce: true }, beforeServerBundle: async (buildOpts, _config) => { // Import edge-compiled config for skew protection @@ -45,6 +50,7 @@ export default buildAdapter((config: OpenNextConfig, buildOpts: BuildOptions) => await compileInit(buildOpts, {} as any); await compileImages(buildOpts); await compileSkewProtection(buildOpts, openNextConfig); + await buildCacheFunction(buildOpts); }, serverBundle: { useEdgeConfig: true, @@ -76,3 +82,30 @@ export default buildAdapter((config: OpenNextConfig, buildOpts: BuildOptions) => }, }; }); + +/** + * Bundles the cache handler function and the named entrypoint exposing it. + * + * The cache function runs in the workerd runtime, as part of the worker, so it uses the + * edge flavour of the config - as the server bundle does. + */ +async function buildCacheFunction(buildOpts: BuildOptions) { + // The cache handler is served by the `OpenNextCache` named entrypoint, it is bundled as a + // regular fetch handler. + await createCacheBundle(buildOpts, { + wrapper: "@opennextjs/core/overrides/wrappers/cloudflare-edge.js", + converter: "@opennextjs/core/overrides/converters/edge.js", + }); + + // `createCacheBundle` copies the node config, replace it with the edge one when available. + const useEdgeConfig = fs.existsSync(path.join(buildOpts.buildDir, "open-next.config.edge.mjs")); + if (useEdgeConfig) { + buildHelper.copyOpenNextConfig( + buildOpts.buildDir, + path.join(buildOpts.outputDir, "cache-function"), + true + ); + } + + await compileCacheEntrypoint(buildOpts); +} diff --git a/packages/cloudflare/src/cli/build/open-next/compile-cache-assets-manifest.ts b/packages/cloudflare/src/cli/build/open-next/compile-cache-assets-manifest.ts deleted file mode 100644 index a7d2899d..00000000 --- a/packages/cloudflare/src/cli/build/open-next/compile-cache-assets-manifest.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { appendFileSync, mkdirSync, writeFileSync } from "node:fs"; -import path from "node:path"; - -import type { BuildOptions } from "@opennextjs/core/build/helper.js"; -import type { TagCacheMetaFile } from "@opennextjs/core/types/cache.js"; - -/** - * Generates SQL statements that can be used to initialize the cache assets manifest in an SQL data store. - */ -export function compileCacheAssetsManifestSqlFile(options: BuildOptions, metaFiles: TagCacheMetaFile[]) { - const outputPath = path.join(options.outputDir, "cloudflare/cache-assets-manifest.sql"); - - mkdirSync(path.dirname(outputPath), { recursive: true }); - writeFileSync( - outputPath, - `CREATE TABLE IF NOT EXISTS tags (tag TEXT NOT NULL, path TEXT NOT NULL, UNIQUE(tag, path) ON CONFLICT REPLACE); - CREATE TABLE IF NOT EXISTS revalidations (tag TEXT NOT NULL, revalidatedAt INTEGER NOT NULL, UNIQUE(tag) ON CONFLICT REPLACE);\n` - ); - - const values = metaFiles.map(({ tag, path }) => `(${JSON.stringify(tag.S)}, ${JSON.stringify(path.S)})`); - - if (values.length) { - appendFileSync(outputPath, `INSERT INTO tags (tag, path) VALUES ${values.join(", ")};`); - } -} diff --git a/packages/cloudflare/src/cli/build/open-next/compile-cache-entrypoint.ts b/packages/cloudflare/src/cli/build/open-next/compile-cache-entrypoint.ts new file mode 100644 index 00000000..c22ee773 --- /dev/null +++ b/packages/cloudflare/src/cli/build/open-next/compile-cache-entrypoint.ts @@ -0,0 +1,29 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import type { BuildOptions } from "@opennextjs/core/build/helper.js"; +import { build } from "esbuild"; + +/** + * Compiles the `OpenNextCache` named entrypoint. + * + * `./init.js` and `../cache-function/index.mjs` are kept external: they are emitted next to the + * entrypoint in the output directory and resolved when wrangler bundles the worker. Inlining + * `./init.js` would duplicate the `AsyncLocalStorage` holding the Cloudflare context. + */ +export async function compileCacheEntrypoint(options: BuildOptions) { + const currentDir = path.join(path.dirname(fileURLToPath(import.meta.url))); + const templatesDir = path.join(currentDir, "../../templates"); + const entrypointPath = path.join(templatesDir, "cache-entrypoint.js"); + + await build({ + entryPoints: [entrypointPath], + outdir: path.join(options.outputDir, "cloudflare"), + bundle: true, + minify: false, + format: "esm", + target: "esnext", + platform: "node", + external: ["cloudflare:workers", "./init.js", "../cache-function/index.mjs"], + }); +} diff --git a/packages/cloudflare/src/cli/commands/utils/helpers.ts b/packages/cloudflare/src/cli/commands/utils/helpers.ts index fe3859da..fc9b2a1e 100644 --- a/packages/cloudflare/src/cli/commands/utils/helpers.ts +++ b/packages/cloudflare/src/cli/commands/utils/helpers.ts @@ -1,6 +1,7 @@ import { type BuildOptions } from "@opennextjs/core/build/helper.js"; -import { getPlatformProxy, type GetPlatformProxyOptions } from "wrangler"; +import { getPlatformProxy, type GetPlatformProxyOptions, unstable_readConfig } from "wrangler"; +import { withoutSelfEntrypointServices } from "../../../utils/wrangler-config.js"; import { extractProjectEnvVars } from "../../utils/extract-project-env-vars.js"; export type WorkerEnvVar = Record; @@ -8,18 +9,29 @@ export type WorkerEnvVar = Record; export async function getEnvFromPlatformProxy(options: GetPlatformProxyOptions, buildOpts: BuildOptions) { const envVars = process.env; - const proxy = await getPlatformProxy({ - ...options, - envFiles: [], - }); + const { configPath, isFlattened, cleanup } = withoutSelfEntrypointServices( + unstable_readConfig({ env: options.environment, config: options.configPath }) + ); - Object.entries(proxy.env).forEach(([key, value]) => { - if (typeof value === "string") { - envVars[key as keyof CloudflareEnv] = value; - } - }); + try { + const proxy = await getPlatformProxy({ + ...options, + configPath, + // The rewritten configuration is already flattened to `options.environment`. + ...(isFlattened ? { environment: undefined } : {}), + envFiles: [], + }); + + Object.entries(proxy.env).forEach(([key, value]) => { + if (typeof value === "string") { + envVars[key as keyof CloudflareEnv] = value; + } + }); - await proxy.dispose(); + await proxy.dispose(); + } finally { + cleanup(); + } let mode: "production" | "development" | "test" = "production"; if (envVars.NEXTJS_ENV === "development") { diff --git a/packages/cloudflare/src/cli/templates/cache-entrypoint.ts b/packages/cloudflare/src/cli/templates/cache-entrypoint.ts new file mode 100644 index 00000000..bbb999c9 --- /dev/null +++ b/packages/cloudflare/src/cli/templates/cache-entrypoint.ts @@ -0,0 +1,27 @@ +/** + * The OpenNext cache handler, exposed as a named entrypoint of the worker. + * + * The cache handler function is bundled by `createCacheBundle` as a regular fetch handler. The + * server and the middleware reach it through the `NEXT_CACHE_SERVICE` binding, which points at + * this worker by default so that the cache runs in the same worker, and can point at another + * worker instead. + * + * See https://developers.cloudflare.com/workers/runtime-apis/bindings/service-bindings/rpc/#named-entrypoints + */ + +import { WorkerEntrypoint } from "cloudflare:workers"; + +// @ts-expect-error: resolved by wrangler build +import { handler } from "../cache-function/index.mjs"; + +import { runWithCloudflareContext } from "./init.js"; + +type CacheHandler = (request: Request, env: CloudflareEnv, ctx: ExecutionContext) => Promise; + +export class OpenNextCache extends WorkerEntrypoint { + override fetch(request: Request): Promise { + return runWithCloudflareContext(this.env, this.ctx, () => + (handler as CacheHandler)(request, this.env, this.ctx) + ); + } +} diff --git a/packages/cloudflare/src/cli/templates/init.ts b/packages/cloudflare/src/cli/templates/init.ts index 5cf9c315..b2d745e4 100644 --- a/packages/cloudflare/src/cli/templates/init.ts +++ b/packages/cloudflare/src/cli/templates/init.ts @@ -23,33 +23,56 @@ Object.defineProperty(globalThis, Symbol.for("__cloudflare-context__"), { /** * Executes the handler with the Cloudflare context. */ -export async function runWithCloudflareRequestContext( +export async function runWithCloudflareRequestContext( request: Request, env: CloudflareEnv, ctx: ExecutionContext, - handler: () => Promise -): Promise { - init(request, env); + handler: () => Promise +): Promise { + init(env, new URL(request.url)); return cloudflareContextALS.run({ env, ctx, cf: request.cf }, handler); } +/** + * Executes the handler with the Cloudflare context, outside of a request. + * + * Used by the named entrypoints (i.e. the cache handler) which are invoked via RPC + * and therefore have no incoming `Request` to derive the origin from. + */ +export async function runWithCloudflareContext( + env: CloudflareEnv, + ctx: ExecutionContext, + handler: () => Promise +): Promise { + init(env); + + return cloudflareContextALS.run({ env, ctx, cf: undefined }, handler); +} + let initialized = false; +let originInitialized = false; /** * Initializes the runtime on the first call, * no-op on subsequent invocations. + * + * The origin is only known when a `Request` is available, so it is populated on the first + * call made from the fetch handler - which might not be the first call overall. */ -function init(request: Request, env: CloudflareEnv) { - if (initialized) { - return; +function init(env: CloudflareEnv, url?: URL) { + if (!initialized) { + initialized = true; + + initRuntime(); + populateProcessEnv(env); } - initialized = true; - const url = new URL(request.url); + if (url && !originInitialized) { + originInitialized = true; - initRuntime(); - populateProcessEnv(url, env); + populateOriginEnv(url); + } } function initRuntime() { @@ -109,9 +132,8 @@ function initRuntime() { * Populate process.env with: * - the environment variables and secrets from the cloudflare platform * - the variables from Next .env* files - * - the origin resolver information */ -function populateProcessEnv(url: URL, env: CloudflareEnv) { +function populateProcessEnv(env: CloudflareEnv) { for (const [key, value] of Object.entries(env)) { if (typeof value === "string") { process.env[key] = value; @@ -125,6 +147,16 @@ function populateProcessEnv(url: URL, env: CloudflareEnv) { } } + // `__DEPLOYMENT_ID__` is a string (passed via ESBuild). + if (__DEPLOYMENT_ID__) { + process.env.DEPLOYMENT_ID = __DEPLOYMENT_ID__; + } +} + +/** + * Populate process.env with the origin resolver information. + */ +function populateOriginEnv(url: URL) { // Set the default Origin for the origin resolver. // This is only needed for an external middleware bundle process.env.OPEN_NEXT_ORIGIN = JSON.stringify({ @@ -140,11 +172,6 @@ function populateProcessEnv(url: URL, env: CloudflareEnv) { * https://github.com/vercel/next.js/blob/6b1e48080e896e0d44a05fe009cb79d2d3f91774/packages/next/src/server/app-render/action-handler.ts#L307-L316 */ process.env.__NEXT_PRIVATE_ORIGIN = url.origin; - - // `__DEPLOYMENT_ID__` is a string (passed via ESBuild). - if (__DEPLOYMENT_ID__) { - process.env.DEPLOYMENT_ID = __DEPLOYMENT_ID__; - } } declare global { diff --git a/packages/cloudflare/src/cli/templates/worker.ts b/packages/cloudflare/src/cli/templates/worker.ts index 4465dbf6..6c43e353 100644 --- a/packages/cloudflare/src/cli/templates/worker.ts +++ b/packages/cloudflare/src/cli/templates/worker.ts @@ -14,6 +14,8 @@ export { DOQueueHandler } from "./.build/durable-objects/queue.js"; export { DOShardedTagCache } from "./.build/durable-objects/sharded-tag-cache.js"; //@ts-expect-error: Will be resolved by wrangler build export { BucketCachePurge } from "./.build/durable-objects/bucket-cache-purge.js"; +//@ts-expect-error: Will be resolved by wrangler build +export { OpenNextCache } from "./cloudflare/cache-entrypoint.js"; export default { async fetch(request, env, ctx) { diff --git a/packages/cloudflare/src/utils/wrangler-config.ts b/packages/cloudflare/src/utils/wrangler-config.ts new file mode 100644 index 00000000..21a23b81 --- /dev/null +++ b/packages/cloudflare/src/utils/wrangler-config.ts @@ -0,0 +1,54 @@ +import fs from "node:fs"; +import path from "node:path"; + +type ServiceBinding = { + binding: string; + service: string; + entrypoint?: string; +}; + +/** The subset of the resolved wrangler configuration used here. */ +type ResolvedConfig = { + configPath?: string; + name?: string; + services?: ServiceBinding[]; +}; + +/** + * `getPlatformProxy` starts the worker without its script, so it is not able to resolve a service + * binding referencing a named entrypoint of the worker itself - which is how the OpenNext cache is + * wired - and fails to start. + * + * Those bindings are only used at runtime by the generated worker, dropping them has no effect on + * what `getPlatformProxy` is used for, so the configuration is rewritten without them. + * + * @returns the configuration path to pass to `getPlatformProxy` and a cleanup function to call + * once the proxy has been disposed of. + */ +export function withoutSelfEntrypointServices(config: ResolvedConfig): { + configPath: string | undefined; + /** + * `true` when the configuration was rewritten. The rewritten file is the already resolved + * configuration, flattened to the selected environment, so the environment must not be selected + * a second time when passing it on - wrangler would warn about the missing environment section. + */ + isFlattened: boolean; + cleanup: () => void; +} { + const services = config.services ?? []; + const keptServices = services.filter((service) => !(service.entrypoint && service.service === config.name)); + + if (!config.configPath || keptServices.length === services.length) { + return { configPath: config.configPath, isFlattened: false, cleanup: () => {} }; + } + + // `unsafe` is dropped as wrangler warns about it being experimental, even when it is empty. + const { unsafe: _unsafe, ...rest } = config as ResolvedConfig & { unsafe?: unknown }; + + // The file has to sit next to the original one: relative paths are resolved from its directory. + const configPath = path.join(path.dirname(config.configPath), `.wrangler.opennext.${process.pid}.json`); + + fs.writeFileSync(configPath, JSON.stringify({ ...rest, services: keptServices })); + + return { configPath, isFlattened: true, cleanup: () => fs.rmSync(configPath, { force: true }) }; +} diff --git a/packages/cloudflare/templates/wrangler.jsonc b/packages/cloudflare/templates/wrangler.jsonc index 7cf1ce87..702c0aea 100644 --- a/packages/cloudflare/templates/wrangler.jsonc +++ b/packages/cloudflare/templates/wrangler.jsonc @@ -14,6 +14,14 @@ // see https://opennext.js.org/cloudflare/caching "binding": "WORKER_SELF_REFERENCE", "service": "" + }, + { + // The OpenNext cache runs behind a named entrypoint of this worker. + // The service name must match the worker name. + // see https://opennext.js.org/cloudflare/caching + "binding": "NEXT_CACHE_SERVICE", + "service": "", + "entrypoint": "OpenNextCache" } ], "r2_buckets": [ From dc444fceb933e6fb03a440a0b5c5d212168d7ab0 Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Sun, 16 Aug 2026 15:10:02 +0200 Subject: [PATCH 18/42] refactor(core)!: route all caching through the cache override The incremental cache and the tag cache stop running inside the server function. They only live in the cache handler function now, and the server, the middleware and the composable cache reach them through the `cache` override. - Remove `incrementalCache` and `tagCache` from `OverrideOptions`; they are only configured under `cacheHandler`. `resolveIncrementalCache` and `resolveTagCache` follow. The esbuild resolve plugin keeps its own fields, so adapter `defaultOverrides` are unaffected. - Move tag revalidation - `hasBeenRevalidated`, `writeTags` and CDN invalidation - out of `adapters/cache.ts`, `composable-cache.ts` and `cacheInterceptor.ts` and into the cache handler, which now applies them in `get`, `set` and `revalidateTags`. - `Cache.get` takes the additional tags to check, so the caller no longer needs the tag cache to resolve them. - `defineCloudflareConfig` wires `cache` to the `OpenNextCache` entrypoint and moves the incremental cache, the tag cache and the cdn invalidation to `cacheHandler`; `ensureCloudflareConfig`, `populateCache` and `isPurgeCacheEnabled` read the new location. - Examples move to `cache: "local"` with a `cacheHandler` block. BREAKING CHANGE: `default.override.incrementalCache` and `default.override.tagCache` are replaced by the top level `cacheHandler` option and `default.override.cache`. --- .changeset/cache-consolidate-override.md | 32 + examples/app-pages-router/open-next.config.ts | 11 +- examples/app-router/open-next.config.ts | 12 +- examples/experimental/open-next.config.ts | 12 +- examples/pages-router/open-next.config.ts | 12 +- packages/cloudflare/src/api/config.ts | 13 +- .../api/overrides/cache/service-cache.spec.ts | 11 +- .../src/api/overrides/cache/service-cache.ts | 10 +- .../cloudflare/src/api/overrides/internal.ts | 5 +- .../src/cli/build/utils/ensure-cf-config.ts | 23 +- .../src/cli/commands/populate-cache.spec.ts | 12 +- .../src/cli/commands/populate-cache.ts | 2 +- packages/core/src/adapters/cache-adapter.ts | 111 ++- packages/core/src/adapters/cache.ts | 194 +---- .../core/src/adapters/composable-cache.ts | 58 +- packages/core/src/adapters/middleware.ts | 6 - .../core/src/build/edge/createEdgeBundle.ts | 7 +- packages/core/src/build/generateOutput.ts | 12 +- .../build/middleware/buildNodeMiddleware.ts | 7 +- packages/core/src/core/createMainHandler.ts | 6 - packages/core/src/core/resolve.ts | 8 +- .../core/src/core/routing/cacheInterceptor.ts | 15 +- packages/core/src/overrides/cache/fetch.ts | 3 +- packages/core/src/overrides/cache/local.ts | 7 +- packages/core/src/plugins/resolve.ts | 8 +- packages/core/src/types/global.ts | 8 +- packages/core/src/types/open-next.ts | 12 - packages/core/src/types/overrides.ts | 3 +- packages/core/src/utils/cache.ts | 18 +- .../tests/adapters/cache-adapter.test.ts | 680 ++++++++++++++++++ .../tests-unit/tests/adapters/cache.test.ts | 607 +++------------- .../tests/adapters/composable-cache.test.ts | 355 ++------- .../core/routing/cacheInterceptor.test.ts | 100 +-- 33 files changed, 1147 insertions(+), 1233 deletions(-) create mode 100644 .changeset/cache-consolidate-override.md create mode 100644 packages/tests-unit/tests/adapters/cache-adapter.test.ts diff --git a/.changeset/cache-consolidate-override.md b/.changeset/cache-consolidate-override.md new file mode 100644 index 00000000..00547bd6 --- /dev/null +++ b/.changeset/cache-consolidate-override.md @@ -0,0 +1,32 @@ +--- +"@opennextjs/core": major +"@opennextjs/cloudflare": minor +--- + +Route all caching through the `cache` override + +The incremental cache and the tag cache no longer run inside the server function. They run in the +cache handler function, which the server, the middleware and the composable cache reach through the +`cache` override. Tag revalidation - `hasBeenRevalidated`, `writeTags` and CDN invalidation - moves +with them, so `get`, `set` and `revalidateTags` now handle tags transparently. + +`incrementalCache` and `tagCache` are removed from `default.override` and from the middleware +override. Configurations that are not created by `defineCloudflareConfig` should move them to the +top level `cacheHandler` option and set `default.override.cache`: + +```diff + default: { + override: { +- incrementalCache: "s3", +- tagCache: "dynamodb", ++ cache: "local", + }, + }, ++ cacheHandler: { ++ incrementalCache: "s3", ++ tagCache: "dynamodb", ++ }, +``` + +`defineCloudflareConfig` is unchanged: it now wires the cache to the `OpenNextCache` entrypoint on +its own. diff --git a/examples/app-pages-router/open-next.config.ts b/examples/app-pages-router/open-next.config.ts index c1124cb4..65a9a7e9 100644 --- a/examples/app-pages-router/open-next.config.ts +++ b/examples/app-pages-router/open-next.config.ts @@ -3,9 +3,8 @@ import type { OpenNextConfig, OverrideOptions } from "@opennextjs/core/types/ope const devOverride = { wrapper: "express-dev", converter: "node", - incrementalCache: "fs-dev", + cache: "local", queue: "direct", - tagCache: "fs-dev-nextMode", } satisfies OverrideOptions; export default { @@ -26,6 +25,14 @@ export default { }, loader: "fs-dev", }, + cacheHandler: { + override: { + wrapper: "dummy", + converter: "dummy", + }, + incrementalCache: "fs-dev", + tagCache: "fs-dev-nextMode", + }, // You can override the build command here so that you don't have to rebuild next every time you make a change // buildCommand: "echo 'No build command'", } satisfies OpenNextConfig; diff --git a/examples/app-router/open-next.config.ts b/examples/app-router/open-next.config.ts index e52dd92b..1c826183 100644 --- a/examples/app-router/open-next.config.ts +++ b/examples/app-router/open-next.config.ts @@ -5,9 +5,8 @@ export default { override: { wrapper: "express-dev", converter: "node", - incrementalCache: "fs-dev", + cache: "local", queue: "direct", - tagCache: "fs-dev-nextMode", }, }, @@ -23,6 +22,15 @@ export default { loader: "fs-dev", }, + cacheHandler: { + override: { + wrapper: "dummy", + converter: "dummy", + }, + incrementalCache: "fs-dev", + tagCache: "fs-dev-nextMode", + }, + // You can override the build command here so that you don't have to rebuild next every time you make a change //buildCommand: "echo 'No build command'", } satisfies OpenNextConfig; diff --git a/examples/experimental/open-next.config.ts b/examples/experimental/open-next.config.ts index b08f90fe..b3444c42 100644 --- a/examples/experimental/open-next.config.ts +++ b/examples/experimental/open-next.config.ts @@ -5,9 +5,8 @@ export default { override: { wrapper: "express-dev", converter: "node", - incrementalCache: "fs-dev", queue: "direct", - tagCache: "fs-dev-nextMode", + cache: "local", }, }, @@ -19,6 +18,15 @@ export default { loader: "fs-dev", }, + cacheHandler: { + override: { + wrapper: "dummy", + converter: "dummy", + }, + incrementalCache: "fs-dev", + tagCache: "fs-dev-nextMode", + }, + // You can override the build command here so that you don't have to rebuild next every time you make a change //buildCommand: "echo 'No build command'", } satisfies OpenNextConfig; diff --git a/examples/pages-router/open-next.config.ts b/examples/pages-router/open-next.config.ts index e3fd064f..67cc2788 100644 --- a/examples/pages-router/open-next.config.ts +++ b/examples/pages-router/open-next.config.ts @@ -3,9 +3,8 @@ export default { override: { wrapper: "express-dev", converter: "node", - incrementalCache: "fs-dev", queue: "direct", - tagCache: "dummy", + cache: "local", }, }, @@ -17,6 +16,15 @@ export default { loader: "fs-dev", }, + cacheHandler: { + override: { + wrapper: "dummy", + converter: "dummy", + }, + incrementalCache: "fs-dev", + tagCache: "dummy", + }, + // You can override the build command here so that you don't have to rebuild next every time you make a change //buildCommand: "echo 'No build command'", }; diff --git a/packages/cloudflare/src/api/config.ts b/packages/cloudflare/src/api/config.ts index 32d9be73..04279127 100644 --- a/packages/cloudflare/src/api/config.ts +++ b/packages/cloudflare/src/api/config.ts @@ -13,6 +13,7 @@ import type { } from "@opennextjs/core/types/overrides.js"; import assetResolver from "./overrides/asset-resolver/index.js"; +import serviceCache from "./overrides/cache/service-cache.js"; export type Override = "dummy" | T | LazyLoadedOverride; @@ -65,13 +66,18 @@ export function defineCloudflareConfig(config: CloudflareOverrides = {}): OpenNe wrapper: "cloudflare-node", converter: "edge", proxyExternalRequest: "fetch", - incrementalCache: resolveIncrementalCache(incrementalCache), - tagCache: resolveTagCache(tagCache), + cache: () => serviceCache, queue: resolveQueue(queue), cdnInvalidation: resolveCdnInvalidation(cachePurge), }, routePreloadingBehavior, }, + // The cache runs in the same worker, behind the `OpenNextCache` named entrypoint. + cacheHandler: { + incrementalCache: resolveIncrementalCache(incrementalCache), + tagCache: resolveTagCache(tagCache), + cdnInvalidation: resolveCdnInvalidation(cachePurge), + }, // node:crypto is used to compute cache keys edgeExternals: ["node:crypto"], cloudflare: { @@ -83,8 +89,7 @@ export function defineCloudflareConfig(config: CloudflareOverrides = {}): OpenNe wrapper: "cloudflare-edge", converter: "edge", proxyExternalRequest: "fetch", - incrementalCache: resolveIncrementalCache(incrementalCache), - tagCache: resolveTagCache(tagCache), + cache: () => serviceCache, queue: resolveQueue(queue), }, assetResolver: () => assetResolver, diff --git a/packages/cloudflare/src/api/overrides/cache/service-cache.spec.ts b/packages/cloudflare/src/api/overrides/cache/service-cache.spec.ts index 6006df2a..7787963d 100644 --- a/packages/cloudflare/src/api/overrides/cache/service-cache.spec.ts +++ b/packages/cloudflare/src/api/overrides/cache/service-cache.spec.ts @@ -28,13 +28,20 @@ describe("serviceCache", () => { }); describe("get", () => { - it("requests the key and the cache type", async () => { - await serviceCache.get("key/with/slashes", "fetch"); + it("requests the key, the cache type and the additional tags", async () => { + await serviceCache.get("key/with/slashes", "fetch", ["tag1", "tag2"]); const { url, method } = lastRequest(); expect(method).toBe("GET"); expect(url.pathname).toBe(`/cache/${encodeURIComponent("key/with/slashes")}`); expect(url.searchParams.get("type")).toBe("fetch"); + expect(url.searchParams.get("tags")).toBe("tag1,tag2"); + }); + + it("omits the tags when there is none", async () => { + await serviceCache.get("key", "cache", []); + + expect(lastRequest().url.searchParams.has("tags")).toBe(false); }); it("returns null on a cache miss", async () => { diff --git a/packages/cloudflare/src/api/overrides/cache/service-cache.ts b/packages/cloudflare/src/api/overrides/cache/service-cache.ts index 2f7931aa..d7808cc1 100644 --- a/packages/cloudflare/src/api/overrides/cache/service-cache.ts +++ b/packages/cloudflare/src/api/overrides/cache/service-cache.ts @@ -35,12 +35,16 @@ function getCacheService(): Service { return service; } -function getCacheUrl(key: string, cacheType?: CacheEntryType) { +function getCacheUrl(key: string, cacheType?: CacheEntryType, additionalTags?: string[]) { const url = new URL(`/cache/${encodeURIComponent(key)}`, CACHE_ORIGIN); if (cacheType) { url.searchParams.set("type", cacheType); } + if (additionalTags && additionalTags.length > 0) { + url.searchParams.set("tags", additionalTags.join(",")); + } + return url.href; } @@ -53,8 +57,8 @@ function getCacheUrl(key: string, cacheType?: CacheEntryType) { const serviceCache = { name: NAME, - get: async (key, cacheType) => { - const response = await getCacheService().fetch(getCacheUrl(key, cacheType)); + get: async (key, cacheType, additionalTags) => { + const response = await getCacheService().fetch(getCacheUrl(key, cacheType, additionalTags)); const body = await response.text(); const headers: Record = {}; diff --git a/packages/cloudflare/src/api/overrides/internal.ts b/packages/cloudflare/src/api/overrides/internal.ts index a4f78b12..0dfd492d 100644 --- a/packages/cloudflare/src/api/overrides/internal.ts +++ b/packages/cloudflare/src/api/overrides/internal.ts @@ -34,7 +34,10 @@ export function computeCacheKey(key: string, options: KeyOptions) { export function isPurgeCacheEnabled(): boolean { // The `?` is required at `openNextConfig?` or the Open Next build fails because of a type error - const cdnInvalidation = globalThis.openNextConfig?.default?.override?.cdnInvalidation; + // The cache handler function only has `cacheHandler` populated, the other functions only have `default`. + const cdnInvalidation = + globalThis.openNextConfig?.cacheHandler?.cdnInvalidation ?? + globalThis.openNextConfig?.default?.override?.cdnInvalidation; return cdnInvalidation !== undefined && cdnInvalidation !== "dummy"; } diff --git a/packages/cloudflare/src/cli/build/utils/ensure-cf-config.ts b/packages/cloudflare/src/cli/build/utils/ensure-cf-config.ts index fb461018..de8a34df 100644 --- a/packages/cloudflare/src/cli/build/utils/ensure-cf-config.ts +++ b/packages/cloudflare/src/cli/build/utils/ensure-cf-config.ts @@ -17,12 +17,12 @@ export function ensureCloudflareConfig(config: OpenNextConfig) { dftUseCloudflareWrapper: config.default?.override?.wrapper === "cloudflare-node", dftUseEdgeConverter: config.default?.override?.converter === "edge", dftUseFetchProxy: config.default?.override?.proxyExternalRequest === "fetch", - dftMaybeUseCache: - config.default?.override?.incrementalCache === "dummy" || - typeof config.default?.override?.incrementalCache === "function", - dftMaybeUseTagCache: - config.default?.override?.tagCache === "dummy" || - typeof config.default?.override?.incrementalCache === "function", + dftUseCacheClient: typeof config.default?.override?.cache === "function", + chMaybeUseIncrementalCache: + config.cacheHandler?.incrementalCache === "dummy" || + typeof config.cacheHandler?.incrementalCache === "function", + chMaybeUseTagCache: + config.cacheHandler?.tagCache === "dummy" || typeof config.cacheHandler?.tagCache === "function", dftMaybeUseQueue: config.default?.override?.queue === "dummy" || config.default?.override?.queue === "direct" || @@ -32,6 +32,7 @@ export function ensureCloudflareConfig(config: OpenNextConfig) { mwUseCloudflareWrapper: mwConfig?.override?.wrapper === "cloudflare-edge", mwUseEdgeConverter: mwConfig?.override?.converter === "edge", mwUseFetchProxy: mwConfig?.override?.proxyExternalRequest === "fetch", + mwUseCacheClient: typeof mwConfig?.override?.cache === "function", hasCryptoExternal: config.edgeExternals?.includes("node:crypto"), }; @@ -48,11 +49,14 @@ export function ensureCloudflareConfig(config: OpenNextConfig) { wrapper: "cloudflare-node", converter: "edge", proxyExternalRequest: "fetch", - incrementalCache: "dummy" | function, - tagCache: "dummy" | function, + cache: function, queue: "dummy" | "direct" | function, }, }, + cacheHandler: { + incrementalCache: "dummy" | function, + tagCache: "dummy" | function, + }, edgeExternals: ["node:crypto"], middleware: { external: true, @@ -60,8 +64,7 @@ export function ensureCloudflareConfig(config: OpenNextConfig) { wrapper: "cloudflare-edge", converter: "edge", proxyExternalRequest: "fetch", - incrementalCache: "dummy" | function, - tagCache: "dummy" | function, + cache: function, queue: "dummy" | "direct" | function, }, }, diff --git a/packages/cloudflare/src/cli/commands/populate-cache.spec.ts b/packages/cloudflare/src/cli/commands/populate-cache.spec.ts index 9127dd1b..abf4e0c6 100644 --- a/packages/cloudflare/src/cli/commands/populate-cache.spec.ts +++ b/packages/cloudflare/src/cli/commands/populate-cache.spec.ts @@ -113,10 +113,8 @@ describe("populateCache", () => { outputDir: "/test/output", } as BuildOptions, { - default: { - override: { - incrementalCache: "cf-r2-incremental-cache", - }, + cacheHandler: { + incrementalCache: "cf-r2-incremental-cache", }, } as any, // oxlint-disable-line @typescript-eslint/no-explicit-any { @@ -149,10 +147,8 @@ describe("populateCache", () => { outputDir: "/test/output", } as BuildOptions, { - default: { - override: { - incrementalCache: "cf-r2-incremental-cache", - }, + cacheHandler: { + incrementalCache: "cf-r2-incremental-cache", }, } as any, // oxlint-disable-line @typescript-eslint/no-explicit-any { diff --git a/packages/cloudflare/src/cli/commands/populate-cache.ts b/packages/cloudflare/src/cli/commands/populate-cache.ts index c60aa20c..fbe6692a 100644 --- a/packages/cloudflare/src/cli/commands/populate-cache.ts +++ b/packages/cloudflare/src/cli/commands/populate-cache.ts @@ -90,7 +90,7 @@ export async function populateCache( populateCacheOptions: PopulateCacheOptions, envVars: WorkerEnvVar ) { - const { incrementalCache, tagCache } = config.default.override ?? {}; + const { incrementalCache, tagCache } = config.cacheHandler ?? {}; if (!fs.existsSync(buildOpts.outputDir)) { logger.error("Unable to populate cache: Open Next build not found"); diff --git a/packages/core/src/adapters/cache-adapter.ts b/packages/core/src/adapters/cache-adapter.ts index 662c98c4..c690b172 100644 --- a/packages/core/src/adapters/cache-adapter.ts +++ b/packages/core/src/adapters/cache-adapter.ts @@ -13,7 +13,7 @@ import type { import { createGenericHandler } from "../core/createGenericHandler.js"; import { resolveCdnInvalidation, resolveIncrementalCache, resolveTagCache } from "../core/resolve.js"; -import { writeTags } from "../utils/cache.js"; +import { getTagsFromValue, writeTags } from "../utils/cache.js"; import { runWithOpenNextRequestContext } from "../utils/promise.js"; import { toReadableStream } from "../utils/stream.js"; @@ -30,13 +30,9 @@ async function initializeCaches() { if (initialized) return; const config = globalThis.openNextConfig; - globalThis.incrementalCache = await resolveIncrementalCache( - config.cacheHandler?.incrementalCache ?? config.default?.override?.incrementalCache - ); + globalThis.incrementalCache = await resolveIncrementalCache(config.cacheHandler?.incrementalCache); - globalThis.tagCache = await resolveTagCache( - config.cacheHandler?.tagCache ?? config.default?.override?.tagCache - ); + globalThis.tagCache = await resolveTagCache(config.cacheHandler?.tagCache); globalThis.cdnInvalidationHandler = await resolveCdnInvalidation( config.cacheHandler?.cdnInvalidation ?? config.default?.override?.cdnInvalidation @@ -89,9 +85,11 @@ async function defaultHandler( const rawType = typeof query?.type === "string" ? query.type : undefined; const cacheType: CacheEntryType = rawType === "fetch" || rawType === "composable" ? rawType : "cache"; + const additionalTags = query?.tags ? (query.tags as string).split(",") : []; + switch (method) { case "GET": - return await handleGet(key, cacheType); + return await handleGet(key, cacheType, additionalTags); case "PUT": return await handleSet(key, cacheType, body); case "DELETE": @@ -109,8 +107,12 @@ async function defaultHandler( // Route handlers // ////////////////////// -async function handleGet(key: string, cacheType: CacheEntryType): Promise { - debug("get", { key, cacheType }); +async function handleGet( + key: string, + cacheType: CacheEntryType, + additionalTags: string[] +): Promise { + debug("get", { key, cacheType, additionalTags }); try { const result = await globalThis.incrementalCache.get(key, cacheType); @@ -128,6 +130,40 @@ async function handleGet(key: string, cacheType: CacheEntryType): Promise)]; + } else if (cacheType === "fetch") { + const fetchValue = result.value as CachedFetchValue; + tags = [...tags, ...(fetchValue.tags ?? []), ...(fetchValue.data?.tags ?? [])]; + } else if (cacheType === "composable") { + const composableValue = result.value as StoredComposableCacheEntry; + tags = [...tags, ...(composableValue.tags ?? [])]; + } + + if (!result.shouldBypassTagCache) { + if (tags.length > 0) { + const revalidated = await checkTagRevalidation(key, tags, result); + if (revalidated) { + return { + type: "core", + statusCode: 404, + body: toReadableStream(""), + isBase64Encoded: false, + headers: { + "x-opennext-cache-found": "false", + "x-opennext-cache-tag-status": "revalidated", + "Cache-Control": "no-store", + }, + }; + } + } + } + return buildCacheGetResponse(result); } catch (e) { error("Failed to get cache entry", e); @@ -135,6 +171,22 @@ async function handleGet(key: string, cacheType: CacheEntryType): Promise> +): Promise { + if (globalThis.openNextConfig?.dangerous?.disableTagCache || tags.length === 0) { + return false; + } + const lastModified = cacheEntry.lastModified ?? Date.now(); + if (globalThis.tagCache.mode === "nextMode") { + return globalThis.tagCache.hasBeenRevalidated(tags, lastModified); + } + const _lastModified = await globalThis.tagCache.getLastModified(key, lastModified); + return _lastModified === -1; +} + async function handleSet(key: string, cacheType: CacheEntryType, body?: Buffer): Promise { debug("set", { key, cacheType }); @@ -156,6 +208,45 @@ async function handleSet(key: string, cacheType: CacheEntryType, body?: Buffer): try { await globalThis.incrementalCache.set(key, payload.value as CacheValue, cacheType); + + // `writeTags` deduplicates through the OpenNext request context and gives up when there is + // none. The cache handler runs as its own function, so nothing established a context for us. + await runWithOpenNextRequestContext({ isISRRevalidation: false }, async () => { + // Write tags for non-composable and non-nextMode tag caches + const tagCache = globalThis.tagCache; + // TODO: fix this horrible typing + if (tagCache.mode !== "nextMode" && !globalThis.openNextConfig?.dangerous?.disableTagCache) { + let derivedTags: string[] = []; + + if (cacheType === "cache") { + const tags = getTagsFromValue(payload.value as CacheValue<"cache">); + derivedTags = tags; + } else if (cacheType === "fetch") { + const fetchValue = payload.value as CacheValue<"fetch">; + const data = fetchValue.data as Record | undefined; + derivedTags = (fetchValue.tags as string[]) ?? (data?.tags as string[]) ?? []; + } else if (cacheType === "composable") { + const composableValue = payload.value as CacheValue<"composable">; + derivedTags = composableValue.tags ?? []; + } + + if (derivedTags.length > 0) { + const storedTags = await tagCache.getByPath(key); + const tagsToWrite = derivedTags.filter((tag) => !storedTags.includes(tag)); + if (tagsToWrite.length > 0) { + await writeTags( + tagsToWrite.map((tag) => ({ + path: key, + tag, + revalidatedAt: 1, + })), + tagCache + ); + } + } + } + }); + return buildJsonResponse({ ok: true }, 200); } catch (e) { error("Failed to set cache entry", e); diff --git a/packages/core/src/adapters/cache.ts b/packages/core/src/adapters/cache.ts index 9cd66847..25378bf9 100644 --- a/packages/core/src/adapters/cache.ts +++ b/packages/core/src/adapters/cache.ts @@ -1,12 +1,9 @@ import type { CacheHandlerValue, IncrementalCacheContext, IncrementalCacheValue } from "@/types/cache"; -import { getTagsFromValue, hasBeenRevalidated, writeTags } from "@/utils/cache"; import { isBinaryContentType } from "../utils/binary"; import { debug, error, warn } from "./logger"; -export const SOFT_TAG_PREFIX = "_N_T_/"; - function isFetchCache(options?: { kindHint?: "app" | "pages" | "fetch"; kind?: "FETCH" }): boolean { if (typeof options === "object") { return options.kindHint === "fetch" || options.kind === "FETCH"; @@ -29,47 +26,21 @@ export default class Cache { return null; } - const softTags = typeof options === "object" ? options.softTags : []; - const tags = typeof options === "object" ? options.tags : []; - return isFetchCache(options) ? this.getFetchCache(key, softTags, tags) : this.getIncrementalCache(key); + return isFetchCache(options) + ? this.getFetchCache(key, [...(options?.tags ?? []), ...(options?.softTags ?? [])]) + : this.getIncrementalCache(key); } - async getFetchCache(key: string, softTags?: string[], tags?: string[]) { - debug("get fetch cache", { key, softTags, tags }); + async getFetchCache(key: string, additionalTags: string[] = []): Promise { + debug("get fetch cache", { key }); try { - const cachedEntry = await globalThis.incrementalCache.get(key, "fetch"); - - if (cachedEntry?.value === undefined) return null; + const result = await globalThis.cache.get(key, "fetch", additionalTags); - const _tags = [...(tags ?? []), ...(softTags ?? [])]; - const _lastModified = cachedEntry.lastModified ?? Date.now(); - const _hasBeenRevalidated = cachedEntry.shouldBypassTagCache - ? false - : await hasBeenRevalidated<"fetch">(key, _tags, cachedEntry); - - if (_hasBeenRevalidated) return null; - - // For cases where we don't have tags, we need to ensure that the soft tags are not being revalidated - // We only need to check for the path as it should already contain all the tags - if ((tags ?? []).length === 0) { - // Then we need to find the path for the given key - const path = softTags?.find( - (tag) => tag.startsWith(SOFT_TAG_PREFIX) && !tag.endsWith("layout") && !tag.endsWith("page") - ); - if (path) { - const hasPathBeenUpdated = cachedEntry.shouldBypassTagCache - ? false - : 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; - } - } - } + if (!result?.value) return null; return { - lastModified: _lastModified, - value: cachedEntry.value, + lastModified: result.lastModified ?? Date.now(), + value: result.value, } as CacheHandlerValue; } catch (e) { // We can usually ignore errors here as they are usually due to cache not being found @@ -80,7 +51,7 @@ export default class Cache { async getIncrementalCache(key: string): Promise { try { - const cachedEntry = await globalThis.incrementalCache.get(key, "cache"); + const cachedEntry = await globalThis.cache.get(key, "cache"); if (!cachedEntry?.value) { return null; @@ -89,12 +60,7 @@ export default class Cache { const cacheData = cachedEntry.value; const meta = cacheData.meta; - const tags = getTagsFromValue(cacheData); const _lastModified = cachedEntry.lastModified ?? Date.now(); - const _hasBeenRevalidated = cachedEntry.shouldBypassTagCache - ? false - : await hasBeenRevalidated(key, tags, cachedEntry); - if (_hasBeenRevalidated) return null; const store = globalThis.__openNextAls.getStore(); if (store) { @@ -174,14 +140,14 @@ export default class Cache { const detachedPromise = globalThis.__openNextAls.getStore()?.pendingPromiseRunner.withResolvers(); try { if (data === null || data === undefined) { - await globalThis.incrementalCache.delete(key); + await globalThis.cache.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 globalThis.cache.set( key, { type: "route", @@ -201,7 +167,7 @@ export default class Cache { const { html, pageData, status, headers } = data; const isAppPath = typeof pageData === "string"; if (isAppPath) { - await globalThis.incrementalCache.set( + await globalThis.cache.set( key, { type: "app", @@ -216,7 +182,7 @@ export default class Cache { "cache" ); } else { - await globalThis.incrementalCache.set( + await globalThis.cache.set( key, { type: "page", @@ -237,7 +203,7 @@ export default class Cache { segmentToWrite[segmentPath] = segmentContent.toString("utf8"); } } - await globalThis.incrementalCache.set( + await globalThis.cache.set( key, { type: "app", @@ -256,10 +222,10 @@ export default class Cache { break; } case "FETCH": - await globalThis.incrementalCache.set(key, data, "fetch"); + await globalThis.cache.set(key, data, "fetch"); break; case "REDIRECT": - await globalThis.incrementalCache.set( + await globalThis.cache.set( key, { type: "redirect", @@ -275,7 +241,6 @@ export default class Cache { } } - await this.updateTagsOnSet(key, data, ctx); debug("Finished setting cache"); } catch (e) { error("Failed to set cache", e); @@ -296,135 +261,12 @@ export default class Cache { } try { - if (globalThis.tagCache.mode === "nextMode") { - const paths = (await globalThis.tagCache.getPathsByTags?.(_tags)) ?? []; - - await writeTags(_tags); - if (paths.length > 0) { - // TODO: we should introduce a new method in cdnInvalidationHandler to invalidate paths by tags for cdn that supports it - // It also means that we'll need to provide the tags used in every request to the wrapper or converter. - await globalThis.cdnInvalidationHandler.invalidatePaths( - paths.map((path) => ({ - initialPath: path, - rawPath: path, - resolvedRoutes: [ - { - route: path, - // TODO: ideally here we should check if it's an app router page or route - type: "app", - isFallback: false, - }, - ], - })) - ); - } - return; - } - - for (const tag of _tags) { - debug("revalidateTag", tag); - // Find all keys with the given tag - const paths = await globalThis.tagCache.getByTag(tag); - debug("Items", paths); - const toInsert = paths.map((path) => ({ - path, - tag, - })); - - // If the tag is a soft tag, we should also revalidate the hard tags - if (tag.startsWith(SOFT_TAG_PREFIX)) { - for (const path of paths) { - // We need to find all hard tags for a given path - const _tags = await globalThis.tagCache.getByPath(path); - const hardTags = _tags.filter((t) => !t.startsWith(SOFT_TAG_PREFIX)); - // For every hard tag, we need to find all paths and revalidate them - for (const hardTag of hardTags) { - const _paths = await globalThis.tagCache.getByTag(hardTag); - debug({ hardTag, _paths }); - toInsert.push( - ..._paths.map((path) => ({ - path, - tag: hardTag, - })) - ); - } - } - } - - // Update all keys with the given tag with revalidatedAt set to now - await writeTags(toInsert); - - // We can now invalidate all paths in the CDN - // This only applies to `revalidateTag`, not to `res.revalidate()` - const uniquePaths = Array.from( - new Set( - toInsert - // We need to filter fetch cache key as they are not in the CDN - .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, - // TODO: ideally here we should check if it's an app router page or route - type: "app", - isFallback: false, - }, - ], - })) - ); - } - } + await globalThis.cache.revalidateTags(_tags); } catch (e) { error("Failed to revalidate tag", e); } } - // TODO: We should delete/update tags in this method - // This will require an update to the tag cache interface - private async updateTagsOnSet(key: string, data?: IncrementalCacheValue, ctx?: IncrementalCacheContext) { - if ( - globalThis.openNextConfig.dangerous?.disableTagCache || - globalThis.tagCache.mode === "nextMode" || - // Here it means it's a delete - !data - ) { - return; - } - // Write derivedTags to the tag cache - // If we use an in house version of getDerivedTags in build we should use it here instead of next's one - const derivedTags: string[] = - data?.kind === "FETCH" - ? //@ts-expect-error - On older versions of next, ctx was a number, but for these cases we use data?.data?.tags - (ctx?.tags ?? data?.data?.tags ?? []) // before version 14 next.js used data?.data?.tags so we keep it for backward compatibility - : data?.kind === "PAGE" - ? (data.headers?.["x-next-cache-tags"]?.split(",") ?? []) - : []; - debug("derivedTags", derivedTags); - - // Get all tags stored in dynamodb for the given key - // If any of the derived tags are not stored in dynamodb for the given key, write them - const storedTags = await globalThis.tagCache.getByPath(key); - const tagsToWrite = derivedTags.filter((tag) => !storedTags.includes(tag)); - if (tagsToWrite.length > 0) { - await writeTags( - tagsToWrite.map((tag) => ({ - path: key, - tag: tag, - // In case the tags are not there we just need to create them - // but we don't want them to return from `getLastModified` as they are not stale - revalidatedAt: 1, - })) - ); - } - } - private extractRevalidateForSet(ctx?: IncrementalCacheContext): number | false | undefined { if (ctx === undefined) { return undefined; diff --git a/packages/core/src/adapters/composable-cache.ts b/packages/core/src/adapters/composable-cache.ts index a6fb19c3..820fbd41 100644 --- a/packages/core/src/adapters/composable-cache.ts +++ b/packages/core/src/adapters/composable-cache.ts @@ -1,6 +1,5 @@ import type { ComposableCacheEntry, ComposableCacheHandler } from "@/types/cache"; import type { CacheValue } from "@/types/overrides"; -import { writeTags } from "@/utils/cache"; import { fromReadableStream, toReadableStream } from "@/utils/stream"; import { debug } from "./logger"; @@ -21,26 +20,13 @@ export default { })); } } - const result = await globalThis.incrementalCache.get(cacheKey, "composable"); + const result = await globalThis.cache.get(cacheKey, "composable"); if (!result?.value?.value) { return undefined; } debug("composable cache result", result); - // We need to check if the tags associated with this entry has been revalidated - if (globalThis.tagCache.mode === "nextMode" && result.value.tags.length > 0) { - const hasBeenRevalidated = result.shouldBypassTagCache - ? false - : await globalThis.tagCache.hasBeenRevalidated(result.value.tags, result.lastModified); - if (hasBeenRevalidated) return undefined; - } else if (globalThis.tagCache.mode === "original" || globalThis.tagCache.mode === undefined) { - const hasBeenRevalidated = result.shouldBypassTagCache - ? false - : (await globalThis.tagCache.getLastModified(cacheKey, result.lastModified)) === -1; - if (hasBeenRevalidated) return undefined; - } - return { ...result.value, value: toReadableStream(result.value.value), @@ -61,7 +47,7 @@ export default { const entry = await promiseEntry.finally(() => { pendingWritePromiseMap.delete(cacheKey); }); - await globalThis.incrementalCache.set( + await globalThis.cache.set( cacheKey, { ...entry, @@ -69,13 +55,6 @@ export default { }, "composable" ); - if (globalThis.tagCache.mode === "original") { - const storedTags = await globalThis.tagCache.getByPath(cacheKey); - const tagsToWrite = entry.tags.filter((tag) => !storedTags.includes(tag)); - if (tagsToWrite.length > 0) { - await writeTags(tagsToWrite.map((tag) => ({ tag, path: cacheKey }))); - } - } }, async refreshTags() { @@ -89,12 +68,8 @@ export default { * - From Next.js 16, the method takes `tags: string[]` */ async getExpiration(...tags: string[] | string[][]) { - if (globalThis.tagCache.mode === "nextMode") { - // Use `.flat()` to accommodate both signatures - return globalThis.tagCache.getLastRevalidated(tags.flat()); - } - // We always return 0 here, original tag cache are handled directly in the get part - // TODO: We need to test this more, i'm not entirely sure that this is working as expected + // Tag revalidation is handled transparently in the cache layer's get(), + // so we always return 0 here to let get() determine freshness. return 0; }, @@ -102,29 +77,10 @@ export default { * This method is only used before Next.js 16 */ async expireTags(...tags: string[]) { - if (globalThis.tagCache.mode === "nextMode") { - return writeTags(tags); - } - const tagCache = globalThis.tagCache; - const revalidatedAt = Date.now(); - // For the original mode, we have more work to do here. - // We need to find all paths linked to to these tags - const pathsToUpdate = await Promise.all( - tags.map(async (tag) => { - const paths = await tagCache.getByTag(tag); - return paths.map((path) => ({ - path, - tag, - revalidatedAt, - })); - }) - ); - // We need to deduplicate paths, we use a set for that - const setToWrite = new Set<{ path: string; tag: string }>(); - for (const entry of pathsToUpdate.flat()) { - setToWrite.add(entry); + const flatTags = tags.flat(); + if (flatTags.length > 0) { + await globalThis.cache.revalidateTags(flatTags); } - await writeTags(Array.from(setToWrite)); }, // This one is necessary for older versions of next diff --git a/packages/core/src/adapters/middleware.ts b/packages/core/src/adapters/middleware.ts index 43038f8d..340e4aab 100644 --- a/packages/core/src/adapters/middleware.ts +++ b/packages/core/src/adapters/middleware.ts @@ -12,11 +12,9 @@ import { createGenericHandler } from "../core/createGenericHandler"; import { resolveAssetResolver, resolveCache, - resolveIncrementalCache, resolveOriginResolver, resolveProxyRequest, resolveQueue, - resolveTagCache, } from "../core/resolve"; import { constructNextUrl } from "../core/routing/util"; import routingHandler, { @@ -41,12 +39,8 @@ const defaultHandler = async ( const assetResolver = await resolveAssetResolver(middlewareConfig?.assetResolver); - globalThis.tagCache = await resolveTagCache(middlewareConfig?.override?.tagCache); - globalThis.queue = await resolveQueue(middlewareConfig?.override?.queue); - globalThis.incrementalCache = await resolveIncrementalCache(middlewareConfig?.override?.incrementalCache); - globalThis.cache = await resolveCache(middlewareConfig?.override?.cache); const requestId = Math.random().toString(36); diff --git a/packages/core/src/build/edge/createEdgeBundle.ts b/packages/core/src/build/edge/createEdgeBundle.ts index 9cd63616..cbeef42f 100644 --- a/packages/core/src/build/edge/createEdgeBundle.ts +++ b/packages/core/src/build/edge/createEdgeBundle.ts @@ -77,8 +77,7 @@ export async function buildEdgeBundle({ overrides: { wrapper: override("wrapper"), converter: override("converter"), - tagCache: override("tagCache"), - incrementalCache: override("incrementalCache"), + cache: override("cache"), queue: override("queue"), originResolver: override("originResolver"), proxyExternalRequest: override("proxyExternalRequest"), @@ -86,9 +85,7 @@ export async function buildEdgeBundle({ defaultOverrides: { wrapper: defaultOverrides?.wrapper ?? "@opennextjs/core/overrides/wrappers/dummy.js", converter: defaultOverrides?.converter ?? defaultConverter, - 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/generateOutput.ts b/packages/core/src/build/generateOutput.ts index 56e39186..595aab37 100644 --- a/packages/core/src/build/generateOutput.ts +++ b/packages/core/src/build/generateOutput.ts @@ -143,18 +143,20 @@ async function extractOverrideFn(override?: DefaultOverrideOptions) { return { wrapper, converter }; } +//TODO: fix this, this is stupid async function extractCommonOverride(override?: OverrideOptions) { if (!override) { return { queue: "sqs", - incrementalCache: "s3", - tagCache: "dynamodb", + incrementalCache: "s3" as const, + tagCache: "dynamodb" as const, }; } const queue = await extractOverrideName("sqs", override.queue); - const incrementalCache = await extractOverrideName("s3", override.incrementalCache); - const tagCache = await extractOverrideName("dynamodb", override.tagCache); - return { queue, incrementalCache, tagCache }; + // incrementalCache and tagCache are no longer in OverrideOptions — they use defaults. + // When using a composite cache (default), composite.ts wraps s3 + dynamodb internally. + // Custom implementations should be provided via the cacheHandler config or a custom cache override. + return { queue, incrementalCache: "s3" as const, tagCache: "dynamodb" as const }; } function prefixPattern(basePath: string) { diff --git a/packages/core/src/build/middleware/buildNodeMiddleware.ts b/packages/core/src/build/middleware/buildNodeMiddleware.ts index 8f1957c4..81d49cc0 100644 --- a/packages/core/src/build/middleware/buildNodeMiddleware.ts +++ b/packages/core/src/build/middleware/buildNodeMiddleware.ts @@ -65,8 +65,7 @@ export async function buildExternalNodeMiddleware( overrides: { wrapper: override("wrapper"), converter: override("converter"), - tagCache: override("tagCache"), - incrementalCache: override("incrementalCache"), + cache: override("cache"), queue: override("queue"), originResolver: override("originResolver"), proxyExternalRequest: override("proxyExternalRequest"), @@ -74,9 +73,7 @@ export async function buildExternalNodeMiddleware( defaultOverrides: { 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", + 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/core/createMainHandler.ts b/packages/core/src/core/createMainHandler.ts index eb6129b3..a158742d 100644 --- a/packages/core/src/core/createMainHandler.ts +++ b/packages/core/src/core/createMainHandler.ts @@ -9,10 +9,8 @@ import { resolveCache, resolveCdnInvalidation, resolveConverter, - resolveIncrementalCache, resolveProxyRequest, resolveQueue, - resolveTagCache, resolveWrapper, } from "./resolve"; @@ -32,10 +30,6 @@ export async function createMainHandler() { // Default queue globalThis.queue = await resolveQueue(thisFunction.override?.queue); - globalThis.incrementalCache = await resolveIncrementalCache(thisFunction.override?.incrementalCache); - - globalThis.tagCache = await resolveTagCache(thisFunction.override?.tagCache); - if (config.middleware?.external !== true) { globalThis.assetResolver = await resolveAssetResolver( globalThis.openNextConfig.middleware?.assetResolver diff --git a/packages/core/src/core/resolve.ts b/packages/core/src/core/resolve.ts index 2bea8501..6b09e35a 100644 --- a/packages/core/src/core/resolve.ts +++ b/packages/core/src/core/resolve.ts @@ -44,7 +44,9 @@ export async function resolveWrapper< * @returns * @__PURE__ */ -export async function resolveTagCache(tagCache: OverrideOptions["tagCache"]): Promise { +export async function resolveTagCache( + tagCache: RemoveUndefined["tagCache"] +): Promise { if (typeof tagCache === "function") { return tagCache(); } @@ -72,7 +74,9 @@ export async function resolveQueue(queue: OverrideOptions["queue"]) { * @returns * @__PURE__ */ -export async function resolveIncrementalCache(incrementalCache: OverrideOptions["incrementalCache"]) { +export async function resolveIncrementalCache( + incrementalCache: RemoveUndefined["incrementalCache"] +) { if (typeof incrementalCache === "function") { return incrementalCache(); } diff --git a/packages/core/src/core/routing/cacheInterceptor.ts b/packages/core/src/core/routing/cacheInterceptor.ts index 271b123f..d54b5781 100644 --- a/packages/core/src/core/routing/cacheInterceptor.ts +++ b/packages/core/src/core/routing/cacheInterceptor.ts @@ -4,7 +4,6 @@ import { NextConfig, PrerenderManifest } from "@/config/index"; import type { InternalEvent, InternalResult, MiddlewareEvent, PartialResult } from "@/types/open-next"; import type { CacheValue } from "@/types/overrides"; import { isBinaryContentType } from "@/utils/binary"; -import { getTagsFromValue, hasBeenRevalidated } from "@/utils/cache"; import { emptyReadableStream, toReadableStream } from "@/utils/stream"; import { debug, error } from "../../adapters/logger"; @@ -340,24 +339,12 @@ export async function cacheInterceptor( } else if (localizedPath === "") { pathToUse = "/index"; } - const cachedData = await globalThis.incrementalCache.get(pathToUse); + const cachedData = await globalThis.cache.get(pathToUse); debug("cached data in interceptor", cachedData); if (!cachedData?.value) { return event; } - // We need to check the tag cache now - if (cachedData.value?.type === "app" || cachedData.value?.type === "route") { - const tags = getTagsFromValue(cachedData.value); - - const _hasBeenRevalidated = cachedData.shouldBypassTagCache - ? false - : await hasBeenRevalidated(localizedPath, tags, cachedData); - - if (_hasBeenRevalidated) { - return event; - } - } const host = event.headers.host; switch (cachedData?.value?.type) { case "app": diff --git a/packages/core/src/overrides/cache/fetch.ts b/packages/core/src/overrides/cache/fetch.ts index dee2ec18..9d8825c4 100644 --- a/packages/core/src/overrides/cache/fetch.ts +++ b/packages/core/src/overrides/cache/fetch.ts @@ -5,9 +5,10 @@ const CACHE_URL = process.env.OPEN_NEXT_CACHE_URL ?? ""; const fetchCache: Cache = { name: "fetch-cache", - get: async (key, cacheType) => { + get: async (key, cacheType, additionalTags) => { const query: Record = {}; if (cacheType) query.type = cacheType; + if (additionalTags && additionalTags.length > 0) query.tags = additionalTags.join(","); 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" }); diff --git a/packages/core/src/overrides/cache/local.ts b/packages/core/src/overrides/cache/local.ts index d0e063ae..33cc78f9 100644 --- a/packages/core/src/overrides/cache/local.ts +++ b/packages/core/src/overrides/cache/local.ts @@ -19,17 +19,20 @@ async function getHandler() { const localCache: Cache = { name: "local-cache", - get: async (key, cacheType) => { + get: async (key, cacheType, additionalTags) => { const h = (await getHandler())!; const encodedKey = encodeURIComponent(key); const url = `https://on/cache/${encodedKey}`; + const query: Record = {}; + if (cacheType) query.type = cacheType; + if (additionalTags && additionalTags.length > 0) query.tags = additionalTags.join(","); const event: InternalEvent = { type: "core", method: "GET", rawPath: `/cache/${encodedKey}`, url, headers: {}, - query: cacheType ? { type: cacheType } : {}, + query, cookies: {}, remoteAddress: "127.0.0.1", }; diff --git a/packages/core/src/plugins/resolve.ts b/packages/core/src/plugins/resolve.ts index 87ed3f61..6992d5ad 100644 --- a/packages/core/src/plugins/resolve.ts +++ b/packages/core/src/plugins/resolve.ts @@ -8,12 +8,14 @@ import type { Plugin } from "esbuild"; import type { DefaultOverrideOptions, IncludedImageLoader, + IncludedIncrementalCache, IncludedOriginResolver, + IncludedTagCache, IncludedWarmer, LazyLoadedOverride, OverrideOptions, } from "@/types/open-next"; -import type { ImageLoader, OriginResolver, Warmer } from "@/types/overrides"; +import type { ImageLoader, IncrementalCache, OriginResolver, TagCache, Warmer } from "@/types/overrides"; import logger from "../logger.js"; import { getCrossPlatformPathRegex } from "../utils/regex.js"; @@ -24,9 +26,9 @@ export interface IPluginSettings { wrapper?: DefaultOverrideOptions["wrapper"]; // oxlint-disable-next-line @typescript-eslint/no-explicit-any - generic overrides for flexibility converter?: DefaultOverrideOptions["converter"]; - tagCache?: OverrideOptions["tagCache"]; + tagCache?: IncludedTagCache | LazyLoadedOverride; queue?: OverrideOptions["queue"]; - incrementalCache?: OverrideOptions["incrementalCache"]; + incrementalCache?: IncludedIncrementalCache | LazyLoadedOverride; imageLoader?: LazyLoadedOverride | IncludedImageLoader; originResolver?: LazyLoadedOverride | IncludedOriginResolver; warmer?: LazyLoadedOverride | IncludedWarmer; diff --git a/packages/core/src/types/global.ts b/packages/core/src/types/global.ts index 65e03701..552bc664 100644 --- a/packages/core/src/types/global.ts +++ b/packages/core/src/types/global.ts @@ -75,15 +75,15 @@ declare global { // Needed in the cache adapter /** * The cache adapter for incremental static regeneration. - * Only available in main functions and in the external middleware when `enableCacheInterception` is `true`. - * Defined in `createMainHandler` and in `adapters/middleware.ts`. + * Only set in the cache handler function (cache-adapter.ts) from `cacheHandler` config. + * Not available in main functions or middleware anymore — use `globalThis.cache` instead. */ var incrementalCache: IncrementalCache; /** * The cache adapter for the tag cache. - * Only available in main functions and in the external middleware when `enableCacheInterception` is `true`. - * Defined in `createMainHandler` and in `adapters/middleware.ts`. + * Only set in the cache handler function (cache-adapter.ts) from `cacheHandler` config. + * Not available in main functions or middleware anymore — use `globalThis.cache` instead. */ var tagCache: TagCache; diff --git a/packages/core/src/types/open-next.ts b/packages/core/src/types/open-next.ts index 46e472f1..a7045250 100644 --- a/packages/core/src/types/open-next.ts +++ b/packages/core/src/types/open-next.ts @@ -254,18 +254,6 @@ export interface DefaultOverrideOptions< } export interface OverrideOptions extends DefaultOverrideOptions { - /** - * Add possibility to override the default s3 cache. Used for fetch cache and html/rsc/json cache. - * @default "s3" - */ - incrementalCache?: IncludedIncrementalCache | LazyLoadedOverride; - - /** - * Add possibility to override the default tag cache. Used for revalidateTags and revalidatePath. - * @default "dynamodb" - */ - tagCache?: IncludedTagCache | LazyLoadedOverride; - /** * Add possibility to override the default queue. Used for isr. * @default "sqs" diff --git a/packages/core/src/types/overrides.ts b/packages/core/src/types/overrides.ts index 9c4f980c..fd767559 100644 --- a/packages/core/src/types/overrides.ts +++ b/packages/core/src/types/overrides.ts @@ -256,7 +256,8 @@ export type ProxyExternalRequest = BaseOverride & { export type Cache = BaseOverride & { get( key: string, - cacheType?: CacheType + cacheType?: CacheType, + additionalTags?: string[] ): Promise> | null>; set( key: string, diff --git a/packages/core/src/utils/cache.ts b/packages/core/src/utils/cache.ts index 090bc247..98f14441 100644 --- a/packages/core/src/utils/cache.ts +++ b/packages/core/src/utils/cache.ts @@ -2,6 +2,7 @@ import type { CacheEntryType, CacheValue, OriginalTagCacheWriteInput, + TagCache, WithLastModified, } from "@/types/overrides"; @@ -10,7 +11,8 @@ import { debug } from "../adapters/logger"; export async function hasBeenRevalidated( key: string, tags: string[], - cacheEntry: WithLastModified> + cacheEntry: WithLastModified>, + tagCache: TagCache = globalThis.tagCache ): Promise { if (globalThis.openNextConfig.dangerous?.disableTagCache) { return false; @@ -24,11 +26,11 @@ export async function hasBeenRevalidated( return false; } const lastModified = cacheEntry.lastModified ?? Date.now(); - if (globalThis.tagCache.mode === "nextMode") { - return tags.length === 0 ? false : await globalThis.tagCache.hasBeenRevalidated(tags, lastModified); + if (tagCache.mode === "nextMode") { + return tags.length === 0 ? false : await tagCache.hasBeenRevalidated(tags, lastModified); } // TODO: refactor this, we should introduce a new method in the tagCache interface so that both implementations use hasBeenRevalidated - const _lastModified = await globalThis.tagCache.getLastModified(key, lastModified); + const _lastModified = await tagCache.getLastModified(key, lastModified); return _lastModified === -1; } @@ -56,7 +58,10 @@ function getTagKey(tag: string | OriginalTagCacheWriteInput): string { }); } -export async function writeTags(tags: (string | OriginalTagCacheWriteInput)[]): Promise { +export async function writeTags( + tags: (string | OriginalTagCacheWriteInput)[], + tagCache: TagCache = globalThis.tagCache +): Promise { const store = globalThis.__openNextAls.getStore(); debug("Writing tags", tags, store); if (!store || globalThis.openNextConfig.dangerous?.disableTagCache) { @@ -75,8 +80,7 @@ export async function writeTags(tags: (string | OriginalTagCacheWriteInput)[]): if (tagsToWrite.length === 0) { return; } - // Here we know that we have the correct type // oxlint-disable-next-line @typescript-eslint/no-explicit-any - writeTags accepts a union type that typescript cannot infer correctly - await globalThis.tagCache.writeTags(tagsToWrite as any); + await tagCache.writeTags(tagsToWrite as any); } 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..1fd3ba53 --- /dev/null +++ b/packages/tests-unit/tests/adapters/cache-adapter.test.ts @@ -0,0 +1,680 @@ +import { AsyncLocalStorage } from "node:async_hooks"; + +import { handler } from "@opennextjs/core/adapters/cache-adapter"; +import type { InternalEvent, InternalResult, OpenNextConfig } from "@opennextjs/core/types/open-next"; +import { fromReadableStream } from "@opennextjs/core/utils/stream"; +import { type Mock, vi, describe, expect, it, beforeEach } from "vitest"; + +const mockResolveIncrementalCache = vi.hoisted(() => vi.fn()); +const mockResolveTagCache = vi.hoisted(() => vi.fn()); +const mockResolveCdnInvalidation = vi.hoisted(() => vi.fn()); + +const mockIncrementalCache = vi.hoisted(() => ({ + name: "mock", + get: vi.fn(), + set: vi.fn(), + delete: vi.fn(), +})); + +const mockTagCache = vi.hoisted(() => ({ + name: "mock", + mode: "original", + getByTag: vi.fn(), + getByPath: vi.fn(), + getLastModified: vi.fn(), + writeTags: vi.fn(), + hasBeenRevalidated: vi.fn(), + getPathsByTags: undefined as Mock | undefined, +})); + +const mockCdnInvalidationHandler = vi.hoisted(() => ({ + name: "mock", + invalidatePaths: vi.fn(), +})); + +vi.mock("@opennextjs/core/core/resolve", () => ({ + resolveIncrementalCache: mockResolveIncrementalCache, + resolveTagCache: mockResolveTagCache, + resolveCdnInvalidation: mockResolveCdnInvalidation, +})); + +vi.mock("@opennextjs/core/core/createGenericHandler", () => ({ + createGenericHandler: vi.fn( + async ({ + handler: h, + }: { + handler: (event: InternalEvent, options?: unknown) => Promise; + }) => { + //@ts-ignore + globalThis.openNextConfig = { + dangerous: {}, + } as Partial; + return async (event: InternalEvent, options?: unknown) => h(event, options); + } + ), +})); + +function createEvent(overrides: Partial = {}): InternalEvent { + return { + type: "core", + method: "GET", + rawPath: "/cache/test-key", + url: "https://on/cache/test-key", + headers: {}, + query: {}, + cookies: {}, + remoteAddress: "127.0.0.1", + ...overrides, + }; +} + +async function runHandler(event: InternalEvent): Promise { + return globalThis.__openNextAls.run( + { + requestId: "test-request", + pendingPromiseRunner: { + withResolvers: () => ({ + resolve: vi.fn(), + promise: Promise.resolve(), + }), + }, + isISRRevalidation: false, + writtenTags: new Set(), + }, + () => handler(event) + ); +} + +describe("cache-adapter", () => { + beforeEach(() => { + vi.clearAllMocks(); + globalThis.__openNextAls = new AsyncLocalStorage(); + // @ts-ignore + globalThis.openNextConfig = { dangerous: {} } as Partial; + mockResolveIncrementalCache.mockResolvedValue(mockIncrementalCache); + mockResolveTagCache.mockResolvedValue(mockTagCache); + mockResolveCdnInvalidation.mockResolvedValue(mockCdnInvalidationHandler); + mockTagCache.mode = "original"; + mockTagCache.getPathsByTags = undefined; + }); + + describe("routing", () => { + it("should return 404 for non-cache paths", async () => { + const event = createEvent({ rawPath: "/other/path" }); + const result = await runHandler(event); + + expect(result.statusCode).toBe(404); + const body = await fromReadableStream(result.body); + expect(body).toContain("Not Found"); + }); + + it("should return 400 for missing cache key", async () => { + const event = createEvent({ rawPath: "/cache/" }); + const result = await runHandler(event); + + expect(result.statusCode).toBe(400); + const body = await fromReadableStream(result.body); + expect(body).toContain("Missing cache key"); + }); + + it("should return 405 for unknown method", async () => { + const event = createEvent({ method: "PATCH" }); + const result = await runHandler(event); + + expect(result.statusCode).toBe(405); + const body = await fromReadableStream(result.body); + expect(body).toContain("Method Not Allowed"); + }); + }); + + describe("GET /cache/:key", () => { + it("should return 404 when cache entry is not found", async () => { + mockIncrementalCache.get.mockResolvedValue(null); + + const result = await runHandler(createEvent()); + + expect(result.statusCode).toBe(404); + expect(result.headers["x-opennext-cache-found"]).toBe("false"); + }); + + it("should return 404 when cache entry value is missing", async () => { + mockIncrementalCache.get.mockResolvedValue({}); + + const result = await runHandler(createEvent()); + + expect(result.statusCode).toBe(404); + expect(result.headers["x-opennext-cache-found"]).toBe("false"); + }); + + it("should return 200 with route cache data", async () => { + mockIncrementalCache.get.mockResolvedValue({ + value: { type: "route", body: "route-body" }, + lastModified: 1000, + }); + + const result = await runHandler(createEvent()); + + expect(result.statusCode).toBe(200); + expect(result.headers["x-opennext-cache-found"]).toBe("true"); + expect(result.headers["x-opennext-cache-type"]).toBe("cache"); + expect(result.headers["x-opennext-cache-sub-type"]).toBe("route"); + expect(result.headers["x-opennext-cache-last-modified"]).toBe("1000"); + const body = await fromReadableStream(result.body); + expect(body).toBe("route-body"); + }); + + it("should return 200 with page cache data", async () => { + mockIncrementalCache.get.mockResolvedValue({ + value: { type: "page", html: "", json: { data: 1 } }, + lastModified: 1000, + }); + + const result = await runHandler(createEvent()); + + expect(result.statusCode).toBe(200); + expect(result.headers["x-opennext-cache-type"]).toBe("cache"); + expect(result.headers["x-opennext-cache-sub-type"]).toBe("page"); + const body = await fromReadableStream(result.body); + const parsed = JSON.parse(body); + expect(parsed).toEqual({ html: "", json: { data: 1 } }); + }); + + it("should return 200 with app cache data", async () => { + mockIncrementalCache.get.mockResolvedValue({ + value: { + type: "app", + html: "", + rsc: "rsc-data", + segmentData: { seg1: "data1" }, + }, + lastModified: 1000, + }); + + const result = await runHandler(createEvent()); + + expect(result.statusCode).toBe(200); + expect(result.headers["x-opennext-cache-type"]).toBe("cache"); + expect(result.headers["x-opennext-cache-sub-type"]).toBe("app"); + const body = await fromReadableStream(result.body); + const parsed = JSON.parse(body); + expect(parsed.html).toBe(""); + expect(parsed.rsc).toBe("rsc-data"); + expect(parsed.segmentData).toEqual({ seg1: "data1" }); + }); + + it("should return 200 with redirect cache data", async () => { + mockIncrementalCache.get.mockResolvedValue({ + value: { type: "redirect", props: { destination: "/new" } }, + lastModified: 1000, + }); + + const result = await runHandler(createEvent()); + + expect(result.statusCode).toBe(200); + expect(result.headers["x-opennext-cache-type"]).toBe("cache"); + expect(result.headers["x-opennext-cache-sub-type"]).toBe("redirect"); + }); + + it("should return 200 with fetch cache data", async () => { + mockIncrementalCache.get.mockResolvedValue({ + value: { + kind: "FETCH", + data: { + headers: { "content-type": "text/plain" }, + body: "fetch-body", + url: "https://example.com", + status: 200, + }, + }, + lastModified: 1000, + }); + + const result = await runHandler(createEvent()); + + expect(result.statusCode).toBe(200); + expect(result.headers["x-opennext-cache-type"]).toBe("fetch"); + expect(result.headers["x-opennext-cache-fetch-kind"]).toBe("FETCH"); + expect(result.headers["x-opennext-cache-fetch-data-url"]).toBe("https://example.com"); + const body = await fromReadableStream(result.body); + expect(body).toBe("fetch-body"); + }); + + it("should use ?type=fetch when query param is provided", async () => { + mockIncrementalCache.get.mockResolvedValue(null); + const event = createEvent({ query: { type: "fetch" } }); + + await runHandler(event); + + expect(mockIncrementalCache.get).toHaveBeenCalledWith("test-key", "fetch"); + }); + + it("should use ?type=cache by default", async () => { + mockIncrementalCache.get.mockResolvedValue(null); + + await runHandler(createEvent()); + + expect(mockIncrementalCache.get).toHaveBeenCalledWith("test-key", "cache"); + }); + + it("should return 500 when incremental cache throws", async () => { + mockIncrementalCache.get.mockRejectedValue(new Error("cache error")); + + const result = await runHandler(createEvent()); + + expect(result.statusCode).toBe(500); + }); + }); + + describe("tag revalidation in GET", () => { + it("should return cached value when there are no tags", async () => { + mockTagCache.mode = "original"; + mockIncrementalCache.get.mockResolvedValue({ + value: { type: "route", body: "data" }, + lastModified: 1000, + }); + + const result = await runHandler(createEvent()); + + expect(result.statusCode).toBe(200); + expect(mockTagCache.getLastModified).not.toHaveBeenCalled(); + }); + + it("should check tag revalidation in nextMode", async () => { + mockTagCache.mode = "nextMode"; + mockTagCache.hasBeenRevalidated.mockResolvedValue(false); + mockIncrementalCache.get.mockResolvedValue({ + value: { + type: "route", + body: "data", + meta: { headers: { "x-next-cache-tags": "tag1" } }, + }, + lastModified: 1000, + }); + + const result = await runHandler(createEvent()); + + expect(mockTagCache.hasBeenRevalidated).toHaveBeenCalledWith(["tag1"], 1000); + expect(result.statusCode).toBe(200); + }); + + it("should return 404 when tags have been revalidated in nextMode", async () => { + mockTagCache.mode = "nextMode"; + mockTagCache.hasBeenRevalidated.mockResolvedValue(true); + mockIncrementalCache.get.mockResolvedValue({ + value: { + type: "route", + body: "data", + meta: { headers: { "x-next-cache-tags": "tag1" } }, + }, + lastModified: 1000, + }); + + const result = await runHandler(createEvent()); + + expect(result.statusCode).toBe(404); + expect(result.headers["x-opennext-cache-tag-status"]).toBe("revalidated"); + }); + + it("should check last modified in original mode", async () => { + mockTagCache.mode = "original"; + mockTagCache.getLastModified.mockResolvedValue(1000); + mockIncrementalCache.get.mockResolvedValue({ + value: { + type: "route", + body: "data", + meta: { headers: { "x-next-cache-tags": "tag1" } }, + }, + lastModified: 1000, + }); + + const result = await runHandler(createEvent()); + + expect(mockTagCache.getLastModified).toHaveBeenCalledWith("test-key", 1000); + expect(result.statusCode).toBe(200); + }); + + it("should return 404 when tags have been revalidated in original mode", async () => { + mockTagCache.mode = "original"; + mockTagCache.getLastModified.mockResolvedValue(-1); + mockIncrementalCache.get.mockResolvedValue({ + value: { + type: "route", + body: "data", + meta: { headers: { "x-next-cache-tags": "tag1" } }, + }, + lastModified: 1000, + }); + + const result = await runHandler(createEvent()); + + expect(result.statusCode).toBe(404); + expect(result.headers["x-opennext-cache-tag-status"]).toBe("revalidated"); + }); + + it("should skip tag revalidation when shouldBypassTagCache is true", async () => { + mockIncrementalCache.get.mockResolvedValue({ + value: { type: "route", body: "data" }, + lastModified: 1000, + shouldBypassTagCache: true, + }); + + const result = await runHandler(createEvent()); + + expect(result.statusCode).toBe(200); + expect(mockTagCache.getLastModified).not.toHaveBeenCalled(); + expect(mockTagCache.hasBeenRevalidated).not.toHaveBeenCalled(); + }); + + it("should skip tag revalidation when disableTagCache is true", async () => { + // @ts-ignore + globalThis.openNextConfig = { + dangerous: { disableTagCache: true }, + } as Partial; + mockIncrementalCache.get.mockResolvedValue({ + value: { type: "route", body: "data" }, + lastModified: 1000, + }); + + const result = await runHandler(createEvent()); + + expect(result.statusCode).toBe(200); + expect(mockTagCache.getLastModified).not.toHaveBeenCalled(); + expect(mockTagCache.hasBeenRevalidated).not.toHaveBeenCalled(); + }); + }); + + describe("PUT /cache/:key", () => { + it("should return 400 when body is missing", async () => { + const result = await runHandler(createEvent({ method: "PUT" })); + + expect(result.statusCode).toBe(400); + }); + + it("should return 400 when body is empty", async () => { + const event = createEvent({ method: "PUT", body: Buffer.from("") }); + const result = await runHandler(event); + + expect(result.statusCode).toBe(400); + }); + + it("should return 400 when value is missing in body", async () => { + const event = createEvent({ method: "PUT", body: Buffer.from(JSON.stringify({})) }); + const result = await runHandler(event); + + expect(result.statusCode).toBe(400); + }); + + it("should return 400 when body is invalid JSON", async () => { + const event = createEvent({ method: "PUT", body: Buffer.from("invalid json") }); + const result = await runHandler(event); + + expect(result.statusCode).toBe(400); + }); + + it("should set cache entry and return 200", async () => { + const value = { type: "route", body: "content" }; + const event = createEvent({ + method: "PUT", + body: Buffer.from(JSON.stringify({ value })), + }); + + const result = await runHandler(event); + + expect(result.statusCode).toBe(200); + expect(mockIncrementalCache.set).toHaveBeenCalledWith("test-key", value, "cache"); + const body = await fromReadableStream(result.body); + expect(JSON.parse(body)).toEqual({ ok: true }); + }); + + it("should write derived tags for non-nextMode tag caches", async () => { + mockTagCache.getByPath.mockResolvedValue([]); + mockTagCache.mode = "original"; + + const value = { + type: "route", + body: "content", + meta: { headers: { "x-next-cache-tags": "tag1,tag2" } }, + }; + const event = createEvent({ + method: "PUT", + body: Buffer.from(JSON.stringify({ value })), + }); + + await runHandler(event); + + expect(mockTagCache.writeTags).toHaveBeenCalled(); + }); + + it("should write derived tags without an ambient request context", async () => { + // The cache handler runs as its own function: nothing establishes an OpenNext request + // context for it, and `writeTags` gives up when there is none. + mockTagCache.getByPath.mockResolvedValue([]); + mockTagCache.mode = "original"; + + const value = { + type: "route", + body: "content", + meta: { headers: { "x-next-cache-tags": "tag1,tag2" } }, + }; + const event = createEvent({ + method: "PUT", + body: Buffer.from(JSON.stringify({ value })), + }); + + await handler(event); + + expect(mockTagCache.writeTags).toHaveBeenCalled(); + }); + + it("should skip tag writing in nextMode", async () => { + mockTagCache.mode = "nextMode"; + const event = createEvent({ + method: "PUT", + body: Buffer.from(JSON.stringify({ value: { type: "route", body: "content" } })), + }); + + await runHandler(event); + + expect(mockTagCache.writeTags).not.toHaveBeenCalled(); + }); + + it("should skip tag writing when disableTagCache is true", async () => { + // @ts-ignore + globalThis.openNextConfig = { + dangerous: { disableTagCache: true }, + } as Partial; + const event = createEvent({ + method: "PUT", + body: Buffer.from(JSON.stringify({ value: { type: "route", body: "content" } })), + }); + + await runHandler(event); + + expect(mockTagCache.writeTags).not.toHaveBeenCalled(); + }); + + it("should skip writing tags that are already stored", async () => { + mockTagCache.getByPath.mockResolvedValue(["tag1", "tag2"]); + + const value = { + type: "route", + body: "content", + meta: { headers: { "x-next-cache-tags": "tag1,tag2" } }, + }; + const event = createEvent({ + method: "PUT", + body: Buffer.from(JSON.stringify({ value })), + }); + + await runHandler(event); + + expect(mockTagCache.writeTags).not.toHaveBeenCalled(); + }); + + it("should return 500 when set fails", async () => { + mockIncrementalCache.set.mockRejectedValue(new Error("set error")); + const event = createEvent({ + method: "PUT", + body: Buffer.from(JSON.stringify({ value: { type: "route", body: "content" } })), + }); + + const result = await runHandler(event); + + expect(result.statusCode).toBe(500); + }); + }); + + describe("DELETE /cache/:key", () => { + it("should delete cache entry and return 200", async () => { + const result = await runHandler(createEvent({ method: "DELETE" })); + + expect(result.statusCode).toBe(200); + expect(mockIncrementalCache.delete).toHaveBeenCalledWith("test-key"); + const body = await fromReadableStream(result.body); + expect(JSON.parse(body)).toEqual({ ok: true }); + }); + + it("should return 500 when delete fails", async () => { + mockIncrementalCache.delete.mockRejectedValue(new Error("delete error")); + + const result = await runHandler(createEvent({ method: "DELETE" })); + + expect(result.statusCode).toBe(500); + }); + }); + + describe("POST /cache/revalidate-tags", () => { + it("should return 400 when body is missing", async () => { + const event = createEvent({ rawPath: "/cache/revalidate-tags", method: "POST" }); + const result = await runHandler(event); + + expect(result.statusCode).toBe(400); + }); + + it("should return 400 when body is empty", async () => { + const event = createEvent({ + rawPath: "/cache/revalidate-tags", + method: "POST", + body: Buffer.from(""), + }); + const result = await runHandler(event); + + expect(result.statusCode).toBe(400); + }); + + it("should return 400 when tags array is missing", async () => { + const event = createEvent({ + rawPath: "/cache/revalidate-tags", + method: "POST", + body: Buffer.from(JSON.stringify({})), + }); + const result = await runHandler(event); + + expect(result.statusCode).toBe(400); + }); + + it("should return 400 when tags are empty array", async () => { + const event = createEvent({ + rawPath: "/cache/revalidate-tags", + method: "POST", + body: Buffer.from(JSON.stringify({ tags: [] })), + }); + const result = await runHandler(event); + + expect(result.statusCode).toBe(400); + }); + + it("should return 400 when body is invalid JSON", async () => { + const event = createEvent({ + rawPath: "/cache/revalidate-tags", + method: "POST", + body: Buffer.from("not json"), + }); + const result = await runHandler(event); + + expect(result.statusCode).toBe(400); + }); + + it("should revalidate tags in nextMode without getPathsByTags", async () => { + mockTagCache.mode = "nextMode"; + mockTagCache.getPathsByTags = undefined; + const event = createEvent({ + rawPath: "/cache/revalidate-tags", + method: "POST", + body: Buffer.from(JSON.stringify({ tags: ["tag1"] })), + }); + + const result = await runHandler(event); + + expect(result.statusCode).toBe(200); + expect(mockTagCache.writeTags).toHaveBeenCalled(); + const body = await fromReadableStream(result.body); + const parsed = JSON.parse(body); + expect(parsed.revalidated).toEqual(["tag1"]); + }); + + it("should revalidate tags in nextMode with getPathsByTags and invalidate CDN", async () => { + mockTagCache.mode = "nextMode"; + mockTagCache.getPathsByTags = vi.fn().mockResolvedValue(["/path1"]); + const event = createEvent({ + rawPath: "/cache/revalidate-tags", + method: "POST", + body: Buffer.from(JSON.stringify({ tags: ["tag1"] })), + }); + + const result = await runHandler(event); + + expect(result.statusCode).toBe(200); + expect(mockTagCache.writeTags).toHaveBeenCalled(); + expect(mockCdnInvalidationHandler.invalidatePaths).toHaveBeenCalledWith([ + expect.objectContaining({ initialPath: "/path1", rawPath: "/path1" }), + ]); + }); + + it("should revalidate tags in original mode", async () => { + mockTagCache.mode = "original"; + mockTagCache.getByTag.mockResolvedValue(["/path1"]); + mockTagCache.getByPath.mockResolvedValue([]); + const event = createEvent({ + rawPath: "/cache/revalidate-tags", + method: "POST", + body: Buffer.from(JSON.stringify({ tags: ["tag1"] })), + }); + + const result = await runHandler(event); + + expect(result.statusCode).toBe(200); + expect(mockTagCache.getByTag).toHaveBeenCalledWith("tag1"); + expect(mockTagCache.writeTags).toHaveBeenCalled(); + }); + + it("should invalidate CDN for soft tags in original mode", async () => { + mockTagCache.mode = "original"; + mockTagCache.getByTag.mockResolvedValue(["/some-path"]); + mockTagCache.getByPath.mockResolvedValue([]); + const event = createEvent({ + rawPath: "/cache/revalidate-tags", + method: "POST", + body: Buffer.from(JSON.stringify({ tags: ["_N_T_//some-path"] })), + }); + + await runHandler(event); + + expect(mockCdnInvalidationHandler.invalidatePaths).toHaveBeenCalled(); + }); + + it("should return 500 when revalidation fails", async () => { + mockTagCache.mode = "original"; + mockTagCache.getByTag.mockRejectedValue(new Error("tag error")); + const event = createEvent({ + rawPath: "/cache/revalidate-tags", + method: "POST", + body: Buffer.from(JSON.stringify({ tags: ["tag1"] })), + }); + + const result = await runHandler(event); + + expect(result.statusCode).toBe(500); + }); + }); +}); diff --git a/packages/tests-unit/tests/adapters/cache.test.ts b/packages/tests-unit/tests/adapters/cache.test.ts index 347f35c1..ef6f8e46 100644 --- a/packages/tests-unit/tests/adapters/cache.test.ts +++ b/packages/tests-unit/tests/adapters/cache.test.ts @@ -1,5 +1,30 @@ -import Cache, { SOFT_TAG_PREFIX } from "@opennextjs/core/adapters/cache.js"; -import { type Mock, vi } from "vitest"; +import Cache from "@opennextjs/core/adapters/cache.js"; +import { vi } from "vitest"; + +const cache = { + name: "mock", + get: vi.fn().mockResolvedValue({ + value: { + type: "route", + body: "{}", + }, + lastModified: Date.now(), + }), + set: vi.fn(), + delete: vi.fn(), + revalidateTags: vi.fn(), +}; +globalThis.cache = cache; + +globalThis.__openNextAls = { + getStore: vi.fn().mockReturnValue({ + pendingPromiseRunner: { + withResolvers: vi.fn().mockReturnValue({ + resolve: vi.fn(), + }), + }, + }), +}; declare global { var openNextConfig: { @@ -9,59 +34,16 @@ declare global { } describe("CacheHandler", () => { - let cache: Cache; + let instance: Cache; vi.useFakeTimers().setSystemTime("2024-01-02T00:00:00Z"); const getFetchCacheSpy = vi.spyOn(Cache.prototype, "getFetchCache"); const getIncrementalCache = vi.spyOn(Cache.prototype, "getIncrementalCache"); - const incrementalCache = { - name: "mock", - get: vi.fn().mockResolvedValue({ - value: { - type: "route", - body: "{}", - }, - lastModified: Date.now(), - }), - set: vi.fn(), - delete: vi.fn(), - }; - globalThis.incrementalCache = incrementalCache; - - const tagCache = { - name: "mock", - mode: "original", - hasBeenRevalidated: vi.fn(), - getByTag: vi.fn(), - getByPath: vi.fn(), - getLastModified: vi.fn().mockResolvedValue(new Date("2024-01-02T00:00:00Z").getTime()), - writeTags: vi.fn(), - getPathsByTags: undefined as Mock | undefined, - }; - globalThis.tagCache = tagCache; - - const invalidateCdnHandler = { - name: "mock", - invalidatePaths: vi.fn(), - }; - globalThis.cdnInvalidationHandler = invalidateCdnHandler; - - globalThis.__openNextAls = { - getStore: vi.fn().mockReturnValue({ - pendingPromiseRunner: { - withResolvers: vi.fn().mockReturnValue({ - resolve: vi.fn(), - }), - }, - writtenTags: new Set(), - }), - }; - beforeEach(() => { vi.clearAllMocks(); - cache = new Cache(); + instance = new Cache(); globalThis.openNextConfig = { dangerous: { @@ -69,15 +51,13 @@ describe("CacheHandler", () => { }, }; globalThis.isNextAfter15 = false; - tagCache.mode = "original"; - tagCache.getPathsByTags = undefined; }); describe("get", () => { it("Should return null for cache miss", async () => { - incrementalCache.get.mockResolvedValueOnce({}); + cache.get.mockResolvedValueOnce({}); - const result = await cache.get("key"); + const result = await instance.get("key"); expect(result).toBeNull(); }); @@ -88,68 +68,51 @@ describe("CacheHandler", () => { }); it("Should return null when incremental cache is disabled", async () => { - const result = await cache.get("key"); + const result = await instance.get("key"); expect(result).toBeNull(); }); it("Should not set cache when incremental cache is disabled", async () => { - globalThis.openNextConfig.dangerous.disableIncrementalCache = true; - - await cache.set("key", { kind: "REDIRECT", props: {} }); + await instance.set("key", { kind: "REDIRECT", props: {} }); - expect(incrementalCache.set).not.toHaveBeenCalled(); + expect(cache.set).not.toHaveBeenCalled(); }); it("Should not delete cache when incremental cache is disabled", async () => { - globalThis.openNextConfig.dangerous.disableIncrementalCache = true; - - await cache.set("key", undefined); + await instance.set("key", undefined); - expect(incrementalCache.delete).not.toHaveBeenCalled(); + expect(cache.delete).not.toHaveBeenCalled(); }); }); describe("fetch cache", () => { it("Should retrieve cache from fetch cache when hint is fetch (next14)", async () => { - await cache.get("key", { kindHint: "fetch" }); + await instance.get("key", { kindHint: "fetch" }); expect(getFetchCacheSpy).toHaveBeenCalled(); }); describe("next15", () => { it("Should retrieve cache from fetch cache when hint is fetch", async () => { - await cache.get("key", { kind: "FETCH" }); + await instance.get("key", { kind: "FETCH" }); expect(getFetchCacheSpy).toHaveBeenCalled(); }); - it("Should return null when tag cache last modified is -1", async () => { - tagCache.getLastModified.mockResolvedValueOnce(-1); - - const result = await cache.get("key", { kind: "FETCH" }); - - expect(getFetchCacheSpy).toHaveBeenCalled(); - expect(result).toBeNull(); - }); + it("Should return null when fetch cache entry is not found", async () => { + cache.get.mockResolvedValueOnce(null); - it("Should return null with nextMode tag cache that has been revalidated", async () => { - tagCache.mode = "nextMode"; - tagCache.hasBeenRevalidated.mockResolvedValueOnce(true); + const result = await instance.get("key", { kind: "FETCH" }); - const result = await cache.get("key", { - kind: "FETCH", - tags: ["tag"], - }); expect(getFetchCacheSpy).toHaveBeenCalled(); - expect(tagCache.hasBeenRevalidated).toHaveBeenCalled(); expect(result).toBeNull(); }); it("Should return null when incremental cache throws", async () => { - incrementalCache.get.mockRejectedValueOnce(new Error("Error retrieving cache")); + cache.get.mockRejectedValueOnce(new Error("Error retrieving cache")); - const result = await cache.get("key", { kind: "FETCH" }); + const result = await instance.get("key", { kind: "FETCH" }); expect(getFetchCacheSpy).toHaveBeenCalled(); expect(result).toBeNull(); @@ -161,51 +124,14 @@ describe("CacheHandler", () => { it.each(["app", "pages", undefined])( "Should retrieve cache from incremental cache when hint is not fetch: %s", async (kindHint) => { - await cache.get("key", { kindHint: kindHint as any }); + await instance.get("key", { kindHint: kindHint as any }); expect(getIncrementalCache).toHaveBeenCalled(); } ); - it("Should return null when tag cache last modified is -1", async () => { - incrementalCache.get.mockResolvedValueOnce({ - value: { - type: "route", - }, - lastModified: Date.now(), - }); - tagCache.getLastModified.mockResolvedValueOnce(-1); - - const result = await cache.get("key", { kindHint: "app" }); - - expect(getIncrementalCache).toHaveBeenCalled(); - expect(result).toBeNull(); - }); - - it("Should return null with nextMode tag cache that has been revalidated", async () => { - tagCache.mode = "nextMode"; - tagCache.hasBeenRevalidated.mockResolvedValueOnce(true); - incrementalCache.get.mockResolvedValueOnce({ - value: { - type: "route", - meta: { - headers: { - "x-next-cache-tags": "tag", - }, - }, - }, - lastModified: Date.now(), - }); - - const result = await cache.get("key", { kindHint: "app" }); - - expect(getIncrementalCache).toHaveBeenCalled(); - expect(tagCache.hasBeenRevalidated).toHaveBeenCalled(); - expect(result).toBeNull(); - }); - it("Should return value when cache data type is route", async () => { - incrementalCache.get.mockResolvedValueOnce({ + cache.get.mockResolvedValueOnce({ value: { type: "route", body: "{}", @@ -213,7 +139,7 @@ describe("CacheHandler", () => { lastModified: Date.now(), }); - const result = await cache.get("key", { kindHint: "app" }); + const result = await instance.get("key", { kindHint: "app" }); expect(getIncrementalCache).toHaveBeenCalled(); expect(result).toEqual({ @@ -226,7 +152,7 @@ describe("CacheHandler", () => { }); it("Should return base64 encoded value when cache data type is route and content is binary", async () => { - incrementalCache.get.mockResolvedValueOnce({ + cache.get.mockResolvedValueOnce({ value: { type: "route", body: Buffer.from("hello").toString("base64"), @@ -239,7 +165,7 @@ describe("CacheHandler", () => { lastModified: Date.now(), }); - const result = await cache.get("key", { kindHint: "app" }); + const result = await instance.get("key", { kindHint: "app" }); expect(getIncrementalCache).toHaveBeenCalled(); expect(result).toEqual({ @@ -255,7 +181,7 @@ describe("CacheHandler", () => { }); it("Should return value when cache data type is app", async () => { - incrementalCache.get.mockResolvedValueOnce({ + cache.get.mockResolvedValueOnce({ value: { type: "app", html: "", @@ -267,7 +193,7 @@ describe("CacheHandler", () => { lastModified: Date.now(), }); - const result = await cache.get("key", { kindHint: "app" }); + const result = await instance.get("key", { kindHint: "app" }); expect(getIncrementalCache).toHaveBeenCalled(); expect(result).toEqual({ @@ -285,7 +211,7 @@ describe("CacheHandler", () => { }); it("Should return value when cache data type is page", async () => { - incrementalCache.get.mockResolvedValueOnce({ + cache.get.mockResolvedValueOnce({ value: { type: "page", html: "", @@ -297,7 +223,7 @@ describe("CacheHandler", () => { lastModified: Date.now(), }); - const result = await cache.get("key", { kindHint: "pages" }); + const result = await instance.get("key", { kindHint: "pages" }); expect(getIncrementalCache).toHaveBeenCalled(); expect(result).toEqual({ @@ -314,7 +240,7 @@ describe("CacheHandler", () => { it("Should return value when cache data type is app with segmentData and postponed (Next 15+)", async () => { globalThis.isNextAfter15 = true; - incrementalCache.get.mockResolvedValueOnce({ + cache.get.mockResolvedValueOnce({ value: { type: "app", html: "", @@ -332,87 +258,7 @@ describe("CacheHandler", () => { lastModified: Date.now(), }); - const result = await cache.get("key", { kindHint: "app" }); - - expect(getIncrementalCache).toHaveBeenCalled(); - expect(result).toEqual({ - value: { - kind: "APP_PAGE", - html: "", - rscData: Buffer.from("rsc-data"), - status: 200, - headers: { "x-custom": "value" }, - postponed: "postponed-data", - segmentData: new Map([ - ["segment1", Buffer.from("data1")], - ["segment2", Buffer.from("data2")], - ]), - }, - lastModified: Date.now(), - }); - }); - - it("Should return value when cache data type is app with segmentData and postponed (Next 15+)", async () => { - globalThis.isNextAfter15 = true; - incrementalCache.get.mockResolvedValueOnce({ - value: { - type: "app", - html: "", - rsc: "rsc-data", - segmentData: { - segment1: "data1", - segment2: "data2", - }, - meta: { - status: 200, - headers: { "x-custom": "value" }, - postponed: "postponed-data", - }, - }, - lastModified: Date.now(), - }); - - const result = await cache.get("key", { kindHint: "app" }); - - expect(getIncrementalCache).toHaveBeenCalled(); - expect(result).toEqual({ - value: { - kind: "APP_PAGE", - html: "", - rscData: Buffer.from("rsc-data"), - status: 200, - headers: { "x-custom": "value" }, - postponed: "postponed-data", - segmentData: new Map([ - ["segment1", Buffer.from("data1")], - ["segment2", Buffer.from("data2")], - ]), - }, - lastModified: Date.now(), - }); - }); - - it("Should return value when cache data type is app with segmentData and postponed (Next 15+)", async () => { - globalThis.isNextAfter15 = true; - incrementalCache.get.mockResolvedValueOnce({ - value: { - type: "app", - html: "", - rsc: "rsc-data", - segmentData: { - segment1: "data1", - segment2: "data2", - }, - meta: { - status: 200, - headers: { "x-custom": "value" }, - postponed: "postponed-data", - }, - }, - lastModified: Date.now(), - }); - - const result = await cache.get("key", { kindHint: "app" }); + const result = await instance.get("key", { kindHint: "app" }); expect(getIncrementalCache).toHaveBeenCalled(); expect(result).toEqual({ @@ -433,14 +279,14 @@ describe("CacheHandler", () => { }); it("Should return value when cache data type is redirect", async () => { - incrementalCache.get.mockResolvedValueOnce({ + cache.get.mockResolvedValueOnce({ value: { type: "redirect", }, lastModified: Date.now(), }); - const result = await cache.get("key", { kindHint: "app" }); + const result = await instance.get("key", { kindHint: "app" }); expect(getIncrementalCache).toHaveBeenCalled(); expect(result).toEqual({ @@ -452,9 +298,9 @@ describe("CacheHandler", () => { }); it("Should return null when incremental cache fails", async () => { - incrementalCache.get.mockRejectedValueOnce(new Error("Error")); + cache.get.mockRejectedValueOnce(new Error("Error")); - const result = await cache.get("key", { kindHint: "app" }); + const result = await instance.get("key", { kindHint: "app" }); expect(getIncrementalCache).toHaveBeenCalled(); expect(result).toBeNull(); @@ -464,20 +310,20 @@ describe("CacheHandler", () => { describe("set", () => { it("Should delete cache when data is undefined", async () => { - await cache.set("key", undefined); + await instance.set("key", undefined); - expect(incrementalCache.delete).toHaveBeenCalled(); + expect(cache.delete).toHaveBeenCalled(); }); it("Should set cache when for ROUTE", async () => { - await cache.set("key", { + await instance.set("key", { kind: "ROUTE", body: Buffer.from("{}"), status: 200, headers: {}, }); - expect(incrementalCache.set).toHaveBeenCalledWith( + expect(cache.set).toHaveBeenCalledWith( "key", { type: "route", body: "{}", meta: { status: 200, headers: {} } }, "cache" @@ -485,7 +331,7 @@ describe("CacheHandler", () => { }); it("Should set cache when for APP_ROUTE", async () => { - await cache.set("key", { + await instance.set("key", { kind: "APP_ROUTE", body: Buffer.from("{}"), status: 200, @@ -494,7 +340,7 @@ describe("CacheHandler", () => { }, }); - expect(incrementalCache.set).toHaveBeenCalledWith( + expect(cache.set).toHaveBeenCalledWith( "key", { type: "route", @@ -506,7 +352,7 @@ describe("CacheHandler", () => { }); it("Should set cache when for PAGE", async () => { - await cache.set("key", { + await instance.set("key", { kind: "PAGE", html: "", pageData: {}, @@ -514,7 +360,7 @@ describe("CacheHandler", () => { headers: {}, }); - expect(incrementalCache.set).toHaveBeenCalledWith( + expect(cache.set).toHaveBeenCalledWith( "key", { type: "page", @@ -526,7 +372,7 @@ describe("CacheHandler", () => { }); it("Should set cache when for PAGES", async () => { - await cache.set("key", { + await instance.set("key", { kind: "PAGES", html: "", pageData: "rsc", @@ -534,7 +380,7 @@ describe("CacheHandler", () => { headers: {}, }); - expect(incrementalCache.set).toHaveBeenCalledWith( + expect(cache.set).toHaveBeenCalledWith( "key", { type: "app", @@ -547,7 +393,7 @@ describe("CacheHandler", () => { }); it("Should set cache when for APP_PAGE", async () => { - await cache.set("key", { + await instance.set("key", { kind: "APP_PAGE", html: "", rscData: Buffer.from("rsc"), @@ -555,7 +401,7 @@ describe("CacheHandler", () => { headers: {}, }); - expect(incrementalCache.set).toHaveBeenCalledWith( + expect(cache.set).toHaveBeenCalledWith( "key", { type: "app", @@ -573,7 +419,7 @@ describe("CacheHandler", () => { ["segment2", Buffer.from("data2")], ]); - await cache.set("key", { + await instance.set("key", { kind: "APP_PAGE", html: "", rscData: Buffer.from("rsc"), @@ -583,7 +429,7 @@ describe("CacheHandler", () => { postponed: "postponed-data", }); - expect(incrementalCache.set).toHaveBeenCalledWith( + expect(cache.set).toHaveBeenCalledWith( "key", { type: "app", @@ -604,7 +450,7 @@ describe("CacheHandler", () => { }); it("Should set cache when for FETCH", async () => { - await cache.set("key", { + await instance.set("key", { kind: "FETCH", data: { headers: {}, @@ -616,7 +462,7 @@ describe("CacheHandler", () => { revalidate: 60, }); - expect(incrementalCache.set).toHaveBeenCalledWith( + expect(cache.set).toHaveBeenCalledWith( "key", { kind: "FETCH", @@ -634,9 +480,9 @@ describe("CacheHandler", () => { }); it("Should set cache when for REDIRECT", async () => { - await cache.set("key", { kind: "REDIRECT", props: {} }); + await instance.set("key", { kind: "REDIRECT", props: {} }); - expect(incrementalCache.set).toHaveBeenCalledWith( + expect(cache.set).toHaveBeenCalledWith( "key", { type: "redirect", @@ -647,20 +493,20 @@ describe("CacheHandler", () => { }); it("Should not set cache when for IMAGE (not implemented)", async () => { - await cache.set("key", { + await instance.set("key", { kind: "IMAGE", etag: "etag", buffer: Buffer.from("hello"), extension: "png", }); - expect(incrementalCache.set).not.toHaveBeenCalled(); + expect(cache.set).not.toHaveBeenCalled(); }); it("Should not throw when set cache throws", async () => { - incrementalCache.set.mockRejectedValueOnce(new Error("Error")); + cache.set.mockRejectedValueOnce(new Error("Error")); - await expect(cache.set("key", { kind: "REDIRECT", props: {} })).resolves.not.toThrow(); + await expect(instance.set("key", { kind: "REDIRECT", props: {} })).resolves.not.toThrow(); }); }); @@ -669,304 +515,45 @@ describe("CacheHandler", () => { globalThis.openNextConfig.dangerous.disableTagCache = false; globalThis.openNextConfig.dangerous.disableIncrementalCache = false; }); + it("Should do nothing if disableIncrementalCache is true", async () => { globalThis.openNextConfig.dangerous.disableIncrementalCache = true; - await cache.revalidateTag("tag"); + await instance.revalidateTag("tag"); - expect(tagCache.writeTags).not.toHaveBeenCalled(); + expect(cache.revalidateTags).not.toHaveBeenCalled(); }); it("Should do nothing if disableTagCache is true", async () => { globalThis.openNextConfig.dangerous.disableTagCache = true; - await cache.revalidateTag("tag"); + await instance.revalidateTag("tag"); - expect(tagCache.writeTags).not.toHaveBeenCalled(); - // Reset the config - globalThis.openNextConfig.dangerous.disableTagCache = false; + expect(cache.revalidateTags).not.toHaveBeenCalled(); }); - it("Should call tagCache.writeTags", async () => { - tagCache.getByTag.mockResolvedValueOnce(["/path"]); - await cache.revalidateTag("tag"); - - expect(tagCache.getByTag).toHaveBeenCalledWith("tag"); + it("Should call cache.revalidateTags with single tag", async () => { + await instance.revalidateTag("tag"); - expect(tagCache.writeTags).toHaveBeenCalledTimes(1); - expect(tagCache.writeTags).toHaveBeenCalledWith([ - { - path: "/path", - tag: "tag", - }, - ]); + expect(cache.revalidateTags).toHaveBeenCalledWith(["tag"]); }); - it("Should call invalidateCdnHandler.invalidatePaths", async () => { - tagCache.getByTag.mockResolvedValueOnce(["/path"]); - tagCache.getByPath.mockResolvedValueOnce([]); - await cache.revalidateTag(`${SOFT_TAG_PREFIX}path`); + it("Should call cache.revalidateTags with array of tags", async () => { + await instance.revalidateTag(["tag1", "tag2"]); - expect(tagCache.writeTags).toHaveBeenCalledTimes(1); - expect(tagCache.writeTags).toHaveBeenCalledWith([ - { - path: "/path", - tag: `${SOFT_TAG_PREFIX}path`, - }, - ]); - - expect(invalidateCdnHandler.invalidatePaths).toHaveBeenCalled(); - }); - - it("Should not call invalidateCdnHandler.invalidatePaths for fetch cache key ", async () => { - tagCache.getByTag.mockResolvedValueOnce(["123456"]); - await cache.revalidateTag("tag"); - - expect(tagCache.writeTags).toHaveBeenCalledTimes(1); - expect(tagCache.writeTags).toHaveBeenCalledWith([ - { - path: "123456", - tag: "tag", - }, - ]); - - expect(invalidateCdnHandler.invalidatePaths).not.toHaveBeenCalled(); - }); - - it("Should only call writeTags for nextMode", async () => { - tagCache.mode = "nextMode"; - await cache.revalidateTag(["tag1", "tag2"]); - - expect(tagCache.writeTags).toHaveBeenCalledTimes(1); - expect(tagCache.writeTags).toHaveBeenCalledWith(["tag1", "tag2"]); - expect(invalidateCdnHandler.invalidatePaths).not.toHaveBeenCalled(); - }); - - it("Should not call writeTags when the tag list is empty for nextMode", async () => { - tagCache.mode = "nextMode"; - await cache.revalidateTag([]); - - expect(tagCache.writeTags).not.toHaveBeenCalled(); - expect(invalidateCdnHandler.invalidatePaths).not.toHaveBeenCalled(); - }); - - it("Should call writeTags and invalidateCdnHandler.invalidatePaths for nextMode that supports getPathsByTags", async () => { - tagCache.mode = "nextMode"; - tagCache.getPathsByTags = vi.fn().mockResolvedValueOnce(["/path"]); - await cache.revalidateTag("tag"); - - expect(tagCache.writeTags).toHaveBeenCalledTimes(1); - expect(tagCache.writeTags).toHaveBeenCalledWith(["tag"]); - expect(invalidateCdnHandler.invalidatePaths).toHaveBeenCalledWith([ - { - initialPath: "/path", - rawPath: "/path", - resolvedRoutes: [ - { - type: "app", - route: "/path", - isFallback: false, - }, - ], - }, - ]); + expect(cache.revalidateTags).toHaveBeenCalledWith(["tag1", "tag2"]); }); - }); - - describe("shouldBypassTagCache", () => { - describe("fetch cache", () => { - it("Should bypass tag cache validation when shouldBypassTagCache is true", async () => { - incrementalCache.get.mockResolvedValueOnce({ - value: { - kind: "FETCH", - data: { - headers: {}, - body: "{}", - url: "https://example.com", - status: 200, - }, - }, - lastModified: Date.now(), - shouldBypassTagCache: true, - }); - - const result = await cache.get("key", { - kind: "FETCH", - tags: ["tag1"], - }); - - expect(getFetchCacheSpy).toHaveBeenCalled(); - expect(tagCache.getLastModified).not.toHaveBeenCalled(); - expect(tagCache.hasBeenRevalidated).not.toHaveBeenCalled(); - expect(result).not.toBeNull(); - expect(result?.value).toEqual({ - kind: "FETCH", - data: { - headers: {}, - body: "{}", - url: "https://example.com", - status: 200, - }, - }); - }); - - it("Should not bypass tag cache validation when shouldBypassTagCache is false", async () => { - tagCache.mode = "nextMode"; - incrementalCache.get.mockResolvedValueOnce({ - value: { - kind: "FETCH", - data: { - headers: {}, - body: "{}", - url: "https://example.com", - status: 200, - }, - }, - lastModified: Date.now(), - shouldBypassTagCache: false, - }); - - const result = await cache.get("key", { - kind: "FETCH", - tags: ["tag1"], - }); - - expect(getFetchCacheSpy).toHaveBeenCalled(); - expect(tagCache.hasBeenRevalidated).toHaveBeenCalled(); - expect(result).not.toBeNull(); - }); - - it("Should not bypass tag cache validation when shouldBypassTagCache is undefined", async () => { - tagCache.mode = "nextMode"; - tagCache.hasBeenRevalidated.mockResolvedValueOnce(false); - incrementalCache.get.mockResolvedValueOnce({ - value: { - kind: "FETCH", - data: { - headers: {}, - body: "{}", - url: "https://example.com", - status: 200, - }, - }, - lastModified: Date.now(), - // shouldBypassTagCache not set - }); - - const result = await cache.get("key", { - kind: "FETCH", - tags: ["tag1"], - }); - - expect(getFetchCacheSpy).toHaveBeenCalled(); - expect(tagCache.hasBeenRevalidated).toHaveBeenCalled(); - expect(result).not.toBeNull(); - }); - it("Should bypass path validation when shouldBypassTagCache is true for soft tags", async () => { - incrementalCache.get.mockResolvedValueOnce({ - value: { - kind: "FETCH", - data: { - headers: {}, - body: "{}", - url: "https://example.com", - status: 200, - }, - }, - lastModified: Date.now(), - shouldBypassTagCache: true, - }); - - const result = await cache.get("key", { - kind: "FETCH", - softTags: [`${SOFT_TAG_PREFIX}path`], - }); + it("Should not call cache.revalidateTags when tags array is empty", async () => { + await instance.revalidateTag([]); - expect(getFetchCacheSpy).toHaveBeenCalled(); - expect(tagCache.getLastModified).not.toHaveBeenCalled(); - expect(tagCache.hasBeenRevalidated).not.toHaveBeenCalled(); - expect(result).not.toBeNull(); - }); + expect(cache.revalidateTags).not.toHaveBeenCalled(); }); - describe("incremental cache", () => { - it("Should bypass tag cache validation when shouldBypassTagCache is true", async () => { - incrementalCache.get.mockResolvedValueOnce({ - value: { - type: "route", - body: "{}", - }, - lastModified: Date.now(), - shouldBypassTagCache: true, - }); - - const result = await cache.get("key", { kindHint: "app" }); - - expect(getIncrementalCache).toHaveBeenCalled(); - expect(tagCache.getLastModified).not.toHaveBeenCalled(); - expect(tagCache.hasBeenRevalidated).not.toHaveBeenCalled(); - expect(result).not.toBeNull(); - expect(result?.value?.kind).toEqual("APP_ROUTE"); - }); + it("Should not throw when revalidateTags fails", async () => { + cache.revalidateTags.mockRejectedValueOnce(new Error("Error")); - it("Should not bypass tag cache validation when shouldBypassTagCache is false", async () => { - tagCache.mode = "nextMode"; - incrementalCache.get.mockResolvedValueOnce({ - value: { - type: "route", - body: "{}", - meta: { headers: { "x-next-cache-tags": "tag" } }, - }, - lastModified: Date.now(), - shouldBypassTagCache: false, - }); - - const result = await cache.get("key", { kindHint: "app" }); - - expect(getIncrementalCache).toHaveBeenCalled(); - expect(tagCache.hasBeenRevalidated).toHaveBeenCalled(); - expect(result).not.toBeNull(); - }); - - it("Should return null when tag cache indicates revalidation and shouldBypassTagCache is false", async () => { - tagCache.mode = "nextMode"; - tagCache.hasBeenRevalidated.mockResolvedValueOnce(true); - incrementalCache.get.mockResolvedValueOnce({ - value: { - type: "route", - body: "{}", - meta: { headers: { "x-next-cache-tags": "tag" } }, - }, - lastModified: Date.now(), - shouldBypassTagCache: false, - }); - - const result = await cache.get("key", { kindHint: "app" }); - - expect(getIncrementalCache).toHaveBeenCalled(); - expect(tagCache.hasBeenRevalidated).toHaveBeenCalled(); - expect(result).toBeNull(); - }); - - it("Should return value when tag cache indicates revalidation but shouldBypassTagCache is true", async () => { - incrementalCache.get.mockResolvedValueOnce({ - value: { - type: "route", - body: "{}", - }, - lastModified: Date.now(), - shouldBypassTagCache: true, - }); - - const result = await cache.get("key", { kindHint: "app" }); - - expect(getIncrementalCache).toHaveBeenCalled(); - expect(tagCache.getLastModified).not.toHaveBeenCalled(); - expect(tagCache.hasBeenRevalidated).not.toHaveBeenCalled(); - expect(result).not.toBeNull(); - expect(result?.value?.kind).toEqual("APP_ROUTE"); - }); + await expect(instance.revalidateTag("tag")).resolves.not.toThrow(); }); }); }); diff --git a/packages/tests-unit/tests/adapters/composable-cache.test.ts b/packages/tests-unit/tests/adapters/composable-cache.test.ts index 35b723b4..e126bcde 100644 --- a/packages/tests-unit/tests/adapters/composable-cache.test.ts +++ b/packages/tests-unit/tests/adapters/composable-cache.test.ts @@ -2,59 +2,41 @@ import ComposableCache from "@opennextjs/core/adapters/composable-cache"; import { fromReadableStream, toReadableStream } from "@opennextjs/core/utils/stream"; import { vi } from "vitest"; +const cache = { + name: "mock", + get: vi.fn().mockResolvedValue({ + value: { + type: "route", + body: "{}", + tags: ["tag1", "tag2"], + stale: 0, + timestamp: Date.now(), + expire: Date.now() + 1000, + revalidate: 3600, + value: "test-value", + }, + lastModified: Date.now(), + }), + set: vi.fn(), + delete: vi.fn(), + revalidateTags: vi.fn(), +}; +globalThis.cache = cache; + +globalThis.__openNextAls = { + getStore: () => ({ + pendingPromiseRunner: { + withResolvers: vi.fn().mockReturnValue({ + resolve: vi.fn(), + }), + }, + writtenTags: new Set(), + }), +}; + describe("Composable cache handler", () => { vi.useFakeTimers().setSystemTime("2024-01-02T00:00:00Z"); - const incrementalCache = { - name: "mock", - get: vi.fn().mockResolvedValue({ - value: { - type: "route", - body: "{}", - tags: ["tag1", "tag2"], - stale: 0, - timestamp: Date.now(), - expire: Date.now() + 1000, - revalidate: 3600, - value: "test-value", - }, - lastModified: Date.now(), - }), - set: vi.fn(), - delete: vi.fn(), - }; - globalThis.incrementalCache = incrementalCache; - - const tagCache = { - name: "mock", - mode: "original" as string | undefined, - hasBeenRevalidated: vi.fn(), - getByTag: vi.fn().mockResolvedValue(["path1", "path2"]), - getByPath: vi.fn().mockResolvedValue(["tag1"]), - getLastModified: vi.fn().mockResolvedValue(new Date("2024-01-02T00:00:00Z").getTime()), - getLastRevalidated: vi.fn().mockResolvedValue(0), - writeTags: vi.fn(), - }; - globalThis.tagCache = tagCache; - - const invalidateCdnHandler = { - name: "mock", - invalidatePaths: vi.fn(), - }; - globalThis.cdnInvalidationHandler = invalidateCdnHandler; - const writtenTags = new Set(); - - globalThis.__openNextAls = { - getStore: () => ({ - pendingPromiseRunner: { - withResolvers: vi.fn().mockReturnValue({ - resolve: vi.fn(), - }), - }, - writtenTags, - }), - }; - beforeEach(() => { vi.clearAllMocks(); @@ -67,17 +49,17 @@ describe("Composable cache handler", () => { }); describe("get", () => { - it("should return cached entry when available and not revalidated", async () => { + it("should return cached entry when available", async () => { const result = await ComposableCache.get("test-key"); - expect(incrementalCache.get).toHaveBeenCalledWith("test-key", "composable"); + expect(cache.get).toHaveBeenCalledWith("test-key", "composable"); expect(result).toBeDefined(); expect(result?.tags).toEqual(["tag1", "tag2"]); expect(result?.value).toBeInstanceOf(ReadableStream); }); it("should return undefined when cache entry does not exist", async () => { - incrementalCache.get.mockResolvedValueOnce(null); + cache.get.mockResolvedValueOnce(null); const result = await ComposableCache.get("non-existent-key"); @@ -85,7 +67,7 @@ describe("Composable cache handler", () => { }); it("should return undefined when cache entry has no value", async () => { - incrementalCache.get.mockResolvedValueOnce({ + cache.get.mockResolvedValueOnce({ value: null, lastModified: Date.now(), }); @@ -95,74 +77,8 @@ describe("Composable cache handler", () => { expect(result).toBeUndefined(); }); - it("should check tag revalidation in nextMode", async () => { - tagCache.mode = "nextMode"; - tagCache.hasBeenRevalidated.mockResolvedValueOnce(false); - - const result = await ComposableCache.get("test-key"); - - expect(tagCache.hasBeenRevalidated).toHaveBeenCalledWith(["tag1", "tag2"], expect.any(Number)); - expect(result).toBeDefined(); - }); - - it("should return undefined when tags have been revalidated in nextMode", async () => { - tagCache.mode = "nextMode"; - tagCache.hasBeenRevalidated.mockResolvedValueOnce(true); - - const result = await ComposableCache.get("test-key"); - - expect(result).toBeUndefined(); - }); - - it("should skip tag check when tags array is empty in nextMode", async () => { - tagCache.mode = "nextMode"; - incrementalCache.get.mockResolvedValueOnce({ - value: { - type: "route", - body: "{}", - tags: [], - value: "test-value", - }, - lastModified: Date.now(), - }); - - const result = await ComposableCache.get("test-key"); - - expect(tagCache.hasBeenRevalidated).not.toHaveBeenCalled(); - expect(result).toBeDefined(); - }); - - it("should check last modified in original mode", async () => { - tagCache.mode = "original"; - tagCache.getLastModified.mockResolvedValueOnce(Date.now()); - - const result = await ComposableCache.get("test-key"); - - expect(tagCache.getLastModified).toHaveBeenCalledWith("test-key", expect.any(Number)); - expect(result).toBeDefined(); - }); - - it("should return undefined when entry has been revalidated in original mode", async () => { - tagCache.mode = "original"; - tagCache.getLastModified.mockResolvedValueOnce(-1); - - const result = await ComposableCache.get("test-key"); - - expect(result).toBeUndefined(); - }); - - it("should handle undefined tag cache mode", async () => { - tagCache.mode = undefined; - tagCache.getLastModified.mockResolvedValueOnce(Date.now()); - - const result = await ComposableCache.get("test-key"); - - expect(tagCache.getLastModified).toHaveBeenCalled(); - expect(result).toBeDefined(); - }); - it("should return undefined on cache read error", async () => { - incrementalCache.get.mockRejectedValueOnce(new Error("Cache error")); + cache.get.mockRejectedValueOnce(new Error("Cache error")); const result = await ComposableCache.get("test-key"); @@ -194,12 +110,7 @@ describe("Composable cache handler", () => { }); describe("set", () => { - beforeEach(() => { - writtenTags.clear(); - }); - - it("should set cache entry and handle tags in original mode", async () => { - tagCache.mode = "original"; + it("should set cache entry", async () => { const entry = { value: toReadableStream("test-value"), tags: ["tag1", "tag2"], @@ -211,7 +122,7 @@ describe("Composable cache handler", () => { await ComposableCache.set("test-key", Promise.resolve(entry)); - expect(incrementalCache.set).toHaveBeenCalledWith( + expect(cache.set).toHaveBeenCalledWith( "test-key", expect.objectContaining({ tags: ["tag1", "tag2"], @@ -219,64 +130,6 @@ describe("Composable cache handler", () => { }), "composable" ); - expect(tagCache.getByPath).toHaveBeenCalledWith("test-key"); - }); - - it("should write new tags not already stored", async () => { - tagCache.mode = "original"; - tagCache.getByPath.mockResolvedValueOnce(["tag1"]); - - const entry = { - value: toReadableStream("test-value"), - tags: ["tag1", "tag2", "tag3"], - stale: 0, - timestamp: Date.now(), - expire: Date.now() + 1000, - revalidate: 3600, - }; - - await ComposableCache.set("test-key", Promise.resolve(entry)); - - expect(tagCache.writeTags).toHaveBeenCalledWith([ - { tag: "tag2", path: "test-key" }, - { tag: "tag3", path: "test-key" }, - ]); - }); - - it("should not write tags if all are already stored", async () => { - tagCache.mode = "original"; - tagCache.getByPath.mockResolvedValueOnce(["tag1", "tag2"]); - - const entry = { - value: toReadableStream("test-value"), - tags: ["tag1", "tag2"], - stale: 0, - timestamp: Date.now(), - expire: Date.now() + 1000, - revalidate: 3600, - }; - - await ComposableCache.set("test-key", Promise.resolve(entry)); - - expect(tagCache.writeTags).not.toHaveBeenCalled(); - }); - - it("should skip tag handling in nextMode", async () => { - tagCache.mode = "nextMode"; - - const entry = { - value: toReadableStream("test-value"), - tags: ["tag1", "tag2"], - stale: 0, - timestamp: Date.now(), - expire: Date.now() + 1000, - revalidate: 3600, - }; - - await ComposableCache.set("test-key", Promise.resolve(entry)); - - expect(tagCache.getByPath).not.toHaveBeenCalled(); - expect(tagCache.writeTags).not.toHaveBeenCalled(); }); it("should convert ReadableStream to string", async () => { @@ -291,7 +144,7 @@ describe("Composable cache handler", () => { await ComposableCache.set("test-key", Promise.resolve(entry)); - expect(incrementalCache.set).toHaveBeenCalledWith( + expect(cache.set).toHaveBeenCalledWith( "test-key", expect.objectContaining({ value: "test-content", @@ -306,131 +159,43 @@ describe("Composable cache handler", () => { await ComposableCache.refreshTags(); // Should not call any methods - expect(incrementalCache.get).not.toHaveBeenCalled(); - expect(incrementalCache.set).not.toHaveBeenCalled(); - expect(tagCache.writeTags).not.toHaveBeenCalled(); + expect(cache.get).not.toHaveBeenCalled(); + expect(cache.set).not.toHaveBeenCalled(); + expect(cache.revalidateTags).not.toHaveBeenCalled(); }); }); - describe("getExpiration (Next 15)", () => { - it("should return last revalidated time in nextMode", async () => { - tagCache.mode = "nextMode"; - tagCache.getLastRevalidated.mockResolvedValueOnce(123456); - - const result = await ComposableCache.getExpiration("tag1", "tag2"); - - expect(tagCache.getLastRevalidated).toHaveBeenCalledWith(["tag1", "tag2"]); - expect(result).toBe(123456); - }); - - it("should return 0 in original mode", async () => { - tagCache.mode = "original"; - + describe("getExpiration", () => { + it("should return 0 regardless of arguments", async () => { const result = await ComposableCache.getExpiration("tag1", "tag2"); expect(result).toBe(0); }); - it("should return 0 when mode is undefined", async () => { - tagCache.mode = undefined; - - const result = await ComposableCache.getExpiration("tag1", "tag2"); - - expect(result).toBe(0); - }); - }); - - describe("getExpiration (Next 16)", () => { - it("should return last revalidated time in nextMode", async () => { - tagCache.mode = "nextMode"; - tagCache.getLastRevalidated.mockResolvedValueOnce(123456); - - const result = await ComposableCache.getExpiration(["tag1", "tag2"]); - - expect(tagCache.getLastRevalidated).toHaveBeenCalledWith(["tag1", "tag2"]); - expect(result).toBe(123456); - }); - - it("should return 0 in original mode", async () => { - tagCache.mode = "original"; - + it("should return 0 for array argument (Next 16 signature)", async () => { const result = await ComposableCache.getExpiration(["tag1", "tag2"]); expect(result).toBe(0); }); - it("should return 0 when mode is undefined", async () => { - tagCache.mode = undefined; - - const result = await ComposableCache.getExpiration(["tag1", "tag2"]); + it("should return 0 for empty args", async () => { + const result = await ComposableCache.getExpiration(); expect(result).toBe(0); }); }); describe("expireTags", () => { - beforeEach(() => { - writtenTags.clear(); - }); - it("should write tags directly in nextMode", async () => { - tagCache.mode = "nextMode"; - - await ComposableCache.expireTags("tag1", "tag2"); - - expect(tagCache.writeTags).toHaveBeenCalledWith(["tag1", "tag2"]); - }); - - it("should find paths and write tag mappings in original mode", async () => { - tagCache.mode = "original"; - tagCache.getByTag.mockImplementation(async (tag) => { - if (tag === "tag1") return ["path1", "path2"]; - if (tag === "tag2") return ["path2", "path3"]; - return []; - }); - - await ComposableCache.expireTags("tag1", "tag2"); - - expect(tagCache.getByTag).toHaveBeenCalledWith("tag1"); - expect(tagCache.getByTag).toHaveBeenCalledWith("tag2"); - expect(tagCache.writeTags).toHaveBeenCalledWith( - expect.arrayContaining([ - { path: "path1", tag: "tag1", revalidatedAt: expect.any(Number) }, - { path: "path2", tag: "tag1", revalidatedAt: expect.any(Number) }, - { path: "path2", tag: "tag2", revalidatedAt: expect.any(Number) }, - { path: "path3", tag: "tag2", revalidatedAt: expect.any(Number) }, - ]) - ); - }); - - it("should deduplicate paths in original mode", async () => { - tagCache.mode = "original"; - tagCache.getByTag.mockImplementation(async (tag) => { - if (tag === "tag1") return ["path1", "path2"]; - if (tag === "tag2") return ["path1", "path2"]; - return []; - }); - + it("should call cache.revalidateTags with flat tags array", async () => { await ComposableCache.expireTags("tag1", "tag2"); - const writtenTags = tagCache.writeTags.mock.calls[0][0]; - expect(writtenTags).toHaveLength(4); // 2 paths × 2 tags = 4 unique combinations - expect(writtenTags).toEqual( - expect.arrayContaining([ - { path: "path1", tag: "tag1", revalidatedAt: expect.any(Number) }, - { path: "path2", tag: "tag1", revalidatedAt: expect.any(Number) }, - { path: "path1", tag: "tag2", revalidatedAt: expect.any(Number) }, - { path: "path2", tag: "tag2", revalidatedAt: expect.any(Number) }, - ]) - ); + expect(cache.revalidateTags).toHaveBeenCalledWith(["tag1", "tag2"]); }); - it("should handle empty paths in original mode", async () => { - tagCache.mode = "original"; - tagCache.getByTag.mockResolvedValue([]); - - await ComposableCache.expireTags("tag1"); + it("should not call revalidateTags when no tags provided", async () => { + await ComposableCache.expireTags(); - expect(tagCache.writeTags).not.toHaveBeenCalled(); + expect(cache.revalidateTags).not.toHaveBeenCalled(); }); }); @@ -439,9 +204,9 @@ describe("Composable cache handler", () => { await ComposableCache.receiveExpiredTags("tag1", "tag2"); // Should not call any methods - expect(incrementalCache.get).not.toHaveBeenCalled(); - expect(incrementalCache.set).not.toHaveBeenCalled(); - expect(tagCache.writeTags).not.toHaveBeenCalled(); + expect(cache.get).not.toHaveBeenCalled(); + expect(cache.set).not.toHaveBeenCalled(); + expect(cache.revalidateTags).not.toHaveBeenCalled(); }); }); @@ -460,7 +225,7 @@ describe("Composable cache handler", () => { await ComposableCache.set("integration-key", Promise.resolve(entry)); // Verify it was stored - expect(incrementalCache.set).toHaveBeenCalledWith( + expect(cache.set).toHaveBeenCalledWith( "integration-key", expect.objectContaining({ value: "integration-test", @@ -470,7 +235,7 @@ describe("Composable cache handler", () => { ); // Mock the get response - incrementalCache.get.mockResolvedValueOnce({ + cache.get.mockResolvedValueOnce({ value: { ...entry, value: "integration-test", @@ -518,8 +283,8 @@ describe("Composable cache handler", () => { const results = await Promise.all(promises); - expect(incrementalCache.set).toHaveBeenCalledTimes(2); - expect(incrementalCache.get).not.toHaveBeenCalled(); + expect(cache.set).toHaveBeenCalledTimes(2); + expect(cache.get).not.toHaveBeenCalled(); expect(results[2]).toBeDefined(); expect(results[3]).toBeDefined(); diff --git a/packages/tests-unit/tests/core/routing/cacheInterceptor.test.ts b/packages/tests-unit/tests/core/routing/cacheInterceptor.test.ts index 4b2475a7..0dcfde57 100644 --- a/packages/tests-unit/tests/core/routing/cacheInterceptor.test.ts +++ b/packages/tests-unit/tests/core/routing/cacheInterceptor.test.ts @@ -46,19 +46,12 @@ function createEvent(event: PartialEvent): MiddlewareEvent { }; } -const incrementalCache = { +const cache = { name: "mock", get: vi.fn(), set: vi.fn(), delete: vi.fn(), -}; - -const tagCache = { - name: "mock", - getByTag: vi.fn(), - getByPath: vi.fn(), - getLastModified: vi.fn(), - writeTags: vi.fn(), + revalidateTags: vi.fn(), }; const queue = { @@ -68,12 +61,10 @@ const queue = { declare global { var queue: Queue; - var incrementalCache: any; - var tagCache: any; + var cache: any; } -globalThis.incrementalCache = incrementalCache; -globalThis.tagCache = tagCache; +globalThis.cache = cache; globalThis.queue = queue; beforeEach(() => { @@ -110,12 +101,12 @@ describe("cacheInterceptor", () => { expect(result).toEqual(event); }); - it("should take no action when incremental cache throws", async () => { + it("should take no action when cache throws", async () => { const event = createEvent({ url: "/albums", }); - incrementalCache.get.mockRejectedValueOnce(new Error("mock error")); + cache.get.mockRejectedValueOnce(new Error("mock error")); const result = await cacheInterceptor(event); expect(result).toEqual(event); @@ -125,7 +116,7 @@ describe("cacheInterceptor", () => { const event = createEvent({ url: "/albums", }); - incrementalCache.get.mockResolvedValueOnce({ + cache.get.mockResolvedValueOnce({ value: { type: "app", html: "Hello, world!", @@ -151,64 +142,11 @@ describe("cacheInterceptor", () => { ); }); - it("should take no action when tagCache lasModified is -1 for app type", async () => { - const event = createEvent({ - url: "/albums", - }); - incrementalCache.get.mockResolvedValueOnce({ - value: { - type: "app", - html: "Hello, world!", - }, - }); - tagCache.getLastModified.mockResolvedValueOnce(-1); - - const result = await cacheInterceptor(event); - - expect(result).toEqual(event); - }); - - it("should bypass the tag cache when shouldBypassTagCache is true", async () => { - const event = createEvent({ - url: "/albums", - }); - incrementalCache.get.mockResolvedValueOnce({ - value: { - type: "app", - html: "Hello, world!", - }, - shouldBypassTagCache: true, - }); - - await cacheInterceptor(event); - - expect(tagCache.getLastModified).not.toHaveBeenCalled(); - }); - - it("should take no action when tagCache lasModified is -1 for route type", async () => { - const event = createEvent({ - url: "/albums", - }); - - const body = "route"; - incrementalCache.get.mockResolvedValueOnce({ - value: { - type: "route", - body: body, - revalidate: false, - }, - lastModified: new Date("2024-01-01T23:58:00Z").getTime(), - }); - tagCache.getLastModified.mockResolvedValueOnce(-1); - const result = await cacheInterceptor(event); - expect(result).toEqual(event); - }); - it("should retrieve page router content from stale cache", async () => { const event = createEvent({ url: "/revalidate", }); - incrementalCache.get.mockResolvedValueOnce({ + cache.get.mockResolvedValueOnce({ value: { type: "page", html: "Hello, world!", @@ -240,7 +178,7 @@ describe("cacheInterceptor", () => { const event = createEvent({ url: "/revalidate", }); - incrementalCache.get.mockResolvedValueOnce({ + cache.get.mockResolvedValueOnce({ value: { type: "page", html: "Hello, world!", @@ -272,7 +210,7 @@ describe("cacheInterceptor", () => { const event = createEvent({ url: "/albums", }); - incrementalCache.get.mockResolvedValueOnce({ + cache.get.mockResolvedValueOnce({ value: { type: "redirect", meta: { @@ -301,7 +239,7 @@ describe("cacheInterceptor", () => { const event = createEvent({ url: "/albums", }); - incrementalCache.get.mockResolvedValueOnce({ + cache.get.mockResolvedValueOnce({ value: { type: "?", html: "Hello, world!", @@ -318,7 +256,7 @@ describe("cacheInterceptor", () => { url: "/albums", }); const routeBody = JSON.stringify({ message: "Hello from API" }); - incrementalCache.get.mockResolvedValueOnce({ + cache.get.mockResolvedValueOnce({ value: { type: "route", body: routeBody, @@ -358,7 +296,7 @@ describe("cacheInterceptor", () => { url: "/albums", }); const routeBody = "randomBinaryData"; - incrementalCache.get.mockResolvedValueOnce({ + cache.get.mockResolvedValueOnce({ value: { type: "route", body: routeBody, @@ -398,7 +336,7 @@ describe("cacheInterceptor", () => { url: "/albums", }); const routeBody = "API response"; - incrementalCache.get.mockResolvedValueOnce({ + cache.get.mockResolvedValueOnce({ value: { type: "route", body: routeBody, @@ -440,7 +378,7 @@ describe("cacheInterceptor", () => { url: "/albums", }); const routeBody = "Simple response"; - incrementalCache.get.mockResolvedValueOnce({ + cache.get.mockResolvedValueOnce({ value: { type: "route", body: routeBody, @@ -473,7 +411,7 @@ describe("cacheInterceptor", () => { url: "/albums", rewriteStatusCode: 403, }); - incrementalCache.get.mockResolvedValueOnce({ + cache.get.mockResolvedValueOnce({ value: { type: "app", html: "Hello, world!", @@ -489,7 +427,7 @@ describe("cacheInterceptor", () => { url: "/albums", rewriteStatusCode: 203, }); - incrementalCache.get.mockResolvedValueOnce({ + cache.get.mockResolvedValueOnce({ value: { type: "app", html: "Hello, world!", @@ -507,7 +445,7 @@ describe("cacheInterceptor", () => { const event = createEvent({ url: "/albums", }); - incrementalCache.get.mockResolvedValueOnce({ + cache.get.mockResolvedValueOnce({ value: { type: "app", html: "Hello, world!", @@ -525,7 +463,7 @@ describe("cacheInterceptor", () => { const event = createEvent({ url: "/albums", }); - incrementalCache.get.mockResolvedValueOnce({ + cache.get.mockResolvedValueOnce({ value: { type: "app", html: "Hello, world!", From 07995729cc162ae9004abd70e75a14d56a6d8096 Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Sun, 16 Aug 2026 15:12:13 +0200 Subject: [PATCH 19/42] feat: port stale-while-revalidate tag revalidation from AWS Ports opennextjs-aws #1122 and #1142 on top of the cache handler function. - Tags carry optional `stale` and `expire` durations: `writeTags` accepts a `{ tag, stale, expire }` input as well as a plain name, `OriginalTagCache` entries gained the same fields, and both tag cache flavours gained an optional `isStale`. - `Cache.revalidateTags` takes the durations; the cache handler function turns the `expire` delay into a timestamp, callers pass it through unchanged. - The composable cache handler implements `updateTags`, added in Next.js 16. - New per-request `RequestCache` on the OpenNext request context, so overrides can deduplicate work within a request without touching global state, and `globalThis.nextVersion` is injected in the esbuild banner. - DynamoDB, fs-dev and the Cloudflare D1 / KV / sharded DO tag caches are updated for the new signatures. --- .changeset/swr-tag-revalidation.md | 17 ++ .../src/overrides/tagCache/dynamodb-lite.ts | 113 ++++++++++- .../overrides/tagCache/dynamodb-nextMode.ts | 183 ++++++++++++++---- .../aws/src/overrides/tagCache/dynamodb.ts | 145 +++++++++++--- .../api/overrides/cache/service-cache.spec.ts | 4 +- .../src/api/overrides/cache/service-cache.ts | 4 +- .../cloudflare/src/api/overrides/internal.ts | 14 +- .../tag-cache/d1-next-tag-cache.spec.ts | 3 +- .../overrides/tag-cache/d1-next-tag-cache.ts | 13 +- .../tag-cache/do-sharded-tag-cache.ts | 7 +- .../tag-cache/kv-next-tag-cache.spec.ts | 3 +- .../overrides/tag-cache/kv-next-tag-cache.ts | 13 +- .../overrides/tag-cache/tag-cache-filter.ts | 14 +- packages/core/src/adapters/cache-adapter.ts | 66 +++++-- packages/core/src/adapters/cache.ts | 4 +- .../core/src/adapters/composable-cache.ts | 25 +++ packages/core/src/build/helper.ts | 2 + .../core/src/core/routing/cacheInterceptor.ts | 41 +++- packages/core/src/overrides/cache/fetch.ts | 4 +- packages/core/src/overrides/cache/local.ts | 4 +- packages/core/src/overrides/tagCache/dummy.ts | 3 + .../src/overrides/tagCache/fs-dev-nextMode.ts | 49 ++++- .../core/src/overrides/tagCache/fs-dev.ts | 27 ++- packages/core/src/types/cache.ts | 4 + packages/core/src/types/global.ts | 9 + packages/core/src/types/overrides.ts | 14 +- packages/core/src/utils/cache.ts | 34 +++- packages/core/src/utils/promise.ts | 3 + packages/core/src/utils/requestCache.ts | 28 +++ .../tests/adapters/cache-adapter.test.ts | 114 +++++++++++ .../tests-unit/tests/adapters/cache.test.ts | 4 +- .../tests/adapters/composable-cache.test.ts | 64 ++++++ .../core/routing/cacheInterceptor.test.ts | 75 +++++++ 33 files changed, 956 insertions(+), 151 deletions(-) create mode 100644 .changeset/swr-tag-revalidation.md create mode 100644 packages/core/src/utils/requestCache.ts diff --git a/.changeset/swr-tag-revalidation.md b/.changeset/swr-tag-revalidation.md new file mode 100644 index 00000000..a75522e5 --- /dev/null +++ b/.changeset/swr-tag-revalidation.md @@ -0,0 +1,17 @@ +--- +"@opennextjs/core": minor +"@opennextjs/aws": minor +--- + +Port stale-while-revalidate tag revalidation from AWS + +Ports [#1122](https://github.com/opennextjs/opennextjs-aws/pull/1122) and +[#1142](https://github.com/opennextjs/opennextjs-aws/pull/1142). + +Tags can now carry `stale` and `expire` durations, so an entry can be served stale while it +revalidates instead of being dropped outright. `writeTags` accepts either a tag name or a +`{ tag, stale, expire }` object, both tag cache flavours gained an optional `isStale`, and the +composable cache handler implements the `updateTags` method added in Next.js 16. + +Tag cache overrides that need to deduplicate work within a request can use the new per-request +`requestCache` on the OpenNext request context. diff --git a/packages/aws/src/overrides/tagCache/dynamodb-lite.ts b/packages/aws/src/overrides/tagCache/dynamodb-lite.ts index 902bc29c..417afc38 100644 --- a/packages/aws/src/overrides/tagCache/dynamodb-lite.ts +++ b/packages/aws/src/overrides/tagCache/dynamodb-lite.ts @@ -14,6 +14,8 @@ type DynamoDBItem = { tag?: { S: string }; path?: { S: string }; revalidatedAt?: { N: string }; + stale?: { N: string }; + expire?: { N: string }; }; type DynamoDBResponse = { @@ -56,11 +58,19 @@ function buildDynamoKey(key: string) { return path.posix.join(NEXT_BUILD_ID ?? "", key); } -function buildDynamoObject(path: string, tags: string, revalidatedAt?: number) { +function buildDynamoObject( + path: string, + tags: string, + revalidatedAt?: number, + stale?: number, + expire?: number +) { return { path: { S: buildDynamoKey(path) }, tag: { S: buildDynamoKey(tags) }, revalidatedAt: { N: `${revalidatedAt ?? Date.now()}` }, + ...(stale !== undefined ? { stale: { N: `${stale}` } } : {}), + ...(expire !== undefined ? { expire: { N: `${expire}` } } : {}), }; } @@ -72,6 +82,11 @@ const tagCache: OriginalTagCache = { return []; } const { CACHE_DYNAMO_TABLE, NEXT_BUILD_ID } = process.env; + const store = globalThis.__openNextAls.getStore(); + const cache = store?.requestCache.getOrCreate("dynamoDb:getByPath"); + if (cache?.has(path)) { + return cache.get(path)!; + } const result = await awsFetch( JSON.stringify({ TableName: CACHE_DYNAMO_TABLE, @@ -93,7 +108,9 @@ const tagCache: OriginalTagCache = { const tags = Items?.map((item) => item.tag?.S ?? "") ?? []; debug("tags for path", path, tags); // We need to remove the buildId from the path - return tags.map((tag: string) => tag.replace(`${NEXT_BUILD_ID}/`, "")); + const resultTags = tags.map((tag: string) => tag.replace(`${NEXT_BUILD_ID}/`, "")); + cache?.set(path, resultTags); + return resultTags; } catch (e) { error("Failed to get tags by path", e); return []; @@ -105,6 +122,11 @@ const tagCache: OriginalTagCache = { return []; } const { CACHE_DYNAMO_TABLE, NEXT_BUILD_ID } = process.env; + const store = globalThis.__openNextAls.getStore(); + const cache = store?.requestCache.getOrCreate("dynamoDb:getByTag"); + if (cache?.has(tag)) { + return cache.get(tag)!; + } const result = await awsFetch( JSON.stringify({ TableName: CACHE_DYNAMO_TABLE, @@ -121,10 +143,9 @@ const tagCache: OriginalTagCache = { throw new RecoverableError(`Failed to get by tag: ${result.status}`); } const { Items } = (await result.json()) as DynamoDBResponse; - return ( - // We need to remove the buildId from the path - Items?.map((item) => item.path?.S?.replace(`${NEXT_BUILD_ID}/`, "") ?? "") ?? [] - ); + const paths = Items?.map((item) => item.path?.S?.replace(`${NEXT_BUILD_ID}/`, "") ?? "") ?? []; + cache?.set(tag, paths); + return paths; } catch (e) { error("Failed to get by tag", e); return []; @@ -136,6 +157,12 @@ const tagCache: OriginalTagCache = { return lastModified ?? Date.now(); } const { CACHE_DYNAMO_TABLE } = process.env; + const store = globalThis.__openNextAls.getStore(); + const cache = store?.requestCache.getOrCreate("dynamoDb:getLastModified"); + const cacheKey = `${key}:${lastModified ?? 0}`; + if (cache?.has(cacheKey)) { + return cache.get(cacheKey)!; + } const result = await awsFetch( JSON.stringify({ TableName: CACHE_DYNAMO_TABLE, @@ -156,14 +183,80 @@ const tagCache: OriginalTagCache = { } const revalidatedTags = ((await result.json()) as DynamoDBResponse).Items ?? []; debug("revalidatedTags", revalidatedTags); - // If we have revalidated tags we return -1 to force revalidation - return revalidatedTags.length > 0 ? -1 : (lastModified ?? Date.now()); + + // Check if any tag has expired + const now = Date.now(); + + const hasExpiredTag = revalidatedTags.some((item) => { + if (item.expire?.N) { + const expiry = Number.parseInt(item.expire.N); + return expiry <= now && expiry > (lastModified ?? 0); + } + return false; + }); + // Exclude expired tags from the revalidated count — they are handled + // separately via hasExpiredTag above. + const nonExpiredRevalidatedTags = revalidatedTags.filter((item) => { + if (item.expire?.N) { + return Number.parseInt(item.expire.N) === Number.parseInt(item.revalidatedAt?.N ?? "0"); + } + return true; + }); + // If we have revalidated tags or expired tags we return -1 to force revalidation + const resultValue = + nonExpiredRevalidatedTags.length > 0 || hasExpiredTag ? -1 : (lastModified ?? Date.now()); + cache?.set(cacheKey, resultValue); + return resultValue; } catch (e) { error("Failed to get revalidated tags", e); return lastModified ?? Date.now(); } }, - async writeTags(tags: { tag: string; path: string; revalidatedAt?: number }[]) { + async isStale(key: string, lastModified?: number) { + try { + if (globalThis.openNextConfig.dangerous?.disableTagCache) { + return false; + } + const { CACHE_DYNAMO_TABLE } = process.env; + const store = globalThis.__openNextAls.getStore(); + const itemsCache = store?.requestCache.getOrCreate( + "dynamoDb:revalidateQueryItems" + ); + const cacheKey = `${key}:${lastModified ?? 0}`; + let items: DynamoDBItem[]; + if (itemsCache?.has(cacheKey)) { + items = itemsCache.get(cacheKey)!; + } else { + // We can reuse the same query as getLastModified since it already checks for revalidatedAt > lastModified as revalidatedAt and stale have the same value + const result = await awsFetch( + JSON.stringify({ + TableName: CACHE_DYNAMO_TABLE, + IndexName: "revalidate", + KeyConditionExpression: "#key = :key AND #revalidatedAt > :lastModified", + ExpressionAttributeNames: { + "#key": "path", + "#revalidatedAt": "revalidatedAt", + }, + ExpressionAttributeValues: { + ":key": { S: buildDynamoKey(key) }, + ":lastModified": { N: String(lastModified ?? 0) }, + }, + }) + ); + if (result.status !== 200) { + throw new RecoverableError(`Failed to check stale tags: ${result.status}`); + } + items = ((await result.json()) as DynamoDBResponse).Items ?? []; + itemsCache?.set(cacheKey, items); + } + debug("isStale items", key, items); + return items.length > 0; + } catch (e) { + error("Failed to check stale tags", e); + return false; + } + }, + async writeTags(tags) { try { const { CACHE_DYNAMO_TABLE } = process.env; if (globalThis.openNextConfig.dangerous?.disableTagCache) { @@ -174,7 +267,7 @@ const tagCache: OriginalTagCache = { [CACHE_DYNAMO_TABLE ?? ""]: Items.map((Item) => ({ PutRequest: { Item: { - ...buildDynamoObject(Item.path, Item.tag, Item.revalidatedAt), + ...buildDynamoObject(Item.path, Item.tag, Item.revalidatedAt, Item.stale, Item.expire), }, }, })), diff --git a/packages/aws/src/overrides/tagCache/dynamodb-nextMode.ts b/packages/aws/src/overrides/tagCache/dynamodb-nextMode.ts index 88deb1a8..2b72d5c8 100644 --- a/packages/aws/src/overrides/tagCache/dynamodb-nextMode.ts +++ b/packages/aws/src/overrides/tagCache/dynamodb-nextMode.ts @@ -2,7 +2,7 @@ import path from "node:path"; import { debug, error } from "@opennextjs/core/adapters/logger.js"; import { chunk, parseNumberFromEnv } from "@opennextjs/core/adapters/util.js"; -import type { NextModeTagCache } from "@opennextjs/core/types/overrides.js"; +import type { NextModeTagCache, NextModeTagCacheWriteInput } from "@opennextjs/core/types/overrides.js"; import { RecoverableError } from "@opennextjs/core/utils/error.js"; import { AwsClient } from "aws4fetch"; @@ -10,17 +10,16 @@ import { customFetchClient } from "../../utils/fetch.js"; import { MAX_DYNAMO_BATCH_WRITE_ITEM_COUNT, getDynamoBatchWriteCommandConcurrency } from "./constants.js"; -type DynamoDBTagItem = { - revalidatedAt: { N: string }; - tag: { S: string }; -}; +let awsClient: AwsClient | null = null; -type DynamoDBBatchGetResponse = { - Responses?: Record; +type DynamoDBItem = { + tag?: { S: string }; + path?: { S: string }; + revalidatedAt?: { N: string }; + stale?: { N: string }; + expire?: { N: string }; }; -let awsClient: AwsClient | null = null; - const getAwsClient = () => { const { CACHE_BUCKET_REGION } = process.env; if (awsClient) { @@ -58,15 +57,84 @@ function buildDynamoKey(key: string) { // We use the same key for both path and tag // That's mostly for compatibility reason so that it's easier to use this with existing infra // FIXME: Allow a simpler object without an unnecessary path key -function buildDynamoObject(tag: string, revalidatedAt?: number) { +function buildDynamoObject(tag: string, revalidatedAt?: number, stale?: number, expire?: number) { return { path: { S: buildDynamoKey(tag) }, tag: { S: buildDynamoKey(tag) }, revalidatedAt: { N: `${revalidatedAt ?? Date.now()}` }, + ...(stale !== undefined ? { stale: { N: `${stale}` } } : {}), + ...(expire !== undefined ? { expire: { N: `${expire}` } } : {}), }; } // This implementation does not support automatic invalidation of paths by the cdn + +/** + * Checks the items cache for each tag. Returns tags not yet cached and whether + * a positive result was already found among the cached ones. + */ +function checkItemsCache( + tags: string[], + itemsCache: Map | undefined, + compute: (item: DynamoDBItem) => boolean +): { uncachedTags: string[]; hasMatch: boolean } { + const uncachedTags: string[] = []; + let hasMatch = false; + for (const tag of tags) { + if (itemsCache?.has(tag)) { + if (compute(itemsCache.get(tag)!)) hasMatch = true; + } else { + uncachedTags.push(tag); + } + } + return { uncachedTags, hasMatch }; +} + +/** + * Fetches uncached tags from DynamoDB via BatchGetItem, populates the items + * cache (storing null for absent tags), and returns whether any tag matched. + */ +async function fetchAndCacheItems( + uncachedTags: string[], + itemsCache: Map | undefined, + compute: (item: DynamoDBItem) => boolean +): Promise { + const { CACHE_DYNAMO_TABLE } = process.env; + const response = await awsFetch( + JSON.stringify({ + RequestItems: { + [CACHE_DYNAMO_TABLE ?? ""]: { + Keys: uncachedTags.map((tag) => ({ + path: { S: buildDynamoKey(tag) }, + tag: { S: buildDynamoKey(tag) }, + })), + }, + }, + }), + "query" + ); + if (response.status !== 200) { + throw new RecoverableError(`Failed to query dynamo item: ${response.status}`); + } + const { Responses } = await response.json(); + const responseItems: DynamoDBItem[] = Responses?.[CACHE_DYNAMO_TABLE ?? ""] ?? []; + + // Build a lookup map: DynamoDB key → item + const responseByKey = new Map(); + for (const item of responseItems) { + responseByKey.set(item.tag?.S ?? "", item); + } + + let hasMatch = false; + for (const tag of uncachedTags) { + const item = responseByKey.get(buildDynamoKey(tag)) ?? null; + if (!item) continue; + itemsCache?.set(tag, item); + if (compute(item)) hasMatch = true; + } + return hasMatch; +} + export default { name: "ddb-nextMode", mode: "nextMode", @@ -83,38 +151,62 @@ export default { "Cannot query more than 100 tags at once. You should not be using this tagCache implementation for this amount of tags" ); } - const { CACHE_DYNAMO_TABLE } = process.env; + + const store = globalThis.__openNextAls.getStore(); + const itemsCache = store?.requestCache.getOrCreate("ddb-nextMode:tagItems"); + + const now = Date.now(); + const compute = (item: DynamoDBItem): boolean => { + if (!item) return false; + if (item.expire?.N) { + const expiry = Number.parseInt(item.expire.N); + if (expiry <= now && expiry > (lastModified ?? 0)) return true; + } + return Number.parseInt(item.revalidatedAt?.N ?? "0") > (lastModified ?? 0); + }; + + const { uncachedTags, hasMatch } = checkItemsCache(tags, itemsCache, compute); + if (hasMatch) return true; + if (uncachedTags.length === 0) return false; + // It's unlikely that we will have more than 100 items to query // If that's the case, you should not use this tagCache implementation - const response = await awsFetch( - JSON.stringify({ - RequestItems: { - [CACHE_DYNAMO_TABLE ?? ""]: { - Keys: tags.map((tag) => ({ - path: { S: buildDynamoKey(tag) }, - tag: { S: buildDynamoKey(tag) }, - })), - }, - }, - }), - "query" - ); - if (response.status !== 200) { - throw new RecoverableError(`Failed to query dynamo item: ${response.status}`); - } - // Now we need to check for every item if lastModified is greater than the revalidatedAt - const { Responses } = (await response.json()) as DynamoDBBatchGetResponse; - if (!Responses) { + const result = await fetchAndCacheItems(uncachedTags, itemsCache, compute); + debug("retrieved tags for hasBeenRevalidated", tags); + return result; + }, + isStale: async (tags: string[], lastModified?: number) => { + if (globalThis.openNextConfig.dangerous?.disableTagCache) { return false; } - const revalidatedTags = - Responses?.[CACHE_DYNAMO_TABLE ?? ""]?.filter( - (item) => Number.parseInt(item.revalidatedAt.N) > (lastModified ?? 0) - ) ?? []; - debug("retrieved tags", revalidatedTags); - return revalidatedTags.length > 0; + if (tags.length === 0) return false; + if (tags.length > 100) { + throw new RecoverableError( + "Cannot query more than 100 tags at once. You should not be using this tagCache implementation for this amount of tags" + ); + } + + const store = globalThis.__openNextAls.getStore(); + const itemsCache = store?.requestCache.getOrCreate("ddb-nextMode:tagItems"); + + const compute = (item: DynamoDBItem): boolean => { + if (!item?.stale?.N) return false; + const revalidatedAt = Number.parseInt(item.revalidatedAt?.N ?? "0"); + // A tag is stale when both its stale timestamp and its revalidatedAt are newer than the page. + // revalidatedAt > lastModified ensures the revalidation that set this stale window happened + // after the page was generated, preventing a stale signal from a previous ISR cycle. + return revalidatedAt > (lastModified ?? 0) && Number.parseInt(item.stale.N) >= (lastModified ?? 0); + }; + + const { uncachedTags, hasMatch } = checkItemsCache(tags, itemsCache, compute); + if (hasMatch) return true; + if (uncachedTags.length === 0) return false; + + const result = await fetchAndCacheItems(uncachedTags, itemsCache, compute); + debug("isStale result:", result); + return result; }, - writeTags: async (tags: string[]) => { + writeTags: async (tags) => { try { const { CACHE_DYNAMO_TABLE } = process.env; if (globalThis.openNextConfig.dangerous?.disableTagCache) { @@ -122,13 +214,18 @@ export default { } const dataChunks = chunk(tags, MAX_DYNAMO_BATCH_WRITE_ITEM_COUNT).map((Items) => ({ RequestItems: { - [CACHE_DYNAMO_TABLE ?? ""]: Items.map((tag) => ({ - PutRequest: { - Item: { - ...buildDynamoObject(tag), + [CACHE_DYNAMO_TABLE ?? ""]: Items.map((tag) => { + const tagStr = typeof tag === "string" ? tag : tag.tag; + const stale = typeof tag === "string" ? undefined : tag.stale; + const expiry = typeof tag === "string" ? undefined : tag.expire; + return { + PutRequest: { + Item: { + ...buildDynamoObject(tagStr, undefined, stale, expiry), + }, }, - }, - })), + }; + }), }, })); const toInsert = chunk(dataChunks, getDynamoBatchWriteCommandConcurrency()); diff --git a/packages/aws/src/overrides/tagCache/dynamodb.ts b/packages/aws/src/overrides/tagCache/dynamodb.ts index 09eed484..2203dbc0 100644 --- a/packages/aws/src/overrides/tagCache/dynamodb.ts +++ b/packages/aws/src/overrides/tagCache/dynamodb.ts @@ -10,6 +10,14 @@ import { MAX_DYNAMO_BATCH_WRITE_ITEM_COUNT, getDynamoBatchWriteCommandConcurrenc const { CACHE_BUCKET_REGION, CACHE_DYNAMO_TABLE, NEXT_BUILD_ID } = process.env; +type DynamoDBItem = { + tag?: { S: string }; + path?: { S: string }; + revalidatedAt?: { N: string }; + stale?: { N: string }; + expire?: { N: string }; +}; + function parseDynamoClientConfigFromEnv(): DynamoDBClientConfig { return { region: CACHE_BUCKET_REGION, @@ -26,11 +34,19 @@ function buildDynamoKey(key: string) { return path.posix.join(NEXT_BUILD_ID ?? "", key); } -function buildDynamoObject(path: string, tags: string, revalidatedAt?: number) { +function buildDynamoObject( + path: string, + tags: string, + revalidatedAt?: number, + stale?: number, + expire?: number +) { return { path: { S: buildDynamoKey(path) }, tag: { S: buildDynamoKey(tags) }, revalidatedAt: { N: `${revalidatedAt ?? Date.now()}` }, + ...(stale !== undefined ? { stale: { N: `${stale}` } } : {}), + ...(expire !== undefined ? { expire: { N: `${expire}` } } : {}), }; } @@ -41,6 +57,11 @@ const tagCache: TagCache = { if (globalThis.openNextConfig.dangerous?.disableTagCache) { return []; } + const store = globalThis.__openNextAls.getStore(); + const cache = store?.requestCache.getOrCreate("dynamoDb:getByPath"); + if (cache?.has(path)) { + return cache.get(path)!; + } const result = await dynamoClient.send( new QueryCommand({ TableName: CACHE_DYNAMO_TABLE, @@ -57,7 +78,9 @@ const tagCache: TagCache = { const tags = result.Items?.map((item) => item.tag.S ?? "") ?? []; debug("tags for path", path, tags); // We need to remove the buildId from the path - return tags.map((tag) => tag.replace(`${NEXT_BUILD_ID}/`, "")); + const resultTags = tags.map((tag) => tag.replace(`${NEXT_BUILD_ID}/`, "")); + cache?.set(path, resultTags); + return resultTags; } catch (e) { error("Failed to get tags by path", e); return []; @@ -68,6 +91,11 @@ const tagCache: TagCache = { if (globalThis.openNextConfig.dangerous?.disableTagCache) { return []; } + const store = globalThis.__openNextAls.getStore(); + const cache = store?.requestCache.getOrCreate("dynamoDb:getByTag"); + if (cache?.has(tag)) { + return cache.get(tag)!; + } const { Items } = await dynamoClient.send( new QueryCommand({ TableName: CACHE_DYNAMO_TABLE, @@ -80,10 +108,10 @@ const tagCache: TagCache = { }, }) ); - return ( - // We need to remove the buildId from the path - Items?.map(({ path: { S: key } }) => key?.replace(`${NEXT_BUILD_ID}/`, "") ?? "") ?? [] - ); + // We need to remove the buildId from the path + const paths = Items?.map(({ path: { S: key } }) => key?.replace(`${NEXT_BUILD_ID}/`, "") ?? "") ?? []; + cache?.set(tag, paths); + return paths; } catch (e) { error("Failed to get by tag", e); return []; @@ -94,30 +122,99 @@ const tagCache: TagCache = { if (globalThis.openNextConfig.dangerous?.disableTagCache) { return lastModified ?? Date.now(); } - const result = await dynamoClient.send( - new QueryCommand({ - TableName: CACHE_DYNAMO_TABLE, - IndexName: "revalidate", - KeyConditionExpression: "#key = :key AND #revalidatedAt > :lastModified", - ExpressionAttributeNames: { - "#key": "path", - "#revalidatedAt": "revalidatedAt", - }, - ExpressionAttributeValues: { - ":key": { S: buildDynamoKey(key) }, - ":lastModified": { N: String(lastModified ?? 0) }, - }, - }) + const store = globalThis.__openNextAls.getStore(); + const itemsCache = store?.requestCache.getOrCreate( + "dynamoDb:revalidateQueryItems" ); - const revalidatedTags = result.Items ?? []; + const cacheKey = `${key}:${lastModified ?? 0}`; + let revalidatedTags: DynamoDBItem[]; + if (itemsCache?.has(cacheKey)) { + revalidatedTags = itemsCache.get(cacheKey)!; + } else { + const result = await dynamoClient.send( + new QueryCommand({ + TableName: CACHE_DYNAMO_TABLE, + IndexName: "revalidate", + KeyConditionExpression: "#key = :key AND #revalidatedAt > :lastModified", + ExpressionAttributeNames: { + "#key": "path", + "#revalidatedAt": "revalidatedAt", + }, + ExpressionAttributeValues: { + ":key": { S: buildDynamoKey(key) }, + ":lastModified": { N: String(lastModified ?? 0) }, + }, + }) + ); + revalidatedTags = result.Items ?? []; + itemsCache?.set(cacheKey, revalidatedTags); + } debug("revalidatedTags", revalidatedTags); - // If we have revalidated tags we return -1 to force revalidation - return revalidatedTags.length > 0 ? -1 : (lastModified ?? Date.now()); + + // Check if any tag has expired + const now = Date.now(); + const hasExpiredTag = revalidatedTags.some((item) => { + if (item.expire?.N) { + const expiry = Number.parseInt(item.expire.N); + return expiry <= now && expiry > (lastModified ?? 0); + } + return false; + }); + // Exclude expired tags from the revalidated count — they are handled + // separately via hasExpiredTag above. + const nonExpiredRevalidatedTags = revalidatedTags.filter((item) => { + if (item.expire?.N) { + return Number.parseInt(item.expire.N) > now; + } + return true; + }); + + // If we have revalidated tags or expired tags we return -1 to force revalidation + return nonExpiredRevalidatedTags.length > 0 || hasExpiredTag ? -1 : (lastModified ?? Date.now()); } catch (e) { error("Failed to get revalidated tags", e); return lastModified ?? Date.now(); } }, + async isStale(key: string, lastModified?: number) { + try { + if (globalThis.openNextConfig.dangerous?.disableTagCache) { + return false; + } + const store = globalThis.__openNextAls.getStore(); + const itemsCache = store?.requestCache.getOrCreate( + "dynamoDb:revalidateQueryItems" + ); + const cacheKey = `${key}:${lastModified ?? 0}`; + let items: DynamoDBItem[]; + if (itemsCache?.has(cacheKey)) { + items = itemsCache.get(cacheKey)!; + } else { + const result = await dynamoClient.send( + new QueryCommand({ + TableName: CACHE_DYNAMO_TABLE, + IndexName: "revalidate", + KeyConditionExpression: "#key = :key AND #revalidatedAt > :lastModified", + ExpressionAttributeNames: { + "#key": "path", + "#revalidatedAt": "revalidatedAt", + }, + ExpressionAttributeValues: { + ":key": { S: buildDynamoKey(key) }, + ":lastModified": { N: String(lastModified ?? 0) }, + }, + }) + ); + items = result.Items ?? []; + itemsCache?.set(cacheKey, items); + } + debug("isStale items", key, items); + return items.length > 0; + } catch (e) { + error("Failed to check stale tags", e); + return false; + } + }, async writeTags(tags) { try { if (globalThis.openNextConfig.dangerous?.disableTagCache) { @@ -128,7 +225,7 @@ const tagCache: TagCache = { [CACHE_DYNAMO_TABLE ?? ""]: Items.map((Item) => ({ PutRequest: { Item: { - ...buildDynamoObject(Item.path, Item.tag, Item.revalidatedAt), + ...buildDynamoObject(Item.path, Item.tag, Item.revalidatedAt, Item.stale, Item.expire), }, }, })), diff --git a/packages/cloudflare/src/api/overrides/cache/service-cache.spec.ts b/packages/cloudflare/src/api/overrides/cache/service-cache.spec.ts index 7787963d..2ad47c58 100644 --- a/packages/cloudflare/src/api/overrides/cache/service-cache.spec.ts +++ b/packages/cloudflare/src/api/overrides/cache/service-cache.spec.ts @@ -92,11 +92,11 @@ describe("serviceCache", () => { }); it("revalidates tags", async () => { - await serviceCache.revalidateTags(["tag1", "tag2"]); + await serviceCache.revalidateTags(["tag1", "tag2"], { expire: 10 }); const { url, method, body } = lastRequest(); expect(method).toBe("POST"); expect(url.pathname).toBe("/cache/revalidate-tags"); - expect(JSON.parse(body as string)).toEqual({ tags: ["tag1", "tag2"] }); + expect(JSON.parse(body as string)).toEqual({ tags: ["tag1", "tag2"], durations: { expire: 10 } }); }); }); diff --git a/packages/cloudflare/src/api/overrides/cache/service-cache.ts b/packages/cloudflare/src/api/overrides/cache/service-cache.ts index d7808cc1..02b3174e 100644 --- a/packages/cloudflare/src/api/overrides/cache/service-cache.ts +++ b/packages/cloudflare/src/api/overrides/cache/service-cache.ts @@ -82,11 +82,11 @@ const serviceCache = { await getCacheService().fetch(getCacheUrl(key), { method: "DELETE" }); }, - revalidateTags: async (tags) => { + revalidateTags: async (tags, durations) => { await getCacheService().fetch(new URL("/cache/revalidate-tags", CACHE_ORIGIN).href, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ tags }), + body: JSON.stringify({ tags, durations }), }); }, } satisfies Cache; diff --git a/packages/cloudflare/src/api/overrides/internal.ts b/packages/cloudflare/src/api/overrides/internal.ts index 0dfd492d..9b6c6d70 100644 --- a/packages/cloudflare/src/api/overrides/internal.ts +++ b/packages/cloudflare/src/api/overrides/internal.ts @@ -1,7 +1,11 @@ import { createHash } from "node:crypto"; import { error } from "@opennextjs/core/adapters/logger.js"; -import type { CacheEntryType, CacheValue } from "@opennextjs/core/types/overrides.js"; +import type { + CacheEntryType, + CacheValue, + NextModeTagCacheWriteInput, +} from "@opennextjs/core/types/overrides.js"; import { getCloudflareContext } from "../cloudflare-context.js"; @@ -32,6 +36,14 @@ export function computeCacheKey(key: string, options: KeyOptions) { return `${prefix}/${buildId}/${hash}.${cacheType}`.replace(/\/+/g, "/"); } +/** + * `writeTags` accepts either plain tag names or objects carrying the `stale`/`expire` durations. + * The Cloudflare tag caches do not support durations, they only need the names. + */ +export function toTagNames(tags: (string | NextModeTagCacheWriteInput)[]): string[] { + return tags.map((tag) => (typeof tag === "string" ? tag : tag.tag)); +} + export function isPurgeCacheEnabled(): boolean { // The `?` is required at `openNextConfig?` or the Open Next build fails because of a type error // The cache handler function only has `cacheHandler` populated, the other functions only have `default`. diff --git a/packages/cloudflare/src/api/overrides/tag-cache/d1-next-tag-cache.spec.ts b/packages/cloudflare/src/api/overrides/tag-cache/d1-next-tag-cache.spec.ts index 87aae85f..9bb43721 100644 --- a/packages/cloudflare/src/api/overrides/tag-cache/d1-next-tag-cache.spec.ts +++ b/packages/cloudflare/src/api/overrides/tag-cache/d1-next-tag-cache.spec.ts @@ -18,7 +18,8 @@ vi.mock("../../cloudflare-context.js", () => ({ getCloudflareContext: vi.fn(), })); -vi.mock("../internal.js", () => ({ +vi.mock("../internal.js", async (importOriginal) => ({ + ...(await importOriginal()), debugCache: vi.fn(), FALLBACK_BUILD_ID: "fallback-build-id", purgeCacheByTags: vi.fn(), diff --git a/packages/cloudflare/src/api/overrides/tag-cache/d1-next-tag-cache.ts b/packages/cloudflare/src/api/overrides/tag-cache/d1-next-tag-cache.ts index 018ef92e..ad3987f2 100644 --- a/packages/cloudflare/src/api/overrides/tag-cache/d1-next-tag-cache.ts +++ b/packages/cloudflare/src/api/overrides/tag-cache/d1-next-tag-cache.ts @@ -1,8 +1,14 @@ import { error } from "@opennextjs/core/adapters/logger.js"; -import type { NextModeTagCache } from "@opennextjs/core/types/overrides.js"; +import type { NextModeTagCache, NextModeTagCacheWriteInput } from "@opennextjs/core/types/overrides.js"; import { getCloudflareContext } from "../../cloudflare-context.js"; -import { debugCache, FALLBACK_BUILD_ID, isPurgeCacheEnabled, purgeCacheByTags } from "../internal.js"; +import { + debugCache, + FALLBACK_BUILD_ID, + isPurgeCacheEnabled, + purgeCacheByTags, + toTagNames, +} from "../internal.js"; export const NAME = "d1-next-mode-tag-cache"; @@ -63,7 +69,8 @@ export class D1NextModeTagCache implements NextModeTagCache { } } - async writeTags(tags: string[]): Promise { + async writeTags(tagsToWrite: (string | NextModeTagCacheWriteInput)[]): Promise { + const tags = toTagNames(tagsToWrite); const { isDisabled, db } = this.getConfig(); if (isDisabled || tags.length === 0) return Promise.resolve(); diff --git a/packages/cloudflare/src/api/overrides/tag-cache/do-sharded-tag-cache.ts b/packages/cloudflare/src/api/overrides/tag-cache/do-sharded-tag-cache.ts index cf9462af..1c625e76 100644 --- a/packages/cloudflare/src/api/overrides/tag-cache/do-sharded-tag-cache.ts +++ b/packages/cloudflare/src/api/overrides/tag-cache/do-sharded-tag-cache.ts @@ -1,12 +1,12 @@ import { debug, error } from "@opennextjs/core/adapters/logger.js"; import { generateShardId } from "@opennextjs/core/core/routing/queue.js"; -import type { NextModeTagCache } from "@opennextjs/core/types/overrides.js"; +import type { NextModeTagCache, NextModeTagCacheWriteInput } from "@opennextjs/core/types/overrides.js"; import { IgnorableError } from "@opennextjs/core/utils/error.js"; import { getCloudflareContext } from "../../cloudflare-context.js"; import type { OpenNextConfig } from "../../config.js"; import { DOShardedTagCache } from "../../durable-objects/sharded-tag-cache.js"; -import { debugCache, isPurgeCacheEnabled, purgeCacheByTags } from "../internal.js"; +import { debugCache, isPurgeCacheEnabled, purgeCacheByTags, toTagNames } from "../internal.js"; export const DEFAULT_WRITE_RETRIES = 3; export const DEFAULT_NUM_SHARDS = 4; @@ -232,7 +232,8 @@ class ShardedDOTagCache implements NextModeTagCache { * @param tags * @returns */ - public async writeTags(tags: string[]): Promise { + public async writeTags(tagsToWrite: (string | NextModeTagCacheWriteInput)[]): Promise { + const tags = toTagNames(tagsToWrite); const { isDisabled } = this.getConfig(); if (isDisabled) return; diff --git a/packages/cloudflare/src/api/overrides/tag-cache/kv-next-tag-cache.spec.ts b/packages/cloudflare/src/api/overrides/tag-cache/kv-next-tag-cache.spec.ts index 7a01d1c6..7fd8836e 100644 --- a/packages/cloudflare/src/api/overrides/tag-cache/kv-next-tag-cache.spec.ts +++ b/packages/cloudflare/src/api/overrides/tag-cache/kv-next-tag-cache.spec.ts @@ -15,7 +15,8 @@ vi.mock("../../cloudflare-context.js", () => ({ getCloudflareContext: vi.fn(), })); -vi.mock("../internal.js", () => ({ +vi.mock("../internal.js", async (importOriginal) => ({ + ...(await importOriginal()), debugCache: vi.fn(), FALLBACK_BUILD_ID: "fallback-build-id", purgeCacheByTags: vi.fn(), diff --git a/packages/cloudflare/src/api/overrides/tag-cache/kv-next-tag-cache.ts b/packages/cloudflare/src/api/overrides/tag-cache/kv-next-tag-cache.ts index 6c4a79ad..7bd2a116 100644 --- a/packages/cloudflare/src/api/overrides/tag-cache/kv-next-tag-cache.ts +++ b/packages/cloudflare/src/api/overrides/tag-cache/kv-next-tag-cache.ts @@ -1,8 +1,14 @@ import { error } from "@opennextjs/core/adapters/logger.js"; -import type { NextModeTagCache } from "@opennextjs/core/types/overrides.js"; +import type { NextModeTagCache, NextModeTagCacheWriteInput } from "@opennextjs/core/types/overrides.js"; import { getCloudflareContext } from "../../cloudflare-context.js"; -import { debugCache, FALLBACK_BUILD_ID, isPurgeCacheEnabled, purgeCacheByTags } from "../internal.js"; +import { + debugCache, + FALLBACK_BUILD_ID, + isPurgeCacheEnabled, + purgeCacheByTags, + toTagNames, +} from "../internal.js"; export const NAME = "kv-next-mode-tag-cache"; @@ -65,7 +71,8 @@ export class KVNextModeTagCache implements NextModeTagCache { return revalidated; } - async writeTags(tags: string[]): Promise { + async writeTags(tagsToWrite: (string | NextModeTagCacheWriteInput)[]): Promise { + const tags = toTagNames(tagsToWrite); const kv = this.getKv(); if (!kv || tags.length === 0) { return Promise.resolve(); diff --git a/packages/cloudflare/src/api/overrides/tag-cache/tag-cache-filter.ts b/packages/cloudflare/src/api/overrides/tag-cache/tag-cache-filter.ts index 05c11970..49efb5e3 100644 --- a/packages/cloudflare/src/api/overrides/tag-cache/tag-cache-filter.ts +++ b/packages/cloudflare/src/api/overrides/tag-cache/tag-cache-filter.ts @@ -1,17 +1,16 @@ -import { NextModeTagCache } from "@opennextjs/core/types/overrides.js"; +import { NextModeTagCache, NextModeTagCacheWriteInput } from "@opennextjs/core/types/overrides.js"; interface WithFilterOptions { /** * The original tag cache. - * Call to this will receive only the filtered tags. */ tagCache: NextModeTagCache; + /** - * The function to filter tags. - * @param tag The tag to filter. + * Filter function that returns true if the tag should be forwarded to the underlying tag cache. * @returns true if the tag should be forwarded, false otherwise. */ - filterFn: (tag: string) => boolean; + filterFn: (tag: string | NextModeTagCacheWriteInput) => boolean; } /** @@ -60,6 +59,7 @@ export function withFilter({ tagCache, filterFn }: WithFilterOptions): NextModeT * This is used to filter out internal soft tags. * Can be used if `revalidatePath` is not used. */ -export function softTagFilter(tag: string): boolean { - return !tag.startsWith("_N_T_"); +export function softTagFilter(tag: string | { tag: string }): boolean { + const tagStr = typeof tag === "string" ? tag : tag.tag; + return !tagStr.startsWith("_N_T_"); } diff --git a/packages/core/src/adapters/cache-adapter.ts b/packages/core/src/adapters/cache-adapter.ts index c690b172..8e62268d 100644 --- a/packages/core/src/adapters/cache-adapter.ts +++ b/packages/core/src/adapters/cache-adapter.ts @@ -13,7 +13,7 @@ import type { import { createGenericHandler } from "../core/createGenericHandler.js"; import { resolveCdnInvalidation, resolveIncrementalCache, resolveTagCache } from "../core/resolve.js"; -import { getTagsFromValue, writeTags } from "../utils/cache.js"; +import { getTagsFromValue, isStale, writeTags } from "../utils/cache.js"; import { runWithOpenNextRequestContext } from "../utils/promise.js"; import { toReadableStream } from "../utils/stream.js"; @@ -146,6 +146,8 @@ async function handleGet( } if (!result.shouldBypassTagCache) { + const lastModified = result.lastModified ?? Date.now(); + if (tags.length > 0) { const revalidated = await checkTagRevalidation(key, tags, result); if (revalidated) { @@ -162,6 +164,12 @@ async function handleGet( }; } } + + // Check if the cache entry is stale (valid but needs background revalidation) + const _isStale = tags.length > 0 ? await isStale(key, tags, lastModified) : false; + if (_isStale) { + result.lastModified = 1; + } } return buildCacheGetResponse(result); @@ -234,6 +242,9 @@ async function handleSet(key: string, cacheType: CacheEntryType, body?: Buffer): const storedTags = await tagCache.getByPath(key); const tagsToWrite = derivedTags.filter((tag) => !storedTags.includes(tag)); if (tagsToWrite.length > 0) { + // `1` marks a row that was never revalidated: it can never be greater than a + // real `lastModified`, so the entry being written is not immediately treated + // as outdated by the next read. await writeTags( tagsToWrite.map((tag) => ({ path: key, @@ -273,24 +284,38 @@ async function handleRevalidateTags(body?: Buffer): Promise { return buildErrorResponse("Missing request body", 400); } - let tags: string[]; + let parsed: { tags?: string[]; durations?: { expire?: number } }; try { - const parsed = JSON.parse(body.toString("utf-8")); - tags = Array.isArray(parsed.tags) ? parsed.tags : []; + parsed = JSON.parse(body.toString("utf-8")); } catch { return buildErrorResponse("Invalid JSON body", 400); } + const tags = Array.isArray(parsed.tags) ? parsed.tags : []; if (tags.length === 0) { return buildErrorResponse("Missing 'tags' array in request body", 400); } + const { durations } = parsed; + try { await runWithOpenNextRequestContext({ isISRRevalidation: false }, async () => { if (globalThis.tagCache.mode === "nextMode") { const paths = (await globalThis.tagCache.getPathsByTags?.(tags)) ?? []; - await writeTags(tags); + const now = Date.now(); + const tagsToWrite = tags.map((tag) => { + if (durations) { + return { + tag, + stale: now, + expire: durations.expire !== undefined ? now + durations.expire * 1000 : undefined, + }; + } + return { tag, expire: now }; + }); + + await writeTags(tagsToWrite); if (paths.length > 0) { await globalThis.cdnInvalidationHandler.invalidatePaths( paths.map((path) => ({ @@ -309,14 +334,22 @@ async function handleRevalidateTags(body?: Buffer): Promise { return; } + const now = Date.now(); 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, - })); + const toInsert = paths.map((path) => { + const baseEntry = { path, tag }; + if (durations) { + return { + ...baseEntry, + stale: now, + expire: durations.expire !== undefined ? now + durations.expire * 1000 : undefined, + }; + } + return { ...baseEntry, expire: now }; + }); if (tag.startsWith(SOFT_TAG_PREFIX)) { for (const path of paths) { @@ -326,10 +359,17 @@ async function handleRevalidateTags(body?: Buffer): Promise { const _paths = await globalThis.tagCache.getByTag(hardTag); debug({ hardTag, _paths }); toInsert.push( - ..._paths.map((path) => ({ - path, - tag: hardTag, - })) + ..._paths.map((path) => { + const baseEntry = { path, tag: hardTag }; + if (durations) { + return { + ...baseEntry, + stale: now, + expire: durations.expire !== undefined ? now + durations.expire * 1000 : undefined, + }; + } + return { ...baseEntry, expire: now }; + }) ); } } diff --git a/packages/core/src/adapters/cache.ts b/packages/core/src/adapters/cache.ts index 25378bf9..6ad0b0f5 100644 --- a/packages/core/src/adapters/cache.ts +++ b/packages/core/src/adapters/cache.ts @@ -250,7 +250,7 @@ export default class Cache { } } - public async revalidateTag(tags: string | string[]) { + public async revalidateTag(tags: string | string[], durations?: { expire?: number }) { const config = globalThis.openNextConfig.dangerous; if (config?.disableTagCache || config?.disableIncrementalCache) { return; @@ -261,7 +261,7 @@ export default class Cache { } try { - await globalThis.cache.revalidateTags(_tags); + await globalThis.cache.revalidateTags(_tags, durations); } catch (e) { error("Failed to revalidate tag", e); } diff --git a/packages/core/src/adapters/composable-cache.ts b/packages/core/src/adapters/composable-cache.ts index 820fbd41..f3377b3e 100644 --- a/packages/core/src/adapters/composable-cache.ts +++ b/packages/core/src/adapters/composable-cache.ts @@ -27,8 +27,15 @@ export default { debug("composable cache result", result); + let revalidate = result.value.revalidate; + // If the cache adapter signaled staleness via lastModified=1, trigger SWR + if (result.lastModified === 1) { + revalidate = -1; + } + return { ...result.value, + revalidate, value: toReadableStream(result.value.value), }; } catch (e) { @@ -83,6 +90,24 @@ export default { } }, + /** + * Added in Next.js 16. Updates tags with optional stale/expire durations. + * Mirrors the revalidateTag logic but without CDN invalidation + * since composable cache keys are not URL paths. + */ + async updateTags(tags: string[], durations?: { expire?: number }) { + if (tags.length === 0) { + return; + } + try { + // `durations.expire` is a delay in seconds, it is turned into a timestamp by the cache + // handler function - it should not be converted here as well. + await globalThis.cache.revalidateTags(tags, durations); + } catch (e) { + debug("Failed to update tags", e); + } + }, + // This one is necessary for older versions of next async receiveExpiredTags(...tags: string[]) { // This function does absolutely nothing diff --git a/packages/core/src/build/helper.ts b/packages/core/src/build/helper.ts index 159495b9..766ab69d 100644 --- a/packages/core/src/build/helper.ts +++ b/packages/core/src/build/helper.ts @@ -117,6 +117,7 @@ export function esbuildSync(esbuildOptions: ESBuildOptions, options: BuildOption esbuildOptions.banner?.js || "", `globalThis.openNextDebug = ${debug};`, `globalThis.openNextVersion = "${openNextVersion}";`, + `globalThis.nextVersion = "${options.nextVersion}";`, ].join(""), }, }); @@ -150,6 +151,7 @@ export async function esbuildAsync(esbuildOptions: ESBuildOptions, options: Buil esbuildOptions.banner?.js || "", `globalThis.openNextDebug = ${debug};`, `globalThis.openNextVersion = "${openNextVersion}";`, + `globalThis.nextVersion = "${options.nextVersion}";`, ].join(""), }, }); diff --git a/packages/core/src/core/routing/cacheInterceptor.ts b/packages/core/src/core/routing/cacheInterceptor.ts index d54b5781..0e711802 100644 --- a/packages/core/src/core/routing/cacheInterceptor.ts +++ b/packages/core/src/core/routing/cacheInterceptor.ts @@ -34,7 +34,8 @@ async function computeCacheControl( body: string, host: string, revalidate?: number | false, - lastModified?: number + lastModified?: number, + isStaleFromTagCache = false ) { let finalRevalidate = CACHE_ONE_YEAR; @@ -59,19 +60,26 @@ async function computeCacheControl( etag, }; } - if (finalRevalidate !== CACHE_ONE_YEAR) { - const sMaxAge = Math.max(finalRevalidate - age, 1); + + // SSG uses one year cache + const isSSG = finalRevalidate === CACHE_ONE_YEAR; + const remainingTtl = Math.max(finalRevalidate - age, 1); + + const isStaleFromTime = !isSSG && remainingTtl === 1; + const isStale = isStaleFromTime || isStaleFromTagCache; + + if (!isSSG || isStaleFromTagCache) { + const sMaxAge = isStaleFromTagCache ? 1 : remainingTtl; debug("sMaxAge", { finalRevalidate, age, lastModified, revalidate, + isStaleFromTagCache, }); - const isStale = sMaxAge === 1; if (isStale) { let url = NextConfig.trailingSlash ? `${path}/` : path; if (NextConfig.basePath) { - // We need to add the basePath to the url url = `${NextConfig.basePath}${url}`; } await globalThis.queue.send({ @@ -164,7 +172,8 @@ async function generateResult( event: MiddlewareEvent, localizedPath: string, cachedValue: CacheValue<"cache">, - lastModified?: number + lastModified?: number, + isStaleFromTagCache = false ): Promise { debug("Returning result from experimental cache"); let body = ""; @@ -231,7 +240,8 @@ async function generateResult( body, event.headers.host, cachedValue.revalidate, - lastModified + lastModified, + isStaleFromTagCache ); return { type: "core", @@ -346,17 +356,27 @@ export async function cacheInterceptor( return event; } const host = event.headers.host; + //TODO: change returned type to provide staleness as a prop + // Detect staleness signaled by the cache adapter (sets lastModified to 1) + const isStaleFromTagCache = cachedData.lastModified === 1; switch (cachedData?.value?.type) { case "app": case "page": - return generateResult(event, localizedPath, cachedData.value, cachedData.lastModified); + return generateResult( + event, + localizedPath, + cachedData.value, + cachedData.lastModified, + isStaleFromTagCache + ); case "redirect": { const cacheControl = await computeCacheControl( localizedPath, "", host, cachedData.value.revalidate, - cachedData.lastModified + cachedData.lastModified, + isStaleFromTagCache ); return { type: "core", @@ -375,7 +395,8 @@ export async function cacheInterceptor( cachedData.value.body, host, cachedData.value.revalidate, - cachedData.lastModified + cachedData.lastModified, + isStaleFromTagCache ); const isBinary = isBinaryContentType(String(cachedData.value.meta?.headers?.["content-type"])); diff --git a/packages/core/src/overrides/cache/fetch.ts b/packages/core/src/overrides/cache/fetch.ts index 9d8825c4..f708e0d6 100644 --- a/packages/core/src/overrides/cache/fetch.ts +++ b/packages/core/src/overrides/cache/fetch.ts @@ -35,11 +35,11 @@ const fetchCache: Cache = { const url = `${CACHE_URL}/cache/${encodeURIComponent(key)}`; await fetch(url, { method: "DELETE" }); }, - revalidateTags: async (tags) => { + revalidateTags: async (tags, durations) => { await fetch(`${CACHE_URL}/cache/revalidate-tags`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ tags }), + body: JSON.stringify({ tags, durations }), }); }, }; diff --git a/packages/core/src/overrides/cache/local.ts b/packages/core/src/overrides/cache/local.ts index 33cc78f9..d501dc91 100644 --- a/packages/core/src/overrides/cache/local.ts +++ b/packages/core/src/overrides/cache/local.ts @@ -78,7 +78,7 @@ const localCache: Cache = { }; await h(event); }, - revalidateTags: async (tags) => { + revalidateTags: async (tags, durations) => { const h = (await getHandler())!; const url = `https://on/cache/revalidate-tags`; const event: InternalEvent = { @@ -90,7 +90,7 @@ const localCache: Cache = { query: {}, cookies: {}, remoteAddress: "127.0.0.1", - body: Buffer.from(JSON.stringify({ tags })), + body: Buffer.from(JSON.stringify({ tags, durations })), }; await h(event); }, diff --git a/packages/core/src/overrides/tagCache/dummy.ts b/packages/core/src/overrides/tagCache/dummy.ts index ac44b532..8a62dfa8 100644 --- a/packages/core/src/overrides/tagCache/dummy.ts +++ b/packages/core/src/overrides/tagCache/dummy.ts @@ -13,6 +13,9 @@ const dummyTagCache: TagCache = { getLastModified: async (_: string, lastModified) => { return lastModified ?? Date.now(); }, + isStale: async () => { + return false; + }, writeTags: async () => { return; }, diff --git a/packages/core/src/overrides/tagCache/fs-dev-nextMode.ts b/packages/core/src/overrides/tagCache/fs-dev-nextMode.ts index 49ccb498..08b6ddb9 100644 --- a/packages/core/src/overrides/tagCache/fs-dev-nextMode.ts +++ b/packages/core/src/overrides/tagCache/fs-dev-nextMode.ts @@ -1,8 +1,14 @@ -import type { NextModeTagCache } from "@/types/overrides"; +import type { NextModeTagCache, NextModeTagCacheWriteInput } from "@/types/overrides"; import { debug } from "../../adapters/logger"; -const tagsMap = new Map(); +type TagData = { + revalidatedAt: number; + stale?: number; + expire?: number; +}; + +const tagsMap = new Map(); export default { name: "fs-dev-nextMode", @@ -15,7 +21,7 @@ export default { let lastRevalidated = 0; tags.forEach((tag) => { - const tagTime = tagsMap.get(tag); + const tagTime = tagsMap.get(tag)?.revalidatedAt; if (tagTime && tagTime > lastRevalidated) { lastRevalidated = tagTime; } @@ -30,22 +36,49 @@ export default { } const hasRevalidatedTag = tags.some((tag) => { - const tagRevalidatedAt = tagsMap.get(tag); - return tagRevalidatedAt ? tagRevalidatedAt > (lastModified ?? 0) : false; + const tagData = tagsMap.get(tag); + return tagData ? tagData.revalidatedAt > (lastModified ?? 0) : false; }); debug("hasBeenRevalidated result:", hasRevalidatedTag); return hasRevalidatedTag; }, - writeTags: async (tags: string[]) => { + isStale: async (tags: string[], lastModified?: number) => { + if (globalThis.openNextConfig.dangerous?.disableTagCache) { + return false; + } + + const hasStaleTag = tags.some((tag) => { + const tagData = tagsMap.get(tag); + if (!tagData || typeof tagData.stale !== "number") { + return false; + } + // A tag is stale when both its stale timestamp and its revalidatedAt are newer than the page. + // revalidatedAt > lastModified ensures the revalidation that set this stale window happened + // after the page was generated, preventing a stale signal from a previous ISR cycle. + return tagData.revalidatedAt > (lastModified ?? 0) && tagData.stale >= (lastModified ?? 0); + }); + debug("isStale result:", hasStaleTag); + return hasStaleTag; + }, + writeTags: async (tags: (string | NextModeTagCacheWriteInput)[]) => { if (globalThis.openNextConfig.dangerous?.disableTagCache || tags.length === 0) { return; } - debug("writeTags", { tags: tags }); + debug("writeTags", { tags }); + const now = Date.now(); tags.forEach((tag) => { - tagsMap.set(tag, Date.now()); + if (typeof tag === "string") { + tagsMap.set(tag, { revalidatedAt: now }); + } else { + tagsMap.set(tag.tag, { + revalidatedAt: now, + ...(tag.stale !== undefined ? { stale: tag.stale } : {}), + ...(tag.expire !== undefined ? { expire: tag.expire } : {}), + }); + } }); debug("writeTags completed, written", tags.length, "tags"); diff --git a/packages/core/src/overrides/tagCache/fs-dev.ts b/packages/core/src/overrides/tagCache/fs-dev.ts index 932eb216..1313b620 100644 --- a/packages/core/src/overrides/tagCache/fs-dev.ts +++ b/packages/core/src/overrides/tagCache/fs-dev.ts @@ -1,17 +1,21 @@ import fs from "node:fs"; import path from "node:path"; -import type { TagCache } from "@/types/overrides"; +import type { OriginalTagCacheWriteInput, TagCache } from "@/types/overrides"; import { getMonorepoRelativePath } from "@/utils/normalize-path"; const tagFile = path.join(getMonorepoRelativePath(), "dynamodb-provider/dynamodb-cache.json"); const tagContent = fs.readFileSync(tagFile, "utf-8"); -let tags = JSON.parse(tagContent) as { +type TagEntry = { tag: { S: string }; path: { S: string }; revalidatedAt: { N: string }; -}[]; + stale?: { N: string }; + expire?: { N: string }; +}; + +let tags = JSON.parse(tagContent) as TagEntry[]; const { NEXT_BUILD_ID } = process.env; @@ -40,7 +44,20 @@ const tagCache: TagCache = { ); return revalidatedTags.length > 0 ? -1 : (lastModified ?? Date.now()); }, - writeTags: async (newTags) => { + isStale: async (path: string, lastModified?: number) => { + const matchingTags = tags.filter((tagPathMapping) => tagPathMapping.path.S === buildKey(path)); + return matchingTags.some((entry) => { + if (!entry.stale?.N) return false; + // A tag is stale when both its stale timestamp and its revalidatedAt are newer than the page. + // revalidatedAt > lastModified ensures the revalidation that set this stale window happened + // after the page was generated, preventing a stale signal from a previous ISR cycle. + return ( + Number.parseInt(entry.revalidatedAt.N) > (lastModified ?? 0) && + Number.parseInt(entry.stale.N) > (lastModified ?? 0) + ); + }); + }, + writeTags: async (newTags: OriginalTagCacheWriteInput[]) => { const newTagsSet = new Set(newTags.map(({ tag, path }) => `${buildKey(tag)}-${buildKey(path)}`)); const unchangedTags = tags.filter(({ tag, path }) => !newTagsSet.has(`${tag.S}-${path.S}`)); tags = unchangedTags.concat( @@ -48,6 +65,8 @@ const tagCache: TagCache = { tag: { S: buildKey(item.tag) }, path: { S: buildKey(item.path) }, revalidatedAt: { N: `${item.revalidatedAt ?? Date.now()}` }, + ...(item.stale !== undefined ? { stale: { N: `${item.stale}` } } : {}), + ...(item.expire !== undefined ? { expire: { N: `${item.expire}` } } : {}), })) ); }, diff --git a/packages/core/src/types/cache.ts b/packages/core/src/types/cache.ts index db5b63f1..1737d0c4 100644 --- a/packages/core/src/types/cache.ts +++ b/packages/core/src/types/cache.ts @@ -168,6 +168,10 @@ export interface ComposableCacheHandler { * Removed from Next.js 16 */ expireTags(...tags: string[]): Promise; + /** + * Added in Next.js 16. Updates tags with optional stale/expire durations. + */ + updateTags?(tags: string[], durations?: { expire?: number }): Promise; /** * This function is only there for older versions and do nothing */ diff --git a/packages/core/src/types/global.ts b/packages/core/src/types/global.ts index 552bc664..6027798d 100644 --- a/packages/core/src/types/global.ts +++ b/packages/core/src/types/global.ts @@ -12,6 +12,7 @@ import type { } from "@/types/overrides"; import type { DetachedPromiseRunner } from "../utils/promise"; +import type { RequestCache } from "../utils/requestCache"; import type { i18nConfig } from "./next-types.js"; import type { OpenNextConfig, WaitUntil } from "./open-next"; @@ -69,6 +70,8 @@ interface OpenNextRequestContext { waitUntil?: WaitUntil; /** We use this to deduplicate write of the tags*/ writtenTags: Set; + /** Per-request in-memory cache. Overrides can use this to store data scoped to the current request. */ + requestCache: RequestCache; } declare global { @@ -196,6 +199,12 @@ declare global { */ var openNextVersion: string; + /** + * The version of Next.js used in this build. + * Available in the cache function (defined in the esbuild banner of the cache bundle). + */ + var nextVersion: string; + /** * The cache client used to communicate with the cache handler function. * Only available in main functions. diff --git a/packages/core/src/types/overrides.ts b/packages/core/src/types/overrides.ts index fd767559..ae4d079f 100644 --- a/packages/core/src/types/overrides.ts +++ b/packages/core/src/types/overrides.ts @@ -153,12 +153,19 @@ Cons : - One page request (i.e. GET request) could require to check a lot of tags (And some of them multiple time when used with the fetch cache) - Almost impossible to do automatic cdn revalidation by itself */ +export interface NextModeTagCacheWriteInput { + tag: string; + stale?: number; + expire?: number; +} + export type NextModeTagCache = BaseTagCache & { mode: "nextMode"; // Necessary for the composable cache getLastRevalidated(tags: string[]): Promise; hasBeenRevalidated(tags: string[], lastModified?: number): Promise; - writeTags(tags: string[]): Promise; + isStale?(tags: string[], lastModified?: number): Promise; + writeTags(tags: (string | NextModeTagCacheWriteInput)[]): Promise; // Optional method to get paths by tags // It is used to automatically invalidate paths in the CDN getPathsByTags?: (tags: string[]) => Promise; @@ -168,6 +175,8 @@ export interface OriginalTagCacheWriteInput { tag: string; path: string; revalidatedAt?: number; + stale?: number; + expire?: number; } /** @@ -194,6 +203,7 @@ export type OriginalTagCache = BaseTagCache & { getByTag(tag: string): Promise; getByPath(path: string): Promise; getLastModified(path: string, lastModified?: number): Promise; + isStale?(path: string, lastModified?: number): Promise; writeTags(tags: OriginalTagCacheWriteInput[]): Promise; }; @@ -265,7 +275,7 @@ export type Cache = BaseOverride & { isFetch?: CacheType ): Promise; delete(key: string): Promise; - revalidateTags(tags: string[]): Promise; + revalidateTags(tags: string[], durations?: { expire?: number }): Promise; }; type CDNPath = { diff --git a/packages/core/src/utils/cache.ts b/packages/core/src/utils/cache.ts index 98f14441..6af52bf3 100644 --- a/packages/core/src/utils/cache.ts +++ b/packages/core/src/utils/cache.ts @@ -1,6 +1,7 @@ import type { CacheEntryType, CacheValue, + NextModeTagCacheWriteInput, OriginalTagCacheWriteInput, TagCache, WithLastModified, @@ -8,6 +9,22 @@ import type { import { debug } from "../adapters/logger"; +export async function isStale( + key: string, + tags: string[], + lastModified: number, + tagCache: TagCache = globalThis.tagCache +): Promise { + if (globalThis.openNextConfig.dangerous?.disableTagCache) { + return false; + } + if (tagCache.mode === "nextMode") { + return tags.length > 0 && (await tagCache.isStale?.(tags, lastModified)) === true; + } + const isCacheStale = await tagCache.isStale?.(key, lastModified); + return isCacheStale === true; +} + export async function hasBeenRevalidated( key: string, tags: string[], @@ -48,18 +65,23 @@ export function getTagsFromValue(value?: CacheValue<"cache">) { } } -function getTagKey(tag: string | OriginalTagCacheWriteInput): string { +type WriteTagInput = string | NextModeTagCacheWriteInput | OriginalTagCacheWriteInput; + +function getTagKey(tag: WriteTagInput): string { if (typeof tag === "string") { return tag; } - return JSON.stringify({ - tag: tag.tag, - path: tag.path, - }); + if ("path" in tag) { + return JSON.stringify({ + tag: tag.tag, + path: tag.path, + }); + } + return JSON.stringify({ tag: tag.tag }); } export async function writeTags( - tags: (string | OriginalTagCacheWriteInput)[], + tags: WriteTagInput[], tagCache: TagCache = globalThis.tagCache ): Promise { const store = globalThis.__openNextAls.getStore(); diff --git a/packages/core/src/utils/promise.ts b/packages/core/src/utils/promise.ts index 00602ce0..e3a2e158 100644 --- a/packages/core/src/utils/promise.ts +++ b/packages/core/src/utils/promise.ts @@ -2,6 +2,8 @@ import type { WaitUntil } from "@/types/open-next"; import { debug, error } from "../adapters/logger"; +import { RequestCache } from "./requestCache"; + /** * A `Promise.withResolvers` implementation that exposes the `resolve` and * `reject` functions on a `Promise`. @@ -120,6 +122,7 @@ export function runWithOpenNextRequestContext( isISRRevalidation, waitUntil, writtenTags: new Set(), + requestCache: new RequestCache(), }, async () => { provideNextAfterProvider(); diff --git a/packages/core/src/utils/requestCache.ts b/packages/core/src/utils/requestCache.ts new file mode 100644 index 00000000..58fe1d1c --- /dev/null +++ b/packages/core/src/utils/requestCache.ts @@ -0,0 +1,28 @@ +/** + * A per-request cache that provides named Map instances. + * Overrides can use this to store and share data within the scope of a single request + * without polluting global state. + * + * Retrieve it from the ALS context: + * ```ts + * const store = globalThis.__openNextAls.getStore(); + * const myMap = store?.requestCache.getOrCreate("my-override"); + * ``` + */ +export class RequestCache { + private _caches = new Map>(); + + /** + * Returns the Map registered under `key`. + * If no Map exists yet for that key, a new empty Map is created, stored, and returned. + * Repeated calls with the same key always return the **same** Map instance. + */ + getOrCreate(key: string): Map { + let cache = this._caches.get(key) as Map | undefined; + if (!cache) { + cache = new Map(); + this._caches.set(key, cache); + } + return cache; + } +} diff --git a/packages/tests-unit/tests/adapters/cache-adapter.test.ts b/packages/tests-unit/tests/adapters/cache-adapter.test.ts index 1fd3ba53..d7bf68fa 100644 --- a/packages/tests-unit/tests/adapters/cache-adapter.test.ts +++ b/packages/tests-unit/tests/adapters/cache-adapter.test.ts @@ -22,6 +22,7 @@ const mockTagCache = vi.hoisted(() => ({ getByTag: vi.fn(), getByPath: vi.fn(), getLastModified: vi.fn(), + isStale: vi.fn(), writeTags: vi.fn(), hasBeenRevalidated: vi.fn(), getPathsByTags: undefined as Mock | undefined, @@ -381,6 +382,57 @@ describe("cache-adapter", () => { expect(mockTagCache.getLastModified).not.toHaveBeenCalled(); expect(mockTagCache.hasBeenRevalidated).not.toHaveBeenCalled(); }); + + it("should set lastModified to 1 when tags are stale", async () => { + mockTagCache.mode = "original"; + mockTagCache.getLastModified.mockResolvedValue(1000); + mockTagCache.isStale.mockResolvedValue(true); + mockIncrementalCache.get.mockResolvedValue({ + value: { + type: "route", + body: "data", + meta: { headers: { "x-next-cache-tags": "tag1" } }, + }, + lastModified: 1000, + }); + + const result = await runHandler(createEvent()); + + expect(result.statusCode).toBe(200); + expect(result.headers["x-opennext-cache-last-modified"]).toBe("1"); + }); + + it("should keep original lastModified when tags are not stale", async () => { + mockTagCache.mode = "original"; + mockTagCache.getLastModified.mockResolvedValue(1000); + mockTagCache.isStale.mockResolvedValue(false); + mockIncrementalCache.get.mockResolvedValue({ + value: { + type: "route", + body: "data", + meta: { headers: { "x-next-cache-tags": "tag1" } }, + }, + lastModified: 1000, + }); + + const result = await runHandler(createEvent()); + + expect(result.statusCode).toBe(200); + expect(result.headers["x-opennext-cache-last-modified"]).toBe("1000"); + }); + + it("should skip isStale when shouldBypassTagCache is true", async () => { + mockIncrementalCache.get.mockResolvedValue({ + value: { type: "route", body: "data" }, + lastModified: 1000, + shouldBypassTagCache: true, + }); + + const result = await runHandler(createEvent()); + + expect(result.statusCode).toBe(200); + expect(mockTagCache.isStale).not.toHaveBeenCalled(); + }); }); describe("PUT /cache/:key", () => { @@ -676,5 +728,67 @@ describe("cache-adapter", () => { expect(result.statusCode).toBe(500); }); + + it("should accept durations and pass stale/expire - nextMode", async () => { + mockTagCache.mode = "nextMode"; + vi.useFakeTimers().setSystemTime(100000); + const event = createEvent({ + rawPath: "/cache/revalidate-tags", + method: "POST", + body: Buffer.from(JSON.stringify({ tags: ["tag1"], durations: { expire: 30 } })), + }); + + await runHandler(event); + + expect(mockTagCache.writeTags).toHaveBeenCalledWith([ + { tag: "tag1", stale: 100000, expire: 100000 + 30 * 1000 }, + ]); + }); + + it("should accept durations and pass stale/expire - original mode", async () => { + mockTagCache.mode = "original"; + mockTagCache.getByTag.mockResolvedValue(["/path1"]); + vi.useFakeTimers().setSystemTime(100000); + const event = createEvent({ + rawPath: "/cache/revalidate-tags", + method: "POST", + body: Buffer.from(JSON.stringify({ tags: ["tag1"], durations: { expire: 30 } })), + }); + + await runHandler(event); + + expect(mockTagCache.writeTags).toHaveBeenCalledWith([ + { path: "/path1", tag: "tag1", stale: 100000, expire: 100000 + 30 * 1000 }, + ]); + }); + + it("should use immediate expiration when no durations provided - nextMode", async () => { + mockTagCache.mode = "nextMode"; + vi.useFakeTimers().setSystemTime(100000); + const event = createEvent({ + rawPath: "/cache/revalidate-tags", + method: "POST", + body: Buffer.from(JSON.stringify({ tags: ["tag1"] })), + }); + + await runHandler(event); + + expect(mockTagCache.writeTags).toHaveBeenCalledWith([{ tag: "tag1", expire: 100000 }]); + }); + + it("should use immediate expiration when no durations provided - original mode", async () => { + mockTagCache.mode = "original"; + mockTagCache.getByTag.mockResolvedValue(["/path1"]); + vi.useFakeTimers().setSystemTime(100000); + const event = createEvent({ + rawPath: "/cache/revalidate-tags", + method: "POST", + body: Buffer.from(JSON.stringify({ tags: ["tag1"] })), + }); + + await runHandler(event); + + expect(mockTagCache.writeTags).toHaveBeenCalledWith([{ path: "/path1", tag: "tag1", expire: 100000 }]); + }); }); }); diff --git a/packages/tests-unit/tests/adapters/cache.test.ts b/packages/tests-unit/tests/adapters/cache.test.ts index ef6f8e46..1d28ed49 100644 --- a/packages/tests-unit/tests/adapters/cache.test.ts +++ b/packages/tests-unit/tests/adapters/cache.test.ts @@ -535,13 +535,13 @@ describe("CacheHandler", () => { it("Should call cache.revalidateTags with single tag", async () => { await instance.revalidateTag("tag"); - expect(cache.revalidateTags).toHaveBeenCalledWith(["tag"]); + expect(cache.revalidateTags).toHaveBeenCalledWith(["tag"], undefined); }); it("Should call cache.revalidateTags with array of tags", async () => { await instance.revalidateTag(["tag1", "tag2"]); - expect(cache.revalidateTags).toHaveBeenCalledWith(["tag1", "tag2"]); + expect(cache.revalidateTags).toHaveBeenCalledWith(["tag1", "tag2"], undefined); }); it("Should not call cache.revalidateTags when tags array is empty", async () => { diff --git a/packages/tests-unit/tests/adapters/composable-cache.test.ts b/packages/tests-unit/tests/adapters/composable-cache.test.ts index e126bcde..884f75bb 100644 --- a/packages/tests-unit/tests/adapters/composable-cache.test.ts +++ b/packages/tests-unit/tests/adapters/composable-cache.test.ts @@ -85,6 +85,44 @@ describe("Composable cache handler", () => { expect(result).toBeUndefined(); }); + it("should set revalidate=-1 when lastModified is 1 (stale from cache adapter)", async () => { + cache.get.mockResolvedValueOnce({ + value: { + value: "stale-value", + tags: ["tag1"], + stale: 0, + timestamp: 1000, + expire: 2000, + revalidate: 3600, + }, + lastModified: 1, + }); + + const result = await ComposableCache.get("stale-key"); + + expect(result).toBeDefined(); + expect(result?.revalidate).toBe(-1); + }); + + it("should keep original revalidate when lastModified is not 1", async () => { + cache.get.mockResolvedValueOnce({ + value: { + value: "fresh-value", + tags: ["tag1"], + stale: 0, + timestamp: 1000, + expire: 2000, + revalidate: 3600, + }, + lastModified: 1000, + }); + + const result = await ComposableCache.get("fresh-key"); + + expect(result).toBeDefined(); + expect(result?.revalidate).toBe(3600); + }); + it("should return pending write promise if available", async () => { const pendingEntry = Promise.resolve({ value: toReadableStream("pending-value"), @@ -296,4 +334,30 @@ describe("Composable cache handler", () => { expect(content2).toBe("concurrent-2"); }); }); + + describe("updateTags", () => { + it("should call cache.revalidateTags with tags and durations", async () => { + await ComposableCache.updateTags(["tag1", "tag2"], { expire: 30 }); + + expect(cache.revalidateTags).toHaveBeenCalledWith(["tag1", "tag2"], { expire: 30 }); + }); + + it("should not call cache.revalidateTags when tags are empty", async () => { + await ComposableCache.updateTags([]); + + expect(cache.revalidateTags).not.toHaveBeenCalled(); + }); + + it("should call cache.revalidateTags without durations when not provided", async () => { + await ComposableCache.updateTags(["tag1"]); + + expect(cache.revalidateTags).toHaveBeenCalledWith(["tag1"], undefined); + }); + + it("should not throw on cache error", async () => { + cache.revalidateTags.mockRejectedValueOnce(new Error("cache error")); + + await expect(ComposableCache.updateTags(["tag1"])).resolves.not.toThrow(); + }); + }); }); diff --git a/packages/tests-unit/tests/core/routing/cacheInterceptor.test.ts b/packages/tests-unit/tests/core/routing/cacheInterceptor.test.ts index 0dcfde57..e995f5f3 100644 --- a/packages/tests-unit/tests/core/routing/cacheInterceptor.test.ts +++ b/packages/tests-unit/tests/core/routing/cacheInterceptor.test.ts @@ -473,4 +473,79 @@ describe("cacheInterceptor", () => { const result = await cacheInterceptor(event); expect(result.statusCode).toBe(200); }); + + describe("isStaleFromTagCache", () => { + it("should serve SSG app content with STALE when lastModified is 1", async () => { + const event = createEvent({ + url: "/albums", + }); + cache.get.mockResolvedValueOnce({ + value: { + type: "app", + html: "Hello, world!", + }, + lastModified: 1, + }); + + const result = await cacheInterceptor(event); + + expect(result).toEqual( + expect.objectContaining({ + type: "core", + headers: expect.objectContaining({ + "cache-control": "s-maxage=1, stale-while-revalidate=2592000", + "x-opennext-cache": "STALE", + }), + }) + ); + }); + + it("should serve SSG page content with STALE when lastModified is 1", async () => { + const event = createEvent({ + url: "/albums", + }); + cache.get.mockResolvedValueOnce({ + value: { + type: "page", + html: "Hello, world!", + }, + lastModified: 1, + }); + + const result = await cacheInterceptor(event); + + expect(result.type).toBe("core"); + expect((result as any).headers["cache-control"]).toBe("s-maxage=1, stale-while-revalidate=2592000"); + expect((result as any).headers["x-opennext-cache"]).toBe("STALE"); + }); + + it("should serve SSG route content with STALE when lastModified is 1", async () => { + const event = createEvent({ + url: "/albums", + }); + cache.get.mockResolvedValueOnce({ + value: { + type: "route", + body: "API response", + meta: { + status: 200, + headers: { "content-type": "text/plain" }, + }, + }, + lastModified: 1, + }); + + const result = await cacheInterceptor(event); + + expect(result).toEqual( + expect.objectContaining({ + type: "core", + headers: expect.objectContaining({ + "cache-control": "s-maxage=1, stale-while-revalidate=2592000", + "x-opennext-cache": "STALE", + }), + }) + ); + }); + }); }); From f4c250a1d29493366270a9f24a0b3d8e6db11469 Mon Sep 17 00:00:00 2001 From: Victor Berchet Date: Thu, 24 Sep 2026 09:20:50 +0200 Subject: [PATCH 20/42] fix(core): restore composable cache stale handling --- packages/core/src/adapters/composable-cache.ts | 1 + .../tests/adapters/composable-cache.test.ts | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/packages/core/src/adapters/composable-cache.ts b/packages/core/src/adapters/composable-cache.ts index dd9a568b..d715e044 100644 --- a/packages/core/src/adapters/composable-cache.ts +++ b/packages/core/src/adapters/composable-cache.ts @@ -26,6 +26,7 @@ export default { } debug("composable cache result", result); + const revalidate = result.lastModified === 1 ? -1 : result.value.revalidate; return { ...result.value, diff --git a/packages/tests-unit/tests/adapters/composable-cache.test.ts b/packages/tests-unit/tests/adapters/composable-cache.test.ts index c2ff6f17..74c8e9a7 100644 --- a/packages/tests-unit/tests/adapters/composable-cache.test.ts +++ b/packages/tests-unit/tests/adapters/composable-cache.test.ts @@ -58,6 +58,24 @@ describe("Composable cache handler", () => { expect(result?.value).toBeInstanceOf(ReadableStream); }); + it("should trigger revalidation for an entry marked stale by the cache handler", async () => { + cache.get.mockResolvedValueOnce({ + value: { + value: "stale-value", + tags: ["tag1"], + stale: 0, + timestamp: Date.now(), + expire: Date.now() + 1000, + revalidate: 3600, + }, + lastModified: 1, + }); + + const result = await ComposableCache.get("stale-key"); + + expect(result?.revalidate).toBe(-1); + }); + it("should return undefined when cache entry does not exist", async () => { cache.get.mockResolvedValueOnce(null); From e1b99d6d504012c1c305537f5489f0aca99518ed Mon Sep 17 00:00:00 2001 From: Victor Berchet Date: Thu, 24 Sep 2026 09:21:24 +0200 Subject: [PATCH 21/42] fix(core): restore stale tag cache reads --- packages/core/src/adapters/cache-handler.ts | 7 +++- .../tests/adapters/cache-adapter.test.ts | 37 +++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/packages/core/src/adapters/cache-handler.ts b/packages/core/src/adapters/cache-handler.ts index 19b0426d..8535f89e 100644 --- a/packages/core/src/adapters/cache-handler.ts +++ b/packages/core/src/adapters/cache-handler.ts @@ -13,7 +13,7 @@ import type { } from "@/types/overrides"; import { resolveCdnInvalidation, resolveIncrementalCache, resolveTagCache } from "../core/resolve.js"; -import { getTagsFromValue, writeTags } from "../utils/cache.js"; +import { getTagsFromValue, isStale, writeTags } from "../utils/cache.js"; import { runWithOpenNextRequestContext } from "../utils/promise.js"; import { fromReadableStream, toReadableStream } from "../utils/stream.js"; @@ -173,6 +173,11 @@ async function handleGet( }, }; } + + const lastModified = result.lastModified ?? Date.now(); + if (await isStale(key, tags, lastModified)) { + result.lastModified = 1; + } } return buildCacheGetResponse(result); diff --git a/packages/tests-unit/tests/adapters/cache-adapter.test.ts b/packages/tests-unit/tests/adapters/cache-adapter.test.ts index 12a13a8a..d6b3163e 100644 --- a/packages/tests-unit/tests/adapters/cache-adapter.test.ts +++ b/packages/tests-unit/tests/adapters/cache-adapter.test.ts @@ -22,6 +22,7 @@ const mockTagCache = vi.hoisted(() => ({ getByTag: vi.fn(), getByPath: vi.fn(), getLastModified: vi.fn(), + isStale: vi.fn(), writeTags: vi.fn(), hasBeenRevalidated: vi.fn(), getPathsByTags: undefined as Mock | undefined, @@ -360,6 +361,42 @@ describe("cache-handler", () => { expect(result.headers["x-opennext-cache-tag-status"]).toBe("revalidated"); }); + it("should mark stale entries in original mode", async () => { + mockTagCache.mode = "original"; + mockTagCache.getLastModified.mockResolvedValue(1000); + mockTagCache.isStale.mockResolvedValue(true); + mockIncrementalCache.get.mockResolvedValue({ + value: { type: "route", body: "data" }, + lastModified: 1000, + }); + + const result = await runHandler(createEvent()); + + expect(mockTagCache.isStale).toHaveBeenCalledWith("test-key", 1000); + expect(result.statusCode).toBe(200); + expect(result.headers["x-opennext-cache-last-modified"]).toBe("1"); + }); + + it("should mark stale entries in nextMode", async () => { + mockTagCache.mode = "nextMode"; + mockTagCache.hasBeenRevalidated.mockResolvedValue(false); + mockTagCache.isStale.mockResolvedValue(true); + mockIncrementalCache.get.mockResolvedValue({ + value: { + type: "route", + body: "data", + meta: { headers: { "x-next-cache-tags": "tag1" } }, + }, + lastModified: 1000, + }); + + const result = await runHandler(createEvent()); + + expect(mockTagCache.isStale).toHaveBeenCalledWith(["tag1"], 1000); + expect(result.statusCode).toBe(200); + expect(result.headers["x-opennext-cache-last-modified"]).toBe("1"); + }); + it("should return 404 when a fetch entry's owning path has been revalidated", async () => { mockTagCache.mode = "original"; mockTagCache.getLastModified.mockResolvedValueOnce(1000).mockResolvedValueOnce(-1); From 621c25b284c0d0fa09f7910e530f4dda025eea1d Mon Sep 17 00:00:00 2001 From: Victor Berchet Date: Thu, 24 Sep 2026 09:23:51 +0200 Subject: [PATCH 22/42] fix(core): propagate tag revalidation durations --- .../api/overrides/cache/service-cache.spec.ts | 7 +++ .../src/api/overrides/cache/service-cache.ts | 4 +- packages/core/src/adapters/cache-handler.ts | 58 ++++++++++++++----- packages/core/src/adapters/cache.ts | 15 ++++- .../core/src/adapters/composable-cache.ts | 18 ++++++ packages/core/src/overrides/cache/fetch.ts | 4 +- packages/core/src/overrides/cache/local.ts | 4 +- packages/core/src/types/overrides.ts | 2 +- packages/core/src/utils/cache.ts | 2 +- .../tests/adapters/cache-adapter.test.ts | 35 +++++++++++ .../tests/overrides/cache/fetch.test.ts | 11 ++++ .../tests/overrides/cache/local.test.ts | 11 ++++ 12 files changed, 145 insertions(+), 26 deletions(-) diff --git a/packages/cloudflare/src/api/overrides/cache/service-cache.spec.ts b/packages/cloudflare/src/api/overrides/cache/service-cache.spec.ts index 0964e2f8..e67f433c 100644 --- a/packages/cloudflare/src/api/overrides/cache/service-cache.spec.ts +++ b/packages/cloudflare/src/api/overrides/cache/service-cache.spec.ts @@ -120,6 +120,13 @@ describe("serviceCache", () => { expect(JSON.parse(body as string)).toEqual({ tags: ["tag1", "tag2"] }); }); + it("forwards revalidation durations", async () => { + await serviceCache.revalidateTags(["tag1"], { expire: 30 }); + + const { body } = lastRequest(); + expect(JSON.parse(body as string)).toEqual({ tags: ["tag1"], durations: { expire: 30 } }); + }); + it("rejects an unsuccessful tag revalidation", async () => { fetchMock.mockResolvedValue(new Response(null, { status: 502 })); diff --git a/packages/cloudflare/src/api/overrides/cache/service-cache.ts b/packages/cloudflare/src/api/overrides/cache/service-cache.ts index 8b1a840d..85a41018 100644 --- a/packages/cloudflare/src/api/overrides/cache/service-cache.ts +++ b/packages/cloudflare/src/api/overrides/cache/service-cache.ts @@ -98,11 +98,11 @@ const serviceCache = { ensureResponseOk(response, "delete cache entry"); }, - revalidateTags: async (tags) => { + revalidateTags: async (tags, durations) => { const response = await getCacheService().fetch(new URL("/cache/revalidate-tags", CACHE_ORIGIN).href, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ tags }), + body: JSON.stringify({ tags, durations }), }); ensureResponseOk(response, "revalidate cache tags"); }, diff --git a/packages/core/src/adapters/cache-handler.ts b/packages/core/src/adapters/cache-handler.ts index 8535f89e..cdf84053 100644 --- a/packages/core/src/adapters/cache-handler.ts +++ b/packages/core/src/adapters/cache-handler.ts @@ -316,28 +316,39 @@ async function handleRevalidateTags(body?: ReadableStream): Promise< return buildErrorResponse("Missing request body", 400); } - let tags: string[]; + let parsed: { tags?: unknown; durations?: { expire?: number } }; try { - 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 - : []; + parsed = JSON.parse(bodyText) as { tags?: unknown; durations?: { expire?: number } }; } catch { return buildErrorResponse("Invalid JSON body", 400); } + const tags = + Array.isArray(parsed.tags) && parsed.tags.every((tag: unknown): tag is string => typeof tag === "string") + ? parsed.tags + : []; + if (tags.length === 0) { return buildErrorResponse("Missing 'tags' array in request body", 400); } try { await runWithOpenNextRequestContext({ isISRRevalidation: false }, async () => { + const now = Date.now(); + const { durations } = parsed; if (globalThis.tagCache.mode === "nextMode") { const paths = (await globalThis.tagCache.getPathsByTags?.(tags)) ?? []; + const tagsToWrite = tags.map((tag) => + durations + ? { + tag, + stale: now, + expire: durations.expire === undefined ? undefined : now + durations.expire * 1000, + } + : { tag, expire: now } + ); - await writeTags(tags); + await writeTags(tagsToWrite); if (paths.length > 0) { await globalThis.cdnInvalidationHandler.invalidatePaths( paths.map((path) => ({ @@ -360,10 +371,16 @@ async function handleRevalidateTags(body?: ReadableStream): Promise< debug("revalidateTag", tag); const paths = await globalThis.tagCache.getByTag(tag); debug("Items", paths); - const toInsert = paths.map((path) => ({ - path, - tag, - })); + const toInsert = paths.map((path) => + durations + ? { + path, + tag, + stale: now, + expire: durations.expire === undefined ? undefined : now + durations.expire * 1000, + } + : { path, tag, expire: now } + ); if (tag.startsWith(SOFT_TAG_PREFIX)) { for (const path of paths) { @@ -373,10 +390,19 @@ async function handleRevalidateTags(body?: ReadableStream): Promise< const _paths = await globalThis.tagCache.getByTag(hardTag); debug({ hardTag, _paths }); toInsert.push( - ..._paths.map((path) => ({ - path, - tag: hardTag, - })) + ..._paths.map((path) => + durations + ? { + path, + tag: hardTag, + stale: now, + expire: + durations.expire === undefined + ? undefined + : now + durations.expire * 1000, + } + : { path, tag: hardTag, expire: now } + ) ); } } diff --git a/packages/core/src/adapters/cache.ts b/packages/core/src/adapters/cache.ts index f98dc42c..e333d39f 100644 --- a/packages/core/src/adapters/cache.ts +++ b/packages/core/src/adapters/cache.ts @@ -256,7 +256,14 @@ export default class Cache { } } - public async revalidateTag(tags: string | string[], durations?: { expire?: number }) { + /** + * Revalidates one or more cache tags. + * + * @param tags Tags to revalidate. + * @param durations Optional stale-while-revalidate durations. + * @return A promise that resolves when revalidation has been requested. + */ + public async revalidateTag(tags: string | string[], durations?: { expire?: number }): Promise { const config = globalThis.openNextConfig.dangerous; if (config?.disableTagCache || config?.disableIncrementalCache) { return; @@ -267,7 +274,11 @@ export default class Cache { } try { - await globalThis.cache.revalidateTags(_tags); + if (durations) { + await globalThis.cache.revalidateTags(_tags, durations); + } else { + await globalThis.cache.revalidateTags(_tags); + } } catch (e) { error("Failed to revalidate tag", e); } diff --git a/packages/core/src/adapters/composable-cache.ts b/packages/core/src/adapters/composable-cache.ts index d715e044..56e28df4 100644 --- a/packages/core/src/adapters/composable-cache.ts +++ b/packages/core/src/adapters/composable-cache.ts @@ -85,6 +85,24 @@ export default { } }, + /** + * Updates tags with optional stale-while-revalidate durations. + * + * @param tags Tags to update. + * @param durations Optional stale-while-revalidate durations. + * @return A promise that resolves when the update has been requested. + */ + async updateTags(tags: string[], durations?: { expire?: number }): Promise { + if (tags.length === 0) { + return; + } + try { + await globalThis.cache.revalidateTags(tags, durations); + } catch (e) { + debug("Failed to update tags", e); + } + }, + // This one is necessary for older versions of next async receiveExpiredTags(...tags: string[]) { // This function does absolutely nothing diff --git a/packages/core/src/overrides/cache/fetch.ts b/packages/core/src/overrides/cache/fetch.ts index 352d97ee..d3c7e515 100644 --- a/packages/core/src/overrides/cache/fetch.ts +++ b/packages/core/src/overrides/cache/fetch.ts @@ -53,11 +53,11 @@ const fetchCache: Cache = { const response = await fetch(url, { method: "DELETE" }); ensureResponseOk(response, "delete cache entry"); }, - revalidateTags: async (tags) => { + revalidateTags: async (tags, durations) => { const response = await fetch(`${CACHE_URL}/cache/revalidate-tags`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ tags }), + body: JSON.stringify({ tags, durations }), }); ensureResponseOk(response, "revalidate cache tags"); }, diff --git a/packages/core/src/overrides/cache/local.ts b/packages/core/src/overrides/cache/local.ts index c906ab68..c779c870 100644 --- a/packages/core/src/overrides/cache/local.ts +++ b/packages/core/src/overrides/cache/local.ts @@ -100,7 +100,7 @@ const localCache: Cache = { const result = await h(event); ensureResultOk(result, "delete cache entry"); }, - revalidateTags: async (tags) => { + revalidateTags: async (tags, durations) => { const h = (await getHandler())!; const url = `https://on/cache/revalidate-tags`; const event: InternalEvent = { @@ -112,7 +112,7 @@ const localCache: Cache = { query: {}, cookies: {}, remoteAddress: "127.0.0.1", - body: toReadableStream(JSON.stringify({ tags })), + body: toReadableStream(JSON.stringify({ tags, durations })), }; const result = await h(event); ensureResultOk(result, "revalidate cache tags"); diff --git a/packages/core/src/types/overrides.ts b/packages/core/src/types/overrides.ts index 7f3c6aa7..beadefae 100644 --- a/packages/core/src/types/overrides.ts +++ b/packages/core/src/types/overrides.ts @@ -305,7 +305,7 @@ export type Cache = BaseOverride & { additionalTags?: string[] ): Promise; delete(key: string): Promise; - revalidateTags(tags: string[]): Promise; + revalidateTags(tags: string[], durations?: { expire?: number }): Promise; }; type CDNPath = { diff --git a/packages/core/src/utils/cache.ts b/packages/core/src/utils/cache.ts index 4fc4ef93..6af52bf3 100644 --- a/packages/core/src/utils/cache.ts +++ b/packages/core/src/utils/cache.ts @@ -81,7 +81,7 @@ function getTagKey(tag: WriteTagInput): string { } export async function writeTags( - tags: (string | OriginalTagCacheWriteInput)[], + tags: WriteTagInput[], tagCache: TagCache = globalThis.tagCache ): Promise { const store = globalThis.__openNextAls.getStore(); diff --git a/packages/tests-unit/tests/adapters/cache-adapter.test.ts b/packages/tests-unit/tests/adapters/cache-adapter.test.ts index d6b3163e..a16b731a 100644 --- a/packages/tests-unit/tests/adapters/cache-adapter.test.ts +++ b/packages/tests-unit/tests/adapters/cache-adapter.test.ts @@ -779,5 +779,40 @@ describe("cache-handler", () => { expect(result.statusCode).toBe(500); }); + + it("should convert nextMode durations to stale and expiry timestamps", async () => { + mockTagCache.mode = "nextMode"; + vi.useFakeTimers().setSystemTime(100_000); + const event = createEvent({ + rawPath: "/cache/revalidate-tags", + method: "POST", + body: toReadableStream(JSON.stringify({ tags: ["tag1"], durations: { expire: 30 } })), + }); + + await runHandler(event); + + expect(mockTagCache.writeTags).toHaveBeenCalledWith([ + { tag: "tag1", stale: 100_000, expire: 130_000 }, + ]); + vi.useRealTimers(); + }); + + it("should convert original-mode durations to stale and expiry timestamps", async () => { + mockTagCache.mode = "original"; + mockTagCache.getByTag.mockResolvedValue(["/path1"]); + vi.useFakeTimers().setSystemTime(100_000); + const event = createEvent({ + rawPath: "/cache/revalidate-tags", + method: "POST", + body: toReadableStream(JSON.stringify({ tags: ["tag1"], durations: { expire: 30 } })), + }); + + await runHandler(event); + + expect(mockTagCache.writeTags).toHaveBeenCalledWith([ + { path: "/path1", tag: "tag1", stale: 100_000, expire: 130_000 }, + ]); + vi.useRealTimers(); + }); }); }); diff --git a/packages/tests-unit/tests/overrides/cache/fetch.test.ts b/packages/tests-unit/tests/overrides/cache/fetch.test.ts index d03d3eaa..dabfebb5 100644 --- a/packages/tests-unit/tests/overrides/cache/fetch.test.ts +++ b/packages/tests-unit/tests/overrides/cache/fetch.test.ts @@ -220,6 +220,17 @@ describe("fetch cache", () => { }); }); + it("should include revalidation durations", async () => { + await fetchCache.revalidateTags(["tag1"], { expire: 30 }); + + expect(global.fetch).toHaveBeenCalledWith( + "/cache/revalidate-tags", + expect.objectContaining({ + body: JSON.stringify({ tags: ["tag1"], durations: { expire: 30 } }), + }) + ); + }); + it("should reject an unsuccessful response", async () => { mockFetch({ headers: {}, body: "", status: 500 }); diff --git a/packages/tests-unit/tests/overrides/cache/local.test.ts b/packages/tests-unit/tests/overrides/cache/local.test.ts index db8d559b..5debe1a4 100644 --- a/packages/tests-unit/tests/overrides/cache/local.test.ts +++ b/packages/tests-unit/tests/overrides/cache/local.test.ts @@ -237,6 +237,17 @@ describe("local cache", () => { expect(await fromReadableStream(event.body)).toBe(JSON.stringify({ tags: ["tag1", "tag2"] })); }); + it("should include revalidation durations", async () => { + mockHandler.mockResolvedValue(createMockResult()); + + await localCache.revalidateTags(["tag1"], { expire: 30 }); + + const event = mockHandler.mock.calls[0][0]; + expect(await fromReadableStream(event.body)).toBe( + JSON.stringify({ tags: ["tag1"], durations: { expire: 30 } }) + ); + }); + it("should reject an unsuccessful response", async () => { mockHandler.mockResolvedValue(createMockResult({ statusCode: 500 })); From 9c738196e550284aa296c6b819f99c965ad961e7 Mon Sep 17 00:00:00 2001 From: Victor Berchet Date: Thu, 24 Sep 2026 09:27:01 +0200 Subject: [PATCH 23/42] fix(aws): preserve DynamoDB SWR entries before expiry --- .../aws/src/overrides/tagCache/dynamodb.ts | 45 ++++++++++--------- .../overrides/tag-cache/dynamodb.test.ts | 24 ++++++++++ 2 files changed, 49 insertions(+), 20 deletions(-) create mode 100644 packages/tests-unit/tests/overrides/tag-cache/dynamodb.test.ts diff --git a/packages/aws/src/overrides/tagCache/dynamodb.ts b/packages/aws/src/overrides/tagCache/dynamodb.ts index 2203dbc0..562cff86 100644 --- a/packages/aws/src/overrides/tagCache/dynamodb.ts +++ b/packages/aws/src/overrides/tagCache/dynamodb.ts @@ -18,6 +18,28 @@ type DynamoDBItem = { expire?: { N: string }; }; +/** + * Determines whether tag records require blocking cache regeneration. + * + * @param items Tag records newer than the cached entry. + * @param lastModified Cached entry timestamp. + * @param now Current timestamp. + * @return Whether a tag was hard-revalidated or its SWR window expired. + */ +export function hasHardRevalidation(items: DynamoDBItem[], lastModified: number, now: number): boolean { + return items.some((item) => { + const revalidatedAt = Number.parseInt(item.revalidatedAt?.N ?? "0"); + if (revalidatedAt <= lastModified) { + return false; + } + if (!item.expire?.N) { + return true; + } + const expiry = Number.parseInt(item.expire.N); + return expiry <= now && expiry > lastModified; + }); +} + function parseDynamoClientConfigFromEnv(): DynamoDBClientConfig { return { region: CACHE_BUCKET_REGION, @@ -151,26 +173,9 @@ const tagCache: TagCache = { } debug("revalidatedTags", revalidatedTags); - // Check if any tag has expired - const now = Date.now(); - const hasExpiredTag = revalidatedTags.some((item) => { - if (item.expire?.N) { - const expiry = Number.parseInt(item.expire.N); - return expiry <= now && expiry > (lastModified ?? 0); - } - return false; - }); - // Exclude expired tags from the revalidated count — they are handled - // separately via hasExpiredTag above. - const nonExpiredRevalidatedTags = revalidatedTags.filter((item) => { - if (item.expire?.N) { - return Number.parseInt(item.expire.N) > now; - } - return true; - }); - - // If we have revalidated tags or expired tags we return -1 to force revalidation - return nonExpiredRevalidatedTags.length > 0 || hasExpiredTag ? -1 : (lastModified ?? Date.now()); + return hasHardRevalidation(revalidatedTags, lastModified ?? 0, Date.now()) + ? -1 + : (lastModified ?? Date.now()); } catch (e) { error("Failed to get revalidated tags", e); return lastModified ?? Date.now(); diff --git a/packages/tests-unit/tests/overrides/tag-cache/dynamodb.test.ts b/packages/tests-unit/tests/overrides/tag-cache/dynamodb.test.ts new file mode 100644 index 00000000..5db330bd --- /dev/null +++ b/packages/tests-unit/tests/overrides/tag-cache/dynamodb.test.ts @@ -0,0 +1,24 @@ +import { hasHardRevalidation } from "../../../../aws/src/overrides/tagCache/dynamodb.js"; +import { describe, expect, it } from "vitest"; + +describe("DynamoDB tag cache", () => { + it("does not hard-invalidate a tag before its SWR expiry", () => { + const result = hasHardRevalidation( + [{ revalidatedAt: { N: "1500" }, stale: { N: "1500" }, expire: { N: "3000" } }], + 1_000, + 2_000 + ); + + expect(result).toBe(false); + }); + + it("hard-invalidates a tag after its SWR expiry", () => { + const result = hasHardRevalidation( + [{ revalidatedAt: { N: "1500" }, stale: { N: "1500" }, expire: { N: "1800" } }], + 1_000, + 2_000 + ); + + expect(result).toBe(true); + }); +}); From f4c03c27d8b767b51e52533712b70a27520becdd Mon Sep 17 00:00:00 2001 From: Victor Berchet Date: Thu, 24 Sep 2026 09:27:38 +0200 Subject: [PATCH 24/42] fix(cache): preserve next-mode SWR entries before expiry --- .../overrides/tagCache/dynamodb-nextMode.ts | 22 +++++++--- .../src/overrides/tagCache/fs-dev-nextMode.ts | 8 +++- .../overrides/tag-cache/next-mode.test.ts | 43 +++++++++++++++++++ 3 files changed, 67 insertions(+), 6 deletions(-) create mode 100644 packages/tests-unit/tests/overrides/tag-cache/next-mode.test.ts diff --git a/packages/aws/src/overrides/tagCache/dynamodb-nextMode.ts b/packages/aws/src/overrides/tagCache/dynamodb-nextMode.ts index 2b72d5c8..af6e029b 100644 --- a/packages/aws/src/overrides/tagCache/dynamodb-nextMode.ts +++ b/packages/aws/src/overrides/tagCache/dynamodb-nextMode.ts @@ -20,6 +20,22 @@ type DynamoDBItem = { expire?: { N: string }; }; +/** + * Determines whether a next-mode tag requires blocking regeneration. + * + * @param item Stored tag record. + * @param lastModified Cached entry timestamp. + * @param now Current timestamp. + * @return Whether the tag was hard-revalidated or its SWR window expired. + */ +export function hasHardRevalidation(item: DynamoDBItem, lastModified: number, now: number): boolean { + if (item.expire?.N) { + const expiry = Number.parseInt(item.expire.N); + return expiry <= now && expiry > lastModified; + } + return Number.parseInt(item.revalidatedAt?.N ?? "0") > lastModified; +} + const getAwsClient = () => { const { CACHE_BUCKET_REGION } = process.env; if (awsClient) { @@ -158,11 +174,7 @@ export default { const now = Date.now(); const compute = (item: DynamoDBItem): boolean => { if (!item) return false; - if (item.expire?.N) { - const expiry = Number.parseInt(item.expire.N); - if (expiry <= now && expiry > (lastModified ?? 0)) return true; - } - return Number.parseInt(item.revalidatedAt?.N ?? "0") > (lastModified ?? 0); + return hasHardRevalidation(item, lastModified ?? 0, now); }; const { uncachedTags, hasMatch } = checkItemsCache(tags, itemsCache, compute); diff --git a/packages/core/src/overrides/tagCache/fs-dev-nextMode.ts b/packages/core/src/overrides/tagCache/fs-dev-nextMode.ts index 08b6ddb9..9a433505 100644 --- a/packages/core/src/overrides/tagCache/fs-dev-nextMode.ts +++ b/packages/core/src/overrides/tagCache/fs-dev-nextMode.ts @@ -37,7 +37,13 @@ export default { const hasRevalidatedTag = tags.some((tag) => { const tagData = tagsMap.get(tag); - return tagData ? tagData.revalidatedAt > (lastModified ?? 0) : false; + if (!tagData) { + return false; + } + if (tagData.expire !== undefined) { + return tagData.expire <= Date.now() && tagData.expire > (lastModified ?? 0); + } + return tagData.revalidatedAt > (lastModified ?? 0); }); debug("hasBeenRevalidated result:", hasRevalidatedTag); diff --git a/packages/tests-unit/tests/overrides/tag-cache/next-mode.test.ts b/packages/tests-unit/tests/overrides/tag-cache/next-mode.test.ts new file mode 100644 index 00000000..886b149d --- /dev/null +++ b/packages/tests-unit/tests/overrides/tag-cache/next-mode.test.ts @@ -0,0 +1,43 @@ +import { hasHardRevalidation } from "../../../../aws/src/overrides/tagCache/dynamodb-nextMode.js"; +import fsDevTagCache from "@opennextjs/core/overrides/tagCache/fs-dev-nextMode"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +describe("next-mode SWR tag revalidation", () => { + beforeEach(() => { + vi.useFakeTimers().setSystemTime(2_000); + globalThis.openNextConfig = { dangerous: {} }; + }); + + it("does not hard-invalidate DynamoDB records before their expiry", () => { + expect( + hasHardRevalidation( + { revalidatedAt: { N: "1500" }, stale: { N: "1500" }, expire: { N: "3000" } }, + 1_000, + 2_000 + ) + ).toBe(false); + }); + + it("hard-invalidates DynamoDB records after their expiry", () => { + expect( + hasHardRevalidation( + { revalidatedAt: { N: "1500" }, stale: { N: "1500" }, expire: { N: "1800" } }, + 1_000, + 2_000 + ) + ).toBe(true); + }); + + it("does not hard-invalidate filesystem records before their expiry", async () => { + await fsDevTagCache.writeTags([{ tag: "future-swr", stale: 2_000, expire: 3_000 }]); + + expect(await fsDevTagCache.hasBeenRevalidated(["future-swr"], 1_000)).toBe(false); + expect(await fsDevTagCache.isStale?.(["future-swr"], 1_000)).toBe(true); + }); + + it("hard-invalidates filesystem records after their expiry", async () => { + await fsDevTagCache.writeTags([{ tag: "expired-swr", stale: 1_500, expire: 1_800 }]); + + expect(await fsDevTagCache.hasBeenRevalidated(["expired-swr"], 1_000)).toBe(true); + }); +}); From 7570c319c518b16a0a22ef892af17ab8d38a4e93 Mon Sep 17 00:00:00 2001 From: Victor Berchet Date: Thu, 24 Sep 2026 10:25:03 +0200 Subject: [PATCH 25/42] fix(core): preserve filesystem SWR entries before expiry --- .../core/src/overrides/tagCache/fs-dev.ts | 6 +++- .../tests/overrides/tag-cache/fs-dev.test.ts | 31 +++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) create mode 100644 packages/tests-unit/tests/overrides/tag-cache/fs-dev.test.ts diff --git a/packages/core/src/overrides/tagCache/fs-dev.ts b/packages/core/src/overrides/tagCache/fs-dev.ts index 1313b620..dcab1f28 100644 --- a/packages/core/src/overrides/tagCache/fs-dev.ts +++ b/packages/core/src/overrides/tagCache/fs-dev.ts @@ -37,10 +37,14 @@ const tagCache: TagCache = { .map((tagEntry) => tagEntry.path.S.replace(`${NEXT_BUILD_ID}/`, "")); }, getLastModified: async (path: string, lastModified?: number) => { + const now = Date.now(); const revalidatedTags = tags.filter( (tagPathMapping) => tagPathMapping.path.S === buildKey(path) && - Number.parseInt(tagPathMapping.revalidatedAt.N) > (lastModified ?? 0) + Number.parseInt(tagPathMapping.revalidatedAt.N) > (lastModified ?? 0) && + (tagPathMapping.expire?.N === undefined || + (Number.parseInt(tagPathMapping.expire.N) <= now && + Number.parseInt(tagPathMapping.expire.N) > (lastModified ?? 0))) ); return revalidatedTags.length > 0 ? -1 : (lastModified ?? Date.now()); }, diff --git a/packages/tests-unit/tests/overrides/tag-cache/fs-dev.test.ts b/packages/tests-unit/tests/overrides/tag-cache/fs-dev.test.ts new file mode 100644 index 00000000..3a5b856e --- /dev/null +++ b/packages/tests-unit/tests/overrides/tag-cache/fs-dev.test.ts @@ -0,0 +1,31 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("node:fs", () => ({ + default: { readFileSync: () => "[]" }, +})); + +globalThis.monorepoPackagePath = ""; +const fsDevTagCache = (await import("@opennextjs/core/overrides/tagCache/fs-dev")).default; + +describe("filesystem tag cache SWR revalidation", () => { + beforeEach(() => { + vi.useFakeTimers().setSystemTime(2_000); + }); + + it("serves a stale entry before its expiry", async () => { + await fsDevTagCache.writeTags([ + { path: "future-page", tag: "future-tag", stale: 2_000, expire: 3_000 }, + ]); + + expect(await fsDevTagCache.getLastModified("future-page", 1_000)).toBe(1_000); + expect(await fsDevTagCache.isStale?.("future-page", 1_000)).toBe(true); + }); + + it("hard-invalidates an entry after its expiry", async () => { + await fsDevTagCache.writeTags([ + { path: "expired-page", tag: "expired-tag", stale: 1_500, expire: 1_800 }, + ]); + + expect(await fsDevTagCache.getLastModified("expired-page", 1_000)).toBe(-1); + }); +}); From 08b80e10611129a181636f4005c6eea9d68969a7 Mon Sep 17 00:00:00 2001 From: Victor Berchet Date: Thu, 24 Sep 2026 10:26:40 +0200 Subject: [PATCH 26/42] perf(aws): cache missing next-mode tags per request --- .../overrides/tagCache/dynamodb-nextMode.ts | 61 +++++++++++++------ .../overrides/tag-cache/next-mode.test.ts | 16 ++++- 2 files changed, 56 insertions(+), 21 deletions(-) diff --git a/packages/aws/src/overrides/tagCache/dynamodb-nextMode.ts b/packages/aws/src/overrides/tagCache/dynamodb-nextMode.ts index af6e029b..571160c8 100644 --- a/packages/aws/src/overrides/tagCache/dynamodb-nextMode.ts +++ b/packages/aws/src/overrides/tagCache/dynamodb-nextMode.ts @@ -12,7 +12,7 @@ import { MAX_DYNAMO_BATCH_WRITE_ITEM_COUNT, getDynamoBatchWriteCommandConcurrenc let awsClient: AwsClient | null = null; -type DynamoDBItem = { +export type DynamoDBItem = { tag?: { S: string }; path?: { S: string }; revalidatedAt?: { N: string }; @@ -91,14 +91,15 @@ function buildDynamoObject(tag: string, revalidatedAt?: number, stale?: number, */ function checkItemsCache( tags: string[], - itemsCache: Map | undefined, + itemsCache: Map | undefined, compute: (item: DynamoDBItem) => boolean ): { uncachedTags: string[]; hasMatch: boolean } { const uncachedTags: string[] = []; let hasMatch = false; for (const tag of tags) { if (itemsCache?.has(tag)) { - if (compute(itemsCache.get(tag)!)) hasMatch = true; + const item = itemsCache.get(tag); + if (item && compute(item)) hasMatch = true; } else { uncachedTags.push(tag); } @@ -106,13 +107,42 @@ function checkItemsCache( return { uncachedTags, hasMatch }; } +/** + * Stores fetched DynamoDB tag records, including absent tags, in the request cache. + * + * @param tags Requested tag names. + * @param responseItems Records returned by DynamoDB. + * @param itemsCache Per-request tag cache. + * @param compute Predicate used to identify a matching record. + * @return Whether any returned record matched the predicate. + */ +export function cacheDynamoItems( + tags: string[], + responseItems: DynamoDBItem[], + itemsCache: Map | undefined, + compute: (item: DynamoDBItem) => boolean +): boolean { + const responseByKey = new Map(); + for (const item of responseItems) { + responseByKey.set(item.tag?.S ?? "", item); + } + + let hasMatch = false; + for (const tag of tags) { + const item = responseByKey.get(buildDynamoKey(tag)) ?? null; + itemsCache?.set(tag, item); + if (item && compute(item)) hasMatch = true; + } + return hasMatch; +} + /** * Fetches uncached tags from DynamoDB via BatchGetItem, populates the items * cache (storing null for absent tags), and returns whether any tag matched. */ async function fetchAndCacheItems( uncachedTags: string[], - itemsCache: Map | undefined, + itemsCache: Map | undefined, compute: (item: DynamoDBItem) => boolean ): Promise { const { CACHE_DYNAMO_TABLE } = process.env; @@ -135,20 +165,7 @@ async function fetchAndCacheItems( const { Responses } = await response.json(); const responseItems: DynamoDBItem[] = Responses?.[CACHE_DYNAMO_TABLE ?? ""] ?? []; - // Build a lookup map: DynamoDB key → item - const responseByKey = new Map(); - for (const item of responseItems) { - responseByKey.set(item.tag?.S ?? "", item); - } - - let hasMatch = false; - for (const tag of uncachedTags) { - const item = responseByKey.get(buildDynamoKey(tag)) ?? null; - if (!item) continue; - itemsCache?.set(tag, item); - if (compute(item)) hasMatch = true; - } - return hasMatch; + return cacheDynamoItems(uncachedTags, responseItems, itemsCache, compute); } export default { @@ -169,7 +186,9 @@ export default { } const store = globalThis.__openNextAls.getStore(); - const itemsCache = store?.requestCache.getOrCreate("ddb-nextMode:tagItems"); + const itemsCache = store?.requestCache.getOrCreate( + "ddb-nextMode:tagItems" + ); const now = Date.now(); const compute = (item: DynamoDBItem): boolean => { @@ -199,7 +218,9 @@ export default { } const store = globalThis.__openNextAls.getStore(); - const itemsCache = store?.requestCache.getOrCreate("ddb-nextMode:tagItems"); + const itemsCache = store?.requestCache.getOrCreate( + "ddb-nextMode:tagItems" + ); const compute = (item: DynamoDBItem): boolean => { if (!item?.stale?.N) return false; diff --git a/packages/tests-unit/tests/overrides/tag-cache/next-mode.test.ts b/packages/tests-unit/tests/overrides/tag-cache/next-mode.test.ts index 886b149d..fa64e6e3 100644 --- a/packages/tests-unit/tests/overrides/tag-cache/next-mode.test.ts +++ b/packages/tests-unit/tests/overrides/tag-cache/next-mode.test.ts @@ -1,4 +1,8 @@ -import { hasHardRevalidation } from "../../../../aws/src/overrides/tagCache/dynamodb-nextMode.js"; +import { + cacheDynamoItems, + hasHardRevalidation, +} from "../../../../aws/src/overrides/tagCache/dynamodb-nextMode.js"; +import type { DynamoDBItem } from "../../../../aws/src/overrides/tagCache/dynamodb-nextMode.js"; import fsDevTagCache from "@opennextjs/core/overrides/tagCache/fs-dev-nextMode"; import { beforeEach, describe, expect, it, vi } from "vitest"; @@ -28,6 +32,16 @@ describe("next-mode SWR tag revalidation", () => { ).toBe(true); }); + it("caches missing DynamoDB tags for the duration of the request", () => { + const itemsCache = new Map(); + + const hasMatch = cacheDynamoItems(["missing-tag"], [], itemsCache, () => true); + + expect(hasMatch).toBe(false); + expect(itemsCache.has("missing-tag")).toBe(true); + expect(itemsCache.get("missing-tag")).toBeNull(); + }); + it("does not hard-invalidate filesystem records before their expiry", async () => { await fsDevTagCache.writeTags([{ tag: "future-swr", stale: 2_000, expire: 3_000 }]); From 3becbc016d79e284817b33e61bbfe546c6d51de9 Mon Sep 17 00:00:00 2001 From: Victor Berchet Date: Thu, 24 Sep 2026 10:29:44 +0200 Subject: [PATCH 27/42] fix(cache): support SWR tags without expiry --- .../aws/src/overrides/tagCache/dynamodb-nextMode.ts | 3 +++ packages/aws/src/overrides/tagCache/dynamodb.ts | 3 +++ .../core/src/overrides/tagCache/fs-dev-nextMode.ts | 3 +++ packages/core/src/overrides/tagCache/fs-dev.ts | 5 +++-- .../tests/overrides/tag-cache/dynamodb.test.ts | 10 ++++++++++ .../tests/overrides/tag-cache/fs-dev.test.ts | 9 +++++++++ .../tests/overrides/tag-cache/next-mode.test.ts | 13 +++++++++++++ 7 files changed, 44 insertions(+), 2 deletions(-) diff --git a/packages/aws/src/overrides/tagCache/dynamodb-nextMode.ts b/packages/aws/src/overrides/tagCache/dynamodb-nextMode.ts index 571160c8..66b2a4c2 100644 --- a/packages/aws/src/overrides/tagCache/dynamodb-nextMode.ts +++ b/packages/aws/src/overrides/tagCache/dynamodb-nextMode.ts @@ -33,6 +33,9 @@ export function hasHardRevalidation(item: DynamoDBItem, lastModified: number, no const expiry = Number.parseInt(item.expire.N); return expiry <= now && expiry > lastModified; } + if (item.stale?.N) { + return false; + } return Number.parseInt(item.revalidatedAt?.N ?? "0") > lastModified; } diff --git a/packages/aws/src/overrides/tagCache/dynamodb.ts b/packages/aws/src/overrides/tagCache/dynamodb.ts index 562cff86..ef9ec0a3 100644 --- a/packages/aws/src/overrides/tagCache/dynamodb.ts +++ b/packages/aws/src/overrides/tagCache/dynamodb.ts @@ -32,6 +32,9 @@ export function hasHardRevalidation(items: DynamoDBItem[], lastModified: number, if (revalidatedAt <= lastModified) { return false; } + if (item.stale?.N && !item.expire?.N) { + return false; + } if (!item.expire?.N) { return true; } diff --git a/packages/core/src/overrides/tagCache/fs-dev-nextMode.ts b/packages/core/src/overrides/tagCache/fs-dev-nextMode.ts index 9a433505..f74bd70c 100644 --- a/packages/core/src/overrides/tagCache/fs-dev-nextMode.ts +++ b/packages/core/src/overrides/tagCache/fs-dev-nextMode.ts @@ -43,6 +43,9 @@ export default { if (tagData.expire !== undefined) { return tagData.expire <= Date.now() && tagData.expire > (lastModified ?? 0); } + if (tagData.stale !== undefined) { + return false; + } return tagData.revalidatedAt > (lastModified ?? 0); }); diff --git a/packages/core/src/overrides/tagCache/fs-dev.ts b/packages/core/src/overrides/tagCache/fs-dev.ts index dcab1f28..156fd530 100644 --- a/packages/core/src/overrides/tagCache/fs-dev.ts +++ b/packages/core/src/overrides/tagCache/fs-dev.ts @@ -42,8 +42,9 @@ const tagCache: TagCache = { (tagPathMapping) => tagPathMapping.path.S === buildKey(path) && Number.parseInt(tagPathMapping.revalidatedAt.N) > (lastModified ?? 0) && - (tagPathMapping.expire?.N === undefined || - (Number.parseInt(tagPathMapping.expire.N) <= now && + (tagPathMapping.stale?.N === undefined || + (tagPathMapping.expire?.N !== undefined && + Number.parseInt(tagPathMapping.expire.N) <= now && Number.parseInt(tagPathMapping.expire.N) > (lastModified ?? 0))) ); return revalidatedTags.length > 0 ? -1 : (lastModified ?? Date.now()); diff --git a/packages/tests-unit/tests/overrides/tag-cache/dynamodb.test.ts b/packages/tests-unit/tests/overrides/tag-cache/dynamodb.test.ts index 5db330bd..6f219bed 100644 --- a/packages/tests-unit/tests/overrides/tag-cache/dynamodb.test.ts +++ b/packages/tests-unit/tests/overrides/tag-cache/dynamodb.test.ts @@ -21,4 +21,14 @@ describe("DynamoDB tag cache", () => { expect(result).toBe(true); }); + + it("keeps a tag stale when its SWR window has no expiry", () => { + const result = hasHardRevalidation( + [{ revalidatedAt: { N: "1500" }, stale: { N: "1500" } }], + 1_000, + 2_000 + ); + + expect(result).toBe(false); + }); }); diff --git a/packages/tests-unit/tests/overrides/tag-cache/fs-dev.test.ts b/packages/tests-unit/tests/overrides/tag-cache/fs-dev.test.ts index 3a5b856e..52d1d146 100644 --- a/packages/tests-unit/tests/overrides/tag-cache/fs-dev.test.ts +++ b/packages/tests-unit/tests/overrides/tag-cache/fs-dev.test.ts @@ -28,4 +28,13 @@ describe("filesystem tag cache SWR revalidation", () => { expect(await fsDevTagCache.getLastModified("expired-page", 1_000)).toBe(-1); }); + + it("serves a stale entry indefinitely when no expiry is set", async () => { + await fsDevTagCache.writeTags([ + { path: "indefinite-page", tag: "indefinite-tag", stale: 2_000 }, + ]); + + expect(await fsDevTagCache.getLastModified("indefinite-page", 1_000)).toBe(1_000); + expect(await fsDevTagCache.isStale?.("indefinite-page", 1_000)).toBe(true); + }); }); diff --git a/packages/tests-unit/tests/overrides/tag-cache/next-mode.test.ts b/packages/tests-unit/tests/overrides/tag-cache/next-mode.test.ts index fa64e6e3..33a8d4cf 100644 --- a/packages/tests-unit/tests/overrides/tag-cache/next-mode.test.ts +++ b/packages/tests-unit/tests/overrides/tag-cache/next-mode.test.ts @@ -32,6 +32,12 @@ describe("next-mode SWR tag revalidation", () => { ).toBe(true); }); + it("keeps DynamoDB records stale when their SWR window has no expiry", () => { + expect( + hasHardRevalidation({ revalidatedAt: { N: "1500" }, stale: { N: "1500" } }, 1_000, 2_000) + ).toBe(false); + }); + it("caches missing DynamoDB tags for the duration of the request", () => { const itemsCache = new Map(); @@ -54,4 +60,11 @@ describe("next-mode SWR tag revalidation", () => { expect(await fsDevTagCache.hasBeenRevalidated(["expired-swr"], 1_000)).toBe(true); }); + + it("keeps filesystem records stale when their SWR window has no expiry", async () => { + await fsDevTagCache.writeTags([{ tag: "indefinite-swr", stale: 2_000 }]); + + expect(await fsDevTagCache.hasBeenRevalidated(["indefinite-swr"], 1_000)).toBe(false); + expect(await fsDevTagCache.isStale?.(["indefinite-swr"], 1_000)).toBe(true); + }); }); From 82d483d34a58250fbfc97b0015962b9290969a2a Mon Sep 17 00:00:00 2001 From: Victor Berchet Date: Thu, 24 Sep 2026 10:30:12 +0200 Subject: [PATCH 28/42] fix(core): validate tag revalidation durations --- packages/core/src/adapters/cache-handler.ts | 17 +++++++-- .../tests/adapters/cache-adapter.test.ts | 36 +++++++++++++++++++ 2 files changed, 50 insertions(+), 3 deletions(-) diff --git a/packages/core/src/adapters/cache-handler.ts b/packages/core/src/adapters/cache-handler.ts index cdf84053..04aa2228 100644 --- a/packages/core/src/adapters/cache-handler.ts +++ b/packages/core/src/adapters/cache-handler.ts @@ -316,9 +316,9 @@ async function handleRevalidateTags(body?: ReadableStream): Promise< return buildErrorResponse("Missing request body", 400); } - let parsed: { tags?: unknown; durations?: { expire?: number } }; + let parsed: { tags?: unknown; durations?: unknown }; try { - parsed = JSON.parse(bodyText) as { tags?: unknown; durations?: { expire?: number } }; + parsed = JSON.parse(bodyText) as { tags?: unknown; durations?: unknown }; } catch { return buildErrorResponse("Invalid JSON body", 400); } @@ -332,10 +332,21 @@ async function handleRevalidateTags(body?: ReadableStream): Promise< return buildErrorResponse("Missing 'tags' array in request body", 400); } + let durations: { expire?: number } | undefined; + if (parsed.durations !== undefined) { + if (typeof parsed.durations !== "object" || parsed.durations === null || Array.isArray(parsed.durations)) { + return buildErrorResponse("Invalid 'durations' object in request body", 400); + } + const expire = Reflect.get(parsed.durations, "expire"); + if (expire !== undefined && (typeof expire !== "number" || !Number.isFinite(expire) || expire < 0)) { + return buildErrorResponse("Invalid 'durations.expire' in request body", 400); + } + durations = expire === undefined ? {} : { expire }; + } + try { await runWithOpenNextRequestContext({ isISRRevalidation: false }, async () => { const now = Date.now(); - const { durations } = parsed; if (globalThis.tagCache.mode === "nextMode") { const paths = (await globalThis.tagCache.getPathsByTags?.(tags)) ?? []; const tagsToWrite = tags.map((tag) => diff --git a/packages/tests-unit/tests/adapters/cache-adapter.test.ts b/packages/tests-unit/tests/adapters/cache-adapter.test.ts index a16b731a..4c536c24 100644 --- a/packages/tests-unit/tests/adapters/cache-adapter.test.ts +++ b/packages/tests-unit/tests/adapters/cache-adapter.test.ts @@ -687,6 +687,42 @@ describe("cache-handler", () => { expect(mockTagCache.writeTags).not.toHaveBeenCalled(); }); + it.each([ + "invalid", + [], + { expire: "30" }, + { expire: -1 }, + ])("should reject invalid durations: %j", async (durations) => { + const event = createEvent({ + rawPath: "/cache/revalidate-tags", + method: "POST", + body: toReadableStream(JSON.stringify({ tags: ["tag1"], durations })), + }); + + const result = await runHandler(event); + + expect(result.statusCode).toBe(400); + expect(mockTagCache.writeTags).not.toHaveBeenCalled(); + }); + + it("should accept an SWR duration without an expiry", async () => { + mockTagCache.mode = "nextMode"; + vi.useFakeTimers().setSystemTime(100_000); + const event = createEvent({ + rawPath: "/cache/revalidate-tags", + method: "POST", + body: toReadableStream(JSON.stringify({ tags: ["tag1"], durations: {} })), + }); + + const result = await runHandler(event); + + expect(result.statusCode).toBe(200); + expect(mockTagCache.writeTags).toHaveBeenCalledWith([ + { tag: "tag1", stale: 100_000, expire: undefined }, + ]); + vi.useRealTimers(); + }); + it("should return 400 when body is invalid JSON", async () => { const event = createEvent({ rawPath: "/cache/revalidate-tags", From 07d8931d40dc904943c333b439b665283ff9b9d0 Mon Sep 17 00:00:00 2001 From: Victor Berchet Date: Thu, 24 Sep 2026 10:30:30 +0200 Subject: [PATCH 29/42] style: format cache review fixes --- .../overrides/tagCache/dynamodb-nextMode.ts | 8 ++--- packages/core/src/adapters/cache-handler.ts | 11 +++--- .../tests/adapters/cache-adapter.test.ts | 36 +++++++++---------- .../overrides/tag-cache/dynamodb.test.ts | 3 +- .../tests/overrides/tag-cache/fs-dev.test.ts | 8 ++--- .../overrides/tag-cache/next-mode.test.ts | 11 +++--- 6 files changed, 34 insertions(+), 43 deletions(-) diff --git a/packages/aws/src/overrides/tagCache/dynamodb-nextMode.ts b/packages/aws/src/overrides/tagCache/dynamodb-nextMode.ts index 66b2a4c2..9565cad9 100644 --- a/packages/aws/src/overrides/tagCache/dynamodb-nextMode.ts +++ b/packages/aws/src/overrides/tagCache/dynamodb-nextMode.ts @@ -189,9 +189,7 @@ export default { } const store = globalThis.__openNextAls.getStore(); - const itemsCache = store?.requestCache.getOrCreate( - "ddb-nextMode:tagItems" - ); + const itemsCache = store?.requestCache.getOrCreate("ddb-nextMode:tagItems"); const now = Date.now(); const compute = (item: DynamoDBItem): boolean => { @@ -221,9 +219,7 @@ export default { } const store = globalThis.__openNextAls.getStore(); - const itemsCache = store?.requestCache.getOrCreate( - "ddb-nextMode:tagItems" - ); + const itemsCache = store?.requestCache.getOrCreate("ddb-nextMode:tagItems"); const compute = (item: DynamoDBItem): boolean => { if (!item?.stale?.N) return false; diff --git a/packages/core/src/adapters/cache-handler.ts b/packages/core/src/adapters/cache-handler.ts index 04aa2228..c70afbd4 100644 --- a/packages/core/src/adapters/cache-handler.ts +++ b/packages/core/src/adapters/cache-handler.ts @@ -334,7 +334,11 @@ async function handleRevalidateTags(body?: ReadableStream): Promise< let durations: { expire?: number } | undefined; if (parsed.durations !== undefined) { - if (typeof parsed.durations !== "object" || parsed.durations === null || Array.isArray(parsed.durations)) { + if ( + typeof parsed.durations !== "object" || + parsed.durations === null || + Array.isArray(parsed.durations) + ) { return buildErrorResponse("Invalid 'durations' object in request body", 400); } const expire = Reflect.get(parsed.durations, "expire"); @@ -407,10 +411,7 @@ async function handleRevalidateTags(body?: ReadableStream): Promise< path, tag: hardTag, stale: now, - expire: - durations.expire === undefined - ? undefined - : now + durations.expire * 1000, + expire: durations.expire === undefined ? undefined : now + durations.expire * 1000, } : { path, tag: hardTag, expire: now } ) diff --git a/packages/tests-unit/tests/adapters/cache-adapter.test.ts b/packages/tests-unit/tests/adapters/cache-adapter.test.ts index 4c536c24..695817ff 100644 --- a/packages/tests-unit/tests/adapters/cache-adapter.test.ts +++ b/packages/tests-unit/tests/adapters/cache-adapter.test.ts @@ -687,23 +687,21 @@ describe("cache-handler", () => { expect(mockTagCache.writeTags).not.toHaveBeenCalled(); }); - it.each([ - "invalid", - [], - { expire: "30" }, - { expire: -1 }, - ])("should reject invalid durations: %j", async (durations) => { - const event = createEvent({ - rawPath: "/cache/revalidate-tags", - method: "POST", - body: toReadableStream(JSON.stringify({ tags: ["tag1"], durations })), - }); - - const result = await runHandler(event); - - expect(result.statusCode).toBe(400); - expect(mockTagCache.writeTags).not.toHaveBeenCalled(); - }); + it.each(["invalid", [], { expire: "30" }, { expire: -1 }])( + "should reject invalid durations: %j", + async (durations) => { + const event = createEvent({ + rawPath: "/cache/revalidate-tags", + method: "POST", + body: toReadableStream(JSON.stringify({ tags: ["tag1"], durations })), + }); + + const result = await runHandler(event); + + expect(result.statusCode).toBe(400); + expect(mockTagCache.writeTags).not.toHaveBeenCalled(); + } + ); it("should accept an SWR duration without an expiry", async () => { mockTagCache.mode = "nextMode"; @@ -827,9 +825,7 @@ describe("cache-handler", () => { await runHandler(event); - expect(mockTagCache.writeTags).toHaveBeenCalledWith([ - { tag: "tag1", stale: 100_000, expire: 130_000 }, - ]); + expect(mockTagCache.writeTags).toHaveBeenCalledWith([{ tag: "tag1", stale: 100_000, expire: 130_000 }]); vi.useRealTimers(); }); diff --git a/packages/tests-unit/tests/overrides/tag-cache/dynamodb.test.ts b/packages/tests-unit/tests/overrides/tag-cache/dynamodb.test.ts index 6f219bed..aecb4376 100644 --- a/packages/tests-unit/tests/overrides/tag-cache/dynamodb.test.ts +++ b/packages/tests-unit/tests/overrides/tag-cache/dynamodb.test.ts @@ -1,6 +1,7 @@ -import { hasHardRevalidation } from "../../../../aws/src/overrides/tagCache/dynamodb.js"; import { describe, expect, it } from "vitest"; +import { hasHardRevalidation } from "../../../../aws/src/overrides/tagCache/dynamodb.js"; + describe("DynamoDB tag cache", () => { it("does not hard-invalidate a tag before its SWR expiry", () => { const result = hasHardRevalidation( diff --git a/packages/tests-unit/tests/overrides/tag-cache/fs-dev.test.ts b/packages/tests-unit/tests/overrides/tag-cache/fs-dev.test.ts index 52d1d146..cfd9e3e4 100644 --- a/packages/tests-unit/tests/overrides/tag-cache/fs-dev.test.ts +++ b/packages/tests-unit/tests/overrides/tag-cache/fs-dev.test.ts @@ -13,9 +13,7 @@ describe("filesystem tag cache SWR revalidation", () => { }); it("serves a stale entry before its expiry", async () => { - await fsDevTagCache.writeTags([ - { path: "future-page", tag: "future-tag", stale: 2_000, expire: 3_000 }, - ]); + await fsDevTagCache.writeTags([{ path: "future-page", tag: "future-tag", stale: 2_000, expire: 3_000 }]); expect(await fsDevTagCache.getLastModified("future-page", 1_000)).toBe(1_000); expect(await fsDevTagCache.isStale?.("future-page", 1_000)).toBe(true); @@ -30,9 +28,7 @@ describe("filesystem tag cache SWR revalidation", () => { }); it("serves a stale entry indefinitely when no expiry is set", async () => { - await fsDevTagCache.writeTags([ - { path: "indefinite-page", tag: "indefinite-tag", stale: 2_000 }, - ]); + await fsDevTagCache.writeTags([{ path: "indefinite-page", tag: "indefinite-tag", stale: 2_000 }]); expect(await fsDevTagCache.getLastModified("indefinite-page", 1_000)).toBe(1_000); expect(await fsDevTagCache.isStale?.("indefinite-page", 1_000)).toBe(true); diff --git a/packages/tests-unit/tests/overrides/tag-cache/next-mode.test.ts b/packages/tests-unit/tests/overrides/tag-cache/next-mode.test.ts index 33a8d4cf..61fca535 100644 --- a/packages/tests-unit/tests/overrides/tag-cache/next-mode.test.ts +++ b/packages/tests-unit/tests/overrides/tag-cache/next-mode.test.ts @@ -1,10 +1,11 @@ +import fsDevTagCache from "@opennextjs/core/overrides/tagCache/fs-dev-nextMode"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + import { cacheDynamoItems, hasHardRevalidation, } from "../../../../aws/src/overrides/tagCache/dynamodb-nextMode.js"; import type { DynamoDBItem } from "../../../../aws/src/overrides/tagCache/dynamodb-nextMode.js"; -import fsDevTagCache from "@opennextjs/core/overrides/tagCache/fs-dev-nextMode"; -import { beforeEach, describe, expect, it, vi } from "vitest"; describe("next-mode SWR tag revalidation", () => { beforeEach(() => { @@ -33,9 +34,9 @@ describe("next-mode SWR tag revalidation", () => { }); it("keeps DynamoDB records stale when their SWR window has no expiry", () => { - expect( - hasHardRevalidation({ revalidatedAt: { N: "1500" }, stale: { N: "1500" } }, 1_000, 2_000) - ).toBe(false); + expect(hasHardRevalidation({ revalidatedAt: { N: "1500" }, stale: { N: "1500" } }, 1_000, 2_000)).toBe( + false + ); }); it("caches missing DynamoDB tags for the duration of the request", () => { From 1d60cf8d279a7d66d9387e27d77afe823e797e7d Mon Sep 17 00:00:00 2001 From: Victor Berchet Date: Thu, 24 Sep 2026 10:36:46 +0200 Subject: [PATCH 30/42] fix(aws): preserve lightweight SWR entries without expiry --- .../src/overrides/tagCache/dynamodb-lite.ts | 46 ++++++++++--------- .../overrides/tag-cache/dynamodb.test.ts | 11 +++++ 2 files changed, 36 insertions(+), 21 deletions(-) diff --git a/packages/aws/src/overrides/tagCache/dynamodb-lite.ts b/packages/aws/src/overrides/tagCache/dynamodb-lite.ts index 417afc38..5cf3413c 100644 --- a/packages/aws/src/overrides/tagCache/dynamodb-lite.ts +++ b/packages/aws/src/overrides/tagCache/dynamodb-lite.ts @@ -18,6 +18,28 @@ type DynamoDBItem = { expire?: { N: string }; }; +/** + * Determines whether lightweight DynamoDB tag records require blocking regeneration. + * + * @param items Tag records newer than the cached entry. + * @param lastModified Cached entry timestamp. + * @param now Current timestamp. + * @return Whether a tag was hard-revalidated or its SWR window expired. + */ +export function hasHardRevalidation(items: DynamoDBItem[], lastModified: number, now: number): boolean { + return items.some((item) => { + const revalidatedAt = Number.parseInt(item.revalidatedAt?.N ?? "0"); + if (revalidatedAt <= lastModified) { + return false; + } + if (item.expire?.N) { + const expiry = Number.parseInt(item.expire.N); + return expiry <= now && expiry > lastModified; + } + return !item.stale?.N; + }); +} + type DynamoDBResponse = { Items?: DynamoDBItem[]; }; @@ -184,27 +206,9 @@ const tagCache: OriginalTagCache = { const revalidatedTags = ((await result.json()) as DynamoDBResponse).Items ?? []; debug("revalidatedTags", revalidatedTags); - // Check if any tag has expired - const now = Date.now(); - - const hasExpiredTag = revalidatedTags.some((item) => { - if (item.expire?.N) { - const expiry = Number.parseInt(item.expire.N); - return expiry <= now && expiry > (lastModified ?? 0); - } - return false; - }); - // Exclude expired tags from the revalidated count — they are handled - // separately via hasExpiredTag above. - const nonExpiredRevalidatedTags = revalidatedTags.filter((item) => { - if (item.expire?.N) { - return Number.parseInt(item.expire.N) === Number.parseInt(item.revalidatedAt?.N ?? "0"); - } - return true; - }); - // If we have revalidated tags or expired tags we return -1 to force revalidation - const resultValue = - nonExpiredRevalidatedTags.length > 0 || hasExpiredTag ? -1 : (lastModified ?? Date.now()); + const resultValue = hasHardRevalidation(revalidatedTags, lastModified ?? 0, Date.now()) + ? -1 + : (lastModified ?? Date.now()); cache?.set(cacheKey, resultValue); return resultValue; } catch (e) { diff --git a/packages/tests-unit/tests/overrides/tag-cache/dynamodb.test.ts b/packages/tests-unit/tests/overrides/tag-cache/dynamodb.test.ts index aecb4376..8086d8d9 100644 --- a/packages/tests-unit/tests/overrides/tag-cache/dynamodb.test.ts +++ b/packages/tests-unit/tests/overrides/tag-cache/dynamodb.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; +import { hasHardRevalidation as hasLiteHardRevalidation } from "../../../../aws/src/overrides/tagCache/dynamodb-lite.js"; import { hasHardRevalidation } from "../../../../aws/src/overrides/tagCache/dynamodb.js"; describe("DynamoDB tag cache", () => { @@ -32,4 +33,14 @@ describe("DynamoDB tag cache", () => { expect(result).toBe(false); }); + + it("keeps a lightweight tag stale when its SWR window has no expiry", () => { + const result = hasLiteHardRevalidation( + [{ revalidatedAt: { N: "1500" }, stale: { N: "1500" } }], + 1_000, + 2_000 + ); + + expect(result).toBe(false); + }); }); From cfae0bf6d935ec9a588bc501fbf945145735d869 Mon Sep 17 00:00:00 2001 From: Victor Berchet Date: Thu, 24 Sep 2026 10:39:27 +0200 Subject: [PATCH 31/42] fix(cache): ignore superseded SWR expirations --- .../overrides/tagCache/dynamodb-nextMode.ts | 6 +++++- .../src/overrides/tagCache/fs-dev-nextMode.ts | 4 ++-- .../overrides/tag-cache/next-mode.test.ts | 18 ++++++++++++++++++ 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/packages/aws/src/overrides/tagCache/dynamodb-nextMode.ts b/packages/aws/src/overrides/tagCache/dynamodb-nextMode.ts index 9565cad9..b3dacfda 100644 --- a/packages/aws/src/overrides/tagCache/dynamodb-nextMode.ts +++ b/packages/aws/src/overrides/tagCache/dynamodb-nextMode.ts @@ -29,6 +29,10 @@ export type DynamoDBItem = { * @return Whether the tag was hard-revalidated or its SWR window expired. */ export function hasHardRevalidation(item: DynamoDBItem, lastModified: number, now: number): boolean { + const revalidatedAt = Number.parseInt(item.revalidatedAt?.N ?? "0"); + if (revalidatedAt <= lastModified) { + return false; + } if (item.expire?.N) { const expiry = Number.parseInt(item.expire.N); return expiry <= now && expiry > lastModified; @@ -36,7 +40,7 @@ export function hasHardRevalidation(item: DynamoDBItem, lastModified: number, no if (item.stale?.N) { return false; } - return Number.parseInt(item.revalidatedAt?.N ?? "0") > lastModified; + return true; } const getAwsClient = () => { diff --git a/packages/core/src/overrides/tagCache/fs-dev-nextMode.ts b/packages/core/src/overrides/tagCache/fs-dev-nextMode.ts index f74bd70c..b5369dc4 100644 --- a/packages/core/src/overrides/tagCache/fs-dev-nextMode.ts +++ b/packages/core/src/overrides/tagCache/fs-dev-nextMode.ts @@ -37,7 +37,7 @@ export default { const hasRevalidatedTag = tags.some((tag) => { const tagData = tagsMap.get(tag); - if (!tagData) { + if (!tagData || tagData.revalidatedAt <= (lastModified ?? 0)) { return false; } if (tagData.expire !== undefined) { @@ -46,7 +46,7 @@ export default { if (tagData.stale !== undefined) { return false; } - return tagData.revalidatedAt > (lastModified ?? 0); + return true; }); debug("hasBeenRevalidated result:", hasRevalidatedTag); diff --git a/packages/tests-unit/tests/overrides/tag-cache/next-mode.test.ts b/packages/tests-unit/tests/overrides/tag-cache/next-mode.test.ts index 61fca535..17ee445b 100644 --- a/packages/tests-unit/tests/overrides/tag-cache/next-mode.test.ts +++ b/packages/tests-unit/tests/overrides/tag-cache/next-mode.test.ts @@ -33,6 +33,16 @@ describe("next-mode SWR tag revalidation", () => { ).toBe(true); }); + it("ignores an expired DynamoDB SWR window older than the cached entry", () => { + expect( + hasHardRevalidation( + { revalidatedAt: { N: "1000" }, stale: { N: "1000" }, expire: { N: "1800" } }, + 1_500, + 2_000 + ) + ).toBe(false); + }); + it("keeps DynamoDB records stale when their SWR window has no expiry", () => { expect(hasHardRevalidation({ revalidatedAt: { N: "1500" }, stale: { N: "1500" } }, 1_000, 2_000)).toBe( false @@ -62,6 +72,14 @@ describe("next-mode SWR tag revalidation", () => { expect(await fsDevTagCache.hasBeenRevalidated(["expired-swr"], 1_000)).toBe(true); }); + it("ignores an expired filesystem SWR window older than the cached entry", async () => { + vi.setSystemTime(1_000); + await fsDevTagCache.writeTags([{ tag: "old-expired-swr", stale: 1_000, expire: 1_800 }]); + vi.setSystemTime(2_000); + + expect(await fsDevTagCache.hasBeenRevalidated(["old-expired-swr"], 1_500)).toBe(false); + }); + it("keeps filesystem records stale when their SWR window has no expiry", async () => { await fsDevTagCache.writeTags([{ tag: "indefinite-swr", stale: 2_000 }]); From 89a07048a1871a0479ceb8837c80f80a11dc46d6 Mon Sep 17 00:00:00 2001 From: Victor Berchet Date: Mon, 28 Sep 2026 11:31:42 +0200 Subject: [PATCH 32/42] fix(cache): preserve timestamps when serving stale entries Replace the lastModified=1 transport sentinel with explicit isStale metadata. Preserve generation times through Next cache adapters, request context, queue messages, and deduplication; retain revalidatedAt=1 for newly associated tags. Patch Next incremental-cache results to consume the stale flag for fetches and routes, including revalidate:false, while preserving blocking invalidation. Test against installed Next source plus wire parsing, adapters, the interceptor, and Cloudflare queue timestamp comparisons. Verification: pnpm fmt:fix, pnpm lint:fix, pnpm ts:check, pnpm test. Addresses Copilot discussion_r4091697951. --- .../src/api/durable-objects/queue.spec.ts | 11 +++ packages/core/src/adapters/cache-handler.ts | 19 ++++- packages/core/src/adapters/cache.ts | 19 +++++ .../core/src/adapters/composable-cache.ts | 8 +- packages/core/src/build/createServerBundle.ts | 1 + .../core/src/build/patch/patches/index.ts | 1 + .../build/patch/patches/patchTagStaleness.ts | 31 ++++++++ .../core/src/core/routing/cacheInterceptor.ts | 10 ++- packages/core/src/types/cache.ts | 2 + packages/core/src/types/overrides.ts | 2 + packages/core/src/utils/cache-get.ts | 10 +++ .../tests/adapters/cache-adapter.test.ts | 6 +- .../tests-unit/tests/adapters/cache.test.ts | 21 +++++ .../tests/adapters/composable-cache.test.ts | 3 +- .../patch/patches/patchTagStaleness.test.ts | 78 +++++++++++++++++++ .../core/routing/cacheInterceptor.test.ts | 20 +++-- .../tests-unit/tests/utils/cache-get.test.ts | 13 ++++ 17 files changed, 241 insertions(+), 14 deletions(-) create mode 100644 packages/core/src/build/patch/patches/patchTagStaleness.ts create mode 100644 packages/tests-unit/tests/build/patch/patches/patchTagStaleness.test.ts diff --git a/packages/cloudflare/src/api/durable-objects/queue.spec.ts b/packages/cloudflare/src/api/durable-objects/queue.spec.ts index e5219d08..c154c03b 100644 --- a/packages/cloudflare/src/api/durable-objects/queue.spec.ts +++ b/packages/cloudflare/src/api/durable-objects/queue.spec.ts @@ -66,6 +66,17 @@ const createMessage = (dedupId: string, lastModified = Date.now()) => ({ }); describe("DurableObjectQueue", () => { + it("does not suppress a later stale entry after an earlier successful regeneration", () => { + const queue = createDurableObjectQueue({ fetchDuration: 0 }); + vi.mocked(queue.sql.exec).mockImplementation( + (_query, _id, timestamp) => + ({ + toArray: () => (90 > Number(timestamp) ? [{ found: 1 }] : []), + }) as never + ); + expect(queue.checkSyncTable(createMessage("later", 100_000))).toBe(false); + expect(queue.checkSyncTable(createMessage("old", 80_000))).toBe(true); + }); describe("successful revalidation", () => { it("should process a single revalidation", async () => { process.env.__NEXT_PREVIEW_MODE_ID = "test"; diff --git a/packages/core/src/adapters/cache-handler.ts b/packages/core/src/adapters/cache-handler.ts index c70afbd4..bb9e520c 100644 --- a/packages/core/src/adapters/cache-handler.ts +++ b/packages/core/src/adapters/cache-handler.ts @@ -108,6 +108,14 @@ export async function handler( // Route handlers // ////////////////////// +/** + * Reads a cache entry and attaches tag freshness without changing its timestamp. + * + * @param key Cache key. + * @param cacheType Entry type. + * @param additionalTags Tags supplied by the caller. + * @return The cache response, or an error response when the read fails. + */ async function handleGet( key: string, cacheType: CacheEntryType, @@ -176,7 +184,7 @@ async function handleGet( const lastModified = result.lastModified ?? Date.now(); if (await isStale(key, tags, lastModified)) { - result.lastModified = 1; + return buildCacheGetResponse({ ...result, isStale: true }); } } @@ -454,6 +462,12 @@ async function handleRevalidateTags(body?: ReadableStream): Promise< // Cache GET response builder // ///////////////////////////// +/** + * Serializes cache metadata and payload for all cache transports. + * + * @param result Cached entry and freshness metadata. + * @return The cache-service response. + */ function buildCacheGetResponse(result: WithLastModified>): InternalResult { const value = result.value!; @@ -468,6 +482,9 @@ function buildCacheGetResponse(result: WithLastModified { debug("get fetch cache", { key }); try { @@ -40,6 +47,7 @@ export default class Cache { return { lastModified: result.lastModified ?? Date.now(), + ...(result.isStale ? { isStale: true } : {}), value: result.value, } as CacheHandlerValue; } catch (e) { @@ -49,6 +57,12 @@ export default class Cache { } } + /** + * Reads route data, preserving the real timestamp for revalidation queue deduplication. + * + * @param key Route cache key. + * @return The Next cache entry, or null on a miss or read failure. + */ async getIncrementalCache(key: string): Promise { try { const cachedEntry = await globalThis.cache.get(key, "cache"); @@ -61,6 +75,7 @@ export default class Cache { const meta = cacheData.meta; const _lastModified = cachedEntry.lastModified ?? Date.now(); + const freshness = cachedEntry.isStale ? { isStale: true } : {}; const store = globalThis.__openNextAls.getStore(); if (store) { @@ -70,6 +85,7 @@ export default class Cache { if (cacheData?.type === "route") { return { lastModified: _lastModified, + ...freshness, value: { kind: "APP_ROUTE", body: Buffer.from( @@ -91,6 +107,7 @@ export default class Cache { } return { lastModified: _lastModified, + ...freshness, value: { kind: "APP_PAGE", html: cacheData.html, @@ -104,6 +121,7 @@ export default class Cache { } return { lastModified: _lastModified, + ...freshness, value: { kind: "PAGES", html: cacheData.html, @@ -116,6 +134,7 @@ export default class Cache { if (cacheData?.type === "redirect") { return { lastModified: _lastModified, + ...freshness, value: { kind: "REDIRECT", props: cacheData.props, diff --git a/packages/core/src/adapters/composable-cache.ts b/packages/core/src/adapters/composable-cache.ts index 56e28df4..a635a908 100644 --- a/packages/core/src/adapters/composable-cache.ts +++ b/packages/core/src/adapters/composable-cache.ts @@ -7,6 +7,12 @@ import { debug } from "./logger"; const pendingWritePromiseMap = new Map>>(); export default { + /** + * Reads a composable entry, translating tag staleness into Next's revalidate interval. + * + * @param cacheKey Entry key. + * @return A streamed entry, or undefined on a miss or read failure. + */ async get(cacheKey: string) { try { // We first check if we have a pending write for this cache key @@ -26,7 +32,7 @@ export default { } debug("composable cache result", result); - const revalidate = result.lastModified === 1 ? -1 : result.value.revalidate; + const revalidate = result.isStale ? -1 : result.value.revalidate; return { ...result.value, diff --git a/packages/core/src/build/createServerBundle.ts b/packages/core/src/build/createServerBundle.ts index f7cd954c..272a7b0b 100644 --- a/packages/core/src/build/createServerBundle.ts +++ b/packages/core/src/build/createServerBundle.ts @@ -209,6 +209,7 @@ async function generateBundle( patches.patchNextServer, patches.getEnvVarsPatch(options), patches.patchBackgroundRevalidation, + patches.patchTagStaleness, patches.patchUseCacheForISR, patches.patchNodeEnvironment, ...additionalCodePatches, diff --git a/packages/core/src/build/patch/patches/index.ts b/packages/core/src/build/patch/patches/index.ts index a6bb2eba..dd0fbe49 100644 --- a/packages/core/src/build/patch/patches/index.ts +++ b/packages/core/src/build/patch/patches/index.ts @@ -7,5 +7,6 @@ export { } from "./patchFetchCacheISR.js"; export { patchFetchCacheSetMissingWaitUntil } from "./patchFetchCacheWaitUntil.js"; export { patchBackgroundRevalidation } from "./patchBackgroundRevalidation.js"; +export { patchTagStaleness } from "./patchTagStaleness.js"; export { patchNodeEnvironment } from "./patchNodeEnvironment.js"; export { patchOriginalNextConfig } from "./patchOriginalNextConfig.js"; diff --git a/packages/core/src/build/patch/patches/patchTagStaleness.ts b/packages/core/src/build/patch/patches/patchTagStaleness.ts new file mode 100644 index 00000000..55fcbb8a --- /dev/null +++ b/packages/core/src/build/patch/patches/patchTagStaleness.ts @@ -0,0 +1,31 @@ +import { getCrossPlatformPathRegex } from "@/utils/regex.js"; + +import { createPatchCode } from "../astCodePatcher.js"; +import type { CodePatcher } from "../codePatcher.js"; + +// Next derives staleness from time and does not consume custom cache-handler metadata. +// Preserve blocking invalidation (-1), and add our tag signal to both fetch and route results. +export const rule = ` +rule: + kind: shorthand_property_identifier + regex: ^isStale$ + inside: + kind: method_definition + stopBy: end + has: + kind: property_identifier + regex: ^get$ +fix: + 'isStale: isStale === -1 ? -1 : cacheData?.isStale || isStale' +`; + +export const patchTagStaleness = { + name: "patchTagStaleness", + patches: [ + { + versions: ">=14.1.0", + pathFilter: getCrossPlatformPathRegex("server/lib/incremental-cache/index.js"), + patchCode: createPatchCode(rule), + }, + ], +} satisfies CodePatcher; diff --git a/packages/core/src/core/routing/cacheInterceptor.ts b/packages/core/src/core/routing/cacheInterceptor.ts index 419f3366..058a228a 100644 --- a/packages/core/src/core/routing/cacheInterceptor.ts +++ b/packages/core/src/core/routing/cacheInterceptor.ts @@ -293,6 +293,12 @@ function decodePathParams(pathname: string): string { .join("/"); } +/** + * Serves eligible cached routes and schedules regeneration for stale entries. + * + * @param event Incoming middleware request. + * @return A cached response or the request to forward to Next.js. + */ export async function cacheInterceptor( event: MiddlewareEvent ): Promise { @@ -356,9 +362,7 @@ export async function cacheInterceptor( return event; } const host = event.headers.host; - //TODO: change returned type to provide staleness as a prop - // Detect staleness signaled by the cache adapter (sets lastModified to 1) - const isStaleFromTagCache = cachedData.lastModified === 1; + const isStaleFromTagCache = cachedData.isStale === true; switch (cachedData?.value?.type) { case "app": case "page": diff --git a/packages/core/src/types/cache.ts b/packages/core/src/types/cache.ts index 1737d0c4..d7bd46bd 100644 --- a/packages/core/src/types/cache.ts +++ b/packages/core/src/types/cache.ts @@ -76,6 +76,8 @@ export interface CacheHandlerContext { fetchCacheKeyPrefix?: string; } export interface CacheHandlerValue { + /** Consumed by the OpenNext incremental-cache patch, including permanently cached routes. */ + isStale?: boolean; lastModified?: number; age?: number; cacheState?: string; diff --git a/packages/core/src/types/overrides.ts b/packages/core/src/types/overrides.ts index beadefae..51375803 100644 --- a/packages/core/src/types/overrides.ts +++ b/packages/core/src/types/overrides.ts @@ -90,6 +90,8 @@ export type CachedFetchValue = { }; export type WithLastModified = { + /** Tag-triggered staleness, independent of the entry's generation timestamp. */ + isStale?: boolean; lastModified?: number; value?: T; /** diff --git a/packages/core/src/utils/cache-get.ts b/packages/core/src/utils/cache-get.ts index 975c22b2..92304ee9 100644 --- a/packages/core/src/utils/cache-get.ts +++ b/packages/core/src/utils/cache-get.ts @@ -5,6 +5,7 @@ type HeadersMap = Record; type Base = { lastModified?: number; + isStale?: boolean; shouldBypassTagCache?: boolean; }; @@ -45,6 +46,14 @@ function collectPrefixedHeaders(headers: HeadersMap, prefix: string): Record { expect(mockTagCache.isStale).toHaveBeenCalledWith("test-key", 1000); expect(result.statusCode).toBe(200); - expect(result.headers["x-opennext-cache-last-modified"]).toBe("1"); + expect(result.headers["x-opennext-cache-last-modified"]).toBe("1000"); + expect(result.headers["x-opennext-cache-stale"]).toBe("true"); }); it("should mark stale entries in nextMode", async () => { @@ -394,7 +395,8 @@ describe("cache-handler", () => { expect(mockTagCache.isStale).toHaveBeenCalledWith(["tag1"], 1000); expect(result.statusCode).toBe(200); - expect(result.headers["x-opennext-cache-last-modified"]).toBe("1"); + expect(result.headers["x-opennext-cache-last-modified"]).toBe("1000"); + expect(result.headers["x-opennext-cache-stale"]).toBe("true"); }); it("should return 404 when a fetch entry's owning path has been revalidated", async () => { diff --git a/packages/tests-unit/tests/adapters/cache.test.ts b/packages/tests-unit/tests/adapters/cache.test.ts index e5b78f67..88ac61a2 100644 --- a/packages/tests-unit/tests/adapters/cache.test.ts +++ b/packages/tests-unit/tests/adapters/cache.test.ts @@ -54,6 +54,27 @@ describe("CacheHandler", () => { }); describe("get", () => { + it("preserves stale route timestamps in both Next results and the queue context", async () => { + cache.get.mockResolvedValueOnce({ + value: { type: "route", body: "data" }, + lastModified: 100_000, + isStale: true, + }); + expect(await instance.get("key")).toMatchObject({ lastModified: 100_000, isStale: true }); + expect(globalThis.__openNextAls.getStore()?.lastModified).toBe(100_000); + }); + + it("passes explicit fetch staleness to Next without changing the timestamp", async () => { + cache.get.mockResolvedValueOnce({ + value: { kind: "FETCH", data: { body: "data" } }, + lastModified: 100_000, + isStale: true, + }); + expect(await instance.get("key", { kind: "FETCH" })).toMatchObject({ + lastModified: 100_000, + isStale: true, + }); + }); it("Should return null for cache miss", async () => { cache.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 74c8e9a7..92b8be23 100644 --- a/packages/tests-unit/tests/adapters/composable-cache.test.ts +++ b/packages/tests-unit/tests/adapters/composable-cache.test.ts @@ -68,7 +68,8 @@ describe("Composable cache handler", () => { expire: Date.now() + 1000, revalidate: 3600, }, - lastModified: 1, + lastModified: 1000, + isStale: true, }); const result = await ComposableCache.get("stale-key"); diff --git a/packages/tests-unit/tests/build/patch/patches/patchTagStaleness.test.ts b/packages/tests-unit/tests/build/patch/patches/patchTagStaleness.test.ts new file mode 100644 index 00000000..cda3385e --- /dev/null +++ b/packages/tests-unit/tests/build/patch/patches/patchTagStaleness.test.ts @@ -0,0 +1,78 @@ +import fs from "node:fs"; +import { createRequire } from "node:module"; + +import { patchCode } from "@opennextjs/core/build/patch/astCodePatcher.js"; +import { rule } from "@opennextjs/core/build/patch/patches/patchTagStaleness.js"; +import { describe, expect, it, vi } from "vitest"; + +const require = createRequire(new URL("../../../../../core/package.json", import.meta.url)); +const filename = require.resolve("next/dist/server/lib/incremental-cache/index.js"); +const source = fs.readFileSync(filename, "utf8"); +const patched = patchCode(source, rule); +const module = { + exports: + {} as typeof import("../../../../../core/node_modules/next/dist/server/lib/incremental-cache/index.js"), +}; +new Function("require", "exports", "module", patched)(createRequire(filename), module.exports, module); + +describe("tag staleness in the installed Next incremental cache", () => { + it("patches the real Next source", () => { + expect(patched).not.toBe(source); + }); + + it.each([false, 3600] as const)("marks a fresh route stale with revalidate=%s", async (revalidate) => { + const cache = new module.exports.IncrementalCache({ + dev: false, + requestHeaders: {}, + getPrerenderManifest: () => + ({ + routes: {}, + dynamicRoutes: {}, + notFoundRoutes: [], + preview: { previewModeId: "test" }, + version: 4, + }) as never, + }); + cache.cacheControls.get = () => ({ revalidate }); + const entry = { + lastModified: Date.now(), + isStale: true, + value: { kind: "APP_ROUTE", body: Buffer.from("data"), status: 200, headers: {} }, + }; + cache.cacheHandler = { get: vi.fn().mockResolvedValue(entry) } as never; + expect(await cache.get("/page", { kind: "APP_ROUTE" } as never)).toMatchObject({ isStale: true }); + entry.isStale = false; + expect((await cache.get("/page", { kind: "APP_ROUTE" } as never))?.isStale).not.toBe(true); + entry.lastModified = -1; + entry.isStale = true; + expect((await cache.get("/page", { kind: "APP_ROUTE" } as never))?.isStale).toBe(-1); + }); + + it("marks fresh fetch data stale and preserves misses", async () => { + const cache = new module.exports.IncrementalCache({ + dev: false, + requestHeaders: {}, + getPrerenderManifest: () => + ({ + routes: {}, + dynamicRoutes: {}, + notFoundRoutes: [], + preview: { previewModeId: "test" }, + version: 4, + }) as never, + }); + const get = vi + .fn() + .mockResolvedValue({ + lastModified: Date.now(), + isStale: true, + value: { kind: "FETCH", data: { body: "data" }, revalidate: 3600 }, + }); + cache.cacheHandler = { get } as never; + expect(await cache.get("fetch-key", { kind: "FETCH", revalidate: 3600 } as never)).toMatchObject({ + isStale: true, + }); + get.mockResolvedValue(null); + expect(await cache.get("fetch-key", { kind: "FETCH" } as never)).toBeNull(); + }); +}); diff --git a/packages/tests-unit/tests/core/routing/cacheInterceptor.test.ts b/packages/tests-unit/tests/core/routing/cacheInterceptor.test.ts index e995f5f3..e7c3280b 100644 --- a/packages/tests-unit/tests/core/routing/cacheInterceptor.test.ts +++ b/packages/tests-unit/tests/core/routing/cacheInterceptor.test.ts @@ -475,7 +475,7 @@ describe("cacheInterceptor", () => { }); describe("isStaleFromTagCache", () => { - it("should serve SSG app content with STALE when lastModified is 1", async () => { + it("should serve stale SSG app content and queue its real timestamp", async () => { const event = createEvent({ url: "/albums", }); @@ -484,10 +484,16 @@ describe("cacheInterceptor", () => { type: "app", html: "Hello, world!", }, - lastModified: 1, + lastModified: 100_000, + isStale: true, }); const result = await cacheInterceptor(event); + expect(globalThis.queue.send).toHaveBeenCalledWith( + expect.objectContaining({ + MessageBody: expect.objectContaining({ lastModified: 100_000 }), + }) + ); expect(result).toEqual( expect.objectContaining({ @@ -500,7 +506,7 @@ describe("cacheInterceptor", () => { ); }); - it("should serve SSG page content with STALE when lastModified is 1", async () => { + it("should serve SSG page content with explicit staleness", async () => { const event = createEvent({ url: "/albums", }); @@ -509,7 +515,8 @@ describe("cacheInterceptor", () => { type: "page", html: "Hello, world!", }, - lastModified: 1, + lastModified: 100_000, + isStale: true, }); const result = await cacheInterceptor(event); @@ -519,7 +526,7 @@ describe("cacheInterceptor", () => { expect((result as any).headers["x-opennext-cache"]).toBe("STALE"); }); - it("should serve SSG route content with STALE when lastModified is 1", async () => { + it("should serve SSG route content with explicit staleness", async () => { const event = createEvent({ url: "/albums", }); @@ -532,7 +539,8 @@ describe("cacheInterceptor", () => { headers: { "content-type": "text/plain" }, }, }, - lastModified: 1, + lastModified: 100_000, + isStale: true, }); const result = await cacheInterceptor(event); diff --git a/packages/tests-unit/tests/utils/cache-get.test.ts b/packages/tests-unit/tests/utils/cache-get.test.ts index b7fe39c2..e3aad880 100644 --- a/packages/tests-unit/tests/utils/cache-get.test.ts +++ b/packages/tests-unit/tests/utils/cache-get.test.ts @@ -2,6 +2,19 @@ import { parseCacheGetResponse } from "@opennextjs/core/utils/cache-get"; import { describe, expect, it } from "vitest"; describe("parseCacheGetResponse", () => { + it("preserves generation time separately from tag staleness", () => { + const result = parseCacheGetResponse( + { + "x-opennext-cache-found": "true", + "x-opennext-cache-type": "cache", + "x-opennext-cache-sub-type": "route", + "x-opennext-cache-last-modified": "100000", + "x-opennext-cache-stale": "true", + }, + "body" + ); + expect(result).toMatchObject({ lastModified: 100_000, isStale: true }); + }); it("should return null when x-opennext-cache-found is not 'true'", () => { const result = parseCacheGetResponse({ "x-opennext-cache-type": "cache" }, "body"); expect(result).toBeNull(); From 966bbecbfdfaac6b40164a3d13bde44174d93d8d Mon Sep 17 00:00:00 2001 From: Victor Berchet Date: Mon, 28 Sep 2026 11:33:47 +0200 Subject: [PATCH 33/42] fix(aws): classify stale tag records explicitly Require a newer stale timestamp and an unexpired or indefinite SWR window instead of treating every DynamoDB query result as stale. This excludes legacy hard invalidations, delayed writes for superseded events, and elapsed windows. Share queried records between lightweight hard and stale checks, re-evaluating expiry rather than caching a time-dependent classification. Add 16 adapter-level cases across SDK and lightweight backends, asserting one query per request. Verification: formatter, linter, workspace type checks and full tests passed. Addresses Copilot discussion_r4091698102 and discussion_r4091698162. --- .../src/overrides/tagCache/dynamodb-lite.ts | 28 +++++++-- .../aws/src/overrides/tagCache/dynamodb.ts | 11 +++- .../src/overrides/tagCache/stale-record.ts | 21 +++++++ .../tag-cache/dynamodb-staleness.test.ts | 63 +++++++++++++++++++ 4 files changed, 117 insertions(+), 6 deletions(-) create mode 100644 packages/aws/src/overrides/tagCache/stale-record.ts create mode 100644 packages/tests-unit/tests/overrides/tag-cache/dynamodb-staleness.test.ts diff --git a/packages/aws/src/overrides/tagCache/dynamodb-lite.ts b/packages/aws/src/overrides/tagCache/dynamodb-lite.ts index 5cf3413c..6a8f3709 100644 --- a/packages/aws/src/overrides/tagCache/dynamodb-lite.ts +++ b/packages/aws/src/overrides/tagCache/dynamodb-lite.ts @@ -9,6 +9,7 @@ import { AwsClient } from "aws4fetch"; import { customFetchClient } from "../../utils/fetch.js"; import { MAX_DYNAMO_BATCH_WRITE_ITEM_COUNT, getDynamoBatchWriteCommandConcurrency } from "./constants.js"; +import { isStaleRecord } from "./stale-record.js"; type DynamoDBItem = { tag?: { S: string }; @@ -173,6 +174,13 @@ const tagCache: OriginalTagCache = { return []; } }, + /** + * Checks hard invalidation and shares queried records with subsequent stale checks. + * + * @param key Entry key. + * @param lastModified Entry generation time. + * @return -1 for hard invalidation, otherwise the generation time; preserves it on failure. + */ async getLastModified(key: string, lastModified?: number) { try { if (globalThis.openNextConfig.dangerous?.disableTagCache) { @@ -180,10 +188,12 @@ const tagCache: OriginalTagCache = { } const { CACHE_DYNAMO_TABLE } = process.env; const store = globalThis.__openNextAls.getStore(); - const cache = store?.requestCache.getOrCreate("dynamoDb:getLastModified"); + const cache = store?.requestCache.getOrCreate("dynamoDb:revalidateQueryItems"); const cacheKey = `${key}:${lastModified ?? 0}`; if (cache?.has(cacheKey)) { - return cache.get(cacheKey)!; + return hasHardRevalidation(cache.get(cacheKey)!, lastModified ?? 0, Date.now()) + ? -1 + : (lastModified ?? Date.now()); } const result = await awsFetch( JSON.stringify({ @@ -209,13 +219,20 @@ const tagCache: OriginalTagCache = { const resultValue = hasHardRevalidation(revalidatedTags, lastModified ?? 0, Date.now()) ? -1 : (lastModified ?? Date.now()); - cache?.set(cacheKey, resultValue); + cache?.set(cacheKey, revalidatedTags); return resultValue; } catch (e) { error("Failed to get revalidated tags", e); return lastModified ?? Date.now(); } }, + /** + * Checks applicable stale timestamps rather than treating every queried tag as stale. + * + * @param key Entry key. + * @param lastModified Entry generation time. + * @return Whether an active stale window applies; false on read failure. + */ async isStale(key: string, lastModified?: number) { try { if (globalThis.openNextConfig.dangerous?.disableTagCache) { @@ -231,7 +248,7 @@ const tagCache: OriginalTagCache = { if (itemsCache?.has(cacheKey)) { items = itemsCache.get(cacheKey)!; } else { - // We can reuse the same query as getLastModified since it already checks for revalidatedAt > lastModified as revalidatedAt and stale have the same value + // Share the query with getLastModified, then evaluate stale timestamps separately. const result = await awsFetch( JSON.stringify({ TableName: CACHE_DYNAMO_TABLE, @@ -254,7 +271,8 @@ const tagCache: OriginalTagCache = { itemsCache?.set(cacheKey, items); } debug("isStale items", key, items); - return items.length > 0; + const now = Date.now(); + return items.some((item) => isStaleRecord(item, lastModified ?? 0, now)); } catch (e) { error("Failed to check stale tags", e); return false; diff --git a/packages/aws/src/overrides/tagCache/dynamodb.ts b/packages/aws/src/overrides/tagCache/dynamodb.ts index ef9ec0a3..c599eee5 100644 --- a/packages/aws/src/overrides/tagCache/dynamodb.ts +++ b/packages/aws/src/overrides/tagCache/dynamodb.ts @@ -7,6 +7,7 @@ import { chunk, parseNumberFromEnv } from "@opennextjs/core/adapters/util.js"; import type { TagCache } from "@opennextjs/core/types/overrides.js"; import { MAX_DYNAMO_BATCH_WRITE_ITEM_COUNT, getDynamoBatchWriteCommandConcurrency } from "./constants"; +import { isStaleRecord } from "./stale-record.js"; const { CACHE_BUCKET_REGION, CACHE_DYNAMO_TABLE, NEXT_BUILD_ID } = process.env; @@ -184,6 +185,13 @@ const tagCache: TagCache = { return lastModified ?? Date.now(); } }, + /** + * Checks stale metadata in the same records used for hard-invalidation checks. + * + * @param key Entry key. + * @param lastModified Entry generation time. + * @return Whether an active stale window applies; false on read failure. + */ async isStale(key: string, lastModified?: number) { try { if (globalThis.openNextConfig.dangerous?.disableTagCache) { @@ -217,7 +225,8 @@ const tagCache: TagCache = { itemsCache?.set(cacheKey, items); } debug("isStale items", key, items); - return items.length > 0; + const now = Date.now(); + return items.some((item) => isStaleRecord(item, lastModified ?? 0, now)); } catch (e) { error("Failed to check stale tags", e); return false; diff --git a/packages/aws/src/overrides/tagCache/stale-record.ts b/packages/aws/src/overrides/tagCache/stale-record.ts new file mode 100644 index 00000000..61c36f1d --- /dev/null +++ b/packages/aws/src/overrides/tagCache/stale-record.ts @@ -0,0 +1,21 @@ +type TagRecord = { + revalidatedAt?: { N: string }; + stale?: { N: string }; + expire?: { N: string }; +}; + +/** + * Checks whether a tag record defines an active stale window for a cached entry. + * + * @param item Persisted tag timestamps. + * @param lastModified Entry generation timestamp. + * @param now Current time. + * @return True only for a newer stale event whose expiry has not elapsed. + */ +export function isStaleRecord(item: TagRecord, lastModified: number, now: number): boolean { + return ( + Number(item.revalidatedAt?.N) > lastModified && + Number(item.stale?.N) > lastModified && + (item.expire === undefined || Number(item.expire.N) > now) + ); +} diff --git a/packages/tests-unit/tests/overrides/tag-cache/dynamodb-staleness.test.ts b/packages/tests-unit/tests/overrides/tag-cache/dynamodb-staleness.test.ts new file mode 100644 index 00000000..b9506195 --- /dev/null +++ b/packages/tests-unit/tests/overrides/tag-cache/dynamodb-staleness.test.ts @@ -0,0 +1,63 @@ +import { AsyncLocalStorage } from "node:async_hooks"; + +import lite from "@opennextjs/aws/overrides/tagCache/dynamodb-lite.js"; +import full from "@opennextjs/aws/overrides/tagCache/dynamodb.js"; +import { runWithOpenNextRequestContext } from "@opennextjs/core/utils/promise.js"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { send, fetchDynamo } = vi.hoisted(() => ({ send: vi.fn(), fetchDynamo: vi.fn() })); +vi.mock("../../../../aws/node_modules/@aws-sdk/client-dynamodb", () => ({ + DynamoDBClient: class { + send = send; + }, + QueryCommand: class { + constructor(public input: unknown) {} + }, + BatchWriteItemCommand: class {}, +})); +vi.mock("@opennextjs/aws/utils/fetch.js", () => ({ customFetchClient: () => fetchDynamo })); + +describe.each([ + ["full", full, send], + ["lite", lite, fetchDynamo], +] as const)("%s DynamoDB stale reads", (_name, cache, query) => { + beforeEach(() => { + vi.clearAllMocks(); + vi.stubEnv("AWS_ACCESS_KEY_ID", "test"); + vi.stubEnv("AWS_SECRET_ACCESS_KEY", "test"); + vi.useFakeTimers().setSystemTime(2_000); + globalThis.openNextConfig = { dangerous: {} } as never; + globalThis.__openNextAls = new AsyncLocalStorage(); + }); + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllEnvs(); + }); + + it.each([ + ["legacy hard invalidation", { revalidatedAt: { N: "1600" } }, false], + ["active SWR", { revalidatedAt: { N: "1600" }, stale: { N: "1600" }, expire: { N: "3000" } }, true], + ["indefinite SWR", { revalidatedAt: { N: "1600" }, stale: { N: "1600" } }, true], + [ + "stale event predates entry", + { revalidatedAt: { N: "1600" }, stale: { N: "1000" }, expire: { N: "3000" } }, + false, + ], + [ + "superseded expiry", + { revalidatedAt: { N: "1600" }, stale: { N: "1000" }, expire: { N: "1200" } }, + false, + ], + ["expired window", { revalidatedAt: { N: "1600" }, stale: { N: "1600" }, expire: { N: "1800" } }, false], + ["older revalidation", { revalidatedAt: { N: "1000" }, stale: { N: "1600" } }, false], + ["equal stale timestamp", { revalidatedAt: { N: "1600" }, stale: { N: "1500" } }, false], + ])("classifies %s and shares the hard-check query", async (_case, item, expected) => { + send.mockResolvedValue({ Items: [item] }); + fetchDynamo.mockImplementation(async () => new Response(JSON.stringify({ Items: [item] }))); + await runWithOpenNextRequestContext({ isISRRevalidation: false }, async () => { + await cache.getLastModified("page", 1500); + expect(await cache.isStale!("page", 1500)).toBe(expected); + expect(query).toHaveBeenCalledTimes(1); + }); + }); +}); From fadd91f57436c300406c1e37e5e19033f223e592 Mon Sep 17 00:00:00 2001 From: Victor Berchet Date: Mon, 28 Sep 2026 11:34:43 +0200 Subject: [PATCH 34/42] fix(cloudflare): preserve string tag filter callbacks Keep filterFn string-based and normalize only the predicate argument. Forward accepted write objects unchanged so stale and expiry metadata reach the backend. This also fixes ordinary invalidation objects, not just SWR. Add mixed-input runtime coverage and a TypeScript semantic check of the consumer test under strictFunctionTypes, since the package build excludes specs. All formatting, lint, type checks and full tests pass. Addresses discussion_r4091698333. --- .../tag-cache/tag-cache-filter.spec.ts | 19 +++++++++++++++++++ .../overrides/tag-cache/tag-cache-filter.ts | 10 +++++++--- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/packages/cloudflare/src/api/overrides/tag-cache/tag-cache-filter.spec.ts b/packages/cloudflare/src/api/overrides/tag-cache/tag-cache-filter.spec.ts index 2e6aebd8..98134b4d 100644 --- a/packages/cloudflare/src/api/overrides/tag-cache/tag-cache-filter.spec.ts +++ b/packages/cloudflare/src/api/overrides/tag-cache/tag-cache-filter.spec.ts @@ -1,4 +1,5 @@ import { NextModeTagCache } from "@opennextjs/core/types/overrides.js"; +import ts from "typescript"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { softTagFilter, withFilter } from "./tag-cache-filter.js"; @@ -15,6 +16,24 @@ const mockedTagCache = { const filterFn = (tag: string) => tag.startsWith("valid_"); describe("withFilter", () => { + it("filters structured writes with existing string predicates without losing metadata", async () => { + const cache = withFilter({ tagCache: mockedTagCache, filterFn }); + const swr = { tag: "valid_swr", stale: 100, expire: 200 }; + const hard = { tag: "valid_hard", expire: 100 }; + await cache.writeTags(["valid_plain", swr, hard, { tag: "invalid_tag", stale: 100 }]); + expect(mockedTagCache.writeTags).toHaveBeenCalledWith(["valid_plain", swr, hard]); + }); + + it("type checks string callbacks under strictFunctionTypes", () => { + // Include the actual consumer tests: the package build intentionally excludes spec files. + const configPath = ts.findConfigFile(process.cwd(), ts.sys.fileExists, "tsconfig.json")!; + const config = ts.readConfigFile(configPath, ts.sys.readFile); + const parsed = ts.parseJsonConfigFileContent(config.config, ts.sys, process.cwd()); + const file = new URL("./tag-cache-filter.spec.ts", import.meta.url).pathname; + const program = ts.createProgram([file], { ...parsed.options, noEmit: true, strictFunctionTypes: true }); + const diagnostics = program.getSemanticDiagnostics(program.getSourceFile(file)); + expect(diagnostics.map((d) => ts.flattenDiagnosticMessageText(d.messageText, "\n"))).toEqual([]); + }); beforeEach(() => { vi.clearAllMocks(); }); diff --git a/packages/cloudflare/src/api/overrides/tag-cache/tag-cache-filter.ts b/packages/cloudflare/src/api/overrides/tag-cache/tag-cache-filter.ts index 49efb5e3..78d8332e 100644 --- a/packages/cloudflare/src/api/overrides/tag-cache/tag-cache-filter.ts +++ b/packages/cloudflare/src/api/overrides/tag-cache/tag-cache-filter.ts @@ -1,4 +1,4 @@ -import { NextModeTagCache, NextModeTagCacheWriteInput } from "@opennextjs/core/types/overrides.js"; +import type { NextModeTagCache } from "@opennextjs/core/types/overrides.js"; interface WithFilterOptions { /** @@ -10,12 +10,16 @@ interface WithFilterOptions { * Filter function that returns true if the tag should be forwarded to the underlying tag cache. * @returns true if the tag should be forwarded, false otherwise. */ - filterFn: (tag: string | NextModeTagCacheWriteInput) => boolean; + filterFn: (tag: string) => boolean; } /** * Creates a new tag cache that filters tags based on the provided filter function. + * * This is useful to remove tags that are not used by the app, this could reduce the number of requests to the underlying tag cache. + * @param options Underlying cache and string-based tag predicate. + * @return A filtering cache that preserves accepted write metadata. + * @throws When the predicate or delegated cache operation throws. */ export function withFilter({ tagCache, filterFn }: WithFilterOptions): NextModeTagCache { return { @@ -45,7 +49,7 @@ export function withFilter({ tagCache, filterFn }: WithFilterOptions): NextModeT return tagCache.hasBeenRevalidated(filteredTags, lastModified); }, writeTags: async (tags) => { - const filteredTags = tags.filter(filterFn); + const filteredTags = tags.filter((tag) => filterFn(typeof tag === "string" ? tag : tag.tag)); if (filteredTags.length === 0) { return; } From 43ede0e47441251666743de8895e6a13376705a6 Mon Sep 17 00:00:00 2001 From: Victor Berchet Date: Mon, 28 Sep 2026 11:35:37 +0200 Subject: [PATCH 35/42] fix(cloudflare): forward filtered tag staleness checks Delegate optional isStale calls through withFilter, preserving the backend receiver and entry timestamp. Do not call the backend for an empty filtered tag list or advertise support when the backend has no stale method. Add regression coverage for positive delegation, excluded tags, receiver binding, and absent support. Formatting, lint, type checks and full tests pass. Addresses discussion_r4091698274. --- .../overrides/tag-cache/tag-cache-filter.spec.ts | 13 +++++++++++++ .../src/api/overrides/tag-cache/tag-cache-filter.ts | 8 +++++++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/packages/cloudflare/src/api/overrides/tag-cache/tag-cache-filter.spec.ts b/packages/cloudflare/src/api/overrides/tag-cache/tag-cache-filter.spec.ts index 98134b4d..19374497 100644 --- a/packages/cloudflare/src/api/overrides/tag-cache/tag-cache-filter.spec.ts +++ b/packages/cloudflare/src/api/overrides/tag-cache/tag-cache-filter.spec.ts @@ -16,6 +16,19 @@ const mockedTagCache = { const filterFn = (tag: string) => tag.startsWith("valid_"); describe("withFilter", () => { + it("forwards filtered stale checks with the original timestamp and receiver", async () => { + const underlying = { ...mockedTagCache, isStale: vi.fn().mockResolvedValue(true) }; + const cache = withFilter({ tagCache: underlying, filterFn }); + expect(await cache.isStale?.(["valid_tag", "invalid_tag"], 1000)).toBe(true); + expect(underlying.isStale).toHaveBeenCalledWith(["valid_tag"], 1000); + expect(underlying.isStale.mock.contexts[0]).toBe(underlying); + expect(await cache.isStale?.(["invalid_tag"], 1000)).toBe(false); + expect(underlying.isStale).toHaveBeenCalledTimes(1); + }); + + it("does not advertise stale checks when the underlying cache has none", () => { + expect(withFilter({ tagCache: mockedTagCache, filterFn }).isStale).toBeUndefined(); + }); it("filters structured writes with existing string predicates without losing metadata", async () => { const cache = withFilter({ tagCache: mockedTagCache, filterFn }); const swr = { tag: "valid_swr", stale: 100, expire: 200 }; diff --git a/packages/cloudflare/src/api/overrides/tag-cache/tag-cache-filter.ts b/packages/cloudflare/src/api/overrides/tag-cache/tag-cache-filter.ts index 78d8332e..20bc3535 100644 --- a/packages/cloudflare/src/api/overrides/tag-cache/tag-cache-filter.ts +++ b/packages/cloudflare/src/api/overrides/tag-cache/tag-cache-filter.ts @@ -18,13 +18,19 @@ interface WithFilterOptions { * * This is useful to remove tags that are not used by the app, this could reduce the number of requests to the underlying tag cache. * @param options Underlying cache and string-based tag predicate. - * @return A filtering cache that preserves accepted write metadata. + * @return A filtering cache preserving write metadata and optional stale checks. * @throws When the predicate or delegated cache operation throws. */ export function withFilter({ tagCache, filterFn }: WithFilterOptions): NextModeTagCache { return { name: `filtered-${tagCache.name}`, mode: "nextMode", + isStale: tagCache.isStale + ? async (tags, lastModified) => { + const filteredTags = tags.filter(filterFn); + return filteredTags.length > 0 && tagCache.isStale!(filteredTags, lastModified); + } + : undefined, getLastRevalidated: async (tags) => { const filteredTags = tags.filter(filterFn); if (filteredTags.length === 0) { From 938b9f79226c325dfa8c6a7c0d33e8f9f677bcaf Mon Sep 17 00:00:00 2001 From: Victor Berchet Date: Mon, 28 Sep 2026 11:36:33 +0200 Subject: [PATCH 36/42] fix(core): scope standalone cache reads to a request Establish an OpenNext request context when a cache service invocation has no caller context. Preserve existing local contexts and waitUntil so GET hard/stale checks can share request-local DynamoDB records without leaking across invocations. Cover concurrent standalone request isolation and local context reuse. Update the test context fixture with RequestCache. Formatting, lint, workspace type checks and full tests pass. --- packages/core/src/adapters/cache-handler.ts | 6 ++++ .../tests/adapters/cache-adapter.test.ts | 30 +++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/packages/core/src/adapters/cache-handler.ts b/packages/core/src/adapters/cache-handler.ts index bb9e520c..a579890e 100644 --- a/packages/core/src/adapters/cache-handler.ts +++ b/packages/core/src/adapters/cache-handler.ts @@ -48,6 +48,7 @@ async function initializeCaches() { /** * Handles an internal cache function request. * + * Standalone invocations establish a request context; local calls reuse their caller's context. * @param event Normalized cache request. * @param options Handler execution options. * @return The normalized cache response. @@ -57,6 +58,11 @@ export async function handler( options?: OpenNextHandlerOptions ): Promise { debug("cache handler event", event); + if (!globalThis.__openNextAls.getStore()) { + return runWithOpenNextRequestContext({ isISRRevalidation: false, waitUntil: options?.waitUntil }, () => + handler(event, options) + ); + } try { await initializeCaches(); diff --git a/packages/tests-unit/tests/adapters/cache-adapter.test.ts b/packages/tests-unit/tests/adapters/cache-adapter.test.ts index 67d474eb..1bafced0 100644 --- a/packages/tests-unit/tests/adapters/cache-adapter.test.ts +++ b/packages/tests-unit/tests/adapters/cache-adapter.test.ts @@ -2,6 +2,8 @@ import { AsyncLocalStorage } from "node:async_hooks"; import { handler } from "@opennextjs/core/adapters/cache-handler"; import type { InternalEvent, InternalResult, OpenNextConfig } from "@opennextjs/core/types/open-next"; +import { runWithOpenNextRequestContext } from "@opennextjs/core/utils/promise"; +import { RequestCache } from "@opennextjs/core/utils/requestCache"; import { fromReadableStream, toReadableStream } from "@opennextjs/core/utils/stream"; import { type Mock, vi, describe, expect, it, beforeEach } from "vitest"; @@ -65,6 +67,7 @@ async function runHandler(event: InternalEvent): Promise { }, isISRRevalidation: false, writtenTags: new Set(), + requestCache: new RequestCache(), }, () => handler(event) ); @@ -94,6 +97,33 @@ describe("cache-handler", () => { }); describe("routing", () => { + it("isolates concurrent standalone cache request contexts", async () => { + const contexts: unknown[] = []; + const read = async () => { + const store = globalThis.__openNextAls.getStore()!; + contexts.push(store.requestCache); + await Promise.resolve(); + expect(globalThis.__openNextAls.getStore()).toBe(store); + expect(store.requestCache).toBeInstanceOf(RequestCache); + return null; + }; + mockIncrementalCache.get.mockImplementationOnce(read).mockImplementationOnce(read); + await Promise.all([handler(createEvent()), handler(createEvent())]); + expect(contexts).toHaveLength(2); + expect(contexts[0]).not.toBe(contexts[1]); + expect(globalThis.__openNextAls.getStore()).toBeUndefined(); + }); + + it("reuses the caller context for a local cache read", async () => { + await runWithOpenNextRequestContext({ isISRRevalidation: false }, async () => { + const store = globalThis.__openNextAls.getStore(); + mockIncrementalCache.get.mockImplementationOnce(async () => { + expect(globalThis.__openNextAls.getStore()).toBe(store); + return null; + }); + await handler(createEvent()); + }); + }); it("should return 404 for non-cache paths", async () => { const event = createEvent({ rawPath: "/other/path" }); const result = await runHandler(event); From 65bde8376aab7806bde08f92f7135e08c785bd02 Mon Sep 17 00:00:00 2001 From: Victor Berchet Date: Mon, 28 Sep 2026 11:37:45 +0200 Subject: [PATCH 37/42] fix(aws): retry unprocessed tag cache reads Distinguish DynamoDB UnprocessedKeys from confirmed missing records before populating the request cache. Retry only pending keys with bounded exponential backoff and throw after three retries rather than treating unread invalidation records as absent. Retain processed hits and misses for reuse between hard and stale checks. Test mixed partial responses, retry payloads, shared results, and retry exhaustion without poisoned negative cache entries. Formatting, lint, type checks and full tests pass. --- .../overrides/tagCache/dynamodb-nextMode.ts | 60 ++++++++++----- .../tag-cache/next-mode-batch.test.ts | 77 +++++++++++++++++++ 2 files changed, 117 insertions(+), 20 deletions(-) create mode 100644 packages/tests-unit/tests/overrides/tag-cache/next-mode-batch.test.ts diff --git a/packages/aws/src/overrides/tagCache/dynamodb-nextMode.ts b/packages/aws/src/overrides/tagCache/dynamodb-nextMode.ts index b3dacfda..5b87639d 100644 --- a/packages/aws/src/overrides/tagCache/dynamodb-nextMode.ts +++ b/packages/aws/src/overrides/tagCache/dynamodb-nextMode.ts @@ -144,8 +144,13 @@ export function cacheDynamoItems( } /** - * Fetches uncached tags from DynamoDB via BatchGetItem, populates the items - * cache (storing null for absent tags), and returns whether any tag matched. + * Fetches tag records, retrying partial batches before treating absent items as misses. + * + * @param uncachedTags Tags to query. + * @param itemsCache Request-local record cache. + * @param compute Predicate evaluated for processed records. + * @return Whether any processed record matches. + * @throws When DynamoDB fails or leaves keys unprocessed after three retries. */ async function fetchAndCacheItems( uncachedTags: string[], @@ -153,26 +158,41 @@ async function fetchAndCacheItems( compute: (item: DynamoDBItem) => boolean ): Promise { const { CACHE_DYNAMO_TABLE } = process.env; - const response = await awsFetch( - JSON.stringify({ - RequestItems: { - [CACHE_DYNAMO_TABLE ?? ""]: { - Keys: uncachedTags.map((tag) => ({ - path: { S: buildDynamoKey(tag) }, - tag: { S: buildDynamoKey(tag) }, - })), + const table = CACHE_DYNAMO_TABLE ?? ""; + let pending = [...new Set(uncachedTags)]; + let hasMatch = false; + for (let attempt = 0; attempt <= 3; attempt++) { + const response = await awsFetch( + JSON.stringify({ + RequestItems: { + [table]: { + Keys: pending.map((tag) => ({ + path: { S: buildDynamoKey(tag) }, + tag: { S: buildDynamoKey(tag) }, + })), + }, }, - }, - }), - "query" - ); - if (response.status !== 200) { - throw new RecoverableError(`Failed to query dynamo item: ${response.status}`); + }), + "query" + ); + if (response.status !== 200) { + throw new RecoverableError(`Failed to query dynamo item: ${response.status}`); + } + const { Responses, UnprocessedKeys } = (await response.json()) as { + Responses?: Record; + UnprocessedKeys?: Record; + }; + const unprocessed = new Set((UnprocessedKeys?.[table]?.Keys ?? []).map((item) => item.tag?.S)); + const processed = pending.filter((tag) => !unprocessed.has(buildDynamoKey(tag))); + const matched = cacheDynamoItems(processed, Responses?.[table] ?? [], itemsCache, compute); + hasMatch ||= matched; + pending = pending.filter((tag) => unprocessed.has(buildDynamoKey(tag))); + if (pending.length === 0) return hasMatch; + if (attempt < 3) { + await new Promise((resolve) => setTimeout(resolve, 50 * 2 ** attempt)); + } } - const { Responses } = await response.json(); - const responseItems: DynamoDBItem[] = Responses?.[CACHE_DYNAMO_TABLE ?? ""] ?? []; - - return cacheDynamoItems(uncachedTags, responseItems, itemsCache, compute); + throw new RecoverableError("DynamoDB tag read still has unprocessed keys after three retries"); } export default { diff --git a/packages/tests-unit/tests/overrides/tag-cache/next-mode-batch.test.ts b/packages/tests-unit/tests/overrides/tag-cache/next-mode-batch.test.ts new file mode 100644 index 00000000..4f6fd4cf --- /dev/null +++ b/packages/tests-unit/tests/overrides/tag-cache/next-mode-batch.test.ts @@ -0,0 +1,77 @@ +import { AsyncLocalStorage } from "node:async_hooks"; + +import cache from "@opennextjs/aws/overrides/tagCache/dynamodb-nextMode.js"; +import { runWithOpenNextRequestContext } from "@opennextjs/core/utils/promise.js"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { fetchDynamo } = vi.hoisted(() => ({ fetchDynamo: vi.fn() })); +vi.mock("@opennextjs/aws/utils/fetch.js", () => ({ customFetchClient: () => fetchDynamo })); + +describe("next-mode partial DynamoDB batches", () => { + beforeEach(() => { + vi.resetAllMocks(); + vi.useFakeTimers().setSystemTime(2000); + vi.stubEnv("AWS_ACCESS_KEY_ID", "test"); + vi.stubEnv("AWS_SECRET_ACCESS_KEY", "test"); + vi.stubEnv("CACHE_DYNAMO_TABLE", "tags"); + vi.stubEnv("NEXT_BUILD_ID", ""); + globalThis.openNextConfig = { dangerous: {} } as never; + globalThis.__openNextAls = new AsyncLocalStorage(); + }); + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllEnvs(); + }); + + it("retries only unprocessed keys and shares processed hits and misses", async () => { + const retried = { + tag: { S: "_tag/retry" }, + revalidatedAt: { N: "1500" }, + stale: { N: "1500" }, + expire: { N: "3000" }, + }; + fetchDynamo + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + Responses: { tags: [{ tag: { S: "_tag/fresh" }, revalidatedAt: { N: "500" } }] }, + UnprocessedKeys: { tags: { Keys: [{ tag: { S: "_tag/retry" }, path: { S: "_tag/retry" } }] } }, + }) + ) + ) + .mockResolvedValueOnce(new Response(JSON.stringify({ Responses: { tags: [retried] } }))); + const pending = runWithOpenNextRequestContext({ isISRRevalidation: false }, async () => { + expect(await cache.hasBeenRevalidated(["fresh", "missing", "retry"], 1000)).toBe(false); + expect(await cache.isStale(["fresh", "missing", "retry"], 1000)).toBe(true); + const records = globalThis.__openNextAls.getStore()!.requestCache.getOrCreate("ddb-nextMode:tagItems"); + expect(records.get("missing")).toBeNull(); + expect(records.get("retry")).toEqual(retried); + }); + await vi.runAllTimersAsync(); + await pending; + expect(fetchDynamo).toHaveBeenCalledTimes(2); + expect(JSON.parse(fetchDynamo.mock.calls[1][1].body).RequestItems.tags.Keys).toEqual([ + { path: { S: "_tag/retry" }, tag: { S: "_tag/retry" } }, + ]); + }); + + it("fails bounded retries without negative-caching unread tags", async () => { + fetchDynamo.mockImplementation( + async () => + new Response( + JSON.stringify({ + UnprocessedKeys: { tags: { Keys: [{ tag: { S: "_tag/retry" } }] } }, + }) + ) + ); + const pending = runWithOpenNextRequestContext({ isISRRevalidation: false }, async () => { + await expect(cache.hasBeenRevalidated(["retry"], 1000)).rejects.toThrow("after three retries"); + expect( + globalThis.__openNextAls.getStore()!.requestCache.getOrCreate("ddb-nextMode:tagItems").has("retry") + ).toBe(false); + }); + await vi.runAllTimersAsync(); + await pending; + expect(fetchDynamo).toHaveBeenCalledTimes(4); + }); +}); From f698072a3be7c7a2f0cf70c59ee6bd1a537f4db5 Mon Sep 17 00:00:00 2001 From: Victor Berchet Date: Mon, 28 Sep 2026 11:38:41 +0200 Subject: [PATCH 38/42] fix(core): inherit owning-path staleness for fetch entries Mirror the existing original-mode owning-path hard-invalidation fallback when checking tag staleness. Fetch entries without explicit hard tags now inherit an active stale window from the owning path, retaining their generation timestamp. Add paired tests proving the fallback applies without hard tags and stays disabled when an explicit hard tag is supplied. Formatting, lint, type checks and full tests pass. --- packages/core/src/adapters/cache-handler.ts | 10 ++++++++-- .../tests/adapters/cache-adapter.test.ts | 20 +++++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/packages/core/src/adapters/cache-handler.ts b/packages/core/src/adapters/cache-handler.ts index a579890e..1b3ff9df 100644 --- a/packages/core/src/adapters/cache-handler.ts +++ b/packages/core/src/adapters/cache-handler.ts @@ -117,6 +117,7 @@ export async function handler( /** * Reads a cache entry and attaches tag freshness without changing its timestamp. * + * Untagged original-mode fetch entries also inherit their owning path's freshness. * @param key Cache key. * @param cacheType Entry type. * @param additionalTags Tags supplied by the caller. @@ -162,6 +163,7 @@ async function handleGet( if (!result.shouldBypassTagCache) { let revalidated = await checkTagRevalidation(key, tags, result); + let owningPath: string | undefined; if (cacheType === "fetch" && globalThis.tagCache.mode === "original") { const hasHardTags = additionalTags.some((tag) => !tag.startsWith(SOFT_TAG_PREFIX)); @@ -170,7 +172,8 @@ async function handleGet( ); if (!revalidated && !hasHardTags && path) { - revalidated = await checkTagRevalidation(path.slice(SOFT_TAG_PREFIX.length), [], result); + owningPath = path.slice(SOFT_TAG_PREFIX.length); + revalidated = await checkTagRevalidation(owningPath, [], result); } } @@ -189,7 +192,10 @@ async function handleGet( } const lastModified = result.lastModified ?? Date.now(); - if (await isStale(key, tags, lastModified)) { + if ( + (await isStale(key, tags, lastModified)) || + (owningPath !== undefined && (await isStale(owningPath, [], lastModified))) + ) { return buildCacheGetResponse({ ...result, isStale: true }); } } diff --git a/packages/tests-unit/tests/adapters/cache-adapter.test.ts b/packages/tests-unit/tests/adapters/cache-adapter.test.ts index 1bafced0..5489f913 100644 --- a/packages/tests-unit/tests/adapters/cache-adapter.test.ts +++ b/packages/tests-unit/tests/adapters/cache-adapter.test.ts @@ -429,6 +429,26 @@ describe("cache-handler", () => { expect(result.headers["x-opennext-cache-stale"]).toBe("true"); }); + it.each([false, true])( + "inherits owning-path fetch staleness only without hard tags (hard=%s)", + async (hasHardTags) => { + mockTagCache.mode = "original"; + mockTagCache.getLastModified.mockResolvedValue(1000); + mockTagCache.isStale.mockImplementation(async (key) => key === "some-path"); + mockIncrementalCache.get.mockResolvedValueOnce({ + value: { kind: "FETCH", data: { headers: {}, body: "data", url: "https://example.com" } }, + lastModified: 1000, + }); + const result = await runHandler( + createEvent({ query: { type: "fetch", tags: `_N_T_/some-path${hasHardTags ? ",hard-tag" : ""}` } }) + ); + expect(result.statusCode).toBe(200); + expect(result.headers["x-opennext-cache-last-modified"]).toBe("1000"); + expect(result.headers["x-opennext-cache-stale"]).toBe(hasHardTags ? undefined : "true"); + expect(mockTagCache.isStale).toHaveBeenCalledTimes(hasHardTags ? 1 : 2); + } + ); + it("should return 404 when a fetch entry's owning path has been revalidated", async () => { mockTagCache.mode = "original"; mockTagCache.getLastModified.mockResolvedValueOnce(1000).mockResolvedValueOnce(-1); From 6f7de8d9dde26befe919cfdfe959faf59c4c5041 Mon Sep 17 00:00:00 2001 From: Victor Berchet Date: Thu, 1 Oct 2026 11:24:33 +0200 Subject: [PATCH 39/42] fix(cloudflare): reject unsupported tag SWR Reject duration-aware tag invalidations before D1, KV, or sharded Durable Object mutations instead of discarding stale and expiry metadata and silently performing a hard invalidation. Continue accepting plain tags and structured hard invalidations. Mark unsupported operations structurally so the cache handler can return HTTP 501 across bundle boundaries, expose the reason through the cache transport, and surface it from local, fetch, and Cloudflare service clients. Document the limitation and cover each backend and transport. Formatting, lint, type checks, full tests, and focused tests pass. --- packages/cloudflare/README.md | 4 +++ .../api/overrides/cache/service-cache.spec.ts | 9 ++++++ .../src/api/overrides/cache/service-cache.ts | 6 ++-- .../cloudflare/src/api/overrides/internal.ts | 19 ++++++++++-- .../tag-cache/d1-next-tag-cache.spec.ts | 13 ++++++++ .../tag-cache/do-sharded-tag-cache.spec.ts | 15 +++++++++ .../tag-cache/kv-next-tag-cache.spec.ts | 13 ++++++++ packages/core/src/adapters/cache-handler.ts | 7 ++++- packages/core/src/overrides/cache/fetch.ts | 6 ++-- packages/core/src/overrides/cache/local.ts | 3 +- packages/core/src/utils/error.ts | 31 +++++++++++++++++++ .../tests/adapters/cache-adapter.test.ts | 17 ++++++++++ .../tests/overrides/cache/fetch.test.ts | 11 +++++++ .../tests/overrides/cache/local.test.ts | 9 ++++++ 14 files changed, 155 insertions(+), 8 deletions(-) diff --git a/packages/cloudflare/README.md b/packages/cloudflare/README.md index cc1f5e72..27ad3ebb 100644 --- a/packages/cloudflare/README.md +++ b/packages/cloudflare/README.md @@ -55,3 +55,7 @@ Deploy your application to production with the following: # or bun opennextjs-cloudflare build && bun opennextjs-cloudflare deploy ``` + +## Cache compatibility + +The D1, KV, and sharded Durable Object tag-cache implementations support immediate tag invalidation. They do not support duration-aware stale-while-revalidate tag invalidation; calls such as `revalidateTag(tag, { expire })` fail explicitly instead of silently becoming immediate invalidations. diff --git a/packages/cloudflare/src/api/overrides/cache/service-cache.spec.ts b/packages/cloudflare/src/api/overrides/cache/service-cache.spec.ts index e67f433c..71e1484a 100644 --- a/packages/cloudflare/src/api/overrides/cache/service-cache.spec.ts +++ b/packages/cloudflare/src/api/overrides/cache/service-cache.spec.ts @@ -134,4 +134,13 @@ describe("serviceCache", () => { "Failed to revalidate cache tags: cache handler returned 502" ); }); + + it("surfaces an exposed cache-handler error", async () => { + fetchMock.mockResolvedValue( + new Response(null, { status: 501, headers: { "x-opennext-cache-error": "SWR is unsupported" } }) + ); + await expect(serviceCache.revalidateTags(["tag"], { expire: 30 })).rejects.toThrow( + "Failed to revalidate cache tags: SWR is unsupported" + ); + }); }); diff --git a/packages/cloudflare/src/api/overrides/cache/service-cache.ts b/packages/cloudflare/src/api/overrides/cache/service-cache.ts index 85a41018..17c02cd2 100644 --- a/packages/cloudflare/src/api/overrides/cache/service-cache.ts +++ b/packages/cloudflare/src/api/overrides/cache/service-cache.ts @@ -56,9 +56,11 @@ function getCacheUrl(key: string, cacheType?: CacheEntryType, additionalTags?: s * @return Nothing. * @throws When the cache handler returns a non-success status. */ -function ensureResponseOk(response: Pick, operation: string): void { +function ensureResponseOk(response: Pick, operation: string): void { if (!response.ok) { - throw new Error(`Failed to ${operation}: cache handler returned ${response.status}`); + const reason = + response.headers.get("x-opennext-cache-error") ?? `cache handler returned ${response.status}`; + throw new Error(`Failed to ${operation}: ${reason}`); } } diff --git a/packages/cloudflare/src/api/overrides/internal.ts b/packages/cloudflare/src/api/overrides/internal.ts index 9b6c6d70..093ed629 100644 --- a/packages/cloudflare/src/api/overrides/internal.ts +++ b/packages/cloudflare/src/api/overrides/internal.ts @@ -24,6 +24,10 @@ export const FALLBACK_BUILD_ID = "no-build-id"; export const DEFAULT_PREFIX = "incremental-cache"; +class UnsupportedTagCacheOperationError extends Error { + readonly __openNextUnsupportedOperation = true; +} + export type KeyOptions = { cacheType?: CacheEntryType; prefix: string | undefined; @@ -37,10 +41,21 @@ export function computeCacheKey(key: string, options: KeyOptions) { } /** - * `writeTags` accepts either plain tag names or objects carrying the `stale`/`expire` durations. - * The Cloudflare tag caches do not support durations, they only need the names. + * Extracts tag names accepted by Cloudflare tag caches. + * + * Structured hard invalidations are accepted, but duration-aware SWR requires storage metadata + * that the D1, KV, and sharded Durable Object implementations do not persist. + * + * @param tags Plain or structured tag writes. + * @return Tag names for a hard invalidation. + * @throws When a write requests stale-while-revalidate behavior. */ export function toTagNames(tags: (string | NextModeTagCacheWriteInput)[]): string[] { + if (tags.some((tag) => typeof tag !== "string" && tag.stale !== undefined)) { + throw new UnsupportedTagCacheOperationError( + "Cloudflare D1, KV, and sharded Durable Object tag caches do not support stale-while-revalidate tag invalidation" + ); + } return tags.map((tag) => (typeof tag === "string" ? tag : tag.tag)); } diff --git a/packages/cloudflare/src/api/overrides/tag-cache/d1-next-tag-cache.spec.ts b/packages/cloudflare/src/api/overrides/tag-cache/d1-next-tag-cache.spec.ts index 9bb43721..37f78507 100644 --- a/packages/cloudflare/src/api/overrides/tag-cache/d1-next-tag-cache.spec.ts +++ b/packages/cloudflare/src/api/overrides/tag-cache/d1-next-tag-cache.spec.ts @@ -227,6 +227,19 @@ describe("D1NextModeTagCache", () => { }); describe("writeTags", () => { + it("rejects SWR metadata before writing or purging", async () => { + await expect(tagCache.writeTags([{ tag: "tag1", stale: 100, expire: 200 }])).rejects.toThrow( + "do not support stale-while-revalidate" + ); + expect(mockBatch).not.toHaveBeenCalled(); + expect(purgeCacheByTags).not.toHaveBeenCalled(); + }); + + it("accepts structured hard invalidations", async () => { + await tagCache.writeTags([{ tag: "tag1", expire: 100 }]); + expect(mockBatch).toHaveBeenCalledOnce(); + }); + it("should do nothing when cache is disabled", async () => { ( globalThis as { openNextConfig?: { dangerous?: { disableTagCache?: boolean } } } diff --git a/packages/cloudflare/src/api/overrides/tag-cache/do-sharded-tag-cache.spec.ts b/packages/cloudflare/src/api/overrides/tag-cache/do-sharded-tag-cache.spec.ts index 15ea2a49..d7c397f9 100644 --- a/packages/cloudflare/src/api/overrides/tag-cache/do-sharded-tag-cache.spec.ts +++ b/packages/cloudflare/src/api/overrides/tag-cache/do-sharded-tag-cache.spec.ts @@ -300,6 +300,21 @@ describe("DOShardedTagCache", () => { expect(writeTagsMock).not.toHaveBeenCalled(); }); + it("rejects SWR metadata before writing", async () => { + const cache = shardedDOTagCache(); + await expect(cache.writeTags([{ tag: "tag1", stale: 100, expire: 200 }])).rejects.toThrow( + "do not support stale-while-revalidate" + ); + expect(idFromNameMock).not.toHaveBeenCalled(); + expect(writeTagsMock).not.toHaveBeenCalled(); + }); + + it("accepts structured hard invalidations", async () => { + const cache = shardedDOTagCache(); + await cache.writeTags([{ tag: "tag1", expire: 100 }]); + expect(writeTagsMock).toHaveBeenCalled(); + }); + it("should write the tags to the cache", async () => { const cache = shardedDOTagCache(); await cache.writeTags(["tag1"]); diff --git a/packages/cloudflare/src/api/overrides/tag-cache/kv-next-tag-cache.spec.ts b/packages/cloudflare/src/api/overrides/tag-cache/kv-next-tag-cache.spec.ts index 7fd8836e..6ebb91a1 100644 --- a/packages/cloudflare/src/api/overrides/tag-cache/kv-next-tag-cache.spec.ts +++ b/packages/cloudflare/src/api/overrides/tag-cache/kv-next-tag-cache.spec.ts @@ -204,6 +204,19 @@ describe("KVNextModeTagCache", () => { }); describe("writeTags", () => { + it("rejects SWR metadata before writing or purging", async () => { + await expect(tagCache.writeTags([{ tag: "tag1", stale: 100, expire: 200 }])).rejects.toThrow( + "do not support stale-while-revalidate" + ); + expect(mockPut).not.toHaveBeenCalled(); + expect(purgeCacheByTags).not.toHaveBeenCalled(); + }); + + it("accepts structured hard invalidations", async () => { + await tagCache.writeTags([{ tag: "tag1", expire: 100 }]); + expect(mockPut).toHaveBeenCalledOnce(); + }); + it("should do nothing when cache is disabled", async () => { ( globalThis as { openNextConfig?: { dangerous?: { disableTagCache?: boolean } } } diff --git a/packages/core/src/adapters/cache-handler.ts b/packages/core/src/adapters/cache-handler.ts index 1b3ff9df..7514b3ce 100644 --- a/packages/core/src/adapters/cache-handler.ts +++ b/packages/core/src/adapters/cache-handler.ts @@ -14,6 +14,7 @@ import type { import { resolveCdnInvalidation, resolveIncrementalCache, resolveTagCache } from "../core/resolve.js"; import { getTagsFromValue, isStale, writeTags } from "../utils/cache.js"; +import { isUnsupportedOperationError } from "../utils/error.js"; import { runWithOpenNextRequestContext } from "../utils/promise.js"; import { fromReadableStream, toReadableStream } from "../utils/stream.js"; @@ -466,6 +467,9 @@ async function handleRevalidateTags(body?: ReadableStream): Promise< return buildJsonResponse({ revalidated: tags }, 200); } catch (e) { error("Failed to revalidate tags", e); + if (isUnsupportedOperationError(e)) { + return buildErrorResponse(e.message, 501, true); + } return buildErrorResponse("Failed to revalidate tags", 500); } } @@ -654,7 +658,7 @@ function buildJsonResponse(data: unknown, statusCode: number): InternalResult { }; } -function buildErrorResponse(message: string, statusCode: number): InternalResult { +function buildErrorResponse(message: string, statusCode: number, exposeMessage = false): InternalResult { debug(message, statusCode); const body = JSON.stringify({ error: message }); return { @@ -665,6 +669,7 @@ function buildErrorResponse(message: string, statusCode: number): InternalResult headers: { "Content-Type": "application/json", "Cache-Control": "no-store", + ...(exposeMessage ? { "x-opennext-cache-error": message } : {}), }, }; } diff --git a/packages/core/src/overrides/cache/fetch.ts b/packages/core/src/overrides/cache/fetch.ts index d3c7e515..0d7dcb1b 100644 --- a/packages/core/src/overrides/cache/fetch.ts +++ b/packages/core/src/overrides/cache/fetch.ts @@ -10,9 +10,11 @@ const CACHE_URL = process.env.OPEN_NEXT_CACHE_URL ?? ""; * @param operation Mutation being performed. * @throws When the cache handler returns a non-success status. */ -function ensureResponseOk(response: Pick, operation: string): void { +function ensureResponseOk(response: Pick, operation: string): void { if (!response.ok) { - throw new Error(`Failed to ${operation}: cache handler returned ${response.status}`); + const reason = + response.headers.get("x-opennext-cache-error") ?? `cache handler returned ${response.status}`; + throw new Error(`Failed to ${operation}: ${reason}`); } } diff --git a/packages/core/src/overrides/cache/local.ts b/packages/core/src/overrides/cache/local.ts index c779c870..de9225b8 100644 --- a/packages/core/src/overrides/cache/local.ts +++ b/packages/core/src/overrides/cache/local.ts @@ -32,7 +32,8 @@ async function getHandler() { */ 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 reason = result.headers["x-opennext-cache-error"] ?? `cache handler returned ${result.statusCode}`; + throw new Error(`Failed to ${operation}: ${reason}`); } } diff --git a/packages/core/src/utils/error.ts b/packages/core/src/utils/error.ts index da22733b..e325074a 100644 --- a/packages/core/src/utils/error.ts +++ b/packages/core/src/utils/error.ts @@ -40,6 +40,37 @@ export class FatalError extends Error implements BaseOpenNextError { } } +/** + * Reports an operation that the configured adapter cannot implement. + * + * @param message Description of the unsupported operation. + * @return An error that must be surfaced to the caller. + */ +export class UnsupportedOperationError extends FatalError { + readonly __openNextUnsupportedOperation = true; + + constructor(message: string) { + super(message); + this.name = "UnsupportedOperationError"; + } +} + +/** + * Identifies unsupported-operation errors across package and bundle boundaries. + * + * @param error Value to inspect. + * @return Whether the value carries the OpenNext unsupported-operation marker. + */ +export function isUnsupportedOperationError( + error: unknown +): error is Error & { readonly __openNextUnsupportedOperation: true } { + return ( + error instanceof Error && + "__openNextUnsupportedOperation" in error && + error.__openNextUnsupportedOperation === true + ); +} + export function isOpenNextError(e: unknown): e is BaseOpenNextError & Error { try { return e !== null && typeof e === "object" && "__openNextInternal" in e; diff --git a/packages/tests-unit/tests/adapters/cache-adapter.test.ts b/packages/tests-unit/tests/adapters/cache-adapter.test.ts index 5489f913..f6f16a21 100644 --- a/packages/tests-unit/tests/adapters/cache-adapter.test.ts +++ b/packages/tests-unit/tests/adapters/cache-adapter.test.ts @@ -2,6 +2,7 @@ import { AsyncLocalStorage } from "node:async_hooks"; import { handler } from "@opennextjs/core/adapters/cache-handler"; import type { InternalEvent, InternalResult, OpenNextConfig } from "@opennextjs/core/types/open-next"; +import { UnsupportedOperationError } from "@opennextjs/core/utils/error"; import { runWithOpenNextRequestContext } from "@opennextjs/core/utils/promise"; import { RequestCache } from "@opennextjs/core/utils/requestCache"; import { fromReadableStream, toReadableStream } from "@opennextjs/core/utils/stream"; @@ -866,6 +867,22 @@ describe("cache-handler", () => { expect(result.statusCode).toBe(500); }); + it("should expose unsupported tag revalidation operations", async () => { + mockTagCache.mode = "nextMode"; + mockTagCache.writeTags.mockRejectedValueOnce( + new UnsupportedOperationError("Configured tag cache does not support SWR") + ); + const result = await runHandler( + createEvent({ + rawPath: "/cache/revalidate-tags", + method: "POST", + body: toReadableStream(JSON.stringify({ tags: ["tag1"], durations: { expire: 30 } })), + }) + ); + expect(result.statusCode).toBe(501); + expect(result.headers["x-opennext-cache-error"]).toBe("Configured tag cache does not support SWR"); + }); + it("should convert nextMode durations to stale and expiry timestamps", async () => { mockTagCache.mode = "nextMode"; vi.useFakeTimers().setSystemTime(100_000); diff --git a/packages/tests-unit/tests/overrides/cache/fetch.test.ts b/packages/tests-unit/tests/overrides/cache/fetch.test.ts index dabfebb5..f151f5ce 100644 --- a/packages/tests-unit/tests/overrides/cache/fetch.test.ts +++ b/packages/tests-unit/tests/overrides/cache/fetch.test.ts @@ -191,6 +191,17 @@ describe("fetch cache", () => { "Failed to set cache entry: cache handler returned 500" ); }); + + it("should surface an exposed cache-handler error", async () => { + mockFetch({ + headers: { "x-opennext-cache-error": "SWR is unsupported" }, + body: "", + status: 501, + }); + await expect(fetchCache.revalidateTags(["tag"], { expire: 30 })).rejects.toThrow( + "Failed to revalidate cache tags: SWR is unsupported" + ); + }); }); describe("delete", () => { diff --git a/packages/tests-unit/tests/overrides/cache/local.test.ts b/packages/tests-unit/tests/overrides/cache/local.test.ts index 5debe1a4..0dbc9a65 100644 --- a/packages/tests-unit/tests/overrides/cache/local.test.ts +++ b/packages/tests-unit/tests/overrides/cache/local.test.ts @@ -203,6 +203,15 @@ describe("local cache", () => { "Failed to set cache entry: cache handler returned 500" ); }); + + it("should surface an exposed cache-handler error", async () => { + mockHandler.mockResolvedValue( + createMockResult({ statusCode: 501, headers: { "x-opennext-cache-error": "SWR is unsupported" } }) + ); + await expect(localCache.revalidateTags(["tag"], { expire: 30 })).rejects.toThrow( + "Failed to revalidate cache tags: SWR is unsupported" + ); + }); }); describe("delete", () => { From cd54cf3b3c0f522aaf2d004625be919499ea5703 Mon Sep 17 00:00:00 2001 From: Victor Berchet Date: Thu, 1 Oct 2026 11:26:33 +0200 Subject: [PATCH 40/42] style: format tag staleness regression test Apply the repository formatter to the installed-Next tag staleness regression added during review. This is formatting-only and fixes the remote fmt check; runtime behavior and assertions are unchanged. --- .../build/patch/patches/patchTagStaleness.test.ts | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/packages/tests-unit/tests/build/patch/patches/patchTagStaleness.test.ts b/packages/tests-unit/tests/build/patch/patches/patchTagStaleness.test.ts index cda3385e..5e563e3d 100644 --- a/packages/tests-unit/tests/build/patch/patches/patchTagStaleness.test.ts +++ b/packages/tests-unit/tests/build/patch/patches/patchTagStaleness.test.ts @@ -61,13 +61,11 @@ describe("tag staleness in the installed Next incremental cache", () => { version: 4, }) as never, }); - const get = vi - .fn() - .mockResolvedValue({ - lastModified: Date.now(), - isStale: true, - value: { kind: "FETCH", data: { body: "data" }, revalidate: 3600 }, - }); + const get = vi.fn().mockResolvedValue({ + lastModified: Date.now(), + isStale: true, + value: { kind: "FETCH", data: { body: "data" }, revalidate: 3600 }, + }); cache.cacheHandler = { get } as never; expect(await cache.get("fetch-key", { kind: "FETCH", revalidate: 3600 } as never)).toMatchObject({ isStale: true, From 979123e58cdbf147580b7095507aeefca961f90d Mon Sep 17 00:00:00 2001 From: Victor Berchet Date: Thu, 1 Oct 2026 11:36:17 +0200 Subject: [PATCH 41/42] fix(cache): surface unsupported SWR through Next Preserve the unsupported-operation marker when local, fetch, and Cloudflare service cache clients reconstruct cache-handler failures. Rethrow marked failures from the incremental and composable Next adapters while continuing to tolerate ordinary cache errors. Update the core max-profile E2E to wait for background SWR regeneration and the Cloudflare E2E to verify unsupported SWR leaves cached content unchanged rather than silently hard-invalidating it. Add marker propagation tests. Code checks, full unit tests, selected builds, and focused core/Cloudflare E2E tests pass. --- .../e2e/experimental/e2e/use-cache.test.ts | 8 ++------ .../src/api/overrides/cache/service-cache.spec.ts | 6 +++--- .../src/api/overrides/cache/service-cache.ts | 11 +++++++++-- packages/core/src/adapters/cache.ts | 4 ++++ packages/core/src/adapters/composable-cache.ts | 4 ++++ packages/core/src/overrides/cache/fetch.ts | 8 ++++++-- packages/core/src/overrides/cache/local.ts | 7 ++++++- .../tests-e2e/tests/experimental/use-cache.test.ts | 11 +++++++---- packages/tests-unit/tests/adapters/cache.test.ts | 8 ++++++++ .../tests/adapters/composable-cache.test.ts | 8 ++++++++ .../tests-unit/tests/overrides/cache/fetch.test.ts | 6 +++--- .../tests-unit/tests/overrides/cache/local.test.ts | 6 +++--- 12 files changed, 63 insertions(+), 24 deletions(-) diff --git a/examples-cloudflare/e2e/experimental/e2e/use-cache.test.ts b/examples-cloudflare/e2e/experimental/e2e/use-cache.test.ts index 3384f15d..8aac623e 100644 --- a/examples-cloudflare/e2e/experimental/e2e/use-cache.test.ts +++ b/examples-cloudflare/e2e/experimental/e2e/use-cache.test.ts @@ -26,21 +26,17 @@ test.describe("Composable Cache", () => { expect(fullyCachedText).toEqual(initialFullyCachedText); }); - test("revalidateTag should work for fullyCached component", async ({ page, request }) => { + test("revalidateTag with SWR should reject unsupported tag caches", async ({ page, request }) => { await page.goto("/use-cache/ssr"); const fullyCachedElt = page.getByTestId("fully-cached-with-tag"); await expect(fullyCachedElt).toBeVisible(); - const initialFullyCachedText = await fullyCachedElt.textContent(); const resp = await request.get("/api/revalidate"); expect(resp.status()).toEqual(200); - expect(await resp.text()).toEqual("DONE"); await page.reload(); - await expect(fullyCachedElt).toBeVisible(); - const newFullyCachedText = await fullyCachedElt.textContent(); - expect(newFullyCachedText).not.toEqual(initialFullyCachedText); + await expect(fullyCachedElt).toHaveText(initialFullyCachedText ?? ""); }); //TODO: figure out why it doesn't work in ISR (my guess is on our patch not working anymore in 16.1, but need investigation) diff --git a/packages/cloudflare/src/api/overrides/cache/service-cache.spec.ts b/packages/cloudflare/src/api/overrides/cache/service-cache.spec.ts index 71e1484a..bf677c5e 100644 --- a/packages/cloudflare/src/api/overrides/cache/service-cache.spec.ts +++ b/packages/cloudflare/src/api/overrides/cache/service-cache.spec.ts @@ -139,8 +139,8 @@ describe("serviceCache", () => { fetchMock.mockResolvedValue( new Response(null, { status: 501, headers: { "x-opennext-cache-error": "SWR is unsupported" } }) ); - await expect(serviceCache.revalidateTags(["tag"], { expire: 30 })).rejects.toThrow( - "Failed to revalidate cache tags: SWR is unsupported" - ); + const operation = serviceCache.revalidateTags(["tag"], { expire: 30 }); + await expect(operation).rejects.toThrow("Failed to revalidate cache tags: SWR is unsupported"); + await expect(operation).rejects.toMatchObject({ __openNextUnsupportedOperation: true }); }); }); diff --git a/packages/cloudflare/src/api/overrides/cache/service-cache.ts b/packages/cloudflare/src/api/overrides/cache/service-cache.ts index 17c02cd2..fa9f8ce0 100644 --- a/packages/cloudflare/src/api/overrides/cache/service-cache.ts +++ b/packages/cloudflare/src/api/overrides/cache/service-cache.ts @@ -12,6 +12,10 @@ export const BINDING_NAME = "NEXT_CACHE_SERVICE"; */ const CACHE_ORIGIN = "https://cache.opennext"; +class UnsupportedCacheServiceOperationError extends Error { + readonly __openNextUnsupportedOperation = true; +} + /** * Returns the cache handler bound to `NEXT_CACHE_SERVICE`. * @@ -58,8 +62,11 @@ function getCacheUrl(key: string, cacheType?: CacheEntryType, additionalTags?: s */ function ensureResponseOk(response: Pick, operation: string): void { if (!response.ok) { - const reason = - response.headers.get("x-opennext-cache-error") ?? `cache handler returned ${response.status}`; + const exposedReason = response.headers.get("x-opennext-cache-error"); + if (exposedReason) { + throw new UnsupportedCacheServiceOperationError(`Failed to ${operation}: ${exposedReason}`); + } + const reason = `cache handler returned ${response.status}`; throw new Error(`Failed to ${operation}: ${reason}`); } } diff --git a/packages/core/src/adapters/cache.ts b/packages/core/src/adapters/cache.ts index 3c7b5dfb..7fad829a 100644 --- a/packages/core/src/adapters/cache.ts +++ b/packages/core/src/adapters/cache.ts @@ -1,6 +1,7 @@ import type { CacheHandlerValue, IncrementalCacheContext, IncrementalCacheValue } from "@/types/cache"; import { isBinaryContentType } from "../utils/binary"; +import { isUnsupportedOperationError } from "../utils/error"; import { debug, error, warn } from "./logger"; @@ -299,6 +300,9 @@ export default class Cache { await globalThis.cache.revalidateTags(_tags); } } catch (e) { + if (isUnsupportedOperationError(e)) { + throw e; + } error("Failed to revalidate tag", e); } } diff --git a/packages/core/src/adapters/composable-cache.ts b/packages/core/src/adapters/composable-cache.ts index a635a908..73ba806a 100644 --- a/packages/core/src/adapters/composable-cache.ts +++ b/packages/core/src/adapters/composable-cache.ts @@ -1,5 +1,6 @@ import type { ComposableCacheEntry, ComposableCacheHandler } from "@/types/cache"; import type { CacheValue } from "@/types/overrides"; +import { isUnsupportedOperationError } from "@/utils/error"; import { fromReadableStream, toReadableStream } from "@/utils/stream"; import { debug } from "./logger"; @@ -105,6 +106,9 @@ export default { try { await globalThis.cache.revalidateTags(tags, durations); } catch (e) { + if (isUnsupportedOperationError(e)) { + throw e; + } debug("Failed to update tags", e); } }, diff --git a/packages/core/src/overrides/cache/fetch.ts b/packages/core/src/overrides/cache/fetch.ts index 0d7dcb1b..993637ac 100644 --- a/packages/core/src/overrides/cache/fetch.ts +++ b/packages/core/src/overrides/cache/fetch.ts @@ -1,5 +1,6 @@ import type { Cache } from "@/types/overrides"; import { parseCacheGetResponse } from "@/utils/cache-get"; +import { UnsupportedOperationError } from "@/utils/error"; const CACHE_URL = process.env.OPEN_NEXT_CACHE_URL ?? ""; @@ -12,8 +13,11 @@ const CACHE_URL = process.env.OPEN_NEXT_CACHE_URL ?? ""; */ function ensureResponseOk(response: Pick, operation: string): void { if (!response.ok) { - const reason = - response.headers.get("x-opennext-cache-error") ?? `cache handler returned ${response.status}`; + const exposedReason = response.headers.get("x-opennext-cache-error"); + if (exposedReason) { + throw new UnsupportedOperationError(`Failed to ${operation}: ${exposedReason}`); + } + const reason = `cache handler returned ${response.status}`; throw new Error(`Failed to ${operation}: ${reason}`); } } diff --git a/packages/core/src/overrides/cache/local.ts b/packages/core/src/overrides/cache/local.ts index de9225b8..a4b1f20c 100644 --- a/packages/core/src/overrides/cache/local.ts +++ b/packages/core/src/overrides/cache/local.ts @@ -3,6 +3,7 @@ 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 { UnsupportedOperationError } from "@/utils/error"; import { getMonorepoRelativePath } from "@/utils/normalize-path"; import { fromReadableStream, toReadableStream } from "@/utils/stream"; @@ -32,7 +33,11 @@ async function getHandler() { */ function ensureResultOk(result: InternalResult, operation: string): void { if (result.statusCode < 200 || result.statusCode >= 300) { - const reason = result.headers["x-opennext-cache-error"] ?? `cache handler returned ${result.statusCode}`; + const exposedReason = result.headers["x-opennext-cache-error"]; + if (exposedReason) { + throw new UnsupportedOperationError(`Failed to ${operation}: ${exposedReason}`); + } + const reason = `cache handler returned ${result.statusCode}`; throw new Error(`Failed to ${operation}: ${reason}`); } } diff --git a/packages/tests-e2e/tests/experimental/use-cache.test.ts b/packages/tests-e2e/tests/experimental/use-cache.test.ts index c5a31679..a66ae92b 100644 --- a/packages/tests-e2e/tests/experimental/use-cache.test.ts +++ b/packages/tests-e2e/tests/experimental/use-cache.test.ts @@ -37,10 +37,13 @@ test.describe("Composable Cache", () => { expect(resp.status()).toEqual(200); expect(await resp.text()).toEqual("DONE"); - await page.reload(); - await expect(fullyCachedElt).toBeVisible(); - const newFullyCachedText = await fullyCachedElt.textContent(); - expect(newFullyCachedText).not.toEqual(initialFullyCachedText); + await expect + .poll(async () => { + await page.reload(); + await expect(fullyCachedElt).toBeVisible(); + return fullyCachedElt.textContent(); + }) + .not.toEqual(initialFullyCachedText); }); //TODO: figure out why it doesn't work in ISR (my guess is on our patch not working anymore in 16.1, but need investigation) diff --git a/packages/tests-unit/tests/adapters/cache.test.ts b/packages/tests-unit/tests/adapters/cache.test.ts index 88ac61a2..4195feca 100644 --- a/packages/tests-unit/tests/adapters/cache.test.ts +++ b/packages/tests-unit/tests/adapters/cache.test.ts @@ -585,5 +585,13 @@ describe("CacheHandler", () => { await expect(instance.revalidateTag("tag")).resolves.not.toThrow(); }); + + it("Should surface unsupported revalidation operations", async () => { + const error = Object.assign(new Error("SWR is unsupported"), { + __openNextUnsupportedOperation: true as const, + }); + cache.revalidateTags.mockRejectedValueOnce(error); + await expect(instance.revalidateTag("tag", { expire: 30 })).rejects.toThrow("SWR is unsupported"); + }); }); }); diff --git a/packages/tests-unit/tests/adapters/composable-cache.test.ts b/packages/tests-unit/tests/adapters/composable-cache.test.ts index 92b8be23..484cce40 100644 --- a/packages/tests-unit/tests/adapters/composable-cache.test.ts +++ b/packages/tests-unit/tests/adapters/composable-cache.test.ts @@ -1,4 +1,5 @@ import ComposableCache from "@opennextjs/core/adapters/composable-cache"; +import { UnsupportedOperationError } from "@opennextjs/core/utils/error"; import { fromReadableStream, toReadableStream } from "@opennextjs/core/utils/stream"; import { vi } from "vitest"; @@ -340,5 +341,12 @@ describe("Composable cache handler", () => { await expect(ComposableCache.updateTags(["tag1"])).resolves.not.toThrow(); }); + + it("should surface unsupported cache operations", async () => { + cache.revalidateTags.mockRejectedValueOnce(new UnsupportedOperationError("SWR is unsupported")); + await expect(ComposableCache.updateTags(["tag1"], { expire: 30 })).rejects.toThrow( + "SWR is unsupported" + ); + }); }); }); diff --git a/packages/tests-unit/tests/overrides/cache/fetch.test.ts b/packages/tests-unit/tests/overrides/cache/fetch.test.ts index f151f5ce..a9794bf8 100644 --- a/packages/tests-unit/tests/overrides/cache/fetch.test.ts +++ b/packages/tests-unit/tests/overrides/cache/fetch.test.ts @@ -198,9 +198,9 @@ describe("fetch cache", () => { body: "", status: 501, }); - await expect(fetchCache.revalidateTags(["tag"], { expire: 30 })).rejects.toThrow( - "Failed to revalidate cache tags: SWR is unsupported" - ); + const operation = fetchCache.revalidateTags(["tag"], { expire: 30 }); + await expect(operation).rejects.toThrow("Failed to revalidate cache tags: SWR is unsupported"); + await expect(operation).rejects.toMatchObject({ __openNextUnsupportedOperation: true }); }); }); diff --git a/packages/tests-unit/tests/overrides/cache/local.test.ts b/packages/tests-unit/tests/overrides/cache/local.test.ts index 0dbc9a65..8c81718a 100644 --- a/packages/tests-unit/tests/overrides/cache/local.test.ts +++ b/packages/tests-unit/tests/overrides/cache/local.test.ts @@ -208,9 +208,9 @@ describe("local cache", () => { mockHandler.mockResolvedValue( createMockResult({ statusCode: 501, headers: { "x-opennext-cache-error": "SWR is unsupported" } }) ); - await expect(localCache.revalidateTags(["tag"], { expire: 30 })).rejects.toThrow( - "Failed to revalidate cache tags: SWR is unsupported" - ); + const operation = localCache.revalidateTags(["tag"], { expire: 30 }); + await expect(operation).rejects.toThrow("Failed to revalidate cache tags: SWR is unsupported"); + await expect(operation).rejects.toMatchObject({ __openNextUnsupportedOperation: true }); }); }); From 3f25aacc58738d3615630d8d6c89037128050573 Mon Sep 17 00:00:00 2001 From: Victor Berchet Date: Thu, 1 Oct 2026 11:43:10 +0200 Subject: [PATCH 42/42] fix(cache): keep zero-expiry invalidations hard Treat an explicit expire duration of zero as immediate hard invalidation and omit stale metadata. This preserves the supported structured hard-write shape for Cloudflare tag caches while positive and indefinite durations remain SWR and are rejected there. Cover both next and original tag-cache modes. Code checks, full unit tests, and the focused Cloudflare revalidateTag/after E2E tests pass. --- packages/core/src/adapters/cache-handler.ts | 15 ++++++++----- .../tests/adapters/cache-adapter.test.ts | 22 +++++++++++++++++++ 2 files changed, 31 insertions(+), 6 deletions(-) diff --git a/packages/core/src/adapters/cache-handler.ts b/packages/core/src/adapters/cache-handler.ts index 7514b3ce..6ba05f2b 100644 --- a/packages/core/src/adapters/cache-handler.ts +++ b/packages/core/src/adapters/cache-handler.ts @@ -368,18 +368,21 @@ async function handleRevalidateTags(body?: ReadableStream): Promise< } durations = expire === undefined ? {} : { expire }; } + const isStaleWhileRevalidate = durations !== undefined && durations.expire !== 0; try { await runWithOpenNextRequestContext({ isISRRevalidation: false }, async () => { const now = Date.now(); + const swrExpire = + isStaleWhileRevalidate && durations?.expire !== undefined ? now + durations.expire * 1000 : undefined; if (globalThis.tagCache.mode === "nextMode") { const paths = (await globalThis.tagCache.getPathsByTags?.(tags)) ?? []; const tagsToWrite = tags.map((tag) => - durations + isStaleWhileRevalidate ? { tag, stale: now, - expire: durations.expire === undefined ? undefined : now + durations.expire * 1000, + expire: swrExpire, } : { tag, expire: now } ); @@ -408,12 +411,12 @@ async function handleRevalidateTags(body?: ReadableStream): Promise< const paths = await globalThis.tagCache.getByTag(tag); debug("Items", paths); const toInsert = paths.map((path) => - durations + isStaleWhileRevalidate ? { path, tag, stale: now, - expire: durations.expire === undefined ? undefined : now + durations.expire * 1000, + expire: swrExpire, } : { path, tag, expire: now } ); @@ -427,12 +430,12 @@ async function handleRevalidateTags(body?: ReadableStream): Promise< debug({ hardTag, _paths }); toInsert.push( ..._paths.map((path) => - durations + isStaleWhileRevalidate ? { path, tag: hardTag, stale: now, - expire: durations.expire === undefined ? undefined : now + durations.expire * 1000, + expire: swrExpire, } : { path, tag: hardTag, expire: now } ) diff --git a/packages/tests-unit/tests/adapters/cache-adapter.test.ts b/packages/tests-unit/tests/adapters/cache-adapter.test.ts index f6f16a21..75510952 100644 --- a/packages/tests-unit/tests/adapters/cache-adapter.test.ts +++ b/packages/tests-unit/tests/adapters/cache-adapter.test.ts @@ -898,6 +898,28 @@ describe("cache-handler", () => { vi.useRealTimers(); }); + it.each(["nextMode", "original"] as const)( + "should preserve zero-expiry hard invalidation in %s", + async (mode) => { + mockTagCache.mode = mode; + mockTagCache.getByTag.mockResolvedValue(["/path1"]); + vi.useFakeTimers().setSystemTime(100_000); + await runHandler( + createEvent({ + rawPath: "/cache/revalidate-tags", + method: "POST", + body: toReadableStream(JSON.stringify({ tags: ["tag1"], durations: { expire: 0 } })), + }) + ); + expect(mockTagCache.writeTags).toHaveBeenCalledWith( + mode === "nextMode" + ? [{ tag: "tag1", expire: 100_000 }] + : [{ path: "/path1", tag: "tag1", expire: 100_000 }] + ); + vi.useRealTimers(); + } + ); + it("should convert original-mode durations to stale and expiry timestamps", async () => { mockTagCache.mode = "original"; mockTagCache.getByTag.mockResolvedValue(["/path1"]);