pulse-js-framework 1.7.11 → 1.7.12

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,874 @@
1
+ /**
2
+ * Pulse WebSocket - Reactive WebSocket client for Pulse Framework
3
+ * @module pulse-js-framework/runtime/websocket
4
+ *
5
+ * Provides WebSocket support with:
6
+ * - Auto-reconnection with exponential backoff
7
+ * - Heartbeat/ping-pong mechanism
8
+ * - Message queuing when disconnected
9
+ * - Full integration with Pulse reactivity
10
+ *
11
+ * @example
12
+ * import { createWebSocket, useWebSocket } from 'pulse-js-framework/runtime/websocket';
13
+ *
14
+ * // Low-level API
15
+ * const ws = createWebSocket('wss://api.example.com/ws', {
16
+ * reconnect: true,
17
+ * heartbeat: true
18
+ * });
19
+ *
20
+ * ws.on('message', (data) => console.log('Received:', data));
21
+ * ws.send({ type: 'subscribe', channel: 'updates' });
22
+ *
23
+ * // Reactive hook
24
+ * const { connected, lastMessage, send } = useWebSocket('wss://api.example.com/ws');
25
+ */
26
+
27
+ import { pulse, computed, batch, onCleanup } from './pulse.js';
28
+ import { createVersionedAsync } from './async.js';
29
+ import { RuntimeError, createErrorMessage, getDocsUrl } from './errors.js';
30
+ import { loggers } from './logger.js';
31
+
32
+ // ============================================================================
33
+ // WebSocket Error Class
34
+ // ============================================================================
35
+
36
+ /**
37
+ * WebSocket-specific error suggestions
38
+ */
39
+ const WEBSOCKET_SUGGESTIONS = {
40
+ CONNECT_FAILED: 'Check the WebSocket URL and ensure the server is running. Verify CORS settings if connecting cross-origin.',
41
+ CLOSE: 'The connection was closed. Check close code for reason. Common codes: 1000 (normal), 1001 (going away), 1006 (abnormal).',
42
+ TIMEOUT: 'Connection timed out. Check network conditions or increase the timeout value.',
43
+ PARSE_ERROR: 'Failed to parse incoming message. Check message format matches expected type (JSON/binary).',
44
+ SEND_FAILED: 'Failed to send message. Connection may be closed or message exceeds size limit.',
45
+ RECONNECT_EXHAUSTED: 'Maximum reconnection attempts reached. Consider increasing maxRetries or checking server availability.'
46
+ };
47
+
48
+ /**
49
+ * WebSocket Error with connection context
50
+ */
51
+ export class WebSocketError extends RuntimeError {
52
+ /**
53
+ * @param {string} message - Error message
54
+ * @param {Object} [options={}] - Error options
55
+ * @param {string} [options.code] - Error code
56
+ * @param {string} [options.url] - WebSocket URL
57
+ * @param {number} [options.closeCode] - WebSocket close code
58
+ * @param {string} [options.closeReason] - WebSocket close reason
59
+ * @param {Event} [options.event] - Original event
60
+ */
61
+ constructor(message, options = {}) {
62
+ const code = options.code || 'WEBSOCKET_ERROR';
63
+ const formattedMessage = createErrorMessage({
64
+ code,
65
+ message,
66
+ context: options.context,
67
+ suggestion: options.suggestion || WEBSOCKET_SUGGESTIONS[code]
68
+ });
69
+
70
+ super(formattedMessage, { code });
71
+
72
+ this.name = 'WebSocketError';
73
+ this.code = code;
74
+ this.url = options.url || null;
75
+ this.closeCode = options.closeCode || null;
76
+ this.closeReason = options.closeReason || null;
77
+ this.event = options.event || null;
78
+ this.isWebSocketError = true;
79
+ }
80
+
81
+ /**
82
+ * Check if an error is a WebSocketError
83
+ * @param {any} error - The error to check
84
+ * @returns {boolean}
85
+ */
86
+ static isWebSocketError(error) {
87
+ return error?.isWebSocketError === true;
88
+ }
89
+
90
+ isConnectFailed() { return this.code === 'CONNECT_FAILED'; }
91
+ isClose() { return this.code === 'CLOSE'; }
92
+ isTimeout() { return this.code === 'TIMEOUT'; }
93
+ isParseError() { return this.code === 'PARSE_ERROR'; }
94
+ isSendFailed() { return this.code === 'SEND_FAILED'; }
95
+ }
96
+
97
+ // ============================================================================
98
+ // Message Interceptor Manager
99
+ // ============================================================================
100
+
101
+ /**
102
+ * Manages message interceptors for incoming/outgoing messages
103
+ */
104
+ class MessageInterceptorManager {
105
+ #handlers = new Map();
106
+ #idCounter = 0;
107
+
108
+ /**
109
+ * Add a message interceptor
110
+ * @param {Function} onMessage - Transform message data
111
+ * @param {Function} [onError] - Handle interceptor errors
112
+ * @returns {number} Interceptor ID
113
+ */
114
+ use(onMessage, onError) {
115
+ const id = this.#idCounter++;
116
+ this.#handlers.set(id, { onMessage, onError });
117
+ return id;
118
+ }
119
+
120
+ /**
121
+ * Remove an interceptor by ID
122
+ * @param {number} id - The interceptor ID
123
+ */
124
+ eject(id) {
125
+ this.#handlers.delete(id);
126
+ }
127
+
128
+ /**
129
+ * Remove all interceptors
130
+ */
131
+ clear() {
132
+ this.#handlers.clear();
133
+ }
134
+
135
+ /**
136
+ * Get the number of interceptors
137
+ * @returns {number}
138
+ */
139
+ get size() {
140
+ return this.#handlers.size;
141
+ }
142
+
143
+ /**
144
+ * Iterate through handlers
145
+ */
146
+ *[Symbol.iterator]() {
147
+ for (const handler of this.#handlers.values()) {
148
+ yield handler;
149
+ }
150
+ }
151
+ }
152
+
153
+ // ============================================================================
154
+ // Internal Helpers
155
+ // ============================================================================
156
+
157
+ /**
158
+ * Calculate exponential backoff with jitter
159
+ * @param {number} attempt - Current attempt number (0-based)
160
+ * @param {number} baseDelay - Base delay in ms
161
+ * @param {number} maxDelay - Maximum delay in ms
162
+ * @param {number} jitterFactor - Jitter factor (0-1)
163
+ * @returns {number} Delay in ms
164
+ */
165
+ function calculateBackoff(attempt, baseDelay, maxDelay, jitterFactor) {
166
+ const exponential = baseDelay * Math.pow(2, attempt);
167
+ const capped = Math.min(exponential, maxDelay);
168
+ const jitter = capped * jitterFactor * (Math.random() - 0.5);
169
+ return Math.floor(capped + jitter);
170
+ }
171
+
172
+ /**
173
+ * Simple event emitter for WebSocket events
174
+ */
175
+ class EventEmitter {
176
+ #listeners = new Map();
177
+
178
+ on(event, handler) {
179
+ if (!this.#listeners.has(event)) {
180
+ this.#listeners.set(event, new Set());
181
+ }
182
+ this.#listeners.get(event).add(handler);
183
+ return () => this.off(event, handler);
184
+ }
185
+
186
+ off(event, handler) {
187
+ this.#listeners.get(event)?.delete(handler);
188
+ }
189
+
190
+ once(event, handler) {
191
+ const wrapper = (...args) => {
192
+ this.off(event, wrapper);
193
+ handler(...args);
194
+ };
195
+ return this.on(event, wrapper);
196
+ }
197
+
198
+ emit(event, ...args) {
199
+ this.#listeners.get(event)?.forEach(handler => {
200
+ try {
201
+ handler(...args);
202
+ } catch (err) {
203
+ console.error(`Error in ${event} handler:`, err);
204
+ }
205
+ });
206
+ }
207
+
208
+ removeAllListeners() {
209
+ this.#listeners.clear();
210
+ }
211
+ }
212
+
213
+ /**
214
+ * Message queue for offline message buffering
215
+ */
216
+ class MessageQueue {
217
+ #queue = [];
218
+ #maxSize;
219
+
220
+ constructor(maxSize = 100) {
221
+ this.#maxSize = maxSize;
222
+ }
223
+
224
+ enqueue(message) {
225
+ if (this.#queue.length >= this.#maxSize) {
226
+ this.#queue.shift(); // Drop oldest (FIFO overflow)
227
+ }
228
+ this.#queue.push(message);
229
+ }
230
+
231
+ dequeueAll() {
232
+ const messages = [...this.#queue];
233
+ this.#queue = [];
234
+ return messages;
235
+ }
236
+
237
+ get length() { return this.#queue.length; }
238
+ clear() { this.#queue = []; }
239
+ }
240
+
241
+ /**
242
+ * Heartbeat manager for connection health monitoring
243
+ */
244
+ class HeartbeatManager {
245
+ #sendFn;
246
+ #options;
247
+ #heartbeatTimer = null;
248
+ #pongTimer = null;
249
+ #missedPongs = 0;
250
+ #onTimeout;
251
+
252
+ constructor(sendFn, options, onTimeout) {
253
+ this.#sendFn = sendFn;
254
+ this.#options = options;
255
+ this.#onTimeout = onTimeout;
256
+ }
257
+
258
+ start() {
259
+ this.stop();
260
+ this.#heartbeatTimer = setInterval(() => this.#sendPing(), this.#options.heartbeatInterval);
261
+ }
262
+
263
+ stop() {
264
+ if (this.#heartbeatTimer) {
265
+ clearInterval(this.#heartbeatTimer);
266
+ this.#heartbeatTimer = null;
267
+ }
268
+ if (this.#pongTimer) {
269
+ clearTimeout(this.#pongTimer);
270
+ this.#pongTimer = null;
271
+ }
272
+ this.#missedPongs = 0;
273
+ }
274
+
275
+ #sendPing() {
276
+ try {
277
+ const msg = this.#options.heartbeatMessage;
278
+ this.#sendFn(typeof msg === 'string' ? msg : JSON.stringify(msg));
279
+
280
+ this.#pongTimer = setTimeout(() => {
281
+ this.#missedPongs++;
282
+ if (this.#missedPongs >= 2) {
283
+ this.#onTimeout();
284
+ }
285
+ }, this.#options.heartbeatTimeout);
286
+ } catch {
287
+ // Connection may be closed
288
+ }
289
+ }
290
+
291
+ handlePong() {
292
+ if (this.#pongTimer) {
293
+ clearTimeout(this.#pongTimer);
294
+ this.#pongTimer = null;
295
+ }
296
+ this.#missedPongs = 0;
297
+ }
298
+ }
299
+
300
+ // ============================================================================
301
+ // Default Configuration
302
+ // ============================================================================
303
+
304
+ const DEFAULT_OPTIONS = {
305
+ // Connection
306
+ protocols: [],
307
+ connectTimeout: 10000,
308
+ autoConnect: true,
309
+
310
+ // Reconnection
311
+ reconnect: true,
312
+ maxRetries: 5,
313
+ baseDelay: 1000,
314
+ maxDelay: 30000,
315
+ jitterFactor: 0.3,
316
+
317
+ // Heartbeat
318
+ heartbeat: false,
319
+ heartbeatInterval: 30000,
320
+ heartbeatTimeout: 10000,
321
+ heartbeatMessage: 'ping',
322
+ isPong: (msg) => msg === 'pong' || (typeof msg === 'object' && msg?.type === 'pong'),
323
+
324
+ // Message handling
325
+ queueWhileDisconnected: true,
326
+ maxQueueSize: 100,
327
+ messageType: 'json',
328
+ autoParseJson: true
329
+ };
330
+
331
+ // ============================================================================
332
+ // createWebSocket - Low-Level Factory
333
+ // ============================================================================
334
+
335
+ /**
336
+ * Create a WebSocket client with auto-reconnection, heartbeat, and message queuing.
337
+ *
338
+ * @param {string} url - WebSocket URL (ws:// or wss://)
339
+ * @param {Object} [options={}] - Configuration options
340
+ * @returns {Object} WebSocket instance with reactive state and controls
341
+ *
342
+ * @example
343
+ * const ws = createWebSocket('wss://api.example.com/ws', {
344
+ * reconnect: true,
345
+ * maxRetries: 5,
346
+ * heartbeat: true,
347
+ * heartbeatInterval: 30000
348
+ * });
349
+ *
350
+ * // Reactive state
351
+ * effect(() => {
352
+ * console.log('Connected:', ws.connected.get());
353
+ * });
354
+ *
355
+ * // Send messages
356
+ * ws.send({ type: 'subscribe', channel: 'updates' });
357
+ *
358
+ * // Listen for messages
359
+ * ws.on('message', (data) => console.log('Received:', data));
360
+ *
361
+ * // Clean up
362
+ * ws.dispose();
363
+ */
364
+ export function createWebSocket(url, options = {}) {
365
+ const opts = { ...DEFAULT_OPTIONS, ...options };
366
+ const log = loggers.websocket;
367
+
368
+ // === Reactive State ===
369
+ const state = pulse('closed');
370
+ const connected = computed(() => state.get() === 'open');
371
+ const reconnecting = pulse(false);
372
+ const reconnectAttempt = pulse(0);
373
+ const error = pulse(null);
374
+ const queuedCount = pulse(0);
375
+
376
+ // === Internal State ===
377
+ let socket = null;
378
+ let intentionalClose = false;
379
+ const messageQueue = new MessageQueue(opts.maxQueueSize);
380
+ const eventEmitter = new EventEmitter();
381
+ const versionController = createVersionedAsync();
382
+
383
+ // Heartbeat manager (created lazily)
384
+ let heartbeatManager = null;
385
+ if (opts.heartbeat) {
386
+ heartbeatManager = new HeartbeatManager(
387
+ (msg) => socket?.send(msg),
388
+ opts,
389
+ () => forceReconnect('Heartbeat timeout')
390
+ );
391
+ }
392
+
393
+ // === Interceptors ===
394
+ const interceptors = {
395
+ incoming: new MessageInterceptorManager(),
396
+ outgoing: new MessageInterceptorManager()
397
+ };
398
+
399
+ // === Connection Logic ===
400
+
401
+ function connect() {
402
+ if (state.get() === 'open' || state.get() === 'connecting') {
403
+ return;
404
+ }
405
+
406
+ intentionalClose = false;
407
+ const ctx = versionController.begin();
408
+ state.set('connecting');
409
+ error.set(null);
410
+
411
+ // Connection timeout
412
+ const timeoutId = ctx.setTimeout(() => {
413
+ if (socket) {
414
+ socket.close();
415
+ }
416
+ handleError(new WebSocketError('Connection timeout', {
417
+ code: 'TIMEOUT',
418
+ url
419
+ }), ctx);
420
+ }, opts.connectTimeout);
421
+
422
+ try {
423
+ socket = new WebSocket(url, opts.protocols);
424
+ socket.binaryType = 'arraybuffer';
425
+
426
+ socket.onopen = (event) => {
427
+ ctx.clearTimeout(timeoutId);
428
+ ctx.ifCurrent(() => {
429
+ const wasReconnecting = reconnecting.get();
430
+
431
+ batch(() => {
432
+ state.set('open');
433
+ reconnecting.set(false);
434
+ reconnectAttempt.set(0);
435
+ });
436
+
437
+ if (heartbeatManager) {
438
+ heartbeatManager.start();
439
+ }
440
+
441
+ // Flush queued messages
442
+ flushQueue();
443
+
444
+ eventEmitter.emit('open', event);
445
+ opts.onOpen?.(event);
446
+
447
+ if (wasReconnecting) {
448
+ opts.onReconnected?.();
449
+ }
450
+
451
+ log.info('WebSocket connected');
452
+ });
453
+ };
454
+
455
+ socket.onclose = (event) => handleClose(event, ctx);
456
+ socket.onerror = (event) => handleSocketError(event, ctx);
457
+ socket.onmessage = (event) => handleMessage(event, ctx);
458
+
459
+ } catch (err) {
460
+ ctx.clearTimeout(timeoutId);
461
+ handleError(new WebSocketError(err.message, {
462
+ code: 'CONNECT_FAILED',
463
+ url
464
+ }), ctx);
465
+ }
466
+ }
467
+
468
+ function handleClose(event, ctx) {
469
+ ctx.ifCurrent(() => {
470
+ if (heartbeatManager) {
471
+ heartbeatManager.stop();
472
+ }
473
+
474
+ state.set('closed');
475
+
476
+ eventEmitter.emit('close', event);
477
+ opts.onClose?.(event);
478
+
479
+ log.info(`WebSocket closed: code=${event.code}, reason=${event.reason || 'none'}`);
480
+
481
+ // Auto-reconnect if enabled and not intentionally closed
482
+ if (opts.reconnect && !intentionalClose && event.code !== 1000) {
483
+ scheduleReconnect();
484
+ }
485
+ });
486
+ }
487
+
488
+ function handleSocketError(event, ctx) {
489
+ ctx.ifCurrent(() => {
490
+ const wsError = new WebSocketError('WebSocket error', {
491
+ code: 'CONNECT_FAILED',
492
+ url,
493
+ event
494
+ });
495
+ error.set(wsError);
496
+ eventEmitter.emit('error', wsError);
497
+ opts.onError?.(wsError);
498
+ log.error('WebSocket error');
499
+ });
500
+ }
501
+
502
+ function handleError(wsError, ctx) {
503
+ ctx.ifCurrent(() => {
504
+ error.set(wsError);
505
+ state.set('closed');
506
+ eventEmitter.emit('error', wsError);
507
+ opts.onError?.(wsError);
508
+
509
+ if (opts.reconnect && !intentionalClose) {
510
+ scheduleReconnect();
511
+ }
512
+ });
513
+ }
514
+
515
+ function handleMessage(event, ctx) {
516
+ ctx.ifCurrent(() => {
517
+ let data = event.data;
518
+
519
+ // Handle binary data
520
+ if (data instanceof ArrayBuffer) {
521
+ data = new Uint8Array(data);
522
+ }
523
+
524
+ // Auto-parse JSON for text messages
525
+ if (opts.autoParseJson && typeof data === 'string') {
526
+ try {
527
+ data = JSON.parse(data);
528
+ } catch {
529
+ // Keep as string if not valid JSON
530
+ }
531
+ }
532
+
533
+ // Check for pong (heartbeat response)
534
+ if (opts.heartbeat && opts.isPong(data)) {
535
+ heartbeatManager?.handlePong();
536
+ return; // Don't emit pong as regular message
537
+ }
538
+
539
+ // Run through incoming interceptors
540
+ for (const { onMessage, onError } of interceptors.incoming) {
541
+ try {
542
+ data = onMessage(data);
543
+ } catch (err) {
544
+ if (onError) {
545
+ onError(err);
546
+ } else {
547
+ log.error('Incoming interceptor error:', err);
548
+ }
549
+ }
550
+ }
551
+
552
+ eventEmitter.emit('message', data, event);
553
+ opts.onMessage?.(data, event);
554
+ });
555
+ }
556
+
557
+ function scheduleReconnect() {
558
+ const attempt = reconnectAttempt.get();
559
+
560
+ if (opts.maxRetries > 0 && attempt >= opts.maxRetries) {
561
+ error.set(new WebSocketError('Max reconnection attempts reached', {
562
+ code: 'RECONNECT_EXHAUSTED',
563
+ url
564
+ }));
565
+ reconnecting.set(false);
566
+ return;
567
+ }
568
+
569
+ reconnecting.set(true);
570
+ reconnectAttempt.update(n => n + 1);
571
+
572
+ const delay = calculateBackoff(
573
+ attempt,
574
+ opts.baseDelay,
575
+ opts.maxDelay,
576
+ opts.jitterFactor
577
+ );
578
+
579
+ log.info(`Reconnecting in ${delay}ms (attempt ${attempt + 1})`);
580
+ opts.onReconnecting?.(attempt + 1, delay);
581
+
582
+ const ctx = versionController.begin();
583
+ ctx.setTimeout(() => {
584
+ ctx.ifCurrent(() => connect());
585
+ }, delay);
586
+ }
587
+
588
+ function forceReconnect(reason) {
589
+ log.warn(`Forcing reconnect: ${reason}`);
590
+ if (socket && socket.readyState < 2) {
591
+ socket.close(4000, reason);
592
+ }
593
+ // scheduleReconnect will be called by onclose
594
+ }
595
+
596
+ function flushQueue() {
597
+ const queued = messageQueue.dequeueAll();
598
+ queuedCount.set(0);
599
+
600
+ for (const msg of queued) {
601
+ try {
602
+ socket.send(msg);
603
+ } catch (err) {
604
+ log.error('Failed to send queued message:', err);
605
+ }
606
+ }
607
+
608
+ if (queued.length > 0) {
609
+ log.info(`Flushed ${queued.length} queued messages`);
610
+ }
611
+ }
612
+
613
+ // === Public API ===
614
+
615
+ function send(data) {
616
+ let payload = data;
617
+
618
+ // Handle different data types
619
+ if (data instanceof ArrayBuffer || data instanceof Blob) {
620
+ payload = data;
621
+ } else if (ArrayBuffer.isView(data)) {
622
+ payload = data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength);
623
+ } else if (typeof data === 'object' && opts.messageType === 'json') {
624
+ payload = JSON.stringify(data);
625
+ }
626
+
627
+ // Run through outgoing interceptors
628
+ for (const { onMessage, onError } of interceptors.outgoing) {
629
+ try {
630
+ payload = onMessage(payload);
631
+ } catch (err) {
632
+ if (onError) {
633
+ onError(err);
634
+ } else {
635
+ log.error('Outgoing interceptor error:', err);
636
+ }
637
+ }
638
+ }
639
+
640
+ // Queue if disconnected
641
+ if (state.get() !== 'open') {
642
+ if (opts.queueWhileDisconnected) {
643
+ messageQueue.enqueue(payload);
644
+ queuedCount.set(messageQueue.length);
645
+ log.debug('Message queued (disconnected)');
646
+ return;
647
+ }
648
+ throw new WebSocketError('Cannot send: not connected', {
649
+ code: 'SEND_FAILED',
650
+ url
651
+ });
652
+ }
653
+
654
+ socket.send(payload);
655
+ }
656
+
657
+ function sendJson(data) {
658
+ send(JSON.stringify(data));
659
+ }
660
+
661
+ function sendBinary(data) {
662
+ if (!(data instanceof ArrayBuffer) && !ArrayBuffer.isView(data) && !(data instanceof Blob)) {
663
+ throw new WebSocketError('sendBinary requires ArrayBuffer, TypedArray, or Blob', {
664
+ code: 'SEND_FAILED'
665
+ });
666
+ }
667
+ send(data);
668
+ }
669
+
670
+ function disconnect(code = 1000, reason) {
671
+ intentionalClose = true;
672
+ opts.reconnect = false;
673
+ if (socket && socket.readyState < 2) {
674
+ state.set('closing');
675
+ socket.close(code, reason);
676
+ }
677
+ }
678
+
679
+ function dispose() {
680
+ intentionalClose = true;
681
+ versionController.abort();
682
+ if (heartbeatManager) {
683
+ heartbeatManager.stop();
684
+ }
685
+ messageQueue.clear();
686
+ eventEmitter.removeAllListeners();
687
+ if (socket) {
688
+ socket.onopen = null;
689
+ socket.onclose = null;
690
+ socket.onerror = null;
691
+ socket.onmessage = null;
692
+ if (socket.readyState < 2) {
693
+ socket.close(1000, 'Disposed');
694
+ }
695
+ }
696
+ state.set('closed');
697
+ }
698
+
699
+ // Auto-connect if enabled
700
+ if (opts.autoConnect) {
701
+ connect();
702
+ }
703
+
704
+ return {
705
+ // Reactive state
706
+ state,
707
+ connected,
708
+ reconnecting,
709
+ reconnectAttempt,
710
+ error,
711
+ queuedCount,
712
+
713
+ // Methods
714
+ connect,
715
+ disconnect,
716
+ send,
717
+ sendJson,
718
+ sendBinary,
719
+ dispose,
720
+
721
+ // Interceptors
722
+ interceptors,
723
+
724
+ // Events
725
+ on: (event, handler) => eventEmitter.on(event, handler),
726
+ off: (event, handler) => eventEmitter.off(event, handler),
727
+ once: (event, handler) => eventEmitter.once(event, handler),
728
+
729
+ // Properties
730
+ get url() { return url; },
731
+ get socket() { return socket; },
732
+ get options() { return opts; }
733
+ };
734
+ }
735
+
736
+ // ============================================================================
737
+ // useWebSocket - Reactive Hook
738
+ // ============================================================================
739
+
740
+ /**
741
+ * Reactive WebSocket hook with automatic cleanup.
742
+ *
743
+ * @param {string} url - WebSocket URL
744
+ * @param {Object} [options={}] - Configuration options
745
+ * @returns {Object} Reactive WebSocket state and controls
746
+ *
747
+ * @example
748
+ * const { connected, lastMessage, send } = useWebSocket('wss://api.example.com/ws', {
749
+ * immediate: true,
750
+ * messageHistorySize: 100,
751
+ * onMessage: (data) => console.log('Received:', data)
752
+ * });
753
+ *
754
+ * effect(() => {
755
+ * if (connected.get()) {
756
+ * send({ type: 'subscribe', channel: 'updates' });
757
+ * }
758
+ * });
759
+ *
760
+ * effect(() => {
761
+ * const msg = lastMessage.get();
762
+ * if (msg) {
763
+ * console.log('Latest message:', msg);
764
+ * }
765
+ * });
766
+ */
767
+ export function useWebSocket(url, options = {}) {
768
+ const {
769
+ immediate = true,
770
+ onMessage,
771
+ onOpen,
772
+ onClose,
773
+ onError,
774
+ initialData = null,
775
+ messageHistorySize = 0,
776
+ ...wsOptions
777
+ } = options;
778
+
779
+ // Create WebSocket instance
780
+ const ws = createWebSocket(url, {
781
+ ...wsOptions,
782
+ autoConnect: false
783
+ });
784
+
785
+ // Additional reactive state for messages
786
+ const lastMessage = pulse(initialData);
787
+ const messages = pulse([]);
788
+
789
+ // Race condition handling
790
+ const versionController = createVersionedAsync();
791
+
792
+ // Message handler with race condition protection
793
+ const handleMessage = (data) => {
794
+ const ctx = versionController.begin();
795
+ ctx.ifCurrent(() => {
796
+ batch(() => {
797
+ lastMessage.set(data);
798
+
799
+ if (messageHistorySize > 0) {
800
+ messages.update(prev => {
801
+ const next = [...prev, data];
802
+ if (next.length > messageHistorySize) {
803
+ return next.slice(-messageHistorySize);
804
+ }
805
+ return next;
806
+ });
807
+ }
808
+ });
809
+
810
+ onMessage?.(data);
811
+ });
812
+ };
813
+
814
+ // Subscribe to events
815
+ ws.on('message', handleMessage);
816
+ ws.on('open', (e) => onOpen?.(e));
817
+ ws.on('close', (e) => onClose?.(e));
818
+ ws.on('error', (e) => onError?.(e));
819
+
820
+ // Auto-cleanup on effect disposal
821
+ onCleanup(() => {
822
+ versionController.abort();
823
+ ws.dispose();
824
+ });
825
+
826
+ // Connect if immediate
827
+ if (immediate) {
828
+ ws.connect();
829
+ }
830
+
831
+ return {
832
+ // State
833
+ connected: ws.connected,
834
+ lastMessage,
835
+ messages,
836
+ error: ws.error,
837
+ reconnecting: ws.reconnecting,
838
+ state: ws.state,
839
+ queuedCount: ws.queuedCount,
840
+ reconnectAttempt: ws.reconnectAttempt,
841
+
842
+ // Methods
843
+ send: ws.send.bind(ws),
844
+ sendJson: ws.sendJson.bind(ws),
845
+ sendBinary: ws.sendBinary.bind(ws),
846
+ connect: ws.connect.bind(ws),
847
+ disconnect: ws.disconnect.bind(ws),
848
+
849
+ // Underlying instance
850
+ ws,
851
+
852
+ // Convenience methods
853
+ clearMessages() {
854
+ messages.set([]);
855
+ },
856
+
857
+ clearError() {
858
+ ws.error.set(null);
859
+ }
860
+ };
861
+ }
862
+
863
+ // ============================================================================
864
+ // Exports
865
+ // ============================================================================
866
+
867
+ export { MessageInterceptorManager };
868
+
869
+ export default {
870
+ createWebSocket,
871
+ useWebSocket,
872
+ WebSocketError,
873
+ MessageInterceptorManager
874
+ };