@nolag/signal 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 +52 -9
- package/dist/NoLagSignal.d.ts +68 -15
- package/dist/SignalRoom.d.ts +3 -1
- package/dist/browser.js +1 -1
- package/dist/browser.js.map +1 -1
- package/dist/constants.d.ts +2 -0
- package/dist/index.cjs +322 -152
- package/dist/index.cjs.map +1 -1
- package/dist/index.mjs +322 -152
- package/dist/index.mjs.map +1 -1
- package/dist/types.d.ts +10 -7
- 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
|
/**
|
|
6
4
|
* Tiny typed event emitter — framework-agnostic base for NoLag SDKs.
|
|
7
5
|
*
|
|
@@ -144,6 +142,29 @@ function createLogger(prefix, enabled) {
|
|
|
144
142
|
console.log(`[${prefix}]`, ...args);
|
|
145
143
|
};
|
|
146
144
|
}
|
|
145
|
+
// ============ Wrapper registry ============
|
|
146
|
+
// One wrapper instance per (client, appName): two wrappers sharing an app on
|
|
147
|
+
// one connection would collide on topics, presence and the online lobby.
|
|
148
|
+
// Warn (not throw): HMR and tests legitimately construct before disposing.
|
|
149
|
+
const wrapperRegistry = new WeakMap();
|
|
150
|
+
/** Register a wrapper against a client + appName; warns on collision. */
|
|
151
|
+
function registerWrapper(client, appName, wrapperName) {
|
|
152
|
+
let apps = wrapperRegistry.get(client);
|
|
153
|
+
if (!apps) {
|
|
154
|
+
apps = new Map();
|
|
155
|
+
wrapperRegistry.set(client, apps);
|
|
156
|
+
}
|
|
157
|
+
const existing = apps.get(appName);
|
|
158
|
+
if (existing) {
|
|
159
|
+
console.warn(`[${wrapperName}] Another wrapper (${existing}) is already attached to this client for app "${appName}". ` +
|
|
160
|
+
`Use one wrapper per (client, app) — detach the other instance first.`);
|
|
161
|
+
}
|
|
162
|
+
apps.set(appName, wrapperName);
|
|
163
|
+
}
|
|
164
|
+
/** Release a wrapper's (client, appName) registration on detach. */
|
|
165
|
+
function releaseWrapper(client, appName) {
|
|
166
|
+
wrapperRegistry.get(client)?.delete(appName);
|
|
167
|
+
}
|
|
147
168
|
|
|
148
169
|
/** Default app name for NoLag signal SDK */
|
|
149
170
|
const DEFAULT_APP_NAME = 'signal';
|
|
@@ -151,6 +172,8 @@ const DEFAULT_APP_NAME = 'signal';
|
|
|
151
172
|
const TOPIC_SIGNALING = 'signaling';
|
|
152
173
|
/** Lobby ID for global online presence */
|
|
153
174
|
const LOBBY_ID = 'online';
|
|
175
|
+
/** Delay before the post-setup lobby presence refetch (catches simultaneous joiners) */
|
|
176
|
+
const LOBBY_REFRESH_DELAY_MS = 2000;
|
|
154
177
|
|
|
155
178
|
/**
|
|
156
179
|
* SignalRoom — a single signaling room for WebRTC peer discovery and exchange.
|
|
@@ -159,13 +182,17 @@ const LOBBY_ID = 'online';
|
|
|
159
182
|
*/
|
|
160
183
|
class SignalRoom extends EventEmitter {
|
|
161
184
|
/** @internal */
|
|
162
|
-
constructor(name, roomContext, localPeer, options, log) {
|
|
185
|
+
constructor(name, roomContext, localPeer, options, log, isConnected) {
|
|
163
186
|
super();
|
|
187
|
+
// Stored topic handler ref — cleanup removes exactly this, never all
|
|
188
|
+
// handlers for a topic (the client may be shared with other consumers).
|
|
189
|
+
this._onSignalingRef = null;
|
|
164
190
|
this.name = name;
|
|
165
191
|
this._roomContext = roomContext;
|
|
166
192
|
this._localPeer = localPeer;
|
|
167
193
|
this._options = options;
|
|
168
194
|
this._log = log;
|
|
195
|
+
this._isConnected = isConnected;
|
|
169
196
|
this._peerManager = new PeerManager(localPeer.actorTokenId);
|
|
170
197
|
}
|
|
171
198
|
// ============ Public Properties ============
|
|
@@ -231,9 +258,11 @@ class SignalRoom extends EventEmitter {
|
|
|
231
258
|
_subscribe() {
|
|
232
259
|
this._log('Room subscribe:', this.name);
|
|
233
260
|
this._roomContext.subscribe(TOPIC_SIGNALING);
|
|
234
|
-
|
|
261
|
+
// Listen for signals (ref stored for handler-specific removal)
|
|
262
|
+
this._onSignalingRef = (data) => {
|
|
235
263
|
this._handleIncomingSignal(data);
|
|
236
|
-
}
|
|
264
|
+
};
|
|
265
|
+
this._roomContext.on(TOPIC_SIGNALING, this._onSignalingRef);
|
|
237
266
|
}
|
|
238
267
|
/** @internal Set presence and fetch room members */
|
|
239
268
|
_activate() {
|
|
@@ -280,8 +309,16 @@ class SignalRoom extends EventEmitter {
|
|
|
280
309
|
/** @internal Unsubscribe and clean up */
|
|
281
310
|
_cleanup() {
|
|
282
311
|
this._log('Room cleanup:', this.name);
|
|
283
|
-
|
|
284
|
-
|
|
312
|
+
// Server unsubscribes need a live socket; skip when disconnected
|
|
313
|
+
// (best-effort — the core would no-op with an error callback anyway).
|
|
314
|
+
if (this._isConnected()) {
|
|
315
|
+
this._roomContext.unsubscribe(TOPIC_SIGNALING);
|
|
316
|
+
}
|
|
317
|
+
// Handler-specific removal only: the client may be shared, and a bare
|
|
318
|
+
// off(topic) would strip other consumers' handlers too.
|
|
319
|
+
if (this._onSignalingRef)
|
|
320
|
+
this._roomContext.off(TOPIC_SIGNALING, this._onSignalingRef);
|
|
321
|
+
this._onSignalingRef = null;
|
|
285
322
|
this._peerManager.clear();
|
|
286
323
|
this.removeAllListeners();
|
|
287
324
|
}
|
|
@@ -298,6 +335,9 @@ class SignalRoom extends EventEmitter {
|
|
|
298
335
|
const presenceData = {
|
|
299
336
|
peerId: this._localPeer.peerId,
|
|
300
337
|
metadata: this._localPeer.metadata,
|
|
338
|
+
// Scope tag: on a shared client, other apps' wrappers filter our
|
|
339
|
+
// presence out by this (and we filter theirs).
|
|
340
|
+
__scope: this._options.appName,
|
|
301
341
|
};
|
|
302
342
|
this._roomContext.setPresence(presenceData);
|
|
303
343
|
}
|
|
@@ -309,50 +349,116 @@ class SignalRoom extends EventEmitter {
|
|
|
309
349
|
* Provides peer discovery, offer/answer/ICE exchange, and global presence
|
|
310
350
|
* tracking — all framework-agnostic via events.
|
|
311
351
|
*
|
|
352
|
+
* The wrapper NEVER manages the connection. The app owns one core NoLag
|
|
353
|
+
* client (shared by any number of wrappers on distinct apps) and the
|
|
354
|
+
* wrapper attaches to it at construction and releases it via `detach()`.
|
|
355
|
+
*
|
|
312
356
|
* @example
|
|
313
357
|
* ```typescript
|
|
358
|
+
* import { NoLag } from '@nolag/js-sdk';
|
|
314
359
|
* import { NoLagSignal } from '@nolag/signal';
|
|
315
360
|
*
|
|
316
|
-
* const
|
|
361
|
+
* const client = NoLag(async () => (await (await fetch('/api/nolag-token')).json()).token);
|
|
362
|
+
* const signal = new NoLagSignal({ client, appName: 'my-signal' });
|
|
317
363
|
*
|
|
318
|
-
* signal.on('connected', () => console.log('Connected!'));
|
|
319
364
|
* signal.on('peerOnline', (peer) => console.log(peer.peerId, 'is online'));
|
|
320
365
|
*
|
|
321
|
-
* await
|
|
366
|
+
* await client.connect(); // the app owns the connection
|
|
367
|
+
* await signal.ready(); // wrapper setup done (identity, lobby)
|
|
322
368
|
*
|
|
323
369
|
* const room = signal.joinRoom('call-room');
|
|
324
370
|
* room.on('signal', (msg) => {
|
|
325
371
|
* if (msg.type === 'offer') handleOffer(msg);
|
|
326
372
|
* });
|
|
327
373
|
* room.sendOffer(remotePeerId, offer);
|
|
374
|
+
*
|
|
375
|
+
* signal.detach(); // wrapper releases its handlers and topics
|
|
376
|
+
* client.disconnect(); // the app closes the socket
|
|
328
377
|
* ```
|
|
329
378
|
*/
|
|
330
379
|
class NoLagSignal extends EventEmitter {
|
|
331
|
-
constructor(
|
|
380
|
+
constructor(options) {
|
|
332
381
|
super();
|
|
333
|
-
this._client = null;
|
|
334
382
|
this._localPeer = null;
|
|
335
383
|
this._rooms = new Map();
|
|
336
384
|
this._lobby = null;
|
|
337
385
|
this._onlinePeers = new Map();
|
|
338
386
|
this._actorToPeerId = new Map();
|
|
339
|
-
|
|
387
|
+
// Lifecycle: one setup run per connection epoch; detach is terminal.
|
|
388
|
+
this._epoch = 0;
|
|
389
|
+
this._detached = false;
|
|
390
|
+
this._isReady = false;
|
|
391
|
+
this._lobbyRefreshTimer = null;
|
|
392
|
+
// Stored client handler refs. INVARIANT: every client.on() below has a
|
|
393
|
+
// matching client.off() in detach() — never bare off(event), never inline
|
|
394
|
+
// closures on the client.
|
|
395
|
+
this._onConnectRef = () => this._onConnect();
|
|
396
|
+
this._onDisconnectRef = (reason) => {
|
|
397
|
+
this._log('Disconnected:', reason);
|
|
398
|
+
this.emit('disconnected', reason);
|
|
399
|
+
};
|
|
400
|
+
this._onReconnectRef = () => {
|
|
401
|
+
this._log('Reconnecting...');
|
|
402
|
+
this.emit('reconnecting');
|
|
403
|
+
};
|
|
404
|
+
this._onErrorRef = (error) => {
|
|
405
|
+
this._log('Error:', error);
|
|
406
|
+
this.emit('error', error);
|
|
407
|
+
};
|
|
408
|
+
this._onPresenceJoinRef = (data) => this._handleRoomPresenceJoin(data);
|
|
409
|
+
this._onPresenceLeaveRef = (data) => this._handleRoomPresenceLeave(data);
|
|
410
|
+
this._onPresenceUpdateRef = (data) => this._handleRoomPresenceUpdate(data);
|
|
411
|
+
this._onLobbyJoinRef = (data) => this._handleLobbyJoin(data);
|
|
412
|
+
this._onLobbyLeaveRef = (data) => this._handleLobbyLeave(data);
|
|
413
|
+
this._onLobbyUpdateRef = (data) => this._handleLobbyUpdate(data);
|
|
414
|
+
if (!options?.client) {
|
|
415
|
+
throw new TypeError('NoLagSignal requires an injected NoLag client: new NoLagSignal({ client, ... })');
|
|
416
|
+
}
|
|
417
|
+
this._client = options.client;
|
|
340
418
|
this._peerId = generateId();
|
|
341
419
|
this._options = {
|
|
342
420
|
metadata: options.metadata,
|
|
343
421
|
appName: options.appName ?? DEFAULT_APP_NAME,
|
|
344
|
-
url: options.url,
|
|
345
422
|
debug: options.debug ?? false,
|
|
346
|
-
reconnect: options.reconnect ?? true,
|
|
347
423
|
};
|
|
348
424
|
this._log = createLogger('NoLagSignal', this._options.debug);
|
|
425
|
+
this._readyPromise = new Promise((resolve, reject) => {
|
|
426
|
+
this._readyResolve = resolve;
|
|
427
|
+
this._readyReject = reject;
|
|
428
|
+
});
|
|
429
|
+
// ready() rejection is only meaningful to callers that await it
|
|
430
|
+
this._readyPromise.catch(() => { });
|
|
431
|
+
registerWrapper(this._client, this._options.appName, 'NoLagSignal');
|
|
432
|
+
// Construction = attach: wire everything now, with stored refs.
|
|
433
|
+
this._client.on('connect', this._onConnectRef);
|
|
434
|
+
this._client.on('disconnect', this._onDisconnectRef);
|
|
435
|
+
this._client.on('reconnect', this._onReconnectRef);
|
|
436
|
+
this._client.on('error', this._onErrorRef);
|
|
437
|
+
this._client.on('presence:join', this._onPresenceJoinRef);
|
|
438
|
+
this._client.on('presence:leave', this._onPresenceLeaveRef);
|
|
439
|
+
this._client.on('presence:update', this._onPresenceUpdateRef);
|
|
440
|
+
this._client.on('lobbyPresence:join', this._onLobbyJoinRef);
|
|
441
|
+
this._client.on('lobbyPresence:leave', this._onLobbyLeaveRef);
|
|
442
|
+
this._client.on('lobbyPresence:update', this._onLobbyUpdateRef);
|
|
443
|
+
// Attach-to-connected: if the client is already authenticated, run setup.
|
|
444
|
+
// The microtask lets the caller wire wrapper event handlers synchronously
|
|
445
|
+
// first; a racing real 'connect' event wins via the epoch guard.
|
|
446
|
+
queueMicrotask(() => {
|
|
447
|
+
if (this._epoch === 0 && !this._detached && this._client.connected) {
|
|
448
|
+
this._onConnect();
|
|
449
|
+
}
|
|
450
|
+
});
|
|
349
451
|
}
|
|
350
452
|
// ============ Public Properties ============
|
|
351
|
-
/** Whether the underlying connection is established */
|
|
453
|
+
/** Whether the underlying connection is established (connected ≠ ready) */
|
|
352
454
|
get connected() {
|
|
353
|
-
return this._client
|
|
455
|
+
return !this._detached && this._client.connected;
|
|
456
|
+
}
|
|
457
|
+
/** The injected core client (owned by the app, not the wrapper) */
|
|
458
|
+
get client() {
|
|
459
|
+
return this._client;
|
|
354
460
|
}
|
|
355
|
-
/** The local peer's info (available after
|
|
461
|
+
/** The local peer's info (available after ready) */
|
|
356
462
|
get localPeer() {
|
|
357
463
|
return this._localPeer;
|
|
358
464
|
}
|
|
@@ -362,92 +468,151 @@ class NoLagSignal extends EventEmitter {
|
|
|
362
468
|
}
|
|
363
469
|
// ============ Lifecycle ============
|
|
364
470
|
/**
|
|
365
|
-
*
|
|
471
|
+
* Resolves once the wrapper's first setup completed (identity and lobby
|
|
472
|
+
* ready — equivalently, once 'connected' has fired). Rejects only if
|
|
473
|
+
* detach() is called before that. Client auth failures surface via the
|
|
474
|
+
* app's own `await client.connect()`, not here.
|
|
366
475
|
*/
|
|
367
|
-
|
|
368
|
-
this.
|
|
369
|
-
const clientOptions = {
|
|
370
|
-
debug: this._options.debug,
|
|
371
|
-
reconnect: this._options.reconnect,
|
|
372
|
-
};
|
|
373
|
-
if (this._options.url) {
|
|
374
|
-
clientOptions.url = this._options.url;
|
|
375
|
-
}
|
|
376
|
-
this._client = jsSdk.NoLag(this._token, clientOptions);
|
|
377
|
-
// Wire client lifecycle events
|
|
378
|
-
this._client.on('connect', () => {
|
|
379
|
-
this._log('Connected');
|
|
380
|
-
if (this._rooms.size > 0) {
|
|
381
|
-
this._log('Reconnected — restoring rooms...');
|
|
382
|
-
this._restoreRooms();
|
|
383
|
-
this.emit('reconnected');
|
|
384
|
-
}
|
|
385
|
-
});
|
|
386
|
-
this._client.on('disconnect', (reason) => {
|
|
387
|
-
this._log('Disconnected:', reason);
|
|
388
|
-
this.emit('disconnected', reason);
|
|
389
|
-
});
|
|
390
|
-
this._client.on('reconnect', () => {
|
|
391
|
-
this._log('Reconnecting...');
|
|
392
|
-
});
|
|
393
|
-
this._client.on('error', (error) => {
|
|
394
|
-
this._log('Error:', error);
|
|
395
|
-
this.emit('error', error);
|
|
396
|
-
});
|
|
397
|
-
// Connect
|
|
398
|
-
await this._client.connect();
|
|
399
|
-
// Wire room-level presence events
|
|
400
|
-
this._client.on('presence:join', (data) => {
|
|
401
|
-
this._handleRoomPresenceJoin(data);
|
|
402
|
-
});
|
|
403
|
-
this._client.on('presence:leave', (data) => {
|
|
404
|
-
this._handleRoomPresenceLeave(data);
|
|
405
|
-
});
|
|
406
|
-
this._client.on('presence:update', (data) => {
|
|
407
|
-
this._handleRoomPresenceUpdate(data);
|
|
408
|
-
});
|
|
409
|
-
// Create local peer
|
|
410
|
-
this._localPeer = {
|
|
411
|
-
peerId: this._peerId,
|
|
412
|
-
actorTokenId: this._client.actorId,
|
|
413
|
-
connectionState: 'new',
|
|
414
|
-
metadata: this._options.metadata,
|
|
415
|
-
joinedAt: Date.now(),
|
|
416
|
-
isLocal: true,
|
|
417
|
-
};
|
|
418
|
-
this._log('Local peer:', this._localPeer.peerId, '→', this._localPeer.actorTokenId);
|
|
419
|
-
// Set up lobby for global presence
|
|
420
|
-
await this._setupLobby();
|
|
421
|
-
// Emit connected now that _localPeer and lobby are ready
|
|
422
|
-
this.emit('connected');
|
|
423
|
-
// Deferred lobby refetch to catch peers who joined during the setup window
|
|
424
|
-
setTimeout(() => {
|
|
425
|
-
if (this._lobby && this._client?.connected) {
|
|
426
|
-
this._lobby.fetchPresence().then((state) => {
|
|
427
|
-
this._hydrateOnlinePeers(state);
|
|
428
|
-
}).catch(() => { });
|
|
429
|
-
}
|
|
430
|
-
}, 2000);
|
|
476
|
+
ready() {
|
|
477
|
+
return this._readyPromise;
|
|
431
478
|
}
|
|
432
479
|
/**
|
|
433
|
-
*
|
|
480
|
+
* Detach from the client: remove every handler this wrapper added,
|
|
481
|
+
* unsubscribe its topics and lobby (when connected), clear state.
|
|
482
|
+
* Terminal and idempotent; never touches the socket. To use signaling
|
|
483
|
+
* again, construct a new instance.
|
|
434
484
|
*/
|
|
435
|
-
|
|
436
|
-
this.
|
|
437
|
-
|
|
485
|
+
detach() {
|
|
486
|
+
if (this._detached)
|
|
487
|
+
return;
|
|
488
|
+
this._log('Detaching...');
|
|
489
|
+
this._detached = true;
|
|
490
|
+
this._epoch++; // aborts any in-flight setup at its next checkpoint
|
|
491
|
+
if (this._lobbyRefreshTimer) {
|
|
492
|
+
clearTimeout(this._lobbyRefreshTimer);
|
|
493
|
+
this._lobbyRefreshTimer = null;
|
|
494
|
+
}
|
|
495
|
+
// Remove all client handlers by stored ref
|
|
496
|
+
this._client.off('connect', this._onConnectRef);
|
|
497
|
+
this._client.off('disconnect', this._onDisconnectRef);
|
|
498
|
+
this._client.off('reconnect', this._onReconnectRef);
|
|
499
|
+
this._client.off('error', this._onErrorRef);
|
|
500
|
+
this._client.off('presence:join', this._onPresenceJoinRef);
|
|
501
|
+
this._client.off('presence:leave', this._onPresenceLeaveRef);
|
|
502
|
+
this._client.off('presence:update', this._onPresenceUpdateRef);
|
|
503
|
+
this._client.off('lobbyPresence:join', this._onLobbyJoinRef);
|
|
504
|
+
this._client.off('lobbyPresence:leave', this._onLobbyLeaveRef);
|
|
505
|
+
this._client.off('lobbyPresence:update', this._onLobbyUpdateRef);
|
|
506
|
+
// Rooms: handler-specific off + connected-gated server unsubscribe
|
|
438
507
|
for (const name of [...this._rooms.keys()]) {
|
|
439
|
-
this.
|
|
508
|
+
this._rooms.get(name)._cleanup();
|
|
509
|
+
this._rooms.delete(name);
|
|
510
|
+
}
|
|
511
|
+
// Lobby: server unsubscribe is best-effort and needs a live socket
|
|
512
|
+
if (this._lobby && this._client.connected) {
|
|
513
|
+
try {
|
|
514
|
+
this._lobby.unsubscribe();
|
|
515
|
+
}
|
|
516
|
+
catch {
|
|
517
|
+
/* best-effort */
|
|
518
|
+
}
|
|
440
519
|
}
|
|
441
|
-
// Unsubscribe from lobby
|
|
442
|
-
this._lobby?.unsubscribe();
|
|
443
520
|
this._lobby = null;
|
|
444
|
-
// Disconnect client
|
|
445
|
-
this._client?.disconnect();
|
|
446
|
-
this._client = null;
|
|
447
|
-
// Clear state
|
|
448
521
|
this._onlinePeers.clear();
|
|
449
522
|
this._actorToPeerId.clear();
|
|
450
523
|
this._localPeer = null;
|
|
524
|
+
releaseWrapper(this._client, this._options.appName);
|
|
525
|
+
if (!this._isReady) {
|
|
526
|
+
this._readyReject(new Error('NoLagSignal detached before ready'));
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
// ============ Private: Epoch Setup ============
|
|
530
|
+
_onConnect() {
|
|
531
|
+
this._epoch++;
|
|
532
|
+
void this._runSetup(this._epoch);
|
|
533
|
+
}
|
|
534
|
+
/**
|
|
535
|
+
* One setup pass per connection epoch. Serves both initial setup (epoch 1)
|
|
536
|
+
* and reconnect restore (epoch > 1). Aborts silently whenever a newer
|
|
537
|
+
* epoch started or the wrapper detached — checked after every await.
|
|
538
|
+
*/
|
|
539
|
+
async _runSetup(epoch) {
|
|
540
|
+
const stale = () => epoch !== this._epoch || this._detached;
|
|
541
|
+
this._log(this._isReady ? 'Restoring after reconnect...' : 'Setting up...');
|
|
542
|
+
// Identity (client.actorId is guaranteed post-auth)
|
|
543
|
+
if (!this._localPeer) {
|
|
544
|
+
this._localPeer = {
|
|
545
|
+
peerId: this._peerId,
|
|
546
|
+
actorTokenId: this._client.actorId,
|
|
547
|
+
connectionState: 'new',
|
|
548
|
+
metadata: this._options.metadata,
|
|
549
|
+
joinedAt: Date.now(),
|
|
550
|
+
isLocal: true,
|
|
551
|
+
};
|
|
552
|
+
this._log('Local peer:', this._localPeer.peerId, '→', this._localPeer.actorTokenId);
|
|
553
|
+
}
|
|
554
|
+
else {
|
|
555
|
+
this._localPeer.actorTokenId = this._client.actorId;
|
|
556
|
+
}
|
|
557
|
+
// Lobby: subscribe every epoch (idempotent server-side) and diff-hydrate
|
|
558
|
+
// from the returned snapshot — one path for setup and restore.
|
|
559
|
+
if (!this._lobby) {
|
|
560
|
+
this._lobby = this._client.setApp(this._options.appName).setLobby(LOBBY_ID);
|
|
561
|
+
}
|
|
562
|
+
try {
|
|
563
|
+
const state = await this._lobby.subscribe();
|
|
564
|
+
if (stale())
|
|
565
|
+
return;
|
|
566
|
+
this._diffHydrateOnlinePeers(state);
|
|
567
|
+
this._log('Lobby subscribed, online peers:', this._onlinePeers.size);
|
|
568
|
+
}
|
|
569
|
+
catch (err) {
|
|
570
|
+
if (stale())
|
|
571
|
+
return;
|
|
572
|
+
this._log('Lobby subscription failed:', err);
|
|
573
|
+
}
|
|
574
|
+
if (this._isReady) {
|
|
575
|
+
// Server auto-restored topic subscriptions; only room-scoped presence
|
|
576
|
+
// needs re-applying (the core does not restore it).
|
|
577
|
+
for (const room of this._rooms.values()) {
|
|
578
|
+
room._updateLocalPresence();
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
if (stale())
|
|
582
|
+
return;
|
|
583
|
+
// Ready keys on the first setup that COMPLETES, not on epoch 1: an
|
|
584
|
+
// epoch aborted by a racing reconnect must not strand ready().
|
|
585
|
+
if (!this._isReady) {
|
|
586
|
+
this._isReady = true;
|
|
587
|
+
this._readyResolve();
|
|
588
|
+
this.emit('connected');
|
|
589
|
+
}
|
|
590
|
+
else {
|
|
591
|
+
this.emit('reconnected');
|
|
592
|
+
}
|
|
593
|
+
// Deferred lobby refetch: catches peers who joined during the setup
|
|
594
|
+
// window (e.g. simultaneous multi-tab connects).
|
|
595
|
+
this._scheduleLobbyRefresh(epoch);
|
|
596
|
+
}
|
|
597
|
+
_scheduleLobbyRefresh(epoch) {
|
|
598
|
+
if (this._lobbyRefreshTimer)
|
|
599
|
+
clearTimeout(this._lobbyRefreshTimer);
|
|
600
|
+
this._lobbyRefreshTimer = setTimeout(() => {
|
|
601
|
+
this._lobbyRefreshTimer = null;
|
|
602
|
+
if (epoch !== this._epoch || this._detached || !this._client.connected || !this._lobby) {
|
|
603
|
+
return;
|
|
604
|
+
}
|
|
605
|
+
this._lobby
|
|
606
|
+
.fetchPresence()
|
|
607
|
+
.then((state) => {
|
|
608
|
+
if (epoch !== this._epoch || this._detached)
|
|
609
|
+
return;
|
|
610
|
+
this._diffHydrateOnlinePeers(state);
|
|
611
|
+
})
|
|
612
|
+
.catch(() => {
|
|
613
|
+
/* best-effort */
|
|
614
|
+
});
|
|
615
|
+
}, LOBBY_REFRESH_DELAY_MS);
|
|
451
616
|
}
|
|
452
617
|
// ============ Room Management ============
|
|
453
618
|
/**
|
|
@@ -455,9 +620,7 @@ class NoLagSignal extends EventEmitter {
|
|
|
455
620
|
* Returns an existing room if already joined.
|
|
456
621
|
*/
|
|
457
622
|
joinRoom(name) {
|
|
458
|
-
|
|
459
|
-
throw new Error('Not connected — call connect() first');
|
|
460
|
-
}
|
|
623
|
+
this._assertUsable();
|
|
461
624
|
let room = this._rooms.get(name);
|
|
462
625
|
if (!room) {
|
|
463
626
|
room = this._subscribeRoom(name);
|
|
@@ -489,24 +652,41 @@ class NoLagSignal extends EventEmitter {
|
|
|
489
652
|
getOnlinePeers() {
|
|
490
653
|
return Array.from(this._onlinePeers.values());
|
|
491
654
|
}
|
|
655
|
+
// ============ Private: Guards ============
|
|
656
|
+
_assertUsable() {
|
|
657
|
+
if (this._detached) {
|
|
658
|
+
throw new Error('NoLagSignal has been detached — construct a new instance');
|
|
659
|
+
}
|
|
660
|
+
if (!this._isReady || !this._localPeer) {
|
|
661
|
+
throw new Error('NoLagSignal not ready — await ready() or the "connected" event');
|
|
662
|
+
}
|
|
663
|
+
}
|
|
492
664
|
// ============ Private: Room Setup ============
|
|
493
665
|
_subscribeRoom(name) {
|
|
494
|
-
if (!this._client || !this._localPeer) {
|
|
495
|
-
throw new Error('Not connected — call connect() first');
|
|
496
|
-
}
|
|
497
666
|
this._log('Subscribing room:', name);
|
|
498
667
|
const roomContext = this._client.setApp(this._options.appName).setRoom(name);
|
|
499
|
-
const room = new SignalRoom(name, roomContext, this._localPeer, this._options, createLogger(`SignalRoom:${name}`, this._options.debug));
|
|
668
|
+
const room = new SignalRoom(name, roomContext, this._localPeer, this._options, createLogger(`SignalRoom:${name}`, this._options.debug), () => this._client.connected);
|
|
500
669
|
this._rooms.set(name, room);
|
|
501
670
|
room._subscribe();
|
|
502
671
|
return room;
|
|
503
672
|
}
|
|
673
|
+
// ============ Private: Scope Filtering ============
|
|
674
|
+
/**
|
|
675
|
+
* On a shared client, presence events from other apps' wrappers arrive on
|
|
676
|
+
* the same connection-level events. Wrappers stamp their presence with a
|
|
677
|
+
* `__scope` (their appName); a mismatched tag means another app's data.
|
|
678
|
+
* Untagged presence is accepted (older peers in this same app).
|
|
679
|
+
*/
|
|
680
|
+
_foreignScope(data) {
|
|
681
|
+
const scope = data?.__scope;
|
|
682
|
+
return typeof scope === 'string' && scope !== this._options.appName;
|
|
683
|
+
}
|
|
504
684
|
// ============ Private: Room Presence ============
|
|
505
685
|
_handleRoomPresenceJoin(data) {
|
|
506
686
|
if (data.actorTokenId === this._localPeer?.actorTokenId)
|
|
507
687
|
return;
|
|
508
688
|
const presenceData = data.presence;
|
|
509
|
-
if (!presenceData?.peerId)
|
|
689
|
+
if (!presenceData?.peerId || this._foreignScope(presenceData))
|
|
510
690
|
return;
|
|
511
691
|
const peer = this._presenceToPeer(data.actorTokenId, presenceData);
|
|
512
692
|
this._actorToPeerId.set(data.actorTokenId, peer.peerId);
|
|
@@ -531,7 +711,7 @@ class NoLagSignal extends EventEmitter {
|
|
|
531
711
|
if (data.actorTokenId === this._localPeer?.actorTokenId)
|
|
532
712
|
return;
|
|
533
713
|
const presenceData = data.presence;
|
|
534
|
-
if (!presenceData?.peerId)
|
|
714
|
+
if (!presenceData?.peerId || this._foreignScope(presenceData))
|
|
535
715
|
return;
|
|
536
716
|
if (this._onlinePeers.has(presenceData.peerId)) {
|
|
537
717
|
const peer = this._presenceToPeer(data.actorTokenId, presenceData);
|
|
@@ -543,37 +723,12 @@ class NoLagSignal extends EventEmitter {
|
|
|
543
723
|
}
|
|
544
724
|
}
|
|
545
725
|
// ============ Private: Lobby ============
|
|
546
|
-
async _setupLobby() {
|
|
547
|
-
if (!this._client)
|
|
548
|
-
return;
|
|
549
|
-
this._lobby = this._client.setApp(this._options.appName).setLobby(LOBBY_ID);
|
|
550
|
-
const lobbyHandler = (type) => (data) => {
|
|
551
|
-
const event = data;
|
|
552
|
-
if (type === 'join')
|
|
553
|
-
this._handleLobbyJoin(event);
|
|
554
|
-
else if (type === 'leave')
|
|
555
|
-
this._handleLobbyLeave(event);
|
|
556
|
-
else
|
|
557
|
-
this._handleLobbyUpdate(event);
|
|
558
|
-
};
|
|
559
|
-
this._client.on('lobbyPresence:join', lobbyHandler('join'));
|
|
560
|
-
this._client.on('lobbyPresence:leave', lobbyHandler('leave'));
|
|
561
|
-
this._client.on('lobbyPresence:update', lobbyHandler('update'));
|
|
562
|
-
try {
|
|
563
|
-
const initialState = await this._lobby.subscribe();
|
|
564
|
-
this._hydrateOnlinePeers(initialState);
|
|
565
|
-
this._log('Lobby subscribed, online peers:', this._onlinePeers.size);
|
|
566
|
-
}
|
|
567
|
-
catch (err) {
|
|
568
|
-
this._log('Lobby subscription failed:', err);
|
|
569
|
-
}
|
|
570
|
-
}
|
|
571
726
|
_handleLobbyJoin(event) {
|
|
572
727
|
const { actorId, data } = event;
|
|
573
728
|
if (actorId === this._localPeer?.actorTokenId)
|
|
574
729
|
return;
|
|
575
730
|
const presenceData = data;
|
|
576
|
-
if (!presenceData.peerId)
|
|
731
|
+
if (!presenceData.peerId || this._foreignScope(presenceData))
|
|
577
732
|
return;
|
|
578
733
|
const peer = this._presenceToPeer(actorId, presenceData);
|
|
579
734
|
this._actorToPeerId.set(actorId, peer.peerId);
|
|
@@ -587,6 +742,8 @@ class NoLagSignal extends EventEmitter {
|
|
|
587
742
|
if (actorId === this._localPeer?.actorTokenId)
|
|
588
743
|
return;
|
|
589
744
|
const presenceData = data;
|
|
745
|
+
if (this._foreignScope(presenceData))
|
|
746
|
+
return;
|
|
590
747
|
const peerId = presenceData?.peerId
|
|
591
748
|
|| this._actorToPeerId.get(actorId)
|
|
592
749
|
|| this._findPeerIdByActorId(actorId);
|
|
@@ -604,29 +761,57 @@ class NoLagSignal extends EventEmitter {
|
|
|
604
761
|
if (actorId === this._localPeer?.actorTokenId)
|
|
605
762
|
return;
|
|
606
763
|
const presenceData = data;
|
|
607
|
-
if (!presenceData.peerId)
|
|
764
|
+
if (!presenceData.peerId || this._foreignScope(presenceData))
|
|
608
765
|
return;
|
|
609
766
|
const peer = this._presenceToPeer(actorId, presenceData);
|
|
610
767
|
this._onlinePeers.set(peer.peerId, peer);
|
|
611
768
|
}
|
|
612
|
-
|
|
769
|
+
/**
|
|
770
|
+
* Reconcile the online-peer map against a fresh lobby snapshot, emitting
|
|
771
|
+
* only the deltas (peerOffline for vanished, peerOnline for new). One path
|
|
772
|
+
* for initial hydration, reconnect restore, and the deferred refetch.
|
|
773
|
+
*/
|
|
774
|
+
_diffHydrateOnlinePeers(state) {
|
|
775
|
+
// Build the fresh peer set from the snapshot
|
|
776
|
+
const fresh = new Map();
|
|
777
|
+
const freshActors = new Map();
|
|
613
778
|
for (const roomId of Object.keys(state)) {
|
|
614
779
|
const roomPresence = state[roomId];
|
|
615
780
|
for (const actorId of Object.keys(roomPresence)) {
|
|
616
781
|
if (actorId === this._localPeer?.actorTokenId)
|
|
617
782
|
continue;
|
|
618
783
|
const raw = roomPresence[actorId];
|
|
784
|
+
// Server returns full actor records with presence nested under .presence
|
|
619
785
|
const presenceData = (raw?.presence ?? raw);
|
|
620
|
-
if (presenceData?.peerId) {
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
if (!this._onlinePeers.has(peer.peerId)) {
|
|
624
|
-
this._onlinePeers.set(peer.peerId, peer);
|
|
625
|
-
this.emit('peerOnline', peer);
|
|
786
|
+
if (presenceData?.peerId && !this._foreignScope(presenceData)) {
|
|
787
|
+
if (!fresh.has(presenceData.peerId)) {
|
|
788
|
+
fresh.set(presenceData.peerId, this._presenceToPeer(actorId, presenceData));
|
|
626
789
|
}
|
|
790
|
+
freshActors.set(actorId, presenceData.peerId);
|
|
627
791
|
}
|
|
628
792
|
}
|
|
629
793
|
}
|
|
794
|
+
// Vanished peers
|
|
795
|
+
for (const [peerId, peer] of [...this._onlinePeers]) {
|
|
796
|
+
if (!fresh.has(peerId)) {
|
|
797
|
+
this._onlinePeers.delete(peerId);
|
|
798
|
+
for (const [actorId, mappedPeerId] of [...this._actorToPeerId]) {
|
|
799
|
+
if (mappedPeerId === peerId)
|
|
800
|
+
this._actorToPeerId.delete(actorId);
|
|
801
|
+
}
|
|
802
|
+
this.emit('peerOffline', peer);
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
// New peers
|
|
806
|
+
for (const [peerId, peer] of fresh) {
|
|
807
|
+
if (!this._onlinePeers.has(peerId)) {
|
|
808
|
+
this._onlinePeers.set(peerId, peer);
|
|
809
|
+
this.emit('peerOnline', peer);
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
for (const [actorId, peerId] of freshActors) {
|
|
813
|
+
this._actorToPeerId.set(actorId, peerId);
|
|
814
|
+
}
|
|
630
815
|
}
|
|
631
816
|
// ============ Private: Helpers ============
|
|
632
817
|
_presenceToPeer(actorTokenId, data) {
|
|
@@ -646,21 +831,6 @@ class NoLagSignal extends EventEmitter {
|
|
|
646
831
|
}
|
|
647
832
|
return undefined;
|
|
648
833
|
}
|
|
649
|
-
_restoreRooms() {
|
|
650
|
-
// On reconnect, js-sdk auto-restores subscriptions.
|
|
651
|
-
// Re-set presence on all active rooms.
|
|
652
|
-
for (const room of this._rooms.values()) {
|
|
653
|
-
room._updateLocalPresence();
|
|
654
|
-
}
|
|
655
|
-
// Re-fetch lobby presence
|
|
656
|
-
this._lobby?.fetchPresence().then((state) => {
|
|
657
|
-
this._onlinePeers.clear();
|
|
658
|
-
this._actorToPeerId.clear();
|
|
659
|
-
this._hydrateOnlinePeers(state);
|
|
660
|
-
}).catch((err) => {
|
|
661
|
-
this._log('Failed to re-fetch lobby presence:', err);
|
|
662
|
-
});
|
|
663
|
-
}
|
|
664
834
|
}
|
|
665
835
|
|
|
666
836
|
exports.EventEmitter = EventEmitter;
|