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