expo-openclaw-chat 0.2.3 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "expo-openclaw-chat",
3
- "version": "0.2.3",
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",
@@ -20,11 +20,22 @@
20
20
  "@noble/hashes": "^2.0.0"
21
21
  },
22
22
  "peerDependencies": {
23
+ "@expo/vector-icons": ">=14.0.0",
24
+ "expo-image-manipulator": ">=12.0.0",
25
+ "expo-image-picker": ">=14.0.0",
23
26
  "expo-secure-store": ">=13.0.0",
24
27
  "react": ">=18.0.0",
25
- "react-native": ">=0.72.0"
28
+ "react-native": ">=0.72.0",
29
+ "react-native-keyboard-controller": ">=1.12.0",
30
+ "react-native-safe-area-context": ">=4.0.0"
26
31
  },
27
32
  "peerDependenciesMeta": {
33
+ "expo-image-manipulator": {
34
+ "optional": true
35
+ },
36
+ "expo-image-picker": {
37
+ "optional": true
38
+ },
28
39
  "expo-secure-store": {
29
40
  "optional": true
30
41
  }
@@ -199,10 +199,19 @@ export class ChatEngine {
199
199
  idempotencyKey: generateIdempotencyKey(),
200
200
  });
201
201
 
202
+ // Skip the placeholder if delta/complete events for this runId already
203
+ // arrived over the WS while we were awaiting chatSend — otherwise we'd
204
+ // append an empty bubble next to the already-rendered reply.
205
+ const alreadyHandled = this._messages.some(
206
+ (m) => m.runId === response.runId && m.role === "assistant",
207
+ );
208
+ if (alreadyHandled) {
209
+ return;
210
+ }
211
+
202
212
  this._activeRunId = response.runId;
203
213
  this._isStreaming = true;
204
214
 
