Cross-site scripting (XSS) is still one of the top risks on the OWASP Top 10. Every time you render user-generated content—comments, rich text, profile bios—you're opening a door. The standard fix is sanitization: strip or escape dangerous HTML before it hits the DOM. There are two different jobs hiding behind that one word. One is preserving safe HTML while removing the dangerous parts — what DOMPurify and sanitize-html do, and the harder problem. The other is emitting untrusted content as plain text, where nothing needs to survive. I built Purifai for the second job, in a footprint small enough for edge runtimes with no DOM available.
Purifai is a zero-dependency strip-to-text sanitizer: it removes markup rather than allow-listing safe tags. That leaves no retained markup for a parser to mutate on re-parse, and it places Purifai in a different category from DOMPurify, which exists to keep safe formatting. It's TypeScript-native, needs no DOM, and runs in Node, browsers, edge runtimes, and workers. The full package build is about 4.5 KB gzipped; a tree-shaken sanitize import is 3.5 KB minified / 1.6 KB gzipped.
<MediaContainer src="/projects/purifai/hero.webp" alt="Abstract tangled input passing through a filter into clean output" />
Quick Start
Install it:
npm install purifai
# or
pnpm add purifai
Use it:
import { Purifai } from 'purifai';
// Simple sanitization
const clean = Purifai.sanitize('<script>alert("xss")</script>Hello World');
console.log(clean); // "Hello World"
// Bound work for application-sized inputs
const safe = Purifai.sanitize(userInput, {
maxLength: 10000
});
That's it. No DOM and no runtime dependencies. The returned value is plain text for normal framework text interpolation.
Security and Content Fidelity
The benchmark inserts each sanitizer's output into a real DOM (jsdom), serializes and re-parses it, then measures executable output, exact benign-text fidelity, retained markup, and raw-container body removal separately:
| Library | Category | No executable output* | Exact text | Markup kept | Raw body removed | Median ops/sec† |
|---|---|---|---|---|---|---|
| Purifai.sanitize | strip-to-text | 100% | 100% | 0% (by design) | 100% | 526,709 |
| Purifai.escape | encode-as-text | 100% | 40% | 0% | 0% | 596,421 |
| striptags | strip-to-text | 100% | 100% | 0% | 20% | 633,697 |
| DOMPurify (jsdom) | preserve-safe-html | 100% | 100% | 100% | 60% | 1,686 |
| sanitize-html | preserve-safe-html | 100% | 100% | 100% | 60% | 88,492 |
| xss | preserve-safe-html | 100% | 100% | 100% | 0% | 335,448 |
| rehype-sanitize | preserve-safe-html | 100% | 100% | 100% | 40% | 24,522 |
| escape-html | encode-as-text | 100% | 40% | 0% | 0% | 2,238,804 |
| validator.escape | encode-as-text | 100% | 40% | 0% | 0% | 735,565 |
| entities.escapeUTF8 | encode-as-text | 100% | 40% | 0% | 0% | 1,208,824 |
| html-entities | encode-as-text | 100% | 40% | 0% | 0% | 869,440 |
| he.escape | encode-as-text | 100% | 40% | 0% | 0% | 952,948 |
84 attack vectors (OWASP, PortSwigger, cure53 corpora) · 15 benign documents.
* This is an observed corpus result, not a security guarantee. † Throughput is
the median of seven warmed-up samples captured on 2026-08-02 with Node 26.3.0
on Apple Silicon. It varies by hardware and is only comparable within one
category. In this snapshot striptags is smaller and faster; Purifai's measured
differentiation is exact benign text together with complete raw-container body
removal and bounded malformed-input scaling. Run pnpm benchmark to reproduce
the current result.
Where Purifai Fits
DOMPurify and sanitize-html preserve safe markup, which is the right choice for rich text. Purifai is designed for places where the output should be text: labels, previews, notifications, search indexes, logs, and API pipelines. Removing markup also means it can run without a browser DOM or a server-side DOM shim.
The choice is about output requirements. Keep a preserve-HTML sanitizer when formatting must survive; use Purifai when plain text is the intended result.
How Purifai Approaches the Problem
Purifai uses a bounded forward scanner without relying on a runtime DOM. It:
- Decodes syntax-relevant encodings — Numeric entities plus encoded angle brackets and JavaScript-style character escapes are normalized without turning prose such as
100%20offinto different text. - Consumes markup once — The scanner advances through the input and avoids the repeated suffix scans that made the previous regular-expression pipeline quadratic.
- Drops raw containers as regions — Script, style, iframe, SVG, template, and related container bodies do not leak into the resulting text.
- Separates output contexts —
sanitize,escape,escapeAttribute, andescapeUrlhave distinct contracts instead of treating one transformation as safe everywhere.
Key Features
Zero dependencies. No DOM, jsdom, or cheerio at runtime. The full package build is about 15.0 KB / 4.5 KB gzip, and a bundled, tree-shaken sanitize import measures 3.5 KB / 1.6 KB gzip. Works in Node and the browser.
Threat analysis. Use analyze() when you need more than sanitization—logging, blocking, or incident response:
import { analyze } from 'purifai';
const result = analyze('<script>alert("hack")</script>User content');
console.log(result.content); // "User content"
console.log(result.hadThreats); // true
console.log(result.threatLevel); // "critical"
if (result.hadThreats) {
console.warn('Potential XSS detected', { level: result.threatLevel });
}
broadcast(result.content); // Plain text; render through normal text interpolation
Batch processing. Sanitize multiple strings at once for APIs or content pipelines:
import { sanitizeBatch } from 'purifai';
const cleanData = sanitizeBatch([
'<script>alert(1)</script>Hello',
'<img src=x onerror=alert(1)>World',
'Safe content'
]);
// ["Hello", "World", "Safe content"]
Danger check. isDangerous() is advisory telemetry for logging and review; it is not an authorization gate.
Choosing Purifai over a Preserve-HTML Sanitizer
Purifai is not a drop-in replacement when your product needs to retain rich-text formatting. Its API is straightforward to adopt when the desired output is plain text.
When Migration Makes Sense
- Plain-text output — Markup does not need to survive
- Edge and worker runtimes — No browser DOM or jsdom available
- Small dependency budget — A compact, zero-dependency package
- TypeScript projects — Typed APIs without separate declarations
From DOMPurify
Before:
import DOMPurify from 'dompurify';
const clean = DOMPurify.sanitize(dirty);
After:
import { sanitize } from 'purifai';
const clean = sanitize(dirty);
If you were using DOMPurify in Node with jsdom, you no longer need jsdom—Purifai doesn't use the DOM.
From sanitize-html
Before:
import sanitizeHtml from 'sanitize-html';
const clean = sanitizeHtml(dirty, {
allowedTags: ['b', 'i', 'em', 'strong', 'p'],
allowedAttributes: { a: ['href'] },
});
After:
import { sanitize } from 'purifai';
const clean = sanitize(dirty, {
maxLength: 50000
});
This migration intentionally removes formatting. If safe tags such as <b> or
<a> must survive, keep sanitize-html or another preserve-HTML sanitizer.
Edge Cases
URL protocols. URL handling belongs to escapeUrl, not sanitize. The
optional list can narrow the built-in http, https, and mailto set; it
cannot enable executable or custom schemes:
import { escapeUrl } from 'purifai';
escapeUrl(candidate, { allowedProtocols: ['https'] });
Max length. Cap input size to avoid DoS:
sanitize(dirty, { maxLength: 10000 });
Batch processing. For many strings (e.g. API request bodies):
import { sanitizeBatch } from 'purifai';
const cleanData = sanitizeBatch(Object.values(request.body));
Testing Strategy
Don't switch cold. Run both sanitizers in parallel during rollout:
- Install Purifai alongside your current library.
- Add a comparison layer in development or staging:
import { sanitize as purifaiSanitize } from 'purifai';
import DOMPurify from 'dompurify';
function sanitizeWithComparison(input: string) {
const purifaiResult = purifaiSanitize(input);
const dompurifyResult = DOMPurify.sanitize(input);
if (purifaiResult !== dompurifyResult) {
console.warn('Sanitizer output differs', { input, purifaiResult, dompurifyResult });
}
return purifaiResult;
}
- Log differences—Purifai may strip more because its default output is plain text.
- Run your test suite. Fix any legitimate content that gets over-stripped.
- Deploy, then remove the old library and comparison code.
Quick Checklist
- Install:
pnpm add purifai - Replace imports:
sanitizefrompurifai - Map
sanitize'smaxLengthoption and anyescapeUrlprotocol narrowing - Remove jsdom (if you only had it for DOMPurify)
- Run both sanitizers in parallel in staging
- Run tests, fix any over-stripping
- Deploy and remove old dependency
Wrap-up
Purifai offers a compact option for turning untrusted markup into text without a DOM dependency. If your product needs rich text, keep a sanitizer built to preserve safe HTML; if it needs text, Purifai keeps that path small and portable.
Purifai is on npm and open source on GitHub. Run the benchmark yourself with pnpm benchmark; the suite compares security, content fidelity, and throughput across the major Node-compatible sanitizer categories. The README and GitHub issues cover usage and edge cases.