@oxidezap/baileyrs 0.0.32 → 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.
Files changed (53) hide show
  1. package/README.md +77 -24
  2. package/lib/Bridge/adapt.d.ts +1 -1
  3. package/lib/Bridge/history-sync-wire.d.ts +1 -1
  4. package/lib/Bridge/history-sync-wire.js +2 -2
  5. package/lib/Bridge/schema.d.ts +1 -1
  6. package/lib/Bridge/schema.js +10 -10
  7. package/lib/Bridge/types.d.ts +1 -1
  8. package/lib/Compatibility/group-metadata.d.ts +1 -1
  9. package/lib/Compatibility/legacy-store/codecs/basic.js +1 -1
  10. package/lib/Compatibility/legacy-store/codecs/signal.js +17 -5
  11. package/lib/Compatibility/legacy-store/device.js +1 -1
  12. package/lib/Compatibility/legacy-store/multi-file.js +1 -1
  13. package/lib/Compatibility/legacy-store/native-projection.d.ts +1 -1
  14. package/lib/Compatibility/legacy-store/types.d.ts +1 -1
  15. package/lib/Compatibility/media-type.d.ts +1 -1
  16. package/lib/Compatibility/message-relay.d.ts +1 -1
  17. package/lib/Compatibility/proto-runtime.js +119 -45
  18. package/lib/Compatibility/socket-results.d.ts +1 -1
  19. package/lib/Compatibility/stanza-responses.d.ts +1 -1
  20. package/lib/Compatibility/usync/adapter.d.ts +1 -1
  21. package/lib/Compatibility/websocket-client.d.ts +24 -3
  22. package/lib/Compatibility/websocket-client.js +47 -18
  23. package/lib/Socket/bridge-client-owner.d.ts +89 -0
  24. package/lib/Socket/bridge-client-owner.js +135 -0
  25. package/lib/Socket/communities.d.ts +2 -2
  26. package/lib/Socket/contacts.d.ts +1 -1
  27. package/lib/Socket/events.d.ts +32 -1
  28. package/lib/Socket/events.js +140 -37
  29. package/lib/Socket/index.d.ts +32 -6
  30. package/lib/Socket/index.js +433 -132
  31. package/lib/Socket/messages.d.ts +4 -1
  32. package/lib/Socket/messages.js +6 -4
  33. package/lib/Socket/newsletter.d.ts +3 -3
  34. package/lib/Socket/terminal-close-reporter.d.ts +79 -0
  35. package/lib/Socket/terminal-close-reporter.js +108 -0
  36. package/lib/Socket/terminal-close.d.ts +39 -0
  37. package/lib/Socket/terminal-close.js +51 -0
  38. package/lib/Socket/transport.d.ts +1 -1
  39. package/lib/Socket/types.d.ts +1 -1
  40. package/lib/Types/Auth.d.ts +1 -1
  41. package/lib/Types/Message.d.ts +1 -1
  42. package/lib/Types/Socket.d.ts +2 -2
  43. package/lib/Utils/crypto.d.ts +2 -2
  44. package/lib/Utils/crypto.js +1 -1
  45. package/lib/Utils/event-buffer.js +31 -0
  46. package/lib/Utils/logger.d.ts +2 -2
  47. package/lib/Utils/logger.js +122 -25
  48. package/lib/Utils/messages.d.ts +12 -1
  49. package/lib/Utils/messages.js +39 -15
  50. package/lib/Utils/process-history-message.js +6 -10
  51. package/lib/Utils/process-message.js +1 -1
  52. package/lib/WAProto/runtime.js +5 -2
  53. package/package.json +3 -3
package/README.md CHANGED
@@ -25,7 +25,7 @@ so existing integrations can migrate with minimal changes. See
25
25
  | Media encrypt/decrypt | Node.js crypto | Rust AES-256-CBC + HMAC |
26
26
  | Media upload/download | JS fetch + temp files | Rust with CDN failover, auth refresh, resumable upload |
27
27
  | Key management | JS auth state | Rust `PersistenceManager` |
28
- | Auto-reconnect | Manual `startSock()` loop | Built-in with fibonacci backoff |
28
+ | Auto-reconnect | Manual `startSock()` loop | Transient drops retried in Rust (fibonacci backoff); terminal ones still yours |
29
29
 
30
30
  ## Documentation
31
31
 
@@ -51,9 +51,16 @@ import makeWASocket from '@oxidezap/baileyrs'
51
51
  ### Drop-in replacement for upstream Baileys
52
52
 
