Streaming: SSE, WebSockets y multipart

Por: Artiko
htmxhtmx4ssewebsocketsstreamingtiempo-real

Streaming: SSE, WebSockets y multipart

Que htmx 4 use fetch() en el core no es un detalle de implementación: es lo que hace posible el streaming. Una respuesta ya no es “un cuerpo que llega entero”, sino un ReadableStream que htmx puede ir procesando conforme llega.

flowchart TD
    A[fetch en el core] --> B[ReadableStream]
    B --> C["hx-sse<br/>text/event-stream"]
    B --> D["hx-multipart<br/>multipart/mixed"]
    A --> E["hx-ws<br/>WebSocket bidireccional"]

Elegir el mecanismo

NecesidadMecanismo
El servidor empuja actualizaciones, el cliente sólo escuchahx-sse
Comunicación bidireccional (chat, colaboración)hx-ws
Una respuesta que llega por partes (informe largo, progreso)hx-multipart
Actualizaciones poco frecuentes y simplesPolling con hx-trigger="every Ns"

Regla práctica: empieza con polling. Si el intervalo baja de 2 segundos o la carga se nota, pasa a SSE. Reserva WebSockets para lo que realmente necesita el canal de vuelta.

hx-sse: Server-Sent Events

<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/htmx.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/ext/hx-sse.min.js"></script>
<meta name="htmx-config" content='extensions:"hx-sse"'>

En htmx 4 la extensión abandona EventSource y usa fetch() con ReadableStream. Esto significa que una respuesta SSE puede venir de cualquier método HTTP, con los valores y headers habituales de htmx: puedes hacer un POST con parámetros y recibir un stream de vuelta, algo imposible con EventSource.

Respuesta streameada en una petición normal

El caso más simple no requiere ningún atributo nuevo. Si la respuesta llega con Content-Type: text/event-stream, htmx procesa cada evento a medida que llega:

<button hx-get="/ping">Ping</button>
data: Pong

El botón muestra “Pong”. Y si el servidor manda varios eventos:

data: P
data: Po
data: Pon
data: Pong

El contenido se actualiza con cada uno. Este es el mecanismo detrás del efecto “escribiendo” de las respuestas de un LLM, sin una línea de JavaScript:

// GET /generar — respuesta incremental
Bun.serve({
  port: 3000,
  fetch(req) {
    const { pathname } = new URL(req.url);
    if (pathname !== "/generar") return new Response(Bun.file("./index.html"));

    const stream = new ReadableStream({
      async start(controller) {
        let texto = "";
        for await (const token of generarTokens()) {
          texto += token;
          controller.enqueue(`data: <p>${escapar(texto)}</p>\n\n`);
        }
        controller.close();
      },
    });

    return new Response(stream, {
      headers: {
        "Content-Type": "text/event-stream",
        "Cache-Control": "no-cache",
      },
    });
  },
});
<button hx-post="/generar"
        hx-vals='{"prompt": "Explícame htmx"}'
        hx-target="#respuesta">
  Generar
</button>
<div id="respuesta"></div>

Conexión persistente: hx-sse:connect

Para escuchar un canal de forma continua:

<div hx-sse:connect="/eventos" hx-target="#feed" hx-swap="beforeend">
  <div id="feed"></div>
</div>

Cada evento que llegue se inserta al final del feed.

Y para cerrar la conexión cuando llegue un evento con nombre concreto:

<div hx-sse:connect="/eventos" hx-sse:close="fin"></div>
event: fin
data: <p>Proceso terminado</p>

Conectar bajo demanda

<button id="conectar">Conectar</button>
<div hx-sse:connect="/eventos" hx-trigger="click from:#conectar"></div>

Barra de progreso real

Un caso donde SSE brilla: una tarea larga que reporta su avance.

// GET /trabajos/:id/progreso
const stream = new ReadableStream({
  async start(controller) {
    for (let p = 0; p <= 100; p += 5) {
      await procesarLote();
      controller.enqueue(
        `data: <progress value="${p}" max="100"></progress> ${p}%\n\n`
      );
    }
    controller.enqueue(`event: fin\ndata: <p class="ok">Completado ✓</p>\n\n`);
    controller.close();
  },
});
<div hx-sse:connect="/trabajos/42/progreso" hx-sse:close="fin"></div>

Reconexión

Los headers Accept y Last-Event-ID permiten negociación de contenido y recuperar los mensajes perdidos durante una desconexión. Si tu servidor emite id: en cada evento, al reconectar recibirá Last-Event-ID y podrá continuar desde ahí:

id: 1042
data: <li>Nuevo pedido #1042</li>

hx-ws: WebSockets

<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/htmx.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/ext/hx-ws.min.js"></script>
<meta name="htmx-config" content='extensions:"hx-ws"'>

Recibir

<div hx-ws:connect="/chat" hx-target="#mensajes" hx-swap="beforeend">
  <div id="mensajes"></div>
</div>

Cuando el servidor envía <p>Hola</p> por el socket, se añade al final de #mensajes.

Enviar

<div hx-ws:connect="/chat">
  <form hx-ws:send hx-target="#mensajes" hx-swap="beforeend">
    <input name="mensaje" autocomplete="off">
    <button>Enviar</button>
  </form>
  <div id="mensajes"></div>
