Skip to content

Commit d927e1b

Browse files
committed
create plugin for extracting polyfills
1 parent 73c0f09 commit d927e1b

File tree

3 files changed

+215
-1
lines changed

3 files changed

+215
-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: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
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+
/** Extract the function name, regardless of the format in which the function is declared */
90+
function getNodeName(node) {
91+
// Function expressions and functions pulled from objects
92+
if (node.type === 'VariableDeclaration') {
93+
// In practice sucrase and rollup only ever declare one polyfill at a time, so it's safe to just grab the first
94+
// entry here
95+
const declarationId = node.declarations[0].id;
96+
97+
// Note: Sucrase and rollup seem to only use the first type of variable declaration for their polyfills, but good to
98+
// cover our bases
99+
100+
// Declarations of the form
101+
// `const dogs = function() { return "are great"; };`
102+
// or
103+
// `const dogs = () => "are great";
104+
if (declarationId.type === 'Identifier') {
105+
return declarationId.name;
106+
}
107+
// Declarations of the form
108+
// `const { dogs } = { dogs: function() { return "are great"; } }`
109+
// or
110+
// `const { dogs } = { dogs: () => "are great" }`
111+
else if (declarationId.type === 'ObjectPattern') {
112+
return declarationId.properties[0].key.name;
113+
}
114+
// Any other format
115+
else {
116+
return 'unknown variable';
117+
}
118+
}
119+
120+
// Regular old functions, of the form
121+
// `function dogs() { return "are great"; }`
122+
else if (node.type === 'FunctionDeclaration') {
123+
return node.id.name;
124+
}
125+
126+
// 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
127+
// polyfill names
128+
else {
129+
return 'nope';
130+
}
131+
}
132+
133+
/**
134+
* Find all nodes whose identifiers match a known polyfill name. (Note: In theory, this could yield false positives, if
135+
* any of the magic names were assigned to something other than a polyfill function, but the chances of that are slim.)
136+
*/
137+
function findPolyfillNodes(ast) {
138+
const isPolyfillNode = node => {
139+
const nodeName = getNodeName(node);
140+
if (POLYFILL_NAMES.has(nodeName)) {
141+
// mark this node for later deletion, since we're going to replace it with an import statement
142+
node.shouldDelete = true;
143+
// store the name in a consistent spot, regardless of node type
144+
node.name = nodeName;
145+
146+
return true;
147+
}
148+
149+
return false;
150+
};
151+
152+
return ast.program.body.filter(isPolyfillNode);
153+
}
154+
155+
/**
156+
* Create a node representing an `import` or `require` statement of the form
157+
*
158+
* import { < polyfills > } from '...'
159+
* or
160+
* var { < polyfills > } = require('...')
161+
*
162+
* @param polyfillNodes The nodes from the current version of the code, defining the polyfill functions
163+
* @param currentSourceFile The path, relative to `src/`, of the file currently being transpiled
164+
* @param moduleFormat Either 'cjs' or 'esm'
165+
* @returns A single node which can be subbed in for the polyfill definition nodes
166+
*/
167+
function createImportOrRequireNode(polyfillNodes, currentSourceFile, moduleFormat) {
168+
const {
169+
callExpression,
170+
identifier,
171+
importDeclaration,
172+
importSpecifier,
173+
literal,
174+
objectPattern,
175+
property,
176+
variableDeclaration,
177+
variableDeclarator,
178+
} = recast.types.builders;
179+
180+
// Since our polyfills live in `@sentry/utils`, if we're importing or requiring them there the path will have to be
181+
// relative
182+
const isUtilsPackage = process.cwd().endsWith('packages/utils');
183+
const importSource = literal(
184+
isUtilsPackage
185+
? path.relative(path.dirname(currentSourceFile), `../jsPolyfills/${moduleFormat}`)
186+
: `@sentry/utils/jsPolyfills/${moduleFormat}`,
187+
);
188+
189+
// This is the `x, y, z` of inside of `import { x, y, z }` or `var { x, y, z }`
190+
const importees = polyfillNodes.map(({ name: fnName }) =>
191+
moduleFormat === 'esm'
192+
? importSpecifier(identifier(fnName))
193+
: property.from({ kind: 'init', key: identifier(fnName), value: identifier(fnName), shorthand: true }),
194+
);
195+
196+
const requireFn = identifier('require');
197+
198+
return moduleFormat === 'esm'
199+
? importDeclaration(importees, importSource)
200+
: variableDeclaration('var', [
201+
variableDeclarator(objectPattern(importees), callExpression(requireFn, [importSource])),
202+
]);
203+
}

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)