From a732739629e9205d73e01b23b6c06c538b64a390 Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Mon, 28 Jul 2025 11:08:29 +0200 Subject: [PATCH 01/47] fix: fail build/deploy when using unsupported Node.js Midleware --- src/build/content/server.ts | 18 +++++++++++++++++ tests/fixtures/middleware-node/app/layout.js | 12 +++++++++++ tests/fixtures/middleware-node/app/page.js | 7 +++++++ tests/fixtures/middleware-node/middleware.ts | 9 +++++++++ tests/fixtures/middleware-node/next.config.js | 12 +++++++++++ tests/fixtures/middleware-node/package.json | 20 +++++++++++++++++++ tests/integration/edge-handler.test.ts | 11 ++++++++++ 7 files changed, 89 insertions(+) create mode 100644 tests/fixtures/middleware-node/app/layout.js create mode 100644 tests/fixtures/middleware-node/app/page.js create mode 100644 tests/fixtures/middleware-node/middleware.ts create mode 100644 tests/fixtures/middleware-node/next.config.js create mode 100644 tests/fixtures/middleware-node/package.json diff --git a/src/build/content/server.ts b/src/build/content/server.ts index 40e15663f3..3959dc6dc9 100644 --- a/src/build/content/server.ts +++ b/src/build/content/server.ts @@ -17,6 +17,7 @@ import { trace } from '@opentelemetry/api' import { wrapTracer } from '@opentelemetry/api/experimental' import glob from 'fast-glob' import type { MiddlewareManifest } from 'next/dist/build/webpack/plugins/middleware-plugin.js' +import type { FunctionsConfigManifest } from 'next-with-cache-handler-v2/dist/build/index.js' import { prerelease, satisfies, lt as semverLowerThan, lte as semverLowerThanOrEqual } from 'semver' import type { RunConfig } from '../../run/config.js' @@ -131,6 +132,10 @@ export const copyNextServerCode = async (ctx: PluginContext): Promise => { return } + if (path === 'server/functions-config-manifest.json') { + await verifyFunctionsConfigManifest(join(srcDir, path)) + } + await cp(srcPath, destPath, { recursive: true, force: true }) }), ) @@ -376,6 +381,19 @@ const replaceMiddlewareManifest = async (sourcePath: string, destPath: string) = await writeFile(destPath, newData) } +const verifyFunctionsConfigManifest = async (sourcePath: string) => { + const data = await readFile(sourcePath, 'utf8') + const manifest = JSON.parse(data) as FunctionsConfigManifest + + // https://github.com/vercel/next.js/blob/8367faedd61501025299e92d43a28393c7bb50e2/packages/next/src/build/index.ts#L2465 + // Node.js Middleware has hardcoded /_middleware path + if (manifest.functions['/_middleware']) { + throw new Error( + 'Only Edge Runtime Middleware is supported. Node.js Middleware is not supported.', + ) + } +} + export const verifyHandlerDirStructure = async (ctx: PluginContext) => { const { nextConfig } = JSON.parse( await readFile(join(ctx.serverHandlerDir, RUN_CONFIG_FILE), 'utf-8'), diff --git a/tests/fixtures/middleware-node/app/layout.js b/tests/fixtures/middleware-node/app/layout.js new file mode 100644 index 0000000000..6565e7bafd --- /dev/null +++ b/tests/fixtures/middleware-node/app/layout.js @@ -0,0 +1,12 @@ +export const metadata = { + title: 'Simple Next App', + description: 'Description for Simple Next App', +} + +export default function RootLayout({ children }) { + return ( + + {children} + + ) +} diff --git a/tests/fixtures/middleware-node/app/page.js b/tests/fixtures/middleware-node/app/page.js new file mode 100644 index 0000000000..1a9fe06903 --- /dev/null +++ b/tests/fixtures/middleware-node/app/page.js @@ -0,0 +1,7 @@ +export default function Home() { + return ( +
+

Home

+
+ ) +} diff --git a/tests/fixtures/middleware-node/middleware.ts b/tests/fixtures/middleware-node/middleware.ts new file mode 100644 index 0000000000..064f5bb6c3 --- /dev/null +++ b/tests/fixtures/middleware-node/middleware.ts @@ -0,0 +1,9 @@ +import type { NextRequest } from 'next/server' + +export async function middleware(request: NextRequest) { + console.log('Node.js Middleware request:', request.method, request.nextUrl.pathname) +} + +export const config = { + runtime: 'nodejs', +} diff --git a/tests/fixtures/middleware-node/next.config.js b/tests/fixtures/middleware-node/next.config.js new file mode 100644 index 0000000000..24a4bdfa44 --- /dev/null +++ b/tests/fixtures/middleware-node/next.config.js @@ -0,0 +1,12 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + output: 'standalone', + eslint: { + ignoreDuringBuilds: true, + }, + experimental: { + nodeMiddleware: true, + }, +} + +module.exports = nextConfig diff --git a/tests/fixtures/middleware-node/package.json b/tests/fixtures/middleware-node/package.json new file mode 100644 index 0000000000..735b637ecc --- /dev/null +++ b/tests/fixtures/middleware-node/package.json @@ -0,0 +1,20 @@ +{ + "name": "middleware-node", + "version": "0.1.0", + "private": true, + "scripts": { + "postinstall": "next build", + "dev": "next dev", + "build": "next build" + }, + "dependencies": { + "next": "canary", + "react": "18.2.0", + "react-dom": "18.2.0" + }, + "test": { + "dependencies": { + "next": ">=15.2.0" + } + } +} diff --git a/tests/integration/edge-handler.test.ts b/tests/integration/edge-handler.test.ts index 825ed6fac1..b3632a279c 100644 --- a/tests/integration/edge-handler.test.ts +++ b/tests/integration/edge-handler.test.ts @@ -4,6 +4,7 @@ import { type FixtureTestContext } from '../utils/contexts.js' import { createFixture, invokeEdgeFunction, runPlugin } from '../utils/fixture.js' import { generateRandomObjectID, startMockBlobStore } from '../utils/helpers.js' import { LocalServer } from '../utils/local-server.js' +import { nextVersionSatisfies } from '../utils/next-version-helpers.mjs' beforeEach(async (ctx) => { // set for each test a new deployID and siteID @@ -626,3 +627,13 @@ describe('page router', () => { expect(bodyFr.nextUrlLocale).toBe('fr') }) }) + +test.skipIf(!nextVersionSatisfies('>=15.2.0'))( + 'should throw an Not Supported error when node middleware is used', + async (ctx) => { + await createFixture('middleware-node', ctx) + await expect(runPlugin(ctx)).rejects.toThrow( + 'Only Edge Runtime Middleware is supported. Node.js Middleware is not supported.', + ) + }, +) From fc55e715871edf9f17d414d473b00b9903a0435a Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Wed, 30 Jul 2025 13:12:25 +0200 Subject: [PATCH 02/47] [wip] feat: support node middleware --- src/build/content/server.ts | 43 ++++- src/build/functions/edge.ts | 159 ++++++++++++++++++ src/build/plugin-context.ts | 18 ++ tests/fixtures/middleware-node/middleware.ts | 7 +- tests/fixtures/middleware-node/next.config.js | 5 + 5 files changed, 223 insertions(+), 9 deletions(-) diff --git a/src/build/content/server.ts b/src/build/content/server.ts index 3959dc6dc9..fbd289533c 100644 --- a/src/build/content/server.ts +++ b/src/build/content/server.ts @@ -133,7 +133,13 @@ export const copyNextServerCode = async (ctx: PluginContext): Promise => { } if (path === 'server/functions-config-manifest.json') { - await verifyFunctionsConfigManifest(join(srcDir, path)) + try { + await replaceFunctionsConfigManifest(srcPath, destPath) + } catch (error) { + throw new Error('Could not patch functions config manifest file', { cause: error }) + } + + return } await cp(srcPath, destPath, { recursive: true, force: true }) @@ -381,16 +387,41 @@ const replaceMiddlewareManifest = async (sourcePath: string, destPath: string) = await writeFile(destPath, newData) } -const verifyFunctionsConfigManifest = async (sourcePath: string) => { +// similar to the middleware manifest, we need to patch the functions config manifest to disable +// the middleware that is defined in the functions config manifest. This is needed to avoid running +// the middleware in the server handler, while still allowing next server to enable some middleware +// specific handling such as _next/data normalization ( https://github.com/vercel/next.js/blob/7bb72e508572237fe0d4aac5418546d4b4b3a363/packages/next/src/server/lib/router-utils/resolve-routes.ts#L395 ) +const replaceFunctionsConfigManifest = async (sourcePath: string, destPath: string) => { const data = await readFile(sourcePath, 'utf8') const manifest = JSON.parse(data) as FunctionsConfigManifest // https://github.com/vercel/next.js/blob/8367faedd61501025299e92d43a28393c7bb50e2/packages/next/src/build/index.ts#L2465 // Node.js Middleware has hardcoded /_middleware path - if (manifest.functions['/_middleware']) { - throw new Error( - 'Only Edge Runtime Middleware is supported. Node.js Middleware is not supported.', - ) + if (manifest?.functions?.['/_middleware']?.matchers) { + const newManifest = { + ...manifest, + functions: { + ...manifest.functions, + '/_middleware': { + ...manifest.functions['/_middleware'], + matchers: manifest.functions['/_middleware'].matchers.map((matcher) => { + return { + ...matcher, + // matcher that won't match on anything + // this is meant to disable actually running middleware in the server handler, + // while still allowing next server to enable some middleware specific handling + // such as _next/data normalization ( https://github.com/vercel/next.js/blob/7bb72e508572237fe0d4aac5418546d4b4b3a363/packages/next/src/server/lib/router-utils/resolve-routes.ts#L395 ) + regexp: '(?!.*)', + } + }), + }, + }, + } + const newData = JSON.stringify(newManifest) + + await writeFile(destPath, newData) + } else { + await cp(sourcePath, destPath, { recursive: true, force: true }) } } diff --git a/src/build/functions/edge.ts b/src/build/functions/edge.ts index af6405b57c..49fa1fd2c0 100644 --- a/src/build/functions/edge.ts +++ b/src/build/functions/edge.ts @@ -194,11 +194,170 @@ export const clearStaleEdgeHandlers = async (ctx: PluginContext) => { } export const createEdgeHandlers = async (ctx: PluginContext) => { + // Edge middleware const nextManifest = await ctx.getMiddlewareManifest() + // Node middleware + const functionsConfigManifest = await ctx.getFunctionsConfigManifest() + const nextDefinitions = [...Object.values(nextManifest.middleware)] await Promise.all(nextDefinitions.map((def) => createEdgeHandler(ctx, def))) const netlifyDefinitions = nextDefinitions.flatMap((def) => buildHandlerDefinition(ctx, def)) + + if (functionsConfigManifest?.functions?.['/_middleware']) { + const middlewareDefinition = functionsConfigManifest?.functions?.['/_middleware'] + const entry = 'server/middleware.js' + const nft = `${entry}.nft.json` + const name = 'node-middleware' + + // await copyHandlerDependencies(ctx, definition) + const srcDir = join(ctx.standaloneDir, ctx.nextDistDir) + // const destDir = join(ctx.edgeFunctionsDir, getHandlerName({ name })) + + const fakeNodeModuleName = 'fake-module-with-middleware' + + const fakeNodeModulePath = ctx.resolveFromPackagePath(join('node_modules', fakeNodeModuleName)) + + const nftFilesPath = join(ctx.nextDistDir, nft) + const nftManifest = JSON.parse(await readFile(nftFilesPath, 'utf8')) + + const files: string[] = nftManifest.files.map((file: string) => join('server', file)) + files.push(entry) + + // files are relative to location of middleware entrypoint + // we need to capture all of them + // they might be going to parent directories, so first we check how many directories we need to go up + const maxDirsUp = files.reduce((max, file) => { + let dirsUp = 0 + for (const part of file.split('/')) { + if (part === '..') { + dirsUp += 1 + } else { + break + } + } + return Math.max(max, dirsUp) + }, 0) + + let prefixPath = '' + for (let nestedIndex = 1; nestedIndex <= maxDirsUp; nestedIndex++) { + // TODO: ideally we preserve the original directory structure + // this is just hack to use arbitrary computed names to speed up hooking things up + prefixPath += `nested-${nestedIndex}/` + } + + for (const file of files) { + const srcPath = join(srcDir, file) + const destPath = join(fakeNodeModulePath, prefixPath, file) + + await mkdir(dirname(destPath), { recursive: true }) + + if (file === entry) { + const content = await readFile(srcPath, 'utf8') + await writeFile( + destPath, + // Next.js needs to be set on global even if it's possible to just require it + // so somewhat similar to existing shim we have for edge runtime + `globalThis.AsyncLocalStorage = require('node:async_hooks').AsyncLocalStorage;\n${content}`, + ) + } else { + await cp(srcPath, destPath, { force: true }) + } + } + + await writeFile(join(fakeNodeModulePath, 'package.json'), JSON.stringify({ type: 'commonjs' })) + + // there is `/chunks/**/*` require coming from webpack-runtime that fails esbuild due to nothing matching, + // so this ensure something does + const dummyChunkPath = join(fakeNodeModulePath, prefixPath, 'server', 'chunks', 'dummy.js') + await mkdir(dirname(dummyChunkPath), { recursive: true }) + await writeFile(dummyChunkPath, '') + + // await writeHandlerFile(ctx, definition) + + const nextConfig = ctx.buildConfig + const handlerName = getHandlerName({ name }) + const handlerDirectory = join(ctx.edgeFunctionsDir, handlerName) + const handlerRuntimeDirectory = join(handlerDirectory, 'edge-runtime') + + // Copying the runtime files. These are the compatibility layer between + // Netlify Edge Functions and the Next.js edge runtime. + await copyRuntime(ctx, handlerDirectory) + + // Writing a file with the matchers that should trigger this function. We'll + // read this file from the function at runtime. + await writeFile( + join(handlerRuntimeDirectory, 'matchers.json'), + JSON.stringify(middlewareDefinition.matchers ?? []), + ) + + // The config is needed by the edge function to match and normalize URLs. To + // avoid shipping and parsing a large file at runtime, let's strip it down to + // just the properties that the edge function actually needs. + const minimalNextConfig = { + basePath: nextConfig.basePath, + i18n: nextConfig.i18n, + trailingSlash: nextConfig.trailingSlash, + skipMiddlewareUrlNormalize: nextConfig.skipMiddlewareUrlNormalize, + } + + await writeFile( + join(handlerRuntimeDirectory, 'next.config.json'), + JSON.stringify(minimalNextConfig), + ) + + const htmlRewriterWasm = await readFile( + join( + ctx.pluginDir, + 'edge-runtime/vendor/deno.land/x/htmlrewriter@v1.0.0/pkg/htmlrewriter_bg.wasm', + ), + ) + + // Writing the function entry file. It wraps the middleware code with the + // compatibility layer mentioned above. + await writeFile( + join(handlerDirectory, `${handlerName}.js`), + ` + import { init as htmlRewriterInit } from './edge-runtime/vendor/deno.land/x/htmlrewriter@v1.0.0/src/index.ts' + import { handleMiddleware } from './edge-runtime/middleware.ts'; + + import * as handlerMod from '${fakeNodeModuleName}/${prefixPath}${entry}'; + + const handler = handlerMod.default || handlerMod; + + await htmlRewriterInit({ module_or_path: Uint8Array.from(${JSON.stringify([ + ...htmlRewriterWasm, + ])}) }); + + export default (req, context) => { + return handleMiddleware(req, context, handler); + }; + `, + ) + + // buildHandlerDefinition(ctx, def) + const netlifyDefinitions: Manifest['functions'] = augmentMatchers( + middlewareDefinition.matchers ?? [], + ctx, + ).map((matcher) => { + return { + function: getHandlerName({ name }), + name: `Next.js Node Middleware Handler`, + pattern: matcher.regexp, + cache: undefined, + generator: `${ctx.pluginName}@${ctx.pluginVersion}`, + } + }) + + const netlifyManifest: Manifest = { + version: 1, + functions: netlifyDefinitions, + } + await writeEdgeManifest(ctx, netlifyManifest) + + return + } + const netlifyManifest: Manifest = { version: 1, functions: netlifyDefinitions, diff --git a/src/build/plugin-context.ts b/src/build/plugin-context.ts index 9148d0dd56..7ecc24e16c 100644 --- a/src/build/plugin-context.ts +++ b/src/build/plugin-context.ts @@ -14,6 +14,7 @@ import type { PrerenderManifest, RoutesManifest } from 'next/dist/build/index.js import type { MiddlewareManifest } from 'next/dist/build/webpack/plugins/middleware-plugin.js' import type { PagesManifest } from 'next/dist/build/webpack/plugins/pages-manifest-plugin.js' import type { NextConfigComplete } from 'next/dist/server/config-shared.js' +import type { FunctionsConfigManifest } from 'next-with-cache-handler-v2/dist/build/index.js' import { satisfies } from 'semver' const MODULE_DIR = fileURLToPath(new URL('.', import.meta.url)) @@ -259,6 +260,23 @@ export class PluginContext { ) } + /** + * Get Next.js Functions Config Manifest config if it exists from the build output + */ + async getFunctionsConfigManifest(): Promise { + const functionsConfigManifestPath = join( + this.publishDir, + 'server/functions-config-manifest.json', + ) + + if (existsSync(functionsConfigManifestPath)) { + return JSON.parse(await readFile(functionsConfigManifestPath, 'utf-8')) + } + + // this file might not have been produced + return null + } + // don't make private as it is handy inside testing to override the config _requiredServerFiles: RequiredServerFilesManifest | null = null diff --git a/tests/fixtures/middleware-node/middleware.ts b/tests/fixtures/middleware-node/middleware.ts index 064f5bb6c3..0ccf3aa76c 100644 --- a/tests/fixtures/middleware-node/middleware.ts +++ b/tests/fixtures/middleware-node/middleware.ts @@ -1,7 +1,8 @@ -import type { NextRequest } from 'next/server' +import { type NextRequest, NextResponse } from 'next/server' +import { join } from 'path' -export async function middleware(request: NextRequest) { - console.log('Node.js Middleware request:', request.method, request.nextUrl.pathname) +export default async function middleware(req: NextRequest) { + return NextResponse.json({ message: 'Hello, world!', joined: join('a', 'b') }) } export const config = { diff --git a/tests/fixtures/middleware-node/next.config.js b/tests/fixtures/middleware-node/next.config.js index 24a4bdfa44..94c39a2d81 100644 --- a/tests/fixtures/middleware-node/next.config.js +++ b/tests/fixtures/middleware-node/next.config.js @@ -7,6 +7,11 @@ const nextConfig = { experimental: { nodeMiddleware: true, }, + webpack: (config) => { + // disable minification for easier inspection of produced build output + config.optimization.minimize = false + return config + }, } module.exports = nextConfig From 2e1f69817f8919774d8fd672a6040e0875bacd43 Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Wed, 30 Jul 2025 14:02:34 +0200 Subject: [PATCH 03/47] shim otel, so things work when deploying from outside of this repo --- src/build/functions/edge.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/build/functions/edge.ts b/src/build/functions/edge.ts index 49fa1fd2c0..b7232dcd21 100644 --- a/src/build/functions/edge.ts +++ b/src/build/functions/edge.ts @@ -273,6 +273,21 @@ export const createEdgeHandlers = async (ctx: PluginContext) => { await mkdir(dirname(dummyChunkPath), { recursive: true }) await writeFile(dummyChunkPath, '') + // there is also `@opentelemetry/api` require that fails esbuild due to nothing matching, + // next is try/catching it and fallback to bundled version of otel package in case of errors + const otelApiPath = join( + fakeNodeModulePath, + 'node_modules', + '@opentelemetry', + 'api', + 'index.js', + ) + await mkdir(dirname(otelApiPath), { recursive: true }) + await writeFile( + otelApiPath, + `throw new Error('this is dummy to satisfy esbuild used for npm compat using fake module')`, + ) + // await writeHandlerFile(ctx, definition) const nextConfig = ctx.buildConfig From 661496bbc0efca403b9eb3a2ff537520701bec52 Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Wed, 30 Jul 2025 14:03:57 +0200 Subject: [PATCH 04/47] test: deploy no longer fail, so ignore this test --- tests/integration/edge-handler.test.ts | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/tests/integration/edge-handler.test.ts b/tests/integration/edge-handler.test.ts index b3632a279c..9782160135 100644 --- a/tests/integration/edge-handler.test.ts +++ b/tests/integration/edge-handler.test.ts @@ -628,12 +628,13 @@ describe('page router', () => { }) }) -test.skipIf(!nextVersionSatisfies('>=15.2.0'))( - 'should throw an Not Supported error when node middleware is used', - async (ctx) => { - await createFixture('middleware-node', ctx) - await expect(runPlugin(ctx)).rejects.toThrow( - 'Only Edge Runtime Middleware is supported. Node.js Middleware is not supported.', - ) - }, -) +// this is now actually deploying +// test.skipIf(!nextVersionSatisfies('>=15.2.0'))( +// 'should throw an Not Supported error when node middleware is used', +// async (ctx) => { +// await createFixture('middleware-node', ctx) +// await expect(runPlugin(ctx)).rejects.toThrow( +// 'Only Edge Runtime Middleware is supported. Node.js Middleware is not supported.', +// ) +// }, +// ) From 3c2789b486e6743d048ca7359cf85f9473edb7c6 Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Wed, 30 Jul 2025 15:15:35 +0200 Subject: [PATCH 05/47] test: try running existing tests against node middleware --- .github/workflows/run-tests.yml | 2 +- tests/fixtures/middleware-conditions/middleware.ts | 1 + tests/fixtures/middleware-i18n-excluded-paths/middleware.ts | 1 + tests/fixtures/middleware-i18n-skip-normalize/middleware.js | 4 ++++ tests/fixtures/middleware-i18n/middleware.js | 4 ++++ tests/fixtures/middleware-node/middleware.ts | 4 +++- tests/fixtures/middleware-pages/middleware.js | 4 ++++ tests/fixtures/middleware-src/src/middleware.ts | 1 + tests/fixtures/middleware-static-asset-matcher/middleware.ts | 1 + tests/fixtures/middleware-subrequest-vuln/middleware.ts | 4 ++++ tests/fixtures/middleware-trailing-slash/middleware.ts | 4 ++++ tests/fixtures/middleware/middleware.ts | 1 + 12 files changed, 29 insertions(+), 2 deletions(-) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 96bce88839..0536c6388d 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -38,7 +38,7 @@ jobs: elif [ "${{ github.event_name }}" = "schedule" ] || [ "${{ steps.check-labels.outputs.result }}" = "true" ]; then echo "matrix=[\"latest\", \"canary\", \"14.2.15\", \"13.5.1\"]" >> $GITHUB_OUTPUT else - echo "matrix=[\"latest\"]" >> $GITHUB_OUTPUT + echo "matrix=[\"canary\"]" >> $GITHUB_OUTPUT fi e2e: diff --git a/tests/fixtures/middleware-conditions/middleware.ts b/tests/fixtures/middleware-conditions/middleware.ts index fdb332cf8e..ae6c50afc5 100644 --- a/tests/fixtures/middleware-conditions/middleware.ts +++ b/tests/fixtures/middleware-conditions/middleware.ts @@ -23,4 +23,5 @@ export const config = { locale: false, }, ], + runtime: 'nodejs', } diff --git a/tests/fixtures/middleware-i18n-excluded-paths/middleware.ts b/tests/fixtures/middleware-i18n-excluded-paths/middleware.ts index 712f3648b7..7f5c235d6f 100644 --- a/tests/fixtures/middleware-i18n-excluded-paths/middleware.ts +++ b/tests/fixtures/middleware-i18n-excluded-paths/middleware.ts @@ -33,4 +33,5 @@ export const config = { */ '/((?!api|excluded|_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)', ], + runtime: 'nodejs', } diff --git a/tests/fixtures/middleware-i18n-skip-normalize/middleware.js b/tests/fixtures/middleware-i18n-skip-normalize/middleware.js index 24517d72de..0c39b3f66b 100644 --- a/tests/fixtures/middleware-i18n-skip-normalize/middleware.js +++ b/tests/fixtures/middleware-i18n-skip-normalize/middleware.js @@ -89,3 +89,7 @@ export async function middleware(request) { }) } } + +export const config = { + runtime: 'nodejs', +} diff --git a/tests/fixtures/middleware-i18n/middleware.js b/tests/fixtures/middleware-i18n/middleware.js index 3462214f1d..72da32c5fc 100644 --- a/tests/fixtures/middleware-i18n/middleware.js +++ b/tests/fixtures/middleware-i18n/middleware.js @@ -114,3 +114,7 @@ export async function middleware(request) { }) } } + +export const config = { + runtime: 'nodejs', +} diff --git a/tests/fixtures/middleware-node/middleware.ts b/tests/fixtures/middleware-node/middleware.ts index 0ccf3aa76c..33328de8d5 100644 --- a/tests/fixtures/middleware-node/middleware.ts +++ b/tests/fixtures/middleware-node/middleware.ts @@ -2,7 +2,9 @@ import { type NextRequest, NextResponse } from 'next/server' import { join } from 'path' export default async function middleware(req: NextRequest) { - return NextResponse.json({ message: 'Hello, world!', joined: join('a', 'b') }) + const response = NextResponse.next() + response.headers.set('x-added-middleware-headers-join', join('a', 'b')) + return response } export const config = { diff --git a/tests/fixtures/middleware-pages/middleware.js b/tests/fixtures/middleware-pages/middleware.js index a89a491a8c..6e689b7bc7 100644 --- a/tests/fixtures/middleware-pages/middleware.js +++ b/tests/fixtures/middleware-pages/middleware.js @@ -123,3 +123,7 @@ const params = (url) => { } return result } + +export const config = { + runtime: 'nodejs', +} diff --git a/tests/fixtures/middleware-src/src/middleware.ts b/tests/fixtures/middleware-src/src/middleware.ts index 247e7755c3..79963f7e9a 100644 --- a/tests/fixtures/middleware-src/src/middleware.ts +++ b/tests/fixtures/middleware-src/src/middleware.ts @@ -28,4 +28,5 @@ const getResponse = (request: NextRequest) => { export const config = { matcher: '/test/:path*', + runtime: 'nodejs', } diff --git a/tests/fixtures/middleware-static-asset-matcher/middleware.ts b/tests/fixtures/middleware-static-asset-matcher/middleware.ts index 26924f826d..3ea6d1362a 100644 --- a/tests/fixtures/middleware-static-asset-matcher/middleware.ts +++ b/tests/fixtures/middleware-static-asset-matcher/middleware.ts @@ -4,4 +4,5 @@ export default function middleware() { export const config = { matcher: '/hello/world.txt', + runtime: 'nodejs', } diff --git a/tests/fixtures/middleware-subrequest-vuln/middleware.ts b/tests/fixtures/middleware-subrequest-vuln/middleware.ts index c91447b69a..2b8cdea2b7 100644 --- a/tests/fixtures/middleware-subrequest-vuln/middleware.ts +++ b/tests/fixtures/middleware-subrequest-vuln/middleware.ts @@ -11,3 +11,7 @@ export async function middleware(request: NextRequest) { return response } + +export const config = { + runtime: 'nodejs', +} diff --git a/tests/fixtures/middleware-trailing-slash/middleware.ts b/tests/fixtures/middleware-trailing-slash/middleware.ts index f4b2ae6390..0a34a67b90 100644 --- a/tests/fixtures/middleware-trailing-slash/middleware.ts +++ b/tests/fixtures/middleware-trailing-slash/middleware.ts @@ -56,3 +56,7 @@ const getResponse = (request: NextRequest) => { return NextResponse.json({ error: 'Error' }, { status: 500 }) } + +export const config = { + runtime: 'nodejs', +} diff --git a/tests/fixtures/middleware/middleware.ts b/tests/fixtures/middleware/middleware.ts index 735f3a8488..6280e410cd 100644 --- a/tests/fixtures/middleware/middleware.ts +++ b/tests/fixtures/middleware/middleware.ts @@ -92,4 +92,5 @@ const getResponse = (request: NextRequest) => { export const config = { matcher: '/test/:path*', + runtime: 'nodejs', } From 22036403820694f1068bf9f476dd413d674422cd Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Wed, 30 Jul 2025 15:36:28 +0200 Subject: [PATCH 06/47] test: skip force chunking in middleware fixture --- tests/fixtures/middleware/next.config.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/fixtures/middleware/next.config.js b/tests/fixtures/middleware/next.config.js index 28875fd694..4de8b236d0 100644 --- a/tests/fixtures/middleware/next.config.js +++ b/tests/fixtures/middleware/next.config.js @@ -7,7 +7,7 @@ const nextConfig = { webpack: (config) => { // this is a trigger to generate multiple `.next/server/middleware-[hash].js` files instead of // single `.next/server/middleware.js` file - config.optimization.splitChunks.maxSize = 100_000 + // config.optimization.splitChunks.maxSize = 100_000 return config }, From b5a32698c52959d1545a41128638d51fee8944d4 Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Thu, 31 Jul 2025 19:30:56 +0200 Subject: [PATCH 07/47] fix: nft reading to work in integration tests --- src/build/functions/edge.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/build/functions/edge.ts b/src/build/functions/edge.ts index b7232dcd21..1c61c40067 100644 --- a/src/build/functions/edge.ts +++ b/src/build/functions/edge.ts @@ -218,7 +218,7 @@ export const createEdgeHandlers = async (ctx: PluginContext) => { const fakeNodeModulePath = ctx.resolveFromPackagePath(join('node_modules', fakeNodeModuleName)) - const nftFilesPath = join(ctx.nextDistDir, nft) + const nftFilesPath = join(process.cwd(), ctx.nextDistDir, nft) const nftManifest = JSON.parse(await readFile(nftFilesPath, 'utf8')) const files: string[] = nftManifest.files.map((file: string) => join('server', file)) From 20310775a5ed6b335be07f756ae7c2c6f63d8d49 Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Thu, 31 Jul 2025 21:07:56 +0200 Subject: [PATCH 08/47] test: make sure to use appropiate edge handler name --- tests/integration/edge-handler.test.ts | 60 ++++++++++--------- .../integration/hello-world-turbopack.test.ts | 10 +++- tests/integration/wasm.test.ts | 13 +++- tests/utils/fixture.ts | 6 ++ 4 files changed, 57 insertions(+), 32 deletions(-) diff --git a/tests/integration/edge-handler.test.ts b/tests/integration/edge-handler.test.ts index 9782160135..23e009079c 100644 --- a/tests/integration/edge-handler.test.ts +++ b/tests/integration/edge-handler.test.ts @@ -1,7 +1,13 @@ import { v4 } from 'uuid' import { beforeEach, describe, expect, test, vi } from 'vitest' import { type FixtureTestContext } from '../utils/contexts.js' -import { createFixture, invokeEdgeFunction, runPlugin } from '../utils/fixture.js' +import { + createFixture, + EDGE_MIDDLEWARE_FUNCTION_NAME, + EDGE_MIDDLEWARE_SRC_FUNCTION_NAME, + invokeEdgeFunction, + runPlugin, +} from '../utils/fixture.js' import { generateRandomObjectID, startMockBlobStore } from '../utils/helpers.js' import { LocalServer } from '../utils/local-server.js' import { nextVersionSatisfies } from '../utils/next-version-helpers.mjs' @@ -30,7 +36,7 @@ test('should add request/response headers', async (ctx) => { ctx.cleanup?.push(() => origin.stop()) const response = await invokeEdgeFunction(ctx, { - functions: ['___netlify-edge-handler-middleware'], + functions: [EDGE_MIDDLEWARE_FUNCTION_NAME], origin, url: '/test/next', }) @@ -58,7 +64,7 @@ test('should add request/response headers when using src dir ctx.cleanup?.push(() => origin.stop()) const response = await invokeEdgeFunction(ctx, { - functions: ['___netlify-edge-handler-src-middleware'], + functions: [EDGE_MIDDLEWARE_SRC_FUNCTION_NAME], origin, url: '/test/next', }) @@ -78,7 +84,7 @@ describe('redirect', () => { const origin = new LocalServer() const response = await invokeEdgeFunction(ctx, { - functions: ['___netlify-edge-handler-middleware'], + functions: [EDGE_MIDDLEWARE_FUNCTION_NAME], origin, redirect: 'manual', url: '/test/redirect', @@ -101,7 +107,7 @@ describe('redirect', () => { const origin = new LocalServer() const response = await invokeEdgeFunction(ctx, { - functions: ['___netlify-edge-handler-middleware'], + functions: [EDGE_MIDDLEWARE_FUNCTION_NAME], origin, redirect: 'manual', url: '/test/redirect-with-headers', @@ -140,7 +146,7 @@ describe('rewrite', () => { ctx.cleanup?.push(() => origin.stop()) const response = await invokeEdgeFunction(ctx, { - functions: ['___netlify-edge-handler-middleware'], + functions: [EDGE_MIDDLEWARE_FUNCTION_NAME], origin, url: `/test/rewrite-external?external-url=http://localhost:${external.port}/some-path`, }) @@ -167,7 +173,7 @@ describe('rewrite', () => { ctx.cleanup?.push(() => origin.stop()) const response = await invokeEdgeFunction(ctx, { - functions: ['___netlify-edge-handler-middleware'], + functions: [EDGE_MIDDLEWARE_FUNCTION_NAME], origin, url: `/test/rewrite-external?external-url=http://localhost:${external.port}/some-path`, redirect: 'manual', @@ -196,7 +202,7 @@ describe("aborts middleware execution when the matcher conditions don't match th ctx.cleanup?.push(() => origin.stop()) const response = await invokeEdgeFunction(ctx, { - functions: ['___netlify-edge-handler-middleware'], + functions: [EDGE_MIDDLEWARE_FUNCTION_NAME], origin, url: '/_next/data', }) @@ -223,7 +229,7 @@ describe("aborts middleware execution when the matcher conditions don't match th // Request 1: Middleware should run because we're not sending the header. const response1 = await invokeEdgeFunction(ctx, { - functions: ['___netlify-edge-handler-middleware'], + functions: [EDGE_MIDDLEWARE_FUNCTION_NAME], origin, url: '/foo', }) @@ -238,7 +244,7 @@ describe("aborts middleware execution when the matcher conditions don't match th headers: { 'x-custom-header': 'custom-value', }, - functions: ['___netlify-edge-handler-middleware'], + functions: [EDGE_MIDDLEWARE_FUNCTION_NAME], origin, url: '/foo', }) @@ -264,7 +270,7 @@ describe("aborts middleware execution when the matcher conditions don't match th for (const path of ['/hello', '/en/hello', '/nl/hello', '/nl/about']) { const response = await invokeEdgeFunction(ctx, { - functions: ['___netlify-edge-handler-middleware'], + functions: [EDGE_MIDDLEWARE_FUNCTION_NAME], origin, url: path, }) @@ -278,7 +284,7 @@ describe("aborts middleware execution when the matcher conditions don't match th for (const path of ['/invalid/hello', '/hello/invalid', '/about', '/en/about']) { const response = await invokeEdgeFunction(ctx, { - functions: ['___netlify-edge-handler-middleware'], + functions: [EDGE_MIDDLEWARE_FUNCTION_NAME], origin, url: path, }) @@ -299,7 +305,7 @@ describe('should run middleware on data requests', () => { const origin = new LocalServer() const response = await invokeEdgeFunction(ctx, { - functions: ['___netlify-edge-handler-middleware'], + functions: [EDGE_MIDDLEWARE_FUNCTION_NAME], origin, redirect: 'manual', url: '/_next/data/dJvEyLV8MW7CBLFf0Ecbk/test/redirect-with-headers.json', @@ -323,7 +329,7 @@ describe('should run middleware on data requests', () => { const origin = new LocalServer() const response = await invokeEdgeFunction(ctx, { - functions: ['___netlify-edge-handler-middleware'], + functions: [EDGE_MIDDLEWARE_FUNCTION_NAME], origin, redirect: 'manual', url: '/_next/data/dJvEyLV8MW7CBLFf0Ecbk/test/redirect-with-headers.json', @@ -357,7 +363,7 @@ describe('page router', () => { }) ctx.cleanup?.push(() => origin.stop()) const response = await invokeEdgeFunction(ctx, { - functions: ['___netlify-edge-handler-middleware'], + functions: [EDGE_MIDDLEWARE_FUNCTION_NAME], origin, url: `/api/edge-headers`, }) @@ -379,7 +385,7 @@ describe('page router', () => { }) ctx.cleanup?.push(() => origin.stop()) const response = await invokeEdgeFunction(ctx, { - functions: ['___netlify-edge-handler-middleware'], + functions: [EDGE_MIDDLEWARE_FUNCTION_NAME], headers: { 'x-nextjs-data': '1', }, @@ -408,7 +414,7 @@ describe('page router', () => { }) ctx.cleanup?.push(() => origin.stop()) const response = await invokeEdgeFunction(ctx, { - functions: ['___netlify-edge-handler-middleware'], + functions: [EDGE_MIDDLEWARE_FUNCTION_NAME], origin, url: `/_next/static/build-id/_devMiddlewareManifest.json?foo=1`, }) @@ -434,7 +440,7 @@ describe('page router', () => { }) ctx.cleanup?.push(() => origin.stop()) const response = await invokeEdgeFunction(ctx, { - functions: ['___netlify-edge-handler-middleware'], + functions: [EDGE_MIDDLEWARE_FUNCTION_NAME], headers: { 'x-nextjs-data': '1', }, @@ -462,7 +468,7 @@ describe('page router', () => { }) ctx.cleanup?.push(() => origin.stop()) const response = await invokeEdgeFunction(ctx, { - functions: ['___netlify-edge-handler-middleware'], + functions: [EDGE_MIDDLEWARE_FUNCTION_NAME], headers: { 'x-nextjs-data': '1', }, @@ -491,7 +497,7 @@ describe('page router', () => { }) ctx.cleanup?.push(() => origin.stop()) const response = await invokeEdgeFunction(ctx, { - functions: ['___netlify-edge-handler-middleware'], + functions: [EDGE_MIDDLEWARE_FUNCTION_NAME], origin, url: `/fr/old-home`, redirect: 'manual', @@ -515,7 +521,7 @@ describe('page router', () => { }) ctx.cleanup?.push(() => origin.stop()) const response = await invokeEdgeFunction(ctx, { - functions: ['___netlify-edge-handler-middleware'], + functions: [EDGE_MIDDLEWARE_FUNCTION_NAME], origin, url: `/fr/redirect-to-same-page-but-default-locale`, redirect: 'manual', @@ -540,7 +546,7 @@ describe('page router', () => { ctx.cleanup?.push(() => origin.stop()) const response = await invokeEdgeFunction(ctx, { - functions: ['___netlify-edge-handler-middleware'], + functions: [EDGE_MIDDLEWARE_FUNCTION_NAME], origin, url: `/json`, }) @@ -552,7 +558,7 @@ describe('page router', () => { expect(body.nextUrlLocale).toBe('en') const responseEn = await invokeEdgeFunction(ctx, { - functions: ['___netlify-edge-handler-middleware'], + functions: [EDGE_MIDDLEWARE_FUNCTION_NAME], origin, url: `/en/json`, }) @@ -564,7 +570,7 @@ describe('page router', () => { expect(bodyEn.nextUrlLocale).toBe('en') const responseFr = await invokeEdgeFunction(ctx, { - functions: ['___netlify-edge-handler-middleware'], + functions: [EDGE_MIDDLEWARE_FUNCTION_NAME], origin, url: `/fr/json`, }) @@ -591,7 +597,7 @@ describe('page router', () => { ctx.cleanup?.push(() => origin.stop()) const response = await invokeEdgeFunction(ctx, { - functions: ['___netlify-edge-handler-middleware'], + functions: [EDGE_MIDDLEWARE_FUNCTION_NAME], origin, url: `/json`, }) @@ -603,7 +609,7 @@ describe('page router', () => { expect(body.nextUrlLocale).toBe('en') const responseEn = await invokeEdgeFunction(ctx, { - functions: ['___netlify-edge-handler-middleware'], + functions: [EDGE_MIDDLEWARE_FUNCTION_NAME], origin, url: `/en/json`, }) @@ -615,7 +621,7 @@ describe('page router', () => { expect(bodyEn.nextUrlLocale).toBe('en') const responseFr = await invokeEdgeFunction(ctx, { - functions: ['___netlify-edge-handler-middleware'], + functions: [EDGE_MIDDLEWARE_FUNCTION_NAME], origin, url: `/fr/json`, }) diff --git a/tests/integration/hello-world-turbopack.test.ts b/tests/integration/hello-world-turbopack.test.ts index d7681179a3..68956e974c 100644 --- a/tests/integration/hello-world-turbopack.test.ts +++ b/tests/integration/hello-world-turbopack.test.ts @@ -5,7 +5,13 @@ import { setupServer } from 'msw/node' import { v4 } from 'uuid' import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi } from 'vitest' import { type FixtureTestContext } from '../utils/contexts.js' -import { createFixture, invokeEdgeFunction, invokeFunction, runPlugin } from '../utils/fixture.js' +import { + createFixture, + EDGE_MIDDLEWARE_FUNCTION_NAME, + invokeEdgeFunction, + invokeFunction, + runPlugin, +} from '../utils/fixture.js' import { generateRandomObjectID, startMockBlobStore } from '../utils/helpers.js' import { nextVersionSatisfies } from '../utils/next-version-helpers.mjs' @@ -93,7 +99,7 @@ describe.skipIf(!nextVersionSatisfies('>=15.3.0-canary.43'))( const pathname = '/middleware/test' const response = await invokeEdgeFunction(ctx, { - functions: ['___netlify-edge-handler-middleware'], + functions: [EDGE_MIDDLEWARE_FUNCTION_NAME], url: pathname, }) diff --git a/tests/integration/wasm.test.ts b/tests/integration/wasm.test.ts index 2de9050400..a103d805bf 100644 --- a/tests/integration/wasm.test.ts +++ b/tests/integration/wasm.test.ts @@ -3,7 +3,14 @@ import { platform } from 'node:process' import { v4 } from 'uuid' import { beforeEach, describe, expect, test, vi } from 'vitest' import { type FixtureTestContext } from '../utils/contexts.js' -import { createFixture, invokeEdgeFunction, invokeFunction, runPlugin } from '../utils/fixture.js' +import { + createFixture, + EDGE_MIDDLEWARE_FUNCTION_NAME, + EDGE_MIDDLEWARE_SRC_FUNCTION_NAME, + invokeEdgeFunction, + invokeFunction, + runPlugin, +} from '../utils/fixture.js' import { generateRandomObjectID, startMockBlobStore } from '../utils/helpers.js' import { LocalServer } from '../utils/local-server.js' @@ -23,8 +30,8 @@ beforeEach(async (ctx) => { }) describe.each([ - { fixture: 'wasm', edgeHandlerFunction: '___netlify-edge-handler-middleware' }, - { fixture: 'wasm-src', edgeHandlerFunction: '___netlify-edge-handler-src-middleware' }, + { fixture: 'wasm', edgeHandlerFunction: EDGE_MIDDLEWARE_FUNCTION_NAME }, + { fixture: 'wasm-src', edgeHandlerFunction: EDGE_MIDDLEWARE_SRC_FUNCTION_NAME }, ])('$fixture', ({ fixture, edgeHandlerFunction }) => { beforeEach(async (ctx) => { // set for each test a new deployID and siteID diff --git a/tests/utils/fixture.ts b/tests/utils/fixture.ts index 3ddd787dcb..9ad43619b1 100644 --- a/tests/utils/fixture.ts +++ b/tests/utils/fixture.ts @@ -560,3 +560,9 @@ export async function invokeSandboxedFunction( exit() return result } + +// export const EDGE_MIDDLEWARE_FUNCTION_NAME = '___netlify-edge-handler-middleware' +// export const EDGE_MIDDLEWARE_SRC_FUNCTION_NAME = '___netlify-edge-handler-src-middleware' +// for right now we will use node middleware in tests +export const EDGE_MIDDLEWARE_FUNCTION_NAME = '___netlify-edge-handler-node-middleware' +export const EDGE_MIDDLEWARE_SRC_FUNCTION_NAME = EDGE_MIDDLEWARE_FUNCTION_NAME From de3a81a325954f791c170492294a9307e081911a Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Tue, 5 Aug 2025 10:52:42 +0200 Subject: [PATCH 09/47] use virtual CJS modules --- edge-runtime/lib/cjs.ts | 135 ++++++++++++++++++ src/build/functions/edge.ts | 86 ++++++----- .../hello-world-turbopack/middleware.ts | 1 + tests/fixtures/middleware/next.config.js | 1 + 4 files changed, 189 insertions(+), 34 deletions(-) create mode 100644 edge-runtime/lib/cjs.ts diff --git a/edge-runtime/lib/cjs.ts b/edge-runtime/lib/cjs.ts new file mode 100644 index 0000000000..b7c84f253f --- /dev/null +++ b/edge-runtime/lib/cjs.ts @@ -0,0 +1,135 @@ +import { Module, createRequire } from 'node:module' +import vm from 'node:vm' +import { join, dirname } from 'node:path/posix' +import { fileURLToPath, pathToFileURL } from 'node:url' + +type RegisteredModule = { + source: string + loaded: boolean + filename: string +} +const registeredModules = new Map() + +const require = createRequire(import.meta.url) + +let hookedIn = false + +function seedCJSModuleCacheAndReturnTarget(matchedModule: RegisteredModule, parent: Module) { + console.error('matched', matchedModule.filename) + if (matchedModule.loaded) { + return matchedModule.filename + } + const { source, filename } = matchedModule + console.error('evaluating module', { filename }) + + const mod = new Module(filename) + mod.parent = parent + mod.filename = filename + mod.path = dirname(filename) + // @ts-expect-error - private untyped API + mod.paths = Module._nodeModulePaths(mod.path) + require.cache[filename] = mod + + const wrappedSource = `(function (exports, require, module, __filename, __dirname) { ${source}\n});` + const compiled = vm.runInThisContext(wrappedSource, { + filename, + lineOffset: 0, + displayErrors: true, + }) + compiled(mod.exports, createRequire(pathToFileURL(filename)), mod, filename, dirname(filename)) + mod.loaded = matchedModule.loaded = true + + console.error('evaluated module', { filename }) + return filename +} + +const exts = ['.js', '.cjs', '.json'] + +function tryWithExtensions(filename: string) { + // console.error('trying to match', filename) + let matchedModule = registeredModules.get(filename) + if (!matchedModule) { + for (const ext of exts) { + // require("./test") might resolve to ./test.js + const targetWithExt = filename + ext + + matchedModule = registeredModules.get(targetWithExt) + if (matchedModule) { + break + } + } + } + + return matchedModule +} + +function tryMatchingWithIndex(target: string) { + console.error('trying to match', target) + let matchedModule = tryWithExtensions(target) + if (!matchedModule) { + // require("./test") might resolve to ./test/index.js + const indexTarget = join(target, 'index') + matchedModule = tryWithExtensions(indexTarget) + } + + return matchedModule +} + +export function registerCJSModules(baseUrl: URL, modules: Map) { + const basePath = dirname(fileURLToPath(baseUrl)) + + for (const [filename, source] of modules.entries()) { + const target = join(basePath, filename) + + registeredModules.set(target, { source, loaded: false, filename: target }) + } + + console.error([...registeredModules.values()].map((m) => m.filename)) + + if (!hookedIn) { + // magic + // @ts-expect-error - private untyped API + const original_resolveFilename = Module._resolveFilename.bind(Module) + // @ts-expect-error - private untyped API + Module._resolveFilename = (...args) => { + console.error( + 'resolving file name for specifier', + args[0] ?? '--missing specifier--', + 'from', + args[1]?.filename ?? 'unknown', + ) + let target = args[0] + let isRelative = args?.[0].startsWith('.') + + if (isRelative) { + // only handle relative require paths + const requireFrom = args?.[1]?.filename + + target = join(dirname(requireFrom), args[0]) + } + + let matchedModule = tryMatchingWithIndex(target) + + if (!isRelative && !target.startsWith('/')) { + console.log('not relative, checking node_modules', args[0]) + for (const nodeModulePaths of args[1].paths) { + const potentialPath = join(nodeModulePaths, target) + console.log('checking potential path', potentialPath) + matchedModule = tryMatchingWithIndex(potentialPath) + if (matchedModule) { + break + } + } + } + + if (matchedModule) { + console.log('matched module', matchedModule.filename) + return seedCJSModuleCacheAndReturnTarget(matchedModule, args[1]) + } + + return original_resolveFilename(...args) + } + + hookedIn = true + } +} diff --git a/src/build/functions/edge.ts b/src/build/functions/edge.ts index 1c61c40067..d9f6d2167e 100644 --- a/src/build/functions/edge.ts +++ b/src/build/functions/edge.ts @@ -214,9 +214,9 @@ export const createEdgeHandlers = async (ctx: PluginContext) => { const srcDir = join(ctx.standaloneDir, ctx.nextDistDir) // const destDir = join(ctx.edgeFunctionsDir, getHandlerName({ name })) - const fakeNodeModuleName = 'fake-module-with-middleware' + // const fakeNodeModuleName = 'fake-module-with-middleware' - const fakeNodeModulePath = ctx.resolveFromPackagePath(join('node_modules', fakeNodeModuleName)) + // const fakeNodeModulePath = ctx.resolveFromPackagePath(join('node_modules', fakeNodeModuleName)) const nftFilesPath = join(process.cwd(), ctx.nextDistDir, nft) const nftManifest = JSON.parse(await readFile(nftFilesPath, 'utf8')) @@ -246,47 +246,53 @@ export const createEdgeHandlers = async (ctx: PluginContext) => { prefixPath += `nested-${nestedIndex}/` } + let virtualModules = '' for (const file of files) { const srcPath = join(srcDir, file) - const destPath = join(fakeNodeModulePath, prefixPath, file) - - await mkdir(dirname(destPath), { recursive: true }) - - if (file === entry) { - const content = await readFile(srcPath, 'utf8') - await writeFile( - destPath, - // Next.js needs to be set on global even if it's possible to just require it - // so somewhat similar to existing shim we have for edge runtime - `globalThis.AsyncLocalStorage = require('node:async_hooks').AsyncLocalStorage;\n${content}`, - ) - } else { - await cp(srcPath, destPath, { force: true }) - } + + const content = await readFile(srcPath, 'utf8') + + virtualModules += `virtualModules.set(${JSON.stringify(join(prefixPath, file))}, ${JSON.stringify(content)});\n` + + // const destPath = join(fakeNodeModulePath, prefixPath, file) + + // await mkdir(dirname(destPath), { recursive: true }) + + // if (file === entry) { + // const content = await readFile(srcPath, 'utf8') + // await writeFile( + // destPath, + // // Next.js needs to be set on global even if it's possible to just require it + // // so somewhat similar to existing shim we have for edge runtime + // `globalThis.AsyncLocalStorage = require('node:async_hooks').AsyncLocalStorage;\n${content}`, + // ) + // } else { + // await cp(srcPath, destPath, { force: true }) + // } } - await writeFile(join(fakeNodeModulePath, 'package.json'), JSON.stringify({ type: 'commonjs' })) + // await writeFile(join(fakeNodeModulePath, 'package.json'), JSON.stringify({ type: 'commonjs' })) // there is `/chunks/**/*` require coming from webpack-runtime that fails esbuild due to nothing matching, // so this ensure something does - const dummyChunkPath = join(fakeNodeModulePath, prefixPath, 'server', 'chunks', 'dummy.js') - await mkdir(dirname(dummyChunkPath), { recursive: true }) - await writeFile(dummyChunkPath, '') + // const dummyChunkPath = join(fakeNodeModulePath, prefixPath, 'server', 'chunks', 'dummy.js') + // await mkdir(dirname(dummyChunkPath), { recursive: true }) + // await writeFile(dummyChunkPath, '') // there is also `@opentelemetry/api` require that fails esbuild due to nothing matching, // next is try/catching it and fallback to bundled version of otel package in case of errors - const otelApiPath = join( - fakeNodeModulePath, - 'node_modules', - '@opentelemetry', - 'api', - 'index.js', - ) - await mkdir(dirname(otelApiPath), { recursive: true }) - await writeFile( - otelApiPath, - `throw new Error('this is dummy to satisfy esbuild used for npm compat using fake module')`, - ) + // const otelApiPath = join( + // fakeNodeModulePath, + // 'node_modules', + // '@opentelemetry', + // 'api', + // 'index.js', + // ) + // await mkdir(dirname(otelApiPath), { recursive: true }) + // await writeFile( + // otelApiPath, + // `throw new Error('this is dummy to satisfy esbuild used for npm compat using fake module')`, + // ) // await writeHandlerFile(ctx, definition) @@ -333,11 +339,23 @@ export const createEdgeHandlers = async (ctx: PluginContext) => { await writeFile( join(handlerDirectory, `${handlerName}.js`), ` + import { createRequire } from "node:module"; import { init as htmlRewriterInit } from './edge-runtime/vendor/deno.land/x/htmlrewriter@v1.0.0/src/index.ts' import { handleMiddleware } from './edge-runtime/middleware.ts'; + import { registerCJSModules } from "./edge-runtime/lib/cjs.ts"; + import { AsyncLocalStorage } from 'node:async_hooks'; + + globalThis.AsyncLocalStorage = AsyncLocalStorage; + + // needed for path.relative and path.resolve to work + Deno.cwd = () => '' - import * as handlerMod from '${fakeNodeModuleName}/${prefixPath}${entry}'; + const virtualModules = new Map(); + ${virtualModules} + registerCJSModules(import.meta.url, virtualModules); + const require = createRequire(import.meta.url); + const handlerMod = require("./${prefixPath}/${entry}"); const handler = handlerMod.default || handlerMod; await htmlRewriterInit({ module_or_path: Uint8Array.from(${JSON.stringify([ diff --git a/tests/fixtures/hello-world-turbopack/middleware.ts b/tests/fixtures/hello-world-turbopack/middleware.ts index a2f7976a78..76529380bd 100644 --- a/tests/fixtures/hello-world-turbopack/middleware.ts +++ b/tests/fixtures/hello-world-turbopack/middleware.ts @@ -9,4 +9,5 @@ export function middleware(request: NextRequest) { export const config = { matcher: '/middleware/:path*', + runtime: 'nodejs', } diff --git a/tests/fixtures/middleware/next.config.js b/tests/fixtures/middleware/next.config.js index 4de8b236d0..90fbfe105b 100644 --- a/tests/fixtures/middleware/next.config.js +++ b/tests/fixtures/middleware/next.config.js @@ -7,6 +7,7 @@ const nextConfig = { webpack: (config) => { // this is a trigger to generate multiple `.next/server/middleware-[hash].js` files instead of // single `.next/server/middleware.js` file + // this doesn't seem to actually work with Node Middleware - it result in next build failures // config.optimization.splitChunks.maxSize = 100_000 return config From a6f1ac36110eda96f7633c9a41deddffc18484ba Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Mon, 18 Aug 2025 10:17:14 +0200 Subject: [PATCH 10/47] chore: remove dev/debug logs --- edge-runtime/lib/cjs.ts | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/edge-runtime/lib/cjs.ts b/edge-runtime/lib/cjs.ts index b7c84f253f..c8be0c561a 100644 --- a/edge-runtime/lib/cjs.ts +++ b/edge-runtime/lib/cjs.ts @@ -15,12 +15,10 @@ const require = createRequire(import.meta.url) let hookedIn = false function seedCJSModuleCacheAndReturnTarget(matchedModule: RegisteredModule, parent: Module) { - console.error('matched', matchedModule.filename) if (matchedModule.loaded) { return matchedModule.filename } const { source, filename } = matchedModule - console.error('evaluating module', { filename }) const mod = new Module(filename) mod.parent = parent @@ -39,14 +37,12 @@ function seedCJSModuleCacheAndReturnTarget(matchedModule: RegisteredModule, pare compiled(mod.exports, createRequire(pathToFileURL(filename)), mod, filename, dirname(filename)) mod.loaded = matchedModule.loaded = true - console.error('evaluated module', { filename }) return filename } const exts = ['.js', '.cjs', '.json'] function tryWithExtensions(filename: string) { - // console.error('trying to match', filename) let matchedModule = registeredModules.get(filename) if (!matchedModule) { for (const ext of exts) { @@ -64,7 +60,6 @@ function tryWithExtensions(filename: string) { } function tryMatchingWithIndex(target: string) { - console.error('trying to match', target) let matchedModule = tryWithExtensions(target) if (!matchedModule) { // require("./test") might resolve to ./test/index.js @@ -84,20 +79,11 @@ export function registerCJSModules(baseUrl: URL, modules: Map) { registeredModules.set(target, { source, loaded: false, filename: target }) } - console.error([...registeredModules.values()].map((m) => m.filename)) - if (!hookedIn) { - // magic // @ts-expect-error - private untyped API const original_resolveFilename = Module._resolveFilename.bind(Module) // @ts-expect-error - private untyped API Module._resolveFilename = (...args) => { - console.error( - 'resolving file name for specifier', - args[0] ?? '--missing specifier--', - 'from', - args[1]?.filename ?? 'unknown', - ) let target = args[0] let isRelative = args?.[0].startsWith('.') @@ -111,10 +97,8 @@ export function registerCJSModules(baseUrl: URL, modules: Map) { let matchedModule = tryMatchingWithIndex(target) if (!isRelative && !target.startsWith('/')) { - console.log('not relative, checking node_modules', args[0]) for (const nodeModulePaths of args[1].paths) { const potentialPath = join(nodeModulePaths, target) - console.log('checking potential path', potentialPath) matchedModule = tryMatchingWithIndex(potentialPath) if (matchedModule) { break @@ -123,7 +107,6 @@ export function registerCJSModules(baseUrl: URL, modules: Map) { } if (matchedModule) { - console.log('matched module', matchedModule.filename) return seedCJSModuleCacheAndReturnTarget(matchedModule, args[1]) } From 487b0db609cd817dc5aad2e999cdbae2c7a1511d Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Wed, 20 Aug 2025 12:52:12 +0200 Subject: [PATCH 11/47] test: initial build variants setup --- tests/e2e/edge-middleware.test.ts | 74 ++++- .../hello-world-turbopack/middleware.ts | 1 - .../middleware-conditions/middleware.ts | 1 - .../middleware.ts | 1 - .../middleware.js | 4 - tests/fixtures/middleware-i18n/middleware.js | 4 - tests/fixtures/middleware-node/app/layout.js | 12 - tests/fixtures/middleware-node/app/page.js | 7 - tests/fixtures/middleware-node/middleware.ts | 12 - tests/fixtures/middleware-node/next.config.js | 17 - tests/fixtures/middleware-node/package.json | 20 -- tests/fixtures/middleware-pages/middleware.js | 4 - .../fixtures/middleware-src/src/middleware.ts | 1 - .../middleware.ts | 1 - .../middleware-subrequest-vuln/middleware.ts | 4 - .../middleware-trailing-slash/middleware.ts | 4 - tests/fixtures/middleware/.gitignore | 1 + tests/fixtures/middleware/middleware-node.ts | 6 + .../fixtures/middleware/middleware-shared.ts | 93 ++++++ tests/fixtures/middleware/middleware.ts | 93 +----- tests/fixtures/middleware/next.config.js | 4 + tests/fixtures/middleware/package.json | 4 +- tests/fixtures/middleware/test-variants.json | 20 ++ tests/integration/edge-handler.test.ts | 303 +++++++++--------- tests/utils/build-variants.mjs | 146 +++++++++ tests/utils/create-e2e-fixture.ts | 9 + tests/utils/fixture.ts | 8 +- tests/utils/next-version-helpers.mjs | 4 + 28 files changed, 507 insertions(+), 351 deletions(-) delete mode 100644 tests/fixtures/middleware-node/app/layout.js delete mode 100644 tests/fixtures/middleware-node/app/page.js delete mode 100644 tests/fixtures/middleware-node/middleware.ts delete mode 100644 tests/fixtures/middleware-node/next.config.js delete mode 100644 tests/fixtures/middleware-node/package.json create mode 100644 tests/fixtures/middleware/.gitignore create mode 100644 tests/fixtures/middleware/middleware-node.ts create mode 100644 tests/fixtures/middleware/middleware-shared.ts create mode 100644 tests/fixtures/middleware/test-variants.json create mode 100644 tests/utils/build-variants.mjs diff --git a/tests/e2e/edge-middleware.test.ts b/tests/e2e/edge-middleware.test.ts index 7bc2207467..7d08cb9941 100644 --- a/tests/e2e/edge-middleware.test.ts +++ b/tests/e2e/edge-middleware.test.ts @@ -1,32 +1,74 @@ import { expect, Response } from '@playwright/test' -import { nextVersionSatisfies } from '../utils/next-version-helpers.mjs' +import { hasNodeMiddlewareSupport, nextVersionSatisfies } from '../utils/next-version-helpers.mjs' import { test } from '../utils/playwright-helpers.js' import { getImageSize } from 'next/dist/server/image-optimizer.js' +import type { Fixture } from '../utils/create-e2e-fixture.js' type ExtendedWindow = Window & { didReload?: boolean } -test('Runs edge middleware', async ({ page, middleware }) => { - await page.goto(`${middleware.url}/test/redirect`) +for (const { expectedRuntime, label, testWithSwitchableMiddlewareRuntime } of [ + { + expectedRuntime: 'edge-runtime', + label: 'Edge runtime middleware', + testWithSwitchableMiddlewareRuntime: test.extend<{}, { edgeOrNodeMiddleware: Fixture }>({ + edgeOrNodeMiddleware: [ + async ({ middleware }, use) => { + await use(middleware) + }, + { + scope: 'worker', + }, + ], + }), + }, + hasNodeMiddlewareSupport() + ? { + expectedRuntime: 'node', + label: 'Node.js runtime middleware', + testWithSwitchableMiddlewareRuntime: test.extend<{}, { edgeOrNodeMiddleware: Fixture }>({ + edgeOrNodeMiddleware: [ + async ({ middlewareNode }, use) => { + await use(middlewareNode) + }, + { + scope: 'worker', + }, + ], + }), + } + : undefined, +].filter(function isDefined(argument: T | undefined): argument is T { + return typeof argument !== 'undefined' +})) { + const test = testWithSwitchableMiddlewareRuntime + + test.describe(label, () => { + test('Runs middleware', async ({ page, edgeOrNodeMiddleware }) => { + const res = await page.goto(`${edgeOrNodeMiddleware.url}/test/redirect`) + + await expect(page).toHaveTitle('Simple Next App') + + const h1 = page.locator('h1') + await expect(h1).toHaveText('Other') + }) - await expect(page).toHaveTitle('Simple Next App') + test('Does not run middleware at the origin', async ({ page, edgeOrNodeMiddleware }) => { + const res = await page.goto(`${edgeOrNodeMiddleware.url}/test/next`) - const h1 = page.locator('h1') - await expect(h1).toHaveText('Other') -}) - -test('Does not run edge middleware at the origin', async ({ page, middleware }) => { - const res = await page.goto(`${middleware.url}/test/next`) + expect(await res?.headerValue('x-deno')).toBeTruthy() + expect(await res?.headerValue('x-node')).toBeNull() - expect(await res?.headerValue('x-deno')).toBeTruthy() - expect(await res?.headerValue('x-node')).toBeNull() + await expect(page).toHaveTitle('Simple Next App') - await expect(page).toHaveTitle('Simple Next App') + const h1 = page.locator('h1') + await expect(h1).toHaveText('Message from middleware: hello') - const h1 = page.locator('h1') - await expect(h1).toHaveText('Message from middleware: hello') -}) + expect(await res?.headerValue('x-runtime')).toEqual(expectedRuntime) + }) + }) +} test('does not run middleware again for rewrite target', async ({ page, middleware }) => { const direct = await page.goto(`${middleware.url}/test/rewrite-target`) diff --git a/tests/fixtures/hello-world-turbopack/middleware.ts b/tests/fixtures/hello-world-turbopack/middleware.ts index 76529380bd..a2f7976a78 100644 --- a/tests/fixtures/hello-world-turbopack/middleware.ts +++ b/tests/fixtures/hello-world-turbopack/middleware.ts @@ -9,5 +9,4 @@ export function middleware(request: NextRequest) { export const config = { matcher: '/middleware/:path*', - runtime: 'nodejs', } diff --git a/tests/fixtures/middleware-conditions/middleware.ts b/tests/fixtures/middleware-conditions/middleware.ts index ae6c50afc5..fdb332cf8e 100644 --- a/tests/fixtures/middleware-conditions/middleware.ts +++ b/tests/fixtures/middleware-conditions/middleware.ts @@ -23,5 +23,4 @@ export const config = { locale: false, }, ], - runtime: 'nodejs', } diff --git a/tests/fixtures/middleware-i18n-excluded-paths/middleware.ts b/tests/fixtures/middleware-i18n-excluded-paths/middleware.ts index 7f5c235d6f..712f3648b7 100644 --- a/tests/fixtures/middleware-i18n-excluded-paths/middleware.ts +++ b/tests/fixtures/middleware-i18n-excluded-paths/middleware.ts @@ -33,5 +33,4 @@ export const config = { */ '/((?!api|excluded|_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)', ], - runtime: 'nodejs', } diff --git a/tests/fixtures/middleware-i18n-skip-normalize/middleware.js b/tests/fixtures/middleware-i18n-skip-normalize/middleware.js index 0c39b3f66b..24517d72de 100644 --- a/tests/fixtures/middleware-i18n-skip-normalize/middleware.js +++ b/tests/fixtures/middleware-i18n-skip-normalize/middleware.js @@ -89,7 +89,3 @@ export async function middleware(request) { }) } } - -export const config = { - runtime: 'nodejs', -} diff --git a/tests/fixtures/middleware-i18n/middleware.js b/tests/fixtures/middleware-i18n/middleware.js index 72da32c5fc..3462214f1d 100644 --- a/tests/fixtures/middleware-i18n/middleware.js +++ b/tests/fixtures/middleware-i18n/middleware.js @@ -114,7 +114,3 @@ export async function middleware(request) { }) } } - -export const config = { - runtime: 'nodejs', -} diff --git a/tests/fixtures/middleware-node/app/layout.js b/tests/fixtures/middleware-node/app/layout.js deleted file mode 100644 index 6565e7bafd..0000000000 --- a/tests/fixtures/middleware-node/app/layout.js +++ /dev/null @@ -1,12 +0,0 @@ -export const metadata = { - title: 'Simple Next App', - description: 'Description for Simple Next App', -} - -export default function RootLayout({ children }) { - return ( - - {children} - - ) -} diff --git a/tests/fixtures/middleware-node/app/page.js b/tests/fixtures/middleware-node/app/page.js deleted file mode 100644 index 1a9fe06903..0000000000 --- a/tests/fixtures/middleware-node/app/page.js +++ /dev/null @@ -1,7 +0,0 @@ -export default function Home() { - return ( -
-

Home

-
- ) -} diff --git a/tests/fixtures/middleware-node/middleware.ts b/tests/fixtures/middleware-node/middleware.ts deleted file mode 100644 index 33328de8d5..0000000000 --- a/tests/fixtures/middleware-node/middleware.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { type NextRequest, NextResponse } from 'next/server' -import { join } from 'path' - -export default async function middleware(req: NextRequest) { - const response = NextResponse.next() - response.headers.set('x-added-middleware-headers-join', join('a', 'b')) - return response -} - -export const config = { - runtime: 'nodejs', -} diff --git a/tests/fixtures/middleware-node/next.config.js b/tests/fixtures/middleware-node/next.config.js deleted file mode 100644 index 94c39a2d81..0000000000 --- a/tests/fixtures/middleware-node/next.config.js +++ /dev/null @@ -1,17 +0,0 @@ -/** @type {import('next').NextConfig} */ -const nextConfig = { - output: 'standalone', - eslint: { - ignoreDuringBuilds: true, - }, - experimental: { - nodeMiddleware: true, - }, - webpack: (config) => { - // disable minification for easier inspection of produced build output - config.optimization.minimize = false - return config - }, -} - -module.exports = nextConfig diff --git a/tests/fixtures/middleware-node/package.json b/tests/fixtures/middleware-node/package.json deleted file mode 100644 index 735b637ecc..0000000000 --- a/tests/fixtures/middleware-node/package.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "name": "middleware-node", - "version": "0.1.0", - "private": true, - "scripts": { - "postinstall": "next build", - "dev": "next dev", - "build": "next build" - }, - "dependencies": { - "next": "canary", - "react": "18.2.0", - "react-dom": "18.2.0" - }, - "test": { - "dependencies": { - "next": ">=15.2.0" - } - } -} diff --git a/tests/fixtures/middleware-pages/middleware.js b/tests/fixtures/middleware-pages/middleware.js index 6e689b7bc7..a89a491a8c 100644 --- a/tests/fixtures/middleware-pages/middleware.js +++ b/tests/fixtures/middleware-pages/middleware.js @@ -123,7 +123,3 @@ const params = (url) => { } return result } - -export const config = { - runtime: 'nodejs', -} diff --git a/tests/fixtures/middleware-src/src/middleware.ts b/tests/fixtures/middleware-src/src/middleware.ts index 79963f7e9a..247e7755c3 100644 --- a/tests/fixtures/middleware-src/src/middleware.ts +++ b/tests/fixtures/middleware-src/src/middleware.ts @@ -28,5 +28,4 @@ const getResponse = (request: NextRequest) => { export const config = { matcher: '/test/:path*', - runtime: 'nodejs', } diff --git a/tests/fixtures/middleware-static-asset-matcher/middleware.ts b/tests/fixtures/middleware-static-asset-matcher/middleware.ts index 3ea6d1362a..26924f826d 100644 --- a/tests/fixtures/middleware-static-asset-matcher/middleware.ts +++ b/tests/fixtures/middleware-static-asset-matcher/middleware.ts @@ -4,5 +4,4 @@ export default function middleware() { export const config = { matcher: '/hello/world.txt', - runtime: 'nodejs', } diff --git a/tests/fixtures/middleware-subrequest-vuln/middleware.ts b/tests/fixtures/middleware-subrequest-vuln/middleware.ts index 2b8cdea2b7..c91447b69a 100644 --- a/tests/fixtures/middleware-subrequest-vuln/middleware.ts +++ b/tests/fixtures/middleware-subrequest-vuln/middleware.ts @@ -11,7 +11,3 @@ export async function middleware(request: NextRequest) { return response } - -export const config = { - runtime: 'nodejs', -} diff --git a/tests/fixtures/middleware-trailing-slash/middleware.ts b/tests/fixtures/middleware-trailing-slash/middleware.ts index 0a34a67b90..f4b2ae6390 100644 --- a/tests/fixtures/middleware-trailing-slash/middleware.ts +++ b/tests/fixtures/middleware-trailing-slash/middleware.ts @@ -56,7 +56,3 @@ const getResponse = (request: NextRequest) => { return NextResponse.json({ error: 'Error' }, { status: 500 }) } - -export const config = { - runtime: 'nodejs', -} diff --git a/tests/fixtures/middleware/.gitignore b/tests/fixtures/middleware/.gitignore new file mode 100644 index 0000000000..43ce5f5532 --- /dev/null +++ b/tests/fixtures/middleware/.gitignore @@ -0,0 +1 @@ +.next-node-middleware diff --git a/tests/fixtures/middleware/middleware-node.ts b/tests/fixtures/middleware/middleware-node.ts new file mode 100644 index 0000000000..26fed28b6b --- /dev/null +++ b/tests/fixtures/middleware/middleware-node.ts @@ -0,0 +1,6 @@ +export { middleware } from './middleware-shared' + +export const config = { + matcher: '/test/:path*', + runtime: 'nodejs', +} diff --git a/tests/fixtures/middleware/middleware-shared.ts b/tests/fixtures/middleware/middleware-shared.ts new file mode 100644 index 0000000000..a484a7d61b --- /dev/null +++ b/tests/fixtures/middleware/middleware-shared.ts @@ -0,0 +1,93 @@ +import type { NextRequest } from 'next/server' +import { NextResponse } from 'next/server' +import { createServerRunner } from '@aws-amplify/adapter-nextjs' + +export const { runWithAmplifyServerContext } = createServerRunner({ + config: {}, +}) + +export async function middleware(request: NextRequest) { + const response = getResponse(request) + + response.headers.append('Deno' in globalThis ? 'x-deno' : 'x-node', Date.now().toString()) + // report Next.js Middleware Runtime (not the execution runtime, but target runtime) + response.headers.append('x-runtime', typeof EdgeRuntime !== 'undefined' ? EdgeRuntime : 'node') + response.headers.set('x-hello-from-middleware-res', 'hello') + + await runWithAmplifyServerContext({ + nextServerContext: { request, response }, + operation: async () => { + response.headers.set('x-cjs-module-works', 'true') + }, + }) + + return response +} + +const getResponse = (request: NextRequest) => { + const requestHeaders = new Headers(request.headers) + + requestHeaders.set('x-hello-from-middleware-req', 'hello') + + if (request.nextUrl.pathname === '/test/next') { + return NextResponse.next({ + request: { + headers: requestHeaders, + }, + }) + } + + if (request.nextUrl.pathname === '/test/redirect') { + return NextResponse.redirect(new URL('/other', request.url)) + } + + if (request.nextUrl.pathname === '/test/redirect-with-headers') { + return NextResponse.redirect(new URL('/other', request.url), { + headers: { 'x-header-from-redirect': 'hello' }, + }) + } + + if (request.nextUrl.pathname === '/test/rewrite-target') { + const response = NextResponse.next() + response.headers.set('x-added-rewrite-target', 'true') + return response + } + + if (request.nextUrl.pathname === '/test/rewrite-internal') { + return NextResponse.rewrite(new URL('/rewrite-target', request.url), { + request: { + headers: requestHeaders, + }, + }) + } + + if (request.nextUrl.pathname === '/test/rewrite-loop-detect') { + return NextResponse.rewrite(new URL('/test/rewrite-target', request.url), { + request: { + headers: requestHeaders, + }, + }) + } + + if (request.nextUrl.pathname === '/test/rewrite-external') { + const requestURL = new URL(request.url) + const externalURL = new URL(requestURL.searchParams.get('external-url') as string) + + externalURL.searchParams.set('from', 'middleware') + + return NextResponse.rewrite(externalURL, { + request: { + headers: requestHeaders, + }, + }) + } + + if (request.nextUrl.pathname === '/test/rewrite-to-cached-page') { + return NextResponse.rewrite(new URL('/caching-rewrite-target', request.url)) + } + if (request.nextUrl.pathname === '/test/redirect-to-cached-page') { + return NextResponse.redirect(new URL('/caching-redirect-target', request.url)) + } + + return NextResponse.json({ error: 'Error' }, { status: 500 }) +} diff --git a/tests/fixtures/middleware/middleware.ts b/tests/fixtures/middleware/middleware.ts index 6280e410cd..708f3c7e81 100644 --- a/tests/fixtures/middleware/middleware.ts +++ b/tests/fixtures/middleware/middleware.ts @@ -1,96 +1,5 @@ -import type { NextRequest } from 'next/server' -import { NextResponse } from 'next/server' -import { createServerRunner } from '@aws-amplify/adapter-nextjs' - -export const { runWithAmplifyServerContext } = createServerRunner({ - config: {}, -}) - -export async function middleware(request: NextRequest) { - const response = getResponse(request) - - response.headers.append('Deno' in globalThis ? 'x-deno' : 'x-node', Date.now().toString()) - response.headers.set('x-hello-from-middleware-res', 'hello') - - await runWithAmplifyServerContext({ - nextServerContext: { request, response }, - operation: async () => { - response.headers.set('x-cjs-module-works', 'true') - }, - }) - - return response -} - -const getResponse = (request: NextRequest) => { - const requestHeaders = new Headers(request.headers) - - requestHeaders.set('x-hello-from-middleware-req', 'hello') - - if (request.nextUrl.pathname === '/test/next') { - return NextResponse.next({ - request: { - headers: requestHeaders, - }, - }) - } - - if (request.nextUrl.pathname === '/test/redirect') { - return NextResponse.redirect(new URL('/other', request.url)) - } - - if (request.nextUrl.pathname === '/test/redirect-with-headers') { - return NextResponse.redirect(new URL('/other', request.url), { - headers: { 'x-header-from-redirect': 'hello' }, - }) - } - - if (request.nextUrl.pathname === '/test/rewrite-target') { - const response = NextResponse.next() - response.headers.set('x-added-rewrite-target', 'true') - return response - } - - if (request.nextUrl.pathname === '/test/rewrite-internal') { - return NextResponse.rewrite(new URL('/rewrite-target', request.url), { - request: { - headers: requestHeaders, - }, - }) - } - - if (request.nextUrl.pathname === '/test/rewrite-loop-detect') { - return NextResponse.rewrite(new URL('/test/rewrite-target', request.url), { - request: { - headers: requestHeaders, - }, - }) - } - - if (request.nextUrl.pathname === '/test/rewrite-external') { - const requestURL = new URL(request.url) - const externalURL = new URL(requestURL.searchParams.get('external-url') as string) - - externalURL.searchParams.set('from', 'middleware') - - return NextResponse.rewrite(externalURL, { - request: { - headers: requestHeaders, - }, - }) - } - - if (request.nextUrl.pathname === '/test/rewrite-to-cached-page') { - return NextResponse.rewrite(new URL('/caching-rewrite-target', request.url)) - } - if (request.nextUrl.pathname === '/test/redirect-to-cached-page') { - return NextResponse.redirect(new URL('/caching-redirect-target', request.url)) - } - - return NextResponse.json({ error: 'Error' }, { status: 500 }) -} +export { middleware } from './middleware-shared' export const config = { matcher: '/test/:path*', - runtime: 'nodejs', } diff --git a/tests/fixtures/middleware/next.config.js b/tests/fixtures/middleware/next.config.js index 69879f1c08..ee033b3e9e 100644 --- a/tests/fixtures/middleware/next.config.js +++ b/tests/fixtures/middleware/next.config.js @@ -1,9 +1,13 @@ /** @type {import('next').NextConfig} */ const nextConfig = { output: 'standalone', + distDir: process.env.NEXT_RUNTIME_MIDDLEWARE === 'nodejs' ? '.next-node-middleware' : '.next', eslint: { ignoreDuringBuilds: true, }, + experimental: { + nodeMiddleware: true, + }, webpack: (config) => { // this is a trigger to generate multiple `.next/server/middleware-[hash].js` files instead of // single `.next/server/middleware.js` file diff --git a/tests/fixtures/middleware/package.json b/tests/fixtures/middleware/package.json index f385ef9276..499867b55b 100644 --- a/tests/fixtures/middleware/package.json +++ b/tests/fixtures/middleware/package.json @@ -3,9 +3,9 @@ "version": "0.1.0", "private": true, "scripts": { - "postinstall": "next build", + "postinstallz": "npm run build", "dev": "next dev", - "build": "next build" + "build": "node ../../utils/build-variants.mjs" }, "dependencies": { "@aws-amplify/adapter-nextjs": "^1.0.18", diff --git a/tests/fixtures/middleware/test-variants.json b/tests/fixtures/middleware/test-variants.json new file mode 100644 index 0000000000..5c5d90b97b --- /dev/null +++ b/tests/fixtures/middleware/test-variants.json @@ -0,0 +1,20 @@ +{ + "node-middleware": { + "files": { + "middleware.ts": "middleware-node.ts" + }, + "env": { + "NEXT_RUNTIME_MIDDLEWARE": "nodejs" + }, + "test": { + "dependencies": { + "next": [ + { + "versionConstraint": ">=15.2.0", + "canaryOnly": true + } + ] + } + } + } +} diff --git a/tests/integration/edge-handler.test.ts b/tests/integration/edge-handler.test.ts index 23e009079c..3549390bba 100644 --- a/tests/integration/edge-handler.test.ts +++ b/tests/integration/edge-handler.test.ts @@ -5,12 +5,13 @@ import { createFixture, EDGE_MIDDLEWARE_FUNCTION_NAME, EDGE_MIDDLEWARE_SRC_FUNCTION_NAME, + NODE_MIDDLEWARE_FUNCTION_NAME, invokeEdgeFunction, runPlugin, } from '../utils/fixture.js' import { generateRandomObjectID, startMockBlobStore } from '../utils/helpers.js' import { LocalServer } from '../utils/local-server.js' -import { nextVersionSatisfies } from '../utils/next-version-helpers.mjs' +import { hasNodeMiddlewareSupport } from '../utils/next-version-helpers.mjs' beforeEach(async (ctx) => { // set for each test a new deployID and siteID @@ -21,33 +22,169 @@ beforeEach(async (ctx) => { await startMockBlobStore(ctx) }) -test('should add request/response headers', async (ctx) => { - await createFixture('middleware', ctx) - await runPlugin(ctx) +for (const { edgeFunctionName, expectedRuntime, label, runPluginConstants } of [ + { + edgeFunctionName: EDGE_MIDDLEWARE_FUNCTION_NAME, + expectedRuntime: 'edge-runtime', + label: 'Edge runtime middleware', + }, + hasNodeMiddlewareSupport() + ? { + edgeFunctionName: NODE_MIDDLEWARE_FUNCTION_NAME, + expectedRuntime: 'node', + label: 'Node.js runtime middleware', + runPluginConstants: { PUBLISH_DIR: '.next-node-middleware' }, + } + : undefined, +].filter(function isDefined(argument: T | undefined): argument is T { + return typeof argument !== 'undefined' +})) { + describe(label, () => { + test('should add request/response headers', async (ctx) => { + await createFixture('middleware', ctx) + await runPlugin(ctx, runPluginConstants) + + const origin = await LocalServer.run(async (req, res) => { + expect(req.url).toBe('/test/next') + expect(req.headers['x-hello-from-middleware-req']).toBe('hello') + + res.write('Hello from origin!') + res.end() + }) - const origin = await LocalServer.run(async (req, res) => { - expect(req.url).toBe('/test/next') - expect(req.headers['x-hello-from-middleware-req']).toBe('hello') + ctx.cleanup?.push(() => origin.stop()) - res.write('Hello from origin!') - res.end() - }) + const response = await invokeEdgeFunction(ctx, { + functions: [edgeFunctionName], + origin, + url: '/test/next', + }) + const text = await response.text() - ctx.cleanup?.push(() => origin.stop()) + expect(text).toBe('Hello from origin!') + expect(response.status).toBe(200) + expect( + response.headers.get('x-hello-from-middleware-res'), + 'added a response header', + ).toEqual('hello') + expect(response.headers.get('x-runtime')).toEqual(expectedRuntime) + expect(origin.calls).toBe(1) + }) + + describe('redirect', () => { + test('should return a redirect response', async (ctx) => { + await createFixture('middleware', ctx) + await runPlugin(ctx, runPluginConstants) + + const origin = new LocalServer() + const response = await invokeEdgeFunction(ctx, { + functions: [edgeFunctionName], + origin, + redirect: 'manual', + url: '/test/redirect', + }) + + ctx.cleanup?.push(() => origin.stop()) + + expect(response.status).toBe(307) + expect(response.headers.get('location'), 'added a location header').toBeTypeOf('string') + expect( + new URL(response.headers.get('location') as string).pathname, + 'redirected to the correct path', + ).toEqual('/other') + expect(response.headers.get('x-runtime')).toEqual(expectedRuntime) + expect(origin.calls).toBe(0) + }) - const response = await invokeEdgeFunction(ctx, { - functions: [EDGE_MIDDLEWARE_FUNCTION_NAME], - origin, - url: '/test/next', - }) + test('should return a redirect response with additional headers', async (ctx) => { + await createFixture('middleware', ctx) + await runPlugin(ctx, runPluginConstants) + + const origin = new LocalServer() + const response = await invokeEdgeFunction(ctx, { + functions: [edgeFunctionName], + origin, + redirect: 'manual', + url: '/test/redirect-with-headers', + }) + + ctx.cleanup?.push(() => origin.stop()) + + expect(response.status).toBe(307) + expect(response.headers.get('location'), 'added a location header').toBeTypeOf('string') + expect( + new URL(response.headers.get('location') as string).pathname, + 'redirected to the correct path', + ).toEqual('/other') + expect(response.headers.get('x-header-from-redirect'), 'hello').toBe('hello') + expect(response.headers.get('x-runtime')).toEqual(expectedRuntime) + expect(origin.calls).toBe(0) + }) + }) - expect(await response.text()).toBe('Hello from origin!') - expect(response.status).toBe(200) - expect(response.headers.get('x-hello-from-middleware-res'), 'added a response header').toEqual( - 'hello', - ) - expect(origin.calls).toBe(1) -}) + describe('rewrite', () => { + test('should rewrite to an external URL', async (ctx) => { + await createFixture('middleware', ctx) + await runPlugin(ctx, runPluginConstants) + + const external = await LocalServer.run(async (req, res) => { + const url = new URL(req.url ?? '', 'http://localhost') + + expect(url.pathname).toBe('/some-path') + expect(url.searchParams.get('from')).toBe('middleware') + + res.write('Hello from external host!') + res.end() + }) + ctx.cleanup?.push(() => external.stop()) + + const origin = new LocalServer() + ctx.cleanup?.push(() => origin.stop()) + + const response = await invokeEdgeFunction(ctx, { + functions: [edgeFunctionName], + origin, + url: `/test/rewrite-external?external-url=http://localhost:${external.port}/some-path`, + }) + + expect(await response.text()).toBe('Hello from external host!') + expect(response.status).toBe(200) + expect(external.calls).toBe(1) + expect(origin.calls).toBe(0) + expect(response.headers.get('x-runtime')).toEqual(expectedRuntime) + }) + + test('rewriting to external URL that redirects should return said redirect', async (ctx) => { + await createFixture('middleware', ctx) + await runPlugin(ctx, runPluginConstants) + + const external = await LocalServer.run(async (req, res) => { + res.writeHead(302, { + location: 'http://example.com/redirected', + }) + res.end() + }) + ctx.cleanup?.push(() => external.stop()) + + const origin = new LocalServer() + ctx.cleanup?.push(() => origin.stop()) + + const response = await invokeEdgeFunction(ctx, { + functions: [edgeFunctionName], + origin, + url: `/test/rewrite-external?external-url=http://localhost:${external.port}/some-path`, + redirect: 'manual', + }) + + expect(await response.text()).toBe('') + + expect(response.status).toBe(302) + expect(response.headers.get('location')).toBe('http://example.com/redirected') + expect(response.headers.get('x-runtime')).toEqual(expectedRuntime) + }) + }) + }) +} test('should add request/response headers when using src dir', async (ctx) => { await createFixture('middleware-src', ctx) @@ -77,115 +214,6 @@ test('should add request/response headers when using src dir expect(origin.calls).toBe(1) }) -describe('redirect', () => { - test('should return a redirect response', async (ctx) => { - await createFixture('middleware', ctx) - await runPlugin(ctx) - - const origin = new LocalServer() - const response = await invokeEdgeFunction(ctx, { - functions: [EDGE_MIDDLEWARE_FUNCTION_NAME], - origin, - redirect: 'manual', - url: '/test/redirect', - }) - - ctx.cleanup?.push(() => origin.stop()) - - expect(response.status).toBe(307) - expect(response.headers.get('location'), 'added a location header').toBeTypeOf('string') - expect( - new URL(response.headers.get('location') as string).pathname, - 'redirected to the correct path', - ).toEqual('/other') - expect(origin.calls).toBe(0) - }) - - test('should return a redirect response with additional headers', async (ctx) => { - await createFixture('middleware', ctx) - await runPlugin(ctx) - - const origin = new LocalServer() - const response = await invokeEdgeFunction(ctx, { - functions: [EDGE_MIDDLEWARE_FUNCTION_NAME], - origin, - redirect: 'manual', - url: '/test/redirect-with-headers', - }) - - ctx.cleanup?.push(() => origin.stop()) - - expect(response.status).toBe(307) - expect(response.headers.get('location'), 'added a location header').toBeTypeOf('string') - expect( - new URL(response.headers.get('location') as string).pathname, - 'redirected to the correct path', - ).toEqual('/other') - expect(response.headers.get('x-header-from-redirect'), 'hello').toBe('hello') - expect(origin.calls).toBe(0) - }) -}) - -describe('rewrite', () => { - test('should rewrite to an external URL', async (ctx) => { - await createFixture('middleware', ctx) - await runPlugin(ctx) - - const external = await LocalServer.run(async (req, res) => { - const url = new URL(req.url ?? '', 'http://localhost') - - expect(url.pathname).toBe('/some-path') - expect(url.searchParams.get('from')).toBe('middleware') - - res.write('Hello from external host!') - res.end() - }) - ctx.cleanup?.push(() => external.stop()) - - const origin = new LocalServer() - ctx.cleanup?.push(() => origin.stop()) - - const response = await invokeEdgeFunction(ctx, { - functions: [EDGE_MIDDLEWARE_FUNCTION_NAME], - origin, - url: `/test/rewrite-external?external-url=http://localhost:${external.port}/some-path`, - }) - - expect(await response.text()).toBe('Hello from external host!') - expect(response.status).toBe(200) - expect(external.calls).toBe(1) - expect(origin.calls).toBe(0) - }) - - test('rewriting to external URL that redirects should return said redirect', async (ctx) => { - await createFixture('middleware', ctx) - await runPlugin(ctx) - - const external = await LocalServer.run(async (req, res) => { - res.writeHead(302, { - location: 'http://example.com/redirected', - }) - res.end() - }) - ctx.cleanup?.push(() => external.stop()) - - const origin = new LocalServer() - ctx.cleanup?.push(() => origin.stop()) - - const response = await invokeEdgeFunction(ctx, { - functions: [EDGE_MIDDLEWARE_FUNCTION_NAME], - origin, - url: `/test/rewrite-external?external-url=http://localhost:${external.port}/some-path`, - redirect: 'manual', - }) - - expect(await response.text()).toBe('') - - expect(response.status).toBe(302) - expect(response.headers.get('location')).toBe('http://example.com/redirected') - }) -}) - describe("aborts middleware execution when the matcher conditions don't match the request", () => { test('when the path is excluded', async (ctx) => { await createFixture('middleware', ctx) @@ -633,14 +661,3 @@ describe('page router', () => { expect(bodyFr.nextUrlLocale).toBe('fr') }) }) - -// this is now actually deploying -// test.skipIf(!nextVersionSatisfies('>=15.2.0'))( -// 'should throw an Not Supported error when node middleware is used', -// async (ctx) => { -// await createFixture('middleware-node', ctx) -// await expect(runPlugin(ctx)).rejects.toThrow( -// 'Only Edge Runtime Middleware is supported. Node.js Middleware is not supported.', -// ) -// }, -// ) diff --git a/tests/utils/build-variants.mjs b/tests/utils/build-variants.mjs new file mode 100644 index 0000000000..084cc01fb8 --- /dev/null +++ b/tests/utils/build-variants.mjs @@ -0,0 +1,146 @@ +// @ts-check + +import { cwd, argv } from 'node:process' +import { join } from 'node:path/posix' +import { readFile, cp, rm } from 'node:fs/promises' +import { createRequire } from 'node:module' + +import { execaCommand } from 'execa' +import { satisfies } from 'semver' + +/** + * @typedef VariantExpandedCondition + * @type {object} + * @property {string} versionConstraint + * @property {boolean} [canaryOnly] + */ + +/** + * @typedef VariantTest + * @type {object} + * @property {Record} dependencies + */ + +/** + * @typedef VariantDescription + * @type {object} + * @property {Record} [files] file overwrites + * @property {Record} [env] environment variables to set + * @property {VariantTest} [test] check if version constraints for variant are met + * @property {string} [buildCommand] command to run + */ + +/** @type {Record} */ +const variantsInput = JSON.parse(await readFile(join(cwd(), 'test-variants.json'), 'utf-8')) + +let packageJson = {} +try { + packageJson = JSON.parse(await readFile(join(cwd(), 'package.json'), 'utf-8')) +} catch {} + +/** @type {Record} */ +const variants = { + ...variantsInput, + // create default even if not in input, we will need empty object to make sure there is default variant and we can use defaults for everything + default: { + // if package.json#test exists, use it + test: packageJson?.test, + // use any overwrites from variants file + ...variantsInput.default, + }, +} + +// build variants declared by args or build everything if not args provided +const variantsToBuild = argv.length > 2 ? argv.slice(2) : Object.keys(variants) + +/** @type {string[]} */ +const notExistingVariants = [] +for (const variantToBuild of variantsToBuild) { + if (!variants[variantToBuild]) { + notExistingVariants.push(variantToBuild) + } +} + +if (notExistingVariants.length > 0) { + throw new Error( + `[build-variants] Variants do not exist: ${notExistingVariants.join(', ')}. Existing variants: ${Object.keys(variants).join(', ')}`, + ) +} + +/** + * Checks if a given version satisfies a constraint and, if `canaryOnly` is true, if it is a canary version. + * @param {string} version The version to check. + * @param {string} constraint The constraint to check against. + * @param {boolean} canaryOnly If true, only canary versions are allowed. + * @return {boolean} True if the version satisfies the constraint and the canary requirement. + */ +function satisfiesConstraint(version, constraint, canaryOnly) { + if (!satisfies(version, constraint, { includePrerelease: true })) { + return false + } + if (canaryOnly && !version.includes('-canary')) { + // If canaryOnly is true, we only allow canary versions + return false + } + return true +} + +for (const variantToBuild of variantsToBuild) { + const variant = variants[variantToBuild] + + if (variant.test?.dependencies?.next) { + const nextCondition = variant.test.dependencies.next + + // get next.js version + const { version } = createRequire(join(cwd(), 'package.json'))('next/package.json') + + const constraintsSatisfied = + typeof nextCondition === 'string' + ? satisfiesConstraint(version, nextCondition, false) + : nextCondition.some(({ versionConstraint, canaryOnly }) => + satisfiesConstraint(version, versionConstraint, canaryOnly ?? false), + ) + + if (!constraintsSatisfied) { + console.log( + `[build-variants] Skipping ${variantToBuild} variant because next version (${version}) or canary status (${version.includes('-canary') ? 'is canary' : 'not canary'}) does not satisfy version constraint:\n${JSON.stringify(nextCondition, null, 2)}`, + ) + continue + } + } + + console.log(`[build-variants] Building ${variantToBuild} variant`) + + /** @type {(() => Promise)[]} */ + const cleanupTasks = [] + + for (const [target, source] of Object.entries(variant.files ?? {})) { + const targetBackup = `${target}.bak` + // create backup + await cp(target, targetBackup, { force: true }) + // overwrite with new file + await cp(source, target, { force: true }) + + cleanupTasks.push(async () => { + // restore original + await cp(targetBackup, target, { force: true }) + // remove backup + await rm(targetBackup, { force: true }) + }) + } + + const result = await execaCommand(variant.buildCommand ?? 'next build', { + env: { + ...process.env, + ...variant.env, + }, + stdio: 'inherit', + }) + + // cleanup + await Promise.all(cleanupTasks.map((task) => task())) + + if (result.exitCode !== 0) { + throw new Error(`[build-variants] Failed to build ${variantToBuild} variant`) + } +} diff --git a/tests/utils/create-e2e-fixture.ts b/tests/utils/create-e2e-fixture.ts index 0999c03db2..65cf9dc227 100644 --- a/tests/utils/create-e2e-fixture.ts +++ b/tests/utils/create-e2e-fixture.ts @@ -314,6 +314,10 @@ async function cleanup(dest: string, deployId?: string): Promise { await Promise.allSettled([deleteDeploy(deployId), rm(dest, { recursive: true, force: true })]) } +function getBuildFixtureVariantCommand(variantName: string) { + return `node ${fileURLToPath(new URL(`./build-variants.mjs`, import.meta.url))} ${variantName}` +} + export const fixtureFactories = { simple: () => createE2EFixture('simple'), helloWorldTurbopack: () => @@ -337,6 +341,11 @@ export const fixtureFactories = { pnpm: () => createE2EFixture('pnpm', { packageManger: 'pnpm' }), bun: () => createE2EFixture('simple', { packageManger: 'bun' }), middleware: () => createE2EFixture('middleware'), + middlewareNode: () => + createE2EFixture('middleware', { + buildCommand: getBuildFixtureVariantCommand('node-middleware'), + publishDirectory: '.next-node-middleware', + }), middlewareI18n: () => createE2EFixture('middleware-i18n'), middlewareI18nExcludedPaths: () => createE2EFixture('middleware-i18n-excluded-paths'), middlewareOg: () => createE2EFixture('middleware-og'), diff --git a/tests/utils/fixture.ts b/tests/utils/fixture.ts index 852d2ba7c2..2736be62c2 100644 --- a/tests/utils/fixture.ts +++ b/tests/utils/fixture.ts @@ -561,8 +561,6 @@ export async function invokeSandboxedFunction( return result } -// export const EDGE_MIDDLEWARE_FUNCTION_NAME = '___netlify-edge-handler-middleware' -// export const EDGE_MIDDLEWARE_SRC_FUNCTION_NAME = '___netlify-edge-handler-src-middleware' -// for right now we will use node middleware in tests -export const EDGE_MIDDLEWARE_FUNCTION_NAME = '___netlify-edge-handler-node-middleware' -export const EDGE_MIDDLEWARE_SRC_FUNCTION_NAME = EDGE_MIDDLEWARE_FUNCTION_NAME +export const EDGE_MIDDLEWARE_FUNCTION_NAME = '___netlify-edge-handler-middleware' +export const EDGE_MIDDLEWARE_SRC_FUNCTION_NAME = '___netlify-edge-handler-src-middleware' +export const NODE_MIDDLEWARE_FUNCTION_NAME = '___netlify-edge-handler-node-middleware' diff --git a/tests/utils/next-version-helpers.mjs b/tests/utils/next-version-helpers.mjs index b9bffcc28c..32b068c84e 100644 --- a/tests/utils/next-version-helpers.mjs +++ b/tests/utils/next-version-helpers.mjs @@ -38,6 +38,10 @@ export function shouldHaveAppRouterNotFoundInPrerenderManifest() { return nextVersionSatisfies('>=15.4.2-canary.33') && isNextCanary() } +export function hasNodeMiddlewareSupport() { + return nextVersionSatisfies('>=15.2.0') && isNextCanary() +} + /** * Check if current next version requires React 19 * @param {string} version Next version From 9ea923bcd8487624eaa947b44bd61f6ffef897e7 Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Wed, 20 Aug 2025 19:09:59 +0200 Subject: [PATCH 12/47] fix: shim global process --- src/build/functions/edge.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/build/functions/edge.ts b/src/build/functions/edge.ts index d9f6d2167e..dee4c68c72 100644 --- a/src/build/functions/edge.ts +++ b/src/build/functions/edge.ts @@ -343,8 +343,10 @@ export const createEdgeHandlers = async (ctx: PluginContext) => { import { init as htmlRewriterInit } from './edge-runtime/vendor/deno.land/x/htmlrewriter@v1.0.0/src/index.ts' import { handleMiddleware } from './edge-runtime/middleware.ts'; import { registerCJSModules } from "./edge-runtime/lib/cjs.ts"; + import process from 'node:process' import { AsyncLocalStorage } from 'node:async_hooks'; + globalThis.process = process globalThis.AsyncLocalStorage = AsyncLocalStorage; // needed for path.relative and path.resolve to work From dd6a38327f1fcde6e4935abd4b465b2976b055a6 Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Wed, 20 Aug 2025 19:11:08 +0200 Subject: [PATCH 13/47] fix: add additional context on CJS module compilation failures to point to specific modules --- edge-runtime/lib/cjs.ts | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/edge-runtime/lib/cjs.ts b/edge-runtime/lib/cjs.ts index c8be0c561a..dc651cd08d 100644 --- a/edge-runtime/lib/cjs.ts +++ b/edge-runtime/lib/cjs.ts @@ -29,13 +29,17 @@ function seedCJSModuleCacheAndReturnTarget(matchedModule: RegisteredModule, pare require.cache[filename] = mod const wrappedSource = `(function (exports, require, module, __filename, __dirname) { ${source}\n});` - const compiled = vm.runInThisContext(wrappedSource, { - filename, - lineOffset: 0, - displayErrors: true, - }) - compiled(mod.exports, createRequire(pathToFileURL(filename)), mod, filename, dirname(filename)) - mod.loaded = matchedModule.loaded = true + try { + const compiled = vm.runInThisContext(wrappedSource, { + filename, + lineOffset: 0, + displayErrors: true, + }) + compiled(mod.exports, createRequire(pathToFileURL(filename)), mod, filename, dirname(filename)) + mod.loaded = matchedModule.loaded = true + } catch (error) { + throw new Error(`Failed to compile CJS module: ${filename}`, { cause: error }) + } return filename } From e74af04a2426d2edd32d880c8c3cb8556526b5b0 Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Wed, 20 Aug 2025 19:14:42 +0200 Subject: [PATCH 14/47] ci: move back to using latest now that node middleware landed as stable --- .github/workflows/run-tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 4a9445fdf0..a627efb189 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -38,7 +38,7 @@ jobs: elif [ "${{ github.event_name }}" = "schedule" ] || [ "${{ steps.check-labels.outputs.result }}" = "true" ]; then echo "matrix=[\"latest\", \"canary\", \"14.2.15\", \"13.5.1\"]" >> $GITHUB_OUTPUT else - echo "matrix=[\"canary\"]" >> $GITHUB_OUTPUT + echo "matrix=[\"latest\"]" >> $GITHUB_OUTPUT fi e2e: From 5a1dea11deadf83249b832009fd894131b5513dc Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Wed, 20 Aug 2025 20:04:46 +0200 Subject: [PATCH 15/47] chore: enable node middleware tests in stable --- tests/fixtures/middleware/test-variants.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/fixtures/middleware/test-variants.json b/tests/fixtures/middleware/test-variants.json index 5c5d90b97b..a45921eca8 100644 --- a/tests/fixtures/middleware/test-variants.json +++ b/tests/fixtures/middleware/test-variants.json @@ -12,6 +12,9 @@ { "versionConstraint": ">=15.2.0", "canaryOnly": true + }, + { + "versionConstraint": ">=15.5.0" } ] } From 4a17111f0d210487a0eeb07b5de8b495a4bffe1b Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Wed, 20 Aug 2025 20:16:51 +0200 Subject: [PATCH 16/47] test: specify distDir in variants config --- tests/fixtures/middleware/next.config.js | 2 +- tests/fixtures/middleware/test-variants.json | 4 +-- tests/utils/build-variants.mjs | 26 ++++++++++++++------ 3 files changed, 20 insertions(+), 12 deletions(-) diff --git a/tests/fixtures/middleware/next.config.js b/tests/fixtures/middleware/next.config.js index ee033b3e9e..6cb247177b 100644 --- a/tests/fixtures/middleware/next.config.js +++ b/tests/fixtures/middleware/next.config.js @@ -1,7 +1,7 @@ /** @type {import('next').NextConfig} */ const nextConfig = { output: 'standalone', - distDir: process.env.NEXT_RUNTIME_MIDDLEWARE === 'nodejs' ? '.next-node-middleware' : '.next', + distDir: process.env.NEXT_DIST_DIR ?? '.next', eslint: { ignoreDuringBuilds: true, }, diff --git a/tests/fixtures/middleware/test-variants.json b/tests/fixtures/middleware/test-variants.json index a45921eca8..f31d535c28 100644 --- a/tests/fixtures/middleware/test-variants.json +++ b/tests/fixtures/middleware/test-variants.json @@ -1,11 +1,9 @@ { "node-middleware": { + "distDir": ".next-node-middleware", "files": { "middleware.ts": "middleware-node.ts" }, - "env": { - "NEXT_RUNTIME_MIDDLEWARE": "nodejs" - }, "test": { "dependencies": { "next": [ diff --git a/tests/utils/build-variants.mjs b/tests/utils/build-variants.mjs index 084cc01fb8..0045dff5bd 100644 --- a/tests/utils/build-variants.mjs +++ b/tests/utils/build-variants.mjs @@ -28,6 +28,7 @@ import { satisfies } from 'semver' * @property {Record} [env] environment variables to set * @property {VariantTest} [test] check if version constraints for variant are met * @property {string} [buildCommand] command to run + * @property {string} [distDir] directory to output build artifacts (will be set as ) */ /** @type {Record} */ @@ -85,6 +86,14 @@ function satisfiesConstraint(version, constraint, canaryOnly) { return true } +/** @type {(() => Promise)[]} */ +let cleanupTasks = [] + +async function runCleanup() { + await Promise.all(cleanupTasks.map((task) => task())) + cleanupTasks = [] +} + for (const variantToBuild of variantsToBuild) { const variant = variants[variantToBuild] @@ -102,17 +111,18 @@ for (const variantToBuild of variantsToBuild) { ) if (!constraintsSatisfied) { - console.log( + console.warn( `[build-variants] Skipping ${variantToBuild} variant because next version (${version}) or canary status (${version.includes('-canary') ? 'is canary' : 'not canary'}) does not satisfy version constraint:\n${JSON.stringify(nextCondition, null, 2)}`, ) continue } } - console.log(`[build-variants] Building ${variantToBuild} variant`) - - /** @type {(() => Promise)[]} */ - const cleanupTasks = [] + const buildCommand = variant.buildCommand ?? 'next build' + const distDir = variant.distDir ?? '.next' + console.warn( + `[build-variants] Building ${variantToBuild} variant with \`${buildCommand}\` to \`${distDir}\``, + ) for (const [target, source] of Object.entries(variant.files ?? {})) { const targetBackup = `${target}.bak` @@ -129,16 +139,16 @@ for (const variantToBuild of variantsToBuild) { }) } - const result = await execaCommand(variant.buildCommand ?? 'next build', { + const result = await execaCommand(buildCommand, { env: { ...process.env, ...variant.env, + NEXT_DIST_DIR: distDir, }, stdio: 'inherit', }) - // cleanup - await Promise.all(cleanupTasks.map((task) => task())) + await runCleanup() if (result.exitCode !== 0) { throw new Error(`[build-variants] Failed to build ${variantToBuild} variant`) From 31f83f540313a4d4eb901d7de36ed5b17711dd51 Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Wed, 20 Aug 2025 20:19:11 +0200 Subject: [PATCH 17/47] test: comment out test checking expected deploy failure with node middleware --- tests/integration/edge-handler.test.ts | 38 +++++++++++++------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/tests/integration/edge-handler.test.ts b/tests/integration/edge-handler.test.ts index 4f3afa4b62..b926ad0aa7 100644 --- a/tests/integration/edge-handler.test.ts +++ b/tests/integration/edge-handler.test.ts @@ -662,22 +662,22 @@ describe('page router', () => { }) }) -test.skipIf(!nextVersionSatisfies('>=15.2.0'))( - 'should throw an Not Supported error when node middleware is used', - async (ctx) => { - await createFixture('middleware-node', ctx) - - const runPluginPromise = runPlugin(ctx) - - await expect(runPluginPromise).rejects.toThrow('Node.js middleware is not yet supported.') - await expect(runPluginPromise).rejects.toThrow( - 'Future @netlify/plugin-nextjs release will support node middleware with following limitations:', - ) - await expect(runPluginPromise).rejects.toThrow( - ' - usage of C++ Addons (https://nodejs.org/api/addons.html) not supported (for example `bcrypt` npm module will not be supported, but `bcryptjs` will be supported)', - ) - await expect(runPluginPromise).rejects.toThrow( - ' - usage of Filesystem (https://nodejs.org/api/fs.html) not supported', - ) - }, -) +// test.skipIf(!nextVersionSatisfies('>=15.2.0'))( +// 'should throw an Not Supported error when node middleware is used', +// async (ctx) => { +// await createFixture('middleware-node', ctx) + +// const runPluginPromise = runPlugin(ctx) + +// await expect(runPluginPromise).rejects.toThrow('Node.js middleware is not yet supported.') +// await expect(runPluginPromise).rejects.toThrow( +// 'Future @netlify/plugin-nextjs release will support node middleware with following limitations:', +// ) +// await expect(runPluginPromise).rejects.toThrow( +// ' - usage of C++ Addons (https://nodejs.org/api/addons.html) not supported (for example `bcrypt` npm module will not be supported, but `bcryptjs` will be supported)', +// ) +// await expect(runPluginPromise).rejects.toThrow( +// ' - usage of Filesystem (https://nodejs.org/api/fs.html) not supported', +// ) +// }, +// ) From 1a0ab5cc325beeb6529bf11d39c96915aae37e56 Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Wed, 20 Aug 2025 21:18:09 +0200 Subject: [PATCH 18/47] test: move middleware-src to test variants --- .gitignore | 1 + tests/fixtures/middleware-src/next.config.js | 4 + tests/fixtures/middleware-src/package.json | 4 +- .../middleware-src/src/middleware-node.ts | 6 ++ .../middleware-src/src/middleware-shared.ts | 33 ++++++++ .../fixtures/middleware-src/src/middleware.ts | 28 +------ .../middleware-src/test-variants.json | 21 +++++ tests/fixtures/middleware/.gitignore | 1 - tests/integration/edge-handler.test.ts | 84 +++++++++++-------- 9 files changed, 116 insertions(+), 66 deletions(-) create mode 100644 tests/fixtures/middleware-src/src/middleware-node.ts create mode 100644 tests/fixtures/middleware-src/src/middleware-shared.ts create mode 100644 tests/fixtures/middleware-src/test-variants.json delete mode 100644 tests/fixtures/middleware/.gitignore diff --git a/.gitignore b/.gitignore index 96d3677625..527665ac4f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ node_modules/ dist/ .next +.next-node-middleware edge-runtime/vendor # deno.json is ephemeral and generated for the purpose of vendoring remote modules in CI tools/deno/deno.json diff --git a/tests/fixtures/middleware-src/next.config.js b/tests/fixtures/middleware-src/next.config.js index 03919602f2..c8df7fbfa6 100644 --- a/tests/fixtures/middleware-src/next.config.js +++ b/tests/fixtures/middleware-src/next.config.js @@ -1,9 +1,13 @@ /** @type {import('next').NextConfig} */ const nextConfig = { output: 'standalone', + distDir: process.env.NEXT_DIST_DIR ?? '.next', eslint: { ignoreDuringBuilds: true, }, + experimental: { + nodeMiddleware: true, + }, outputFileTracingRoot: __dirname, } diff --git a/tests/fixtures/middleware-src/package.json b/tests/fixtures/middleware-src/package.json index b3f8a8dec2..884a420458 100644 --- a/tests/fixtures/middleware-src/package.json +++ b/tests/fixtures/middleware-src/package.json @@ -3,9 +3,9 @@ "version": "0.1.0", "private": true, "scripts": { - "postinstall": "next build", + "postinstall": "npm run build", "dev": "next dev", - "build": "next build" + "build": "node ../../utils/build-variants.mjs" }, "dependencies": { "next": "latest", diff --git a/tests/fixtures/middleware-src/src/middleware-node.ts b/tests/fixtures/middleware-src/src/middleware-node.ts new file mode 100644 index 0000000000..26fed28b6b --- /dev/null +++ b/tests/fixtures/middleware-src/src/middleware-node.ts @@ -0,0 +1,6 @@ +export { middleware } from './middleware-shared' + +export const config = { + matcher: '/test/:path*', + runtime: 'nodejs', +} diff --git a/tests/fixtures/middleware-src/src/middleware-shared.ts b/tests/fixtures/middleware-src/src/middleware-shared.ts new file mode 100644 index 0000000000..483d616d8f --- /dev/null +++ b/tests/fixtures/middleware-src/src/middleware-shared.ts @@ -0,0 +1,33 @@ +import type { NextRequest } from 'next/server' +import { NextResponse } from 'next/server' + +export function middleware(request: NextRequest) { + const response = getResponse(request) + + response.headers.append('Deno' in globalThis ? 'x-deno' : 'x-node', Date.now().toString()) + // report Next.js Middleware Runtime (not the execution runtime, but target runtime) + response.headers.append('x-runtime', typeof EdgeRuntime !== 'undefined' ? EdgeRuntime : 'node') + response.headers.set('x-hello-from-middleware-res', 'hello') + + return response +} + +const getResponse = (request: NextRequest) => { + const requestHeaders = new Headers(request.headers) + + requestHeaders.set('x-hello-from-middleware-req', 'hello') + + if (request.nextUrl.pathname === '/test/next') { + return NextResponse.next({ + request: { + headers: requestHeaders, + }, + }) + } + + return NextResponse.json({ error: 'Error' }, { status: 500 }) +} + +export const config = { + matcher: '/test/:path*', +} diff --git a/tests/fixtures/middleware-src/src/middleware.ts b/tests/fixtures/middleware-src/src/middleware.ts index 247e7755c3..708f3c7e81 100644 --- a/tests/fixtures/middleware-src/src/middleware.ts +++ b/tests/fixtures/middleware-src/src/middleware.ts @@ -1,30 +1,4 @@ -import type { NextRequest } from 'next/server' -import { NextResponse } from 'next/server' - -export function middleware(request: NextRequest) { - const response = getResponse(request) - - response.headers.append('Deno' in globalThis ? 'x-deno' : 'x-node', Date.now().toString()) - response.headers.set('x-hello-from-middleware-res', 'hello') - - return response -} - -const getResponse = (request: NextRequest) => { - const requestHeaders = new Headers(request.headers) - - requestHeaders.set('x-hello-from-middleware-req', 'hello') - - if (request.nextUrl.pathname === '/test/next') { - return NextResponse.next({ - request: { - headers: requestHeaders, - }, - }) - } - - return NextResponse.json({ error: 'Error' }, { status: 500 }) -} +export { middleware } from './middleware-shared' export const config = { matcher: '/test/:path*', diff --git a/tests/fixtures/middleware-src/test-variants.json b/tests/fixtures/middleware-src/test-variants.json new file mode 100644 index 0000000000..8bcfdd30d0 --- /dev/null +++ b/tests/fixtures/middleware-src/test-variants.json @@ -0,0 +1,21 @@ +{ + "node-middleware": { + "distDir": ".next-node-middleware", + "files": { + "src/middleware.ts": "src/middleware-node.ts" + }, + "test": { + "dependencies": { + "next": [ + { + "versionConstraint": ">=15.2.0", + "canaryOnly": true + }, + { + "versionConstraint": ">=15.5.0" + } + ] + } + } + } +} diff --git a/tests/fixtures/middleware/.gitignore b/tests/fixtures/middleware/.gitignore deleted file mode 100644 index 43ce5f5532..0000000000 --- a/tests/fixtures/middleware/.gitignore +++ /dev/null @@ -1 +0,0 @@ -.next-node-middleware diff --git a/tests/integration/edge-handler.test.ts b/tests/integration/edge-handler.test.ts index b926ad0aa7..a6467c35a8 100644 --- a/tests/integration/edge-handler.test.ts +++ b/tests/integration/edge-handler.test.ts @@ -22,15 +22,23 @@ beforeEach(async (ctx) => { await startMockBlobStore(ctx) }) -for (const { edgeFunctionName, expectedRuntime, label, runPluginConstants } of [ +for (const { + edgeFunctionNameRoot, + edgeFunctionNameSrc, + expectedRuntime, + label, + runPluginConstants, +} of [ { - edgeFunctionName: EDGE_MIDDLEWARE_FUNCTION_NAME, + edgeFunctionNameRoot: EDGE_MIDDLEWARE_FUNCTION_NAME, + edgeFunctionNameSrc: EDGE_MIDDLEWARE_SRC_FUNCTION_NAME, expectedRuntime: 'edge-runtime', label: 'Edge runtime middleware', }, hasNodeMiddlewareSupport() ? { - edgeFunctionName: NODE_MIDDLEWARE_FUNCTION_NAME, + edgeFunctionNameRoot: NODE_MIDDLEWARE_FUNCTION_NAME, + edgeFunctionNameSrc: NODE_MIDDLEWARE_FUNCTION_NAME, expectedRuntime: 'node', label: 'Node.js runtime middleware', runPluginConstants: { PUBLISH_DIR: '.next-node-middleware' }, @@ -55,7 +63,7 @@ for (const { edgeFunctionName, expectedRuntime, label, runPluginConstants } of [ ctx.cleanup?.push(() => origin.stop()) const response = await invokeEdgeFunction(ctx, { - functions: [edgeFunctionName], + functions: [edgeFunctionNameRoot], origin, url: '/test/next', }) @@ -71,6 +79,38 @@ for (const { edgeFunctionName, expectedRuntime, label, runPluginConstants } of [ expect(origin.calls).toBe(1) }) + test.only('should add request/response headers when using src dir', async (ctx) => { + await createFixture('middleware-src', ctx) + await runPlugin(ctx, runPluginConstants) + + const origin = await LocalServer.run(async (req, res) => { + expect(req.url).toBe('/test/next') + expect(req.headers['x-hello-from-middleware-req']).toBe('hello') + + res.write('Hello from origin!') + res.end() + }) + + ctx.cleanup?.push(() => origin.stop()) + + const response = await invokeEdgeFunction(ctx, { + functions: [edgeFunctionNameSrc], + origin, + url: '/test/next', + }) + + console.log(response) + + expect(await response.text()).toBe('Hello from origin!') + expect(response.status).toBe(200) + expect( + response.headers.get('x-hello-from-middleware-res'), + 'added a response header', + ).toEqual('hello') + expect(response.headers.get('x-runtime')).toEqual(expectedRuntime) + expect(origin.calls).toBe(1) + }) + describe('redirect', () => { test('should return a redirect response', async (ctx) => { await createFixture('middleware', ctx) @@ -78,7 +118,7 @@ for (const { edgeFunctionName, expectedRuntime, label, runPluginConstants } of [ const origin = new LocalServer() const response = await invokeEdgeFunction(ctx, { - functions: [edgeFunctionName], + functions: [edgeFunctionNameRoot], origin, redirect: 'manual', url: '/test/redirect', @@ -102,7 +142,7 @@ for (const { edgeFunctionName, expectedRuntime, label, runPluginConstants } of [ const origin = new LocalServer() const response = await invokeEdgeFunction(ctx, { - functions: [edgeFunctionName], + functions: [edgeFunctionNameRoot], origin, redirect: 'manual', url: '/test/redirect-with-headers', @@ -142,7 +182,7 @@ for (const { edgeFunctionName, expectedRuntime, label, runPluginConstants } of [ ctx.cleanup?.push(() => origin.stop()) const response = await invokeEdgeFunction(ctx, { - functions: [edgeFunctionName], + functions: [edgeFunctionNameRoot], origin, url: `/test/rewrite-external?external-url=http://localhost:${external.port}/some-path`, }) @@ -170,7 +210,7 @@ for (const { edgeFunctionName, expectedRuntime, label, runPluginConstants } of [ ctx.cleanup?.push(() => origin.stop()) const response = await invokeEdgeFunction(ctx, { - functions: [edgeFunctionName], + functions: [edgeFunctionNameRoot], origin, url: `/test/rewrite-external?external-url=http://localhost:${external.port}/some-path`, redirect: 'manual', @@ -186,34 +226,6 @@ for (const { edgeFunctionName, expectedRuntime, label, runPluginConstants } of [ }) } -test('should add request/response headers when using src dir', async (ctx) => { - await createFixture('middleware-src', ctx) - await runPlugin(ctx) - - const origin = await LocalServer.run(async (req, res) => { - expect(req.url).toBe('/test/next') - expect(req.headers['x-hello-from-middleware-req']).toBe('hello') - - res.write('Hello from origin!') - res.end() - }) - - ctx.cleanup?.push(() => origin.stop()) - - const response = await invokeEdgeFunction(ctx, { - functions: [EDGE_MIDDLEWARE_SRC_FUNCTION_NAME], - origin, - url: '/test/next', - }) - - expect(await response.text()).toBe('Hello from origin!') - expect(response.status).toBe(200) - expect(response.headers.get('x-hello-from-middleware-res'), 'added a response header').toEqual( - 'hello', - ) - expect(origin.calls).toBe(1) -}) - describe("aborts middleware execution when the matcher conditions don't match the request", () => { test('when the path is excluded', async (ctx) => { await createFixture('middleware', ctx) From abd729f11fe6ac81b49718f15fc1315ea874b55a Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Wed, 20 Aug 2025 21:18:26 +0200 Subject: [PATCH 19/47] test: update hasNodeMiddlewareSupport check --- tests/utils/next-version-helpers.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/utils/next-version-helpers.mjs b/tests/utils/next-version-helpers.mjs index d43a869282..23b83b5e1c 100644 --- a/tests/utils/next-version-helpers.mjs +++ b/tests/utils/next-version-helpers.mjs @@ -39,7 +39,7 @@ export function shouldHaveAppRouterNotFoundInPrerenderManifest() { } export function hasNodeMiddlewareSupport() { - return nextVersionSatisfies('>=15.2.0') && isNextCanary() + return nextVersionSatisfies(isNextCanary() ? '>=15.2.0' : '>=15.5.0') } /** From c1efaf11e9aa87cc1b98557821b48293152af79b Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Wed, 20 Aug 2025 21:18:37 +0200 Subject: [PATCH 20/47] tmp: run latest and canary for now --- .github/workflows/run-tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index a627efb189..9e902cbff1 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -38,7 +38,7 @@ jobs: elif [ "${{ github.event_name }}" = "schedule" ] || [ "${{ steps.check-labels.outputs.result }}" = "true" ]; then echo "matrix=[\"latest\", \"canary\", \"14.2.15\", \"13.5.1\"]" >> $GITHUB_OUTPUT else - echo "matrix=[\"latest\"]" >> $GITHUB_OUTPUT + echo "matrix=[\"latest\",\"canary\"]" >> $GITHUB_OUTPUT fi e2e: From 3e7238c07fc2112a3955aa60d702ec775b3f2d4c Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Thu, 21 Aug 2025 07:58:47 +0200 Subject: [PATCH 21/47] test: remove .only --- tests/integration/edge-handler.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/edge-handler.test.ts b/tests/integration/edge-handler.test.ts index a6467c35a8..2ecc713108 100644 --- a/tests/integration/edge-handler.test.ts +++ b/tests/integration/edge-handler.test.ts @@ -79,7 +79,7 @@ for (const { expect(origin.calls).toBe(1) }) - test.only('should add request/response headers when using src dir', async (ctx) => { + test('should add request/response headers when using src dir', async (ctx) => { await createFixture('middleware-src', ctx) await runPlugin(ctx, runPluginConstants) From 99df488cb60c7d533a52bd320ee2a7e652edfb6d Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Thu, 21 Aug 2025 08:23:05 +0200 Subject: [PATCH 22/47] test: move middleware-conditions to test variants --- .../middleware-conditions/middleware-node.ts | 18 ++ .../middleware-shared.ts | 10 + .../middleware-conditions/middleware.ts | 11 +- .../middleware-conditions/next.config.js | 4 + .../middleware-conditions/package.json | 4 +- .../middleware-conditions/test-variants.json | 21 ++ tests/integration/edge-handler.test.ts | 190 +++++++++--------- 7 files changed, 152 insertions(+), 106 deletions(-) create mode 100644 tests/fixtures/middleware-conditions/middleware-node.ts create mode 100644 tests/fixtures/middleware-conditions/middleware-shared.ts create mode 100644 tests/fixtures/middleware-conditions/test-variants.json diff --git a/tests/fixtures/middleware-conditions/middleware-node.ts b/tests/fixtures/middleware-conditions/middleware-node.ts new file mode 100644 index 0000000000..2fe1090276 --- /dev/null +++ b/tests/fixtures/middleware-conditions/middleware-node.ts @@ -0,0 +1,18 @@ +export { middleware } from './middleware-shared' + +export const config = { + runtime: 'nodejs', + matcher: [ + { + source: '/foo', + missing: [{ type: 'header', key: 'x-custom-header', value: 'custom-value' }], + }, + { + source: '/hello', + }, + { + source: '/nl/about', + locale: false, + }, + ], +} diff --git a/tests/fixtures/middleware-conditions/middleware-shared.ts b/tests/fixtures/middleware-conditions/middleware-shared.ts new file mode 100644 index 0000000000..491e23c5b3 --- /dev/null +++ b/tests/fixtures/middleware-conditions/middleware-shared.ts @@ -0,0 +1,10 @@ +import type { NextRequest } from 'next/server' +import { NextResponse } from 'next/server' + +export function middleware(request: NextRequest) { + const response: NextResponse = NextResponse.next() + + response.headers.set('x-hello-from-middleware-res', 'hello') + + return response +} diff --git a/tests/fixtures/middleware-conditions/middleware.ts b/tests/fixtures/middleware-conditions/middleware.ts index fdb332cf8e..0e610af64a 100644 --- a/tests/fixtures/middleware-conditions/middleware.ts +++ b/tests/fixtures/middleware-conditions/middleware.ts @@ -1,13 +1,4 @@ -import type { NextRequest } from 'next/server' -import { NextResponse } from 'next/server' - -export function middleware(request: NextRequest) { - const response: NextResponse = NextResponse.next() - - response.headers.set('x-hello-from-middleware-res', 'hello') - - return response -} +export { middleware } from './middleware-shared' export const config = { matcher: [ diff --git a/tests/fixtures/middleware-conditions/next.config.js b/tests/fixtures/middleware-conditions/next.config.js index 4cb9dfb916..d14c96ccd1 100644 --- a/tests/fixtures/middleware-conditions/next.config.js +++ b/tests/fixtures/middleware-conditions/next.config.js @@ -1,6 +1,7 @@ /** @type {import('next').NextConfig} */ const nextConfig = { output: 'standalone', + distDir: process.env.NEXT_DIST_DIR ?? '.next', i18n: { locales: ['en', 'fr', 'nl', 'es'], defaultLocale: 'en', @@ -8,6 +9,9 @@ const nextConfig = { eslint: { ignoreDuringBuilds: true, }, + experimental: { + nodeMiddleware: true, + }, outputFileTracingRoot: __dirname, } diff --git a/tests/fixtures/middleware-conditions/package.json b/tests/fixtures/middleware-conditions/package.json index 76019a6f08..f8a6e2125b 100644 --- a/tests/fixtures/middleware-conditions/package.json +++ b/tests/fixtures/middleware-conditions/package.json @@ -3,9 +3,9 @@ "version": "0.1.0", "private": true, "scripts": { - "postinstall": "next build", + "postinstall": "npm run build", "dev": "next dev", - "build": "next build" + "build": "node ../../utils/build-variants.mjs" }, "dependencies": { "next": "latest", diff --git a/tests/fixtures/middleware-conditions/test-variants.json b/tests/fixtures/middleware-conditions/test-variants.json new file mode 100644 index 0000000000..f31d535c28 --- /dev/null +++ b/tests/fixtures/middleware-conditions/test-variants.json @@ -0,0 +1,21 @@ +{ + "node-middleware": { + "distDir": ".next-node-middleware", + "files": { + "middleware.ts": "middleware-node.ts" + }, + "test": { + "dependencies": { + "next": [ + { + "versionConstraint": ">=15.2.0", + "canaryOnly": true + }, + { + "versionConstraint": ">=15.5.0" + } + ] + } + } + } +} diff --git a/tests/integration/edge-handler.test.ts b/tests/integration/edge-handler.test.ts index 2ecc713108..8cbca4e911 100644 --- a/tests/integration/edge-handler.test.ts +++ b/tests/integration/edge-handler.test.ts @@ -223,120 +223,122 @@ for (const { expect(response.headers.get('x-runtime')).toEqual(expectedRuntime) }) }) - }) -} - -describe("aborts middleware execution when the matcher conditions don't match the request", () => { - test('when the path is excluded', async (ctx) => { - await createFixture('middleware', ctx) - await runPlugin(ctx) - const origin = await LocalServer.run(async (req, res) => { - expect(req.url).toBe('/_next/data') - expect(req.headers['x-hello-from-middleware-req']).toBeUndefined() - - res.write('Hello from origin!') - res.end() - }) + describe("aborts middleware execution when the matcher conditions don't match the request", () => { + test('when the path is excluded', async (ctx) => { + await createFixture('middleware', ctx) + await runPlugin(ctx, runPluginConstants) - ctx.cleanup?.push(() => origin.stop()) + const origin = await LocalServer.run(async (req, res) => { + expect(req.url).toBe('/_next/data') + expect(req.headers['x-hello-from-middleware-req']).toBeUndefined() - const response = await invokeEdgeFunction(ctx, { - functions: [EDGE_MIDDLEWARE_FUNCTION_NAME], - origin, - url: '/_next/data', - }) + res.write('Hello from origin!') + res.end() + }) - expect(await response.text()).toBe('Hello from origin!') - expect(response.status).toBe(200) - expect(response.headers.has('x-hello-from-middleware-res')).toBeFalsy() - expect(origin.calls).toBe(1) - }) + ctx.cleanup?.push(() => origin.stop()) - test('when a request header matches a condition', async (ctx) => { - await createFixture('middleware-conditions', ctx) - await runPlugin(ctx) + const response = await invokeEdgeFunction(ctx, { + functions: [edgeFunctionNameRoot], + origin, + url: '/_next/data', + }) - const origin = await LocalServer.run(async (req, res) => { - expect(req.url).toBe('/foo') - expect(req.headers['x-hello-from-middleware-req']).toBeUndefined() + expect(await response.text()).toBe('Hello from origin!') + expect(response.status).toBe(200) + expect(response.headers.has('x-hello-from-middleware-res')).toBeFalsy() + expect(origin.calls).toBe(1) + }) - res.write('Hello from origin!') - res.end() - }) + test('when a request header matches a condition', async (ctx) => { + await createFixture('middleware-conditions', ctx) + await runPlugin(ctx, runPluginConstants) - ctx.cleanup?.push(() => origin.stop()) + const origin = await LocalServer.run(async (req, res) => { + expect(req.url).toBe('/foo') + expect(req.headers['x-hello-from-middleware-req']).toBeUndefined() - // Request 1: Middleware should run because we're not sending the header. - const response1 = await invokeEdgeFunction(ctx, { - functions: [EDGE_MIDDLEWARE_FUNCTION_NAME], - origin, - url: '/foo', - }) + res.write('Hello from origin!') + res.end() + }) - expect(await response1.text()).toBe('Hello from origin!') - expect(response1.status).toBe(200) - expect(response1.headers.has('x-hello-from-middleware-res')).toBeTruthy() - expect(origin.calls).toBe(1) + ctx.cleanup?.push(() => origin.stop()) - // Request 2: Middleware should not run because we're sending the header. - const response2 = await invokeEdgeFunction(ctx, { - headers: { - 'x-custom-header': 'custom-value', - }, - functions: [EDGE_MIDDLEWARE_FUNCTION_NAME], - origin, - url: '/foo', - }) + // Request 1: Middleware should run because we're not sending the header. + const response1 = await invokeEdgeFunction(ctx, { + functions: [edgeFunctionNameRoot], + origin, + url: '/foo', + }) - expect(await response2.text()).toBe('Hello from origin!') - expect(response2.status).toBe(200) - expect(response2.headers.has('x-hello-from-middleware-res')).toBeFalsy() - expect(origin.calls).toBe(2) - }) + expect(await response1.text()).toBe('Hello from origin!') + expect(response1.status).toBe(200) + expect(response1.headers.has('x-hello-from-middleware-res')).toBeTruthy() + expect(response1.headers.get('x-runtime')).toEqual(expectedRuntime) + expect(origin.calls).toBe(1) + + // Request 2: Middleware should not run because we're sending the header. + const response2 = await invokeEdgeFunction(ctx, { + headers: { + 'x-custom-header': 'custom-value', + }, + functions: [edgeFunctionNameRoot], + origin, + url: '/foo', + }) - test('should handle locale matching correctly', async (ctx) => { - await createFixture('middleware-conditions', ctx) - await runPlugin(ctx) + expect(await response2.text()).toBe('Hello from origin!') + expect(response2.status).toBe(200) + expect(response2.headers.has('x-hello-from-middleware-res')).toBeFalsy() + expect(origin.calls).toBe(2) + }) - const origin = await LocalServer.run(async (req, res) => { - expect(req.headers['x-hello-from-middleware-req']).toBeUndefined() + test('should handle locale matching correctly', async (ctx) => { + await createFixture('middleware-conditions', ctx) + await runPlugin(ctx, runPluginConstants) - res.write('Hello from origin!') - res.end() - }) + const origin = await LocalServer.run(async (req, res) => { + expect(req.headers['x-hello-from-middleware-req']).toBeUndefined() - ctx.cleanup?.push(() => origin.stop()) + res.write('Hello from origin!') + res.end() + }) - for (const path of ['/hello', '/en/hello', '/nl/hello', '/nl/about']) { - const response = await invokeEdgeFunction(ctx, { - functions: [EDGE_MIDDLEWARE_FUNCTION_NAME], - origin, - url: path, - }) - expect( - response.headers.has('x-hello-from-middleware-res'), - `should match ${path}`, - ).toBeTruthy() - expect(await response.text()).toBe('Hello from origin!') - expect(response.status).toBe(200) - } + ctx.cleanup?.push(() => origin.stop()) - for (const path of ['/invalid/hello', '/hello/invalid', '/about', '/en/about']) { - const response = await invokeEdgeFunction(ctx, { - functions: [EDGE_MIDDLEWARE_FUNCTION_NAME], - origin, - url: path, + for (const path of ['/hello', '/en/hello', '/nl/hello', '/nl/about']) { + const response = await invokeEdgeFunction(ctx, { + functions: [edgeFunctionNameRoot], + origin, + url: path, + }) + expect( + response.headers.has('x-hello-from-middleware-res'), + `should match ${path}`, + ).toBeTruthy() + expect(response.headers.get('x-runtime')).toEqual(expectedRuntime) + expect(await response.text()).toBe('Hello from origin!') + expect(response.status).toBe(200) + } + + for (const path of ['/invalid/hello', '/hello/invalid', '/about', '/en/about']) { + const response = await invokeEdgeFunction(ctx, { + functions: [edgeFunctionNameRoot], + origin, + url: path, + }) + expect( + response.headers.has('x-hello-from-middleware-res'), + `should not match ${path}`, + ).toBeFalsy() + expect(await response.text()).toBe('Hello from origin!') + expect(response.status).toBe(200) + } }) - expect( - response.headers.has('x-hello-from-middleware-res'), - `should not match ${path}`, - ).toBeFalsy() - expect(await response.text()).toBe('Hello from origin!') - expect(response.status).toBe(200) - } + }) }) -}) +} describe('should run middleware on data requests', () => { test('when `trailingSlash: false`', async (ctx) => { From 5c0c948f555b739cdff1f83a9a99461f83d7e34c Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Thu, 21 Aug 2025 08:41:59 +0200 Subject: [PATCH 23/47] test: move middleware-trailing-slash to test variants --- .../middleware-node.ts | 5 ++ .../middleware-shared.ts | 60 +++++++++++++ .../middleware-trailing-slash/middleware.ts | 59 +------------ .../middleware-trailing-slash/next.config.js | 4 + .../middleware-trailing-slash/package.json | 4 +- .../test-variants.json | 21 +++++ tests/integration/edge-handler.test.ts | 88 ++++++++++--------- 7 files changed, 138 insertions(+), 103 deletions(-) create mode 100644 tests/fixtures/middleware-trailing-slash/middleware-node.ts create mode 100644 tests/fixtures/middleware-trailing-slash/middleware-shared.ts create mode 100644 tests/fixtures/middleware-trailing-slash/test-variants.json diff --git a/tests/fixtures/middleware-trailing-slash/middleware-node.ts b/tests/fixtures/middleware-trailing-slash/middleware-node.ts new file mode 100644 index 0000000000..780faa76fc --- /dev/null +++ b/tests/fixtures/middleware-trailing-slash/middleware-node.ts @@ -0,0 +1,5 @@ +export { middleware } from './middleware-shared' + +export const config = { + runtime: 'nodejs', +} diff --git a/tests/fixtures/middleware-trailing-slash/middleware-shared.ts b/tests/fixtures/middleware-trailing-slash/middleware-shared.ts new file mode 100644 index 0000000000..ee3d7d963e --- /dev/null +++ b/tests/fixtures/middleware-trailing-slash/middleware-shared.ts @@ -0,0 +1,60 @@ +import type { NextRequest } from 'next/server' +import { NextResponse } from 'next/server' + +export function middleware(request: NextRequest) { + const response = getResponse(request) + + response.headers.append('Deno' in globalThis ? 'x-deno' : 'x-node', Date.now().toString()) + // report Next.js Middleware Runtime (not the execution runtime, but target runtime) + response.headers.append('x-runtime', typeof EdgeRuntime !== 'undefined' ? EdgeRuntime : 'node') + response.headers.set('x-hello-from-middleware-res', 'hello') + + return response +} + +const getResponse = (request: NextRequest) => { + const requestHeaders = new Headers(request.headers) + + requestHeaders.set('x-hello-from-middleware-req', 'hello') + + if (request.nextUrl.pathname === '/test/next/') { + return NextResponse.next({ + request: { + headers: requestHeaders, + }, + }) + } + + if (request.nextUrl.pathname === '/test/redirect/') { + return NextResponse.redirect(new URL('/other', request.url)) + } + + if (request.nextUrl.pathname === '/test/redirect-with-headers/') { + return NextResponse.redirect(new URL('/other', request.url), { + headers: { 'x-header-from-redirect': 'hello' }, + }) + } + + if (request.nextUrl.pathname === '/test/rewrite-internal/') { + return NextResponse.rewrite(new URL('/rewrite-target', request.url), { + request: { + headers: requestHeaders, + }, + }) + } + + if (request.nextUrl.pathname === '/test/rewrite-external/') { + const requestURL = new URL(request.url) + const externalURL = new URL(requestURL.searchParams.get('external-url') as string) + + externalURL.searchParams.set('from', 'middleware') + + return NextResponse.rewrite(externalURL, { + request: { + headers: requestHeaders, + }, + }) + } + + return NextResponse.json({ error: 'Error' }, { status: 500 }) +} diff --git a/tests/fixtures/middleware-trailing-slash/middleware.ts b/tests/fixtures/middleware-trailing-slash/middleware.ts index f4b2ae6390..fcc87b30fd 100644 --- a/tests/fixtures/middleware-trailing-slash/middleware.ts +++ b/tests/fixtures/middleware-trailing-slash/middleware.ts @@ -1,58 +1 @@ -import type { NextRequest } from 'next/server' -import { NextResponse } from 'next/server' - -export function middleware(request: NextRequest) { - const response = getResponse(request) - - response.headers.append('Deno' in globalThis ? 'x-deno' : 'x-node', Date.now().toString()) - response.headers.set('x-hello-from-middleware-res', 'hello') - - return response -} - -const getResponse = (request: NextRequest) => { - const requestHeaders = new Headers(request.headers) - - requestHeaders.set('x-hello-from-middleware-req', 'hello') - - if (request.nextUrl.pathname === '/test/next/') { - return NextResponse.next({ - request: { - headers: requestHeaders, - }, - }) - } - - if (request.nextUrl.pathname === '/test/redirect/') { - return NextResponse.redirect(new URL('/other', request.url)) - } - - if (request.nextUrl.pathname === '/test/redirect-with-headers/') { - return NextResponse.redirect(new URL('/other', request.url), { - headers: { 'x-header-from-redirect': 'hello' }, - }) - } - - if (request.nextUrl.pathname === '/test/rewrite-internal/') { - return NextResponse.rewrite(new URL('/rewrite-target', request.url), { - request: { - headers: requestHeaders, - }, - }) - } - - if (request.nextUrl.pathname === '/test/rewrite-external/') { - const requestURL = new URL(request.url) - const externalURL = new URL(requestURL.searchParams.get('external-url') as string) - - externalURL.searchParams.set('from', 'middleware') - - return NextResponse.rewrite(externalURL, { - request: { - headers: requestHeaders, - }, - }) - } - - return NextResponse.json({ error: 'Error' }, { status: 500 }) -} +export { middleware } from './middleware-shared' diff --git a/tests/fixtures/middleware-trailing-slash/next.config.js b/tests/fixtures/middleware-trailing-slash/next.config.js index 5219ceeb38..4e1f45a26d 100644 --- a/tests/fixtures/middleware-trailing-slash/next.config.js +++ b/tests/fixtures/middleware-trailing-slash/next.config.js @@ -2,9 +2,13 @@ const nextConfig = { trailingSlash: true, output: 'standalone', + distDir: process.env.NEXT_DIST_DIR ?? '.next', eslint: { ignoreDuringBuilds: true, }, + experimental: { + nodeMiddleware: true, + }, outputFileTracingRoot: __dirname, } diff --git a/tests/fixtures/middleware-trailing-slash/package.json b/tests/fixtures/middleware-trailing-slash/package.json index 21dadcf3ac..b8ffd6b68d 100644 --- a/tests/fixtures/middleware-trailing-slash/package.json +++ b/tests/fixtures/middleware-trailing-slash/package.json @@ -3,9 +3,9 @@ "version": "0.1.0", "private": true, "scripts": { - "postinstall": "next build", + "postinstall": "npm run build", "dev": "next dev", - "build": "next build" + "build": "node ../../utils/build-variants.mjs" }, "dependencies": { "next": "latest", diff --git a/tests/fixtures/middleware-trailing-slash/test-variants.json b/tests/fixtures/middleware-trailing-slash/test-variants.json new file mode 100644 index 0000000000..f31d535c28 --- /dev/null +++ b/tests/fixtures/middleware-trailing-slash/test-variants.json @@ -0,0 +1,21 @@ +{ + "node-middleware": { + "distDir": ".next-node-middleware", + "files": { + "middleware.ts": "middleware-node.ts" + }, + "test": { + "dependencies": { + "next": [ + { + "versionConstraint": ">=15.2.0", + "canaryOnly": true + }, + { + "versionConstraint": ">=15.5.0" + } + ] + } + } + } +} diff --git a/tests/integration/edge-handler.test.ts b/tests/integration/edge-handler.test.ts index 8cbca4e911..2354bd63c1 100644 --- a/tests/integration/edge-handler.test.ts +++ b/tests/integration/edge-handler.test.ts @@ -337,58 +337,60 @@ for (const { } }) }) - }) -} -describe('should run middleware on data requests', () => { - test('when `trailingSlash: false`', async (ctx) => { - await createFixture('middleware', ctx) - await runPlugin(ctx) + describe('should run middleware on data requests', () => { + test('when `trailingSlash: false`', async (ctx) => { + await createFixture('middleware', ctx) + await runPlugin(ctx, runPluginConstants) - const origin = new LocalServer() - const response = await invokeEdgeFunction(ctx, { - functions: [EDGE_MIDDLEWARE_FUNCTION_NAME], - origin, - redirect: 'manual', - url: '/_next/data/dJvEyLV8MW7CBLFf0Ecbk/test/redirect-with-headers.json', - }) + const origin = new LocalServer() + const response = await invokeEdgeFunction(ctx, { + functions: [edgeFunctionNameRoot], + origin, + redirect: 'manual', + url: '/_next/data/dJvEyLV8MW7CBLFf0Ecbk/test/redirect-with-headers.json', + }) - ctx.cleanup?.push(() => origin.stop()) + ctx.cleanup?.push(() => origin.stop()) - expect(response.status).toBe(307) - expect(response.headers.get('location'), 'added a location header').toBeTypeOf('string') - expect( - new URL(response.headers.get('location') as string).pathname, - 'redirected to the correct path', - ).toEqual('/other') - expect(response.headers.get('x-header-from-redirect'), 'hello').toBe('hello') - expect(origin.calls).toBe(0) - }) + expect(response.status).toBe(307) + expect(response.headers.get('location'), 'added a location header').toBeTypeOf('string') + expect( + new URL(response.headers.get('location') as string).pathname, + 'redirected to the correct path', + ).toEqual('/other') + expect(response.headers.get('x-header-from-redirect'), 'hello').toBe('hello') + expect(response.headers.get('x-runtime')).toEqual(expectedRuntime) + expect(origin.calls).toBe(0) + }) - test('when `trailingSlash: true`', async (ctx) => { - await createFixture('middleware-trailing-slash', ctx) - await runPlugin(ctx) + test('when `trailingSlash: true`', async (ctx) => { + await createFixture('middleware-trailing-slash', ctx) + await runPlugin(ctx, runPluginConstants) - const origin = new LocalServer() - const response = await invokeEdgeFunction(ctx, { - functions: [EDGE_MIDDLEWARE_FUNCTION_NAME], - origin, - redirect: 'manual', - url: '/_next/data/dJvEyLV8MW7CBLFf0Ecbk/test/redirect-with-headers.json', - }) + const origin = new LocalServer() + const response = await invokeEdgeFunction(ctx, { + functions: [edgeFunctionNameRoot], + origin, + redirect: 'manual', + url: '/_next/data/dJvEyLV8MW7CBLFf0Ecbk/test/redirect-with-headers.json', + }) - ctx.cleanup?.push(() => origin.stop()) + ctx.cleanup?.push(() => origin.stop()) - expect(response.status).toBe(307) - expect(response.headers.get('location'), 'added a location header').toBeTypeOf('string') - expect( - new URL(response.headers.get('location') as string).pathname, - 'redirected to the correct path', - ).toEqual('/other') - expect(response.headers.get('x-header-from-redirect'), 'hello').toBe('hello') - expect(origin.calls).toBe(0) + expect(response.status).toBe(307) + expect(response.headers.get('location'), 'added a location header').toBeTypeOf('string') + expect( + new URL(response.headers.get('location') as string).pathname, + 'redirected to the correct path', + ).toEqual('/other') + expect(response.headers.get('x-header-from-redirect'), 'hello').toBe('hello') + expect(response.headers.get('x-runtime')).toEqual(expectedRuntime) + expect(origin.calls).toBe(0) + }) + }) }) -}) +} describe('page router', () => { test('edge api routes should work with middleware', async (ctx) => { From 65209f669f0a5c7f887a5f1365fdd97b155b8eb5 Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Thu, 21 Aug 2025 09:23:32 +0200 Subject: [PATCH 24/47] test: adjust rest of middleware e2e --- tests/e2e/edge-middleware.test.ts | 694 +++++++++++++++--------------- 1 file changed, 355 insertions(+), 339 deletions(-) diff --git a/tests/e2e/edge-middleware.test.ts b/tests/e2e/edge-middleware.test.ts index bd6debe8e3..da10f9a232 100644 --- a/tests/e2e/edge-middleware.test.ts +++ b/tests/e2e/edge-middleware.test.ts @@ -67,410 +67,426 @@ for (const { expectedRuntime, label, testWithSwitchableMiddlewareRuntime } of [ expect(await res?.headerValue('x-runtime')).toEqual(expectedRuntime) }) - }) -} -test('does not run middleware again for rewrite target', async ({ page, middleware }) => { - const direct = await page.goto(`${middleware.url}/test/rewrite-target`) - expect(await direct?.headerValue('x-added-rewrite-target')).toBeTruthy() - - const rewritten = await page.goto(`${middleware.url}/test/rewrite-loop-detect`) - - expect(await rewritten?.headerValue('x-added-rewrite-target')).toBeNull() - const h1 = page.locator('h1') - await expect(h1).toHaveText('Hello rewrite') -}) - -test('Supports CJS dependencies in Edge Middleware', async ({ page, middleware }) => { - const res = await page.goto(`${middleware.url}/test/next`) - - expect(await res?.headerValue('x-cjs-module-works')).toEqual('true') -}) - -// adaptation of https://github.com/vercel/next.js/blob/8aa9a52c36f338320d55bd2ec292ffb0b8c7cb35/test/e2e/app-dir/metadata-edge/index.test.ts#L24C5-L31C7 -test('it should render OpenGraph image meta tag correctly', async ({ page, middlewareOg }) => { - test.skip(!nextVersionSatisfies('>=14.0.0'), 'This test is only for Next.js 14+') - await page.goto(`${middlewareOg.url}/`) - const ogURL = await page.locator('meta[property="og:image"]').getAttribute('content') - expect(ogURL).toBeTruthy() - const ogResponse = await fetch(new URL(new URL(ogURL!).pathname, middlewareOg.url)) - const imageBuffer = await ogResponse.arrayBuffer() - const size = await getImageSize(Buffer.from(imageBuffer), 'png') - expect([size.width, size.height]).toEqual([1200, 630]) -}) - -test.describe('json data', () => { - const testConfigs = [ - { - describeLabel: 'NextResponse.next() -> getServerSideProps page', - selector: 'NextResponse.next()#getServerSideProps', - jsonPathMatcher: '/link/next-getserversideprops.json', - }, - { - describeLabel: 'NextResponse.next() -> getStaticProps page', - selector: 'NextResponse.next()#getStaticProps', - jsonPathMatcher: '/link/next-getstaticprops.json', - }, - { - describeLabel: 'NextResponse.next() -> fully static page', - selector: 'NextResponse.next()#fullyStatic', - jsonPathMatcher: '/link/next-fullystatic.json', - }, - { - describeLabel: 'NextResponse.rewrite() -> getServerSideProps page', - selector: 'NextResponse.rewrite()#getServerSideProps', - jsonPathMatcher: '/link/rewrite-me-getserversideprops.json', - }, - { - describeLabel: 'NextResponse.rewrite() -> getStaticProps page', - selector: 'NextResponse.rewrite()#getStaticProps', - jsonPathMatcher: '/link/rewrite-me-getstaticprops.json', - }, - ] - - // Linking to static pages reloads on rewrite for versions below 14 - if (nextVersionSatisfies('>=14.0.0')) { - testConfigs.push({ - describeLabel: 'NextResponse.rewrite() -> fully static page', - selector: 'NextResponse.rewrite()#fullyStatic', - jsonPathMatcher: '/link/rewrite-me-fullystatic.json', - }) - } - - test.describe('no 18n', () => { - for (const testConfig of testConfigs) { - test.describe(testConfig.describeLabel, () => { - test('json data fetch', async ({ middlewarePages, page }) => { - const dataFetchPromise = new Promise((resolve) => { - page.on('response', (response) => { - if (response.url().includes(testConfig.jsonPathMatcher)) { - resolve(response) - } - }) - }) + test('does not run middleware again for rewrite target', async ({ + page, + edgeOrNodeMiddleware, + }) => { + const direct = await page.goto(`${edgeOrNodeMiddleware.url}/test/rewrite-target`) + expect(await direct?.headerValue('x-added-rewrite-target')).toBeTruthy() - await page.goto(`${middlewarePages.url}/link`) + const rewritten = await page.goto(`${edgeOrNodeMiddleware.url}/test/rewrite-loop-detect`) - await page.hover(`[data-link="${testConfig.selector}"]`) + expect(await rewritten?.headerValue('x-added-rewrite-target')).toBeNull() + const h1 = page.locator('h1') + await expect(h1).toHaveText('Hello rewrite') + }) - const dataResponse = await dataFetchPromise + test('Supports CJS dependencies in Edge Middleware', async ({ page, edgeOrNodeMiddleware }) => { + const res = await page.goto(`${edgeOrNodeMiddleware.url}/test/next`) - expect(dataResponse.ok()).toBe(true) - }) + expect(await res?.headerValue('x-cjs-module-works')).toEqual('true') + }) - test('navigation', async ({ middlewarePages, page }) => { - await page.goto(`${middlewarePages.url}/link`) + if (expectedRuntime !== 'node') { + // adaptation of https://github.com/vercel/next.js/blob/8aa9a52c36f338320d55bd2ec292ffb0b8c7cb35/test/e2e/app-dir/metadata-edge/index.test.ts#L24C5-L31C7 + test('it should render OpenGraph image meta tag correctly', async ({ + page, + middlewareOg, + }) => { + test.skip(!nextVersionSatisfies('>=14.0.0'), 'This test is only for Next.js 14+') + await page.goto(`${middlewareOg.url}/`) + const ogURL = await page.locator('meta[property="og:image"]').getAttribute('content') + expect(ogURL).toBeTruthy() + const ogResponse = await fetch(new URL(new URL(ogURL!).pathname, middlewareOg.url)) + const imageBuffer = await ogResponse.arrayBuffer() + const size = await getImageSize(Buffer.from(imageBuffer), 'png') + expect([size.width, size.height]).toEqual([1200, 630]) + }) - await page.evaluate(() => { - // set some value to window to check later if browser did reload and lost this state - ;(window as ExtendedWindow).didReload = false + test.describe('json data', () => { + const testConfigs = [ + { + describeLabel: 'NextResponse.next() -> getServerSideProps page', + selector: 'NextResponse.next()#getServerSideProps', + jsonPathMatcher: '/link/next-getserversideprops.json', + }, + { + describeLabel: 'NextResponse.next() -> getStaticProps page', + selector: 'NextResponse.next()#getStaticProps', + jsonPathMatcher: '/link/next-getstaticprops.json', + }, + { + describeLabel: 'NextResponse.next() -> fully static page', + selector: 'NextResponse.next()#fullyStatic', + jsonPathMatcher: '/link/next-fullystatic.json', + }, + { + describeLabel: 'NextResponse.rewrite() -> getServerSideProps page', + selector: 'NextResponse.rewrite()#getServerSideProps', + jsonPathMatcher: '/link/rewrite-me-getserversideprops.json', + }, + { + describeLabel: 'NextResponse.rewrite() -> getStaticProps page', + selector: 'NextResponse.rewrite()#getStaticProps', + jsonPathMatcher: '/link/rewrite-me-getstaticprops.json', + }, + ] + + // Linking to static pages reloads on rewrite for versions below 14 + if (nextVersionSatisfies('>=14.0.0')) { + testConfigs.push({ + describeLabel: 'NextResponse.rewrite() -> fully static page', + selector: 'NextResponse.rewrite()#fullyStatic', + jsonPathMatcher: '/link/rewrite-me-fullystatic.json', }) + } - await page.click(`[data-link="${testConfig.selector}"]`) + test.describe('no 18n', () => { + for (const testConfig of testConfigs) { + test.describe(testConfig.describeLabel, () => { + test('json data fetch', async ({ middlewarePages, page }) => { + const dataFetchPromise = new Promise((resolve) => { + page.on('response', (response) => { + if (response.url().includes(testConfig.jsonPathMatcher)) { + resolve(response) + } + }) + }) - // wait for page to be rendered - await page.waitForSelector(`[data-page="${testConfig.selector}"]`) + await page.goto(`${middlewarePages.url}/link`) - // check if browser navigation worked by checking if state was preserved - const browserNavigationWorked = - (await page.evaluate(() => { - return (window as ExtendedWindow).didReload - })) === false + await page.hover(`[data-link="${testConfig.selector}"]`) - // we expect client navigation to work without browser reload - expect(browserNavigationWorked).toBe(true) - }) - }) - } - }) - test.describe('with 18n', () => { - for (const testConfig of testConfigs) { - test.describe(testConfig.describeLabel, () => { - for (const { localeLabel, pageWithLinksPathname } of [ - { localeLabel: 'implicit default locale', pageWithLinksPathname: '/link' }, - { localeLabel: 'explicit default locale', pageWithLinksPathname: '/en/link' }, - { localeLabel: 'explicit non-default locale', pageWithLinksPathname: '/fr/link' }, - ]) { - test.describe(localeLabel, () => { - test('json data fetch', async ({ middlewareI18n, page }) => { - const dataFetchPromise = new Promise((resolve) => { - page.on('response', (response) => { - if (response.url().includes(testConfig.jsonPathMatcher)) { - resolve(response) - } - }) + const dataResponse = await dataFetchPromise + + expect(dataResponse.ok()).toBe(true) }) - await page.goto(`${middlewareI18n.url}${pageWithLinksPathname}`) + test('navigation', async ({ middlewarePages, page }) => { + await page.goto(`${middlewarePages.url}/link`) - await page.hover(`[data-link="${testConfig.selector}"]`) + await page.evaluate(() => { + // set some value to window to check later if browser did reload and lost this state + ;(window as ExtendedWindow).didReload = false + }) - const dataResponse = await dataFetchPromise + await page.click(`[data-link="${testConfig.selector}"]`) - expect(dataResponse.ok()).toBe(true) - }) + // wait for page to be rendered + await page.waitForSelector(`[data-page="${testConfig.selector}"]`) - test('navigation', async ({ middlewareI18n, page }) => { - await page.goto(`${middlewareI18n.url}${pageWithLinksPathname}`) + // check if browser navigation worked by checking if state was preserved + const browserNavigationWorked = + (await page.evaluate(() => { + return (window as ExtendedWindow).didReload + })) === false - await page.evaluate(() => { - // set some value to window to check later if browser did reload and lost this state - ;(window as ExtendedWindow).didReload = false + // we expect client navigation to work without browser reload + expect(browserNavigationWorked).toBe(true) }) + }) + } + }) + test.describe('with 18n', () => { + for (const testConfig of testConfigs) { + test.describe(testConfig.describeLabel, () => { + for (const { localeLabel, pageWithLinksPathname } of [ + { localeLabel: 'implicit default locale', pageWithLinksPathname: '/link' }, + { localeLabel: 'explicit default locale', pageWithLinksPathname: '/en/link' }, + { localeLabel: 'explicit non-default locale', pageWithLinksPathname: '/fr/link' }, + ]) { + test.describe(localeLabel, () => { + test('json data fetch', async ({ middlewareI18n, page }) => { + const dataFetchPromise = new Promise((resolve) => { + page.on('response', (response) => { + if (response.url().includes(testConfig.jsonPathMatcher)) { + resolve(response) + } + }) + }) + + await page.goto(`${middlewareI18n.url}${pageWithLinksPathname}`) + + await page.hover(`[data-link="${testConfig.selector}"]`) + + const dataResponse = await dataFetchPromise + + expect(dataResponse.ok()).toBe(true) + }) + + test('navigation', async ({ middlewareI18n, page }) => { + await page.goto(`${middlewareI18n.url}${pageWithLinksPathname}`) + + await page.evaluate(() => { + // set some value to window to check later if browser did reload and lost this state + ;(window as ExtendedWindow).didReload = false + }) + + await page.click(`[data-link="${testConfig.selector}"]`) + + // wait for page to be rendered + await page.waitForSelector(`[data-page="${testConfig.selector}"]`) + + // check if browser navigation worked by checking if state was preserved + const browserNavigationWorked = + (await page.evaluate(() => { + return (window as ExtendedWindow).didReload + })) === false + + // we expect client navigation to work without browser reload + expect(browserNavigationWorked).toBe(true) + }) + }) + } + }) + } + }) + }) + + // those tests use `fetch` instead of `page.goto` intentionally to avoid potential client rendering + // hiding any potential edge/server issues + test.describe('Middleware with i18n and excluded paths', () => { + const DEFAULT_LOCALE = 'en' + + /** helper function to extract JSON data from page rendering data with `
{JSON.stringify(data)}
` */ + function extractDataFromHtml(html: string): Record { + const match = html.match(/
(?[^<]+)<\/pre>/)
+          if (!match || !match.groups?.rawInput) {
+            console.error('
 not found in html input', {
+              html,
+            })
+            throw new Error('Failed to extract data from HTML')
+          }
+
+          const { rawInput } = match.groups
+          const unescapedInput = rawInput.replaceAll('"', '"')
+          try {
+            return JSON.parse(unescapedInput)
+          } catch (originalError) {
+            console.error('Failed to parse JSON', {
+              originalError,
+              rawInput,
+              unescapedInput,
+            })
+          }
+          throw new Error('Failed to extract data from HTML')
+        }
 
-              await page.click(`[data-link="${testConfig.selector}"]`)
+        // those tests hit paths ending with `/json` which has special handling in middleware
+        // to return JSON response from middleware itself
+        test.describe('Middleware response path', () => {
+          test('should match on non-localized not excluded page path', async ({
+            middlewareI18nExcludedPaths,
+          }) => {
+            const response = await fetch(`${middlewareI18nExcludedPaths.url}/json`)
 
-              // wait for page to be rendered
-              await page.waitForSelector(`[data-page="${testConfig.selector}"]`)
+            expect(response.headers.get('x-test-used-middleware')).toBe('true')
+            expect(response.status).toBe(200)
 
-              // check if browser navigation worked by checking if state was preserved
-              const browserNavigationWorked =
-                (await page.evaluate(() => {
-                  return (window as ExtendedWindow).didReload
-                })) === false
+            const { nextUrlPathname, nextUrlLocale } = await response.json()
 
-              // we expect client navigation to work without browser reload
-              expect(browserNavigationWorked).toBe(true)
-            })
+            expect(nextUrlPathname).toBe('/json')
+            expect(nextUrlLocale).toBe(DEFAULT_LOCALE)
           })
-        }
-      })
-    }
-  })
-})
-
-// those tests use `fetch` instead of `page.goto` intentionally to avoid potential client rendering
-// hiding any potential edge/server issues
-test.describe('Middleware with i18n and excluded paths', () => {
-  const DEFAULT_LOCALE = 'en'
-
-  /** helper function to extract JSON data from page rendering data with `
{JSON.stringify(data)}
` */ - function extractDataFromHtml(html: string): Record { - const match = html.match(/
(?[^<]+)<\/pre>/)
-    if (!match || !match.groups?.rawInput) {
-      console.error('
 not found in html input', {
-        html,
-      })
-      throw new Error('Failed to extract data from HTML')
-    }
 
-    const { rawInput } = match.groups
-    const unescapedInput = rawInput.replaceAll('"', '"')
-    try {
-      return JSON.parse(unescapedInput)
-    } catch (originalError) {
-      console.error('Failed to parse JSON', {
-        originalError,
-        rawInput,
-        unescapedInput,
-      })
-    }
-    throw new Error('Failed to extract data from HTML')
-  }
-
-  // those tests hit paths ending with `/json` which has special handling in middleware
-  // to return JSON response from middleware itself
-  test.describe('Middleware response path', () => {
-    test('should match on non-localized not excluded page path', async ({
-      middlewareI18nExcludedPaths,
-    }) => {
-      const response = await fetch(`${middlewareI18nExcludedPaths.url}/json`)
+          test('should match on localized not excluded page path', async ({
+            middlewareI18nExcludedPaths,
+          }) => {
+            const response = await fetch(`${middlewareI18nExcludedPaths.url}/fr/json`)
 
-      expect(response.headers.get('x-test-used-middleware')).toBe('true')
-      expect(response.status).toBe(200)
+            expect(response.headers.get('x-test-used-middleware')).toBe('true')
+            expect(response.status).toBe(200)
 
-      const { nextUrlPathname, nextUrlLocale } = await response.json()
+            const { nextUrlPathname, nextUrlLocale } = await response.json()
 
-      expect(nextUrlPathname).toBe('/json')
-      expect(nextUrlLocale).toBe(DEFAULT_LOCALE)
-    })
+            expect(nextUrlPathname).toBe('/json')
+            expect(nextUrlLocale).toBe('fr')
+          })
+        })
 
-    test('should match on localized not excluded page path', async ({
-      middlewareI18nExcludedPaths,
-    }) => {
-      const response = await fetch(`${middlewareI18nExcludedPaths.url}/fr/json`)
+        // those tests hit paths that don't end with `/json` while still satisfying middleware matcher
+        // so middleware should pass them through to origin
+        test.describe('Middleware passthrough', () => {
+          test('should match on non-localized not excluded page path', async ({
+            middlewareI18nExcludedPaths,
+          }) => {
+            const response = await fetch(`${middlewareI18nExcludedPaths.url}/html`)
 
-      expect(response.headers.get('x-test-used-middleware')).toBe('true')
-      expect(response.status).toBe(200)
+            expect(response.headers.get('x-test-used-middleware')).toBe('true')
+            expect(response.status).toBe(200)
+            expect(response.headers.get('content-type')).toMatch(/text\/html/)
 
-      const { nextUrlPathname, nextUrlLocale } = await response.json()
+            const html = await response.text()
+            const { locale, params } = extractDataFromHtml(html)
 
-      expect(nextUrlPathname).toBe('/json')
-      expect(nextUrlLocale).toBe('fr')
-    })
-  })
+            expect(params).toMatchObject({ catchall: ['html'] })
+            expect(locale).toBe(DEFAULT_LOCALE)
+          })
 
-  // those tests hit paths that don't end with `/json` while still satisfying middleware matcher
-  // so middleware should pass them through to origin
-  test.describe('Middleware passthrough', () => {
-    test('should match on non-localized not excluded page path', async ({
-      middlewareI18nExcludedPaths,
-    }) => {
-      const response = await fetch(`${middlewareI18nExcludedPaths.url}/html`)
+          test('should match on localized not excluded page path', async ({
+            middlewareI18nExcludedPaths,
+          }) => {
+            const response = await fetch(`${middlewareI18nExcludedPaths.url}/fr/html`)
 
-      expect(response.headers.get('x-test-used-middleware')).toBe('true')
-      expect(response.status).toBe(200)
-      expect(response.headers.get('content-type')).toMatch(/text\/html/)
+            expect(response.headers.get('x-test-used-middleware')).toBe('true')
+            expect(response.status).toBe(200)
+            expect(response.headers.get('content-type')).toMatch(/text\/html/)
 
-      const html = await response.text()
-      const { locale, params } = extractDataFromHtml(html)
+            const html = await response.text()
+            const { locale, params } = extractDataFromHtml(html)
 
-      expect(params).toMatchObject({ catchall: ['html'] })
-      expect(locale).toBe(DEFAULT_LOCALE)
-    })
+            expect(params).toMatchObject({ catchall: ['html'] })
+            expect(locale).toBe('fr')
+          })
+        })
 
-    test('should match on localized not excluded page path', async ({
-      middlewareI18nExcludedPaths,
-    }) => {
-      const response = await fetch(`${middlewareI18nExcludedPaths.url}/fr/html`)
+        // those tests hit paths that don't satisfy middleware matcher, so should go directly to origin
+        // without going through middleware
+        test.describe('Middleware skipping (paths not satisfying middleware matcher)', () => {
+          test('should NOT match on non-localized excluded API path', async ({
+            middlewareI18nExcludedPaths,
+          }) => {
+            const response = await fetch(`${middlewareI18nExcludedPaths.url}/api/html`)
 
-      expect(response.headers.get('x-test-used-middleware')).toBe('true')
-      expect(response.status).toBe(200)
-      expect(response.headers.get('content-type')).toMatch(/text\/html/)
+            expect(response.headers.get('x-test-used-middleware')).not.toBe('true')
+            expect(response.status).toBe(200)
 
-      const html = await response.text()
-      const { locale, params } = extractDataFromHtml(html)
+            const { params } = await response.json()
 
-      expect(params).toMatchObject({ catchall: ['html'] })
-      expect(locale).toBe('fr')
-    })
-  })
+            expect(params).toMatchObject({ catchall: ['html'] })
+          })
 
-  // those tests hit paths that don't satisfy middleware matcher, so should go directly to origin
-  // without going through middleware
-  test.describe('Middleware skipping (paths not satisfying middleware matcher)', () => {
-    test('should NOT match on non-localized excluded API path', async ({
-      middlewareI18nExcludedPaths,
-    }) => {
-      const response = await fetch(`${middlewareI18nExcludedPaths.url}/api/html`)
+          test('should NOT match on non-localized excluded page path', async ({
+            middlewareI18nExcludedPaths,
+          }) => {
+            const response = await fetch(`${middlewareI18nExcludedPaths.url}/excluded`)
 
-      expect(response.headers.get('x-test-used-middleware')).not.toBe('true')
-      expect(response.status).toBe(200)
+            expect(response.headers.get('x-test-used-middleware')).not.toBe('true')
+            expect(response.status).toBe(200)
+            expect(response.headers.get('content-type')).toMatch(/text\/html/)
 
-      const { params } = await response.json()
+            const html = await response.text()
+            const { locale, params } = extractDataFromHtml(html)
 
-      expect(params).toMatchObject({ catchall: ['html'] })
-    })
+            expect(params).toMatchObject({ catchall: ['excluded'] })
+            expect(locale).toBe(DEFAULT_LOCALE)
+          })
 
-    test('should NOT match on non-localized excluded page path', async ({
-      middlewareI18nExcludedPaths,
-    }) => {
-      const response = await fetch(`${middlewareI18nExcludedPaths.url}/excluded`)
+          test('should NOT match on localized excluded page path', async ({
+            middlewareI18nExcludedPaths,
+          }) => {
+            const response = await fetch(`${middlewareI18nExcludedPaths.url}/fr/excluded`)
 
-      expect(response.headers.get('x-test-used-middleware')).not.toBe('true')
-      expect(response.status).toBe(200)
-      expect(response.headers.get('content-type')).toMatch(/text\/html/)
+            expect(response.headers.get('x-test-used-middleware')).not.toBe('true')
+            expect(response.status).toBe(200)
+            expect(response.headers.get('content-type')).toMatch(/text\/html/)
 
-      const html = await response.text()
-      const { locale, params } = extractDataFromHtml(html)
+            const html = await response.text()
+            const { locale, params } = extractDataFromHtml(html)
 
-      expect(params).toMatchObject({ catchall: ['excluded'] })
-      expect(locale).toBe(DEFAULT_LOCALE)
-    })
+            expect(params).toMatchObject({ catchall: ['excluded'] })
+            expect(locale).toBe('fr')
+          })
+        })
+      })
 
-    test('should NOT match on localized excluded page path', async ({
-      middlewareI18nExcludedPaths,
-    }) => {
-      const response = await fetch(`${middlewareI18nExcludedPaths.url}/fr/excluded`)
+      test("requests with x-middleware-subrequest don't skip middleware (GHSA-f82v-jwr5-mffw)", async ({
+        middlewareSubrequestVuln,
+      }) => {
+        const response = await fetch(`${middlewareSubrequestVuln.url}`, {
+          headers: {
+            'x-middleware-subrequest': 'middleware:middleware:middleware:middleware:middleware',
+          },
+        })
 
-      expect(response.headers.get('x-test-used-middleware')).not.toBe('true')
-      expect(response.status).toBe(200)
-      expect(response.headers.get('content-type')).toMatch(/text\/html/)
+        // middleware was not skipped
+        expect(response.headers.get('x-test-used-middleware')).toBe('true')
 
-      const html = await response.text()
-      const { locale, params } = extractDataFromHtml(html)
+        // ensure we are testing version before the fix for self hosted
+        expect(response.headers.get('x-test-used-next-version')).toBe('15.2.2')
+      })
 
-      expect(params).toMatchObject({ catchall: ['excluded'] })
-      expect(locale).toBe('fr')
-    })
-  })
-})
-
-test("requests with x-middleware-subrequest don't skip middleware (GHSA-f82v-jwr5-mffw)", async ({
-  middlewareSubrequestVuln,
-}) => {
-  const response = await fetch(`${middlewareSubrequestVuln.url}`, {
-    headers: {
-      'x-middleware-subrequest': 'middleware:middleware:middleware:middleware:middleware',
-    },
-  })
+      test('requests with different encoding than matcher match anyway', async ({
+        middlewareStaticAssetMatcher,
+      }) => {
+        const response = await fetch(`${middlewareStaticAssetMatcher.url}/hello%2Fworld.txt`)
 
-  // middleware was not skipped
-  expect(response.headers.get('x-test-used-middleware')).toBe('true')
-
-  // ensure we are testing version before the fix for self hosted
-  expect(response.headers.get('x-test-used-next-version')).toBe('15.2.2')
-})
-
-test('requests with different encoding than matcher match anyway', async ({
-  middlewareStaticAssetMatcher,
-}) => {
-  const response = await fetch(`${middlewareStaticAssetMatcher.url}/hello%2Fworld.txt`)
-
-  // middleware was not skipped
-  expect(await response.text()).toBe('hello from middleware')
-})
-
-test.describe('RSC cache poisoning', () => {
-  test('Middleware rewrite', async ({ page, middleware }) => {
-    const prefetchResponsePromise = new Promise((resolve) => {
-      page.on('response', (response) => {
-        if (
-          (response.url().includes('/test/rewrite-to-cached-page') ||
-            response.url().includes('/caching-rewrite-target')) &&
-          response.status() === 200
-        ) {
-          resolve(response)
-        }
+        // middleware was not skipped
+        expect(await response.text()).toBe('hello from middleware')
       })
-    })
-    await page.goto(`${middleware.url}/link-to-rewrite-to-cached-page`)
+    }
 
-    // ensure prefetch
-    await page.hover('text=NextResponse.rewrite')
+    test.describe('RSC cache poisoning', () => {
+      test('Middleware rewrite', async ({ page, edgeOrNodeMiddleware }) => {
+        const prefetchResponsePromise = new Promise((resolve) => {
+          page.on('response', (response) => {
+            if (
+              (response.url().includes('/test/rewrite-to-cached-page') ||
+                response.url().includes('/caching-rewrite-target')) &&
+              response.status() === 200
+            ) {
+              resolve(response)
+            }
+          })
+        })
+        await page.goto(`${edgeOrNodeMiddleware.url}/link-to-rewrite-to-cached-page`)
 
-    // wait for prefetch request to finish
-    const prefetchResponse = await prefetchResponsePromise
+        // ensure prefetch
+        await page.hover('text=NextResponse.rewrite')
 
-    // ensure prefetch respond with RSC data
-    expect(prefetchResponse.headers()['content-type']).toMatch(/text\/x-component/)
-    expect(prefetchResponse.headers()['debug-netlify-cdn-cache-control']).toMatch(
-      /s-maxage=31536000/,
-    )
+        // wait for prefetch request to finish
+        const prefetchResponse = await prefetchResponsePromise
 
-    const htmlResponse = await page.goto(`${middleware.url}/test/rewrite-to-cached-page`)
+        // ensure prefetch respond with RSC data
+        expect(prefetchResponse.headers()['content-type']).toMatch(/text\/x-component/)
+        expect(prefetchResponse.headers()['debug-netlify-cdn-cache-control']).toMatch(
+          /s-maxage=31536000/,
+        )
 
-    // ensure we get HTML response
-    expect(htmlResponse?.headers()['content-type']).toMatch(/text\/html/)
-    expect(htmlResponse?.headers()['debug-netlify-cdn-cache-control']).toMatch(/s-maxage=31536000/)
-  })
+        const htmlResponse = await page.goto(
+          `${edgeOrNodeMiddleware.url}/test/rewrite-to-cached-page`,
+        )
 
-  test('Middleware redirect', async ({ page, middleware }) => {
-    const prefetchResponsePromise = new Promise((resolve) => {
-      page.on('response', (response) => {
-        if (response.url().includes('/caching-redirect-target') && response.status() === 200) {
-          resolve(response)
-        }
+        // ensure we get HTML response
+        expect(htmlResponse?.headers()['content-type']).toMatch(/text\/html/)
+        expect(htmlResponse?.headers()['debug-netlify-cdn-cache-control']).toMatch(
+          /s-maxage=31536000/,
+        )
       })
-    })
-    await page.goto(`${middleware.url}/link-to-redirect-to-cached-page`)
 
-    // ensure prefetch
-    await page.hover('text=NextResponse.redirect')
+      test('Middleware redirect', async ({ page, edgeOrNodeMiddleware }) => {
+        const prefetchResponsePromise = new Promise((resolve) => {
+          page.on('response', (response) => {
+            if (response.url().includes('/caching-redirect-target') && response.status() === 200) {
+              resolve(response)
+            }
+          })
+        })
+        await page.goto(`${edgeOrNodeMiddleware.url}/link-to-redirect-to-cached-page`)
+
+        // ensure prefetch
+        await page.hover('text=NextResponse.redirect')
 
-    // wait for prefetch request to finish
-    const prefetchResponse = await prefetchResponsePromise
+        // wait for prefetch request to finish
+        const prefetchResponse = await prefetchResponsePromise
 
-    // ensure prefetch respond with RSC data
-    expect(prefetchResponse.headers()['content-type']).toMatch(/text\/x-component/)
-    expect(prefetchResponse.headers()['debug-netlify-cdn-cache-control']).toMatch(
-      /s-maxage=31536000/,
-    )
+        // ensure prefetch respond with RSC data
+        expect(prefetchResponse.headers()['content-type']).toMatch(/text\/x-component/)
+        expect(prefetchResponse.headers()['debug-netlify-cdn-cache-control']).toMatch(
+          /s-maxage=31536000/,
+        )
 
-    const htmlResponse = await page.goto(`${middleware.url}/test/redirect-to-cached-page`)
+        const htmlResponse = await page.goto(
+          `${edgeOrNodeMiddleware.url}/test/redirect-to-cached-page`,
+        )
 
-    // ensure we get HTML response
-    expect(htmlResponse?.headers()['content-type']).toMatch(/text\/html/)
-    expect(htmlResponse?.headers()['debug-netlify-cdn-cache-control']).toMatch(/s-maxage=31536000/)
+        // ensure we get HTML response
+        expect(htmlResponse?.headers()['content-type']).toMatch(/text\/html/)
+        expect(htmlResponse?.headers()['debug-netlify-cdn-cache-control']).toMatch(
+          /s-maxage=31536000/,
+        )
+      })
+    })
   })
-})
+}

From 8b5579f93f8e1f18be511d772531e4678a8df2df Mon Sep 17 00:00:00 2001
From: Michal Piechowiak 
Date: Thu, 21 Aug 2025 10:09:22 +0200
Subject: [PATCH 25/47] test: move middleware-pages to test variants

---
 tests/e2e/edge-middleware.test.ts             | 175 +++---
 .../middleware-pages/middleware-node.ts       |   5 +
 .../{middleware.js => middleware-shared.ts}   |  20 +-
 tests/fixtures/middleware-pages/middleware.ts |   1 +
 .../fixtures/middleware-pages/next.config.js  |   4 +
 tests/fixtures/middleware-pages/package.json  |   4 +-
 .../middleware-pages/test-variants.json       |  21 +
 tests/integration/edge-handler.test.ts        | 546 +++++++++---------
 tests/utils/create-e2e-fixture.ts             |   5 +
 9 files changed, 437 insertions(+), 344 deletions(-)
 create mode 100644 tests/fixtures/middleware-pages/middleware-node.ts
 rename tests/fixtures/middleware-pages/{middleware.js => middleware-shared.ts} (80%)
 create mode 100644 tests/fixtures/middleware-pages/middleware.ts
 create mode 100644 tests/fixtures/middleware-pages/test-variants.json

diff --git a/tests/e2e/edge-middleware.test.ts b/tests/e2e/edge-middleware.test.ts
index da10f9a232..65d46cd10e 100644
--- a/tests/e2e/edge-middleware.test.ts
+++ b/tests/e2e/edge-middleware.test.ts
@@ -8,11 +8,16 @@ type ExtendedWindow = Window & {
   didReload?: boolean
 }
 
+type ExtendedFixtures = {
+  edgeOrNodeMiddleware: Fixture
+  edgeOrNodeMiddlewarePages: Fixture
+}
+
 for (const { expectedRuntime, label, testWithSwitchableMiddlewareRuntime } of [
   {
     expectedRuntime: 'edge-runtime',
     label: 'Edge runtime middleware',
-    testWithSwitchableMiddlewareRuntime: test.extend<{}, { edgeOrNodeMiddleware: Fixture }>({
+    testWithSwitchableMiddlewareRuntime: test.extend<{}, ExtendedFixtures>({
       edgeOrNodeMiddleware: [
         async ({ middleware }, use) => {
           await use(middleware)
@@ -21,13 +26,21 @@ for (const { expectedRuntime, label, testWithSwitchableMiddlewareRuntime } of [
           scope: 'worker',
         },
       ],
+      edgeOrNodeMiddlewarePages: [
+        async ({ middlewarePages }, use) => {
+          await use(middlewarePages)
+        },
+        {
+          scope: 'worker',
+        },
+      ],
     }),
   },
   hasNodeMiddlewareSupport()
     ? {
         expectedRuntime: 'node',
         label: 'Node.js runtime middleware',
-        testWithSwitchableMiddlewareRuntime: test.extend<{}, { edgeOrNodeMiddleware: Fixture }>({
+        testWithSwitchableMiddlewareRuntime: test.extend<{}, ExtendedFixtures>({
           edgeOrNodeMiddleware: [
             async ({ middlewareNode }, use) => {
               await use(middlewareNode)
@@ -36,6 +49,14 @@ for (const { expectedRuntime, label, testWithSwitchableMiddlewareRuntime } of [
               scope: 'worker',
             },
           ],
+          edgeOrNodeMiddlewarePages: [
+            async ({ middlewarePagesNode }, use) => {
+              await use(middlewarePagesNode)
+            },
+            {
+              scope: 'worker',
+            },
+          ],
         }),
       }
     : undefined,
@@ -103,91 +124,95 @@ for (const { expectedRuntime, label, testWithSwitchableMiddlewareRuntime } of [
         const size = await getImageSize(Buffer.from(imageBuffer), 'png')
         expect([size.width, size.height]).toEqual([1200, 630])
       })
+    }
 
-      test.describe('json data', () => {
-        const testConfigs = [
-          {
-            describeLabel: 'NextResponse.next() -> getServerSideProps page',
-            selector: 'NextResponse.next()#getServerSideProps',
-            jsonPathMatcher: '/link/next-getserversideprops.json',
-          },
-          {
-            describeLabel: 'NextResponse.next() -> getStaticProps page',
-            selector: 'NextResponse.next()#getStaticProps',
-            jsonPathMatcher: '/link/next-getstaticprops.json',
-          },
-          {
-            describeLabel: 'NextResponse.next() -> fully static page',
-            selector: 'NextResponse.next()#fullyStatic',
-            jsonPathMatcher: '/link/next-fullystatic.json',
-          },
-          {
-            describeLabel: 'NextResponse.rewrite() -> getServerSideProps page',
-            selector: 'NextResponse.rewrite()#getServerSideProps',
-            jsonPathMatcher: '/link/rewrite-me-getserversideprops.json',
-          },
-          {
-            describeLabel: 'NextResponse.rewrite() -> getStaticProps page',
-            selector: 'NextResponse.rewrite()#getStaticProps',
-            jsonPathMatcher: '/link/rewrite-me-getstaticprops.json',
-          },
-        ]
-
-        // Linking to static pages reloads on rewrite for versions below 14
-        if (nextVersionSatisfies('>=14.0.0')) {
-          testConfigs.push({
-            describeLabel: 'NextResponse.rewrite() -> fully static page',
-            selector: 'NextResponse.rewrite()#fullyStatic',
-            jsonPathMatcher: '/link/rewrite-me-fullystatic.json',
-          })
-        }
+    test.describe('json data', () => {
+      const testConfigs = [
+        {
+          describeLabel: 'NextResponse.next() -> getServerSideProps page',
+          selector: 'NextResponse.next()#getServerSideProps',
+          jsonPathMatcher: '/link/next-getserversideprops.json',
+        },
+        {
+          describeLabel: 'NextResponse.next() -> getStaticProps page',
+          selector: 'NextResponse.next()#getStaticProps',
+          jsonPathMatcher: '/link/next-getstaticprops.json',
+        },
+        {
+          describeLabel: 'NextResponse.next() -> fully static page',
+          selector: 'NextResponse.next()#fullyStatic',
+          jsonPathMatcher: '/link/next-fullystatic.json',
+        },
+        {
+          describeLabel: 'NextResponse.rewrite() -> getServerSideProps page',
+          selector: 'NextResponse.rewrite()#getServerSideProps',
+          jsonPathMatcher: '/link/rewrite-me-getserversideprops.json',
+        },
+        {
+          describeLabel: 'NextResponse.rewrite() -> getStaticProps page',
+          selector: 'NextResponse.rewrite()#getStaticProps',
+          jsonPathMatcher: '/link/rewrite-me-getstaticprops.json',
+        },
+      ]
+
+      // Linking to static pages reloads on rewrite for versions below 14
+      if (nextVersionSatisfies('>=14.0.0')) {
+        testConfigs.push({
+          describeLabel: 'NextResponse.rewrite() -> fully static page',
+          selector: 'NextResponse.rewrite()#fullyStatic',
+          jsonPathMatcher: '/link/rewrite-me-fullystatic.json',
+        })
+      }
 
-        test.describe('no 18n', () => {
-          for (const testConfig of testConfigs) {
-            test.describe(testConfig.describeLabel, () => {
-              test('json data fetch', async ({ middlewarePages, page }) => {
-                const dataFetchPromise = new Promise((resolve) => {
-                  page.on('response', (response) => {
-                    if (response.url().includes(testConfig.jsonPathMatcher)) {
-                      resolve(response)
-                    }
-                  })
+      test.describe('no 18n', () => {
+        for (const testConfig of testConfigs) {
+          test.describe(testConfig.describeLabel, () => {
+            test('json data fetch', async ({ edgeOrNodeMiddlewarePages, page }) => {
+              const dataFetchPromise = new Promise((resolve) => {
+                page.on('response', (response) => {
+                  if (response.url().includes(testConfig.jsonPathMatcher)) {
+                    resolve(response)
+                  }
                 })
+              })
 
-                await page.goto(`${middlewarePages.url}/link`)
+              const pageResponse = await page.goto(`${edgeOrNodeMiddlewarePages.url}/link`)
+              expect(await pageResponse?.headerValue('x-runtime')).toEqual(expectedRuntime)
 
-                await page.hover(`[data-link="${testConfig.selector}"]`)
+              await page.hover(`[data-link="${testConfig.selector}"]`)
 
-                const dataResponse = await dataFetchPromise
+              const dataResponse = await dataFetchPromise
 
-                expect(dataResponse.ok()).toBe(true)
-              })
+              expect(dataResponse.ok()).toBe(true)
+            })
 
-              test('navigation', async ({ middlewarePages, page }) => {
-                await page.goto(`${middlewarePages.url}/link`)
+            test('navigation', async ({ edgeOrNodeMiddlewarePages, page }) => {
+              const pageResponse = await page.goto(`${edgeOrNodeMiddlewarePages.url}/link`)
+              expect(await pageResponse?.headerValue('x-runtime')).toEqual(expectedRuntime)
 
-                await page.evaluate(() => {
-                  // set some value to window to check later if browser did reload and lost this state
-                  ;(window as ExtendedWindow).didReload = false
-                })
+              await page.evaluate(() => {
+                // set some value to window to check later if browser did reload and lost this state
+                ;(window as ExtendedWindow).didReload = false
+              })
 
-                await page.click(`[data-link="${testConfig.selector}"]`)
+              await page.click(`[data-link="${testConfig.selector}"]`)
 
-                // wait for page to be rendered
-                await page.waitForSelector(`[data-page="${testConfig.selector}"]`)
+              // wait for page to be rendered
+              await page.waitForSelector(`[data-page="${testConfig.selector}"]`)
 
-                // check if browser navigation worked by checking if state was preserved
-                const browserNavigationWorked =
-                  (await page.evaluate(() => {
-                    return (window as ExtendedWindow).didReload
-                  })) === false
+              // check if browser navigation worked by checking if state was preserved
+              const browserNavigationWorked =
+                (await page.evaluate(() => {
+                  return (window as ExtendedWindow).didReload
+                })) === false
 
-                // we expect client navigation to work without browser reload
-                expect(browserNavigationWorked).toBe(true)
-              })
+              // we expect client navigation to work without browser reload
+              expect(browserNavigationWorked).toBe(true)
             })
-          }
-        })
+          })
+        }
+      })
+      if (expectedRuntime !== 'node') {
         test.describe('with 18n', () => {
           for (const testConfig of testConfigs) {
             test.describe(testConfig.describeLabel, () => {
@@ -242,8 +267,10 @@ for (const { expectedRuntime, label, testWithSwitchableMiddlewareRuntime } of [
             })
           }
         })
-      })
+      }
+    })
 
+    if (expectedRuntime !== 'node') {
       // those tests use `fetch` instead of `page.goto` intentionally to avoid potential client rendering
       // hiding any potential edge/server issues
       test.describe('Middleware with i18n and excluded paths', () => {
diff --git a/tests/fixtures/middleware-pages/middleware-node.ts b/tests/fixtures/middleware-pages/middleware-node.ts
new file mode 100644
index 0000000000..780faa76fc
--- /dev/null
+++ b/tests/fixtures/middleware-pages/middleware-node.ts
@@ -0,0 +1,5 @@
+export { middleware } from './middleware-shared'
+
+export const config = {
+  runtime: 'nodejs',
+}
diff --git a/tests/fixtures/middleware-pages/middleware.js b/tests/fixtures/middleware-pages/middleware-shared.ts
similarity index 80%
rename from tests/fixtures/middleware-pages/middleware.js
rename to tests/fixtures/middleware-pages/middleware-shared.ts
index a89a491a8c..c176f631cd 100644
--- a/tests/fixtures/middleware-pages/middleware.js
+++ b/tests/fixtures/middleware-pages/middleware-shared.ts
@@ -1,6 +1,19 @@
+import type { NextRequest } from 'next/server'
 import { NextResponse, URLPattern } from 'next/server'
 
-export async function middleware(request) {
+export async function middleware(request: NextRequest) {
+  const response = getResponse(request)
+
+  response.headers.append('Deno' in globalThis ? 'x-deno' : 'x-node', Date.now().toString())
+  // report Next.js Middleware Runtime (not the execution runtime, but target runtime)
+  // @ts-expect-error EdgeRuntime global not declared
+  response.headers.append('x-runtime', typeof EdgeRuntime !== 'undefined' ? EdgeRuntime : 'node')
+  response.headers.set('x-hello-from-middleware-res', 'hello')
+
+  return response
+}
+
+const getResponse = (request: NextRequest) => {
   const url = request.nextUrl
 
   // this is needed for tests to get the BUILD_ID
@@ -93,7 +106,10 @@ export async function middleware(request) {
   })
 }
 
-const PATTERNS = [
+const PATTERNS: [
+  URLPattern,
+  (params: ReturnType) => { pathname: string; params: Record },
+][] = [
   [
     new URLPattern({ pathname: '/:locale/:id' }),
     ({ pathname }) => ({
diff --git a/tests/fixtures/middleware-pages/middleware.ts b/tests/fixtures/middleware-pages/middleware.ts
new file mode 100644
index 0000000000..fcc87b30fd
--- /dev/null
+++ b/tests/fixtures/middleware-pages/middleware.ts
@@ -0,0 +1 @@
+export { middleware } from './middleware-shared'
diff --git a/tests/fixtures/middleware-pages/next.config.js b/tests/fixtures/middleware-pages/next.config.js
index 961eb46136..44bdf7d001 100644
--- a/tests/fixtures/middleware-pages/next.config.js
+++ b/tests/fixtures/middleware-pages/next.config.js
@@ -25,9 +25,13 @@ if (platform === 'win32') {
 module.exports = {
   trailingSlash: true,
   output: 'standalone',
+  distDir: process.env.NEXT_DIST_DIR ?? '.next',
   eslint: {
     ignoreDuringBuilds: true,
   },
+  experimental: {
+    nodeMiddleware: true,
+  },
   generateBuildId: () => 'build-id',
   redirects() {
     return [
diff --git a/tests/fixtures/middleware-pages/package.json b/tests/fixtures/middleware-pages/package.json
index 4f57aa1121..2f23e9a218 100644
--- a/tests/fixtures/middleware-pages/package.json
+++ b/tests/fixtures/middleware-pages/package.json
@@ -3,9 +3,9 @@
   "version": "0.1.0",
   "private": true,
   "scripts": {
-    "postinstall": "next build",
+    "postinstall": "npm run build",
     "dev": "next dev",
-    "build": "next build"
+    "build": "node ../../utils/build-variants.mjs"
   },
   "dependencies": {
     "next": "latest",
diff --git a/tests/fixtures/middleware-pages/test-variants.json b/tests/fixtures/middleware-pages/test-variants.json
new file mode 100644
index 0000000000..f31d535c28
--- /dev/null
+++ b/tests/fixtures/middleware-pages/test-variants.json
@@ -0,0 +1,21 @@
+{
+  "node-middleware": {
+    "distDir": ".next-node-middleware",
+    "files": {
+      "middleware.ts": "middleware-node.ts"
+    },
+    "test": {
+      "dependencies": {
+        "next": [
+          {
+            "versionConstraint": ">=15.2.0",
+            "canaryOnly": true
+          },
+          {
+            "versionConstraint": ">=15.5.0"
+          }
+        ]
+      }
+    }
+  }
+}
diff --git a/tests/integration/edge-handler.test.ts b/tests/integration/edge-handler.test.ts
index 2354bd63c1..ad0dfffbea 100644
--- a/tests/integration/edge-handler.test.ts
+++ b/tests/integration/edge-handler.test.ts
@@ -389,294 +389,308 @@ for (const {
         expect(origin.calls).toBe(0)
       })
     })
-  })
-}
-
-describe('page router', () => {
-  test('edge api routes should work with middleware', async (ctx) => {
-    await createFixture('middleware-pages', ctx)
-    await runPlugin(ctx)
-    const origin = await LocalServer.run(async (req, res) => {
-      res.write(
-        JSON.stringify({
-          url: req.url,
-          headers: req.headers,
-        }),
-      )
-      res.end()
-    })
-    ctx.cleanup?.push(() => origin.stop())
-    const response = await invokeEdgeFunction(ctx, {
-      functions: [EDGE_MIDDLEWARE_FUNCTION_NAME],
-      origin,
-      url: `/api/edge-headers`,
-    })
-    const res = await response.json()
-    expect(res.url).toBe('/api/edge-headers')
-    expect(response.status).toBe(200)
-  })
-  test('middleware should rewrite data requests', async (ctx) => {
-    await createFixture('middleware-pages', ctx)
-    await runPlugin(ctx)
-    const origin = await LocalServer.run(async (req, res) => {
-      res.write(
-        JSON.stringify({
-          url: req.url,
-          headers: req.headers,
-        }),
-      )
-      res.end()
-    })
-    ctx.cleanup?.push(() => origin.stop())
-    const response = await invokeEdgeFunction(ctx, {
-      functions: [EDGE_MIDDLEWARE_FUNCTION_NAME],
-      headers: {
-        'x-nextjs-data': '1',
-      },
-      origin,
-      url: `/_next/data/build-id/ssr-page.json`,
-    })
-    const res = await response.json()
-    const url = new URL(res.url, 'http://n/')
-    expect(url.pathname).toBe('/_next/data/build-id/ssr-page-2.json')
-    expect(res.headers['x-nextjs-data']).toBe('1')
-    expect(response.headers.get('x-nextjs-rewrite')).toBe('/ssr-page-2/')
-    expect(response.status).toBe(200)
-  })
 
-  test('middleware should leave non-data requests untouched', async (ctx) => {
-    await createFixture('middleware-pages', ctx)
-    await runPlugin(ctx)
-    const origin = await LocalServer.run(async (req, res) => {
-      res.write(
-        JSON.stringify({
-          url: req.url,
-          headers: req.headers,
-        }),
-      )
-      res.end()
-    })
-    ctx.cleanup?.push(() => origin.stop())
-    const response = await invokeEdgeFunction(ctx, {
-      functions: [EDGE_MIDDLEWARE_FUNCTION_NAME],
-      origin,
-      url: `/_next/static/build-id/_devMiddlewareManifest.json?foo=1`,
-    })
-    const res = await response.json()
-    const url = new URL(res.url, 'http://n/')
-    expect(url.pathname).toBe('/_next/static/build-id/_devMiddlewareManifest.json')
-    expect(url.search).toBe('?foo=1')
-    expect(res.headers['x-nextjs-data']).toBeUndefined()
-    expect(response.status).toBe(200)
-  })
+    describe('page router', () => {
+      test('edge api routes should work with middleware', async (ctx) => {
+        await createFixture('middleware-pages', ctx)
+        await runPlugin(ctx, runPluginConstants)
+        const origin = await LocalServer.run(async (req, res) => {
+          res.write(
+            JSON.stringify({
+              url: req.url,
+              headers: req.headers,
+            }),
+          )
+          res.end()
+        })
+        ctx.cleanup?.push(() => origin.stop())
+        const response = await invokeEdgeFunction(ctx, {
+          functions: [edgeFunctionNameRoot],
+          origin,
+          url: `/api/edge-headers`,
+        })
+        const res = await response.json()
+        expect(res.url).toBe('/api/edge-headers')
+        expect(response.status).toBe(200)
+        expect(response.headers.get('x-runtime')).toEqual(expectedRuntime)
+      })
+      test('middleware should rewrite data requests', async (ctx) => {
+        await createFixture('middleware-pages', ctx)
+        await runPlugin(ctx, runPluginConstants)
+        const origin = await LocalServer.run(async (req, res) => {
+          res.write(
+            JSON.stringify({
+              url: req.url,
+              headers: req.headers,
+            }),
+          )
+          res.end()
+        })
+        ctx.cleanup?.push(() => origin.stop())
+        const response = await invokeEdgeFunction(ctx, {
+          functions: [edgeFunctionNameRoot],
+          headers: {
+            'x-nextjs-data': '1',
+          },
+          origin,
+          url: `/_next/data/build-id/ssr-page.json`,
+        })
+        const res = await response.json()
+        const url = new URL(res.url, 'http://n/')
+        expect(url.pathname).toBe('/_next/data/build-id/ssr-page-2.json')
+        expect(res.headers['x-nextjs-data']).toBe('1')
+        expect(response.status).toBe(200)
+        expect(response.headers.get('x-nextjs-rewrite')).toBe('/ssr-page-2/')
+        expect(response.headers.get('x-runtime')).toEqual(expectedRuntime)
+      })
 
-  test('should NOT rewrite un-rewritten data requests to page route', async (ctx) => {
-    await createFixture('middleware-pages', ctx)
-    await runPlugin(ctx)
-    const origin = await LocalServer.run(async (req, res) => {
-      res.write(
-        JSON.stringify({
-          url: req.url,
-          headers: req.headers,
-        }),
-      )
-      res.end()
-    })
-    ctx.cleanup?.push(() => origin.stop())
-    const response = await invokeEdgeFunction(ctx, {
-      functions: [EDGE_MIDDLEWARE_FUNCTION_NAME],
-      headers: {
-        'x-nextjs-data': '1',
-      },
-      origin,
-      url: `/_next/data/build-id/ssg/hello.json`,
-    })
-    const res = await response.json()
-    const url = new URL(res.url, 'http://n/')
-    expect(url.pathname).toBe('/_next/data/build-id/ssg/hello.json')
-    expect(res.headers['x-nextjs-data']).toBe('1')
-    expect(response.status).toBe(200)
-  })
+      test('middleware should leave non-data requests untouched', async (ctx) => {
+        await createFixture('middleware-pages', ctx)
+        await runPlugin(ctx, runPluginConstants)
+        const origin = await LocalServer.run(async (req, res) => {
+          res.write(
+            JSON.stringify({
+              url: req.url,
+              headers: req.headers,
+            }),
+          )
+          res.end()
+        })
+        ctx.cleanup?.push(() => origin.stop())
+        const response = await invokeEdgeFunction(ctx, {
+          functions: [edgeFunctionNameRoot],
+          origin,
+          url: `/_next/static/build-id/_devMiddlewareManifest.json?foo=1`,
+        })
+        const res = await response.json()
+        const url = new URL(res.url, 'http://n/')
+        expect(url.pathname).toBe('/_next/static/build-id/_devMiddlewareManifest.json')
+        expect(url.search).toBe('?foo=1')
+        expect(res.headers['x-nextjs-data']).toBeUndefined()
+        expect(response.status).toBe(200)
+        expect(response.headers.get('x-runtime')).toEqual(expectedRuntime)
+      })
 
-  test('should preserve query params in rewritten data requests', async (ctx) => {
-    await createFixture('middleware-pages', ctx)
-    await runPlugin(ctx)
-    const origin = await LocalServer.run(async (req, res) => {
-      res.write(
-        JSON.stringify({
-          url: req.url,
-          headers: req.headers,
-        }),
-      )
-      res.end()
-    })
-    ctx.cleanup?.push(() => origin.stop())
-    const response = await invokeEdgeFunction(ctx, {
-      functions: [EDGE_MIDDLEWARE_FUNCTION_NAME],
-      headers: {
-        'x-nextjs-data': '1',
-      },
-      origin,
-      url: `/_next/data/build-id/blog/first.json?slug=first`,
-    })
-    const res = await response.json()
-    const url = new URL(res.url, 'http://n/')
-    expect(url.pathname).toBe('/_next/data/build-id/blog/first.json')
-    expect(url.searchParams.get('slug')).toBe('first')
-    expect(res.headers['x-nextjs-data']).toBe('1')
-    expect(response.status).toBe(200)
-  })
+      test('should NOT rewrite un-rewritten data requests to page route', async (ctx) => {
+        await createFixture('middleware-pages', ctx)
+        await runPlugin(ctx, runPluginConstants)
+        const origin = await LocalServer.run(async (req, res) => {
+          res.write(
+            JSON.stringify({
+              url: req.url,
+              headers: req.headers,
+            }),
+          )
+          res.end()
+        })
+        ctx.cleanup?.push(() => origin.stop())
+        const response = await invokeEdgeFunction(ctx, {
+          functions: [edgeFunctionNameRoot],
+          headers: {
+            'x-nextjs-data': '1',
+          },
+          origin,
+          url: `/_next/data/build-id/ssg/hello.json`,
+        })
+        const res = await response.json()
+        const url = new URL(res.url, 'http://n/')
+        expect(url.pathname).toBe('/_next/data/build-id/ssg/hello.json')
+        expect(res.headers['x-nextjs-data']).toBe('1')
+        expect(response.status).toBe(200)
+        expect(response.headers.get('x-runtime')).toEqual(expectedRuntime)
+      })
 
-  test('should preserve locale in redirects', async (ctx) => {
-    await createFixture('middleware-i18n', ctx)
-    await runPlugin(ctx)
-    const origin = await LocalServer.run(async (req, res) => {
-      res.write(
-        JSON.stringify({
-          url: req.url,
-          headers: req.headers,
-        }),
-      )
-      res.end()
-    })
-    ctx.cleanup?.push(() => origin.stop())
-    const response = await invokeEdgeFunction(ctx, {
-      functions: [EDGE_MIDDLEWARE_FUNCTION_NAME],
-      origin,
-      url: `/fr/old-home`,
-      redirect: 'manual',
-    })
-    const url = new URL(response.headers.get('location') ?? '', 'http://n/')
-    expect(url.pathname).toBe('/fr/new-home')
-    expect(response.status).toBe(302)
-  })
+      test('should preserve query params in rewritten data requests', async (ctx) => {
+        await createFixture('middleware-pages', ctx)
+        await runPlugin(ctx, runPluginConstants)
+        const origin = await LocalServer.run(async (req, res) => {
+          res.write(
+            JSON.stringify({
+              url: req.url,
+              headers: req.headers,
+            }),
+          )
+          res.end()
+        })
+        ctx.cleanup?.push(() => origin.stop())
+        const response = await invokeEdgeFunction(ctx, {
+          functions: [edgeFunctionNameRoot],
+          headers: {
+            'x-nextjs-data': '1',
+          },
+          origin,
+          url: `/_next/data/build-id/blog/first.json?slug=first`,
+        })
+        const res = await response.json()
+        const url = new URL(res.url, 'http://n/')
+        expect(url.pathname).toBe('/_next/data/build-id/blog/first.json')
+        expect(url.searchParams.get('slug')).toBe('first')
+        expect(res.headers['x-nextjs-data']).toBe('1')
+        expect(response.status).toBe(200)
+        expect(response.headers.get('x-runtime')).toEqual(expectedRuntime)
+      })
 
-  test('should support redirects to default locale without changing path', async (ctx) => {
-    await createFixture('middleware-i18n', ctx)
-    await runPlugin(ctx)
-    const origin = await LocalServer.run(async (req, res) => {
-      res.write(
-        JSON.stringify({
-          url: req.url,
-          headers: req.headers,
-        }),
-      )
-      res.end()
-    })
-    ctx.cleanup?.push(() => origin.stop())
-    const response = await invokeEdgeFunction(ctx, {
-      functions: [EDGE_MIDDLEWARE_FUNCTION_NAME],
-      origin,
-      url: `/fr/redirect-to-same-page-but-default-locale`,
-      redirect: 'manual',
-    })
-    const url = new URL(response.headers.get('location') ?? '', 'http://n/')
-    expect(url.pathname).toBe('/redirect-to-same-page-but-default-locale')
-    expect(response.status).toBe(302)
-  })
+      test('should preserve locale in redirects', async (ctx) => {
+        await createFixture('middleware-i18n', ctx)
+        await runPlugin(ctx)
+        const origin = await LocalServer.run(async (req, res) => {
+          res.write(
+            JSON.stringify({
+              url: req.url,
+              headers: req.headers,
+            }),
+          )
+          res.end()
+        })
+        ctx.cleanup?.push(() => origin.stop())
+        const response = await invokeEdgeFunction(ctx, {
+          functions: [EDGE_MIDDLEWARE_FUNCTION_NAME],
+          origin,
+          url: `/fr/old-home`,
+          redirect: 'manual',
+        })
+        const url = new URL(response.headers.get('location') ?? '', 'http://n/')
+        expect(url.pathname).toBe('/fr/new-home')
+        expect(response.status).toBe(302)
+      })
 
-  test('should preserve locale in request.nextUrl', async (ctx) => {
-    await createFixture('middleware-i18n', ctx)
-    await runPlugin(ctx)
-    const origin = await LocalServer.run(async (req, res) => {
-      res.write(
-        JSON.stringify({
-          url: req.url,
-          headers: req.headers,
-        }),
+      test.skipIf(expectedRuntime === 'node')(
+        'should support redirects to default locale without changing path',
+        async (ctx) => {
+          await createFixture('middleware-i18n', ctx)
+          await runPlugin(ctx)
+          const origin = await LocalServer.run(async (req, res) => {
+            res.write(
+              JSON.stringify({
+                url: req.url,
+                headers: req.headers,
+              }),
+            )
+            res.end()
+          })
+          ctx.cleanup?.push(() => origin.stop())
+          const response = await invokeEdgeFunction(ctx, {
+            functions: [EDGE_MIDDLEWARE_FUNCTION_NAME],
+            origin,
+            url: `/fr/redirect-to-same-page-but-default-locale`,
+            redirect: 'manual',
+          })
+          const url = new URL(response.headers.get('location') ?? '', 'http://n/')
+          expect(url.pathname).toBe('/redirect-to-same-page-but-default-locale')
+          expect(response.status).toBe(302)
+        },
       )
-      res.end()
-    })
-    ctx.cleanup?.push(() => origin.stop())
 
-    const response = await invokeEdgeFunction(ctx, {
-      functions: [EDGE_MIDDLEWARE_FUNCTION_NAME],
-      origin,
-      url: `/json`,
-    })
-    expect(response.status).toBe(200)
-    const body = await response.json()
+      test.skipIf(expectedRuntime === 'node')(
+        'should preserve locale in request.nextUrl',
+        async (ctx) => {
+          await createFixture('middleware-i18n', ctx)
+          await runPlugin(ctx)
+          const origin = await LocalServer.run(async (req, res) => {
+            res.write(
+              JSON.stringify({
+                url: req.url,
+                headers: req.headers,
+              }),
+            )
+            res.end()
+          })
+          ctx.cleanup?.push(() => origin.stop())
 
-    expect(body.requestUrlPathname).toBe('/json')
-    expect(body.nextUrlPathname).toBe('/json')
-    expect(body.nextUrlLocale).toBe('en')
+          const response = await invokeEdgeFunction(ctx, {
+            functions: [EDGE_MIDDLEWARE_FUNCTION_NAME],
+            origin,
+            url: `/json`,
+          })
+          expect(response.status).toBe(200)
+          const body = await response.json()
 
-    const responseEn = await invokeEdgeFunction(ctx, {
-      functions: [EDGE_MIDDLEWARE_FUNCTION_NAME],
-      origin,
-      url: `/en/json`,
-    })
-    expect(responseEn.status).toBe(200)
-    const bodyEn = await responseEn.json()
+          expect(body.requestUrlPathname).toBe('/json')
+          expect(body.nextUrlPathname).toBe('/json')
+          expect(body.nextUrlLocale).toBe('en')
 
-    expect(bodyEn.requestUrlPathname).toBe('/json')
-    expect(bodyEn.nextUrlPathname).toBe('/json')
-    expect(bodyEn.nextUrlLocale).toBe('en')
+          const responseEn = await invokeEdgeFunction(ctx, {
+            functions: [EDGE_MIDDLEWARE_FUNCTION_NAME],
+            origin,
+            url: `/en/json`,
+          })
+          expect(responseEn.status).toBe(200)
+          const bodyEn = await responseEn.json()
 
-    const responseFr = await invokeEdgeFunction(ctx, {
-      functions: [EDGE_MIDDLEWARE_FUNCTION_NAME],
-      origin,
-      url: `/fr/json`,
-    })
-    expect(responseFr.status).toBe(200)
-    const bodyFr = await responseFr.json()
+          expect(bodyEn.requestUrlPathname).toBe('/json')
+          expect(bodyEn.nextUrlPathname).toBe('/json')
+          expect(bodyEn.nextUrlLocale).toBe('en')
 
-    expect(bodyFr.requestUrlPathname).toBe('/fr/json')
-    expect(bodyFr.nextUrlPathname).toBe('/json')
-    expect(bodyFr.nextUrlLocale).toBe('fr')
-  })
+          const responseFr = await invokeEdgeFunction(ctx, {
+            functions: [EDGE_MIDDLEWARE_FUNCTION_NAME],
+            origin,
+            url: `/fr/json`,
+          })
+          expect(responseFr.status).toBe(200)
+          const bodyFr = await responseFr.json()
 
-  test('should preserve locale in request.nextUrl with skipMiddlewareUrlNormalize', async (ctx) => {
-    await createFixture('middleware-i18n-skip-normalize', ctx)
-    await runPlugin(ctx)
-    const origin = await LocalServer.run(async (req, res) => {
-      res.write(
-        JSON.stringify({
-          url: req.url,
-          headers: req.headers,
-        }),
+          expect(bodyFr.requestUrlPathname).toBe('/fr/json')
+          expect(bodyFr.nextUrlPathname).toBe('/json')
+          expect(bodyFr.nextUrlLocale).toBe('fr')
+        },
       )
-      res.end()
-    })
-    ctx.cleanup?.push(() => origin.stop())
 
-    const response = await invokeEdgeFunction(ctx, {
-      functions: [EDGE_MIDDLEWARE_FUNCTION_NAME],
-      origin,
-      url: `/json`,
-    })
-    expect(response.status).toBe(200)
-    const body = await response.json()
+      test.skipIf(expectedRuntime === 'node')(
+        'should preserve locale in request.nextUrl with skipMiddlewareUrlNormalize',
+        async (ctx) => {
+          await createFixture('middleware-i18n-skip-normalize', ctx)
+          await runPlugin(ctx)
+          const origin = await LocalServer.run(async (req, res) => {
+            res.write(
+              JSON.stringify({
+                url: req.url,
+                headers: req.headers,
+              }),
+            )
+            res.end()
+          })
+          ctx.cleanup?.push(() => origin.stop())
 
-    expect(body.requestUrlPathname).toBe('/json')
-    expect(body.nextUrlPathname).toBe('/json')
-    expect(body.nextUrlLocale).toBe('en')
+          const response = await invokeEdgeFunction(ctx, {
+            functions: [EDGE_MIDDLEWARE_FUNCTION_NAME],
+            origin,
+            url: `/json`,
+          })
+          expect(response.status).toBe(200)
+          const body = await response.json()
 
-    const responseEn = await invokeEdgeFunction(ctx, {
-      functions: [EDGE_MIDDLEWARE_FUNCTION_NAME],
-      origin,
-      url: `/en/json`,
-    })
-    expect(responseEn.status).toBe(200)
-    const bodyEn = await responseEn.json()
+          expect(body.requestUrlPathname).toBe('/json')
+          expect(body.nextUrlPathname).toBe('/json')
+          expect(body.nextUrlLocale).toBe('en')
 
-    expect(bodyEn.requestUrlPathname).toBe('/en/json')
-    expect(bodyEn.nextUrlPathname).toBe('/json')
-    expect(bodyEn.nextUrlLocale).toBe('en')
+          const responseEn = await invokeEdgeFunction(ctx, {
+            functions: [EDGE_MIDDLEWARE_FUNCTION_NAME],
+            origin,
+            url: `/en/json`,
+          })
+          expect(responseEn.status).toBe(200)
+          const bodyEn = await responseEn.json()
 
-    const responseFr = await invokeEdgeFunction(ctx, {
-      functions: [EDGE_MIDDLEWARE_FUNCTION_NAME],
-      origin,
-      url: `/fr/json`,
-    })
-    expect(responseFr.status).toBe(200)
-    const bodyFr = await responseFr.json()
+          expect(bodyEn.requestUrlPathname).toBe('/en/json')
+          expect(bodyEn.nextUrlPathname).toBe('/json')
+          expect(bodyEn.nextUrlLocale).toBe('en')
+
+          const responseFr = await invokeEdgeFunction(ctx, {
+            functions: [EDGE_MIDDLEWARE_FUNCTION_NAME],
+            origin,
+            url: `/fr/json`,
+          })
+          expect(responseFr.status).toBe(200)
+          const bodyFr = await responseFr.json()
 
-    expect(bodyFr.requestUrlPathname).toBe('/fr/json')
-    expect(bodyFr.nextUrlPathname).toBe('/json')
-    expect(bodyFr.nextUrlLocale).toBe('fr')
+          expect(bodyFr.requestUrlPathname).toBe('/fr/json')
+          expect(bodyFr.nextUrlPathname).toBe('/json')
+          expect(bodyFr.nextUrlLocale).toBe('fr')
+        },
+      )
+    })
   })
-})
+}
 
 // test.skipIf(!nextVersionSatisfies('>=15.2.0'))(
 //   'should throw an Not Supported error when node middleware is used',
diff --git a/tests/utils/create-e2e-fixture.ts b/tests/utils/create-e2e-fixture.ts
index 65cf9dc227..aaf8b24376 100644
--- a/tests/utils/create-e2e-fixture.ts
+++ b/tests/utils/create-e2e-fixture.ts
@@ -350,6 +350,11 @@ export const fixtureFactories = {
   middlewareI18nExcludedPaths: () => createE2EFixture('middleware-i18n-excluded-paths'),
   middlewareOg: () => createE2EFixture('middleware-og'),
   middlewarePages: () => createE2EFixture('middleware-pages'),
+  middlewarePagesNode: () =>
+    createE2EFixture('middleware-pages', {
+      buildCommand: getBuildFixtureVariantCommand('node-middleware'),
+      publishDirectory: '.next-node-middleware',
+    }),
   middlewareStaticAssetMatcher: () => createE2EFixture('middleware-static-asset-matcher'),
   middlewareSubrequestVuln: () => createE2EFixture('middleware-subrequest-vuln'),
   pageRouter: () => createE2EFixture('page-router'),

From aec3a714d2b24ed052a1bc5b3a81525ca4607771 Mon Sep 17 00:00:00 2001
From: Michal Piechowiak 
Date: Thu, 21 Aug 2025 10:09:46 +0200
Subject: [PATCH 26/47] chore: don't throw immediately on failed build and
 instead allow for cleanup

---
 tests/utils/build-variants.mjs | 1 +
 1 file changed, 1 insertion(+)

diff --git a/tests/utils/build-variants.mjs b/tests/utils/build-variants.mjs
index 0045dff5bd..0fc6f33c06 100644
--- a/tests/utils/build-variants.mjs
+++ b/tests/utils/build-variants.mjs
@@ -146,6 +146,7 @@ for (const variantToBuild of variantsToBuild) {
       NEXT_DIST_DIR: distDir,
     },
     stdio: 'inherit',
+    reject: false,
   })
 
   await runCleanup()

From b04d1cadf376153840136fdbefe6382bb336089d Mon Sep 17 00:00:00 2001
From: Michal Piechowiak 
Date: Thu, 21 Aug 2025 10:39:55 +0200
Subject: [PATCH 27/47] test: move middleware-i18n to test variants

---
 tests/e2e/edge-middleware.test.ts             | 115 ++++++++------
 .../middleware-i18n/middleware-node.ts        |   5 +
 .../{middleware.js => middleware-shared.ts}   |  15 +-
 tests/fixtures/middleware-i18n/middleware.ts  |   1 +
 tests/fixtures/middleware-i18n/next.config.js |   2 +
 tests/fixtures/middleware-i18n/package.json   |   4 +-
 .../middleware-i18n/test-variants.json        |  21 +++
 tests/integration/edge-handler.test.ts        | 149 +++++++++---------
 tests/utils/create-e2e-fixture.ts             |   5 +
 9 files changed, 195 insertions(+), 122 deletions(-)
 create mode 100644 tests/fixtures/middleware-i18n/middleware-node.ts
 rename tests/fixtures/middleware-i18n/{middleware.js => middleware-shared.ts} (82%)
 create mode 100644 tests/fixtures/middleware-i18n/middleware.ts
 create mode 100644 tests/fixtures/middleware-i18n/test-variants.json

diff --git a/tests/e2e/edge-middleware.test.ts b/tests/e2e/edge-middleware.test.ts
index 65d46cd10e..25ac1b8356 100644
--- a/tests/e2e/edge-middleware.test.ts
+++ b/tests/e2e/edge-middleware.test.ts
@@ -11,6 +11,7 @@ type ExtendedWindow = Window & {
 type ExtendedFixtures = {
   edgeOrNodeMiddleware: Fixture
   edgeOrNodeMiddlewarePages: Fixture
+  edgeOrNodeMiddlewareI18n: Fixture
 }
 
 for (const { expectedRuntime, label, testWithSwitchableMiddlewareRuntime } of [
@@ -34,6 +35,14 @@ for (const { expectedRuntime, label, testWithSwitchableMiddlewareRuntime } of [
           scope: 'worker',
         },
       ],
+      edgeOrNodeMiddlewareI18n: [
+        async ({ middlewareI18n }, use) => {
+          await use(middlewareI18n)
+        },
+        {
+          scope: 'worker',
+        },
+      ],
     }),
   },
   hasNodeMiddlewareSupport()
@@ -57,6 +66,14 @@ for (const { expectedRuntime, label, testWithSwitchableMiddlewareRuntime } of [
               scope: 'worker',
             },
           ],
+          edgeOrNodeMiddlewareI18n: [
+            async ({ middlewareI18nNode }, use) => {
+              await use(middlewareI18nNode)
+            },
+            {
+              scope: 'worker',
+            },
+          ],
         }),
       }
     : undefined,
@@ -73,6 +90,8 @@ for (const { expectedRuntime, label, testWithSwitchableMiddlewareRuntime } of [
 
       const h1 = page.locator('h1')
       await expect(h1).toHaveText('Other')
+
+      expect(await res?.headerValue('x-runtime')).toEqual(expectedRuntime)
     })
 
     test('Does not run middleware at the origin', async ({ page, edgeOrNodeMiddleware }) => {
@@ -101,12 +120,15 @@ for (const { expectedRuntime, label, testWithSwitchableMiddlewareRuntime } of [
       expect(await rewritten?.headerValue('x-added-rewrite-target')).toBeNull()
       const h1 = page.locator('h1')
       await expect(h1).toHaveText('Hello rewrite')
+
+      expect(await direct?.headerValue('x-runtime')).toEqual(expectedRuntime)
     })
 
     test('Supports CJS dependencies in Edge Middleware', async ({ page, edgeOrNodeMiddleware }) => {
       const res = await page.goto(`${edgeOrNodeMiddleware.url}/test/next`)
 
       expect(await res?.headerValue('x-cjs-module-works')).toEqual('true')
+      expect(await res?.headerValue('x-runtime')).toEqual(expectedRuntime)
     })
 
     if (expectedRuntime !== 'node') {
@@ -212,62 +234,67 @@ for (const { expectedRuntime, label, testWithSwitchableMiddlewareRuntime } of [
           })
         }
       })
-      if (expectedRuntime !== 'node') {
-        test.describe('with 18n', () => {
-          for (const testConfig of testConfigs) {
-            test.describe(testConfig.describeLabel, () => {
-              for (const { localeLabel, pageWithLinksPathname } of [
-                { localeLabel: 'implicit default locale', pageWithLinksPathname: '/link' },
-                { localeLabel: 'explicit default locale', pageWithLinksPathname: '/en/link' },
-                { localeLabel: 'explicit non-default locale', pageWithLinksPathname: '/fr/link' },
-              ]) {
-                test.describe(localeLabel, () => {
-                  test('json data fetch', async ({ middlewareI18n, page }) => {
-                    const dataFetchPromise = new Promise((resolve) => {
-                      page.on('response', (response) => {
-                        if (response.url().includes(testConfig.jsonPathMatcher)) {
-                          resolve(response)
-                        }
-                      })
+
+      test.describe('with 18n', () => {
+        for (const testConfig of testConfigs) {
+          test.describe(testConfig.describeLabel, () => {
+            for (const { localeLabel, pageWithLinksPathname } of [
+              { localeLabel: 'implicit default locale', pageWithLinksPathname: '/link' },
+              { localeLabel: 'explicit default locale', pageWithLinksPathname: '/en/link' },
+              { localeLabel: 'explicit non-default locale', pageWithLinksPathname: '/fr/link' },
+            ]) {
+              test.describe(localeLabel, () => {
+                test('json data fetch', async ({ edgeOrNodeMiddlewareI18n, page }) => {
+                  const dataFetchPromise = new Promise((resolve) => {
+                    page.on('response', (response) => {
+                      if (response.url().includes(testConfig.jsonPathMatcher)) {
+                        resolve(response)
+                      }
                     })
+                  })
 
-                    await page.goto(`${middlewareI18n.url}${pageWithLinksPathname}`)
+                  const pageResponse = await page.goto(
+                    `${edgeOrNodeMiddlewareI18n.url}${pageWithLinksPathname}`,
+                  )
+                  expect(await pageResponse?.headerValue('x-runtime')).toEqual(expectedRuntime)
 
-                    await page.hover(`[data-link="${testConfig.selector}"]`)
+                  await page.hover(`[data-link="${testConfig.selector}"]`)
 
-                    const dataResponse = await dataFetchPromise
+                  const dataResponse = await dataFetchPromise
 
-                    expect(dataResponse.ok()).toBe(true)
-                  })
+                  expect(dataResponse.ok()).toBe(true)
+                })
 
-                  test('navigation', async ({ middlewareI18n, page }) => {
-                    await page.goto(`${middlewareI18n.url}${pageWithLinksPathname}`)
+                test('navigation', async ({ edgeOrNodeMiddlewareI18n, page }) => {
+                  const pageResponse = await page.goto(
+                    `${edgeOrNodeMiddlewareI18n.url}${pageWithLinksPathname}`,
+                  )
+                  expect(await pageResponse?.headerValue('x-runtime')).toEqual(expectedRuntime)
 
-                    await page.evaluate(() => {
-                      // set some value to window to check later if browser did reload and lost this state
-                      ;(window as ExtendedWindow).didReload = false
-                    })
+                  await page.evaluate(() => {
+                    // set some value to window to check later if browser did reload and lost this state
+                    ;(window as ExtendedWindow).didReload = false
+                  })
 
-                    await page.click(`[data-link="${testConfig.selector}"]`)
+                  await page.click(`[data-link="${testConfig.selector}"]`)
 
-                    // wait for page to be rendered
-                    await page.waitForSelector(`[data-page="${testConfig.selector}"]`)
+                  // wait for page to be rendered
+                  await page.waitForSelector(`[data-page="${testConfig.selector}"]`)
 
-                    // check if browser navigation worked by checking if state was preserved
-                    const browserNavigationWorked =
-                      (await page.evaluate(() => {
-                        return (window as ExtendedWindow).didReload
-                      })) === false
+                  // check if browser navigation worked by checking if state was preserved
+                  const browserNavigationWorked =
+                    (await page.evaluate(() => {
+                      return (window as ExtendedWindow).didReload
+                    })) === false
 
-                    // we expect client navigation to work without browser reload
-                    expect(browserNavigationWorked).toBe(true)
-                  })
+                  // we expect client navigation to work without browser reload
+                  expect(browserNavigationWorked).toBe(true)
                 })
-              }
-            })
-          }
-        })
-      }
+              })
+            }
+          })
+        }
+      })
     })
 
     if (expectedRuntime !== 'node') {
diff --git a/tests/fixtures/middleware-i18n/middleware-node.ts b/tests/fixtures/middleware-i18n/middleware-node.ts
new file mode 100644
index 0000000000..780faa76fc
--- /dev/null
+++ b/tests/fixtures/middleware-i18n/middleware-node.ts
@@ -0,0 +1,5 @@
+export { middleware } from './middleware-shared'
+
+export const config = {
+  runtime: 'nodejs',
+}
diff --git a/tests/fixtures/middleware-i18n/middleware.js b/tests/fixtures/middleware-i18n/middleware-shared.ts
similarity index 82%
rename from tests/fixtures/middleware-i18n/middleware.js
rename to tests/fixtures/middleware-i18n/middleware-shared.ts
index 3462214f1d..8a24bf39fb 100644
--- a/tests/fixtures/middleware-i18n/middleware.js
+++ b/tests/fixtures/middleware-i18n/middleware-shared.ts
@@ -1,6 +1,19 @@
+import type { NextRequest } from 'next/server'
 import { NextResponse } from 'next/server'
 
-export async function middleware(request) {
+export async function middleware(request: NextRequest) {
+  const response = getResponse(request)
+
+  if (response) {
+    response.headers.append('Deno' in globalThis ? 'x-deno' : 'x-node', Date.now().toString())
+    // report Next.js Middleware Runtime (not the execution runtime, but target runtime)
+    // @ts-expect-error EdgeRuntime global not declared
+    response.headers.append('x-runtime', typeof EdgeRuntime !== 'undefined' ? EdgeRuntime : 'node')
+    response.headers.set('x-hello-from-middleware-res', 'hello')
+  }
+}
+
+const getResponse = (request: NextRequest) => {
   const url = request.nextUrl
 
   // this is needed for tests to get the BUILD_ID
diff --git a/tests/fixtures/middleware-i18n/middleware.ts b/tests/fixtures/middleware-i18n/middleware.ts
new file mode 100644
index 0000000000..fcc87b30fd
--- /dev/null
+++ b/tests/fixtures/middleware-i18n/middleware.ts
@@ -0,0 +1 @@
+export { middleware } from './middleware-shared'
diff --git a/tests/fixtures/middleware-i18n/next.config.js b/tests/fixtures/middleware-i18n/next.config.js
index 027a9334b5..163d0acabd 100644
--- a/tests/fixtures/middleware-i18n/next.config.js
+++ b/tests/fixtures/middleware-i18n/next.config.js
@@ -1,5 +1,6 @@
 module.exports = {
   output: 'standalone',
+  distDir: process.env.NEXT_DIST_DIR ?? '.next',
   eslint: {
     ignoreDuringBuilds: true,
   },
@@ -10,6 +11,7 @@ module.exports = {
   experimental: {
     clientRouterFilter: true,
     clientRouterFilterRedirects: true,
+    nodeMiddleware: true,
   },
   redirects() {
     return [
diff --git a/tests/fixtures/middleware-i18n/package.json b/tests/fixtures/middleware-i18n/package.json
index 5708c88b50..b336803e43 100644
--- a/tests/fixtures/middleware-i18n/package.json
+++ b/tests/fixtures/middleware-i18n/package.json
@@ -3,9 +3,9 @@
   "version": "0.1.0",
   "private": true,
   "scripts": {
-    "postinstall": "next build",
+    "postinstall": "npm run build",
     "dev": "next dev",
-    "build": "next build"
+    "build": "node ../../utils/build-variants.mjs"
   },
   "dependencies": {
     "next": "latest",
diff --git a/tests/fixtures/middleware-i18n/test-variants.json b/tests/fixtures/middleware-i18n/test-variants.json
new file mode 100644
index 0000000000..f31d535c28
--- /dev/null
+++ b/tests/fixtures/middleware-i18n/test-variants.json
@@ -0,0 +1,21 @@
+{
+  "node-middleware": {
+    "distDir": ".next-node-middleware",
+    "files": {
+      "middleware.ts": "middleware-node.ts"
+    },
+    "test": {
+      "dependencies": {
+        "next": [
+          {
+            "versionConstraint": ">=15.2.0",
+            "canaryOnly": true
+          },
+          {
+            "versionConstraint": ">=15.5.0"
+          }
+        ]
+      }
+    }
+  }
+}
diff --git a/tests/integration/edge-handler.test.ts b/tests/integration/edge-handler.test.ts
index ad0dfffbea..f2e9401a52 100644
--- a/tests/integration/edge-handler.test.ts
+++ b/tests/integration/edge-handler.test.ts
@@ -532,7 +532,7 @@ for (const {
 
       test('should preserve locale in redirects', async (ctx) => {
         await createFixture('middleware-i18n', ctx)
-        await runPlugin(ctx)
+        await runPlugin(ctx, runPluginConstants)
         const origin = await LocalServer.run(async (req, res) => {
           res.write(
             JSON.stringify({
@@ -544,7 +544,7 @@ for (const {
         })
         ctx.cleanup?.push(() => origin.stop())
         const response = await invokeEdgeFunction(ctx, {
-          functions: [EDGE_MIDDLEWARE_FUNCTION_NAME],
+          functions: [edgeFunctionNameRoot],
           origin,
           url: `/fr/old-home`,
           redirect: 'manual',
@@ -552,88 +552,87 @@ for (const {
         const url = new URL(response.headers.get('location') ?? '', 'http://n/')
         expect(url.pathname).toBe('/fr/new-home')
         expect(response.status).toBe(302)
+        expect(response.headers.get('x-runtime')).toEqual(expectedRuntime)
       })
 
-      test.skipIf(expectedRuntime === 'node')(
-        'should support redirects to default locale without changing path',
-        async (ctx) => {
-          await createFixture('middleware-i18n', ctx)
-          await runPlugin(ctx)
-          const origin = await LocalServer.run(async (req, res) => {
-            res.write(
-              JSON.stringify({
-                url: req.url,
-                headers: req.headers,
-              }),
-            )
-            res.end()
-          })
-          ctx.cleanup?.push(() => origin.stop())
-          const response = await invokeEdgeFunction(ctx, {
-            functions: [EDGE_MIDDLEWARE_FUNCTION_NAME],
-            origin,
-            url: `/fr/redirect-to-same-page-but-default-locale`,
-            redirect: 'manual',
-          })
-          const url = new URL(response.headers.get('location') ?? '', 'http://n/')
-          expect(url.pathname).toBe('/redirect-to-same-page-but-default-locale')
-          expect(response.status).toBe(302)
-        },
-      )
+      test('should support redirects to default locale without changing path', async (ctx) => {
+        await createFixture('middleware-i18n', ctx)
+        await runPlugin(ctx, runPluginConstants)
+        const origin = await LocalServer.run(async (req, res) => {
+          res.write(
+            JSON.stringify({
+              url: req.url,
+              headers: req.headers,
+            }),
+          )
+          res.end()
+        })
+        ctx.cleanup?.push(() => origin.stop())
+        const response = await invokeEdgeFunction(ctx, {
+          functions: [edgeFunctionNameRoot],
+          origin,
+          url: `/fr/redirect-to-same-page-but-default-locale`,
+          redirect: 'manual',
+        })
+        const url = new URL(response.headers.get('location') ?? '', 'http://n/')
+        expect(url.pathname).toBe('/redirect-to-same-page-but-default-locale')
+        expect(response.status).toBe(302)
+        expect(response.headers.get('x-runtime')).toEqual(expectedRuntime)
+      })
 
-      test.skipIf(expectedRuntime === 'node')(
-        'should preserve locale in request.nextUrl',
-        async (ctx) => {
-          await createFixture('middleware-i18n', ctx)
-          await runPlugin(ctx)
-          const origin = await LocalServer.run(async (req, res) => {
-            res.write(
-              JSON.stringify({
-                url: req.url,
-                headers: req.headers,
-              }),
-            )
-            res.end()
-          })
-          ctx.cleanup?.push(() => origin.stop())
+      test('should preserve locale in request.nextUrl', async (ctx) => {
+        await createFixture('middleware-i18n', ctx)
+        await runPlugin(ctx, runPluginConstants)
+        const origin = await LocalServer.run(async (req, res) => {
+          res.write(
+            JSON.stringify({
+              url: req.url,
+              headers: req.headers,
+            }),
+          )
+          res.end()
+        })
+        ctx.cleanup?.push(() => origin.stop())
 
-          const response = await invokeEdgeFunction(ctx, {
-            functions: [EDGE_MIDDLEWARE_FUNCTION_NAME],
-            origin,
-            url: `/json`,
-          })
-          expect(response.status).toBe(200)
-          const body = await response.json()
+        const response = await invokeEdgeFunction(ctx, {
+          functions: [edgeFunctionNameRoot],
+          origin,
+          url: `/json`,
+        })
+        expect(response.status).toBe(200)
+        expect(response.headers.get('x-runtime')).toEqual(expectedRuntime)
+        const body = await response.json()
 
-          expect(body.requestUrlPathname).toBe('/json')
-          expect(body.nextUrlPathname).toBe('/json')
-          expect(body.nextUrlLocale).toBe('en')
+        expect(body.requestUrlPathname).toBe('/json')
+        expect(body.nextUrlPathname).toBe('/json')
+        expect(body.nextUrlLocale).toBe('en')
 
-          const responseEn = await invokeEdgeFunction(ctx, {
-            functions: [EDGE_MIDDLEWARE_FUNCTION_NAME],
-            origin,
-            url: `/en/json`,
-          })
-          expect(responseEn.status).toBe(200)
-          const bodyEn = await responseEn.json()
+        const responseEn = await invokeEdgeFunction(ctx, {
+          functions: [edgeFunctionNameRoot],
+          origin,
+          url: `/en/json`,
+        })
+        expect(responseEn.status).toBe(200)
+        expect(responseEn.headers.get('x-runtime')).toEqual(expectedRuntime)
+        const bodyEn = await responseEn.json()
 
-          expect(bodyEn.requestUrlPathname).toBe('/json')
-          expect(bodyEn.nextUrlPathname).toBe('/json')
-          expect(bodyEn.nextUrlLocale).toBe('en')
+        expect(bodyEn.requestUrlPathname).toBe('/json')
+        expect(bodyEn.nextUrlPathname).toBe('/json')
+        expect(bodyEn.nextUrlLocale).toBe('en')
 
-          const responseFr = await invokeEdgeFunction(ctx, {
-            functions: [EDGE_MIDDLEWARE_FUNCTION_NAME],
-            origin,
-            url: `/fr/json`,
-          })
-          expect(responseFr.status).toBe(200)
-          const bodyFr = await responseFr.json()
+        const responseFr = await invokeEdgeFunction(ctx, {
+          functions: [edgeFunctionNameRoot],
+          origin,
+          url: `/fr/json`,
+        })
+        expect(responseFr.status).toBe(200)
+        expect(responseFr.headers.get('x-runtime')).toEqual(expectedRuntime)
+        const bodyFr = await responseFr.json()
 
-          expect(bodyFr.requestUrlPathname).toBe('/fr/json')
-          expect(bodyFr.nextUrlPathname).toBe('/json')
-          expect(bodyFr.nextUrlLocale).toBe('fr')
-        },
-      )
+        expect(bodyFr.requestUrlPathname).toBe('/fr/json')
+        expect(bodyFr.nextUrlPathname).toBe('/json')
+        expect(bodyFr.nextUrlLocale).toBe('fr')
+      })
 
       test.skipIf(expectedRuntime === 'node')(
         'should preserve locale in request.nextUrl with skipMiddlewareUrlNormalize',
diff --git a/tests/utils/create-e2e-fixture.ts b/tests/utils/create-e2e-fixture.ts
index aaf8b24376..ebb17a9b24 100644
--- a/tests/utils/create-e2e-fixture.ts
+++ b/tests/utils/create-e2e-fixture.ts
@@ -347,6 +347,11 @@ export const fixtureFactories = {
       publishDirectory: '.next-node-middleware',
     }),
   middlewareI18n: () => createE2EFixture('middleware-i18n'),
+  middlewareI18nNode: () =>
+    createE2EFixture('middleware-i18n', {
+      buildCommand: getBuildFixtureVariantCommand('node-middleware'),
+      publishDirectory: '.next-node-middleware',
+    }),
   middlewareI18nExcludedPaths: () => createE2EFixture('middleware-i18n-excluded-paths'),
   middlewareOg: () => createE2EFixture('middleware-og'),
   middlewarePages: () => createE2EFixture('middleware-pages'),

From 55d1722bb52582b22d9f2486f4783cf15d26eada Mon Sep 17 00:00:00 2001
From: Michal Piechowiak 
Date: Thu, 21 Aug 2025 10:40:30 +0200
Subject: [PATCH 28/47] chore: remove debug log

---
 tests/integration/edge-handler.test.ts | 2 --
 1 file changed, 2 deletions(-)

diff --git a/tests/integration/edge-handler.test.ts b/tests/integration/edge-handler.test.ts
index f2e9401a52..4c18424788 100644
--- a/tests/integration/edge-handler.test.ts
+++ b/tests/integration/edge-handler.test.ts
@@ -99,8 +99,6 @@ for (const {
         url: '/test/next',
       })
 
-      console.log(response)
-
       expect(await response.text()).toBe('Hello from origin!')
       expect(response.status).toBe(200)
       expect(

From 38a2f3b1a284cde31a091f71f864e0f1ec5d2c3f Mon Sep 17 00:00:00 2001
From: Michal Piechowiak 
Date: Thu, 21 Aug 2025 10:51:09 +0200
Subject: [PATCH 29/47] test: move middleware-i18n-skip-normalize to test
 variants

---
 .../middleware-node.ts                        |  5 +
 .../{middleware.js => middleware-shared.ts}   | 15 ++-
 .../middleware.ts                             |  1 +
 .../next.config.js                            |  2 +
 .../package.json                              |  4 +-
 .../test-variants.json                        | 21 ++++
 tests/integration/edge-handler.test.ts        | 96 +++++++++----------
 7 files changed, 93 insertions(+), 51 deletions(-)
 create mode 100644 tests/fixtures/middleware-i18n-skip-normalize/middleware-node.ts
 rename tests/fixtures/middleware-i18n-skip-normalize/{middleware.js => middleware-shared.ts} (79%)
 create mode 100644 tests/fixtures/middleware-i18n-skip-normalize/middleware.ts
 create mode 100644 tests/fixtures/middleware-i18n-skip-normalize/test-variants.json

diff --git a/tests/fixtures/middleware-i18n-skip-normalize/middleware-node.ts b/tests/fixtures/middleware-i18n-skip-normalize/middleware-node.ts
new file mode 100644
index 0000000000..780faa76fc
--- /dev/null
+++ b/tests/fixtures/middleware-i18n-skip-normalize/middleware-node.ts
@@ -0,0 +1,5 @@
+export { middleware } from './middleware-shared'
+
+export const config = {
+  runtime: 'nodejs',
+}
diff --git a/tests/fixtures/middleware-i18n-skip-normalize/middleware.js b/tests/fixtures/middleware-i18n-skip-normalize/middleware-shared.ts
similarity index 79%
rename from tests/fixtures/middleware-i18n-skip-normalize/middleware.js
rename to tests/fixtures/middleware-i18n-skip-normalize/middleware-shared.ts
index 24517d72de..7e1e1abc3a 100644
--- a/tests/fixtures/middleware-i18n-skip-normalize/middleware.js
+++ b/tests/fixtures/middleware-i18n-skip-normalize/middleware-shared.ts
@@ -1,6 +1,19 @@
+import type { NextRequest } from 'next/server'
 import { NextResponse } from 'next/server'
 
-export async function middleware(request) {
+export async function middleware(request: NextRequest) {
+  const response = getResponse(request)
+
+  if (response) {
+    response.headers.append('Deno' in globalThis ? 'x-deno' : 'x-node', Date.now().toString())
+    // report Next.js Middleware Runtime (not the execution runtime, but target runtime)
+    // @ts-expect-error EdgeRuntime global not declared
+    response.headers.append('x-runtime', typeof EdgeRuntime !== 'undefined' ? EdgeRuntime : 'node')
+    response.headers.set('x-hello-from-middleware-res', 'hello')
+  }
+}
+
+const getResponse = (request: NextRequest) => {
   const url = request.nextUrl
 
   // this is needed for tests to get the BUILD_ID
diff --git a/tests/fixtures/middleware-i18n-skip-normalize/middleware.ts b/tests/fixtures/middleware-i18n-skip-normalize/middleware.ts
new file mode 100644
index 0000000000..fcc87b30fd
--- /dev/null
+++ b/tests/fixtures/middleware-i18n-skip-normalize/middleware.ts
@@ -0,0 +1 @@
+export { middleware } from './middleware-shared'
diff --git a/tests/fixtures/middleware-i18n-skip-normalize/next.config.js b/tests/fixtures/middleware-i18n-skip-normalize/next.config.js
index 14e6db6f09..ec5c3f0a94 100644
--- a/tests/fixtures/middleware-i18n-skip-normalize/next.config.js
+++ b/tests/fixtures/middleware-i18n-skip-normalize/next.config.js
@@ -1,5 +1,6 @@
 module.exports = {
   output: 'standalone',
+  distDir: process.env.NEXT_DIST_DIR ?? '.next',
   eslint: {
     ignoreDuringBuilds: true,
   },
@@ -11,6 +12,7 @@ module.exports = {
   experimental: {
     clientRouterFilter: true,
     clientRouterFilterRedirects: true,
+    nodeMiddleware: true,
   },
   redirects() {
     return [
diff --git a/tests/fixtures/middleware-i18n-skip-normalize/package.json b/tests/fixtures/middleware-i18n-skip-normalize/package.json
index 5708c88b50..b336803e43 100644
--- a/tests/fixtures/middleware-i18n-skip-normalize/package.json
+++ b/tests/fixtures/middleware-i18n-skip-normalize/package.json
@@ -3,9 +3,9 @@
   "version": "0.1.0",
   "private": true,
   "scripts": {
-    "postinstall": "next build",
+    "postinstall": "npm run build",
     "dev": "next dev",
-    "build": "next build"
+    "build": "node ../../utils/build-variants.mjs"
   },
   "dependencies": {
     "next": "latest",
diff --git a/tests/fixtures/middleware-i18n-skip-normalize/test-variants.json b/tests/fixtures/middleware-i18n-skip-normalize/test-variants.json
new file mode 100644
index 0000000000..f31d535c28
--- /dev/null
+++ b/tests/fixtures/middleware-i18n-skip-normalize/test-variants.json
@@ -0,0 +1,21 @@
+{
+  "node-middleware": {
+    "distDir": ".next-node-middleware",
+    "files": {
+      "middleware.ts": "middleware-node.ts"
+    },
+    "test": {
+      "dependencies": {
+        "next": [
+          {
+            "versionConstraint": ">=15.2.0",
+            "canaryOnly": true
+          },
+          {
+            "versionConstraint": ">=15.5.0"
+          }
+        ]
+      }
+    }
+  }
+}
diff --git a/tests/integration/edge-handler.test.ts b/tests/integration/edge-handler.test.ts
index 4c18424788..09fad87596 100644
--- a/tests/integration/edge-handler.test.ts
+++ b/tests/integration/edge-handler.test.ts
@@ -632,59 +632,59 @@ for (const {
         expect(bodyFr.nextUrlLocale).toBe('fr')
       })
 
-      test.skipIf(expectedRuntime === 'node')(
-        'should preserve locale in request.nextUrl with skipMiddlewareUrlNormalize',
-        async (ctx) => {
-          await createFixture('middleware-i18n-skip-normalize', ctx)
-          await runPlugin(ctx)
-          const origin = await LocalServer.run(async (req, res) => {
-            res.write(
-              JSON.stringify({
-                url: req.url,
-                headers: req.headers,
-              }),
-            )
-            res.end()
-          })
-          ctx.cleanup?.push(() => origin.stop())
+      test('should preserve locale in request.nextUrl with skipMiddlewareUrlNormalize', async (ctx) => {
+        await createFixture('middleware-i18n-skip-normalize', ctx)
+        await runPlugin(ctx, runPluginConstants)
+        const origin = await LocalServer.run(async (req, res) => {
+          res.write(
+            JSON.stringify({
+              url: req.url,
+              headers: req.headers,
+            }),
+          )
+          res.end()
+        })
+        ctx.cleanup?.push(() => origin.stop())
 
-          const response = await invokeEdgeFunction(ctx, {
-            functions: [EDGE_MIDDLEWARE_FUNCTION_NAME],
-            origin,
-            url: `/json`,
-          })
-          expect(response.status).toBe(200)
-          const body = await response.json()
+        const response = await invokeEdgeFunction(ctx, {
+          functions: [edgeFunctionNameRoot],
+          origin,
+          url: `/json`,
+        })
+        expect(response.status).toBe(200)
+        expect(response.headers.get('x-runtime')).toEqual(expectedRuntime)
+        const body = await response.json()
+
+        expect(body.requestUrlPathname).toBe('/json')
+        expect(body.nextUrlPathname).toBe('/json')
+        expect(body.nextUrlLocale).toBe('en')
 
-          expect(body.requestUrlPathname).toBe('/json')
-          expect(body.nextUrlPathname).toBe('/json')
-          expect(body.nextUrlLocale).toBe('en')
+        const responseEn = await invokeEdgeFunction(ctx, {
+          functions: [edgeFunctionNameRoot],
+          origin,
+          url: `/en/json`,
+        })
+        expect(responseEn.status).toBe(200)
+        expect(responseEn.headers.get('x-runtime')).toEqual(expectedRuntime)
+        const bodyEn = await responseEn.json()
 
-          const responseEn = await invokeEdgeFunction(ctx, {
-            functions: [EDGE_MIDDLEWARE_FUNCTION_NAME],
-            origin,
-            url: `/en/json`,
-          })
-          expect(responseEn.status).toBe(200)
-          const bodyEn = await responseEn.json()
+        expect(bodyEn.requestUrlPathname).toBe('/en/json')
+        expect(bodyEn.nextUrlPathname).toBe('/json')
+        expect(bodyEn.nextUrlLocale).toBe('en')
 
-          expect(bodyEn.requestUrlPathname).toBe('/en/json')
-          expect(bodyEn.nextUrlPathname).toBe('/json')
-          expect(bodyEn.nextUrlLocale).toBe('en')
+        const responseFr = await invokeEdgeFunction(ctx, {
+          functions: [edgeFunctionNameRoot],
+          origin,
+          url: `/fr/json`,
+        })
+        expect(responseFr.status).toBe(200)
+        expect(responseFr.headers.get('x-runtime')).toEqual(expectedRuntime)
+        const bodyFr = await responseFr.json()
 
-          const responseFr = await invokeEdgeFunction(ctx, {
-            functions: [EDGE_MIDDLEWARE_FUNCTION_NAME],
-            origin,
-            url: `/fr/json`,
-          })
-          expect(responseFr.status).toBe(200)
-          const bodyFr = await responseFr.json()
-
-          expect(bodyFr.requestUrlPathname).toBe('/fr/json')
-          expect(bodyFr.nextUrlPathname).toBe('/json')
-          expect(bodyFr.nextUrlLocale).toBe('fr')
-        },
-      )
+        expect(bodyFr.requestUrlPathname).toBe('/fr/json')
+        expect(bodyFr.nextUrlPathname).toBe('/json')
+        expect(bodyFr.nextUrlLocale).toBe('fr')
+      })
     })
   })
 }

From 7492aacf5c94abb76746b1f37f546300892780c5 Mon Sep 17 00:00:00 2001
From: Michal Piechowiak 
Date: Thu, 21 Aug 2025 10:59:02 +0200
Subject: [PATCH 30/47] test: add x-runtime res header to middleware-conditions

---
 tests/fixtures/middleware-conditions/middleware-shared.ts | 2 ++
 1 file changed, 2 insertions(+)

diff --git a/tests/fixtures/middleware-conditions/middleware-shared.ts b/tests/fixtures/middleware-conditions/middleware-shared.ts
index 491e23c5b3..b3ef07ae96 100644
--- a/tests/fixtures/middleware-conditions/middleware-shared.ts
+++ b/tests/fixtures/middleware-conditions/middleware-shared.ts
@@ -4,6 +4,8 @@ import { NextResponse } from 'next/server'
 export function middleware(request: NextRequest) {
   const response: NextResponse = NextResponse.next()
 
+  // report Next.js Middleware Runtime (not the execution runtime, but target runtime)
+  response.headers.append('x-runtime', typeof EdgeRuntime !== 'undefined' ? EdgeRuntime : 'node')
   response.headers.set('x-hello-from-middleware-res', 'hello')
 
   return response

From 6069a744138ca31654e64014ea405626b722ff03 Mon Sep 17 00:00:00 2001
From: Michal Piechowiak 
Date: Thu, 21 Aug 2025 11:53:21 +0200
Subject: [PATCH 31/47] test: skip runtime check on some tests

---
 tests/integration/edge-handler.test.ts | 10 ++++++++--
 1 file changed, 8 insertions(+), 2 deletions(-)

diff --git a/tests/integration/edge-handler.test.ts b/tests/integration/edge-handler.test.ts
index 09fad87596..2582d68414 100644
--- a/tests/integration/edge-handler.test.ts
+++ b/tests/integration/edge-handler.test.ts
@@ -495,7 +495,10 @@ for (const {
         expect(url.pathname).toBe('/_next/data/build-id/ssg/hello.json')
         expect(res.headers['x-nextjs-data']).toBe('1')
         expect(response.status).toBe(200)
-        expect(response.headers.get('x-runtime')).toEqual(expectedRuntime)
+
+        // there is some middleware handling problem where we are not applying additional response headers
+        // set in middleware, so skipping assertion for now
+        // expect(response.headers.get('x-runtime')).toEqual(expectedRuntime)
       })
 
       test('should preserve query params in rewritten data requests', async (ctx) => {
@@ -525,7 +528,10 @@ for (const {
         expect(url.searchParams.get('slug')).toBe('first')
         expect(res.headers['x-nextjs-data']).toBe('1')
         expect(response.status).toBe(200)
-        expect(response.headers.get('x-runtime')).toEqual(expectedRuntime)
+
+        // there is some middleware handling problem where we are not applying additional response headers
+        // set in middleware, so skipping assertion for now
+        // expect(response.headers.get('x-runtime')).toEqual(expectedRuntime)
       })
 
       test('should preserve locale in redirects', async (ctx) => {

From 3d996dd4c36bddb9410c027ff885e2a09e3db6a4 Mon Sep 17 00:00:00 2001
From: Michal Piechowiak 
Date: Thu, 21 Aug 2025 12:13:12 +0200
Subject: [PATCH 32/47] test: move middleware-i18n-exluded-path to test
 variants

---
 tests/e2e/edge-middleware.test.ts             | 250 ++++++++++--------
 .../middleware-node.ts                        |  18 ++
 .../middleware-shared.ts                      |  23 ++
 .../middleware.ts                             |  21 +-
 .../next.config.js                            |   4 +
 .../package.json                              |   4 +-
 .../test-variants.json                        |  21 ++
 tests/utils/create-e2e-fixture.ts             |   5 +
 8 files changed, 211 insertions(+), 135 deletions(-)
 create mode 100644 tests/fixtures/middleware-i18n-excluded-paths/middleware-node.ts
 create mode 100644 tests/fixtures/middleware-i18n-excluded-paths/middleware-shared.ts
 create mode 100644 tests/fixtures/middleware-i18n-excluded-paths/test-variants.json

diff --git a/tests/e2e/edge-middleware.test.ts b/tests/e2e/edge-middleware.test.ts
index 25ac1b8356..a61591f6f2 100644
--- a/tests/e2e/edge-middleware.test.ts
+++ b/tests/e2e/edge-middleware.test.ts
@@ -12,6 +12,7 @@ type ExtendedFixtures = {
   edgeOrNodeMiddleware: Fixture
   edgeOrNodeMiddlewarePages: Fixture
   edgeOrNodeMiddlewareI18n: Fixture
+  edgeOrNodeMiddlewareI18nExcludedPaths: Fixture
 }
 
 for (const { expectedRuntime, label, testWithSwitchableMiddlewareRuntime } of [
@@ -43,6 +44,14 @@ for (const { expectedRuntime, label, testWithSwitchableMiddlewareRuntime } of [
           scope: 'worker',
         },
       ],
+      edgeOrNodeMiddlewareI18nExcludedPaths: [
+        async ({ middlewareI18nExcludedPaths }, use) => {
+          await use(middlewareI18nExcludedPaths)
+        },
+        {
+          scope: 'worker',
+        },
+      ],
     }),
   },
   hasNodeMiddlewareSupport()
@@ -74,6 +83,14 @@ for (const { expectedRuntime, label, testWithSwitchableMiddlewareRuntime } of [
               scope: 'worker',
             },
           ],
+          edgeOrNodeMiddlewareI18nExcludedPaths: [
+            async ({ middlewareI18nExcludedPathsNode }, use) => {
+              await use(middlewareI18nExcludedPathsNode)
+            },
+            {
+              scope: 'worker',
+            },
+          ],
         }),
       }
     : undefined,
@@ -297,154 +314,161 @@ for (const { expectedRuntime, label, testWithSwitchableMiddlewareRuntime } of [
       })
     })
 
-    if (expectedRuntime !== 'node') {
-      // those tests use `fetch` instead of `page.goto` intentionally to avoid potential client rendering
-      // hiding any potential edge/server issues
-      test.describe('Middleware with i18n and excluded paths', () => {
-        const DEFAULT_LOCALE = 'en'
-
-        /** helper function to extract JSON data from page rendering data with `
{JSON.stringify(data)}
` */ - function extractDataFromHtml(html: string): Record { - const match = html.match(/
(?[^<]+)<\/pre>/)
-          if (!match || !match.groups?.rawInput) {
-            console.error('
 not found in html input', {
-              html,
-            })
-            throw new Error('Failed to extract data from HTML')
-          }
-
-          const { rawInput } = match.groups
-          const unescapedInput = rawInput.replaceAll('"', '"')
-          try {
-            return JSON.parse(unescapedInput)
-          } catch (originalError) {
-            console.error('Failed to parse JSON', {
-              originalError,
-              rawInput,
-              unescapedInput,
-            })
-          }
+    // those tests use `fetch` instead of `page.goto` intentionally to avoid potential client rendering
+    // hiding any potential edge/server issues
+    test.describe('Middleware with i18n and excluded paths', () => {
+      const DEFAULT_LOCALE = 'en'
+
+      /** helper function to extract JSON data from page rendering data with `
{JSON.stringify(data)}
` */ + function extractDataFromHtml(html: string): Record { + const match = html.match(/
(?[^<]+)<\/pre>/)
+        if (!match || !match.groups?.rawInput) {
+          console.error('
 not found in html input', {
+            html,
+          })
           throw new Error('Failed to extract data from HTML')
         }
 
-        // those tests hit paths ending with `/json` which has special handling in middleware
-        // to return JSON response from middleware itself
-        test.describe('Middleware response path', () => {
-          test('should match on non-localized not excluded page path', async ({
-            middlewareI18nExcludedPaths,
-          }) => {
-            const response = await fetch(`${middlewareI18nExcludedPaths.url}/json`)
+        const { rawInput } = match.groups
+        const unescapedInput = rawInput.replaceAll('"', '"')
+        try {
+          return JSON.parse(unescapedInput)
+        } catch (originalError) {
+          console.error('Failed to parse JSON', {
+            originalError,
+            rawInput,
+            unescapedInput,
+          })
+        }
+        throw new Error('Failed to extract data from HTML')
+      }
 
-            expect(response.headers.get('x-test-used-middleware')).toBe('true')
-            expect(response.status).toBe(200)
+      // those tests hit paths ending with `/json` which has special handling in middleware
+      // to return JSON response from middleware itself
+      test.describe('Middleware response path', () => {
+        test('should match on non-localized not excluded page path', async ({
+          edgeOrNodeMiddlewareI18nExcludedPaths,
+        }) => {
+          const response = await fetch(`${edgeOrNodeMiddlewareI18nExcludedPaths.url}/json`)
 
-            const { nextUrlPathname, nextUrlLocale } = await response.json()
+          expect(response.headers.get('x-test-used-middleware')).toBe('true')
+          expect(response.headers.get('x-runtime')).toEqual(expectedRuntime)
+          expect(response.status).toBe(200)
 
-            expect(nextUrlPathname).toBe('/json')
-            expect(nextUrlLocale).toBe(DEFAULT_LOCALE)
-          })
+          const { nextUrlPathname, nextUrlLocale } = await response.json()
+
+          expect(nextUrlPathname).toBe('/json')
+          expect(nextUrlLocale).toBe(DEFAULT_LOCALE)
+        })
 
-          test('should match on localized not excluded page path', async ({
-            middlewareI18nExcludedPaths,
-          }) => {
-            const response = await fetch(`${middlewareI18nExcludedPaths.url}/fr/json`)
+        test('should match on localized not excluded page path', async ({
+          edgeOrNodeMiddlewareI18nExcludedPaths,
+        }) => {
+          const response = await fetch(`${edgeOrNodeMiddlewareI18nExcludedPaths.url}/fr/json`)
 
-            expect(response.headers.get('x-test-used-middleware')).toBe('true')
-            expect(response.status).toBe(200)
+          expect(response.headers.get('x-test-used-middleware')).toBe('true')
+          expect(response.headers.get('x-runtime')).toEqual(expectedRuntime)
+          expect(response.status).toBe(200)
 
-            const { nextUrlPathname, nextUrlLocale } = await response.json()
+          const { nextUrlPathname, nextUrlLocale } = await response.json()
 
-            expect(nextUrlPathname).toBe('/json')
-            expect(nextUrlLocale).toBe('fr')
-          })
+          expect(nextUrlPathname).toBe('/json')
+          expect(nextUrlLocale).toBe('fr')
         })
+      })
 
-        // those tests hit paths that don't end with `/json` while still satisfying middleware matcher
-        // so middleware should pass them through to origin
-        test.describe('Middleware passthrough', () => {
-          test('should match on non-localized not excluded page path', async ({
-            middlewareI18nExcludedPaths,
-          }) => {
-            const response = await fetch(`${middlewareI18nExcludedPaths.url}/html`)
+      // those tests hit paths that don't end with `/json` while still satisfying middleware matcher
+      // so middleware should pass them through to origin
+      test.describe('Middleware passthrough', () => {
+        test('should match on non-localized not excluded page path', async ({
+          edgeOrNodeMiddlewareI18nExcludedPaths,
+        }) => {
+          const response = await fetch(`${edgeOrNodeMiddlewareI18nExcludedPaths.url}/html`)
 
-            expect(response.headers.get('x-test-used-middleware')).toBe('true')
-            expect(response.status).toBe(200)
-            expect(response.headers.get('content-type')).toMatch(/text\/html/)
+          expect(response.headers.get('x-test-used-middleware')).toBe('true')
+          expect(response.headers.get('x-runtime')).toEqual(expectedRuntime)
+          expect(response.status).toBe(200)
+          expect(response.headers.get('content-type')).toMatch(/text\/html/)
 
-            const html = await response.text()
-            const { locale, params } = extractDataFromHtml(html)
+          const html = await response.text()
+          const { locale, params } = extractDataFromHtml(html)
 
-            expect(params).toMatchObject({ catchall: ['html'] })
-            expect(locale).toBe(DEFAULT_LOCALE)
-          })
+          expect(params).toMatchObject({ catchall: ['html'] })
+          expect(locale).toBe(DEFAULT_LOCALE)
+        })
 
-          test('should match on localized not excluded page path', async ({
-            middlewareI18nExcludedPaths,
-          }) => {
-            const response = await fetch(`${middlewareI18nExcludedPaths.url}/fr/html`)
+        test('should match on localized not excluded page path', async ({
+          edgeOrNodeMiddlewareI18nExcludedPaths,
+        }) => {
+          const response = await fetch(`${edgeOrNodeMiddlewareI18nExcludedPaths.url}/fr/html`)
 
-            expect(response.headers.get('x-test-used-middleware')).toBe('true')
-            expect(response.status).toBe(200)
-            expect(response.headers.get('content-type')).toMatch(/text\/html/)
+          expect(response.headers.get('x-test-used-middleware')).toBe('true')
+          expect(response.headers.get('x-runtime')).toEqual(expectedRuntime)
+          expect(response.status).toBe(200)
+          expect(response.headers.get('content-type')).toMatch(/text\/html/)
 
-            const html = await response.text()
-            const { locale, params } = extractDataFromHtml(html)
+          const html = await response.text()
+          const { locale, params } = extractDataFromHtml(html)
 
-            expect(params).toMatchObject({ catchall: ['html'] })
-            expect(locale).toBe('fr')
-          })
+          expect(params).toMatchObject({ catchall: ['html'] })
+          expect(locale).toBe('fr')
         })
+      })
 
-        // those tests hit paths that don't satisfy middleware matcher, so should go directly to origin
-        // without going through middleware
-        test.describe('Middleware skipping (paths not satisfying middleware matcher)', () => {
-          test('should NOT match on non-localized excluded API path', async ({
-            middlewareI18nExcludedPaths,
-          }) => {
-            const response = await fetch(`${middlewareI18nExcludedPaths.url}/api/html`)
+      // those tests hit paths that don't satisfy middleware matcher, so should go directly to origin
+      // without going through middleware
+      test.describe('Middleware skipping (paths not satisfying middleware matcher)', () => {
+        test('should NOT match on non-localized excluded API path', async ({
+          edgeOrNodeMiddlewareI18nExcludedPaths,
+        }) => {
+          const response = await fetch(`${edgeOrNodeMiddlewareI18nExcludedPaths.url}/api/html`)
 
-            expect(response.headers.get('x-test-used-middleware')).not.toBe('true')
-            expect(response.status).toBe(200)
+          expect(response.headers.get('x-test-used-middleware')).not.toBe('true')
+          expect(response.headers.get('x-runtime')).toEqual(expectedRuntime)
+          expect(response.status).toBe(200)
 
-            const { params } = await response.json()
+          const { params } = await response.json()
 
-            expect(params).toMatchObject({ catchall: ['html'] })
-          })
+          expect(params).toMatchObject({ catchall: ['html'] })
+        })
 
-          test('should NOT match on non-localized excluded page path', async ({
-            middlewareI18nExcludedPaths,
-          }) => {
-            const response = await fetch(`${middlewareI18nExcludedPaths.url}/excluded`)
+        test('should NOT match on non-localized excluded page path', async ({
+          edgeOrNodeMiddlewareI18nExcludedPaths,
+        }) => {
+          const response = await fetch(`${edgeOrNodeMiddlewareI18nExcludedPaths.url}/excluded`)
 
-            expect(response.headers.get('x-test-used-middleware')).not.toBe('true')
-            expect(response.status).toBe(200)
-            expect(response.headers.get('content-type')).toMatch(/text\/html/)
+          expect(response.headers.get('x-test-used-middleware')).not.toBe('true')
+          expect(response.headers.get('x-runtime')).toEqual(expectedRuntime)
+          expect(response.status).toBe(200)
+          expect(response.headers.get('content-type')).toMatch(/text\/html/)
 
-            const html = await response.text()
-            const { locale, params } = extractDataFromHtml(html)
+          const html = await response.text()
+          const { locale, params } = extractDataFromHtml(html)
 
-            expect(params).toMatchObject({ catchall: ['excluded'] })
-            expect(locale).toBe(DEFAULT_LOCALE)
-          })
+          expect(params).toMatchObject({ catchall: ['excluded'] })
+          expect(locale).toBe(DEFAULT_LOCALE)
+        })
 
-          test('should NOT match on localized excluded page path', async ({
-            middlewareI18nExcludedPaths,
-          }) => {
-            const response = await fetch(`${middlewareI18nExcludedPaths.url}/fr/excluded`)
+        test('should NOT match on localized excluded page path', async ({
+          edgeOrNodeMiddlewareI18nExcludedPaths,
+        }) => {
+          const response = await fetch(`${edgeOrNodeMiddlewareI18nExcludedPaths.url}/fr/excluded`)
 
-            expect(response.headers.get('x-test-used-middleware')).not.toBe('true')
-            expect(response.status).toBe(200)
-            expect(response.headers.get('content-type')).toMatch(/text\/html/)
+          expect(response.headers.get('x-test-used-middleware')).not.toBe('true')
+          expect(response.headers.get('x-runtime')).toEqual(expectedRuntime)
+          expect(response.status).toBe(200)
+          expect(response.headers.get('content-type')).toMatch(/text\/html/)
 
-            const html = await response.text()
-            const { locale, params } = extractDataFromHtml(html)
+          const html = await response.text()
+          const { locale, params } = extractDataFromHtml(html)
 
-            expect(params).toMatchObject({ catchall: ['excluded'] })
-            expect(locale).toBe('fr')
-          })
+          expect(params).toMatchObject({ catchall: ['excluded'] })
+          expect(locale).toBe('fr')
         })
       })
+    })
 
+    if (expectedRuntime !== 'node') {
       test("requests with x-middleware-subrequest don't skip middleware (GHSA-f82v-jwr5-mffw)", async ({
         middlewareSubrequestVuln,
       }) => {
diff --git a/tests/fixtures/middleware-i18n-excluded-paths/middleware-node.ts b/tests/fixtures/middleware-i18n-excluded-paths/middleware-node.ts
new file mode 100644
index 0000000000..7a3a078fb8
--- /dev/null
+++ b/tests/fixtures/middleware-i18n-excluded-paths/middleware-node.ts
@@ -0,0 +1,18 @@
+export { middleware } from './middleware-shared'
+
+// matcher copied from example in https://nextjs.org/docs/pages/building-your-application/routing/middleware#matcher
+// with `excluded` segment added to exclusion
+export const config = {
+  matcher: [
+    /*
+     * Match all request paths except for the ones starting with:
+     * - api (API routes)
+     * - excluded (for testing localized routes and not just API routes)
+     * - _next/static (static files)
+     * - _next/image (image optimization files)
+     * - favicon.ico, sitemap.xml, robots.txt (metadata files)
+     */
+    '/((?!api|excluded|_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)',
+  ],
+  runtime: 'nodejs',
+}
diff --git a/tests/fixtures/middleware-i18n-excluded-paths/middleware-shared.ts b/tests/fixtures/middleware-i18n-excluded-paths/middleware-shared.ts
new file mode 100644
index 0000000000..dea6509aaa
--- /dev/null
+++ b/tests/fixtures/middleware-i18n-excluded-paths/middleware-shared.ts
@@ -0,0 +1,23 @@
+import { NextResponse } from 'next/server'
+import type { NextRequest } from 'next/server'
+
+export async function middleware(request: NextRequest) {
+  const url = request.nextUrl
+
+  // if path ends with /json we create response in middleware, otherwise we pass it through
+  // to next server to get page or api response from it
+  const response = url.pathname.includes('/json')
+    ? NextResponse.json({
+        requestUrlPathname: new URL(request.url).pathname,
+        nextUrlPathname: request.nextUrl.pathname,
+        nextUrlLocale: request.nextUrl.locale,
+      })
+    : NextResponse.next()
+
+  response.headers.set('x-test-used-middleware', 'true')
+  // report Next.js Middleware Runtime (not the execution runtime, but target runtime)
+  // @ts-expect-error EdgeRuntime global not declared
+  response.headers.append('x-runtime', typeof EdgeRuntime !== 'undefined' ? EdgeRuntime : 'node')
+
+  return response
+}
diff --git a/tests/fixtures/middleware-i18n-excluded-paths/middleware.ts b/tests/fixtures/middleware-i18n-excluded-paths/middleware.ts
index 712f3648b7..6a12920470 100644
--- a/tests/fixtures/middleware-i18n-excluded-paths/middleware.ts
+++ b/tests/fixtures/middleware-i18n-excluded-paths/middleware.ts
@@ -1,23 +1,4 @@
-import { NextResponse } from 'next/server'
-import type { NextRequest } from 'next/server'
-
-export async function middleware(request: NextRequest) {
-  const url = request.nextUrl
-
-  // if path ends with /json we create response in middleware, otherwise we pass it through
-  // to next server to get page or api response from it
-  const response = url.pathname.includes('/json')
-    ? NextResponse.json({
-        requestUrlPathname: new URL(request.url).pathname,
-        nextUrlPathname: request.nextUrl.pathname,
-        nextUrlLocale: request.nextUrl.locale,
-      })
-    : NextResponse.next()
-
-  response.headers.set('x-test-used-middleware', 'true')
-
-  return response
-}
+export { middleware } from './middleware-shared'
 
 // matcher copied from example in https://nextjs.org/docs/pages/building-your-application/routing/middleware#matcher
 // with `excluded` segment added to exclusion
diff --git a/tests/fixtures/middleware-i18n-excluded-paths/next.config.js b/tests/fixtures/middleware-i18n-excluded-paths/next.config.js
index 6fe5dbe464..bc74300f84 100644
--- a/tests/fixtures/middleware-i18n-excluded-paths/next.config.js
+++ b/tests/fixtures/middleware-i18n-excluded-paths/next.config.js
@@ -1,5 +1,6 @@
 module.exports = {
   output: 'standalone',
+  distDir: process.env.NEXT_DIST_DIR ?? '.next',
   eslint: {
     ignoreDuringBuilds: true,
   },
@@ -7,5 +8,8 @@ module.exports = {
     locales: ['en', 'fr'],
     defaultLocale: 'en',
   },
+  experimental: {
+    nodeMiddleware: true,
+  },
   outputFileTracingRoot: __dirname,
 }
diff --git a/tests/fixtures/middleware-i18n-excluded-paths/package.json b/tests/fixtures/middleware-i18n-excluded-paths/package.json
index 3246e924fe..114e8d6e3b 100644
--- a/tests/fixtures/middleware-i18n-excluded-paths/package.json
+++ b/tests/fixtures/middleware-i18n-excluded-paths/package.json
@@ -3,9 +3,9 @@
   "version": "0.1.0",
   "private": true,
   "scripts": {
-    "postinstall": "next build",
+    "postinstall": "npm run build",
     "dev": "next dev",
-    "build": "next build"
+    "build": "node ../../utils/build-variants.mjs"
   },
   "dependencies": {
     "next": "latest",
diff --git a/tests/fixtures/middleware-i18n-excluded-paths/test-variants.json b/tests/fixtures/middleware-i18n-excluded-paths/test-variants.json
new file mode 100644
index 0000000000..f31d535c28
--- /dev/null
+++ b/tests/fixtures/middleware-i18n-excluded-paths/test-variants.json
@@ -0,0 +1,21 @@
+{
+  "node-middleware": {
+    "distDir": ".next-node-middleware",
+    "files": {
+      "middleware.ts": "middleware-node.ts"
+    },
+    "test": {
+      "dependencies": {
+        "next": [
+          {
+            "versionConstraint": ">=15.2.0",
+            "canaryOnly": true
+          },
+          {
+            "versionConstraint": ">=15.5.0"
+          }
+        ]
+      }
+    }
+  }
+}
diff --git a/tests/utils/create-e2e-fixture.ts b/tests/utils/create-e2e-fixture.ts
index ebb17a9b24..17400d9779 100644
--- a/tests/utils/create-e2e-fixture.ts
+++ b/tests/utils/create-e2e-fixture.ts
@@ -353,6 +353,11 @@ export const fixtureFactories = {
       publishDirectory: '.next-node-middleware',
     }),
   middlewareI18nExcludedPaths: () => createE2EFixture('middleware-i18n-excluded-paths'),
+  middlewareI18nExcludedPathsNode: () =>
+    createE2EFixture('middleware-i18n-excluded-paths', {
+      buildCommand: getBuildFixtureVariantCommand('node-middleware'),
+      publishDirectory: '.next-node-middleware',
+    }),
   middlewareOg: () => createE2EFixture('middleware-og'),
   middlewarePages: () => createE2EFixture('middleware-pages'),
   middlewarePagesNode: () =>

From e974d7f0797137693498fd319752d0ef78f6fa3f Mon Sep 17 00:00:00 2001
From: Michal Piechowiak 
Date: Thu, 21 Aug 2025 12:22:17 +0200
Subject: [PATCH 33/47] test: skip prebuilding some fixtures that are not used
 in integration tests

---
 tests/prepare.mjs | 2 ++
 1 file changed, 2 insertions(+)

diff --git a/tests/prepare.mjs b/tests/prepare.mjs
index 072d08e6b5..50b78d8a9e 100644
--- a/tests/prepare.mjs
+++ b/tests/prepare.mjs
@@ -24,6 +24,8 @@ const e2eOnlyFixtures = new Set([
   'cli-before-regional-blobs-support',
   'dist-dir',
   'middleware-i18n-excluded-paths',
+  'middleware-static-asset-matcher',
+  'middleware-subrequest-vuln',
   // There is also a bug on Windows on Node.js 18.20.6, that cause build failures on this fixture
   // see https://github.com/opennextjs/opennextjs-netlify/actions/runs/13268839161/job/37043172448?pr=2749#step:12:78
   'middleware-og',

From 61d261e85ef9b32ca1f6db59f3349e2df1e75746 Mon Sep 17 00:00:00 2001
From: Michal Piechowiak 
Date: Thu, 21 Aug 2025 12:25:52 +0200
Subject: [PATCH 34/47] test: subrequest vuln test is not applicable to node
 middleware

---
 tests/e2e/edge-middleware.test.ts | 33 ++++++++++++++++---------------
 1 file changed, 17 insertions(+), 16 deletions(-)

diff --git a/tests/e2e/edge-middleware.test.ts b/tests/e2e/edge-middleware.test.ts
index a61591f6f2..c369a309cc 100644
--- a/tests/e2e/edge-middleware.test.ts
+++ b/tests/e2e/edge-middleware.test.ts
@@ -469,22 +469,6 @@ for (const { expectedRuntime, label, testWithSwitchableMiddlewareRuntime } of [
     })
 
     if (expectedRuntime !== 'node') {
-      test("requests with x-middleware-subrequest don't skip middleware (GHSA-f82v-jwr5-mffw)", async ({
-        middlewareSubrequestVuln,
-      }) => {
-        const response = await fetch(`${middlewareSubrequestVuln.url}`, {
-          headers: {
-            'x-middleware-subrequest': 'middleware:middleware:middleware:middleware:middleware',
-          },
-        })
-
-        // middleware was not skipped
-        expect(response.headers.get('x-test-used-middleware')).toBe('true')
-
-        // ensure we are testing version before the fix for self hosted
-        expect(response.headers.get('x-test-used-next-version')).toBe('15.2.2')
-      })
-
       test('requests with different encoding than matcher match anyway', async ({
         middlewareStaticAssetMatcher,
       }) => {
@@ -568,3 +552,20 @@ for (const { expectedRuntime, label, testWithSwitchableMiddlewareRuntime } of [
     })
   })
 }
+
+// this test is using pinned next version that doesn't support node middleware
+test("requests with x-middleware-subrequest don't skip middleware (GHSA-f82v-jwr5-mffw)", async ({
+  middlewareSubrequestVuln,
+}) => {
+  const response = await fetch(`${middlewareSubrequestVuln.url}`, {
+    headers: {
+      'x-middleware-subrequest': 'middleware:middleware:middleware:middleware:middleware',
+    },
+  })
+
+  // middleware was not skipped
+  expect(response.headers.get('x-test-used-middleware')).toBe('true')
+
+  // ensure we are testing version before the fix for self hosted
+  expect(response.headers.get('x-test-used-next-version')).toBe('15.2.2')
+})

From 8669b58a6db86f383d1d35bd741e22f682f71e6f Mon Sep 17 00:00:00 2001
From: Michal Piechowiak 
Date: Thu, 21 Aug 2025 12:33:18 +0200
Subject: [PATCH 35/47] test: move middleware-i18n-static-asset-matcher to test
 variants

---
 tests/e2e/edge-middleware.test.ts             | 36 ++++++++++++++-----
 .../middleware-node.ts                        |  6 ++++
 .../middleware-shared.ts                      |  9 +++++
 .../middleware.ts                             |  4 +--
 .../next.config.js                            |  4 +++
 .../package.json                              |  4 +--
 .../test-variants.json                        | 21 +++++++++++
 tests/utils/create-e2e-fixture.ts             |  5 +++
 8 files changed, 75 insertions(+), 14 deletions(-)
 create mode 100644 tests/fixtures/middleware-static-asset-matcher/middleware-node.ts
 create mode 100644 tests/fixtures/middleware-static-asset-matcher/middleware-shared.ts
 create mode 100644 tests/fixtures/middleware-static-asset-matcher/test-variants.json

diff --git a/tests/e2e/edge-middleware.test.ts b/tests/e2e/edge-middleware.test.ts
index c369a309cc..8f31a5884e 100644
--- a/tests/e2e/edge-middleware.test.ts
+++ b/tests/e2e/edge-middleware.test.ts
@@ -13,6 +13,7 @@ type ExtendedFixtures = {
   edgeOrNodeMiddlewarePages: Fixture
   edgeOrNodeMiddlewareI18n: Fixture
   edgeOrNodeMiddlewareI18nExcludedPaths: Fixture
+  edgeOrNodeMiddlewareStaticAssetMatcher: Fixture
 }
 
 for (const { expectedRuntime, label, testWithSwitchableMiddlewareRuntime } of [
@@ -52,6 +53,14 @@ for (const { expectedRuntime, label, testWithSwitchableMiddlewareRuntime } of [
           scope: 'worker',
         },
       ],
+      edgeOrNodeMiddlewareStaticAssetMatcher: [
+        async ({ middlewareStaticAssetMatcher }, use) => {
+          await use(middlewareStaticAssetMatcher)
+        },
+        {
+          scope: 'worker',
+        },
+      ],
     }),
   },
   hasNodeMiddlewareSupport()
@@ -91,6 +100,14 @@ for (const { expectedRuntime, label, testWithSwitchableMiddlewareRuntime } of [
               scope: 'worker',
             },
           ],
+          edgeOrNodeMiddlewareStaticAssetMatcher: [
+            async ({ middlewareStaticAssetMatcherNode }, use) => {
+              await use(middlewareStaticAssetMatcherNode)
+            },
+            {
+              scope: 'worker',
+            },
+          ],
         }),
       }
     : undefined,
@@ -468,16 +485,17 @@ for (const { expectedRuntime, label, testWithSwitchableMiddlewareRuntime } of [
       })
     })
 
-    if (expectedRuntime !== 'node') {
-      test('requests with different encoding than matcher match anyway', async ({
-        middlewareStaticAssetMatcher,
-      }) => {
-        const response = await fetch(`${middlewareStaticAssetMatcher.url}/hello%2Fworld.txt`)
+    test('requests with different encoding than matcher match anyway', async ({
+      edgeOrNodeMiddlewareStaticAssetMatcher,
+    }) => {
+      const response = await fetch(
+        `${edgeOrNodeMiddlewareStaticAssetMatcher.url}/hello%2Fworld.txt`,
+      )
 
-        // middleware was not skipped
-        expect(await response.text()).toBe('hello from middleware')
-      })
-    }
+      // middleware was not skipped
+      expect(await response.text()).toBe('hello from middleware')
+      expect(response.headers.get('x-runtime')).toEqual(expectedRuntime)
+    })
 
     test.describe('RSC cache poisoning', () => {
       test('Middleware rewrite', async ({ page, edgeOrNodeMiddleware }) => {
diff --git a/tests/fixtures/middleware-static-asset-matcher/middleware-node.ts b/tests/fixtures/middleware-static-asset-matcher/middleware-node.ts
new file mode 100644
index 0000000000..139bfc5e71
--- /dev/null
+++ b/tests/fixtures/middleware-static-asset-matcher/middleware-node.ts
@@ -0,0 +1,6 @@
+export { middleware } from './middleware-shared'
+
+export const config = {
+  matcher: '/hello/world.txt',
+  runtime: 'nodejs',
+}
diff --git a/tests/fixtures/middleware-static-asset-matcher/middleware-shared.ts b/tests/fixtures/middleware-static-asset-matcher/middleware-shared.ts
new file mode 100644
index 0000000000..b6de8de284
--- /dev/null
+++ b/tests/fixtures/middleware-static-asset-matcher/middleware-shared.ts
@@ -0,0 +1,9 @@
+export function middleware() {
+  return new Response('hello from middleware', {
+    headers: {
+      // report Next.js Middleware Runtime (not the execution runtime, but target runtime)
+      // @ts-expect-error EdgeRuntime global not declared
+      'x-runtime': typeof EdgeRuntime !== 'undefined' ? EdgeRuntime : 'node',
+    },
+  })
+}
diff --git a/tests/fixtures/middleware-static-asset-matcher/middleware.ts b/tests/fixtures/middleware-static-asset-matcher/middleware.ts
index 26924f826d..10b5718e3d 100644
--- a/tests/fixtures/middleware-static-asset-matcher/middleware.ts
+++ b/tests/fixtures/middleware-static-asset-matcher/middleware.ts
@@ -1,6 +1,4 @@
-export default function middleware() {
-  return new Response('hello from middleware')
-}
+export { middleware } from './middleware-shared'
 
 export const config = {
   matcher: '/hello/world.txt',
diff --git a/tests/fixtures/middleware-static-asset-matcher/next.config.js b/tests/fixtures/middleware-static-asset-matcher/next.config.js
index 03919602f2..c8df7fbfa6 100644
--- a/tests/fixtures/middleware-static-asset-matcher/next.config.js
+++ b/tests/fixtures/middleware-static-asset-matcher/next.config.js
@@ -1,9 +1,13 @@
 /** @type {import('next').NextConfig} */
 const nextConfig = {
   output: 'standalone',
+  distDir: process.env.NEXT_DIST_DIR ?? '.next',
   eslint: {
     ignoreDuringBuilds: true,
   },
+  experimental: {
+    nodeMiddleware: true,
+  },
   outputFileTracingRoot: __dirname,
 }
 
diff --git a/tests/fixtures/middleware-static-asset-matcher/package.json b/tests/fixtures/middleware-static-asset-matcher/package.json
index b3f8a8dec2..884a420458 100644
--- a/tests/fixtures/middleware-static-asset-matcher/package.json
+++ b/tests/fixtures/middleware-static-asset-matcher/package.json
@@ -3,9 +3,9 @@
   "version": "0.1.0",
   "private": true,
   "scripts": {
-    "postinstall": "next build",
+    "postinstall": "npm run build",
     "dev": "next dev",
-    "build": "next build"
+    "build": "node ../../utils/build-variants.mjs"
   },
   "dependencies": {
     "next": "latest",
diff --git a/tests/fixtures/middleware-static-asset-matcher/test-variants.json b/tests/fixtures/middleware-static-asset-matcher/test-variants.json
new file mode 100644
index 0000000000..f31d535c28
--- /dev/null
+++ b/tests/fixtures/middleware-static-asset-matcher/test-variants.json
@@ -0,0 +1,21 @@
+{
+  "node-middleware": {
+    "distDir": ".next-node-middleware",
+    "files": {
+      "middleware.ts": "middleware-node.ts"
+    },
+    "test": {
+      "dependencies": {
+        "next": [
+          {
+            "versionConstraint": ">=15.2.0",
+            "canaryOnly": true
+          },
+          {
+            "versionConstraint": ">=15.5.0"
+          }
+        ]
+      }
+    }
+  }
+}
diff --git a/tests/utils/create-e2e-fixture.ts b/tests/utils/create-e2e-fixture.ts
index 17400d9779..63749e7dd7 100644
--- a/tests/utils/create-e2e-fixture.ts
+++ b/tests/utils/create-e2e-fixture.ts
@@ -366,6 +366,11 @@ export const fixtureFactories = {
       publishDirectory: '.next-node-middleware',
     }),
   middlewareStaticAssetMatcher: () => createE2EFixture('middleware-static-asset-matcher'),
+  middlewareStaticAssetMatcherNode: () =>
+    createE2EFixture('middleware-static-asset-matcher', {
+      buildCommand: getBuildFixtureVariantCommand('node-middleware'),
+      publishDirectory: '.next-node-middleware',
+    }),
   middlewareSubrequestVuln: () => createE2EFixture('middleware-subrequest-vuln'),
   pageRouter: () => createE2EFixture('page-router'),
   pageRouterBasePathI18n: () => createE2EFixture('page-router-base-path-i18n'),

From db4f02d03a93e364fbbdc235e7bb62d5027d7fd2 Mon Sep 17 00:00:00 2001
From: Michal Piechowiak 
Date: Thu, 21 Aug 2025 16:03:50 +0200
Subject: [PATCH 36/47] refactor: adjust node middleware handling to share
 common handling with edge middleware

---
 edge-runtime/shim/{index.js => edge.js} |   0
 edge-runtime/shim/node.js               |  16 ++
 src/build/functions/edge.ts             | 355 ++++++++++--------------
 3 files changed, 169 insertions(+), 202 deletions(-)
 rename edge-runtime/shim/{index.js => edge.js} (100%)
 create mode 100644 edge-runtime/shim/node.js

diff --git a/edge-runtime/shim/index.js b/edge-runtime/shim/edge.js
similarity index 100%
rename from edge-runtime/shim/index.js
rename to edge-runtime/shim/edge.js
diff --git a/edge-runtime/shim/node.js b/edge-runtime/shim/node.js
new file mode 100644
index 0000000000..9f3e94fe7e
--- /dev/null
+++ b/edge-runtime/shim/node.js
@@ -0,0 +1,16 @@
+// NOTE: This is a fragment of a JavaScript program that will be inlined with
+// a Webpack bundle. You should not import this file from anywhere in the
+// application.
+import { AsyncLocalStorage } from 'node:async_hooks'
+
+import { createRequire } from 'node:module' // used in dynamically generated part
+import process from 'node:process'
+
+import { registerCJSModules } from '../edge-runtime/lib/cjs.ts' // used in dynamically generated part
+
+globalThis.process = process
+
+globalThis.AsyncLocalStorage = AsyncLocalStorage
+
+// needed for path.relative and path.resolve to work
+Deno.cwd = () => ''
diff --git a/src/build/functions/edge.ts b/src/build/functions/edge.ts
index dee4c68c72..90eaeb6d31 100644
--- a/src/build/functions/edge.ts
+++ b/src/build/functions/edge.ts
@@ -1,13 +1,43 @@
 import { cp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
-import { dirname, join } from 'node:path'
+import { dirname, join, relative } from 'node:path'
 
 import type { Manifest, ManifestFunction } from '@netlify/edge-functions'
 import { glob } from 'fast-glob'
-import type { EdgeFunctionDefinition as NextDefinition } from 'next/dist/build/webpack/plugins/middleware-plugin.js'
+import type { FunctionsConfigManifest } from 'next-with-cache-handler-v2/dist/build/index.js'
+import type { EdgeFunctionDefinition as EdgeMiddlewareDefinition } from 'next-with-cache-handler-v2/dist/build/webpack/plugins/middleware-plugin.js'
 import { pathToRegexp } from 'path-to-regexp'
 
 import { EDGE_HANDLER_NAME, PluginContext } from '../plugin-context.js'
 
+type NodeMiddlewareDefinitionWithOptionalMatchers = FunctionsConfigManifest['functions'][0]
+type WithRequired = T & { [P in K]-?: T[P] }
+type NodeMiddlewareDefinition = WithRequired<
+  NodeMiddlewareDefinitionWithOptionalMatchers,
+  'matchers'
+>
+
+function nodeMiddlewareDefinitionHasMatcher(
+  definition: NodeMiddlewareDefinitionWithOptionalMatchers,
+): definition is NodeMiddlewareDefinition {
+  return Array.isArray(definition.matchers)
+}
+
+type EdgeOrNodeMiddlewareDefinition = {
+  runtime: 'nodejs' | 'edge'
+  // hoisting shared properties from underlying definitions for common handling
+  name: string
+  matchers: EdgeMiddlewareDefinition['matchers']
+} & (
+  | {
+      runtime: 'nodejs'
+      functionDefinition: NodeMiddlewareDefinition
+    }
+  | {
+      runtime: 'edge'
+      functionDefinition: EdgeMiddlewareDefinition
+    }
+)
+
 const writeEdgeManifest = async (ctx: PluginContext, manifest: Manifest) => {
   await mkdir(ctx.edgeFunctionsDir, { recursive: true })
   await writeFile(join(ctx.edgeFunctionsDir, 'manifest.json'), JSON.stringify(manifest, null, 2))
@@ -33,9 +63,9 @@ const copyRuntime = async (ctx: PluginContext, handlerDirectory: string): Promis
  * We don't need to do this for data routes because they always have the locale.
  */
 const augmentMatchers = (
-  matchers: NextDefinition['matchers'],
+  matchers: EdgeMiddlewareDefinition['matchers'],
   ctx: PluginContext,
-): NextDefinition['matchers'] => {
+): EdgeMiddlewareDefinition['matchers'] => {
   const i18NConfig = ctx.buildConfig.i18n
   if (!i18NConfig) {
     return matchers
@@ -63,7 +93,10 @@ const augmentMatchers = (
   })
 }
 
-const writeHandlerFile = async (ctx: PluginContext, { matchers, name }: NextDefinition) => {
+const writeHandlerFile = async (
+  ctx: PluginContext,
+  { matchers, name }: EdgeOrNodeMiddlewareDefinition,
+) => {
   const nextConfig = ctx.buildConfig
   const handlerName = getHandlerName({ name })
   const handlerDirectory = join(ctx.edgeFunctionsDir, handlerName)
@@ -117,15 +150,15 @@ const writeHandlerFile = async (ctx: PluginContext, { matchers, name }: NextDefi
   )
 }
 
-const copyHandlerDependencies = async (
+const copyHandlerDependenciesForEdgeMiddleware = async (
   ctx: PluginContext,
-  { name, env, files, wasm }: NextDefinition,
+  { name, env, files, wasm }: EdgeMiddlewareDefinition,
 ) => {
   const srcDir = join(ctx.standaloneDir, ctx.nextDistDir)
   const destDir = join(ctx.edgeFunctionsDir, getHandlerName({ name }))
 
   const edgeRuntimeDir = join(ctx.pluginDir, 'edge-runtime')
-  const shimPath = join(edgeRuntimeDir, 'shim/index.js')
+  const shimPath = join(edgeRuntimeDir, 'shim/edge.js')
   const shim = await readFile(shimPath, 'utf8')
 
   const parts = [shim]
@@ -161,238 +194,156 @@ const copyHandlerDependencies = async (
   await writeFile(outputFile, parts.join('\n'))
 }
 
-const createEdgeHandler = async (ctx: PluginContext, definition: NextDefinition): Promise => {
-  await copyHandlerDependencies(ctx, definition)
-  await writeHandlerFile(ctx, definition)
-}
-
-const getHandlerName = ({ name }: Pick): string =>
-  `${EDGE_HANDLER_NAME}-${name.replace(/\W/g, '-')}`
-
-const buildHandlerDefinition = (
+const NODE_MIDDLEWARE_NAME = 'node-middleware'
+const copyHandlerDependenciesForNodeMiddleware = async (
   ctx: PluginContext,
-  { name, matchers, page }: NextDefinition,
-): Array => {
-  const functionHandlerName = getHandlerName({ name })
-  const functionName = name.endsWith('middleware')
-    ? 'Next.js Middleware Handler'
-    : `Next.js Edge Handler: ${page}`
-  const cache = name.endsWith('middleware') ? undefined : ('manual' as const)
-  const generator = `${ctx.pluginName}@${ctx.pluginVersion}`
-
-  return augmentMatchers(matchers, ctx).map((matcher) => ({
-    function: functionHandlerName,
-    name: functionName,
-    pattern: matcher.regexp,
-    cache,
-    generator,
-  }))
-}
-
-export const clearStaleEdgeHandlers = async (ctx: PluginContext) => {
-  await rm(ctx.edgeFunctionsDir, { recursive: true, force: true })
-}
-
-export const createEdgeHandlers = async (ctx: PluginContext) => {
-  // Edge middleware
-  const nextManifest = await ctx.getMiddlewareManifest()
-  // Node middleware
-  const functionsConfigManifest = await ctx.getFunctionsConfigManifest()
-
-  const nextDefinitions = [...Object.values(nextManifest.middleware)]
-  await Promise.all(nextDefinitions.map((def) => createEdgeHandler(ctx, def)))
-
-  const netlifyDefinitions = nextDefinitions.flatMap((def) => buildHandlerDefinition(ctx, def))
-
-  if (functionsConfigManifest?.functions?.['/_middleware']) {
-    const middlewareDefinition = functionsConfigManifest?.functions?.['/_middleware']
-    const entry = 'server/middleware.js'
-    const nft = `${entry}.nft.json`
-    const name = 'node-middleware'
+  _arg: NodeMiddlewareDefinition,
+) => {
+  const name = NODE_MIDDLEWARE_NAME
 
-    // await copyHandlerDependencies(ctx, definition)
-    const srcDir = join(ctx.standaloneDir, ctx.nextDistDir)
-    // const destDir = join(ctx.edgeFunctionsDir, getHandlerName({ name }))
+  const srcDir = join(ctx.standaloneDir, ctx.nextDistDir)
+  const destDir = join(ctx.edgeFunctionsDir, getHandlerName({ name }))
 
-    // const fakeNodeModuleName = 'fake-module-with-middleware'
+  const edgeRuntimeDir = join(ctx.pluginDir, 'edge-runtime')
+  const shimPath = join(edgeRuntimeDir, 'shim/node.js')
+  const shim = await readFile(shimPath, 'utf8')
 
-    // const fakeNodeModulePath = ctx.resolveFromPackagePath(join('node_modules', fakeNodeModuleName))
+  const parts = [shim]
 
-    const nftFilesPath = join(process.cwd(), ctx.nextDistDir, nft)
-    const nftManifest = JSON.parse(await readFile(nftFilesPath, 'utf8'))
+  const entry = 'server/middleware.js'
+  const nft = `${entry}.nft.json`
+  const nftFilesPath = join(process.cwd(), ctx.nextDistDir, nft)
+  const nftManifest = JSON.parse(await readFile(nftFilesPath, 'utf8'))
 
-    const files: string[] = nftManifest.files.map((file: string) => join('server', file))
-    files.push(entry)
+  const files: string[] = nftManifest.files.map((file: string) => join('server', file))
+  files.push(entry)
 
-    // files are relative to location of middleware entrypoint
-    // we need to capture all of them
-    // they might be going to parent directories, so first we check how many directories we need to go up
-    const maxDirsUp = files.reduce((max, file) => {
+  // files are relative to location of middleware entrypoint
+  // we need to capture all of them
+  // they might be going to parent directories, so first we check how many directories we need to go up
+  const { maxParentDirectoriesPath } = files.reduce(
+    (acc, file) => {
       let dirsUp = 0
+      let parentDirectoriesPath = ''
       for (const part of file.split('/')) {
         if (part === '..') {
           dirsUp += 1
+          parentDirectoriesPath += '../'
         } else {
           break
         }
       }
-      return Math.max(max, dirsUp)
-    }, 0)
-
-    let prefixPath = ''
-    for (let nestedIndex = 1; nestedIndex <= maxDirsUp; nestedIndex++) {
-      // TODO: ideally we preserve the original directory structure
-      // this is just hack to use arbitrary computed names to speed up hooking things up
-      prefixPath += `nested-${nestedIndex}/`
-    }
 
-    let virtualModules = ''
-    for (const file of files) {
-      const srcPath = join(srcDir, file)
+      if (dirsUp > acc.maxDirsUp) {
+        return {
+          maxDirsUp: dirsUp,
+          maxParentDirectoriesPath: parentDirectoriesPath,
+        }
+      }
 
-      const content = await readFile(srcPath, 'utf8')
+      return acc
+    },
+    { maxDirsUp: 0, maxParentDirectoriesPath: '' },
+  )
 
-      virtualModules += `virtualModules.set(${JSON.stringify(join(prefixPath, file))}, ${JSON.stringify(content)});\n`
+  const commonPrefix = relative(join(srcDir, maxParentDirectoriesPath), srcDir)
 
-      // const destPath = join(fakeNodeModulePath, prefixPath, file)
+  parts.push(`const virtualModules = new Map();`)
 
-      // await mkdir(dirname(destPath), { recursive: true })
+  for (const file of files) {
+    const srcPath = join(srcDir, file)
 
-      // if (file === entry) {
-      //   const content = await readFile(srcPath, 'utf8')
-      //   await writeFile(
-      //     destPath,
-      //     // Next.js needs to be set on global even if it's possible to just require it
-      //     // so somewhat similar to existing shim we have for edge runtime
-      //     `globalThis.AsyncLocalStorage = require('node:async_hooks').AsyncLocalStorage;\n${content}`,
-      //   )
-      // } else {
-      //   await cp(srcPath, destPath, { force: true })
-      // }
-    }
+    const content = await readFile(srcPath, 'utf8')
 
-    // await writeFile(join(fakeNodeModulePath, 'package.json'), JSON.stringify({ type: 'commonjs' }))
-
-    // there is `/chunks/**/*` require coming from webpack-runtime that fails esbuild due to nothing matching,
-    // so this ensure something does
-    // const dummyChunkPath = join(fakeNodeModulePath, prefixPath, 'server', 'chunks', 'dummy.js')
-    // await mkdir(dirname(dummyChunkPath), { recursive: true })
-    // await writeFile(dummyChunkPath, '')
-
-    // there is also `@opentelemetry/api` require that fails esbuild due to nothing matching,
-    // next is try/catching it and fallback to bundled version of otel package in case of errors
-    // const otelApiPath = join(
-    //   fakeNodeModulePath,
-    //   'node_modules',
-    //   '@opentelemetry',
-    //   'api',
-    //   'index.js',
-    // )
-    // await mkdir(dirname(otelApiPath), { recursive: true })
-    // await writeFile(
-    //   otelApiPath,
-    //   `throw new Error('this is dummy to satisfy esbuild used for npm compat using fake module')`,
-    // )
-
-    // await writeHandlerFile(ctx, definition)
-
-    const nextConfig = ctx.buildConfig
-    const handlerName = getHandlerName({ name })
-    const handlerDirectory = join(ctx.edgeFunctionsDir, handlerName)
-    const handlerRuntimeDirectory = join(handlerDirectory, 'edge-runtime')
-
-    // Copying the runtime files. These are the compatibility layer between
-    // Netlify Edge Functions and the Next.js edge runtime.
-    await copyRuntime(ctx, handlerDirectory)
-
-    // Writing a file with the matchers that should trigger this function. We'll
-    // read this file from the function at runtime.
-    await writeFile(
-      join(handlerRuntimeDirectory, 'matchers.json'),
-      JSON.stringify(middlewareDefinition.matchers ?? []),
+    parts.push(
+      `virtualModules.set(${JSON.stringify(join(commonPrefix, file))}, ${JSON.stringify(content)});`,
     )
+  }
+  parts.push(`registerCJSModules(import.meta.url, virtualModules);
 
-    // The config is needed by the edge function to match and normalize URLs. To
-    // avoid shipping and parsing a large file at runtime, let's strip it down to
-    // just the properties that the edge function actually needs.
-    const minimalNextConfig = {
-      basePath: nextConfig.basePath,
-      i18n: nextConfig.i18n,
-      trailingSlash: nextConfig.trailingSlash,
-      skipMiddlewareUrlNormalize: nextConfig.skipMiddlewareUrlNormalize,
-    }
-
-    await writeFile(
-      join(handlerRuntimeDirectory, 'next.config.json'),
-      JSON.stringify(minimalNextConfig),
-    )
+    const require = createRequire(import.meta.url);
+    const handlerMod = require("./${join(commonPrefix, entry)}");
+    const handler = handlerMod.default || handlerMod;
 
-    const htmlRewriterWasm = await readFile(
-      join(
-        ctx.pluginDir,
-        'edge-runtime/vendor/deno.land/x/htmlrewriter@v1.0.0/pkg/htmlrewriter_bg.wasm',
-      ),
-    )
+    export default handler
+    `)
 
-    // Writing the function entry file. It wraps the middleware code with the
-    // compatibility layer mentioned above.
-    await writeFile(
-      join(handlerDirectory, `${handlerName}.js`),
-      `
-    import { createRequire } from "node:module";
-    import { init as htmlRewriterInit } from './edge-runtime/vendor/deno.land/x/htmlrewriter@v1.0.0/src/index.ts'
-    import { handleMiddleware } from './edge-runtime/middleware.ts';
-    import { registerCJSModules } from "./edge-runtime/lib/cjs.ts";
-    import process from 'node:process'
-    import { AsyncLocalStorage } from 'node:async_hooks';
+  const outputFile = join(destDir, `server/${name}.js`)
 
-    globalThis.process = process
-    globalThis.AsyncLocalStorage = AsyncLocalStorage;
+  await mkdir(dirname(outputFile), { recursive: true })
 
-    // needed for path.relative and path.resolve to work
-    Deno.cwd = () => ''
+  await writeFile(outputFile, parts.join('\n'))
+}
 
-    const virtualModules = new Map();
-    ${virtualModules}
-    registerCJSModules(import.meta.url, virtualModules);
+const createEdgeHandler = async (
+  ctx: PluginContext,
+  definition: EdgeOrNodeMiddlewareDefinition,
+): Promise => {
+  await (definition.runtime === 'edge'
+    ? copyHandlerDependenciesForEdgeMiddleware(ctx, definition.functionDefinition)
+    : copyHandlerDependenciesForNodeMiddleware(ctx, definition.functionDefinition))
+  await writeHandlerFile(ctx, definition)
+}
 
-    const require = createRequire(import.meta.url);
-    const handlerMod = require("./${prefixPath}/${entry}");
-    const handler = handlerMod.default || handlerMod;
+const getHandlerName = ({ name }: Pick): string =>
+  `${EDGE_HANDLER_NAME}-${name.replace(/\W/g, '-')}`
 
-    await htmlRewriterInit({ module_or_path: Uint8Array.from(${JSON.stringify([
-      ...htmlRewriterWasm,
-    ])}) });
+const buildHandlerDefinition = (
+  ctx: PluginContext,
+  def: EdgeOrNodeMiddlewareDefinition,
+): Array => {
+  const functionHandlerName = getHandlerName({ name: def.name })
+  const functionName = 'Next.js Middleware Handler'
+  const cache = def.name.endsWith('middleware') ? undefined : ('manual' as const)
+  const generator = `${ctx.pluginName}@${ctx.pluginVersion}`
 
-    export default (req, context) => {
-      return handleMiddleware(req, context, handler);
-    };
-    `,
-    )
+  return augmentMatchers(def.matchers, ctx).map((matcher) => ({
+    function: functionHandlerName,
+    name: functionName,
+    pattern: matcher.regexp,
+    cache,
+    generator,
+  }))
+}
 
-    // buildHandlerDefinition(ctx, def)
-    const netlifyDefinitions: Manifest['functions'] = augmentMatchers(
-      middlewareDefinition.matchers ?? [],
-      ctx,
-    ).map((matcher) => {
-      return {
-        function: getHandlerName({ name }),
-        name: `Next.js Node Middleware Handler`,
-        pattern: matcher.regexp,
-        cache: undefined,
-        generator: `${ctx.pluginName}@${ctx.pluginVersion}`,
-      }
-    })
+export const clearStaleEdgeHandlers = async (ctx: PluginContext) => {
+  await rm(ctx.edgeFunctionsDir, { recursive: true, force: true })
+}
 
-    const netlifyManifest: Manifest = {
-      version: 1,
-      functions: netlifyDefinitions,
+export const createEdgeHandlers = async (ctx: PluginContext) => {
+  // Edge middleware
+  const nextManifest = await ctx.getMiddlewareManifest()
+  const middlewareDefinitions: EdgeOrNodeMiddlewareDefinition[] = [
+    ...Object.values(nextManifest.middleware),
+  ].map((edgeDefinition) => {
+    return {
+      runtime: 'edge',
+      functionDefinition: edgeDefinition,
+      name: edgeDefinition.name,
+      matchers: edgeDefinition.matchers,
     }
-    await writeEdgeManifest(ctx, netlifyManifest)
+  })
 
-    return
+  // Node middleware
+  const functionsConfigManifest = await ctx.getFunctionsConfigManifest()
+  if (
+    functionsConfigManifest?.functions?.['/_middleware'] &&
+    nodeMiddlewareDefinitionHasMatcher(functionsConfigManifest?.functions?.['/_middleware'])
+  ) {
+    middlewareDefinitions.push({
+      runtime: 'nodejs',
+      functionDefinition: functionsConfigManifest?.functions?.['/_middleware'],
+      name: NODE_MIDDLEWARE_NAME,
+      matchers: functionsConfigManifest?.functions?.['/_middleware']?.matchers,
+    })
   }
 
+  await Promise.all(middlewareDefinitions.map((def) => createEdgeHandler(ctx, def)))
+
+  const netlifyDefinitions = middlewareDefinitions.flatMap((def) =>
+    buildHandlerDefinition(ctx, def),
+  )
+
   const netlifyManifest: Manifest = {
     version: 1,
     functions: netlifyDefinitions,

From 1276559b9d5b7f6bcfba6b0fd796a3d03a281004 Mon Sep 17 00:00:00 2001
From: Michal Piechowiak 
Date: Thu, 21 Aug 2025 16:28:40 +0200
Subject: [PATCH 37/47] test: make sure to return middleware response in
 middleware-i18n

---
 tests/fixtures/middleware-i18n/middleware-shared.ts | 6 ++++++
 1 file changed, 6 insertions(+)

diff --git a/tests/fixtures/middleware-i18n/middleware-shared.ts b/tests/fixtures/middleware-i18n/middleware-shared.ts
index 8a24bf39fb..8ea581a7fb 100644
--- a/tests/fixtures/middleware-i18n/middleware-shared.ts
+++ b/tests/fixtures/middleware-i18n/middleware-shared.ts
@@ -6,10 +6,15 @@ export async function middleware(request: NextRequest) {
 
   if (response) {
     response.headers.append('Deno' in globalThis ? 'x-deno' : 'x-node', Date.now().toString())
+    console.error('[middleware] adding x-runtime')
     // report Next.js Middleware Runtime (not the execution runtime, but target runtime)
     // @ts-expect-error EdgeRuntime global not declared
     response.headers.append('x-runtime', typeof EdgeRuntime !== 'undefined' ? EdgeRuntime : 'node')
     response.headers.set('x-hello-from-middleware-res', 'hello')
+
+    return response
+  } else {
+    console.error('[middleware] NOT adding x-runtime')
   }
 }
 
@@ -120,6 +125,7 @@ const getResponse = (request: NextRequest) => {
   }
 
   if (url.pathname.includes('/json')) {
+    console.error('[middleware] hitting json stuff')
     return NextResponse.json({
       requestUrlPathname: new URL(request.url).pathname,
       nextUrlPathname: request.nextUrl.pathname,

From 2a5bdff1623e4d25abd1d17657c11b72e3134456 Mon Sep 17 00:00:00 2001
From: Michal Piechowiak 
Date: Thu, 21 Aug 2025 16:32:55 +0200
Subject: [PATCH 38/47] fix: duplicate type import

---
 src/build/content/server.ts | 1 -
 1 file changed, 1 deletion(-)

diff --git a/src/build/content/server.ts b/src/build/content/server.ts
index d3f928849d..fbd289533c 100644
--- a/src/build/content/server.ts
+++ b/src/build/content/server.ts
@@ -18,7 +18,6 @@ import { wrapTracer } from '@opentelemetry/api/experimental'
 import glob from 'fast-glob'
 import type { MiddlewareManifest } from 'next/dist/build/webpack/plugins/middleware-plugin.js'
 import type { FunctionsConfigManifest } from 'next-with-cache-handler-v2/dist/build/index.js'
-import type { FunctionsConfigManifest } from 'next-with-cache-handler-v2/dist/build/index.js'
 import { prerelease, satisfies, lt as semverLowerThan, lte as semverLowerThanOrEqual } from 'semver'
 
 import type { RunConfig } from '../../run/config.js'

From 9c302c0962f0f3b060a500039fd24fa840db28c9 Mon Sep 17 00:00:00 2001
From: Michal Piechowiak 
Date: Thu, 21 Aug 2025 16:45:04 +0200
Subject: [PATCH 39/47] chore: remove debug logs

---
 tests/fixtures/middleware-i18n/middleware-shared.ts | 3 ---
 1 file changed, 3 deletions(-)

diff --git a/tests/fixtures/middleware-i18n/middleware-shared.ts b/tests/fixtures/middleware-i18n/middleware-shared.ts
index 8ea581a7fb..3a0af0d26c 100644
--- a/tests/fixtures/middleware-i18n/middleware-shared.ts
+++ b/tests/fixtures/middleware-i18n/middleware-shared.ts
@@ -6,15 +6,12 @@ export async function middleware(request: NextRequest) {
 
   if (response) {
     response.headers.append('Deno' in globalThis ? 'x-deno' : 'x-node', Date.now().toString())
-    console.error('[middleware] adding x-runtime')
     // report Next.js Middleware Runtime (not the execution runtime, but target runtime)
     // @ts-expect-error EdgeRuntime global not declared
     response.headers.append('x-runtime', typeof EdgeRuntime !== 'undefined' ? EdgeRuntime : 'node')
     response.headers.set('x-hello-from-middleware-res', 'hello')
 
     return response
-  } else {
-    console.error('[middleware] NOT adding x-runtime')
   }
 }
 

From 2782f43a6a3b8a0f2ceab914999bae47a0d30168 Mon Sep 17 00:00:00 2001
From: Michal Piechowiak 
Date: Thu, 21 Aug 2025 20:03:23 +0200
Subject: [PATCH 40/47] test fixes

---
 tests/e2e/edge-middleware.test.ts             |  4 +-
 .../middleware-shared.ts                      | 30 ++++++------
 .../middleware-i18n/middleware-shared.ts      | 47 +++++++++----------
 .../fixtures/middleware/middleware-shared.ts  |  5 +-
 tests/integration/edge-handler.test.ts        |  6 +--
 5 files changed, 47 insertions(+), 45 deletions(-)

diff --git a/tests/e2e/edge-middleware.test.ts b/tests/e2e/edge-middleware.test.ts
index 8f31a5884e..ca7aaeb569 100644
--- a/tests/e2e/edge-middleware.test.ts
+++ b/tests/e2e/edge-middleware.test.ts
@@ -16,6 +16,8 @@ type ExtendedFixtures = {
   edgeOrNodeMiddlewareStaticAssetMatcher: Fixture
 }
 
+console.log({ hasNodeMiddlewareSupport: hasNodeMiddlewareSupport() })
+
 for (const { expectedRuntime, label, testWithSwitchableMiddlewareRuntime } of [
   {
     expectedRuntime: 'edge-runtime',
@@ -124,8 +126,6 @@ for (const { expectedRuntime, label, testWithSwitchableMiddlewareRuntime } of [
 
       const h1 = page.locator('h1')
       await expect(h1).toHaveText('Other')
-
-      expect(await res?.headerValue('x-runtime')).toEqual(expectedRuntime)
     })
 
     test('Does not run middleware at the origin', async ({ page, edgeOrNodeMiddleware }) => {
diff --git a/tests/fixtures/middleware-i18n-skip-normalize/middleware-shared.ts b/tests/fixtures/middleware-i18n-skip-normalize/middleware-shared.ts
index 7e1e1abc3a..976afb54db 100644
--- a/tests/fixtures/middleware-i18n-skip-normalize/middleware-shared.ts
+++ b/tests/fixtures/middleware-i18n-skip-normalize/middleware-shared.ts
@@ -10,6 +10,8 @@ export async function middleware(request: NextRequest) {
     // @ts-expect-error EdgeRuntime global not declared
     response.headers.append('x-runtime', typeof EdgeRuntime !== 'undefined' ? EdgeRuntime : 'node')
     response.headers.set('x-hello-from-middleware-res', 'hello')
+
+    return response
   }
 }
 
@@ -23,75 +25,75 @@ const getResponse = (request: NextRequest) => {
 
   if (url.pathname === '/old-home') {
     if (url.searchParams.get('override') === 'external') {
-      return Response.redirect('https://example.vercel.sh')
+      return NextResponse.redirect('https://example.vercel.sh')
     } else {
       url.pathname = '/new-home'
-      return Response.redirect(url)
+      return NextResponse.redirect(url)
     }
   }
 
   if (url.searchParams.get('foo') === 'bar') {
     url.pathname = '/new-home'
     url.searchParams.delete('foo')
-    return Response.redirect(url)
+    return NextResponse.redirect(url)
   }
 
   // Chained redirects
   if (url.pathname === '/redirect-me-alot') {
     url.pathname = '/redirect-me-alot-2'
-    return Response.redirect(url)
+    return NextResponse.redirect(url)
   }
 
   if (url.pathname === '/redirect-me-alot-2') {
     url.pathname = '/redirect-me-alot-3'
-    return Response.redirect(url)
+    return NextResponse.redirect(url)
   }
 
   if (url.pathname === '/redirect-me-alot-3') {
     url.pathname = '/redirect-me-alot-4'
-    return Response.redirect(url)
+    return NextResponse.redirect(url)
   }
 
   if (url.pathname === '/redirect-me-alot-4') {
     url.pathname = '/redirect-me-alot-5'
-    return Response.redirect(url)
+    return NextResponse.redirect(url)
   }
 
   if (url.pathname === '/redirect-me-alot-5') {
     url.pathname = '/redirect-me-alot-6'
-    return Response.redirect(url)
+    return NextResponse.redirect(url)
   }
 
   if (url.pathname === '/redirect-me-alot-6') {
     url.pathname = '/redirect-me-alot-7'
-    return Response.redirect(url)
+    return NextResponse.redirect(url)
   }
 
   if (url.pathname === '/redirect-me-alot-7') {
     url.pathname = '/new-home'
-    return Response.redirect(url)
+    return NextResponse.redirect(url)
   }
 
   // Infinite loop
   if (url.pathname === '/infinite-loop') {
     url.pathname = '/infinite-loop-1'
-    return Response.redirect(url)
+    return NextResponse.redirect(url)
   }
 
   if (url.pathname === '/infinite-loop-1') {
     url.pathname = '/infinite-loop'
-    return Response.redirect(url)
+    return NextResponse.redirect(url)
   }
 
   if (url.pathname === '/to') {
     url.pathname = url.searchParams.get('pathname')
     url.searchParams.delete('pathname')
-    return Response.redirect(url)
+    return NextResponse.redirect(url)
   }
 
   if (url.pathname === '/with-fragment') {
     console.log(String(new URL('/new-home#fragment', url)))
-    return Response.redirect(new URL('/new-home#fragment', url))
+    return NextResponse.redirect(new URL('/new-home#fragment', url))
   }
 
   if (url.pathname.includes('/json')) {
diff --git a/tests/fixtures/middleware-i18n/middleware-shared.ts b/tests/fixtures/middleware-i18n/middleware-shared.ts
index 3a0af0d26c..2281805378 100644
--- a/tests/fixtures/middleware-i18n/middleware-shared.ts
+++ b/tests/fixtures/middleware-i18n/middleware-shared.ts
@@ -4,15 +4,13 @@ import { NextResponse } from 'next/server'
 export async function middleware(request: NextRequest) {
   const response = getResponse(request)
 
-  if (response) {
-    response.headers.append('Deno' in globalThis ? 'x-deno' : 'x-node', Date.now().toString())
-    // report Next.js Middleware Runtime (not the execution runtime, but target runtime)
-    // @ts-expect-error EdgeRuntime global not declared
-    response.headers.append('x-runtime', typeof EdgeRuntime !== 'undefined' ? EdgeRuntime : 'node')
-    response.headers.set('x-hello-from-middleware-res', 'hello')
+  response.headers.set('Deno' in globalThis ? 'x-deno' : 'x-node', Date.now().toString())
+  // report Next.js Middleware Runtime (not the execution runtime, but target runtime)
+  // @ts-expect-error EdgeRuntime global not declared
+  response.headers.set('x-runtime', typeof EdgeRuntime !== 'undefined' ? EdgeRuntime : 'node')
+  response.headers.set('x-hello-from-middleware-res', 'hello')
 
-    return response
-  }
+  return response
 }
 
 const getResponse = (request: NextRequest) => {
@@ -45,88 +43,89 @@ const getResponse = (request: NextRequest) => {
 
   if (url.pathname === '/old-home') {
     if (url.searchParams.get('override') === 'external') {
-      return Response.redirect('https://example.vercel.sh')
+      return NextResponse.redirect('https://example.vercel.sh')
     } else {
       url.pathname = '/new-home'
-      return Response.redirect(url)
+      return NextResponse.redirect(url)
     }
   }
 
   if (url.searchParams.get('foo') === 'bar') {
     url.pathname = '/new-home'
     url.searchParams.delete('foo')
-    return Response.redirect(url)
+    return NextResponse.redirect(url)
   }
 
   // Chained redirects
   if (url.pathname === '/redirect-me-alot') {
     url.pathname = '/redirect-me-alot-2'
-    return Response.redirect(url)
+    return NextResponse.redirect(url)
   }
 
   if (url.pathname === '/redirect-me-alot-2') {
     url.pathname = '/redirect-me-alot-3'
-    return Response.redirect(url)
+    return NextResponse.redirect(url)
   }
 
   if (url.pathname === '/redirect-me-alot-3') {
     url.pathname = '/redirect-me-alot-4'
-    return Response.redirect(url)
+    return NextResponse.redirect(url)
   }
 
   if (url.pathname === '/redirect-me-alot-4') {
     url.pathname = '/redirect-me-alot-5'
-    return Response.redirect(url)
+    return NextResponse.redirect(url)
   }
 
   if (url.pathname === '/redirect-me-alot-5') {
     url.pathname = '/redirect-me-alot-6'
-    return Response.redirect(url)
+    return NextResponse.redirect(url)
   }
 
   if (url.pathname === '/redirect-me-alot-6') {
     url.pathname = '/redirect-me-alot-7'
-    return Response.redirect(url)
+    return NextResponse.redirect(url)
   }
 
   if (url.pathname === '/redirect-me-alot-7') {
     url.pathname = '/new-home'
-    return Response.redirect(url)
+    return NextResponse.redirect(url)
   }
 
   // Infinite loop
   if (url.pathname === '/infinite-loop') {
     url.pathname = '/infinite-loop-1'
-    return Response.redirect(url)
+    return NextResponse.redirect(url)
   }
 
   if (url.pathname === '/infinite-loop-1') {
     url.pathname = '/infinite-loop'
-    return Response.redirect(url)
+    return NextResponse.redirect(url)
   }
 
   if (url.pathname === '/to') {
     url.pathname = url.searchParams.get('pathname')
     url.searchParams.delete('pathname')
-    return Response.redirect(url)
+    return NextResponse.redirect(url)
   }
 
   if (url.pathname === '/with-fragment') {
     console.log(String(new URL('/new-home#fragment', url)))
-    return Response.redirect(new URL('/new-home#fragment', url))
+    return NextResponse.redirect(new URL('/new-home#fragment', url))
   }
 
   if (url.locale !== 'en' && url.pathname === '/redirect-to-same-page-but-default-locale') {
     url.locale = 'en'
-    return Response.redirect(url)
+    return NextResponse.redirect(url)
   }
 
   if (url.pathname.includes('/json')) {
-    console.error('[middleware] hitting json stuff')
     return NextResponse.json({
       requestUrlPathname: new URL(request.url).pathname,
       nextUrlPathname: request.nextUrl.pathname,
       nextUrlLocale: request.nextUrl.locale,
     })
   }
+
+  return NextResponse.next()
 }
diff --git a/tests/fixtures/middleware/middleware-shared.ts b/tests/fixtures/middleware/middleware-shared.ts
index a484a7d61b..9e799be115 100644
--- a/tests/fixtures/middleware/middleware-shared.ts
+++ b/tests/fixtures/middleware/middleware-shared.ts
@@ -9,9 +9,10 @@ export const { runWithAmplifyServerContext } = createServerRunner({
 export async function middleware(request: NextRequest) {
   const response = getResponse(request)
 
-  response.headers.append('Deno' in globalThis ? 'x-deno' : 'x-node', Date.now().toString())
+  response.headers.set('Deno' in globalThis ? 'x-deno' : 'x-node', Date.now().toString())
   // report Next.js Middleware Runtime (not the execution runtime, but target runtime)
-  response.headers.append('x-runtime', typeof EdgeRuntime !== 'undefined' ? EdgeRuntime : 'node')
+  // @ts-expect-error EdgeRuntime global not declared
+  response.headers.set('x-runtime', typeof EdgeRuntime !== 'undefined' ? EdgeRuntime : 'node')
   response.headers.set('x-hello-from-middleware-res', 'hello')
 
   await runWithAmplifyServerContext({
diff --git a/tests/integration/edge-handler.test.ts b/tests/integration/edge-handler.test.ts
index 2582d68414..dd5488a529 100644
--- a/tests/integration/edge-handler.test.ts
+++ b/tests/integration/edge-handler.test.ts
@@ -216,7 +216,7 @@ for (const {
 
         expect(await response.text()).toBe('')
 
-        expect(response.status).toBe(302)
+        expect(response.status).toBe(307)
         expect(response.headers.get('location')).toBe('http://example.com/redirected')
         expect(response.headers.get('x-runtime')).toEqual(expectedRuntime)
       })
@@ -555,7 +555,7 @@ for (const {
         })
         const url = new URL(response.headers.get('location') ?? '', 'http://n/')
         expect(url.pathname).toBe('/fr/new-home')
-        expect(response.status).toBe(302)
+        expect(response.status).toBe(307)
         expect(response.headers.get('x-runtime')).toEqual(expectedRuntime)
       })
 
@@ -580,7 +580,7 @@ for (const {
         })
         const url = new URL(response.headers.get('location') ?? '', 'http://n/')
         expect(url.pathname).toBe('/redirect-to-same-page-but-default-locale')
-        expect(response.status).toBe(302)
+        expect(response.status).toBe(307)
         expect(response.headers.get('x-runtime')).toEqual(expectedRuntime)
       })
 

From bc6dc50937adcbbbefe077b241a0a4e4aae51e98 Mon Sep 17 00:00:00 2001
From: Michal Piechowiak 
Date: Fri, 22 Aug 2025 09:25:52 +0200
Subject: [PATCH 41/47] fix: don't pass unused manifest

---
 src/build/functions/edge.ts | 7 ++-----
 1 file changed, 2 insertions(+), 5 deletions(-)

diff --git a/src/build/functions/edge.ts b/src/build/functions/edge.ts
index 90eaeb6d31..5f1899f264 100644
--- a/src/build/functions/edge.ts
+++ b/src/build/functions/edge.ts
@@ -195,10 +195,7 @@ const copyHandlerDependenciesForEdgeMiddleware = async (
 }
 
 const NODE_MIDDLEWARE_NAME = 'node-middleware'
-const copyHandlerDependenciesForNodeMiddleware = async (
-  ctx: PluginContext,
-  _arg: NodeMiddlewareDefinition,
-) => {
+const copyHandlerDependenciesForNodeMiddleware = async (ctx: PluginContext) => {
   const name = NODE_MIDDLEWARE_NAME
 
   const srcDir = join(ctx.standaloneDir, ctx.nextDistDir)
@@ -281,7 +278,7 @@ const createEdgeHandler = async (
 ): Promise => {
   await (definition.runtime === 'edge'
     ? copyHandlerDependenciesForEdgeMiddleware(ctx, definition.functionDefinition)
-    : copyHandlerDependenciesForNodeMiddleware(ctx, definition.functionDefinition))
+    : copyHandlerDependenciesForNodeMiddleware(ctx))
   await writeHandlerFile(ctx, definition)
 }
 

From 9a52755e9e8eaefaf047eebb2b6e746524cc246a Mon Sep 17 00:00:00 2001
From: Michal Piechowiak 
Date: Fri, 22 Aug 2025 09:27:36 +0200
Subject: [PATCH 42/47] fix: static html blobs if distDir is different than
 default .next

---
 src/run/config.ts          |  4 +++-
 src/run/handlers/server.ts |  6 +++---
 src/run/next.cts           | 11 ++++++++---
 3 files changed, 14 insertions(+), 7 deletions(-)

diff --git a/src/run/config.ts b/src/run/config.ts
index 5233a5f7ea..2ff3c39592 100644
--- a/src/run/config.ts
+++ b/src/run/config.ts
@@ -19,7 +19,7 @@ export const getRunConfig = async () => {
   return JSON.parse(await readFile(resolve(PLUGIN_DIR, RUN_CONFIG_FILE), 'utf-8')) as RunConfig
 }
 
-type NextConfigForMultipleVersions = NextConfigComplete & {
+export type NextConfigForMultipleVersions = NextConfigComplete & {
   experimental: NextConfigComplete['experimental'] & {
     // those are pre 14.1.0 options that were moved out of experimental in // https://github.com/vercel/next.js/pull/57953/files#diff-c49c4767e6ed8627e6e1b8f96b141ee13246153f5e9142e1da03450c8e81e96fL311
 
@@ -62,4 +62,6 @@ export const setRunConfig = (config: NextConfigForMultipleVersions) => {
 
   // set config
   process.env.__NEXT_PRIVATE_STANDALONE_CONFIG = JSON.stringify(config)
+
+  return config
 }
diff --git a/src/run/handlers/server.ts b/src/run/handlers/server.ts
index 4eafa40af1..6434a571d1 100644
--- a/src/run/handlers/server.ts
+++ b/src/run/handlers/server.ts
@@ -24,11 +24,11 @@ import { setupWaitUntil } from './wait-until.cjs'
 setFetchBeforeNextPatchedIt(globalThis.fetch)
 // configure globals that Next.js make use of before we start importing any Next.js code
 // as some globals are consumed at import time
-const { nextConfig, enableUseCacheHandler } = await getRunConfig()
+const { nextConfig: initialNextConfig, enableUseCacheHandler } = await getRunConfig()
 if (enableUseCacheHandler) {
   configureUseCacheHandlers()
 }
-setRunConfig(nextConfig)
+const nextConfig = setRunConfig(initialNextConfig)
 setupWaitUntil()
 
 const nextImportPromise = import('../next.cjs')
@@ -71,7 +71,7 @@ export default async (
       const { getMockedRequestHandler } = await nextImportPromise
       const url = new URL(request.url)
 
-      nextHandler = await getMockedRequestHandler({
+      nextHandler = await getMockedRequestHandler(nextConfig, {
         port: Number(url.port) || 443,
         hostname: url.hostname,
         dir: process.cwd(),
diff --git a/src/run/next.cts b/src/run/next.cts
index 085cf057de..4f7fb6e884 100644
--- a/src/run/next.cts
+++ b/src/run/next.cts
@@ -1,12 +1,13 @@
 import { AsyncLocalStorage } from 'node:async_hooks'
 import fs from 'node:fs/promises'
-import { relative, resolve } from 'node:path'
+import { relative, resolve, join } from 'node:path'
 
 // @ts-expect-error no types installed
 import { patchFs } from 'fs-monkey'
 
 import { HtmlBlob } from '../shared/blob-types.cjs'
 
+import type { NextConfigForMultipleVersions } from './config.js'
 import { getRequestContext } from './handlers/request-context.cjs'
 import { getTracer } from './handlers/tracer.cjs'
 import { getMemoizedKeyValueStoreBackedByRegionalBlobStore } from './storage/storage.cjs'
@@ -79,7 +80,10 @@ ResponseCache.prototype.get = function get(...getArgs: unknown[]) {
 
 type FS = typeof import('fs')
 
-export async function getMockedRequestHandler(...args: Parameters) {
+export async function getMockedRequestHandler(
+  nextConfig: NextConfigForMultipleVersions,
+  ...args: Parameters
+) {
   const initContext = { initializingServer: true }
   /**
    * Using async local storage to identify operations happening as part of server initialization
@@ -101,8 +105,9 @@ export async function getMockedRequestHandler(...args: Parameters(relPath, 'staticHtml.get')
+          console.log('trying to read path', { path, relPath, file })
           if (file !== null) {
             if (file.isFullyStaticPage) {
               const requestContext = getRequestContext()

From cbf47e54b5c175ba5bf6f8b77dd8a5b4735b0812 Mon Sep 17 00:00:00 2001
From: Michal Piechowiak 
Date: Fri, 22 Aug 2025 09:28:08 +0200
Subject: [PATCH 43/47] test: don't assert middleware runtime on tests that
 assert that middleware did NOT run

---
 tests/e2e/edge-middleware.test.ts | 3 ---
 1 file changed, 3 deletions(-)

diff --git a/tests/e2e/edge-middleware.test.ts b/tests/e2e/edge-middleware.test.ts
index ca7aaeb569..72aac7bc70 100644
--- a/tests/e2e/edge-middleware.test.ts
+++ b/tests/e2e/edge-middleware.test.ts
@@ -441,7 +441,6 @@ for (const { expectedRuntime, label, testWithSwitchableMiddlewareRuntime } of [
           const response = await fetch(`${edgeOrNodeMiddlewareI18nExcludedPaths.url}/api/html`)
 
           expect(response.headers.get('x-test-used-middleware')).not.toBe('true')
-          expect(response.headers.get('x-runtime')).toEqual(expectedRuntime)
           expect(response.status).toBe(200)
 
           const { params } = await response.json()
@@ -455,7 +454,6 @@ for (const { expectedRuntime, label, testWithSwitchableMiddlewareRuntime } of [
           const response = await fetch(`${edgeOrNodeMiddlewareI18nExcludedPaths.url}/excluded`)
 
           expect(response.headers.get('x-test-used-middleware')).not.toBe('true')
-          expect(response.headers.get('x-runtime')).toEqual(expectedRuntime)
           expect(response.status).toBe(200)
           expect(response.headers.get('content-type')).toMatch(/text\/html/)
 
@@ -472,7 +470,6 @@ for (const { expectedRuntime, label, testWithSwitchableMiddlewareRuntime } of [
           const response = await fetch(`${edgeOrNodeMiddlewareI18nExcludedPaths.url}/fr/excluded`)
 
           expect(response.headers.get('x-test-used-middleware')).not.toBe('true')
-          expect(response.headers.get('x-runtime')).toEqual(expectedRuntime)
           expect(response.status).toBe(200)
           expect(response.headers.get('content-type')).toMatch(/text\/html/)
 

From f5d32d45cd0e329a511939dbb72bfaba0bb44944 Mon Sep 17 00:00:00 2001
From: Michal Piechowiak 
Date: Fri, 22 Aug 2025 09:28:35 +0200
Subject: [PATCH 44/47] test: fix expected redirect status

---
 src/run/next.cts                       | 3 +--
 tests/integration/edge-handler.test.ts | 2 +-
 2 files changed, 2 insertions(+), 3 deletions(-)

diff --git a/src/run/next.cts b/src/run/next.cts
index 4f7fb6e884..779b995e2b 100644
--- a/src/run/next.cts
+++ b/src/run/next.cts
@@ -1,6 +1,6 @@
 import { AsyncLocalStorage } from 'node:async_hooks'
 import fs from 'node:fs/promises'
-import { relative, resolve, join } from 'node:path'
+import { relative, resolve } from 'node:path'
 
 // @ts-expect-error no types installed
 import { patchFs } from 'fs-monkey'
@@ -107,7 +107,6 @@ export async function getMockedRequestHandler(
           const cacheStore = getMemoizedKeyValueStoreBackedByRegionalBlobStore()
           const relPath = relative(resolve(nextConfig.distDir, 'server/pages'), path)
           const file = await cacheStore.get(relPath, 'staticHtml.get')
-          console.log('trying to read path', { path, relPath, file })
           if (file !== null) {
             if (file.isFullyStaticPage) {
               const requestContext = getRequestContext()
diff --git a/tests/integration/edge-handler.test.ts b/tests/integration/edge-handler.test.ts
index dd5488a529..9e0a2f0008 100644
--- a/tests/integration/edge-handler.test.ts
+++ b/tests/integration/edge-handler.test.ts
@@ -216,7 +216,7 @@ for (const {
 
         expect(await response.text()).toBe('')
 
-        expect(response.status).toBe(307)
+        expect(response.status).toBe(302)
         expect(response.headers.get('location')).toBe('http://example.com/redirected')
         expect(response.headers.get('x-runtime')).toEqual(expectedRuntime)
       })

From 37bb7da78067a8ce0465be645545b192ec2b220d Mon Sep 17 00:00:00 2001
From: Michal Piechowiak 
Date: Fri, 22 Aug 2025 10:14:00 +0200
Subject: [PATCH 45/47] test: convert middleware-conditions fixture to page
 router, as app router with i18n enabled fails to build on next@canary now

---
 .../fixtures/middleware-conditions/app/layout.js | 12 ------------
 .../middleware-conditions/app/test/next/page.js  | 12 ------------
 .../{app/page.js => pages/index.js}              |  0
 .../{app/other/page.js => pages/other.js}        |  0
 .../middleware-conditions/pages/test/next.js     | 16 ++++++++++++++++
 .../redirect/page.js => pages/test/redirect.js}  |  0
 .../rewrite/page.js => pages/test/rewrite.js}    |  0
 7 files changed, 16 insertions(+), 24 deletions(-)
 delete mode 100644 tests/fixtures/middleware-conditions/app/layout.js
 delete mode 100644 tests/fixtures/middleware-conditions/app/test/next/page.js
 rename tests/fixtures/middleware-conditions/{app/page.js => pages/index.js} (100%)
 rename tests/fixtures/middleware-conditions/{app/other/page.js => pages/other.js} (100%)
 create mode 100644 tests/fixtures/middleware-conditions/pages/test/next.js
 rename tests/fixtures/middleware-conditions/{app/test/redirect/page.js => pages/test/redirect.js} (100%)
 rename tests/fixtures/middleware-conditions/{app/test/rewrite/page.js => pages/test/rewrite.js} (100%)

diff --git a/tests/fixtures/middleware-conditions/app/layout.js b/tests/fixtures/middleware-conditions/app/layout.js
deleted file mode 100644
index 6565e7bafd..0000000000
--- a/tests/fixtures/middleware-conditions/app/layout.js
+++ /dev/null
@@ -1,12 +0,0 @@
-export const metadata = {
-  title: 'Simple Next App',
-  description: 'Description for Simple Next App',
-}
-
-export default function RootLayout({ children }) {
-  return (
-    
-      {children}
-    
-  )
-}
diff --git a/tests/fixtures/middleware-conditions/app/test/next/page.js b/tests/fixtures/middleware-conditions/app/test/next/page.js
deleted file mode 100644
index 0908c69938..0000000000
--- a/tests/fixtures/middleware-conditions/app/test/next/page.js
+++ /dev/null
@@ -1,12 +0,0 @@
-import { headers } from 'next/headers'
-
-export default function Page() {
-  const headersList = headers()
-  const message = headersList.get('x-hello-from-middleware-req')
-
-  return (
-    
-

Message from middleware: {message}

-
- ) -} diff --git a/tests/fixtures/middleware-conditions/app/page.js b/tests/fixtures/middleware-conditions/pages/index.js similarity index 100% rename from tests/fixtures/middleware-conditions/app/page.js rename to tests/fixtures/middleware-conditions/pages/index.js diff --git a/tests/fixtures/middleware-conditions/app/other/page.js b/tests/fixtures/middleware-conditions/pages/other.js similarity index 100% rename from tests/fixtures/middleware-conditions/app/other/page.js rename to tests/fixtures/middleware-conditions/pages/other.js diff --git a/tests/fixtures/middleware-conditions/pages/test/next.js b/tests/fixtures/middleware-conditions/pages/test/next.js new file mode 100644 index 0000000000..2fe36935eb --- /dev/null +++ b/tests/fixtures/middleware-conditions/pages/test/next.js @@ -0,0 +1,16 @@ +export default function Page({ message }) { + return ( +
+

Message from middleware: {message}

+
+ ) +} + +/** @type {import('next').GetServerSideProps} */ +export const getServerSideProps = async (ctx) => { + return { + props: { + message: ctx.req.headers['x-hello-from-middleware-req'] || null, + }, + } +} diff --git a/tests/fixtures/middleware-conditions/app/test/redirect/page.js b/tests/fixtures/middleware-conditions/pages/test/redirect.js similarity index 100% rename from tests/fixtures/middleware-conditions/app/test/redirect/page.js rename to tests/fixtures/middleware-conditions/pages/test/redirect.js diff --git a/tests/fixtures/middleware-conditions/app/test/rewrite/page.js b/tests/fixtures/middleware-conditions/pages/test/rewrite.js similarity index 100% rename from tests/fixtures/middleware-conditions/app/test/rewrite/page.js rename to tests/fixtures/middleware-conditions/pages/test/rewrite.js From 0eae894d22ece8af3179acdce3ffae63ff343fa2 Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Fri, 22 Aug 2025 10:54:44 +0200 Subject: [PATCH 46/47] test: adjust for /_global-error --- tests/integration/cache-handler.test.ts | 3 +++ tests/integration/simple-app.test.ts | 2 ++ tests/utils/next-version-helpers.mjs | 7 +++++++ 3 files changed, 12 insertions(+) diff --git a/tests/integration/cache-handler.test.ts b/tests/integration/cache-handler.test.ts index 7eaf6b70c5..b459d62559 100644 --- a/tests/integration/cache-handler.test.ts +++ b/tests/integration/cache-handler.test.ts @@ -19,6 +19,7 @@ import { } from '../utils/helpers.js' import { nextVersionSatisfies, + shouldHaveAppRouterGlobalErrorInPrerenderManifest, shouldHaveAppRouterNotFoundInPrerenderManifest, } from '../utils/next-version-helpers.mjs' @@ -218,6 +219,7 @@ describe('app router', () => { expect(blobEntries.map(({ key }) => decodeBlobKey(key)).sort()).toEqual( [ '/404', + shouldHaveAppRouterGlobalErrorInPrerenderManifest() ? '/_global-error' : undefined, shouldHaveAppRouterNotFoundInPrerenderManifest() ? '/_not-found' : undefined, '/index', '/posts/1', @@ -371,6 +373,7 @@ describe('plugin', () => { expect(blobEntries.map(({ key }) => decodeBlobKey(key)).sort()).toEqual( [ '/404', + shouldHaveAppRouterGlobalErrorInPrerenderManifest() ? '/_global-error' : undefined, shouldHaveAppRouterNotFoundInPrerenderManifest() ? '/_not-found' : undefined, '/api/revalidate-handler', '/api/static/first', diff --git a/tests/integration/simple-app.test.ts b/tests/integration/simple-app.test.ts index 2d2acf46bf..0afb9472b8 100644 --- a/tests/integration/simple-app.test.ts +++ b/tests/integration/simple-app.test.ts @@ -36,6 +36,7 @@ import { } from '../utils/helpers.js' import { nextVersionSatisfies, + shouldHaveAppRouterGlobalErrorInPrerenderManifest, shouldHaveAppRouterNotFoundInPrerenderManifest, } from '../utils/next-version-helpers.mjs' @@ -394,6 +395,7 @@ test.skipIf(process.env.NEXT_VERSION !== 'canary')( expect(blobEntries.map(({ key }) => decodeBlobKey(key)).sort()).toEqual( [ '/404', + shouldHaveAppRouterGlobalErrorInPrerenderManifest() ? '/_global-error' : undefined, shouldHaveAppRouterNotFoundInPrerenderManifest() ? '/_not-found' : undefined, '/index', '404.html', diff --git a/tests/utils/next-version-helpers.mjs b/tests/utils/next-version-helpers.mjs index 23b83b5e1c..1afb74aa5d 100644 --- a/tests/utils/next-version-helpers.mjs +++ b/tests/utils/next-version-helpers.mjs @@ -38,6 +38,13 @@ export function shouldHaveAppRouterNotFoundInPrerenderManifest() { return nextVersionSatisfies(isNextCanary() ? '>=15.4.2-canary.33' : '>=15.5.0') } +export function shouldHaveAppRouterGlobalErrorInPrerenderManifest() { + // https://github.com/vercel/next.js/pull/82444 + + // this is not used in any stable version yet + return isNextCanary() && nextVersionSatisfies('>=15.5.1-canary.4') +} + export function hasNodeMiddlewareSupport() { return nextVersionSatisfies(isNextCanary() ? '>=15.2.0' : '>=15.5.0') } From a9c05bb420590fde5c4533745908bc54f871fe3a Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Fri, 22 Aug 2025 12:20:54 +0200 Subject: [PATCH 47/47] test: add node.js runtime specific middleware tests checking availability of crypto, path and http(s) node.js builtins --- ...-middleware.test.ts => middleware.test.ts} | 40 ++++++++++++- .../app/layout.js | 0 .../app/page.js | 0 .../middleware.ts | 60 +++++++++++++++++++ .../next.config.js | 0 .../package.json | 2 +- .../public/http-test-target.json | 3 + tests/fixtures/middleware-node/middleware.ts | 9 --- tests/utils/create-e2e-fixture.ts | 1 + 9 files changed, 102 insertions(+), 13 deletions(-) rename tests/e2e/{edge-middleware.test.ts => middleware.test.ts} (92%) rename tests/fixtures/{middleware-node => middleware-node-runtime-specific}/app/layout.js (100%) rename tests/fixtures/{middleware-node => middleware-node-runtime-specific}/app/page.js (100%) create mode 100644 tests/fixtures/middleware-node-runtime-specific/middleware.ts rename tests/fixtures/{middleware-node => middleware-node-runtime-specific}/next.config.js (100%) rename tests/fixtures/{middleware-node => middleware-node-runtime-specific}/package.json (87%) create mode 100644 tests/fixtures/middleware-node-runtime-specific/public/http-test-target.json delete mode 100644 tests/fixtures/middleware-node/middleware.ts diff --git a/tests/e2e/edge-middleware.test.ts b/tests/e2e/middleware.test.ts similarity index 92% rename from tests/e2e/edge-middleware.test.ts rename to tests/e2e/middleware.test.ts index 72aac7bc70..505434b849 100644 --- a/tests/e2e/edge-middleware.test.ts +++ b/tests/e2e/middleware.test.ts @@ -16,11 +16,10 @@ type ExtendedFixtures = { edgeOrNodeMiddlewareStaticAssetMatcher: Fixture } -console.log({ hasNodeMiddlewareSupport: hasNodeMiddlewareSupport() }) - -for (const { expectedRuntime, label, testWithSwitchableMiddlewareRuntime } of [ +for (const { expectedRuntime, isNodeMiddleware, label, testWithSwitchableMiddlewareRuntime } of [ { expectedRuntime: 'edge-runtime', + isNodeMiddleware: false, label: 'Edge runtime middleware', testWithSwitchableMiddlewareRuntime: test.extend<{}, ExtendedFixtures>({ edgeOrNodeMiddleware: [ @@ -68,6 +67,7 @@ for (const { expectedRuntime, label, testWithSwitchableMiddlewareRuntime } of [ hasNodeMiddlewareSupport() ? { expectedRuntime: 'node', + isNodeMiddleware: true, label: 'Node.js runtime middleware', testWithSwitchableMiddlewareRuntime: test.extend<{}, ExtendedFixtures>({ edgeOrNodeMiddleware: [ @@ -565,6 +565,40 @@ for (const { expectedRuntime, label, testWithSwitchableMiddlewareRuntime } of [ ) }) }) + + if (isNodeMiddleware) { + // Node.js Middleware specific tests to test features not available in Edge Runtime + test.describe('Node.js Middleware specific', () => { + test('node:crypto module', async ({ middlewareNodeRuntimeSpecific }) => { + const response = await fetch(`${middlewareNodeRuntimeSpecific.url}/test/crypto`) + expect(response.status).toBe(200) + const body = await response.json() + expect( + body.random, + 'random should have 16 random bytes generated with `randomBytes` function from node:crypto in hex format', + ).toMatch(/[0-9a-f]{32}/) + }) + + test('node:http(s) module', async ({ middlewareNodeRuntimeSpecific }) => { + const response = await fetch(`${middlewareNodeRuntimeSpecific.url}/test/http`) + expect(response.status).toBe(200) + const body = await response.json() + expect( + body.proxiedWithHttpRequest, + 'proxiedWithHttpRequest should be the result of `http.request` from node:http fetching static asset', + ).toStrictEqual({ hello: 'world' }) + }) + + test('node:path module', async ({ middlewareNodeRuntimeSpecific }) => { + const response = await fetch(`${middlewareNodeRuntimeSpecific.url}/test/path`) + expect(response.status).toBe(200) + const body = await response.json() + expect(body.joined, 'joined should be the result of `join` function from node:path').toBe( + 'a/b', + ) + }) + }) + } }) } diff --git a/tests/fixtures/middleware-node/app/layout.js b/tests/fixtures/middleware-node-runtime-specific/app/layout.js similarity index 100% rename from tests/fixtures/middleware-node/app/layout.js rename to tests/fixtures/middleware-node-runtime-specific/app/layout.js diff --git a/tests/fixtures/middleware-node/app/page.js b/tests/fixtures/middleware-node-runtime-specific/app/page.js similarity index 100% rename from tests/fixtures/middleware-node/app/page.js rename to tests/fixtures/middleware-node-runtime-specific/app/page.js diff --git a/tests/fixtures/middleware-node-runtime-specific/middleware.ts b/tests/fixtures/middleware-node-runtime-specific/middleware.ts new file mode 100644 index 0000000000..94b72b14b0 --- /dev/null +++ b/tests/fixtures/middleware-node-runtime-specific/middleware.ts @@ -0,0 +1,60 @@ +import { randomBytes } from 'node:crypto' +import { request as httpRequest } from 'node:http' +import { request as httpsRequest } from 'node:https' +import { join } from 'node:path' + +import { NextResponse } from 'next/server' +import type { NextRequest } from 'next/server' + +export async function middleware(request: NextRequest) { + // this middleware is using Node.js APIs that are not available in Edge Runtime in very simple way to assert support for them + if (request.nextUrl.pathname === '/test/crypto') { + return NextResponse.json({ random: randomBytes(16).toString('hex') }) + } + + if (request.nextUrl.pathname === '/test/http') { + const body = await new Promise((resolve, reject) => { + const origin = + typeof Netlify !== 'undefined' + ? `https://${Netlify.context.deploy.id}--${Netlify.context.site.name}.netlify.app` + : `http://localhost:3000` + + const target = new URL('/http-test-target.json', origin) + + const httpOrHttpsRequest = target.protocol === 'https:' ? httpsRequest : httpRequest + + const req = httpOrHttpsRequest(target, (res) => { + if (res.statusCode !== 200) { + reject(new Error(`Failed to fetch ${target}: ${res.statusCode}`)) + // Consume response data to free up memory + res.resume() + return + } + + res.setEncoding('utf8') + let rawData = '' + res.on('data', (chunk) => { + rawData += chunk + }) + res.on('end', () => { + try { + resolve(JSON.parse(rawData)) + } catch (e) { + reject(e) + } + }) + }) + req.end() + console.log({ target }) + }) + return NextResponse.json({ proxiedWithHttpRequest: body }) + } + + if (request.nextUrl.pathname === '/test/path') { + return NextResponse.json({ joined: join('a', 'b') }) + } +} + +export const config = { + runtime: 'nodejs', +} diff --git a/tests/fixtures/middleware-node/next.config.js b/tests/fixtures/middleware-node-runtime-specific/next.config.js similarity index 100% rename from tests/fixtures/middleware-node/next.config.js rename to tests/fixtures/middleware-node-runtime-specific/next.config.js diff --git a/tests/fixtures/middleware-node/package.json b/tests/fixtures/middleware-node-runtime-specific/package.json similarity index 87% rename from tests/fixtures/middleware-node/package.json rename to tests/fixtures/middleware-node-runtime-specific/package.json index ce0360a5f4..361384756b 100644 --- a/tests/fixtures/middleware-node/package.json +++ b/tests/fixtures/middleware-node-runtime-specific/package.json @@ -1,5 +1,5 @@ { - "name": "middleware-node", + "name": "middleware-node-runtime-specific", "version": "0.1.0", "private": true, "scripts": { diff --git a/tests/fixtures/middleware-node-runtime-specific/public/http-test-target.json b/tests/fixtures/middleware-node-runtime-specific/public/http-test-target.json new file mode 100644 index 0000000000..f2a886f39d --- /dev/null +++ b/tests/fixtures/middleware-node-runtime-specific/public/http-test-target.json @@ -0,0 +1,3 @@ +{ + "hello": "world" +} diff --git a/tests/fixtures/middleware-node/middleware.ts b/tests/fixtures/middleware-node/middleware.ts deleted file mode 100644 index 064f5bb6c3..0000000000 --- a/tests/fixtures/middleware-node/middleware.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { NextRequest } from 'next/server' - -export async function middleware(request: NextRequest) { - console.log('Node.js Middleware request:', request.method, request.nextUrl.pathname) -} - -export const config = { - runtime: 'nodejs', -} diff --git a/tests/utils/create-e2e-fixture.ts b/tests/utils/create-e2e-fixture.ts index 63749e7dd7..3f65ff17d3 100644 --- a/tests/utils/create-e2e-fixture.ts +++ b/tests/utils/create-e2e-fixture.ts @@ -346,6 +346,7 @@ export const fixtureFactories = { buildCommand: getBuildFixtureVariantCommand('node-middleware'), publishDirectory: '.next-node-middleware', }), + middlewareNodeRuntimeSpecific: () => createE2EFixture('middleware-node-runtime-specific'), middlewareI18n: () => createE2EFixture('middleware-i18n'), middlewareI18nNode: () => createE2EFixture('middleware-i18n', {