@pylonsync/react 0.22.39 → 0.22.41
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/dist/index.d.ts +2 -1
- package/dist/useRoom.d.ts +19 -1
- package/package.json +4 -4
- package/src/index.ts +2 -0
- package/src/useRoom.test.ts +50 -0
- package/src/useRoom.ts +57 -5
package/dist/index.d.ts
CHANGED
|
@@ -20,7 +20,8 @@ export type { QueryOptions, QueryFilter, IncludeSpec, UseQueryReturn, UseQueryOn
|
|
|
20
20
|
export { useAgentRun } from "./useAgentRun";
|
|
21
21
|
export type { AgentRunRow, AgentMessageRow, UseAgentRunReturn, } from "./useAgentRun";
|
|
22
22
|
export { useRoom, useRoomMessages } from "./useRoom";
|
|
23
|
-
export
|
|
23
|
+
export { rowRoom } from "@pylonsync/sync";
|
|
24
|
+
export type { RoomPeer, RoomSnapshot, UseRoomOptions, UseRoomReturn, RowRoomRef, } from "./useRoom";
|
|
24
25
|
export type { RoomMessage } from "@pylonsync/sync";
|
|
25
26
|
export { MutationRejectedError } from "@pylonsync/sync";
|
|
26
27
|
export { useShard, connectShard } from "./useShard";
|
package/dist/useRoom.d.ts
CHANGED
|
@@ -36,6 +36,17 @@ export interface UseRoomReturn {
|
|
|
36
36
|
leave: () => void;
|
|
37
37
|
/** Error message, if any. */
|
|
38
38
|
error: string | null;
|
|
39
|
+
/**
|
|
40
|
+
* True when the server refused this room or revoked access to it: a room
|
|
41
|
+
* tied to a row (`useRoom({ entity, id })`) the user may not read, or no
|
|
42
|
+
* longer may. The hook stops rejoining; peers are empty.
|
|
43
|
+
*/
|
|
44
|
+
denied: boolean;
|
|
45
|
+
}
|
|
46
|
+
/** A room tied to a row (see {@link rowRoom}). */
|
|
47
|
+
export interface RowRoomRef {
|
|
48
|
+
entity: string;
|
|
49
|
+
id: string;
|
|
39
50
|
}
|
|
40
51
|
interface SharedRoom {
|
|
41
52
|
/** Number of live React hooks holding this room. */
|
|
@@ -87,6 +98,8 @@ interface SharedRoom {
|
|
|
87
98
|
rejoinAttempts: number;
|
|
88
99
|
/** The caller left with `leave()`: no keepalive, no rejoin. */
|
|
89
100
|
left: boolean;
|
|
101
|
+
/** The server refused the room or revoked access: no keepalive, no rejoin. */
|
|
102
|
+
denied: boolean;
|
|
90
103
|
}
|
|
91
104
|
declare function releaseRoom(room: SharedRoom): void;
|
|
92
105
|
export declare const __roomRegistryInternals: {
|
|
@@ -107,7 +120,12 @@ export declare const __roomRegistryInternals: {
|
|
|
107
120
|
/** Diagnostic: is the polling timer running for this room? */
|
|
108
121
|
isPolling(room: SharedRoom): boolean;
|
|
109
122
|
};
|
|
110
|
-
|
|
123
|
+
/**
|
|
124
|
+
* Join a presence room. Pass `{ entity, id }` for the room tied to a row:
|
|
125
|
+
* only people who may read the row get in, and someone who loses access is
|
|
126
|
+
* removed (`denied` turns true).
|
|
127
|
+
*/
|
|
128
|
+
export declare function useRoom(room: string | RowRoomRef, userId: string, options?: UseRoomOptions): UseRoomReturn;
|
|
111
129
|
/**
|
|
112
130
|
* Subscribe to broadcasts on a room — both from other members
|
|
113
131
|
* (`useRoom(...).broadcast`) and from the server
|
package/package.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"publishConfig": {
|
|
4
4
|
"access": "public"
|
|
5
5
|
},
|
|
6
|
-
"version": "0.22.
|
|
6
|
+
"version": "0.22.41",
|
|
7
7
|
"type": "module",
|
|
8
8
|
"main": "./src/index.ts",
|
|
9
9
|
"types": "./dist/index.d.ts",
|
|
@@ -14,9 +14,9 @@
|
|
|
14
14
|
"prepack": "bun run build"
|
|
15
15
|
},
|
|
16
16
|
"dependencies": {
|
|
17
|
-
"@pylonsync/realtime": "0.22.
|
|
18
|
-
"@pylonsync/sdk": "0.22.
|
|
19
|
-
"@pylonsync/sync": "0.22.
|
|
17
|
+
"@pylonsync/realtime": "0.22.41",
|
|
18
|
+
"@pylonsync/sdk": "0.22.41",
|
|
19
|
+
"@pylonsync/sync": "0.22.41"
|
|
20
20
|
},
|
|
21
21
|
"peerDependencies": {
|
|
22
22
|
"react": ">=19.0.0"
|
package/src/index.ts
CHANGED
|
@@ -129,11 +129,13 @@ export type {
|
|
|
129
129
|
|
|
130
130
|
// Room hook
|
|
131
131
|
export { useRoom, useRoomMessages } from "./useRoom";
|
|
132
|
+
export { rowRoom } from "@pylonsync/sync";
|
|
132
133
|
export type {
|
|
133
134
|
RoomPeer,
|
|
134
135
|
RoomSnapshot,
|
|
135
136
|
UseRoomOptions,
|
|
136
137
|
UseRoomReturn,
|
|
138
|
+
RowRoomRef,
|
|
137
139
|
} from "./useRoom";
|
|
138
140
|
export type { RoomMessage } from "@pylonsync/sync";
|
|
139
141
|
// Thrown by db.insert / update / delete (and useEntity's) when the server
|
package/src/useRoom.test.ts
CHANGED
|
@@ -214,3 +214,53 @@ describe("useRoom shared registry", () => {
|
|
|
214
214
|
expect(count(isLeave)).toBe(1);
|
|
215
215
|
});
|
|
216
216
|
});
|
|
217
|
+
|
|
218
|
+
// Rooms tied to a row: the server refuses callers who may not read the row
|
|
219
|
+
// (403 ROOM_FORBIDDEN on join) and removes members who lose access (a
|
|
220
|
+
// heartbeat answers alive:false, reason ROOM_FORBIDDEN). Retrying cannot
|
|
221
|
+
// help, so the room stops instead of rejoining.
|
|
222
|
+
describe("useRoom access denied", () => {
|
|
223
|
+
function stubFetch(answer: (url: string) => { status: number; body: unknown }): void {
|
|
224
|
+
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
225
|
+
const url = typeof input === "string" ? input : input.toString();
|
|
226
|
+
recorded.push({ url, method: (init?.method ?? "GET").toUpperCase(), body: null });
|
|
227
|
+
const { status, body } = answer(url);
|
|
228
|
+
return new Response(JSON.stringify(body), {
|
|
229
|
+
status,
|
|
230
|
+
headers: { "content-type": "application/json" },
|
|
231
|
+
});
|
|
232
|
+
}) as typeof fetch;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
test("a refused join does not retry", async () => {
|
|
236
|
+
__roomRegistryInternals.setTimings({ rejoinBaseMs: 5, keepaliveMs: 10 });
|
|
237
|
+
stubFetch(() => ({
|
|
238
|
+
status: 403,
|
|
239
|
+
body: { error: { code: "ROOM_FORBIDDEN", message: "You cannot use this room" } },
|
|
240
|
+
}));
|
|
241
|
+
const room = __roomRegistryInternals.acquire(BASE, "@Document/d1", USER, TOKEN, {}, 1000);
|
|
242
|
+
await new Promise((res) => setTimeout(res, 60));
|
|
243
|
+
expect(room.denied).toBe(true);
|
|
244
|
+
expect(room.error).toBe("You cannot use this room.");
|
|
245
|
+
expect(count(isJoin)).toBe(1);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test("a heartbeat that reports lost access stops the room", async () => {
|
|
249
|
+
__roomRegistryInternals.setTimings({ rejoinBaseMs: 5, keepaliveMs: 10 });
|
|
250
|
+
stubFetch((url) =>
|
|
251
|
+
url.endsWith("/api/rooms/heartbeat")
|
|
252
|
+
? { status: 200, body: { alive: false, reason: "ROOM_FORBIDDEN" } }
|
|
253
|
+
: { status: 200, body: { snapshot: { peers: [{ user_id: "u2", data: {} }] } } },
|
|
254
|
+
);
|
|
255
|
+
const room = __roomRegistryInternals.acquire(BASE, "@Document/d1", USER, TOKEN, {}, 1000);
|
|
256
|
+
await new Promise((res) => setTimeout(res, 60));
|
|
257
|
+
expect(room.denied).toBe(true);
|
|
258
|
+
expect(room.peers).toEqual([]);
|
|
259
|
+
expect(room.error).toBe("You no longer have access to this room.");
|
|
260
|
+
const joins = count(isJoin);
|
|
261
|
+
const beats = count((r) => r.url.endsWith("/api/rooms/heartbeat"));
|
|
262
|
+
await new Promise((res) => setTimeout(res, 50));
|
|
263
|
+
expect(count(isJoin)).toBe(joins);
|
|
264
|
+
expect(count((r) => r.url.endsWith("/api/rooms/heartbeat"))).toBe(beats);
|
|
265
|
+
});
|
|
266
|
+
});
|
package/src/useRoom.ts
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
type RoomMember,
|
|
7
7
|
type RoomMessage,
|
|
8
8
|
type SyncEngine,
|
|
9
|
+
rowRoom,
|
|
9
10
|
} from '@pylonsync/sync';
|
|
10
11
|
import { getBaseUrl, getReactStorage, storageKey } from './index';
|
|
11
12
|
import { getSync } from './db';
|
|
@@ -65,6 +66,18 @@ export interface UseRoomReturn {
|
|
|
65
66
|
leave: () => void;
|
|
66
67
|
/** Error message, if any. */
|
|
67
68
|
error: string | null;
|
|
69
|
+
/**
|
|
70
|
+
* True when the server refused this room or revoked access to it: a room
|
|
71
|
+
* tied to a row (`useRoom({ entity, id })`) the user may not read, or no
|
|
72
|
+
* longer may. The hook stops rejoining; peers are empty.
|
|
73
|
+
*/
|
|
74
|
+
denied: boolean;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** A room tied to a row (see {@link rowRoom}). */
|
|
78
|
+
export interface RowRoomRef {
|
|
79
|
+
entity: string;
|
|
80
|
+
id: string;
|
|
68
81
|
}
|
|
69
82
|
|
|
70
83
|
// ---------------------------------------------------------------------------
|
|
@@ -175,6 +188,8 @@ interface SharedRoom {
|
|
|
175
188
|
rejoinAttempts: number;
|
|
176
189
|
/** The caller left with `leave()`: no keepalive, no rejoin. */
|
|
177
190
|
left: boolean;
|
|
191
|
+
/** The server refused the room or revoked access: no keepalive, no rejoin. */
|
|
192
|
+
denied: boolean;
|
|
178
193
|
}
|
|
179
194
|
|
|
180
195
|
const rooms = new Map<string, SharedRoom>();
|
|
@@ -188,6 +203,20 @@ function roomKey(
|
|
|
188
203
|
return `${baseUrl}|${roomId}|${userId}|${token ?? ''}`;
|
|
189
204
|
}
|
|
190
205
|
|
|
206
|
+
/** Error codes that end the room for this user: retrying cannot help. */
|
|
207
|
+
const DENIED_CODES = new Set(["ROOM_FORBIDDEN", "ROOM_REVOKED", "INVALID_ROOM"]);
|
|
208
|
+
|
|
209
|
+
/** The server refused the room, or revoked access: stop, clear, and say so. */
|
|
210
|
+
function deny(room: SharedRoom, code: string): void {
|
|
211
|
+
room.denied = true;
|
|
212
|
+
stopKeepalive(room);
|
|
213
|
+
room.peers = [];
|
|
214
|
+
room.isConnected = false;
|
|
215
|
+
room.error =
|
|
216
|
+
code === "ROOM_REVOKED" ? "You no longer have access to this room." : "You cannot use this room.";
|
|
217
|
+
notify(room);
|
|
218
|
+
}
|
|
219
|
+
|
|
191
220
|
function notify(room: SharedRoom): void {
|
|
192
221
|
for (const cb of room.subs) cb();
|
|
193
222
|
}
|
|
@@ -256,9 +285,14 @@ function startWsPush(room: SharedRoom): void {
|
|
|
256
285
|
// (reconnect), the idle sweep dropped us, or the subscribe beat the
|
|
257
286
|
// join. Rejoin over HTTP, then resubscribe. Surface the error only
|
|
258
287
|
// when rejoining keeps failing.
|
|
288
|
+
if (DENIED_CODES.has(err.code)) {
|
|
289
|
+
deny(room, err.code);
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
259
292
|
if (
|
|
260
293
|
err.code === 'NOT_IN_ROOM' &&
|
|
261
294
|
!room.left &&
|
|
295
|
+
!room.denied &&
|
|
262
296
|
room.rejoinAttempts < MAX_REJOIN_ATTEMPTS
|
|
263
297
|
) {
|
|
264
298
|
if (room.isConnected) {
|
|
@@ -365,7 +399,7 @@ function joinRoom(room: SharedRoom): void {
|
|
|
365
399
|
// and notifies all subscribers. Late subscribers either pick up the
|
|
366
400
|
// already-resolved snapshot from `room.peers` or the next push /
|
|
367
401
|
// heartbeat.
|
|
368
|
-
if (room.joining || room.left) return;
|
|
402
|
+
if (room.joining || room.left || room.denied) return;
|
|
369
403
|
room.joining = true;
|
|
370
404
|
pylonFetch<{ snapshot?: { peers?: RoomPeer[] } }>(
|
|
371
405
|
transportFor(room),
|
|
@@ -400,6 +434,10 @@ function joinRoom(room: SharedRoom): void {
|
|
|
400
434
|
.catch((e: any) => {
|
|
401
435
|
room.joining = false;
|
|
402
436
|
if (!rooms.has(roomKeyFor(room))) return;
|
|
437
|
+
if (typeof e?.code === 'string' && DENIED_CODES.has(e.code)) {
|
|
438
|
+
deny(room, e.code);
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
403
441
|
if (room.rejoinAttempts < MAX_REJOIN_ATTEMPTS && !room.left) {
|
|
404
442
|
scheduleRejoin(room);
|
|
405
443
|
return;
|
|
@@ -412,7 +450,7 @@ function joinRoom(room: SharedRoom): void {
|
|
|
412
450
|
/** Rejoin with backoff: at once for the first attempt, then 1 s, 2 s, 4 s,
|
|
413
451
|
* ... capped at 30 s. One pending rejoin at a time. */
|
|
414
452
|
function scheduleRejoin(room: SharedRoom): void {
|
|
415
|
-
if (room.rejoinTimer || room.joining || room.left) return;
|
|
453
|
+
if (room.rejoinTimer || room.joining || room.left || room.denied) return;
|
|
416
454
|
const attempt = room.rejoinAttempts;
|
|
417
455
|
room.rejoinAttempts += 1;
|
|
418
456
|
const delay =
|
|
@@ -431,11 +469,15 @@ function startKeepalive(room: SharedRoom): void {
|
|
|
431
469
|
if (room.keepalive || room.left) return;
|
|
432
470
|
room.keepalive = setInterval(() => {
|
|
433
471
|
if (!rooms.has(roomKeyFor(room)) || room.left) return;
|
|
434
|
-
pylonFetch<{ alive?: boolean }>(transportFor(room), '/api/rooms/heartbeat', {
|
|
472
|
+
pylonFetch<{ alive?: boolean; reason?: string }>(transportFor(room), '/api/rooms/heartbeat', {
|
|
435
473
|
method: 'POST',
|
|
436
474
|
json: { room: room.roomId, user_id: room.userId },
|
|
437
475
|
})
|
|
438
476
|
.then((body) => {
|
|
477
|
+
if (body?.alive === false && body.reason && DENIED_CODES.has(body.reason)) {
|
|
478
|
+
if (rooms.has(roomKeyFor(room))) deny(room, "ROOM_REVOKED");
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
439
481
|
if (body?.alive === false && rooms.has(roomKeyFor(room))) {
|
|
440
482
|
if (room.isConnected) {
|
|
441
483
|
room.isConnected = false;
|
|
@@ -520,6 +562,7 @@ function acquireRoom(
|
|
|
520
562
|
rejoinTimer: null,
|
|
521
563
|
rejoinAttempts: 0,
|
|
522
564
|
left: false,
|
|
565
|
+
denied: false,
|
|
523
566
|
};
|
|
524
567
|
rooms.set(key, room);
|
|
525
568
|
joinRoom(room);
|
|
@@ -706,11 +749,17 @@ function tryGetSync(): SyncEngine | null {
|
|
|
706
749
|
}
|
|
707
750
|
}
|
|
708
751
|
|
|
752
|
+
/**
|
|
753
|
+
* Join a presence room. Pass `{ entity, id }` for the room tied to a row:
|
|
754
|
+
* only people who may read the row get in, and someone who loses access is
|
|
755
|
+
* removed (`denied` turns true).
|
|
756
|
+
*/
|
|
709
757
|
export function useRoom(
|
|
710
|
-
|
|
758
|
+
room: string | RowRoomRef,
|
|
711
759
|
userId: string,
|
|
712
760
|
options: UseRoomOptions = {},
|
|
713
761
|
): UseRoomReturn {
|
|
762
|
+
const roomId = typeof room === 'string' ? room : rowRoom(room.entity, room.id);
|
|
714
763
|
const {
|
|
715
764
|
// Fall back to the globally configured baseUrl so room requests don't
|
|
716
765
|
// land on the Vite dev origin (localhost:5173) and 404 when the caller
|
|
@@ -727,6 +776,7 @@ export function useRoom(
|
|
|
727
776
|
const [peers, setPeers] = useState<RoomPeer[]>([]);
|
|
728
777
|
const [isConnected, setIsConnected] = useState(false);
|
|
729
778
|
const [error, setError] = useState<string | null>(null);
|
|
779
|
+
const [denied, setDenied] = useState(false);
|
|
730
780
|
|
|
731
781
|
// Remember the most recent shared room this hook is attached to so
|
|
732
782
|
// setPresence / broadcast / leave can route through it without
|
|
@@ -751,11 +801,13 @@ export function useRoom(
|
|
|
751
801
|
setPeers(room.peers);
|
|
752
802
|
setIsConnected(room.isConnected);
|
|
753
803
|
setError(room.error);
|
|
804
|
+
setDenied(room.denied);
|
|
754
805
|
|
|
755
806
|
const sub = () => {
|
|
756
807
|
setPeers(room.peers);
|
|
757
808
|
setIsConnected(room.isConnected);
|
|
758
809
|
setError(room.error);
|
|
810
|
+
setDenied(room.denied);
|
|
759
811
|
};
|
|
760
812
|
room.subs.add(sub);
|
|
761
813
|
|
|
@@ -828,7 +880,7 @@ export function useRoom(
|
|
|
828
880
|
setPeers([]);
|
|
829
881
|
}, [roomId, userId, baseUrl, token]);
|
|
830
882
|
|
|
831
|
-
return { peers, isConnected, setPresence, broadcast, leave, error };
|
|
883
|
+
return { peers, isConnected, setPresence, broadcast, leave, error, denied };
|
|
832
884
|
}
|
|
833
885
|
|
|
834
886
|
/**
|