@pythnetwork/pyth-lazer-sdk 0.2.0 → 0.3.1

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/LICENSE CHANGED
@@ -1,4 +1,4 @@
1
- Copyright 2024 Pyth Data Association.
1
+ Copyright 2025 Pyth Data Association.
2
2
 
3
3
  Licensed under the Apache License, Version 2.0 (the "License");
4
4
  you may not use this file except in compliance with the License.
@@ -1,6 +1,5 @@
1
1
  import { type Logger } from "ts-log";
2
2
  import { type ParsedPayload, type Request, type Response } from "./protocol.js";
3
- import { WebSocketPool } from "./socket/web-socket-pool.js";
4
3
  export type BinaryResponse = {
5
4
  subscriptionId: number;
6
5
  evm?: Buffer | undefined;
@@ -15,18 +14,31 @@ export type JsonOrBinaryResponse = {
15
14
  value: BinaryResponse;
16
15
  };
17
16
  export declare class PythLazerClient {
18
- wsp: WebSocketPool;
17
+ private readonly wsp;
18
+ private constructor();
19
19
  /**
20
20
  * Creates a new PythLazerClient instance.
21
21
  * @param urls - List of WebSocket URLs of the Pyth Lazer service
22
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.
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
24
  * @param logger - Optional logger to get socket level logs. Compatible with most loggers such as the built-in console and `bunyan`.
25
25
  */
26
- constructor(urls: string[], token: string, numConnections?: number, logger?: Logger);
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
+ */
27
33
  addMessageListener(handler: (event: JsonOrBinaryResponse) => void): void;
28
34
  subscribe(request: Request): Promise<void>;
29
35
  unsubscribe(subscriptionId: number): Promise<void>;
30
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;
31
43
  shutdown(): void;
32
44
  }
@@ -3,22 +3,32 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.PythLazerClient = void 0;
4
4
  const ts_log_1 = require("ts-log");
5
5
  const protocol_js_1 = require("./protocol.js");
6
- const web_socket_pool_js_1 = require("./socket/web-socket-pool.js");
6
+ const websocket_pool_js_1 = require("./socket/websocket-pool.js");
7
7
  const UINT16_NUM_BYTES = 2;
8
8
  const UINT32_NUM_BYTES = 4;
9
9
  const UINT64_NUM_BYTES = 8;
10
10
  class PythLazerClient {
11
11
  wsp;
12
+ constructor(wsp) {
13
+ this.wsp = wsp;
14
+ }
12
15
  /**
13
16
  * Creates a new PythLazerClient instance.
14
17
  * @param urls - List of WebSocket URLs of the Pyth Lazer service
15
18
  * @param token - The access token for authentication
16
- * @param numConnections - The number of parallel WebSocket connections to establish (default: 3). A higher number gives a more reliable stream.
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.
17
20
  * @param logger - Optional logger to get socket level logs. Compatible with most loggers such as the built-in console and `bunyan`.
18
21
  */
19
- constructor(urls, token, numConnections = 3, logger = ts_log_1.dummyLogger) {
20
- this.wsp = new web_socket_pool_js_1.WebSocketPool(urls, token, numConnections, logger);
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);
21
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
+ */
22
32
  addMessageListener(handler) {
23
33
  this.wsp.addMessageListener((data) => {
24
34
  if (typeof data == "string") {
@@ -77,6 +87,14 @@ class PythLazerClient {
77
87
  async send(request) {
78
88
  await this.wsp.sendRequest(request);
79
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
+ }
80
98
  shutdown() {
81
99
  this.wsp.shutdown();
82
100
  }
@@ -0,0 +1,3 @@
1
+ import { PublicKey } from "@solana/web3.js";
2
+ export declare const SOLANA_LAZER_PROGRAM_ID: PublicKey;
3
+ export declare const SOLANA_STORAGE_ID: PublicKey;
@@ -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");
@@ -1,3 +1,4 @@
1
1
  export * from "./client.js";
2
2
  export * from "./protocol.js";
3
3
  export * from "./ed25519.js";
4
+ export * from "./constants.js";
package/dist/cjs/index.js CHANGED
@@ -17,3 +17,4 @@ Object.defineProperty(exports, "__esModule", { value: true });
17
17
  __exportStar(require("./client.js"), exports);
18
18
  __exportStar(require("./protocol.js"), exports);
19
19
  __exportStar(require("./ed25519.js"), exports);
20
+ __exportStar(require("./constants.js"), exports);
@@ -1,16 +1,6 @@
1
1
  import type { ClientRequestArgs } from "node:http";
2
2
  import WebSocket, { type ClientOptions, type ErrorEvent } from "isomorphic-ws";
3
3
  import type { Logger } from "ts-log";
4
- /**
5
- * This class wraps websocket to provide a resilient web socket client.
6
- *
7
- * It will reconnect if connection fails with exponential backoff. Also, it will reconnect
8
- * if it receives no ping request or regular message from server within a while as indication
9
- * of timeout (assuming the server sends either regularly).
10
- *
11
- * This class also logs events if logger is given and by replacing onError method you can handle
12
- * connection errors yourself (e.g: do not retry and close the connection).
13
- */
14
4
  export declare class ResilientWebSocket {
15
5
  endpoint: string;
16
6
  wsClient: undefined | WebSocket;
@@ -19,17 +9,18 @@ export declare class ResilientWebSocket {
19
9
  private wsFailedAttempts;
20
10
  private heartbeatTimeout;
21
11
  private logger;
12
+ private connectionPromise;
13
+ private resolveConnection;
14
+ private rejectConnection;
15
+ private _isReconnecting;
16
+ get isReconnecting(): boolean;
17
+ get isConnected(): boolean;
22
18
  onError: (error: ErrorEvent) => void;
23
19
  onMessage: (data: WebSocket.Data) => void;
24
20
  onReconnect: () => void;
25
21
  constructor(endpoint: string, wsOptions?: ClientOptions | ClientRequestArgs, logger?: Logger);
26
22
  send(data: string | Buffer): Promise<void>;
27
- startWebSocket(): void;
28
- /**
29
- * Reset the heartbeat timeout. This is called when we receive any message (ping or regular)
30
- * from the server. If we don't receive any message within HEARTBEAT_TIMEOUT_DURATION,
31
- * we assume the connection is dead and reconnect.
32
- */
23
+ startWebSocket(): Promise<void>;
33
24
  private resetHeartbeat;
34
25
  private waitForMaybeReadyWebSocket;
35
26
  private handleClose;
@@ -5,18 +5,8 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.ResilientWebSocket = void 0;
7
7
  const isomorphic_ws_1 = __importDefault(require("isomorphic-ws"));
8
- // Reconnect with expo backoff if we don't get a message or ping for 10 seconds
9
8
  const HEARTBEAT_TIMEOUT_DURATION = 10_000;
10
- /**
11
- * This class wraps websocket to provide a resilient web socket client.
12
- *
13
- * It will reconnect if connection fails with exponential backoff. Also, it will reconnect
14
- * if it receives no ping request or regular message from server within a while as indication
15
- * of timeout (assuming the server sends either regularly).
16
- *
17
- * This class also logs events if logger is given and by replacing onError method you can handle
18
- * connection errors yourself (e.g: do not retry and close the connection).
19
- */
9
+ const CONNECTION_TIMEOUT = 5000;
20
10
  class ResilientWebSocket {
21
11
  endpoint;
22
12
  wsClient;
@@ -25,6 +15,16 @@ class ResilientWebSocket {
25
15
  wsFailedAttempts;
26
16
  heartbeatTimeout;
27
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
28
  onError;
29
29
  onMessage;
30
30
  onReconnect;
@@ -54,41 +54,60 @@ class ResilientWebSocket {
54
54
  this.wsClient.send(data);
55
55
  }
56
56
  }
57
- startWebSocket() {
57
+ async startWebSocket() {
58
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
+ }
59
63
  return;
60
64
  }
61
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);
62
77
  this.wsClient = new isomorphic_ws_1.default(this.endpoint, this.wsOptions);
63
78
  this.wsUserClosed = false;
64
79
  this.wsClient.addEventListener("open", () => {
65
80
  this.wsFailedAttempts = 0;
66
81
  this.resetHeartbeat();
82
+ clearTimeout(timeoutId);
83
+ this._isReconnecting = false;
84
+ this.resolveConnection?.();
67
85
  });
68
86
  this.wsClient.addEventListener("error", (event) => {
69
87
  this.onError(event);
88
+ if (this.rejectConnection) {
89
+ this.rejectConnection(new Error("WebSocket connection failed"));
90
+ }
70
91
  });
71
92
  this.wsClient.addEventListener("message", (event) => {
72
93
  this.resetHeartbeat();
73
94
  this.onMessage(event.data);
74
95
  });
75
96
  this.wsClient.addEventListener("close", () => {
97
+ clearTimeout(timeoutId);
98
+ if (this.rejectConnection) {
99
+ this.rejectConnection(new Error("WebSocket closed before connecting"));
100
+ }
76
101
  void this.handleClose();
77
102
  });
78
- // Handle ping events if supported (Node.js only)
79
103
  if ("on" in this.wsClient) {
80
- // Ping handler is undefined in browser side
81
104
  this.wsClient.on("ping", () => {
82
105
  this.logger?.info("Ping received");
83
106
  this.resetHeartbeat();
84
107
  });
85
108
  }
109
+ return this.connectionPromise;
86
110
  }
87
- /**
88
- * Reset the heartbeat timeout. This is called when we receive any message (ping or regular)
89
- * from the server. If we don't receive any message within HEARTBEAT_TIMEOUT_DURATION,
90
- * we assume the connection is dead and reconnect.
91
- */
92
111
  resetHeartbeat() {
93
112
  if (this.heartbeatTimeout !== undefined) {
94
113
  clearTimeout(this.heartbeatTimeout);
@@ -123,7 +142,11 @@ class ResilientWebSocket {
123
142
  else {
124
143
  this.wsFailedAttempts += 1;
125
144
  this.wsClient = undefined;
145
+ this.connectionPromise = undefined;
146
+ this.resolveConnection = undefined;
147
+ this.rejectConnection = undefined;
126
148
  const waitTime = expoBackoff(this.wsFailedAttempts);
149
+ this._isReconnecting = true;
127
150
  this.logger?.error("Connection closed unexpectedly or because of timeout. Reconnecting after " +
128
151
  String(waitTime) +
129
152
  "ms.");
@@ -135,7 +158,7 @@ class ResilientWebSocket {
135
158
  if (this.wsUserClosed) {
136
159
  return;
137
160
  }
138
- this.startWebSocket();
161
+ await this.startWebSocket();
139
162
  await this.waitForMaybeReadyWebSocket();
140
163
  if (this.wsClient === undefined) {
141
164
  this.logger?.error("Couldn't reconnect to websocket. Error callback is called.");
@@ -147,6 +170,9 @@ class ResilientWebSocket {
147
170
  if (this.wsClient !== undefined) {
148
171
  const client = this.wsClient;
149
172
  this.wsClient = undefined;
173
+ this.connectionPromise = undefined;
174
+ this.resolveConnection = undefined;
175
+ this.rejectConnection = undefined;
150
176
  client.close();
151
177
  }
152
178
  this.wsUserClosed = true;
@@ -1,6 +1,6 @@
1
1
  import WebSocket from "isomorphic-ws";
2
2
  import { type Logger } from "ts-log";
3
- import { ResilientWebSocket } from "./resilient-web-socket.js";
3
+ import { ResilientWebSocket } from "./resilient-websocket.js";
4
4
  import type { Request } from "../protocol.js";
5
5
  export declare class WebSocketPool {
6
6
  private readonly logger;
@@ -8,6 +8,10 @@ export declare class WebSocketPool {
8
8
  private cache;
9
9
  private subscriptions;
10
10
  private messageListeners;
11
+ private allConnectionsDownListeners;
12
+ private wasAllDown;
13
+ private checkConnectionStatesInterval;
14
+ private constructor();
11
15
  /**
12
16
  * Creates a new WebSocketPool instance that uses multiple redundant WebSocket connections for reliability.
13
17
  * Usage semantics are similar to using a regular WebSocket client.
@@ -16,7 +20,7 @@ export declare class WebSocketPool {
16
20
  * @param numConnections - Number of parallel WebSocket connections to maintain (default: 3)
17
21
  * @param logger - Optional logger to get socket level logs. Compatible with most loggers such as the built-in console and `bunyan`.
18
22
  */
19
- constructor(urls: string[], token: string, numConnections?: number, logger?: Logger);
23
+ static create(urls: string[], token: string, numConnections?: number, logger?: Logger): Promise<WebSocketPool>;
20
24
  /**
21
25
  * Checks for error responses in JSON messages and throws appropriate errors
22
26
  */
@@ -26,30 +30,16 @@ export declare class WebSocketPool {
26
30
  * multiple connections before forwarding to registered handlers
27
31
  */
28
32
  dedupeHandler: (data: WebSocket.Data) => void;
29
- /**
30
- * Sends a message to all websockets in the pool
31
- * @param request - The request to send
32
- */
33
33
  sendRequest(request: Request): Promise<void>;
34
- /**
35
- * Adds a subscription by sending a subscribe request to all websockets in the pool
36
- * and storing it for replay on reconnection
37
- * @param request - The subscription request to send
38
- */
39
34
  addSubscription(request: Request): Promise<void>;
40
- /**
41
- * Removes a subscription by sending an unsubscribe request to all websockets in the pool
42
- * and removing it from stored subscriptions
43
- * @param subscriptionId - The ID of the subscription to remove
44
- */
45
35
  removeSubscription(subscriptionId: number): Promise<void>;
46
- /**
47
- * Adds a message handler function to receive websocket messages
48
- * @param handler - Function that will be called with each received message
49
- */
50
36
  addMessageListener(handler: (data: WebSocket.Data) => void): void;
51
37
  /**
52
- * Elegantly closes all websocket connections in the pool
38
+ * Calls the handler if all websocket connections are currently down or in reconnecting state.
39
+ * The connections may still try to reconnect in the background.
53
40
  */
41
+ addAllConnectionsDownListener(handler: () => void): void;
42
+ private areAllConnectionsDown;
43
+ private checkConnectionStates;
54
44
  shutdown(): void;
55
45
  }
@@ -6,8 +6,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.WebSocketPool = void 0;
7
7
  const ttlcache_1 = __importDefault(require("@isaacs/ttlcache"));
8
8
  const ts_log_1 = require("ts-log");
9
- const resilient_web_socket_js_1 = require("./resilient-web-socket.js");
10
- // Number of redundant parallel WebSocket connections
9
+ const resilient_websocket_js_1 = require("./resilient-websocket.js");
11
10
  const DEFAULT_NUM_CONNECTIONS = 3;
12
11
  class WebSocketPool {
13
12
  logger;
@@ -15,6 +14,21 @@ class WebSocketPool {
15
14
  cache;
16
15
  subscriptions; // id -> subscription Request
17
16
  messageListeners;
17
+ allConnectionsDownListeners;
18
+ wasAllDown = true;
19
+ checkConnectionStatesInterval;
20
+ constructor(logger = ts_log_1.dummyLogger) {
21
+ this.logger = logger;
22
+ this.rwsPool = [];
23
+ this.cache = new ttlcache_1.default({ ttl: 1000 * 10 }); // TTL of 10 seconds
24
+ this.subscriptions = new Map();
25
+ this.messageListeners = [];
26
+ this.allConnectionsDownListeners = [];
27
+ // Start monitoring connection states
28
+ this.checkConnectionStatesInterval = setInterval(() => {
29
+ this.checkConnectionStates();
30
+ }, 100);
31
+ }
18
32
  /**
19
33
  * Creates a new WebSocketPool instance that uses multiple redundant WebSocket connections for reliability.
20
34
  * Usage semantics are similar to using a regular WebSocket client.
@@ -23,18 +37,13 @@ class WebSocketPool {
23
37
  * @param numConnections - Number of parallel WebSocket connections to maintain (default: 3)
24
38
  * @param logger - Optional logger to get socket level logs. Compatible with most loggers such as the built-in console and `bunyan`.
25
39
  */
26
- constructor(urls, token, numConnections = DEFAULT_NUM_CONNECTIONS, logger = ts_log_1.dummyLogger) {
27
- this.logger = logger;
40
+ static async create(urls, token, numConnections = DEFAULT_NUM_CONNECTIONS, logger = ts_log_1.dummyLogger) {
28
41
  if (urls.length === 0) {
29
42
  throw new Error("No URLs provided");
30
43
  }
31
- // This cache is used to deduplicate messages received across different websocket clients in the pool.
32
- // A TTL cache is used to prevent unbounded memory usage. A very short TTL of 10 seconds is chosen since
33
- // deduplication only needs to happen between messages received very close together in time.
34
- this.cache = new ttlcache_1.default({ ttl: 1000 * 10 }); // TTL of 10 seconds
35
- this.rwsPool = [];
36
- this.subscriptions = new Map();
37
- this.messageListeners = [];
44
+ const pool = new WebSocketPool(logger);
45
+ // Create all websocket instances
46
+ const connectionPromises = [];
38
47
  for (let i = 0; i < numConnections; i++) {
39
48
  const url = urls[i % urls.length];
40
49
  if (!url) {
@@ -45,33 +54,39 @@ class WebSocketPool {
45
54
  Authorization: `Bearer ${token}`,
46
55
  },
47
56
  };
48
- const rws = new resilient_web_socket_js_1.ResilientWebSocket(url, wsOptions, logger);
57
+ const rws = new resilient_websocket_js_1.ResilientWebSocket(url, wsOptions, logger);
49
58
  // If a websocket client unexpectedly disconnects, ResilientWebSocket will reestablish
50
59
  // the connection and call the onReconnect callback.
51
- // When we reconnect, replay all subscription messages to resume the data stream.
52
60
  rws.onReconnect = () => {
53
61
  if (rws.wsUserClosed) {
54
62
  return;
55
63
  }
56
- for (const [, request] of this.subscriptions) {
64
+ for (const [, request] of pool.subscriptions) {
57
65
  try {
58
66
  void rws.send(JSON.stringify(request));
59
67
  }
60
68
  catch (error) {
61
- this.logger.error("Failed to resend subscription on reconnect:", error);
69
+ pool.logger.error("Failed to resend subscription on reconnect:", error);
62
70
  }
63
71
  }
64
72
  };
65
73
  // Handle all client messages ourselves. Dedupe before sending to registered message handlers.
66
- rws.onMessage = this.dedupeHandler;
67
- this.rwsPool.push(rws);
74
+ rws.onMessage = pool.dedupeHandler;
75
+ pool.rwsPool.push(rws);
76
+ // Start the websocket and collect the promise
77
+ connectionPromises.push(rws.startWebSocket());
68
78
  }
69
- // Let it rip
70
- // TODO: wait for sockets to receive `open` msg before subscribing?
71
- for (const rws of this.rwsPool) {
72
- rws.startWebSocket();
79
+ // Wait for all connections to be established
80
+ try {
81
+ await Promise.all(connectionPromises);
82
+ }
83
+ catch (error) {
84
+ // If any connection fails, clean up and throw
85
+ pool.shutdown();
86
+ throw error;
73
87
  }
74
- this.logger.info(`Using ${numConnections.toString()} redundant WebSocket connections`);
88
+ pool.logger.info(`Successfully established ${numConnections.toString()} redundant WebSocket connections`);
89
+ return pool;
75
90
  }
76
91
  /**
77
92
  * Checks for error responses in JSON messages and throws appropriate errors
@@ -90,19 +105,14 @@ class WebSocketPool {
90
105
  * multiple connections before forwarding to registered handlers
91
106
  */
92
107
  dedupeHandler = (data) => {
93
- // For string data, use the whole string as the cache key. This avoids expensive JSON parsing during deduping.
94
- // For binary data, use the hex string representation as the cache key
95
108
  const cacheKey = typeof data === "string"
96
109
  ? data
97
110
  : Buffer.from(data).toString("hex");
98
- // If we've seen this exact message recently, drop it
99
111
  if (this.cache.has(cacheKey)) {
100
112
  this.logger.debug("Dropping duplicate message");
101
113
  return;
102
114
  }
103
- // Haven't seen this message, cache it and forward to handlers
104
115
  this.cache.set(cacheKey, true);
105
- // Check for errors in JSON responses
106
116
  if (typeof data === "string") {
107
117
  this.handleErrorMessages(data);
108
118
  }
@@ -110,28 +120,18 @@ class WebSocketPool {
110
120
  handler(data);
111
121
  }
112
122
  };
113
- /**
114
- * Sends a message to all websockets in the pool
115
- * @param request - The request to send
116
- */
117
123
  async sendRequest(request) {
118
- // Send to all websockets in the pool
119
124
  const sendPromises = this.rwsPool.map(async (rws) => {
120
125
  try {
121
126
  await rws.send(JSON.stringify(request));
122
127
  }
123
128
  catch (error) {
124
129
  this.logger.error("Failed to send request:", error);
125
- throw error; // Re-throw the error
130
+ throw error;
126
131
  }
127
132
  });
128
133
  await Promise.all(sendPromises);
129
134
  }
130
- /**
131
- * Adds a subscription by sending a subscribe request to all websockets in the pool
132
- * and storing it for replay on reconnection
133
- * @param request - The subscription request to send
134
- */
135
135
  async addSubscription(request) {
136
136
  if (request.type !== "subscribe") {
137
137
  throw new Error("Request must be a subscribe request");
@@ -139,11 +139,6 @@ class WebSocketPool {
139
139
  this.subscriptions.set(request.subscriptionId, request);
140
140
  await this.sendRequest(request);
141
141
  }
142
- /**
143
- * Removes a subscription by sending an unsubscribe request to all websockets in the pool
144
- * and removing it from stored subscriptions
145
- * @param subscriptionId - The ID of the subscription to remove
146
- */
147
142
  async removeSubscription(subscriptionId) {
148
143
  this.subscriptions.delete(subscriptionId);
149
144
  const request = {
@@ -152,16 +147,35 @@ class WebSocketPool {
152
147
  };
153
148
  await this.sendRequest(request);
154
149
  }
155
- /**
156
- * Adds a message handler function to receive websocket messages
157
- * @param handler - Function that will be called with each received message
158
- */
159
150
  addMessageListener(handler) {
160
151
  this.messageListeners.push(handler);
161
152
  }
162
153
  /**
163
- * Elegantly closes all websocket connections in the pool
154
+ * Calls the handler if all websocket connections are currently down or in reconnecting state.
155
+ * The connections may still try to reconnect in the background.
164
156
  */
157
+ addAllConnectionsDownListener(handler) {
158
+ this.allConnectionsDownListeners.push(handler);
159
+ }
160
+ areAllConnectionsDown() {
161
+ return this.rwsPool.every((ws) => !ws.isConnected || ws.isReconnecting);
162
+ }
163
+ checkConnectionStates() {
164
+ const allDown = this.areAllConnectionsDown();
165
+ // If all connections just went down
166
+ if (allDown && !this.wasAllDown) {
167
+ this.wasAllDown = true;
168
+ this.logger.error("All WebSocket connections are down or reconnecting");
169
+ // Notify all listeners
170
+ for (const listener of this.allConnectionsDownListeners) {
171
+ listener();
172
+ }
173
+ }
174
+ // If at least one connection was restored
175
+ if (!allDown && this.wasAllDown) {
176
+ this.wasAllDown = false;
177
+ }
178
+ }
165
179
  shutdown() {
166
180
  for (const rws of this.rwsPool) {
167
181
  rws.closeWebSocket();
@@ -169,6 +183,8 @@ class WebSocketPool {
169
183
  this.rwsPool = [];
170
184
  this.subscriptions.clear();
171
185
  this.messageListeners = [];
186
+ this.allConnectionsDownListeners = [];
187
+ clearInterval(this.checkConnectionStatesInterval);
172
188
  }
173
189
  }
174
190
  exports.WebSocketPool = WebSocketPool;
@@ -1,6 +1,5 @@
1
1
  import { type Logger } from "ts-log";
2
2
  import { type ParsedPayload, type Request, type Response } from "./protocol.js";
3
- import { WebSocketPool } from "./socket/web-socket-pool.js";
4
3
  export type BinaryResponse = {
5
4
  subscriptionId: number;
6
5
  evm?: Buffer | undefined;
@@ -15,18 +14,31 @@ export type JsonOrBinaryResponse = {
15
14
  value: BinaryResponse;
16
15
  };
17
16
  export declare class PythLazerClient {
18
- wsp: WebSocketPool;
17
+ private readonly wsp;
18
+ private constructor();
19
19
  /**
20
20
  * Creates a new PythLazerClient instance.
21
21
  * @param urls - List of WebSocket URLs of the Pyth Lazer service
22
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.
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
24
  * @param logger - Optional logger to get socket level logs. Compatible with most loggers such as the built-in console and `bunyan`.
25
25
  */
26
- constructor(urls: string[], token: string, numConnections?: number, logger?: Logger);
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
+ */
27
33
  addMessageListener(handler: (event: JsonOrBinaryResponse) => void): void;
28
34
  subscribe(request: Request): Promise<void>;
29
35
  unsubscribe(subscriptionId: number): Promise<void>;
30
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;
31
43
  shutdown(): void;
32
44
  }
@@ -1,22 +1,32 @@
1
1
  import WebSocket from "isomorphic-ws";
2
2
  import { dummyLogger } from "ts-log";
3
3
  import { BINARY_UPDATE_FORMAT_MAGIC, EVM_FORMAT_MAGIC, PARSED_FORMAT_MAGIC, SOLANA_FORMAT_MAGIC_BE, } from "./protocol.js";
4
- import { WebSocketPool } from "./socket/web-socket-pool.js";
4
+ import { WebSocketPool } from "./socket/websocket-pool.js";
5
5
  const UINT16_NUM_BYTES = 2;
6
6
  const UINT32_NUM_BYTES = 4;
7
7
  const UINT64_NUM_BYTES = 8;
8
8
  export class PythLazerClient {
9
9
  wsp;
10
+ constructor(wsp) {
11
+ this.wsp = wsp;
12
+ }
10
13
  /**
11
14
  * Creates a new PythLazerClient instance.
12
15
  * @param urls - List of WebSocket URLs of the Pyth Lazer service
13
16
  * @param token - The access token for authentication
14
- * @param numConnections - The number of parallel WebSocket connections to establish (default: 3). A higher number gives a more reliable stream.
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.
15
18
  * @param logger - Optional logger to get socket level logs. Compatible with most loggers such as the built-in console and `bunyan`.
16
19
  */
17
- constructor(urls, token, numConnections = 3, logger = dummyLogger) {
18
- this.wsp = new WebSocketPool(urls, token, numConnections, logger);
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);
19
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
+ */
20
30
  addMessageListener(handler) {
21
31
  this.wsp.addMessageListener((data) => {
22
32
  if (typeof data == "string") {
@@ -75,6 +85,14 @@ export class PythLazerClient {
75
85
  async send(request) {
76
86
  await this.wsp.sendRequest(request);
77
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
+ }
78
96
  shutdown() {
79
97
  this.wsp.shutdown();
80
98
  }
@@ -0,0 +1,3 @@
1
+ import { PublicKey } from "@solana/web3.js";
2
+ export declare const SOLANA_LAZER_PROGRAM_ID: PublicKey;
3
+ export declare const SOLANA_STORAGE_ID: PublicKey;
@@ -0,0 +1,3 @@
1
+ import { PublicKey } from "@solana/web3.js";
2
+ export const SOLANA_LAZER_PROGRAM_ID = new PublicKey("pytd2yyk641x7ak7mkaasSJVXh6YYZnC7wTmtgAyxPt");
3
+ export const SOLANA_STORAGE_ID = new PublicKey("3rdJbqfnagQ4yx9HXJViD4zc4xpiSqmFsKpPuSCQVyQL");
@@ -1,3 +1,4 @@
1
1
  export * from "./client.js";
2
2
  export * from "./protocol.js";
3
3
  export * from "./ed25519.js";
4
+ export * from "./constants.js";
package/dist/esm/index.js CHANGED
@@ -1,3 +1,4 @@
1
1
  export * from "./client.js";
2
2
  export * from "./protocol.js";
3
3
  export * from "./ed25519.js";
4
+ export * from "./constants.js";
@@ -1,16 +1,6 @@
1
1
  import type { ClientRequestArgs } from "node:http";
2
2
  import WebSocket, { type ClientOptions, type ErrorEvent } from "isomorphic-ws";
3
3
  import type { Logger } from "ts-log";
4
- /**
5
- * This class wraps websocket to provide a resilient web socket client.
6
- *
7
- * It will reconnect if connection fails with exponential backoff. Also, it will reconnect
8
- * if it receives no ping request or regular message from server within a while as indication
9
- * of timeout (assuming the server sends either regularly).
10
- *
11
- * This class also logs events if logger is given and by replacing onError method you can handle
12
- * connection errors yourself (e.g: do not retry and close the connection).
13
- */
14
4
  export declare class ResilientWebSocket {
15
5
  endpoint: string;
16
6
  wsClient: undefined | WebSocket;
@@ -19,17 +9,18 @@ export declare class ResilientWebSocket {
19
9
  private wsFailedAttempts;
20
10
  private heartbeatTimeout;
21
11
  private logger;
12
+ private connectionPromise;
13
+ private resolveConnection;
14
+ private rejectConnection;
15
+ private _isReconnecting;
16
+ get isReconnecting(): boolean;
17
+ get isConnected(): boolean;
22
18
  onError: (error: ErrorEvent) => void;
23
19
  onMessage: (data: WebSocket.Data) => void;
24
20
  onReconnect: () => void;
25
21
  constructor(endpoint: string, wsOptions?: ClientOptions | ClientRequestArgs, logger?: Logger);
26
22
  send(data: string | Buffer): Promise<void>;
27
- startWebSocket(): void;
28
- /**
29
- * Reset the heartbeat timeout. This is called when we receive any message (ping or regular)
30
- * from the server. If we don't receive any message within HEARTBEAT_TIMEOUT_DURATION,
31
- * we assume the connection is dead and reconnect.
32
- */
23
+ startWebSocket(): Promise<void>;
33
24
  private resetHeartbeat;
34
25
  private waitForMaybeReadyWebSocket;
35
26
  private handleClose;
@@ -1,16 +1,6 @@
1
1
  import WebSocket, {} from "isomorphic-ws";
2
- // Reconnect with expo backoff if we don't get a message or ping for 10 seconds
3
2
  const HEARTBEAT_TIMEOUT_DURATION = 10_000;
4
- /**
5
- * This class wraps websocket to provide a resilient web socket client.
6
- *
7
- * It will reconnect if connection fails with exponential backoff. Also, it will reconnect
8
- * if it receives no ping request or regular message from server within a while as indication
9
- * of timeout (assuming the server sends either regularly).
10
- *
11
- * This class also logs events if logger is given and by replacing onError method you can handle
12
- * connection errors yourself (e.g: do not retry and close the connection).
13
- */
3
+ const CONNECTION_TIMEOUT = 5000;
14
4
  export class ResilientWebSocket {
15
5
  endpoint;
16
6
  wsClient;
@@ -19,6 +9,16 @@ export class ResilientWebSocket {
19
9
  wsFailedAttempts;
20
10
  heartbeatTimeout;
21
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
22
  onError;
23
23
  onMessage;
24
24
  onReconnect;
@@ -48,41 +48,60 @@ export class ResilientWebSocket {
48
48
  this.wsClient.send(data);
49
49
  }
50
50
  }
51
- startWebSocket() {
51
+ async startWebSocket() {
52
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
+ }
53
57
  return;
54
58
  }
55
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);
56
71
  this.wsClient = new WebSocket(this.endpoint, this.wsOptions);
57
72
  this.wsUserClosed = false;
58
73
  this.wsClient.addEventListener("open", () => {
59
74
  this.wsFailedAttempts = 0;
60
75
  this.resetHeartbeat();
76
+ clearTimeout(timeoutId);
77
+ this._isReconnecting = false;
78
+ this.resolveConnection?.();
61
79
  });
62
80
  this.wsClient.addEventListener("error", (event) => {
63
81
  this.onError(event);
82
+ if (this.rejectConnection) {
83
+ this.rejectConnection(new Error("WebSocket connection failed"));
84
+ }
64
85
  });
65
86
  this.wsClient.addEventListener("message", (event) => {
66
87
  this.resetHeartbeat();
67
88
  this.onMessage(event.data);
68
89
  });
69
90
  this.wsClient.addEventListener("close", () => {
91
+ clearTimeout(timeoutId);
92
+ if (this.rejectConnection) {
93
+ this.rejectConnection(new Error("WebSocket closed before connecting"));
94
+ }
70
95
  void this.handleClose();
71
96
  });
72
- // Handle ping events if supported (Node.js only)
73
97
  if ("on" in this.wsClient) {
74
- // Ping handler is undefined in browser side
75
98
  this.wsClient.on("ping", () => {
76
99
  this.logger?.info("Ping received");
77
100
  this.resetHeartbeat();
78
101
  });
79
102
  }
103
+ return this.connectionPromise;
80
104
  }
81
- /**
82
- * Reset the heartbeat timeout. This is called when we receive any message (ping or regular)
83
- * from the server. If we don't receive any message within HEARTBEAT_TIMEOUT_DURATION,
84
- * we assume the connection is dead and reconnect.
85
- */
86
105
  resetHeartbeat() {
87
106
  if (this.heartbeatTimeout !== undefined) {
88
107
  clearTimeout(this.heartbeatTimeout);
@@ -117,7 +136,11 @@ export class ResilientWebSocket {
117
136
  else {
118
137
  this.wsFailedAttempts += 1;
119
138
  this.wsClient = undefined;
139
+ this.connectionPromise = undefined;
140
+ this.resolveConnection = undefined;
141
+ this.rejectConnection = undefined;
120
142
  const waitTime = expoBackoff(this.wsFailedAttempts);
143
+ this._isReconnecting = true;
121
144
  this.logger?.error("Connection closed unexpectedly or because of timeout. Reconnecting after " +
122
145
  String(waitTime) +
123
146
  "ms.");
@@ -129,7 +152,7 @@ export class ResilientWebSocket {
129
152
  if (this.wsUserClosed) {
130
153
  return;
131
154
  }
132
- this.startWebSocket();
155
+ await this.startWebSocket();
133
156
  await this.waitForMaybeReadyWebSocket();
134
157
  if (this.wsClient === undefined) {
135
158
  this.logger?.error("Couldn't reconnect to websocket. Error callback is called.");
@@ -141,6 +164,9 @@ export class ResilientWebSocket {
141
164
  if (this.wsClient !== undefined) {
142
165
  const client = this.wsClient;
143
166
  this.wsClient = undefined;
167
+ this.connectionPromise = undefined;
168
+ this.resolveConnection = undefined;
169
+ this.rejectConnection = undefined;
144
170
  client.close();
145
171
  }
146
172
  this.wsUserClosed = true;
@@ -1,6 +1,6 @@
1
1
  import WebSocket from "isomorphic-ws";
2
2
  import { type Logger } from "ts-log";
3
- import { ResilientWebSocket } from "./resilient-web-socket.js";
3
+ import { ResilientWebSocket } from "./resilient-websocket.js";
4
4
  import type { Request } from "../protocol.js";
5
5
  export declare class WebSocketPool {
6
6
  private readonly logger;
@@ -8,6 +8,10 @@ export declare class WebSocketPool {
8
8
  private cache;
9
9
  private subscriptions;
10
10
  private messageListeners;
11
+ private allConnectionsDownListeners;
12
+ private wasAllDown;
13
+ private checkConnectionStatesInterval;
14
+ private constructor();
11
15
  /**
12
16
  * Creates a new WebSocketPool instance that uses multiple redundant WebSocket connections for reliability.
13
17
  * Usage semantics are similar to using a regular WebSocket client.
@@ -16,7 +20,7 @@ export declare class WebSocketPool {
16
20
  * @param numConnections - Number of parallel WebSocket connections to maintain (default: 3)
17
21
  * @param logger - Optional logger to get socket level logs. Compatible with most loggers such as the built-in console and `bunyan`.
18
22
  */
19
- constructor(urls: string[], token: string, numConnections?: number, logger?: Logger);
23
+ static create(urls: string[], token: string, numConnections?: number, logger?: Logger): Promise<WebSocketPool>;
20
24
  /**
21
25
  * Checks for error responses in JSON messages and throws appropriate errors
22
26
  */
@@ -26,30 +30,16 @@ export declare class WebSocketPool {
26
30
  * multiple connections before forwarding to registered handlers
27
31
  */
28
32
  dedupeHandler: (data: WebSocket.Data) => void;
29
- /**
30
- * Sends a message to all websockets in the pool
31
- * @param request - The request to send
32
- */
33
33
  sendRequest(request: Request): Promise<void>;
34
- /**
35
- * Adds a subscription by sending a subscribe request to all websockets in the pool
36
- * and storing it for replay on reconnection
37
- * @param request - The subscription request to send
38
- */
39
34
  addSubscription(request: Request): Promise<void>;
40
- /**
41
- * Removes a subscription by sending an unsubscribe request to all websockets in the pool
42
- * and removing it from stored subscriptions
43
- * @param subscriptionId - The ID of the subscription to remove
44
- */
45
35
  removeSubscription(subscriptionId: number): Promise<void>;
46
- /**
47
- * Adds a message handler function to receive websocket messages
48
- * @param handler - Function that will be called with each received message
49
- */
50
36
  addMessageListener(handler: (data: WebSocket.Data) => void): void;
51
37
  /**
52
- * Elegantly closes all websocket connections in the pool
38
+ * Calls the handler if all websocket connections are currently down or in reconnecting state.
39
+ * The connections may still try to reconnect in the background.
53
40
  */
41
+ addAllConnectionsDownListener(handler: () => void): void;
42
+ private areAllConnectionsDown;
43
+ private checkConnectionStates;
54
44
  shutdown(): void;
55
45
  }
@@ -1,8 +1,7 @@
1
1
  import TTLCache from "@isaacs/ttlcache";
2
2
  import WebSocket from "isomorphic-ws";
3
3
  import { dummyLogger } from "ts-log";
4
- import { ResilientWebSocket } from "./resilient-web-socket.js";
5
- // Number of redundant parallel WebSocket connections
4
+ import { ResilientWebSocket } from "./resilient-websocket.js";
6
5
  const DEFAULT_NUM_CONNECTIONS = 3;
7
6
  export class WebSocketPool {
8
7
  logger;
@@ -10,6 +9,21 @@ export class WebSocketPool {
10
9
  cache;
11
10
  subscriptions; // id -> subscription Request
12
11
  messageListeners;
12
+ allConnectionsDownListeners;
13
+ wasAllDown = true;
14
+ checkConnectionStatesInterval;
15
+ constructor(logger = dummyLogger) {
16
+ this.logger = logger;
17
+ this.rwsPool = [];
18
+ this.cache = new TTLCache({ ttl: 1000 * 10 }); // TTL of 10 seconds
19
+ this.subscriptions = new Map();
20
+ this.messageListeners = [];
21
+ this.allConnectionsDownListeners = [];
22
+ // Start monitoring connection states
23
+ this.checkConnectionStatesInterval = setInterval(() => {
24
+ this.checkConnectionStates();
25
+ }, 100);
26
+ }
13
27
  /**
14
28
  * Creates a new WebSocketPool instance that uses multiple redundant WebSocket connections for reliability.
15
29
  * Usage semantics are similar to using a regular WebSocket client.
@@ -18,18 +32,13 @@ export class WebSocketPool {
18
32
  * @param numConnections - Number of parallel WebSocket connections to maintain (default: 3)
19
33
  * @param logger - Optional logger to get socket level logs. Compatible with most loggers such as the built-in console and `bunyan`.
20
34
  */
21
- constructor(urls, token, numConnections = DEFAULT_NUM_CONNECTIONS, logger = dummyLogger) {
22
- this.logger = logger;
35
+ static async create(urls, token, numConnections = DEFAULT_NUM_CONNECTIONS, logger = dummyLogger) {
23
36
  if (urls.length === 0) {
24
37
  throw new Error("No URLs provided");
25
38
  }
26
- // This cache is used to deduplicate messages received across different websocket clients in the pool.
27
- // A TTL cache is used to prevent unbounded memory usage. A very short TTL of 10 seconds is chosen since
28
- // deduplication only needs to happen between messages received very close together in time.
29
- this.cache = new TTLCache({ ttl: 1000 * 10 }); // TTL of 10 seconds
30
- this.rwsPool = [];
31
- this.subscriptions = new Map();
32
- this.messageListeners = [];
39
+ const pool = new WebSocketPool(logger);
40
+ // Create all websocket instances
41
+ const connectionPromises = [];
33
42
  for (let i = 0; i < numConnections; i++) {
34
43
  const url = urls[i % urls.length];
35
44
  if (!url) {
@@ -43,30 +52,36 @@ export class WebSocketPool {
43
52
  const rws = new ResilientWebSocket(url, wsOptions, logger);
44
53
  // If a websocket client unexpectedly disconnects, ResilientWebSocket will reestablish
45
54
  // the connection and call the onReconnect callback.
46
- // When we reconnect, replay all subscription messages to resume the data stream.
47
55
  rws.onReconnect = () => {
48
56
  if (rws.wsUserClosed) {
49
57
  return;
50
58
  }
51
- for (const [, request] of this.subscriptions) {
59
+ for (const [, request] of pool.subscriptions) {
52
60
  try {
53
61
  void rws.send(JSON.stringify(request));
54
62
  }
55
63
  catch (error) {
56
- this.logger.error("Failed to resend subscription on reconnect:", error);
64
+ pool.logger.error("Failed to resend subscription on reconnect:", error);
57
65
  }
58
66
  }
59
67
  };
60
68
  // Handle all client messages ourselves. Dedupe before sending to registered message handlers.
61
- rws.onMessage = this.dedupeHandler;
62
- this.rwsPool.push(rws);
69
+ rws.onMessage = pool.dedupeHandler;
70
+ pool.rwsPool.push(rws);
71
+ // Start the websocket and collect the promise
72
+ connectionPromises.push(rws.startWebSocket());
63
73
  }
64
- // Let it rip
65
- // TODO: wait for sockets to receive `open` msg before subscribing?
66
- for (const rws of this.rwsPool) {
67
- rws.startWebSocket();
74
+ // Wait for all connections to be established
75
+ try {
76
+ await Promise.all(connectionPromises);
77
+ }
78
+ catch (error) {
79
+ // If any connection fails, clean up and throw
80
+ pool.shutdown();
81
+ throw error;
68
82
  }
69
- this.logger.info(`Using ${numConnections.toString()} redundant WebSocket connections`);
83
+ pool.logger.info(`Successfully established ${numConnections.toString()} redundant WebSocket connections`);
84
+ return pool;
70
85
  }
71
86
  /**
72
87
  * Checks for error responses in JSON messages and throws appropriate errors
@@ -85,19 +100,14 @@ export class WebSocketPool {
85
100
  * multiple connections before forwarding to registered handlers
86
101
  */
87
102
  dedupeHandler = (data) => {
88
- // For string data, use the whole string as the cache key. This avoids expensive JSON parsing during deduping.
89
- // For binary data, use the hex string representation as the cache key
90
103
  const cacheKey = typeof data === "string"
91
104
  ? data
92
105
  : Buffer.from(data).toString("hex");
93
- // If we've seen this exact message recently, drop it
94
106
  if (this.cache.has(cacheKey)) {
95
107
  this.logger.debug("Dropping duplicate message");
96
108
  return;
97
109
  }
98
- // Haven't seen this message, cache it and forward to handlers
99
110
  this.cache.set(cacheKey, true);
100
- // Check for errors in JSON responses
101
111
  if (typeof data === "string") {
102
112
  this.handleErrorMessages(data);
103
113
  }
@@ -105,28 +115,18 @@ export class WebSocketPool {
105
115
  handler(data);
106
116
  }
107
117
  };
108
- /**
109
- * Sends a message to all websockets in the pool
110
- * @param request - The request to send
111
- */
112
118
  async sendRequest(request) {
113
- // Send to all websockets in the pool
114
119
  const sendPromises = this.rwsPool.map(async (rws) => {
115
120
  try {
116
121
  await rws.send(JSON.stringify(request));
117
122
  }
118
123
  catch (error) {
119
124
  this.logger.error("Failed to send request:", error);
120
- throw error; // Re-throw the error
125
+ throw error;
121
126
  }
122
127
  });
123
128
  await Promise.all(sendPromises);
124
129
  }
125
- /**
126
- * Adds a subscription by sending a subscribe request to all websockets in the pool
127
- * and storing it for replay on reconnection
128
- * @param request - The subscription request to send
129
- */
130
130
  async addSubscription(request) {
131
131
  if (request.type !== "subscribe") {
132
132
  throw new Error("Request must be a subscribe request");
@@ -134,11 +134,6 @@ export class WebSocketPool {
134
134
  this.subscriptions.set(request.subscriptionId, request);
135
135
  await this.sendRequest(request);
136
136
  }
137
- /**
138
- * Removes a subscription by sending an unsubscribe request to all websockets in the pool
139
- * and removing it from stored subscriptions
140
- * @param subscriptionId - The ID of the subscription to remove
141
- */
142
137
  async removeSubscription(subscriptionId) {
143
138
  this.subscriptions.delete(subscriptionId);
144
139
  const request = {
@@ -147,16 +142,35 @@ export class WebSocketPool {
147
142
  };
148
143
  await this.sendRequest(request);
149
144
  }
150
- /**
151
- * Adds a message handler function to receive websocket messages
152
- * @param handler - Function that will be called with each received message
153
- */
154
145
  addMessageListener(handler) {
155
146
  this.messageListeners.push(handler);
156
147
  }
157
148
  /**
158
- * Elegantly closes all websocket connections in the pool
149
+ * Calls the handler if all websocket connections are currently down or in reconnecting state.
150
+ * The connections may still try to reconnect in the background.
159
151
  */
152
+ addAllConnectionsDownListener(handler) {
153
+ this.allConnectionsDownListeners.push(handler);
154
+ }
155
+ areAllConnectionsDown() {
156
+ return this.rwsPool.every((ws) => !ws.isConnected || ws.isReconnecting);
157
+ }
158
+ checkConnectionStates() {
159
+ const allDown = this.areAllConnectionsDown();
160
+ // If all connections just went down
161
+ if (allDown && !this.wasAllDown) {
162
+ this.wasAllDown = true;
163
+ this.logger.error("All WebSocket connections are down or reconnecting");
164
+ // Notify all listeners
165
+ for (const listener of this.allConnectionsDownListeners) {
166
+ listener();
167
+ }
168
+ }
169
+ // If at least one connection was restored
170
+ if (!allDown && this.wasAllDown) {
171
+ this.wasAllDown = false;
172
+ }
173
+ }
160
174
  shutdown() {
161
175
  for (const rws of this.rwsPool) {
162
176
  rws.closeWebSocket();
@@ -164,5 +178,7 @@ export class WebSocketPool {
164
178
  this.rwsPool = [];
165
179
  this.subscriptions.clear();
166
180
  this.messageListeners = [];
181
+ this.allConnectionsDownListeners = [];
182
+ clearInterval(this.checkConnectionStatesInterval);
167
183
  }
168
184
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pythnetwork/pyth-lazer-sdk",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "description": "Pyth Lazer SDK",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -67,5 +67,5 @@
67
67
  "ts-log": "^2.2.7",
68
68
  "ws": "^8.18.0"
69
69
  },
70
- "gitHead": "7cb1725be86f01810025b6ceac1a8d6df2c37285"
70
+ "gitHead": "bf18253126f281a3ae0606ac748ca5496795fbc9"
71
71
  }