mirror of
https://github.com/lancedikson/bowser
synced 2026-09-22 12:05:23 +00:00
fix: bundled.js is not ES5 — restore ES5 output and guard it (#632)
This commit is contained in:
10
.github/workflows/pull-request.yml
vendored
10
.github/workflows/pull-request.yml
vendored
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
27
pnpm-lock.yaml
generated
27
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
50
test/acceptance/test-es5-conformance.js
Normal file
50
test/acceptance/test-es5-conformance.js
Normal file
@@ -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' }));
|
||||
});
|
||||
133
test/acceptance/test-es5-runtime.js
Normal file
133
test/acceptance/test-es5-runtime.js
Normal file
@@ -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');
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
174
test/types/check.mjs
Normal file
174
test/types/check.mjs
Normal file
@@ -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`);
|
||||
15
test/types/fixtures/consumer-cjs.ts
Normal file
15
test/types/fixtures/consumer-cjs.ts
Normal file
@@ -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<string, string> = Bowser.BROWSER_MAP;
|
||||
|
||||
export { name, satisfies, hints, maps };
|
||||
17
test/types/fixtures/consumer-esm.ts
Normal file
17
test/types/fixtures/consumer-esm.ts
Normal file
@@ -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<string, string> = Bowser.BROWSER_MAP;
|
||||
|
||||
export { result, satisfies, hints, maps };
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user