Skip to content

Commit c331c84

Browse files
committed
create plugin for extracting polyfills
1 parent 3630b87 commit c331c84

File tree

3 files changed

+228
-1
lines changed

3 files changed

+228
-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 = {
@@ -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)