diff --git a/build/entries/bundled.js b/build/entries/bundled.js index 59b49b6..240facd 100644 --- a/build/entries/bundled.js +++ b/build/entries/bundled.js @@ -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'; diff --git a/package.json b/package.json index 0e142a0..83b4722 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/test/acceptance/test-es5-conformance.js b/test/acceptance/test-es5-conformance.js index a5266c3..18084ea 100644 --- a/test/acceptance/test-es5-conformance.js +++ b/test/acceptance/test-es5-conformance.js @@ -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', + ); }); diff --git a/test/acceptance/test-es5-runtime.js b/test/acceptance/test-es5-runtime.js index 00d42aa..8723df8 100644 --- a/test/acceptance/test-es5-runtime.js +++ b/test/acceptance/test-es5-runtime.js @@ -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'); +}); diff --git a/test/package/assertions.cjs b/test/package/assertions.cjs index f3f86b4..6e71762 100644 --- a/test/package/assertions.cjs +++ b/test/package/assertions.cjs @@ -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); diff --git a/tsdown.config.ts b/tsdown.config.ts index 7a5a469..90b8674 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -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,