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