@pylonsync/sync 0.3.259 → 0.3.261

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 CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "0.3.259",
6
+ "version": "0.3.261",
7
7
  "type": "module",
8
8
  "main": "src/index.ts",
9
9
  "types": "src/index.ts",
package/src/index.ts CHANGED
@@ -38,6 +38,8 @@ export {
38
38
  type RoomError,
39
39
  type RoomErrorCode,
40
40
  type RoomMember,
41
+ type RoomMessage,
42
+ type RoomMessageSubscriber,
41
43
  type RoomSubscriber,
42
44
  } from "./room-subscriptions";
43
45
  export { IndexedDBPersistence, persistChange } from "./persistence";
@@ -2738,6 +2740,39 @@ export class SyncEngine {
2738
2740
  };
2739
2741
  }
2740
2742
 
2743
+ /**
2744
+ * Subscribe to BROADCAST MESSAGES relayed through a room (the
2745
+ * payloads sent via `POST /api/rooms/broadcast` / `useRoom`'s
2746
+ * `broadcast()`). Same refcounted wire-subscription and leader /
2747
+ * follower routing as `subscribeRoom` — the two channels share one
2748
+ * `room-subscribe` per room per origin.
2749
+ *
2750
+ * The callback receives `{ topic, payload, from }` where `from` is
2751
+ * the server-stamped sender user id (own broadcasts echo back —
2752
+ * filter on `from` if unwanted). Returns an unsubscribe function.
2753
+ */
2754
+ subscribeRoomMessages(
2755
+ roomId: string,
2756
+ callback: (message: import("./room-subscriptions").RoomMessage) => void,
2757
+ ): () => void {
2758
+ if (this.isMultiTabLeader) {
2759
+ return this.rooms.registerMessages(roomId, callback);
2760
+ }
2761
+ // Follower path mirrors subscribeRoom: register locally for
2762
+ // routing, ask the leader to hold the wire sub on first add.
2763
+ const hadRoomBefore = this.rooms.has(roomId);
2764
+ const unsubscribe = this.rooms.registerMessages(roomId, callback);
2765
+ if (!hadRoomBefore) {
2766
+ this.broadcastToTabs({ type: "room-sub-register", room: roomId });
2767
+ }
2768
+ return () => {
2769
+ unsubscribe();
2770
+ if (!this.rooms.has(roomId)) {
2771
+ this.broadcastToTabs({ type: "room-sub-unregister", room: roomId });
2772
+ }
2773
+ };
2774
+ }
2775
+
2741
2776
  /** Force-unsubscribe every local subscriber of a room and ship a
2742
2777
  * `room-unsubscribe`. Used by the `useRoom` hook's manual `leave()`
2743
2778
  * action so a deliberate exit propagates to the server immediately. */
@@ -34,6 +34,66 @@ function makeHarness() {
34
34
  };
35
35
  }
36
36
 
