@fluxstack/live-client 0.5.0 → 0.5.1

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,508 @@
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 startHeartbeat(): void {
249
+ this.stopHeartbeat()
250
+ this.heartbeatInterval = setInterval(() => {
251
+ if (this.ws?.readyState === WebSocket.OPEN) {
252
+ for (const componentId of this.componentCallbacks.keys()) {
253
+ this.sendMessage({
254
+ type: 'COMPONENT_PING',
255
+ componentId,
256
+ timestamp: Date.now(),
257
+ }).catch(() => {})
258
+ }
259
+ }
260
+ }, this.options.heartbeatInterval)
261
+ }
262
+
263
+ private stopHeartbeat(): void {
264
+ if (this.heartbeatInterval) {
265
+ clearInterval(this.heartbeatInterval)
266
+ this.heartbeatInterval = null
267
+ }
268
+ }
269
+
270
+ private handleMessage(response: WebSocketResponse): void {
271
+ // Handle connection established
272
+ if (response.type === 'CONNECTION_ESTABLISHED') {
273
+ this.setState({
274
+ connectionId: response.connectionId || null,
275
+ authenticated: (response as any).authenticated || false,
276
+ })
277
+
278
+ // Send AUTH message if credentials provided (always via socket, never in URL)
279
+ const auth = this.options.auth
280
+ if (auth && Object.keys(auth).some(k => auth[k])) {
281
+ this.sendMessageAndWait({ type: 'AUTH', payload: auth } as any)
282
+ .then(authResp => {
283
+ const payload = (authResp as any).payload
284
+ if (payload?.authenticated) {
285
+ this.setState({
286
+ authenticated: true,
287
+ auth: { authenticated: true, session: payload.session || null },
288
+ })
289
+ }
290
+ })
291
+ .catch(() => {})
292
+ }
293
+ }
294
+
295
+ // Handle auth response
296
+ if (response.type === 'AUTH_RESPONSE') {
297
+ const payload = (response as any).payload
298
+ const authenticated = payload?.authenticated || false
299
+ this.setState({
300
+ authenticated,
301
+ auth: {
302
+ authenticated,
303
+ session: authenticated ? (payload?.session || null) : null,
304
+ },
305
+ })
306
+ }
307
+
308
+ // Handle pending requests (request-response pattern)
309
+ if (response.requestId && this.pendingRequests.has(response.requestId)) {
310
+ const request = this.pendingRequests.get(response.requestId)!
311
+ clearTimeout(request.timeout)
312
+ this.pendingRequests.delete(response.requestId)
313
+
314
+ if (response.success !== false) {
315
+ request.resolve(response)
316
+ } else {
317
+ if (response.error?.includes?.('COMPONENT_REHYDRATION_REQUIRED')) {
318
+ request.resolve(response)
319
+ } else {
320
+ request.reject(new Error(response.error || 'Request failed'))
321
+ }
322
+ }
323
+ return
324
+ }
325
+
326
+ // Broadcast messages go to ALL components (not just sender)
327
+ if (response.type === 'BROADCAST') {
328
+ this.componentCallbacks.forEach((callback, compId) => {
329
+ if (compId !== response.componentId) {
330
+ callback(response)
331
+ }
332
+ })
333
+ return
334
+ }
335
+
336
+ // Route message to specific component
337
+ if (response.componentId) {
338
+ const callback = this.componentCallbacks.get(response.componentId)
339
+ if (callback) {
340
+ callback(response)
341
+ } else {
342
+ this.log('No callback registered for component:', response.componentId)
343
+ }
344
+ }
345
+ }
346
+
347
+ /** Send message without waiting for response */
348
+ async sendMessage(message: WebSocketMessage): Promise<void> {
349
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
350
+ throw new Error('WebSocket is not connected')
351
+ }
352
+ const messageWithTimestamp = { ...message, timestamp: Date.now() }
353
+ this.ws.send(JSON.stringify(messageWithTimestamp))
354
+ this.log('Sent', { type: message.type, componentId: message.componentId })
355
+ }
356
+
357
+ /** Send message and wait for response */
358
+ async sendMessageAndWait(message: WebSocketMessage, timeout = 10000): Promise<WebSocketResponse> {
359
+ return new Promise((resolve, reject) => {
360
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
361
+ reject(new Error('WebSocket is not connected'))
362
+ return
363
+ }
364
+
365
+ const requestId = this.generateRequestId()
366
+
367
+ const timeoutHandle = setTimeout(() => {
368
+ this.pendingRequests.delete(requestId)
369
+ reject(new Error(`Request timeout after ${timeout}ms`))
370
+ }, timeout)
371
+
372
+ this.pendingRequests.set(requestId, { resolve, reject, timeout: timeoutHandle })
373
+
374
+ try {
375
+ const messageWithRequestId = {
376
+ ...message,
377
+ requestId,
378
+ expectResponse: true,
379
+ timestamp: Date.now(),
380
+ }
381
+ this.ws.send(JSON.stringify(messageWithRequestId))
382
+ this.log('Sent with requestId', { requestId, type: message.type })
383
+ } catch (error) {
384
+ clearTimeout(timeoutHandle)
385
+ this.pendingRequests.delete(requestId)
386
+ reject(error)
387
+ }
388
+ })
389
+ }
390
+
391
+ /** Send binary data and wait for response (for file uploads) */
392
+ async sendBinaryAndWait(data: ArrayBuffer, requestId: string, timeout = 10000): Promise<WebSocketResponse> {
393
+ return new Promise((resolve, reject) => {
394
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
395
+ reject(new Error('WebSocket is not connected'))
396
+ return
397
+ }
398
+
399
+ const timeoutHandle = setTimeout(() => {
400
+ this.pendingRequests.delete(requestId)
401
+ reject(new Error(`Binary request timeout after ${timeout}ms`))
402
+ }, timeout)
403
+
404
+ this.pendingRequests.set(requestId, { resolve, reject, timeout: timeoutHandle })
405
+
406
+ try {
407
+ this.ws.send(data)
408
+ this.log('Sent binary', { requestId, size: data.byteLength })
409
+ } catch (error) {
410
+ clearTimeout(timeoutHandle)
411
+ this.pendingRequests.delete(requestId)
412
+ reject(error)
413
+ }
414
+ })
415
+ }
416
+
417
+ /** Parse and route binary frames (state delta, room events, room state) */
418
+ private handleBinaryMessage(buffer: Uint8Array): void {
419
+ if (buffer.length < 3) return
420
+
421
+ const frameType = buffer[0]
422
+
423
+ if (frameType === 0x01) {
424
+ // BINARY_STATE_DELTA: [0x01][idLen:u8][compId:utf8][payload]
425
+ const idLen = buffer[1]
426
+ if (buffer.length < 2 + idLen) return
427
+ const componentId = new TextDecoder().decode(buffer.subarray(2, 2 + idLen))
428
+ const payload = buffer.subarray(2 + idLen)
429
+
430
+ const callback = this.binaryCallbacks.get(componentId)
431
+ if (callback) callback(payload)
432
+ } else if (frameType === 0x02 || frameType === 0x03) {
433
+ // BINARY_ROOM_EVENT (0x02) or BINARY_ROOM_STATE (0x03)
434
+ // Route to all registered room binary handlers (RoomManager instances)
435
+ for (const callback of this.roomBinaryHandlers) {
436
+ callback(buffer)
437
+ }
438
+ }
439
+ }
440
+
441
+ /** Register a handler for binary room frames (0x02 / 0x03). Returns unsubscribe. */
442
+ registerRoomBinaryHandler(callback: (frame: Uint8Array) => void): () => void {
443
+ this.roomBinaryHandlers.add(callback)
444
+ return () => { this.roomBinaryHandlers.delete(callback) }
445
+ }
446
+
447
+ /** Register a binary message handler for a component */
448
+ registerBinaryHandler(componentId: string, callback: (payload: Uint8Array) => void): () => void {
449
+ this.binaryCallbacks.set(componentId, callback)
450
+ return () => { this.binaryCallbacks.delete(componentId) }
451
+ }
452
+
453
+ /** Register a component message callback */
454
+ registerComponent(componentId: string, callback: ComponentCallback): () => void {
455
+ this.log('Registering component', componentId)
456
+ this.componentCallbacks.set(componentId, callback)
457
+ return () => {
458
+ this.componentCallbacks.delete(componentId)
459
+ this.log('Unregistered component', componentId)
460
+ }
461
+ }
462
+
463
+ /** Unregister a component */
464
+ unregisterComponent(componentId: string): void {
465
+ this.componentCallbacks.delete(componentId)
466
+ }
467
+
468
+ /** Authenticate (or re-authenticate) the WebSocket connection */
469
+ async authenticate(credentials: LiveAuthOptions): Promise<boolean> {
470
+ try {
471
+ const response = await this.sendMessageAndWait(
472
+ { type: 'AUTH', payload: credentials } as any,
473
+ 5000
474
+ )
475
+ const payload = (response as any).payload
476
+ const success = payload?.authenticated || false
477
+ this.setState({
478
+ authenticated: success,
479
+ auth: {
480
+ authenticated: success,
481
+ session: success ? (payload?.session || null) : null,
482
+ },
483
+ })
484
+ return success
485
+ } catch {
486
+ return false
487
+ }
488
+ }
489
+
490
+ /** Get the raw WebSocket instance */
491
+ getWebSocket(): WebSocket | null {
492
+ return this.ws
493
+ }
494
+
495
+ /** Destroy the connection and clean up all resources */
496
+ destroy(): void {
497
+ this.disconnect()
498
+ this.componentCallbacks.clear()
499
+ this.binaryCallbacks.clear()
500
+ this.roomBinaryHandlers.clear()
501
+ for (const [, req] of this.pendingRequests) {
502
+ clearTimeout(req.timeout)
503
+ req.reject(new Error('Connection destroyed'))
504
+ }
505
+ this.pendingRequests.clear()
506
+ this.stateListeners.clear()
507
+ }
508
+ }