@nevuamarkets/poly-websockets 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,201 @@
1
+ import ms from 'ms';
2
+ import _ from 'lodash';
3
+ import Bottleneck from 'bottleneck';
4
+ import {
5
+ WebSocketHandlers,
6
+ PriceChangeEvent,
7
+ BookEvent,
8
+ LastTradePriceEvent,
9
+ TickSizeChangeEvent,
10
+ PolymarketWSEvent,
11
+ PolymarketPriceUpdateEvent
12
+ } from './types/PolymarketWebSocket';
13
+ import { SubscriptionManagerOptions } from './types/WebSocketSubscriptions';
14
+
15
+ import { GroupRegistry } from './modules/GroupRegistry';
16
+ import { OrderBookCache } from './modules/OrderBookCache';
17
+ import { GroupSocket } from './modules/GroupSocket';
18
+
19
+ import { logger } from './logger';
20
+
21
+
22
+ // Keeping a burst limit under 10/s to avoid rate limiting
23
+ // See https://docs.polymarket.com/quickstart/introduction/rate-limits#api-rate-limits
24
+ const BURST_LIMIT_PER_SECOND = 5;
25
+
26
+ const DEFAULT_RECONNECT_AND_CLEANUP_INTERVAL_MS = ms('10s');
27
+ const DEFAULT_MAX_MARKETS_PER_WS = 100;
28
+
29
+ class WSSubscriptionManager {
30
+ private handlers: WebSocketHandlers;
31
+ private burstLimiter: Bottleneck;
32
+ private groupRegistry: GroupRegistry;
33
+ private bookCache: OrderBookCache;
34
+ private reconnectAndCleanupIntervalMs: number;
35
+ private maxMarketsPerWS: number;
36
+
37
+ constructor(userHandlers: WebSocketHandlers, options?: SubscriptionManagerOptions) {
38
+ this.groupRegistry = new GroupRegistry();
39
+ this.bookCache = new OrderBookCache();
40
+ this.burstLimiter = options?.burstLimiter || new Bottleneck({
41
+ reservoir: BURST_LIMIT_PER_SECOND,
42
+ reservoirRefreshAmount: BURST_LIMIT_PER_SECOND,
43
+ reservoirRefreshInterval: ms('1s'),
44
+ maxConcurrent: BURST_LIMIT_PER_SECOND
45
+ });
46
+
47
+ this.reconnectAndCleanupIntervalMs = options?.reconnectAndCleanupIntervalMs || DEFAULT_RECONNECT_AND_CLEANUP_INTERVAL_MS;
48
+ this.maxMarketsPerWS = options?.maxMarketsPerWS || DEFAULT_MAX_MARKETS_PER_WS;
49
+
50
+ this.handlers = {
51
+ onBook: async (events: BookEvent[]) => {
52
+ await this.actOnSubscribedEvents(events, userHandlers.onBook);
53
+ },
54
+ onLastTradePrice: async (events: LastTradePriceEvent[]) => {
55
+ await this.actOnSubscribedEvents(events, userHandlers.onLastTradePrice);
56
+ },
57
+ onTickSizeChange: async (events: TickSizeChangeEvent[]) => {
58
+ await this.actOnSubscribedEvents(events, userHandlers.onTickSizeChange);
59
+ },
60
+ onPriceChange: async (events: PriceChangeEvent[]) => {
61
+ await this.actOnSubscribedEvents(events, userHandlers.onPriceChange);
62
+ },
63
+ onPolymarketPriceUpdate: async (events: PolymarketPriceUpdateEvent[]) => {
64
+ await this.actOnSubscribedEvents(events, userHandlers.onPolymarketPriceUpdate);
65
+ },
66
+ onWSClose: userHandlers.onWSClose,
67
+ onWSOpen: userHandlers.onWSOpen,
68
+ onError: userHandlers.onError
69
+ };
70
+
71
+ this.burstLimiter.on('error', (err: Error) => {
72
+ this.handlers.onError?.(err);
73
+ });
74
+
75
+ // Check for dead groups every 10s and reconnect them if needed
76
+ setInterval(() => {
77
+ this.reconnectAndCleanupGroups();
78
+ }, this.reconnectAndCleanupIntervalMs);
79
+ }
80
+
81
+ /*
82
+ Clears all WebSocket subscriptions and state.
83
+
84
+ This will:
85
+
86
+ 1. Remove all subscriptions and groups
87
+ 2. Close all WebSocket connections
88
+ 3. Clear the order book cache
89
+ */
90
+ public async clearState() {
91
+ const previousGroups = await this.groupRegistry.clearAllGroups();
92
+
93
+ // Close sockets outside the lock
94
+ for (const group of previousGroups) {
95
+ this.groupRegistry.disconnectGroup(group);
96
+ }
97
+
98
+ // Also clear the order book cache
99
+ this.bookCache.clear();
100
+ }
101
+
102
+ /*
103
+ This function is called when:
104
+ - a websocket event is received from the Polymarket WS
105
+ - a price update event detected, either by after a 'last_trade_price' event or a 'price_change' event
106
+ depending on the current bid-ask spread (see https://docs.polymarket.com/polymarket-learn/trading/how-are-prices-calculated)
107
+
108
+ The user handlers will be called **ONLY** for assets that are actively subscribed to by any groups.
109
+ */
110
+ private async actOnSubscribedEvents<T extends PolymarketWSEvent | PolymarketPriceUpdateEvent>(events: T[], action?: (events: T[]) => Promise<void>) {
111
+
112
+ // Filter out events that are not subscribed to by any groups
113
+ events = _.filter(events, (event: T) => {
114
+ const groupIndices = this.groupRegistry.getGroupIndicesForAsset(event.asset_id);
115
+
116
+ if (groupIndices.length > 1) {
117
+ logger.warn({
118
+ message: 'Found multiple groups for asset',
119
+ asset_id: event.asset_id,
120
+ group_indices: groupIndices
121
+ });
122
+ }
123
+ return groupIndices.length > 0;
124
+ });
125
+
126
+ await action?.(events);
127
+ }
128
+
129
+ /*
130
+ Edits wsGroups: Adds new subscriptions.
131
+
132
+ - Filters out assets that are already subscribed
133
+ - Finds a group with capacity or creates a new one
134
+ - Creates a new WebSocket client and adds it to the group
135
+ */
136
+ public async addSubscriptions(assetIdsToAdd: string[]) {
137
+ try {
138
+ const groupIdsToConnect = await this.groupRegistry.addAssets(assetIdsToAdd, this.maxMarketsPerWS);
139
+ for (const groupId of groupIdsToConnect) {
140
+ await this.createWebSocketClient(groupId, this.handlers);
141
+ }
142
+ } catch (error) {
143
+ const msg = `Error adding subscriptions: ${error instanceof Error ? error.message : String(error)}`;
144
+ await this.handlers.onError?.(new Error(msg));
145
+ }
146
+ }
147
+
148
+ /*
149
+ Edits wsGroups: Removes subscriptions.
150
+ The group will use the updated subscriptions when it reconnects.
151
+ We do that because we don't want to miss events by reconnecting.
152
+ */
153
+ public async removeSubscriptions(assetIdsToRemove: string[]) {
154
+ try {
155
+ await this.groupRegistry.removeAssets(assetIdsToRemove, this.bookCache);
156
+ } catch (error) {
157
+ const errMsg = `Error removing subscriptions: ${error instanceof Error ? error.message : String(error)}`;
158
+ await this.handlers.onError?.(new Error(errMsg));
159
+ }
160
+ }
161
+
162
+ /*
163
+ This function runs periodically and:
164
+
165
+ - Tries to reconnect groups that have assets and are disconnected
166
+ - Cleans up groups that have no assets
167
+ */
168
+ private async reconnectAndCleanupGroups() {
169
+ try {
170
+ const reconnectIds = await this.groupRegistry.getGroupsToReconnectAndCleanup();
171
+
172
+ for (const groupId of reconnectIds) {
173
+ await this.createWebSocketClient(groupId, this.handlers);
174
+ }
175
+ } catch (err) {
176
+ await this.handlers.onError?.(err as Error);
177
+ }
178
+ }
179
+
180
+ private async createWebSocketClient(groupId: string, handlers: WebSocketHandlers) {
181
+ const group = this.groupRegistry.findGroupById(groupId);
182
+
183
+ /*
184
+ Should never happen, but just in case.
185
+ */
186
+ if (!group) {
187
+ await handlers.onError?.(new Error(`Group ${groupId} not found in registry`));
188
+ return;
189
+ }
190
+
191
+ const groupSocket = new GroupSocket(group, this.burstLimiter, this.bookCache, handlers);
192
+ try {
193
+ await groupSocket.connect();
194
+ } catch (error) {
195
+ const errorMessage = `Error creating WebSocket client for group ${groupId}: ${error instanceof Error ? error.message : String(error)}`;
196
+ await handlers.onError?.(new Error(errorMessage));
197
+ }
198
+ }
199
+ }
200
+
201
+ export { WSSubscriptionManager, WebSocketHandlers };
package/src/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ export { WSSubscriptionManager, WebSocketHandlers } from './WSSubscriptionManager';
2
+ export * from './types/PolymarketWebSocket';
3
+ export * from './types/WebSocketSubscriptions';
package/src/logger.ts ADDED
@@ -0,0 +1,37 @@
1
+ import winston from 'winston';
2
+
3
+
4
+ // Override with LOG_LEVEL environment variable (e.g., LOG_LEVEL=info npm start)
5
+ export const logger = winston.createLogger({
6
+ level: process.env.LOG_LEVEL || 'error',
7
+ format: winston.format.combine(
8
+ winston.format.timestamp(),
9
+ winston.format.errors({ stack: true }),
10
+ winston.format.colorize(),
11
+ winston.format.printf(({ level, message, timestamp, ...rest }) => {
12
+ // Ensure consistent order: timestamp, level, message, then rest of fields
13
+ const restString = Object.keys(rest)
14
+ .filter(key => key !== 'service') // Exclude service since we add it in defaultMeta
15
+ .sort()
16
+ .map(key => `${key}: ${JSON.stringify(rest[key])}`)
17
+ .join(', ');
18
+ return `${timestamp} ${level}: ${message}${restString ? ` (${restString})` : ''}`;
19
+ })
20
+ ),
21
+ defaultMeta: { service: 'poly-websockets' },
22
+ transports: [
23
+ new winston.transports.Console({
24
+ format: winston.format.combine(
25
+ winston.format.colorize({
26
+ all: true,
27
+ colors: {
28
+ error: 'red',
29
+ warn: 'yellow',
30
+ info: 'cyan',
31
+ debug: 'green'
32
+ }
33
+ })
34
+ )
35
+ })
36
+ ]
37
+ });
@@ -0,0 +1,274 @@
1
+ import { Mutex } from 'async-mutex';
2
+ import _ from 'lodash';
3
+ import { v4 as uuidv4 } from 'uuid';
4
+ import { WebSocketGroup, WebSocketStatus } from '../types/WebSocketSubscriptions';
5
+ import { OrderBookCache } from './OrderBookCache';
6
+ import { logger } from '../logger';
7
+
8
+ /*
9
+ * Global group store and mutex, intentionally **not** exported anymore to prevent
10
+ * accidental external mutation. All access should go through the helper methods
11
+ * on GroupRegistry instead.
12
+ */
13
+ const wsGroups: WebSocketGroup[] = [];
14
+ const wsGroupsMutex = new Mutex();
15
+
16
+ export class GroupRegistry {
17
+
18
+ /**
19
+ * Atomic mutate helper.
20
+ *
21
+ * @param fn - The function to run atomically.
22
+ * @returns The result of the function.
23
+ */
24
+ public async mutate<T>(fn: (groups: WebSocketGroup[]) => T | Promise<T>): Promise<T> {
25
+ const release = await wsGroupsMutex.acquire();
26
+ try { return await fn(wsGroups); }
27
+ finally { release(); }
28
+ }
29
+
30
+ /**
31
+ * Read-only copy of the registry.
32
+ *
33
+ * Only to be used in test suite.
34
+ */
35
+ public snapshot(): WebSocketGroup[] {
36
+ return wsGroups.map(group => ({
37
+ ...group,
38
+ assetIds: new Set(group.assetIds),
39
+ }));
40
+ }
41
+
42
+ /**
43
+ * Find the first group with capacity to hold new assets.
44
+ *
45
+ * Returns the groupId if found, otherwise null.
46
+ */
47
+ public findGroupWithCapacity(newAssetLen: number, maxPerWS: number): string | null {
48
+ for (const group of wsGroups) {
49
+ if (group.assetIds.size === 0) continue;
50
+ if (group.assetIds.size + newAssetLen <= maxPerWS) return group.groupId;
51
+ }
52
+ return null;
53
+ }
54
+
55
+ /**
56
+ * Get the indices of all groups that contain the asset.
57
+ *
58
+ * Returns an array of indices.
59
+ */
60
+ public getGroupIndicesForAsset(assetId: string): number[] {
61
+ const indices: number[] = [];
62
+ for (let i = 0; i < wsGroups.length; i++) {
63
+ if (wsGroups[i]?.assetIds.has(assetId)) indices.push(i);
64
+ }
65
+ return indices;
66
+ }
67
+
68
+ /**
69
+ * Check if any group contains the asset.
70
+ */
71
+ public hasAsset(assetId: string): boolean {
72
+ return wsGroups.some(group => group.assetIds.has(assetId));
73
+ }
74
+
75
+ /**
76
+ * Find the group by groupId.
77
+ *
78
+ * Returns the group if found, otherwise undefined.
79
+ */
80
+ public findGroupById(groupId: string): WebSocketGroup | undefined {
81
+ return wsGroups.find(g => g.groupId === groupId);
82
+ }
83
+
84
+ /**
85
+ * Atomically remove **all** groups from the registry and return them so the
86
+ * caller can perform any asynchronous cleanup (closing sockets, etc.)
87
+ * outside the lock.
88
+ *
89
+ * Returns the removed groups.
90
+ */
91
+ public async clearAllGroups(): Promise<WebSocketGroup[]> {
92
+ let removed: WebSocketGroup[] = [];
93
+ await this.mutate(groups => {
94
+ removed = [...groups];
95
+ groups.length = 0;
96
+ });
97
+ return removed;
98
+ }
99
+
100
+ /**
101
+ * Add new asset subscriptions.
102
+ *
103
+ * – Ignores assets that are already subscribed.
104
+ * – Either reuses an existing group with capacity or creates new groups (size ≤ maxPerWS).
105
+ * – If appending to a group:
106
+ * - A new group is created with the updated assetIds.
107
+ * - The old group is marked for cleanup.
108
+ * - The group is added to the list of groups to connect.
109
+ *
110
+ * @param assetIds - The assetIds to add.
111
+ * @param maxPerWS - The maximum number of assets per WebSocket group.
112
+ * @returns An array of *new* groupIds that need websocket connections.
113
+ */
114
+ public async addAssets(assetIds: string[], maxPerWS: number): Promise<string[]> {
115
+ const groupIdsToConnect: string[] = [];
116
+ let newAssetIds: string[] = []
117
+
118
+ await this.mutate(groups => {
119
+ newAssetIds = assetIds.filter(id => !groups.some(g => g.assetIds.has(id)));
120
+ if (newAssetIds.length === 0) return;
121
+
122
+ const existingGroupId = this.findGroupWithCapacity(newAssetIds.length, maxPerWS);
123
+
124
+ /*
125
+ If no existing group with capacity is found, create new groups.
126
+ */
127
+ if (existingGroupId === null) {
128
+ const chunks = _.chunk(newAssetIds, maxPerWS);
129
+ for (const chunk of chunks) {
130
+ const groupId = uuidv4();
131
+ groups.push(
132
+ {
133
+ groupId,
134
+ assetIds: new Set(chunk),
135
+ wsClient: null,
136
+ status: WebSocketStatus.PENDING
137
+ }
138
+ );
139
+ groupIdsToConnect.push(groupId);
140
+ }
141
+
142
+ /*
143
+ If an existing group with capacity is found, update the group.
144
+ */
145
+ } else {
146
+ const existingGroup = groups.find(g => g.groupId === existingGroupId);
147
+ if (!existingGroup) {
148
+ // Should never happen
149
+ throw new Error(`Group with capacity not found for ${newAssetIds.join(', ')}`);
150
+ }
151
+
152
+ const updatedAssetIds = new Set([...existingGroup.assetIds, ...newAssetIds]);
153
+
154
+ // Mark old group ready for cleanup
155
+ existingGroup.assetIds = new Set();
156
+ existingGroup.status = WebSocketStatus.CLEANUP;
157
+
158
+ const groupId = uuidv4();
159
+ groups.push(
160
+ {
161
+ groupId,
162
+ assetIds: updatedAssetIds,
163
+ wsClient: null,
164
+ status: WebSocketStatus.PENDING
165
+ }
166
+ );
167
+ groupIdsToConnect.push(groupId);
168
+ }
169
+ });
170
+
171
+ if (newAssetIds.length > 0) {
172
+ logger.info({
173
+ message: `Added ${newAssetIds.length} new asset(s)`
174
+ })
175
+ }
176
+ return groupIdsToConnect;
177
+ }
178
+
179
+ /**
180
+ * Remove asset subscriptions from every group that contains the asset.
181
+ *
182
+ * It should be only one group that contains the asset, we search all of them
183
+ * regardless.
184
+ *
185
+ * Returns the list of assetIds that were removed.
186
+ */
187
+ public async removeAssets(assetIds: string[], bookCache: OrderBookCache): Promise<string[]> {
188
+ const removedAssetIds: string[] = [];
189
+ await this.mutate(groups => {
190
+ groups.forEach(group => {
191
+ if (group.assetIds.size === 0) return;
192
+
193
+ assetIds.forEach(id => {
194
+ if (group.assetIds.delete(id)) {
195
+ bookCache.clear(id);
196
+ removedAssetIds.push(id)
197
+ }
198
+ });
199
+ });
200
+ });
201
+ if (removedAssetIds.length > 0) {
202
+ logger.info({
203
+ message: `Removed ${removedAssetIds.length} asset(s)`
204
+ })
205
+ }
206
+ return removedAssetIds;
207
+ }
208
+
209
+ /**
210
+ * Disconnect a group.
211
+ */
212
+ public disconnectGroup(group: WebSocketGroup) {
213
+ group.wsClient?.close();
214
+ group.wsClient = null;
215
+
216
+ logger.info({
217
+ message: 'Disconnected group',
218
+ groupId: group.groupId,
219
+ assetIds: Array.from(group.assetIds),
220
+ });
221
+
222
+ };
223
+ /**
224
+ * Check status of groups and reconnect or cleanup as needed.
225
+ *
226
+ * – Empty groups are removed from the global array and returned.
227
+ * – Dead (but non-empty) groups are reset so that caller can reconnect them.
228
+ * – Pending groups are returned so that caller can connect them.
229
+ *
230
+ * Returns an array of group IDs that need to be reconnected, after cleaning up empty and cleanup-marked groups.
231
+ */
232
+ public async getGroupsToReconnectAndCleanup(): Promise<string[]> {
233
+ const reconnectIds: string[] = [];
234
+
235
+ await this.mutate(groups => {
236
+ const groupsToRemove = new Set<string>();
237
+
238
+ for (const group of groups) {
239
+ if (group.assetIds.size === 0) {
240
+ groupsToRemove.add(group.groupId);
241
+ continue;
242
+ }
243
+
244
+ if (group.status === WebSocketStatus.ALIVE) {
245
+ continue;
246
+ }
247
+
248
+ if (group.status === WebSocketStatus.DEAD) {
249
+ this.disconnectGroup(group);
250
+ reconnectIds.push(group.groupId);
251
+ }
252
+ if (group.status === WebSocketStatus.CLEANUP) {
253
+ groupsToRemove.add(group.groupId);
254
+ group.assetIds = new Set();
255
+ continue;
256
+ }
257
+
258
+ if (group.status === WebSocketStatus.PENDING) {
259
+ reconnectIds.push(group.groupId);
260
+ }
261
+ }
262
+ if (groupsToRemove.size > 0) {
263
+ groups.forEach(group => {
264
+ if (groupsToRemove.has(group.groupId)) {
265
+ this.disconnectGroup(group);
266
+ }
267
+ });
268
+ const remaining = groups.filter(group => !groupsToRemove.has(group.groupId));
269
+ groups.splice(0, groups.length, ...remaining);
270
+ }
271
+ });
272
+ return reconnectIds;
273
+ }
274
+ }