@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.
- package/README.md +147 -24
- package/lib/Compatibility/legacy-store/namespaces.d.ts +20 -0
- package/lib/Compatibility/legacy-store/namespaces.js +27 -0
- package/lib/Compatibility/newsletter-results.d.ts +15 -0
- package/lib/Compatibility/newsletter-results.js +38 -0
- package/lib/Compatibility/proto-runtime.js +30 -20
- package/lib/Compatibility/websocket-client.d.ts +23 -2
- package/lib/Compatibility/websocket-client.js +47 -18
- package/lib/Socket/bridge-client-owner.d.ts +89 -0
- package/lib/Socket/bridge-client-owner.js +135 -0
- package/lib/Socket/business.d.ts +29 -0
- package/lib/Socket/business.js +104 -0
- package/lib/Socket/chat-actions.d.ts +20 -11
- package/lib/Socket/chat-actions.js +171 -83
- package/lib/Socket/events.d.ts +31 -0
- package/lib/Socket/events.js +144 -41
- package/lib/Socket/index.d.ts +113 -16
- package/lib/Socket/index.js +468 -157
- package/lib/Socket/internals.d.ts +88 -0
- package/lib/Socket/internals.js +145 -0
- package/lib/Socket/messages.d.ts +1 -12
- package/lib/Socket/messages.js +3 -20
- package/lib/Socket/newsletter.d.ts +61 -6
- package/lib/Socket/newsletter.js +125 -7
- package/lib/Socket/privacy.d.ts +25 -0
- package/lib/Socket/privacy.js +54 -0
- package/lib/Socket/server-queries.d.ts +38 -0
- package/lib/Socket/server-queries.js +121 -0
- package/lib/Socket/terminal-close-reporter.d.ts +79 -0
- package/lib/Socket/terminal-close-reporter.js +108 -0
- package/lib/Socket/terminal-close.d.ts +39 -0
- package/lib/Socket/terminal-close.js +51 -0
- package/lib/Socket/types.d.ts +6 -0
- package/lib/Types/Product.d.ts +9 -0
- package/lib/Utils/event-buffer.js +31 -0
- package/lib/Utils/index.d.ts +1 -0
- package/lib/Utils/index.js +3 -0
- package/lib/Utils/link-preview.d.ts +60 -0
- package/lib/Utils/link-preview.js +357 -0
- package/lib/Utils/messages.d.ts +31 -7
- package/lib/Utils/messages.js +49 -17
- package/lib/Utils/wrap-legacy-store.d.ts +1 -0
- package/lib/Utils/wrap-legacy-store.js +1 -0
- package/package.json +4 -2
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 |
|
|
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 — **
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
}
|
|
89
|
-
// Auto-reconnect is handled by the Rust engine — no need to call makeWASocket again
|
|
90
|
-
}
|
|
91
|
-
if (connection === 'open') {
|
|
92
|
-
console.log('Connected')
|
|
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)))
|
|
93
94
|
}
|
|
94
|
-
}
|
|
95
|
+
}
|
|
95
96
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
+
})
|
|
101
134
|
|
|
135
|
+
return sock
|
|
136
|
+
}
|
|
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
|
|
160
|
-
|
|
161
|
-
|
|
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.
|
|
@@ -167,6 +220,76 @@ A few behaviors that differ from upstream — almost always to your advantage:
|
|
|
167
220
|
`(err as Boom).output.statusCode` pattern works unchanged. If your
|
|
168
221
|
`package.json` was pulling `@hapi/boom` only for baileys, you can drop
|
|
169
222
|
the dependency.
|
|
223
|
+
- **Your key store also holds bridge state, so "empty" is not "unpaired".**
|
|
224
|
+
See [Bridge state in your key store](#bridge-state-in-your-key-store) — this
|
|
225
|
+
one can break a boot path, so it has its own section.
|
|
226
|
+
|
|
227
|
+
### Bridge state in your key store
|
|
228
|
+
|
|
229
|
+
Upstream Baileys keeps engine state in `creds` (persisted by `saveCreds`) and
|
|
230
|
+
puts only Signal key material in `keys`. baileyrs uses that same `keys` store
|
|
231
|
+
as the persistence channel for the Rust core's own state as well: its device
|
|
232
|
+
record, and the byte-level records the Signal namespaces are projected from.
|
|
233
|
+
Those live under namespaces reserved with the **`bridge-` prefix**:
|
|
234
|
+
|
|
235
|
+
| namespace | what it holds |
|
|
236
|
+
| --- | --- |
|
|
237
|
+
| `bridge-native-*` | the core's own encoding of a namespace that also has a Baileys projection (`bridge-native-session`, `bridge-native-prekey`, `bridge-native-device`, …) |
|
|
238
|
+
| `bridge-*` | core records with no Baileys equivalent (`bridge-signed-prekey`, `bridge-sent-message`, `bridge-msg-secret`, `bridge-meta`, …) |
|
|
239
|
+
|
|
240
|
+
No upstream Baileys namespace starts with `bridge-`, and none of your data is
|
|
241
|
+
stored under one. Which of them you actually see depends on what the engine
|
|
242
|
+
touches; `bridge-native-device` shows up first, because the core reads its
|
|
243
|
+
device record while the socket is still `connecting` — **before any QR, before
|
|
244
|
+
any pairing**.
|
|
245
|
+
|
|
246
|
+
That last point is the one that bites. A store that counted rows to decide
|
|
247
|
+
whether this was a first run reports a session that does not exist:
|
|
248
|
+
|
|
249
|
+
```js
|
|
250
|
+
// WRONG on baileyrs: bridge-native-device is already in the table before pairing,
|
|
251
|
+
// so this never reports empty again — the bot skips its pairing flow and hangs.
|
|
252
|
+
const isEmpty = () => !creds.registered && !creds.me?.id && countKeys() === 0
|
|
253
|
+
|
|
254
|
+
// Right, on baileyrs and upstream alike: creds are the pairing record.
|
|
255
|
+
const isEmpty = () => !creds.registered && !creds.me?.id
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
**A non-empty key store is not evidence of a session.** `creds.registered` and
|
|
259
|
+
`creds.me?.id` are, and they are the only thing to check.
|
|
260
|
+
|
|
261
|
+
When you need to present a store the way upstream would — counting rows,
|
|
262
|
+
listing the Signal namespaces, exporting an upstream-shaped dump — filter the
|
|
263
|
+
bridge rows out with the exported classifier rather than matching the prefix
|
|
264
|
+
yourself. It is derived from the internal routing catalog, so a namespace added
|
|
265
|
+
in a later release is covered without you changing anything:
|
|
266
|
+
|
|
267
|
+
```ts
|
|
268
|
+
// Aliased install? Import from '@whiskeysockets/baileys' instead; it resolves here.
|
|
269
|
+
import { BRIDGE_INTERNAL_KEY_TYPES, isBridgeInternalKeyType } from '@oxidezap/baileyrs'
|
|
270
|
+
|
|
271
|
+
isBridgeInternalKeyType('bridge-native-device') // true
|
|
272
|
+
isBridgeInternalKeyType('pre-key') // false
|
|
273
|
+
|
|
274
|
+
// Every bridge-internal namespace, e.g. for a SQL `NOT IN (...)` clause. The
|
|
275
|
+
// store also holds the Baileys namespaces the engine projects into it.
|
|
276
|
+
BRIDGE_INTERNAL_KEY_TYPES
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
> **Do not drop these rows from a backup or a store-to-store move.** They are
|
|
280
|
+
> effective state, not metadata. `bridge-signed-prekey`, `bridge-sender-key-devices`,
|
|
281
|
+
> `bridge-base-key`, `bridge-sent-message`, `bridge-msg-secret`,
|
|
282
|
+
> `bridge-mutation-mac` and `bridge-meta` have no Baileys projection at all, and
|
|
283
|
+
> a Signal session that turned native-only lives under `bridge-native-session`
|
|
284
|
+
> alone. Restoring only the non-bridge rows rolls those sessions back or loses
|
|
285
|
+
> the state outright. Filter the bridge rows only where the destination is an
|
|
286
|
+
> upstream-shaped view that cannot represent them; a full backup, or a move
|
|
287
|
+
> between two baileyrs stores, carries every `bridge-` row across.
|
|
288
|
+
|
|
289
|
+
This applies to stores you own — the upstream `{ creds, keys }` shape that
|
|
290
|
+
baileyrs auto-wraps, including `useLegacyMultiFileAuthState`. It does not apply
|
|
291
|
+
to `useMultiFileAuthState`, whose `keys` is a projection over the engine's own
|
|
292
|
+
store; the `bridge-` namespaces never surface through it.
|
|
170
293
|
|
|
171
294
|
## Disclaimer
|
|
172
295
|
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Namespace prefix reserved for bridge-internal state. Every namespace the
|
|
3
|
+
* routing catalog writes back to a legacy key store starts with it, and no
|
|
4
|
+
* Baileys Signal namespace does, so the prefix is treated as reserved: a
|
|
5
|
+
* namespace added to the catalog later is classified without a code change.
|
|
6
|
+
*/
|
|
7
|
+
export declare const BRIDGE_INTERNAL_KEY_PREFIX = "bridge-";
|
|
8
|
+
/**
|
|
9
|
+
* Every namespace `wrapLegacyStore` can write that is not a Signal key.
|
|
10
|
+
* Derived from the routing catalog so it cannot drift from what is written.
|
|
11
|
+
*/
|
|
12
|
+
export declare const BRIDGE_INTERNAL_KEY_TYPES: readonly string[];
|
|
13
|
+
/**
|
|
14
|
+
* Whether a `SignalKeyStore` namespace holds bridge-internal state rather than
|
|
15
|
+
* Signal key material. Custom stores use it to skip those rows when they
|
|
16
|
+
* enumerate, count or migrate. Never throws: anything that is not a known
|
|
17
|
+
* namespace, including a non-string, is reported as not internal.
|
|
18
|
+
*/
|
|
19
|
+
export declare function isBridgeInternalKeyType(type: unknown): boolean;
|
|
20
|
+
//# sourceMappingURL=namespaces.d.ts.map
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Public classifier for the namespaces the bridge parks in a consumer's key store.
|
|
2
|
+
import { StoreCatalog } from './constants.js';
|
|
3
|
+
/**
|
|
4
|
+
* Namespace prefix reserved for bridge-internal state. Every namespace the
|
|
5
|
+
* routing catalog writes back to a legacy key store starts with it, and no
|
|
6
|
+
* Baileys Signal namespace does, so the prefix is treated as reserved: a
|
|
7
|
+
* namespace added to the catalog later is classified without a code change.
|
|
8
|
+
*/
|
|
9
|
+
export const BRIDGE_INTERNAL_KEY_PREFIX = 'bridge-';
|
|
10
|
+
/**
|
|
11
|
+
* Every namespace `wrapLegacyStore` can write that is not a Signal key.
|
|
12
|
+
* Derived from the routing catalog so it cannot drift from what is written.
|
|
13
|
+
*/
|
|
14
|
+
export const BRIDGE_INTERNAL_KEY_TYPES = Object.freeze([...new Set(Object.values(StoreCatalog).map(route => route.nativeType))].toSorted());
|
|
15
|
+
const internalTypes = new Set(BRIDGE_INTERNAL_KEY_TYPES);
|
|
16
|
+
/**
|
|
17
|
+
* Whether a `SignalKeyStore` namespace holds bridge-internal state rather than
|
|
18
|
+
* Signal key material. Custom stores use it to skip those rows when they
|
|
19
|
+
* enumerate, count or migrate. Never throws: anything that is not a known
|
|
20
|
+
* namespace, including a non-string, is reported as not internal.
|
|
21
|
+
*/
|
|
22
|
+
export function isBridgeInternalKeyType(type) {
|
|
23
|
+
if (typeof type !== 'string' || type.length === 0)
|
|
24
|
+
return false;
|
|
25
|
+
return type.startsWith(BRIDGE_INTERNAL_KEY_PREFIX) || internalTypes.has(type);
|
|
26
|
+
}
|
|
27
|
+
//# sourceMappingURL=namespaces.js.map
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { NewsletterMetadataResult } from '@oxidezap/whatsapp-rust-bridge';
|
|
2
|
+
import type { NewsletterMetadata, NewsletterViewRole } from '../Types/Newsletter.js';
|
|
3
|
+
/** Upstream's `NewsletterViewRole`, or undefined for a role it does not name. */
|
|
4
|
+
export declare const bridgeNewsletterRoleToBaileys: (role: string | undefined) => NewsletterViewRole | undefined;
|
|
5
|
+
/**
|
|
6
|
+
* Neutral newsletter metadata in upstream's shape.
|
|
7
|
+
*
|
|
8
|
+
* Four upstream fields have no source in the bridge result and stay absent
|
|
9
|
+
* rather than being invented: `owner` (the result carries the viewer's role,
|
|
10
|
+
* not the owner's jid), `mute_state` (the result's `state` is the newsletter's
|
|
11
|
+
* lifecycle, Active/Suspended, not a mute), `reaction_codes`, and
|
|
12
|
+
* `thread_metadata`.
|
|
13
|
+
*/
|
|
14
|
+
export declare const bridgeNewsletterMetadataToBaileys: (result: NewsletterMetadataResult) => NewsletterMetadata;
|
|
15
|
+
//# sourceMappingURL=newsletter-results.d.ts.map
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The bridge spells these the way the core's enums are named, and upstream
|
|
3
|
+
* spells them in screaming case. Written out rather than upper-cased blindly so
|
|
4
|
+
* a variant the core adds later shows up as `undefined` instead of as a string
|
|
5
|
+
* nobody declared.
|
|
6
|
+
*/
|
|
7
|
+
const VERIFICATION = {
|
|
8
|
+
Verified: 'VERIFIED',
|
|
9
|
+
Unverified: 'UNVERIFIED'
|
|
10
|
+
};
|
|
11
|
+
const VIEW_ROLE = {
|
|
12
|
+
Owner: 'OWNER',
|
|
13
|
+
Admin: 'ADMIN',
|
|
14
|
+
Subscriber: 'SUBSCRIBER',
|
|
15
|
+
Guest: 'GUEST'
|
|
16
|
+
};
|
|
17
|
+
/** Upstream's `NewsletterViewRole`, or undefined for a role it does not name. */
|
|
18
|
+
export const bridgeNewsletterRoleToBaileys = (role) => role === undefined ? undefined : VIEW_ROLE[role];
|
|
19
|
+
/**
|
|
20
|
+
* Neutral newsletter metadata in upstream's shape.
|
|
21
|
+
*
|
|
22
|
+
* Four upstream fields have no source in the bridge result and stay absent
|
|
23
|
+
* rather than being invented: `owner` (the result carries the viewer's role,
|
|
24
|
+
* not the owner's jid), `mute_state` (the result's `state` is the newsletter's
|
|
25
|
+
* lifecycle, Active/Suspended, not a mute), `reaction_codes`, and
|
|
26
|
+
* `thread_metadata`.
|
|
27
|
+
*/
|
|
28
|
+
export const bridgeNewsletterMetadataToBaileys = (result) => ({
|
|
29
|
+
id: result.jid,
|
|
30
|
+
name: result.name,
|
|
31
|
+
...(result.description !== undefined ? { description: result.description } : {}),
|
|
32
|
+
...(result.inviteCode !== undefined ? { invite: result.inviteCode } : {}),
|
|
33
|
+
...(result.creationTime !== undefined ? { creation_time: result.creationTime } : {}),
|
|
34
|
+
subscribers: result.subscriberCount,
|
|
35
|
+
...(VERIFICATION[result.verification] !== undefined ? { verification: VERIFICATION[result.verification] } : {}),
|
|
36
|
+
...(result.pictureUrl !== undefined ? { picture: { url: result.pictureUrl } } : {})
|
|
37
|
+
});
|
|
38
|
+
//# sourceMappingURL=newsletter-results.js.map
|
|
@@ -558,34 +558,44 @@ class ProtoCompatibilityRuntime {
|
|
|
558
558
|
const reader = new LongBinaryReader(asUint8Array(input));
|
|
559
559
|
return this.hydrate(schemaId, codec.decode(reader, length));
|
|
560
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
|
+
*/
|
|
561
566
|
hydrate(schemaId, value) {
|
|
562
|
-
const
|
|
567
|
+
const source = isObject(value) ? value : {};
|
|
568
|
+
const instance = Object.create(this.constructorFor(schemaId).prototype);
|
|
563
569
|
const messageFields = this.messageFieldsByName[schemaId];
|
|
564
|
-
for (const key in
|
|
570
|
+
for (const key in source) {
|
|
571
|
+
const nested = source[key];
|
|
565
572
|
const field = messageFields[key];
|
|
566
|
-
if (!field)
|
|
567
|
-
|
|
568
|
-
const nested = object[key];
|
|
569
|
-
if (field[3] & PROTO_FIELD_FLAG.repeated) {
|
|
570
|
-
if (Array.isArray(nested))
|
|
571
|
-
for (const item of nested)
|
|
572
|
-
if (isObject(item))
|
|
573
|
-
this.hydrate(field[2], item);
|
|
573
|
+
if (!field) {
|
|
574
|
+
instance[key] = nested;
|
|
574
575
|
}
|
|
575
|
-
else if (field[3] & PROTO_FIELD_FLAG.
|
|
576
|
-
if (
|
|
577
|
-
for (
|
|
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];
|
|
578
580
|
if (isObject(item))
|
|
579
|
-
this.hydrate(field[2], item);
|
|
581
|
+
nested[index] = this.hydrate(field[2], item);
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
instance[key] = nested;
|
|
580
585
|
}
|
|
581
|
-
else if (isObject(nested)) {
|
|
582
|
-
|
|
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;
|
|
583
596
|
}
|
|
584
597
|
}
|
|
585
|
-
|
|
586
|
-
delete object.toJSON;
|
|
587
|
-
Object.setPrototypeOf(object, this.constructorFor(schemaId).prototype);
|
|
588
|
-
return object;
|
|
598
|
+
return instance;
|
|
589
599
|
}
|
|
590
600
|
projectForEncode(schemaId, value) {
|
|
591
601
|
if (!isObject(value))
|
|
@@ -12,8 +12,13 @@ export declare class WebSocketClient extends EventEmitter {
|
|
|
12
12
|
protected readonly socket: {
|
|
13
13
|
readonly readyState: ReadyState;
|
|
14
14
|
};
|
|
15
|
-
|
|
16
|
-
|
|
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();
|
|
@@ -7,8 +7,13 @@ export const isRawNodeForwardingEnabled = (client) => client.hasRawNodeListeners
|
|
|
7
7
|
export class WebSocketClient extends EventEmitter {
|
|
8
8
|
constructor(url, config, getClient) {
|
|
9
9
|
super();
|
|
10
|
-
|
|
11
|
-
|
|
10
|
+
/**
|
|
11
|
+
* One value rather than a pair of booleans plus a promise that could
|
|
12
|
+
* disagree with them. `closing` carries the in-flight close so a second
|
|
13
|
+
* caller joins it instead of returning while the first `disconnect()` is
|
|
14
|
+
* still running — which is how teardown reached `free()` on a busy client.
|
|
15
|
+
*/
|
|
16
|
+
this.closeState = { phase: 'open' };
|
|
12
17
|
this.listenerMutationDepth = 0;
|
|
13
18
|
this.url = url instanceof URL ? url : new URL(url);
|
|
14
19
|
this.config = config;
|
|
@@ -24,13 +29,13 @@ export class WebSocketClient extends EventEmitter {
|
|
|
24
29
|
return this.getClient()?.isConnected() ?? false;
|
|
25
30
|
}
|
|
26
31
|
get isClosed() {
|
|
27
|
-
return this.closed;
|
|
32
|
+
return this.closeState.phase === 'closed';
|
|
28
33
|
}
|
|
29
34
|
get isClosing() {
|
|
30
|
-
return this.closing;
|
|
35
|
+
return this.closeState.phase === 'closing';
|
|
31
36
|
}
|
|
32
37
|
get isConnecting() {
|
|
33
|
-
return !this.isOpen &&
|
|
38
|
+
return !this.isOpen && this.closeState.phase === 'open';
|
|
34
39
|
}
|
|
35
40
|
get hasRawNodeListeners() {
|
|
36
41
|
return this.eventNames().some(eventName => isRawNodeEventName(eventName));
|
|
@@ -84,20 +89,44 @@ export class WebSocketClient extends EventEmitter {
|
|
|
84
89
|
const client = this.getClient();
|
|
85
90
|
if (!client || client.isConnected())
|
|
86
91
|
return;
|
|
87
|
-
|
|
92
|
+
// A close in flight has already told the client to disconnect, which is
|
|
93
|
+
// exactly what makes `isConnected()` false above. Reopening here would
|
|
94
|
+
// clear the `closing` phase, and the next `close()` would start a second
|
|
95
|
+
// disconnect against the same client while the first is still running.
|
|
96
|
+
if (this.closeState.phase === 'closing')
|
|
97
|
+
return;
|
|
98
|
+
this.closeState = { phase: 'open' };
|
|
88
99
|
void client.connect().catch(error => this.emit('error', error));
|
|
89
100
|
}
|
|
101
|
+
/**
|
|
102
|
+
* Idempotent, and a second caller joins the first rather than returning
|
|
103
|
+
* while it is still going.
|
|
104
|
+
*
|
|
105
|
+
* The early return used to be bare: `void ws.close(); await sock.end()` saw
|
|
106
|
+
* the flag, returned immediately, and let teardown reach `free()` with the
|
|
107
|
+
* original `disconnect()` still in flight — the wasm heap corruption
|
|
108
|
+
* `bridge-free-safety.test.ts` documents. Awaiting a *second* `disconnect()`
|
|
109
|
+
* does not join the first one.
|
|
110
|
+
*
|
|
111
|
+
* The state is stored before `disconnect()` is called, and the work is
|
|
112
|
+
* deferred by a microtask to make that ordering hold: an inline async body
|
|
113
|
+
* runs eagerly to its first `await`, so `disconnect()` would be invoked
|
|
114
|
+
* while the state still said `open`, and anything it reaches synchronously
|
|
115
|
+
* that calls back into `close()` would issue a second one.
|
|
116
|
+
*/
|
|
90
117
|
async close() {
|
|
91
|
-
if (this.
|
|
92
|
-
return;
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
}
|
|
118
|
+
if (this.closeState.phase !== 'open')
|
|
119
|
+
return this.closeState.done;
|
|
120
|
+
const done = Promise.resolve().then(async () => {
|
|
121
|
+
try {
|
|
122
|
+
await this.getClient()?.disconnect();
|
|
123
|
+
}
|
|
124
|
+
finally {
|
|
125
|
+
this.closeState = { phase: 'closed', done };
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
this.closeState = { phase: 'closing', done };
|
|
129
|
+
return done;
|
|
101
130
|
}
|
|
102
131
|
send(str, cb) {
|
|
103
132
|
const client = this.getClient();
|
|
@@ -110,9 +139,9 @@ export class WebSocketClient extends EventEmitter {
|
|
|
110
139
|
get readyState() {
|
|
111
140
|
if (this.isOpen)
|
|
112
141
|
return 1;
|
|
113
|
-
if (this.closing)
|
|
142
|
+
if (this.closeState.phase === 'closing')
|
|
114
143
|
return 2;
|
|
115
|
-
if (this.closed)
|
|
144
|
+
if (this.closeState.phase === 'closed')
|
|
116
145
|
return 3;
|
|
117
146
|
return 0;
|
|
118
147
|
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Owns the bridge client's lifetime.
|
|
3
|
+
*
|
|
4
|
+
* The socket's startup is async and its teardown can start at any point during
|
|
5
|
+
* it — a `sock.end()` right after `makeWASocket()`, an `await using` scope
|
|
6
|
+
* exiting, or a terminal disconnect the dispatcher reports while `init()` is
|
|
7
|
+
* still building the client. That window used to be managed by hand across six
|
|
8
|
+
* closure variables and a scattering of `if (ended) return` checks, which is
|
|
9
|
+
* where every teardown bug in this file came from: startup dereferencing a
|
|
10
|
+
* handle teardown had already nulled, a client built after teardown with
|
|
11
|
+
* nobody left to free it, an `end()` that resolved for its second caller while
|
|
12
|
+
* the first was still flushing, and a re-entrant close releasing the same wasm
|
|
13
|
+
* handle twice.
|
|
14
|
+
*
|
|
15
|
+
* All of those are one question — *what phase is this client in?* — so it is
|
|
16
|
+
* one discriminated union rather than a set of booleans that can disagree:
|
|
17
|
+
*
|
|
18
|
+
* ```
|
|
19
|
+
* starting ──adopt()──► running ──close()──► closing ──► closed
|
|
20
|
+
* │ │ ▲
|
|
21
|
+
* └──────close()────────┴──discard()─────────┘
|
|
22
|
+
* ```
|
|
23
|
+
*
|
|
24
|
+
* Two rules make the whole thing safe, and both are properties of the union
|
|
25
|
+
* rather than of any individual method:
|
|
26
|
+
*
|
|
27
|
+
* - **The transition is synchronous and happens first.** `close()` publishes
|
|
28
|
+
* `closing` — carrying the promise callers await — before any teardown work
|
|
29
|
+
* starts, so re-entering it finds an in-flight close instead of starting a
|
|
30
|
+
* second one. That is why the work is deferred by a microtask rather than
|
|
31
|
+
* started inline: an async body would otherwise run eagerly to its first
|
|
32
|
+
* `await`, i.e. before the state was stored.
|
|
33
|
+
* - **`closing` still carries the client.** The socket's transport close
|
|
34
|
+
* reads it back through `peek()` (`ws.close()` is `getClient()?.disconnect()`),
|
|
35
|
+
* so dropping the handle at the start of teardown silently turns that into
|
|
36
|
+
* a no-op and moves the disconnect after the auth-store flush. `isClosing()`
|
|
37
|
+
* — not `peek()` — is the "should I still be doing work" signal.
|
|
38
|
+
*/
|
|
39
|
+
import type { WasmWhatsAppClient } from '@oxidezap/whatsapp-rust-bridge';
|
|
40
|
+
import type { ILogger } from '../Utils/logger.js';
|
|
41
|
+
export interface BridgeClientOwnerOptions {
|
|
42
|
+
logger: ILogger;
|
|
43
|
+
/**
|
|
44
|
+
* The socket's own shutdown work. Receives the adopted client, if there is
|
|
45
|
+
* one, while it is still usable, and the error teardown was started with.
|
|
46
|
+
*
|
|
47
|
+
* Runs once. Throwing propagates to `close()`'s callers; the client is
|
|
48
|
+
* released either way.
|
|
49
|
+
*/
|
|
50
|
+
teardown: (client: WasmWhatsAppClient | undefined, error: Error | undefined) => Promise<void>;
|
|
51
|
+
/**
|
|
52
|
+
* Hand the client back to the bridge. Separate from `teardown` because
|
|
53
|
+
* ordering matters and the two have different failure semantics: this one
|
|
54
|
+
* is best-effort and never throws out.
|
|
55
|
+
*/
|
|
56
|
+
release: (client: WasmWhatsAppClient) => Promise<void>;
|
|
57
|
+
}
|
|
58
|
+
export interface BridgeClientOwner {
|
|
59
|
+
/** The client while `running` or `closing`; undefined before and after. */
|
|
60
|
+
peek: () => WasmWhatsAppClient | undefined;
|
|
61
|
+
/**
|
|
62
|
+
* Publish a freshly built client. Returns `false` when teardown has already
|
|
63
|
+
* started — the client is released here and the caller must stop, because
|
|
64
|
+
* nothing else will ever own it.
|
|
65
|
+
*/
|
|
66
|
+
adopt: (client: WasmWhatsAppClient) => boolean;
|
|
67
|
+
/** True from the moment `close()` is called. Startup checks it between awaits. */
|
|
68
|
+
isClosing: () => boolean;
|
|
69
|
+
/** Runs teardown once; later callers await that same run. */
|
|
70
|
+
close: (error: Error | undefined) => Promise<void>;
|
|
71
|
+
/**
|
|
72
|
+
* Drop the client without tearing the socket down — for a startup that
|
|
73
|
+
* failed after adopting, where the client exists but its read loop never
|
|
74
|
+
* started. Joins an in-flight close rather than releasing a client that
|
|
75
|
+
* close already owns.
|
|
76
|
+
*/
|
|
77
|
+
discard: () => Promise<void>;
|
|
78
|
+
/**
|
|
79
|
+
* Resolves once every release this owner started has finished, including
|
|
80
|
+
* the one a refused `adopt()` kicks off. Startup awaits it on the way out
|
|
81
|
+
* so its own promise does not settle with a release still in flight.
|
|
82
|
+
*
|
|
83
|
+
* A release can outlive the set it started in, so this drains rather than
|
|
84
|
+
* awaiting one snapshot.
|
|
85
|
+
*/
|
|
86
|
+
settled: () => Promise<void>;
|
|
87
|
+
}
|
|
88
|
+
export declare const makeBridgeClientOwner: (opts: BridgeClientOwnerOptions) => BridgeClientOwner;
|
|
89
|
+
//# sourceMappingURL=bridge-client-owner.d.ts.map
|