Skip to content

ADR 0002: jsdom ESM incompatibility breaks harn read on Node

Date: 2026-06-21 Status: Resolved in 0.2.2 (jsdom replaced with linkedom)

After the 0.2.0 launch, a published-package smoke test on Node (npm i harnery with production deps only, no dev deps) revealed two startup crashes and one runtime failure:

  1. outline statically imported typescript (a devDependency) at module top level, and the readability lib statically imported jsdom. Because every command is registered eagerly at CLI startup, an end user hit ERR_MODULE_NOT_FOUND (typescript) or ERR_REQUIRE_ESM (jsdom) on any command, including harn --version. Fixed in 0.2.1 by lazy-loading both deps inside the command that needs them.

  2. After that fix, harn read still fails on Node with ERR_REQUIRE_ESM. This is a deeper, upstream problem in jsdom’s dependency tree, not something the lazy-load fix can address.

harn read (HTML to markdown via @mozilla/readability + jsdom) depends on jsdom@29.1.1, the current latest. Multiple packages in jsdom’s dependency tree (html-encoding-sniffer@6, whatwg-url) do a CommonJS require() of @exodus/bytes, which is published ESM-only ("type": "module") in every version that exists (verified 1.0.0-rc.0 through 1.15.1). On Node, a require() of an ESM-only module throws ERR_REQUIRE_ESM. The failure is not at our boundary: harnery’s dist/ is ESM and imports cleanly. The broken require() lives inside jsdom’s own transitive CJS code.

Ship 0.2.1 with the startup fix (the dead-on-arrival crash affected every command and was the urgent problem). Defer the jsdom fix to a follow-up. harn read now fails gracefully with a captured error instead of crashing the whole CLI, so the rest of the tool is usable.

  • Bump jsdom: rejected, no-op. jsdom@29.1.1 is already the latest; it still pulls html-encoding-sniffer@^6.0.0.
  • Pin @exodus/bytes to a CJS version via overrides: rejected, impossible. Every published version is ESM-only; there is no CJS target.
  • Override html-encoding-sniffer down to 4.0.0 (uses whatwg-encoding, no @exodus/bytes): rejected. Tested; the ERR_REQUIRE_ESM simply moves to whatwg-url, which also require()s @exodus/bytes. Whack-a-mole across jsdom’s tree.

Replaced jsdom with linkedom in src/lib/readability/client.ts. linkedom is a lighter, CJS-friendly DOM that @mozilla/readability supports, and it drops the entire @exodus/bytes chain. The readability test suite passed against linkedom unchanged (no DOM-behavior reconciliation needed for this usage), and harn read was verified to produce correct markdown on a plain Node --omit=dev install. The --url option’s relative-link resolution is preserved by injecting a <base> tag, since linkedom’s parseHTML has no base-URL option.

A published-on-Node smoke test now guards against this class of regression (scripts/smoke-test.mjs, the smoke CI job): it builds dist/, packs the tarball, installs it with --omit=dev, and runs the CLI via node dist/cli.js (not bin/harn, which prefers Bun and would mask Node-only failures), asserting --version, --help, outline, and read all work. Verified to catch the exact failure mode that shipped: a used static import of a devDependency typechecks clean but fails the smoke test with ERR_MODULE_NOT_FOUND on the production install. The test/lint/build gates all missed this because they run against source under Bun.