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