@pythnetwork/pyth-lazer-sdk 0.1.1 → 0.2.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,174 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.WebSocketPool = void 0;
7
+ const ttlcache_1 = __importDefault(require("@isaacs/ttlcache"));
8
+ const ts_log_1 = require("ts-log");
9
+ const resilient_web_socket_js_1 = require("./resilient-web-socket.js");
10
+ // Number of redundant parallel WebSocket connections
11
+ const DEFAULT_NUM_CONNECTIONS = 3;
12
+ class WebSocketPool {
13
+ logger;
14
+ rwsPool;
15
+ cache;
16
+ subscriptions; // id -> subscription Request
17
+ messageListeners;
18
+ /**
19
+ * Creates a new WebSocketPool instance that uses multiple redundant WebSocket connections for reliability.
20
+ * Usage semantics are similar to using a regular WebSocket client.
21
+ * @param urls - List of WebSocket URLs to connect to
22
+ * @param token - Authentication token to use for the connections
23
+ * @param numConnections - Number of parallel WebSocket connections to maintain (default: 3)
24
+ * @param logger - Optional logger to get socket level logs. Compatible with most loggers such as the built-in console and `bunyan`.
25
+ */
26
+ constructor(urls, token, numConnections = DEFAULT_NUM_CONNECTIONS, logger = ts_log_1.dummyLogger) {
27
+ this.logger = logger;
28
+ if (urls.length === 0) {
29
+ throw new Error("No URLs provided");
30
+ }
31
+ // This cache is used to deduplicate messages received across different websocket clients in the pool.
32
+ // A TTL cache is used to prevent unbounded memory usage. A very short TTL of 10 seconds is chosen since
33
+ // deduplication only needs to happen between messages received very close together in time.
34
+ this.cache = new ttlcache_1.default({ ttl: 1000 * 10 }); // TTL of 10 seconds
35
+ this.rwsPool = [];
36
+ this.subscriptions = new Map();
37
+ this.messageListeners = [];
38
+ for (let i = 0; i < numConnections; i++) {
39
+ const url = urls[i % urls.length];
40
+ if (!url) {
41
+ throw new Error(`URLs must not be null or empty`);
42
+ }
43
+ const wsOptions = {
44
+ headers: {
45
+ Authorization: `Bearer ${token}`,
46
+ },
47
+ };
48
+ const rws = new resilient_web_socket_js_1.ResilientWebSocket(url, wsOptions, logger);
49
+ // If a websocket client unexpectedly disconnects, ResilientWebSocket will reestablish
50
+ // the connection and call the onReconnect callback.
51
+ // When we reconnect, replay all subscription messages to resume the data stream.
52
+ rws.onReconnect = () => {
53
+ if (rws.wsUserClosed) {
54
+ return;
55
+ }
56
+ for (const [, request] of this.subscriptions) {
57
+ try {
58
+ void rws.send(JSON.stringify(request));
59
+ }
60
+ catch (error) {
61
+ this.logger.error("Failed to resend subscription on reconnect:", error);
62
+ }
63
+ }
64
+ };
65
+ // Handle all client messages ourselves. Dedupe before sending to registered message handlers.
66
+ rws.onMessage = this.dedupeHandler;
67
+ this.rwsPool.push(rws);
68
+ }
69
+ // Let it rip
70
+ // TODO: wait for sockets to receive `open` msg before subscribing?
71
+ for (const rws of this.rwsPool) {
72
+ rws.startWebSocket();
73
+ }
74
+ this.logger.info(`Using ${numConnections.toString()} redundant WebSocket connections`);
75
+ }
76
+ /**
77
+ * Checks for error responses in JSON messages and throws appropriate errors
78
+ */
79
+ handleErrorMessages(data) {
80
+ const message = JSON.parse(data);
81
+ if (message.type === "subscriptionError") {
82
+ throw new Error(`Error occurred for subscription ID ${String(message.subscriptionId)}: ${message.error}`);
83
+ }
84
+ else if (message.type === "error") {
85
+ throw new Error(`Error: ${message.error}`);
86
+ }
87
+ }
88
+ /**
89
+ * Handles incoming websocket messages by deduplicating identical messages received across
90
+ * multiple connections before forwarding to registered handlers
91
+ */
92
+ dedupeHandler = (data) => {
93
+ // For string data, use the whole string as the cache key. This avoids expensive JSON parsing during deduping.
94
+ // For binary data, use the hex string representation as the cache key
95
+ const cacheKey = typeof data === "string"
96
+ ? data
97
+ : Buffer.from(data).toString("hex");
98
+ // If we've seen this exact message recently, drop it
99
+ if (this.cache.has(cacheKey)) {
100
+ this.logger.debug("Dropping duplicate message");
101
+ return;
102
+ }
103
+ // Haven't seen this message, cache it and forward to handlers
104
+ this.cache.set(cacheKey, true);
105
+ // Check for errors in JSON responses
106
+ if (typeof data === "string") {
107
+ this.handleErrorMessages(data);
108
+ }
109
+ for (const handler of this.messageListeners) {
110
+ handler(data);
111
+ }
112
+ };
113
+ /**
114
+ * Sends a message to all websockets in the pool
115
+ * @param request - The request to send
116
+ */
117
+ async sendRequest(request) {
118
+ // Send to all websockets in the pool
119
+ const sendPromises = this.rwsPool.map(async (rws) => {
120
+ try {
121
+ await rws.send(JSON.stringify(request));
122
+ }
123
+ catch (error) {
124
+ this.logger.error("Failed to send request:", error);
125
+ throw error; // Re-throw the error
126
+ }
127
+ });
128
+ await Promise.all(sendPromises);
129
+ }
130
+ /**
131
+ * Adds a subscription by sending a subscribe request to all websockets in the pool
132
+ * and storing it for replay on reconnection
133
+ * @param request - The subscription request to send
134
+ */
135
+ async addSubscription(request) {
136
+ if (request.type !== "subscribe") {
137
+ throw new Error("Request must be a subscribe request");
138
+ }
139
+ this.subscriptions.set(request.subscriptionId, request);
140
+ await this.sendRequest(request);
141
+ }
142
+ /**
143
+ * Removes a subscription by sending an unsubscribe request to all websockets in the pool
144
+ * and removing it from stored subscriptions
145
+ * @param subscriptionId - The ID of the subscription to remove
146
+ */
147
+ async removeSubscription(subscriptionId) {
148
+ this.subscriptions.delete(subscriptionId);
149
+ const request = {
150
+ type: "unsubscribe",
151
+ subscriptionId,
152
+ };
153
+ await this.sendRequest(request);
154
+ }
155
+ /**
156
+ * Adds a message handler function to receive websocket messages
157
+ * @param handler - Function that will be called with each received message
158
+ */
159
+ addMessageListener(handler) {
160
+ this.messageListeners.push(handler);
161
+ }
162
+ /**
163
+ * Elegantly closes all websocket connections in the pool
164
+ */
165
+ shutdown() {
166
+ for (const rws of this.rwsPool) {
167
+ rws.closeWebSocket();
168
+ }
169
+ this.rwsPool = [];
170
+ this.subscriptions.clear();
171
+ this.messageListeners = [];
172
+ }
173
+ }
174
+ exports.WebSocketPool = WebSocketPool;
@@ -0,0 +1,32 @@
1
+ import { type Logger } from "ts-log";
2
+ import { type ParsedPayload, type Request, type Response } from "./protocol.js";
3
+ import { WebSocketPool } from "./socket/web-socket-pool.js";
4
+ export type BinaryResponse = {
5
+ subscriptionId: number;
6
+ evm?: Buffer | undefined;
7
+ solana?: Buffer | undefined;
8
+ parsed?: ParsedPayload | undefined;
9
+ };
10
+ export type JsonOrBinaryResponse = {
11
+ type: "json";
12
+ value: Response;
13
+ } | {
14
+ type: "binary";
15
+ value: BinaryResponse;
16
+ };
17
+ export declare class PythLazerClient {
18
+ wsp: WebSocketPool;
19
+ /**
20
+ * Creates a new PythLazerClient instance.
21
+ * @param urls - List of WebSocket URLs of the Pyth Lazer service
22
+ * @param token - The access token for authentication
23
+ * @param numConnections - The number of parallel WebSocket connections to establish (default: 3). A higher number gives a more reliable stream.
24
+ * @param logger - Optional logger to get socket level logs. Compatible with most loggers such as the built-in console and `bunyan`.
25
+ */
26
+ constructor(urls: string[], token: string, numConnections?: number, logger?: Logger);
27
+ addMessageListener(handler: (event: JsonOrBinaryResponse) => void): void;
28
+ subscribe(request: Request): Promise<void>;
29
+ unsubscribe(subscriptionId: number): Promise<void>;
30
+ send(request: Request): Promise<void>;
31
+ shutdown(): void;
32
+ }
@@ -0,0 +1,81 @@
1
+ import WebSocket from "isomorphic-ws";
2
+ import { dummyLogger } from "ts-log";
3
+ import { BINARY_UPDATE_FORMAT_MAGIC, EVM_FORMAT_MAGIC, PARSED_FORMAT_MAGIC, SOLANA_FORMAT_MAGIC_BE, } from "./protocol.js";
4
+ import { WebSocketPool } from "./socket/web-socket-pool.js";
5
+ const UINT16_NUM_BYTES = 2;
6
+ const UINT32_NUM_BYTES = 4;
7
+ const UINT64_NUM_BYTES = 8;
8
+ export class PythLazerClient {
9
+ wsp;
10
+ /**
11
+ * Creates a new PythLazerClient instance.
12
+ * @param urls - List of WebSocket URLs of the Pyth Lazer service
13
+ * @param token - The access token for authentication
14
+ * @param numConnections - The number of parallel WebSocket connections to establish (default: 3). A higher number gives a more reliable stream.
15
+ * @param logger - Optional logger to get socket level logs. Compatible with most loggers such as the built-in console and `bunyan`.
16
+ */
17
+ constructor(urls, token, numConnections = 3, logger = dummyLogger) {
18
+ this.wsp = new WebSocketPool(urls, token, numConnections, logger);
19
+ }
20
+ addMessageListener(handler) {
21
+ this.wsp.addMessageListener((data) => {
22
+ if (typeof data == "string") {
23
+ handler({
24
+ type: "json",
25
+ value: JSON.parse(data),
26
+ });
27
+ }
28
+ else if (Buffer.isBuffer(data)) {
29
+ let pos = 0;
30
+ const magic = data.subarray(pos, pos + UINT32_NUM_BYTES).readUint32BE();
31
+ pos += UINT32_NUM_BYTES;
32
+ if (magic != BINARY_UPDATE_FORMAT_MAGIC) {
33
+ throw new Error("binary update format magic mismatch");
34
+ }
35
+ // TODO: some uint64 values may not be representable as Number.
36
+ const subscriptionId = Number(data.subarray(pos, pos + UINT64_NUM_BYTES).readBigInt64BE());
37
+ pos += UINT64_NUM_BYTES;
38
+ const value = { subscriptionId };
39
+ while (pos < data.length) {
40
+ const len = data.subarray(pos, pos + UINT16_NUM_BYTES).readUint16BE();
41
+ pos += UINT16_NUM_BYTES;
42
+ const magic = data
43
+ .subarray(pos, pos + UINT32_NUM_BYTES)
44
+ .readUint32BE();
45
+ if (magic == EVM_FORMAT_MAGIC) {
46
+ value.evm = data.subarray(pos, pos + len);
47
+ }
48
+ else if (magic == SOLANA_FORMAT_MAGIC_BE) {
49
+ value.solana = data.subarray(pos, pos + len);
50
+ }
51
+ else if (magic == PARSED_FORMAT_MAGIC) {
52
+ value.parsed = JSON.parse(data.subarray(pos + UINT32_NUM_BYTES, pos + len).toString());
53
+ }
54
+ else {
55
+ throw new Error("unknown magic: " + magic.toString());
56
+ }
57
+ pos += len;
58
+ }
59
+ handler({ type: "binary", value });
60
+ }
61
+ else {
62
+ throw new TypeError("unexpected event data type");
63
+ }
64
+ });
65
+ }
66
+ async subscribe(request) {
67
+ if (request.type !== "subscribe") {
68
+ throw new Error("Request must be a subscribe request");
69
+ }
70
+ await this.wsp.addSubscription(request);
71
+ }
72
+ async unsubscribe(subscriptionId) {
73
+ await this.wsp.removeSubscription(subscriptionId);
74
+ }
75
+ async send(request) {
76
+ await this.wsp.sendRequest(request);
77
+ }
78
+ shutdown() {
79
+ this.wsp.shutdown();
80
+ }
81
+ }
@@ -0,0 +1,2 @@
1
+ import { TransactionInstruction } from "@solana/web3.js";
2
+ export declare const createEd25519Instruction: (message: Buffer, instructionIndex: number, startingOffset: number) => TransactionInstruction;
@@ -0,0 +1,42 @@
1
+ import * as BufferLayout from "@solana/buffer-layout";
2
+ import { Ed25519Program, TransactionInstruction } from "@solana/web3.js";
3
+ const ED25519_INSTRUCTION_LEN = 16;
4
+ const SIGNATURE_LEN = 64;
5
+ const PUBKEY_LEN = 32;
6
+ const MAGIC_LEN = 4;
7
+ const MESSAGE_SIZE_LEN = 2;
8
+ const ED25519_INSTRUCTION_LAYOUT = BufferLayout.struct([
9
+ BufferLayout.u8("numSignatures"),
10
+ BufferLayout.u8("padding"),
11
+ BufferLayout.u16("signatureOffset"),
12
+ BufferLayout.u16("signatureInstructionIndex"),
13
+ BufferLayout.u16("publicKeyOffset"),
14
+ BufferLayout.u16("publicKeyInstructionIndex"),
15
+ BufferLayout.u16("messageDataOffset"),
16
+ BufferLayout.u16("messageDataSize"),
17
+ BufferLayout.u16("messageInstructionIndex"),
18
+ ]);
19
+ export const createEd25519Instruction = (message, instructionIndex, startingOffset) => {
20
+ const signatureOffset = startingOffset + MAGIC_LEN;
21
+ const publicKeyOffset = signatureOffset + SIGNATURE_LEN;
22
+ const messageDataSizeOffset = publicKeyOffset + PUBKEY_LEN;
23
+ const messageDataOffset = messageDataSizeOffset + MESSAGE_SIZE_LEN;
24
+ const messageDataSize = message.readUInt16LE(messageDataSizeOffset - startingOffset);
25
+ const instructionData = Buffer.alloc(ED25519_INSTRUCTION_LEN);
26
+ ED25519_INSTRUCTION_LAYOUT.encode({
27
+ numSignatures: 1,
28
+ padding: 0,
29
+ signatureOffset,
30
+ signatureInstructionIndex: instructionIndex,
31
+ publicKeyOffset,
32
+ publicKeyInstructionIndex: instructionIndex,
33
+ messageDataOffset,
34
+ messageDataSize: messageDataSize,
35
+ messageInstructionIndex: instructionIndex,
36
+ }, instructionData);
37
+ return new TransactionInstruction({
38
+ keys: [],
39
+ programId: Ed25519Program.programId,
40
+ data: instructionData,
41
+ });
42
+ };
@@ -1,21 +1,3 @@
1
- import WebSocket from "isomorphic-ws";
2
- import { type ParsedPayload, type Request, type Response } from "./protocol.js";
3
- export type BinaryResponse = {
4
- subscriptionId: number;
5
- evm?: Buffer | undefined;
6
- solana?: Buffer | undefined;
7
- parsed?: ParsedPayload | undefined;
8
- };
9
- export type JsonOrBinaryResponse = {
10
- type: "json";
11
- value: Response;
12
- } | {
13
- type: "binary";
14
- value: BinaryResponse;
15
- };
16
- export declare class PythLazerClient {
17
- ws: WebSocket;
18
- constructor(url: string, token: string);
19
- addMessageListener(handler: (event: JsonOrBinaryResponse) => void): void;
20
- send(request: Request): void;
21
- }
1
+ export * from "./client.js";
2
+ export * from "./protocol.js";
3
+ export * from "./ed25519.js";
package/dist/esm/index.js CHANGED
@@ -1,66 +1,3 @@
1
- import WebSocket from "isomorphic-ws";
2
- import { BINARY_UPDATE_FORMAT_MAGIC, EVM_FORMAT_MAGIC, PARSED_FORMAT_MAGIC, SOLANA_FORMAT_MAGIC_BE, } from "./protocol.js";
3
- const UINT16_NUM_BYTES = 2;
4
- const UINT32_NUM_BYTES = 4;
5
- const UINT64_NUM_BYTES = 8;
6
- export class PythLazerClient {
7
- ws;
8
- constructor(url, token) {
9
- const finalUrl = new URL(url);
10
- finalUrl.searchParams.append("ACCESS_TOKEN", token);
11
- this.ws = new WebSocket(finalUrl);
12
- }
13
- addMessageListener(handler) {
14
- this.ws.addEventListener("message", (event) => {
15
- if (typeof event.data == "string") {
16
- handler({
17
- type: "json",
18
- value: JSON.parse(event.data),
19
- });
20
- }
21
- else if (Buffer.isBuffer(event.data)) {
22
- let pos = 0;
23
- const magic = event.data
24
- .subarray(pos, pos + UINT32_NUM_BYTES)
25
- .readUint32BE();
26
- pos += UINT32_NUM_BYTES;
27
- if (magic != BINARY_UPDATE_FORMAT_MAGIC) {
28
- throw new Error("binary update format magic mismatch");
29
- }
30
- // TODO: some uint64 values may not be representable as Number.
31
- const subscriptionId = Number(event.data.subarray(pos, pos + UINT64_NUM_BYTES).readBigInt64BE());
32
- pos += UINT64_NUM_BYTES;
33
- const value = { subscriptionId };
34
- while (pos < event.data.length) {
35
- const len = event.data
36
- .subarray(pos, pos + UINT16_NUM_BYTES)
37
- .readUint16BE();
38
- pos += UINT16_NUM_BYTES;
39
- const magic = event.data
40
- .subarray(pos, pos + UINT32_NUM_BYTES)
41
- .readUint32BE();
42
- if (magic == EVM_FORMAT_MAGIC) {
43
- value.evm = event.data.subarray(pos, pos + len);
44
- }
45
- else if (magic == SOLANA_FORMAT_MAGIC_BE) {
46
- value.solana = event.data.subarray(pos, pos + len);
47
- }
48
- else if (magic == PARSED_FORMAT_MAGIC) {
49
- value.parsed = JSON.parse(event.data.subarray(pos + UINT32_NUM_BYTES, pos + len).toString());
50
- }
51
- else {
52
- throw new Error("unknown magic: " + magic.toString());
53
- }
54
- pos += len;
55
- }
56
- handler({ type: "binary", value });
57
- }
58
- else {
59
- throw new TypeError("unexpected event data type");
60
- }
61
- });
62
- }
63
- send(request) {
64
- this.ws.send(JSON.stringify(request));
65
- }
66
- }
1
+ export * from "./client.js";
2
+ export * from "./protocol.js";
3
+ export * from "./ed25519.js";
@@ -0,0 +1,38 @@
1
+ import type { ClientRequestArgs } from "node:http";
2
+ import WebSocket, { type ClientOptions, type ErrorEvent } from "isomorphic-ws";
3
+ import type { Logger } from "ts-log";
4
+ /**
5
+ * This class wraps websocket to provide a resilient web socket client.
6
+ *
7
+ * It will reconnect if connection fails with exponential backoff. Also, it will reconnect
8
+ * if it receives no ping request or regular message from server within a while as indication
9
+ * of timeout (assuming the server sends either regularly).
10
+ *
11
+ * This class also logs events if logger is given and by replacing onError method you can handle
12
+ * connection errors yourself (e.g: do not retry and close the connection).
13
+ */
14
+ export declare class ResilientWebSocket {
15
+ endpoint: string;
16
+ wsClient: undefined | WebSocket;
17
+ wsUserClosed: boolean;
18
+ private wsOptions;
19
+ private wsFailedAttempts;
20
+ private heartbeatTimeout;
21
+ private logger;
22
+ onError: (error: ErrorEvent) => void;
23
+ onMessage: (data: WebSocket.Data) => void;
24
+ onReconnect: () => void;
25
+ constructor(endpoint: string, wsOptions?: ClientOptions | ClientRequestArgs, logger?: Logger);
26
+ send(data: string | Buffer): Promise<void>;
27
+ startWebSocket(): void;
28
+ /**
29
+ * Reset the heartbeat timeout. This is called when we receive any message (ping or regular)
30
+ * from the server. If we don't receive any message within HEARTBEAT_TIMEOUT_DURATION,
31
+ * we assume the connection is dead and reconnect.
32
+ */
33
+ private resetHeartbeat;
34
+ private waitForMaybeReadyWebSocket;
35
+ private handleClose;
36
+ private restartUnexpectedClosedWebsocket;
37
+ closeWebSocket(): void;
38
+ }
@@ -0,0 +1,154 @@
1
+ import WebSocket, {} from "isomorphic-ws";
2
+ // Reconnect with expo backoff if we don't get a message or ping for 10 seconds
3
+ const HEARTBEAT_TIMEOUT_DURATION = 10_000;
4
+ /**
5
+ * This class wraps websocket to provide a resilient web socket client.
6
+ *
7
+ * It will reconnect if connection fails with exponential backoff. Also, it will reconnect
8
+ * if it receives no ping request or regular message from server within a while as indication
9
+ * of timeout (assuming the server sends either regularly).
10
+ *
11
+ * This class also logs events if logger is given and by replacing onError method you can handle
12
+ * connection errors yourself (e.g: do not retry and close the connection).
13
+ */
14
+ export class ResilientWebSocket {
15
+ endpoint;
16
+ wsClient;
17
+ wsUserClosed;
18
+ wsOptions;
19
+ wsFailedAttempts;
20
+ heartbeatTimeout;
21
+ logger;
22
+ onError;
23
+ onMessage;
24
+ onReconnect;
25
+ constructor(endpoint, wsOptions, logger) {
26
+ this.endpoint = endpoint;
27
+ this.wsOptions = wsOptions;
28
+ this.logger = logger;
29
+ this.wsFailedAttempts = 0;
30
+ this.onError = (error) => {
31
+ this.logger?.error(error.error);
32
+ };
33
+ this.wsUserClosed = true;
34
+ this.onMessage = (data) => {
35
+ void data;
36
+ };
37
+ this.onReconnect = () => {
38
+ // Empty function, can be set by the user.
39
+ };
40
+ }
41
+ async send(data) {
42
+ this.logger?.info(`Sending message`);
43
+ await this.waitForMaybeReadyWebSocket();
44
+ if (this.wsClient === undefined) {
45
+ this.logger?.error("Couldn't connect to the websocket server. Error callback is called.");
46
+ }
47
+ else {
48
+ this.wsClient.send(data);
49
+ }
50
+ }
51
+ startWebSocket() {
52
+ if (this.wsClient !== undefined) {
53
+ return;
54
+ }
55
+ this.logger?.info(`Creating Web Socket client`);
56
+ this.wsClient = new WebSocket(this.endpoint, this.wsOptions);
57
+ this.wsUserClosed = false;
58
+ this.wsClient.addEventListener("open", () => {
59
+ this.wsFailedAttempts = 0;
60
+ this.resetHeartbeat();
61
+ });
62
+ this.wsClient.addEventListener("error", (event) => {
63
+ this.onError(event);
64
+ });
65
+ this.wsClient.addEventListener("message", (event) => {
66
+ this.resetHeartbeat();
67
+ this.onMessage(event.data);
68
+ });
69
+ this.wsClient.addEventListener("close", () => {
70
+ void this.handleClose();
71
+ });
72
+ // Handle ping events if supported (Node.js only)
73
+ if ("on" in this.wsClient) {
74
+ // Ping handler is undefined in browser side
75
+ this.wsClient.on("ping", () => {
76
+ this.logger?.info("Ping received");
77
+ this.resetHeartbeat();
78
+ });
79
+ }
80
+ }
81
+ /**
82
+ * Reset the heartbeat timeout. This is called when we receive any message (ping or regular)
83
+ * from the server. If we don't receive any message within HEARTBEAT_TIMEOUT_DURATION,
84
+ * we assume the connection is dead and reconnect.
85
+ */
86
+ resetHeartbeat() {
87
+ if (this.heartbeatTimeout !== undefined) {
88
+ clearTimeout(this.heartbeatTimeout);
89
+ }
90
+ this.heartbeatTimeout = setTimeout(() => {
91
+ this.logger?.warn("Connection timed out. Reconnecting...");
92
+ this.wsClient?.terminate();
93
+ void this.restartUnexpectedClosedWebsocket();
94
+ }, HEARTBEAT_TIMEOUT_DURATION);
95
+ }
96
+ async waitForMaybeReadyWebSocket() {
97
+ let waitedTime = 0;
98
+ while (this.wsClient !== undefined &&
99
+ this.wsClient.readyState !== this.wsClient.OPEN) {
100
+ if (waitedTime > 5000) {
101
+ this.wsClient.close();
102
+ return;
103
+ }
104
+ else {
105
+ waitedTime += 10;
106
+ await sleep(10);
107
+ }
108
+ }
109
+ }
110
+ async handleClose() {
111
+ if (this.heartbeatTimeout !== undefined) {
112
+ clearTimeout(this.heartbeatTimeout);
113
+ }
114
+ if (this.wsUserClosed) {
115
+ this.logger?.info("The connection has been closed successfully.");
116
+ }
117
+ else {
118
+ this.wsFailedAttempts += 1;
119
+ this.wsClient = undefined;
120
+ const waitTime = expoBackoff(this.wsFailedAttempts);
121
+ this.logger?.error("Connection closed unexpectedly or because of timeout. Reconnecting after " +
122
+ String(waitTime) +
123
+ "ms.");
124
+ await sleep(waitTime);
125
+ await this.restartUnexpectedClosedWebsocket();
126
+ }
127
+ }
128
+ async restartUnexpectedClosedWebsocket() {
129
+ if (this.wsUserClosed) {
130
+ return;
131
+ }
132
+ this.startWebSocket();
133
+ await this.waitForMaybeReadyWebSocket();
134
+ if (this.wsClient === undefined) {
135
+ this.logger?.error("Couldn't reconnect to websocket. Error callback is called.");
136
+ return;
137
+ }
138
+ this.onReconnect();
139
+ }
140
+ closeWebSocket() {
141
+ if (this.wsClient !== undefined) {
142
+ const client = this.wsClient;
143
+ this.wsClient = undefined;
144
+ client.close();
145
+ }
146
+ this.wsUserClosed = true;
147
+ }
148
+ }
149
+ async function sleep(ms) {
150
+ return new Promise((resolve) => setTimeout(resolve, ms));
151
+ }
152
+ function expoBackoff(attempts) {
153
+ return 2 ** attempts * 100;
154
+ }
@@ -0,0 +1,55 @@
1
+ import WebSocket from "isomorphic-ws";
2
+ import { type Logger } from "ts-log";
3
+ import { ResilientWebSocket } from "./resilient-web-socket.js";
4
+ import type { Request } from "../protocol.js";
5
+ export declare class WebSocketPool {
6
+ private readonly logger;
7
+ rwsPool: ResilientWebSocket[];
8
+ private cache;
9
+ private subscriptions;
10
+ private messageListeners;
11
+ /**
12
+ * Creates a new WebSocketPool instance that uses multiple redundant WebSocket connections for reliability.
13
+ * Usage semantics are similar to using a regular WebSocket client.
14
+ * @param urls - List of WebSocket URLs to connect to
15
+ * @param token - Authentication token to use for the connections
16
+ * @param numConnections - Number of parallel WebSocket connections to maintain (default: 3)
17
+ * @param logger - Optional logger to get socket level logs. Compatible with most loggers such as the built-in console and `bunyan`.
18
+ */
19
+ constructor(urls: string[], token: string, numConnections?: number, logger?: Logger);
20
+ /**
21
+ * Checks for error responses in JSON messages and throws appropriate errors
22
+ */
23
+ private handleErrorMessages;
24
+ /**
25
+ * Handles incoming websocket messages by deduplicating identical messages received across
26
+ * multiple connections before forwarding to registered handlers
27
+ */
28
+ dedupeHandler: (data: WebSocket.Data) => void;
29
+ /**
30
+ * Sends a message to all websockets in the pool
31
+ * @param request - The request to send
32
+ */
33
+ sendRequest(request: Request): Promise<void>;
34
+ /**
35
+ * Adds a subscription by sending a subscribe request to all websockets in the pool
36
+ * and storing it for replay on reconnection
37
+ * @param request - The subscription request to send
38
+ */
39
+ addSubscription(request: Request): Promise<void>;
40
+ /**
41
+ * Removes a subscription by sending an unsubscribe request to all websockets in the pool
42
+ * and removing it from stored subscriptions
43
+ * @param subscriptionId - The ID of the subscription to remove
44
+ */
45
+ removeSubscription(subscriptionId: number): Promise<void>;
46
+ /**
47
+ * Adds a message handler function to receive websocket messages
48
+ * @param handler - Function that will be called with each received message
49
+ */
50
+ addMessageListener(handler: (data: WebSocket.Data) => void): void;
51
+ /**
52
+ * Elegantly closes all websocket connections in the pool
53
+ */
54
+ shutdown(): void;
55
+ }