@nolag/chat 0.2.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -1,5 +1,3 @@
1
- import { NoLag } from '@nolag/js-sdk';
2
-
3
1
  /**
4
2
  * Tiny typed event emitter — framework-agnostic base for NoLagChat and ChatRoom.
5
3
  *
@@ -349,6 +347,29 @@ function createLogger(prefix, enabled) {
349
347
  console.log(`[${prefix}]`, ...args);
350
348
  };
351
349
  }
350
+ // ============ Wrapper registry ============
351
+ // One wrapper instance per (client, appName): two wrappers sharing an app on
352
+ // one connection would collide on topics, presence and the online lobby.
353
+ // Warn (not throw): HMR and tests legitimately construct before disposing.
354
+ const wrapperRegistry = new WeakMap();
355
+ /** Register a wrapper against a client + appName; warns on collision. */
356
+ function registerWrapper(client, appName, wrapperName) {
357
+ let apps = wrapperRegistry.get(client);
358
+ if (!apps) {
359
+ apps = new Map();
360
+ wrapperRegistry.set(client, apps);
361
+ }
362
+ const existing = apps.get(appName);
363
+ if (existing) {
364
+ console.warn(`[${wrapperName}] Another wrapper (${existing}) is already attached to this client for app "${appName}". ` +
365
+ `Use one wrapper per (client, app) — detach the other instance first.`);
366
+ }
367
+ apps.set(appName, wrapperName);
368
+ }
369
+ /** Release a wrapper's (client, appName) registration on detach. */
370
+ function releaseWrapper(client, appName) {
371
+ wrapperRegistry.get(client)?.delete(appName);
372
+ }
352
373
 
353
374
  /** Default app name for room topic prefixes */
354
375
  const DEFAULT_APP_NAME = 'chat';
@@ -366,6 +387,8 @@ const TOPIC_STREAM = '_stream';
366
387
  const DEFAULT_STREAM_FLUSH_MS = 60;
367
388
  /** Lobby ID for global online presence */
368
389
  const LOBBY_ID = 'online';
390
+ /** Delay before the post-setup lobby presence refetch (catches simultaneous joiners) */
391
+ const LOBBY_REFRESH_DELAY_MS = 2000;
369
392
 
370
393
  /**
371
394
  * Producer-side controller for an outgoing streamed message.
@@ -450,15 +473,21 @@ class MessageStreamController {
450
473
  */
