@nolag/dash 0.1.2 → 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.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
|
-
|
|
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
|
-
|
|
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(
|
|
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.
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
this.
|
|
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({
|
|
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(
|
|
359
|
+
constructor(options) {
|
|
277
360
|
super();
|
|
278
|
-
this.
|
|
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
|
-
|
|
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,
|
|
287
|
-
|
|
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,
|
|
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
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
this._client
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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.
|
|
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
|
|
369
|
-
|
|
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.
|
|
707
|
+
if (data.actorTokenId === this._localViewer?.actorTokenId)
|
|
373
708
|
return;
|
|
374
|
-
|
|
375
|
-
|
|
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.
|
|
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
|
|
384
|
-
|
|
721
|
+
for (const panel of this._panels.values()) {
|
|
722
|
+
panel._handlePresenceUpdate(data.actorTokenId, pd);
|
|
723
|
+
}
|
|
385
724
|
}
|
|
386
|
-
|
|
387
|
-
|
|
725
|
+
// ============ Private: Lobby ============
|
|
726
|
+
_handleLobbyJoin(event) {
|
|
727
|
+
const { actorId, data } = event;
|
|
728
|
+
if (actorId === this._localViewer?.actorTokenId)
|
|
388
729
|
return;
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
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
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
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 =
|
|
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
|
-
|
|
435
|
-
|
|
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 {
|
|
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
|
-
|
|
448
|
-
for (const
|
|
449
|
-
|
|
450
|
-
|
|
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
|
|