Image

Base de conocimientos → Características y configuración del servidor WebSocket

[Servidores virtuales] [Aplicaciones en VPS/VDS]
Fecha de publicación: 07.11.2025

En el artículo anterior describimos el proceso de instalación y configuración básica del servicio WebSocket Server.

El servicio ofrece amplias posibilidades, y en esta guía analizaremos algunas de ellas. En el artículo anterior solo mostramos la configuración básica, en la que se llama a un script y se envían datos continuamente a través de WebSocket. Este enfoque no siempre es eficiente cuando se trata de configurar un servidor de producción.

1. Enviar datos solo si han cambiado

En este código, los datos se envían a los clientes solo si la nueva cadena JSON difiere de la anterior.

const newJSON = JSON.stringify(live);

if (newJSON !== previousLiveJSON) {
  previousLiveJSON = newJSON;
  latestLiveData = live;
  broadcast();
  console.log(`Datos en vivo actualizados (${live.length} visitas)`);
}

Este enfoque ahorra considerablemente los recursos del cliente, especialmente cuando los datos se transmiten a un microcontrolador, lo que reduce significativamente la carga.

2. Combinación de dos solicitudes en una sola

Puedes estructurar el código de manera que ambas solicitudes se ejecuten simultáneamente y entreguen los datos necesarios.
En este ejemplo obtenemos dos tipos de datos diferentes: Live y Summary.

/root/websocket-server/server.js
import express from "express";
import { WebSocketServer } from "ws";
import fetch from "node-fetch";
import https from "https";
import fs from "fs";
import url from "url";
import dotenv from "dotenv";

dotenv.config();

const PORT = process.env.PORT || 8080;
const MATOMO_URL = process.env.MATOMO_URL;
const MATOMO_TOKEN = process.env.MATOMO_TOKEN;
const ID_SITE = process.env.MATOMO_SITE_ID || 1;
const FETCH_INTERVAL = parseInt(process.env.FETCH_INTERVAL) || 1000;
const VALID_TOKENS = process.env.VALID_TOKENS.split(",").map(t => t.trim());
const SSL_KEY = process.env.SSL_KEY;
const SSL_CERT = process.env.SSL_CERT;

const TIMEZONE_OFFSET = 3; // tu zona horaria +3

const options = {
  key: fs.readFileSync(SSL_KEY),
  cert: fs.readFileSync(SSL_CERT)
};

const app = express();
const server = https.createServer(options, app);
const wss = new WebSocketServer({ server });

// === Variables globales ===
let latestLiveData = [];
let latestSummary = {};
let previousLiveJSON = "";
let previousSummaryJSON = "";

// ===== Obtener estadísticas en vivo (últimas visitas) =====
async function fetchMatomoLive() {
  const apiUrl = `${MATOMO_URL}index.php?module=API&method=Live.getLastVisitsDetails&format=JSON&period=day&date=today&idSite=${ID_SITE}`;
  const res = await fetch(apiUrl, {
    method: "POST",
    body: new URLSearchParams({
      token_auth: MATOMO_TOKEN,
      filter_limit: "5",
      expanded: "1"
    }),
    headers: { "Content-Type": "application/x-www-form-urlencoded" }
  });

  const data = await res.json();
  const visits = [];

  if (Array.isArray(data)) {
    for (const visit of data) {
      if (
        visit.latitude &&
        visit.longitude &&
        !isNaN(visit.latitude) &&
        !isNaN(visit.longitude)
      ) {
        let pageTitle = "";
        if (Array.isArray(visit.actionDetails)) {
          for (const action of visit.actionDetails) {
            if (action.pageTitle) {
              pageTitle = action.pageTitle;
              break;
            }
          }
        }

        let localTime = "";
        if (visit.lastActionDateTime) {
          try {
            const date = new Date(visit.lastActionDateTime.replace(" ", "T") + "Z");
            const shifted = new Date(date.getTime() + TIMEZONE_OFFSET * 3600000);
            localTime = shifted.toISOString().replace("T", " ").substring(0, 19);
          } catch {
            localTime = visit.lastActionDateTime;
          }
        }

        visits.push({
          latitude: parseFloat(visit.latitude),
          longitude: parseFloat(visit.longitude),
          city: visit.city || "",
          countryCode: visit.countryCode || "",
          country: visit.country || "",
          actions: visit.actions || "",
          visitIp: visit.visitIp || "",
          operatingSystem: visit.operatingSystem || "",
          browserName: visit.browserName || "",
          source: visit.referrerName || "",
          lastActionDateTime: localTime
        });
      }
    }
  }
  return visits;
}

// ===== Obtener estadísticas resumidas (VisitsSummary) =====
async function fetchSummaryData() {
  async function getMetric(method, date) {
    const url = `${MATOMO_URL}index.php?module=API&method=${method}&format=JSON&period=day&date=${date}&idSite=${ID_SITE}`;
    const res = await fetch(url, {
      method: "POST",
      body: new URLSearchParams({ token_auth: MATOMO_TOKEN }),
      headers: { "Content-Type": "application/x-www-form-urlencoded" }
    });
    const data = await res.json();
    return data?.value ? parseInt(data.value) : 0;
  }

  async function getDayData(date) {
    const visits = await getMetric("VisitsSummary.getVisits", date);
    const timeSec = await getMetric("VisitsSummary.getSumVisitsLength", date);
    const h = Math.floor(timeSec / 3600);
    const m = Math.floor((timeSec % 3600) / 60);
    const s = timeSec % 60;
    const formattedTime = `${h.toString().padStart(2, "0")}:${m
      .toString()
      .padStart(2, "0")}:${s.toString().padStart(2, "0")}`;
    return { visits, time_spent: formattedTime };
  }

  const today = new Date(Date.now() + TIMEZONE_OFFSET * 3600000);
  const yesterday = new Date(today.getTime() - 86400000);
  const dayBefore = new Date(today.getTime() - 2 * 86400000);

  function fmt(d) {
    return d.toISOString().substring(0, 10);
  }

  const [todayStats, yStats, dbStats] = await Promise.all([
    getDayData("today"),
    getDayData(fmt(yesterday)),
    getDayData(fmt(dayBefore))
  ]);

  return {
    today: todayStats,
    yesterday: yStats,
    day_before: dbStats
  };
}

