ADR 0002: jsdom ESM incompatibility breaks harn read on Node
Date: 2026-06-21 Status: Resolved in 0.2.2 (jsdom replaced with linkedom)
Context
Section titled “Context”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:
-
outlinestatically importedtypescript(a devDependency) at module top level, and the readability lib statically importedjsdom. Because every command is registered eagerly at CLI startup, an end user hitERR_MODULE_NOT_FOUND(typescript) orERR_REQUIRE_ESM(jsdom) on any command, includingharn --version. Fixed in 0.2.1 by lazy-loading both deps inside the command that needs them. -
After that fix,
harn readstill fails on Node withERR_REQUIRE_ESM. This is a deeper, upstream problem in jsdom’s dependency tree, not something the lazy-load fix can address.
The jsdom problem
Section titled “The jsdom problem”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.
Decision
Section titled “Decision”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.
Rejected alternatives (for the jsdom fix)
Section titled “Rejected alternatives (for the jsdom fix)”- Bump jsdom: rejected, no-op.
jsdom@29.1.1is already the latest; it still pullshtml-encoding-sniffer@^6.0.0. - Pin
@exodus/bytesto a CJS version viaoverrides: rejected, impossible. Every published version is ESM-only; there is no CJS target. - Override
html-encoding-snifferdown to4.0.0(useswhatwg-encoding, no@exodus/bytes): rejected. Tested; theERR_REQUIRE_ESMsimply moves towhatwg-url, which alsorequire()s@exodus/bytes. Whack-a-mole across jsdom’s tree.
Fix (shipped in 0.2.2)
Section titled “Fix (shipped in 0.2.2)”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.