From 2bc3522e167fef7dc695d0807243b1c4fa1f596d Mon Sep 17 00:00:00 2001 From: George Kalpakas Date: Fri, 26 Nov 2021 19:46:27 +0200 Subject: [PATCH] refactor(ngcc): make it easy to support more UMD wrapper function formats Previously, ngcc could only handle UMD modules whose wrapper function was implemented as a `ts.ConditionalExpression` (i.e. using a ternary operator). This is the format emitted by popular bundlers, such as Rollup. However, this failed to account for a different format, using `if/else` statements, such as the one [emitted by Webpack][1]. This commit prepares ngcc for supporting different UMD wrapper function formats by decoupling the operation of parsing the wrapper function body to capture the various factory function calls and that of operating on the factory function calls (for example, to read or update their arguments). In a subsequent commit, this will be used to add support for the Webpack format. [1]: https://webpack.js.org/configuration/output/#type-umd --- .../compiler-cli/ngcc/src/host/umd_host.ts | 241 +++++++++++++++--- .../src/rendering/umd_rendering_formatter.ts | 114 ++------- .../ngcc/test/host/umd_host_spec.ts | 12 +- .../ngcc/test/packages/entry_point_spec.ts | 21 ++ 4 files changed, 257 insertions(+), 131 deletions(-) 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', () => {