@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.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
- this._roomContext.on(TOPIC_SIGNALING, (data) => {
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
- this._roomContext.unsubscribe(TOPIC_SIGNALING);
284
- this._roomContext.off(TOPIC_SIGNALING);
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 signal = new NoLagSignal(token, { debug: true });
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 signal.connect();
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(token, options = {}) {
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
- this._token = token;
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?.connected ?? false;
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 connect) */
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
- * Connect to NoLag and set up global presence.
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
- async connect() {
368
- this._log('Connecting...');
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
- * Disconnect from NoLag and clean up all rooms.
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
- disconnect() {
436
- this._log('Disconnecting...');
437
- // Clean up rooms
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.leaveRoom(name);
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
- if (!this._client || !this._localPeer) {
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
- _hydrateOnlinePeers(state) {
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
- const peer = this._presenceToPeer(actorId, presenceData);
622
- this._actorToPeerId.set(actorId, peer.peerId);
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;