@pythnetwork/pyth-lazer-sdk 6.2.0 → 6.2.2
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.cjs +43 -30
- package/dist/cjs/client.d.ts +3 -11
- package/dist/cjs/socket/resilient-websocket.cjs +63 -35
- package/dist/cjs/socket/websocket-pool.cjs +96 -90
- package/dist/cjs/socket/websocket-pool.d.ts +1 -2
- package/dist/esm/client.d.ts +3 -11
- package/dist/esm/client.mjs +43 -30
- package/dist/esm/socket/resilient-websocket.mjs +63 -35
- package/dist/esm/socket/websocket-pool.d.ts +1 -2
- package/dist/esm/socket/websocket-pool.mjs +95 -84
- package/package.json +4 -4
package/dist/cjs/client.cjs
CHANGED
|
@@ -22,39 +22,64 @@ class PythLazerClient {
|
|
|
22
22
|
priceServiceUrl;
|
|
23
23
|
token;
|
|
24
24
|
wsp;
|
|
25
|
-
|
|
26
|
-
constructor({ abortSignal, logger, metadataServiceUrl, priceServiceUrl, token, wsp }){
|
|
27
|
-
this.abortSignal = abortSignal;
|
|
25
|
+
constructor({ logger, metadataServiceUrl, priceServiceUrl, token, wsp }){
|
|
28
26
|
this.logger = logger;
|
|
29
27
|
this.metadataServiceUrl = metadataServiceUrl;
|
|
30
28
|
this.priceServiceUrl = priceServiceUrl;
|
|
31
29
|
this.token = token;
|
|
32
30
|
this.wsp = wsp;
|
|
33
|
-
this.bindHandlers();
|
|
34
31
|
}
|
|
35
32
|
/**
|
|
36
33
|
* Creates a new PythLazerClient instance.
|
|
37
34
|
* @param config - Configuration including token, metadata service URL, and price service URL, and WebSocket pool configuration
|
|
38
35
|
*/ static async create(config) {
|
|
36
|
+
let wsp = undefined;
|
|
37
|
+
let client = undefined;
|
|
38
|
+
const { abortSignal } = config;
|
|
39
|
+
const handleAbort = ()=>{
|
|
40
|
+
// we need to EXPLICITLY shutdown both items,
|
|
41
|
+
// because there is a possibility the client was undefined
|
|
42
|
+
// when the abort signal was called
|
|
43
|
+
client?.shutdown();
|
|
44
|
+
wsp?.shutdown();
|
|
45
|
+
abortSignal?.removeEventListener("abort", handleAbort);
|
|
46
|
+
};
|
|
47
|
+
const throwIfAborted = ()=>{
|
|
48
|
+
if (abortSignal?.aborted) {
|
|
49
|
+
throw new DOMException("PythLazerClient.create() was aborted", "AbortError");
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
abortSignal?.addEventListener("abort", handleAbort);
|
|
39
53
|
const token = config.token;
|
|
40
54
|
// Collect and remove trailing slash from URLs
|
|
41
55
|
const metadataServiceUrl = (config.metadataServiceUrl ?? _constants.DEFAULT_METADATA_SERVICE_URL).replace(/\/+$/, "");
|
|
42
56
|
const priceServiceUrl = (config.priceServiceUrl ?? _constants.DEFAULT_PRICE_SERVICE_URL).replace(/\/+$/, "");
|
|
43
57
|
const logger = config.logger ?? _tslog.dummyLogger;
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
+
try {
|
|
59
|
+
throwIfAborted();
|
|
60
|
+
// the prior API was mismatched, in that it marked a websocket pool as optional,
|
|
61
|
+
// yet all internal code on the Pyth Pro client used it and threw if it didn't exist.
|
|
62
|
+
// now, the typings indicate it's no longer optional and we don't sanity check
|
|
63
|
+
// if it's set
|
|
64
|
+
wsp = await _websocketpool.WebSocketPool.create(config.webSocketPoolConfig, token, abortSignal, logger);
|
|
65
|
+
throwIfAborted();
|
|
66
|
+
client = new PythLazerClient({
|
|
67
|
+
logger,
|
|
68
|
+
metadataServiceUrl,
|
|
69
|
+
priceServiceUrl,
|
|
70
|
+
token,
|
|
71
|
+
wsp
|
|
72
|
+
});
|
|
73
|
+
throwIfAborted();
|
|
74
|
+
// detach the abortSignal handler here, because we've made it!
|
|
75
|
+
abortSignal?.removeEventListener("abort", handleAbort);
|
|
76
|
+
return client;
|
|
77
|
+
} catch (error) {
|
|
78
|
+
client?.shutdown();
|
|
79
|
+
wsp?.shutdown();
|
|
80
|
+
abortSignal?.removeEventListener("abort", handleAbort);
|
|
81
|
+
throw error;
|
|
82
|
+
}
|
|
58
83
|
}
|
|
59
84
|
/**
|
|
60
85
|
* Adds a message listener that receives either JSON or binary responses from the WebSocket connections.
|
|
@@ -108,11 +133,6 @@ class PythLazerClient {
|
|
|
108
133
|
});
|
|
109
134
|
});
|
|
110
135
|
}
|
|
111
|
-
/**
|
|
112
|
-
* binds any internal event handlers
|
|
113
|
-
*/ bindHandlers() {
|
|
114
|
-
this.abortSignal?.addEventListener("abort", this.abortHandler);
|
|
115
|
-
}
|
|
116
136
|
subscribe(request) {
|
|
117
137
|
if (request.type !== "subscribe") {
|
|
118
138
|
throw new Error("Request must be a subscribe request");
|
|
@@ -150,14 +170,7 @@ class PythLazerClient {
|
|
|
150
170
|
*/ addConnectionReconnectListener(handler) {
|
|
151
171
|
this.wsp.addConnectionReconnectListener(handler);
|
|
152
172
|
}
|
|
153
|
-
/**
|
|
154
|
-
* called if and only if a user provided an abort signal and it was aborted
|
|
155
|
-
*/ abortHandler = ()=>{
|
|
156
|
-
this.shutdown();
|
|
157
|
-
};
|
|
158
173
|
shutdown() {
|
|
159
|
-
// Clean up abort signal listener to prevent memory leak
|
|
160
|
-
this.abortSignal?.removeEventListener("abort", this.abortHandler);
|
|
161
174
|
this.wsp.shutdown();
|
|
162
175
|
}
|
|
163
176
|
/**
|
package/dist/cjs/client.d.ts
CHANGED
|
@@ -18,8 +18,9 @@ export type JsonOrBinaryResponse = {
|
|
|
18
18
|
};
|
|
19
19
|
export type LazerClientConfig = {
|
|
20
20
|
/**
|
|
21
|
-
*
|
|
22
|
-
*
|
|
21
|
+
* If provided, allows aborting the client creation process.
|
|
22
|
+
* Once the client is successfully created, this signal has no effect on the client's lifecycle.
|
|
23
|
+
* When aborted, the promise will reject with a DOMException with name 'AbortError'.
|
|
23
24
|
*/
|
|
24
25
|
abortSignal?: AbortSignal;
|
|
25
26
|
token: string;
|
|
@@ -34,7 +35,6 @@ export declare class PythLazerClient {
|
|
|
34
35
|
private priceServiceUrl;
|
|
35
36
|
private token;
|
|
36
37
|
private wsp;
|
|
37
|
-
private abortSignal;
|
|
38
38
|
private constructor();
|
|
39
39
|
/**
|
|
40
40
|
* Creates a new PythLazerClient instance.
|
|
@@ -48,10 +48,6 @@ export declare class PythLazerClient {
|
|
|
48
48
|
* or a binary response containing EVM, Solana, or parsed payload data.
|
|
49
49
|
*/
|
|
50
50
|
addMessageListener(handler: (event: JsonOrBinaryResponse) => void): void;
|
|
51
|
-
/**
|
|
52
|
-
* binds any internal event handlers
|
|
53
|
-
*/
|
|
54
|
-
bindHandlers(): void;
|
|
55
51
|
subscribe(request: Request): void;
|
|
56
52
|
unsubscribe(subscriptionId: number): void;
|
|
57
53
|
send(request: Request): void;
|
|
@@ -76,10 +72,6 @@ export declare class PythLazerClient {
|
|
|
76
72
|
* @param handler - Function to be called with connection index and endpoint URL
|
|
77
73
|
*/
|
|
78
74
|
addConnectionReconnectListener(handler: (connectionIndex: number, endpoint: string) => void): void;
|
|
79
|
-
/**
|
|
80
|
-
* called if and only if a user provided an abort signal and it was aborted
|
|
81
|
-
*/
|
|
82
|
-
protected abortHandler: () => void;
|
|
83
75
|
shutdown(): void;
|
|
84
76
|
/**
|
|
85
77
|
* Private helper method to make authenticated HTTP requests with Bearer token
|
|
@@ -84,46 +84,68 @@ class ResilientWebSocket {
|
|
|
84
84
|
this.logger.info("WebSocket client already started.");
|
|
85
85
|
return;
|
|
86
86
|
}
|
|
87
|
-
if (this.wsFailedAttempts
|
|
87
|
+
if (this.wsFailedAttempts === 0) {
|
|
88
88
|
this.logger.info(`Creating Web Socket client`);
|
|
89
89
|
}
|
|
90
90
|
if (this.retryTimeout !== undefined) {
|
|
91
91
|
clearTimeout(this.retryTimeout);
|
|
92
92
|
this.retryTimeout = undefined;
|
|
93
93
|
}
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
this.
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
this.
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
this.
|
|
94
|
+
// we wrap the new WebSocket() construction in a try / catch because,
|
|
95
|
+
// if one of our instances legitimately goes down or there's some network
|
|
96
|
+
// error that impacts connectivity, the WebSocket constructor will throw
|
|
97
|
+
// an uncaught DOMException in the browser (or equivalent in Node, Bun or Deno)
|
|
98
|
+
// and potentially blow up the process (if running in Node, Bun or Deno)
|
|
99
|
+
try {
|
|
100
|
+
// browser constructor supports a different 2nd argument for the constructor,
|
|
101
|
+
// so we need to ensure it's not included if we're running in that environment:
|
|
102
|
+
// https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/WebSocket#protocols
|
|
103
|
+
this.wsClient = new _isomorphicws.default(this.endpoint, (0, _envutil.envIsBrowserOrWorker)() ? undefined : this.wsOptions);
|
|
104
|
+
this.wsClient.addEventListener("open", ()=>{
|
|
105
|
+
this.logger.info("WebSocket connection established");
|
|
106
|
+
this.wsFailedAttempts = 0;
|
|
107
|
+
this._isReconnecting = false;
|
|
108
|
+
this.resetHeartbeat();
|
|
109
|
+
try {
|
|
110
|
+
this.onReconnect();
|
|
111
|
+
} catch (error) {
|
|
112
|
+
this.logger.error("Error in onReconnect callback:", error);
|
|
111
113
|
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
this.wsClient.
|
|
124
|
-
|
|
114
|
+
});
|
|
115
|
+
this.wsClient.addEventListener("close", (e)=>{
|
|
116
|
+
if (this.wsUserClosed) {
|
|
117
|
+
this.logger.info(`WebSocket connection to ${this.endpoint} closed by user`);
|
|
118
|
+
} else {
|
|
119
|
+
if (this.shouldLogRetry()) {
|
|
120
|
+
this.logger.warn(`WebSocket connection to ${this.endpoint} closed unexpectedly: Code: ${e.code.toString()}`);
|
|
121
|
+
}
|
|
122
|
+
this.handleReconnect();
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
this.wsClient.addEventListener("error", (event)=>{
|
|
126
|
+
try {
|
|
127
|
+
this.onError(event);
|
|
128
|
+
} catch (error) {
|
|
129
|
+
this.logger.error("Error in onError callback:", error);
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
this.wsClient.addEventListener("message", (event)=>{
|
|
125
133
|
this.resetHeartbeat();
|
|
134
|
+
try {
|
|
135
|
+
this.onMessage(event.data);
|
|
136
|
+
} catch (error) {
|
|
137
|
+
this.logger.error("Error in onMessage callback:", error);
|
|
138
|
+
}
|
|
126
139
|
});
|
|
140
|
+
if (typeof this.wsClient?.on === "function") {
|
|
141
|
+
this.wsClient.on("ping", ()=>{
|
|
142
|
+
this.logger.debug("Ping received");
|
|
143
|
+
this.resetHeartbeat();
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
} catch (error) {
|
|
147
|
+
this.logger.error("Failed to create WebSocket client:", error);
|
|
148
|
+
this.handleReconnect();
|
|
127
149
|
}
|
|
128
150
|
}
|
|
129
151
|
resetHeartbeat() {
|
|
@@ -133,7 +155,11 @@ class ResilientWebSocket {
|
|
|
133
155
|
this.heartbeatTimeout = setTimeout(()=>{
|
|
134
156
|
const warnMsg = "Connection timed out. Reconnecting...";
|
|
135
157
|
this.logger.warn(warnMsg);
|
|
136
|
-
|
|
158
|
+
try {
|
|
159
|
+
this.onTimeout();
|
|
160
|
+
} catch (error) {
|
|
161
|
+
this.logger.error("Error in onTimeout callback:", error);
|
|
162
|
+
}
|
|
137
163
|
if (this.wsClient) {
|
|
138
164
|
if (typeof this.wsClient.terminate === "function") {
|
|
139
165
|
this.wsClient.terminate();
|
|
@@ -144,7 +170,8 @@ class ResilientWebSocket {
|
|
|
144
170
|
this.wsClient.close(_protocol.CustomSocketClosureCodes.CLIENT_TIMEOUT_BUT_RECONNECTING, warnMsg);
|
|
145
171
|
}
|
|
146
172
|
}
|
|
147
|
-
|
|
173
|
+
// No direct call to handleReconnect() here
|
|
174
|
+
// terminate/close will trigger the 'close' event, which will call it.
|
|
148
175
|
}, this.heartbeatTimeoutDurationMs);
|
|
149
176
|
}
|
|
150
177
|
handleReconnect() {
|
|
@@ -169,11 +196,12 @@ class ResilientWebSocket {
|
|
|
169
196
|
}, this.retryDelayMs());
|
|
170
197
|
}
|
|
171
198
|
closeWebSocket() {
|
|
172
|
-
|
|
199
|
+
// immediately block duplicate calls to this function
|
|
200
|
+
this.wsUserClosed = true;
|
|
201
|
+
if (typeof this.wsClient?.close === "function") {
|
|
173
202
|
this.wsClient.close();
|
|
174
203
|
this.wsClient = undefined;
|
|
175
204
|
}
|
|
176
|
-
this.wsUserClosed = true;
|
|
177
205
|
}
|
|
178
206
|
/**
|
|
179
207
|
* Calculates the delay in milliseconds for exponential backoff based on the number of failed attempts.
|
|
@@ -8,21 +8,15 @@ Object.defineProperty(exports, "WebSocketPool", {
|
|
|
8
8
|
return WebSocketPool;
|
|
9
9
|
}
|
|
10
10
|
});
|
|
11
|
-
const _ttlcache =
|
|
11
|
+
const _ttlcache = require("@isaacs/ttlcache");
|
|
12
12
|
const _tslog = require("ts-log");
|
|
13
13
|
const _constants = require("../constants.cjs");
|
|
14
14
|
const _index = require("../emitter/index.cjs");
|
|
15
15
|
const _index1 = require("../util/index.cjs");
|
|
16
16
|
const _resilientwebsocket = require("./resilient-websocket.cjs");
|
|
17
|
-
function _interop_require_default(obj) {
|
|
18
|
-
return obj && obj.__esModule ? obj : {
|
|
19
|
-
default: obj
|
|
20
|
-
};
|
|
21
|
-
}
|
|
22
17
|
const DEFAULT_NUM_CONNECTIONS = 4;
|
|
23
18
|
class WebSocketPool extends _index.IsomorphicEventEmitter {
|
|
24
19
|
logger;
|
|
25
|
-
abortSignal;
|
|
26
20
|
rwsPool;
|
|
27
21
|
cache;
|
|
28
22
|
subscriptions;
|
|
@@ -34,10 +28,10 @@ class WebSocketPool extends _index.IsomorphicEventEmitter {
|
|
|
34
28
|
wasAllDown = true;
|
|
35
29
|
checkConnectionStatesInterval;
|
|
36
30
|
isShutdown = false;
|
|
37
|
-
constructor(logger
|
|
38
|
-
super(), this.logger = logger
|
|
31
|
+
constructor(logger){
|
|
32
|
+
super(), this.logger = logger;
|
|
39
33
|
this.rwsPool = [];
|
|
40
|
-
this.cache = new _ttlcache.
|
|
34
|
+
this.cache = new _ttlcache.TTLCache({
|
|
41
35
|
ttl: 1000 * 10
|
|
42
36
|
}); // TTL of 10 seconds
|
|
43
37
|
this.subscriptions = new Map();
|
|
@@ -59,90 +53,102 @@ class WebSocketPool extends _index.IsomorphicEventEmitter {
|
|
|
59
53
|
* @param numConnections - Number of parallel WebSocket connections to maintain (default: 3)
|
|
60
54
|
* @param logger - Optional logger to get socket level logs. Compatible with most loggers such as the built-in console and `bunyan`.
|
|
61
55
|
*/ static async create(config, token, abortSignal, logger) {
|
|
56
|
+
// Helper to check if aborted and throw
|
|
57
|
+
const throwIfAborted = ()=>{
|
|
58
|
+
if (abortSignal?.aborted) {
|
|
59
|
+
throw new DOMException("WebSocketPool.create() was aborted", "AbortError");
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
// Check before starting
|
|
63
|
+
throwIfAborted();
|
|
62
64
|
const urls = config.urls ?? [
|
|
63
65
|
_constants.DEFAULT_STREAM_SERVICE_0_URL,
|
|
64
66
|
_constants.DEFAULT_STREAM_SERVICE_1_URL
|
|
65
67
|
];
|
|
66
68
|
const log = logger ?? _tslog.dummyLogger;
|
|
67
|
-
const pool = new WebSocketPool(log
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
for(let i = 0; i < numConnections; i++){
|
|
79
|
-
const baseUrl = urls[i % urls.length];
|
|
80
|
-
const isBrowser = (0, _index1.envIsBrowserOrWorker)();
|
|
81
|
-
const url = isBrowser ? (0, _index1.addAuthTokenToWebSocketUrl)(baseUrl, token) : baseUrl;
|
|
82
|
-
if (!url) {
|
|
83
|
-
throw new Error(`URLs must not be null or empty`);
|
|
69
|
+
const pool = new WebSocketPool(log);
|
|
70
|
+
try {
|
|
71
|
+
const numConnections = config.numConnections ?? DEFAULT_NUM_CONNECTIONS;
|
|
72
|
+
// bind a handler to capture any emitted errors and send them to the user-provided
|
|
73
|
+
// onWebSocketPoolError callback (if it is present)
|
|
74
|
+
if (typeof config.onWebSocketPoolError === "function") {
|
|
75
|
+
pool.on("error", config.onWebSocketPoolError);
|
|
76
|
+
pool.once("shutdown", ()=>{
|
|
77
|
+
// unbind all error handlers so we don't leak memory
|
|
78
|
+
pool.off("error");
|
|
79
|
+
});
|
|
84
80
|
}
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
const rws = new _resilientwebsocket.ResilientWebSocket({
|
|
92
|
-
...config.rwsConfig,
|
|
93
|
-
endpoint: url,
|
|
94
|
-
logger: log,
|
|
95
|
-
wsOptions
|
|
96
|
-
});
|
|
97
|
-
const connectionIndex = i;
|
|
98
|
-
const connectionEndpoint = url;
|
|
99
|
-
// If a websocket client unexpectedly disconnects, ResilientWebSocket will reestablish
|
|
100
|
-
// the connection and call the onReconnect callback.
|
|
101
|
-
rws.onReconnect = ()=>{
|
|
102
|
-
if (rws.wsUserClosed || pool.isShutdown || pool.abortSignal?.aborted) {
|
|
103
|
-
return;
|
|
104
|
-
}
|
|
105
|
-
for (const listener of pool.connectionReconnectListeners){
|
|
106
|
-
listener(connectionIndex, connectionEndpoint);
|
|
81
|
+
for(let i = 0; i < numConnections; i++){
|
|
82
|
+
const baseUrl = urls[i % urls.length];
|
|
83
|
+
const isBrowser = (0, _index1.envIsBrowserOrWorker)();
|
|
84
|
+
const url = isBrowser ? (0, _index1.addAuthTokenToWebSocketUrl)(baseUrl, token) : baseUrl;
|
|
85
|
+
if (!url) {
|
|
86
|
+
throw new Error(`URLs must not be null or empty`);
|
|
107
87
|
}
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
pool.logger.error("Failed to resend subscription on reconnect:", error);
|
|
88
|
+
const wsOptions = {
|
|
89
|
+
...config.rwsConfig?.wsOptions,
|
|
90
|
+
headers: isBrowser ? undefined : {
|
|
91
|
+
Authorization: `Bearer ${token}`
|
|
113
92
|
}
|
|
93
|
+
};
|
|
94
|
+
const rws = new _resilientwebsocket.ResilientWebSocket({
|
|
95
|
+
...config.rwsConfig,
|
|
96
|
+
endpoint: url,
|
|
97
|
+
logger: log,
|
|
98
|
+
wsOptions
|
|
99
|
+
});
|
|
100
|
+
const connectionIndex = i;
|
|
101
|
+
const connectionEndpoint = url;
|
|
102
|
+
// If a websocket client unexpectedly disconnects, ResilientWebSocket will reestablish
|
|
103
|
+
// the connection and call the onReconnect callback.
|
|
104
|
+
rws.onReconnect = ()=>{
|
|
105
|
+
if (rws.wsUserClosed || pool.isShutdown) {
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
for (const listener of pool.connectionReconnectListeners){
|
|
109
|
+
listener(connectionIndex, connectionEndpoint);
|
|
110
|
+
}
|
|
111
|
+
for (const [, request] of pool.subscriptions){
|
|
112
|
+
try {
|
|
113
|
+
rws.send(JSON.stringify(request));
|
|
114
|
+
} catch (error) {
|
|
115
|
+
pool.logger.error("Failed to resend subscription on reconnect:", error);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
rws.onTimeout = ()=>{
|
|
120
|
+
for (const listener of pool.connectionTimeoutListeners){
|
|
121
|
+
listener(connectionIndex, connectionEndpoint);
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
// eslint-disable-next-line @typescript-eslint/no-deprecated
|
|
125
|
+
const onErrorHandler = config.onWebSocketError ?? config.onError;
|
|
126
|
+
if (typeof onErrorHandler === "function") {
|
|
127
|
+
rws.onError = onErrorHandler;
|
|
114
128
|
}
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
if (typeof onErrorHandler === "function") {
|
|
124
|
-
rws.onError = onErrorHandler;
|
|
129
|
+
// Handle all client messages ourselves. Dedupe before sending to registered message handlers.
|
|
130
|
+
rws.onMessage = (data)=>{
|
|
131
|
+
pool.dedupeHandler(data).catch((error)=>{
|
|
132
|
+
pool.emitPoolError(error, "Error in WebSocketPool dedupeHandler");
|
|
133
|
+
});
|
|
134
|
+
};
|
|
135
|
+
pool.rwsPool.push(rws);
|
|
136
|
+
rws.startWebSocket();
|
|
125
137
|
}
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
});
|
|
131
|
-
};
|
|
132
|
-
pool.rwsPool.push(rws);
|
|
133
|
-
rws.startWebSocket();
|
|
134
|
-
}
|
|
135
|
-
pool.logger.info(`Started WebSocketPool with ${numConnections.toString()} connections. Waiting for at least one to connect...`);
|
|
136
|
-
while(!pool.isAnyConnectionEstablished()){
|
|
137
|
-
if (pool.abortSignal?.aborted) {
|
|
138
|
-
pool.logger.warn("the WebSocket Pool's abort signal was aborted during connection. Shutting down.");
|
|
139
|
-
pool.shutdown();
|
|
140
|
-
return pool;
|
|
138
|
+
pool.logger.info(`Started WebSocketPool with ${numConnections.toString()} connections. Waiting for at least one to connect...`);
|
|
139
|
+
while(!pool.isAnyConnectionEstablished() && !pool.isShutdown){
|
|
140
|
+
throwIfAborted();
|
|
141
|
+
await new Promise((resolve)=>setTimeout(resolve, 100));
|
|
141
142
|
}
|
|
142
|
-
|
|
143
|
+
// Final check after loop exits
|
|
144
|
+
throwIfAborted();
|
|
145
|
+
pool.logger.info(`At least one WebSocket connection is established. WebSocketPool is ready.`);
|
|
146
|
+
return pool;
|
|
147
|
+
} catch (error) {
|
|
148
|
+
// Clean up the pool if aborted during connection wait
|
|
149
|
+
pool.shutdown();
|
|
150
|
+
throw error;
|
|
143
151
|
}
|
|
144
|
-
pool.logger.info(`At least one WebSocket connection is established. WebSocketPool is ready.`);
|
|
145
|
-
return pool;
|
|
146
152
|
}
|
|
147
153
|
emitPoolError(error, context) {
|
|
148
154
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
@@ -194,8 +200,8 @@ class WebSocketPool extends _index.IsomorphicEventEmitter {
|
|
|
194
200
|
}
|
|
195
201
|
};
|
|
196
202
|
sendRequest(request) {
|
|
197
|
-
if (this.isShutdown
|
|
198
|
-
this.logger.warn("Cannot send request: WebSocketPool is shutdown
|
|
203
|
+
if (this.isShutdown) {
|
|
204
|
+
this.logger.warn("Cannot send request: WebSocketPool is shutdown");
|
|
199
205
|
return;
|
|
200
206
|
}
|
|
201
207
|
for (const rws of this.rwsPool){
|
|
@@ -207,8 +213,8 @@ class WebSocketPool extends _index.IsomorphicEventEmitter {
|
|
|
207
213
|
}
|
|
208
214
|
}
|
|
209
215
|
addSubscription(request) {
|
|
210
|
-
if (this.isShutdown
|
|
211
|
-
this.logger.warn("Cannot add subscription: WebSocketPool is shutdown
|
|
216
|
+
if (this.isShutdown) {
|
|
217
|
+
this.logger.warn("Cannot add subscription: WebSocketPool is shutdown");
|
|
212
218
|
return;
|
|
213
219
|
}
|
|
214
220
|
if (request.type !== "subscribe") {
|
|
@@ -219,8 +225,8 @@ class WebSocketPool extends _index.IsomorphicEventEmitter {
|
|
|
219
225
|
this.sendRequest(request);
|
|
220
226
|
}
|
|
221
227
|
removeSubscription(subscriptionId) {
|
|
222
|
-
if (this.isShutdown
|
|
223
|
-
this.logger.warn("Cannot remove subscription: WebSocketPool is shutdown
|
|
228
|
+
if (this.isShutdown) {
|
|
229
|
+
this.logger.warn("Cannot remove subscription: WebSocketPool is shutdown");
|
|
224
230
|
return;
|
|
225
231
|
}
|
|
226
232
|
this.subscriptions.delete(subscriptionId);
|
|
@@ -263,8 +269,8 @@ class WebSocketPool extends _index.IsomorphicEventEmitter {
|
|
|
263
269
|
return this.rwsPool.some((ws)=>ws.isConnected());
|
|
264
270
|
}
|
|
265
271
|
checkConnectionStates() {
|
|
266
|
-
// Stop monitoring if shutdown
|
|
267
|
-
if (this.isShutdown
|
|
272
|
+
// Stop monitoring if shutdown
|
|
273
|
+
if (this.isShutdown) {
|
|
268
274
|
return;
|
|
269
275
|
}
|
|
270
276
|
const allDown = this.areAllConnectionsDown();
|
|
@@ -46,7 +46,6 @@ export type WebSocketPoolEvents = {
|
|
|
46
46
|
};
|
|
47
47
|
export declare class WebSocketPool extends IsomorphicEventEmitter<WebSocketPoolEvents> {
|
|
48
48
|
private readonly logger;
|
|
49
|
-
private readonly abortSignal?;
|
|
50
49
|
rwsPool: ResilientWebSocket[];
|
|
51
50
|
private cache;
|
|
52
51
|
private subscriptions;
|
|
@@ -67,7 +66,7 @@ export declare class WebSocketPool extends IsomorphicEventEmitter<WebSocketPoolE
|
|
|
67
66
|
* @param numConnections - Number of parallel WebSocket connections to maintain (default: 3)
|
|
68
67
|
* @param logger - Optional logger to get socket level logs. Compatible with most loggers such as the built-in console and `bunyan`.
|
|
69
68
|
*/
|
|
70
|
-
static create(config: WebSocketPoolConfig, token: string, abortSignal
|
|
69
|
+
static create(config: WebSocketPoolConfig, token: string, abortSignal?: AbortSignal | null | undefined, logger?: Logger): Promise<WebSocketPool>;
|
|
71
70
|
private emitPoolError;
|
|
72
71
|
/**
|
|
73
72
|
* Checks for error responses in JSON messages and throws appropriate errors
|
package/dist/esm/client.d.ts
CHANGED
|
@@ -18,8 +18,9 @@ export type JsonOrBinaryResponse = {
|
|
|
18
18
|
};
|
|
19
19
|
export type LazerClientConfig = {
|
|
20
20
|
/**
|
|
21
|
-
*
|
|
22
|
-
*
|
|
21
|
+
* If provided, allows aborting the client creation process.
|
|
22
|
+
* Once the client is successfully created, this signal has no effect on the client's lifecycle.
|
|
23
|
+
* When aborted, the promise will reject with a DOMException with name 'AbortError'.
|
|
23
24
|
*/
|
|
24
25
|
abortSignal?: AbortSignal;
|
|
25
26
|
token: string;
|
|
@@ -34,7 +35,6 @@ export declare class PythLazerClient {
|
|
|
34
35
|
private priceServiceUrl;
|
|
35
36
|
private token;
|
|
36
37
|
private wsp;
|
|
37
|
-
private abortSignal;
|
|
38
38
|
private constructor();
|
|
39
39
|
/**
|
|
40
40
|
* Creates a new PythLazerClient instance.
|
|
@@ -48,10 +48,6 @@ export declare class PythLazerClient {
|
|
|
48
48
|
* or a binary response containing EVM, Solana, or parsed payload data.
|
|
49
49
|
*/
|
|
50
50
|
addMessageListener(handler: (event: JsonOrBinaryResponse) => void): void;
|
|
51
|
-
/**
|
|
52
|
-
* binds any internal event handlers
|
|
53
|
-
*/
|
|
54
|
-
bindHandlers(): void;
|
|
55
51
|
subscribe(request: Request): void;
|
|
56
52
|
unsubscribe(subscriptionId: number): void;
|
|
57
53
|
send(request: Request): void;
|
|
@@ -76,10 +72,6 @@ export declare class PythLazerClient {
|
|
|
76
72
|
* @param handler - Function to be called with connection index and endpoint URL
|
|
77
73
|
*/
|
|
78
74
|
addConnectionReconnectListener(handler: (connectionIndex: number, endpoint: string) => void): void;
|
|
79
|
-
/**
|
|
80
|
-
* called if and only if a user provided an abort signal and it was aborted
|
|
81
|
-
*/
|
|
82
|
-
protected abortHandler: () => void;
|
|
83
75
|
shutdown(): void;
|
|
84
76
|
/**
|
|
85
77
|
* Private helper method to make authenticated HTTP requests with Bearer token
|
package/dist/esm/client.mjs
CHANGED
|
@@ -12,39 +12,64 @@ export class PythLazerClient {
|
|
|
12
12
|
priceServiceUrl;
|
|
13
13
|
token;
|
|
14
14
|
wsp;
|
|
15
|
-
|
|
16
|
-
constructor({ abortSignal, logger, metadataServiceUrl, priceServiceUrl, token, wsp }){
|
|
17
|
-
this.abortSignal = abortSignal;
|
|
15
|
+
constructor({ logger, metadataServiceUrl, priceServiceUrl, token, wsp }){
|
|
18
16
|
this.logger = logger;
|
|
19
17
|
this.metadataServiceUrl = metadataServiceUrl;
|
|
20
18
|
this.priceServiceUrl = priceServiceUrl;
|
|
21
19
|
this.token = token;
|
|
22
20
|
this.wsp = wsp;
|
|
23
|
-
this.bindHandlers();
|
|
24
21
|
}
|
|
25
22
|
/**
|
|
26
23
|
* Creates a new PythLazerClient instance.
|
|
27
24
|
* @param config - Configuration including token, metadata service URL, and price service URL, and WebSocket pool configuration
|
|
28
25
|
*/ static async create(config) {
|
|
26
|
+
let wsp = undefined;
|
|
27
|
+
let client = undefined;
|
|
28
|
+
const { abortSignal } = config;
|
|
29
|
+
const handleAbort = ()=>{
|
|
30
|
+
// we need to EXPLICITLY shutdown both items,
|
|
31
|
+
// because there is a possibility the client was undefined
|
|
32
|
+
// when the abort signal was called
|
|
33
|
+
client?.shutdown();
|
|
34
|
+
wsp?.shutdown();
|
|
35
|
+
abortSignal?.removeEventListener("abort", handleAbort);
|
|
36
|
+
};
|
|
37
|
+
const throwIfAborted = ()=>{
|
|
38
|
+
if (abortSignal?.aborted) {
|
|
39
|
+
throw new DOMException("PythLazerClient.create() was aborted", "AbortError");
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
abortSignal?.addEventListener("abort", handleAbort);
|
|
29
43
|
const token = config.token;
|
|
30
44
|
// Collect and remove trailing slash from URLs
|
|
31
45
|
const metadataServiceUrl = (config.metadataServiceUrl ?? DEFAULT_METADATA_SERVICE_URL).replace(/\/+$/, "");
|
|
32
46
|
const priceServiceUrl = (config.priceServiceUrl ?? DEFAULT_PRICE_SERVICE_URL).replace(/\/+$/, "");
|
|
33
47
|
const logger = config.logger ?? dummyLogger;
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
+
try {
|
|
49
|
+
throwIfAborted();
|
|
50
|
+
// the prior API was mismatched, in that it marked a websocket pool as optional,
|
|
51
|
+
// yet all internal code on the Pyth Pro client used it and threw if it didn't exist.
|
|
52
|
+
// now, the typings indicate it's no longer optional and we don't sanity check
|
|
53
|
+
// if it's set
|
|
54
|
+
wsp = await WebSocketPool.create(config.webSocketPoolConfig, token, abortSignal, logger);
|
|
55
|
+
throwIfAborted();
|
|
56
|
+
client = new PythLazerClient({
|
|
57
|
+
logger,
|
|
58
|
+
metadataServiceUrl,
|
|
59
|
+
priceServiceUrl,
|
|
60
|
+
token,
|
|
61
|
+
wsp
|
|
62
|
+
});
|
|
63
|
+
throwIfAborted();
|
|
64
|
+
// detach the abortSignal handler here, because we've made it!
|
|
65
|
+
abortSignal?.removeEventListener("abort", handleAbort);
|
|
66
|
+
return client;
|
|
67
|
+
} catch (error) {
|
|
68
|
+
client?.shutdown();
|
|
69
|
+
wsp?.shutdown();
|
|
70
|
+
abortSignal?.removeEventListener("abort", handleAbort);
|
|
71
|
+
throw error;
|
|
72
|
+
}
|
|
48
73
|
}
|
|
49
74
|
/**
|
|
50
75
|
* Adds a message listener that receives either JSON or binary responses from the WebSocket connections.
|
|
@@ -98,11 +123,6 @@ export class PythLazerClient {
|
|
|
98
123
|
});
|
|
99
124
|
});
|
|
100
125
|
}
|
|
101
|
-
/**
|
|
102
|
-
* binds any internal event handlers
|
|
103
|
-
*/ bindHandlers() {
|
|
104
|
-
this.abortSignal?.addEventListener("abort", this.abortHandler);
|
|
105
|
-
}
|
|
106
126
|
subscribe(request) {
|
|
107
127
|
if (request.type !== "subscribe") {
|
|
108
128
|
throw new Error("Request must be a subscribe request");
|
|
@@ -140,14 +160,7 @@ export class PythLazerClient {
|
|
|
140
160
|
*/ addConnectionReconnectListener(handler) {
|
|
141
161
|
this.wsp.addConnectionReconnectListener(handler);
|
|
142
162
|
}
|
|
143
|
-
/**
|
|
144
|
-
* called if and only if a user provided an abort signal and it was aborted
|
|
145
|
-
*/ abortHandler = ()=>{
|
|
146
|
-
this.shutdown();
|
|
147
|
-
};
|
|
148
163
|
shutdown() {
|
|
149
|
-
// Clean up abort signal listener to prevent memory leak
|
|
150
|
-
this.abortSignal?.removeEventListener("abort", this.abortHandler);
|
|
151
164
|
this.wsp.shutdown();
|
|
152
165
|
}
|
|
153
166
|
/**
|
|
@@ -69,46 +69,68 @@ export class ResilientWebSocket {
|
|
|
69
69
|
this.logger.info("WebSocket client already started.");
|
|
70
70
|
return;
|
|
71
71
|
}
|
|
72
|
-
if (this.wsFailedAttempts
|
|
72
|
+
if (this.wsFailedAttempts === 0) {
|
|
73
73
|
this.logger.info(`Creating Web Socket client`);
|
|
74
74
|
}
|
|
75
75
|
if (this.retryTimeout !== undefined) {
|
|
76
76
|
clearTimeout(this.retryTimeout);
|
|
77
77
|
this.retryTimeout = undefined;
|
|
78
78
|
}
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
this.
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
this.
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
this.
|
|
79
|
+
// we wrap the new WebSocket() construction in a try / catch because,
|
|
80
|
+
// if one of our instances legitimately goes down or there's some network
|
|
81
|
+
// error that impacts connectivity, the WebSocket constructor will throw
|
|
82
|
+
// an uncaught DOMException in the browser (or equivalent in Node, Bun or Deno)
|
|
83
|
+
// and potentially blow up the process (if running in Node, Bun or Deno)
|
|
84
|
+
try {
|
|
85
|
+
// browser constructor supports a different 2nd argument for the constructor,
|
|
86
|
+
// so we need to ensure it's not included if we're running in that environment:
|
|
87
|
+
// https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/WebSocket#protocols
|
|
88
|
+
this.wsClient = new WebSocket(this.endpoint, envIsBrowserOrWorker() ? undefined : this.wsOptions);
|
|
89
|
+
this.wsClient.addEventListener("open", ()=>{
|
|
90
|
+
this.logger.info("WebSocket connection established");
|
|
91
|
+
this.wsFailedAttempts = 0;
|
|
92
|
+
this._isReconnecting = false;
|
|
93
|
+
this.resetHeartbeat();
|
|
94
|
+
try {
|
|
95
|
+
this.onReconnect();
|
|
96
|
+
} catch (error) {
|
|
97
|
+
this.logger.error("Error in onReconnect callback:", error);
|
|
96
98
|
}
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
this.wsClient.
|
|
109
|
-
|
|
99
|
+
});
|
|
100
|
+
this.wsClient.addEventListener("close", (e)=>{
|
|
101
|
+
if (this.wsUserClosed) {
|
|
102
|
+
this.logger.info(`WebSocket connection to ${this.endpoint} closed by user`);
|
|
103
|
+
} else {
|
|
104
|
+
if (this.shouldLogRetry()) {
|
|
105
|
+
this.logger.warn(`WebSocket connection to ${this.endpoint} closed unexpectedly: Code: ${e.code.toString()}`);
|
|
106
|
+
}
|
|
107
|
+
this.handleReconnect();
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
this.wsClient.addEventListener("error", (event)=>{
|
|
111
|
+
try {
|
|
112
|
+
this.onError(event);
|
|
113
|
+
} catch (error) {
|
|
114
|
+
this.logger.error("Error in onError callback:", error);
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
this.wsClient.addEventListener("message", (event)=>{
|
|
110
118
|
this.resetHeartbeat();
|
|
119
|
+
try {
|
|
120
|
+
this.onMessage(event.data);
|
|
121
|
+
} catch (error) {
|
|
122
|
+
this.logger.error("Error in onMessage callback:", error);
|
|
123
|
+
}
|
|
111
124
|
});
|
|
125
|
+
if (typeof this.wsClient?.on === "function") {
|
|
126
|
+
this.wsClient.on("ping", ()=>{
|
|
127
|
+
this.logger.debug("Ping received");
|
|
128
|
+
this.resetHeartbeat();
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
} catch (error) {
|
|
132
|
+
this.logger.error("Failed to create WebSocket client:", error);
|
|
133
|
+
this.handleReconnect();
|
|
112
134
|
}
|
|
113
135
|
}
|
|
114
136
|
resetHeartbeat() {
|
|
@@ -118,7 +140,11 @@ export class ResilientWebSocket {
|
|
|
118
140
|
this.heartbeatTimeout = setTimeout(()=>{
|
|
119
141
|
const warnMsg = "Connection timed out. Reconnecting...";
|
|
120
142
|
this.logger.warn(warnMsg);
|
|
121
|
-
|
|
143
|
+
try {
|
|
144
|
+
this.onTimeout();
|
|
145
|
+
} catch (error) {
|
|
146
|
+
this.logger.error("Error in onTimeout callback:", error);
|
|
147
|
+
}
|
|
122
148
|
if (this.wsClient) {
|
|
123
149
|
if (typeof this.wsClient.terminate === "function") {
|
|
124
150
|
this.wsClient.terminate();
|
|
@@ -129,7 +155,8 @@ export class ResilientWebSocket {
|
|
|
129
155
|
this.wsClient.close(CustomSocketClosureCodes.CLIENT_TIMEOUT_BUT_RECONNECTING, warnMsg);
|
|
130
156
|
}
|
|
131
157
|
}
|
|
132
|
-
|
|
158
|
+
// No direct call to handleReconnect() here
|
|
159
|
+
// terminate/close will trigger the 'close' event, which will call it.
|
|
133
160
|
}, this.heartbeatTimeoutDurationMs);
|
|
134
161
|
}
|
|
135
162
|
handleReconnect() {
|
|
@@ -154,11 +181,12 @@ export class ResilientWebSocket {
|
|
|
154
181
|
}, this.retryDelayMs());
|
|
155
182
|
}
|
|
156
183
|
closeWebSocket() {
|
|
157
|
-
|
|
184
|
+
// immediately block duplicate calls to this function
|
|
185
|
+
this.wsUserClosed = true;
|
|
186
|
+
if (typeof this.wsClient?.close === "function") {
|
|
158
187
|
this.wsClient.close();
|
|
159
188
|
this.wsClient = undefined;
|
|
160
189
|
}
|
|
161
|
-
this.wsUserClosed = true;
|
|
162
190
|
}
|
|
163
191
|
/**
|
|
164
192
|
* Calculates the delay in milliseconds for exponential backoff based on the number of failed attempts.
|
|
@@ -46,7 +46,6 @@ export type WebSocketPoolEvents = {
|
|
|
46
46
|
};
|
|
47
47
|
export declare class WebSocketPool extends IsomorphicEventEmitter<WebSocketPoolEvents> {
|
|
48
48
|
private readonly logger;
|
|
49
|
-
private readonly abortSignal?;
|
|
50
49
|
rwsPool: ResilientWebSocket[];
|
|
51
50
|
private cache;
|
|
52
51
|
private subscriptions;
|
|
@@ -67,7 +66,7 @@ export declare class WebSocketPool extends IsomorphicEventEmitter<WebSocketPoolE
|
|
|
67
66
|
* @param numConnections - Number of parallel WebSocket connections to maintain (default: 3)
|
|
68
67
|
* @param logger - Optional logger to get socket level logs. Compatible with most loggers such as the built-in console and `bunyan`.
|
|
69
68
|
*/
|
|
70
|
-
static create(config: WebSocketPoolConfig, token: string, abortSignal
|
|
69
|
+
static create(config: WebSocketPoolConfig, token: string, abortSignal?: AbortSignal | null | undefined, logger?: Logger): Promise<WebSocketPool>;
|
|
71
70
|
private emitPoolError;
|
|
72
71
|
/**
|
|
73
72
|
* Checks for error responses in JSON messages and throws appropriate errors
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import TTLCache from "@isaacs/ttlcache";
|
|
1
|
+
import { TTLCache } from "@isaacs/ttlcache";
|
|
2
2
|
import { dummyLogger } from "ts-log";
|
|
3
3
|
import { DEFAULT_STREAM_SERVICE_0_URL, DEFAULT_STREAM_SERVICE_1_URL } from "../constants.mjs";
|
|
4
4
|
import { IsomorphicEventEmitter } from "../emitter/index.mjs";
|
|
@@ -7,7 +7,6 @@ import { ResilientWebSocket } from "./resilient-websocket.mjs";
|
|
|
7
7
|
const DEFAULT_NUM_CONNECTIONS = 4;
|
|
8
8
|
export class WebSocketPool extends IsomorphicEventEmitter {
|
|
9
9
|
logger;
|
|
10
|
-
abortSignal;
|
|
11
10
|
rwsPool;
|
|
12
11
|
cache;
|
|
13
12
|
subscriptions;
|
|
@@ -19,8 +18,8 @@ export class WebSocketPool extends IsomorphicEventEmitter {
|
|
|
19
18
|
wasAllDown = true;
|
|
20
19
|
checkConnectionStatesInterval;
|
|
21
20
|
isShutdown = false;
|
|
22
|
-
constructor(logger
|
|
23
|
-
super(), this.logger = logger
|
|
21
|
+
constructor(logger){
|
|
22
|
+
super(), this.logger = logger;
|
|
24
23
|
this.rwsPool = [];
|
|
25
24
|
this.cache = new TTLCache({
|
|
26
25
|
ttl: 1000 * 10
|
|
@@ -44,90 +43,102 @@ export class WebSocketPool extends IsomorphicEventEmitter {
|
|
|
44
43
|
* @param numConnections - Number of parallel WebSocket connections to maintain (default: 3)
|
|
45
44
|
* @param logger - Optional logger to get socket level logs. Compatible with most loggers such as the built-in console and `bunyan`.
|
|
46
45
|
*/ static async create(config, token, abortSignal, logger) {
|
|
46
|
+
// Helper to check if aborted and throw
|
|
47
|
+
const throwIfAborted = ()=>{
|
|
48
|
+
if (abortSignal?.aborted) {
|
|
49
|
+
throw new DOMException("WebSocketPool.create() was aborted", "AbortError");
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
// Check before starting
|
|
53
|
+
throwIfAborted();
|
|
47
54
|
const urls = config.urls ?? [
|
|
48
55
|
DEFAULT_STREAM_SERVICE_0_URL,
|
|
49
56
|
DEFAULT_STREAM_SERVICE_1_URL
|
|
50
57
|
];
|
|
51
58
|
const log = logger ?? dummyLogger;
|
|
52
|
-
const pool = new WebSocketPool(log
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
for(let i = 0; i < numConnections; i++){
|
|
64
|
-
const baseUrl = urls[i % urls.length];
|
|
65
|
-
const isBrowser = envIsBrowserOrWorker();
|
|
66
|
-
const url = isBrowser ? addAuthTokenToWebSocketUrl(baseUrl, token) : baseUrl;
|
|
67
|
-
if (!url) {
|
|
68
|
-
throw new Error(`URLs must not be null or empty`);
|
|
59
|
+
const pool = new WebSocketPool(log);
|
|
60
|
+
try {
|
|
61
|
+
const numConnections = config.numConnections ?? DEFAULT_NUM_CONNECTIONS;
|
|
62
|
+
// bind a handler to capture any emitted errors and send them to the user-provided
|
|
63
|
+
// onWebSocketPoolError callback (if it is present)
|
|
64
|
+
if (typeof config.onWebSocketPoolError === "function") {
|
|
65
|
+
pool.on("error", config.onWebSocketPoolError);
|
|
66
|
+
pool.once("shutdown", ()=>{
|
|
67
|
+
// unbind all error handlers so we don't leak memory
|
|
68
|
+
pool.off("error");
|
|
69
|
+
});
|
|
69
70
|
}
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
71
|
+
for(let i = 0; i < numConnections; i++){
|
|
72
|
+
const baseUrl = urls[i % urls.length];
|
|
73
|
+
const isBrowser = envIsBrowserOrWorker();
|
|
74
|
+
const url = isBrowser ? addAuthTokenToWebSocketUrl(baseUrl, token) : baseUrl;
|
|
75
|
+
if (!url) {
|
|
76
|
+
throw new Error(`URLs must not be null or empty`);
|
|
74
77
|
}
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
logger: log,
|
|
80
|
-
wsOptions
|
|
81
|
-
});
|
|
82
|
-
const connectionIndex = i;
|
|
83
|
-
const connectionEndpoint = url;
|
|
84
|
-
// If a websocket client unexpectedly disconnects, ResilientWebSocket will reestablish
|
|
85
|
-
// the connection and call the onReconnect callback.
|
|
86
|
-
rws.onReconnect = ()=>{
|
|
87
|
-
if (rws.wsUserClosed || pool.isShutdown || pool.abortSignal?.aborted) {
|
|
88
|
-
return;
|
|
89
|
-
}
|
|
90
|
-
for (const listener of pool.connectionReconnectListeners){
|
|
91
|
-
listener(connectionIndex, connectionEndpoint);
|
|
92
|
-
}
|
|
93
|
-
for (const [, request] of pool.subscriptions){
|
|
94
|
-
try {
|
|
95
|
-
rws.send(JSON.stringify(request));
|
|
96
|
-
} catch (error) {
|
|
97
|
-
pool.logger.error("Failed to resend subscription on reconnect:", error);
|
|
78
|
+
const wsOptions = {
|
|
79
|
+
...config.rwsConfig?.wsOptions,
|
|
80
|
+
headers: isBrowser ? undefined : {
|
|
81
|
+
Authorization: `Bearer ${token}`
|
|
98
82
|
}
|
|
83
|
+
};
|
|
84
|
+
const rws = new ResilientWebSocket({
|
|
85
|
+
...config.rwsConfig,
|
|
86
|
+
endpoint: url,
|
|
87
|
+
logger: log,
|
|
88
|
+
wsOptions
|
|
89
|
+
});
|
|
90
|
+
const connectionIndex = i;
|
|
91
|
+
const connectionEndpoint = url;
|
|
92
|
+
// If a websocket client unexpectedly disconnects, ResilientWebSocket will reestablish
|
|
93
|
+
// the connection and call the onReconnect callback.
|
|
94
|
+
rws.onReconnect = ()=>{
|
|
95
|
+
if (rws.wsUserClosed || pool.isShutdown) {
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
for (const listener of pool.connectionReconnectListeners){
|
|
99
|
+
listener(connectionIndex, connectionEndpoint);
|
|
100
|
+
}
|
|
101
|
+
for (const [, request] of pool.subscriptions){
|
|
102
|
+
try {
|
|
103
|
+
rws.send(JSON.stringify(request));
|
|
104
|
+
} catch (error) {
|
|
105
|
+
pool.logger.error("Failed to resend subscription on reconnect:", error);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
rws.onTimeout = ()=>{
|
|
110
|
+
for (const listener of pool.connectionTimeoutListeners){
|
|
111
|
+
listener(connectionIndex, connectionEndpoint);
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
// eslint-disable-next-line @typescript-eslint/no-deprecated
|
|
115
|
+
const onErrorHandler = config.onWebSocketError ?? config.onError;
|
|
116
|
+
if (typeof onErrorHandler === "function") {
|
|
117
|
+
rws.onError = onErrorHandler;
|
|
99
118
|
}
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
if (typeof onErrorHandler === "function") {
|
|
109
|
-
rws.onError = onErrorHandler;
|
|
119
|
+
// Handle all client messages ourselves. Dedupe before sending to registered message handlers.
|
|
120
|
+
rws.onMessage = (data)=>{
|
|
121
|
+
pool.dedupeHandler(data).catch((error)=>{
|
|
122
|
+
pool.emitPoolError(error, "Error in WebSocketPool dedupeHandler");
|
|
123
|
+
});
|
|
124
|
+
};
|
|
125
|
+
pool.rwsPool.push(rws);
|
|
126
|
+
rws.startWebSocket();
|
|
110
127
|
}
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
});
|
|
116
|
-
};
|
|
117
|
-
pool.rwsPool.push(rws);
|
|
118
|
-
rws.startWebSocket();
|
|
119
|
-
}
|
|
120
|
-
pool.logger.info(`Started WebSocketPool with ${numConnections.toString()} connections. Waiting for at least one to connect...`);
|
|
121
|
-
while(!pool.isAnyConnectionEstablished()){
|
|
122
|
-
if (pool.abortSignal?.aborted) {
|
|
123
|
-
pool.logger.warn("the WebSocket Pool's abort signal was aborted during connection. Shutting down.");
|
|
124
|
-
pool.shutdown();
|
|
125
|
-
return pool;
|
|
128
|
+
pool.logger.info(`Started WebSocketPool with ${numConnections.toString()} connections. Waiting for at least one to connect...`);
|
|
129
|
+
while(!pool.isAnyConnectionEstablished() && !pool.isShutdown){
|
|
130
|
+
throwIfAborted();
|
|
131
|
+
await new Promise((resolve)=>setTimeout(resolve, 100));
|
|
126
132
|
}
|
|
127
|
-
|
|
133
|
+
// Final check after loop exits
|
|
134
|
+
throwIfAborted();
|
|
135
|
+
pool.logger.info(`At least one WebSocket connection is established. WebSocketPool is ready.`);
|
|
136
|
+
return pool;
|
|
137
|
+
} catch (error) {
|
|
138
|
+
// Clean up the pool if aborted during connection wait
|
|
139
|
+
pool.shutdown();
|
|
140
|
+
throw error;
|
|
128
141
|
}
|
|
129
|
-
pool.logger.info(`At least one WebSocket connection is established. WebSocketPool is ready.`);
|
|
130
|
-
return pool;
|
|
131
142
|
}
|
|
132
143
|
emitPoolError(error, context) {
|
|
133
144
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
@@ -179,8 +190,8 @@ export class WebSocketPool extends IsomorphicEventEmitter {
|
|
|
179
190
|
}
|
|
180
191
|
};
|
|
181
192
|
sendRequest(request) {
|
|
182
|
-
if (this.isShutdown
|
|
183
|
-
this.logger.warn("Cannot send request: WebSocketPool is shutdown
|
|
193
|
+
if (this.isShutdown) {
|
|
194
|
+
this.logger.warn("Cannot send request: WebSocketPool is shutdown");
|
|
184
195
|
return;
|
|
185
196
|
}
|
|
186
197
|
for (const rws of this.rwsPool){
|
|
@@ -192,8 +203,8 @@ export class WebSocketPool extends IsomorphicEventEmitter {
|
|
|
192
203
|
}
|
|
193
204
|
}
|
|
194
205
|
addSubscription(request) {
|
|
195
|
-
if (this.isShutdown
|
|
196
|
-
this.logger.warn("Cannot add subscription: WebSocketPool is shutdown
|
|
206
|
+
if (this.isShutdown) {
|
|
207
|
+
this.logger.warn("Cannot add subscription: WebSocketPool is shutdown");
|
|
197
208
|
return;
|
|
198
209
|
}
|
|
199
210
|
if (request.type !== "subscribe") {
|
|
@@ -204,8 +215,8 @@ export class WebSocketPool extends IsomorphicEventEmitter {
|
|
|
204
215
|
this.sendRequest(request);
|
|
205
216
|
}
|
|
206
217
|
removeSubscription(subscriptionId) {
|
|
207
|
-
if (this.isShutdown
|
|
208
|
-
this.logger.warn("Cannot remove subscription: WebSocketPool is shutdown
|
|
218
|
+
if (this.isShutdown) {
|
|
219
|
+
this.logger.warn("Cannot remove subscription: WebSocketPool is shutdown");
|
|
209
220
|
return;
|
|
210
221
|
}
|
|
211
222
|
this.subscriptions.delete(subscriptionId);
|
|
@@ -248,8 +259,8 @@ export class WebSocketPool extends IsomorphicEventEmitter {
|
|
|
248
259
|
return this.rwsPool.some((ws)=>ws.isConnected());
|
|
249
260
|
}
|
|
250
261
|
checkConnectionStates() {
|
|
251
|
-
// Stop monitoring if shutdown
|
|
252
|
-
if (this.isShutdown
|
|
262
|
+
// Stop monitoring if shutdown
|
|
263
|
+
if (this.isShutdown) {
|
|
253
264
|
return;
|
|
254
265
|
}
|
|
255
266
|
const allDown = this.areAllConnectionsDown();
|
package/package.json
CHANGED
|
@@ -3,18 +3,18 @@
|
|
|
3
3
|
"url": "https://github.com/pyth-network/pyth-crosschain/issues"
|
|
4
4
|
},
|
|
5
5
|
"dependencies": {
|
|
6
|
-
"@isaacs/ttlcache": "^1.4
|
|
6
|
+
"@isaacs/ttlcache": "^2.1.4",
|
|
7
7
|
"buffer": "^6.0.3",
|
|
8
8
|
"isomorphic-ws": "^5.0.0",
|
|
9
9
|
"ts-log": "^2.2.7",
|
|
10
|
-
"ws": "^8.
|
|
10
|
+
"ws": "^8.20.0"
|
|
11
11
|
},
|
|
12
12
|
"description": "Pyth Lazer SDK",
|
|
13
13
|
"devDependencies": {
|
|
14
14
|
"@cprussin/tsconfig": "^4.0.2",
|
|
15
15
|
"@types/node": "^24.10.1",
|
|
16
16
|
"@types/ws": "^8.18.1",
|
|
17
|
-
"typedoc": "^0.28.
|
|
17
|
+
"typedoc": "^0.28.18"
|
|
18
18
|
},
|
|
19
19
|
"engines": {
|
|
20
20
|
"node": "^24.0.0"
|
|
@@ -164,5 +164,5 @@
|
|
|
164
164
|
},
|
|
165
165
|
"type": "module",
|
|
166
166
|
"types": "./dist/cjs/index.d.ts",
|
|
167
|
-
"version": "6.2.
|
|
167
|
+
"version": "6.2.2"
|
|
168
168
|
}
|