@zerotal/broadcasting 1.0.0

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.
@@ -0,0 +1,112 @@
1
+ // ── Channel authorization registry ────────────────────────────────────────────
2
+ //
3
+ // Holds the per-pattern channel-authorization callbacks registered in `routes/channels.ts`
4
+ // via `Broadcast.channel(...)`. The `/broadcasting/auth` route consults it to decide whether a
5
+ // connection may subscribe to a private/presence channel.
6
+ //
7
+ // Patterns use the framework's file-routing `[param]` placeholder syntax, e.g.
8
+ // Broadcast.channel("orders.[orderId]", (user, orderId) => ...)
9
+ // Each `[param]` matches one channel segment (no dots) and is passed to the callback positionally
10
+ // after the authenticated user.
11
+
12
+ /** Member data returned by a presence-channel authorizer. `id` identifies the member. */
13
+ export interface PresenceMemberData {
14
+ id: string | number;
15
+ [key: string]: unknown;
16
+ }
17
+
18
+ /**
19
+ * A channel authorization callback.
20
+ * - Private channel: return a boolean (`true` = authorized).
21
+ * - Presence channel: return a member-data object to authorize + publish presence, or
22
+ * `false`/`null`/`undefined` to deny.
23
+ */
24
+ export type ChannelCallback = (
25
+ user: unknown,
26
+ ...params: string[]
27
+ ) =>
28
+ | boolean
29
+ | PresenceMemberData
30
+ | null
31
+ | undefined
32
+ | Promise<boolean | PresenceMemberData | null | undefined>;
33
+
34
+ interface CompiledChannel {
35
+ pattern: string;
36
+ regex: RegExp;
37
+ paramNames: string[];
38
+ callback: ChannelCallback;
39
+ }
40
+
41
+ function escapeRegex(s: string): string {
42
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
43
+ }
44
+
45
+ /** Compile a `[param]` pattern into a regex + ordered param names. */
46
+ export function compileChannelPattern(pattern: string): { regex: RegExp; paramNames: string[] } {
47
+ const paramNames: string[] = [];
48
+ let regexStr = "^";
49
+ let lastIndex = 0;
50
+ const re = /\[(\w+)\]/g;
51
+ let m: RegExpExecArray | null;
52
+ while ((m = re.exec(pattern)) !== null) {
53
+ regexStr += escapeRegex(pattern.slice(lastIndex, m.index));
54
+ regexStr += "([^.]+)";
55
+ paramNames.push(m[1]!);
56
+ lastIndex = m.index + m[0].length;
57
+ }
58
+ regexStr += escapeRegex(pattern.slice(lastIndex)) + "$";
59
+ return { regex: new RegExp(regexStr), paramNames };
60
+ }
61
+
62
+ /** Result of authorizing a channel subscription. */
63
+ export type AuthorizeResult =
64
+ | { matched: false } // no registered pattern claims this channel
65
+ | { matched: true; result: boolean | PresenceMemberData | null | undefined };
66
+
67
+ export class ChannelRegistry {
68
+ private _channels: CompiledChannel[] = [];
69
+
70
+ /**
71
+ * Register an authorization callback for a channel pattern.
72
+ *
73
+ * @example
74
+ * registry.register("orders.[orderId]", (user, orderId) => user.id === ownerOf(orderId));
75
+ */
76
+ register(pattern: string, callback: ChannelCallback): void {
77
+ const { regex, paramNames } = compileChannelPattern(pattern);
78
+ this._channels.push({ pattern, regex, paramNames, callback });
79
+ }
80
+
81
+ /** Registered channel patterns (for `channel:list` / introspection). */
82
+ all(): { pattern: string; paramNames: string[] }[] {
83
+ return this._channels.map((c) => ({ pattern: c.pattern, paramNames: c.paramNames }));
84
+ }
85
+
86
+ /** Remove all registrations (test isolation). */
87
+ clear(): void {
88
+ this._channels = [];
89
+ }
90
+
91
+ /**
92
+ * Authorize a subscription. The channel name may carry a `private-`/`presence-` prefix; it is
93
+ * stripped before matching (patterns are registered without the prefix).
94
+ *
95
+ * Returns `{ matched: false }` when no pattern claims the channel (caller should deny), or
96
+ * `{ matched: true, result }` where `result` is the callback's return value.
97
+ */
98
+ async authorize(channelName: string, user: unknown): Promise<AuthorizeResult> {
99
+ const bare = channelName.replace(/^private-/, "").replace(/^presence-/, "");
100
+ for (const ch of this._channels) {
101
+ const match = ch.regex.exec(bare);
102
+ if (!match) continue;
103
+ const params = match.slice(1);
104
+ const result = await ch.callback(user, ...params);
105
+ return { matched: true, result };
106
+ }
107
+ return { matched: false };
108
+ }
109
+ }
110
+
111
+ /** Process-wide registry shared by the `Broadcast` facade and `BroadcastProvider`. */
112
+ export const channelRegistry = new ChannelRegistry();
@@ -0,0 +1,53 @@
1
+ import { Broadcast } from "./facades/Broadcast.ts";
2
+ import { currentSocketId } from "./currentSocketId.ts";
3
+ import type { BroadcastEvent } from "./types.ts";
4
+
5
+ /**
6
+ * A deferred broadcast returned by `broadcast(event)`. It is thenable and sends automatically on
7
+ * the next microtask (or immediately when awaited), so `.toOthers()` can configure it first.
8
+ */
9
+ export class PendingBroadcast implements PromiseLike<void> {
10
+ private _toOthers = false;
11
+ private _sent = false;
12
+
13
+ constructor(private readonly event: BroadcastEvent) {}
14
+
15
+ /**
16
+ * Exclude the connection that originated the triggering request (its `X-Socket-ID`), so the
17
+ * user who just made an optimistic update doesn't receive a duplicate.
18
+ */
19
+ toOthers(): this {
20
+ this._toOthers = true;
21
+ return this;
22
+ }
23
+
24
+ /** Send now (idempotent). Called automatically on microtask / await. */
25
+ send(): void {
26
+ if (this._sent) return;
27
+ this._sent = true;
28
+ const exceptSocketId = this._toOthers ? currentSocketId() : undefined;
29
+ Broadcast.send(this.event, exceptSocketId ? { exceptSocketId } : {});
30
+ }
31
+
32
+ then<TResult1 = void, TResult2 = never>(
33
+ onfulfilled?: ((value: void) => TResult1 | PromiseLike<TResult1>) | null,
34
+ onrejected?: ((reason: unknown) => TResult2 | PromiseLike<TResult2>) | null,
35
+ ): PromiseLike<TResult1 | TResult2> {
36
+ this.send();
37
+ return Promise.resolve().then(onfulfilled, onrejected);
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Broadcast an event with fluent modifiers. Sends automatically (no `.send()` needed).
43
+ *
44
+ * @example
45
+ * broadcast(new OrderShipmentStatusUpdated(update)).toOthers();
46
+ * await broadcast(new OrderShipmentStatusUpdated(update));
47
+ */
48
+ export function broadcast(event: BroadcastEvent): PendingBroadcast {
49
+ const pending = new PendingBroadcast(event);
50
+ // Auto-send after the synchronous chain (so .toOthers() runs first). Awaiting sends sooner.
51
+ queueMicrotask(() => pending.send());
52
+ return pending;
53
+ }
@@ -0,0 +1,330 @@
1
+ import { safeEqual, hmacHex } from "@zerotal/core";
2
+ import { BroadcastManager, _isValidChannel } from "./BroadcastManager.ts";
3
+ import type { PresenceMember } from "./BroadcastManager.ts";
4
+ import type { WsConnectionData } from "./types.ts";
5
+
6
+ type WS = ServerWebSocket<WsConnectionData>;
7
+
8
+ interface PusherSubscribeData {
9
+ channel: string;
10
+ auth?: string;
11
+ channel_data?: string;
12
+ }
13
+
14
+ interface PusherClientMessage {
15
+ event: string;
16
+ data?: PusherSubscribeData | Record<string, unknown>;
17
+ channel?: string;
18
+ }
19
+
20
+ /**
21
+ * Callback for resolving a presence-channel member from an HTTP auth request.
22
+ * Return `false` to deny authorization.
23
+ */
24
+ export type PusherPresenceResolver = (
25
+ req: Request,
26
+ channel: string,
27
+ ) =>
28
+ | Promise<{ id: string | number; info?: Record<string, unknown> } | false>
29
+ | { id: string | number; info?: Record<string, unknown> }
30
+ | false;
31
+
32
+ /**
33
+ * Pusher/Reverb-compatible WebSocket broadcast manager.
34
+ *
35
+ * Implements the Pusher wire protocol on top of `BroadcastManager` so that
36
+ * Any Pusher-compatible client can connect to a Zerotal
37
+ * backend without modifications.
38
+ *
39
+ * Key protocol differences from the native Zerotal protocol:
40
+ * - Connection event: `pusher:connection_established` (data is JSON string)
41
+ * - Subscribe event: `pusher:subscribe` (channel inside `data` object)
42
+ * - Subscription succeeded: `pusher_internal:subscription_succeeded`
43
+ * - All server-sent data fields are JSON strings, not objects
44
+ * - Auth for private/presence channels uses HMAC-SHA256 signatures
45
+ *
46
+ * Auth flow:
47
+ * 1. Echo calls POST /broadcasting/auth with socket_id + channel_name
48
+ * 2. Server signs with `signAuth()` and returns `{auth: "key:sig"}`
49
+ * 3. Echo includes `auth` in the pusher:subscribe message
50
+ * 4. Manager verifies HMAC before allowing subscription
51
+ *
52
+ * @example
53
+ * // config/broadcasting.ts
54
+ * BroadcastConfig({
55
+ * driver: 'pusher',
56
+ * pusher: {
57
+ * appKey: Bun.env.PUSHER_APP_KEY!,
58
+ * appSecret: Bun.env.PUSHER_APP_SECRET!,
59
+ * },
60
+ * });
61
+ */
62
+ export class PusherCompatManager extends BroadcastManager {
63
+ private _presenceResolver: PusherPresenceResolver | undefined;
64
+
65
+ constructor(
66
+ private readonly _appKey: string,
67
+ private readonly _appSecret: string,
68
+ ) {
69
+ super();
70
+ }
71
+
72
+ // ── Auth ──────────────────────────────────────────────────────────────────
73
+
74
+ /**
75
+ * Register a callback that resolves presence-channel member data for the
76
+ * HTTP auth endpoint. Return `false` to deny the subscription.
77
+ *
78
+ * @example
79
+ * pusher.resolvePresenceWith(async (req, _channel) => {
80
+ * const userId = await getUserIdFromSession(req);
81
+ * if (!userId) return false;
82
+ * return { id: userId, info: { name: await getUserName(userId) } };
83
+ * });
84
+ */
85
+ resolvePresenceWith(fn: PusherPresenceResolver): void {
86
+ this._presenceResolver = fn;
87
+ }
88
+
89
+ /**
90
+ * Generate a Pusher auth token for a given socket/channel pair.
91
+ * Called by the HTTP auth endpoint before returning the token to the client.
92
+ *
93
+ * For private channels: `signAuth(socketId, channel)`
94
+ * For presence channels: `signAuth(socketId, channel, channelData)`
95
+ * where `channelData = JSON.stringify({ user_id, user_info })`
96
+ */
97
+ signAuth(socketId: string, channel: string, channelData?: string): string {
98
+ const str = channelData ? `${socketId}:${channel}:${channelData}` : `${socketId}:${channel}`;
99
+ const sig = hmacHex(str, this._appSecret);
100
+ return `${this._appKey}:${sig}`;
101
+ }
102
+
103
+ /**
104
+ * Resolve presence member data for the HTTP auth endpoint.
105
+ * Returns `false` when no resolver is registered or the resolver denies.
106
+ */
107
+ async resolvePresence(
108
+ req: Request,
109
+ channel: string,
110
+ ): Promise<{ id: string | number; info?: Record<string, unknown> } | false> {
111
+ if (!this._presenceResolver) return false;
112
+ return this._presenceResolver(req, channel);
113
+ }
114
+
115
+ // ── WS lifecycle ──────────────────────────────────────────────────────────
116
+
117
+ override handleOpen(ws: WS): void {
118
+ this._conns.set(ws.data.id, ws);
119
+ ws.send(
120
+ JSON.stringify({
121
+ event: "pusher:connection_established",
122
+ data: JSON.stringify({ socket_id: ws.data.id, activity_timeout: 120 }),
123
+ }),
124
+ );
125
+ }
126
+
127
+ override async handleMessage(ws: WS, raw: string | Uint8Array): Promise<void> {
128
+ let msg: PusherClientMessage;
129
+ try {
130
+ msg = JSON.parse(
131
+ typeof raw === "string" ? raw : new TextDecoder().decode(raw),
132
+ ) as PusherClientMessage;
133
+ } catch {
134
+ ws.send(
135
+ JSON.stringify({
136
+ event: "pusher:error",
137
+ data: JSON.stringify({ message: "Invalid JSON.", code: 4001 }),
138
+ }),
139
+ );
140
+ return;
141
+ }
142
+
143
+ switch (msg.event) {
144
+ case "pusher:subscribe": {
145
+ const data = (msg.data ?? {}) as PusherSubscribeData;
146
+ await this._pusherSubscribe(ws, data);
147
+ break;
148
+ }
149
+ case "pusher:unsubscribe": {
150
+ const channel = ((msg.data ?? {}) as PusherSubscribeData).channel;
151
+ if (channel) this._subs.get(channel)?.delete(ws.data.id);
152
+ break;
153
+ }
154
+ case "pusher:ping":
155
+ ws.send(JSON.stringify({ event: "pusher:pong", data: {} }));
156
+ break;
157
+ default:
158
+ // Forward client-* events to other channel subscribers
159
+ if (typeof msg.event === "string" && msg.event.startsWith("client-") && msg.channel) {
160
+ this._forwardClientEvent(ws, msg);
161
+ }
162
+ }
163
+ }
164
+
165
+ override handleClose(ws: WS): void {
166
+ this._conns.delete(ws.data.id);
167
+
168
+ for (const [ch, ids] of this._subs) {
169
+ ids.delete(ws.data.id);
170
+ if (ids.size === 0) this._subs.delete(ch);
171
+ }
172
+
173
+ for (const [ch, members] of this._members) {
174
+ if (members.has(ws.data.id)) {
175
+ const member = members.get(ws.data.id)!;
176
+ members.delete(ws.data.id);
177
+ if (members.size === 0) this._members.delete(ch);
178
+ this.to(ch, "pusher_internal:member_removed", { user_id: String(member.id) });
179
+ }
180
+ }
181
+ }
182
+
183
+ // ── Broadcast API ─────────────────────────────────────────────────────────
184
+
185
+ /**
186
+ * Broadcast an event to all subscribers.
187
+ * Pusher format: `data` is always a JSON string, not an object.
188
+ */
189
+ override to(
190
+ channel: string,
191
+ eventName: string,
192
+ data: unknown = {},
193
+ opts?: { exceptSocketId?: string },
194
+ ): void {
195
+ const ids = this._subs.get(channel);
196
+ if (!ids || ids.size === 0) return;
197
+ const msg = JSON.stringify({ event: eventName, channel, data: JSON.stringify(data) });
198
+ for (const id of ids) {
199
+ // `toOthers()` excludes the originating connection. Declaring three parameters and
200
+ // dropping the fourth made the exclusion vanish here without a type error.
201
+ if (opts?.exceptSocketId && id === opts.exceptSocketId) continue;
202
+ this._conns.get(id)?.send(msg);
203
+ }
204
+ }
205
+
206
+ // ── Private ───────────────────────────────────────────────────────────────
207
+
208
+ private _verifyAuth(
209
+ socketId: string,
210
+ channel: string,
211
+ auth: string,
212
+ channelData?: string,
213
+ ): boolean {
214
+ return safeEqual(auth, this.signAuth(socketId, channel, channelData));
215
+ }
216
+
217
+ private async _pusherSubscribe(ws: WS, data: PusherSubscribeData): Promise<void> {
218
+ const { channel, auth, channel_data } = data;
219
+
220
+ // Reject structurally invalid channel names before anything is signed or verified.
221
+ //
222
+ // signAuth() keeps Pusher's `socketId:channel:channelData` wire format for protocol
223
+ // compatibility, so a channel name containing `:` could otherwise be crafted to shift the
224
+ // field boundaries — signAuth(s, 'presence-chat.5:{"user_id":1}') produces the same bytes
225
+ // as signAuth(s, 'presence-chat.5', '{"user_id":1}'), letting an attacker obtain a token
226
+ // for a channel they may join and replay it as another member's presence identity.
227
+ // _isValidChannel excludes `:` for exactly this reason. It also rejects non-strings and
228
+ // over-long names, which previously threw out of the handler.
229
+ if (!_isValidChannel(channel)) {
230
+ ws.send(
231
+ JSON.stringify({
232
+ event: "pusher:error",
233
+ data: JSON.stringify({ message: "Invalid channel name.", code: 4009 }),
234
+ }),
235
+ );
236
+ return;
237
+ }
238
+
239
+ const isPresence = channel.startsWith("presence-");
240
+ const isPrivate = channel.startsWith("private-") || isPresence;
241
+
242
+ if (isPrivate) {
243
+ if (!auth || !this._verifyAuth(ws.data.id, channel, auth, channel_data)) {
244
+ ws.send(
245
+ JSON.stringify({
246
+ event: "pusher:error",
247
+ data: JSON.stringify({ message: "Unauthorized.", code: 4009 }),
248
+ }),
249
+ );
250
+ return;
251
+ }
252
+ }
253
+
254
+ if (isPresence) {
255
+ let memberData: { user_id?: string | number; user_info?: Record<string, unknown> } = {};
256
+ try {
257
+ if (channel_data) {
258
+ memberData = JSON.parse(channel_data) as typeof memberData;
259
+ }
260
+ } catch {
261
+ /* use defaults */
262
+ }
263
+
264
+ const member: PresenceMember = {
265
+ id: memberData.user_id ?? ws.data.id,
266
+ info: memberData.user_info ?? {},
267
+ };
268
+
269
+ if (!this._members.has(channel)) this._members.set(channel, new Map());
270
+ if (!this._subs.has(channel)) this._subs.set(channel, new Set());
271
+
272
+ // Notify existing subscribers BEFORE adding the new one (no self-notify)
273
+ this.to(channel, "pusher_internal:member_added", {
274
+ user_id: String(member.id),
275
+ user_info: member.info,
276
+ });
277
+
278
+ this._members.get(channel)!.set(ws.data.id, member);
279
+ this._subs.get(channel)!.add(ws.data.id);
280
+
281
+ const allMembers = [...this._members.get(channel)!.values()];
282
+ const presenceData = {
283
+ presence: {
284
+ count: allMembers.length,
285
+ ids: allMembers.map((m) => String(m.id)),
286
+ hash: Object.fromEntries(allMembers.map((m) => [String(m.id), m.info])),
287
+ },
288
+ };
289
+
290
+ ws.send(
291
+ JSON.stringify({
292
+ event: "pusher_internal:subscription_succeeded",
293
+ data: JSON.stringify(presenceData),
294
+ channel,
295
+ }),
296
+ );
297
+ return;
298
+ }
299
+
300
+ if (!this._subs.has(channel)) this._subs.set(channel, new Set());
301
+ this._subs.get(channel)!.add(ws.data.id);
302
+ ws.send(
303
+ JSON.stringify({
304
+ event: "pusher_internal:subscription_succeeded",
305
+ data: "{}",
306
+ channel,
307
+ }),
308
+ );
309
+ }
310
+
311
+ private _forwardClientEvent(ws: WS, msg: PusherClientMessage): void {
312
+ const channel = msg.channel!;
313
+
314
+ // Pusher permits client events only on private/presence channels, and only from a
315
+ // connection that is actually subscribed to the channel. This checked neither: it verified
316
+ // the `client-` event-name prefix and nothing else, so any socket — never subscribed, never
317
+ // authenticated — could inject a frame into `private-admin` and every real subscriber
318
+ // received it as though it came from a peer.
319
+ if (!channel.startsWith("private-") && !channel.startsWith("presence-")) return;
320
+
321
+ const ids = this._subs.get(channel);
322
+ if (!ids) return;
323
+ if (!ids.has(ws.data.id)) return; // sender is not a member of this channel
324
+
325
+ const out = JSON.stringify({ event: msg.event, channel, data: msg.data });
326
+ for (const id of ids) {
327
+ if (id !== ws.data.id) this._conns.get(id)?.send(out);
328
+ }
329
+ }
330
+ }
@@ -0,0 +1,121 @@
1
+ import { RedisClient } from "bun";
2
+ import { BroadcastManager } from "./BroadcastManager.ts";
3
+ import { frameworkLog } from "@zerotal/core/logger";
4
+
5
+ const TOPIC = "__zerotal:broadcast";
6
+
7
+ interface Envelope {
8
+ channel: string;
9
+ event: string;
10
+ data: unknown;
11
+ /**
12
+ * The originating connection, when the broadcast came from `toOthers()`.
13
+ *
14
+ * It has to cross the pub/sub hop: the socket to exclude is connected to whichever node
15
+ * published, and every *other* node has to know to skip it too — or rather, has to know
16
+ * that it does not hold it. Without this field the exclusion could not survive the hop
17
+ * even in principle, so `toOthers()` was a silent no-op on the driver documented for
18
+ * horizontal scaling, and every optimistic update double-applied in production.
19
+ */
20
+ exceptSocketId?: string;
21
+ }
22
+
23
+ /**
24
+ * Redis-backed broadcast driver for horizontal scaling.
25
+ *
26
+ * Extends `BroadcastManager` and overrides `to()` to publish through
27
+ * Redis Pub/Sub instead of writing directly to local WebSocket connections.
28
+ * Every server instance subscribes on `__zerotal:broadcast` and delivers
29
+ * incoming messages to its own local WS clients.
30
+ *
31
+ * Result: a broadcast on Server A is received by subscribers on Server B,
32
+ * Server C, etc. — no direct server-to-server connection needed.
33
+ *
34
+ * Architecture:
35
+ *
36
+ * Server A Redis Server B
37
+ * ───────────────── ────────────── ─────────────────
38
+ * Broadcast.to(ch, ev)
39
+ * → pub.publish(TOPIC) ──► fan-out ───────────────► sub.subscribe cb
40
+ * ◄─────────────────────────── super.to() → ws
41
+ *
42
+ * The publishing server also receives its own message via the subscriber,
43
+ * so all delivery — local or remote — flows through the same path.
44
+ *
45
+ * @example
46
+ * // config/broadcasting.ts
47
+ * export default BroadcastConfig({
48
+ * driver: 'redis',
49
+ * redis: { url: Bun.env.REDIS_URL ?? 'redis://localhost:6379' },
50
+ * });
51
+ */
52
+ export class RedisBroadcastDriver extends BroadcastManager {
53
+ private _pub!: RedisClient;
54
+ private _sub!: RedisClient;
55
+
56
+ constructor(private readonly _url: string) {
57
+ super();
58
+ }
59
+
60
+ /**
61
+ * Open the pub + sub connections and start listening for cross-server
62
+ * broadcasts. Call once during application boot.
63
+ *
64
+ * If `_pub`/`_sub` are already set (e.g. injected in tests), this method
65
+ * skips creating new clients and only registers the subscriber callback.
66
+ */
67
+ async boot(): Promise<void> {
68
+ if (!this._pub) {
69
+ this._pub = new RedisClient(this._url);
70
+ // Bun.Redis cannot publish and subscribe on the same connection.
71
+ // duplicate() creates a new independent connection with the same URL.
72
+ this._sub = await this._pub.duplicate();
73
+ }
74
+
75
+ await this._sub.subscribe(TOPIC, (msg: string) => {
76
+ let envelope: Envelope;
77
+ try {
78
+ envelope = JSON.parse(msg) as Envelope;
79
+ } catch {
80
+ return;
81
+ }
82
+ // Deliver to local WebSocket clients on this server instance.
83
+ // super.to() bypasses our Redis override so we don't re-publish. The exclusion is
84
+ // applied on every node: only one of them holds that connection, and the others
85
+ // simply have nothing matching to skip.
86
+ super.to(
87
+ envelope.channel,
88
+ envelope.event,
89
+ envelope.data,
90
+ envelope.exceptSocketId ? { exceptSocketId: envelope.exceptSocketId } : undefined,
91
+ );
92
+ });
93
+ }
94
+
95
+ /** Unsubscribe the Redis listener. Called when the application stops. */
96
+ async stop(): Promise<void> {
97
+ await this._sub.unsubscribe(TOPIC);
98
+ }
99
+
100
+ /**
101
+ * Publish the event to Redis instead of delivering locally.
102
+ * All server instances — including this one — receive it through
103
+ * the subscriber and deliver it to their own local WS clients.
104
+ */
105
+ override to(
106
+ channel: string,
107
+ event: string,
108
+ data: unknown = {},
109
+ opts?: { exceptSocketId?: string },
110
+ ): void {
111
+ const msg = JSON.stringify({
112
+ channel,
113
+ event,
114
+ data,
115
+ ...(opts?.exceptSocketId ? { exceptSocketId: opts.exceptSocketId } : {}),
116
+ } satisfies Envelope);
117
+ this._pub.publish(TOPIC, msg).catch((err: Error) => {
118
+ frameworkLog("broadcast").error("Redis publish failed", undefined, err);
119
+ });
120
+ }
121
+ }