Seleccionar página
En esta charla, organizada por MadridJS, Pablo Almunia explica cómo la mayoría de nosotros cuando oímos hablar por primera vez de HTTP/2 nos ilusionamos con las posibilidades que presumiblemente se abrían para el desarrollo de soluciones web avanzadas. Muchos nos sentimos defraudados con lo que realmente se podía implementar.

En esta se explora cómo funciona el HTTP/2, que debemos tener en cuenta en el servidor para hacer uso de este protocolo y, sobre todo, cómo podemos enviar información desde el servidor al cliente de forma efectiva y fácil. Veremos con detenimiento cómo por medio de los Server Sent Events (SSE) podemos recibir en el cliente datos enviados desde el servidor sin utilizar WebSocket (WS), simplificando la construcción de aplicaciones con comunicación bidireccional.

Más detenidamente, vamos a intentar dar respuesta a estas preguntas:

  • ¿Qué es HTTP?
  • ¿Qué es HTTP/2?
  • ¿Qué ventajas tiene HTTP/2?
  • ¿Qué es y que ha pasado con el Server Push de HTTP/2?
  • ¿Qué nos ofrece HTTP/2 y Server Sent Events para esta comunicación bidireccional?
  • ¿Cómo implementar un servidor HTTP/2 y Server Sent Events en Node, Express y Fastify?
  • ¿Cuáles son las diferencias entre HTTP2 y WebSocket? ¿Cuándo utilizar uno u otro?
  • ¿Qué nos deparará el futuro?

El código completo de los ejemplos está publicado en https://github.com/pabloalmunia/sse/.

HTTP

¿Qué es?

El Hypertext Transfer Protocol (protocolo de transferencia de hipertexto), abreviado HTTP, es el protocolo de comunicación que permite las transferencias de información y archivos en la web. Desarrollado por Tim Berners-Lee y su equipo entre 1989 y 1991, es tan omnipresente que nos olvidamos de él y su importancia.

Es un protocolo orientado a mensajes que sigue el esquema petición-respuesta entre un cliente y un servidor. El cliente realiza una petición enviando un mensaje (request). El servidor le envía al cliente un mensaje de respuesta (response).

Los mensajes de solicitud (request) están compuestos de un método (GET, POST, PUT, DELETE, etc…), la referencia a un recurso (URL), unas cabeceras (headers) y, en algunos casos, un cuerpo (body).

Los mensajes de respuesta (response) están compuestos de un código de respuesta (200, 404, 500, etc.), una descripción del código de respuesta, unas cabeceras (headers) y un cuerpo (body).

Versiones del HTTP

El HTTP ha sufrido muchos cambios en su historia. Alguna de las más importantes han sido:

HTTP/0.9 – El protocolo de una línea (1991)

La versión inicial de HTTP no tenía número de versión; más tarde se llamó 0.9 para diferenciarla de las versiones posteriores. HTTP/0.9 era extremadamente simple: las peticiones consistían en una sola línea y comenzaban con el único método posible, GET, seguido de la ruta al recurso. No se incluía la URL completa, ya que el protocolo, el servidor y el puerto no eran necesarios una vez conectado al servidor. A diferencia de las evoluciones posteriores, no había cabeceras HTTP. Esto significaba que únicamente se podían transmitir archivos HTML. No había códigos de estado ni de error. Si había algún problema, se generaba un código HTML específico que incluía una descripción del problema.

HTTP/1.0 – Mejora de funcionalidad (1996)

Los navegadores y servidores web fueron evolucionando a partir de esta versión inicial. Se introdujo el número de versión en las peticiones, los códigos de respuesta, las cabeceras, que incluían el content-type parar describir el tipo de contenido que se estaba transmitiendo. A pesar de todo ello, los problemas de interoperabilidad eran
frecuentes y ciertos navegadores tenían problemas con ciertos servidores.

HTTP/1.1 – Clarificación del estándar (1999)

Para resolver los problemas y confusiones en el protocolo, apareció HTTP/1.1, que se ha convertido el estándar que todos usamos cada día. Entre las mejoras que se incluyeron en esta versión está la de mantener a sesión abierta parar mejorar el rendimiento. Se añadió el pipelining que permite enviar una segunda solicitud antes de que la respuesta a la primera se hubiera transmitido por completo. Se introdujeron mecanismos adicionales de control de caché. Se introdujo la negociación de contenidos, incluidos el idioma, la codificación y el tipo. Gracias a la cabecera Host, se permitió alojar con facilidad diferentes dominios desde la misma dirección IP.

Referencias:

HTTP/2 – Mejora del rendimiento (2015)

