Skip to content

feat(runtime): HTMLRewriter (lol-html binding)#94

Open
colinhacks wants to merge 7 commits into
mainfrom
html-rewriter
Open

feat(runtime): HTMLRewriter (lol-html binding)#94
colinhacks wants to merge 7 commits into
mainfrom
html-rewriter

Conversation

@colinhacks

@colinhacks colinhacks commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

Adds a Cloudflare-Workers-shape HTMLRewriter global, backed by a WebAssembly build of lol-html (Cloudflare's own streaming HTML rewriter). Code written against the Workers — or Bun — HTMLRewriter API ports over unchanged, including async handlers.

const res = new HTMLRewriter()
  .on("a[href]", {
    async element(el) {
      if (!(await isAllowed(el.getAttribute("href")))) el.remove();
    },
  })
  .transform(await fetch("https://example.com"));

Architecture — WASM + Asyncify (one engine, all platforms)

The engine is lol-html compiled to WebAssembly with Binaryen's Asyncify pass, vendored prebuilt under runtime/html-rewriter-engine/ (the BSD-3 html-rewriter-wasm build — html_rewriter.js + asyncify.js + a 908KB html_rewriter_bg.wasm, with the upstream license retained). Asyncify is what enables full async-handler support: when a handler returns a Promise, the WASM stack is unwound, the Promise awaited, then the stack rewound and the transform continues — the same mechanism Cloudflare and Bun use.

Chosen over a native napi binding (the earlier revision of this PR) because:

  • One portable .wasm for every platform — no per-platform native prebuilds, no 8-platform cross-build concern for this feature.
  • Async handlers work on stock Node — a native binding can't await a Promise from inside lol-html's synchronous callback without owning the event loop (workerd uses kj fibers, Bun its own loop); Asyncify solves it in pure WASM.
  • The native path's entire unsafe + re-entrancy-guard surface is gone.

Path decision: vendor CF's proven prebuilt build rather than build our own lol-html→WASM+Asyncify (which needs a patched wasm-pack fork + a specific binaryen). The prebuilt artifact is proven, BSD-3, and maintainable.

API

  • new HTMLRewriter().on(selector, handlers).onDocument(handlers).transform(response), fluent + chainable, Cloudflare-exact transform(Response) → Response. A non-Response input throws a TypeError.
  • Element/Text/Comment/Doctype/EndTag/DocumentEnd surface (getAttribute/setAttribute/before/after/prepend/append/replace/remove/removeAndKeepContent/setInnerContent/onEndTag, text get/set, etc.). Insertion is text-escaped by default; { html: true } inserts raw HTML.
  • Handlers may be sync or async — a Promise return is awaited mid-transform.
  • Installed as a lazy, non-enumerable globalThis.HTMLRewriter from the preload (feature-detected, bows out if a native global exists). Absent under --node / NODE_COMPAT (compat mode skips preload injection). Engine + .wasm load lazily on first transform.
  • Invalid selectors throw at .on(). The output stream's cancel frees the engine + releases the source reader (no leak on early abort).

No companion npm package

HTMLRewriter is a standard API injected as the built-in global — zero lock-in without a separate package, and non-nub users already have packages like html-rewriter-wasm. Built-in global only.

Verification (local, macOS arm64)

  • cargo clippy --all-targets --all-features -- -D warnings, cargo fmt --check — clean.
  • cargo test -p nub-core --lib (feature-matrix invariants) — 285 pass.
  • cargo test -p nub-cli --test html_rewriter — 3 pass (full API surface, async handlers awaited mid-transform, streaming + header carry-over, non-Response throw, invalid-selector throw, --node/NODE_COMPAT absence), through the real nub binary.
  • e2e: sync rewrite, async handler (element + document-end) awaited mid-transform, streaming, and output-stream cancel — all verified.
  • nub-native builds + clippy-clean without lol_html.

Refs html-rewriter

Add a Cloudflare-Workers-shape HTMLRewriter global, backed by a first-party
napi-rs binding to lol-html in the nub-native addon. Code written against the
Workers (or Bun) HTMLRewriter API runs unchanged.

- crates/nub-native: lol_html 3 dependency + an HtmlRewriterEngine napi class
  exposing Element/TextChunk/Comment/Doctype/EndTag/DocumentEnd wrappers. The
  engine streams output through a JS sink callback; handlers run synchronously
  inline as the document parses. Unit wrappers hold a poison-guarded pointer
  valid only during their handler call.
- runtime/html-rewriter.mjs: the fluent HTMLRewriter wrapper — on()/onDocument()
  chaining, transform(Response) streaming over the body, and a transform(string)
  convenience form. Invalid selectors throw at on(); a Promise-returning handler
  throws (sync-only first cut).
- Installed as a lazy non-enumerable global from the preload (fast + compat
  tiers), feature-detected so it bows out when a native global exists. Absent
  under --node / NODE_COMPAT, which skip preload injection.
- feature_matrix row, @nubjs/types ambient declarations, a docs page, and
  integration tests covering the API surface, streaming, and compat-mode absence.

The nub-native addon is already cross-built for all 8 platforms by the release
pipeline, so no new prebuild wiring is needed; the matrix is exercised by CI.

Refs html-rewriter
Copilot AI review requested due to automatic review settings June 23, 2026 19:37
@vercel

vercel Bot commented Jun 23, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
nub Ready Ready Preview, Comment Jun 24, 2026 5:33am

Request Review

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important

The rewritable-unit mutator methods are typed as chainable (returning the unit) but the native binding returns undefined, so chained calls throw at runtime — which contradicts the PR's "ports over unchanged" claim.

Reviewed changes — initial review of a Cloudflare-Workers-shape HTMLRewriter global backed by a first-party napi-rs binding to lol-html 3.0.0 in the nub-native addon.

  • Native lol-html bindingcrates/nub-native/src/html_rewriter.rs exposes a low-level HtmlRewriterEngine plus JS-facing Element/EndTag/TextChunk/Comment/Doctype/DocumentEnd wrappers, using the lol-html-js-api lifetime-erasure pattern (NativeRefWrap + poison-on-drop Anchor) over synchronous single-threaded dispatch.
  • Fluent JS wrapperruntime/html-rewriter.mjs layers the chainable .on()/.onDocument()/.transform() surface on top, with transform(Response) streaming via ReadableStream and a transform(string) eager convenience form; the engine is resolved lazily + memoized.
  • Preload wiringpreload.cjs installs a non-enumerable lazy getter (fast tier + worker threads) and preload.mjs installs eagerly threading the floor createRequire (compat tier), mirroring the Worker lazy-global precedent.
  • Types + docs + tests — ambient HTMLRewriter declarations in npm/nub-types/index.d.ts, a feature_matrix.rs entry, a docs page, and an integration test driving two fixtures through the built nub binary (full surface, --node/NODE_COMPAT absence).

The lifetime-erasure + poison-cell soundness argument holds (synchronous, single-threaded dispatch; the anchor poisons on return), the tier split is correct, and the additive contract (non-enumerable, feature-detected, absent under compat mode) is covered by tests. The one substantive issue is the typed-but-not-implemented chaining; the rest are minor.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Claude Opus𝕏

Comment thread npm/nub-types/index.d.ts
Comment thread npm/nub-types/index.d.ts
Comment thread runtime/preload.mjs Outdated
… transform

Address review of the HTMLRewriter binding:

- Guard against same-instance re-entrancy. lol-html drives handlers
  synchronously from inside write()/end(); a handler calling write()/end() on
  the SAME engine would alias &mut HtmlRewriterEngine (napi-rs has no borrow
  guard) and re-enter lol-html on an already-borrowed rewriter — aliasing UB
  reachable from safe JS. Add a per-instance RAII flag set across write()/end()
  (cleared on unwind) that rejects re-entrant calls with a clean error.
  Cross-instance re-entrancy (a handler driving a different engine) still works.
- Add a cancel() to the transform output stream so an aborted consumer cancels
  the source body reader and drops the engine, instead of leaking the engine and
  locking the source body.
- Drop the transform(string) convenience form; transform is now
  Cloudflare-exact (Response in, Response out). A non-Response input throws a
  TypeError. Updated the wrapper, ambient types, docs, and tests.
- Add a re-entrancy regression test (same-instance rejected, cross-instance
  allowed) and a non-Response-input test.

Refs html-rewriter

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ No new issues in the engine, stream, or type changes — one garbled comment in the new test fixture.

Reviewed changes — the incremental delta since the prior pullfrog review (d672914): a per-instance re-entrancy guard on the native engine, a stream-cancel path on transform, and the removal of the transform(string) ergonomic to match Cloudflare exactly.

  • Re-entrancy guard on HtmlRewriterEngine — added an active flag + ActiveGuard RAII + enter(), acquired by write/end, so a handler that re-enters the same instance throws cleanly instead of aliasing &mut into lol-html. Cross-instance re-entrancy stays allowed; covered by the new reentrancy.mjs fixture and integration test.
  • Stream cancel on transform — the output ReadableStream now cancels and releases the source reader and drops the engine on consumer abort, with engine/reader hoisted to outer scope and nulled in the start finally, preventing a source-body-lock and engine leak.
  • CF-exact transform — dropped the transform(string) Bun-style form; transform now accepts only a Response and throws a TypeError otherwise. The .d.ts overload, the "Rewriting a string" docs section, and the concatChunks helper were removed, and rewrite.mjs was rewritten to wrap strings in a Response.

The guard's RAII clear-on-unwind is correct, the cancel path is sound, and the API narrowing is consistent across the binding, .d.ts, docs, and fixtures.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Claude Opus𝕏

Comment thread tests/fixtures/html-rewriter/reentrancy.mjs Outdated
Replace the native napi lol-html binding with a WebAssembly build of lol-html
(Binaryen Asyncify), giving full async-handler support and a single portable
engine for every platform.

- Vendor the prebuilt html-rewriter-wasm engine (BSD-3) under
  runtime/html-rewriter-engine/ (html_rewriter.js, asyncify.js,
  html_rewriter_bg.wasm, LICENSE). One ~908KB .wasm runs on all platforms — no
  per-platform native build.
- Async handlers: a handler returning a Promise is awaited mid-transform
  (Asyncify unwinds/rewinds the WASM stack), matching Cloudflare/Bun. The
  wrapper's write()/end() are now awaited; the sync-only guard and
  throw-on-Promise are removed.
- Remove the native binding: delete crates/nub-native/src/html_rewriter.rs and
  the lol_html dependency. nub-native is back to its data-format + transpiler
  surface, and the entire unsafe/re-entrancy-guard surface is gone with it.
- Keep the public API Cloudflare-exact: transform(Response) -> Response,
  fluent on()/onDocument(), lazy non-enumerable global, absent under
  --node/NODE_COMPAT. Selector errors still throw at on().
- No companion npm package — the built-in global is the only surface.
- Update the engine load path, ambient types (handlers return
  void | Promise<void>), the docs (async supported, WASM mechanism), and the
  feature_matrix row. Tests now assert async handlers are awaited mid-transform.

Refs html-rewriter

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ No critical issues — the WASM pivot is clean and the prior .d.ts threads are genuinely resolved by it. One stale doc-comment in the touched test file.

Reviewed changes — the incremental delta since the prior pullfrog review (33b3240): commit 28a05d4 replaces the native napi lol-html binding with a vendored prebuilt WASM build of lol-html (Binaryen Asyncify), adding full async-handler support.

  • Pivoted the engine to WASM + Asyncify — vendored the BSD-3 html-rewriter-wasm dist (html_rewriter.js, asyncify.js, ~908KB html_rewriter_bg.wasm, LICENSE) under runtime/html-rewriter-engine/, deleting the 847-line native binding and the lol_html dependency.
  • Added async-handler support — the wrapper's write()/end() are now awaited; a handler returning a Promise is awaited mid-transform via Asyncify stack unwind/rewind, dropping the old sync-only guard and throw-on-Promise path.
  • Rewrote runtime/html-rewriter.mjs as a thin fluent wrapper over the WASM engine — lazy memoized engine resolution, eager selector validation via a freed probe engine, a streaming transform with a cancel path freeing the engine and releasing the source reader.
  • Resolved the prior chaining + iterator threads structurally — the vendored engine's element mutators all return this and get attributes() returns a real iterator, so the chainable .d.ts types and the IterableIterator<[string, string]> declaration now match runtime.
  • Updated the surrounding surface — ambient types (handlers return void | Promise<void>), the docs page (async supported, WASM mechanism), the feature_matrix row, and the integration test (now asserts async handlers are awaited mid-transform).

The pivot is internally consistent — chaining, the iterator shape, the streaming/cancel lifecycle, and the lazy non-enumerable global install all check out against the vendored engine, and the BSD-3 license is retained.

One prior thread remains open and was not addressed by 28a05d4: the garbled runtime/preload.mjs comment (…the createRequire in before installing, missing a verb). It is left open rather than re-raised here.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Claude Opus𝕏

Comment thread crates/nub-cli/tests/html_rewriter.rs Outdated
…sumer cancel

Two engine-leak fixes with a shared root cause: the Asyncify wrap() only rewound
the WASM stack on the success path, so a suspended async handler that rejected (or
was cancelled mid-suspend) left the Rust write frame holding its wasm-bindgen
borrow; engine.free() then threw "recursive use of an object" and the engine
leaked.

- asyncify.js (vendored, marked NUB PATCH): on a rejected handler promise, still
  rewind so the suspended frame returns and drops its borrow, swallow the
  secondary error the rewind re-triggers, then re-throw the ORIGINAL rejection.
  The original error wins; the engine is freed. Provenance noted in the engine
  LICENSE so a re-vendor re-applies it.
- html-rewriter.mjs: free() the engine through a non-throwing safeFree() in the
  cleanup/cancel paths, and add a `cancelled` flag so a consumer cancel during an
  in-flight async handler resolves cleanly (the engine is freed when the suspended
  write resumes) instead of rejecting reader.cancel() with the borrow error.
- Tests: async-handler-reject (original error propagates + recovers),
  consumer-cancel incl. cancel-mid-suspend (reader.cancel resolves), and a bounded
  leak-loop (RSS stays flat over rejecting/cancelled transforms). Fix the stale
  "lol-html binding in nub-native" header (the engine is WASM now).

Verified through the real nub binary on host Node 26, the fast-tier floor (22.15),
and the compat tier (20.19): async handlers, rejection, and cancel all correct;
the per-engine WASM leak (was +102MB/5000 rejects) is gone.

Refs html-rewriter

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues found.

Reviewed changes — the incremental delta since the prior pullfrog review (28a05d4): commit bfee36b fixes two engine-leak paths sharing one root cause — a rejected or cancelled-mid-suspend async handler left the suspended Rust frame holding its wasm-bindgen borrow, so engine.free() threw and the WASM engine leaked.

  • Patched the Asyncify wrap() to rewind on rejectionruntime/html-rewriter-engine/asyncify.js now also start_rewinds when the handler promise rejects (so the suspended frame returns and drops its borrow), swallows the secondary error the re-run re-throws, settles a possible re-suspend, then re-throws the ORIGINAL rejection. Marked NUB PATCH in-file with provenance in the engine LICENSE for re-vendoring.
  • Made consumer cancel resolve cleanly mid-suspendruntime/html-rewriter.mjs adds a cancelled flag and a non-throwing safeFree(); on a cancel during an in-flight async handler the held borrow is tolerated and the engine is freed when the suspended write resumes via start()'s finally{cleanup()}. The read loop, enqueue, end(), and controller.error are all cancelled-gated so nothing touches a torn-down stream.
  • Added three regression fixtures + testsasync-reject.mjs (original error propagates + a fresh transform recovers), cancel.mjs (plain and cancel-mid-suspend both resolve), and leak-loop.mjs (RSS stays bounded over 3000 rejecting/cancelled cycles).
  • Refreshed the stale test-file module doccrates/nub-cli/tests/html_rewriter.rs header now names the vendored WASM engine and drops the make addon / nub-native.node prerequisite (the prior review thread, now resolved).

The reject-rewind logic is sound: the original handler error always wins (the swallowed re-throw and re-suspend handling are correctly gated on rejected !== null), and safeFree()'s retry-on-resume frees the engine on every reject/cancel path with no leak and no hang. The cancelled-gating is consistent across the source-read loop, sink, terminate, and error paths.

Pullfrog  | View workflow run | Using Claude Opus𝕏

…) (residual leak)

