@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/README.md +60 -27
- package/dist/DeviceGroup.d.ts +5 -1
- package/dist/NoLagIoT.d.ts +70 -19
- package/dist/browser.js +1 -1
- package/dist/browser.js.map +1 -1
- package/dist/constants.d.ts +2 -0
- package/dist/index.cjs +352 -169
- package/dist/index.cjs.map +1 -1
- package/dist/index.mjs +352 -169
- package/dist/index.mjs.map +1 -1
- package/dist/types.d.ts +9 -6
- package/dist/utils.d.ts +4 -0
- package/package.json +3 -3
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
|
-
|
|
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(
|
|
522
|
+
};
|
|
523
|
+
this._roomContext.on(TOPIC_TELEMETRY, this._onTelemetryRef);
|
|
524
|
+
this._onCommandsRef = (data) => {
|
|
493
525
|
this._handleIncomingCommand(data);
|
|
494
|
-
}
|
|
495
|
-
this._roomContext.on(
|
|
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
|
-
|
|
545
|
-
|
|
546
|
-
this.
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
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
|
|
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
|
-
*
|
|
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
|
-
* //
|
|
614
|
-
*
|
|
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(
|
|
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
|
-
|
|
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
|
|
757
|
+
return !this._detached && this._client.connected;
|
|
649
758
|
}
|
|
650
|
-
/** The
|
|
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
|
-
*
|
|
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
|
-
|
|
663
|
-
this.
|
|
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
|
-
*
|
|
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
|
-
|
|
736
|
-
this.
|
|
737
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
922
|
-
|
|
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 };
|