'use strict';
import { Buffer } from 'buffer';
import { errorLogger} from './utils/requestLogger.js';
import { isObjectVisible } from './utils/authChecks.js';
import { HEX2uuid, UUID2hex } from '@commtool/sql-query';
import { normalizeUserSession } from '@commtool/shared-auth';
// @ts-check
/**
* @import {ExpressRequest, ExpressResponse} from './types.js'
*/
/**
* @typedef {Object} SocketIOServer
* Socket.IO server instance used to register connection handlers and emit to rooms.
*/
/**
* @typedef {Object} SocketIOSocket
* Represents a single connected Socket.IO client.
* @property {Object} request - Underlying HTTP upgrade request (contains session, headers)
* @property {Object} data - Per-socket mutable storage set by connection handler
* @property {string} data.connectID - Base64-encoded socketID used as key in clientDataStore
* @property {string} data.user - userUID of the authenticated user
* @property {Object} data.userInfo - Full decoded token payload
* @property {string} data.root - Current organisation UID (may be updated via setRoot)
* @property {Buffer} data.rootHex - Hex-encoded root for fast comparisons
* @property {boolean} data.transaction - Whether a bulk-transaction is in progress
* @property {string} id - Internal Socket.IO connection ID
* @property {boolean} connected - Whether the socket is currently connected
* @property {function(string, any): void} emit - Emit an event to this client
* @property {function(string): void} join - Join a Socket.IO room
* @property {function(boolean): void} disconnect - Forcefully close the connection
* @property {Object} handshake - Handshake information (headers, query, auth)
*/
/**
* @typedef {Object} ClientData
* Per-connection state stored in {@link clientDataStore}.
* @property {Object|null} updateBuffer - Pending list-update message to flush on reconnect
* @property {Set<Buffer>} updateAbo - Set of hex-encoded list UIDs this client monitors (via `monitor` events)
* @property {Set<Buffer>} objectAbo - Set of hex-encoded object UIDs this client monitors (via `monitorObject` events)
* @property {Array} messageBuffer - General message buffer (reserved for future use)
* @property {string} UIDuser - userUID of the connected user
* @property {string} UIDroot - Organisation UID active for this connection
* @property {string} orgSource - How orgUID was determined: 'header' | 'session' | 'user-default' | 'dynamic'
* @property {number} connectedAt - Unix ms timestamp of initial connect
* @property {any} [queueStatus] - Last known queue status (cached for reference)
*/
/** @type {SocketIOServer|null} */
let ioInstance
/** @type {Map<string, any>} */
export const clientDataStore = new Map();
// Delayed update timestamps for batching updates
/** @type {Map<string, number>} */
export const delayedUpdateTimestamps = new Map();
// Debounce delay for updates (2.5 seconds)
/** @type {number} */
const debounceDelay = 7000;
/**
* Registers all Socket.IO connection and event handlers.
*
* Called once at server startup with the Socket.IO `io` instance.
* Stores `io` in the module-level `ioInstance` so that other exported
* functions (e.g. `broadcastUpdate`) can reach connected sockets.
*
* **Connection lifecycle:**
* 1. Validates JWT-authenticated `socket.data.user` (set upstream by auth middleware).
* 2. Requires a `?id=<uuid>` query parameter to derive a stable `socketID`.
* 3. Requires organisation context from `x-organization` header or token claims.
* 4. Creates an entry in {@link clientDataStore} keyed by `socketID`.
* 5. Joins the socket to a private room named `socketID` for targeted emits.
* 6. Registers per-socket event handlers (see below).
* 7. Removes the `clientDataStore` entry on disconnect.
*
* **Socket events handled per connection:**
* | Event | Payload | Effect |
* |-----------------|-------------------------------|--------|
* | `transaction` | `{ start: boolean }` | Marks bulk-transaction mode on `socket.data.transaction` |
* | `monitor` | `{ UID: string\|null }` | Adds list UID to `updateAbo` Set; `null` clears the Set |
* | `monitorObject` | `{ UID: string\|null }` | Adds object UID to `objectAbo` Set; `null` clears the Set |
* | `setRoot` | `{ UIDroot: string }` | Updates active organisation for the connection |
* | `disconnect` | reason string | Removes entry from `clientDataStore` |
* | `error` | Error | Logs the socket error |
*
* @param {SocketIOServer} io - Socket.IO server instance
* @returns {void}
*/
export const setupSocketHandlers = (io) => {
ioInstance = io;
io.on('connection', (socket) => {
try {
// User is set by createSocketAuthMiddleware from Bearer token
const userInfo = socket.data.user;
if (!userInfo) {
socket.disconnect(true);
console.error('Socket connection without valid authentication');
return;
}
const user = userInfo.userUID || userInfo.id;
// Get client ID from query parameters
const id = socket.handshake.query.id;
if (!id) {
socket.disconnect(true);
console.error('Socket connection without client ID');
return;
}
// Get organization from header or user token
const orgFromHeader = socket.handshake.headers['x-organization'];
const orgUID = orgFromHeader || userInfo.organization || userInfo.loginOrgaUID;
if (!orgUID || orgUID === 'undefined') {
socket.disconnect(true);
console.error('Socket connection without organization context');
return;
}
// Create a unique ID for this client
const UID = 'UUID-' + id;
const socketID = Buffer.from(UID).toString('base64');
console.log(`Client connected: ${user} (${userInfo.source}), org: ${orgUID}, socketID: ${socketID}`);
// Store connection info
socket.data.connectID = socketID;
socket.data.user = user;
socket.data.userInfo = userInfo;
socket.data.root = orgUID;
socket.data.userHex = UUID2hex(user);
// Create a session object for compatibility with authChecks functions
// Using normalizeUserSession from auth-lib for consistency
const normalizedUser = normalizeUserSession(userInfo, userInfo.source || 'websocket');
socket.request.session = {
user: user,
baseUser: userInfo.baseUserUID || user,
root: orgUID,
loginOrgaUID: userInfo.loginOrgaUID || orgUID,
authUser: normalizedUser,
};
// Also set organization in headers for other middleware
if (!socket.request.headers) {
socket.request.headers = {};
}
socket.request.headers['x-organization'] = orgUID;
// Join a room for this specific client
socket.join(socketID);
// Create client data entry
clientDataStore.set(socketID, {
updateBuffer: null,
updateAbo: new Set(),
objectAbo: new Set(),
messageBuffer: [],
UIDuser: user,
UIDroot: orgUID,
orgSource: orgFromHeader ? 'header' : (socket.request.session.root ? 'session' : 'user-default'),
connectedAt: Date.now()
});
// Handle transactions
socket.on('transaction', (data) => {
socket.data.transaction = data.start;
socket.emit('transactionAck', { received: true });
});
// Handle list monitoring
socket.on('monitor', async (data) => {
if (!data) return;
if (data.UID === null) {
// Clear all list monitors (sent by frontend before re-syncing)
const clientData = clientDataStore.get(socketID);
if (clientData) clientData.updateAbo = new Set();
socket.emit('monitorAck', { success: true });
} else if (data.UID) {
await addListAbo(socketID, data.UID, socket.request);
socket.emit('monitorAck', { success: true });
}
});
// Handle object monitoring
socket.on('monitorObject', async (data) => {
if (!data) return;
if (data.UID === null) {
// Clear all object monitors (sent by frontend before re-syncing)
const clientData = clientDataStore.get(socketID);
if (clientData) clientData.objectAbo = new Set();
socket.emit('monitorObjectAck', { success: true });
} else if (data.UID) {
await addObjectAbo(socketID, data.UID, socket.request);
socket.emit('monitorObjectAck', { success: true });
}
});
// Replace your ping/pong with a custom event
socket.on('setRoot', (data) => {
if (data && data.UIDroot) {
socket.data.root = data.UIDroot;
socket.data.rootHex = UUID2hex(data.UIDroot);
// Update client data store
const clientData = clientDataStore.get(socketID);
if (clientData) {
clientData.UIDroot = data.UIDroot;
clientData.orgSource = 'dynamic';
}
}
socket.emit('rootSet', { success: true, root: socket.data.root });
// Send any buffered messages
sendMessageBuffered(socket, null);
});
// Handle client disconnect
socket.on('disconnect', (reason) => {
console.log(`Client disconnected: ${socket.data.user}, reason: ${reason}`);
clientDataStore.delete(socketID);
});
// Handle connection errors
socket.on('error', (error) => {
console.error(`Socket error for client ${socketID}:`, error);
});
} catch (e) {
console.error('Error in Socket.IO connection handler:', e);
socket.disconnect(true);
}
});
};
/**
* Sends a message to a client, buffering update messages when the socket is
* temporarily disconnected.
*
* **Behaviour:**
* - If `message.update` is truthy, the message is treated as a list-update
* notification. Only the latest such notification per client is kept
* (`clientData.updateBuffer`). If the socket is connected it is sent
* immediately and the buffer is cleared; otherwise it waits.
* - Any other message payload is forwarded directly via `socket.emit`
* (Socket.IO itself buffers these internally when disconnected).
* - When called with `message = null` the function flushes any buffered
* update notification. This is triggered automatically after a successful
* `setRoot` event.
*
* @param {SocketIOSocket} socket - The target Socket.IO socket
* @param {Object|null} message - Message to send, or `null` to flush the buffer
* @returns {void}
*/
export const sendMessageBuffered = (socket, message) => {
try {
// Make sure we have a valid socket and connection ID
if (!socket || !socket.data || !socket.data.connectID) {
return;
}
const clientData = clientDataStore.get(socket.data.connectID);
if (!clientData) {
return;
}
// If socket is not connected, Socket.IO will automatically buffer the message
// for future delivery when connection is restored
if (message) {
if (message.update) {
// For updates, we only care about the latest one, so store it
clientData.updateBuffer = { UID: message.UID, timestamp: message.timestamp };
// Only send if connected
if (socket.connected) {
socket.emit('update', {
update: true,
UID: message.UID,
timestamp: message.timestamp
});
clientData.updateBuffer = null;
}
} else {
// Regular messages can be sent directly - Socket.IO will buffer if needed
socket.emit('message', message);
}
} else if (socket.connected) {
// This is a request to flush any pending messages
// Send update buffer if it exists
if (clientData.updateBuffer) {
socket.emit('update', {
update: true,
UID: clientData.updateBuffer.UID,
timestamp: clientData.updateBuffer.timestamp
});
clientData.updateBuffer = null;
}
}
} catch (e) {
errorLogger(e);
}
};
/**
* Adds one or more object UIDs to the monitoring set of a connected client.
*
* Each UID is authorisation-checked via {@link isObjectVisible} before being
* added. Unauthorised UIDs are silently skipped.
*
* Passing `'quit'` or `null` clears the entire `objectAbo` Set, which is the
* same effect as the frontend sending `monitorObject { UID: null }` during a
* subscription re-sync.
*
* Once registered, the backend will push `{ object: true, value }` messages
* via {@link addUpdateEntry} whenever the object changes.
*
* @param {string} socketID - Base64-encoded socket identifier (key in clientDataStore)
* @param {string|string[]|null|'quit'} UIDs - Object UUID(s) to subscribe to, or `null`/`'quit'` to clear
* @param {Object} req - HTTP request object used for authorisation checks
* @returns {Promise<void>}
*/
export const addObjectAbo = async (socketID, UIDs, req) => {
try {
const clientData = clientDataStore.get(socketID);
if (!clientData) return;
if (UIDs === 'quit' || UIDs === null) {
clientData.objectAbo = new Set();
} else {
const UIDobjects = Array.isArray(UIDs) ? UIDs.map(U => UUID2hex(U)) : [UUID2hex(UIDs)];
for (const myUID of UIDobjects) {
if (await isObjectVisible(req, myUID)) {
clientData.objectAbo.add(myUID);
}
}
}
} catch (e) {
errorLogger(e);
}
};
/**
* Adds a list UID to the monitoring set of a connected client.
*
* The UID is authorisation-checked via {@link isObjectVisible} before being
* added. An unauthorised UID is silently skipped.
*
* Passing `'quit'` or `null` clears the entire `updateAbo` Set, which is the
* same effect as the frontend sending `monitor { UID: null }` during a
* subscription re-sync.
*
* Once registered, the backend will push `{ update: true, UID, timestamp }`
* messages via {@link addUpdateList} / {@link broadcastUpdate} whenever the
* list changes.
*
* @param {string} socketID - Base64-encoded socket identifier (key in clientDataStore)
* @param {string|null|'quit'} UID - List UUID to subscribe to, or `null`/`'quit'` to clear
* @param {Object} req - HTTP request object used for authorisation checks
* @returns {Promise<void>}
*/
export const addListAbo = async (socketID, UID, req) => {
try {
const clientData = clientDataStore.get(socketID);
if (!clientData) return;
if (UID === 'quit' || UID === null) {
clientData.updateAbo = new Set();
} else {
const UIDlist = UUID2hex(UID);
if (await isObjectVisible(req, UIDlist)) {
clientData.updateAbo.add(UIDlist);
}
}
} catch (e) {
errorLogger(e);
}
};
/**
* Broadcasts the current job-queue status to all clients of an organisation.
*
* This is an organisation-wide broadcast — every connected client whose
* `UIDroot` matches `root` receives the message, regardless of individual
* subscriptions. The status is also cached in `clientData.queueStatus` for
* reference.
*
* The frontend receives `{ root, queueStatus }` and calls any registered
* `setQueueStatus` callbacks across all active subscriptions.
*
* @param {string|Buffer} root - Organisation UID to address
* @param {any} status - Queue status payload to forward to clients
* @returns {void}
*/
export const updateQueueStatus = (root, status) => {
try {
if (!ioInstance) return;
for (const [socketID, clientData] of clientDataStore) {
if (clientData.UIDroot &&
UUID2hex(clientData.UIDroot).equals(UUID2hex(root))) {
const socket = ioInstance.in(socketID);
socket.emit('message', { root: root, queueStatus: status });
// Also update the client data
clientData.queueStatus = status;
}
}
} catch (e) {
errorLogger(e);
}
};
/**
* Notifies all clients of an organisation that their UI template has changed.
*
* This is an organisation-wide broadcast — every connected client whose
* `UIDroot` matches `root` receives `{ root, updateTemplate: <timestamp> }`.
* The frontend reacts by re-fetching the template configuration.
*
* @param {string|Buffer} root - Organisation UID whose clients should refresh their template
* @returns {void}
*/
export const updateTemplate = (root) => {
try {
if (!ioInstance) return;
for (const [socketID, clientData] of clientDataStore) {
if (clientData.UIDroot &&
UUID2hex(clientData.UIDroot).equals(UUID2hex(root))) {
const socket = ioInstance.in(socketID);
socket.emit('message', {
root: root,
updateTemplate: Date.now()
});
}
}
} catch (e) {
errorLogger(e);
}
};
/**
* Notifies all clients of an organisation that the app configuration has changed.
*
* This is an organisation-wide broadcast — every connected client whose
* `UIDroot` matches `root` receives `{ updateConfig: <timestamp>, app }`.
* The frontend re-fetches config for the specified app.
*
* @param {string|Object} app - App identifier or config object forwarded to the client
* @param {string|Buffer} root - Organisation UID whose clients should refresh their config
* @returns {void}
*/
export const updateConfig = (app, root) => {
try {
if (!ioInstance) return;
for (const [socketID, clientData] of clientDataStore) {
if (clientData.UIDroot &&
UUID2hex(clientData.UIDroot).equals(UUID2hex(root))) {
const socket = ioInstance.in(socketID);
socket.emit('message', {
updateConfig: Date.now(),
app
});
}
}
} catch (e) {
errorLogger(e);
}
};
/**
* Pushes a fake-login (impersonation) state update to all sockets of a user.
*
* When an admin impersonates another user the session's `fakeLogin` flag
* changes. This broadcasts `{ baseUser, user, fakeLogin }` to every socket
* belonging to that user so the frontend can update the displayed identity
* without a page reload.
*
* @param {Object} session - Session data for the affected user
* @param {string} session.user - userUID of the connected user
* @param {string} session.baseUser - userUID of the real (base) user
* @param {string|null} session.fakeLogin - Display name during impersonation, or null to clear
* @returns {void}
*/
export const wsFakeUser = (session) => {
try {
if (!ioInstance || !session || !session.user) return;
for (const [socketID, clientData] of clientDataStore) {
if (clientData.UIDuser &&
clientData.UIDuser===session.user) {
const socket = ioInstance.in(socketID);
socket.emit('message', {
baseUser: session.baseUser,
user: session.user,
fakeLogin: session.fakeLogin
});
}
}
} catch (e) {
errorLogger(e);
}
};
/**
* Notifies all sockets of a specific user that their own settings have changed.
*
* Only targets sockets belonging to the given Keycloak userUID, so other users
* in the same organisation are not affected.
*
* @param {string} userUID - Keycloak userUID of the user whose settings changed
* @param {Object} settings - The new settings object to push to the client
* @returns {void}
*/
export const updateUserSettings = (userUID, settings) => {
try {
if (!ioInstance || !userUID) return;
for (const [socketID, clientData] of clientDataStore) {
if (clientData.UIDuser && clientData.UIDuser === userUID) {
const socket = ioInstance.in(socketID);
socket.emit('message', {
updateUserSettings: settings
});
}
}
} catch (e) {
errorLogger(e);
}
};
/**
* Like {@link updateTemplate} but for the event application specifically.
* Emits `{ root, updateTemplateEvent: <timestamp> }` to all matching clients.
*
* @param {string|Buffer} root - Organisation UID whose clients should refresh the event template
* @returns {void}
*/
export const updateTemplateEvent = (root) => {
try {
if (!ioInstance || !root) return;
for (const [socketID, clientData] of clientDataStore) {
if (clientData.UIDroot &&
UUID2hex(clientData.UIDroot).equals(UUID2hex(root))) {
const socket = ioInstance.in(socketID);
socket.emit('message', {
root: root,
updateTemplateEvent: Date.now()
});
}
}
} catch (e) {
errorLogger(e);
}
};
/**
* Broadcasts a long-running operation's progress status to all clients of an organisation.
*
* This is an organisation-wide broadcast — every connected client whose
* `UIDroot` matches `root` receives `{ progressStatus: status }`.
* The frontend forwards the payload to any registered `cbProgressData` callbacks.
*
* Typical callers: import jobs, bulk operations, async queue workers.
*
* @param {string|Buffer} root - Organisation UID to address
* @param {Object} status - Progress payload (structure is caller-defined)
* @returns {void}
*/
export const sendProgressStatus = (root, status) => {
try {
if (!ioInstance || !root) return;
for (const [socketID, clientData] of clientDataStore) {
if (clientData.UIDroot &&
UUID2hex(clientData.UIDroot).equals(UUID2hex(root))) {
const socket = ioInstance.in(socketID);
socket.emit('message', {progressStatus: status });
}
}
} catch (e) {
errorLogger(e);
}
};
/**
* Pushes an object-change payload to all clients that have registered the
* object via `monitorObject` / {@link addObjectAbo}.
*
* Iterates `clientData.objectAbo` (a `Set<Buffer>`) for each connected client
* and emits `{ object: true, value }` to matching sockets.
*
* Called by API routes whenever an object's data changes (e.g. after a PATCH).
*
* @param {string|Buffer} UID - UUID of the changed object
* @param {*} value - Full or partial object payload to push to clients
* @returns {void}
*/
export const addUpdateEntry = (UID, value) => {
try {
if (!ioInstance) return;
const myUID = UUID2hex(UID);
if (!myUID) return;
for (const [socketID, clientData] of clientDataStore) {
if (clientData.objectAbo instanceof Set &&
[...clientData.objectAbo].some(oa => oa && oa.equals(myUID))) {
const socket = ioInstance.in(socketID);
socket.emit('message', { object: true, value: value });
}
}
} catch (e) {
errorLogger(e);
}
};
/**
* Notifies clients that one or more lists have changed, with leading-edge
* debouncing to prevent update storms.
*
* **Debounce strategy (per list UID):**
* - First change: sends `{ update: true, UID, timestamp }` immediately to all
* subscribed clients and records the UID in `delayedUpdateTimestamps`.
* - Subsequent changes within the debounce window: refreshes the timestamp
* but does NOT send again immediately.
* - {@link monitorDelayedUpdates} (called by a periodic interval) flushes any
* entry that has been quiet for the configured debounce period.
*
* Clients subscribed via `monitor` / {@link addListAbo} have the matching
* UID in their `updateAbo` Set and receive the notification.
*
* @param {string|Buffer|string[]|Buffer[]} values - List UUID(s) that have changed
* @returns {boolean} `true` if at least one update was processed, `false` on error
*/
export const addUpdateList = (values) => {
try {
const updates = Array.isArray(values)
? values.filter(v => v).map(v => UUID2hex(v))
: values ? [UUID2hex(values)] : [];
if (updates.length > 0) {
const timestamp = Date.now();
for (const update of updates) {
const updateKey = update.toString('base64');
// Check if this update is already being debounced
if (delayedUpdateTimestamps.has(updateKey)) {
// Update already queued, just refresh its timestamp
delayedUpdateTimestamps.set(updateKey, timestamp);
} else {
// First update for this list - send immediately and start debouncing
delayedUpdateTimestamps.set(updateKey, timestamp);
// Send immediately to subscribed clients
for (const [socketID, clientData] of clientDataStore) {
if (clientData.updateAbo instanceof Set &&
[...clientData.updateAbo].some(abo => abo && abo.equals(update))) {
const socket = ioInstance.in(socketID);
socket.emit('message', {
update: true,
UID: HEX2uuid(update),
timestamp: timestamp
});
}
}
}
}
return true;
}
return false;
} catch (e) {
errorLogger(e);
return false;
}
};
/**
* Processes the `delayedUpdateTimestamps` map and flushes any entries that
* have been quiet for longer than the debounce threshold (2.5 s).
*
* Should be invoked on a periodic interval (e.g. `setInterval`) by the
* server startup code. For each overdue entry it:
* 1. Finds all clients with a matching UID in their `updateAbo` Set.
* 2. Emits `{ update: true, UID, timestamp }` to those clients.
* 3. Removes the entry from `delayedUpdateTimestamps`.
*
* This is the trailing-edge part of the two-phase debounce implemented
* together with {@link addUpdateList}.
*
* @returns {void}
*/
export const monitorDelayedUpdates = () => {
try {
if (!ioInstance) return;
const debounceDelay = 2500; // 2.5 seconds debounce
const now = Date.now();
// Check all delayed updates
for (const [UIDlistB64, timestamp] of delayedUpdateTimestamps) {
// If update has been delayed long enough, process it
if (now - timestamp > debounceDelay) {
try {
const UIDlist = Buffer.from(UIDlistB64, 'base64');
// Find all clients that have subscribed to this list
for (const [socketID, clientData] of clientDataStore) {
if (clientData.updateAbo instanceof Set &&
[...clientData.updateAbo].some(abo => abo && abo.equals(UIDlist))) {
const socket = ioInstance.in(socketID);
// Send update notification
socket.emit('message', {
update: true,
UID: HEX2uuid(UIDlist),
timestamp: now
});
}
}
// Remove this update from the delayed updates map
delayedUpdateTimestamps.delete(UIDlistB64);
} catch (innerError) {
console.error('Error processing delayed update:', innerError);
}
}
}
} catch (e) {
errorLogger(e);
}
};
/**
* Immediately emits a list-update notification to all clients monitoring `UID`,
* bypassing the debounce logic of {@link addUpdateList}.
*
* Use this when the update must arrive without delay (e.g. after a server-side
* transaction completes and the client needs to refresh right away).
* Emits `{ update: true, UID, timestamp }` via the `update` event.
*
* @param {string|Buffer} UID - UUID of the changed list
* @param {number} [timestamp=Date.now()] - Timestamp to include in the message
* @returns {void}
*/
export const broadcastUpdate = (UID, timestamp = Date.now()) => {
try {
if (!ioInstance) return;
const myUID = UUID2hex(UID);
if (!myUID) return;
for (const [socketID, clientData] of clientDataStore) {
if (clientData.updateAbo instanceof Set &&
[...clientData.updateAbo].some(abo => abo && abo.equals(myUID))) {
const socket = ioInstance.in(socketID);
socket.emit('update', {
update: true,
UID: HEX2uuid(myUID),
timestamp
});
}
}
} catch (e) {
errorLogger(e);
}
};
/**
* Sends a transaction-completion message to a single specific client.
*
* Unlike the organisation-wide broadcast functions, this targets exactly one
* socket by its `socketID` room. Emits a `transactionComplete` event with
* the provided data payload.
*
* Called after a bulk database transaction finishes, so the initiating client
* can refresh its state.
*
* @param {string} socketID - Base64-encoded socket identifier (key in clientDataStore)
* @param {Object} data - Transaction result payload to forward to the client
* @returns {void}
*/
export const sendTransaction = (socketID, data) => {
try {
if (!ioInstance) return;
const socket = ioInstance.in(socketID);
socket.emit('transactionComplete', data);
} catch (e) {
errorLogger(e);
}
};
/**
* Clears all in-memory state managed by this module.
*
* Empties both {@link clientDataStore} and {@link delayedUpdateTimestamps}.
* Intended for use during a graceful server shutdown or in test teardown to
* prevent state leaking between test cases.
*
* Does NOT close existing socket connections — call `io.close()` separately
* if a full shutdown is required.
*
* @returns {void}
*/
export const cleanupSocketData = () => {
try {
clientDataStore.clear();
delayedUpdateTimestamps.clear();
} catch (e) {
errorLogger(e);
}
};
// Update the default export to include all functions
export default {
clientDataStore,
delayedUpdateTimestamps,
setupSocketHandlers,
sendMessageBuffered,
broadcastUpdate, // <-- added
sendTransaction, // <-- added
monitorDelayedUpdates,
cleanupSocketData, // <-- added
updateQueueStatus,
updateTemplate,
updateConfig,
wsFakeUser,
updateUserSettings,
updateTemplateEvent,
sendProgressStatus,
addUpdateEntry,
addUpdateList,
};