@0xmonaco/core 1.0.25-develop.1fab96c → 1.0.26

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.
@@ -1,15 +1,51 @@
1
1
  import type { ConditionalOrderEvent, Interval, OHLCVEvent, OrderbookEvent, OrderbookQuotationMode, OrderEvent, SessionCredentials, TradeEvent, TradingMode, UserBalanceEvent, UserMovementEvent, WebSocketStatus } from "@0xmonaco/types";
2
2
  export type StatusHandler = (status: WebSocketStatus) => void;
3
3
  export type MessageHandler<T> = (data: T) => void;
4
+ /**
5
+ * Why the connection was re-established. Delivered by
6
+ * {@link MonacoWebSocketOptions.onResync} after the client has reconnected,
7
+ * re-authenticated, and re-subscribed — the point where the app should
8
+ * refetch REST snapshots and reconcile by `(id, updatedAt)`.
9
+ */
10
+ export interface WebSocketResyncInfo {
11
+ /** WebSocket close code of the connection that was lost. */
12
+ code: number;
13
+ /**
14
+ * True when the server closed with 1013 (try again later): the client fell
15
+ * behind a sequential stream (orders, conditional orders, liquidations,
16
+ * trades, movements) and the stream has a gap. There is no wire sequence
17
+ * number — the 1013 close is the gap signal, and a snapshot refetch is the
18
+ * only safe repair.
19
+ */
20
+ slowClient: boolean;
21
+ }
22
+ export type ResyncHandler = (info: WebSocketResyncInfo) => void;
4
23
  export interface MonacoWebSocketOptions {
5
24
  /** Session keypair for authenticated channels (orders, balances, etc.) */
6
25
  session?: SessionCredentials;
7
26
  /** Enable auto-reconnect on disconnect (default: true) */
8
27
  autoReconnect?: boolean;
9
- /** Maximum reconnection attempts (default: 5) */
28
+ /**
29
+ * Maximum reconnection attempts (default: unlimited). Retry delay is
30
+ * full-jitter exponential backoff capped at 30s, so unlimited retries are
31
+ * capped-rate, not a tight loop.
32
+ */
10
33
  maxReconnectAttempts?: number;
34
+ /** Base delay for the exponential backoff in milliseconds (default: 1000) */
35
+ reconnectBaseDelayMs?: number;
36
+ /** How long a connection attempt may stay CONNECTING before it is failed
37
+ * and retried, in milliseconds (default: 10000) */
38
+ connectionTimeoutMs?: number;
11
39
  /** Callback for connection status changes */
12
40
  onStatusChange?: StatusHandler;
41
+ /**
42
+ * Called after every automatic reconnect once the live stream is resumed
43
+ * (re-authenticated and re-subscribed). Refetch REST snapshots here and
44
+ * reconcile by `(id, updatedAt)`, last-writer-wins; events that arrived
45
+ * while the snapshot was in flight are newer than the snapshot rows they
46
+ * duplicate.
47
+ */
48
+ onResync?: ResyncHandler;
13
49
  }
