@pythnetwork/pyth-lazer-sdk 0.1.2 → 0.3.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 +27 -4
- package/dist/cjs/client.js +56 -27
- package/dist/cjs/constants.d.ts +3 -0
- package/dist/cjs/constants.js +6 -0
- package/dist/cjs/index.d.ts +1 -0
- package/dist/cjs/index.js +1 -0
- package/dist/cjs/socket/resilient-websocket.d.ts +29 -0
- package/dist/cjs/socket/resilient-websocket.js +187 -0
- package/dist/cjs/socket/websocket-pool.d.ts +44 -0
- package/dist/cjs/socket/websocket-pool.js +188 -0
- package/dist/esm/client.d.ts +27 -4
- package/dist/esm/client.js +56 -23
- package/dist/esm/constants.d.ts +3 -0
- package/dist/esm/constants.js +3 -0
- package/dist/esm/index.d.ts +1 -0
- package/dist/esm/index.js +1 -0
- package/dist/esm/socket/resilient-websocket.d.ts +29 -0
- package/dist/esm/socket/resilient-websocket.js +180 -0
- package/dist/esm/socket/websocket-pool.d.ts +44 -0
- package/dist/esm/socket/websocket-pool.js +182 -0
- package/package.json +4 -2
package/dist/cjs/client.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { type Logger } from "ts-log";
|
|
2
2
|
import { type ParsedPayload, type Request, type Response } from "./protocol.js";
|
|
3
3
|
export type BinaryResponse = {
|
|
4
4
|
subscriptionId: number;
|
|
@@ -14,8 +14,31 @@ export type JsonOrBinaryResponse = {
|
|
|
14
14
|
value: BinaryResponse;
|
|
15
15
|
};
|
|
16
16
|
export declare class PythLazerClient {
|
|
17
|
-
|
|
18
|
-
constructor(
|
|
17
|
+
private readonly wsp;
|
|
18
|
+
private constructor();
|
|
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. The connections will round-robin across the provided URLs.
|
|
24
|
+
* @param logger - Optional logger to get socket level logs. Compatible with most loggers such as the built-in console and `bunyan`.
|
|
25
|
+
*/
|
|
26
|
+
static create(urls: string[], token: string, numConnections?: number, logger?: Logger): Promise<PythLazerClient>;
|
|
27
|
+
/**
|
|
28
|
+
* Adds a message listener that receives either JSON or binary responses from the WebSocket connections.
|
|
29
|
+
* The listener will be called for each message received, with deduplication across redundant connections.
|
|
30
|
+
* @param handler - Callback function that receives the parsed message. The message can be either a JSON response
|
|
31
|
+
* or a binary response containing EVM, Solana, or parsed payload data.
|
|
32
|
+
*/
|
|
19
33
|
addMessageListener(handler: (event: JsonOrBinaryResponse) => void): void;
|
|
20
|
-
|
|
34
|
+
subscribe(request: Request): Promise<void>;
|
|
35
|
+
unsubscribe(subscriptionId: number): Promise<void>;
|
|
36
|
+
send(request: Request): Promise<void>;
|
|
37
|
+
/**
|
|
38
|
+
* Registers a handler function that will be called whenever all WebSocket connections are down or attempting to reconnect.
|
|
39
|
+
* The connections may still try to reconnect in the background. To shut down the pool, call `shutdown()`.
|
|
40
|
+
* @param handler - Function to be called when all connections are down
|
|
41
|
+
*/
|
|
42
|
+
addAllConnectionsDownListener(handler: () => void): void;
|
|
43
|
+
shutdown(): void;
|
|
21
44
|
}
|
package/dist/cjs/client.js
CHANGED
|
@@ -1,58 +1,67 @@
|
|
|
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 websocket_pool_js_1 = require("./socket/websocket-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
|
-
constructor(
|
|
15
|
-
|
|
16
|
-
finalUrl.searchParams.append("ACCESS_TOKEN", token);
|
|
17
|
-
this.ws = new isomorphic_ws_1.default(finalUrl);
|
|
11
|
+
wsp;
|
|
12
|
+
constructor(wsp) {
|
|
13
|
+
this.wsp = wsp;
|
|
18
14
|
}
|
|
15
|
+
/**
|
|
16
|
+
* Creates a new PythLazerClient instance.
|
|
17
|
+
* @param urls - List of WebSocket URLs of the Pyth Lazer service
|
|
18
|
+
* @param token - The access token for authentication
|
|
19
|
+
* @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
|
+
* @param logger - Optional logger to get socket level logs. Compatible with most loggers such as the built-in console and `bunyan`.
|
|
21
|
+
*/
|
|
22
|
+
static async create(urls, token, numConnections = 3, logger = ts_log_1.dummyLogger) {
|
|
23
|
+
const wsp = await websocket_pool_js_1.WebSocketPool.create(urls, token, numConnections, logger);
|
|
24
|
+
return new PythLazerClient(wsp);
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Adds a message listener that receives either JSON or binary responses from the WebSocket connections.
|
|
28
|
+
* The listener will be called for each message received, with deduplication across redundant connections.
|
|
29
|
+
* @param handler - Callback function that receives the parsed message. The message can be either a JSON response
|
|
30
|
+
* or a binary response containing EVM, Solana, or parsed payload data.
|
|
31
|
+
*/
|
|
19
32
|
addMessageListener(handler) {
|
|
20
|
-
this.
|
|
21
|
-
if (typeof
|
|
33
|
+
this.wsp.addMessageListener((data) => {
|
|
34
|
+
if (typeof data == "string") {
|
|
22
35
|
handler({
|
|
23
36
|
type: "json",
|
|
24
|
-
value: JSON.parse(
|
|
37
|
+
value: JSON.parse(data),
|
|
25
38
|
});
|
|
26
39
|
}
|
|
27
|
-
else if (Buffer.isBuffer(
|
|
40
|
+
else if (Buffer.isBuffer(data)) {
|
|
28
41
|
let pos = 0;
|
|
29
|
-
const magic =
|
|
30
|
-
.subarray(pos, pos + UINT32_NUM_BYTES)
|
|
31
|
-
.readUint32BE();
|
|
42
|
+
const magic = data.subarray(pos, pos + UINT32_NUM_BYTES).readUint32BE();
|
|
32
43
|
pos += UINT32_NUM_BYTES;
|
|
33
44
|
if (magic != protocol_js_1.BINARY_UPDATE_FORMAT_MAGIC) {
|
|
34
45
|
throw new Error("binary update format magic mismatch");
|
|
35
46
|
}
|
|
36
47
|
// TODO: some uint64 values may not be representable as Number.
|
|
37
|
-
const subscriptionId = Number(
|
|
48
|
+
const subscriptionId = Number(data.subarray(pos, pos + UINT64_NUM_BYTES).readBigInt64BE());
|
|
38
49
|
pos += UINT64_NUM_BYTES;
|
|
39
50
|
const value = { subscriptionId };
|
|
40
|
-
while (pos <
|
|
41
|
-
const len =
|
|
42
|
-
.subarray(pos, pos + UINT16_NUM_BYTES)
|
|
43
|
-
.readUint16BE();
|
|
51
|
+
while (pos < data.length) {
|
|
52
|
+
const len = data.subarray(pos, pos + UINT16_NUM_BYTES).readUint16BE();
|
|
44
53
|
pos += UINT16_NUM_BYTES;
|
|
45
|
-
const magic =
|
|
54
|
+
const magic = data
|
|
46
55
|
.subarray(pos, pos + UINT32_NUM_BYTES)
|
|
47
56
|
.readUint32BE();
|
|
48
57
|
if (magic == protocol_js_1.EVM_FORMAT_MAGIC) {
|
|
49
|
-
value.evm =
|
|
58
|
+
value.evm = data.subarray(pos, pos + len);
|
|
50
59
|
}
|
|
51
60
|
else if (magic == protocol_js_1.SOLANA_FORMAT_MAGIC_BE) {
|
|
52
|
-
value.solana =
|
|
61
|
+
value.solana = data.subarray(pos, pos + len);
|
|
53
62
|
}
|
|
54
63
|
else if (magic == protocol_js_1.PARSED_FORMAT_MAGIC) {
|
|
55
|
-
value.parsed = JSON.parse(
|
|
64
|
+
value.parsed = JSON.parse(data.subarray(pos + UINT32_NUM_BYTES, pos + len).toString());
|
|
56
65
|
}
|
|
57
66
|
else {
|
|
58
67
|
throw new Error("unknown magic: " + magic.toString());
|
|
@@ -66,8 +75,28 @@ class PythLazerClient {
|
|
|
66
75
|
}
|
|
67
76
|
});
|
|
68
77
|
}
|
|
69
|
-
|
|
70
|
-
|
|
78
|
+
async subscribe(request) {
|
|
79
|
+
if (request.type !== "subscribe") {
|
|
80
|
+
throw new Error("Request must be a subscribe request");
|
|
81
|
+
}
|
|
82
|
+
await this.wsp.addSubscription(request);
|
|
83
|
+
}
|
|
84
|
+
async unsubscribe(subscriptionId) {
|
|
85
|
+
await this.wsp.removeSubscription(subscriptionId);
|
|
86
|
+
}
|
|
87
|
+
async send(request) {
|
|
88
|
+
await this.wsp.sendRequest(request);
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Registers a handler function that will be called whenever all WebSocket connections are down or attempting to reconnect.
|
|
92
|
+
* The connections may still try to reconnect in the background. To shut down the pool, call `shutdown()`.
|
|
93
|
+
* @param handler - Function to be called when all connections are down
|
|
94
|
+
*/
|
|
95
|
+
addAllConnectionsDownListener(handler) {
|
|
96
|
+
this.wsp.addAllConnectionsDownListener(handler);
|
|
97
|
+
}
|
|
98
|
+
shutdown() {
|
|
99
|
+
this.wsp.shutdown();
|
|
71
100
|
}
|
|
72
101
|
}
|
|
73
102
|
exports.PythLazerClient = PythLazerClient;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.SOLANA_STORAGE_ID = exports.SOLANA_LAZER_PROGRAM_ID = void 0;
|
|
4
|
+
const web3_js_1 = require("@solana/web3.js");
|
|
5
|
+
exports.SOLANA_LAZER_PROGRAM_ID = new web3_js_1.PublicKey("pytd2yyk641x7ak7mkaasSJVXh6YYZnC7wTmtgAyxPt");
|
|
6
|
+
exports.SOLANA_STORAGE_ID = new web3_js_1.PublicKey("3rdJbqfnagQ4yx9HXJViD4zc4xpiSqmFsKpPuSCQVyQL");
|
package/dist/cjs/index.d.ts
CHANGED
package/dist/cjs/index.js
CHANGED
|
@@ -0,0 +1,29 @@
|
|
|
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
|
+
export declare class ResilientWebSocket {
|
|
5
|
+
endpoint: string;
|
|
6
|
+
wsClient: undefined | WebSocket;
|
|
7
|
+
wsUserClosed: boolean;
|
|
8
|
+
private wsOptions;
|
|
9
|
+
private wsFailedAttempts;
|
|
10
|
+
private heartbeatTimeout;
|
|
11
|
+
private logger;
|
|
12
|
+
private connectionPromise;
|
|
13
|
+
private resolveConnection;
|
|
14
|
+
private rejectConnection;
|
|
15
|
+
private _isReconnecting;
|
|
16
|
+
get isReconnecting(): boolean;
|
|
17
|
+
get isConnected(): boolean;
|
|
18
|
+
onError: (error: ErrorEvent) => void;
|
|
19
|
+
onMessage: (data: WebSocket.Data) => void;
|
|
20
|
+
onReconnect: () => void;
|
|
21
|
+
constructor(endpoint: string, wsOptions?: ClientOptions | ClientRequestArgs, logger?: Logger);
|
|
22
|
+
send(data: string | Buffer): Promise<void>;
|
|
23
|
+
startWebSocket(): Promise<void>;
|
|
24
|
+
private resetHeartbeat;
|
|
25
|
+
private waitForMaybeReadyWebSocket;
|
|
26
|
+
private handleClose;
|
|
27
|
+
private restartUnexpectedClosedWebsocket;
|
|
28
|
+
closeWebSocket(): void;
|
|
29
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
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
|
+
const HEARTBEAT_TIMEOUT_DURATION = 10_000;
|
|
9
|
+
const CONNECTION_TIMEOUT = 5000;
|
|
10
|
+
class ResilientWebSocket {
|
|
11
|
+
endpoint;
|
|
12
|
+
wsClient;
|
|
13
|
+
wsUserClosed;
|
|
14
|
+
wsOptions;
|
|
15
|
+
wsFailedAttempts;
|
|
16
|
+
heartbeatTimeout;
|
|
17
|
+
logger;
|
|
18
|
+
connectionPromise;
|
|
19
|
+
resolveConnection;
|
|
20
|
+
rejectConnection;
|
|
21
|
+
_isReconnecting = false;
|
|
22
|
+
get isReconnecting() {
|
|
23
|
+
return this._isReconnecting;
|
|
24
|
+
}
|
|
25
|
+
get isConnected() {
|
|
26
|
+
return this.wsClient?.readyState === isomorphic_ws_1.default.OPEN;
|
|
27
|
+
}
|
|
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
|
+
async startWebSocket() {
|
|
58
|
+
if (this.wsClient !== undefined) {
|
|
59
|
+
// If there's an existing connection attempt, wait for it
|
|
60
|
+
if (this.connectionPromise) {
|
|
61
|
+
return this.connectionPromise;
|
|
62
|
+
}
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
this.logger?.info(`Creating Web Socket client`);
|
|
66
|
+
// Create a new promise for this connection attempt
|
|
67
|
+
this.connectionPromise = new Promise((resolve, reject) => {
|
|
68
|
+
this.resolveConnection = resolve;
|
|
69
|
+
this.rejectConnection = reject;
|
|
70
|
+
});
|
|
71
|
+
// Set a connection timeout
|
|
72
|
+
const timeoutId = setTimeout(() => {
|
|
73
|
+
if (this.rejectConnection) {
|
|
74
|
+
this.rejectConnection(new Error(`Connection timeout after ${String(CONNECTION_TIMEOUT)}ms`));
|
|
75
|
+
}
|
|
76
|
+
}, CONNECTION_TIMEOUT);
|
|
77
|
+
this.wsClient = new isomorphic_ws_1.default(this.endpoint, this.wsOptions);
|
|
78
|
+
this.wsUserClosed = false;
|
|
79
|
+
this.wsClient.addEventListener("open", () => {
|
|
80
|
+
this.wsFailedAttempts = 0;
|
|
81
|
+
this.resetHeartbeat();
|
|
82
|
+
clearTimeout(timeoutId);
|
|
83
|
+
this._isReconnecting = false;
|
|
84
|
+
this.resolveConnection?.();
|
|
85
|
+
});
|
|
86
|
+
this.wsClient.addEventListener("error", (event) => {
|
|
87
|
+
this.onError(event);
|
|
88
|
+
if (this.rejectConnection) {
|
|
89
|
+
this.rejectConnection(new Error("WebSocket connection failed"));
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
this.wsClient.addEventListener("message", (event) => {
|
|
93
|
+
this.resetHeartbeat();
|
|
94
|
+
this.onMessage(event.data);
|
|
95
|
+
});
|
|
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
|
+
if ("on" in this.wsClient) {
|
|
104
|
+
this.wsClient.on("ping", () => {
|
|
105
|
+
this.logger?.info("Ping received");
|
|
106
|
+
this.resetHeartbeat();
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
return this.connectionPromise;
|
|
110
|
+
}
|
|
111
|
+
resetHeartbeat() {
|
|
112
|
+
if (this.heartbeatTimeout !== undefined) {
|
|
113
|
+
clearTimeout(this.heartbeatTimeout);
|
|
114
|
+
}
|
|
115
|
+
this.heartbeatTimeout = setTimeout(() => {
|
|
116
|
+
this.logger?.warn("Connection timed out. Reconnecting...");
|
|
117
|
+
this.wsClient?.terminate();
|
|
118
|
+
void this.restartUnexpectedClosedWebsocket();
|
|
119
|
+
}, HEARTBEAT_TIMEOUT_DURATION);
|
|
120
|
+
}
|
|
121
|
+
async waitForMaybeReadyWebSocket() {
|
|
122
|
+
let waitedTime = 0;
|
|
123
|
+
while (this.wsClient !== undefined &&
|
|
124
|
+
this.wsClient.readyState !== this.wsClient.OPEN) {
|
|
125
|
+
if (waitedTime > 5000) {
|
|
126
|
+
this.wsClient.close();
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
else {
|
|
130
|
+
waitedTime += 10;
|
|
131
|
+
await sleep(10);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
async handleClose() {
|
|
136
|
+
if (this.heartbeatTimeout !== undefined) {
|
|
137
|
+
clearTimeout(this.heartbeatTimeout);
|
|
138
|
+
}
|
|
139
|
+
if (this.wsUserClosed) {
|
|
140
|
+
this.logger?.info("The connection has been closed successfully.");
|
|
141
|
+
}
|
|
142
|
+
else {
|
|
143
|
+
this.wsFailedAttempts += 1;
|
|
144
|
+
this.wsClient = undefined;
|
|
145
|
+
this.connectionPromise = undefined;
|
|
146
|
+
this.resolveConnection = undefined;
|
|
147
|
+
this.rejectConnection = undefined;
|
|
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) +
|
|
152
|
+
"ms.");
|
|
153
|
+
await sleep(waitTime);
|
|
154
|
+
await this.restartUnexpectedClosedWebsocket();
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
async restartUnexpectedClosedWebsocket() {
|
|
158
|
+
if (this.wsUserClosed) {
|
|
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();
|
|
168
|
+
}
|
|
169
|
+
closeWebSocket() {
|
|
170
|
+
if (this.wsClient !== undefined) {
|
|
171
|
+
const client = this.wsClient;
|
|
172
|
+
this.wsClient = undefined;
|
|
173
|
+
this.connectionPromise = undefined;
|
|
174
|
+
this.resolveConnection = undefined;
|
|
175
|
+
this.rejectConnection = undefined;
|
|
176
|
+
client.close();
|
|
177
|
+
}
|
|
178
|
+
this.wsUserClosed = true;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
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
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import WebSocket from "isomorphic-ws";
|
|
2
|
+
import { type Logger } from "ts-log";
|
|
3
|
+
import { ResilientWebSocket } from "./resilient-websocket.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
|
+
private allConnectionsDownListeners;
|
|
12
|
+
private wasAllDown;
|
|
13
|
+
private constructor();
|
|
14
|
+
/**
|
|
15
|
+
* Creates a new WebSocketPool instance that uses multiple redundant WebSocket connections for reliability.
|
|
16
|
+
* Usage semantics are similar to using a regular WebSocket client.
|
|
17
|
+
* @param urls - List of WebSocket URLs to connect to
|
|
18
|
+
* @param token - Authentication token to use for the connections
|
|
19
|
+
* @param numConnections - Number of parallel WebSocket connections to maintain (default: 3)
|
|
20
|
+
* @param logger - Optional logger to get socket level logs. Compatible with most loggers such as the built-in console and `bunyan`.
|
|
21
|
+
*/
|
|
22
|
+
static create(urls: string[], token: string, numConnections?: number, logger?: Logger): Promise<WebSocketPool>;
|
|
23
|
+
/**
|
|
24
|
+
* Checks for error responses in JSON messages and throws appropriate errors
|
|
25
|
+
*/
|
|
26
|
+
private handleErrorMessages;
|
|
27
|
+
/**
|
|
28
|
+
* Handles incoming websocket messages by deduplicating identical messages received across
|
|
29
|
+
* multiple connections before forwarding to registered handlers
|
|
30
|
+
*/
|
|
31
|
+
dedupeHandler: (data: WebSocket.Data) => void;
|
|
32
|
+
sendRequest(request: Request): Promise<void>;
|
|
33
|
+
addSubscription(request: Request): Promise<void>;
|
|
34
|
+
removeSubscription(subscriptionId: number): Promise<void>;
|
|
35
|
+
addMessageListener(handler: (data: WebSocket.Data) => void): void;
|
|
36
|
+
/**
|
|
37
|
+
* Calls the handler if all websocket connections are currently down or in reconnecting state.
|
|
38
|
+
* The connections may still try to reconnect in the background.
|
|
39
|
+
*/
|
|
40
|
+
addAllConnectionsDownListener(handler: () => void): void;
|
|
41
|
+
private areAllConnectionsDown;
|
|
42
|
+
private checkConnectionStates;
|
|
43
|
+
shutdown(): void;
|
|
44
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
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_websocket_js_1 = require("./resilient-websocket.js");
|
|
10
|
+
const DEFAULT_NUM_CONNECTIONS = 3;
|
|
11
|
+
class WebSocketPool {
|
|
12
|
+
logger;
|
|
13
|
+
rwsPool;
|
|
14
|
+
cache;
|
|
15
|
+
subscriptions; // id -> subscription Request
|
|
16
|
+
messageListeners;
|
|
17
|
+
allConnectionsDownListeners;
|
|
18
|
+
wasAllDown = true;
|
|
19
|
+
constructor(logger = ts_log_1.dummyLogger) {
|
|
20
|
+
this.logger = logger;
|
|
21
|
+
this.rwsPool = [];
|
|
22
|
+
this.cache = new ttlcache_1.default({ ttl: 1000 * 10 }); // TTL of 10 seconds
|
|
23
|
+
this.subscriptions = new Map();
|
|
24
|
+
this.messageListeners = [];
|
|
25
|
+
this.allConnectionsDownListeners = [];
|
|
26
|
+
// Start monitoring connection states
|
|
27
|
+
setInterval(() => {
|
|
28
|
+
this.checkConnectionStates();
|
|
29
|
+
}, 100);
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Creates a new WebSocketPool instance that uses multiple redundant WebSocket connections for reliability.
|
|
33
|
+
* Usage semantics are similar to using a regular WebSocket client.
|
|
34
|
+
* @param urls - List of WebSocket URLs to connect to
|
|
35
|
+
* @param token - Authentication token to use for the connections
|
|
36
|
+
* @param numConnections - Number of parallel WebSocket connections to maintain (default: 3)
|
|
37
|
+
* @param logger - Optional logger to get socket level logs. Compatible with most loggers such as the built-in console and `bunyan`.
|
|
38
|
+
*/
|
|
39
|
+
static async create(urls, token, numConnections = DEFAULT_NUM_CONNECTIONS, logger = ts_log_1.dummyLogger) {
|
|
40
|
+
if (urls.length === 0) {
|
|
41
|
+
throw new Error("No URLs provided");
|
|
42
|
+
}
|
|
43
|
+
const pool = new WebSocketPool(logger);
|
|
44
|
+
// Create all websocket instances
|
|
45
|
+
const connectionPromises = [];
|
|
46
|
+
for (let i = 0; i < numConnections; i++) {
|
|
47
|
+
const url = urls[i % urls.length];
|
|
48
|
+
if (!url) {
|
|
49
|
+
throw new Error(`URLs must not be null or empty`);
|
|
50
|
+
}
|
|
51
|
+
const wsOptions = {
|
|
52
|
+
headers: {
|
|
53
|
+
Authorization: `Bearer ${token}`,
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
const rws = new resilient_websocket_js_1.ResilientWebSocket(url, wsOptions, logger);
|
|
57
|
+
// If a websocket client unexpectedly disconnects, ResilientWebSocket will reestablish
|
|
58
|
+
// the connection and call the onReconnect callback.
|
|
59
|
+
rws.onReconnect = () => {
|
|
60
|
+
if (rws.wsUserClosed) {
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
for (const [, request] of pool.subscriptions) {
|
|
64
|
+
try {
|
|
65
|
+
void rws.send(JSON.stringify(request));
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
pool.logger.error("Failed to resend subscription on reconnect:", error);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
// Handle all client messages ourselves. Dedupe before sending to registered message handlers.
|
|
73
|
+
rws.onMessage = pool.dedupeHandler;
|
|
74
|
+
pool.rwsPool.push(rws);
|
|
75
|
+
// Start the websocket and collect the promise
|
|
76
|
+
connectionPromises.push(rws.startWebSocket());
|
|
77
|
+
}
|
|
78
|
+
// Wait for all connections to be established
|
|
79
|
+
try {
|
|
80
|
+
await Promise.all(connectionPromises);
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
// If any connection fails, clean up and throw
|
|
84
|
+
pool.shutdown();
|
|
85
|
+
throw error;
|
|
86
|
+
}
|
|
87
|
+
pool.logger.info(`Successfully established ${numConnections.toString()} redundant WebSocket connections`);
|
|
88
|
+
return pool;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Checks for error responses in JSON messages and throws appropriate errors
|
|
92
|
+
*/
|
|
93
|
+
handleErrorMessages(data) {
|
|
94
|
+
const message = JSON.parse(data);
|
|
95
|
+
if (message.type === "subscriptionError") {
|
|
96
|
+
throw new Error(`Error occurred for subscription ID ${String(message.subscriptionId)}: ${message.error}`);
|
|
97
|
+
}
|
|
98
|
+
else if (message.type === "error") {
|
|
99
|
+
throw new Error(`Error: ${message.error}`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Handles incoming websocket messages by deduplicating identical messages received across
|
|
104
|
+
* multiple connections before forwarding to registered handlers
|
|
105
|
+
*/
|
|
106
|
+
dedupeHandler = (data) => {
|
|
107
|
+
const cacheKey = typeof data === "string"
|
|
108
|
+
? data
|
|
109
|
+
: Buffer.from(data).toString("hex");
|
|
110
|
+
if (this.cache.has(cacheKey)) {
|
|
111
|
+
this.logger.debug("Dropping duplicate message");
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
this.cache.set(cacheKey, true);
|
|
115
|
+
if (typeof data === "string") {
|
|
116
|
+
this.handleErrorMessages(data);
|
|
117
|
+
}
|
|
118
|
+
for (const handler of this.messageListeners) {
|
|
119
|
+
handler(data);
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
async sendRequest(request) {
|
|
123
|
+
const sendPromises = this.rwsPool.map(async (rws) => {
|
|
124
|
+
try {
|
|
125
|
+
await rws.send(JSON.stringify(request));
|
|
126
|
+
}
|
|
127
|
+
catch (error) {
|
|
128
|
+
this.logger.error("Failed to send request:", error);
|
|
129
|
+
throw error;
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
await Promise.all(sendPromises);
|
|
133
|
+
}
|
|
134
|
+
async addSubscription(request) {
|
|
135
|
+
if (request.type !== "subscribe") {
|
|
136
|
+
throw new Error("Request must be a subscribe request");
|
|
137
|
+
}
|
|
138
|
+
this.subscriptions.set(request.subscriptionId, request);
|
|
139
|
+
await this.sendRequest(request);
|
|
140
|
+
}
|
|
141
|
+
async removeSubscription(subscriptionId) {
|
|
142
|
+
this.subscriptions.delete(subscriptionId);
|
|
143
|
+
const request = {
|
|
144
|
+
type: "unsubscribe",
|
|
145
|
+
subscriptionId,
|
|
146
|
+
};
|
|
147
|
+
await this.sendRequest(request);
|
|
148
|
+
}
|
|
149
|
+
addMessageListener(handler) {
|
|
150
|
+
this.messageListeners.push(handler);
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Calls the handler if all websocket connections are currently down or in reconnecting state.
|
|
154
|
+
* The connections may still try to reconnect in the background.
|
|
155
|
+
*/
|
|
156
|
+
addAllConnectionsDownListener(handler) {
|
|
157
|
+
this.allConnectionsDownListeners.push(handler);
|
|
158
|
+
}
|
|
159
|
+
areAllConnectionsDown() {
|
|
160
|
+
return this.rwsPool.every((ws) => !ws.isConnected || ws.isReconnecting);
|
|
161
|
+
}
|
|
162
|
+
checkConnectionStates() {
|
|
163
|
+
const allDown = this.areAllConnectionsDown();
|
|
164
|
+
// If all connections just went down
|
|
165
|
+
if (allDown && !this.wasAllDown) {
|
|
166
|
+
this.wasAllDown = true;
|
|
167
|
+
this.logger.error("All WebSocket connections are down or reconnecting");
|
|
168
|
+
// Notify all listeners
|
|
169
|
+
for (const listener of this.allConnectionsDownListeners) {
|
|
170
|
+
listener();
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
// If at least one connection was restored
|
|
174
|
+
if (!allDown && this.wasAllDown) {
|
|
175
|
+
this.wasAllDown = false;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
shutdown() {
|
|
179
|
+
for (const rws of this.rwsPool) {
|
|
180
|
+
rws.closeWebSocket();
|
|
181
|
+
}
|
|
182
|
+
this.rwsPool = [];
|
|
183
|
+
this.subscriptions.clear();
|
|
184
|
+
this.messageListeners = [];
|
|
185
|
+
this.allConnectionsDownListeners = [];
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
exports.WebSocketPool = WebSocketPool;
|
package/dist/esm/client.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { type Logger } from "ts-log";
|
|
2
2
|
import { type ParsedPayload, type Request, type Response } from "./protocol.js";
|
|
3
3
|
export type BinaryResponse = {
|
|
4
4
|
subscriptionId: number;
|
|
@@ -14,8 +14,31 @@ export type JsonOrBinaryResponse = {
|
|
|
14
14
|
value: BinaryResponse;
|
|
15
15
|
};
|
|
16
16
|
export declare class PythLazerClient {
|
|
17
|
-
|
|
18
|
-
constructor(
|
|
17
|
+
private readonly wsp;
|
|
18
|
+
private constructor();
|
|
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. The connections will round-robin across the provided URLs.
|
|
24
|
+
* @param logger - Optional logger to get socket level logs. Compatible with most loggers such as the built-in console and `bunyan`.
|
|
25
|
+
*/
|
|
26
|
+
static create(urls: string[], token: string, numConnections?: number, logger?: Logger): Promise<PythLazerClient>;
|
|
27
|
+
/**
|
|
28
|
+
* Adds a message listener that receives either JSON or binary responses from the WebSocket connections.
|
|
29
|
+
* The listener will be called for each message received, with deduplication across redundant connections.
|
|
30
|
+
* @param handler - Callback function that receives the parsed message. The message can be either a JSON response
|
|
31
|
+
* or a binary response containing EVM, Solana, or parsed payload data.
|
|
32
|
+
*/
|
|
19
33
|
addMessageListener(handler: (event: JsonOrBinaryResponse) => void): void;
|
|
20
|
-
|
|
34
|
+
subscribe(request: Request): Promise<void>;
|
|
35
|
+
unsubscribe(subscriptionId: number): Promise<void>;
|
|
36
|
+
send(request: Request): Promise<void>;
|
|
37
|
+
/**
|
|
38
|
+
* Registers a handler function that will be called whenever all WebSocket connections are down or attempting to reconnect.
|
|
39
|
+
* The connections may still try to reconnect in the background. To shut down the pool, call `shutdown()`.
|
|
40
|
+
* @param handler - Function to be called when all connections are down
|
|
41
|
+
*/
|
|
42
|
+
addAllConnectionsDownListener(handler: () => void): void;
|
|
43
|
+
shutdown(): void;
|
|
21
44
|
}
|
package/dist/esm/client.js
CHANGED
|
@@ -1,52 +1,65 @@
|
|
|
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/websocket-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
|
-
constructor(
|
|
9
|
-
|
|
10
|
-
finalUrl.searchParams.append("ACCESS_TOKEN", token);
|
|
11
|
-
this.ws = new WebSocket(finalUrl);
|
|
9
|
+
wsp;
|
|
10
|
+
constructor(wsp) {
|
|
11
|
+
this.wsp = wsp;
|
|
12
12
|
}
|
|
13
|
+
/**
|
|
14
|
+
* Creates a new PythLazerClient instance.
|
|
15
|
+
* @param urls - List of WebSocket URLs of the Pyth Lazer service
|
|
16
|
+
* @param token - The access token for authentication
|
|
17
|
+
* @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
|
+
* @param logger - Optional logger to get socket level logs. Compatible with most loggers such as the built-in console and `bunyan`.
|
|
19
|
+
*/
|
|
20
|
+
static async create(urls, token, numConnections = 3, logger = dummyLogger) {
|
|
21
|
+
const wsp = await WebSocketPool.create(urls, token, numConnections, logger);
|
|
22
|
+
return new PythLazerClient(wsp);
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Adds a message listener that receives either JSON or binary responses from the WebSocket connections.
|
|
26
|
+
* The listener will be called for each message received, with deduplication across redundant connections.
|
|
27
|
+
* @param handler - Callback function that receives the parsed message. The message can be either a JSON response
|
|
28
|
+
* or a binary response containing EVM, Solana, or parsed payload data.
|
|
29
|
+
*/
|
|
13
30
|
addMessageListener(handler) {
|
|
14
|
-
this.
|
|
15
|
-
if (typeof
|
|
31
|
+
this.wsp.addMessageListener((data) => {
|
|
32
|
+
if (typeof data == "string") {
|
|
16
33
|
handler({
|
|
17
34
|
type: "json",
|
|
18
|
-
value: JSON.parse(
|
|
35
|
+
value: JSON.parse(data),
|
|
19
36
|
});
|
|
20
37
|
}
|
|
21
|
-
else if (Buffer.isBuffer(
|
|
38
|
+
else if (Buffer.isBuffer(data)) {
|
|
22
39
|
let pos = 0;
|
|
23
|
-
const magic =
|
|
24
|
-
.subarray(pos, pos + UINT32_NUM_BYTES)
|
|
25
|
-
.readUint32BE();
|
|
40
|
+
const magic = data.subarray(pos, pos + UINT32_NUM_BYTES).readUint32BE();
|
|
26
41
|
pos += UINT32_NUM_BYTES;
|
|
27
42
|
if (magic != BINARY_UPDATE_FORMAT_MAGIC) {
|
|
28
43
|
throw new Error("binary update format magic mismatch");
|
|
29
44
|
}
|
|
30
45
|
// TODO: some uint64 values may not be representable as Number.
|
|
31
|
-
const subscriptionId = Number(
|
|
46
|
+
const subscriptionId = Number(data.subarray(pos, pos + UINT64_NUM_BYTES).readBigInt64BE());
|
|
32
47
|
pos += UINT64_NUM_BYTES;
|
|
33
48
|
const value = { subscriptionId };
|
|
34
|
-
while (pos <
|
|
35
|
-
const len =
|
|
36
|
-
.subarray(pos, pos + UINT16_NUM_BYTES)
|
|
37
|
-
.readUint16BE();
|
|
49
|
+
while (pos < data.length) {
|
|
50
|
+
const len = data.subarray(pos, pos + UINT16_NUM_BYTES).readUint16BE();
|
|
38
51
|
pos += UINT16_NUM_BYTES;
|
|
39
|
-
const magic =
|
|
52
|
+
const magic = data
|
|
40
53
|
.subarray(pos, pos + UINT32_NUM_BYTES)
|
|
41
54
|
.readUint32BE();
|
|
42
55
|
if (magic == EVM_FORMAT_MAGIC) {
|
|
43
|
-
value.evm =
|
|
56
|
+
value.evm = data.subarray(pos, pos + len);
|
|
44
57
|
}
|
|
45
58
|
else if (magic == SOLANA_FORMAT_MAGIC_BE) {
|
|
46
|
-
value.solana =
|
|
59
|
+
value.solana = data.subarray(pos, pos + len);
|
|
47
60
|
}
|
|
48
61
|
else if (magic == PARSED_FORMAT_MAGIC) {
|
|
49
|
-
value.parsed = JSON.parse(
|
|
62
|
+
value.parsed = JSON.parse(data.subarray(pos + UINT32_NUM_BYTES, pos + len).toString());
|
|
50
63
|
}
|
|
51
64
|
else {
|
|
52
65
|
throw new Error("unknown magic: " + magic.toString());
|
|
@@ -60,7 +73,27 @@ export class PythLazerClient {
|
|
|
60
73
|
}
|
|
61
74
|
});
|
|
62
75
|
}
|
|
63
|
-
|
|
64
|
-
|
|
76
|
+
async subscribe(request) {
|
|
77
|
+
if (request.type !== "subscribe") {
|
|
78
|
+
throw new Error("Request must be a subscribe request");
|
|
79
|
+
}
|
|
80
|
+
await this.wsp.addSubscription(request);
|
|
81
|
+
}
|
|
82
|
+
async unsubscribe(subscriptionId) {
|
|
83
|
+
await this.wsp.removeSubscription(subscriptionId);
|
|
84
|
+
}
|
|
85
|
+
async send(request) {
|
|
86
|
+
await this.wsp.sendRequest(request);
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Registers a handler function that will be called whenever all WebSocket connections are down or attempting to reconnect.
|
|
90
|
+
* The connections may still try to reconnect in the background. To shut down the pool, call `shutdown()`.
|
|
91
|
+
* @param handler - Function to be called when all connections are down
|
|
92
|
+
*/
|
|
93
|
+
addAllConnectionsDownListener(handler) {
|
|
94
|
+
this.wsp.addAllConnectionsDownListener(handler);
|
|
95
|
+
}
|
|
96
|
+
shutdown() {
|
|
97
|
+
this.wsp.shutdown();
|
|
65
98
|
}
|
|
66
99
|
}
|
package/dist/esm/index.d.ts
CHANGED
package/dist/esm/index.js
CHANGED
|
@@ -0,0 +1,29 @@
|
|
|
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
|
+
export declare class ResilientWebSocket {
|
|
5
|
+
endpoint: string;
|
|
6
|
+
wsClient: undefined | WebSocket;
|
|
7
|
+
wsUserClosed: boolean;
|
|
8
|
+
private wsOptions;
|
|
9
|
+
private wsFailedAttempts;
|
|
10
|
+
private heartbeatTimeout;
|
|
11
|
+
private logger;
|
|
12
|
+
private connectionPromise;
|
|
13
|
+
private resolveConnection;
|
|
14
|
+
private rejectConnection;
|
|
15
|
+
private _isReconnecting;
|
|
16
|
+
get isReconnecting(): boolean;
|
|
17
|
+
get isConnected(): boolean;
|
|
18
|
+
onError: (error: ErrorEvent) => void;
|
|
19
|
+
onMessage: (data: WebSocket.Data) => void;
|
|
20
|
+
onReconnect: () => void;
|
|
21
|
+
constructor(endpoint: string, wsOptions?: ClientOptions | ClientRequestArgs, logger?: Logger);
|
|
22
|
+
send(data: string | Buffer): Promise<void>;
|
|
23
|
+
startWebSocket(): Promise<void>;
|
|
24
|
+
private resetHeartbeat;
|
|
25
|
+
private waitForMaybeReadyWebSocket;
|
|
26
|
+
private handleClose;
|
|
27
|
+
private restartUnexpectedClosedWebsocket;
|
|
28
|
+
closeWebSocket(): void;
|
|
29
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import WebSocket, {} from "isomorphic-ws";
|
|
2
|
+
const HEARTBEAT_TIMEOUT_DURATION = 10_000;
|
|
3
|
+
const CONNECTION_TIMEOUT = 5000;
|
|
4
|
+
export class ResilientWebSocket {
|
|
5
|
+
endpoint;
|
|
6
|
+
wsClient;
|
|
7
|
+
wsUserClosed;
|
|
8
|
+
wsOptions;
|
|
9
|
+
wsFailedAttempts;
|
|
10
|
+
heartbeatTimeout;
|
|
11
|
+
logger;
|
|
12
|
+
connectionPromise;
|
|
13
|
+
resolveConnection;
|
|
14
|
+
rejectConnection;
|
|
15
|
+
_isReconnecting = false;
|
|
16
|
+
get isReconnecting() {
|
|
17
|
+
return this._isReconnecting;
|
|
18
|
+
}
|
|
19
|
+
get isConnected() {
|
|
20
|
+
return this.wsClient?.readyState === WebSocket.OPEN;
|
|
21
|
+
}
|
|
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
|
+
async startWebSocket() {
|
|
52
|
+
if (this.wsClient !== undefined) {
|
|
53
|
+
// If there's an existing connection attempt, wait for it
|
|
54
|
+
if (this.connectionPromise) {
|
|
55
|
+
return this.connectionPromise;
|
|
56
|
+
}
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
this.logger?.info(`Creating Web Socket client`);
|
|
60
|
+
// Create a new promise for this connection attempt
|
|
61
|
+
this.connectionPromise = new Promise((resolve, reject) => {
|
|
62
|
+
this.resolveConnection = resolve;
|
|
63
|
+
this.rejectConnection = reject;
|
|
64
|
+
});
|
|
65
|
+
// Set a connection timeout
|
|
66
|
+
const timeoutId = setTimeout(() => {
|
|
67
|
+
if (this.rejectConnection) {
|
|
68
|
+
this.rejectConnection(new Error(`Connection timeout after ${String(CONNECTION_TIMEOUT)}ms`));
|
|
69
|
+
}
|
|
70
|
+
}, CONNECTION_TIMEOUT);
|
|
71
|
+
this.wsClient = new WebSocket(this.endpoint, this.wsOptions);
|
|
72
|
+
this.wsUserClosed = false;
|
|
73
|
+
this.wsClient.addEventListener("open", () => {
|
|
74
|
+
this.wsFailedAttempts = 0;
|
|
75
|
+
this.resetHeartbeat();
|
|
76
|
+
clearTimeout(timeoutId);
|
|
77
|
+
this._isReconnecting = false;
|
|
78
|
+
this.resolveConnection?.();
|
|
79
|
+
});
|
|
80
|
+
this.wsClient.addEventListener("error", (event) => {
|
|
81
|
+
this.onError(event);
|
|
82
|
+
if (this.rejectConnection) {
|
|
83
|
+
this.rejectConnection(new Error("WebSocket connection failed"));
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
this.wsClient.addEventListener("message", (event) => {
|
|
87
|
+
this.resetHeartbeat();
|
|
88
|
+
this.onMessage(event.data);
|
|
89
|
+
});
|
|
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
|
+
if ("on" in this.wsClient) {
|
|
98
|
+
this.wsClient.on("ping", () => {
|
|
99
|
+
this.logger?.info("Ping received");
|
|
100
|
+
this.resetHeartbeat();
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
return this.connectionPromise;
|
|
104
|
+
}
|
|
105
|
+
resetHeartbeat() {
|
|
106
|
+
if (this.heartbeatTimeout !== undefined) {
|
|
107
|
+
clearTimeout(this.heartbeatTimeout);
|
|
108
|
+
}
|
|
109
|
+
this.heartbeatTimeout = setTimeout(() => {
|
|
110
|
+
this.logger?.warn("Connection timed out. Reconnecting...");
|
|
111
|
+
this.wsClient?.terminate();
|
|
112
|
+
void this.restartUnexpectedClosedWebsocket();
|
|
113
|
+
}, HEARTBEAT_TIMEOUT_DURATION);
|
|
114
|
+
}
|
|
115
|
+
async waitForMaybeReadyWebSocket() {
|
|
116
|
+
let waitedTime = 0;
|
|
117
|
+
while (this.wsClient !== undefined &&
|
|
118
|
+
this.wsClient.readyState !== this.wsClient.OPEN) {
|
|
119
|
+
if (waitedTime > 5000) {
|
|
120
|
+
this.wsClient.close();
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
else {
|
|
124
|
+
waitedTime += 10;
|
|
125
|
+
await sleep(10);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
async handleClose() {
|
|
130
|
+
if (this.heartbeatTimeout !== undefined) {
|
|
131
|
+
clearTimeout(this.heartbeatTimeout);
|
|
132
|
+
}
|
|
133
|
+
if (this.wsUserClosed) {
|
|
134
|
+
this.logger?.info("The connection has been closed successfully.");
|
|
135
|
+
}
|
|
136
|
+
else {
|
|
137
|
+
this.wsFailedAttempts += 1;
|
|
138
|
+
this.wsClient = undefined;
|
|
139
|
+
this.connectionPromise = undefined;
|
|
140
|
+
this.resolveConnection = undefined;
|
|
141
|
+
this.rejectConnection = undefined;
|
|
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) +
|
|
146
|
+
"ms.");
|
|
147
|
+
await sleep(waitTime);
|
|
148
|
+
await this.restartUnexpectedClosedWebsocket();
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
async restartUnexpectedClosedWebsocket() {
|
|
152
|
+
if (this.wsUserClosed) {
|
|
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();
|
|
162
|
+
}
|
|
163
|
+
closeWebSocket() {
|
|
164
|
+
if (this.wsClient !== undefined) {
|
|
165
|
+
const client = this.wsClient;
|
|
166
|
+
this.wsClient = undefined;
|
|
167
|
+
this.connectionPromise = undefined;
|
|
168
|
+
this.resolveConnection = undefined;
|
|
169
|
+
this.rejectConnection = undefined;
|
|
170
|
+
client.close();
|
|
171
|
+
}
|
|
172
|
+
this.wsUserClosed = true;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
async function sleep(ms) {
|
|
176
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
177
|
+
}
|
|
178
|
+
function expoBackoff(attempts) {
|
|
179
|
+
return 2 ** attempts * 100;
|
|
180
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import WebSocket from "isomorphic-ws";
|
|
2
|
+
import { type Logger } from "ts-log";
|
|
3
|
+
import { ResilientWebSocket } from "./resilient-websocket.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
|
+
private allConnectionsDownListeners;
|
|
12
|
+
private wasAllDown;
|
|
13
|
+
private constructor();
|
|
14
|
+
/**
|
|
15
|
+
* Creates a new WebSocketPool instance that uses multiple redundant WebSocket connections for reliability.
|
|
16
|
+
* Usage semantics are similar to using a regular WebSocket client.
|
|
17
|
+
* @param urls - List of WebSocket URLs to connect to
|
|
18
|
+
* @param token - Authentication token to use for the connections
|
|
19
|
+
* @param numConnections - Number of parallel WebSocket connections to maintain (default: 3)
|
|
20
|
+
* @param logger - Optional logger to get socket level logs. Compatible with most loggers such as the built-in console and `bunyan`.
|
|
21
|
+
*/
|
|
22
|
+
static create(urls: string[], token: string, numConnections?: number, logger?: Logger): Promise<WebSocketPool>;
|
|
23
|
+
/**
|
|
24
|
+
* Checks for error responses in JSON messages and throws appropriate errors
|
|
25
|
+
*/
|
|
26
|
+
private handleErrorMessages;
|
|
27
|
+
/**
|
|
28
|
+
* Handles incoming websocket messages by deduplicating identical messages received across
|
|
29
|
+
* multiple connections before forwarding to registered handlers
|
|
30
|
+
*/
|
|
31
|
+
dedupeHandler: (data: WebSocket.Data) => void;
|
|
32
|
+
sendRequest(request: Request): Promise<void>;
|
|
33
|
+
addSubscription(request: Request): Promise<void>;
|
|
34
|
+
removeSubscription(subscriptionId: number): Promise<void>;
|
|
35
|
+
addMessageListener(handler: (data: WebSocket.Data) => void): void;
|
|
36
|
+
/**
|
|
37
|
+
* Calls the handler if all websocket connections are currently down or in reconnecting state.
|
|
38
|
+
* The connections may still try to reconnect in the background.
|
|
39
|
+
*/
|
|
40
|
+
addAllConnectionsDownListener(handler: () => void): void;
|
|
41
|
+
private areAllConnectionsDown;
|
|
42
|
+
private checkConnectionStates;
|
|
43
|
+
shutdown(): void;
|
|
44
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import TTLCache from "@isaacs/ttlcache";
|
|
2
|
+
import WebSocket from "isomorphic-ws";
|
|
3
|
+
import { dummyLogger } from "ts-log";
|
|
4
|
+
import { ResilientWebSocket } from "./resilient-websocket.js";
|
|
5
|
+
const DEFAULT_NUM_CONNECTIONS = 3;
|
|
6
|
+
export class WebSocketPool {
|
|
7
|
+
logger;
|
|
8
|
+
rwsPool;
|
|
9
|
+
cache;
|
|
10
|
+
subscriptions; // id -> subscription Request
|
|
11
|
+
messageListeners;
|
|
12
|
+
allConnectionsDownListeners;
|
|
13
|
+
wasAllDown = true;
|
|
14
|
+
constructor(logger = dummyLogger) {
|
|
15
|
+
this.logger = logger;
|
|
16
|
+
this.rwsPool = [];
|
|
17
|
+
this.cache = new TTLCache({ ttl: 1000 * 10 }); // TTL of 10 seconds
|
|
18
|
+
this.subscriptions = new Map();
|
|
19
|
+
this.messageListeners = [];
|
|
20
|
+
this.allConnectionsDownListeners = [];
|
|
21
|
+
// Start monitoring connection states
|
|
22
|
+
setInterval(() => {
|
|
23
|
+
this.checkConnectionStates();
|
|
24
|
+
}, 100);
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Creates a new WebSocketPool instance that uses multiple redundant WebSocket connections for reliability.
|
|
28
|
+
* Usage semantics are similar to using a regular WebSocket client.
|
|
29
|
+
* @param urls - List of WebSocket URLs to connect to
|
|
30
|
+
* @param token - Authentication token to use for the connections
|
|
31
|
+
* @param numConnections - Number of parallel WebSocket connections to maintain (default: 3)
|
|
32
|
+
* @param logger - Optional logger to get socket level logs. Compatible with most loggers such as the built-in console and `bunyan`.
|
|
33
|
+
*/
|
|
34
|
+
static async create(urls, token, numConnections = DEFAULT_NUM_CONNECTIONS, logger = dummyLogger) {
|
|
35
|
+
if (urls.length === 0) {
|
|
36
|
+
throw new Error("No URLs provided");
|
|
37
|
+
}
|
|
38
|
+
const pool = new WebSocketPool(logger);
|
|
39
|
+
// Create all websocket instances
|
|
40
|
+
const connectionPromises = [];
|
|
41
|
+
for (let i = 0; i < numConnections; i++) {
|
|
42
|
+
const url = urls[i % urls.length];
|
|
43
|
+
if (!url) {
|
|
44
|
+
throw new Error(`URLs must not be null or empty`);
|
|
45
|
+
}
|
|
46
|
+
const wsOptions = {
|
|
47
|
+
headers: {
|
|
48
|
+
Authorization: `Bearer ${token}`,
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
const rws = new ResilientWebSocket(url, wsOptions, logger);
|
|
52
|
+
// If a websocket client unexpectedly disconnects, ResilientWebSocket will reestablish
|
|
53
|
+
// the connection and call the onReconnect callback.
|
|
54
|
+
rws.onReconnect = () => {
|
|
55
|
+
if (rws.wsUserClosed) {
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
for (const [, request] of pool.subscriptions) {
|
|
59
|
+
try {
|
|
60
|
+
void rws.send(JSON.stringify(request));
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
pool.logger.error("Failed to resend subscription on reconnect:", error);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
// Handle all client messages ourselves. Dedupe before sending to registered message handlers.
|
|
68
|
+
rws.onMessage = pool.dedupeHandler;
|
|
69
|
+
pool.rwsPool.push(rws);
|
|
70
|
+
// Start the websocket and collect the promise
|
|
71
|
+
connectionPromises.push(rws.startWebSocket());
|
|
72
|
+
}
|
|
73
|
+
// Wait for all connections to be established
|
|
74
|
+
try {
|
|
75
|
+
await Promise.all(connectionPromises);
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
// If any connection fails, clean up and throw
|
|
79
|
+
pool.shutdown();
|
|
80
|
+
throw error;
|
|
81
|
+
}
|
|
82
|
+
pool.logger.info(`Successfully established ${numConnections.toString()} redundant WebSocket connections`);
|
|
83
|
+
return pool;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Checks for error responses in JSON messages and throws appropriate errors
|
|
87
|
+
*/
|
|
88
|
+
handleErrorMessages(data) {
|
|
89
|
+
const message = JSON.parse(data);
|
|
90
|
+
if (message.type === "subscriptionError") {
|
|
91
|
+
throw new Error(`Error occurred for subscription ID ${String(message.subscriptionId)}: ${message.error}`);
|
|
92
|
+
}
|
|
93
|
+
else if (message.type === "error") {
|
|
94
|
+
throw new Error(`Error: ${message.error}`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Handles incoming websocket messages by deduplicating identical messages received across
|
|
99
|
+
* multiple connections before forwarding to registered handlers
|
|
100
|
+
*/
|
|
101
|
+
dedupeHandler = (data) => {
|
|
102
|
+
const cacheKey = typeof data === "string"
|
|
103
|
+
? data
|
|
104
|
+
: Buffer.from(data).toString("hex");
|
|
105
|
+
if (this.cache.has(cacheKey)) {
|
|
106
|
+
this.logger.debug("Dropping duplicate message");
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
this.cache.set(cacheKey, true);
|
|
110
|
+
if (typeof data === "string") {
|
|
111
|
+
this.handleErrorMessages(data);
|
|
112
|
+
}
|
|
113
|
+
for (const handler of this.messageListeners) {
|
|
114
|
+
handler(data);
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
async sendRequest(request) {
|
|
118
|
+
const sendPromises = this.rwsPool.map(async (rws) => {
|
|
119
|
+
try {
|
|
120
|
+
await rws.send(JSON.stringify(request));
|
|
121
|
+
}
|
|
122
|
+
catch (error) {
|
|
123
|
+
this.logger.error("Failed to send request:", error);
|
|
124
|
+
throw error;
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
await Promise.all(sendPromises);
|
|
128
|
+
}
|
|
129
|
+
async addSubscription(request) {
|
|
130
|
+
if (request.type !== "subscribe") {
|
|
131
|
+
throw new Error("Request must be a subscribe request");
|
|
132
|
+
}
|
|
133
|
+
this.subscriptions.set(request.subscriptionId, request);
|
|
134
|
+
await this.sendRequest(request);
|
|
135
|
+
}
|
|
136
|
+
async removeSubscription(subscriptionId) {
|
|
137
|
+
this.subscriptions.delete(subscriptionId);
|
|
138
|
+
const request = {
|
|
139
|
+
type: "unsubscribe",
|
|
140
|
+
subscriptionId,
|
|
141
|
+
};
|
|
142
|
+
await this.sendRequest(request);
|
|
143
|
+
}
|
|
144
|
+
addMessageListener(handler) {
|
|
145
|
+
this.messageListeners.push(handler);
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Calls the handler if all websocket connections are currently down or in reconnecting state.
|
|
149
|
+
* The connections may still try to reconnect in the background.
|
|
150
|
+
*/
|
|
151
|
+
addAllConnectionsDownListener(handler) {
|
|
152
|
+
this.allConnectionsDownListeners.push(handler);
|
|
153
|
+
}
|
|
154
|
+
areAllConnectionsDown() {
|
|
155
|
+
return this.rwsPool.every((ws) => !ws.isConnected || ws.isReconnecting);
|
|
156
|
+
}
|
|
157
|
+
checkConnectionStates() {
|
|
158
|
+
const allDown = this.areAllConnectionsDown();
|
|
159
|
+
// If all connections just went down
|
|
160
|
+
if (allDown && !this.wasAllDown) {
|
|
161
|
+
this.wasAllDown = true;
|
|
162
|
+
this.logger.error("All WebSocket connections are down or reconnecting");
|
|
163
|
+
// Notify all listeners
|
|
164
|
+
for (const listener of this.allConnectionsDownListeners) {
|
|
165
|
+
listener();
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
// If at least one connection was restored
|
|
169
|
+
if (!allDown && this.wasAllDown) {
|
|
170
|
+
this.wasAllDown = false;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
shutdown() {
|
|
174
|
+
for (const rws of this.rwsPool) {
|
|
175
|
+
rws.closeWebSocket();
|
|
176
|
+
}
|
|
177
|
+
this.rwsPool = [];
|
|
178
|
+
this.subscriptions.clear();
|
|
179
|
+
this.messageListeners = [];
|
|
180
|
+
this.allConnectionsDownListeners = [];
|
|
181
|
+
}
|
|
182
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pythnetwork/pyth-lazer-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.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": "1c6530498edb3112be05a4f7e44a3973310df8ed"
|
|
69
71
|
}
|