@strav/broadcast 1.0.0-alpha.35 → 1.0.0-alpha.38

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": "@strav/broadcast",
3
- "version": "1.0.0-alpha.35",
3
+ "version": "1.0.0-alpha.38",
4
4
  "description": "Strav broadcast / pub-sub primitive — Broadcaster interface + MemoryBroadcaster (in-process) + PostgresBroadcaster (polling-ledger backplane) + per-channel authorization. Pairs with @strav/http's router.sse and @strav/notification/broadcast.",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -9,7 +9,9 @@
9
9
  ".": "./src/index.ts",
10
10
  "./memory": "./src/drivers/memory/index.ts",
11
11
  "./postgres": "./src/drivers/postgres/index.ts",
12
- "./redis": "./src/drivers/redis/index.ts"
12
+ "./redis": "./src/drivers/redis/index.ts",
13
+ "./websocket": "./src/drivers/websocket/index.ts",
14
+ "./client": "./src/client/index.ts"
13
15
  },
14
16
  "files": [
15
17
  "src",
@@ -22,15 +24,19 @@
22
24
  "access": "public"
23
25
  },
24
26
  "dependencies": {
25
- "@strav/kernel": "1.0.0-alpha.35"
27
+ "@strav/kernel": "1.0.0-alpha.38"
26
28
  },
27
29
  "peerDependencies": {
28
- "@strav/database": "1.0.0-alpha.35",
30
+ "@strav/database": "1.0.0-alpha.38",
31
+ "@strav/http": "1.0.0-alpha.38",
29
32
  "@types/bun": ">=1.3.14"
30
33
  },
31
34
  "peerDependenciesMeta": {
32
35
  "@strav/database": {
33
36
  "optional": true
37
+ },
38
+ "@strav/http": {
39
+ "optional": true
34
40
  }
35
41
  },
36
42
  "devDependencies": null
