violetics 7.0.12-alpha → 7.0.14-alpha

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.
@@ -5,6 +5,7 @@ export class WebSocketClient extends AbstractSocketClient {
5
5
  constructor() {
6
6
  super(...arguments);
7
7
  this.socket = null;
8
+ this.socketListeners = new Map();
8
9
  this.reconnectAttempts = 0;
9
10
  this.maxReconnectAttempts = this.config.maxReconnectAttempts || 5;
10
11
  this.reconnectDelay = this.config.reconnectDelay || 1000;
@@ -24,10 +25,9 @@ export class WebSocketClient extends AbstractSocketClient {
24
25
  return this.socket?.readyState === WebSocket.CONNECTING;
25
26
  }
26
27
  connect() {
27
- if (this.socket && !this.isClosed) {
28
+ if (this.socket) {
28
29
  return;
29
30
  }
30
- this.reconnecting = false;
31
31
  this.socket = new WebSocket(this.url, {
32
32
  origin: DEFAULT_ORIGIN,
33
33
  headers: this.config.options?.headers,
@@ -38,74 +38,29 @@ export class WebSocketClient extends AbstractSocketClient {
38
38
  this.socket.setMaxListeners(0);
39
39
  const events = ['close', 'error', 'upgrade', 'message', 'open', 'ping', 'pong', 'unexpected-response'];
40
40
  for (const event of events) {
41
- this.socket?.on(event, (...args) => this.emit(event, ...args));
41
+ const handler = (...args) => this.emit(event, ...args);
42
+ this.socketListeners.set(event, handler);
43
+ this.socket?.on(event, handler);
42
44
  }
43
-
44
- // Handle close to attempt reconnect
45
- this.socket.on('close', (code, reason) => {
46
- this.emit('close', code, reason);
47
- this.attemptReconnect(code, reason);
48
- });
49
45
  }
50
-
51
- async attemptReconnect(code, reason) {
52
- // Don't reconnect if intentionally closed or max attempts reached
53
- if (this.reconnecting || code === 1000 || this.reconnectAttempts >= this.maxReconnectAttempts) {
54
- return;
55
- }
56
-
57
- this.reconnecting = true;
58
- const delay = Math.min(
59
- this.reconnectDelay * Math.pow(2, this.reconnectAttempts),
60
- this.maxReconnectDelay
61
- );
62
-
63
- this.emit('reconnecting', {
64
- attempt: this.reconnectAttempts + 1,
65
- maxAttempts: this.maxReconnectAttempts,
66
- delay,
67
- code,
68
- reason: reason?.toString() || 'Connection closed'
69
- });
70
-
71
- await new Promise(resolve => setTimeout(resolve, delay));
72
-
73
- this.reconnectAttempts++;
74
- this.reconnecting = false;
75
-
76
- // Reconnect
77
- this.connect();
78
- }
79
-
80
46
  async close() {
81
- this.reconnecting = false;
82
- this.reconnectAttempts = 0;
83
-
84
47
  if (!this.socket) {
85
48
  return;
86
49
  }
50
+ for (const [event, handler] of this.socketListeners) {
51
+ this.socket.removeListener(event, handler);
52
+ }
53
+ this.socketListeners.clear();
87
54
  const closePromise = new Promise(resolve => {
88
55
  this.socket?.once('close', resolve);
89
56
  });
90
- this.socket.close(1000, 'Intentional close');
57
+ this.socket.close();
91
58
  await closePromise;
92
59
  this.socket = null;
93
60
  }
94
-
95
61
  send(str, cb) {
96
62
  this.socket?.send(str, cb);
97
63
  return Boolean(this.socket);
98
64
  }
99
-
100
- get connectionStats() {
101
- return {
102
- isOpen: this.isOpen,
103
- isClosed: this.isClosed,
104
- isConnecting: this.isConnecting,
105
- reconnectAttempts: this.reconnectAttempts,
106
- maxReconnectAttempts: this.maxReconnectAttempts,
107
- isReconnecting: this.reconnecting
108
- };
109
- }
110
65
  }
111
66
  //# sourceMappingURL=websocket.js.map
@@ -1,4 +1,4 @@
1
- import NodeCache from '@cacheable/node-cache';
1
+ import { LRUCache } from 'lru-cache';
2
2
  import { Boom } from '@hapi/boom';
3
3
  import { proto } from '../../WAProto/index.js';
4
4
  import { DEFAULT_CACHE_TTLS, PROCESSABLE_HISTORY_TYPES } from '../Defaults/index.js';
@@ -29,9 +29,10 @@ export const makeChatsSocket = (config) => {
29
29
  // Timeout for AwaitingInitialSync state
30
30
  let awaitingSyncTimeout;
31
31
  const placeholderResendCache = config.placeholderResendCache ||
32
- new NodeCache({
33
- stdTTL: DEFAULT_CACHE_TTLS.MSG_RETRY, // 1 hour
34
- useClones: false
32
+ new LRUCache({
33
+ max: 5000,
34
+ ttl: DEFAULT_CACHE_TTLS.MSG_RETRY * 1000, // 1 hour
35
+ allowStale: false
35
36
  });
36
37
  if (!config.placeholderResendCache) {
37
38
  config.placeholderResendCache = placeholderResendCache;
@@ -1,4 +1,4 @@
1
- import NodeCache from '@cacheable/node-cache';
1
+ import { LRUCache } from 'lru-cache';
2
2
  import { Boom } from '@hapi/boom';
3
3
  import { randomBytes } from 'crypto';
4
4
  import Long from 'long';
@@ -18,31 +18,28 @@ export const makeMessagesRecvSocket = (config) => {
18
18
  /** this mutex ensures that each retryRequest will wait for the previous one to finish */
19
19
  const retryMutex = makeMutex();
20
20
  const msgRetryCache = config.msgRetryCounterCache ||
21
- new NodeCache({
22
- stdTTL: DEFAULT_CACHE_TTLS.MSG_RETRY, // 1 hour
23
- useClones: false,
24
- checkperiod: 300, // Check every 5 min
25
- maxKeys: 10000 // Limit to prevent memory bloat
21
+ new LRUCache({
22
+ max: 10000,
23
+ ttl: DEFAULT_CACHE_TTLS.MSG_RETRY * 1000,
24
+ allowStale: false
26
25
  });
27
26
  const callOfferCache = config.callOfferCache ||
28
- new NodeCache({
29
- stdTTL: DEFAULT_CACHE_TTLS.CALL_OFFER, // 5 mins
30
- useClones: false,
31
- checkperiod: 60,
32
- maxKeys: 5000
27
+ new LRUCache({
28
+ max: 5000,
29
+ ttl: DEFAULT_CACHE_TTLS.CALL_OFFER * 1000,
30
+ allowStale: false
33
31
  });
34
32
  const placeholderResendCache = config.placeholderResendCache ||
35
- new NodeCache({
36
- stdTTL: DEFAULT_CACHE_TTLS.MSG_RETRY, // 1 hour
37
- useClones: false,
38
- checkperiod: 300,
39
- maxKeys: 5000
33
+ new LRUCache({
34
+ max: 5000,
35
+ ttl: DEFAULT_CACHE_TTLS.MSG_RETRY * 1000,
36
+ allowStale: false
40
37
  });
41
38
  // Debounce identity-change session refreshes per JID to avoid bursts
42
- const identityAssertDebounce = new NodeCache({
43
- stdTTL: 10,
44
- useClones: false,
45
- maxKeys: 1000
39
+ const identityAssertDebounce = new LRUCache({
40
+ max: 1000,
41
+ ttl: 10000,
42
+ allowStale: false
46
43
  });
47
44
  let sendActiveReceipts = false;
48
45
  const fetchMessageHistory = async (count, oldestMsgKey, oldestMsgTimestamp) => {
@@ -1677,12 +1674,16 @@ export const makeMessagesRecvSocket = (config) => {
1677
1674
  sendActiveReceipts = isOnline;
1678
1675
  logger.trace(`sendActiveReceipts set to "${sendActiveReceipts}"`);
1679
1676
  }
1680
- // Clean up caches on disconnect
1677
+ // Clean up caches on disconnect - LRU cache will expire naturally, but we can reset
1681
1678
  if (connection === 'close' || connection === 'loggedOut') {
1682
- msgRetryCache.flush();
1683
- placeholderResendCache.flush();
1684
- identityAssertDebounce.flush();
1685
- logger.debug('flushed message caches on disconnect');
1679
+ try {
1680
+ // LRU cache doesn't have clear(), but TTL handles expiration
1681
+ // We can reset by creating new cache instances if needed
1682
+ // For now just log that disconnection happened
1683
+ logger.debug('connection closed, caches will expire via TTL');
1684
+ } catch (e) {
1685
+ logger.warn({ err: e }, 'error in cache cleanup');
1686
+ }
1686
1687
  }
1687
1688
  });
1688
1689
  return {
@@ -1,4 +1,4 @@
1
- import NodeCache from '@cacheable/node-cache';
1
+ import { LRUCache } from 'lru-cache';
2
2
  import { Boom } from '@hapi/boom';
3
3
  import { randomBytes } from 'crypto';
4
4
  import { proto } from '../../WAProto/index.js';
@@ -20,13 +20,15 @@ export const makeMessagesSocket = (config) => {
20
20
  const sock = makeNewsletterSocket(config);
21
21
  const { ev, authState, messageMutex, signalRepository, upsertMessage, query, fetchPrivacySettings, sendNode, groupMetadata, groupToggleEphemeral } = sock;
22
22
  const userDevicesCache = config.userDevicesCache ||
23
- new NodeCache({
24
- stdTTL: DEFAULT_CACHE_TTLS.USER_DEVICES, // 5 minutes
25
- useClones: false
23
+ new LRUCache({
24
+ max: 5000,
25
+ ttl: DEFAULT_CACHE_TTLS.USER_DEVICES * 1000, // 5 minutes
26
+ allowStale: false
26
27
  });
27
- const peerSessionsCache = new NodeCache({
28
- stdTTL: DEFAULT_CACHE_TTLS.USER_DEVICES,
29
- useClones: false
28
+ const peerSessionsCache = new LRUCache({
29
+ max: 5000,
30
+ ttl: DEFAULT_CACHE_TTLS.USER_DEVICES * 1000,
31
+ allowStale: false
30
32
  });
31
33
  // Initialize message retry manager if enabled
32
34
  const messageRetryManager = enableRecentMessageCache ? new MessageRetryManager(logger, maxMsgRetryCount) : null;
@@ -280,6 +280,8 @@ export const makeSocket = (config) => {
280
280
  let keepAliveReq;
281
281
  let qrTimer;
282
282
  let closed = false;
283
+ let consecutivePingFailures = 0;
284
+ const MAX_PING_FAILURES = 3;
283
285
  /** log & process any unexpected errors */
284
286
  const onUnexpectedError = (err, msg) => {
285
287
  logger.error({ err }, `unexpected error in '${msg}'`);
@@ -490,44 +492,32 @@ export const makeSocket = (config) => {
490
492
  return;
491
493
  }
492
494
  closed = true;
493
-
494
- const disconnectReason = error?.output?.statusCode;
495
- const isLogout = disconnectReason === DisconnectReason.loggedOut;
496
-
497
- logger.info({
498
- trace: error?.stack,
499
- reason: disconnectReason,
500
- isLogout
501
- }, error ? 'connection errored' : 'connection closed');
502
-
503
- clearInterval(keepAliveReq);
495
+ logger.info({ trace: error?.stack }, error ? 'connection errored' : 'connection closed');
496
+ clearTimeout(keepAliveReq);
504
497
  clearTimeout(qrTimer);
505
-
506
- // Clean up event listeners but preserve reconnect listener
507
- ws.removeAllListeners('close');
508
- ws.removeAllListeners('open');
509
- ws.removeAllListeners('message');
510
- ws.removeAllListeners('reconnecting');
511
-
512
- // Only close websocket if not reconnecting
513
- if (!ws.reconnecting && !ws.isClosed && !ws.isClosing) {
498
+ consecutivePingFailures = 0;
499
+ ws.removeAllListeners();
500
+ if (ev.isBuffering()) {
501
+ ev.flush();
502
+ }
503
+ if (!ws.isClosed && !ws.isClosing) {
514
504
  try {
515
- await ws.close();
505
+ await Promise.race([
506
+ ws.close(),
507
+ new Promise(resolve => setTimeout(resolve, 5000))
508
+ ]);
516
509
  }
517
510
  catch { }
518
511
  }
519
-
520
- // Emit final connection update
521
512
  ev.emit('connection.update', {
522
- connection: isLogout ? 'loggedOut' : 'close',
513
+ connection: 'close',
523
514
  lastDisconnect: {
524
515
  error,
525
516
  date: new Date()
526
- },
527
- isOnline: false
517
+ }
528
518
  });
529
-
530
- ev.removeAllListeners('connection.update');
519
+ noise.destroy();
520
+ ev.removeAllListeners();
531
521
  };
532
522
  const waitForSocketOpen = async () => {
533
523
  if (ws.isOpen) {
@@ -550,42 +540,57 @@ export const makeSocket = (config) => {
550
540
  ws.off('error', onClose);
551
541
  });
552
542
  };
553
- const startKeepAliveRequest = () => (keepAliveReq = setInterval(() => {
554
- if (!lastDateRecv) {
555
- lastDateRecv = new Date();
556
- }
557
- const diff = Date.now() - lastDateRecv.getTime();
558
- /*
559
- check if it's been a suspicious amount of time since the server responded with our last seen
560
- it could be that the network is down
561
- */
562
- if (diff > keepAliveIntervalMs + 60000) {
563
- // Don't trigger disconnect if we're in reconnecting state
564
- if (ws.reconnecting) {
565
- logger.debug('skipping keepalive disconnect - reconnecting');
566
- return;
567
- }
568
- void end(new Boom('Connection was lost', { statusCode: DisconnectReason.connectionLost }));
569
- }
570
- else if (ws.isOpen) {
571
- // if its all good, send a keep alive request
572
- query({
573
- tag: 'iq',
574
- attrs: {
575
- id: generateMessageTag(),
576
- to: S_WHATSAPP_NET,
577
- type: 'get',
578
- xmlns: 'w:p'
579
- },
580
- content: [{ tag: 'ping', attrs: {} }]
581
- }).catch(err => {
582
- logger.error({ trace: err.stack }, 'error in sending keep alive');
583
- });
584
- }
585
- else {
586
- logger.warn('keep alive called when WS not open');
587
- }
588
- }, keepAliveIntervalMs));
543
+ const startKeepAliveRequest = () => {
544
+ const scheduleNextPing = () => {
545
+ keepAliveReq = setTimeout(async () => {
546
+ if (closed) {
547
+ return;
548
+ }
549
+ if (!lastDateRecv) {
550
+ lastDateRecv = new Date();
551
+ }
552
+ const diff = Date.now() - lastDateRecv.getTime();
553
+ if (diff > keepAliveIntervalMs * 2 + 5000) {
554
+ logger.warn({ diff, keepAliveIntervalMs }, 'connection silent for too long');
555
+ void end(new Boom('Connection was lost', { statusCode: DisconnectReason.connectionLost }));
556
+ return;
557
+ }
558
+ if (ws.isOpen) {
559
+ try {
560
+ await query({
561
+ tag: 'iq',
562
+ attrs: {
563
+ id: generateMessageTag(),
564
+ to: S_WHATSAPP_NET,
565
+ type: 'get',
566
+ xmlns: 'w:p'
567
+ },
568
+ content: [{ tag: 'ping', attrs: {} }]
569
+ });
570
+ consecutivePingFailures = 0;
571
+ }
572
+ catch (err) {
573
+ consecutivePingFailures++;
574
+ logger.error({ trace: err.stack, consecutivePingFailures, maxFailures: MAX_PING_FAILURES }, 'error in sending keep alive');
575
+ if (consecutivePingFailures >= MAX_PING_FAILURES) {
576
+ logger.warn('max ping failures reached, terminating connection');
577
+ void end(new Boom('Connection was lost (ping failures)', {
578
+ statusCode: DisconnectReason.connectionLost
579
+ }));
580
+ return;
581
+ }
582
+ }
583
+ }
584
+ else {
585
+ logger.warn('keep alive called when WS not open');
586
+ }
587
+ if (!closed) {
588
+ scheduleNextPing();
589
+ }
590
+ }, keepAliveIntervalMs);
591
+ };
592
+ scheduleNextPing();
593
+ };
589
594
  /** i have no idea why this exists. pls enlighten me */
590
595
  const sendPassiveIq = (tag) => query({
591
596
  tag: 'iq',
@@ -1013,7 +1018,13 @@ export const makeSocket = (config) => {
1013
1018
  waitForConnectionUpdate: bindWaitForConnectionUpdate(ev),
1014
1019
  sendWAMBuffer,
1015
1020
  executeUSyncQuery,
1016
- onWhatsApp
1021
+ onWhatsApp,
1022
+ get connectionHealth() {
1023
+ return {
1024
+ lastMessageReceived: lastDateRecv,
1025
+ consecutivePingFailures
1026
+ };
1027
+ }
1017
1028
  };
1018
1029
  };
1019
1030
  /**
@@ -23,5 +23,8 @@ export var DisconnectReason;
23
23
  DisconnectReason[DisconnectReason["multideviceMismatch"] = 411] = "multideviceMismatch";
24
24
  DisconnectReason[DisconnectReason["forbidden"] = 403] = "forbidden";
25
25
  DisconnectReason[DisconnectReason["unavailableService"] = 503] = "unavailableService";
26
+ DisconnectReason[DisconnectReason["streamReplaced"] = 405] = "streamReplaced";
27
+ DisconnectReason[DisconnectReason["conflict"] = 409] = "conflict";
28
+ DisconnectReason[DisconnectReason["preconditionFailed"] = 412] = "preconditionFailed";
26
29
  })(DisconnectReason || (DisconnectReason = {}));
27
30
  //# sourceMappingURL=index.js.map
@@ -1,4 +1,4 @@
1
- import NodeCache from '@cacheable/node-cache';
1
+ import { LRUCache } from 'lru-cache';
2
2
  import { AsyncLocalStorage } from 'async_hooks';
3
3
  import { Mutex } from 'async-mutex';
4
4
  import { randomBytes } from 'crypto';
@@ -15,10 +15,10 @@ import { PreKeyManager } from './pre-key-manager.js';
15
15
  */
16
16
  export function makeCacheableSignalKeyStore(store, logger, _cache) {
17
17
  const cache = _cache ||
18
- new NodeCache({
19
- stdTTL: DEFAULT_CACHE_TTLS.SIGNAL_STORE, // 5 minutes
20
- useClones: false,
21
- deleteOnExpire: true
18
+ new LRUCache({
19
+ max: 5000,
20
+ ttl: DEFAULT_CACHE_TTLS.SIGNAL_STORE * 1000, // 5 minutes
21
+ allowStale: false
22
22
  });
23
23
  // Mutex for protecting cache operations
24
24
  const cacheMutex = new Mutex();
@@ -30,9 +30,8 @@ export const makeEventBuffer = (logger, config = {}) => {
30
30
  let bufferTimeout = null;
31
31
  let flushPendingTimeout = null;
32
32
  let bufferCount = 0;
33
-
34
- // Make limits configurable
35
- const MAX_HISTORY_CACHE_SIZE = config.maxHistoryCacheSize || 15000;
33
+ let activeBufferedTimeouts = new Set();
34
+ const MAX_HISTORY_CACHE_SIZE = config.maxHistoryCacheSize || 10000;
36
35
  const BUFFER_TIMEOUT_MS = config.bufferTimeoutMs || 30000;
37
36
  const MAX_BUFFER_COUNT = config.maxBufferCount || 10000;
38
37
  // take the generic event and fire it as a baileys event
@@ -152,13 +151,14 @@ export const makeEventBuffer = (logger, config = {}) => {
152
151
  buffer();
153
152
  try {
154
153
  const result = await work(...args);
155
- // If this is the only buffer, flush after a small delay
156
154
  if (bufferCount === 1) {
157
- setTimeout(() => {
155
+ const t = setTimeout(() => {
156
+ activeBufferedTimeouts.delete(t);
158
157
  if (isBuffering && bufferCount === 1) {
159
158
  flush();
160
159
  }
161
- }, 100); // Small delay to allow nested buffers
160
+ }, 100);
161
+ activeBufferedTimeouts.add(t);
162
162
  }
163
163
  return result;
164
164
  }
@@ -168,7 +168,6 @@ export const makeEventBuffer = (logger, config = {}) => {
168
168
  finally {
169
169
  bufferCount = Math.max(0, bufferCount - 1);
170
170
  if (bufferCount === 0) {
171
- // Only schedule ONE timeout, not 10,000
172
171
  if (!flushPendingTimeout) {
173
172
  flushPendingTimeout = setTimeout(flush, 100);
174
173
  }
@@ -178,7 +177,25 @@ export const makeEventBuffer = (logger, config = {}) => {
178
177
  },
179
178
  on: (...args) => ev.on(...args),
180
179
  off: (...args) => ev.off(...args),
181
- removeAllListeners: (...args) => ev.removeAllListeners(...args)
180
+ removeAllListeners: (...args) => {
181
+ if (bufferTimeout) {
182
+ clearTimeout(bufferTimeout);
183
+ bufferTimeout = null;
184
+ }
185
+ if (flushPendingTimeout) {
186
+ clearTimeout(flushPendingTimeout);
187
+ flushPendingTimeout = null;
188
+ }
189
+ for (const t of activeBufferedTimeouts) {
190
+ clearTimeout(t);
191
+ }
192
+ activeBufferedTimeouts.clear();
193
+ isBuffering = false;
194
+ bufferCount = 0;
195
+ historyCache.clear();
196
+ data = makeBufferData();
197
+ return ev.removeAllListeners(...args);
198
+ }
182
199
  };
183
200
  };
184
201
  const makeBufferData = () => {
@@ -281,7 +281,10 @@ export const getStatusFromReceiptType = (type) => {
281
281
  return status;
282
282
  };
283
283
  const CODE_MAP = {
284
- conflict: DisconnectReason.connectionReplaced
284
+ conflict: DisconnectReason.connectionReplaced,
285
+ 'stream-replaced': DisconnectReason.streamReplaced,
286
+ conflict: DisconnectReason.conflict,
287
+ 'precondition-failed': DisconnectReason.preconditionFailed
285
288
  };
286
289
  /**
287
290
  * Stream errors generally provide a reason, map that to a baileys DisconnectReason
@@ -1,4 +1,4 @@
1
- import NodeCache from '@cacheable/node-cache';
1
+ import { LRUCache } from 'lru-cache';
2
2
  import { areJidsSameUser, getBinaryNodeChild, jidDecode } from '../WABinary/index.js';
3
3
  import { isStringNullOrEmpty } from './generics.js';
4
4
  export async function handleIdentityChange(node, ctx) {
@@ -5,6 +5,7 @@ import { decodeBinaryNode } from '../WABinary/index.js';
5
5
  import { aesDecryptGCM, aesEncryptGCM, Curve, hkdf, sha256 } from './crypto.js';
6
6
  const IV_LENGTH = 12;
7
7
  const EMPTY_BUFFER = Buffer.alloc(0);
8
+ const MAX_BUFFER_SIZE = 10 * 1024 * 1024; // 10MB max buffer cap
8
9
  const generateIV = (counter) => {
9
10
  const iv = new ArrayBuffer(IV_LENGTH);
10
11
  new DataView(iv).setUint32(8, counter);
@@ -194,7 +195,18 @@ export const makeNoiseHandler = ({ keyPair: { private: privateKey, public: publi
194
195
  else {
195
196
  inBytes = Buffer.concat([inBytes, newData]);
196
197
  }
198
+ if (inBytes.length > MAX_BUFFER_SIZE) {
199
+ logger.error({ bufferSize: inBytes.length }, 'noise handler buffer exceeded max size, clearing');
200
+ inBytes = Buffer.alloc(0);
201
+ return;
202
+ }
197
203
  await processData(onFrame);
204
+ },
205
+ destroy: () => {
206
+ inBytes = Buffer.alloc(0);
207
+ transport = null;
208
+ pendingOnFrame = null;
209
+ isWaitingForTransport = false;
198
210
  }
199
211
  };
200
212
  };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "violetics",
3
3
  "type": "module",
4
- "version": "7.0.12-alpha",
4
+ "version": "7.0.14-alpha",
5
5
  "description": "A WebSockets library for interacting with WhatsApp Web",
6
6
  "keywords": [
7
7
  "whatsapp",