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.test.ts b/edge-runtime/lib/cjs.test.ts new file mode 100644 index 0000000000..16347ddd7c --- /dev/null +++ b/edge-runtime/lib/cjs.test.ts @@ -0,0 +1,106 @@ +import { createRequire } from 'node:module' +import { join, dirname, relative } from 'node:path' +import { fileURLToPath } from 'node:url' + +import { assertEquals } from 'https://deno.land/std@0.175.0/testing/asserts.ts' + +import { registerCJSModules } from './cjs.ts' + +type RequireResult = Record + +const localRequire = createRequire(import.meta.url) +const realRequireResult = localRequire('./fixture/cjs/entry.js') as RequireResult + +const fixtureRoot = new URL('./fixture/cjs/', import.meta.url) +const virtualRoot = new URL('file:///virtual-root/index.mjs') + +const fixtureRootPath = fileURLToPath(fixtureRoot) +const virtualRootPath = dirname(fileURLToPath(virtualRoot)) + +// load fixture into virtual CJS +const virtualModules = new Map() +const decoder = new TextDecoder('utf-8') +async function addVirtualModulesFromDir(dir: string) { + const dirUrl = new URL('./' + dir, fixtureRoot) + + for await (const dirEntry of Deno.readDir(dirUrl)) { + const relPath = join(dir, dirEntry.name) + if (dirEntry.isDirectory) { + await addVirtualModulesFromDir(relPath + '/') + } else if (dirEntry.isFile) { + const fileURL = new URL('./' + dirEntry.name, dirUrl) + virtualModules.set(relPath, decoder.decode(await Deno.readFile(fileURL))) + } + } +} + +await addVirtualModulesFromDir('') +registerCJSModules(virtualRoot, virtualModules) + +const virtualRequire = createRequire(virtualRoot) +const virtualRequireResult = virtualRequire('./entry.js') as RequireResult + +const expectedVirtualRequireResult = { + entry: '/virtual-root/entry.js', + + packageExportsConditionsExportedModule: + '/virtual-root/node_modules/package-exports-conditions/dist/exported-module.js', + packageExportsConditionsRoot: + '/virtual-root/node_modules/package-exports-conditions/root-export.js', + packageExportsConditionsWildcardModuleNoExt: + '/virtual-root/node_modules/package-exports-conditions/dist/wildcard/module.js', + packageExportsConditionsWildcardModuleWithExt: + '/virtual-root/node_modules/package-exports-conditions/dist/wildcard/module.js', + packageExportsExportedModule: + '/virtual-root/node_modules/package-exports/dist/exported-module.js', + packageExportsMainRoot: '/virtual-root/node_modules/package-exports-main/root-export.js', + packageExportsNotAllowedBecauseNotInExportMap: 'ERROR', + packageExportsRoot: '/virtual-root/node_modules/package-exports/root-export.js', + packageExportsSugarRoot: '/virtual-root/node_modules/package-exports-sugar/root-export.js', + packageExportsWildcardModuleNoExt: + '/virtual-root/node_modules/package-exports/dist/wildcard/module.js', + packageExportsWildcardModuleWithExt: + '/virtual-root/node_modules/package-exports/dist/wildcard/module.js', + packageRoot: '/virtual-root/node_modules/package/index.js', + packageInternalModule: '/virtual-root/node_modules/package/internal-module.js', + packageMainRoot: '/virtual-root/node_modules/package-main/main.js', + packageMainInternalModule: '/virtual-root/node_modules/package-main/internal-module.js', +} as RequireResult + +Deno.test('Virtual CJS Module loader matches real CJS Module loader', async (t) => { + // make sure we collect all the possible keys to spot any cases of potentially missing keys in one of the objects + const allTheKeys = [ + ...new Set([ + ...Object.keys(expectedVirtualRequireResult), + ...Object.keys(realRequireResult), + ...Object.keys(virtualRequireResult), + ]), + ] + + function normalizeValue(value: string, basePath: string) { + if (value === 'ERROR') { + return value + } + + return relative(basePath, value) + } + + for (const key of allTheKeys) { + const virtualValue = virtualRequireResult[key] + const realValue = realRequireResult[key] + + // values are filepaths or "ERROR" strings, "real" require has actual file system paths, virtual ones has virtual paths starting with file:///virtual-root/ + // we compare remaining paths to ensure same relative paths are reported indicating that resolution works the same in + // in real CommonJS and simulated one + assertEquals( + normalizeValue(realValue, fixtureRootPath), + normalizeValue(virtualValue, virtualRootPath), + ) + } +}) + +Deno.test('Virtual CJS Module loader matches expected results', async (t) => { + // the main portion of testing functionality is in above assertions that compare real require and virtual one + // below is additional explicit assertion mostly to make sure that test setup is correct + assertEquals(virtualRequireResult, expectedVirtualRequireResult) +}) diff --git a/edge-runtime/lib/cjs.ts b/edge-runtime/lib/cjs.ts new file mode 100644 index 0000000000..00981146d4 --- /dev/null +++ b/edge-runtime/lib/cjs.ts @@ -0,0 +1,330 @@ +import { Module, createRequire } from 'node:module' +import vm from 'node:vm' +import { sep } from 'node:path' +import { join, dirname, sep as posixSep } from 'node:path/posix' +import { fileURLToPath, pathToFileURL } from 'node:url' + +const toPosixPath = (path: string) => path.split(sep).join(posixSep) + +type RegisteredModule = { + source: string + loaded: boolean + filepath: string + // lazily parsed json string + parsedJson?: any +} +type ModuleResolutions = (subpath: string) => string +const registeredModules = new Map() +const memoizedPackageResolvers = new WeakMap() + +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 }) + } +} + +type Condition = string // 'import', 'require', 'default', 'node-addon' etc +type SubpathMatcher = string +type ConditionalTarget = { [key in Condition]: string | ConditionalTarget } +type SubpathTarget = string | ConditionalTarget +/** + * @example + * { + * ".": "./main.js", + * "./foo": { + * "import": "./foo.js", + * "require": "./foo.cjs" + * } + * } + */ +type NormalizedExports = Record> + +// https://github.com/nodejs/node/blob/6fd67ec6e3ccbdfcfa0300b9b742040a0607a4bc/lib/internal/modules/esm/resolve.js#L555 +function isConditionalExportsMainSugar(exports: any) { + if (typeof exports === 'string' || Array.isArray(exports)) { + return true + } + if (typeof exports !== 'object' || exports === null) { + return false + } + + // not doing validation at this point, if the package.json was misconfigured + // we would not get to this point as it would throw when running `next build` + const keys = Object.keys(exports) + return keys.length > 0 && (keys[0] === '' || keys[0][0] !== '.') +} + +// https://github.com/nodejs/node/blob/6fd67ec6e3ccbdfcfa0300b9b742040a0607a4bc/lib/internal/modules/esm/resolve.js#L671 +function patternKeyCompare(a: string, b: string) { + const aPatternIndex = a.indexOf('*') + const bPatternIndex = b.indexOf('*') + const baseLenA = aPatternIndex === -1 ? a.length : aPatternIndex + 1 + const baseLenB = bPatternIndex === -1 ? b.length : bPatternIndex + 1 + if (baseLenA > baseLenB) { + return -1 + } + if (baseLenB > baseLenA) { + return 1 + } + if (aPatternIndex === -1) { + return 1 + } + if (bPatternIndex === -1) { + return -1 + } + if (a.length > b.length) { + return -1 + } + if (b.length > a.length) { + return 1 + } + return 0 +} + +function applyWildcardMatch(target: string, bestMatchSubpath?: string) { + return bestMatchSubpath ? target.replace('*', bestMatchSubpath) : target +} + +// https://github.com/nodejs/node/blob/323f19c18fea06b9234a0c945394447b077fe565/lib/internal/modules/helpers.js#L76 +const conditions = new Set(['require', 'node', 'node-addons', 'default']) + +// https://github.com/nodejs/node/blob/6fd67ec6e3ccbdfcfa0300b9b742040a0607a4bc/lib/internal/modules/esm/resolve.js#L480 +function matchConditions(target: SubpathTarget, bestMatchSubpath?: string) { + if (typeof target === 'string') { + return applyWildcardMatch(target, bestMatchSubpath) + } + + if (Array.isArray(target) && target.length > 0) { + for (const targetItem of target) { + return matchConditions(targetItem, bestMatchSubpath) + } + } + + if (typeof target === 'object' && target !== null) { + for (const [condition, targetValue] of Object.entries(target)) { + if (conditions.has(condition)) { + return matchConditions(targetValue, bestMatchSubpath) + } + } + } + + throw new Error('Invalid package target') +} + +function getPackageResolver(packageJsonMatchedModule: RegisteredModule) { + const memoized = memoizedPackageResolvers.get(packageJsonMatchedModule) + if (memoized) { + return memoized + } + + // https://nodejs.org/api/packages.html#package-entry-points + + const pkgJson = parseJson(packageJsonMatchedModule) + + let exports: NormalizedExports | null = null + if (pkgJson.exports) { + // https://github.com/nodejs/node/blob/6fd67ec6e3ccbdfcfa0300b9b742040a0607a4bc/lib/internal/modules/esm/resolve.js#L590 + exports = isConditionalExportsMainSugar(pkgJson.exports) + ? { '.': pkgJson.exports } + : pkgJson.exports + } + + const resolveInPackage: ModuleResolutions = (subpath: string) => { + if (exports) { + const normalizedSubpath = subpath.length === 0 ? '.' : './' + subpath + + // https://github.com/nodejs/node/blob/6fd67ec6e3ccbdfcfa0300b9b742040a0607a4bc/lib/internal/modules/esm/resolve.js#L594 + // simple case with matching as-is + if ( + normalizedSubpath in exports && + !normalizedSubpath.includes('*') && + !normalizedSubpath.endsWith('/') + ) { + return matchConditions(exports[normalizedSubpath]) + } + + // https://github.com/nodejs/node/blob/6fd67ec6e3ccbdfcfa0300b9b742040a0607a4bc/lib/internal/modules/esm/resolve.js#L610 + let bestMatchKey = '' + let bestMatchSubpath + for (const key of Object.keys(exports)) { + const patternIndex = key.indexOf('*') + if (patternIndex !== -1 && normalizedSubpath.startsWith(key.slice(0, patternIndex))) { + const patternTrailer = key.slice(patternIndex + 1) + if ( + normalizedSubpath.length > key.length && + normalizedSubpath.endsWith(patternTrailer) && + patternKeyCompare(bestMatchKey, key) === 1 && + key.lastIndexOf('*') === patternIndex + ) { + bestMatchKey = key + bestMatchSubpath = normalizedSubpath.slice( + patternIndex, + normalizedSubpath.length - patternTrailer.length, + ) + } + } + } + + if (bestMatchKey && typeof bestMatchSubpath === 'string') { + const matchedTarget = exports[bestMatchKey] + return matchConditions(matchedTarget, bestMatchSubpath) + } + + // if exports are defined, they are source of truth and any imports not allowed by it will fail + throw new Error(`Cannot find module '${normalizedSubpath}'`) + } + + if (subpath.length === 0 && pkgJson.main) { + return pkgJson.main + } + + return subpath + } + + memoizedPackageResolvers.set(packageJsonMatchedModule, resolveInPackage) + + return resolveInPackage +} + +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, { windows: false })) + 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(toPosixPath(fileURLToPath(baseUrl, { windows: false }))) + + 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 = toPosixPath(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 nodeModulePathsRaw of args[1].paths) { + const nodeModulePaths = toPosixPath(nodeModulePathsRaw) + const potentialPackageJson = join(nodeModulePaths, packageName, 'package.json') + + const maybePackageJson = registeredModules.get(potentialPackageJson) + + let relativeTarget = moduleInPackagePath + + if (maybePackageJson) { + const packageResolver = getPackageResolver(maybePackageJson) + + relativeTarget = packageResolver(moduleInPackagePath) + } + + 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/lib/fixture/cjs/.gitignore b/edge-runtime/lib/fixture/cjs/.gitignore new file mode 100644 index 0000000000..66d3a7f503 --- /dev/null +++ b/edge-runtime/lib/fixture/cjs/.gitignore @@ -0,0 +1,2 @@ +!node_modules +!dist diff --git a/edge-runtime/lib/fixture/cjs/entry.js b/edge-runtime/lib/fixture/cjs/entry.js new file mode 100644 index 0000000000..d6274f819c --- /dev/null +++ b/edge-runtime/lib/fixture/cjs/entry.js @@ -0,0 +1,45 @@ +function safeRequire(target) { + try { + return require(target) + } catch (error) { + return 'ERROR' + } +} + +module.exports = { + // myself + entry: __filename, + + // package with no `main` or `exports` + packageRoot: safeRequire('package'), + packageInternalModule: safeRequire('package/internal-module'), + + // package with `main`, but no `exports` + packageMainRoot: safeRequire('package-main'), + packageMainInternalModule: safeRequire('package-main/internal-module'), + + // package with `exports` (no conditions), but no `main` + packageExportsRoot: safeRequire('package-exports'), + packageExportsExportedModule: safeRequire('package-exports/exported-module.js'), + packageExportsWildcardModuleNoExt: safeRequire('package-exports/wildcard/module'), + packageExportsWildcardModuleWithExt: safeRequire('package-exports/wildcard/module.js'), + packageExportsNotAllowedBecauseNotInExportMap: safeRequire('package-exports/not-allowed.js'), + + // package with `exports` (with conditions, including nested ones), but no `main` + packageExportsConditionsRoot: safeRequire('package-exports-conditions'), + packageExportsConditionsExportedModule: safeRequire( + 'package-exports-conditions/exported-module.js', + ), + packageExportsConditionsWildcardModuleNoExt: safeRequire( + 'package-exports-conditions/wildcard/module', + ), + packageExportsConditionsWildcardModuleWithExt: safeRequire( + 'package-exports-conditions/wildcard/module.js', + ), + + // package with `exports` and `main` (exports should win) + packageExportsMainRoot: safeRequire('package-exports-main'), + + // package with `exports` using shorthand / sugar syntax with single export + packageExportsSugarRoot: safeRequire('package-exports-sugar'), +} diff --git a/edge-runtime/lib/fixture/cjs/node_modules/package-exports-conditions/dist/exported-module.js b/edge-runtime/lib/fixture/cjs/node_modules/package-exports-conditions/dist/exported-module.js new file mode 100644 index 0000000000..7e27f6ad0b --- /dev/null +++ b/edge-runtime/lib/fixture/cjs/node_modules/package-exports-conditions/dist/exported-module.js @@ -0,0 +1 @@ +module.exports = __filename diff --git a/edge-runtime/lib/fixture/cjs/node_modules/package-exports-conditions/dist/wildcard/module.js b/edge-runtime/lib/fixture/cjs/node_modules/package-exports-conditions/dist/wildcard/module.js new file mode 100644 index 0000000000..7e27f6ad0b --- /dev/null +++ b/edge-runtime/lib/fixture/cjs/node_modules/package-exports-conditions/dist/wildcard/module.js @@ -0,0 +1 @@ +module.exports = __filename diff --git a/edge-runtime/lib/fixture/cjs/node_modules/package-exports-conditions/package.json b/edge-runtime/lib/fixture/cjs/node_modules/package-exports-conditions/package.json new file mode 100644 index 0000000000..43810ed8fc --- /dev/null +++ b/edge-runtime/lib/fixture/cjs/node_modules/package-exports-conditions/package.json @@ -0,0 +1,19 @@ +{ + "exports": { + ".": { + "import": "./does-not-exist.js", + "require": "./root-export.js" + }, + "./exported-module.js": { + "default": "./dist/exported-module.js" + }, + "./wildcard/*": { + "default": { + "require": "./dist/wildcard/*.js" + } + }, + "./wildcard/*.js": { + "node": "./dist/wildcard/*.js" + } + } +} diff --git a/edge-runtime/lib/fixture/cjs/node_modules/package-exports-conditions/root-export.js b/edge-runtime/lib/fixture/cjs/node_modules/package-exports-conditions/root-export.js new file mode 100644 index 0000000000..7e27f6ad0b --- /dev/null +++ b/edge-runtime/lib/fixture/cjs/node_modules/package-exports-conditions/root-export.js @@ -0,0 +1 @@ +module.exports = __filename diff --git a/edge-runtime/lib/fixture/cjs/node_modules/package-exports-main/main.js b/edge-runtime/lib/fixture/cjs/node_modules/package-exports-main/main.js new file mode 100644 index 0000000000..7e27f6ad0b --- /dev/null +++ b/edge-runtime/lib/fixture/cjs/node_modules/package-exports-main/main.js @@ -0,0 +1 @@ +module.exports = __filename diff --git a/edge-runtime/lib/fixture/cjs/node_modules/package-exports-main/package.json b/edge-runtime/lib/fixture/cjs/node_modules/package-exports-main/package.json new file mode 100644 index 0000000000..633f35c199 --- /dev/null +++ b/edge-runtime/lib/fixture/cjs/node_modules/package-exports-main/package.json @@ -0,0 +1,6 @@ +{ + "main": "./main.js", + "exports": { + ".": "./root-export.js" + } +} diff --git a/edge-runtime/lib/fixture/cjs/node_modules/package-exports-main/root-export.js b/edge-runtime/lib/fixture/cjs/node_modules/package-exports-main/root-export.js new file mode 100644 index 0000000000..7e27f6ad0b --- /dev/null +++ b/edge-runtime/lib/fixture/cjs/node_modules/package-exports-main/root-export.js @@ -0,0 +1 @@ +module.exports = __filename diff --git a/edge-runtime/lib/fixture/cjs/node_modules/package-exports-sugar/package.json b/edge-runtime/lib/fixture/cjs/node_modules/package-exports-sugar/package.json new file mode 100644 index 0000000000..ff32471ad7 --- /dev/null +++ b/edge-runtime/lib/fixture/cjs/node_modules/package-exports-sugar/package.json @@ -0,0 +1,3 @@ +{ + "exports": "./root-export.js" +} diff --git a/edge-runtime/lib/fixture/cjs/node_modules/package-exports-sugar/root-export.js b/edge-runtime/lib/fixture/cjs/node_modules/package-exports-sugar/root-export.js new file mode 100644 index 0000000000..7e27f6ad0b --- /dev/null +++ b/edge-runtime/lib/fixture/cjs/node_modules/package-exports-sugar/root-export.js @@ -0,0 +1 @@ +module.exports = __filename diff --git a/edge-runtime/lib/fixture/cjs/node_modules/package-exports/dist/exported-module.js b/edge-runtime/lib/fixture/cjs/node_modules/package-exports/dist/exported-module.js new file mode 100644 index 0000000000..7e27f6ad0b --- /dev/null +++ b/edge-runtime/lib/fixture/cjs/node_modules/package-exports/dist/exported-module.js @@ -0,0 +1 @@ +module.exports = __filename diff --git a/edge-runtime/lib/fixture/cjs/node_modules/package-exports/dist/wildcard/module.js b/edge-runtime/lib/fixture/cjs/node_modules/package-exports/dist/wildcard/module.js new file mode 100644 index 0000000000..7e27f6ad0b --- /dev/null +++ b/edge-runtime/lib/fixture/cjs/node_modules/package-exports/dist/wildcard/module.js @@ -0,0 +1 @@ +module.exports = __filename diff --git a/edge-runtime/lib/fixture/cjs/node_modules/package-exports/not-allowed.js b/edge-runtime/lib/fixture/cjs/node_modules/package-exports/not-allowed.js new file mode 100644 index 0000000000..7e27f6ad0b --- /dev/null +++ b/edge-runtime/lib/fixture/cjs/node_modules/package-exports/not-allowed.js @@ -0,0 +1 @@ +module.exports = __filename diff --git a/edge-runtime/lib/fixture/cjs/node_modules/package-exports/package.json b/edge-runtime/lib/fixture/cjs/node_modules/package-exports/package.json new file mode 100644 index 0000000000..f406e5d567 --- /dev/null +++ b/edge-runtime/lib/fixture/cjs/node_modules/package-exports/package.json @@ -0,0 +1,8 @@ +{ + "exports": { + ".": "./root-export.js", + "./exported-module.js": "./dist/exported-module.js", + "./wildcard/*": "./dist/wildcard/*.js", + "./wildcard/*.js": "./dist/wildcard/*.js" + } +} diff --git a/edge-runtime/lib/fixture/cjs/node_modules/package-exports/root-export.js b/edge-runtime/lib/fixture/cjs/node_modules/package-exports/root-export.js new file mode 100644 index 0000000000..7e27f6ad0b --- /dev/null +++ b/edge-runtime/lib/fixture/cjs/node_modules/package-exports/root-export.js @@ -0,0 +1 @@ +module.exports = __filename diff --git a/edge-runtime/lib/fixture/cjs/node_modules/package-main/internal-module.js b/edge-runtime/lib/fixture/cjs/node_modules/package-main/internal-module.js new file mode 100644 index 0000000000..7e27f6ad0b --- /dev/null +++ b/edge-runtime/lib/fixture/cjs/node_modules/package-main/internal-module.js @@ -0,0 +1 @@ +module.exports = __filename diff --git a/edge-runtime/lib/fixture/cjs/node_modules/package-main/main.js b/edge-runtime/lib/fixture/cjs/node_modules/package-main/main.js new file mode 100644 index 0000000000..7e27f6ad0b --- /dev/null +++ b/edge-runtime/lib/fixture/cjs/node_modules/package-main/main.js @@ -0,0 +1 @@ +module.exports = __filename diff --git a/edge-runtime/lib/fixture/cjs/node_modules/package-main/package.json b/edge-runtime/lib/fixture/cjs/node_modules/package-main/package.json new file mode 100644 index 0000000000..654aaa6caf --- /dev/null +++ b/edge-runtime/lib/fixture/cjs/node_modules/package-main/package.json @@ -0,0 +1,5 @@ +{ + "name": "package-main", + "main": "./main.js", + "type": "commonjs" +} diff --git a/edge-runtime/lib/fixture/cjs/node_modules/package/index.js b/edge-runtime/lib/fixture/cjs/node_modules/package/index.js new file mode 100644 index 0000000000..7e27f6ad0b --- /dev/null +++ b/edge-runtime/lib/fixture/cjs/node_modules/package/index.js @@ -0,0 +1 @@ +module.exports = __filename diff --git a/edge-runtime/lib/fixture/cjs/node_modules/package/internal-module.js b/edge-runtime/lib/fixture/cjs/node_modules/package/internal-module.js new file mode 100644 index 0000000000..7e27f6ad0b --- /dev/null +++ b/edge-runtime/lib/fixture/cjs/node_modules/package/internal-module.js @@ -0,0 +1 @@ +module.exports = __filename diff --git a/edge-runtime/lib/fixture/cjs/node_modules/package/package.json b/edge-runtime/lib/fixture/cjs/node_modules/package/package.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/edge-runtime/lib/fixture/cjs/node_modules/package/package.json @@ -0,0 +1 @@ +{} diff --git a/edge-runtime/lib/fixture/cjs/package.json b/edge-runtime/lib/fixture/cjs/package.json new file mode 100644 index 0000000000..98d1d6e26d --- /dev/null +++ b/edge-runtime/lib/fixture/cjs/package.json @@ -0,0 +1,5 @@ +{ + "name": "cjs-fixture", + "private": true, + "type": "commonjs" +} 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/package.json b/package.json index 51f5f97cf6..01cde5462a 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,8 @@ "edge-runtime", "!edge-runtime/deno.json", "!edge-runtime/deno.lock", + "!edge-runtime/**/*.test.ts", + "!edge-runtime/lib/fixture", "manifest.yml" ], "engines": { 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..5dfae48b7e 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/posix' 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/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..b4b5a347d9
--- /dev/null
+++ b/tests/fixtures/middleware-conditions/middleware-shared.ts
@@ -0,0 +1,13 @@
+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)
+  // @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
+}
diff --git a/tests/fixtures/middleware-conditions/middleware.ts b/tests/fixtures/middleware-conditions/middleware.ts
index fdb332cf8e..0e610af64a 100644
--- a/tests/fixtures/middleware-conditions/middleware.ts
+++ b/tests/fixtures/middleware-conditions/middleware.ts
@@ -1,13 +1,4 @@
-import type { NextRequest } from 'next/server'
-import { NextResponse } from 'next/server'
-
-export function middleware(request: NextRequest) {
-  const response: NextResponse = NextResponse.next()
-
-  response.headers.set('x-hello-from-middleware-res', 'hello')
-
-  return response
-}
+export { middleware } from './middleware-shared'
 
 export const config = {
   matcher: [
diff --git a/tests/fixtures/middleware-conditions/next.config.js b/tests/fixtures/middleware-conditions/next.config.js
index 4cb9dfb916..d14c96ccd1 100644
--- a/tests/fixtures/middleware-conditions/next.config.js
+++ b/tests/fixtures/middleware-conditions/next.config.js
@@ -1,6 +1,7 @@
 /** @type {import('next').NextConfig} */
 const nextConfig = {
   output: 'standalone',
+  distDir: process.env.NEXT_DIST_DIR ?? '.next',
   i18n: {
     locales: ['en', 'fr', 'nl', 'es'],
     defaultLocale: 'en',
@@ -8,6 +9,9 @@ const nextConfig = {
   eslint: {
     ignoreDuringBuilds: true,
   },
+  experimental: {
+    nodeMiddleware: true,
+  },
   outputFileTracingRoot: __dirname,
 }
 
diff --git a/tests/fixtures/middleware-conditions/package.json b/tests/fixtures/middleware-conditions/package.json
index 76019a6f08..f8a6e2125b 100644
--- a/tests/fixtures/middleware-conditions/package.json
+++ b/tests/fixtures/middleware-conditions/package.json
@@ -3,9 +3,9 @@
   "version": "0.1.0",
   "private": true,
   "scripts": {
-    "postinstall": "next build",
+    "postinstall": "npm run build",
     "dev": "next dev",
-    "build": "next build"
+    "build": "node ../../utils/build-variants.mjs"
   },
   "dependencies": {
     "next": "latest",
diff --git a/tests/fixtures/middleware-conditions/test-variants.json b/tests/fixtures/middleware-conditions/test-variants.json
new file mode 100644
index 0000000000..f31d535c28
--- /dev/null
+++ b/tests/fixtures/middleware-conditions/test-variants.json
@@ -0,0 +1,21 @@
+{
+  "node-middleware": {
+    "distDir": ".next-node-middleware",
+    "files": {
+      "middleware.ts": "middleware-node.ts"
+    },
+    "test": {
+      "dependencies": {
+        "next": [
+          {
+            "versionConstraint": ">=15.2.0",
+            "canaryOnly": true
+          },
+          {
+            "versionConstraint": ">=15.5.0"
+          }
+        ]
+      }
+    }
+  }
+}
diff --git a/tests/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-node/app/layout.js b/tests/fixtures/middleware-node-runtime-specific/app/layout.js
similarity index 100%
rename from tests/fixtures/middleware-node/app/layout.js
rename to tests/fixtures/middleware-node-runtime-specific/app/layout.js
diff --git a/tests/fixtures/middleware-node/app/page.js b/tests/fixtures/middleware-node-runtime-specific/app/page.js
similarity index 100%
rename from tests/fixtures/middleware-node/app/page.js
rename to tests/fixtures/middleware-node-runtime-specific/app/page.js
diff --git a/tests/fixtures/middleware-node-runtime-specific/middleware.ts b/tests/fixtures/middleware-node-runtime-specific/middleware.ts
new file mode 100644
index 0000000000..94b72b14b0
--- /dev/null
+++ b/tests/fixtures/middleware-node-runtime-specific/middleware.ts
@@ -0,0 +1,60 @@
+import { randomBytes } from 'node:crypto'
+import { request as httpRequest } from 'node:http'
+import { request as httpsRequest } from 'node:https'
+import { join } from 'node:path'
+
+import { NextResponse } from 'next/server'
+import type { NextRequest } from 'next/server'
+
+export async function middleware(request: NextRequest) {
+  // this middleware is using Node.js APIs that are not available in Edge Runtime in very simple way to assert support for them
+  if (request.nextUrl.pathname === '/test/crypto') {
+    return NextResponse.json({ random: randomBytes(16).toString('hex') })
+  }
+
+  if (request.nextUrl.pathname === '/test/http') {
+    const body = await new Promise((resolve, reject) => {
+      const origin =
+        typeof Netlify !== 'undefined'
+          ? `https://${Netlify.context.deploy.id}--${Netlify.context.site.name}.netlify.app`
+          : `http://localhost:3000`
+
+      const target = new URL('/http-test-target.json', origin)
+
+      const httpOrHttpsRequest = target.protocol === 'https:' ? httpsRequest : httpRequest
+
+      const req = httpOrHttpsRequest(target, (res) => {
+        if (res.statusCode !== 200) {
+          reject(new Error(`Failed to fetch ${target}: ${res.statusCode}`))
+          // Consume response data to free up memory
+          res.resume()
+          return
+        }
+
+        res.setEncoding('utf8')
+        let rawData = ''
+        res.on('data', (chunk) => {
+          rawData += chunk
+        })
+        res.on('end', () => {
+          try {
+            resolve(JSON.parse(rawData))
+          } catch (e) {
+            reject(e)
+          }
+        })
+      })
+      req.end()
+      console.log({ target })
+    })
+    return NextResponse.json({ proxiedWithHttpRequest: body })
+  }
+
+  if (request.nextUrl.pathname === '/test/path') {
+    return NextResponse.json({ joined: join('a', 'b') })
+  }
+}
+
+export const config = {
+  runtime: 'nodejs',
+}
diff --git a/tests/fixtures/middleware-node/next.config.js b/tests/fixtures/middleware-node-runtime-specific/next.config.js
similarity index 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-unsupported-cpp-addons/app/layout.js b/tests/fixtures/middleware-node-unsupported-cpp-addons/app/layout.js
new file mode 100644
index 0000000000..6565e7bafd
--- /dev/null
+++ b/tests/fixtures/middleware-node-unsupported-cpp-addons/app/layout.js
@@ -0,0 +1,12 @@
+export const metadata = {
+  title: 'Simple Next App',
+  description: 'Description for Simple Next App',
+}
+
+export default function RootLayout({ children }) {
+  return (
+    
+      {children}
+    
+  )
+}
diff --git a/tests/fixtures/middleware-node-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..79760f16f0 --- /dev/null +++ b/tests/fixtures/middleware-src/src/middleware-shared.ts @@ -0,0 +1,34 @@ +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) + // @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 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..8bbcd7ebfb --- /dev/null +++ b/tests/fixtures/middleware-trailing-slash/middleware-shared.ts @@ -0,0 +1,61 @@ +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) + // @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 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..03c2828b32 100644 --- a/tests/fixtures/middleware/next.config.js +++ b/tests/fixtures/middleware/next.config.js @@ -1,13 +1,21 @@ /** @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 + if (process.env.SPLIT_CHUNKS) { + // this doesn't seem to actually work with Node Middleware - it result in next build failures + // so we only do this for default/Edge Runtime + 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..470390f274 --- /dev/null +++ b/tests/fixtures/middleware/test-variants.json @@ -0,0 +1,26 @@ +{ + "default": { + "env": { + "SPLIT_CHUNKS": "true" + } + }, + "node-middleware": { + "distDir": ".next-node-middleware", + "files": { + "middleware.ts": "middleware-node.ts" + }, + "test": { + "dependencies": { + "next": [ + { + "versionConstraint": ">=15.2.0", + "canaryOnly": true + }, + { + "versionConstraint": ">=15.5.0" + } + ] + } + } + } +} diff --git a/tests/integration/edge-handler.test.ts b/tests/integration/edge-handler.test.ts 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..5e8e7a2812 --- /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(/node_modules\/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/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 58bb5ee7c4..1afb74aa5d 100644 --- a/tests/utils/next-version-helpers.mjs +++ b/tests/utils/next-version-helpers.mjs @@ -45,6 +45,10 @@ export function shouldHaveAppRouterGlobalErrorInPrerenderManifest() { 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