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: 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/edge-runtime/lib/cjs.ts b/edge-runtime/lib/cjs.ts new file mode 100644 index 0000000000..a7704ce365 --- /dev/null +++ b/edge-runtime/lib/cjs.ts @@ -0,0 +1,166 @@ +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 + filepath: string + // lazily parsed json string + parsedJson?: any +} +const registeredModules = new Map() + +const require = createRequire(import.meta.url) + +let hookedIn = false + +function parseJson(matchedModule: RegisteredModule) { + if (matchedModule.parsedJson) { + return matchedModule.parsedJson + } + + try { + const jsonContent = JSON.parse(matchedModule.source) + matchedModule.parsedJson = jsonContent + return jsonContent + } catch (error) { + throw new Error(`Failed to parse JSON module: ${matchedModule.filepath}`, { cause: error }) + } +} + +function seedCJSModuleCacheAndReturnTarget(matchedModule: RegisteredModule, parent: Module) { + if (matchedModule.loaded) { + return matchedModule.filepath + } + const { source, filepath } = matchedModule + + const mod = new Module(filepath) + mod.parent = parent + mod.filename = filepath + mod.path = dirname(filepath) + // @ts-expect-error - private untyped API + mod.paths = Module._nodeModulePaths(mod.path) + require.cache[filepath] = mod + + try { + if (filepath.endsWith('.json')) { + Object.assign(mod.exports, parseJson(matchedModule)) + } else { + const wrappedSource = `(function (exports, require, module, __filename, __dirname) { ${source}\n});` + const compiled = vm.runInThisContext(wrappedSource, { + filename: filepath, + lineOffset: 0, + displayErrors: true, + }) + const modRequire = createRequire(pathToFileURL(filepath)) + compiled(mod.exports, modRequire, mod, filepath, dirname(filepath)) + } + mod.loaded = matchedModule.loaded = true + } catch (error) { + throw new Error(`Failed to compile CJS module: ${filepath}`, { cause: error }) + } + + return filepath +} + +// ideally require.extensions could be used, but it does NOT include '.cjs', so hardcoding instead +const exts = ['.js', '.cjs', '.json'] + +function tryWithExtensions(filename: string) { + 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) { + 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, filepath: target }) + } + + if (!hookedIn) { + // @ts-expect-error - private untyped API + const original_resolveFilename = Module._resolveFilename.bind(Module) + // @ts-expect-error - private untyped API + Module._resolveFilename = (...args) => { + 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('/')) { + const packageName = target.startsWith('@') + ? target.split('/').slice(0, 2).join('/') + : target.split('/')[0] + const moduleInPackagePath = target.slice(packageName.length + 1) + + for (const nodeModulePaths of args[1].paths) { + const potentialPackageJson = join(nodeModulePaths, packageName, 'package.json') + + const maybePackageJson = registeredModules.get(potentialPackageJson) + + let relativeTarget = moduleInPackagePath + + let pkgJson: any = null + if (maybePackageJson) { + pkgJson = parseJson(maybePackageJson) + + // TODO: exports and anything else like that + if (moduleInPackagePath.length === 0 && pkgJson.main) { + relativeTarget = pkgJson.main + } + } + + const potentialPath = join(nodeModulePaths, packageName, relativeTarget) + + matchedModule = tryMatchingWithIndex(potentialPath) + if (matchedModule) { + break + } + } + } + + if (matchedModule) { + return seedCJSModuleCacheAndReturnTarget(matchedModule, args[1]) + } + + return original_resolveFilename(...args) + } + + hookedIn = true + } +} 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/content/server.ts b/src/build/content/server.ts index da754d67cc..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,19 +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( - 'Node.js middleware is not yet supported.\n\n' + - 'Future @netlify/plugin-nextjs release will support node middleware with following limitations:\n' + - ' - 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),\n' + - ' - usage of Filesystem (https://nodejs.org/api/fs.html) 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..e58afbc13e 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,26 +194,119 @@ const copyHandlerDependencies = async ( await writeFile(outputFile, parts.join('\n')) } -const createEdgeHandler = async (ctx: PluginContext, definition: NextDefinition): Promise => { - await copyHandlerDependencies(ctx, definition) +const NODE_MIDDLEWARE_NAME = 'node-middleware' +const copyHandlerDependenciesForNodeMiddleware = async (ctx: PluginContext) => { + const name = NODE_MIDDLEWARE_NAME + + 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/node.js') + const shim = await readFile(shimPath, 'utf8') + + const parts = [shim] + + 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) + + // 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, unsupportedDotNodeModules } = files.reduce( + (acc, file) => { + let dirsUp = 0 + let parentDirectoriesPath = '' + for (const part of file.split('/')) { + if (part === '..') { + dirsUp += 1 + parentDirectoriesPath += '../' + } else { + break + } + } + + if (file.endsWith('.node')) { + // C++ addons are not supported + acc.unsupportedDotNodeModules.push(join(srcDir, file)) + } + + if (dirsUp > acc.maxDirsUp) { + return { + ...acc, + maxDirsUp: dirsUp, + maxParentDirectoriesPath: parentDirectoriesPath, + } + } + + return acc + }, + { maxDirsUp: 0, maxParentDirectoriesPath: '', unsupportedDotNodeModules: [] as string[] }, + ) + + if (unsupportedDotNodeModules.length !== 0) { + throw new Error( + `Usage of unsupported C++ Addon(s) found in Node.js Middleware:\n${unsupportedDotNodeModules.map((file) => `- ${file}`).join('\n')}\n\nCheck https://docs.netlify.com/build/frameworks/framework-setup-guides/nextjs/overview/#limitations for more information.`, + ) + } + + const commonPrefix = relative(join(srcDir, maxParentDirectoriesPath), srcDir) + + parts.push(`const virtualModules = new Map();`) + + for (const file of files) { + const srcPath = join(srcDir, file) + + const content = await readFile(srcPath, 'utf8') + + parts.push( + `virtualModules.set(${JSON.stringify(join(commonPrefix, file))}, ${JSON.stringify(content)});`, + ) + } + parts.push(`registerCJSModules(import.meta.url, virtualModules); + + const require = createRequire(import.meta.url); + const handlerMod = require("./${join(commonPrefix, entry)}"); + const handler = handlerMod.default || handlerMod; + + export default handler + `) + + const outputFile = join(destDir, `server/${name}.js`) + + await mkdir(dirname(outputFile), { recursive: true }) + + await writeFile(outputFile, parts.join('\n')) +} + +const createEdgeHandler = async ( + ctx: PluginContext, + definition: EdgeOrNodeMiddlewareDefinition, +): Promise => { + await (definition.runtime === 'edge' + ? copyHandlerDependenciesForEdgeMiddleware(ctx, definition.functionDefinition) + : copyHandlerDependenciesForNodeMiddleware(ctx)) await writeHandlerFile(ctx, definition) } -const getHandlerName = ({ name }: Pick): string => +const getHandlerName = ({ name }: Pick): string => `${EDGE_HANDLER_NAME}-${name.replace(/\W/g, '-')}` const buildHandlerDefinition = ( ctx: PluginContext, - { name, matchers, page }: NextDefinition, + def: EdgeOrNodeMiddlewareDefinition, ): 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 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}` - return augmentMatchers(matchers, ctx).map((matcher) => ({ + return augmentMatchers(def.matchers, ctx).map((matcher) => ({ function: functionHandlerName, name: functionName, pattern: matcher.regexp, @@ -194,11 +320,39 @@ export const clearStaleEdgeHandlers = async (ctx: PluginContext) => { } export const createEdgeHandlers = async (ctx: PluginContext) => { + // Edge middleware const nextManifest = await ctx.getMiddlewareManifest() - const nextDefinitions = [...Object.values(nextManifest.middleware)] - await Promise.all(nextDefinitions.map((def) => createEdgeHandler(ctx, def))) + const middlewareDefinitions: EdgeOrNodeMiddlewareDefinition[] = [ + ...Object.values(nextManifest.middleware), + ].map((edgeDefinition) => { + return { + runtime: 'edge', + functionDefinition: edgeDefinition, + name: edgeDefinition.name, + matchers: edgeDefinition.matchers, + } + }) + + // 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 netlifyDefinitions = nextDefinitions.flatMap((def) => buildHandlerDefinition(ctx, def)) 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/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..779b995e2b 100644 --- a/src/run/next.cts +++ b/src/run/next.cts @@ -7,6 +7,7 @@ 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,7 +105,7 @@ export async function getMockedRequestHandler(...args: Parameters(relPath, 'staticHtml.get') if (file !== null) { if (file.isFullyStaticPage) { diff --git a/tests/e2e/edge-middleware.test.ts b/tests/e2e/edge-middleware.test.ts deleted file mode 100644 index e95fa9f000..0000000000 --- a/tests/e2e/edge-middleware.test.ts +++ /dev/null @@ -1,434 +0,0 @@ -import { expect, Response } from '@playwright/test' -import { nextVersionSatisfies } from '../utils/next-version-helpers.mjs' -import { test } from '../utils/playwright-helpers.js' -import { getImageSize } from 'next/dist/server/image-optimizer.js' - -type ExtendedWindow = Window & { - didReload?: boolean -} - -test('Runs edge middleware', async ({ page, middleware }) => { - await page.goto(`${middleware.url}/test/redirect`) - - await expect(page).toHaveTitle('Simple Next App') - - 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() - - await expect(page).toHaveTitle('Simple Next App') - - const h1 = page.locator('h1') - await expect(h1).toHaveText('Message from middleware: hello') -}) - -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) - } - }) - }) - - await page.goto(`${middlewarePages.url}/link`) - - await page.hover(`[data-link="${testConfig.selector}"]`) - - const dataResponse = await dataFetchPromise - - expect(dataResponse.ok()).toBe(true) - }) - - test('navigation', async ({ middlewarePages, page }) => { - await page.goto(`${middlewarePages.url}/link`) - - 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) - }) - }) - } - }) - 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')
-  }
-
-  // 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`)
-
-      expect(response.headers.get('x-test-used-middleware')).toBe('true')
-      expect(response.status).toBe(200)
-
-      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`)
-
-      expect(response.headers.get('x-test-used-middleware')).toBe('true')
-      expect(response.status).toBe(200)
-
-      const { nextUrlPathname, nextUrlLocale } = await response.json()
-
-      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`)
-
-      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)
-
-      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`)
-
-      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)
-
-      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`)
-
-      expect(response.headers.get('x-test-used-middleware')).not.toBe('true')
-      expect(response.status).toBe(200)
-
-      const { params } = await response.json()
-
-      expect(params).toMatchObject({ catchall: ['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('content-type')).toMatch(/text\/html/)
-
-      const html = await response.text()
-      const { locale, params } = extractDataFromHtml(html)
-
-      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`)
-
-      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)
-
-      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',
-    },
-  })
-
-  // 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)
-        }
-      })
-    })
-    await page.goto(`${middleware.url}/link-to-rewrite-to-cached-page`)
-
-    // ensure prefetch
-    await page.hover('text=NextResponse.rewrite')
-
-    // 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/,
-    )
-
-    const htmlResponse = await page.goto(`${middleware.url}/test/rewrite-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/)
-  })
-
-  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)
-        }
-      })
-    })
-    await page.goto(`${middleware.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
-
-    // 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`)
-
-    // 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/)
-  })
-})
diff --git a/tests/e2e/middleware.test.ts b/tests/e2e/middleware.test.ts
new file mode 100644
index 0000000000..505434b849
--- /dev/null
+++ b/tests/e2e/middleware.test.ts
@@ -0,0 +1,620 @@
+import { expect, Response } from '@playwright/test'
+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
+}
+
+type ExtendedFixtures = {
+  edgeOrNodeMiddleware: Fixture
+  edgeOrNodeMiddlewarePages: Fixture
+  edgeOrNodeMiddlewareI18n: Fixture
+  edgeOrNodeMiddlewareI18nExcludedPaths: Fixture
+  edgeOrNodeMiddlewareStaticAssetMatcher: Fixture
+}
+
+for (const { expectedRuntime, isNodeMiddleware, label, testWithSwitchableMiddlewareRuntime } of [
+  {
+    expectedRuntime: 'edge-runtime',
+    isNodeMiddleware: false,
+    label: 'Edge runtime middleware',
+    testWithSwitchableMiddlewareRuntime: test.extend<{}, ExtendedFixtures>({
+      edgeOrNodeMiddleware: [
+        async ({ middleware }, use) => {
+          await use(middleware)
+        },
+        {
+          scope: 'worker',
+        },
+      ],
+      edgeOrNodeMiddlewarePages: [
+        async ({ middlewarePages }, use) => {
+          await use(middlewarePages)
+        },
+        {
+          scope: 'worker',
+        },
+      ],
+      edgeOrNodeMiddlewareI18n: [
+        async ({ middlewareI18n }, use) => {
+          await use(middlewareI18n)
+        },
+        {
+          scope: 'worker',
+        },
+      ],
+      edgeOrNodeMiddlewareI18nExcludedPaths: [
+        async ({ middlewareI18nExcludedPaths }, use) => {
+          await use(middlewareI18nExcludedPaths)
+        },
+        {
+          scope: 'worker',
+        },
+      ],
+      edgeOrNodeMiddlewareStaticAssetMatcher: [
+        async ({ middlewareStaticAssetMatcher }, use) => {
+          await use(middlewareStaticAssetMatcher)
+        },
+        {
+          scope: 'worker',
+        },
+      ],
+    }),
+  },
+  hasNodeMiddlewareSupport()
+    ? {
+        expectedRuntime: 'node',
+        isNodeMiddleware: true,
+        label: 'Node.js runtime middleware',
+        testWithSwitchableMiddlewareRuntime: test.extend<{}, ExtendedFixtures>({
+          edgeOrNodeMiddleware: [
+            async ({ middlewareNode }, use) => {
+              await use(middlewareNode)
+            },
+            {
+              scope: 'worker',
+            },
+          ],
+          edgeOrNodeMiddlewarePages: [
+            async ({ middlewarePagesNode }, use) => {
+              await use(middlewarePagesNode)
+            },
+            {
+              scope: 'worker',
+            },
+          ],
+          edgeOrNodeMiddlewareI18n: [
+            async ({ middlewareI18nNode }, use) => {
+              await use(middlewareI18nNode)
+            },
+            {
+              scope: 'worker',
+            },
+          ],
+          edgeOrNodeMiddlewareI18nExcludedPaths: [
+            async ({ middlewareI18nExcludedPathsNode }, use) => {
+              await use(middlewareI18nExcludedPathsNode)
+            },
+            {
+              scope: 'worker',
+            },
+          ],
+          edgeOrNodeMiddlewareStaticAssetMatcher: [
+            async ({ middlewareStaticAssetMatcherNode }, use) => {
+              await use(middlewareStaticAssetMatcherNode)
+            },
+            {
+              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')
+    })
+
+    test('Does not run middleware at the origin', async ({ page, edgeOrNodeMiddleware }) => {
+      const res = await page.goto(`${edgeOrNodeMiddleware.url}/test/next`)
+
+      expect(await res?.headerValue('x-deno')).toBeTruthy()
+      expect(await res?.headerValue('x-node')).toBeNull()
+
+      await expect(page).toHaveTitle('Simple Next App')
+
+      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,
+      edgeOrNodeMiddleware,
+    }) => {
+      const direct = await page.goto(`${edgeOrNodeMiddleware.url}/test/rewrite-target`)
+      expect(await direct?.headerValue('x-added-rewrite-target')).toBeTruthy()
+
+      const rewritten = await page.goto(`${edgeOrNodeMiddleware.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')
+
+      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') {
+      // 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 ({ edgeOrNodeMiddlewarePages, page }) => {
+              const dataFetchPromise = new Promise((resolve) => {
+                page.on('response', (response) => {
+                  if (response.url().includes(testConfig.jsonPathMatcher)) {
+                    resolve(response)
+                  }
+                })
+              })
+
+              const pageResponse = await page.goto(`${edgeOrNodeMiddlewarePages.url}/link`)
+              expect(await pageResponse?.headerValue('x-runtime')).toEqual(expectedRuntime)
+
+              await page.hover(`[data-link="${testConfig.selector}"]`)
+
+              const dataResponse = await dataFetchPromise
+
+              expect(dataResponse.ok()).toBe(true)
+            })
+
+            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.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)
+            })
+          })
+        }
+      })
+
+      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)
+                      }
+                    })
+                  })
+
+                  const pageResponse = await page.goto(
+                    `${edgeOrNodeMiddlewareI18n.url}${pageWithLinksPathname}`,
+                  )
+                  expect(await pageResponse?.headerValue('x-runtime')).toEqual(expectedRuntime)
+
+                  await page.hover(`[data-link="${testConfig.selector}"]`)
+
+                  const dataResponse = await dataFetchPromise
+
+                  expect(dataResponse.ok()).toBe(true)
+                })
+
+                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.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')
+      }
+
+      // 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`)
+
+          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()
+
+          expect(nextUrlPathname).toBe('/json')
+          expect(nextUrlLocale).toBe(DEFAULT_LOCALE)
+        })
+
+        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.headers.get('x-runtime')).toEqual(expectedRuntime)
+          expect(response.status).toBe(200)
+
+          const { nextUrlPathname, nextUrlLocale } = await response.json()
+
+          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 ({
+          edgeOrNodeMiddlewareI18nExcludedPaths,
+        }) => {
+          const response = await fetch(`${edgeOrNodeMiddlewareI18nExcludedPaths.url}/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)
+
+          expect(params).toMatchObject({ catchall: ['html'] })
+          expect(locale).toBe(DEFAULT_LOCALE)
+        })
+
+        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.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)
+
+          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 ({
+          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)
+
+          const { params } = await response.json()
+
+          expect(params).toMatchObject({ catchall: ['html'] })
+        })
+
+        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/)
+
+          const html = await response.text()
+          const { locale, params } = extractDataFromHtml(html)
+
+          expect(params).toMatchObject({ catchall: ['excluded'] })
+          expect(locale).toBe(DEFAULT_LOCALE)
+        })
+
+        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/)
+
+          const html = await response.text()
+          const { locale, params } = extractDataFromHtml(html)
+
+          expect(params).toMatchObject({ catchall: ['excluded'] })
+          expect(locale).toBe('fr')
+        })
+      })
+    })
+
+    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')
+      expect(response.headers.get('x-runtime')).toEqual(expectedRuntime)
+    })
+
+    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`)
+
+        // ensure prefetch
+        await page.hover('text=NextResponse.rewrite')
+
+        // 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/,
+        )
+
+        const htmlResponse = await page.goto(
+          `${edgeOrNodeMiddleware.url}/test/rewrite-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/,
+        )
+      })
+
+      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
+
+        // 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(
+          `${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/,
+        )
+      })
+    })
+
+    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',
+          )
+        })
+      })
+    }
+  })
+}
+
+// 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')
+})
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/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..b3ef07ae96 --- /dev/null +++ b/tests/fixtures/middleware-conditions/middleware-shared.ts @@ -0,0 +1,12 @@ +import type { NextRequest } from 'next/server' +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 +} 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/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 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/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/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 59% rename from tests/fixtures/middleware-i18n-skip-normalize/middleware.js rename to tests/fixtures/middleware-i18n-skip-normalize/middleware-shared.ts index 24517d72de..976afb54db 100644 --- a/tests/fixtures/middleware-i18n-skip-normalize/middleware.js +++ b/tests/fixtures/middleware-i18n-skip-normalize/middleware-shared.ts @@ -1,6 +1,21 @@ +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') + + return response + } +} + +const getResponse = (request: NextRequest) => { const url = request.nextUrl // this is needed for tests to get the BUILD_ID @@ -10,75 +25,75 @@ export async function middleware(request) { 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-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/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 65% rename from tests/fixtures/middleware-i18n/middleware.js rename to tests/fixtures/middleware-i18n/middleware-shared.ts index 3462214f1d..2281805378 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) + + 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 +} + +const getResponse = (request: NextRequest) => { const url = request.nextUrl // this is needed for tests to get the BUILD_ID @@ -30,80 +43,80 @@ export async function middleware(request) { 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')) { @@ -113,4 +126,6 @@ export async function middleware(request) { nextUrlLocale: request.nextUrl.locale, }) } + + return NextResponse.next() } 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/fixtures/middleware-conditions/app/layout.js b/tests/fixtures/middleware-node-runtime-specific/app/layout.js similarity index 100% rename from tests/fixtures/middleware-conditions/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 85% rename from tests/fixtures/middleware-node/next.config.js rename to tests/fixtures/middleware-node-runtime-specific/next.config.js index 24a4bdfa44..3b68b4c137 100644 --- a/tests/fixtures/middleware-node/next.config.js +++ b/tests/fixtures/middleware-node-runtime-specific/next.config.js @@ -7,6 +7,7 @@ const nextConfig = { experimental: { nodeMiddleware: true, }, + outputFileTracingRoot: __dirname, } module.exports = nextConfig 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/app/layout.js b/tests/fixtures/middleware-node-unsupported-cpp-addons/app/layout.js similarity index 100% rename from tests/fixtures/middleware-node/app/layout.js rename to tests/fixtures/middleware-node-unsupported-cpp-addons/app/layout.js diff --git a/tests/fixtures/middleware-node-unsupported-cpp-addons/app/page.js b/tests/fixtures/middleware-node-unsupported-cpp-addons/app/page.js new file mode 100644 index 0000000000..1a9fe06903 --- /dev/null +++ b/tests/fixtures/middleware-node-unsupported-cpp-addons/app/page.js @@ -0,0 +1,7 @@ +export default function Home() { + return ( +
+

Home

+
+ ) +} diff --git a/tests/fixtures/middleware-node-unsupported-cpp-addons/middleware.ts b/tests/fixtures/middleware-node-unsupported-cpp-addons/middleware.ts new file mode 100644 index 0000000000..b700a60b80 --- /dev/null +++ b/tests/fixtures/middleware-node-unsupported-cpp-addons/middleware.ts @@ -0,0 +1,19 @@ +// bcrypt is using C++ Addons (.node binaries) which are unsupported currently +// example copied from https://nextjs.org/blog/next-15-2#nodejs-middleware-experimental +import bcrypt from 'bcrypt' + +const API_KEY_HASH = process.env.API_KEY_HASH // Pre-hashed API key in env + +export default async function middleware(req) { + const apiKey = req.headers.get('x-api-key') + + if (!apiKey || !(await bcrypt.compare(apiKey, API_KEY_HASH))) { + return new Response('Forbidden', { status: 403 }) + } + + console.log('API key validated') +} + +export const config = { + runtime: 'nodejs', +} diff --git a/tests/fixtures/middleware-node-unsupported-cpp-addons/next.config.js b/tests/fixtures/middleware-node-unsupported-cpp-addons/next.config.js new file mode 100644 index 0000000000..3b68b4c137 --- /dev/null +++ b/tests/fixtures/middleware-node-unsupported-cpp-addons/next.config.js @@ -0,0 +1,13 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + output: 'standalone', + eslint: { + ignoreDuringBuilds: true, + }, + experimental: { + nodeMiddleware: true, + }, + outputFileTracingRoot: __dirname, +} + +module.exports = nextConfig diff --git a/tests/fixtures/middleware-node-unsupported-cpp-addons/package.json b/tests/fixtures/middleware-node-unsupported-cpp-addons/package.json new file mode 100644 index 0000000000..e0e0b028fa --- /dev/null +++ b/tests/fixtures/middleware-node-unsupported-cpp-addons/package.json @@ -0,0 +1,21 @@ +{ + "name": "middleware-node-unsupported-cpp-addons", + "version": "0.1.0", + "private": true, + "scripts": { + "postinstall": "next build", + "dev": "next dev", + "build": "next build" + }, + "dependencies": { + "bcrypt": "^6.0.0", + "next": "latest", + "react": "18.2.0", + "react-dom": "18.2.0" + }, + "test": { + "dependencies": { + "next": ">=15.2.0" + } + } +} 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/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/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-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/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/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..9e799be115 --- /dev/null +++ b/tests/fixtures/middleware/middleware-shared.ts @@ -0,0 +1,94 @@ +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.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') + + 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 735f3a8488..708f3c7e81 100644 --- a/tests/fixtures/middleware/middleware.ts +++ b/tests/fixtures/middleware/middleware.ts @@ -1,94 +1,4 @@ -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*', diff --git a/tests/fixtures/middleware/next.config.js b/tests/fixtures/middleware/next.config.js index bab41c7998..6cb247177b 100644 --- a/tests/fixtures/middleware/next.config.js +++ b/tests/fixtures/middleware/next.config.js @@ -1,13 +1,18 @@ /** @type {import('next').NextConfig} */ const nextConfig = { output: 'standalone', + distDir: process.env.NEXT_DIST_DIR ?? '.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 - config.optimization.splitChunks.maxSize = 100_000 + // this doesn't seem to actually work with Node Middleware - it result in next build failures + // config.optimization.splitChunks.maxSize = 100_000 return config }, diff --git a/tests/fixtures/middleware/package.json b/tests/fixtures/middleware/package.json index f385ef9276..a4003c3951 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", + "postinstall": "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..f31d535c28 --- /dev/null +++ b/tests/fixtures/middleware/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/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/edge-handler.test.ts b/tests/integration/edge-handler.test.ts deleted file mode 100644 index 0b33db7936..0000000000 --- a/tests/integration/edge-handler.test.ts +++ /dev/null @@ -1,649 +0,0 @@ -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 { 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 - ctx.deployID = generateRandomObjectID() - ctx.siteID = v4() - vi.stubEnv('DEPLOY_ID', ctx.deployID) - - await startMockBlobStore(ctx) -}) - -test('should add request/response headers', async (ctx) => { - await createFixture('middleware', 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: ['___netlify-edge-handler-middleware'], - 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) -}) - -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: ['___netlify-edge-handler-src-middleware'], - 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('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: ['___netlify-edge-handler-middleware'], - 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: ['___netlify-edge-handler-middleware'], - 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: ['___netlify-edge-handler-middleware'], - 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: ['___netlify-edge-handler-middleware'], - 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) - 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() - }) - - ctx.cleanup?.push(() => origin.stop()) - - const response = await invokeEdgeFunction(ctx, { - functions: ['___netlify-edge-handler-middleware'], - origin, - url: '/_next/data', - }) - - 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) - }) - - test('when a request header matches a condition', async (ctx) => { - await createFixture('middleware-conditions', ctx) - await runPlugin(ctx) - - const origin = await LocalServer.run(async (req, res) => { - expect(req.url).toBe('/foo') - expect(req.headers['x-hello-from-middleware-req']).toBeUndefined() - - res.write('Hello from origin!') - res.end() - }) - - ctx.cleanup?.push(() => origin.stop()) - - // Request 1: Middleware should run because we're not sending the header. - const response1 = await invokeEdgeFunction(ctx, { - functions: ['___netlify-edge-handler-middleware'], - origin, - url: '/foo', - }) - - 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) - - // Request 2: Middleware should not run because we're sending the header. - const response2 = await invokeEdgeFunction(ctx, { - headers: { - 'x-custom-header': 'custom-value', - }, - functions: ['___netlify-edge-handler-middleware'], - 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) - }) - - test('should handle locale matching correctly', async (ctx) => { - await createFixture('middleware-conditions', ctx) - await runPlugin(ctx) - - const origin = await LocalServer.run(async (req, res) => { - expect(req.headers['x-hello-from-middleware-req']).toBeUndefined() - - res.write('Hello from origin!') - res.end() - }) - - ctx.cleanup?.push(() => origin.stop()) - - for (const path of ['/hello', '/en/hello', '/nl/hello', '/nl/about']) { - const response = await invokeEdgeFunction(ctx, { - functions: ['___netlify-edge-handler-middleware'], - 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) - } - - for (const path of ['/invalid/hello', '/hello/invalid', '/about', '/en/about']) { - const response = await invokeEdgeFunction(ctx, { - functions: ['___netlify-edge-handler-middleware'], - 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) - } - }) -}) - -describe('should run middleware on data requests', () => { - test('when `trailingSlash: false`', async (ctx) => { - await createFixture('middleware', ctx) - await runPlugin(ctx) - - const origin = new LocalServer() - const response = await invokeEdgeFunction(ctx, { - functions: ['___netlify-edge-handler-middleware'], - origin, - redirect: 'manual', - url: '/_next/data/dJvEyLV8MW7CBLFf0Ecbk/test/redirect-with-headers.json', - }) - - 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) - }) - - test('when `trailingSlash: true`', async (ctx) => { - await createFixture('middleware-trailing-slash', ctx) - await runPlugin(ctx) - - const origin = new LocalServer() - const response = await invokeEdgeFunction(ctx, { - functions: ['___netlify-edge-handler-middleware'], - origin, - redirect: 'manual', - url: '/_next/data/dJvEyLV8MW7CBLFf0Ecbk/test/redirect-with-headers.json', - }) - - 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('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: ['___netlify-edge-handler-middleware'], - 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: ['___netlify-edge-handler-middleware'], - 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: ['___netlify-edge-handler-middleware'], - 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) - }) - - 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: ['___netlify-edge-handler-middleware'], - 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('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: ['___netlify-edge-handler-middleware'], - 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 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: ['___netlify-edge-handler-middleware'], - 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 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: ['___netlify-edge-handler-middleware'], - 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 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()) - - const response = await invokeEdgeFunction(ctx, { - functions: ['___netlify-edge-handler-middleware'], - origin, - url: `/json`, - }) - expect(response.status).toBe(200) - const body = await response.json() - - expect(body.requestUrlPathname).toBe('/json') - expect(body.nextUrlPathname).toBe('/json') - expect(body.nextUrlLocale).toBe('en') - - const responseEn = await invokeEdgeFunction(ctx, { - functions: ['___netlify-edge-handler-middleware'], - origin, - url: `/en/json`, - }) - expect(responseEn.status).toBe(200) - const bodyEn = await responseEn.json() - - expect(bodyEn.requestUrlPathname).toBe('/json') - expect(bodyEn.nextUrlPathname).toBe('/json') - expect(bodyEn.nextUrlLocale).toBe('en') - - const responseFr = await invokeEdgeFunction(ctx, { - functions: ['___netlify-edge-handler-middleware'], - 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') - }) - - 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, - }), - ) - res.end() - }) - ctx.cleanup?.push(() => origin.stop()) - - const response = await invokeEdgeFunction(ctx, { - functions: ['___netlify-edge-handler-middleware'], - origin, - url: `/json`, - }) - expect(response.status).toBe(200) - const body = await response.json() - - expect(body.requestUrlPathname).toBe('/json') - expect(body.nextUrlPathname).toBe('/json') - expect(body.nextUrlLocale).toBe('en') - - const responseEn = await invokeEdgeFunction(ctx, { - functions: ['___netlify-edge-handler-middleware'], - 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') - - const responseFr = await invokeEdgeFunction(ctx, { - functions: ['___netlify-edge-handler-middleware'], - 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') - }) -}) - -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', - ) - }, -) 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/middleware.test.ts b/tests/integration/middleware.test.ts new file mode 100644 index 0000000000..759217400b --- /dev/null +++ b/tests/integration/middleware.test.ts @@ -0,0 +1,744 @@ +import { v4 } from 'uuid' +import { beforeEach, describe, expect, test, vi } from 'vitest' +import { type FixtureTestContext } from '../utils/contexts.js' +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 { hasNodeMiddlewareSupport } from '../utils/next-version-helpers.mjs' + +beforeEach(async (ctx) => { + // set for each test a new deployID and siteID + ctx.deployID = generateRandomObjectID() + ctx.siteID = v4() + vi.stubEnv('DEPLOY_ID', ctx.deployID) + + await startMockBlobStore(ctx) +}) + +for (const { + edgeFunctionNameRoot, + edgeFunctionNameSrc, + expectedRuntime, + isNodeMiddleware, + label, + runPluginConstants, +} of [ + { + edgeFunctionNameRoot: EDGE_MIDDLEWARE_FUNCTION_NAME, + edgeFunctionNameSrc: EDGE_MIDDLEWARE_SRC_FUNCTION_NAME, + expectedRuntime: 'edge-runtime', + isNodeMiddleware: false, + label: 'Edge runtime middleware', + }, + hasNodeMiddlewareSupport() + ? { + edgeFunctionNameRoot: NODE_MIDDLEWARE_FUNCTION_NAME, + edgeFunctionNameSrc: NODE_MIDDLEWARE_FUNCTION_NAME, + expectedRuntime: 'node', + isNodeMiddleware: true, + 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() + }) + + ctx.cleanup?.push(() => origin.stop()) + + const response = await invokeEdgeFunction(ctx, { + functions: [edgeFunctionNameRoot], + origin, + url: '/test/next', + }) + const text = await response.text() + + 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) + }) + + test('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', + }) + + 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) + await runPlugin(ctx, runPluginConstants) + + const origin = new LocalServer() + const response = await invokeEdgeFunction(ctx, { + functions: [edgeFunctionNameRoot], + 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) + }) + + 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: [edgeFunctionNameRoot], + 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) + }) + }) + + 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: [edgeFunctionNameRoot], + 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: [edgeFunctionNameRoot], + 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) + }) + }) + + 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) + + 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() + }) + + ctx.cleanup?.push(() => origin.stop()) + + const response = await invokeEdgeFunction(ctx, { + functions: [edgeFunctionNameRoot], + origin, + url: '/_next/data', + }) + + 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) + }) + + test('when a request header matches a condition', async (ctx) => { + await createFixture('middleware-conditions', ctx) + await runPlugin(ctx, runPluginConstants) + + const origin = await LocalServer.run(async (req, res) => { + expect(req.url).toBe('/foo') + expect(req.headers['x-hello-from-middleware-req']).toBeUndefined() + + res.write('Hello from origin!') + res.end() + }) + + ctx.cleanup?.push(() => origin.stop()) + + // Request 1: Middleware should run because we're not sending the header. + const response1 = await invokeEdgeFunction(ctx, { + functions: [edgeFunctionNameRoot], + origin, + url: '/foo', + }) + + 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', + }) + + 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) + }) + + test('should handle locale matching correctly', async (ctx) => { + await createFixture('middleware-conditions', ctx) + await runPlugin(ctx, runPluginConstants) + + const origin = await LocalServer.run(async (req, res) => { + expect(req.headers['x-hello-from-middleware-req']).toBeUndefined() + + res.write('Hello from origin!') + res.end() + }) + + ctx.cleanup?.push(() => origin.stop()) + + 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) + } + }) + }) + + 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: [edgeFunctionNameRoot], + origin, + redirect: 'manual', + url: '/_next/data/dJvEyLV8MW7CBLFf0Ecbk/test/redirect-with-headers.json', + }) + + 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) + }) + + 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: [edgeFunctionNameRoot], + origin, + redirect: 'manual', + url: '/_next/data/dJvEyLV8MW7CBLFf0Ecbk/test/redirect-with-headers.json', + }) + + 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) + }) + }) + + 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('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 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) + + // 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) => { + 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) + + // 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) => { + 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/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(307) + 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, 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(307) + expect(response.headers.get('x-runtime')).toEqual(expectedRuntime) + }) + + 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: [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') + + 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') + + 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') + }) + + 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: [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') + + 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('/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() + + expect(bodyFr.requestUrlPathname).toBe('/fr/json') + expect(bodyFr.nextUrlPathname).toBe('/json') + expect(bodyFr.nextUrlLocale).toBe('fr') + }) + }) + + if (isNodeMiddleware) { + describe('Node.js Middleware specific', () => { + test('should fail to deploy when using unsupported C++ Addons with meaningful message about limitation', async (ctx) => { + await createFixture('middleware-node-unsupported-cpp-addons', ctx) + + const runPluginPromise = runPlugin(ctx) + + await expect( + runPluginPromise, + 'error message should describe error cause', + ).rejects.toThrow('Usage of unsupported C++ Addon(s) found in Node.js Middleware') + await expect( + runPluginPromise, + 'error message should mention c++ addons (.node) file names to help finding the package(s) that contain them', + ).rejects.toThrow('bcrypt.node') + await expect( + runPluginPromise, + 'link to documentation should be provided', + ).rejects.toThrow( + 'https://docs.netlify.com/build/frameworks/framework-setup-guides/nextjs/overview/#limitations', + ) + }) + }) + } + }) +} + +// 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', +// ) +// }, +// ) 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/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/prepare.mjs b/tests/prepare.mjs index 072d08e6b5..e1086e860f 100644 --- a/tests/prepare.mjs +++ b/tests/prepare.mjs @@ -24,6 +24,9 @@ const e2eOnlyFixtures = new Set([ 'cli-before-regional-blobs-support', 'dist-dir', 'middleware-i18n-excluded-paths', + 'middleware-node-runtime-specific', + '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', diff --git a/tests/utils/build-variants.mjs b/tests/utils/build-variants.mjs new file mode 100644 index 0000000000..0fc6f33c06 --- /dev/null +++ b/tests/utils/build-variants.mjs @@ -0,0 +1,157 @@ +// @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 + * @property {string} [distDir] directory to output build artifacts (will be set as ) + */ + +/** @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 +} + +/** @type {(() => Promise)[]} */ +let cleanupTasks = [] + +async function runCleanup() { + await Promise.all(cleanupTasks.map((task) => task())) + cleanupTasks = [] +} + +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.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 + } + } + + 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` + // 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(buildCommand, { + env: { + ...process.env, + ...variant.env, + NEXT_DIST_DIR: distDir, + }, + stdio: 'inherit', + reject: false, + }) + + await runCleanup() + + 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..3f65ff17d3 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,11 +341,37 @@ 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', + }), + middlewareNodeRuntimeSpecific: () => createE2EFixture('middleware-node-runtime-specific'), middlewareI18n: () => createE2EFixture('middleware-i18n'), + middlewareI18nNode: () => + createE2EFixture('middleware-i18n', { + buildCommand: getBuildFixtureVariantCommand('node-middleware'), + 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: () => + createE2EFixture('middleware-pages', { + buildCommand: getBuildFixtureVariantCommand('node-middleware'), + 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'), diff --git a/tests/utils/fixture.ts b/tests/utils/fixture.ts index a863c253a3..2736be62c2 100644 --- a/tests/utils/fixture.ts +++ b/tests/utils/fixture.ts @@ -560,3 +560,7 @@ 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' +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 26a8df1b7f..1afb74aa5d 100644 --- a/tests/utils/next-version-helpers.mjs +++ b/tests/utils/next-version-helpers.mjs @@ -38,6 +38,17 @@ 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') +} + /** * Check if current next version requires React 19 * @param {string} version Next version