@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.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
|
-
|
|
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(
|
|
524
|
+
};
|
|
525
|
+
this._roomContext.on(TOPIC_TELEMETRY, this._onTelemetryRef);
|
|
526
|
+
this._onCommandsRef = (data) => {
|
|
495
527
|
this._handleIncomingCommand(data);
|
|
496
|
-
}
|
|
497
|
-
this._roomContext.on(
|
|
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
|
-
|
|
547
|
-
|
|
548
|
-
this.
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
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
|
|
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
|
-
*
|
|
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
|
-
* //
|
|
616
|
-
*
|
|
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(
|
|
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
|
-
|
|
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
|
|
759
|
+
return !this._detached && this._client.connected;
|
|
651
760
|
}
|
|
652
|
-
/** The
|
|
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
|
-
*
|
|
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
|
-
|
|
665
|
-
this.
|
|
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
|
-
*
|
|
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
|
-
|
|
738
|
-
this.
|
|
739
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
924
|
-
|
|
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;
|