@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 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: unknown, ...params: string[]) => boolean | PresenceMemberData | null | undefined | Promise<boolean | PresenceMemberData | null | undefined>
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.0",
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.0"
33
+ "@zerotal/core": "1.7.3"
34
34
  },
35
35
  "devDependencies": {
36
36
  "typescript": "^5.8.0"
@@ -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
- const deny = () =>
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. Echo.leave on a component teardown / SPA navigation) must
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: Echo.private(`orders.${id}`).listen("OrderUpdated", (e) => ...)
48
+ * // Client: Socket.private(`orders.${id}`).listen("OrderUpdated", (e) => ...)
49
49
  */
50
50
  export function broadcastsModelEvents<M extends object>(
51
51
  ModelClass: ModelClassWithEvents<M>,
@@ -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
- export type ChannelCallback = (
25
- user: unknown,
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
- this._channels.push({ pattern, regex, paramNames, callback });
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. Echo calls POST /broadcasting/auth with socket_id + channel_name
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. Echo includes `auth` in the pusher:subscribe message
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 = {
@@ -1,6 +1,6 @@
1
1
  import { RequestContext } from "@zerotal/core";
2
2
 
3
- /** Read the client's Echo socket id from the current request's `X-Socket-ID` header, if any. */
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
  }
@@ -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 path = `${process.cwd()}/routes/channels.ts`;
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("Failed to load routes/channels.ts", undefined, err);
126
+ frameworkLog("broadcast").error(`Failed to load ${configured}`, undefined, err);
112
127
  }
113
128
  }
114
129
 
package/src/types.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import type { ServerWebSocket } from "bun";
2
+
1
3
  // ── BroadcastEvent ────────────────────────────────────────────────────────────
2
4
 
3
5
  /**
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
- }