Skip to content

Commit f35246f

Browse files
committed
create plugin for extracting polyfills
1 parent 2ddfa6a commit f35246f

File tree

3 files changed

+213
-1
lines changed

3 files changed

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

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)