@nevuamarkets/poly-websockets 0.2.2 → 0.3.1

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 CHANGED
@@ -90,6 +90,12 @@ Clears all subscriptions and state:
90
90
  - Closes all WebSocket connections
91
91
  - Clears the internal order book cache
92
92
 
93
+ ##### `getStatistics(): { openWebSockets: number; subscribedAssetIds: number }`
94
+
95
+ Returns statistics about the current state of the subscription manager:
96
+ - `openWebSockets`: The number of websockets that are currently in OPEN state
97
+ - `subscribedAssetIds`: The number of unique asset IDs that are currently subscribed
98
+
93
99
  ### WebSocketHandlers
94
100
 
95
101
  Interface defining event handlers for different WebSocket events.
@@ -8,11 +8,57 @@ declare class WSSubscriptionManager {
8
8
  private reconnectAndCleanupIntervalMs;
9
9
  private maxMarketsPerWS;
10
10
  constructor(userHandlers: WebSocketHandlers, options?: SubscriptionManagerOptions);
11
+ /**
12
+ * Clears all WebSocket subscriptions and state.
13
+ *
14
+ * This will:
15
+ *
16
+ * 1. Remove all subscriptions and groups
17
+ * 2. Close all WebSocket connections
18
+ * 3. Clear the order book cache
19
+ */
11
20
  clearState(): Promise<void>;
21
+ /**
22
+ * This function is called when:
23
+ * - a websocket event is received from the Polymarket WS
24
+ * - a price update event detected, either by after a 'last_trade_price' event or a 'price_change' event
25
+ * depending on the current bid-ask spread (see https://docs.polymarket.com/polymarket-learn/trading/how-are-prices-calculated)
26
+ *
27
+ * The user handlers will be called **ONLY** for assets that are actively subscribed to by any groups.
28
+ *
29
+ * @param events - The events to process.
30
+ * @param action - The action to perform on the filtered events.
31
+ */
12
32
  private actOnSubscribedEvents;
33
+ /**
34
+ * Edits wsGroups: Adds new subscriptions.
35
+ *
36
+ * - Filters out assets that are already subscribed
37
+ * - Finds a group with capacity or creates a new one
38
+ * - Creates a new WebSocket client and adds it to the group
39
+ *
40
+ * @param assetIdsToAdd - The asset IDs to add subscriptions for.
41
+ */
13
42
  addSubscriptions(assetIdsToAdd: string[]): Promise<void>;
43
+ /**
44
+ * Edits wsGroups: Removes subscriptions.
45
+ * The group will use the updated subscriptions when it reconnects.
46
+ * We do that because we don't want to miss events by reconnecting.
47
+ *
48
+ * @param assetIdsToRemove - The asset IDs to remove subscriptions for.
49
+ */
14
50
  removeSubscriptions(assetIdsToRemove: string[]): Promise<void>;
51
+ /**
52
+ * This function runs periodically and:
53
+ *
54
+ * - Tries to reconnect groups that have assets and are disconnected
55
+ * - Cleans up groups that have no assets
56
+ */
15
57
  private reconnectAndCleanupGroups;
58
+ getStatistics(): {
59
+ openWebSockets: number;
60
+ subscribedAssetIds: number;
61
+ };
16
62
  private createWebSocketClient;
17
63
  }
18
64
  export { WSSubscriptionManager, WebSocketHandlers };
@@ -58,15 +58,15 @@ class WSSubscriptionManager {
58
58
  this.reconnectAndCleanupGroups();
59
59
  }, this.reconnectAndCleanupIntervalMs);
60
60
  }