14
50
  export interface MonacoWebSocket {
15
51
  /** Connect to the WebSocket server */
@@ -6,6 +6,7 @@ import { keysToCamelCase } from "./utils";
6
6
  const CONNECTION_TIMEOUT = 10000;
7
7
  const HEARTBEAT_INTERVAL = 15000;
8
8
  const MAX_RECONNECT_DELAY = 30000;
9
+ const RECONNECT_BASE_DELAY = 1000;
9
10
  const CONDITIONAL_ORDER_REASONS = ["created", "activated", "cancelled", "parent_cancelled", "triggered", "failed", "oco_cancelled"];
10
11
  const CONDITIONAL_ORDER_CONDITION_TYPES = ["STOP_LOSS", "TAKE_PROFIT"];
11
12
  const CONDITIONAL_ORDER_TRIGGER_SOURCES = ["MARK_PRICE"];
@@ -114,19 +115,33 @@ export function createMonacoWebSocket(baseUrl, options = {}) {
114
115
  let reconnectTimer = null;
115
116
  let heartbeatTimer = null;
116
117
  let autoReconnect = options.autoReconnect ?? true;
117
- const maxReconnectAttempts = options.maxReconnectAttempts ?? 5;
118
+ // Capped-infinite by default: unlimited attempts, delay capped at 30s. A
119
+ // client that gives up permanently (the old 5-attempt default) turns any
120
+ // blip longer than ~1 minute into a dead app.
121
+ const maxReconnectAttempts = options.maxReconnectAttempts ?? Number.POSITIVE_INFINITY;
122
+ // Normalize to a positive finite base: a negative/NaN/Infinity override
123
+ // would degenerate into a tight reconnect loop or no reconnect at all.
124
+ const reconnectBaseDelay = Number.isFinite(options.reconnectBaseDelayMs) && options.reconnectBaseDelayMs > 0
125
+ ? options.reconnectBaseDelayMs
126
+ : RECONNECT_BASE_DELAY;
127
+ const connectionTimeout = Number.isFinite(options.connectionTimeoutMs) && options.connectionTimeoutMs > 0 ? options.connectionTimeoutMs : CONNECTION_TIMEOUT;
128
+ // Close code of the connection loss the next successful reopen recovers
129
+ // from; null when no resync is owed (first connect, clean disconnect).
130
+ let pendingResyncCode = null;
118
131
  // Handler storage: channel -> set of handlers (supports multiple subscribers per channel)
119
132
  const handlers = new Map();
120
133
  const getStatus = () => {
121
134
  if (!ws)
122
- return "disconnected";
135
+ return reconnectTimer ? "reconnecting" : "disconnected";
123
136
  switch (ws.readyState) {
124
137
  case WebSocket.CONNECTING:
125
138
  return "connecting";
126
139
  case WebSocket.OPEN:
127
140
  return "connected";
128
141
  default:
129
- return "disconnected";
142
+ // CLOSING/CLOSED with a retry armed is mid-outage, not terminal:
143
+ // the timeout path arms the retry before the close event lands.
144
+ return reconnectTimer ? "reconnecting" : "disconnected";
130
145
  }
131
146
  };
132
147
  const getUrl = () => baseUrl;
@@ -169,17 +184,35 @@ export function createMonacoWebSocket(baseUrl, options = {}) {
169
184
  }
170
185
  };
171
186
  const scheduleReconnect = () => {
187
+ // Single scheduling authority: a failed attempt can report through both
188
+ // the connect() rejection and the socket's own close event — an armed
189
+ // timer means the retry is already owned.
190
+ if (reconnectTimer)
191
+ return;
172
192
  if (!autoReconnect || reconnectAttempts >= maxReconnectAttempts) {
173
193
  console.error("WebSocket: Max reconnect attempts reached");
174
194
  return;
175
195
  }
196
+ // Full-jitter exponential backoff: uniform in [0, min(base * 2^n, cap)],
197
+ // n starting at 0 so the first retry's ceiling is the base itself.
198
+ // Jitter desynchronizes the reconnect storm after a server deploy or a
199
+ // mass 1013 shed — synchronized clients would otherwise reconnect in
200
+ // lockstep waves.
201
+ const ceiling = Math.min(reconnectBaseDelay * 2 ** reconnectAttempts, MAX_RECONNECT_DELAY);
176
202
  reconnectAttempts++;
177
- const delay = Math.min(1000 * 2 ** reconnectAttempts, MAX_RECONNECT_DELAY);
203
+ const delay = Math.random() * ceiling;
178
204
  reconnectTimer = setTimeout(() => {
205
+ reconnectTimer = null;
206
+ // Re-check: a callback already dequeued when disconnect() ran is not
207
+ // stopped by clearTimeout and must not reopen a socket.
208
+ if (!autoReconnect)
209
+ return;
179
210
  connect().catch((err) => {
180
211
  console.warn("WebSocket: Failed to reconnect:", err);
212
+ scheduleReconnect();
181
213
  });
182
214
  }, delay);
215
+ options.onStatusChange?.("reconnecting");
183
216
  };
184
217
  const send = (data) => {
185
218
  if (ws?.readyState === WebSocket.OPEN) {
@@ -223,7 +256,7 @@ export function createMonacoWebSocket(baseUrl, options = {}) {
223
256
  socket.close(1000, "Connection timeout");
224
257
  reject(new Error("WebSocket connection timeout"));
225
258
  }
226
- }, CONNECTION_TIMEOUT);
259
+ }, connectionTimeout);
227
260
  socket.onopen = () => {
228
261
  clearTimeout(timeout);
229
262
  if (ws !== socket) {
@@ -231,12 +264,26 @@ export function createMonacoWebSocket(baseUrl, options = {}) {
231
264
  return;
232
265
  }
233
266
  reconnectAttempts = 0;
267
+ // A retry armed before this open (e.g. a manual connect() during
268
+ // backoff) is moot now — left running it would suppress the
269
+ // terminal disconnected emit and reopen a socket after a clean
270
+ // close.
271
+ stopReconnect();
234
272
  startHeartbeat();
235
273
  // Authenticate before resubscribing so user-specific channels
236
274
  // (orders, balances, …) are authorized when the Subscribe arrives.
237
275
  sendAuthenticate();
238
276
  resubscribeAll();
239
277
  options.onStatusChange?.("connected");
278
+ // Resync-join order: resume the live stream first (auth +
279
+ // subscribe above), then tell the app to refetch snapshots and
280
+ // reconcile by (id, updatedAt) — events racing the snapshot are
281
+ // newer than the rows they duplicate.
282
+ if (pendingResyncCode !== null) {
283
+ const code = pendingResyncCode;
284
+ pendingResyncCode = null;
285
+ options.onResync?.({ code, slowClient: code === 1013 });
286
+ }
240
287
  resolve();
241
288
  };
242
289
  socket.onmessage = (event) => {
@@ -247,6 +294,12 @@ export function createMonacoWebSocket(baseUrl, options = {}) {
247
294
  // Handle pong silently
248
295
  if (msg.type === "Pong")
249
296
  return;
297
+ // Answer a server-initiated application ping: the server owns
298
+ // liveness through proxy idle timeouts, the client just echoes.
299
+ if (msg.type === "Ping") {
300
+ send({ type: "Pong" });
301
+ return;
302
+ }
250
303
  routeMessage(msg);
251
304
  }
252
305
  catch (err) {
@@ -261,11 +314,22 @@ export function createMonacoWebSocket(baseUrl, options = {}) {
261
314
  }
262
315
  ws = null;
263
316
  stopHeartbeat();
264
- options.onStatusChange?.("disconnected");
265
- // Reconnect on abnormal close
317
+ // Reconnect on abnormal close. 1013 is the server's slow-client
318
+ // shed: the stream has a gap, so the reopen owes the app a resync.
319
+ // Keep the first owed code (upgrading to 1013 if it arrives later):
320
+ // failures of the reconnect attempts themselves must not launder
321
+ // the slow-client signal into a generic 1006.
266
322
  if (autoReconnect && event.code !== 1000) {
323
+ if (pendingResyncCode === null || event.code === 1013) {
324
+ pendingResyncCode = event.code;
325
+ }
267
326
  scheduleReconnect();
268
327
  }
328
+ // "disconnected" is terminal: suppressed while a retry is armed
329
+ // (scheduleReconnect emitted "reconnecting" instead).
330
+ if (!reconnectTimer) {
331
+ options.onStatusChange?.("disconnected");
332
+ }
269
333
  };
270
334
  socket.onerror = () => clearTimeout(timeout);
271
335
  }
@@ -279,6 +343,7 @@ export function createMonacoWebSocket(baseUrl, options = {}) {
279
343
  autoReconnect = false;
280
344
  stopReconnect();
281
345
  stopHeartbeat();
346
+ pendingResyncCode = null;
282
347
  handlers.clear();
283
348
  ws?.close(1000, "Client disconnect");
284
349
  ws = null;
@@ -22,7 +22,10 @@ const DEFAULT_API_URLS = {
22
22
  */
23
23
  const DEFAULT_WS_URLS = {
24
24
  local: "ws://localhost:8085/ws",
25
- development: "wss://develop.apimonaco.xyz/ws",
25
+ // Dedicated WebSocket ingress (NLB behind Cloudflare). The previous
26
+ // ALB path (wss://develop.apimonaco.xyz/ws) keeps working during the
27
+ // migration window.
28
+ development: "wss://ws-develop.apimonaco.xyz/ws",
26
29
  staging: "wss://staging.apimonaco.xyz/ws",
27
30
  mainnet: "wss://api.monaco.xyz/ws",
28
31
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@0xmonaco/core",
3
- "version": "1.0.25-develop.1fab96c",
3
+ "version": "1.0.26",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -23,8 +23,8 @@
23
23
  "viem": "^2.45.2"
24
24
  },
25
25
  "dependencies": {
26
- "@0xmonaco/contracts": "1.0.25-develop.1fab96c",
27
- "@0xmonaco/types": "1.0.25-develop.1fab96c",
26
+ "@0xmonaco/contracts": "1.0.26",
27
+ "@0xmonaco/types": "1.0.26",
28
28
  "@noble/curves": "^1.9.1",
29
29
  "@noble/hashes": "^1.8.0",
30
30
  "http-status-codes": "^2.3.0"