Skip to content

Commit 1c65304

Browse files
feat(lazer/js-sdk): add promises for open and disconnected (#2254)
* feat: add promise for connection open * feat: add promise for all connections down * refactor: rename socket files * fix: lint * feat: bump ver * feat: better interface for allConnectionsDown events * fix: docs * fix: naming
1 parent 8bf0ad2 commit 1c65304

File tree

5 files changed

+160
-77
lines changed

5 files changed

+160
-77
lines changed

lazer/sdk/js/examples/index.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,14 @@ import { PythLazerClient } from "../src/index.js";
66
// Ignore debug messages
77
console.debug = () => {};
88

9-
const client = new PythLazerClient(
9+
const client = await PythLazerClient.create(
1010
["wss://pyth-lazer.dourolabs.app/v1/stream"],
1111
"access_token",
1212
3, // Optionally specify number of parallel redundant connections to reduce the chance of dropped messages. The connections will round-robin across the provided URLs. Default is 3.
1313
console // Optionally log socket operations (to the console in this case.)
1414
);
1515

16+
// Read and process messages from the Lazer stream
1617
client.addMessageListener((message) => {
1718
console.info("got message:", message);
1819
switch (message.type) {
@@ -39,6 +40,12 @@ client.addMessageListener((message) => {
3940
}
4041
});
4142

43+
// Monitor for all connections in the pool being down simultaneously (e.g. if the internet goes down)
44+
// The connections may still try to reconnect in the background. To shut down the client completely, call shutdown().
45+
client.addAllConnectionsDownListener(() => {
46+
console.error("All connections are down!");
47+
});
48+
4249
// Create and remove one or more subscriptions on the fly
4350
await client.subscribe({
4451
type: "subscribe",

lazer/sdk/js/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@pythnetwork/pyth-lazer-sdk",
3-
"version": "0.2.1",
3+
"version": "0.3.0",
44
"description": "Pyth Lazer SDK",
55
"publishConfig": {
66
"access": "public"

lazer/sdk/js/src/client.ts

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010
type Response,
1111
SOLANA_FORMAT_MAGIC_BE,
1212
} from "./protocol.js";
13-
import { WebSocketPool } from "./socket/web-socket-pool.js";
13+
import { WebSocketPool } from "./socket/websocket-pool.js";
1414

1515
export type BinaryResponse = {
1616
subscriptionId: number;
@@ -30,24 +30,31 @@ const UINT32_NUM_BYTES = 4;
3030
const UINT64_NUM_BYTES = 8;
3131

3232
export class PythLazerClient {
33-
wsp: WebSocketPool;
33+
private constructor(private readonly wsp: WebSocketPool) {}
3434

3535
/**
3636
* Creates a new PythLazerClient instance.
3737
* @param urls - List of WebSocket URLs of the Pyth Lazer service
3838
* @param token - The access token for authentication
39-
* @param numConnections - The number of parallel WebSocket connections to establish (default: 3). A higher number gives a more reliable stream.
39+
* @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.
4040
* @param logger - Optional logger to get socket level logs. Compatible with most loggers such as the built-in console and `bunyan`.
4141
*/
42-
constructor(
42+
static async create(
4343
urls: string[],
4444
token: string,
4545
numConnections = 3,
4646
logger: Logger = dummyLogger
47-
) {
48-
this.wsp = new WebSocketPool(urls, token, numConnections, logger);
47+
): Promise<PythLazerClient> {
48+
const wsp = await WebSocketPool.create(urls, token, numConnections, logger);
49+
return new PythLazerClient(wsp);
4950
}
5051

52+
/**
53+
* Adds a message listener that receives either JSON or binary responses from the WebSocket connections.
54+
* The listener will be called for each message received, with deduplication across redundant connections.
55+
* @param handler - Callback function that receives the parsed message. The message can be either a JSON response
56+
* or a binary response containing EVM, Solana, or parsed payload data.
57+
*/
5158
addMessageListener(handler: (event: JsonOrBinaryResponse) => void) {
5259
this.wsp.addMessageListener((data: WebSocket.Data) => {
5360
if (typeof data == "string") {
@@ -110,6 +117,15 @@ export class PythLazerClient {
110117
await this.wsp.sendRequest(request);
111118
}
112119

120+
/**
121+
* Registers a handler function that will be called whenever all WebSocket connections are down or attempting to reconnect.
122+
* The connections may still try to reconnect in the background. To shut down the pool, call `shutdown()`.
123+
* @param handler - Function to be called when all connections are down
124+
*/
125+
addAllConnectionsDownListener(handler: () => void): void {
126+
this.wsp.addAllConnectionsDownListener(handler);
127+
}
128+
113129
shutdown(): void {
114130
this.wsp.shutdown();
115131
}

lazer/sdk/js/src/socket/resilient-web-socket.ts renamed to lazer/sdk/js/src/socket/resilient-websocket.ts

Lines changed: 55 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,9 @@ import type { ClientRequestArgs } from "node:http";
33
import WebSocket, { type ClientOptions, type ErrorEvent } from "isomorphic-ws";
44
import type { Logger } from "ts-log";
55

6-
// Reconnect with expo backoff if we don't get a message or ping for 10 seconds
76
const HEARTBEAT_TIMEOUT_DURATION = 10_000;
7+
const CONNECTION_TIMEOUT = 5000;
88

9-
/**
10-
* This class wraps websocket to provide a resilient web socket client.
11-
*
12-
* It will reconnect if connection fails with exponential backoff. Also, it will reconnect
13-
* if it receives no ping request or regular message from server within a while as indication
14-
* of timeout (assuming the server sends either regularly).
15-
*
16-
* This class also logs events if logger is given and by replacing onError method you can handle
17-
* connection errors yourself (e.g: do not retry and close the connection).
18-
*/
199
export class ResilientWebSocket {
2010
endpoint: string;
2111
wsClient: undefined | WebSocket;
@@ -24,10 +14,23 @@ export class ResilientWebSocket {
2414
private wsFailedAttempts: number;
2515
private heartbeatTimeout: undefined | NodeJS.Timeout;
2616
private logger: undefined | Logger;
17+
private connectionPromise: Promise<void> | undefined;
18+
private resolveConnection: (() => void) | undefined;
19+
private rejectConnection: ((error: Error) => void) | undefined;
20+
private _isReconnecting = false;
21+
22+
get isReconnecting(): boolean {
23+
return this._isReconnecting;
24+
}
25+
26+
get isConnected(): boolean {
27+
return this.wsClient?.readyState === WebSocket.OPEN;
28+
}
2729

2830
onError: (error: ErrorEvent) => void;
2931
onMessage: (data: WebSocket.Data) => void;
3032
onReconnect: () => void;
33+
3134
constructor(
3235
endpoint: string,
3336
wsOptions?: ClientOptions | ClientRequestArgs,
@@ -64,23 +67,48 @@ export class ResilientWebSocket {
6467
}
6568
}
6669

67-
startWebSocket(): void {
70+
async startWebSocket(): Promise<void> {
6871
if (this.wsClient !== undefined) {
72+
// If there's an existing connection attempt, wait for it
73+
if (this.connectionPromise) {
74+
return this.connectionPromise;
75+
}
6976
return;
7077
}
7178

7279
this.logger?.info(`Creating Web Socket client`);
7380

81+
// Create a new promise for this connection attempt
82+
this.connectionPromise = new Promise((resolve, reject) => {
83+
this.resolveConnection = resolve;
84+
this.rejectConnection = reject;
85+
});
86+
87+
// Set a connection timeout
88+
const timeoutId = setTimeout(() => {
89+
if (this.rejectConnection) {
90+
this.rejectConnection(
91+
new Error(`Connection timeout after ${String(CONNECTION_TIMEOUT)}ms`)
92+
);
93+
}
94+
}, CONNECTION_TIMEOUT);
95+
7496
this.wsClient = new WebSocket(this.endpoint, this.wsOptions);
7597
this.wsUserClosed = false;
7698

7799
this.wsClient.addEventListener("open", () => {
78100
this.wsFailedAttempts = 0;
79101
this.resetHeartbeat();
102+
clearTimeout(timeoutId);
103+
this._isReconnecting = false;
104+
this.resolveConnection?.();
80105
});
81106

82107
this.wsClient.addEventListener("error", (event) => {
83108
this.onError(event);
109+
if (this.rejectConnection) {
110+
this.rejectConnection(new Error("WebSocket connection failed"));
111+
}
84112
});
85113

86114
this.wsClient.addEventListener("message", (event) => {
@@ -89,24 +117,23 @@ export class ResilientWebSocket {
89117
});
90118

91119
this.wsClient.addEventListener("close", () => {
120+
clearTimeout(timeoutId);
121+
if (this.rejectConnection) {
122+
this.rejectConnection(new Error("WebSocket closed before connecting"));
123+
}
92124
void this.handleClose();
93125
});
94126

95-
// Handle ping events if supported (Node.js only)
96127
if ("on" in this.wsClient) {
97-
// Ping handler is undefined in browser side
98128
this.wsClient.on("ping", () => {
99129
this.logger?.info("Ping received");
100130
this.resetHeartbeat();
101131
});
102132
}
133+
134+
return this.connectionPromise;
103135
}
104136

105-
/**
106-
* Reset the heartbeat timeout. This is called when we receive any message (ping or regular)
107-
* from the server. If we don't receive any message within HEARTBEAT_TIMEOUT_DURATION,
108-
* we assume the connection is dead and reconnect.
109-
*/
110137
private resetHeartbeat(): void {
111138
if (this.heartbeatTimeout !== undefined) {
112139
clearTimeout(this.heartbeatTimeout);
@@ -145,8 +172,13 @@ export class ResilientWebSocket {
145172
} else {
146173
this.wsFailedAttempts += 1;
147174
this.wsClient = undefined;
175+
this.connectionPromise = undefined;
176+
this.resolveConnection = undefined;
177+
this.rejectConnection = undefined;
178+
148179
const waitTime = expoBackoff(this.wsFailedAttempts);
149180

181+
this._isReconnecting = true;
150182
this.logger?.error(
151183
"Connection closed unexpectedly or because of timeout. Reconnecting after " +
152184
String(waitTime) +
@@ -163,7 +195,7 @@ export class ResilientWebSocket {
163195
return;
164196
}
165197

166-
this.startWebSocket();
198+
await this.startWebSocket();
167199
await this.waitForMaybeReadyWebSocket();
168200

169201
if (this.wsClient === undefined) {
@@ -180,6 +212,9 @@ export class ResilientWebSocket {
180212
if (this.wsClient !== undefined) {
181213
const client = this.wsClient;
182214
this.wsClient = undefined;
215+
this.connectionPromise = undefined;
216+
this.resolveConnection = undefined;
217+
this.rejectConnection = undefined;
183218
client.close();
184219
}
185220
this.wsUserClosed = true;

0 commit comments

Comments
 (0)