Skip to content

Commit 1fe7a02

Browse files
lobsterkatieAbhiPrasad
authored andcommitted
feat(build): Vendor polyfills injected during build (#5051)
Both Rollup and Sucrase add polyfills (similar to those in `tslib`) during the build process, to down-compile newer language features and help ESM and CJS modules play nicely together. Unlike with `tslib`, however, there's no option equivalent to tsc's `importHelpers` option[1], which allows those polyfills to be imports from one central place rather than copies of the full code for each necessary polyfill in every file that uses them. Having all that repeated code is a surefire way to wreck our bundle-size progress, so as part of the switch to using those tools, we will replicate the behavior of `importHelpers` in the context of our new sucrase/rollup builds. This is the first of two PRs accomplishing that change. In this one, the polyfills we'll use have been added to the repo. In the next one, a rollup plugin will be added to replace the duplicated injected code with import statements, which will then import the polyfills this PR adds. Originally this was one PR, but it has been split into two for easier reviewing. Notes: - The polyfills themselves have been added to `@sentry/utils`, in a separate folder under `src/`. Various discussions have been had about where they should live[2], and they have moved around as this PR evolved. Without going into too much boring detail about about the exact pros and cons of each location, suffice it to say that my first instinct was to put them in `utils`, and as I worked on other options, each one of the reasons why I didn't actually do that to begin with (they weren't in TS, they weren't originally ours, I didn't want to pollute the `@sentry/utils` namespace) got solved, and in the end, putting them in `utils` was the simplest and most logical option. - The added polyfills are copies of the code injected by Sucrase or Rollup, translated into TS and in some cases modified in order to make them less verbose. Since some treeshaking algorithms can only work file by file, each one is in its own file. Polyfills which were modified have had tests added, both to ensure that they do the same thing as the originals, and to ensure that what they do is actually correct. - As we're not the original authors, attribution has been given within each file to the original source. Further, since the MIT license under which both Rollup and Sucrase are distributed requires duplication of the license blurb, it has been added to the README in the polyfills' folder. - Because we don't want these to become part of our public API, their code is not exported by `index.ts`, meaning they are not importable directly from `@sentry/utils` and won't come up under intellisense code completions and the like. When our code imports them, it will import from `@sentry/utils/cjs/buildPolyfills` or `@sentry/utils/esm/buildPolyfills` as appropriate. - Though not every polyfill Rollup and Sucrase can inject ends up being used in our built code, they are all included just to cover our bases. That said, in the interest of time, only the ones we use or are likely to use have had tests written for them. - One of the polyfills we do use is the optional chain polyfill. Typing it is tricky, because it takes a variable number of arguments, whose types are effectively `[ A, B, C, B, C, B, C,... ]` (one type for the first argument, and then alternating types for the rest). Because it's not going to be used by the public, the typing in the polyfill itself uses the generic `unknown`. For tests it was easier, because only the test cases needed to be typed, and could have a slightly modified structure: `[ A, [ B, C ][] ]`, which then gets flattened again before being passed to the optional chain polyfill. In order to be able to use `Array.prototype.flat` in tests run on older versions of Node, it itself has been polyfilled, and our testing `tsconfig` target ramped back down when running tests under those old versions. [1] https://www.typescriptlang.org/tsconfig#importHelpers [2] #5023 (comment)
1 parent ef336c3 commit 1fe7a02

31 files changed

+1231
-9
lines changed

packages/utils/.eslintrc.js

+16
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,19 @@
11
module.exports = {
22
extends: ['../../.eslintrc.js'],
3+
overrides: [
4+
{
5+
files: ['scripts/**/*.ts'],
6+
parserOptions: {
7+
project: ['../../tsconfig.dev.json'],
8+
},
9+
},
10+
{
11+
files: ['test/**'],
12+
parserOptions: {
13+
sourceType: 'module',
14+
},
15+
},
16+
],
17+
// symlinks to the folders inside of `build`, created to simulate what's in the npm package
18+
ignorePatterns: ['cjs/**', 'esm/**'],
319
};

packages/utils/.gitignore

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
# symlinks to the folders in `build`, needed for tests
2+
cjs
3+
esm
4+
5+
# needed so we can test our versions of polyfills against Sucrase and Rollup's originals
6+
!test/buildPolyfills/originals.d.ts

packages/utils/jest.config.js

+9-1
Original file line numberDiff line numberDiff line change
@@ -1 +1,9 @@
1-
module.exports = require('../../jest/jest.config.js');
1+
const baseConfig = require('../../jest/jest.config.js');
2+
3+
module.exports = {
4+
...baseConfig,
5+
transform: {
6+
'^.+\\.ts$': 'ts-jest',
7+
'^.+\\.js$': 'ts-jest',
8+
},
9+
};

packages/utils/package.json

+4-2
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@
2020
"tslib": "^1.9.3"
2121
},
2222
"devDependencies": {
23+
"@types/array.prototype.flat": "^1.2.1",
24+
"array.prototype.flat": "^1.3.0",
2325
"chai": "^4.1.2"
2426
},
2527
"scripts": {
@@ -28,7 +30,7 @@
2830
"build:dev": "run-s build",
2931
"build:es5": "yarn build:cjs # *** backwards compatibility - remove in v7 ***",
3032
"build:esm": "tsc -p tsconfig.esm.json",
31-
"build:rollup": "rollup -c rollup.npm.config.js",
33+
"build:rollup": "yarn ts-node scripts/buildRollup.ts",
3234
"build:types": "tsc -p tsconfig.types.json",
3335
"build:watch": "run-p build:cjs:watch build:esm:watch build:types:watch",
3436
"build:cjs:watch": "tsc -p tsconfig.cjs.json --watch",
@@ -39,7 +41,7 @@
3941
"build:types:watch": "tsc -p tsconfig.types.json --watch",
4042
"build:npm": "ts-node ../../scripts/prepack.ts && npm pack ./build",
4143
"circularDepCheck": "madge --circular src/index.ts",
42-
"clean": "rimraf build coverage",
44+
"clean": "rimraf build coverage cjs esm",
4345
"fix": "run-s fix:eslint fix:prettier",
4446
"fix:eslint": "eslint . --format stylish --fix",
4547
"fix:prettier": "prettier --write \"{src,test,scripts}/**/*.ts\"",

packages/utils/rollup.npm.config.js

+7-1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
11
import { makeBaseNPMConfig, makeNPMConfigVariants } from '../../rollup/index.js';
22

3-
export default makeNPMConfigVariants(makeBaseNPMConfig());
3+
export default makeNPMConfigVariants(
4+
makeBaseNPMConfig({
5+
// We build the polyfills separately because they're not included in the top-level exports of the package, in order
6+
// to keep them out of the public API.
7+
entrypoints: ['src/index.ts', 'src/buildPolyfills/index.ts'],
8+
}),
9+
);

packages/utils/scripts/buildRollup.ts

+30
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import * as childProcess from 'child_process';
2+
import * as fs from 'fs';
3+
4+
/**
5+
* Run the given shell command, piping the shell process's `stdin`, `stdout`, and `stderr` to that of the current
6+
* process. Returns contents of `stdout`.
7+
*/
8+
function run(cmd: string, options?: childProcess.ExecSyncOptions): string | Buffer {
9+
return childProcess.execSync(cmd, { stdio: 'inherit', ...options });
10+
}
11+
12+
run('yarn rollup -c rollup.npm.config.js');
13+
14+
// We want to distribute the README because it contains the MIT license blurb from Sucrase and Rollup
15+
fs.copyFileSync('src/buildPolyfills/README.md', 'build/cjs/buildPolyfills/README.md');
16+
fs.copyFileSync('src/buildPolyfills/README.md', 'build/esm/buildPolyfills/README.md');
17+
18+
// Because we import our polyfills from `@sentry/utils/cjs/buildPolyfills` and `@sentry/utils/esm/buildPolyfills` rather
19+
// than straight from `@sentry/utils` (so as to avoid having them in the package's public API), when tests run, they'll
20+
// expect to find `cjs` and `esm` at the root level of the repo.
21+
try {
22+
fs.symlinkSync('build/cjs', 'cjs');
23+
} catch (oO) {
24+
// if we get here, it's because the symlink already exists, so we're good
25+
}
26+
try {
27+
fs.symlinkSync('build/esm', 'esm');
28+
} catch (oO) {
29+
// same as above
30+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
## Build Polyfills
2+
3+
This is a collection of syntax and import/export polyfills either copied directly from or heavily inspired by those used by [Rollup](https://github.com/rollup/rollup) and [Sucrase](https://github.com/alangpierce/sucrase). When either tool uses one of these polyfills during a build, it injects the function source code into each file needing the function, which can lead to a great deal of duplication. For our builds, we have therefore implemented something similar to [`tsc`'s `importHelpers` behavior](https://www.typescriptlang.org/tsconfig#importHelpers): Instead of leaving the polyfills injected in multiple places, we instead replace each injected function with an `import` or `require` statement, pulling from the CJS or ESM builds as appropriate. (In other words, the injected `import` statements import from `@sentry/utils/esm/buildPolyfills` and the injected `require` statements pull from `@sentry/utils/cjs/buildPolyfills/`. Because these functions should never be part of the public API, they're not exported from the package directly.)
4+
5+
Note that not all polyfills are currently used by the SDK, but all are included here for future compatitibility, should they ever be needed. Also, since we're never going to be calling these directly from within another TS file, their types are fairly generic. In some cases testing required more specific types, which can be found in the test files.
6+
7+
--------
8+
9+
_Code from both Rollup and Sucrase is used under the MIT license, copyright 2017 and 2012-2018, respectively._
10+
11+
_Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:_
12+
13+
_The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software._
14+
15+
_THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE._
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
// adapted from Sucrase (https://github.com/alangpierce/sucrase)
2+
3+
import { _nullishCoalesce } from './_nullishCoalesce';
4+
5+
/**
6+
* Polyfill for the nullish coalescing operator (`??`), when used in situations where at least one of the values is the
7+
* result of an async operation.
8+
*
9+
* Note that the RHS is wrapped in a function so that if it's a computed value, that evaluation won't happen unless the
10+
* LHS evaluates to a nullish value, to mimic the operator's short-circuiting behavior.
11+
*
12+
* Adapted from Sucrase (https://github.com/alangpierce/sucrase)
13+
*
14+
* @param lhs The value of the expression to the left of the `??`
15+
* @param rhsFn A function returning the value of the expression to the right of the `??`
16+
* @returns The LHS value, unless it's `null` or `undefined`, in which case, the RHS value
17+
*/
18+
// eslint-disable-next-line @sentry-internal/sdk/no-async-await
19+
export async function _asyncNullishCoalesce(lhs: unknown, rhsFn: () => unknown): Promise<unknown> {
20+
return _nullishCoalesce(lhs, rhsFn);
21+
}
22+
23+
// Sucrase version:
24+
// async function _asyncNullishCoalesce(lhs, rhsFn) {
25+
// if (lhs != null) {
26+
// return lhs;
27+
// } else {
28+
// return await rhsFn();
29+
// }
30+
// }
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { GenericFunction } from './types';
2+
3+
/**
4+
* Polyfill for the optional chain operator, `?.`, given previous conversion of the expression into an array of values,
5+
* descriptors, and functions, for situations in which at least one part of the expression is async.
6+
*
7+
* Adapted from Sucrase (https://github.com/alangpierce/sucrase) See
8+
* https://github.com/alangpierce/sucrase/blob/265887868966917f3b924ce38dfad01fbab1329f/src/transformers/OptionalChainingNullishTransformer.ts#L15
9+
*
10+
* @param ops Array result of expression conversion
11+
* @returns The value of the expression
12+
*/
13+
// eslint-disable-next-line @sentry-internal/sdk/no-async-await
14+
export async function _asyncOptionalChain(ops: unknown[]): Promise<unknown> {
15+
let lastAccessLHS: unknown = undefined;
16+
let value = ops[0];
17+
let i = 1;
18+
while (i < ops.length) {
19+
const op = ops[i] as string;
20+
const fn = ops[i + 1] as (intermediateValue: unknown) => Promise<unknown>;
21+
i += 2;
22+
// by checking for loose equality to `null`, we catch both `null` and `undefined`
23+
if ((op === 'optionalAccess' || op === 'optionalCall') && value == null) {
24+
// really we're meaning to return `undefined` as an actual value here, but it saves bytes not to write it
25+
return;
26+
}
27+
if (op === 'access' || op === 'optionalAccess') {
28+
lastAccessLHS = value;
29+
value = await fn(value);
30+
} else if (op === 'call' || op === 'optionalCall') {
31+
value = await fn((...args: unknown[]) => (value as GenericFunction).call(lastAccessLHS, ...args));
32+
lastAccessLHS = undefined;
33+
}
34+
}
35+
return value;
36+
}
37+
38+
// Sucrase version:
39+
// async function _asyncOptionalChain(ops) {
40+
// let lastAccessLHS = undefined;
41+
// let value = ops[0];
42+
// let i = 1;
43+
// while (i < ops.length) {
44+
// const op = ops[i];
45+
// const fn = ops[i + 1];
46+
// i += 2;
47+
// if ((op === 'optionalAccess' || op === 'optionalCall') && value == null) {
48+
// return undefined;
49+
// }
50+
// if (op === 'access' || op === 'optionalAccess') {
51+
// lastAccessLHS = value;
52+
// value = await fn(value);
53+
// } else if (op === 'call' || op === 'optionalCall') {
54+
// value = await fn((...args) => value.call(lastAccessLHS, ...args));
55+
// lastAccessLHS = undefined;
56+
// }
57+
// }
58+
// return value;
59+
// }
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { _asyncOptionalChain } from './_asyncOptionalChain';
2+
3+
/**
4+
* Polyfill for the optional chain operator, `?.`, given previous conversion of the expression into an array of values,
5+
* descriptors, and functions, in cases where the value of the expression is to be deleted.
6+
*
7+
* Adapted from Sucrase (https://github.com/alangpierce/sucrase) See
8+
* https://github.com/alangpierce/sucrase/blob/265887868966917f3b924ce38dfad01fbab1329f/src/transformers/OptionalChainingNullishTransformer.ts#L15
9+
*
10+
* @param ops Array result of expression conversion
11+
* @returns The return value of the `delete` operator: `true`, unless the deletion target is an own, non-configurable
12+
* property (one which can't be deleted or turned into an accessor, and whose enumerability can't be changed), in which
13+
* case `false`.
14+
*/
15+
// eslint-disable-next-line @sentry-internal/sdk/no-async-await
16+
export async function _asyncOptionalChainDelete(ops: unknown[]): Promise<boolean> {
17+
const result = (await _asyncOptionalChain(ops)) as Promise<boolean | null>;
18+
// If `result` is `null`, it means we didn't get to the end of the chain and so nothing was deleted (in which case,
19+
// return `true` since that's what `delete` does when it no-ops). If it's non-null, we know the delete happened, in
20+
// which case we return whatever the `delete` returned, which will be a boolean.
21+
return result == null ? true : (result as Promise<boolean>);
22+
}
23+
24+
// Sucrase version:
25+
// async function asyncOptionalChainDelete(ops) {
26+
// const result = await ASYNC_OPTIONAL_CHAIN_NAME(ops);
27+
// return result == null ? true : result;
28+
// }
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { GenericObject } from './types';
2+
3+
declare const exports: GenericObject;
4+
5+
/**
6+
* Copy a property from the given object into `exports`, under the given name.
7+
*
8+
* Adapted from Sucrase (https://github.com/alangpierce/sucrase)
9+
*
10+
* @param obj The object containing the property to copy.
11+
* @param localName The name under which to export the property
12+
* @param importedName The name under which the property lives in `obj`
13+
*/
14+
export function _createNamedExportFrom(obj: GenericObject, localName: string, importedName: string): void {
15+
exports[localName] = obj[importedName];
16+
}
17+
18+
// Sucrase version:
19+
// function _createNamedExportFrom(obj, localName, importedName) {
20+
// Object.defineProperty(exports, localName, {enumerable: true, get: () => obj[importedName]});
21+
// }
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { GenericObject } from './types';
2+
3+
declare const exports: GenericObject;
4+
5+
/**
6+
* Copy properties from an object into `exports`.
7+
*
8+
* Adapted from Sucrase (https://github.com/alangpierce/sucrase)
9+
*
10+
* @param obj The object containing the properties to copy.
11+
*/
12+
export function _createStarExport(obj: GenericObject): void {
13+
Object.keys(obj)
14+
.filter(key => key !== 'default' && key !== '__esModule' && !(key in exports))
15+
.forEach(key => (exports[key] = obj[key]));
16+
}
17+
18+
// Sucrase version:
19+
// function _createStarExport(obj) {
20+
// Object.keys(obj)
21+
// .filter(key => key !== 'default' && key !== '__esModule')
22+
// .forEach(key => {
23+
// if (exports.hasOwnProperty(key)) {
24+
// return;
25+
// }
26+
// Object.defineProperty(exports, key, { enumerable: true, get: () => obj[key] });
27+
// });
28+
// }
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { RequireResult } from './types';
2+
3+
/**
4+
* Unwraps a module if it has been wrapped in an object under the key `default`.
5+
*
6+
* Adapted from Rollup (https://github.com/rollup/rollup)
7+
*
8+
* @param requireResult The result of calling `require` on a module
9+
* @returns The full module, unwrapped if necessary.
10+
*/
11+
export function _interopDefault(requireResult: RequireResult): RequireResult {
12+
return requireResult.__esModule ? (requireResult.default as RequireResult) : requireResult;
13+
}
14+
15+
// Rollup version:
16+
// function _interopDefault(e) {
17+
// return e && e.__esModule ? e['default'] : e;
18+
// }
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { RequireResult } from './types';
2+
3+
/**
4+
* Adds a self-referential `default` property to CJS modules which aren't the result of transpilation from ESM modules.
5+
*
6+
* Adapted from Rollup (https://github.com/rollup/rollup)
7+
*
8+
* @param requireResult The result of calling `require` on a module
9+
* @returns Either `requireResult` or a copy of `requireResult` with an added self-referential `default` property
10+
*/
11+
export function _interopNamespace(requireResult: RequireResult): RequireResult {
12+
return requireResult.__esModule ? requireResult : { ...requireResult, default: requireResult };
13+
}
14+
15+
// Rollup version (with `output.externalLiveBindings` and `output.freeze` both set to false)
16+
// function _interopNamespace(e) {
17+
// if (e && e.__esModule) return e;
18+
// var n = Object.create(null);
19+
// if (e) {
20+
// for (var k in e) {
21+
// n[k] = e[k];
22+
// }
23+
// }
24+
// n["default"] = e;
25+
// return n;
26+
// }
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { RequireResult } from './types';
2+
3+
/**
4+
* Wrap a module in an object, as the value under the key `default`.
5+
*
6+
* Adapted from Rollup (https://github.com/rollup/rollup)
7+
*
8+
* @param requireResult The result of calling `require` on a module
9+
* @returns An object containing the key-value pair (`default`, `requireResult`)
10+
*/
11+
export function _interopNamespaceDefaultOnly(requireResult: RequireResult): RequireResult {
12+
return {
13+
__proto__: null,
14+
default: requireResult,
15+
};
16+
}
17+
18+
// Rollup version
19+
// function _interopNamespaceDefaultOnly(e) {
20+
// return {
21+
// __proto__: null,
22+
// 'default': e
23+
// };
24+
// }
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { RequireResult } from './types';
2+
3+
/**
4+
* Wraps modules which aren't the result of transpiling an ESM module in an object under the key `default`
5+
*
6+
* Adapted from Sucrase (https://github.com/alangpierce/sucrase)
7+
*
8+
* @param requireResult The result of calling `require` on a module
9+
* @returns `requireResult` or `requireResult` wrapped in an object, keyed as `default`
10+
*/
11+
export function _interopRequireDefault(requireResult: RequireResult): RequireResult {
12+
return requireResult.__esModule ? requireResult : { default: requireResult };
13+
}
14+
15+
// Sucrase version
16+
// function _interopRequireDefault(obj) {
17+
// return obj && obj.__esModule ? obj : { default: obj };
18+
// }

0 commit comments

Comments
 (0)