Después de mucho tiempo utilizando la versión HTTP/1.1, y con el objetivo de hacer frente a la creciente complejidad y exigencia en las llamadas entre los clientes y servidores web, Google implementó en 2010 un protocolo experimental denominado SPDY. Este nuevo protocolo demostró una significativa mejora en la capacidad de respuesta y sirvió de base para el protocolo HTTP/2, que vio la luz en 2015 como un nuevo estándar.

Nuevas funcionalidades

El protocolo HTTP/2 mejora el HTTP/1.1 en algunos aspectos:

  • Es un protocolo binario en lugar de un protocolo de texto. No puede leerse ni crearse manualmente.
  • Es un protocolo multiplexado. Se pueden hacer peticiones paralelas a través de la misma conexión.
  • Comprime las cabeceras. Como estas suelen ser similares entre un conjunto de peticiones, se elimina la duplicación y la sobrecarga de los datos transmitidos.
  • Permite a un servidor rellenar datos en la caché de un cliente a través de un mecanismo llamado server push.

Uso de HTTP/2

Se ha producido una rápida adopción se ha debido a que HTTP/2 no requería cambios en los sitios web y las aplicaciones. Para utilizarlo, solo es necesario un servidor que soporte esta versión del protocolo, manteniendo compatibilidad con las versiones anteriores y no exigiendo cambio alguno en las aplicaciones y las API ya desarrolladas.

El uso de HTTP/2 tuvo su máximo en enero de 2022 con 46,9% de todos los sitios web. En diciembre de este año ha bajado al 40,3%. Este leve descenso no debe preocuparnos.

Referencias:

Implementando un servidor HTTP/2 en Node

Es siempre esclarecedor implementar, aunque sea de forma básica, un servidor con la librería estándar de Node. De esta manera podemos comprobar de primera mano algunos detalles que cambian entre HTTP/1.1 y HTTP/2.

Utilizar el paquete http2 en vez de http

Lo primero que tenemos que tener en cuenta es que debemos utilizar la librería http2 de Node en vez de la librería http. Este parece un cambio evidente y sencillo:

import http2 from 'node:http2';

Usar certificados SSL

Aunque estrictamente el protocolo HTTP/2 no requiere el uso de una conexión cifrada, los navegadores exigen que esta se realice siempre por SSL, por ello en la mayoría de las ocasiones deberemos utilizar unos certificados si queremos que todo funcione correctamente. Si nuestro sistema solo va a gestionar conexiones en una red privada entre servidores, podríamos obviar este paso.

En nuestro entorno de desarrollo podemos crear un certificado autofirmado para localhost con una llamada de este tipo:

openssl req -x509 -newkey rsa:2048 -nodes -sha256 -subj '/CN=localhost' -keyout localhost.key -out localhost.cert

Esta llamada va a generar dos ficheros, que tenemos que poner en algún sitio accesible a nuestro servidor para que podamos que leerlos, en nuestro caso, por medio de la librería fs de node.

import http2 from 'node:http2';
import fs    from 'node:fs';

const server = http2.createSecureServer({
  key : fs.readFileSync('localhost.key'),
  cert: fs.readFileSync('localhost.cert')
});

Leer del stream

Aquí nos encontramos con el primer gran cambio. En un servidor http todo gira al rededor de una request y una response, por ello, cuando creamos un servidor con http en Node pasamos un callback con dos parámetros donde recibimos cada uno de estos dos objetos:

import http from 'node:http';

const server = http.createServer((request, response) => {
  if (request.method === 'GET' && request.url === '/test') {
    response.writeHead(200, {'Content-Type': 'text/plain'});
    response.write('http server ok');
    response.end();
  }
});
server.listen(8000);

En cambio, en un servidor http2 todo se gestiona alrededor de un stream, es decir, de un flujo de datos.

import http2 from 'node:http2';
import fs    from 'node:fs';

const server = http2.createSecureServer({
  key : fs.readFileSync('localhost.key'),
  cert: fs.readFileSync('localhost.cert')
});

server.on('stream', (stream, headers) => {

});

server.listen(8001);

Una anotación, Node ofrece una API con compatibilidad con request y response para hacer más sencilla la transición, encargándose internamente de hacer los cambios entre un modelo y otro.

En HTTP/2 todo son cabeceras (o casi)

A diferencia de HTTP/1.1, el método de la llamada está incluido en una cabecera, en este caso :method, la URL a la que estamos invocando también está en una cabecera :path, el código de respuesta también lo incluiremos en una cabecera :status.

Por supuesto, también existe body, pero el resto de elementos se van a incluir como cabeceras.

Algunas cambian también de nombre, como host que ahora es :authority.

Por todo ello, para el ejemplo anterior tenemos que hacer algo de este tipo:

import http2 from 'node:http2';
import fs    from 'node:fs';

