const assert = require('assert')
const path = require('path')
const recast = require('recast')
const astUtil = require('ast-util-plus')
const b = recast.types.builders
const resolve = require('resolve')
const acorn = require('recast/parsers/acorn')
const GLOBALS = new Set(['global', 'window', 'process', 'document', 'console'])
const NODE_CORE_MODULES = new Set([
'assert', 'buffer', 'child_process', 'cluster', 'constants', 'crypto',
'dgram', 'dns', 'domain', 'events', 'fs', 'http', 'https', 'module', 'net',
'os', 'path', 'punycode', 'querystring', 'readline', 'repl', 'stream',
'string_decoder', 'tls', 'tty', 'url', 'util', 'v8', 'vm', 'zlib'
])
module.exports = class FileRequireTransform {
constructor (options) {
this.options = options
}
apply () {
let source = this.options.source
if (this.options.filePath && path.extname(this.options.filePath) === '.json') {
source = "module.exports = " + source.replace(/\u2028/g, '\\u2028').replace(/\u2029/g, '\\u2029')
}
this.ast = recast.parse(source, {
parser: {
parse(source) {
return acorn.parse(source, {
ecmaVersion: 2020,
sourceType: 'script'
})
}
}
})
this.lazyRequireFunctionsByVariableName = new Map()
this.replaceDeferredRequiresWithLazyFunctions()
this.replaceReferencesToDeferredRequiresWithFunctionCalls()
this.replaceReferencesToGlobalsWithFunctionCalls()
return recast.print(this.ast).code
}
replaceDeferredRequiresWithLazyFunctions () {
const self = this
recast.types.visit(this.ast, {
visitCallExpression: function (astPath) {
if (isStaticRequire(astPath)) {
const moduleName = astPath.node.arguments[0].value
const absoluteModulePath = self.resolveModulePath(moduleName)
if (absoluteModulePath) {
let relativeModulePath = path.relative(self.options.baseDirPath, absoluteModulePath).replace(/\\/g, '/')
if (!relativeModulePath.startsWith('.')) {
relativeModulePath = './' + relativeModulePath
}
astPath.get('arguments', 0).replace(b.literal(relativeModulePath))
}
const deferRequire = NODE_CORE_MODULES.has(moduleName) || self.options.didFindRequire(moduleName, absoluteModulePath || moduleName)
if (deferRequire && isTopLevelASTPath(astPath)) {
self.replaceAssignmentOrDeclarationWithLazyFunction(astPath)
}
} else if (isStaticRequireResolve(astPath)) {
const moduleName = astPath.node.arguments[0].value
const absoluteModulePath = self.resolveModulePath(moduleName)
if (absoluteModulePath) {
let relativeModulePath = path.relative(self.options.baseDirPath, absoluteModulePath).replace(/\\/g, '/')
if (!relativeModulePath.startsWith('.')) {
relativeModulePath = './' + relativeModulePath
}
astPath.get('arguments', 0).replace(b.literal(relativeModulePath))
}
}
this.traverse(astPath)
}
})
let foundReferenceToLazyRequire = true
while (foundReferenceToLazyRequire) {
foundReferenceToLazyRequire = false
recast.types.visit(this.ast, {
visitIdentifier: function (astPath) {
if (isTopLevelASTPath(astPath) && self.isReferenceToLazyRequire(astPath)) {
astPath.replace(b.callExpression(b.identifier(self.lazyRequireFunctionsByVariableName.get(astPath.node.name)), []))
self.replaceAssignmentOrDeclarationWithLazyFunction(astPath)
foundReferenceToLazyRequire = true
this.abort()
} else {
this.traverse(astPath)
}
}
})
}
}
replaceReferencesToDeferredRequiresWithFunctionCalls () {
const self = this
recast.types.visit(this.ast, {
visitIdentifier: function (astPath) {
if (!isTopLevelASTPath(astPath) && self.isReferenceToLazyRequire(astPath) && !isReferenceToShadowedVariable(astPath)) {
astPath.replace(b.callExpression(b.identifier(self.lazyRequireFunctionsByVariableName.get(astPath.node.name)), []))
}
this.traverse(astPath)
}
})
}
replaceReferencesToGlobalsWithFunctionCalls () {
recast.types.visit(this.ast, {
visitIdentifier: function (astPath) {
if (astUtil.isReference(astPath) && !isReferenceToShadowedVariable(astPath) && GLOBALS.has(astPath.node.name)) {
astPath.replace(b.callExpression(b.identifier(`get_${astPath.node.name}`), []))
}
this.traverse(astPath)
}
})
}
replaceAssignmentOrDeclarationWithLazyFunction (astPath) {
let parentPath = astPath.parent
while (parentPath != null && parentPath.scope === astPath.scope) {
const parentNode = parentPath.node
if (parentNode.type === 'AssignmentExpression') {
const lazyRequireFunctionName = `get_${parentNode.left.name}`
let ifStatementPath = parentPath.parent
while (ifStatementPath && ifStatementPath.node.type !== 'IfStatement') {
ifStatementPath = ifStatementPath.parent
}
let assignmentLhs = parentNode.left
while (assignmentLhs.type === 'MemberExpression') {
assignmentLhs = assignmentLhs.object
}
assert.equal(assignmentLhs.type, 'Identifier')
if (["module", "exports"].includes(assignmentLhs.name)) {
console.warn(`##[warning] The reference to the module is replaced with the lazy function, but it is assigned to "module" or "exports". In some cases the bundle might not work, which you should fix manually: \n ${this.options.filePath} \n`);
return
}
assert(
astPath.scope.declares(assignmentLhs.name),
`${this.options.filePath}\nAssigning a deferred module to a variable that was not declared in this scope is not supported!`
)
let replacement = null
if (ifStatementPath) {
if (!this.lazyRequireFunctionsByVariableName.has(parentNode.left.name)) {
ifStatementPath.insertBefore(b.variableDeclaration(
'let',
[b.variableDeclarator(b.identifier(lazyRequireFunctionName), null)]
))
}
replacement = b.assignmentExpression('=', b.identifier(lazyRequireFunctionName), b.functionExpression(null, [], b.blockStatement([
b.returnStatement(
b.assignmentExpression('=', parentNode.left, b.logicalExpression('||', parentNode.left, parentNode.right))
)
])))
} else {
replacement = b.functionDeclaration(b.identifier(lazyRequireFunctionName), [], b.blockStatement([
b.returnStatement(
b.assignmentExpression('=', parentNode.left, b.logicalExpression('||', parentNode.left, parentNode.right))
)
]))
}
if (parentPath.parent.node.type === 'SequenceExpression') {
const sequenceExpressionPath = parentPath.parent
const expressionContainerPath = sequenceExpressionPath.parent
if (sequenceExpressionPath.node.expressions.length === 1) {
expressionContainerPath.replace(replacement)
} else {
expressionContainerPath.insertBefore(replacement)
parentPath.replace()
}
} else {
parentPath.replace(replacement)
}
this.lazyRequireFunctionsByVariableName.set(parentNode.left.name, lazyRequireFunctionName)
return
} else if (parentNode.type === 'VariableDeclarator') {
const variableDeclarationPath = parentPath.parent
const variableDeclarationNode = variableDeclarationPath.node
if (variableDeclarationNode.kind === 'const') {
variableDeclarationNode.kind = 'let'
}
if (parentNode.id.type === 'ObjectPattern') {
parentPath.replace(b.variableDeclarator(parentNode.id, b.objectExpression([])))
for (const property of parentNode.id.properties.slice().reverse()) {
const lazyRequireFunctionName = `get_${property.key.name}`
variableDeclarationPath.insertAfter(b.functionDeclaration(b.identifier(lazyRequireFunctionName), [], b.blockStatement([
b.returnStatement(
b.assignmentExpression('=', property.key, b.logicalExpression('||', property.key, b.memberExpression(parentNode.init, property.key)))
)
])))
this.lazyRequireFunctionsByVariableName.set(property.key.name, lazyRequireFunctionName)
}
} else {
parentPath.replace(b.variableDeclarator(parentNode.id, null))
const lazyRequireFunctionName = `get_${parentNode.id.name}`
variableDeclarationPath.insertAfter(b.functionDeclaration(b.identifier(lazyRequireFunctionName), [], b.blockStatement([
b.returnStatement(
b.assignmentExpression('=', parentNode.id, b.logicalExpression('||', parentNode.id, parentNode.init))
)
])))
this.lazyRequireFunctionsByVariableName.set(parentNode.id.name, lazyRequireFunctionName)
}
return
}
parentPath = parentPath.parent
}
console.warn(`##[warning] The reference to the module is replaced with the lazy function, but it was not in an assignment expression or a variable declaration. In some cases the bundle might not work, which you should fix manually: \n ${this.options.filePath} `);
return
}
isReferenceToLazyRequire (astPath) {
const scope = astPath.scope
const lazyRequireFunctionName = this.lazyRequireFunctionsByVariableName.get(astPath.node.name)
if (lazyRequireFunctionName != null &&
(scope.node.type !== 'FunctionDeclaration' || lazyRequireFunctionName !== astPath.scope.node.id.name) &&
(scope.node.type !== 'FunctionExpression' || scope.path.parent.node.type !== 'AssignmentExpression' || lazyRequireFunctionName !== scope.path.parent.node.left.name) &&
(astPath.parent.node.type !== 'Property' || astPath.parent.parent.node.type !== 'ObjectPattern') ) {
if (astPath.parent.node.type === 'AssignmentExpression') {
return astPath.name === "right" && astUtil.isReference(astPath)
} else {
return astUtil.isReference(astPath)
}
}
}
resolveModulePath (moduleName) {
try {
const absolutePath = resolve.sync(moduleName, {basedir: path.dirname(this.options.filePath), extensions: ['.js', '.json']})
const isCoreNodeModule = absolutePath.indexOf(path.sep) === -1
return isCoreNodeModule ? null : absolutePath
} catch (e) {
return null
}
}
}
function isReferenceToShadowedVariable (astPath) {
const referenceName = astPath.node.name
let scope = astPath.scope
let foundDeclaration = GLOBALS.has(referenceName)
while (scope) {
if (scope.declares(referenceName)) {
if (foundDeclaration) {
return true
} else {
foundDeclaration = true
}
}
scope = scope.parent
}
return false
}
function isStaticRequire (astPath) {
const node = astPath.node
return (
node.callee.name === 'require' &&
node.arguments.length === 1 &&
node.arguments[0].type === 'Literal'
)
}
function isStaticRequireResolve (astPath) {
const node = astPath.node
return (
node.callee.type === 'MemberExpression' &&
node.callee.object.name === 'require' &&
node.callee.property.name === 'resolve' &&
node.arguments.length === 1 &&
node.arguments[0].type === 'Literal'
)
}
function isTopLevelASTPath (astPath) {
if (astPath.scope.isGlobal) {
return true
} else if (astPath.scope.depth === 1) {
while (astPath) {
const node = astPath.node
if (node.type === 'FunctionExpression') {
const parentNode = astPath.parent.node
const grandparentNode = astPath.parent.parent.node
const parentIsCallExpression = parentNode.type === 'CallExpression' && parentNode.arguments.indexOf(node) === -1
const grandparentIsCallExpression = grandparentNode.type === 'CallExpression'
if (parentIsCallExpression || grandparentIsCallExpression) {
return true
}
}
astPath = astPath.parent
}
return false
} else {
return false
}
}