lol-html: streaming HTML rewriter trên Workers — 3 production patterns

CSS-selector streaming HTML rewriter trên Cloudflare Workers. 3 pattern production: CSP nonce per request, rewrite analytics URL, A/B variant inject.

· 7 phút đọc

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 HTMLRewriter API 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 đề đó.
  • HTMLRewriter Workers API có 2 handler type: element (match qua CSS selector, callback nhận Element) 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:

  1. Worker đọc full body vào memory (500KB)
  2. Parse thành DOM tree (cheerio: ~3MB, jsdom: ~30MB)
  3. Modify DOM
  4. Serialize lại thành string (500KB)
  5. 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:

  1. Worker pipe response body qua rewriter
  2. Mỗi chunk 4-8KB tới → parser advance state → emit modified chunk → flush ra client
  3. 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>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 đề:

  1. EU compliance (GDPR/ePrivacy) — third-party script set cookie cross-domain
  2. AdBlocker block trực tiếp domain googletagmanager.com → analytics fail
  3. Latency — connect tới googletagmanager.com từ 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:

  1. AdBlocker bypass — first-party domain
  2. 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):

Operationp50 added latencyp99 added latency
Pass-through (fetch without rewriter)0ms0ms
1 handler (script[src])1.2ms3.8ms
3 handler chain (CSP + analytics + AB)2.8ms6.5ms
10 handler chain (kitchen sink)4.5ms11ms

So sánh với cheerio approach trên Node.js Worker (Vercel Edge nếu dùng Node compat):

Operationp50p99
cheerio parse + modify35ms120ms
jsdom (full DOM)180ms600ms+ (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 (unbound model). 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 await body — 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ặc getRandomValues(), không Math.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: gzip từ origin (Workers tự decode trước rewriter)
  • Set Vary: Cookie nế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à &lt;div&gt;. 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.

Tham chiếu