@zerotal/broadcasting 1.6.3 → 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/api-surface.md ADDED
@@ -0,0 +1,285 @@
1
+ # @zerotal/broadcasting — public API surface
2
+
3
+ <!-- AUTO-GENERATED by scripts/api-surface.ts. Do not edit by hand.
4
+ Run `bun run api:surface` to regenerate after an intentional API change. -->
5
+
6
+ ## . `(./src/index.ts)`
7
+
8
+ class AnonymousBroadcast = {
9
+ new (channel: string): AnonymousBroadcast
10
+ as: (event: string) => AnonymousBroadcast
11
+ send: () => void
12
+ sendNow: () => void
13
+ toOthers: () => AnonymousBroadcast
14
+ with: (data: unknown) => AnonymousBroadcast
15
+ }
16
+
17
+ class Broadcast = {
18
+ new (): Broadcast
19
+ static channel: (pattern: string, callback: ChannelCallback) => void
20
+ static channels: () => { pattern: string; paramNames: string[];}[]
21
+ static fake: () => BroadcastFake
22
+ static getMembers: (channel: string) => PresenceMember[]
23
+ static on: (channel: string) => AnonymousBroadcast
24
+ static presence: (channel: string) => AnonymousBroadcast
25
+ static private: (channel: string) => AnonymousBroadcast
26
+ static resetFake: () => void
27
+ static send: (event: BroadcastEvent, opts?: { exceptSocketId?: string;}) => void
28
+ static to: (channel: string, eventName: string, data?: unknown, opts?: { exceptSocketId?: string;}) => void
29
+ }
30
+
31
+ class BroadcastError = {
32
+ new (message: string, code?: string, status?: number, context?: Record<string, unknown>): BroadcastError
33
+ readonly code: string
34
+ readonly context?: Record<string, unknown> | undefined
35
+ readonly status: number
36
+ }
37
+
38
+ class BroadcastFake = {
39
+ new (): BroadcastFake
40
+ assertBroadcast: (eventName: string, channel?: string, data?: Record<string, unknown>) => void
41
+ assertBroadcastCount: (count: number) => void
42
+ assertNotBroadcast: (eventName: string, channel?: string) => void
43
+ assertNothingBroadcast: () => void
44
+ recorded: () => RecordedBroadcast[]
45
+ reset: () => void
46
+ send: (event: BroadcastEvent, opts?: { exceptSocketId?: string;}) => void
47
+ to: (channel: string, eventName: string, data?: unknown, _opts?: { exceptSocketId?: string;}) => void
48
+ }
49
+
50
+ class BroadcastingEvent = {
51
+ new (): BroadcastingEvent
52
+ static dispatch: <T extends BroadcastingEvent, A extends unknown[]>(this: new (...args: A) => T, ...args: A) => T
53
+ broadcastAs: () => string
54
+ broadcastOn: () => string | string[]
55
+ broadcastWhen: () => boolean
56
+ broadcastWith: () => object
57
+ }
58
+
59
+ class BroadcastManager = {
60
+ new (): BroadcastManager
61
+ authorizePresenceWith: (fn: PresenceAuthFn) => void
62
+ authorizeWith: (fn: ChannelAuthFn) => void
63
+ connectionCount: () => number
64
+ getMembers: (channel: string) => PresenceMember[]
65
+ handleClose: (ws: WS) => void
66
+ handleMessage: (ws: WS, raw: string | Uint8Array) => Promise<void>
67
+ handleOpen: (ws: WS) => void
68
+ send: (event: BroadcastEvent, opts?: { exceptSocketId?: string;}) => void
69
+ setAuthSecret: (secret: string) => void
70
+ signAuth: (socketId: string, channel: string, channelData?: string) => string
71
+ subscriberCount: (channel: string) => number
72
+ subscriptionsFor: (connectionId: string) => string[]
73
+ to: (channel: string, eventName: string, data?: unknown, opts?: { exceptSocketId?: string;}) => void
74
+ upgradeData: (req: Request) => Record<string, unknown>
75
+ verifyAuth: (socketId: string, channel: string, auth: string, channelData?: string) => boolean
76
+ wsHandlers: { open: (ws: WS) => void; message: (ws: WS, msg: string | Uint8Array) => undefined; close: (ws: WS) => void;}
77
+ }
78
+
79
+ class BroadcastProvider = {
80
+ new (app: Application): BroadcastProvider
81
+ static dependsOn?: (new (app: Application) => ServiceProvider)[]
82
+ static environments: AppEnvironment[]
83
+ static priority?: number
84
+ static provides: readonly ['broadcast']
85
+ devProcesses: () => DevProcessDefinition[]
86
+ doctorChecks: () => DoctorCheck[]
87
+ onBooted: () => Promise<void>
88
+ onBooting: () => Promise<void>
89
+ onRegister: () => void
90
+ onRequestProcessed: (_ctx: HttpContext) => Promise<void>
91
+ onRequestReceived: (_ctx: HttpContext) => Promise<void>
92
+ onResponseSent: (_ctx: HttpContext) => Promise<void>
93
+ onStarted: () => Promise<void>
94
+ onStarting: () => Promise<void>
95
+ onStopped: () => Promise<void>
96
+ onStopping: () => Promise<void>
97
+ replContext: () => Record<string, unknown>
98
+ }
99
+
100
+ class BroadcastProviderNotRegisteredError = {
101
+ new (): BroadcastProviderNotRegisteredError
102
+ readonly code: string
103
+ readonly context?: Record<string, unknown> | undefined
104
+ readonly status: number
105
+ }
106
+
107
+ class ChannelRegistry = {
108
+ new (): ChannelRegistry
109
+ all: () => { pattern: string; paramNames: string[];}[]
110
+ authorize: (channelName: string, user: unknown) => Promise<AuthorizeResult>
111
+ clear: () => void
112
+ register: (pattern: string, callback: ChannelCallback) => void
113
+ }
114
+
115
+ class MissingChannelParameterError = {
116
+ new (key: string): MissingChannelParameterError
117
+ readonly code: string
118
+ readonly context?: Record<string, unknown> | undefined
119
+ readonly status: number
120
+ }
121
+
122
+ class PendingBroadcast = {
123
+ new (event: BroadcastEvent): PendingBroadcast
124
+ send: () => void
125
+ then: <TResult1 = void, TResult2 = never>(onfulfilled?: ((value: void) => TResult1 | PromiseLike<TResult1>) | null, onrejected?: ((reason: unknown) => TResult2 | PromiseLike<TResult2>) | null) => PromiseLike<TResult1 | TResult2>
126
+ toOthers: () => PendingBroadcast
127
+ }
128
+
129
+ class PusherCompatManager = {
130
+ new (_appKey: string, _appSecret: string): PusherCompatManager
131
+ authorizePresenceWith: (fn: PresenceAuthFn) => void
132
+ authorizeWith: (fn: ChannelAuthFn) => void
133
+ connectionCount: () => number
134
+ getMembers: (channel: string) => PresenceMember[]
135
+ handleClose: (ws: WS) => void
136
+ handleMessage: (ws: WS, raw: string | Uint8Array) => Promise<void>
137
+ handleOpen: (ws: WS) => void
138
+ resolvePresence: (req: Request, channel: string) => Promise<{ id: string | number; info?: Record<string, unknown>;} | false>
139
+ resolvePresenceWith: (fn: PusherPresenceResolver) => void
140
+ send: (event: BroadcastEvent, opts?: { exceptSocketId?: string;}) => void
141
+ setAuthSecret: (secret: string) => void
142
+ signAuth: (socketId: string, channel: string, channelData?: string) => string
143
+ subscriberCount: (channel: string) => number
144
+ subscriptionsFor: (connectionId: string) => string[]
145
+ to: (channel: string, eventName: string, data?: unknown, opts?: { exceptSocketId?: string;}) => void
146
+ upgradeData: (req: Request) => Record<string, unknown>
147
+ verifyAuth: (socketId: string, channel: string, auth: string, channelData?: string) => boolean
148
+ wsHandlers: { open: (ws: ServerWebSocket<WsConnectionData>) => void; message: (ws: ServerWebSocket<WsConnectionData>, msg: string | Uint8Array) => undefined; close: (ws: ServerWebSocket<WsConnectionData>) => void;}
149
+ }
150
+
151
+ class RedisBroadcastDriver = {
152
+ new (_url: string): RedisBroadcastDriver
153
+ authorizePresenceWith: (fn: PresenceAuthFn) => void
154
+ authorizeWith: (fn: ChannelAuthFn) => void
155
+ boot: () => Promise<void>
156
+ connectionCount: () => number
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
161
+ send: (event: BroadcastEvent, opts?: { exceptSocketId?: string;}) => void
162
+ setAuthSecret: (secret: string) => void
163
+ signAuth: (socketId: string, channel: string, channelData?: string) => string
164
+ stop: () => Promise<void>
165
+ subscriberCount: (channel: string) => number
166
+ subscriptionsFor: (connectionId: string) => string[]
167
+ to: (channel: string, event: string, data?: unknown, opts?: { exceptSocketId?: string;}) => void
168
+ upgradeData: (req: Request) => Record<string, unknown>
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;}
171
+ }
172
+
173
+ class TypedBroadcastManager = {
174
+ new <Channels extends BroadcastChannelMap>(): TypedBroadcastManager<Channels>
175
+ authorizePresenceWith: (fn: PresenceAuthFn) => void
176
+ authorizeWith: (fn: ChannelAuthFn) => void
177
+ connectionCount: () => number
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
182
+ send: (event: TypedBroadcastEvent<Channels, keyof Channels & string> | BroadcastEvent) => void
183
+ setAuthSecret: (secret: string) => void
184
+ signAuth: (socketId: string, channel: string, channelData?: string) => string
185
+ subscriberCount: (channel: string) => number
186
+ subscriptionsFor: (connectionId: string) => string[]
187
+ to: { <Ch extends StaticChannels<Channels>, Ev extends EventsOf<Channels, Ch>>(channel: Ch, event: Ev, data: PayloadOf<Channels, Ch, Ev>, opts?: { exceptSocketId?: string; }): void; (channel: string, event: string, data?: unknown, opts?: { exceptSocketId?: string; }): void;}
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
+ upgradeData: (req: Request) => Record<string, unknown>
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;}
192
+ }
193
+
194
+ const channelRegistry = ChannelRegistry
195
+
196
+ function broadcast = (event: BroadcastEvent) => PendingBroadcast
197
+
198
+ function BroadcastConfig = (overrides?: Partial<BroadcastConfigShape>) => BroadcastConfigShape
199
+
200
+ function broadcastOnce = (event: BroadcastEvent) => void
201
+
202
+ function broadcastsModelEvents = <M extends object>(ModelClass: ModelClassWithEvents<M>, options: BroadcastsModelEventsOptions<M>) => void
203
+
204
+ function channel = (name: string) => string
205
+
206
+ function compileChannelPattern = (pattern: string) => { regex: RegExp; paramNames: string[];}
207
+
208
+ function isPrivateChannel = (name: string) => boolean
209
+
210
+ function presenceChannel = (name: string) => string
211
+
212
+ function privateChannel = (name: string) => string
213
+
214
+ interface BroadcastEvent = {
215
+ broadcastAs?: () => string
216
+ broadcastOn: () => string | string[]
217
+ broadcastWith?: () => object
218
+ }
219
+
220
+ interface BroadcastRecord = {
221
+ channel: string
222
+ data: Record<string, unknown>
223
+ event: string
224
+ }
225
+
226
+ interface BroadcastsModelEventsOptions = {
227
+ as?: (modelName: string, event: ModelBroadcastEventName) => string
228
+ channels: (model: M, event: ModelBroadcastEventName) => string | string[]
229
+ events?: ModelBroadcastEventName[]
230
+ with?: (model: M, event: ModelBroadcastEventName) => object
231
+ }
232
+
233
+ interface PresenceMember = {
234
+ id: string | number
235
+ info: Record<string, unknown>
236
+ }
237
+
238
+ interface PresenceMemberData = {
239
+ [key: string]: unknown
240
+ id: string | number
241
+ }
242
+
243
+ interface RecordedBroadcast = {
244
+ channel: string
245
+ data: Record<string, unknown>
246
+ event: string
247
+ }
248
+
249
+ interface TypedBroadcastEvent = {
250
+ broadcastAs: () => Ev
251
+ broadcastOn: () => Ch | Ch[]
252
+ broadcastWith: () => PayloadOf<M, Ch, Ev>
253
+ }
254
+
255
+ interface WsConnectionData = {
256
+ id: string
257
+ token?: string
258
+ userId?: string | number
259
+ }
260
+
261
+ type AuthorizeResult = { matched: false;} | { matched: true; result: boolean | PresenceMemberData | null | undefined;}
262
+
263
+ type BroadcastChannelMap = { [x: string]: Record<string, object>;}
264
+
265
+ type ChannelAuthFn = (channel: string, ws: ServerWebSocket<WsConnectionData>) => boolean | Promise<boolean>
266
+
267
+ type ChannelCallback = (user: unknown, ...params: string[]) => boolean | PresenceMemberData | null | undefined | Promise<boolean | PresenceMemberData | null | undefined>
268
+
269
+ type ChannelParamRecord = [ChannelParams<Pattern>] extends [never] ? undefined : { [K in ChannelParams<Pattern>]: string | number; }
270
+
271
+ type ChannelParams = Pattern extends `${string}[${infer Param}]${infer Tail}` ? Param | ChannelParams<Tail> : never
272
+
273
+ type EventsOf = keyof M[Ch] & string
274
+
275
+ type ModelBroadcastEventName = 'created' | 'updated' | 'deleted'
276
+
277
+ type ParameterizedChannels = { [K in keyof M & string]: [ChannelParams<K>] extends [never] ? never : K; }[keyof M & string]
278
+
279
+ type PayloadOf = M[Ch][Ev]
280
+
281
+ type PresenceAuthFn = (channel: string, ws: ServerWebSocket<WsConnectionData>) => Promise<PresenceMember | false> | PresenceMember | false
282
+
283
+ type PusherPresenceResolver = (req: Request, channel: string) => Promise<{ id: string | number; info?: Record<string, unknown>;} | false> | { id: string | number; info?: Record<string, unknown>;} | false
284
+
285
+ type StaticChannels = { [K in keyof M & string]: [ChannelParams<K>] extends [never] ? K : never; }[keyof M & string]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zerotal/broadcasting",
3
- "version": "1.6.3",
3
+ "version": "1.7.2",
4
4
  "license": "MIT",
5
5
  "maturity": "stable",
6
6
  "private": false,
@@ -12,6 +12,7 @@
12
12
  },
13
13
  "files": [
14
14
  "CHANGELOG.md",
15
+ "api-surface.md",
15
16
  "src",
16
17
  "!src/**/*.test.ts",
17
18
  "!src/**/*.test.tsx",
@@ -29,7 +30,7 @@
29
30
  "typecheck": "tsc --noEmit"
30
31
  },
31
32
  "dependencies": {
32
- "@zerotal/core": "1.6.3"
33
+ "@zerotal/core": "1.7.2"
33
34
  },
34
35
  "devDependencies": {
35
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
- }