wandres.dev
REQUEST Y RESPONSE · streaming en el edge

HTMLRewriter: transformar HTML en streaming con element handlers

Para cambiar una página no hace falta cargarla entera, construir un DOM, mutarlo y volver a serializarlo. HTMLRewriter transforma el HTML según fluye por el Worker: declaras handlers por selector CSS y él los invoca sobre cada elemento que coincide mientras los bytes pasan. Inyectar una etiqueta, reescribir todos los enlaces o extraer datos, con una huella de memoria casi constante y sin parsear el documento completo.

⏱ 18 min

La forma clásica de modificar una página es cargarla entera, parsearla en un árbol DOM, mutar los nodos y volver a serializarla a texto. Eso exige tener todo el documento en memoria y pagar dos travesías completas. HTMLRewriter propone lo contrario: transforma el HTML mientras atraviesa el Worker como un flujo. Tú declaras handlers asociados a selectores CSS, y el motor los llama sobre cada elemento que coincide justo cuando ese fragmento pasa por delante. Inyectar una etiqueta, reescribir cada enlace o extraer un dato dejan de requerir el árbol completo: se resuelven al vuelo, con una huella de memoria casi constante por grande que sea la página.

🎯 Al terminar esta lección sabrás
  • Entender que HTMLRewriter transforma HTML en streaming, sin construir el documento entero en memoria.
  • Registrar handlers por selector CSS siguiendo el patrón element handler.
  • Inyectar, reescribir y eliminar elementos y atributos sobre la marcha.
  • Extraer datos del HTML sin bloquear ni bufferizar la respuesta.

Transformar sin parsear el documento entero

Una librería tipo DOM en el servidor carga el HTML completo, levanta un árbol de nodos y te deja consultarlo y mutarlo con acceso aleatorio. Es cómodo, pero cuesta memoria proporcional al tamaño de la página y obliga a tener el documento entero antes de tocar nada; en un isolate con 128 MB compartidos, esa cuenta se vuelve un lujo peligroso en cuanto las páginas crecen. HTMLRewriter funciona como un analizador en streaming: dispara callbacks a medida que los tokens del HTML pasan, sin materializar el árbol.

Lo usas envolviendo una Response. Registras handlers con on, indicando un selector CSS, y llamas a transform sobre la respuesta del origen; recibes una Response nueva cuyo cuerpo es el flujo ya transformado.

export default {
  async fetch(request: Request): Promise<Response> {
    const original = await fetch("https://ejemplo.com");
    return new HTMLRewriter()
      .on("a[href]", new ReescribirEnlaces())
      .transform(original);
  },
};

El objeto HTMLRewriter es reutilizable y encadenable: cada on devuelve el propio rewriter, así que registras cuantos handlers necesites antes de llamar a transform. Y transform no ejecuta nada de inmediato; solo conecta la maquinaria, que irá disparando los handlers conforme el cuerpo de la respuesta se consuma aguas abajo.

Como la transformación es en streaming, la memoria se mantiene plana tanto si la página pesa diez kilobytes como diez megabytes: los bytes entran, pasan por los handlers y salen, sin quedarse. Y como el resultado es a su vez un flujo, encaja con todo lo demás: puedes transformar la respuesta de un fetch a un origen, la de un objeto servido desde R2 o la salida de otro Worker, porque todas son una Response con un cuerpo que fluye.

El patrón element handler

Cada on recibe un selector y un objeto handler. Si ese objeto tiene un método element, el motor lo llama una vez por cada elemento que casa con el selector, pasándole un Element sobre el que puedes operar: getAttribute, setAttribute, removeAttribute, setInnerContent, append, prepend, before, after o remove. Ese es el patrón element handler: una clase pequeña por cada transformación.

class ReescribirEnlaces {
  element(el: Element) {
    const href = el.getAttribute("href");
    if (href?.startsWith("http://")) {
      // fuerza https en cada enlace que pasa
      el.setAttribute("href", href.replace("http://", "https://"));
    }
  }
}

El handler ve un elemento cada vez y nunca sostiene el árbol completo: no puede preguntar por el hermano siguiente ni por un ancestro lejano, porque quizá aún no han llegado o ya pasaron. Esa restricción es el precio del streaming, y a cambio te da un coste en memoria independiente del tamaño del documento. Por eso los handlers se escriben como clases pequeñas con estado propio: cada una encapsula una transformación y, si necesita recordar algo entre llamadas —un contador, un búfer de texto—, lo guarda en sus campos, nunca en el documento.

flowchart LR
O[HTML del origen] --> HR[HTMLRewriter]
HR -->|coincide a href| H1[Reescribir enlace]
HR -->|coincide head| H2[Inyectar tag]
H1 --> OUT[HTML transformado en streaming]
H2 --> OUT

Inyectar, reescribir y extraer

Tres arquetipos cubren casi todo. Inyectar: añadir un nodo donde interese, como una etiqueta en la cabecera. Reescribir: cambiar atributos o contenido, como forzar https o proxiar imágenes. Extraer: leer un valor del HTML hacia una variable externa sin alterar la salida. Para inyectar HTML crudo, pasa la opción html: true; sin ella, el contenido se escapa como texto.

class InyectarEnHead {
  element(el: Element) {
    el.append(`<link rel="preconnect" href="https://cdn.example.com">`, {
      html: true,
    });
  }
}

// componer varios handlers en una sola pasada de streaming
new HTMLRewriter()
  .on("head", new InyectarEnHead())
  .on("a[href]", new ReescribirEnlaces())
  .transform(original);

