@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.
@@ -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
+ }