@nolag/iot 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/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
  *
@@ -233,6 +231,29 @@ function createLogger(prefix, enabled) {
233
231
  console.log(`[${prefix}]`, ...args);
234
232
  };
235
233
  }
234
+ // ============ Wrapper registry ============
235
+ // One wrapper instance per (client, appName): two wrappers sharing an app on
236
+ // one connection would collide on topics, presence and the online lobby.
237
+ // Warn (not throw): HMR and tests legitimately construct before disposing.
238
+ const wrapperRegistry = new WeakMap();
239
+ /** Register a wrapper against a client + appName; warns on collision. */
240
+ function registerWrapper(client, appName, wrapperName) {
241
+ let apps = wrapperRegistry.get(client);
242
+ if (!apps) {
243
+ apps = new Map();
244
+ wrapperRegistry.set(client, apps);
245
+ }
246
+ const existing = apps.get(appName);
247
+ if (existing) {
248
+ console.warn(`[${wrapperName}] Another wrapper (${existing}) is already attached to this client for app "${appName}". ` +
249
+ `Use one wrapper per (client, app) — detach the other instance first.`);
250
+ }
251
+ apps.set(appName, wrapperName);
252
+ }
253
+ /** Release a wrapper's (client, appName) registration on detach. */
254
+ function releaseWrapper(client, appName) {
255
+ wrapperRegistry.get(client)?.delete(appName);
256
+ }
236
257
 
237
258
  /**
238
259
  * Command dispatch with ack tracking and per-command timeout.
@@ -362,6 +383,8 @@ const TOPIC_COMMANDS = 'commands';
362
383
  const TOPIC_CMD_ACK = '_cmd_ack';
363
384
  /** Lobby ID for global online presence */
364
385
  const LOBBY_ID = 'online';
386
+ /** Delay before the post-setup lobby presence refetch (catches simultaneous joiners) */
387
+ const LOBBY_REFRESH_DELAY_MS = 2000;
365
388
 
366
389
  /**
367
390
  * DeviceGroup — a single IoT group for telemetry streaming and command dispatch.
@@ -370,14 +393,20 @@ const LOBBY_ID = 'online';
370
393
  */
