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