@pythnetwork/pyth-lazer-sdk 0.1.1 → 0.2.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.
@@ -0,0 +1,168 @@
1
+ import TTLCache from "@isaacs/ttlcache";
2
+ import WebSocket from "isomorphic-ws";
3
+ import { dummyLogger } from "ts-log";
4
+ import { ResilientWebSocket } from "./resilient-web-socket.js";
5
+ // Number of redundant parallel WebSocket connections
6
+ const DEFAULT_NUM_CONNECTIONS = 3;
7
+ export class WebSocketPool {
8
+ logger;
9
+ rwsPool;
10
+ cache;
11
+ subscriptions; // id -> subscription Request
12
+ messageListeners;
13
+ /**
14
+ * Creates a new WebSocketPool instance that uses multiple redundant WebSocket connections for reliability.
15
+ * Usage semantics are similar to using a regular WebSocket client.
16
+ * @param urls - List of WebSocket URLs to connect to
17
+ * @param token - Authentication token to use for the connections
18
+ * @param numConnections - Number of parallel WebSocket connections to maintain (default: 3)
19
+ * @param logger - Optional logger to get socket level logs. Compatible with most loggers such as the built-in console and `bunyan`.
20
+ */
21
+ constructor(urls, token, numConnections = DEFAULT_NUM_CONNECTIONS, logger = dummyLogger) {
22
+ this.logger = logger;
23
+ if (urls.length === 0) {
24
+ throw new Error("No URLs provided");
25
+ }
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 = [];
33
+ for (let i = 0; i < numConnections; i++) {
34
+ const url = urls[i % urls.length];
35
+ if (!url) {
36
+ throw new Error(`URLs must not be null or empty`);
37
+ }
38
+ const wsOptions = {
39
+ headers: {
40
+ Authorization: `Bearer ${token}`,
41
+ },
42
+ };
43
+ const rws = new ResilientWebSocket(url, wsOptions, logger);
44
+ // If a websocket client unexpectedly disconnects, ResilientWebSocket will reestablish
45
+ // the connection and call the onReconnect callback.
46
+ // When we reconnect, replay all subscription messages to resume the data stream.
47
+ rws.onReconnect = () => {
48
+ if (rws.wsUserClosed) {
49
+ return;
50
+ }
51
+ for (const [, request] of this.subscriptions) {
52
+ try {
53
+ void rws.send(JSON.stringify(request));
54
+ }
55
+ catch (error) {
56
+ this.logger.error("Failed to resend subscription on reconnect:", error);
57
+ }
58
+ }
59
+ };
60
+ // Handle all client messages ourselves. Dedupe before sending to registered message handlers.
61
+ rws.onMessage = this.dedupeHandler;
62
+ this.rwsPool.push(rws);
63
+ }
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();
68
+ }
69
+ this.logger.info(`Using ${numConnections.toString()} redundant WebSocket connections`);
70
+ }
71
+ /**
72
+ * Checks for error responses in JSON messages and throws appropriate errors
73
+ */
74
+ handleErrorMessages(data) {
75
+ const message = JSON.parse(data);
76
+ if (message.type === "subscriptionError") {
77
+ throw new Error(`Error occurred for subscription ID ${String(message.subscriptionId)}: ${message.error}`);
78
+ }
79
+ else if (message.type === "error") {
80
+ throw new Error(`Error: ${message.error}`);
81
+ }
82
+ }
83
+ /**
84
+ * Handles incoming websocket messages by deduplicating identical messages received across
85
+ * multiple connections before forwarding to registered handlers
86
+ */
87
+ 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
+ const cacheKey = typeof data === "string"
91
+ ? data
92
+ : Buffer.from(data).toString("hex");
93
+ // If we've seen this exact message recently, drop it
94
+ if (this.cache.has(cacheKey)) {
95
+ this.logger.debug("Dropping duplicate message");
96
+ return;
97
+ }
98
+ // Haven't seen this message, cache it and forward to handlers
99
+ this.cache.set(cacheKey, true);
100
+ // Check for errors in JSON responses
101
+ if (typeof data === "string") {
102
+ this.handleErrorMessages(data);
103
+ }
104
+ for (const handler of this.messageListeners) {
105
+ handler(data);
106
+ }
107
+ };
108
+ /**
109
+ * Sends a message to all websockets in the pool
110
+ * @param request - The request to send
111
+ */
112
+ async sendRequest(request) {
113
+ // Send to all websockets in the pool
114
+ const sendPromises = this.rwsPool.map(async (rws) => {
115
+ try {
116
+ await rws.send(JSON.stringify(request));
117
+ }
118
+ catch (error) {
119
+ this.logger.error("Failed to send request:", error);
120
+ throw error; // Re-throw the error
121
+ }
122
+ });
123
+ await Promise.all(sendPromises);
124
+ }
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
+ async addSubscription(request) {
131
+ if (request.type !== "subscribe") {
132
+ throw new Error("Request must be a subscribe request");
133
+ }
134
+ this.subscriptions.set(request.subscriptionId, request);
135
+ await this.sendRequest(request);
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
+ async removeSubscription(subscriptionId) {
143
+ this.subscriptions.delete(subscriptionId);
144
+ const request = {
145
+ type: "unsubscribe",
146
+ subscriptionId,
147
+ };
148
+ await this.sendRequest(request);
149
+ }
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
+ addMessageListener(handler) {
155
+ this.messageListeners.push(handler);
156
+ }
157
+ /**
158
+ * Elegantly closes all websocket connections in the pool
159
+ */
160
+ shutdown() {
161
+ for (const rws of this.rwsPool) {
162
+ rws.closeWebSocket();
163
+ }
164
+ this.rwsPool = [];
165
+ this.subscriptions.clear();
166
+ this.messageListeners = [];
167
+ }
168
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pythnetwork/pyth-lazer-sdk",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Pyth Lazer SDK",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -60,8 +60,12 @@
60
60
  ],
61
61
  "license": "Apache-2.0",
62
62
  "dependencies": {
63
+ "@isaacs/ttlcache": "^1.4.1",
64
+ "@solana/buffer-layout": "^4.0.1",
65
+ "@solana/web3.js": "^1.98.0",
63
66
  "isomorphic-ws": "^5.0.0",
67
+ "ts-log": "^2.2.7",
64
68
  "ws": "^8.18.0"
65
69
  },
66
- "gitHead": "83f4174d8235fb6095c347366fd432fb95307162"
70
+ "gitHead": "7cb1725be86f01810025b6ceac1a8d6df2c37285"
67
71
  }