@oxidezap/baileyrs 0.2.12 → 0.3.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.
@@ -31,6 +31,7 @@ import { makeBridgeClientOwner } from './bridge-client-owner.js';
31
31
  import { warnUnsupportedConfig } from './unsupported-config.js';
32
32
  import { wrapBridgeClient } from './bridge-error-boundary.js';
33
33
  import { makeTerminalCloseReporter } from './terminal-close-reporter.js';
34
+ import { mapConnectFailureToDisconnect } from './terminal-close.js';
34
35
  import { makeEventHandlers } from './events.js';
35
36
  import { makeGroupMethods } from './groups.js';
36
37
  import { makeInternalMethods, makeUnexpectedErrorReporter } from './internals.js';
@@ -43,6 +44,7 @@ import { makeServerQueryMethods } from './server-queries.js';
43
44
  import { makeProfileMethods } from './profile.js';
44
45
  import { mapReachoutTimelock } from './reachout.js';
45
46
  import { makeHttpClient, makeTransport } from './transport.js';
47
+ import { makeWithClient } from './client-operations.js';
46
48
  import { makeUSyncMethods } from './usync.js';
47
49
  let wasmInitialized = false;
48
50
  /**
@@ -71,6 +73,29 @@ const browserToPlatformType = (browser) => {
71
73
  return 'CHROME';
72
74
  }
73
75
  };
76
+ const COMPLETION_FAILURE_CODES = new Map([
77
+ ['Generic', 400],
78
+ ['LoggedOut', 401],
79
+ ['TempBanned', 402],
80
+ ['AccountLocked', 403],
81
+ ['UnknownLogout', 406],
82
+ ['ClientOutdated', 405],
83
+ ['BadUserAgent', 409],
84
+ ['CatExpired', 413],
85
+ ['CatInvalid', 414],
86
+ ['NotFound', 415],
87
+ ['ClientUnknown', 418],
88
+ ['InternalServerError', 500],
89
+ ['Experimental', 501],
90
+ ['ServiceUnavailable', 503]
91
+ ]);
92
+ const completionFailureCode = (reason) => {
93
+ const named = COMPLETION_FAILURE_CODES.get(reason);
94
+ if (named !== undefined)
95
+ return named;
96
+ const unknown = /^Unknown\((-?\d+)\)$/.exec(reason)?.[1];
97
+ return unknown === undefined ? undefined : Number(unknown);
98
+ };
74
99
  /** Build the ws EventEmitter with auto-enable raw node forwarding */