53
53
  baileyrs is API-compatible with [@whiskeysockets/baileys](https://github.com/WhiskeySockets/Baileys).
54
- Existing projects switch over by aliasing the package — **no source changes needed**
55
- (one exception: carrying an existing pairing across takes a one-line import swap,
56
- see [Migrating from Upstream Baileys](#migrating-from-upstream-baileys)):
54
+ Existing projects switch over by aliasing the package — **the API and imports
55
+ need no source changes**. Two things do:
56
+
57
+ - Carrying an existing pairing across takes a one-line import swap, see
58
+ [Migrating from Upstream Baileys](#migrating-from-upstream-baileys).
59
+ - If your `connection.update` handler was written for a version of baileyrs
60
+ before 0.1, see [Gotchas](#gotchas): a `close` now always means the socket
61
+ is finished, and you have to recreate it. Code written against upstream
62
+ Baileys already does the right thing.
63
+
57
64
 
58
65
  ```sh
59
66
  npm install @whiskeysockets/baileys@npm:@oxidezap/baileyrs
@@ -78,27 +85,57 @@ now resolves to baileyrs.
78
85
  import makeWASocket, { Boom, DisconnectReason, useMultiFileAuthState } from '@oxidezap/baileyrs'
79
86
 
80
87
  const { state } = await useMultiFileAuthState('auth_info')
81
- const sock = makeWASocket({ auth: state })
82
88
 
83
- sock.ev.on('connection.update', ({ connection, lastDisconnect }) => {
84
- if (connection === 'close') {
85
- const statusCode = (lastDisconnect?.error as Boom)?.output?.statusCode
86
- if (statusCode === DisconnectReason.loggedOut) {
87
- console.log('Logged out')
88
- }
89
- // Auto-reconnect is handled by the Rust engine — no need to call makeWASocket again
89
+ // setTimeout caps at ~2^31-1 ms (~24.8 days) and fires immediately past that,
90
+ // so a long ban has to be waited out in chunks.
91
+ async function waitUntil(deadlineMs: number) {
92
+ for (let left = deadlineMs - Date.now(); left > 0; left = deadlineMs - Date.now()) {
93
+ await new Promise(resolve => setTimeout(resolve, Math.min(left, 2_147_483_647)))
90
94
  }
91
- if (connection === 'open') {
92
- console.log('Connected')
93
- }
94
- })
95
+ }
95
96
 
96
- sock.ev.on('messages.upsert', ({ messages }) => {
97
- for (const msg of messages) {
98
- console.log('received message', msg.key.id)
99
- }
100
- })
97
+ async function connectToWhatsApp() {
98
+ const sock = makeWASocket({ auth: state })
99
+
100
+ sock.ev.on('connection.update', ({ connection, lastDisconnect }) => {
101
+ if (connection === 'close') {
102
+ // `close` means this socket is finished — same as upstream Baileys.
103
+ // Transient drops never get here; the Rust engine retries those and
104
+ // reports `connecting`.
105
+ const statusCode = (lastDisconnect?.error as Boom)?.output?.statusCode
106
+ // See the reconnect table under Gotchas: a few terminal closes
107
+ // reject the replacement just as fast, so they are not worth
108
+ // retrying — or not yet. `Example/example.ts` has the full policy.
109
+ if (statusCode === DisconnectReason.loggedOut || statusCode === 405) {
110
+ console.log('Closed for good', statusCode)
111
+ } else if (statusCode === DisconnectReason.forbidden) {
112
+ // Temporary ban: `expire` is unix seconds. A missing or past
113
+ // expiry means the ban is over — reconnect like any other
114
+ // terminal close rather than staying offline forever.
115
+ const expire = (lastDisconnect?.error as Boom)?.data?.expire
116
+ console.log('Temporarily banned until', expire)
117
+ waitUntil(typeof expire === 'number' ? expire * 1000 : 0).then(connectToWhatsApp)
118
+ } else {
119
+ setTimeout(connectToWhatsApp, 5_000)
120
+ }
121
+ }
122
+ if (connection === 'open') {
123
+ console.log('Connected')
124
+ }
125
+ })
126
+
127
+ // Register every handler in here. A replacement socket is a new emitter,
128
+ // so anything attached outside stops firing after the first reconnect.
129
+ sock.ev.on('messages.upsert', ({ messages }) => {
130
+ for (const msg of messages) {
131
+ console.log('received message', msg.key.id)
132
+ }
133
+ })
134
+
135
+ return sock
136
+ }
101
137
 
138
+ const sock = await connectToWhatsApp()
102
139
  await sock.sendMessage('1234567890@s.whatsapp.net', { text: 'Hello!' })
103
140
  ```
104
141
 
@@ -156,9 +193,25 @@ preserved. No QR re-scan, no logged-out events.
156
193
 
157
194
  A few behaviors that differ from upstream — almost always to your advantage:
158
195
 
159
- - **Auto-reconnect is built in.** Don't call `makeWASocket()` again from
160
- `connection.update`'s `'close'` branch. The Rust engine retries with
161
- fibonacci backoff; opening a second socket leaks the first one.
196
+ - **Auto-reconnect is built in, but `close` still means `close`.** The Rust
197
+ engine retries transient drops on a fibonacci backoff and reports them as
198
+ `connection: 'connecting'`, so the canonical upstream handler never fires
199
+ for those and you never end up with two sockets on one account. A
200
+ `connection: 'close'` is only emitted once the engine has given up — a
201
+ replaced session, an outdated build, a temporary ban, an unrecoverable
202
+ `<failure>` — and by then the socket has already released its resources.
203
+ Ignoring it leaves the bot permanently offline, so handle it the upstream
204
+ way and build a replacement — with three exceptions, because some of those
205
+ failures reject the replacement just as fast:
206
+
207
+ | `statusCode` | what to do |
208
+ | --- | --- |
209
+ | `DisconnectReason.loggedOut` (401) | stop; needs a fresh pairing |
210
+ | `405` | stop; the server rejected this build, and the next one too |
211
+ | `DisconnectReason.forbidden` (403) | wait until `lastDisconnect.error.data.expire` (unix seconds) — it is a temporary ban |
212
+ | anything else | reconnect, after a short delay |
213
+
214
+ `Example/example.ts` implements exactly this.
162
215
  - **No `getMessage` / `cachedGroupMetadata` polyfill required.** The Rust
163
216
  side caches group metadata and message keys natively. You can still pass
164
217
  them — they're respected as overrides — but they're optional.
@@ -7,7 +7,7 @@
7
7
  * (table-driven dispatch, additional validation, schema-derived types,
8
8
  * etc.) without rippling through call sites.
9
9
  */
10
- import type { WhatsAppEvent } from 'whatsapp-rust-bridge';
10
+ import type { WhatsAppEvent } from '@oxidezap/whatsapp-rust-bridge';
11
11
  import type { ILogger } from '../Utils/logger.js';
12
12
  import type { CanonicalEvent } from './types.js';
13
13
  import { adaptBridgeMessageWire } from './schema.js';
@@ -1,4 +1,4 @@
1
- import { type HistorySyncWireBatch, type WhatsAppEvent } from 'whatsapp-rust-bridge';
1
+ import { type HistorySyncWireBatch, type WhatsAppEvent } from '@oxidezap/whatsapp-rust-bridge';
2
2
  import type { ILogger } from '../Utils/logger.js';
3
3
  declare const HISTORY_SYNC_EVENT_TYPE: 'history_sync';
4
4
  export interface DecodedHistorySyncWireBatch {
@@ -1,5 +1,5 @@
1
- import { BinaryReader } from 'whatsapp-rust-bridge';
2
- import { proto as WAProto } from 'whatsapp-rust-bridge/proto-types';
1
+ import { BinaryReader } from '@oxidezap/whatsapp-rust-bridge';
2
+ import { proto as WAProto } from '@oxidezap/whatsapp-rust-bridge/proto-types';
3
3
  import { isConversationHistorySync } from '../Utils/process-history-message.js';
4
4
  import { createSparseArray } from '../Utils/sparse-array.js';
5
5
  const HISTORY_SYNC_EVENT_TYPE = 'history_sync';
@@ -22,7 +22,7 @@
22
22
  * `asNumber`, `asString`) at those leak points only — not boilerplate
23
23
  * everywhere.
24
24
  */
25
- import type { MessageWireInfo, WhatsAppEvent } from 'whatsapp-rust-bridge';
25
+ import type { MessageWireInfo, WhatsAppEvent } from '@oxidezap/whatsapp-rust-bridge';
26
26
  import type { ILogger } from '../Utils/logger.js';
27
27
  import type { CanonicalEvent, CanonicalMessage } from './types.js';
28
28
  export type BridgeEventType = WhatsAppEvent['type'];
@@ -718,8 +718,15 @@ const adaptIncomingCall = (data, logger) => {
718
718
  const action = isObject(data.action) ? data.action : undefined;
719
719
  if (!action)
720
720
  return null;
721
- const actionType = parseCallActionType(action.type);
722
- const callId = asString(action.call_id);
721
+ // The bridge `CallAction` union narrows variant-specific fields only on the
722
+ // matching variant, and its group-call variants (group updates, rekeys,
723
+ // waiting rooms) carry no `call_id` at all. `parseCallActionType` admits
724
+ // none of those, so a runtime read through the action's own shape is safe —
725
+ // and is the cheapest way around TS's inability to follow our
726
+ // string-normalized discriminator back into the bridge union.
727
+ const fields = action;
728
+ const actionType = parseCallActionType(fields.type);
729
+ const callId = asString(fields.call_id);
723
730
  if (!actionType || !callId) {
724
731
  logger?.debug({ data }, 'incoming_call adapter: missing action.type/call_id');
725
732
  return null;
@@ -727,15 +734,8 @@ const adaptIncomingCall = (data, logger) => {
727
734
  const canonicalAction = {
728
735
  type: actionType,
729
736
  callId,
730
- callCreator: asJidAddressString(action.call_creator)
737
+ callCreator: asJidAddressString(fields.call_creator)
731
738
  };
732
- // The bridge `CallAction` union narrows variant-specific fields only
733
- // on the matching variant. We've already validated `action.type` via
734
- // `parseCallActionType`, so a runtime cast through the action's own
735
- // shape is safe — reading fields off `unknown` is the cheapest way
736
- // around TS's inability to follow our string-normalized discriminator
737
- // back into the bridge union.
738
- const fields = action;
739
739
  if (actionType === 'offer') {
740
740
  canonicalAction.callerPn = asJidString(fields.caller_pn);
741
741
  canonicalAction.callerCountryCode = asString(fields.caller_country_code);
@@ -13,7 +13,7 @@
13
13
  * - All timestamps are unix seconds.
14
14
  * - Optional fields use `?` (omitted) rather than `null`.
15
15
  */
16
- import type { proto } from 'whatsapp-rust-bridge/proto-types';
16
+ import type { proto } from '@oxidezap/whatsapp-rust-bridge/proto-types';
17
17
  import type { Chat, Contact, LIDMapping, WAMessage } from '../Types/index.js';
18
18
  import type { BinaryNode, WAProto } from '../Types/index.js';
19
19
  export interface CanonicalConnected {
@@ -1,4 +1,4 @@
1
- import type { GroupMetadataResult } from 'whatsapp-rust-bridge';
1
+ import type { GroupMetadataResult } from '@oxidezap/whatsapp-rust-bridge';
2
2
  import type { GroupMetadata } from '../Types/GroupMetadata.js';
3
3
  /**
4
4
  * Translate the bridge's protocol-neutral group DTO into the public Baileys
@@ -1,6 +1,6 @@
1
1
  // Basic value codecs for the Baileys legacy-store projection.
2
2
  import { Buffer } from 'node:buffer';
3
- import { proto } from 'whatsapp-rust-bridge/proto-types';
3
+ import { proto } from '@oxidezap/whatsapp-rust-bridge/proto-types';
4
4
  import { AppState, JsonByteEncoding, LidMapping, NativeStore, TimeValue } from '../constants.js';
5
5
  import { bytesToNumbers, fromJsonBytes, toBytes, toJsonBytes } from '../common.js';
6
6
  import { parseLidMappingKey } from '../routing.js';
@@ -1,5 +1,5 @@
1
1
  import { Buffer } from 'node:buffer';
2
- import { decodeSenderKeyRecordComponents, encodeSenderKeyRecordComponents, importLegacySessionRecordV1, projectLegacySessionRecordV1 } from 'whatsapp-rust-bridge';
2
+ import { decodeSenderKeyRecordComponents, encodeSenderKeyRecordComponents, importLegacySessionRecordV1, projectLegacySessionRecordV1 } from '@oxidezap/whatsapp-rust-bridge';
3
3
  import { JsonByteEncoding, LegacySession, NativeStore, TimeValue } from '../constants.js';
4
4
  import { EMPTY_BYTES } from '../common.js';
5
5
  import { NATIVE_ONLY_PROJECTION } from '../types.js';
@@ -28,9 +28,18 @@ const integer = (value, label) => {
28
28
  }
29
29
  return value;
30
30
  };
31
+ /**
32
+ * The two shapes order their states in opposite directions: the core keeps the
33
+ * current state at the front and prunes from the back, while the legacy record
34
+ * is read back-to-front — `getSenderKeyState()` takes the last entry and
35
+ * `addSenderKeyState` drops the first one on overflow. Both conversions below
36
+ * therefore reverse. Carrying an order through unchanged writes the record
37
+ * upside down: the old implementation would send under a stale key and evict
38
+ * the freshest state instead of the oldest.
39
+ */
31
40
  function senderKeyToLegacy(nativeBytes) {
32
41
  const record = decodeSenderKeyRecordComponents(nativeBytes);
33
- const states = record.states.map(state => ({
42
+ const states = record.states.toReversed().map(state => ({
34
43
  senderKeyId: state.keyId,
35
44
  senderChainKey: {
36
45
  iteration: state.chainKey.iteration,
@@ -54,6 +63,8 @@ function senderKeyFromLegacy(value) {
54
63
  const parsed = JSON.parse(Buffer.from(value).toString(JsonByteEncoding.UTF8), BufferJSON.reviver);
55
64
  if (!Array.isArray(parsed))
56
65
  throw new TypeError('legacy sender-key record must contain an array');
66
+ // Indexed against the stored order so a validation error names the state the
67
+ // caller can find in the record; reversed afterwards. See `senderKeyToLegacy`.
57
68
  const record = {
58
69
  states: parsed.map((state, stateIndex) => ({
59
70
  keyId: integer(state.senderKeyId, `sender-key state ${stateIndex} id`),
@@ -71,6 +82,7 @@ function senderKeyFromLegacy(value) {
71
82
  }))
72
83
  }))
73
84
  };
85
+ record.states.reverse();
74
86
  return encodeSenderKeyRecordComponents(record);
75
87
  }
76
88
  const messageKeyIndex = (value) => {
@@ -91,13 +103,13 @@ function messageKeysFromLegacy(messageKeys) {
91
103
  function messageKeysToLegacy(messageKeys) {
92
104
  return Object.fromEntries(messageKeys.map(messageKey => [messageKey.index, toBase64(messageKey.seed)]));
93
105
  }
94
- function legacyEntryToCore(entry) {
106
+ function legacyEntryToCore(entry, recordRegistrationId) {
95
107
  const ratchet = entry.currentRatchet;
96
108
  if (!entry._chains || typeof entry._chains !== 'object') {
97
109
  throw new TypeError('legacy session is missing its chain catalog');
98
110
  }
99
111
  return {
100
- registrationId: entry.registrationId ?? TimeValue.UNKNOWN_SECONDS,
112
+ registrationId: entry.registrationId ?? recordRegistrationId ?? TimeValue.UNKNOWN_SECONDS,
101
113
  ratchet: {
102
114
  keyPair: {
103
115
  public: fromBase64(ratchet.ephemeralKeyPair.pubKey, 'sender ratchet public key'),
@@ -145,7 +157,7 @@ function sessionFromLegacy(value, defaults) {
145
157
  const decoded = {
146
158
  sessions: Object.entries(record._sessions).map(([indexKey, entry]) => ({
147
159
  indexKey: fromBase64(indexKey, 'legacy session index key'),
148
- session: legacyEntryToCore(entry)
160
+ session: legacyEntryToCore(entry, record.registrationId)
149
161
  }))
150
162
  };
151
163
  return importLegacySessionRecordV1(decoded, {
@@ -1,6 +1,6 @@
1
1
  // Device credential projection for legacy Baileys authentication states.
2
2
  import { Buffer } from 'node:buffer';
3
- import { proto } from 'whatsapp-rust-bridge/proto-types';
3
+ import { proto } from '@oxidezap/whatsapp-rust-bridge/proto-types';
4
4
  import { WA_DEFAULT_VERSION } from '../../Defaults/index.js';
5
5
  import { jidDecode, jidEncode } from '../../WABinary/jid-utils.js';
6
6
  import { DeviceRecordKey, JsonByteEncoding, SignalDomain, SignalKeyLength, TimeValue } from './constants.js';
@@ -1,6 +1,6 @@
1
1
  import { mkdir, readFile, stat, unlink, writeFile } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
- import { proto } from 'whatsapp-rust-bridge/proto-types';
3
+ import { proto } from '@oxidezap/whatsapp-rust-bridge/proto-types';
4
4
  import { BufferJSON } from '../../Utils/generics.js';
5
5
  import { JsonByteEncoding, LegacyMultiFile, LegacyStore } from './constants.js';
6
6
  const PATH_SEPARATOR = /[\\/]/g;
@@ -1,4 +1,4 @@
1
- import type { JsStoreCallbacks } from 'whatsapp-rust-bridge';
1
+ import type { JsStoreCallbacks } from '@oxidezap/whatsapp-rust-bridge';
2
2
  import type { SignalCreds, SignalKeyStore } from '../../Types/index.js';
3
3
  /**
4
4
  * Project a neutral native byte store as the public legacy key store. Values
@@ -1,4 +1,4 @@
1
- import type { JsStoreCallbacks } from 'whatsapp-rust-bridge';
1
+ import type { JsStoreCallbacks } from '@oxidezap/whatsapp-rust-bridge';
2
2
  import type { RouteKind } from './constants.js';
3
3
  export type LegacyStoreLogger = {
4
4
  warn(obj: unknown, msg?: string): void;
@@ -1,4 +1,4 @@
1
- import type { MediaType as BridgeMediaType } from 'whatsapp-rust-bridge';
1
+ import type { MediaType as BridgeMediaType } from '@oxidezap/whatsapp-rust-bridge';
2
2
  import type { MediaType } from '../Defaults/index.js';
3
3
  /**
4
4
  * Narrow upstream's semantic media names to the protocol-neutral modes the
@@ -1,4 +1,4 @@
1
- import type { MessageRetransmissionInput } from 'whatsapp-rust-bridge';
1
+ import type { MessageRetransmissionInput } from '@oxidezap/whatsapp-rust-bridge';
2
2
  import type { BinaryNode, MessageRelayOptions } from '../Types/index.js';
3
3
  type OwnRelayIdentity = {
4
4
  id?: string;
@@ -1,6 +1,6 @@
1
1
  import { Buffer } from 'node:buffer';
2
2
  import { createRequire } from 'node:module';
3
- import { BinaryReader } from 'whatsapp-rust-bridge';
3
+ import { BinaryReader } from '@oxidezap/whatsapp-rust-bridge';
4
4
  import { PROTO_ENUM_SCHEMAS, PROTO_FIELD_FLAG, PROTO_FIELD_KIND, PROTO_MESSAGE_SCHEMAS } from '../WAProto/compatibility-schema.js';
5
5
  const INSTANCE_SCHEMA = Symbol('proto compatibility schema');
6
6
  const EMPTY_ARRAY = Object.freeze([]);
@@ -138,24 +138,62 @@ const lookupPath = (root, path) => {
138
138
  }
139
139
  return current;
140
140
  };
141
- const installPath = (root, path, value) => {
142
- const segments = path.split('.');
143
- const leaf = segments.pop();
144
- if (!leaf)
145
- throw new Error(`invalid protobuf path: ${path}`);
146
- let current = root;
147
- for (const segment of segments) {
148
- const next = current[segment];
149
- if (!isObject(next) && typeof next !== 'function')
150
- current[segment] = {};
151
- current = current[segment];
152
- }
153
- current[leaf] = value;
154
- };
155
141
  const isSourceCodec = (value) => (isObject(value) || typeof value === 'function') &&
156
142
  typeof value.encode === 'function' &&
157
143
  typeof value.decode === 'function';
158
- const schemaDepth = (path) => path.split('.').length;
144
+ const buildSchemaTree = (messagePaths, enumPaths) => {
145
+ const roots = new Map();
146
+ const nodeFor = (path) => {
147
+ let level = roots;
148
+ let node;
149
+ for (const segment of path.split('.')) {
150
+ node = level.get(segment);
151
+ if (!node) {
152
+ node = { children: new Map() };
153
+ level.set(segment, node);
154
+ }
155
+ level = node.children;
156
+ }
157
+ if (!node)
158
+ throw new Error(`invalid protobuf path: ${path}`);
159
+ return node;
160
+ };
161
+ messagePaths.forEach((path, messageId) => {
162
+ nodeFor(path).messageId = messageId;
163
+ });
164
+ enumPaths.forEach((path, enumId) => {
165
+ nodeFor(path).enumId = enumId;
166
+ });
167
+ return roots;
168
+ };
169
+ /**
170
+ * Self-replacing getter, so only the first read pays. It stays enumerable,
171
+ * writable and configurable so the entry behaves exactly like the eagerly
172
+ * installed value it replaced.
173
+ *
174
+ * The replacement targets the *receiver*, not `target`: these descriptors are
175
+ * also copied onto the exported namespace, so writing back to `target` would
176
+ * leave the object actually being read still holding an accessor and rebuild on
177
+ * every access — 7.4% of process CPU when this was wrong.
178
+ */
179
+ const defineLazyValue = (target, key, build) => {
180
+ const settle = (receiver, value) => {
181
+ const owner = (isObject(receiver) || typeof receiver === 'function' ? receiver : target);
182
+ Object.defineProperty(owner, key, { configurable: true, enumerable: true, value, writable: true });
183
+ };
184
+ Object.defineProperty(target, key, {
185
+ configurable: true,
186
+ enumerable: true,
187
+ get() {
188
+ const value = build();
189
+ settle(this, value);
190
+ return value;
191
+ },
192
+ set(value) {
193
+ settle(this, value);
194
+ }
195
+ });
196
+ };
159
197
  class ProtoCompatibilityRuntime {
160
198
  constructor(sourceNamespace) {
161
199
  this.namespace = { ...sourceNamespace };
@@ -171,15 +209,41 @@ class ProtoCompatibilityRuntime {
171
209
  const candidate = lookupPath(sourceNamespace, path);
172
210
  return isSourceCodec(candidate) ? candidate : undefined;
173
211
  });
174
- this.constructors = PROTO_MESSAGE_SCHEMAS.map(([, fields], schemaId) => this.makeConstructor(schemaId, fields, this.sourceCodecs[schemaId]));
212
+ // Building all 498 eagerly cost ~4.6 MB of RSS in every importing process
213
+ // while a full connection touches under a dozen. `Array.from` rather than
214
+ // `new Array(n)` keeps the array packed, so `constructorFor`'s indexed read
215
+ // stays on the fast element kind.
216
+ this.constructors = Array.from({ length: PROTO_MESSAGE_SCHEMAS.length });
175
217
  this.unsupportedCodecs = Object.freeze(PROTO_MESSAGE_SCHEMAS.flatMap(([path], schemaId) => (this.sourceCodecs[schemaId] ? [] : [path])));
176
- const messageIds = PROTO_MESSAGE_SCHEMAS.map((_, index) => index).toSorted((left, right) => schemaDepth(PROTO_MESSAGE_SCHEMAS[left][0]) - schemaDepth(PROTO_MESSAGE_SCHEMAS[right][0]));
177
- for (const schemaId of messageIds) {
178
- installPath(this.namespace, PROTO_MESSAGE_SCHEMAS[schemaId][0], this.constructors[schemaId]);
179
- }
180
- const enumIds = PROTO_ENUM_SCHEMAS.map((_, index) => index).toSorted((left, right) => schemaDepth(PROTO_ENUM_SCHEMAS[left][0]) - schemaDepth(PROTO_ENUM_SCHEMAS[right][0]));
181
- for (const enumId of enumIds)
182
- installPath(this.namespace, PROTO_ENUM_SCHEMAS[enumId][0], this.enums[enumId].publicValue);
218
+ const tree = buildSchemaTree(PROTO_MESSAGE_SCHEMAS.map(([path]) => path), PROTO_ENUM_SCHEMAS.map(([path]) => path));
219
+ for (const [name, node] of tree)
220
+ this.installNode(this.namespace, name, node);
221
+ }
222
+ /**
223
+ * Nested types are installed only once their container is read, so using a
224
+ * container does not build what is nested inside it.
225
+ */
226
+ installNode(target, name, node) {
227
+ defineLazyValue(target, name, () => {
228
+ // Build once: this getter is reachable from both this namespace and the
229
+ // exported copy, and a second build would reinstall accessors over
230
+ // entries a caller had already resolved.
231
+ if (node.value === undefined) {
232
+ const value = node.messageId !== undefined
233
+ ? this.constructorFor(node.messageId)
234
+ : node.enumId !== undefined
235
+ ? this.enums[node.enumId].publicValue
236
+ : {};
237
+ node.value = value;
238
+ for (const [childName, child] of node.children)
239
+ this.installNode(value, childName, child);
240
+ }
241
+ return node.value;
242
+ });
243
+ }
244
+ constructorFor(schemaId) {
245
+ var _a;
246
+ return ((_a = this.constructors)[schemaId] ?? (_a[schemaId] = this.makeConstructor(schemaId, PROTO_MESSAGE_SCHEMAS[schemaId][1], this.sourceCodecs[schemaId])));
183
247
  }
184
248
  makeEnum(entries) {
185
249
  const publicValue = {};
@@ -312,7 +376,7 @@ class ProtoCompatibilityRuntime {
312
376
  }
313
377
  }
314
378
  fromObject(schemaId, input) {
315
- const constructor = this.constructors[schemaId];
379
+ const constructor = this.constructorFor(schemaId);
316
380
  if (input instanceof constructor)
317
381
  return input;
318
382
  const data = input;
@@ -494,34 +558,44 @@ class ProtoCompatibilityRuntime {
494
558
  const reader = new LongBinaryReader(asUint8Array(input));
495
559
  return this.hydrate(schemaId, codec.decode(reader, length));
496
560
  }
561
+ /**
562
+ * A fresh instance rather than an in-place re-parent: the codec installs its
563
+ * own `toJSON` on what it returns, and deleting that normalizes the object
564
+ * into dictionary mode, where every later read is a megamorphic lookup.
565
+ */
497
566
  hydrate(schemaId, value) {
498
- const object = isObject(value) ? value : {};
567
+ const source = isObject(value) ? value : {};
568
+ const instance = Object.create(this.constructorFor(schemaId).prototype);
499
569
  const messageFields = this.messageFieldsByName[schemaId];
500
- for (const key in object) {
570
+ for (const key in source) {
571
+ const nested = source[key];
501
572
  const field = messageFields[key];
502
- if (!field)
503
- continue;
504
- const nested = object[key];
505
- if (field[3] & PROTO_FIELD_FLAG.repeated) {
506
- if (Array.isArray(nested))
507
- for (const item of nested)
508
- if (isObject(item))
509
- this.hydrate(field[2], item);
573
+ if (!field) {
574
+ instance[key] = nested;
510
575
  }
511
- else if (field[3] & PROTO_FIELD_FLAG.map) {
512
- if (isObject(nested))
513
- for (const item of Object.values(nested))
576
+ else if (field[3] & PROTO_FIELD_FLAG.repeated) {
577
+ if (Array.isArray(nested)) {
578
+ for (let index = 0; index < nested.length; index++) {
579
+ const item = nested[index];
514
580
  if (isObject(item))
515
- this.hydrate(field[2], item);
581
+ nested[index] = this.hydrate(field[2], item);
582
+ }
583
+ }
584
+ instance[key] = nested;
516
585
  }
517
- else if (isObject(nested)) {
518
- this.hydrate(field[2], nested);
586
+ else if (field[3] & PROTO_FIELD_FLAG.map && isObject(nested)) {
587
+ const entries = {};
588
+ for (const entry in nested) {
589
+ const item = nested[entry];
590
+ entries[entry] = isObject(item) ? this.hydrate(field[2], item) : item;
591
+ }
592
+ instance[key] = entries;
593
+ }
594
+ else {
595
+ instance[key] = isObject(nested) ? this.hydrate(field[2], nested) : nested;
519
596
  }
520
597
  }
521
- if (hasOwn(object, 'toJSON'))
522
- delete object.toJSON;
523
- Object.setPrototypeOf(object, this.constructors[schemaId].prototype);
524
- return object;
598
+ return instance;
525
599
  }
526
600
  projectForEncode(schemaId, value) {
527
601
  if (!isObject(value))
@@ -1,4 +1,4 @@
1
- import type { BlocklistEntryResult, BusinessProfileResult, MembershipRequestResult, ParticipantChangeResult } from 'whatsapp-rust-bridge';
1
+ import type { BlocklistEntryResult, BusinessProfileResult, MembershipRequestResult, ParticipantChangeResult } from '@oxidezap/whatsapp-rust-bridge';
2
2
  import type { BinaryNode, WABusinessProfile } from '../Types/index.js';
3
3
  export type ParticipantUpdateResult = {
4
4
  status: string;
@@ -1,4 +1,4 @@
1
- import type { WasmWhatsAppClient } from 'whatsapp-rust-bridge';
1
+ import type { WasmWhatsAppClient } from '@oxidezap/whatsapp-rust-bridge';
2
2
  import type { BinaryNode } from '../Types/index.js';
3
3
  type StanzaResponseClient = Pick<WasmWhatsAppClient, 'acknowledgeStanza' | 'rejectStanza' | 'requestMessageRetry'>;
4
4
  export interface StanzaResponseContext {
@@ -1,4 +1,4 @@
1
- import type { UsyncQuery as NativeUsyncQuery, UsyncResponse as NativeUsyncResponse } from 'whatsapp-rust-bridge';
1
+ import type { UsyncQuery as NativeUsyncQuery, UsyncResponse as NativeUsyncResponse } from '@oxidezap/whatsapp-rust-bridge';
2
2
  import type { BinaryNode } from '../../Types/index.js';
3
3
  import { type USyncQuery, type USyncQueryResult } from '../../WAUSync/USyncQuery.js';
4
4
  export type RawUSyncQuery = (node: BinaryNode, timeoutMs?: number) => Promise<BinaryNode>;
@@ -1,6 +1,6 @@
1
1
  import { EventEmitter } from 'node:events';
2
2
  import { URL } from 'node:url';
3
- import type { WasmWhatsAppClient } from 'whatsapp-rust-bridge';
3
+ import type { WasmWhatsAppClient } from '@oxidezap/whatsapp-rust-bridge';
4
4
  import type { SocketConfig } from '../Types/index.js';
5
5
  type ReadyState = 0 | 1 | 2 | 3;
6
6
  type EventListener = Parameters<EventEmitter['on']>[1];
@@ -12,8 +12,13 @@ export declare class WebSocketClient extends EventEmitter {
12
12
  protected readonly socket: {
13
13
  readonly readyState: ReadyState;
14
14
  };
15
- private closing;
16
- private closed;
15
+ /**
16
+ * One value rather than a pair of booleans plus a promise that could
17
+ * disagree with them. `closing` carries the in-flight close so a second
18
+ * caller joins it instead of returning while the first `disconnect()` is
19
+ * still running — which is how teardown reached `free()` on a busy client.
20
+ */
21
+ private closeState;
17
22
  private readonly getClient;
18
23
  private listenerMutationDepth;
19
24
  constructor(url: string | URL, config: SocketConfig, getClient: () => WasmWhatsAppClient | undefined);
@@ -32,6 +37,22 @@ export declare class WebSocketClient extends EventEmitter {
32
37
  off(eventName: string | symbol, listener: EventListener): this;
33
38
  removeAllListeners(eventName?: string | symbol): this;
34
39
  connect(): void;
40
+ /**
41
+ * Idempotent, and a second caller joins the first rather than returning
42
+ * while it is still going.
43
+ *
44
+ * The early return used to be bare: `void ws.close(); await sock.end()` saw
45
+ * the flag, returned immediately, and let teardown reach `free()` with the
46
+ * original `disconnect()` still in flight — the wasm heap corruption
47
+ * `bridge-free-safety.test.ts` documents. Awaiting a *second* `disconnect()`
48
+ * does not join the first one.
49
+ *
50
+ * The state is stored before `disconnect()` is called, and the work is
51
+ * deferred by a microtask to make that ordering hold: an inline async body
52
+ * runs eagerly to its first `await`, so `disconnect()` would be invoked
53
+ * while the state still said `open`, and anything it reaches synchronously
54
+ * that calls back into `close()` would issue a second one.
55
+ */
35
56
  close(): Promise<void>;
36
57
  send(str: string | Uint8Array, cb?: (err?: Error) => void): boolean;
37
58
  private get readyState();