@oxidezap/baileyrs 0.0.35 → 0.1.0
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/README.md +77 -24
- package/lib/Compatibility/proto-runtime.js +30 -20
- package/lib/Compatibility/websocket-client.d.ts +23 -2
- package/lib/Compatibility/websocket-client.js +47 -18
- package/lib/Socket/bridge-client-owner.d.ts +89 -0
- package/lib/Socket/bridge-client-owner.js +135 -0
- package/lib/Socket/events.d.ts +31 -0
- package/lib/Socket/events.js +139 -36
- package/lib/Socket/index.d.ts +28 -2
- package/lib/Socket/index.js +432 -131
- package/lib/Socket/messages.d.ts +4 -1
- package/lib/Socket/messages.js +5 -3
- package/lib/Socket/terminal-close-reporter.d.ts +79 -0
- package/lib/Socket/terminal-close-reporter.js +108 -0
- package/lib/Socket/terminal-close.d.ts +39 -0
- package/lib/Socket/terminal-close.js +51 -0
- package/lib/Utils/event-buffer.js +31 -0
- package/lib/Utils/messages.d.ts +11 -0
- package/lib/Utils/messages.js +35 -14
- package/package.json +2 -2
package/lib/Socket/index.js
CHANGED
|
@@ -16,7 +16,7 @@ import { DEFAULT_CONNECTION_CONFIG } from '../Defaults/index.js';
|
|
|
16
16
|
import { DisconnectReason } from '../Types/index.js';
|
|
17
17
|
import { Boom } from '../Utils/boom.js';
|
|
18
18
|
import { makeEventBuffer } from '../Utils/event-buffer.js';
|
|
19
|
-
import { _registerActiveBridgeClient, downloadMediaMessage } from '../Utils/messages.js';
|
|
19
|
+
import { _registerActiveBridgeClient, _unregisterActiveBridgeClient, downloadMediaMessage } from '../Utils/messages.js';
|
|
20
20
|
import { makeNativeCryptoProvider } from '../Utils/native-crypto-provider.js';
|
|
21
21
|
import { wrapLegacyStore } from '../Utils/wrap-legacy-store.js';
|
|
22
22
|
import { assertNodeErrorFree } from '../WABinary/generic-utils.js';
|
|
@@ -24,6 +24,8 @@ import { makeBlockingMethods } from './blocking.js';
|
|
|
24
24
|
import { makeChatActionMethods } from './chat-actions.js';
|
|
25
25
|
import { makeContactMethods } from './contacts.js';
|
|
26
26
|
import { makeCommunityMethods } from './communities.js';
|
|
27
|
+
import { makeBridgeClientOwner } from './bridge-client-owner.js';
|
|
28
|
+
import { makeTerminalCloseReporter } from './terminal-close-reporter.js';
|
|
27
29
|
import { makeEventHandlers } from './events.js';
|
|
28
30
|
import { makeGroupMethods } from './groups.js';
|
|
29
31
|
import { makeMessageMethods } from './messages.js';
|
|
@@ -72,10 +74,160 @@ const makeWASocket = (config) => {
|
|
|
72
74
|
// this first so `ev.on('creds.update', saveCreds)` persists the merged state
|
|
73
75
|
// rather than the pre-pair placeholder.
|
|
74
76
|
ev.on('creds.update', update => Object.assign(auth.creds, update));
|
|
75
|
-
let client;
|
|
76
|
-
let readyClient;
|
|
77
77
|
let user;
|
|
78
|
-
|
|
78
|
+
/** True once `init()` has finished wiring the client and started its read loop. */
|
|
79
|
+
let initialized = false;
|
|
80
|
+
/** True while the socket's end handlers run — see `end`. */
|
|
81
|
+
let runningEndHandlers = false;
|
|
82
|
+
/**
|
|
83
|
+
* Consumer teardown hooks, plus the socket's own. Declared here rather than
|
|
84
|
+
* beside their registrations so the owner below can close over the list
|
|
85
|
+
* before anything fills it.
|
|
86
|
+
*/
|
|
87
|
+
const socketEndHandlers = [];
|
|
88
|
+
/**
|
|
89
|
+
* Drain both auth stores, returning the FIRST failure rather than throwing
|
|
90
|
+
* so the caller can finish the rest of its work and still report it.
|
|
91
|
+
*
|
|
92
|
+
* Called through their owners on purpose: collecting the two methods into
|
|
93
|
+
* an array and invoking them bare drops the receiver, so a consumer store
|
|
94
|
+
* whose `flush()` touches `this` throws on `undefined` and the teardown
|
|
95
|
+
* publishes a close with the auth writes unpersisted.
|
|
96
|
+
*/
|
|
97
|
+
const flushStores = async () => {
|
|
98
|
+
let firstError;
|
|
99
|
+
try {
|
|
100
|
+
await auth.store?.flush?.();
|
|
101
|
+
}
|
|
102
|
+
catch (e) {
|
|
103
|
+
firstError ?? (firstError = e);
|
|
104
|
+
}
|
|
105
|
+
try {
|
|
106
|
+
await autoWrappedStore?.flush?.();
|
|
107
|
+
}
|
|
108
|
+
catch (e) {
|
|
109
|
+
firstError ?? (firstError = e);
|
|
110
|
+
}
|
|
111
|
+
return firstError;
|
|
112
|
+
};
|
|
113
|
+
/**
|
|
114
|
+
* Single home for the bridge client's lifetime. `ws` below reads the current
|
|
115
|
+
* client from it, and this teardown closes `ws` — the cycle is fine because
|
|
116
|
+
* both directions only run once the socket is live.
|
|
117
|
+
*/
|
|
118
|
+
const owner = makeBridgeClientOwner({
|
|
119
|
+
logger,
|
|
120
|
+
/**
|
|
121
|
+
* Everything the socket owns beyond the client itself. Runs once, with
|
|
122
|
+
* the client still usable, whether or not one was ever adopted — a
|
|
123
|
+
* teardown that landed mid-init still has a transport to close and a
|
|
124
|
+
* store to drain.
|
|
125
|
+
*/
|
|
126
|
+
teardown: async (client, error) => {
|
|
127
|
+
try {
|
|
128
|
+
await ws.close();
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
// The transport refused to close cleanly. Go straight at the
|
|
132
|
+
// client so the disconnect still happens *before* the barrier
|
|
133
|
+
// and flush below: `release` retries it, but that runs after the
|
|
134
|
+
// flush, and the closing-session ratchet writes a disconnect
|
|
135
|
+
// enqueues would then have nothing left to persist them.
|
|
136
|
+
try {
|
|
137
|
+
await client?.disconnect();
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
/* ignore */
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (client) {
|
|
144
|
+
// Barrier: bridge cleanup paths fired during `disconnect()` may
|
|
145
|
+
// emit `set()` calls that are still queued as microtasks /
|
|
146
|
+
// `setImmediate` callbacks at this point. Two yields to the
|
|
147
|
+
// event loop drain (1) the microtask queue and (2) the next
|
|
148
|
+
// macrotask tick where wasm-bindgen async callbacks land.
|
|
149
|
+
// Without this barrier the flushes below run before the bridge
|
|
150
|
+
// has finished writing — a race that loses the last few sets
|
|
151
|
+
// (typically the closing-session ratchet step).
|
|
152
|
+
await new Promise(resolve => setImmediate(resolve));
|
|
153
|
+
await new Promise(resolve => setImmediate(resolve));
|
|
154
|
+
}
|
|
155
|
+
const firstFlushError = await flushStores();
|
|
156
|
+
// End handlers run before the flush error is rethrown: they are the
|
|
157
|
+
// consumer's teardown hook, and a corrupt-on-shutdown auth store is
|
|
158
|
+
// exactly when they most need to run.
|
|
159
|
+
//
|
|
160
|
+
// The flag makes a re-entrant `end()` from inside one of them a
|
|
161
|
+
// no-op instead of a deadlock: shared cleanup used both directly and
|
|
162
|
+
// as an end hook would otherwise be handed the very promise that is
|
|
163
|
+
// waiting for it to return, and nothing would ever settle — no
|
|
164
|
+
// release, and no terminal close until the watchdog.
|
|
165
|
+
runningEndHandlers = true;
|
|
166
|
+
try {
|
|
167
|
+
for (const handler of socketEndHandlers) {
|
|
168
|
+
try {
|
|
169
|
+
await handler(error);
|
|
170
|
+
}
|
|
171
|
+
catch (handlerError) {
|
|
172
|
+
logger.error({ err: handlerError }, 'error in socket end handler');
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
finally {
|
|
177
|
+
runningEndHandlers = false;
|
|
178
|
+
}
|
|
179
|
+
if (firstFlushError)
|
|
180
|
+
throw firstFlushError;
|
|
181
|
+
},
|
|
182
|
+
release: async (client) => {
|
|
183
|
+
// `disconnect()` before `free()` is defence in depth, not a fix for a
|
|
184
|
+
// reproduced bug on this path.
|
|
185
|
+
//
|
|
186
|
+
// The hazard is real and reproducible at the bridge: freeing a client
|
|
187
|
+
// with any call still pending corrupts the wasm heap — dlmalloc trips
|
|
188
|
+
// `assertion failed: psize <= size + max_overhead` and the process
|
|
189
|
+
// dies on `RuntimeError: unreachable`, from a microtask no try/catch
|
|
190
|
+
// here can reach, since `free()` itself returns normally.
|
|
191
|
+
// `logout()`, `disconnect()` and a plain `fetchBlocklist()` all
|
|
192
|
+
// reproduce it — see `__tests__/bridge-free-safety.test.ts`.
|
|
193
|
+
//
|
|
194
|
+
// What keeps teardown off that path is the `ws.close()` above, which
|
|
195
|
+
// is itself a `client.disconnect()` (`Compatibility/websocket-client.ts`).
|
|
196
|
+
// This is the belt to that braces, and the gap it closes is
|
|
197
|
+
// `WebSocketClient.close()`'s early return when `closing`/`closed` is
|
|
198
|
+
// already set: that path does NOT await the disconnect it skipped, so
|
|
199
|
+
// `void sock.ws.close(); await sock.end()` could otherwise reach
|
|
200
|
+
// `free()` with the first disconnect still running.
|
|
201
|
+
let disconnected = true;
|
|
202
|
+
try {
|
|
203
|
+
await client.disconnect();
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
disconnected = false;
|
|
207
|
+
}
|
|
208
|
+
// Teardown already flushed, but only after its own disconnect
|
|
209
|
+
// attempts. If those all failed and this one succeeded, the
|
|
210
|
+
// closing-session ratchet writes it enqueues arrived after that
|
|
211
|
+
// flush — with nothing left to persist them. Cheap enough to just
|
|
212
|
+
// drain again.
|
|
213
|
+
if (disconnected) {
|
|
214
|
+
const lateFlushError = await flushStores();
|
|
215
|
+
if (lateFlushError)
|
|
216
|
+
logger.error({ err: lateFlushError }, 'failed to flush after the final disconnect');
|
|
217
|
+
}
|
|
218
|
+
// Unregister before freeing: `free()` is swallowed, so ordering it
|
|
219
|
+
// last would leave the module-level pointer aimed at a client that is
|
|
220
|
+
// already gone if anything between them threw.
|
|
221
|
+
_unregisterActiveBridgeClient(client);
|
|
222
|
+
try {
|
|
223
|
+
client.free();
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
/* ignore */
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
const ws = new WebSocketClient(fullConfig.waWebSocketUrl, fullConfig, () => owner.peek());
|
|
79
231
|
let tagEpoch = 0;
|
|
80
232
|
// Per-socket random prefix avoids collisions between sockets created
|
|
81
233
|
// in the same millisecond. Date.now()-based prefixes (the previous
|
|
@@ -91,6 +243,12 @@ const makeWASocket = (config) => {
|
|
|
91
243
|
// `end()` to drain the debounced `saveCreds` timer — `auth.store?.flush?.()`
|
|
92
244
|
// covers the explicit-store path but not this one.
|
|
93
245
|
let autoWrappedStore;
|
|
246
|
+
// Mirrors the engine's `enable_auto_reconnect`, which defaults to on. Only
|
|
247
|
+
// `sock.setAutoReconnect()` moves it, and the dispatcher reads it to tell a
|
|
248
|
+
// transient drop from a terminal one.
|
|
249
|
+
let autoReconnectEnabled = true;
|
|
250
|
+
/** Owns reporting the terminal close: once, after teardown, never not at all. */
|
|
251
|
+
const terminalClose = makeTerminalCloseReporter({ logger });
|
|
94
252
|
const ctx = {
|
|
95
253
|
ev,
|
|
96
254
|
logger,
|
|
@@ -108,21 +266,58 @@ const makeWASocket = (config) => {
|
|
|
108
266
|
user = u;
|
|
109
267
|
},
|
|
110
268
|
getClient: () => {
|
|
111
|
-
|
|
112
|
-
|
|
269
|
+
// `peek()` keeps returning the client through `closing` so teardown
|
|
270
|
+
// can still close the transport with it — but that is teardown's
|
|
271
|
+
// client, not everyone's. Handing it to an ordinary call racing
|
|
272
|
+
// shutdown, or made from an end handler, starts a bridge operation
|
|
273
|
+
// while the client is being disconnected and freed, which is the
|
|
274
|
+
// heap-corruption hazard the whole teardown ordering exists to
|
|
275
|
+
// avoid. Refuse from the moment `close()` is called.
|
|
276
|
+
if (owner.isClosing()) {
|
|
277
|
+
return Promise.reject(new Boom('Connection Closed', { statusCode: DisconnectReason.connectionClosed }));
|
|
278
|
+
}
|
|
279
|
+
// Otherwise gated on `initialized`, not merely on the client
|
|
280
|
+
// existing. `adopt()` publishes it several awaits before
|
|
281
|
+
// `setDeviceProps`, the account lookups and `run()`, so keying off
|
|
282
|
+
// `peek()` alone would hand ordinary calls like `sendMessage()` a
|
|
283
|
+
// half-built client whose read loop has not started — and skip the
|
|
284
|
+
// `initError` check when startup later fails.
|
|
285
|
+
if (initialized) {
|
|
286
|
+
const ready = owner.peek();
|
|
287
|
+
if (ready)
|
|
288
|
+
return Promise.resolve(ready);
|
|
289
|
+
}
|
|
113
290
|
return initPromise.then(() => {
|
|
291
|
+
// Rechecked after the await: a close landing while startup was
|
|
292
|
+
// still running would otherwise be handed the client anyway.
|
|
293
|
+
//
|
|
294
|
+
// The window between handing a client back and the call reaching
|
|
295
|
+
// wasm cannot be closed here — that needs in-flight call
|
|
296
|
+
// tracking. What covers it is `release`, which awaits
|
|
297
|
+
// `client.disconnect()` before `free()`; the corruption comes
|
|
298
|
+
// from freeing with a call pending, and the disconnect drains
|
|
299
|
+
// those first (`__tests__/bridge-free-safety.test.ts`).
|
|
300
|
+
if (owner.isClosing()) {
|
|
301
|
+
throw new Boom('Connection Closed', { statusCode: DisconnectReason.connectionClosed });
|
|
302
|
+
}
|
|
114
303
|
if (initError) {
|
|
115
304
|
throw new Boom('Bridge client failed to initialize: ' + initError.message, { statusCode: 500 });
|
|
116
305
|
}
|
|
117
|
-
|
|
306
|
+
const built = owner.peek();
|
|
307
|
+
if (!built)
|
|
118
308
|
throw new Boom('Client not initialized', { statusCode: 500 });
|
|
119
|
-
return
|
|
309
|
+
return built;
|
|
120
310
|
});
|
|
121
311
|
},
|
|
122
312
|
getClientSync: () => {
|
|
123
|
-
|
|
313
|
+
// Same rule as `getClient` — see there.
|
|
314
|
+
if (owner.isClosing()) {
|
|
315
|
+
throw new Boom('Connection Closed', { statusCode: DisconnectReason.connectionClosed });
|
|
316
|
+
}
|
|
317
|
+
const built = owner.peek();
|
|
318
|
+
if (!built)
|
|
124
319
|
throw new Boom('Client not initialized', { statusCode: 500 });
|
|
125
|
-
return
|
|
320
|
+
return built;
|
|
126
321
|
}
|
|
127
322
|
};
|
|
128
323
|
// The native repository delegates Signal state directly to the core and does
|
|
@@ -140,6 +335,7 @@ const makeWASocket = (config) => {
|
|
|
140
335
|
const appStatePatchMutex = makeMutex();
|
|
141
336
|
const notificationMutex = makeMutex();
|
|
142
337
|
const activeCallContexts = new Map();
|
|
338
|
+
socketEndHandlers.push(() => activeCallContexts.clear());
|
|
143
339
|
const groupMethods = makeGroupMethods(ctx);
|
|
144
340
|
const communityMethods = makeCommunityMethods(ctx, groupMethods);
|
|
145
341
|
const refreshParticipating = makeParticipatingRefreshHandler(ctx, {
|
|
@@ -149,7 +345,8 @@ const makeWASocket = (config) => {
|
|
|
149
345
|
const eventHandlers = makeEventHandlers(ctx, {
|
|
150
346
|
onPairSuccess: data => {
|
|
151
347
|
pairedAccount = data;
|
|
152
|
-
|
|
348
|
+
owner
|
|
349
|
+
.peek()
|
|
153
350
|
?.getAccount?.()
|
|
154
351
|
.then((acc) => {
|
|
155
352
|
cachedAccount = acc ?? undefined;
|
|
@@ -165,7 +362,34 @@ const makeWASocket = (config) => {
|
|
|
165
362
|
activeCallContexts.set(callId, { peer: event.from, callCreator });
|
|
166
363
|
}
|
|
167
364
|
},
|
|
168
|
-
onDirtyState: event => refreshParticipating(event.dirtyType)
|
|
365
|
+
onDirtyState: event => refreshParticipating(event.dirtyType),
|
|
366
|
+
/**
|
|
367
|
+
* The engine has stopped reconnecting, so this client is dead weight
|
|
368
|
+
* that only `free()` reclaims — `run()` returns `void`, so its loop
|
|
369
|
+
* exiting is otherwise invisible from here.
|
|
370
|
+
*
|
|
371
|
+
* Tearing down and reporting are both handed to the reporter: the close
|
|
372
|
+
* has to reach the consumer exactly once and only after this socket has
|
|
373
|
+
* released what it owns, or a replacement built in response overlaps it
|
|
374
|
+
* on the same auth folder.
|
|
375
|
+
*/
|
|
376
|
+
onTerminalClose: (error, publish) => {
|
|
377
|
+
// `owner.close()`, not `end()`. `end()` short-circuits when called
|
|
378
|
+
// from inside an end handler — it has to, or the handler awaits the
|
|
379
|
+
// teardown waiting for it — and a terminal event raised from one of
|
|
380
|
+
// those would then publish against an already-resolved promise,
|
|
381
|
+
// letting a close listener build a replacement while the old client
|
|
382
|
+
// is still owned. This waits for the real teardown, and cannot
|
|
383
|
+
// deadlock because `reportAfter` runs it detached; nothing in the
|
|
384
|
+
// teardown is waiting on this.
|
|
385
|
+
terminalClose.reportAfter(() => owner.close(error).finally(() => initPromise), publish);
|
|
386
|
+
},
|
|
387
|
+
isAutoReconnectEnabled: () => autoReconnectEnabled,
|
|
388
|
+
// Timers the dispatcher armed outlive the events that armed them, and
|
|
389
|
+
// only the terminal-close path clears them. Ending the socket any other
|
|
390
|
+
// way — `sock.end()`, an `await using` scope exiting — has to as well,
|
|
391
|
+
// or one fires from a socket whose client is already freed.
|
|
392
|
+
onCleanup: cleanup => socketEndHandlers.push(cleanup)
|
|
169
393
|
});
|
|
170
394
|
const init = async () => {
|
|
171
395
|
if (!wasmInitialized) {
|
|
@@ -211,25 +435,50 @@ const makeWASocket = (config) => {
|
|
|
211
435
|
}
|
|
212
436
|
if (useNativeMemory)
|
|
213
437
|
logger.debug('auth: using socket-local native memory backend');
|
|
214
|
-
|
|
438
|
+
const created = await createWhatsAppClient(makeTransport(fullConfig), makeHttpClient(fullConfig), eventHandlers, bridgeStore, fullConfig.cache ?? null, fullConfig.version, fullConfig.wantedPreKeyCount ?? null);
|
|
439
|
+
// `end()` can land while the client is still being built — a `sock.end()`
|
|
440
|
+
// or `await using` right after `makeWASocket()` does exactly that. When
|
|
441
|
+
// it has, `adopt` frees this client and tells us to stop: nothing else
|
|
442
|
+
// would ever own it, and `run()` below would reconnect it forever
|
|
443
|
+
// against a socket the caller already disposed.
|
|
444
|
+
// `adopt` starts releasing the refused client; joining it here keeps
|
|
445
|
+
// that work inside `initPromise`, which `Symbol.asyncDispose` awaits.
|
|
446
|
+
if (!owner.adopt(created))
|
|
447
|
+
return owner.settled();
|
|
448
|
+
// Fallback for standalone helpers like `downloadContentFromMessage`
|
|
449
|
+
// that carry no socket reference.
|
|
450
|
+
_registerActiveBridgeClient(created, logger);
|
|
451
|
+
// Replay a preference set before the client existed. `setAutoReconnect`
|
|
452
|
+
// forwards through `client?.`, so `makeWASocket(cfg).setAutoReconnect(false)`
|
|
453
|
+
// — the idiomatic first line — used to move only the JS mirror and leave
|
|
454
|
+
// the engine retrying.
|
|
455
|
+
if (!autoReconnectEnabled)
|
|
456
|
+
created.setAutoReconnect(false);
|
|
457
|
+
// Everything below talks to `created`, which stays valid for the whole
|
|
458
|
+
// body, and re-checks `isClosing()` between awaits: once teardown has
|
|
459
|
+
// started it owns this client, and issuing more bridge calls against it
|
|
460
|
+
// races the release.
|
|
215
461
|
if (fullConfig.pushName) {
|
|
216
|
-
await
|
|
462
|
+
await created.setInitialPushName(fullConfig.pushName);
|
|
217
463
|
}
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
_registerActiveBridgeClient(client, logger);
|
|
464
|
+
if (owner.isClosing())
|
|
465
|
+
return;
|
|
221
466
|
const [osName, browserName] = fullConfig.browser;
|
|
222
467
|
const deviceOs = browserName === 'Android' ? 'Android' : osName;
|
|
223
|
-
await
|
|
468
|
+
await created.setDeviceProps({
|
|
224
469
|
os: deviceOs,
|
|
225
470
|
platformType: browserToPlatformType(browserName),
|
|
226
471
|
...fullConfig.deviceProps
|
|
227
472
|
});
|
|
473
|
+
if (owner.isClosing())
|
|
474
|
+
return;
|
|
228
475
|
const [jid, lid, account] = await Promise.all([
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
476
|
+
created.getJid(),
|
|
477
|
+
created.getLid(),
|
|
478
|
+
created.getAccount().catch(() => undefined)
|
|
232
479
|
]);
|
|
480
|
+
if (owner.isClosing())
|
|
481
|
+
return;
|
|
233
482
|
if (jid) {
|
|
234
483
|
user = { id: jid, lid: lid ?? undefined };
|
|
235
484
|
}
|
|
@@ -240,129 +489,145 @@ const makeWASocket = (config) => {
|
|
|
240
489
|
// `UserAgent.platform = ANDROID` (no `web_info`), mirroring upstream
|
|
241
490
|
// Baileys PR #2201. Required for the server to deliver view_once payloads.
|
|
242
491
|
if (browserName === 'Android') {
|
|
243
|
-
await
|
|
492
|
+
await created.setClientProfile({ preset: 'android', osVersion: osName });
|
|
493
|
+
if (owner.isClosing())
|
|
494
|
+
return;
|
|
244
495
|
}
|
|
245
496
|
if (isRawNodeForwardingEnabled(ws)) {
|
|
246
|
-
|
|
247
|
-
}
|
|
248
|
-
// `run()` is fire-and-forget by design (the bridge runs the read
|
|
249
|
-
// loop until disconnect/free) but typed `Promise<void>`. A late
|
|
250
|
-
// rejection (lost connection during cleanup, etc) without a
|
|
251
|
-
// `.catch` would escape to `process.on('unhandledRejection')`.
|
|
252
|
-
// Funnel into the connection.update channel so consumers' regular
|
|
253
|
-
// reconnect/diagnostic plumbing handles it like any other close.
|
|
254
|
-
const runPromise = client.run();
|
|
255
|
-
if (runPromise && typeof runPromise.catch === 'function') {
|
|
256
|
-
runPromise.catch(err => {
|
|
257
|
-
logger.error({ err }, 'bridge client.run() rejected');
|
|
258
|
-
ev.emit('connection.update', {
|
|
259
|
-
connection: 'close',
|
|
260
|
-
lastDisconnect: {
|
|
261
|
-
// restartRequired (515) signals "Rust read loop crashed
|
|
262
|
-
// unrecoverably, restart the sock". Previously mapped to
|
|
263
|
-
// 500, which collided with the server-side <stream:error
|
|
264
|
-
// code="500"> path and made it impossible for consumers
|
|
265
|
-
// to tell the two apart.
|
|
266
|
-
error: err instanceof Error ? err : new Boom(String(err), { statusCode: DisconnectReason.restartRequired }),
|
|
267
|
-
date: new Date()
|
|
268
|
-
}
|
|
269
|
-
});
|
|
270
|
-
});
|
|
497
|
+
created.setRawNodeForwarding(true);
|
|
271
498
|
}
|
|
499
|
+
// Same race as above: teardown already owns and releases this client, so
|
|
500
|
+
// starting the read loop now would run against a handle about to go.
|
|
501
|
+
if (owner.isClosing())
|
|
502
|
+
return;
|
|
503
|
+
// `run()` spawns the connect/handshake/read/reconnect loop as a
|
|
504
|
+
// background task and returns `void` — it deliberately is not `async`,
|
|
505
|
+
// so that it does not hold a wasm-bindgen borrow on `self` that would
|
|
506
|
+
// block `disconnect()`.
|
|
507
|
+
//
|
|
508
|
+
// Consequence: the loop's exit is not observable from here. The engine
|
|
509
|
+
// clears `enable_auto_reconnect` and breaks out on every terminal
|
|
510
|
+
// disconnect (conflict/401/409/516, and any `<failure>` whose reason is
|
|
511
|
+
// not 500/503), and when it does, the `WasmWhatsAppClient` is dead
|
|
512
|
+
// weight that only `sock.end()` can free — nothing else can, because
|
|
513
|
+
// the bridge holds the JS event callbacks as wasm-bindgen externrefs,
|
|
514
|
+
// those close over `ctx`, and `ctx` closes over `client`, so the cycle
|
|
515
|
+
// crosses the JS/wasm boundary and no `FinalizationRegistry` fires.
|
|
516
|
+
// Freeing that automatically needs the bridge to expose loop completion
|
|
517
|
+
// (a terminal callback or an awaitable handle); until it does, the
|
|
518
|
+
// consumer has to call `sock.end()` on a terminal close.
|
|
519
|
+
created.run();
|
|
520
|
+
initialized = true;
|
|
521
|
+
};
|
|
522
|
+
/**
|
|
523
|
+
* Joins startup too, not just the teardown.
|
|
524
|
+
*
|
|
525
|
+
* `owner.close()` covers the client it can see. A close landing while
|
|
526
|
+
* `createWhatsAppClient()` is still pending sees none — the client arrives
|
|
527
|
+
* afterwards, `adopt()` refuses it, and the release runs detached. Without
|
|
528
|
+
* waiting for `init()` to finish, `await sock.end()` therefore returns while
|
|
529
|
+
* that client is still disconnecting and being freed, and the replacement
|
|
530
|
+
* socket the consumer builds next overlaps it on the same auth folder.
|
|
531
|
+
*
|
|
532
|
+
* `initPromise` is declared below and swallows its own failures, so this
|
|
533
|
+
* neither hits its TDZ (nothing can call `end` during the synchronous
|
|
534
|
+
* construction below) nor masks the teardown error.
|
|
535
|
+
*/
|
|
536
|
+
const end = (error) => {
|
|
537
|
+
// Called from inside an end handler, the teardown is already running and
|
|
538
|
+
// is waiting for that handler to return. Handing back its promise would
|
|
539
|
+
// have the handler await itself.
|
|
540
|
+
if (runningEndHandlers)
|
|
541
|
+
return Promise.resolve();
|
|
542
|
+
return owner.close(error).finally(() => initPromise);
|
|
272
543
|
};
|
|
273
544
|
let initError;
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
545
|
+
// Started only once `end` exists. `init()`'s synchronous prefix reaches
|
|
546
|
+
// `await createWhatsAppClient(...)` before the bridge can dispatch anything,
|
|
547
|
+
// so today nothing can call `onTerminalClose` — and therefore `end` — that
|
|
548
|
+
// early. But that is an argument about the current shape of `init()`, not a
|
|
549
|
+
// rule the code enforces: dispatch a terminal close any sooner and line 401
|
|
550
|
+
// becomes a `ReferenceError` inside a bridge callback, where no `try/catch`
|
|
551
|
+
// of ours can reach it. Ordering it here makes the dependency structural.
|
|
552
|
+
const initPromise = init().catch(err => {
|
|
280
553
|
initError = err instanceof Error ? err : new Error(String(err));
|
|
281
554
|
logger.error({ err }, 'failed to initialize bridge client');
|
|
555
|
+
// A client adopted before the failure outlives a read loop that never
|
|
556
|
+
// started: `getClient()` correctly rejects, but the standalone
|
|
557
|
+
// helpers bypass it and would keep reaching the half-built client.
|
|
558
|
+
return owner.discard();
|
|
282
559
|
});
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
}
|
|
292
|
-
ended = true;
|
|
293
|
-
const c = client;
|
|
294
|
-
if (c) {
|
|
295
|
-
try {
|
|
296
|
-
await ws.close();
|
|
297
|
-
}
|
|
298
|
-
catch {
|
|
299
|
-
/* ignore */
|
|
300
|
-
}
|
|
301
|
-
client = undefined;
|
|
302
|
-
readyClient = undefined;
|
|
303
|
-
// Barrier: bridge cleanup paths fired during `disconnect()` may
|
|
304
|
-
// emit `set()` calls that are still queued as microtasks /
|
|
305
|
-
// `setImmediate` callbacks at this point. Two yields to the
|
|
306
|
-
// event loop drain (1) microtask queue and (2) the next
|
|
307
|
-
// macrotask tick where wasm-bindgen async callbacks land.
|
|
308
|
-
// Without this barrier, the flushes below run before the bridge
|
|
309
|
-
// has finished writing — a race that loses the last few sets
|
|
310
|
-
// (typically the closing-session ratchet step).
|
|
311
|
-
await new Promise(resolve => setImmediate(resolve));
|
|
312
|
-
await new Promise(resolve => setImmediate(resolve));
|
|
313
|
-
// Capture the FIRST flush failure so a corrupt-on-shutdown auth
|
|
314
|
-
// state surfaces to the caller. Always finish the rest of
|
|
315
|
-
// cleanup; rethrow at the end so c.free() still runs.
|
|
316
|
-
let firstFlushError;
|
|
317
|
-
try {
|
|
318
|
-
await auth.store?.flush?.();
|
|
319
|
-
}
|
|
320
|
-
catch (e) {
|
|
321
|
-
firstFlushError ?? (firstFlushError = e);
|
|
322
|
-
}
|
|
323
|
-
try {
|
|
324
|
-
await autoWrappedStore?.flush?.();
|
|
325
|
-
}
|
|
326
|
-
catch (e) {
|
|
327
|
-
firstFlushError ?? (firstFlushError = e);
|
|
328
|
-
}
|
|
329
|
-
try {
|
|
330
|
-
c.free();
|
|
331
|
-
}
|
|
332
|
-
catch {
|
|
333
|
-
/* ignore */
|
|
334
|
-
}
|
|
335
|
-
if (firstFlushError)
|
|
336
|
-
throw firstFlushError;
|
|
337
|
-
}
|
|
338
|
-
for (const handler of socketEndHandlers) {
|
|
339
|
-
try {
|
|
340
|
-
await handler(error);
|
|
341
|
-
}
|
|
342
|
-
catch (handlerError) {
|
|
343
|
-
logger.error({ err: handlerError }, 'error in socket end handler');
|
|
344
|
-
}
|
|
345
|
-
}
|
|
346
|
-
};
|
|
560
|
+
/**
|
|
561
|
+
* Idempotent shutdown that later callers can actually await — the socket
|
|
562
|
+
* now ends *itself* on a terminal disconnect, so a consumer's
|
|
563
|
+
* `await sock.end()` is usually the second call. Resolving it early while
|
|
564
|
+
* the first was still flushing turned
|
|
565
|
+
* `close` → `await sock.end()` → `makeWASocket()` into two writers on one
|
|
566
|
+
* auth folder.
|
|
567
|
+
*/
|
|
347
568
|
const logout = async (msg) => {
|
|
348
569
|
user = undefined;
|
|
349
|
-
|
|
570
|
+
const logoutError = new Boom(msg || 'Logged out', { statusCode: DisconnectReason.loggedOut });
|
|
571
|
+
// `Client::logout()` dispatches `LoggedOut` itself, which the dispatcher
|
|
572
|
+
// turns into the terminal close. Reporting our own on top of that gave
|
|
573
|
+
// consumers two `close` events for one logout, and upstream guarantees
|
|
574
|
+
// at most one — so watch for the dispatcher's instead of assuming
|
|
575
|
+
// either way. Counting rather than flagging, because a terminal close
|
|
576
|
+
// may already have happened earlier in this socket's life.
|
|
577
|
+
// `Client::logout()` dispatches `LoggedOut` itself, which the dispatcher
|
|
578
|
+
// turns into the terminal close. Reporting our own on top of that gave
|
|
579
|
+
// consumers two closes for one logout, and upstream guarantees at most
|
|
580
|
+
// one — so watch for the dispatcher's instead of assuming either way.
|
|
581
|
+
const reportedBefore = terminalClose.hasReported();
|
|
582
|
+
const live = owner.peek();
|
|
583
|
+
if (live) {
|
|
350
584
|
try {
|
|
351
|
-
await
|
|
585
|
+
await live.logout();
|
|
352
586
|
}
|
|
353
587
|
catch {
|
|
354
588
|
/* ignore */
|
|
355
589
|
}
|
|
356
590
|
}
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
591
|
+
// Nothing announced it: no client at all, or `logout()` threw before the
|
|
592
|
+
// bridge got to it. Upstream always reports exactly one close for a
|
|
593
|
+
// logout, so emitting none would be worse than emitting one too many.
|
|
594
|
+
// Keyed on a close having been *reported*, not on the socket closing —
|
|
595
|
+
// a plain `end()` reports nothing, so keying off that would let a logout
|
|
596
|
+
// racing one finish with no close at all.
|
|
597
|
+
const mayNeedFallback = !reportedBefore && !terminalClose.hasReported();
|
|
598
|
+
try {
|
|
599
|
+
await end(logoutError);
|
|
600
|
+
}
|
|
601
|
+
finally {
|
|
602
|
+
// After the teardown, like the dispatcher's own close: doing it
|
|
603
|
+
// before would hand a logged-out handler a socket still flushing the
|
|
604
|
+
// auth folder it is about to delete. In a `finally` because `end()`
|
|
605
|
+
// rethrows the first flush failure and the owner releases the client
|
|
606
|
+
// regardless — on that path listeners would otherwise see no
|
|
607
|
+
// terminal close at all for a socket that has definitely ended.
|
|
608
|
+
// Rechecked here, not just before the await: a dispatcher close
|
|
609
|
+
// arriving while `end()` ran would otherwise be followed by this
|
|
610
|
+
// stale decision, giving consumers two closes for one logout.
|
|
611
|
+
if (mayNeedFallback && !terminalClose.hasReported()) {
|
|
612
|
+
terminalClose.reportNow(() => ev.emit('connection.update', {
|
|
613
|
+
connection: 'close',
|
|
614
|
+
lastDisconnect: {
|
|
615
|
+
error: logoutError,
|
|
616
|
+
date: new Date()
|
|
617
|
+
}
|
|
618
|
+
}));
|
|
363
619
|
}
|
|
364
|
-
}
|
|
365
|
-
|
|
620
|
+
}
|
|
621
|
+
// The close is published on a chain one hop further out than the
|
|
622
|
+
// teardown both paths await, so without this `await sock.logout()`
|
|
623
|
+
// returns just before the event it caused.
|
|
624
|
+
//
|
|
625
|
+
// Skipped when re-entered from an end handler, for the same reason
|
|
626
|
+
// `end()` short-circuits there: the publish waits for the teardown, the
|
|
627
|
+
// teardown waits for the handler, and the handler would be waiting here
|
|
628
|
+
// — stalled until the watchdog fires.
|
|
629
|
+
if (!runningEndHandlers)
|
|
630
|
+
await terminalClose.published();
|
|
366
631
|
};
|
|
367
632
|
const registerSocketEndHandler = (handler) => {
|
|
368
633
|
socketEndHandlers.push(handler);
|
|
@@ -428,6 +693,39 @@ const makeWASocket = (config) => {
|
|
|
428
693
|
logger,
|
|
429
694
|
ws,
|
|
430
695
|
type: 'md',
|
|
696
|
+
/**
|
|
697
|
+
* `await using sock = makeWASocket(config)` — frees the wasm client on
|
|
698
|
+
* scope exit. Nothing else can: the bridge holds the JS event callbacks
|
|
699
|
+
* as wasm-bindgen externrefs and those callbacks reach back to this
|
|
700
|
+
* closure, so the reference cycle crosses the JS/wasm boundary and no
|
|
701
|
+
* `FinalizationRegistry` will ever fire for the client.
|
|
702
|
+
*
|
|
703
|
+
* Delegates to `end()`, so it flushes the auth store and is idempotent.
|
|
704
|
+
*
|
|
705
|
+
* Then awaits `initPromise`, because `end()` alone does not satisfy the
|
|
706
|
+
* async-disposal contract: called before `createWhatsAppClient()`
|
|
707
|
+
* settles, it sees `client === undefined`, frees nothing, and resolves
|
|
708
|
+
* while initialization is still running. What actually disposes that
|
|
709
|
+
* client is the `ended` guard inside `init()` — so code after the
|
|
710
|
+
* `await using` scope would otherwise overlap with bridge construction,
|
|
711
|
+
* event callbacks, and store access. `end()` runs first because it sets
|
|
712
|
+
* `ended` synchronously, which is what makes `init()` bail out early.
|
|
713
|
+
*
|
|
714
|
+
* The `finally` is load-bearing: `end()` rethrows the first auth-store
|
|
715
|
+
* flush failure, and letting that propagate directly would skip the wait
|
|
716
|
+
* and resolve the disposer with `init()` still in flight — losing the
|
|
717
|
+
* one guarantee it exists to provide, in exactly the situation where
|
|
718
|
+
* cleanup already went wrong. `initPromise` swallows its own failures,
|
|
719
|
+
* so awaiting it here cannot mask the flush error.
|
|
720
|
+
*/
|
|
721
|
+
async [Symbol.asyncDispose]() {
|
|
722
|
+
try {
|
|
723
|
+
await end(undefined);
|
|
724
|
+
}
|
|
725
|
+
finally {
|
|
726
|
+
await initPromise;
|
|
727
|
+
}
|
|
728
|
+
},
|
|
431
729
|
// Upstream `socket.ts:1106-1108` returns `authState.creds.me`, which
|
|
432
730
|
// carries `{ id, lid, name, verifiedName, ... }` — full Contact
|
|
433
731
|
// shape. Returning the bare `{id, lid}` like before broke
|
|
@@ -446,13 +744,13 @@ const makeWASocket = (config) => {
|
|
|
446
744
|
};
|
|
447
745
|
},
|
|
448
746
|
get waClient() {
|
|
449
|
-
return
|
|
747
|
+
return owner.peek();
|
|
450
748
|
},
|
|
451
749
|
get isConnected() {
|
|
452
|
-
return
|
|
750
|
+
return owner.peek()?.isConnected() ?? false;
|
|
453
751
|
},
|
|
454
752
|
get isLoggedIn() {
|
|
455
|
-
return
|
|
753
|
+
return owner.peek()?.isLoggedIn() ?? false;
|
|
456
754
|
},
|
|
457
755
|
get authState() {
|
|
458
756
|
return {
|
|
@@ -496,7 +794,10 @@ const makeWASocket = (config) => {
|
|
|
496
794
|
registerSocketEndHandler,
|
|
497
795
|
waitForConnectionUpdate,
|
|
498
796
|
setAutoReconnect: (enabled) => {
|
|
499
|
-
|
|
797
|
+
// Mirrored locally because the dispatcher has to know: with this off,
|
|
798
|
+
// a plain drop is terminal rather than the start of a backoff.
|
|
799
|
+
autoReconnectEnabled = enabled;
|
|
800
|
+
owner.peek()?.setAutoReconnect(enabled);
|
|
500
801
|
},
|
|
501
802
|
/**
|
|
502
803
|
* Update presence either globally (`available`/`unavailable`) or per-chat
|