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