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