@tentou-tech/poly-websockets 1.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.
@@ -0,0 +1,1020 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.WSSubscriptionManager = void 0;
7
+ const ms_1 = __importDefault(require("ms"));
8
+ const ws_1 = __importDefault(require("ws"));
9
+ const uuid_1 = require("uuid");
10
+ const crypto_1 = require("crypto");
11
+ const PolymarketWebSocket_1 = require("./types/PolymarketWebSocket");
12
+ const WebSocketSubscriptions_1 = require("./types/WebSocketSubscriptions");
13
+ const OrderBookCache_1 = require("./modules/OrderBookCache");
14
+ const logger_1 = require("./logger");
15
+ const lodash_1 = __importDefault(require("lodash"));
16
+ // Note: We intentionally use a static reconnection interval rather than exponential backoff.
17
+ // Perhaps change this to exponential backoff in the future.
18
+ const DEFAULT_RECONNECT_INTERVAL_MS = (0, ms_1.default)('5s');
19
+ const DEFAULT_PENDING_FLUSH_INTERVAL_MS = (0, ms_1.default)('100ms');
20
+ const CLOB_WSS_URL = 'wss://ws-subscriptions-clob.polymarket.com/ws/market';
21
+ /**
22
+ * WebSocket Subscription Manager for Polymarket CLOB WebSocket.
23
+ *
24
+ * Each instance manages a single WebSocket connection and tracks:
25
+ * - Subscribed assets: Successfully subscribed to the WebSocket
26
+ * - Pending assets: Waiting to be subscribed (batched and flushed periodically)
27
+ *
28
+ * Instances are fully independent - no shared state between managers.
29
+ */
30
+ class WSSubscriptionManager {
31
+ constructor(userHandlers, options) {
32
+ // WebSocket connection
33
+ this.wsClient = null;
34
+ this.status = WebSocketSubscriptions_1.WebSocketConnectionStatus.DISCONNECTED;
35
+ this.connecting = false;
36
+ // Asset tracking
37
+ this.subscribedAssetIds = new Set();
38
+ this.pendingSubscribeAssetIds = new Set();
39
+ this.pendingUnsubscribeAssetIds = new Set();
40
+ this.managerId = (0, uuid_1.v4)();
41
+ this.bookCache = new OrderBookCache_1.OrderBookCache();
42
+ this.reconnectIntervalMs = (options === null || options === void 0 ? void 0 : options.reconnectAndCleanupIntervalMs) || DEFAULT_RECONNECT_INTERVAL_MS;
43
+ this.pendingFlushIntervalMs = (options === null || options === void 0 ? void 0 : options.pendingFlushIntervalMs) || DEFAULT_PENDING_FLUSH_INTERVAL_MS;
44
+ this.enableCustomFeatures = (options === null || options === void 0 ? void 0 : options.enableCustomFeatures) || false;
45
+ this.handlers = {
46
+ onBook: async (events) => {
47
+ await this.actOnSubscribedEvents(events, userHandlers.onBook);
48
+ },
49
+ onLastTradePrice: async (events) => {
50
+ await this.actOnSubscribedEvents(events, userHandlers.onLastTradePrice);
51
+ },
52
+ onTickSizeChange: async (events) => {
53
+ await this.actOnSubscribedEvents(events, userHandlers.onTickSizeChange);
54
+ },
55
+ onPriceChange: async (events) => {
56
+ await this.actOnSubscribedEvents(events, userHandlers.onPriceChange);
57
+ },
58
+ onBestBidAsk: async (events) => {
59
+ await this.actOnSubscribedEvents(events, userHandlers.onBestBidAsk);
60
+ },
61
+ onPolymarketPriceUpdate: async (events) => {
62
+ await this.actOnSubscribedEvents(events, userHandlers.onPolymarketPriceUpdate);
63
+ },
64
+ onNewMarket: userHandlers.onNewMarket,
65
+ onMarketResolved: userHandlers.onMarketResolved,
66
+ onWSClose: userHandlers.onWSClose,
67
+ onWSOpen: userHandlers.onWSOpen,
68
+ onError: userHandlers.onError
69
+ };
70
+ // Periodic reconnection check
71
+ this.scheduleReconnectionCheck();
72
+ // Periodic pending flush
73
+ this.pendingFlushInterval = setInterval(() => {
74
+ this.flushPendingSubscriptions();
75
+ }, this.pendingFlushIntervalMs);
76
+ }
77
+ /**
78
+ * Clears all WebSocket subscriptions and state.
79
+ *
80
+ * This will:
81
+ * 1. Stop all timers
82
+ * 2. Close the WebSocket connection
83
+ * 3. Clear all asset tracking
84
+ * 4. Clear the order book cache
85
+ */
86
+ async clearState() {
87
+ // Stop all timers (reconnectInterval is now a timeout, not interval)
88
+ if (this.reconnectInterval) {
89
+ clearTimeout(this.reconnectInterval);
90
+ this.reconnectInterval = undefined;
91
+ }
92
+ if (this.pendingFlushInterval) {
93
+ clearInterval(this.pendingFlushInterval);
94
+ this.pendingFlushInterval = undefined;
95
+ }
96
+ if (this.pingInterval) {
97
+ clearInterval(this.pingInterval);
98
+ this.pingInterval = undefined;
99
+ }
100
+ if (this.connectionTimeout) {
101
+ clearTimeout(this.connectionTimeout);
102
+ this.connectionTimeout = undefined;
103
+ }
104
+ // Close WebSocket
105
+ if (this.wsClient) {
106
+ this.wsClient.removeAllListeners();
107
+ this.wsClient.close();
108
+ this.wsClient = null;
109
+ }
110
+ // Clear all asset tracking
111
+ this.subscribedAssetIds.clear();
112
+ this.pendingSubscribeAssetIds.clear();
113
+ this.pendingUnsubscribeAssetIds.clear();
114
+ // Reset status
115
+ this.status = WebSocketSubscriptions_1.WebSocketConnectionStatus.DISCONNECTED;
116
+ this.connecting = false;
117
+ // Clear the order book cache
118
+ this.bookCache.clear();
119
+ }
120
+ /**
121
+ * Filters events to only include those for subscribed assets.
122
+ * Wraps user handler calls in try-catch to prevent user errors from breaking internal logic.
123
+ * Does not call the handler if all events are filtered out.
124
+ */
125
+ async actOnSubscribedEvents(events, action) {
126
+ events = events.filter((event) => {
127
+ if ((0, PolymarketWebSocket_1.isPriceChangeEvent)(event)) {
128
+ return event.price_changes.some(pc => this.subscribedAssetIds.has(pc.asset_id));
129
+ }
130
+ if ('asset_id' in event) {
131
+ return this.subscribedAssetIds.has(event.asset_id);
132
+ }
133
+ return false;
134
+ });
135
+ // Skip if no events passed the filter
136
+ if (events.length === 0) {
137
+ return;
138
+ }
139
+ // Wrap user handler calls in try-catch
140
+ try {
141
+ await (action === null || action === void 0 ? void 0 : action(events));
142
+ }
143
+ catch (handlerErr) {
144
+ logger_1.logger.warn({
145
+ message: 'Error in user event handler',
146
+ error: handlerErr instanceof Error ? handlerErr.message : String(handlerErr),
147
+ managerId: this.managerId,
148
+ eventCount: events.length,
149
+ });
150
+ }
151
+ }
152
+ /**
153
+ * Adds new subscriptions.
154
+ *
155
+ * Assets are added to a pending queue and will be subscribed when:
156
+ * - The WebSocket connects (initial subscription)
157
+ * - The pending flush timer fires (for new assets on an existing connection)
158
+ *
159
+ * @param assetIdsToAdd - The asset IDs to add subscriptions for.
160
+ */
161
+ async addSubscriptions(assetIdsToAdd) {
162
+ try {
163
+ for (const assetId of assetIdsToAdd) {
164
+ // Remove from pending unsubscribe if it's there (cancel the unsubscription)
165
+ // This must happen BEFORE the subscribed check, so that adding an asset
166
+ // that was pending unsubscribe correctly cancels the unsubscription.
167
+ this.pendingUnsubscribeAssetIds.delete(assetId);
168
+ // Skip if already subscribed (pending subscribe is safe to re-add due to Set behavior)
169
+ if (this.subscribedAssetIds.has(assetId))
170
+ continue;
171
+ // Add to pending subscribe (no-op if already pending due to Set)
172
+ this.pendingSubscribeAssetIds.add(assetId);
173
+ }
174
+ // Restart intervals if they were cleared (e.g. after clearState)
175
+ this.ensureIntervalsRunning();
176
+ // Ensure we have a connection
177
+ if (!this.wsClient || this.wsClient.readyState !== ws_1.default.OPEN) {
178
+ await this.connect();
179
+ }
180
+ }
181
+ catch (error) {
182
+ const msg = `Error adding subscriptions: ${error instanceof Error ? error.message : String(error)}`;
183
+ await this.safeCallErrorHandler(new Error(msg));
184
+ }
185
+ }
186
+ /**
187
+ * Ensures the periodic intervals are running.
188
+ * Called after clearState() when new subscriptions are added.
189
+ */
190
+ ensureIntervalsRunning() {
191
+ if (!this.reconnectInterval) {
192
+ this.scheduleReconnectionCheck();
193
+ }
194
+ if (!this.pendingFlushInterval) {
195
+ this.pendingFlushInterval = setInterval(() => {
196
+ this.flushPendingSubscriptions();
197
+ }, this.pendingFlushIntervalMs);
198
+ }
199
+ }
200
+ /**
201
+ * Schedules the next reconnection check.
202
+ * Uses a fixed interval (default 5 seconds) between checks.
203
+ */
204
+ scheduleReconnectionCheck() {
205
+ if (this.reconnectInterval) {
206
+ clearTimeout(this.reconnectInterval);
207
+ }
208
+ this.reconnectInterval = setTimeout(async () => {
209
+ await this.checkReconnection();
210
+ // Schedule next check (only if not cleared)
211
+ if (this.reconnectInterval) {
212
+ this.scheduleReconnectionCheck();
213
+ }
214
+ }, this.reconnectIntervalMs);
215
+ }
216
+ /**
217
+ * Safely calls the error handler, catching any exceptions thrown by it.
218
+ * Prevents user handler exceptions from breaking internal logic.
219
+ */
220
+ async safeCallErrorHandler(error) {
221
+ var _a, _b;
222
+ try {
223
+ await ((_b = (_a = this.handlers).onError) === null || _b === void 0 ? void 0 : _b.call(_a, error));
224
+ }
225
+ catch (handlerErr) {
226
+ logger_1.logger.warn({
227
+ message: 'Error in onError handler',
228
+ originalError: error.message,
229
+ handlerError: handlerErr instanceof Error ? handlerErr.message : String(handlerErr),
230
+ managerId: this.managerId,
231
+ });
232
+ }
233
+ }
234
+ /**
235
+ * Removes subscriptions.
236
+ *
237
+ * @param assetIdsToRemove - The asset IDs to remove subscriptions for.
238
+ */
239
+ async removeSubscriptions(assetIdsToRemove) {
240
+ try {
241
+ for (const assetId of assetIdsToRemove) {
242
+ // Remove from pending subscribe if it's there
243
+ if (this.pendingSubscribeAssetIds.delete(assetId)) {
244
+ continue; // Was only pending, no need to send unsubscribe
245
+ }
246
+ // If subscribed, add to pending unsubscribe
247
+ if (this.subscribedAssetIds.has(assetId)) {
248
+ this.pendingUnsubscribeAssetIds.add(assetId);
249
+ }
250
+ // Note: We don't clear the book cache here because the unsubscription
251
+ // hasn't been sent yet. The cache entry will be cleared when the
252
+ // unsubscription is flushed (after the asset is removed from subscribedAssetIds).
253
+ }
254
+ }
255
+ catch (error) {
256
+ const errMsg = `Error removing subscriptions: ${error instanceof Error ? error.message : String(error)}`;
257
+ await this.safeCallErrorHandler(new Error(errMsg));
258
+ }
259
+ }
260
+ /**
261
+ * Get all currently monitored asset IDs.
262
+ * This includes both successfully subscribed assets and pending subscriptions.
263
+ *
264
+ * @returns Array of asset IDs being monitored.
265
+ */
266
+ getAssetIds() {
267
+ const allAssets = new Set(this.subscribedAssetIds);
268
+ for (const assetId of this.pendingSubscribeAssetIds) {
269
+ allAssets.add(assetId);
270
+ }
271
+ // Exclude pending unsubscribes
272
+ for (const assetId of this.pendingUnsubscribeAssetIds) {
273
+ allAssets.delete(assetId);
274
+ }
275
+ return Array.from(allAssets);
276
+ }
277
+ /**
278
+ * Returns statistics about the current state of the subscription manager.
279
+ */
280
+ getStatistics() {
281
+ var _a;
282
+ const isOpen = ((_a = this.wsClient) === null || _a === void 0 ? void 0 : _a.readyState) === ws_1.default.OPEN;
283
+ return {
284
+ openWebSockets: isOpen ? 1 : 0,
285
+ assetIds: this.getAssetIds().length,
286
+ pendingSubscribeCount: this.pendingSubscribeAssetIds.size,
287
+ pendingUnsubscribeCount: this.pendingUnsubscribeAssetIds.size,
288
+ pendingAssetIds: this.pendingSubscribeAssetIds.size + this.pendingUnsubscribeAssetIds.size,
289
+ };
290
+ }
291
+ /**
292
+ * Flush pending subscriptions and unsubscriptions to the WebSocket.
293
+ *
294
+ * SUBSCRIPTION PROTOCOL NOTE:
295
+ * The Polymarket WebSocket protocol does NOT send any confirmation or acknowledgment
296
+ * messages for subscribe/unsubscribe operations. The server silently processes these
297
+ * requests. We optimistically assume success after sending. If the server rejects
298
+ * a request (e.g., invalid asset ID), events for those assets simply won't arrive -
299
+ * there is no error response to handle.
300
+ *
301
+ * This means:
302
+ * - We cannot definitively know if a subscription succeeded
303
+ * - We cannot definitively know if an unsubscription succeeded
304
+ * - The only indication of failure is the absence of expected events
305
+ */
306
+ flushPendingSubscriptions() {
307
+ if (!this.wsClient || this.wsClient.readyState !== ws_1.default.OPEN) {
308
+ return;
309
+ }
310
+ // Process unsubscriptions first
311
+ if (this.pendingUnsubscribeAssetIds.size > 0) {
312
+ const toUnsubscribe = Array.from(this.pendingUnsubscribeAssetIds);
313
+ const message = {
314
+ operation: 'unsubscribe',
315
+ assets_ids: toUnsubscribe,
316
+ custom_feature_enabled: this.enableCustomFeatures,
317
+ };
318
+ // IMPORTANT: The Polymarket WebSocket protocol does NOT send any confirmation
319
+ // or acknowledgment message for subscribe/unsubscribe operations. The server
320
+ // silently accepts the request. We optimistically assume success after sending.
321
+ // If the server rejects the request, events for those assets simply won't arrive
322
+ // (there is no error response to handle).
323
+ try {
324
+ this.wsClient.send(JSON.stringify(message));
325
+ // Remove from subscribed, clear pending, and clear book cache for unsubscribed assets
326
+ for (const assetId of toUnsubscribe) {
327
+ this.subscribedAssetIds.delete(assetId);
328
+ this.bookCache.clear(assetId);
329
+ }
330
+ this.pendingUnsubscribeAssetIds.clear();
331
+ logger_1.logger.info({
332
+ message: `Unsubscribed from ${toUnsubscribe.length} asset(s)`,
333
+ managerId: this.managerId,
334
+ });
335
+ }
336
+ catch (error) {
337
+ logger_1.logger.warn({
338
+ message: 'Failed to send unsubscribe message',
339
+ error: error instanceof Error ? error.message : String(error),
340
+ });
341
+ }
342
+ }
343
+ // Process subscriptions
344
+ if (this.pendingSubscribeAssetIds.size > 0) {
345
+ const toSubscribe = Array.from(this.pendingSubscribeAssetIds);
346
+ const message = {
347
+ operation: 'subscribe',
348
+ assets_ids: toSubscribe,
349
+ custom_feature_enabled: this.enableCustomFeatures,
350
+ };
351
+ // IMPORTANT: The Polymarket WebSocket protocol does NOT send any confirmation
352
+ // or acknowledgment message for subscribe/unsubscribe operations. The server
353
+ // silently accepts the request. We optimistically assume success after sending.
354
+ // If the server rejects the request, events for those assets simply won't arrive
355
+ // (there is no error response to handle).
356
+ try {
357
+ this.wsClient.send(JSON.stringify(message));
358
+ // Move to subscribed and clear pending
359
+ for (const assetId of toSubscribe) {
360
+ this.subscribedAssetIds.add(assetId);
361
+ }
362
+ this.pendingSubscribeAssetIds.clear();
363
+ logger_1.logger.info({
364
+ message: `Subscribed to ${toSubscribe.length} asset(s)`,
365
+ managerId: this.managerId,
366
+ });
367
+ }
368
+ catch (error) {
369
+ logger_1.logger.warn({
370
+ message: 'Failed to send subscribe message',
371
+ error: error instanceof Error ? error.message : String(error),
372
+ });
373
+ }
374
+ }
375
+ // Close WebSocket if no assets remain and no pending unsubscriptions
376
+ // (pendingUnsubscribeAssetIds is already cleared at this point)
377
+ if (this.subscribedAssetIds.size === 0 &&
378
+ this.pendingSubscribeAssetIds.size === 0 &&
379
+ this.pendingUnsubscribeAssetIds.size === 0) {
380
+ this.closeWebSocket();
381
+ }
382
+ }
383
+ /**
384
+ * Closes the WebSocket connection and cleans up related resources.
385
+ */
386
+ closeWebSocket() {
387
+ if (this.wsClient) {
388
+ logger_1.logger.info({
389
+ message: 'Closing WebSocket - no assets to monitor',
390
+ managerId: this.managerId,
391
+ });
392
+ this.wsClient.removeAllListeners();
393
+ this.wsClient.close();
394
+ this.wsClient = null;
395
+ }
396
+ this.status = WebSocketSubscriptions_1.WebSocketConnectionStatus.DISCONNECTED;
397
+ this.connecting = false;
398
+ if (this.pingInterval) {
399
+ clearInterval(this.pingInterval);
400
+ this.pingInterval = undefined;
401
+ }
402
+ }
403
+ /**
404
+ * Check if we need to reconnect.
405
+ * Note: Assets are moved to pending in handleClose/handleError handlers,
406
+ * so this method only needs to check if reconnection is needed.
407
+ */
408
+ async checkReconnection() {
409
+ // If we have pending assets but no connection, reconnect
410
+ const hasPendingAssets = this.pendingSubscribeAssetIds.size > 0;
411
+ const isDisconnected = !this.wsClient || this.wsClient.readyState !== ws_1.default.OPEN;
412
+ if (hasPendingAssets && isDisconnected && !this.connecting) {
413
+ logger_1.logger.info({
414
+ message: 'Reconnection check - attempting to reconnect',
415
+ managerId: this.managerId,
416
+ pendingCount: this.pendingSubscribeAssetIds.size,
417
+ });
418
+ // Clear stale book cache data - will be repopulated after reconnection
419
+ this.bookCache.clear();
420
+ await this.connect();
421
+ }
422
+ }
423
+ /**
424
+ * Establish the WebSocket connection.
425
+ */
426
+ async connect() {
427
+ var _a;
428
+ if (this.connecting) {
429
+ return;
430
+ }
431
+ if (((_a = this.wsClient) === null || _a === void 0 ? void 0 : _a.readyState) === ws_1.default.OPEN) {
432
+ return;
433
+ }
434
+ // No assets to subscribe to
435
+ if (this.pendingSubscribeAssetIds.size === 0 && this.subscribedAssetIds.size === 0) {
436
+ return;
437
+ }
438
+ this.connecting = true;
439
+ this.status = WebSocketSubscriptions_1.WebSocketConnectionStatus.CONNECTING;
440
+ try {
441
+ logger_1.logger.info({
442
+ message: 'Connecting to CLOB WebSocket',
443
+ managerId: this.managerId,
444
+ pendingAssetCount: this.pendingSubscribeAssetIds.size,
445
+ });
446
+ this.wsClient = new ws_1.default(CLOB_WSS_URL);
447
+ // Set up event handlers immediately (handlers are set up before any events can fire)
448
+ this.setupEventHandlers();
449
+ // Connection timeout
450
+ this.connectionTimeout = setTimeout(async () => {
451
+ if (this.connecting && this.wsClient && this.wsClient.readyState !== ws_1.default.OPEN) {
452
+ logger_1.logger.warn({
453
+ message: 'WebSocket connection timeout',
454
+ managerId: this.managerId,
455
+ });
456
+ this.status = WebSocketSubscriptions_1.WebSocketConnectionStatus.DISCONNECTED;
457
+ this.connecting = false;
458
+ if (this.wsClient) {
459
+ this.wsClient.removeAllListeners();
460
+ this.wsClient.close();
461
+ this.wsClient = null;
462
+ }
463
+ // Notify error handler about the timeout
464
+ await this.safeCallErrorHandler(new Error('WebSocket connection timeout after 30s'));
465
+ }
466
+ }, (0, ms_1.default)('30s'));
467
+ }
468
+ catch (err) {
469
+ this.status = WebSocketSubscriptions_1.WebSocketConnectionStatus.DISCONNECTED;
470
+ this.connecting = false;
471
+ throw err;
472
+ }
473
+ }
474
+ /**
475
+ * Sets up event handlers for the WebSocket connection.
476
+ */
477
+ setupEventHandlers() {
478
+ const ws = this.wsClient;
479
+ if (!ws)
480
+ return;
481
+ const handleOpen = async () => {
482
+ var _a, _b;
483
+ this.status = WebSocketSubscriptions_1.WebSocketConnectionStatus.CONNECTED;
484
+ this.connecting = false;
485
+ if (this.connectionTimeout) {
486
+ clearTimeout(this.connectionTimeout);
487
+ this.connectionTimeout = undefined;
488
+ }
489
+ // Send an empty MarketSubscriptionMessage as the initial handshake.
490
+ // The Polymarket WebSocket protocol requires a 'market' type message as the first message.
491
+ // We send it with an empty assets_ids array, and then use 'subscribe' operation messages
492
+ // for all actual subscriptions (via flushPendingSubscriptions) to keep the subscription
493
+ // logic consistent in one place.
494
+ try {
495
+ const initMessage = {
496
+ assets_ids: [],
497
+ type: 'market',
498
+ custom_feature_enabled: this.enableCustomFeatures,
499
+ };
500
+ ws.send(JSON.stringify(initMessage));
501
+ }
502
+ catch (error) {
503
+ logger_1.logger.warn({
504
+ message: 'Failed to send initial market message',
505
+ error: error instanceof Error ? error.message : String(error),
506
+ managerId: this.managerId,
507
+ });
508
+ // Close and let reconnection logic handle retry
509
+ // Wrap in try-catch as close() can throw in edge cases
510
+ try {
511
+ ws.close();
512
+ }
513
+ catch (closeErr) {
514
+ logger_1.logger.debug({
515
+ message: 'Error closing WebSocket after init message failure (safe to ignore)',
516
+ error: closeErr instanceof Error ? closeErr.message : String(closeErr),
517
+ managerId: this.managerId,
518
+ });
519
+ }
520
+ return;
521
+ }
522
+ const pendingAssets = Array.from(this.pendingSubscribeAssetIds);
523
+ // Safely call open handler
524
+ try {
525
+ await ((_b = (_a = this.handlers).onWSOpen) === null || _b === void 0 ? void 0 : _b.call(_a, this.managerId, pendingAssets));
526
+ }
527
+ catch (handlerErr) {
528
+ logger_1.logger.warn({
529
+ message: 'Error in onWSOpen handler',
530
+ error: handlerErr instanceof Error ? handlerErr.message : String(handlerErr),
531
+ managerId: this.managerId,
532
+ });
533
+ }
534
+ // Immediately flush pending subscriptions now that we're connected
535
+ this.flushPendingSubscriptions();
536
+ // Start ping interval with jitter per-ping
537
+ const basePingIntervalMs = (0, ms_1.default)('20s');
538
+ this.pingInterval = setInterval(() => {
539
+ if (!ws || ws.readyState !== ws_1.default.OPEN) {
540
+ if (this.pingInterval) {
541
+ clearInterval(this.pingInterval);
542
+ this.pingInterval = undefined;
543
+ }
544
+ return;
545
+ }
546
+ // Add jitter by randomly delaying the ping within a ±5s window
547
+ const jitterMs = (0, crypto_1.randomInt)(0, (0, ms_1.default)('5s'));
548
+ setTimeout(() => {
549
+ if (ws && ws.readyState === ws_1.default.OPEN) {
550
+ try {
551
+ ws.ping();
552
+ }
553
+ catch (pingErr) {
554
+ // Ping can fail if the socket is closing or in a bad state.
555
+ // This is not critical - the socket will be cleaned up on close/error events.
556
+ logger_1.logger.debug({
557
+ message: 'Ping failed (safe to ignore)',
558
+ error: pingErr instanceof Error ? pingErr.message : String(pingErr),
559
+ });
560
+ }
561
+ }
562
+ }, jitterMs);
563
+ }, basePingIntervalMs);
564
+ };
565
+ const handleMessage = async (data) => {
566
+ try {
567
+ const messageStr = data.toString();
568
+ const normalizedMessageStr = messageStr.trim().toUpperCase();
569
+ if (normalizedMessageStr === 'PONG') {
570
+ return;
571
+ }
572
+ let events = [];
573
+ try {
574
+ const parsedData = JSON.parse(messageStr);
575
+ events = Array.isArray(parsedData) ? parsedData : [parsedData];
576
+ }
577
+ catch (err) {
578
+ await this.safeCallErrorHandler(new Error(`Not JSON: ${messageStr}`));
579
+ return;
580
+ }
581
+ events = lodash_1.default.filter(events, (event) => {
582
+ if (!event)
583
+ return false;
584
+ if ((0, PolymarketWebSocket_1.isPriceChangeEvent)(event)) {
585
+ return event.price_changes && event.price_changes.length > 0;
586
+ }
587
+ // Market-level events don't have asset_id, so include them
588
+ if ((0, PolymarketWebSocket_1.isNewMarketEvent)(event) || (0, PolymarketWebSocket_1.isMarketResolvedEvent)(event)) {
589
+ return true;
590
+ }
591
+ // Asset-level events must have an asset_id
592
+ return 'asset_id' in event && lodash_1.default.size(event.asset_id) > 0;
593
+ });
594
+ const bookEvents = [];
595
+ const lastTradeEvents = [];
596
+ const tickEvents = [];
597
+ const priceChangeEvents = [];
598
+ const bestBidAskEvents = [];
599
+ const newMarketEvents = [];
600
+ const marketResolvedEvents = [];
601
+ for (const event of events) {
602
+ if ((0, PolymarketWebSocket_1.isPriceChangeEvent)(event)) {
603
+ const relevantChanges = event.price_changes.filter(pc => this.subscribedAssetIds.has(pc.asset_id));
604
+ if (relevantChanges.length === 0)
605
+ continue;
606
+ priceChangeEvents.push({
607
+ ...event,
608
+ price_changes: relevantChanges
609
+ });
610
+ }
611
+ else if ((0, PolymarketWebSocket_1.isNewMarketEvent)(event)) {
612
+ // Market-level events bypass asset filtering
613
+ newMarketEvents.push(event);
614
+ }
615
+ else if ((0, PolymarketWebSocket_1.isMarketResolvedEvent)(event)) {
616
+ // Market-level events bypass asset filtering
617
+ marketResolvedEvents.push(event);
618
+ }
619
+ else {
620
+ // Safely check asset_id existence
621
+ const assetId = 'asset_id' in event ? event.asset_id : undefined;
622
+ if (!assetId || !this.subscribedAssetIds.has(assetId))
623
+ continue;
624
+ if ((0, PolymarketWebSocket_1.isBookEvent)(event)) {
625
+ bookEvents.push(event);
626
+ }
627
+ else if ((0, PolymarketWebSocket_1.isLastTradePriceEvent)(event)) {
628
+ lastTradeEvents.push(event);
629
+ }
630
+ else if ((0, PolymarketWebSocket_1.isTickSizeChangeEvent)(event)) {
631
+ tickEvents.push(event);
632
+ }
633
+ else if ((0, PolymarketWebSocket_1.isBestBidAskEvent)(event)) {
634
+ bestBidAskEvents.push(event);
635
+ }
636
+ else {
637
+ await this.safeCallErrorHandler(new Error(`Unknown event: ${JSON.stringify(event)}`));
638
+ }
639
+ }
640
+ }
641
+ // Wrap each handler call in try-catch to prevent one failure
642
+ // from breaking the entire event loop
643
+ try {
644
+ await this.handleBookEvents(bookEvents);
645
+ }
646
+ catch (err) {
647
+ logger_1.logger.warn({
648
+ message: 'Error in handleBookEvents',
649
+ error: err instanceof Error ? err.message : String(err),
650
+ managerId: this.managerId,
651
+ });
652
+ }
653
+ try {
654
+ await this.handleTickEvents(tickEvents);
655
+ }
656
+ catch (err) {
657
+ logger_1.logger.warn({
658
+ message: 'Error in handleTickEvents',
659
+ error: err instanceof Error ? err.message : String(err),
660
+ managerId: this.managerId,
661
+ });
662
+ }
663
+ try {
664
+ await this.handlePriceChangeEvents(priceChangeEvents);
665
+ }
666
+ catch (err) {
667
+ logger_1.logger.warn({
668
+ message: 'Error in handlePriceChangeEvents',
669
+ error: err instanceof Error ? err.message : String(err),
670
+ managerId: this.managerId,
671
+ });
672
+ }
673
+ try {
674
+ await this.handleLastTradeEvents(lastTradeEvents);
675
+ }
676
+ catch (err) {
677
+ logger_1.logger.warn({
678
+ message: 'Error in handleLastTradeEvents',
679
+ error: err instanceof Error ? err.message : String(err),
680
+ managerId: this.managerId,
681
+ });
682
+ }
683
+ try {
684
+ await this.handleBestBidAskEvents(bestBidAskEvents);
685
+ }
686
+ catch (err) {
687
+ logger_1.logger.warn({
688
+ message: 'Error in handleBestBidAskEvents',
689
+ error: err instanceof Error ? err.message : String(err),
690
+ managerId: this.managerId,
691
+ });
692
+ }
693
+ try {
694
+ await this.handleNewMarketEvents(newMarketEvents);
695
+ }
696
+ catch (err) {
697
+ logger_1.logger.warn({
698
+ message: 'Error in handleNewMarketEvents',
699
+ error: err instanceof Error ? err.message : String(err),
700
+ managerId: this.managerId,
701
+ });
702
+ }
703
+ try {
704
+ await this.handleMarketResolvedEvents(marketResolvedEvents);
705
+ }
706
+ catch (err) {
707
+ logger_1.logger.warn({
708
+ message: 'Error in handleMarketResolvedEvents',
709
+ error: err instanceof Error ? err.message : String(err),
710
+ managerId: this.managerId,
711
+ });
712
+ }
713
+ }
714
+ catch (err) {
715
+ await this.safeCallErrorHandler(new Error(`Error handling message: ${err}`));
716
+ }
717
+ };
718
+ const handlePong = () => {
719
+ // Pong received - connection is alive
720
+ };
721
+ const handleError = async (err) => {
722
+ var _a, _b;
723
+ this.status = WebSocketSubscriptions_1.WebSocketConnectionStatus.DISCONNECTED;
724
+ this.connecting = false;
725
+ // Clean up WebSocket reference - close the socket before nullifying
726
+ // Wrap in try-catch because close() can throw if socket is in a bad state
727
+ if (this.wsClient) {
728
+ this.wsClient.removeAllListeners();
729
+ try {
730
+ this.wsClient.close();
731
+ }
732
+ catch (closeErr) {
733
+ logger_1.logger.debug({
734
+ message: 'Error closing WebSocket in error handler (safe to ignore)',
735
+ error: closeErr instanceof Error ? closeErr.message : String(closeErr),
736
+ managerId: this.managerId,
737
+ });
738
+ }
739
+ this.wsClient = null;
740
+ }
741
+ if (this.pingInterval) {
742
+ clearInterval(this.pingInterval);
743
+ this.pingInterval = undefined;
744
+ }
745
+ if (this.connectionTimeout) {
746
+ clearTimeout(this.connectionTimeout);
747
+ this.connectionTimeout = undefined;
748
+ }
749
+ // Move subscribed assets back to pending for re-subscription,
750
+ // but skip assets that user wanted to unsubscribe (preserve user intent)
751
+ for (const assetId of this.subscribedAssetIds) {
752
+ if (!this.pendingUnsubscribeAssetIds.has(assetId)) {
753
+ this.pendingSubscribeAssetIds.add(assetId);
754
+ }
755
+ }
756
+ this.subscribedAssetIds.clear();
757
+ // Clear pending unsubscribes - they were either:
758
+ // 1. Successfully excluded from re-subscription (above), or
759
+ // 2. Were never subscribed anyway (user's intent is preserved)
760
+ this.pendingUnsubscribeAssetIds.clear();
761
+ // Clear the book cache - data is stale and will be repopulated on reconnection
762
+ this.bookCache.clear();
763
+ // Safely call error handler
764
+ try {
765
+ await ((_b = (_a = this.handlers).onError) === null || _b === void 0 ? void 0 : _b.call(_a, new Error(`WebSocket error: ${err.message}`)));
766
+ }
767
+ catch (handlerErr) {
768
+ logger_1.logger.warn({
769
+ message: 'Error in onError handler',
770
+ error: handlerErr instanceof Error ? handlerErr.message : String(handlerErr),
771
+ managerId: this.managerId,
772
+ });
773
+ }
774
+ };
775
+ const handleClose = async (code, reason) => {
776
+ var _a, _b;
777
+ this.status = WebSocketSubscriptions_1.WebSocketConnectionStatus.DISCONNECTED;
778
+ this.connecting = false;
779
+ // Clean up WebSocket reference
780
+ if (this.wsClient) {
781
+ this.wsClient.removeAllListeners();
782
+ this.wsClient = null;
783
+ }
784
+ if (this.pingInterval) {
785
+ clearInterval(this.pingInterval);
786
+ this.pingInterval = undefined;
787
+ }
788
+ if (this.connectionTimeout) {
789
+ clearTimeout(this.connectionTimeout);
790
+ this.connectionTimeout = undefined;
791
+ }
792
+ // Move subscribed assets back to pending for re-subscription,
793
+ // but skip assets that user wanted to unsubscribe (preserve user intent)
794
+ for (const assetId of this.subscribedAssetIds) {
795
+ if (!this.pendingUnsubscribeAssetIds.has(assetId)) {
796
+ this.pendingSubscribeAssetIds.add(assetId);
797
+ }
798
+ }
799
+ this.subscribedAssetIds.clear();
800
+ // Clear pending unsubscribes - they were either:
801
+ // 1. Successfully excluded from re-subscription (above), or
802
+ // 2. Were never subscribed anyway (user's intent is preserved)
803
+ this.pendingUnsubscribeAssetIds.clear();
804
+ // Clear the book cache - data is stale and will be repopulated on reconnection
805
+ this.bookCache.clear();
806
+ // Safely call close handler
807
+ try {
808
+ await ((_b = (_a = this.handlers).onWSClose) === null || _b === void 0 ? void 0 : _b.call(_a, this.managerId, code, (reason === null || reason === void 0 ? void 0 : reason.toString()) || ''));
809
+ }
810
+ catch (handlerErr) {
811
+ logger_1.logger.warn({
812
+ message: 'Error in onWSClose handler',
813
+ error: handlerErr instanceof Error ? handlerErr.message : String(handlerErr),
814
+ managerId: this.managerId,
815
+ });
816
+ }
817
+ };
818
+ ws.removeAllListeners();
819
+ ws.on('open', handleOpen);
820
+ ws.on('message', handleMessage);
821
+ ws.on('pong', handlePong);
822
+ ws.on('error', handleError);
823
+ ws.on('close', handleClose);
824
+ }
825
+ /**
826
+ * Handles book events by updating the cache and notifying listeners.
827
+ */
828
+ async handleBookEvents(bookEvents) {
829
+ var _a, _b;
830
+ if (bookEvents.length) {
831
+ for (const event of bookEvents) {
832
+ this.bookCache.replaceBook(event);
833
+ }
834
+ await ((_b = (_a = this.handlers).onBook) === null || _b === void 0 ? void 0 : _b.call(_a, bookEvents));
835
+ }
836
+ }
837
+ /**
838
+ * Handles tick size change events by notifying listeners.
839
+ */
840
+ async handleTickEvents(tickEvents) {
841
+ var _a, _b;
842
+ if (tickEvents.length) {
843
+ await ((_b = (_a = this.handlers).onTickSizeChange) === null || _b === void 0 ? void 0 : _b.call(_a, tickEvents));
844
+ }
845
+ }
846
+ /**
847
+ * Handles price change events.
848
+ */
849
+ async handlePriceChangeEvents(priceChangeEvents) {
850
+ var _a, _b, _c, _d;
851
+ if (priceChangeEvents.length) {
852
+ await ((_b = (_a = this.handlers).onPriceChange) === null || _b === void 0 ? void 0 : _b.call(_a, priceChangeEvents));
853
+ for (const event of priceChangeEvents) {
854
+ try {
855
+ this.bookCache.upsertPriceChange(event);
856
+ }
857
+ catch (err) {
858
+ logger_1.logger.debug({
859
+ message: `Skipping derived future price calculation price_change: book not found for asset`,
860
+ event: event,
861
+ error: err === null || err === void 0 ? void 0 : err.message
862
+ });
863
+ continue;
864
+ }
865
+ const assetIds = event.price_changes.map(pc => pc.asset_id);
866
+ for (const assetId of assetIds) {
867
+ let spreadOver10Cents;
868
+ try {
869
+ spreadOver10Cents = this.bookCache.spreadOver(assetId, 0.1);
870
+ }
871
+ catch (err) {
872
+ logger_1.logger.debug({
873
+ message: 'Skipping derived future price calculation for price_change: error calculating spread',
874
+ asset_id: assetId,
875
+ event: event,
876
+ error: err === null || err === void 0 ? void 0 : err.message
877
+ });
878
+ continue;
879
+ }
880
+ if (!spreadOver10Cents) {
881
+ let newPrice;
882
+ try {
883
+ newPrice = this.bookCache.midpoint(assetId);
884
+ }
885
+ catch (err) {
886
+ logger_1.logger.debug({
887
+ message: 'Skipping derived future price calculation for price_change: error calculating midpoint',
888
+ asset_id: assetId,
889
+ event: event,
890
+ error: err === null || err === void 0 ? void 0 : err.message
891
+ });
892
+ continue;
893
+ }
894
+ const bookEntry = this.bookCache.getBookEntry(assetId);
895
+ if (!bookEntry) {
896
+ logger_1.logger.debug({
897
+ message: 'Skipping derived future price calculation price_change: book not found for asset',
898
+ asset_id: assetId,
899
+ event: event,
900
+ });
901
+ continue;
902
+ }
903
+ if (newPrice !== bookEntry.price) {
904
+ bookEntry.price = newPrice;
905
+ const priceUpdateEvent = {
906
+ asset_id: assetId,
907
+ event_type: 'price_update',
908
+ triggeringEvent: event,
909
+ timestamp: event.timestamp,
910
+ book: { bids: bookEntry.bids, asks: bookEntry.asks },
911
+ price: newPrice,
912
+ midpoint: bookEntry.midpoint || '',
913
+ spread: bookEntry.spread || '',
914
+ };
915
+ await ((_d = (_c = this.handlers).onPolymarketPriceUpdate) === null || _d === void 0 ? void 0 : _d.call(_c, [priceUpdateEvent]));
916
+ }
917
+ }
918
+ }
919
+ }
920
+ }
921
+ }
922
+ /**
923
+ * Handles last trade price events.
924
+ */
925
+ async handleLastTradeEvents(lastTradeEvents) {
926
+ var _a, _b, _c, _d;
927
+ if (lastTradeEvents.length) {
928
+ await ((_b = (_a = this.handlers).onLastTradePrice) === null || _b === void 0 ? void 0 : _b.call(_a, lastTradeEvents));
929
+ for (const event of lastTradeEvents) {
930
+ let spreadOver10Cents;
931
+ try {
932
+ spreadOver10Cents = this.bookCache.spreadOver(event.asset_id, 0.1);
933
+ }
934
+ catch (err) {
935
+ logger_1.logger.debug({
936
+ message: 'Skipping derived future price calculation for last_trade_price: error calculating spread',
937
+ asset_id: event.asset_id,
938
+ event: event,
939
+ error: err === null || err === void 0 ? void 0 : err.message
940
+ });
941
+ continue;
942
+ }
943
+ if (spreadOver10Cents) {
944
+ const newPrice = parseFloat(event.price).toString();
945
+ const bookEntry = this.bookCache.getBookEntry(event.asset_id);
946
+ if (!bookEntry) {
947
+ logger_1.logger.debug({
948
+ message: 'Skipping derived future price calculation last_trade_price: book not found for asset',
949
+ asset_id: event.asset_id,
950
+ event: event,
951
+ });
952
+ continue;
953
+ }
954
+ if (newPrice !== bookEntry.price) {
955
+ bookEntry.price = newPrice;
956
+ const priceUpdateEvent = {
957
+ asset_id: event.asset_id,
958
+ event_type: 'price_update',
959
+ triggeringEvent: event,
960
+ timestamp: event.timestamp,
961
+ book: { bids: bookEntry.bids, asks: bookEntry.asks },
962
+ price: newPrice,
963
+ midpoint: bookEntry.midpoint || '',
964
+ spread: bookEntry.spread || '',
965
+ };
966
+ await ((_d = (_c = this.handlers).onPolymarketPriceUpdate) === null || _d === void 0 ? void 0 : _d.call(_c, [priceUpdateEvent]));
967
+ }
968
+ }
969
+ }
970
+ }
971
+ }
972
+ /**
973
+ * Handles best bid/ask events.
974
+ */
975
+ async handleBestBidAskEvents(bestBidAskEvents) {
976
+ var _a, _b;
977
+ if (bestBidAskEvents.length) {
978
+ await ((_b = (_a = this.handlers).onBestBidAsk) === null || _b === void 0 ? void 0 : _b.call(_a, bestBidAskEvents));
979
+ }
980
+ }
981
+ /**
982
+ * Handles new market events.
983
+ * These events bypass asset-level filtering as they are market-level events.
984
+ */
985
+ async handleNewMarketEvents(newMarketEvents) {
986
+ var _a, _b;
987
+ if (newMarketEvents.length) {
988
+ try {
989
+ await ((_b = (_a = this.handlers).onNewMarket) === null || _b === void 0 ? void 0 : _b.call(_a, newMarketEvents));
990
+ }
991
+ catch (handlerErr) {
992
+ logger_1.logger.warn({
993
+ message: 'Error in onNewMarket handler',
994
+ error: handlerErr instanceof Error ? handlerErr.message : String(handlerErr),
995
+ managerId: this.managerId,
996
+ });
997
+ }
998
+ }
999
+ }
1000
+ /**
1001
+ * Handles market resolved events.
1002
+ * These events bypass asset-level filtering as they are market-level events.
1003
+ */
1004
+ async handleMarketResolvedEvents(marketResolvedEvents) {
1005
+ var _a, _b;
1006
+ if (marketResolvedEvents.length) {
1007
+ try {
1008
+ await ((_b = (_a = this.handlers).onMarketResolved) === null || _b === void 0 ? void 0 : _b.call(_a, marketResolvedEvents));
1009
+ }
1010
+ catch (handlerErr) {
1011
+ logger_1.logger.warn({
1012
+ message: 'Error in onMarketResolved handler',
1013
+ error: handlerErr instanceof Error ? handlerErr.message : String(handlerErr),
1014
+ managerId: this.managerId,
1015
+ });
1016
+ }
1017
+ }
1018
+ }
1019
+ }
1020
+ exports.WSSubscriptionManager = WSSubscriptionManager;