Skip to content

Commit cb9260f

Browse files
authored
feat(build): Add polyfill-extraction rollup plugin (#5023)
This is a follow-up to #5051, which added our own versions of the polyfills that Rollup and Sucrase inject during build, in order to be able to import them rather than have their code repeated in every file in which they're needed, essentially replicating the behavior of `tslib`'s `importHelpers` option. This completes that change, by adding a rollup plugin to the build process which extracts the injected code and replaces it with an equivalent `import` or `require` statement. Because the import comes from `@sentry/utils`, the few packages which didn't already depend on it have had it added as a dependency.
1 parent 904b2d3 commit cb9260f

File tree

6 files changed

+232
-1
lines changed

6 files changed

+232
-1
lines changed

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@
6767
"@types/mocha": "^5.2.0",
6868
"@types/node": "~10.17.0",
6969
"@types/sinon": "^7.0.11",
70+
"acorn": "^8.7.0",
7071
"chai": "^4.1.2",
7172
"codecov": "^3.6.5",
7273
"deepmerge": "^4.2.2",
@@ -81,6 +82,7 @@
8182
"mocha": "^6.1.4",
8283
"npm-run-all": "^4.1.5",
8384
"prettier": "2.5.1",
85+
"recast": "^0.20.5",
8486
"replace-in-file": "^4.0.0",
8587
"rimraf": "^3.0.2",
8688
"rollup": "^2.67.1",

packages/gatsby/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
"dependencies": {
2323
"@sentry/react": "7.0.0-beta.0",
2424
"@sentry/tracing": "7.0.0-beta.0",
25+
"@sentry/utils": "7.0.0-beta.0",
2526
"@sentry/webpack-plugin": "1.18.9"
2627
},
2728
"peerDependencies": {

packages/wasm/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
"dependencies": {
1919
"@sentry/browser": "7.0.0-beta.0",
2020
"@sentry/types": "7.0.0-beta.0",
21+
"@sentry/utils": "7.0.0-beta.0",
2122
"tslib": "^1.9.3"
2223
},
2324
"devDependencies": {

rollup/npmHelpers.js

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import deepMerge from 'deepmerge';
99

1010
import {
1111
makeConstToVarPlugin,
12+
makeExtractPolyfillsPlugin,
1213
makeNodeResolvePlugin,
1314
makeRemoveBlankLinesPlugin,
1415
makeRemoveESLintCommentsPlugin,
@@ -30,6 +31,7 @@ export function makeBaseNPMConfig(options = {}) {
3031
const constToVarPlugin = makeConstToVarPlugin();
3132
const removeESLintCommentsPlugin = makeRemoveESLintCommentsPlugin();
3233
const removeBlankLinesPlugin = makeRemoveBlankLinesPlugin();
34+
const extractPolyfillsPlugin = makeExtractPolyfillsPlugin();
3335

3436
// return {
3537
const config = {
@@ -75,7 +77,14 @@ export function makeBaseNPMConfig(options = {}) {
7577
interop: esModuleInterop ? 'auto' : 'esModule',
7678
},
7779

78-
plugins: [nodeResolvePlugin, sucrasePlugin, constToVarPlugin, removeESLintCommentsPlugin, removeBlankLinesPlugin],
80+
plugins: [
81+
nodeResolvePlugin,
82+
sucrasePlugin,
83+
constToVarPlugin,
84+
removeESLintCommentsPlugin,
85+
removeBlankLinesPlugin,
86+
extractPolyfillsPlugin,
87+
],
7988

8089
// don't include imported modules from outside the package in the final output
8190
external: [
Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
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+
}

rollup/plugins/npmPlugins.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,3 +97,5 @@ export function makeRemoveBlankLinesPlugin() {
9797
],
9898
});
9999
}
100+
101+
export { makeExtractPolyfillsPlugin } from './extractPolyfillsPlugin.js';

0 commit comments

Comments
 (0)