@trops/dash-core 0.1.162 → 0.1.164

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.
@@ -44949,6 +44949,10 @@ var ws = WebSocket$1;
44949
44949
  * - pendingConnects Map for in-flight deduplication
44950
44950
  * - Status constants matching MCP pattern
44951
44951
  *
44952
+ * Features:
44953
+ * - Auto-reconnect with exponential backoff (1s → 2s → 4s → 8s → 16s, max 30s, 5 retries)
44954
+ * - Heartbeat ping/pong keepalive (30s interval, 10s pong timeout)
44955
+ *
44952
44956
  * Uses the `ws` package (installed in dash-electron) for WebSocket clients.
44953
44957
  * Multiple widgets referencing the same provider share a single socket.
44954
44958
  */
@@ -44959,7 +44963,10 @@ const WebSocket = ws;
44959
44963
  * Active WebSocket connections
44960
44964
  * Map<string, { socket: WebSocket, status: string, config: object,
44961
44965
  * consumers: Set<number>, messageCount: number,
44962
- * connectedAt: number|null, lastMessageAt: number|null }>
44966
+ * connectedAt: number|null, lastMessageAt: number|null,
44967
+ * retryCount: number, retryTimer: NodeJS.Timeout|null,
44968
+ * heartbeatTimer: NodeJS.Timeout|null, pongTimer: NodeJS.Timeout|null,
44969
+ * intentionalClose: boolean }>
44963
44970
  */
44964
44971
  const activeConnections = new Map();
44965
44972
 
@@ -44981,6 +44988,24 @@ const STATUS = {
44981
44988
  ERROR: "error",
44982
44989
  };
44983
44990
 
44991
+ /**
44992
+ * Reconnect configuration
44993
+ */
44994
+ const RECONNECT = {
44995
+ BASE_DELAY: 1000, // 1 second
44996
+ MULTIPLIER: 2,
44997
+ MAX_DELAY: 30000, // 30 seconds
44998
+ MAX_RETRIES: 5,
44999
+ };
45000
+
45001
+ /**
45002
+ * Heartbeat configuration
45003
+ */
45004
+ const HEARTBEAT = {
45005
+ PING_INTERVAL: 30000, // 30 seconds
45006
+ PONG_TIMEOUT: 10000, // 10 seconds
45007
+ };
45008
+
44984
45009
  /**
44985
45010
  * Interpolate {{fieldName}} placeholders in a string with credential values.
44986
45011
  * Reuses the same pattern as mcpController for URL and header templates.
@@ -45001,7 +45026,7 @@ function interpolate(template, credentials) {
45001
45026
  *
45002
45027
  * @param {string} providerName - The provider whose status changed
45003
45028
  * @param {string} status - New status value
45004
- * @param {object} extra - Additional fields (error, etc.)
45029
+ * @param {object} extra - Additional fields (error, retryCount, retryIn, etc.)
45005
45030
  */
45006
45031
  function broadcastStatusChange(providerName, status, extra = {}) {
45007
45032
  const { WS_STATUS_CHANGE } = webSocketEvents$1;
@@ -45037,6 +45062,320 @@ function broadcastMessage(providerName, data) {
45037
45062
  }
45038
45063
  }
45039
45064
 
