From 09522c0154267e2ed909908f81a2301586bc798f Mon Sep 17 00:00:00 2001 From: Noley Holland Date: Wed, 2 Sep 2026 11:01:30 -0700 Subject: [PATCH 1/4] Add stable per-client identity and presence events (client-connect/reconnect/gone) to ui-control, keyed on a durable clientId instead of the ephemeral socket.id --- nodes/config/ui_base.js | 8 ++ nodes/store/clients.js | 53 ++++++++++++ nodes/utils/index.js | 3 +- nodes/widgets/locales/en-US/ui_control.json | 3 +- nodes/widgets/ui_control.html | 2 +- nodes/widgets/ui_control.js | 20 +++++ test/store/clients.spec.js | 96 +++++++++++++++++++++ test/ui/client-id.spec.js | 35 ++++++++ ui/src/main.mjs | 4 +- ui/src/util/client-id.js | 30 +++++++ 10 files changed, 250 insertions(+), 4 deletions(-) create mode 100644 nodes/store/clients.js create mode 100644 test/store/clients.spec.js create mode 100644 test/ui/client-id.spec.js create mode 100644 ui/src/util/client-id.js diff --git a/nodes/config/ui_base.js b/nodes/config/ui_base.js index 833f54095..e82cc60d2 100644 --- a/nodes/config/ui_base.js +++ b/nodes/config/ui_base.js @@ -4,6 +4,7 @@ const path = require('path') const axios = require('axios') const v = require('../../package.json').version +const { createClientStore } = require('../store/clients.js') const datastore = require('../store/data.js') const statestore = require('../store/state.js') const { appendTopic, addConnectionCredentials, getThirdPartyWidgets } = require('../utils/index.js') @@ -59,6 +60,8 @@ module.exports = function (RED) { ioServer: null, /** @type {Object.} */ connections: {}, + // presence registry: clientId -> live sockets, with connect/reconnect/gone events + clientStore: createClientStore(), settings: {}, contribs: {} } @@ -413,6 +416,7 @@ module.exports = function (RED) { socket.on('widget-action', onAction.bind(null, socket)) socket.on('widget-change', onChange.bind(null, socket)) socket.on('widget-load', onLoad.bind(null, socket)) + socket.on('disconnect', () => uiShared.clientStore.disconnect(socket._clientId, socket.id)) } } /** @type {NodeJS.Timeout} */ @@ -591,6 +595,7 @@ module.exports = function (RED) { socket.on('disconnect', reason => { cleanupEventHandlers(socket) delete uiShared.connections[socket.id] + uiShared.clientStore.disconnect(socket._clientId, socket.id) node.log(`Disconnected ${socket.id} due to ${reason}`) }) } @@ -606,6 +611,9 @@ module.exports = function (RED) { // node.connections[socket.id] = socket // store the connection for later use uiShared.connections[socket.id] = socket // store the connection for later use + socket._clientId = socket.handshake?.query?.clientId + uiShared.clientStore.connect(socket._clientId, socket.id) + emitConfig(socket) // clean up then re-register listeners diff --git a/nodes/store/clients.js b/nodes/store/clients.js new file mode 100644 index 000000000..d05baf543 --- /dev/null +++ b/nodes/store/clients.js @@ -0,0 +1,53 @@ +const { EventEmitter } = require('events') + +const DEFAULT_GRACE_MS = 20000 + +/** + * Tracks client presence across reconnects (socket.id churns every reconnect; clientId is stable). + * A client may hold several live sockets (one per tab); events fire on its socket count crossing + * zero, not per socket. Emits { event, clientId, socketId? }: + * connect: count 0 -> 1, no grace pending (new client) + * reconnect: count 0 -> 1 during the grace window (returned before being declared gone) + * gone: count -> 0 and the grace window elapses + * Extra tabs (1 -> 2, 2 -> 1) emit nothing; the client is still present. + */ +function createClientStore ({ graceMs = DEFAULT_GRACE_MS, setTimeoutFn = setTimeout, clearTimeoutFn = clearTimeout } = {}) { + const clients = {} // clientId -> { sockets: Set, graceTO } + const events = new EventEmitter() + events.setMaxListeners(0) + + function connect (clientId, socketId) { + if (!clientId) { return } + let entry = clients[clientId] + const returning = !!(entry && entry.graceTO) + if (!entry) { + entry = clients[clientId] = { sockets: new Set(), graceTO: null } + } + if (entry.graceTO) { + clearTimeoutFn(entry.graceTO) + entry.graceTO = null + } + const wasEmpty = entry.sockets.size === 0 + entry.sockets.add(socketId) + if (wasEmpty) { + events.emit('client', { event: returning ? 'reconnect' : 'connect', clientId, socketId }) + } + } + + function disconnect (clientId, socketId) { + if (!clientId) { return } + const entry = clients[clientId] + if (!entry) { return } + entry.sockets.delete(socketId) + if (entry.sockets.size === 0 && !entry.graceTO) { + entry.graceTO = setTimeoutFn(() => { + delete clients[clientId] + events.emit('client', { event: 'gone', clientId }) + }, graceMs) + } + } + + return { connect, disconnect, events } +} + +module.exports = { createClientStore } diff --git a/nodes/utils/index.js b/nodes/utils/index.js index f12865c70..5b535407f 100644 --- a/nodes/utils/index.js +++ b/nodes/utils/index.js @@ -60,7 +60,8 @@ function addConnectionCredentials (RED, msg, conn, config) { ...item._client, ...{ socketId: conn.id, - socketIp: conn.handshake?.address + socketIp: conn.handshake?.address, + clientId: conn.handshake?.query?.clientId } } return item diff --git a/nodes/widgets/locales/en-US/ui_control.json b/nodes/widgets/locales/en-US/ui_control.json index 9407e712f..45eaacefa 100644 --- a/nodes/widgets/locales/en-US/ui_control.json +++ b/nodes/widgets/locales/en-US/ui_control.json @@ -7,7 +7,8 @@ "events": { "all": "All Events", "change": "Page/Tab Change Events Only", - "connect": "Connection Events Only" + "connect": "Connection Events Only", + "clients": "Client Presence Events Only" } } } \ No newline at end of file diff --git a/nodes/widgets/ui_control.html b/nodes/widgets/ui_control.html index 8e4105249..6d9dbbd2e 100644 --- a/nodes/widgets/ui_control.html +++ b/nodes/widgets/ui_control.html @@ -24,7 +24,7 @@ oneditprepare: function () { const node = this const sel = $('#node-input-events') - for (const name of ['all', 'change', 'connect']) { + for (const name of ['all', 'change', 'connect', 'clients']) { const text = c_('events.' + name) $('