1
0
mirror of https://github.com/lancedikson/bowser synced 2026-09-23 12:34:58 +00:00

fix: make dual packaging non-breaking for existing consumers

Keeps the published surface of bowser@2.14.1 intact while adding a real
ESM build, and rewrites CI so it can actually run the new toolchain.

Packaging

- Restore the flat artifact layout. es5.js and bundled.js stay at the
  tarball root next to src/, so unpkg.com/bowser/es5.js and
  require('bowser/bundled') keep working. bowser.mjs is added alongside.
- Build the UMD bundles from dedicated single-default-export entries in
  build/entries/. src/bowser.js gained named exports (parse, getParser)
  to fix #511, but a UMD bundle with named exports makes require('bowser')
  a namespace object instead of the class: typeof flips from 'function' to
  'object' and BROWSER_MAP / ENGINE_MAP / OS_MAP / PLATFORMS_MAP disappear,
  since they are static getters rather than exports.
- globalName back to lowercase 'bowser', matching the shipped es5.js.
  Renaming it to 'Bowser' would break every script-tag consumer.
- Enumerate every legacy subpath in the exports map. Conditional exports
  are honoured from Node 12.16.0 onward, so a "."-only map turns paths
  that resolve today into ERR_PACKAGE_PATH_NOT_EXPORTED. Subpath patterns
  ("./src/*") need Node 12.20.0+ and the trailing-slash folder form was
  removed in Node 17, so explicit per-file keys are the only spelling that
  works across the whole supported range. Extension-less aliases included:
  the README documents require('bowser/bundled').
- main, browser and module keep their existing values. No engines field
  (npm warns EBADENGINE, pnpm fails under engine-strict) and no type
  field (it would reclassify es5.js as ESM).

Build

- Minify the UMD bundles with terser instead of rolldown's built-in oxc
  minifier. oxc prints every string literal as a template literal and
  rejects any compress.target below es2015, so it cannot emit ES5 and was
  silently undoing babel's lowering. terser is what webpack 4 used.
- Wire up the copyright banner, which was declared but never passed to a
  config, and restore bundled.js as the polyfilled build via core-js/stable.
- Add index.d.mts so the import condition has ESM types. Reusing the
  export = declarations for both conditions describes an ES module with
  CommonJS types.

CI

- Replace npm ci with pnpm across all workflows. This is what was failing:
  the lockfile was swapped for pnpm-lock.yaml but the workflows still ran
  npm ci, pinned to Node 12.16.3 and 16. Build now runs on Node 24, which
  tsdown requires.
- Add a pack-smoke job that installs the packed tarball on Node 12.16.3,
  14, 18, 20 and 24 and exercises every documented entry point, plus
  publint and attw on the tarball. This is what makes "non-breaking" a
  tested claim rather than an argument; it catches all of the above.
- Restore test-list-of-ua.js to asserting src against the built es5.js.
  It had been collapsed to comparing Bowser.parse with itself.
This commit is contained in:
naorpeled
2026-08-01 14:40:56 +03:00
parent 1944c3d05c
commit a78d9d35a2
16 changed files with 1467 additions and 586 deletions

View File