@@ -0,0 +1,470 @@
1
+ /**
2
+ * `BroadcastClient` — browser-side WebSocket client for
3
+ * `@strav/broadcast/websocket`.
4
+ *
5
+ * Zero runtime dependencies — uses only the platform `WebSocket`. Safe to
6
+ * bundle for any browser. The protocol module re-exports its types so
7
+ * callers can refer to wire shapes without re-declaring them.
8
+ *
9
+ * Highlights:
10
+ *
11
+ * - **Multiplexed**: one socket per `BroadcastClient`, N channel
12
+ * subscriptions on top.
13
+ * - **Auto-reconnect** with exponential backoff (250 ms → cap), jittered.
14
+ * Re-subscribes to active channels after `welcome`.
15
+ * - **Outbox**: frames sent while disconnected queue up to a cap and
16
+ * flush once the next `welcome` arrives.
17
+ * - **Event API**: lifecycle events on the client (`open`, `close`,
18
+ * `reconnecting`, `subscribed`, `error`) plus channel-level
19
+ * `on(eventName, handler)` / `on('*', handler)`.
20
+ * - **No throwing**: surface failures via the `error` event and `err`
21
+ * channel frames; the API resolves rather than rejects when possible
22
+ * so UI code stays simple.
23
+ *
24
+ * The file deliberately imports nothing from `@strav/kernel`, `@strav/http`,
25
+ * or any server module — bundlers tracing this entry will not pull server
26
+ * code into the browser bundle.
27
+ */
28
+
29
+ import type {
30
+ AnyFrame,
31
+ ClientFrame,
32
+ ErrorFrame,
33
+ MessageFrame,
34
+ ServerFrame,
35
+ WelcomeFrame,
36
+ } from '../drivers/websocket/protocol.ts'
37
+
38
+ export type Listener<T = unknown> = (payload: T) => void
39
+
40
+ export interface BroadcastClientOptions {
41
+ /**
42
+ * Full WebSocket URL — `wss://host/_broadcast`. When omitted, the client
43
+ * auto-derives `${ws|wss}://${location.host}/_broadcast` using the page
44
+ * protocol. Throws on construction in non-browser environments unless
45
+ * `url` is explicitly passed.
46
+ */
47
+ url?: string
48
+ /**
49
+ * Open the socket immediately on construction. Default `true`. Set
50
+ * `false` if you want to call `connect()` later (e.g. after login).
51
+ */
52
+ autoConnect?: boolean
53
+ /** Max reconnect attempts; `Infinity` to retry forever. Default `Infinity`. */
54
+ maxReconnectAttempts?: number
55
+ /** First reconnect delay in ms. Default `250`. */
56
+ reconnectInitialDelayMs?: number
57
+ /** Reconnect delay cap in ms. Default `10000`. */
58
+ reconnectMaxDelayMs?: number
59
+ /**
60
+ * Cap on queued outbound frames while disconnected. Older frames are
61
+ * dropped when the cap is hit. Default `200`.
62
+ */
63
+ outboxLimit?: number
64
+ /**
65
+ * WebSocket factory — overridable so tests can inject a mock. Defaults
66
+ * to `new WebSocket(url)`. The returned object must expose the standard
67
+ * `addEventListener` / `send` / `close` / `readyState` surface.
68
+ */
69
+ webSocketFactory?: (url: string) => WebSocketLike
70
+ }
71
+
72
+ /** The minimal WebSocket surface the client relies on. */
73
+ export interface WebSocketLike {
74
+ readonly readyState: number
75
+ send(data: string): void
76
+ close(code?: number, reason?: string): void
77
+ addEventListener(type: 'open', listener: () => void): void
78
+ addEventListener(type: 'message', listener: (event: { data: unknown }) => void): void
79
+ addEventListener(type: 'close', listener: () => void): void
80
+ addEventListener(type: 'error', listener: () => void): void
81
+ }
82
+
83
+ /** Connection lifecycle event payloads. */
84
+ export interface BroadcastClientEvents {
85
+ open: void
86
+ close: void
87
+ reconnecting: { attempt: number; delayMs: number }
88
+ subscribed: { channel: string }
89
+ error: { channel?: string; reason: string }
90
+ }
91
+
92
+ const WS_OPEN = 1
93
+
94
+ /**
95
+ * Per-channel handle. Cheap value type — call `on()` to register listeners,
96
+ * `publish()` to send (if the server allows it), `leave()` to detach.
97
+ */
98
+ export class BroadcastChannel {
99
+ private listeners: Map<string, Set<Listener<unknown>>> = new Map()
100
+ /** @internal called by `BroadcastClient`. */
101
+ _onError: ((reason: string) => void) | undefined
102
+
103
+ constructor(
104
+ public readonly name: string,
105
+ private readonly sendFrame: (frame: ClientFrame) => void,
106
+ private readonly detach: () => void,
107
+ ) {}
108
+
109
+ /**
110
+ * Listen for a named event. Use `'*'` to receive every frame on this
111
+ * channel — handlers receive `(eventName, data)` for wildcards and
112
+ * `(data)` for named events. Returns an unsubscribe function.
113
+ */
114
+ on(event: '*', handler: (eventName: string, data: unknown) => void): () => void
115
+ on<T = unknown>(event: string, handler: Listener<T>): () => void
116
+ on(
117
+ event: string,
118
+ handler: Listener<unknown> | ((eventName: string, data: unknown) => void),
119
+ ): () => void {
120
+ let set = this.listeners.get(event)
121
+ if (set === undefined) {
122
+ set = new Set()
123
+ this.listeners.set(event, set)
124
+ }
125
+ set.add(handler as Listener<unknown>)
126
+ return () => {
127
+ set?.delete(handler as Listener<unknown>)
128
+ }
129
+ }
130
+
131
+ /**
132
+ * Publish on this channel. Server-side `allowClientPublish` decides if
133
+ * the publish goes through; otherwise an `error` event lands on the
134
+ * channel with `reason = 'publish denied'` (or 'unauthorized').
135
+ */
136
+ publish(event: string, data?: unknown): void {
137
+ this.sendFrame({ t: 'pub', c: this.name, e: event, d: data })
138
+ }
139
+
140
+ /** Unsubscribe from the channel + drop every listener. */
141
+ leave(): void {
142
+ this.sendFrame({ t: 'unsub', c: this.name })
143
+ this.listeners.clear()
144
+ this.detach()
145
+ }
146
+
147
+ /** @internal dispatch a `msg` frame to listeners. */
148
+ _dispatch(eventName: string, data: unknown): void {
149
+ const named = this.listeners.get(eventName)
150
+ if (named !== undefined) {
151
+ for (const listener of named) {
152
+ try {
153
+ listener(data)
154
+ } catch (err) {
155
+ reportListenerError(err)
156
+ }
157
+ }
158
+ }
159
+ const wild = this.listeners.get('*')
160
+ if (wild !== undefined) {
161
+ for (const listener of wild) {
162
+ try {
163
+ ;(listener as (eventName: string, data: unknown) => void)(eventName, data)
164
+ } catch (err) {
165
+ reportListenerError(err)
166
+ }
167
+ }
168
+ }
169
+ }
170
+
171
+ /** @internal route an `err` frame to a per-channel handler. */
172
+ _emitError(reason: string): void {
173
+ this._onError?.(reason)
174
+ }
175
+ }
176
+
177
+ export class BroadcastClient {
178
+ private readonly url: string
179
+ private readonly maxReconnectAttempts: number
180
+ private readonly reconnectInitialDelayMs: number
181
+ private readonly reconnectMaxDelayMs: number
182
+ private readonly outboxLimit: number
183
+ private readonly factory: (url: string) => WebSocketLike
184
+
185
+ private ws: WebSocketLike | undefined
186
+ private state: 'idle' | 'connecting' | 'open' | 'closed' = 'idle'
187
+ private explicitClose = false
188
+ private reconnectAttempt = 0
189
+ private reconnectTimer: ReturnType<typeof setTimeout> | undefined
190
+ private outbox: string[] = []
191
+ private clientId: string | undefined
192
+
193
+ private channels = new Map<string, BroadcastChannel>()
194
+ private listeners = new Map<string, Set<Listener<unknown>>>()
195
+
196
+ constructor(options: BroadcastClientOptions = {}) {
197
+ this.url = options.url ?? autoUrl()
198
+ this.maxReconnectAttempts = options.maxReconnectAttempts ?? Infinity
199
+ this.reconnectInitialDelayMs = options.reconnectInitialDelayMs ?? 250
200
+ this.reconnectMaxDelayMs = options.reconnectMaxDelayMs ?? 10_000
201
+ this.outboxLimit = options.outboxLimit ?? 200
202
+ this.factory =
203
+ options.webSocketFactory ??
204
+ ((url) => new WebSocket(url) as unknown as WebSocketLike)
205
+
206
+ if (options.autoConnect !== false) this.connect()
207
+ }
208
+
209
+ /** Server-minted ID for this connection — populated after `welcome`. */
210
+ get id(): string | undefined {
211
+ return this.clientId
212
+ }
213
+
214
+ /** Whether the socket is currently open. */
215
+ get connected(): boolean {
216
+ return this.state === 'open'
217
+ }
218
+
219
+ /** Open the socket (no-op if already opening/open). */
220
+ connect(): void {
221
+ if (this.state === 'connecting' || this.state === 'open') return
222
+ this.explicitClose = false
223
+ this.openSocket()
224
+ }
225
+
226
+ /**
227
+ * Subscribe to a channel. Returns the same handle on repeated calls so
228
+ * apps can wire multiple parts of the UI to the same channel without
229
+ * extra bookkeeping.
230
+ */
231
+ channel(name: string): BroadcastChannel {
232
+ const existing = this.channels.get(name)
233
+ if (existing !== undefined) return existing
234
+ const handle = new BroadcastChannel(
235
+ name,
236
+ (frame) => this.sendFrame(frame),
237
+ () => {
238
+ this.channels.delete(name)
239
+ },
240
+ )
241
+ this.channels.set(name, handle)
242
+ this.sendFrame({ t: 'sub', c: name })
243
+ return handle
244
+ }
245
+
246
+ /** Listen for client-level lifecycle events. Returns an unsubscribe fn. */
247
+ on<K extends keyof BroadcastClientEvents>(
248
+ event: K,
249
+ listener: Listener<BroadcastClientEvents[K]>,
250
+ ): () => void {
251
+ let set = this.listeners.get(event)
252
+ if (set === undefined) {
253
+ set = new Set()
254
+ this.listeners.set(event, set)
255
+ }
256
+ set.add(listener as Listener<unknown>)
257
+ return () => {
258
+ set?.delete(listener as Listener<unknown>)
259
+ }
260
+ }
261
+
262
+ /**
263
+ * Close the socket permanently — disables reconnect. Active channels
264
+ * are emptied; calling `channel(name)` after disconnect will reopen the
265
+ * socket only if you call `connect()` first.
266
+ */
267
+ disconnect(): void {
268
+ this.explicitClose = true
269
+ this.state = 'closed'
270
+ if (this.reconnectTimer !== undefined) {
271
+ clearTimeout(this.reconnectTimer)
272
+ this.reconnectTimer = undefined
273
+ }
274
+ if (this.ws !== undefined) {
275
+ try {
276
+ this.ws.close()
277
+ } catch {
278
+ // Already closed.
279
+ }
280
+ this.ws = undefined
281
+ }
282
+ this.channels.clear()
283
+ this.outbox = []
284
+ }
285
+
286
+ // ─── Internals ─────────────────────────────────────────────────────────────
287
+
288
+ private openSocket(): void {
289
+ this.state = 'connecting'
290
+ let ws: WebSocketLike
291
+ try {
292
+ ws = this.factory(this.url)
293
+ } catch (err) {
294
+ this.emit('error', { reason: (err as Error).message })
295
+ this.scheduleReconnect()
296
+ return
297
+ }
298
+ this.ws = ws
299
+ ws.addEventListener('open', () => {
300
+ this.state = 'open'
301
+ this.reconnectAttempt = 0
302
+ this.emit('open', undefined)
303
+ })
304
+ ws.addEventListener('message', (event) => {
305
+ const data = event.data
306
+ if (typeof data !== 'string') return
307
+ let frame: ServerFrame
308
+ try {
309
+ frame = JSON.parse(data) as ServerFrame
310
+ } catch {
311
+ return
312
+ }
313
+ this.handleFrame(frame)
314
+ })
315
+ ws.addEventListener('close', () => {
316
+ const wasOpen = this.state === 'open'
317
+ this.ws = undefined
318
+ if (this.explicitClose) {
319
+ this.state = 'closed'
320
+ if (wasOpen) this.emit('close', undefined)
321
+ return
322
+ }
323
+ this.state = 'idle'
324
+ if (wasOpen) this.emit('close', undefined)
325
+ this.scheduleReconnect()
326
+ })
327
+ ws.addEventListener('error', () => {
328
+ // The close event will fire too — handle teardown there.
329
+ try {
330
+ ws.close()
331
+ } catch {
332
+ // Already closing.
333
+ }
334
+ })
335
+ }
336
+
337
+ private handleFrame(frame: AnyFrame): void {
338
+ switch (frame.t) {
339
+ case 'welcome':
340
+ this.onWelcome(frame)
341
+ return
342
+ case 'ok':
343
+ this.emit('subscribed', { channel: frame.c })
344
+ return
345
+ case 'err':
346
+ this.onErrorFrame(frame)
347
+ return
348
+ case 'msg':
349
+ this.onMessage(frame)
350
+ return
351
+ case 'ping':
352
+ this.sendFrame({ t: 'pong' })
353
+ return
354
+ default:
355
+ return
356
+ }
357
+ }
358
+
359
+ private onWelcome(frame: WelcomeFrame): void {
360
+ this.clientId = frame.id
361
+ for (const name of this.channels.keys()) {
362
+ this.rawSend({ t: 'sub', c: name })
363
+ }
364
+ if (this.outbox.length > 0 && this.ws !== undefined) {
365
+ for (const raw of this.outbox) {
366
+ try {
367
+ this.ws.send(raw)
368
+ } catch {
369
+ // If sending blows up we'll catch it on the next reconnect.
370
+ break
371
+ }
372
+ }
373
+ this.outbox = []
374
+ }
375
+ }
376
+
377
+ private onErrorFrame(frame: ErrorFrame): void {
378
+ const channel = this.channels.get(frame.c)
379
+ if (channel !== undefined) channel._emitError(frame.r)
380
+ this.emit('error', { channel: frame.c, reason: frame.r })
381
+ }
382
+
383
+ private onMessage(frame: MessageFrame): void {
384
+ const channel = this.channels.get(frame.c)
385
+ if (channel === undefined) return
386
+ channel._dispatch(frame.e, frame.d)
387
+ }
388
+
389
+ private sendFrame(frame: ClientFrame): void {
390
+ const raw = JSON.stringify(frame)
391
+ if (this.state === 'open' && this.ws !== undefined && this.ws.readyState === WS_OPEN) {
392
+ try {
393
+ this.ws.send(raw)
394
+ return
395
+ } catch {
396
+ // Fallthrough to outbox.
397
+ }
398
+ }
399
+ this.enqueue(raw)
400
+ }
401
+
402
+ private rawSend(frame: ClientFrame): void {
403
+ if (this.ws !== undefined && this.ws.readyState === WS_OPEN) {
404
+ try {
405
+ this.ws.send(JSON.stringify(frame))
406
+ } catch {
407
+ // Swallow — re-subscribe will fire again on next welcome.
408
+ }
409
+ }
410
+ }
411
+
412
+ private enqueue(raw: string): void {
413
+ if (this.outbox.length >= this.outboxLimit) this.outbox.shift()
414
+ this.outbox.push(raw)
415
+ }
416
+
417
+ private scheduleReconnect(): void {
418
+ if (this.explicitClose) return
419
+ if (this.reconnectAttempt >= this.maxReconnectAttempts) return
420
+ this.reconnectAttempt += 1
421
+ const base = Math.min(
422
+ this.reconnectInitialDelayMs * 2 ** (this.reconnectAttempt - 1),
423
+ this.reconnectMaxDelayMs,
424
+ )
425
+ // Full jitter — randomise the whole interval to avoid thundering herd.
426
+ const delayMs = Math.floor(Math.random() * base)
427
+ this.emit('reconnecting', { attempt: this.reconnectAttempt, delayMs })
428
+ this.reconnectTimer = setTimeout(() => {
429
+ this.reconnectTimer = undefined
430
+ this.openSocket()
431
+ }, delayMs)
432
+ }
433
+
434
+ private emit<K extends keyof BroadcastClientEvents>(
435
+ event: K,
436
+ payload: BroadcastClientEvents[K],
437
+ ): void {
438
+ const set = this.listeners.get(event)
439
+ if (set === undefined) return
440
+ for (const listener of set) {
441
+ try {
442
+ ;(listener as Listener<BroadcastClientEvents[K]>)(payload)
443
+ } catch (err) {
444
+ reportListenerError(err)
445
+ }
446
+ }
447
+ }
448
+ }
449
+
450
+ function autoUrl(): string {
451
+ // biome-ignore lint/suspicious/noExplicitAny: browser-only globals
452
+ const loc = (globalThis as any).location as
453
+ | { protocol: string; host: string }
454
+ | undefined
455
+ if (loc === undefined) {
456
+ throw new Error(
457
+ 'BroadcastClient: no `url` provided and `location` is not available. Pass `url` explicitly outside a browser.',
458
+ )
459
+ }
460
+ const proto = loc.protocol === 'https:' ? 'wss:' : 'ws:'
461
+ return `${proto}//${loc.host}/_broadcast`
462
+ }
463
+
464
+ function reportListenerError(err: unknown): void {
465
+ // Use `console.error` if present so userland sees its own throws without
466
+ // taking down the socket. The check keeps the bundle SSR-safe.
467
+ // biome-ignore lint/suspicious/noExplicitAny: optional global
468
+ const c = (globalThis as any).console as { error?: (...args: unknown[]) => void } | undefined
469
+ c?.error?.('BroadcastClient listener threw:', err)
470
+ }
@@ -0,0 +1,28 @@
1
+ // `@strav/broadcast/client` — browser WebSocket client for the gateway.
2
+ //
3
+ // Zero server-side imports: this subpath is safe to bundle for browsers.
4
+ // Wire-protocol types are re-exported so callers can refer to them without
5
+ // reaching into the driver subpath.
6
+
7
+ export {
8
+ BroadcastChannel,
9
+ BroadcastClient,
10
+ type BroadcastClientEvents,
11
+ type BroadcastClientOptions,
12
+ type Listener,
13
+ type WebSocketLike,
14
+ } from './broadcast_client.ts'
15
+ export type {
16
+ AnyFrame,
17
+ AckFrame,
18
+ ClientFrame,
19
+ ErrorFrame,
20
+ MessageFrame,
21
+ PingFrame,
22
+ PongFrame,
23
+ PublishFrame,
24
+ ServerFrame,
25
+ SubscribeFrame,
26
+ UnsubscribeFrame,
27
+ WelcomeFrame,
28
+ } from '../drivers/websocket/protocol.ts'
@@ -0,0 +1,22 @@
1
+ // `@strav/broadcast/websocket` — browser-WebSocket gateway over a Broadcaster.
2
+
3
+ export {
4
+ type AnyFrame,
5
+ type AckFrame,
6
+ type ClientFrame,
7
+ DEFAULT_PATH,
8
+ type ErrorFrame,
9
+ type MessageFrame,
10
+ type PingFrame,
11
+ type PongFrame,
12
+ type PublishFrame,
13
+ type ServerFrame,
14
+ type SubscribeFrame,
15
+ type UnsubscribeFrame,
16
+ type WelcomeFrame,
17
+ } from './protocol.ts'
18
+ export {
19
+ BroadcastWebSocketGateway,
20
+ type BroadcastWebSocketGatewayOptions,
21
+ type ClientPublishContext,
22
+ } from './websocket_gateway.ts'
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Wire protocol for the `@strav/broadcast/websocket` gateway.
3
+ *
4
+ * Tiny one-letter keys (`t`, `c`, `e`, `d`, `i`, `r`, `id`) — saves bytes
5
+ * on every frame and matches the 0.x shape. Encoding is JSON.
6
+ *
7
+ * Frame types:
8
+ *
9
+ * Server → client:
10
+ * - `welcome` — first frame after upgrade, carries the server-minted client ID.
11
+ * - `ok` — ack of a sub / unsub for channel `c`.
12
+ * - `err` — failure for channel `c`, reason in `r`.
13
+ * - `msg` — broadcast event for channel `c` (`e`/`d`/`i` = event/data/id).
14
+ * - `ping` — keepalive; client replies with `pong`.
15
+ *
16
+ * Client → server:
17
+ * - `sub` — subscribe to channel `c`.
18
+ * - `unsub` — unsubscribe from channel `c`.
19
+ * - `pub` — publish (`e`/`d` = event/data) on channel `c`. Gated by the
20
+ * gateway's `allowClientPublish` option — denied by default.
21
+ * - `pong` — reply to a server `ping`.
22
+ *
23
+ * Unknown frame types are dropped silently on both sides. The protocol is
24
+ * forward-compatible: receivers only act on tags they know.
25
+ */
26
+
27
+ export interface WelcomeFrame {
28
+ t: 'welcome'
29
+ id: string
30
+ }
31
+
32
+ export interface AckFrame {
33
+ t: 'ok'
34
+ c: string
35
+ }
36
+
37
+ export interface ErrorFrame {
38
+ t: 'err'
39
+ c: string
40
+ r: string
41
+ }
42
+
43
+ export interface MessageFrame {
44
+ t: 'msg'
45
+ c: string
46
+ e: string
47
+ d: unknown
48
+ i: string
49
+ }
50
+
51
+ export interface PingFrame {
52
+ t: 'ping'
53
+ }
54
+
55
+ export interface PongFrame {
56
+ t: 'pong'
57
+ }
58
+
59
+ export interface SubscribeFrame {
60
+ t: 'sub'
61
+ c: string
62
+ }
63
+
64
+ export interface UnsubscribeFrame {
65
+ t: 'unsub'
66
+ c: string
67
+ }
68
+
69
+ export interface PublishFrame {
70
+ t: 'pub'
71
+ c: string
72
+ e: string
73
+ d: unknown
74
+ }
75
+
76
+ export type ServerFrame = WelcomeFrame | AckFrame | ErrorFrame | MessageFrame | PingFrame
77
+ export type ClientFrame = SubscribeFrame | UnsubscribeFrame | PublishFrame | PongFrame
78
+ export type AnyFrame = ServerFrame | ClientFrame
79
+
80
+ /** Default mount path for the gateway endpoint. */
81
+ export const DEFAULT_PATH = '/_broadcast'
@@ -0,0 +1,474 @@
1
+ /**
2
+ * `BroadcastWebSocketGateway` — bridges browser WebSocket clients to a
3
+ * `Broadcaster` backplane (Memory / Postgres / Redis / any user driver).
4
+ *
5
+ * Architecture:
6
+ *
7
+ * browser ──ws──> gateway ──┐
8
+ * ├──> Broadcaster (the backplane)
9
+ * browser ──ws──> gateway ──┘ ▲
10
+ * │
11
+ * server-side broadcaster.publish(...)
12
+ *
13
+ * The gateway is **not** itself a `Broadcaster` driver. The application
14
+ * keeps registering its preferred backplane (`BroadcastProvider`,
15
+ * `PostgresBroadcastProvider`, `RedisBroadcastProvider`); the gateway sits
16
+ * on top, exposing a single multiplexed WebSocket endpoint per node.
17
+ *
18
+ * Why split it out:
19
+ *
20
+ * - Multi-node fan-out is the backplane's job. Two nodes both running
21
+ * a gateway naturally share traffic through the shared Postgres/Redis
22
+ * they're both subscribed to — every gateway sees every publish.
23
+ * - Client publishes go through the same `broadcaster.publish` path as
24
+ * server publishes, so authorization, deduping, and cross-node
25
+ * delivery all reuse one code path.
26
+ *
27
+ * Per-channel upstream coalescing: at most one `broadcaster.subscribe(ch)`
28
+ * runs per gateway per channel, regardless of how many local clients are
29
+ * on it. The upstream subscription is created when the first local client
30
+ * subscribes and torn down when the last one leaves.
31
+ *
32
+ * Authorization:
33
+ *
34
+ * - Subscribe path: `broadcaster.authorizeFor(channel, subject)` —
35
+ * same path SSE uses. The `subject` comes from a user-supplied
36
+ * `resolveSubject(request)` hook (typically reads a session cookie).
37
+ * - Publish path: client publishes are **denied by default**. Apps opt
38
+ * in by passing `allowClientPublish` — a predicate that decides
39
+ * per (channel, event, subject, clientId) whether the publish goes
40
+ * through. The subscribe-side authorizer ALSO runs first, so a
41
+ * client can't publish to a channel they can't subscribe to.
42
+ */
43
+
44
+ import { Logger, ulid } from '@strav/kernel'
45
+ import type { Router, WebSocketData, WebSocketHandlers } from '@strav/http'
46
+ import type { ServerWebSocket } from 'bun'
47
+ import { BroadcastUnauthorizedError } from '../../broadcast_error.ts'
48
+ import type { Broadcaster } from '../../broadcaster.ts'
49
+ import type { BroadcastEvent, BroadcastSubscription } from '../../types.ts'
50
+ import {
51
+ type ClientFrame,
52
+ DEFAULT_PATH,
53
+ type ServerFrame,
54
+ } from './protocol.ts'
55
+
56
+ export interface ClientPublishContext {
57
+ channel: string
58
+ event: string
59
+ /** JSON-deserialized payload from the client. */
60
+ data: unknown
61
+ /** Whatever `resolveSubject(request)` returned for this connection. */
62
+ subject: unknown
63
+ /** Server-minted ID for this socket — unique across the gateway's lifetime. */
64
+ clientId: string
65
+ }
66
+
67
+ export interface BroadcastWebSocketGatewayOptions {
68
+ /** The backplane every connected client publishes / subscribes through. */
69
+ broadcaster: Broadcaster
70
+ /** Mount path for the WS endpoint. Default `/_broadcast`. */
71
+ path?: string
72
+ /**
73
+ * Server → client keepalive interval (ms). The client replies with
74
+ * `pong`. Set `0` to disable. Default `30000`.
75
+ */
76
+ pingIntervalMs?: number
77
+ /**
78
+ * Per-connection auth hook — resolves a subject from the original
79
+ * upgrade `Request`. The subject is passed to
80
+ * `broadcaster.authorizeFor(channel, subject)` on every sub attempt and
81
+ * to `allowClientPublish` on every pub attempt.
82
+ *
83
+ * Default: `undefined` (no subject). With no subject, `private-*` /
84
+ * `presence-*` channels are denied by the broadcaster's default policy
85
+ * unless a custom authorizer is registered.
86
+ */
87
+ resolveSubject?: (request: Request) => unknown | Promise<unknown>
88
+ /**
89
+ * Gate for client → server publishes. Returning falsy denies the
90
+ * publish (an `err` frame goes back to the client). Default: deny all
91
+ * client publishes.
92
+ *
93
+ * The subscribe-side `broadcaster.authorizeFor(channel, subject)`
94
+ * runs FIRST — clients can't publish to channels they can't subscribe
95
+ * to. This hook is the second gate.
96
+ */
97
+ allowClientPublish?: (ctx: ClientPublishContext) => boolean | Promise<boolean>
98
+ /**
99
+ * Optional logger. Defaults to a no-op when omitted; tests typically
100
+ * leave it unset and assert via the public diagnostics
101
+ * (`clientCount`, `channelCount`, `subscriberCount`).
102
+ */
103
+ logger?: Pick<Logger, 'error' | 'warn'>
104
+ }
105
+
106
+ interface GatewayClient {
107
+ ws: ServerWebSocket<WebSocketData>
108
+ clientId: string
109
+ /** Channels this client is subscribed to. */
110
+ channels: Set<string>
111
+ /** Cached subject from `resolveSubject(request)`. */
112
+ subjectReady: Promise<unknown>
113
+ }
114
+
115
+ interface UpstreamChannel {
116
+ subscription: BroadcastSubscription
117
+ clients: Set<string>
118
+ /** The forwarding loop's promise — awaited by `close()`. */
119
+ loop: Promise<void>
120
+ }
121
+
122
+ export class BroadcastWebSocketGateway {
123
+ private readonly broadcaster: Broadcaster
124
+ private readonly path: string
125
+ private readonly pingIntervalMs: number
126
+ private readonly resolveSubject: (
127
+ request: Request,
128
+ ) => unknown | Promise<unknown>
129
+ private readonly allowClientPublish: (
130
+ ctx: ClientPublishContext,
131
+ ) => boolean | Promise<boolean>
132
+ private readonly logger: Pick<Logger, 'error' | 'warn'> | undefined
133
+
134
+ private readonly clients = new Map<string, GatewayClient>()
135
+ private readonly upstream = new Map<string, UpstreamChannel>()
136
+ private pingTimer: ReturnType<typeof setInterval> | undefined
137
+ private closed = false
138
+
139
+ constructor(options: BroadcastWebSocketGatewayOptions) {
140
+ this.broadcaster = options.broadcaster
141
+ this.path = options.path ?? DEFAULT_PATH
142
+ this.pingIntervalMs = options.pingIntervalMs ?? 30_000
143
+ this.resolveSubject = options.resolveSubject ?? (() => undefined)
144
+ this.allowClientPublish = options.allowClientPublish ?? (() => false)
145
+ this.logger = options.logger
146
+ }
147
+
148
+ /** Register the gateway's WS endpoint on a `@strav/http` router. */
149
+ register(router: Router): void {
150
+ router.ws(this.path, this.handlers())
151
+ if (this.pingIntervalMs > 0) {
152
+ this.pingTimer = setInterval(() => this.pingAll(), this.pingIntervalMs)
153
+ // Don't keep the event loop alive solely for pings.
154
+ const t = this.pingTimer as unknown as { unref?: () => void }
155
+ t.unref?.()
156
+ }
157
+ }
158
+
159
+ /** Mounted path — useful for tests and diagnostics. */
160
+ get mountPath(): string {
161
+ return this.path
162
+ }
163
+
164
+ /** Number of connected clients. */
165
+ get clientCount(): number {
166
+ return this.clients.size
167
+ }
168
+
169
+ /** Number of channels currently fanned out upstream from this gateway. */
170
+ get channelCount(): number {
171
+ return this.upstream.size
172
+ }
173
+
174
+ /** Number of local clients subscribed to `channel`. */
175
+ subscriberCount(channel: string): number {
176
+ return this.upstream.get(channel)?.clients.size ?? 0
177
+ }
178
+
179
+ /**
180
+ * Tear down: stop the ping timer, unsubscribe upstream, close every
181
+ * socket. Idempotent.
182
+ */
183
+ async close(): Promise<void> {
184
+ if (this.closed) return
185
+ this.closed = true
186
+ if (this.pingTimer !== undefined) {
187
+ clearInterval(this.pingTimer)
188
+ this.pingTimer = undefined
189
+ }
190
+ for (const client of this.clients.values()) {
191
+ try {
192
+ client.ws.close()
193
+ } catch {
194
+ // Already closed.
195
+ }
196
+ }
197
+ this.clients.clear()
198
+ const teardowns = Array.from(this.upstream.values()).map(async (u) => {
199
+ try {
200
+ await u.subscription.unsubscribe()
201
+ } catch {
202
+ // Best-effort.
203
+ }
204
+ try {
205
+ await u.loop
206
+ } catch {
207
+ // Loop exit is expected on unsubscribe.
208
+ }
209
+ })
210
+ this.upstream.clear()
211
+ await Promise.all(teardowns)
212
+ }
213
+
214
+ // ─── Internals ─────────────────────────────────────────────────────────────
215
+
216
+ private handlers(): WebSocketHandlers {
217
+ return {
218
+ open: (ws) => this.onOpen(ws),
219
+ message: (ws, raw) => this.onMessage(ws, raw),
220
+ close: (ws) => this.onClose(ws),
221
+ pong: () => {
222
+ // Client-originated pong — keepalive accounting is implicit (the
223
+ // socket would have died long before we cared about a missing pong).
224
+ },
225
+ }
226
+ }
227
+
228
+ private onOpen(ws: ServerWebSocket<WebSocketData>): void {
229
+ if (this.closed) {
230
+ try {
231
+ ws.close()
232
+ } catch {
233
+ // Already gone.
234
+ }
235
+ return
236
+ }
237
+ const clientId = ulid()
238
+ const client: GatewayClient = {
239
+ ws,
240
+ clientId,
241
+ channels: new Set(),
242
+ subjectReady: Promise.resolve(this.resolveSubject(ws.data.request)).catch(
243
+ (err) => {
244
+ this.logger?.warn('broadcast.ws.resolve_subject_failed', { err })
245
+ return undefined
246
+ },
247
+ ),
248
+ }
249
+ this.clients.set(clientId, client)
250
+ ws.data.state.clientId = clientId
251
+ this.sendFrame(ws, { t: 'welcome', id: clientId })
252
+ }
253
+
254
+ private async onMessage(
255
+ ws: ServerWebSocket<WebSocketData>,
256
+ raw: string | Buffer,
257
+ ): Promise<void> {
258
+ const clientId = ws.data.state.clientId as string | undefined
259
+ if (clientId === undefined) return
260
+ const client = this.clients.get(clientId)
261
+ if (client === undefined) return
262
+
263
+ let frame: ClientFrame
264
+ try {
265
+ const text = typeof raw === 'string' ? raw : raw.toString('utf8')
266
+ frame = JSON.parse(text) as ClientFrame
267
+ } catch {
268
+ // Malformed JSON — silently drop. A noisy client doesn't deserve a reply.
269
+ return
270
+ }
271
+ if (typeof frame !== 'object' || frame === null || typeof frame.t !== 'string') {
272
+ return
273
+ }
274
+
275
+ switch (frame.t) {
276
+ case 'sub':
277
+ await this.handleSubscribe(client, frame.c)
278
+ return
279
+ case 'unsub':
280
+ this.handleUnsubscribe(client, frame.c)
281
+ return
282
+ case 'pub':
283
+ await this.handlePublish(client, frame.c, frame.e, frame.d)
284
+ return
285
+ case 'pong':
286
+ return
287
+ default:
288
+ // Unknown tag — drop. Forward-compatible.
289
+ return
290
+ }
291
+ }
292
+
293
+ private onClose(ws: ServerWebSocket<WebSocketData>): void {
294
+ const clientId = ws.data.state.clientId as string | undefined
295
+ if (clientId === undefined) return
296
+ const client = this.clients.get(clientId)
297
+ if (client === undefined) return
298
+ this.clients.delete(clientId)
299
+ for (const channel of client.channels) {
300
+ this.dropFromUpstream(channel, clientId)
301
+ }
302
+ }
303
+
304
+ private async handleSubscribe(client: GatewayClient, channel: string): Promise<void> {
305
+ if (typeof channel !== 'string' || channel.length === 0) return
306
+ if (client.channels.has(channel)) {
307
+ this.sendFrame(client.ws, { t: 'ok', c: channel })
308
+ return
309
+ }
310
+ const subject = await client.subjectReady
311
+ let allowed: boolean
312
+ try {
313
+ const result = await this.broadcaster.authorizeFor(channel, subject)
314
+ allowed = result.authorized
315
+ } catch (err) {
316
+ this.logger?.error('broadcast.ws.authorize_failed', { channel, err })
317
+ this.sendFrame(client.ws, { t: 'err', c: channel, r: 'authorization failed' })
318
+ return
319
+ }
320
+ if (!allowed) {
321
+ this.sendFrame(client.ws, { t: 'err', c: channel, r: 'unauthorized' })
322
+ return
323
+ }
324
+ client.channels.add(channel)
325
+ this.ensureUpstream(channel).clients.add(client.clientId)
326
+ this.sendFrame(client.ws, { t: 'ok', c: channel })
327
+ }
328
+
329
+ private handleUnsubscribe(client: GatewayClient, channel: string): void {
330
+ if (typeof channel !== 'string' || channel.length === 0) return
331
+ if (!client.channels.delete(channel)) return
332
+ this.dropFromUpstream(channel, client.clientId)
333
+ }
334
+
335
+ private async handlePublish(
336
+ client: GatewayClient,
337
+ channel: string,
338
+ event: string,
339
+ data: unknown,
340
+ ): Promise<void> {
341
+ if (typeof channel !== 'string' || channel.length === 0) return
342
+ if (typeof event !== 'string' || event.length === 0) return
343
+
344
+ const subject = await client.subjectReady
345
+ let authorized: boolean
346
+ try {
347
+ authorized = (await this.broadcaster.authorizeFor(channel, subject)).authorized
348
+ } catch (err) {
349
+ this.logger?.error('broadcast.ws.publish_authorize_failed', { channel, err })
350
+ this.sendFrame(client.ws, { t: 'err', c: channel, r: 'authorization failed' })
351
+ return
352
+ }
353
+ if (!authorized) {
354
+ this.sendFrame(client.ws, { t: 'err', c: channel, r: 'unauthorized' })
355
+ return
356
+ }
357
+ let permitted: boolean
358
+ try {
359
+ permitted = await this.allowClientPublish({
360
+ channel,
361
+ event,
362
+ data,
363
+ subject,
364
+ clientId: client.clientId,
365
+ })
366
+ } catch (err) {
367
+ this.logger?.error('broadcast.ws.allow_publish_failed', {
368
+ channel,
369
+ event,
370
+ err,
371
+ })
372
+ this.sendFrame(client.ws, { t: 'err', c: channel, r: 'publish denied' })
373
+ return
374
+ }
375
+ if (!permitted) {
376
+ this.sendFrame(client.ws, { t: 'err', c: channel, r: 'publish denied' })
377
+ return
378
+ }
379
+ try {
380
+ await this.broadcaster.publish(channel, {
381
+ event,
382
+ data,
383
+ id: ulid(),
384
+ })
385
+ } catch (err) {
386
+ if (err instanceof BroadcastUnauthorizedError) {
387
+ this.sendFrame(client.ws, { t: 'err', c: channel, r: 'unauthorized' })
388
+ return
389
+ }
390
+ this.logger?.error('broadcast.ws.publish_failed', { channel, event, err })
391
+ this.sendFrame(client.ws, { t: 'err', c: channel, r: 'publish failed' })
392
+ }
393
+ }
394
+
395
+ private ensureUpstream(channel: string): UpstreamChannel {
396
+ const existing = this.upstream.get(channel)
397
+ if (existing !== undefined) return existing
398
+ const subscription = this.broadcaster.subscribe(channel)
399
+ const clients = new Set<string>()
400
+ const upstream: UpstreamChannel = {
401
+ subscription,
402
+ clients,
403
+ loop: this.forwardLoop(channel, subscription, clients),
404
+ }
405
+ this.upstream.set(channel, upstream)
406
+ return upstream
407
+ }
408
+
409
+ private async forwardLoop(
410
+ channel: string,
411
+ subscription: BroadcastSubscription,
412
+ clients: Set<string>,
413
+ ): Promise<void> {
414
+ try {
415
+ for await (const event of subscription) {
416
+ if (this.closed) return
417
+ this.fanout(channel, event, clients)
418
+ }
419
+ } catch (err) {
420
+ this.logger?.error('broadcast.ws.forward_loop_failed', { channel, err })
421
+ }
422
+ }
423
+
424
+ private fanout(channel: string, event: BroadcastEvent, clientIds: Set<string>): void {
425
+ const frame: ServerFrame = {
426
+ t: 'msg',
427
+ c: channel,
428
+ e: event.event,
429
+ d: event.data,
430
+ i: event.id,
431
+ }
432
+ const payload = JSON.stringify(frame)
433
+ for (const id of clientIds) {
434
+ const client = this.clients.get(id)
435
+ if (client === undefined) continue
436
+ try {
437
+ client.ws.send(payload)
438
+ } catch {
439
+ // The socket already errored; the close handler will clean up.
440
+ }
441
+ }
442
+ }
443
+
444
+ private dropFromUpstream(channel: string, clientId: string): void {
445
+ const upstream = this.upstream.get(channel)
446
+ if (upstream === undefined) return
447
+ upstream.clients.delete(clientId)
448
+ if (upstream.clients.size > 0) return
449
+ this.upstream.delete(channel)
450
+ void upstream.subscription.unsubscribe().catch(() => {
451
+ // Best-effort — a transient driver error here shouldn't crash the gateway.
452
+ })
453
+ }
454
+
455
+ private pingAll(): void {
456
+ const frame: ServerFrame = { t: 'ping' }
457
+ const payload = JSON.stringify(frame)
458
+ for (const client of this.clients.values()) {
459
+ try {
460
+ client.ws.send(payload)
461
+ } catch {
462
+ // Same rationale as `fanout` — let the close handler do the cleanup.
463
+ }
464
+ }
465
+ }
466
+
467
+ private sendFrame(ws: ServerWebSocket<WebSocketData>, frame: ServerFrame): void {
468
+ try {
469
+ ws.send(JSON.stringify(frame))
470
+ } catch {
471
+ // Socket gone; close handler will run.
472
+ }
473
+ }
474
+ }