Extraer datos tiene un matiz importante: el texto de un elemento no llega de una vez, sino en varios trozos, y tu handler de tipo text puede ser invocado varias veces por un mismo nodo. Para reconstruir un valor completo debes acumular esos trozos y actuar cuando el fragmento marque el final con su propiedad lastInTextNode.

class ExtraerTitulo {
  private buffer = "";
  text(trozo: Text) {
    this.buffer += trozo.text;         // llega en varios trozos
    if (trozo.lastInTextNode) {
      console.log("titulo completo:", this.buffer);
      this.buffer = "";
    }
  }
}

// el handler text se registra igual que element, por selector
new HTMLRewriter().on("title", new ExtraerTitulo()).transform(original);

Reescribir sigue el mismo molde con element. Un caso típico es proxiar imágenes: reencaminas cada src para que pase por tu dominio en vez de servirse del origen. Y eliminar es igual de directo con remove, que descarta el elemento y su contenido según pasa —útil para quitar script de terceros que no deben llegar al cliente.

class ProxiarImagenes {
  element(el: Element) {
    const src = el.getAttribute("src");
    if (src) el.setAttribute("src", `/img?u=${encodeURIComponent(src)}`);
  }
}

class QuitarScripts {
  element(el: Element) {
    el.remove();
  }
}

Un mismo HTMLRewriter puede llevar los tres a la vez —extraer el título, proxiar imágenes y quitar scripts— y resolverlos en una sola pasada, porque los selectores se evalúan sobre el mismo flujo y no en recorridos sucesivos.

💡
El texto llega en trozos, no de golpe

Si registras un handler text para capturar el título o un precio, no supongas que lo recibes entero en la primera llamada. Concatena cada trozo en un búfer y considéralo cerrado solo cuando lastInTextNode es verdadero. Olvidar esto produce el bug clásico de extraer cadenas partidas por la mitad en páginas grandes.

Los límites del modelo en streaming

Trabajar en un flujo impone reglas que no existen en un DOM. No hay acceso aleatorio: no puedes consultar un elemento que ya pasó ni uno que aún no ha llegado, así que toda decisión es local al elemento actual. El orden importa: los handlers se aplican en el orden en que registras los on, y sobre el HTML según fluye, de arriba abajo. Y como todo ocurre sobre la marcha, una transformación no puede depender del documento entero: si necesitas eso, HTMLRewriter no es la herramienta.

ℹ️
Mas alla de element hay onDocument

Además de los handlers por selector, onDocument observa el documento como un todo secuencial: el doctype, los comentarios, los nodos de texto sueltos y el evento end, que se dispara al cerrarse el flujo. Es el sitio natural para añadir algo justo al final de la página o para reaccionar cuando ya ha pasado todo, sin romper el modelo de una sola travesía.

A cambio, ganas propiedades que un DOM no puede darte. La memoria no crece con la página, porque nunca la tienes entera. La latencia es mínima, porque la salida empieza a fluir en cuanto pasa el primer fragmento, sin esperar al cierre del documento. Y encadenar varias transformaciones no cuesta pasadas extra: todos los handlers viven en la misma travesía única del flujo. Esa combinación —memoria plana, primer byte temprano y composición sin coste— es justo lo que el edge necesita para reescribir páginas a escala sin convertirse en un cuello de botella.

Renunciar a ver el todo para poder tocarlo mientras pasa

HTMLRewriter es la culminación natural de todo lo que este nivel te ha ido enseñando, y su lección es casi filosófica. Estás acostumbrado a que transformar algo empiece por poseerlo entero: cargas el documento, lo tienes delante, lo miras desde arriba y lo mutas con la tranquilidad de quien ve el mapa completo. Ese poder tiene un precio que en el edge no puedes pagar, porque poseer el todo significa alojarlo en memoria y esperar a tenerlo antes de actuar. El streaming te ofrece un trato radicalmente distinto: renuncias a ver el documento completo, aceptas que solo conoces el fragmento que tienes delante en este instante, y a cambio recibes la capacidad de tocarlo mientras pasa, con un coste que no depende de su tamaño. Es la misma renuncia que hiciste al leer el cuerpo de la petición como un flujo de un solo pase, y al emitir la respuesta por trozos en vez de fabricarla entera; HTMLRewriter solo la lleva a su forma más pura, aplicándola a la estructura más rica que manejas, el árbol de un HTML. Perder el acceso aleatorio no es una mutilación, es el intercambio consciente que define el oficio en el edge: no eres el dueño del documento que reposa en tu máquina, eres el punto por el que el documento pasa, y tu arte consiste en decidir qué hacer con cada elemento en el breve instante en que está frente a ti y antes de que siga su camino. Quien piensa en árboles pregunta cómo reorganizar el todo; quien piensa en flujos pregunta qué hacer con esto que pasa ahora. Cuando ese segundo modo se vuelve tu instinto, dejas de arrastrar el peso del documento y empiezas a moverte a su velocidad.

⚔️ Toca el HTML mientras fluye
  1. Proxia una página con fetch y reescribe cada enlace a[href] de http a https con un element handler.
  2. Inyecta una etiqueta link de preconnect dentro de head con append y la opción html: true.
  3. Elimina todos los script de un origen con remove y comprueba que la página sigue sirviéndose sin ellos.
  4. Extrae el texto del title acumulando trozos y cerrando con lastInTextNode, y devuélvelo en una cabecera de respuesta.
  5. Explica por qué HTMLRewriter mantiene la memoria plana en una página enorme y qué renuncia haces respecto a una librería DOM tradicional.