@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,243 @@
|
|
|
1
|
+
import { ServiceProvider, Router, type Application, type Emitter } from "@zerotal/core";
|
|
2
|
+
import type { AppEnvironment } from "@zerotal/core";
|
|
3
|
+
import type { ConfigManager } from "@zerotal/core/config";
|
|
4
|
+
import type { HttpContext } from "@zerotal/core";
|
|
5
|
+
import { BroadcastManager } from "../BroadcastManager.ts";
|
|
6
|
+
import { PusherCompatManager } from "../PusherCompatManager.ts";
|
|
7
|
+
import { RedisBroadcastDriver } from "../RedisBroadcastDriver.ts";
|
|
8
|
+
import { channelRegistry } from "../ChannelRegistry.ts";
|
|
9
|
+
import type { PresenceMemberData } from "../ChannelRegistry.ts";
|
|
10
|
+
import { broadcastOnce } from "../BroadcastingEvent.ts";
|
|
11
|
+
import type { BroadcastEvent } from "../types.ts";
|
|
12
|
+
import { BroadcastConfig, validateBroadcastConfig } from "../config.ts";
|
|
13
|
+
import type { BroadcastConfigShape } from "../config.ts";
|
|
14
|
+
import { frameworkLog } from "@zerotal/core/logger";
|
|
15
|
+
|
|
16
|
+
declare module "@zerotal/core" {
|
|
17
|
+
interface ContainerBindings {
|
|
18
|
+
broadcast: BroadcastManager;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class BroadcastProvider extends ServiceProvider {
|
|
23
|
+
static override provides = ["broadcast"] as const;
|
|
24
|
+
static override environments: AppEnvironment[] = ["web", "worker", "test", "console"];
|
|
25
|
+
|
|
26
|
+
private _redisDriver: RedisBroadcastDriver | undefined;
|
|
27
|
+
|
|
28
|
+
override onRegister(): void {
|
|
29
|
+
// Refuse a production boot when the configured driver is missing the
|
|
30
|
+
// credentials it cannot work without. Runs in the boot-time config pass.
|
|
31
|
+
this.app.registerConfigValidator?.("broadcasting", validateBroadcastConfig);
|
|
32
|
+
|
|
33
|
+
// The concrete manager must exist at registration time because the
|
|
34
|
+
// WebSocket handlers and the Pusher auth route below are wired from it —
|
|
35
|
+
// so config is resolved here rather than inside a lazy singleton closure.
|
|
36
|
+
const configManager = this.app.container.tryMake("config") as ConfigManager | null;
|
|
37
|
+
const raw = configManager?.get<Partial<BroadcastConfigShape>>("broadcasting") ?? {};
|
|
38
|
+
const cfg = BroadcastConfig(raw);
|
|
39
|
+
|
|
40
|
+
let manager: BroadcastManager;
|
|
41
|
+
|
|
42
|
+
if (cfg.driver === "redis") {
|
|
43
|
+
const url = cfg.redis?.url ?? "redis://localhost:6379";
|
|
44
|
+
const driver = new RedisBroadcastDriver(url);
|
|
45
|
+
this._redisDriver = driver;
|
|
46
|
+
manager = driver;
|
|
47
|
+
} else if (cfg.driver === "pusher") {
|
|
48
|
+
const { appKey = "", appSecret = "" } = cfg.pusher ?? {};
|
|
49
|
+
manager = new PusherCompatManager(appKey, appSecret);
|
|
50
|
+
} else {
|
|
51
|
+
manager = new BroadcastManager();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
this.app.container.value("broadcast", manager);
|
|
55
|
+
|
|
56
|
+
if (cfg.driver === "ws" || cfg.driver === "redis" || cfg.driver === "pusher") {
|
|
57
|
+
// Register at the configured path so this coexists with flow's `/__flow/ws` (multiplexed
|
|
58
|
+
// by path). Pusher clients connect to a dynamic `/app/{APP_KEY}`, so register it catch-all.
|
|
59
|
+
(this.app as unknown as Application).withWebSocket(
|
|
60
|
+
manager.wsHandlers,
|
|
61
|
+
(req) => manager.upgradeData(req),
|
|
62
|
+
cfg.driver === "pusher" ? undefined : cfg.path,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (cfg.driver === "pusher") {
|
|
67
|
+
const pusher = manager as PusherCompatManager;
|
|
68
|
+
Router.post("/broadcasting/auth", _makePusherAuthController(pusher), "handle");
|
|
69
|
+
} else if (cfg.driver === "ws" || cfg.driver === "redis") {
|
|
70
|
+
// Native drivers: sign per-subscription auth tokens with the app's APP_KEY, and expose
|
|
71
|
+
// the same `/broadcasting/auth` endpoint so the first-party `Socket` client can fetch a
|
|
72
|
+
// Pusher-style signature for private/presence channels.
|
|
73
|
+
const secret = configManager?.get<string>("app.key") ?? "";
|
|
74
|
+
manager.setAuthSecret(secret);
|
|
75
|
+
Router.post("/broadcasting/auth", _makeNativeAuthController(manager), "handle");
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
override async onBooting(): Promise<void> {
|
|
80
|
+
await this._redisDriver?.boot();
|
|
81
|
+
|
|
82
|
+
// Auto-broadcast: any BroadcastingEvent emitted on this app's Events bus is
|
|
83
|
+
// broadcast too. The hook lives on the app's own emitter, so it stays scoped
|
|
84
|
+
// per application.
|
|
85
|
+
const emitter = (await this.app.container.make("events")) as Emitter;
|
|
86
|
+
emitter.setBroadcaster((event: object) => broadcastOnce(event as BroadcastEvent));
|
|
87
|
+
|
|
88
|
+
await this._loadChannelRoutes();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
override async onBooted(): Promise<void> {
|
|
92
|
+
const runner = this.app.container.tryMake("commands");
|
|
93
|
+
if (!runner) return;
|
|
94
|
+
runner.registerLazy("channel:list", () =>
|
|
95
|
+
import("../commands/ChannelListCommand.ts").then((m) => m.ChannelListCommand),
|
|
96
|
+
);
|
|
97
|
+
runner.registerLazy("make:channel", () =>
|
|
98
|
+
import("../commands/MakeChannelCommand.ts").then((m) => m.MakeChannelCommand),
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Side-effect import of `routes/channels.ts` so its `Broadcast.channel(...)` authorization
|
|
104
|
+
* rules register before the first `/broadcasting/auth` request. Missing file is fine.
|
|
105
|
+
*/
|
|
106
|
+
private async _loadChannelRoutes(): Promise<void> {
|
|
107
|
+
const path = `${process.cwd()}/routes/channels.ts`;
|
|
108
|
+
try {
|
|
109
|
+
if (await Bun.file(path).exists()) await import(path);
|
|
110
|
+
} catch (err) {
|
|
111
|
+
frameworkLog("broadcast").error("Failed to load routes/channels.ts", undefined, err);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
override async onStopping(): Promise<void> {
|
|
116
|
+
await this._redisDriver?.stop();
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ── Auth controller factories ─────────────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Native (`ws` / `redis`) auth endpoint. Mirrors the Pusher flow: authorize via the
|
|
124
|
+
* `routes/channels.ts` rules, then return an HMAC signature (and, for presence, the signed
|
|
125
|
+
* `channel_data`) the `Socket` client echoes in its `subscribe` message.
|
|
126
|
+
*/
|
|
127
|
+
function _makeNativeAuthController(manager: BroadcastManager) {
|
|
128
|
+
return class NativeBroadcastAuthController {
|
|
129
|
+
async handle(http: HttpContext): Promise<void> {
|
|
130
|
+
const { socket_id: socketId, channel_name: channelName } = await _parseAuthBody(http.request);
|
|
131
|
+
|
|
132
|
+
if (!socketId || !channelName) {
|
|
133
|
+
http.json({ error: "socket_id and channel_name are required" }, 400);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const auth = await channelRegistry.authorize(
|
|
138
|
+
channelName,
|
|
139
|
+
(http as unknown as { user?: unknown }).user,
|
|
140
|
+
);
|
|
141
|
+
if (!auth.matched) {
|
|
142
|
+
http.json({ error: `No authorization rule registered for channel "${channelName}".` }, 403);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (channelName.startsWith("presence-")) {
|
|
147
|
+
const member = auth.result;
|
|
148
|
+
if (!member || typeof member !== "object") {
|
|
149
|
+
http.json({ error: "Unauthorized" }, 403);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
const data = member as PresenceMemberData;
|
|
153
|
+
// Native presence member shape is `{ id, info }`; the whole member object is the info.
|
|
154
|
+
const channelData = JSON.stringify({ id: data.id, info: data });
|
|
155
|
+
http.json({
|
|
156
|
+
auth: manager.signAuth(socketId, channelName, channelData),
|
|
157
|
+
channel_data: channelData,
|
|
158
|
+
});
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Private channel — authorizer returns a boolean.
|
|
163
|
+
if (!auth.result) {
|
|
164
|
+
http.json({ error: "Unauthorized" }, 403);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
http.json({ auth: manager.signAuth(socketId, channelName) });
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function _makePusherAuthController(manager: PusherCompatManager) {
|
|
173
|
+
return class PusherAuthController {
|
|
174
|
+
async handle(http: HttpContext): Promise<void> {
|
|
175
|
+
const { socket_id: socketId, channel_name: channelName } = await _parseAuthBody(http.request);
|
|
176
|
+
|
|
177
|
+
if (!socketId || !channelName) {
|
|
178
|
+
http.json({ error: "socket_id and channel_name are required" }, 400);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Authorize via the routes/channels.ts rules. The authenticated user is whatever the
|
|
183
|
+
// app's auth middleware put on the request (undefined for guests -> denied).
|
|
184
|
+
const auth = await channelRegistry.authorize(
|
|
185
|
+
channelName,
|
|
186
|
+
(http as unknown as { user?: unknown }).user,
|
|
187
|
+
);
|
|
188
|
+
if (!auth.matched) {
|
|
189
|
+
http.json({ error: `No authorization rule registered for channel "${channelName}".` }, 403);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (channelName.startsWith("presence-")) {
|
|
194
|
+
const member = auth.result;
|
|
195
|
+
if (!member || typeof member !== "object") {
|
|
196
|
+
http.json({ error: "Unauthorized" }, 403);
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
const data = member as PresenceMemberData;
|
|
200
|
+
const channelData = JSON.stringify({ user_id: data.id, user_info: data });
|
|
201
|
+
http.json({
|
|
202
|
+
auth: manager.signAuth(socketId, channelName, channelData),
|
|
203
|
+
channel_data: channelData,
|
|
204
|
+
});
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Private channel — authorizer returns a boolean.
|
|
209
|
+
if (!auth.result) {
|
|
210
|
+
http.json({ error: "Unauthorized" }, 403);
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
http.json({ auth: manager.signAuth(socketId, channelName) });
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Parse socket_id / channel_name from JSON or form-encoded body. */
|
|
219
|
+
async function _parseAuthBody(
|
|
220
|
+
req: Request,
|
|
221
|
+
): Promise<{ socket_id?: string; channel_name?: string }> {
|
|
222
|
+
const ct = req.headers.get("content-type") ?? "";
|
|
223
|
+
|
|
224
|
+
if (ct.includes("application/json")) {
|
|
225
|
+
try {
|
|
226
|
+
return (await req.clone().json()) as { socket_id?: string; channel_name?: string };
|
|
227
|
+
} catch {
|
|
228
|
+
return {};
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
try {
|
|
233
|
+
const fd = await req.clone().formData();
|
|
234
|
+
const result: { socket_id?: string; channel_name?: string } = {};
|
|
235
|
+
const sid = fd.get("socket_id");
|
|
236
|
+
const cname = fd.get("channel_name");
|
|
237
|
+
if (sid !== null) result.socket_id = sid as string;
|
|
238
|
+
if (cname !== null) result.channel_name = cname as string;
|
|
239
|
+
return result;
|
|
240
|
+
} catch {
|
|
241
|
+
return {};
|
|
242
|
+
}
|
|
243
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// ── BroadcastEvent ────────────────────────────────────────────────────────────
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Implement this interface on any event class to make it broadcastable.
|
|
5
|
+
*
|
|
6
|
+
* @example
|
|
7
|
+
* export class PostUpdated implements BroadcastEvent {
|
|
8
|
+
* constructor(private post: Post) {}
|
|
9
|
+
*
|
|
10
|
+
* broadcastOn() { return channel('posts'); }
|
|
11
|
+
* broadcastAs() { return 'PostUpdated'; }
|
|
12
|
+
* broadcastWith() { return { id: this.post.id, title: this.post.title }; }
|
|
13
|
+
* }
|
|
14
|
+
*
|
|
15
|
+
* // Dispatch
|
|
16
|
+
* Broadcast.send(new PostUpdated(post));
|
|
17
|
+
*/
|
|
18
|
+
export interface BroadcastEvent {
|
|
19
|
+
/** Channel name(s) to broadcast on. Use channel(), privateChannel(), or presenceChannel(). */
|
|
20
|
+
broadcastOn(): string | string[];
|
|
21
|
+
/** Optional — defaults to the class constructor name. */
|
|
22
|
+
broadcastAs?(): string;
|
|
23
|
+
/** Data to include in the broadcast payload. Defaults to `{}`. */
|
|
24
|
+
broadcastWith?(): object;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// ── WebSocket data attached to each connection ────────────────────────────────
|
|
28
|
+
|
|
29
|
+
export interface WsConnectionData {
|
|
30
|
+
/** Unique connection ID assigned on upgrade. */
|
|
31
|
+
id: string;
|
|
32
|
+
/** User ID if the connection was authenticated during upgrade. */
|
|
33
|
+
userId?: string | number;
|
|
34
|
+
/** Raw auth token from the upgrade request (for lazy auth). */
|
|
35
|
+
token?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ── Protocol messages ─────────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
export interface SubscribeMessage {
|
|
41
|
+
event: "subscribe";
|
|
42
|
+
channel: string;
|
|
43
|
+
/**
|
|
44
|
+
* Per-subscription HMAC signature for private/presence channels (Pusher-style), obtained
|
|
45
|
+
* from `POST /broadcasting/auth`. When present, the server verifies it instead of running
|
|
46
|
+
* the connection-level authorize callback.
|
|
47
|
+
*/
|
|
48
|
+
auth?: string;
|
|
49
|
+
/**
|
|
50
|
+
* Signed presence member data (a JSON string) returned alongside `auth` by the auth
|
|
51
|
+
* endpoint. Its exact bytes are covered by `auth`, so the server trusts it as the member.
|
|
52
|
+
*/
|
|
53
|
+
channelData?: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface UnsubscribeMessage {
|
|
57
|
+
event: "unsubscribe";
|
|
58
|
+
channel: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface PingMessage {
|
|
62
|
+
event: "ping";
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export type ClientMessage = SubscribeMessage | UnsubscribeMessage | PingMessage;
|
|
66
|
+
|
|
67
|
+
export interface ServerMessage {
|
|
68
|
+
event: string;
|
|
69
|
+
channel?: string;
|
|
70
|
+
data?: unknown;
|
|
71
|
+
message?: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ── Channel auth callback ─────────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
export type ChannelAuthFn = (
|
|
77
|
+
channel: string,
|
|
78
|
+
ws: ServerWebSocket<WsConnectionData>,
|
|
79
|
+
) => boolean | Promise<boolean>;
|