The first cancel fix had cancel() call safeFree() while a write was suspended
mid-Asyncify. wasm-bindgen's __destroy_into_raw() zeroes the wrapper ptr BEFORE
the wasm free (which throws because the handler's Rust borrow is still held, and
was swallowed), leaving the wrapper with ptr=0 but the Rust object alive. When the
suspended write resumed, cleanup() retried free() on a null pointer (no-op) so the
engine leaked permanently — +44MB/500 cancel-mid-suspend cycles, hard-crashing
with "memory access out of bounds" by ~1000.

Fix: cancel() no longer frees. It only sets `cancelled`, cancels the source reader,
and returns — start()'s `finally{cleanup()}` owns ALL freeing, and it runs only
after the suspended write resumes and the borrow releases, so free() succeeds
exactly once. safeFree() stays in cleanup() as defense.

Tighten the leak fixture to a PURE cancel-mid-suspend reclamation loop (was a
false negative — interleaved with a fast-resume path): each cycle forces a real
suspension, cancels mid-suspend, then lets the write resume; it asserts no OOB
crash and FLAT RSS across a post-warmup measure window (a per-engine WASM leak
would balloon past the bound and climb with N).

Verified through the real nub binary on host Node 26, the fast-tier floor 22.15,
and the compat tier 20.19: pure cancel-mid-suspend stays flat with no crash;
plain + early cancel still free; the full html_rewriter suite passes.

