@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.
- package/LICENSE +21 -0
- package/README.md +159 -0
- package/dist/index.cjs +949 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +3 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +938 -0
- package/dist/index.js.map +1 -0
- package/dist/types/index.cjs +14 -0
- package/dist/types/index.cjs.map +1 -0
- package/dist/types/index.d.cts +202 -0
- package/dist/types/index.d.ts +202 -0
- package/dist/types/index.js +12 -0
- package/dist/types/index.js.map +1 -0
- package/dist/utils/index.cjs +70 -0
- package/dist/utils/index.cjs.map +1 -0
- package/dist/utils/index.d.cts +48 -0
- package/dist/utils/index.d.ts +48 -0
- package/dist/utils/index.js +66 -0
- package/dist/utils/index.js.map +1 -0
- package/dist/ws/index.cjs +896 -0
- package/dist/ws/index.cjs.map +1 -0
- package/dist/ws/index.d.cts +516 -0
- package/dist/ws/index.d.ts +516 -0
- package/dist/ws/index.js +889 -0
- package/dist/ws/index.js.map +1 -0
- package/package.json +92 -0
- package/src/index.ts +34 -0
- package/src/types/index.ts +212 -0
- package/src/utils/base58.ts +123 -0
- package/src/utils/index.ts +7 -0
- package/src/ws/client.ts +786 -0
- package/src/ws/decoder.ts +490 -0
- package/src/ws/index.ts +55 -0
- package/src/ws/types.ts +227 -0
package/src/ws/client.ts
ADDED
|
@@ -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;
|