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