const server = http2.createSecureServer({
  key : fs.readFileSync('localhost.key'),
  cert: fs.readFileSync('localhost.cert'),
});

server.on('stream', (stream, headers) => {
  if (headers[':method'] === 'GET' && headers[':path'] === '/test') {
    stream.respond({
      'Content-Type': 'text/plain',
      ':status'     : 200
    });
    stream.write(`http2 server ok`);
    stream.end();
  }
});

server.listen(8001);

Trasparente

Todo esto es muy interesante y educativo, pero si usamos paquetes como Express, Fastify o la API del paquete http2 de Node compatible con el modelo response y request todo esto es absolutamente transparente para nosotros. En general, no nos vamos a dar cuenta de que estamos trabajando sobre HTTP/2 y podemos adaptarnos a este protocolo con facilidad. Son otros los que se ocupan de adaptar las nuevas peculiaridades a lo que ya conocemos, pero tampoco vamos a aprovechar realmente todas sus capacidades.

Ejemplos:

Referencias

Server Push

Como hemos visto hace un momento, una de las funcionalidades de HTTP/2 es permitir a un servidor enviar datos a un cliente a través de un mecanismo llamado Server Push. Esta funcionalidad se describió como la posibilidad de enviar información desde el servidor al cliente sin que este lo hubiera solicitado, lo que nos emocionó a muchos. Por fin se iba a poder hacer una comunicación desde el servidor al cliente sin una solicitud previa del cliente. Parecía que se abría una nueva forma de hacer una comunicación bidireccional.

La realidad es un poco diferente, la funcionalidad de Server Push lo que permite es nutrir la caché del navegador con contenido disponible para un futuro acceso, mejorando la velocidad de respuesta al usuario. No parece una mala funcionalidad, pero no es lo que algunos habíamos creído que era.

Implementar server push en Node

Es bastante sencillo, solo hay que crear un nuevo stream cuando se hace la petición inicial, indicar que recurso vamos a enviar a la caché del navegador y enviar el contenido en ese nuevo stream. Básicamente, es algo como esto:

function push (stream, headers, file) {
  stream.pushStream({':path': `/${file}`}, (error, pushStream) => {
    if (error) throw error;
    pushStream.respondWithFile(
      file,
      {
        ':status'      : 200,
        'content-type' : 'text/html',
        'Cache-Control': 'max-age=3600'
      }
    );
  });
}

server.on('stream', (stream, headers) => {
  if (headers[':method'] === 'GET' && headers[':path'] === '/test') {
    stream.respond({
      ':status'     : 200,
      'Content-Type': 'text/html; charset=utf-8'
    });
    push(stream, headers, '04-push.html');
    stream.write(fs.readFileSync('04-index.html'));
  } else {
    stream.respond({':status': 404});
  }
  stream.end();
});

En este ejemplo, cuando nos piden /test, además de enviar su contenido, también enviamos el fichero 04-push.html, de esta forma estará disponible cuando naveguemos hasta esta nueva página.

No “todos” los navegadores lo soportan (ya)

Algo importante es que no todos los navegadores que soporta HTTP/2 aceptan este tipo de llamadas, y lo informan enviando una cabecera interna en la negociación con SETTINGS_ENABLE_PUSH = 0. Es decir, soporta HTTP/2, pero la funcionalidad de Server Push.

Desde el punto de vista de Node, tendríamos que comprobar de una forma similar a esta:

function push (stream, headers, file) {
  if (!stream.pushAllowed) {
    console.log(`this user-agent don't accept push: ${headers['user-agent']}`)
    return;
  }
  //...
}

Lo más triste es que Chrome (y con él Edge) han retirado el soporte a esta funcionalidad, por lo que en la práctica, no nos sirve de prácticamente para nada. Una de las funcionalidades más interesantes que nos ofrecía HTTP/2 no podemos utilizarla en los navegadores más extendidos.

La alternativa al Server Push es el Prefetch, pero eso lo explicaremos -si hay interés- en otra ocasión.

Ejemplo:

Referencias:

Server Sent Events

Hasta ahora poco hemos podido aprovechar de todo lo que hemos contado. Quizás hemos aprendido algunos detalles de cómo funciona el HTTP/2, hemos comprendido que lo de enviar ficheros por anticipado con Server Push no lo podemos utilizar en la práctica. Por lo que parece, HTTP/2 no es relevante para nosotros como programadores y solo tiene ventajas desde el punto de vista de la infraestructura, por lo que es algo que podemos ignorar sin mayor dificultad.

Lo cierto es que hay una funcionalidad de los navegadores que con HTTP/1.1 apenas podemos utilizar con confianza y que con HTTP/2 exprime toda su capacidad, el Server Sent Events o SSE.

