diff --git a/package.json b/package.json index f2b81a98123e..3d2a67cea99f 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,7 @@ "@types/mocha": "^5.2.0", "@types/node": "~10.17.0", "@types/sinon": "^7.0.11", + "acorn": "^8.7.0", "chai": "^4.1.2", "codecov": "^3.6.5", "deepmerge": "^4.2.2", @@ -81,6 +82,7 @@ "mocha": "^6.1.4", "npm-run-all": "^4.1.5", "prettier": "2.5.1", + "recast": "^0.20.5", "replace-in-file": "^4.0.0", "rimraf": "^3.0.2", "rollup": "^2.67.1", diff --git a/packages/gatsby/package.json b/packages/gatsby/package.json index 50811fce6b03..504b34015793 100644 --- a/packages/gatsby/package.json +++ b/packages/gatsby/package.json @@ -22,6 +22,7 @@ "dependencies": { "@sentry/react": "7.0.0-beta.0", "@sentry/tracing": "7.0.0-beta.0", + "@sentry/utils": "7.0.0-beta.0", "@sentry/webpack-plugin": "1.18.9" }, "peerDependencies": { diff --git a/packages/wasm/package.json b/packages/wasm/package.json index 831d45671b0c..0dfffd50df12 100644 --- a/packages/wasm/package.json +++ b/packages/wasm/package.json @@ -18,6 +18,7 @@ "dependencies": { "@sentry/browser": "7.0.0-beta.0", "@sentry/types": "7.0.0-beta.0", + "@sentry/utils": "7.0.0-beta.0", "tslib": "^1.9.3" }, "devDependencies": { diff --git a/rollup/npmHelpers.js b/rollup/npmHelpers.js index 440223c7e757..0ac0ad33a01b 100644 --- a/rollup/npmHelpers.js +++ b/rollup/npmHelpers.js @@ -9,6 +9,7 @@ import deepMerge from 'deepmerge'; import { makeConstToVarPlugin, + makeExtractPolyfillsPlugin, makeNodeResolvePlugin, makeRemoveBlankLinesPlugin, makeRemoveESLintCommentsPlugin, @@ -30,6 +31,7 @@ export function makeBaseNPMConfig(options = {}) { const constToVarPlugin = makeConstToVarPlugin(); const removeESLintCommentsPlugin = makeRemoveESLintCommentsPlugin(); const removeBlankLinesPlugin = makeRemoveBlankLinesPlugin(); + const extractPolyfillsPlugin = makeExtractPolyfillsPlugin(); // return { const config = { @@ -75,7 +77,14 @@ export function makeBaseNPMConfig(options = {}) { interop: esModuleInterop ? 'auto' : 'esModule', }, - plugins: [nodeResolvePlugin, sucrasePlugin, constToVarPlugin, removeESLintCommentsPlugin, removeBlankLinesPlugin], + plugins: [ + nodeResolvePlugin, + sucrasePlugin, + constToVarPlugin, + removeESLintCommentsPlugin, + removeBlankLinesPlugin, + extractPolyfillsPlugin, + ], // don't include imported modules from outside the package in the final output external: [ diff --git a/rollup/plugins/extractPolyfillsPlugin.js b/rollup/plugins/extractPolyfillsPlugin.js new file mode 100644 index 000000000000..e7b83b23dd35 --- /dev/null +++ b/rollup/plugins/extractPolyfillsPlugin.js @@ -0,0 +1,216 @@ +import * as path from 'path'; + +import * as recast from 'recast'; +import * as acornParser from 'recast/parsers/acorn'; + +const POLYFILL_NAMES = new Set([ + '_asyncNullishCoalesce', + '_asyncOptionalChain', + '_asyncOptionalChainDelete', + '_createNamedExportFrom', + '_createStarExport', + '_interopDefault', // rollup's version + '_interopNamespace', // rollup's version + '_interopNamespaceDefaultOnly', + '_interopRequireDefault', // sucrase's version + '_interopRequireWildcard', // sucrase's version + '_nullishCoalesce', + '_optionalChain', + '_optionalChainDelete', +]); + +/** + * Create a plugin which will replace function definitions of any of the above funcions with an `import` or `require` + * statement pulling them in from a central source. Mimics tsc's `importHelpers` option. + */ +export function makeExtractPolyfillsPlugin() { + let moduleFormat; + + // For more on the hooks used in this plugin, see https://rollupjs.org/guide/en/#output-generation-hooks + return { + name: 'extractPolyfills', + + // Figure out which build we're currently in (esm or cjs) + outputOptions(options) { + moduleFormat = options.format; + }, + + // This runs after both the sucrase transpilation (which happens in the `transform` hook) and rollup's own + // esm-i-fying or cjs-i-fying work (which happens right before `renderChunk`), in other words, after all polyfills + // will have been injected + renderChunk(code, chunk) { + const sourceFile = chunk.fileName; + + // We don't want to pull the function definitions out of their actual sourcefiles, just the places where they've + // been injected + if (sourceFile.includes('buildPolyfills')) { + return null; + } + + const parserOptions = { + sourceFileName: sourceFile, + // We supply a custom parser which wraps the provided `acorn` parser in order to override the `ecmaVersion` value. + // See https://github.com/benjamn/recast/issues/578. + parser: { + parse(source, options) { + return acornParser.parse(source, { + ...options, + // By this point in the build, everything should already have been down-compiled to whatever JS version + // we're targeting. Setting this parser to `latest` just means that whatever that version is (or changes + // to in the future), this parser will be able to handle the generated code. + ecmaVersion: 'latest', + }); + }, + }, + }; + + const ast = recast.parse(code, parserOptions); + + // Find function definitions and function expressions whose identifiers match a known polyfill name + const polyfillNodes = findPolyfillNodes(ast); + + if (polyfillNodes.length === 0) { + return null; + } + + console.log(`${sourceFile} - polyfills: ${polyfillNodes.map(node => node.name)}`); + + // Depending on the output format, generate `import { x, y, z } from '...'` or `var { x, y, z } = require('...')` + const importOrRequireNode = createImportOrRequireNode(polyfillNodes, sourceFile, moduleFormat); + + // Insert our new `import` or `require` node at the top of the file, and then delete the function definitions it's + // meant to replace (polyfill nodes get marked for deletion in `findPolyfillNodes`) + ast.program.body = [importOrRequireNode, ...ast.program.body.filter(node => !node.shouldDelete)]; + + // In spite of the name, this doesn't actually print anything - it just stringifies the code, and keeps track of + // where original nodes end up in order to generate a sourcemap. + const result = recast.print(ast, { + sourceMapName: `${sourceFile}.map`, + quote: 'single', + }); + + return { code: result.code, map: result.map }; + }, + }; +} + +/** + * Extract the function name, regardless of the format in which the function is declared + */ +function getNodeName(node) { + // Function expressions and functions pulled from objects + if (node.type === 'VariableDeclaration') { + // In practice sucrase and rollup only ever declare one polyfill at a time, so it's safe to just grab the first + // entry here + const declarationId = node.declarations[0].id; + + // Note: Sucrase and rollup seem to only use the first type of variable declaration for their polyfills, but good to + // cover our bases + + // Declarations of the form + // `const dogs = function() { return "are great"; };` + // or + // `const dogs = () => "are great"; + if (declarationId.type === 'Identifier') { + return declarationId.name; + } + // Declarations of the form + // `const { dogs } = { dogs: function() { return "are great"; } }` + // or + // `const { dogs } = { dogs: () => "are great" }` + else if (declarationId.type === 'ObjectPattern') { + return declarationId.properties[0].key.name; + } + // Any other format + else { + return 'unknown variable'; + } + } + + // Regular old functions, of the form + // `function dogs() { return "are great"; }` + else if (node.type === 'FunctionDeclaration') { + return node.id.name; + } + + // 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 + // polyfill names + else { + return 'nope'; + } +} + +/** + * Find all nodes whose identifiers match a known polyfill name. + * + * Note: In theory, this could yield false positives, if any of the magic names were assigned to something other than a + * polyfill function, but the chances of that are slim. Also, it only searches the module global scope, but that's + * always where the polyfills appear, so no reason to traverse the whole tree. + */ +function findPolyfillNodes(ast) { + const isPolyfillNode = node => { + const nodeName = getNodeName(node); + if (POLYFILL_NAMES.has(nodeName)) { + // Mark this node for later deletion, since we're going to replace it with an import statement + node.shouldDelete = true; + // Store the name in a consistent spot, regardless of node type + node.name = nodeName; + + return true; + } + + return false; + }; + + return ast.program.body.filter(isPolyfillNode); +} + +/** + * Create a node representing an `import` or `require` statement of the form + * + * import { < polyfills > } from '...' + * or + * var { < polyfills > } = require('...') + * + * @param polyfillNodes The nodes from the current version of the code, defining the polyfill functions + * @param currentSourceFile The path, relative to `src/`, of the file currently being transpiled + * @param moduleFormat Either 'cjs' or 'esm' + * @returns A single node which can be subbed in for the polyfill definition nodes + */ +function createImportOrRequireNode(polyfillNodes, currentSourceFile, moduleFormat) { + const { + callExpression, + identifier, + importDeclaration, + importSpecifier, + literal, + objectPattern, + property, + variableDeclaration, + variableDeclarator, + } = recast.types.builders; + + // Since our polyfills live in `@sentry/utils`, if we're importing or requiring them there the path will have to be + // relative + const isUtilsPackage = process.cwd().endsWith('packages/utils'); + const importSource = literal( + isUtilsPackage + ? `./${path.relative(path.dirname(currentSourceFile), 'buildPolyfills')}` + : `@sentry/utils/${moduleFormat}/buildPolyfills`, + ); + + // This is the `x, y, z` of inside of `import { x, y, z }` or `var { x, y, z }` + const importees = polyfillNodes.map(({ name: fnName }) => + moduleFormat === 'esm' + ? importSpecifier(identifier(fnName)) + : property.from({ kind: 'init', key: identifier(fnName), value: identifier(fnName), shorthand: true }), + ); + + const requireFn = identifier('require'); + + return moduleFormat === 'esm' + ? importDeclaration(importees, importSource) + : variableDeclaration('var', [ + variableDeclarator(objectPattern(importees), callExpression(requireFn, [importSource])), + ]); +} diff --git a/rollup/plugins/npmPlugins.js b/rollup/plugins/npmPlugins.js index 568ef6b69e10..eec8eab81cda 100644 --- a/rollup/plugins/npmPlugins.js +++ b/rollup/plugins/npmPlugins.js @@ -97,3 +97,5 @@ export function makeRemoveBlankLinesPlugin() { ], }); } + +export { makeExtractPolyfillsPlugin } from './extractPolyfillsPlugin.js';