svelte-adapter-uws 0.5.5 → 0.5.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/MIGRATION.md +2 -2
- package/README.md +12 -11
- package/package.json +1 -1
- package/plugins/cursor/server.js +31 -36
- package/plugins/presence/client.d.ts +1 -1
- package/plugins/presence/client.js +6 -6
- package/plugins/presence/server.d.ts +7 -7
- package/plugins/presence/server.js +8 -8
package/MIGRATION.md
CHANGED
|
@@ -145,9 +145,9 @@ These change observable runtime behavior. Most apps are unaffected; a few will n
|
|
|
145
145
|
|
|
146
146
|
### Presence plugin wire format switched to a compact diff protocol
|
|
147
147
|
|
|
148
|
-
**What changed.** The five-event format (`list` / `join` / `updated` / `leave` / `heartbeat`) collapses to two diff-shaped events plus the existing heartbeat: `
|
|
148
|
+
**What changed.** The five-event format (`list` / `join` / `updated` / `leave` / `heartbeat`) collapses to two diff-shaped events plus the existing heartbeat: `state` (full snapshot) and `diff` (joins/leaves). Diffs are microtask-batched. Server and client ship in one bundle, so a single-package upgrade is seamless.
|
|
149
149
|
|
|
150
|
-
**How to migrate.** No action needed for users of the bundled `presence()` Svelte store on the client. Hand-rolled clients that consume the wire directly need to switch decoders to handle `
|
|
150
|
+
**How to migrate.** No action needed for users of the bundled `presence()` Svelte store on the client. Hand-rolled clients that consume the wire directly need to switch decoders to handle `state` and `diff` events. Stale browser tabs from a previous deploy will see a blank presence list until refresh.
|
|
151
151
|
|
|
152
152
|
### Wire single-subscribe frames consult `subscribeBatch` when only `subscribeBatch` is exported
|
|
153
153
|
|
package/README.md
CHANGED
|
@@ -2476,7 +2476,7 @@ presence.leave(ws, platform) // remove from all topics (call from close
|
|
|
2476
2476
|
presence.sync(ws, topic, platform) // send snapshot without joining (for observers)
|
|
2477
2477
|
presence.list(topic) // current user data array
|
|
2478
2478
|
presence.count(topic) // unique user count
|
|
2479
|
-
presence.flushDiffs() // drain buffered
|
|
2479
|
+
presence.flushDiffs() // drain buffered diff publishes synchronously
|
|
2480
2480
|
presence.clear() // reset everything (stops heartbeat timer)
|
|
2481
2481
|
```
|
|
2482
2482
|
|
|
@@ -2484,9 +2484,9 @@ presence.clear() // reset everything (stops heartbeat timer)
|
|
|
2484
2484
|
|
|
2485
2485
|
The plugin emits three frame types on the `__presence:{topic}` channel:
|
|
2486
2486
|
|
|
2487
|
-
- `{event: '
|
|
2488
|
-
- `{event: '
|
|
2489
|
-
- `{event: 'heartbeat', data: {[key]: meta}}` - periodic full-roster refresh, broadcast every `heartbeat` ms (30 s default). Carries a `{userKey: data}` map so a client whose entry aged out of its local `maxAge` sweep can re-add it from the heartbeat alone, without waiting for the next `
|
|
2487
|
+
- `{event: 'state', data: {[key]: meta}}` - full snapshot, sent to a single connection on join or sync.
|
|
2488
|
+
- `{event: 'diff', data: {joins: {[key]: meta}, leaves: {[key]: meta}}}` - changes, broadcast to all subscribers of the topic.
|
|
2489
|
+
- `{event: 'heartbeat', data: {[key]: meta}}` - periodic full-roster refresh, broadcast every `heartbeat` ms (30 s default). Carries a `{userKey: data}` map so a client whose entry aged out of its local `maxAge` sweep can re-add it from the heartbeat alone, without waiting for the next `diff`.
|
|
2490
2490
|
|
|
2491
2491
|
Diffs are buffered in a microtask queue: multiple joins / leaves in the same tick collapse into one diff frame. Within a diff, `leaves` are applied first then `joins`, so an update (same key in both) ends with the user present using the new data. If a key cycles join then leave in the same tick, the diff carries only the latest op (`leave` wins).
|
|
2492
2492
|
|
|
@@ -2501,7 +2501,7 @@ const users = presence('room');
|
|
|
2501
2501
|
// $users = [{ id: '1', name: 'Alice' }, { id: '2', name: 'Bob' }]
|
|
2502
2502
|
```
|
|
2503
2503
|
|
|
2504
|
-
The client store defaults to a 90 s `maxAge` sweep: entries that haven't been refreshed by a heartbeat or `
|
|
2504
|
+
The client store defaults to a 90 s `maxAge` sweep: entries that haven't been refreshed by a heartbeat or `diff` / `state` inside the window are removed from the local map. With the server's 30 s default heartbeat, still-present users are refreshed three times per window and never flicker; ghost entries left over by silent server-side cleanup (cluster mass-disconnect, ungraceful client close) clear within one sweep window.
|
|
2505
2505
|
|
|
2506
2506
|
For admin / audit views that want unbounded retention ("show every user who ever touched this topic"), opt out with `maxAge: 0`:
|
|
2507
2507
|
|
|
@@ -2898,22 +2898,23 @@ const positions = cursor('canvas', { maxAge: 30_000 });
|
|
|
2898
2898
|
|
|
2899
2899
|
#### How throttle works
|
|
2900
2900
|
|
|
2901
|
-
The cursor plugin uses two layers of
|
|
2901
|
+
The cursor plugin uses two layers of throttle:
|
|
2902
2902
|
|
|
2903
|
-
1. **`throttle`** caps how often a single user broadcasts on a single topic.
|
|
2904
|
-
2. **`topicThrottle`** caps how often a topic emits a frame at all. Multiple movers in the same window coalesce into one `bulk` array; a single mover in the window emits one `update`.
|
|
2903
|
+
1. **`throttle`** caps how often a single user broadcasts on a single topic. Leading edge fires the first move immediately; subsequent moves within the window are stored and a trailing timer flushes the latest position at the window boundary.
|
|
2904
|
+
2. **`topicThrottle`** caps how often a topic emits a frame at all. Every move appends to the topic's dirty set and shares a single tracker-wide timer that fires once per cadence cycle. Multiple movers in the same window coalesce into one `bulk` array; a single mover in the window emits one `update`. There is no synchronous leading-edge fire: every flush goes through the tick, so movers arriving from different sockets (each a separate JS task in production) batch into the same frame regardless of how many task boundaries separate them.
|
|
2905
2905
|
|
|
2906
2906
|
```
|
|
2907
2907
|
throttle: 16, topicThrottle: 16
|
|
2908
2908
|
|
|
2909
|
-
t=0 A.update({x:0}) --> 'join' A
|
|
2909
|
+
t=0 A.update({x:0}) --> 'join' A (catalog channel)
|
|
2910
|
+
position queued in topic dirty set
|
|
2910
2911
|
t=4 B.update({x:0}) --> 'join' B (catalog channel)
|
|
2911
2912
|
position queued in topic dirty set
|
|
2912
2913
|
t=8 A.update({x:5}) --> queued (entry-level throttle says wait until t=16)
|
|
2913
|
-
t=16 [
|
|
2914
|
+
t=16 [tick timer fires] --> 'bulk' [{key:A, data:{x:5}}, {key:B, data:{x:0}}]
|
|
2914
2915
|
```
|
|
2915
2916
|
|
|
2916
|
-
The
|
|
2917
|
+
Latency cost vs. the alternate "fire-the-first-mover-synchronously" design: the first mover on an idle topic waits up to `topicThrottleMs` before its frame leaves. At the default 16 ms (~60 Hz) that's one frame-budget; well below the perceptual floor for cursor. The cost buys cross-socket coalescing - without it, the first mover from each socket fragments out as its own single-cursor `update` because uWS dispatches each WS message as its own JS task and microtasks drain between dispatches.
|
|
2917
2918
|
|
|
2918
2919
|
#### Limitations
|
|
2919
2920
|
|
package/package.json
CHANGED
package/plugins/cursor/server.js
CHANGED
|
@@ -383,23 +383,30 @@ export function createCursor(options = {}) {
|
|
|
383
383
|
|
|
384
384
|
/**
|
|
385
385
|
* Route a broadcast through the per-topic coalesce window when
|
|
386
|
-
* `topicThrottle` is enabled, or
|
|
386
|
+
* `topicThrottle` is enabled, or publish immediately when disabled.
|
|
387
387
|
*
|
|
388
|
-
*
|
|
389
|
-
*
|
|
390
|
-
*
|
|
391
|
-
*
|
|
392
|
-
*
|
|
393
|
-
*
|
|
394
|
-
* trailing tick: under sustained pressure (30K RPCs/sec/worker) this
|
|
395
|
-
* fragmented 86% of cursor frames into single-cursor UPDATEs.
|
|
396
|
-
* Microtasks run after the current synchronous code completes but
|
|
397
|
-
* before the next I/O / setTimeout / event-loop tick, so any
|
|
398
|
-
* subsequent broadcast() in the same handler batch adds itself to
|
|
399
|
-
* `dirty` before the flush runs.
|
|
388
|
+
* Every broadcast appends to `dirty` and arms (or shares) the
|
|
389
|
+
* tracker-wide tick timer. When the cadence window has already
|
|
390
|
+
* elapsed since the last flush, the tick is armed at delay 0 so it
|
|
391
|
+
* fires on the next event-loop iteration; otherwise it is armed at
|
|
392
|
+
* the remaining window time. Either way, the actual fanout happens
|
|
393
|
+
* inside `tick()`, never synchronously.
|
|
400
394
|
*
|
|
401
|
-
*
|
|
402
|
-
*
|
|
395
|
+
* Why no synchronous leading-edge fire: uWS dispatches each WS
|
|
396
|
+
* message as its own JS task. Microtasks drain at the C++ <-> JS
|
|
397
|
+
* boundary between tasks, so a `queueMicrotask`-deferred flush
|
|
398
|
+
* (previous design) runs BEFORE the next socket's message handler -
|
|
399
|
+
* cross-socket coalescing window is zero, and every "first message
|
|
400
|
+
* of a new cadence slot" from any socket fires alone as a single-
|
|
401
|
+
* cursor UPDATE. Going through the tick timer instead schedules a
|
|
402
|
+
* macrotask, which is dequeued only after the poll phase processes
|
|
403
|
+
* every ready message on every socket. All messages dispatched in
|
|
404
|
+
* the same loop iteration end up in one flush.
|
|
405
|
+
*
|
|
406
|
+
* Latency cost: the first cursor on an idle topic waits up to
|
|
407
|
+
* `topicThrottleMs` (one cycle) before its frame leaves. At the
|
|
408
|
+
* default 16 ms / 60 Hz this is one frame-budget; at 8 ms / 125 Hz
|
|
409
|
+
* it is half a frame. Below the perceptual floor for cursor.
|
|
403
410
|
*/
|
|
404
411
|
function broadcast(topic, key, data, platform) {
|
|
405
412
|
if (topicThrottleMs <= 0) {
|
|
@@ -409,31 +416,19 @@ export function createCursor(options = {}) {
|
|
|
409
416
|
|
|
410
417
|
let state = topicFlush.get(topic);
|
|
411
418
|
if (!state) {
|
|
412
|
-
|
|
419
|
+
// Anchor lastFlush one full cycle in the past so the first
|
|
420
|
+
// broadcast on a fresh topic is treated as "cycle ready" and
|
|
421
|
+
// schedules the tick at delay 0 with zero drift, rather than
|
|
422
|
+
// inflating drift stats by Date.now() worth of "lateness".
|
|
423
|
+
state = { dirty: new Map(), lastFlush: Date.now() - topicThrottleMs };
|
|
413
424
|
topicFlush.set(topic, state);
|
|
414
425
|
}
|
|
415
426
|
state.dirty.set(key, { data, platform });
|
|
416
|
-
|
|
417
|
-
const now = Date.now();
|
|
418
|
-
if (now - state.lastFlush >= topicThrottleMs) {
|
|
419
|
-
state.lastFlush = now;
|
|
420
|
-
dirtyTopics.delete(topic);
|
|
421
|
-
// Schedule once per cycle slot; subsequent broadcasts inside
|
|
422
|
-
// the same microtask boundary just append to `state.dirty`.
|
|
423
|
-
if (!state.pendingMicroflush) {
|
|
424
|
-
state.pendingMicroflush = true;
|
|
425
|
-
queueMicrotask(() => {
|
|
426
|
-
state.pendingMicroflush = false;
|
|
427
|
-
if (state.dirty.size === 0) return;
|
|
428
|
-
flushDirty(topic, state.dirty);
|
|
429
|
-
state.dirty.clear();
|
|
430
|
-
});
|
|
431
|
-
}
|
|
432
|
-
return;
|
|
433
|
-
}
|
|
434
|
-
|
|
435
427
|
dirtyTopics.add(topic);
|
|
436
|
-
|
|
428
|
+
|
|
429
|
+
const elapsed = Date.now() - state.lastFlush;
|
|
430
|
+
const delay = elapsed >= topicThrottleMs ? 0 : topicThrottleMs - elapsed;
|
|
431
|
+
armTick(delay);
|
|
437
432
|
}
|
|
438
433
|
|
|
439
434
|
/** @type {CursorTracker} */
|
|
@@ -7,7 +7,7 @@ import type { Readable } from 'svelte/store';
|
|
|
7
7
|
* The array updates automatically when users join or leave.
|
|
8
8
|
*
|
|
9
9
|
* Defaults to a 90 s `maxAge` sweep: entries that haven't been refreshed
|
|
10
|
-
* by a heartbeat or
|
|
10
|
+
* by a heartbeat or diff/state inside the window are removed
|
|
11
11
|
* from the local map. The server emits `{userKey: data}` heartbeats
|
|
12
12
|
* every 30 s by default, so still-present users re-appear on the next
|
|
13
13
|
* heartbeat (no flicker). Pass `maxAge: 0` to opt out of the sweep for
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* this module just keeps the client-side state in sync.
|
|
7
7
|
*
|
|
8
8
|
* Defaults to a 90 s `maxAge` sweep: entries that haven't been refreshed
|
|
9
|
-
* by a heartbeat or
|
|
9
|
+
* by a heartbeat or diff/state inside the window are removed
|
|
10
10
|
* from the local map. The in-memory server (and the Redis-backed variant
|
|
11
11
|
* in svelte-adapter-uws-extensions) emits `{userKey: data}` heartbeats
|
|
12
12
|
* every 30 s by default, so a still-present user re-appears on the very
|
|
@@ -127,7 +127,7 @@ export function presence(topic, options) {
|
|
|
127
127
|
sourceUnsub = source.subscribe((event) => {
|
|
128
128
|
if (event === null) return;
|
|
129
129
|
|
|
130
|
-
if (event.event === '
|
|
130
|
+
if (event.event === 'state' && event.data && typeof event.data === 'object') {
|
|
131
131
|
userMap = new Map();
|
|
132
132
|
timestamps.clear();
|
|
133
133
|
const now = Date.now();
|
|
@@ -139,7 +139,7 @@ export function presence(topic, options) {
|
|
|
139
139
|
return;
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
-
if (event.event === '
|
|
142
|
+
if (event.event === 'diff' && event.data && typeof event.data === 'object') {
|
|
143
143
|
const { joins, leaves } = event.data;
|
|
144
144
|
const now = Date.now();
|
|
145
145
|
let changed = false;
|
|
@@ -175,7 +175,7 @@ export function presence(topic, options) {
|
|
|
175
175
|
// recover entries the local sweep had already removed -
|
|
176
176
|
// once an entry aged out, the next heartbeat couldn't
|
|
177
177
|
// bring it back and the user stayed missing until a
|
|
178
|
-
//
|
|
178
|
+
// diff or state arrived.
|
|
179
179
|
for (const [key, data] of Object.entries(event.data)) {
|
|
180
180
|
timestamps.set(key, now);
|
|
181
181
|
const prev = userMap.get(key);
|
|
@@ -187,7 +187,7 @@ export function presence(topic, options) {
|
|
|
187
187
|
} else if (Array.isArray(event.data)) {
|
|
188
188
|
// Back-compat: keys-only heartbeat (older server). Refresh
|
|
189
189
|
// existing entries; cannot recover aged-out ones from this
|
|
190
|
-
// shape. The
|
|
190
|
+
// shape. The diff / state reconciliation
|
|
191
191
|
// path still corrects missing entries on the next event.
|
|
192
192
|
for (const key of event.data) {
|
|
193
193
|
if (timestamps.has(key)) {
|
|
@@ -206,7 +206,7 @@ export function presence(topic, options) {
|
|
|
206
206
|
|
|
207
207
|
// Request a presence snapshot every time the socket opens (initial
|
|
208
208
|
// connect AND reconnects). Without this, a reconnecting client
|
|
209
|
-
// missed any
|
|
209
|
+
// missed any diff frames that fired during the disconnect
|
|
210
210
|
// window and its in-memory map stayed at whatever it last knew.
|
|
211
211
|
// Symmetric to the cursor plugin's `cursor-snapshot` send.
|
|
212
212
|
statusUnsub = status.subscribe((s) => {
|
|
@@ -51,7 +51,7 @@ export interface PresenceOptions<UserData = unknown, Selected extends Record<str
|
|
|
51
51
|
* topics carrying a `{userKey: data}` map of every active user. This
|
|
52
52
|
* refreshes each entry's `maxAge` timer on the client AND re-adds any
|
|
53
53
|
* entry the client swept while the user was still present, so live
|
|
54
|
-
* users do not flicker out when a `
|
|
54
|
+
* users do not flicker out when a `diff` is missed (transient
|
|
55
55
|
* network blip, JS thread saturation).
|
|
56
56
|
*
|
|
57
57
|
* Set this to a value shorter than the client's `maxAge`. The 30 s
|
|
@@ -69,7 +69,7 @@ export interface PresenceOptions<UserData = unknown, Selected extends Record<str
|
|
|
69
69
|
*
|
|
70
70
|
* @example
|
|
71
71
|
* ```js
|
|
72
|
-
* // Disable heartbeats; client must rely on
|
|
72
|
+
* // Disable heartbeats; client must rely on diff alone
|
|
73
73
|
* const presence = createPresence({ heartbeat: 0 });
|
|
74
74
|
* ```
|
|
75
75
|
*/
|
|
@@ -105,9 +105,9 @@ export interface PresenceTracker<Selected extends Record<string, any> = Record<s
|
|
|
105
105
|
*
|
|
106
106
|
* What happens:
|
|
107
107
|
* 1. Adds the user to the topic's presence map
|
|
108
|
-
* 2. Buffers a `join` entry into the next
|
|
108
|
+
* 2. Buffers a `join` entry into the next diff broadcast (microtask-flushed)
|
|
109
109
|
* 3. Subscribes this ws to the presence channel
|
|
110
|
-
* 4. Sends the full current snapshot (`
|
|
110
|
+
* 4. Sends the full current snapshot (`state`) to this ws
|
|
111
111
|
*
|
|
112
112
|
* @example
|
|
113
113
|
* ```js
|
|
@@ -123,7 +123,7 @@ export interface PresenceTracker<Selected extends Record<string, any> = Record<s
|
|
|
123
123
|
*
|
|
124
124
|
* Call this from your `close` hook. Handles multi-tab correctly:
|
|
125
125
|
* if the user has other connections still open, they stay present.
|
|
126
|
-
* Only buffers a `leave` entry into the next
|
|
126
|
+
* Only buffers a `leave` entry into the next diff when the
|
|
127
127
|
* last connection closes.
|
|
128
128
|
*
|
|
129
129
|
* @example
|
|
@@ -136,7 +136,7 @@ export interface PresenceTracker<Selected extends Record<string, any> = Record<s
|
|
|
136
136
|
leave(ws: WebSocket<any>, platform: Platform): void;
|
|
137
137
|
|
|
138
138
|
/**
|
|
139
|
-
* Send the current presence snapshot (`
|
|
139
|
+
* Send the current presence snapshot (`state`) to a connection without joining.
|
|
140
140
|
*
|
|
141
141
|
* Use this for observers (admin dashboards, spectators) who want to
|
|
142
142
|
* see who's present without being counted as present themselves.
|
|
@@ -187,7 +187,7 @@ export interface PresenceTracker<Selected extends Record<string, any> = Record<s
|
|
|
187
187
|
clear(): void;
|
|
188
188
|
|
|
189
189
|
/**
|
|
190
|
-
* Drain any buffered `
|
|
190
|
+
* Drain any buffered `diff` publishes synchronously.
|
|
191
191
|
*
|
|
192
192
|
* Diffs are normally microtask-batched: multiple joins / leaves in the
|
|
193
193
|
* same tick collapse into one broadcast frame. Tests use this to
|
|
@@ -56,7 +56,7 @@ const TOPIC_PREFIX = '__presence:';
|
|
|
56
56
|
* The server periodically publishes a `heartbeat` event to all presence topics carrying a
|
|
57
57
|
* `{userKey: data}` map of every active user. This refreshes each entry's `maxAge` timer on
|
|
58
58
|
* the client AND re-adds any entry the client swept while the user was still present, so
|
|
59
|
-
* live users do not flicker out when a `
|
|
59
|
+
* live users do not flicker out when a `diff` is missed (e.g. transient network
|
|
60
60
|
* blip, JS thread saturation). Set this to a value shorter than the client's `maxAge`
|
|
61
61
|
* (default client `maxAge` is 90 s, so 30 s gives a 3x safety margin). Pass `0` to disable
|
|
62
62
|
* heartbeats entirely (apps that do not use the `maxAge` self-healing path).
|
|
@@ -341,13 +341,13 @@ export function createPresence(options = {}) {
|
|
|
341
341
|
if (op === 'join') joins[key] = data;
|
|
342
342
|
else leaves[key] = data;
|
|
343
343
|
}
|
|
344
|
-
platform.publish(TOPIC_PREFIX + topic, '
|
|
344
|
+
platform.publish(TOPIC_PREFIX + topic, 'diff', { joins, leaves });
|
|
345
345
|
}
|
|
346
346
|
pendingDiffs.clear();
|
|
347
347
|
}
|
|
348
348
|
|
|
349
349
|
/**
|
|
350
|
-
* Build a
|
|
350
|
+
* Build a state snapshot for a topic: {[key]: data}.
|
|
351
351
|
* @param {Map<string, { data: Record<string, any>, count: number }> | undefined} users
|
|
352
352
|
* @returns {Record<string, Record<string, any>>}
|
|
353
353
|
*/
|
|
@@ -387,8 +387,8 @@ export function createPresence(options = {}) {
|
|
|
387
387
|
// Publish a `{userKey: data}` map (rather than a keys-only
|
|
388
388
|
// array) so a client whose entry aged out of its local
|
|
389
389
|
// `maxAge` sweep between heartbeats can re-add it from the
|
|
390
|
-
// heartbeat alone, without waiting for a
|
|
391
|
-
//
|
|
390
|
+
// heartbeat alone, without waiting for a diff /
|
|
391
|
+
// state to reconcile. Matches the Redis-backed
|
|
392
392
|
// variant in svelte-adapter-uws-extensions.
|
|
393
393
|
/** @type {Record<string, any>} */
|
|
394
394
|
const dataMap = {};
|
|
@@ -484,7 +484,7 @@ export function createPresence(options = {}) {
|
|
|
484
484
|
if (existing) {
|
|
485
485
|
// Same user, additional connection (another tab) - bump count.
|
|
486
486
|
// A data change (e.g. avatar updated in another session) becomes
|
|
487
|
-
// a `join` entry in the next
|
|
487
|
+
// a `join` entry in the next diff: client overwrites
|
|
488
488
|
// the existing key with the new data.
|
|
489
489
|
existing.count++;
|
|
490
490
|
if (!deepEqual(existing.data, data)) {
|
|
@@ -507,7 +507,7 @@ export function createPresence(options = {}) {
|
|
|
507
507
|
// user sees the complete state (including themselves) immediately;
|
|
508
508
|
// any pending diff fan-out reaches them too but is idempotent on
|
|
509
509
|
// the client (joins[key] = data is a no-op if already set).
|
|
510
|
-
platform.send(ws, presenceTopic, '
|
|
510
|
+
platform.send(ws, presenceTopic, 'state', snapshotState(users));
|
|
511
511
|
},
|
|
512
512
|
|
|
513
513
|
leave(ws, platform) {
|
|
@@ -527,7 +527,7 @@ export function createPresence(options = {}) {
|
|
|
527
527
|
const users = topicPresence.get(topic);
|
|
528
528
|
const presenceTopic = TOPIC_PREFIX + topic;
|
|
529
529
|
try { ws.subscribe(presenceTopic); } catch { return; }
|
|
530
|
-
platform.send(ws, presenceTopic, '
|
|
530
|
+
platform.send(ws, presenceTopic, 'state', snapshotState(users));
|
|
531
531
|
},
|
|
532
532
|
|
|
533
533
|
list(topic) {
|