TL;DR
- lol-html là streaming HTML rewriter viết bằng Rust, < 100KB memory cho mọi document size. Là engine phía sau
HTMLRewriterAPI trong Cloudflare Workers.- Streaming nghĩa là: parse từng chunk (4-8KB), chạy CSS selector match, emit modified chunk — không bao giờ build full DOM trong memory. Document 50MB cũng dùng < 100KB RAM.
- 3 production pattern tôi đang dùng: (1) inject CSP nonce per request, (2) rewrite analytics script URL cho privacy/compliance, (3) A/B variant inject HTML attribute. Cả 3 chạy ở edge, latency < 1ms thêm.
- So với DOMParser/cheerio/jsdom: jsdom load full DOM ~10-50MB cho document trung bình. Worker memory limit 128MB → jsdom + node_modules nuốt 80% trước khi handle request. lol-html không có vấn đề đó.
HTMLRewriterWorkers API có 2 handler type: element (match qua CSS selector, callback nhậnElement) và text (callback nhận text chunk). Không có “document” handler kiểu DOM.- Đừng dùng lol-html cho transformation cần lookahead (đếm
<h2>rồi inject TOC ở đầu). Streaming nature không cho biết tương lai. Cho transformation đó, parse trước upstream rồi cache.- Performance thực: rewrite 1MB HTML thêm 2-4ms p50 ở Workers, 1ms ở native Rust binary. So với origin response time 100-300ms, đó là noise.
Streaming rewriter — vì sao nó khác cheerio/jsdom
Tưởng tượng response 500KB HTML từ origin. Cách truyền thống:
- Worker đọc full body vào memory (500KB)
- Parse thành DOM tree (cheerio: ~3MB, jsdom: ~30MB)
- Modify DOM
- Serialize lại thành string (500KB)
- Stream về client
Latency: phải đợi toàn bộ body tới rồi mới bắt đầu modify. Time-to-first-byte (TTFB) cộng full origin latency.
lol-html streaming:
- Worker pipe response body qua rewriter
- Mỗi chunk 4-8KB tới → parser advance state → emit modified chunk → flush ra client
- Memory: chỉ buffer đủ để match CSS selector (longest selector path)
Kết quả: TTFB gần như nguyên gốc. Document càng lớn càng thắng. Đây là post Cloudflare giải thích design — họ viết parser theo tokenization spec WHATWG nhưng tối ưu cho throughput, không phải compliance hoàn hảo.
HTMLRewriter API trong Workers — không cần biết Rust
Workers expose lol-html qua HTMLRewriter class — bạn không touch Rust, chỉ JavaScript handler:
export default {
async fetch(request, env) {
const response = await fetch(request);
return new HTMLRewriter()
.on("a", {
element(el) {
const href = el.getAttribute("href");
if (href?.startsWith("http://")) {
el.setAttribute("href", href.replace("http://", "https://"));
}
},
})
.transform(response);
},
};
Pattern: chain .on(selector, handler) calls, mỗi handler nhận Element object với getter/setter. CSS selector subset: tag name, class, id, attribute selector, combinator (>, ). KHÔNG support: :nth-child, :hover, pseudo-element, ~.
Pattern 1: CSP nonce per request
CSP nonce là kỹ thuật security thay vì allow script-src 'self' 'unsafe-inline', bạn generate nonce ngẫu nhiên mỗi request, allow chỉ inline script có nonce="..." match. Đó là OWASP recommendation.
Static HTML build-time không generate nonce được — nonce phải per-request. Edge worker là chỗ hoàn hảo:
import { v4 as uuid } from "uuid"; // hoặc crypto.randomUUID()
export default {
async fetch(request) {
const nonce = crypto.randomUUID().replace(/-/g, "");
const response = await fetch(request);
const rewriter = new HTMLRewriter()
.on("script", {
element(el) {
// Chỉ apply nonce cho inline script (không có src)
if (!el.getAttribute("src")) {
el.setAttribute("nonce", nonce);
}
},
})
.on("style", {
element(el) {
el.setAttribute("nonce", nonce);
},
});
const transformed = rewriter.transform(response);
// Set CSP header với nonce vừa generate
const newResponse = new Response(transformed.body, transformed);
newResponse.headers.set(
"Content-Security-Policy",
`default-src 'self'; script-src 'self' 'nonce-${nonce}'; style-src 'self' 'nonce-${nonce}'; object-src 'none'; base-uri 'self'`,
);
return newResponse;
},
};
Lưu ý quan trọng: nonce phải cryptographically random (≥ 128 bit entropy), không reuse. crypto.randomUUID() cho 122 bit, đủ. Đừng dùng Math.random().
Test: load page, view source, mỗi inline <script> có nonce="abc123..." khác nhau giữa các request, CSP header match. Mở DevTools Console — inline script không nonce sẽ bị block với CSP error message rõ ràng.
Origin chỉ cần output inline script bình thường, không cần biết nonce. Đó là clean separation: origin = content, edge = security policy.
Pattern 2: Rewrite analytics URL cho privacy
Use case: site đang dùng Google Analytics qua <script src="https://www.googletagmanager.com/gtag/js?id=...">. Vấn đề:
- EU compliance (GDPR/ePrivacy) — third-party script set cookie cross-domain
- AdBlocker block trực tiếp domain
googletagmanager.com→ analytics fail - Latency — connect tới
googletagmanager.comtừ user thêm 100-200ms
Giải pháp pattern: proxy analytics qua first-party domain. User browser fetch /.well-known/analytics/gtag.js (cùng domain), Worker proxy về Google. AdBlocker không block first-party.
Edge rewriter inject:
const ANALYTICS_HOSTS = new Map([
["www.googletagmanager.com", "/.well-known/analytics/gtm"],
["www.google-analytics.com", "/.well-known/analytics/ga"],
["connect.facebook.net", "/.well-known/analytics/fb"],
]);
export default {
async fetch(request) {
const url = new URL(request.url);
// Handle proxy endpoint riêng
if (url.pathname.startsWith("/.well-known/analytics/")) {
return handleAnalyticsProxy(request);
}
const response = await fetch(request);
return new HTMLRewriter()
.on("script[src]", {
element(el) {
const src = el.getAttribute("src");
if (!src) return;
try {
const srcUrl = new URL(src, url);
const proxy = ANALYTICS_HOSTS.get(srcUrl.hostname);
if (proxy) {
// Preserve query string
el.setAttribute(
"src",
proxy + srcUrl.search,
);
}
} catch {
// Invalid URL, skip
}
},
})
.on("img[src]", {
element(el) {
// Pixel tracker cũng rewrite
const src = el.getAttribute("src");
if (src?.includes("google-analytics.com/collect")) {
el.setAttribute("src", src.replace(
/https?:\/\/[^/]*google-analytics\.com/,
"/.well-known/analytics/ga",
));
}
},
})
.transform(response);
},
};
async function handleAnalyticsProxy(request) {
const url = new URL(request.url);
let target;
if (url.pathname.startsWith("/.well-known/analytics/gtm")) {
target = "https://www.googletagmanager.com/gtag/js" + url.search;
} else if (url.pathname.startsWith("/.well-known/analytics/ga")) {
target = "https://www.google-analytics.com" +
url.pathname.replace("/.well-known/analytics/ga", "") +
url.search;
}
// Strip user identifying headers
const req = new Request(target, {
method: request.method,
headers: { "user-agent": request.headers.get("user-agent") || "" },
body: request.body,
});
return fetch(req);
}
Hai win:
- AdBlocker bypass — first-party domain
- PII control — Worker có thể strip IP, modify referrer trước khi forward tới Google
Đây không phải “circumvent privacy law” — pattern này là tiêu chuẩn server-side tagging mà Google document chính thức trong GA4 server-side tagging. Edge worker chỉ là implementation hiệu quả hơn cho người không muốn deploy thêm App Engine.
Pattern 3: A/B variant inject
Use case: A/B test giao diện. Cách cổ điển: load test framework (Optimizely, VWO) client-side. Vấn đề: flash of original content trước khi JS rewrite. CLS (Cumulative Layout Shift) tệ.
Edge variant inject: Worker decide variant before HTML stream to client, inject CSS class/data attribute, client side render variant ngay từ đầu.
function pickVariant(request) {
// Sticky variant qua cookie, fallback random
const cookie = request.headers.get("cookie") || "";
const match = cookie.match(/ab_test=(\w+)/);
if (match) return match[1];
// 50/50 split deterministic theo IP+UA hash
const seed = (request.headers.get("cf-connecting-ip") || "") +
(request.headers.get("user-agent") || "");
const hash = [...seed].reduce((a, c) => a + c.charCodeAt(0), 0);
return hash % 2 === 0 ? "control" : "variant-a";
}
export default {
async fetch(request) {
const variant = pickVariant(request);
const response = await fetch(request);
const transformed = new HTMLRewriter()
.on("html", {
element(el) {
el.setAttribute("data-experiment", "checkout-redesign");
el.setAttribute("data-variant", variant);
},
})
.on("body", {
element(el) {
// Class cho CSS scope
const existing = el.getAttribute("class") || "";
el.setAttribute("class", `${existing} exp-${variant}`.trim());
},
})
.transform(response);
// Sticky variant 30 ngày
const out = new Response(transformed.body, transformed);
if (!request.headers.get("cookie")?.includes("ab_test=")) {
out.headers.append(
"Set-Cookie",
`ab_test=${variant}; Path=/; Max-Age=2592000; SameSite=Lax`,
);
}
return out;
},
};
CSS có sẵn từ build:
/* Default: control variant */
.cta-button { background: blue; }
/* Variant A */
.exp-variant-a .cta-button { background: orange; padding: 1.5rem; }
Zero flash, zero CLS, zero client JS needed cho variant assignment. Variant pick stable qua cookie 30 ngày — cùng user thấy cùng variant. Analytics có thể đọc data-variant attribute để correlate conversion.
Performance thực
Tôi đo trên Workers production traffic (~500 req/min average, 99 percentile body size ~80KB HTML):
| Operation | p50 added latency | p99 added latency |
|---|---|---|
Pass-through (fetch without rewriter) | 0ms | 0ms |
1 handler (script[src]) | 1.2ms | 3.8ms |
| 3 handler chain (CSP + analytics + AB) | 2.8ms | 6.5ms |
| 10 handler chain (kitchen sink) | 4.5ms | 11ms |
So sánh với cheerio approach trên Node.js Worker (Vercel Edge nếu dùng Node compat):
| Operation | p50 | p99 |
|---|---|---|
| cheerio parse + modify | 35ms | 120ms |
| jsdom (full DOM) | 180ms | 600ms+ (OOM) |
50-100× faster, không có nguy cơ OOM. Đây là lý do tôi nói lol-html “không cùng league” với mainstream parser.
Giới hạn — đừng vượt qua
1. Không lookahead. Rewriter không biết tương lai. Pattern “đếm <h2> rồi inject TOC ở đầu” không work — khi handler gặp </body>, body đã stream xong. Giải pháp: parse trước upstream (cache result), hoặc dùng JS client-side cho TOC.
2. Không cross-element state. Mỗi handler invocation độc lập. Muốn rewrite “first paragraph after H1” cần state tracking qua closure — hoạt động nhưng dễ bug:
let sawH1 = false;
let rewroteFirstP = false;
new HTMLRewriter()
.on("h1", { element() { sawH1 = true; rewroteFirstP = false; } })
.on("p", {
element(el) {
if (sawH1 && !rewroteFirstP) {
el.before('<div class="lead">First paragraph below</div>', { html: true });
rewroteFirstP = true;
}
},
});
3. CSS selector subset. Không có :nth-child(2n+1), :not(.foo), ~. Test selector trên dev console không guarantee work với lol-html. Docs Workers list rõ supported selectors.
4. HTML chỉ — không XHTML, không XML. lol-html parse theo HTML5 spec WHATWG. Self-closing tag rules khác XML. Test với <br/> vs <br> để confirm hành vi.
5. Encoding. Document phải UTF-8. Latin-1 hoặc legacy encoding cần convert upstream. lol-html không sniff <meta charset> — nó assume UTF-8.
Workers limit cần biết
- CPU time per request: 50ms free, lên 30 giây paid plan (
unboundmodel). Rewriter 1MB tốn ~5-10ms — đủ buffer. - Memory: 128MB per isolate. lol-html không bao giờ vượt 1MB. Vấn đề thường là origin response cache, không phải rewriter.
- Streaming response timeout: Workers tự handle stream tới client. Không cần
awaitbody — rewriter pipe trực tiếp.
Native Rust use case
Nếu bạn không trên Workers — Rust binary với lol_html crate:
use lol_html::{element, HtmlRewriter, Settings};
fn rewrite(input: &str) -> Result<String, Box<dyn std::error::Error>> {
let mut output = vec![];
{
let mut rewriter = HtmlRewriter::new(
Settings {
element_content_handlers: vec![element!("a[href]", |el| {
let href = el.get_attribute("href").unwrap();
if href.starts_with("http://") {
el.set_attribute("href", &href.replace("http://", "https://"))?;
}
Ok(())
})],
..Settings::default()
},
|c: &[u8]| output.extend_from_slice(c),
);
rewriter.write(input.as_bytes())?;
rewriter.end()?;
}
Ok(String::from_utf8(output)?)
}
Use case native: CDN custom server (Pingora + lol-html), build pipeline rewrite (static site post-processing), email HTML sanitization service.
Bottom line
lol-html cho phép HTML rewriting ở edge với memory profile mà DOM parser không bao giờ đạt được. Pattern CSP nonce, analytics proxy, A/B inject là 3 use case tôi đang chạy production — đều dưới 5ms added latency, zero infrastructure ngoài Workers. Nếu bạn đang serve HTML qua Workers/Pages và chưa explore HTMLRewriter, đó là cost saving lớn (không cần origin compute) và security win (CSP nonce per request không hash trick). Trade-off: streaming nature không cho lookahead — design transformation phải single-pass.
Checklist trước production
- CSP nonce dùng
crypto.randomUUID()hoặcgetRandomValues(), khôngMath.random() - CSS selector test với fixture HTML matching production output
- Handler không throw — try/catch trong handler, log error nhưng không break stream
- Test với
Content-Encoding: gziptừ origin (Workers tự decode trước rewriter) - Set
Vary: Cookienếu output depend on cookie (variant) - Test variant assignment sticky 30 ngày (cookie expire test)
- Verify CSP error chỉ block inline script không nonce, không block legit script
- Analytics proxy strip PII headers trước khi forward
- Monitor CPU time per request — alert nếu p99 > 20ms (rewriter chain quá lớn)
- Test với document size từ 1KB đến 5MB, verify TTFB không tăng
- Handle non-HTML response (JSON API endpoint) — skip rewriter qua content-type check
- Cache control header: nếu modify per-user, set
Cache-Control: private - Document selector và handler trong code comment — selector implicit dependency
Cạm bẫy thường gặp
1. Apply rewriter cho response không phải HTML. JSON, XML, image qua rewriter = corrupt output. Check content-type.startsWith("text/html") trước.
2. Selector body không match nếu HTML không có <body> tag explicit. HTML parser thêm body tag ngầm — lol-html không vì streaming. Một số fragment template không có <body>.
3. el.setInnerContent("...") thay vì el.append. setInnerContent REPLACE existing children. Vô tình xóa nội dung.
4. State trong closure không thread-safe. Workers isolate single-thread nên OK, nhưng pattern này không portable sang Node.js multi-thread.
5. Quên { html: true } khi inject HTML. el.before("<div>x</div>") mặc định escape → output là <div>. Phải { html: true }.
6. Cookie Set-Cookie không append đúng. Workers Response.headers.set() overwrite, dùng .append(). Multiple Set-Cookie là valid.