451
474
  class ChatRoom extends EventEmitter {
452
475
  /** @internal */
453
- constructor(name, roomContext, localUser, options, log) {
476
+ constructor(name, roomContext, localUser, options, log, isConnected) {
454
477
  super();
455
478
  this._unreadCount = 0;
456
479
  this._active = false;
480
+ // Stored topic handler refs — cleanup removes exactly these, never all
481
+ // handlers for a topic (the client may be shared with other consumers).
482
+ this._onMessagesRef = null;
483
+ this._onTypingRef = null;
484
+ this._onStreamRef = null;
457
485
  this.name = name;
458
486
  this._roomContext = roomContext;
459
487
  this._localUser = localUser;
460
488
  this._options = options;
461
489
  this._log = log;
490
+ this._isConnected = isConnected;
462
491
  this._presenceManager = new PresenceManager(localUser.actorTokenId);
463
492
  this._typingManager = new TypingManager(options.typingTimeout);
464
493
  this._messageStore = new MessageStore(options.maxMessageCache);
@@ -649,21 +678,24 @@ class ChatRoom extends EventEmitter {
649
678
  this._roomContext.subscribe(TOPIC_MESSAGES);
650
679
  this._roomContext.subscribe(TOPIC_TYPING);
651
680
  this._roomContext.subscribe(TOPIC_STREAM);
652
- // Listen for messages
653
- this._roomContext.on(TOPIC_MESSAGES, (data, meta) => {
681
+ // Listen for messages (refs stored for handler-specific removal)
682
+ this._onMessagesRef = (data, meta) => {
654
683
  this._handleIncomingMessage(data, meta);
655
- });
684
+ };
685
+ this._roomContext.on(TOPIC_MESSAGES, this._onMessagesRef);
656
686
  // Listen for typing
657
- this._roomContext.on(TOPIC_TYPING, (data) => {
687
+ this._onTypingRef = (data) => {
658
688
  const { userId, typing } = data;
659
689
  if (userId !== this._localUser.userId) {
660
690
  this._typingManager.handleRemote(userId, typing);
661
691
  }
662
- });
692
+ };
693
+ this._roomContext.on(TOPIC_TYPING, this._onTypingRef);
663
694
  // Listen for live streamed messages (start / delta / abort)
664
- this._roomContext.on(TOPIC_STREAM, (data) => {
695
+ this._onStreamRef = (data) => {
665
696
  this._handleStreamEvent(data);
666
- });
697
+ };
698
+ this._roomContext.on(TOPIC_STREAM, this._onStreamRef);
667
699
  }
668
700
  /** @internal Set presence and fetch room members (active room only) */
669
701
  _activate() {
@@ -730,12 +762,24 @@ class ChatRoom extends EventEmitter {
730
762
  /** @internal Unsubscribe and clean up */
731
763
  _cleanup() {
732
764
  this._log('Room cleanup:', this.name);
733
- this._roomContext.unsubscribe(TOPIC_MESSAGES);
734
- this._roomContext.unsubscribe(TOPIC_TYPING);
735
- this._roomContext.unsubscribe(TOPIC_STREAM);
736
- this._roomContext.off(TOPIC_MESSAGES);
737
- this._roomContext.off(TOPIC_TYPING);
738
- this._roomContext.off(TOPIC_STREAM);
765
+ // Server unsubscribes need a live socket; skip when disconnected
766
+ // (best-effort — the core would no-op with an error callback anyway).
767
+ if (this._isConnected()) {
768
+ this._roomContext.unsubscribe(TOPIC_MESSAGES);
769
+ this._roomContext.unsubscribe(TOPIC_TYPING);
770
+ this._roomContext.unsubscribe(TOPIC_STREAM);
771
+ }
772
+ // Handler-specific removal only: the client may be shared, and a bare
773
+ // off(topic) would strip other consumers' handlers too.
774
+ if (this._onMessagesRef)
775
+ this._roomContext.off(TOPIC_MESSAGES, this._onMessagesRef);
776
+ if (this._onTypingRef)
777
+ this._roomContext.off(TOPIC_TYPING, this._onTypingRef);
778
+ if (this._onStreamRef)
779
+ this._roomContext.off(TOPIC_STREAM, this._onStreamRef);
780
+ this._onMessagesRef = null;
781
+ this._onTypingRef = null;
782
+ this._onStreamRef = null;
739
783
  this._typingManager.dispose();
740
784
  this._messageStore.clear();
741
785
  this._presenceManager.clear();
@@ -837,6 +881,9 @@ class ChatRoom extends EventEmitter {
837
881
  avatar: this._localUser.avatar,
838
882
  status: this._localUser.status,
839
883
  metadata: this._localUser.metadata,
884
+ // Scope tag: on a shared client, other apps' wrappers filter our
885
+ // presence out by this (and we filter theirs).
886
+ __scope: this._options.appName,
840
887
  };
841
888
  this._roomContext.setPresence(presenceData);
842
889
  }
@@ -848,54 +895,134 @@ class ChatRoom extends EventEmitter {
848
895
  * Provides multi-room chat, presence (who's online), typing indicators,
849
896
  * message replay, and user mapping — all framework-agnostic via events.
850
897
  *
898
+ * The wrapper NEVER manages the connection. The app owns one core NoLag
899
+ * client (shared by any number of wrappers on distinct apps) and the
900
+ * wrapper attaches to it at construction and releases it via `detach()`.
901
+ *
851
902
  * @example
852
903
  * ```typescript
904
+ * import { NoLag } from '@nolag/js-sdk';
853
905
  * import { NoLagChat } from '@nolag/chat';
854
906
  *
855
- * const chat = new NoLagChat(token, { username: 'Alice' });
907
+ * const client = NoLag(async () => (await (await fetch('/api/nolag-token')).json()).token);
908
+ * const chat = new NoLagChat({ client, appName: 'my-chat', username: 'Alice' });
856
909
  *
857
- * chat.on('connected', () => console.log('Connected!'));
858
910
  * chat.on('userOnline', (user) => console.log(user.username, 'is online'));
859
911
  *
860
- * await chat.connect();
912
+ * await client.connect(); // the app owns the connection
913
+ * await chat.ready(); // wrapper setup done (identity, lobby, rooms)
861
914
  *
862
915
  * const room = chat.joinRoom('general');
863
916
  * room.on('message', (msg) => console.log(msg.username + ':', msg.text));
864
917
  * room.sendMessage('Hello!');
918
+ *
919
+ * chat.detach(); // wrapper releases its handlers and topics
920
+ * client.disconnect(); // the app closes the socket
865
921
  * ```
866
922
  */
867
923
  class NoLagChat extends EventEmitter {
868
- constructor(token, options) {
924
+ constructor(options) {
869
925
  super();
870
- this._client = null;
871
926
  this._localUser = null;
872
927
  this._rooms = new Map();
873
928
  this._lobby = null;
874
929
  this._onlineUsers = new Map();
875
930
  this._actorToUserId = new Map();
876
931
  this._activeRoom = null;
877
- this._token = token;
932
+ // Lifecycle: one setup run per connection epoch; detach is terminal.
933
+ this._epoch = 0;
934
+ this._detached = false;
935
+ this._isReady = false;
936
+ this._lobbyRefreshTimer = null;
937
+ // Stored client handler refs. INVARIANT: every client.on() below has a
938
+ // matching client.off() in detach() — never bare off(event), never inline
939
+ // closures on the client.
940
+ this._onConnectRef = () => this._onConnect();
941
+ this._onDisconnectRef = (reason) => {
942
+ this._log('Disconnected:', reason);
943
+ this.emit('disconnected', reason);
944
+ };
945
+ this._onReconnectRef = () => {
946
+ this._log('Reconnecting...');
947
+ this.emit('reconnecting');
948
+ };
949
+ this._onErrorRef = (error) => {
950
+ this._log('Error:', error);
951
+ this.emit('error', error);
952
+ };
953
+ this._onReplayStartRef = (data) => {
954
+ const event = data;
955
+ for (const room of this._rooms.values()) {
956
+ room._handleReplayStart(event.count);
957
+ }
958
+ };
959
+ this._onReplayEndRef = (data) => {
960
+ const event = data;
961
+ for (const room of this._rooms.values()) {
962
+ room._handleReplayEnd(event.replayed);
963
+ }
964
+ };
965
+ this._onPresenceJoinRef = (data) => this._handleRoomPresenceJoin(data);
966
+ this._onPresenceLeaveRef = (data) => this._handleRoomPresenceLeave(data);
967
+ this._onPresenceUpdateRef = (data) => this._handleRoomPresenceUpdate(data);
968
+ this._onLobbyJoinRef = (data) => this._handleLobbyJoin(data);
969
+ this._onLobbyLeaveRef = (data) => this._handleLobbyLeave(data);
970
+ this._onLobbyUpdateRef = (data) => this._handleLobbyUpdate(data);
971
+ if (!options?.client) {
972
+ throw new TypeError('NoLagChat requires an injected NoLag client: new NoLagChat({ client, username, ... })');
973
+ }
974
+ this._client = options.client;
878
975
  this._userId = generateId();
879
976
  this._options = {
880
977
  username: options.username,
881
978
  avatar: options.avatar,
882
979
  metadata: options.metadata,
883
980
  appName: options.appName ?? DEFAULT_APP_NAME,
884
- url: options.url,
885
981
  typingTimeout: options.typingTimeout ?? DEFAULT_TYPING_TIMEOUT,
886
982
  maxMessageCache: options.maxMessageCache ?? DEFAULT_MAX_MESSAGE_CACHE,
887
983
  debug: options.debug ?? false,
888
- reconnect: options.reconnect ?? true,
889
984
  rooms: options.rooms ?? [],
890
985
  };
891
986
  this._log = createLogger('NoLagChat', this._options.debug);
987
+ this._readyPromise = new Promise((resolve, reject) => {
988
+ this._readyResolve = resolve;
989
+ this._readyReject = reject;
990
+ });
991
+ // ready() rejection is only meaningful to callers that await it
992
+ this._readyPromise.catch(() => { });
993
+ registerWrapper(this._client, this._options.appName, 'NoLagChat');
994
+ // Construction = attach: wire everything now, with stored refs.
995
+ this._client.on('connect', this._onConnectRef);
996
+ this._client.on('disconnect', this._onDisconnectRef);
997
+ this._client.on('reconnect', this._onReconnectRef);
998
+ this._client.on('error', this._onErrorRef);
999
+ this._client.on('replay:start', this._onReplayStartRef);
1000
+ this._client.on('replay:end', this._onReplayEndRef);
1001
+ this._client.on('presence:join', this._onPresenceJoinRef);
1002
+ this._client.on('presence:leave', this._onPresenceLeaveRef);
1003
+ this._client.on('presence:update', this._onPresenceUpdateRef);
1004
+ this._client.on('lobbyPresence:join', this._onLobbyJoinRef);
1005
+ this._client.on('lobbyPresence:leave', this._onLobbyLeaveRef);
1006
+ this._client.on('lobbyPresence:update', this._onLobbyUpdateRef);
1007
+ // Attach-to-connected: if the client is already authenticated, run setup.
1008
+ // The microtask lets the caller wire wrapper event handlers synchronously
1009
+ // first; a racing real 'connect' event wins via the epoch guard.
1010
+ queueMicrotask(() => {
1011
+ if (this._epoch === 0 && !this._detached && this._client.connected) {
1012
+ this._onConnect();
1013
+ }
1014
+ });
892
1015
  }
893
1016
  // ============ Public Properties ============
894
- /** Whether the underlying connection is established */
1017
+ /** Whether the underlying connection is established (connected ≠ ready) */
895
1018
  get connected() {
896
- return this._client?.connected ?? false;
1019
+ return !this._detached && this._client.connected;
897
1020
  }
898
- /** The local user's info (available after connect) */
1021
+ /** The injected core client (owned by the app, not the wrapper) */
1022
+ get client() {
1023
+ return this._client;
1024
+ }
1025
+ /** The local user's info (available after ready) */
899
1026
  get localUser() {
900
1027
  return this._localUser;
901
1028
  }
@@ -905,121 +1032,161 @@ class NoLagChat extends EventEmitter {
905
1032
  }
906
1033
  // ============ Lifecycle ============
907
1034
  /**
908
- * Connect to NoLag and set up global presence.
1035
+ * Resolves once the wrapper's first setup completed (identity, lobby and
1036
+ * configured rooms ready — equivalently, once 'connected' has fired).
1037
+ * Rejects only if detach() is called before that. Client auth failures
1038
+ * surface via the app's own `await client.connect()`, not here.
909
1039
  */
910
- async connect() {
911
- this._log('Connecting...');
912
- const clientOptions = {
913
- debug: this._options.debug,
914
- reconnect: this._options.reconnect,
915
- };
916
- if (this._options.url) {
917
- clientOptions.url = this._options.url;
918
- }
919
- this._client = NoLag(this._token, clientOptions);
920
- // Wire client lifecycle events
921
- // Note: we emit 'connected' after _localUser and lobby are ready (below),
922
- // not here, so that joinRoom() works inside the connected handler.
923
- this._client.on('connect', () => {
924
- this._log('Connected');
925
- // On reconnect, the SDK fires 'connect' after the connection is
926
- // re-established. Restore rooms here (not in 'reconnect') so that
927
- // presence updates and lobby fetches go over a live socket.
928
- if (this._rooms.size > 0) {
929
- this._log('Reconnected — restoring rooms...');
930
- this._restoreRooms();
931
- this.emit('reconnected');
932
- }
933
- });
934
- this._client.on('disconnect', (reason) => {
935
- this._log('Disconnected:', reason);
936
- this.emit('disconnected', reason);
937
- });
938
- this._client.on('reconnect', () => {
939
- this._log('Reconnecting...');
940
- });
941
- this._client.on('error', (error) => {
942
- this._log('Error:', error);
943
- this.emit('error', error);
944
- });
945
- // Wire replay events
946
- this._client.on('replay:start', (data) => {
947
- const event = data;
948
- for (const room of this._rooms.values()) {
949
- room._handleReplayStart(event.count);
950
- }
951
- });
952
- this._client.on('replay:end', (data) => {
953
- const event = data;
954
- for (const room of this._rooms.values()) {
955
- room._handleReplayEnd(event.replayed);
956
- }
957
- });
958
- // Connect
959
- await this._client.connect();
960
- // Wire room-level presence events (these arrive as client-level events)
961
- this._client.on('presence:join', (data) => {
962
- this._handleRoomPresenceJoin(data);
963
- });
964
- this._client.on('presence:leave', (data) => {
965
- this._handleRoomPresenceLeave(data);
966
- });
967
- this._client.on('presence:update', (data) => {
968
- this._handleRoomPresenceUpdate(data);
969
- });
970
- // Create local user
971
- this._localUser = {
972
- userId: this._userId,
973
- actorTokenId: this._client.actorId,
974
- username: this._options.username,
975
- avatar: this._options.avatar,
976
- metadata: this._options.metadata,
977
- status: 'online',
978
- joinedAt: Date.now(),
979
- isLocal: true,
980
- };
981
- this._log('Local user:', this._localUser.userId, '→', this._localUser.actorTokenId);
982
- // Set up lobby for global presence
983
- await this._setupLobby();
984
- // Pre-subscribe to all configured rooms (messages only, no presence)
985
- for (const roomName of this._options.rooms) {
986
- this._subscribeRoom(roomName);
987
- }
988
- // Emit connected now that _localUser and lobby are ready,
989
- // so handlers can safely call joinRoom().
990
- this.emit('connected');
991
- // Deferred lobby refetch: the initial lobby snapshot is taken before
992
- // rooms are joined and presence is set. When multiple tabs connect
993
- // simultaneously, one tab may get its snapshot before the other has
994
- // set presence — causing a missed user. A short-delay refetch
995
- // catches anyone who joined during the setup window.
996
- setTimeout(() => {
997
- if (this._lobby && this._client?.connected) {
998
- this._lobby.fetchPresence().then((state) => {
999
- this._hydrateOnlineUsers(state);
1000
- }).catch(() => { });
1001
- }
1002
- }, 2000);
1040
+ ready() {
1041
+ return this._readyPromise;
1003
1042
  }
1004
1043
  /**
1005
- * Disconnect from NoLag and clean up all rooms.
1044
+ * Detach from the client: remove every handler this wrapper added,
1045
+ * unsubscribe its topics and lobby (when connected), clear state.
1046
+ * Terminal and idempotent; never touches the socket. To use chat again,
1047
+ * construct a new instance.
1006
1048
  */
1007
- disconnect() {
1008
- this._log('Disconnecting...');
1009
- // Clean up rooms
1049
+ detach() {
1050
+ if (this._detached)
1051
+ return;
1052
+ this._log('Detaching...');
1053
+ this._detached = true;
1054
+ this._epoch++; // aborts any in-flight setup at its next checkpoint
1055
+ if (this._lobbyRefreshTimer) {
1056
+ clearTimeout(this._lobbyRefreshTimer);
1057
+ this._lobbyRefreshTimer = null;
1058
+ }
1059
+ // Remove all client handlers by stored ref
1060
+ this._client.off('connect', this._onConnectRef);
1061
+ this._client.off('disconnect', this._onDisconnectRef);
1062
+ this._client.off('reconnect', this._onReconnectRef);
1063
+ this._client.off('error', this._onErrorRef);
1064
+ this._client.off('replay:start', this._onReplayStartRef);
1065
+ this._client.off('replay:end', this._onReplayEndRef);
1066
+ this._client.off('presence:join', this._onPresenceJoinRef);
1067
+ this._client.off('presence:leave', this._onPresenceLeaveRef);
1068
+ this._client.off('presence:update', this._onPresenceUpdateRef);
1069
+ this._client.off('lobbyPresence:join', this._onLobbyJoinRef);
1070
+ this._client.off('lobbyPresence:leave', this._onLobbyLeaveRef);
1071
+ this._client.off('lobbyPresence:update', this._onLobbyUpdateRef);
1072
+ // Rooms: handler-specific off + connected-gated server unsubscribe
1010
1073
  for (const name of [...this._rooms.keys()]) {
1011
- this.leaveRoom(name);
1074
+ this._rooms.get(name)._cleanup();
1075
+ this._rooms.delete(name);
1076
+ }
1077
+ this._activeRoom = null;
1078
+ // Lobby: server unsubscribe is best-effort and needs a live socket
1079
+ if (this._lobby && this._client.connected) {
1080
+ try {
1081
+ this._lobby.unsubscribe();
1082
+ }
1083
+ catch {
1084
+ /* best-effort */
1085
+ }
1012
1086
  }
1013
- // Unsubscribe from lobby
1014
- this._lobby?.unsubscribe();
1015
1087
  this._lobby = null;
1016
- // Disconnect client
1017
- this._client?.disconnect();
1018
- this._client = null;
1019
- // Clear state
1020
1088
  this._onlineUsers.clear();
1021
1089
  this._actorToUserId.clear();
1022
1090
  this._localUser = null;
1091
+ releaseWrapper(this._client, this._options.appName);
1092
+ if (!this._isReady) {
1093
+ this._readyReject(new Error('NoLagChat detached before ready'));
1094
+ }
1095
+ }
1096
+ // ============ Private: Epoch Setup ============
1097
+ _onConnect() {
1098
+ this._epoch++;
1099
+ void this._runSetup(this._epoch);
1100
+ }
1101
+ /**
1102
+ * One setup pass per connection epoch. Serves both initial setup (epoch 1)
1103
+ * and reconnect restore (epoch > 1). Aborts silently whenever a newer
1104
+ * epoch started or the wrapper detached — checked after every await.
1105
+ */
1106
+ async _runSetup(epoch) {
1107
+ const stale = () => epoch !== this._epoch || this._detached;
1108
+ this._log(this._isReady ? 'Restoring after reconnect...' : 'Setting up...');
1109
+ // Identity (client.actorId is guaranteed post-auth)
1110
+ if (!this._localUser) {
1111
+ this._localUser = {
1112
+ userId: this._userId,
1113
+ actorTokenId: this._client.actorId,
1114
+ username: this._options.username,
1115
+ avatar: this._options.avatar,
1116
+ metadata: this._options.metadata,
1117
+ status: 'online',
1118
+ joinedAt: Date.now(),
1119
+ isLocal: true,
1120
+ };
1121
+ this._log('Local user:', this._localUser.userId, '→', this._localUser.actorTokenId);
1122
+ }
1123
+ else {
1124
+ this._localUser.actorTokenId = this._client.actorId;
1125
+ }
1126
+ // Lobby: subscribe every epoch (idempotent server-side) and diff-hydrate
1127
+ // from the returned snapshot — one path for setup and restore.
1128
+ if (!this._lobby) {
1129
+ this._lobby = this._client.setApp(this._options.appName).setLobby(LOBBY_ID);
1130
+ }
1131
+ try {
1132
+ const state = await this._lobby.subscribe();
1133
+ if (stale())
1134
+ return;
1135
+ this._diffHydrateOnlineUsers(state);
1136
+ this._log('Lobby subscribed, online users:', this._onlineUsers.size);
1137
+ }
1138
+ catch (err) {
1139
+ if (stale())
1140
+ return;
1141
+ this._log('Lobby subscription failed:', err);
1142
+ }
1143
+ if (!this._isReady) {
1144
+ // First successful setup: pre-subscribe configured rooms
1145
+ // (messages only, no presence)
1146
+ for (const roomName of this._options.rooms) {
1147
+ this._subscribeRoomInternal(roomName);
1148
+ }
1149
+ }
1150
+ else if (this._activeRoom) {
1151
+ // Server auto-restored topic subscriptions; only room-scoped presence
1152
+ // needs re-applying (the core does not restore it).
1153
+ this._rooms.get(this._activeRoom)?._updateLocalPresence();
1154
+ }
1155
+ if (stale())
1156
+ return;
1157
+ // Ready keys on the first setup that COMPLETES, not on epoch 1: an
1158
+ // epoch aborted by a racing reconnect must not strand ready().
1159
+ if (!this._isReady) {
1160
+ this._isReady = true;
1161
+ this._readyResolve();
1162
+ this.emit('connected');
1163
+ }
1164
+ else {
1165
+ this.emit('reconnected');
1166
+ }
1167
+ // Deferred lobby refetch: catches users who joined during the setup
1168
+ // window (e.g. simultaneous multi-tab connects).
1169
+ this._scheduleLobbyRefresh(epoch);
1170
+ }
1171
+ _scheduleLobbyRefresh(epoch) {
1172
+ if (this._lobbyRefreshTimer)
1173
+ clearTimeout(this._lobbyRefreshTimer);
1174
+ this._lobbyRefreshTimer = setTimeout(() => {
1175
+ this._lobbyRefreshTimer = null;
1176
+ if (epoch !== this._epoch || this._detached || !this._client.connected || !this._lobby) {
1177
+ return;
1178
+ }
1179
+ this._lobby
1180
+ .fetchPresence()
1181
+ .then((state) => {
1182
+ if (epoch !== this._epoch || this._detached)
1183
+ return;
1184
+ this._diffHydrateOnlineUsers(state);
1185
+ })
1186
+ .catch(() => {
1187
+ /* best-effort */
1188
+ });
1189
+ }, LOBBY_REFRESH_DELAY_MS);
1023
1190
  }
1024
1191
  // ============ Room Management ============
1025
1192
  /**
@@ -1028,9 +1195,7 @@ class NoLagChat extends EventEmitter {
1028
1195
  * Otherwise creates, subscribes, and activates it.
1029
1196
  */
1030
1197
  joinRoom(name) {
1031
- if (!this._client || !this._localUser) {
1032
- throw new Error('Not connected — call connect() first');
1033
- }
1198
+ this._assertUsable();
1034
1199
  // Deactivate the current active room
1035
1200
  if (this._activeRoom && this._activeRoom !== name) {
1036
1201
  const prev = this._rooms.get(this._activeRoom);
@@ -1040,7 +1205,7 @@ class NoLagChat extends EventEmitter {
1040
1205
  // Get or create the room
1041
1206
  let room = this._rooms.get(name);
1042
1207
  if (!room) {
1043
- room = this._subscribeRoom(name);
1208
+ room = this._subscribeRoomInternal(name);
1044
1209
  }
1045
1210
  this._activeRoom = name;
1046
1211
  room._activate();
@@ -1113,24 +1278,41 @@ class NoLagChat extends EventEmitter {
1113
1278
  activeRoom._updateLocalPresence();
1114
1279
  }
1115
1280
  }
1116
- // ============ Private: Room Setup ============
1117
- _subscribeRoom(name) {
1118
- if (!this._client || !this._localUser) {
1119
- throw new Error('Not connectedcall connect() first');
1281
+ // ============ Private: Guards ============
1282
+ _assertUsable() {
1283
+ if (this._detached) {
1284
+ throw new Error('NoLagChat has been detached construct a new instance');
1285
+ }
1286
+ if (!this._isReady || !this._localUser) {
1287
+ throw new Error('NoLagChat not ready — await ready() or the "connected" event');
1120
1288
  }
1289
+ }
1290
+ // ============ Private: Room Setup ============
1291
+ _subscribeRoomInternal(name) {
1121
1292
  this._log('Subscribing room:', name);
1122
1293
  const roomContext = this._client.setApp(this._options.appName).setRoom(name);
1123
- const room = new ChatRoom(name, roomContext, this._localUser, this._options, createLogger(`ChatRoom:${name}`, this._options.debug));
1294
+ const room = new ChatRoom(name, roomContext, this._localUser, this._options, createLogger(`ChatRoom:${name}`, this._options.debug), () => this._client.connected);
1124
1295
  this._rooms.set(name, room);
1125
1296
  room._subscribe();
1126
1297
  return room;
1127
1298
  }
1299
+ // ============ Private: Scope Filtering ============
1300
+ /**
1301
+ * On a shared client, presence events from other apps' wrappers arrive on
1302
+ * the same connection-level events. Wrappers stamp their presence with a
1303
+ * `__scope` (their appName); a mismatched tag means another app's data.
1304
+ * Untagged presence is accepted (older peers in this same app).
1305
+ */
1306
+ _foreignScope(data) {
1307
+ const scope = data?.__scope;
1308
+ return typeof scope === 'string' && scope !== this._options.appName;
1309
+ }
1128
1310
  // ============ Private: Room Presence → Active Room ============
1129
1311
  _handleRoomPresenceJoin(data) {
1130
1312
  if (data.actorTokenId === this._localUser?.actorTokenId)
1131
1313
  return;
1132
1314
  const presenceData = data.presence;
1133
- if (!presenceData?.userId)
1315
+ if (!presenceData?.userId || this._foreignScope(presenceData))
1134
1316
  return;
1135
1317
  // Track as online user
1136
1318
  const user = this._presenceToUser(data.actorTokenId, presenceData);
@@ -1158,7 +1340,7 @@ class NoLagChat extends EventEmitter {
1158
1340
  if (data.actorTokenId === this._localUser?.actorTokenId)
1159
1341
  return;
1160
1342
  const presenceData = data.presence;
1161
- if (!presenceData?.userId)
1343
+ if (!presenceData?.userId || this._foreignScope(presenceData))
1162
1344
  return;
1163
1345
  // Update online user info if we already track them
1164
1346
  if (this._onlineUsers.has(presenceData.userId)) {
@@ -1172,41 +1354,12 @@ class NoLagChat extends EventEmitter {
1172
1354
  }
1173
1355
  }
1174
1356
  // ============ Private: Lobby ============
1175
- async _setupLobby() {
1176
- if (!this._client)
1177
- return;
1178
- this._lobby = this._client.setApp(this._options.appName).setLobby(LOBBY_ID);
1179
- // Register on the client's generic lobby events (lobbyPresence:join/leave/update)
1180
- // instead of lobby.on(), because the server sends presence events with the
1181
- // server-assigned lobby UUID, while lobby.on() listens on the client-provided
1182
- // lobby name — the keys never match.
1183
- const lobbyHandler = (type) => (data) => {
1184
- const event = data;
1185
- if (type === 'join')
1186
- this._handleLobbyJoin(event);
1187
- else if (type === 'leave')
1188
- this._handleLobbyLeave(event);
1189
- else
1190
- this._handleLobbyUpdate(event);
1191
- };
1192
- this._client.on('lobbyPresence:join', lobbyHandler('join'));
1193
- this._client.on('lobbyPresence:leave', lobbyHandler('leave'));
1194
- this._client.on('lobbyPresence:update', lobbyHandler('update'));
1195
- try {
1196
- const initialState = await this._lobby.subscribe();
1197
- this._hydrateOnlineUsers(initialState);
1198
- this._log('Lobby subscribed, online users:', this._onlineUsers.size);
1199
- }
1200
- catch (err) {
1201
- this._log('Lobby subscription failed:', err);
1202
- }
1203
- }
1204
1357
  _handleLobbyJoin(event) {
1205
1358
  const { actorId, data } = event;
1206
1359
  if (actorId === this._localUser?.actorTokenId)
1207
1360
  return;
1208
1361
  const presenceData = data;
1209
- if (!presenceData.userId)
1362
+ if (!presenceData.userId || this._foreignScope(presenceData))
1210
1363
  return;
1211
1364
  const user = this._presenceToUser(actorId, presenceData);
1212
1365
  this._actorToUserId.set(actorId, user.userId);
@@ -1220,6 +1373,8 @@ class NoLagChat extends EventEmitter {
1220
1373
  if (actorId === this._localUser?.actorTokenId)
1221
1374
  return;
1222
1375
  const presenceData = data;
1376
+ if (this._foreignScope(presenceData))
1377
+ return;
1223
1378
  const userId = presenceData?.userId
1224
1379
  || this._actorToUserId.get(actorId)
1225
1380
  || this._findUserIdByActorId(actorId);
@@ -1237,15 +1392,22 @@ class NoLagChat extends EventEmitter {
1237
1392
  if (actorId === this._localUser?.actorTokenId)
1238
1393
  return;
1239
1394
  const presenceData = data;
1240
- if (!presenceData.userId)
1395
+ if (!presenceData.userId || this._foreignScope(presenceData))
1241
1396
  return;
1242
1397
  const user = this._presenceToUser(actorId, presenceData);
1243
1398
  this._onlineUsers.set(user.userId, user);
1244
1399
  this.emit('userUpdated', user);
1245
1400
  }
1246
- _hydrateOnlineUsers(state) {
1247
- // state = { roomId: { actorId: actorRecord } }
1248
- // actorRecord from the server is { actorTokenId, presence: ChatPresenceData, joinedAt }
1401
+ /**
1402
+ * Reconcile the online-user map against a fresh lobby snapshot, emitting
1403
+ * only the deltas (userOffline for vanished, userOnline for new,
1404
+ * userUpdated for changed). One path for initial hydration, reconnect
1405
+ * restore, and the deferred refetch.
1406
+ */
1407
+ _diffHydrateOnlineUsers(state) {
1408
+ // Build the fresh user set from the snapshot
1409
+ const fresh = new Map();
1410
+ const freshActors = new Map();
1249
1411
  for (const roomId of Object.keys(state)) {
1250
1412
  const roomPresence = state[roomId];
1251
1413
  for (const actorId of Object.keys(roomPresence)) {
@@ -1254,16 +1416,42 @@ class NoLagChat extends EventEmitter {
1254
1416
  const raw = roomPresence[actorId];
1255
1417
  // Server returns full actor records with presence nested under .presence
1256
1418
  const presenceData = (raw?.presence ?? raw);
1257
- if (presenceData?.userId) {
1258
- const user = this._presenceToUser(actorId, presenceData);
1259
- this._actorToUserId.set(actorId, user.userId);
1260
- if (!this._onlineUsers.has(user.userId)) {
1261
- this._onlineUsers.set(user.userId, user);
1262
- this.emit('userOnline', user);
1419
+ if (presenceData?.userId && !this._foreignScope(presenceData)) {
1420
+ if (!fresh.has(presenceData.userId)) {
1421
+ fresh.set(presenceData.userId, this._presenceToUser(actorId, presenceData));
1263
1422
  }
1423
+ freshActors.set(actorId, presenceData.userId);
1264
1424
  }
1265
1425
  }
1266
1426
  }
1427
+ // Vanished users
1428
+ for (const [userId, user] of [...this._onlineUsers]) {
1429
+ if (!fresh.has(userId)) {
1430
+ this._onlineUsers.delete(userId);
1431
+ for (const [actorId, mappedUserId] of [...this._actorToUserId]) {
1432
+ if (mappedUserId === userId)
1433
+ this._actorToUserId.delete(actorId);
1434
+ }
1435
+ this.emit('userOffline', user);
1436
+ }
1437
+ }
1438
+ // New and changed users
1439
+ for (const [userId, user] of fresh) {
1440
+ const prev = this._onlineUsers.get(userId);
1441
+ if (!prev) {
1442
+ this._onlineUsers.set(userId, user);
1443
+ this.emit('userOnline', user);
1444
+ }
1445
+ else if (prev.username !== user.username ||
1446
+ prev.avatar !== user.avatar ||
1447
+ prev.status !== user.status) {
1448
+ this._onlineUsers.set(userId, user);
1449
+ this.emit('userUpdated', user);
1450
+ }
1451
+ }
1452
+ for (const [actorId, userId] of freshActors) {
1453
+ this._actorToUserId.set(actorId, userId);
1454
+ }
1267
1455
  }
1268
1456
  // ============ Private: Helpers ============
1269
1457
  _presenceToUser(actorTokenId, data) {
@@ -1285,23 +1473,6 @@ class NoLagChat extends EventEmitter {
1285
1473
  }
1286
1474
  return undefined;
1287
1475
  }
1288
- _restoreRooms() {
1289
- // On reconnect, js-sdk auto-restores subscriptions.
1290
- // Re-set presence only on the active room.
1291
- if (this._activeRoom) {
1292
- const activeRoom = this._rooms.get(this._activeRoom);
1293
- if (activeRoom)
1294
- activeRoom._updateLocalPresence();
1295
- }
1296
- // Re-fetch lobby presence
1297
- this._lobby?.fetchPresence().then((state) => {
1298
- this._onlineUsers.clear();
1299
- this._actorToUserId.clear();
1300
- this._hydrateOnlineUsers(state);
1301
- }).catch((err) => {
1302
- this._log('Failed to re-fetch lobby presence:', err);
1303
- });
1304
- }
1305
1476
  }
1306
1477
 
1307
1478
  export { ChatRoom, EventEmitter, NoLagChat };