import type { API, ASTPath, Collection, FileInfo, ObjectExpression, ObjectProperty, Property, SpreadElement, SpreadProperty, ObjectMethod, } from 'jscodeshift' import fs from 'fs' import { join, parse } from 'path' import { isNextConfigFile } from './lib/utils' import { createParserFromPath } from '../lib/parser' // Middleware config properties that need to be renamed to proxy equivalents const CONFIG_PROPERTY_MAP = { middlewarePrefetch: 'proxyPrefetch', middlewareClientMaxBodySize: 'proxyClientMaxBodySize', externalMiddlewareRewritesResolve: 'externalProxyRewritesResolve', skipMiddlewareUrlNormalize: 'skipProxyUrlNormalize', } // Type imports from 'next/server' that need to be transformed const MIDDLEWARE_TYPE_IMPORT_MAP = { NextMiddleware: 'NextProxy', MiddlewareConfig: 'ProxyConfig', } export default function transformer(file: FileInfo) { const j = createParserFromPath(file.path) const root = j(file.source) if (!root.length) { return file.source } const isNextConfig = isNextConfigFile(file) || (process.env.NODE_ENV === 'test' && /next-config-/.test(file.path)) const isMiddlewareFile = /(^|[/\\])middleware\.|[/\\]src[/\\]middleware\./.test(file.path) || (process.env.NODE_ENV === 'test' && !isNextConfig) const hasMiddlewareTypeImports = checkForNextServerTypeImports(root, j) // In test mode, process all files. Otherwise, only process relevant files if (process.env.NODE_ENV !== 'test') { if (!isMiddlewareFile && !isNextConfig && !hasMiddlewareTypeImports) { return file.source } } let hasChanges = false if (hasMiddlewareTypeImports) { const typeImportChanges = transformMiddlewareTypeImports(root, j) hasChanges = hasChanges || typeImportChanges } if (isMiddlewareFile) { const middlewareChanges = transformMiddlewareFunction(root, j) hasChanges = hasChanges || middlewareChanges.hasChanges // Remove runtime segment config const runtimeChanges = removeRuntimeConfig(root, j) hasChanges = hasChanges || runtimeChanges } if (isNextConfig) { const { hasConfigChanges } = transformNextConfig(root, j) hasChanges = hasChanges || hasConfigChanges } if (!hasChanges) { return file.source } const source = root.toSource() // Need to write proxy file and unlink the original middleware file. if (isMiddlewareFile) { return handleMiddlewareFileRename(file, source) } return source } function checkForNextServerTypeImports( root: Collection, j: API['j'] ): boolean { return ( root .find(j.ImportDeclaration, { source: { value: 'next/server' }, }) .find(j.ImportSpecifier) .filter( (path: ASTPath) => MIDDLEWARE_TYPE_IMPORT_MAP[path.node.imported.name] ).length > 0 ) } function transformMiddlewareTypeImports( root: Collection, j: API['j'] ): boolean { let hasChanges = false // Transform type imports from 'next/server' root .find(j.ImportDeclaration, { source: { value: 'next/server' }, }) .forEach((importPath: ASTPath) => { const specifiers = importPath.node.specifiers if (!specifiers) return specifiers.forEach((specifier: any) => { if ( j.ImportSpecifier.check(specifier) && specifier.imported && MIDDLEWARE_TYPE_IMPORT_MAP[specifier.imported.name] ) { const oldImportName = specifier.imported.name const newImportName = MIDDLEWARE_TYPE_IMPORT_MAP[oldImportName] // Update the local name if it matches the original imported name if (specifier.local && specifier.local.name === oldImportName) { specifier.local.name = newImportName } // Transform the import name specifier.imported.name = newImportName hasChanges = true } }) }) // Also transform any type annotations using the old types Object.keys(MIDDLEWARE_TYPE_IMPORT_MAP).forEach((oldType) => { root .find(j.TSTypeReference) .filter((path: ASTPath) => { return ( path.node.typeName && path.node.typeName.type === 'Identifier' && path.node.typeName.name === oldType ) }) .forEach((path: ASTPath) => { path.node.typeName.name = MIDDLEWARE_TYPE_IMPORT_MAP[oldType] hasChanges = true }) }) return hasChanges } function transformNextConfig( root: Collection, j: API['j'] ): { hasConfigChanges: boolean } { let hasConfigChanges = false // Collect config-related object expressions instead of processing all const configObjects = findNextConfigObjects(root, j) configObjects.forEach((objPath: ASTPath) => { const result = processConfigObject(objPath.value) hasConfigChanges = hasConfigChanges || result.hasChanges }) // Process function configurations that are likely to be Next.js config const configFunctions = findNextConfigFunctions(root, j) configFunctions.forEach((path: ASTPath) => { const result = processFunctionConfig(path, j) hasConfigChanges = hasConfigChanges || result.hasChanges }) // Process arrow function configurations that are likely to be Next.js config const configArrowFunctions = findNextConfigArrowFunctions(root, j) configArrowFunctions.forEach((path: ASTPath) => { const result = processArrowFunctionConfig(path, j) hasConfigChanges = hasConfigChanges || result.hasChanges }) // Process direct property assignments: config.experimental.middlewarePrefetch = value Object.keys(CONFIG_PROPERTY_MAP).forEach((oldProp) => { const newProp = CONFIG_PROPERTY_MAP[oldProp] // Handle experimental.* properties if ( oldProp.startsWith('middleware') && oldProp !== 'skipMiddlewareUrlNormalize' ) { root .find(j.AssignmentExpression, { left: { type: 'MemberExpression', object: { type: 'MemberExpression', property: { name: 'experimental' }, }, property: { name: oldProp }, }, }) .forEach((path: ASTPath) => { path.node.left.property.name = newProp hasConfigChanges = true }) } else { // Handle top-level properties like skipMiddlewareUrlNormalize root .find(j.AssignmentExpression, { left: { type: 'MemberExpression', property: { name: oldProp }, }, }) .forEach((path: ASTPath) => { path.node.left.property.name = newProp hasConfigChanges = true }) } }) return { hasConfigChanges } } function processConfigObject(configObj: ObjectExpression): { hasChanges: boolean } { let hasChanges = false // Check for experimental property const experimentalProp = configObj.properties.find( (prop) => isStaticProperty(prop) && prop.key && prop.key.type === 'Identifier' && prop.key.name === 'experimental' ) if (experimentalProp && isStaticProperty(experimentalProp)) { const experimentalObj = experimentalProp.value if (experimentalObj.type === 'ObjectExpression') { // Transform properties in experimental object experimentalObj.properties.forEach((prop) => { if ( isStaticProperty(prop) && prop.key && prop.key.type === 'Identifier' && CONFIG_PROPERTY_MAP[prop.key.name] && prop.key.name !== 'skipMiddlewareUrlNormalize' // This is top-level ) { prop.key.name = CONFIG_PROPERTY_MAP[prop.key.name] hasChanges = true } }) } } // Transform top-level properties configObj.properties.forEach((prop) => { if ( isStaticProperty(prop) && prop.key && prop.key.type === 'Identifier' && prop.key.name === 'skipMiddlewareUrlNormalize' ) { prop.key.name = CONFIG_PROPERTY_MAP[prop.key.name] hasChanges = true } }) // Also transform any top-level middleware properties (for spread scenarios) configObj.properties.forEach((prop) => { if ( isStaticProperty(prop) && prop.key && prop.key.type === 'Identifier' && CONFIG_PROPERTY_MAP[prop.key.name] && prop.key.name !== 'skipMiddlewareUrlNormalize' // Already handled above ) { prop.key.name = CONFIG_PROPERTY_MAP[prop.key.name] hasChanges = true } }) return { hasChanges } } function processFunctionConfig( path: ASTPath, j: API['j'] ): { hasChanges: boolean } { let hasChanges = false // Look for return statements with object expressions j(path) .find(j.ReturnStatement) .forEach((returnPath: ASTPath) => { if ( returnPath.node.argument && returnPath.node.argument.type === 'ObjectExpression' ) { const result = processConfigObject(returnPath.node.argument) hasChanges = hasChanges || result.hasChanges } }) return { hasChanges } } function processArrowFunctionConfig( path: ASTPath, j: API['j'] ): { hasChanges: boolean } { let hasChanges = false const body = path.node.body // Handle: () => ({ ... }) if (body && body.type === 'ObjectExpression') { const result = processConfigObject(body) hasChanges = hasChanges || result.hasChanges } // Handle: () => { return { ... } } if (body && body.type === 'BlockStatement') { j(path) .find(j.ReturnStatement) .forEach((returnPath: ASTPath) => { if ( returnPath.node.argument && returnPath.node.argument.type === 'ObjectExpression' ) { const result = processConfigObject(returnPath.node.argument) hasChanges = hasChanges || result.hasChanges } }) } return { hasChanges } } function transformMiddlewareFunction( root: Collection, j: API['j'] ): { hasChanges: boolean } { const proxyIdentifier = generateUniqueIdentifier(root, j, 'proxy') const needsAlias = proxyIdentifier !== 'proxy' let hasChanges = false // Track if we exported something as 'proxy' let exportedAsProxy = false // Handle named export declarations root.find(j.ExportNamedDeclaration).forEach((nodePath) => { const declaration = nodePath.node.declaration // Handle: export function middleware() {} or export async function middleware() {} if ( j.FunctionDeclaration.check(declaration) && declaration.id?.name === 'middleware' ) { declaration.id.name = proxyIdentifier exportedAsProxy = true // Exported function declarations become proxy hasChanges = true } // Handle: export { middleware } if (nodePath.node.specifiers) { nodePath.node.specifiers.forEach((specifier) => { if ( j.ExportSpecifier.check(specifier) && j.Identifier.check(specifier.local) && specifier.local.name === 'middleware' ) { // Check if this is exporting middleware as 'middleware' (which should become 'proxy') if ( j.Identifier.check(specifier.exported) && specifier.exported.name === 'middleware' ) { if (needsAlias) { // Create export alias: export { _proxy1 as proxy } const newSpecifier = j.exportSpecifier.from({ local: j.identifier(proxyIdentifier), exported: j.identifier('proxy'), }) // Replace in the specifiers array const specifierIndex = nodePath.node.specifiers.indexOf(specifier) nodePath.node.specifiers[specifierIndex] = newSpecifier } else { // Simple rename: export { proxy } specifier.exported = j.identifier('proxy') specifier.local = j.identifier('proxy') } exportedAsProxy = true hasChanges = true } else { // This is exporting middleware as something else (e.g., export { middleware as randomName }) // Just update the local reference to the new identifier specifier.local = j.identifier(proxyIdentifier) hasChanges = true } } }) } }) // Handle default export declarations root.find(j.ExportDefaultDeclaration).forEach((nodePath) => { const declaration = nodePath.node.declaration // Handle: export default function middleware() {} or export default async function middleware() {} if ( j.FunctionDeclaration.check(declaration) && declaration.id?.name === 'middleware' ) { declaration.id.name = proxyIdentifier hasChanges = true } }) // Handle function declarations that are later exported root .find(j.FunctionDeclaration, { id: { name: 'middleware' }, }) .forEach((nodePath) => { if (nodePath.node.id) { nodePath.node.id.name = proxyIdentifier hasChanges = true } }) // Handle variable declarations: const middleware = ... root .find(j.VariableDeclarator, { id: { name: 'middleware' }, }) .forEach((nodePath) => { if (j.Identifier.check(nodePath.node.id)) { nodePath.node.id.name = proxyIdentifier hasChanges = true } }) // Update all references to middleware in the scope if (hasChanges && needsAlias) { root .find(j.Identifier, { name: 'middleware' }) .filter((astPath: ASTPath) => { // Don't rename if it's part of an export specifier we already handled const parent = astPath.parent if (j.ExportSpecifier.check(parent.node)) { return false } // Don't rename if it's a function/variable declaration we already handled if ( (j.FunctionDeclaration.check(parent.node) && parent.node.id === astPath.node) || (j.VariableDeclarator.check(parent.node) && parent.node.id === astPath.node) ) { return false } return true }) .forEach((astPath: ASTPath) => { astPath.node.name = proxyIdentifier }) } // If we used a unique identifier AND we exported `as proxy`, add an export alias // This handles cases where the export was part of the declaration itself: // export function middleware() {} -> export function _proxy1() {} (needs alias) // vs cases where export was separate: // export { middleware } -> export { _proxy1 as proxy } (already handled) if (needsAlias && hasChanges && exportedAsProxy) { // Check if we already created a proxy export (from export specifiers like `export { middleware }`) const hasExportSpecifier = root.find(j.ExportNamedDeclaration).filter((astPath: ASTPath) => { return ( astPath.node.specifiers && astPath.node.specifiers.some( (spec) => j.ExportSpecifier.check(spec) && j.Identifier.check(spec.exported) && spec.exported.name === 'proxy' ) ) }).length > 0 // If no proxy export exists yet, create one to maintain the 'proxy' API // Example: export function _proxy1() {} + export { _proxy1 as proxy } if (!hasExportSpecifier) { const exportSpecifier = j.exportSpecifier.from({ local: j.identifier(proxyIdentifier), exported: j.identifier('proxy'), }) const exportDeclaration = j.exportNamedDeclaration(null, [ exportSpecifier, ]) // Add the export at the end of the file const program = root.find(j.Program) if (program.length > 0) { program.get('body').value.push(exportDeclaration) } } } return { hasChanges } } function handleMiddlewareFileRename(file: FileInfo, source: string): string { // We will not modify the original file in real world, // so return the source here for testing. if (process.env.NODE_ENV === 'test') { return source } const { dir, ext } = parse(file.path) const newFilePath = join(dir, 'proxy' + ext) try { fs.writeFileSync(newFilePath, source) fs.unlinkSync(file.path) // Return empty string to indicate successful file replacement. return '' } catch (cause) { console.error( `Failed to write "${newFilePath}" and delete "${file.path}".\n${JSON.stringify({ cause })}` ) return file.source } } function isStaticProperty( prop: | Property | ObjectProperty | SpreadElement | SpreadProperty | ObjectMethod ): prop is Property | ObjectProperty { return prop.type === 'Property' || prop.type === 'ObjectProperty' } function generateUniqueIdentifier( root: Collection, j: API['j'], baseName: string ): string { // First check if baseName itself is available if (!hasIdentifierInScope(root, j, baseName)) { return baseName } // Generate _proxy1, _proxy2, etc. let counter = 1 while (true) { const candidate = `_${baseName}${counter}` if (!hasIdentifierInScope(root, j, candidate)) { return candidate } counter++ } } function hasIdentifierInScope( root: Collection, j: API['j'], name: string ): boolean { // Check for variable declarations const hasVariableDeclaration = root .find(j.VariableDeclarator) .filter( (astPath: ASTPath) => j.Identifier.check(astPath.value.id) && astPath.value.id.name === name ).length > 0 // Check for function declarations const hasFunctionDeclaration = root .find(j.FunctionDeclaration) .filter( (astPath: ASTPath) => astPath.value.id && astPath.value.id.name === name ).length > 0 // Check for import specifiers const hasImportSpecifier = root .find(j.ImportSpecifier) .filter( (astPath: ASTPath) => j.Identifier.check(astPath.value.local) && astPath.value.local.name === name ).length > 0 return hasVariableDeclaration || hasFunctionDeclaration || hasImportSpecifier } function findNextConfigObjects( root: Collection, j: API['j'] ): ASTPath[] { const configObjects: ASTPath[] = [] // Find identifiers that are exported as default or assigned to module.exports const exportedNames = new Set() // Handle: export default nextConfig or export default wrappedFunction(nextConfig) root.find(j.ExportDefaultDeclaration).forEach((path: ASTPath) => { if (j.Identifier.check(path.node.declaration)) { exportedNames.add(path.node.declaration.name) } else if (j.ObjectExpression.check(path.node.declaration)) { // Direct object export: export default { ... } configObjects.push(path.get('declaration')) } else if (j.CallExpression.check(path.node.declaration)) { // Handle wrapped exports: export default wrapper(config) extractObjectsFromCallExpression( path.node.declaration, configObjects, exportedNames, j ) } }) // Handle: module.exports = nextConfig or module.exports = wrappedFunction(nextConfig) root .find(j.AssignmentExpression, { left: { type: 'MemberExpression', object: { name: 'module' }, property: { name: 'exports' }, }, }) .forEach((path: ASTPath) => { if (j.Identifier.check(path.node.right)) { exportedNames.add(path.node.right.name) } else if (j.ObjectExpression.check(path.node.right)) { // Direct object assignment: module.exports = { ... } configObjects.push(path.get('right')) } else if (j.CallExpression.check(path.node.right)) { // Handle wrapped assignments: module.exports = wrapper(config) extractObjectsFromCallExpression( path.node.right, configObjects, exportedNames, j ) } }) // Find variable declarations for exported names exportedNames.forEach((name) => { root .find(j.VariableDeclarator, { id: { name } }) .forEach((path: ASTPath) => { if (j.ObjectExpression.check(path.node.init)) { configObjects.push(path.get('init')) } }) }) return configObjects } function findNextConfigFunctions( root: Collection, j: API['j'] ): ASTPath[] { const configFunctions: ASTPath[] = [] const exportedNames = new Set() // Handle: export default function or export default functionName root.find(j.ExportDefaultDeclaration).forEach((path: ASTPath) => { if (j.FunctionDeclaration.check(path.node.declaration)) { // export default function configFunction() { ... } configFunctions.push(path.get('declaration')) } else if (j.Identifier.check(path.node.declaration)) { exportedNames.add(path.node.declaration.name) } }) // Handle: module.exports = function root .find(j.AssignmentExpression, { left: { type: 'MemberExpression', object: { name: 'module' }, property: { name: 'exports' }, }, }) .forEach((path: ASTPath) => { if (j.FunctionExpression.check(path.node.right)) { // module.exports = function() { ... } configFunctions.push(path.get('right')) } else if (j.Identifier.check(path.node.right)) { exportedNames.add(path.node.right.name) } }) // Find function declarations for exported names exportedNames.forEach((name) => { root .find(j.FunctionDeclaration, { id: { name } }) .forEach((path: ASTPath) => { configFunctions.push(path) }) }) return configFunctions } function findNextConfigArrowFunctions( root: Collection, j: API['j'] ): ASTPath[] { const configArrowFunctions: ASTPath[] = [] const exportedNames = new Set() // Handle: export default arrowFunction root.find(j.ExportDefaultDeclaration).forEach((path: ASTPath) => { if (j.ArrowFunctionExpression.check(path.node.declaration)) { // export default () => { ... } configArrowFunctions.push(path.get('declaration')) } else if (j.Identifier.check(path.node.declaration)) { exportedNames.add(path.node.declaration.name) } }) // Handle: module.exports = arrowFunction root .find(j.AssignmentExpression, { left: { type: 'MemberExpression', object: { name: 'module' }, property: { name: 'exports' }, }, }) .forEach((path: ASTPath) => { if (j.ArrowFunctionExpression.check(path.node.right)) { // module.exports = () => { ... } configArrowFunctions.push(path.get('right')) } else if (j.Identifier.check(path.node.right)) { exportedNames.add(path.node.right.name) } }) // Find variable declarations with arrow functions for exported names exportedNames.forEach((name) => { root .find(j.VariableDeclarator, { id: { name } }) .forEach((path: ASTPath) => { if (j.ArrowFunctionExpression.check(path.node.init)) { configArrowFunctions.push(path.get('init')) } }) }) return configArrowFunctions } function extractObjectsFromCallExpression( callExpr: any, configObjects: ASTPath[], exportedNames: Set, j: API['j'] ): void { // Recursively extract arguments from call expressions // E.g., wrapper(anotherWrapper(config)) or wrapper(config) if (callExpr.arguments) { callExpr.arguments.forEach((arg: any) => { if (j.Identifier.check(arg)) { exportedNames.add(arg.name) } else if (j.ObjectExpression.check(arg)) { // This would be unusual but handle direct object arguments // We don't have the path here, so we'll skip this case // It would be handled by the direct export case anyway } else if (j.CallExpression.check(arg)) { extractObjectsFromCallExpression(arg, configObjects, exportedNames, j) } }) } } function removeRuntimeConfig(root: Collection, j: API['j']): boolean { let hasChanges = false // Remove export const runtime = 'string' const directRuntimeExports = root.find(j.ExportNamedDeclaration, { declaration: { type: 'VariableDeclaration', declarations: [ { id: { name: 'runtime' }, }, ], }, }) if (directRuntimeExports.size() > 0) { directRuntimeExports.remove() hasChanges = true } // Remove const runtime = 'string' declarations const runtimeVariableDeclarations = root .find(j.VariableDeclaration) .filter((path) => path.node.declarations.some((decl) => { if (j.VariableDeclarator.check(decl) && j.Identifier.check(decl.id)) { return decl.id.name === 'runtime' } return false }) ) if (runtimeVariableDeclarations.size() > 0) { runtimeVariableDeclarations.forEach((path) => { const originalDeclarations = path.node.declarations const filteredDeclarations = originalDeclarations.filter((decl) => { if (j.VariableDeclarator.check(decl) && j.Identifier.check(decl.id)) { return decl.id.name !== 'runtime' } return true }) // If we filtered out some declarations, update the node if (filteredDeclarations.length !== originalDeclarations.length) { // Remove the entire declaration only if no declarators left if (filteredDeclarations.length === 0) { j(path).remove() } else { path.node.declarations = filteredDeclarations } } }) hasChanges = true } // Handle export { runtime } and export { runtime, other } const namedExports = root .find(j.ExportNamedDeclaration) .filter((path) => path.node.specifiers && path.node.specifiers.length > 0) namedExports.forEach((path) => { const specifiers = path.node.specifiers if (!specifiers) return const filteredSpecifiers = specifiers.filter((spec) => { if (j.ExportSpecifier.check(spec) && j.Identifier.check(spec.local)) { return spec.local.name !== 'runtime' } return true }) // If we removed any specifiers if (filteredSpecifiers.length !== specifiers.length) { hasChanges = true // If no specifiers left, remove the entire export statement if (filteredSpecifiers.length === 0) { j(path).remove() } else { // Update the specifiers array path.node.specifiers = filteredSpecifiers } } }) // Handle runtime property in config objects const configExports = root.find(j.ExportNamedDeclaration, { declaration: { type: 'VariableDeclaration', declarations: [ { id: { name: 'config' }, }, ], }, }) configExports.forEach((path) => { const declaration = path.node.declaration if (j.VariableDeclaration.check(declaration)) { declaration.declarations.forEach((decl) => { if ( j.VariableDeclarator.check(decl) && j.Identifier.check(decl.id) && decl.id.name === 'config' && j.ObjectExpression.check(decl.init) ) { const objExpr = decl.init const initialLength = objExpr.properties.length // Filter out runtime property objExpr.properties = objExpr.properties.filter((prop) => { if ( isStaticProperty(prop) && prop.key && prop.key.type === 'Identifier' ) { return prop.key.name !== 'runtime' } return true }) // If we removed any properties if (objExpr.properties.length !== initialLength) { hasChanges = true // If no properties left, remove the entire config export if (objExpr.properties.length === 0) { j(path).remove() } } } }) } }) return hasChanges }