domdomdom
v0.1.1
Published
DOM-aware JS evaluator. Pipe code via stdin to run against any HTML page (URL, local file, or inline). Lightweight happy-dom alternative to Playwright for non-layout, non-interactive tasks.
Maintainers
Readme
domdomdom
Evaluate JavaScript against an HTML page from the command line. Pipe in code, get back the truth, cue dramatic chipmunk!
echo "return document.querySelectorAll('h1').length" | domdomdom https://example.comPowered by happy-dom. No browser binary, no Playwright install, no MCP server. Two TypeScript files, ~12KB on npm.
Why this exists
happy-dom almost works on Bun out of the box. Then you hit four walls. domdomdom fixes them so you don't have to:
- Built-ins are missing. happy-dom's BrowserWindow on Bun starts with
Object,Math,JSON,parseInt,SyntaxErroretc. set toundefined. ItsVMGlobalPropertyScripttries to copy them fromglobalThis, but insideScript.runInContextglobalThisrefers to the (empty) inner scope. EveryquerySelectorthrowsTypeErrorbecauseSyntaxErrorisn't onwindow. domdomdom enumeratesObject.getOwnPropertyNames(globalThis)from the host realm and assigns each to the page window. file://doesn't fetch.page.goto('file:///abs/path.html')rejects. domdomdom reads HTML manually and usespage.content =pluspage.url =to set up the page.- IIFE bundles silently break. happy-dom's HTML parser wraps every
<script>body infunction anonymous($happy_dom) { ... }. Top-levelvar foo = (() => { ... })()becomes a function-local — never reacheswindow. domdomdom extracts<script src>tags beforepage.contentand runs them viapage.evaluate()(usesScript.runInContextdirectly, preserves real script-top-level scope). - ES modules can't import.
<script type="module" src="./foo.js">can't be fetched from disk. domdomdom maps a synthetichttp://origin to the page directory via happy-dom'svirtualServersso relative imports work.
Each of these is a one-line fix once you've found it. Finding them took an afternoon.
When to use this vs. alternatives
| You want | Use this |
| ------------------------------------------- | ------------------ |
| Run a snippet against a real page, fast | domdomdom |
| Test code that uses document, window | domdomdom |
| Verify an IIFE bundle attaches to window | domdomdom |
| Layout, computed styles, screenshots | Playwright |
| Run untrusted JS safely | Playwright (sandbox) or a worker pool |
| Parse HTML without executing scripts | linkedom (faster) |
| Module bundling / build tooling | bun build / esbuild |
Install
# global install
bun add -g domdomdom
npm install -g domdomdom
# one-off, no install
bunx domdomdom ./page.html
npx domdomdom https://example.com
# clone for development
git clone https://github.com/scruffymongrel/domdomdom && cd domdomdom && bun linkRuntime requirements
- Bun ≥ 1.3 — works out of the box.
- Node ≥ 23.6 (LTS: 24+) — uses Node's built-in TypeScript stripping. The shebang silences the experimental-feature warning automatically. Node 22 LTS users need
node --experimental-strip-typesset inNODE_OPTIONS, or just install via Bun.
No build step. The published package ships .ts source directly; both runtimes execute it natively.
CLI
domdomdom [options] [URL_OR_PATH]| Source | Interpretation |
| ---------------- | ------------------------------------------------- |
| http(s)://... | fetched via happy-dom |
| ./path.html | read from disk; relative scripts/modules resolved |
| --html '<...>' | inline HTML |
| (none) | about:blank |
| Code source | Interpretation |
| --------------- | ---------------------------------------------------- |
| stdin | default; auto-return if a single expression |
| --script <f> | read user code from a file (no auto-return) |
Flags
| Flag | Effect |
| ---------------- | ----------------------------------------------------------- |
| --inject <f> | preload a JS file in the window before user code; repeatable |
| --module | evaluate user code as ES module (allows top-level import) |
| --user-agent | override navigator.userAgent |
| --viewport WxH | override page viewport (e.g. 1024x768) |
| --timeout <ms> | time limit; 0 disables; default 5000 |
| --no-console | drop console.* output instead of capturing it |
| --json | emit one JSON line: { ok, result?, error?, logs } |
| -h, --help | show help |
Output contract
Default (human): result on stdout, console.* on stderr ([log] / [warn] / etc), errors on stderr (EVAL ERROR: ...).
--json: single line on stdout, nothing else. Captured logs included.
Exit codes: 0 ok · 1 eval error · 2 timeout · 3 setup/usage error.
Examples
# one-liner expression against about:blank
echo "1 + 2" | domdomdom
# query a real page
echo "return [...document.querySelectorAll('a')].map(a => a.href).slice(0, 5)" \
| domdomdom https://news.ycombinator.com
# verify an IIFE bundle exposes its export on window
echo "return typeof window.MyLib" | domdomdom ./dist/test.html
# preload a stub before running test code
echo "return fetch('/api/x').then(r => r.json())" | \
domdomdom --inject ./test/stubs.js
# structured output for an agent
echo "return document.title" | domdomdom --json https://example.com
# {"ok":true,"result":"Example Domain","logs":[]}Library
Same engine, programmatic:
import { evaluate } from 'domdomdom'
const r = await evaluate('return document.title', {
html: '<title>hi</title>',
timeout: 1000,
})
if (r.ok) console.log(r.result)evaluate(code, opts?)
interface EvaluateOptions {
source?: string // URL or local file path
html?: string // inline HTML (mutually exclusive with source)
baseDir?: string // resolve <script src> and inject paths against this
timeout?: number // ms; 0 disables; default 5000
module?: boolean // treat user code as ES module
inject?: string[] // preload JS files in window before user code
userAgent?: string // navigator.userAgent override
viewport?: { width: number; height: number }
quietConsole?: boolean // drop console.* instead of capturing
}
type EvaluateResult =
| { ok: true; result: unknown; logs: ConsoleEntry[] }
| { ok: false; error: EvaluateError; logs: ConsoleEntry[] }
type EvaluateError =
| { kind: 'eval'; message: string; stack?: string }
| { kind: 'timeout'; message: string }
| { kind: 'setup'; message: string; stack?: string }toCloneable(value)
JSON-stringify-safe transform. Cycles → "[Circular]". Functions, BigInt, Symbol, undefined → tagged strings. DOM nodes → plain objects. Use this if you want a result you can post over a wire.
Agent integration
domdomdom was built for LLM agents to drive — --json plus stdin/stdout-only contracts mean it works behind a plain Bash tool without an MCP server, persistent browser, or context overhead. The repo ships an Agent Skill at skills/domdomdom/SKILL.md that teaches the agent when to reach for the tool and how to read its output.
Claude Code
domdomdom is a Claude Code plugin (.claude-plugin/plugin.json in this repo) listed in the scruffymongrel marketplace. From inside Claude Code:
/plugin marketplace add scruffymongrel/claude-plugins
/plugin install domdomdom@scruffymongrelRestart Claude Code. The skill auto-loads when the user's prompt matches its trigger ("evaluate JS against this page", "test if the bundle exposes X on window", "extract X from this HTML", etc.). Users can also invoke explicitly with /domdomdom.
Other agents (Cursor, Aider, Codex CLI, Copilot, etc.)
The skill follows the Agent Skills open standard — an emerging cross-agent format that's just SKILL.md with YAML frontmatter. After installing domdomdom (npm i -g domdomdom), the skill ships at $(npm root -g)/domdomdom/skills/domdomdom/. Copy it into your agent's skill directory:
cp -r "$(npm root -g)/domdomdom/skills/domdomdom" <your-agent>/skills/
# or, from a clone:
cp -r ./skills/domdomdom <your-agent>/skills/For agents without skill support, paste this into your system prompt (covers ~90% of usage):
To execute JS against an HTML page, pipe code via stdin to
domdomdom --json --timeout <ms>followed by the URL/path or--html '<...>'. Single-line expressions auto-return; multi-line code requiresreturnexplicitly. Parse stdout as JSON; check.okfirst. Capturedconsole.*output is inlogs[].
Output contract for agents
Stdout is one JSON line. Branch on .ok:
// success
{ "ok": true, "result": <any>, "logs": [{ "level": "log"|"warn"|"error"|"info"|"debug", "message": "..." }] }
// failure
{ "ok": false, "error": { "kind": "eval"|"timeout"|"setup", "message": "...", "stack": "..." }, "logs": [...] }Exit codes (0 ok / 1 eval / 2 timeout / 3 setup) give a cheap pre-check before parsing.
When to reach for it
Verifying a built bundle exposes its export on window · extracting structured data from a fetched HTML page · running a DOM snippet without spinning up Playwright · smoke-testing <script> evaluation in CI.
When not to
Layout, screenshots, click/scroll interaction, or untrusted-code isolation. Use Playwright.
Limits
- No layout.
getComputedStyle().getPropertyValue('height')returns''for unstyled elements. happy-dom doesn't render. For layout-dependent assertions, use Playwright. - Synchronous infinite loops.
timeoutcatches async hangs (long fetches, unresolved promises, slow setIntervals). It can't kill awhile(true){}because the host event loop is shared with the page's V8 isolate. Wrap the CLI intimeout 5s domdomdom ...for a hard ceiling. - Bare module specifiers.
import 'lodash'from inside a<script type="module">won't resolve — happy-dom needs aresolveNodeModulesconfig, which we don't currently surface. Relative imports (import './foo.js') work. - Source maps. Stack traces refer to evaluated-script offsets, not your original
.tsfiles. outerHTMLround-trips drop reactive inline styles. If a custom element sets inline styles inattributeChangedCallback(e.g.this.style.display = 'grid'), assigningouterHTMLcan clobber pre-existing inline styles in the markup. Real browsers preserve them. Don't trustel.style.getPropertyValue(...)after a happy-domouterHTMLround-trip if the SUT has reactive style assignments.
Development
bun install
bun test # 60 tests, 100% line + function coverage on engine and CLI
bun run typecheck # tsc --noEmit
bun run quality # bothLicense
MIT.
