diff --git a/docs/en/getting-started.md b/docs/en/getting-started.md
index 9e1214f53..bffe120ff 100644
--- a/docs/en/getting-started.md
+++ b/docs/en/getting-started.md
@@ -152,7 +152,7 @@ In FlowFuse Dashboard we can configure a given node type to ["Accept Client Data
Screenshot of an example "Client Data" tab
-If "Include Client Data" is toggled on, then _all_ `msg` objects emitted from _all_ nodes will contain a `msg._client` object, which will at a minimum detail the `socketId` for the connected client. It is possible to add on more data to this object, such as a username, email address, or other unique identifier with Dashboard plugins, e.g. the [FlowFuse User Plugin](https://flowfuse.com/blog/2024/04/displaying-logged-in-users-on-dashboard/).
+If "Include Client Data" is toggled on, then _all_ `msg` objects emitted from _all_ nodes will contain a `msg._client` object, which will at a minimum detail the `clientId` and `socketId` for the connected client. It is possible to add on more data to this object, such as a username, email address, or other unique identifier with Dashboard plugins, e.g. the [FlowFuse User Plugin](https://flowfuse.com/blog/2024/04/displaying-logged-in-users-on-dashboard/).
The "Accept Client Data" table allow configuration over which node types will pay attention to any provided `msg._client` information. Any `msg` sent _to_ one of these nodes can include a `msg._client` value to specify a particular connection (e.g. username, socket ID) that the data should be sent to, rather than to all clients.
diff --git a/docs/en/nodes/widgets/ui-control.md b/docs/en/nodes/widgets/ui-control.md
index 889a59c52..bea440bcc 100644
--- a/docs/en/nodes/widgets/ui-control.md
+++ b/docs/en/nodes/widgets/ui-control.md
@@ -200,4 +200,57 @@ msg = {
tab: '',
name: ''
}
-```
\ No newline at end of file
+```
+
+### Client Presence
+
+The `connect`/`lost` events above are per _socket_, and a socket's id changes every time the connection is re-established (a device sleep, a network blip, a page reload). That makes `socketId` unreliable as a key for tracking a client over time.
+
+For higher-level, per-_client_ events keyed on the stable [`clientId`](../../user/multi-tenancy.md#core-client-data), set the node's **Output** to **Client Presence Events Only** (or **All Events**). It then emits:
+
+#### client-connected
+
+A client opens its first connection, a genuinely new client:
+
+```js
+msg = {
+ payload: 'client-connected',
+ _client: {
+ clientId: '',
+ socketId: ''
+ }
+}
+```
+
+#### client-reconnected
+
+A client that had dropped returns within a short grace window (same `clientId`, new socket):
+
+```js
+msg = {
+ payload: 'client-reconnected',
+ _client: {
+ clientId: '',
+ socketId: ''
+ }
+}
+```
+
+#### client-gone
+
+A client's last connection drops and it does not return within the grace window:
+
+```js
+msg = {
+ payload: 'client-gone',
+ _client: {
+ clientId: ''
+ }
+}
+```
+
+The grace window means a brief blip or a page refresh does **not** fire `client-gone`; only a genuine departure does. Opening a second tab of a client that is already connected emits nothing (it is already present).
+
+Use these to maintain per-client state that survives reconnects: add on `client-connected`, keep on `client-reconnected`, and remove on `client-gone`, all keyed on `clientId`.
+
+> **Note:** the `client-*` events also fire under **All Events**. Their `client-`-prefixed payloads are distinct from the socket-level `connect`/`lost`, so a flow switching on `msg.payload` won't confuse the two.
diff --git a/docs/en/nodes/widgets/ui-event.md b/docs/en/nodes/widgets/ui-event.md
index df49a03ea..a8fdcef17 100644
--- a/docs/en/nodes/widgets/ui-event.md
+++ b/docs/en/nodes/widgets/ui-event.md
@@ -52,6 +52,7 @@ msg = {
}
},
_client: {
+ clientId: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
socketId: '1234',
socketIp: '127.0.0.1',
}
diff --git a/docs/en/user/multi-tenancy.md b/docs/en/user/multi-tenancy.md
index db580bc5e..d3cf64906 100644
--- a/docs/en/user/multi-tenancy.md
+++ b/docs/en/user/multi-tenancy.md
@@ -30,9 +30,10 @@ With "Include Client Data" enabled, every `msg` a node emits will have a `_clien
### Core Client Data
-Out of the box, Dashboard will append two piece of information to the `_client` object:
+Out of the box, Dashboard will append this information to the `_client` object:
-- `socketId`: The unique ID of the socket connection that the client is using to interact with the Dashboard.
+- `clientId`: A stable identifier for the browser, generated on first load and kept in `localStorage`. Unlike `socketId`, it survives reconnects, page reloads, and browser restarts, making it a reliable key for per-client state that outlives a single connection. It is set by the client, so treat it as an identifier, not an authorization boundary. The [`ui-control` node](../nodes/widgets/ui-control.md#client-presence) can emit presence events keyed on this id.
+- `socketId`: The unique ID of the socket connection that the client is using to interact with the Dashboard. This changes on every reconnect (device sleep, network blip, reload), so it is not stable for tracking a client over time.
- `socketIp`: The IP address of the client interacting with the Dashboard.
### Authentication Providers
@@ -82,7 +83,7 @@ In the [Dashboard sidebar](./sidebar.md#client-data) within the Node-RED Editor,
Client data defines information on the user/client interacting with the Dashboard. This data can be appended to every `msg` a node emits, underneath the `msg._client` object.
-When "Include Client Data" is enabled, every `msg._client` will detail the `socketId` and `socketIp` of any connected users.
+When "Include Client Data" is enabled, every `msg._client` will detail the `clientId`, `socketId`, and `socketIp` of any connected users.
## Examples
diff --git a/docs/en/user/sidebar.md b/docs/en/user/sidebar.md
index 5b31f0794..1dd368754 100644
--- a/docs/en/user/sidebar.md
+++ b/docs/en/user/sidebar.md
@@ -36,7 +36,7 @@ Dashboard 2.0 can append data to every `msg` a node emits that details informati
Defines whether or not any client data is being appended to messages emitted by the Dashboard. If on, then a new `msg._client` value will be available to you which the relevant data included.
-You will also find a list here of "Data providers" that are currently active in the Dashboard. In addition to the core provider (which provides `msg._client.socketId`), it will list any plugins that have declared `auth: true` in their `index.html` file (see [Plugins docs](../contributing/plugins/index.md) for more information)
+You will also find a list here of "Data providers" that are currently active in the Dashboard. In addition to the core provider (which provides `msg._client.clientId` and `msg._client.socketId`), it will list any plugins that have declared `auth: true` in their `index.html` file (see [Plugins docs](../contributing/plugins/index.md) for more information)
#### Accepts Client Data
diff --git a/nodes/config/ui_base.js b/nodes/config/ui_base.js
index 833f54095..085edf961 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,7 @@ module.exports = function (RED) {
ioServer: null,
/** @type {Object.} */
connections: {},
+ clientStore: createClientStore(),
settings: {},
contribs: {}
}
@@ -413,6 +415,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 +594,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 +610,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..d1f3ff5f9
--- /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? }:
+ * connected: count 0 -> 1, no grace pending (new client)
+ * reconnected: 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 ? 'reconnected' : 'connected', 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)
$('').val(name).text(text).appendTo(sel)
}
diff --git a/nodes/widgets/ui_control.js b/nodes/widgets/ui_control.js
index 671316789..281492fe5 100644
--- a/nodes/widgets/ui_control.js
+++ b/nodes/widgets/ui_control.js
@@ -411,7 +411,27 @@ module.exports = function (RED) {
node.error('No UI configured')
}
+ let onClientEvent = null
+ let clientEventStore = null
+ if (ui && (config.events === 'all' || config.events === 'clients')) {
+ clientEventStore = ui.uiShared?.clientStore
+ if (clientEventStore) {
+ onClientEvent = (e) => {
+ const wNode = RED.nodes.getNode(node.id)
+ if (wNode && typeof wNode.send === 'function') {
+ const client = { clientId: e.clientId }
+ if (e.socketId) { client.socketId = e.socketId }
+ wNode.send({ payload: 'client-' + e.event, _client: client })
+ }
+ }
+ clientEventStore.events.on('client', onClientEvent)
+ }
+ }
+
node.on('close', function (removed, done) {
+ if (onClientEvent && clientEventStore) {
+ clientEventStore.events.off('client', onClientEvent)
+ }
if (removed) {
// handle node being removed
ui?.deregister(null, null, node)
diff --git a/test/store/clients.spec.js b/test/store/clients.spec.js
new file mode 100644
index 000000000..21014bf58
--- /dev/null
+++ b/test/store/clients.spec.js
@@ -0,0 +1,96 @@
+const should = require('should') // eslint-disable-line no-unused-vars
+
+const { createClientStore } = require('../../nodes/store/clients.js')
+
+// Controllable timer so grace behavior is deterministic
+function fakeTimer () {
+ let pending = null
+ return {
+ setTimeoutFn: (fn) => { pending = fn; return { id: 1 } },
+ clearTimeoutFn: () => { pending = null },
+ fire: () => { const fn = pending; pending = null; if (fn) { fn() } }
+ }
+}
+
+function collect (store) {
+ const seen = []
+ store.events.on('client', (e) => seen.push(e))
+ return seen
+}
+
+describe('client store', function () {
+ it('emits connect on a genuinely new client', function () {
+ const store = createClientStore()
+ const events = collect(store)
+ store.connect('c1', 's1')
+ events.should.have.length(1)
+ events[0].should.match({ event: 'connected', clientId: 'c1' })
+ })
+
+ it('does not emit for a second tab of the same client', function () {
+ const store = createClientStore()
+ store.connect('c1', 's1')
+ const events = collect(store)
+ store.connect('c1', 's2') // second tab
+ events.should.have.length(0)
+ })
+
+ it('does not go gone while one of two sockets is still live', function () {
+ const t = fakeTimer()
+ const store = createClientStore(t)
+ store.connect('c1', 's1')
+ store.connect('c1', 's2')
+ const events = collect(store)
+ store.disconnect('c1', 's1') // s2 still live -> no grace, no gone
+ t.fire() // nothing should be pending
+ events.should.have.length(0)
+ })
+
+ it('emits reconnect (not gone) when it comes back within grace', function () {
+ const t = fakeTimer()
+ const store = createClientStore(t)
+ store.connect('c1', 's1')
+ const events = collect(store)
+ store.disconnect('c1', 's1') // last socket -> grace pending
+ store.connect('c1', 's2') // returns before grace fires
+ events.should.matchAny({ event: 'reconnected', clientId: 'c1' })
+ events.should.not.matchAny({ event: 'gone' })
+ })
+
+ it('emits gone when the grace window elapses', function () {
+ const t = fakeTimer()
+ const store = createClientStore(t)
+ store.connect('c1', 's1')
+ const events = collect(store)
+ store.disconnect('c1', 's1')
+ t.fire() // grace elapses
+ events.should.matchAny({ event: 'gone', clientId: 'c1' })
+ })
+
+ it('re-emits connect after a client has gone (entry was deleted)', function () {
+ const t = fakeTimer()
+ const store = createClientStore(t)
+ store.connect('c1', 's1')
+ store.disconnect('c1', 's1')
+ t.fire() // gone -> entry deleted
+ const events = collect(store)
+ store.connect('c1', 's2') // same clientId, but it's a fresh presence now
+ events.should.matchAny({ event: 'connected', clientId: 'c1' })
+ })
+
+ it('is silent when a new socket arrives while the old one is still live', function () {
+ const store = createClientStore()
+ store.connect('c1', 's1')
+ const events = collect(store)
+ store.connect('c1', 's2') // e.g. reconnect races ahead of the old socket's drop
+ events.should.have.length(0) // looks like a second tab; no reconnect/connect
+ })
+
+ it('ignores connects/disconnects with no clientId', function () {
+ const store = createClientStore()
+ const events = collect(store)
+ store.connect(undefined, 's1')
+ store.disconnect(undefined, 's1')
+ events.should.have.length(0)
+ })
+})
diff --git a/test/ui/client-id.spec.js b/test/ui/client-id.spec.js
new file mode 100644
index 000000000..e081a4ce1
--- /dev/null
+++ b/test/ui/client-id.spec.js
@@ -0,0 +1,35 @@
+const should = require('should') // eslint-disable-line no-unused-vars
+
+const { getOrCreateClientId } = require('../../ui/src/util/client-id.js')
+
+function fakeStorage () {
+ const map = {}
+ return {
+ getItem: (k) => (k in map ? map[k] : null),
+ setItem: (k, v) => { map[k] = v }
+ }
+}
+
+describe('getOrCreateClientId', function () {
+ it('generates and persists an id on first call', function () {
+ const storage = fakeStorage()
+ const id = getOrCreateClientId(storage, () => 'fixed-id')
+ id.should.equal('fixed-id')
+ storage.getItem('nrdb-client-id').should.equal('fixed-id')
+ })
+
+ it('returns the same id on subsequent calls', function () {
+ const storage = fakeStorage()
+ const first = getOrCreateClientId(storage)
+ const second = getOrCreateClientId(storage)
+ second.should.equal(first)
+ })
+
+ it('falls back to a fresh id when storage throws (blocked storage)', function () {
+ const blocked = {
+ getItem: () => { throw new Error('blocked') },
+ setItem: () => { throw new Error('blocked') }
+ }
+ getOrCreateClientId(blocked, () => 'session-id').should.equal('session-id')
+ })
+})
diff --git a/ui/src/main.mjs b/ui/src/main.mjs
index 5c0778b70..fe9c46977 100644
--- a/ui/src/main.mjs
+++ b/ui/src/main.mjs
@@ -9,6 +9,7 @@ import { io } from 'socket.io-client'
import router from './router.mjs'
import Alerts from './services/alerts.js'
import Resize from './directives/resize.js'
+import { getOrCreateClientId } from './util/client-id'
import { nextReconnectInterval } from './util/reconnect-interval'
// Vuetify
@@ -160,10 +161,11 @@ fetch('_setup')
let reconnectTO = null
let reconnecting = false
const editKey = host.searchParams.get('edit-key')
+ const clientId = getOrCreateClientId(() => window.localStorage)
const socket = io({
...setup.socketio,
reconnection: false,
- query: editKey ? { editKey } : undefined // include handshake data so that only original edit-key holder can edit
+ query: { clientId, ...(editKey ? { editKey } : {}) }
})
// handle final disconnection
diff --git a/ui/src/util/client-id.js b/ui/src/util/client-id.js
new file mode 100644
index 000000000..002285c90
--- /dev/null
+++ b/ui/src/util/client-id.js
@@ -0,0 +1,30 @@
+const CLIENT_ID_KEY = 'nrdb-client-id'
+
+function generateClientId () {
+ if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
+ return crypto.randomUUID()
+ }
+ return Date.now().toString(36) + Math.random().toString(36).substring(2)
+}
+
+function getOrCreateClientId (getStorage, generate = generateClientId) {
+ try {
+ const storage = typeof getStorage === 'function' ? getStorage() : getStorage
+ const existing = storage.getItem(CLIENT_ID_KEY)
+ if (existing) {
+ return existing
+ }
+ const id = generate()
+ storage.setItem(CLIENT_ID_KEY, id)
+ return id
+ } catch (_error) {
+ return generate()
+ }
+}
+
+export { getOrCreateClientId }
+
+if (typeof module !== 'undefined' && module.exports) {
+ module.exports = { getOrCreateClientId }
+ module.exports.default = module.exports
+}