@@ -1,7 +1,6 @@
{
"presets": [["@babel/preset-env", {
"useBuiltIns": "usage",
"corejs": "3",
"useBuiltIns": "entry",
"modules": "cjs",
"loose": true,
"targets": {

View File

@@ -8,30 +8,29 @@ jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [12.16.3]
steps:
- name: Get branch name (merge)
if: github.event_name != 'pull_request'
- name: Get branch name
shell: bash
run: echo "BRANCH_NAME=$(echo ${GITHUB_REF#refs/heads/} | tr / -)" >> $GITHUB_ENV
- name: Get branch name (pull request)
if: github.event_name == 'pull_request'
shell: bash
run: echo "BRANCH_NAME=$(echo ${GITHUB_HEAD_REF} | tr / -)" >> $GITHUB_ENV
- uses: actions/checkout@v4
- name: Use Node.js ${{ matrix.node-version }}
- name: Set up pnpm
uses: pnpm/action-setup@v4
# tsdown requires Node ^22.18.0 || >=24.11.0. The published artifacts are
# still ES5 — see the `pack-smoke` job in pull-request.yml.
- name: Set up node
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- run: npm i -g nyc@15
- run: npm ci
- run: npm run build
- run: nyc npm test && nyc report --reporter=text-lcov | ./node_modules/coveralls/bin/coveralls.js
node-version: "24"
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm build
# `pnpm test` already runs ava under nyc; this only reports the result.
- run: pnpm test && pnpm exec nyc report --reporter=text-lcov | pnpm exec coveralls
env:
COVERALLS_SERVICE_NAME: GithubActions
COVERALLS_REPO_TOKEN: ${{ secrets.COVERALLS_REPO_TOKEN }}

View File

@@ -21,13 +21,17 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up pnpm
uses: pnpm/action-setup@v4
# tsdown requires Node ^22.18.0 || >=24.11.0. This only affects how the
# package is built; the artifacts it emits are still ES5.
- uses: actions/setup-node@v4
with:
node-version: 12.16.3
- uses: actions/cache@v4
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ hashFiles('**/package.json') }}
node-version: "24"
cache: pnpm
# Store the release version (from release tag or manual input)
- name: Set release version
run: |
@@ -38,14 +42,33 @@ jobs:
echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
echo "Release triggered from tag: ${GITHUB_REF#refs/*/}"
fi
- run: npm ci
- run: npm version $RELEASE_VERSION --no-git-tag-version
- run: npm run build
- run: pnpm install --frozen-lockfile
- run: npm version $RELEASE_VERSION --no-git-tag-version --allow-same-version
- run: pnpm build
# Guard the release: the same checks the PR workflow runs, against the
# artifacts actually about to be published.
- run: pnpm test
- run: pnpm exec publint
- run: pnpm exec attw --pack . --profile node16 --entrypoints .
# Upload only what `files` publishes, plus the manifest. The previous
# `path: .` also shipped node_modules through the artifact store.
- name: Upload build artifacts
uses: actions/upload-artifact@v4
with:
name: dist
path: .
path: |
package.json
README.md
LICENSE
es5.js
bundled.js
bowser.mjs
index.d.ts
index.d.mts
src
publish:
needs: build
@@ -61,4 +84,4 @@ jobs:
- name: Update npm to latest (trusted publishing requires npm >= 11.5.1)
run: npm install -g npm@latest
- name: Publish to npm
run: npm publish --provenance --access public
run: npm publish --provenance --access public

View File

@@ -3,42 +3,122 @@ on:
pull_request:
types: [opened, reopened, synchronize]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
node: [12.16.3]
name: Node ${{ matrix.node }}
steps:
- name: "Checkout latest code"
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Set up node
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build
- name: Run tests
run: npm run test
env:
# tsdown requires Node ^22.18.0 || >=24.11.0. This is the *build* toolchain
# only — see the `pack-smoke` job for the versions the published package
# itself has to keep working on.
BUILD_NODE_VERSION: "24"
lint:
name: "ESLint"
jobs:
build:
name: "Build & test"
runs-on: ubuntu-latest
steps:
- name: Checkout latest code
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Set up pnpm
uses: pnpm/action-setup@v4
- name: Set up node
uses: actions/setup-node@v4
with:
node-version: "16"
node-version: ${{ env.BUILD_NODE_VERSION }}
cache: pnpm
- name: Install dependencies
run: npm ci
run: pnpm install --frozen-lockfile
- name: Run ESLint
run: npm run lint:check
run: pnpm lint:check
- name: Build
run: pnpm build
- name: Run tests
run: pnpm test
# package.json intentionally carries no `version` — it is stamped at
# release time by publish.yml. `npm pack` needs one, so use a throwaway.
- name: Pack
run: |
npm version 0.0.0-ci --no-git-tag-version --allow-same-version
npm pack --pack-destination ./tarball
- name: Upload package tarball
uses: actions/upload-artifact@v4
with:
name: package-tarball
path: tarball/*.tgz
pack-smoke:
# Installs the packed tarball and exercises every documented entry point.
# bowser publishes ES5 artifacts and has no `engines` field, so the package
# must keep working far below the version we build on.
name: "Consumer smoke (Node ${{ matrix.node }})"
needs: build
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
node: ["12.16.3", "14", "18", "20", "24"]
steps:
- name: Checkout latest code
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Download package tarball
uses: actions/download-artifact@v4
with:
name: package-tarball
path: tarball
- name: Set up node
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
# No install step: bowser has no runtime dependencies and the smoke test
# only uses Node built-ins, so this runs on every version above.
- name: Run package smoke test
run: node test/package/smoke.cjs tarball/*.tgz
package-lint:
name: "Package manifest"
needs: build
runs-on: ubuntu-latest
steps:
- name: Checkout latest code
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Set up pnpm
uses: pnpm/action-setup@v4
- name: Set up node
uses: actions/setup-node@v4
with:
node-version: ${{ env.BUILD_NODE_VERSION }}
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build
run: pnpm build
- name: Stamp a throwaway version
run: npm version 0.0.0-ci --no-git-tag-version --allow-same-version
- name: publint
run: pnpm exec publint
# Scoped to the root entrypoint. The legacy `bowser/src/*` subpaths are
# untyped ESM sources in a CJS package — true on every published version
# of bowser, and preserved here on purpose.
- name: Are the types wrong?
run: pnpm exec attw --pack . --profile node16 --entrypoints .

13
.gitignore vendored
View File

@@ -2,6 +2,13 @@ node_modules/
.idea/
.nyc_output
coverage
dist
bundled.js*
es5.js*
# Build output (see tsdown.config.ts). Anchored to the repo root on purpose —
# unanchored these would also match the build entry points in build/entries/.
/bundled.js*
/es5.js*
/bowser.mjs*
# `npm pack` output, used by the package smoke test
*.tgz
/tarball/

View File

@@ -27,7 +27,9 @@ _For legacy code, check out the [1.x](https://github.com/bowser-js/bowser/tree/v
# Use cases
First of all, require the library. This is a UMD Module, so it will work for AMD, TypeScript, ES6, and CommonJS module systems.
First of all, require the library. Bowser is a dual package: `require` resolves
to a UMD build (which also works for AMD and as a plain `<script>` tag), and
`import` resolves to a real ES module.
```javascript
const Bowser = require("bowser"); // CommonJS
@@ -37,11 +39,30 @@ import * as Bowser from "bowser"; // TypeScript
import Bowser from "bowser"; // ES6 (and TypeScript with --esModuleInterop enabled)
```
The ES module build also exposes `parse` and `getParser` as named exports, so
you can import just the part you use and let your bundler drop the rest:
```javascript
import { getParser, parse } from "bowser";
const browser = getParser(window.navigator.userAgent);
```
Loaded from a CDN or a `<script>` tag, Bowser attaches itself to the global as
`bowser` (lowercase):
```html
<script src="https://unpkg.com/bowser@2/es5.js"></script>
<script>
console.log(bowser.parse(window.navigator.userAgent));
</script>
```
By default, the exported version is the *ES5 transpiled version*, which **do not** include any polyfills.
In case you don't use your own `babel-polyfill` you may need to have pre-built bundle with all needed polyfills.
In case you don't use your own polyfills you may need to have pre-built bundle with all needed polyfills.
So, for you it's suitable to require bowser like this: `require('bowser/bundled')`.
As the result, you get a ES5 version of bowser with `babel-polyfill` bundled together.
As the result, you get a ES5 version of bowser with `core-js` polyfills bundled together.
You may need to use the source files, so they will be available in the package as well.

16
build/entries/bundled.js Normal file
View File

@@ -0,0 +1,16 @@
/**
* 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.
*
* See `./es5.js` for why this only re-exports the default.
*/
import 'core-js/stable';
import 'regenerator-runtime/runtime';
import Bowser from '../../src/bowser.js';
export default Bowser;

15
build/entries/es5.js Normal file
View File

@@ -0,0 +1,15 @@
/**
* UMD entry point for `es5.js`.
*
* `src/bowser.js` also has named exports (`parse`, `getParser`) so that ESM
* consumers can `import { getParser } from 'bowser'`. A UMD bundle with named
* exports would expose `module.exports = { default, parse, getParser }`, which
* would break every existing `require('bowser')` and `window.bowser` consumer.
*
* Re-exporting only the default here keeps the UMD output at
* `module.exports = Bowser` / `window.bowser = Bowser`. The class carries
* `parse` and `getParser` as static methods, so nothing is lost.
*/
import Bowser from '../../src/bowser.js';
export default Bowser;

35
index.d.mts Normal file
View File

@@ -0,0 +1,35 @@
// ESM type definitions for Bowser v2.
//
// `index.d.ts` uses `export =`, which describes the CommonJS/UMD shape of
// `es5.js` (`module.exports = Bowser`). It is reached via the `require`
// condition and stays the source of truth for the type declarations.
//
// This file describes `bowser.mjs`, which is a real ES module: it has a default
// export *and* the `parse` / `getParser` named exports. It is reached via the
// `import` condition. Declaring it separately is what keeps the types honest
// for `moduleResolution: node16`/`bundler` consumers — reusing `index.d.ts` for
// both conditions would describe an ES module with CommonJS types.
import Bowser = require('./index.js');
export default Bowser;
export declare const parse: typeof Bowser.parse;
export declare const getParser: typeof Bowser.getParser;
export declare const BROWSER_MAP: typeof Bowser.BROWSER_MAP;
export declare const ENGINE_MAP: typeof Bowser.ENGINE_MAP;
export declare const OS_MAP: typeof Bowser.OS_MAP;
export declare const PLATFORMS_MAP: typeof Bowser.PLATFORMS_MAP;
export type ClientHints = Bowser.ClientHints;
export type Parser = Bowser.Parser.Parser;
export type ParsedResult = Bowser.Parser.ParsedResult;
export type Details = Bowser.Parser.Details;
export type BrowserDetails = Bowser.Parser.BrowserDetails;
export type EngineDetails = Bowser.Parser.EngineDetails;
export type OSDetails = Bowser.Parser.OSDetails;
export type PlatformDetails = Bowser.Parser.PlatformDetails;
export type checkTree = Bowser.Parser.checkTree;
export { Bowser };

View File

@@ -3,8 +3,11 @@
"description": "Lightweight browser detector",
"files": [
"src",
"dist",
"index.d.ts"
"es5.js",
"bundled.js",
"bowser.mjs",
"index.d.ts",
"index.d.mts"
],
"keywords": [
"browser",
@@ -28,27 +31,51 @@
"url": "https://github.com/naorpeled"
}
],
"main": "./dist/umd/bowser.umd.js",
"browser": "./dist/esm/bowser.mjs",
"module": "./dist/esm/bowser.mjs",
"main": "es5.js",
"browser": "es5.js",
"module": "src/bowser.js",
"types": "index.d.ts",
"exports": {
".": {
"import": {
"types": "./index.d.ts",
"default": "./dist/esm/bowser.mjs"
"types": "./index.d.mts",
"default": "./bowser.mjs"
},
"require": {
"types": "./index.d.ts",
"default": "./dist/umd/bowser.umd.js"
}
}
"default": "./es5.js"
},
"default": "./es5.js"
},
"./es5.js": "./es5.js",
"./es5": "./es5.js",
"./bundled.js": "./bundled.js",
"./bundled": "./bundled.js",
"./bowser.mjs": "./bowser.mjs",
"./src/bowser.js": "./src/bowser.js",
"./src/bowser": "./src/bowser.js",
"./src/constants.js": "./src/constants.js",
"./src/constants": "./src/constants.js",
"./src/parser.js": "./src/parser.js",
"./src/parser": "./src/parser.js",
"./src/parser-browsers.js": "./src/parser-browsers.js",
"./src/parser-browsers": "./src/parser-browsers.js",
"./src/parser-engines.js": "./src/parser-engines.js",
"./src/parser-engines": "./src/parser-engines.js",
"./src/parser-os.js": "./src/parser-os.js",
"./src/parser-os": "./src/parser-os.js",
"./src/parser-platforms.js": "./src/parser-platforms.js",
"./src/parser-platforms": "./src/parser-platforms.js",
"./src/utils.js": "./src/utils.js",
"./src/utils": "./src/utils.js",
"./package.json": "./package.json"
},
"repository": {
"type": "git",
"url": "git+https://github.com/bowser-js/bowser.git"
},
"devDependencies": {
"@arethetypeswrong/cli": "^0.18.5",
"@babel/cli": "^7.29.7",
"@babel/core": "^7.29.7",
"@babel/preset-env": "^7.29.7",
@@ -58,9 +85,9 @@
"ava": "^3.0.0",
"babel-plugin-add-module-exports": "^1.0.4",
"babel-plugin-istanbul": "^8.0.0",
"core-js": "^3.49.0",
"coveralls": "^3.0.6",
"docdash": "^1.1.1",
"core-js": "^3.49.0",
"eslint": "^10.8.0",
"eslint-config-airbnb-extended": "^3.1.0",
"eslint-plugin-ava": "^17.0.1",
@@ -68,15 +95,22 @@
"gh-pages": "^3.0.0",
"jsdoc": "^3.6.3",
"nyc": "^18.0.0",
"publint": "^0.3.22",
"regenerator-runtime": "^0.14.1",
"sinon": "^22.0.0",
"terser": "^5.49.0",
"testem": "^3.0.0",
"tsdown": "^0.22.14",
"typescript": "^5.9.3",
"yamljs": "^0.3.0"
},
"ava": {
"require": [
"@babel/register"
],
"files": [
"test/**/*.js",
"!test/package/**"
]
},
"bugs": {
@@ -94,8 +128,10 @@
"testem": "testem",
"test": "nyc --reporter=html --reporter=text ava",
"test:watch": "ava --watch",
"test:package": "node test/package/smoke.cjs",
"coverage": "nyc report --reporter=text-lcov | coveralls",
"generate-docs": "jsdoc -c jsdoc.json"
},
"license": "MIT"
"license": "MIT",
"packageManager": "pnpm@11.18.0"
}

