@pythnetwork/pyth-lazer-sdk 4.0.0 → 5.0.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.
- package/README.md +57 -0
- package/dist/cjs/client.js +43 -45
- package/dist/cjs/protocol.d.ts +3 -0
- package/dist/cjs/protocol.js +5 -1
- package/dist/cjs/socket/resilient-websocket.d.ts +15 -1
- package/dist/cjs/socket/resilient-websocket.js +19 -3
- package/dist/cjs/socket/websocket-pool.d.ts +5 -2
- package/dist/cjs/socket/websocket-pool.js +22 -12
- package/dist/cjs/util/buffer-util.d.ts +7 -0
- package/dist/cjs/util/buffer-util.js +33 -0
- package/dist/cjs/util/env-util.d.ts +17 -0
- package/dist/cjs/util/env-util.js +32 -0
- package/dist/cjs/util/index.d.ts +3 -0
- package/dist/cjs/util/index.js +19 -0
- package/dist/cjs/util/url-util.d.ts +8 -0
- package/dist/cjs/util/url-util.js +18 -0
- package/dist/esm/client.js +42 -41
- package/dist/esm/protocol.d.ts +3 -0
- package/dist/esm/protocol.js +4 -0
- package/dist/esm/socket/resilient-websocket.d.ts +15 -1
- package/dist/esm/socket/resilient-websocket.js +19 -3
- package/dist/esm/socket/websocket-pool.d.ts +5 -2
- package/dist/esm/socket/websocket-pool.js +22 -12
- package/dist/esm/util/buffer-util.d.ts +7 -0
- package/dist/esm/util/buffer-util.js +30 -0
- package/dist/esm/util/env-util.d.ts +17 -0
- package/dist/esm/util/env-util.js +27 -0
- package/dist/esm/util/index.d.ts +3 -0
- package/dist/esm/util/index.js +3 -0
- package/dist/esm/util/url-util.d.ts +8 -0
- package/dist/esm/util/url-util.js +15 -0
- package/package.json +5 -2
package/README.md
CHANGED
|
@@ -3,3 +3,60 @@
|
|
|
3
3
|
## Contributing & Development
|
|
4
4
|
|
|
5
5
|
See [contributing.md](docs/contributing/contributing.md) for information on how to develop or contribute to this project!
|
|
6
|
+
|
|
7
|
+
## How to use
|
|
8
|
+
|
|
9
|
+
```javascript
|
|
10
|
+
import { PythLazerClient } from "@pythnetwork/pyth-lazer-sdk";
|
|
11
|
+
|
|
12
|
+
const c = await PythLazerClient.create({
|
|
13
|
+
token: "YOUR-AUTH-TOKEN-HERE",
|
|
14
|
+
logger: console, // Optionally log operations (to the console in this case.)
|
|
15
|
+
webSocketPoolConfig: {
|
|
16
|
+
numConnections: 4, // 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 4.
|
|
17
|
+
onError: (error) => {
|
|
18
|
+
console.error("⛔️ WebSocket error:", error.message);
|
|
19
|
+
},
|
|
20
|
+
// Optional configuration for resilient WebSocket connections
|
|
21
|
+
rwsConfig: {
|
|
22
|
+
heartbeatTimeoutDurationMs: 5000, // Optional heartbeat timeout duration in milliseconds
|
|
23
|
+
maxRetryDelayMs: 1000, // Optional maximum retry delay in milliseconds
|
|
24
|
+
logAfterRetryCount: 10, // Optional log after how many retries
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
c.addMessageListener((message) => {
|
|
30
|
+
console.info("received the following from the Lazer stream:", message);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// Monitor for all connections in the pool being down simultaneously (e.g. if the internet goes down)
|
|
34
|
+
// The connections may still try to reconnect in the background. To shut down the client completely, call shutdown().
|
|
35
|
+
c.addAllConnectionsDownListener(() => {
|
|
36
|
+
console.error("All connections are down!");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// Create and remove one or more subscriptions on the fly
|
|
40
|
+
c.subscribe({
|
|
41
|
+
type: "subscribe",
|
|
42
|
+
subscriptionId: 1,
|
|
43
|
+
priceFeedIds: [1, 2],
|
|
44
|
+
properties: ["price"],
|
|
45
|
+
formats: ["solana"],
|
|
46
|
+
deliveryFormat: "binary",
|
|
47
|
+
channel: "fixed_rate@200ms",
|
|
48
|
+
parsed: false,
|
|
49
|
+
jsonBinaryEncoding: "base64",
|
|
50
|
+
});
|
|
51
|
+
c.subscribe({
|
|
52
|
+
type: "subscribe",
|
|
53
|
+
subscriptionId: 2,
|
|
54
|
+
priceFeedIds: [1, 2, 3, 4, 5],
|
|
55
|
+
properties: ["price", "exponent", "publisherCount", "confidence"],
|
|
56
|
+
formats: ["evm"],
|
|
57
|
+
deliveryFormat: "json",
|
|
58
|
+
channel: "fixed_rate@200ms",
|
|
59
|
+
parsed: true,
|
|
60
|
+
jsonBinaryEncoding: "hex",
|
|
61
|
+
});
|
|
62
|
+
```
|
package/dist/cjs/client.js
CHANGED
|
@@ -1,14 +1,11 @@
|
|
|
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 cross_fetch_1 = __importDefault(require("cross-fetch"));
|
|
8
4
|
const ts_log_1 = require("ts-log");
|
|
9
5
|
const constants_js_1 = require("./constants.js");
|
|
10
6
|
const protocol_js_1 = require("./protocol.js");
|
|
11
7
|
const websocket_pool_js_1 = require("./socket/websocket-pool.js");
|
|
8
|
+
const buffer_util_js_1 = require("./util/buffer-util.js");
|
|
12
9
|
const UINT16_NUM_BYTES = 2;
|
|
13
10
|
const UINT32_NUM_BYTES = 4;
|
|
14
11
|
const UINT64_NUM_BYTES = 8;
|
|
@@ -61,55 +58,56 @@ class PythLazerClient {
|
|
|
61
58
|
*/
|
|
62
59
|
addMessageListener(handler) {
|
|
63
60
|
const wsp = this.getWebSocketPool();
|
|
64
|
-
wsp.addMessageListener((data) => {
|
|
61
|
+
wsp.addMessageListener(async (data) => {
|
|
65
62
|
if (typeof data == "string") {
|
|
66
63
|
handler({
|
|
67
64
|
type: "json",
|
|
68
65
|
value: JSON.parse(data),
|
|
69
66
|
});
|
|
67
|
+
return;
|
|
70
68
|
}
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
pos
|
|
75
|
-
|
|
76
|
-
|
|
69
|
+
const buffData = await (0, buffer_util_js_1.bufferFromWebsocketData)(data);
|
|
70
|
+
let pos = 0;
|
|
71
|
+
const magic = buffData
|
|
72
|
+
.subarray(pos, pos + UINT32_NUM_BYTES)
|
|
73
|
+
.readUint32LE();
|
|
74
|
+
pos += UINT32_NUM_BYTES;
|
|
75
|
+
if (magic != protocol_js_1.BINARY_UPDATE_FORMAT_MAGIC_LE) {
|
|
76
|
+
throw new Error("binary update format magic mismatch");
|
|
77
|
+
}
|
|
78
|
+
// TODO: some uint64 values may not be representable as Number.
|
|
79
|
+
const subscriptionId = Number(buffData.subarray(pos, pos + UINT64_NUM_BYTES).readBigInt64BE());
|
|
80
|
+
pos += UINT64_NUM_BYTES;
|
|
81
|
+
const value = { subscriptionId };
|
|
82
|
+
while (pos < buffData.length) {
|
|
83
|
+
const len = buffData
|
|
84
|
+
.subarray(pos, pos + UINT16_NUM_BYTES)
|
|
85
|
+
.readUint16BE();
|
|
86
|
+
pos += UINT16_NUM_BYTES;
|
|
87
|
+
const magic = buffData
|
|
88
|
+
.subarray(pos, pos + UINT32_NUM_BYTES)
|
|
89
|
+
.readUint32LE();
|
|
90
|
+
if (magic == protocol_js_1.FORMAT_MAGICS_LE.EVM) {
|
|
91
|
+
value.evm = buffData.subarray(pos, pos + len);
|
|
77
92
|
}
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
pos += UINT64_NUM_BYTES;
|
|
81
|
-
const value = { subscriptionId };
|
|
82
|
-
while (pos < data.length) {
|
|
83
|
-
const len = data.subarray(pos, pos + UINT16_NUM_BYTES).readUint16BE();
|
|
84
|
-
pos += UINT16_NUM_BYTES;
|
|
85
|
-
const magic = data
|
|
86
|
-
.subarray(pos, pos + UINT32_NUM_BYTES)
|
|
87
|
-
.readUint32LE();
|
|
88
|
-
if (magic == protocol_js_1.FORMAT_MAGICS_LE.EVM) {
|
|
89
|
-
value.evm = data.subarray(pos, pos + len);
|
|
90
|
-
}
|
|
91
|
-
else if (magic == protocol_js_1.FORMAT_MAGICS_LE.SOLANA) {
|
|
92
|
-
value.solana = data.subarray(pos, pos + len);
|
|
93
|
-
}
|
|
94
|
-
else if (magic == protocol_js_1.FORMAT_MAGICS_LE.LE_ECDSA) {
|
|
95
|
-
value.leEcdsa = data.subarray(pos, pos + len);
|
|
96
|
-
}
|
|
97
|
-
else if (magic == protocol_js_1.FORMAT_MAGICS_LE.LE_UNSIGNED) {
|
|
98
|
-
value.leUnsigned = data.subarray(pos, pos + len);
|
|
99
|
-
}
|
|
100
|
-
else if (magic == protocol_js_1.FORMAT_MAGICS_LE.JSON) {
|
|
101
|
-
value.parsed = JSON.parse(data.subarray(pos + UINT32_NUM_BYTES, pos + len).toString());
|
|
102
|
-
}
|
|
103
|
-
else {
|
|
104
|
-
throw new Error("unknown magic: " + magic.toString());
|
|
105
|
-
}
|
|
106
|
-
pos += len;
|
|
93
|
+
else if (magic == protocol_js_1.FORMAT_MAGICS_LE.SOLANA) {
|
|
94
|
+
value.solana = buffData.subarray(pos, pos + len);
|
|
107
95
|
}
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
96
|
+
else if (magic == protocol_js_1.FORMAT_MAGICS_LE.LE_ECDSA) {
|
|
97
|
+
value.leEcdsa = buffData.subarray(pos, pos + len);
|
|
98
|
+
}
|
|
99
|
+
else if (magic == protocol_js_1.FORMAT_MAGICS_LE.LE_UNSIGNED) {
|
|
100
|
+
value.leUnsigned = buffData.subarray(pos, pos + len);
|
|
101
|
+
}
|
|
102
|
+
else if (magic == protocol_js_1.FORMAT_MAGICS_LE.JSON) {
|
|
103
|
+
value.parsed = JSON.parse(buffData.subarray(pos + UINT32_NUM_BYTES, pos + len).toString());
|
|
104
|
+
}
|
|
105
|
+
else {
|
|
106
|
+
throw new Error(`unknown magic: ${magic.toString()}`);
|
|
107
|
+
}
|
|
108
|
+
pos += len;
|
|
112
109
|
}
|
|
110
|
+
handler({ type: "binary", value });
|
|
113
111
|
});
|
|
114
112
|
}
|
|
115
113
|
subscribe(request) {
|
|
@@ -151,7 +149,7 @@ class PythLazerClient {
|
|
|
151
149
|
Authorization: `Bearer ${this.token}`,
|
|
152
150
|
...options.headers,
|
|
153
151
|
};
|
|
154
|
-
return (
|
|
152
|
+
return fetch(url, {
|
|
155
153
|
...options,
|
|
156
154
|
headers,
|
|
157
155
|
});
|
package/dist/cjs/protocol.d.ts
CHANGED
package/dist/cjs/protocol.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.FORMAT_MAGICS_LE = exports.BINARY_UPDATE_FORMAT_MAGIC_LE = void 0;
|
|
3
|
+
exports.CustomSocketClosureCodes = exports.FORMAT_MAGICS_LE = exports.BINARY_UPDATE_FORMAT_MAGIC_LE = void 0;
|
|
4
4
|
exports.BINARY_UPDATE_FORMAT_MAGIC_LE = 461_928_307;
|
|
5
5
|
exports.FORMAT_MAGICS_LE = {
|
|
6
6
|
JSON: 3_302_625_434,
|
|
@@ -9,3 +9,7 @@ exports.FORMAT_MAGICS_LE = {
|
|
|
9
9
|
LE_ECDSA: 1_296_547_300,
|
|
10
10
|
LE_UNSIGNED: 1_499_680_012,
|
|
11
11
|
};
|
|
12
|
+
var CustomSocketClosureCodes;
|
|
13
|
+
(function (CustomSocketClosureCodes) {
|
|
14
|
+
CustomSocketClosureCodes[CustomSocketClosureCodes["CLIENT_TIMEOUT_BUT_RECONNECTING"] = 4000] = "CLIENT_TIMEOUT_BUT_RECONNECTING";
|
|
15
|
+
})(CustomSocketClosureCodes || (exports.CustomSocketClosureCodes = CustomSocketClosureCodes = {}));
|
|
@@ -10,6 +10,19 @@ export type ResilientWebSocketConfig = {
|
|
|
10
10
|
maxRetryDelayMs?: number;
|
|
11
11
|
logAfterRetryCount?: number;
|
|
12
12
|
};
|
|
13
|
+
/**
|
|
14
|
+
* the isomorphic-ws package ships with some slightly-erroneous typings.
|
|
15
|
+
* namely, it returns a WebSocket with typings that indicate the "terminate()" function
|
|
16
|
+
* is available on all platforms.
|
|
17
|
+
* Given that, under the hood, it is using the globalThis.WebSocket class, if it's available,
|
|
18
|
+
* and falling back to using the https://www.npmjs.com/package/ws package, this
|
|
19
|
+
* means there are API differences between the native WebSocket (the one in a web browser)
|
|
20
|
+
* and the server-side version from the "ws" package.
|
|
21
|
+
*
|
|
22
|
+
* This type creates a WebSocket type reference we use to indicate the unknown
|
|
23
|
+
* nature of the env in which is code is run.
|
|
24
|
+
*/
|
|
25
|
+
type UnsafeWebSocket = Omit<WebSocket, "terminate"> & Partial<Pick<WebSocket, "terminate">>;
|
|
13
26
|
export declare class ResilientWebSocket {
|
|
14
27
|
private endpoint;
|
|
15
28
|
private wsOptions?;
|
|
@@ -17,7 +30,7 @@ export declare class ResilientWebSocket {
|
|
|
17
30
|
private heartbeatTimeoutDurationMs;
|
|
18
31
|
private maxRetryDelayMs;
|
|
19
32
|
private logAfterRetryCount;
|
|
20
|
-
wsClient:
|
|
33
|
+
wsClient: UnsafeWebSocket | undefined;
|
|
21
34
|
wsUserClosed: boolean;
|
|
22
35
|
private wsFailedAttempts;
|
|
23
36
|
private heartbeatTimeout?;
|
|
@@ -47,3 +60,4 @@ export declare class ResilientWebSocket {
|
|
|
47
60
|
*/
|
|
48
61
|
private retryDelayMs;
|
|
49
62
|
}
|
|
63
|
+
export {};
|
|
@@ -6,6 +6,8 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
6
6
|
exports.ResilientWebSocket = void 0;
|
|
7
7
|
const isomorphic_ws_1 = __importDefault(require("isomorphic-ws"));
|
|
8
8
|
const ts_log_1 = require("ts-log");
|
|
9
|
+
const protocol_js_1 = require("../protocol.js");
|
|
10
|
+
const env_util_js_1 = require("../util/env-util.js");
|
|
9
11
|
const DEFAULT_HEARTBEAT_TIMEOUT_DURATION_MS = 5000; // 5 seconds
|
|
10
12
|
const DEFAULT_MAX_RETRY_DELAY_MS = 1000; // 1 second'
|
|
11
13
|
const DEFAULT_LOG_AFTER_RETRY_COUNT = 10;
|
|
@@ -80,7 +82,10 @@ class ResilientWebSocket {
|
|
|
80
82
|
clearTimeout(this.retryTimeout);
|
|
81
83
|
this.retryTimeout = undefined;
|
|
82
84
|
}
|
|
83
|
-
|
|
85
|
+
// browser constructor supports a different 2nd argument for the constructor,
|
|
86
|
+
// so we need to ensure it's not included if we're running in that environment:
|
|
87
|
+
// https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/WebSocket#protocols
|
|
88
|
+
this.wsClient = new isomorphic_ws_1.default(this.endpoint, (0, env_util_js_1.envIsBrowserOrWorker)() ? undefined : this.wsOptions);
|
|
84
89
|
this.wsClient.addEventListener("open", () => {
|
|
85
90
|
this.logger.info("WebSocket connection established");
|
|
86
91
|
this.wsFailedAttempts = 0;
|
|
@@ -118,8 +123,19 @@ class ResilientWebSocket {
|
|
|
118
123
|
clearTimeout(this.heartbeatTimeout);
|
|
119
124
|
}
|
|
120
125
|
this.heartbeatTimeout = setTimeout(() => {
|
|
121
|
-
|
|
122
|
-
this.
|
|
126
|
+
const warnMsg = "Connection timed out. Reconnecting...";
|
|
127
|
+
this.logger.warn(warnMsg);
|
|
128
|
+
if (this.wsClient) {
|
|
129
|
+
if (typeof this.wsClient.terminate === "function") {
|
|
130
|
+
this.wsClient.terminate();
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
// terminate is an implementation detail of the node-friendly
|
|
134
|
+
// https://www.npmjs.com/package/ws package, but is not a native WebSocket API,
|
|
135
|
+
// so we have to use the close method
|
|
136
|
+
this.wsClient.close(protocol_js_1.CustomSocketClosureCodes.CLIENT_TIMEOUT_BUT_RECONNECTING, warnMsg);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
123
139
|
this.handleReconnect();
|
|
124
140
|
}, this.heartbeatTimeoutDurationMs);
|
|
125
141
|
}
|
|
@@ -4,6 +4,7 @@ import type { Logger } from "ts-log";
|
|
|
4
4
|
import type { Request } from "../protocol.js";
|
|
5
5
|
import type { ResilientWebSocketConfig } from "./resilient-websocket.js";
|
|
6
6
|
import { ResilientWebSocket } from "./resilient-websocket.js";
|
|
7
|
+
type WebSocketOnMessageCallback = (data: WebSocket.Data) => void | Promise<void>;
|
|
7
8
|
export type WebSocketPoolConfig = {
|
|
8
9
|
urls?: string[];
|
|
9
10
|
numConnections?: number;
|
|
@@ -33,15 +34,16 @@ export declare class WebSocketPool {
|
|
|
33
34
|
* Checks for error responses in JSON messages and throws appropriate errors
|
|
34
35
|
*/
|
|
35
36
|
private handleErrorMessages;
|
|
37
|
+
private constructCacheKeyFromWebsocketData;
|
|
36
38
|
/**
|
|
37
39
|
* Handles incoming websocket messages by deduplicating identical messages received across
|
|
38
40
|
* multiple connections before forwarding to registered handlers
|
|
39
41
|
*/
|
|
40
|
-
dedupeHandler: (data: WebSocket.Data) => void
|
|
42
|
+
dedupeHandler: (data: WebSocket.Data) => Promise<void>;
|
|
41
43
|
sendRequest(request: Request): void;
|
|
42
44
|
addSubscription(request: Request): void;
|
|
43
45
|
removeSubscription(subscriptionId: number): void;
|
|
44
|
-
addMessageListener(handler:
|
|
46
|
+
addMessageListener(handler: WebSocketOnMessageCallback): void;
|
|
45
47
|
/**
|
|
46
48
|
* Calls the handler if all websocket connections are currently down or in reconnecting state.
|
|
47
49
|
* The connections may still try to reconnect in the background.
|
|
@@ -52,3 +54,4 @@ export declare class WebSocketPool {
|
|
|
52
54
|
private checkConnectionStates;
|
|
53
55
|
shutdown(): void;
|
|
54
56
|
}
|
|
57
|
+
export {};
|
|
@@ -8,6 +8,7 @@ const ttlcache_1 = __importDefault(require("@isaacs/ttlcache"));
|
|
|
8
8
|
const ts_log_1 = require("ts-log");
|
|
9
9
|
const resilient_websocket_js_1 = require("./resilient-websocket.js");
|
|
10
10
|
const constants_js_1 = require("../constants.js");
|
|
11
|
+
const index_js_1 = require("../util/index.js");
|
|
11
12
|
const DEFAULT_NUM_CONNECTIONS = 4;
|
|
12
13
|
class WebSocketPool {
|
|
13
14
|
logger;
|
|
@@ -47,15 +48,17 @@ class WebSocketPool {
|
|
|
47
48
|
const pool = new WebSocketPool(log);
|
|
48
49
|
const numConnections = config.numConnections ?? DEFAULT_NUM_CONNECTIONS;
|
|
49
50
|
for (let i = 0; i < numConnections; i++) {
|
|
50
|
-
const
|
|
51
|
+
const baseUrl = urls[i % urls.length];
|
|
52
|
+
const isBrowser = (0, index_js_1.envIsBrowserOrWorker)();
|
|
53
|
+
const url = isBrowser
|
|
54
|
+
? (0, index_js_1.addAuthTokenToWebSocketUrl)(baseUrl, token)
|
|
55
|
+
: baseUrl;
|
|
51
56
|
if (!url) {
|
|
52
57
|
throw new Error(`URLs must not be null or empty`);
|
|
53
58
|
}
|
|
54
59
|
const wsOptions = {
|
|
55
60
|
...config.rwsConfig?.wsOptions,
|
|
56
|
-
headers: {
|
|
57
|
-
Authorization: `Bearer ${token}`,
|
|
58
|
-
},
|
|
61
|
+
headers: isBrowser ? undefined : { Authorization: `Bearer ${token}` },
|
|
59
62
|
};
|
|
60
63
|
const rws = new resilient_websocket_js_1.ResilientWebSocket({
|
|
61
64
|
...config.rwsConfig,
|
|
@@ -82,7 +85,12 @@ class WebSocketPool {
|
|
|
82
85
|
rws.onError = config.onError;
|
|
83
86
|
}
|
|
84
87
|
// Handle all client messages ourselves. Dedupe before sending to registered message handlers.
|
|
85
|
-
rws.onMessage =
|
|
88
|
+
rws.onMessage = (data) => {
|
|
89
|
+
pool.dedupeHandler(data).catch((error) => {
|
|
90
|
+
const errMsg = `An error occurred in the WebSocket pool's dedupeHandler: ${error instanceof Error ? error.message : String(error)}`;
|
|
91
|
+
throw new Error(errMsg);
|
|
92
|
+
});
|
|
93
|
+
};
|
|
86
94
|
pool.rwsPool.push(rws);
|
|
87
95
|
rws.startWebSocket();
|
|
88
96
|
}
|
|
@@ -105,14 +113,18 @@ class WebSocketPool {
|
|
|
105
113
|
throw new Error(`Error: ${message.error}`);
|
|
106
114
|
}
|
|
107
115
|
}
|
|
116
|
+
async constructCacheKeyFromWebsocketData(data) {
|
|
117
|
+
if (typeof data === "string")
|
|
118
|
+
return data;
|
|
119
|
+
const buff = await (0, index_js_1.bufferFromWebsocketData)(data);
|
|
120
|
+
return buff.toString("hex");
|
|
121
|
+
}
|
|
108
122
|
/**
|
|
109
123
|
* Handles incoming websocket messages by deduplicating identical messages received across
|
|
110
124
|
* multiple connections before forwarding to registered handlers
|
|
111
125
|
*/
|
|
112
|
-
dedupeHandler = (data) => {
|
|
113
|
-
const cacheKey =
|
|
114
|
-
? data
|
|
115
|
-
: Buffer.from(data).toString("hex");
|
|
126
|
+
dedupeHandler = async (data) => {
|
|
127
|
+
const cacheKey = await this.constructCacheKeyFromWebsocketData(data);
|
|
116
128
|
if (this.cache.has(cacheKey)) {
|
|
117
129
|
this.logger.debug("Dropping duplicate message");
|
|
118
130
|
return;
|
|
@@ -121,9 +133,7 @@ class WebSocketPool {
|
|
|
121
133
|
if (typeof data === "string") {
|
|
122
134
|
this.handleErrorMessages(data);
|
|
123
135
|
}
|
|
124
|
-
|
|
125
|
-
handler(data);
|
|
126
|
-
}
|
|
136
|
+
await Promise.all(this.messageListeners.map((handler) => handler(data)));
|
|
127
137
|
};
|
|
128
138
|
sendRequest(request) {
|
|
129
139
|
for (const rws of this.rwsPool) {
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { Data } from "isomorphic-ws";
|
|
2
|
+
/**
|
|
3
|
+
* given a relatively unknown websocket frame data object,
|
|
4
|
+
* returns a valid Buffer instance that is safe to use
|
|
5
|
+
* isomorphically in any JS runtime environment
|
|
6
|
+
*/
|
|
7
|
+
export declare function bufferFromWebsocketData(data: Data): Promise<Buffer>;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.bufferFromWebsocketData = bufferFromWebsocketData;
|
|
4
|
+
// the linting rules don't allow importing anything that might clash with
|
|
5
|
+
// a global, top-level import. we disable this rule because we need this
|
|
6
|
+
// imported from our installed dependency
|
|
7
|
+
// eslint-disable-next-line unicorn/prefer-node-protocol
|
|
8
|
+
const buffer_1 = require("buffer");
|
|
9
|
+
const BufferClassToUse = "Buffer" in globalThis ? globalThis.Buffer : buffer_1.Buffer;
|
|
10
|
+
/**
|
|
11
|
+
* given a relatively unknown websocket frame data object,
|
|
12
|
+
* returns a valid Buffer instance that is safe to use
|
|
13
|
+
* isomorphically in any JS runtime environment
|
|
14
|
+
*/
|
|
15
|
+
async function bufferFromWebsocketData(data) {
|
|
16
|
+
if (typeof data === "string") {
|
|
17
|
+
return BufferClassToUse.from(new TextEncoder().encode(data).buffer);
|
|
18
|
+
}
|
|
19
|
+
if (data instanceof BufferClassToUse)
|
|
20
|
+
return data;
|
|
21
|
+
if (data instanceof Blob) {
|
|
22
|
+
// let the uncaught promise exception bubble up if there's an issue
|
|
23
|
+
return BufferClassToUse.from(await data.arrayBuffer());
|
|
24
|
+
}
|
|
25
|
+
if (data instanceof ArrayBuffer)
|
|
26
|
+
return BufferClassToUse.from(data);
|
|
27
|
+
if (Array.isArray(data)) {
|
|
28
|
+
// an array of buffers is highly unlikely, but it is a possibility
|
|
29
|
+
// indicated by the WebSocket Data interface
|
|
30
|
+
return BufferClassToUse.concat(data);
|
|
31
|
+
}
|
|
32
|
+
return data;
|
|
33
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detects if this code is running within any Service or WebWorker context.
|
|
3
|
+
* @returns true if in a worker of some kind, false if otherwise
|
|
4
|
+
*/
|
|
5
|
+
export declare function envIsServiceOrWebWorker(): boolean;
|
|
6
|
+
/**
|
|
7
|
+
* Detects if the code is running in a regular DOM or Web Worker context.
|
|
8
|
+
* @returns true if running in a DOM or Web Worker context, false if running in Node.js
|
|
9
|
+
*/
|
|
10
|
+
export declare function envIsBrowser(): boolean;
|
|
11
|
+
/**
|
|
12
|
+
* a convenience method that returns whether or not
|
|
13
|
+
* this code is executing in some type of browser-centric environment
|
|
14
|
+
*
|
|
15
|
+
* @returns true if in the browser's main UI thread or in a worker, false if otherwise
|
|
16
|
+
*/
|
|
17
|
+
export declare function envIsBrowserOrWorker(): boolean;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.envIsServiceOrWebWorker = envIsServiceOrWebWorker;
|
|
4
|
+
exports.envIsBrowser = envIsBrowser;
|
|
5
|
+
exports.envIsBrowserOrWorker = envIsBrowserOrWorker;
|
|
6
|
+
// we create this local-only type, which has assertions made to indicate
|
|
7
|
+
// that we do not know and cannot guarantee which JS environment we are in
|
|
8
|
+
const g = globalThis;
|
|
9
|
+
/**
|
|
10
|
+
* Detects if this code is running within any Service or WebWorker context.
|
|
11
|
+
* @returns true if in a worker of some kind, false if otherwise
|
|
12
|
+
*/
|
|
13
|
+
function envIsServiceOrWebWorker() {
|
|
14
|
+
return (typeof WorkerGlobalScope !== "undefined" &&
|
|
15
|
+
g.self instanceof WorkerGlobalScope);
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Detects if the code is running in a regular DOM or Web Worker context.
|
|
19
|
+
* @returns true if running in a DOM or Web Worker context, false if running in Node.js
|
|
20
|
+
*/
|
|
21
|
+
function envIsBrowser() {
|
|
22
|
+
return g.window !== undefined;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* a convenience method that returns whether or not
|
|
26
|
+
* this code is executing in some type of browser-centric environment
|
|
27
|
+
*
|
|
28
|
+
* @returns true if in the browser's main UI thread or in a worker, false if otherwise
|
|
29
|
+
*/
|
|
30
|
+
function envIsBrowserOrWorker() {
|
|
31
|
+
return envIsServiceOrWebWorker() || envIsBrowser();
|
|
32
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
14
|
+
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
15
|
+
};
|
|
16
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
+
__exportStar(require("./buffer-util.js"), exports);
|
|
18
|
+
__exportStar(require("./env-util.js"), exports);
|
|
19
|
+
__exportStar(require("./url-util.js"), exports);
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Given a URL to a hosted lazer stream service and a possible auth token,
|
|
3
|
+
* appends the auth token as a query parameter and returns the URL with the token
|
|
4
|
+
* contained within.
|
|
5
|
+
* If the URL provided is nullish, it is returned as-is (in the same nullish format).
|
|
6
|
+
* If the token is nullish, the baseUrl given is returned, instead.
|
|
7
|
+
*/
|
|
8
|
+
export declare function addAuthTokenToWebSocketUrl(baseUrl: string | null | undefined, authToken: string | null | undefined): string | null | undefined;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.addAuthTokenToWebSocketUrl = addAuthTokenToWebSocketUrl;
|
|
4
|
+
const ACCESS_TOKEN_QUERY_PARAM_KEY = "ACCESS_TOKEN";
|
|
5
|
+
/**
|
|
6
|
+
* Given a URL to a hosted lazer stream service and a possible auth token,
|
|
7
|
+
* appends the auth token as a query parameter and returns the URL with the token
|
|
8
|
+
* contained within.
|
|
9
|
+
* If the URL provided is nullish, it is returned as-is (in the same nullish format).
|
|
10
|
+
* If the token is nullish, the baseUrl given is returned, instead.
|
|
11
|
+
*/
|
|
12
|
+
function addAuthTokenToWebSocketUrl(baseUrl, authToken) {
|
|
13
|
+
if (!baseUrl || !authToken)
|
|
14
|
+
return baseUrl;
|
|
15
|
+
const parsedUrl = new URL(baseUrl);
|
|
16
|
+
parsedUrl.searchParams.set(ACCESS_TOKEN_QUERY_PARAM_KEY, authToken);
|
|
17
|
+
return parsedUrl.toString();
|
|
18
|
+
}
|
package/dist/esm/client.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import fetch from "cross-fetch";
|
|
2
1
|
import WebSocket from "isomorphic-ws";
|
|
3
2
|
import { dummyLogger } from "ts-log";
|
|
4
3
|
import { DEFAULT_METADATA_SERVICE_URL, DEFAULT_PRICE_SERVICE_URL, } from "./constants.js";
|
|
5
4
|
import { BINARY_UPDATE_FORMAT_MAGIC_LE, FORMAT_MAGICS_LE } from "./protocol.js";
|
|
6
5
|
import { WebSocketPool } from "./socket/websocket-pool.js";
|
|
6
|
+
import { bufferFromWebsocketData } from "./util/buffer-util.js";
|
|
7
7
|
const UINT16_NUM_BYTES = 2;
|
|
8
8
|
const UINT32_NUM_BYTES = 4;
|
|
9
9
|
const UINT64_NUM_BYTES = 8;
|
|
@@ -56,55 +56,56 @@ export class PythLazerClient {
|
|
|
56
56
|
*/
|
|
57
57
|
addMessageListener(handler) {
|
|
58
58
|
const wsp = this.getWebSocketPool();
|
|
59
|
-
wsp.addMessageListener((data) => {
|
|
59
|
+
wsp.addMessageListener(async (data) => {
|
|
60
60
|
if (typeof data == "string") {
|
|
61
61
|
handler({
|
|
62
62
|
type: "json",
|
|
63
63
|
value: JSON.parse(data),
|
|
64
64
|
});
|
|
65
|
+
return;
|
|
65
66
|
}
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
pos
|
|
70
|
-
|
|
71
|
-
|
|
67
|
+
const buffData = await bufferFromWebsocketData(data);
|
|
68
|
+
let pos = 0;
|
|
69
|
+
const magic = buffData
|
|
70
|
+
.subarray(pos, pos + UINT32_NUM_BYTES)
|
|
71
|
+
.readUint32LE();
|
|
72
|
+
pos += UINT32_NUM_BYTES;
|
|
73
|
+
if (magic != BINARY_UPDATE_FORMAT_MAGIC_LE) {
|
|
74
|
+
throw new Error("binary update format magic mismatch");
|
|
75
|
+
}
|
|
76
|
+
// TODO: some uint64 values may not be representable as Number.
|
|
77
|
+
const subscriptionId = Number(buffData.subarray(pos, pos + UINT64_NUM_BYTES).readBigInt64BE());
|
|
78
|
+
pos += UINT64_NUM_BYTES;
|
|
79
|
+
const value = { subscriptionId };
|
|
80
|
+
while (pos < buffData.length) {
|
|
81
|
+
const len = buffData
|
|
82
|
+
.subarray(pos, pos + UINT16_NUM_BYTES)
|
|
83
|
+
.readUint16BE();
|
|
84
|
+
pos += UINT16_NUM_BYTES;
|
|
85
|
+
const magic = buffData
|
|
86
|
+
.subarray(pos, pos + UINT32_NUM_BYTES)
|
|
87
|
+
.readUint32LE();
|
|
88
|
+
if (magic == FORMAT_MAGICS_LE.EVM) {
|
|
89
|
+
value.evm = buffData.subarray(pos, pos + len);
|
|
72
90
|
}
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
pos += UINT64_NUM_BYTES;
|
|
76
|
-
const value = { subscriptionId };
|
|
77
|
-
while (pos < data.length) {
|
|
78
|
-
const len = data.subarray(pos, pos + UINT16_NUM_BYTES).readUint16BE();
|
|
79
|
-
pos += UINT16_NUM_BYTES;
|
|
80
|
-
const magic = data
|
|
81
|
-
.subarray(pos, pos + UINT32_NUM_BYTES)
|
|
82
|
-
.readUint32LE();
|
|
83
|
-
if (magic == FORMAT_MAGICS_LE.EVM) {
|
|
84
|
-
value.evm = data.subarray(pos, pos + len);
|
|
85
|
-
}
|
|
86
|
-
else if (magic == FORMAT_MAGICS_LE.SOLANA) {
|
|
87
|
-
value.solana = data.subarray(pos, pos + len);
|
|
88
|
-
}
|
|
89
|
-
else if (magic == FORMAT_MAGICS_LE.LE_ECDSA) {
|
|
90
|
-
value.leEcdsa = data.subarray(pos, pos + len);
|
|
91
|
-
}
|
|
92
|
-
else if (magic == FORMAT_MAGICS_LE.LE_UNSIGNED) {
|
|
93
|
-
value.leUnsigned = data.subarray(pos, pos + len);
|
|
94
|
-
}
|
|
95
|
-
else if (magic == FORMAT_MAGICS_LE.JSON) {
|
|
96
|
-
value.parsed = JSON.parse(data.subarray(pos + UINT32_NUM_BYTES, pos + len).toString());
|
|
97
|
-
}
|
|
98
|
-
else {
|
|
99
|
-
throw new Error("unknown magic: " + magic.toString());
|
|
100
|
-
}
|
|
101
|
-
pos += len;
|
|
91
|
+
else if (magic == FORMAT_MAGICS_LE.SOLANA) {
|
|
92
|
+
value.solana = buffData.subarray(pos, pos + len);
|
|
102
93
|
}
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
94
|
+
else if (magic == FORMAT_MAGICS_LE.LE_ECDSA) {
|
|
95
|
+
value.leEcdsa = buffData.subarray(pos, pos + len);
|
|
96
|
+
}
|
|
97
|
+
else if (magic == FORMAT_MAGICS_LE.LE_UNSIGNED) {
|
|
98
|
+
value.leUnsigned = buffData.subarray(pos, pos + len);
|
|
99
|
+
}
|
|
100
|
+
else if (magic == FORMAT_MAGICS_LE.JSON) {
|
|
101
|
+
value.parsed = JSON.parse(buffData.subarray(pos + UINT32_NUM_BYTES, pos + len).toString());
|
|
102
|
+
}
|
|
103
|
+
else {
|
|
104
|
+
throw new Error(`unknown magic: ${magic.toString()}`);
|
|
105
|
+
}
|
|
106
|
+
pos += len;
|
|
107
107
|
}
|
|
108
|
+
handler({ type: "binary", value });
|
|
108
109
|
});
|
|
109
110
|
}
|
|
110
111
|
subscribe(request) {
|
package/dist/esm/protocol.d.ts
CHANGED
package/dist/esm/protocol.js
CHANGED
|
@@ -6,3 +6,7 @@ export const FORMAT_MAGICS_LE = {
|
|
|
6
6
|
LE_ECDSA: 1_296_547_300,
|
|
7
7
|
LE_UNSIGNED: 1_499_680_012,
|
|
8
8
|
};
|
|
9
|
+
export var CustomSocketClosureCodes;
|
|
10
|
+
(function (CustomSocketClosureCodes) {
|
|
11
|
+
CustomSocketClosureCodes[CustomSocketClosureCodes["CLIENT_TIMEOUT_BUT_RECONNECTING"] = 4000] = "CLIENT_TIMEOUT_BUT_RECONNECTING";
|
|
12
|
+
})(CustomSocketClosureCodes || (CustomSocketClosureCodes = {}));
|
|
@@ -10,6 +10,19 @@ export type ResilientWebSocketConfig = {
|
|
|
10
10
|
maxRetryDelayMs?: number;
|
|
11
11
|
logAfterRetryCount?: number;
|
|
12
12
|
};
|
|
13
|
+
/**
|
|
14
|
+
* the isomorphic-ws package ships with some slightly-erroneous typings.
|
|
15
|
+
* namely, it returns a WebSocket with typings that indicate the "terminate()" function
|
|
16
|
+
* is available on all platforms.
|
|
17
|
+
* Given that, under the hood, it is using the globalThis.WebSocket class, if it's available,
|
|
18
|
+
* and falling back to using the https://www.npmjs.com/package/ws package, this
|
|
19
|
+
* means there are API differences between the native WebSocket (the one in a web browser)
|
|
20
|
+
* and the server-side version from the "ws" package.
|
|
21
|
+
*
|
|
22
|
+
* This type creates a WebSocket type reference we use to indicate the unknown
|
|
23
|
+
* nature of the env in which is code is run.
|
|
24
|
+
*/
|
|
25
|
+
type UnsafeWebSocket = Omit<WebSocket, "terminate"> & Partial<Pick<WebSocket, "terminate">>;
|
|
13
26
|
export declare class ResilientWebSocket {
|
|
14
27
|
private endpoint;
|
|
15
28
|
private wsOptions?;
|
|
@@ -17,7 +30,7 @@ export declare class ResilientWebSocket {
|
|
|
17
30
|
private heartbeatTimeoutDurationMs;
|
|
18
31
|
private maxRetryDelayMs;
|
|
19
32
|
private logAfterRetryCount;
|
|
20
|
-
wsClient:
|
|
33
|
+
wsClient: UnsafeWebSocket | undefined;
|
|
21
34
|
wsUserClosed: boolean;
|
|
22
35
|
private wsFailedAttempts;
|
|
23
36
|
private heartbeatTimeout?;
|
|
@@ -47,3 +60,4 @@ export declare class ResilientWebSocket {
|
|
|
47
60
|
*/
|
|
48
61
|
private retryDelayMs;
|
|
49
62
|
}
|
|
63
|
+
export {};
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import WebSocket from "isomorphic-ws";
|
|
2
2
|
import { dummyLogger } from "ts-log";
|
|
3
|
+
import { CustomSocketClosureCodes } from "../protocol.js";
|
|
4
|
+
import { envIsBrowserOrWorker } from "../util/env-util.js";
|
|
3
5
|
const DEFAULT_HEARTBEAT_TIMEOUT_DURATION_MS = 5000; // 5 seconds
|
|
4
6
|
const DEFAULT_MAX_RETRY_DELAY_MS = 1000; // 1 second'
|
|
5
7
|
const DEFAULT_LOG_AFTER_RETRY_COUNT = 10;
|
|
@@ -74,7 +76,10 @@ export class ResilientWebSocket {
|
|
|
74
76
|
clearTimeout(this.retryTimeout);
|
|
75
77
|
this.retryTimeout = undefined;
|
|
76
78
|
}
|
|
77
|
-
|
|
79
|
+
// browser constructor supports a different 2nd argument for the constructor,
|
|
80
|
+
// so we need to ensure it's not included if we're running in that environment:
|
|
81
|
+
// https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/WebSocket#protocols
|
|
82
|
+
this.wsClient = new WebSocket(this.endpoint, envIsBrowserOrWorker() ? undefined : this.wsOptions);
|
|
78
83
|
this.wsClient.addEventListener("open", () => {
|
|
79
84
|
this.logger.info("WebSocket connection established");
|
|
80
85
|
this.wsFailedAttempts = 0;
|
|
@@ -112,8 +117,19 @@ export class ResilientWebSocket {
|
|
|
112
117
|
clearTimeout(this.heartbeatTimeout);
|
|
113
118
|
}
|
|
114
119
|
this.heartbeatTimeout = setTimeout(() => {
|
|
115
|
-
|
|
116
|
-
this.
|
|
120
|
+
const warnMsg = "Connection timed out. Reconnecting...";
|
|
121
|
+
this.logger.warn(warnMsg);
|
|
122
|
+
if (this.wsClient) {
|
|
123
|
+
if (typeof this.wsClient.terminate === "function") {
|
|
124
|
+
this.wsClient.terminate();
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
// terminate is an implementation detail of the node-friendly
|
|
128
|
+
// https://www.npmjs.com/package/ws package, but is not a native WebSocket API,
|
|
129
|
+
// so we have to use the close method
|
|
130
|
+
this.wsClient.close(CustomSocketClosureCodes.CLIENT_TIMEOUT_BUT_RECONNECTING, warnMsg);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
117
133
|
this.handleReconnect();
|
|
118
134
|
}, this.heartbeatTimeoutDurationMs);
|
|
119
135
|
}
|
|
@@ -4,6 +4,7 @@ import type { Logger } from "ts-log";
|
|
|
4
4
|
import type { Request } from "../protocol.js";
|
|
5
5
|
import type { ResilientWebSocketConfig } from "./resilient-websocket.js";
|
|
6
6
|
import { ResilientWebSocket } from "./resilient-websocket.js";
|
|
7
|
+
type WebSocketOnMessageCallback = (data: WebSocket.Data) => void | Promise<void>;
|
|
7
8
|
export type WebSocketPoolConfig = {
|
|
8
9
|
urls?: string[];
|
|
9
10
|
numConnections?: number;
|
|
@@ -33,15 +34,16 @@ export declare class WebSocketPool {
|
|
|
33
34
|
* Checks for error responses in JSON messages and throws appropriate errors
|
|
34
35
|
*/
|
|
35
36
|
private handleErrorMessages;
|
|
37
|
+
private constructCacheKeyFromWebsocketData;
|
|
36
38
|
/**
|
|
37
39
|
* Handles incoming websocket messages by deduplicating identical messages received across
|
|
38
40
|
* multiple connections before forwarding to registered handlers
|
|
39
41
|
*/
|
|
40
|
-
dedupeHandler: (data: WebSocket.Data) => void
|
|
42
|
+
dedupeHandler: (data: WebSocket.Data) => Promise<void>;
|
|
41
43
|
sendRequest(request: Request): void;
|
|
42
44
|
addSubscription(request: Request): void;
|
|
43
45
|
removeSubscription(subscriptionId: number): void;
|
|
44
|
-
addMessageListener(handler:
|
|
46
|
+
addMessageListener(handler: WebSocketOnMessageCallback): void;
|
|
45
47
|
/**
|
|
46
48
|
* Calls the handler if all websocket connections are currently down or in reconnecting state.
|
|
47
49
|
* The connections may still try to reconnect in the background.
|
|
@@ -52,3 +54,4 @@ export declare class WebSocketPool {
|
|
|
52
54
|
private checkConnectionStates;
|
|
53
55
|
shutdown(): void;
|
|
54
56
|
}
|
|
57
|
+
export {};
|
|
@@ -3,6 +3,7 @@ import WebSocket from "isomorphic-ws";
|
|
|
3
3
|
import { dummyLogger } from "ts-log";
|
|
4
4
|
import { ResilientWebSocket } from "./resilient-websocket.js";
|
|
5
5
|
import { DEFAULT_STREAM_SERVICE_0_URL, DEFAULT_STREAM_SERVICE_1_URL, } from "../constants.js";
|
|
6
|
+
import { addAuthTokenToWebSocketUrl, bufferFromWebsocketData, envIsBrowserOrWorker, } from "../util/index.js";
|
|
6
7
|
const DEFAULT_NUM_CONNECTIONS = 4;
|
|
7
8
|
export class WebSocketPool {
|
|
8
9
|
logger;
|
|
@@ -42,15 +43,17 @@ export class WebSocketPool {
|
|
|
42
43
|
const pool = new WebSocketPool(log);
|
|
43
44
|
const numConnections = config.numConnections ?? DEFAULT_NUM_CONNECTIONS;
|
|
44
45
|
for (let i = 0; i < numConnections; i++) {
|
|
45
|
-
const
|
|
46
|
+
const baseUrl = urls[i % urls.length];
|
|
47
|
+
const isBrowser = envIsBrowserOrWorker();
|
|
48
|
+
const url = isBrowser
|
|
49
|
+
? addAuthTokenToWebSocketUrl(baseUrl, token)
|
|
50
|
+
: baseUrl;
|
|
46
51
|
if (!url) {
|
|
47
52
|
throw new Error(`URLs must not be null or empty`);
|
|
48
53
|
}
|
|
49
54
|
const wsOptions = {
|
|
50
55
|
...config.rwsConfig?.wsOptions,
|
|
51
|
-
headers: {
|
|
52
|
-
Authorization: `Bearer ${token}`,
|
|
53
|
-
},
|
|
56
|
+
headers: isBrowser ? undefined : { Authorization: `Bearer ${token}` },
|
|
54
57
|
};
|
|
55
58
|
const rws = new ResilientWebSocket({
|
|
56
59
|
...config.rwsConfig,
|
|
@@ -77,7 +80,12 @@ export class WebSocketPool {
|
|
|
77
80
|
rws.onError = config.onError;
|
|
78
81
|
}
|
|
79
82
|
// Handle all client messages ourselves. Dedupe before sending to registered message handlers.
|
|
80
|
-
rws.onMessage =
|
|
83
|
+
rws.onMessage = (data) => {
|
|
84
|
+
pool.dedupeHandler(data).catch((error) => {
|
|
85
|
+
const errMsg = `An error occurred in the WebSocket pool's dedupeHandler: ${error instanceof Error ? error.message : String(error)}`;
|
|
86
|
+
throw new Error(errMsg);
|
|
87
|
+
});
|
|
88
|
+
};
|
|
81
89
|
pool.rwsPool.push(rws);
|
|
82
90
|
rws.startWebSocket();
|
|
83
91
|
}
|
|
@@ -100,14 +108,18 @@ export class WebSocketPool {
|
|
|
100
108
|
throw new Error(`Error: ${message.error}`);
|
|
101
109
|
}
|
|
102
110
|
}
|
|
111
|
+
async constructCacheKeyFromWebsocketData(data) {
|
|
112
|
+
if (typeof data === "string")
|
|
113
|
+
return data;
|
|
114
|
+
const buff = await bufferFromWebsocketData(data);
|
|
115
|
+
return buff.toString("hex");
|
|
116
|
+
}
|
|
103
117
|
/**
|
|
104
118
|
* Handles incoming websocket messages by deduplicating identical messages received across
|
|
105
119
|
* multiple connections before forwarding to registered handlers
|
|
106
120
|
*/
|
|
107
|
-
dedupeHandler = (data) => {
|
|
108
|
-
const cacheKey =
|
|
109
|
-
? data
|
|
110
|
-
: Buffer.from(data).toString("hex");
|
|
121
|
+
dedupeHandler = async (data) => {
|
|
122
|
+
const cacheKey = await this.constructCacheKeyFromWebsocketData(data);
|
|
111
123
|
if (this.cache.has(cacheKey)) {
|
|
112
124
|
this.logger.debug("Dropping duplicate message");
|
|
113
125
|
return;
|
|
@@ -116,9 +128,7 @@ export class WebSocketPool {
|
|
|
116
128
|
if (typeof data === "string") {
|
|
117
129
|
this.handleErrorMessages(data);
|
|
118
130
|
}
|
|
119
|
-
|
|
120
|
-
handler(data);
|
|
121
|
-
}
|
|
131
|
+
await Promise.all(this.messageListeners.map((handler) => handler(data)));
|
|
122
132
|
};
|
|
123
133
|
sendRequest(request) {
|
|
124
134
|
for (const rws of this.rwsPool) {
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { Data } from "isomorphic-ws";
|
|
2
|
+
/**
|
|
3
|
+
* given a relatively unknown websocket frame data object,
|
|
4
|
+
* returns a valid Buffer instance that is safe to use
|
|
5
|
+
* isomorphically in any JS runtime environment
|
|
6
|
+
*/
|
|
7
|
+
export declare function bufferFromWebsocketData(data: Data): Promise<Buffer>;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// the linting rules don't allow importing anything that might clash with
|
|
2
|
+
// a global, top-level import. we disable this rule because we need this
|
|
3
|
+
// imported from our installed dependency
|
|
4
|
+
// eslint-disable-next-line unicorn/prefer-node-protocol
|
|
5
|
+
import { Buffer as BrowserBuffer } from "buffer";
|
|
6
|
+
const BufferClassToUse = "Buffer" in globalThis ? globalThis.Buffer : BrowserBuffer;
|
|
7
|
+
/**
|
|
8
|
+
* given a relatively unknown websocket frame data object,
|
|
9
|
+
* returns a valid Buffer instance that is safe to use
|
|
10
|
+
* isomorphically in any JS runtime environment
|
|
11
|
+
*/
|
|
12
|
+
export async function bufferFromWebsocketData(data) {
|
|
13
|
+
if (typeof data === "string") {
|
|
14
|
+
return BufferClassToUse.from(new TextEncoder().encode(data).buffer);
|
|
15
|
+
}
|
|
16
|
+
if (data instanceof BufferClassToUse)
|
|
17
|
+
return data;
|
|
18
|
+
if (data instanceof Blob) {
|
|
19
|
+
// let the uncaught promise exception bubble up if there's an issue
|
|
20
|
+
return BufferClassToUse.from(await data.arrayBuffer());
|
|
21
|
+
}
|
|
22
|
+
if (data instanceof ArrayBuffer)
|
|
23
|
+
return BufferClassToUse.from(data);
|
|
24
|
+
if (Array.isArray(data)) {
|
|
25
|
+
// an array of buffers is highly unlikely, but it is a possibility
|
|
26
|
+
// indicated by the WebSocket Data interface
|
|
27
|
+
return BufferClassToUse.concat(data);
|
|
28
|
+
}
|
|
29
|
+
return data;
|
|
30
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detects if this code is running within any Service or WebWorker context.
|
|
3
|
+
* @returns true if in a worker of some kind, false if otherwise
|
|
4
|
+
*/
|
|
5
|
+
export declare function envIsServiceOrWebWorker(): boolean;
|
|
6
|
+
/**
|
|
7
|
+
* Detects if the code is running in a regular DOM or Web Worker context.
|
|
8
|
+
* @returns true if running in a DOM or Web Worker context, false if running in Node.js
|
|
9
|
+
*/
|
|
10
|
+
export declare function envIsBrowser(): boolean;
|
|
11
|
+
/**
|
|
12
|
+
* a convenience method that returns whether or not
|
|
13
|
+
* this code is executing in some type of browser-centric environment
|
|
14
|
+
*
|
|
15
|
+
* @returns true if in the browser's main UI thread or in a worker, false if otherwise
|
|
16
|
+
*/
|
|
17
|
+
export declare function envIsBrowserOrWorker(): boolean;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// we create this local-only type, which has assertions made to indicate
|
|
2
|
+
// that we do not know and cannot guarantee which JS environment we are in
|
|
3
|
+
const g = globalThis;
|
|
4
|
+
/**
|
|
5
|
+
* Detects if this code is running within any Service or WebWorker context.
|
|
6
|
+
* @returns true if in a worker of some kind, false if otherwise
|
|
7
|
+
*/
|
|
8
|
+
export function envIsServiceOrWebWorker() {
|
|
9
|
+
return (typeof WorkerGlobalScope !== "undefined" &&
|
|
10
|
+
g.self instanceof WorkerGlobalScope);
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Detects if the code is running in a regular DOM or Web Worker context.
|
|
14
|
+
* @returns true if running in a DOM or Web Worker context, false if running in Node.js
|
|
15
|
+
*/
|
|
16
|
+
export function envIsBrowser() {
|
|
17
|
+
return g.window !== undefined;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* a convenience method that returns whether or not
|
|
21
|
+
* this code is executing in some type of browser-centric environment
|
|
22
|
+
*
|
|
23
|
+
* @returns true if in the browser's main UI thread or in a worker, false if otherwise
|
|
24
|
+
*/
|
|
25
|
+
export function envIsBrowserOrWorker() {
|
|
26
|
+
return envIsServiceOrWebWorker() || envIsBrowser();
|
|
27
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Given a URL to a hosted lazer stream service and a possible auth token,
|
|
3
|
+
* appends the auth token as a query parameter and returns the URL with the token
|
|
4
|
+
* contained within.
|
|
5
|
+
* If the URL provided is nullish, it is returned as-is (in the same nullish format).
|
|
6
|
+
* If the token is nullish, the baseUrl given is returned, instead.
|
|
7
|
+
*/
|
|
8
|
+
export declare function addAuthTokenToWebSocketUrl(baseUrl: string | null | undefined, authToken: string | null | undefined): string | null | undefined;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
const ACCESS_TOKEN_QUERY_PARAM_KEY = "ACCESS_TOKEN";
|
|
2
|
+
/**
|
|
3
|
+
* Given a URL to a hosted lazer stream service and a possible auth token,
|
|
4
|
+
* appends the auth token as a query parameter and returns the URL with the token
|
|
5
|
+
* contained within.
|
|
6
|
+
* If the URL provided is nullish, it is returned as-is (in the same nullish format).
|
|
7
|
+
* If the token is nullish, the baseUrl given is returned, instead.
|
|
8
|
+
*/
|
|
9
|
+
export function addAuthTokenToWebSocketUrl(baseUrl, authToken) {
|
|
10
|
+
if (!baseUrl || !authToken)
|
|
11
|
+
return baseUrl;
|
|
12
|
+
const parsedUrl = new URL(baseUrl);
|
|
13
|
+
parsedUrl.searchParams.set(ACCESS_TOKEN_QUERY_PARAM_KEY, authToken);
|
|
14
|
+
return parsedUrl.toString();
|
|
15
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pythnetwork/pyth-lazer-sdk",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "5.0.0",
|
|
4
4
|
"description": "Pyth Lazer SDK",
|
|
5
|
+
"engines": {
|
|
6
|
+
"node": ">=22"
|
|
7
|
+
},
|
|
5
8
|
"publishConfig": {
|
|
6
9
|
"access": "public"
|
|
7
10
|
},
|
|
@@ -48,7 +51,7 @@
|
|
|
48
51
|
"license": "Apache-2.0",
|
|
49
52
|
"dependencies": {
|
|
50
53
|
"@isaacs/ttlcache": "^1.4.1",
|
|
51
|
-
"
|
|
54
|
+
"buffer": "^6.0.3",
|
|
52
55
|
"isomorphic-ws": "^5.0.0",
|
|
53
56
|
"ts-log": "^2.2.7",
|
|
54
57
|
"ws": "^8.18.0"
|