205
- // Add streaming placeholder
206
215
  const placeholder: UIMessage = {
207
216
  id: `asst-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`,
208
217
  role: "assistant",
@@ -65,6 +65,8 @@ export interface GatewayClientOptions {
65
65
  platform?: string;
66
66
  /** Client ID for gateway registration (default: openclaw-ios) */
67
67
  clientId?: string;
68
+ /** Extra headers to include in the WebSocket upgrade request (React Native only) */
69
+ headers?: Record<string, string>;
68
70
  }
69
71
 
70
72
  // ─── Event Listener Types ───────────────────────────────────────────────────────
@@ -154,9 +156,14 @@ export class GatewayClient {
154
156
  // Sequence tracking
155
157
  private lastSeq = -1;
156
158
 
157
- // Handshake
158
- private connectPromiseResolve: ((value: HelloOk) => void) | null = null;
159
- 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
+ }> = [];
160
167
  private challengeNonce: string | null = null;
161
168
  private helloOk: HelloOk | null = null;
162
169
 
@@ -166,6 +173,10 @@ export class GatewayClient {
166
173
  // Pairing flow state
167
174
  private awaitingPairing = false;
168
175
 
176
+ // Connection protection: when true, disconnect() is a no-op.
177
+ // Set during connect(), cleared on hello-ok or connect failure.
178
+ private _connectInFlight = false;
179
+
169
180
  constructor(url: string, options: GatewayClientOptions = {}) {
170
181
  this.url = url;
171
182
  this.options = {
@@ -207,22 +218,41 @@ export class GatewayClient {
207
218
  * Connect to the gateway. Resolves with HelloOk on successful handshake.
208
219
  */
209
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).
210
229
  if (
211
- this._connectionState === "connected" ||
212
- this._connectionState === "connecting"
230
+ this._connectionState === "connecting" ||
231
+ this._connectionState === "reconnecting"
213
232
  ) {
214
- throw new Error(`Already ${this._connectionState}`);
233
+ return new Promise<HelloOk>((resolve, reject) => {
234
+ this.connectPromisePending.push({ resolve, reject });
235
+ });
215
236
  }
216
237
 
217
238
  this.intentionalClose = false;
239
+ this._connectInFlight = true;
218
240
  this.setConnectionState("connecting");
219
241
 
220
- // Load device identity before opening WebSocket
221
- 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
+ }
222
253
 
223
254
  return new Promise<HelloOk>((resolve, reject) => {
224
- this.connectPromiseResolve = resolve;
225
- this.connectPromiseReject = reject;
255
+ this.connectPromisePending.push({ resolve, reject });
226
256
  this.openWebSocket();
227
257
  });
228
258
  }
@@ -231,6 +261,10 @@ export class GatewayClient {
231
261
  * Cleanly disconnect from the gateway.
232
262
  */
233
263
  disconnect(): void {
264
+ // Don't kill a connection that's mid-handshake (race with React effect cleanup)
265
+ if (this._connectInFlight) {
266
+ return;
267
+ }
234
268
  this.intentionalClose = true;
235
269
  this.awaitingPairing = false;
236
270
  this.clearReconnectTimer();
@@ -253,6 +287,13 @@ export class GatewayClient {
253
287
  this.ws = null;
254
288
  }
255
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
+
256
297
  this.setConnectionState("disconnected");
257
298
  }
258
299
 
@@ -274,24 +315,29 @@ export class GatewayClient {
274
315
  if (this.options.autoReconnect !== false && this._connectionState !== "disconnected") {
275
316
  const waitTimeout = 5000;
276
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);
277
327
  const unsub = this.onConnectionStateChange((state) => {
278
328
  if (settled) return;
279
329
  if (state === "connected") {
280
330
  settled = true;
331
+ clearTimeout(waitTimer);
281
332
  unsub();
282
333
  this.request<T>(method, params, timeoutMs).then(resolve, reject);
283
334
  } else if (state === "disconnected") {
284
335
  settled = true;
336
+ clearTimeout(waitTimer);
285
337
  unsub();
286
338
  reject(new Error("Not connected"));
287
339
  }
288
340
  });
289
- setTimeout(() => {
290
- if (settled) return;
291
- settled = true;
292
- unsub();
293
- reject(new Error("Not connected"));
294
- }, waitTimeout);
295
341
  return;
296
342
  }
297
343
  return reject(new Error("Not connected"));
@@ -526,6 +572,17 @@ export class GatewayClient {
526
572
  // ─── Private: WebSocket Lifecycle ──────────────────────────────────────────
527
573
 
528
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
+
529
586
  // Auto-add wss:// if protocol is missing
530
587
  let url = this.url;
531
588
  if (!url.startsWith("wss://") && !url.startsWith("ws://")) {
@@ -533,18 +590,31 @@ export class GatewayClient {
533
590
  }
534
591
 
535
592
  try {
536
- this.ws = new WebSocket(url);
593
+ const wsHeaders = this.options.headers;
594
+ if (wsHeaders && Object.keys(wsHeaders).length > 0) {
595
+ // React Native WebSocket accepts a 3rd argument for headers.
596
+ // Standard browser WebSocket does not, so we use a cast here.
597
+ const WS = WebSocket as unknown as new (
598
+ url: string,
599
+ protocols: null,
600
+ options: { headers: Record<string, string> },
601
+ ) => WebSocket;
602
+ this.ws = new WS(url, null, { headers: wsHeaders });
603
+ } else {
604
+ this.ws = new WebSocket(url);
605
+ }
537
606
  } catch (err) {
538
607
  this.handleConnectFailure(err as Error);
539
608
  return;
540
609
  }
541
610
 
542
611
  this.ws.onopen = () => {
543
- // Wait for either connect.challenge or send connect immediately
544
- // The server may or may not send a challenge; set a small timeout
545
- // 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().
546
617
  this.challengeNonce = null;
547
- // Give 2s for an optional challenge, then connect anyway
548
618
  setTimeout(() => {
549
619
  if (this._connectionState === "connecting" && !this.challengeNonce) {
550
620
  this.sendConnectFrame();
@@ -637,7 +707,6 @@ export class GatewayClient {
637
707
 
638
708
  private handleEvent(frame: EventFrame): void {
639
709
  const { event, payload, seq } = frame;
640
-
641
710
  // Sequence gap detection
642
711
  if (seq != null) {
643
712
  if (this.lastSeq >= 0 && seq > this.lastSeq + 1) {
@@ -668,15 +737,17 @@ export class GatewayClient {
668
737
  });
669
738
  break;
670
739
 
671
- case GatewayEvents.CHAT:
740
+ case GatewayEvents.CHAT: {
741
+ const chatPayload = payload as ChatEventPayload;
672
742
  this.chatEventListeners.forEach((cb) => {
673
743
  try {
674
- cb(payload as ChatEventPayload);
744
+ cb(chatPayload);
675
745
  } catch {
676
746
  // Ignore listener errors to avoid breaking event dispatch
677
747
  }
678
748
  });
679
749
  break;
750
+ }
680
751
 
681
752
  case GatewayEvents.AGENT:
682
753
  this.agentEventListeners.forEach((cb) => {
@@ -724,8 +795,14 @@ export class GatewayClient {
724
795
  this.awaitingPairing = false;
725
796
 
726
797
  if (payload.decision === "approved") {
727
- // Retry connect now that we're approved
728
- 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
+ }
729
806
  } else {
730
807
  // Rejected - fail the connection
731
808
  this.handleConnectFailure(new Error("Device pairing was denied"));
@@ -742,6 +819,9 @@ export class GatewayClient {
742
819
  this.helloOk = helloOk;
743
820
  this.reconnectAttempt = 0;
744
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();
745
825
 
746
826
  // Configure tick interval from policy
747
827
  if (helloOk.policy?.tickIntervalMs) {
@@ -751,11 +831,27 @@ export class GatewayClient {
751
831
  this.startTickMonitor();
752
832
  this.setConnectionState("connected");
753
833
 
754
- // Resolve the connect() promise
755
- if (this.connectPromiseResolve) {
756
- this.connectPromiseResolve(helloOk);
757
- this.connectPromiseResolve = null;
758
- 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
+ }
759
855
  }
760
856
  }
761
857
 
@@ -795,9 +891,17 @@ export class GatewayClient {
795
891
  const signedAtMs = Date.now();
796
892
  const authToken = (auth.token as string) ?? (auth.deviceToken as string);
797
893
 
894
+ // Generate a random nonce if no challenge was received from the gateway.
895
+ // OpenClaw 2026.3.x requires the nonce field in all device connect params.
896
+ const nonce =
897
+ this.challengeNonce ??
898
+ Array.from(crypto.getRandomValues(new Uint8Array(16)))
899
+ .map((b) => b.toString(16).padStart(2, "0"))
900
+ .join("");
901
+
798
902
  // Build signature payload (matches Swift implementation)
799
903
  const payload = buildSignaturePayload({
800
- nonce: this.challengeNonce ?? undefined,
904
+ nonce,
801
905
  deviceId: identity.deviceId,
802
906
  clientId,
803
907
  clientMode,
@@ -816,7 +920,7 @@ export class GatewayClient {
816
920
  publicKey,
817
921
  signature,
818
922
  signedAt: signedAtMs,
819
- nonce: this.challengeNonce ?? undefined,
923
+ nonce,
820
924
  };
821
925
  }
822
926
  }
@@ -876,12 +980,7 @@ export class GatewayClient {
876
980
  }
877
981
 
878
982
  private handleConnectFailure(error: Error): void {
879
- if (this.connectPromiseReject) {
880
- this.connectPromiseReject(error);
881
- this.connectPromiseResolve = null;
882
- this.connectPromiseReject = null;
883
- }
884
-
983
+ this.drainConnectPending((p) => p.reject(error));
885
984
  this.setConnectionState("disconnected");
886
985
  }
887
986
 
@@ -902,7 +1001,11 @@ export class GatewayClient {
902
1001
  return;
903
1002
  }
904
1003
 
905
- // 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.
906
1009
  if (
907
1010
  code === 1008 &&
908
1011
  reason.toLowerCase().includes("pairing") &&
@@ -912,18 +1015,19 @@ export class GatewayClient {
912
1015
  this.emitEvent("pairing.required", {
913
1016
  deviceId: this.deviceIdentity?.deviceId,
914
1017
  });
915
- // Don't reject connect promise wait for user to get approved,
916
- // then the UI will retry the connection
917
- return;
1018
+ // Don't returnlet auto-reconnect schedule the retry below.
918
1019
  }
919
1020
 
920
- // If we were still in the initial connect(), reject it
921
- if (this.connectPromiseReject) {
922
- // 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));
923
1029
  if (!this.options.autoReconnect) {
924
- this.handleConnectFailure(
925
- new Error(`WebSocket closed during connect: ${code} ${reason}`),
926
- );
1030
+ this.setConnectionState("disconnected");
927
1031
  return;
928
1032
  }
929
1033
  }
@@ -966,20 +1070,19 @@ export class GatewayClient {
966
1070
  this.setConnectionState("connecting");
967
1071
  this.challengeNonce = null;
968
1072
 
969
- // Wrap the reconnect in its own promise tracking
970
- const prevResolve = this.connectPromiseResolve;
971
-
972
- // Keep original promise callbacks if we're reconnecting from a failed initial connect
973
- if (!prevResolve) {
974
- // Just reconnecting after a successful session that dropped
975
- this.connectPromiseResolve = () => {}; // no-op, state already updated in handleHelloOk
976
- this.connectPromiseReject = () => {
977
- // Reconnect failure — try again
978
- if (!this.intentionalClose && this.options.autoReconnect) {
979
- this.setConnectionState("reconnecting");
980
- this.scheduleReconnect();
981
- }
982
- };
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
+ });
983
1086
  }
984
1087
 
985
1088
  this.openWebSocket();