Streaming: SSE, WebSockets y multipart
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
| Necesidad | Mecanismo |
|---|---|
| El servidor empuja actualizaciones, el cliente sólo escucha | hx-sse |
| Comunicación bidireccional (chat, colaboración) | hx-ws |
| Una respuesta que llega por partes (informe largo, progreso) | hx-multipart |
| Actualizaciones poco frecuentes y simples | Polling 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ón | Qué hace |
|---|---|
reconnect | Reintenta al caerse la conexión |
reconnectDelay | Espera entre reintentos |
reconnectMaxAttempts | Límite de reintentos |
pauseOnBackground | Pausa 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íntoma | Causa | Solución |
|---|---|---|
| El stream llega de golpe al final | Un proxy hace buffering | X-Accel-Buffering: no en nginx |
| Nada llega por SSE | Falta Content-Type: text/event-stream | Corregir el header |
| Los eventos no se separan | Falta la línea en blanco final | Cada evento termina con \n\n |
| El WebSocket se cae cada minuto | Timeout del proxy | Configurar keepalive o enviar pings |
| El HTML del chat rompe la página | No se escapó la entrada | Escapar siempre |
| La extensión no carga | Falta en el allowlist | extensions:"hx-ws" en la meta |
Resumen
- htmx 4 procesa respuestas como streams gracias a
fetch(). hx-ssefunciona con cualquier método HTTP, no sólo GET comoEventSource.- Una respuesta con
Content-Type: text/event-streamse procesa incrementalmente sin atributos extra: es la base del efecto “escribiendo”. hx-sse:connectyhx-sse:closemanejan conexiones persistentes.hx-ws:connectyhx-ws:senddan comunicación bidireccional, con HTML en ambos sentidos.hx-multipartentrega 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 →.