37
+ describe("RoomSubscriptions: broadcast message channel", () => {
38
+ test("registerMessages receives relayed broadcast payloads with sender", () => {
39
+ const h = makeHarness();
40
+ const received: unknown[] = [];
41
+ h.rooms.registerMessages("battle", (m) => received.push(m));
42
+ // First message-subscriber ships the wire subscribe — broadcasts
43
+ // arrive on the same room-subscribe as membership.
44
+ expect(h.sent).toEqual([{ type: "room-subscribe", room: "battle" }]);
45
+
46
+ h.rooms.applyUpdate(
47
+ "battle",
48
+ "broadcast",
49
+ { user_id: "u_42", joined_at: "", data: {} },
50
+ { topic: "fire", payload: { k: "s" } },
51
+ );
52
+ expect(received).toEqual([{ topic: "fire", payload: { k: "s" }, from: "u_42" }]);
53
+ });
54
+
55
+ test("broadcasts do NOT pulse membership subscribers (fire-rate traffic)", () => {
56
+ const h = makeHarness();
57
+ let membershipPulses = 0;
58
+ h.rooms.register("battle", () => membershipPulses++);
59
+ h.rooms.applySnapshot("battle", []);
60
+ const after = membershipPulses;
61
+ h.rooms.applyUpdate("battle", "broadcast", { user_id: "u", joined_at: "", data: {} }, {
62
+ topic: "fire",
63
+ payload: 1,
64
+ });
65
+ expect(membershipPulses).toBe(after);
66
+ });
67
+
68
+ test("message-only subscriber refcounts the wire sub like membership", () => {
69
+ const h = makeHarness();
70
+ const off = h.rooms.registerMessages("battle", () => {});
71
+ expect(h.sent).toEqual([{ type: "room-subscribe", room: "battle" }]);
72
+ off();
73
+ expect(h.sent).toEqual([
74
+ { type: "room-subscribe", room: "battle" },
75
+ { type: "room-unsubscribe", room: "battle" },
76
+ ]);
77
+ // Double-unsubscribe is harmless.
78
+ off();
79
+ expect(h.sent.length).toBe(2);
80
+ });
81
+
82
+ test("mixed membership + message subscribers share one wire sub", () => {
83
+ const h = makeHarness();
84
+ const offMembers = h.rooms.register("battle", () => {});
85
+ const offMessages = h.rooms.registerMessages("battle", () => {});
86
+ expect(h.sent.length).toBe(1); // one subscribe for both
87
+ offMembers();
88
+ expect(h.sent.length).toBe(1); // message sub still holds the room
89
+ offMessages();
90
+ expect(h.sent).toEqual([
91
+ { type: "room-subscribe", room: "battle" },
92
+ { type: "room-unsubscribe", room: "battle" },
93
+ ]);
94
+ });
95
+ });
96
+
37
97
  describe("RoomSubscriptions: refcount + wire frames", () => {
38
98
  test("first register ships room-subscribe; second is a no-op", () => {
39
99
  const h = makeHarness();
@@ -66,6 +66,16 @@ export interface RoomError {
66
66
  * React hooks can re-render. */
67
67
  export type RoomSubscriber = () => void;
68
68
 
69
+ /** A broadcast message relayed through a room. `from` is the sender's
70
+ * user id (the server stamps it — clients can't spoof each other). */
71
+ export interface RoomMessage {
72
+ topic: string;
73
+ payload: unknown;
74
+ from: string;
75
+ }
76
+
77
+ export type RoomMessageSubscriber = (message: RoomMessage) => void;
78
+
69
79
  interface RoomEntry {
70
80
  roomId: string;
71
81
  /** Current members snapshot (post-snapshot/update). `null` until the
@@ -77,10 +87,14 @@ interface RoomEntry {
77
87
  error: RoomError | null;
78
88
  /** Number of `register()` calls that haven't been balanced by
79
89
  * `unregister()`. The first add ships `room-subscribe`; the last
80
- * remove ships `room-unsubscribe`. */
90
+ * remove ships `room-unsubscribe`. Message subscribers count too —
91
+ * a tab that only listens for broadcasts still needs the wire sub. */
81
92
  refs: number;
82
93
  /** Subscriber callbacks — one per mounted React hook. */
83
94
  subs: Set<RoomSubscriber>;
95
+ /** Broadcast-message callbacks. Unlike `subs` (membership pulses),
96
+ * these receive the actual relayed payloads. */
97
+ messageSubs: Set<RoomMessageSubscriber>;
84
98
  }
85
99
 
86
100
  export class RoomSubscriptions {
@@ -107,18 +121,7 @@ export class RoomSubscriptions {
107
121
  * Idempotent w.r.t. wire frames: a re-subscribe with no intervening
108
122
  * full unsubscribe doesn't re-send `room-subscribe` to the server. */
109
123
  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
- }
124
+ const { entry, isFirst } = this.ensureEntry(roomId);
122
125
  entry.refs += 1;
123
126
  entry.subs.add(subscriber);
124
127
 
@@ -142,13 +145,57 @@ export class RoomSubscriptions {
142
145
  return () => this.unregisterOne(roomId, subscriber);
143
146
  }
144
147
 
148
+ /** Get-or-create the entry for a room. `isFirst` means this call
149
+ * created it, i.e. the caller must ship the wire `room-subscribe`. */
150
+ private ensureEntry(roomId: string): { entry: RoomEntry; isFirst: boolean } {
151
+ let entry = this.rooms.get(roomId);
152
+ const isFirst = !entry;
153
+ if (!entry) {
154
+ entry = {
155
+ roomId,
156
+ members: null,
157
+ error: null,
158
+ refs: 0,
159
+ subs: new Set(),
160
+ messageSubs: new Set(),
161
+ };
162
+ this.rooms.set(roomId, entry);
163
+ }
164
+ return { entry, isFirst };
165
+ }
166
+
167
+ /**
168
+ * Register a BROADCAST-MESSAGE listener for `roomId`. Counts toward
169
+ * the same refcount as membership subscribers (a tab that only
170
+ * listens for broadcasts still needs the wire `room-subscribe`).
171
+ * The callback receives every `action: "broadcast"` relay for the
172
+ * room — including the caller's own broadcasts echoed back; filter
173
+ * on `message.from` if self-echo is unwanted.
174
+ */
175
+ registerMessages(roomId: string, subscriber: RoomMessageSubscriber): () => void {
176
+ const { entry, isFirst } = this.ensureEntry(roomId);
177
+ entry.refs += 1;
178
+ entry.messageSubs.add(subscriber);
179
+ if (isFirst) {
180
+ this.sendWs({ type: "room-subscribe", room: roomId });
181
+ }
182
+ return () => {
183
+ const e = this.rooms.get(roomId);
184
+ if (!e || !e.messageSubs.delete(subscriber)) return;
185
+ e.refs -= 1;
186
+ if (e.refs > 0) return;
187
+ this.rooms.delete(roomId);
188
+ this.sendWs({ type: "room-unsubscribe", room: roomId });
189
+ };
190
+ }
191
+
145
192
  /** Decrement the refcount for one subscriber. Internal — the
146
193
  * `register()` returned unsubscribe routes here. Last out ships
147
194
  * `room-unsubscribe`. */
148
195
  private unregisterOne(roomId: string, subscriber: RoomSubscriber): void {
149
196
  const entry = this.rooms.get(roomId);
150
197
  if (!entry) return;
151
- entry.subs.delete(subscriber);
198
+ if (!entry.subs.delete(subscriber)) return;
152
199
  entry.refs -= 1;
153
200
  if (entry.refs > 0) return;
154
201
  this.rooms.delete(roomId);
@@ -178,13 +225,12 @@ export class RoomSubscriptions {
178
225
  }
179
226
 
180
227
  /** 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. */
228
+ * RoomEvent variants — join / leave / presence / broadcast.
229
+ * Membership actions mutate the cached `members` snapshot in-place
230
+ * and pulse the membership subscribers; `broadcast` actions route
231
+ * the relayed payload to the message subscribers instead (and do
232
+ * NOT pulse membership fire-rate broadcasts would otherwise
233
+ * re-render every useRoom consumer per message). */
188
234
  applyUpdate(
189
235
  roomId: string,
190
236
  action: "join" | "leave" | "presence" | "broadcast",
@@ -224,10 +270,24 @@ export class RoomSubscriptions {
224
270
  break;
225
271
  }
226
272
  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;
273
+ // No membership delta route the payload to the message
274
+ // listeners and return WITHOUT pulsing membership subscribers
275
+ // (game fire events broadcast at ~10 Hz; pulsing would
276
+ // re-render every useRoom consumer per message).
277
+ const raw = (_data ?? {}) as { topic?: unknown; payload?: unknown };
278
+ const message: RoomMessage = {
279
+ topic: typeof raw.topic === "string" ? raw.topic : "",
280
+ payload: raw.payload,
281
+ from: member?.user_id ?? "",
282
+ };
283
+ for (const cb of entry.messageSubs) {
284
+ try {
285
+ cb(message);
286
+ } catch (err) {
287
+ console.warn("[sync] room message subscriber threw:", err);
288
+ }
289
+ }
290
+ return;
231
291
  }
232
292
  }
233
293
  entry.error = null;