Skip to content

Commit 687703b

Browse files
committed
create plugin for extracting polyfills
1 parent 7f17e5e commit 687703b

File tree

3 files changed

+220
-1
lines changed

3 files changed

+220
-1
lines changed

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 = {
@@ -71,7 +73,14 @@ export function makeBaseNPMConfig(options = {}) {
7173
interop: esModuleInterop ? 'auto' : 'esModule',
7274
},
7375

74-
plugins: [nodeResolvePlugin, sucrasePlugin, constToVarPlugin, removeESLintCommentsPlugin, removeBlankLinesPlugin],
76+
plugins: [
77+
nodeResolvePlugin,
78+
sucrasePlugin,
79+
constToVarPlugin,
80+
removeESLintCommentsPlugin,
81+
removeBlankLinesPlugin,
82+
extractPolyfillsPlugin,
83+
],
7584

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

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)