@pythnetwork/pyth-lazer-sdk 0.5.0 → 1.0.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/dist/cjs/client.d.ts +5 -5
- package/dist/cjs/client.js +8 -9
- package/dist/cjs/socket/resilient-websocket.d.ts +34 -15
- package/dist/cjs/socket/resilient-websocket.js +93 -109
- package/dist/cjs/socket/websocket-pool.d.ts +15 -4
- package/dist/cjs/socket/websocket-pool.js +36 -37
- package/dist/esm/client.d.ts +5 -5
- package/dist/esm/client.js +8 -9
- package/dist/esm/socket/resilient-websocket.d.ts +34 -15
- package/dist/esm/socket/resilient-websocket.js +93 -109
- package/dist/esm/socket/websocket-pool.d.ts +15 -4
- package/dist/esm/socket/websocket-pool.js +36 -37
- package/package.json +20 -22
package/dist/cjs/client.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type { Logger } from "ts-log";
|
|
2
1
|
import type { ParsedPayload, Request, Response } from "./protocol.js";
|
|
2
|
+
import type { WebSocketPoolConfig } from "./socket/websocket-pool.js";
|
|
3
3
|
export type BinaryResponse = {
|
|
4
4
|
subscriptionId: number;
|
|
5
5
|
evm?: Buffer | undefined;
|
|
@@ -25,7 +25,7 @@ export declare class PythLazerClient {
|
|
|
25
25
|
* @param numConnections - The number of parallel WebSocket connections to establish (default: 3). A higher number gives a more reliable stream. The connections will round-robin across the provided URLs.
|
|
26
26
|
* @param logger - Optional logger to get socket level logs. Compatible with most loggers such as the built-in console and `bunyan`.
|
|
27
27
|
*/
|
|
28
|
-
static create(
|
|
28
|
+
static create(config: WebSocketPoolConfig): Promise<PythLazerClient>;
|
|
29
29
|
/**
|
|
30
30
|
* Adds a message listener that receives either JSON or binary responses from the WebSocket connections.
|
|
31
31
|
* The listener will be called for each message received, with deduplication across redundant connections.
|
|
@@ -33,9 +33,9 @@ export declare class PythLazerClient {
|
|
|
33
33
|
* or a binary response containing EVM, Solana, or parsed payload data.
|
|
34
34
|
*/
|
|
35
35
|
addMessageListener(handler: (event: JsonOrBinaryResponse) => void): void;
|
|
36
|
-
subscribe(request: Request):
|
|
37
|
-
unsubscribe(subscriptionId: number):
|
|
38
|
-
send(request: Request):
|
|
36
|
+
subscribe(request: Request): void;
|
|
37
|
+
unsubscribe(subscriptionId: number): void;
|
|
38
|
+
send(request: Request): void;
|
|
39
39
|
/**
|
|
40
40
|
* Registers a handler function that will be called whenever all WebSocket connections are down or attempting to reconnect.
|
|
41
41
|
* The connections may still try to reconnect in the background. To shut down the pool, call `shutdown()`.
|
package/dist/cjs/client.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.PythLazerClient = void 0;
|
|
4
|
-
const ts_log_1 = require("ts-log");
|
|
5
4
|
const protocol_js_1 = require("./protocol.js");
|
|
6
5
|
const websocket_pool_js_1 = require("./socket/websocket-pool.js");
|
|
7
6
|
const UINT16_NUM_BYTES = 2;
|
|
@@ -19,8 +18,8 @@ class PythLazerClient {
|
|
|
19
18
|
* @param numConnections - The number of parallel WebSocket connections to establish (default: 3). A higher number gives a more reliable stream. The connections will round-robin across the provided URLs.
|
|
20
19
|
* @param logger - Optional logger to get socket level logs. Compatible with most loggers such as the built-in console and `bunyan`.
|
|
21
20
|
*/
|
|
22
|
-
static async create(
|
|
23
|
-
const wsp = await websocket_pool_js_1.WebSocketPool.create(
|
|
21
|
+
static async create(config) {
|
|
22
|
+
const wsp = await websocket_pool_js_1.WebSocketPool.create(config);
|
|
24
23
|
return new PythLazerClient(wsp);
|
|
25
24
|
}
|
|
26
25
|
/**
|
|
@@ -81,17 +80,17 @@ class PythLazerClient {
|
|
|
81
80
|
}
|
|
82
81
|
});
|
|
83
82
|
}
|
|
84
|
-
|
|
83
|
+
subscribe(request) {
|
|
85
84
|
if (request.type !== "subscribe") {
|
|
86
85
|
throw new Error("Request must be a subscribe request");
|
|
87
86
|
}
|
|
88
|
-
|
|
87
|
+
this.wsp.addSubscription(request);
|
|
89
88
|
}
|
|
90
|
-
|
|
91
|
-
|
|
89
|
+
unsubscribe(subscriptionId) {
|
|
90
|
+
this.wsp.removeSubscription(subscriptionId);
|
|
92
91
|
}
|
|
93
|
-
|
|
94
|
-
|
|
92
|
+
send(request) {
|
|
93
|
+
this.wsp.sendRequest(request);
|
|
95
94
|
}
|
|
96
95
|
/**
|
|
97
96
|
* Registers a handler function that will be called whenever all WebSocket connections are down or attempting to reconnect.
|
|
@@ -2,29 +2,48 @@ import type { ClientRequestArgs } from "node:http";
|
|
|
2
2
|
import type { ClientOptions, ErrorEvent } from "isomorphic-ws";
|
|
3
3
|
import WebSocket from "isomorphic-ws";
|
|
4
4
|
import type { Logger } from "ts-log";
|
|
5
|
-
export
|
|
5
|
+
export type ResilientWebSocketConfig = {
|
|
6
6
|
endpoint: string;
|
|
7
|
+
wsOptions?: ClientOptions | ClientRequestArgs | undefined;
|
|
8
|
+
logger?: Logger;
|
|
9
|
+
heartbeatTimeoutDurationMs?: number;
|
|
10
|
+
maxRetryDelayMs?: number;
|
|
11
|
+
logAfterRetryCount?: number;
|
|
12
|
+
};
|
|
13
|
+
export declare class ResilientWebSocket {
|
|
14
|
+
private endpoint;
|
|
15
|
+
private wsOptions?;
|
|
16
|
+
private logger;
|
|
17
|
+
private heartbeatTimeoutDurationMs;
|
|
18
|
+
private maxRetryDelayMs;
|
|
19
|
+
private logAfterRetryCount;
|
|
7
20
|
wsClient: undefined | WebSocket;
|
|
8
21
|
wsUserClosed: boolean;
|
|
9
|
-
private wsOptions;
|
|
10
22
|
private wsFailedAttempts;
|
|
11
|
-
private heartbeatTimeout
|
|
12
|
-
private
|
|
13
|
-
private connectionPromise;
|
|
14
|
-
private resolveConnection;
|
|
15
|
-
private rejectConnection;
|
|
23
|
+
private heartbeatTimeout?;
|
|
24
|
+
private retryTimeout?;
|
|
16
25
|
private _isReconnecting;
|
|
17
|
-
|
|
18
|
-
|
|
26
|
+
isReconnecting(): boolean;
|
|
27
|
+
isConnected(): this is this & {
|
|
28
|
+
wsClient: WebSocket;
|
|
29
|
+
};
|
|
30
|
+
private shouldLogRetry;
|
|
19
31
|
onError: (error: ErrorEvent) => void;
|
|
20
32
|
onMessage: (data: WebSocket.Data) => void;
|
|
21
33
|
onReconnect: () => void;
|
|
22
|
-
constructor(
|
|
23
|
-
send(data: string | Buffer):
|
|
24
|
-
startWebSocket():
|
|
34
|
+
constructor(config: ResilientWebSocketConfig);
|
|
35
|
+
send(data: string | Buffer): void;
|
|
36
|
+
startWebSocket(): void;
|
|
25
37
|
private resetHeartbeat;
|
|
26
|
-
private
|
|
27
|
-
private handleClose;
|
|
28
|
-
private restartUnexpectedClosedWebsocket;
|
|
38
|
+
private handleReconnect;
|
|
29
39
|
closeWebSocket(): void;
|
|
40
|
+
/**
|
|
41
|
+
* Calculates the delay in milliseconds for exponential backoff based on the number of failed attempts.
|
|
42
|
+
*
|
|
43
|
+
* The delay increases exponentially with each attempt, starting at 20ms for the first attempt,
|
|
44
|
+
* and is capped at maxRetryDelayMs for attempts greater than or equal to 10.
|
|
45
|
+
*
|
|
46
|
+
* @returns The calculated delay in milliseconds before the next retry.
|
|
47
|
+
*/
|
|
48
|
+
private retryDelayMs;
|
|
30
49
|
}
|
|
@@ -5,38 +5,49 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.ResilientWebSocket = void 0;
|
|
7
7
|
const isomorphic_ws_1 = __importDefault(require("isomorphic-ws"));
|
|
8
|
-
const
|
|
9
|
-
const
|
|
8
|
+
const ts_log_1 = require("ts-log");
|
|
9
|
+
const DEFAULT_HEARTBEAT_TIMEOUT_DURATION_MS = 5000; // 5 seconds
|
|
10
|
+
const DEFAULT_MAX_RETRY_DELAY_MS = 1000; // 1 second'
|
|
11
|
+
const DEFAULT_LOG_AFTER_RETRY_COUNT = 10;
|
|
10
12
|
class ResilientWebSocket {
|
|
11
13
|
endpoint;
|
|
12
|
-
wsClient;
|
|
13
|
-
wsUserClosed;
|
|
14
14
|
wsOptions;
|
|
15
|
+
logger;
|
|
16
|
+
heartbeatTimeoutDurationMs;
|
|
17
|
+
maxRetryDelayMs;
|
|
18
|
+
logAfterRetryCount;
|
|
19
|
+
wsClient;
|
|
20
|
+
wsUserClosed = false;
|
|
15
21
|
wsFailedAttempts;
|
|
16
22
|
heartbeatTimeout;
|
|
17
|
-
|
|
18
|
-
connectionPromise;
|
|
19
|
-
resolveConnection;
|
|
20
|
-
rejectConnection;
|
|
23
|
+
retryTimeout;
|
|
21
24
|
_isReconnecting = false;
|
|
22
|
-
|
|
25
|
+
isReconnecting() {
|
|
23
26
|
return this._isReconnecting;
|
|
24
27
|
}
|
|
25
|
-
|
|
28
|
+
isConnected() {
|
|
26
29
|
return this.wsClient?.readyState === isomorphic_ws_1.default.OPEN;
|
|
27
30
|
}
|
|
31
|
+
shouldLogRetry() {
|
|
32
|
+
return this.wsFailedAttempts % this.logAfterRetryCount === 0;
|
|
33
|
+
}
|
|
28
34
|
onError;
|
|
29
35
|
onMessage;
|
|
30
36
|
onReconnect;
|
|
31
|
-
constructor(
|
|
32
|
-
this.endpoint = endpoint;
|
|
33
|
-
this.wsOptions = wsOptions;
|
|
34
|
-
this.logger = logger;
|
|
37
|
+
constructor(config) {
|
|
38
|
+
this.endpoint = config.endpoint;
|
|
39
|
+
this.wsOptions = config.wsOptions;
|
|
40
|
+
this.logger = config.logger ?? ts_log_1.dummyLogger;
|
|
41
|
+
this.heartbeatTimeoutDurationMs =
|
|
42
|
+
config.heartbeatTimeoutDurationMs ??
|
|
43
|
+
DEFAULT_HEARTBEAT_TIMEOUT_DURATION_MS;
|
|
44
|
+
this.maxRetryDelayMs = config.maxRetryDelayMs ?? DEFAULT_MAX_RETRY_DELAY_MS;
|
|
45
|
+
this.logAfterRetryCount =
|
|
46
|
+
config.logAfterRetryCount ?? DEFAULT_LOG_AFTER_RETRY_COUNT;
|
|
35
47
|
this.wsFailedAttempts = 0;
|
|
36
48
|
this.onError = (error) => {
|
|
37
|
-
|
|
49
|
+
void error;
|
|
38
50
|
};
|
|
39
|
-
this.wsUserClosed = true;
|
|
40
51
|
this.onMessage = (data) => {
|
|
41
52
|
void data;
|
|
42
53
|
};
|
|
@@ -44,144 +55,117 @@ class ResilientWebSocket {
|
|
|
44
55
|
// Empty function, can be set by the user.
|
|
45
56
|
};
|
|
46
57
|
}
|
|
47
|
-
|
|
48
|
-
this.logger
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
this.logger?.error("Couldn't connect to the websocket server. Error callback is called.");
|
|
58
|
+
send(data) {
|
|
59
|
+
this.logger.debug(`Sending message`);
|
|
60
|
+
if (this.isConnected()) {
|
|
61
|
+
this.wsClient.send(data);
|
|
52
62
|
}
|
|
53
63
|
else {
|
|
54
|
-
this.
|
|
64
|
+
this.logger.warn(`WebSocket to ${this.endpoint} is not connected. Cannot send message.`);
|
|
55
65
|
}
|
|
56
66
|
}
|
|
57
|
-
|
|
67
|
+
startWebSocket() {
|
|
68
|
+
if (this.wsUserClosed) {
|
|
69
|
+
this.logger.error("Connection was explicitly closed by user. Will not reconnect.");
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
58
72
|
if (this.wsClient !== undefined) {
|
|
59
|
-
|
|
60
|
-
if (this.connectionPromise) {
|
|
61
|
-
return this.connectionPromise;
|
|
62
|
-
}
|
|
73
|
+
this.logger.info("WebSocket client already started.");
|
|
63
74
|
return;
|
|
64
75
|
}
|
|
65
|
-
this.
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
this.
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
const timeoutId = setTimeout(() => {
|
|
73
|
-
if (this.rejectConnection) {
|
|
74
|
-
this.rejectConnection(new Error(`Connection timeout after ${String(CONNECTION_TIMEOUT)}ms`));
|
|
75
|
-
}
|
|
76
|
-
}, CONNECTION_TIMEOUT);
|
|
76
|
+
if (this.wsFailedAttempts == 0) {
|
|
77
|
+
this.logger.info(`Creating Web Socket client`);
|
|
78
|
+
}
|
|
79
|
+
if (this.retryTimeout !== undefined) {
|
|
80
|
+
clearTimeout(this.retryTimeout);
|
|
81
|
+
this.retryTimeout = undefined;
|
|
82
|
+
}
|
|
77
83
|
this.wsClient = new isomorphic_ws_1.default(this.endpoint, this.wsOptions);
|
|
78
|
-
this.wsUserClosed = false;
|
|
79
84
|
this.wsClient.addEventListener("open", () => {
|
|
85
|
+
this.logger.info("WebSocket connection established");
|
|
80
86
|
this.wsFailedAttempts = 0;
|
|
81
|
-
this.resetHeartbeat();
|
|
82
|
-
clearTimeout(timeoutId);
|
|
83
87
|
this._isReconnecting = false;
|
|
84
|
-
this.
|
|
88
|
+
this.resetHeartbeat();
|
|
89
|
+
this.onReconnect();
|
|
90
|
+
});
|
|
91
|
+
this.wsClient.addEventListener("close", (e) => {
|
|
92
|
+
if (this.wsUserClosed) {
|
|
93
|
+
this.logger.info(`WebSocket connection to ${this.endpoint} closed by user`);
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
if (this.shouldLogRetry()) {
|
|
97
|
+
this.logger.warn(`WebSocket connection to ${this.endpoint} closed unexpectedly: Code: ${e.code.toString()}`);
|
|
98
|
+
}
|
|
99
|
+
this.handleReconnect();
|
|
100
|
+
}
|
|
85
101
|
});
|
|
86
102
|
this.wsClient.addEventListener("error", (event) => {
|
|
87
103
|
this.onError(event);
|
|
88
|
-
if (this.rejectConnection) {
|
|
89
|
-
this.rejectConnection(new Error("WebSocket connection failed"));
|
|
90
|
-
}
|
|
91
104
|
});
|
|
92
105
|
this.wsClient.addEventListener("message", (event) => {
|
|
93
106
|
this.resetHeartbeat();
|
|
94
107
|
this.onMessage(event.data);
|
|
95
108
|
});
|
|
96
|
-
this.wsClient.addEventListener("close", () => {
|
|
97
|
-
clearTimeout(timeoutId);
|
|
98
|
-
if (this.rejectConnection) {
|
|
99
|
-
this.rejectConnection(new Error("WebSocket closed before connecting"));
|
|
100
|
-
}
|
|
101
|
-
void this.handleClose();
|
|
102
|
-
});
|
|
103
109
|
if ("on" in this.wsClient) {
|
|
104
110
|
this.wsClient.on("ping", () => {
|
|
105
|
-
this.logger
|
|
111
|
+
this.logger.info("Ping received");
|
|
106
112
|
this.resetHeartbeat();
|
|
107
113
|
});
|
|
108
114
|
}
|
|
109
|
-
return this.connectionPromise;
|
|
110
115
|
}
|
|
111
116
|
resetHeartbeat() {
|
|
112
117
|
if (this.heartbeatTimeout !== undefined) {
|
|
113
118
|
clearTimeout(this.heartbeatTimeout);
|
|
114
119
|
}
|
|
115
120
|
this.heartbeatTimeout = setTimeout(() => {
|
|
116
|
-
this.logger
|
|
121
|
+
this.logger.warn("Connection timed out. Reconnecting...");
|
|
117
122
|
this.wsClient?.terminate();
|
|
118
|
-
|
|
119
|
-
},
|
|
123
|
+
this.handleReconnect();
|
|
124
|
+
}, this.heartbeatTimeoutDurationMs);
|
|
120
125
|
}
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
if (waitedTime > 5000) {
|
|
126
|
-
this.wsClient.close();
|
|
127
|
-
return;
|
|
128
|
-
}
|
|
129
|
-
else {
|
|
130
|
-
waitedTime += 10;
|
|
131
|
-
await sleep(10);
|
|
132
|
-
}
|
|
126
|
+
handleReconnect() {
|
|
127
|
+
if (this.wsUserClosed) {
|
|
128
|
+
this.logger.info("WebSocket connection closed by user, not reconnecting.");
|
|
129
|
+
return;
|
|
133
130
|
}
|
|
134
|
-
}
|
|
135
|
-
async handleClose() {
|
|
136
131
|
if (this.heartbeatTimeout !== undefined) {
|
|
137
132
|
clearTimeout(this.heartbeatTimeout);
|
|
138
133
|
}
|
|
139
|
-
if (this.
|
|
140
|
-
this.
|
|
134
|
+
if (this.retryTimeout !== undefined) {
|
|
135
|
+
clearTimeout(this.retryTimeout);
|
|
141
136
|
}
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
this.
|
|
147
|
-
|
|
148
|
-
const waitTime = expoBackoff(this.wsFailedAttempts);
|
|
149
|
-
this._isReconnecting = true;
|
|
150
|
-
this.logger?.error("Connection closed unexpectedly or because of timeout. Reconnecting after " +
|
|
151
|
-
String(waitTime) +
|
|
137
|
+
this.wsFailedAttempts += 1;
|
|
138
|
+
this.wsClient = undefined;
|
|
139
|
+
this._isReconnecting = true;
|
|
140
|
+
if (this.shouldLogRetry()) {
|
|
141
|
+
this.logger.error("Connection closed unexpectedly or because of timeout. Reconnecting after " +
|
|
142
|
+
String(this.retryDelayMs()) +
|
|
152
143
|
"ms.");
|
|
153
|
-
await sleep(waitTime);
|
|
154
|
-
await this.restartUnexpectedClosedWebsocket();
|
|
155
144
|
}
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
return;
|
|
160
|
-
}
|
|
161
|
-
await this.startWebSocket();
|
|
162
|
-
await this.waitForMaybeReadyWebSocket();
|
|
163
|
-
if (this.wsClient === undefined) {
|
|
164
|
-
this.logger?.error("Couldn't reconnect to websocket. Error callback is called.");
|
|
165
|
-
return;
|
|
166
|
-
}
|
|
167
|
-
this.onReconnect();
|
|
145
|
+
this.retryTimeout = setTimeout(() => {
|
|
146
|
+
this.startWebSocket();
|
|
147
|
+
}, this.retryDelayMs());
|
|
168
148
|
}
|
|
169
149
|
closeWebSocket() {
|
|
170
150
|
if (this.wsClient !== undefined) {
|
|
171
|
-
|
|
151
|
+
this.wsClient.close();
|
|
172
152
|
this.wsClient = undefined;
|
|
173
|
-
this.connectionPromise = undefined;
|
|
174
|
-
this.resolveConnection = undefined;
|
|
175
|
-
this.rejectConnection = undefined;
|
|
176
|
-
client.close();
|
|
177
153
|
}
|
|
178
154
|
this.wsUserClosed = true;
|
|
179
155
|
}
|
|
156
|
+
/**
|
|
157
|
+
* Calculates the delay in milliseconds for exponential backoff based on the number of failed attempts.
|
|
158
|
+
*
|
|
159
|
+
* The delay increases exponentially with each attempt, starting at 20ms for the first attempt,
|
|
160
|
+
* and is capped at maxRetryDelayMs for attempts greater than or equal to 10.
|
|
161
|
+
*
|
|
162
|
+
* @returns The calculated delay in milliseconds before the next retry.
|
|
163
|
+
*/
|
|
164
|
+
retryDelayMs() {
|
|
165
|
+
if (this.wsFailedAttempts >= 10) {
|
|
166
|
+
return this.maxRetryDelayMs;
|
|
167
|
+
}
|
|
168
|
+
return Math.min(2 ** this.wsFailedAttempts * 10, this.maxRetryDelayMs);
|
|
169
|
+
}
|
|
180
170
|
}
|
|
181
171
|
exports.ResilientWebSocket = ResilientWebSocket;
|
|
182
|
-
async function sleep(ms) {
|
|
183
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
184
|
-
}
|
|
185
|
-
function expoBackoff(attempts) {
|
|
186
|
-
return 2 ** attempts * 100;
|
|
187
|
-
}
|
|
@@ -1,7 +1,17 @@
|
|
|
1
|
+
import type { ErrorEvent } from "isomorphic-ws";
|
|
1
2
|
import WebSocket from "isomorphic-ws";
|
|
2
3
|
import type { Logger } from "ts-log";
|
|
3
4
|
import type { Request } from "../protocol.js";
|
|
5
|
+
import type { ResilientWebSocketConfig } from "./resilient-websocket.js";
|
|
4
6
|
import { ResilientWebSocket } from "./resilient-websocket.js";
|
|
7
|
+
export type WebSocketPoolConfig = {
|
|
8
|
+
urls: string[];
|
|
9
|
+
token: string;
|
|
10
|
+
numConnections?: number;
|
|
11
|
+
logger?: Logger;
|
|
12
|
+
rwsConfig?: Omit<ResilientWebSocketConfig, "logger" | "endpoint">;
|
|
13
|
+
onError?: (error: ErrorEvent) => void;
|
|
14
|
+
};
|
|
5
15
|
export declare class WebSocketPool {
|
|
6
16
|
private readonly logger;
|
|
7
17
|
rwsPool: ResilientWebSocket[];
|
|
@@ -20,7 +30,7 @@ export declare class WebSocketPool {
|
|
|
20
30
|
* @param numConnections - Number of parallel WebSocket connections to maintain (default: 3)
|
|
21
31
|
* @param logger - Optional logger to get socket level logs. Compatible with most loggers such as the built-in console and `bunyan`.
|
|
22
32
|
*/
|
|
23
|
-
static create(
|
|
33
|
+
static create(config: WebSocketPoolConfig): Promise<WebSocketPool>;
|
|
24
34
|
/**
|
|
25
35
|
* Checks for error responses in JSON messages and throws appropriate errors
|
|
26
36
|
*/
|
|
@@ -30,9 +40,9 @@ export declare class WebSocketPool {
|
|
|
30
40
|
* multiple connections before forwarding to registered handlers
|
|
31
41
|
*/
|
|
32
42
|
dedupeHandler: (data: WebSocket.Data) => void;
|
|
33
|
-
sendRequest(request: Request):
|
|
34
|
-
addSubscription(request: Request):
|
|
35
|
-
removeSubscription(subscriptionId: number):
|
|
43
|
+
sendRequest(request: Request): void;
|
|
44
|
+
addSubscription(request: Request): void;
|
|
45
|
+
removeSubscription(subscriptionId: number): void;
|
|
36
46
|
addMessageListener(handler: (data: WebSocket.Data) => void): void;
|
|
37
47
|
/**
|
|
38
48
|
* Calls the handler if all websocket connections are currently down or in reconnecting state.
|
|
@@ -40,6 +50,7 @@ export declare class WebSocketPool {
|
|
|
40
50
|
*/
|
|
41
51
|
addAllConnectionsDownListener(handler: () => void): void;
|
|
42
52
|
private areAllConnectionsDown;
|
|
53
|
+
private isAnyConnectionEstablished;
|
|
43
54
|
private checkConnectionStates;
|
|
44
55
|
shutdown(): void;
|
|
45
56
|
}
|
|
@@ -7,7 +7,7 @@ exports.WebSocketPool = void 0;
|
|
|
7
7
|
const ttlcache_1 = __importDefault(require("@isaacs/ttlcache"));
|
|
8
8
|
const ts_log_1 = require("ts-log");
|
|
9
9
|
const resilient_websocket_js_1 = require("./resilient-websocket.js");
|
|
10
|
-
const DEFAULT_NUM_CONNECTIONS =
|
|
10
|
+
const DEFAULT_NUM_CONNECTIONS = 4;
|
|
11
11
|
class WebSocketPool {
|
|
12
12
|
logger;
|
|
13
13
|
rwsPool;
|
|
@@ -17,7 +17,7 @@ class WebSocketPool {
|
|
|
17
17
|
allConnectionsDownListeners;
|
|
18
18
|
wasAllDown = true;
|
|
19
19
|
checkConnectionStatesInterval;
|
|
20
|
-
constructor(logger
|
|
20
|
+
constructor(logger) {
|
|
21
21
|
this.logger = logger;
|
|
22
22
|
this.rwsPool = [];
|
|
23
23
|
this.cache = new ttlcache_1.default({ ttl: 1000 * 10 }); // TTL of 10 seconds
|
|
@@ -37,24 +37,30 @@ class WebSocketPool {
|
|
|
37
37
|
* @param numConnections - Number of parallel WebSocket connections to maintain (default: 3)
|
|
38
38
|
* @param logger - Optional logger to get socket level logs. Compatible with most loggers such as the built-in console and `bunyan`.
|
|
39
39
|
*/
|
|
40
|
-
static async create(
|
|
41
|
-
if (urls.length === 0) {
|
|
40
|
+
static async create(config) {
|
|
41
|
+
if (config.urls.length === 0) {
|
|
42
42
|
throw new Error("No URLs provided");
|
|
43
43
|
}
|
|
44
|
+
const logger = config.logger ?? ts_log_1.dummyLogger;
|
|
44
45
|
const pool = new WebSocketPool(logger);
|
|
45
|
-
|
|
46
|
-
const connectionPromises = [];
|
|
46
|
+
const numConnections = config.numConnections ?? DEFAULT_NUM_CONNECTIONS;
|
|
47
47
|
for (let i = 0; i < numConnections; i++) {
|
|
48
|
-
const url = urls[i % urls.length];
|
|
48
|
+
const url = config.urls[i % config.urls.length];
|
|
49
49
|
if (!url) {
|
|
50
50
|
throw new Error(`URLs must not be null or empty`);
|
|
51
51
|
}
|
|
52
52
|
const wsOptions = {
|
|
53
|
+
...config.rwsConfig?.wsOptions,
|
|
53
54
|
headers: {
|
|
54
|
-
Authorization: `Bearer ${token}`,
|
|
55
|
+
Authorization: `Bearer ${config.token}`,
|
|
55
56
|
},
|
|
56
57
|
};
|
|
57
|
-
const rws = new resilient_websocket_js_1.ResilientWebSocket(
|
|
58
|
+
const rws = new resilient_websocket_js_1.ResilientWebSocket({
|
|
59
|
+
...config.rwsConfig,
|
|
60
|
+
endpoint: url,
|
|
61
|
+
wsOptions,
|
|
62
|
+
logger,
|
|
63
|
+
});
|
|
58
64
|
// If a websocket client unexpectedly disconnects, ResilientWebSocket will reestablish
|
|
59
65
|
// the connection and call the onReconnect callback.
|
|
60
66
|
rws.onReconnect = () => {
|
|
@@ -63,29 +69,26 @@ class WebSocketPool {
|
|
|
63
69
|
}
|
|
64
70
|
for (const [, request] of pool.subscriptions) {
|
|
65
71
|
try {
|
|
66
|
-
|
|
72
|
+
rws.send(JSON.stringify(request));
|
|
67
73
|
}
|
|
68
74
|
catch (error) {
|
|
69
75
|
pool.logger.error("Failed to resend subscription on reconnect:", error);
|
|
70
76
|
}
|
|
71
77
|
}
|
|
72
78
|
};
|
|
79
|
+
if (config.onError) {
|
|
80
|
+
rws.onError = config.onError;
|
|
81
|
+
}
|
|
73
82
|
// Handle all client messages ourselves. Dedupe before sending to registered message handlers.
|
|
74
83
|
rws.onMessage = pool.dedupeHandler;
|
|
75
84
|
pool.rwsPool.push(rws);
|
|
76
|
-
|
|
77
|
-
connectionPromises.push(rws.startWebSocket());
|
|
78
|
-
}
|
|
79
|
-
// Wait for all connections to be established
|
|
80
|
-
try {
|
|
81
|
-
await Promise.all(connectionPromises);
|
|
85
|
+
rws.startWebSocket();
|
|
82
86
|
}
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
throw error;
|
|
87
|
+
pool.logger.info(`Started WebSocketPool with ${numConnections.toString()} connections. Waiting for at least one to connect...`);
|
|
88
|
+
while (!pool.isAnyConnectionEstablished()) {
|
|
89
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
87
90
|
}
|
|
88
|
-
pool.logger.info(`
|
|
91
|
+
pool.logger.info(`At least one WebSocket connection is established. WebSocketPool is ready.`);
|
|
89
92
|
return pool;
|
|
90
93
|
}
|
|
91
94
|
/**
|
|
@@ -120,32 +123,25 @@ class WebSocketPool {
|
|
|
120
123
|
handler(data);
|
|
121
124
|
}
|
|
122
125
|
};
|
|
123
|
-
|
|
124
|
-
const
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
}
|
|
128
|
-
catch (error) {
|
|
129
|
-
this.logger.error("Failed to send request:", error);
|
|
130
|
-
throw error;
|
|
131
|
-
}
|
|
132
|
-
});
|
|
133
|
-
await Promise.all(sendPromises);
|
|
126
|
+
sendRequest(request) {
|
|
127
|
+
for (const rws of this.rwsPool) {
|
|
128
|
+
rws.send(JSON.stringify(request));
|
|
129
|
+
}
|
|
134
130
|
}
|
|
135
|
-
|
|
131
|
+
addSubscription(request) {
|
|
136
132
|
if (request.type !== "subscribe") {
|
|
137
133
|
throw new Error("Request must be a subscribe request");
|
|
138
134
|
}
|
|
139
135
|
this.subscriptions.set(request.subscriptionId, request);
|
|
140
|
-
|
|
136
|
+
this.sendRequest(request);
|
|
141
137
|
}
|
|
142
|
-
|
|
138
|
+
removeSubscription(subscriptionId) {
|
|
143
139
|
this.subscriptions.delete(subscriptionId);
|
|
144
140
|
const request = {
|
|
145
141
|
type: "unsubscribe",
|
|
146
142
|
subscriptionId,
|
|
147
143
|
};
|
|
148
|
-
|
|
144
|
+
this.sendRequest(request);
|
|
149
145
|
}
|
|
150
146
|
addMessageListener(handler) {
|
|
151
147
|
this.messageListeners.push(handler);
|
|
@@ -158,7 +154,10 @@ class WebSocketPool {
|
|
|
158
154
|
this.allConnectionsDownListeners.push(handler);
|
|
159
155
|
}
|
|
160
156
|
areAllConnectionsDown() {
|
|
161
|
-
return this.rwsPool.every((ws) => !ws.isConnected || ws.isReconnecting);
|
|
157
|
+
return this.rwsPool.every((ws) => !ws.isConnected() || ws.isReconnecting());
|
|
158
|
+
}
|
|
159
|
+
isAnyConnectionEstablished() {
|
|
160
|
+
return this.rwsPool.some((ws) => ws.isConnected());
|
|
162
161
|
}
|
|
163
162
|
checkConnectionStates() {
|
|
164
163
|
const allDown = this.areAllConnectionsDown();
|
package/dist/esm/client.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type { Logger } from "ts-log";
|
|
2
1
|
import type { ParsedPayload, Request, Response } from "./protocol.js";
|
|
2
|
+
import type { WebSocketPoolConfig } from "./socket/websocket-pool.js";
|
|
3
3
|
export type BinaryResponse = {
|
|
4
4
|
subscriptionId: number;
|
|
5
5
|
evm?: Buffer | undefined;
|
|
@@ -25,7 +25,7 @@ export declare class PythLazerClient {
|
|
|
25
25
|
* @param numConnections - The number of parallel WebSocket connections to establish (default: 3). A higher number gives a more reliable stream. The connections will round-robin across the provided URLs.
|
|
26
26
|
* @param logger - Optional logger to get socket level logs. Compatible with most loggers such as the built-in console and `bunyan`.
|
|
27
27
|
*/
|
|
28
|
-
static create(
|
|
28
|
+
static create(config: WebSocketPoolConfig): Promise<PythLazerClient>;
|
|
29
29
|
/**
|
|
30
30
|
* Adds a message listener that receives either JSON or binary responses from the WebSocket connections.
|
|
31
31
|
* The listener will be called for each message received, with deduplication across redundant connections.
|
|
@@ -33,9 +33,9 @@ export declare class PythLazerClient {
|
|
|
33
33
|
* or a binary response containing EVM, Solana, or parsed payload data.
|
|
34
34
|
*/
|
|
35
35
|
addMessageListener(handler: (event: JsonOrBinaryResponse) => void): void;
|
|
36
|
-
subscribe(request: Request):
|
|
37
|
-
unsubscribe(subscriptionId: number):
|
|
38
|
-
send(request: Request):
|
|
36
|
+
subscribe(request: Request): void;
|
|
37
|
+
unsubscribe(subscriptionId: number): void;
|
|
38
|
+
send(request: Request): void;
|
|
39
39
|
/**
|
|
40
40
|
* Registers a handler function that will be called whenever all WebSocket connections are down or attempting to reconnect.
|
|
41
41
|
* The connections may still try to reconnect in the background. To shut down the pool, call `shutdown()`.
|
package/dist/esm/client.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import WebSocket from "isomorphic-ws";
|
|
2
|
-
import { dummyLogger } from "ts-log";
|
|
3
2
|
import { BINARY_UPDATE_FORMAT_MAGIC_LE, FORMAT_MAGICS_LE } from "./protocol.js";
|
|
4
3
|
import { WebSocketPool } from "./socket/websocket-pool.js";
|
|
5
4
|
const UINT16_NUM_BYTES = 2;
|
|
@@ -17,8 +16,8 @@ export class PythLazerClient {
|
|
|
17
16
|
* @param numConnections - The number of parallel WebSocket connections to establish (default: 3). A higher number gives a more reliable stream. The connections will round-robin across the provided URLs.
|
|
18
17
|
* @param logger - Optional logger to get socket level logs. Compatible with most loggers such as the built-in console and `bunyan`.
|
|
19
18
|
*/
|
|
20
|
-
static async create(
|
|
21
|
-
const wsp = await WebSocketPool.create(
|
|
19
|
+
static async create(config) {
|
|
20
|
+
const wsp = await WebSocketPool.create(config);
|
|
22
21
|
return new PythLazerClient(wsp);
|
|
23
22
|
}
|
|
24
23
|
/**
|
|
@@ -79,17 +78,17 @@ export class PythLazerClient {
|
|
|
79
78
|
}
|
|
80
79
|
});
|
|
81
80
|
}
|
|
82
|
-
|
|
81
|
+
subscribe(request) {
|
|
83
82
|
if (request.type !== "subscribe") {
|
|
84
83
|
throw new Error("Request must be a subscribe request");
|
|
85
84
|
}
|
|
86
|
-
|
|
85
|
+
this.wsp.addSubscription(request);
|
|
87
86
|
}
|
|
88
|
-
|
|
89
|
-
|
|
87
|
+
unsubscribe(subscriptionId) {
|
|
88
|
+
this.wsp.removeSubscription(subscriptionId);
|
|
90
89
|
}
|
|
91
|
-
|
|
92
|
-
|
|
90
|
+
send(request) {
|
|
91
|
+
this.wsp.sendRequest(request);
|
|
93
92
|
}
|
|
94
93
|
/**
|
|
95
94
|
* Registers a handler function that will be called whenever all WebSocket connections are down or attempting to reconnect.
|
|
@@ -2,29 +2,48 @@ import type { ClientRequestArgs } from "node:http";
|
|
|
2
2
|
import type { ClientOptions, ErrorEvent } from "isomorphic-ws";
|
|
3
3
|
import WebSocket from "isomorphic-ws";
|
|
4
4
|
import type { Logger } from "ts-log";
|
|
5
|
-
export
|
|
5
|
+
export type ResilientWebSocketConfig = {
|
|
6
6
|
endpoint: string;
|
|
7
|
+
wsOptions?: ClientOptions | ClientRequestArgs | undefined;
|
|
8
|
+
logger?: Logger;
|
|
9
|
+
heartbeatTimeoutDurationMs?: number;
|
|
10
|
+
maxRetryDelayMs?: number;
|
|
11
|
+
logAfterRetryCount?: number;
|
|
12
|
+
};
|
|
13
|
+
export declare class ResilientWebSocket {
|
|
14
|
+
private endpoint;
|
|
15
|
+
private wsOptions?;
|
|
16
|
+
private logger;
|
|
17
|
+
private heartbeatTimeoutDurationMs;
|
|
18
|
+
private maxRetryDelayMs;
|
|
19
|
+
private logAfterRetryCount;
|
|
7
20
|
wsClient: undefined | WebSocket;
|
|
8
21
|
wsUserClosed: boolean;
|
|
9
|
-
private wsOptions;
|
|
10
22
|
private wsFailedAttempts;
|
|
11
|
-
private heartbeatTimeout
|
|
12
|
-
private
|
|
13
|
-
private connectionPromise;
|
|
14
|
-
private resolveConnection;
|
|
15
|
-
private rejectConnection;
|
|
23
|
+
private heartbeatTimeout?;
|
|
24
|
+
private retryTimeout?;
|
|
16
25
|
private _isReconnecting;
|
|
17
|
-
|
|
18
|
-
|
|
26
|
+
isReconnecting(): boolean;
|
|
27
|
+
isConnected(): this is this & {
|
|
28
|
+
wsClient: WebSocket;
|
|
29
|
+
};
|
|
30
|
+
private shouldLogRetry;
|
|
19
31
|
onError: (error: ErrorEvent) => void;
|
|
20
32
|
onMessage: (data: WebSocket.Data) => void;
|
|
21
33
|
onReconnect: () => void;
|
|
22
|
-
constructor(
|
|
23
|
-
send(data: string | Buffer):
|
|
24
|
-
startWebSocket():
|
|
34
|
+
constructor(config: ResilientWebSocketConfig);
|
|
35
|
+
send(data: string | Buffer): void;
|
|
36
|
+
startWebSocket(): void;
|
|
25
37
|
private resetHeartbeat;
|
|
26
|
-
private
|
|
27
|
-
private handleClose;
|
|
28
|
-
private restartUnexpectedClosedWebsocket;
|
|
38
|
+
private handleReconnect;
|
|
29
39
|
closeWebSocket(): void;
|
|
40
|
+
/**
|
|
41
|
+
* Calculates the delay in milliseconds for exponential backoff based on the number of failed attempts.
|
|
42
|
+
*
|
|
43
|
+
* The delay increases exponentially with each attempt, starting at 20ms for the first attempt,
|
|
44
|
+
* and is capped at maxRetryDelayMs for attempts greater than or equal to 10.
|
|
45
|
+
*
|
|
46
|
+
* @returns The calculated delay in milliseconds before the next retry.
|
|
47
|
+
*/
|
|
48
|
+
private retryDelayMs;
|
|
30
49
|
}
|
|
@@ -1,36 +1,47 @@
|
|
|
1
1
|
import WebSocket from "isomorphic-ws";
|
|
2
|
-
|
|
3
|
-
const
|
|
2
|
+
import { dummyLogger } from "ts-log";
|
|
3
|
+
const DEFAULT_HEARTBEAT_TIMEOUT_DURATION_MS = 5000; // 5 seconds
|
|
4
|
+
const DEFAULT_MAX_RETRY_DELAY_MS = 1000; // 1 second'
|
|
5
|
+
const DEFAULT_LOG_AFTER_RETRY_COUNT = 10;
|
|
4
6
|
export class ResilientWebSocket {
|
|
5
7
|
endpoint;
|
|
6
|
-
wsClient;
|
|
7
|
-
wsUserClosed;
|
|
8
8
|
wsOptions;
|
|
9
|
+
logger;
|
|
10
|
+
heartbeatTimeoutDurationMs;
|
|
11
|
+
maxRetryDelayMs;
|
|
12
|
+
logAfterRetryCount;
|
|
13
|
+
wsClient;
|
|
14
|
+
wsUserClosed = false;
|
|
9
15
|
wsFailedAttempts;
|
|
10
16
|
heartbeatTimeout;
|
|
11
|
-
|
|
12
|
-
connectionPromise;
|
|
13
|
-
resolveConnection;
|
|
14
|
-
rejectConnection;
|
|
17
|
+
retryTimeout;
|
|
15
18
|
_isReconnecting = false;
|
|
16
|
-
|
|
19
|
+
isReconnecting() {
|
|
17
20
|
return this._isReconnecting;
|
|
18
21
|
}
|
|
19
|
-
|
|
22
|
+
isConnected() {
|
|
20
23
|
return this.wsClient?.readyState === WebSocket.OPEN;
|
|
21
24
|
}
|
|
25
|
+
shouldLogRetry() {
|
|
26
|
+
return this.wsFailedAttempts % this.logAfterRetryCount === 0;
|
|
27
|
+
}
|
|
22
28
|
onError;
|
|
23
29
|
onMessage;
|
|
24
30
|
onReconnect;
|
|
25
|
-
constructor(
|
|
26
|
-
this.endpoint = endpoint;
|
|
27
|
-
this.wsOptions = wsOptions;
|
|
28
|
-
this.logger = logger;
|
|
31
|
+
constructor(config) {
|
|
32
|
+
this.endpoint = config.endpoint;
|
|
33
|
+
this.wsOptions = config.wsOptions;
|
|
34
|
+
this.logger = config.logger ?? dummyLogger;
|
|
35
|
+
this.heartbeatTimeoutDurationMs =
|
|
36
|
+
config.heartbeatTimeoutDurationMs ??
|
|
37
|
+
DEFAULT_HEARTBEAT_TIMEOUT_DURATION_MS;
|
|
38
|
+
this.maxRetryDelayMs = config.maxRetryDelayMs ?? DEFAULT_MAX_RETRY_DELAY_MS;
|
|
39
|
+
this.logAfterRetryCount =
|
|
40
|
+
config.logAfterRetryCount ?? DEFAULT_LOG_AFTER_RETRY_COUNT;
|
|
29
41
|
this.wsFailedAttempts = 0;
|
|
30
42
|
this.onError = (error) => {
|
|
31
|
-
|
|
43
|
+
void error;
|
|
32
44
|
};
|
|
33
|
-
this.wsUserClosed = true;
|
|
34
45
|
this.onMessage = (data) => {
|
|
35
46
|
void data;
|
|
36
47
|
};
|
|
@@ -38,143 +49,116 @@ export class ResilientWebSocket {
|
|
|
38
49
|
// Empty function, can be set by the user.
|
|
39
50
|
};
|
|
40
51
|
}
|
|
41
|
-
|
|
42
|
-
this.logger
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
this.logger?.error("Couldn't connect to the websocket server. Error callback is called.");
|
|
52
|
+
send(data) {
|
|
53
|
+
this.logger.debug(`Sending message`);
|
|
54
|
+
if (this.isConnected()) {
|
|
55
|
+
this.wsClient.send(data);
|
|
46
56
|
}
|
|
47
57
|
else {
|
|
48
|
-
this.
|
|
58
|
+
this.logger.warn(`WebSocket to ${this.endpoint} is not connected. Cannot send message.`);
|
|
49
59
|
}
|
|
50
60
|
}
|
|
51
|
-
|
|
61
|
+
startWebSocket() {
|
|
62
|
+
if (this.wsUserClosed) {
|
|
63
|
+
this.logger.error("Connection was explicitly closed by user. Will not reconnect.");
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
52
66
|
if (this.wsClient !== undefined) {
|
|
53
|
-
|
|
54
|
-
if (this.connectionPromise) {
|
|
55
|
-
return this.connectionPromise;
|
|
56
|
-
}
|
|
67
|
+
this.logger.info("WebSocket client already started.");
|
|
57
68
|
return;
|
|
58
69
|
}
|
|
59
|
-
this.
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
this.
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
const timeoutId = setTimeout(() => {
|
|
67
|
-
if (this.rejectConnection) {
|
|
68
|
-
this.rejectConnection(new Error(`Connection timeout after ${String(CONNECTION_TIMEOUT)}ms`));
|
|
69
|
-
}
|
|
70
|
-
}, CONNECTION_TIMEOUT);
|
|
70
|
+
if (this.wsFailedAttempts == 0) {
|
|
71
|
+
this.logger.info(`Creating Web Socket client`);
|
|
72
|
+
}
|
|
73
|
+
if (this.retryTimeout !== undefined) {
|
|
74
|
+
clearTimeout(this.retryTimeout);
|
|
75
|
+
this.retryTimeout = undefined;
|
|
76
|
+
}
|
|
71
77
|
this.wsClient = new WebSocket(this.endpoint, this.wsOptions);
|
|
72
|
-
this.wsUserClosed = false;
|
|
73
78
|
this.wsClient.addEventListener("open", () => {
|
|
79
|
+
this.logger.info("WebSocket connection established");
|
|
74
80
|
this.wsFailedAttempts = 0;
|
|
75
|
-
this.resetHeartbeat();
|
|
76
|
-
clearTimeout(timeoutId);
|
|
77
81
|
this._isReconnecting = false;
|
|
78
|
-
this.
|
|
82
|
+
this.resetHeartbeat();
|
|
83
|
+
this.onReconnect();
|
|
84
|
+
});
|
|
85
|
+
this.wsClient.addEventListener("close", (e) => {
|
|
86
|
+
if (this.wsUserClosed) {
|
|
87
|
+
this.logger.info(`WebSocket connection to ${this.endpoint} closed by user`);
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
if (this.shouldLogRetry()) {
|
|
91
|
+
this.logger.warn(`WebSocket connection to ${this.endpoint} closed unexpectedly: Code: ${e.code.toString()}`);
|
|
92
|
+
}
|
|
93
|
+
this.handleReconnect();
|
|
94
|
+
}
|
|
79
95
|
});
|
|
80
96
|
this.wsClient.addEventListener("error", (event) => {
|
|
81
97
|
this.onError(event);
|
|
82
|
-
if (this.rejectConnection) {
|
|
83
|
-
this.rejectConnection(new Error("WebSocket connection failed"));
|
|
84
|
-
}
|
|
85
98
|
});
|
|
86
99
|
this.wsClient.addEventListener("message", (event) => {
|
|
87
100
|
this.resetHeartbeat();
|
|
88
101
|
this.onMessage(event.data);
|
|
89
102
|
});
|
|
90
|
-
this.wsClient.addEventListener("close", () => {
|
|
91
|
-
clearTimeout(timeoutId);
|
|
92
|
-
if (this.rejectConnection) {
|
|
93
|
-
this.rejectConnection(new Error("WebSocket closed before connecting"));
|
|
94
|
-
}
|
|
95
|
-
void this.handleClose();
|
|
96
|
-
});
|
|
97
103
|
if ("on" in this.wsClient) {
|
|
98
104
|
this.wsClient.on("ping", () => {
|
|
99
|
-
this.logger
|
|
105
|
+
this.logger.info("Ping received");
|
|
100
106
|
this.resetHeartbeat();
|
|
101
107
|
});
|
|
102
108
|
}
|
|
103
|
-
return this.connectionPromise;
|
|
104
109
|
}
|
|
105
110
|
resetHeartbeat() {
|
|
106
111
|
if (this.heartbeatTimeout !== undefined) {
|
|
107
112
|
clearTimeout(this.heartbeatTimeout);
|
|
108
113
|
}
|
|
109
114
|
this.heartbeatTimeout = setTimeout(() => {
|
|
110
|
-
this.logger
|
|
115
|
+
this.logger.warn("Connection timed out. Reconnecting...");
|
|
111
116
|
this.wsClient?.terminate();
|
|
112
|
-
|
|
113
|
-
},
|
|
117
|
+
this.handleReconnect();
|
|
118
|
+
}, this.heartbeatTimeoutDurationMs);
|
|
114
119
|
}
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
if (waitedTime > 5000) {
|
|
120
|
-
this.wsClient.close();
|
|
121
|
-
return;
|
|
122
|
-
}
|
|
123
|
-
else {
|
|
124
|
-
waitedTime += 10;
|
|
125
|
-
await sleep(10);
|
|
126
|
-
}
|
|
120
|
+
handleReconnect() {
|
|
121
|
+
if (this.wsUserClosed) {
|
|
122
|
+
this.logger.info("WebSocket connection closed by user, not reconnecting.");
|
|
123
|
+
return;
|
|
127
124
|
}
|
|
128
|
-
}
|
|
129
|
-
async handleClose() {
|
|
130
125
|
if (this.heartbeatTimeout !== undefined) {
|
|
131
126
|
clearTimeout(this.heartbeatTimeout);
|
|
132
127
|
}
|
|
133
|
-
if (this.
|
|
134
|
-
this.
|
|
128
|
+
if (this.retryTimeout !== undefined) {
|
|
129
|
+
clearTimeout(this.retryTimeout);
|
|
135
130
|
}
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
this.
|
|
141
|
-
|
|
142
|
-
const waitTime = expoBackoff(this.wsFailedAttempts);
|
|
143
|
-
this._isReconnecting = true;
|
|
144
|
-
this.logger?.error("Connection closed unexpectedly or because of timeout. Reconnecting after " +
|
|
145
|
-
String(waitTime) +
|
|
131
|
+
this.wsFailedAttempts += 1;
|
|
132
|
+
this.wsClient = undefined;
|
|
133
|
+
this._isReconnecting = true;
|
|
134
|
+
if (this.shouldLogRetry()) {
|
|
135
|
+
this.logger.error("Connection closed unexpectedly or because of timeout. Reconnecting after " +
|
|
136
|
+
String(this.retryDelayMs()) +
|
|
146
137
|
"ms.");
|
|
147
|
-
await sleep(waitTime);
|
|
148
|
-
await this.restartUnexpectedClosedWebsocket();
|
|
149
138
|
}
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
return;
|
|
154
|
-
}
|
|
155
|
-
await this.startWebSocket();
|
|
156
|
-
await this.waitForMaybeReadyWebSocket();
|
|
157
|
-
if (this.wsClient === undefined) {
|
|
158
|
-
this.logger?.error("Couldn't reconnect to websocket. Error callback is called.");
|
|
159
|
-
return;
|
|
160
|
-
}
|
|
161
|
-
this.onReconnect();
|
|
139
|
+
this.retryTimeout = setTimeout(() => {
|
|
140
|
+
this.startWebSocket();
|
|
141
|
+
}, this.retryDelayMs());
|
|
162
142
|
}
|
|
163
143
|
closeWebSocket() {
|
|
164
144
|
if (this.wsClient !== undefined) {
|
|
165
|
-
|
|
145
|
+
this.wsClient.close();
|
|
166
146
|
this.wsClient = undefined;
|
|
167
|
-
this.connectionPromise = undefined;
|
|
168
|
-
this.resolveConnection = undefined;
|
|
169
|
-
this.rejectConnection = undefined;
|
|
170
|
-
client.close();
|
|
171
147
|
}
|
|
172
148
|
this.wsUserClosed = true;
|
|
173
149
|
}
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
150
|
+
/**
|
|
151
|
+
* Calculates the delay in milliseconds for exponential backoff based on the number of failed attempts.
|
|
152
|
+
*
|
|
153
|
+
* The delay increases exponentially with each attempt, starting at 20ms for the first attempt,
|
|
154
|
+
* and is capped at maxRetryDelayMs for attempts greater than or equal to 10.
|
|
155
|
+
*
|
|
156
|
+
* @returns The calculated delay in milliseconds before the next retry.
|
|
157
|
+
*/
|
|
158
|
+
retryDelayMs() {
|
|
159
|
+
if (this.wsFailedAttempts >= 10) {
|
|
160
|
+
return this.maxRetryDelayMs;
|
|
161
|
+
}
|
|
162
|
+
return Math.min(2 ** this.wsFailedAttempts * 10, this.maxRetryDelayMs);
|
|
163
|
+
}
|
|
180
164
|
}
|
|
@@ -1,7 +1,17 @@
|
|
|
1
|
+
import type { ErrorEvent } from "isomorphic-ws";
|
|
1
2
|
import WebSocket from "isomorphic-ws";
|
|
2
3
|
import type { Logger } from "ts-log";
|
|
3
4
|
import type { Request } from "../protocol.js";
|
|
5
|
+
import type { ResilientWebSocketConfig } from "./resilient-websocket.js";
|
|
4
6
|
import { ResilientWebSocket } from "./resilient-websocket.js";
|
|
7
|
+
export type WebSocketPoolConfig = {
|
|
8
|
+
urls: string[];
|
|
9
|
+
token: string;
|
|
10
|
+
numConnections?: number;
|
|
11
|
+
logger?: Logger;
|
|
12
|
+
rwsConfig?: Omit<ResilientWebSocketConfig, "logger" | "endpoint">;
|
|
13
|
+
onError?: (error: ErrorEvent) => void;
|
|
14
|
+
};
|
|
5
15
|
export declare class WebSocketPool {
|
|
6
16
|
private readonly logger;
|
|
7
17
|
rwsPool: ResilientWebSocket[];
|
|
@@ -20,7 +30,7 @@ export declare class WebSocketPool {
|
|
|
20
30
|
* @param numConnections - Number of parallel WebSocket connections to maintain (default: 3)
|
|
21
31
|
* @param logger - Optional logger to get socket level logs. Compatible with most loggers such as the built-in console and `bunyan`.
|
|
22
32
|
*/
|
|
23
|
-
static create(
|
|
33
|
+
static create(config: WebSocketPoolConfig): Promise<WebSocketPool>;
|
|
24
34
|
/**
|
|
25
35
|
* Checks for error responses in JSON messages and throws appropriate errors
|
|
26
36
|
*/
|
|
@@ -30,9 +40,9 @@ export declare class WebSocketPool {
|
|
|
30
40
|
* multiple connections before forwarding to registered handlers
|
|
31
41
|
*/
|
|
32
42
|
dedupeHandler: (data: WebSocket.Data) => void;
|
|
33
|
-
sendRequest(request: Request):
|
|
34
|
-
addSubscription(request: Request):
|
|
35
|
-
removeSubscription(subscriptionId: number):
|
|
43
|
+
sendRequest(request: Request): void;
|
|
44
|
+
addSubscription(request: Request): void;
|
|
45
|
+
removeSubscription(subscriptionId: number): void;
|
|
36
46
|
addMessageListener(handler: (data: WebSocket.Data) => void): void;
|
|
37
47
|
/**
|
|
38
48
|
* Calls the handler if all websocket connections are currently down or in reconnecting state.
|
|
@@ -40,6 +50,7 @@ export declare class WebSocketPool {
|
|
|
40
50
|
*/
|
|
41
51
|
addAllConnectionsDownListener(handler: () => void): void;
|
|
42
52
|
private areAllConnectionsDown;
|
|
53
|
+
private isAnyConnectionEstablished;
|
|
43
54
|
private checkConnectionStates;
|
|
44
55
|
shutdown(): void;
|
|
45
56
|
}
|
|
@@ -2,7 +2,7 @@ import TTLCache from "@isaacs/ttlcache";
|
|
|
2
2
|
import WebSocket from "isomorphic-ws";
|
|
3
3
|
import { dummyLogger } from "ts-log";
|
|
4
4
|
import { ResilientWebSocket } from "./resilient-websocket.js";
|
|
5
|
-
const DEFAULT_NUM_CONNECTIONS =
|
|
5
|
+
const DEFAULT_NUM_CONNECTIONS = 4;
|
|
6
6
|
export class WebSocketPool {
|
|
7
7
|
logger;
|
|
8
8
|
rwsPool;
|
|
@@ -12,7 +12,7 @@ export class WebSocketPool {
|
|
|
12
12
|
allConnectionsDownListeners;
|
|
13
13
|
wasAllDown = true;
|
|
14
14
|
checkConnectionStatesInterval;
|
|
15
|
-
constructor(logger
|
|
15
|
+
constructor(logger) {
|
|
16
16
|
this.logger = logger;
|
|
17
17
|
this.rwsPool = [];
|
|
18
18
|
this.cache = new TTLCache({ ttl: 1000 * 10 }); // TTL of 10 seconds
|
|
@@ -32,24 +32,30 @@ export class WebSocketPool {
|
|
|
32
32
|
* @param numConnections - Number of parallel WebSocket connections to maintain (default: 3)
|
|
33
33
|
* @param logger - Optional logger to get socket level logs. Compatible with most loggers such as the built-in console and `bunyan`.
|
|
34
34
|
*/
|
|
35
|
-
static async create(
|
|
36
|
-
if (urls.length === 0) {
|
|
35
|
+
static async create(config) {
|
|
36
|
+
if (config.urls.length === 0) {
|
|
37
37
|
throw new Error("No URLs provided");
|
|
38
38
|
}
|
|
39
|
+
const logger = config.logger ?? dummyLogger;
|
|
39
40
|
const pool = new WebSocketPool(logger);
|
|
40
|
-
|
|
41
|
-
const connectionPromises = [];
|
|
41
|
+
const numConnections = config.numConnections ?? DEFAULT_NUM_CONNECTIONS;
|
|
42
42
|
for (let i = 0; i < numConnections; i++) {
|
|
43
|
-
const url = urls[i % urls.length];
|
|
43
|
+
const url = config.urls[i % config.urls.length];
|
|
44
44
|
if (!url) {
|
|
45
45
|
throw new Error(`URLs must not be null or empty`);
|
|
46
46
|
}
|
|
47
47
|
const wsOptions = {
|
|
48
|
+
...config.rwsConfig?.wsOptions,
|
|
48
49
|
headers: {
|
|
49
|
-
Authorization: `Bearer ${token}`,
|
|
50
|
+
Authorization: `Bearer ${config.token}`,
|
|
50
51
|
},
|
|
51
52
|
};
|
|
52
|
-
const rws = new ResilientWebSocket(
|
|
53
|
+
const rws = new ResilientWebSocket({
|
|
54
|
+
...config.rwsConfig,
|
|
55
|
+
endpoint: url,
|
|
56
|
+
wsOptions,
|
|
57
|
+
logger,
|
|
58
|
+
});
|
|
53
59
|
// If a websocket client unexpectedly disconnects, ResilientWebSocket will reestablish
|
|
54
60
|
// the connection and call the onReconnect callback.
|
|
55
61
|
rws.onReconnect = () => {
|
|
@@ -58,29 +64,26 @@ export class WebSocketPool {
|
|
|
58
64
|
}
|
|
59
65
|
for (const [, request] of pool.subscriptions) {
|
|
60
66
|
try {
|
|
61
|
-
|
|
67
|
+
rws.send(JSON.stringify(request));
|
|
62
68
|
}
|
|
63
69
|
catch (error) {
|
|
64
70
|
pool.logger.error("Failed to resend subscription on reconnect:", error);
|
|
65
71
|
}
|
|
66
72
|
}
|
|
67
73
|
};
|
|
74
|
+
if (config.onError) {
|
|
75
|
+
rws.onError = config.onError;
|
|
76
|
+
}
|
|
68
77
|
// Handle all client messages ourselves. Dedupe before sending to registered message handlers.
|
|
69
78
|
rws.onMessage = pool.dedupeHandler;
|
|
70
79
|
pool.rwsPool.push(rws);
|
|
71
|
-
|
|
72
|
-
connectionPromises.push(rws.startWebSocket());
|
|
73
|
-
}
|
|
74
|
-
// Wait for all connections to be established
|
|
75
|
-
try {
|
|
76
|
-
await Promise.all(connectionPromises);
|
|
80
|
+
rws.startWebSocket();
|
|
77
81
|
}
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
throw error;
|
|
82
|
+
pool.logger.info(`Started WebSocketPool with ${numConnections.toString()} connections. Waiting for at least one to connect...`);
|
|
83
|
+
while (!pool.isAnyConnectionEstablished()) {
|
|
84
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
82
85
|
}
|
|
83
|
-
pool.logger.info(`
|
|
86
|
+
pool.logger.info(`At least one WebSocket connection is established. WebSocketPool is ready.`);
|
|
84
87
|
return pool;
|
|
85
88
|
}
|
|
86
89
|
/**
|
|
@@ -115,32 +118,25 @@ export class WebSocketPool {
|
|
|
115
118
|
handler(data);
|
|
116
119
|
}
|
|
117
120
|
};
|
|
118
|
-
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
}
|
|
123
|
-
catch (error) {
|
|
124
|
-
this.logger.error("Failed to send request:", error);
|
|
125
|
-
throw error;
|
|
126
|
-
}
|
|
127
|
-
});
|
|
128
|
-
await Promise.all(sendPromises);
|
|
121
|
+
sendRequest(request) {
|
|
122
|
+
for (const rws of this.rwsPool) {
|
|
123
|
+
rws.send(JSON.stringify(request));
|
|
124
|
+
}
|
|
129
125
|
}
|
|
130
|
-
|
|
126
|
+
addSubscription(request) {
|
|
131
127
|
if (request.type !== "subscribe") {
|
|
132
128
|
throw new Error("Request must be a subscribe request");
|
|
133
129
|
}
|
|
134
130
|
this.subscriptions.set(request.subscriptionId, request);
|
|
135
|
-
|
|
131
|
+
this.sendRequest(request);
|
|
136
132
|
}
|
|
137
|
-
|
|
133
|
+
removeSubscription(subscriptionId) {
|
|
138
134
|
this.subscriptions.delete(subscriptionId);
|
|
139
135
|
const request = {
|
|
140
136
|
type: "unsubscribe",
|
|
141
137
|
subscriptionId,
|
|
142
138
|
};
|
|
143
|
-
|
|
139
|
+
this.sendRequest(request);
|
|
144
140
|
}
|
|
145
141
|
addMessageListener(handler) {
|
|
146
142
|
this.messageListeners.push(handler);
|
|
@@ -153,7 +149,10 @@ export class WebSocketPool {
|
|
|
153
149
|
this.allConnectionsDownListeners.push(handler);
|
|
154
150
|
}
|
|
155
151
|
areAllConnectionsDown() {
|
|
156
|
-
return this.rwsPool.every((ws) => !ws.isConnected || ws.isReconnecting);
|
|
152
|
+
return this.rwsPool.every((ws) => !ws.isConnected() || ws.isReconnecting());
|
|
153
|
+
}
|
|
154
|
+
isAnyConnectionEstablished() {
|
|
155
|
+
return this.rwsPool.some((ws) => ws.isConnected());
|
|
157
156
|
}
|
|
158
157
|
checkConnectionStates() {
|
|
159
158
|
const allDown = this.areAllConnectionsDown();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pythnetwork/pyth-lazer-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "1.0.0",
|
|
4
4
|
"description": "Pyth Lazer SDK",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
@@ -20,31 +20,19 @@
|
|
|
20
20
|
"default": "./dist/cjs/index.js"
|
|
21
21
|
}
|
|
22
22
|
},
|
|
23
|
-
"scripts": {
|
|
24
|
-
"build:cjs": "tsc --project tsconfig.build.json --verbatimModuleSyntax false --module commonjs --outDir ./dist/cjs && echo '{\"type\":\"commonjs\"}' > dist/cjs/package.json",
|
|
25
|
-
"build:esm": "tsc --project tsconfig.build.json --outDir ./dist/esm && echo '{\"type\":\"module\"}' > dist/esm/package.json",
|
|
26
|
-
"fix:lint": "eslint --fix . --max-warnings 0",
|
|
27
|
-
"test:lint": "eslint . --max-warnings 0",
|
|
28
|
-
"test:types": "tsc",
|
|
29
|
-
"test:format": "prettier --check .",
|
|
30
|
-
"fix:format": "prettier --write .",
|
|
31
|
-
"example": "node --loader ts-node/esm examples/index.js",
|
|
32
|
-
"doc": "typedoc --out docs/typedoc src",
|
|
33
|
-
"publish": "pnpm run script -- publish"
|
|
34
|
-
},
|
|
35
23
|
"devDependencies": {
|
|
36
|
-
"@cprussin/eslint-config": "
|
|
37
|
-
"@cprussin/tsconfig": "
|
|
24
|
+
"@cprussin/eslint-config": "^4.0.2",
|
|
25
|
+
"@cprussin/tsconfig": "^3.1.2",
|
|
38
26
|
"@types/node": "^18.19.54",
|
|
39
27
|
"@types/ws": "^8.5.12",
|
|
40
|
-
"eslint": "
|
|
41
|
-
"prettier": "
|
|
42
|
-
"ts-node": "
|
|
28
|
+
"eslint": "^9.23.0",
|
|
29
|
+
"prettier": "^3.5.3",
|
|
30
|
+
"ts-node": "^10.9.2",
|
|
43
31
|
"typedoc": "^0.26.8",
|
|
44
|
-
"typescript": "
|
|
32
|
+
"typescript": "^5.8.2"
|
|
45
33
|
},
|
|
46
34
|
"bugs": {
|
|
47
|
-
"url": "https://github.com/pyth-
|
|
35
|
+
"url": "https://github.com/pyth-network/pyth-crosschain/issues"
|
|
48
36
|
},
|
|
49
37
|
"type": "module",
|
|
50
38
|
"homepage": "https://github.com/pyth-network/pyth-crosschain/tree/main/lazer/sdk/js",
|
|
@@ -66,5 +54,15 @@
|
|
|
66
54
|
"ts-log": "^2.2.7",
|
|
67
55
|
"ws": "^8.18.0"
|
|
68
56
|
},
|
|
69
|
-
"
|
|
70
|
-
}
|
|
57
|
+
"scripts": {
|
|
58
|
+
"build:cjs": "tsc --project tsconfig.build.json --verbatimModuleSyntax false --module commonjs --outDir ./dist/cjs && echo '{\"type\":\"commonjs\"}' > dist/cjs/package.json",
|
|
59
|
+
"build:esm": "tsc --project tsconfig.build.json --outDir ./dist/esm && echo '{\"type\":\"module\"}' > dist/esm/package.json",
|
|
60
|
+
"fix:lint": "eslint --fix . --max-warnings 0",
|
|
61
|
+
"test:lint": "eslint . --max-warnings 0",
|
|
62
|
+
"test:types": "tsc",
|
|
63
|
+
"test:format": "prettier --check .",
|
|
64
|
+
"fix:format": "prettier --write .",
|
|
65
|
+
"example": "node --loader ts-node/esm examples/index.js",
|
|
66
|
+
"doc": "typedoc --out docs/typedoc src"
|
|
67
|
+
}
|
|
68
|
+
}
|