feat(runtime): HTMLRewriter (lol-html binding)#94
Conversation
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
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
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 binding —
crates/nub-native/src/html_rewriter.rsexposes a low-levelHtmlRewriterEngineplus JS-facingElement/EndTag/TextChunk/Comment/Doctype/DocumentEndwrappers, using the lol-html-js-api lifetime-erasure pattern (NativeRefWrap+ poison-on-dropAnchor) over synchronous single-threaded dispatch. - Fluent JS wrapper —
runtime/html-rewriter.mjslayers the chainable.on()/.onDocument()/.transform()surface on top, withtransform(Response)streaming viaReadableStreamand atransform(string)eager convenience form; the engine is resolved lazily + memoized. - Preload wiring —
preload.cjsinstalls a non-enumerable lazy getter (fast tier + worker threads) andpreload.mjsinstalls eagerly threading the floorcreateRequire(compat tier), mirroring theWorkerlazy-global precedent. - Types + docs + tests — ambient
HTMLRewriterdeclarations innpm/nub-types/index.d.ts, afeature_matrix.rsentry, a docs page, and an integration test driving two fixtures through the builtnubbinary (full surface,--node/NODE_COMPATabsence).
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.
Claude Opus | 𝕏
… 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
There was a problem hiding this comment.
ℹ️ 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 anactiveflag +ActiveGuardRAII +enter(), acquired bywrite/end, so a handler that re-enters the same instance throws cleanly instead of aliasing&mutinto lol-html. Cross-instance re-entrancy stays allowed; covered by the newreentrancy.mjsfixture and integration test. - Stream
cancelontransform— the outputReadableStreamnow cancels and releases the source reader and drops the engine on consumer abort, withengine/readerhoisted to outer scope and nulled in thestartfinally, preventing a source-body-lock and engine leak. - CF-exact
transform— dropped thetransform(string)Bun-style form;transformnow accepts only aResponseand throws aTypeErrorotherwise. The.d.tsoverload, the "Rewriting a string" docs section, and theconcatChunkshelper were removed, andrewrite.mjswas rewritten to wrap strings in aResponse.
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.
Claude Opus | 𝕏
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
There was a problem hiding this comment.
ℹ️ No critical issues — the WASM pivot is clean and the prior
.d.tsthreads 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-wasmdist (html_rewriter.js,asyncify.js, ~908KBhtml_rewriter_bg.wasm,LICENSE) underruntime/html-rewriter-engine/, deleting the 847-line native binding and thelol_htmldependency. - 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.mjsas a thin fluent wrapper over the WASM engine — lazy memoized engine resolution, eager selector validation via a freed probe engine, a streamingtransformwith acancelpath freeing the engine and releasing the source reader. - Resolved the prior chaining + iterator threads structurally — the vendored engine's element mutators all
return thisandget attributes()returns a real iterator, so the chainable.d.tstypes and theIterableIterator<[string, string]>declaration now match runtime. - Updated the surrounding surface — ambient types (handlers return
void | Promise<void>), the docs page (async supported, WASM mechanism), thefeature_matrixrow, 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.
Claude Opus | 𝕏
…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
There was a problem hiding this comment.
✅ 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 rejection —runtime/html-rewriter-engine/asyncify.jsnow alsostart_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. MarkedNUB PATCHin-file with provenance in the engineLICENSEfor re-vendoring. - Made consumer cancel resolve cleanly mid-suspend —
runtime/html-rewriter.mjsadds acancelledflag and a non-throwingsafeFree(); on a cancel during an in-flight async handler the held borrow is tolerated and the engine is freed when the suspendedwriteresumes viastart()'sfinally{cleanup()}. The read loop, enqueue,end(), andcontroller.errorare allcancelled-gated so nothing touches a torn-down stream. - Added three regression fixtures + tests —
async-reject.mjs(original error propagates + a fresh transform recovers),cancel.mjs(plain and cancel-mid-suspend both resolve), andleak-loop.mjs(RSS stays bounded over 3000 rejecting/cancelled cycles). - Refreshed the stale test-file module doc —
crates/nub-cli/tests/html_rewriter.rsheader now names the vendored WASM engine and drops themake addon/nub-native.nodeprerequisite (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.
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
There was a problem hiding this comment.
✅ 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 streamcancel()path —runtime/html-rewriter.mjsnow defers ALL engine freeing tostart()'sfinally{cleanup()}. wasm-bindgen's__destroy_into_raw()zeroes the wrapperptrbefore the throwing wasm free, so afree()fromcancel()mid-suspend left a truthy engine withptr=0; the latercleanup()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 loop —
tests/fixtures/html-rewriter/leak-loop.mjsnow warms up 300 cycles, then asserts RSS stays flat (< 20MBdelta) over a 2000-cycle measure window and that the loop reaches its markers without an OOB crash. - Updated the integration test —
crates/nub-cli/tests/html_rewriter.rsrenames the test tocancel_mid_suspend_reclaims_the_engineand asserts the newCANCEL_MIDSUSPEND_NO_CRASH/CANCEL_MIDSUSPEND_FLATmarkers.
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).
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

Adds a Cloudflare-Workers-shape
HTMLRewriterglobal, backed by a WebAssembly build of lol-html (Cloudflare's own streaming HTML rewriter). Code written against the Workers — or Bun —HTMLRewriterAPI ports over unchanged, including async handlers.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-3html-rewriter-wasmbuild —html_rewriter.js+asyncify.js+ a 908KBhtml_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:
.wasmfor every platform — no per-platform native prebuilds, no 8-platform cross-build concern for this feature.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-exacttransform(Response) → Response. A non-Response input throws aTypeError.{ html: true }inserts raw HTML.globalThis.HTMLRewriterfrom the preload (feature-detected, bows out if a native global exists). Absent under--node/NODE_COMPAT(compat mode skips preload injection). Engine +.wasmload lazily on first transform..on(). The output stream'scancelfrees 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_COMPATabsence), through the realnubbinary.nub-nativebuilds + clippy-clean withoutlol_html.Refs html-rewriter