Skip to content

Commit 7cb1725

Browse files
feat(lazer): Improve JS SDK reliability via redundant parallel websocket cxns (#2236)
* feat: improve js sdk reliability via redundant parallel websocket cxns * doc: update comment * feat: add error handler, bump ver * feat: improve promise handling, fix eslint * fix: eslint * doc: fix comment
1 parent 74e976f commit 7cb1725

File tree

6 files changed

+503
-46
lines changed

6 files changed

+503
-46
lines changed

lazer/sdk/js/examples/index.ts

Lines changed: 46 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,24 @@
1+
/* eslint-disable no-console */
2+
/* eslint-disable @typescript-eslint/no-empty-function */
3+
14
import { PythLazerClient } from "../src/index.js";
25

3-
/* eslint-disable no-console */
4-
const client = new PythLazerClient("ws://127.0.0.1:1234/v1/stream", "ctoken1");
6+
// Ignore debug messages
7+
console.debug = () => {};
8+
9+
const client = new PythLazerClient(
10+
["wss://pyth-lazer.dourolabs.app/v1/stream"],
11+
"access_token",
12+
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.
13+
console // Optionally log socket operations (to the console in this case.)
14+
);
15+
516
client.addMessageListener((message) => {
6-
console.log("got message:", message);
17+
console.info("got message:", message);
718
switch (message.type) {
819
case "json": {
920
if (message.value.type == "streamUpdated") {
10-
console.log(
21+
console.info(
1122
"stream updated for subscription",
1223
message.value.subscriptionId,
1324
":",
@@ -18,24 +29,42 @@ client.addMessageListener((message) => {
1829
}
1930
case "binary": {
2031
if ("solana" in message.value) {
21-
console.log("solana message:", message.value.solana?.toString("hex"));
32+
console.info("solana message:", message.value.solana?.toString("hex"));
2233
}
2334
if ("evm" in message.value) {
24-
console.log("evm message:", message.value.evm?.toString("hex"));
35+
console.info("evm message:", message.value.evm?.toString("hex"));
2536
}
2637
break;
2738
}
2839
}
2940
});
30-
client.ws.addEventListener("open", () => {
31-
client.send({
32-
type: "subscribe",
33-
subscriptionId: 1,
34-
priceFeedIds: [1, 2],
35-
properties: ["price"],
36-
chains: ["solana"],
37-
deliveryFormat: "json",
38-
channel: "fixed_rate@200ms",
39-
jsonBinaryEncoding: "hex",
40-
});
41+
42+
// Create and remove one or more subscriptions on the fly
43+
await client.subscribe({
44+
type: "subscribe",
45+
subscriptionId: 1,
46+
priceFeedIds: [1, 2],
47+
properties: ["price"],
48+
chains: ["solana"],
49+
deliveryFormat: "binary",
50+
channel: "fixed_rate@200ms",
51+
parsed: false,
52+
jsonBinaryEncoding: "base64",
53+
});
54+
await client.subscribe({
55+
type: "subscribe",
56+
subscriptionId: 2,
57+
priceFeedIds: [1, 2, 3, 4, 5],
58+
properties: ["price"],
59+
chains: ["evm"],
60+
deliveryFormat: "json",
61+
channel: "fixed_rate@200ms",
62+
parsed: true,
63+
jsonBinaryEncoding: "hex",
4164
});
65+
66+
await new Promise((resolve) => setTimeout(resolve, 10_000));
67+
68+
await client.unsubscribe(1);
69+
await client.unsubscribe(2);
70+
client.shutdown();

lazer/sdk/js/package.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@pythnetwork/pyth-lazer-sdk",
3-
"version": "0.1.2",
3+
"version": "0.2.0",
44
"description": "Pyth Lazer SDK",
55
"publishConfig": {
66
"access": "public"
@@ -63,6 +63,8 @@
6363
"@solana/buffer-layout": "^4.0.1",
6464
"@solana/web3.js": "^1.98.0",
6565
"isomorphic-ws": "^5.0.0",
66-
"ws": "^8.18.0"
66+
"ws": "^8.18.0",
67+
"@isaacs/ttlcache": "^1.4.1",
68+
"ts-log": "^2.2.7"
6769
}
6870
}

lazer/sdk/js/src/client.ts

Lines changed: 46 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import WebSocket from "isomorphic-ws";
2+
import { dummyLogger, type Logger } from "ts-log";
23

34
import {
45
BINARY_UPDATE_FORMAT_MAGIC,
@@ -9,6 +10,7 @@ import {
910
type Response,
1011
SOLANA_FORMAT_MAGIC_BE,
1112
} from "./protocol.js";
13+
import { WebSocketPool } from "./socket/web-socket-pool.js";
1214

1315
export type BinaryResponse = {
1416
subscriptionId: number;
@@ -28,52 +30,58 @@ const UINT32_NUM_BYTES = 4;
2830
const UINT64_NUM_BYTES = 8;
2931

3032
export class PythLazerClient {
31-
ws: WebSocket;
33+
wsp: WebSocketPool;
3234

33-
constructor(url: string, token: string) {
34-
const finalUrl = new URL(url);
35-
finalUrl.searchParams.append("ACCESS_TOKEN", token);
36-
this.ws = new WebSocket(finalUrl);
35+
/**
36+
* Creates a new PythLazerClient instance.
37+
* @param urls - List of WebSocket URLs of the Pyth Lazer service
38+
* @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.
40+
* @param logger - Optional logger to get socket level logs. Compatible with most loggers such as the built-in console and `bunyan`.
41+
*/
42+
constructor(
43+
urls: string[],
44+
token: string,
45+
numConnections = 3,
46+
logger: Logger = dummyLogger
47+
) {
48+
this.wsp = new WebSocketPool(urls, token, numConnections, logger);
3749
}
3850

3951
addMessageListener(handler: (event: JsonOrBinaryResponse) => void) {
40-
this.ws.addEventListener("message", (event: WebSocket.MessageEvent) => {
41-
if (typeof event.data == "string") {
52+
this.wsp.addMessageListener((data: WebSocket.Data) => {
53+
if (typeof data == "string") {
4254
handler({
4355
type: "json",
44-
value: JSON.parse(event.data) as Response,
56+
value: JSON.parse(data) as Response,
4557
});
46-
} else if (Buffer.isBuffer(event.data)) {
58+
} else if (Buffer.isBuffer(data)) {
4759
let pos = 0;
48-
const magic = event.data
49-
.subarray(pos, pos + UINT32_NUM_BYTES)
50-
.readUint32BE();
60+
const magic = data.subarray(pos, pos + UINT32_NUM_BYTES).readUint32BE();
5161
pos += UINT32_NUM_BYTES;
5262
if (magic != BINARY_UPDATE_FORMAT_MAGIC) {
5363
throw new Error("binary update format magic mismatch");
5464
}
5565
// TODO: some uint64 values may not be representable as Number.
5666
const subscriptionId = Number(
57-
event.data.subarray(pos, pos + UINT64_NUM_BYTES).readBigInt64BE()
67+
data.subarray(pos, pos + UINT64_NUM_BYTES).readBigInt64BE()
5868
);
5969
pos += UINT64_NUM_BYTES;
6070

6171
const value: BinaryResponse = { subscriptionId };
62-
while (pos < event.data.length) {
63-
const len = event.data
64-
.subarray(pos, pos + UINT16_NUM_BYTES)
65-
.readUint16BE();
72+
while (pos < data.length) {
73+
const len = data.subarray(pos, pos + UINT16_NUM_BYTES).readUint16BE();
6674
pos += UINT16_NUM_BYTES;
67-
const magic = event.data
75+
const magic = data
6876
.subarray(pos, pos + UINT32_NUM_BYTES)
6977
.readUint32BE();
7078
if (magic == EVM_FORMAT_MAGIC) {
71-
value.evm = event.data.subarray(pos, pos + len);
79+
value.evm = data.subarray(pos, pos + len);
7280
} else if (magic == SOLANA_FORMAT_MAGIC_BE) {
73-
value.solana = event.data.subarray(pos, pos + len);
81+
value.solana = data.subarray(pos, pos + len);
7482
} else if (magic == PARSED_FORMAT_MAGIC) {
7583
value.parsed = JSON.parse(
76-
event.data.subarray(pos + UINT32_NUM_BYTES, pos + len).toString()
84+
data.subarray(pos + UINT32_NUM_BYTES, pos + len).toString()
7785
) as ParsedPayload;
7886
} else {
7987
throw new Error("unknown magic: " + magic.toString());
@@ -87,7 +95,22 @@ export class PythLazerClient {
8795
});
8896
}
8997

90-
send(request: Request) {
91-
this.ws.send(JSON.stringify(request));
98+
async subscribe(request: Request): Promise<void> {
99+
if (request.type !== "subscribe") {
100+
throw new Error("Request must be a subscribe request");
101+
}
102+
await this.wsp.addSubscription(request);
103+
}
104+
105+
async unsubscribe(subscriptionId: number): Promise<void> {
106+
await this.wsp.removeSubscription(subscriptionId);
107+
}
108+
109+
async send(request: Request): Promise<void> {
110+
await this.wsp.sendRequest(request);
111+
}
112+
113+
shutdown(): void {
114+
this.wsp.shutdown();
92115
}
93116
}

0 commit comments

Comments
 (0)