61
- /*
62
- Clears all WebSocket subscriptions and state.
63
-
64
- This will:
65
-
66
- 1. Remove all subscriptions and groups
67
- 2. Close all WebSocket connections
68
- 3. Clear the order book cache
69
- */
61
+ /**
62
+ * Clears all WebSocket subscriptions and state.
63
+ *
64
+ * This will:
65
+ *
66
+ * 1. Remove all subscriptions and groups
67
+ * 2. Close all WebSocket connections
68
+ * 3. Clear the order book cache
69
+ */
70
70
  async clearState() {
71
71
  const previousGroups = await this.groupRegistry.clearAllGroups();
72
72
  // Close sockets outside the lock
@@ -76,14 +76,17 @@ class WSSubscriptionManager {
76
76
  // Also clear the order book cache
77
77
  this.bookCache.clear();
78
78
  }
79
- /*
80
- This function is called when:
81
- - a websocket event is received from the Polymarket WS
82
- - a price update event detected, either by after a 'last_trade_price' event or a 'price_change' event
83
- depending on the current bid-ask spread (see https://docs.polymarket.com/polymarket-learn/trading/how-are-prices-calculated)
84
-
85
- The user handlers will be called **ONLY** for assets that are actively subscribed to by any groups.
86
- */
79
+ /**
80
+ * This function is called when:
81
+ * - a websocket event is received from the Polymarket WS
82
+ * - a price update event detected, either by after a 'last_trade_price' event or a 'price_change' event
83
+ * depending on the current bid-ask spread (see https://docs.polymarket.com/polymarket-learn/trading/how-are-prices-calculated)
84
+ *
85
+ * The user handlers will be called **ONLY** for assets that are actively subscribed to by any groups.
86
+ *
87
+ * @param events - The events to process.
88
+ * @param action - The action to perform on the filtered events.
89
+ */
87
90
  async actOnSubscribedEvents(events, action) {
88
91
  // Filter out events that are not subscribed to by any groups
89
92
  events = lodash_1.default.filter(events, (event) => {
@@ -118,13 +121,15 @@ class WSSubscriptionManager {
118
121
  });
119
122
  await (action === null || action === void 0 ? void 0 : action(events));
120
123
  }
121
- /*
122
- Edits wsGroups: Adds new subscriptions.
123
-
124
- - Filters out assets that are already subscribed
125
- - Finds a group with capacity or creates a new one
126
- - Creates a new WebSocket client and adds it to the group
127
- */
124
+ /**
125
+ * Edits wsGroups: Adds new subscriptions.
126
+ *
127
+ * - Filters out assets that are already subscribed
128
+ * - Finds a group with capacity or creates a new one
129
+ * - Creates a new WebSocket client and adds it to the group
130
+ *
131
+ * @param assetIdsToAdd - The asset IDs to add subscriptions for.
132
+ */
128
133
  async addSubscriptions(assetIdsToAdd) {
129
134
  var _a, _b;
130
135
  try {
@@ -138,11 +143,13 @@ class WSSubscriptionManager {
138
143
  await ((_b = (_a = this.handlers).onError) === null || _b === void 0 ? void 0 : _b.call(_a, new Error(msg)));
139
144
  }
140
145
  }
141
- /*
142
- Edits wsGroups: Removes subscriptions.
143
- The group will use the updated subscriptions when it reconnects.
144
- We do that because we don't want to miss events by reconnecting.
145
- */
146
+ /**
147
+ * Edits wsGroups: Removes subscriptions.
148
+ * The group will use the updated subscriptions when it reconnects.
149
+ * We do that because we don't want to miss events by reconnecting.
150
+ *
151
+ * @param assetIdsToRemove - The asset IDs to remove subscriptions for.
152
+ */
146
153
  async removeSubscriptions(assetIdsToRemove) {
147
154
  var _a, _b;
148
155
  try {
@@ -153,12 +160,12 @@ class WSSubscriptionManager {
153
160
  await ((_b = (_a = this.handlers).onError) === null || _b === void 0 ? void 0 : _b.call(_a, new Error(errMsg)));
154
161
  }
155
162
  }
156
- /*
157
- This function runs periodically and:
158
-
159
- - Tries to reconnect groups that have assets and are disconnected
160
- - Cleans up groups that have no assets
161
- */
163
+ /**
164
+ * This function runs periodically and:
165
+ *
166
+ * - Tries to reconnect groups that have assets and are disconnected
167
+ * - Cleans up groups that have no assets
168
+ */
162
169
  async reconnectAndCleanupGroups() {
163
170
  var _a, _b;
164
171
  try {
@@ -171,6 +178,16 @@ class WSSubscriptionManager {
171
178
  await ((_b = (_a = this.handlers).onError) === null || _b === void 0 ? void 0 : _b.call(_a, err));
172
179
  }
173
180
  }
181
+ /*
182
+ Returns statistics about the current state of the subscription manager.
183
+
184
+ Returns an object with:
185
+ - openWebSockets: The number of websockets that are currently in OPEN state
186
+ - subscribedAssetIds: The number of unique asset IDs that are currently subscribed
187
+ */
188
+ getStatistics() {
189
+ return this.groupRegistry.getStatistics();
190
+ }
174
191
  async createWebSocketClient(groupId, handlers) {
175
192
  var _a, _b;
176
193
  const group = this.groupRegistry.findGroupById(groupId);
@@ -17,31 +17,47 @@ export declare class GroupRegistry {
17
17
  /**
18
18
  * Find the first group with capacity to hold new assets.
19
19
  *
20
- * Returns the groupId if found, otherwise null.
20
+ * @returns The groupId if found, otherwise null.
21
21
  */
22
22
  findGroupWithCapacity(newAssetLen: number, maxPerWS: number): string | null;
23
23
  /**
24
24
  * Get the indices of all groups that contain the asset.
25
25
  *
26
- * Returns an array of indices.
26
+ * @param assetId - The tokenId of a market.
27
+ * @returns An array of indices.
27
28
  */
28
29
  getGroupIndicesForAsset(assetId: string): number[];
29
30
  /**
30
31
  * Check if any group contains the asset.
32
+ *
33
+ * @param assetId - The tokenId of a market.
34
+ * @returns True if found.
31
35
  */
32
36
  hasAsset(assetId: string): boolean;
33
37
  /**
34
38
  * Find the group by groupId.
35
39
  *
36
- * Returns the group if found, otherwise undefined.
40
+ * @param groupId - The group UUID.
41
+ * @returns The group if found, otherwise undefined.
37
42
  */
38
43
  findGroupById(groupId: string): WebSocketGroup | undefined;
44
+ /**
45
+ * Get statistics about the current state of the registry.
46
+ *
47
+ * Returns an object with:
48
+ * - openWebSockets: The number of websockets that are currently in OPEN state
49
+ * - subscribedAssetIds: The number of unique asset IDs that are currently subscribed
50
+ */
51
+ getStatistics(): {
52
+ openWebSockets: number;
53
+ subscribedAssetIds: number;
54
+ };
39
55
  /**
40
56
  * Atomically remove **all** groups from the registry and return them so the
41
57
  * caller can perform any asynchronous cleanup (closing sockets, etc.)
42
58
  * outside the lock.
43
59
  *
44
- * Returns the removed groups.
60
+ * @returns The removed groups.
45
61
  */
46
62
  clearAllGroups(): Promise<WebSocketGroup[]>;
47
63
  /**
@@ -62,14 +78,18 @@ export declare class GroupRegistry {
62
78
  /**
63
79
  * Remove asset subscriptions from every group that contains the asset.
64
80
  *
65
- * It should be only one group that contains the asset, we search all of them
81
+ * It should be only one group that contains the asset, but we search all of them
66
82
  * regardless.
67
83
  *
68
- * Returns the list of assetIds that were removed.
84
+ * @param assetIds - The tokenIds of the markets to remove.
85
+ * @param bookCache - The stored orderbook.
86
+ * @returns The list of assetIds that were removed.
69
87
  */
70
88
  removeAssets(assetIds: string[], bookCache: OrderBookCache): Promise<string[]>;
71
89
  /**
72
90
  * Disconnect a group.
91
+ *
92
+ * @param group - The group to disconnect.
73
93
  */
74
94
  disconnectGroup(group: WebSocketGroup): void;
75
95
  /**
@@ -79,7 +99,7 @@ export declare class GroupRegistry {
79
99
  * – Dead (but non-empty) groups are reset so that caller can reconnect them.
80
100
  * – Pending groups are returned so that caller can connect them.
81
101
  *
82
- * Returns an array of group IDs that need to be reconnected, after cleaning up empty and cleanup-marked groups.
102
+ * @returns An array of group IDs that need to be reconnected, after cleaning up empty and cleanup-marked groups.
83
103
  */
84
104
  getGroupsToReconnectAndCleanup(): Promise<string[]>;
85
105
  }
@@ -7,6 +7,7 @@ exports.GroupRegistry = void 0;
7
7
  const async_mutex_1 = require("async-mutex");
8
8
  const lodash_1 = __importDefault(require("lodash"));
9
9
  const uuid_1 = require("uuid");
10
+ const ws_1 = __importDefault(require("ws"));
10
11
  const WebSocketSubscriptions_1 = require("../types/WebSocketSubscriptions");
11
12
  const logger_1 = require("../logger");
12
13
  /*
@@ -46,7 +47,7 @@ class GroupRegistry {
46
47
  /**
47
48
  * Find the first group with capacity to hold new assets.
48
49
  *
49
- * Returns the groupId if found, otherwise null.
50
+ * @returns The groupId if found, otherwise null.
50
51
  */
51
52
  findGroupWithCapacity(newAssetLen, maxPerWS) {
52
53
  for (const group of wsGroups) {
@@ -60,7 +61,8 @@ class GroupRegistry {
60
61
  /**
61
62
  * Get the indices of all groups that contain the asset.
62
63
  *
63
- * Returns an array of indices.
64
+ * @param assetId - The tokenId of a market.
65
+ * @returns An array of indices.
64
66
  */
65
67
  getGroupIndicesForAsset(assetId) {
66
68
  var _a;
@@ -73,6 +75,9 @@ class GroupRegistry {
73
75
  }
74
76
  /**
75
77
  * Check if any group contains the asset.
78
+ *
79
+ * @param assetId - The tokenId of a market.
80
+ * @returns True if found.
76
81
  */
77
82
  hasAsset(assetId) {
78
83
  return wsGroups.some(group => group.assetIds.has(assetId));
@@ -80,17 +85,43 @@ class GroupRegistry {
80
85
  /**
81
86
  * Find the group by groupId.
82
87
  *
83
- * Returns the group if found, otherwise undefined.
88
+ * @param groupId - The group UUID.
89
+ * @returns The group if found, otherwise undefined.
84
90
  */
85
91
  findGroupById(groupId) {
86
92
  return wsGroups.find(g => g.groupId === groupId);
87
93
  }
94
+ /**
95
+ * Get statistics about the current state of the registry.
96
+ *
97
+ * Returns an object with:
98
+ * - openWebSockets: The number of websockets that are currently in OPEN state
99
+ * - subscribedAssetIds: The number of unique asset IDs that are currently subscribed
100
+ */
101
+ getStatistics() {
102
+ let openWebSockets = 0;
103
+ const uniqueAssetIds = new Set();
104
+ for (const group of wsGroups) {
105
+ // Count open websockets
106
+ if (group.wsClient && group.wsClient.readyState === ws_1.default.OPEN) {
107
+ openWebSockets++;
108
+ }
109
+ // Collect unique asset IDs
110
+ for (const assetId of group.assetIds) {
111
+ uniqueAssetIds.add(assetId);
112
+ }
113
+ }
114
+ return {
115
+ openWebSockets,
116
+ subscribedAssetIds: uniqueAssetIds.size,
117
+ };
118
+ }
88
119
  /**
89
120
  * Atomically remove **all** groups from the registry and return them so the
90
121
  * caller can perform any asynchronous cleanup (closing sockets, etc.)
91
122
  * outside the lock.
92
123
  *
93
- * Returns the removed groups.
124
+ * @returns The removed groups.
94
125
  */
95
126
  async clearAllGroups() {
96
127
  let removed = [];
@@ -171,10 +202,12 @@ class GroupRegistry {
171
202
  /**
172
203
  * Remove asset subscriptions from every group that contains the asset.
173
204
  *
174
- * It should be only one group that contains the asset, we search all of them
205
+ * It should be only one group that contains the asset, but we search all of them
175
206
  * regardless.
176
207
  *
177
- * Returns the list of assetIds that were removed.
208
+ * @param assetIds - The tokenIds of the markets to remove.
209
+ * @param bookCache - The stored orderbook.
210
+ * @returns The list of assetIds that were removed.
178
211
  */
179
212
  async removeAssets(assetIds, bookCache) {
180
213
  const removedAssetIds = [];
@@ -199,11 +232,14 @@ class GroupRegistry {
199
232
  }
200
233
  /**
201
234
  * Disconnect a group.
235
+ *
236
+ * @param group - The group to disconnect.
202
237
  */
203
238
  disconnectGroup(group) {
204
239
  var _a;
205
240
  (_a = group.wsClient) === null || _a === void 0 ? void 0 : _a.close();
206
241
  group.wsClient = null;
242
+ group.connecting = false;
207
243
  logger_1.logger.info({
208
244
  message: 'Disconnected group',
209
245
  groupId: group.groupId,
@@ -218,7 +254,7 @@ class GroupRegistry {
218
254
  * – Dead (but non-empty) groups are reset so that caller can reconnect them.
219
255
  * – Pending groups are returned so that caller can connect them.
220
256
  *
221
- * Returns an array of group IDs that need to be reconnected, after cleaning up empty and cleanup-marked groups.
257
+ * @returns An array of group IDs that need to be reconnected, after cleaning up empty and cleanup-marked groups.
222
258
  */
223
259
  async getGroupsToReconnectAndCleanup() {
224
260
  const reconnectIds = [];
@@ -242,6 +278,9 @@ class GroupRegistry {
242
278
  continue;
243
279
  }
244
280
  if (group.status === WebSocketSubscriptions_1.WebSocketStatus.PENDING) {
281
+ if (group.connecting) {
282
+ continue;
283
+ }
245
284
  reconnectIds.push(group.groupId);
246
285
  }
247
286
  }
@@ -14,9 +14,46 @@ export declare class GroupSocket {
14
14
  *
15
15
  */
16
16
  connect(): Promise<void>;
17
+ /**
18
+ * Sets up event handlers for the WebSocket connection.
19
+ *
20
+ * Handles:
21
+ * - 'open': Authenticates and starts ping interval
22
+ * - 'message': Parses and routes events
23
+ * - 'pong': Handles pong responses
24
+ * - 'error': Handles errors
25
+ * - 'close': Handles connection closure
26
+ */
17
27
  private setupEventHandlers;
28
+ /**
29
+ * Handles book events by updating the cache and notifying listeners.
30
+ *
31
+ * @param bookEvents - The book events to process.
32
+ */
18
33
  private handleBookEvents;
34
+ /**
35
+ * Handles tick size change events by notifying listeners.
36
+ *
37
+ * @param tickEvents - The tick size change events to process.
38
+ */
19
39
  private handleTickEvents;
40
+ /**
41
+ * Handles price change events.
42
+ *
43
+ * - Updates the order book cache
44
+ * - Calculates derived price updates based on spread and midpoint
45
+ * - Notifies listeners of price changes and derived updates
46
+ *
47
+ * @param priceChangeEvents - The price change events to process.
48
+ */
20
49
  private handlePriceChangeEvents;
50
+ /**
51
+ * Handles last trade price events.
52
+ *
53
+ * - Notifies listeners of last trade price
54
+ * - Calculates derived price updates based on spread
55
+ *
56
+ * @param lastTradeEvents - The last trade price events to process.
57
+ */
21
58
  private handleLastTradeEvents;
22
59
  }
@@ -24,10 +24,14 @@ class GroupSocket {
24
24
  *
25
25
  */
26
26
  async connect() {
27
+ if (this.group.connecting) {
28
+ return;
29
+ }
27
30
  if (this.group.assetIds.size === 0) {
28
31
  this.group.status = WebSocketSubscriptions_1.WebSocketStatus.CLEANUP;
29
32
  return;
30
33
  }
34
+ this.group.connecting = true;
31
35
  try {
32
36
  logger_1.logger.info({
33
37
  message: 'Connecting to CLOB WebSocket',
@@ -54,8 +58,21 @@ class GroupSocket {
54
58
  this.group.status = WebSocketSubscriptions_1.WebSocketStatus.DEAD;
55
59
  throw err; // caller responsible for error handler
56
60
  }
61
+ finally {
62
+ this.group.connecting = false;
63
+ }
57
64
  this.setupEventHandlers();
58
65
  }
66
+ /**
67
+ * Sets up event handlers for the WebSocket connection.
68
+ *
69
+ * Handles:
70
+ * - 'open': Authenticates and starts ping interval
71
+ * - 'message': Parses and routes events
72
+ * - 'pong': Handles pong responses
73
+ * - 'error': Handles errors
74
+ * - 'close': Handles connection closure
75
+ */
59
76
  setupEventHandlers() {
60
77
  const group = this.group;
61
78
  const handlers = this.handlers;
@@ -220,6 +237,11 @@ class GroupSocket {
220
237
  return;
221
238
  }
222
239
  }
240
+ /**
241
+ * Handles book events by updating the cache and notifying listeners.
242
+ *
243
+ * @param bookEvents - The book events to process.
244
+ */
223
245
  async handleBookEvents(bookEvents) {
224
246
  var _a, _b;
225
247
  if (bookEvents.length) {
@@ -229,12 +251,26 @@ class GroupSocket {
229
251
  await ((_b = (_a = this.handlers).onBook) === null || _b === void 0 ? void 0 : _b.call(_a, bookEvents));
230
252
  }
231
253
  }
254
+ /**
255
+ * Handles tick size change events by notifying listeners.
256
+ *
257
+ * @param tickEvents - The tick size change events to process.
258
+ */
232
259
  async handleTickEvents(tickEvents) {
233
260
  var _a, _b;
234
261
  if (tickEvents.length) {
235
262
  await ((_b = (_a = this.handlers).onTickSizeChange) === null || _b === void 0 ? void 0 : _b.call(_a, tickEvents));
236
263
  }
237
264
  }
265
+ /**
266
+ * Handles price change events.
267
+ *
268
+ * - Updates the order book cache
269
+ * - Calculates derived price updates based on spread and midpoint
270
+ * - Notifies listeners of price changes and derived updates
271
+ *
272
+ * @param priceChangeEvents - The price change events to process.
273
+ */
238
274
  async handlePriceChangeEvents(priceChangeEvents) {
239
275
  var _a, _b, _c, _d;
240
276
  if (priceChangeEvents.length) {
@@ -309,6 +345,14 @@ class GroupSocket {
309
345
  }
310
346
  }
311
347
  }
348
+ /**
349
+ * Handles last trade price events.
350
+ *
351
+ * - Notifies listeners of last trade price
352
+ * - Calculates derived price updates based on spread
353
+ *
354
+ * @param lastTradeEvents - The last trade price events to process.
355
+ */
312
356
  async handleLastTradeEvents(lastTradeEvents) {
313
357
  var _a, _b, _c, _d;
314
358
  if (lastTradeEvents.length) {
@@ -6,27 +6,27 @@ export interface BookEntry {
6
6
  midpoint: string | null;
7
7
  spread: string | null;
8
8
  }
9
- export declare function sortDescendingInPlace(bookSide: PriceLevel[]): void;
10
9
  export declare class OrderBookCache {
11
10
  private bookCache;
12
11
  constructor();
13
12
  /**
14
13
  * Replace full book (after a `book` event)
14
+ * @param event new orderbook event
15
15
  */
16
16
  replaceBook(event: BookEvent): void;
17
17
  /**
18
18
  * Update a cached book from a `price_change` event.
19
19
  *
20
- * Returns true if the book was updated.
21
- * Throws if the book is not found.
20
+ * @param event PriceChangeEvent
21
+ * @returns true if the book was updated.
22
+ * @throws if the book is not found.
22
23
  */
23
24
  upsertPriceChange(event: PriceChangeEvent): void;
24
25
  /**
25
- * Return `true` if best-bid/best-ask spread exceeds `cents`.
26
- *
27
26
  * Side effect: updates the book's spread
28
27
  *
29
- * Throws if either side of the book is empty.
28
+ * @returns `true` if best-bid/best-ask spread exceeds `cents`.
29
+ * @throws if either side of the book is empty.
30
30
  */
31
31
  spreadOver(assetId: string, cents?: number): boolean;
32
32
  /**
@@ -39,11 +39,16 @@ export declare class OrderBookCache {
39
39
  * - the midpoint is NaN.
40
40
  */
41
41
  midpoint(assetId: string): string;
42
+ /**
43
+ * Removes a specific market from the orderbook if assetId is provided
44
+ * otherwise clears all orderbook
45
+ * @param assetId tokenId of a market
46
+ */
42
47
  clear(assetId?: string): void;
43
48
  /**
44
49
  * Get a book entry by asset id.
45
50
  *
46
- * Return null if the book is not found.
51
+ * @returns book entry if found, otherwise null
47
52
  */
48
53
  getBookEntry(assetId: string): BookEntry | null;
49
54
  }
@@ -1,7 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.OrderBookCache = void 0;
4
- exports.sortDescendingInPlace = sortDescendingInPlace;
5
4
  function sortDescendingInPlace(bookSide) {
6
5
  bookSide.sort((a, b) => parseFloat(b.price) - parseFloat(a.price));
7
6
  }
@@ -14,6 +13,7 @@ class OrderBookCache {
14
13
  }
15
14
  /**
16
15
  * Replace full book (after a `book` event)
16
+ * @param event new orderbook event
17
17
  */
18
18
  replaceBook(event) {
19
19
  let lastPrice = null;
@@ -43,8 +43,9 @@ class OrderBookCache {
43
43
  /**
44
44
  * Update a cached book from a `price_change` event.
45
45
  *
46
- * Returns true if the book was updated.
47
- * Throws if the book is not found.
46
+ * @param event PriceChangeEvent
47
+ * @returns true if the book was updated.
48
+ * @throws if the book is not found.
48
49
  */
49
50
  upsertPriceChange(event) {
50
51
  // Iterate through price_changes array
@@ -79,11 +80,10 @@ class OrderBookCache {
79
80
  }
80
81
  }
81
82
  /**
82
- * Return `true` if best-bid/best-ask spread exceeds `cents`.
83
- *
84
83
  * Side effect: updates the book's spread
85
84
  *
86
- * Throws if either side of the book is empty.
85
+ * @returns `true` if best-bid/best-ask spread exceeds `cents`.
86
+ * @throws if either side of the book is empty.
87
87
  */
88
88
  spreadOver(assetId, cents = 0.1) {
89
89
  const book = this.bookCache[assetId];
@@ -149,6 +149,11 @@ class OrderBookCache {
149
149
  book.midpoint = parseFloat(midpoint.toFixed(3)).toString();
150
150
  return parseFloat(midpoint.toFixed(3)).toString();
151
151
  }
152
+ /**
153
+ * Removes a specific market from the orderbook if assetId is provided
154
+ * otherwise clears all orderbook
155
+ * @param assetId tokenId of a market
156
+ */
152
157
  clear(assetId) {
153
158
  if (assetId) {
154
159
  delete this.bookCache[assetId];
@@ -162,7 +167,7 @@ class OrderBookCache {
162
167
  /**
163
168
  * Get a book entry by asset id.
164
169
  *
165
- * Return null if the book is not found.
170
+ * @returns book entry if found, otherwise null
166
171
  */
167
172
  getBookEntry(assetId) {
168
173
  if (!this.bookCache[assetId]) {
@@ -22,7 +22,7 @@ export type PriceChangeItem = {
22
22
  /**
23
23
  * Represents a price_change event from Polymarket WebSocket
24
24
  *
25
- * Schema example:
25
+ * @example
26
26
  * {
27
27
  * market: "0x5f65177b394277fd294cd75650044e32ba009a95022d88a0c1d565897d72f8f1",
28
28
  * price_changes: [
@@ -11,6 +11,7 @@ export type WebSocketGroup = {
11
11
  assetIds: Set<string>;
12
12
  wsClient: WebSocket | null;
13
13
  status: WebSocketStatus;
14
+ connecting?: boolean;
14
15
  };
15
16
  export type SubscriptionManagerOptions = {
16
17
  burstLimiter?: Bottleneck;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nevuamarkets/poly-websockets",
3
- "version": "0.2.2",
3
+ "version": "0.3.1",
4
4
  "description": "Plug-and-play Polymarket WebSocket price alerts",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -11,7 +11,9 @@
11
11
  "scripts": {
12
12
  "build": "tsc",
13
13
  "prepare": "npm run build && npm run test",
14
- "test": "vitest run"
14
+ "test": "vitest run --exclude LiveData.test.ts",
15
+ "test:live": "vitest run LiveData.test.ts",
16
+ "prepublishOnly": "npm run test:live"
15
17
  },
16
18
  "repository": {
17
19
  "type": "git",
@@ -79,15 +79,15 @@ class WSSubscriptionManager {
79
79
  }, this.reconnectAndCleanupIntervalMs);
80
80
  }
81
81
 
82
- /*
83
- Clears all WebSocket subscriptions and state.
84
-
85
- This will:
86
-
87
- 1. Remove all subscriptions and groups
88
- 2. Close all WebSocket connections
89
- 3. Clear the order book cache
90
- */
82
+ /**
83
+ * Clears all WebSocket subscriptions and state.
84
+ *
85
+ * This will:
86
+ *
87
+ * 1. Remove all subscriptions and groups
88
+ * 2. Close all WebSocket connections
89
+ * 3. Clear the order book cache
90
+ */
91
91
  public async clearState() {
92
92
  const previousGroups = await this.groupRegistry.clearAllGroups();
93
93
 
@@ -100,14 +100,17 @@ class WSSubscriptionManager {
100
100
  this.bookCache.clear();
101
101
  }
102
102
 
103
- /*
104
- This function is called when:
105
- - a websocket event is received from the Polymarket WS
106
- - a price update event detected, either by after a 'last_trade_price' event or a 'price_change' event
107
- depending on the current bid-ask spread (see https://docs.polymarket.com/polymarket-learn/trading/how-are-prices-calculated)
108
-
109
- The user handlers will be called **ONLY** for assets that are actively subscribed to by any groups.
110
- */
103
+ /**
104
+ * This function is called when:
105
+ * - a websocket event is received from the Polymarket WS
106
+ * - a price update event detected, either by after a 'last_trade_price' event or a 'price_change' event
107
+ * depending on the current bid-ask spread (see https://docs.polymarket.com/polymarket-learn/trading/how-are-prices-calculated)
108
+ *
109
+ * The user handlers will be called **ONLY** for assets that are actively subscribed to by any groups.
110
+ *
111
+ * @param events - The events to process.
112
+ * @param action - The action to perform on the filtered events.
113
+ */
111
114
  private async actOnSubscribedEvents<T extends PolymarketWSEvent | PolymarketPriceUpdateEvent>(events: T[], action?: (events: T[]) => Promise<void>) {
112
115
 
113
116
  // Filter out events that are not subscribed to by any groups
@@ -148,13 +151,15 @@ class WSSubscriptionManager {
148
151
  await action?.(events);
149
152
  }
150
153
 
151
- /*
152
- Edits wsGroups: Adds new subscriptions.
153
-
154
- - Filters out assets that are already subscribed
155
- - Finds a group with capacity or creates a new one
156
- - Creates a new WebSocket client and adds it to the group
157
- */
154
+ /**
155
+ * Edits wsGroups: Adds new subscriptions.
156
+ *
157
+ * - Filters out assets that are already subscribed
158
+ * - Finds a group with capacity or creates a new one
159
+ * - Creates a new WebSocket client and adds it to the group
160
+ *
161
+ * @param assetIdsToAdd - The asset IDs to add subscriptions for.
162
+ */
158
163
  public async addSubscriptions(assetIdsToAdd: string[]) {
159
164
  try {
160
165
  const groupIdsToConnect = await this.groupRegistry.addAssets(assetIdsToAdd, this.maxMarketsPerWS);
@@ -167,11 +172,13 @@ class WSSubscriptionManager {
167
172
  }
168
173
  }
169
174
 
170
- /*
171
- Edits wsGroups: Removes subscriptions.
172
- The group will use the updated subscriptions when it reconnects.
173
- We do that because we don't want to miss events by reconnecting.
174
- */
175
+ /**
176
+ * Edits wsGroups: Removes subscriptions.
177
+ * The group will use the updated subscriptions when it reconnects.
178
+ * We do that because we don't want to miss events by reconnecting.
179
+ *
180
+ * @param assetIdsToRemove - The asset IDs to remove subscriptions for.
181
+ */
175
182
  public async removeSubscriptions(assetIdsToRemove: string[]) {
176
183
  try {
177
184
  await this.groupRegistry.removeAssets(assetIdsToRemove, this.bookCache);
@@ -181,12 +188,12 @@ class WSSubscriptionManager {
181
188
  }
182
189
  }
183
190
 
184
- /*
185
- This function runs periodically and:
186
-
187
- - Tries to reconnect groups that have assets and are disconnected
188
- - Cleans up groups that have no assets
189
- */
191
+ /**
192
+ * This function runs periodically and:
193
+ *
194
+ * - Tries to reconnect groups that have assets and are disconnected
195
+ * - Cleans up groups that have no assets
196
+ */
190
197
  private async reconnectAndCleanupGroups() {
191
198
  try {
192
199
  const reconnectIds = await this.groupRegistry.getGroupsToReconnectAndCleanup();
@@ -199,6 +206,20 @@ class WSSubscriptionManager {
199
206
  }
200
207
  }
201
208
 
209
+ /*
210
+ Returns statistics about the current state of the subscription manager.
211
+
212
+ Returns an object with:
213
+ - openWebSockets: The number of websockets that are currently in OPEN state
214
+ - subscribedAssetIds: The number of unique asset IDs that are currently subscribed
215
+ */
216
+ public getStatistics(): {
217
+ openWebSockets: number;
218
+ subscribedAssetIds: number;
219
+ } {
220
+ return this.groupRegistry.getStatistics();
221
+ }
222
+
202
223
  private async createWebSocketClient(groupId: string, handlers: WebSocketHandlers) {
203
224
  const group = this.groupRegistry.findGroupById(groupId);
204
225
 
@@ -1,6 +1,7 @@
1
1
  import { Mutex } from 'async-mutex';
2
2
  import _ from 'lodash';
3
3
  import { v4 as uuidv4 } from 'uuid';
4
+ import WebSocket from 'ws';
4
5
  import { WebSocketGroup, WebSocketStatus } from '../types/WebSocketSubscriptions';
5
6
  import { OrderBookCache } from './OrderBookCache';
6
7
  import { logger } from '../logger';
@@ -42,7 +43,7 @@ export class GroupRegistry {
42
43
  /**
43
44
  * Find the first group with capacity to hold new assets.
44
45
  *
45
- * Returns the groupId if found, otherwise null.
46
+ * @returns The groupId if found, otherwise null.
46
47
  */
47
48
  public findGroupWithCapacity(newAssetLen: number, maxPerWS: number): string | null {
48
49
  for (const group of wsGroups) {
@@ -55,7 +56,8 @@ export class GroupRegistry {
55
56
  /**
56
57
  * Get the indices of all groups that contain the asset.
57
58
  *
58
- * Returns an array of indices.
59
+ * @param assetId - The tokenId of a market.
60
+ * @returns An array of indices.
59
61
  */
60
62
  public getGroupIndicesForAsset(assetId: string): number[] {
61
63
  const indices: number[] = [];
@@ -67,6 +69,9 @@ export class GroupRegistry {
67
69
 
68
70
  /**
69
71
  * Check if any group contains the asset.
72
+ *
73
+ * @param assetId - The tokenId of a market.
74
+ * @returns True if found.
70
75
  */
71
76
  public hasAsset(assetId: string): boolean {
72
77
  return wsGroups.some(group => group.assetIds.has(assetId));
@@ -75,18 +80,51 @@ export class GroupRegistry {
75
80
  /**
76
81
  * Find the group by groupId.
77
82
  *
78
- * Returns the group if found, otherwise undefined.
83
+ * @param groupId - The group UUID.
84
+ * @returns The group if found, otherwise undefined.
79
85
  */
80
86
  public findGroupById(groupId: string): WebSocketGroup | undefined {
81
87
  return wsGroups.find(g => g.groupId === groupId);
82
88
  }
83
89
 
90
+ /**
91
+ * Get statistics about the current state of the registry.
92
+ *
93
+ * Returns an object with:
94
+ * - openWebSockets: The number of websockets that are currently in OPEN state
95
+ * - subscribedAssetIds: The number of unique asset IDs that are currently subscribed
96
+ */
97
+ public getStatistics(): {
98
+ openWebSockets: number;
99
+ subscribedAssetIds: number;
100
+ } {
101
+ let openWebSockets = 0;
102
+ const uniqueAssetIds = new Set<string>();
103
+
104
+ for (const group of wsGroups) {
105
+ // Count open websockets
106
+ if (group.wsClient && group.wsClient.readyState === WebSocket.OPEN) {
107
+ openWebSockets++;
108
+ }
109
+
110
+ // Collect unique asset IDs
111
+ for (const assetId of group.assetIds) {
112
+ uniqueAssetIds.add(assetId);
113
+ }
114
+ }
115
+
116
+ return {
117
+ openWebSockets,
118
+ subscribedAssetIds: uniqueAssetIds.size,
119
+ };
120
+ }
121
+
84
122
  /**
85
123
  * Atomically remove **all** groups from the registry and return them so the
86
124
  * caller can perform any asynchronous cleanup (closing sockets, etc.)
87
125
  * outside the lock.
88
126
  *
89
- * Returns the removed groups.
127
+ * @returns The removed groups.
90
128
  */
91
129
  public async clearAllGroups(): Promise<WebSocketGroup[]> {
92
130
  let removed: WebSocketGroup[] = [];
@@ -179,10 +217,12 @@ export class GroupRegistry {
179
217
  /**
180
218
  * Remove asset subscriptions from every group that contains the asset.
181
219
  *
182
- * It should be only one group that contains the asset, we search all of them
220
+ * It should be only one group that contains the asset, but we search all of them
183
221
  * regardless.
184
222
  *
185
- * Returns the list of assetIds that were removed.
223
+ * @param assetIds - The tokenIds of the markets to remove.
224
+ * @param bookCache - The stored orderbook.
225
+ * @returns The list of assetIds that were removed.
186
226
  */
187
227
  public async removeAssets(assetIds: string[], bookCache: OrderBookCache): Promise<string[]> {
188
228
  const removedAssetIds: string[] = [];
@@ -208,10 +248,13 @@ export class GroupRegistry {
208
248
 
209
249
  /**
210
250
  * Disconnect a group.
251
+ *
252
+ * @param group - The group to disconnect.
211
253
  */
212
254
  public disconnectGroup(group: WebSocketGroup) {
213
255
  group.wsClient?.close();
214
256
  group.wsClient = null;
257
+ group.connecting = false;
215
258
 
216
259
  logger.info({
217
260
  message: 'Disconnected group',
@@ -227,7 +270,7 @@ export class GroupRegistry {
227
270
  * – Dead (but non-empty) groups are reset so that caller can reconnect them.
228
271
  * – Pending groups are returned so that caller can connect them.
229
272
  *
230
- * Returns an array of group IDs that need to be reconnected, after cleaning up empty and cleanup-marked groups.
273
+ * @returns An array of group IDs that need to be reconnected, after cleaning up empty and cleanup-marked groups.
231
274
  */
232
275
  public async getGroupsToReconnectAndCleanup(): Promise<string[]> {
233
276
  const reconnectIds: string[] = [];
@@ -254,8 +297,10 @@ export class GroupRegistry {
254
297
  group.assetIds = new Set();
255
298
  continue;
256
299
  }
257
-
258
300
  if (group.status === WebSocketStatus.PENDING) {
301
+ if (group.connecting) {
302
+ continue;
303
+ }
259
304
  reconnectIds.push(group.groupId);
260
305
  }
261
306
  }
@@ -271,4 +316,4 @@ export class GroupRegistry {
271
316
  });
272
317
  return reconnectIds;
273
318
  }
274
- }
319
+ }
@@ -37,11 +37,16 @@ export class GroupSocket {
37
37
  *
38
38
  */
39
39
  public async connect(): Promise<void> {
40
+ if (this.group.connecting) {
41
+ return;
42
+ }
40
43
  if (this.group.assetIds.size === 0) {
41
44
  this.group.status = WebSocketStatus.CLEANUP;
42
45
  return;
43
46
  }
44
47
 
48
+ this.group.connecting = true;
49
+
45
50
  try {
46
51
  logger.info({
47
52
  message: 'Connecting to CLOB WebSocket',
@@ -66,11 +71,23 @@ export class GroupSocket {
66
71
  } catch (err) {
67
72
  this.group.status = WebSocketStatus.DEAD;
68
73
  throw err; // caller responsible for error handler
74
+ } finally {
75
+ this.group.connecting = false;
69
76
  }
70
77
 
71
78
  this.setupEventHandlers();
72
79
  }
73
80
 
81
+ /**
82
+ * Sets up event handlers for the WebSocket connection.
83
+ *
84
+ * Handles:
85
+ * - 'open': Authenticates and starts ping interval
86
+ * - 'message': Parses and routes events
87
+ * - 'pong': Handles pong responses
88
+ * - 'error': Handles errors
89
+ * - 'close': Handles connection closure
90
+ */
74
91
  private setupEventHandlers() {
75
92
  const group = this.group;
76
93
  const handlers = this.handlers;
@@ -249,6 +266,11 @@ export class GroupSocket {
249
266
  }
250
267
  }
251
268
 
269
+ /**
270
+ * Handles book events by updating the cache and notifying listeners.
271
+ *
272
+ * @param bookEvents - The book events to process.
273
+ */
252
274
  private async handleBookEvents(bookEvents: BookEvent[]): Promise<void> {
253
275
  if (bookEvents.length) {
254
276
  for (const event of bookEvents) {
@@ -258,12 +280,26 @@ export class GroupSocket {
258
280
  }
259
281
  }
260
282
 
283
+ /**
284
+ * Handles tick size change events by notifying listeners.
285
+ *
286
+ * @param tickEvents - The tick size change events to process.
287
+ */
261
288
  private async handleTickEvents(tickEvents: TickSizeChangeEvent[]): Promise<void> {
262
289
  if (tickEvents.length) {
263
290
  await this.handlers.onTickSizeChange?.(tickEvents);
264
291
  }
265
292
  }
266
293
 
294
+ /**
295
+ * Handles price change events.
296
+ *
297
+ * - Updates the order book cache
298
+ * - Calculates derived price updates based on spread and midpoint
299
+ * - Notifies listeners of price changes and derived updates
300
+ *
301
+ * @param priceChangeEvents - The price change events to process.
302
+ */
267
303
  private async handlePriceChangeEvents(priceChangeEvents: PriceChangeEvent[]): Promise<void> {
268
304
  if (priceChangeEvents.length) {
269
305
  await this.handlers.onPriceChange?.(priceChangeEvents);
@@ -341,6 +377,14 @@ export class GroupSocket {
341
377
  }
342
378
  }
343
379
 
380
+ /**
381
+ * Handles last trade price events.
382
+ *
383
+ * - Notifies listeners of last trade price
384
+ * - Calculates derived price updates based on spread
385
+ *
386
+ * @param lastTradeEvents - The last trade price events to process.
387
+ */
344
388
  private async handleLastTradeEvents(lastTradeEvents: LastTradePriceEvent[]): Promise<void> {
345
389
  if (lastTradeEvents.length) {
346
390
  /*
@@ -396,4 +440,4 @@ export class GroupSocket {
396
440
  }
397
441
  }
398
442
  }
399
- }
443
+ }
@@ -16,8 +16,7 @@ export interface BookEntry {
16
16
  midpoint: string | null;
17
17
  spread: string | null;
18
18
  }
19
-
20
- export
19
+
21
20
 
22
21
  function sortDescendingInPlace(bookSide: PriceLevel[]): void {
23
22
  bookSide.sort((a, b) => parseFloat(b.price) - parseFloat(a.price));
@@ -36,6 +35,7 @@ export class OrderBookCache {
36
35
 
37
36
  /**
38
37
  * Replace full book (after a `book` event)
38
+ * @param event new orderbook event
39
39
  */
40
40
  public replaceBook(event: BookEvent): void {
41
41
  let lastPrice = null;
@@ -68,8 +68,9 @@ export class OrderBookCache {
68
68
  /**
69
69
  * Update a cached book from a `price_change` event.
70
70
  *
71
- * Returns true if the book was updated.
72
- * Throws if the book is not found.
71
+ * @param event PriceChangeEvent
72
+ * @returns true if the book was updated.
73
+ * @throws if the book is not found.
73
74
  */
74
75
  public upsertPriceChange(event: PriceChangeEvent): void {
75
76
  // Iterate through price_changes array
@@ -105,11 +106,10 @@ export class OrderBookCache {
105
106
  }
106
107
 
107
108
  /**
108
- * Return `true` if best-bid/best-ask spread exceeds `cents`.
109
- *
110
109
  * Side effect: updates the book's spread
111
110
  *
112
- * Throws if either side of the book is empty.
111
+ * @returns `true` if best-bid/best-ask spread exceeds `cents`.
112
+ * @throws if either side of the book is empty.
113
113
  */
114
114
  public spreadOver(assetId: string, cents = 0.1): boolean {
115
115
  const book = this.bookCache[assetId];
@@ -183,7 +183,11 @@ export class OrderBookCache {
183
183
 
184
184
  return parseFloat(midpoint.toFixed(3)).toString();
185
185
  }
186
-
186
+ /**
187
+ * Removes a specific market from the orderbook if assetId is provided
188
+ * otherwise clears all orderbook
189
+ * @param assetId tokenId of a market
190
+ */
187
191
  public clear(assetId?: string): void {
188
192
  if (assetId) {
189
193
  delete this.bookCache[assetId];
@@ -197,7 +201,7 @@ export class OrderBookCache {
197
201
  /**
198
202
  * Get a book entry by asset id.
199
203
  *
200
- * Return null if the book is not found.
204
+ * @returns book entry if found, otherwise null
201
205
  */
202
206
  public getBookEntry(assetId: string): BookEntry | null {
203
207
  if (!this.bookCache[assetId]) {
@@ -24,7 +24,7 @@ export type PriceChangeItem = {
24
24
  /**
25
25
  * Represents a price_change event from Polymarket WebSocket
26
26
  *
27
- * Schema example:
27
+ * @example
28
28
  * {
29
29
  * market: "0x5f65177b394277fd294cd75650044e32ba009a95022d88a0c1d565897d72f8f1",
30
30
  * price_changes: [
@@ -13,6 +13,7 @@ export type WebSocketGroup = {
13
13
  assetIds: Set<string>;
14
14
  wsClient: WebSocket | null;
15
15
  status: WebSocketStatus;
16
+ connecting?: boolean;
16
17
  };
17
18
 
18
19
  export type SubscriptionManagerOptions = {
@@ -23,4 +24,4 @@ export type SubscriptionManagerOptions = {
23
24
 
24
25
  // How many assets to allow per WebSocket
25
26
  maxMarketsPerWS?: number;
26
- }
27
+ }