@trops/dash-core 0.1.161 → 0.1.163

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.
@@ -29,11 +29,12 @@ var require$$2$4 = require('os');
29
29
  var require$$4$1 = require('url');
30
30
  var require$$2$3 = require('vm');
31
31
  var require$$1$5 = require('croner');
32
+ var require$$1$6 = require('node-vibrant/node');
33
+ var require$$3$4 = require('http');
32
34
  var require$$0$c = require('events');
33
- var require$$2$5 = require('http');
34
- var require$$3$4 = require('net');
35
+ var require$$3$5 = require('net');
35
36
  var require$$4$2 = require('tls');
36
- var require$$1$6 = require('crypto');
37
+ var require$$1$7 = require('crypto');
37
38
  var require$$0$a = require('zlib');
38
39
  var require$$0$b = require('buffer');
39
40
 
@@ -3748,7 +3749,7 @@ const { getFileContents: getFileContents$5, writeToFile: writeToFile$2 } = file;
3748
3749
  const ObjectsToCsv = require$$5;
3749
3750
  const Transform = transform;
3750
3751
  const { extractColorsFromImageURL: extractColorsFromImageURL$1 } = color;
3751
- const https$1 = require$$8;
3752
+ const https$2 = require$$8;
3752
3753
  const appName$5 = "Dashboard";
3753
3754
 
