diff --git a/.gitignore b/.gitignore index 430a977178a7..be80e41d00b7 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,8 @@ scratch/ scenarios/*/dist/ # transpiled transformers jest/transformers/*.js +# node tarballs +packages/*/sentry-*.tgz # logs yarn-error.log diff --git a/package.json b/package.json index f2b81a98123e..a1630f24cdfc 100644 --- a/package.json +++ b/package.json @@ -67,10 +67,12 @@ "@types/mocha": "^5.2.0", "@types/node": "~10.17.0", "@types/sinon": "^7.0.11", + "acorn": "^8.7.0", "chai": "^4.1.2", "codecov": "^3.6.5", "deepmerge": "^4.2.2", "eslint": "7.32.0", + "fs-extra": "^10.1.0", "jest": "^27.5.1", "jest-environment-node": "^27.5.1", "jsdom": "^19.0.0", @@ -81,6 +83,7 @@ "mocha": "^6.1.4", "npm-run-all": "^4.1.5", "prettier": "2.5.1", + "recast": "^0.20.5", "replace-in-file": "^4.0.0", "rimraf": "^3.0.2", "rollup": "^2.67.1", diff --git a/packages/gatsby/package.json b/packages/gatsby/package.json index 50811fce6b03..504b34015793 100644 --- a/packages/gatsby/package.json +++ b/packages/gatsby/package.json @@ -22,6 +22,7 @@ "dependencies": { "@sentry/react": "7.0.0-beta.0", "@sentry/tracing": "7.0.0-beta.0", + "@sentry/utils": "7.0.0-beta.0", "@sentry/webpack-plugin": "1.18.9" }, "peerDependencies": { diff --git a/packages/utils/.eslintrc.js b/packages/utils/.eslintrc.js index 5a2cc7f1ec08..f0112704e4a1 100644 --- a/packages/utils/.eslintrc.js +++ b/packages/utils/.eslintrc.js @@ -1,3 +1,12 @@ module.exports = { extends: ['../../.eslintrc.js'], + overrides: [ + { + files: ['scripts/**/*.ts'], + parserOptions: { + project: ['../../tsconfig.dev.json'], + }, + }, + ], + ignorePatterns: ['jsPolyfills/**'], }; diff --git a/packages/utils/.gitignore b/packages/utils/.gitignore new file mode 100644 index 000000000000..7bbaced569d9 --- /dev/null +++ b/packages/utils/.gitignore @@ -0,0 +1 @@ +jsPolyfills/ diff --git a/packages/utils/.npmignore b/packages/utils/.npmignore index 329293958886..81ff5ae73ae7 100644 --- a/packages/utils/.npmignore +++ b/packages/utils/.npmignore @@ -13,3 +13,6 @@ !/build/cjs/**/* !/build/esm/**/* !/build/types/**/* + +# polyfills for language features and import helpers +!jsPolyfills/**/* diff --git a/packages/utils/package.json b/packages/utils/package.json index d67412868965..583d2764bdea 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -28,7 +28,7 @@ "build:dev": "run-s build", "build:es5": "yarn build:cjs # *** backwards compatibility - remove in v7 ***", "build:esm": "tsc -p tsconfig.esm.json", - "build:rollup": "rollup -c rollup.npm.config.js", + "build:rollup": "rollup -c rollup.npm.config.js && rollup -c rollup.polyfills.config.js && cp ../../rollup/jsPolyfills/README.md jsPolyfills", "build:types": "tsc -p tsconfig.types.json", "build:watch": "run-p build:cjs:watch build:esm:watch build:types:watch", "build:cjs:watch": "tsc -p tsconfig.cjs.json --watch", @@ -39,7 +39,7 @@ "build:types:watch": "tsc -p tsconfig.types.json --watch", "build:npm": "ts-node ../../scripts/prepack.ts && npm pack ./build", "circularDepCheck": "madge --circular src/index.ts", - "clean": "rimraf build coverage", + "clean": "rimraf build coverage jsPolyfills", "fix": "run-s fix:eslint fix:prettier", "fix:eslint": "eslint . --format stylish --fix", "fix:prettier": "prettier --write \"{src,test,scripts}/**/*.ts\"", diff --git a/packages/utils/rollup.polyfills.config.js b/packages/utils/rollup.polyfills.config.js new file mode 100644 index 000000000000..4eb1d8a139ac --- /dev/null +++ b/packages/utils/rollup.polyfills.config.js @@ -0,0 +1,28 @@ +// TODO: Swich out the sucrase hack for the real, currently-commented-out code once we switch sucrase builds on. + +// export default ['esm', 'cjs'].map(format => ({ +export default ['esm', 'cjs'].map(format => { + const config = { + input: '../../rollup/jsPolyfills/index.js', + output: { + // preserveModules: true, + dir: `jsPolyfills/${format}`, + format, + strict: false, + }, + }; + // })); + + // temporary hack for testing sucrase bundles before we switch over + if (!process.version.startsWith('v8')) { + // eslint-disable-next-line no-console + console.log('Doing normal preserveModules in polyfill config'); + config.output.preserveModules = true; + } else { + // eslint-disable-next-line no-console + console.log('Doing node 8 preserveModules in polyfill config'); + config.preserveModules = true; + } + + return config; +}); diff --git a/packages/utils/scripts/prepack.ts b/packages/utils/scripts/prepack.ts new file mode 100644 index 000000000000..f7712d99fb19 --- /dev/null +++ b/packages/utils/scripts/prepack.ts @@ -0,0 +1,28 @@ +/* eslint-disable no-console */ + +// DO NOT RUN this script yourself! +// This is invoked from the main `prepack.ts` script in `sentry-javascript/scripts/prepack.ts`. + +import * as fs from 'fs'; +import * as fse from 'fs-extra'; +import * as path from 'path'; + +export function prepack(buildDir: string): boolean { + // copy package-specific assets to build dir + const assetPath = path.resolve('jsPolyfills'); + const destinationPath = path.resolve(buildDir, 'jsPolyfills'); + try { + if (!fs.existsSync(assetPath)) { + console.error( + "\nERROR: Missing 'packages/utils/jsPolyfills' directory. Please run `yarn build` in the `utils` package before running this script again.", + ); + return false; + } + console.log(`Copying jsPolyfills to ${path.relative('../..', destinationPath)}.`); + fse.copySync(assetPath, destinationPath); + } catch (error) { + console.error(`\nERROR: Error while copying jsPolyfills to ${path.relative('../..', destinationPath)}:\n${error}`); + return false; + } + return true; +} diff --git a/packages/wasm/package.json b/packages/wasm/package.json index 831d45671b0c..0dfffd50df12 100644 --- a/packages/wasm/package.json +++ b/packages/wasm/package.json @@ -18,6 +18,7 @@ "dependencies": { "@sentry/browser": "7.0.0-beta.0", "@sentry/types": "7.0.0-beta.0", + "@sentry/utils": "7.0.0-beta.0", "tslib": "^1.9.3" }, "devDependencies": { diff --git a/rollup/jsPolyfills/README.md b/rollup/jsPolyfills/README.md new file mode 100644 index 000000000000..9fec3423eca7 --- /dev/null +++ b/rollup/jsPolyfills/README.md @@ -0,0 +1,17 @@ +## Javascript Polyfills + +This is a collection of syntax 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. This directory is the location from which we import. For simplicity (and greater treeshaking ability when using tools which only work on a file-by-file level), each polyfill lives in its own file. + +During build, this directory is copied over to the root level of `@sentry/utils`, and the polyfills are compiled into esm and cjs versions. When the injected implementations are replaced by either `import` or `require` by our rollup plugin, each `import`/`require` pulls from the correct module format's folder. (In other words, the injected `import` statements import from `@sentry/utils/jsPolyfills/esm` and the injected `require` statements pull from `@sentry/utils/jsPolyfills/cjs`.) + +Note that not all polyfills are currently used by the SDK, but all are included here for future compatitibility, should they ever be needed. + +-------- + +_Code from both Rollup and Sucrase is used under the MIT license, copyright 2017 and 2012-2018, respectively._ + +_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:_ + +_The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software._ + +_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._ diff --git a/rollup/jsPolyfills/_asyncNullishCoalesce.js b/rollup/jsPolyfills/_asyncNullishCoalesce.js new file mode 100644 index 000000000000..0bb5eba8e6da --- /dev/null +++ b/rollup/jsPolyfills/_asyncNullishCoalesce.js @@ -0,0 +1,15 @@ +// adapted from Sucrase (https://github.com/alangpierce/sucrase) + +export async function _asyncNullishCoalesce(lhs, rhsFn) { + // by checking for loose equality to `null`, we catch both `null` and `undefined` + return lhs != null ? lhs : rhsFn(); +} + +// Sucrase version: +// async function _asyncNullishCoalesce(lhs, rhsFn) { +// if (lhs != null) { +// return lhs; +// } else { +// return await rhsFn(); +// } +// } diff --git a/rollup/jsPolyfills/_asyncOptionalChain.js b/rollup/jsPolyfills/_asyncOptionalChain.js new file mode 100644 index 000000000000..4bdc8f127437 --- /dev/null +++ b/rollup/jsPolyfills/_asyncOptionalChain.js @@ -0,0 +1,48 @@ +// adapted from Sucrase (https://github.com/alangpierce/sucrase) + +export async function _asyncOptionalChain(ops) { + let lastAccessLHS = undefined; + let value = ops[0]; + let i = 1; + while (i < ops.length) { + const op = ops[i]; + const fn = ops[i + 1]; + i += 2; + // by checking for loose equality to `null`, we catch both `null` and `undefined` + if (op in ['optionalAccess', 'optionalCall'] && value == null) { + // really we're meaning to return `undefined` as an actual value here, but it saves bytes not to write it + return; + } + if (op in ['access', 'optionalAccess']) { + lastAccessLHS = value; + value = await fn(value); + } else if (op in ['call', 'optionalCall']) { + value = await fn((...args) => value.call(lastAccessLHS, ...args)); + lastAccessLHS = undefined; + } + } + return value; +} + +// Sucrase version: +// async function _asyncOptionalChain(ops) { +// let lastAccessLHS = undefined; +// let value = ops[0]; +// let i = 1; +// while (i < ops.length) { +// const op = ops[i]; +// const fn = ops[i + 1]; +// i += 2; +// if ((op === 'optionalAccess' || op === 'optionalCall') && value == null) { +// return undefined; +// } +// if (op === 'access' || op === 'optionalAccess') { +// lastAccessLHS = value; +// value = await fn(value); +// } else if (op === 'call' || op === 'optionalCall') { +// value = await fn((...args) => value.call(lastAccessLHS, ...args)); +// lastAccessLHS = undefined; +// } +// } +// return value; +// } diff --git a/rollup/jsPolyfills/_asyncOptionalChainDelete.js b/rollup/jsPolyfills/_asyncOptionalChainDelete.js new file mode 100644 index 000000000000..cdabfc17cb10 --- /dev/null +++ b/rollup/jsPolyfills/_asyncOptionalChainDelete.js @@ -0,0 +1,9 @@ +// originally from Sucrase (https://github.com/alangpierce/sucrase) + +import { _asyncOptionalChain } from './_asyncOptionalChain'; + +export async function _asyncOptionalChainDelete(ops) { + const result = await _asyncOptionalChain(ops); + // by checking for loose equality to `null`, we catch both `null` and `undefined` + return result == null ? true : result; +} diff --git a/rollup/jsPolyfills/_createNamedExportFrom.js b/rollup/jsPolyfills/_createNamedExportFrom.js new file mode 100644 index 000000000000..41d2ac450a82 --- /dev/null +++ b/rollup/jsPolyfills/_createNamedExportFrom.js @@ -0,0 +1,10 @@ +// adapted from Sucrase (https://github.com/alangpierce/sucrase) + +export function _createNamedExportFrom(obj, localName, importedName) { + exports[localName] = obj[importedName]; +} + +// Sucrase version: +// function _createNamedExportFrom(obj, localName, importedName) { +// Object.defineProperty(exports, localName, {enumerable: true, get: () => obj[importedName]}); +// } diff --git a/rollup/jsPolyfills/_createStarExport.js b/rollup/jsPolyfills/_createStarExport.js new file mode 100644 index 000000000000..c2301b537da3 --- /dev/null +++ b/rollup/jsPolyfills/_createStarExport.js @@ -0,0 +1,19 @@ +// adapted from Sucrase (https://github.com/alangpierce/sucrase) + +export function _createStarExport(obj) { + Object.keys(obj) + .filter(key => key !== 'default' && key !== '__esModule' && !(key in exports)) + .forEach(key => (exports[key] = obj[key])); +} + +// Sucrase version: +// function _createStarExport(obj) { +// Object.keys(obj) +// .filter(key => key !== 'default' && key !== '__esModule') +// .forEach(key => { +// if (exports.hasOwnProperty(key)) { +// return; +// } +// Object.defineProperty(exports, key, { enumerable: true, get: () => obj[key] }); +// }); +// } diff --git a/rollup/jsPolyfills/_interopDefault.js b/rollup/jsPolyfills/_interopDefault.js new file mode 100644 index 000000000000..99f13aff6eaa --- /dev/null +++ b/rollup/jsPolyfills/_interopDefault.js @@ -0,0 +1,10 @@ +// adapted from Rollup (https://github.com/rollup/rollup) + +export function _interopDefault(importTarget) { + return importTarget.__esModule ? importTarget.default : importTarget; +} + +// Rollup version: +// function _interopDefault(e) { +// return e && e.__esModule ? e['default'] : e; +// } diff --git a/rollup/jsPolyfills/_interopNamespace.js b/rollup/jsPolyfills/_interopNamespace.js new file mode 100644 index 000000000000..15cbbb0b06d9 --- /dev/null +++ b/rollup/jsPolyfills/_interopNamespace.js @@ -0,0 +1,18 @@ +// adapted from Rollup (https://github.com/rollup/rollup) + +export function _interopNamespace(importTarget) { + return importTarget.__esModule ? importTarget : { ...importTarget, default: importTarget }; +} + +// Rollup version (with `output.externalLiveBindings` and `output.freeze` both set to false) +// function _interopNamespace(e) { +// if (e && e.__esModule) return e; +// var n = Object.create(null); +// if (e) { +// for (var k in e) { +// n[k] = e[k]; +// } +// } +// n["default"] = e; +// return n; +// } diff --git a/rollup/jsPolyfills/_interopRequireDefault.js b/rollup/jsPolyfills/_interopRequireDefault.js new file mode 100644 index 000000000000..6eb6199ee11f --- /dev/null +++ b/rollup/jsPolyfills/_interopRequireDefault.js @@ -0,0 +1,10 @@ +// adapted from Sucrase (https://github.com/alangpierce/sucrase) + +export function _interopRequireDefault(importTarget) { + return importTarget.__esModule ? importTarget : { default: importTarget }; +} + +// Sucrase version +// function _interopRequireDefault(obj) { +// return obj && obj.__esModule ? obj : { default: obj }; +// } diff --git a/rollup/jsPolyfills/_interopRequireWildcard.js b/rollup/jsPolyfills/_interopRequireWildcard.js new file mode 100644 index 000000000000..577296e83826 --- /dev/null +++ b/rollup/jsPolyfills/_interopRequireWildcard.js @@ -0,0 +1,23 @@ +// adapted from Sucrase (https://github.com/alangpierce/sucrase) + +export function _interopRequireWildcard(importTarget) { + return importTarget.__esModule ? importTarget : { ...importTarget, default: importTarget }; +} + +// Sucrase version +// function _interopRequireWildcard(obj) { +// if (obj && obj.__esModule) { +// return obj; +// } else { +// var newObj = {}; +// if (obj != null) { +// for (var key in obj) { +// if (Object.prototype.hasOwnProperty.call(obj, key)) { +// newObj[key] = obj[key]; +// } +// } +// } +// newObj.default = obj; +// return newObj; +// } +// } diff --git a/rollup/jsPolyfills/_nullishCoalesce.js b/rollup/jsPolyfills/_nullishCoalesce.js new file mode 100644 index 000000000000..5a1474edaa95 --- /dev/null +++ b/rollup/jsPolyfills/_nullishCoalesce.js @@ -0,0 +1,15 @@ +// adapted from Sucrase (https://github.com/alangpierce/sucrase) + +export function _nullishCoalesce(lhs, rhsFn) { + // by checking for loose equality to `null`, we catch both `null` and `undefined` + return lhs != null ? lhs : rhsFn(); +} + +// Sucrase version +// function _nullishCoalesce(lhs, rhsFn) { +// if (lhs != null) { +// return lhs; +// } else { +// return rhsFn(); +// } +// } diff --git a/rollup/jsPolyfills/_optionalChain.js b/rollup/jsPolyfills/_optionalChain.js new file mode 100644 index 000000000000..bb8105e792df --- /dev/null +++ b/rollup/jsPolyfills/_optionalChain.js @@ -0,0 +1,48 @@ +// adapted from Sucrase (https://github.com/alangpierce/sucrase) + +export function _optionalChain(ops) { + let lastAccessLHS = undefined; + let value = ops[0]; + let i = 1; + while (i < ops.length) { + const op = ops[i]; + const fn = ops[i + 1]; + i += 2; + // by checking for loose equality to `null`, we catch both `null` and `undefined` + if (op in ['optionalAccess', 'optionalCall'] && value == null) { + // really we're meaning to return `undefined` as an actual value here, but it saves bytes not to write it + return; + } + if (op in ['access', 'optionalAccess']) { + lastAccessLHS = value; + value = fn(value); + } else if (op in ['call', 'optionalCall']) { + value = fn((...args) => value.call(lastAccessLHS, ...args)); + lastAccessLHS = undefined; + } + } + return value; +} + +// Sucrase version +// function _optionalChain(ops) { +// let lastAccessLHS = undefined; +// let value = ops[0]; +// let i = 1; +// while (i < ops.length) { +// const op = ops[i]; +// const fn = ops[i + 1]; +// i += 2; +// if ((op === 'optionalAccess' || op === 'optionalCall') && value == null) { +// return undefined; +// } +// if (op === 'access' || op === 'optionalAccess') { +// lastAccessLHS = value; +// value = fn(value); +// } else if (op === 'call' || op === 'optionalCall') { +// value = fn((...args) => value.call(lastAccessLHS, ...args)); +// lastAccessLHS = undefined; +// } +// } +// return value; +// } diff --git a/rollup/jsPolyfills/_optionalChainDelete.js b/rollup/jsPolyfills/_optionalChainDelete.js new file mode 100644 index 000000000000..60f0f90d5b0a --- /dev/null +++ b/rollup/jsPolyfills/_optionalChainDelete.js @@ -0,0 +1,9 @@ +// originally from Sucrase (https://github.com/alangpierce/sucrase) + +import { _optionalChain } from './_optionalChain'; + +export function _optionalChainDelete(ops) { + const result = _optionalChain(ops); + // by checking for loose equality to `null`, we catch both `null` and `undefined` + return result == null ? true : result; +} diff --git a/rollup/jsPolyfills/index.js b/rollup/jsPolyfills/index.js new file mode 100644 index 000000000000..f1c46088f61b --- /dev/null +++ b/rollup/jsPolyfills/index.js @@ -0,0 +1,12 @@ +export { _asyncNullishCoalesce } from './_asyncNullishCoalesce'; +export { _asyncOptionalChain } from './_asyncOptionalChain'; +export { _asyncOptionalChainDelete } from './_asyncOptionalChainDelete'; +export { _createNamedExportFrom } from './_createNamedExportFrom'; +export { _createStarExport } from './_createStarExport'; +export { _interopDefault } from './_interopDefault'; +export { _interopNamespace } from './_interopNamespace'; +export { _interopRequireDefault } from './_interopRequireDefault'; +export { _interopRequireWildcard } from './_interopRequireWildcard'; +export { _nullishCoalesce } from './_nullishCoalesce'; +export { _optionalChain } from './_optionalChain'; +export { _optionalChainDelete } from './_optionalChainDelete'; diff --git a/rollup/jsPolyfills/originals.js b/rollup/jsPolyfills/originals.js new file mode 100644 index 000000000000..87b9783e6540 --- /dev/null +++ b/rollup/jsPolyfills/originals.js @@ -0,0 +1,140 @@ +// Originals of the jsPolyfills from Sucrase and Rollup we use (which we have adapted in various ways), preserved here for testing, to prove that +// the modified versions do the same thing the originals do. + +// From Sucrase +export async function _asyncNullishCoalesce(lhs, rhsFn) { + if (lhs != null) { + return lhs; + } else { + return await rhsFn(); + } +} + +// From Sucrase +export async function _asyncOptionalChain(ops) { + let lastAccessLHS = undefined; + let value = ops[0]; + let i = 1; + while (i < ops.length) { + const op = ops[i]; + const fn = ops[i + 1]; + i += 2; + if ((op === 'optionalAccess' || op === 'optionalCall') && value == null) { + return undefined; + } + if (op === 'access' || op === 'optionalAccess') { + lastAccessLHS = value; + value = await fn(value); + } else if (op === 'call' || op === 'optionalCall') { + value = await fn((...args) => value.call(lastAccessLHS, ...args)); + lastAccessLHS = undefined; + } + } + return value; +} + +// From Sucrase +export async function _asyncOptionalChainDelete(ops) { + const result = await _asyncOptionalChain(ops); + // by checking for loose equality to `null`, we catch both `null` and `undefined` + return result == null ? true : result; +} + +// From Sucrase +export function createNamedExportFrom(obj, localName, importedName) { + Object.defineProperty(exports, localName, { enumerable: true, get: () => obj[importedName] }); +} + +// From Sucrase +export function _createStarExport(obj) { + Object.keys(obj) + .filter(key => key !== 'default' && key !== '__esModule') + .forEach(key => { + // eslint-disable-next-line no-prototype-builtins + if (exports.hasOwnProperty(key)) { + return; + } + Object.defineProperty(exports, key, { enumerable: true, get: () => obj[key] }); + }); +} + +// From Rollup +export function _interopDefault(e) { + return e && e.__esModule ? e['default'] : e; +} + +// From Rollup +export function _interopNamespace(e) { + if (e && e.__esModule) return e; + var n = Object.create(null); + if (e) { + // eslint-disable-next-line guard-for-in + for (var k in e) { + n[k] = e[k]; + } + } + n['default'] = e; + return n; +} + +// From Sucrase +export function _interopRequireDefault(obj) { + return obj && obj.__esModule ? obj : { default: obj }; +} + +// From Sucrase +export function _interopRequireWildcard(obj) { + if (obj && obj.__esModule) { + return obj; + } else { + var newObj = {}; + if (obj != null) { + for (var key in obj) { + if (Object.prototype.hasOwnProperty.call(obj, key)) { + newObj[key] = obj[key]; + } + } + } + newObj.default = obj; + return newObj; + } +} + +// From Sucrase +export function _nullishCoalesce(lhs, rhsFn) { + if (lhs != null) { + return lhs; + } else { + return rhsFn(); + } +} + +// From Sucrase +export function _optionalChain(ops) { + let lastAccessLHS = undefined; + let value = ops[0]; + let i = 1; + while (i < ops.length) { + const op = ops[i]; + const fn = ops[i + 1]; + i += 2; + if ((op === 'optionalAccess' || op === 'optionalCall') && value == null) { + return undefined; + } + if (op === 'access' || op === 'optionalAccess') { + lastAccessLHS = value; + value = fn(value); + } else if (op === 'call' || op === 'optionalCall') { + value = fn((...args) => value.call(lastAccessLHS, ...args)); + lastAccessLHS = undefined; + } + } + return value; +} + +// From Sucrase +export function _optionalChainDelete(ops) { + const result = _optionalChain(ops); + // by checking for loose equality to `null`, we catch both `null` and `undefined` + return result == null ? true : result; +} diff --git a/rollup/npmHelpers.js b/rollup/npmHelpers.js index 79a14189a880..0ac0ad33a01b 100644 --- a/rollup/npmHelpers.js +++ b/rollup/npmHelpers.js @@ -9,6 +9,7 @@ import deepMerge from 'deepmerge'; import { makeConstToVarPlugin, + makeExtractPolyfillsPlugin, makeNodeResolvePlugin, makeRemoveBlankLinesPlugin, makeRemoveESLintCommentsPlugin, @@ -30,6 +31,7 @@ export function makeBaseNPMConfig(options = {}) { const constToVarPlugin = makeConstToVarPlugin(); const removeESLintCommentsPlugin = makeRemoveESLintCommentsPlugin(); const removeBlankLinesPlugin = makeRemoveBlankLinesPlugin(); + const extractPolyfillsPlugin = makeExtractPolyfillsPlugin(); // return { const config = { @@ -59,6 +61,10 @@ export function makeBaseNPMConfig(options = {}) { // }); externalLiveBindings: false, + // Don't call `Object.freeze` on the results of `import * as someModule from '...'` + // (We don't need it, so why waste the bytes?) + freeze: false, + // Equivalent to `esModuleInterop` in tsconfig. // Controls whether rollup emits helpers to handle special cases where turning // `import * as dogs from 'dogs'` @@ -71,7 +77,14 @@ export function makeBaseNPMConfig(options = {}) { interop: esModuleInterop ? 'auto' : 'esModule', }, - plugins: [nodeResolvePlugin, sucrasePlugin, constToVarPlugin, removeESLintCommentsPlugin, removeBlankLinesPlugin], + plugins: [ + nodeResolvePlugin, + sucrasePlugin, + constToVarPlugin, + removeESLintCommentsPlugin, + removeBlankLinesPlugin, + extractPolyfillsPlugin, + ], // don't include imported modules from outside the package in the final output external: [ diff --git a/rollup/plugins/extractPolyfillsPlugin.js b/rollup/plugins/extractPolyfillsPlugin.js new file mode 100644 index 000000000000..8c527bb94c34 --- /dev/null +++ b/rollup/plugins/extractPolyfillsPlugin.js @@ -0,0 +1,208 @@ +import * as path from 'path'; + +import * as recast from 'recast'; +import * as acornParser from 'recast/parsers/acorn'; + +const POLYFILL_NAMES = new Set([ + '_asyncNullishCoalesce', + '_asyncOptionalChain', + '_asyncOptionalChainDelete', + '_createNamedExportFrom', + '_createStarExport', + '_interopDefault', // rollup's version + '_interopNamespace', // rollup's version + '_interopRequireDefault', // sucrase's version + '_interopRequireWildcard', // sucrase's version + '_nullishCoalesce', + '_optionalChain', + '_optionalChainDelete', +]); + +/** + * Create a plugin which will replace function definitions of any of the above funcions with an `import` or `require` + * statement pulling them in from a central source. Mimics tsc's `importHelpers` option. + */ +export function makeExtractPolyfillsPlugin() { + let moduleFormat; + + // For more on the hooks used in this plugin, see https://rollupjs.org/guide/en/#output-generation-hooks + return { + name: 'extractPolyfills', + + // Figure out which build we're currently in (esm or cjs) + outputOptions(options) { + moduleFormat = options.format; + }, + + // This runs after both the sucrase transpilation (which happens in the `transform` hook) and rollup's own + // esm-i-fying or cjs-i-fying work (which happens right before `renderChunk`), in other words, after all polyfills + // will have been injected + renderChunk(code, chunk) { + const sourceFile = chunk.fileName; + const parserOptions = { + sourceFileName: sourceFile, + // We supply a custom parser which wraps the provided `acorn` parser in order to override the `ecmaVersion` value. + // See https://github.com/benjamn/recast/issues/578. + parser: { + parse(source, options) { + return acornParser.parse(source, { + ...options, + // By this point in the build, everything should already have been down-compiled to whatever JS version + // we're targeting. Setting this parser to `latest` just means that whatever that version is (or changes + // to in the future), this parser will be able to handle the generated code. + ecmaVersion: 'latest', + }); + }, + }, + }; + + const ast = recast.parse(code, parserOptions); + + // Find function definitions and function expressions whose identifiers match a known polyfill name + const polyfillNodes = findPolyfillNodes(ast); + + if (polyfillNodes.length === 0) { + return null; + } + + console.log(`${sourceFile} - polyfills: ${polyfillNodes.map(node => node.name)}`); + + // Depending on the output format, generate `import { x, y, z } from '...'` or `var { x, y, z } = require('...')` + const importOrRequireNode = createImportOrRequireNode(polyfillNodes, sourceFile, moduleFormat); + + // Insert our new `import` or `require` node at the top of the file, and then delete the function definitions it's + // meant to replace (polyfill nodes get marked for deletion in `findPolyfillNodes`) + ast.program.body = [importOrRequireNode, ...ast.program.body.filter(node => !node.shouldDelete)]; + + // In spite of the name, this doesn't actually print anything - it just stringifies the code, and keeps track of + // where original nodes end up in order to generate a sourcemap. + const result = recast.print(ast, { + sourceMapName: `${sourceFile}.map`, + quote: 'single', + }); + + return { code: result.code, map: result.map }; + }, + }; +} + +/** + * Extract the function name, regardless of the format in which the function is declared + */ +function getNodeName(node) { + // Function expressions and functions pulled from objects + if (node.type === 'VariableDeclaration') { + // In practice sucrase and rollup only ever declare one polyfill at a time, so it's safe to just grab the first + // entry here + const declarationId = node.declarations[0].id; + + // Note: Sucrase and rollup seem to only use the first type of variable declaration for their polyfills, but good to + // cover our bases + + // Declarations of the form + // `const dogs = function() { return "are great"; };` + // or + // `const dogs = () => "are great"; + if (declarationId.type === 'Identifier') { + return declarationId.name; + } + // Declarations of the form + // `const { dogs } = { dogs: function() { return "are great"; } }` + // or + // `const { dogs } = { dogs: () => "are great" }` + else if (declarationId.type === 'ObjectPattern') { + return declarationId.properties[0].key.name; + } + // Any other format + else { + return 'unknown variable'; + } + } + + // Regular old functions, of the form + // `function dogs() { return "are great"; }` + else if (node.type === 'FunctionDeclaration') { + return node.id.name; + } + + // 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 + // polyfill names + else { + return 'nope'; + } +} + +/** + * Find all nodes whose identifiers match a known polyfill name. + * + * Note: In theory, this could yield false positives, if any of the magic names were assigned to something other than a + * polyfill function, but the chances of that are slim. Also, it only searches the module global scope, but that's + * always where the polyfills appear, so no reason to traverse the whole tree. + */ +function findPolyfillNodes(ast) { + const isPolyfillNode = node => { + const nodeName = getNodeName(node); + if (POLYFILL_NAMES.has(nodeName)) { + // Mark this node for later deletion, since we're going to replace it with an import statement + node.shouldDelete = true; + // Store the name in a consistent spot, regardless of node type + node.name = nodeName; + + return true; + } + + return false; + }; + + return ast.program.body.filter(isPolyfillNode); +} + +/** + * Create a node representing an `import` or `require` statement of the form + * + * import { < polyfills > } from '...' + * or + * var { < polyfills > } = require('...') + * + * @param polyfillNodes The nodes from the current version of the code, defining the polyfill functions + * @param currentSourceFile The path, relative to `src/`, of the file currently being transpiled + * @param moduleFormat Either 'cjs' or 'esm' + * @returns A single node which can be subbed in for the polyfill definition nodes + */ +function createImportOrRequireNode(polyfillNodes, currentSourceFile, moduleFormat) { + const { + callExpression, + identifier, + importDeclaration, + importSpecifier, + literal, + objectPattern, + property, + variableDeclaration, + variableDeclarator, + } = recast.types.builders; + + // Since our polyfills live in `@sentry/utils`, if we're importing or requiring them there the path will have to be + // relative + const isUtilsPackage = process.cwd().endsWith('packages/utils'); + const importSource = literal( + isUtilsPackage + ? path.relative(path.dirname(currentSourceFile), `../jsPolyfills/${moduleFormat}`) + : `@sentry/utils/jsPolyfills/${moduleFormat}`, + ); + + // This is the `x, y, z` of inside of `import { x, y, z }` or `var { x, y, z }` + const importees = polyfillNodes.map(({ name: fnName }) => + moduleFormat === 'esm' + ? importSpecifier(identifier(fnName)) + : property.from({ kind: 'init', key: identifier(fnName), value: identifier(fnName), shorthand: true }), + ); + + const requireFn = identifier('require'); + + return moduleFormat === 'esm' + ? importDeclaration(importees, importSource) + : variableDeclaration('var', [ + variableDeclarator(objectPattern(importees), callExpression(requireFn, [importSource])), + ]); +} diff --git a/rollup/plugins/npmPlugins.js b/rollup/plugins/npmPlugins.js index 568ef6b69e10..eec8eab81cda 100644 --- a/rollup/plugins/npmPlugins.js +++ b/rollup/plugins/npmPlugins.js @@ -97,3 +97,5 @@ export function makeRemoveBlankLinesPlugin() { ], }); } + +export { makeExtractPolyfillsPlugin } from './extractPolyfillsPlugin.js'; diff --git a/yarn.lock b/yarn.lock index bf506395377a..faa60565f6b8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5772,6 +5772,11 @@ acorn@^8.4.1: resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.6.0.tgz#e3692ba0eb1a0c83eaa4f37f5fa7368dd7142895" integrity sha512-U1riIR+lBSNi3IbxtaHOIKdH8sLFv3NYfNv8sg7ZsNhcfl4HF2++BfqqrNAxoCLQW1iiylOj76ecnaUxz+z9yw== +acorn@^8.7.0: + version "8.7.1" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.7.1.tgz#0197122c843d1bf6d0a5e83220a788f278f63c30" + integrity sha512-Xx54uLJQZ19lKygFXOWsscKUbsBZW0CPykPhVQdhIeIwrbPmJzqeASDInc8nKBnp/JT6igTs82qPXz069H8I/A== + adjust-sourcemap-loader@3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/adjust-sourcemap-loader/-/adjust-sourcemap-loader-3.0.0.tgz#5ae12fb5b7b1c585e80bbb5a63ec163a1a45e61e" @@ -6272,6 +6277,13 @@ ast-types@0.13.3: resolved "https://registry.yarnpkg.com/ast-types/-/ast-types-0.13.3.tgz#50da3f28d17bdbc7969a3a2d83a0e4a72ae755a7" integrity sha512-XTZ7xGML849LkQP86sWdQzfhwbt3YwIO6MqbX9mUNYY98VKaaVZP7YNNm70IpwecbkkxmfC5IYAzOQ/2p29zRA== +ast-types@0.14.2: + version "0.14.2" + resolved "https://registry.yarnpkg.com/ast-types/-/ast-types-0.14.2.tgz#600b882df8583e3cd4f2df5fa20fa83759d4bdfd" + integrity sha512-O0yuUDnZeQDL+ncNGlJ78BiO4jnYI3bvMsD5prT0/nsgijG/LpNBIr63gTjVTNsiGkgQhiyCShTgxt8oXOrklA== + dependencies: + tslib "^2.0.1" + astral-regex@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/astral-regex/-/astral-regex-2.0.0.tgz#483143c567aeed4785759c0865786dc77d7d2e31" @@ -13018,6 +13030,15 @@ fs-extra@^10.0.0: jsonfile "^6.0.1" universalify "^2.0.0" +fs-extra@^10.1.0: + version "10.1.0" + resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-10.1.0.tgz#02873cfbc4084dde127eaa5f9905eef2325d1abf" + integrity sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ== + dependencies: + graceful-fs "^4.2.0" + jsonfile "^6.0.1" + universalify "^2.0.0" + fs-extra@^4.0.2, fs-extra@^4.0.3: version "4.0.3" resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-4.0.3.tgz#0d852122e5bc5beb453fb028e9c0c9bf36340c94" @@ -21204,6 +21225,16 @@ recast@^0.18.1: private "^0.1.8" source-map "~0.6.1" +recast@^0.20.5: + version "0.20.5" + resolved "https://registry.yarnpkg.com/recast/-/recast-0.20.5.tgz#8e2c6c96827a1b339c634dd232957d230553ceae" + integrity sha512-E5qICoPoNL4yU0H0NoBDntNB0Q5oMSNh9usFctYniLBluTthi3RsQVBXIJNbApOlvSwW/RGxIuokPcAc59J5fQ== + dependencies: + ast-types "0.14.2" + esprima "~4.0.0" + source-map "~0.6.1" + tslib "^2.0.1" + rechoir@^0.6.2: version "0.6.2" resolved "https://registry.yarnpkg.com/rechoir/-/rechoir-0.6.2.tgz#85204b54dba82d5742e28c96756ef43af50e3384" @@ -24347,6 +24378,11 @@ tslib@^2.0.0, tslib@^2.0.3: resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.1.0.tgz#da60860f1c2ecaa5703ab7d39bc05b6bf988b97a" integrity sha512-hcVC3wYEziELGGmEEXue7D75zbwIIVUMWAVbHItGPx0ziyXxrOMQx4rQEVEV45Ut/1IotuEvwqPopzIOkDMf0A== +tslib@^2.0.1: + version "2.4.0" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.4.0.tgz#7cecaa7f073ce680a05847aa77be941098f36dc3" + integrity sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ== + tslib@^2.3.0, tslib@^2.3.1: version "2.3.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.3.1.tgz#e8a335add5ceae51aa261d32a490158ef042ef01"