@zerotal/broadcasting 1.7.0 → 1.7.3
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/api-surface.md +19 -11
- package/package.json +2 -2
- package/src/BroadcastManager.ts +8 -2
- package/src/BroadcastsModelEvents.ts +1 -1
- package/src/ChannelRegistry.ts +17 -4
- package/src/PusherCompatManager.ts +3 -2
- package/src/config.ts +13 -0
- package/src/currentSocketId.ts +1 -1
- package/src/facades/Broadcast.ts +1 -1
- package/src/index.ts +3 -0
- package/src/provider/BroadcastProvider.ts +17 -2
- package/src/types.ts +2 -0
- package/src/global.d.ts +0 -10
package/api-surface.md
CHANGED
|
@@ -16,7 +16,7 @@ class AnonymousBroadcast = {
|
|
|
16
16
|
|
|
17
17
|
class Broadcast = {
|
|
18
18
|
new (): Broadcast
|
|
19
|
-
static channel: (pattern: string, callback: ChannelCallback) => void
|
|
19
|
+
static channel: <User = unknown>(pattern: string, callback: ChannelCallback<User>) => void
|
|
20
20
|
static channels: () => { pattern: string; paramNames: string[];}[]
|
|
21
21
|
static fake: () => BroadcastFake
|
|
22
22
|
static getMembers: (channel: string) => PresenceMember[]
|
|
@@ -109,7 +109,7 @@ class ChannelRegistry = {
|
|
|
109
109
|
all: () => { pattern: string; paramNames: string[];}[]
|
|
110
110
|
authorize: (channelName: string, user: unknown) => Promise<AuthorizeResult>
|
|
111
111
|
clear: () => void
|
|
112
|
-
register: (pattern: string, callback: ChannelCallback) => void
|
|
112
|
+
register: <User = unknown>(pattern: string, callback: ChannelCallback<User>) => void
|
|
113
113
|
}
|
|
114
114
|
|
|
115
115
|
class MissingChannelParameterError = {
|
|
@@ -155,9 +155,9 @@ class RedisBroadcastDriver = {
|
|
|
155
155
|
boot: () => Promise<void>
|
|
156
156
|
connectionCount: () => number
|
|
157
157
|
getMembers: (channel: string) => PresenceMember[]
|
|
158
|
-
handleClose: (ws: ServerWebSocket<WsConnectionData>) => void
|
|
159
|
-
handleMessage: (ws: ServerWebSocket<WsConnectionData>, raw: string | Uint8Array) => Promise<void>
|
|
160
|
-
handleOpen: (ws: ServerWebSocket<WsConnectionData>) => void
|
|
158
|
+
handleClose: (ws: Bun.ServerWebSocket<WsConnectionData>) => void
|
|
159
|
+
handleMessage: (ws: Bun.ServerWebSocket<WsConnectionData>, raw: string | Uint8Array) => Promise<void>
|
|
160
|
+
handleOpen: (ws: Bun.ServerWebSocket<WsConnectionData>) => void
|
|
161
161
|
send: (event: BroadcastEvent, opts?: { exceptSocketId?: string;}) => void
|
|
162
162
|
setAuthSecret: (secret: string) => void
|
|
163
163
|
signAuth: (socketId: string, channel: string, channelData?: string) => string
|
|
@@ -167,7 +167,7 @@ class RedisBroadcastDriver = {
|
|
|
167
167
|
to: (channel: string, event: string, data?: unknown, opts?: { exceptSocketId?: string;}) => void
|
|
168
168
|
upgradeData: (req: Request) => Record<string, unknown>
|
|
169
169
|
verifyAuth: (socketId: string, channel: string, auth: string, channelData?: string) => boolean
|
|
170
|
-
wsHandlers: { open: (ws: ServerWebSocket<WsConnectionData>) => void; message: (ws: ServerWebSocket<WsConnectionData>, msg: string | Uint8Array) => undefined; close: (ws: ServerWebSocket<WsConnectionData>) => void;}
|
|
170
|
+
wsHandlers: { open: (ws: Bun.ServerWebSocket<WsConnectionData>) => void; message: (ws: Bun.ServerWebSocket<WsConnectionData>, msg: string | Uint8Array) => undefined; close: (ws: Bun.ServerWebSocket<WsConnectionData>) => void;}
|
|
171
171
|
}
|
|
172
172
|
|
|
173
173
|
class TypedBroadcastManager = {
|
|
@@ -176,9 +176,9 @@ class TypedBroadcastManager = {
|
|
|
176
176
|
authorizeWith: (fn: ChannelAuthFn) => void
|
|
177
177
|
connectionCount: () => number
|
|
178
178
|
getMembers: (channel: string) => PresenceMember[]
|
|
179
|
-
handleClose: (ws: ServerWebSocket<WsConnectionData>) => void
|
|
180
|
-
handleMessage: (ws: ServerWebSocket<WsConnectionData>, raw: string | Uint8Array) => Promise<void>
|
|
181
|
-
handleOpen: (ws: ServerWebSocket<WsConnectionData>) => void
|
|
179
|
+
handleClose: (ws: Bun.ServerWebSocket<WsConnectionData>) => void
|
|
180
|
+
handleMessage: (ws: Bun.ServerWebSocket<WsConnectionData>, raw: string | Uint8Array) => Promise<void>
|
|
181
|
+
handleOpen: (ws: Bun.ServerWebSocket<WsConnectionData>) => void
|
|
182
182
|
send: (event: TypedBroadcastEvent<Channels, keyof Channels & string> | BroadcastEvent) => void
|
|
183
183
|
setAuthSecret: (secret: string) => void
|
|
184
184
|
signAuth: (socketId: string, channel: string, channelData?: string) => string
|
|
@@ -188,7 +188,7 @@ class TypedBroadcastManager = {
|
|
|
188
188
|
toChannel: <Pattern extends ParameterizedChannels<Channels>, Ev extends EventsOf<Channels, Pattern>>(pattern: Pattern, params: ChannelParamRecord<Pattern>, event: Ev, data: PayloadOf<Channels, Pattern, Ev>) => void
|
|
189
189
|
upgradeData: (req: Request) => Record<string, unknown>
|
|
190
190
|
verifyAuth: (socketId: string, channel: string, auth: string, channelData?: string) => boolean
|
|
191
|
-
wsHandlers: { open: (ws: ServerWebSocket<WsConnectionData>) => void; message: (ws: ServerWebSocket<WsConnectionData>, msg: string | Uint8Array) => undefined; close: (ws: ServerWebSocket<WsConnectionData>) => void;}
|
|
191
|
+
wsHandlers: { open: (ws: Bun.ServerWebSocket<WsConnectionData>) => void; message: (ws: Bun.ServerWebSocket<WsConnectionData>, msg: string | Uint8Array) => undefined; close: (ws: Bun.ServerWebSocket<WsConnectionData>) => void;}
|
|
192
192
|
}
|
|
193
193
|
|
|
194
194
|
const channelRegistry = ChannelRegistry
|
|
@@ -211,6 +211,14 @@ function presenceChannel = (name: string) => string
|
|
|
211
211
|
|
|
212
212
|
function privateChannel = (name: string) => string
|
|
213
213
|
|
|
214
|
+
interface BroadcastConfigShape = {
|
|
215
|
+
channels?: string
|
|
216
|
+
driver: 'null' | 'ws' | 'redis' | 'pusher'
|
|
217
|
+
path: string
|
|
218
|
+
pusher?: { appKey: string; appSecret: string;}
|
|
219
|
+
redis?: { url: string;}
|
|
220
|
+
}
|
|
221
|
+
|
|
214
222
|
interface BroadcastEvent = {
|
|
215
223
|
broadcastAs?: () => string
|
|
216
224
|
broadcastOn: () => string | string[]
|
|
@@ -264,7 +272,7 @@ type BroadcastChannelMap = { [x: string]: Record<string, object>;}
|
|
|
264
272
|
|
|
265
273
|
type ChannelAuthFn = (channel: string, ws: ServerWebSocket<WsConnectionData>) => boolean | Promise<boolean>
|
|
266
274
|
|
|
267
|
-
type ChannelCallback = (user:
|
|
275
|
+
type ChannelCallback = (user: User, ...params: string[]) => boolean | PresenceMemberData | null | undefined | Promise<boolean | PresenceMemberData | null | undefined>
|
|
268
276
|
|
|
269
277
|
type ChannelParamRecord = [ChannelParams<Pattern>] extends [never] ? undefined : { [K in ChannelParams<Pattern>]: string | number; }
|
|
270
278
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zerotal/broadcasting",
|
|
3
|
-
"version": "1.7.
|
|
3
|
+
"version": "1.7.3",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"maturity": "stable",
|
|
6
6
|
"private": false,
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"typecheck": "tsc --noEmit"
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
|
-
"@zerotal/core": "1.7.
|
|
33
|
+
"@zerotal/core": "1.7.3"
|
|
34
34
|
},
|
|
35
35
|
"devDependencies": {
|
|
36
36
|
"typescript": "^5.8.0"
|
package/src/BroadcastManager.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ServerWebSocket } from "bun";
|
|
1
2
|
import { safeEqual, hmacHex } from "@zerotal/core";
|
|
2
3
|
import { isPrivateChannel } from "./Channel.ts";
|
|
3
4
|
import type { BroadcastEvent, WsConnectionData, ClientMessage, ChannelAuthFn } from "./types.ts";
|
|
@@ -290,8 +291,13 @@ export class BroadcastManager {
|
|
|
290
291
|
channelData?: string,
|
|
291
292
|
): Promise<void> {
|
|
292
293
|
const isPresence = channel.startsWith("presence-");
|
|
293
|
-
|
|
294
|
+
// Block body, not an expression body: `ServerWebSocket.send` returns a send
|
|
295
|
+
// status, and `return deny()` in a `Promise<void>` method would otherwise
|
|
296
|
+
// try to return that number. The status is not useful here — a client that
|
|
297
|
+
// failed to receive its own denial is already gone.
|
|
298
|
+
const deny = (): void => {
|
|
294
299
|
ws.send(JSON.stringify({ event: "subscription_error", channel, message: "Unauthorized" }));
|
|
300
|
+
};
|
|
295
301
|
|
|
296
302
|
if (isPresence) {
|
|
297
303
|
// Two ways to authorize a presence subscription:
|
|
@@ -352,7 +358,7 @@ export class BroadcastManager {
|
|
|
352
358
|
|
|
353
359
|
private _unsubscribe(ws: WS, channel: string): void {
|
|
354
360
|
this._subs.get(channel)?.delete(ws.data.id);
|
|
355
|
-
// Presence: an explicit leave (e.g.
|
|
361
|
+
// Presence: an explicit leave (e.g. Socket.leave on a component teardown / SPA navigation) must
|
|
356
362
|
// remove the member and notify the remaining subscribers — the same cleanup a full disconnect
|
|
357
363
|
// does in handleClose. Without this, a member who navigated away lingers in others' "who's here"
|
|
358
364
|
// until they close the tab.
|
|
@@ -45,7 +45,7 @@ type ModelClassWithEvents<M> = (new (...args: never[]) => M) & {
|
|
|
45
45
|
* // optional: with: (order) => ({ id: order.id, status: order.status }),
|
|
46
46
|
* });
|
|
47
47
|
*
|
|
48
|
-
* // Client:
|
|
48
|
+
* // Client: Socket.private(`orders.${id}`).listen("OrderUpdated", (e) => ...)
|
|
49
49
|
*/
|
|
50
50
|
export function broadcastsModelEvents<M extends object>(
|
|
51
51
|
ModelClass: ModelClassWithEvents<M>,
|
package/src/ChannelRegistry.ts
CHANGED
|
@@ -21,8 +21,19 @@ export interface PresenceMemberData {
|
|
|
21
21
|
* - Presence channel: return a member-data object to authorize + publish presence, or
|
|
22
22
|
* `false`/`null`/`undefined` to deny.
|
|
23
23
|
*/
|
|
24
|
-
|
|
25
|
-
|
|
24
|
+
/**
|
|
25
|
+
* `User` is a type parameter so a rule can annotate the user it expects:
|
|
26
|
+
*
|
|
27
|
+
* ```ts
|
|
28
|
+
* Broadcast.channel("orders.[orderId]", (user: User, orderId: string) => …);
|
|
29
|
+
* ```
|
|
30
|
+
*
|
|
31
|
+
* infers `User` from the callback rather than failing against a fixed `unknown`.
|
|
32
|
+
* The default keeps every existing rule — and the registry's own storage, which
|
|
33
|
+
* cannot know the app's user model — working unchanged.
|
|
34
|
+
*/
|
|
35
|
+
export type ChannelCallback<User = unknown> = (
|
|
36
|
+
user: User,
|
|
26
37
|
...params: string[]
|
|
27
38
|
) =>
|
|
28
39
|
| boolean
|
|
@@ -77,9 +88,11 @@ export class ChannelRegistry {
|
|
|
77
88
|
* @example
|
|
78
89
|
* registry.register("orders.[orderId]", (user, orderId) => user.id === ownerOf(orderId));
|
|
79
90
|
*/
|
|
80
|
-
register(pattern: string, callback: ChannelCallback): void {
|
|
91
|
+
register<User = unknown>(pattern: string, callback: ChannelCallback<User>): void {
|
|
81
92
|
const { regex, paramNames } = compileChannelPattern(pattern);
|
|
82
|
-
|
|
93
|
+
// Stored as the unknown-user form: the registry calls every rule with whatever
|
|
94
|
+
// the auth middleware produced, which it has no way to type.
|
|
95
|
+
this._channels.push({ pattern, regex, paramNames, callback: callback as ChannelCallback });
|
|
83
96
|
}
|
|
84
97
|
|
|
85
98
|
/** Registered channel patterns (for `channel:list` / introspection). */
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ServerWebSocket } from "bun";
|
|
1
2
|
import { safeEqual, hmacHex } from "@zerotal/core";
|
|
2
3
|
import { BroadcastManager, _isValidChannel } from "./BroadcastManager.ts";
|
|
3
4
|
import type { PresenceMember } from "./BroadcastManager.ts";
|
|
@@ -44,9 +45,9 @@ export type PusherPresenceResolver = (
|
|
|
44
45
|
* - Auth for private/presence channels uses HMAC-SHA256 signatures
|
|
45
46
|
*
|
|
46
47
|
* Auth flow:
|
|
47
|
-
* 1.
|
|
48
|
+
* 1. The client calls POST /broadcasting/auth with socket_id + channel_name
|
|
48
49
|
* 2. Server signs with `signAuth()` and returns `{auth: "key:sig"}`
|
|
49
|
-
* 3.
|
|
50
|
+
* 3. The client includes `auth` in the pusher:subscribe message
|
|
50
51
|
* 4. Manager verifies HMAC before allowing subscription
|
|
51
52
|
*
|
|
52
53
|
* @example
|
package/src/config.ts
CHANGED
|
@@ -31,6 +31,19 @@ export interface BroadcastConfigShape {
|
|
|
31
31
|
appKey: string;
|
|
32
32
|
appSecret: string;
|
|
33
33
|
};
|
|
34
|
+
/**
|
|
35
|
+
* Where the `Broadcast.channel(...)` authorization rules live, relative to the
|
|
36
|
+
* project root (or absolute).
|
|
37
|
+
*
|
|
38
|
+
* Defaults to `routes/channels.ts`. Set it when the app keeps its routes
|
|
39
|
+
* somewhere else — an app that scaffolded its HTTP routes into `app/routes`
|
|
40
|
+
* would otherwise grow a second, unrelated `routes/` directory holding one
|
|
41
|
+
* file.
|
|
42
|
+
*
|
|
43
|
+
* @example
|
|
44
|
+
* channels: "app/routes/channels.ts"
|
|
45
|
+
*/
|
|
46
|
+
channels?: string;
|
|
34
47
|
}
|
|
35
48
|
|
|
36
49
|
const defaults: BroadcastConfigShape = {
|
package/src/currentSocketId.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { RequestContext } from "@zerotal/core";
|
|
2
2
|
|
|
3
|
-
/** Read the client's
|
|
3
|
+
/** Read the client's socket id from the current request's `X-Socket-ID` header, if any. */
|
|
4
4
|
export function currentSocketId(): string | undefined {
|
|
5
5
|
return RequestContext.tryGet()?.header("x-socket-id") ?? undefined;
|
|
6
6
|
}
|
package/src/facades/Broadcast.ts
CHANGED
|
@@ -58,7 +58,7 @@ export class Broadcast {
|
|
|
58
58
|
* return user.canJoin(roomId) ? { id: user.id, name: user.name } : null;
|
|
59
59
|
* });
|
|
60
60
|
*/
|
|
61
|
-
static channel(pattern: string, callback: ChannelCallback): void {
|
|
61
|
+
static channel<User = unknown>(pattern: string, callback: ChannelCallback<User>): void {
|
|
62
62
|
channelRegistry.register(pattern, callback);
|
|
63
63
|
}
|
|
64
64
|
|
package/src/index.ts
CHANGED
|
@@ -18,6 +18,9 @@ export { BroadcastFake } from "./BroadcastFake.ts";
|
|
|
18
18
|
export { Broadcast } from "./facades/Broadcast.ts";
|
|
19
19
|
export { BroadcastProvider } from "./provider/BroadcastProvider.ts";
|
|
20
20
|
export { BroadcastConfig } from "./config.ts";
|
|
21
|
+
// Exported so an app can cast an `env()`-derived driver to the literal union it
|
|
22
|
+
// has to satisfy, the way `config/queue.ts` does with `QueueConfigShape`.
|
|
23
|
+
export type { BroadcastConfigShape } from "./config.ts";
|
|
21
24
|
export { channel, privateChannel, presenceChannel, isPrivateChannel } from "./Channel.ts";
|
|
22
25
|
export { BroadcastingEvent, broadcastOnce } from "./BroadcastingEvent.ts";
|
|
23
26
|
export { broadcastsModelEvents } from "./BroadcastsModelEvents.ts";
|
|
@@ -19,11 +19,21 @@ declare module "@zerotal/core" {
|
|
|
19
19
|
}
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
/** Where channel rules live when `config.broadcasting.channels` says nothing. */
|
|
23
|
+
const DEFAULT_CHANNELS_PATH = "routes/channels.ts";
|
|
24
|
+
|
|
25
|
+
/** POSIX `/…` and Windows `C:\…` both, since this joins a path by hand. */
|
|
26
|
+
function isAbsolutePath(path: string): boolean {
|
|
27
|
+
return path.startsWith("/") || /^[A-Za-z]:[\\/]/.test(path);
|
|
28
|
+
}
|
|
29
|
+
|
|
22
30
|
export class BroadcastProvider extends ServiceProvider {
|
|
23
31
|
static override provides = ["broadcast"] as const;
|
|
24
32
|
static override environments: AppEnvironment[] = ["web", "worker", "test", "console"];
|
|
25
33
|
|
|
26
34
|
private _redisDriver: RedisBroadcastDriver | undefined;
|
|
35
|
+
/** Resolved in `onRegister`, read in `onBooting`, so config is read once. */
|
|
36
|
+
private _channelsPath = DEFAULT_CHANNELS_PATH;
|
|
27
37
|
|
|
28
38
|
override onRegister(): void {
|
|
29
39
|
// Refuse a production boot when the configured driver is missing the
|
|
@@ -36,6 +46,7 @@ export class BroadcastProvider extends ServiceProvider {
|
|
|
36
46
|
const configManager = this.app.container.tryMake("config") as ConfigManager | null;
|
|
37
47
|
const raw = configManager?.get<Partial<BroadcastConfigShape>>("broadcasting") ?? {};
|
|
38
48
|
const cfg = BroadcastConfig(raw);
|
|
49
|
+
this._channelsPath = cfg.channels ?? DEFAULT_CHANNELS_PATH;
|
|
39
50
|
|
|
40
51
|
let manager: BroadcastManager;
|
|
41
52
|
|
|
@@ -104,11 +115,15 @@ export class BroadcastProvider extends ServiceProvider {
|
|
|
104
115
|
* rules register before the first `/broadcasting/auth` request. Missing file is fine.
|
|
105
116
|
*/
|
|
106
117
|
private async _loadChannelRoutes(): Promise<void> {
|
|
107
|
-
const
|
|
118
|
+
const configured = this._channelsPath;
|
|
119
|
+
// Absolute stays as given; relative resolves from the project root, so an app
|
|
120
|
+
// that keeps its routes under `app/routes` can point at
|
|
121
|
+
// `app/routes/channels.ts` instead of growing a second `routes/` directory.
|
|
122
|
+
const path = isAbsolutePath(configured) ? configured : `${process.cwd()}/${configured}`;
|
|
108
123
|
try {
|
|
109
124
|
if (await Bun.file(path).exists()) await import(path);
|
|
110
125
|
} catch (err) {
|
|
111
|
-
frameworkLog("broadcast").error(
|
|
126
|
+
frameworkLog("broadcast").error(`Failed to load ${configured}`, undefined, err);
|
|
112
127
|
}
|
|
113
128
|
}
|
|
114
129
|
|
package/src/types.ts
CHANGED
package/src/global.d.ts
DELETED
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
// Ambient declarations specific to this package.
|
|
2
|
-
// Bun, Node (node:*), and bun:test types come from @types/bun (→ bun-types).
|
|
3
|
-
// Only declarations bun-types does NOT provide are kept here.
|
|
4
|
-
|
|
5
|
-
interface ServerWebSocket<T = unknown> {
|
|
6
|
-
readonly data: T;
|
|
7
|
-
send(message: string | Uint8Array): void;
|
|
8
|
-
close(code?: number, reason?: string): void;
|
|
9
|
-
readyState: number;
|
|
10
|
-
}
|