3754
3755
  const dataController$1 = {
@@ -3901,7 +3902,7 @@ const dataController$1 = {
3901
3902
 
3902
3903
  const writeStream = fs$8.createWriteStream(resolvedFilepath);
3903
3904
 
3904
- https$1
3905
+ https$2
3905
3906
  .get(url, (resp) => {
3906
3907
  resp.on("data", (chunk) => {
3907
3908
  writeStream.write(chunk);
@@ -38774,10 +38775,14 @@ css$1.stringify = stringify;
38774
38775
  *
38775
38776
  * Color extraction pipeline for generating themes from a website URL.
38776
38777
  * Extracts brand colors from HTML meta tags, CSS custom properties,
38777
- * and computed styles, then merges and ranks them into a palette.
38778
+ * computed styles, and favicon/logo images (via node-vibrant).
38778
38779
  */
38779
38780
 
38780
38781
  const css = css$1;
38782
+ const { Vibrant } = require$$1$6;
38783
+ const https$1 = require$$8;
38784
+ const http$2 = require$$3$4;
38785
+ const { URL: URL$2 } = require$$4$1;
38781
38786
 
38782
38787
  // ─── Color conversion helpers ───────────────────────────────────────────────
38783
38788
 
@@ -39072,6 +39077,178 @@ function extractComputedColors(computedStyles) {
39072
39077
  return results;
39073
39078
  }
39074
39079
 
39080
+ // ─── Favicon extraction ──────────────────────────────────────────────────────
39081
+
39082
+ /**
39083
+ * Parse HTML to find favicon and apple-touch-icon URLs.
39084
+ * Prefers apple-touch-icon (higher resolution) and largest available sizes.
39085
+ * @param {string} htmlContent - Raw HTML string
39086
+ * @returns {Array<{url: string, priority: number}>} Sorted by priority (highest first)
39087
+ */
39088
+ function extractFaviconUrls(htmlContent) {
39089
+ if (!htmlContent) return [];
39090
+ const icons = [];
39091
+
39092
+ // apple-touch-icon (higher resolution, best for extraction)
39093
+ const appleTouchPattern =
39094
+ /<link[^>]*rel\s*=\s*["']apple-touch-icon(?:-precomposed)?["'][^>]*>/gi;
39095
+ let match;
39096
+ while ((match = appleTouchPattern.exec(htmlContent)) !== null) {
39097
+ const hrefMatch = match[0].match(/href\s*=\s*["']([^"']+)["']/i);
39098
+ if (hrefMatch) {
39099
+ const sizesMatch = match[0].match(/sizes\s*=\s*["'](\d+)x(\d+)["']/i);
39100
+ const size = sizesMatch ? parseInt(sizesMatch[1], 10) : 180; // apple-touch-icon defaults to 180
39101
+ icons.push({ url: hrefMatch[1], priority: 100 + size });
39102
+ }
39103
+ }
39104
+
39105
+ // Standard favicon link tags (icon, shortcut icon)
39106
+ const iconPattern =
39107
+ /<link[^>]*rel\s*=\s*["'](?:shortcut\s+)?icon["'][^>]*>/gi;
39108
+ while ((match = iconPattern.exec(htmlContent)) !== null) {
39109
+ const hrefMatch = match[0].match(/href\s*=\s*["']([^"']+)["']/i);
39110
+ if (hrefMatch) {
39111
+ const sizesMatch = match[0].match(/sizes\s*=\s*["'](\d+)x(\d+)["']/i);
39112
+ const size = sizesMatch ? parseInt(sizesMatch[1], 10) : 16;
39113
+ icons.push({ url: hrefMatch[1], priority: size });
39114
+ }
39115
+ }
39116
+
39117
+ // Sort by priority descending (prefer largest / apple-touch-icon)
39118
+ icons.sort((a, b) => b.priority - a.priority);
39119
+ return icons;
39120
+ }
39121
+
39122
+ /**
39123
+ * Resolve a potentially relative URL against a base URL.
39124
+ * @param {string} href - The href from the HTML (may be relative)
39125
+ * @param {string} baseUrl - The page URL to resolve against
39126
+ * @returns {string|null} Absolute URL or null if invalid
39127
+ */
39128
+ function resolveUrl(href, baseUrl) {
39129
+ try {
39130
+ return new URL$2(href, baseUrl).href;
39131
+ } catch {
39132
+ return null;
39133
+ }
39134
+ }
39135
+
39136
+ /**
39137
+ * Fetch a URL and return the response as a Buffer.
39138
+ * Follows redirects (up to 5). Times out after 10 seconds.
39139
+ * @param {string} url - Absolute URL to fetch
39140
+ * @returns {Promise<Buffer>}
39141
+ */
39142
+ function fetchBuffer(url) {
39143
+ return new Promise((resolve, reject) => {
39144
+ const parsedUrl = new URL$2(url);
39145
+ const client = parsedUrl.protocol === "https:" ? https$1 : http$2;
39146
+ const request = client.get(
39147
+ url,
39148
+ { timeout: 10000, headers: { "User-Agent": "Dash/1.0" } },
39149
+ (res) => {
39150
+ // Follow redirects
39151
+ if (
39152
+ res.statusCode >= 300 &&
39153
+ res.statusCode < 400 &&
39154
+ res.headers.location
39155
+ ) {
39156
+ const redirectUrl = resolveUrl(res.headers.location, url);
39157
+ if (redirectUrl) {
39158
+ fetchBuffer(redirectUrl).then(resolve).catch(reject);
39159
+ return;
39160
+ }
39161
+ }
39162
+ if (res.statusCode !== 200) {
39163
+ reject(new Error(`HTTP ${res.statusCode}`));
39164
+ return;
39165
+ }
39166
+ const chunks = [];
39167
+ res.on("data", (chunk) => chunks.push(chunk));
39168
+ res.on("end", () => resolve(Buffer.concat(chunks)));
39169
+ res.on("error", reject);
39170
+ },
39171
+ );
39172
+ request.on("error", reject);
39173
+ request.on("timeout", () => {
39174
+ request.destroy();
39175
+ reject(new Error("Timeout"));
39176
+ });
39177
+ });
39178
+ }
39179
+
39180
+ /**
39181
+ * Extract colors from favicon/logo images using node-vibrant.
39182
+ * Tries icons in priority order (apple-touch-icon first, largest first).
39183
+ * Returns on the first successful extraction.
39184
+ *
39185
+ * @param {string} htmlContent - Raw HTML to parse for icon URLs
39186
+ * @param {string} baseUrl - Page URL for resolving relative icon paths
39187
+ * @returns {Promise<Array<{hex: string, source: string, confidence: number}>>}
39188
+ */
39189
+ async function extractFaviconColors(htmlContent, baseUrl) {
39190
+ const iconEntries = extractFaviconUrls(htmlContent);
39191
+ if (iconEntries.length === 0) {
39192
+ // Fallback: try /favicon.ico at the domain root
39193
+ try {
39194
+ const rootFavicon = new URL$2("/favicon.ico", baseUrl).href;
39195
+ iconEntries.push({ url: rootFavicon, priority: 1 });
39196
+ } catch {
39197
+ return [];
39198
+ }
39199
+ }
39200
+
39201
+ for (const entry of iconEntries) {
39202
+ const absoluteUrl = resolveUrl(entry.url, baseUrl);
39203
+ if (!absoluteUrl) continue;
39204
+
39205
+ try {
39206
+ const buffer = await fetchBuffer(absoluteUrl);
39207
+ const palette = await Vibrant.from(buffer).getPalette();
39208
+
39209
+ const results = [];
39210
+ const swatchNames = [
39211
+ "Vibrant",
39212
+ "DarkVibrant",
39213
+ "LightVibrant",
39214
+ "Muted",
39215
+ "DarkMuted",
39216
+ "LightMuted",
39217
+ ];
39218
+
39219
+ for (const name of swatchNames) {
39220
+ const swatch = palette[name];
39221
+ if (!swatch) continue;
39222
+ const [r, g, b] = swatch.rgb;
39223
+ const hex = rgbToHex({
39224
+ r: Math.round(r),
39225
+ g: Math.round(g),
39226
+ b: Math.round(b),
39227
+ });
39228
+ results.push({
39229
+ hex,
39230
+ source: "favicon",
39231
+ confidence: 0.7,
39232
+ });
39233
+ }
39234
+
39235
+ if (results.length > 0) {
39236
+ console.log(
39237
+ `[themeFromUrlController] Favicon vibrant: ${results.length} swatches from ${absoluteUrl}`,
39238
+ );
39239
+ return results;
39240
+ }
39241
+ } catch (err) {
39242
+ console.warn(
39243
+ `[themeFromUrlController] Favicon extraction failed for ${absoluteUrl}: ${err.message}`,
39244
+ );
39245
+ // Try next icon
39246
+ }
39247
+ }
39248
+
39249
+ return [];
39250
+ }
39251
+
39075
39252
  // ─── Merge & rank ────────────────────────────────────────────────────────────
39076
39253
 
39077
39254
  /**
@@ -39198,13 +39375,23 @@ function mergeAndRank(allColors, maxColors = 6) {
39198
39375
  /**
39199
39376
  * Extract a ranked color palette from website content.
39200
39377
  *
39378
+ * When `baseUrl` is provided, also extracts colors from favicon/logo images
39379
+ * via node-vibrant (async). Without `baseUrl`, runs synchronously using only
39380
+ * meta tags, CSS vars, and computed styles.
39381
+ *
39201
39382
  * @param {Object} params
39202
39383
  * @param {string} params.htmlContent - Raw HTML of the page
39203
39384
  * @param {string} params.cssContent - Concatenated CSS content
39204
39385
  * @param {Object} params.computedStyles - Map of selector → { color, backgroundColor, borderColor }
39205
- * @returns {{ palette: Array, rawCount: number }}
39206
- */
39207
- function extractColorsFromUrl({ htmlContent, cssContent, computedStyles }) {
39386
+ * @param {string} [params.baseUrl] - Page URL for resolving favicon paths (enables image extraction)
39387
+ * @returns {Promise<{ palette: Array, rawCount: number }>}
39388
+ */
39389
+ async function extractColorsFromUrl({
39390
+ htmlContent,
39391
+ cssContent,
39392
+ computedStyles,
39393
+ baseUrl,
39394
+ }) {
39208
39395
  console.log("[themeFromUrlController] Starting color extraction pipeline");
39209
39396
 
39210
39397
  const metaColors = extractMetaColors(htmlContent);
@@ -39222,7 +39409,27 @@ function extractColorsFromUrl({ htmlContent, cssContent, computedStyles }) {
39222
39409
  `[themeFromUrlController] Computed styles: ${computedColors.length} colors`,
39223
39410
  );
39224
39411
 
39225
- const allColors = [...metaColors, ...cssVarColors, ...computedColors];
39412
+ // Favicon extraction (async, requires baseUrl)
39413
+ let faviconColors = [];
39414
+ if (baseUrl) {
39415
+ try {
39416
+ faviconColors = await extractFaviconColors(htmlContent, baseUrl);
39417
+ console.log(
39418
+ `[themeFromUrlController] Favicon/logo: ${faviconColors.length} colors`,
39419
+ );
39420
+ } catch (err) {
39421
+ console.warn(
39422
+ `[themeFromUrlController] Favicon extraction failed: ${err.message}`,
39423
+ );
39424
+ }
39425
+ }
39426
+
39427
+ const allColors = [
39428
+ ...metaColors,
39429
+ ...cssVarColors,
39430
+ ...computedColors,
39431
+ ...faviconColors,
39432
+ ];
39226
39433
  console.log(`[themeFromUrlController] Total raw colors: ${allColors.length}`);
39227
39434
 
39228
39435
  const palette = mergeAndRank(allColors);
@@ -39238,6 +39445,8 @@ function extractColorsFromUrl({ htmlContent, cssContent, computedStyles }) {
39238
39445
 
39239
39446
  const themeFromUrlController$1 = {
39240
39447
  extractColorsFromUrl,
39448
+ extractFaviconUrls,
39449
+ extractFaviconColors,
39241
39450
  };
39242
39451
 
39243
39452
  var themeFromUrlController_1 = themeFromUrlController$1;
@@ -41465,7 +41674,7 @@ let Receiver$1 = class Receiver extends Writable {
41465
41674
  var receiver = Receiver$1;
41466
41675
 
41467
41676
  /* eslint no-unused-vars: ["error", { "varsIgnorePattern": "^Duplex" }] */
41468
- const { randomFillSync } = require$$1$6;
41677
+ const { randomFillSync } = require$$1$7;
41469
41678
 
41470
41679
  const PerMessageDeflate$2 = permessageDeflate;
41471
41680
  const { EMPTY_BUFFER: EMPTY_BUFFER$1, kWebSocket: kWebSocket$2, NOOP: NOOP$1 } = constants;
@@ -42560,10 +42769,10 @@ var extension$1 = { format: format$1, parse: parse$2 };
42560
42769
 
42561
42770
  const EventEmitter$1 = require$$0$c;
42562
42771
  const https = require$$8;
42563
- const http$1 = require$$2$5;
42564
- const net = require$$3$4;
42772
+ const http$1 = require$$3$4;
42773
+ const net = require$$3$5;
42565
42774
  const tls = require$$4$2;
42566
- const { randomBytes, createHash: createHash$1 } = require$$1$6;
42775
+ const { randomBytes, createHash: createHash$1 } = require$$1$7;
42567
42776
  const { URL: URL$1 } = require$$4$1;
42568
42777
 
42569
42778
  const PerMessageDeflate$1 = permessageDeflate;
@@ -44170,8 +44379,8 @@ var subprotocol$1 = { parse };
44170
44379
  /* eslint no-unused-vars: ["error", { "varsIgnorePattern": "^Duplex$", "caughtErrors": "none" }] */
44171
44380
 
44172
44381
  const EventEmitter = require$$0$c;
44173
- const http = require$$2$5;
44174
- const { createHash } = require$$1$6;
44382
+ const http = require$$3$4;
44383
+ const { createHash } = require$$1$7;
44175
44384
 
44176
44385
  const extension = extension$1;
44177
44386
  const PerMessageDeflate = permessageDeflate;
@@ -44740,6 +44949,10 @@ var ws = WebSocket$1;
44740
44949
  * - pendingConnects Map for in-flight deduplication
44741
44950
  * - Status constants matching MCP pattern
44742
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
+ *
44743
44956
  * Uses the `ws` package (installed in dash-electron) for WebSocket clients.
44744
44957
  * Multiple widgets referencing the same provider share a single socket.
44745
44958
  */
@@ -44750,7 +44963,10 @@ const WebSocket = ws;
44750
44963
  * Active WebSocket connections
44751
44964
  * Map<string, { socket: WebSocket, status: string, config: object,
44752
44965
  * consumers: Set<number>, messageCount: number,
44753
- * 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 }>
44754
44970
  */
44755
44971
  const activeConnections = new Map();
44756
44972
 
@@ -44772,6 +44988,24 @@ const STATUS = {
44772
44988
  ERROR: "error",
44773
44989
  };
44774
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
+
44775
45009
  /**
44776
45010
  * Interpolate {{fieldName}} placeholders in a string with credential values.
44777
45011
  * Reuses the same pattern as mcpController for URL and header templates.
@@ -44792,7 +45026,7 @@ function interpolate(template, credentials) {
44792
45026
  *
44793
45027
  * @param {string} providerName - The provider whose status changed
44794
45028
  * @param {string} status - New status value
44795
- * @param {object} extra - Additional fields (error, etc.)
45029
+ * @param {object} extra - Additional fields (error, retryCount, retryIn, etc.)
44796
45030
  */
44797
45031
  function broadcastStatusChange(providerName, status, extra = {}) {
44798
45032
  const { WS_STATUS_CHANGE } = webSocketEvents$1;
@@ -44828,6 +45062,320 @@ function broadcastMessage(providerName, data) {
44828
45062
  }
44829
45063
  }
44830
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
+
44831
45379
  const webSocketController$1 = {
44832
45380
  /**
44833
45381
  * connect
@@ -44867,8 +45415,11 @@ const webSocketController$1 = {
44867
45415
  // 3. Fresh connect — wrap in a promise and track it
44868
45416
  const connectPromise = (async () => {
44869
45417
  try {
44870
- // Clean up stale/error state
45418
+ // Clean up stale/error state (including any pending reconnect timers)
44871
45419
  if (activeConnections.has(providerName)) {
45420
+ const stale = activeConnections.get(providerName);
45421
+ clearReconnectTimer(stale);
45422
+ clearHeartbeatTimers(stale);
44872
45423
  await webSocketController$1.disconnect(win, providerName);
44873
45424
  }
44874
45425
 
@@ -44911,6 +45462,11 @@ const webSocketController$1 = {
44911
45462
  messageCount: 0,
44912
45463
  connectedAt: null,
44913
45464
  lastMessageAt: null,
45465
+ retryCount: 0,
45466
+ retryTimer: null,
45467
+ heartbeatTimer: null,
45468
+ pongTimer: null,
45469
+ intentionalClose: false,
44914
45470
  });
44915
45471
  broadcastStatusChange(providerName, STATUS.CONNECTING);
44916
45472
 
@@ -44943,64 +45499,18 @@ const webSocketController$1 = {
44943
45499
  messageCount: 0,
44944
45500
  connectedAt: Date.now(),
44945
45501
  lastMessageAt: null,
45502
+ retryCount: 0,
45503
+ retryTimer: null,
45504
+ heartbeatTimer: null,
45505
+ pongTimer: null,
45506
+ intentionalClose: false,
44946
45507
  });
44947
45508
 
44948
- // Wire up message handler
44949
- socket.on("message", (data) => {
44950
- const conn = activeConnections.get(providerName);
44951
- if (conn) {
44952
- conn.messageCount++;
44953
- conn.lastMessageAt = Date.now();
44954
- }
44955
-
44956
- // Parse if JSON, otherwise pass as string
44957
- let parsed;
44958
- try {
44959
- parsed = JSON.parse(data.toString());
44960
- } catch {
44961
- parsed = data.toString();
44962
- }
45509
+ // Wire up socket event handlers
45510
+ wireSocketHandlers(providerName, socket);
44963
45511
 
44964
- broadcastMessage(providerName, parsed);
44965
- });
44966
-
44967
- // Wire up close handler
44968
- socket.on("close", (code, reason) => {
44969
- console.log(
44970
- `[webSocketController] Connection closed: ${providerName} (code: ${code})`,
44971
- );
44972
- const conn = activeConnections.get(providerName);
44973
- if (conn && conn.socket === socket) {
44974
- activeConnections.set(providerName, {
44975
- ...conn,
44976
- socket: null,
44977
- status: STATUS.DISCONNECTED,
44978
- });
44979
- broadcastStatusChange(providerName, STATUS.DISCONNECTED, {
44980
- code,
44981
- reason: reason?.toString(),
44982
- });
44983
- }
44984
- });
44985
-
44986
- // Wire up error handler
44987
- socket.on("error", (err) => {
44988
- console.error(
44989
- `[webSocketController] Socket error for ${providerName}:`,
44990
- err.message,
44991
- );
44992
- const conn = activeConnections.get(providerName);
44993
- if (conn && conn.socket === socket) {
44994
- activeConnections.set(providerName, {
44995
- ...conn,
44996
- status: STATUS.ERROR,
44997
- error: err.message,
44998
- });
44999
- broadcastStatusChange(providerName, STATUS.ERROR, {
45000
- error: err.message,
45001
- });
45002
- }
45003
- });
45512
+ // Start heartbeat
45513
+ startHeartbeat(providerName);
45004
45514
 
45005
45515
  broadcastStatusChange(providerName, STATUS.CONNECTED);
45006
45516
 
@@ -45026,6 +45536,11 @@ const webSocketController$1 = {
45026
45536
  messageCount: 0,
45027
45537
  connectedAt: null,
45028
45538
  lastMessageAt: null,
45539
+ retryCount: 0,
45540
+ retryTimer: null,
45541
+ heartbeatTimer: null,
45542
+ pongTimer: null,
45543
+ intentionalClose: false,
45029
45544
  error: error.message,
45030
45545
  });
45031
45546
  broadcastStatusChange(providerName, STATUS.ERROR, {
@@ -45050,6 +45565,7 @@ const webSocketController$1 = {
45050
45565
  /**
45051
45566
  * disconnect
45052
45567
  * Close a WebSocket connection and clean up.
45568
+ * Marks the close as intentional to suppress auto-reconnect.
45053
45569
  *
45054
45570
  * @param {BrowserWindow} win - the requesting window
45055
45571
  * @param {string} providerName - the provider to disconnect
@@ -45077,6 +45593,13 @@ const webSocketController$1 = {
45077
45593
 
45078
45594
  console.log(`[webSocketController] Disconnecting: ${providerName}`);
45079
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
+
45080
45603
  // Close the socket
45081
45604
  if (conn.socket) {
45082
45605
  try {
@@ -45104,6 +45627,11 @@ const webSocketController$1 = {
45104
45627
  error,
45105
45628
  );
45106
45629
  // Clean up anyway
45630
+ const conn = activeConnections.get(providerName);
45631
+ if (conn) {
45632
+ clearHeartbeatTimers(conn);
45633
+ clearReconnectTimer(conn);
45634
+ }
45107
45635
  activeConnections.delete(providerName);
45108
45636
  return {
45109
45637
  error: true,
@@ -45158,7 +45686,7 @@ const webSocketController$1 = {
45158
45686
  *
45159
45687
  * @param {BrowserWindow} win - the requesting window
45160
45688
  * @param {string} providerName - the provider name
45161
- * @returns {{ providerName, status, messageCount, connectedAt, lastMessageAt, error }}
45689
+ * @returns {{ providerName, status, messageCount, connectedAt, lastMessageAt, error, retryCount }}
45162
45690
  */
45163
45691
  getStatus: (win, providerName) => {
45164
45692
  const conn = activeConnections.get(providerName);
@@ -45179,6 +45707,7 @@ const webSocketController$1 = {
45179
45707
  connectedAt: conn.connectedAt || null,
45180
45708
  lastMessageAt: conn.lastMessageAt || null,
45181
45709
  error: conn.error || null,
45710
+ retryCount: conn.retryCount || 0,
45182
45711
  };
45183
45712
  },
45184
45713
 
@@ -45187,7 +45716,7 @@ const webSocketController$1 = {
45187
45716
  * Returns all active connections with their status.
45188
45717
  *
45189
45718
  * @param {BrowserWindow} win - the requesting window
45190
- * @returns {{ connections: Array<{ providerName, status, messageCount, connectedAt, lastMessageAt }> }}
45719
+ * @returns {{ connections: Array<{ providerName, status, messageCount, connectedAt, lastMessageAt, retryCount }> }}
45191
45720
  */
45192
45721
  getAll: (win) => {
45193
45722
  const connections = [];
@@ -45199,6 +45728,7 @@ const webSocketController$1 = {
45199
45728
  connectedAt: conn.connectedAt || null,
45200
45729
  lastMessageAt: conn.lastMessageAt || null,
45201
45730
  error: conn.error || null,
45731
+ retryCount: conn.retryCount || 0,
45202
45732
  });
45203
45733
  }
45204
45734
  return { success: true, connections };