violetics 7.0.3-alpha → 7.0.5-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.
@@ -53,7 +53,7 @@ export const DEFAULT_CONNECTION_CONFIG = {
53
53
  emitOwnEvents: true,
54
54
  defaultQueryTimeoutMs: 60000,
55
55
  customUploadHosts: [],
56
- retryRequestDelayMs: 250,
56
+ retryRequestDelayMs: 0,
57
57
  maxMsgRetryCount: 3,
58
58
  fireInitQueries: true,
59
59
  auth: undefined,
@@ -5,6 +5,7 @@ export class WebSocketClient extends AbstractSocketClient {
5
5
  constructor() {
6
6
  super(...arguments);
7
7
  this.socket = null;
8
+ this._eventForwarders = [];
8
9
  }
9
10
  get isOpen() {
10
11
  return this.socket?.readyState === WebSocket.OPEN;
@@ -31,14 +32,22 @@ export class WebSocketClient extends AbstractSocketClient {
31
32
  });
32
33
  this.socket.setMaxListeners(0);
33
34
  const events = ['close', 'error', 'upgrade', 'message', 'open', 'ping', 'pong', 'unexpected-response'];
35
+ this._eventForwarders = [];
34
36
  for (const event of events) {
35
- this.socket?.on(event, (...args) => this.emit(event, ...args));
37
+ const handler = (...args) => this.emit(event, ...args);
38
+ this.socket?.on(event, handler);
39
+ this._eventForwarders.push({ event, handler });
36
40
  }
37
41
  }
38
42
  async close() {
39
43
  if (!this.socket) {
40
44
  return;
41
45
  }
46
+ // Remove all forwarding listeners to prevent memory leaks
47
+ for (const { event, handler } of this._eventForwarders) {
48
+ this.socket?.off(event, handler);
49
+ }
50
+ this._eventForwarders = [];
42
51
  const closePromise = new Promise(resolve => {
43
52
  this.socket?.once('close', resolve);
44
53
  });
@@ -1,4 +1,5 @@
1
1
  import NodeCache from '@cacheable/node-cache';
2
+ import { LRUCache } from 'lru-cache';
2
3
  import { Boom } from '@hapi/boom';
3
4
  import { proto } from '../../WAProto/index.js';
4
5
  import { DEFAULT_CACHE_TTLS, PROCESSABLE_HISTORY_TYPES } from '../Defaults/index.js';
@@ -12,12 +13,12 @@ import { getBinaryNodeChild, getBinaryNodeChildren, isLidUser, isPnUser, jidDeco
12
13
  import { USyncQuery, USyncUser } from '../WAUSync/index.js';
13
14
  import { makeSocket } from './socket.js';
14
15
  const MAX_SYNC_ATTEMPTS = 2;
15
- // Lia@Note 08-02-26 --- I know it's not efficient for RSS ಥ⁠‿⁠ಥ
16
- const USER_ID_CACHE = new Map();
17
16
  export const makeChatsSocket = (config) => {
18
17
  const { logger, markOnlineOnConnect, fireInitQueries, appStateMacVerification, shouldIgnoreJid, shouldSyncHistoryMessage, getMessage } = config;
19
18
  const sock = makeSocket(config);
20
19
  const { ev, ws, authState, generateMessageTag, sendNode, query, signalRepository, onUnexpectedError, sendUnifiedSession } = sock;
20
+ // Scoped per-connection with LRU to prevent unbounded memory growth
21
+ const USER_ID_CACHE = new LRUCache({ max: 5000, ttl: 30 * 60 * 1000 });
21
22
  let privacySettings;
22
23
  let syncState = SyncState.Connecting;
23
24
  /** this mutex ensures that messages are processed in order */
@@ -7,6 +7,8 @@ import { DEFAULT_CACHE_TTLS, KEY_BUNDLE_TYPE, MIN_PREKEY_COUNT, PLACEHOLDER_MAX_
7
7
  import { WAMessageStatus, WAMessageStubType } from '../Types/index.js';
8
8
  import { aesDecryptCTR, aesEncryptGCM, cleanMessage, Curve, decodeMediaRetryNode, decodeMessageNode, decryptMessageNode, delay, derivePairingCodeKey, encodeBigEndian, encodeSignedDeviceIdentity, extractAddressingContext, getCallStatusFromNode, getHistoryMsg, getNextPreKeys, getStatusFromReceiptType, handleIdentityChange, hkdf, MISSING_KEYS_ERROR_TEXT, NACK_REASONS, NO_MESSAGE_FOUND_ERROR_TEXT, toNumber, unixTimestampSeconds, xmppPreKey, xmppSignedPreKey } from '../Utils/index.js';
9
9
  import { makeMutex } from '../Utils/make-mutex.js';
10
+ import { makeOfflineNodeProcessor } from '../Utils/offline-node-processor.js';
11
+ import { buildAckStanza } from '../Utils/stanza-ack.js';
10
12
  import { areJidsSameUser, binaryNodeToString, getAllBinaryNodeChildren, getBinaryNodeChild, getBinaryNodeChildBuffer, getBinaryNodeChildren, getBinaryNodeChildString, isJidGroup, isJidNewsletter, isJidStatusBroadcast, isLidUser, isPnUser, jidDecode, jidNormalizedUser, S_WHATSAPP_NET } from '../WABinary/index.js';
11
13
  import { extractGroupMetadata } from './groups.js';
12
14
  import { makeMessagesSocket } from './messages-send.js';
@@ -63,7 +65,7 @@ export const makeMessagesRecvSocket = (config) => {
63
65
  // metadata (LID details, timestamps, etc.) that the phone may omit
64
66
  await placeholderResendCache.set(messageKey?.id, msgData || true);
65
67
  }
66
- await delay(2000);
68
+ await delay(500);
67
69
  if (!(await placeholderResendCache.get(messageKey?.id))) {
68
70
  logger.debug({ messageKey }, 'message received while resend requested');
69
71
  return 'RESOLVED';
@@ -217,32 +219,9 @@ export const makeMessagesRecvSocket = (config) => {
217
219
  break;
218
220
  }
219
221
  };
220
- const sendMessageAck = async ({ tag, attrs, content }, errorCode) => {
221
- const stanza = {
222
- tag: 'ack',
223
- attrs: {
224
- id: attrs.id,
225
- to: attrs.from,
226
- class: tag
227
- }
228
- };
229
- if (!!errorCode) {
230
- stanza.attrs.error = errorCode.toString();
231
- }
232
- if (!!attrs.participant) {
233
- stanza.attrs.participant = attrs.participant;
234
- }
235
- if (!!attrs.recipient) {
236
- stanza.attrs.recipient = attrs.recipient;
237
- }
238
- if (!!attrs.type &&
239
- (tag !== 'message' || getBinaryNodeChild({ tag, attrs, content }, 'unavailable') || errorCode !== 0)) {
240
- stanza.attrs.type = attrs.type;
241
- }
242
- if (tag === 'message' && getBinaryNodeChild({ tag, attrs, content }, 'unavailable')) {
243
- stanza.attrs.from = authState.creds.me.id;
244
- }
245
- logger.debug({ recv: { tag, attrs }, sent: stanza.attrs }, 'sent ack');
222
+ const sendMessageAck = async (node, errorCode) => {
223
+ const stanza = buildAckStanza(node, errorCode, authState.creds.me.id);
224
+ logger.debug({ recv: { tag: node.tag, attrs: node.attrs }, sent: stanza.attrs }, 'sent ack');
246
225
  await sendNode(stanza);
247
226
  };
248
227
  const rejectCall = async (callId, callFrom) => {
@@ -995,7 +974,7 @@ export const makeMessagesRecvSocket = (config) => {
995
974
  ]);
996
975
  }
997
976
  finally {
998
- await sendMessageAck(node);
977
+ await sendMessageAck(node).catch(ackErr => logger.error({ ackErr }, 'failed to ack receipt'));
999
978
  }
1000
979
  };
1001
980
  const handleNotification = async (node) => {
@@ -1030,7 +1009,7 @@ export const makeMessagesRecvSocket = (config) => {
1030
1009
  ]);
1031
1010
  }
1032
1011
  finally {
1033
- await sendMessageAck(node);
1012
+ await sendMessageAck(node).catch(ackErr => logger.error({ ackErr }, 'failed to ack notification'));
1034
1013
  }
1035
1014
  };
1036
1015
  const handleMessage = async (node) => {
@@ -1046,36 +1025,34 @@ export const makeMessagesRecvSocket = (config) => {
1046
1025
  await sendMessageAck(node, NACK_REASONS.MissingMessageSecret);
1047
1026
  return;
1048
1027
  }
1049
- const { fullMessage: msg, category, author, decrypt } = decryptMessageNode(node, authState.creds.me.id, authState.creds.me.lid || '', signalRepository, logger);
1050
- const alt = msg.key.participantAlt || msg.key.remoteJidAlt;
1051
- // store new mappings we didn't have before
1052
- if (!!alt) {
1053
- const altServer = jidDecode(alt)?.server;
1054
- const primaryJid = msg.key.participant || msg.key.remoteJid;
1055
- if (altServer === 'lid') {
1056
- if (!(await signalRepository.lidMapping.getPNForLID(alt))) {
1057
- await signalRepository.lidMapping.storeLIDPNMappings([{ lid: alt, pn: primaryJid }]);
1058
- await signalRepository.migrateSession(primaryJid, alt);
1028
+ let acked = false;
1029
+ try {
1030
+ const { fullMessage: msg, category, author, decrypt } = decryptMessageNode(node, authState.creds.me.id, authState.creds.me.lid || '', signalRepository, logger);
1031
+ const alt = msg.key.participantAlt || msg.key.remoteJidAlt;
1032
+ // store new mappings we didn't have before
1033
+ if (!!alt) {
1034
+ const altServer = jidDecode(alt)?.server;
1035
+ const primaryJid = msg.key.participant || msg.key.remoteJid;
1036
+ if (altServer === 'lid') {
1037
+ if (!(await signalRepository.lidMapping.getPNForLID(alt))) {
1038
+ await signalRepository.lidMapping.storeLIDPNMappings([{ lid: alt, pn: primaryJid }]);
1039
+ await signalRepository.migrateSession(primaryJid, alt);
1040
+ }
1041
+ }
1042
+ else {
1043
+ await signalRepository.lidMapping.storeLIDPNMappings([{ lid: primaryJid, pn: alt }]);
1044
+ await signalRepository.migrateSession(alt, primaryJid);
1059
1045
  }
1060
1046
  }
1061
- else {
1062
- await signalRepository.lidMapping.storeLIDPNMappings([{ lid: primaryJid, pn: alt }]);
1063
- await signalRepository.migrateSession(alt, primaryJid);
1064
- }
1065
- }
1066
- if (msg.key?.remoteJid && msg.key?.id && messageRetryManager) {
1067
- messageRetryManager.addRecentMessage(msg.key.remoteJid, msg.key.id, msg.message);
1068
- logger.debug({
1069
- jid: msg.key.remoteJid,
1070
- id: msg.key.id
1071
- }, 'Added message to recent cache for retry receipts');
1072
- }
1073
- try {
1074
1047
  await messageMutex.mutex(async () => {
1075
1048
  await decrypt();
1049
+ if (msg.key?.remoteJid && msg.key?.id && msg.message && messageRetryManager) {
1050
+ messageRetryManager.addRecentMessage(msg.key.remoteJid, msg.key.id, msg.message);
1051
+ }
1076
1052
  // message failed to decrypt
1077
1053
  if (msg.messageStubType === proto.WebMessageInfo.StubType.CIPHERTEXT && msg.category !== 'peer') {
1078
1054
  if (msg?.messageStubParameters?.[0] === MISSING_KEYS_ERROR_TEXT) {
1055
+ acked = true;
1079
1056
  return sendMessageAck(node, NACK_REASONS.ParsingError);
1080
1057
  }
1081
1058
  if (msg.messageStubParameters?.[0] === NO_MESSAGE_FOUND_ERROR_TEXT) {
@@ -1087,11 +1064,13 @@ export const makeMessagesRecvSocket = (config) => {
1087
1064
  unavailableType === 'hosted_unavailable_fanout' ||
1088
1065
  unavailableType === 'view_once_unavailable_fanout') {
1089
1066
  logger.debug({ msgId: msg.key.id, unavailableType }, 'skipping placeholder resend for excluded unavailable type');
1067
+ acked = true;
1090
1068
  return sendMessageAck(node);
1091
1069
  }
1092
1070
  const messageAge = unixTimestampSeconds() - toNumber(msg.messageTimestamp);
1093
1071
  if (messageAge > PLACEHOLDER_MAX_AGE_SECONDS) {
1094
1072
  logger.debug({ msgId: msg.key.id, messageAge }, 'skipping placeholder resend for old message');
1073
+ acked = true;
1095
1074
  return sendMessageAck(node);
1096
1075
  }
1097
1076
  // Request the real content from the phone via placeholder resend PDO.
@@ -1115,19 +1094,20 @@ export const makeMessagesRecvSocket = (config) => {
1115
1094
  };
1116
1095
  requestPlaceholderResend(cleanKey, msgData)
1117
1096
  .then(requestId => {
1118
- if (requestId && requestId !== 'RESOLVED') {
1119
- logger.debug({ msgId: msg.key.id, requestId }, 'requested placeholder resend for unavailable message');
1120
- ev.emit('messages.update', [
1121
- {
1122
- key: msg.key,
1123
- update: { messageStubParameters: [NO_MESSAGE_FOUND_ERROR_TEXT, requestId] }
1124
- }
1125
- ]);
1126
- }
1127
- })
1097
+ if (requestId && requestId !== 'RESOLVED') {
1098
+ logger.debug({ msgId: msg.key.id, requestId }, 'requested placeholder resend for unavailable message');
1099
+ ev.emit('messages.update', [
1100
+ {
1101
+ key: msg.key,
1102
+ update: { messageStubParameters: [NO_MESSAGE_FOUND_ERROR_TEXT, requestId] }
1103
+ }
1104
+ ]);
1105
+ }
1106
+ })
1128
1107
  .catch(err => {
1129
- logger.warn({ err, msgId: msg.key.id }, 'failed to request placeholder resend for unavailable message');
1130
- });
1108
+ logger.warn({ err, msgId: msg.key.id }, 'failed to request placeholder resend for unavailable message');
1109
+ });
1110
+ acked = true;
1131
1111
  await sendMessageAck(node);
1132
1112
  // Don't return — fall through to upsertMessage so the stub is emitted
1133
1113
  }
@@ -1137,6 +1117,7 @@ export const makeMessagesRecvSocket = (config) => {
1137
1117
  const messageAge = unixTimestampSeconds() - toNumber(msg.messageTimestamp);
1138
1118
  if (messageAge > STATUS_EXPIRY_SECONDS) {
1139
1119
  logger.debug({ msgId: msg.key.id, messageAge, remoteJid: msg.key.remoteJid }, 'skipping retry for expired status message');
1120
+ acked = true;
1140
1121
  return sendMessageAck(node);
1141
1122
  }
1142
1123
  }
@@ -1157,7 +1138,7 @@ export const makeMessagesRecvSocket = (config) => {
1157
1138
  logger.debug('Uploading pre-keys for error recovery');
1158
1139
  await uploadPreKeys(5);
1159
1140
  logger.debug('Waiting for server to process new pre-keys');
1160
- await delay(1000);
1141
+ await delay(200);
1161
1142
  }
1162
1143
  catch (uploadErr) {
1163
1144
  logger.error({ uploadErr }, 'Pre-key upload failed, proceeding with retry anyway');
@@ -1180,6 +1161,7 @@ export const makeMessagesRecvSocket = (config) => {
1180
1161
  logger.error({ retryErr }, 'Failed to send retry after error handling');
1181
1162
  }
1182
1163
  }
1164
+ acked = true;
1183
1165
  await sendMessageAck(node, NACK_REASONS.UnhandledError);
1184
1166
  });
1185
1167
  }
@@ -1208,6 +1190,7 @@ export const makeMessagesRecvSocket = (config) => {
1208
1190
  else if (!sendActiveReceipts) {
1209
1191
  type = 'inactive';
1210
1192
  }
1193
+ acked = true;
1211
1194
  await sendReceipt(msg.key.remoteJid, participant, [msg.key.id], type);
1212
1195
  // send ack for history message
1213
1196
  const isAnyHistoryMsg = getHistoryMsg(msg.message);
@@ -1217,6 +1200,7 @@ export const makeMessagesRecvSocket = (config) => {
1217
1200
  }
1218
1201
  }
1219
1202
  else {
1203
+ acked = true;
1220
1204
  await sendMessageAck(node);
1221
1205
  logger.debug({ key: msg.key }, 'processed newsletter message without receipts');
1222
1206
  }
@@ -1227,45 +1211,55 @@ export const makeMessagesRecvSocket = (config) => {
1227
1211
  }
1228
1212
  catch (error) {
1229
1213
  logger.error({ error, node: binaryNodeToString(node) }, 'error in handling message');
1214
+ if (!acked) {
1215
+ await sendMessageAck(node, NACK_REASONS.UnhandledError).catch(ackErr => logger.error({ ackErr }, 'failed to ack message after error'));
1216
+ }
1230
1217
  }
1231
1218
  };
1232
1219
  const handleCall = async (node) => {
1233
- const { attrs } = node;
1234
- const [infoChild] = getAllBinaryNodeChildren(node);
1235
- const status = getCallStatusFromNode(infoChild);
1236
- if (!infoChild) {
1237
- throw new Boom('Missing call info in call node');
1238
- }
1239
- const callId = infoChild.attrs['call-id'];
1240
- const from = infoChild.attrs.from || infoChild.attrs['call-creator'];
1241
- const call = {
1242
- chatId: attrs.from,
1243
- from,
1244
- callerPn: infoChild.attrs['caller_pn'],
1245
- id: callId,
1246
- date: new Date(+attrs.t * 1000),
1247
- offline: !!attrs.offline,
1248
- status
1249
- };
1250
- if (status === 'offer') {
1251
- call.isVideo = !!getBinaryNodeChild(infoChild, 'video');
1252
- call.isGroup = infoChild.attrs.type === 'group' || !!infoChild.attrs['group-jid'];
1253
- call.groupJid = infoChild.attrs['group-jid'];
1254
- await callOfferCache.set(call.id, call);
1220
+ try {
1221
+ const { attrs } = node;
1222
+ const [infoChild] = getAllBinaryNodeChildren(node);
1223
+ if (!infoChild) {
1224
+ throw new Boom('Missing call info in call node');
1225
+ }
1226
+ const status = getCallStatusFromNode(infoChild);
1227
+ const callId = infoChild.attrs['call-id'];
1228
+ const from = infoChild.attrs.from || infoChild.attrs['call-creator'];
1229
+ const call = {
1230
+ chatId: attrs.from,
1231
+ from,
1232
+ callerPn: infoChild.attrs['caller_pn'],
1233
+ id: callId,
1234
+ date: new Date(+attrs.t * 1000),
1235
+ offline: !!attrs.offline,
1236
+ status
1237
+ };
1238
+ if (status === 'offer') {
1239
+ call.isVideo = !!getBinaryNodeChild(infoChild, 'video');
1240
+ call.isGroup = infoChild.attrs.type === 'group' || !!infoChild.attrs['group-jid'];
1241
+ call.groupJid = infoChild.attrs['group-jid'];
1242
+ await callOfferCache.set(call.id, call);
1243
+ }
1244
+ const existingCall = await callOfferCache.get(call.id);
1245
+ // use existing call info to populate this event
1246
+ if (existingCall) {
1247
+ call.isVideo = existingCall.isVideo;
1248
+ call.isGroup = existingCall.isGroup;
1249
+ call.callerPn = call.callerPn || existingCall.callerPn;
1250
+ }
1251
+ // delete data once call has ended
1252
+ if (status === 'reject' || status === 'accept' || status === 'timeout' || status === 'terminate') {
1253
+ await callOfferCache.del(call.id);
1254
+ }
1255
+ ev.emit('call', [call]);
1255
1256
  }
1256
- const existingCall = await callOfferCache.get(call.id);
1257
- // use existing call info to populate this event
1258
- if (existingCall) {
1259
- call.isVideo = existingCall.isVideo;
1260
- call.isGroup = existingCall.isGroup;
1261
- call.callerPn = call.callerPn || existingCall.callerPn;
1257
+ catch (error) {
1258
+ logger.error({ error, node: binaryNodeToString(node) }, 'error in handling call');
1262
1259
  }
1263
- // delete data once call has ended
1264
- if (status === 'reject' || status === 'accept' || status === 'timeout' || status === 'terminate') {
1265
- await callOfferCache.del(call.id);
1260
+ finally {
1261
+ await sendMessageAck(node).catch(ackErr => logger.error({ ackErr }, 'failed to ack call'));
1266
1262
  }
1267
- ev.emit('call', [call]);
1268
- await sendMessageAck(node);
1269
1263
  };
1270
1264
  const handleBadAck = async ({ attrs }) => {
1271
1265
  const key = { remoteJid: attrs.from, fromMe: true, id: attrs.id };
@@ -1314,58 +1308,26 @@ export const makeMessagesRecvSocket = (config) => {
1314
1308
  /// and adds the task to the existing buffer if we're buffering events
1315
1309
  const processNodeWithBuffer = async (node, identifier, exec) => {
1316
1310
  ev.buffer();
1317
- await execTask();
1318
- ev.flush();
1311
+ try {
1312
+ await execTask();
1313
+ }
1314
+ finally {
1315
+ ev.flush();
1316
+ }
1319
1317
  function execTask() {
1320
1318
  return exec(node, false).catch(err => onUnexpectedError(err, identifier));
1321
1319
  }
1322
1320
  };
1323
- /** Yields control to the event loop to prevent blocking */
1324
- const yieldToEventLoop = () => {
1325
- return new Promise(resolve => setImmediate(resolve));
1326
- };
1327
- const makeOfflineNodeProcessor = () => {
1328
- const nodeProcessorMap = new Map([
1329
- ['message', handleMessage],
1330
- ['call', handleCall],
1331
- ['receipt', handleReceipt],
1332
- ['notification', handleNotification]
1333
- ]);
1334
- const nodes = [];
1335
- let isProcessing = false;
1336
- // Number of nodes to process before yielding to event loop
1337
- const BATCH_SIZE = 10;
1338
- const enqueue = (type, node) => {
1339
- nodes.push({ type, node });
1340
- if (isProcessing) {
1341
- return;
1342
- }
1343
- isProcessing = true;
1344
- const promise = async () => {
1345
- let processedInBatch = 0;
1346
- while (nodes.length && ws.isOpen) {
1347
- const { type, node } = nodes.shift();
1348
- const nodeProcessor = nodeProcessorMap.get(type);
1349
- if (!nodeProcessor) {
1350
- onUnexpectedError(new Error(`unknown offline node type: ${type}`), 'processing offline node');
1351
- continue;
1352
- }
1353
- await nodeProcessor(node);
1354
- processedInBatch++;
1355
- // Yield to event loop after processing a batch
1356
- // This prevents blocking the event loop for too long when there are many offline nodes
1357
- if (processedInBatch >= BATCH_SIZE) {
1358
- processedInBatch = 0;
1359
- await yieldToEventLoop();
1360
- }
1361
- }
1362
- isProcessing = false;
1363
- };
1364
- promise().catch(error => onUnexpectedError(error, 'processing offline nodes'));
1365
- };
1366
- return { enqueue };
1367
- };
1368
- const offlineNodeProcessor = makeOfflineNodeProcessor();
1321
+ const offlineNodeProcessor = makeOfflineNodeProcessor(new Map([
1322
+ ['message', handleMessage],
1323
+ ['call', handleCall],
1324
+ ['receipt', handleReceipt],
1325
+ ['notification', handleNotification]
1326
+ ]), {
1327
+ isWsOpen: () => ws.isOpen,
1328
+ onUnexpectedError,
1329
+ yieldToEventLoop: () => new Promise(resolve => setImmediate(resolve))
1330
+ });
1369
1331
  const processNode = async (type, node, identifier, exec) => {
1370
1332
  const isOffline = !!node.attrs.offline;
1371
1333
  if (isOffline) {
@@ -123,12 +123,9 @@ export const makeSocket = (config) => {
123
123
  node.attrs.id = generateMessageTag();
124
124
  }
125
125
  const msgId = node.attrs.id;
126
- const result = await promiseTimeout(timeoutMs, async (resolve, reject) => {
127
- const result = waitForMessage(msgId, timeoutMs).catch(reject);
128
- sendNode(node)
129
- .then(async () => resolve(await result))
130
- .catch(reject);
131
- });
126
+ const resultPromise = waitForMessage(msgId, timeoutMs);
127
+ await sendNode(node);
128
+ const result = await resultPromise;
132
129
  if (result && 'tag' in result) {
133
130
  assertNodeErrorFree(result);
134
131
  }
@@ -483,15 +480,18 @@ export const makeSocket = (config) => {
483
480
  logger.info({ trace: error?.stack }, error ? 'connection errored' : 'connection closed');
484
481
  clearInterval(keepAliveReq);
485
482
  clearTimeout(qrTimer);
486
- ws.removeAllListeners('close');
487
- ws.removeAllListeners('open');
488
- ws.removeAllListeners('message');
483
+ // Clear all WS listeners (CB:*, TAG:*, frame, error, etc.) to prevent memory leaks
484
+ ws.removeAllListeners();
489
485
  if (!ws.isClosed && !ws.isClosing) {
490
486
  try {
491
487
  await ws.close();
492
488
  }
493
489
  catch { }
494
490
  }
491
+ // Clean up noise handler buffers
492
+ if (noise.destroy) {
493
+ noise.destroy();
494
+ }
495
495
  ev.emit('connection.update', {
496
496
  connection: 'close',
497
497
  lastDisconnect: {
@@ -499,7 +499,11 @@ export const makeSocket = (config) => {
499
499
  date: new Date()
500
500
  }
501
501
  });
502
+ // Remove only internal socket listeners — preserve user-registered listeners
502
503
  ev.removeAllListeners('connection.update');
504
+ ev.removeAllListeners('creds.update');
505
+ ev.removeAllListeners('call');
506
+ ev.removeAllListeners('lid-mapping.update');
503
507
  };
504
508
  const waitForSocketOpen = async () => {
505
509
  if (ws.isOpen) {
@@ -522,7 +526,8 @@ export const makeSocket = (config) => {
522
526
  ws.off('error', onClose);
523
527
  });
524
528
  };
525
- const startKeepAliveRequest = () => (keepAliveReq = setInterval(() => {
529
+ const startKeepAliveRequest = () => {
530
+ keepAliveReq = setInterval(() => {
526
531
  if (!lastDateRecv) {
527
532
  lastDateRecv = new Date();
528
533
  }
@@ -552,7 +557,12 @@ export const makeSocket = (config) => {
552
557
  else {
553
558
  logger.warn('keep alive called when WS not open');
554
559
  }
555
- }, keepAliveIntervalMs));
560
+ }, keepAliveIntervalMs);
561
+ // unref so the interval doesn't prevent Node.js process from exiting
562
+ if (keepAliveReq?.unref) {
563
+ keepAliveReq.unref();
564
+ }
565
+ };
556
566
  /** i have no idea why this exists. pls enlighten me */
557
567
  const sendPassiveIq = (tag) => query({
558
568
  tag: 'iq',
@@ -598,7 +608,7 @@ export const makeSocket = (config) => {
598
608
  id: jidEncode(phoneNumber, 's.whatsapp.net'),
599
609
  name: '~'
600
610
  };
601
- ev.emit('creds.update', authState.creds);
611
+ ev.emit('creds.update', { pairingCode: authState.creds.pairingCode, me: authState.creds.me });
602
612
  await sendNode({
603
613
  tag: 'iq',
604
614
  attrs: {
@@ -810,7 +820,7 @@ export const makeSocket = (config) => {
810
820
  const routingInfo = getBinaryNodeChild(edgeRoutingNode, 'routing_info');
811
821
  if (routingInfo?.content) {
812
822
  authState.creds.routingInfo = Buffer.from(routingInfo?.content);
813
- ev.emit('creds.update', authState.creds);
823
+ ev.emit('creds.update', { routingInfo: authState.creds.routingInfo });
814
824
  }
815
825
  });
816
826
  let didStartBuffer = false;
@@ -149,7 +149,7 @@ export const makeEventBuffer = (logger) => {
149
149
  if (isBuffering && bufferCount === 1) {
150
150
  flush();
151
151
  }
152
- }, 100); // Small delay to allow nested buffers
152
+ }, 10); // Minimal delay to allow nested buffers without adding latency
153
153
  }
154
154
  return result;
155
155
  }
@@ -161,7 +161,7 @@ export const makeEventBuffer = (logger) => {
161
161
  if (bufferCount === 0) {
162
162
  // Only schedule ONE timeout, not 10,000
163
163
  if (!flushPendingTimeout) {
164
- flushPendingTimeout = setTimeout(flush, 100);
164
+ flushPendingTimeout = setTimeout(flush, 10);
165
165
  }
166
166
  }
167
167
  }
@@ -16,4 +16,5 @@ export * from './event-buffer.js';
16
16
  export * from './process-message.js';
17
17
  export * from './message-retry-manager.js';
18
18
  export * from './browser-utils.js';
19
- export * from './identity-change-handler.js';
19
+ export * from './identity-change-handler.js';
20
+ export * from './stanza-ack.js';
@@ -4,7 +4,7 @@ const RECENT_MESSAGES_SIZE = 512;
4
4
  const MESSAGE_KEY_SEPARATOR = '\u0000';
5
5
  /** Timeout for session recreation - 1 hour */
6
6
  const RECREATE_SESSION_TIMEOUT = 60 * 60 * 1000; // 1 hour in milliseconds
7
- const PHONE_REQUEST_DELAY = 3000;
7
+ const PHONE_REQUEST_DELAY = 1000;
8
8
  // Retry reason codes matching WhatsApp Web's Signal error codes.
9
9
  export var RetryReason;
10
10
  (function (RetryReason) {
@@ -195,6 +195,12 @@ export const makeNoiseHandler = ({ keyPair: { private: privateKey, public: publi
195
195
  inBytes = Buffer.concat([inBytes, newData]);
196
196
  }
197
197
  await processData(onFrame);
198
+ },
199
+ /** Clean up internal buffers to prevent memory leaks on connection close */
200
+ destroy() {
201
+ inBytes = Buffer.alloc(0);
202
+ transport = null;
203
+ pendingOnFrame = null;
198
204
  }
199
205
  };
200
206
  };
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Creates a processor for offline stanza nodes that:
3
+ * - Queues nodes for sequential processing
4
+ * - Yields to the event loop periodically to avoid blocking
5
+ * - Catches handler errors to prevent the processing loop from crashing
6
+ */
7
+ export function makeOfflineNodeProcessor(nodeProcessorMap, deps, batchSize = 10) {
8
+ const nodes = [];
9
+ let isProcessing = false;
10
+ const enqueue = (type, node) => {
11
+ nodes.push({ type, node });
12
+ if (isProcessing) {
13
+ return;
14
+ }
15
+ isProcessing = true;
16
+ const promise = async () => {
17
+ let processedInBatch = 0;
18
+ while (nodes.length && deps.isWsOpen()) {
19
+ const { type, node } = nodes.shift();
20
+ const nodeProcessor = nodeProcessorMap.get(type);
21
+ if (!nodeProcessor) {
22
+ deps.onUnexpectedError(new Error(`unknown offline node type: ${type}`), 'processing offline node');
23
+ continue;
24
+ }
25
+ await nodeProcessor(node).catch(err => deps.onUnexpectedError(err, `processing offline ${type}`));
26
+ processedInBatch++;
27
+ // Yield to event loop after processing a batch
28
+ // This prevents blocking the event loop for too long when there are many offline nodes
29
+ if (processedInBatch >= batchSize) {
30
+ processedInBatch = 0;
31
+ await deps.yieldToEventLoop();
32
+ }
33
+ }
34
+ isProcessing = false;
35
+ };
36
+ promise().catch(error => deps.onUnexpectedError(error, 'processing offline nodes'));
37
+ };
38
+ return { enqueue };
39
+ }