@pylonsync/react 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 +3 -3
- package/src/db.ts +5 -1
- package/src/useRoom.push.test.ts +360 -0
- package/src/useRoom.ts +282 -19
package/package.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"publishConfig": {
|
|
4
4
|
"access": "public"
|
|
5
5
|
},
|
|
6
|
-
"version": "0.3.
|
|
6
|
+
"version": "0.3.216",
|
|
7
7
|
"type": "module",
|
|
8
8
|
"main": "src/index.ts",
|
|
9
9
|
"types": "src/index.ts",
|
|
@@ -12,8 +12,8 @@
|
|
|
12
12
|
"check": "tsc -p tsconfig.json --noEmit"
|
|
13
13
|
},
|
|
14
14
|
"dependencies": {
|
|
15
|
-
"@pylonsync/sdk": "0.3.
|
|
16
|
-
"@pylonsync/sync": "0.3.
|
|
15
|
+
"@pylonsync/sdk": "0.3.216",
|
|
16
|
+
"@pylonsync/sync": "0.3.216"
|
|
17
17
|
},
|
|
18
18
|
"peerDependencies": {
|
|
19
19
|
"react": ">=19.0.0"
|
package/src/db.ts
CHANGED
|
@@ -75,7 +75,11 @@ export function init(config?: Partial<SyncEngineConfig> & { baseUrl?: string })
|
|
|
75
75
|
});
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
-
|
|
78
|
+
/** Module-internal accessor for the global sync engine. Exported so
|
|
79
|
+
* hooks living outside this file (e.g. `useRoom`) can share the same
|
|
80
|
+
* engine instance and benefit from the same lazy-start / lazy-init
|
|
81
|
+
* semantics — without re-implementing the resolution rules. */
|
|
82
|
+
export function getSync(): SyncEngine {
|
|
79
83
|
if (!_sync) {
|
|
80
84
|
// Lazy fallback for callers that never invoked init(). Same
|
|
81
85
|
// resolution rules as init: browser → window.location.origin,
|
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
// useRoom — WebSocket push path coverage.
|
|
2
|
+
//
|
|
3
|
+
// The legacy polling-only path is covered in `useRoom.test.ts`; this
|
|
4
|
+
// file focuses on the v0.3.216 push protocol integration:
|
|
5
|
+
//
|
|
6
|
+
// - WS connected + engine wired in → registry attaches to
|
|
7
|
+
// engine.subscribeRoom AND does NOT start a polling interval
|
|
8
|
+
// - inbound room-snapshot via the engine flows through to subscriber
|
|
9
|
+
// callbacks
|
|
10
|
+
// - inbound room-update action:join updates peers in the registry
|
|
11
|
+
// - WS not connected → polling fallback fires (existing behaviour
|
|
12
|
+
// preserved when the engine reports the WS down)
|
|
13
|
+
// - StrictMode double-mount with WS → still one engine subscription
|
|
14
|
+
//
|
|
15
|
+
// We drive the registry directly via `__roomRegistryInternals` and feed
|
|
16
|
+
// in a controllable fake engine — no React renderer involved, no real
|
|
17
|
+
// network. The fake engine matches the surface the hook actually
|
|
18
|
+
// consumes (subscribeRoom, getRoomMembers, getRoomError,
|
|
19
|
+
// isWebSocketConnected, connectionStatus, store.subscribe).
|
|
20
|
+
|
|
21
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
22
|
+
|
|
23
|
+
import { __roomRegistryInternals } from "./useRoom";
|
|
24
|
+
|
|
25
|
+
// --- fetch stub (mirrors useRoom.test.ts) ------------------------------
|
|
26
|
+
|
|
27
|
+
interface RecordedRequest {
|
|
28
|
+
url: string;
|
|
29
|
+
method: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
let recorded: RecordedRequest[];
|
|
33
|
+
let originalFetch: typeof globalThis.fetch;
|
|
34
|
+
|
|
35
|
+
function installFetchStub(): void {
|
|
36
|
+
recorded = [];
|
|
37
|
+
originalFetch = globalThis.fetch;
|
|
38
|
+
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
39
|
+
const url = typeof input === "string" ? input : input.toString();
|
|
40
|
+
const method = (init?.method ?? "GET").toUpperCase();
|
|
41
|
+
recorded.push({ url, method });
|
|
42
|
+
return new Response(JSON.stringify({ snapshot: { peers: [] }, members: [] }), {
|
|
43
|
+
status: 200,
|
|
44
|
+
headers: { "content-type": "application/json" },
|
|
45
|
+
});
|
|
46
|
+
}) as typeof fetch;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function restoreFetch(): void {
|
|
50
|
+
globalThis.fetch = originalFetch;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function isHeartbeat(r: RecordedRequest): boolean {
|
|
54
|
+
return r.method === "GET" && /\/api\/rooms\/[^/]+$/.test(r.url);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// --- fake engine -------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
interface FakeRoomEntry {
|
|
60
|
+
callbacks: Set<() => void>;
|
|
61
|
+
members: Array<{ user_id: string; joined_at: string; data?: any }> | null;
|
|
62
|
+
error: { code: string; message?: string } | null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
interface FakeEngine {
|
|
66
|
+
// Surface that useRoom actually pokes:
|
|
67
|
+
subscribeRoom: (roomId: string, cb: () => void) => () => void;
|
|
68
|
+
getRoomMembers: (roomId: string) =>
|
|
69
|
+
| Array<{ user_id: string; joined_at: string; data?: any }>
|
|
70
|
+
| null;
|
|
71
|
+
getRoomError: (roomId: string) => { code: string; message?: string } | null;
|
|
72
|
+
isWebSocketConnected: () => boolean;
|
|
73
|
+
connectionStatus: () => string;
|
|
74
|
+
// The store.subscribe surface — useRoom subscribes to connection-status
|
|
75
|
+
// changes via it.
|
|
76
|
+
store: {
|
|
77
|
+
subscribe: (fn: () => void) => () => void;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
// Test helpers — not on the real engine.
|
|
81
|
+
_wsOpen: boolean;
|
|
82
|
+
_statusListeners: Set<() => void>;
|
|
83
|
+
_rooms: Map<string, FakeRoomEntry>;
|
|
84
|
+
_subscribeCalls: string[];
|
|
85
|
+
_unsubscribeCalls: string[];
|
|
86
|
+
setWsConnected(open: boolean): void;
|
|
87
|
+
pushSnapshot(roomId: string, members: any[]): void;
|
|
88
|
+
pushUpdate(
|
|
89
|
+
roomId: string,
|
|
90
|
+
action: "join" | "leave" | "presence" | "broadcast",
|
|
91
|
+
member?: any,
|
|
92
|
+
): void;
|
|
93
|
+
pushError(roomId: string, code: string, message?: string): void;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function makeFakeEngine(): FakeEngine {
|
|
97
|
+
const engine: FakeEngine = {
|
|
98
|
+
_wsOpen: true,
|
|
99
|
+
_statusListeners: new Set(),
|
|
100
|
+
_rooms: new Map(),
|
|
101
|
+
_subscribeCalls: [],
|
|
102
|
+
_unsubscribeCalls: [],
|
|
103
|
+
|
|
104
|
+
isWebSocketConnected: () => engine._wsOpen,
|
|
105
|
+
connectionStatus: () => (engine._wsOpen ? "connected" : "reconnecting"),
|
|
106
|
+
store: {
|
|
107
|
+
subscribe: (fn) => {
|
|
108
|
+
engine._statusListeners.add(fn);
|
|
109
|
+
return () => engine._statusListeners.delete(fn);
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
subscribeRoom: (roomId, cb) => {
|
|
113
|
+
engine._subscribeCalls.push(roomId);
|
|
114
|
+
let entry = engine._rooms.get(roomId);
|
|
115
|
+
if (!entry) {
|
|
116
|
+
entry = { callbacks: new Set(), members: null, error: null };
|
|
117
|
+
engine._rooms.set(roomId, entry);
|
|
118
|
+
}
|
|
119
|
+
entry.callbacks.add(cb);
|
|
120
|
+
// Fire one tick if there's already cached state (registry late-
|
|
121
|
+
// subscriber path).
|
|
122
|
+
if (entry.members !== null || entry.error !== null) cb();
|
|
123
|
+
return () => {
|
|
124
|
+
const e = engine._rooms.get(roomId);
|
|
125
|
+
if (!e) return;
|
|
126
|
+
e.callbacks.delete(cb);
|
|
127
|
+
if (e.callbacks.size === 0) {
|
|
128
|
+
engine._rooms.delete(roomId);
|
|
129
|
+
engine._unsubscribeCalls.push(roomId);
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
},
|
|
133
|
+
getRoomMembers: (roomId) => engine._rooms.get(roomId)?.members ?? null,
|
|
134
|
+
getRoomError: (roomId) => engine._rooms.get(roomId)?.error ?? null,
|
|
135
|
+
|
|
136
|
+
setWsConnected: (open) => {
|
|
137
|
+
engine._wsOpen = open;
|
|
138
|
+
for (const fn of engine._statusListeners) fn();
|
|
139
|
+
},
|
|
140
|
+
pushSnapshot: (roomId, members) => {
|
|
141
|
+
const entry = engine._rooms.get(roomId);
|
|
142
|
+
if (!entry) return;
|
|
143
|
+
entry.members = members;
|
|
144
|
+
entry.error = null;
|
|
145
|
+
for (const cb of entry.callbacks) cb();
|
|
146
|
+
},
|
|
147
|
+
pushUpdate: (roomId, action, member) => {
|
|
148
|
+
const entry = engine._rooms.get(roomId);
|
|
149
|
+
if (!entry) return;
|
|
150
|
+
if (entry.members === null) entry.members = [];
|
|
151
|
+
if (action === "join" && member) {
|
|
152
|
+
const filtered = entry.members.filter(
|
|
153
|
+
(m) => m.user_id !== member.user_id,
|
|
154
|
+
);
|
|
155
|
+
filtered.push(member);
|
|
156
|
+
entry.members = filtered;
|
|
157
|
+
} else if (action === "leave" && member) {
|
|
158
|
+
entry.members = entry.members.filter(
|
|
159
|
+
(m) => m.user_id !== member.user_id,
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
for (const cb of entry.callbacks) cb();
|
|
163
|
+
},
|
|
164
|
+
pushError: (roomId, code, message) => {
|
|
165
|
+
const entry = engine._rooms.get(roomId);
|
|
166
|
+
if (!entry) return;
|
|
167
|
+
entry.error = { code, message };
|
|
168
|
+
for (const cb of entry.callbacks) cb();
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
return engine;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// --- harness -----------------------------------------------------------
|
|
175
|
+
|
|
176
|
+
const BASE = "http://stub.invalid";
|
|
177
|
+
const ROOM = "channel:foo";
|
|
178
|
+
const USER = "user-1";
|
|
179
|
+
const TOKEN = "tok-1";
|
|
180
|
+
|
|
181
|
+
async function flush(ms = 5): Promise<void> {
|
|
182
|
+
for (let i = 0; i < 5; i++) await Promise.resolve();
|
|
183
|
+
if (ms > 0) await new Promise((res) => setTimeout(res, ms));
|
|
184
|
+
for (let i = 0; i < 5; i++) await Promise.resolve();
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
beforeEach(() => {
|
|
188
|
+
installFetchStub();
|
|
189
|
+
__roomRegistryInternals.reset();
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
afterEach(() => {
|
|
193
|
+
__roomRegistryInternals.reset();
|
|
194
|
+
restoreFetch();
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
// --- tests -------------------------------------------------------------
|
|
198
|
+
|
|
199
|
+
describe("useRoom WS push path", () => {
|
|
200
|
+
test("WS connected + engine wired → subscribeRoom called, no polling interval", async () => {
|
|
201
|
+
const engine = makeFakeEngine();
|
|
202
|
+
const room = __roomRegistryInternals.acquire(
|
|
203
|
+
BASE,
|
|
204
|
+
ROOM,
|
|
205
|
+
USER,
|
|
206
|
+
TOKEN,
|
|
207
|
+
{},
|
|
208
|
+
25, // would tick every 25ms IF polling were active
|
|
209
|
+
engine as any,
|
|
210
|
+
);
|
|
211
|
+
expect(engine._subscribeCalls).toEqual([ROOM]);
|
|
212
|
+
expect(__roomRegistryInternals.isWsAttached(room)).toBe(true);
|
|
213
|
+
expect(__roomRegistryInternals.isPolling(room)).toBe(false);
|
|
214
|
+
|
|
215
|
+
// Wait through what WOULD have been multiple polling ticks. No
|
|
216
|
+
// GET /api/rooms/<room> should ever land.
|
|
217
|
+
await flush(80);
|
|
218
|
+
expect(recorded.filter(isHeartbeat).length).toBe(0);
|
|
219
|
+
|
|
220
|
+
// Engine pushes a snapshot — the hook's local cache should reflect
|
|
221
|
+
// it once the registry's pulse runs.
|
|
222
|
+
engine.pushSnapshot(ROOM, [
|
|
223
|
+
{ user_id: "alice", joined_at: "t1" },
|
|
224
|
+
{ user_id: USER, joined_at: "t2" },
|
|
225
|
+
]);
|
|
226
|
+
await flush();
|
|
227
|
+
// The registry filters out the current user → only alice should
|
|
228
|
+
// remain.
|
|
229
|
+
expect(room.peers.map((p) => p.user_id)).toEqual(["alice"]);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test("inbound room-update action:join updates peers", async () => {
|
|
233
|
+
const engine = makeFakeEngine();
|
|
234
|
+
const room = __roomRegistryInternals.acquire(
|
|
235
|
+
BASE,
|
|
236
|
+
ROOM,
|
|
237
|
+
USER,
|
|
238
|
+
TOKEN,
|
|
239
|
+
{},
|
|
240
|
+
1_000,
|
|
241
|
+
engine as any,
|
|
242
|
+
);
|
|
243
|
+
// Let the HTTP join settle first so its (empty) snapshot doesn't
|
|
244
|
+
// race the WS-pushed members. In production the WS snapshot
|
|
245
|
+
// typically lands shortly AFTER the join HTTP response — this
|
|
246
|
+
// ordering matches that.
|
|
247
|
+
await flush();
|
|
248
|
+
engine.pushSnapshot(ROOM, []);
|
|
249
|
+
engine.pushUpdate(ROOM, "join", { user_id: "bob", joined_at: "t3" });
|
|
250
|
+
await flush();
|
|
251
|
+
expect(room.peers.map((p) => p.user_id)).toEqual(["bob"]);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test("server pushes NOT_IN_ROOM error → surfaces via room.error, isConnected false, no retry", async () => {
|
|
255
|
+
const engine = makeFakeEngine();
|
|
256
|
+
const room = __roomRegistryInternals.acquire(
|
|
257
|
+
BASE,
|
|
258
|
+
ROOM,
|
|
259
|
+
USER,
|
|
260
|
+
TOKEN,
|
|
261
|
+
{},
|
|
262
|
+
1_000,
|
|
263
|
+
engine as any,
|
|
264
|
+
);
|
|
265
|
+
// Let the HTTP join settle first so its success doesn't clobber
|
|
266
|
+
// the WS-pushed error state.
|
|
267
|
+
await flush();
|
|
268
|
+
engine.pushError(ROOM, "NOT_IN_ROOM", "not a member");
|
|
269
|
+
await flush();
|
|
270
|
+
expect(typeof room.error).toBe("string");
|
|
271
|
+
expect(room.error ?? "").toContain("not a member");
|
|
272
|
+
expect(room.isConnected).toBe(false);
|
|
273
|
+
// Snapshot-timeout fallback should NOT have engaged (error path
|
|
274
|
+
// cancels the backwards-compat timer).
|
|
275
|
+
await flush(80);
|
|
276
|
+
expect(__roomRegistryInternals.isPolling(room)).toBe(false);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
test("WS down → polling fallback fires; reconnect promotes back to push", async () => {
|
|
280
|
+
const engine = makeFakeEngine();
|
|
281
|
+
engine.setWsConnected(false);
|
|
282
|
+
const room = __roomRegistryInternals.acquire(
|
|
283
|
+
BASE,
|
|
284
|
+
ROOM,
|
|
285
|
+
USER,
|
|
286
|
+
TOKEN,
|
|
287
|
+
{},
|
|
288
|
+
25,
|
|
289
|
+
engine as any,
|
|
290
|
+
);
|
|
291
|
+
// No WS — no subscribeRoom call, polling active.
|
|
292
|
+
expect(engine._subscribeCalls).toEqual([]);
|
|
293
|
+
expect(__roomRegistryInternals.isPolling(room)).toBe(true);
|
|
294
|
+
|
|
295
|
+
await flush(80);
|
|
296
|
+
expect(recorded.filter(isHeartbeat).length).toBeGreaterThan(0);
|
|
297
|
+
|
|
298
|
+
// Flip to connected → registry promotes to push, polling stops.
|
|
299
|
+
engine.setWsConnected(true);
|
|
300
|
+
await flush();
|
|
301
|
+
expect(__roomRegistryInternals.isWsAttached(room)).toBe(true);
|
|
302
|
+
expect(__roomRegistryInternals.isPolling(room)).toBe(false);
|
|
303
|
+
expect(engine._subscribeCalls).toEqual([ROOM]);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
test("backwards-compat: WS connected but no snapshot in 2s → fall back to polling", async () => {
|
|
307
|
+
const engine = makeFakeEngine();
|
|
308
|
+
// Crucial: engine.subscribeRoom returns successfully but never
|
|
309
|
+
// pushes a snapshot. That mirrors talking to a pre-v0.3.214 server
|
|
310
|
+
// which silently drops the room-subscribe frame.
|
|
311
|
+
const room = __roomRegistryInternals.acquire(
|
|
312
|
+
BASE,
|
|
313
|
+
ROOM,
|
|
314
|
+
USER,
|
|
315
|
+
TOKEN,
|
|
316
|
+
{},
|
|
317
|
+
25,
|
|
318
|
+
engine as any,
|
|
319
|
+
);
|
|
320
|
+
expect(__roomRegistryInternals.isWsAttached(room)).toBe(true);
|
|
321
|
+
expect(__roomRegistryInternals.isPolling(room)).toBe(false);
|
|
322
|
+
|
|
323
|
+
// The push-snapshot timeout is 2s in the implementation. Wait
|
|
324
|
+
// long enough for it to fire AND let one polling tick happen.
|
|
325
|
+
await flush(2_100);
|
|
326
|
+
expect(__roomRegistryInternals.isWsAttached(room)).toBe(false);
|
|
327
|
+
expect(__roomRegistryInternals.isPolling(room)).toBe(true);
|
|
328
|
+
expect(recorded.filter(isHeartbeat).length).toBeGreaterThan(0);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
test("StrictMode double-mount on WS path: still one engine.subscribeRoom call", async () => {
|
|
332
|
+
const engine = makeFakeEngine();
|
|
333
|
+
const r1 = __roomRegistryInternals.acquire(
|
|
334
|
+
BASE,
|
|
335
|
+
ROOM,
|
|
336
|
+
USER,
|
|
337
|
+
TOKEN,
|
|
338
|
+
{},
|
|
339
|
+
1_000,
|
|
340
|
+
engine as any,
|
|
341
|
+
);
|
|
342
|
+
const r2 = __roomRegistryInternals.acquire(
|
|
343
|
+
BASE,
|
|
344
|
+
ROOM,
|
|
345
|
+
USER,
|
|
346
|
+
TOKEN,
|
|
347
|
+
{},
|
|
348
|
+
1_000,
|
|
349
|
+
engine as any,
|
|
350
|
+
);
|
|
351
|
+
expect(r1).toBe(r2);
|
|
352
|
+
// Registry dedup → only one acquire path → only one subscribe.
|
|
353
|
+
expect(engine._subscribeCalls).toEqual([ROOM]);
|
|
354
|
+
__roomRegistryInternals.release(r1);
|
|
355
|
+
__roomRegistryInternals.release(r2);
|
|
356
|
+
await flush(20);
|
|
357
|
+
// One subscribe, one unsubscribe — no fanout.
|
|
358
|
+
expect(engine._unsubscribeCalls).toEqual([ROOM]);
|
|
359
|
+
});
|
|
360
|
+
});
|
package/src/useRoom.ts
CHANGED
|
@@ -1,8 +1,20 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
4
|
-
import { pylonFetch } from '@pylonsync/sync';
|
|
4
|
+
import { pylonFetch, type RoomMember, type SyncEngine } from '@pylonsync/sync';
|
|
5
5
|
import { getBaseUrl, getReactStorage, storageKey } from './index';
|
|
6
|
+
import { getSync } from './db';
|
|
7
|
+
|
|
8
|
+
/** Project an engine RoomMember (data optional) into a RoomPeer (data
|
|
9
|
+
* required). The wire frame always carries a `data` slot — empty objects
|
|
10
|
+
* are valid — so the normalization is purely a type contract. */
|
|
11
|
+
function memberToPeer(m: RoomMember): RoomPeer {
|
|
12
|
+
return {
|
|
13
|
+
user_id: m.user_id,
|
|
14
|
+
joined_at: m.joined_at,
|
|
15
|
+
data: m.data ?? {},
|
|
16
|
+
};
|
|
17
|
+
}
|
|
6
18
|
|
|
7
19
|
// ---------------------------------------------------------------------------
|
|
8
20
|
// Room types
|
|
@@ -26,7 +38,12 @@ export interface UseRoomOptions {
|
|
|
26
38
|
token?: string;
|
|
27
39
|
/** Initial presence data sent on join. */
|
|
28
40
|
initialPresence?: Record<string, any>;
|
|
29
|
-
/**
|
|
41
|
+
/**
|
|
42
|
+
* How often (ms) to poll for peer updates WHEN the WebSocket
|
|
43
|
+
* push channel is unavailable. With a connected WebSocket the SDK
|
|
44
|
+
* uses `room-subscribe` push and the polling timer never fires.
|
|
45
|
+
* Defaults to 5_000.
|
|
46
|
+
*/
|
|
30
47
|
heartbeatInterval?: number;
|
|
31
48
|
}
|
|
32
49
|
|
|
@@ -54,7 +71,8 @@ export interface UseRoomReturn {
|
|
|
54
71
|
// composer, …). Without deduping that explodes into N joins on mount, N
|
|
55
72
|
// leaves on unmount, and N×interval heartbeat polls every tick. This
|
|
56
73
|
// registry collapses all calls with the same identity tuple into a single
|
|
57
|
-
// network footprint: one join, one interval
|
|
74
|
+
// network footprint: one join, one interval (polling-fallback only),
|
|
75
|
+
// one leave.
|
|
58
76
|
//
|
|
59
77
|
// Identity key includes everything that would change the network shape:
|
|
60
78
|
// baseUrl + token (different server / auth) + roomId + userId.
|
|
@@ -63,9 +81,27 @@ export interface UseRoomReturn {
|
|
|
63
81
|
// teardown is scheduled on a microtask. If the same hook re-mounts
|
|
64
82
|
// synchronously in the same tick (StrictMode double-mount, fast tab
|
|
65
83
|
// switch, parent re-key), the pending teardown is cancelled and the room
|
|
66
|
-
// stays alive. The
|
|
67
|
-
//
|
|
68
|
-
//
|
|
84
|
+
// stays alive. The `joined` race-fix from the in-effect implementation
|
|
85
|
+
// is preserved: leave only fires once the original join has actually
|
|
86
|
+
// landed on the server.
|
|
87
|
+
//
|
|
88
|
+
// WS-push integration (v0.3.216): when the sync engine's transport is
|
|
89
|
+
// websocket AND currently connected, the registry attaches to
|
|
90
|
+
// `engine.subscribeRoom(roomId, cb)` and stops the polling interval.
|
|
91
|
+
// The engine's room registry handles the wire-level refcount
|
|
92
|
+
// independently. When the WS isn't available (SSE transport, polling
|
|
93
|
+
// transport, OR WS not yet connected), the registry runs the legacy 5s
|
|
94
|
+
// polling loop. We re-evaluate on connection-status changes so a tab
|
|
95
|
+
// that mounts while offline polls until the WS comes up, then quietly
|
|
96
|
+
// switches to push.
|
|
97
|
+
|
|
98
|
+
/** Window (ms) we wait for a `room-snapshot` after sending the first
|
|
99
|
+
* `room-subscribe`. If nothing lands by then we assume the server is
|
|
100
|
+
* pre-v0.3.214 and doesn't speak the push protocol — fall back to
|
|
101
|
+
* polling. Sized generously enough to absorb a slow link + the
|
|
102
|
+
* server's RoomManager lookup; short enough that a misconfigured
|
|
103
|
+
* setup recovers without the user noticing. */
|
|
104
|
+
const PUSH_SNAPSHOT_TIMEOUT_MS = 2_000;
|
|
69
105
|
|
|
70
106
|
interface SharedRoom {
|
|
71
107
|
/** Number of live React hooks holding this room. */
|
|
@@ -78,7 +114,7 @@ interface SharedRoom {
|
|
|
78
114
|
error: string | null;
|
|
79
115
|
/** Last presence data the caller pushed via setPresence. */
|
|
80
116
|
presence: Record<string, any>;
|
|
81
|
-
/** Active heartbeat poll. */
|
|
117
|
+
/** Active heartbeat poll (only set when polling fallback is active). */
|
|
82
118
|
interval: ReturnType<typeof setInterval> | null;
|
|
83
119
|
/** Pending teardown — set when refcount hit 0 but we haven't actually
|
|
84
120
|
* fired leave yet, to absorb StrictMode mount/unmount/mount races. */
|
|
@@ -91,6 +127,22 @@ interface SharedRoom {
|
|
|
91
127
|
roomId: string;
|
|
92
128
|
userId: string;
|
|
93
129
|
heartbeatInterval: number;
|
|
130
|
+
/** Sync engine instance the room is attached to (for WS push).
|
|
131
|
+
* `null` when none provided / running in SSR or test harness that
|
|
132
|
+
* never invoked init(). */
|
|
133
|
+
engine: SyncEngine | null;
|
|
134
|
+
/** Unsubscribe handle returned by engine.subscribeRoom. `null` when
|
|
135
|
+
* we're polling. Mutually exclusive with `interval` — exactly one
|
|
136
|
+
* is set whenever the room is live. */
|
|
137
|
+
wsUnsubscribe: (() => void) | null;
|
|
138
|
+
/** Timer that fires PUSH_SNAPSHOT_TIMEOUT_MS after we send the first
|
|
139
|
+
* room-subscribe. If a snapshot lands first we cancel it; if it
|
|
140
|
+
* fires we assume the server doesn't speak the push protocol and
|
|
141
|
+
* fall back to polling. */
|
|
142
|
+
pushSnapshotTimer: ReturnType<typeof setTimeout> | null;
|
|
143
|
+
/** Connection-status unsubscribe — fires whenever the engine's
|
|
144
|
+
* transport state changes so we can flip between push and polling. */
|
|
145
|
+
connectionStatusUnsubscribe: (() => void) | null;
|
|
94
146
|
}
|
|
95
147
|
|
|
96
148
|
const rooms = new Map<string, SharedRoom>();
|
|
@@ -112,6 +164,12 @@ function transportFor(room: SharedRoom) {
|
|
|
112
164
|
return { baseUrl: room.baseUrl, token: room.token };
|
|
113
165
|
}
|
|
114
166
|
|
|
167
|
+
function roomKeyFor(room: SharedRoom): string {
|
|
168
|
+
return roomKey(room.baseUrl, room.roomId, room.userId, room.token);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Begin the 5s GET /api/rooms/<room> polling loop. Idempotent — if
|
|
172
|
+
* a polling timer is already running, no-op. */
|
|
115
173
|
function startHeartbeat(room: SharedRoom): void {
|
|
116
174
|
if (room.interval) return;
|
|
117
175
|
room.interval = setInterval(async () => {
|
|
@@ -133,14 +191,124 @@ function startHeartbeat(room: SharedRoom): void {
|
|
|
133
191
|
}, room.heartbeatInterval);
|
|
134
192
|
}
|
|
135
193
|
|
|
136
|
-
|
|
137
|
-
|
|
194
|
+
/** Stop the polling timer if running. */
|
|
195
|
+
function stopHeartbeat(room: SharedRoom): void {
|
|
196
|
+
if (room.interval) {
|
|
197
|
+
clearInterval(room.interval);
|
|
198
|
+
room.interval = null;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Attach to the engine's WS room subscription. Pulls in the current
|
|
203
|
+
* snapshot (or, when one lands, replaces the cached peers). If the
|
|
204
|
+
* initial subscribe doesn't surface a snapshot within
|
|
205
|
+
* PUSH_SNAPSHOT_TIMEOUT_MS, fall back to polling — that's the
|
|
206
|
+
* backwards-compat path for pre-v0.3.214 servers. */
|
|
207
|
+
function startWsPush(room: SharedRoom): void {
|
|
208
|
+
const engine = room.engine;
|
|
209
|
+
if (!engine) return;
|
|
210
|
+
if (room.wsUnsubscribe) return; // already attached
|
|
211
|
+
stopHeartbeat(room);
|
|
212
|
+
room.wsUnsubscribe = engine.subscribeRoom(room.roomId, () => {
|
|
213
|
+
if (!rooms.has(roomKeyFor(room))) return;
|
|
214
|
+
const members = engine.getRoomMembers(room.roomId);
|
|
215
|
+
const err = engine.getRoomError(room.roomId);
|
|
216
|
+
if (err) {
|
|
217
|
+
// Snapshot landing implies the server speaks the push protocol.
|
|
218
|
+
// Cancel the backwards-compat timer; we're staying on push.
|
|
219
|
+
if (room.pushSnapshotTimer) {
|
|
220
|
+
clearTimeout(room.pushSnapshotTimer);
|
|
221
|
+
room.pushSnapshotTimer = null;
|
|
222
|
+
}
|
|
223
|
+
room.error =
|
|
224
|
+
err.code === 'NOT_IN_ROOM'
|
|
225
|
+
? 'You are not a member of this room.'
|
|
226
|
+
: (err.message ?? err.code);
|
|
227
|
+
room.isConnected = false;
|
|
228
|
+
notify(room);
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
if (members !== null) {
|
|
232
|
+
if (room.pushSnapshotTimer) {
|
|
233
|
+
clearTimeout(room.pushSnapshotTimer);
|
|
234
|
+
room.pushSnapshotTimer = null;
|
|
235
|
+
}
|
|
236
|
+
room.peers = members
|
|
237
|
+
.filter((m) => m.user_id !== room.userId)
|
|
238
|
+
.map(memberToPeer);
|
|
239
|
+
room.isConnected = true;
|
|
240
|
+
room.error = null;
|
|
241
|
+
notify(room);
|
|
242
|
+
}
|
|
243
|
+
});
|
|
244
|
+
// Cached state inside the engine's registry may already be populated
|
|
245
|
+
// (we are a late subscriber to an existing engine-level sub). Pull
|
|
246
|
+
// it once so the hook re-renders immediately without waiting for the
|
|
247
|
+
// next push.
|
|
248
|
+
const cachedMembers = engine.getRoomMembers(room.roomId);
|
|
249
|
+
if (cachedMembers !== null) {
|
|
250
|
+
room.peers = cachedMembers
|
|
251
|
+
.filter((m) => m.user_id !== room.userId)
|
|
252
|
+
.map(memberToPeer);
|
|
253
|
+
room.isConnected = true;
|
|
254
|
+
notify(room);
|
|
255
|
+
} else if (!room.pushSnapshotTimer) {
|
|
256
|
+
// Arm the backwards-compat timer. If no snapshot lands in 2s, the
|
|
257
|
+
// server is presumed pre-v0.3.214 and we revert to polling. The
|
|
258
|
+
// timer is cancelled the moment a real snapshot OR a real error
|
|
259
|
+
// lands (both paths above clear it).
|
|
260
|
+
room.pushSnapshotTimer = setTimeout(() => {
|
|
261
|
+
room.pushSnapshotTimer = null;
|
|
262
|
+
if (!rooms.has(roomKeyFor(room))) return;
|
|
263
|
+
// No snapshot — tear down the WS sub and fall back to polling.
|
|
264
|
+
// The engine's room-unsubscribe ships harmlessly even on old
|
|
265
|
+
// servers (unknown frames are ignored).
|
|
266
|
+
if (room.wsUnsubscribe) {
|
|
267
|
+
room.wsUnsubscribe();
|
|
268
|
+
room.wsUnsubscribe = null;
|
|
269
|
+
}
|
|
270
|
+
startHeartbeat(room);
|
|
271
|
+
}, PUSH_SNAPSHOT_TIMEOUT_MS);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Detach the engine WS sub if attached. */
|
|
276
|
+
function stopWsPush(room: SharedRoom): void {
|
|
277
|
+
if (room.wsUnsubscribe) {
|
|
278
|
+
room.wsUnsubscribe();
|
|
279
|
+
room.wsUnsubscribe = null;
|
|
280
|
+
}
|
|
281
|
+
if (room.pushSnapshotTimer) {
|
|
282
|
+
clearTimeout(room.pushSnapshotTimer);
|
|
283
|
+
room.pushSnapshotTimer = null;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Pick the right delivery channel for the room based on the engine's
|
|
288
|
+
* current transport state. Called on initial acquire AND whenever the
|
|
289
|
+
* engine's connection status changes — a room that started on polling
|
|
290
|
+
* promotes to push the moment the WS opens, and vice versa. */
|
|
291
|
+
function evaluateDelivery(room: SharedRoom): void {
|
|
292
|
+
const engine = room.engine;
|
|
293
|
+
if (!engine) {
|
|
294
|
+
// No engine wired in — pure HTTP polling mode. Idempotent.
|
|
295
|
+
stopWsPush(room);
|
|
296
|
+
startHeartbeat(room);
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
if (engine.isWebSocketConnected()) {
|
|
300
|
+
startWsPush(room);
|
|
301
|
+
} else {
|
|
302
|
+
stopWsPush(room);
|
|
303
|
+
startHeartbeat(room);
|
|
304
|
+
}
|
|
138
305
|
}
|
|
139
306
|
|
|
140
307
|
function joinRoom(room: SharedRoom): void {
|
|
141
308
|
// Fire-and-forget — the join promise resolves into the shared state
|
|
142
309
|
// and notifies all subscribers. Late subscribers either pick up the
|
|
143
|
-
// already-resolved snapshot from `room.peers` or the next
|
|
310
|
+
// already-resolved snapshot from `room.peers` or the next push /
|
|
311
|
+
// heartbeat.
|
|
144
312
|
pylonFetch<{ snapshot?: { peers?: RoomPeer[] } }>(
|
|
145
313
|
transportFor(room),
|
|
146
314
|
'/api/rooms/join',
|
|
@@ -188,6 +356,7 @@ function acquireRoom(
|
|
|
188
356
|
token: string | undefined,
|
|
189
357
|
initialPresence: Record<string, any>,
|
|
190
358
|
heartbeatInterval: number,
|
|
359
|
+
engine: SyncEngine | null,
|
|
191
360
|
): SharedRoom {
|
|
192
361
|
const key = roomKey(baseUrl, roomId, userId, token);
|
|
193
362
|
let room = rooms.get(key);
|
|
@@ -217,10 +386,23 @@ function acquireRoom(
|
|
|
217
386
|
roomId,
|
|
218
387
|
userId,
|
|
219
388
|
heartbeatInterval,
|
|
389
|
+
engine,
|
|
390
|
+
wsUnsubscribe: null,
|
|
391
|
+
pushSnapshotTimer: null,
|
|
392
|
+
connectionStatusUnsubscribe: null,
|
|
220
393
|
};
|
|
221
394
|
rooms.set(key, room);
|
|
222
395
|
joinRoom(room);
|
|
223
|
-
|
|
396
|
+
// Watch connection-status so the room flips from polling to push (or
|
|
397
|
+
// back) as the WS opens / drops. Only useful when an engine is wired
|
|
398
|
+
// in; without one, the hook is HTTP-only and the status never matters.
|
|
399
|
+
if (engine) {
|
|
400
|
+
room.connectionStatusUnsubscribe = subscribeConnectionStatus(engine, () => {
|
|
401
|
+
const r = rooms.get(roomKeyFor(room!));
|
|
402
|
+
if (r) evaluateDelivery(r);
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
evaluateDelivery(room);
|
|
224
406
|
return room;
|
|
225
407
|
}
|
|
226
408
|
|
|
@@ -240,9 +422,11 @@ function releaseRoom(room: SharedRoom): void {
|
|
|
240
422
|
room.pendingTeardown = null;
|
|
241
423
|
return;
|
|
242
424
|
}
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
425
|
+
stopHeartbeat(room);
|
|
426
|
+
stopWsPush(room);
|
|
427
|
+
if (room.connectionStatusUnsubscribe) {
|
|
428
|
+
room.connectionStatusUnsubscribe();
|
|
429
|
+
room.connectionStatusUnsubscribe = null;
|
|
246
430
|
}
|
|
247
431
|
rooms.delete(roomKeyFor(room));
|
|
248
432
|
room.pendingTeardown = null;
|
|
@@ -250,6 +434,37 @@ function releaseRoom(room: SharedRoom): void {
|
|
|
250
434
|
}, 0);
|
|
251
435
|
}
|
|
252
436
|
|
|
437
|
+
/**
|
|
438
|
+
* Best-effort subscription to the engine's connection-status stream.
|
|
439
|
+
* The sync engine notifies via `store.notify()` on every status change;
|
|
440
|
+
* we listen on the LocalStore's subscribe surface and poll the status
|
|
441
|
+
* inside the callback. Returns an unsubscribe.
|
|
442
|
+
*
|
|
443
|
+
* This module deliberately doesn't import `useSyncStatus` (a React
|
|
444
|
+
* hook) — the registry runs outside React, and the only thing we need
|
|
445
|
+
* is a notify-on-change pulse. The store's subscribe method gives us
|
|
446
|
+
* exactly that without pulling in another dep.
|
|
447
|
+
*/
|
|
448
|
+
function subscribeConnectionStatus(
|
|
449
|
+
engine: SyncEngine,
|
|
450
|
+
cb: () => void,
|
|
451
|
+
): () => void {
|
|
452
|
+
// The engine's LocalStore notify() fires on every status flip
|
|
453
|
+
// (setConnectionStatus calls store.notify). Subscribing to that is
|
|
454
|
+
// a cheap way to observe transport transitions.
|
|
455
|
+
const store = (engine as unknown as { store?: { subscribe: (fn: () => void) => () => void } }).store;
|
|
456
|
+
if (!store?.subscribe) {
|
|
457
|
+
return () => {};
|
|
458
|
+
}
|
|
459
|
+
let lastStatus = engine.connectionStatus();
|
|
460
|
+
return store.subscribe(() => {
|
|
461
|
+
const nextStatus = engine.connectionStatus();
|
|
462
|
+
if (nextStatus === lastStatus) return;
|
|
463
|
+
lastStatus = nextStatus;
|
|
464
|
+
cb();
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
|
|
253
468
|
// Exported for tests — lets the regression test reset registry state
|
|
254
469
|
// between cases so leaked rooms from one test can't bleed into the next,
|
|
255
470
|
// and drive the refcount through the same code path the hook uses.
|
|
@@ -257,15 +472,46 @@ export const __roomRegistryInternals = {
|
|
|
257
472
|
reset(): void {
|
|
258
473
|
for (const room of rooms.values()) {
|
|
259
474
|
if (room.pendingTeardown) clearTimeout(room.pendingTeardown);
|
|
260
|
-
|
|
475
|
+
stopHeartbeat(room);
|
|
476
|
+
stopWsPush(room);
|
|
477
|
+
if (room.connectionStatusUnsubscribe) {
|
|
478
|
+
room.connectionStatusUnsubscribe();
|
|
479
|
+
room.connectionStatusUnsubscribe = null;
|
|
480
|
+
}
|
|
261
481
|
}
|
|
262
482
|
rooms.clear();
|
|
263
483
|
},
|
|
264
|
-
acquire
|
|
484
|
+
acquire(
|
|
485
|
+
baseUrl: string,
|
|
486
|
+
roomId: string,
|
|
487
|
+
userId: string,
|
|
488
|
+
token: string | undefined,
|
|
489
|
+
initialPresence: Record<string, any>,
|
|
490
|
+
heartbeatInterval: number,
|
|
491
|
+
engine: SyncEngine | null = null,
|
|
492
|
+
): SharedRoom {
|
|
493
|
+
return acquireRoom(
|
|
494
|
+
baseUrl,
|
|
495
|
+
roomId,
|
|
496
|
+
userId,
|
|
497
|
+
token,
|
|
498
|
+
initialPresence,
|
|
499
|
+
heartbeatInterval,
|
|
500
|
+
engine,
|
|
501
|
+
);
|
|
502
|
+
},
|
|
265
503
|
release: releaseRoom,
|
|
266
504
|
size(): number {
|
|
267
505
|
return rooms.size;
|
|
268
506
|
},
|
|
507
|
+
/** Diagnostic: is this room currently on the WS push path? */
|
|
508
|
+
isWsAttached(room: SharedRoom): boolean {
|
|
509
|
+
return room.wsUnsubscribe !== null;
|
|
510
|
+
},
|
|
511
|
+
/** Diagnostic: is the polling timer running for this room? */
|
|
512
|
+
isPolling(room: SharedRoom): boolean {
|
|
513
|
+
return room.interval !== null;
|
|
514
|
+
},
|
|
269
515
|
};
|
|
270
516
|
|
|
271
517
|
// ---------------------------------------------------------------------------
|
|
@@ -274,12 +520,16 @@ export const __roomRegistryInternals = {
|
|
|
274
520
|
|
|
275
521
|
/**
|
|
276
522
|
* Subscribe to a real-time room. Joins on mount, leaves on unmount, and
|
|
277
|
-
*
|
|
523
|
+
* receives peer updates over the sync engine's WebSocket push channel
|
|
524
|
+
* (server v0.3.214+). When the WS isn't available (SSE transport, polling
|
|
525
|
+
* transport, OR WS not yet connected) the hook automatically falls back
|
|
526
|
+
* to a 5s `GET /api/rooms/<room>` polling loop — same behaviour as
|
|
527
|
+
* v0.3.213 and earlier.
|
|
278
528
|
*
|
|
279
529
|
* Multiple components calling `useRoom` with the same `(roomId, userId,
|
|
280
|
-
* baseUrl, token)` tuple share a single underlying join +
|
|
530
|
+
* baseUrl, token)` tuple share a single underlying join + subscription —
|
|
281
531
|
* mounting it from 5 components costs one POST /api/rooms/join, one
|
|
282
|
-
*
|
|
532
|
+
* room-subscribe on the wire, and one POST /api/rooms/leave.
|
|
283
533
|
*
|
|
284
534
|
* ```tsx
|
|
285
535
|
* const { peers, isConnected, setPresence, broadcast, leave, error } = useRoom(
|
|
@@ -300,6 +550,17 @@ function readStoredToken(): string | undefined {
|
|
|
300
550
|
return getReactStorage().get(storageKey('token')) ?? undefined;
|
|
301
551
|
}
|
|
302
552
|
|
|
553
|
+
/** Best-effort engine accessor. SSR / test-harness use-cases that
|
|
554
|
+
* never invoked `init()` end up here with a null engine and the
|
|
555
|
+
* hook silently runs in HTTP-only polling mode. */
|
|
556
|
+
function tryGetSync(): SyncEngine | null {
|
|
557
|
+
try {
|
|
558
|
+
return getSync();
|
|
559
|
+
} catch {
|
|
560
|
+
return null;
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
303
564
|
export function useRoom(
|
|
304
565
|
roomId: string,
|
|
305
566
|
userId: string,
|
|
@@ -328,6 +589,7 @@ export function useRoom(
|
|
|
328
589
|
const roomRef = useRef<SharedRoom | null>(null);
|
|
329
590
|
|
|
330
591
|
useEffect(() => {
|
|
592
|
+
const engine = tryGetSync();
|
|
331
593
|
const room = acquireRoom(
|
|
332
594
|
baseUrl,
|
|
333
595
|
roomId,
|
|
@@ -335,6 +597,7 @@ export function useRoom(
|
|
|
335
597
|
token,
|
|
336
598
|
initialPresence,
|
|
337
599
|
heartbeatInterval,
|
|
600
|
+
engine,
|
|
338
601
|
);
|
|
339
602
|
roomRef.current = room;
|
|
340
603
|
|