@nevuamarkets/poly-websockets
Version:
Plug-and-play Polymarket WebSocket price alerts
916 lines (915 loc) • 41.4 kB
JavaScript
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.WSSubscriptionManager = void 0;
const ms_1 = __importDefault(require("ms"));
const ws_1 = __importDefault(require("ws"));
const uuid_1 = require("uuid");
const crypto_1 = require("crypto");
const PolymarketWebSocket_1 = require("./types/PolymarketWebSocket");
const WebSocketSubscriptions_1 = require("./types/WebSocketSubscriptions");
const OrderBookCache_1 = require("./modules/OrderBookCache");
const logger_1 = require("./logger");
const lodash_1 = __importDefault(require("lodash"));
// Note: We intentionally use a static reconnection interval rather than exponential backoff.
// Perhaps change this to exponential backoff in the future.
const DEFAULT_RECONNECT_INTERVAL_MS = (0, ms_1.default)('5s');
const DEFAULT_PENDING_FLUSH_INTERVAL_MS = (0, ms_1.default)('100ms');
const CLOB_WSS_URL = 'wss://ws-subscriptions-clob.polymarket.com/ws/market';
/**
* WebSocket Subscription Manager for Polymarket CLOB WebSocket.
*
* Each instance manages a single WebSocket connection and tracks:
* - Subscribed assets: Successfully subscribed to the WebSocket
* - Pending assets: Waiting to be subscribed (batched and flushed periodically)
*
* Instances are fully independent - no shared state between managers.
*/
class WSSubscriptionManager {
constructor(userHandlers, options) {
// WebSocket connection
this.wsClient = null;
this.status = WebSocketSubscriptions_1.WebSocketConnectionStatus.DISCONNECTED;
this.connecting = false;
// Asset tracking
this.subscribedAssetIds = new Set();
this.pendingSubscribeAssetIds = new Set();
this.pendingUnsubscribeAssetIds = new Set();
this.managerId = (0, uuid_1.v4)();
this.bookCache = new OrderBookCache_1.OrderBookCache();
this.reconnectIntervalMs = (options === null || options === void 0 ? void 0 : options.reconnectAndCleanupIntervalMs) || DEFAULT_RECONNECT_INTERVAL_MS;
this.pendingFlushIntervalMs = (options === null || options === void 0 ? void 0 : options.pendingFlushIntervalMs) || DEFAULT_PENDING_FLUSH_INTERVAL_MS;
this.handlers = {
onBook: async (events) => {
await this.actOnSubscribedEvents(events, userHandlers.onBook);
},
onLastTradePrice: async (events) => {
await this.actOnSubscribedEvents(events, userHandlers.onLastTradePrice);
},
onTickSizeChange: async (events) => {
await this.actOnSubscribedEvents(events, userHandlers.onTickSizeChange);
},
onPriceChange: async (events) => {
await this.actOnSubscribedEvents(events, userHandlers.onPriceChange);
},
onPolymarketPriceUpdate: async (events) => {
await this.actOnSubscribedEvents(events, userHandlers.onPolymarketPriceUpdate);
},
onWSClose: userHandlers.onWSClose,
onWSOpen: userHandlers.onWSOpen,
onError: userHandlers.onError
};
// Periodic reconnection check
this.scheduleReconnectionCheck();
// Periodic pending flush
this.pendingFlushInterval = setInterval(() => {
this.flushPendingSubscriptions();
}, this.pendingFlushIntervalMs);
}
/**
* Clears all WebSocket subscriptions and state.
*
* This will:
* 1. Stop all timers
* 2. Close the WebSocket connection
* 3. Clear all asset tracking
* 4. Clear the order book cache
*/
async clearState() {
// Stop all timers (reconnectInterval is now a timeout, not interval)
if (this.reconnectInterval) {
clearTimeout(this.reconnectInterval);
this.reconnectInterval = undefined;
}
if (this.pendingFlushInterval) {
clearInterval(this.pendingFlushInterval);
this.pendingFlushInterval = undefined;
}
if (this.pingInterval) {
clearInterval(this.pingInterval);
this.pingInterval = undefined;
}
if (this.connectionTimeout) {
clearTimeout(this.connectionTimeout);
this.connectionTimeout = undefined;
}
// Close WebSocket
if (this.wsClient) {
this.wsClient.removeAllListeners();
this.wsClient.close();
this.wsClient = null;
}
// Clear all asset tracking
this.subscribedAssetIds.clear();
this.pendingSubscribeAssetIds.clear();
this.pendingUnsubscribeAssetIds.clear();
// Reset status
this.status = WebSocketSubscriptions_1.WebSocketConnectionStatus.DISCONNECTED;
this.connecting = false;
// Clear the order book cache
this.bookCache.clear();
}
/**
* Filters events to only include those for subscribed assets.
* Wraps user handler calls in try-catch to prevent user errors from breaking internal logic.
* Does not call the handler if all events are filtered out.
*/
async actOnSubscribedEvents(events, action) {
events = events.filter((event) => {
if ((0, PolymarketWebSocket_1.isPriceChangeEvent)(event)) {
return event.price_changes.some(pc => this.subscribedAssetIds.has(pc.asset_id));
}
if ('asset_id' in event) {
return this.subscribedAssetIds.has(event.asset_id);
}
return false;
});
// Skip if no events passed the filter
if (events.length === 0) {
return;
}
// Wrap user handler calls in try-catch
try {
await (action === null || action === void 0 ? void 0 : action(events));
}
catch (handlerErr) {
logger_1.logger.warn({
message: 'Error in user event handler',
error: handlerErr instanceof Error ? handlerErr.message : String(handlerErr),
managerId: this.managerId,
eventCount: events.length,
});
}
}
/**
* Adds new subscriptions.
*
* Assets are added to a pending queue and will be subscribed when:
* - The WebSocket connects (initial subscription)
* - The pending flush timer fires (for new assets on an existing connection)
*
* @param assetIdsToAdd - The asset IDs to add subscriptions for.
*/
async addSubscriptions(assetIdsToAdd) {
try {
for (const assetId of assetIdsToAdd) {
// Remove from pending unsubscribe if it's there (cancel the unsubscription)
// This must happen BEFORE the subscribed check, so that adding an asset
// that was pending unsubscribe correctly cancels the unsubscription.
this.pendingUnsubscribeAssetIds.delete(assetId);
// Skip if already subscribed (pending subscribe is safe to re-add due to Set behavior)
if (this.subscribedAssetIds.has(assetId))
continue;
// Add to pending subscribe (no-op if already pending due to Set)
this.pendingSubscribeAssetIds.add(assetId);
}
// Restart intervals if they were cleared (e.g. after clearState)
this.ensureIntervalsRunning();
// Ensure we have a connection
if (!this.wsClient || this.wsClient.readyState !== ws_1.default.OPEN) {
await this.connect();
}
}
catch (error) {
const msg = `Error adding subscriptions: ${error instanceof Error ? error.message : String(error)}`;
await this.safeCallErrorHandler(new Error(msg));
}
}
/**
* Ensures the periodic intervals are running.
* Called after clearState() when new subscriptions are added.
*/
ensureIntervalsRunning() {
if (!this.reconnectInterval) {
this.scheduleReconnectionCheck();
}
if (!this.pendingFlushInterval) {
this.pendingFlushInterval = setInterval(() => {
this.flushPendingSubscriptions();
}, this.pendingFlushIntervalMs);
}
}
/**
* Schedules the next reconnection check.
* Uses a fixed interval (default 5 seconds) between checks.
*/
scheduleReconnectionCheck() {
if (this.reconnectInterval) {
clearTimeout(this.reconnectInterval);
}
this.reconnectInterval = setTimeout(async () => {
await this.checkReconnection();
// Schedule next check (only if not cleared)
if (this.reconnectInterval) {
this.scheduleReconnectionCheck();
}
}, this.reconnectIntervalMs);
}
/**
* Safely calls the error handler, catching any exceptions thrown by it.
* Prevents user handler exceptions from breaking internal logic.
*/
async safeCallErrorHandler(error) {
var _a, _b;
try {
await ((_b = (_a = this.handlers).onError) === null || _b === void 0 ? void 0 : _b.call(_a, error));
}
catch (handlerErr) {
logger_1.logger.warn({
message: 'Error in onError handler',
originalError: error.message,
handlerError: handlerErr instanceof Error ? handlerErr.message : String(handlerErr),
managerId: this.managerId,
});
}
}
/**
* Removes subscriptions.
*
* @param assetIdsToRemove - The asset IDs to remove subscriptions for.
*/
async removeSubscriptions(assetIdsToRemove) {
try {
for (const assetId of assetIdsToRemove) {
// Remove from pending subscribe if it's there
if (this.pendingSubscribeAssetIds.delete(assetId)) {
continue; // Was only pending, no need to send unsubscribe
}
// If subscribed, add to pending unsubscribe
if (this.subscribedAssetIds.has(assetId)) {
this.pendingUnsubscribeAssetIds.add(assetId);
}
// Note: We don't clear the book cache here because the unsubscription
// hasn't been sent yet. The cache entry will be cleared when the
// unsubscription is flushed (after the asset is removed from subscribedAssetIds).
}
}
catch (error) {
const errMsg = `Error removing subscriptions: ${error instanceof Error ? error.message : String(error)}`;
await this.safeCallErrorHandler(new Error(errMsg));
}
}
/**
* Get all currently monitored asset IDs.
* This includes both successfully subscribed assets and pending subscriptions.
*
* @returns Array of asset IDs being monitored.
*/
getAssetIds() {
const allAssets = new Set(this.subscribedAssetIds);
for (const assetId of this.pendingSubscribeAssetIds) {
allAssets.add(assetId);
}
// Exclude pending unsubscribes
for (const assetId of this.pendingUnsubscribeAssetIds) {
allAssets.delete(assetId);
}
return Array.from(allAssets);
}
/**
* Returns statistics about the current state of the subscription manager.
*/
getStatistics() {
var _a;
const isOpen = ((_a = this.wsClient) === null || _a === void 0 ? void 0 : _a.readyState) === ws_1.default.OPEN;
return {
openWebSockets: isOpen ? 1 : 0,
assetIds: this.getAssetIds().length,
pendingSubscribeCount: this.pendingSubscribeAssetIds.size,
pendingUnsubscribeCount: this.pendingUnsubscribeAssetIds.size,
pendingAssetIds: this.pendingSubscribeAssetIds.size + this.pendingUnsubscribeAssetIds.size,
};
}
/**
* Flush pending subscriptions and unsubscriptions to the WebSocket.
*
* SUBSCRIPTION PROTOCOL NOTE:
* The Polymarket WebSocket protocol does NOT send any confirmation or acknowledgment
* messages for subscribe/unsubscribe operations. The server silently processes these
* requests. We optimistically assume success after sending. If the server rejects
* a request (e.g., invalid asset ID), events for those assets simply won't arrive -
* there is no error response to handle.
*
* This means:
* - We cannot definitively know if a subscription succeeded
* - We cannot definitively know if an unsubscription succeeded
* - The only indication of failure is the absence of expected events
*/
flushPendingSubscriptions() {
if (!this.wsClient || this.wsClient.readyState !== ws_1.default.OPEN) {
return;
}
// Process unsubscriptions first
if (this.pendingUnsubscribeAssetIds.size > 0) {
const toUnsubscribe = Array.from(this.pendingUnsubscribeAssetIds);
const message = {
operation: 'unsubscribe',
assets_ids: toUnsubscribe,
};
// IMPORTANT: The Polymarket WebSocket protocol does NOT send any confirmation
// or acknowledgment message for subscribe/unsubscribe operations. The server
// silently accepts the request. We optimistically assume success after sending.
// If the server rejects the request, events for those assets simply won't arrive
// (there is no error response to handle).
try {
this.wsClient.send(JSON.stringify(message));
// Remove from subscribed, clear pending, and clear book cache for unsubscribed assets
for (const assetId of toUnsubscribe) {
this.subscribedAssetIds.delete(assetId);
this.bookCache.clear(assetId);
}
this.pendingUnsubscribeAssetIds.clear();
logger_1.logger.info({
message: `Unsubscribed from ${toUnsubscribe.length} asset(s)`,
managerId: this.managerId,
});
}
catch (error) {
logger_1.logger.warn({
message: 'Failed to send unsubscribe message',
error: error instanceof Error ? error.message : String(error),
});
}
}
// Process subscriptions
if (this.pendingSubscribeAssetIds.size > 0) {
const toSubscribe = Array.from(this.pendingSubscribeAssetIds);
const message = {
operation: 'subscribe',
assets_ids: toSubscribe,
};
// IMPORTANT: The Polymarket WebSocket protocol does NOT send any confirmation
// or acknowledgment message for subscribe/unsubscribe operations. The server
// silently accepts the request. We optimistically assume success after sending.
// If the server rejects the request, events for those assets simply won't arrive
// (there is no error response to handle).
try {
this.wsClient.send(JSON.stringify(message));
// Move to subscribed and clear pending
for (const assetId of toSubscribe) {
this.subscribedAssetIds.add(assetId);
}
this.pendingSubscribeAssetIds.clear();
logger_1.logger.info({
message: `Subscribed to ${toSubscribe.length} asset(s)`,
managerId: this.managerId,
});
}
catch (error) {
logger_1.logger.warn({
message: 'Failed to send subscribe message',
error: error instanceof Error ? error.message : String(error),
});
}
}
// Close WebSocket if no assets remain and no pending unsubscriptions
// (pendingUnsubscribeAssetIds is already cleared at this point)
if (this.subscribedAssetIds.size === 0 &&
this.pendingSubscribeAssetIds.size === 0 &&
this.pendingUnsubscribeAssetIds.size === 0) {
this.closeWebSocket();
}
}
/**
* Closes the WebSocket connection and cleans up related resources.
*/
closeWebSocket() {
if (this.wsClient) {
logger_1.logger.info({
message: 'Closing WebSocket - no assets to monitor',
managerId: this.managerId,
});
this.wsClient.removeAllListeners();
this.wsClient.close();
this.wsClient = null;
}
this.status = WebSocketSubscriptions_1.WebSocketConnectionStatus.DISCONNECTED;
this.connecting = false;
if (this.pingInterval) {
clearInterval(this.pingInterval);
this.pingInterval = undefined;
}
}
/**
* Check if we need to reconnect.
* Note: Assets are moved to pending in handleClose/handleError handlers,
* so this method only needs to check if reconnection is needed.
*/
async checkReconnection() {
// If we have pending assets but no connection, reconnect
const hasPendingAssets = this.pendingSubscribeAssetIds.size > 0;
const isDisconnected = !this.wsClient || this.wsClient.readyState !== ws_1.default.OPEN;
if (hasPendingAssets && isDisconnected && !this.connecting) {
logger_1.logger.info({
message: 'Reconnection check - attempting to reconnect',
managerId: this.managerId,
pendingCount: this.pendingSubscribeAssetIds.size,
});
// Clear stale book cache data - will be repopulated after reconnection
this.bookCache.clear();
await this.connect();
}
}
/**
* Establish the WebSocket connection.
*/
async connect() {
var _a;
if (this.connecting) {
return;
}
if (((_a = this.wsClient) === null || _a === void 0 ? void 0 : _a.readyState) === ws_1.default.OPEN) {
return;
}
// No assets to subscribe to
if (this.pendingSubscribeAssetIds.size === 0 && this.subscribedAssetIds.size === 0) {
return;
}
this.connecting = true;
this.status = WebSocketSubscriptions_1.WebSocketConnectionStatus.CONNECTING;
try {
logger_1.logger.info({
message: 'Connecting to CLOB WebSocket',
managerId: this.managerId,
pendingAssetCount: this.pendingSubscribeAssetIds.size,
});
this.wsClient = new ws_1.default(CLOB_WSS_URL);
// Set up event handlers immediately (handlers are set up before any events can fire)
this.setupEventHandlers();
// Connection timeout
this.connectionTimeout = setTimeout(async () => {
if (this.connecting && this.wsClient && this.wsClient.readyState !== ws_1.default.OPEN) {
logger_1.logger.warn({
message: 'WebSocket connection timeout',
managerId: this.managerId,
});
this.status = WebSocketSubscriptions_1.WebSocketConnectionStatus.DISCONNECTED;
this.connecting = false;
if (this.wsClient) {
this.wsClient.removeAllListeners();
this.wsClient.close();
this.wsClient = null;
}
// Notify error handler about the timeout
await this.safeCallErrorHandler(new Error('WebSocket connection timeout after 30s'));
}
}, (0, ms_1.default)('30s'));
}
catch (err) {
this.status = WebSocketSubscriptions_1.WebSocketConnectionStatus.DISCONNECTED;
this.connecting = false;
throw err;
}
}
/**
* Sets up event handlers for the WebSocket connection.
*/
setupEventHandlers() {
const ws = this.wsClient;
if (!ws)
return;
const handleOpen = async () => {
var _a, _b;
this.status = WebSocketSubscriptions_1.WebSocketConnectionStatus.CONNECTED;
this.connecting = false;
if (this.connectionTimeout) {
clearTimeout(this.connectionTimeout);
this.connectionTimeout = undefined;
}
// Send an empty MarketSubscriptionMessage as the initial handshake.
// The Polymarket WebSocket protocol requires a 'market' type message as the first message.
// We send it with an empty assets_ids array, and then use 'subscribe' operation messages
// for all actual subscriptions (via flushPendingSubscriptions) to keep the subscription
// logic consistent in one place.
try {
const initMessage = {
assets_ids: [],
type: 'market',
};
ws.send(JSON.stringify(initMessage));
}
catch (error) {
logger_1.logger.warn({
message: 'Failed to send initial market message',
error: error instanceof Error ? error.message : String(error),
managerId: this.managerId,
});
// Close and let reconnection logic handle retry
// Wrap in try-catch as close() can throw in edge cases
try {
ws.close();
}
catch (closeErr) {
logger_1.logger.debug({
message: 'Error closing WebSocket after init message failure (safe to ignore)',
error: closeErr instanceof Error ? closeErr.message : String(closeErr),
managerId: this.managerId,
});
}
return;
}
const pendingAssets = Array.from(this.pendingSubscribeAssetIds);
// Safely call open handler
try {
await ((_b = (_a = this.handlers).onWSOpen) === null || _b === void 0 ? void 0 : _b.call(_a, this.managerId, pendingAssets));
}
catch (handlerErr) {
logger_1.logger.warn({
message: 'Error in onWSOpen handler',
error: handlerErr instanceof Error ? handlerErr.message : String(handlerErr),
managerId: this.managerId,
});
}
// Immediately flush pending subscriptions now that we're connected
this.flushPendingSubscriptions();
// Start ping interval with jitter per-ping
const basePingIntervalMs = (0, ms_1.default)('20s');
this.pingInterval = setInterval(() => {
if (!ws || ws.readyState !== ws_1.default.OPEN) {
if (this.pingInterval) {
clearInterval(this.pingInterval);
this.pingInterval = undefined;
}
return;
}
// Add jitter by randomly delaying the ping within a ±5s window
const jitterMs = (0, crypto_1.randomInt)(0, (0, ms_1.default)('5s'));
setTimeout(() => {
if (ws && ws.readyState === ws_1.default.OPEN) {
try {
ws.ping();
}
catch (pingErr) {
// Ping can fail if the socket is closing or in a bad state.
// This is not critical - the socket will be cleaned up on close/error events.
logger_1.logger.debug({
message: 'Ping failed (safe to ignore)',
error: pingErr instanceof Error ? pingErr.message : String(pingErr),
});
}
}
}, jitterMs);
}, basePingIntervalMs);
};
const handleMessage = async (data) => {
try {
const messageStr = data.toString();
const normalizedMessageStr = messageStr.trim().toUpperCase();
if (normalizedMessageStr === 'PONG') {
return;
}
let events = [];
try {
const parsedData = JSON.parse(messageStr);
events = Array.isArray(parsedData) ? parsedData : [parsedData];
}
catch (err) {
await this.safeCallErrorHandler(new Error(`Not JSON: ${messageStr}`));
return;
}
events = lodash_1.default.filter(events, (event) => {
if (!event)
return false;
if ((0, PolymarketWebSocket_1.isPriceChangeEvent)(event)) {
return event.price_changes && event.price_changes.length > 0;
}
return lodash_1.default.size(event.asset_id) > 0;
});
const bookEvents = [];
const lastTradeEvents = [];
const tickEvents = [];
const priceChangeEvents = [];
for (const event of events) {
if ((0, PolymarketWebSocket_1.isPriceChangeEvent)(event)) {
const relevantChanges = event.price_changes.filter(pc => this.subscribedAssetIds.has(pc.asset_id));
if (relevantChanges.length === 0)
continue;
priceChangeEvents.push({
...event,
price_changes: relevantChanges
});
}
else {
// Safely check asset_id existence
const assetId = 'asset_id' in event ? event.asset_id : undefined;
if (!assetId || !this.subscribedAssetIds.has(assetId))
continue;
if ((0, PolymarketWebSocket_1.isBookEvent)(event)) {
bookEvents.push(event);
}
else if ((0, PolymarketWebSocket_1.isLastTradePriceEvent)(event)) {
lastTradeEvents.push(event);
}
else if ((0, PolymarketWebSocket_1.isTickSizeChangeEvent)(event)) {
tickEvents.push(event);
}
else {
await this.safeCallErrorHandler(new Error(`Unknown event: ${JSON.stringify(event)}`));
}
}
}
// Wrap each handler call in try-catch to prevent one failure
// from breaking the entire event loop
try {
await this.handleBookEvents(bookEvents);
}
catch (err) {
logger_1.logger.warn({
message: 'Error in handleBookEvents',
error: err instanceof Error ? err.message : String(err),
managerId: this.managerId,
});
}
try {
await this.handleTickEvents(tickEvents);
}
catch (err) {
logger_1.logger.warn({
message: 'Error in handleTickEvents',
error: err instanceof Error ? err.message : String(err),
managerId: this.managerId,
});
}
try {
await this.handlePriceChangeEvents(priceChangeEvents);
}
catch (err) {
logger_1.logger.warn({
message: 'Error in handlePriceChangeEvents',
error: err instanceof Error ? err.message : String(err),
managerId: this.managerId,
});
}
try {
await this.handleLastTradeEvents(lastTradeEvents);
}
catch (err) {
logger_1.logger.warn({
message: 'Error in handleLastTradeEvents',
error: err instanceof Error ? err.message : String(err),
managerId: this.managerId,
});
}
}
catch (err) {
await this.safeCallErrorHandler(new Error(`Error handling message: ${err}`));
}
};
const handlePong = () => {
// Pong received - connection is alive
};
const handleError = async (err) => {
var _a, _b;
this.status = WebSocketSubscriptions_1.WebSocketConnectionStatus.DISCONNECTED;
this.connecting = false;
// Clean up WebSocket reference - close the socket before nullifying
// Wrap in try-catch because close() can throw if socket is in a bad state
if (this.wsClient) {
this.wsClient.removeAllListeners();
try {
this.wsClient.close();
}
catch (closeErr) {
logger_1.logger.debug({
message: 'Error closing WebSocket in error handler (safe to ignore)',
error: closeErr instanceof Error ? closeErr.message : String(closeErr),
managerId: this.managerId,
});
}
this.wsClient = null;
}
if (this.pingInterval) {
clearInterval(this.pingInterval);
this.pingInterval = undefined;
}
if (this.connectionTimeout) {
clearTimeout(this.connectionTimeout);
this.connectionTimeout = undefined;
}
// Move subscribed assets back to pending for re-subscription,
// but skip assets that user wanted to unsubscribe (preserve user intent)
for (const assetId of this.subscribedAssetIds) {
if (!this.pendingUnsubscribeAssetIds.has(assetId)) {
this.pendingSubscribeAssetIds.add(assetId);
}
}
this.subscribedAssetIds.clear();
// Clear pending unsubscribes - they were either:
// 1. Successfully excluded from re-subscription (above), or
// 2. Were never subscribed anyway (user's intent is preserved)
this.pendingUnsubscribeAssetIds.clear();
// Clear the book cache - data is stale and will be repopulated on reconnection
this.bookCache.clear();
// Safely call error handler
try {
await ((_b = (_a = this.handlers).onError) === null || _b === void 0 ? void 0 : _b.call(_a, new Error(`WebSocket error: ${err.message}`)));
}
catch (handlerErr) {
logger_1.logger.warn({
message: 'Error in onError handler',
error: handlerErr instanceof Error ? handlerErr.message : String(handlerErr),
managerId: this.managerId,
});
}
};
const handleClose = async (code, reason) => {
var _a, _b;
this.status = WebSocketSubscriptions_1.WebSocketConnectionStatus.DISCONNECTED;
this.connecting = false;
// Clean up WebSocket reference
if (this.wsClient) {
this.wsClient.removeAllListeners();
this.wsClient = null;
}
if (this.pingInterval) {
clearInterval(this.pingInterval);
this.pingInterval = undefined;
}
if (this.connectionTimeout) {
clearTimeout(this.connectionTimeout);
this.connectionTimeout = undefined;
}
// Move subscribed assets back to pending for re-subscription,
// but skip assets that user wanted to unsubscribe (preserve user intent)
for (const assetId of this.subscribedAssetIds) {
if (!this.pendingUnsubscribeAssetIds.has(assetId)) {
this.pendingSubscribeAssetIds.add(assetId);
}
}
this.subscribedAssetIds.clear();
// Clear pending unsubscribes - they were either:
// 1. Successfully excluded from re-subscription (above), or
// 2. Were never subscribed anyway (user's intent is preserved)
this.pendingUnsubscribeAssetIds.clear();
// Clear the book cache - data is stale and will be repopulated on reconnection
this.bookCache.clear();
// Safely call close handler
try {
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()) || ''));
}
catch (handlerErr) {
logger_1.logger.warn({
message: 'Error in onWSClose handler',
error: handlerErr instanceof Error ? handlerErr.message : String(handlerErr),
managerId: this.managerId,
});
}
};
ws.removeAllListeners();
ws.on('open', handleOpen);
ws.on('message', handleMessage);
ws.on('pong', handlePong);
ws.on('error', handleError);
ws.on('close', handleClose);
}
/**
* Handles book events by updating the cache and notifying listeners.
*/
async handleBookEvents(bookEvents) {
var _a, _b;
if (bookEvents.length) {
for (const event of bookEvents) {
this.bookCache.replaceBook(event);
}
await ((_b = (_a = this.handlers).onBook) === null || _b === void 0 ? void 0 : _b.call(_a, bookEvents));
}
}
/**
* Handles tick size change events by notifying listeners.
*/
async handleTickEvents(tickEvents) {
var _a, _b;
if (tickEvents.length) {
await ((_b = (_a = this.handlers).onTickSizeChange) === null || _b === void 0 ? void 0 : _b.call(_a, tickEvents));
}
}
/**
* Handles price change events.
*/
async handlePriceChangeEvents(priceChangeEvents) {
var _a, _b, _c, _d;
if (priceChangeEvents.length) {
await ((_b = (_a = this.handlers).onPriceChange) === null || _b === void 0 ? void 0 : _b.call(_a, priceChangeEvents));
for (const event of priceChangeEvents) {
try {
this.bookCache.upsertPriceChange(event);
}
catch (err) {
logger_1.logger.debug({
message: `Skipping derived future price calculation price_change: book not found for asset`,
event: event,
error: err === null || err === void 0 ? void 0 : err.message
});
continue;
}
const assetIds = event.price_changes.map(pc => pc.asset_id);
for (const assetId of assetIds) {
let spreadOver10Cents;
try {
spreadOver10Cents = this.bookCache.spreadOver(assetId, 0.1);
}
catch (err) {
logger_1.logger.debug({
message: 'Skipping derived future price calculation for price_change: error calculating spread',
asset_id: assetId,
event: event,
error: err === null || err === void 0 ? void 0 : err.message
});
continue;
}
if (!spreadOver10Cents) {
let newPrice;
try {
newPrice = this.bookCache.midpoint(assetId);
}
catch (err) {
logger_1.logger.debug({
message: 'Skipping derived future price calculation for price_change: error calculating midpoint',
asset_id: assetId,
event: event,
error: err === null || err === void 0 ? void 0 : err.message
});
continue;
}
const bookEntry = this.bookCache.getBookEntry(assetId);
if (!bookEntry) {
logger_1.logger.debug({
message: 'Skipping derived future price calculation price_change: book not found for asset',
asset_id: assetId,
event: event,
});
continue;
}
if (newPrice !== bookEntry.price) {
bookEntry.price = newPrice;
const priceUpdateEvent = {
asset_id: assetId,
event_type: 'price_update',
triggeringEvent: event,
timestamp: event.timestamp,
book: { bids: bookEntry.bids, asks: bookEntry.asks },
price: newPrice,
midpoint: bookEntry.midpoint || '',
spread: bookEntry.spread || '',
};
await ((_d = (_c = this.handlers).onPolymarketPriceUpdate) === null || _d === void 0 ? void 0 : _d.call(_c, [priceUpdateEvent]));
}
}
}
}
}
}
/**
* Handles last trade price events.
*/
async handleLastTradeEvents(lastTradeEvents) {
var _a, _b, _c, _d;
if (lastTradeEvents.length) {
await ((_b = (_a = this.handlers).onLastTradePrice) === null || _b === void 0 ? void 0 : _b.call(_a, lastTradeEvents));
for (const event of lastTradeEvents) {
let spreadOver10Cents;
try {
spreadOver10Cents = this.bookCache.spreadOver(event.asset_id, 0.1);
}
catch (err) {
logger_1.logger.debug({
message: 'Skipping derived future price calculation for last_trade_price: error calculating spread',
asset_id: event.asset_id,
event: event,
error: err === null || err === void 0 ? void 0 : err.message
});
continue;
}
if (spreadOver10Cents) {
const newPrice = parseFloat(event.price).toString();
const bookEntry = this.bookCache.getBookEntry(event.asset_id);
if (!bookEntry) {
logger_1.logger.debug({
message: 'Skipping derived future price calculation last_trade_price: book not found for asset',
asset_id: event.asset_id,
event: event,
});
continue;
}
if (newPrice !== bookEntry.price) {
bookEntry.price = newPrice;
const priceUpdateEvent = {
asset_id: event.asset_id,
event_type: 'price_update',
triggeringEvent: event,
timestamp: event.timestamp,
book: { bids: bookEntry.bids, asks: bookEntry.asks },
price: newPrice,
midpoint: bookEntry.midpoint || '',
spread: bookEntry.spread || '',
};
await ((_d = (_c = this.handlers).onPolymarketPriceUpdate) === null || _d === void 0 ? void 0 : _d.call(_c, [priceUpdateEvent]));
}
}
}
}
}
}
exports.WSSubscriptionManager = WSSubscriptionManager;