1284
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -209,7 +209,6 @@ class Parser {
return this.getBrowser().name || '';
}
/**
* Get browser's version
* @return {String} version of browser

View File

@@ -1,7 +1,8 @@
import test from 'ava';
import yaml from 'yamljs';
import path from 'path';
import Bowser from '../../dist/esm/bowser.mjs';
import Bowser from '../../src/bowser';
import BowserBuild from '../../es5';
const listOfUA = yaml.load(path.join(__dirname, 'useragentstrings.yml'));
@@ -11,7 +12,7 @@ browserNames.forEach((browserName) => {
listOfUA[browserName].forEach((browser, index) => {
test(`Test ${browserName} ${index}`, (t) => {
const parsed = Bowser.parse(browser.ua);
const parsedBuild = Bowser.parse(browser.ua);
const parsedBuild = BowserBuild.parse(browser.ua);
t.deepEqual(parsed, browser.spec, `${browser.ua}`);
t.deepEqual(parsedBuild, browser.spec, `${browser.ua}`);
t.is(parsed.browser.name, browserName, `${browser.ua}`);

192
test/package/assertions.cjs Normal file
View File

@@ -0,0 +1,192 @@
/* eslint-disable */
/**
* Runs *inside* a throwaway directory that has the packed bowser tarball
* extracted into ./node_modules/bowser, so that bare specifiers resolve the way
* they would for a real consumer.
*
* Deliberately written as ES5-compatible CommonJS: this file has to run on
* every Node version bowser claims to support, down to 12.16.3. No optional
* chaining, no nullish coalescing, no top-level await.
*/
var assert = require('assert');
var fs = require('fs');
var path = require('path');
var vm = require('vm');
var 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';
var passed = 0;
function check(name, fn) {
fn();
passed += 1;
console.log(' ok ' + name);
}
/** Node version gate — ESM was unflagged in 12.17.0. */
function nodeAtLeast(major, minor) {
var parts = process.versions.node.split('.');
var m = Number(parts[0]);
var n = Number(parts[1]);
return m > major || (m === major && n >= minor);
}
console.log('bowser package smoke test on Node ' + process.versions.node);
// --- The CommonJS contract -------------------------------------------------
// `require('bowser')` must be the Bowser class itself. If it ever becomes
// `{ default, parse, getParser }`, every existing consumer breaks.
check('require("bowser") is the class, not a module namespace', function () {
var Bowser = require('bowser');
assert.strictEqual(typeof Bowser, 'function');
assert.strictEqual(typeof Bowser.getParser, 'function');
assert.strictEqual(typeof Bowser.parse, 'function');
assert.strictEqual(
Bowser.default,
undefined,
'require("bowser").default is set — the CJS interop unwrap was lost',
);
});
check('require("bowser") parses a user agent', function () {
var Bowser = require('bowser');
assert.strictEqual(Bowser.parse(UA).browser.name, 'Chrome');
assert.strictEqual(Bowser.getParser(UA).getBrowserName(), 'Chrome');
});
check('constant maps are exposed', function () {
var Bowser = require('bowser');
assert.strictEqual(typeof Bowser.BROWSER_MAP, 'object');
assert.strictEqual(typeof Bowser.ENGINE_MAP, 'object');
assert.strictEqual(typeof Bowser.OS_MAP, 'object');
assert.strictEqual(typeof Bowser.PLATFORMS_MAP, 'object');
});
// --- Legacy deep paths -----------------------------------------------------
// These resolved before the exports map existed. Adding an exports map without
// listing them turns them into ERR_PACKAGE_PATH_NOT_EXPORTED.
['bowser/es5.js', 'bowser/es5', 'bowser/bundled.js', 'bowser/bundled'].forEach(function (id) {
check('require("' + id + '") works', function () {
var B = require(id);
assert.strictEqual(typeof B, 'function');
assert.strictEqual(B.parse(UA).browser.name, 'Chrome');
});
});
// 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.
[
'bowser.js', 'constants.js', 'parser.js', 'parser-browsers.js',
'parser-engines.js', 'parser-os.js', 'parser-platforms.js', 'utils.js',
].forEach(function (file) {
var withExt = 'bowser/src/' + file;
var withoutExt = withExt.replace(/\.js$/, '');
check('resolves "' + withExt + '" and "' + withoutExt + '"', function () {
assert.ok(fs.existsSync(require.resolve(withExt)));
assert.ok(fs.existsSync(require.resolve(withoutExt)));
});
});
check('require.resolve("bowser/package.json") works', function () {
assert.ok(fs.existsSync(require.resolve('bowser/package.json')));
});
// --- The published manifest ------------------------------------------------
check('main/browser/module/types fields are unchanged', function () {
var pkg = require('bowser/package.json');
assert.strictEqual(pkg.main, 'es5.js');
assert.strictEqual(pkg.browser, 'es5.js');
assert.strictEqual(pkg.module, 'src/bowser.js');
assert.strictEqual(pkg.types, 'index.d.ts');
});
check('no "engines" field (would warn/fail installs on old Node)', function () {
var pkg = require('bowser/package.json');
assert.strictEqual(pkg.engines, undefined);
});
check('no "type" field (would reclassify es5.js as ESM)', function () {
var pkg = require('bowser/package.json');
assert.strictEqual(pkg.type, undefined);
});
check('published file list is exactly what we expect', function () {
var root = path.dirname(require.resolve('bowser/package.json'));
var actual = [];
(function walk(dir, prefix) {
fs.readdirSync(dir).forEach(function (name) {
var full = path.join(dir, name);
if (fs.statSync(full).isDirectory()) walk(full, prefix + name + '/');
else actual.push(prefix + name);
});
}(root, ''));
var expected = [
'LICENSE', 'README.md', 'bowser.mjs', 'bundled.js', 'es5.js',
'index.d.mts', 'index.d.ts', 'package.json',
'src/bowser.js', 'src/constants.js', 'src/parser-browsers.js',
'src/parser-engines.js', 'src/parser-os.js', 'src/parser-platforms.js',
'src/parser.js', 'src/utils.js',
];
assert.deepStrictEqual(actual.sort(), expected.sort());
});
// --- The UMD / CDN contract ------------------------------------------------
// Script-tag consumers get `window.bowser` (lowercase). Nothing else covers
// this path, and a renamed global fails silently at runtime.
check('es5.js sets a lowercase `bowser` global when loaded as a script', function () {
var file = require.resolve('bowser/es5.js');
var sandbox = {};
sandbox.self = sandbox;
vm.runInNewContext(fs.readFileSync(file, 'utf8'), sandbox);
assert.strictEqual(typeof sandbox.bowser, 'function', 'global `bowser` not set');
assert.strictEqual(sandbox.bowser.parse(UA).browser.name, 'Chrome');
assert.strictEqual(sandbox.Bowser, undefined, 'unexpected capitalised global');
});
check('es5.js contains no ES6 template literals', function () {
// rolldown's built-in (oxc) minifier rewrites every string literal as a
// template literal, which is a syntax error in the old browsers this bundle
// targets. Guards against the terser step being dropped from the build.
var code = fs.readFileSync(require.resolve('bowser/es5.js'), 'utf8');
assert.strictEqual(code.indexOf('`'), -1, 'es5.js contains a backtick');
});
check('es5.js keeps the copyright banner', function () {
var code = fs.readFileSync(require.resolve('bowser/es5.js'), 'utf8');
assert.ok(code.indexOf('Bowser - a browser detector') !== -1);
});
// --- The ESM contract ------------------------------------------------------
if (!nodeAtLeast(12, 17)) {
console.log(' -- skipping ESM checks (Node ' + process.versions.node + ' < 12.17)');
console.log('\n' + passed + ' checks passed');
} else {
// eslint-disable-next-line no-eval
eval('import("bowser")')
.then(function (mod) {
check('import("bowser") has a working default export', function () {
assert.strictEqual(typeof mod.default, 'function');
assert.strictEqual(mod.default.parse(UA).browser.name, 'Chrome');
});
// This is issue #511: named imports must work, and must work unbound.
check('import("bowser") has working named exports', function () {
assert.strictEqual(typeof mod.parse, 'function');
assert.strictEqual(typeof mod.getParser, 'function');
var parse = mod.parse;
var getParser = mod.getParser;
assert.strictEqual(parse(UA).browser.name, 'Chrome');
assert.strictEqual(getParser(UA).getBrowserName(), 'Chrome');
});
console.log('\n' + passed + ' checks passed');
})
.catch(function (err) {
console.error('\nESM check failed: ' + err.stack);
process.exit(1);
});
}

