@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,404 @@
1
+ import { safeEqual, hmacHex } from "@zerotal/core";
2
+ import { isPrivateChannel } from "./Channel.ts";
3
+ import type { BroadcastEvent, WsConnectionData, ClientMessage, ChannelAuthFn } from "./types.ts";
4
+ import { frameworkLog } from "@zerotal/core/logger";
5
+
6
+ type WS = ServerWebSocket<WsConnectionData>;
7
+
8
+ /**
9
+ * Core broadcast manager.
10
+ *
11
+ * - Tracks all open WebSocket connections.
12
+ * - Manages per-channel subscription sets.
13
+ * - Dispatches broadcast events to subscribers.
14
+ * - Authenticates private/presence channel subscriptions via a registered callback.
15
+ *
16
+ * Wire into the application via BroadcastProvider, which calls
17
+ * `app.withWebSocket(manager.wsHandlers)` before the server starts.
18
+ */
19
+ export interface PresenceMember {
20
+ id: string | number;
21
+ info: Record<string, unknown>;
22
+ }
23
+
24
+ /** Callback that returns member data for a presence channel subscription. */
25
+ export type PresenceAuthFn = (
26
+ channel: string,
27
+ ws: ServerWebSocket<WsConnectionData>,
28
+ ) => Promise<PresenceMember | false> | PresenceMember | false;
29
+
30
+ export class BroadcastManager {
31
+ /** connectionId → live WebSocket */
32
+ protected _conns = new Map<string, WS>();
33
+ /** channelName → Set<connectionId> */
34
+ protected _subs = new Map<string, Set<string>>();
35
+ /** presence channelName → Map<connectionId, PresenceMember> */
36
+ protected _members = new Map<string, Map<string, PresenceMember>>();
37
+ /** Auth callback for private/presence channels */
38
+ private _authFn: ChannelAuthFn | undefined = undefined;
39
+ /** Presence auth callback (returns member data or false) */
40
+ private _presenceFn: PresenceAuthFn | undefined = undefined;
41
+ /** Secret for per-subscription HMAC signatures (the app's APP_KEY). */
42
+ protected _authSecret: string | undefined = undefined;
43
+
44
+ // ── Per-subscription signatures (Pusher-style) ─────────────────────────────
45
+
46
+ /**
47
+ * Set the secret used to sign/verify per-subscription auth tokens. Wired from the app's
48
+ * `APP_KEY` by `BroadcastProvider`. Without it the signed-auth path is disabled and the
49
+ * server falls back to the connection-level authorize callbacks.
50
+ */
51
+ setAuthSecret(secret: string): void {
52
+ this._authSecret = secret;
53
+ }
54
+
55
+ /**
56
+ * Sign a `socket_id` / `channel` pair (and, for presence, its `channelData`) — the token
57
+ * `POST /broadcasting/auth` hands back to the client, which echoes it in `subscribe`.
58
+ */
59
+ signAuth(socketId: string, channel: string, channelData?: string): string {
60
+ // Sign an unambiguous encoding, not a `:`-joined string.
61
+ //
62
+ // The previous form was `${socketId}:${channel}:${channelData}` with an unescaped separator,
63
+ // while the channel pattern compiler maps `[param]` to `([^.]+)` — which matches `:` and
64
+ // `{`. That made signAuth(s, 'presence-chat.5:{"user_id":1}') produce a byte-identical
65
+ // payload to signAuth(s, 'presence-chat.5', '{"user_id":1}'), so an attacker could request
66
+ // a token for a crafted channel name and replay it as another member's presence identity.
67
+ // JSON.stringify of a fixed-arity array is injective: the delimiters cannot be forged from
68
+ // within a field because they are escaped inside it.
69
+ const str = JSON.stringify([socketId, channel, channelData ?? null]);
70
+ return hmacHex(str, this._authSecret ?? "");
71
+ }
72
+
73
+ /** Constant-time check that `auth` is a valid signature for this socket/channel(/data). */
74
+ verifyAuth(socketId: string, channel: string, auth: string, channelData?: string): boolean {
75
+ if (!this._authSecret) return false; // no secret → cannot trust signatures
76
+ return safeEqual(auth, this.signAuth(socketId, channel, channelData));
77
+ }
78
+
79
+ // ── Auth ──────────────────────────────────────────────────────────────────
80
+
81
+ /**
82
+ * Register an authorization callback for private/presence channels.
83
+ * Return `true` to allow subscription, `false` to deny.
84
+ *
85
+ * @example
86
+ * broadcast.authorizeWith(async (channel, ws) => {
87
+ * if (!ws.data.userId) return false;
88
+ * if (channel.startsWith('private-orders.')) {
89
+ * const id = channel.split('.')[1];
90
+ * return await Order.findOwner(id) === ws.data.userId;
91
+ * }
92
+ * return true;
93
+ * });
94
+ */
95
+ authorizeWith(fn: ChannelAuthFn): void {
96
+ this._authFn = fn;
97
+ }
98
+
99
+ /**
100
+ * Register a presence channel auth callback.
101
+ * Return a `PresenceMember` object to grant access and track the member,
102
+ * or `false` to deny.
103
+ *
104
+ * @example
105
+ * manager.authorizePresenceWith(async (channel, ws) => {
106
+ * const user = await User.find(ws.data.userId);
107
+ * if (!user) return false;
108
+ * return { id: user.id, info: { name: user.name, avatar: user.avatar } };
109
+ * });
110
+ */
111
+ authorizePresenceWith(fn: PresenceAuthFn): void {
112
+ this._presenceFn = fn;
113
+ }
114
+
115
+ /**
116
+ * Return all members currently subscribed to a presence channel.
117
+ *
118
+ * @example
119
+ * const members = manager.getMembers('presence-chat.room');
120
+ * // [{ id: 1, info: { name: 'Alice' } }, ...]
121
+ */
122
+ getMembers(channel: string): PresenceMember[] {
123
+ return [...(this._members.get(channel)?.values() ?? [])];
124
+ }
125
+
126
+ // ── WS lifecycle ──────────────────────────────────────────────────────────
127
+
128
+ handleOpen(ws: WS): void {
129
+ this._conns.set(ws.data.id, ws);
130
+ ws.send(JSON.stringify({ event: "connected", data: { socketId: ws.data.id } }));
131
+ }
132
+
133
+ async handleMessage(ws: WS, raw: string | Uint8Array): Promise<void> {
134
+ let msg: ClientMessage;
135
+ try {
136
+ msg = JSON.parse(
137
+ typeof raw === "string" ? raw : new TextDecoder().decode(raw),
138
+ ) as ClientMessage;
139
+ } catch {
140
+ ws.send(JSON.stringify({ event: "error", message: "Invalid JSON." }));
141
+ return;
142
+ }
143
+
144
+ switch (msg.event) {
145
+ case "subscribe":
146
+ // `channel` is client input and reaches String.prototype methods immediately.
147
+ // An omitted or non-string value used to throw a TypeError out of the handler.
148
+ if (!_isValidChannel(msg.channel)) {
149
+ ws.send(JSON.stringify({ event: "error", message: "Invalid channel." }));
150
+ return;
151
+ }
152
+ await this._subscribe(ws, msg.channel, msg.auth, msg.channelData);
153
+ break;
154
+ case "unsubscribe":
155
+ if (!_isValidChannel(msg.channel)) return;
156
+ this._unsubscribe(ws, msg.channel);
157
+ break;
158
+ case "ping":
159
+ ws.send(JSON.stringify({ event: "pong" }));
160
+ break;
161
+ default:
162
+ ws.send(JSON.stringify({ event: "error", message: "Unknown event." }));
163
+ }
164
+ }
165
+
166
+ handleClose(ws: WS): void {
167
+ this._conns.delete(ws.data.id);
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
+ // Remove from presence member maps and notify channel
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
+ // Notify remaining members
179
+ this.to(ch, "presence:member_removed", { member, channel: ch });
180
+ }
181
+ }
182
+ }
183
+
184
+ // ── Broadcast API ─────────────────────────────────────────────────────────
185
+
186
+ /**
187
+ * Broadcast a raw event to all subscribers of `channel`.
188
+ *
189
+ * @example
190
+ * broadcast.to('posts', 'PostCreated', { id: 99 });
191
+ */
192
+ to(
193
+ channel: string,
194
+ eventName: string,
195
+ data: unknown = {},
196
+ opts?: { exceptSocketId?: string },
197
+ ): void {
198
+ const ids = this._subs.get(channel);
199
+ if (!ids || ids.size === 0) return;
200
+ const msg = JSON.stringify({ event: eventName, channel, data });
201
+ for (const id of ids) {
202
+ // `toOthers()` excludes the originating connection (its socket id == connection id).
203
+ if (opts?.exceptSocketId && id === opts.exceptSocketId) continue;
204
+ this._conns.get(id)?.send(msg);
205
+ }
206
+ }
207
+
208
+ /**
209
+ * Dispatch a BroadcastEvent to all its declared channels.
210
+ *
211
+ * @example
212
+ * broadcast.send(new PostUpdated(post));
213
+ */
214
+ send(event: BroadcastEvent, opts?: { exceptSocketId?: string }): void {
215
+ const channels = [event.broadcastOn()].flat();
216
+ const name = event.broadcastAs?.() ?? event.constructor.name;
217
+ const data = event.broadcastWith?.() ?? {};
218
+ for (const ch of channels) {
219
+ this.to(ch, name, data, opts);
220
+ }
221
+ }
222
+
223
+ // ── Subscriptions ─────────────────────────────────────────────────────────
224
+
225
+ /** Returns the list of channels a connection is subscribed to. */
226
+ subscriptionsFor(connectionId: string): string[] {
227
+ const result: string[] = [];
228
+ for (const [ch, ids] of this._subs) {
229
+ if (ids.has(connectionId)) result.push(ch);
230
+ }
231
+ return result;
232
+ }
233
+
234
+ /** Returns the number of subscribers on a channel. */
235
+ subscriberCount(channel: string): number {
236
+ return this._subs.get(channel)?.size ?? 0;
237
+ }
238
+
239
+ /** Total open connections. */
240
+ connectionCount(): number {
241
+ return this._conns.size;
242
+ }
243
+
244
+ // ── Bun.serve() handler config ────────────────────────────────────────────
245
+
246
+ get wsHandlers() {
247
+ return {
248
+ open: (ws: WS) => this.handleOpen(ws),
249
+ // The rejection handler is not optional. Bun's ws.message callback is sync, so an
250
+ // unhandled rejection from handleMessage() propagates to the process — and there is no
251
+ // process-level unhandledRejection guard anywhere in the framework. A single malformed
252
+ // frame (or a throwing app-supplied authorizeWith callback) took the server down.
253
+ message: (ws: WS, msg: string | Uint8Array) =>
254
+ void this.handleMessage(ws, msg).catch((error: unknown) => {
255
+ this._onHandlerError(ws, error);
256
+ }),
257
+ close: (ws: WS) => this.handleClose(ws),
258
+ };
259
+ }
260
+
261
+ /**
262
+ * Last-resort handler for a rejection escaping {@link handleMessage}.
263
+ *
264
+ * Logs and, where possible, tells the offending client. Never rethrows: the whole point is
265
+ * that one client's bad frame must not terminate the process serving everyone else.
266
+ */
267
+ protected _onHandlerError(ws: WS, error: unknown): void {
268
+ const message = error instanceof Error ? error.message : String(error);
269
+ frameworkLog("broadcast").error(`Message handler failed: ${message}`);
270
+ try {
271
+ ws.send(JSON.stringify({ event: "error", message: "Message could not be processed." }));
272
+ } catch {
273
+ // The socket may already be closed — nothing useful left to do.
274
+ }
275
+ }
276
+
277
+ /** Factory for `upgradeData` passed to `app.withWebSocket()`. */
278
+ upgradeData(req: Request): Record<string, unknown> {
279
+ const auth = req.headers.get("Authorization") ?? "";
280
+ const token = auth.startsWith("Bearer ") ? auth.slice(7) : undefined;
281
+ return { token };
282
+ }
283
+
284
+ // ── Private ───────────────────────────────────────────────────────────────
285
+
286
+ private async _subscribe(
287
+ ws: WS,
288
+ channel: string,
289
+ auth?: string,
290
+ channelData?: string,
291
+ ): Promise<void> {
292
+ const isPresence = channel.startsWith("presence-");
293
+ const deny = () =>
294
+ ws.send(JSON.stringify({ event: "subscription_error", channel, message: "Unauthorized" }));
295
+
296
+ if (isPresence) {
297
+ // Two ways to authorize a presence subscription:
298
+ // • Signed (Pusher-style): a per-subscription `auth` token covering `channelData`.
299
+ // We verify the HMAC and take the member straight from the signed `channelData`.
300
+ // • Connection-level: the registered presence/auth callback reads `ws.data`.
301
+ let member: PresenceMember;
302
+ if (auth !== undefined) {
303
+ if (!this.verifyAuth(ws.data.id, channel, auth, channelData)) return deny();
304
+ member = _parseSignedMember(channelData, ws.data.id);
305
+ } else {
306
+ const authFn = this._presenceFn ?? this._authFn;
307
+ if (!authFn) return deny();
308
+ const result = await authFn(channel, ws);
309
+ if (!result) return deny();
310
+ member =
311
+ result === true
312
+ ? ({ id: ws.data.id, info: {} } as PresenceMember)
313
+ : (result as PresenceMember);
314
+ }
315
+
316
+ if (!this._members.has(channel)) this._members.set(channel, new Map());
317
+ if (!this._subs.has(channel)) this._subs.set(channel, new Set());
318
+ this._members.get(channel)!.set(ws.data.id, member);
319
+
320
+ // Notify EXISTING subscribers before adding the new one (so they don't self-notify)
321
+ this.to(channel, "presence:member_added", { member, channel });
322
+
323
+ // Now add the new subscriber to the channel
324
+ this._subs.get(channel)!.add(ws.data.id);
325
+
326
+ // Tell the new subscriber who's already here (full member list after their join)
327
+ ws.send(
328
+ JSON.stringify({
329
+ event: "subscription_succeeded",
330
+ channel,
331
+ data: { members: this.getMembers(channel) },
332
+ }),
333
+ );
334
+ return;
335
+ }
336
+
337
+ if (isPrivateChannel(channel)) {
338
+ // Signed token if provided, else the connection-level authorize callback.
339
+ const authorized =
340
+ auth !== undefined
341
+ ? this.verifyAuth(ws.data.id, channel, auth)
342
+ : this._authFn
343
+ ? await this._authFn(channel, ws)
344
+ : false;
345
+ if (!authorized) return deny();
346
+ }
347
+
348
+ if (!this._subs.has(channel)) this._subs.set(channel, new Set());
349
+ this._subs.get(channel)!.add(ws.data.id);
350
+ ws.send(JSON.stringify({ event: "subscription_succeeded", channel }));
351
+ }
352
+
353
+ private _unsubscribe(ws: WS, channel: string): void {
354
+ this._subs.get(channel)?.delete(ws.data.id);
355
+ // Presence: an explicit leave (e.g. Echo.leave on a component teardown / SPA navigation) must
356
+ // remove the member and notify the remaining subscribers — the same cleanup a full disconnect
357
+ // does in handleClose. Without this, a member who navigated away lingers in others' "who's here"
358
+ // until they close the tab.
359
+ const members = this._members.get(channel);
360
+ const member = members?.get(ws.data.id);
361
+ if (members && member) {
362
+ members.delete(ws.data.id);
363
+ if (members.size === 0) this._members.delete(channel);
364
+ this.to(channel, "presence:member_removed", { member, channel });
365
+ }
366
+ ws.send(JSON.stringify({ event: "unsubscribed", channel }));
367
+ }
368
+ }
369
+
370
+ /** Parse the signed presence `channelData` (`{ id, info }`) into a member, with fallbacks. */
371
+ function _parseSignedMember(channelData: string | undefined, fallbackId: string): PresenceMember {
372
+ if (!channelData) return { id: fallbackId, info: {} };
373
+ try {
374
+ const parsed = JSON.parse(channelData) as {
375
+ id?: string | number;
376
+ info?: Record<string, unknown>;
377
+ };
378
+ return { id: parsed.id ?? fallbackId, info: parsed.info ?? {} };
379
+ } catch {
380
+ return { id: fallbackId, info: {} };
381
+ }
382
+ }
383
+
384
+ /**
385
+ * Longest channel name accepted from a client. Channel names are used as Map keys and echoed
386
+ * into outbound frames, so an unbounded value is both a memory and an amplification vector.
387
+ */
388
+ const _MAX_CHANNEL_LENGTH = 256;
389
+
390
+ /**
391
+ * Whether a client-supplied channel name is structurally acceptable.
392
+ *
393
+ * The character set deliberately excludes `:`, which is the field separator used when signing
394
+ * presence auth payloads — without that exclusion a crafted channel name could be split across
395
+ * the separator to forge another member's identity.
396
+ */
397
+ export function _isValidChannel(channel: unknown): channel is string {
398
+ return (
399
+ typeof channel === "string" &&
400
+ channel.length > 0 &&
401
+ channel.length <= _MAX_CHANNEL_LENGTH &&
402
+ /^[A-Za-z0-9_\-=@,.;]+$/.test(channel)
403
+ );
404
+ }
@@ -0,0 +1,89 @@
1
+ import { Events } from "@zerotal/core";
2
+ import { Broadcast } from "./facades/Broadcast.ts";
3
+ import type { BroadcastEvent } from "./types.ts";
4
+
5
+ // Tracks events already broadcast, so dispatch() and the Events-bus hook never double-broadcast.
6
+ const _broadcasted = new WeakSet<object>();
7
+
8
+ /**
9
+ * Broadcast an event at most once (idempotent across `dispatch()` and the `Events` auto-broadcast
10
+ * hook), gated by `broadcastWhen()` (default true). Used by both paths; exported for the hook.
11
+ */
12
+ export function broadcastOnce(event: BroadcastEvent): void {
13
+ if (_broadcasted.has(event)) return;
14
+ _broadcasted.add(event);
15
+ const when = (event as { broadcastWhen?(): boolean }).broadcastWhen;
16
+ if (typeof when === "function" && when.call(event) === false) return;
17
+ Broadcast.send(event);
18
+ }
19
+
20
+ /**
21
+ * Base class for broadcastable events. Extend it, implement `broadcastOn()`, and you get
22
+ * sensible defaults plus a static `dispatch()` that both fires application listeners and
23
+ * broadcasts the event.
24
+ *
25
+ * @example
26
+ * import { BroadcastingEvent, privateChannel } from "@zerotal/broadcasting";
27
+ *
28
+ * export class OrderShipmentStatusUpdated extends BroadcastingEvent {
29
+ * constructor(public readonly order: Order) { super(); }
30
+ *
31
+ * broadcastOn() { return privateChannel(`orders.${this.order.id}`); }
32
+ * broadcastWith() { return { id: this.order.id, status: this.order.status }; }
33
+ * broadcastWhen() { return this.order.total > 100; }
34
+ * }
35
+ *
36
+ * // Construct + dispatch (runs listeners + broadcasts):
37
+ * OrderShipmentStatusUpdated.dispatch(order);
38
+ *
39
+ * // Or broadcast only, excluding the current socket:
40
+ * broadcast(new OrderShipmentStatusUpdated(order)).toOthers();
41
+ */
42
+ export abstract class BroadcastingEvent implements BroadcastEvent {
43
+ /** Channel name(s) to broadcast on. Use channel()/privateChannel()/presenceChannel(). */
44
+ abstract broadcastOn(): string | string[];
45
+
46
+ /** Wire event name. Defaults to the class name. */
47
+ broadcastAs(): string {
48
+ return this.constructor.name;
49
+ }
50
+
51
+ /**
52
+ * Payload to broadcast. Defaults to the event's own enumerable, non-function properties
53
+ * (so constructor-assigned fields are sent automatically). Override for a custom shape.
54
+ */
55
+ broadcastWith(): object {
56
+ const out: Record<string, unknown> = {};
57
+ for (const [key, value] of Object.entries(this)) {
58
+ if (typeof value !== "function") out[key] = value;
59
+ }
60
+ return out;
61
+ }
62
+
63
+ /** Conditional gate — the event broadcasts only when this returns true. Default: true. */
64
+ broadcastWhen(): boolean {
65
+ return true;
66
+ }
67
+
68
+ /**
69
+ * Construct the event from the given arguments, fire it on the application event bus (so any
70
+ * registered listeners run), and broadcast it (gated by `broadcastWhen()`).
71
+ *
72
+ * @example OrderShipmentStatusUpdated.dispatch(order);
73
+ */
74
+ static dispatch<T extends BroadcastingEvent, A extends unknown[]>(
75
+ this: new (...args: A) => T,
76
+ ...args: A
77
+ ): T {
78
+ const event = new this(...args);
79
+ // Fire app listeners if an event bus is bound (its hook may broadcast). Broadcasting still
80
+ // works when no bus is bound (e.g. outside a booted app) via the explicit broadcastOnce below.
81
+ try {
82
+ void Events.emit(event as object);
83
+ } catch {
84
+ /* no "events" binding */
85
+ }
86
+ broadcastOnce(event); // no-op if the Events hook already broadcast it
87
+ return event;
88
+ }
89
+ }
@@ -0,0 +1,93 @@
1
+ import { Str } from "@zerotal/core";
2
+ import { BroadcastingEvent } from "./BroadcastingEvent.ts";
3
+
4
+ /** The model lifecycle events that can be auto-broadcast. */
5
+ export type ModelBroadcastEventName = "created" | "updated" | "deleted";
6
+
7
+ export interface BroadcastsModelEventsOptions<M> {
8
+ /** Which lifecycle events to broadcast. Default: `created`, `updated`, `deleted`. */
9
+ events?: ModelBroadcastEventName[];
10
+ /** The channel(s) to broadcast on for a given model instance + event. */
11
+ channels: (model: M, event: ModelBroadcastEventName) => string | string[];
12
+ /** The wire event name. Default: `${ModelName}${Event}`, e.g. `OrderUpdated`. */
13
+ as?: (modelName: string, event: ModelBroadcastEventName) => string;
14
+ /** The wire payload. Default: `{ [camelModelName]: model }`, e.g. `{ order }`. */
15
+ with?: (model: M, event: ModelBroadcastEventName) => object;
16
+ }
17
+
18
+ /** Minimal shape of an ORM model class with the `dispatchesEvents` bridge. */
19
+ type ModelClassWithEvents<M> = (new (...args: never[]) => M) & {
20
+ name: string;
21
+ dispatchesEvents?: Record<string, new (model: unknown) => object>;
22
+ };
23
+
24
+ /**
25
+ * Auto-broadcast a model's lifecycle events.
26
+ *
27
+ * It populates the model's `dispatchesEvents` map with generated `BroadcastingEvent` subclasses,
28
+ * reusing the existing model-event bridge: when the model is created/updated/deleted, the event
29
+ * is fired on the application `Events` bus (so listeners run) and broadcast (so connected clients
30
+ * receive it). Call it once — at the bottom of the model file or in a provider's boot.
31
+ *
32
+ * @example
33
+ * // app/models/Order.ts
34
+ * import { BaseModel, column, table } from "@zerotal/orm";
35
+ * import { broadcastsModelEvents, privateChannel } from "@zerotal/broadcasting";
36
+ *
37
+ * @table("orders")
38
+ * export class Order extends BaseModel {
39
+ * @column() id!: number;
40
+ * @column() status!: string;
41
+ * }
42
+ *
43
+ * broadcastsModelEvents(Order, {
44
+ * channels: (order) => privateChannel(`orders.${order.id}`),
45
+ * // optional: with: (order) => ({ id: order.id, status: order.status }),
46
+ * });
47
+ *
48
+ * // Client: Echo.private(`orders.${id}`).listen("OrderUpdated", (e) => ...)
49
+ */
50
+ export function broadcastsModelEvents<M extends object>(
51
+ ModelClass: ModelClassWithEvents<M>,
52
+ options: BroadcastsModelEventsOptions<M>,
53
+ ): void {
54
+ const events = options.events ?? ["created", "updated", "deleted"];
55
+ const modelName = ModelClass.name;
56
+ const modelKey = Str.camelCase(modelName);
57
+ const channelsFn = options.channels;
58
+ const withFn = options.with;
59
+ const asFn = options.as;
60
+
61
+ const map: Record<string, new (model: unknown) => object> = {
62
+ ...(ModelClass.dispatchesEvents ?? {}),
63
+ };
64
+
65
+ for (const event of events) {
66
+ const wireName = asFn ? asFn(modelName, event) : `${modelName}${capitalize(event)}`;
67
+
68
+ class ModelBroadcast extends BroadcastingEvent {
69
+ readonly model: M;
70
+ constructor(model: M) {
71
+ super();
72
+ this.model = model;
73
+ }
74
+ broadcastOn(): string | string[] {
75
+ return channelsFn(this.model, event);
76
+ }
77
+ broadcastAs(): string {
78
+ return wireName;
79
+ }
80
+ broadcastWith(): object {
81
+ return withFn ? withFn(this.model, event) : { [modelKey]: this.model };
82
+ }
83
+ }
84
+
85
+ map[event] = ModelBroadcast as unknown as new (model: unknown) => object;
86
+ }
87
+
88
+ ModelClass.dispatchesEvents = map;
89
+ }
90
+
91
+ function capitalize(s: string): string {
92
+ return s.charAt(0).toUpperCase() + s.slice(1);
93
+ }
package/src/Channel.ts ADDED
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Channel name helpers — channel(), privateChannel(), and presenceChannel().
3
+ *
4
+ * @example
5
+ * broadcastOn() {
6
+ * return [
7
+ * channel('posts'), // public — anyone can subscribe
8
+ * privateChannel('user.42'), // private — requires auth
9
+ * presenceChannel('chat.room'), // presence — tracks members
10
+ * ];
11
+ * }
12
+ */
13
+
14
+ /** Public channel — no authentication required. */
15
+ export function channel(name: string): string {
16
+ return name;
17
+ }
18
+
19
+ /** Private channel — subscriber must be authenticated. Name is prefixed with `private-`. */
20
+ export function privateChannel(name: string): string {
21
+ return name.startsWith("private-") ? name : `private-${name}`;
22
+ }
23
+
24
+ /** Presence channel — tracks who is subscribed. Prefixed with `presence-`. */
25
+ export function presenceChannel(name: string): string {
26
+ return name.startsWith("presence-") ? name : `presence-${name}`;
27
+ }
28
+
29
+ /** Returns true if the channel name indicates a private channel. */
30
+ export function isPrivateChannel(name: string): boolean {
31
+ return name.startsWith("private-") || name.startsWith("presence-");
32
+ }