// ===== Función principal de actualización =====
async function fetchMatomoData() {
  try {
    const [live, summary] = await Promise.all([
      fetchMatomoLive(),
      fetchSummaryData()
    ]);

    // --- Verificación de cambios ---
    const newLiveJSON = JSON.stringify(live);
    const newSummaryJSON = JSON.stringify(summary);

    const liveChanged = newLiveJSON !== previousLiveJSON;
    const summaryChanged = newSummaryJSON !== previousSummaryJSON;

    // --- Actualizar cachés ---
    if (liveChanged) {
      previousLiveJSON = newLiveJSON;
      latestLiveData = live;
      console.log(`Live actualizado (${live.length} visitas)`);
    } else {
      console.log("⏸ Live sin cambios");
    }

    if (summaryChanged) {
      previousSummaryJSON = newSummaryJSON;
      latestSummary = summary;
      console.log("Summary actualizado");
    } else {
      console.log("⏸ Summary sin cambios");
    }

    // --- Enviar a los clientes solo si hay cambios ---
    if (liveChanged || summaryChanged) {
      const combined = JSON.stringify({
        visits_live: latestLiveData,
        summary: latestSummary
      });

      wss.clients.forEach(client => {
        if (client.readyState === 1) {
          client.send(combined);
        }
      });

      console.log("Datos actualizados enviados a los clientes");
    } else {
      console.log("Sin cambios — envío omitido");
    }
  } catch (err) {
    console.error("Error al obtener los datos:", err.message);
  }
}

// ===== Iniciar ciclo =====
setInterval(fetchMatomoData, FETCH_INTERVAL);
fetchMatomoData();

// ===== WebSocket =====
wss.on("connection", (ws, req) => {
  const query = url.parse(req.url, true).query;
  const token = query.token;

  if (!VALID_TOKENS.includes(token)) {
    console.log(`Conexión rechazada con token: ${token}`);
    ws.close(4001, "Invalid token");
    return;
  }

  console.log(`Cliente conectado (${token})`);

  if (latestLiveData.length > 0 || Object.keys(latestSummary).length > 0) {
    ws.send(JSON.stringify({ visits_live: latestLiveData, summary: latestSummary }));
  }

  ws.on("close", () => {
    console.log(`Cliente ${token} desconectado`);
  });
});

// ===== Depuración HTTP =====
app.get("/debug", (req, res) => {
  res.setHeader("Content-Type", "application/json; charset=utf-8");
  res.send(
    JSON.stringify({ visits_live: latestLiveData, summary: latestSummary }, null, 2)
  );
});

server.listen(PORT, () => {
  console.log(`Servidor HTTPS iniciado en el puerto ${PORT}`);
});

En este ejemplo, solicitamos directamente los datos de dos llamadas API diferentes y los enviamos en una sola respuesta, omitiendo los scripts PHP.

3. Configuración segura de autenticación

En el código anterior se utilizan variables para almacenar los datos de autenticación. Para ello es necesario instalar el paquete:

npm install dotenv

El archivo de configuración se ve así:

nano /root/websocket-server/.env
# === Configuración de la API ===
MATOMO_URL=https://domain.tld/path/
MATOMO_TOKEN=YOUR_API_KEY
MATOMO_SITE_ID=1

# === Configuración del servidor ===
PORT=8080
FETCH_INTERVAL=1000

# === Accesos WebSocket ===
VALID_TOKENS=WSS_CLIENT_TOKEN_1,WSS_CLIENT_TOKEN_2

# === Rutas de SSL ===
SSL_KEY=/etc/letsencrypt/live/wss.domain.tld/privkey.pem
SSL_CERT=/etc/letsencrypt/live/wss.domain.tld/fullchain.pem

Como habrás notado, la autenticación se realiza tanto para las solicitudes API como para los clientes al conectarse al servidor WebSocket.

Para conectarte a WSS, utiliza la dirección con el token:
/?token=WSS_CLIENT_TOKEN_1

4. Frecuencia de solicitudes de datos de la API

La configuración se realiza en la siguiente línea:

const FETCH_INTERVAL = parseInt(process.env.FETCH_INTERVAL) || 1000;

El parámetro se especifica en milisegundos, donde 1000 = 1 segundo.

Para 10 segundos, se vería así:

const FETCH_INTERVAL = parseInt(process.env.FETCH_INTERVAL) || 10000;

En nuestro código, este parámetro aparece con un valor predeterminado si no está definido en .env.
Dado que está configurado en .env, debe editarse directamente allí.

Para 12 horas, cambia el parámetro en .env:

FETCH_INTERVAL=43200000

5. Conclusión

Esta configuración permite recibir estadísticas en tiempo real sin necesidad de usar scripts PHP, teniendo en cuenta los cambios en los datos antes de enviarlos a los clientes WSS.

Para aplicar la configuración, no olvides reiniciar el servicio:

cd /root/websocket-server
pm2 restart server.js --name websocket

Ver el estado del servicio:

pm2 status server.js --name websocket




Sin comentarios aún