Refs html-rewriter

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues found.

Reviewed changes — the incremental delta since the prior pullfrog review (bfee36b): commit 4a19550 fixes a residual engine leak the previous cancel fix introduced — on a consumer cancel mid-suspend, calling free() from cancel() swallowed the held-borrow throw but left a wrapper whose pointer was already zeroed, orphaning a live Rust object.

  • Removed safeFree() from the stream cancel() pathruntime/html-rewriter.mjs now defers ALL engine freeing to start()'s finally{cleanup()}. wasm-bindgen's __destroy_into_raw() zeroes the wrapper ptr before the throwing wasm free, so a free() from cancel() mid-suspend left a truthy engine with ptr=0; the later cleanup() then freed a null pointer (no-op) and the live Rust object leaked (~20KB each, OOB crash by ~1000 cycles). Freeing only after the suspended write resumes and the borrow releases frees exactly once.
  • Rewrote the leak fixture into a pure cancel-mid-suspend reclamation looptests/fixtures/html-rewriter/leak-loop.mjs now warms up 300 cycles, then asserts RSS stays flat (< 20MB delta) over a 2000-cycle measure window and that the loop reaches its markers without an OOB crash.
  • Updated the integration testcrates/nub-cli/tests/html_rewriter.rs renames the test to cancel_mid_suspend_reclaims_the_engine and asserts the new CANCEL_MIDSUSPEND_NO_CRASH / CANCEL_MIDSUSPEND_FLAT markers.

Traced sound against the vendored engine: html_rewriter.js:573-583 confirms __destroy_into_raw sets ptr=0 before the throwing wasm.__wbg_*_free, so the double-free orphan the fix avoids is real. The cancel-without-in-flight-handler path remains correct (the cancelled reader resolves done, the read loop breaks, and finally{cleanup()} frees the still-referenced engine).

Pullfrog  | View workflow run | Using Claude Opus𝕏

The comment at the HTMLRewriter install site still named the deleted native
binding ("lol-html engine in nub-native") and had a garbled clause ("the
createRequire in before installing" — missing verb). Rewrote to match the
current WASM engine and be grammatical.

Claude-Session: https://claude.ai/code/session_01YRvztkcr4fzfg9rD5edUwa
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants