Skip to content

Using @johnhenry packages in the browser

Most @johnhenry packages are plain ES modules, so a page can load them from a CDN with no bundler and no build step. Where a package runs in a browser, the install block on its docs page has a CDN tab next to npm (and JSR, for the packages published there). Those snippets are generated from the published npm packages, pinned to the current version, and each one was loaded in headless Chromium before it appeared here. A snippet that failed that check is left out.

  • Browser-ready: every library entry point loads in a browser. Any CLI that ships with the package is still Node.
  • Partial: some entry points load in a browser and others need Node. The package’s install block names both.
  • Node only: the package needs Node built-ins (node:http, node:fs, child_process) or a native addon.

65 packages: 47 browser-ready, 11 partial (some entry points only), 7 Node only. Versions as of 2026-10-07.

PackagenpmJSRBrowserCDN snippetWhy
@johnhenry/a2a-query0.0.0—Browser-readyimport map + esm.shLibrary entries (., ./react, ./testing) are plain ESM with no Node built-ins; @a2a-js/sdk comes in through the import map. Only the a2a-query-codegen CLI is Node.
@johnhenry/a2a-query-tanstack0.0.0—Browser-readyimport map + esm.shReact hooks over a2a-query; react and @tanstack/react-query come in through the import map (react via jsDelivr's /+esm build, since it ships CommonJS).
@johnhenry/acp-query0.0.2—Browser-readyimport map + esm.shPlain ESM with no Node built-ins; @agentclientprotocol/sdk comes in through the import map.
@johnhenry/agent-query-core0.1.0—Browser-readyimport map + esm.shPlain ESM, no dependencies; ./react needs react.
@johnhenry/mcp-gate0.4.0—Partialimport map + esm.shThe package has a "browser" export condition whose build carries only the policy helpers (compilePolicy, policyListFilter, redact, validateGateConfig). createGate, the proxy itself, and the mcp-gate CLI are Node.
@johnhenry/mcp-query0.2.1—Partialimport map + esm.shEvery entry loads in a browser except ./transports, the stdio transport (node:process, node:stream, cross-spawn). ./otel loads from esm.sh but not raw from jsDelivr: @opentelemetry/api's ESM build uses extensionless relative imports.
@johnhenry/mcp-query-tanstack0.0.0—Browser-readyimport map + esm.shReact hooks over mcp-query; the MCP client SDK, react and @tanstack/react-query come in through the import map.
@johnhenry/aimatey-backend0.5.0—Browser-readyimport map + esm.shPlain ESM, every provider subpath loads in a browser. Calling a remote provider from a page is still subject to that provider's CORS policy and puts your API key in the page.
@johnhenry/aimatey-core0.6.0—Browser-readyimport map + esm.shPlain ESM (an ESM and a CJS build); its three @johnhenry/aimatey-* dependencies come in through the import map.
@johnhenry/aimatey-frontend0.4.0—Browser-readyimport map + esm.shPlain ESM, every adapter subpath loads in a browser.
@johnhenry/aimatey-middleware0.3.0—Browser-readyimport map + esm.shPlain ESM. ./opentelemetry imports the @opentelemetry/* peers lazily, so map them only if you use it.
@johnhenry/aimatey-middleware-andbox0.0.3—Browser-readyimport map + esm.shPlain ESM, no Node built-ins. @johnhenry/andbox is a peer you add yourself.
@johnhenry/andbox0.0.6—Browser-readyimport map + esm.shPlain ESM, no dependencies, no Node built-ins.
@johnhenry/apple-foundation-models0.0.0—Node only—A Node bridge to a Swift helper built at install time: child_process, net, fs, readline. macOS only (`"os": ["darwin"]`).
@johnhenry/browsermesh-kernel0.2.0—Browser-readyimport map + esm.shPlain ESM, no dependencies.
@johnhenry/browsermesh-netway0.0.1—Browser-readyimport map + esm.shPlain ESM, no dependencies.
@johnhenry/browsermesh-primitives0.2.0—Browser-readyimport map + esm.shPlain ESM, no dependencies.
@johnhenry/canvas-fx0.0.1—Browser-readyimport map + esm.shPlain ESM custom elements with no dependencies. global.mjs registers every element.
@johnhenry/css-signals0.0.0—Browser-readyimport map + esm.shPlain ESM and CSS files, no dependencies.
@johnhenry/data-plot0.0.0—Browser-readyimport map + esm.shPlain ESM custom elements with no dependencies. global.mjs registers every element.
@johnhenry/dialback0.0.5—Node only—The root imports node:http, node:crypto and ws's server. ./browsermesh imports node:events, so it loads raw only in Node; esm.sh's events polyfill gets it to import.
@johnhenry/domable0.0.1—Browser-readyimport map + esm.shPlain ESM, no dependencies.
@johnhenry/domkit0.0.4—Browser-ready—Source ESM. These docs describe domkit 0.1.0, whose module layout differs from the 0.0.4 on npm, so no CDN snippet is generated until 0.1.0 is published.
@johnhenry/ecmanim0.1.0—Partialimport map + esm.shThe root, ./browser, ./browser-three, ./authoring, ./studio and ./physics/* load in a browser. Optional renderers (three, mathjax-full, mp4-muxer, …) are imported lazily, so map them only if you use them. ./node and ./service (ffmpeg, worker_threads, http) are Node.
@johnhenry/fileable0.0.4—Partialimport map + esm.shThe root renders to disk (node:fs, child_process, zlib) and is Node-only. ./browser and the JSX runtimes load in a browser.
@johnhenry/hashish0.0.0—Browser-readyimport map + esm.shPlain ESM, no dependencies.
@johnhenry/hostable0.0.2—Partialimport map + esm.shThe root, the JSX runtimes and the Deno/Bun/Cloudflare adapters are Fetch-API code that loads in a browser (a service worker, say); servable's #imports resolve through an import-map scope. ./adapters/node needs node:http.
@johnhenry/html-modules0.0.0—Partialimport map + esm.shThe bootstrap, runtime, compiler and safe-fragment bridge load in a browser. ./dev (a node:http dev server), ./vite and the html-module CLI are Node.
@johnhenry/http-converter0.0.1—Browser-readyimport map + esm.shPlain ESM, no dependencies.
@johnhenry/http-fields0.1.0—Browser-readyimport map + esm.shPlain ESM, no dependencies.
@johnhenry/isomorphic-jj1.13.0—Browser-readyimport map + esm.shThe root and ./browser load in a browser. isomorphic-git (a peer) and its CommonJS dependencies come in through jsDelivr's /+esm builds; @isomorphic-git/lightning-fs is optional. The isojj CLI is Node.
@johnhenry/jth0.0.0—Node only—The jth CLI and compiler driver: esbuild, fs, child_process.
@johnhenry/jth-stdlib0.0.0—Browser-readyimport map + esm.shPlain ESM; jth-runtime and jth-types come in through the import map.
@johnhenry/laya0.3.2—Browser-readyimport map + esm.shThe root loads in a browser; its #io import resolves to the browser build through an import-map scope. Pair it with @johnhenry/backend-webgpu (or backend-cpu), which it imports lazily.
@johnhenry/leserve0.1.0—Node only—An HTTP server on node:http/https and ws. A few helper subpaths (./body, ./compose, ./auth) import cleanly in a browser, but the package exists to run a Node server.
@johnhenry/letterpress0.2.1—Partialimport map + esm.shThe router and ./rewrite are runtime-agnostic and load in a browser. ./fs, the filesystem router, needs node:fs.
@johnhenry/iteration0.0.00.0.0Browser-readyimport map + esm.shPlain ESM, no dependencies.
@johnhenry/math0.0.10.0.1Browser-readyimport map + esm.shPlain ESM, no dependencies.
@johnhenry/math-plus-tensor-core0.2.10.2.1Browser-readyimport map + esm.shPlain ESM; @johnhenry/math-plus-special comes in through the import map.
@johnhenry/math-prototype-patch0.0.1—Browser-readyimport map + esm.shPlain ESM; @johnhenry/math comes in through the import map.
@johnhenry/mport0.0.0—Partialimport map + esm.shThe router (., ./core, ./firefox) is plain ESM with no dependencies. ./node, ./vite, ./rollup and the CLI read and write files.
@johnhenry/oat-bootstrap0.1.1—Browser-readyimport map + esm.shPlain ESM.
@johnhenry/oat-protocol0.1.1—Browser-readyimport map + esm.shPlain ESM; cbor-x and @noble/* come in through the import map.
@johnhenry/oat-receiver0.1.1—Browser-readyimport map + esm.shPlain ESM; jsqr (CommonJS) comes in through jsDelivr's /+esm build.
@johnhenry/oat-sender0.1.1—Browser-readyimport map + esm.shPlain ESM; qrcode (CommonJS) comes in through jsDelivr's /+esm build.
@johnhenry/oat-sim0.1.1—Browser-readyimport map + esm.shPlain ESM.
@johnhenry/objectify0.0.1—Node only—A CLI on better-sqlite3 (a native addon), fs and child_process.
@johnhenry/packfile0.0.1—Partialimport map + esm.sh./browser and ./blob-preview load in a browser. The root and ./compat, ./cache, ./hash, ./compression, ./web-bundle use node:fs, node:crypto or zlib.
@johnhenry/raijin-consensus0.2.0—Browser-readyimport map + esm.shPlain ESM.
@johnhenry/raijin-core0.2.0—Browser-readyimport map + esm.shPlain ESM, no dependencies.
@johnhenry/raijin-da0.0.4—Browser-readyimport map + esm.shPlain ESM. viem (peer) and fflate (optional) are imported only by the features that need them.
@johnhenry/raijin-mempool0.1.1—Browser-readyimport map + esm.shPlain ESM.
@johnhenry/raijin-sdk0.1.0—Browser-readyimport map + esm.shPlain ESM.
@johnhenry/raijin-validator0.2.0—Browser-readyimport map + esm.shPlain ESM.
@johnhenry/safe-fragment0.0.1—Browser-readyimport map + esm.shPlain ESM. dompurify is imported lazily (only without Element.setHTML), and a raw CDN cannot resolve that bare import, so the import map includes it.
@johnhenry/semantic-chunker0.0.2—Partialimport map + esm.shThe chunker and ./embed/xenova load in a browser. ./embed/ollama imports node:fs.
@johnhenry/servable0.0.3—Partialimport map + esm.shThe root (it has a "browser" export condition), the JSX runtimes and the Deno/Bun/Cloudflare adapters load in a browser; its #imports resolve through an import-map scope. ./adapters/node needs node:http.
@johnhenry/servant0.3.0—Node only—Controls a Node HTTP server: node:http, node:https, node:stream, ws.
@johnhenry/signalle0.1.2—Browser-readyimport map + esm.shPlain ESM, no dependencies.
@johnhenry/spintax0.0.1—Browser-readyimport map + esm.shPlain ESM, no dependencies.
@johnhenry/temporals0.0.3—Browser-readyimport map + esm.shPlain ESM; temporal-polyfill (a peer) comes in through the import map.
@johnhenry/tester0.0.1—Browser-readyimport map + esm.shPlain ESM, no dependencies.
@johnhenry/webwire0.0.1—Node only—Converts between Node's http objects and Fetch API Request/Response. Some subpaths import in a browser, but they only make sense with Node objects.
@johnhenry/window-algebra0.0.0—Browser-readyimport map + esm.shPlain ESM, no dependencies; every entry point loads in a browser.
@johnhenry/wsh0.23.0—Browser-readyimport map + esm.shPlain ESM, no dependencies; @noble/post-quantum is an optional, lazily imported extra.

Each classification comes from reading the package’s exports the way a browser bundler does (the browser, import and default conditions), following its module graph for node: and other built-in imports, and then loading the generated snippet in Chromium.

A clean import from esm.sh is not proof of browser support. esm.sh replaces Node built-ins with browser polyfills or stubs. Several Node-only entry points (fileable’s root, packfile’s root, webwire, objectify, jth, mport’s ./node) import from esm.sh without an error and then fail when you call them. It can go the other way too: @johnhenry/mcp-query/otel loads from esm.sh but not as raw files, because @opentelemetry/api’s ESM build uses extensionless relative imports.

An import map and raw files (jsDelivr or unpkg). These CDNs serve npm’s files byte for byte. They do not rewrite a bare import "dompurify" inside a package, so the page’s import map has to name every bare specifier the modules reach, dependencies included. safe-fragment is a typical case: it imports dompurify lazily, and only on browsers without Element.setHTML (Safari today, per its docs). A map that leaves dompurify out appears to work wherever setHTML exists and fails where it doesn’t.

safe-fragment from jsDelivr
<!-- In <head>, before any <script type="module"> or modulepreload. -->
<script type="importmap">
{
"imports": {
"@johnhenry/safe-fragment": "https://cdn.jsdelivr.net/npm/@johnhenry/[email protected]/dist/index.js",
"dompurify": "https://cdn.jsdelivr.net/npm/[email protected]/dist/purify.es.mjs"
}
}
</script>
<script type="module">
import * as safeFragment from "@johnhenry/safe-fragment";
</script>

Two more details apply to raw files:

  • A package’s internal #imports (servable, hostable, laya) are private to that package, so they go in the map’s scopes, keyed by the package’s CDN URL. The generated snippets include them.
  • A dependency that ships only CommonJS, such as react, qrcode or jsqr, cannot load raw. The snippets point it at jsDelivr’s /+esm build of the same version, at the same URL jsDelivr’s own bundles use (/npm/react@<version>/+esm). Two spellings of one module would load two copies, which breaks React.

esm.sh. A single URL, no map. esm.sh rewrites every import to another esm.sh URL and picks dependency versions from the package’s own ranges:

import * as safeFragment from "https://esm.sh/@johnhenry/[email protected]";

Its output is a build, not npm’s bytes. It is generated per request (target, polyfills), which means you cannot pin it with SRI, and two packages share a dependency only when esm.sh resolves both to the same URL.

jsDelivr’s /+esm. https://cdn.jsdelivr.net/npm/<name>@<version>/+esm is jsDelivr’s own bundle of a package, with its imports rewritten to other /+esm URLs. Like esm.sh it is generated, so it does not take SRI either.

Write @johnhenry/[email protected], not @0, @^0.0.0 or @latest. A CDN resolves a range to whatever is newest when its cache expires, so the code on your page changes without a deploy. SRI needs a fixed file anyway. Several of these packages are at 0.0.x, and before 1.0 a caret range means little: ^0.0.0 matches only 0.0.0, while ^0.2.0 accepts any 0.2.x.

The snippets on this site are pinned to npm’s latest on 2026-10-07. The version source is src/data/package-versions.json: npm run sync:versions refreshes it from npm and JSR, and npm run verify:cdn loads the snippets in a browser again.

Put the import map before any module script

Section titled “Put the import map before any module script”

Place <script type="importmap"> in <head>, ahead of every <script type="module"> and every <link rel="modulepreload">. Firefox ignores an import map that arrives after a module load or preload has started. It logs “Import maps are not allowed after a module load or preload has started”, and every bare import on the page then fails. In our test, Chromium 143 accepted a map after a module script and after a modulepreload, but the page then works in Chromium and breaks in Firefox. mport’s limitations page records the per-engine results.

A raw file at an exact version never changes, so you can pin it. An import map’s integrity key takes a hash for each module URL. Unlike <script integrity>, which covers only that one script, it applies to every module the page loads from a listed URL, including files reached through relative imports. Here is the safe-fragment map with every file pinned. Chromium blocked the module when we changed one character of a hash.

with integrity
<!-- In <head>, before any <script type="module"> or modulepreload. -->
<script type="importmap">
{
"imports": {
"@johnhenry/safe-fragment": "https://cdn.jsdelivr.net/npm/@johnhenry/[email protected]/dist/index.js",
"dompurify": "https://cdn.jsdelivr.net/npm/[email protected]/dist/purify.es.mjs"
},
"integrity": {
"https://cdn.jsdelivr.net/npm/@johnhenry/[email protected]/dist/index.js": "sha384-Wdbb2pEsDBQuAiBEvhTrQQYJnCKK60Ow2saI8VrCdb17+IVQq4N59qexggWvH7en",
"https://cdn.jsdelivr.net/npm/[email protected]/dist/purify.es.mjs": "sha384-a/XFwrpeq88LPRbyWBWlLnv0sUPkA6mAADSFODl/XWtg+PHClcleY9DmdjDED9+Z"
}
}
</script>
<script type="module">
import * as safeFragment from "@johnhenry/safe-fragment";
</script>

To hash a file yourself:

Terminal window
curl -sL https://cdn.jsdelivr.net/npm/[email protected]/dist/purify.es.mjs \
| openssl dgst -sha384 -binary | openssl base64 -A

Integrity only works for raw files. esm.sh output and jsDelivr’s /+esm are generated, and jsDelivr asks you not to use SRI with them. Browsers that don’t implement import-map integrity ignore the key, so it adds protection where it is supported and changes nothing elsewhere.

An inline <script type="importmap"> counts as an inline script. Under a script-src without 'unsafe-inline' the browser blocks it unless it carries a nonce or the policy lists the hash of its exact text. Every bare import then fails. script-src must also allow the CDN origins the modules load from:

Content-Security-Policy: script-src 'nonce-RANDOM' https://cdn.jsdelivr.net https://esm.sh
<script type="importmap" nonce="RANDOM">{ "imports": { "…": "…" } }</script>

A static site cannot send a fresh nonce on each response, so it lists the map’s hash instead ('sha256-…', computed over the exact bytes between the tags). Any change to the map, including whitespace, changes the hash. In Chromium, a map without a nonce under that policy was blocked, and the same map with a nonce loaded.

mport is the family’s tool for building import maps. It resolves version ranges to exact versions with a lockfile, routes each package to a CDN with fallback across providers (esm.sh, jsDelivr, unpkg and others), can hash the whole module graph into the map’s integrity, and returns the CSP hash for the map it renders. See Import maps, lockfiles and the CLI and In the browser. The snippets on this site come from a smaller script in this repository (scripts/sync-package-versions.mjs), not from mport.