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.
- package/README.md +2 -1
- package/package.json +15 -2
- package/runtime/context.js +374 -0
- package/runtime/graphql.js +1356 -0
- package/runtime/index.js +6 -0
- package/runtime/logger.js +2 -1
- package/runtime/websocket.js +874 -0
- package/types/context.d.ts +171 -0
- package/types/graphql.d.ts +490 -0
- package/types/index.d.ts +15 -0
- package/types/websocket.d.ts +347 -0
|
@@ -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
|
+
};
|