Esta funcionalidad, ampliamente soportada por los navegadores, permite recibir mensajes desde el servidor de una forma bastante sencilla. De esta forma podemos desarrollar aplicaciones con comunicaciones bidireccionales:

  • Si queremos enviar del cliente al servidor, utilizaremos una llamada estándar, como un fetch.
  • Si queremos enviar del servidor al cliente, este último se suscribe a los mensajes del servidor y los recibe por medio de EventSource.

Limitación “mortal” con HTTP/1.1

Cuando se usa SSE sobre HTTP/1.1, este tiene una limitación muy importante con el número máximo de conexiones abiertas, que son solo 6 por dominio. Pueden parecer suficientes, pero cuando el usuario abre varias pestañas, estas se van sumando al límite de conexiones disponibles. Cuando se utiliza con HTTP/2, el número máximo de conexiones simultáneas se negocia entre el servidor y el cliente, siendo por defecto de 100. Esto hace que, en la práctica, no sea una buena idea implementar SSE sobre HTTP/1.1 y realmente debemos basarnos en HTTP/2 para no tener problemas con el número de conexiones.

Referencias:

EventSource

Cuando queremos suscribirnos a mensajes enviados desde el servidor, el cliente tiene que hacer uso de EventSource. Este constructor está disponible en la mayoría de los navegadores desde hace ya bastante tiempo, pero es un gran desconocida. Vamos a explicar como funciona.

Crear un EventSource

Es muy sencillo, solo tenemos que crear una nueva instancia del constructor EventSource pasando como parámetro la URL del servidor que va a enviar los mensajes.

const source = new EventSource('/echo');

Este constructor puede recibir un segundo parámetros con opciones, pero en estos momentos la única que se acepta es withCredencials, que es un valor booleano, por defecto false, que indica si CORS debe configurarse para incluir credenciales.

Eventos

El resto del trabajo con EventSource se hace por medio de eventos.

Apertura y cierre

Cuando se establece la conexión con el servidor se lanza el evento open. Cuando se cierra la conexión se lanza el evento close.

source.addEventListener('open', () => {
  //...
});

source.addEventListener('close', () => {
  //...
});

Para cerrar la conexión explícitamente podemos llamar al método .close() de la instancia de EventSource.

Recepción de mensajes

Por defecto, todos los mensajes enviados desde el servidor se reciben en el evento message, que en el objeto que recibe como parámetro recibe:

  • .data con los datos enviados por el servidor
  • .lastEventId con el identificador del mensaje recibido
  • .source con la referencia al objeto EventSource que ha recibido el mensaje
source.addEventListener('message', (e) => {
  //...
});

Ejemplo:

Referencias:

Explorando la estructura de los mensajesRecibir la suscripción a los eventos

Si se envía la cabecera accept con el contenido text/event-stream, es que un EventSource está intentando conectar para recibir eventos desde el servidor. Esta es la clave para diferencia este tipo de llamada de otras que pueda hacernos el navegador.

server.on('stream', (stream, headers) => {
  if (headers['accept'] === 'text/event-stream' && headers[':path'] === '/time') {
    let id = 0;
    stream.respond({
      'content-type': 'text/event-stream',
      ':status'     : 200
    });
    setInterval(() => {
      stream.write('id: ' + id + '\n' +
                   'data: ' + 'new server event ' + (new Date()).toLocaleTimeString() + '\n\n');
    }, 1000);
  }
});

Enviar mensajes al cliente

Para enviar mensajes desde el servidor, lo que tenemos que hacer contestar una vez con la cabecera text/event-stream y el código 200, para indicar que aceptamos la suscripción. A partir de ese momento podemos escribir en el stream que se ha abierto en la suscripción enviando mensajes.

stream.respond({
  'content-type': 'text/event-stream',
  ':status'     : 200
});
setInterval(() => {
  stream.write('id: ' + id + '\n' +
               'data: ' + 'new server event ' + (new Date()).toLocaleTimeString() + '\n\n');
}, 1000);

El resto del servidor http

Puede seguir atendiendo al resto de llamadas a ficheros o a una API Rest, sin mayor dificultad. Por ejemplo, en nuestro caso, para evitar problemas con CORS, estamos enviando el fichero HTML desde el mismo servidor HTTP/2:

stream.respondWithFile('06-index.html', {
  'content-type': 'text/html; charset=utf-8',
  ':status'     : 200,
});

Ejemplo:

Eventos personalizados

Al igual que con WebSocket, podemos ver los mensajes que se reciben desde la venta de herramientas de desarrollo de los navegadores basados en Chromium. Esto nos permite depurar nuestros mensajes y aprender un poco más sobre cómo están construidos

id