45065
+ /**
45066
+ * Calculate the backoff delay for a given retry attempt.
45067
+ *
45068
+ * @param {number} retryCount - Current retry attempt (0-based)
45069
+ * @returns {number} Delay in milliseconds
45070
+ */
45071
+ function getBackoffDelay(retryCount) {
45072
+ const delay =
45073
+ RECONNECT.BASE_DELAY * Math.pow(RECONNECT.MULTIPLIER, retryCount);
45074
+ return Math.min(delay, RECONNECT.MAX_DELAY);
45075
+ }
45076
+
45077
+ /**
45078
+ * Clear heartbeat and pong timers for a connection.
45079
+ *
45080
+ * @param {object} conn - The connection object from activeConnections
45081
+ */
45082
+ function clearHeartbeatTimers(conn) {
45083
+ if (conn.heartbeatTimer) {
45084
+ clearInterval(conn.heartbeatTimer);
45085
+ conn.heartbeatTimer = null;
45086
+ }
45087
+ if (conn.pongTimer) {
45088
+ clearTimeout(conn.pongTimer);
45089
+ conn.pongTimer = null;
45090
+ }
45091
+ }
45092
+
45093
+ /**
45094
+ * Clear the reconnect timer for a connection.
45095
+ *
45096
+ * @param {object} conn - The connection object from activeConnections
45097
+ */
45098
+ function clearReconnectTimer(conn) {
45099
+ if (conn.retryTimer) {
45100
+ clearTimeout(conn.retryTimer);
45101
+ conn.retryTimer = null;
45102
+ }
45103
+ }
45104
+
45105
+ /**
45106
+ * Start heartbeat ping/pong for an active connection.
45107
+ * Sends a WebSocket ping frame every HEARTBEAT.PING_INTERVAL ms.
45108
+ * If no pong is received within HEARTBEAT.PONG_TIMEOUT ms, the connection
45109
+ * is considered stale and a reconnect is triggered.
45110
+ *
45111
+ * @param {string} providerName - The provider name
45112
+ */
45113
+ function startHeartbeat(providerName) {
45114
+ const conn = activeConnections.get(providerName);
45115
+ if (!conn || !conn.socket) return;
45116
+
45117
+ // Clear any existing heartbeat timers
45118
+ clearHeartbeatTimers(conn);
45119
+
45120
+ conn.heartbeatTimer = setInterval(() => {
45121
+ const current = activeConnections.get(providerName);
45122
+ if (
45123
+ !current ||
45124
+ !current.socket ||
45125
+ current.socket.readyState !== WebSocket.OPEN
45126
+ ) {
45127
+ clearHeartbeatTimers(current || conn);
45128
+ return;
45129
+ }
45130
+
45131
+ // Send ping
45132
+ try {
45133
+ current.socket.ping();
45134
+ } catch {
45135
+ // Socket errored during ping — will be caught by error/close handlers
45136
+ return;
45137
+ }
45138
+
45139
+ // Start pong timeout
45140
+ current.pongTimer = setTimeout(() => {
45141
+ const staleConn = activeConnections.get(providerName);
45142
+ if (!staleConn || staleConn.intentionalClose) return;
45143
+
45144
+ console.log(
45145
+ `[webSocketController] Heartbeat timeout (no pong): ${providerName}`,
45146
+ );
45147
+
45148
+ // Clear heartbeat before triggering reconnect
45149
+ clearHeartbeatTimers(staleConn);
45150
+
45151
+ // Close the stale socket to trigger the close handler → reconnect
45152
+ if (staleConn.socket) {
45153
+ try {
45154
+ staleConn.socket.terminate();
45155
+ } catch {
45156
+ /* already closing */
45157
+ }
45158
+ }
45159
+ }, HEARTBEAT.PONG_TIMEOUT);
45160
+ }, HEARTBEAT.PING_INTERVAL);
45161
+ }
45162
+
45163
+ /**
45164
+ * Attempt to reconnect a provider with exponential backoff.
45165
+ * Called from the socket close handler when the close was unexpected.
45166
+ *
45167
+ * @param {string} providerName - The provider to reconnect
45168
+ */
45169
+ function scheduleReconnect(providerName) {
45170
+ const conn = activeConnections.get(providerName);
45171
+ if (!conn) return;
45172
+
45173
+ if (conn.retryCount >= RECONNECT.MAX_RETRIES) {
45174
+ console.log(
45175
+ `[webSocketController] Max retries (${RECONNECT.MAX_RETRIES}) reached for ${providerName}`,
45176
+ );
45177
+ activeConnections.set(providerName, {
45178
+ ...conn,
45179
+ socket: null,
45180
+ status: STATUS.ERROR,
45181
+ error: `Reconnect failed after ${RECONNECT.MAX_RETRIES} attempts`,
45182
+ });
45183
+ broadcastStatusChange(providerName, STATUS.ERROR, {
45184
+ error: `Reconnect failed after ${RECONNECT.MAX_RETRIES} attempts`,
45185
+ });
45186
+ return;
45187
+ }
45188
+
45189
+ const delay = getBackoffDelay(conn.retryCount);
45190
+ console.log(
45191
+ `[webSocketController] Reconnecting ${providerName} in ${delay}ms (attempt ${conn.retryCount + 1}/${RECONNECT.MAX_RETRIES})`,
45192
+ );
45193
+
45194
+ // Broadcast connecting status with retry info
45195
+ broadcastStatusChange(providerName, STATUS.CONNECTING, {
45196
+ retryCount: conn.retryCount + 1,
45197
+ retryIn: delay,
45198
+ });
45199
+
45200
+ conn.retryTimer = setTimeout(async () => {
45201
+ const current = activeConnections.get(providerName);
45202
+ if (!current || current.intentionalClose) return;
45203
+
45204
+ // Update retry count before attempting
45205
+ current.retryCount++;
45206
+
45207
+ try {
45208
+ // Use the stored config to reconnect
45209
+ const config = current.config;
45210
+ const url = config.credentials
45211
+ ? interpolate(config.url, config.credentials)
45212
+ : config.url;
45213
+
45214
+ const wsOptions = {};
45215
+ if (config.headers) {
45216
+ const headers = {};
45217
+ if (config.credentials) {
45218
+ Object.entries(config.headers).forEach(([headerName, template]) => {
45219
+ headers[headerName] = interpolate(template, config.credentials);
45220
+ });
45221
+ } else {
45222
+ Object.assign(headers, config.headers);
45223
+ }
45224
+ wsOptions.headers = headers;
45225
+ }
45226
+
45227
+ console.log(
45228
+ `[webSocketController] Reconnect attempt ${current.retryCount}/${RECONNECT.MAX_RETRIES}: ${providerName}`,
45229
+ );
45230
+
45231
+ const socket = new WebSocket(url, config.subprotocols || [], wsOptions);
45232
+
45233
+ // Wait for open or error
45234
+ await new Promise((resolve, reject) => {
45235
+ const onOpen = () => {
45236
+ socket.removeListener("error", onError);
45237
+ resolve();
45238
+ };
45239
+ const onError = (err) => {
45240
+ socket.removeListener("open", onOpen);
45241
+ reject(err);
45242
+ };
45243
+ socket.once("open", onOpen);
45244
+ socket.once("error", onError);
45245
+ });
45246
+
45247
+ // Reconnect succeeded — reset retry count, update connection
45248
+ const reconnected = activeConnections.get(providerName);
45249
+ if (!reconnected || reconnected.intentionalClose) {
45250
+ // Was disconnected intentionally during reconnect
45251
+ socket.close(1000, "Client disconnect");
45252
+ return;
45253
+ }
45254
+
45255
+ activeConnections.set(providerName, {
45256
+ ...reconnected,
45257
+ socket,
45258
+ status: STATUS.CONNECTED,
45259
+ connectedAt: Date.now(),
45260
+ retryCount: 0,
45261
+ retryTimer: null,
45262
+ error: undefined,
45263
+ });
45264
+
45265
+ // Wire up handlers on the new socket
45266
+ wireSocketHandlers(providerName, socket);
45267
+
45268
+ // Start heartbeat on the new socket
45269
+ startHeartbeat(providerName);
45270
+
45271
+ broadcastStatusChange(providerName, STATUS.CONNECTED);
45272
+
45273
+ console.log(
45274
+ `[webSocketController] Reconnected: ${providerName} (after ${current.retryCount} attempt(s))`,
45275
+ );
45276
+ } catch (err) {
45277
+ console.error(
45278
+ `[webSocketController] Reconnect attempt ${current.retryCount} failed for ${providerName}:`,
45279
+ err.message,
45280
+ );
45281
+
45282
+ // Schedule next retry
45283
+ scheduleReconnect(providerName);
45284
+ }
45285
+ }, delay);
45286
+ }
45287
+
45288
+ /**
45289
+ * Wire up message, close, error, and pong handlers on a WebSocket instance.
45290
+ * Extracted so both initial connect and reconnect use the same handlers.
45291
+ *
45292
+ * @param {string} providerName - The provider name
45293
+ * @param {WebSocket} socket - The WebSocket instance
45294
+ */
45295
+ function wireSocketHandlers(providerName, socket) {
45296
+ // Message handler
45297
+ socket.on("message", (data) => {
45298
+ const conn = activeConnections.get(providerName);
45299
+ if (conn) {
45300
+ conn.messageCount++;
45301
+ conn.lastMessageAt = Date.now();
45302
+ }
45303
+
45304
+ let parsed;
45305
+ try {
45306
+ parsed = JSON.parse(data.toString());
45307
+ } catch {
45308
+ parsed = data.toString();
45309
+ }
45310
+
45311
+ broadcastMessage(providerName, parsed);
45312
+ });
45313
+
45314
+ // Close handler — triggers auto-reconnect for unexpected closes
45315
+ socket.on("close", (code, reason) => {
45316
+ console.log(
45317
+ `[webSocketController] Connection closed: ${providerName} (code: ${code})`,
45318
+ );
45319
+ const conn = activeConnections.get(providerName);
45320
+ if (!conn || conn.socket !== socket) return;
45321
+
45322
+ // Clean up heartbeat timers
45323
+ clearHeartbeatTimers(conn);
45324
+
45325
+ // Update status
45326
+ activeConnections.set(providerName, {
45327
+ ...conn,
45328
+ socket: null,
45329
+ status: STATUS.DISCONNECTED,
45330
+ });
45331
+
45332
+ // Normal close (code 1000) or intentional disconnect — don't reconnect
45333
+ if (conn.intentionalClose || code === 1000) {
45334
+ broadcastStatusChange(providerName, STATUS.DISCONNECTED, {
45335
+ code,
45336
+ reason: reason?.toString(),
45337
+ });
45338
+ return;
45339
+ }
45340
+
45341
+ // Unexpected close — attempt auto-reconnect
45342
+ broadcastStatusChange(providerName, STATUS.DISCONNECTED, {
45343
+ code,
45344
+ reason: reason?.toString(),
45345
+ reconnecting: true,
45346
+ });
45347
+ scheduleReconnect(providerName);
45348
+ });
45349
+
45350
+ // Error handler
45351
+ socket.on("error", (err) => {
45352
+ console.error(
45353
+ `[webSocketController] Socket error for ${providerName}:`,
45354
+ err.message,
45355
+ );
45356
+ const conn = activeConnections.get(providerName);
45357
+ if (conn && conn.socket === socket) {
45358
+ activeConnections.set(providerName, {
45359
+ ...conn,
45360
+ status: STATUS.ERROR,
45361
+ error: err.message,
45362
+ });
45363
+ broadcastStatusChange(providerName, STATUS.ERROR, {
45364
+ error: err.message,
45365
+ });
45366
+ }
45367
+ });
45368
+
45369
+ // Pong handler — clear the pong timeout when we receive a pong
45370
+ socket.on("pong", () => {
45371
+ const conn = activeConnections.get(providerName);
45372
+ if (conn && conn.pongTimer) {
45373
+ clearTimeout(conn.pongTimer);
45374
+ conn.pongTimer = null;
45375
+ }
45376
+ });
45377
+ }
45378
+
45040
45379
  const webSocketController$1 = {
45041
45380
  /**
45042
45381
  * connect
@@ -45076,8 +45415,11 @@ const webSocketController$1 = {
45076
45415
  // 3. Fresh connect — wrap in a promise and track it
45077
45416
  const connectPromise = (async () => {
45078
45417
  try {
45079
- // Clean up stale/error state
45418
+ // Clean up stale/error state (including any pending reconnect timers)
45080
45419
  if (activeConnections.has(providerName)) {
45420
+ const stale = activeConnections.get(providerName);
45421
+ clearReconnectTimer(stale);
45422
+ clearHeartbeatTimers(stale);
45081
45423
  await webSocketController$1.disconnect(win, providerName);
45082
45424
  }
45083
45425
 
@@ -45120,6 +45462,11 @@ const webSocketController$1 = {
45120
45462
  messageCount: 0,
45121
45463
  connectedAt: null,
45122
45464
  lastMessageAt: null,
45465
+ retryCount: 0,
45466
+ retryTimer: null,
45467
+ heartbeatTimer: null,
45468
+ pongTimer: null,
45469
+ intentionalClose: false,
45123
45470
  });
45124
45471
  broadcastStatusChange(providerName, STATUS.CONNECTING);
45125
45472
 
@@ -45152,64 +45499,18 @@ const webSocketController$1 = {
45152
45499
  messageCount: 0,
45153
45500
  connectedAt: Date.now(),
45154
45501
  lastMessageAt: null,
45502
+ retryCount: 0,
45503
+ retryTimer: null,
45504
+ heartbeatTimer: null,
45505
+ pongTimer: null,
45506
+ intentionalClose: false,
45155
45507
  });
45156
45508
 
45157
- // Wire up message handler
45158
- socket.on("message", (data) => {
45159
- const conn = activeConnections.get(providerName);
45160
- if (conn) {
45161
- conn.messageCount++;
45162
- conn.lastMessageAt = Date.now();
45163
- }
45164
-
45165
- // Parse if JSON, otherwise pass as string
45166
- let parsed;
45167
- try {
45168
- parsed = JSON.parse(data.toString());
45169
- } catch {
45170
- parsed = data.toString();
45171
- }
45172
-
45173
- broadcastMessage(providerName, parsed);
45174
- });
45175
-
45176
- // Wire up close handler
45177
- socket.on("close", (code, reason) => {
45178
- console.log(
45179
- `[webSocketController] Connection closed: ${providerName} (code: ${code})`,
45180
- );
45181
- const conn = activeConnections.get(providerName);
45182
- if (conn && conn.socket === socket) {
45183
- activeConnections.set(providerName, {
45184
- ...conn,
45185
- socket: null,
45186
- status: STATUS.DISCONNECTED,
45187
- });
45188
- broadcastStatusChange(providerName, STATUS.DISCONNECTED, {
45189
- code,
45190
- reason: reason?.toString(),
45191
- });
45192
- }
45193
- });
45509
+ // Wire up socket event handlers
45510
+ wireSocketHandlers(providerName, socket);
45194
45511
 
45195
- // Wire up error handler
45196
- socket.on("error", (err) => {
45197
- console.error(
45198
- `[webSocketController] Socket error for ${providerName}:`,
45199
- err.message,
45200
- );
45201
- const conn = activeConnections.get(providerName);
45202
- if (conn && conn.socket === socket) {
45203
- activeConnections.set(providerName, {
45204
- ...conn,
45205
- status: STATUS.ERROR,
45206
- error: err.message,
45207
- });
45208
- broadcastStatusChange(providerName, STATUS.ERROR, {
45209
- error: err.message,
45210
- });
45211
- }
45212
- });
45512
+ // Start heartbeat
45513
+ startHeartbeat(providerName);
45213
45514
 
45214
45515
  broadcastStatusChange(providerName, STATUS.CONNECTED);
45215
45516
 
@@ -45235,6 +45536,11 @@ const webSocketController$1 = {
45235
45536
  messageCount: 0,
45236
45537
  connectedAt: null,
45237
45538
  lastMessageAt: null,
45539
+ retryCount: 0,
45540
+ retryTimer: null,
45541
+ heartbeatTimer: null,
45542
+ pongTimer: null,
45543
+ intentionalClose: false,
45238
45544
  error: error.message,
45239
45545
  });
45240
45546
  broadcastStatusChange(providerName, STATUS.ERROR, {
@@ -45259,6 +45565,7 @@ const webSocketController$1 = {
45259
45565
  /**
45260
45566
  * disconnect
45261
45567
  * Close a WebSocket connection and clean up.
45568
+ * Marks the close as intentional to suppress auto-reconnect.
45262
45569
  *
45263
45570
  * @param {BrowserWindow} win - the requesting window
45264
45571
  * @param {string} providerName - the provider to disconnect
@@ -45286,6 +45593,13 @@ const webSocketController$1 = {
45286
45593
 
45287
45594
  console.log(`[webSocketController] Disconnecting: ${providerName}`);
45288
45595
 
45596
+ // Mark as intentional so the close handler doesn't auto-reconnect
45597
+ conn.intentionalClose = true;
45598
+
45599
+ // Clear all timers
45600
+ clearHeartbeatTimers(conn);
45601
+ clearReconnectTimer(conn);
45602
+
45289
45603
  // Close the socket
45290
45604
  if (conn.socket) {
45291
45605
  try {
@@ -45313,6 +45627,11 @@ const webSocketController$1 = {
45313
45627
  error,
45314
45628
  );
45315
45629
  // Clean up anyway
45630
+ const conn = activeConnections.get(providerName);
45631
+ if (conn) {
45632
+ clearHeartbeatTimers(conn);
45633
+ clearReconnectTimer(conn);
45634
+ }
45316
45635
  activeConnections.delete(providerName);
45317
45636
  return {
45318
45637
  error: true,
@@ -45367,7 +45686,7 @@ const webSocketController$1 = {
45367
45686
  *
45368
45687
  * @param {BrowserWindow} win - the requesting window
45369
45688
  * @param {string} providerName - the provider name
45370
- * @returns {{ providerName, status, messageCount, connectedAt, lastMessageAt, error }}
45689
+ * @returns {{ providerName, status, messageCount, connectedAt, lastMessageAt, error, retryCount }}
45371
45690
  */
45372
45691
  getStatus: (win, providerName) => {
45373
45692
  const conn = activeConnections.get(providerName);
@@ -45388,6 +45707,7 @@ const webSocketController$1 = {
45388
45707
  connectedAt: conn.connectedAt || null,
45389
45708
  lastMessageAt: conn.lastMessageAt || null,
45390
45709
  error: conn.error || null,
45710
+ retryCount: conn.retryCount || 0,
45391
45711
  };
45392
45712
  },
45393
45713
 
@@ -45396,7 +45716,7 @@ const webSocketController$1 = {
45396
45716
  * Returns all active connections with their status.
45397
45717
  *
45398
45718
  * @param {BrowserWindow} win - the requesting window
45399
- * @returns {{ connections: Array<{ providerName, status, messageCount, connectedAt, lastMessageAt }> }}
45719
+ * @returns {{ connections: Array<{ providerName, status, messageCount, connectedAt, lastMessageAt, retryCount }> }}
45400
45720
  */
45401
45721
  getAll: (win) => {
45402
45722
  const connections = [];
@@ -45408,6 +45728,7 @@ const webSocketController$1 = {
45408
45728
  connectedAt: conn.connectedAt || null,
45409
45729
  lastMessageAt: conn.lastMessageAt || null,
45410
45730
  error: conn.error || null,
45731
+ retryCount: conn.retryCount || 0,
45411
45732
  });
45412
45733
  }
45413
45734
  return { success: true, connections };