@nolag/chat 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs ADDED
@@ -0,0 +1,1091 @@
1
+ 'use strict';
2
+
3
+ var jsSdk = require('@nolag/js-sdk');
4
+
5
+ /**
6
+ * Tiny typed event emitter — framework-agnostic base for NoLagChat and ChatRoom.
7
+ *
8
+ * EventMap is a record of event name → tuple of handler arguments.
9
+ * e.g. { message: [ChatMessage]; typing: [{ users: ChatUser[] }] }
10
+ */
11
+ class EventEmitter {
12
+ constructor() {
13
+ this._handlers = new Map();
14
+ }
15
+ /**
16
+ * Register an event handler.
17
+ */
18
+ on(event, handler) {
19
+ if (!this._handlers.has(event)) {
20
+ this._handlers.set(event, new Set());
21
+ }
22
+ this._handlers.get(event).add(handler);
23
+ return this;
24
+ }
25
+ /**
26
+ * Remove a specific handler, or all handlers for an event.
27
+ */
28
+ off(event, handler) {
29
+ if (handler) {
30
+ this._handlers.get(event)?.delete(handler);
31
+ }
32
+ else {
33
+ this._handlers.delete(event);
34
+ }
35
+ return this;
36
+ }
37
+ /**
38
+ * Remove all handlers for all events.
39
+ */
40
+ removeAllListeners() {
41
+ this._handlers.clear();
42
+ return this;
43
+ }
44
+ /**
45
+ * Emit an event to all registered handlers.
46
+ */
47
+ emit(event, ...args) {
48
+ const handlers = this._handlers.get(event);
49
+ if (!handlers)
50
+ return;
51
+ for (const handler of handlers) {
52
+ try {
53
+ handler(...args);
54
+ }
55
+ catch (e) {
56
+ console.error(`Error in ${String(event)} handler:`, e);
57
+ }
58
+ }
59
+ }
60
+ /**
61
+ * Returns the number of handlers registered for an event.
62
+ */
63
+ listenerCount(event) {
64
+ return this._handlers.get(event)?.size ?? 0;
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Bounded, deduplicated message cache ordered by timestamp.
70
+ */
71
+ class MessageStore {
72
+ constructor(maxSize) {
73
+ this._messages = [];
74
+ this._ids = new Set();
75
+ this._maxSize = maxSize;
76
+ }
77
+ /**
78
+ * Add a message. Returns true if the message was new (not a duplicate).
79
+ */
80
+ add(message) {
81
+ if (this._ids.has(message.id)) {
82
+ return false;
83
+ }
84
+ this._ids.add(message.id);
85
+ this._messages.push(message);
86
+ // Keep sorted by timestamp
87
+ if (this._messages.length > 1 &&
88
+ message.timestamp < this._messages[this._messages.length - 2].timestamp) {
89
+ this._messages.sort((a, b) => a.timestamp - b.timestamp);
90
+ }
91
+ // Trim if over capacity
92
+ while (this._messages.length > this._maxSize) {
93
+ const removed = this._messages.shift();
94
+ this._ids.delete(removed.id);
95
+ }
96
+ return true;
97
+ }
98
+ /**
99
+ * Get all messages in timestamp order.
100
+ */
101
+ getAll() {
102
+ return [...this._messages];
103
+ }
104
+ /**
105
+ * Get message count.
106
+ */
107
+ get size() {
108
+ return this._messages.length;
109
+ }
110
+ /**
111
+ * Check if a message ID exists.
112
+ */
113
+ has(id) {
114
+ return this._ids.has(id);
115
+ }
116
+ /**
117
+ * Clear all messages.
118
+ */
119
+ clear() {
120
+ this._messages = [];
121
+ this._ids.clear();
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Maps actorTokenId ↔ ChatUser, filtering self.
127
+ */
128
+ class PresenceManager {
129
+ constructor(localActorId) {
130
+ this._users = new Map();
131
+ this._actorToUserId = new Map();
132
+ this._localActorId = localActorId;
133
+ }
134
+ /**
135
+ * Add or update a user from presence data.
136
+ * Returns the ChatUser if it's a remote user, null if it's self.
137
+ */
138
+ addFromPresence(actorTokenId, presence, joinedAt) {
139
+ const isLocal = actorTokenId === this._localActorId;
140
+ // Skip self
141
+ if (isLocal)
142
+ return null;
143
+ const existing = this._actorToUserId.get(actorTokenId);
144
+ const userId = presence.userId || existing || actorTokenId;
145
+ const user = {
146
+ userId,
147
+ actorTokenId,
148
+ username: presence.username,
149
+ avatar: presence.avatar,
150
+ metadata: presence.metadata,
151
+ status: presence.status || 'online',
152
+ joinedAt: joinedAt || Date.now(),
153
+ isLocal: false,
154
+ };
155
+ this._users.set(userId, user);
156
+ this._actorToUserId.set(actorTokenId, userId);
157
+ return user;
158
+ }
159
+ /**
160
+ * Remove a user by actorTokenId.
161
+ * Returns the removed user, or null if not found / is self.
162
+ */
163
+ removeByActorId(actorTokenId) {
164
+ if (actorTokenId === this._localActorId)
165
+ return null;
166
+ const userId = this._actorToUserId.get(actorTokenId);
167
+ if (!userId)
168
+ return null;
169
+ const user = this._users.get(userId) || null;
170
+ this._users.delete(userId);
171
+ this._actorToUserId.delete(actorTokenId);
172
+ return user;
173
+ }
174
+ /**
175
+ * Get a user by userId.
176
+ */
177
+ getUser(userId) {
178
+ return this._users.get(userId);
179
+ }
180
+ /**
181
+ * Get a user by actorTokenId.
182
+ */
183
+ getUserByActorId(actorTokenId) {
184
+ const userId = this._actorToUserId.get(actorTokenId);
185
+ return userId ? this._users.get(userId) : undefined;
186
+ }
187
+ /**
188
+ * Get all remote users.
189
+ */
190
+ getAll() {
191
+ return Array.from(this._users.values());
192
+ }
193
+ /**
194
+ * Get the users Map (readonly view).
195
+ */
196
+ get users() {
197
+ return this._users;
198
+ }
199
+ /**
200
+ * Clear all tracked users.
201
+ */
202
+ clear() {
203
+ this._users.clear();
204
+ this._actorToUserId.clear();
205
+ }
206
+ }
207
+
208
+ /**
209
+ * Manages typing indicator state for both local (send) and remote (receive) sides.
210
+ *
211
+ * Send-side: debounced — calling startTyping() sends a signal, then auto-stops
212
+ * after a configurable timeout unless startTyping() is called again.
213
+ *
214
+ * Receive-side: per-user timeouts — if no typing signal arrives within the
215
+ * timeout window, the user is considered to have stopped typing.
216
+ */
217
+ class TypingManager {
218
+ constructor(timeout) {
219
+ // Send-side
220
+ this._localTimer = null;
221
+ this._localTyping = false;
222
+ // Receive-side: userId → timeout
223
+ this._remoteTimers = new Map();
224
+ this._typingUserIds = new Set();
225
+ // Callbacks
226
+ this._onSend = null;
227
+ this._onChange = null;
228
+ this._timeout = timeout;
229
+ }
230
+ /**
231
+ * Set the callback invoked when a typing signal needs to be sent.
232
+ */
233
+ onSend(cb) {
234
+ this._onSend = cb;
235
+ }
236
+ /**
237
+ * Set the callback invoked when the set of typing users changes.
238
+ */
239
+ onChange(cb) {
240
+ this._onChange = cb;
241
+ }
242
+ /**
243
+ * Called by the local user when they are typing.
244
+ * Sends typing=true if not already sent, and (re)starts the auto-stop timer.
245
+ */
246
+ startTyping() {
247
+ if (!this._localTyping) {
248
+ this._localTyping = true;
249
+ this._onSend?.(true);
250
+ }
251
+ // Reset auto-stop timer
252
+ if (this._localTimer)
253
+ clearTimeout(this._localTimer);
254
+ this._localTimer = setTimeout(() => {
255
+ this.stopTyping();
256
+ }, this._timeout);
257
+ }
258
+ /**
259
+ * Called by the local user to explicitly stop typing.
260
+ */
261
+ stopTyping() {
262
+ if (!this._localTyping)
263
+ return;
264
+ this._localTyping = false;
265
+ if (this._localTimer) {
266
+ clearTimeout(this._localTimer);
267
+ this._localTimer = null;
268
+ }
269
+ this._onSend?.(false);
270
+ }
271
+ /**
272
+ * Handle a remote typing signal.
273
+ */
274
+ handleRemote(userId, typing) {
275
+ // Clear existing timer for this user
276
+ const existing = this._remoteTimers.get(userId);
277
+ if (existing)
278
+ clearTimeout(existing);
279
+ if (typing) {
280
+ this._typingUserIds.add(userId);
281
+ // Auto-expire if no follow-up
282
+ this._remoteTimers.set(userId, setTimeout(() => {
283
+ this._typingUserIds.delete(userId);
284
+ this._remoteTimers.delete(userId);
285
+ this._onChange?.();
286
+ }, this._timeout + 1000));
287
+ }
288
+ else {
289
+ this._typingUserIds.delete(userId);
290
+ this._remoteTimers.delete(userId);
291
+ }
292
+ this._onChange?.();
293
+ }
294
+ /**
295
+ * Get the set of currently-typing remote user IDs.
296
+ */
297
+ getTypingUserIds() {
298
+ return this._typingUserIds;
299
+ }
300
+ /**
301
+ * Whether the local user is currently typing.
302
+ */
303
+ get isLocalTyping() {
304
+ return this._localTyping;
305
+ }
306
+ /**
307
+ * Clean up all timers.
308
+ */
309
+ dispose() {
310
+ if (this._localTimer)
311
+ clearTimeout(this._localTimer);
312
+ for (const timer of this._remoteTimers.values()) {
313
+ clearTimeout(timer);
314
+ }
315
+ this._remoteTimers.clear();
316
+ this._typingUserIds.clear();
317
+ this._localTyping = false;
318
+ this._onSend = null;
319
+ this._onChange = null;
320
+ }
321
+ }
322
+
323
+ /**
324
+ * Generate a unique ID.
325
+ * Uses crypto.randomUUID when available, falls back to a simple random string.
326
+ */
327
+ function generateId() {
328
+ if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
329
+ return crypto.randomUUID();
330
+ }
331
+ // Fallback for environments without crypto.randomUUID
332
+ return 'xxxx-xxxx-xxxx-xxxx'.replace(/x/g, () => Math.floor(Math.random() * 16).toString(16));
333
+ }
334
+ /**
335
+ * Create a debug logger that only logs when enabled.
336
+ */
337
+ function createLogger(prefix, enabled) {
338
+ if (!enabled) {
339
+ return (..._args) => { };
340
+ }
341
+ return (...args) => {
342
+ console.log(`[${prefix}]`, ...args);
343
+ };
344
+ }
345
+
346
+ /** Default app name for room topic prefixes */
347
+ const DEFAULT_APP_NAME = 'chat';
348
+ /** Default typing indicator auto-stop timeout (ms) */
349
+ const DEFAULT_TYPING_TIMEOUT = 3000;
350
+ /** Default max messages kept per room */
351
+ const DEFAULT_MAX_MESSAGE_CACHE = 500;
352
+ /** Topic name for chat messages within a room */
353
+ const TOPIC_MESSAGES = 'messages';
354
+ /** Topic name for typing indicators within a room */
355
+ const TOPIC_TYPING = '_typing';
356
+ /** Lobby ID for global online presence */
357
+ const LOBBY_ID = 'online';
358
+
359
+ /**
360
+ * ChatRoom — a single chat room with messages, users, and typing indicators.
361
+ *
362
+ * Created via `NoLagChat.joinRoom(name)`. Do not instantiate directly.
363
+ */
364
+ class ChatRoom extends EventEmitter {
365
+ /** @internal */
366
+ constructor(name, roomContext, localUser, options, log) {
367
+ super();
368
+ this._unreadCount = 0;
369
+ this._active = false;
370
+ this.name = name;
371
+ this._roomContext = roomContext;
372
+ this._localUser = localUser;
373
+ this._options = options;
374
+ this._log = log;
375
+ this._presenceManager = new PresenceManager(localUser.actorTokenId);
376
+ this._typingManager = new TypingManager(options.typingTimeout);
377
+ this._messageStore = new MessageStore(options.maxMessageCache);
378
+ // Wire typing send callback
379
+ this._typingManager.onSend((typing) => {
380
+ this._roomContext.emit(TOPIC_TYPING, {
381
+ userId: this._localUser.userId,
382
+ typing,
383
+ }, { echo: false });
384
+ });
385
+ // Wire typing change callback
386
+ this._typingManager.onChange(() => {
387
+ this.emit('typing', { users: this.typingUsers });
388
+ });
389
+ }
390
+ // ============ Public Properties ============
391
+ /** All remote users currently in this room */
392
+ get users() {
393
+ return this._presenceManager.users;
394
+ }
395
+ /** All messages in this room (timestamp order) */
396
+ get messages() {
397
+ return this._messageStore.getAll();
398
+ }
399
+ /** Users currently typing */
400
+ get typingUsers() {
401
+ const typingIds = this._typingManager.getTypingUserIds();
402
+ const users = [];
403
+ for (const userId of typingIds) {
404
+ const user = this._presenceManager.getUser(userId);
405
+ if (user)
406
+ users.push(user);
407
+ }
408
+ return users;
409
+ }
410
+ /** Number of unread messages (increments when room is not active) */
411
+ get unreadCount() {
412
+ return this._unreadCount;
413
+ }
414
+ /** Whether this room is the currently active (visible) room */
415
+ get active() {
416
+ return this._active;
417
+ }
418
+ /** Reset the unread count to zero */
419
+ markRead() {
420
+ if (this._unreadCount !== 0) {
421
+ this._unreadCount = 0;
422
+ this.emit('unreadChanged', { room: this.name, count: 0 });
423
+ }
424
+ }
425
+ // ============ Messaging ============
426
+ /**
427
+ * Send a text message to this room. Returns an optimistic ChatMessage.
428
+ */
429
+ sendMessage(text, options) {
430
+ const message = {
431
+ id: generateId(),
432
+ userId: this._localUser.userId,
433
+ username: this._localUser.username,
434
+ avatar: this._localUser.avatar,
435
+ text,
436
+ data: options?.data,
437
+ timestamp: Date.now(),
438
+ status: 'sending',
439
+ isReplay: false,
440
+ };
441
+ // Add to local store (optimistic)
442
+ this._messageStore.add(message);
443
+ this.emit('messageSent', message);
444
+ // Publish to room (echo: false prevents duplicate)
445
+ this._roomContext.emit(TOPIC_MESSAGES, {
446
+ id: message.id,
447
+ userId: message.userId,
448
+ username: message.username,
449
+ avatar: message.avatar,
450
+ text: message.text,
451
+ data: message.data,
452
+ timestamp: message.timestamp,
453
+ }, { echo: false });
454
+ // Mark as sent
455
+ message.status = 'sent';
456
+ // Stop typing on send
457
+ this._typingManager.stopTyping();
458
+ return message;
459
+ }
460
+ /**
461
+ * Get all messages (alias for the messages getter).
462
+ */
463
+ getMessages() {
464
+ return this._messageStore.getAll();
465
+ }
466
+ // ============ Typing ============
467
+ /**
468
+ * Signal that the local user is typing. Auto-stops after timeout.
469
+ */
470
+ startTyping() {
471
+ this._typingManager.startTyping();
472
+ }
473
+ /**
474
+ * Explicitly signal that the local user has stopped typing.
475
+ */
476
+ stopTyping() {
477
+ this._typingManager.stopTyping();
478
+ }
479
+ // ============ Users ============
480
+ /**
481
+ * Get all remote users in this room.
482
+ */
483
+ getUsers() {
484
+ return this._presenceManager.getAll();
485
+ }
486
+ /**
487
+ * Get a specific user by userId.
488
+ */
489
+ getUser(userId) {
490
+ return this._presenceManager.getUser(userId);
491
+ }
492
+ // ============ Internal (called by NoLagChat) ============
493
+ /** @internal Subscribe to message/typing topics and attach listeners (all rooms) */
494
+ _subscribe() {
495
+ this._log('Room subscribe:', this.name);
496
+ // Subscribe to topics
497
+ this._roomContext.subscribe(TOPIC_MESSAGES);
498
+ this._roomContext.subscribe(TOPIC_TYPING);
499
+ // Listen for messages
500
+ this._roomContext.on(TOPIC_MESSAGES, (data, meta) => {
501
+ this._handleIncomingMessage(data, meta);
502
+ });
503
+ // Listen for typing
504
+ this._roomContext.on(TOPIC_TYPING, (data) => {
505
+ const { userId, typing } = data;
506
+ if (userId !== this._localUser.userId) {
507
+ this._typingManager.handleRemote(userId, typing);
508
+ }
509
+ });
510
+ }
511
+ /** @internal Set presence and fetch room members (active room only) */
512
+ _activate() {
513
+ this._log('Room activate:', this.name);
514
+ this._active = true;
515
+ this._markRead();
516
+ // Set room presence
517
+ this._setPresence();
518
+ // Fetch existing users
519
+ this._roomContext.fetchPresence().then((actors) => {
520
+ this._log('Room presence fetched:', this.name, actors.length, 'actors');
521
+ for (const actor of actors) {
522
+ if (actor.presence) {
523
+ const user = this._presenceManager.addFromPresence(actor.actorTokenId, actor.presence, actor.joinedAt);
524
+ if (user) {
525
+ this.emit('userJoined', user);
526
+ }
527
+ }
528
+ }
529
+ }).catch((err) => {
530
+ this._log('Failed to fetch room presence:', err);
531
+ });
532
+ }
533
+ /** @internal Clear presence state but keep subscriptions alive */
534
+ _deactivate() {
535
+ this._log('Room deactivate:', this.name);
536
+ this._active = false;
537
+ this._presenceManager.clear();
538
+ }
539
+ /** @internal Handle a lobby presence:join event routed from NoLagChat */
540
+ _handlePresenceJoin(actorTokenId, presenceData) {
541
+ const user = this._presenceManager.addFromPresence(actorTokenId, presenceData);
542
+ if (user) {
543
+ this._log('User joined room:', this.name, user.username);
544
+ this.emit('userJoined', user);
545
+ }
546
+ }
547
+ /** @internal Handle a lobby presence:leave event routed from NoLagChat */
548
+ _handlePresenceLeave(actorTokenId) {
549
+ const user = this._presenceManager.removeByActorId(actorTokenId);
550
+ if (user) {
551
+ this._log('User left room:', this.name, user.username);
552
+ // Remove from typing
553
+ this._typingManager.handleRemote(user.userId, false);
554
+ this.emit('userLeft', user);
555
+ }
556
+ }
557
+ /** @internal Handle a lobby presence:update event routed from NoLagChat */
558
+ _handlePresenceUpdate(actorTokenId, presenceData) {
559
+ this._presenceManager.addFromPresence(actorTokenId, presenceData);
560
+ }
561
+ /** @internal Handle replay start event */
562
+ _handleReplayStart(count) {
563
+ this.emit('replayStart', { count });
564
+ }
565
+ /** @internal Handle replay end event */
566
+ _handleReplayEnd(replayed) {
567
+ this.emit('replayEnd', { replayed });
568
+ }
569
+ /** @internal Re-set presence after reconnect */
570
+ _updateLocalPresence() {
571
+ this._setPresence();
572
+ }
573
+ /** @internal Unsubscribe and clean up */
574
+ _cleanup() {
575
+ 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);
580
+ this._typingManager.dispose();
581
+ this._messageStore.clear();
582
+ this._presenceManager.clear();
583
+ this.removeAllListeners();
584
+ }
585
+ // ============ Private ============
586
+ _handleIncomingMessage(data, meta) {
587
+ const msg = data;
588
+ const chatMessage = {
589
+ id: msg.id,
590
+ userId: msg.userId,
591
+ username: msg.username,
592
+ avatar: msg.avatar,
593
+ text: msg.text,
594
+ data: msg.data,
595
+ timestamp: msg.timestamp,
596
+ status: 'delivered',
597
+ isReplay: meta.isReplay ?? false,
598
+ };
599
+ if (this._messageStore.add(chatMessage)) {
600
+ this.emit('message', chatMessage);
601
+ // Track unread when not the active room
602
+ if (!this._active && !chatMessage.isReplay) {
603
+ this._unreadCount++;
604
+ this.emit('unreadChanged', { room: this.name, count: this._unreadCount });
605
+ }
606
+ }
607
+ }
608
+ _markRead() {
609
+ if (this._unreadCount !== 0) {
610
+ this._unreadCount = 0;
611
+ this.emit('unreadChanged', { room: this.name, count: 0 });
612
+ }
613
+ }
614
+ _setPresence() {
615
+ const presenceData = {
616
+ userId: this._localUser.userId,
617
+ username: this._localUser.username,
618
+ avatar: this._localUser.avatar,
619
+ status: this._localUser.status,
620
+ metadata: this._localUser.metadata,
621
+ };
622
+ this._roomContext.setPresence(presenceData);
623
+ }
624
+ }
625
+
626
+ /**
627
+ * NoLagChat — high-level chat SDK built on @nolag/js-sdk.
628
+ *
629
+ * Provides multi-room chat, presence (who's online), typing indicators,
630
+ * message replay, and user mapping — all framework-agnostic via events.
631
+ *
632
+ * @example
633
+ * ```typescript
634
+ * import { NoLagChat } from '@nolag/chat';
635
+ *
636
+ * const chat = new NoLagChat(token, { username: 'Alice' });
637
+ *
638
+ * chat.on('connected', () => console.log('Connected!'));
639
+ * chat.on('userOnline', (user) => console.log(user.username, 'is online'));
640
+ *
641
+ * await chat.connect();
642
+ *
643
+ * const room = chat.joinRoom('general');
644
+ * room.on('message', (msg) => console.log(msg.username + ':', msg.text));
645
+ * room.sendMessage('Hello!');
646
+ * ```
647
+ */
648
+ class NoLagChat extends EventEmitter {
649
+ constructor(token, options) {
650
+ super();
651
+ this._client = null;
652
+ this._localUser = null;
653
+ this._rooms = new Map();
654
+ this._lobby = null;
655
+ this._onlineUsers = new Map();
656
+ this._actorToUserId = new Map();
657
+ this._activeRoom = null;
658
+ this._token = token;
659
+ this._userId = generateId();
660
+ this._options = {
661
+ username: options.username,
662
+ avatar: options.avatar,
663
+ metadata: options.metadata,
664
+ appName: options.appName ?? DEFAULT_APP_NAME,
665
+ url: options.url,
666
+ typingTimeout: options.typingTimeout ?? DEFAULT_TYPING_TIMEOUT,
667
+ maxMessageCache: options.maxMessageCache ?? DEFAULT_MAX_MESSAGE_CACHE,
668
+ debug: options.debug ?? false,
669
+ reconnect: options.reconnect ?? true,
670
+ rooms: options.rooms ?? [],
671
+ };
672
+ this._log = createLogger('NoLagChat', this._options.debug);
673
+ }
674
+ // ============ Public Properties ============
675
+ /** Whether the underlying connection is established */
676
+ get connected() {
677
+ return this._client?.connected ?? false;
678
+ }
679
+ /** The local user's info (available after connect) */
680
+ get localUser() {
681
+ return this._localUser;
682
+ }
683
+ /** All currently joined rooms */
684
+ get rooms() {
685
+ return this._rooms;
686
+ }
687
+ // ============ Lifecycle ============
688
+ /**
689
+ * Connect to NoLag and set up global presence.
690
+ */
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);
784
+ }
785
+ /**
786
+ * Disconnect from NoLag and clean up all rooms.
787
+ */
788
+ disconnect() {
789
+ this._log('Disconnecting...');
790
+ // Clean up rooms
791
+ for (const name of [...this._rooms.keys()]) {
792
+ this.leaveRoom(name);
793
+ }
794
+ // Unsubscribe from lobby
795
+ this._lobby?.unsubscribe();
796
+ this._lobby = null;
797
+ // Disconnect client
798
+ this._client?.disconnect();
799
+ this._client = null;
800
+ // Clear state
801
+ this._onlineUsers.clear();
802
+ this._actorToUserId.clear();
803
+ this._localUser = null;
804
+ }
805
+ // ============ Room Management ============
806
+ /**
807
+ * Join (activate) a chat room. Deactivates the previous active room.
808
+ * If the room was pre-subscribed via the `rooms` option, activates it.
809
+ * Otherwise creates, subscribes, and activates it.
810
+ */
811
+ joinRoom(name) {
812
+ if (!this._client || !this._localUser) {
813
+ throw new Error('Not connected — call connect() first');
814
+ }
815
+ // Deactivate the current active room
816
+ if (this._activeRoom && this._activeRoom !== name) {
817
+ const prev = this._rooms.get(this._activeRoom);
818
+ if (prev)
819
+ prev._deactivate();
820
+ }
821
+ // Get or create the room
822
+ let room = this._rooms.get(name);
823
+ if (!room) {
824
+ room = this._subscribeRoom(name);
825
+ }
826
+ this._activeRoom = name;
827
+ room._activate();
828
+ return room;
829
+ }
830
+ /**
831
+ * Leave a chat room. Fully unsubscribes and removes it.
832
+ */
833
+ leaveRoom(name) {
834
+ const room = this._rooms.get(name);
835
+ if (!room)
836
+ return;
837
+ this._log('Leaving room:', name);
838
+ room._cleanup();
839
+ this._rooms.delete(name);
840
+ if (this._activeRoom === name) {
841
+ this._activeRoom = null;
842
+ }
843
+ }
844
+ /**
845
+ * Get all joined rooms.
846
+ */
847
+ getRooms() {
848
+ return Array.from(this._rooms.values());
849
+ }
850
+ // ============ Global Presence ============
851
+ /**
852
+ * Get all users currently online across all rooms.
853
+ */
854
+ getOnlineUsers() {
855
+ return Array.from(this._onlineUsers.values());
856
+ }
857
+ /**
858
+ * Update the local user's online status.
859
+ */
860
+ setStatus(status) {
861
+ if (this._localUser) {
862
+ this._localUser.status = status;
863
+ }
864
+ // Re-set presence only on the active room
865
+ if (this._activeRoom) {
866
+ const activeRoom = this._rooms.get(this._activeRoom);
867
+ if (activeRoom)
868
+ activeRoom._updateLocalPresence();
869
+ }
870
+ }
871
+ // ============ Profile ============
872
+ /**
873
+ * Update the local user's profile info (broadcast to all rooms).
874
+ */
875
+ updateProfile(updates) {
876
+ if (!this._localUser)
877
+ return;
878
+ if (updates.username !== undefined) {
879
+ this._localUser.username = updates.username;
880
+ this._options.username = updates.username;
881
+ }
882
+ if (updates.avatar !== undefined) {
883
+ this._localUser.avatar = updates.avatar;
884
+ this._options.avatar = updates.avatar;
885
+ }
886
+ if (updates.metadata !== undefined) {
887
+ this._localUser.metadata = { ...this._localUser.metadata, ...updates.metadata };
888
+ this._options.metadata = this._localUser.metadata;
889
+ }
890
+ // Re-set presence only on the active room
891
+ if (this._activeRoom) {
892
+ const activeRoom = this._rooms.get(this._activeRoom);
893
+ if (activeRoom)
894
+ activeRoom._updateLocalPresence();
895
+ }
896
+ }
897
+ // ============ Private: Room Setup ============
898
+ _subscribeRoom(name) {
899
+ if (!this._client || !this._localUser) {
900
+ throw new Error('Not connected — call connect() first');
901
+ }
902
+ this._log('Subscribing room:', name);
903
+ 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));
905
+ this._rooms.set(name, room);
906
+ room._subscribe();
907
+ return room;
908
+ }
909
+ // ============ Private: Room Presence → Active Room ============
910
+ _handleRoomPresenceJoin(data) {
911
+ if (data.actorTokenId === this._localUser?.actorTokenId)
912
+ return;
913
+ const presenceData = data.presence;
914
+ if (!presenceData?.userId)
915
+ return;
916
+ // Track as online user
917
+ const user = this._presenceToUser(data.actorTokenId, presenceData);
918
+ this._actorToUserId.set(data.actorTokenId, user.userId);
919
+ if (!this._onlineUsers.has(user.userId)) {
920
+ this._onlineUsers.set(user.userId, user);
921
+ this.emit('userOnline', user);
922
+ }
923
+ const room = this._activeRoom ? this._rooms.get(this._activeRoom) : undefined;
924
+ if (room) {
925
+ room._handlePresenceJoin(data.actorTokenId, presenceData);
926
+ }
927
+ }
928
+ _handleRoomPresenceLeave(data) {
929
+ if (data.actorTokenId === this._localUser?.actorTokenId)
930
+ return;
931
+ // Room leave ≠ offline — user may still be in another room.
932
+ // Lobby leave handles actual offline status.
933
+ const room = this._activeRoom ? this._rooms.get(this._activeRoom) : undefined;
934
+ if (room) {
935
+ room._handlePresenceLeave(data.actorTokenId);
936
+ }
937
+ }
938
+ _handleRoomPresenceUpdate(data) {
939
+ if (data.actorTokenId === this._localUser?.actorTokenId)
940
+ return;
941
+ const presenceData = data.presence;
942
+ if (!presenceData?.userId)
943
+ return;
944
+ // Update online user info if we already track them
945
+ if (this._onlineUsers.has(presenceData.userId)) {
946
+ const user = this._presenceToUser(data.actorTokenId, presenceData);
947
+ this._onlineUsers.set(user.userId, user);
948
+ this.emit('userUpdated', user);
949
+ }
950
+ const room = this._activeRoom ? this._rooms.get(this._activeRoom) : undefined;
951
+ if (room) {
952
+ room._handlePresenceUpdate(data.actorTokenId, presenceData);
953
+ }
954
+ }
955
+ // ============ 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
+ _handleLobbyJoin(event) {
986
+ const { actorId, data } = event;
987
+ if (actorId === this._localUser?.actorTokenId)
988
+ return;
989
+ const presenceData = data;
990
+ if (!presenceData.userId)
991
+ return;
992
+ const user = this._presenceToUser(actorId, presenceData);
993
+ this._actorToUserId.set(actorId, user.userId);
994
+ if (!this._onlineUsers.has(user.userId)) {
995
+ this._onlineUsers.set(user.userId, user);
996
+ this.emit('userOnline', user);
997
+ }
998
+ }
999
+ _handleLobbyLeave(event) {
1000
+ const { actorId, data } = event;
1001
+ if (actorId === this._localUser?.actorTokenId)
1002
+ return;
1003
+ const presenceData = data;
1004
+ const userId = presenceData?.userId
1005
+ || this._actorToUserId.get(actorId)
1006
+ || this._findUserIdByActorId(actorId);
1007
+ if (userId) {
1008
+ const user = this._onlineUsers.get(userId);
1009
+ if (user) {
1010
+ this._onlineUsers.delete(userId);
1011
+ this._actorToUserId.delete(actorId);
1012
+ this.emit('userOffline', user);
1013
+ }
1014
+ }
1015
+ }
1016
+ _handleLobbyUpdate(event) {
1017
+ const { actorId, data } = event;
1018
+ if (actorId === this._localUser?.actorTokenId)
1019
+ return;
1020
+ const presenceData = data;
1021
+ if (!presenceData.userId)
1022
+ return;
1023
+ const user = this._presenceToUser(actorId, presenceData);
1024
+ this._onlineUsers.set(user.userId, user);
1025
+ this.emit('userUpdated', user);
1026
+ }
1027
+ _hydrateOnlineUsers(state) {
1028
+ // state = { roomId: { actorId: actorRecord } }
1029
+ // actorRecord from the server is { actorTokenId, presence: ChatPresenceData, joinedAt }
1030
+ for (const roomId of Object.keys(state)) {
1031
+ const roomPresence = state[roomId];
1032
+ for (const actorId of Object.keys(roomPresence)) {
1033
+ if (actorId === this._localUser?.actorTokenId)
1034
+ continue;
1035
+ const raw = roomPresence[actorId];
1036
+ // Server returns full actor records with presence nested under .presence
1037
+ 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);
1044
+ }
1045
+ }
1046
+ }
1047
+ }
1048
+ }
1049
+ // ============ Private: Helpers ============
1050
+ _presenceToUser(actorTokenId, data) {
1051
+ return {
1052
+ userId: data.userId,
1053
+ actorTokenId,
1054
+ username: data.username,
1055
+ avatar: data.avatar,
1056
+ metadata: data.metadata,
1057
+ status: data.status || 'online',
1058
+ joinedAt: Date.now(),
1059
+ isLocal: false,
1060
+ };
1061
+ }
1062
+ _findUserIdByActorId(actorTokenId) {
1063
+ for (const user of this._onlineUsers.values()) {
1064
+ if (user.actorTokenId === actorTokenId)
1065
+ return user.userId;
1066
+ }
1067
+ return undefined;
1068
+ }
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
+ }
1087
+
1088
+ exports.ChatRoom = ChatRoom;
1089
+ exports.EventEmitter = EventEmitter;
1090
+ exports.NoLagChat = NoLagChat;
1091
+ //# sourceMappingURL=index.cjs.map