@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/README.md +41 -9
- package/dist/ChatRoom.d.ts +35 -2
- package/dist/MessageStore.d.ts +5 -0
- package/dist/MessageStream.d.ts +56 -0
- package/dist/NoLagChat.d.ts +72 -16
- package/dist/browser.js +1 -1
- package/dist/browser.js.map +1 -1
- package/dist/constants.d.ts +6 -0
- package/dist/index.cjs +594 -202
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.mjs +594 -202
- package/dist/index.mjs.map +1 -1
- package/dist/types.d.ts +55 -9
- package/dist/utils.d.ts +4 -0
- package/package.json +5 -4
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
|
-
|
|
498
|
-
|
|
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.
|
|
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
|
-
|
|
575
|
-
|
|
576
|
-
this.
|
|
577
|
-
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
*
|
|
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
|
-
|
|
690
|
-
this.
|
|
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
|
-
*
|
|
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
|
-
|
|
787
|
-
this.
|
|
788
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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:
|
|
896
|
-
|
|
897
|
-
if (
|
|
898
|
-
throw new Error('
|
|
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
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
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
|
-
|
|
1038
|
-
|
|
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 };
|