@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.
- package/CHANGELOG.md +22 -0
- package/LICENSE +21 -0
- package/README.md +111 -0
- package/package.json +54 -0
- package/src/AnonymousBroadcast.ts +52 -0
- package/src/BroadcastFake.ts +114 -0
- package/src/BroadcastManager.ts +404 -0
- package/src/BroadcastingEvent.ts +89 -0
- package/src/BroadcastsModelEvents.ts +93 -0
- package/src/Channel.ts +32 -0
- package/src/ChannelRegistry.ts +112 -0
- package/src/PendingBroadcast.ts +53 -0
- package/src/PusherCompatManager.ts +330 -0
- package/src/RedisBroadcastDriver.ts +121 -0
- package/src/TypedBroadcastManager.ts +185 -0
- package/src/commands/ChannelListCommand.ts +35 -0
- package/src/commands/MakeChannelCommand.ts +108 -0
- package/src/config.ts +106 -0
- package/src/currentSocketId.ts +6 -0
- package/src/errors.ts +32 -0
- package/src/facades/Broadcast.ts +135 -0
- package/src/global.d.ts +10 -0
- package/src/index.ts +36 -0
- package/src/provider/BroadcastProvider.ts +243 -0
- package/src/types.ts +79 -0
|
@@ -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
|
+
}
|