@violetix/baileys 0.5.0 → 0.5.1
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.
- package/lib/Defaults/index.d.ts +5 -0
- package/lib/Defaults/index.js +8 -0
- package/lib/Socket/Client/websocket.js +38 -3
- package/lib/Socket/index.d.ts +3 -0
- package/lib/Socket/messages-send.js +5 -1
- package/lib/Socket/socket.js +130 -10
- package/lib/Utils/connection-manager.d.ts +44 -0
- package/lib/Utils/connection-manager.js +258 -0
- package/lib/Utils/index.d.ts +1 -0
- package/lib/Utils/index.js +1 -0
- package/package.json +1 -1
package/lib/Defaults/index.d.ts
CHANGED
|
@@ -41,6 +41,11 @@ export namespace DEFAULT_CONNECTION_CONFIG {
|
|
|
41
41
|
export let browser: any[];
|
|
42
42
|
export let waWebSocketUrl: string;
|
|
43
43
|
export let connectTimeoutMs: number;
|
|
44
|
+
export let closeTimeoutMs: number;
|
|
45
|
+
export let autoReconnect: boolean;
|
|
46
|
+
export let reconnectDelayMs: number;
|
|
47
|
+
export let maxReconnectDelayMs: number;
|
|
48
|
+
export let maxReconnectAttempts: number;
|
|
44
49
|
export let keepAliveIntervalMs: number;
|
|
45
50
|
export let logger: any;
|
|
46
51
|
export let emitOwnEvents: boolean;
|
package/lib/Defaults/index.js
CHANGED
|
@@ -57,6 +57,14 @@ export const DEFAULT_CONNECTION_CONFIG = {
|
|
|
57
57
|
browser: Browsers.macOS('Chrome'),
|
|
58
58
|
waWebSocketUrl: 'wss://web.whatsapp.com/ws/chat',
|
|
59
59
|
connectTimeoutMs: 20000,
|
|
60
|
+
// a half-open socket never answers close(); without a ceiling every reconnect leaks one
|
|
61
|
+
closeTimeoutMs: 5000,
|
|
62
|
+
// reconnect the transport in place instead of ending the socket, so a host that treats
|
|
63
|
+
// connection 'close' as "build a new socket" never runs two sockets for one account
|
|
64
|
+
autoReconnect: true,
|
|
65
|
+
reconnectDelayMs: 2000,
|
|
66
|
+
maxReconnectDelayMs: 60000,
|
|
67
|
+
maxReconnectAttempts: 10,
|
|
60
68
|
keepAliveIntervalMs: 30000,
|
|
61
69
|
keepAliveGracePeriodMs: 30000,
|
|
62
70
|
logger: logger.child({ class: 'baileys' }),
|
|
@@ -35,16 +35,51 @@ export class WebSocketClient extends AbstractSocketClient {
|
|
|
35
35
|
this.socket?.on(event, (...args) => this.emit(event, ...args));
|
|
36
36
|
}
|
|
37
37
|
}
|
|
38
|
+
/**
|
|
39
|
+
* Drop the raw socket and open a new one on the SAME emitter. Every stanza listener the layers
|
|
40
|
+
* above registered (messages-recv, chats, the host) stays attached, which is what makes a
|
|
41
|
+
* reconnect invisible to them. The noise session is rebuilt by the caller.
|
|
42
|
+
*/
|
|
43
|
+
reconnect() {
|
|
44
|
+
const socket = this.socket;
|
|
45
|
+
this.socket = null;
|
|
46
|
+
if (socket) {
|
|
47
|
+
// remove the forwarders first: a late close from the old socket must not be reported
|
|
48
|
+
// as a close of the new one
|
|
49
|
+
socket.removeAllListeners();
|
|
50
|
+
try {
|
|
51
|
+
socket.terminate();
|
|
52
|
+
}
|
|
53
|
+
catch { }
|
|
54
|
+
}
|
|
55
|
+
this.connect();
|
|
56
|
+
}
|
|
38
57
|
async close() {
|
|
39
58
|
if (!this.socket) {
|
|
40
59
|
return;
|
|
41
60
|
}
|
|
61
|
+
const socket = this.socket;
|
|
62
|
+
// A half-open peer never answers the close handshake, so an unbounded wait here leaks one
|
|
63
|
+
// socket per reconnect. With a fleet that ends as FD exhaustion, so bound it and terminate.
|
|
64
|
+
const timeoutMs = this.config.closeTimeoutMs ?? 5000;
|
|
42
65
|
const closePromise = new Promise(resolve => {
|
|
43
|
-
|
|
44
|
-
|
|
66
|
+
const timer = setTimeout(() => {
|
|
67
|
+
try {
|
|
68
|
+
socket.terminate();
|
|
69
|
+
}
|
|
70
|
+
catch { }
|
|
71
|
+
resolve();
|
|
72
|
+
}, timeoutMs);
|
|
73
|
+
timer.unref?.();
|
|
74
|
+
const done = () => {
|
|
75
|
+
clearTimeout(timer);
|
|
76
|
+
resolve();
|
|
77
|
+
};
|
|
78
|
+
socket.once('close', done);
|
|
79
|
+
socket.once('error', done);
|
|
45
80
|
});
|
|
46
81
|
try {
|
|
47
|
-
|
|
82
|
+
socket.close();
|
|
48
83
|
} catch { }
|
|
49
84
|
await closePromise;
|
|
50
85
|
this.socket = null;
|
package/lib/Socket/index.d.ts
CHANGED
|
@@ -875,6 +875,8 @@ declare function makeWASocket(config: import('../Types/Socket.js').SocketConfig)
|
|
|
875
875
|
};
|
|
876
876
|
signalRepository: any;
|
|
877
877
|
user: any;
|
|
878
|
+
/** true while the transport is repairing itself; do not build a second socket for the account */
|
|
879
|
+
isReconnecting: boolean;
|
|
878
880
|
generateMessageTag: () => string;
|
|
879
881
|
/**
|
|
880
882
|
* Drops matching inbound stanzas before any handler — including the decryption and
|
|
@@ -890,6 +892,7 @@ declare function makeWASocket(config: import('../Types/Socket.js').SocketConfig)
|
|
|
890
892
|
logout: (msg: any) => Promise<void>;
|
|
891
893
|
end: (error: any) => Promise<void>;
|
|
892
894
|
registerSocketEndHandler: (handler: any) => void;
|
|
895
|
+
registerSocketReconnectHandler: (handler: any) => void;
|
|
893
896
|
onUnexpectedError: (err: any, msg: any) => void;
|
|
894
897
|
uploadPreKeys: (count?: number) => Promise<void>;
|
|
895
898
|
uploadPreKeysToServerIfRequired: () => Promise<void>;
|
|
@@ -55,7 +55,7 @@ const getMediaTypeForGroupStatus = (media) => {
|
|
|
55
55
|
export const makeMessagesSocket = (config) => {
|
|
56
56
|
const { logger, linkPreviewImageThumbnailWidth, generateHighQualityLinkPreview, options: httpRequestOptions, patchMessageBeforeSending, cachedGroupMetadata, enableRecentMessageCache, maxMsgRetryCount } = config;
|
|
57
57
|
const sock = makeNewsletterSocket(config);
|
|
58
|
-
const { ev, authState, messageMutex, signalRepository, upsertMessage, query, fetchPrivacySettings, sendNode, groupMetadata, groupToggleEphemeral, registerSocketEndHandler } = sock;
|
|
58
|
+
const { ev, authState, messageMutex, signalRepository, upsertMessage, query, fetchPrivacySettings, sendNode, groupMetadata, groupToggleEphemeral, registerSocketEndHandler, registerSocketReconnectHandler } = sock;
|
|
59
59
|
/**
|
|
60
60
|
* Echo a message we just sent back into our own store, off the send path.
|
|
61
61
|
*
|
|
@@ -1242,6 +1242,10 @@ export const makeMessagesSocket = (config) => {
|
|
|
1242
1242
|
messageRetryManager.clear();
|
|
1243
1243
|
}
|
|
1244
1244
|
});
|
|
1245
|
+
// the media host auth token is bound to the connection that fetched it
|
|
1246
|
+
registerSocketReconnectHandler?.(() => {
|
|
1247
|
+
mediaConn = undefined;
|
|
1248
|
+
});
|
|
1245
1249
|
const socket = {
|
|
1246
1250
|
...sock,
|
|
1247
1251
|
userDevicesCache,
|
package/lib/Socket/socket.js
CHANGED
|
@@ -27,6 +27,17 @@ import { executeWMexQuery } from './mex.js';
|
|
|
27
27
|
* so a stale hardcoded version heals on the next reconnect instead of looping on 405.
|
|
28
28
|
*/
|
|
29
29
|
let latestVersionOverride;
|
|
30
|
+
/**
|
|
31
|
+
* Reasons that end a session for good. Reconnecting any of these is how an unattended host turns
|
|
32
|
+
* one logout into an auth loop and gets the number banned.
|
|
33
|
+
*/
|
|
34
|
+
const DEAD_DISCONNECT_REASONS = new Set([
|
|
35
|
+
DisconnectReason.loggedOut,
|
|
36
|
+
DisconnectReason.forbidden,
|
|
37
|
+
DisconnectReason.multideviceMismatch,
|
|
38
|
+
DisconnectReason.badSession,
|
|
39
|
+
DisconnectReason.connectionReplaced
|
|
40
|
+
]);
|
|
30
41
|
export const makeSocket = (config) => {
|
|
31
42
|
const { waWebSocketUrl, connectTimeoutMs, logger, keepAliveIntervalMs, keepAliveGracePeriodMs, browser, auth: authState, printQRInTerminal, defaultQueryTimeoutMs, transactionOpts, qrTimeout, makeSignalRepository } = config;
|
|
32
43
|
const publicWAMBuffer = new BinaryInfo();
|
|
@@ -52,9 +63,9 @@ export const makeSocket = (config) => {
|
|
|
52
63
|
url.searchParams.append('ED', authState.creds.routingInfo.toString('base64url'));
|
|
53
64
|
}
|
|
54
65
|
/** ephemeral key pair used to encrypt/decrypt communication. Unique for each connection */
|
|
55
|
-
|
|
66
|
+
let ephemeralKeyPair = Curve.generateKeyPair();
|
|
56
67
|
/** WA noise protocol wrapper */
|
|
57
|
-
|
|
68
|
+
let noise = makeNoiseHandler({
|
|
58
69
|
keyPair: ephemeralKeyPair,
|
|
59
70
|
NOISE_HEADER: NOISE_WA_HEADER,
|
|
60
71
|
logger,
|
|
@@ -383,6 +394,11 @@ export const makeSocket = (config) => {
|
|
|
383
394
|
let keepAliveReq;
|
|
384
395
|
let qrTimer;
|
|
385
396
|
let closed = false;
|
|
397
|
+
/** transparent transport reconnect state; see scheduleReconnect */
|
|
398
|
+
let reconnecting = false;
|
|
399
|
+
let reconnectAttempts = 0;
|
|
400
|
+
let reconnectTimer;
|
|
401
|
+
let reconnectWaitResolve;
|
|
386
402
|
/** set once WhatsApp sends pair-device; requestPairingCode queues until then */
|
|
387
403
|
let pairingReady = false;
|
|
388
404
|
let pairingInProgress = false;
|
|
@@ -391,6 +407,8 @@ export const makeSocket = (config) => {
|
|
|
391
407
|
let pendingPairingResolve;
|
|
392
408
|
let pendingPairingReject;
|
|
393
409
|
const socketEndHandlers = [];
|
|
410
|
+
/** per-connection caches (media host auth, ...) that must be dropped when the transport reopens */
|
|
411
|
+
const socketReconnectHandlers = [];
|
|
394
412
|
const timers = new Set();
|
|
395
413
|
const safeSetTimeout = (fn, ms) => { const id = setTimeout(fn, ms); timers.add(id); return id; };
|
|
396
414
|
const safeSetInterval = (fn, ms) => { const id = setInterval(fn, ms); timers.add(id); return id; };
|
|
@@ -650,6 +668,9 @@ export const makeSocket = (config) => {
|
|
|
650
668
|
keepAliveReq?.cancel();
|
|
651
669
|
clearSafeTimer(qrTimer);
|
|
652
670
|
clearAllTimers();
|
|
671
|
+
// release a pending reconnect wait so scheduleReconnect cannot stay suspended
|
|
672
|
+
reconnectWaitResolve?.();
|
|
673
|
+
reconnectWaitResolve = undefined;
|
|
653
674
|
// a queued requestPairingCode would hang forever otherwise
|
|
654
675
|
pairingReady = false;
|
|
655
676
|
pairingInProgress = false;
|
|
@@ -693,6 +714,90 @@ export const makeSocket = (config) => {
|
|
|
693
714
|
logger.error({ err }, 'error while closing connection');
|
|
694
715
|
}
|
|
695
716
|
};
|
|
717
|
+
/** rebuild the state that belongs to one transport connection, not to the socket object */
|
|
718
|
+
const resetTransport = () => {
|
|
719
|
+
keepAliveReq?.cancel();
|
|
720
|
+
keepAliveReq = undefined;
|
|
721
|
+
clearSafeTimer(qrTimer);
|
|
722
|
+
qrTimer = undefined;
|
|
723
|
+
lastDateRecv = undefined;
|
|
724
|
+
ephemeralKeyPair = Curve.generateKeyPair();
|
|
725
|
+
noise = makeNoiseHandler({
|
|
726
|
+
keyPair: ephemeralKeyPair,
|
|
727
|
+
NOISE_HEADER: NOISE_WA_HEADER,
|
|
728
|
+
logger,
|
|
729
|
+
routingInfo: authState?.creds?.routingInfo
|
|
730
|
+
});
|
|
731
|
+
// the media host token is issued per connection; a cached one fails every upload after this
|
|
732
|
+
for (const handler of socketReconnectHandlers) {
|
|
733
|
+
try {
|
|
734
|
+
handler();
|
|
735
|
+
}
|
|
736
|
+
catch (err) {
|
|
737
|
+
logger.error({ err }, 'error in socket reconnect handler');
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
};
|
|
741
|
+
/**
|
|
742
|
+
* Transparent reconnect. The WebSocket client object is kept and only the raw socket and the
|
|
743
|
+
* noise session under it are replaced, so every stanza listener registered by the layers above
|
|
744
|
+
* (messages-recv, chats, the host) keeps working and the host never sees a `close` for a drop
|
|
745
|
+
* that this socket can repair itself.
|
|
746
|
+
*/
|
|
747
|
+
const scheduleReconnect = async (error) => {
|
|
748
|
+
if (reconnecting || closed) {
|
|
749
|
+
return;
|
|
750
|
+
}
|
|
751
|
+
reconnecting = true;
|
|
752
|
+
const statusCode = error?.output?.statusCode;
|
|
753
|
+
const maxAttempts = config.maxReconnectAttempts ?? 10;
|
|
754
|
+
if (!config.autoReconnect || DEAD_DISCONNECT_REASONS.has(statusCode) || reconnectAttempts >= maxAttempts) {
|
|
755
|
+
reconnecting = false;
|
|
756
|
+
return end(error);
|
|
757
|
+
}
|
|
758
|
+
reconnectAttempts += 1;
|
|
759
|
+
// 515 means the server wants the connection back now; backing off only delays it
|
|
760
|
+
const immediate = statusCode === DisconnectReason.restartRequired;
|
|
761
|
+
const base = config.reconnectDelayMs ?? 2000;
|
|
762
|
+
const ceiling = config.maxReconnectDelayMs ?? 60000;
|
|
763
|
+
const backoff = Math.min(ceiling, base * 2 ** (reconnectAttempts - 1));
|
|
764
|
+
// jitter keeps a fleet from reconnecting in lockstep after a server-side blip
|
|
765
|
+
const delayMs = immediate ? 0 : Math.round(backoff * (0.75 + Math.random() * 0.5));
|
|
766
|
+
logger.info({ statusCode, attempt: reconnectAttempts, delayMs }, 'connection lost, reconnecting');
|
|
767
|
+
// NOT 'close': a host reads close as "build a new socket", and a second socket for the same
|
|
768
|
+
// account is what earns a 440 connectionReplaced
|
|
769
|
+
ev.emit('connection.update', {
|
|
770
|
+
connection: 'connecting',
|
|
771
|
+
lastDisconnect: { error, date: new Date() }
|
|
772
|
+
});
|
|
773
|
+
await new Promise(resolve => {
|
|
774
|
+
reconnectWaitResolve = resolve;
|
|
775
|
+
reconnectTimer = safeSetTimeout(() => {
|
|
776
|
+
reconnectWaitResolve = undefined;
|
|
777
|
+
resolve();
|
|
778
|
+
}, delayMs);
|
|
779
|
+
});
|
|
780
|
+
reconnectTimer = undefined;
|
|
781
|
+
reconnecting = false;
|
|
782
|
+
if (closed) {
|
|
783
|
+
return;
|
|
784
|
+
}
|
|
785
|
+
try {
|
|
786
|
+
resetTransport();
|
|
787
|
+
ws.reconnect();
|
|
788
|
+
}
|
|
789
|
+
catch (err) {
|
|
790
|
+
logger.error({ err }, 'failed to reopen the transport');
|
|
791
|
+
return end(err);
|
|
792
|
+
}
|
|
793
|
+
};
|
|
794
|
+
/** single entry point for every transport-level close */
|
|
795
|
+
const handleDisconnect = (error) => {
|
|
796
|
+
if (closed) {
|
|
797
|
+
return;
|
|
798
|
+
}
|
|
799
|
+
return scheduleReconnect(error);
|
|
800
|
+
};
|
|
696
801
|
const waitForSocketOpen = async () => {
|
|
697
802
|
if (ws.isOpen) {
|
|
698
803
|
return;
|
|
@@ -758,7 +863,7 @@ export const makeSocket = (config) => {
|
|
|
758
863
|
logger.warn({ lagMs, sinceLastRecvMs: sinceRecvMs }, 'event loop stalled; deferring keep-alive timeout');
|
|
759
864
|
}
|
|
760
865
|
if (sinceRecvMs > keepAliveIntervalMs + keepAliveGracePeriodMs && !loopStalled) {
|
|
761
|
-
void
|
|
866
|
+
void handleDisconnect(new Boom('Connection was lost', { statusCode: DisconnectReason.connectionLost }));
|
|
762
867
|
return;
|
|
763
868
|
}
|
|
764
869
|
if (!ws.isOpen) {
|
|
@@ -988,13 +1093,13 @@ export const makeSocket = (config) => {
|
|
|
988
1093
|
}
|
|
989
1094
|
catch (err) {
|
|
990
1095
|
logger.error({ err }, 'error in validating connection');
|
|
991
|
-
void
|
|
1096
|
+
void handleDisconnect(err);
|
|
992
1097
|
}
|
|
993
1098
|
});
|
|
994
|
-
ws.on('error', mapWebSocketError(
|
|
995
|
-
ws.on('close', () => void
|
|
1099
|
+
ws.on('error', mapWebSocketError(handleDisconnect));
|
|
1100
|
+
ws.on('close', () => void handleDisconnect(new Boom('Connection Terminated', { statusCode: DisconnectReason.connectionClosed })));
|
|
996
1101
|
// the server terminated the connection
|
|
997
|
-
ws.on('CB:xmlstreamend', () => void
|
|
1102
|
+
ws.on('CB:xmlstreamend', () => void handleDisconnect(new Boom('Connection Terminated by Server', { statusCode: DisconnectReason.connectionClosed })));
|
|
998
1103
|
// QR gen
|
|
999
1104
|
ws.on('CB:iq,type:set,pair-device', async (stanza) => {
|
|
1000
1105
|
const iq = {
|
|
@@ -1059,6 +1164,9 @@ export const makeSocket = (config) => {
|
|
|
1059
1164
|
});
|
|
1060
1165
|
// login complete
|
|
1061
1166
|
ws.on('CB:success', async (node) => {
|
|
1167
|
+
// a completed handshake clears the reconnect budget: the next drop starts from the base delay
|
|
1168
|
+
reconnectAttempts = 0;
|
|
1169
|
+
reconnecting = false;
|
|
1062
1170
|
try {
|
|
1063
1171
|
updateServerTimeOffset(node);
|
|
1064
1172
|
await uploadPreKeysToServerIfRequired();
|
|
@@ -1107,7 +1215,7 @@ export const makeSocket = (config) => {
|
|
|
1107
1215
|
const [reasonNode] = getAllBinaryNodeChildren(node);
|
|
1108
1216
|
logger.error({ reasonNode, fullErrorNode: node }, 'stream errored out');
|
|
1109
1217
|
const { reason, statusCode } = getErrorCodeFromStreamError(node);
|
|
1110
|
-
void
|
|
1218
|
+
void handleDisconnect(new Boom(`Stream Errored (${reason})`, { statusCode, data: reasonNode || node }));
|
|
1111
1219
|
});
|
|
1112
1220
|
// stream fail, possible logout
|
|
1113
1221
|
ws.on('CB:failure', async (node) => {
|
|
@@ -1123,10 +1231,10 @@ export const makeSocket = (config) => {
|
|
|
1123
1231
|
logger.warn({ err: latest.error?.message }, 'server rejected the client version as too old (405) and the latest version could not be fetched; pass a current `version`');
|
|
1124
1232
|
}
|
|
1125
1233
|
}
|
|
1126
|
-
void
|
|
1234
|
+
void handleDisconnect(new Boom('Connection Failure', { statusCode: reason, data: node.attrs }));
|
|
1127
1235
|
});
|
|
1128
1236
|
ws.on('CB:ib,,downgrade_webclient', () => {
|
|
1129
|
-
void
|
|
1237
|
+
void handleDisconnect(new Boom('Multi-device beta not joined', { statusCode: DisconnectReason.multideviceMismatch }));
|
|
1130
1238
|
});
|
|
1131
1239
|
// The server delivers the offline queue in windows and waits for another <offline_batch/>
|
|
1132
1240
|
// before sending the next one. A single request only drained the first 100 stanzas, so a
|
|
@@ -1255,6 +1363,9 @@ export const makeSocket = (config) => {
|
|
|
1255
1363
|
const registerSocketEndHandler = (handler) => {
|
|
1256
1364
|
socketEndHandlers.push(handler);
|
|
1257
1365
|
};
|
|
1366
|
+
const registerSocketReconnectHandler = (handler) => {
|
|
1367
|
+
socketReconnectHandlers.push(handler);
|
|
1368
|
+
};
|
|
1258
1369
|
/**
|
|
1259
1370
|
* Fetches your account's standing when it comes to restrictions.
|
|
1260
1371
|
* @returns Returns the state of the restrictions.
|
|
@@ -1287,6 +1398,14 @@ export const makeSocket = (config) => {
|
|
|
1287
1398
|
get user() {
|
|
1288
1399
|
return authState.creds.me;
|
|
1289
1400
|
},
|
|
1401
|
+
/**
|
|
1402
|
+
* True while the transport is repairing itself. A host that reads this does not build a
|
|
1403
|
+
* second socket for the same account, which is what a `ws.isOpen === false` check would
|
|
1404
|
+
* make it do during the backoff window and the handshake that follows.
|
|
1405
|
+
*/
|
|
1406
|
+
get isReconnecting() {
|
|
1407
|
+
return reconnecting || (reconnectAttempts > 0 && !closed && !ws.isOpen);
|
|
1408
|
+
},
|
|
1290
1409
|
generateMessageTag,
|
|
1291
1410
|
ignoreKey: ignoreKeyRegistry.register,
|
|
1292
1411
|
query,
|
|
@@ -1297,6 +1416,7 @@ export const makeSocket = (config) => {
|
|
|
1297
1416
|
logout,
|
|
1298
1417
|
end,
|
|
1299
1418
|
registerSocketEndHandler,
|
|
1419
|
+
registerSocketReconnectHandler,
|
|
1300
1420
|
onUnexpectedError,
|
|
1301
1421
|
uploadPreKeys,
|
|
1302
1422
|
uploadPreKeysToServerIfRequired,
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { EventEmitter } from "events";
|
|
2
|
+
import { Agent } from "https";
|
|
3
|
+
export type ConnectionManagerSession = {
|
|
4
|
+
id: string;
|
|
5
|
+
[key: string]: any;
|
|
6
|
+
};
|
|
7
|
+
export type ConnectionManagerOptions = {
|
|
8
|
+
/** Build one socket for a session. Called again for every reconnect. */
|
|
9
|
+
create: (session: ConnectionManagerSession) => Promise<any> | any;
|
|
10
|
+
sessions?: ConnectionManagerSession[];
|
|
11
|
+
maxConcurrent?: number;
|
|
12
|
+
reconnect?: boolean;
|
|
13
|
+
baseDelayMs?: number;
|
|
14
|
+
maxDelayMs?: number;
|
|
15
|
+
maxAttempts?: number;
|
|
16
|
+
staggerMs?: number;
|
|
17
|
+
logger?: any;
|
|
18
|
+
};
|
|
19
|
+
export type ConnectionManagerStats = {
|
|
20
|
+
total: number;
|
|
21
|
+
idle: number;
|
|
22
|
+
connecting: number;
|
|
23
|
+
open: number;
|
|
24
|
+
reconnecting: number;
|
|
25
|
+
closed: number;
|
|
26
|
+
failed: number;
|
|
27
|
+
activeConnects: number;
|
|
28
|
+
queuedConnects: number;
|
|
29
|
+
};
|
|
30
|
+
export declare class ConnectionManager extends EventEmitter {
|
|
31
|
+
#private;
|
|
32
|
+
constructor(options: ConnectionManagerOptions);
|
|
33
|
+
start(): ConnectionManagerStats;
|
|
34
|
+
get(id: string): any | null;
|
|
35
|
+
stop(id: string): boolean;
|
|
36
|
+
close(): Promise<void>;
|
|
37
|
+
stats(): ConnectionManagerStats;
|
|
38
|
+
}
|
|
39
|
+
export declare const makeConnectionManager: (options: ConnectionManagerOptions) => ConnectionManager;
|
|
40
|
+
export declare const makeWssAgent: (options?: {
|
|
41
|
+
maxSockets?: number;
|
|
42
|
+
keepAlive?: boolean;
|
|
43
|
+
keepAliveMsecs?: number;
|
|
44
|
+
}) => Agent;
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import { EventEmitter } from 'events';
|
|
2
|
+
import { Agent } from 'https';
|
|
3
|
+
import { DisconnectReason } from '../Types/index.js';
|
|
4
|
+
/**
|
|
5
|
+
* Supervises a fleet of sockets inside one process.
|
|
6
|
+
*
|
|
7
|
+
* The library builds and tears down one socket; it deliberately does not reconnect, because only
|
|
8
|
+
* the host knows which auth state to reload. With hundreds of accounts that leaves three fleet-level
|
|
9
|
+
* problems to whoever wires them up, and getting them wrong is what makes accounts drop:
|
|
10
|
+
*
|
|
11
|
+
* - every socket reconnecting the moment the server closes it, all at once, which hammers the
|
|
12
|
+
* endpoint and gets throttled or banned (this is what `baseDelayMs` + jitter + stagger fix);
|
|
13
|
+
* - a permanently dead session (401/403/411/440) being reconnected forever, which is the
|
|
14
|
+
* documented path to a ban (this is what DEAD_REASONS stops);
|
|
15
|
+
* - unbounded parallel connects, which exhausts FDs and TLS handshakes on boot.
|
|
16
|
+
*
|
|
17
|
+
* Events are deliberately never named 'error': EventEmitter throws on an unhandled 'error', and one
|
|
18
|
+
* dead session must not take the process down.
|
|
19
|
+
*
|
|
20
|
+
* ponytail: supervision only. Every socket still shares one event loop, so this bounds reconnect
|
|
21
|
+
* storms and socket leaks but not CPU-bound crypto; past a few hundred accounts, shard by process.
|
|
22
|
+
*/
|
|
23
|
+
const DEAD_REASONS = new Set([
|
|
24
|
+
DisconnectReason.loggedOut,
|
|
25
|
+
DisconnectReason.forbidden,
|
|
26
|
+
DisconnectReason.multideviceMismatch,
|
|
27
|
+
DisconnectReason.badSession,
|
|
28
|
+
DisconnectReason.connectionReplaced
|
|
29
|
+
]);
|
|
30
|
+
export class ConnectionManager extends EventEmitter {
|
|
31
|
+
#create;
|
|
32
|
+
#sessions = new Map();
|
|
33
|
+
#maxConcurrent;
|
|
34
|
+
#active = 0;
|
|
35
|
+
#waiters = [];
|
|
36
|
+
#reconnect;
|
|
37
|
+
#baseDelayMs;
|
|
38
|
+
#maxDelayMs;
|
|
39
|
+
#maxAttempts;
|
|
40
|
+
#staggerMs;
|
|
41
|
+
#logger;
|
|
42
|
+
#started = false;
|
|
43
|
+
constructor({ create, sessions = [], maxConcurrent = 8, reconnect = true, baseDelayMs = 2000, maxDelayMs = 60000, maxAttempts = Infinity, staggerMs = 250, logger = console }) {
|
|
44
|
+
super();
|
|
45
|
+
if (typeof create !== 'function') {
|
|
46
|
+
throw new TypeError('ConnectionManager needs a create(session) function');
|
|
47
|
+
}
|
|
48
|
+
for (const session of sessions) {
|
|
49
|
+
if (!session?.id) {
|
|
50
|
+
throw new TypeError('every session needs an id');
|
|
51
|
+
}
|
|
52
|
+
if (this.#sessions.has(session.id)) {
|
|
53
|
+
throw new TypeError(`duplicate session id: ${session.id}`);
|
|
54
|
+
}
|
|
55
|
+
this.#sessions.set(session.id, {
|
|
56
|
+
id: session.id,
|
|
57
|
+
session,
|
|
58
|
+
sock: null,
|
|
59
|
+
state: 'idle',
|
|
60
|
+
attempts: 0,
|
|
61
|
+
generation: 0,
|
|
62
|
+
timer: null,
|
|
63
|
+
stopped: false,
|
|
64
|
+
lastError: null
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
this.#create = create;
|
|
68
|
+
this.#maxConcurrent = Math.max(1, maxConcurrent);
|
|
69
|
+
this.#reconnect = reconnect;
|
|
70
|
+
this.#baseDelayMs = baseDelayMs;
|
|
71
|
+
this.#maxDelayMs = maxDelayMs;
|
|
72
|
+
this.#maxAttempts = maxAttempts;
|
|
73
|
+
this.#staggerMs = staggerMs;
|
|
74
|
+
this.#logger = logger;
|
|
75
|
+
}
|
|
76
|
+
/** Connect every session, staggered so boot does not fire them all in one tick. */
|
|
77
|
+
start() {
|
|
78
|
+
if (this.#started) {
|
|
79
|
+
return this.stats();
|
|
80
|
+
}
|
|
81
|
+
this.#started = true;
|
|
82
|
+
let index = 0;
|
|
83
|
+
for (const rec of this.#sessions.values()) {
|
|
84
|
+
const delayMs = this.#staggerMs * index++;
|
|
85
|
+
if (!delayMs) {
|
|
86
|
+
void this.#connect(rec);
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
rec.timer = setTimeout(() => {
|
|
90
|
+
rec.timer = null;
|
|
91
|
+
void this.#connect(rec);
|
|
92
|
+
}, delayMs);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return this.stats();
|
|
96
|
+
}
|
|
97
|
+
get(id) {
|
|
98
|
+
return this.#sessions.get(id)?.sock ?? null;
|
|
99
|
+
}
|
|
100
|
+
/** Stop one session for good. A close event that arrives afterwards is ignored. */
|
|
101
|
+
stop(id) {
|
|
102
|
+
const rec = this.#sessions.get(id);
|
|
103
|
+
if (!rec) {
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
this.#stop(rec);
|
|
107
|
+
return true;
|
|
108
|
+
}
|
|
109
|
+
/** Stop everything and wait for each socket's teardown. */
|
|
110
|
+
async close() {
|
|
111
|
+
const pending = [];
|
|
112
|
+
for (const rec of this.#sessions.values()) {
|
|
113
|
+
const sock = this.#stop(rec);
|
|
114
|
+
if (sock) {
|
|
115
|
+
pending.push(Promise.resolve(sock.end?.(undefined)).catch(() => { }));
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
await Promise.all(pending);
|
|
119
|
+
this.#started = false;
|
|
120
|
+
this.removeAllListeners();
|
|
121
|
+
}
|
|
122
|
+
stats() {
|
|
123
|
+
const counts = { total: 0, idle: 0, connecting: 0, open: 0, reconnecting: 0, closed: 0, failed: 0 };
|
|
124
|
+
for (const rec of this.#sessions.values()) {
|
|
125
|
+
counts.total += 1;
|
|
126
|
+
counts[rec.state] += 1;
|
|
127
|
+
}
|
|
128
|
+
counts.activeConnects = this.#active;
|
|
129
|
+
counts.queuedConnects = this.#waiters.length;
|
|
130
|
+
return counts;
|
|
131
|
+
}
|
|
132
|
+
#stop(rec) {
|
|
133
|
+
rec.stopped = true;
|
|
134
|
+
rec.generation += 1;
|
|
135
|
+
if (rec.timer) {
|
|
136
|
+
clearTimeout(rec.timer);
|
|
137
|
+
rec.timer = null;
|
|
138
|
+
}
|
|
139
|
+
const sock = rec.sock;
|
|
140
|
+
rec.sock = null;
|
|
141
|
+
rec.state = 'closed';
|
|
142
|
+
return sock;
|
|
143
|
+
}
|
|
144
|
+
async #connect(rec) {
|
|
145
|
+
if (rec.stopped) {
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
if (rec.timer) {
|
|
149
|
+
clearTimeout(rec.timer);
|
|
150
|
+
rec.timer = null;
|
|
151
|
+
}
|
|
152
|
+
const generation = ++rec.generation;
|
|
153
|
+
rec.state = 'connecting';
|
|
154
|
+
await this.#acquire();
|
|
155
|
+
let sock;
|
|
156
|
+
try {
|
|
157
|
+
sock = await this.#create(rec.session);
|
|
158
|
+
}
|
|
159
|
+
catch (err) {
|
|
160
|
+
this.#release();
|
|
161
|
+
rec.lastError = err;
|
|
162
|
+
this.emit('session-error', { id: rec.id, error: err });
|
|
163
|
+
this.#scheduleReconnect(rec, err);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
this.#release();
|
|
167
|
+
// stop() or a newer connect won the race while this one was being created
|
|
168
|
+
if (rec.stopped || generation !== rec.generation) {
|
|
169
|
+
void sock?.end?.(undefined);
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
rec.sock = sock;
|
|
173
|
+
try {
|
|
174
|
+
sock.ev.on('connection.update', update => this.#onUpdate(rec, sock, update));
|
|
175
|
+
}
|
|
176
|
+
catch (err) {
|
|
177
|
+
rec.lastError = err;
|
|
178
|
+
this.emit('session-error', { id: rec.id, error: err });
|
|
179
|
+
this.#scheduleReconnect(rec, err);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
#onUpdate(rec, sock, update) {
|
|
183
|
+
// a socket that was already replaced must not drive this session any more
|
|
184
|
+
if (rec.sock !== sock) {
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
const { connection, lastDisconnect, qr } = update;
|
|
188
|
+
if (qr) {
|
|
189
|
+
this.emit('qr', { id: rec.id, qr });
|
|
190
|
+
}
|
|
191
|
+
if (connection === 'open') {
|
|
192
|
+
rec.state = 'open';
|
|
193
|
+
rec.attempts = 0;
|
|
194
|
+
rec.lastError = null;
|
|
195
|
+
this.emit('open', { id: rec.id, sock });
|
|
196
|
+
}
|
|
197
|
+
else if (connection === 'close') {
|
|
198
|
+
const error = lastDisconnect?.error;
|
|
199
|
+
rec.sock = null;
|
|
200
|
+
rec.lastError = error;
|
|
201
|
+
this.emit('close', { id: rec.id, error, statusCode: error?.output?.statusCode });
|
|
202
|
+
this.#scheduleReconnect(rec, error);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
#scheduleReconnect(rec, error) {
|
|
206
|
+
if (!this.#reconnect || rec.stopped) {
|
|
207
|
+
rec.state = 'closed';
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
const statusCode = error?.output?.statusCode;
|
|
211
|
+
if (DEAD_REASONS.has(statusCode) || rec.attempts >= this.#maxAttempts) {
|
|
212
|
+
rec.state = 'failed';
|
|
213
|
+
this.#logger?.warn?.({ id: rec.id, statusCode, attempts: rec.attempts }, 'session will not be reconnected');
|
|
214
|
+
this.emit('dead', { id: rec.id, error, statusCode });
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
// the server asks for a restart on 515; backing off there only delays the reconnect
|
|
218
|
+
const immediate = statusCode === DisconnectReason.restartRequired;
|
|
219
|
+
const backoff = Math.min(this.#maxDelayMs, this.#baseDelayMs * 2 ** rec.attempts);
|
|
220
|
+
const delayMs = immediate ? 0 : Math.round(backoff * (0.75 + Math.random() * 0.5));
|
|
221
|
+
rec.attempts += 1;
|
|
222
|
+
rec.state = 'reconnecting';
|
|
223
|
+
this.emit('reconnect', { id: rec.id, attempt: rec.attempts, delayMs, statusCode });
|
|
224
|
+
rec.timer = setTimeout(() => {
|
|
225
|
+
rec.timer = null;
|
|
226
|
+
void this.#connect(rec);
|
|
227
|
+
}, delayMs);
|
|
228
|
+
}
|
|
229
|
+
#acquire() {
|
|
230
|
+
if (this.#active < this.#maxConcurrent) {
|
|
231
|
+
this.#active += 1;
|
|
232
|
+
return Promise.resolve();
|
|
233
|
+
}
|
|
234
|
+
return new Promise(resolve => this.#waiters.push(resolve));
|
|
235
|
+
}
|
|
236
|
+
#release() {
|
|
237
|
+
const next = this.#waiters.shift();
|
|
238
|
+
if (next) {
|
|
239
|
+
// hand the slot over without touching the counter
|
|
240
|
+
next();
|
|
241
|
+
}
|
|
242
|
+
else {
|
|
243
|
+
this.#active -= 1;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
export const makeConnectionManager = (options) => new ConnectionManager(options);
|
|
248
|
+
/**
|
|
249
|
+
* One TLS agent shared by every socket in the fleet. `ws` opens one TCP connection per socket, so
|
|
250
|
+
* this does not multiplex them, but it does keep the agent, its DNS cache and its free-socket pool
|
|
251
|
+
* shared instead of one per account.
|
|
252
|
+
*/
|
|
253
|
+
export const makeWssAgent = ({ maxSockets = 256, keepAlive = true, keepAliveMsecs = 30000 } = {}) => new Agent({
|
|
254
|
+
keepAlive,
|
|
255
|
+
keepAliveMsecs,
|
|
256
|
+
maxSockets,
|
|
257
|
+
maxFreeSockets: Math.min(32, maxSockets)
|
|
258
|
+
});
|
package/lib/Utils/index.d.ts
CHANGED
|
@@ -24,6 +24,7 @@ export * from "./identity-change-handler.js";
|
|
|
24
24
|
export * from "./stanza-ack.js";
|
|
25
25
|
export * from "./ignore-key.js";
|
|
26
26
|
export * from "./anchor-guard.js";
|
|
27
|
+
export * from "./connection-manager.js";
|
|
27
28
|
export * from "./a2ui.js";
|
|
28
29
|
export * from "./meta-ai-msmsg.js";
|
|
29
30
|
//# sourceMappingURL=index.d.ts.map
|
package/lib/Utils/index.js
CHANGED
|
@@ -18,6 +18,7 @@ export * from './event-buffer.js';
|
|
|
18
18
|
export * from './process-message.js';
|
|
19
19
|
export * from './rich-message-utils.js';
|
|
20
20
|
export * from './message-retry-manager.js';
|
|
21
|
+
export * from './connection-manager.js';
|
|
21
22
|
export * from './browser-utils.js';
|
|
22
23
|
export * from './companion-reg-client-utils.js';
|
|
23
24
|
export * from './identity-change-handler.js';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@violetix/baileys",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.1",
|
|
4
4
|
"description": "Enhanced Baileys v7 with fixes for newsletter media uploads, plus support for interactive messages, albums, and additional message types.",
|
|
5
5
|
"main": "lib/index.js",
|
|
6
6
|
"types": "lib/index.d.ts",
|