</div>

El mensaje saliente viaja como JSON estructurado con la forma {headers, body}: los metadatos de htmx van separados de los valores del formulario, así que el servidor puede leerlos sin ambigüedad.

Actualizar varias zonas

<div hx-ws:connect="/chat" hx-swap="none"></div>
<div id="feed"><p>Antiguo</p></div>
<span id="conectados">0</span>

El servidor envía HTML con OOB:

<div id="feed" hx-swap-oob="beforeend"><p>Mensaje nuevo</p></div>
<span id="conectados" hx-swap-oob="true">7</span>

O una respuesta JSON con destino explícito:

{
  "content": "<p>Mensaje nuevo</p>",
  "target": "#mensajes",
  "swap": "beforeend"
}

Configuración de reconexión

<meta name="htmx-config" content="ws.reconnect:true ws.reconnectDelay:1s ws.reconnectMaxAttempts:10 ws.pauseOnBackground:true">
OpciónQué hace
reconnectReintenta al caerse la conexión
reconnectDelayEspera entre reintentos
reconnectMaxAttemptsLímite de reintentos
pauseOnBackgroundPausa la conexión con la pestaña en segundo plano

Servidor de chat mínimo con Bun

const clientes = new Set();

Bun.serve({
  port: 3000,
  fetch(req, server) {
    const { pathname } = new URL(req.url);
    if (pathname === "/chat" && server.upgrade(req)) return;
    return new Response(Bun.file("./index.html"));
  },
  websocket: {
    open(ws) {
      clientes.add(ws);
      difundir(`<span id="conectados" hx-swap-oob="true">${clientes.size}</span>`);
    },
    message(ws, raw) {
      const { body } = JSON.parse(raw);
      const texto = escapar(body.mensaje);
      difundir(`<p class="msg">${texto}</p>`);
    },
    close(ws) {
      clientes.delete(ws);
      difundir(`<span id="conectados" hx-swap-oob="true">${clientes.size}</span>`);
    },
  },
});

function difundir(html) {
  for (const c of clientes) c.send(html);
}

El servidor manda HTML, no JSON con datos para que el cliente renderice. Ese es el punto: la plantilla vive en un solo lugar.

sequenceDiagram
    participant A as Cliente A
    participant S as Servidor
    participant B as Cliente B
    A->>S: {headers, body:{mensaje:"Hola"}}
    S->>S: renderiza <p class="msg">Hola</p>
    S-->>A: HTML
    S-->>B: HTML
    Note over A,B: ambos hacen swap beforeend

hx-multipart: respuestas por partes

<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/ext/hx-multipart.js"></script>
<meta name="htmx-config" content='extensions:"hx-multipart"'>

Permite que una única respuesta entregue varias partes, cada una procesada en cuanto llega, usando multipart/mixed o multipart/parallel. El caso de uso: un panel donde cada widget tarda distinto y no quieres esperar al más lento.

Content-Type: multipart/mixed; boundary=parte

--parte
Content-Type: text/html

<hx-partial id="ventas">$ 1.240.000</hx-partial>
--parte
Content-Type: text/html

<hx-partial id="usuarios">1.204 activos</hx-partial>
--parte--

Cada <hx-partial> se aplica en cuanto su parte termina de llegar. Una sola petición, actualización progresiva.

Seguridad en tiempo real

Los canales de tiempo real son endpoints como cualquier otro:

  • Autenticación: SSE con fetch() envía las cookies y headers normales de htmx, incluida la cabecera CSRF heredada. Con WebSockets, valida en el handshake.
  • Autorización por canal: nunca difundas a todos los clientes conectados sin filtrar por permisos.
  • Escapado: todo lo que venga de un usuario y vuelva como HTML tiene que ir escapado. Es el mismo XSS de siempre, pero con más caminos de entrada.
  • Límite de conexiones: un cliente malicioso puede abrir cientos de conexiones SSE.

Errores comunes

SíntomaCausaSolución
El stream llega de golpe al finalUn proxy hace bufferingX-Accel-Buffering: no en nginx
Nada llega por SSEFalta Content-Type: text/event-streamCorregir el header
Los eventos no se separanFalta la línea en blanco finalCada evento termina con \n\n
El WebSocket se cae cada minutoTimeout del proxyConfigurar keepalive o enviar pings
El HTML del chat rompe la páginaNo se escapó la entradaEscapar siempre
La extensión no cargaFalta en el allowlistextensions:"hx-ws" en la meta

Resumen

  • htmx 4 procesa respuestas como streams gracias a fetch().
  • hx-sse funciona con cualquier método HTTP, no sólo GET como EventSource.
  • Una respuesta con Content-Type: text/event-stream se procesa incrementalmente sin atributos extra: es la base del efecto “escribiendo”.
  • hx-sse:connect y hx-sse:close manejan conexiones persistentes.
  • hx-ws:connect y hx-ws:send dan comunicación bidireccional, con HTML en ambos sentidos.
  • hx-multipart entrega una respuesta en varias partes procesadas conforme llegan.
  • Empieza con polling; sube a SSE cuando lo necesites; usa WebSockets sólo si hay canal de vuelta.

Siguiente: Catálogo de extensiones →.