mirror of
https://github.com/lancedikson/bowser
synced 2026-10-15 15:40:52 +00:00
fix: keep ESM consumers on ES5, enable tree-shaking, trim bundled.js
Found by a differential test of the packed candidate against the published
2.14.1 across Node 12-22, Bun, TypeScript 4.7-7.0, seven bundlers and a
real browser.
- bowser.mjs is now lowered to ES5 syntax (keeping `export`). Before the
exports map, webpack, Rollup and esbuild resolved `import 'bowser'` to
es5.js via the `browser` field; the ES2015 bowser.mjs broke esbuild
`--target=es5` and shipped classes into webpack ES5 builds. The ESM build
sets `minify: false` explicitly, because rolldown's default DCE-only pass
re-prints the chunk after renderChunk and restores shorthand properties.
- `"sideEffects": ["./bundled.js"]` lets esbuild, Vite and webpack 4 drop an
unused `import 'bowser'`; bundled.js stays listed so polyfill-only imports
are never tree-shaken away.
- `./package` export restores extensionless `require('bowser/package')`,
which resolved on 2.14.1.
- bundled.js imports `core-js/es` plus the timers, immediate and
DOM-collection web polyfills instead of `core-js/stable`, dropping URL,
URLSearchParams and queueMicrotask, which no released bundled.js ever
shipped: 176.9 kB -> 156.1 kB (56.6 -> 49.3 kB gzip).
Tests: bowser.mjs must parse as ES5 apart from its export; bundled.js must
install the @babel/polyfill surface 2.14.1 shipped (passes on 2.14.1's
bundled.js, fails on unpolyfilled es5.js); package smoke test covers
`bowser/package` and `sideEffects`.
This commit is contained in:
@@ -2,14 +2,23 @@
|
||||
* UMD entry point for `bundled.js` — same as `es5.js`, but with the polyfills
|
||||
* needed by the browser targets baked in.
|
||||
*
|
||||
* `core-js/stable` + `regenerator-runtime/runtime` is the core-js@3 equivalent
|
||||
* of the deprecated `@babel/polyfill` that this bundle used to be built from.
|
||||
* `@babel/preset-env`'s `useBuiltIns: 'entry'` rewrites the `core-js/stable`
|
||||
* import below into just the polyfills the configured targets actually need.
|
||||
* This is the core-js@3 equivalent of the deprecated `@babel/polyfill` that
|
||||
* this bundle used to be built from, which was: all of ECMAScript, the
|
||||
* `timers`, `immediate` and DOM-collection iterator web polyfills, and
|
||||
* `regenerator-runtime`. `@babel/preset-env`'s `useBuiltIns: 'entry'` rewrites
|
||||
* the `core-js/*` imports below into the modules the configured targets need.
|
||||
*
|
||||
* `core-js/es` rather than `core-js/stable`: `stable` is `es` plus every web
|
||||
* polyfill, and the extra ones — `URL`, `URLSearchParams`, `queueMicrotask` —
|
||||
* were never in any released `bundled.js`, yet cost ~20 kB (~7 kB gzipped).
|
||||
* `test/acceptance/test-es5-runtime.js` pins the `@babel/polyfill` surface.
|
||||
*
|
||||
* See `./es5.js` for why this only re-exports the default.
|
||||
*/
|
||||
import 'core-js/stable';
|
||||
import 'core-js/es';
|
||||
import 'core-js/web/dom-collections';
|
||||
import 'core-js/web/immediate';
|
||||
import 'core-js/web/timers';
|
||||
import 'regenerator-runtime/runtime';
|
||||
import Bowser from '../../src/bowser.js';
|
||||
|
||||
|
||||
@@ -35,6 +35,9 @@
|
||||
"browser": "es5.js",
|
||||
"module": "src/bowser.js",
|
||||
"types": "index.d.ts",
|
||||
"sideEffects": [
|
||||
"./bundled.js"
|
||||
],
|
||||
"exports": {
|
||||
".": {
|
||||
"import": {
|
||||
@@ -75,7 +78,8 @@
|
||||
"./README.md": "./README.md",
|
||||
"./index.d.ts": "./index.d.ts",
|
||||
"./index.d.mts": "./index.d.mts",
|
||||
"./package.json": "./package.json"
|
||||
"./package.json": "./package.json",
|
||||
"./package": "./package.json"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -44,7 +44,19 @@ legacyBundles.forEach((file) => {
|
||||
});
|
||||
});
|
||||
|
||||
test('bowser.mjs is a valid ES module', (t) => {
|
||||
/**
|
||||
* `bowser.mjs` is what `import 'bowser'` resolves to in every bundler that
|
||||
* reads the exports map. Before the map existed those same bundlers read the
|
||||
* `browser` field and got `es5.js`, so ES5 builds — esbuild `--target=es5`,
|
||||
* webpack without transpiling node_modules — depend on this file being ES5
|
||||
* too. acorn's ES5 grammar in module mode accepts `import`/`export` and
|
||||
* nothing else from ES2015, which is exactly the contract.
|
||||
*/
|
||||
test('bowser.mjs parses as ES5 apart from its export statement', (t) => {
|
||||
const source = fs.readFileSync(path.join(root, 'bowser.mjs'), 'utf8');
|
||||
t.notThrows(() => acorn.parse(source, { ecmaVersion: 'latest', sourceType: 'module' }));
|
||||
t.notThrows(
|
||||
() => acorn.parse(source, { ecmaVersion: 5, sourceType: 'module' }),
|
||||
'bowser.mjs contains syntax newer than ES5 — ES5 bundler targets will '
|
||||
+ 'fail to build or ship it untranspiled',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -131,3 +131,51 @@ test('bundled.js installs the polyfills it promises', (t) => {
|
||||
t.is(vm.runInContext('typeof Object.assign', context), 'function');
|
||||
t.is(vm.runInContext('typeof [].includes', context), 'function');
|
||||
});
|
||||
|
||||
/**
|
||||
* What every released `bundled.js` installed, back when it was built from
|
||||
* `@babel/polyfill`: ES2015, the handful of ES2016/2017 additions that package
|
||||
* pulled in individually, the `timers`/`immediate`/DOM-iterable web polyfills,
|
||||
* and `regenerator-runtime`. `build/entries/bundled.js` picks its core-js
|
||||
* entries to stay a superset of this; trimming them must not break it.
|
||||
*
|
||||
* Representative rather than exhaustive — one or more members per polyfill
|
||||
* group, so that dropping any group fails here.
|
||||
*/
|
||||
const BABEL_POLYFILL_SURFACE = [
|
||||
'this.Promise', 'Promise.prototype.finally', 'this.Symbol', 'Symbol.iterator', 'Symbol.asyncIterator',
|
||||
'this.Map', 'this.Set', 'this.WeakMap', 'this.WeakSet', 'this.Reflect', 'Reflect.ownKeys',
|
||||
'Object.assign', 'Object.is', 'Object.setPrototypeOf', 'Object.getOwnPropertySymbols',
|
||||
'Object.entries', 'Object.values', 'Object.getOwnPropertyDescriptors',
|
||||
'Array.from', 'Array.of', 'Array.prototype.includes', 'Array.prototype.find', 'Array.prototype.findIndex',
|
||||
'Array.prototype.fill', 'Array.prototype.copyWithin', 'Array.prototype.flatMap',
|
||||
'Array.prototype.entries', 'Array.prototype.keys', 'Array.prototype.values',
|
||||
'String.raw', 'String.fromCodePoint', 'String.prototype.includes', 'String.prototype.startsWith',
|
||||
'String.prototype.endsWith', 'String.prototype.repeat', 'String.prototype.codePointAt',
|
||||
'String.prototype.padStart', 'String.prototype.padEnd', 'String.prototype.trimStart', 'String.prototype.trimEnd',
|
||||
// Not `Number.EPSILON`: it is non-configurable, so the sandbox cannot strip it.
|
||||
'Number.isInteger', 'Number.isFinite', 'Number.isNaN', 'Number.isSafeInteger', 'Number.parseInt',
|
||||
'Math.trunc', 'Math.sign', 'Math.log2', 'Math.clz32', 'Math.hypot',
|
||||
'this.setImmediate', 'this.clearImmediate', 'this.regeneratorRuntime',
|
||||
// core-js 2 made DOM collections iterable; `forEach` on them is core-js 3.
|
||||
'NodeList.prototype[Symbol.iterator]',
|
||||
];
|
||||
|
||||
test('bundled.js still installs everything @babel/polyfill did', (t) => {
|
||||
const context = createEs5Context();
|
||||
// Strip whatever on the list the ES5 sandbox left in place, and give core-js
|
||||
// a DOM collection to patch, so the check cannot pass against Node's own
|
||||
// implementations.
|
||||
vm.runInContext(BABEL_POLYFILL_SURFACE
|
||||
.filter((ref) => /^[\w.]+$/.test(ref))
|
||||
.map((ref) => `try { delete ${ref}; } catch (e) {}`)
|
||||
.join('\n'), context);
|
||||
vm.runInContext('this.NodeList = function NodeList() {};', context);
|
||||
const present = (ref) => {
|
||||
try { return vm.runInContext(`typeof (${ref})`, context) !== 'undefined'; } catch (e) { return false; }
|
||||
};
|
||||
|
||||
t.deepEqual(BABEL_POLYFILL_SURFACE.filter(present), [], 'the sandbox did not strip these');
|
||||
vm.runInContext(fs.readFileSync(path.join(root, 'bundled.js'), 'utf8'), context);
|
||||
t.deepEqual(BABEL_POLYFILL_SURFACE.filter((ref) => !present(ref)), [], 'bundled.js no longer installs these');
|
||||
});
|
||||
|
||||
@@ -101,6 +101,12 @@ check('require.resolve("bowser/package.json") works', function () {
|
||||
assert.ok(fs.existsSync(require.resolve('bowser/package.json')));
|
||||
});
|
||||
|
||||
// Extensionless, like `bowser/es5` — CommonJS resolution appended `.json`
|
||||
// before the exports map existed.
|
||||
check('require("bowser/package") works', function () {
|
||||
assert.strictEqual(require('bowser/package').name, 'bowser');
|
||||
});
|
||||
|
||||
// Before the exports map existed, *every* published file was reachable by
|
||||
// subpath. An exports map is a closed list, so anything published but not
|
||||
// enumerated silently becomes ERR_PACKAGE_PATH_NOT_EXPORTED. These four are
|
||||
@@ -124,6 +130,14 @@ check('main/browser/module/types fields are unchanged', function () {
|
||||
assert.strictEqual(pkg.types, 'index.d.ts');
|
||||
});
|
||||
|
||||
// Lets esbuild and Vite drop an unused `import 'bowser'` (webpack and Rollup
|
||||
// already do). `bundled.js` is the one file whose import *is* the point: it
|
||||
// installs polyfills, so it must never be tree-shaken away.
|
||||
check('"sideEffects" lists exactly bundled.js', function () {
|
||||
var pkg = require('bowser/package.json');
|
||||
assert.deepStrictEqual(pkg.sideEffects, ['./bundled.js']);
|
||||
});
|
||||
|
||||
check('no "engines" field (would warn/fail installs on old Node)', function () {
|
||||
var pkg = require('bowser/package.json');
|
||||
assert.strictEqual(pkg.engines, undefined);
|
||||
|
||||
@@ -21,19 +21,16 @@ const legacyTargets = {
|
||||
};
|
||||
|
||||
/**
|
||||
* `useBuiltIns: false` for `es5.js` (syntax transpilation only) and `'entry'`
|
||||
* for `bundled.js`, which expands the `core-js/stable` import in its entry.
|
||||
*
|
||||
* `'entry'` is why `bundled.js` grew from 124 kB to 174 kB when it stopped
|
||||
* being built from the deprecated `@babel/polyfill`. That package was core-js
|
||||
* **2**; `core-js/stable` is core-js **3**, whose stable surface is genuinely
|
||||
* larger — `globalThis`, `Object.fromEntries` and `URLSearchParams` are all
|
||||
* new here. The extra weight is the upgrade, not waste.
|
||||
* `useBuiltIns: false` for `es5.js` and `bowser.mjs` (syntax transpilation
|
||||
* only) and `'entry'` for `bundled.js`, which expands the `core-js/*` imports in
|
||||
* its entry. `corejs: '3'` means 3.0, so `'entry'` only ever expands to modules
|
||||
* that existed in core-js 3.0 — no later additions leak in.
|
||||
*
|
||||
* Switching to `useBuiltIns: 'usage'` would shrink the bundle a long way, and
|
||||
* would be wrong: the README tells consumers to reach for `bundled.js`
|
||||
* precisely when they have no polyfills of their own, so it has to keep
|
||||
* shipping the full payload rather than only what bowser itself calls.
|
||||
* shipping the full payload rather than only what bowser itself calls. See
|
||||
* `build/entries/bundled.js` for what that payload is.
|
||||
*/
|
||||
const legacyBabel = (useBuiltIns: false | 'entry') => babel({
|
||||
presets: [['@babel/preset-env', {
|
||||
@@ -64,15 +61,17 @@ const legacyBabel = (useBuiltIns: false | 'entry') => babel({
|
||||
*
|
||||
* `useBuiltIns: false` here on purpose: `bundled.js` already has its polyfills
|
||||
* inlined by the input pass, and re-expanding them would recurse.
|
||||
*
|
||||
* `sourceType` is `'script'` for the UMD IIFEs and `'module'` for
|
||||
* `bowser.mjs`, whose `export` statement must survive untouched.
|
||||
*/
|
||||
const lowerChunkToEs5 = () => ({
|
||||
const lowerChunkToEs5 = (sourceType: 'script' | 'module' = 'script') => ({
|
||||
name: 'bowser:babel-output',
|
||||
async renderChunk(code: string, chunk: { fileName: string }) {
|
||||
const result = await transformAsync(code, {
|
||||
babelrc: false,
|
||||
configFile: false,
|
||||
// The emitted chunk is a UMD IIFE, i.e. a script, not a module.
|
||||
sourceType: 'script',
|
||||
sourceType,
|
||||
// core-js is large and already ES5; skipping its size guard keeps babel
|
||||
// from silently bailing out of compiling `bundled.js`.
|
||||
compact: false,
|
||||
@@ -149,14 +148,24 @@ export default defineConfig([
|
||||
umd('es5', 'build/entries/es5.js', false),
|
||||
umd('bundled', 'build/entries/bundled.js', 'entry'),
|
||||
{
|
||||
// Modern ESM build. Reached via the `import` condition of the exports map.
|
||||
// Deliberately not run through babel: `module` still points at the raw
|
||||
// `src/bowser.js`, so this must not be *more* conservative than that.
|
||||
// ESM build, reached via the `import` condition of the exports map.
|
||||
//
|
||||
// Lowered to ES5 syntax like the UMD bundles, keeping only `export`. Before
|
||||
// the exports map existed, webpack, Rollup (`browser: true`) and esbuild all
|
||||
// picked the `browser` field — `es5.js` — for `import 'bowser'`, not
|
||||
// `module`. Shipping ES2015 here would make esbuild `--target=es5` fail
|
||||
// outright, and ship classes into ES5 builds from webpack, which does not
|
||||
// transpile node_modules by default.
|
||||
entry: { bowser: 'src/bowser.js' },
|
||||
format: ['esm'],
|
||||
outDir: '.',
|
||||
outExtensions: () => ({ js: '.mjs' }),
|
||||
platform: 'browser',
|
||||
plugins: [legacyBabel(false), lowerChunkToEs5('module')],
|
||||
// Must be explicit. Left unset, rolldown still re-prints the chunk *after*
|
||||
// `renderChunk` (dead-code-elimination-only minify), and its printer turns
|
||||
// `{ version: version }` back into the ES2015 shorthand `{ version }`.
|
||||
minify: false,
|
||||
banner,
|
||||
dts: false,
|
||||
clean: false,
|
||||
|
||||
Reference in New Issue
Block a user