Un panel en directo con change streams

30 minIntermedio

Un panel en el navegador con el número de pedidos y los ingresos, que se actualiza en cuanto entra cada pedido.

Un panel que pregunta a la base cada pocos segundos hace consultas aunque nada cambie. Con un change stream, el servidor sabe cuándo entra un pedido y solo entonces recalcula los totales y se los manda a los navegadores conectados.

Lo que vas a construir

Un servidor de Node.js en el puerto 3000 que manda el número de pedidos y los ingresos con Server-Sent Events a una página HTML, que los muestra y se actualiza con cada pedido nuevo.

Requisitos

  • Node.js 18 o posterior.
  • Docker y mongosh, para arrancar TeselaDB en tu máquina y mirar los datos.

Prepara el entorno

Shell
docker run -d --name teseladb -p 27017:27017 viuzi/teseladb:latest \
  start --dbpath /data --insecure --insecure-allow-remote --bind_ip 0.0.0.0
export TESELADB_URI="mongodb://127.0.0.1:27017/shop"
mongosh "$TESELADB_URI" --quiet --eval 'db.runCommand({ ping: 1 }).ok'
mkdir panel && cd panel && npm init -y && npm install mongodb

El contenedor arranca un nodo de TeselaDB sin TLS ni usuarios, que basta para seguir la guía en tu máquina. Cuando mongosh responde 1, el nodo ya acepta conexiones. TESELADB_URI apunta a ese nodo y a la base shop; en un clúster de producción la cadena lleva TLS y usuario, como cuenta Conectar tu aplicación. La última línea crea la carpeta del programa con el paquete mongodb instalado.

1. El servidor

Guárdalo como dashboard.mjs. Sirve la página en / y, en /totals, cada navegador recibe los totales al conectarse y otra vez con cada inserción en orders:

JavaScript
import { readFileSync } from "node:fs";
import { createServer } from "node:http";
import { MongoClient } from "mongodb";

const client = new MongoClient(process.env.TESELADB_URI);
const orders = client.db("shop").collection("orders");
const page = readFileSync(new URL("./dashboard.html", import.meta.url));
const listeners = new Set();

async function totals() {
  const [row] = await orders
    .aggregate([{ $group: { _id: null, orders: { $sum: 1 }, revenue: { $sum: "$total" } } }])
    .toArray();
  return { orders: row?.orders ?? 0, revenue: row?.revenue ?? 0 };
}

// The page at /, and at /totals one open connection per browser that gets the totals as Server-Sent Events.
createServer(async (req, res) => {
  if (req.url !== "/totals") {
    res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
    return res.end(page);
  }
  res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache" });
  res.write(`data: ${JSON.stringify(await totals())}\n\n`);
  listeners.add(res);
  req.on("close", () => listeners.delete(res));
}).listen(3000);

for await (const change of orders.watch([{ $match: { operationType: "insert" } }])) {
  const data = `data: ${JSON.stringify(await totals())}\n\n`;
  for (const res of listeners) res.write(data);
}

2. La página

Guárdala como dashboard.html, junto a dashboard.mjs. EventSource mantiene la conexión abierta y se reconecta solo si se corta:

<p>Pedidos: <strong id="orders">0</strong> · Ingresos: <strong id="revenue">0</strong></p>
<script>
  const events = new EventSource("/totals");
  events.onmessage = (event) => {
    const { orders, revenue } = JSON.parse(event.data);
    document.getElementById("orders").textContent = orders;
    document.getElementById("revenue").textContent = revenue;
  };
</script>

Probarlo

Arranca el servidor con node dashboard.mjs y abre http://localhost:3000 en el navegador, que muestra los totales actuales en cuanto la página se conecta a /totals, y después, con la página todavía abierta, inserta un pedido desde mongosh:

JavaScript
db.getSiblingDB("shop").orders.insertOne({ total: 30 });

Los dos números cambian en cuanto se confirma la inserción, sin recargar la página.

Limpiar

Para el servidor con Ctrl+C y cierra la página. No hay nada que borrar, porque el servidor no guarda nada en la base. Al final borra también el contenedor con la última orden: el nodo guarda los datos dentro, sin volumen, así que con él desaparece todo lo que escribiste durante la guía y el puerto 27017 queda libre.

Shell
docker rm -f teseladb

Siguientes pasos