From c1208b4a56ced2fcc1e03b017d067989e72bfd3a Mon Sep 17 00:00:00 2001 From: Naor Peled Date: Sat, 5 Sep 2026 23:16:26 +0300 Subject: [PATCH] =?UTF-8?q?fix:=20bundled.js=20is=20not=20ES5=20=E2=80=94?= =?UTF-8?q?=20restore=20ES5=20output=20and=20guard=20it=20(#632)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/pull-request.yml | 10 ++ package.json | 7 +- pnpm-lock.yaml | 27 ++-- src/parser.js | 6 +- test/acceptance/test-es5-conformance.js | 50 +++++++ test/acceptance/test-es5-runtime.js | 133 ++++++++++++++++++ test/package/assertions.cjs | 8 ++ test/types/check.mjs | 174 ++++++++++++++++++++++++ test/types/fixtures/consumer-cjs.ts | 15 ++ test/types/fixtures/consumer-esm.ts | 17 +++ tsdown.config.ts | 59 +++++++- 11 files changed, 490 insertions(+), 16 deletions(-) create mode 100644 test/acceptance/test-es5-conformance.js create mode 100644 test/acceptance/test-es5-runtime.js create mode 100644 test/types/check.mjs create mode 100644 test/types/fixtures/consumer-cjs.ts create mode 100644 test/types/fixtures/consumer-esm.ts diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index a5a25d2..5eb446b 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -128,3 +128,13 @@ jobs: # of bowser, and preserved here on purpose. - name: Are the types wrong? run: pnpm exec attw --pack . --profile node16 --entrypoints . + + # attw checks that the types *resolve* to the right file per condition. + # This compiles a real consumer against the packed tarball in every + # module resolution mode, which is what catches a declaration that + # resolves fine but does not match the runtime. + - name: Type-check consumers + run: | + mkdir -p tarball + npm pack --pack-destination ./tarball + node test/types/check.mjs tarball/*.tgz diff --git a/package.json b/package.json index 2ef035d..0e142a0 100644 --- a/package.json +++ b/package.json @@ -89,6 +89,7 @@ "@babel/register": "^7.29.7", "@eslint/js": "^10.0.1", "@rolldown/plugin-babel": "0.2.3", + "acorn": "^8.18.0", "ava": "^3.0.0", "babel-plugin-add-module-exports": "^1.0.4", "babel-plugin-istanbul": "^8.0.0", @@ -117,7 +118,8 @@ ], "files": [ "test/**/*.js", - "!test/package/**" + "!test/package/**", + "!test/types/**" ] }, "bugs": { @@ -137,7 +139,8 @@ "test:watch": "ava --watch", "test:package": "node test/package/smoke.cjs", "coverage": "nyc report --reporter=text-lcov | coveralls", - "generate-docs": "jsdoc -c jsdoc.json" + "generate-docs": "jsdoc -c jsdoc.json", + "test:types": "node test/types/check.mjs" }, "license": "MIT", "packageManager": "pnpm@11.18.0" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c6dc8d1..a3f2c14 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -42,6 +42,9 @@ importers: '@rolldown/plugin-babel': specifier: 0.2.3 version: 0.2.3(@babel/core@7.29.7(supports-color@7.2.0))(rolldown@1.2.0) + acorn: + specifier: ^8.18.0 + version: 8.18.0 ava: specifier: ^3.0.0 version: 3.15.0(supports-color@7.2.0) @@ -1330,8 +1333,8 @@ packages: resolution: {integrity: sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==} engines: {node: '>=0.4.0'} - acorn@8.17.0: - resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==} + acorn@8.18.0: + resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==} engines: {node: '>=0.4.0'} hasBin: true @@ -5853,15 +5856,15 @@ snapshots: mime-types: 3.0.2 negotiator: 1.0.0 - acorn-jsx@5.3.2(acorn@8.17.0): + acorn-jsx@5.3.2(acorn@8.18.0): dependencies: - acorn: 8.17.0 + acorn: 8.18.0 acorn-walk@8.3.5: dependencies: - acorn: 8.17.0 + acorn: 8.18.0 - acorn@8.17.0: {} + acorn@8.18.0: {} aggregate-error@3.1.0: dependencies: @@ -6006,7 +6009,7 @@ snapshots: ava@3.15.0(supports-color@7.2.0): dependencies: '@concordance/react': 2.0.0 - acorn: 8.17.0 + acorn: 8.18.0 acorn-walk: 8.3.5 ansi-styles: 5.2.0 arrgv: 1.0.2 @@ -6993,14 +6996,14 @@ snapshots: espree@10.4.0: dependencies: - acorn: 8.17.0 - acorn-jsx: 5.3.2(acorn@8.17.0) + acorn: 8.18.0 + acorn-jsx: 5.3.2(acorn@8.18.0) eslint-visitor-keys: 4.2.1 espree@11.2.0: dependencies: - acorn: 8.17.0 - acorn-jsx: 5.3.2(acorn@8.17.0) + acorn: 8.18.0 + acorn-jsx: 5.3.2(acorn@8.18.0) eslint-visitor-keys: 5.0.1 esprima@4.0.1: {} @@ -8915,7 +8918,7 @@ snapshots: terser@5.49.0: dependencies: '@jridgewell/source-map': 0.3.11 - acorn: 8.17.0 + acorn: 8.18.0 commander: 2.20.3 source-map-support: 0.5.21 diff --git a/src/parser.js b/src/parser.js index d94aa9b..7412af8 100644 --- a/src/parser.js +++ b/src/parser.js @@ -132,7 +132,11 @@ class Parser { return undefined; } const brandLower = brandName.toLowerCase(); - const brand = this._hints.brands.find( + // `Utils.find`, not `Array.prototype.find`: the latter is ES6, and `es5.js` + // ships no polyfills, so it throws outright on the browsers that bundle + // targets. Every other lookup in this file already goes through the helper. + const brand = Utils.find( + this._hints.brands, b => b.brand && b.brand.toLowerCase() === brandLower, ); return brand ? brand.version : undefined; diff --git a/test/acceptance/test-es5-conformance.js b/test/acceptance/test-es5-conformance.js new file mode 100644 index 0000000..a5266c3 --- /dev/null +++ b/test/acceptance/test-es5-conformance.js @@ -0,0 +1,50 @@ +import test from 'ava'; +import fs from 'fs'; +import path from 'path'; +import * as acorn from 'acorn'; + +/** + * `es5.js` and `bundled.js` exist to serve browsers that predate ES2015. If a + * single arrow function or template literal reaches either file, the whole + * script is a SyntaxError there and bowser is not merely degraded, it is dead. + * + * A grep for backticks is not enough. When the webpack build was replaced by + * tsdown, rolldown's `__commonJS` interop helper — appended *after* babel runs, + * and left alone by terser, which avoids introducing new syntax but does not + * transpile — shipped arrow functions into `bundled.js`: + * + * var t=(t,e)=>()=>(e||(t((e={exports:{}}).exports,e),t=null),e.exports) + * + * Parsing the emitted files at `ecmaVersion: 5` is the only check that covers + * the whole file, including helpers no source-level transform ever sees. + * + * These are build outputs — run `pnpm build` before `pnpm test`. + */ +const root = path.join(__dirname, '..', '..'); + +const legacyBundles = ['es5.js', 'bundled.js']; + +legacyBundles.forEach((file) => { + test(`${file} parses as ES5`, (t) => { + const source = fs.readFileSync(path.join(root, file), 'utf8'); + t.notThrows( + () => acorn.parse(source, { ecmaVersion: 5 }), + `${file} contains syntax newer than ES5 — it will throw on load in the ` + + 'browsers this bundle exists to support', + ); + }); + + test(`${file} contains no template literals`, (t) => { + // Backticks inside string literals are fine (core-js has a few). Only a + // real template-literal token is a problem, so tokenise rather than grep. + const source = fs.readFileSync(path.join(root, file), 'utf8'); + const templates = [...acorn.tokenizer(source, { ecmaVersion: 2020 })] + .filter((token) => token.type.label === '`' || token.type.label === 'template'); + t.is(templates.length, 0, `${file} contains a template literal`); + }); +}); + +test('bowser.mjs is a valid ES module', (t) => { + const source = fs.readFileSync(path.join(root, 'bowser.mjs'), 'utf8'); + t.notThrows(() => acorn.parse(source, { ecmaVersion: 'latest', sourceType: 'module' })); +}); diff --git a/test/acceptance/test-es5-runtime.js b/test/acceptance/test-es5-runtime.js new file mode 100644 index 0000000..00d42aa --- /dev/null +++ b/test/acceptance/test-es5-runtime.js @@ -0,0 +1,133 @@ +import test from 'ava'; +import fs from 'fs'; +import path from 'path'; +import vm from 'vm'; + +/** + * Runs the legacy bundles on a global object stripped back to ES5.1. + * + * `test-es5-conformance.js` checks *syntax*. This checks *runtime APIs*, which + * is a separate failure mode babel cannot protect against: `@babel/preset-env` + * lowers syntax, but without `useBuiltIns` it never polyfills library calls. A + * single `Array.prototype.includes` or `Object.assign` in the parser source + * compiles cleanly, passes every test on modern Node, and then throws + * `TypeError: undefined is not a function` on the old browsers `es5.js` targets. + * + * `es5.js` ships with no polyfills at all, so it has to survive here on its own. + * `bundled.js` carries core-js and has to install what it needs and still work. + * + * These are build outputs — run `pnpm build` before `pnpm test`. + */ +const root = path.join(__dirname, '..', '..'); + +const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 ' + + '(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'; + +// Everything below postdates ES5.1. Not exhaustive — it covers the APIs a UA +// parser plausibly reaches for, which is what makes it a useful tripwire. +const ES6_GLOBALS = ['Promise', 'Symbol', 'Map', 'Set', 'WeakMap', 'WeakSet', 'Proxy', 'Reflect', 'globalThis', 'BigInt']; +const ES6_STATICS = { + Object: ['assign', 'entries', 'values', 'fromEntries', 'getOwnPropertySymbols', 'setPrototypeOf'], + Array: ['from', 'of'], + String: ['raw', 'fromCodePoint'], + Number: ['isInteger', 'isNaN', 'parseFloat', 'isFinite', 'EPSILON'], + Math: ['trunc', 'sign', 'log2', 'clz32'], +}; +const ES6_PROTOS = { + Array: ['includes', 'find', 'findIndex', 'flat', 'flatMap', 'fill', 'copyWithin', 'at'], + String: ['includes', 'startsWith', 'endsWith', 'repeat', 'padStart', 'padEnd', 'trimStart', 'trimEnd', 'matchAll', 'at', 'normalize', 'codePointAt'], +}; + +function createEs5Context() { + const context = vm.createContext({}); + // UMD bundles look for a global; `self` is the browser-shaped one. + vm.runInContext('this.self = this;', context); + const deletions = [] + .concat(ES6_GLOBALS.map((g) => `this.${g}`)) + .concat(...Object.entries(ES6_STATICS).map(([o, keys]) => keys.map((k) => `${o}.${k}`))) + .concat(...Object.entries(ES6_PROTOS).map(([o, keys]) => keys.map((k) => `${o}.prototype.${k}`))) + .map((ref) => `try { delete ${ref}; } catch (e) {}`) + .join('\n'); + vm.runInContext(deletions, context); + return context; +} + +test('the ES5 sandbox actually strips the modern APIs', (t) => { + // Guards the guard: if stripping silently stopped working, every assertion + // below would pass against a fully modern global and prove nothing. + const context = createEs5Context(); + t.is(vm.runInContext('typeof Promise', context), 'undefined'); + t.is(vm.runInContext('typeof Object.assign', context), 'undefined'); + t.is(vm.runInContext('typeof [].includes', context), 'undefined'); + t.is(vm.runInContext('typeof "".startsWith', context), 'undefined'); +}); + +['es5.js', 'bundled.js'].forEach((file) => { + test(`${file} runs on an ES5-only global`, (t) => { + const context = createEs5Context(); + const source = fs.readFileSync(path.join(root, file), 'utf8'); + + t.notThrows(() => vm.runInContext(source, context), `${file} threw while loading`); + t.is(vm.runInContext('typeof this.bowser', context), 'function'); + + context.__ua = UA; + t.is(vm.runInContext('this.bowser.parse(this.__ua).browser.name', context), 'Chrome'); + t.is(vm.runInContext('this.bowser.parse(this.__ua).os.name', context), 'macOS'); + t.true(vm.runInContext('this.bowser.getParser(this.__ua).satisfies({ chrome: ">100" })', context)); + }); +}); + +/** + * Client Hints go down a different code path than `parse()` — `isBrandVersion` + * and `getBrandVersion` reach into `_hints.brands` directly — so the checks + * above never touch them. `getBrandVersion` used `Array.prototype.find`, which + * is ES6, and threw on exactly the browsers `es5.js` exists for. + * + * The inputs are built by a script evaluated *inside* the context rather than + * assigned onto it. An array created in the host realm keeps the host's + * `Array.prototype`, so its `find` survives the sandbox's delete and the test + * passes against a bug that is still there. A real browser hands the parser a + * same-realm array, which is what this reproduces. + */ +['es5.js', 'bundled.js'].forEach((file) => { + test(`${file} handles Client Hints on an ES5-only global`, (t) => { + const context = createEs5Context(); + vm.runInContext(fs.readFileSync(path.join(root, file), 'utf8'), context); + + const result = vm.runInContext(` + var ua = ${JSON.stringify(UA)}; + var hints = { + brands: [ + { brand: 'Chromium', version: '131' }, + { brand: 'Google Chrome', version: '131' }, + ], + mobile: false, + platform: 'macOS', + }; + var parser = this.bowser.getParser(ua, false, hints); + ({ + brandVersion: parser.getBrandVersion('Google Chrome'), + missingBrand: parser.getBrandVersion('Firefox'), + hasBrand: parser.hasBrand('Google Chrome'), + hasOtherBrand: parser.hasBrand('Firefox'), + hints: !!parser.getHints(), + }) + `, context); + + t.is(result.brandVersion, '131'); + t.is(result.missingBrand, undefined); + t.true(result.hasBrand); + t.false(result.hasOtherBrand); + t.true(result.hints); + }); +}); + +test('bundled.js installs the polyfills it promises', (t) => { + // The README tells consumers to reach for bundled.js when they have no + // polyfills of their own, so it has to actually populate the environment. + const context = createEs5Context(); + vm.runInContext(fs.readFileSync(path.join(root, 'bundled.js'), 'utf8'), context); + t.is(vm.runInContext('typeof Promise', context), 'function'); + t.is(vm.runInContext('typeof Object.assign', context), 'function'); + t.is(vm.runInContext('typeof [].includes', context), 'function'); +}); diff --git a/test/package/assertions.cjs b/test/package/assertions.cjs index 083ff36..f3f86b4 100644 --- a/test/package/assertions.cjs +++ b/test/package/assertions.cjs @@ -77,6 +77,14 @@ check('constant maps are exposed', function () { // The src/*.js files are ES module sources, so they resolve but do not execute // under require(). Bundlers are the real consumer here. Assert resolution only. +// +// This is also why publint warns on every `pkg.exports["./src/*"]` entry, and +// why these paths fail under Yarn PnP (ERR_REQUIRE_CYCLE_MODULE) and on Node +// below 20.19, which has no module-syntax detection. Verified identical on +// 2.14.1, so it is longstanding rather than new. The obvious fix — a nested +// `src/package.json` with `"type": "module"` — would stop `@babel/register` +// from loading `src/` and take the whole AVA suite with it, so the wart stays +// until the test tooling moves off `require()` hooks. [ 'bowser.js', 'constants.js', 'parser.js', 'parser-browsers.js', 'parser-engines.js', 'parser-os.js', 'parser-platforms.js', 'utils.js', diff --git a/test/types/check.mjs b/test/types/check.mjs new file mode 100644 index 0000000..b532e72 --- /dev/null +++ b/test/types/check.mjs @@ -0,0 +1,174 @@ +/** + * Type-checks a real consumer against the *packed* package under every module + * resolution mode TypeScript offers. + * + * node test/types/check.mjs [path-to-bowser-x.y.z.tgz] + * + * With a tarball, the published artifact itself is checked — that is how CI + * runs it. With no argument the package is assembled from the working tree's + * `files` allowlist instead, so `pnpm test:types` works from a plain checkout + * without the caller having to pack first. + * + * `attw` already runs in CI, but it answers a narrower question: whether the + * types *resolve* to the right file for each condition. It does not compile + * anything, so it cannot catch a declaration that resolves fine and then fails + * to describe the runtime — a missing member, a wrong signature, or a named + * export declared in `index.d.mts` that `bowser.mjs` does not actually have. + * + * Both directions are asserted. The negative cases matter as much as the + * positive ones: `index.d.mts` deliberately omits `BROWSER_MAP` and friends as + * named exports because importing them that way throws at runtime, and only a + * compile that is expected to *fail* can hold that line. + * + * Either way the package is installed into a real `node_modules` and resolved + * by the normal upward walk, so the exports map, the `types` field and the + * published file list are all exercised exactly as a consumer sees them. A + * tsconfig `paths` mapping would resolve the directory directly and silently + * bypass the exports map, which is the thing most worth testing here. + */ +import cp from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.join(here, '..', '..'); +const tsc = path.join(repoRoot, 'node_modules', '.bin', 'tsc'); + +const tarball = process.argv[2]; +if (tarball && !fs.existsSync(tarball)) { + console.error(`no such tarball: ${tarball}`); + process.exit(1); +} + +/** Each mode a real consumer can be configured with. */ +const MODES = [ + { name: 'node10 (CJS)', moduleResolution: 'node10', module: 'commonjs', type: 'commonjs', fixture: 'consumer-cjs.ts' }, + { name: 'node16 (CJS)', moduleResolution: 'node16', module: 'node16', type: 'commonjs', fixture: 'consumer-cjs.ts' }, + { name: 'node16 (ESM)', moduleResolution: 'node16', module: 'node16', type: 'module', fixture: 'consumer-esm.ts' }, + { name: 'nodenext (ESM)', moduleResolution: 'nodenext', module: 'nodenext', type: 'module', fixture: 'consumer-esm.ts' }, + { name: 'bundler', moduleResolution: 'bundler', module: 'esnext', type: 'module', fixture: 'consumer-esm.ts' }, +]; + +/** + * Imports that must NOT compile, because the runtime does not provide them. + * Keeps `index.d.mts` honest — if any of these starts compiling, the types are + * promising something `bowser.mjs` will not deliver. + */ +const MUST_NOT_COMPILE = [ + ['BROWSER_MAP is not a named export', 'import { BROWSER_MAP } from "bowser"; export default BROWSER_MAP;'], + ['OS_MAP is not a named export', 'import { OS_MAP } from "bowser"; export default OS_MAP;'], + ['ENGINE_MAP is not a named export', 'import { ENGINE_MAP } from "bowser"; export default ENGINE_MAP;'], + ['PLATFORMS_MAP is not a named export', 'import { PLATFORMS_MAP } from "bowser"; export default PLATFORMS_MAP;'], + ['Parser is a type, not a value', 'import { Parser } from "bowser"; export default new Parser("x");'], +]; + +const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'bowser-types-')); +const modules = path.join(tmp, 'node_modules'); +fs.mkdirSync(modules, { recursive: true }); + +/** + * Assembles the package from the repo the way `npm pack` would, for when no + * tarball is supplied. `npm pack` is not an option here: package.json carries + * no `version` (it is stamped at release time), and npm refuses to pack + * without one. Copying the `files` allowlist gives the same tree, so + * `pnpm test:types` works from a plain checkout while CI keeps passing the + * real tarball it already builds. + */ +function assembleFromRepo(dest) { + const manifest = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8')); + // npm always includes these regardless of the `files` allowlist. + const entries = [...manifest.files, 'package.json', 'README.md', 'LICENSE']; + for (const entry of entries) { + const from = path.join(repoRoot, entry); + if (!fs.existsSync(from)) continue; + fs.cpSync(from, path.join(dest, entry), { recursive: true }); + } + const missing = ['es5.js', 'bundled.js', 'bowser.mjs'] + .filter((f) => !fs.existsSync(path.join(dest, f))); + if (missing.length) { + console.error(`missing build output: ${missing.join(', ')} — run \`pnpm build\` first`); + process.exit(1); + } +} + +const pkgDir = path.join(modules, 'bowser'); +if (tarball) { + cp.execFileSync('tar', ['xzf', path.resolve(tarball), '-C', modules]); + fs.renameSync(path.join(modules, 'package'), pkgDir); +} else { + fs.mkdirSync(pkgDir, { recursive: true }); + assembleFromRepo(pkgDir); +} + +/** Runs tsc over a single file in a project configured for `mode`. */ +function typeCheck(mode, fileName, source) { + // Nested one level under `tmp`, so resolution walks up into tmp/node_modules + // the way a real consumer's does. `paths` would short-circuit the exports map. + const dir = path.join(tmp, `case-${Math.abs(hash(mode.name + fileName))}`); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'app.ts'), source); + fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ + name: 'consumer', version: '1.0.0', private: true, type: mode.type, + })); + fs.writeFileSync(path.join(dir, 'tsconfig.json'), JSON.stringify({ + compilerOptions: { + strict: true, + noEmit: true, + // Do not skip: a broken .d.ts in the package itself must fail the check. + skipLibCheck: false, + target: 'es2020', + module: mode.module, + moduleResolution: mode.moduleResolution, + esModuleInterop: true, + resolveJsonModule: true, + types: [], + }, + files: ['app.ts'], + })); + const result = cp.spawnSync(tsc, ['-p', 'tsconfig.json'], { cwd: dir, encoding: 'utf8' }); + return { ok: result.status === 0, output: `${result.stdout || ''}${result.stderr || ''}`.trim() }; +} + +function hash(s) { + let h = 0; + for (let i = 0; i < s.length; i += 1) { h = ((h << 5) - h + s.charCodeAt(i)) | 0; } + return h; +} + +let failures = 0; +console.log(`Type-checking consumers against ${tarball ? path.basename(tarball) : 'the working tree'}\n`); + +for (const mode of MODES) { + const source = fs.readFileSync(path.join(here, 'fixtures', mode.fixture), 'utf8'); + const { ok, output } = typeCheck(mode, mode.fixture, source); + if (ok) { + console.log(` ok ${mode.name.padEnd(16)} ${mode.fixture}`); + } else { + failures += 1; + console.log(` FAIL ${mode.name.padEnd(16)} ${mode.fixture}\n${output.replace(/^/gm, ' ')}`); + } +} + +console.log(''); +// Only needs one ESM-shaped mode; the .d.mts is what is under test. +const negativeMode = MODES.find((m) => m.name === 'node16 (ESM)'); +for (const [label, source] of MUST_NOT_COMPILE) { + const { ok } = typeCheck(negativeMode, `negative-${label}`, source); + if (ok) { + failures += 1; + console.log(` FAIL types accept something the runtime rejects: ${label}`); + } else { + console.log(` ok rejected: ${label}`); + } +} + +try { fs.rmSync(tmp, { recursive: true, force: true }); } catch { /* best effort */ } + +console.log(''); +if (failures) { + console.error(`${failures} type check(s) failed`); + process.exit(1); +} +console.log(`all ${MODES.length + MUST_NOT_COMPILE.length} type checks passed`); diff --git a/test/types/fixtures/consumer-cjs.ts b/test/types/fixtures/consumer-cjs.ts new file mode 100644 index 0000000..2414e48 --- /dev/null +++ b/test/types/fixtures/consumer-cjs.ts @@ -0,0 +1,15 @@ +// A CommonJS consumer: default import via esModuleInterop, types reached +// through the `export =` namespace. This is what `moduleResolution: node10` +// and `node16` (from CJS) consumers write. +import Bowser from 'bowser'; + +const UA = 'Mozilla/5.0 (Macintosh) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'; + +const result: Bowser.Parser.ParsedResult = Bowser.parse(UA); +const parser: Bowser.Parser.Parser = Bowser.getParser(UA); +const name: string | undefined = result.browser.name; +const satisfies: boolean | undefined = parser.satisfies({ chrome: '>100' }); +const hints: Bowser.ClientHints = { mobile: false }; +const maps: Record = Bowser.BROWSER_MAP; + +export { name, satisfies, hints, maps }; diff --git a/test/types/fixtures/consumer-esm.ts b/test/types/fixtures/consumer-esm.ts new file mode 100644 index 0000000..c1a6066 --- /dev/null +++ b/test/types/fixtures/consumer-esm.ts @@ -0,0 +1,17 @@ +// An ES module consumer: default plus named imports, types imported as types. +// This is `moduleResolution: node16` (from ESM), `nodenext` and `bundler`. +import Bowser, { parse, getParser } from 'bowser'; +import type { ParsedResult, Parser, ClientHints, checkTree } from 'bowser'; + +const UA = 'Mozilla/5.0 (Macintosh) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'; + +const result: ParsedResult = parse(UA); +const parser: Parser = getParser(UA); +const tree: checkTree = { chrome: '>100' }; +const satisfies: boolean | undefined = parser.satisfies(tree); +const hints: ClientHints = { mobile: false }; +// BROWSER_MAP is deliberately not a named export of bowser.mjs — it only +// exists as a static on the class. Reaching it any other way must not compile. +const maps: Record = Bowser.BROWSER_MAP; + +export { result, satisfies, hints, maps }; diff --git a/tsdown.config.ts b/tsdown.config.ts index 60f85d3..7a5a469 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -1,6 +1,7 @@ import { defineConfig } from 'tsdown'; import babel from '@rolldown/plugin-babel'; import { minify } from 'terser'; +import { transformAsync } from '@babel/core'; const banner = `/*! * Bowser - a browser detector @@ -22,6 +23,17 @@ 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. + * + * 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. */ const legacyBabel = (useBuiltIns: false | 'entry') => babel({ presets: [['@babel/preset-env', { @@ -34,6 +46,51 @@ const legacyBabel = (useBuiltIns: false | 'entry') => babel({ }]], }); +/** + * Lowers the *emitted chunk* to ES5, after bundling and before terser. + * + * `legacyBabel()` above only transforms input modules. Rolldown appends its own + * runtime helpers afterwards — notably the `__commonJS` wrapper it injects for + * CommonJS dependencies — and emits them in modern syntax: + * + * var t=(t,e)=>()=>(e||(t((e={exports:{}}).exports,e),t=null),e.exports) + * + * terser's `ecma: 5` does not transpile; it only avoids *introducing* newer + * syntax. So those arrow functions survived into the published `bundled.js`, + * making the whole file a SyntaxError in the ES5 engines it exists to serve. + * `es5.js` has no CommonJS dependencies, so it never got a helper — which is + * why only `bundled.js` was affected, and why this has to run on the output + * rather than being folded into `legacyBabel()`. + * + * `useBuiltIns: false` here on purpose: `bundled.js` already has its polyfills + * inlined by the input pass, and re-expanding them would recurse. + */ +const lowerChunkToEs5 = () => ({ + 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', + // core-js is large and already ES5; skipping its size guard keeps babel + // from silently bailing out of compiling `bundled.js`. + compact: false, + generatorOpts: { comments: true }, + presets: [['@babel/preset-env', { + modules: false, + loose: true, + useBuiltIns: false, + targets: legacyTargets, + }]], + }); + if (typeof result?.code !== 'string') { + throw new Error(`babel produced no output for ${chunk.fileName}`); + } + return { code: result.code }; + }, +}); + /** * Minifies the UMD chunks with terser instead of rolldown's built-in (oxc) * minifier. @@ -78,7 +135,7 @@ const umd = (name: string, entry: string, useBuiltIns: false | 'entry') => ({ }, outDir: '.', platform: 'browser' as const, - plugins: [legacyBabel(useBuiltIns), terser()], + plugins: [legacyBabel(useBuiltIns), lowerChunkToEs5(), terser()], // webpack ran in `mode: 'production'`; minification happens in `terser()` // above, so rolldown's own minifier stays off. See its comment for why. minify: false,