@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/README.md +66 -16
- package/dist/DashboardPanel.d.ts +5 -1
- package/dist/NoLagDash.d.ts +118 -10
- package/dist/browser.js +1 -1
- package/dist/browser.js.map +1 -1
- package/dist/constants.d.ts +2 -0
- package/dist/index.cjs +505 -123
- package/dist/index.cjs.map +1 -1
- package/dist/index.mjs +505 -123
- package/dist/index.mjs.map +1 -1
- package/dist/types.d.ts +16 -4
- package/dist/utils.d.ts +4 -0
- package/package.json +3 -3
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
|
-
|
|
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
|
-
|
|
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(
|
|
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.
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
this.
|
|
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({
|
|
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(
|
|
361
|
+
constructor(options) {
|
|
279
362
|
super();
|
|
280
|
-
this.
|
|
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
|
-
|
|
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,
|
|
289
|
-
|
|
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,
|
|
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
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
this._client
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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.
|
|
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
|
|
371
|
-
|
|
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.
|
|
709
|
+
if (data.actorTokenId === this._localViewer?.actorTokenId)
|
|
375
710
|
return;
|
|
376
|
-
|
|
377
|
-
|
|
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.
|
|
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
|
|
386
|
-
|
|
723
|
+
for (const panel of this._panels.values()) {
|
|
724
|
+
panel._handlePresenceUpdate(data.actorTokenId, pd);
|
|
725
|
+
}
|
|
387
726
|
}
|
|
388
|
-
|
|
389
|
-
|
|
727
|
+
// ============ Private: Lobby ============
|
|
728
|
+
_handleLobbyJoin(event) {
|
|
729
|
+
const { actorId, data } = event;
|
|
730
|
+
if (actorId === this._localViewer?.actorTokenId)
|
|
390
731
|
return;
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
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
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
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 =
|
|
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
|
-
|
|
437
|
-
|
|
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 {
|
|
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
|
-
|
|
450
|
-
for (const
|
|
451
|
-
|
|
452
|
-
|
|
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
|
|