Skip to content

feat(build): Add polyfill-extraction rollup plugin #5023

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
May 10, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
1 change: 1 addition & 0 deletions packages/gatsby/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
1 change: 1 addition & 0 deletions packages/wasm/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
11 changes: 10 additions & 1 deletion rollup/npmHelpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import deepMerge from 'deepmerge';

import {
makeConstToVarPlugin,
makeExtractPolyfillsPlugin,
makeNodeResolvePlugin,
makeRemoveBlankLinesPlugin,
makeRemoveESLintCommentsPlugin,
Expand All @@ -30,6 +31,7 @@ export function makeBaseNPMConfig(options = {}) {
const constToVarPlugin = makeConstToVarPlugin();
const removeESLintCommentsPlugin = makeRemoveESLintCommentsPlugin();
const removeBlankLinesPlugin = makeRemoveBlankLinesPlugin();
const extractPolyfillsPlugin = makeExtractPolyfillsPlugin();

// return {
const config = {
Expand Down Expand Up @@ -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: [
Expand Down
216 changes: 216 additions & 0 deletions rollup/plugins/extractPolyfillsPlugin.js
Original file line number Diff line number Diff line change
@@ -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])),
]);
}
2 changes: 2 additions & 0 deletions rollup/plugins/npmPlugins.js
Original file line number Diff line number Diff line change
Expand Up @@ -97,3 +97,5 @@ export function makeRemoveBlankLinesPlugin() {
],
});
}

export { makeExtractPolyfillsPlugin } from './extractPolyfillsPlugin.js';