@nolag/dash 0.1.2 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/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/react-native.d.ts +13 -0
- package/dist/react-native.js +837 -0
- package/dist/react-native.js.map +1 -0
- package/dist/types.d.ts +16 -4
- package/dist/utils.d.ts +4 -0
- package/package.json +10 -4
|
@@ -0,0 +1,837 @@
|
|
|
1
|
+
class EventEmitter {
|
|
2
|
+
constructor() {
|
|
3
|
+
this._handlers = new Map();
|
|
4
|
+
}
|
|
5
|
+
on(event, handler) {
|
|
6
|
+
if (!this._handlers.has(event)) {
|
|
7
|
+
this._handlers.set(event, new Set());
|
|
8
|
+
}
|
|
9
|
+
this._handlers.get(event).add(handler);
|
|
10
|
+
return this;
|
|
11
|
+
}
|
|
12
|
+
off(event, handler) {
|
|
13
|
+
if (handler) {
|
|
14
|
+
this._handlers.get(event)?.delete(handler);
|
|
15
|
+
}
|
|
16
|
+
else {
|
|
17
|
+
this._handlers.delete(event);
|
|
18
|
+
}
|
|
19
|
+
return this;
|
|
20
|
+
}
|
|
21
|
+
removeAllListeners() {
|
|
22
|
+
this._handlers.clear();
|
|
23
|
+
return this;
|
|
24
|
+
}
|
|
25
|
+
emit(event, ...args) {
|
|
26
|
+
const handlers = this._handlers.get(event);
|
|
27
|
+
if (!handlers)
|
|
28
|
+
return;
|
|
29
|
+
for (const handler of handlers) {
|
|
30
|
+
try {
|
|
31
|
+
handler(...args);
|
|
32
|
+
}
|
|
33
|
+
catch (e) {
|
|
34
|
+
console.error(`Error in ${String(event)} handler:`, e);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
listenerCount(event) {
|
|
39
|
+
return this._handlers.get(event)?.size ?? 0;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
class MetricStore {
|
|
44
|
+
constructor(maxPerStream) {
|
|
45
|
+
this._streams = new Map();
|
|
46
|
+
this._ids = new Set();
|
|
47
|
+
this._maxPerStream = maxPerStream;
|
|
48
|
+
}
|
|
49
|
+
add(point) {
|
|
50
|
+
if (this._ids.has(point.id))
|
|
51
|
+
return false;
|
|
52
|
+
this._ids.add(point.id);
|
|
53
|
+
if (!this._streams.has(point.streamId))
|
|
54
|
+
this._streams.set(point.streamId, []);
|
|
55
|
+
const points = this._streams.get(point.streamId);
|
|
56
|
+
points.push(point);
|
|
57
|
+
if (points.length > 1 && point.timestamp < points[points.length - 2].timestamp) {
|
|
58
|
+
points.sort((a, b) => a.timestamp - b.timestamp);
|
|
59
|
+
}
|
|
60
|
+
while (points.length > this._maxPerStream) {
|
|
61
|
+
const removed = points.shift();
|
|
62
|
+
this._ids.delete(removed.id);
|
|
63
|
+
}
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
getAll(streamId) {
|
|
67
|
+
if (streamId)
|
|
68
|
+
return [...(this._streams.get(streamId) ?? [])];
|
|
69
|
+
const all = [];
|
|
70
|
+
for (const points of this._streams.values())
|
|
71
|
+
all.push(...points);
|
|
72
|
+
return all.sort((a, b) => a.timestamp - b.timestamp);
|
|
73
|
+
}
|
|
74
|
+
getAggregation(streamId, windowMs) {
|
|
75
|
+
const points = this._streams.get(streamId) ?? [];
|
|
76
|
+
const now = Date.now();
|
|
77
|
+
const window = windowMs ?? 60000;
|
|
78
|
+
const filtered = points.filter(p => p.timestamp >= now - window);
|
|
79
|
+
if (filtered.length === 0) {
|
|
80
|
+
return { streamId, min: 0, max: 0, avg: 0, sum: 0, count: 0, last: 0, windowMs: window };
|
|
81
|
+
}
|
|
82
|
+
let min = Infinity, max = -Infinity, sum = 0;
|
|
83
|
+
for (const p of filtered) {
|
|
84
|
+
if (p.value < min)
|
|
85
|
+
min = p.value;
|
|
86
|
+
if (p.value > max)
|
|
87
|
+
max = p.value;
|
|
88
|
+
sum += p.value;
|
|
89
|
+
}
|
|
90
|
+
return { streamId, min, max, avg: sum / filtered.length, sum, count: filtered.length, last: filtered[filtered.length - 1].value, windowMs: window };
|
|
91
|
+
}
|
|
92
|
+
has(id) { return this._ids.has(id); }
|
|
93
|
+
get size() { return this._ids.size; }
|
|
94
|
+
clear() {
|
|
95
|
+
this._streams.clear();
|
|
96
|
+
this._ids.clear();
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
class WidgetManager {
|
|
101
|
+
constructor() {
|
|
102
|
+
this._widgets = new Map();
|
|
103
|
+
}
|
|
104
|
+
update(widget) {
|
|
105
|
+
this._widgets.set(widget.widgetId, widget);
|
|
106
|
+
}
|
|
107
|
+
get(widgetId) {
|
|
108
|
+
return this._widgets.get(widgetId);
|
|
109
|
+
}
|
|
110
|
+
getAll() {
|
|
111
|
+
return Array.from(this._widgets.values());
|
|
112
|
+
}
|
|
113
|
+
clear() {
|
|
114
|
+
this._widgets.clear();
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
class PresenceManager {
|
|
119
|
+
constructor(localActorId) {
|
|
120
|
+
this._users = new Map();
|
|
121
|
+
this._actorToViewerId = new Map();
|
|
122
|
+
this._localActorId = localActorId;
|
|
123
|
+
}
|
|
124
|
+
addFromPresence(actorTokenId, presence, joinedAt) {
|
|
125
|
+
if (actorTokenId === this._localActorId)
|
|
126
|
+
return null;
|
|
127
|
+
const viewerId = presence.viewerId || this._actorToViewerId.get(actorTokenId) || actorTokenId;
|
|
128
|
+
const viewer = { viewerId, actorTokenId, username: presence.username, metadata: presence.metadata, joinedAt: joinedAt || Date.now(), isLocal: false };
|
|
129
|
+
this._users.set(viewerId, viewer);
|
|
130
|
+
this._actorToViewerId.set(actorTokenId, viewerId);
|
|
131
|
+
return viewer;
|
|
132
|
+
}
|
|
133
|
+
removeByActorId(actorTokenId) {
|
|
134
|
+
if (actorTokenId === this._localActorId)
|
|
135
|
+
return null;
|
|
136
|
+
const viewerId = this._actorToViewerId.get(actorTokenId);
|
|
137
|
+
if (!viewerId)
|
|
138
|
+
return null;
|
|
139
|
+
const viewer = this._users.get(viewerId) || null;
|
|
140
|
+
this._users.delete(viewerId);
|
|
141
|
+
this._actorToViewerId.delete(actorTokenId);
|
|
142
|
+
return viewer;
|
|
143
|
+
}
|
|
144
|
+
getAll() { return Array.from(this._users.values()); }
|
|
145
|
+
get users() { return this._users; }
|
|
146
|
+
clear() { this._users.clear(); this._actorToViewerId.clear(); }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function generateId() {
|
|
150
|
+
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
|
151
|
+
return crypto.randomUUID();
|
|
152
|
+
}
|
|
153
|
+
return 'xxxx-xxxx-xxxx-xxxx'.replace(/x/g, () => Math.floor(Math.random() * 16).toString(16));
|
|
154
|
+
}
|
|
155
|
+
function createLogger(prefix, enabled) {
|
|
156
|
+
if (!enabled) {
|
|
157
|
+
return (..._args) => { };
|
|
158
|
+
}
|
|
159
|
+
return (...args) => { console.log(`[${prefix}]`, ...args); };
|
|
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
|
+
}
|
|
184
|
+
|
|
185
|
+
const DEFAULT_APP_NAME = 'dash';
|
|
186
|
+
const DEFAULT_MAX_METRIC_POINTS = 1000;
|
|
187
|
+
const DEFAULT_AGGREGATION_WINDOW = 60000; // 1 minute
|
|
188
|
+
const TOPIC_METRICS = 'metrics';
|
|
189
|
+
const TOPIC_WIDGETS = 'widgets';
|
|
190
|
+
const LOBBY_ID = 'online';
|
|
191
|
+
/** Delay before the post-setup lobby presence refetch (catches simultaneous joiners) */
|
|
192
|
+
const LOBBY_REFRESH_DELAY_MS = 2000;
|
|
193
|
+
|
|
194
|
+
class DashboardPanel extends EventEmitter {
|
|
195
|
+
/** @internal */
|
|
196
|
+
constructor(name, roomContext, localViewerId, localActorId, options, log, isConnected) {
|
|
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;
|
|
202
|
+
this.name = name;
|
|
203
|
+
this._roomContext = roomContext;
|
|
204
|
+
this._localViewerId = localViewerId;
|
|
205
|
+
this._options = options;
|
|
206
|
+
this._log = log;
|
|
207
|
+
this._isConnected = isConnected;
|
|
208
|
+
this._presenceManager = new PresenceManager(localActorId);
|
|
209
|
+
this._metricStore = new MetricStore(options.maxMetricPoints);
|
|
210
|
+
this._widgetManager = new WidgetManager();
|
|
211
|
+
}
|
|
212
|
+
publishMetric(streamId, value, opts) {
|
|
213
|
+
const point = { id: generateId(), streamId, value, unit: opts?.unit, tags: opts?.tags, timestamp: Date.now(), isReplay: false };
|
|
214
|
+
this._metricStore.add(point);
|
|
215
|
+
const emitOpts = { echo: false };
|
|
216
|
+
if (opts?.filter)
|
|
217
|
+
emitOpts.filter = opts.filter;
|
|
218
|
+
this._roomContext.emit(TOPIC_METRICS, { id: point.id, streamId, value, unit: point.unit, tags: point.tags, timestamp: point.timestamp }, emitOpts);
|
|
219
|
+
return point;
|
|
220
|
+
}
|
|
221
|
+
/** Replace all metric filters — only receive metrics published with these filter values */
|
|
222
|
+
setMetricFilters(filters) {
|
|
223
|
+
this._roomContext.setFilters(TOPIC_METRICS, filters);
|
|
224
|
+
}
|
|
225
|
+
/** Add filter values to the existing metric filter set */
|
|
226
|
+
addMetricFilters(filters) {
|
|
227
|
+
this._roomContext.addFilters(TOPIC_METRICS, filters);
|
|
228
|
+
}
|
|
229
|
+
/** Remove specific filter values from the metric filter set */
|
|
230
|
+
removeMetricFilters(filters) {
|
|
231
|
+
this._roomContext.removeFilters(TOPIC_METRICS, filters);
|
|
232
|
+
}
|
|
233
|
+
publishWidget(widgetId, type, data, label) {
|
|
234
|
+
const update = { id: generateId(), widgetId, type, data, label, timestamp: Date.now(), isReplay: false };
|
|
235
|
+
this._widgetManager.update(update);
|
|
236
|
+
this._roomContext.emit(TOPIC_WIDGETS, { id: update.id, widgetId, type, data, label, timestamp: update.timestamp }, { echo: false });
|
|
237
|
+
return update;
|
|
238
|
+
}
|
|
239
|
+
getMetrics(streamId) { return this._metricStore.getAll(streamId); }
|
|
240
|
+
getAggregation(streamId, windowMs) { return this._metricStore.getAggregation(streamId, windowMs); }
|
|
241
|
+
getWidget(widgetId) { return this._widgetManager.get(widgetId); }
|
|
242
|
+
getWidgets() { return this._widgetManager.getAll(); }
|
|
243
|
+
getViewers() { return this._presenceManager.getAll(); }
|
|
244
|
+
_subscribe(metricFilters) {
|
|
245
|
+
if (metricFilters !== undefined) {
|
|
246
|
+
// Subscribe with filters. If empty array, use a no-match placeholder
|
|
247
|
+
// to avoid wildcard subscription (which receives everything).
|
|
248
|
+
const filters = metricFilters.length > 0 ? metricFilters : ['__none__'];
|
|
249
|
+
this._roomContext.subscribe(TOPIC_METRICS, { filters });
|
|
250
|
+
}
|
|
251
|
+
else {
|
|
252
|
+
this._roomContext.subscribe(TOPIC_METRICS);
|
|
253
|
+
}
|
|
254
|
+
this._roomContext.subscribe(TOPIC_WIDGETS);
|
|
255
|
+
// Listen for metrics (refs stored for handler-specific removal)
|
|
256
|
+
this._onMetricsRef = (data, meta) => {
|
|
257
|
+
const raw = data;
|
|
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 };
|
|
259
|
+
if (this._metricStore.add(point))
|
|
260
|
+
this.emit('metric', point);
|
|
261
|
+
};
|
|
262
|
+
this._roomContext.on(TOPIC_METRICS, this._onMetricsRef);
|
|
263
|
+
// Listen for widget updates
|
|
264
|
+
this._onWidgetsRef = (data, meta) => {
|
|
265
|
+
const raw = data;
|
|
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 };
|
|
267
|
+
this._widgetManager.update(update);
|
|
268
|
+
this.emit('widgetUpdate', update);
|
|
269
|
+
};
|
|
270
|
+
this._roomContext.on(TOPIC_WIDGETS, this._onWidgetsRef);
|
|
271
|
+
}
|
|
272
|
+
_activate() {
|
|
273
|
+
this._setPresence();
|
|
274
|
+
this._roomContext.fetchPresence().then((actors) => {
|
|
275
|
+
for (const actor of actors) {
|
|
276
|
+
if (actor.presence) {
|
|
277
|
+
const v = this._presenceManager.addFromPresence(actor.actorTokenId, actor.presence, actor.joinedAt);
|
|
278
|
+
if (v)
|
|
279
|
+
this.emit('viewerJoined', v);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}).catch(() => { });
|
|
283
|
+
}
|
|
284
|
+
_deactivate() { this._presenceManager.clear(); }
|
|
285
|
+
_handlePresenceJoin(actorTokenId, pd) { const v = this._presenceManager.addFromPresence(actorTokenId, pd); if (v)
|
|
286
|
+
this.emit('viewerJoined', v); }
|
|
287
|
+
_handlePresenceLeave(actorTokenId) { const v = this._presenceManager.removeByActorId(actorTokenId); if (v)
|
|
288
|
+
this.emit('viewerLeft', v); }
|
|
289
|
+
_handlePresenceUpdate(actorTokenId, pd) { this._presenceManager.addFromPresence(actorTokenId, pd); }
|
|
290
|
+
_handleReplayStart(count) { this.emit('replayStart', { count }); }
|
|
291
|
+
_handleReplayEnd(replayed) { this.emit('replayEnd', { replayed }); }
|
|
292
|
+
_updateLocalPresence() { this._setPresence(); }
|
|
293
|
+
_cleanup() {
|
|
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;
|
|
309
|
+
this._metricStore.clear();
|
|
310
|
+
this._widgetManager.clear();
|
|
311
|
+
this._presenceManager.clear();
|
|
312
|
+
this.removeAllListeners();
|
|
313
|
+
}
|
|
314
|
+
_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
|
+
});
|
|
323
|
+
}
|
|
324
|
+
}
|
|
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
|
+
*/
|
|
358
|
+
class NoLagDash extends EventEmitter {
|
|
359
|
+
constructor(options) {
|
|
360
|
+
super();
|
|
361
|
+
this._localViewer = null;
|
|
362
|
+
this._panels = new Map();
|
|
363
|
+
this._lobby = null;
|
|
364
|
+
this._onlineViewers = new Map();
|
|
365
|
+
this._actorToViewerId = new Map();
|
|
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;
|
|
409
|
+
this._viewerId = generateId();
|
|
410
|
+
this._options = {
|
|
411
|
+
username: options.username,
|
|
412
|
+
metadata: options.metadata,
|
|
413
|
+
appName: options.appName ?? DEFAULT_APP_NAME,
|
|
414
|
+
maxMetricPoints: options.maxMetricPoints ?? DEFAULT_MAX_METRIC_POINTS,
|
|
415
|
+
aggregationWindow: options.aggregationWindow ?? DEFAULT_AGGREGATION_WINDOW,
|
|
416
|
+
debug: options.debug ?? false,
|
|
417
|
+
panels: options.panels ?? [],
|
|
418
|
+
};
|
|
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
|
+
});
|
|
448
|
+
}
|
|
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
|
+
}
|
|
519
|
+
this._lobby = null;
|
|
520
|
+
this._onlineViewers.clear();
|
|
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
|
+
}
|
|
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
|
+
*/
|
|
627
|
+
joinPanel(name, opts) {
|
|
628
|
+
this._assertUsable();
|
|
629
|
+
let panel = this._panels.get(name);
|
|
630
|
+
if (!panel) {
|
|
631
|
+
panel = this._subscribePanelInternal(name, opts?.metricFilters);
|
|
632
|
+
}
|
|
633
|
+
panel._activate();
|
|
634
|
+
return panel;
|
|
635
|
+
}
|
|
636
|
+
/**
|
|
637
|
+
* Leave a dashboard panel. Fully unsubscribes and removes it.
|
|
638
|
+
*/
|
|
639
|
+
leavePanel(name) {
|
|
640
|
+
const panel = this._panels.get(name);
|
|
641
|
+
if (!panel)
|
|
642
|
+
return;
|
|
643
|
+
this._log('Leaving panel:', name);
|
|
644
|
+
panel._cleanup();
|
|
645
|
+
this._panels.delete(name);
|
|
646
|
+
}
|
|
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);
|
|
672
|
+
const roomContext = this._client.setApp(this._options.appName).setRoom(name);
|
|
673
|
+
const panel = new DashboardPanel(name, roomContext, this._viewerId, this._client.actorId, this._options, createLogger(`DashPanel:${name}`, this._options.debug), () => this._client.connected);
|
|
674
|
+
this._panels.set(name, panel);
|
|
675
|
+
panel._subscribe(metricFilters);
|
|
676
|
+
return panel;
|
|
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 ============
|
|
690
|
+
_handlePresenceJoin(data) {
|
|
691
|
+
if (data.actorTokenId === this._localViewer?.actorTokenId)
|
|
692
|
+
return;
|
|
693
|
+
const pd = data.presence;
|
|
694
|
+
if (!pd?.viewerId || this._foreignScope(pd))
|
|
695
|
+
return;
|
|
696
|
+
const viewer = this._toViewer(data.actorTokenId, pd);
|
|
697
|
+
this._actorToViewerId.set(data.actorTokenId, viewer.viewerId);
|
|
698
|
+
if (!this._onlineViewers.has(viewer.viewerId)) {
|
|
699
|
+
this._onlineViewers.set(viewer.viewerId, viewer);
|
|
700
|
+
this.emit('viewerOnline', viewer);
|
|
701
|
+
}
|
|
702
|
+
for (const panel of this._panels.values()) {
|
|
703
|
+
panel._handlePresenceJoin(data.actorTokenId, pd);
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
_handlePresenceLeave(data) {
|
|
707
|
+
if (data.actorTokenId === this._localViewer?.actorTokenId)
|
|
708
|
+
return;
|
|
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
|
+
}
|
|
714
|
+
}
|
|
715
|
+
_handlePresenceUpdate(data) {
|
|
716
|
+
if (data.actorTokenId === this._localViewer?.actorTokenId)
|
|
717
|
+
return;
|
|
718
|
+
const pd = data.presence;
|
|
719
|
+
if (!pd?.viewerId || this._foreignScope(pd))
|
|
720
|
+
return;
|
|
721
|
+
for (const panel of this._panels.values()) {
|
|
722
|
+
panel._handlePresenceUpdate(data.actorTokenId, pd);
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
// ============ Private: Lobby ============
|
|
726
|
+
_handleLobbyJoin(event) {
|
|
727
|
+
const { actorId, data } = event;
|
|
728
|
+
if (actorId === this._localViewer?.actorTokenId)
|
|
729
|
+
return;
|
|
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);
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
}
|
|
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)
|
|
782
|
+
continue;
|
|
783
|
+
const raw = roomPresence[actorId];
|
|
784
|
+
// Server returns full actor records with presence nested under .presence
|
|
785
|
+
const pd = (raw?.presence ?? raw);
|
|
786
|
+
if (pd?.viewerId && !this._foreignScope(pd)) {
|
|
787
|
+
if (!fresh.has(pd.viewerId)) {
|
|
788
|
+
fresh.set(pd.viewerId, this._toViewer(actorId, pd));
|
|
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);
|
|
801
|
+
}
|
|
802
|
+
this.emit('viewerOffline', viewer);
|
|
803
|
+
}
|
|
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
|
+
}
|
|
815
|
+
}
|
|
816
|
+
// ============ Private: Helpers ============
|
|
817
|
+
_toViewer(actorTokenId, data) {
|
|
818
|
+
return {
|
|
819
|
+
viewerId: data.viewerId,
|
|
820
|
+
actorTokenId,
|
|
821
|
+
username: data.username,
|
|
822
|
+
metadata: data.metadata,
|
|
823
|
+
joinedAt: Date.now(),
|
|
824
|
+
isLocal: false,
|
|
825
|
+
};
|
|
826
|
+
}
|
|
827
|
+
_findViewerIdByActorId(actorTokenId) {
|
|
828
|
+
for (const viewer of this._onlineViewers.values()) {
|
|
829
|
+
if (viewer.actorTokenId === actorTokenId)
|
|
830
|
+
return viewer.viewerId;
|
|
831
|
+
}
|
|
832
|
+
return undefined;
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
export { DashboardPanel, EventEmitter, NoLagDash };
|
|
837
|
+
//# sourceMappingURL=react-native.js.map
|