Debemos enviar un identificador por cada mensaje que no se repita. Esto es bastante importante en la gestión de reintentos de recepción de mensajes, por lo que es conveniente enviar siempre un identificador único por cada mensaje. En la práctica es muy común no incluirlo, pero hay que ser cuidadosos y respetuosos con el protocolo.

data

Es texto plano. Aunque HTTP/2 es un formato binario, el Server Send Event es compatible con HTTP/1.1 y envía los datos como texto. Si los datos son un objeto, deberíamos hacer JSON.stringify() antes de enviarlos y JSON.parse() al recibirlos.

event

Si no indicamos nada, el evento que se va a enviar es message, pero podemos personalizar el nombre del evento que queremos recibir en el cliente. De esta forma, podemos atender a un tipo de mensaje y no a otro, gestionando el nombre del evento al que atendemos.

Debemos tener en cuenta que estos nombres de evento no debería ser close, error, open, ya que estos son eventos
que ya tiene SSE y tendríamos un “conflicto”.

fin del mensaje

Para indicar que el mensaje ha terminado, se envía dos retornos de carro seguidos: \n\n. Si el contenido del mensaje tiene retornos de carro, tenemos que tener cuidado y escaparlos, ya que si los dejamos el mensaje puede llegar truncado.

Formato completo

Un mensaje completo es un texto con un formato similar a este:

id: 10221\n
event: bienvenida\n
data: hola a todos\n
\n

Ejemplo:

– [server.js](src/08-server.js)
– [index.html](src/08-index.html)

Gestión de errores y desconexiones

Una de las características de SSE es que gestionar por sí mismo la reconexión en caso de caída. Por defecto lo hace inmediatamente, pero podemos configurar el tiempo (en milisegundos), que debe esperar el cliente entre varios reintentos de conexión. Esto puede ser importante en servidores con una carga significativa. Para configurar este tipo entre reintentos debemos enviar al cliente:

retry: 10000\n\n

En este caso, el mensaje termina como es habitual en \n\n, pero no incluimos id, nombre del evento o datos, solo indicamos que los reintentos debe hacerse -en este caso- cada 10 segundos.

stream.write('retry: 10000\n\n');

En el cliente podemos comprobar si se produce algún error en la conexión por medio del evento error:

source.addEventListener('error', (e) => {
  //...
});

Ejemplo:

Implementar un chat con Server Send Event y HTTP/2 en Express

Como no podía ser de otra forma, vamos a implementar un chat, la aplicación de ejemplo por antonomasia siempre que se habla de comunicación bidireccional. Para implementarla vamos a usar ExpressJS en el servidor, uno de los framework más extendidos en Node.

Habilitar HTTP/2

En primer lugar, tenemos que habilitar que Express trabaje con HTTP/2. Lamentablemente, la versión 4 de Express tiene problemas no resueltos. Parece que solucionarán en la próxima versión 5, pero de momento tenemos que dar un pequeño rodeo y utilizar el paquete http2-express-bridge que facilita la convivencia entre http2 y Express:

const express      = require('express');
const bodyParser   = require('body-parser');
const http2Express = require('http2-express-bridge');
const http2        = require('http2');
const fs           = require('fs');

const options = {
  key : fs.readFileSync('../localhost.key'),
  cert: fs.readFileSync('../localhost.cert'),
};
const app     = http2Express(express);

//...

http2.createSecureServer(options, app).listen(9000);

Servir ficheros estáticos

Ahora que Express puede funcionar con HTTP/2, vamos a servir ficheros estáticos desde la carpeta public:

app.use('/', express.static('public'));

Enviar mensajes desde el cliente hasta el servidor (POST)

Esto lo vamos a hacer como solemos hacerlo habitualmente, simplemente en el cliente haremos una llamada con fetch que enviará nuestro mensaje al servidor, donde por medio de express obtendremos el texto y lo guardaremos en una matriz.

En el cliente:

fetch('/chat/message', {
  method : 'POST',
  headers: {
    'Content-Type': 'text/plain'
  },
  body   : message.value
});

En el servidor:

const messages = [];

app.post('/chat/message', async (req, res) => {
  const message = {id: id++, text: req.body};
  messages.push(message);
  res.end();
});

Recibir del servidor los mensajes

En el cliente crearemos una nueva instancia de EventSource y capturaremos el evento message, donde mostraremos el id y el mensaje en pantalla.

const source = new EventSource('/chat');
source.addEventListener('message', function (e) {
  addMsg(e.lastEventId + '> ' + e.data);
});

En el servidor, registraremos todas las conexiones realizadas y las guardaremos en una matriz de conexiones, además de configurar un reintento de conexión de 1 segundo:

const connections = [];
app.get('/chat', async (req, res) => {
  res.set({
    'Cache-Control': 'no-cache',
    'Content-Type' : 'text/event-stream'
  });
  res.flushHeaders();
  res.write('retry: 10000\n\n');
  connections.push(res);
});

