@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 +1 -1
- package/src/index.ts +35 -0
- package/src/room-subscriptions.test.ts +60 -0
- package/src/room-subscriptions.ts +85 -25
package/package.json
CHANGED
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
|
-
|
|
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.
|
|
182
|
-
*
|
|
183
|
-
*
|
|
184
|
-
*
|
|
185
|
-
*
|
|
186
|
-
*
|
|
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
|
|
228
|
-
//
|
|
229
|
-
//
|
|
230
|
-
|
|
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;
|