@nolag/chat 0.1.3 → 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
  *
@@ -113,6 +111,15 @@ class MessageStore {
113
111
  has(id) {
114
112
  return this._ids.has(id);
115
113
  }
114
+ /**
115
+ * Get a message by ID. Returns the live reference (mutating it mutates the
116
+ * stored message), or undefined if not present.
117
+ */
118
+ get(id) {
119
+ if (!this._ids.has(id))
120
+ return undefined;
121
+ return this._messages.find((m) => m.id === id);
122
+ }
116
123
  /**
117
124
  * Clear all messages.
118
125
  */
@@ -342,6 +349,29 @@ function createLogger(prefix, enabled) {
342
349
  console.log(`[${prefix}]`, ...args);
343
350
  };
344
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
+ }
345
375
 
346
376
  /** Default app name for room topic prefixes */
347
377
  const DEFAULT_APP_NAME = 'chat';
@@ -353,8 +383,90 @@ const DEFAULT_MAX_MESSAGE_CACHE = 500;
353
383
  const TOPIC_MESSAGES = 'messages';
354
384
  /** Topic name for typing indicators within a room */
355
385
  const TOPIC_TYPING = '_typing';
386
+ /** Topic name for live streamed message chunks within a room (ephemeral) */
387
+ const TOPIC_STREAM = '_stream';
388
+ /** Default coalesce/flush interval for streamed token chunks (ms) */
389
+ const DEFAULT_STREAM_FLUSH_MS = 60;
356
390
  /** Lobby ID for global online presence */
357
391
  const LOBBY_ID = 'online';
392
+ /** Delay before the post-setup lobby presence refetch (catches simultaneous joiners) */
393
+ const LOBBY_REFRESH_DELAY_MS = 2000;
394
+
395
+ /**
396
+ * Producer-side controller for an outgoing streamed message.
397
+ *
398
+ * `append()` updates the local message immediately and buffers the token; a
399
+ * timer flushes buffered tokens as a single network delta at most every
400
+ * `flushIntervalMs`, so a token-per-character source doesn't flood the broker.
401
+ */
402
+ class MessageStreamController {
403
+ constructor(message, _flushIntervalMs, _hooks) {
404
+ this._flushIntervalMs = _flushIntervalMs;
405
+ this._hooks = _hooks;
406
+ this._buffer = '';
407
+ this._flushTimer = null;
408
+ this._closed = false;
409
+ this.message = message;
410
+ // Announce the stream so receivers can render a live placeholder at once.
411
+ this._hooks.publishStream({
412
+ type: 'start',
413
+ id: message.id,
414
+ userId: message.userId,
415
+ username: message.username,
416
+ avatar: message.avatar,
417
+ timestamp: message.timestamp,
418
+ });
419
+ this._hooks.emitStart(message);
420
+ }
421
+ append(text) {
422
+ if (this._closed || !text)
423
+ return;
424
+ this.message.text += text;
425
+ this._buffer += text;
426
+ this._hooks.emitChunk(this.message, text);
427
+ if (!this._flushTimer) {
428
+ this._flushTimer = setTimeout(() => this._flush(), this._flushIntervalMs);
429
+ }
430
+ }
431
+ complete() {
432
+ if (this._closed)
433
+ return this.message;
434
+ this._flush(); // send any buffered tokens
435
+ this._closed = true;
436
+ this._clearTimer();
437
+ this.message.status = 'sent';
438
+ this._hooks.publishFinal(this.message); // persisted final → finalizes receivers
439
+ this._hooks.emitEnd(this.message);
440
+ return this.message;
441
+ }
442
+ abort(error) {
443
+ if (this._closed)
444
+ return;
445
+ this._closed = true;
446
+ this._clearTimer();
447
+ this._buffer = '';
448
+ this.message.status = 'aborted';
449
+ this._hooks.publishStream({ type: 'abort', id: this.message.id, error });
450
+ this._hooks.emitAbort(this.message, error);
451
+ }
452
+ _flush() {
453
+ this._clearTimer();
454
+ if (this._buffer) {
455
+ this._hooks.publishStream({
456
+ type: 'delta',
457
+ id: this.message.id,
458
+ text: this._buffer,
459
+ });
460
+ this._buffer = '';
461
+ }
462
+ }
463
+ _clearTimer() {
464
+ if (this._flushTimer) {
465
+ clearTimeout(this._flushTimer);
466
+ this._flushTimer = null;
467
+ }
468
+ }
469
+ }
358
470
 
