@zerotal/broadcasting 1.7.0 → 1.7.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zerotal/broadcasting",
3
- "version": "1.7.0",
3
+ "version": "1.7.2",
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.2"
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
- }