expo-openclaw-chat 0.2.4 → 0.2.5

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 (2) hide show
  1. package/package.json +1 -1
  2. package/src/core/client.ts +128 -59
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "expo-openclaw-chat",
3
- "version": "0.2.4",
3
+ "version": "0.2.5",
4
4
  "description": "Minimal chat SDK for Expo apps to connect to OpenClaw gateway",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -156,9 +156,14 @@ export class GatewayClient {
156
156
  // Sequence tracking
157
157
  private lastSeq = -1;
158
158
 
159
- // Handshake
160
- private connectPromiseResolve: ((value: HelloOk) => void) | null = null;
161
- private connectPromiseReject: ((reason: Error) => void) | null = null;
159
+ // Handshake — every overlapping connect() call pushes its own resolver pair
160
+ // here. Settled atomically (drain + clear) on hello-ok, connect failure, or
161
+ // close so callers can't be silently dropped if a later attempt overwrites
162
+ // the slot.
163
+ private connectPromisePending: Array<{
164
+ resolve: (value: HelloOk) => void;
165
+ reject: (reason: Error) => void;
166
+ }> = [];
162
167
  private challengeNonce: string | null = null;
163
168
  private helloOk: HelloOk | null = null;
164
169
 
@@ -213,23 +218,41 @@ export class GatewayClient {
213
218
  * Connect to the gateway. Resolves with HelloOk on successful handshake.
214
219
  */
215
220
  async connect(): Promise<HelloOk> {
221
+ if (this._connectionState === "connected" && this.helloOk) {
222
+ return this.helloOk;
223
+ }
224
+
225
+ // If already connecting or reconnecting, attach to the in-flight handshake
226
+ // instead of starting another one. This prevents the autoConnect effect
227
+ // from creating duplicate connection attempts during React lifecycle
228
+ // re-runs (e.g. when clientOptions or token deps change mid-handshake).
216
229
  if (
217
- this._connectionState === "connected" ||
218
- this._connectionState === "connecting"
230
+ this._connectionState === "connecting" ||
231
+ this._connectionState === "reconnecting"
219
232
  ) {
220
- throw new Error(`Already ${this._connectionState}`);
233
+ return new Promise<HelloOk>((resolve, reject) => {
234
+ this.connectPromisePending.push({ resolve, reject });
235
+ });
221
236
  }
222
237
 
223
238
  this.intentionalClose = false;
224
239
  this._connectInFlight = true;
225
240
  this.setConnectionState("connecting");
226
241
 
227
- // Load device identity before opening WebSocket
228
- await this.ensureIdentity();
242
+ // Load device identity before opening WebSocket. If this throws, any
243
+ // overlapping connect() callers that queued themselves during the await
244
+ // would otherwise hang forever and wedge the state machine at
245
+ // "connecting" — drain them with the same error.
246
+ try {
247
+ await this.ensureIdentity();
248
+ } catch (err) {
249
+ const error = err instanceof Error ? err : new Error(String(err));
250
+ this.handleConnectFailure(error);
251
+ throw error;
252
+ }
229
253
 
230
254
  return new Promise<HelloOk>((resolve, reject) => {
231
- this.connectPromiseResolve = resolve;
232
- this.connectPromiseReject = reject;
255
+ this.connectPromisePending.push({ resolve, reject });
233
256
  this.openWebSocket();
234
257
  });
235
258
  }
@@ -264,6 +287,13 @@ export class GatewayClient {
264
287
  this.ws = null;
265
288
  }
266
289
 
290
+ // Drain any callers that queued via connect() while we were
291
+ // reconnecting — without this they hang on a connection that's
292
+ // intentionally going away.
293
+ if (this.connectPromisePending.length > 0) {
294
+ this.drainConnectPending((p) => p.reject(new Error("Client disconnected")));
295
+ }
296
+
267
297
  this.setConnectionState("disconnected");
268
298
  }
269
299
 
@@ -285,24 +315,29 @@ export class GatewayClient {
285
315
  if (this.options.autoReconnect !== false && this._connectionState !== "disconnected") {
286
316
  const waitTimeout = 5000;
287
317
  let settled = false;
318
+ // Capture the timer so the listener path can clear it; otherwise
319
+ // each settled-by-listener request still pins a 5s timer + closure
320
+ // until it fires, which compounds badly during fan-out reconnects.
321
+ const waitTimer = setTimeout(() => {
322
+ if (settled) return;
323
+ settled = true;
324
+ unsub();
325
+ reject(new Error("Not connected"));
326
+ }, waitTimeout);
288
327
  const unsub = this.onConnectionStateChange((state) => {
289
328
  if (settled) return;
290
329
  if (state === "connected") {
291
330
  settled = true;
331
+ clearTimeout(waitTimer);
292
332
  unsub();
293
333
  this.request<T>(method, params, timeoutMs).then(resolve, reject);
294
334
  } else if (state === "disconnected") {
295
335
  settled = true;
336
+ clearTimeout(waitTimer);
296
337
  unsub();
297
338
  reject(new Error("Not connected"));
298
339
  }
299
340
  });
300
- setTimeout(() => {
301
- if (settled) return;
302
- settled = true;
303
- unsub();
304
- reject(new Error("Not connected"));
305
- }, waitTimeout);
306
341
  return;
307
342
  }
308
343
  return reject(new Error("Not connected"));
@@ -537,6 +572,17 @@ export class GatewayClient {
537
572
  // ─── Private: WebSocket Lifecycle ──────────────────────────────────────────
538
573
 
539
574
  private openWebSocket(): void {
575
+ // Don't open a new WebSocket if we already have a live one. The React
576
+ // lifecycle can race with reconnect attempts, leading to multiple
577
+ // overlapping connect frames and orphaned sockets.
578
+ if (
579
+ this.ws &&
580
+ (this.ws.readyState === WebSocket.OPEN ||
581
+ this.ws.readyState === WebSocket.CONNECTING)
582
+ ) {
583
+ return;
584
+ }
585
+
540
586
  // Auto-add wss:// if protocol is missing
541
587
  let url = this.url;
542
588
  if (!url.startsWith("wss://") && !url.startsWith("ws://")) {
@@ -563,11 +609,12 @@ export class GatewayClient {
563
609
  }
564
610
 
565
611
  this.ws.onopen = () => {
566
- // Wait for either connect.challenge or send connect immediately
567
- // The server may or may not send a challenge; set a small timeout
568
- // to send connect if no challenge arrives
612
+ // Wait briefly for an optional connect.challenge from the server. If a
613
+ // challenge arrives, handleChallenge() sends the connect frame with its
614
+ // nonce; otherwise we send with a self-generated nonce after the timeout.
615
+ // The React-lifecycle race that previously killed the WS during this
616
+ // window is now blocked by the _connectInFlight guard in disconnect().
569
617
  this.challengeNonce = null;
570
- // Give 2s for an optional challenge, then connect anyway
571
618
  setTimeout(() => {
572
619
  if (this._connectionState === "connecting" && !this.challengeNonce) {
573
620
  this.sendConnectFrame();
@@ -748,8 +795,14 @@ export class GatewayClient {
748
795
  this.awaitingPairing = false;
749
796
 
750
797
  if (payload.decision === "approved") {
751
- // Retry connect now that we're approved
752
- this.sendConnectFrame();
798
+ // If the WS is still open we can re-handshake immediately. If it's
799
+ // already closed (the gateway typically closes with 1008 before
800
+ // approval lands), let the auto-reconnect cycle that's already in
801
+ // flight do the next attempt — the device is now approved on the
802
+ // server, so the next connect will succeed.
803
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
804
+ this.sendConnectFrame();
805
+ }
753
806
  } else {
754
807
  // Rejected - fail the connection
755
808
  this.handleConnectFailure(new Error("Device pairing was denied"));
@@ -763,10 +816,12 @@ export class GatewayClient {
763
816
  }
764
817
 
765
818
  private handleHelloOk(helloOk: HelloOk): void {
766
- this._connectInFlight = false;
767
819
  this.helloOk = helloOk;
768
820
  this.reconnectAttempt = 0;
769
821
  this.lastSeq = -1;
822
+ // Clear any pending reconnect timer — a stale timer from a prior
823
+ // failed attempt could otherwise stomp on this fresh connection.
824
+ this.clearReconnectTimer();
770
825
 
771
826
  // Configure tick interval from policy
772
827
  if (helloOk.policy?.tickIntervalMs) {
@@ -776,11 +831,27 @@ export class GatewayClient {
776
831
  this.startTickMonitor();
777
832
  this.setConnectionState("connected");
778
833
 
779
- // Resolve the connect() promise
780
- if (this.connectPromiseResolve) {
781
- this.connectPromiseResolve(helloOk);
782
- this.connectPromiseResolve = null;
783
- this.connectPromiseReject = null;
834
+ this.drainConnectPending((p) => p.resolve(helloOk));
835
+ }
836
+
837
+ /**
838
+ * Settle every pending connect() caller atomically. Captures the queue,
839
+ * clears it, then invokes each callback with throws isolated so one bad
840
+ * listener can't strand the others. Called from hello-ok (resolve), connect
841
+ * failure (reject), and close (reject).
842
+ */
843
+ private drainConnectPending(
844
+ settle: (p: { resolve: (v: HelloOk) => void; reject: (e: Error) => void }) => void,
845
+ ): void {
846
+ this._connectInFlight = false;
847
+ const pending = this.connectPromisePending;
848
+ this.connectPromisePending = [];
849
+ for (const p of pending) {
850
+ try {
851
+ settle(p);
852
+ } catch {
853
+ // Ignore listener throws — other callers must still be settled.
854
+ }
784
855
  }
785
856
  }
786
857
 
@@ -909,13 +980,7 @@ export class GatewayClient {
909
980
  }
910
981
 
911
982
  private handleConnectFailure(error: Error): void {
912
- this._connectInFlight = false;
913
- if (this.connectPromiseReject) {
914
- this.connectPromiseReject(error);
915
- this.connectPromiseResolve = null;
916
- this.connectPromiseReject = null;
917
- }
918
-
983
+ this.drainConnectPending((p) => p.reject(error));
919
984
  this.setConnectionState("disconnected");
920
985
  }
921
986
 
@@ -936,7 +1001,11 @@ export class GatewayClient {
936
1001
  return;
937
1002
  }
938
1003
 
939
- // Gateway closed with 1008 "pairing required" — treat like NOT_PAIRED
1004
+ // Gateway closed with 1008 "pairing required" — emit event for UI and
1005
+ // fall through to auto-reconnect. On hosted gateways the server runs a
1006
+ // background loop that approves new devices within a few seconds, so a
1007
+ // backoff retry will succeed once approval lands. On self-hosted gateways
1008
+ // the user approves manually; auto-reconnect keeps trying in the meantime.
940
1009
  if (
941
1010
  code === 1008 &&
942
1011
  reason.toLowerCase().includes("pairing") &&
@@ -946,18 +1015,19 @@ export class GatewayClient {
946
1015
  this.emitEvent("pairing.required", {
947
1016
  deviceId: this.deviceIdentity?.deviceId,
948
1017
  });
949
- // Don't reject connect promise wait for user to get approved,
950
- // then the UI will retry the connection
951
- return;
1018
+ // Don't returnlet auto-reconnect schedule the retry below.
952
1019
  }
953
1020
 
954
- // If we were still in the initial connect(), reject it
955
- if (this.connectPromiseReject) {
956
- // Don't reject we'll try reconnecting
1021
+ // Reject any in-flight connect() promises so callers don't hang. The
1022
+ // auto-reconnect path will create fresh promises on the next attempt —
1023
+ // this just unblocks anything queued against the dying socket.
1024
+ if (this.connectPromisePending.length > 0) {
1025
+ const closeError = new Error(
1026
+ `WebSocket closed during connect: ${code} ${reason}`,
1027
+ );
1028
+ this.drainConnectPending((p) => p.reject(closeError));
957
1029
  if (!this.options.autoReconnect) {
958
- this.handleConnectFailure(
959
- new Error(`WebSocket closed during connect: ${code} ${reason}`),
960
- );
1030
+ this.setConnectionState("disconnected");
961
1031
  return;
962
1032
  }
963
1033
  }
@@ -1000,20 +1070,19 @@ export class GatewayClient {
1000
1070
  this.setConnectionState("connecting");
1001
1071
  this.challengeNonce = null;
1002
1072
 
1003
- // Wrap the reconnect in its own promise tracking
1004
- const prevResolve = this.connectPromiseResolve;
1005
-
1006
- // Keep original promise callbacks if we're reconnecting from a failed initial connect
1007
- if (!prevResolve) {
1008
- // Just reconnecting after a successful session that dropped
1009
- this.connectPromiseResolve = () => {}; // no-op, state already updated in handleHelloOk
1010
- this.connectPromiseReject = () => {
1011
- // Reconnect failure — try again
1012
- if (!this.intentionalClose && this.options.autoReconnect) {
1013
- this.setConnectionState("reconnecting");
1014
- this.scheduleReconnect();
1015
- }
1016
- };
1073
+ // If no caller is waiting on a connect() (we're just reconnecting after a
1074
+ // dropped session), seed an internal pending entry whose reject schedules
1075
+ // another retry. handleHelloOk will drain it as a no-op resolve.
1076
+ if (this.connectPromisePending.length === 0) {
1077
+ this.connectPromisePending.push({
1078
+ resolve: () => {},
1079
+ reject: () => {
1080
+ if (!this.intentionalClose && this.options.autoReconnect) {
1081
+ this.setConnectionState("reconnecting");
1082
+ this.scheduleReconnect();
1083
+ }
1084
+ },
1085
+ });
1017
1086
  }
1018
1087
 
1019
1088
  this.openWebSocket();