|
| 1 | +import * as path from 'path'; |
| 2 | + |
| 3 | +import * as recast from 'recast'; |
| 4 | +import * as acornParser from 'recast/parsers/acorn'; |
| 5 | + |
| 6 | +const POLYFILL_NAMES = new Set([ |
| 7 | + '_asyncNullishCoalesce', |
| 8 | + '_asyncOptionalChain', |
| 9 | + '_asyncOptionalChainDelete', |
| 10 | + '_createNamedExportFrom', |
| 11 | + '_createStarExport', |
| 12 | + '_interopDefault', // rollup's version |
| 13 | + '_interopNamespace', // rollup's version |
| 14 | + '_interopNamespaceDefaultOnly', |
| 15 | + '_interopRequireDefault', // sucrase's version |
| 16 | + '_interopRequireWildcard', // sucrase's version |
| 17 | + '_nullishCoalesce', |
| 18 | + '_optionalChain', |
| 19 | + '_optionalChainDelete', |
| 20 | +]); |
| 21 | + |
| 22 | +/** |
| 23 | + * Create a plugin which will replace function definitions of any of the above funcions with an `import` or `require` |
| 24 | + * statement pulling them in from a central source. Mimics tsc's `importHelpers` option. |
| 25 | + */ |
| 26 | +export function makeExtractPolyfillsPlugin() { |
| 27 | + let moduleFormat; |
| 28 | + |
| 29 | + // For more on the hooks used in this plugin, see https://rollupjs.org/guide/en/#output-generation-hooks |
| 30 | + return { |
| 31 | + name: 'extractPolyfills', |
| 32 | + |
| 33 | + // Figure out which build we're currently in (esm or cjs) |
| 34 | + outputOptions(options) { |
| 35 | + moduleFormat = options.format; |
| 36 | + }, |
| 37 | + |
| 38 | + // This runs after both the sucrase transpilation (which happens in the `transform` hook) and rollup's own |
| 39 | + // esm-i-fying or cjs-i-fying work (which happens right before `renderChunk`), in other words, after all polyfills |
| 40 | + // will have been injected |
| 41 | + renderChunk(code, chunk) { |
| 42 | + const sourceFile = chunk.fileName; |
| 43 | + |
| 44 | + // We don't want to pull the function definitions out of their actual sourcefiles, just the places where they've |
| 45 | + // been injected |
| 46 | + if (sourceFile.includes('buildPolyfills')) { |
| 47 | + return null; |
| 48 | + } |
| 49 | + |
| 50 | + const parserOptions = { |
| 51 | + sourceFileName: sourceFile, |
| 52 | + // We supply a custom parser which wraps the provided `acorn` parser in order to override the `ecmaVersion` value. |
| 53 | + // See https://github.com/benjamn/recast/issues/578. |
| 54 | + parser: { |
| 55 | + parse(source, options) { |
| 56 | + return acornParser.parse(source, { |
| 57 | + ...options, |
| 58 | + // By this point in the build, everything should already have been down-compiled to whatever JS version |
| 59 | + // we're targeting. Setting this parser to `latest` just means that whatever that version is (or changes |
| 60 | + // to in the future), this parser will be able to handle the generated code. |
| 61 | + ecmaVersion: 'latest', |
| 62 | + }); |
| 63 | + }, |
| 64 | + }, |
| 65 | + }; |
| 66 | + |
| 67 | + const ast = recast.parse(code, parserOptions); |
| 68 | + |
| 69 | + // Find function definitions and function expressions whose identifiers match a known polyfill name |
| 70 | + const polyfillNodes = findPolyfillNodes(ast); |
| 71 | + |
| 72 | + if (polyfillNodes.length === 0) { |
| 73 | + return null; |
| 74 | + } |
| 75 | + |
| 76 | + console.log(`${sourceFile} - polyfills: ${polyfillNodes.map(node => node.name)}`); |
| 77 | + |
| 78 | + // Depending on the output format, generate `import { x, y, z } from '...'` or `var { x, y, z } = require('...')` |
| 79 | + const importOrRequireNode = createImportOrRequireNode(polyfillNodes, sourceFile, moduleFormat); |
| 80 | + |
| 81 | + // Insert our new `import` or `require` node at the top of the file, and then delete the function definitions it's |
| 82 | + // meant to replace (polyfill nodes get marked for deletion in `findPolyfillNodes`) |
| 83 | + ast.program.body = [importOrRequireNode, ...ast.program.body.filter(node => !node.shouldDelete)]; |
| 84 | + |
| 85 | + // In spite of the name, this doesn't actually print anything - it just stringifies the code, and keeps track of |
| 86 | + // where original nodes end up in order to generate a sourcemap. |
| 87 | + const result = recast.print(ast, { |
| 88 | + sourceMapName: `${sourceFile}.map`, |
| 89 | + quote: 'single', |
| 90 | + }); |
| 91 | + |
| 92 | + return { code: result.code, map: result.map }; |
| 93 | + }, |
| 94 | + }; |
| 95 | +} |
| 96 | + |
| 97 | +/** |
| 98 | + * Extract the function name, regardless of the format in which the function is declared |
| 99 | + */ |
| 100 | +function getNodeName(node) { |
| 101 | + // Function expressions and functions pulled from objects |
| 102 | + if (node.type === 'VariableDeclaration') { |
| 103 | + // In practice sucrase and rollup only ever declare one polyfill at a time, so it's safe to just grab the first |
| 104 | + // entry here |
| 105 | + const declarationId = node.declarations[0].id; |
| 106 | + |
| 107 | + // Note: Sucrase and rollup seem to only use the first type of variable declaration for their polyfills, but good to |
| 108 | + // cover our bases |
| 109 | + |
| 110 | + // Declarations of the form |
| 111 | + // `const dogs = function() { return "are great"; };` |
| 112 | + // or |
| 113 | + // `const dogs = () => "are great"; |
| 114 | + if (declarationId.type === 'Identifier') { |
| 115 | + return declarationId.name; |
| 116 | + } |
| 117 | + // Declarations of the form |
| 118 | + // `const { dogs } = { dogs: function() { return "are great"; } }` |
| 119 | + // or |
| 120 | + // `const { dogs } = { dogs: () => "are great" }` |
| 121 | + else if (declarationId.type === 'ObjectPattern') { |
| 122 | + return declarationId.properties[0].key.name; |
| 123 | + } |
| 124 | + // Any other format |
| 125 | + else { |
| 126 | + return 'unknown variable'; |
| 127 | + } |
| 128 | + } |
| 129 | + |
| 130 | + // Regular old functions, of the form |
| 131 | + // `function dogs() { return "are great"; }` |
| 132 | + else if (node.type === 'FunctionDeclaration') { |
| 133 | + return node.id.name; |
| 134 | + } |
| 135 | + |
| 136 | + // If we get here, this isn't a node we're interested in, so just return a string we know will never match any of the |
| 137 | + // polyfill names |
| 138 | + else { |
| 139 | + return 'nope'; |
| 140 | + } |
| 141 | +} |
| 142 | + |
| 143 | +/** |
| 144 | + * Find all nodes whose identifiers match a known polyfill name. |
| 145 | + * |
| 146 | + * Note: In theory, this could yield false positives, if any of the magic names were assigned to something other than a |
| 147 | + * polyfill function, but the chances of that are slim. Also, it only searches the module global scope, but that's |
| 148 | + * always where the polyfills appear, so no reason to traverse the whole tree. |
| 149 | + */ |
| 150 | +function findPolyfillNodes(ast) { |
| 151 | + const isPolyfillNode = node => { |
| 152 | + const nodeName = getNodeName(node); |
| 153 | + if (POLYFILL_NAMES.has(nodeName)) { |
| 154 | + // Mark this node for later deletion, since we're going to replace it with an import statement |
| 155 | + node.shouldDelete = true; |
| 156 | + // Store the name in a consistent spot, regardless of node type |
| 157 | + node.name = nodeName; |
| 158 | + |
| 159 | + return true; |
| 160 | + } |
| 161 | + |
| 162 | + return false; |
| 163 | + }; |
| 164 | + |
| 165 | + return ast.program.body.filter(isPolyfillNode); |
| 166 | +} |
| 167 | + |
| 168 | +/** |
| 169 | + * Create a node representing an `import` or `require` statement of the form |
| 170 | + * |
| 171 | + * import { < polyfills > } from '...' |
| 172 | + * or |
| 173 | + * var { < polyfills > } = require('...') |
| 174 | + * |
| 175 | + * @param polyfillNodes The nodes from the current version of the code, defining the polyfill functions |
| 176 | + * @param currentSourceFile The path, relative to `src/`, of the file currently being transpiled |
| 177 | + * @param moduleFormat Either 'cjs' or 'esm' |
| 178 | + * @returns A single node which can be subbed in for the polyfill definition nodes |
| 179 | + */ |
| 180 | +function createImportOrRequireNode(polyfillNodes, currentSourceFile, moduleFormat) { |
| 181 | + const { |
| 182 | + callExpression, |
| 183 | + identifier, |
| 184 | + importDeclaration, |
| 185 | + importSpecifier, |
| 186 | + literal, |
| 187 | + objectPattern, |
| 188 | + property, |
| 189 | + variableDeclaration, |
| 190 | + variableDeclarator, |
| 191 | + } = recast.types.builders; |
| 192 | + |
| 193 | + // Since our polyfills live in `@sentry/utils`, if we're importing or requiring them there the path will have to be |
| 194 | + // relative |
| 195 | + const isUtilsPackage = process.cwd().endsWith('packages/utils'); |
| 196 | + const importSource = literal( |
| 197 | + isUtilsPackage |
| 198 | + ? `./${path.relative(path.dirname(currentSourceFile), 'buildPolyfills')}` |
| 199 | + : `@sentry/utils/${moduleFormat}/buildPolyfills`, |
| 200 | + ); |
| 201 | + |
| 202 | + // This is the `x, y, z` of inside of `import { x, y, z }` or `var { x, y, z }` |
| 203 | + const importees = polyfillNodes.map(({ name: fnName }) => |
| 204 | + moduleFormat === 'esm' |
| 205 | + ? importSpecifier(identifier(fnName)) |
| 206 | + : property.from({ kind: 'init', key: identifier(fnName), value: identifier(fnName), shorthand: true }), |
| 207 | + ); |
| 208 | + |
| 209 | + const requireFn = identifier('require'); |
| 210 | + |
| 211 | + return moduleFormat === 'esm' |
| 212 | + ? importDeclaration(importees, importSource) |
| 213 | + : variableDeclaration('var', [ |
| 214 | + variableDeclarator(objectPattern(importees), callExpression(requireFn, [importSource])), |
| 215 | + ]); |
| 216 | +} |
0 commit comments