# How to package one utility for Node, Deno, Bun, and browsers

> Publish one zero-dependency function so it imports cleanly in Node, Deno, Bun, and a bundler, and prove it against the packed tarball rather than the source tree.

Source: https://voxgig.com/howto/package-a-utility-for-node-deno-and-browsers

- Audience: library-maintainer
- Level: intermediate
- Languages: typescript, javascript
- Verified: 2026-10-15
- Published: 2026-10-15

## Short answer

Write the function as one ES module that imports nothing and reads `globalThis.crypto` at call time. Give `package.json` an `exports` map with `types` first and `default` last, and a Node floor where `require` loads ES modules. Then run `npm pack`, check the tarball with publint and are-the-types-wrong, and import the unpacked package from Node, Deno, Bun, and an esbuild browser bundle.

---
## You will need

Node 20.19 or later on the 20 line, or 22.12 or later, the releases where `require()` loads an ES
module without a flag.[^1] Verified 2026-10-15 against Node 22.22.0, publint 0.3.25,
are-the-types-wrong 0.18.5, esbuild 0.28.2 and TypeScript 6.0.3, all pinned in the sample's
`package.json`.

## Approaches compared

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [A single hand-written .mjs file with no build step](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Modules) | One small function whose source already runs in every target | You write the declaration file by hand, and only a type check compares it with the code | The source is TypeScript, or the utility grows past a file or two |
| [dnt to generate a Node package from Deno source](https://jsr.io/@deno/dnt) | The code lives in a Deno repository and npm is the second registry | A build that runs Deno, shims Deno globals for Node, and emits output you then inspect | You do not otherwise use Deno |
| [Dual ESM and CommonJS built with tsup or unbuild](https://tsup.egoist.dev/) | CommonJS callers on Node versions without `require(esm)` must load you | Two artifacts and two declaration files, and two module instances when both load in one process | Your Node floor already loads ES modules through `require` |
| [ESM only with package.json exports conditions](https://nodejs.org/api/packages.html#conditional-exports) | A Node floor of 20.19 or 22.12, and TypeScript or bundler consumers | CommonJS callers on older Node versions cannot `require` you at all | Callers you must keep run Node 18 or an older bundler |

The single file and ESM only are one choice made at two sizes: both ship ES modules alone, and
the first skips the compiler. Dual output reaches CommonJS callers on old Node versions and pays
for it twice, in artifacts and in the dual-package hazard measured below. dnt suits a Deno-first
codebase and adds a build for everyone else.

## Keep the entry free of Node

The module imports nothing. Randomness comes from the Web Cryptography API, which Node, Deno, Bun,
and browsers all expose on `globalThis`, and the function looks it up when an id is made.

```js title="pkg/rid.mjs"
function webCryptoFill(bytes) {
  const crypto = globalThis.crypto
  if (typeof crypto?.getRandomValues !== 'function') {
    throw new RidError('globalThis.crypto.getRandomValues is not available in this runtime')
  }
  crypto.getRandomValues(bytes)
}
```

[`getRandomValues`](https://developer.mozilla.org/en-US/docs/Web/API/Crypto/getRandomValues)
fills the array in place. Importing `node:crypto` instead ties the module to runtimes that provide
Node's built-ins, so a browser build stops, as `smoke/node-only.mjs` shows:

```bash
npx esbuild smoke/node-only.mjs --bundle --platform=browser --format=esm --outfile=out/node-only.mjs
```

```text output
✘ [ERROR] Could not resolve "node:crypto"

    smoke/node-only.mjs:2:31:
      2 │ import { randomFillSync } from 'node:crypto'
        ╵                                ~~~~~~~~~~~~~
```

Reading `process.env` has the same problem. A test in the sample reads `rid.mjs` and fails on
`import`, `require(`, `process`, `Buffer` or `node:`, so the portability rule is checked rather
than remembered.

## Order the exports map

Conditions are matched in object order, first match wins.
[Node's documentation](https://nodejs.org/api/packages.html#community-conditions-definitions) says
`types` should always come first and `default` last.

```json title="pkg/package.json"
  "type": "module",
  "exports": {
    ".": {
      "types": "./rid.d.mts",
      "default": "./rid.mjs"
    },
    "./package.json": "./package.json"
  },
  "files": ["rid.mjs", "rid.d.mts"],
  "sideEffects": false,
  "engines": {
    "node": "^20.19.0 || >=22.12.0"
  }
```

There is no `require` condition, because `require` resolves through `default` to the ES module.
The `engines` range is written as two ranges on purpose. A plain `>=20.19` admits Node 22.0 to
22.11, which still refuse `require(esm)` without a flag. A dual package nests `types` inside each
branch, `import` pointing at `.d.mts` and `require` at `.d.cts`, as `variants/dual` does.

## Pack it and lint the tarball

Check what ships, not what sits in the directory. `pack.mjs` runs `npm pack --json`, lists the
tarball, and unpacks it into `smoke/node_modules` the way an install would.

```bash
node pack.mjs
```

```text output
out/meterco-rid-1.0.0.tgz
  package.json
  rid.d.mts
  rid.mjs
```

```bash
npx publint out/meterco-rid-1.0.0.tgz
```

```text output
Running publint v0.3.25 for out/meterco-rid-1.0.0.tgz...
Unpacking tarball...
Linting...
All good!
```

[are-the-types-wrong](https://arethetypeswrong.github.io/) resolves the package the way
TypeScript does under each module setting. The `esm-only` profile reports, without failing on,
the two modes an ESM-only package does not target.

```bash
npx attw out/meterco-rid-1.0.0.tgz --profile esm-only --no-color --no-summary
```

```text output
┌───────────────────┬────────────────────────────────────────┬────────────────────────────┐
│                   │ "meterco-rid"                          │ "meterco-rid/package.json" │
├───────────────────┼────────────────────────────────────────┼────────────────────────────┤
│ node16 (from ESM) │ 🟢 (ESM)                               │ 🟢 (JSON)                  │
├───────────────────┼────────────────────────────────────────┼────────────────────────────┤
│ bundler           │ 🟢                                     │ 🟢 (JSON)                  │
├───────────────────┼────────────────────────────────────────┼────────────────────────────┤
│ node10            │ (ignored) 💀 Resolution failed         │ (ignored) 🟢 (JSON)        │
├───────────────────┼────────────────────────────────────────┼────────────────────────────┤
│ node16 (from CJS) │ (ignored) ⚠️ ESM (dynamic import only) │ (ignored) 🟢 (JSON)        │
└───────────────────┴────────────────────────────────────────┴────────────────────────────┘
```

`node10` predates `exports` entirely. The `node16 (from CJS)` warning describes TypeScript's
`node16` mode, which still treats `require` of an ES module as an error; the
[TypeScript 5.8 release](https://devblogs.microsoft.com/typescript/announcing-typescript-5-8/)
allows it under `--module nodenext`.[^2]

## Check it worked

The smoke test is four lines, and each runtime must print the same id from the same fixed bytes.

```js title="smoke/smoke.mjs"
import { rid } from 'meterco-rid'
const id = rid(8, (bytes) => bytes.fill(200))
if (id !== '88888888' || rid().length !== 21) throw new Error(`unexpected id ${id}`)
console.log(`import: ${id} and a random id of ${rid().length} characters`)
```

```bash
node smoke/smoke.mjs && node smoke/smoke.cjs
```

```text output
import: 88888888 and a random id of 21 characters
require: 88888888 and a random id of 21 characters
```

The browser build goes through [esbuild with `platform=browser`](https://esbuild.github.io/api/#platform),
which turns on the `browser` condition and refuses to resolve a `node:` import. The runner has no
browser, so Node runs the bundle; what the run proves is that the bundle resolves and works without
Node's built-ins.

```bash
npx esbuild smoke/smoke.mjs --bundle --platform=browser --format=esm --log-level=warning --outfile=out/smoke.browser.mjs && node out/smoke.browser.mjs
```

```text output
import: 88888888 and a random id of 21 characters
```

Deno and Bun are not on the runner that re-checks these pages, so they are not captured here.
Run by hand in the Linux x64 container that verified this page, `deno run smoke/smoke.mjs` under
Deno 2.9.6 and `bun smoke/smoke.mjs` under Bun 1.4.2 both printed the same `import:` line, and
both also ran `smoke.cjs`. The unit tests cover the
alphabet, the size limits, a runtime without the Web Cryptography API, and the portability rule.

```bash
node --test test/rid.test.mjs
```

```text output
1..5
# tests 5
# suites 0
# pass 5
# fail 0
```

## When it goes wrong

TypeScript users on `moduleResolution: bundler` see an old signature. The package lists one
`types` condition for both `import` and `require`, and it points at `.d.cts` declarations nobody
updated. `variants/types-first` is that package.

```json title="variants/types-first/package.json"
  "exports": {
    ".": {
      "types": "./rid.d.cts",
      "import": "./rid.mjs",
      "require": "./rid.cjs"
    }
  },
```

```bash
node pack.mjs variants/types-first variants/dual
npx attw out/meterco-rid-stale-1.0.0.tgz --no-color --no-summary
```

```text output
┌───────────────────┬────────────────────────┐
│                   │ "meterco-rid-stale"    │
├───────────────────┼────────────────────────┤
│ node10            │ 💀 Resolution failed   │
├───────────────────┼────────────────────────┤
│ node16 (from CJS) │ 🟢 (CJS)               │
├───────────────────┼────────────────────────┤
│ node16 (from ESM) │ 🎭 Masquerading as CJS │
├───────────────────┼────────────────────────┤
│ bundler           │ 🟢                     │
└───────────────────┴────────────────────────┘
```

The `bundler` row is green, which is why the failure is silent for those users. TypeScript reads
`rid.d.cts` and rejects a call the JavaScript accepts:

```bash
npx tsc -p smoke
```

```text output
smoke/types.ts(7,51): error TS2554: Expected 0-1 arguments, but got 2.
```

Nest `types` inside each of `import` and `require`, and generate both declaration files from the
same source, or ship one.

An error from your package fails an `instanceof` check in the caller. In `hazard.mjs` the
application imports the dual package, a `createRequire` call stands in for a CommonJS dependency
that requires it, and Node loads two separate copies.

```bash
node smoke/hazard.mjs
```

```text output
meterco-rid-dual
  same RidError class for import and require: false
  error thrown via require is instanceof the imported RidError: false
meterco-rid
  same RidError class for import and require: true
  error thrown via require is instanceof the imported RidError: true
```

The ESM-only package gives one instance to both, because `require(esm)` returns the module that
`import` loaded. Run by hand in the same container, Deno 2.9.6 (with `--allow-read --allow-env`)
and Bun 1.4.2 printed the same six lines. The
[documentation for esbuild](https://esbuild.github.io/api/#conditions) names the same failure in
bundles the dual package hazard.[^3]

## When not to do this

Do not go ESM only while your users run Node 18 or need `require` on Node 22.0 to 22.11. Ship dual
output until your floor moves, and keep module state and `instanceof` checks out of the API, so
two copies cost size and nothing else. Match errors by `name` or a `code` property instead.

Do not hand-write declarations for a utility past a single file. Generate them from TypeScript
source; the hand-written `.d.mts` here works because the type check in `smoke/` compares it with a
real call.

Do not add dnt to a codebase that is not already Deno-first. It earns its build step when Deno is
where you work, and adds a second build system when it is not.

Do not trust the source directory. `files`, `exports` and the declaration paths only meet in the
tarball, so run every check on the output of `npm pack`.

## Related how-tos

- [Ship a changelog inside your published package](/howto/ship-a-changelog-inside-the-package)

- [Accept optional and reordered function arguments](/howto/normalise-optional-function-arguments)

- [Choose a release automation tool for an SDK repository](/howto/choose-a-release-automation-tool-for-an-sdk)

## Last verified

Verified 2026-10-15 against Node 22.22.0 and npm 10.9.4, with publint 0.3.25, are-the-types-wrong
0.18.5, esbuild 0.28.2 and TypeScript 6.0.3. Every output block is what the preceding command
printed. Deno 2.9.6 and Bun 1.4.2 were run by hand in the same Linux x64 container, outside the
captured commands, and their results are described in prose.

[^1]: Node's [modules documentation](https://nodejs.org/api/modules.html) keeps the whole history
in a table. The feature arrived behind a flag in 22.0.0 and 20.17.0, lost the flag in 22.12.0 and
20.19.0, stopped printing its experimental warning in 22.13.0, and stopped being experimental in
25.4.0. Node 20 skipped a step: 20.19.0 lost the flag and the warning in the same release.

[^2]: The tool's resolution runs on its own copy of TypeScript.
[The published manifest of `@arethetypeswrong/core` 0.18.5](https://registry.npmjs.org/@arethetypeswrong/core/0.18.5)
pins it at `5.6.1-rc`, a release candidate from before TypeScript learned that Node can `require`
an ES module. The checker is therefore exactly as strict as the compiler it ships with.

[^3]: The [esbuild page](https://esbuild.github.io/api/#conditions) that names the hazard goes on to
say that Node deliberately does not implement loading ESM through `require`. Node's own
documentation records the flag for that loading being removed in 22.12.0. Documentation about
another project's behavior ages at that project's pace, which is the case for linking the project's
own page.