@shapeshiftoss/unchained-client 9.1.1 → 9.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.
@@ -2,9 +2,9 @@
2
2
  import type { ErrorResponse, Topics, TxsTopicData } from '@shapeshiftoss/common-api';
3
3
  import WebSocket from 'isomorphic-ws';
4
4
  export interface Connection {
5
- ws: WebSocket;
5
+ ws?: WebSocket;
6
6
  pingTimeout?: NodeJS.Timeout;
7
- interval: NodeJS.Timeout;
7
+ interval?: NodeJS.Timeout;
8
8
  }
9
9
  export interface TransactionMessage<T> {
10
10
  address: string;
@@ -12,7 +12,7 @@ export interface TransactionMessage<T> {
12
12
  subscriptionId: string;
13
13
  }
14
14
  export interface TxsParams<T> {
15
- data: TxsTopicData | undefined;
15
+ data: TxsTopicData;
16
16
  onMessage: (message: TransactionMessage<T>) => void;
17
17
  onError?: (err: ErrorResponse) => void;
18
18
  }
@@ -21,9 +21,17 @@ export declare class Client<T> {
21
21
  private readonly url;
22
22
  private readonly connections;
23
23
  private readonly pingInterval;
24
+ private readonly retryAttempts;
25
+ private retryCount;
24
26
  private txs;
25
27
  constructor(url: string);
28
+ private initialize;
29
+ private onOpen;
30
+ private onClose;
31
+ private onError;
32
+ private onMessage;
26
33
  private heartbeat;
34
+ private getDefaultSubscriptionId;
27
35
  /**
28
36
  * Subscribe to transaction history and updates for newly confirmed and pending transactions.
29
37
  *
package/dist/websocket.js CHANGED
@@ -10,25 +10,119 @@ const logger = new logger_1.Logger({
10
10
  namespace: ['unchained-client', 'websocket'],
11
11
  level: process.env.LOG_LEVEL
12
12
  });
13
+ const NORMAL_CLOSURE_CODE = 1000;
13
14
  class Client {
14
15
  constructor(url) {
15
16
  this.pingInterval = 10000;
17
+ this.retryAttempts = 5;
18
+ this.retryCount = 0;
16
19
  this.txs = {};
17
20
  this.url = url;
18
21
  this.connections = {
19
22
  txs: undefined
20
23
  };
21
24
  }
25
+ async initialize() {
26
+ const ws = new isomorphic_ws_1.default(this.url);
27
+ this.connections.txs = { ws };
28
+ // wait for connection to open with handshake timeout
29
+ await new Promise((resolve, reject) => {
30
+ setTimeout(() => reject(new Error('timeout while trying to connect')), 5000);
31
+ ws.onopen = ({ target }) => this.onOpen(target, resolve, reject);
32
+ ws.onclose = (event) => this.onClose(event, resolve, reject);
33
+ ws.onerror = (event) => this.onError(event);
34
+ ws.onmessage = (event) => this.onMessage(event);
35
+ });
36
+ }
37
+ onOpen(ws, resolve, reject) {
38
+ this.retryCount = 0;
39
+ const interval = setInterval(() => {
40
+ // browsers do not support ping/pong frame, send ping payload instead
41
+ const payload = { subscriptionId: '', method: 'ping' };
42
+ ws.send(JSON.stringify(payload));
43
+ }, this.pingInterval);
44
+ this.heartbeat('txs');
45
+ if (this.connections.txs) {
46
+ this.connections.txs.interval = interval;
47
+ }
48
+ else {
49
+ this.connections.txs = { ws, interval };
50
+ }
51
+ try {
52
+ // subscribe to all subscriptions
53
+ Object.entries(this.txs).forEach(([id, { data }]) => {
54
+ const payload = { subscriptionId: id, method: 'subscribe', data };
55
+ ws.send(JSON.stringify(payload));
56
+ });
57
+ resolve(true);
58
+ }
59
+ catch (err) {
60
+ reject(err);
61
+ }
62
+ }
63
+ onClose(event, resolve, reject) {
64
+ var _a, _b;
65
+ logger.warn({ fn: 'onClose', code: event.code, reason: event.reason, type: event.type }, 'websocket closed');
66
+ ((_a = this.connections.txs) === null || _a === void 0 ? void 0 : _a.pingTimeout) && clearTimeout(this.connections.txs.pingTimeout);
67
+ ((_b = this.connections.txs) === null || _b === void 0 ? void 0 : _b.interval) && clearInterval(this.connections.txs.interval);
68
+ // attempt reconnect logic on non standard exit codes
69
+ if (event.code !== NORMAL_CLOSURE_CODE) {
70
+ if (++this.retryCount >= this.retryAttempts) {
71
+ reject(new Error('failed to reconnect, connection closed'));
72
+ return;
73
+ }
74
+ setTimeout(() => this.initialize(), 500 * this.retryCount ** 2);
75
+ }
76
+ resolve(false);
77
+ }
78
+ onError(event) {
79
+ if (!event.message)
80
+ return;
81
+ logger.error({ fn: 'onError', err: event.error, message: event.message, type: event.type }, 'websocket error');
82
+ // send connection errors to all subscription onError handlers
83
+ Object.entries(this.txs).forEach(([id, sub]) => {
84
+ var _a;
85
+ (_a = sub.onError) === null || _a === void 0 ? void 0 : _a.call(sub, { subscriptionId: id, type: 'error', message: event.message });
86
+ });
87
+ }
88
+ onMessage(event) {
89
+ var _a, _b, _c, _d;
90
+ if (event.type !== 'message')
91
+ return;
92
+ // trigger heartbeat keep alive on pong response
93
+ if (event.data === 'pong') {
94
+ this.heartbeat('txs');
95
+ return;
96
+ }
97
+ try {
98
+ const message = JSON.parse(event.data.toString());
99
+ const subscriptionId = this.getDefaultSubscriptionId();
100
+ // narrow type to ErrorResponse if key `type` exists and forward to correct onError handler
101
+ if ('type' in message) {
102
+ return (_b = (_a = this.txs[message.subscriptionId || subscriptionId]) === null || _a === void 0 ? void 0 : _a.onError) === null || _b === void 0 ? void 0 : _b.call(_a, message);
103
+ }
104
+ // forward the transaction message to the correct onMessage handler
105
+ (_d = (_c = this.txs[message.subscriptionId || subscriptionId]) === null || _c === void 0 ? void 0 : _c.onMessage) === null || _d === void 0 ? void 0 : _d.call(_c, message);
106
+ }
107
+ catch (err) {
108
+ logger.warn(`failed to handle onmessage event: ${JSON.stringify(event)}: ${err}`);
109
+ }
110
+ }
22
111
  heartbeat(topic) {
23
112
  const connection = this.connections[topic];
24
113
  if (!connection)
25
114
  return;
26
115
  connection.pingTimeout && clearTimeout(connection.pingTimeout);
27
116
  connection.pingTimeout = setTimeout(() => {
117
+ var _a;
28
118
  logger.warn({ fn: 'pingTimeout' }, `${topic} heartbeat failed`);
29
- connection === null || connection === void 0 ? void 0 : connection.ws.close();
119
+ (_a = connection.ws) === null || _a === void 0 ? void 0 : _a.close();
30
120
  }, this.pingInterval + 5000);
31
121
  }
122
+ // for websocket connections that do not return the subscriptionId on the message, use the initial subscriptionId as the default
123
+ getDefaultSubscriptionId() {
124
+ return Object.keys(this.txs)[0];
125
+ }
32
126
  /**
33
127
  * Subscribe to transaction history and updates for newly confirmed and pending transactions.
34
128
  *
@@ -42,95 +136,20 @@ class Client {
42
136
  * @param [onError] optional handler for any error messages associated with `subscriptionId`
43
137
  */
44
138
  async subscribeTxs(subscriptionId, data, onMessage, onError) {
45
- // keep track of the onMessage and onError handlers associated with each subscriptionId
46
- this.txs[subscriptionId] = { onMessage, onError, data: undefined };
47
- if (this.connections.txs) {
48
- if (this.connections.txs.ws.readyState === 1) {
49
- // subscribe if connection exists and is ready
50
- this.connections.txs.ws.send(JSON.stringify({ subscriptionId, method: 'subscribe', data }));
51
- }
52
- else {
53
- // queue up unique addresses for subscriptions if connection exists, but is not ready yet
54
- const txsData = this.txs[subscriptionId].data;
55
- this.txs[subscriptionId].data = txsData
56
- ? { ...txsData, addresses: [...new Set(...txsData.addresses, ...data.addresses)] }
57
- : data;
58
- }
59
- return;
139
+ var _a, _b, _c, _d;
140
+ const addresses = [
141
+ ...new Set([...((_c = (_b = (_a = this.txs[subscriptionId]) === null || _a === void 0 ? void 0 : _a.data) === null || _b === void 0 ? void 0 : _b.addresses) !== null && _c !== void 0 ? _c : []), ...data.addresses])
142
+ ];
143
+ // keep track of all subscribed addresses and onMessage/onError handlers associated with each subscriptionId
144
+ this.txs[subscriptionId] = { onMessage, onError, data: { topic: 'txs', addresses } };
145
+ // initialize websocket connection if one does not already exist
146
+ if (!((_d = this.connections.txs) === null || _d === void 0 ? void 0 : _d.ws)) {
147
+ return this.initialize();
148
+ }
149
+ // subscribe if connection exists and is ready
150
+ if (this.connections.txs.ws.readyState === isomorphic_ws_1.default.OPEN) {
151
+ this.connections.txs.ws.send(JSON.stringify({ subscriptionId, method: 'subscribe', data }));
60
152
  }
61
- const ws = new isomorphic_ws_1.default(this.url);
62
- const interval = setInterval(() => {
63
- // browsers do not support ping/pong frame, send ping payload instead
64
- const payload = { subscriptionId: '', method: 'ping' };
65
- ws.send(JSON.stringify(payload));
66
- }, this.pingInterval);
67
- this.connections.txs = { ws, interval };
68
- // send connection errors to all subscription onError handlers
69
- ws.onerror = (event) => {
70
- Object.entries(this.txs).forEach(([id, sub]) => {
71
- sub.onError && sub.onError({ subscriptionId: id, type: 'error', message: event.message });
72
- });
73
- };
74
- // clear heartbeat timeout on close
75
- ws.onclose = () => {
76
- var _a, _b;
77
- ((_a = this.connections.txs) === null || _a === void 0 ? void 0 : _a.pingTimeout) && clearTimeout(this.connections.txs.pingTimeout);
78
- ((_b = this.connections.txs) === null || _b === void 0 ? void 0 : _b.interval) && clearInterval(this.connections.txs.interval);
79
- };
80
- ws.onmessage = (event) => {
81
- var _a, _b;
82
- if (!event)
83
- return;
84
- if (event.type !== 'message')
85
- return;
86
- if (event.data === 'pong') {
87
- // trigger heartbeat keep alive on pong response
88
- this.heartbeat('txs');
89
- return;
90
- }
91
- try {
92
- const message = JSON.parse(event.data.toString());
93
- // narrow type to ErrorResponse if key `type` exists and forward to correct onError handler
94
- if ('type' in message) {
95
- const onErrorHandler = (_a = this.txs[message.subscriptionId || subscriptionId]) === null || _a === void 0 ? void 0 : _a.onError;
96
- onErrorHandler && onErrorHandler(message);
97
- return;
98
- }
99
- // forward the transaction message to the correct onMessage handler
100
- const onMessageHandler = (_b = this.txs[message.subscriptionId || subscriptionId]) === null || _b === void 0 ? void 0 : _b.onMessage;
101
- onMessageHandler && onMessageHandler(message);
102
- }
103
- catch (err) {
104
- logger.warn(`failed to handle onmessage event: ${JSON.stringify(event)}: ${err}`);
105
- }
106
- };
107
- // wait for the connection to open
108
- const openConnection = new Promise((resolve, reject) => {
109
- ws.onopen = () => {
110
- this.heartbeat('txs');
111
- try {
112
- // subscribe to all queued subscriptions
113
- Object.values(this.txs).forEach((tx) => {
114
- if (!tx.data)
115
- return;
116
- const payload = { subscriptionId, method: 'subscribe', data: tx.data };
117
- ws.send(JSON.stringify(payload));
118
- delete this.txs[subscriptionId].data;
119
- });
120
- // subscribe to initial subscription
121
- const payload = { subscriptionId, method: 'subscribe', data };
122
- ws.send(JSON.stringify(payload));
123
- resolve(true);
124
- }
125
- catch (err) {
126
- reject(err);
127
- }
128
- };
129
- });
130
- const connectionTimeout = new Promise((_, reject) => {
131
- setTimeout(() => reject(new Error('timeout while trying to connect')), 5000);
132
- });
133
- await Promise.race([openConnection, connectionTimeout]);
134
153
  }
135
154
  /**
136
155
  * Unsubscribe from transaction history and updates for newly confirmed and pending transactions.
@@ -146,19 +165,27 @@ class Client {
146
165
  * @param data.addresses list of addresses to unsubscribe from
147
166
  */
148
167
  unsubscribeTxs(subscriptionId, data) {
149
- var _a, _b, _c;
150
- if (((_a = this.connections.txs) === null || _a === void 0 ? void 0 : _a.ws.readyState) !== isomorphic_ws_1.default.OPEN)
168
+ var _a, _b, _c, _d, _e;
169
+ if (((_b = (_a = this.connections.txs) === null || _a === void 0 ? void 0 : _a.ws) === null || _b === void 0 ? void 0 : _b.readyState) !== isomorphic_ws_1.default.OPEN)
151
170
  return;
152
171
  if (!subscriptionId)
153
172
  this.txs = {};
154
- if (subscriptionId && !(data === null || data === void 0 ? void 0 : data.addresses.length))
155
- delete this.txs[subscriptionId];
173
+ if (subscriptionId && this.txs[subscriptionId]) {
174
+ // unsubscribe addresses from the current subscribed address set if addresses provided
175
+ if ((_c = data === null || data === void 0 ? void 0 : data.addresses) === null || _c === void 0 ? void 0 : _c.length) {
176
+ this.txs[subscriptionId].data.addresses = this.txs[subscriptionId].data.addresses.filter((address) => !data.addresses.includes(address));
177
+ }
178
+ else {
179
+ // delete subscription if no addresses provided
180
+ delete this.txs[subscriptionId];
181
+ }
182
+ }
156
183
  const payload = {
157
184
  subscriptionId: subscriptionId !== null && subscriptionId !== void 0 ? subscriptionId : '',
158
185
  method: 'unsubscribe',
159
- data: { topic: 'txs', addresses: (_b = data === null || data === void 0 ? void 0 : data.addresses) !== null && _b !== void 0 ? _b : [] }
186
+ data: { topic: 'txs', addresses: (_d = data === null || data === void 0 ? void 0 : data.addresses) !== null && _d !== void 0 ? _d : [] }
160
187
  };
161
- (_c = this.connections.txs) === null || _c === void 0 ? void 0 : _c.ws.send(JSON.stringify(payload));
188
+ (_e = this.connections.txs) === null || _e === void 0 ? void 0 : _e.ws.send(JSON.stringify(payload));
162
189
  }
163
190
  /**
164
191
  * Close and unsubscribe from any subscriptions
@@ -169,9 +196,9 @@ class Client {
169
196
  */
170
197
  close(topic) {
171
198
  const closeTxs = () => {
172
- var _a;
199
+ var _a, _b;
173
200
  this.unsubscribeTxs();
174
- (_a = this.connections.txs) === null || _a === void 0 ? void 0 : _a.ws.close();
201
+ (_b = (_a = this.connections.txs) === null || _a === void 0 ? void 0 : _a.ws) === null || _b === void 0 ? void 0 : _b.close(NORMAL_CLOSURE_CODE);
175
202
  };
176
203
  // close all connections if no topic is provided
177
204
  if (!topic) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shapeshiftoss/unchained-client",
3
- "version": "9.1.1",
3
+ "version": "9.2.0",
4
4
  "license": "MIT",
5
5
  "main": "dist/index.js",
6
6
  "source": "src/index.ts",