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