@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.
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.
@@ -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 object = isObject(value) ? value : {};
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 object) {
570
+ for (const key in source) {
571
+ const nested = source[key];
565
572
  const field = messageFields[key];
566
- if (!field)
567
- continue;
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.map) {
576
- if (isObject(nested))
577
- 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];
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
- 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;
583
596
  }
584
597
  }
585
- if (hasOwn(object, 'toJSON'))
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
- 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();
@@ -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
- this.closing = false;
11
- this.closed = false;
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 && !this.closing && !this.closed;
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
- this.closed = false;
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.closing || this.closed)
92
- return;
93
- this.closing = true;
94
- try {
95
- await this.getClient()?.disconnect();
96
- }
97
- finally {
98
- this.closing = false;
99
- this.closed = true;
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
@@ -0,0 +1,135 @@
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
+ export const makeBridgeClientOwner = (opts) => {
40
+ const { logger, teardown, release } = opts;
41
+ let state = { phase: 'starting' };
42
+ /** Releases started outside `close()`, so `settled()` can join them. */
43
+ const pendingReleases = new Set();
44
+ const releaseQuietly = async (target) => {
45
+ try {
46
+ await release(target);
47
+ }
48
+ catch (err) {
49
+ logger.error({ err }, 'failed to release the bridge client');
50
+ }
51
+ };
52
+ /** `releaseQuietly`, but joinable through `settled()`. */
53
+ const trackRelease = (target) => {
54
+ const running = releaseQuietly(target).finally(() => pendingReleases.delete(running));
55
+ pendingReleases.add(running);
56
+ return running;
57
+ };
58
+ /** Drain releases started outside this close — see `runClose`. */
59
+ const drainReleases = async () => {
60
+ while (pendingReleases.size)
61
+ await Promise.all(pendingReleases);
62
+ };
63
+ const runClose = async (client, error, done) => {
64
+ try {
65
+ await teardown(client, error);
66
+ }
67
+ finally {
68
+ state = { phase: 'closed', done };
69
+ if (client)
70
+ await releaseQuietly(client);
71
+ // A `discard()` in flight when this close started put the client
72
+ // back in `starting`, so the capture above found none and teardown
73
+ // ran without it. Its release is still going: joining here keeps
74
+ // `close()` from settling while a client is being disconnected and
75
+ // freed, which is what the caller is told it can rely on.
76
+ await drainReleases();
77
+ }
78
+ };
79
+ return {
80
+ peek: () => (state.phase === 'running' || state.phase === 'closing' ? state.client : undefined),
81
+ adopt: candidate => {
82
+ if (state.phase !== 'starting') {
83
+ // Teardown has already been through here and found nothing, so
84
+ // this client would have no owner: nothing would free it and its
85
+ // read loop would reconnect forever against a disposed socket.
86
+ void trackRelease(candidate);
87
+ return false;
88
+ }
89
+ state = { phase: 'running', client: candidate };
90
+ return true;
91
+ },
92
+ isClosing: () => state.phase === 'closing' || state.phase === 'closed',
93
+ close: error => {
94
+ if (state.phase === 'closing' || state.phase === 'closed') {
95
+ logger.trace({ trace: error?.stack }, 'already closing; awaiting the in-flight teardown');
96
+ return state.done;
97
+ }
98
+ const client = state.phase === 'running' ? state.client : undefined;
99
+ // Deferred by a microtask so the `closing` state below is stored
100
+ // before any teardown work runs. Calling `runClose` inline would
101
+ // execute its body eagerly up to the first `await` — teardown starts
102
+ // by closing the transport — and anything reached synchronously that
103
+ // calls back into `close()` would find no in-flight close and start
104
+ // a second teardown, releasing the same wasm handle twice.
105
+ const done = Promise.resolve().then(() => runClose(client, error, done));
106
+ state = { phase: 'closing', client, done };
107
+ return done;
108
+ },
109
+ discard: async () => {
110
+ // `close()` owns the client from the moment it starts, and keeps it
111
+ // published for the whole of teardown — releasing it here as well
112
+ // would be two disconnect/free sequences on one handle. Join instead.
113
+ //
114
+ // Joining without adopting the failure: `close()` rejects when
115
+ // teardown rethrows the first auth-store flush error, and this is
116
+ // best-effort cleanup called from `init()`'s catch. Propagating it
117
+ // would make `initPromise` reject — which the socket documents as
118
+ // impossible, relies on for `getClient()`'s error message, and does
119
+ // not always await, so it could surface as an unhandled rejection.
120
+ if (state.phase === 'closing' || state.phase === 'closed') {
121
+ await state.done.catch(() => { });
122
+ return;
123
+ }
124
+ if (state.phase !== 'running')
125
+ return;
126
+ const { client } = state;
127
+ // Back to `starting`, not `closed`: a later `close()` must still run
128
+ // teardown for everything the socket owns beyond the client.
129
+ state = { phase: 'starting' };
130
+ await trackRelease(client);
131
+ },
132
+ settled: drainReleases
133
+ };
134
+ };
135
+ //# sourceMappingURL=bridge-client-owner.js.map
@@ -20,6 +20,37 @@ interface EventCallbacks {
20
20
  onDirtyState?: (event: Extract<CanonicalEvent, {
21
21
  type: 'dirtyState';
22
22
  }>) => void;
23
+ /**
24
+ * The engine has stopped reconnecting: this client is dead weight only
25
+ * `free()` can reclaim.
26
+ *
27
+ * Owns publishing the `close` too — `publish()` must be called, and the
28
+ * point of handing it over is that the socket can finish tearing down
29
+ * first. Upstream does the same, emitting its close only after `ws.close()`
30
+ * and the end handlers (`Socket/socket.ts`). A consumer answering `close`
31
+ * with a replacement socket on the same auth folder would otherwise race
32
+ * the old one's store flush and `free()`.
33
+ */
34
+ onTerminalClose?: (error: Error, publish: () => void) => void;
35
+ /**
36
+ * Hand back a cleanup for anything the dispatcher armed that outlives a
37
+ * single event — today the history-sync pause timer. The socket registers
38
+ * it as an end handler, so a plain `sock.end()` or an `await using` scope
39
+ * exiting cancels it too: only the terminal-close path goes through
40
+ * `emitClose`, and a timer surviving disposal fires
41
+ * `messaging-history.status: paused` from a socket that is already gone.
42
+ *
43
+ * Called once, during `makeEventHandlers`.
44
+ */
45
+ onCleanup?: (cleanup: () => void) => void;
46
+ /**
47
+ * Whether `sock.setAutoReconnect(true)` is in effect. A plain drop is only
48
+ * transient while the engine still intends to retry: with auto-reconnect
49
+ * off, the run loop dispatches `Disconnected` and then breaks for good
50
+ * (`client/lifecycle.rs` tests the flag *after* the dispatch), so the same
51
+ * event becomes terminal. Absent callback means the default, enabled.
52
+ */
53
+ isAutoReconnectEnabled?: () => boolean;
23
54
  }
24
55
  /**
25
56
  * Create typed single and batch handlers for the bridge. The bridge only uses