@nolag/dash 0.1.2 → 1.1.0

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/dist/index.mjs CHANGED
@@ -1,5 +1,3 @@
1
- import { NoLag } from '@nolag/js-sdk';
2
-
3
1
  class EventEmitter {
4
2
  constructor() {
5
3
  this._handlers = new Map();
@@ -160,6 +158,29 @@ function createLogger(prefix, enabled) {
160
158
  }
161
159
  return (...args) => { console.log(`[${prefix}]`, ...args); };
162
160
  }
161
+ // ============ Wrapper registry ============
162
+ // One wrapper instance per (client, appName): two wrappers sharing an app on
163
+ // one connection would collide on topics, presence and the online lobby.
164
+ // Warn (not throw): HMR and tests legitimately construct before disposing.
165
+ const wrapperRegistry = new WeakMap();
166
+ /** Register a wrapper against a client + appName; warns on collision. */
167
+ function registerWrapper(client, appName, wrapperName) {
168
+ let apps = wrapperRegistry.get(client);
169
+ if (!apps) {
170
+ apps = new Map();
171
+ wrapperRegistry.set(client, apps);
172
+ }
173
+ const existing = apps.get(appName);
174
+ if (existing) {
175
+ console.warn(`[${wrapperName}] Another wrapper (${existing}) is already attached to this client for app "${appName}". ` +
176
+ `Use one wrapper per (client, app) — detach the other instance first.`);
177
+ }
178
+ apps.set(appName, wrapperName);
179
+ }
180
+ /** Release a wrapper's (client, appName) registration on detach. */
181
+ function releaseWrapper(client, appName) {
182
+ wrapperRegistry.get(client)?.delete(appName);
183
+ }
163
184
 
164
185
  const DEFAULT_APP_NAME = 'dash';
165
186
  const DEFAULT_MAX_METRIC_POINTS = 1000;
@@ -167,15 +188,23 @@ const DEFAULT_AGGREGATION_WINDOW = 60000; // 1 minute
167
188
  const TOPIC_METRICS = 'metrics';
168
189
  const TOPIC_WIDGETS = 'widgets';
169
190
  const LOBBY_ID = 'online';
191
+ /** Delay before the post-setup lobby presence refetch (catches simultaneous joiners) */
192
+ const LOBBY_REFRESH_DELAY_MS = 2000;
170
193
 
171
194
  class DashboardPanel extends EventEmitter {
172
- constructor(name, roomContext, localViewerId, localActorId, options, log) {
195
+ /** @internal */
196
+ constructor(name, roomContext, localViewerId, localActorId, options, log, isConnected) {
173
197
  super();
198
+ // Stored topic handler refs — cleanup removes exactly these, never all
199
+ // handlers for a topic (the client may be shared with other consumers).
200
+ this._onMetricsRef = null;
201
+ this._onWidgetsRef = null;
174
202
  this.name = name;
175
203
  this._roomContext = roomContext;
176
204
  this._localViewerId = localViewerId;
177
205
  this._options = options;
178
206
  this._log = log;
207
+ this._isConnected = isConnected;
179
208
  this._presenceManager = new PresenceManager(localActorId);
180
209
  this._metricStore = new MetricStore(options.maxMetricPoints);
181
210
  this._widgetManager = new WidgetManager();
@@ -223,18 +252,22 @@ class DashboardPanel extends EventEmitter {
223
252
  this._roomContext.subscribe(TOPIC_METRICS);
224
253
  }
225
254
  this._roomContext.subscribe(TOPIC_WIDGETS);
226
- this._roomContext.on(TOPIC_METRICS, (data, meta) => {
255
+ // Listen for metrics (refs stored for handler-specific removal)
256
+ this._onMetricsRef = (data, meta) => {
227
257
  const raw = data;
228
258
  const point = { id: raw.id, streamId: raw.streamId, value: raw.value, unit: raw.unit, tags: raw.tags, timestamp: raw.timestamp, isReplay: meta.isReplay ?? false };
229
259
  if (this._metricStore.add(point))
230
260
  this.emit('metric', point);
231
- });
232
- this._roomContext.on(TOPIC_WIDGETS, (data, meta) => {
261
+ };
262
+ this._roomContext.on(TOPIC_METRICS, this._onMetricsRef);
263
+ // Listen for widget updates
264
+ this._onWidgetsRef = (data, meta) => {
233
265
  const raw = data;
234
266
  const update = { id: raw.id, widgetId: raw.widgetId, type: raw.type, data: raw.data, label: raw.label, timestamp: raw.timestamp, isReplay: meta.isReplay ?? false };
235
267
  this._widgetManager.update(update);
236
268
  this.emit('widgetUpdate', update);
237
- });
269
+ };
270
+ this._roomContext.on(TOPIC_WIDGETS, this._onWidgetsRef);
238
271
  }
239
272
  _activate() {
240
273
  this._setPresence();
@@ -258,106 +291,407 @@ class DashboardPanel extends EventEmitter {
258
291
  _handleReplayEnd(replayed) { this.emit('replayEnd', { replayed }); }
259
292
  _updateLocalPresence() { this._setPresence(); }
260
293
  _cleanup() {
261
- this._roomContext.unsubscribe(TOPIC_METRICS);
262
- this._roomContext.unsubscribe(TOPIC_WIDGETS);
263
- this._roomContext.off(TOPIC_METRICS);
264
- this._roomContext.off(TOPIC_WIDGETS);
294
+ this._log('Panel cleanup:', this.name);
295
+ // Server unsubscribes need a live socket; skip when disconnected
296
+ // (best-effort — the core would no-op with an error callback anyway).
297
+ if (this._isConnected()) {
298
+ this._roomContext.unsubscribe(TOPIC_METRICS);
299
+ this._roomContext.unsubscribe(TOPIC_WIDGETS);
300
+ }
301
+ // Handler-specific removal only: the client may be shared, and a bare
302
+ // off(topic) would strip other consumers' handlers too.
303
+ if (this._onMetricsRef)
304
+ this._roomContext.off(TOPIC_METRICS, this._onMetricsRef);
305
+ if (this._onWidgetsRef)
306
+ this._roomContext.off(TOPIC_WIDGETS, this._onWidgetsRef);
307
+ this._onMetricsRef = null;
308
+ this._onWidgetsRef = null;
265
309
  this._metricStore.clear();
266
310
  this._widgetManager.clear();
267
311
  this._presenceManager.clear();
268
312
  this.removeAllListeners();
269
313
  }
270
314
  _setPresence() {
271
- this._roomContext.setPresence({ viewerId: this._localViewerId, username: this._options.username, metadata: this._options.metadata });
315
+ this._roomContext.setPresence({
316
+ viewerId: this._localViewerId,
317
+ username: this._options.username,
318
+ metadata: this._options.metadata,
319
+ // Scope tag: on a shared client, other apps' wrappers filter our
320
+ // presence out by this (and we filter theirs).
321
+ __scope: this._options.appName,
322
+ });
272
323
  }
273
324
  }
274
325
 
326
+ /**
327
+ * NoLagDash — high-level live dashboard SDK built on @nolag/js-sdk.
328
+ *
329
+ * Provides multi-panel dashboards, real-time metric streams with
330
+ * aggregation, widget updates, viewer presence, and replay — all
331
+ * framework-agnostic via events.
332
+ *
333
+ * The wrapper NEVER manages the connection. The app owns one core NoLag
334
+ * client (shared by any number of wrappers on distinct apps) and the
335
+ * wrapper attaches to it at construction and releases it via `detach()`.
336
+ *
337
+ * @example
338
+ * ```typescript
339
+ * import { NoLag } from '@nolag/js-sdk';
340
+ * import { NoLagDash } from '@nolag/dash';
341
+ *
342
+ * const client = NoLag(async () => (await (await fetch('/api/nolag-token')).json()).token);
343
+ * const dash = new NoLagDash({ client, appName: 'my-dash', username: 'Alice' });
344
+ *
345
+ * dash.on('viewerOnline', (v) => console.log(v.username, 'is watching'));
346
+ *
347
+ * await client.connect(); // the app owns the connection
348
+ * await dash.ready(); // wrapper setup done (identity, lobby, panels)
349
+ *
350
+ * const panel = dash.joinPanel('overview');
351
+ * panel.on('metric', (m) => console.log(m.streamId, m.value));
352
+ * panel.publishMetric('cpu', 75);
353
+ *
354
+ * dash.detach(); // wrapper releases its handlers and topics
355
+ * client.disconnect(); // the app closes the socket
356
+ * ```
357
+ */
275
358
  class NoLagDash extends EventEmitter {
276
- constructor(token, options = {}) {
359
+ constructor(options) {
277
360
  super();
278
- this._client = null;
361
+ this._localViewer = null;
279
362
  this._panels = new Map();
280
363
  this._lobby = null;
281
364
  this._onlineViewers = new Map();
282
365
  this._actorToViewerId = new Map();
283
- this._token = token;
366
+ // Lifecycle: one setup run per connection epoch; detach is terminal.
367
+ this._epoch = 0;
368
+ this._detached = false;
369
+ this._isReady = false;
370
+ this._lobbyRefreshTimer = null;
371
+ // Stored client handler refs. INVARIANT: every client.on() below has a
372
+ // matching client.off() in detach() — never bare off(event), never inline
373
+ // closures on the client.
374
+ this._onConnectRef = () => this._onConnect();
375
+ this._onDisconnectRef = (reason) => {
376
+ this._log('Disconnected:', reason);
377
+ this.emit('disconnected', reason);
378
+ };
379
+ this._onReconnectRef = () => {
380
+ this._log('Reconnecting...');
381
+ this.emit('reconnecting');
382
+ };
383
+ this._onErrorRef = (error) => {
384
+ this._log('Error:', error);
385
+ this.emit('error', error);
386
+ };
387
+ this._onReplayStartRef = (data) => {
388
+ const event = data;
389
+ for (const panel of this._panels.values()) {
390
+ panel._handleReplayStart(event.count);
391
+ }
392
+ };
393
+ this._onReplayEndRef = (data) => {
394
+ const event = data;
395
+ for (const panel of this._panels.values()) {
396
+ panel._handleReplayEnd(event.replayed);
397
+ }
398
+ };
399
+ this._onPresenceJoinRef = (data) => this._handlePresenceJoin(data);
400
+ this._onPresenceLeaveRef = (data) => this._handlePresenceLeave(data);
401
+ this._onPresenceUpdateRef = (data) => this._handlePresenceUpdate(data);
402
+ this._onLobbyJoinRef = (data) => this._handleLobbyJoin(data);
403
+ this._onLobbyLeaveRef = (data) => this._handleLobbyLeave(data);
404
+ this._onLobbyUpdateRef = (data) => this._handleLobbyUpdate(data);
405
+ if (!options?.client) {
406
+ throw new TypeError('NoLagDash requires an injected NoLag client: new NoLagDash({ client, appName, ... })');
407
+ }
408
+ this._client = options.client;
284
409
  this._viewerId = generateId();
285
410
  this._options = {
286
- username: options.username, metadata: options.metadata, appName: options.appName ?? DEFAULT_APP_NAME,
287
- url: options.url, maxMetricPoints: options.maxMetricPoints ?? DEFAULT_MAX_METRIC_POINTS,
411
+ username: options.username,
412
+ metadata: options.metadata,
413
+ appName: options.appName ?? DEFAULT_APP_NAME,
414
+ maxMetricPoints: options.maxMetricPoints ?? DEFAULT_MAX_METRIC_POINTS,
288
415
  aggregationWindow: options.aggregationWindow ?? DEFAULT_AGGREGATION_WINDOW,
289
- debug: options.debug ?? false, reconnect: options.reconnect ?? true, panels: options.panels ?? [],
416
+ debug: options.debug ?? false,
417
+ panels: options.panels ?? [],
290
418
  };
291
419
  this._log = createLogger('NoLagDash', this._options.debug);
420
+ this._readyPromise = new Promise((resolve, reject) => {
421
+ this._readyResolve = resolve;
422
+ this._readyReject = reject;
423
+ });
424
+ // ready() rejection is only meaningful to callers that await it
425
+ this._readyPromise.catch(() => { });
426
+ registerWrapper(this._client, this._options.appName, 'NoLagDash');
427
+ // Construction = attach: wire everything now, with stored refs.
428
+ this._client.on('connect', this._onConnectRef);
429
+ this._client.on('disconnect', this._onDisconnectRef);
430
+ this._client.on('reconnect', this._onReconnectRef);
431
+ this._client.on('error', this._onErrorRef);
432
+ this._client.on('replay:start', this._onReplayStartRef);
433
+ this._client.on('replay:end', this._onReplayEndRef);
434
+ this._client.on('presence:join', this._onPresenceJoinRef);
435
+ this._client.on('presence:leave', this._onPresenceLeaveRef);
436
+ this._client.on('presence:update', this._onPresenceUpdateRef);
437
+ this._client.on('lobbyPresence:join', this._onLobbyJoinRef);
438
+ this._client.on('lobbyPresence:leave', this._onLobbyLeaveRef);
439
+ this._client.on('lobbyPresence:update', this._onLobbyUpdateRef);
440
+ // Attach-to-connected: if the client is already authenticated, run setup.
441
+ // The microtask lets the caller wire wrapper event handlers synchronously
442
+ // first; a racing real 'connect' event wins via the epoch guard.
443
+ queueMicrotask(() => {
444
+ if (this._epoch === 0 && !this._detached && this._client.connected) {
445
+ this._onConnect();
446
+ }
447
+ });
292
448
  }
293
- get connected() { return this._client?.connected ?? false; }
294
- get panels() { return this._panels; }
295
- async connect() {
296
- const clientOptions = { debug: this._options.debug, reconnect: this._options.reconnect };
297
- if (this._options.url)
298
- clientOptions.url = this._options.url;
299
- this._client = NoLag(this._token, clientOptions);
300
- this._client.on('connect', () => { if (this._panels.size > 0) {
301
- this._restorePanels();
302
- this.emit('reconnected');
303
- } });
304
- this._client.on('disconnect', (reason) => this.emit('disconnected', reason));
305
- this._client.on('error', (error) => this.emit('error', error));
306
- this._client.on('replay:start', (data) => { for (const p of this._panels.values())
307
- p._handleReplayStart(data.count); });
308
- this._client.on('replay:end', (data) => { for (const p of this._panels.values())
309
- p._handleReplayEnd(data.replayed); });
310
- await this._client.connect();
311
- this._client.on('presence:join', (data) => this._handlePresenceJoin(data));
312
- this._client.on('presence:leave', (data) => this._handlePresenceLeave(data));
313
- this._client.on('presence:update', (data) => this._handlePresenceUpdate(data));
314
- await this._setupLobby();
315
- for (const name of this._options.panels)
316
- this._subscribePanel(name);
317
- this.emit('connected');
318
- setTimeout(() => { if (this._lobby && this._client?.connected)
319
- this._lobby.fetchPresence().then(s => this._hydrateViewers(s)).catch(() => { }); }, 2000);
320
- }
321
- disconnect() {
322
- for (const name of [...this._panels.keys()])
323
- this.leavePanel(name);
324
- this._lobby?.unsubscribe();
449
+ // ============ Public Properties ============
450
+ /** Whether the underlying connection is established (connected ready) */
451
+ get connected() {
452
+ return !this._detached && this._client.connected;
453
+ }
454
+ /** The injected core client (owned by the app, not the wrapper) */
455
+ get client() {
456
+ return this._client;
457
+ }
458
+ /** The local viewer's info (available after ready) */
459
+ get localViewer() {
460
+ return this._localViewer;
461
+ }
462
+ /** All currently joined panels */
463
+ get panels() {
464
+ return this._panels;
465
+ }
466
+ // ============ Lifecycle ============
467
+ /**
468
+ * Resolves once the wrapper's first setup completed (identity, lobby and
469
+ * configured panels ready — equivalently, once 'connected' has fired).
470
+ * Rejects only if detach() is called before that. Client auth failures
471
+ * surface via the app's own `await client.connect()`, not here.
472
+ */
473
+ ready() {
474
+ return this._readyPromise;
475
+ }
476
+ /**
477
+ * Detach from the client: remove every handler this wrapper added,
478
+ * unsubscribe its topics and lobby (when connected), clear state.
479
+ * Terminal and idempotent; never touches the socket. To use dash again,
480
+ * construct a new instance.
481
+ */
482
+ detach() {
483
+ if (this._detached)
484
+ return;
485
+ this._log('Detaching...');
486
+ this._detached = true;
487
+ this._epoch++; // aborts any in-flight setup at its next checkpoint
488
+ if (this._lobbyRefreshTimer) {
489
+ clearTimeout(this._lobbyRefreshTimer);
490
+ this._lobbyRefreshTimer = null;
491
+ }
492
+ // Remove all client handlers by stored ref
493
+ this._client.off('connect', this._onConnectRef);
494
+ this._client.off('disconnect', this._onDisconnectRef);
495
+ this._client.off('reconnect', this._onReconnectRef);
496
+ this._client.off('error', this._onErrorRef);
497
+ this._client.off('replay:start', this._onReplayStartRef);
498
+ this._client.off('replay:end', this._onReplayEndRef);
499
+ this._client.off('presence:join', this._onPresenceJoinRef);
500
+ this._client.off('presence:leave', this._onPresenceLeaveRef);
501
+ this._client.off('presence:update', this._onPresenceUpdateRef);
502
+ this._client.off('lobbyPresence:join', this._onLobbyJoinRef);
503
+ this._client.off('lobbyPresence:leave', this._onLobbyLeaveRef);
504
+ this._client.off('lobbyPresence:update', this._onLobbyUpdateRef);
505
+ // Panels: handler-specific off + connected-gated server unsubscribe
506
+ for (const name of [...this._panels.keys()]) {
507
+ this._panels.get(name)._cleanup();
508
+ this._panels.delete(name);
509
+ }
510
+ // Lobby: server unsubscribe is best-effort and needs a live socket
511
+ if (this._lobby && this._client.connected) {
512
+ try {
513
+ this._lobby.unsubscribe();
514
+ }
515
+ catch {
516
+ /* best-effort */
517
+ }
518
+ }
325
519
  this._lobby = null;
326
- this._client?.disconnect();
327
- this._client = null;
328
520
  this._onlineViewers.clear();
329
521
  this._actorToViewerId.clear();
522
+ this._localViewer = null;
523
+ releaseWrapper(this._client, this._options.appName);
524
+ if (!this._isReady) {
525
+ this._readyReject(new Error('NoLagDash detached before ready'));
526
+ }
330
527
  }
528
+ // ============ Private: Epoch Setup ============
529
+ _onConnect() {
530
+ this._epoch++;
531
+ void this._runSetup(this._epoch);
532
+ }
533
+ /**
534
+ * One setup pass per connection epoch. Serves both initial setup (epoch 1)
535
+ * and reconnect restore (epoch > 1). Aborts silently whenever a newer
536
+ * epoch started or the wrapper detached — checked after every await.
537
+ */
538
+ async _runSetup(epoch) {
539
+ const stale = () => epoch !== this._epoch || this._detached;
540
+ this._log(this._isReady ? 'Restoring after reconnect...' : 'Setting up...');
541
+ // Identity (client.actorId is guaranteed post-auth)
542
+ if (!this._localViewer) {
543
+ this._localViewer = {
544
+ viewerId: this._viewerId,
545
+ actorTokenId: this._client.actorId,
546
+ username: this._options.username,
547
+ metadata: this._options.metadata,
548
+ joinedAt: Date.now(),
549
+ isLocal: true,
550
+ };
551
+ this._log('Local viewer:', this._localViewer.viewerId, '→', this._localViewer.actorTokenId);
552
+ }
553
+ else {
554
+ this._localViewer.actorTokenId = this._client.actorId;
555
+ }
556
+ // Lobby: subscribe every epoch (idempotent server-side) and diff-hydrate
557
+ // from the returned snapshot — one path for setup and restore.
558
+ if (!this._lobby) {
559
+ this._lobby = this._client.setApp(this._options.appName).setLobby(LOBBY_ID);
560
+ }
561
+ try {
562
+ const state = await this._lobby.subscribe();
563
+ if (stale())
564
+ return;
565
+ this._diffHydrateOnlineViewers(state);
566
+ this._log('Lobby subscribed, online viewers:', this._onlineViewers.size);
567
+ }
568
+ catch (err) {
569
+ if (stale())
570
+ return;
571
+ this._log('Lobby subscription failed:', err);
572
+ }
573
+ if (!this._isReady) {
574
+ // First successful setup: pre-subscribe configured panels
575
+ for (const panelName of this._options.panels) {
576
+ this._subscribePanelInternal(panelName);
577
+ }
578
+ }
579
+ else {
580
+ // Server auto-restored topic subscriptions; only panel-scoped presence
581
+ // needs re-applying (the core does not restore it).
582
+ for (const panel of this._panels.values()) {
583
+ panel._updateLocalPresence();
584
+ }
585
+ }
586
+ if (stale())
587
+ return;
588
+ // Ready keys on the first setup that COMPLETES, not on epoch 1: an
589
+ // epoch aborted by a racing reconnect must not strand ready().
590
+ if (!this._isReady) {
591
+ this._isReady = true;
592
+ this._readyResolve();
593
+ this.emit('connected');
594
+ }
595
+ else {
596
+ this.emit('reconnected');
597
+ }
598
+ // Deferred lobby refetch: catches viewers who joined during the setup
599
+ // window (e.g. simultaneous multi-tab connects).
600
+ this._scheduleLobbyRefresh(epoch);
601
+ }
602
+ _scheduleLobbyRefresh(epoch) {
603
+ if (this._lobbyRefreshTimer)
604
+ clearTimeout(this._lobbyRefreshTimer);
605
+ this._lobbyRefreshTimer = setTimeout(() => {
606
+ this._lobbyRefreshTimer = null;
607
+ if (epoch !== this._epoch || this._detached || !this._client.connected || !this._lobby) {
608
+ return;
609
+ }
610
+ this._lobby
611
+ .fetchPresence()
612
+ .then((state) => {
613
+ if (epoch !== this._epoch || this._detached)
614
+ return;
615
+ this._diffHydrateOnlineViewers(state);
616
+ })
617
+ .catch(() => {
618
+ /* best-effort */
619
+ });
620
+ }, LOBBY_REFRESH_DELAY_MS);
621
+ }
622
+ // ============ Panel Management ============
623
+ /**
624
+ * Join a dashboard panel. If the panel was pre-subscribed via the `panels`
625
+ * option, activates it. Otherwise creates, subscribes, and activates it.
626
+ */
331
627
  joinPanel(name, opts) {
332
- if (!this._client)
333
- throw new Error('Not connected — call connect() first');
628
+ this._assertUsable();
334
629
  let panel = this._panels.get(name);
335
- if (!panel)
336
- panel = this._subscribePanel(name, opts?.metricFilters);
630
+ if (!panel) {
631
+ panel = this._subscribePanelInternal(name, opts?.metricFilters);
632
+ }
337
633
  panel._activate();
338
634
  return panel;
339
635
  }
636
+ /**
637
+ * Leave a dashboard panel. Fully unsubscribes and removes it.
638
+ */
340
639
  leavePanel(name) {
341
640
  const panel = this._panels.get(name);
342
641
  if (!panel)
343
642
  return;
643
+ this._log('Leaving panel:', name);
344
644
  panel._cleanup();
345
645
  this._panels.delete(name);
346
646
  }
347
- _subscribePanel(name, metricFilters) {
348
- if (!this._client)
349
- throw new Error('Not connected');
647
+ /**
648
+ * Get all joined panels.
649
+ */
650
+ getPanels() {
651
+ return Array.from(this._panels.values());
652
+ }
653
+ // ============ Global Presence ============
654
+ /**
655
+ * Get all viewers currently online across all panels.
656
+ */
657
+ getOnlineViewers() {
658
+ return Array.from(this._onlineViewers.values());
659
+ }
660
+ // ============ Private: Guards ============
661
+ _assertUsable() {
662
+ if (this._detached) {
663
+ throw new Error('NoLagDash has been detached — construct a new instance');
664
+ }
665
+ if (!this._isReady || !this._localViewer) {
666
+ throw new Error('NoLagDash not ready — await ready() or the "connected" event');
667
+ }
668
+ }
669
+ // ============ Private: Panel Setup ============
670
+ _subscribePanelInternal(name, metricFilters) {
671
+ this._log('Subscribing panel:', name);
350
672
  const roomContext = this._client.setApp(this._options.appName).setRoom(name);
351
- const panel = new DashboardPanel(name, roomContext, this._viewerId, this._client.actorId, this._options, createLogger(`DashPanel:${name}`, this._options.debug));
673
+ const panel = new DashboardPanel(name, roomContext, this._viewerId, this._client.actorId, this._options, createLogger(`DashPanel:${name}`, this._options.debug), () => this._client.connected);
352
674
  this._panels.set(name, panel);
353
675
  panel._subscribe(metricFilters);
354
676
  return panel;
355
677
  }
678
+ // ============ Private: Scope Filtering ============
679
+ /**
680
+ * On a shared client, presence events from other apps' wrappers arrive on
681
+ * the same connection-level events. Wrappers stamp their presence with a
682
+ * `__scope` (their appName); a mismatched tag means another app's data.
683
+ * Untagged presence is accepted (older peers in this same app).
684
+ */
685
+ _foreignScope(data) {
686
+ const scope = data?.__scope;
687
+ return typeof scope === 'string' && scope !== this._options.appName;
688
+ }
689
+ // ============ Private: Room Presence → Panels ============
356
690
  _handlePresenceJoin(data) {
357
- if (data.actorTokenId === this._client?.actorId)
691
+ if (data.actorTokenId === this._localViewer?.actorTokenId)
358
692
  return;
359
693
  const pd = data.presence;
360
- if (!pd?.viewerId)
694
+ if (!pd?.viewerId || this._foreignScope(pd))
361
695
  return;
362
696
  const viewer = this._toViewer(data.actorTokenId, pd);
363
697
  this._actorToViewerId.set(data.actorTokenId, viewer.viewerId);
@@ -365,89 +699,137 @@ class NoLagDash extends EventEmitter {
365
699
  this._onlineViewers.set(viewer.viewerId, viewer);
366
700
  this.emit('viewerOnline', viewer);
367
701
  }
368
- for (const p of this._panels.values())
369
- p._handlePresenceJoin(data.actorTokenId, pd);
702
+ for (const panel of this._panels.values()) {
703
+ panel._handlePresenceJoin(data.actorTokenId, pd);
704
+ }
370
705
  }
371
706
  _handlePresenceLeave(data) {
372
- if (data.actorTokenId === this._client?.actorId)
707
+ if (data.actorTokenId === this._localViewer?.actorTokenId)
373
708
  return;
374
- for (const p of this._panels.values())
375
- p._handlePresenceLeave(data.actorTokenId);
709
+ // Panel leave offline — viewer may still be on another panel.
710
+ // Lobby leave handles actual offline status.
711
+ for (const panel of this._panels.values()) {
712
+ panel._handlePresenceLeave(data.actorTokenId);
713
+ }
376
714
  }
377
715
  _handlePresenceUpdate(data) {
378
- if (data.actorTokenId === this._client?.actorId)
716
+ if (data.actorTokenId === this._localViewer?.actorTokenId)
379
717
  return;
380
718
  const pd = data.presence;
381
- if (!pd?.viewerId)
719
+ if (!pd?.viewerId || this._foreignScope(pd))
382
720
  return;
383
- for (const p of this._panels.values())
384
- p._handlePresenceUpdate(data.actorTokenId, pd);
721
+ for (const panel of this._panels.values()) {
722
+ panel._handlePresenceUpdate(data.actorTokenId, pd);
723
+ }
385
724
  }
386
- async _setupLobby() {
387
- if (!this._client)
725
+ // ============ Private: Lobby ============
726
+ _handleLobbyJoin(event) {
727
+ const { actorId, data } = event;
728
+ if (actorId === this._localViewer?.actorTokenId)
388
729
  return;
389
- this._lobby = this._client.setApp(this._options.appName).setLobby(LOBBY_ID);
390
- const lh = (type) => (data) => {
391
- const e = data;
392
- if (type === 'join') {
393
- const pd = e.data;
394
- if (e.actorId !== this._client?.actorId && pd?.viewerId) {
395
- const v = this._toViewer(e.actorId, pd);
396
- this._actorToViewerId.set(e.actorId, v.viewerId);
397
- if (!this._onlineViewers.has(v.viewerId)) {
398
- this._onlineViewers.set(v.viewerId, v);
399
- this.emit('viewerOnline', v);
400
- }
401
- }
402
- }
403
- else {
404
- if (e.actorId !== this._client?.actorId) {
405
- const vid = this._actorToViewerId.get(e.actorId);
406
- if (vid) {
407
- const v = this._onlineViewers.get(vid);
408
- if (v) {
409
- this._onlineViewers.delete(vid);
410
- this._actorToViewerId.delete(e.actorId);
411
- this.emit('viewerOffline', v);
412
- }
413
- }
414
- }
730
+ const pd = data;
731
+ if (!pd?.viewerId || this._foreignScope(pd))
732
+ return;
733
+ const viewer = this._toViewer(actorId, pd);
734
+ this._actorToViewerId.set(actorId, viewer.viewerId);
735
+ if (!this._onlineViewers.has(viewer.viewerId)) {
736
+ this._onlineViewers.set(viewer.viewerId, viewer);
737
+ this.emit('viewerOnline', viewer);
738
+ }
739
+ }
740
+ _handleLobbyLeave(event) {
741
+ const { actorId, data } = event;
742
+ if (actorId === this._localViewer?.actorTokenId)
743
+ return;
744
+ const pd = data;
745
+ if (this._foreignScope(pd))
746
+ return;
747
+ const viewerId = pd?.viewerId
748
+ || this._actorToViewerId.get(actorId)
749
+ || this._findViewerIdByActorId(actorId);
750
+ if (viewerId) {
751
+ const viewer = this._onlineViewers.get(viewerId);
752
+ if (viewer) {
753
+ this._onlineViewers.delete(viewerId);
754
+ this._actorToViewerId.delete(actorId);
755
+ this.emit('viewerOffline', viewer);
415
756
  }
416
- };
417
- this._client.on('lobbyPresence:join', lh('join'));
418
- this._client.on('lobbyPresence:leave', lh('leave'));
419
- this._client.on('lobbyPresence:update', lh('join'));
420
- try {
421
- const s = await this._lobby.subscribe();
422
- this._hydrateViewers(s);
423
757
  }
424
- catch { }
425
758
  }
426
- _hydrateViewers(state) {
427
- for (const rid of Object.keys(state)) {
428
- for (const aid of Object.keys(state[rid])) {
429
- if (aid === this._client?.actorId)
759
+ _handleLobbyUpdate(event) {
760
+ const { actorId, data } = event;
761
+ if (actorId === this._localViewer?.actorTokenId)
762
+ return;
763
+ const pd = data;
764
+ if (!pd?.viewerId || this._foreignScope(pd))
765
+ return;
766
+ const viewer = this._toViewer(actorId, pd);
767
+ this._onlineViewers.set(viewer.viewerId, viewer);
768
+ }
769
+ /**
770
+ * Reconcile the online-viewer map against a fresh lobby snapshot, emitting
771
+ * only the deltas (viewerOffline for vanished, viewerOnline for new). One
772
+ * path for initial hydration, reconnect restore, and the deferred refetch.
773
+ */
774
+ _diffHydrateOnlineViewers(state) {
775
+ // Build the fresh viewer set from the snapshot
776
+ const fresh = new Map();
777
+ const freshActors = new Map();
778
+ for (const roomId of Object.keys(state)) {
779
+ const roomPresence = state[roomId];
780
+ for (const actorId of Object.keys(roomPresence)) {
781
+ if (actorId === this._localViewer?.actorTokenId)
430
782
  continue;
431
- const raw = state[rid][aid];
783
+ const raw = roomPresence[actorId];
784
+ // Server returns full actor records with presence nested under .presence
432
785
  const pd = (raw?.presence ?? raw);
433
- if (pd?.viewerId) {
434
- const v = this._toViewer(aid, pd);
435
- this._actorToViewerId.set(aid, v.viewerId);
436
- if (!this._onlineViewers.has(v.viewerId)) {
437
- this._onlineViewers.set(v.viewerId, v);
438
- this.emit('viewerOnline', v);
786
+ if (pd?.viewerId && !this._foreignScope(pd)) {
787
+ if (!fresh.has(pd.viewerId)) {
788
+ fresh.set(pd.viewerId, this._toViewer(actorId, pd));
439
789
  }
790
+ freshActors.set(actorId, pd.viewerId);
791
+ }
792
+ }
793
+ }
794
+ // Vanished viewers
795
+ for (const [viewerId, viewer] of [...this._onlineViewers]) {
796
+ if (!fresh.has(viewerId)) {
797
+ this._onlineViewers.delete(viewerId);
798
+ for (const [actorId, mappedViewerId] of [...this._actorToViewerId]) {
799
+ if (mappedViewerId === viewerId)
800
+ this._actorToViewerId.delete(actorId);
440
801
  }
802
+ this.emit('viewerOffline', viewer);
441
803
  }
442
804
  }
805
+ // New viewers
806
+ for (const [viewerId, viewer] of fresh) {
807
+ if (!this._onlineViewers.has(viewerId)) {
808
+ this._onlineViewers.set(viewerId, viewer);
809
+ this.emit('viewerOnline', viewer);
810
+ }
811
+ }
812
+ for (const [actorId, viewerId] of freshActors) {
813
+ this._actorToViewerId.set(actorId, viewerId);
814
+ }
443
815
  }
816
+ // ============ Private: Helpers ============
444
817
  _toViewer(actorTokenId, data) {
445
- return { viewerId: data.viewerId, actorTokenId, username: data.username, metadata: data.metadata, joinedAt: Date.now(), isLocal: false };
818
+ return {
819
+ viewerId: data.viewerId,
820
+ actorTokenId,
821
+ username: data.username,
822
+ metadata: data.metadata,
823
+ joinedAt: Date.now(),
824
+ isLocal: false,
825
+ };
446
826
  }
447
- _restorePanels() {
448
- for (const p of this._panels.values())
449
- p._updateLocalPresence();
450
- this._lobby?.fetchPresence().then(s => { this._onlineViewers.clear(); this._actorToViewerId.clear(); this._hydrateViewers(s); }).catch(() => { });
827
+ _findViewerIdByActorId(actorTokenId) {
828
+ for (const viewer of this._onlineViewers.values()) {
829
+ if (viewer.actorTokenId === actorTokenId)
830
+ return viewer.viewerId;
831
+ }
832
+ return undefined;
451
833
  }
452
834
  }
453
835