1
0
mirror of https://github.com/lancedikson/bowser synced 2026-09-23 20:44:24 +00:00

Dual packaging, without breaking existing consumers (#628)

Co-authored-by: Yasumasa Ashida <ys.ashida@gmail.com>
This commit is contained in:
Naor Peled
2026-08-30 00:10:11 +03:00
committed by GitHub
parent a37b8be8a7
commit a88622557d
20 changed files with 10542 additions and 14655 deletions

View File

@@ -1,14 +0,0 @@
parser: babel-eslint
extends: airbnb-base
rules:
no-underscore-dangle: 0
no-void: 0
import/extensions:
- 'error'
- 'ignorePackages'
- {js: 'always'}
import/prefer-default-export: 1
plugins:
- ava
- import

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,128 @@ 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
# Every job only reads the repo and moves artifacts between jobs; none of them
# needs write access to anything.
permissions:
contents: read
lint:
name: "ESLint"
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"
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
mkdir -p tarball
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;

36
eslint.config.mjs Normal file
View File

@@ -0,0 +1,36 @@
import js from '@eslint/js';
import importPlugin from 'eslint-plugin-import';
import { configs, plugins } from 'eslint-config-airbnb-extended';
export default [
js.configs.recommended,
importPlugin.flatConfigs.recommended,
// Stylistic plugin
plugins.stylistic,
// Import X plugin
plugins.importX,
// Airbnb base recommended config
...configs.base.recommended,
{
files: ['**/*.{js,mjs,cjs}'],
languageOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
},
rules: {
'no-underscore-dangle': 0,
'no-void': 0,
'import/prefer-default-export': 1,
'import-x/extensions': [
'error',
'always',
{
ignorePackages: true,
js: 'always',
},
],
'@stylistic/max-len': 0,
'@stylistic/arrow-parens': 0,
},
},
];

33
index.d.mts Normal file
View File

@@ -0,0 +1,33 @@
// 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;
// BROWSER_MAP / ENGINE_MAP / OS_MAP / PLATFORMS_MAP are *not* named exports of
// bowser.mjs — they exist only as static getters on the class. Declaring them
// here would let TypeScript accept `import { BROWSER_MAP } from 'bowser'`,
// which throws at runtime. Reach them via the default export instead.
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;

14506
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -2,10 +2,12 @@
"name": "bowser",
"description": "Lightweight browser detector",
"files": [
"src/",
"src",
"es5.js",
"bundled.js",
"index.d.ts"
"bowser.mjs",
"index.d.ts",
"index.d.mts"
],
"keywords": [
"browser",
@@ -33,41 +35,85 @@
"browser": "es5.js",
"module": "src/bowser.js",
"types": "index.d.ts",
"exports": {
".": {
"import": {
"types": "./index.d.mts",
"default": "./bowser.mjs"
},
"require": {
"types": "./index.d.ts",
"default": "./es5.js"
},
"default": "./es5.js"
},
"./es5.js": "./es5.js",
"./es5": "./es5.js",
"./bundled.js": "./bundled.js",
"./bundled": "./bundled.js",
"./bowser.mjs": {
"types": "./index.d.mts",
"default": "./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": {
"@babel/cli": "^7.11.6",
"@babel/core": "^7.8.0",
"@babel/polyfill": "^7.8.3",
"@babel/preset-env": "^7.8.2",
"@babel/register": "^7.8.3",
"@arethetypeswrong/cli": "^0.18.5",
"@babel/cli": "^7.29.7",
"@babel/core": "^7.29.7",
"@babel/preset-env": "^7.29.7",
"@babel/register": "^7.29.7",
"@eslint/js": "^10.0.1",
"@rolldown/plugin-babel": "0.2.3",
"ava": "^3.0.0",
"babel-eslint": "^10.0.3",
"babel-loader": "^8.0.6",
"babel-plugin-add-module-exports": "^1.0.2",
"babel-plugin-istanbul": "^6.0.0",
"compression-webpack-plugin": "^4.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",
"eslint": "^6.5.1",
"eslint-config-airbnb-base": "^13.2.0",
"eslint-plugin-ava": "^10.0.0",
"eslint-plugin-import": "^2.18.2",
"eslint": "^10.8.0",
"eslint-config-airbnb-extended": "^3.1.0",
"eslint-plugin-ava": "^17.0.1",
"eslint-plugin-import": "^2.32.0",
"gh-pages": "^3.0.0",
"jsdoc": "^3.6.3",
"nyc": "^15.0.0",
"sinon": "^9.0.0",
"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",
"webpack": "^4.41.0",
"webpack-bundle-analyzer": "^3.5.2",
"webpack-cli": "^3.3.9",
"tsdown": "^0.22.14",
"typescript": "^5.9.3",
"yamljs": "^0.3.0"
},
"ava": {
"require": [
"@babel/register"
],
"files": [
"test/**/*.js",
"!test/package/**"
]
},
"bugs": {
@@ -77,16 +123,18 @@
"test": "test"
},
"scripts": {
"build": "webpack --config webpack.config.js",
"generate-and-deploy-docs": "npm run generate-docs && gh-pages --dist docs --dest docs",
"watch": "webpack --watch --config webpack.config.js",
"build": "tsdown",
"generate-and-deploy-docs": "pnpm generate-docs && gh-pages --dist docs --dest docs",
"watch": "tsdown --watch",
"lint:check": "eslint ./src",
"lint:fix": "eslint --fix ./src",
"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"
}

9770
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

3
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,3 @@
allowBuilds:
core-js: false
unrs-resolver: false

View File

@@ -91,3 +91,4 @@ class Bowser {
}
export default Bowser;
export const { parse, getParser } = Bowser;

View File

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

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);
});
}

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

