@pylonsync/sync 0.3.215 → 0.3.216
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/package.json +1 -1
- package/src/index.ts +339 -4
- package/src/multi-tab-orchestrator.ts +80 -0
- package/src/room-push.test.ts +197 -0
- package/src/room-subscriptions.test.ts +189 -0
- package/src/room-subscriptions.ts +301 -0
- package/src/test-harness/env.ts +6 -0
- package/src/test-harness/transport.ts +23 -0
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -17,6 +17,12 @@ import { LocalStore } from "./local-store";
|
|
|
17
17
|
import { MutationQueue, type PendingMutation } from "./mutation-queue";
|
|
18
18
|
import { MultiTabOrchestrator } from "./multi-tab-orchestrator";
|
|
19
19
|
import { OpQueue } from "./op-queue";
|
|
20
|
+
import {
|
|
21
|
+
RoomSubscriptions,
|
|
22
|
+
type RoomError,
|
|
23
|
+
type RoomMember,
|
|
24
|
+
type RoomSubscriber,
|
|
25
|
+
} from "./room-subscriptions";
|
|
20
26
|
import { ServerSubscriptions } from "./server-subscriptions";
|
|
21
27
|
import { SessionResolver } from "./session-resolver";
|
|
22
28
|
import { SubscriptionCoordinator } from "./subscription-coordinator";
|
|
@@ -27,6 +33,13 @@ import {
|
|
|
27
33
|
type TransportKind,
|
|
28
34
|
} from "./transports";
|
|
29
35
|
import { generateClientId, generateId } from "./ids";
|
|
36
|
+
export {
|
|
37
|
+
RoomSubscriptions,
|
|
38
|
+
type RoomError,
|
|
39
|
+
type RoomErrorCode,
|
|
40
|
+
type RoomMember,
|
|
41
|
+
type RoomSubscriber,
|
|
42
|
+
} from "./room-subscriptions";
|
|
30
43
|
export { IndexedDBPersistence, persistChange } from "./persistence";
|
|
31
44
|
export {
|
|
32
45
|
buildRequest,
|
|
@@ -335,6 +348,27 @@ export class SyncEngine {
|
|
|
335
348
|
* serverSubs isn't built until then. */
|
|
336
349
|
private subscriptions!: SubscriptionCoordinator;
|
|
337
350
|
|
|
351
|
+
/** Room presence subscriptions. Replaces the per-component
|
|
352
|
+
* setInterval(GET /api/rooms/<room>, 5s) polling loop the `useRoom`
|
|
353
|
+
* hook used to run for every channel. New server protocol
|
|
354
|
+
* (v0.3.214+): the client sends `room-subscribe` / `room-unsubscribe`
|
|
355
|
+
* over the existing WS, and the server pushes `room-snapshot` /
|
|
356
|
+
* `room-update` whenever membership changes.
|
|
357
|
+
*
|
|
358
|
+
* This engine field is leader-only (followers forward register /
|
|
359
|
+
* unregister calls over the multi-tab channel — the leader's
|
|
360
|
+
* registry is the single source of truth on the wire). Constructed
|
|
361
|
+
* lazily in start() so SSR-only callers don't pay for it. */
|
|
362
|
+
private rooms!: RoomSubscriptions;
|
|
363
|
+
|
|
364
|
+
/** Per-room set of follower tabIds that have forwarded a
|
|
365
|
+
* `room-sub-register`. Leader-only. Mirrors `crdtForwarders` in the
|
|
366
|
+
* SubscriptionCoordinator — the WS room sub stays alive until both
|
|
367
|
+
* the local refcount AND this set are empty. A separate map on the
|
|
368
|
+
* engine (instead of inside RoomSubscriptions) because the registry
|
|
369
|
+
* is leader-local and forwarder bookkeeping is multi-tab specific. */
|
|
370
|
+
private roomForwarders: Map<string, Set<string>> = new Map();
|
|
371
|
+
|
|
338
372
|
/**
|
|
339
373
|
* Listeners notified when the server signals a per-subscriber row
|
|
340
374
|
* revocation (`row-revoked` envelope). Used by `@pylonsync/loro`
|
|
@@ -404,6 +438,16 @@ export class SyncEngine {
|
|
|
404
438
|
isLeader: () => this.isMultiTabLeader,
|
|
405
439
|
broadcastToTabs: (payload) => this.broadcastToTabs(payload),
|
|
406
440
|
});
|
|
441
|
+
this.rooms = new RoomSubscriptions((msg) => {
|
|
442
|
+
// Leader: send over the WS. Followers don't open a transport;
|
|
443
|
+
// the leader-side WS is the only path to the server.
|
|
444
|
+
if (!this.isMultiTabLeader) return false;
|
|
445
|
+
if (this.transport?.isOpen()) {
|
|
446
|
+
this.transport.send(msg);
|
|
447
|
+
return true;
|
|
448
|
+
}
|
|
449
|
+
return false;
|
|
450
|
+
});
|
|
407
451
|
// When multi-tab coordination is explicitly disabled, this engine
|
|
408
452
|
// is always its own sole leader — even before start(). Tests that
|
|
409
453
|
// construct an engine and call reconcile()/pull() directly without
|
|
@@ -746,14 +790,108 @@ export class SyncEngine {
|
|
|
746
790
|
}
|
|
747
791
|
}
|
|
748
792
|
},
|
|
749
|
-
onPeerLeft: (
|
|
750
|
-
// Orchestrator already scrubbed
|
|
751
|
-
// departed tab.
|
|
752
|
-
//
|
|
793
|
+
onPeerLeft: (tabId: string) => {
|
|
794
|
+
// Orchestrator already scrubbed CRDT / reactive sub state for
|
|
795
|
+
// the departed tab. Engine cleans up room forwarder sets —
|
|
796
|
+
// a follower that crashed without sending `room-sub-unregister`
|
|
797
|
+
// would otherwise keep the leader's hold (and therefore the WS
|
|
798
|
+
// sub) alive for a room nobody actually wants.
|
|
799
|
+
if (!this.isMultiTabLeader) return;
|
|
800
|
+
for (const [room, set] of [...this.roomForwarders]) {
|
|
801
|
+
if (!set.delete(tabId)) continue;
|
|
802
|
+
if (set.size === 0) {
|
|
803
|
+
this.roomForwarders.delete(room);
|
|
804
|
+
const release = this.leaderForwarderHolds.get(room);
|
|
805
|
+
if (release) {
|
|
806
|
+
this.leaderForwarderHolds.delete(room);
|
|
807
|
+
release();
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
},
|
|
812
|
+
onRoomSubRegister: (roomId: string, fromTabId: string) => {
|
|
813
|
+
let set = this.roomForwarders.get(roomId);
|
|
814
|
+
if (!set) {
|
|
815
|
+
set = new Set();
|
|
816
|
+
this.roomForwarders.set(roomId, set);
|
|
817
|
+
}
|
|
818
|
+
const wasFirst = set.size === 0;
|
|
819
|
+
set.add(fromTabId);
|
|
820
|
+
// First forwarder for this room: acquire a leader-internal
|
|
821
|
+
// hold on the registry so the WS sub stays alive as long as
|
|
822
|
+
// any tab (leader or follower) still cares. The hold is a
|
|
823
|
+
// refcount increment with a noop callback — it doesn't fire
|
|
824
|
+
// on snapshots / updates (the leader's own subscribers, if
|
|
825
|
+
// any, get the pulse; the forwarded tab gets the snapshot via
|
|
826
|
+
// the `room-fanout-*` channel). Without this hold, a leader
|
|
827
|
+
// with no local subscribers would never enter the registry
|
|
828
|
+
// and inbound `room-snapshot` for forwarded rooms would drop
|
|
829
|
+
// on the floor.
|
|
830
|
+
if (wasFirst) {
|
|
831
|
+
const noop: RoomSubscriber = () => {};
|
|
832
|
+
const release = this.rooms.register(roomId, noop);
|
|
833
|
+
this.leaderForwarderHolds.set(roomId, release);
|
|
834
|
+
}
|
|
835
|
+
},
|
|
836
|
+
onRoomSubUnregister: (roomId: string, fromTabId: string) => {
|
|
837
|
+
const set = this.roomForwarders.get(roomId);
|
|
838
|
+
if (!set) return;
|
|
839
|
+
set.delete(fromTabId);
|
|
840
|
+
if (set.size > 0) return;
|
|
841
|
+
this.roomForwarders.delete(roomId);
|
|
842
|
+
// Release the leader-internal hold IF no local subscriber on
|
|
843
|
+
// this tab cares either. The registry's refcount will then
|
|
844
|
+
// drop to zero and `room-unsubscribe` ships.
|
|
845
|
+
const release = this.leaderForwarderHolds.get(roomId);
|
|
846
|
+
if (release) {
|
|
847
|
+
this.leaderForwarderHolds.delete(roomId);
|
|
848
|
+
release();
|
|
849
|
+
}
|
|
850
|
+
},
|
|
851
|
+
onRoomFanoutSnapshot: (roomId: string, members: unknown) => {
|
|
852
|
+
if (Array.isArray(members)) {
|
|
853
|
+
this.rooms.applySnapshot(roomId, members as RoomMember[]);
|
|
854
|
+
}
|
|
855
|
+
},
|
|
856
|
+
onRoomFanoutUpdate: (
|
|
857
|
+
roomId: string,
|
|
858
|
+
action: "join" | "leave" | "presence" | "broadcast",
|
|
859
|
+
member: unknown,
|
|
860
|
+
data: unknown,
|
|
861
|
+
) => {
|
|
862
|
+
this.rooms.applyUpdate(
|
|
863
|
+
roomId,
|
|
864
|
+
action,
|
|
865
|
+
(member ?? undefined) as RoomMember | undefined,
|
|
866
|
+
data,
|
|
867
|
+
);
|
|
868
|
+
},
|
|
869
|
+
onRoomFanoutError: (roomId: string, error: unknown) => {
|
|
870
|
+
const err = (error ?? { code: "UNKNOWN" }) as RoomError;
|
|
871
|
+
this.rooms.applyError(roomId, err);
|
|
872
|
+
},
|
|
873
|
+
onReplayRoomSubs: () => {
|
|
874
|
+
// Follower path: re-broadcast `room-sub-register` for every
|
|
875
|
+
// room we still care about. New leader rebuilds its forwarder
|
|
876
|
+
// set and resends `room-subscribe` on its WS.
|
|
877
|
+
if (this.isMultiTabLeader) return;
|
|
878
|
+
for (const roomId of this.rooms.roomIds()) {
|
|
879
|
+
this.broadcastToTabs({
|
|
880
|
+
type: "room-sub-register",
|
|
881
|
+
room: roomId,
|
|
882
|
+
});
|
|
883
|
+
}
|
|
753
884
|
},
|
|
754
885
|
};
|
|
755
886
|
}
|
|
756
887
|
|
|
888
|
+
/** Leader-side hold tokens for room subs forwarded by followers. The
|
|
889
|
+
* leader subscribes via its own registry with a no-op callback so
|
|
890
|
+
* the registry's first-add gate ships `room-subscribe` exactly
|
|
891
|
+
* once; the release tokens here let us undo that hold when the
|
|
892
|
+
* last follower stops caring. */
|
|
893
|
+
private leaderForwarderHolds: Map<string, () => void> = new Map();
|
|
894
|
+
|
|
757
895
|
/** Seed serverSubs with every subscription this tab currently wants.
|
|
758
896
|
* Called from both the initial-leader path (subscribes that
|
|
759
897
|
* happened before start() took the follower branch and broadcast
|
|
@@ -2158,6 +2296,126 @@ export class SyncEngine {
|
|
|
2158
2296
|
this.transport?.send(msg);
|
|
2159
2297
|
}
|
|
2160
2298
|
|
|
2299
|
+
// -----------------------------------------------------------------------
|
|
2300
|
+
// Room presence subscriptions
|
|
2301
|
+
//
|
|
2302
|
+
// Replaces the SDK's per-component `setInterval(GET /api/rooms/<room>,
|
|
2303
|
+
// 5s)` polling loop. Wire shape (server v0.3.214+):
|
|
2304
|
+
//
|
|
2305
|
+
// client → server:
|
|
2306
|
+
// { type: "room-subscribe", room: "channel:foo" }
|
|
2307
|
+
// { type: "room-unsubscribe", room: "channel:foo" }
|
|
2308
|
+
// server → client:
|
|
2309
|
+
// { type: "room-snapshot", room, members: [...] }
|
|
2310
|
+
// { type: "room-update", room, action: "join"|"leave"|"presence"|"broadcast",
|
|
2311
|
+
// member?, data? }
|
|
2312
|
+
// { type: "error", code: "NOT_IN_ROOM", room } // server gate
|
|
2313
|
+
//
|
|
2314
|
+
// The actual `POST /api/rooms/join` still happens over HTTP (it carries
|
|
2315
|
+
// initial presence + identity, returns the snapshot). This API only
|
|
2316
|
+
// moves the "stay subscribed to membership deltas" loop off polling
|
|
2317
|
+
// and onto the WS push channel. setPresence + broadcast also stay on
|
|
2318
|
+
// HTTP for now — the WS-RPC envelope for those lands in v0.3.217.
|
|
2319
|
+
// -----------------------------------------------------------------------
|
|
2320
|
+
|
|
2321
|
+
/**
|
|
2322
|
+
* Subscribe to a room's membership over WebSocket. The callback fires
|
|
2323
|
+
* whenever the room's `members` list OR `error` state changes — read
|
|
2324
|
+
* the current snapshot via `getRoomMembers(roomId)` and the latest
|
|
2325
|
+
* error via `getRoomError(roomId)` inside the callback.
|
|
2326
|
+
*
|
|
2327
|
+
* Refcounted: multiple subscribers for the same `roomId` share one
|
|
2328
|
+
* `room-subscribe` on the wire. Returns an unsubscribe function;
|
|
2329
|
+
* the last unsubscribe ships `room-unsubscribe`.
|
|
2330
|
+
*
|
|
2331
|
+
* Follower tabs forward the register / unregister over the multi-tab
|
|
2332
|
+
* channel so the leader's WS carries one subscribe per room across
|
|
2333
|
+
* the whole origin. Inbound snapshot / update / error envelopes land
|
|
2334
|
+
* on the leader's WS and the leader fans them out cross-tab so each
|
|
2335
|
+
* follower's local registry routes to its own subscribers.
|
|
2336
|
+
*
|
|
2337
|
+
* Idempotent w.r.t. wire frames: a re-subscribe with no intervening
|
|
2338
|
+
* full unsubscribe doesn't re-send `room-subscribe`. ServerSubscriptions-
|
|
2339
|
+
* style replay on reconnect is built in — the registry resends
|
|
2340
|
+
* `room-subscribe` for every active room on WS reopen.
|
|
2341
|
+
*/
|
|
2342
|
+
subscribeRoom(roomId: string, callback: RoomSubscriber): () => void {
|
|
2343
|
+
if (this.isMultiTabLeader) {
|
|
2344
|
+
// Leader path: register against the local registry which owns
|
|
2345
|
+
// the wire. If a follower had already forwarded this room, the
|
|
2346
|
+
// WS sub is already alive — `register()` short-circuits the
|
|
2347
|
+
// re-send via the registry's first-add gate.
|
|
2348
|
+
return this.rooms.register(roomId, callback);
|
|
2349
|
+
}
|
|
2350
|
+
// Follower path: register locally for callback routing, then ask
|
|
2351
|
+
// the leader to subscribe on our behalf (only on first local
|
|
2352
|
+
// subscriber for the room — multiple mounts in the same follower
|
|
2353
|
+
// tab share one `room-sub-register`, mirroring the leader-side
|
|
2354
|
+
// ServerSubscriptions first-add gate). The leader echoes snapshots
|
|
2355
|
+
// / updates / errors back over the broadcast channel and our local
|
|
2356
|
+
// registry's notify() fires our callback.
|
|
2357
|
+
const hadRoomBefore = this.rooms.has(roomId);
|
|
2358
|
+
const unsubscribe = this.rooms.register(roomId, callback);
|
|
2359
|
+
if (!hadRoomBefore) {
|
|
2360
|
+
this.broadcastToTabs({
|
|
2361
|
+
type: "room-sub-register",
|
|
2362
|
+
room: roomId,
|
|
2363
|
+
});
|
|
2364
|
+
}
|
|
2365
|
+
return () => {
|
|
2366
|
+
unsubscribe();
|
|
2367
|
+
// If this was the LAST subscriber on this tab for the room,
|
|
2368
|
+
// tell the leader so it can drop us from the forwarder set.
|
|
2369
|
+
if (!this.rooms.has(roomId)) {
|
|
2370
|
+
this.broadcastToTabs({
|
|
2371
|
+
type: "room-sub-unregister",
|
|
2372
|
+
room: roomId,
|
|
2373
|
+
});
|
|
2374
|
+
}
|
|
2375
|
+
};
|
|
2376
|
+
}
|
|
2377
|
+
|
|
2378
|
+
/** Force-unsubscribe every local subscriber of a room and ship a
|
|
2379
|
+
* `room-unsubscribe`. Used by the `useRoom` hook's manual `leave()`
|
|
2380
|
+
* action so a deliberate exit propagates to the server immediately. */
|
|
2381
|
+
unsubscribeRoom(roomId: string): void {
|
|
2382
|
+
this.rooms.unregisterRoom(roomId);
|
|
2383
|
+
if (!this.isMultiTabLeader) {
|
|
2384
|
+
this.broadcastToTabs({
|
|
2385
|
+
type: "room-sub-unregister",
|
|
2386
|
+
room: roomId,
|
|
2387
|
+
});
|
|
2388
|
+
}
|
|
2389
|
+
}
|
|
2390
|
+
|
|
2391
|
+
/** Read the current cached members snapshot for `roomId`. Returns
|
|
2392
|
+
* `null` when no snapshot has landed yet (distinct from `[]` for
|
|
2393
|
+
* an empty room). */
|
|
2394
|
+
getRoomMembers(roomId: string): RoomMember[] | null {
|
|
2395
|
+
return this.rooms?.members(roomId) ?? null;
|
|
2396
|
+
}
|
|
2397
|
+
|
|
2398
|
+
/** Read the latest error for `roomId` (e.g. NOT_IN_ROOM). null when
|
|
2399
|
+
* none. */
|
|
2400
|
+
getRoomError(roomId: string): RoomError | null {
|
|
2401
|
+
return this.rooms?.error(roomId) ?? null;
|
|
2402
|
+
}
|
|
2403
|
+
|
|
2404
|
+
/** Active transport kind. Used by the `useRoom` hook to decide
|
|
2405
|
+
* between WS push and HTTP polling fallback — only the WS transport
|
|
2406
|
+
* supports the room-subscribe push protocol; SSE and polling fall
|
|
2407
|
+
* back to the legacy 5s GET /api/rooms/<room>. */
|
|
2408
|
+
getActiveTransportType(): TransportType {
|
|
2409
|
+
return this.transportKind();
|
|
2410
|
+
}
|
|
2411
|
+
|
|
2412
|
+
/** True when the active transport is a WebSocket AND the socket is
|
|
2413
|
+
* currently open. The `useRoom` hook gates its WS-push path on this
|
|
2414
|
+
* — when false, fall back to polling. */
|
|
2415
|
+
isWebSocketConnected(): boolean {
|
|
2416
|
+
return this.transportKind() === "websocket" && this.connected;
|
|
2417
|
+
}
|
|
2418
|
+
|
|
2161
2419
|
/** Resolved transport kind, with the websocket default applied. */
|
|
2162
2420
|
private transportKind(): TransportKind {
|
|
2163
2421
|
return this.config.transport ?? "websocket";
|
|
@@ -2188,6 +2446,12 @@ export class SyncEngine {
|
|
|
2188
2446
|
// so without this resync the subscriber's first event would
|
|
2189
2447
|
// never arrive.
|
|
2190
2448
|
this.serverSubs.replay();
|
|
2449
|
+
// Room subscriptions live in their own registry (not under
|
|
2450
|
+
// serverSubs) because they carry per-room state (members
|
|
2451
|
+
// snapshot, error). Replay them on the same beat so the
|
|
2452
|
+
// server starts pushing room-snapshot/room-update again for
|
|
2453
|
+
// every room the user is still subscribed to.
|
|
2454
|
+
this.rooms.replay();
|
|
2191
2455
|
// Pull-on-open catches every event broadcast in the gap
|
|
2192
2456
|
// between the prior pull() returning and the socket actually
|
|
2193
2457
|
// opening. Reconcile fires after the pull since pull is the
|
|
@@ -2286,6 +2550,77 @@ export class SyncEngine {
|
|
|
2286
2550
|
return;
|
|
2287
2551
|
}
|
|
2288
2552
|
|
|
2553
|
+
// Room push: snapshot / update / error envelopes from the WS.
|
|
2554
|
+
// Leader receives them on its socket and routes through the local
|
|
2555
|
+
// registry; if any follower forwarded a sub for the room, fan the
|
|
2556
|
+
// envelope out cross-tab so follower-local registries pick up the
|
|
2557
|
+
// same state. The followers receive `room-fanout-*` envelopes
|
|
2558
|
+
// through the orchestrator and apply them to their own registries.
|
|
2559
|
+
if (msg.type === "room-snapshot" && typeof msg.room === "string") {
|
|
2560
|
+
const room = msg.room;
|
|
2561
|
+
const members = Array.isArray(msg.members)
|
|
2562
|
+
? (msg.members as RoomMember[])
|
|
2563
|
+
: [];
|
|
2564
|
+
this.rooms.applySnapshot(room, members);
|
|
2565
|
+
if ((this.roomForwarders.get(room)?.size ?? 0) > 0) {
|
|
2566
|
+
this.broadcastToTabs({
|
|
2567
|
+
type: "room-fanout-snapshot",
|
|
2568
|
+
room,
|
|
2569
|
+
members,
|
|
2570
|
+
});
|
|
2571
|
+
}
|
|
2572
|
+
return;
|
|
2573
|
+
}
|
|
2574
|
+
if (msg.type === "room-update" && typeof msg.room === "string") {
|
|
2575
|
+
const room = msg.room;
|
|
2576
|
+
const action = msg.action as
|
|
2577
|
+
| "join"
|
|
2578
|
+
| "leave"
|
|
2579
|
+
| "presence"
|
|
2580
|
+
| "broadcast"
|
|
2581
|
+
| undefined;
|
|
2582
|
+
if (!action) return;
|
|
2583
|
+
const member = (msg.member as RoomMember | undefined) ?? undefined;
|
|
2584
|
+
const data = msg.data;
|
|
2585
|
+
this.rooms.applyUpdate(room, action, member, data);
|
|
2586
|
+
if ((this.roomForwarders.get(room)?.size ?? 0) > 0) {
|
|
2587
|
+
this.broadcastToTabs({
|
|
2588
|
+
type: "room-fanout-update",
|
|
2589
|
+
room,
|
|
2590
|
+
action,
|
|
2591
|
+
member,
|
|
2592
|
+
data,
|
|
2593
|
+
});
|
|
2594
|
+
}
|
|
2595
|
+
return;
|
|
2596
|
+
}
|
|
2597
|
+
if (
|
|
2598
|
+
msg.type === "error" &&
|
|
2599
|
+
typeof msg.room === "string" &&
|
|
2600
|
+
typeof msg.code === "string"
|
|
2601
|
+
) {
|
|
2602
|
+
// Server-side gate failure on a room subscribe. Surface to the
|
|
2603
|
+
// hook via the registry so the React side can render an error
|
|
2604
|
+
// state. Server only emits this in the `room-subscribe` reject
|
|
2605
|
+
// path right now, but the dispatch is keyed by `code` so future
|
|
2606
|
+
// error codes (rate-limited, room-full, etc.) route here too.
|
|
2607
|
+
const room = msg.room;
|
|
2608
|
+
const code = msg.code === "NOT_IN_ROOM" ? "NOT_IN_ROOM" : "UNKNOWN";
|
|
2609
|
+
const error: RoomError = {
|
|
2610
|
+
code,
|
|
2611
|
+
message: typeof msg.message === "string" ? msg.message : undefined,
|
|
2612
|
+
};
|
|
2613
|
+
this.rooms.applyError(room, error);
|
|
2614
|
+
if ((this.roomForwarders.get(room)?.size ?? 0) > 0) {
|
|
2615
|
+
this.broadcastToTabs({
|
|
2616
|
+
type: "room-fanout-error",
|
|
2617
|
+
room,
|
|
2618
|
+
error,
|
|
2619
|
+
});
|
|
2620
|
+
}
|
|
2621
|
+
return;
|
|
2622
|
+
}
|
|
2623
|
+
|
|
2289
2624
|
// Reactive query push: the server-side ReactiveRegistry re-ran a
|
|
2290
2625
|
// subscribed handler and the result hash changed. Route to the
|
|
2291
2626
|
// local handler if we own the subscription AND forward to follower
|
|
@@ -87,6 +87,31 @@ export interface MultiTabOrchestratorHooks {
|
|
|
87
87
|
/** A peer tab disappeared (broker observed `bye`). Engine and
|
|
88
88
|
* SubscriptionCoordinator both clean up state for the departed tab. */
|
|
89
89
|
onPeerLeft(tabId: string): void;
|
|
90
|
+
|
|
91
|
+
/** Follower → leader: a follower wants to subscribe to a room.
|
|
92
|
+
* Leader-only. Engine increments the per-room forwarder set and
|
|
93
|
+
* sends `room-subscribe` to the server if no one else owned it. */
|
|
94
|
+
onRoomSubRegister?(roomId: string, fromTabId: string): void;
|
|
95
|
+
/** Follower → leader: a follower's last room subscriber unmounted.
|
|
96
|
+
* Leader-only. Engine decrements the forwarder set and sends
|
|
97
|
+
* `room-unsubscribe` when both the local refcount and the forwarder
|
|
98
|
+
* set are empty. */
|
|
99
|
+
onRoomSubUnregister?(roomId: string, fromTabId: string): void;
|
|
100
|
+
/** Leader → followers: a room-snapshot landed on the WS. Followers
|
|
101
|
+
* apply it to their local room registry so their subscribers fire. */
|
|
102
|
+
onRoomFanoutSnapshot?(roomId: string, members: unknown): void;
|
|
103
|
+
/** Leader → followers: a room-update landed. */
|
|
104
|
+
onRoomFanoutUpdate?(
|
|
105
|
+
roomId: string,
|
|
106
|
+
action: "join" | "leave" | "presence" | "broadcast",
|
|
107
|
+
member: unknown,
|
|
108
|
+
data: unknown,
|
|
109
|
+
): void;
|
|
110
|
+
/** Leader → followers: the server rejected a room-subscribe. */
|
|
111
|
+
onRoomFanoutError?(roomId: string, error: unknown): void;
|
|
112
|
+
/** New leader → followers: re-forward your locally-wanted room subs.
|
|
113
|
+
* Triggered alongside CRDT/reactive replay after a leader change. */
|
|
114
|
+
onReplayRoomSubs?(): void;
|
|
90
115
|
}
|
|
91
116
|
|
|
92
117
|
export interface MultiTabOrchestratorConfig {
|
|
@@ -359,6 +384,61 @@ export class MultiTabOrchestrator {
|
|
|
359
384
|
// already has the bundle.
|
|
360
385
|
if (this._isLeader) return;
|
|
361
386
|
this.subscriptions.replayForwardedSubs();
|
|
387
|
+
// Followers also re-forward their active room subscriptions
|
|
388
|
+
// so the new leader can rebuild its forwarder sets and resend
|
|
389
|
+
// `room-subscribe` on the WS.
|
|
390
|
+
this.hooks.onReplayRoomSubs?.();
|
|
391
|
+
break;
|
|
392
|
+
}
|
|
393
|
+
case "room-sub-register": {
|
|
394
|
+
// Follower → leader. Leaders only — a follower receiving this
|
|
395
|
+
// (own broadcast echo, or stale leader transition) ignores.
|
|
396
|
+
if (!this._isLeader) return;
|
|
397
|
+
const room = msg.room as string | undefined;
|
|
398
|
+
if (typeof room === "string") {
|
|
399
|
+
this.hooks.onRoomSubRegister?.(room, fromTabId);
|
|
400
|
+
}
|
|
401
|
+
break;
|
|
402
|
+
}
|
|
403
|
+
case "room-sub-unregister": {
|
|
404
|
+
if (!this._isLeader) return;
|
|
405
|
+
const room = msg.room as string | undefined;
|
|
406
|
+
if (typeof room === "string") {
|
|
407
|
+
this.hooks.onRoomSubUnregister?.(room, fromTabId);
|
|
408
|
+
}
|
|
409
|
+
break;
|
|
410
|
+
}
|
|
411
|
+
case "room-fanout-snapshot": {
|
|
412
|
+
// Leader → follower. Leader ignores its own echo.
|
|
413
|
+
if (this._isLeader) return;
|
|
414
|
+
const room = msg.room as string | undefined;
|
|
415
|
+
const members = msg.members;
|
|
416
|
+
if (typeof room === "string") {
|
|
417
|
+
this.hooks.onRoomFanoutSnapshot?.(room, members);
|
|
418
|
+
}
|
|
419
|
+
break;
|
|
420
|
+
}
|
|
421
|
+
case "room-fanout-update": {
|
|
422
|
+
if (this._isLeader) return;
|
|
423
|
+
const room = msg.room as string | undefined;
|
|
424
|
+
const action = msg.action as
|
|
425
|
+
| "join"
|
|
426
|
+
| "leave"
|
|
427
|
+
| "presence"
|
|
428
|
+
| "broadcast"
|
|
429
|
+
| undefined;
|
|
430
|
+
if (typeof room === "string" && action) {
|
|
431
|
+
this.hooks.onRoomFanoutUpdate?.(room, action, msg.member, msg.data);
|
|
432
|
+
}
|
|
433
|
+
break;
|
|
434
|
+
}
|
|
435
|
+
case "room-fanout-error": {
|
|
436
|
+
if (this._isLeader) return;
|
|
437
|
+
const room = msg.room as string | undefined;
|
|
438
|
+
const error = msg.error;
|
|
439
|
+
if (typeof room === "string") {
|
|
440
|
+
this.hooks.onRoomFanoutError?.(room, error);
|
|
441
|
+
}
|
|
362
442
|
break;
|
|
363
443
|
}
|
|
364
444
|
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
// Integration tests for the engine's WS room subscription path.
|
|
2
|
+
// Exercises subscribeRoom / unsubscribeRoom against the in-process
|
|
3
|
+
// TestServer:
|
|
4
|
+
// - subscribeRoom emits `room-subscribe` over the WS exactly once,
|
|
5
|
+
// refcounted across multiple subscribers
|
|
6
|
+
// - inbound `room-snapshot` populates the local registry + fires
|
|
7
|
+
// subscriber callbacks
|
|
8
|
+
// - inbound `room-update` action:join mutates the cached members
|
|
9
|
+
// - WS reconnect resends `room-subscribe` for every active room
|
|
10
|
+
// - last unsubscribe emits `room-unsubscribe`
|
|
11
|
+
// - inbound `error { code: NOT_IN_ROOM, room }` surfaces via the
|
|
12
|
+
// registry's error slot
|
|
13
|
+
//
|
|
14
|
+
// All harness-level — no real network. The TestServer's `pushToUser`
|
|
15
|
+
// + WS subprotocol bearer threading do the work.
|
|
16
|
+
|
|
17
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
18
|
+
|
|
19
|
+
import { createTestEnv, type TestEnv } from "./test-harness";
|
|
20
|
+
|
|
21
|
+
function isRoomSubscribe(msg: unknown, room: string): boolean {
|
|
22
|
+
return (
|
|
23
|
+
typeof msg === "object" &&
|
|
24
|
+
msg !== null &&
|
|
25
|
+
(msg as Record<string, unknown>).type === "room-subscribe" &&
|
|
26
|
+
(msg as Record<string, unknown>).room === room
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function isRoomUnsubscribe(msg: unknown, room: string): boolean {
|
|
31
|
+
return (
|
|
32
|
+
typeof msg === "object" &&
|
|
33
|
+
msg !== null &&
|
|
34
|
+
(msg as Record<string, unknown>).type === "room-unsubscribe" &&
|
|
35
|
+
(msg as Record<string, unknown>).room === room
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function countWs(env: TestEnv, predicate: (msg: unknown) => boolean): number {
|
|
40
|
+
return env.server.receivedWsMessages.filter((m) => predicate(m.msg)).length;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe("engine WS room subscriptions", () => {
|
|
44
|
+
let env: TestEnv;
|
|
45
|
+
|
|
46
|
+
beforeEach(() => {
|
|
47
|
+
env = createTestEnv();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
afterEach(async () => {
|
|
51
|
+
await env.dispose();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("first subscribeRoom emits room-subscribe; second is dedup'd", async () => {
|
|
55
|
+
env.signIn({ userId: "u1" });
|
|
56
|
+
await env.start();
|
|
57
|
+
env.engine.subscribeRoom("channel:foo", () => {});
|
|
58
|
+
env.engine.subscribeRoom("channel:foo", () => {});
|
|
59
|
+
await env.flush();
|
|
60
|
+
expect(countWs(env, (m) => isRoomSubscribe(m, "channel:foo"))).toBe(1);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("inbound room-snapshot fires subscriber callback with members", async () => {
|
|
64
|
+
env.signIn({ userId: "u1" });
|
|
65
|
+
await env.start();
|
|
66
|
+
let callbackCount = 0;
|
|
67
|
+
env.engine.subscribeRoom("channel:foo", () => {
|
|
68
|
+
callbackCount += 1;
|
|
69
|
+
});
|
|
70
|
+
await env.flush();
|
|
71
|
+
env.server.pushToUser("u1", {
|
|
72
|
+
type: "room-snapshot",
|
|
73
|
+
room: "channel:foo",
|
|
74
|
+
members: [
|
|
75
|
+
{ user_id: "alice", joined_at: "t0" },
|
|
76
|
+
{ user_id: "bob", joined_at: "t1" },
|
|
77
|
+
],
|
|
78
|
+
});
|
|
79
|
+
await env.flush();
|
|
80
|
+
expect(callbackCount).toBeGreaterThanOrEqual(1);
|
|
81
|
+
const members = env.engine.getRoomMembers("channel:foo");
|
|
82
|
+
expect(members).toHaveLength(2);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("inbound room-update action:join mutates cached members", async () => {
|
|
86
|
+
env.signIn({ userId: "u1" });
|
|
87
|
+
await env.start();
|
|
88
|
+
env.engine.subscribeRoom("channel:foo", () => {});
|
|
89
|
+
await env.flush();
|
|
90
|
+
env.server.pushToUser("u1", {
|
|
91
|
+
type: "room-snapshot",
|
|
92
|
+
room: "channel:foo",
|
|
93
|
+
members: [{ user_id: "alice", joined_at: "t0" }],
|
|
94
|
+
});
|
|
95
|
+
env.server.pushToUser("u1", {
|
|
96
|
+
type: "room-update",
|
|
97
|
+
room: "channel:foo",
|
|
98
|
+
action: "join",
|
|
99
|
+
member: { user_id: "bob", joined_at: "t1" },
|
|
100
|
+
});
|
|
101
|
+
await env.flush();
|
|
102
|
+
const members = env.engine.getRoomMembers("channel:foo");
|
|
103
|
+
expect(members?.map((m) => m.user_id).sort()).toEqual(["alice", "bob"]);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("last unsubscribeRoom emits room-unsubscribe + clears registry entry", async () => {
|
|
107
|
+
env.signIn({ userId: "u1" });
|
|
108
|
+
await env.start();
|
|
109
|
+
const unsub1 = env.engine.subscribeRoom("channel:foo", () => {});
|
|
110
|
+
const unsub2 = env.engine.subscribeRoom("channel:foo", () => {});
|
|
111
|
+
await env.flush();
|
|
112
|
+
unsub1();
|
|
113
|
+
expect(countWs(env, (m) => isRoomUnsubscribe(m, "channel:foo"))).toBe(0);
|
|
114
|
+
unsub2();
|
|
115
|
+
await env.flush();
|
|
116
|
+
expect(countWs(env, (m) => isRoomUnsubscribe(m, "channel:foo"))).toBe(1);
|
|
117
|
+
expect(env.engine.getRoomMembers("channel:foo")).toBeNull();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("inbound error { code: NOT_IN_ROOM } surfaces via getRoomError", async () => {
|
|
121
|
+
env.signIn({ userId: "u1" });
|
|
122
|
+
await env.start();
|
|
123
|
+
let callbackFired = false;
|
|
124
|
+
env.engine.subscribeRoom("channel:foo", () => {
|
|
125
|
+
callbackFired = true;
|
|
126
|
+
});
|
|
127
|
+
await env.flush();
|
|
128
|
+
env.server.pushToUser("u1", {
|
|
129
|
+
type: "error",
|
|
130
|
+
code: "NOT_IN_ROOM",
|
|
131
|
+
room: "channel:foo",
|
|
132
|
+
message: "not a member",
|
|
133
|
+
});
|
|
134
|
+
await env.flush();
|
|
135
|
+
expect(callbackFired).toBe(true);
|
|
136
|
+
expect(env.engine.getRoomError("channel:foo")).toEqual({
|
|
137
|
+
code: "NOT_IN_ROOM",
|
|
138
|
+
message: "not a member",
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("isWebSocketConnected reflects WS open state", async () => {
|
|
143
|
+
env.signIn({ userId: "u1" });
|
|
144
|
+
await env.start();
|
|
145
|
+
expect(env.engine.isWebSocketConnected()).toBe(true);
|
|
146
|
+
expect(env.engine.getActiveTransportType()).toBe("websocket");
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("WS reconnect resends room-subscribe for every active room", async () => {
|
|
150
|
+
// Tight reconnect budget so the test runs in a couple hundred ms
|
|
151
|
+
// rather than seconds. Picks a short base delay; the full-jitter
|
|
152
|
+
// formula caps the first attempt at random(0, 50ms).
|
|
153
|
+
const reconnectEnv = createTestEnv({ reconnectDelay: 50 });
|
|
154
|
+
try {
|
|
155
|
+
reconnectEnv.signIn({ userId: "u1" });
|
|
156
|
+
await reconnectEnv.start();
|
|
157
|
+
reconnectEnv.engine.subscribeRoom("channel:a", () => {});
|
|
158
|
+
reconnectEnv.engine.subscribeRoom("channel:b", () => {});
|
|
159
|
+
await reconnectEnv.flush();
|
|
160
|
+
expect(
|
|
161
|
+
countWs(reconnectEnv, (m) => isRoomSubscribe(m, "channel:a")),
|
|
162
|
+
).toBe(1);
|
|
163
|
+
expect(
|
|
164
|
+
countWs(reconnectEnv, (m) => isRoomSubscribe(m, "channel:b")),
|
|
165
|
+
).toBe(1);
|
|
166
|
+
// Force the WS to drop. The engine schedules a reconnect via its
|
|
167
|
+
// backoff, opens a new socket, fires onConnected → rooms.replay().
|
|
168
|
+
const closed = reconnectEnv.transport.closeLatestWs();
|
|
169
|
+
expect(closed).toBe(true);
|
|
170
|
+
await reconnectEnv.flush(200);
|
|
171
|
+
expect(reconnectEnv.transport.wsConnectCount()).toBeGreaterThanOrEqual(2);
|
|
172
|
+
// Replay: each active room got a fresh room-subscribe.
|
|
173
|
+
expect(
|
|
174
|
+
countWs(reconnectEnv, (m) => isRoomSubscribe(m, "channel:a")),
|
|
175
|
+
).toBe(2);
|
|
176
|
+
expect(
|
|
177
|
+
countWs(reconnectEnv, (m) => isRoomSubscribe(m, "channel:b")),
|
|
178
|
+
).toBe(2);
|
|
179
|
+
} finally {
|
|
180
|
+
await reconnectEnv.dispose();
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("StrictMode-style double-subscribe to same room only ships one wire frame", async () => {
|
|
185
|
+
env.signIn({ userId: "u1" });
|
|
186
|
+
await env.start();
|
|
187
|
+
// Simulate StrictMode: mount, mount again, unmount, unmount.
|
|
188
|
+
const u1 = env.engine.subscribeRoom("channel:foo", () => {});
|
|
189
|
+
const u2 = env.engine.subscribeRoom("channel:foo", () => {});
|
|
190
|
+
await env.flush();
|
|
191
|
+
expect(countWs(env, (m) => isRoomSubscribe(m, "channel:foo"))).toBe(1);
|
|
192
|
+
u1();
|
|
193
|
+
u2();
|
|
194
|
+
await env.flush();
|
|
195
|
+
expect(countWs(env, (m) => isRoomUnsubscribe(m, "channel:foo"))).toBe(1);
|
|
196
|
+
});
|
|
197
|
+
});
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// Unit tests for RoomSubscriptions. Pins the contract the engine
|
|
2
|
+
// depends on:
|
|
3
|
+
// - register/unregister refcount → first add sends room-subscribe,
|
|
4
|
+
// last remove sends room-unsubscribe
|
|
5
|
+
// - applySnapshot / applyUpdate / applyError mutate cached state and
|
|
6
|
+
// pulse subscribers
|
|
7
|
+
// - replay() resends room-subscribe for every active room (used by
|
|
8
|
+
// the engine's onConnected hook so reconnect resyncs membership)
|
|
9
|
+
// - sendWs returning false (no WS) doesn't break refcount semantics
|
|
10
|
+
//
|
|
11
|
+
// The engine integration (multi-tab forwarding, fanout) is covered in
|
|
12
|
+
// the scenarios suite — these tests pin the registry in isolation so
|
|
13
|
+
// regressions surface at the layer they originated.
|
|
14
|
+
|
|
15
|
+
import { describe, expect, test } from "bun:test";
|
|
16
|
+
|
|
17
|
+
import { RoomSubscriptions } from "./room-subscriptions";
|
|
18
|
+
|
|
19
|
+
function makeHarness() {
|
|
20
|
+
const sent: unknown[] = [];
|
|
21
|
+
// Default to "ws is open" so the refcount path actually ships
|
|
22
|
+
// subscribe / unsubscribe frames; individual tests override.
|
|
23
|
+
let wsOpen = true;
|
|
24
|
+
const rooms = new RoomSubscriptions((msg) => {
|
|
25
|
+
sent.push(msg);
|
|
26
|
+
return wsOpen;
|
|
27
|
+
});
|
|
28
|
+
return {
|
|
29
|
+
rooms,
|
|
30
|
+
sent,
|
|
31
|
+
setWsOpen(v: boolean) {
|
|
32
|
+
wsOpen = v;
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe("RoomSubscriptions: refcount + wire frames", () => {
|
|
38
|
+
test("first register ships room-subscribe; second is a no-op", () => {
|
|
39
|
+
const h = makeHarness();
|
|
40
|
+
h.rooms.register("channel:foo", () => {});
|
|
41
|
+
expect(h.sent).toEqual([{ type: "room-subscribe", room: "channel:foo" }]);
|
|
42
|
+
h.rooms.register("channel:foo", () => {});
|
|
43
|
+
expect(h.sent.length).toBe(1); // no second subscribe
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("last unregister ships room-unsubscribe; mid-stream calls are no-ops on wire", () => {
|
|
47
|
+
const h = makeHarness();
|
|
48
|
+
const unsub1 = h.rooms.register("channel:foo", () => {});
|
|
49
|
+
const unsub2 = h.rooms.register("channel:foo", () => {});
|
|
50
|
+
unsub1();
|
|
51
|
+
expect(h.sent.length).toBe(1); // still just the initial subscribe
|
|
52
|
+
unsub2();
|
|
53
|
+
expect(h.sent).toEqual([
|
|
54
|
+
{ type: "room-subscribe", room: "channel:foo" },
|
|
55
|
+
{ type: "room-unsubscribe", room: "channel:foo" },
|
|
56
|
+
]);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("unregisterRoom force-tears-down regardless of refcount", () => {
|
|
60
|
+
const h = makeHarness();
|
|
61
|
+
h.rooms.register("channel:foo", () => {});
|
|
62
|
+
h.rooms.register("channel:foo", () => {});
|
|
63
|
+
h.rooms.unregisterRoom("channel:foo");
|
|
64
|
+
expect(h.sent).toEqual([
|
|
65
|
+
{ type: "room-subscribe", room: "channel:foo" },
|
|
66
|
+
{ type: "room-unsubscribe", room: "channel:foo" },
|
|
67
|
+
]);
|
|
68
|
+
expect(h.rooms.has("channel:foo")).toBe(false);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("sendWs returning false (WS closed / follower) still refcounts correctly", () => {
|
|
72
|
+
const h = makeHarness();
|
|
73
|
+
h.setWsOpen(false);
|
|
74
|
+
const unsub = h.rooms.register("channel:foo", () => {});
|
|
75
|
+
// The send happened (registry doesn't care if it landed); it just
|
|
76
|
+
// didn't reach the wire. Engine's replay() on reconnect handles the
|
|
77
|
+
// catch-up.
|
|
78
|
+
expect(h.sent.length).toBe(1);
|
|
79
|
+
unsub();
|
|
80
|
+
expect(h.sent.length).toBe(2); // unsubscribe still queued
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe("RoomSubscriptions: snapshot / update / error", () => {
|
|
85
|
+
test("applySnapshot stores members and pulses every subscriber", () => {
|
|
86
|
+
const h = makeHarness();
|
|
87
|
+
let calls = 0;
|
|
88
|
+
h.rooms.register("channel:foo", () => {
|
|
89
|
+
calls += 1;
|
|
90
|
+
});
|
|
91
|
+
h.rooms.applySnapshot("channel:foo", [
|
|
92
|
+
{ user_id: "u1", joined_at: "t1" },
|
|
93
|
+
{ user_id: "u2", joined_at: "t2" },
|
|
94
|
+
]);
|
|
95
|
+
expect(h.rooms.members("channel:foo")).toHaveLength(2);
|
|
96
|
+
expect(calls).toBe(1);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("members() returns null before any snapshot lands", () => {
|
|
100
|
+
const h = makeHarness();
|
|
101
|
+
h.rooms.register("channel:foo", () => {});
|
|
102
|
+
expect(h.rooms.members("channel:foo")).toBeNull();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("applyUpdate join inserts a member; idempotent on duplicate user_id", () => {
|
|
106
|
+
const h = makeHarness();
|
|
107
|
+
h.rooms.register("channel:foo", () => {});
|
|
108
|
+
h.rooms.applySnapshot("channel:foo", []);
|
|
109
|
+
h.rooms.applyUpdate(
|
|
110
|
+
"channel:foo",
|
|
111
|
+
"join",
|
|
112
|
+
{ user_id: "u1", joined_at: "t1" },
|
|
113
|
+
undefined,
|
|
114
|
+
);
|
|
115
|
+
h.rooms.applyUpdate(
|
|
116
|
+
"channel:foo",
|
|
117
|
+
"join",
|
|
118
|
+
{ user_id: "u1", joined_at: "t1" },
|
|
119
|
+
undefined,
|
|
120
|
+
);
|
|
121
|
+
expect(h.rooms.members("channel:foo")).toHaveLength(1);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("applyUpdate leave removes the matching user", () => {
|
|
125
|
+
const h = makeHarness();
|
|
126
|
+
h.rooms.register("channel:foo", () => {});
|
|
127
|
+
h.rooms.applySnapshot("channel:foo", [
|
|
128
|
+
{ user_id: "u1", joined_at: "t1" },
|
|
129
|
+
{ user_id: "u2", joined_at: "t2" },
|
|
130
|
+
]);
|
|
131
|
+
h.rooms.applyUpdate(
|
|
132
|
+
"channel:foo",
|
|
133
|
+
"leave",
|
|
134
|
+
{ user_id: "u1", joined_at: "t1" },
|
|
135
|
+
undefined,
|
|
136
|
+
);
|
|
137
|
+
expect(h.rooms.members("channel:foo")).toEqual([
|
|
138
|
+
{ user_id: "u2", joined_at: "t2" },
|
|
139
|
+
]);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("applyError stores the error code and surfaces via error()", () => {
|
|
143
|
+
const h = makeHarness();
|
|
144
|
+
let calls = 0;
|
|
145
|
+
h.rooms.register("channel:foo", () => {
|
|
146
|
+
calls += 1;
|
|
147
|
+
});
|
|
148
|
+
h.rooms.applyError("channel:foo", { code: "NOT_IN_ROOM" });
|
|
149
|
+
expect(h.rooms.error("channel:foo")).toEqual({ code: "NOT_IN_ROOM" });
|
|
150
|
+
expect(calls).toBe(1);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("late register on an already-snapshotted room pulses the new sub immediately", () => {
|
|
154
|
+
const h = makeHarness();
|
|
155
|
+
h.rooms.register("channel:foo", () => {});
|
|
156
|
+
h.rooms.applySnapshot("channel:foo", [
|
|
157
|
+
{ user_id: "u1", joined_at: "t1" },
|
|
158
|
+
]);
|
|
159
|
+
let latePulseCount = 0;
|
|
160
|
+
h.rooms.register("channel:foo", () => {
|
|
161
|
+
latePulseCount += 1;
|
|
162
|
+
});
|
|
163
|
+
expect(latePulseCount).toBe(1); // fired synchronously
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
describe("RoomSubscriptions: replay on reconnect", () => {
|
|
168
|
+
test("replay() resends room-subscribe for every active room", () => {
|
|
169
|
+
const h = makeHarness();
|
|
170
|
+
h.rooms.register("channel:a", () => {});
|
|
171
|
+
h.rooms.register("channel:b", () => {});
|
|
172
|
+
h.sent.length = 0;
|
|
173
|
+
h.rooms.replay();
|
|
174
|
+
expect(h.sent).toEqual([
|
|
175
|
+
{ type: "room-subscribe", room: "channel:a" },
|
|
176
|
+
{ type: "room-subscribe", room: "channel:b" },
|
|
177
|
+
]);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("replay() skips fully-unregistered rooms", () => {
|
|
181
|
+
const h = makeHarness();
|
|
182
|
+
const unsub = h.rooms.register("channel:a", () => {});
|
|
183
|
+
h.rooms.register("channel:b", () => {});
|
|
184
|
+
unsub();
|
|
185
|
+
h.sent.length = 0;
|
|
186
|
+
h.rooms.replay();
|
|
187
|
+
expect(h.sent).toEqual([{ type: "room-subscribe", room: "channel:b" }]);
|
|
188
|
+
});
|
|
189
|
+
});
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
// RoomSubscriptions — client-side registry for WS-pushed room membership.
|
|
2
|
+
//
|
|
3
|
+
// The server (>=v0.3.214) accepts `{ type: "room-subscribe", room }` and
|
|
4
|
+
// `{ type: "room-unsubscribe", room }` control frames, and pushes back
|
|
5
|
+
// `room-snapshot` (members list) + `room-update` (join/leave/presence/
|
|
6
|
+
// broadcast deltas). This registry owns the client-side bookkeeping:
|
|
7
|
+
//
|
|
8
|
+
// - refcount per roomId so N `useRoom` mounts of the same channel
|
|
9
|
+
// produce exactly one `room-subscribe` on the wire (the first add)
|
|
10
|
+
// and one `room-unsubscribe` (the last remove). Mirrors the dedup
|
|
11
|
+
// story `useRoom`'s `__roomRegistryInternals` already enforced at
|
|
12
|
+
// the React layer — but moved INTO the engine because the WS sub
|
|
13
|
+
// is the engine's connection, not React's.
|
|
14
|
+
//
|
|
15
|
+
// - per-room subscriber callbacks. Server pushes are O(rooms), but
|
|
16
|
+
// each push has to fan out to every component that subscribed to
|
|
17
|
+
// that room. We notify them all when the snapshot or update lands.
|
|
18
|
+
//
|
|
19
|
+
// - cached `members` snapshot so a late subscriber (mounting after
|
|
20
|
+
// the initial snapshot already landed) gets the current state on
|
|
21
|
+
// `register()` without waiting for the next push.
|
|
22
|
+
//
|
|
23
|
+
// - replay-on-reconnect. Server clears its per-client room
|
|
24
|
+
// subscription state on disconnect; on reconnect, we resend a
|
|
25
|
+
// `room-subscribe` for every roomId that still has subscribers.
|
|
26
|
+
//
|
|
27
|
+
// Why a separate registry from ServerSubscriptions:
|
|
28
|
+
// ServerSubscriptions is a write-only replay registry — it remembers
|
|
29
|
+
// the SUBSCRIBE message and re-sends it on reconnect. Rooms need
|
|
30
|
+
// more than that: per-room state (the members snapshot), per-room
|
|
31
|
+
// callbacks, and per-room error surfacing. Mixing those concerns
|
|
32
|
+
// into ServerSubscriptions would dilute its single responsibility.
|
|
33
|
+
// Both are used together by the engine: ServerSubscriptions handles
|
|
34
|
+
// the replay primitive; RoomSubscriptions is the consumer that
|
|
35
|
+
// keys into it for rooms.
|
|
36
|
+
//
|
|
37
|
+
// Multi-tab note: only the leader tab opens a WS, so only the leader
|
|
38
|
+
// runs this registry against a real socket. Follower tabs broadcast
|
|
39
|
+
// `room-sub-register` envelopes to the leader, the leader keeps a
|
|
40
|
+
// per-room follower set so the WS sub stays alive while any tab in
|
|
41
|
+
// the origin still wants it, and inbound `room-snapshot`/`room-update`
|
|
42
|
+
// land on the leader's WS and get fanned out cross-tab so each
|
|
43
|
+
// follower's local registry routes to its own React subscribers.
|
|
44
|
+
// The leader-side fanout / follower-side mirror plumbing lives on
|
|
45
|
+
// the engine + orchestrator; this module is leader-local state.
|
|
46
|
+
|
|
47
|
+
export interface RoomMember {
|
|
48
|
+
user_id: string;
|
|
49
|
+
joined_at: string;
|
|
50
|
+
data?: Record<string, unknown>;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Reason codes the SDK exposes on a room subscription error. */
|
|
54
|
+
export type RoomErrorCode = "NOT_IN_ROOM" | "UNKNOWN";
|
|
55
|
+
|
|
56
|
+
export interface RoomError {
|
|
57
|
+
code: RoomErrorCode;
|
|
58
|
+
message?: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Callback fired when a room's membership snapshot OR error state
|
|
62
|
+
* changes. The same callback receives every transition for the room —
|
|
63
|
+
* snapshots overwrite state, updates mutate it, and errors set the
|
|
64
|
+
* `error` slot. Subscribers read the latest values via the getters on
|
|
65
|
+
* the entry; this fires purely as a "something changed" pulse so
|
|
66
|
+
* React hooks can re-render. */
|
|
67
|
+
export type RoomSubscriber = () => void;
|
|
68
|
+
|
|
69
|
+
interface RoomEntry {
|
|
70
|
+
roomId: string;
|
|
71
|
+
/** Current members snapshot (post-snapshot/update). `null` until the
|
|
72
|
+
* first snapshot lands — distinct from "empty room" which is `[]`.
|
|
73
|
+
* React hooks distinguish loading vs empty using this. */
|
|
74
|
+
members: RoomMember[] | null;
|
|
75
|
+
/** Latest error from the server (NOT_IN_ROOM). Cleared when a fresh
|
|
76
|
+
* snapshot lands. */
|
|
77
|
+
error: RoomError | null;
|
|
78
|
+
/** Number of `register()` calls that haven't been balanced by
|
|
79
|
+
* `unregister()`. The first add ships `room-subscribe`; the last
|
|
80
|
+
* remove ships `room-unsubscribe`. */
|
|
81
|
+
refs: number;
|
|
82
|
+
/** Subscriber callbacks — one per mounted React hook. */
|
|
83
|
+
subs: Set<RoomSubscriber>;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export class RoomSubscriptions {
|
|
87
|
+
private readonly rooms: Map<string, RoomEntry> = new Map();
|
|
88
|
+
|
|
89
|
+
/** Caller-supplied uplink to the WS. The engine routes through its
|
|
90
|
+
* active transport; this module stays transport-agnostic.
|
|
91
|
+
* Returns true when the message reached the wire (transport is open),
|
|
92
|
+
* false otherwise — RoomSubscriptions uses this to decide whether
|
|
93
|
+
* to surface "not connected yet" to the registry's callers.
|
|
94
|
+
* No-op transports (followers, no WS yet) return false; the engine
|
|
95
|
+
* hides the broadcast/leader split behind this hook. */
|
|
96
|
+
constructor(private readonly sendWs: (msg: unknown) => boolean) {}
|
|
97
|
+
|
|
98
|
+
/** Register a subscriber for `roomId`. First add ships the WS
|
|
99
|
+
* `room-subscribe`; subsequent adds just bump the refcount and
|
|
100
|
+
* deliver the cached snapshot to the new subscriber's callback.
|
|
101
|
+
*
|
|
102
|
+
* Returns an unsubscribe function that decrements the refcount on
|
|
103
|
+
* call. The last unsubscribe ships `room-unsubscribe`, clears the
|
|
104
|
+
* entry, and the next register() for the same room is a fresh
|
|
105
|
+
* start.
|
|
106
|
+
*
|
|
107
|
+
* Idempotent w.r.t. wire frames: a re-subscribe with no intervening
|
|
108
|
+
* full unsubscribe doesn't re-send `room-subscribe` to the server. */
|
|
109
|
+
register(roomId: string, subscriber: RoomSubscriber): () => void {
|
|
110
|
+
let entry = this.rooms.get(roomId);
|
|
111
|
+
const isFirst = !entry;
|
|
112
|
+
if (!entry) {
|
|
113
|
+
entry = {
|
|
114
|
+
roomId,
|
|
115
|
+
members: null,
|
|
116
|
+
error: null,
|
|
117
|
+
refs: 0,
|
|
118
|
+
subs: new Set(),
|
|
119
|
+
};
|
|
120
|
+
this.rooms.set(roomId, entry);
|
|
121
|
+
}
|
|
122
|
+
entry.refs += 1;
|
|
123
|
+
entry.subs.add(subscriber);
|
|
124
|
+
|
|
125
|
+
if (isFirst) {
|
|
126
|
+
// First subscriber — ask the server to start pushing updates.
|
|
127
|
+
// `sendWs` returns false when the transport isn't open yet (or
|
|
128
|
+
// we're a follower); that's fine — `replay()` on reconnect /
|
|
129
|
+
// promotion will re-send the subscribe.
|
|
130
|
+
this.sendWs({ type: "room-subscribe", room: roomId });
|
|
131
|
+
} else if (entry.members !== null || entry.error !== null) {
|
|
132
|
+
// Late subscriber on an already-active room. Fire one tick so
|
|
133
|
+
// the new callback observes the cached snapshot / error
|
|
134
|
+
// immediately instead of waiting for the next push.
|
|
135
|
+
try {
|
|
136
|
+
subscriber();
|
|
137
|
+
} catch (err) {
|
|
138
|
+
console.warn("[sync] room subscriber threw on initial notify:", err);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return () => this.unregisterOne(roomId, subscriber);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Decrement the refcount for one subscriber. Internal — the
|
|
146
|
+
* `register()` returned unsubscribe routes here. Last out ships
|
|
147
|
+
* `room-unsubscribe`. */
|
|
148
|
+
private unregisterOne(roomId: string, subscriber: RoomSubscriber): void {
|
|
149
|
+
const entry = this.rooms.get(roomId);
|
|
150
|
+
if (!entry) return;
|
|
151
|
+
entry.subs.delete(subscriber);
|
|
152
|
+
entry.refs -= 1;
|
|
153
|
+
if (entry.refs > 0) return;
|
|
154
|
+
this.rooms.delete(roomId);
|
|
155
|
+
this.sendWs({ type: "room-unsubscribe", room: roomId });
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Force a full teardown of one room regardless of refcount. Used by
|
|
159
|
+
* the engine's leader-handoff and the hook's manual `leave()` path.
|
|
160
|
+
* Notifies every remaining subscriber via the standard pulse (so
|
|
161
|
+
* they observe `members === null` and re-render as disconnected). */
|
|
162
|
+
unregisterRoom(roomId: string): void {
|
|
163
|
+
const entry = this.rooms.get(roomId);
|
|
164
|
+
if (!entry) return;
|
|
165
|
+
this.rooms.delete(roomId);
|
|
166
|
+
this.sendWs({ type: "room-unsubscribe", room: roomId });
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Snapshot push from the server: full membership for the room.
|
|
170
|
+
* Overwrites any cached state, clears any prior error, and pulses
|
|
171
|
+
* every subscriber callback. */
|
|
172
|
+
applySnapshot(roomId: string, members: RoomMember[]): void {
|
|
173
|
+
const entry = this.rooms.get(roomId);
|
|
174
|
+
if (!entry) return;
|
|
175
|
+
entry.members = members;
|
|
176
|
+
entry.error = null;
|
|
177
|
+
this.notify(entry);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Incremental update from the server. `action` mirrors the server's
|
|
181
|
+
* RoomEvent variants — join / leave / presence / broadcast. The
|
|
182
|
+
* registry mutates the cached `members` snapshot in-place so a
|
|
183
|
+
* re-render after the update sees the right shape without waiting
|
|
184
|
+
* for the next snapshot. `broadcast` updates don't touch members
|
|
185
|
+
* (no peer joined/left) but still fan out to listeners — Future
|
|
186
|
+
* work: expose a separate broadcast channel. For now the React
|
|
187
|
+
* hook only needs the membership delta. */
|
|
188
|
+
applyUpdate(
|
|
189
|
+
roomId: string,
|
|
190
|
+
action: "join" | "leave" | "presence" | "broadcast",
|
|
191
|
+
member: RoomMember | undefined,
|
|
192
|
+
_data: unknown,
|
|
193
|
+
): void {
|
|
194
|
+
const entry = this.rooms.get(roomId);
|
|
195
|
+
if (!entry) return;
|
|
196
|
+
// If we haven't received a snapshot yet, seed an empty list so
|
|
197
|
+
// the in-place mutation paths below have something to mutate.
|
|
198
|
+
if (entry.members === null) entry.members = [];
|
|
199
|
+
switch (action) {
|
|
200
|
+
case "join": {
|
|
201
|
+
if (!member) break;
|
|
202
|
+
// Idempotent insert: server may re-send a join on flapping
|
|
203
|
+
// connections; collapse by user_id.
|
|
204
|
+
const filtered = entry.members.filter(
|
|
205
|
+
(m) => m.user_id !== member.user_id,
|
|
206
|
+
);
|
|
207
|
+
filtered.push(member);
|
|
208
|
+
entry.members = filtered;
|
|
209
|
+
break;
|
|
210
|
+
}
|
|
211
|
+
case "leave": {
|
|
212
|
+
if (!member) break;
|
|
213
|
+
entry.members = entry.members.filter(
|
|
214
|
+
(m) => m.user_id !== member.user_id,
|
|
215
|
+
);
|
|
216
|
+
break;
|
|
217
|
+
}
|
|
218
|
+
case "presence": {
|
|
219
|
+
if (!member) break;
|
|
220
|
+
// Swap the matching member's data while preserving join order.
|
|
221
|
+
entry.members = entry.members.map((m) =>
|
|
222
|
+
m.user_id === member.user_id ? { ...m, ...member } : m,
|
|
223
|
+
);
|
|
224
|
+
break;
|
|
225
|
+
}
|
|
226
|
+
case "broadcast": {
|
|
227
|
+
// No membership delta. Still pulse subscribers so apps that
|
|
228
|
+
// want to react to broadcast traffic can read it via a future
|
|
229
|
+
// dedicated callback. The current React hook ignores this.
|
|
230
|
+
break;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
entry.error = null;
|
|
234
|
+
this.notify(entry);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Server pushed `{ type: "error", code, room }` after a subscribe.
|
|
238
|
+
* Record the error on the room and pulse subscribers — the React
|
|
239
|
+
* hook surfaces it via the `error` return slot. We do NOT
|
|
240
|
+
* unregister automatically: the user genuinely isn't in the room
|
|
241
|
+
* and the SDK shouldn't retry, but the registry entry stays so
|
|
242
|
+
* React unmount still ships a `room-unsubscribe` (server is
|
|
243
|
+
* idempotent for unknown subs). */
|
|
244
|
+
applyError(roomId: string, error: RoomError): void {
|
|
245
|
+
const entry = this.rooms.get(roomId);
|
|
246
|
+
if (!entry) return;
|
|
247
|
+
entry.error = error;
|
|
248
|
+
this.notify(entry);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Read the cached snapshot for a room without subscribing. The
|
|
252
|
+
* React hook uses this in its initial-state effect so a re-mount
|
|
253
|
+
* inside the same registry lifecycle picks up the current members
|
|
254
|
+
* on tick zero. Returns `null` when the snapshot hasn't landed
|
|
255
|
+
* yet — DISTINCT from `[]` ("empty room"). */
|
|
256
|
+
members(roomId: string): RoomMember[] | null {
|
|
257
|
+
return this.rooms.get(roomId)?.members ?? null;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Latest error for a room (null if none). */
|
|
261
|
+
error(roomId: string): RoomError | null {
|
|
262
|
+
return this.rooms.get(roomId)?.error ?? null;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Is there at least one local subscriber for `roomId`. Used by the
|
|
266
|
+
* hook's polling-fallback path to decide whether to keep polling. */
|
|
267
|
+
has(roomId: string): boolean {
|
|
268
|
+
return this.rooms.has(roomId);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Every room currently tracked. Used by `replay()` and by the
|
|
272
|
+
* multi-tab seed-on-promotion. */
|
|
273
|
+
roomIds(): string[] {
|
|
274
|
+
return Array.from(this.rooms.keys());
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Resend `room-subscribe` for every active room. Called by the
|
|
278
|
+
* engine's `onConnected` hook after the WS reopens — the server
|
|
279
|
+
* forgets per-client subs across disconnects, so without this
|
|
280
|
+
* resync the first push would never arrive. */
|
|
281
|
+
replay(): void {
|
|
282
|
+
for (const roomId of this.rooms.keys()) {
|
|
283
|
+
this.sendWs({ type: "room-subscribe", room: roomId });
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Test/diagnostics: total active rooms. */
|
|
288
|
+
size(): number {
|
|
289
|
+
return this.rooms.size;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
private notify(entry: RoomEntry): void {
|
|
293
|
+
for (const cb of entry.subs) {
|
|
294
|
+
try {
|
|
295
|
+
cb();
|
|
296
|
+
} catch (err) {
|
|
297
|
+
console.warn("[sync] room subscriber threw:", err);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
package/src/test-harness/env.ts
CHANGED
|
@@ -62,6 +62,11 @@ export interface CreateTestEnvOptions {
|
|
|
62
62
|
* "poll" to disable the WS-onopen reconcile race in scenarios
|
|
63
63
|
* that only want to pin the in-start pipeline. */
|
|
64
64
|
transport?: "websocket" | "poll" | "sse";
|
|
65
|
+
/** Base delay (ms) for WS reconnect backoff. The default value used
|
|
66
|
+
* inside the harness (`undefined` → 1000) introduces seconds of
|
|
67
|
+
* wall-clock waiting on every reconnect scenario; pass a small
|
|
68
|
+
* value when the test specifically exercises the reconnect path. */
|
|
69
|
+
reconnectDelay?: number;
|
|
65
70
|
}
|
|
66
71
|
|
|
67
72
|
export interface TestEnv {
|
|
@@ -116,6 +121,7 @@ export function createTestEnv(opts: CreateTestEnvOptions = {}): TestEnv {
|
|
|
116
121
|
persist: false,
|
|
117
122
|
storage,
|
|
118
123
|
transport: opts.transport ?? "websocket",
|
|
124
|
+
reconnectDelay: opts.reconnectDelay,
|
|
119
125
|
// Tight timings so scenarios don't have to sleep seconds. The
|
|
120
126
|
// engine's reconcile debounce is also relaxed so back-to-back
|
|
121
127
|
// visibility-change triggers don't get coalesced away in tests.
|
|
@@ -19,6 +19,10 @@ export interface TransportHandle {
|
|
|
19
19
|
fetchCount: () => number;
|
|
20
20
|
/** Number of WS connections opened so far. */
|
|
21
21
|
wsConnectCount: () => number;
|
|
22
|
+
/** Close the most-recently-opened mock WS so the engine sees a
|
|
23
|
+
* disconnect and schedules reconnect via its backoff. Returns
|
|
24
|
+
* true if a socket was actually closed, false otherwise. */
|
|
25
|
+
closeLatestWs: () => boolean;
|
|
22
26
|
/** Tear down the global stubs. */
|
|
23
27
|
restore: () => void;
|
|
24
28
|
}
|
|
@@ -33,6 +37,12 @@ export function installTransport(server: TestServer): TransportHandle {
|
|
|
33
37
|
let token: string | undefined;
|
|
34
38
|
let fetchCount = 0;
|
|
35
39
|
let wsConnectCount = 0;
|
|
40
|
+
// Track open mock sockets so a test that needs to simulate a
|
|
41
|
+
// server-side disconnect (cluster bounce, autostop) can grab the
|
|
42
|
+
// latest connection and close it. The engine's reconnect loop
|
|
43
|
+
// builds a fresh MockWebSocket on the next attempt; each shows up
|
|
44
|
+
// here in order so the list maps 1:1 to actual connect attempts.
|
|
45
|
+
const openSockets: MockWebSocket[] = [];
|
|
36
46
|
|
|
37
47
|
const originalFetch = globalThis.fetch;
|
|
38
48
|
const originalWS = (globalThis as { WebSocket?: unknown }).WebSocket;
|
|
@@ -87,6 +97,7 @@ export function installTransport(server: TestServer): TransportHandle {
|
|
|
87
97
|
super();
|
|
88
98
|
this.url = url;
|
|
89
99
|
wsConnectCount += 1;
|
|
100
|
+
openSockets.push(this);
|
|
90
101
|
// The engine encodes the token as `bearer.<percent-encoded>` in
|
|
91
102
|
// the WS subprotocol when one is set. Decode it so the harness
|
|
92
103
|
// can route this connection to the right subscriber bucket
|
|
@@ -144,6 +155,18 @@ export function installTransport(server: TestServer): TransportHandle {
|
|
|
144
155
|
},
|
|
145
156
|
fetchCount: () => fetchCount,
|
|
146
157
|
wsConnectCount: () => wsConnectCount,
|
|
158
|
+
closeLatestWs: () => {
|
|
159
|
+
// Walk back from the most recent socket to find one that's
|
|
160
|
+
// still open. Older closed sockets get skipped silently.
|
|
161
|
+
for (let i = openSockets.length - 1; i >= 0; i--) {
|
|
162
|
+
const s = openSockets[i];
|
|
163
|
+
if (s.readyState !== 3) {
|
|
164
|
+
s.close();
|
|
165
|
+
return true;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return false;
|
|
169
|
+
},
|
|
147
170
|
restore: () => {
|
|
148
171
|
globalThis.fetch = originalFetch;
|
|
149
172
|
if (originalWS) {
|