Sanitize HTML
Green · 2026-04-21 Node + Browser Text 1.7–3.7× fasterHTML sanitization via Mozilla's ammonia engine.
- Targets
- Node + Browser
- Version
- 0.2.0
Install
pnpm add @amigo-labs/sanitize-htmlBenchmarks
Trend (16 pts)Benchmark
transform ol→ul (simple rename) - small (~80B)
- sanitize-html (npm) 34.70K hz · 1.35×
- amigo (baseline, no transform) 28.21K hz · 1.10×
- regex-wrapper + amigo 26.63K hz
- tokenizer-wrapper + amigo 25.67K hz
Benchmark
transform ol→ul - medium (~6KB, 100 lists)
- amigo (baseline, no transform) 5.65K hz · 3.11×
- regex-wrapper + amigo 4.46K hz · 2.45×
- tokenizer-wrapper + amigo 2.83K hz · 1.56×
- sanitize-html (npm) 1.82K hz
Benchmark
transform ol→ul - heavy (~40KB, 1000 transforms)
- amigo (baseline, no transform) 741 hz · 4.13×
- regex-wrapper + amigo 559 hz · 3.12×
- sanitize-html (npm) 294 hz · 1.64×
- tokenizer-wrapper + amigo 179 hz
Benchmark
transform rewrite ol→ul + add class (attribute mutation) - 1000 tags
- sanitize-html (npm) 197 hz · 1.08×
- tokenizer-wrapper + amigo 182 hz
Benchmark
sanitize - small safe HTML (~200 chars)
- @amigo-labs/sanitize-html 47.19K hz · 18.27×
- sanitize-html (npm) 27.77K hz · 10.75×
- isomorphic-dompurify 2.58K hz
Benchmark
sanitize - medium with XSS (~2 KB)
- @amigo-labs/sanitize-html 9.58K hz · 36.64×
- sanitize-html (npm) 3.57K hz · 13.66×
- isomorphic-dompurify 261 hz
Benchmark
sanitize - large document (~100 KB)
- @amigo-labs/sanitize-html 350 hz · 36.05×
- sanitize-html (npm) 95.7 hz · 9.86×
- isomorphic-dompurify 9.70 hz
Not enough history to draw a trend.
README
@amigo-labs/sanitize-html
Blazing fast HTML sanitization powered by Rust via NAPI-RS. A stream-based native Node.js sanitizer built directly on top of the html5ever tokenizer — faster than the JS sanitize-html / isomorphic-dompurify packages on realistic inputs. Live numbers are on the dashboard.
Installation
npm install @amigo-labs/sanitize-html
Usage
import { sanitize, isClean } from "@amigo-labs/sanitize-html";
// Sanitize HTML (removes dangerous tags/attributes)
const clean = sanitize('<p>Hello <script>alert("xss")</script></p>');
// "<p>Hello </p>"
// Check if HTML is already clean
const safe = isClean("<p>Hello</p>"); // true
const unsafe = isClean('<img onerror="alert(1)">'); // false
// With custom options
const result = sanitize("<div class='foo'>bar</div>", {
allowedTags: ["div", "p", "b", "i"],
allowedAttributes: { div: ["class"] },
});
API
sanitize(html, options?): string
Sanitizes HTML by removing dangerous tags and attributes. Returns the cleaned HTML string.
isClean(html, options?): boolean
Returns true if the HTML contains no dangerous content (i.e. sanitization would not change it).
Options
| Option | Type | Description |
|---|---|---|
allowedTags | string[] | Tags to allow (default: a conservative safe set — see DEFAULT_TAGS in src/v2.rs) |
allowedAttributes | Record<string, string[]> | Attributes to allow per tag |
allowedClasses | Record<string, string[]> | CSS classes to allow per tag |
allowedSchemes | string[] | URL schemes accepted in href/src/… (default: http, https, mailto, ftp, tel, …) |
stripComments | boolean | Whether to strip HTML comments (default: true) |
linkRel | string | Value for rel attribute on <a href> (default: "noopener noreferrer") |
Performance
Live benchmark numbers vs upstream sanitize-html and isomorphic-dompurify are tracked on the dashboard and in docs/data.json. The margin grows with input size — the streaming tokenizer avoids the DOM build cost the JS alternatives pay.
Install for the browser
The same import works in Angular, React, Vite, esbuild, and webpack ≥ 5 — the bundler picks the WASM build via the browser conditional export:
import { sanitize } from '@amigo-labs/sanitize-html'
ammonia + html5ever is ~250–400 KB gzipped (the heaviest text-category crate, but still under the 500 KB budget). The napi Either<String, f64> input quirk is dropped on the browser side — pass a string (or null/undefined) directly.
Supported Platforms
| Platform | Architecture |
|---|---|
| Linux | x64 (glibc), x64 (musl), arm64 |
| macOS | x64, arm64 |
| Windows | x64 |
License
MIT
Perf review
Perf-Review: @amigo-labs/sanitize-html
Status: 🟢 Green · Reviewed: 2026-04-21 · Version: 0.2.0
Verdict
1.63×–4.15× vs. sanitize-html npm on the sanitize surface; 39.6×–122× vs. isomorphic-dompurify on large documents. HTML parsing + rule dispatch + serialization is exactly the Green shape from commonmark/turndown: Buffer in/String out, substantial compute, no chain API. Mozilla’s ammonia is the backend (also used in servo and the Mozilla Observatory scanners); on 100 KB input with XSS content we are 4.15× faster than upstream sanitize-html. The transform API (regex + tokenizer-based wrappers) gives medium/heavy HTML mutation an additional 1.5–2.6× over the npm equivalent.
Classification rationale
ammonia=html5ever+ whitelist rules. The parser (html5ever, Mozilla Servo) is SIMD-accelerated and zero-GC. Rule dispatch in Rust is a pattern match on node type (no JS hashmap lookup per tag).- V0.2 upgrade with the hybrid engine. Tokenizer wrapper + regex wrapper coexist — regex is the fastest path for simple tag renames (ol→ul), the tokenizer for attribute mutations. The user picks the right tool per transform operation.
- The small-case delta is conservative. 200-char safe HTML: 1.63× vs. sanitize-html. For very small inputs the FFI floor is more visible — but it stays above 1×, hence Green-compliant.
- The DOMPurify comparison is an extreme case. DOMPurify is browser-oriented and massively slow in a Node context (jsdom-based). The 122× win on 100 KB confirms that DOMPurify is not a real server alternative.
Evidence
Measured speedup (docs/data.json, 2026-04-18)
Sanitize surface:
| Scenario | @amigo-labs/sanitize-html | sanitize-html npm | isomorphic-dompurify | vs. sanitize-html | vs. dompurify |
|---|---|---|---|---|---|
| small safe HTML (~200 chars) | 45 747 Hz | 28 032 Hz | 1 157 Hz | 1.63× | 39.5× |
| medium with XSS (~2 KB) | 10 320 Hz | 3 928 Hz | 227 Hz | 2.63× | 45.5× |
| large document (~100 KB) | 375.5 Hz | 90.5 Hz | 9.5 Hz | 4.15× | 39.6× |
Transform surface (ol→ul simple rename):
| Scenario | regex-wrapper + amigo | tokenizer-wrapper + amigo | sanitize-html npm | best-of-amigo vs. upstream |
|---|---|---|---|---|
| small (~80B) | 25 923 Hz | 25 409 Hz | 35 525 Hz | 0.73× (small: upstream still wins) |
| medium (~6KB, 100 lists) | 4 737 Hz | 3 032 Hz | 1 821 Hz | 2.60× (regex-wrapper) |
| heavy (~40KB, 1000 transforms) | 602.3 Hz | 178.7 Hz | 283.4 Hz | 2.13× (regex-wrapper) |
Realistic use-case
User-generated-content sanitization — comments, forum posts, rich-text-editor output. Typically 500 B – 10 KB. Email HTML rendering with XSS prevention for incoming mail. Markdown-to-HTML pipeline after @amigo-labs/commonmark. Content import from legacy systems with unsafe HTML. Median: 2 KB of safe or untrusted HTML.
The transform use case is rarer: CMS content migration (e.g. ol→ul for style consistency), rich-text-editor normalization (WYSIWYG output cleanup).
Benchmark gaps
- Very large (1 MB+) not benchmarked. Realistic for article-corpus processing.
- Allow-list-heavy configs (many custom allowed tags + attributes) not measured in isolation. Rule-set size affects dispatch overhead.
- Multi-transform chain (tokenizer + regex in one pass) not benchmarked — users would have to make two calls today.
API surface
Based on sanitize-html parity plus the hybrid-engine additions:
sanitize(html, options?)— main call, returns the cleaned HTML string- Options:
allowedTags,allowedAttributes,allowedClasses,allowedSchemes,transformTags,textFilter,parser, etc. (sanitize-html parity) - Hybrid-engine flag or auto-select: ideally the user should not have to decide between regex/tokenizer.
Bundle / binary size
ammonia + html5ever + deps: ~2–3 MB per target. One of the larger binaries in the portfolio, but justified by the 4× speedup + security.
FFI-overhead baseline
- 100 KB sanitize: buffer input ~180 ns, 80 KB string output ~28 µs UTF conversion. Against ~2.7 ms of Rust parse+sanitize = 1% FFI share. Tolerable.
- 200-char sanitize: FFI ~1 µs against ~22 µs Rust = ~5%. Still Green territory.
Phase-C optimization checklist
| # | Lever | Applicable | Notes |
|---|---|---|---|
| C.1 | Input-type minimization | ✅ already done | Buffer zero-copy |
| C.2 | Output-type minimization | ✅ already done | — |
| C.3 | Batch API (sanitizeMany) | 🟡 potential | Bulk UGC import, comments migration. Not measured |
| C.4 | Stateful API (pre-compiled rule set) | ✅ already done | ammonia::Builder pattern: the user constructs the config once and calls .clean() many times |
| C.5 | Parallelization (rayon over document batches) | 🟡 potential | Only for a batch API |
| C.6 | Algorithm swap | ❌ not applicable | ammonia + html5ever is best-in-class |
| C.7 | Allocator tuning | ✅ already done | — |
| C.8 | Bundle-size | ⚠️ trade-off | ~2-3 MB is large, but unavoidable for an HTML parser. LTO enabled |
Action plan
Keep as-is. Green across all production scenarios. The v0.2 hybrid engine was the last major sprint.
Maintenance:
- Add a very-large bench (1 MB document).
- Bench an allow-list-heavy config (e.g. 50+ custom tags).
- The transform small case (80 B) is the only non-Green spot — upstream sanitize-html wins there thanks to the JS regex fast path. Not critical, but document it.
References
- Crate:
crates/sanitize-html - Bench (main):
crates/sanitize-html/__bench__/index.bench.ts - Bench (transforms):
crates/sanitize-html/__bench__/transforms.bench.ts - Lib:
crates/sanitize-html/src/lib.rs - Cargo:
crates/sanitize-html/Cargo.toml docs/packages.jsonspeedup:"1.63–4.1× faster"