@nolag/chat 0.2.0 → 1.0.0

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