75
100
  const makeWASocket = (config) => {
76
101
  const fullConfig = { ...DEFAULT_CONNECTION_CONFIG, ...config };
@@ -192,24 +217,19 @@ const makeWASocket = (config) => {
192
217
  throw firstFlushError;
193
218
  },
194
219
  release: async (client) => {
195
- // `disconnect()` before `free()` is defence in depth, not a fix for a
196
- // reproduced bug on this path.
197
- //
198
- // The hazard is real and reproducible at the bridge: freeing a client
199
- // with any call still pending corrupts the wasm heap — dlmalloc trips
200
- // `assertion failed: psize <= size + max_overhead` and the process
201
- // dies on `RuntimeError: unreachable`, from a microtask no try/catch
202
- // here can reach, since `free()` itself returns normally.
203
- // `logout()`, `disconnect()` and a plain `fetchBlocklist()` all
204
- // reproduce it — see `__tests__/bridge-free-safety.test.ts`.
220
+ // `disconnect()` before `free()` drains exactly one shape: a
221
+ // `disconnect()` still in flight. Since bridge 0.21.1, freeing
222
+ // with ordinary calls pending (`fetchBlocklist()`, `logout()`) is
223
+ // safe its `Drop` signals shutdown and aborts the background
224
+ // tasks but freeing mid-`disconnect()` still aborts the process
225
+ // (`async-lock` panicking while panicking). See
226
+ // `__tests__/bridge-free-safety.test.ts`.
205
227
  //
206
- // What keeps teardown off that path is the `ws.close()` above, which
207
- // is itself a `client.disconnect()` (`Compatibility/websocket-client.ts`).
208
- // This is the belt to that braces, and the gap it closes is
209
- // `WebSocketClient.close()`'s early return when `closing`/`closed` is
210
- // already set: that path does NOT await the disconnect it skipped, so
211
- // `void sock.ws.close(); await sock.end()` could otherwise reach
212
- // `free()` with the first disconnect still running.
228
+ // That shape is reachable: `WebSocketClient.close()` early-returns
229
+ // when `closing`/`closed` is already set without awaiting the
230
+ // disconnect it skipped, so `void sock.ws.close(); await sock.end()`
231
+ // could otherwise reach `free()` with the first disconnect still
232
+ // running. Awaiting it here is the belt to `ws.close()`'s braces.
213
233
  let disconnected = true;
214
234
  try {
215
235
  await client.disconnect();
@@ -261,6 +281,37 @@ const makeWASocket = (config) => {
261
281
  let autoReconnectEnabled = true;
262
282
  /** Owns reporting the terminal close: once, after teardown, never not at all. */
263
283
  const terminalClose = makeTerminalCloseReporter({ logger });
284
+ const runCompletionError = (completion) => {
285
+ let statusCode = DisconnectReason.connectionClosed;
286
+ let message = 'Connection closed';
287
+ if (completion.reason === 'unknown') {
288
+ message = `Connection run ended: ${completion.detail}`;
289
+ }
290
+ else if (completion.reason === 'stopped') {
291
+ message = 'Connection run stopped';
292
+ }
293
+ else if (completion.reason === 'already-running') {
294
+ message = 'Connection run was already running';
295
+ }
296
+ else if (completion.reason === 'auto-reconnect-disabled') {
297
+ const protocol = completion.protocolError;
298
+ if (protocol?.kind === 'conflict') {
299
+ statusCode = DisconnectReason.connectionReplaced;
300
+ message = 'Connection replaced';
301
+ }
302
+ else if (protocol?.kind === 'stream-error') {
303
+ statusCode = mapConnectFailureToDisconnect(protocol.code);
304
+ }
305
+ else if (protocol?.kind === 'connect-failure') {
306
+ statusCode = mapConnectFailureToDisconnect(completionFailureCode(protocol.reason));
307
+ }
308
+ else if (completion.connection?.kind === 'server-close') {
309
+ message = completion.connection.reason;
310
+ }
311
+ }
312
+ return new Boom(message, { statusCode, data: { runCompletion: completion } });
313
+ };
314
+ const reportTerminalClose = (error, publish) => terminalClose.reportAfter(() => owner.close(error).finally(() => initPromise), publish);
264
315
  /**
265
316
  * Held in a reporter rather than captured, because `sock.onUnexpectedError`
266
317
  * is an assignable property: a consumer that replaces it has to be the one
@@ -284,61 +335,42 @@ const makeWASocket = (config) => {
284
335
  setUser: u => {
285
336
  user = u;
286
337
  },
287
- getClient: () => {
288
- // `peek()` keeps returning the client through `closing` so teardown
289
- // can still close the transport with it — but that is teardown's
290
- // client, not everyone's. Handing it to an ordinary call racing
291
- // shutdown, or made from an end handler, starts a bridge operation
292
- // while the client is being disconnected and freed, which is the
293
- // heap-corruption hazard the whole teardown ordering exists to
294
- // avoid. Refuse from the moment `close()` is called.
295
- if (owner.isClosing()) {
296
- return Promise.reject(new Boom('Connection Closed', { statusCode: DisconnectReason.connectionClosed }));
297
- }
298
- // Otherwise gated on `initialized`, not merely on the client
299
- // existing. `adopt()` publishes it several awaits before
300
- // `setDeviceProps`, the account lookups and `run()`, so keying off
301
- // `peek()` alone would hand ordinary calls like `sendMessage()` a
302
- // half-built client whose read loop has not started — and skip the
303
- // `initError` check when startup later fails.
304
- if (initialized) {
305
- const ready = owner.peek();
306
- if (ready)
307
- return Promise.resolve(wrapBridgeClient(ready));
308
- }
309
- return initPromise.then(() => {
310
- // Rechecked after the await: a close landing while startup was
311
- // still running would otherwise be handed the client anyway.
312
- //
313
- // The window between handing a client back and the call reaching
314
- // wasm cannot be closed here — that needs in-flight call
315
- // tracking. What covers it is `release`, which awaits
316
- // `client.disconnect()` before `free()`; the corruption comes
317
- // from freeing with a call pending, and the disconnect drains
318
- // those first (`__tests__/bridge-free-safety.test.ts`).
319
- if (owner.isClosing()) {
320
- throw new Boom('Connection Closed', { statusCode: DisconnectReason.connectionClosed });
321
- }
322
- if (initError) {
323
- throw new Boom('Bridge client failed to initialize: ' + initError.message, { statusCode: 500 });
324
- }
325
- const built = owner.peek();
326
- if (!built)
327
- throw new Boom('Client not initialized', { statusCode: 500 });
328
- return wrapBridgeClient(built);
329
- });
330
- },
331
- getClientSync: () => {
332
- // Same rule as `getClient` — see there.
338
+ withClient: makeWithClient(getClient)
339
+ };
340
+ function getClient() {
341
+ // Teardown retains the client through closing to disconnect the transport.
342
+ // Ordinary operations must stop being admitted when close() is called.
343
+ if (owner.isClosing()) {
344
+ return Promise.reject(new Boom('Connection Closed', { statusCode: DisconnectReason.connectionClosed }));
345
+ }
346
+ // Otherwise gated on `initialized`, not merely on the client
347
+ // existing. `adopt()` publishes it several awaits before
348
+ // `setDeviceProps`, the account lookups and `run()`, so keying off
349
+ // `peek()` alone would hand ordinary calls like `sendMessage()` a
350
+ // half-built client whose read loop has not started — and skip the
351
+ // `initError` check when startup later fails.
352
+ if (initialized) {
353
+ const ready = owner.peek();
354
+ if (ready)
355
+ return Promise.resolve(wrapBridgeClient(ready));
356
+ }
357
+ return initPromise.then(() => {
358
+ // Closing may have started while initialization was pending.
359
+ // This gate does not track admitted operations. Bridge 0.21.1 tolerates
360
+ // free() during ordinary calls, but not during disconnect(); release
361
+ // still awaits that drain before freeing the client.
333
362
  if (owner.isClosing()) {
334
363
  throw new Boom('Connection Closed', { statusCode: DisconnectReason.connectionClosed });
335
364
  }
365
+ if (initError) {
366
+ throw new Boom('Bridge client failed to initialize: ' + initError.message, { statusCode: 500 });
367
+ }
336
368
  const built = owner.peek();
337
369
  if (!built)
338
370
  throw new Boom('Client not initialized', { statusCode: 500 });
339
371
  return wrapBridgeClient(built);
340
- }
341
- };
372
+ });
373
+ }
342
374
  // The native repository delegates Signal state directly to the core and does
343
375
  // not need the standalone transaction facade. Keep that facade lazy for the
344
376
  // public authState and custom repository contracts that can observe it.
@@ -401,7 +433,7 @@ const makeWASocket = (config) => {
401
433
  // is still owned. This waits for the real teardown, and cannot
402
434
  // deadlock because `reportAfter` runs it detached; nothing in the
403
435
  // teardown is waiting on this.
404
- terminalClose.reportAfter(() => owner.close(error).finally(() => initPromise), publish);
436
+ reportTerminalClose(error, publish);
405
437
  },
406
438
  isAutoReconnectEnabled: () => autoReconnectEnabled,
407
439
  // Timers the dispatcher armed outlive the events that armed them, and
@@ -527,23 +559,46 @@ const makeWASocket = (config) => {
527
559
  // starting the read loop now would run against a handle about to go.
528
560
  if (owner.isClosing())
529
561
  return;
530
- // `run()` spawns the connect/handshake/read/reconnect loop as a
531
- // background task and returns `void` it deliberately is not `async`,
532
- // so that it does not hold a wasm-bindgen borrow on `self` that would
533
- // block `disconnect()`.
534
- //
535
- // Consequence: the loop's exit is not observable from here. The engine
536
- // clears `enable_auto_reconnect` and breaks out on every terminal
537
- // disconnect (conflict/401/409/516, and any `<failure>` whose reason is
538
- // not 500/503), and when it does, the `WasmWhatsAppClient` is dead
539
- // weight that only `sock.end()` can free — nothing else can, because
540
- // the bridge holds the JS event callbacks as wasm-bindgen externrefs,
541
- // those close over `ctx`, and `ctx` closes over `client`, so the cycle
542
- // crosses the JS/wasm boundary and no `FinalizationRegistry` fires.
543
- // Freeing that automatically needs the bridge to expose loop completion
544
- // (a terminal callback or an awaitable handle); until it does, the
545
- // consumer has to call `sock.end()` on a terminal close.
562
+ // `run()` deliberately returns immediately so callers can use the
563
+ // client while supervision owns its background task. Registering the
564
+ // completion observer after it is started is safe: bridge 0.21.0 admits
565
+ // late observers against the stored result for this run generation.
546
566
  created.run();
567
+ const observedClient = created;
568
+ void created
569
+ .waitForRunCompletion()
570
+ .then(completion => {
571
+ // The owner identity is the socket generation fence. A completion
572
+ // from a client that teardown already released must never close a
573
+ // later socket using the same auth state.
574
+ if (owner.isClosing() || owner.peek() !== observedClient)
575
+ return;
576
+ const error = runCompletionError(completion);
577
+ reportTerminalClose(error, () => ev.emit('connection.update', {
578
+ connection: 'close',
579
+ lastDisconnect: { error, date: new Date() }
580
+ }));
581
+ }, error => {
582
+ if (owner.isClosing() || owner.peek() !== observedClient)
583
+ return;
584
+ const closeError = new Boom('Connection run ended without a completion result', {
585
+ statusCode: DisconnectReason.connectionClosed
586
+ });
587
+ reportTerminalClose(closeError, () => ev.emit('connection.update', {
588
+ connection: 'close',
589
+ lastDisconnect: { error: closeError, date: new Date() }
590
+ }));
591
+ try {
592
+ logger.error({ err: error }, 'bridge run completion observation failed');
593
+ }
594
+ catch {
595
+ // A consumer logger cannot prevent the terminal cleanup above.
596
+ }
597
+ })
598
+ .catch(() => {
599
+ // The reporter contains teardown and publish failures; this final guard
600
+ // also contains a consumer logger that throws from an observation path.
601
+ });
547
602
  initialized = true;
548
603
  };
549
604
  /**
@@ -698,7 +753,7 @@ const makeWASocket = (config) => {
698
753
  });
699
754
  };
700
755
  const fetchReachoutTimelock = async () => {
701
- const payload = await (await ctx.getClient()).fetchReachoutTimelock();
756
+ const payload = await ctx.withClient(client => client.fetchReachoutTimelock());
702
757
  const state = mapReachoutTimelock(payload) ?? { isActive: false };
703
758
  ev.emit('connection.update', { reachoutTimeLock: state });
704
759
  return state;
@@ -706,14 +761,14 @@ const makeWASocket = (config) => {
706
761
  const query = async (node, timeoutMs) => {
707
762
  if (!node.attrs.id)
708
763
  node.attrs.id = generateMessageTag();
709
- const result = (await (await ctx.getClient()).queryNode(node, timeoutMs));
764
+ const result = (await ctx.withClient(client => client.queryNode(node, timeoutMs)));
710
765
  assertNodeErrorFree(result);
711
766
  return result;
712
767
  };
713
768
  const waitForMessage = makeTaggedMessageWaiter(ws, logger, fullConfig.defaultQueryTimeoutMs);
714
769
  const usyncMethods = makeUSyncMethods({
715
770
  queryNode: query,
716
- queryUsync: async (typedQuery) => (await ctx.getClient()).queryUsync(typedQuery)
771
+ queryUsync: async (typedQuery) => ctx.withClient(client => client.queryUsync(typedQuery))
717
772
  });
718
773
  const sock = {
719
774
  ev,
@@ -798,18 +853,18 @@ const makeWASocket = (config) => {
798
853
  notificationMutex,
799
854
  generateMessageTag,
800
855
  sendNode: async (frame) => {
801
- return (await ctx.getClient()).sendNode(frame);
856
+ return ctx.withClient(client => client.sendNode(frame));
802
857
  },
803
858
  assertSessions: async (jids, force) => {
804
- return (await ctx.getClient()).assertSessions(jids, force ?? false);
859
+ return ctx.withClient(client => client.assertSessions(jids, force ?? false));
805
860
  },
806
861
  getUSyncDevices: async (jids, useCache, ignoreZeroDevices) => {
807
- return (await ctx.getClient()).getUSyncDevices(jids, useCache, ignoreZeroDevices);
862
+ return ctx.withClient(client => client.getUSyncDevices(jids, useCache, ignoreZeroDevices));
808
863
  },
809
864
  waitForMessage,
810
865
  query,
811
866
  sendRawMessage: async (data) => {
812
- return (await ctx.getClient()).sendRawMessage(data instanceof Uint8Array ? data : new Uint8Array(data));
867
+ return ctx.withClient(client => client.sendRawMessage(data instanceof Uint8Array ? data : new Uint8Array(data)));
813
868
  },
814
869
  /**
815
870
  * `dsmMessage` is accepted so the signature matches upstream, and
@@ -823,7 +878,7 @@ const makeWASocket = (config) => {
823
878
  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 });
824
879
  }
825
880
  const bytes = encodeProtoCompat('Message', message);
826
- return (await ctx.getClient()).createParticipantNodesBytes(jids, bytes, extraAttrs ?? {});
881
+ return ctx.withClient(client => client.createParticipantNodesBytes(jids, bytes, extraAttrs ?? {}));
827
882
  },
828
883
  signalRepository,
829
884
  ...makePreKeyMethods(ctx),
@@ -848,14 +903,15 @@ const makeWASocket = (config) => {
848
903
  // Ahead of the client: an off-union value used to fall through to the
849
904
  // chat-state branch and be reported as a missing jid.
850
905
  assertArgumentDomain('sendPresenceUpdate', 'type', type, WA_PRESENCES);
851
- const c = await ctx.getClient();
852
- if (type === 'available' || type === 'unavailable') {
853
- return c.sendPresence(type);
854
- }
855
- if (!toJid) {
856
- throw new Boom(`sendPresenceUpdate('${type}') requires a target jid`, { statusCode: 400 });
857
- }
858
- return c.sendChatState(toJid, type);
906
+ return ctx.withClient(async (c) => {
907
+ if (type === 'available' || type === 'unavailable') {
908
+ return c.sendPresence(type);
909
+ }
910
+ if (!toJid) {
911
+ throw new Boom(`sendPresenceUpdate('${type}') requires a target jid`, { statusCode: 400 });
912
+ }
913
+ return c.sendChatState(toJid, type);
914
+ });
859
915
  },
860
916
  /**
861
917
  * Plaintext media upload helper, source-compatible with the upstream
@@ -868,15 +924,15 @@ const makeWASocket = (config) => {
868
924
  // `toBridgeMediaType` below still refuses the ones it cannot map.
869
925
  assertArgumentDomain('waUploadToServer', 'mediaType', opts?.mediaType, MEDIA_TYPES);
870
926
  const bytes = data instanceof Uint8Array && !Buffer.isBuffer(data) ? data : new Uint8Array(data);
871
- return (await ctx.getClient()).uploadMedia(bytes, toBridgeMediaType(opts.mediaType));
927
+ return ctx.withClient(client => client.uploadMedia(bytes, toBridgeMediaType(opts.mediaType)));
872
928
  },
873
929
  ...makePrivacyMethods(ctx),
874
930
  updateDefaultDisappearingMode: async (duration) => {
875
- await (await ctx.getClient()).updateDefaultDisappearingMode(duration);
931
+ await ctx.withClient(client => client.updateDefaultDisappearingMode(duration));
876
932
  },
877
933
  rejectCall: async (callId, callFrom) => {
878
934
  const context = activeCallContexts.get(callId);
879
- await (await ctx.getClient()).rejectCall(callId, context?.peer ?? callFrom, context?.callCreator ?? callFrom);
935
+ await ctx.withClient(client => client.rejectCall(callId, context?.peer ?? callFrom, context?.callCreator ?? callFrom));
880
936
  activeCallContexts.delete(callId);
881
937
  },
882
938
  /**
@@ -896,14 +952,14 @@ const makeWASocket = (config) => {
896
952
  /** Upstream Baileys-compatible name. */
897
953
  fetchAccountReachoutTimelock: fetchReachoutTimelock,
898
954
  getBusinessProfile: async (jid) => {
899
- return bridgeBusinessProfileToBaileys(await (await ctx.getClient()).getBusinessProfile(jid));
955
+ return bridgeBusinessProfileToBaileys(await ctx.withClient(client => client.getBusinessProfile(jid)));
900
956
  },
901
957
  fetchMessageHistory: async (count, oldestMsgKey, oldestMsgTimestamp) => {
902
- return (await ctx.getClient()).fetchMessageHistory(count, oldestMsgKey.remoteJid || '', oldestMsgKey.id || '', oldestMsgKey.fromMe || false, typeof oldestMsgTimestamp === 'number' ? oldestMsgTimestamp : oldestMsgTimestamp.toNumber());
958
+ return ctx.withClient(client => client.fetchMessageHistory(count, oldestMsgKey.remoteJid || '', oldestMsgKey.id || '', oldestMsgKey.fromMe || false, typeof oldestMsgTimestamp === 'number' ? oldestMsgTimestamp : oldestMsgTimestamp.toNumber()));
903
959
  },
904
960
  sendStatusMessage: async (message, recipients) => {
905
961
  const bytes = encodeProtoCompat('Message', message);
906
- return (await ctx.getClient()).sendStatusMessageBytes(bytes, recipients);
962
+ return ctx.withClient(client => client.sendStatusMessageBytes(bytes, recipients));
907
963
  },
908
964
  ...makeMessageMethods(ctx),
909
965
  ...groupMethods,
@@ -924,11 +980,11 @@ const makeWASocket = (config) => {
924
980
  // the context below is built, and a check past that await reports a
925
981
  // stack without the caller in it.
926
982
  assertArgumentDomain('downloadMedia', 'type', type, MEDIA_DOWNLOAD_TYPES);
927
- return downloadMediaMessage(message, type, options, {
983
+ return ctx.withClient(client => downloadMediaMessage(message, type, options, {
928
984
  logger,
929
985
  reuploadRequest: (m) => sock.updateMediaMessage(m),
930
- waClient: await ctx.getClient()
931
- });
986
+ waClient: client
987
+ }));
932
988
  }
933
989
  };
934
990
  // Assigning replaces the handler the socket itself reports through, rather
@@ -65,7 +65,7 @@ export const makeInternalMethods = (ctx) => {
65
65
  * that wants the next attempt waits again.
66
66
  */
67
67
  waitForSocketOpen: async () => {
68
- await (await ctx.getClient()).waitForSocket(TRANSPORT_CONNECT_TIMEOUT_MS);
68
+ await ctx.withClient(client => client.waitForSocket(TRANSPORT_CONNECT_TIMEOUT_MS));
69
69
  },
70
70
  /**
71
71
  * Publishes a message onto the event bus, which is this layer's own job.