@nolag/signal 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 +52 -9
- package/dist/NoLagSignal.d.ts +68 -15
- package/dist/SignalRoom.d.ts +3 -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 +322 -152
- package/dist/index.cjs.map +1 -1
- package/dist/index.mjs +322 -152
- package/dist/index.mjs.map +1 -1
- package/dist/types.d.ts +10 -7
- package/dist/utils.d.ts +4 -0
- package/package.json +3 -3
package/dist/index.mjs
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import { NoLag } from '@nolag/js-sdk';
|
|
2
|
-
|
|
3
1
|
/**
|
|
4
2
|
* Tiny typed event emitter — framework-agnostic base for NoLag SDKs.
|
|
5
3
|
*
|
|
@@ -142,6 +140,29 @@ function createLogger(prefix, enabled) {
|
|
|
142
140
|
console.log(`[${prefix}]`, ...args);
|
|
143
141
|
};
|
|
144
142
|
}
|
|
143
|
+
// ============ Wrapper registry ============
|
|
144
|
+
// One wrapper instance per (client, appName): two wrappers sharing an app on
|
|
145
|
+
// one connection would collide on topics, presence and the online lobby.
|
|
146
|
+
// Warn (not throw): HMR and tests legitimately construct before disposing.
|
|
147
|
+
const wrapperRegistry = new WeakMap();
|
|
148
|
+
/** Register a wrapper against a client + appName; warns on collision. */
|
|
149
|
+
function registerWrapper(client, appName, wrapperName) {
|
|
150
|
+
let apps = wrapperRegistry.get(client);
|
|
151
|
+
if (!apps) {
|
|
152
|
+
apps = new Map();
|
|
153
|
+
wrapperRegistry.set(client, apps);
|
|
154
|
+
}
|
|
155
|
+
const existing = apps.get(appName);
|
|
156
|
+
if (existing) {
|
|
157
|
+
console.warn(`[${wrapperName}] Another wrapper (${existing}) is already attached to this client for app "${appName}". ` +
|
|
158
|
+
`Use one wrapper per (client, app) — detach the other instance first.`);
|
|
159
|
+
}
|
|
160
|
+
apps.set(appName, wrapperName);
|
|
161
|
+
}
|
|
162
|
+
/** Release a wrapper's (client, appName) registration on detach. */
|
|
163
|
+
function releaseWrapper(client, appName) {
|
|
164
|
+
wrapperRegistry.get(client)?.delete(appName);
|
|
165
|
+
}
|
|
145
166
|
|
|
146
167
|
/** Default app name for NoLag signal SDK */
|
|
147
168
|
const DEFAULT_APP_NAME = 'signal';
|
|
@@ -149,6 +170,8 @@ const DEFAULT_APP_NAME = 'signal';
|
|
|
149
170
|
const TOPIC_SIGNALING = 'signaling';
|
|
150
171
|
/** Lobby ID for global online presence */
|
|
151
172
|
const LOBBY_ID = 'online';
|
|
173
|
+
/** Delay before the post-setup lobby presence refetch (catches simultaneous joiners) */
|
|
174
|
+
const LOBBY_REFRESH_DELAY_MS = 2000;
|
|
152
175
|
|
|
153
176
|
/**
|
|
154
177
|
* SignalRoom — a single signaling room for WebRTC peer discovery and exchange.
|
|
@@ -157,13 +180,17 @@ const LOBBY_ID = 'online';
|
|
|
157
180
|
*/
|
|
158
181
|
class SignalRoom extends EventEmitter {
|
|
159
182
|
/** @internal */
|
|
160
|
-
constructor(name, roomContext, localPeer, options, log) {
|
|
183
|
+
constructor(name, roomContext, localPeer, options, log, isConnected) {
|
|
161
184
|
super();
|
|
185
|
+
// Stored topic handler ref — cleanup removes exactly this, never all
|
|
186
|
+
// handlers for a topic (the client may be shared with other consumers).
|
|
187
|
+
this._onSignalingRef = null;
|
|
162
188
|
this.name = name;
|
|
163
189
|
this._roomContext = roomContext;
|
|
164
190
|
this._localPeer = localPeer;
|
|
165
191
|
this._options = options;
|
|
166
192
|
this._log = log;
|
|
193
|
+
this._isConnected = isConnected;
|
|
167
194
|
this._peerManager = new PeerManager(localPeer.actorTokenId);
|
|
168
195
|
}
|
|
169
196
|
// ============ Public Properties ============
|
|
@@ -229,9 +256,11 @@ class SignalRoom extends EventEmitter {
|
|
|
229
256
|
_subscribe() {
|
|
230
257
|
this._log('Room subscribe:', this.name);
|
|
231
258
|
this._roomContext.subscribe(TOPIC_SIGNALING);
|
|
232
|
-
|
|
259
|
+
// Listen for signals (ref stored for handler-specific removal)
|
|
260
|
+
this._onSignalingRef = (data) => {
|
|
233
261
|
this._handleIncomingSignal(data);
|
|
234
|
-
}
|
|
262
|
+
};
|
|
263
|
+
this._roomContext.on(TOPIC_SIGNALING, this._onSignalingRef);
|
|
235
264
|
}
|
|
236
265
|
/** @internal Set presence and fetch room members */
|
|
237
266
|
_activate() {
|
|
@@ -278,8 +307,16 @@ class SignalRoom extends EventEmitter {
|
|
|
278
307
|
/** @internal Unsubscribe and clean up */
|
|
279
308
|
_cleanup() {
|
|
280
309
|
this._log('Room cleanup:', this.name);
|
|
281
|
-
|
|
282
|
-
|
|
310
|
+
// Server unsubscribes need a live socket; skip when disconnected
|
|
311
|
+
// (best-effort — the core would no-op with an error callback anyway).
|
|
312
|
+
if (this._isConnected()) {
|
|
313
|
+
this._roomContext.unsubscribe(TOPIC_SIGNALING);
|
|
314
|
+
}
|
|
315
|
+
// Handler-specific removal only: the client may be shared, and a bare
|
|
316
|
+
// off(topic) would strip other consumers' handlers too.
|
|
317
|
+
if (this._onSignalingRef)
|
|
318
|
+
this._roomContext.off(TOPIC_SIGNALING, this._onSignalingRef);
|
|
319
|
+
this._onSignalingRef = null;
|
|
283
320
|
this._peerManager.clear();
|
|
284
321
|
this.removeAllListeners();
|
|
285
322
|
}
|
|
@@ -296,6 +333,9 @@ class SignalRoom extends EventEmitter {
|
|
|
296
333
|
const presenceData = {
|
|
297
334
|
peerId: this._localPeer.peerId,
|
|
298
335
|
metadata: this._localPeer.metadata,
|
|
336
|
+
// Scope tag: on a shared client, other apps' wrappers filter our
|
|
337
|
+
// presence out by this (and we filter theirs).
|
|
338
|
+
__scope: this._options.appName,
|
|
299
339
|
};
|
|
300
340
|
this._roomContext.setPresence(presenceData);
|
|
301
341
|
}
|
|
@@ -307,50 +347,116 @@ class SignalRoom extends EventEmitter {
|
|
|
307
347
|
* Provides peer discovery, offer/answer/ICE exchange, and global presence
|
|
308
348
|
* tracking — all framework-agnostic via events.
|
|
309
349
|
*
|
|
350
|
+
* The wrapper NEVER manages the connection. The app owns one core NoLag
|
|
351
|
+
* client (shared by any number of wrappers on distinct apps) and the
|
|
352
|
+
* wrapper attaches to it at construction and releases it via `detach()`.
|
|
353
|
+
*
|
|
310
354
|
* @example
|
|
311
355
|
* ```typescript
|
|
356
|
+
* import { NoLag } from '@nolag/js-sdk';
|
|
312
357
|
* import { NoLagSignal } from '@nolag/signal';
|
|
313
358
|
*
|
|
314
|
-
* const
|
|
359
|
+
* const client = NoLag(async () => (await (await fetch('/api/nolag-token')).json()).token);
|
|
360
|
+
* const signal = new NoLagSignal({ client, appName: 'my-signal' });
|
|
315
361
|
*
|
|
316
|
-
* signal.on('connected', () => console.log('Connected!'));
|
|
317
362
|
* signal.on('peerOnline', (peer) => console.log(peer.peerId, 'is online'));
|
|
318
363
|
*
|
|
319
|
-
* await
|
|
364
|
+
* await client.connect(); // the app owns the connection
|
|
365
|
+
* await signal.ready(); // wrapper setup done (identity, lobby)
|
|
320
366
|
*
|
|
321
367
|
* const room = signal.joinRoom('call-room');
|
|
322
368
|
* room.on('signal', (msg) => {
|
|
323
369
|
* if (msg.type === 'offer') handleOffer(msg);
|
|
324
370
|
* });
|
|
325
371
|
* room.sendOffer(remotePeerId, offer);
|
|
372
|
+
*
|
|
373
|
+
* signal.detach(); // wrapper releases its handlers and topics
|
|
374
|
+
* client.disconnect(); // the app closes the socket
|
|
326
375
|
* ```
|
|
327
376
|
*/
|
|
328
377
|
class NoLagSignal extends EventEmitter {
|
|
329
|
-
constructor(
|
|
378
|
+
constructor(options) {
|
|
330
379
|
super();
|
|
331
|
-
this._client = null;
|
|
332
380
|
this._localPeer = null;
|
|
333
381
|
this._rooms = new Map();
|
|
334
382
|
this._lobby = null;
|
|
335
383
|
this._onlinePeers = new Map();
|
|
336
384
|
this._actorToPeerId = new Map();
|
|
337
|
-
|
|
385
|
+
// Lifecycle: one setup run per connection epoch; detach is terminal.
|
|
386
|
+
this._epoch = 0;
|
|
387
|
+
this._detached = false;
|
|
388
|
+
this._isReady = false;
|
|
389
|
+
this._lobbyRefreshTimer = null;
|
|
390
|
+
// Stored client handler refs. INVARIANT: every client.on() below has a
|
|
391
|
+
// matching client.off() in detach() — never bare off(event), never inline
|
|
392
|
+
// closures on the client.
|
|
393
|
+
this._onConnectRef = () => this._onConnect();
|
|
394
|
+
this._onDisconnectRef = (reason) => {
|
|
395
|
+
this._log('Disconnected:', reason);
|
|
396
|
+
this.emit('disconnected', reason);
|
|
397
|
+
};
|
|
398
|
+
this._onReconnectRef = () => {
|
|
399
|
+
this._log('Reconnecting...');
|
|
400
|
+
this.emit('reconnecting');
|
|
401
|
+
};
|
|
402
|
+
this._onErrorRef = (error) => {
|
|
403
|
+
this._log('Error:', error);
|
|
404
|
+
this.emit('error', error);
|
|
405
|
+
};
|
|
406
|
+
this._onPresenceJoinRef = (data) => this._handleRoomPresenceJoin(data);
|
|
407
|
+
this._onPresenceLeaveRef = (data) => this._handleRoomPresenceLeave(data);
|
|
408
|
+
this._onPresenceUpdateRef = (data) => this._handleRoomPresenceUpdate(data);
|
|
409
|
+
this._onLobbyJoinRef = (data) => this._handleLobbyJoin(data);
|
|
410
|
+
this._onLobbyLeaveRef = (data) => this._handleLobbyLeave(data);
|
|
411
|
+
this._onLobbyUpdateRef = (data) => this._handleLobbyUpdate(data);
|
|
412
|
+
if (!options?.client) {
|
|
413
|
+
throw new TypeError('NoLagSignal requires an injected NoLag client: new NoLagSignal({ client, ... })');
|
|
414
|
+
}
|
|
415
|
+
this._client = options.client;
|
|
338
416
|
this._peerId = generateId();
|
|
339
417
|
this._options = {
|
|
340
418
|
metadata: options.metadata,
|
|
341
419
|
appName: options.appName ?? DEFAULT_APP_NAME,
|
|
342
|
-
url: options.url,
|
|
343
420
|
debug: options.debug ?? false,
|
|
344
|
-
reconnect: options.reconnect ?? true,
|
|
345
421
|
};
|
|
346
422
|
this._log = createLogger('NoLagSignal', this._options.debug);
|
|
423
|
+
this._readyPromise = new Promise((resolve, reject) => {
|
|
424
|
+
this._readyResolve = resolve;
|
|
425
|
+
this._readyReject = reject;
|
|
426
|
+
});
|
|
427
|
+
// ready() rejection is only meaningful to callers that await it
|
|
428
|
+
this._readyPromise.catch(() => { });
|
|
429
|
+
registerWrapper(this._client, this._options.appName, 'NoLagSignal');
|
|
430
|
+
// Construction = attach: wire everything now, with stored refs.
|
|
431
|
+
this._client.on('connect', this._onConnectRef);
|
|
432
|
+
this._client.on('disconnect', this._onDisconnectRef);
|
|
433
|
+
this._client.on('reconnect', this._onReconnectRef);
|
|
434
|
+
this._client.on('error', this._onErrorRef);
|
|
435
|
+
this._client.on('presence:join', this._onPresenceJoinRef);
|
|
436
|
+
this._client.on('presence:leave', this._onPresenceLeaveRef);
|
|
437
|
+
this._client.on('presence:update', this._onPresenceUpdateRef);
|
|
438
|
+
this._client.on('lobbyPresence:join', this._onLobbyJoinRef);
|
|
439
|
+
this._client.on('lobbyPresence:leave', this._onLobbyLeaveRef);
|
|
440
|
+
this._client.on('lobbyPresence:update', this._onLobbyUpdateRef);
|
|
441
|
+
// Attach-to-connected: if the client is already authenticated, run setup.
|
|
442
|
+
// The microtask lets the caller wire wrapper event handlers synchronously
|
|
443
|
+
// first; a racing real 'connect' event wins via the epoch guard.
|
|
444
|
+
queueMicrotask(() => {
|
|
445
|
+
if (this._epoch === 0 && !this._detached && this._client.connected) {
|
|
446
|
+
this._onConnect();
|
|
447
|
+
}
|
|
448
|
+
});
|
|
347
449
|
}
|
|
348
450
|
// ============ Public Properties ============
|
|
349
|
-
/** Whether the underlying connection is established */
|
|
451
|
+
/** Whether the underlying connection is established (connected ≠ ready) */
|
|
350
452
|
get connected() {
|
|
351
|
-
return this._client
|
|
453
|
+
return !this._detached && this._client.connected;
|
|
454
|
+
}
|
|
455
|
+
/** The injected core client (owned by the app, not the wrapper) */
|
|
456
|
+
get client() {
|
|
457
|
+
return this._client;
|
|
352
458
|
}
|
|
353
|
-
/** The local peer's info (available after
|
|
459
|
+
/** The local peer's info (available after ready) */
|
|
354
460
|
get localPeer() {
|
|
355
461
|
return this._localPeer;
|
|
356
462
|
}
|
|
@@ -360,92 +466,151 @@ class NoLagSignal extends EventEmitter {
|
|
|
360
466
|
}
|
|
361
467
|
// ============ Lifecycle ============
|
|
362
468
|
/**
|
|
363
|
-
*
|
|
469
|
+
* Resolves once the wrapper's first setup completed (identity and lobby
|
|
470
|
+
* ready — equivalently, once 'connected' has fired). Rejects only if
|
|
471
|
+
* detach() is called before that. Client auth failures surface via the
|
|
472
|
+
* app's own `await client.connect()`, not here.
|
|
364
473
|
*/
|
|
365
|
-
|
|
366
|
-
this.
|
|
367
|
-
const clientOptions = {
|
|
368
|
-
debug: this._options.debug,
|
|
369
|
-
reconnect: this._options.reconnect,
|
|
370
|
-
};
|
|
371
|
-
if (this._options.url) {
|
|
372
|
-
clientOptions.url = this._options.url;
|
|
373
|
-
}
|
|
374
|
-
this._client = NoLag(this._token, clientOptions);
|
|
375
|
-
// Wire client lifecycle events
|
|
376
|
-
this._client.on('connect', () => {
|
|
377
|
-
this._log('Connected');
|
|
378
|
-
if (this._rooms.size > 0) {
|
|
379
|
-
this._log('Reconnected — restoring rooms...');
|
|
380
|
-
this._restoreRooms();
|
|
381
|
-
this.emit('reconnected');
|
|
382
|
-
}
|
|
383
|
-
});
|
|
384
|
-
this._client.on('disconnect', (reason) => {
|
|
385
|
-
this._log('Disconnected:', reason);
|
|
386
|
-
this.emit('disconnected', reason);
|
|
387
|
-
});
|
|
388
|
-
this._client.on('reconnect', () => {
|
|
389
|
-
this._log('Reconnecting...');
|
|
390
|
-
});
|
|
391
|
-
this._client.on('error', (error) => {
|
|
392
|
-
this._log('Error:', error);
|
|
393
|
-
this.emit('error', error);
|
|
394
|
-
});
|
|
395
|
-
// Connect
|
|
396
|
-
await this._client.connect();
|
|
397
|
-
// Wire room-level presence events
|
|
398
|
-
this._client.on('presence:join', (data) => {
|
|
399
|
-
this._handleRoomPresenceJoin(data);
|
|
400
|
-
});
|
|
401
|
-
this._client.on('presence:leave', (data) => {
|
|
402
|
-
this._handleRoomPresenceLeave(data);
|
|
403
|
-
});
|
|
404
|
-
this._client.on('presence:update', (data) => {
|
|
405
|
-
this._handleRoomPresenceUpdate(data);
|
|
406
|
-
});
|
|
407
|
-
// Create local peer
|
|
408
|
-
this._localPeer = {
|
|
409
|
-
peerId: this._peerId,
|
|
410
|
-
actorTokenId: this._client.actorId,
|
|
411
|
-
connectionState: 'new',
|
|
412
|
-
metadata: this._options.metadata,
|
|
413
|
-
joinedAt: Date.now(),
|
|
414
|
-
isLocal: true,
|
|
415
|
-
};
|
|
416
|
-
this._log('Local peer:', this._localPeer.peerId, '→', this._localPeer.actorTokenId);
|
|
417
|
-
// Set up lobby for global presence
|
|
418
|
-
await this._setupLobby();
|
|
419
|
-
// Emit connected now that _localPeer and lobby are ready
|
|
420
|
-
this.emit('connected');
|
|
421
|
-
// Deferred lobby refetch to catch peers who joined during the setup window
|
|
422
|
-
setTimeout(() => {
|
|
423
|
-
if (this._lobby && this._client?.connected) {
|
|
424
|
-
this._lobby.fetchPresence().then((state) => {
|
|
425
|
-
this._hydrateOnlinePeers(state);
|
|
426
|
-
}).catch(() => { });
|
|
427
|
-
}
|
|
428
|
-
}, 2000);
|
|
474
|
+
ready() {
|
|
475
|
+
return this._readyPromise;
|
|
429
476
|
}
|
|
430
477
|
/**
|
|
431
|
-
*
|
|
478
|
+
* Detach from the client: remove every handler this wrapper added,
|
|
479
|
+
* unsubscribe its topics and lobby (when connected), clear state.
|
|
480
|
+
* Terminal and idempotent; never touches the socket. To use signaling
|
|
481
|
+
* again, construct a new instance.
|
|
432
482
|
*/
|
|
433
|
-
|
|
434
|
-
this.
|
|
435
|
-
|
|
483
|
+
detach() {
|
|
484
|
+
if (this._detached)
|
|
485
|
+
return;
|
|
486
|
+
this._log('Detaching...');
|
|
487
|
+
this._detached = true;
|
|
488
|
+
this._epoch++; // aborts any in-flight setup at its next checkpoint
|
|
489
|
+
if (this._lobbyRefreshTimer) {
|
|
490
|
+
clearTimeout(this._lobbyRefreshTimer);
|
|
491
|
+
this._lobbyRefreshTimer = null;
|
|
492
|
+
}
|
|
493
|
+
// Remove all client handlers by stored ref
|
|
494
|
+
this._client.off('connect', this._onConnectRef);
|
|
495
|
+
this._client.off('disconnect', this._onDisconnectRef);
|
|
496
|
+
this._client.off('reconnect', this._onReconnectRef);
|
|
497
|
+
this._client.off('error', this._onErrorRef);
|
|
498
|
+
this._client.off('presence:join', this._onPresenceJoinRef);
|
|
499
|
+
this._client.off('presence:leave', this._onPresenceLeaveRef);
|
|
500
|
+
this._client.off('presence:update', this._onPresenceUpdateRef);
|
|
501
|
+
this._client.off('lobbyPresence:join', this._onLobbyJoinRef);
|
|
502
|
+
this._client.off('lobbyPresence:leave', this._onLobbyLeaveRef);
|
|
503
|
+
this._client.off('lobbyPresence:update', this._onLobbyUpdateRef);
|
|
504
|
+
// Rooms: handler-specific off + connected-gated server unsubscribe
|
|
436
505
|
for (const name of [...this._rooms.keys()]) {
|
|
437
|
-
this.
|
|
506
|
+
this._rooms.get(name)._cleanup();
|
|
507
|
+
this._rooms.delete(name);
|
|
508
|
+
}
|
|
509
|
+
// Lobby: server unsubscribe is best-effort and needs a live socket
|
|
510
|
+
if (this._lobby && this._client.connected) {
|
|
511
|
+
try {
|
|
512
|
+
this._lobby.unsubscribe();
|
|
513
|
+
}
|
|
514
|
+
catch {
|
|
515
|
+
/* best-effort */
|
|
516
|
+
}
|
|
438
517
|
}
|
|
439
|
-
// Unsubscribe from lobby
|
|
440
|
-
this._lobby?.unsubscribe();
|
|
441
518
|
this._lobby = null;
|
|
442
|
-
// Disconnect client
|
|
443
|
-
this._client?.disconnect();
|
|
444
|
-
this._client = null;
|
|
445
|
-
// Clear state
|
|
446
519
|
this._onlinePeers.clear();
|
|
447
520
|
this._actorToPeerId.clear();
|
|
448
521
|
this._localPeer = null;
|
|
522
|
+
releaseWrapper(this._client, this._options.appName);
|
|
523
|
+
if (!this._isReady) {
|
|
524
|
+
this._readyReject(new Error('NoLagSignal detached before ready'));
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
// ============ Private: Epoch Setup ============
|
|
528
|
+
_onConnect() {
|
|
529
|
+
this._epoch++;
|
|
530
|
+
void this._runSetup(this._epoch);
|
|
531
|
+
}
|
|
532
|
+
/**
|
|
533
|
+
* One setup pass per connection epoch. Serves both initial setup (epoch 1)
|
|
534
|
+
* and reconnect restore (epoch > 1). Aborts silently whenever a newer
|
|
535
|
+
* epoch started or the wrapper detached — checked after every await.
|
|
536
|
+
*/
|
|
537
|
+
async _runSetup(epoch) {
|
|
538
|
+
const stale = () => epoch !== this._epoch || this._detached;
|
|
539
|
+
this._log(this._isReady ? 'Restoring after reconnect...' : 'Setting up...');
|
|
540
|
+
// Identity (client.actorId is guaranteed post-auth)
|
|
541
|
+
if (!this._localPeer) {
|
|
542
|
+
this._localPeer = {
|
|
543
|
+
peerId: this._peerId,
|
|
544
|
+
actorTokenId: this._client.actorId,
|
|
545
|
+
connectionState: 'new',
|
|
546
|
+
metadata: this._options.metadata,
|
|
547
|
+
joinedAt: Date.now(),
|
|
548
|
+
isLocal: true,
|
|
549
|
+
};
|
|
550
|
+
this._log('Local peer:', this._localPeer.peerId, '→', this._localPeer.actorTokenId);
|
|
551
|
+
}
|
|
552
|
+
else {
|
|
553
|
+
this._localPeer.actorTokenId = this._client.actorId;
|
|
554
|
+
}
|
|
555
|
+
// Lobby: subscribe every epoch (idempotent server-side) and diff-hydrate
|
|
556
|
+
// from the returned snapshot — one path for setup and restore.
|
|
557
|
+
if (!this._lobby) {
|
|
558
|
+
this._lobby = this._client.setApp(this._options.appName).setLobby(LOBBY_ID);
|
|
559
|
+
}
|
|
560
|
+
try {
|
|
561
|
+
const state = await this._lobby.subscribe();
|
|
562
|
+
if (stale())
|
|
563
|
+
return;
|
|
564
|
+
this._diffHydrateOnlinePeers(state);
|
|
565
|
+
this._log('Lobby subscribed, online peers:', this._onlinePeers.size);
|
|
566
|
+
}
|
|
567
|
+
catch (err) {
|
|
568
|
+
if (stale())
|
|
569
|
+
return;
|
|
570
|
+
this._log('Lobby subscription failed:', err);
|
|
571
|
+
}
|
|
572
|
+
if (this._isReady) {
|
|
573
|
+
// Server auto-restored topic subscriptions; only room-scoped presence
|
|
574
|
+
// needs re-applying (the core does not restore it).
|
|
575
|
+
for (const room of this._rooms.values()) {
|
|
576
|
+
room._updateLocalPresence();
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
if (stale())
|
|
580
|
+
return;
|
|
581
|
+
// Ready keys on the first setup that COMPLETES, not on epoch 1: an
|
|
582
|
+
// epoch aborted by a racing reconnect must not strand ready().
|
|
583
|
+
if (!this._isReady) {
|
|
584
|
+
this._isReady = true;
|
|
585
|
+
this._readyResolve();
|
|
586
|
+
this.emit('connected');
|
|
587
|
+
}
|
|
588
|
+
else {
|
|
589
|
+
this.emit('reconnected');
|
|
590
|
+
}
|
|
591
|
+
// Deferred lobby refetch: catches peers who joined during the setup
|
|
592
|
+
// window (e.g. simultaneous multi-tab connects).
|
|
593
|
+
this._scheduleLobbyRefresh(epoch);
|
|
594
|
+
}
|
|
595
|
+
_scheduleLobbyRefresh(epoch) {
|
|
596
|
+
if (this._lobbyRefreshTimer)
|
|
597
|
+
clearTimeout(this._lobbyRefreshTimer);
|
|
598
|
+
this._lobbyRefreshTimer = setTimeout(() => {
|
|
599
|
+
this._lobbyRefreshTimer = null;
|
|
600
|
+
if (epoch !== this._epoch || this._detached || !this._client.connected || !this._lobby) {
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
this._lobby
|
|
604
|
+
.fetchPresence()
|
|
605
|
+
.then((state) => {
|
|
606
|
+
if (epoch !== this._epoch || this._detached)
|
|
607
|
+
return;
|
|
608
|
+
this._diffHydrateOnlinePeers(state);
|
|
609
|
+
})
|
|
610
|
+
.catch(() => {
|
|
611
|
+
/* best-effort */
|
|
612
|
+
});
|
|
613
|
+
}, LOBBY_REFRESH_DELAY_MS);
|
|
449
614
|
}
|
|
450
615
|
// ============ Room Management ============
|
|
451
616
|
/**
|
|
@@ -453,9 +618,7 @@ class NoLagSignal extends EventEmitter {
|
|
|
453
618
|
* Returns an existing room if already joined.
|
|
454
619
|
*/
|
|
455
620
|
joinRoom(name) {
|
|
456
|
-
|
|
457
|
-
throw new Error('Not connected — call connect() first');
|
|
458
|
-
}
|
|
621
|
+
this._assertUsable();
|
|
459
622
|
let room = this._rooms.get(name);
|
|
460
623
|
if (!room) {
|
|
461
624
|
room = this._subscribeRoom(name);
|
|
@@ -487,24 +650,41 @@ class NoLagSignal extends EventEmitter {
|
|
|
487
650
|
getOnlinePeers() {
|
|
488
651
|
return Array.from(this._onlinePeers.values());
|
|
489
652
|
}
|
|
653
|
+
// ============ Private: Guards ============
|
|
654
|
+
_assertUsable() {
|
|
655
|
+
if (this._detached) {
|
|
656
|
+
throw new Error('NoLagSignal has been detached — construct a new instance');
|
|
657
|
+
}
|
|
658
|
+
if (!this._isReady || !this._localPeer) {
|
|
659
|
+
throw new Error('NoLagSignal not ready — await ready() or the "connected" event');
|
|
660
|
+
}
|
|
661
|
+
}
|
|
490
662
|
// ============ Private: Room Setup ============
|
|
491
663
|
_subscribeRoom(name) {
|
|
492
|
-
if (!this._client || !this._localPeer) {
|
|
493
|
-
throw new Error('Not connected — call connect() first');
|
|
494
|
-
}
|
|
495
664
|
this._log('Subscribing room:', name);
|
|
496
665
|
const roomContext = this._client.setApp(this._options.appName).setRoom(name);
|
|
497
|
-
const room = new SignalRoom(name, roomContext, this._localPeer, this._options, createLogger(`SignalRoom:${name}`, this._options.debug));
|
|
666
|
+
const room = new SignalRoom(name, roomContext, this._localPeer, this._options, createLogger(`SignalRoom:${name}`, this._options.debug), () => this._client.connected);
|
|
498
667
|
this._rooms.set(name, room);
|
|
499
668
|
room._subscribe();
|
|
500
669
|
return room;
|
|
501
670
|
}
|
|
671
|
+
// ============ Private: Scope Filtering ============
|
|
672
|
+
/**
|
|
673
|
+
* On a shared client, presence events from other apps' wrappers arrive on
|
|
674
|
+
* the same connection-level events. Wrappers stamp their presence with a
|
|
675
|
+
* `__scope` (their appName); a mismatched tag means another app's data.
|
|
676
|
+
* Untagged presence is accepted (older peers in this same app).
|
|
677
|
+
*/
|
|
678
|
+
_foreignScope(data) {
|
|
679
|
+
const scope = data?.__scope;
|
|
680
|
+
return typeof scope === 'string' && scope !== this._options.appName;
|
|
681
|
+
}
|
|
502
682
|
// ============ Private: Room Presence ============
|
|
503
683
|
_handleRoomPresenceJoin(data) {
|
|
504
684
|
if (data.actorTokenId === this._localPeer?.actorTokenId)
|
|
505
685
|
return;
|
|
506
686
|
const presenceData = data.presence;
|
|
507
|
-
if (!presenceData?.peerId)
|
|
687
|
+
if (!presenceData?.peerId || this._foreignScope(presenceData))
|
|
508
688
|
return;
|
|
509
689
|
const peer = this._presenceToPeer(data.actorTokenId, presenceData);
|
|
510
690
|
this._actorToPeerId.set(data.actorTokenId, peer.peerId);
|
|
@@ -529,7 +709,7 @@ class NoLagSignal extends EventEmitter {
|
|
|
529
709
|
if (data.actorTokenId === this._localPeer?.actorTokenId)
|
|
530
710
|
return;
|
|
531
711
|
const presenceData = data.presence;
|
|
532
|
-
if (!presenceData?.peerId)
|
|
712
|
+
if (!presenceData?.peerId || this._foreignScope(presenceData))
|
|
533
713
|
return;
|
|
534
714
|
if (this._onlinePeers.has(presenceData.peerId)) {
|
|
535
715
|
const peer = this._presenceToPeer(data.actorTokenId, presenceData);
|
|
@@ -541,37 +721,12 @@ class NoLagSignal extends EventEmitter {
|
|
|
541
721
|
}
|
|
542
722
|
}
|
|
543
723
|
// ============ Private: Lobby ============
|
|
544
|
-
async _setupLobby() {
|
|
545
|
-
if (!this._client)
|
|
546
|
-
return;
|
|
547
|
-
this._lobby = this._client.setApp(this._options.appName).setLobby(LOBBY_ID);
|
|
548
|
-
const lobbyHandler = (type) => (data) => {
|
|
549
|
-
const event = data;
|
|
550
|
-
if (type === 'join')
|
|
551
|
-
this._handleLobbyJoin(event);
|
|
552
|
-
else if (type === 'leave')
|
|
553
|
-
this._handleLobbyLeave(event);
|
|
554
|
-
else
|
|
555
|
-
this._handleLobbyUpdate(event);
|
|
556
|
-
};
|
|
557
|
-
this._client.on('lobbyPresence:join', lobbyHandler('join'));
|
|
558
|
-
this._client.on('lobbyPresence:leave', lobbyHandler('leave'));
|
|
559
|
-
this._client.on('lobbyPresence:update', lobbyHandler('update'));
|
|
560
|
-
try {
|
|
561
|
-
const initialState = await this._lobby.subscribe();
|
|
562
|
-
this._hydrateOnlinePeers(initialState);
|
|
563
|
-
this._log('Lobby subscribed, online peers:', this._onlinePeers.size);
|
|
564
|
-
}
|
|
565
|
-
catch (err) {
|
|
566
|
-
this._log('Lobby subscription failed:', err);
|
|
567
|
-
}
|
|
568
|
-
}
|
|
569
724
|
_handleLobbyJoin(event) {
|
|
570
725
|
const { actorId, data } = event;
|
|
571
726
|
if (actorId === this._localPeer?.actorTokenId)
|
|
572
727
|
return;
|
|
573
728
|
const presenceData = data;
|
|
574
|
-
if (!presenceData.peerId)
|
|
729
|
+
if (!presenceData.peerId || this._foreignScope(presenceData))
|
|
575
730
|
return;
|
|
576
731
|
const peer = this._presenceToPeer(actorId, presenceData);
|
|
577
732
|
this._actorToPeerId.set(actorId, peer.peerId);
|
|
@@ -585,6 +740,8 @@ class NoLagSignal extends EventEmitter {
|
|
|
585
740
|
if (actorId === this._localPeer?.actorTokenId)
|
|
586
741
|
return;
|
|
587
742
|
const presenceData = data;
|
|
743
|
+
if (this._foreignScope(presenceData))
|
|
744
|
+
return;
|
|
588
745
|
const peerId = presenceData?.peerId
|
|
589
746
|
|| this._actorToPeerId.get(actorId)
|
|
590
747
|
|| this._findPeerIdByActorId(actorId);
|
|
@@ -602,29 +759,57 @@ class NoLagSignal extends EventEmitter {
|
|
|
602
759
|
if (actorId === this._localPeer?.actorTokenId)
|
|
603
760
|
return;
|
|
604
761
|
const presenceData = data;
|
|
605
|
-
if (!presenceData.peerId)
|
|
762
|
+
if (!presenceData.peerId || this._foreignScope(presenceData))
|
|
606
763
|
return;
|
|
607
764
|
const peer = this._presenceToPeer(actorId, presenceData);
|
|
608
765
|
this._onlinePeers.set(peer.peerId, peer);
|
|
609
766
|
}
|
|
610
|
-
|
|
767
|
+
/**
|
|
768
|
+
* Reconcile the online-peer map against a fresh lobby snapshot, emitting
|
|
769
|
+
* only the deltas (peerOffline for vanished, peerOnline for new). One path
|
|
770
|
+
* for initial hydration, reconnect restore, and the deferred refetch.
|
|
771
|
+
*/
|
|
772
|
+
_diffHydrateOnlinePeers(state) {
|
|
773
|
+
// Build the fresh peer set from the snapshot
|
|
774
|
+
const fresh = new Map();
|
|
775
|
+
const freshActors = new Map();
|
|
611
776
|
for (const roomId of Object.keys(state)) {
|
|
612
777
|
const roomPresence = state[roomId];
|
|
613
778
|
for (const actorId of Object.keys(roomPresence)) {
|
|
614
779
|
if (actorId === this._localPeer?.actorTokenId)
|
|
615
780
|
continue;
|
|
616
781
|
const raw = roomPresence[actorId];
|
|
782
|
+
// Server returns full actor records with presence nested under .presence
|
|
617
783
|
const presenceData = (raw?.presence ?? raw);
|
|
618
|
-
if (presenceData?.peerId) {
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
if (!this._onlinePeers.has(peer.peerId)) {
|
|
622
|
-
this._onlinePeers.set(peer.peerId, peer);
|
|
623
|
-
this.emit('peerOnline', peer);
|
|
784
|
+
if (presenceData?.peerId && !this._foreignScope(presenceData)) {
|
|
785
|
+
if (!fresh.has(presenceData.peerId)) {
|
|
786
|
+
fresh.set(presenceData.peerId, this._presenceToPeer(actorId, presenceData));
|
|
624
787
|
}
|
|
788
|
+
freshActors.set(actorId, presenceData.peerId);
|
|
625
789
|
}
|
|
626
790
|
}
|
|
627
791
|
}
|
|
792
|
+
// Vanished peers
|
|
793
|
+
for (const [peerId, peer] of [...this._onlinePeers]) {
|
|
794
|
+
if (!fresh.has(peerId)) {
|
|
795
|
+
this._onlinePeers.delete(peerId);
|
|
796
|
+
for (const [actorId, mappedPeerId] of [...this._actorToPeerId]) {
|
|
797
|
+
if (mappedPeerId === peerId)
|
|
798
|
+
this._actorToPeerId.delete(actorId);
|
|
799
|
+
}
|
|
800
|
+
this.emit('peerOffline', peer);
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
// New peers
|
|
804
|
+
for (const [peerId, peer] of fresh) {
|
|
805
|
+
if (!this._onlinePeers.has(peerId)) {
|
|
806
|
+
this._onlinePeers.set(peerId, peer);
|
|
807
|
+
this.emit('peerOnline', peer);
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
for (const [actorId, peerId] of freshActors) {
|
|
811
|
+
this._actorToPeerId.set(actorId, peerId);
|
|
812
|
+
}
|
|
628
813
|
}
|
|
629
814
|
// ============ Private: Helpers ============
|
|
630
815
|
_presenceToPeer(actorTokenId, data) {
|
|
@@ -644,21 +829,6 @@ class NoLagSignal extends EventEmitter {
|
|
|
644
829
|
}
|
|
645
830
|
return undefined;
|
|
646
831
|
}
|
|
647
|
-
_restoreRooms() {
|
|
648
|
-
// On reconnect, js-sdk auto-restores subscriptions.
|
|
649
|
-
// Re-set presence on all active rooms.
|
|
650
|
-
for (const room of this._rooms.values()) {
|
|
651
|
-
room._updateLocalPresence();
|
|
652
|
-
}
|
|
653
|
-
// Re-fetch lobby presence
|
|
654
|
-
this._lobby?.fetchPresence().then((state) => {
|
|
655
|
-
this._onlinePeers.clear();
|
|
656
|
-
this._actorToPeerId.clear();
|
|
657
|
-
this._hydrateOnlinePeers(state);
|
|
658
|
-
}).catch((err) => {
|
|
659
|
-
this._log('Failed to re-fetch lobby presence:', err);
|
|
660
|
-
});
|
|
661
|
-
}
|
|
662
832
|
}
|
|
663
833
|
|
|
664
834
|
export { EventEmitter, NoLagSignal, SignalRoom };
|