diff --git a/packages/compiler-cli/ngcc/src/host/umd_host.ts b/packages/compiler-cli/ngcc/src/host/umd_host.ts index 2bf0f2f77a2..098c5c1c71f 100644 --- a/packages/compiler-cli/ngcc/src/host/umd_host.ts +++ b/packages/compiler-cli/ngcc/src/host/umd_host.ts @@ -292,9 +292,9 @@ export class UmdReflectionHost extends Esm5ReflectionHost { statement: WildcardReexportStatement, containingFile: ts.SourceFile): ExportDeclaration[] { const reexportArg = statement.expression.arguments[0]; - const requireCall = isRequireCall(reexportArg) ? - reexportArg : - ts.isIdentifier(reexportArg) ? findRequireCallReference(reexportArg, this.checker) : null; + const requireCall = isRequireCall(reexportArg) ? reexportArg : + ts.isIdentifier(reexportArg) ? findRequireCallReference(reexportArg, this.checker) : + null; let importPath: string|null = null; @@ -521,7 +521,25 @@ export function parseStatementForUmdModule(statement: ts.Statement): UmdModule|n const factoryFn = stripParentheses(wrapper.call.arguments[factoryFnParamIndex]); if (!factoryFn || !ts.isFunctionExpression(factoryFn)) return null; - return {wrapperFn: wrapper.fn, factoryFn}; + let factoryCalls: UmdModule['factoryCalls']|null = null; + + return { + wrapperFn: wrapper.fn, + factoryFn, + // Compute `factoryCalls` lazily, because in some cases they might not be needed for the task at + // hand. + // For example, if we just want to determine if an entry-point is in CommonJS or UMD format, + // trying to parse the wrapper function could potentially throw a (premature) error. By making + // the computation of `factoryCalls` lazy, such an error would be thrown later (during an + // operation where the format of the wrapper function does actually matter) or potentially not + // at all (if we end up not having to process that entry-point). + get factoryCalls() { + if (factoryCalls === null) { + factoryCalls = parseUmdWrapperFunction(this.wrapperFn); + } + return factoryCalls; + }, + }; } function getUmdWrapper(statement: ts.Statement): @@ -547,51 +565,216 @@ function getUmdWrapper(statement: ts.Statement): return null; } +/** + * Parse the wrapper function of a UMD module and extract info about the factory function calls for + * the various formats (CommonJS, AMD, global). + * + * The supported format for the UMD wrapper function body is a single statement which is a + * `ts.ConditionalExpression` (i.e. using a ternary operator). For example: + * + * ```js + * // Using a conditional expression: + * (function (global, factory) { + * typeof exports === 'object' && typeof module !== 'undefined' ? + * // CommonJS2 factory call. + * factory(exports, require('foo'), require('bar')) : + * typeof define === 'function' && define.amd ? + * // AMD factory call. + * define(['exports', 'foo', 'bar'], factory) : + * // Global factory call. + * (factory((global['my-lib'] = {}), global.foo, global.bar)); + * }(this, (function (exports, foo, bar) { + * // ... + * })); + * ``` + */ +function parseUmdWrapperFunction(wrapperFn: ts.FunctionExpression): UmdModule['factoryCalls'] { + const stmt = wrapperFn.body.statements[0]; + let conditionalFactoryCalls: UmdConditionalFactoryCall[]; + + if (ts.isExpressionStatement(stmt) && ts.isConditionalExpression(stmt.expression)) { + conditionalFactoryCalls = extractFactoryCallsFromConditionalExpression(stmt.expression); + } else { + throw new Error( + 'UMD wrapper body is not in a supported format (expected a conditional expression):\n' + + wrapperFn.body.getText()); + } + + const factoryCalls = { + amdDefine: getAmdDefineCall(conditionalFactoryCalls), + commonJs: getCommonJsFactoryCall(conditionalFactoryCalls), + global: getGlobalFactoryCall(conditionalFactoryCalls), + }; + + if (factoryCalls.commonJs === null) { + throw new Error( + 'Unable to find a CommonJS factory call inside the UMD wrapper function:\n' + + stmt.getText()); + } + + return factoryCalls as (typeof factoryCalls&{commonJs: ts.CallExpression}); +} + +/** + * Extract `UmdConditionalFactoryCall`s from a `ts.ConditionalExpression` of the form: + * + * ```js + * typeof exports === 'object' && typeof module !== 'undefined' ? + * // CommonJS2 factory call. + * factory(exports, require('foo'), require('bar')) : + * typeof define === 'function' && define.amd ? + * // AMD factory call. + * define(['exports', 'foo', 'bar'], factory) : + * // Global factory call. + * (factory((global['my-lib'] = {}), global.foo, global.bar)); + * ``` + */ +function extractFactoryCallsFromConditionalExpression(node: ts.ConditionalExpression): + UmdConditionalFactoryCall[] { + const factoryCalls: UmdConditionalFactoryCall[] = []; + let currentNode: ts.Expression = node; + + while (ts.isConditionalExpression(currentNode)) { + if (!ts.isBinaryExpression(currentNode.condition)) { + throw new Error( + 'Condition inside UMD wrapper is not a binary expression:\n' + + currentNode.condition.getText()); + } + + factoryCalls.push({ + condition: currentNode.condition, + factoryCall: getFunctionCallFromExpression(currentNode.whenTrue), + }); + + currentNode = currentNode.whenFalse; + } + + factoryCalls.push({ + condition: null, + factoryCall: getFunctionCallFromExpression(currentNode), + }); + + return factoryCalls; +} + +function getFunctionCallFromExpression(node: ts.Expression): ts.CallExpression { + // Be resilient to `node` being inside parenthesis. + if (ts.isParenthesizedExpression(node)) { + // NOTE: + // Since we are going further down the AST, there is no risk of infinite recursion. + return getFunctionCallFromExpression(node.expression); + } + + // Be resilient to `node` being part of a comma expression. + if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.CommaToken) { + // NOTE: + // Since we are going further down the AST, there is no risk of infinite recursion. + return getFunctionCallFromExpression(node.right); + } + + if (!ts.isCallExpression(node)) { + throw new Error('Expression inside UMD wrapper is not a call expression:\n' + node.getText()); + } + + return node; +} + +/** + * Get the `define` call for setting up the AMD dependencies in the UMD wrapper. + */ +function getAmdDefineCall(calls: UmdConditionalFactoryCall[]): ts.CallExpression|null { + // The `define` call for AMD dependencies is the one that is guarded with a `&&` expression whose + // one side is a `typeof define` condition. + const amdConditionalCall = calls.find( + call => call.condition?.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken && + oneOfBinaryConditions(call.condition, exp => isTypeOf(exp, 'define')) && + ts.isIdentifier(call.factoryCall.expression) && + call.factoryCall.expression.text === 'define'); + + return amdConditionalCall?.factoryCall ?? null; +} + +/** + * Get the factory call for setting up the CommonJS dependencies in the UMD wrapper. + */ +function getCommonJsFactoryCall(calls: UmdConditionalFactoryCall[]): ts.CallExpression|null { + // The factory call for CommonJS dependencies is the one that is guarded with a `&&` expression + // whose one side is a `typeof exports` or `typeof module` condition. + const cjsConditionalCall = calls.find( + call => call.condition?.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken && + oneOfBinaryConditions(call.condition, exp => isTypeOf(exp, 'exports', 'module')) && + ts.isIdentifier(call.factoryCall.expression) && + call.factoryCall.expression.text === 'factory'); + + return cjsConditionalCall?.factoryCall ?? null; +} + +/** + * Get the factory call for setting up the global dependencies in the UMD wrapper. + */ +function getGlobalFactoryCall(calls: UmdConditionalFactoryCall[]): ts.CallExpression|null { + // The factory call for global dependencies is the one that is the final else-case (i.e. the one + // that has `condition: null`). + const globalConditionalCall = calls.find(call => call.condition === null); + + return globalConditionalCall?.factoryCall ?? null; +} + +function oneOfBinaryConditions( + node: ts.BinaryExpression, test: (expression: ts.Expression) => boolean) { + return test(node.left) || test(node.right); +} + +function isTypeOf(node: ts.Expression, ...types: string[]): boolean { + return ts.isBinaryExpression(node) && ts.isTypeOfExpression(node.left) && + ts.isIdentifier(node.left.expression) && types.includes(node.left.expression.text); +} export function getImportsOfUmdModule(umdModule: UmdModule): {parameter: ts.ParameterDeclaration, path: string}[] { const imports: {parameter: ts.ParameterDeclaration, path: string}[] = []; + const cjsFactoryCall = umdModule.factoryCalls.commonJs; + for (let i = 1; i < umdModule.factoryFn.parameters.length; i++) { imports.push({ parameter: umdModule.factoryFn.parameters[i], - path: getRequiredModulePath(umdModule.wrapperFn, i) + path: getRequiredModulePath(cjsFactoryCall, i), }); } + return imports; } interface UmdModule { wrapperFn: ts.FunctionExpression; factoryFn: ts.FunctionExpression; + factoryCalls: { + commonJs: ts.CallExpression; amdDefine: ts.CallExpression | null; + global: ts.CallExpression | null; + }; } -function getRequiredModulePath(wrapperFn: ts.FunctionExpression, paramIndex: number): string { - const statement = wrapperFn.body.statements[0]; - if (!ts.isExpressionStatement(statement)) { +/** + * Represents a factory call found inside the UMD wrapper function. + * + * Each factory call corresponds to a format (such as AMD, CommonJS, etc.) and is guarded by a + * condition (except for the last factory call, which is reached when all other conditions fail). + */ +interface UmdConditionalFactoryCall { + condition: ts.BinaryExpression|null; + factoryCall: ts.CallExpression; +} + +function getRequiredModulePath(cjsFactoryCall: ts.CallExpression, paramIndex: number): string { + const requireCall = cjsFactoryCall.arguments[paramIndex]; + + if (requireCall !== undefined && !isRequireCall(requireCall)) { throw new Error( - 'UMD wrapper body is not an expression statement:\n' + wrapperFn.body.getText()); + `Argument at index ${paramIndex} of UMD factory call is not a \`require\` call with a ` + + 'single string argument:\n' + cjsFactoryCall.getText()); } - const modulePaths: string[] = []; - findModulePaths(statement.expression); - // Since we were only interested in the `require()` calls, we miss the `exports` argument, so we - // need to subtract 1. - // E.g. `function(exports, dep1, dep2)` maps to `function(exports, require('path/to/dep1'), - // require('path/to/dep2'))` - return modulePaths[paramIndex - 1]; - - // Search the statement for calls to `require('...')` and extract the string value of the first - // argument - function findModulePaths(node: ts.Node) { - if (isRequireCall(node)) { - const argument = node.arguments[0]; - if (ts.isStringLiteral(argument)) { - modulePaths.push(argument.text); - } - } else { - node.forEachChild(findModulePaths); - } - } + return requireCall.arguments[0].text; } /** diff --git a/packages/compiler-cli/ngcc/src/rendering/umd_rendering_formatter.ts b/packages/compiler-cli/ngcc/src/rendering/umd_rendering_formatter.ts index ebbc3e0b3dd..60544dc421a 100644 --- a/packages/compiler-cli/ngcc/src/rendering/umd_rendering_formatter.ts +++ b/packages/compiler-cli/ngcc/src/rendering/umd_rendering_formatter.ts @@ -17,9 +17,6 @@ import {UmdReflectionHost} from '../host/umd_host'; import {Esm5RenderingFormatter} from './esm5_rendering_formatter'; import {stripExtension} from './utils'; -type CommonJsConditional = ts.ConditionalExpression&{whenTrue: ts.CallExpression}; -type AmdConditional = ts.ConditionalExpression&{whenTrue: ts.CallExpression}; - /** * A RenderingFormatter that works with UMD files, instead of `import` and `export` statements * the module is an IIFE with a factory function call with dependencies, which are defined in a @@ -61,12 +58,12 @@ export class UmdRenderingFormatter extends Esm5RenderingFormatter { return; } - const {wrapperFn, factoryFn} = umdModule; + const {factoryFn, factoryCalls} = umdModule; // We need to add new `require()` calls for each import in the CommonJS initializer - renderCommonJsDependencies(output, wrapperFn, imports); - renderAmdDependencies(output, wrapperFn, imports); - renderGlobalDependencies(output, wrapperFn, imports); + renderCommonJsDependencies(output, factoryCalls.commonJs, imports); + renderAmdDependencies(output, factoryCalls.amdDefine, imports); + renderGlobalDependencies(output, factoryCalls.global, imports); renderFactoryParameters(output, factoryFn, imports); } @@ -140,12 +137,11 @@ export class UmdRenderingFormatter extends Esm5RenderingFormatter { * Add dependencies to the CommonJS part of the UMD wrapper function. */ function renderCommonJsDependencies( - output: MagicString, wrapperFunction: ts.FunctionExpression, imports: Import[]) { - const conditional = find(wrapperFunction.body.statements[0], isCommonJSConditional); - if (!conditional) { + output: MagicString, factoryCall: ts.CallExpression|null, imports: Import[]) { + if (factoryCall === null) { return; } - const factoryCall = conditional.whenTrue; + const injectionPoint = factoryCall.arguments.length > 0 ? // Add extra dependencies before the first argument factoryCall.arguments[0].getFullStart() : @@ -159,12 +155,11 @@ function renderCommonJsDependencies( * Add dependencies to the AMD part of the UMD wrapper function. */ function renderAmdDependencies( - output: MagicString, wrapperFunction: ts.FunctionExpression, imports: Import[]) { - const conditional = find(wrapperFunction.body.statements[0], isAmdConditional); - if (!conditional) { + output: MagicString, amdDefineCall: ts.CallExpression|null, imports: Import[]) { + if (amdDefineCall === null) { return; } - const amdDefineCall = conditional.whenTrue; + const importString = imports.map(i => `'${i.specifier}'`).join(','); // The dependency array (if it exists) is the second to last argument // `define(id?, dependencies?, factory);` @@ -191,19 +186,18 @@ function renderAmdDependencies( * Add dependencies to the global part of the UMD wrapper function. */ function renderGlobalDependencies( - output: MagicString, wrapperFunction: ts.FunctionExpression, imports: Import[]) { - const globalFactoryCall = find(wrapperFunction.body.statements[0], isGlobalFactoryCall); - if (!globalFactoryCall) { + output: MagicString, factoryCall: ts.CallExpression|null, imports: Import[]) { + if (factoryCall === null) { return; } - const injectionPoint = globalFactoryCall.arguments.length > 0 ? + + const injectionPoint = factoryCall.arguments.length > 0 ? // Add extra dependencies before the first argument - globalFactoryCall.arguments[0].getFullStart() : + factoryCall.arguments[0].getFullStart() : // Backup one char to account for the closing parenthesis on the call - globalFactoryCall.getEnd() - 1; + factoryCall.getEnd() - 1; const importString = imports.map(i => `global.${getGlobalIdentifier(i)}`).join(','); - output.appendLeft( - injectionPoint, importString + (globalFactoryCall.arguments.length > 0 ? ',' : '')); + output.appendLeft(injectionPoint, importString + (factoryCall.arguments.length > 0 ? ',' : '')); } /** @@ -226,66 +220,6 @@ function renderFactoryParameters( } } -/** - * Is this node the CommonJS conditional expression in the UMD wrapper? - */ -function isCommonJSConditional(value: ts.Node): value is CommonJsConditional { - if (!ts.isConditionalExpression(value)) { - return false; - } - if (!ts.isBinaryExpression(value.condition) || - value.condition.operatorToken.kind !== ts.SyntaxKind.AmpersandAmpersandToken) { - return false; - } - if (!oneOfBinaryConditions(value.condition, (exp) => isTypeOf(exp, 'exports', 'module'))) { - return false; - } - if (!ts.isCallExpression(value.whenTrue) || !ts.isIdentifier(value.whenTrue.expression)) { - return false; - } - return value.whenTrue.expression.text === 'factory'; -} - -/** - * Is this node the AMD conditional expression in the UMD wrapper? - */ -function isAmdConditional(value: ts.Node): value is AmdConditional { - if (!ts.isConditionalExpression(value)) { - return false; - } - if (!ts.isBinaryExpression(value.condition) || - value.condition.operatorToken.kind !== ts.SyntaxKind.AmpersandAmpersandToken) { - return false; - } - if (!oneOfBinaryConditions(value.condition, (exp) => isTypeOf(exp, 'define'))) { - return false; - } - if (!ts.isCallExpression(value.whenTrue) || !ts.isIdentifier(value.whenTrue.expression)) { - return false; - } - return value.whenTrue.expression.text === 'define'; -} - -/** - * Is this node the call to setup the global dependencies in the UMD wrapper? - */ -function isGlobalFactoryCall(value: ts.Node): value is ts.CallExpression { - if (ts.isCallExpression(value) && !!value.parent) { - // Be resilient to the value being part of a comma list - value = isCommaExpression(value.parent) ? value.parent : value; - // Be resilient to the value being inside parentheses - value = ts.isParenthesizedExpression(value.parent) ? value.parent : value; - return !!value.parent && ts.isConditionalExpression(value.parent) && - value.parent.whenFalse === value; - } else { - return false; - } -} - -function isCommaExpression(value: ts.Node): value is ts.BinaryExpression { - return ts.isBinaryExpression(value) && value.operatorToken.kind === ts.SyntaxKind.CommaToken; -} - /** * Compute a global identifier for the given import (`i`). * @@ -315,17 +249,3 @@ function getGlobalIdentifier(i: Import): string { .replace(/[-_]+(.?)/g, (_, c) => c.toUpperCase()) .replace(/^./, c => c.toLowerCase()); } - -function find(node: ts.Node, test: (node: ts.Node) => node is ts.Node & T): T|undefined { - return test(node) ? node : node.forEachChild(child => find(child, test)); -} - -function oneOfBinaryConditions( - node: ts.BinaryExpression, test: (expression: ts.Expression) => boolean) { - return test(node.left) || test(node.right); -} - -function isTypeOf(node: ts.Expression, ...types: string[]): boolean { - return ts.isBinaryExpression(node) && ts.isTypeOfExpression(node.left) && - ts.isIdentifier(node.left.expression) && types.indexOf(node.left.expression.text) !== -1; -} diff --git a/packages/compiler-cli/ngcc/test/host/umd_host_spec.ts b/packages/compiler-cli/ngcc/test/host/umd_host_spec.ts index 3ebae7955d1..46f2d31b7b5 100644 --- a/packages/compiler-cli/ngcc/test/host/umd_host_spec.ts +++ b/packages/compiler-cli/ngcc/test/host/umd_host_spec.ts @@ -735,7 +735,8 @@ runInEachFileSystem(() => { name: _('/wildcard_reexports_with_require.js'), contents: `(function (global, factory) {\n` + ` typeof exports === 'object' && typeof module !== 'undefined' ? factory(require, exports) :\n` + - ` typeof define === 'function' && define.amd ? define('wildcard_reexports_with_require', ['require', 'exports'], factory);\n` + + ` typeof define === 'function' && define.amd ? define('wildcard_reexports_with_require', ['require', 'exports'], factory) :\n` + + ` (factory(global.require, global.wildcard_reexports_with_require));\n` + `}(this, (function (require, exports) { 'use strict';\n` + ` function __export(m) {\n` + ` for (var p in m) if (!exports.hasOwnProperty(p)) exports[p] = m[p];\n` + @@ -749,7 +750,8 @@ runInEachFileSystem(() => { name: _('/define_property_reexports.js'), contents: `(function (global, factory) {\n` + ` typeof exports === 'object' && typeof module !== 'undefined' ? factory(require, exports) :\n` + - ` typeof define === 'function' && define.amd ? define('define_property_reexports', ['require', 'exports'], factory);\n` + + ` typeof define === 'function' && define.amd ? define('define_property_reexports', ['require', 'exports'], factory) :\n` + + ` (factory(global.require, global.define_property_reexports));\n` + `}(this, (function (require, exports) { 'use strict';\n` + `var moduleA = require("./a_module");\n` + `Object.defineProperty(exports, "newA", { enumerable: true, get: function () { return moduleA.a; } });\n` + @@ -1769,7 +1771,7 @@ runInEachFileSystem(() => { case 'imported': fileHeaderWithUmd = ` (function (global, factory) { - typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports, require('tslib'))) : + typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports, require('tslib')) : typeof define === 'function' && define.amd ? define('test', ['exports', 'tslib'], factory) : (factory(global.test, global.tslib)); }(this, (function (exports, tslib) { 'use strict'; @@ -1778,7 +1780,7 @@ runInEachFileSystem(() => { case 'inlined': fileHeaderWithUmd = ` (function (global, factory) { - typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports)) : + typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) : typeof define === 'function' && define.amd ? define('test', ['exports'], factory) : (factory(global.test)); }(this, (function (exports) { 'use strict'; @@ -1791,7 +1793,7 @@ runInEachFileSystem(() => { case 'inlined_with_suffix': fileHeaderWithUmd = ` (function (global, factory) { - typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports)) : + typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) : typeof define === 'function' && define.amd ? define('test', ['exports'], factory) : (factory(global.test)); }(this, (function (exports) { 'use strict'; diff --git a/packages/compiler-cli/ngcc/test/packages/entry_point_spec.ts b/packages/compiler-cli/ngcc/test/packages/entry_point_spec.ts index f573dce42e2..1970a8ddcc8 100644 --- a/packages/compiler-cli/ngcc/test/packages/entry_point_spec.ts +++ b/packages/compiler-cli/ngcc/test/packages/entry_point_spec.ts @@ -851,6 +851,27 @@ runInEachFileSystem(() => { entryPoint.packageJson.main = './bundles/valid_entry_point'; expect(getEntryPointFormat(fs, entryPoint, browserOrMain)).toBe('umd'); }); + + it('should detect UMD even if the wrapper function is not of a supported format', () => { + // In order to correctly process UMD, we require that at least one CommonJS variant is + // present. However, for the purposes of detecting the module format, it should not matter + // what the body of the wrapper function looks like. + loadTestFiles([{ + name: _( + '/project/node_modules/some_package/valid_entry_point/bundles/valid_entry_point/index.js'), + contents: ` + (function (global, factory) { + // This is not a supported format for the wrapper function body, but it should still + // be detected as UMD (since it is closer to UMD than any other of the supported + // formats). + typeof define === 'function' && define.amd ? define([], factory) : factory(); + })(this, function () { 'use strict'; }); + `, + }]); + + entryPoint.packageJson.main = './bundles/valid_entry_point'; + expect(getEntryPointFormat(fs, entryPoint, browserOrMain)).toBe('umd'); + }); }); it('should return `undefined` if the `browser` property is not a string', () => {