@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.
@@ -22,6 +22,7 @@ import { buildGroupCreateStubMessage, buildGroupJoinRequestEvents, buildGroupNot
22
22
  import { emitMessageUpsert } from '../Compatibility/message-upsert.js';
23
23
  import { extractMessageCappingPayload } from './message-capping.js';
24
24
  import { mapReachoutTimelock } from './reachout.js';
25
+ import { isReconnectableConnectFailure } from './terminal-close.js';
25
26
  const CANONICAL_MESSAGE_EVENT = 'message';
26
27
  const MESSAGE_UPSERT_APPEND = 'append';
27
28
  const MESSAGE_UPSERT_NOTIFY = 'notify';
@@ -104,15 +105,72 @@ const clearHistorySyncPausedTimeout = (state) => {
104
105
  clearTimeout(state.pausedTimeout);
105
106
  state.pausedTimeout = undefined;
106
107
  };
107
- const emitClose = (ctx, reason, statusCode, data) => ctx.ev.emit('connection.update', {
108
- connection: 'close',
109
- lastDisconnect: { error: new Boom(reason, { statusCode, data }), date: new Date() }
108
+ /**
109
+ * Report a disconnect the engine will NOT recover from.
110
+ *
111
+ * `connection: 'close'` carries one meaning on this socket, the same one it
112
+ * carries in upstream Baileys: the socket is finished and the consumer has to
113
+ * build a new one. Disconnects the engine is still retrying never come through
114
+ * here — see `emitRetrying` and `terminal-close.ts`.
115
+ *
116
+ * `onTerminalClose` owns both the teardown and the publish, so the socket can
117
+ * be fully torn down before the consumer sees the event — the order upstream
118
+ * uses, and what keeps a replacement socket from overlapping the old one's
119
+ * store flush. Without that callback (a bare `makeEventHandler`), the close
120
+ * goes out immediately.
121
+ */
122
+ const emitClose = ({ ctx, callbacks, historySync }, reason, statusCode, data) => {
123
+ // Every terminal path cancels the history-sync pause timer, not just the
124
+ // one in `disconnected`. A transient drop deliberately keeps it armed, so
125
+ // without this a drop followed by a terminal close leaves it to fire a
126
+ // `messaging-history.status: paused` from a socket that has already ended.
127
+ clearHistorySyncPausedTimeout(historySync);
128
+ const error = new Boom(reason, { statusCode, data });
129
+ const publish = () => ctx.ev.emit('connection.update', {
130
+ connection: 'close',
131
+ lastDisconnect: { error, date: new Date() }
132
+ });
133
+ if (callbacks?.onTerminalClose) {
134
+ callbacks.onTerminalClose(error, publish);
135
+ return;
136
+ }
137
+ publish();
138
+ };
139
+ /**
140
+ * Report a disconnect the engine is retrying on its own.
141
+ *
142
+ * Emitted as `connecting` rather than `close` on purpose. The canonical
143
+ * upstream handler is `if (connection === 'close') reconnect()`, and upstream
144
+ * has no auto-reconnect — so surfacing a transient drop as `close` makes that
145
+ * handler build a second socket while the engine is already backing off to
146
+ * restore the first. Two live sockets for one connection. `connecting` is the
147
+ * state upstream itself uses while a connection is being (re)established, so
148
+ * consumers read this as `open → connecting → open` and stay out of the way.
149
+ */
150
+ const emitRetrying = (ctx) => ctx.ev.emit('connection.update', {
151
+ connection: 'connecting',
152
+ // Same shape upstream's own `connecting` always carries. Without the
153
+ // explicit clear, a consumer merging partial state keeps rendering the
154
+ // QR from before the drop — one the server has already rotated away.
155
+ qr: undefined,
156
+ receivedPendingNotifications: false
110
157
  });
158
+ /**
159
+ * `<failure reason="405">` — the wire code, kept as-is rather than folded into
160
+ * `DisconnectReason`, which has no member for it. See the `clientOutdated`
161
+ * dispatcher for why `badSession` was the wrong home.
162
+ */
163
+ const CLIENT_OUTDATED_STATUS = 405;
111
164
  /**
112
165
  * Map bridge `ConnectFailureReason` wire codes (per the bridge's
113
166
  * `.d.ts` annotation) onto upstream Baileys' `DisconnectReason`.
114
167
  * Unknown codes fall through to `connectionClosed` so existing
115
168
  * reconnect heuristics keep working.
169
+ *
170
+ * Several cases here are belt-and-braces: the engine dispatches its own event
171
+ * for `is_logged_out()` reasons (401/403/406) and for 405, so those never
172
+ * reach `connectFailure` in practice. Kept because they cost nothing and the
173
+ * engine's routing is not ours to depend on.
116
174
  */
117
175
  const mapConnectFailureToDisconnect = (reason) => {
118
176
  switch (reason) {
@@ -123,7 +181,7 @@ const mapConnectFailureToDisconnect = (reason) => {
123
181
  case 402: // TempBanned
124
182
  return DisconnectReason.forbidden;
125
183
  case 405: // ClientOutdated
126
- return DisconnectReason.badSession;
184
+ return CLIENT_OUTDATED_STATUS;
127
185
  case 411: // MultideviceMismatch (legacy alias)
128
186
  return DisconnectReason.multideviceMismatch;
129
187
  case 503: // ServiceUnavailable
@@ -168,9 +226,26 @@ const DISPATCHERS = {
168
226
  offlineSyncCompleted: (_, { ctx }) => emitConnectionUpdate(ctx, {
169
227
  receivedPendingNotifications: true
170
228
  }),
171
- disconnected: (_, { ctx, historySync }) => {
172
- clearHistorySyncPausedTimeout(historySync);
173
- emitClose(ctx, 'Connection closed', DisconnectReason.connectionClosed);
229
+ // The engine dispatches `Disconnected` only for an *unexpected* loop exit,
230
+ // and every terminal path marks `expected_disconnect` first — so this is
231
+ // normally "the Fibonacci backoff is already running".
232
+ //
233
+ // The exception is `sock.setAutoReconnect(false)`: `client/lifecycle.rs`
234
+ // dispatches `Disconnected` and only *then* tests the flag and breaks out
235
+ // of the run loop. Treating that as transient would leave the socket
236
+ // reporting `connecting` forever, with the wasm client never freed and the
237
+ // consumer's reconnect handler never firing.
238
+ disconnected: (_, dispatchCtx) => {
239
+ if (dispatchCtx.callbacks?.isAutoReconnectEnabled?.() === false) {
240
+ emitClose(dispatchCtx, 'Connection closed', DisconnectReason.connectionClosed);
241
+ return;
242
+ }
243
+ // The pause timer survives a retrying drop. It is the only pending
244
+ // transition to `messaging-history.status: paused`, and this socket
245
+ // lives on — clearing it left a RECENT sync that had reported progress
246
+ // below 100 with neither `paused` nor `complete` if the reconnect
247
+ // produced no further chunk, hanging consumers waiting on hydration.
248
+ emitRetrying(dispatchCtx.ctx);
174
249
  },
175
250
  qr: (evt, { ctx }) => emitConnectionUpdate(ctx, { qr: evt.code }),
176
251
  pairSuccess: (evt, { ctx, callbacks }) => {
@@ -183,47 +258,57 @@ const DISPATCHERS = {
183
258
  // a compat hook for upstream's lifecycle.
184
259
  ctx.ev.emit('creds.update', { registered: true, me: { id, lid, name: businessName }, platform });
185
260
  },
186
- pairError: (evt, { ctx }) => emitClose(ctx, 'Pairing failed: ' + evt.error, DisconnectReason.connectionClosed),
187
- loggedOut: (evt, { ctx }) => emitClose(ctx, evt.reason ? `Logged out: ${evt.reason}` : 'Logged out', DisconnectReason.loggedOut),
188
- connectFailure: (evt, { ctx }) => {
261
+ // Pairing failed, but the engine keeps its loop and re-emits a QR — nothing
262
+ // about the client is dead, so this must not read as `close`. It still
263
+ // belongs on the bus: the QR the user was shown is spent, and `connecting`
264
+ // clears it while telling the consumer a fresh one is coming.
265
+ pairError: (evt, { ctx }) => {
266
+ ctx.logger.error({ err: evt.error }, 'pairing failed; the engine will retry');
267
+ emitRetrying(ctx);
268
+ },
269
+ loggedOut: (evt, dispatchCtx) => emitClose(dispatchCtx, evt.reason ? `Logged out: ${evt.reason}` : 'Logged out', DisconnectReason.loggedOut),
270
+ connectFailure: (evt, dispatchCtx) => {
189
271
  // Map bridge `ConnectFailureReason` wire codes onto Baileys'
190
272
  // DisconnectReason. Defaults to connectionClosed for unknown codes.
191
273
  // LoggedOut paths (401/403/406) drive bots' "should I re-pair?"
192
274
  // branch — folding them into connectionClosed kept that broken.
275
+ // "Reconnectable" is only true while the engine is allowed to reconnect.
276
+ // With `setAutoReconnect(false)` the run loop breaks on the next pass,
277
+ // so reporting `connecting` for a 500/503 would leave the socket stuck
278
+ // in that state with nothing left to restore it.
279
+ if (isReconnectableConnectFailure(evt.reason) && dispatchCtx.callbacks?.isAutoReconnectEnabled?.() !== false) {
280
+ dispatchCtx.ctx.logger.warn({ reason: evt.reason, message: evt.message }, 'connect failure; the engine will retry');
281
+ emitRetrying(dispatchCtx.ctx);
282
+ return;
283
+ }
193
284
  const status = mapConnectFailureToDisconnect(evt.reason);
194
- emitClose(ctx, evt.message ?? 'Connection failure', status);
285
+ emitClose(dispatchCtx, evt.message ?? 'Connection failure', status);
195
286
  },
196
- // Preserve the wire code on `boom.data.streamErrorCode` so consumers can
197
- // branch on the actual `<stream:error code="...">` (e.g. 500 vs an
198
- // unknown code) without losing the canonical DisconnectReason.badSession.
199
- streamError: (evt, { ctx }) => emitClose(ctx, 'Stream error: ' + evt.code, DisconnectReason.badSession, {
200
- streamErrorCode: evt.code
201
- }),
202
- streamReplaced: (_, { ctx }) => emitConnectionUpdate(ctx, {
203
- connection: 'close',
204
- lastDisconnect: {
205
- error: new Boom('Connection replaced', { statusCode: DisconnectReason.connectionReplaced }),
206
- date: new Date()
207
- }
208
- }),
209
- clientOutdated: (_, { ctx }) => emitClose(ctx, 'Client outdated', DisconnectReason.badSession),
210
- temporaryBan: (evt, { ctx }) => {
287
+ // NOT a close. The engine dispatches `StreamError` only from its catch-all
288
+ // `<stream:error>` branch an unknown code, an `<ack/>` it still owes the
289
+ // server, or `<xml-not-well-formed>` and it keeps or deliberately recycles
290
+ // the connection in every one of those. This used to report
291
+ // `DisconnectReason.badSession`, the code bots use to wipe credentials and
292
+ // re-pair, so a routine stream recycle could destroy a working session. The
293
+ // coded stream errors (401/409/515/516) never arrive here; they dispatch
294
+ // `loggedOut` / `streamReplaced` of their own.
295
+ streamError: (evt, { ctx }) => ctx.logger.warn({ code: evt.code }, 'stream error; the connection is preserved'),
296
+ streamReplaced: (_, dispatchCtx) => emitClose(dispatchCtx, 'Connection replaced', DisconnectReason.connectionReplaced),
297
+ // Carries the wire code (405), not `DisconnectReason.badSession`. Upstream
298
+ // does the same for stream errors — `+node.attrs.code` first, `badSession`
299
+ // only as the no-code fallback (`Utils/generics.ts` `getErrorCodeFromStreamError`).
300
+ // badSession is 500, the code bots use to wipe credentials and re-pair; an
301
+ // outdated build is the one failure where wiping a perfectly good session
302
+ // helps nobody, and the next connect fails identically.
303
+ clientOutdated: (_, dispatchCtx) => emitClose(dispatchCtx, 'Client outdated', CLIENT_OUTDATED_STATUS),
304
+ temporaryBan: (evt, dispatchCtx) => {
211
305
  // Surface the wire code + expire on the Boom so consumers reading
212
306
  // `lastDisconnect.error.data` can act on the specific reason.
213
307
  const reason = describeTempBan(evt.code);
214
308
  const message = evt.expire
215
309
  ? `Temporary ban (${reason}); expires at ${new Date(evt.expire * 1000).toISOString()}`
216
310
  : `Temporary ban (${reason})`;
217
- ctx.ev.emit('connection.update', {
218
- connection: 'close',
219
- lastDisconnect: {
220
- error: new Boom(message, {
221
- statusCode: DisconnectReason.forbidden,
222
- data: { code: evt.code, expire: evt.expire }
223
- }),
224
- date: new Date()
225
- }
226
- });
311
+ emitClose(dispatchCtx, message, DisconnectReason.forbidden, { code: evt.code, expire: evt.expire });
227
312
  },
228
313
  qrScannedWithoutMultidevice: (_, { ctx }) => ctx.logger.warn('QR scanned but multi-device not enabled on phone'),
229
314
  // ── Messages ──
@@ -784,6 +869,7 @@ export const makeEventHandlers = (ctx, callbacks) => {
784
869
  callbacks,
785
870
  historySync: { initialBootstrapComplete: false, recentSyncComplete: false }
786
871
  };
872
+ callbacks?.onCleanup?.(() => clearHistorySyncPausedTimeout(dispatchCtx.historySync));
787
873
  const onEvent = (event) => {
788
874
  const canonical = adaptBridgeEvent(event, ctx.logger);
789
875
  if (canonical)
@@ -857,12 +943,29 @@ export const makeEventHandlers = (ctx, callbacks) => {
857
943
  for (const data of acks)
858
944
  onEvent({ type: 'server_ack', data });
859
945
  };
946
+ // Opting the two packed paths into the bridge's borrowing contract, which
947
+ // lets it hand every batch out of one reused buffer instead of allocating
948
+ // per batch. Both handlers already satisfied the contract unchanged: each
949
+ // decodes as its first act, and `decodeReceiptWireBatch` /
950
+ // `decodeServerAckWireBatch` materialise every field rather than keeping a
951
+ // view, so nothing points at the buffer once they return. Neither is async,
952
+ // and no exception escapes them — the decode is guarded here and the
953
+ // dispatch is guarded in `dispatchCanonicalEvent` — both of which the
954
+ // contract requires, since either one drops the whole session back to a
955
+ // buffer per batch.
956
+ //
957
+ // Declaring a borrowing name replaces its copying counterpart as the sink,
958
+ // so the pair below is the whole opt-in. There is deliberately none for
959
+ // messages: `decodeMessageWireBatch` returns views over the batch, so reuse
960
+ // would alias the decoded result and not just the buffer.
860
961
  return {
861
962
  onEvent,
862
963
  onMessageBatch,
863
964
  onHistorySyncBatch,
864
965
  onReceiptBatch,
865
966
  onServerAckBatch,
967
+ onReceiptBatchBorrowed: onReceiptBatch,
968
+ onServerAckBatchBorrowed: onServerAckBatch,
866
969
  historySyncConversationTypes: CONVERSATION_HISTORY_SYNC_TYPES
867
970
  };
868
971
  };
@@ -1,5 +1,5 @@
1
1
  import { Buffer } from 'node:buffer';
2
- import { type UploadMediaResult, type WasmWhatsAppClient } from '@oxidezap/whatsapp-rust-bridge';
2
+ import { type UploadMediaResult } from '@oxidezap/whatsapp-rust-bridge';
3
3
  import { WebSocketClient } from '../Compatibility/websocket-client.js';
4
4
  import { type MediaType } from '../Defaults/index.js';
5
5
  import type { BinaryNode, AuthenticationCreds, ConnectionState, Contact, ReachoutTimelockState, SignalKeyStoreWithTransaction, UserFacingSocketConfig, WAPrivacyGroupAddValue, WAPrivacyOnlineValue, WAPrivacyValue, WABusinessProfile, WAReadReceiptsValue, WAMessage, WAMessageKey } from '../Types/index.js';
@@ -120,8 +120,34 @@ declare const makeWASocket: (config: UserFacingSocketConfig) => {
120
120
  logger: import("../Utils/logger.js").ILogger;
121
121
  ws: WebSocketClient;
122
122
  type: 'md';
123
+ /**
124
+ * `await using sock = makeWASocket(config)` — frees the wasm client on
125
+ * scope exit. Nothing else can: the bridge holds the JS event callbacks
126
+ * as wasm-bindgen externrefs and those callbacks reach back to this
127
+ * closure, so the reference cycle crosses the JS/wasm boundary and no
128
+ * `FinalizationRegistry` will ever fire for the client.
129
+ *
130
+ * Delegates to `end()`, so it flushes the auth store and is idempotent.
131
+ *
132
+ * Then awaits `initPromise`, because `end()` alone does not satisfy the
133
+ * async-disposal contract: called before `createWhatsAppClient()`
134
+ * settles, it sees `client === undefined`, frees nothing, and resolves
135
+ * while initialization is still running. What actually disposes that
136
+ * client is the `ended` guard inside `init()` — so code after the
137
+ * `await using` scope would otherwise overlap with bridge construction,
138
+ * event callbacks, and store access. `end()` runs first because it sets
139
+ * `ended` synchronously, which is what makes `init()` bail out early.
140
+ *
141
+ * The `finally` is load-bearing: `end()` rethrows the first auth-store
142
+ * flush failure, and letting that propagate directly would skip the wait
143
+ * and resolve the disposer with `init()` still in flight — losing the
144
+ * one guarantee it exists to provide, in exactly the situation where
145
+ * cleanup already went wrong. `initPromise` swallows its own failures,
146
+ * so awaiting it here cannot mask the flush error.
147
+ */
148
+ [Symbol.asyncDispose](): Promise<void>;
123
149
  user: Contact | undefined;
124
- waClient: WasmWhatsAppClient | undefined;
150
+ waClient: import("@oxidezap/whatsapp-rust-bridge").WasmWhatsAppClient | undefined;
125
151
  isConnected: boolean;
126
152
  isLoggedIn: boolean;
127
153
  authState: {