@nolag/notify 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 -8
- package/dist/NoLagNotify.d.ts +68 -14
- package/dist/NotifyChannel.d.ts +4 -1
- package/dist/browser.js +1 -1
- package/dist/browser.js.map +1 -1
- package/dist/constants.d.ts +2 -0
- package/dist/index.cjs +319 -157
- package/dist/index.cjs.map +1 -1
- package/dist/index.mjs +319 -157
- package/dist/index.mjs.map +1 -1
- package/dist/types.d.ts +9 -6
- 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
|
/**
|
|
6
4
|
* Tiny typed event emitter — framework-agnostic base for NoLag SDKs.
|
|
7
5
|
*/
|
|
@@ -150,6 +148,29 @@ function createLogger(prefix, enabled) {
|
|
|
150
148
|
console.log(`[${prefix}]`, ...args);
|
|
151
149
|
};
|
|
152
150
|
}
|
|
151
|
+
// ============ Wrapper registry ============
|
|
152
|
+
// One wrapper instance per (client, appName): two wrappers sharing an app on
|
|
153
|
+
// one connection would collide on topics, presence and the online lobby.
|
|
154
|
+
// Warn (not throw): HMR and tests legitimately construct before disposing.
|
|
155
|
+
const wrapperRegistry = new WeakMap();
|
|
156
|
+
/** Register a wrapper against a client + appName; warns on collision. */
|
|
157
|
+
function registerWrapper(client, appName, wrapperName) {
|
|
158
|
+
let apps = wrapperRegistry.get(client);
|
|
159
|
+
if (!apps) {
|
|
160
|
+
apps = new Map();
|
|
161
|
+
wrapperRegistry.set(client, apps);
|
|
162
|
+
}
|
|
163
|
+
const existing = apps.get(appName);
|
|
164
|
+
if (existing) {
|
|
165
|
+
console.warn(`[${wrapperName}] Another wrapper (${existing}) is already attached to this client for app "${appName}". ` +
|
|
166
|
+
`Use one wrapper per (client, app) — detach the other instance first.`);
|
|
167
|
+
}
|
|
168
|
+
apps.set(appName, wrapperName);
|
|
169
|
+
}
|
|
170
|
+
/** Release a wrapper's (client, appName) registration on detach. */
|
|
171
|
+
function releaseWrapper(client, appName) {
|
|
172
|
+
wrapperRegistry.get(client)?.delete(appName);
|
|
173
|
+
}
|
|
153
174
|
|
|
154
175
|
/** Default app name for channel topic prefixes */
|
|
155
176
|
const DEFAULT_APP_NAME = 'notify';
|
|
@@ -161,6 +182,8 @@ const TOPIC_NOTIFICATIONS = 'notifications';
|
|
|
161
182
|
const TOPIC_READ = '_read';
|
|
162
183
|
/** Lobby ID for global online presence */
|
|
163
184
|
const LOBBY_ID = 'online';
|
|
185
|
+
/** Delay before the post-setup lobby presence refetch (catches simultaneous joiners) */
|
|
186
|
+
const LOBBY_REFRESH_DELAY_MS = 2000;
|
|
164
187
|
|
|
165
188
|
/**
|
|
166
189
|
* NotifyChannel — a single notification channel with read/unread tracking.
|
|
@@ -169,14 +192,19 @@ const LOBBY_ID = 'online';
|
|
|
169
192
|
*/
|
|
170
193
|
class NotifyChannel extends EventEmitter {
|
|
171
194
|
/** @internal */
|
|
172
|
-
constructor(name, roomContext, options, log) {
|
|
195
|
+
constructor(name, roomContext, options, log, isConnected) {
|
|
173
196
|
super();
|
|
174
197
|
this._active = false;
|
|
198
|
+
// Stored topic handler refs — cleanup removes exactly these, never all
|
|
199
|
+
// handlers for a topic (the client may be shared with other consumers).
|
|
200
|
+
this._onNotificationsRef = null;
|
|
201
|
+
this._onReadRef = null;
|
|
175
202
|
this.name = name;
|
|
176
203
|
this._roomContext = roomContext;
|
|
177
204
|
this._options = options;
|
|
178
205
|
this._store = new NotificationStore(options.maxNotificationCache);
|
|
179
206
|
this._log = log;
|
|
207
|
+
this._isConnected = isConnected;
|
|
180
208
|
}
|
|
181
209
|
// ============ Public Properties ============
|
|
182
210
|
/** All notifications in this channel (timestamp order) */
|
|
@@ -253,12 +281,15 @@ class NotifyChannel extends EventEmitter {
|
|
|
253
281
|
this._log('Channel subscribe:', this.name);
|
|
254
282
|
this._roomContext.subscribe(TOPIC_NOTIFICATIONS);
|
|
255
283
|
this._roomContext.subscribe(TOPIC_READ);
|
|
256
|
-
|
|
284
|
+
// Listen for notifications (refs stored for handler-specific removal)
|
|
285
|
+
this._onNotificationsRef = (data, meta) => {
|
|
257
286
|
this._handleIncomingNotification(data, meta);
|
|
258
|
-
}
|
|
259
|
-
this._roomContext.on(
|
|
287
|
+
};
|
|
288
|
+
this._roomContext.on(TOPIC_NOTIFICATIONS, this._onNotificationsRef);
|
|
289
|
+
this._onReadRef = (data) => {
|
|
260
290
|
this._handleIncomingRead(data);
|
|
261
|
-
}
|
|
291
|
+
};
|
|
292
|
+
this._roomContext.on(TOPIC_READ, this._onReadRef);
|
|
262
293
|
}
|
|
263
294
|
/** @internal Activate this channel (mark as visible/active) */
|
|
264
295
|
_activate() {
|
|
@@ -281,10 +312,20 @@ class NotifyChannel extends EventEmitter {
|
|
|
281
312
|
/** @internal Unsubscribe and clean up */
|
|
282
313
|
_cleanup() {
|
|
283
314
|
this._log('Channel cleanup:', this.name);
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
this.
|
|
287
|
-
|
|
315
|
+
// Server unsubscribes need a live socket; skip when disconnected
|
|
316
|
+
// (best-effort — the core would no-op with an error callback anyway).
|
|
317
|
+
if (this._isConnected()) {
|
|
318
|
+
this._roomContext.unsubscribe(TOPIC_NOTIFICATIONS);
|
|
319
|
+
this._roomContext.unsubscribe(TOPIC_READ);
|
|
320
|
+
}
|
|
321
|
+
// Handler-specific removal only: the client may be shared, and a bare
|
|
322
|
+
// off(topic) would strip other consumers' handlers too.
|
|
323
|
+
if (this._onNotificationsRef)
|
|
324
|
+
this._roomContext.off(TOPIC_NOTIFICATIONS, this._onNotificationsRef);
|
|
325
|
+
if (this._onReadRef)
|
|
326
|
+
this._roomContext.off(TOPIC_READ, this._onReadRef);
|
|
327
|
+
this._onNotificationsRef = null;
|
|
328
|
+
this._onReadRef = null;
|
|
288
329
|
this._store.clear();
|
|
289
330
|
this.removeAllListeners();
|
|
290
331
|
}
|
|
@@ -434,47 +475,127 @@ class PresenceManager {
|
|
|
434
475
|
* Provides multi-channel notifications, read/unread tracking, badge counts,
|
|
435
476
|
* message replay, and global presence — all framework-agnostic via events.
|
|
436
477
|
*
|
|
478
|
+
* The wrapper NEVER manages the connection. The app owns one core NoLag
|
|
479
|
+
* client (shared by any number of wrappers on distinct apps) and the
|
|
480
|
+
* wrapper attaches to it at construction and releases it via `detach()`.
|
|
481
|
+
*
|
|
437
482
|
* @example
|
|
438
483
|
* ```typescript
|
|
484
|
+
* import { NoLag } from '@nolag/js-sdk';
|
|
439
485
|
* import { NoLagNotify } from '@nolag/notify';
|
|
440
486
|
*
|
|
441
|
-
* const
|
|
487
|
+
* const client = NoLag(async () => (await (await fetch('/api/nolag-token')).json()).token);
|
|
488
|
+
* const notify = new NoLagNotify({ client, appName: 'my-notify' });
|
|
442
489
|
*
|
|
443
|
-
* notify.on('connected', () => console.log('Connected!'));
|
|
444
490
|
* notify.on('notification', (n) => console.log('New notification:', n.title));
|
|
445
491
|
*
|
|
446
|
-
* await
|
|
492
|
+
* await client.connect(); // the app owns the connection
|
|
493
|
+
* await notify.ready(); // wrapper setup done (identity, lobby, channels)
|
|
447
494
|
*
|
|
448
495
|
* const alerts = notify.subscribe('alerts');
|
|
449
496
|
* alerts.on('notification', (n) => console.log(n.title));
|
|
497
|
+
*
|
|
498
|
+
* notify.detach(); // wrapper releases its handlers and topics
|
|
499
|
+
* client.disconnect(); // the app closes the socket
|
|
450
500
|
* ```
|
|
451
501
|
*/
|
|
452
502
|
class NoLagNotify extends EventEmitter {
|
|
453
|
-
constructor(
|
|
503
|
+
constructor(options) {
|
|
454
504
|
super();
|
|
455
|
-
this._client = null;
|
|
456
505
|
this._channels = new Map();
|
|
457
506
|
this._lobby = null;
|
|
458
507
|
this._badgeManager = new BadgeManager();
|
|
459
508
|
this._presenceManager = new PresenceManager();
|
|
460
509
|
this._actorToUserId = new Map();
|
|
461
|
-
|
|
510
|
+
// Lifecycle: one setup run per connection epoch; detach is terminal.
|
|
511
|
+
this._epoch = 0;
|
|
512
|
+
this._detached = false;
|
|
513
|
+
this._isReady = false;
|
|
514
|
+
this._lobbyRefreshTimer = null;
|
|
515
|
+
// Stored client handler refs. INVARIANT: every client.on() below has a
|
|
516
|
+
// matching client.off() in detach() — never bare off(event), never inline
|
|
517
|
+
// closures on the client.
|
|
518
|
+
this._onConnectRef = () => this._onConnect();
|
|
519
|
+
this._onDisconnectRef = (reason) => {
|
|
520
|
+
this._log('Disconnected:', reason);
|
|
521
|
+
this.emit('disconnected', reason);
|
|
522
|
+
};
|
|
523
|
+
this._onReconnectRef = () => {
|
|
524
|
+
this._log('Reconnecting...');
|
|
525
|
+
this.emit('reconnecting');
|
|
526
|
+
};
|
|
527
|
+
this._onErrorRef = (error) => {
|
|
528
|
+
this._log('Error:', error);
|
|
529
|
+
this.emit('error', error);
|
|
530
|
+
};
|
|
531
|
+
this._onReplayStartRef = (data) => {
|
|
532
|
+
const event = data;
|
|
533
|
+
for (const channel of this._channels.values()) {
|
|
534
|
+
channel._handleReplayStart(event.count);
|
|
535
|
+
}
|
|
536
|
+
};
|
|
537
|
+
this._onReplayEndRef = (data) => {
|
|
538
|
+
const event = data;
|
|
539
|
+
for (const channel of this._channels.values()) {
|
|
540
|
+
channel._handleReplayEnd(event.replayed);
|
|
541
|
+
}
|
|
542
|
+
};
|
|
543
|
+
this._onPresenceJoinRef = (data) => this._handleRoomPresenceJoin(data);
|
|
544
|
+
this._onPresenceLeaveRef = (data) => this._handleRoomPresenceLeave(data);
|
|
545
|
+
this._onPresenceUpdateRef = (data) => this._handleRoomPresenceUpdate(data);
|
|
546
|
+
this._onLobbyJoinRef = (data) => this._handleLobbyJoin(data);
|
|
547
|
+
this._onLobbyLeaveRef = (data) => this._handleLobbyLeave(data);
|
|
548
|
+
this._onLobbyUpdateRef = (data) => this._handleLobbyUpdate(data);
|
|
549
|
+
if (!options?.client) {
|
|
550
|
+
throw new TypeError('NoLagNotify requires an injected NoLag client: new NoLagNotify({ client, ... })');
|
|
551
|
+
}
|
|
552
|
+
this._client = options.client;
|
|
462
553
|
this._userId = generateId();
|
|
463
554
|
this._options = {
|
|
464
555
|
metadata: options.metadata,
|
|
465
556
|
appName: options.appName ?? DEFAULT_APP_NAME,
|
|
466
|
-
url: options.url,
|
|
467
557
|
maxNotificationCache: options.maxNotificationCache ?? DEFAULT_MAX_NOTIFICATION_CACHE,
|
|
468
558
|
debug: options.debug ?? false,
|
|
469
|
-
reconnect: options.reconnect ?? true,
|
|
470
559
|
channels: options.channels ?? [],
|
|
471
560
|
};
|
|
472
561
|
this._log = createLogger('NoLagNotify', this._options.debug);
|
|
562
|
+
this._readyPromise = new Promise((resolve, reject) => {
|
|
563
|
+
this._readyResolve = resolve;
|
|
564
|
+
this._readyReject = reject;
|
|
565
|
+
});
|
|
566
|
+
// ready() rejection is only meaningful to callers that await it
|
|
567
|
+
this._readyPromise.catch(() => { });
|
|
568
|
+
registerWrapper(this._client, this._options.appName, 'NoLagNotify');
|
|
569
|
+
// Construction = attach: wire everything now, with stored refs.
|
|
570
|
+
this._client.on('connect', this._onConnectRef);
|
|
571
|
+
this._client.on('disconnect', this._onDisconnectRef);
|
|
572
|
+
this._client.on('reconnect', this._onReconnectRef);
|
|
573
|
+
this._client.on('error', this._onErrorRef);
|
|
574
|
+
this._client.on('replay:start', this._onReplayStartRef);
|
|
575
|
+
this._client.on('replay:end', this._onReplayEndRef);
|
|
576
|
+
this._client.on('presence:join', this._onPresenceJoinRef);
|
|
577
|
+
this._client.on('presence:leave', this._onPresenceLeaveRef);
|
|
578
|
+
this._client.on('presence:update', this._onPresenceUpdateRef);
|
|
579
|
+
this._client.on('lobbyPresence:join', this._onLobbyJoinRef);
|
|
580
|
+
this._client.on('lobbyPresence:leave', this._onLobbyLeaveRef);
|
|
581
|
+
this._client.on('lobbyPresence:update', this._onLobbyUpdateRef);
|
|
582
|
+
// Attach-to-connected: if the client is already authenticated, run setup.
|
|
583
|
+
// The microtask lets the caller wire wrapper event handlers synchronously
|
|
584
|
+
// first; a racing real 'connect' event wins via the epoch guard.
|
|
585
|
+
queueMicrotask(() => {
|
|
586
|
+
if (this._epoch === 0 && !this._detached && this._client.connected) {
|
|
587
|
+
this._onConnect();
|
|
588
|
+
}
|
|
589
|
+
});
|
|
473
590
|
}
|
|
474
591
|
// ============ Public Properties ============
|
|
475
|
-
/** Whether the underlying connection is established */
|
|
592
|
+
/** Whether the underlying connection is established (connected ≠ ready) */
|
|
476
593
|
get connected() {
|
|
477
|
-
return this._client
|
|
594
|
+
return !this._detached && this._client.connected;
|
|
595
|
+
}
|
|
596
|
+
/** The injected core client (owned by the app, not the wrapper) */
|
|
597
|
+
get client() {
|
|
598
|
+
return this._client;
|
|
478
599
|
}
|
|
479
600
|
/** All currently subscribed channels */
|
|
480
601
|
get channels() {
|
|
@@ -482,96 +603,139 @@ class NoLagNotify extends EventEmitter {
|
|
|
482
603
|
}
|
|
483
604
|
// ============ Lifecycle ============
|
|
484
605
|
/**
|
|
485
|
-
*
|
|
606
|
+
* Resolves once the wrapper's first setup completed (identity, lobby and
|
|
607
|
+
* configured channels ready — equivalently, once 'connected' has fired).
|
|
608
|
+
* Rejects only if detach() is called before that. Client auth failures
|
|
609
|
+
* surface via the app's own `await client.connect()`, not here.
|
|
486
610
|
*/
|
|
487
|
-
|
|
488
|
-
this.
|
|
489
|
-
const clientOptions = {
|
|
490
|
-
debug: this._options.debug,
|
|
491
|
-
reconnect: this._options.reconnect,
|
|
492
|
-
};
|
|
493
|
-
if (this._options.url) {
|
|
494
|
-
clientOptions.url = this._options.url;
|
|
495
|
-
}
|
|
496
|
-
this._client = jsSdk.NoLag(this._token, clientOptions);
|
|
497
|
-
// Wire client lifecycle events
|
|
498
|
-
this._client.on('connect', () => {
|
|
499
|
-
this._log('Connected');
|
|
500
|
-
if (this._channels.size > 0) {
|
|
501
|
-
this._log('Reconnected — restoring channels...');
|
|
502
|
-
this._restoreChannels();
|
|
503
|
-
this.emit('reconnected');
|
|
504
|
-
}
|
|
505
|
-
});
|
|
506
|
-
this._client.on('disconnect', (reason) => {
|
|
507
|
-
this._log('Disconnected:', reason);
|
|
508
|
-
this.emit('disconnected', reason);
|
|
509
|
-
});
|
|
510
|
-
this._client.on('reconnect', () => {
|
|
511
|
-
this._log('Reconnecting...');
|
|
512
|
-
});
|
|
513
|
-
this._client.on('error', (error) => {
|
|
514
|
-
this._log('Error:', error);
|
|
515
|
-
this.emit('error', error);
|
|
516
|
-
});
|
|
517
|
-
// Wire replay events
|
|
518
|
-
this._client.on('replay:start', (data) => {
|
|
519
|
-
const event = data;
|
|
520
|
-
for (const channel of this._channels.values()) {
|
|
521
|
-
channel._handleReplayStart(event.count);
|
|
522
|
-
}
|
|
523
|
-
});
|
|
524
|
-
this._client.on('replay:end', (data) => {
|
|
525
|
-
const event = data;
|
|
526
|
-
for (const channel of this._channels.values()) {
|
|
527
|
-
channel._handleReplayEnd(event.replayed);
|
|
528
|
-
}
|
|
529
|
-
});
|
|
530
|
-
// Connect
|
|
531
|
-
await this._client.connect();
|
|
532
|
-
// Wire room-level presence events
|
|
533
|
-
this._client.on('presence:join', (data) => {
|
|
534
|
-
this._handleRoomPresenceJoin(data);
|
|
535
|
-
});
|
|
536
|
-
this._client.on('presence:leave', (data) => {
|
|
537
|
-
this._handleRoomPresenceLeave(data);
|
|
538
|
-
});
|
|
539
|
-
this._client.on('presence:update', (data) => {
|
|
540
|
-
this._handleRoomPresenceUpdate(data);
|
|
541
|
-
});
|
|
542
|
-
this._log('Local userId:', this._userId, '→ actorId:', this._client.actorId);
|
|
543
|
-
// Set up lobby for global presence
|
|
544
|
-
await this._setupLobby();
|
|
545
|
-
// Pre-subscribe to all configured channels
|
|
546
|
-
for (const channelName of this._options.channels) {
|
|
547
|
-
this._subscribeChannel(channelName);
|
|
548
|
-
}
|
|
549
|
-
// Emit connected now that lobby is ready
|
|
550
|
-
this.emit('connected');
|
|
551
|
-
// Deferred lobby refetch to catch late-joining users
|
|
552
|
-
setTimeout(() => {
|
|
553
|
-
if (this._lobby && this._client?.connected) {
|
|
554
|
-
this._lobby.fetchPresence().then((state) => {
|
|
555
|
-
this._hydratePresence(state);
|
|
556
|
-
}).catch(() => { });
|
|
557
|
-
}
|
|
558
|
-
}, 2000);
|
|
611
|
+
ready() {
|
|
612
|
+
return this._readyPromise;
|
|
559
613
|
}
|
|
560
614
|
/**
|
|
561
|
-
*
|
|
615
|
+
* Detach from the client: remove every handler this wrapper added,
|
|
616
|
+
* unsubscribe its topics and lobby (when connected), clear state.
|
|
617
|
+
* Terminal and idempotent; never touches the socket. To use notify again,
|
|
618
|
+
* construct a new instance.
|
|
562
619
|
*/
|
|
563
|
-
|
|
564
|
-
this.
|
|
620
|
+
detach() {
|
|
621
|
+
if (this._detached)
|
|
622
|
+
return;
|
|
623
|
+
this._log('Detaching...');
|
|
624
|
+
this._detached = true;
|
|
625
|
+
this._epoch++; // aborts any in-flight setup at its next checkpoint
|
|
626
|
+
if (this._lobbyRefreshTimer) {
|
|
627
|
+
clearTimeout(this._lobbyRefreshTimer);
|
|
628
|
+
this._lobbyRefreshTimer = null;
|
|
629
|
+
}
|
|
630
|
+
// Remove all client handlers by stored ref
|
|
631
|
+
this._client.off('connect', this._onConnectRef);
|
|
632
|
+
this._client.off('disconnect', this._onDisconnectRef);
|
|
633
|
+
this._client.off('reconnect', this._onReconnectRef);
|
|
634
|
+
this._client.off('error', this._onErrorRef);
|
|
635
|
+
this._client.off('replay:start', this._onReplayStartRef);
|
|
636
|
+
this._client.off('replay:end', this._onReplayEndRef);
|
|
637
|
+
this._client.off('presence:join', this._onPresenceJoinRef);
|
|
638
|
+
this._client.off('presence:leave', this._onPresenceLeaveRef);
|
|
639
|
+
this._client.off('presence:update', this._onPresenceUpdateRef);
|
|
640
|
+
this._client.off('lobbyPresence:join', this._onLobbyJoinRef);
|
|
641
|
+
this._client.off('lobbyPresence:leave', this._onLobbyLeaveRef);
|
|
642
|
+
this._client.off('lobbyPresence:update', this._onLobbyUpdateRef);
|
|
643
|
+
// Channels: handler-specific off + connected-gated server unsubscribe
|
|
565
644
|
for (const name of [...this._channels.keys()]) {
|
|
566
|
-
this.
|
|
645
|
+
this._channels.get(name)._cleanup();
|
|
646
|
+
this._channels.delete(name);
|
|
647
|
+
}
|
|
648
|
+
// Lobby: server unsubscribe is best-effort and needs a live socket
|
|
649
|
+
if (this._lobby && this._client.connected) {
|
|
650
|
+
try {
|
|
651
|
+
this._lobby.unsubscribe();
|
|
652
|
+
}
|
|
653
|
+
catch {
|
|
654
|
+
/* best-effort */
|
|
655
|
+
}
|
|
567
656
|
}
|
|
568
|
-
this._lobby?.unsubscribe();
|
|
569
657
|
this._lobby = null;
|
|
570
|
-
this._client?.disconnect();
|
|
571
|
-
this._client = null;
|
|
572
658
|
this._badgeManager.clear();
|
|
573
659
|
this._presenceManager.clear();
|
|
574
660
|
this._actorToUserId.clear();
|
|
661
|
+
releaseWrapper(this._client, this._options.appName);
|
|
662
|
+
if (!this._isReady) {
|
|
663
|
+
this._readyReject(new Error('NoLagNotify detached before ready'));
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
// ============ Private: Epoch Setup ============
|
|
667
|
+
_onConnect() {
|
|
668
|
+
this._epoch++;
|
|
669
|
+
void this._runSetup(this._epoch);
|
|
670
|
+
}
|
|
671
|
+
/**
|
|
672
|
+
* One setup pass per connection epoch. Serves both initial setup (epoch 1)
|
|
673
|
+
* and reconnect restore (epoch > 1). Aborts silently whenever a newer
|
|
674
|
+
* epoch started or the wrapper detached — checked after every await.
|
|
675
|
+
*/
|
|
676
|
+
async _runSetup(epoch) {
|
|
677
|
+
const stale = () => epoch !== this._epoch || this._detached;
|
|
678
|
+
this._log(this._isReady ? 'Restoring after reconnect...' : 'Setting up...');
|
|
679
|
+
this._log('Local userId:', this._userId, '→ actorId:', this._client.actorId);
|
|
680
|
+
// Lobby: subscribe every epoch (idempotent server-side) and diff-hydrate
|
|
681
|
+
// from the returned snapshot — one path for setup and restore.
|
|
682
|
+
if (!this._lobby) {
|
|
683
|
+
this._lobby = this._client.setApp(this._options.appName).setLobby(LOBBY_ID);
|
|
684
|
+
}
|
|
685
|
+
try {
|
|
686
|
+
const state = await this._lobby.subscribe();
|
|
687
|
+
if (stale())
|
|
688
|
+
return;
|
|
689
|
+
this._diffHydratePresence(state);
|
|
690
|
+
this._log('Lobby subscribed');
|
|
691
|
+
}
|
|
692
|
+
catch (err) {
|
|
693
|
+
if (stale())
|
|
694
|
+
return;
|
|
695
|
+
this._log('Lobby subscription failed:', err);
|
|
696
|
+
}
|
|
697
|
+
// First successful setup: pre-subscribe configured channels. The core
|
|
698
|
+
// auto-restores topic subscriptions on reconnect, so later epochs skip it.
|
|
699
|
+
if (!this._isReady) {
|
|
700
|
+
for (const channelName of this._options.channels) {
|
|
701
|
+
this._subscribeChannel(channelName);
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
if (stale())
|
|
705
|
+
return;
|
|
706
|
+
// Ready keys on the first setup that COMPLETES, not on epoch 1: an
|
|
707
|
+
// epoch aborted by a racing reconnect must not strand ready().
|
|
708
|
+
if (!this._isReady) {
|
|
709
|
+
this._isReady = true;
|
|
710
|
+
this._readyResolve();
|
|
711
|
+
this.emit('connected');
|
|
712
|
+
}
|
|
713
|
+
else {
|
|
714
|
+
this.emit('reconnected');
|
|
715
|
+
}
|
|
716
|
+
// Deferred lobby refetch: catches users who joined during the setup
|
|
717
|
+
// window (e.g. simultaneous multi-tab connects).
|
|
718
|
+
this._scheduleLobbyRefresh(epoch);
|
|
719
|
+
}
|
|
720
|
+
_scheduleLobbyRefresh(epoch) {
|
|
721
|
+
if (this._lobbyRefreshTimer)
|
|
722
|
+
clearTimeout(this._lobbyRefreshTimer);
|
|
723
|
+
this._lobbyRefreshTimer = setTimeout(() => {
|
|
724
|
+
this._lobbyRefreshTimer = null;
|
|
725
|
+
if (epoch !== this._epoch || this._detached || !this._client.connected || !this._lobby) {
|
|
726
|
+
return;
|
|
727
|
+
}
|
|
728
|
+
this._lobby
|
|
729
|
+
.fetchPresence()
|
|
730
|
+
.then((state) => {
|
|
731
|
+
if (epoch !== this._epoch || this._detached)
|
|
732
|
+
return;
|
|
733
|
+
this._diffHydratePresence(state);
|
|
734
|
+
})
|
|
735
|
+
.catch(() => {
|
|
736
|
+
/* best-effort */
|
|
737
|
+
});
|
|
738
|
+
}, LOBBY_REFRESH_DELAY_MS);
|
|
575
739
|
}
|
|
576
740
|
// ============ Channel Management ============
|
|
577
741
|
/**
|
|
@@ -579,9 +743,7 @@ class NoLagNotify extends EventEmitter {
|
|
|
579
743
|
* Returns the NotifyChannel instance.
|
|
580
744
|
*/
|
|
581
745
|
subscribe(channelName) {
|
|
582
|
-
|
|
583
|
-
throw new Error('Not connected — call connect() first');
|
|
584
|
-
}
|
|
746
|
+
this._assertUsable();
|
|
585
747
|
const existing = this._channels.get(channelName);
|
|
586
748
|
if (existing)
|
|
587
749
|
return existing;
|
|
@@ -618,14 +780,20 @@ class NoLagNotify extends EventEmitter {
|
|
|
618
780
|
channel.markAllRead();
|
|
619
781
|
}
|
|
620
782
|
}
|
|
783
|
+
// ============ Private: Guards ============
|
|
784
|
+
_assertUsable() {
|
|
785
|
+
if (this._detached) {
|
|
786
|
+
throw new Error('NoLagNotify has been detached — construct a new instance');
|
|
787
|
+
}
|
|
788
|
+
if (!this._isReady) {
|
|
789
|
+
throw new Error('NoLagNotify not ready — await ready() or the "connected" event');
|
|
790
|
+
}
|
|
791
|
+
}
|
|
621
792
|
// ============ Private: Channel Setup ============
|
|
622
793
|
_subscribeChannel(name) {
|
|
623
|
-
if (!this._client) {
|
|
624
|
-
throw new Error('Not connected — call connect() first');
|
|
625
|
-
}
|
|
626
794
|
this._log('Subscribing channel:', name);
|
|
627
795
|
const roomContext = this._client.setApp(this._options.appName).setRoom(name);
|
|
628
|
-
const channel = new NotifyChannel(name, roomContext, this._options, createLogger(`NotifyChannel:${name}`, this._options.debug));
|
|
796
|
+
const channel = new NotifyChannel(name, roomContext, this._options, createLogger(`NotifyChannel:${name}`, this._options.debug), () => this._client.connected);
|
|
629
797
|
this._channels.set(name, channel);
|
|
630
798
|
channel._subscribe();
|
|
631
799
|
// Relay notifications up to the main client and update badges
|
|
@@ -647,12 +815,23 @@ class NoLagNotify extends EventEmitter {
|
|
|
647
815
|
_emitBadgeUpdated() {
|
|
648
816
|
this.emit('badgeUpdated', this._badgeManager.getAll());
|
|
649
817
|
}
|
|
818
|
+
// ============ Private: Scope Filtering ============
|
|
819
|
+
/**
|
|
820
|
+
* On a shared client, presence events from other apps' wrappers arrive on
|
|
821
|
+
* the same connection-level events. Wrappers stamp their presence with a
|
|
822
|
+
* `__scope` (their appName); a mismatched tag means another app's data.
|
|
823
|
+
* Untagged presence is accepted (older peers in this same app).
|
|
824
|
+
*/
|
|
825
|
+
_foreignScope(data) {
|
|
826
|
+
const scope = data?.__scope;
|
|
827
|
+
return typeof scope === 'string' && scope !== this._options.appName;
|
|
828
|
+
}
|
|
650
829
|
// ============ Private: Room Presence ============
|
|
651
830
|
_handleRoomPresenceJoin(data) {
|
|
652
|
-
if (data.actorTokenId === this._client
|
|
831
|
+
if (data.actorTokenId === this._client.actorId)
|
|
653
832
|
return;
|
|
654
833
|
const presenceData = data.presence;
|
|
655
|
-
if (!presenceData?.userId)
|
|
834
|
+
if (!presenceData?.userId || this._foreignScope(presenceData))
|
|
656
835
|
return;
|
|
657
836
|
const user = this._presenceManager.addFromPresence(data.actorTokenId, presenceData);
|
|
658
837
|
if (user) {
|
|
@@ -660,50 +839,25 @@ class NoLagNotify extends EventEmitter {
|
|
|
660
839
|
}
|
|
661
840
|
}
|
|
662
841
|
_handleRoomPresenceLeave(data) {
|
|
663
|
-
if (data.actorTokenId === this._client
|
|
842
|
+
if (data.actorTokenId === this._client.actorId)
|
|
664
843
|
return;
|
|
665
844
|
this._presenceManager.removeByActorId(data.actorTokenId);
|
|
666
845
|
}
|
|
667
846
|
_handleRoomPresenceUpdate(data) {
|
|
668
|
-
if (data.actorTokenId === this._client
|
|
847
|
+
if (data.actorTokenId === this._client.actorId)
|
|
669
848
|
return;
|
|
670
849
|
const presenceData = data.presence;
|
|
671
|
-
if (!presenceData?.userId)
|
|
850
|
+
if (!presenceData?.userId || this._foreignScope(presenceData))
|
|
672
851
|
return;
|
|
673
852
|
this._presenceManager.addFromPresence(data.actorTokenId, presenceData);
|
|
674
853
|
}
|
|
675
854
|
// ============ Private: Lobby ============
|
|
676
|
-
async _setupLobby() {
|
|
677
|
-
if (!this._client)
|
|
678
|
-
return;
|
|
679
|
-
this._lobby = this._client.setApp(this._options.appName).setLobby(LOBBY_ID);
|
|
680
|
-
const lobbyHandler = (type) => (data) => {
|
|
681
|
-
const event = data;
|
|
682
|
-
if (type === 'join')
|
|
683
|
-
this._handleLobbyJoin(event);
|
|
684
|
-
else if (type === 'leave')
|
|
685
|
-
this._handleLobbyLeave(event);
|
|
686
|
-
else
|
|
687
|
-
this._handleLobbyUpdate(event);
|
|
688
|
-
};
|
|
689
|
-
this._client.on('lobbyPresence:join', lobbyHandler('join'));
|
|
690
|
-
this._client.on('lobbyPresence:leave', lobbyHandler('leave'));
|
|
691
|
-
this._client.on('lobbyPresence:update', lobbyHandler('update'));
|
|
692
|
-
try {
|
|
693
|
-
const initialState = await this._lobby.subscribe();
|
|
694
|
-
this._hydratePresence(initialState);
|
|
695
|
-
this._log('Lobby subscribed');
|
|
696
|
-
}
|
|
697
|
-
catch (err) {
|
|
698
|
-
this._log('Lobby subscription failed:', err);
|
|
699
|
-
}
|
|
700
|
-
}
|
|
701
855
|
_handleLobbyJoin(event) {
|
|
702
856
|
const { actorId, data } = event;
|
|
703
|
-
if (actorId === this._client
|
|
857
|
+
if (actorId === this._client.actorId)
|
|
704
858
|
return;
|
|
705
859
|
const presenceData = data;
|
|
706
|
-
if (!presenceData?.userId)
|
|
860
|
+
if (!presenceData?.userId || this._foreignScope(presenceData))
|
|
707
861
|
return;
|
|
708
862
|
const user = this._presenceManager.addFromPresence(actorId, presenceData);
|
|
709
863
|
if (user) {
|
|
@@ -711,30 +865,41 @@ class NoLagNotify extends EventEmitter {
|
|
|
711
865
|
}
|
|
712
866
|
}
|
|
713
867
|
_handleLobbyLeave(event) {
|
|
714
|
-
const { actorId } = event;
|
|
715
|
-
if (actorId === this._client
|
|
868
|
+
const { actorId, data } = event;
|
|
869
|
+
if (actorId === this._client.actorId)
|
|
870
|
+
return;
|
|
871
|
+
const presenceData = data;
|
|
872
|
+
if (this._foreignScope(presenceData))
|
|
716
873
|
return;
|
|
717
874
|
this._presenceManager.removeByActorId(actorId);
|
|
718
875
|
this._actorToUserId.delete(actorId);
|
|
719
876
|
}
|
|
720
877
|
_handleLobbyUpdate(event) {
|
|
721
878
|
const { actorId, data } = event;
|
|
722
|
-
if (actorId === this._client
|
|
879
|
+
if (actorId === this._client.actorId)
|
|
723
880
|
return;
|
|
724
881
|
const presenceData = data;
|
|
725
|
-
if (!presenceData?.userId)
|
|
882
|
+
if (!presenceData?.userId || this._foreignScope(presenceData))
|
|
726
883
|
return;
|
|
727
884
|
this._presenceManager.addFromPresence(actorId, presenceData);
|
|
728
885
|
}
|
|
729
|
-
|
|
886
|
+
/**
|
|
887
|
+
* Reconcile tracked presence against a fresh lobby snapshot. One path for
|
|
888
|
+
* initial hydration, reconnect restore, and the deferred refetch.
|
|
889
|
+
*/
|
|
890
|
+
_diffHydratePresence(state) {
|
|
891
|
+
// Build the fresh actor set from the snapshot
|
|
892
|
+
const freshActors = new Set();
|
|
730
893
|
for (const roomId of Object.keys(state)) {
|
|
731
894
|
const roomPresence = state[roomId];
|
|
732
895
|
for (const actorId of Object.keys(roomPresence)) {
|
|
733
|
-
if (actorId === this._client
|
|
896
|
+
if (actorId === this._client.actorId)
|
|
734
897
|
continue;
|
|
735
898
|
const raw = roomPresence[actorId];
|
|
899
|
+
// Server returns full actor records with presence nested under .presence
|
|
736
900
|
const presenceData = (raw?.presence ?? raw);
|
|
737
|
-
if (presenceData?.userId) {
|
|
901
|
+
if (presenceData?.userId && !this._foreignScope(presenceData)) {
|
|
902
|
+
freshActors.add(actorId);
|
|
738
903
|
const user = this._presenceManager.addFromPresence(actorId, presenceData);
|
|
739
904
|
if (user) {
|
|
740
905
|
this._actorToUserId.set(actorId, user.userId);
|
|
@@ -742,16 +907,13 @@ class NoLagNotify extends EventEmitter {
|
|
|
742
907
|
}
|
|
743
908
|
}
|
|
744
909
|
}
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
}).catch((err) => {
|
|
753
|
-
this._log('Failed to re-fetch lobby presence:', err);
|
|
754
|
-
});
|
|
910
|
+
// Vanished actors: present locally but absent from the fresh snapshot
|
|
911
|
+
for (const [actorId] of [...this._actorToUserId]) {
|
|
912
|
+
if (!freshActors.has(actorId)) {
|
|
913
|
+
this._presenceManager.removeByActorId(actorId);
|
|
914
|
+
this._actorToUserId.delete(actorId);
|
|
915
|
+
}
|
|
916
|
+
}
|
|
755
917
|
}
|
|
756
918
|
}
|
|
757
919
|
|