@nolag/collab 0.1.2 → 1.1.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 +63 -18
- package/dist/CollabDocument.d.ts +4 -1
- package/dist/NoLagCollab.d.ts +69 -16
- package/dist/browser.js +1 -1
- package/dist/browser.js.map +1 -1
- package/dist/constants.d.ts +2 -0
- package/dist/index.cjs +346 -166
- package/dist/index.cjs.map +1 -1
- package/dist/index.mjs +346 -166
- package/dist/index.mjs.map +1 -1
- package/dist/react-native.d.ts +13 -0
- package/dist/react-native.js +1160 -0
- package/dist/react-native.js.map +1 -0
- package/dist/types.d.ts +8 -6
- package/dist/utils.d.ts +4 -0
- package/package.json +10 -4
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
|
*
|
|
@@ -320,6 +318,29 @@ function createLogger(prefix, enabled) {
|
|
|
320
318
|
console.log(`[${prefix}]`, ...args);
|
|
321
319
|
};
|
|
322
320
|
}
|
|
321
|
+
// ============ Wrapper registry ============
|
|
322
|
+
// One wrapper instance per (client, appName): two wrappers sharing an app on
|
|
323
|
+
// one connection would collide on topics, presence and the online lobby.
|
|
324
|
+
// Warn (not throw): HMR and tests legitimately construct before disposing.
|
|
325
|
+
const wrapperRegistry = new WeakMap();
|
|
326
|
+
/** Register a wrapper against a client + appName; warns on collision. */
|
|
327
|
+
function registerWrapper(client, appName, wrapperName) {
|
|
328
|
+
let apps = wrapperRegistry.get(client);
|
|
329
|
+
if (!apps) {
|
|
330
|
+
apps = new Map();
|
|
331
|
+
wrapperRegistry.set(client, apps);
|
|
332
|
+
}
|
|
333
|
+
const existing = apps.get(appName);
|
|
334
|
+
if (existing) {
|
|
335
|
+
console.warn(`[${wrapperName}] Another wrapper (${existing}) is already attached to this client for app "${appName}". ` +
|
|
336
|
+
`Use one wrapper per (client, app) — detach the other instance first.`);
|
|
337
|
+
}
|
|
338
|
+
apps.set(appName, wrapperName);
|
|
339
|
+
}
|
|
340
|
+
/** Release a wrapper's (client, appName) registration on detach. */
|
|
341
|
+
function releaseWrapper(client, appName) {
|
|
342
|
+
wrapperRegistry.get(client)?.delete(appName);
|
|
343
|
+
}
|
|
323
344
|
|
|
324
345
|
/** Default app name for NoLag collab SDK */
|
|
325
346
|
const DEFAULT_APP_NAME = 'collab';
|
|
@@ -335,6 +356,8 @@ const TOPIC_OPERATIONS = 'operations';
|
|
|
335
356
|
const TOPIC_CURSORS = '_cursors';
|
|
336
357
|
/** Lobby ID for global online presence */
|
|
337
358
|
const LOBBY_ID = 'online';
|
|
359
|
+
/** Delay before the post-setup lobby presence refetch (catches simultaneous joiners) */
|
|
360
|
+
const LOBBY_REFRESH_DELAY_MS = 2000;
|
|
338
361
|
|
|
339
362
|
/**
|
|
340
363
|
* CollabDocument — a single collaborative document room.
|
|
@@ -347,16 +370,21 @@ const LOBBY_ID = 'online';
|
|
|
347
370
|
*/
|
|
348
371
|
class CollabDocument extends EventEmitter {
|
|
349
372
|
/** @internal */
|
|
350
|
-
constructor(name, roomContext, localUser, options, log) {
|
|
373
|
+
constructor(name, roomContext, localUser, options, log, isConnected) {
|
|
351
374
|
super();
|
|
352
375
|
/** Throttle state for cursor updates */
|
|
353
376
|
this._cursorThrottleTimer = null;
|
|
354
377
|
this._pendingCursorUpdate = null;
|
|
378
|
+
// Stored topic handler refs — cleanup removes exactly these, never all
|
|
379
|
+
// handlers for a topic (the client may be shared with other consumers).
|
|
380
|
+
this._onOperationsRef = null;
|
|
381
|
+
this._onCursorsRef = null;
|
|
355
382
|
this.name = name;
|
|
356
383
|
this._roomContext = roomContext;
|
|
357
384
|
this._localUser = localUser;
|
|
358
385
|
this._options = options;
|
|
359
386
|
this._log = log;
|
|
387
|
+
this._isConnected = isConnected;
|
|
360
388
|
this._presenceManager = new PresenceManager(localUser.actorTokenId);
|
|
361
389
|
this._operationStore = new OperationStore(options.maxOperationCache);
|
|
362
390
|
this._awarenessManager = new AwarenessManager(localUser.userId);
|
|
@@ -450,12 +478,16 @@ class CollabDocument extends EventEmitter {
|
|
|
450
478
|
this._log('Document subscribe:', this.name);
|
|
451
479
|
this._roomContext.subscribe(TOPIC_OPERATIONS);
|
|
452
480
|
this._roomContext.subscribe(TOPIC_CURSORS);
|
|
453
|
-
|
|
481
|
+
// Listen for operations (refs stored for handler-specific removal)
|
|
482
|
+
this._onOperationsRef = (data) => {
|
|
454
483
|
this._handleIncomingOperation(data);
|
|
455
|
-
}
|
|
456
|
-
this._roomContext.on(
|
|
484
|
+
};
|
|
485
|
+
this._roomContext.on(TOPIC_OPERATIONS, this._onOperationsRef);
|
|
486
|
+
// Listen for cursors
|
|
487
|
+
this._onCursorsRef = (data) => {
|
|
457
488
|
this._handleIncomingCursor(data);
|
|
458
|
-
}
|
|
489
|
+
};
|
|
490
|
+
this._roomContext.on(TOPIC_CURSORS, this._onCursorsRef);
|
|
459
491
|
}
|
|
460
492
|
/** @internal Set presence and fetch current room members */
|
|
461
493
|
_activate() {
|
|
@@ -526,15 +558,27 @@ class CollabDocument extends EventEmitter {
|
|
|
526
558
|
/** @internal Unsubscribe and clean up */
|
|
527
559
|
_cleanup() {
|
|
528
560
|
this._log('Document cleanup:', this.name);
|
|
529
|
-
// Cancel throttle timer
|
|
561
|
+
// Cancel cursor throttle timer
|
|
530
562
|
if (this._cursorThrottleTimer !== null) {
|
|
531
563
|
clearTimeout(this._cursorThrottleTimer);
|
|
532
564
|
this._cursorThrottleTimer = null;
|
|
533
565
|
}
|
|
534
|
-
this.
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
this.
|
|
566
|
+
this._pendingCursorUpdate = null;
|
|
567
|
+
// Server unsubscribes need a live socket; skip when disconnected
|
|
568
|
+
// (best-effort — the core would no-op with an error callback anyway).
|
|
569
|
+
if (this._isConnected()) {
|
|
570
|
+
this._roomContext.unsubscribe(TOPIC_OPERATIONS);
|
|
571
|
+
this._roomContext.unsubscribe(TOPIC_CURSORS);
|
|
572
|
+
}
|
|
573
|
+
// Handler-specific removal only: the client may be shared, and a bare
|
|
574
|
+
// off(topic) would strip other consumers' handlers too.
|
|
575
|
+
if (this._onOperationsRef)
|
|
576
|
+
this._roomContext.off(TOPIC_OPERATIONS, this._onOperationsRef);
|
|
577
|
+
if (this._onCursorsRef)
|
|
578
|
+
this._roomContext.off(TOPIC_CURSORS, this._onCursorsRef);
|
|
579
|
+
this._onOperationsRef = null;
|
|
580
|
+
this._onCursorsRef = null;
|
|
581
|
+
// Disposes all per-user idle timers alongside cursor/status state.
|
|
538
582
|
this._awarenessManager.dispose();
|
|
539
583
|
this._presenceManager.clear();
|
|
540
584
|
this._operationStore.clear();
|
|
@@ -588,6 +632,9 @@ class CollabDocument extends EventEmitter {
|
|
|
588
632
|
color: this._localUser.color,
|
|
589
633
|
status: this._localUser.status,
|
|
590
634
|
metadata: this._localUser.metadata,
|
|
635
|
+
// Scope tag: on a shared client, other apps' wrappers filter our
|
|
636
|
+
// presence out by this (and we filter theirs).
|
|
637
|
+
__scope: this._options.appName,
|
|
591
638
|
};
|
|
592
639
|
this._roomContext.setPresence(presenceData);
|
|
593
640
|
}
|
|
@@ -609,32 +656,70 @@ class CollabDocument extends EventEmitter {
|
|
|
609
656
|
* Provides document-scoped operations, cursor broadcasting, and user awareness
|
|
610
657
|
* (idle detection, status tracking) — all framework-agnostic via events.
|
|
611
658
|
*
|
|
659
|
+
* The wrapper NEVER manages the connection. The app owns one core NoLag
|
|
660
|
+
* client (shared by any number of wrappers on distinct apps) and the
|
|
661
|
+
* wrapper attaches to it at construction and releases it via `detach()`.
|
|
662
|
+
*
|
|
612
663
|
* @example
|
|
613
664
|
* ```typescript
|
|
665
|
+
* import { NoLag } from '@nolag/js-sdk';
|
|
614
666
|
* import { NoLagCollab } from '@nolag/collab';
|
|
615
667
|
*
|
|
616
|
-
* const
|
|
668
|
+
* const client = NoLag(async () => (await (await fetch('/api/nolag-token')).json()).token);
|
|
669
|
+
* const collab = new NoLagCollab({ client, appName: 'my-collab', username: 'Alice' });
|
|
617
670
|
*
|
|
618
|
-
* collab.on('connected', () => console.log('Connected!'));
|
|
619
671
|
* collab.on('userOnline', (user) => console.log(user.username, 'is online'));
|
|
620
672
|
*
|
|
621
|
-
* await
|
|
673
|
+
* await client.connect(); // the app owns the connection
|
|
674
|
+
* await collab.ready(); // wrapper setup done (identity, lobby, documents)
|
|
622
675
|
*
|
|
623
676
|
* const doc = collab.joinDocument('my-doc');
|
|
624
677
|
* doc.on('operation', (op) => applyOp(op));
|
|
625
678
|
* doc.sendOperation('insert', { position: 0, content: 'Hello' });
|
|
679
|
+
*
|
|
680
|
+
* collab.detach(); // wrapper releases its handlers and topics
|
|
681
|
+
* client.disconnect(); // the app closes the socket
|
|
626
682
|
* ```
|
|
627
683
|
*/
|
|
628
684
|
class NoLagCollab extends EventEmitter {
|
|
629
|
-
constructor(
|
|
685
|
+
constructor(options) {
|
|
630
686
|
super();
|
|
631
|
-
this._client = null;
|
|
632
687
|
this._localUser = null;
|
|
633
688
|
this._documents = new Map();
|
|
634
689
|
this._lobby = null;
|
|
635
690
|
this._onlineUsers = new Map();
|
|
636
691
|
this._actorToUserId = new Map();
|
|
637
|
-
|
|
692
|
+
// Lifecycle: one setup run per connection epoch; detach is terminal.
|
|
693
|
+
this._epoch = 0;
|
|
694
|
+
this._detached = false;
|
|
695
|
+
this._isReady = false;
|
|
696
|
+
this._lobbyRefreshTimer = null;
|
|
697
|
+
// Stored client handler refs. INVARIANT: every client.on() below has a
|
|
698
|
+
// matching client.off() in detach() — never bare off(event), never inline
|
|
699
|
+
// closures on the client.
|
|
700
|
+
this._onConnectRef = () => this._onConnect();
|
|
701
|
+
this._onDisconnectRef = (reason) => {
|
|
702
|
+
this._log('Disconnected:', reason);
|
|
703
|
+
this.emit('disconnected', reason);
|
|
704
|
+
};
|
|
705
|
+
this._onReconnectRef = () => {
|
|
706
|
+
this._log('Reconnecting...');
|
|
707
|
+
this.emit('reconnecting');
|
|
708
|
+
};
|
|
709
|
+
this._onErrorRef = (error) => {
|
|
710
|
+
this._log('Error:', error);
|
|
711
|
+
this.emit('error', error);
|
|
712
|
+
};
|
|
713
|
+
this._onPresenceJoinRef = (data) => this._handleRoomPresenceJoin(data);
|
|
714
|
+
this._onPresenceLeaveRef = (data) => this._handleRoomPresenceLeave(data);
|
|
715
|
+
this._onPresenceUpdateRef = (data) => this._handleRoomPresenceUpdate(data);
|
|
716
|
+
this._onLobbyJoinRef = (data) => this._handleLobbyJoin(data);
|
|
717
|
+
this._onLobbyLeaveRef = (data) => this._handleLobbyLeave(data);
|
|
718
|
+
this._onLobbyUpdateRef = (data) => this._handleLobbyUpdate(data);
|
|
719
|
+
if (!options?.client) {
|
|
720
|
+
throw new TypeError('NoLagCollab requires an injected NoLag client: new NoLagCollab({ client, username, ... })');
|
|
721
|
+
}
|
|
722
|
+
this._client = options.client;
|
|
638
723
|
this._userId = generateId();
|
|
639
724
|
this._options = {
|
|
640
725
|
username: options.username,
|
|
@@ -642,22 +727,50 @@ class NoLagCollab extends EventEmitter {
|
|
|
642
727
|
color: options.color,
|
|
643
728
|
metadata: options.metadata,
|
|
644
729
|
appName: options.appName ?? DEFAULT_APP_NAME,
|
|
645
|
-
url: options.url,
|
|
646
730
|
maxOperationCache: options.maxOperationCache ?? DEFAULT_MAX_OPERATION_CACHE,
|
|
647
731
|
idleTimeout: options.idleTimeout ?? DEFAULT_IDLE_TIMEOUT,
|
|
648
732
|
cursorThrottle: options.cursorThrottle ?? DEFAULT_CURSOR_THROTTLE,
|
|
649
733
|
debug: options.debug ?? false,
|
|
650
|
-
reconnect: options.reconnect ?? true,
|
|
651
734
|
documents: options.documents ?? [],
|
|
652
735
|
};
|
|
653
736
|
this._log = createLogger('NoLagCollab', this._options.debug);
|
|
737
|
+
this._readyPromise = new Promise((resolve, reject) => {
|
|
738
|
+
this._readyResolve = resolve;
|
|
739
|
+
this._readyReject = reject;
|
|
740
|
+
});
|
|
741
|
+
// ready() rejection is only meaningful to callers that await it
|
|
742
|
+
this._readyPromise.catch(() => { });
|
|
743
|
+
registerWrapper(this._client, this._options.appName, 'NoLagCollab');
|
|
744
|
+
// Construction = attach: wire everything now, with stored refs.
|
|
745
|
+
this._client.on('connect', this._onConnectRef);
|
|
746
|
+
this._client.on('disconnect', this._onDisconnectRef);
|
|
747
|
+
this._client.on('reconnect', this._onReconnectRef);
|
|
748
|
+
this._client.on('error', this._onErrorRef);
|
|
749
|
+
this._client.on('presence:join', this._onPresenceJoinRef);
|
|
750
|
+
this._client.on('presence:leave', this._onPresenceLeaveRef);
|
|
751
|
+
this._client.on('presence:update', this._onPresenceUpdateRef);
|
|
752
|
+
this._client.on('lobbyPresence:join', this._onLobbyJoinRef);
|
|
753
|
+
this._client.on('lobbyPresence:leave', this._onLobbyLeaveRef);
|
|
754
|
+
this._client.on('lobbyPresence:update', this._onLobbyUpdateRef);
|
|
755
|
+
// Attach-to-connected: if the client is already authenticated, run setup.
|
|
756
|
+
// The microtask lets the caller wire wrapper event handlers synchronously
|
|
757
|
+
// first; a racing real 'connect' event wins via the epoch guard.
|
|
758
|
+
queueMicrotask(() => {
|
|
759
|
+
if (this._epoch === 0 && !this._detached && this._client.connected) {
|
|
760
|
+
this._onConnect();
|
|
761
|
+
}
|
|
762
|
+
});
|
|
654
763
|
}
|
|
655
764
|
// ============ Public Properties ============
|
|
656
|
-
/** Whether the underlying connection is established */
|
|
765
|
+
/** Whether the underlying connection is established (connected ≠ ready) */
|
|
657
766
|
get connected() {
|
|
658
|
-
return this._client
|
|
767
|
+
return !this._detached && this._client.connected;
|
|
768
|
+
}
|
|
769
|
+
/** The injected core client (owned by the app, not the wrapper) */
|
|
770
|
+
get client() {
|
|
771
|
+
return this._client;
|
|
659
772
|
}
|
|
660
|
-
/** The local user's info (available after
|
|
773
|
+
/** The local user's info (available after ready) */
|
|
661
774
|
get localUser() {
|
|
662
775
|
return this._localUser;
|
|
663
776
|
}
|
|
@@ -667,99 +780,161 @@ class NoLagCollab extends EventEmitter {
|
|
|
667
780
|
}
|
|
668
781
|
// ============ Lifecycle ============
|
|
669
782
|
/**
|
|
670
|
-
*
|
|
783
|
+
* Resolves once the wrapper's first setup completed (identity, lobby and
|
|
784
|
+
* configured documents ready — equivalently, once 'connected' has fired).
|
|
785
|
+
* Rejects only if detach() is called before that. Client auth failures
|
|
786
|
+
* surface via the app's own `await client.connect()`, not here.
|
|
671
787
|
*/
|
|
672
|
-
|
|
673
|
-
this.
|
|
674
|
-
const clientOptions = {
|
|
675
|
-
debug: this._options.debug,
|
|
676
|
-
reconnect: this._options.reconnect,
|
|
677
|
-
};
|
|
678
|
-
if (this._options.url) {
|
|
679
|
-
clientOptions.url = this._options.url;
|
|
680
|
-
}
|
|
681
|
-
this._client = jsSdk.NoLag(this._token, clientOptions);
|
|
682
|
-
// Wire client lifecycle events
|
|
683
|
-
this._client.on('connect', () => {
|
|
684
|
-
this._log('Connected');
|
|
685
|
-
if (this._documents.size > 0) {
|
|
686
|
-
this._log('Reconnected — restoring documents...');
|
|
687
|
-
this._restoreDocuments();
|
|
688
|
-
this.emit('reconnected');
|
|
689
|
-
}
|
|
690
|
-
});
|
|
691
|
-
this._client.on('disconnect', (reason) => {
|
|
692
|
-
this._log('Disconnected:', reason);
|
|
693
|
-
this.emit('disconnected', reason);
|
|
694
|
-
});
|
|
695
|
-
this._client.on('reconnect', () => {
|
|
696
|
-
this._log('Reconnecting...');
|
|
697
|
-
});
|
|
698
|
-
this._client.on('error', (error) => {
|
|
699
|
-
this._log('Error:', error);
|
|
700
|
-
this.emit('error', error);
|
|
701
|
-
});
|
|
702
|
-
// Connect
|
|
703
|
-
await this._client.connect();
|
|
704
|
-
// Wire room-level presence events
|
|
705
|
-
this._client.on('presence:join', (data) => {
|
|
706
|
-
this._handleRoomPresenceJoin(data);
|
|
707
|
-
});
|
|
708
|
-
this._client.on('presence:leave', (data) => {
|
|
709
|
-
this._handleRoomPresenceLeave(data);
|
|
710
|
-
});
|
|
711
|
-
this._client.on('presence:update', (data) => {
|
|
712
|
-
this._handleRoomPresenceUpdate(data);
|
|
713
|
-
});
|
|
714
|
-
// Create local user
|
|
715
|
-
this._localUser = {
|
|
716
|
-
userId: this._userId,
|
|
717
|
-
actorTokenId: this._client.actorId,
|
|
718
|
-
username: this._options.username,
|
|
719
|
-
avatar: this._options.avatar,
|
|
720
|
-
color: this._options.color,
|
|
721
|
-
status: 'active',
|
|
722
|
-
metadata: this._options.metadata,
|
|
723
|
-
joinedAt: Date.now(),
|
|
724
|
-
isLocal: true,
|
|
725
|
-
};
|
|
726
|
-
this._log('Local user:', this._localUser.userId, '→', this._localUser.actorTokenId);
|
|
727
|
-
// Set up lobby for global presence
|
|
728
|
-
await this._setupLobby();
|
|
729
|
-
// Emit connected now that _localUser and lobby are ready
|
|
730
|
-
this.emit('connected');
|
|
731
|
-
// Auto-join documents specified in options
|
|
732
|
-
for (const docName of this._options.documents) {
|
|
733
|
-
this.joinDocument(docName);
|
|
734
|
-
}
|
|
735
|
-
// Deferred lobby refetch to catch users who joined during the setup window
|
|
736
|
-
setTimeout(() => {
|
|
737
|
-
if (this._lobby && this._client?.connected) {
|
|
738
|
-
this._lobby.fetchPresence().then((state) => {
|
|
739
|
-
this._hydrateOnlineUsers(state);
|
|
740
|
-
}).catch(() => { });
|
|
741
|
-
}
|
|
742
|
-
}, 2000);
|
|
788
|
+
ready() {
|
|
789
|
+
return this._readyPromise;
|
|
743
790
|
}
|
|
744
791
|
/**
|
|
745
|
-
*
|
|
792
|
+
* Detach from the client: remove every handler this wrapper added,
|
|
793
|
+
* unsubscribe its topics and lobby (when connected), clear state.
|
|
794
|
+
* Terminal and idempotent; never touches the socket. To use collab again,
|
|
795
|
+
* construct a new instance.
|
|
746
796
|
*/
|
|
747
|
-
|
|
748
|
-
this.
|
|
749
|
-
|
|
797
|
+
detach() {
|
|
798
|
+
if (this._detached)
|
|
799
|
+
return;
|
|
800
|
+
this._log('Detaching...');
|
|
801
|
+
this._detached = true;
|
|
802
|
+
this._epoch++; // aborts any in-flight setup at its next checkpoint
|
|
803
|
+
if (this._lobbyRefreshTimer) {
|
|
804
|
+
clearTimeout(this._lobbyRefreshTimer);
|
|
805
|
+
this._lobbyRefreshTimer = null;
|
|
806
|
+
}
|
|
807
|
+
// Remove all client handlers by stored ref
|
|
808
|
+
this._client.off('connect', this._onConnectRef);
|
|
809
|
+
this._client.off('disconnect', this._onDisconnectRef);
|
|
810
|
+
this._client.off('reconnect', this._onReconnectRef);
|
|
811
|
+
this._client.off('error', this._onErrorRef);
|
|
812
|
+
this._client.off('presence:join', this._onPresenceJoinRef);
|
|
813
|
+
this._client.off('presence:leave', this._onPresenceLeaveRef);
|
|
814
|
+
this._client.off('presence:update', this._onPresenceUpdateRef);
|
|
815
|
+
this._client.off('lobbyPresence:join', this._onLobbyJoinRef);
|
|
816
|
+
this._client.off('lobbyPresence:leave', this._onLobbyLeaveRef);
|
|
817
|
+
this._client.off('lobbyPresence:update', this._onLobbyUpdateRef);
|
|
818
|
+
// Documents: handler-specific off + connected-gated server unsubscribe.
|
|
819
|
+
// _cleanup also clears each document's cursor-throttle and idle timers.
|
|
750
820
|
for (const name of [...this._documents.keys()]) {
|
|
751
|
-
this.
|
|
821
|
+
this._documents.get(name)._cleanup();
|
|
822
|
+
this._documents.delete(name);
|
|
823
|
+
}
|
|
824
|
+
// Lobby: server unsubscribe is best-effort and needs a live socket
|
|
825
|
+
if (this._lobby && this._client.connected) {
|
|
826
|
+
try {
|
|
827
|
+
this._lobby.unsubscribe();
|
|
828
|
+
}
|
|
829
|
+
catch {
|
|
830
|
+
/* best-effort */
|
|
831
|
+
}
|
|
752
832
|
}
|
|
753
|
-
// Unsubscribe from lobby
|
|
754
|
-
this._lobby?.unsubscribe();
|
|
755
833
|
this._lobby = null;
|
|
756
|
-
// Disconnect client
|
|
757
|
-
this._client?.disconnect();
|
|
758
|
-
this._client = null;
|
|
759
|
-
// Clear state
|
|
760
834
|
this._onlineUsers.clear();
|
|
761
835
|
this._actorToUserId.clear();
|
|
762
836
|
this._localUser = null;
|
|
837
|
+
releaseWrapper(this._client, this._options.appName);
|
|
838
|
+
if (!this._isReady) {
|
|
839
|
+
this._readyReject(new Error('NoLagCollab detached before ready'));
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
// ============ Private: Epoch Setup ============
|
|
843
|
+
_onConnect() {
|
|
844
|
+
this._epoch++;
|
|
845
|
+
void this._runSetup(this._epoch);
|
|
846
|
+
}
|
|
847
|
+
/**
|
|
848
|
+
* One setup pass per connection epoch. Serves both initial setup (epoch 1)
|
|
849
|
+
* and reconnect restore (epoch > 1). Aborts silently whenever a newer
|
|
850
|
+
* epoch started or the wrapper detached — checked after every await.
|
|
851
|
+
*/
|
|
852
|
+
async _runSetup(epoch) {
|
|
853
|
+
const stale = () => epoch !== this._epoch || this._detached;
|
|
854
|
+
this._log(this._isReady ? 'Restoring after reconnect...' : 'Setting up...');
|
|
855
|
+
// Identity (client.actorId is guaranteed post-auth)
|
|
856
|
+
if (!this._localUser) {
|
|
857
|
+
this._localUser = {
|
|
858
|
+
userId: this._userId,
|
|
859
|
+
actorTokenId: this._client.actorId,
|
|
860
|
+
username: this._options.username,
|
|
861
|
+
avatar: this._options.avatar,
|
|
862
|
+
color: this._options.color,
|
|
863
|
+
status: 'active',
|
|
864
|
+
metadata: this._options.metadata,
|
|
865
|
+
joinedAt: Date.now(),
|
|
866
|
+
isLocal: true,
|
|
867
|
+
};
|
|
868
|
+
this._log('Local user:', this._localUser.userId, '→', this._localUser.actorTokenId);
|
|
869
|
+
}
|
|
870
|
+
else {
|
|
871
|
+
this._localUser.actorTokenId = this._client.actorId;
|
|
872
|
+
}
|
|
873
|
+
// Lobby: subscribe every epoch (idempotent server-side) and diff-hydrate
|
|
874
|
+
// from the returned snapshot — one path for setup and restore.
|
|
875
|
+
if (!this._lobby) {
|
|
876
|
+
this._lobby = this._client.setApp(this._options.appName).setLobby(LOBBY_ID);
|
|
877
|
+
}
|
|
878
|
+
try {
|
|
879
|
+
const state = await this._lobby.subscribe();
|
|
880
|
+
if (stale())
|
|
881
|
+
return;
|
|
882
|
+
this._diffHydrateOnlineUsers(state);
|
|
883
|
+
this._log('Lobby subscribed, online users:', this._onlineUsers.size);
|
|
884
|
+
}
|
|
885
|
+
catch (err) {
|
|
886
|
+
if (stale())
|
|
887
|
+
return;
|
|
888
|
+
this._log('Lobby subscription failed:', err);
|
|
889
|
+
}
|
|
890
|
+
if (!this._isReady) {
|
|
891
|
+
// First successful setup: auto-join configured documents
|
|
892
|
+
for (const name of this._options.documents) {
|
|
893
|
+
this._subscribeDocumentInternal(name);
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
else {
|
|
897
|
+
// Server auto-restored topic subscriptions; only room-scoped presence
|
|
898
|
+
// needs re-applying (the core does not restore it).
|
|
899
|
+
for (const doc of this._documents.values()) {
|
|
900
|
+
doc._updateLocalPresence();
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
if (stale())
|
|
904
|
+
return;
|
|
905
|
+
// Ready keys on the first setup that COMPLETES, not on epoch 1: an
|
|
906
|
+
// epoch aborted by a racing reconnect must not strand ready().
|
|
907
|
+
if (!this._isReady) {
|
|
908
|
+
this._isReady = true;
|
|
909
|
+
this._readyResolve();
|
|
910
|
+
this.emit('connected');
|
|
911
|
+
}
|
|
912
|
+
else {
|
|
913
|
+
this.emit('reconnected');
|
|
914
|
+
}
|
|
915
|
+
// Deferred lobby refetch: catches users who joined during the setup
|
|
916
|
+
// window (e.g. simultaneous multi-tab connects).
|
|
917
|
+
this._scheduleLobbyRefresh(epoch);
|
|
918
|
+
}
|
|
919
|
+
_scheduleLobbyRefresh(epoch) {
|
|
920
|
+
if (this._lobbyRefreshTimer)
|
|
921
|
+
clearTimeout(this._lobbyRefreshTimer);
|
|
922
|
+
this._lobbyRefreshTimer = setTimeout(() => {
|
|
923
|
+
this._lobbyRefreshTimer = null;
|
|
924
|
+
if (epoch !== this._epoch || this._detached || !this._client.connected || !this._lobby) {
|
|
925
|
+
return;
|
|
926
|
+
}
|
|
927
|
+
this._lobby
|
|
928
|
+
.fetchPresence()
|
|
929
|
+
.then((state) => {
|
|
930
|
+
if (epoch !== this._epoch || this._detached)
|
|
931
|
+
return;
|
|
932
|
+
this._diffHydrateOnlineUsers(state);
|
|
933
|
+
})
|
|
934
|
+
.catch(() => {
|
|
935
|
+
/* best-effort */
|
|
936
|
+
});
|
|
937
|
+
}, LOBBY_REFRESH_DELAY_MS);
|
|
763
938
|
}
|
|
764
939
|
// ============ Document Management ============
|
|
765
940
|
/**
|
|
@@ -767,12 +942,10 @@ class NoLagCollab extends EventEmitter {
|
|
|
767
942
|
* Returns an existing document if already joined.
|
|
768
943
|
*/
|
|
769
944
|
joinDocument(name) {
|
|
770
|
-
|
|
771
|
-
throw new Error('Not connected — call connect() first');
|
|
772
|
-
}
|
|
945
|
+
this._assertUsable();
|
|
773
946
|
let doc = this._documents.get(name);
|
|
774
947
|
if (!doc) {
|
|
775
|
-
doc = this.
|
|
948
|
+
doc = this._subscribeDocumentInternal(name);
|
|
776
949
|
doc._activate();
|
|
777
950
|
}
|
|
778
951
|
return doc;
|
|
@@ -801,24 +974,41 @@ class NoLagCollab extends EventEmitter {
|
|
|
801
974
|
getOnlineUsers() {
|
|
802
975
|
return Array.from(this._onlineUsers.values());
|
|
803
976
|
}
|
|
804
|
-
// ============ Private:
|
|
805
|
-
|
|
806
|
-
if (
|
|
807
|
-
throw new Error('
|
|
977
|
+
// ============ Private: Guards ============
|
|
978
|
+
_assertUsable() {
|
|
979
|
+
if (this._detached) {
|
|
980
|
+
throw new Error('NoLagCollab has been detached — construct a new instance');
|
|
981
|
+
}
|
|
982
|
+
if (!this._isReady || !this._localUser) {
|
|
983
|
+
throw new Error('NoLagCollab not ready — await ready() or the "connected" event');
|
|
808
984
|
}
|
|
985
|
+
}
|
|
986
|
+
// ============ Private: Document Setup ============
|
|
987
|
+
_subscribeDocumentInternal(name) {
|
|
809
988
|
this._log('Subscribing document:', name);
|
|
810
989
|
const roomContext = this._client.setApp(this._options.appName).setRoom(name);
|
|
811
|
-
const doc = new CollabDocument(name, roomContext, this._localUser, this._options, createLogger(`CollabDocument:${name}`, this._options.debug));
|
|
990
|
+
const doc = new CollabDocument(name, roomContext, this._localUser, this._options, createLogger(`CollabDocument:${name}`, this._options.debug), () => this._client.connected);
|
|
812
991
|
this._documents.set(name, doc);
|
|
813
992
|
doc._subscribe();
|
|
814
993
|
return doc;
|
|
815
994
|
}
|
|
995
|
+
// ============ Private: Scope Filtering ============
|
|
996
|
+
/**
|
|
997
|
+
* On a shared client, presence events from other apps' wrappers arrive on
|
|
998
|
+
* the same connection-level events. Wrappers stamp their presence with a
|
|
999
|
+
* `__scope` (their appName); a mismatched tag means another app's data.
|
|
1000
|
+
* Untagged presence is accepted (older peers in this same app).
|
|
1001
|
+
*/
|
|
1002
|
+
_foreignScope(data) {
|
|
1003
|
+
const scope = data?.__scope;
|
|
1004
|
+
return typeof scope === 'string' && scope !== this._options.appName;
|
|
1005
|
+
}
|
|
816
1006
|
// ============ Private: Room Presence ============
|
|
817
1007
|
_handleRoomPresenceJoin(data) {
|
|
818
1008
|
if (data.actorTokenId === this._localUser?.actorTokenId)
|
|
819
1009
|
return;
|
|
820
1010
|
const presenceData = data.presence;
|
|
821
|
-
if (!presenceData?.userId)
|
|
1011
|
+
if (!presenceData?.userId || this._foreignScope(presenceData))
|
|
822
1012
|
return;
|
|
823
1013
|
const user = this._presenceToUser(data.actorTokenId, presenceData);
|
|
824
1014
|
this._actorToUserId.set(data.actorTokenId, user.userId);
|
|
@@ -843,7 +1033,7 @@ class NoLagCollab extends EventEmitter {
|
|
|
843
1033
|
if (data.actorTokenId === this._localUser?.actorTokenId)
|
|
844
1034
|
return;
|
|
845
1035
|
const presenceData = data.presence;
|
|
846
|
-
if (!presenceData?.userId)
|
|
1036
|
+
if (!presenceData?.userId || this._foreignScope(presenceData))
|
|
847
1037
|
return;
|
|
848
1038
|
if (this._onlineUsers.has(presenceData.userId)) {
|
|
849
1039
|
const user = this._presenceToUser(data.actorTokenId, presenceData);
|
|
@@ -855,37 +1045,12 @@ class NoLagCollab extends EventEmitter {
|
|
|
855
1045
|
}
|
|
856
1046
|
}
|
|
857
1047
|
// ============ Private: Lobby ============
|
|
858
|
-
async _setupLobby() {
|
|
859
|
-
if (!this._client)
|
|
860
|
-
return;
|
|
861
|
-
this._lobby = this._client.setApp(this._options.appName).setLobby(LOBBY_ID);
|
|
862
|
-
const lobbyHandler = (type) => (data) => {
|
|
863
|
-
const event = data;
|
|
864
|
-
if (type === 'join')
|
|
865
|
-
this._handleLobbyJoin(event);
|
|
866
|
-
else if (type === 'leave')
|
|
867
|
-
this._handleLobbyLeave(event);
|
|
868
|
-
else
|
|
869
|
-
this._handleLobbyUpdate(event);
|
|
870
|
-
};
|
|
871
|
-
this._client.on('lobbyPresence:join', lobbyHandler('join'));
|
|
872
|
-
this._client.on('lobbyPresence:leave', lobbyHandler('leave'));
|
|
873
|
-
this._client.on('lobbyPresence:update', lobbyHandler('update'));
|
|
874
|
-
try {
|
|
875
|
-
const initialState = await this._lobby.subscribe();
|
|
876
|
-
this._hydrateOnlineUsers(initialState);
|
|
877
|
-
this._log('Lobby subscribed, online users:', this._onlineUsers.size);
|
|
878
|
-
}
|
|
879
|
-
catch (err) {
|
|
880
|
-
this._log('Lobby subscription failed:', err);
|
|
881
|
-
}
|
|
882
|
-
}
|
|
883
1048
|
_handleLobbyJoin(event) {
|
|
884
1049
|
const { actorId, data } = event;
|
|
885
1050
|
if (actorId === this._localUser?.actorTokenId)
|
|
886
1051
|
return;
|
|
887
1052
|
const presenceData = data;
|
|
888
|
-
if (!presenceData.userId)
|
|
1053
|
+
if (!presenceData.userId || this._foreignScope(presenceData))
|
|
889
1054
|
return;
|
|
890
1055
|
const user = this._presenceToUser(actorId, presenceData);
|
|
891
1056
|
this._actorToUserId.set(actorId, user.userId);
|
|
@@ -899,6 +1064,8 @@ class NoLagCollab extends EventEmitter {
|
|
|
899
1064
|
if (actorId === this._localUser?.actorTokenId)
|
|
900
1065
|
return;
|
|
901
1066
|
const presenceData = data;
|
|
1067
|
+
if (this._foreignScope(presenceData))
|
|
1068
|
+
return;
|
|
902
1069
|
const userId = presenceData?.userId
|
|
903
1070
|
|| this._actorToUserId.get(actorId)
|
|
904
1071
|
|| this._findUserIdByActorId(actorId);
|
|
@@ -916,29 +1083,57 @@ class NoLagCollab extends EventEmitter {
|
|
|
916
1083
|
if (actorId === this._localUser?.actorTokenId)
|
|
917
1084
|
return;
|
|
918
1085
|
const presenceData = data;
|
|
919
|
-
if (!presenceData.userId)
|
|
1086
|
+
if (!presenceData.userId || this._foreignScope(presenceData))
|
|
920
1087
|
return;
|
|
921
1088
|
const user = this._presenceToUser(actorId, presenceData);
|
|
922
1089
|
this._onlineUsers.set(user.userId, user);
|
|
923
1090
|
}
|
|
924
|
-
|
|
1091
|
+
/**
|
|
1092
|
+
* Reconcile the online-user map against a fresh lobby snapshot, emitting
|
|
1093
|
+
* only the deltas (userOffline for vanished, userOnline for new). One path
|
|
1094
|
+
* for initial hydration, reconnect restore, and the deferred refetch.
|
|
1095
|
+
*/
|
|
1096
|
+
_diffHydrateOnlineUsers(state) {
|
|
1097
|
+
// Build the fresh user set from the snapshot
|
|
1098
|
+
const fresh = new Map();
|
|
1099
|
+
const freshActors = new Map();
|
|
925
1100
|
for (const roomId of Object.keys(state)) {
|
|
926
1101
|
const roomPresence = state[roomId];
|
|
927
1102
|
for (const actorId of Object.keys(roomPresence)) {
|
|
928
1103
|
if (actorId === this._localUser?.actorTokenId)
|
|
929
1104
|
continue;
|
|
930
1105
|
const raw = roomPresence[actorId];
|
|
1106
|
+
// Server returns full actor records with presence nested under .presence
|
|
931
1107
|
const presenceData = (raw?.presence ?? raw);
|
|
932
|
-
if (presenceData?.userId) {
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
if (!this._onlineUsers.has(user.userId)) {
|
|
936
|
-
this._onlineUsers.set(user.userId, user);
|
|
937
|
-
this.emit('userOnline', user);
|
|
1108
|
+
if (presenceData?.userId && !this._foreignScope(presenceData)) {
|
|
1109
|
+
if (!fresh.has(presenceData.userId)) {
|
|
1110
|
+
fresh.set(presenceData.userId, this._presenceToUser(actorId, presenceData));
|
|
938
1111
|
}
|
|
1112
|
+
freshActors.set(actorId, presenceData.userId);
|
|
939
1113
|
}
|
|
940
1114
|
}
|
|
941
1115
|
}
|
|
1116
|
+
// Vanished users
|
|
1117
|
+
for (const [userId, user] of [...this._onlineUsers]) {
|
|
1118
|
+
if (!fresh.has(userId)) {
|
|
1119
|
+
this._onlineUsers.delete(userId);
|
|
1120
|
+
for (const [actorId, mappedUserId] of [...this._actorToUserId]) {
|
|
1121
|
+
if (mappedUserId === userId)
|
|
1122
|
+
this._actorToUserId.delete(actorId);
|
|
1123
|
+
}
|
|
1124
|
+
this.emit('userOffline', user);
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
// New users
|
|
1128
|
+
for (const [userId, user] of fresh) {
|
|
1129
|
+
if (!this._onlineUsers.has(userId)) {
|
|
1130
|
+
this._onlineUsers.set(userId, user);
|
|
1131
|
+
this.emit('userOnline', user);
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
for (const [actorId, userId] of freshActors) {
|
|
1135
|
+
this._actorToUserId.set(actorId, userId);
|
|
1136
|
+
}
|
|
942
1137
|
}
|
|
943
1138
|
// ============ Private: Helpers ============
|
|
944
1139
|
_presenceToUser(actorTokenId, data) {
|
|
@@ -961,21 +1156,6 @@ class NoLagCollab extends EventEmitter {
|
|
|
961
1156
|
}
|
|
962
1157
|
return undefined;
|
|
963
1158
|
}
|
|
964
|
-
_restoreDocuments() {
|
|
965
|
-
// On reconnect, js-sdk auto-restores subscriptions.
|
|
966
|
-
// Re-set presence on all active documents.
|
|
967
|
-
for (const doc of this._documents.values()) {
|
|
968
|
-
doc._updateLocalPresence();
|
|
969
|
-
}
|
|
970
|
-
// Re-fetch lobby presence
|
|
971
|
-
this._lobby?.fetchPresence().then((state) => {
|
|
972
|
-
this._onlineUsers.clear();
|
|
973
|
-
this._actorToUserId.clear();
|
|
974
|
-
this._hydrateOnlineUsers(state);
|
|
975
|
-
}).catch((err) => {
|
|
976
|
-
this._log('Failed to re-fetch lobby presence:', err);
|
|
977
|
-
});
|
|
978
|
-
}
|
|
979
1159
|
}
|
|
980
1160
|
|
|
981
1161
|
exports.CollabDocument = CollabDocument;
|