Image

Knowledge base → WebSocket Server Features and Configuration

[Virtual servers] [Applications on VPS/VDS]
Date of publication: 07.11.2025

In the previous article, we described the process of installing and configuring the WebSocket Server service.

The service offers extensive capabilities, and in this guide, we’ll take a look at some of them. In the previous article, we only demonstrated a basic configuration — where we call a script and continuously send data through a WebSocket. This approach is not always efficient when it comes to setting up a production server.

1. Sending data only if it has changed

In this code, data is sent to clients only if the new JSON string differs from the previous one.

const newJSON = JSON.stringify(live);

if (newJSON !== previousLiveJSON) {
  previousLiveJSON = newJSON;
  latestLiveData = live;
  broadcast();
  console.log(`Live data updated (${live.length} visits)`);
}

This approach significantly saves client resources, especially when the data is sent to a microcontroller — in such cases, it greatly reduces the load.

2. Combining two requests into one

You can structure your code so that both requests are executed simultaneously and provide the required data.
In this example, we receive two different types of data: Live and 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; // your +3 timezone

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 });

// === Global variables ===
let latestLiveData = [];
let latestSummary = {};
let previousLiveJSON = "";
let previousSummaryJSON = "";

// ===== Fetching Live statistics (recent visits) =====
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;
}

// ===== Fetching summary statistics (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
  };
}

// ===== Main update function =====
async function fetchMatomoData() {
  try {
    const [live, summary] = await Promise.all([
      fetchMatomoLive(),
      fetchSummaryData()
    ]);

    // --- Check for changes ---
    const newLiveJSON = JSON.stringify(live);
    const newSummaryJSON = JSON.stringify(summary);

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

    // --- Update caches ---
    if (liveChanged) {
      previousLiveJSON = newLiveJSON;
      latestLiveData = live;
      console.log(`Live updated (${live.length} visits)`);
    } else {
      console.log("⏸ Live unchanged");
    }

    if (summaryChanged) {
      previousSummaryJSON = newSummaryJSON;
      latestSummary = summary;
      console.log("Summary updated");
    } else {
      console.log("⏸ Summary unchanged");
    }

    // --- Send updates to clients only if data has changed ---
    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("Updated data sent to clients");
    } else {
      console.log("No changes — sending skipped");
    }
  } catch (err) {
    console.error("Error fetching data:", err.message);
  }
}

// ===== Start loop =====
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(`Rejected connection with token: ${token}`);
    ws.close(4001, "Invalid token");
    return;
  }

  console.log(`Client connected (${token})`);

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

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

// ===== HTTP Debug =====
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(`HTTPS server running on port ${PORT}`);
});

In this example, we directly request data from two different API calls and send them in a single response, bypassing PHP scripts.

3. Secure authentication setup

In the code above, variables are used to store authentication data. To handle this, you need to install the following package:

npm install dotenv

The configuration file looks like this:

nano /root/websocket-server/.env
# === API Configuration ===
MATOMO_URL=https://domain.tld/path/
MATOMO_TOKEN=YOUR_API_KEY
MATOMO_SITE_ID=1

# === Server Configuration ===
PORT=8080
FETCH_INTERVAL=1000

# === WebSocket Access ===
VALID_TOKENS=WSS_CLIENT_TOKEN_1,WSS_CLIENT_TOKEN_2

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

As you can see, authentication is performed both for API requests and for clients when connecting to the WebSocket server.

To connect to WSS, use the address with a token:
/?token=WSS_CLIENT_TOKEN_1

4. API data request frequency

Configuration is done in the following line:

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

The parameter is specified in milliseconds, where 1000 = 1 second.

For 10 seconds, it will look like this:

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

In our code, this parameter is set by default if not defined in .env.
Since it’s already specified there, it should be edited directly in the .env file.

For 12 hours, change the parameter in .env:

FETCH_INTERVAL=43200000

5. Conclusion

This configuration allows you to receive real-time statistics, bypassing PHP scripts, while also accounting for data changes before sending them to WSS clients.

To apply the new settings, don’t forget to restart the service:

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

Check the service status:

pm2 status server.js --name websocket




No Comments Yet