Recibir todos los mensajes cuando se realiza la conexión anterior

En el servidor, cada vez que un nuevo cliente se suscribe a los mensajes, le enviaremos todos los mensajes anteriores. De esta forma podrá ver lo que se ha escrito en el chat con anterioridad. Para ello modificamos el método GET anterior enviando todos los mensajes existentes:

app.get('/chat', async (req, res) => {
  res.set({
    'Cache-Control': 'no-cache',
    'Content-Type' : 'text/event-stream'
  });
  res.flushHeaders();
  res.write('retry: 10000\n\n');
  connections.push(res);
  messages.forEach(message => {
    res.write(`id: ${message.id}\n`);
    res.write(`data: ${message.text}\n\n`);
  });
});

Enviar nuevos mensajes a todos las conexiones abiertas

Para ello, modificamos el POST anterior incluyendo algunas líneas adicionales donde se recorren todas las conexiones guardas y se envía el mensaje:

app.post('/chat/message', async (req, res) => {
  const message = {id: id++, text: req.body};
  messages.push(message);
  connections.forEach(connection => {
    connection.write(`id: ${message.id}\n`);
    connection.write(`data: ${message.text}\n\n`);
  });
  res.end();
});

Es importante tener en cuenta que en esta implementación el mensaje también lo recibe quien lo ha enviado.

Ejemplo:

Implementar una actualización de datos de una lista de tareas en Fastify

Como no podía ser de otra forma, vamos a implementar una mini aplicación de tareas (ToDo), la aplicación de ejemplo más utilizada. En este caso vamos a hacer que cualquier cambio en esta lista de tareas se pueda compartir con el resto de usuarios conectados. Para implementarla vamos a usar Fastify en el servidor, uno de los framework modernos más interesantes Node.

Habilitar HTTP/2

En primer lugar, tenemos que habilitar que Fastify trabaje con HTTP/2. Es muy sencillo en este caso, solo tenemos que añadir algo de información cuando creamos el servidor:

import fs      from "node:fs";
import path    from 'node:path';
import url     from 'node:url';
import Fastify from 'fastify';

const __filename = url.fileURLToPath(import.meta.url);
const __dirname  = path.dirname(__filename);

const fastify = Fastify({
  logger: true,
  http2 : true,
  https : {
    key : fs.readFileSync(path.join(__dirname, '..', 'localhost.key')),
    cert: fs.readFileSync(path.join(__dirname, '..', 'localhost.cert'))
  }
});

//...

fastify.listen({port: 3000}, function (err) {
  if (err) {
    fastify.log.error(err)
    process.exit(1)
  }
});

Servir ficheros estáticos

Ahora que Fastify funciona con HTTP/2, vamos a servir ficheros estáticos desde la carpeta public, para lo cual debemos cargar @fastify/static y registrar este plugin:

import fastifyStatic from '@fastify/static';

//...

fastify.register(fastifyStatic, {
  root: path.join(__dirname, './public')
});

CRUD de las tareas

En el fichero tasks.js vamos a implementar el CRUD de las tareas, apoyándonos en una librería que gestionará el acceso a una base de datos, en nuestro caso SQLite.

import db from './db.js';

const tasks = {
  async create (data) {
    const result = await db.insert('tasks', data);
    return result;
  },
  read (filter) {
    return db.select('tasks', filter);
  },
  async update (filter, data) {
    const result = await db.update('tasks', filter, data);
    return result;
  },
  async delete (id) {
    const result = await db.delete('tasks', id);
    return result;
  }
}
export default tasks;

Aunque hay algo de código innecesario y podríamos simplificar nuestro programa, vamos a dejar estas líneas, luego nos serán de utilidad.

API Rest

Servidor

Ahora preparamos la API Rest que llamará a las funciones que hemos implementado en core.js:

import tasks from './tasks.js';

fastify.get('/tasks', async (req, res) => {
  return tasks.read();
});

fastify.get('/tasks/:id', async (req, res) => {
  return tasks.read(req.params.id);
});

fastify.post('/tasks', async (req, res) => {
  return tasks.create(req.body);
});

fastify.put('/tasks/:id', async (req, res) => {
  return tasks.update(req.params.id, req.body);
});

fastify.delete('/tasks/:id', async (req, res) => {
  return tasks.delete(req.params.id);
});

Cliente

El cliente vamos a crear también un fichero tasks.js que va a conectar con la API Rest que acabamos de ver:

const tasks = {
  async create (data) {
    const result = await fetch('/tasks', {
      method : 'POST',
      headers: {'Content-Type': 'application/json'},
      body   : JSON.stringify(data)
    });
    if (result.status !== 200) {
      throw new Error(`Error ${result.status}`);
    }
    return true;
  },
  async read () {
    const result = await fetch('/tasks');
    if (result.status === 200) {
      return await result.json()
    } else {
      throw new Error(`Error ${result.status}`);
    }
  },
  async update (id, data) {
    const result = await fetch(`/tasks/${id}`, {
      method : 'PUT',
      headers: {'Content-Type': 'application/json'},
      body   : JSON.stringify(data)
    });
    if (result.status !== 200) {
      throw new Error(`Error ${result.status}`);
    }
  },
  async delete (id) {
    const result = await fetch(`/tasks/${id}`, {
      method: 'DELETE'
    });
    if (result.status !== 200) {
      throw new Error(`Error ${result.status}`);
    }
    return true
  }
};

export default tasks;

Preparar la suscripción para estar informados de cambios en las tareas

Ahora tenemos que preparar un sistema para conocer que se han producido cambios en las tareas. El mejor lugar para esto es en el propio CRUD que habíamos preparado anteriormente. Lo que haremos es añadir un par de métodos para suscribirse y de-suscribirse a estar informados de los cambios. En las operaciones de creación, actualización y borrador llamaremos a las funciones callback que se hayan subscrito a los cambios.

import db from './db.js';

const subscribers = [];

const tasks = {
  async create (data) {
    const result = await db.insert('tasks', data);
    data.id      = result;
    subscribers.forEach(cb => cb('create', data))
    return result;
  },
  read (filter) {
    return db.select('tasks', filter);
  },
  async update (filter, data) {
    const result = await db.update('tasks', filter, data);
    const rows   = await db.select('tasks', filter);
    subscribers.forEach(cb => cb('update', rows[0]));
    return result;
  },
  async delete (id) {
    const result = await db.delete('tasks', id);
    subscribers.forEach(cb => cb('delete', id));
    return result;
  },
  subscribe (cb) {
    return subscribers.push(cb) - 1;
  },
  unsubscribe (id) {
    delete subscribers[id];
  }
}

export default tasks;

Realizar la suscripción con SSE

Lo que acabamos de hacer se queda en el servidor, es decir, tenemos que implementar la parte de Server Sent Events para poder conectarnos con este sistema de suscripción que hemos preparado en el CRUD. Para ello tenemos que hacer algunas cosas en el servidor y en el cliente.

Servidor

Debemos crear una ruta en Fastify para aceptar suscripciones. En esa ruta realizaremos el enlace con la suscripción de cambios que hemos implementado y devolveremos las cabeceras necesarias para enviar eventos con SSE.

fastify.get('/tasks/subscribe', async (req, res) => {
  if (req.headers.accept === 'text/event-stream') {
    res.raw.writeHead(200, {
      'content-type' : 'text/event-stream',
      'Access-Control-Allow-Origin': '*',
      'cache-control': 'no-cache'
    });
    const subscription = tasks.subscribe((op, data) => {
      res.raw.write(`event: ${op}\n`);
      res.raw.write(`data: ${JSON.stringify(data)}\n\n`)
    });
    res.raw.on('close', () => {
      tasks.unsubscribe(subscription);
    });
  } else {
    req.code(404);
  }
});

Es importante indicar que cuando la conexión se pierde se lanza el evento close y nos de-suscribimos. De esta forma evitamos intentar enviar mensajes a clientes que ya no están conectados.

Cliente

En la parte cliente vamos a implementar esta suscripción en tasks.js, donde llamaremos al servidor para suscribirnos y que nos avise de los cambios:

let source;

const tasks = {

  //...
  
  subscribe(event, callback) {
    if (!source) {
      source = new EventSource('/tasks/subscribe');
    }
    source.addEventListener(event, function (e) {
      callback(JSON.parse(e.data));
    });
  }
};

export default tasks;

Dos aspectos interesantes de esta implementación. En primer lugar, si no hay ninguna suscripción, no se abre la conexión con el servidor. Además, cada tipo de actualización, es un evento diferente. Es decir, si queremos obtener las acciones de creación, nos suscribiremos a create, de actualización a update y de borrado a delete.

Hemos utilizado Javascript y HTML básico, no hemos utilizado ningún framework o sistema de plantillas, por lo que en este ejemplo hemos tenido que realizar las actualizaciones manualmente. Usando un framework moderno es casi seguro que este proceso de actualización será aún más sencillo del que aquí hemos desarrollado.

Ejemplo:

Server Sent Events vs. WebSocket

Realmente WebSocket (WS) y Server Sent Events (SSE) pueden hacer cosas muy parecidas, aunque tiene algunas diferencias.
Quizás algunas, aunque parezcan pequeñas, han echado para atrás a más de uno a la hora de implementar WS en sus
aplicaciones. A modo de resumen, estas serian las principales diferencias entre WebSocket (WS) y Server Sent Events (SSE):

