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