@fluxstack/live-client 0.5.0 → 0.6.0
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/dist/index.cjs +30 -4
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +2 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +30 -4
- package/dist/index.js.map +1 -1
- package/dist/live-client.browser.global.js +31 -4
- package/dist/live-client.browser.global.js.map +1 -1
- package/package.json +4 -3
- package/src/__tests__/rooms.binary.test.ts +359 -0
- package/src/component.ts +364 -0
- package/src/connection.ts +524 -0
- package/src/index.ts +219 -0
- package/src/persistence.ts +52 -0
- package/src/rooms.ts +539 -0
- package/src/state-validator.ts +121 -0
- package/src/upload.ts +366 -0
|
@@ -0,0 +1,524 @@
|
|
|
1
|
+
// @fluxstack/live-client - WebSocket Connection Manager
|
|
2
|
+
//
|
|
3
|
+
// Framework-agnostic WebSocket connection with auto-reconnect, heartbeat,
|
|
4
|
+
// request-response pattern, and component message routing.
|
|
5
|
+
|
|
6
|
+
import type { WebSocketMessage, WebSocketResponse } from '@fluxstack/live'
|
|
7
|
+
|
|
8
|
+
/** Auth credentials to send during WebSocket connection */
|
|
9
|
+
export interface LiveAuthOptions {
|
|
10
|
+
/** JWT or opaque token */
|
|
11
|
+
token?: string
|
|
12
|
+
/** Provider name (if multiple auth providers configured) */
|
|
13
|
+
provider?: string
|
|
14
|
+
/** Additional credentials (publicKey, signature, etc.) */
|
|
15
|
+
[key: string]: unknown
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface LiveConnectionOptions {
|
|
19
|
+
/** WebSocket URL. Auto-detected from window.location if omitted. */
|
|
20
|
+
url?: string
|
|
21
|
+
/** Auth credentials to send on connection */
|
|
22
|
+
auth?: LiveAuthOptions
|
|
23
|
+
/** Auto-connect on creation. Default: true */
|
|
24
|
+
autoConnect?: boolean
|
|
25
|
+
/** Reconnect interval in ms. Default: 1000 */
|
|
26
|
+
reconnectInterval?: number
|
|
27
|
+
/** Max reconnect attempts. Default: 5 */
|
|
28
|
+
maxReconnectAttempts?: number
|
|
29
|
+
/** Heartbeat interval in ms. Default: 30000 */
|
|
30
|
+
heartbeatInterval?: number
|
|
31
|
+
/** Enable debug logging. Default: false */
|
|
32
|
+
debug?: boolean
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Auth state exposed to the client */
|
|
36
|
+
export interface LiveClientAuth {
|
|
37
|
+
authenticated: boolean
|
|
38
|
+
/** Session data from the server. Shape defined by your LiveAuthProvider. */
|
|
39
|
+
session: Record<string, unknown> | null
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface LiveConnectionState {
|
|
43
|
+
connected: boolean
|
|
44
|
+
connecting: boolean
|
|
45
|
+
error: string | null
|
|
46
|
+
connectionId: string | null
|
|
47
|
+
authenticated: boolean
|
|
48
|
+
/** Auth context with session data */
|
|
49
|
+
auth: LiveClientAuth
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
type StateChangeCallback = (state: LiveConnectionState) => void
|
|
53
|
+
type ComponentCallback = (message: WebSocketResponse) => void
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Framework-agnostic WebSocket connection manager.
|
|
57
|
+
* Handles reconnection, heartbeat, request-response pattern, and message routing.
|
|
58
|
+
*/
|
|
59
|
+
export class LiveConnection {
|
|
60
|
+
private ws: WebSocket | null = null
|
|
61
|
+
private options: Required<Omit<LiveConnectionOptions, 'url' | 'auth'>> & { url?: string; auth?: LiveAuthOptions }
|
|
62
|
+
private reconnectAttempts = 0
|
|
63
|
+
private reconnectTimeout: ReturnType<typeof setTimeout> | null = null
|
|
64
|
+
private heartbeatInterval: ReturnType<typeof setInterval> | null = null
|
|
65
|
+
private componentCallbacks = new Map<string, ComponentCallback>()
|
|
66
|
+
private binaryCallbacks = new Map<string, (payload: Uint8Array) => void>()
|
|
67
|
+
private roomBinaryHandlers = new Set<(frame: Uint8Array) => void>()
|
|
68
|
+
private pendingRequests = new Map<string, {
|
|
69
|
+
resolve: (value: any) => void
|
|
70
|
+
reject: (error: any) => void
|
|
71
|
+
timeout: ReturnType<typeof setTimeout>
|
|
72
|
+
}>()
|
|
73
|
+
private stateListeners = new Set<StateChangeCallback>()
|
|
74
|
+
private _state: LiveConnectionState = {
|
|
75
|
+
connected: false,
|
|
76
|
+
connecting: false,
|
|
77
|
+
error: null,
|
|
78
|
+
connectionId: null,
|
|
79
|
+
authenticated: false,
|
|
80
|
+
auth: { authenticated: false, session: null },
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
constructor(options: LiveConnectionOptions = {}) {
|
|
84
|
+
this.options = {
|
|
85
|
+
url: options.url,
|
|
86
|
+
auth: options.auth,
|
|
87
|
+
autoConnect: options.autoConnect ?? true,
|
|
88
|
+
reconnectInterval: options.reconnectInterval ?? 1000,
|
|
89
|
+
maxReconnectAttempts: options.maxReconnectAttempts ?? 5,
|
|
90
|
+
heartbeatInterval: options.heartbeatInterval ?? 30000,
|
|
91
|
+
debug: options.debug ?? false,
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (this.options.autoConnect) {
|
|
95
|
+
this.connect()
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
get state(): LiveConnectionState {
|
|
100
|
+
return { ...this._state }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Subscribe to connection state changes */
|
|
104
|
+
onStateChange(callback: StateChangeCallback): () => void {
|
|
105
|
+
this.stateListeners.add(callback)
|
|
106
|
+
return () => { this.stateListeners.delete(callback) }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
private setState(patch: Partial<LiveConnectionState>) {
|
|
110
|
+
this._state = { ...this._state, ...patch }
|
|
111
|
+
for (const cb of this.stateListeners) {
|
|
112
|
+
cb(this._state)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
private getWebSocketUrl(): string {
|
|
117
|
+
if (this.options.url) {
|
|
118
|
+
return this.options.url
|
|
119
|
+
} else if (typeof window === 'undefined') {
|
|
120
|
+
return 'ws://localhost:3000/api/live/ws'
|
|
121
|
+
} else {
|
|
122
|
+
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
|
123
|
+
return `${protocol}//${window.location.host}/api/live/ws`
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
private log(message: string, data?: any) {
|
|
128
|
+
if (this.options.debug) {
|
|
129
|
+
console.log(`[LiveConnection] ${message}`, data || '')
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Generate unique request ID */
|
|
134
|
+
generateRequestId(): string {
|
|
135
|
+
return `req-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Connect to WebSocket server */
|
|
139
|
+
connect(): void {
|
|
140
|
+
if (this.ws?.readyState === WebSocket.CONNECTING) {
|
|
141
|
+
this.log('Already connecting, skipping...')
|
|
142
|
+
return
|
|
143
|
+
}
|
|
144
|
+
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
145
|
+
this.log('Already connected, skipping...')
|
|
146
|
+
return
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
this.setState({ connecting: true, error: null })
|
|
150
|
+
const url = this.getWebSocketUrl()
|
|
151
|
+
this.log('Connecting...', { url })
|
|
152
|
+
|
|
153
|
+
try {
|
|
154
|
+
const ws = new WebSocket(url)
|
|
155
|
+
ws.binaryType = 'arraybuffer'
|
|
156
|
+
this.ws = ws
|
|
157
|
+
|
|
158
|
+
ws.onopen = () => {
|
|
159
|
+
this.log('Connected')
|
|
160
|
+
this.setState({ connected: true, connecting: false })
|
|
161
|
+
this.reconnectAttempts = 0
|
|
162
|
+
this.startHeartbeat()
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
ws.onmessage = (event) => {
|
|
166
|
+
// Binary message path (BINARY_STATE_DELTA)
|
|
167
|
+
if (event.data instanceof ArrayBuffer) {
|
|
168
|
+
this.handleBinaryMessage(new Uint8Array(event.data))
|
|
169
|
+
return
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
try {
|
|
173
|
+
const parsed = JSON.parse(event.data)
|
|
174
|
+
// Server may send batched messages as a JSON array
|
|
175
|
+
if (Array.isArray(parsed)) {
|
|
176
|
+
for (const msg of parsed) {
|
|
177
|
+
this.log('Received', { type: msg.type, componentId: msg.componentId })
|
|
178
|
+
this.handleMessage(msg)
|
|
179
|
+
}
|
|
180
|
+
} else {
|
|
181
|
+
this.log('Received', { type: parsed.type, componentId: parsed.componentId })
|
|
182
|
+
this.handleMessage(parsed)
|
|
183
|
+
}
|
|
184
|
+
} catch {
|
|
185
|
+
this.log('Failed to parse message')
|
|
186
|
+
this.setState({ error: 'Failed to parse message' })
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
ws.onclose = (event) => {
|
|
191
|
+
this.log('Disconnected', { code: event.code, reason: event.reason })
|
|
192
|
+
this.setState({ connected: false, connecting: false, connectionId: null, authenticated: false, auth: { authenticated: false, session: null } })
|
|
193
|
+
this.stopHeartbeat()
|
|
194
|
+
|
|
195
|
+
// Server rejected connection due to CSRF origin validation — don't retry
|
|
196
|
+
if (event.code === 4003) {
|
|
197
|
+
this.setState({ error: 'Connection rejected: origin not allowed' })
|
|
198
|
+
return
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
this.attemptReconnect()
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
ws.onerror = () => {
|
|
205
|
+
this.log('WebSocket error')
|
|
206
|
+
this.setState({ error: 'WebSocket connection error', connecting: false })
|
|
207
|
+
}
|
|
208
|
+
} catch (error) {
|
|
209
|
+
this.setState({
|
|
210
|
+
connecting: false,
|
|
211
|
+
error: error instanceof Error ? error.message : 'Connection failed',
|
|
212
|
+
})
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Disconnect from WebSocket server */
|
|
217
|
+
disconnect(): void {
|
|
218
|
+
if (this.reconnectTimeout) {
|
|
219
|
+
clearTimeout(this.reconnectTimeout)
|
|
220
|
+
this.reconnectTimeout = null
|
|
221
|
+
}
|
|
222
|
+
this.stopHeartbeat()
|
|
223
|
+
if (this.ws) {
|
|
224
|
+
this.ws.close()
|
|
225
|
+
this.ws = null
|
|
226
|
+
}
|
|
227
|
+
this.reconnectAttempts = this.options.maxReconnectAttempts
|
|
228
|
+
this.setState({ connected: false, connecting: false, connectionId: null })
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Manual reconnect */
|
|
232
|
+
reconnect(): void {
|
|
233
|
+
this.disconnect()
|
|
234
|
+
this.reconnectAttempts = 0
|
|
235
|
+
setTimeout(() => this.connect(), 100)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
private attemptReconnect(): void {
|
|
239
|
+
if (this.reconnectAttempts < this.options.maxReconnectAttempts) {
|
|
240
|
+
this.reconnectAttempts++
|
|
241
|
+
this.log(`Reconnecting... (${this.reconnectAttempts}/${this.options.maxReconnectAttempts})`)
|
|
242
|
+
this.reconnectTimeout = setTimeout(() => this.connect(), this.options.reconnectInterval)
|
|
243
|
+
} else {
|
|
244
|
+
this.setState({ error: 'Max reconnection attempts reached' })
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
private consecutiveHeartbeatFailures = 0
|
|
249
|
+
private static readonly MAX_HEARTBEAT_FAILURES = 3
|
|
250
|
+
|
|
251
|
+
private startHeartbeat(): void {
|
|
252
|
+
this.stopHeartbeat()
|
|
253
|
+
this.consecutiveHeartbeatFailures = 0
|
|
254
|
+
this.heartbeatInterval = setInterval(() => {
|
|
255
|
+
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
256
|
+
let failed = false
|
|
257
|
+
for (const componentId of this.componentCallbacks.keys()) {
|
|
258
|
+
this.sendMessage({
|
|
259
|
+
type: 'COMPONENT_PING',
|
|
260
|
+
componentId,
|
|
261
|
+
timestamp: Date.now(),
|
|
262
|
+
}).catch(() => { failed = true })
|
|
263
|
+
}
|
|
264
|
+
if (failed) {
|
|
265
|
+
this.consecutiveHeartbeatFailures++
|
|
266
|
+
this.log(`Heartbeat failed (${this.consecutiveHeartbeatFailures}/${LiveConnection.MAX_HEARTBEAT_FAILURES})`)
|
|
267
|
+
if (this.consecutiveHeartbeatFailures >= LiveConnection.MAX_HEARTBEAT_FAILURES) {
|
|
268
|
+
this.log('Too many heartbeat failures, reconnecting...')
|
|
269
|
+
this.setState({ error: 'Heartbeat failed' })
|
|
270
|
+
this.reconnect()
|
|
271
|
+
}
|
|
272
|
+
} else {
|
|
273
|
+
this.consecutiveHeartbeatFailures = 0
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}, this.options.heartbeatInterval)
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
private stopHeartbeat(): void {
|
|
280
|
+
if (this.heartbeatInterval) {
|
|
281
|
+
clearInterval(this.heartbeatInterval)
|
|
282
|
+
this.heartbeatInterval = null
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
private handleMessage(response: WebSocketResponse): void {
|
|
287
|
+
// Handle connection established
|
|
288
|
+
if (response.type === 'CONNECTION_ESTABLISHED') {
|
|
289
|
+
this.setState({
|
|
290
|
+
connectionId: response.connectionId || null,
|
|
291
|
+
authenticated: (response as any).authenticated || false,
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
// Send AUTH message if credentials provided (always via socket, never in URL)
|
|
295
|
+
const auth = this.options.auth
|
|
296
|
+
if (auth && Object.keys(auth).some(k => auth[k])) {
|
|
297
|
+
this.sendMessageAndWait({ type: 'AUTH', payload: auth } as any)
|
|
298
|
+
.then(authResp => {
|
|
299
|
+
const payload = (authResp as any).payload
|
|
300
|
+
if (payload?.authenticated) {
|
|
301
|
+
this.setState({
|
|
302
|
+
authenticated: true,
|
|
303
|
+
auth: { authenticated: true, session: payload.session || null },
|
|
304
|
+
})
|
|
305
|
+
}
|
|
306
|
+
})
|
|
307
|
+
.catch(() => {})
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Handle auth response
|
|
312
|
+
if (response.type === 'AUTH_RESPONSE') {
|
|
313
|
+
const payload = (response as any).payload
|
|
314
|
+
const authenticated = payload?.authenticated || false
|
|
315
|
+
this.setState({
|
|
316
|
+
authenticated,
|
|
317
|
+
auth: {
|
|
318
|
+
authenticated,
|
|
319
|
+
session: authenticated ? (payload?.session || null) : null,
|
|
320
|
+
},
|
|
321
|
+
})
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Handle pending requests (request-response pattern)
|
|
325
|
+
if (response.requestId && this.pendingRequests.has(response.requestId)) {
|
|
326
|
+
const request = this.pendingRequests.get(response.requestId)!
|
|
327
|
+
clearTimeout(request.timeout)
|
|
328
|
+
this.pendingRequests.delete(response.requestId)
|
|
329
|
+
|
|
330
|
+
if (response.success !== false) {
|
|
331
|
+
request.resolve(response)
|
|
332
|
+
} else {
|
|
333
|
+
if (response.error?.includes?.('COMPONENT_REHYDRATION_REQUIRED')) {
|
|
334
|
+
request.resolve(response)
|
|
335
|
+
} else {
|
|
336
|
+
request.reject(new Error(response.error || 'Request failed'))
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
return
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Broadcast messages go to ALL components (not just sender)
|
|
343
|
+
if (response.type === 'BROADCAST') {
|
|
344
|
+
this.componentCallbacks.forEach((callback, compId) => {
|
|
345
|
+
if (compId !== response.componentId) {
|
|
346
|
+
callback(response)
|
|
347
|
+
}
|
|
348
|
+
})
|
|
349
|
+
return
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// Route message to specific component
|
|
353
|
+
if (response.componentId) {
|
|
354
|
+
const callback = this.componentCallbacks.get(response.componentId)
|
|
355
|
+
if (callback) {
|
|
356
|
+
callback(response)
|
|
357
|
+
} else {
|
|
358
|
+
this.log('No callback registered for component:', response.componentId)
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** Send message without waiting for response */
|
|
364
|
+
async sendMessage(message: WebSocketMessage): Promise<void> {
|
|
365
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
366
|
+
throw new Error('WebSocket is not connected')
|
|
367
|
+
}
|
|
368
|
+
const messageWithTimestamp = { ...message, timestamp: Date.now() }
|
|
369
|
+
this.ws.send(JSON.stringify(messageWithTimestamp))
|
|
370
|
+
this.log('Sent', { type: message.type, componentId: message.componentId })
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** Send message and wait for response */
|
|
374
|
+
async sendMessageAndWait(message: WebSocketMessage, timeout = 10000): Promise<WebSocketResponse> {
|
|
375
|
+
return new Promise((resolve, reject) => {
|
|
376
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
377
|
+
reject(new Error('WebSocket is not connected'))
|
|
378
|
+
return
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const requestId = this.generateRequestId()
|
|
382
|
+
|
|
383
|
+
const timeoutHandle = setTimeout(() => {
|
|
384
|
+
this.pendingRequests.delete(requestId)
|
|
385
|
+
reject(new Error(`Request timeout after ${timeout}ms`))
|
|
386
|
+
}, timeout)
|
|
387
|
+
|
|
388
|
+
this.pendingRequests.set(requestId, { resolve, reject, timeout: timeoutHandle })
|
|
389
|
+
|
|
390
|
+
try {
|
|
391
|
+
const messageWithRequestId = {
|
|
392
|
+
...message,
|
|
393
|
+
requestId,
|
|
394
|
+
expectResponse: true,
|
|
395
|
+
timestamp: Date.now(),
|
|
396
|
+
}
|
|
397
|
+
this.ws.send(JSON.stringify(messageWithRequestId))
|
|
398
|
+
this.log('Sent with requestId', { requestId, type: message.type })
|
|
399
|
+
} catch (error) {
|
|
400
|
+
clearTimeout(timeoutHandle)
|
|
401
|
+
this.pendingRequests.delete(requestId)
|
|
402
|
+
reject(error)
|
|
403
|
+
}
|
|
404
|
+
})
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/** Send binary data and wait for response (for file uploads) */
|
|
408
|
+
async sendBinaryAndWait(data: ArrayBuffer, requestId: string, timeout = 10000): Promise<WebSocketResponse> {
|
|
409
|
+
return new Promise((resolve, reject) => {
|
|
410
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
411
|
+
reject(new Error('WebSocket is not connected'))
|
|
412
|
+
return
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const timeoutHandle = setTimeout(() => {
|
|
416
|
+
this.pendingRequests.delete(requestId)
|
|
417
|
+
reject(new Error(`Binary request timeout after ${timeout}ms`))
|
|
418
|
+
}, timeout)
|
|
419
|
+
|
|
420
|
+
this.pendingRequests.set(requestId, { resolve, reject, timeout: timeoutHandle })
|
|
421
|
+
|
|
422
|
+
try {
|
|
423
|
+
this.ws.send(data)
|
|
424
|
+
this.log('Sent binary', { requestId, size: data.byteLength })
|
|
425
|
+
} catch (error) {
|
|
426
|
+
clearTimeout(timeoutHandle)
|
|
427
|
+
this.pendingRequests.delete(requestId)
|
|
428
|
+
reject(error)
|
|
429
|
+
}
|
|
430
|
+
})
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/** Parse and route binary frames (state delta, room events, room state) */
|
|
434
|
+
private handleBinaryMessage(buffer: Uint8Array): void {
|
|
435
|
+
if (buffer.length < 3) return
|
|
436
|
+
|
|
437
|
+
const frameType = buffer[0]
|
|
438
|
+
|
|
439
|
+
if (frameType === 0x01) {
|
|
440
|
+
// BINARY_STATE_DELTA: [0x01][idLen:u8][compId:utf8][payload]
|
|
441
|
+
const idLen = buffer[1]
|
|
442
|
+
if (buffer.length < 2 + idLen) return
|
|
443
|
+
const componentId = new TextDecoder().decode(buffer.subarray(2, 2 + idLen))
|
|
444
|
+
const payload = buffer.subarray(2 + idLen)
|
|
445
|
+
|
|
446
|
+
const callback = this.binaryCallbacks.get(componentId)
|
|
447
|
+
if (callback) callback(payload)
|
|
448
|
+
} else if (frameType === 0x02 || frameType === 0x03) {
|
|
449
|
+
// BINARY_ROOM_EVENT (0x02) or BINARY_ROOM_STATE (0x03)
|
|
450
|
+
// Route to all registered room binary handlers (RoomManager instances)
|
|
451
|
+
for (const callback of this.roomBinaryHandlers) {
|
|
452
|
+
callback(buffer)
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/** Register a handler for binary room frames (0x02 / 0x03). Returns unsubscribe. */
|
|
458
|
+
registerRoomBinaryHandler(callback: (frame: Uint8Array) => void): () => void {
|
|
459
|
+
this.roomBinaryHandlers.add(callback)
|
|
460
|
+
return () => { this.roomBinaryHandlers.delete(callback) }
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/** Register a binary message handler for a component */
|
|
464
|
+
registerBinaryHandler(componentId: string, callback: (payload: Uint8Array) => void): () => void {
|
|
465
|
+
this.binaryCallbacks.set(componentId, callback)
|
|
466
|
+
return () => { this.binaryCallbacks.delete(componentId) }
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/** Register a component message callback */
|
|
470
|
+
registerComponent(componentId: string, callback: ComponentCallback): () => void {
|
|
471
|
+
this.log('Registering component', componentId)
|
|
472
|
+
this.componentCallbacks.set(componentId, callback)
|
|
473
|
+
return () => {
|
|
474
|
+
this.componentCallbacks.delete(componentId)
|
|
475
|
+
this.log('Unregistered component', componentId)
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/** Unregister a component */
|
|
480
|
+
unregisterComponent(componentId: string): void {
|
|
481
|
+
this.componentCallbacks.delete(componentId)
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/** Authenticate (or re-authenticate) the WebSocket connection */
|
|
485
|
+
async authenticate(credentials: LiveAuthOptions): Promise<boolean> {
|
|
486
|
+
try {
|
|
487
|
+
const response = await this.sendMessageAndWait(
|
|
488
|
+
{ type: 'AUTH', payload: credentials } as any,
|
|
489
|
+
5000
|
|
490
|
+
)
|
|
491
|
+
const payload = (response as any).payload
|
|
492
|
+
const success = payload?.authenticated || false
|
|
493
|
+
this.setState({
|
|
494
|
+
authenticated: success,
|
|
495
|
+
auth: {
|
|
496
|
+
authenticated: success,
|
|
497
|
+
session: success ? (payload?.session || null) : null,
|
|
498
|
+
},
|
|
499
|
+
})
|
|
500
|
+
return success
|
|
501
|
+
} catch {
|
|
502
|
+
return false
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/** Get the raw WebSocket instance */
|
|
507
|
+
getWebSocket(): WebSocket | null {
|
|
508
|
+
return this.ws
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/** Destroy the connection and clean up all resources */
|
|
512
|
+
destroy(): void {
|
|
513
|
+
this.disconnect()
|
|
514
|
+
this.componentCallbacks.clear()
|
|
515
|
+
this.binaryCallbacks.clear()
|
|
516
|
+
this.roomBinaryHandlers.clear()
|
|
517
|
+
for (const [, req] of this.pendingRequests) {
|
|
518
|
+
clearTimeout(req.timeout)
|
|
519
|
+
req.reject(new Error('Connection destroyed'))
|
|
520
|
+
}
|
|
521
|
+
this.pendingRequests.clear()
|
|
522
|
+
this.stateListeners.clear()
|
|
523
|
+
}
|
|
524
|
+
}
|