1
0
mirror of https://github.com/lancedikson/bowser synced 2026-10-09 12:22:12 +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: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy:
matrix:
node-version: [12.16.3]
steps: steps:
- name: Get branch name (merge) - name: Get branch name
if: github.event_name != 'pull_request'
shell: bash shell: bash
run: echo "BRANCH_NAME=$(echo ${GITHUB_REF#refs/heads/} | tr / -)" >> $GITHUB_ENV 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 - 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 uses: actions/setup-node@v4
with: with:
node-version: ${{ matrix.node-version }} node-version: "24"
- run: npm i -g nyc@15 cache: pnpm
- run: npm ci
- run: npm run build - run: pnpm install --frozen-lockfile
- run: nyc npm test && nyc report --reporter=text-lcov | ./node_modules/coveralls/bin/coveralls.js - 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: env:
COVERALLS_SERVICE_NAME: GithubActions COVERALLS_SERVICE_NAME: GithubActions
COVERALLS_REPO_TOKEN: ${{ secrets.COVERALLS_REPO_TOKEN }} COVERALLS_REPO_TOKEN: ${{ secrets.COVERALLS_REPO_TOKEN }}

View File

@@ -21,13 +21,17 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - 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 - uses: actions/setup-node@v4
with: with:
node-version: 12.16.3 node-version: "24"
- uses: actions/cache@v4 cache: pnpm
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ hashFiles('**/package.json') }}
# Store the release version (from release tag or manual input) # Store the release version (from release tag or manual input)
- name: Set release version - name: Set release version
run: | run: |
@@ -38,14 +42,33 @@ jobs:
echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
echo "Release triggered from tag: ${GITHUB_REF#refs/*/}" echo "Release triggered from tag: ${GITHUB_REF#refs/*/}"
fi fi
- run: npm ci
- run: npm version $RELEASE_VERSION --no-git-tag-version - run: pnpm install --frozen-lockfile
- run: npm run build - 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 - name: Upload build artifacts
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: dist name: dist
path: . path: |
package.json
README.md
LICENSE
es5.js
bundled.js
bowser.mjs
index.d.ts
index.d.mts
src
publish: publish:
needs: build needs: build
@@ -61,4 +84,4 @@ jobs:
- name: Update npm to latest (trusted publishing requires npm >= 11.5.1) - name: Update npm to latest (trusted publishing requires npm >= 11.5.1)
run: npm install -g npm@latest run: npm install -g npm@latest
- name: Publish to npm - 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: pull_request:
types: [opened, reopened, synchronize] types: [opened, reopened, synchronize]
jobs: # Every job only reads the repo and moves artifacts between jobs; none of them
test: # needs write access to anything.
runs-on: ubuntu-latest permissions:
strategy: contents: read
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
lint: env:
name: "ESLint" # 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 runs-on: ubuntu-latest
steps: steps:
- name: Checkout latest code - name: Checkout latest code
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
ref: ${{ github.event.pull_request.head.sha }} ref: ${{ github.event.pull_request.head.sha }}
- name: Set up pnpm
uses: pnpm/action-setup@v4
- name: Set up node - name: Set up node
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: "16" node-version: ${{ env.BUILD_NODE_VERSION }}
cache: pnpm
- name: Install dependencies - name: Install dependencies
run: npm ci run: pnpm install --frozen-lockfile
- name: Run ESLint - 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/ .idea/
.nyc_output .nyc_output
coverage coverage
dist
bundled.js* # Build output (see tsdown.config.ts). Anchored to the repo root on purpose —
es5.js* # 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 # 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 ```javascript
const Bowser = require("bowser"); // CommonJS 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) 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. 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')`. 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. 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", "name": "bowser",
"description": "Lightweight browser detector", "description": "Lightweight browser detector",
"files": [ "files": [
"src/", "src",
"es5.js", "es5.js",
"bundled.js", "bundled.js",
"index.d.ts" "bowser.mjs",
"index.d.ts",
"index.d.mts"
], ],
"keywords": [ "keywords": [
"browser", "browser",
@@ -33,41 +35,85 @@
"browser": "es5.js", "browser": "es5.js",
"module": "src/bowser.js", "module": "src/bowser.js",
"types": "index.d.ts", "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": { "repository": {
"type": "git", "type": "git",
"url": "git+https://github.com/bowser-js/bowser.git" "url": "git+https://github.com/bowser-js/bowser.git"
}, },
"devDependencies": { "devDependencies": {
"@babel/cli": "^7.11.6", "@arethetypeswrong/cli": "^0.18.5",
"@babel/core": "^7.8.0", "@babel/cli": "^7.29.7",
"@babel/polyfill": "^7.8.3", "@babel/core": "^7.29.7",
"@babel/preset-env": "^7.8.2", "@babel/preset-env": "^7.29.7",
"@babel/register": "^7.8.3", "@babel/register": "^7.29.7",
"@eslint/js": "^10.0.1",
"@rolldown/plugin-babel": "0.2.3",
"ava": "^3.0.0", "ava": "^3.0.0",
"babel-eslint": "^10.0.3", "babel-plugin-add-module-exports": "^1.0.4",
"babel-loader": "^8.0.6", "babel-plugin-istanbul": "^8.0.0",
"babel-plugin-add-module-exports": "^1.0.2", "core-js": "^3.49.0",
"babel-plugin-istanbul": "^6.0.0",
"compression-webpack-plugin": "^4.0.0",
"coveralls": "^3.0.6", "coveralls": "^3.0.6",
"docdash": "^1.1.1", "docdash": "^1.1.1",
"eslint": "^6.5.1", "eslint": "^10.8.0",
"eslint-config-airbnb-base": "^13.2.0", "eslint-config-airbnb-extended": "^3.1.0",
"eslint-plugin-ava": "^10.0.0", "eslint-plugin-ava": "^17.0.1",
"eslint-plugin-import": "^2.18.2", "eslint-plugin-import": "^2.32.0",
"gh-pages": "^3.0.0", "gh-pages": "^3.0.0",
"jsdoc": "^3.6.3", "jsdoc": "^3.6.3",
"nyc": "^15.0.0", "nyc": "^18.0.0",
"sinon": "^9.0.0", "publint": "^0.3.22",
"regenerator-runtime": "^0.14.1",
"sinon": "^22.0.0",
"terser": "^5.49.0",
"testem": "^3.0.0", "testem": "^3.0.0",
"webpack": "^4.41.0", "tsdown": "^0.22.14",
"webpack-bundle-analyzer": "^3.5.2", "typescript": "^5.9.3",
"webpack-cli": "^3.3.9",
"yamljs": "^0.3.0" "yamljs": "^0.3.0"
}, },
"ava": { "ava": {
"require": [ "require": [
"@babel/register" "@babel/register"
],
"files": [
"test/**/*.js",
"!test/package/**"
] ]
}, },
"bugs": { "bugs": {
@@ -77,16 +123,18 @@
"test": "test" "test": "test"
}, },
"scripts": { "scripts": {
"build": "webpack --config webpack.config.js", "build": "tsdown",
"generate-and-deploy-docs": "npm run generate-docs && gh-pages --dist docs --dest docs", "generate-and-deploy-docs": "pnpm generate-docs && gh-pages --dist docs --dest docs",
"watch": "webpack --watch --config webpack.config.js", "watch": "tsdown --watch",
"lint:check": "eslint ./src", "lint:check": "eslint ./src",
"lint:fix": "eslint --fix ./src", "lint:fix": "eslint --fix ./src",
"testem": "testem", "testem": "testem",
"test": "nyc --reporter=html --reporter=text ava", "test": "nyc --reporter=html --reporter=text ava",
"test:watch": "ava --watch", "test:watch": "ava --watch",
"test:package": "node test/package/smoke.cjs",
"coverage": "nyc report --reporter=text-lcov | coveralls", "coverage": "nyc report --reporter=text-lcov | coveralls",
"generate-docs": "jsdoc -c jsdoc.json" "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 default Bowser;
export const { parse, getParser } = Bowser;

View File

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