359
471
  /**
360
472
  * ChatRoom — a single chat room with messages, users, and typing indicators.
@@ -363,15 +475,21 @@ const LOBBY_ID = 'online';
363
475
  */
364
476
  class ChatRoom extends EventEmitter {
365
477
  /** @internal */
366
- constructor(name, roomContext, localUser, options, log) {
478
+ constructor(name, roomContext, localUser, options, log, isConnected) {
367
479
  super();
368
480
  this._unreadCount = 0;
369
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;
370
487
  this.name = name;
371
488
  this._roomContext = roomContext;
372
489
  this._localUser = localUser;
373
490
  this._options = options;
374
491
  this._log = log;
492
+ this._isConnected = isConnected;
375
493
  this._presenceManager = new PresenceManager(localUser.actorTokenId);
376
494
  this._typingManager = new TypingManager(options.typingTimeout);
377
495
  this._messageStore = new MessageStore(options.maxMessageCache);
@@ -442,6 +560,76 @@ class ChatRoom extends EventEmitter {
442
560
  this._messageStore.add(message);
443
561
  this.emit('messageSent', message);
444
562
  // Publish to room (echo: false prevents duplicate)
563
+ this._publishFinalMessage(message);
564
+ // Mark as sent
565
+ message.status = 'sent';
566
+ // Stop typing on send
567
+ this._typingManager.stopTyping();
568
+ return message;
569
+ }
570
+ // ============ Streaming ============
571
+ /**
572
+ * Begin a streamed message (e.g. an AI response). Returns a handle you append
573
+ * tokens to. Receivers see the message appear and grow live; on `complete()`
574
+ * the full message is persisted like a normal message.
575
+ *
576
+ * @example
577
+ * ```ts
578
+ * const stream = room.startStream();
579
+ * for await (const token of llm) stream.append(token);
580
+ * stream.complete();
581
+ * ```
582
+ */
583
+ startStream(options) {
584
+ const message = {
585
+ id: generateId(),
586
+ userId: this._localUser.userId,
587
+ username: this._localUser.username,
588
+ avatar: this._localUser.avatar,
589
+ text: '',
590
+ data: options?.data,
591
+ timestamp: Date.now(),
592
+ status: 'streaming',
593
+ isReplay: false,
594
+ };
595
+ // Optimistic: appears in room.messages and grows as tokens arrive.
596
+ this._messageStore.add(message);
597
+ this._typingManager.stopTyping();
598
+ return new MessageStreamController(message, options?.flushIntervalMs ?? DEFAULT_STREAM_FLUSH_MS, {
599
+ publishStream: (payload) => this._roomContext.emit(TOPIC_STREAM, payload, { echo: false }),
600
+ publishFinal: (m) => this._publishFinalMessage(m),
601
+ emitStart: (m) => this.emit('streamStart', m),
602
+ emitChunk: (m, delta) => this.emit('streamChunk', { message: m, delta }),
603
+ emitEnd: (m) => this.emit('streamEnd', m),
604
+ emitAbort: (m, error) => this.emit('streamAbort', { message: m, error }),
605
+ });
606
+ }
607
+ /**
608
+ * Stream a message from a token source (sync or async iterable) — drops in
609
+ * for an LLM stream. Appends each chunk, finalizes on completion, and aborts
610
+ * (re-throwing) if the source errors.
611
+ *
612
+ * @example
613
+ * ```ts
614
+ * // OpenAI / Anthropic style streams yield text chunks
615
+ * await room.streamMessage(tokenIterable);
616
+ * ```
617
+ */
618
+ async streamMessage(source, options) {
619
+ const stream = this.startStream(options);
620
+ try {
621
+ for await (const chunk of source) {
622
+ stream.append(chunk);
623
+ }
624
+ return stream.complete();
625
+ }
626
+ catch (err) {
627
+ stream.abort(err instanceof Error ? err.message : String(err));
628
+ throw err;
629
+ }
630
+ }
631
+ /** @internal Publish a final message on the persisted `messages` topic. */
632
+ _publishFinalMessage(message) {
445
633
  this._roomContext.emit(TOPIC_MESSAGES, {
446
634
  id: message.id,
447
635
  userId: message.userId,
@@ -451,11 +639,6 @@ class ChatRoom extends EventEmitter {
451
639
  data: message.data,
452
640
  timestamp: message.timestamp,
453
641
  }, { echo: false });
454
- // Mark as sent
455
- message.status = 'sent';
456
- // Stop typing on send
457
- this._typingManager.stopTyping();
458
- return message;
459
642
  }
460
643
  /**
461
644
  * Get all messages (alias for the messages getter).
@@ -496,17 +679,25 @@ class ChatRoom extends EventEmitter {
496
679
  // Subscribe to topics
497
680
  this._roomContext.subscribe(TOPIC_MESSAGES);
498
681
  this._roomContext.subscribe(TOPIC_TYPING);
499
- // Listen for messages
500
- this._roomContext.on(TOPIC_MESSAGES, (data, meta) => {
682
+ this._roomContext.subscribe(TOPIC_STREAM);
683
+ // Listen for messages (refs stored for handler-specific removal)
684
+ this._onMessagesRef = (data, meta) => {
501
685
  this._handleIncomingMessage(data, meta);
502
- });
686
+ };
687
+ this._roomContext.on(TOPIC_MESSAGES, this._onMessagesRef);
503
688
  // Listen for typing
504
- this._roomContext.on(TOPIC_TYPING, (data) => {
689
+ this._onTypingRef = (data) => {
505
690
  const { userId, typing } = data;
506
691
  if (userId !== this._localUser.userId) {
507
692
  this._typingManager.handleRemote(userId, typing);
508
693
  }
509
- });
694
+ };
695
+ this._roomContext.on(TOPIC_TYPING, this._onTypingRef);
696
+ // Listen for live streamed messages (start / delta / abort)
697
+ this._onStreamRef = (data) => {
698
+ this._handleStreamEvent(data);
699
+ };
700
+ this._roomContext.on(TOPIC_STREAM, this._onStreamRef);
510
701
  }
511
702
  /** @internal Set presence and fetch room members (active room only) */
512
703
  _activate() {
@@ -573,10 +764,24 @@ class ChatRoom extends EventEmitter {
573
764
  /** @internal Unsubscribe and clean up */
574
765
  _cleanup() {
575
766
  this._log('Room cleanup:', this.name);
576
- this._roomContext.unsubscribe(TOPIC_MESSAGES);
577
- this._roomContext.unsubscribe(TOPIC_TYPING);
578
- this._roomContext.off(TOPIC_MESSAGES);
579
- this._roomContext.off(TOPIC_TYPING);
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;
580
785
  this._typingManager.dispose();
581
786
  this._messageStore.clear();
582
787
  this._presenceManager.clear();
@@ -585,8 +790,25 @@ class ChatRoom extends EventEmitter {
585
790
  // ============ Private ============
586
791
  _handleIncomingMessage(data, meta) {
587
792
  const msg = data;
793
+ const id = msg.id;
794
+ // If this is the persisted final for a message we streamed live, finalize
795
+ // the existing placeholder in place (authoritative text) rather than adding
796
+ // a duplicate. This also catches a late delta race — the final wins.
797
+ const streaming = this._messageStore.get(id);
798
+ if (streaming && streaming.status === 'streaming') {
799
+ streaming.text = msg.text;
800
+ streaming.data = msg.data;
801
+ streaming.status = 'delivered';
802
+ this.emit('streamEnd', streaming);
803
+ this.emit('message', streaming);
804
+ if (!this._active && !streaming.isReplay) {
805
+ this._unreadCount++;
806
+ this.emit('unreadChanged', { room: this.name, count: this._unreadCount });
807
+ }
808
+ return;
809
+ }
588
810
  const chatMessage = {
589
- id: msg.id,
811
+ id,
590
812
  userId: msg.userId,
591
813
  username: msg.username,
592
814
  avatar: msg.avatar,
@@ -605,6 +827,49 @@ class ChatRoom extends EventEmitter {
605
827
  }
606
828
  }
607
829
  }
830
+ /** Handle an incoming live stream control payload (start / delta / abort). */
831
+ _handleStreamEvent(data) {
832
+ const evt = data;
833
+ if (!evt || !evt.id)
834
+ return;
835
+ switch (evt.type) {
836
+ case 'start': {
837
+ // Ignore our own (echo:false should prevent it, but be safe).
838
+ if (evt.userId === this._localUser.userId)
839
+ return;
840
+ const message = {
841
+ id: evt.id,
842
+ userId: evt.userId,
843
+ username: evt.username,
844
+ avatar: evt.avatar,
845
+ text: '',
846
+ timestamp: evt.timestamp,
847
+ status: 'streaming',
848
+ isReplay: false,
849
+ };
850
+ if (this._messageStore.add(message)) {
851
+ this.emit('streamStart', message);
852
+ }
853
+ break;
854
+ }
855
+ case 'delta': {
856
+ const message = this._messageStore.get(evt.id);
857
+ if (message && message.status === 'streaming') {
858
+ message.text += evt.text;
859
+ this.emit('streamChunk', { message, delta: evt.text });
860
+ }
861
+ break;
862
+ }
863
+ case 'abort': {
864
+ const message = this._messageStore.get(evt.id);
865
+ if (message && message.status === 'streaming') {
866
+ message.status = 'aborted';
867
+ this.emit('streamAbort', { message, error: evt.error });
868
+ }
869
+ break;
870
+ }
871
+ }
872
+ }
608
873
  _markRead() {
609
874
  if (this._unreadCount !== 0) {
610
875
  this._unreadCount = 0;
@@ -618,6 +883,9 @@ class ChatRoom extends EventEmitter {
618
883
  avatar: this._localUser.avatar,
619
884
  status: this._localUser.status,
620
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,
621
889
  };
622
890
  this._roomContext.setPresence(presenceData);
623
891
  }
@@ -629,54 +897,134 @@ class ChatRoom extends EventEmitter {
629
897
  * Provides multi-room chat, presence (who's online), typing indicators,
630
898
  * message replay, and user mapping — all framework-agnostic via events.
631
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
+ *
632
904
  * @example
633
905
  * ```typescript
906
+ * import { NoLag } from '@nolag/js-sdk';
634
907
  * import { NoLagChat } from '@nolag/chat';
635
908
  *
636
- * 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' });
637
911
  *
638
- * chat.on('connected', () => console.log('Connected!'));
639
912
  * chat.on('userOnline', (user) => console.log(user.username, 'is online'));
640
913
  *
641
- * await chat.connect();
914
+ * await client.connect(); // the app owns the connection
915
+ * await chat.ready(); // wrapper setup done (identity, lobby, rooms)
642
916
  *
643
917
  * const room = chat.joinRoom('general');
644
918
  * room.on('message', (msg) => console.log(msg.username + ':', msg.text));
645
919
  * room.sendMessage('Hello!');
920
+ *
921
+ * chat.detach(); // wrapper releases its handlers and topics
922
+ * client.disconnect(); // the app closes the socket
646
923
  * ```
647
924
  */
648
925
  class NoLagChat extends EventEmitter {
649
- constructor(token, options) {
926
+ constructor(options) {
650
927
  super();
651
- this._client = null;
652
928
  this._localUser = null;
653
929
  this._rooms = new Map();
654
930
  this._lobby = null;
655
931
  this._onlineUsers = new Map();
656
932
  this._actorToUserId = new Map();
657
933
  this._activeRoom = null;
658
- 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;
659
977
  this._userId = generateId();
660
978
  this._options = {
661
979
  username: options.username,
662
980
  avatar: options.avatar,
663
981
  metadata: options.metadata,
664
982
  appName: options.appName ?? DEFAULT_APP_NAME,
665
- url: options.url,
666
983
  typingTimeout: options.typingTimeout ?? DEFAULT_TYPING_TIMEOUT,
667
984
  maxMessageCache: options.maxMessageCache ?? DEFAULT_MAX_MESSAGE_CACHE,
668
985
  debug: options.debug ?? false,
669
- reconnect: options.reconnect ?? true,
670
986
  rooms: options.rooms ?? [],
671
987
  };
672
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
+ });
673
1017
  }
674
1018
  // ============ Public Properties ============
675
- /** Whether the underlying connection is established */
1019
+ /** Whether the underlying connection is established (connected ≠ ready) */
676
1020
  get connected() {
677
- return this._client?.connected ?? false;
1021
+ return !this._detached && this._client.connected;
1022
+ }
1023
+ /** The injected core client (owned by the app, not the wrapper) */
1024
+ get client() {
1025
+ return this._client;
678
1026
  }
679
- /** The local user's info (available after connect) */
1027
+ /** The local user's info (available after ready) */
680
1028
  get localUser() {
681
1029
  return this._localUser;
682
1030
  }
@@ -686,121 +1034,161 @@ class NoLagChat extends EventEmitter {
686
1034
  }
687
1035
  // ============ Lifecycle ============
688
1036
  /**
689
- * 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.
690
1041
  */
691
- async connect() {
692
- this._log('Connecting...');
693
- const clientOptions = {
694
- debug: this._options.debug,
695
- reconnect: this._options.reconnect,
696
- };
697
- if (this._options.url) {
698
- clientOptions.url = this._options.url;
699
- }
700
- this._client = jsSdk.NoLag(this._token, clientOptions);
701
- // Wire client lifecycle events
702
- // Note: we emit 'connected' after _localUser and lobby are ready (below),
703
- // not here, so that joinRoom() works inside the connected handler.
704
- this._client.on('connect', () => {
705
- this._log('Connected');
706
- // On reconnect, the SDK fires 'connect' after the connection is
707
- // re-established. Restore rooms here (not in 'reconnect') so that
708
- // presence updates and lobby fetches go over a live socket.
709
- if (this._rooms.size > 0) {
710
- this._log('Reconnected — restoring rooms...');
711
- this._restoreRooms();
712
- this.emit('reconnected');
713
- }
714
- });
715
- this._client.on('disconnect', (reason) => {
716
- this._log('Disconnected:', reason);
717
- this.emit('disconnected', reason);
718
- });
719
- this._client.on('reconnect', () => {
720
- this._log('Reconnecting...');
721
- });
722
- this._client.on('error', (error) => {
723
- this._log('Error:', error);
724
- this.emit('error', error);
725
- });
726
- // Wire replay events
727
- this._client.on('replay:start', (data) => {
728
- const event = data;
729
- for (const room of this._rooms.values()) {
730
- room._handleReplayStart(event.count);
731
- }
732
- });
733
- this._client.on('replay:end', (data) => {
734
- const event = data;
735
- for (const room of this._rooms.values()) {
736
- room._handleReplayEnd(event.replayed);
737
- }
738
- });
739
- // Connect
740
- await this._client.connect();
741
- // Wire room-level presence events (these arrive as client-level events)
742
- this._client.on('presence:join', (data) => {
743
- this._handleRoomPresenceJoin(data);
744
- });
745
- this._client.on('presence:leave', (data) => {
746
- this._handleRoomPresenceLeave(data);
747
- });
748
- this._client.on('presence:update', (data) => {
749
- this._handleRoomPresenceUpdate(data);
750
- });
751
- // Create local user
752
- this._localUser = {
753
- userId: this._userId,
754
- actorTokenId: this._client.actorId,
755
- username: this._options.username,
756
- avatar: this._options.avatar,
757
- metadata: this._options.metadata,
758
- status: 'online',
759
- joinedAt: Date.now(),
760
- isLocal: true,
761
- };
762
- this._log('Local user:', this._localUser.userId, '→', this._localUser.actorTokenId);
763
- // Set up lobby for global presence
764
- await this._setupLobby();
765
- // Pre-subscribe to all configured rooms (messages only, no presence)
766
- for (const roomName of this._options.rooms) {
767
- this._subscribeRoom(roomName);
768
- }
769
- // Emit connected now that _localUser and lobby are ready,
770
- // so handlers can safely call joinRoom().
771
- this.emit('connected');
772
- // Deferred lobby refetch: the initial lobby snapshot is taken before
773
- // rooms are joined and presence is set. When multiple tabs connect
774
- // simultaneously, one tab may get its snapshot before the other has
775
- // set presence — causing a missed user. A short-delay refetch
776
- // catches anyone who joined during the setup window.
777
- setTimeout(() => {
778
- if (this._lobby && this._client?.connected) {
779
- this._lobby.fetchPresence().then((state) => {
780
- this._hydrateOnlineUsers(state);
781
- }).catch(() => { });
782
- }
783
- }, 2000);
1042
+ ready() {
1043
+ return this._readyPromise;
784
1044
  }
785
1045
  /**
786
- * 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.
787
1050
  */
788
- disconnect() {
789
- this._log('Disconnecting...');
790
- // 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
791
1075
  for (const name of [...this._rooms.keys()]) {
792
- 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
+ }
793
1088
  }
794
- // Unsubscribe from lobby
795
- this._lobby?.unsubscribe();
796
1089
  this._lobby = null;
797
- // Disconnect client
798
- this._client?.disconnect();
799
- this._client = null;
800
- // Clear state
801
1090
  this._onlineUsers.clear();
802
1091
  this._actorToUserId.clear();
803
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);
804
1192
  }
805
1193
  // ============ Room Management ============
806
1194
  /**
@@ -809,9 +1197,7 @@ class NoLagChat extends EventEmitter {
809
1197
  * Otherwise creates, subscribes, and activates it.
810
1198
  */
811
1199
  joinRoom(name) {
812
- if (!this._client || !this._localUser) {
813
- throw new Error('Not connected — call connect() first');
814
- }
1200
+ this._assertUsable();
815
1201
  // Deactivate the current active room
816
1202
  if (this._activeRoom && this._activeRoom !== name) {
817
1203
  const prev = this._rooms.get(this._activeRoom);
@@ -821,7 +1207,7 @@ class NoLagChat extends EventEmitter {
821
1207
  // Get or create the room
822
1208
  let room = this._rooms.get(name);
823
1209
  if (!room) {
824
- room = this._subscribeRoom(name);
1210
+ room = this._subscribeRoomInternal(name);
825
1211
  }
826
1212
  this._activeRoom = name;
827
1213
  room._activate();
@@ -894,24 +1280,41 @@ class NoLagChat extends EventEmitter {
894
1280
  activeRoom._updateLocalPresence();
895
1281
  }
896
1282
  }
897
- // ============ Private: Room Setup ============
898
- _subscribeRoom(name) {
899
- if (!this._client || !this._localUser) {
900
- 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');
901
1290
  }
1291
+ }
1292
+ // ============ Private: Room Setup ============
1293
+ _subscribeRoomInternal(name) {
902
1294
  this._log('Subscribing room:', name);
903
1295
  const roomContext = this._client.setApp(this._options.appName).setRoom(name);
904
- 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);
905
1297
  this._rooms.set(name, room);
906
1298
  room._subscribe();
907
1299
  return room;
908
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
+ }
909
1312
  // ============ Private: Room Presence → Active Room ============
910
1313
  _handleRoomPresenceJoin(data) {
911
1314
  if (data.actorTokenId === this._localUser?.actorTokenId)
912
1315
  return;
913
1316
  const presenceData = data.presence;
914
- if (!presenceData?.userId)
1317
+ if (!presenceData?.userId || this._foreignScope(presenceData))
915
1318
  return;
916
1319
  // Track as online user
917
1320
  const user = this._presenceToUser(data.actorTokenId, presenceData);
@@ -939,7 +1342,7 @@ class NoLagChat extends EventEmitter {
939
1342
  if (data.actorTokenId === this._localUser?.actorTokenId)
940
1343
  return;
941
1344
  const presenceData = data.presence;
942
- if (!presenceData?.userId)
1345
+ if (!presenceData?.userId || this._foreignScope(presenceData))
943
1346
  return;
944
1347
  // Update online user info if we already track them
945
1348
  if (this._onlineUsers.has(presenceData.userId)) {
@@ -953,41 +1356,12 @@ class NoLagChat extends EventEmitter {
953
1356
  }
954
1357
  }
955
1358
  // ============ Private: Lobby ============
956
- async _setupLobby() {
957
- if (!this._client)
958
- return;
959
- this._lobby = this._client.setApp(this._options.appName).setLobby(LOBBY_ID);
960
- // Register on the client's generic lobby events (lobbyPresence:join/leave/update)
961
- // instead of lobby.on(), because the server sends presence events with the
962
- // server-assigned lobby UUID, while lobby.on() listens on the client-provided
963
- // lobby name — the keys never match.
964
- const lobbyHandler = (type) => (data) => {
965
- const event = data;
966
- if (type === 'join')
967
- this._handleLobbyJoin(event);
968
- else if (type === 'leave')
969
- this._handleLobbyLeave(event);
970
- else
971
- this._handleLobbyUpdate(event);
972
- };
973
- this._client.on('lobbyPresence:join', lobbyHandler('join'));
974
- this._client.on('lobbyPresence:leave', lobbyHandler('leave'));
975
- this._client.on('lobbyPresence:update', lobbyHandler('update'));
976
- try {
977
- const initialState = await this._lobby.subscribe();
978
- this._hydrateOnlineUsers(initialState);
979
- this._log('Lobby subscribed, online users:', this._onlineUsers.size);
980
- }
981
- catch (err) {
982
- this._log('Lobby subscription failed:', err);
983
- }
984
- }
985
1359
  _handleLobbyJoin(event) {
986
1360
  const { actorId, data } = event;
987
1361
  if (actorId === this._localUser?.actorTokenId)
988
1362
  return;
989
1363
  const presenceData = data;
990
- if (!presenceData.userId)
1364
+ if (!presenceData.userId || this._foreignScope(presenceData))
991
1365
  return;
992
1366
  const user = this._presenceToUser(actorId, presenceData);
993
1367
  this._actorToUserId.set(actorId, user.userId);
@@ -1001,6 +1375,8 @@ class NoLagChat extends EventEmitter {
1001
1375
  if (actorId === this._localUser?.actorTokenId)
1002
1376
  return;
1003
1377
  const presenceData = data;
1378
+ if (this._foreignScope(presenceData))
1379
+ return;
1004
1380
  const userId = presenceData?.userId
1005
1381
  || this._actorToUserId.get(actorId)
1006
1382
  || this._findUserIdByActorId(actorId);
@@ -1018,15 +1394,22 @@ class NoLagChat extends EventEmitter {
1018
1394
  if (actorId === this._localUser?.actorTokenId)
1019
1395
  return;
1020
1396
  const presenceData = data;
1021
- if (!presenceData.userId)
1397
+ if (!presenceData.userId || this._foreignScope(presenceData))
1022
1398
  return;
1023
1399
  const user = this._presenceToUser(actorId, presenceData);
1024
1400
  this._onlineUsers.set(user.userId, user);
1025
1401
  this.emit('userUpdated', user);
1026
1402
  }
1027
- _hydrateOnlineUsers(state) {
1028
- // state = { roomId: { actorId: actorRecord } }
1029
- // 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();
1030
1413
  for (const roomId of Object.keys(state)) {
1031
1414
  const roomPresence = state[roomId];
1032
1415
  for (const actorId of Object.keys(roomPresence)) {
@@ -1035,16 +1418,42 @@ class NoLagChat extends EventEmitter {
1035
1418
  const raw = roomPresence[actorId];
1036
1419
  // Server returns full actor records with presence nested under .presence
1037
1420
  const presenceData = (raw?.presence ?? raw);
1038
- if (presenceData?.userId) {
1039
- const user = this._presenceToUser(actorId, presenceData);
1040
- this._actorToUserId.set(actorId, user.userId);
1041
- if (!this._onlineUsers.has(user.userId)) {
1042
- this._onlineUsers.set(user.userId, user);
1043
- 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));
1044
1424
  }
1425
+ freshActors.set(actorId, presenceData.userId);
1426
+ }
1427
+ }
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);
1045
1436
  }
1437
+ this.emit('userOffline', user);
1046
1438
  }
1047
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
+ }
1048
1457
  }
1049
1458
  // ============ Private: Helpers ============
1050
1459
  _presenceToUser(actorTokenId, data) {
@@ -1066,23 +1475,6 @@ class NoLagChat extends EventEmitter {
1066
1475
  }
1067
1476
  return undefined;
1068
1477
  }
1069
- _restoreRooms() {
1070
- // On reconnect, js-sdk auto-restores subscriptions.
1071
- // Re-set presence only on the active room.
1072
- if (this._activeRoom) {
1073
- const activeRoom = this._rooms.get(this._activeRoom);
1074
- if (activeRoom)
1075
- activeRoom._updateLocalPresence();
1076
- }
1077
- // Re-fetch lobby presence
1078
- this._lobby?.fetchPresence().then((state) => {
1079
- this._onlineUsers.clear();
1080
- this._actorToUserId.clear();
1081
- this._hydrateOnlineUsers(state);
1082
- }).catch((err) => {
1083
- this._log('Failed to re-fetch lobby presence:', err);
1084
- });
1085
- }
1086
1478
  }
1087
1479
 
1088
1480
  exports.ChatRoom = ChatRoom;