371
394
  class DeviceGroup extends EventEmitter {
372
395
  /** @internal */
373
- constructor(name, roomContext, localDevice, options, log) {
396
+ constructor(name, roomContext, localDevice, options, log, isConnected) {
374
397
  super();
375
398
  this._receivedCommands = new Map();
399
+ // Stored topic handler refs — cleanup removes exactly these, never all
400
+ // handlers for a topic (the client may be shared with other consumers).
401
+ this._onTelemetryRef = null;
402
+ this._onCommandsRef = null;
403
+ this._onCmdAckRef = null;
376
404
  this.name = name;
377
405
  this._roomContext = roomContext;
378
406
  this._localDevice = localDevice;
379
407
  this._options = options;
380
408
  this._log = log;
409
+ this._isConnected = isConnected;
381
410
  this._presenceManager = new PresenceManager(localDevice.actorTokenId);
382
411
  this._telemetryStore = new TelemetryStore(options.maxTelemetryPoints);
383
412
  this._commandManager = new CommandManager(options.commandTimeout);
@@ -486,15 +515,20 @@ class DeviceGroup extends EventEmitter {
486
515
  else {
487
516
  this._roomContext.subscribe(TOPIC_CMD_ACK);
488
517
  }
489
- this._roomContext.on(TOPIC_TELEMETRY, (data) => {
518
+ // Listeners: refs stored for handler-specific removal (the client may be
519
+ // shared with other consumers on the same topic).
520
+ this._onTelemetryRef = (data) => {
490
521
  this._handleIncomingTelemetry(data);
491
- });
492
- this._roomContext.on(TOPIC_COMMANDS, (data) => {
522
+ };
523
+ this._roomContext.on(TOPIC_TELEMETRY, this._onTelemetryRef);
524
+ this._onCommandsRef = (data) => {
493
525
  this._handleIncomingCommand(data);
494
- });
495
- this._roomContext.on(TOPIC_CMD_ACK, (data) => {
526
+ };
527
+ this._roomContext.on(TOPIC_COMMANDS, this._onCommandsRef);
528
+ this._onCmdAckRef = (data) => {
496
529
  this._handleIncomingCmdAck(data);
497
- });
530
+ };
531
+ this._roomContext.on(TOPIC_CMD_ACK, this._onCmdAckRef);
498
532
  }
499
533
  /** @internal Set presence and fetch existing group members */
500
534
  _activate() {
@@ -541,12 +575,25 @@ class DeviceGroup extends EventEmitter {
541
575
  /** @internal Unsubscribe and clean up */
542
576
  _cleanup() {
543
577
  this._log('Group cleanup:', this.name);
544
- this._roomContext.unsubscribe(TOPIC_TELEMETRY);
545
- this._roomContext.unsubscribe(TOPIC_COMMANDS);
546
- this._roomContext.unsubscribe(TOPIC_CMD_ACK);
547
- this._roomContext.off(TOPIC_TELEMETRY);
548
- this._roomContext.off(TOPIC_COMMANDS);
549
- this._roomContext.off(TOPIC_CMD_ACK);
578
+ // Server unsubscribes need a live socket; skip when disconnected
579
+ // (best-effort — the core would no-op with an error callback anyway).
580
+ if (this._isConnected()) {
581
+ this._roomContext.unsubscribe(TOPIC_TELEMETRY);
582
+ this._roomContext.unsubscribe(TOPIC_COMMANDS);
583
+ this._roomContext.unsubscribe(TOPIC_CMD_ACK);
584
+ }
585
+ // Handler-specific removal only: the client may be shared, and a bare
586
+ // off(topic) would strip other consumers' handlers too.
587
+ if (this._onTelemetryRef)
588
+ this._roomContext.off(TOPIC_TELEMETRY, this._onTelemetryRef);
589
+ if (this._onCommandsRef)
590
+ this._roomContext.off(TOPIC_COMMANDS, this._onCommandsRef);
591
+ if (this._onCmdAckRef)
592
+ this._roomContext.off(TOPIC_CMD_ACK, this._onCmdAckRef);
593
+ this._onTelemetryRef = null;
594
+ this._onCommandsRef = null;
595
+ this._onCmdAckRef = null;
596
+ // Clears all pending command-timeout timers and rejects orphaned commands.
550
597
  this._commandManager.dispose();
551
598
  this._receivedCommands.clear();
552
599
  this._presenceManager.clear();
@@ -584,6 +631,9 @@ class DeviceGroup extends EventEmitter {
584
631
  deviceName: this._localDevice.deviceName,
585
632
  role: this._localDevice.role,
586
633
  metadata: this._localDevice.metadata,
634
+ // Scope tag: on a shared client, other apps' wrappers filter our
635
+ // presence out by this (and we filter theirs).
636
+ __scope: this._options.appName,
587
637
  };
588
638
  this._roomContext.setPresence(presenceData);
589
639
  }
@@ -595,37 +645,72 @@ class DeviceGroup extends EventEmitter {
595
645
  * Provides device presence, real-time telemetry streaming, and command dispatch
596
646
  * with ack tracking — all framework-agnostic via events.
597
647
  *
648
+ * The wrapper NEVER manages the connection. The app owns one core NoLag
649
+ * client (shared by any number of wrappers on distinct apps) and the
650
+ * wrapper attaches to it at construction and releases it via `detach()`.
651
+ *
598
652
  * @example
599
653
  * ```typescript
654
+ * import { NoLag } from '@nolag/js-sdk';
600
655
  * import { NoLagIoT } from '@nolag/iot';
601
656
  *
602
- * const iot = new NoLagIoT(token, { deviceId: 'sensor-01', role: 'device', debug: true });
657
+ * const client = NoLag(async () => (await (await fetch('/api/nolag-token')).json()).token);
658
+ * const iot = new NoLagIoT({ client, deviceId: 'sensor-01', role: 'device' });
603
659
  *
604
660
  * iot.on('connected', () => console.log('Connected!'));
605
- * await iot.connect();
661
+ *
662
+ * await client.connect(); // the app owns the connection
663
+ * await iot.ready(); // wrapper setup done (identity, lobby, groups)
606
664
  *
607
665
  * const group = iot.joinGroup('factory-floor');
608
666
  * group.on('command', (cmd) => {
609
- * console.log('Received command:', cmd.command);
610
667
  * group.ackCommand(cmd.id, 'completed', { ok: true });
611
668
  * });
669
+ * group.sendTelemetry('temperature', 22.5, { unit: '°C' });
612
670
  *
613
- * // Send telemetry every second
614
- * setInterval(() => {
615
- * group.sendTelemetry('temperature', 22.5, { unit: '°C' });
616
- * }, 1000);
671
+ * iot.detach(); // wrapper releases its handlers and topics
672
+ * client.disconnect(); // the app closes the socket
617
673
  * ```
618
674
  */
619
675
  class NoLagIoT extends EventEmitter {
620
- constructor(token, options = {}) {
676
+ constructor(options) {
621
677
  super();
622
- this._client = null;
623
678
  this._localDevice = null;
624
679
  this._groups = new Map();
625
680
  this._lobby = null;
626
681
  this._onlineDevices = new Map();
627
682
  this._actorToDeviceId = new Map();
628
- this._token = token;
683
+ // Lifecycle: one setup run per connection epoch; detach is terminal.
684
+ this._epoch = 0;
685
+ this._detached = false;
686
+ this._isReady = false;
687
+ this._lobbyRefreshTimer = null;
688
+ // Stored client handler refs. INVARIANT: every client.on() below has a
689
+ // matching client.off() in detach() — never bare off(event), never inline
690
+ // closures on the client.
691
+ this._onConnectRef = () => this._onConnect();
692
+ this._onDisconnectRef = (reason) => {
693
+ this._log('Disconnected:', reason);
694
+ this.emit('disconnected', reason);
695
+ };
696
+ this._onReconnectRef = () => {
697
+ this._log('Reconnecting...');
698
+ this.emit('reconnecting');
699
+ };
700
+ this._onErrorRef = (error) => {
701
+ this._log('Error:', error);
702
+ this.emit('error', error);
703
+ };
704
+ this._onPresenceJoinRef = (data) => this._handleRoomPresenceJoin(data);
705
+ this._onPresenceLeaveRef = (data) => this._handleRoomPresenceLeave(data);
706
+ this._onPresenceUpdateRef = (data) => this._handleRoomPresenceUpdate(data);
707
+ this._onLobbyJoinRef = (data) => this._handleLobbyJoin(data);
708
+ this._onLobbyLeaveRef = (data) => this._handleLobbyLeave(data);
709
+ this._onLobbyUpdateRef = (data) => this._handleLobbyUpdate(data);
710
+ if (!options?.client) {
711
+ throw new TypeError('NoLagIoT requires an injected NoLag client: new NoLagIoT({ client, deviceId, ... })');
712
+ }
713
+ this._client = options.client;
629
714
  this._deviceId = options.deviceId ?? generateId();
630
715
  this._options = {
631
716
  deviceId: this._deviceId,
@@ -633,21 +718,49 @@ class NoLagIoT extends EventEmitter {
633
718
  role: options.role ?? 'device',
634
719
  metadata: options.metadata,
635
720
  appName: options.appName ?? DEFAULT_APP_NAME,
636
- url: options.url,
637
721
  maxTelemetryPoints: options.maxTelemetryPoints ?? DEFAULT_MAX_TELEMETRY_POINTS,
638
722
  commandTimeout: options.commandTimeout ?? DEFAULT_COMMAND_TIMEOUT,
639
723
  debug: options.debug ?? false,
640
- reconnect: options.reconnect ?? true,
641
724
  groups: options.groups ?? [],
642
725
  };
643
726
  this._log = createLogger('NoLagIoT', this._options.debug);
727
+ this._readyPromise = new Promise((resolve, reject) => {
728
+ this._readyResolve = resolve;
729
+ this._readyReject = reject;
730
+ });
731
+ // ready() rejection is only meaningful to callers that await it
732
+ this._readyPromise.catch(() => { });
733
+ registerWrapper(this._client, this._options.appName, 'NoLagIoT');
734
+ // Construction = attach: wire everything now, with stored refs.
735
+ this._client.on('connect', this._onConnectRef);
736
+ this._client.on('disconnect', this._onDisconnectRef);
737
+ this._client.on('reconnect', this._onReconnectRef);
738
+ this._client.on('error', this._onErrorRef);
739
+ this._client.on('presence:join', this._onPresenceJoinRef);
740
+ this._client.on('presence:leave', this._onPresenceLeaveRef);
741
+ this._client.on('presence:update', this._onPresenceUpdateRef);
742
+ this._client.on('lobbyPresence:join', this._onLobbyJoinRef);
743
+ this._client.on('lobbyPresence:leave', this._onLobbyLeaveRef);
744
+ this._client.on('lobbyPresence:update', this._onLobbyUpdateRef);
745
+ // Attach-to-connected: if the client is already authenticated, run setup.
746
+ // The microtask lets the caller wire wrapper event handlers synchronously
747
+ // first; a racing real 'connect' event wins via the epoch guard.
748
+ queueMicrotask(() => {
749
+ if (this._epoch === 0 && !this._detached && this._client.connected) {
750
+ this._onConnect();
751
+ }
752
+ });
644
753
  }
645
754
  // ============ Public Properties ============
646
- /** Whether the underlying connection is established */
755
+ /** Whether the underlying connection is established (connected ≠ ready) */
647
756
  get connected() {
648
- return this._client?.connected ?? false;
757
+ return !this._detached && this._client.connected;
649
758
  }
650
- /** The local device info (available after connect) */
759
+ /** The injected core client (owned by the app, not the wrapper) */
760
+ get client() {
761
+ return this._client;
762
+ }
763
+ /** The local device info (available after ready) */
651
764
  get localDevice() {
652
765
  return this._localDevice;
653
766
  }
@@ -657,97 +770,162 @@ class NoLagIoT extends EventEmitter {
657
770
  }
658
771
  // ============ Lifecycle ============
659
772
  /**
660
- * Connect to NoLag and set up global presence.
773
+ * Resolves once the wrapper's first setup completed (identity, lobby and
774
+ * configured groups ready — equivalently, once 'connected' has fired).
775
+ * Rejects only if detach() is called before that. Client auth failures
776
+ * surface via the app's own `await client.connect()`, not here.
661
777
  */
662
- async connect() {
663
- this._log('Connecting...');
664
- const clientOptions = {
665
- debug: this._options.debug,
666
- reconnect: this._options.reconnect,
667
- };
668
- if (this._options.url) {
669
- clientOptions.url = this._options.url;
670
- }
671
- this._client = NoLag(this._token, clientOptions);
672
- // Wire client lifecycle events
673
- this._client.on('connect', () => {
674
- this._log('Connected');
675
- if (this._groups.size > 0) {
676
- this._log('Reconnected — restoring groups...');
677
- this._restoreGroups();
678
- this.emit('reconnected');
679
- }
680
- });
681
- this._client.on('disconnect', (reason) => {
682
- this._log('Disconnected:', reason);
683
- this.emit('disconnected', reason);
684
- });
685
- this._client.on('reconnect', () => {
686
- this._log('Reconnecting...');
687
- });
688
- this._client.on('error', (error) => {
689
- this._log('Error:', error);
690
- this.emit('error', error);
691
- });
692
- // Connect
693
- await this._client.connect();
694
- // Wire room-level presence events
695
- this._client.on('presence:join', (data) => {
696
- this._handleRoomPresenceJoin(data);
697
- });
698
- this._client.on('presence:leave', (data) => {
699
- this._handleRoomPresenceLeave(data);
700
- });
701
- this._client.on('presence:update', (data) => {
702
- this._handleRoomPresenceUpdate(data);
703
- });
704
- // Create local device record
705
- this._localDevice = {
706
- deviceId: this._deviceId,
707
- actorTokenId: this._client.actorId,
708
- deviceName: this._options.deviceName,
709
- role: this._options.role,
710
- metadata: this._options.metadata,
711
- joinedAt: Date.now(),
712
- isLocal: true,
713
- };
714
- this._log('Local device:', this._localDevice.deviceId, '→', this._localDevice.actorTokenId);
715
- // Set up lobby for global presence
716
- await this._setupLobby();
717
- // Emit connected now that _localDevice and lobby are ready
718
- this.emit('connected');
719
- // Auto-join configured groups
720
- for (const groupName of this._options.groups) {
721
- this.joinGroup(groupName);
722
- }
723
- // Deferred lobby refetch to catch devices that joined during setup window
724
- setTimeout(() => {
725
- if (this._lobby && this._client?.connected) {
726
- this._lobby.fetchPresence().then((state) => {
727
- this._hydrateOnlineDevices(state);
728
- }).catch(() => { });
729
- }
730
- }, 2000);
778
+ ready() {
779
+ return this._readyPromise;
731
780
  }
732
781
  /**
733
- * Disconnect from NoLag and clean up all groups.
782
+ * Detach from the client: remove every handler this wrapper added,
783
+ * unsubscribe its topics and lobby (when connected), clear state. Also
784
+ * clears any pending command-timeout timers on every group. Terminal and
785
+ * idempotent; never touches the socket. To use IoT again, construct a new
786
+ * instance.
734
787
  */
735
- disconnect() {
736
- this._log('Disconnecting...');
737
- // Clean up groups
788
+ detach() {
789
+ if (this._detached)
790
+ return;
791
+ this._log('Detaching...');
792
+ this._detached = true;
793
+ this._epoch++; // aborts any in-flight setup at its next checkpoint
794
+ if (this._lobbyRefreshTimer) {
795
+ clearTimeout(this._lobbyRefreshTimer);
796
+ this._lobbyRefreshTimer = null;
797
+ }
798
+ // Remove all client handlers by stored ref
799
+ this._client.off('connect', this._onConnectRef);
800
+ this._client.off('disconnect', this._onDisconnectRef);
801
+ this._client.off('reconnect', this._onReconnectRef);
802
+ this._client.off('error', this._onErrorRef);
803
+ this._client.off('presence:join', this._onPresenceJoinRef);
804
+ this._client.off('presence:leave', this._onPresenceLeaveRef);
805
+ this._client.off('presence:update', this._onPresenceUpdateRef);
806
+ this._client.off('lobbyPresence:join', this._onLobbyJoinRef);
807
+ this._client.off('lobbyPresence:leave', this._onLobbyLeaveRef);
808
+ this._client.off('lobbyPresence:update', this._onLobbyUpdateRef);
809
+ // Groups: handler-specific off + connected-gated server unsubscribe.
810
+ // _cleanup() also disposes each group's command-timeout timers.
738
811
  for (const name of [...this._groups.keys()]) {
739
- this.leaveGroup(name);
812
+ this._groups.get(name)._cleanup();
813
+ this._groups.delete(name);
814
+ }
815
+ // Lobby: server unsubscribe is best-effort and needs a live socket
816
+ if (this._lobby && this._client.connected) {
817
+ try {
818
+ this._lobby.unsubscribe();
819
+ }
820
+ catch {
821
+ /* best-effort */
822
+ }
740
823
  }
741
- // Unsubscribe from lobby
742
- this._lobby?.unsubscribe();
743
824
  this._lobby = null;
744
- // Disconnect client
745
- this._client?.disconnect();
746
- this._client = null;
747
- // Clear state
748
825
  this._onlineDevices.clear();
749
826
  this._actorToDeviceId.clear();
750
827
  this._localDevice = null;
828
+ releaseWrapper(this._client, this._options.appName);
829
+ if (!this._isReady) {
830
+ this._readyReject(new Error('NoLagIoT detached before ready'));
831
+ }
832
+ }
833
+ // ============ Private: Epoch Setup ============
834
+ _onConnect() {
835
+ this._epoch++;
836
+ void this._runSetup(this._epoch);
837
+ }
838
+ /**
839
+ * One setup pass per connection epoch. Serves both initial setup (epoch 1)
840
+ * and reconnect restore (epoch > 1). Aborts silently whenever a newer
841
+ * epoch started or the wrapper detached — checked after every await.
842
+ */
843
+ async _runSetup(epoch) {
844
+ const stale = () => epoch !== this._epoch || this._detached;
845
+ this._log(this._isReady ? 'Restoring after reconnect...' : 'Setting up...');
846
+ // Identity (client.actorId is guaranteed post-auth)
847
+ if (!this._localDevice) {
848
+ this._localDevice = {
849
+ deviceId: this._deviceId,
850
+ actorTokenId: this._client.actorId,
851
+ deviceName: this._options.deviceName,
852
+ role: this._options.role,
853
+ metadata: this._options.metadata,
854
+ joinedAt: Date.now(),
855
+ isLocal: true,
856
+ };
857
+ this._log('Local device:', this._localDevice.deviceId, '→', this._localDevice.actorTokenId);
858
+ }
859
+ else {
860
+ this._localDevice.actorTokenId = this._client.actorId;
861
+ }
862
+ // Lobby: subscribe every epoch (idempotent server-side) and diff-hydrate
863
+ // from the returned snapshot — one path for setup and restore.
864
+ if (!this._lobby) {
865
+ this._lobby = this._client.setApp(this._options.appName).setLobby(LOBBY_ID);
866
+ }
867
+ try {
868
+ const state = await this._lobby.subscribe();
869
+ if (stale())
870
+ return;
871
+ this._diffHydrateOnlineDevices(state);
872
+ this._log('Lobby subscribed, online devices:', this._onlineDevices.size);
873
+ }
874
+ catch (err) {
875
+ if (stale())
876
+ return;
877
+ this._log('Lobby subscription failed:', err);
878
+ }
879
+ if (!this._isReady) {
880
+ // First successful setup: pre-join configured groups.
881
+ for (const groupName of this._options.groups) {
882
+ const group = this._subscribeGroup(groupName);
883
+ group._activate();
884
+ }
885
+ }
886
+ else {
887
+ // Server auto-restored topic subscriptions; only room-scoped presence
888
+ // needs re-applying (the core does not restore it) — persistent-presence
889
+ // semantics across reconnects.
890
+ for (const group of this._groups.values()) {
891
+ group._updateLocalPresence();
892
+ }
893
+ }
894
+ if (stale())
895
+ return;
896
+ // Ready keys on the first setup that COMPLETES, not on epoch 1: an
897
+ // epoch aborted by a racing reconnect must not strand ready().
898
+ if (!this._isReady) {
899
+ this._isReady = true;
900
+ this._readyResolve();
901
+ this.emit('connected');
902
+ }
903
+ else {
904
+ this.emit('reconnected');
905
+ }
906
+ // Deferred lobby refetch: catches devices who joined during the setup
907
+ // window (e.g. simultaneous multi-tab connects).
908
+ this._scheduleLobbyRefresh(epoch);
909
+ }
910
+ _scheduleLobbyRefresh(epoch) {
911
+ if (this._lobbyRefreshTimer)
912
+ clearTimeout(this._lobbyRefreshTimer);
913
+ this._lobbyRefreshTimer = setTimeout(() => {
914
+ this._lobbyRefreshTimer = null;
915
+ if (epoch !== this._epoch || this._detached || !this._client.connected || !this._lobby) {
916
+ return;
917
+ }
918
+ this._lobby
919
+ .fetchPresence()
920
+ .then((state) => {
921
+ if (epoch !== this._epoch || this._detached)
922
+ return;
923
+ this._diffHydrateOnlineDevices(state);
924
+ })
925
+ .catch(() => {
926
+ /* best-effort */
927
+ });
928
+ }, LOBBY_REFRESH_DELAY_MS);
751
929
  }
752
930
  // ============ Group Management ============
753
931
  /**
@@ -755,9 +933,7 @@ class NoLagIoT extends EventEmitter {
755
933
  * Returns an existing group if already joined.
756
934
  */
757
935
  joinGroup(name) {
758
- if (!this._client || !this._localDevice) {
759
- throw new Error('Not connected — call connect() first');
760
- }
936
+ this._assertUsable();
761
937
  let group = this._groups.get(name);
762
938
  if (!group) {
763
939
  group = this._subscribeGroup(name);
@@ -789,24 +965,41 @@ class NoLagIoT extends EventEmitter {
789
965
  getOnlineDevices() {
790
966
  return Array.from(this._onlineDevices.values());
791
967
  }
968
+ // ============ Private: Guards ============
969
+ _assertUsable() {
970
+ if (this._detached) {
971
+ throw new Error('NoLagIoT has been detached — construct a new instance');
972
+ }
973
+ if (!this._isReady || !this._localDevice) {
974
+ throw new Error('NoLagIoT not ready — await ready() or the "connected" event');
975
+ }
976
+ }
792
977
  // ============ Private: Group Setup ============
793
978
  _subscribeGroup(name) {
794
- if (!this._client || !this._localDevice) {
795
- throw new Error('Not connected — call connect() first');
796
- }
797
979
  this._log('Subscribing group:', name);
798
980
  const roomContext = this._client.setApp(this._options.appName).setRoom(name);
799
- const group = new DeviceGroup(name, roomContext, this._localDevice, this._options, createLogger(`DeviceGroup:${name}`, this._options.debug));
981
+ const group = new DeviceGroup(name, roomContext, this._localDevice, this._options, createLogger(`DeviceGroup:${name}`, this._options.debug), () => this._client.connected);
800
982
  this._groups.set(name, group);
801
983
  group._subscribe();
802
984
  return group;
803
985
  }
986
+ // ============ Private: Scope Filtering ============
987
+ /**
988
+ * On a shared client, presence events from other apps' wrappers arrive on
989
+ * the same connection-level events. Wrappers stamp their presence with a
990
+ * `__scope` (their appName); a mismatched tag means another app's data.
991
+ * Untagged presence is accepted (older peers in this same app).
992
+ */
993
+ _foreignScope(data) {
994
+ const scope = data?.__scope;
995
+ return typeof scope === 'string' && scope !== this._options.appName;
996
+ }
804
997
  // ============ Private: Room Presence ============
805
998
  _handleRoomPresenceJoin(data) {
806
999
  if (data.actorTokenId === this._localDevice?.actorTokenId)
807
1000
  return;
808
1001
  const presenceData = data.presence;
809
- if (!presenceData?.deviceId)
1002
+ if (!presenceData?.deviceId || this._foreignScope(presenceData))
810
1003
  return;
811
1004
  const device = this._presenceToDevice(data.actorTokenId, presenceData);
812
1005
  this._actorToDeviceId.set(data.actorTokenId, device.deviceId);
@@ -831,7 +1024,7 @@ class NoLagIoT extends EventEmitter {
831
1024
  if (data.actorTokenId === this._localDevice?.actorTokenId)
832
1025
  return;
833
1026
  const presenceData = data.presence;
834
- if (!presenceData?.deviceId)
1027
+ if (!presenceData?.deviceId || this._foreignScope(presenceData))
835
1028
  return;
836
1029
  if (this._onlineDevices.has(presenceData.deviceId)) {
837
1030
  const device = this._presenceToDevice(data.actorTokenId, presenceData);
@@ -843,37 +1036,12 @@ class NoLagIoT extends EventEmitter {
843
1036
  }
844
1037
  }
845
1038
  // ============ Private: Lobby ============
846
- async _setupLobby() {
847
- if (!this._client)
848
- return;
849
- this._lobby = this._client.setApp(this._options.appName).setLobby(LOBBY_ID);
850
- const lobbyHandler = (type) => (data) => {
851
- const event = data;
852
- if (type === 'join')
853
- this._handleLobbyJoin(event);
854
- else if (type === 'leave')
855
- this._handleLobbyLeave(event);
856
- else
857
- this._handleLobbyUpdate(event);
858
- };
859
- this._client.on('lobbyPresence:join', lobbyHandler('join'));
860
- this._client.on('lobbyPresence:leave', lobbyHandler('leave'));
861
- this._client.on('lobbyPresence:update', lobbyHandler('update'));
862
- try {
863
- const initialState = await this._lobby.subscribe();
864
- this._hydrateOnlineDevices(initialState);
865
- this._log('Lobby subscribed, online devices:', this._onlineDevices.size);
866
- }
867
- catch (err) {
868
- this._log('Lobby subscription failed:', err);
869
- }
870
- }
871
1039
  _handleLobbyJoin(event) {
872
1040
  const { actorId, data } = event;
873
1041
  if (actorId === this._localDevice?.actorTokenId)
874
1042
  return;
875
1043
  const presenceData = data;
876
- if (!presenceData.deviceId)
1044
+ if (!presenceData?.deviceId || this._foreignScope(presenceData))
877
1045
  return;
878
1046
  const device = this._presenceToDevice(actorId, presenceData);
879
1047
  this._actorToDeviceId.set(actorId, device.deviceId);
@@ -887,6 +1055,8 @@ class NoLagIoT extends EventEmitter {
887
1055
  if (actorId === this._localDevice?.actorTokenId)
888
1056
  return;
889
1057
  const presenceData = data;
1058
+ if (this._foreignScope(presenceData))
1059
+ return;
890
1060
  const deviceId = presenceData?.deviceId
891
1061
  || this._actorToDeviceId.get(actorId)
892
1062
  || this._findDeviceIdByActorId(actorId);
@@ -904,29 +1074,57 @@ class NoLagIoT extends EventEmitter {
904
1074
  if (actorId === this._localDevice?.actorTokenId)
905
1075
  return;
906
1076
  const presenceData = data;
907
- if (!presenceData.deviceId)
1077
+ if (!presenceData?.deviceId || this._foreignScope(presenceData))
908
1078
  return;
909
1079
  const device = this._presenceToDevice(actorId, presenceData);
910
1080
  this._onlineDevices.set(device.deviceId, device);
911
1081
  }
912
- _hydrateOnlineDevices(state) {
1082
+ /**
1083
+ * Reconcile the online-device map against a fresh lobby snapshot, emitting
1084
+ * only the deltas (deviceOffline for vanished, deviceOnline for new). One
1085
+ * path for initial hydration, reconnect restore, and the deferred refetch.
1086
+ */
1087
+ _diffHydrateOnlineDevices(state) {
1088
+ // Build the fresh device set from the snapshot
1089
+ const fresh = new Map();
1090
+ const freshActors = new Map();
913
1091
  for (const roomId of Object.keys(state)) {
914
1092
  const roomPresence = state[roomId];
915
1093
  for (const actorId of Object.keys(roomPresence)) {
916
1094
  if (actorId === this._localDevice?.actorTokenId)
917
1095
  continue;
918
1096
  const raw = roomPresence[actorId];
1097
+ // Server returns full actor records with presence nested under .presence
919
1098
  const presenceData = (raw?.presence ?? raw);
920
- if (presenceData?.deviceId) {
921
- const device = this._presenceToDevice(actorId, presenceData);
922
- this._actorToDeviceId.set(actorId, device.deviceId);
923
- if (!this._onlineDevices.has(device.deviceId)) {
924
- this._onlineDevices.set(device.deviceId, device);
925
- this.emit('deviceOnline', device);
1099
+ if (presenceData?.deviceId && !this._foreignScope(presenceData)) {
1100
+ if (!fresh.has(presenceData.deviceId)) {
1101
+ fresh.set(presenceData.deviceId, this._presenceToDevice(actorId, presenceData));
926
1102
  }
1103
+ freshActors.set(actorId, presenceData.deviceId);
927
1104
  }
928
1105
  }
929
1106
  }
1107
+ // Vanished devices
1108
+ for (const [deviceId, device] of [...this._onlineDevices]) {
1109
+ if (!fresh.has(deviceId)) {
1110
+ this._onlineDevices.delete(deviceId);
1111
+ for (const [actorId, mappedDeviceId] of [...this._actorToDeviceId]) {
1112
+ if (mappedDeviceId === deviceId)
1113
+ this._actorToDeviceId.delete(actorId);
1114
+ }
1115
+ this.emit('deviceOffline', device);
1116
+ }
1117
+ }
1118
+ // New devices
1119
+ for (const [deviceId, device] of fresh) {
1120
+ if (!this._onlineDevices.has(deviceId)) {
1121
+ this._onlineDevices.set(deviceId, device);
1122
+ this.emit('deviceOnline', device);
1123
+ }
1124
+ }
1125
+ for (const [actorId, deviceId] of freshActors) {
1126
+ this._actorToDeviceId.set(actorId, deviceId);
1127
+ }
930
1128
  }
931
1129
  // ============ Private: Helpers ============
932
1130
  _presenceToDevice(actorTokenId, data) {
@@ -947,21 +1145,6 @@ class NoLagIoT extends EventEmitter {
947
1145
  }
948
1146
  return undefined;
949
1147
  }
950
- _restoreGroups() {
951
- // On reconnect, js-sdk auto-restores subscriptions.
952
- // Re-set presence on all active groups.
953
- for (const group of this._groups.values()) {
954
- group._updateLocalPresence();
955
- }
956
- // Re-fetch lobby presence
957
- this._lobby?.fetchPresence().then((state) => {
958
- this._onlineDevices.clear();
959
- this._actorToDeviceId.clear();
960
- this._hydrateOnlineDevices(state);
961
- }).catch((err) => {
962
- this._log('Failed to re-fetch lobby presence:', err);
963
- });
964
- }
965
1148
  }
966
1149
 
967
1150
  export { CommandManager, DeviceGroup, EventEmitter, NoLagIoT, PresenceManager, TelemetryStore };