62
test/package/smoke.cjs Normal file
View File

@@ -0,0 +1,62 @@
/* eslint-disable */
/**
* Verifies the *packed* bowser package against the surface real consumers use.
*
* node test/package/smoke.cjs <path-to-bowser-x.y.z.tgz>
*
* The tarball is extracted into a throwaway directory as ./node_modules/bowser
* and `assertions.cjs` is run next to it, so bare specifiers such as
* `require('bowser')` and `require('bowser/es5.js')` resolve exactly as they
* would after `npm install bowser`.
*
* Run this on every Node version bowser supports — that is the point of it.
* See `.github/workflows/pull-request.yml`.
*
* Deliberately ES5-compatible CommonJS so it runs on Node 12.16.3.
*/
var cp = require('child_process');
var fs = require('fs');
var os = require('os');
var path = require('path');
var tarball = process.argv[2];
if (!tarball) {
console.error('usage: node test/package/smoke.cjs <path-to-tarball.tgz>');
console.error('');
console.error('Create one with:');
console.error(' npm version 0.0.0-ci --no-git-tag-version --allow-same-version');
console.error(' npm pack');
process.exit(1);
}
tarball = path.resolve(tarball);
if (!fs.existsSync(tarball)) {
console.error('no such tarball: ' + tarball);
process.exit(1);
}
var tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'bowser-smoke-'));
var modules = path.join(tmp, 'node_modules');
fs.mkdirSync(modules);
// `npm install <tarball>` would work too, but plain tar keeps this dependency
// free and identical across the whole Node matrix. npm tarballs always unpack
// to a single top-level `package/` directory.
cp.execFileSync('tar', ['-xzf', tarball, '-C', modules], { stdio: 'inherit' });
fs.renameSync(path.join(modules, 'package'), path.join(modules, 'bowser'));
var runner = path.join(tmp, 'assertions.cjs');
fs.writeFileSync(runner, fs.readFileSync(path.join(__dirname, 'assertions.cjs')));
var result = cp.spawnSync(process.execPath, [runner], { cwd: tmp, stdio: 'inherit' });
try {
// fs.rmSync landed in Node 14.14; rimraf-by-hand keeps Node 12 happy.
cp.execFileSync('rm', ['-rf', tmp]);
} catch (e) { /* best effort */ }
if (result.status !== 0) {
console.error('\npackage smoke test FAILED on Node ' + process.versions.node);
process.exit(result.status || 1);
}

