@k256/sdk 0.1.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,786 @@
1
+ /**
2
+ * K256 WebSocket Client
3
+ *
4
+ * Production-grade WebSocket client with:
5
+ * - Automatic reconnection with exponential backoff
6
+ * - Binary and JSON mode support
7
+ * - Ping/pong keepalive
8
+ * - Heartbeat monitoring
9
+ * - Full error handling with RFC 6455 close codes
10
+ * - Type-safe event emitters
11
+ *
12
+ * @example
13
+ * ```typescript
14
+ * const client = new K256WebSocketClient({
15
+ * apiKey: 'your-api-key',
16
+ * mode: 'binary', // or 'json'
17
+ * onPoolUpdate: (update) => console.log(update),
18
+ * onError: (error) => console.error(error),
19
+ * });
20
+ *
21
+ * await client.connect();
22
+ * client.subscribe({ channels: ['pools', 'priority_fees'] });
23
+ * ```
24
+ */
25
+
26
+ import { decodeMessage, decodePoolUpdateBatch } from './decoder';
27
+ import { MessageType, type DecodedMessage, type PoolUpdateMessage } from './types';
28
+
29
+ /**
30
+ * RFC 6455 WebSocket Close Codes
31
+ * @see https://www.rfc-editor.org/rfc/rfc6455#section-7.4.1
32
+ */
33
+ export const CloseCode = {
34
+ /** 1000: Normal closure - connection completed successfully */
35
+ NORMAL: 1000,
36
+ /** 1001: Going away - server/client shutting down. Client: reconnect immediately */
37
+ GOING_AWAY: 1001,
38
+ /** 1002: Protocol error - invalid frame format. Client: fix client code */
39
+ PROTOCOL_ERROR: 1002,
40
+ /** 1003: Unsupported data - message type not supported */
41
+ UNSUPPORTED_DATA: 1003,
42
+ /** 1005: No status received (reserved, not sent over wire) */
43
+ NO_STATUS: 1005,
44
+ /** 1006: Abnormal closure - connection dropped without close frame */
45
+ ABNORMAL: 1006,
46
+ /** 1007: Invalid payload - malformed UTF-8 or data. Client: fix message format */
47
+ INVALID_PAYLOAD: 1007,
48
+ /** 1008: Policy violation - rate limit exceeded, auth failed. Client: check credentials/limits */
49
+ POLICY_VIOLATION: 1008,
50
+ /** 1009: Message too big - message exceeds size limits */
51
+ MESSAGE_TOO_BIG: 1009,
52
+ /** 1010: Missing extension - required extension not negotiated */
53
+ MISSING_EXTENSION: 1010,
54
+ /** 1011: Internal error - unexpected server error. Client: retry with backoff */
55
+ INTERNAL_ERROR: 1011,
56
+ /** 1012: Service restart - server is restarting. Client: reconnect after brief delay */
57
+ SERVICE_RESTART: 1012,
58
+ /** 1013: Try again later - server overloaded. Client: retry with backoff */
59
+ TRY_AGAIN_LATER: 1013,
60
+ /** 1014: Bad gateway - upstream connection failed */
61
+ BAD_GATEWAY: 1014,
62
+ /** 1015: TLS handshake failed (reserved, not sent over wire) */
63
+ TLS_HANDSHAKE: 1015,
64
+ } as const;
65
+
66
+ export type CloseCodeValue = typeof CloseCode[keyof typeof CloseCode];
67
+
68
+ /**
69
+ * Connection state
70
+ */
71
+ export type ConnectionState =
72
+ | 'disconnected'
73
+ | 'connecting'
74
+ | 'connected'
75
+ | 'reconnecting'
76
+ | 'closed';
77
+
78
+ /**
79
+ * Subscribe request options
80
+ */
81
+ export interface SubscribeOptions {
82
+ /** Channels to subscribe to: 'pools', 'priority_fees', 'blockhash' */
83
+ channels: string[];
84
+ /** Pool address filters (optional) */
85
+ pools?: string[];
86
+ /** Protocol filters (optional): 'Raydium AMM', 'Orca Whirlpool', etc. */
87
+ protocols?: string[];
88
+ /** Token pair filters (optional): [['mint1', 'mint2'], ...] */
89
+ tokenPairs?: string[][];
90
+ }
91
+
92
+ /**
93
+ * Quote subscription options
94
+ */
95
+ export interface SubscribeQuoteOptions {
96
+ /** Input token mint address */
97
+ inputMint: string;
98
+ /** Output token mint address */
99
+ outputMint: string;
100
+ /** Amount in base units (lamports/smallest unit) */
101
+ amount: number | string;
102
+ /** Slippage tolerance in basis points */
103
+ slippageBps: number;
104
+ /** How often to refresh the quote (ms) */
105
+ refreshIntervalMs?: number;
106
+ }
107
+
108
+ /**
109
+ * WebSocket client configuration
110
+ */
111
+ export interface K256WebSocketClientConfig {
112
+ /** API key for authentication */
113
+ apiKey: string;
114
+ /** Gateway URL (default: wss://gateway.k256.xyz/v1/ws) */
115
+ url?: string;
116
+ /** Message format: 'binary' (default, efficient) or 'json' (debugging) */
117
+ mode?: 'binary' | 'json';
118
+
119
+ // Reconnection settings
120
+ /** Enable automatic reconnection (default: true) */
121
+ autoReconnect?: boolean;
122
+ /** Initial reconnect delay in ms (default: 1000) */
123
+ reconnectDelayMs?: number;
124
+ /** Max reconnect delay in ms (default: 30000) */
125
+ maxReconnectDelayMs?: number;
126
+ /** Max reconnect attempts (default: Infinity) */
127
+ maxReconnectAttempts?: number;
128
+
129
+ // Keepalive settings
130
+ /** Ping interval in ms (default: 30000) */
131
+ pingIntervalMs?: number;
132
+ /** Pong timeout in ms - disconnect if no pong (default: 10000) */
133
+ pongTimeoutMs?: number;
134
+ /** Heartbeat timeout in ms - warn if no heartbeat (default: 15000) */
135
+ heartbeatTimeoutMs?: number;
136
+
137
+ // Event callbacks
138
+ /** Called when connection state changes */
139
+ onStateChange?: (state: ConnectionState, prevState: ConnectionState) => void;
140
+ /** Called on successful connection */
141
+ onConnect?: () => void;
142
+ /** Called on disconnection */
143
+ onDisconnect?: (code: number, reason: string, wasClean: boolean) => void;
144
+ /** Called on reconnection attempt */
145
+ onReconnecting?: (attempt: number, delayMs: number) => void;
146
+ /** Called on any error */
147
+ onError?: (error: K256WebSocketError) => void;
148
+
149
+ // Message callbacks
150
+ /** Called on subscription confirmed */
151
+ onSubscribed?: (data: DecodedMessage & { type: 'subscribed' }) => void;
152
+ /** Called on pool update */
153
+ onPoolUpdate?: (update: PoolUpdateMessage) => void;
154
+ /** Called on batched pool updates (for efficiency) */
155
+ onPoolUpdateBatch?: (updates: PoolUpdateMessage[]) => void;
156
+ /** Called on priority fees update */
157
+ onPriorityFees?: (data: DecodedMessage & { type: 'priority_fees' }) => void;
158
+ /** Called on blockhash update */
159
+ onBlockhash?: (data: DecodedMessage & { type: 'blockhash' }) => void;
160
+ /** Called on quote update */
161
+ onQuote?: (data: DecodedMessage & { type: 'quote' }) => void;
162
+ /** Called on quote subscription confirmed */
163
+ onQuoteSubscribed?: (data: DecodedMessage & { type: 'quote_subscribed' }) => void;
164
+ /** Called on heartbeat */
165
+ onHeartbeat?: (data: DecodedMessage & { type: 'heartbeat' }) => void;
166
+ /** Called on pong response (with round-trip latency) */
167
+ onPong?: (latencyMs: number) => void;
168
+ /** Called on any message (raw) */
169
+ onMessage?: (message: DecodedMessage) => void;
170
+ /** Called on raw binary message (for debugging) */
171
+ onRawMessage?: (data: ArrayBuffer | string) => void;
172
+ }
173
+
174
+ /**
175
+ * Error types for K256 WebSocket
176
+ */
177
+ export type K256ErrorCode =
178
+ | 'CONNECTION_FAILED'
179
+ | 'CONNECTION_LOST'
180
+ | 'PROTOCOL_ERROR'
181
+ | 'AUTH_FAILED'
182
+ | 'RATE_LIMITED'
183
+ | 'SERVER_ERROR'
184
+ | 'PING_TIMEOUT'
185
+ | 'HEARTBEAT_TIMEOUT'
186
+ | 'INVALID_MESSAGE'
187
+ | 'RECONNECT_FAILED';
188
+
189
+ /**
190
+ * WebSocket error with context
191
+ */
192
+ export class K256WebSocketError extends Error {
193
+ constructor(
194
+ public readonly code: K256ErrorCode,
195
+ message: string,
196
+ public readonly closeCode?: number,
197
+ public readonly closeReason?: string,
198
+ public readonly cause?: unknown
199
+ ) {
200
+ super(message);
201
+ this.name = 'K256WebSocketError';
202
+ }
203
+
204
+ /** Check if error is recoverable (should trigger reconnect) */
205
+ get isRecoverable(): boolean {
206
+ switch (this.code) {
207
+ case 'AUTH_FAILED':
208
+ case 'RATE_LIMITED':
209
+ return false;
210
+ default:
211
+ return true;
212
+ }
213
+ }
214
+
215
+ /** Check if error is an auth failure */
216
+ get isAuthError(): boolean {
217
+ return this.code === 'AUTH_FAILED' || this.closeCode === CloseCode.POLICY_VIOLATION;
218
+ }
219
+ }
220
+
221
+ /**
222
+ * Production-grade K256 WebSocket Client
223
+ */
224
+ export class K256WebSocketClient {
225
+ private ws: WebSocket | null = null;
226
+ private config: Required<Omit<K256WebSocketClientConfig,
227
+ 'onStateChange' | 'onConnect' | 'onDisconnect' | 'onReconnecting' | 'onError' |
228
+ 'onSubscribed' | 'onPoolUpdate' | 'onPoolUpdateBatch' | 'onPriorityFees' |
229
+ 'onBlockhash' | 'onQuote' | 'onQuoteSubscribed' | 'onHeartbeat' | 'onPong' |
230
+ 'onMessage' | 'onRawMessage'
231
+ >> & K256WebSocketClientConfig;
232
+
233
+ private _state: ConnectionState = 'disconnected';
234
+ private reconnectAttempts = 0;
235
+ private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
236
+ private pingTimer: ReturnType<typeof setInterval> | null = null;
237
+ private pongTimer: ReturnType<typeof setTimeout> | null = null;
238
+ private heartbeatTimer: ReturnType<typeof setTimeout> | null = null;
239
+ private lastPingTime = 0;
240
+ private lastHeartbeatTime = 0;
241
+ private pendingSubscription: SubscribeOptions | null = null;
242
+ private pendingQuoteSubscription: SubscribeQuoteOptions | null = null;
243
+ private isIntentionallyClosed = false;
244
+
245
+ /** Current connection state */
246
+ get state(): ConnectionState {
247
+ return this._state;
248
+ }
249
+
250
+ /** Whether currently connected */
251
+ get isConnected(): boolean {
252
+ return this._state === 'connected' && this.ws?.readyState === WebSocket.OPEN;
253
+ }
254
+
255
+ /** Time since last heartbeat (ms) or null if no heartbeat received */
256
+ get timeSinceHeartbeat(): number | null {
257
+ return this.lastHeartbeatTime ? Date.now() - this.lastHeartbeatTime : null;
258
+ }
259
+
260
+ /** Current reconnect attempt number */
261
+ get currentReconnectAttempt(): number {
262
+ return this.reconnectAttempts;
263
+ }
264
+
265
+ constructor(config: K256WebSocketClientConfig) {
266
+ this.config = {
267
+ url: 'wss://gateway.k256.xyz/v1/ws',
268
+ mode: 'binary',
269
+ autoReconnect: true,
270
+ reconnectDelayMs: 1000,
271
+ maxReconnectDelayMs: 30000,
272
+ maxReconnectAttempts: Infinity,
273
+ pingIntervalMs: 30000,
274
+ pongTimeoutMs: 10000,
275
+ heartbeatTimeoutMs: 15000,
276
+ ...config,
277
+ };
278
+ }
279
+
280
+ /**
281
+ * Connect to the WebSocket server
282
+ * @returns Promise that resolves when connected
283
+ */
284
+ async connect(): Promise<void> {
285
+ if (this._state === 'connected' || this._state === 'connecting') {
286
+ return;
287
+ }
288
+
289
+ this.isIntentionallyClosed = false;
290
+ return this.doConnect();
291
+ }
292
+
293
+ /**
294
+ * Disconnect from the WebSocket server
295
+ * @param code - Close code (default: 1000 NORMAL)
296
+ * @param reason - Close reason
297
+ */
298
+ disconnect(code: number = CloseCode.NORMAL, reason: string = 'Client disconnect'): void {
299
+ this.isIntentionallyClosed = true;
300
+ this.cleanup();
301
+
302
+ if (this.ws) {
303
+ try {
304
+ this.ws.close(code, reason);
305
+ } catch {
306
+ // Ignore errors during close
307
+ }
308
+ this.ws = null;
309
+ }
310
+
311
+ this.setState('closed');
312
+ }
313
+
314
+ /**
315
+ * Subscribe to channels
316
+ */
317
+ subscribe(options: SubscribeOptions): void {
318
+ this.pendingSubscription = options;
319
+
320
+ if (!this.isConnected) {
321
+ // Will be sent on connect
322
+ return;
323
+ }
324
+
325
+ this.sendSubscription(options);
326
+ }
327
+
328
+ /**
329
+ * Subscribe to a quote stream
330
+ */
331
+ subscribeQuote(options: SubscribeQuoteOptions): void {
332
+ this.pendingQuoteSubscription = options;
333
+
334
+ if (!this.isConnected) {
335
+ return;
336
+ }
337
+
338
+ this.sendQuoteSubscription(options);
339
+ }
340
+
341
+ /**
342
+ * Unsubscribe from a quote stream
343
+ * @param topicId - Topic ID from quote_subscribed response
344
+ */
345
+ unsubscribeQuote(topicId: string): void {
346
+ if (!this.isConnected) return;
347
+
348
+ const msg = JSON.stringify({ type: 'unsubscribe_quote', topicId });
349
+ this.ws?.send(msg);
350
+ }
351
+
352
+ /**
353
+ * Unsubscribe from all channels
354
+ */
355
+ unsubscribe(): void {
356
+ this.pendingSubscription = null;
357
+ this.pendingQuoteSubscription = null;
358
+
359
+ if (!this.isConnected) return;
360
+
361
+ this.ws?.send(JSON.stringify({ type: 'unsubscribe' }));
362
+ }
363
+
364
+ /**
365
+ * Send a ping to measure latency
366
+ */
367
+ ping(): void {
368
+ if (!this.isConnected) return;
369
+
370
+ // Binary ping: [0x0B]
371
+ const pingData = new Uint8Array([MessageType.Ping]);
372
+ this.lastPingTime = Date.now();
373
+ this.ws?.send(pingData);
374
+
375
+ // Start pong timeout
376
+ this.startPongTimeout();
377
+ }
378
+
379
+ // ─────────────────────────────────────────────────────────────────────────────
380
+ // Private methods
381
+ // ─────────────────────────────────────────────────────────────────────────────
382
+
383
+ private async doConnect(): Promise<void> {
384
+ return new Promise((resolve, reject) => {
385
+ this.setState('connecting');
386
+
387
+ const url = new URL(this.config.url);
388
+ url.searchParams.set('apiKey', this.config.apiKey);
389
+
390
+ try {
391
+ this.ws = new WebSocket(url.toString());
392
+
393
+ // Set binary mode
394
+ if (this.config.mode === 'binary') {
395
+ this.ws.binaryType = 'arraybuffer';
396
+ }
397
+
398
+ // Connection timeout
399
+ const connectTimeout = setTimeout(() => {
400
+ if (this.ws?.readyState !== WebSocket.OPEN) {
401
+ this.ws?.close();
402
+ const error = new K256WebSocketError(
403
+ 'CONNECTION_FAILED',
404
+ 'Connection timeout'
405
+ );
406
+ this.handleError(error);
407
+ reject(error);
408
+ }
409
+ }, 10000);
410
+
411
+ this.ws.onopen = () => {
412
+ clearTimeout(connectTimeout);
413
+ this.setState('connected');
414
+ this.reconnectAttempts = 0;
415
+ this.lastHeartbeatTime = Date.now();
416
+
417
+ // Start keepalive
418
+ this.startPingInterval();
419
+ this.startHeartbeatTimeout();
420
+
421
+ // Restore subscriptions
422
+ if (this.pendingSubscription) {
423
+ this.sendSubscription(this.pendingSubscription);
424
+ }
425
+ if (this.pendingQuoteSubscription) {
426
+ this.sendQuoteSubscription(this.pendingQuoteSubscription);
427
+ }
428
+
429
+ this.config.onConnect?.();
430
+ resolve();
431
+ };
432
+
433
+ this.ws.onclose = (event) => {
434
+ clearTimeout(connectTimeout);
435
+ this.cleanup();
436
+
437
+ const wasClean = event.wasClean;
438
+ const code = event.code;
439
+ const reason = event.reason || this.getCloseReason(code);
440
+
441
+ this.config.onDisconnect?.(code, reason, wasClean);
442
+
443
+ // Determine if we should reconnect
444
+ if (!this.isIntentionallyClosed && this.config.autoReconnect) {
445
+ if (this.shouldReconnect(code)) {
446
+ this.scheduleReconnect();
447
+ } else {
448
+ const error = new K256WebSocketError(
449
+ this.getErrorCodeFromClose(code),
450
+ reason,
451
+ code,
452
+ reason
453
+ );
454
+ this.handleError(error);
455
+ this.setState('closed');
456
+ }
457
+ } else {
458
+ this.setState('disconnected');
459
+ }
460
+ };
461
+
462
+ this.ws.onerror = () => {
463
+ // WebSocket errors don't provide details - wait for onclose
464
+ };
465
+
466
+ this.ws.onmessage = (event) => {
467
+ this.handleMessage(event.data);
468
+ };
469
+
470
+ } catch (error) {
471
+ const wsError = new K256WebSocketError(
472
+ 'CONNECTION_FAILED',
473
+ 'Failed to create WebSocket',
474
+ undefined,
475
+ undefined,
476
+ error
477
+ );
478
+ this.handleError(wsError);
479
+ reject(wsError);
480
+ }
481
+ });
482
+ }
483
+
484
+ private handleMessage(data: ArrayBuffer | string): void {
485
+ this.config.onRawMessage?.(data);
486
+
487
+ try {
488
+ let decoded: DecodedMessage | null = null;
489
+
490
+ if (data instanceof ArrayBuffer) {
491
+ // Binary message
492
+ decoded = decodeMessage(data);
493
+
494
+ // Handle batch specially
495
+ if (decoded === null) {
496
+ // Check if it's a batch
497
+ const view = new DataView(data);
498
+ if (view.byteLength > 0 && view.getUint8(0) === MessageType.PoolUpdateBatch) {
499
+ const payload = data.slice(1);
500
+ const updates = decodePoolUpdateBatch(payload);
501
+
502
+ // Emit batch callback
503
+ if (this.config.onPoolUpdateBatch) {
504
+ this.config.onPoolUpdateBatch(updates);
505
+ }
506
+
507
+ // Also emit individual updates
508
+ for (const update of updates) {
509
+ this.config.onPoolUpdate?.(update);
510
+ this.config.onMessage?.(update);
511
+ }
512
+ return;
513
+ }
514
+ }
515
+ } else {
516
+ // JSON string (when mode is 'json')
517
+ const parsed = JSON.parse(data);
518
+ decoded = {
519
+ type: parsed.type,
520
+ data: parsed.data || parsed,
521
+ } as DecodedMessage;
522
+ }
523
+
524
+ if (!decoded) {
525
+ return;
526
+ }
527
+
528
+ // Emit message callback
529
+ this.config.onMessage?.(decoded);
530
+
531
+ // Emit type-specific callbacks
532
+ switch (decoded.type) {
533
+ case 'subscribed':
534
+ this.config.onSubscribed?.(decoded as DecodedMessage & { type: 'subscribed' });
535
+ break;
536
+ case 'pool_update':
537
+ this.config.onPoolUpdate?.(decoded as PoolUpdateMessage);
538
+ break;
539
+ case 'priority_fees':
540
+ this.config.onPriorityFees?.(decoded as DecodedMessage & { type: 'priority_fees' });
541
+ break;
542
+ case 'blockhash':
543
+ this.config.onBlockhash?.(decoded as DecodedMessage & { type: 'blockhash' });
544
+ break;
545
+ case 'quote':
546
+ this.config.onQuote?.(decoded as DecodedMessage & { type: 'quote' });
547
+ break;
548
+ case 'quote_subscribed':
549
+ this.config.onQuoteSubscribed?.(decoded as DecodedMessage & { type: 'quote_subscribed' });
550
+ break;
551
+ case 'heartbeat':
552
+ this.lastHeartbeatTime = Date.now();
553
+ this.resetHeartbeatTimeout();
554
+ this.config.onHeartbeat?.(decoded as DecodedMessage & { type: 'heartbeat' });
555
+ break;
556
+ case 'pong':
557
+ this.clearPongTimeout();
558
+ const latencyMs = this.lastPingTime ? Date.now() - this.lastPingTime : 0;
559
+ this.config.onPong?.(latencyMs);
560
+ break;
561
+ case 'error':
562
+ const errorData = (decoded as DecodedMessage & { type: 'error' }).data;
563
+ const error = new K256WebSocketError(
564
+ 'SERVER_ERROR',
565
+ errorData.message
566
+ );
567
+ this.handleError(error);
568
+ break;
569
+ }
570
+ } catch (error) {
571
+ const wsError = new K256WebSocketError(
572
+ 'INVALID_MESSAGE',
573
+ 'Failed to decode message',
574
+ undefined,
575
+ undefined,
576
+ error
577
+ );
578
+ this.handleError(wsError);
579
+ }
580
+ }
581
+
582
+ private sendSubscription(options: SubscribeOptions): void {
583
+ const msg: Record<string, unknown> = {
584
+ type: 'subscribe',
585
+ channels: options.channels,
586
+ };
587
+
588
+ // Add format for JSON mode
589
+ if (this.config.mode === 'json') {
590
+ msg.format = 'json';
591
+ }
592
+
593
+ // Add filters
594
+ if (options.pools?.length) {
595
+ msg.pools = options.pools;
596
+ }
597
+ if (options.protocols?.length) {
598
+ msg.protocols = options.protocols;
599
+ }
600
+ if (options.tokenPairs?.length) {
601
+ msg.token_pairs = options.tokenPairs;
602
+ }
603
+
604
+ this.ws?.send(JSON.stringify(msg));
605
+ }
606
+
607
+ private sendQuoteSubscription(options: SubscribeQuoteOptions): void {
608
+ const msg = {
609
+ type: 'subscribe_quote',
610
+ inputMint: options.inputMint,
611
+ outputMint: options.outputMint,
612
+ amount: typeof options.amount === 'string' ? parseInt(options.amount, 10) : options.amount,
613
+ slippageBps: options.slippageBps,
614
+ refreshIntervalMs: options.refreshIntervalMs ?? 1000,
615
+ };
616
+
617
+ this.ws?.send(JSON.stringify(msg));
618
+ }
619
+
620
+ private setState(state: ConnectionState): void {
621
+ if (this._state !== state) {
622
+ const prevState = this._state;
623
+ this._state = state;
624
+ this.config.onStateChange?.(state, prevState);
625
+ }
626
+ }
627
+
628
+ private handleError(error: K256WebSocketError): void {
629
+ this.config.onError?.(error);
630
+ }
631
+
632
+ private cleanup(): void {
633
+ if (this.pingTimer) {
634
+ clearInterval(this.pingTimer);
635
+ this.pingTimer = null;
636
+ }
637
+ if (this.pongTimer) {
638
+ clearTimeout(this.pongTimer);
639
+ this.pongTimer = null;
640
+ }
641
+ if (this.heartbeatTimer) {
642
+ clearTimeout(this.heartbeatTimer);
643
+ this.heartbeatTimer = null;
644
+ }
645
+ if (this.reconnectTimer) {
646
+ clearTimeout(this.reconnectTimer);
647
+ this.reconnectTimer = null;
648
+ }
649
+ }
650
+
651
+ private startPingInterval(): void {
652
+ if (this.pingTimer) {
653
+ clearInterval(this.pingTimer);
654
+ }
655
+
656
+ this.pingTimer = setInterval(() => {
657
+ this.ping();
658
+ }, this.config.pingIntervalMs);
659
+ }
660
+
661
+ private startPongTimeout(): void {
662
+ this.clearPongTimeout();
663
+
664
+ this.pongTimer = setTimeout(() => {
665
+ const error = new K256WebSocketError(
666
+ 'PING_TIMEOUT',
667
+ 'Server did not respond to ping'
668
+ );
669
+ this.handleError(error);
670
+
671
+ // Force reconnect
672
+ this.ws?.close(CloseCode.GOING_AWAY, 'Ping timeout');
673
+ }, this.config.pongTimeoutMs);
674
+ }
675
+
676
+ private clearPongTimeout(): void {
677
+ if (this.pongTimer) {
678
+ clearTimeout(this.pongTimer);
679
+ this.pongTimer = null;
680
+ }
681
+ }
682
+
683
+ private startHeartbeatTimeout(): void {
684
+ this.resetHeartbeatTimeout();
685
+ }
686
+
687
+ private resetHeartbeatTimeout(): void {
688
+ if (this.heartbeatTimer) {
689
+ clearTimeout(this.heartbeatTimer);
690
+ }
691
+
692
+ this.heartbeatTimer = setTimeout(() => {
693
+ const error = new K256WebSocketError(
694
+ 'HEARTBEAT_TIMEOUT',
695
+ 'No heartbeat received from server'
696
+ );
697
+ this.handleError(error);
698
+ // Don't disconnect - heartbeat is informational
699
+ }, this.config.heartbeatTimeoutMs);
700
+ }
701
+
702
+ private shouldReconnect(closeCode: number): boolean {
703
+ // Don't reconnect on auth failures or policy violations
704
+ if (closeCode === CloseCode.POLICY_VIOLATION) {
705
+ return false;
706
+ }
707
+
708
+ // Check max attempts
709
+ if (this.reconnectAttempts >= this.config.maxReconnectAttempts) {
710
+ return false;
711
+ }
712
+
713
+ return true;
714
+ }
715
+
716
+ private scheduleReconnect(): void {
717
+ this.setState('reconnecting');
718
+ this.reconnectAttempts++;
719
+
720
+ // Exponential backoff with jitter
721
+ const baseDelay = Math.min(
722
+ this.config.reconnectDelayMs * Math.pow(2, this.reconnectAttempts - 1),
723
+ this.config.maxReconnectDelayMs
724
+ );
725
+ const jitter = Math.random() * 0.3 * baseDelay;
726
+ const delay = Math.floor(baseDelay + jitter);
727
+
728
+ this.config.onReconnecting?.(this.reconnectAttempts, delay);
729
+
730
+ this.reconnectTimer = setTimeout(async () => {
731
+ try {
732
+ await this.doConnect();
733
+ } catch {
734
+ // Error already handled in doConnect
735
+ }
736
+ }, delay);
737
+ }
738
+
739
+ private getCloseReason(code: number): string {
740
+ switch (code) {
741
+ case CloseCode.NORMAL:
742
+ return 'Normal closure';
743
+ case CloseCode.GOING_AWAY:
744
+ return 'Server shutting down';
745
+ case CloseCode.PROTOCOL_ERROR:
746
+ return 'Protocol error';
747
+ case CloseCode.UNSUPPORTED_DATA:
748
+ return 'Unsupported message type';
749
+ case CloseCode.ABNORMAL:
750
+ return 'Connection lost unexpectedly';
751
+ case CloseCode.INVALID_PAYLOAD:
752
+ return 'Invalid message data';
753
+ case CloseCode.POLICY_VIOLATION:
754
+ return 'Authentication failed or rate limited';
755
+ case CloseCode.MESSAGE_TOO_BIG:
756
+ return 'Message too large';
757
+ case CloseCode.INTERNAL_ERROR:
758
+ return 'Server error';
759
+ case CloseCode.SERVICE_RESTART:
760
+ return 'Server is restarting';
761
+ case CloseCode.TRY_AGAIN_LATER:
762
+ return 'Server overloaded';
763
+ default:
764
+ return `Unknown close code: ${code}`;
765
+ }
766
+ }
767
+
768
+ private getErrorCodeFromClose(code: number): K256ErrorCode {
769
+ switch (code) {
770
+ case CloseCode.POLICY_VIOLATION:
771
+ return 'AUTH_FAILED';
772
+ case CloseCode.INTERNAL_ERROR:
773
+ case CloseCode.SERVICE_RESTART:
774
+ case CloseCode.TRY_AGAIN_LATER:
775
+ return 'SERVER_ERROR';
776
+ case CloseCode.PROTOCOL_ERROR:
777
+ case CloseCode.UNSUPPORTED_DATA:
778
+ case CloseCode.INVALID_PAYLOAD:
779
+ return 'PROTOCOL_ERROR';
780
+ default:
781
+ return 'CONNECTION_LOST';
782
+ }
783
+ }
784
+ }
785
+
786
+ export default K256WebSocketClient;