@nolag/feed 0.1.2 → 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 +66 -18
- package/dist/FeedChannel.d.ts +14 -1
- package/dist/NoLagFeed.d.ts +121 -10
- package/dist/browser.js +1 -1
- package/dist/browser.js.map +1 -1
- package/dist/constants.d.ts +2 -0
- package/dist/index.cjs +549 -133
- package/dist/index.cjs.map +1 -1
- package/dist/index.mjs +549 -133
- package/dist/index.mjs.map +1 -1
- package/dist/types.d.ts +10 -7
- package/dist/utils.d.ts +4 -0
- package/package.json +3 -3
package/dist/index.mjs
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import { NoLag } from '@nolag/js-sdk';
|
|
2
|
-
|
|
3
1
|
class EventEmitter {
|
|
4
2
|
constructor() {
|
|
5
3
|
this._handlers = new Map();
|
|
@@ -273,6 +271,29 @@ function createLogger(prefix, enabled) {
|
|
|
273
271
|
}
|
|
274
272
|
return (...args) => { console.log(`[${prefix}]`, ...args); };
|
|
275
273
|
}
|
|
274
|
+
// ============ Wrapper registry ============
|
|
275
|
+
// One wrapper instance per (client, appName): two wrappers sharing an app on
|
|
276
|
+
// one connection would collide on topics, presence and the online lobby.
|
|
277
|
+
// Warn (not throw): HMR and tests legitimately construct before disposing.
|
|
278
|
+
const wrapperRegistry = new WeakMap();
|
|
279
|
+
/** Register a wrapper against a client + appName; warns on collision. */
|
|
280
|
+
function registerWrapper(client, appName, wrapperName) {
|
|
281
|
+
let apps = wrapperRegistry.get(client);
|
|
282
|
+
if (!apps) {
|
|
283
|
+
apps = new Map();
|
|
284
|
+
wrapperRegistry.set(client, apps);
|
|
285
|
+
}
|
|
286
|
+
const existing = apps.get(appName);
|
|
287
|
+
if (existing) {
|
|
288
|
+
console.warn(`[${wrapperName}] Another wrapper (${existing}) is already attached to this client for app "${appName}". ` +
|
|
289
|
+
`Use one wrapper per (client, app) — detach the other instance first.`);
|
|
290
|
+
}
|
|
291
|
+
apps.set(appName, wrapperName);
|
|
292
|
+
}
|
|
293
|
+
/** Release a wrapper's (client, appName) registration on detach. */
|
|
294
|
+
function releaseWrapper(client, appName) {
|
|
295
|
+
wrapperRegistry.get(client)?.delete(appName);
|
|
296
|
+
}
|
|
276
297
|
|
|
277
298
|
const DEFAULT_APP_NAME = 'feed';
|
|
278
299
|
const DEFAULT_MAX_POST_CACHE = 200;
|
|
@@ -281,18 +302,33 @@ const TOPIC_POSTS = 'posts';
|
|
|
281
302
|
const TOPIC_REACTIONS = 'reactions';
|
|
282
303
|
const TOPIC_COMMENTS = 'comments';
|
|
283
304
|
const LOBBY_ID = 'online';
|
|
305
|
+
/** Delay before the post-setup lobby presence refetch (catches simultaneous joiners) */
|
|
306
|
+
const LOBBY_REFRESH_DELAY_MS = 2000;
|
|
284
307
|
|
|
308
|
+
/**
|
|
309
|
+
* FeedChannel — a single feed channel with posts, comments, reactions, and
|
|
310
|
+
* presence.
|
|
311
|
+
*
|
|
312
|
+
* Created via `NoLagFeed.joinChannel(name)`. Do not instantiate directly.
|
|
313
|
+
*/
|
|
285
314
|
class FeedChannel extends EventEmitter {
|
|
286
|
-
|
|
315
|
+
/** @internal */
|
|
316
|
+
constructor(name, roomContext, localUser, options, log, isConnected) {
|
|
287
317
|
super();
|
|
288
318
|
this._comments = new Map();
|
|
289
319
|
this._unreadCount = 0;
|
|
290
320
|
this._active = false;
|
|
321
|
+
// Stored topic handler refs — cleanup removes exactly these, never all
|
|
322
|
+
// handlers for a topic (the client may be shared with other consumers).
|
|
323
|
+
this._onPostsRef = null;
|
|
324
|
+
this._onReactionsRef = null;
|
|
325
|
+
this._onCommentsRef = null;
|
|
291
326
|
this.name = name;
|
|
292
327
|
this._roomContext = roomContext;
|
|
293
328
|
this._localUser = localUser;
|
|
294
329
|
this._options = options;
|
|
295
330
|
this._log = log;
|
|
331
|
+
this._isConnected = isConnected;
|
|
296
332
|
this._presenceManager = new PresenceManager(localUser.actorTokenId);
|
|
297
333
|
this._postStore = new PostStore(options.maxPostCache);
|
|
298
334
|
this._reactionManager = new ReactionManager();
|
|
@@ -358,13 +394,27 @@ class FeedChannel extends EventEmitter {
|
|
|
358
394
|
}
|
|
359
395
|
}
|
|
360
396
|
getUsers() { return this._presenceManager.getAll(); }
|
|
397
|
+
/** @internal Subscribe to post/reaction/comment topics and attach listeners (all channels) */
|
|
361
398
|
_subscribe() {
|
|
399
|
+
this._log('Channel subscribe:', this.name);
|
|
362
400
|
this._roomContext.subscribe(TOPIC_POSTS);
|
|
363
401
|
this._roomContext.subscribe(TOPIC_REACTIONS);
|
|
364
402
|
this._roomContext.subscribe(TOPIC_COMMENTS);
|
|
365
|
-
|
|
366
|
-
this.
|
|
367
|
-
|
|
403
|
+
// Listen for posts (refs stored for handler-specific removal)
|
|
404
|
+
this._onPostsRef = (data, meta) => {
|
|
405
|
+
this._handleIncomingPost(data, meta);
|
|
406
|
+
};
|
|
407
|
+
this._roomContext.on(TOPIC_POSTS, this._onPostsRef);
|
|
408
|
+
// Listen for reactions
|
|
409
|
+
this._onReactionsRef = (data) => {
|
|
410
|
+
this._handleIncomingReaction(data);
|
|
411
|
+
};
|
|
412
|
+
this._roomContext.on(TOPIC_REACTIONS, this._onReactionsRef);
|
|
413
|
+
// Listen for comments
|
|
414
|
+
this._onCommentsRef = (data, meta) => {
|
|
415
|
+
this._handleIncomingComment(data, meta);
|
|
416
|
+
};
|
|
417
|
+
this._roomContext.on(TOPIC_COMMENTS, this._onCommentsRef);
|
|
368
418
|
}
|
|
369
419
|
_activate() {
|
|
370
420
|
this._active = true;
|
|
@@ -397,13 +447,27 @@ class FeedChannel extends EventEmitter {
|
|
|
397
447
|
_handleReplayStart(count) { this.emit('replayStart', { count }); }
|
|
398
448
|
_handleReplayEnd(replayed) { this.emit('replayEnd', { replayed }); }
|
|
399
449
|
_updateLocalPresence() { this._setPresence(); }
|
|
450
|
+
/** @internal Unsubscribe and clean up */
|
|
400
451
|
_cleanup() {
|
|
401
|
-
this.
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
this.
|
|
405
|
-
|
|
406
|
-
|
|
452
|
+
this._log('Channel cleanup:', this.name);
|
|
453
|
+
// Server unsubscribes need a live socket; skip when disconnected
|
|
454
|
+
// (best-effort — the core would no-op with an error callback anyway).
|
|
455
|
+
if (this._isConnected()) {
|
|
456
|
+
this._roomContext.unsubscribe(TOPIC_POSTS);
|
|
457
|
+
this._roomContext.unsubscribe(TOPIC_REACTIONS);
|
|
458
|
+
this._roomContext.unsubscribe(TOPIC_COMMENTS);
|
|
459
|
+
}
|
|
460
|
+
// Handler-specific removal only: the client may be shared, and a bare
|
|
461
|
+
// off(topic) would strip other consumers' handlers too.
|
|
462
|
+
if (this._onPostsRef)
|
|
463
|
+
this._roomContext.off(TOPIC_POSTS, this._onPostsRef);
|
|
464
|
+
if (this._onReactionsRef)
|
|
465
|
+
this._roomContext.off(TOPIC_REACTIONS, this._onReactionsRef);
|
|
466
|
+
if (this._onCommentsRef)
|
|
467
|
+
this._roomContext.off(TOPIC_COMMENTS, this._onCommentsRef);
|
|
468
|
+
this._onPostsRef = null;
|
|
469
|
+
this._onReactionsRef = null;
|
|
470
|
+
this._onCommentsRef = null;
|
|
407
471
|
this._postStore.clear();
|
|
408
472
|
this._reactionManager.clear();
|
|
409
473
|
this._comments.clear();
|
|
@@ -465,169 +529,470 @@ class FeedChannel extends EventEmitter {
|
|
|
465
529
|
this._roomContext.setPresence({
|
|
466
530
|
userId: this._localUser.userId, username: this._localUser.username,
|
|
467
531
|
avatar: this._localUser.avatar, metadata: this._localUser.metadata,
|
|
532
|
+
// Scope tag: on a shared client, other apps' wrappers filter our
|
|
533
|
+
// presence out by this (and we filter theirs).
|
|
534
|
+
__scope: this._options.appName,
|
|
468
535
|
});
|
|
469
536
|
}
|
|
470
537
|
}
|
|
471
538
|
|
|
539
|
+
/**
|
|
540
|
+
* NoLagFeed — high-level activity-feed SDK built on @nolag/js-sdk.
|
|
541
|
+
*
|
|
542
|
+
* Provides multi-channel feeds, posts, likes, comments, presence (who's
|
|
543
|
+
* online), replay, and user mapping — all framework-agnostic via events.
|
|
544
|
+
*
|
|
545
|
+
* The wrapper NEVER manages the connection. The app owns one core NoLag
|
|
546
|
+
* client (shared by any number of wrappers on distinct apps) and the
|
|
547
|
+
* wrapper attaches to it at construction and releases it via `detach()`.
|
|
548
|
+
*
|
|
549
|
+
* @example
|
|
550
|
+
* ```typescript
|
|
551
|
+
* import { NoLag } from '@nolag/js-sdk';
|
|
552
|
+
* import { NoLagFeed } from '@nolag/feed';
|
|
553
|
+
*
|
|
554
|
+
* const client = NoLag(async () => (await (await fetch('/api/nolag-token')).json()).token);
|
|
555
|
+
* const feed = new NoLagFeed({ client, appName: 'my-feed', username: 'Alice' });
|
|
556
|
+
*
|
|
557
|
+
* feed.on('userOnline', (user) => console.log(user.username, 'is online'));
|
|
558
|
+
*
|
|
559
|
+
* await client.connect(); // the app owns the connection
|
|
560
|
+
* await feed.ready(); // wrapper setup done (identity, lobby, channels)
|
|
561
|
+
*
|
|
562
|
+
* const channel = feed.joinChannel('general');
|
|
563
|
+
* channel.on('postCreated', (post) => console.log(post.username + ':', post.content));
|
|
564
|
+
* channel.createPost({ content: 'Hello!' });
|
|
565
|
+
*
|
|
566
|
+
* feed.detach(); // wrapper releases its handlers and topics
|
|
567
|
+
* client.disconnect(); // the app closes the socket
|
|
568
|
+
* ```
|
|
569
|
+
*/
|
|
472
570
|
class NoLagFeed extends EventEmitter {
|
|
473
|
-
constructor(
|
|
571
|
+
constructor(options) {
|
|
474
572
|
super();
|
|
475
|
-
this._client = null;
|
|
476
573
|
this._localUser = null;
|
|
477
574
|
this._channels = new Map();
|
|
478
575
|
this._lobby = null;
|
|
479
576
|
this._onlineUsers = new Map();
|
|
480
577
|
this._actorToUserId = new Map();
|
|
481
578
|
this._activeChannel = null;
|
|
482
|
-
|
|
579
|
+
// Lifecycle: one setup run per connection epoch; detach is terminal.
|
|
580
|
+
this._epoch = 0;
|
|
581
|
+
this._detached = false;
|
|
582
|
+
this._isReady = false;
|
|
583
|
+
this._lobbyRefreshTimer = null;
|
|
584
|
+
// Stored client handler refs. INVARIANT: every client.on() below has a
|
|
585
|
+
// matching client.off() in detach() — never bare off(event), never inline
|
|
586
|
+
// closures on the client.
|
|
587
|
+
this._onConnectRef = () => this._onConnect();
|
|
588
|
+
this._onDisconnectRef = (reason) => {
|
|
589
|
+
this._log('Disconnected:', reason);
|
|
590
|
+
this.emit('disconnected', reason);
|
|
591
|
+
};
|
|
592
|
+
this._onReconnectRef = () => {
|
|
593
|
+
this._log('Reconnecting...');
|
|
594
|
+
this.emit('reconnecting');
|
|
595
|
+
};
|
|
596
|
+
this._onErrorRef = (error) => {
|
|
597
|
+
this._log('Error:', error);
|
|
598
|
+
this.emit('error', error);
|
|
599
|
+
};
|
|
600
|
+
this._onReplayStartRef = (data) => {
|
|
601
|
+
const event = data;
|
|
602
|
+
for (const channel of this._channels.values()) {
|
|
603
|
+
channel._handleReplayStart(event.count);
|
|
604
|
+
}
|
|
605
|
+
};
|
|
606
|
+
this._onReplayEndRef = (data) => {
|
|
607
|
+
const event = data;
|
|
608
|
+
for (const channel of this._channels.values()) {
|
|
609
|
+
channel._handleReplayEnd(event.replayed);
|
|
610
|
+
}
|
|
611
|
+
};
|
|
612
|
+
this._onPresenceJoinRef = (data) => this._handleRoomPresenceJoin(data);
|
|
613
|
+
this._onPresenceLeaveRef = (data) => this._handleRoomPresenceLeave(data);
|
|
614
|
+
this._onPresenceUpdateRef = (data) => this._handleRoomPresenceUpdate(data);
|
|
615
|
+
this._onLobbyJoinRef = (data) => this._handleLobbyJoin(data);
|
|
616
|
+
this._onLobbyLeaveRef = (data) => this._handleLobbyLeave(data);
|
|
617
|
+
this._onLobbyUpdateRef = (data) => this._handleLobbyUpdate(data);
|
|
618
|
+
if (!options?.client) {
|
|
619
|
+
throw new TypeError('NoLagFeed requires an injected NoLag client: new NoLagFeed({ client, username, ... })');
|
|
620
|
+
}
|
|
621
|
+
this._client = options.client;
|
|
483
622
|
this._userId = generateId();
|
|
484
623
|
this._options = {
|
|
485
|
-
username: options.username,
|
|
486
|
-
|
|
624
|
+
username: options.username,
|
|
625
|
+
avatar: options.avatar,
|
|
626
|
+
metadata: options.metadata,
|
|
627
|
+
appName: options.appName ?? DEFAULT_APP_NAME,
|
|
487
628
|
maxPostCache: options.maxPostCache ?? DEFAULT_MAX_POST_CACHE,
|
|
488
629
|
maxCommentCache: options.maxCommentCache ?? DEFAULT_MAX_COMMENT_CACHE,
|
|
489
|
-
debug: options.debug ?? false,
|
|
630
|
+
debug: options.debug ?? false,
|
|
631
|
+
channels: options.channels ?? [],
|
|
490
632
|
};
|
|
491
633
|
this._log = createLogger('NoLagFeed', this._options.debug);
|
|
634
|
+
this._readyPromise = new Promise((resolve, reject) => {
|
|
635
|
+
this._readyResolve = resolve;
|
|
636
|
+
this._readyReject = reject;
|
|
637
|
+
});
|
|
638
|
+
// ready() rejection is only meaningful to callers that await it
|
|
639
|
+
this._readyPromise.catch(() => { });
|
|
640
|
+
registerWrapper(this._client, this._options.appName, 'NoLagFeed');
|
|
641
|
+
// Construction = attach: wire everything now, with stored refs.
|
|
642
|
+
this._client.on('connect', this._onConnectRef);
|
|
643
|
+
this._client.on('disconnect', this._onDisconnectRef);
|
|
644
|
+
this._client.on('reconnect', this._onReconnectRef);
|
|
645
|
+
this._client.on('error', this._onErrorRef);
|
|
646
|
+
this._client.on('replay:start', this._onReplayStartRef);
|
|
647
|
+
this._client.on('replay:end', this._onReplayEndRef);
|
|
648
|
+
this._client.on('presence:join', this._onPresenceJoinRef);
|
|
649
|
+
this._client.on('presence:leave', this._onPresenceLeaveRef);
|
|
650
|
+
this._client.on('presence:update', this._onPresenceUpdateRef);
|
|
651
|
+
this._client.on('lobbyPresence:join', this._onLobbyJoinRef);
|
|
652
|
+
this._client.on('lobbyPresence:leave', this._onLobbyLeaveRef);
|
|
653
|
+
this._client.on('lobbyPresence:update', this._onLobbyUpdateRef);
|
|
654
|
+
// Attach-to-connected: if the client is already authenticated, run setup.
|
|
655
|
+
// The microtask lets the caller wire wrapper event handlers synchronously
|
|
656
|
+
// first; a racing real 'connect' event wins via the epoch guard.
|
|
657
|
+
queueMicrotask(() => {
|
|
658
|
+
if (this._epoch === 0 && !this._detached && this._client.connected) {
|
|
659
|
+
this._onConnect();
|
|
660
|
+
}
|
|
661
|
+
});
|
|
492
662
|
}
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
get
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
this._client
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
this.
|
|
663
|
+
// ============ Public Properties ============
|
|
664
|
+
/** Whether the underlying connection is established (connected ≠ ready) */
|
|
665
|
+
get connected() {
|
|
666
|
+
return !this._detached && this._client.connected;
|
|
667
|
+
}
|
|
668
|
+
/** The injected core client (owned by the app, not the wrapper) */
|
|
669
|
+
get client() {
|
|
670
|
+
return this._client;
|
|
671
|
+
}
|
|
672
|
+
/** The local user's info (available after ready) */
|
|
673
|
+
get localUser() {
|
|
674
|
+
return this._localUser;
|
|
675
|
+
}
|
|
676
|
+
/** All currently joined channels */
|
|
677
|
+
get channels() {
|
|
678
|
+
return this._channels;
|
|
679
|
+
}
|
|
680
|
+
// ============ Lifecycle ============
|
|
681
|
+
/**
|
|
682
|
+
* Resolves once the wrapper's first setup completed (identity, lobby and
|
|
683
|
+
* configured channels ready — equivalently, once 'connected' has fired).
|
|
684
|
+
* Rejects only if detach() is called before that. Client auth failures
|
|
685
|
+
* surface via the app's own `await client.connect()`, not here.
|
|
686
|
+
*/
|
|
687
|
+
ready() {
|
|
688
|
+
return this._readyPromise;
|
|
689
|
+
}
|
|
690
|
+
/**
|
|
691
|
+
* Detach from the client: remove every handler this wrapper added,
|
|
692
|
+
* unsubscribe its topics and lobby (when connected), clear state.
|
|
693
|
+
* Terminal and idempotent; never touches the socket. To use the feed again,
|
|
694
|
+
* construct a new instance.
|
|
695
|
+
*/
|
|
696
|
+
detach() {
|
|
697
|
+
if (this._detached)
|
|
698
|
+
return;
|
|
699
|
+
this._log('Detaching...');
|
|
700
|
+
this._detached = true;
|
|
701
|
+
this._epoch++; // aborts any in-flight setup at its next checkpoint
|
|
702
|
+
if (this._lobbyRefreshTimer) {
|
|
703
|
+
clearTimeout(this._lobbyRefreshTimer);
|
|
704
|
+
this._lobbyRefreshTimer = null;
|
|
705
|
+
}
|
|
706
|
+
// Remove all client handlers by stored ref
|
|
707
|
+
this._client.off('connect', this._onConnectRef);
|
|
708
|
+
this._client.off('disconnect', this._onDisconnectRef);
|
|
709
|
+
this._client.off('reconnect', this._onReconnectRef);
|
|
710
|
+
this._client.off('error', this._onErrorRef);
|
|
711
|
+
this._client.off('replay:start', this._onReplayStartRef);
|
|
712
|
+
this._client.off('replay:end', this._onReplayEndRef);
|
|
713
|
+
this._client.off('presence:join', this._onPresenceJoinRef);
|
|
714
|
+
this._client.off('presence:leave', this._onPresenceLeaveRef);
|
|
715
|
+
this._client.off('presence:update', this._onPresenceUpdateRef);
|
|
716
|
+
this._client.off('lobbyPresence:join', this._onLobbyJoinRef);
|
|
717
|
+
this._client.off('lobbyPresence:leave', this._onLobbyLeaveRef);
|
|
718
|
+
this._client.off('lobbyPresence:update', this._onLobbyUpdateRef);
|
|
719
|
+
// Channels: handler-specific off + connected-gated server unsubscribe
|
|
720
|
+
for (const name of [...this._channels.keys()]) {
|
|
721
|
+
this._channels.get(name)._cleanup();
|
|
722
|
+
this._channels.delete(name);
|
|
723
|
+
}
|
|
724
|
+
this._activeChannel = null;
|
|
725
|
+
// Lobby: server unsubscribe is best-effort and needs a live socket
|
|
726
|
+
if (this._lobby && this._client.connected) {
|
|
727
|
+
try {
|
|
728
|
+
this._lobby.unsubscribe();
|
|
729
|
+
}
|
|
730
|
+
catch {
|
|
731
|
+
/* best-effort */
|
|
732
|
+
}
|
|
733
|
+
}
|
|
531
734
|
this._lobby = null;
|
|
532
|
-
this._client?.disconnect();
|
|
533
|
-
this._client = null;
|
|
534
735
|
this._onlineUsers.clear();
|
|
535
736
|
this._actorToUserId.clear();
|
|
536
737
|
this._localUser = null;
|
|
738
|
+
releaseWrapper(this._client, this._options.appName);
|
|
739
|
+
if (!this._isReady) {
|
|
740
|
+
this._readyReject(new Error('NoLagFeed detached before ready'));
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
// ============ Private: Epoch Setup ============
|
|
744
|
+
_onConnect() {
|
|
745
|
+
this._epoch++;
|
|
746
|
+
void this._runSetup(this._epoch);
|
|
537
747
|
}
|
|
748
|
+
/**
|
|
749
|
+
* One setup pass per connection epoch. Serves both initial setup (epoch 1)
|
|
750
|
+
* and reconnect restore (epoch > 1). Aborts silently whenever a newer
|
|
751
|
+
* epoch started or the wrapper detached — checked after every await.
|
|
752
|
+
*/
|
|
753
|
+
async _runSetup(epoch) {
|
|
754
|
+
const stale = () => epoch !== this._epoch || this._detached;
|
|
755
|
+
this._log(this._isReady ? 'Restoring after reconnect...' : 'Setting up...');
|
|
756
|
+
// Identity (client.actorId is guaranteed post-auth)
|
|
757
|
+
if (!this._localUser) {
|
|
758
|
+
this._localUser = {
|
|
759
|
+
userId: this._userId,
|
|
760
|
+
actorTokenId: this._client.actorId,
|
|
761
|
+
username: this._options.username,
|
|
762
|
+
avatar: this._options.avatar,
|
|
763
|
+
metadata: this._options.metadata,
|
|
764
|
+
joinedAt: Date.now(),
|
|
765
|
+
isLocal: true,
|
|
766
|
+
};
|
|
767
|
+
this._log('Local user:', this._localUser.userId, '→', this._localUser.actorTokenId);
|
|
768
|
+
}
|
|
769
|
+
else {
|
|
770
|
+
this._localUser.actorTokenId = this._client.actorId;
|
|
771
|
+
}
|
|
772
|
+
// Lobby: subscribe every epoch (idempotent server-side) and diff-hydrate
|
|
773
|
+
// from the returned snapshot — one path for setup and restore.
|
|
774
|
+
if (!this._lobby) {
|
|
775
|
+
this._lobby = this._client.setApp(this._options.appName).setLobby(LOBBY_ID);
|
|
776
|
+
}
|
|
777
|
+
try {
|
|
778
|
+
const state = await this._lobby.subscribe();
|
|
779
|
+
if (stale())
|
|
780
|
+
return;
|
|
781
|
+
this._diffHydrateOnlineUsers(state);
|
|
782
|
+
this._log('Lobby subscribed, online users:', this._onlineUsers.size);
|
|
783
|
+
}
|
|
784
|
+
catch (err) {
|
|
785
|
+
if (stale())
|
|
786
|
+
return;
|
|
787
|
+
this._log('Lobby subscription failed:', err);
|
|
788
|
+
}
|
|
789
|
+
if (!this._isReady) {
|
|
790
|
+
// First successful setup: pre-subscribe configured channels
|
|
791
|
+
// (posts only, no presence)
|
|
792
|
+
for (const channelName of this._options.channels) {
|
|
793
|
+
this._subscribeChannelInternal(channelName);
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
else if (this._activeChannel) {
|
|
797
|
+
// Server auto-restored topic subscriptions; only channel-scoped presence
|
|
798
|
+
// needs re-applying (the core does not restore it).
|
|
799
|
+
this._channels.get(this._activeChannel)?._updateLocalPresence();
|
|
800
|
+
}
|
|
801
|
+
if (stale())
|
|
802
|
+
return;
|
|
803
|
+
// Ready keys on the first setup that COMPLETES, not on epoch 1: an
|
|
804
|
+
// epoch aborted by a racing reconnect must not strand ready().
|
|
805
|
+
if (!this._isReady) {
|
|
806
|
+
this._isReady = true;
|
|
807
|
+
this._readyResolve();
|
|
808
|
+
this.emit('connected');
|
|
809
|
+
}
|
|
810
|
+
else {
|
|
811
|
+
this.emit('reconnected');
|
|
812
|
+
}
|
|
813
|
+
// Deferred lobby refetch: catches users who joined during the setup
|
|
814
|
+
// window (e.g. simultaneous multi-tab connects).
|
|
815
|
+
this._scheduleLobbyRefresh(epoch);
|
|
816
|
+
}
|
|
817
|
+
_scheduleLobbyRefresh(epoch) {
|
|
818
|
+
if (this._lobbyRefreshTimer)
|
|
819
|
+
clearTimeout(this._lobbyRefreshTimer);
|
|
820
|
+
this._lobbyRefreshTimer = setTimeout(() => {
|
|
821
|
+
this._lobbyRefreshTimer = null;
|
|
822
|
+
if (epoch !== this._epoch || this._detached || !this._client.connected || !this._lobby) {
|
|
823
|
+
return;
|
|
824
|
+
}
|
|
825
|
+
this._lobby
|
|
826
|
+
.fetchPresence()
|
|
827
|
+
.then((state) => {
|
|
828
|
+
if (epoch !== this._epoch || this._detached)
|
|
829
|
+
return;
|
|
830
|
+
this._diffHydrateOnlineUsers(state);
|
|
831
|
+
})
|
|
832
|
+
.catch(() => {
|
|
833
|
+
/* best-effort */
|
|
834
|
+
});
|
|
835
|
+
}, LOBBY_REFRESH_DELAY_MS);
|
|
836
|
+
}
|
|
837
|
+
// ============ Channel Management ============
|
|
838
|
+
/**
|
|
839
|
+
* Join (activate) a feed channel. Deactivates the previous active channel.
|
|
840
|
+
* If the channel was pre-subscribed via the `channels` option, activates it.
|
|
841
|
+
* Otherwise creates, subscribes, and activates it.
|
|
842
|
+
*/
|
|
538
843
|
joinChannel(name) {
|
|
539
|
-
|
|
540
|
-
|
|
844
|
+
this._assertUsable();
|
|
845
|
+
// Deactivate the current active channel
|
|
541
846
|
if (this._activeChannel && this._activeChannel !== name) {
|
|
542
|
-
this._channels.get(this._activeChannel)
|
|
847
|
+
const prev = this._channels.get(this._activeChannel);
|
|
848
|
+
if (prev)
|
|
849
|
+
prev._deactivate();
|
|
850
|
+
}
|
|
851
|
+
// Get or create the channel
|
|
852
|
+
let channel = this._channels.get(name);
|
|
853
|
+
if (!channel) {
|
|
854
|
+
channel = this._subscribeChannelInternal(name);
|
|
543
855
|
}
|
|
544
|
-
let ch = this._channels.get(name);
|
|
545
|
-
if (!ch)
|
|
546
|
-
ch = this._subscribeChannel(name);
|
|
547
856
|
this._activeChannel = name;
|
|
548
|
-
|
|
549
|
-
return
|
|
857
|
+
channel._activate();
|
|
858
|
+
return channel;
|
|
550
859
|
}
|
|
860
|
+
/**
|
|
861
|
+
* Leave a feed channel. Fully unsubscribes and removes it.
|
|
862
|
+
*/
|
|
551
863
|
leaveChannel(name) {
|
|
552
|
-
const
|
|
553
|
-
if (!
|
|
864
|
+
const channel = this._channels.get(name);
|
|
865
|
+
if (!channel)
|
|
554
866
|
return;
|
|
555
|
-
|
|
867
|
+
this._log('Leaving channel:', name);
|
|
868
|
+
channel._cleanup();
|
|
556
869
|
this._channels.delete(name);
|
|
557
|
-
if (this._activeChannel === name)
|
|
870
|
+
if (this._activeChannel === name) {
|
|
558
871
|
this._activeChannel = null;
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
/**
|
|
875
|
+
* Get all joined channels.
|
|
876
|
+
*/
|
|
877
|
+
getChannels() {
|
|
878
|
+
return Array.from(this._channels.values());
|
|
879
|
+
}
|
|
880
|
+
// ============ Global Presence ============
|
|
881
|
+
/**
|
|
882
|
+
* Get all users currently online across all channels.
|
|
883
|
+
*/
|
|
884
|
+
getOnlineUsers() {
|
|
885
|
+
return Array.from(this._onlineUsers.values());
|
|
886
|
+
}
|
|
887
|
+
// ============ Profile ============
|
|
888
|
+
/**
|
|
889
|
+
* Update the local user's profile info (broadcast to the active channel).
|
|
890
|
+
*/
|
|
891
|
+
updateProfile(updates) {
|
|
892
|
+
if (!this._localUser)
|
|
893
|
+
return;
|
|
894
|
+
if (updates.username !== undefined) {
|
|
895
|
+
this._localUser.username = updates.username;
|
|
896
|
+
this._options.username = updates.username;
|
|
897
|
+
}
|
|
898
|
+
if (updates.avatar !== undefined) {
|
|
899
|
+
this._localUser.avatar = updates.avatar;
|
|
900
|
+
this._options.avatar = updates.avatar;
|
|
901
|
+
}
|
|
902
|
+
if (updates.metadata !== undefined) {
|
|
903
|
+
this._localUser.metadata = { ...this._localUser.metadata, ...updates.metadata };
|
|
904
|
+
this._options.metadata = this._localUser.metadata;
|
|
905
|
+
}
|
|
906
|
+
// Re-set presence only on the active channel
|
|
907
|
+
if (this._activeChannel) {
|
|
908
|
+
const activeChannel = this._channels.get(this._activeChannel);
|
|
909
|
+
if (activeChannel)
|
|
910
|
+
activeChannel._updateLocalPresence();
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
// ============ Private: Guards ============
|
|
914
|
+
_assertUsable() {
|
|
915
|
+
if (this._detached) {
|
|
916
|
+
throw new Error('NoLagFeed has been detached — construct a new instance');
|
|
917
|
+
}
|
|
918
|
+
if (!this._isReady || !this._localUser) {
|
|
919
|
+
throw new Error('NoLagFeed not ready — await ready() or the "connected" event');
|
|
920
|
+
}
|
|
559
921
|
}
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
throw new Error('Not connected');
|
|
922
|
+
// ============ Private: Channel Setup ============
|
|
923
|
+
_subscribeChannelInternal(name) {
|
|
924
|
+
this._log('Subscribing channel:', name);
|
|
564
925
|
const roomContext = this._client.setApp(this._options.appName).setRoom(name);
|
|
565
|
-
const
|
|
566
|
-
this._channels.set(name,
|
|
567
|
-
|
|
568
|
-
return
|
|
926
|
+
const channel = new FeedChannel(name, roomContext, this._localUser, this._options, createLogger(`FeedChannel:${name}`, this._options.debug), () => this._client.connected);
|
|
927
|
+
this._channels.set(name, channel);
|
|
928
|
+
channel._subscribe();
|
|
929
|
+
return channel;
|
|
569
930
|
}
|
|
931
|
+
// ============ Private: Scope Filtering ============
|
|
932
|
+
/**
|
|
933
|
+
* On a shared client, presence events from other apps' wrappers arrive on
|
|
934
|
+
* the same connection-level events. Wrappers stamp their presence with a
|
|
935
|
+
* `__scope` (their appName); a mismatched tag means another app's data.
|
|
936
|
+
* Untagged presence is accepted (older peers in this same app).
|
|
937
|
+
*/
|
|
938
|
+
_foreignScope(data) {
|
|
939
|
+
const scope = data?.__scope;
|
|
940
|
+
return typeof scope === 'string' && scope !== this._options.appName;
|
|
941
|
+
}
|
|
942
|
+
// ============ Private: Channel Presence → Active Channel ============
|
|
570
943
|
_handleRoomPresenceJoin(data) {
|
|
571
944
|
if (data.actorTokenId === this._localUser?.actorTokenId)
|
|
572
945
|
return;
|
|
573
|
-
const
|
|
574
|
-
if (!
|
|
946
|
+
const presenceData = data.presence;
|
|
947
|
+
if (!presenceData?.userId || this._foreignScope(presenceData))
|
|
575
948
|
return;
|
|
576
|
-
|
|
949
|
+
// Track as online user
|
|
950
|
+
const user = this._presenceToUser(data.actorTokenId, presenceData);
|
|
577
951
|
this._actorToUserId.set(data.actorTokenId, user.userId);
|
|
578
952
|
if (!this._onlineUsers.has(user.userId)) {
|
|
579
953
|
this._onlineUsers.set(user.userId, user);
|
|
580
954
|
this.emit('userOnline', user);
|
|
581
955
|
}
|
|
582
|
-
const
|
|
583
|
-
if (
|
|
584
|
-
|
|
956
|
+
const channel = this._activeChannel ? this._channels.get(this._activeChannel) : undefined;
|
|
957
|
+
if (channel) {
|
|
958
|
+
channel._handlePresenceJoin(data.actorTokenId, presenceData);
|
|
959
|
+
}
|
|
585
960
|
}
|
|
586
961
|
_handleRoomPresenceLeave(data) {
|
|
587
962
|
if (data.actorTokenId === this._localUser?.actorTokenId)
|
|
588
963
|
return;
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
964
|
+
// Channel leave ≠ offline — user may still be in another channel.
|
|
965
|
+
// Lobby leave handles actual offline status.
|
|
966
|
+
const channel = this._activeChannel ? this._channels.get(this._activeChannel) : undefined;
|
|
967
|
+
if (channel) {
|
|
968
|
+
channel._handlePresenceLeave(data.actorTokenId);
|
|
969
|
+
}
|
|
592
970
|
}
|
|
593
971
|
_handleRoomPresenceUpdate(data) {
|
|
594
972
|
if (data.actorTokenId === this._localUser?.actorTokenId)
|
|
595
973
|
return;
|
|
596
|
-
const
|
|
597
|
-
if (!
|
|
598
|
-
return;
|
|
599
|
-
const room = this._activeChannel ? this._channels.get(this._activeChannel) : undefined;
|
|
600
|
-
if (room)
|
|
601
|
-
room._handlePresenceUpdate(data.actorTokenId, pd);
|
|
602
|
-
}
|
|
603
|
-
async _setupLobby() {
|
|
604
|
-
if (!this._client)
|
|
974
|
+
const presenceData = data.presence;
|
|
975
|
+
if (!presenceData?.userId || this._foreignScope(presenceData))
|
|
605
976
|
return;
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
const
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
this._client.on('lobbyPresence:join', lh('join'));
|
|
615
|
-
this._client.on('lobbyPresence:leave', lh('leave'));
|
|
616
|
-
this._client.on('lobbyPresence:update', lh('update'));
|
|
617
|
-
try {
|
|
618
|
-
const s = await this._lobby.subscribe();
|
|
619
|
-
this._hydrateOnlineUsers(s);
|
|
977
|
+
// Update online user info if we already track them
|
|
978
|
+
if (this._onlineUsers.has(presenceData.userId)) {
|
|
979
|
+
const user = this._presenceToUser(data.actorTokenId, presenceData);
|
|
980
|
+
this._onlineUsers.set(user.userId, user);
|
|
981
|
+
}
|
|
982
|
+
const channel = this._activeChannel ? this._channels.get(this._activeChannel) : undefined;
|
|
983
|
+
if (channel) {
|
|
984
|
+
channel._handlePresenceUpdate(data.actorTokenId, presenceData);
|
|
620
985
|
}
|
|
621
|
-
catch { }
|
|
622
986
|
}
|
|
987
|
+
// ============ Private: Lobby ============
|
|
623
988
|
_handleLobbyJoin(event) {
|
|
624
989
|
const { actorId, data } = event;
|
|
625
990
|
if (actorId === this._localUser?.actorTokenId)
|
|
626
991
|
return;
|
|
627
|
-
const
|
|
628
|
-
if (!
|
|
992
|
+
const presenceData = data;
|
|
993
|
+
if (!presenceData.userId || this._foreignScope(presenceData))
|
|
629
994
|
return;
|
|
630
|
-
const user = this._presenceToUser(actorId,
|
|
995
|
+
const user = this._presenceToUser(actorId, presenceData);
|
|
631
996
|
this._actorToUserId.set(actorId, user.userId);
|
|
632
997
|
if (!this._onlineUsers.has(user.userId)) {
|
|
633
998
|
this._onlineUsers.set(user.userId, user);
|
|
@@ -638,8 +1003,12 @@ class NoLagFeed extends EventEmitter {
|
|
|
638
1003
|
const { actorId, data } = event;
|
|
639
1004
|
if (actorId === this._localUser?.actorTokenId)
|
|
640
1005
|
return;
|
|
641
|
-
const
|
|
642
|
-
|
|
1006
|
+
const presenceData = data;
|
|
1007
|
+
if (this._foreignScope(presenceData))
|
|
1008
|
+
return;
|
|
1009
|
+
const userId = presenceData?.userId
|
|
1010
|
+
|| this._actorToUserId.get(actorId)
|
|
1011
|
+
|| this._findUserIdByActorId(actorId);
|
|
643
1012
|
if (userId) {
|
|
644
1013
|
const user = this._onlineUsers.get(userId);
|
|
645
1014
|
if (user) {
|
|
@@ -649,34 +1018,81 @@ class NoLagFeed extends EventEmitter {
|
|
|
649
1018
|
}
|
|
650
1019
|
}
|
|
651
1020
|
}
|
|
652
|
-
|
|
1021
|
+
_handleLobbyUpdate(event) {
|
|
1022
|
+
const { actorId, data } = event;
|
|
1023
|
+
if (actorId === this._localUser?.actorTokenId)
|
|
1024
|
+
return;
|
|
1025
|
+
const presenceData = data;
|
|
1026
|
+
if (!presenceData.userId || this._foreignScope(presenceData))
|
|
1027
|
+
return;
|
|
1028
|
+
const user = this._presenceToUser(actorId, presenceData);
|
|
1029
|
+
this._onlineUsers.set(user.userId, user);
|
|
1030
|
+
}
|
|
1031
|
+
/**
|
|
1032
|
+
* Reconcile the online-user map against a fresh lobby snapshot, emitting
|
|
1033
|
+
* only the deltas (userOffline for vanished, userOnline for new). One path
|
|
1034
|
+
* for initial hydration, reconnect restore, and the deferred refetch.
|
|
1035
|
+
*/
|
|
1036
|
+
_diffHydrateOnlineUsers(state) {
|
|
1037
|
+
// Build the fresh user set from the snapshot
|
|
1038
|
+
const fresh = new Map();
|
|
1039
|
+
const freshActors = new Map();
|
|
653
1040
|
for (const roomId of Object.keys(state)) {
|
|
654
|
-
|
|
1041
|
+
const roomPresence = state[roomId];
|
|
1042
|
+
for (const actorId of Object.keys(roomPresence)) {
|
|
655
1043
|
if (actorId === this._localUser?.actorTokenId)
|
|
656
1044
|
continue;
|
|
657
|
-
const raw =
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
this._onlineUsers.set(user.userId, user);
|
|
664
|
-
this.emit('userOnline', user);
|
|
1045
|
+
const raw = roomPresence[actorId];
|
|
1046
|
+
// Server returns full actor records with presence nested under .presence
|
|
1047
|
+
const presenceData = (raw?.presence ?? raw);
|
|
1048
|
+
if (presenceData?.userId && !this._foreignScope(presenceData)) {
|
|
1049
|
+
if (!fresh.has(presenceData.userId)) {
|
|
1050
|
+
fresh.set(presenceData.userId, this._presenceToUser(actorId, presenceData));
|
|
665
1051
|
}
|
|
1052
|
+
freshActors.set(actorId, presenceData.userId);
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
// Vanished users
|
|
1057
|
+
for (const [userId, user] of [...this._onlineUsers]) {
|
|
1058
|
+
if (!fresh.has(userId)) {
|
|
1059
|
+
this._onlineUsers.delete(userId);
|
|
1060
|
+
for (const [actorId, mappedUserId] of [...this._actorToUserId]) {
|
|
1061
|
+
if (mappedUserId === userId)
|
|
1062
|
+
this._actorToUserId.delete(actorId);
|
|
666
1063
|
}
|
|
1064
|
+
this.emit('userOffline', user);
|
|
667
1065
|
}
|
|
668
1066
|
}
|
|
1067
|
+
// New users
|
|
1068
|
+
for (const [userId, user] of fresh) {
|
|
1069
|
+
if (!this._onlineUsers.has(userId)) {
|
|
1070
|
+
this._onlineUsers.set(userId, user);
|
|
1071
|
+
this.emit('userOnline', user);
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
for (const [actorId, userId] of freshActors) {
|
|
1075
|
+
this._actorToUserId.set(actorId, userId);
|
|
1076
|
+
}
|
|
669
1077
|
}
|
|
1078
|
+
// ============ Private: Helpers ============
|
|
670
1079
|
_presenceToUser(actorTokenId, data) {
|
|
671
|
-
return {
|
|
1080
|
+
return {
|
|
1081
|
+
userId: data.userId,
|
|
1082
|
+
actorTokenId,
|
|
1083
|
+
username: data.username,
|
|
1084
|
+
avatar: data.avatar,
|
|
1085
|
+
metadata: data.metadata,
|
|
1086
|
+
joinedAt: Date.now(),
|
|
1087
|
+
isLocal: false,
|
|
1088
|
+
};
|
|
672
1089
|
}
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
ch._updateLocalPresence();
|
|
1090
|
+
_findUserIdByActorId(actorTokenId) {
|
|
1091
|
+
for (const user of this._onlineUsers.values()) {
|
|
1092
|
+
if (user.actorTokenId === actorTokenId)
|
|
1093
|
+
return user.userId;
|
|
678
1094
|
}
|
|
679
|
-
|
|
1095
|
+
return undefined;
|
|
680
1096
|
}
|
|
681
1097
|
}
|
|
682
1098
|
|