View File

@@ -1,42 +1,104 @@
import { defineConfig } from 'tsdown';
import babel from '@rolldown/plugin-babel'
import babel from '@rolldown/plugin-babel';
import { minify } from 'terser';
const banner = `Bowser - a browser detector
https://github.com/lancedikson/bowser
MIT License | (c) Dustin Diaz 2012-2015
MIT License | (c) Denis Demchenko 2015-2026`;
const banner = `/*!
* Bowser - a browser detector
* https://github.com/lancedikson/bowser
* MIT License | (c) Dustin Diaz 2012-2015
* MIT License | (c) Denis Demchenko 2015-2026
*/`;
/**
* The browser targets the UMD bundles are transpiled down to. Unchanged from
* the webpack build these bundles replaced — the published `es5.js` is relied
* on by script-tag and CDN consumers on very old browsers.
*/
const legacyTargets = {
ie: '8',
browsers: '>2%',
};
/**
* `useBuiltIns: false` for `es5.js` (syntax transpilation only) and `'entry'`
* for `bundled.js`, which expands the `core-js/stable` import in its entry.
*/
const legacyBabel = (useBuiltIns: false | 'entry') => babel({
presets: [['@babel/preset-env', {
// Let rolldown emit the UMD wrapper; babel only lowers syntax here.
modules: false,
loose: true,
useBuiltIns,
...(useBuiltIns ? { corejs: '3' } : {}),
targets: legacyTargets,
}]],
});
/**
* Minifies the UMD chunks with terser instead of rolldown's built-in (oxc)
* minifier.
*
* This is not a preference. oxc's minifier prints every string literal as a
* template literal and refuses any `compress.target` below `es2015`, so it
* cannot emit ES5 — it would silently undo babel's lowering and break the very
* old browsers `es5.js` exists to serve. terser (`ecma: 5`) is also what the
* webpack 4 build this replaced used, via terser-webpack-plugin.
*/
const terser = () => ({
name: 'bowser:terser',
async renderChunk(code: string) {
const result = await minify(code, {
ecma: 5,
// IE 8: reserved words as property names must stay quoted.
ie8: true,
safari10: true,
format: {
// Keep the `/*!` banner.
comments: /^!/,
},
});
return { code: result.code as string };
},
});
const umd = (name: string, entry: string, useBuiltIns: false | 'entry') => ({
entry: { [name]: entry },
format: ['umd' as const],
// Lowercase, matching the global the webpack build published. Renaming this
// to `Bowser` would break every `<script src=".../bowser/es5.js">` consumer.
globalName: 'bowser',
outputOptions: {
// `module.exports = Bowser` rather than `{ default: Bowser }`.
exports: 'default' as const,
// Default would be `<name>.umd.js`; these files are published paths.
entryFileNames: '[name].js',
},
outDir: '.',
platform: 'browser' as const,
plugins: [legacyBabel(useBuiltIns), 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,
banner,
dts: false,
// outDir is the repo root — never let tsdown clean it.
clean: false,
});
export default defineConfig([
umd('es5', 'build/entries/es5.js', false),
umd('bundled', 'build/entries/bundled.js', 'entry'),
{
entry: ['src/bowser.js'],
format: ['umd'],
plugins:[
babel({
presets: [['@babel/preset-env', {
useBuiltIns: 'usage',
corejs: '3',
modules: 'cjs',
loose: true,
targets: {
ie: '8',
browsers: '>2%'
}
}]],
"plugins": [
"add-module-exports"
]
})
],
outDir: 'dist/umd', // universal module definition
globalName: 'Bowser',
dts: false,
clean: true,
},
{
entry: ['src/bowser.js'],
// 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.
entry: { bowser: 'src/bowser.js' },
format: ['esm'],
outDir: 'dist/esm', // es module
outDir: '.',
outExtensions: () => ({ js: '.mjs' }),
platform: 'browser',
banner,
dts: false,
clean: true
clean: false,
},
])
]);