@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 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 type { RoomPeer, RoomSnapshot, UseRoomOptions, UseRoomReturn, } from "./useRoom";
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
- export declare function useRoom(roomId: string, userId: string, options?: UseRoomOptions): UseRoomReturn;
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.39",
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.39",
18
- "@pylonsync/sdk": "0.22.39",
19
- "@pylonsync/sync": "0.22.39"
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
@@ -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
- roomId: string,
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
  /**