@strav/broadcast 1.0.0-alpha.36 → 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.
|
|
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.
|
|
27
|
+
"@strav/kernel": "1.0.0-alpha.38"
|
|
26
28
|
},
|
|
27
29
|
"peerDependencies": {
|
|
28
|
-
"@strav/database": "1.0.0-alpha.
|
|
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
|
+
}
|