Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/en/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ In FlowFuse Dashboard we can configure a given node type to ["Accept Client Data
<img data-zoomable style="max-width: 400px; margin: auto;" src="/images/dashboard-sidebar-clientdata.png" alt="Screenshot of an example 'Client Data' tab"/>
<em>Screenshot of an example "Client Data" tab</em>

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.

Expand Down
55 changes: 54 additions & 1 deletion docs/en/nodes/widgets/ui-control.md
Original file line number Diff line number Diff line change
Expand Up @@ -200,4 +200,57 @@ msg = {
tab: '<Page Index>',
name: '<Page Name>'
}
```
```

### Client Presence <AddedIn version="1.32.0" />

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: '<clientId>',
socketId: '<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: '<clientId>',
socketId: '<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: '<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).

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wondering if this is ideal behaviour.

One of the reasons to expose socketId at all is to be able to direct a message to just that dashboard.

I may open the same dashboard in two tabs, and want to treat them as separate 'sessions'. Each of those sessions would be identifiable via clientId/socketId pair - but only if I know about them.

If we don't emit the client connected events for a second connection, we lose the ability to track all of the open dashboards.

Could we instead emit the events with an additional sockets property that is an array of all active sockets we see this clientId on?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yahhh so I think you can accomplish that here by combining the socket level events with the client-presence events. The socket connect/lost fire per tab and already come through with the clientId, so you can reconcile from that end and get the data you need.

The client-connected/reconnected/gone events are meant to be client-level presence with the grace window, not per-tab. So if you wanted to completely manage both you could use both channels, totally open to adding a sockets array to the presence events if it's useful, but firing one of these on every socket connect/disconnect feels like it'd just duplicate the socket events.


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.
1 change: 1 addition & 0 deletions docs/en/nodes/widgets/ui-event.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ msg = {
}
},
_client: {
clientId: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
socketId: '1234',
socketIp: '127.0.0.1',
}
Expand Down
7 changes: 4 additions & 3 deletions docs/en/user/multi-tenancy.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion docs/en/user/sidebar.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
7 changes: 7 additions & 0 deletions nodes/config/ui_base.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -59,6 +60,7 @@ module.exports = function (RED) {
ioServer: null,
/** @type {Object.<string, Socket>} */
connections: {},
clientStore: createClientStore(),
settings: {},
contribs: {}
}
Expand Down Expand Up @@ -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} */
Expand Down Expand Up @@ -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}`)
})
}
Expand All @@ -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
Expand Down
53 changes: 53 additions & 0 deletions nodes/store/clients.js
Original file line number Diff line number Diff line change
@@ -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<socketId>, 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 }
3 changes: 2 additions & 1 deletion nodes/utils/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion nodes/widgets/locales/en-US/ui_control.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
}
2 changes: 1 addition & 1 deletion nodes/widgets/ui_control.html
Original file line number Diff line number Diff line change
Expand Up @@ -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)
$('<option/>').val(name).text(text).appendTo(sel)
}
Expand Down
20 changes: 20 additions & 0 deletions nodes/widgets/ui_control.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
96 changes: 96 additions & 0 deletions test/store/clients.spec.js
Original file line number Diff line number Diff line change
@@ -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)
})
})
35 changes: 35 additions & 0 deletions test/ui/client-id.spec.js
Original file line number Diff line number Diff line change
@@ -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')
})
})
Loading
Loading