WebSocketServer Sent Events
Transmisión de mensajes bidireccionalTransmisión de mensajes del servidor a cliente, usando http del cliente al servidor
Admite la transmisión de datos binarios y textoAdmite únicamente la transmisión de datos en formato texto
Admite un gran número de conexiones por navegadorCon HTTP/2 admite un gran número de conexiones por navegador
No se puede implementar polyfills con JavaScriptExisten polyfillJavaScript para navegadores que no lo soportan nativamente
Algunos firewall interrumpen la conexiónNo hay bloqueo por parte de los firewalls
No hay soporte integrado para reconexiónSí hay soporte integrado para reconexión

En mi modesta opinión, si ya has implementado un sistema basado en API REST y necesitas hacer llamadas desde el servidor hasta el cliente, SSE es una excelente solución. Es bastante sencilla de implementar en el servidor y extremadamente sencilla en el cliente.

Si tu aplicación necesita enviar y recibir información binaria o quieres implementar la comunicación bidireccional con un único API y no te importa gestionar tu mismo la reconexión, entonces WS es tu solución.

La dificultad de ambos modelos es diseñar adecuadamente la comunicación bidireccional, que mensajes se van a recibir, como se estructura la información y cómo se debe orquestar la información entre el cliente y el servidor.

El segundo reto, no menos importante, es establecer un modelo para comunicar adecuadamente al usuario que otros también están interactuando con la misma información y que esta puede cambiar sin que él, en principio, sepa por qué. Ya existen suficientes ejemplos y modelos a imitar como para que esto cada día sea un reto más fácil de abordar, pero debemos ser muy cuidadoso en dar al usuario una experiencia coherente y satisfactoria.

La transmisión de información entre el servidor y el cliente no es, realmente, un problema. SSE es realmente fácil de implementar, como espero se haya podido ver en estos ejemplos y explicaciones. Es verdad que tenemos que hacer algunas cosas nuevas, pero son bastante sencillas.

El futuro: HTTP/3 y WebTransport

Esto no para. Hace unos pocos meses se ha publicado una nueva especificación denominad WebTransport y que está asociada a HTTP/3, que es la nueva versión de HTTP que está empezando a implementarse en los navegadores. Esta API está pensada para comunicaciones bidireccionales entre un cliente y un servidor, soportando el envío de datos tanto de forma no-fiable a través de datagramas (algo muy útil en entornos IoT), como fiable a través de streams (que es lo más habitual en aplicaciones destinadas a usuarios).

Si os parece interesante, podemos preparar otra charla para conocer algo más sobre esta nueva posibilidad, pero hoy ya no nos da para más.

Como decía, en el nunca suficientemente bien valorado Buzz Lightyear: to infinity and beyond!.

Referencias:

./

Novedades

Observables en Javascript con Proxies

Observables en Javascript con Proxies

En esta charla, organizada por MadridJS, Pablo Almunia nos habla de la observación reactiva de objetos en Javascript por medio de Proxy. Se describe paso a paso cómo funcionan los Proxies y en que casos pueden ser nuestro mejor aliado. Veremos que no hay que tenerles miedo, son bastante sencillos de utilizar, y nos ofrecen una gran abanico de posibilidades.

Aplicaciones JAMStack, SEO friendly y escalables con NextJS

Aplicaciones JAMStack, SEO friendly y escalables con NextJS

En esta charla de Madrid JS, Rafael Ventura nos describe las funcionalidades clave de NextJS, nos muestra en vivo cómo desarrollar una completa aplicación JAMStack con Server Side Rendering (SSR) y Static Site Generation (SSG) y termina mostrando como publicar esta aplicación en Vercel.

Stencil JS: mejora el Time To Market de tu producto, por Rubén Aguilera

Stencil JS: mejora el Time To Market de tu producto, por Rubén Aguilera

En esta charla Rubén Aguilera nos cuenta los problemas que tienen muchas empresas a la hora de sacar productos accesibles, vistosos y usables en el Time To Market que requiere Negocio y cómo podemos minimizar este tiempo gracias al DevUI con StencilJS para adecuar una aplicación de Angular a las exigencias del mercado en tiempo record.

Svelte JS: por qué dedicarle tiempo, por Jesús Cuesta

Svelte JS: por qué dedicarle tiempo, por Jesús Cuesta

Jesús Cuesta cuenta qué es Svelte, para qué sirve, cómo compite contra aplicaciones construidas con React o Vuejs, si sirve para desarrollar web components, si la curva de aprendizaje es muy alta, y sobre todo si está suficiente maduro para utilizarlo. Si quieres conocer Svelte no puedes perderte esta introducción.