@@ -0,0 +1,100 @@
/* 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 zlib = require('zlib');
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);
/**
* Extracts an npm tarball using only Node built-ins, so the test needs no
* external `tar`/`npm` process and runs identically on every OS and every
* Node version in the support matrix.
*
* npm tarballs are gzipped ustar archives of regular files (npm rejects
* anything else at publish time), which is the only shape handled here:
* pax/global extended headers and directory entries are skipped, and parent
* directories are created per file instead.
*/
function extractNpmTarball(tgz, dest) {
var buf = zlib.gunzipSync(fs.readFileSync(tgz));
var offset = 0;
while (offset + 512 <= buf.length) {
var header = buf.slice(offset, offset + 512);
offset += 512;
if (header[0] === 0) break; // zero block: end of archive
var name = header.slice(0, 100).toString('utf8').replace(/\0[\s\S]*$/, '');
var prefix = header.slice(345, 500).toString('utf8').replace(/\0[\s\S]*$/, '');
if (prefix) name = prefix + '/' + name;
var size = parseInt(header.slice(124, 136).toString('utf8'), 8) || 0;
var type = header[156];
if (type === 48 /* '0' */ || type === 0) {
var target = path.join(dest, name);
if (target.indexOf(dest + path.sep) !== 0) {
throw new Error('tarball entry escapes destination: ' + name);
}
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.writeFileSync(target, buf.slice(offset, offset + size));
}
offset += Math.ceil(size / 512) * 512;
}
}
// npm tarballs always unpack to a single top-level `package/` directory.
extractNpmTarball(tarball, modules);
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; recursive fs.rmdirSync (12.10+) covers the
// bottom of the matrix. Both work on Windows, unlike shelling out to `rm`.
if (fs.rmSync) {
fs.rmSync(tmp, { recursive: true, force: true });
} else {
fs.rmdirSync(tmp, { recursive: true });
}
} catch (e) { /* best effort */ }
if (result.status !== 0) {
console.error('\npackage smoke test FAILED on Node ' + process.versions.node);
process.exit(result.status || 1);
}

107
tsdown.config.ts Normal file
View File

@@ -0,0 +1,107 @@
import { defineConfig } from 'tsdown';
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
*/`;
/**
* 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, chunk: { fileName: 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: /^!/,
},
});
if (typeof result.code !== 'string') {
throw new Error(`terser produced no output for ${chunk.fileName}`);
}
return { code: result.code };
},
});
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'),
{
// 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: '.',
outExtensions: () => ({ js: '.mjs' }),
platform: 'browser',
banner,
dts: false,
clean: false,
},
]);

View File

@@ -1,49 +0,0 @@
const path = require('path');
const CompressionPlugin = require('compression-webpack-plugin');
const webpack = require('webpack');
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`;
module.exports = {
plugins: [
new CompressionPlugin(),
new webpack.BannerPlugin({ banner }),
],
mode: 'production', // "production" | "development" | "none"
// Chosen mode tells webpack to use its built-in optimizations accordingly.
entry: {
bundled: ['@babel/polyfill', './src/bowser.js'],
es5: './src/bowser.js',
}, // string | object | array
// defaults to ./src
// Here the application starts executing
// and webpack starts bundling
output: {
// options related to how webpack emits results
path: path.resolve(__dirname, './'), // string
// the target directory for all output files
// must be an absolute path (use the Node.js path module)
filename: '[name].js', // string
// the filename template for entry chunks
library: 'bowser',
libraryTarget: 'umd', // universal module definition
// the type of the exported library
globalObject: 'this',
},
module: {
// configuration regarding modules
rules: [
// rules for modules (configure loaders, parser options, etc.)
{
test: /\.js$/,
exclude: /(node_modules|bower_components)/,
use: {
loader: 'babel-loader',
},
},
],
},
};