@pythnetwork/pyth-lazer-sdk 0.1.2 → 0.3.0

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