expo-openclaw-chat 0.2.3 → 0.2.4

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.4",
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 ───────────────────────────────────────────────────────
@@ -166,6 +168,10 @@ export class GatewayClient {
166
168
  // Pairing flow state
167
169
  private awaitingPairing = false;
168
170
 
171
+ // Connection protection: when true, disconnect() is a no-op.
172
+ // Set during connect(), cleared on hello-ok or connect failure.
173
+ private _connectInFlight = false;
174
+
169
175
  constructor(url: string, options: GatewayClientOptions = {}) {
170
176
  this.url = url;
171
177
  this.options = {
@@ -215,6 +221,7 @@ export class GatewayClient {
215
221
  }
216
222
 
217
223
  this.intentionalClose = false;
224
+ this._connectInFlight = true;
218
225
  this.setConnectionState("connecting");
219
226
 
220
227
  // Load device identity before opening WebSocket
@@ -231,6 +238,10 @@ export class GatewayClient {
231
238
  * Cleanly disconnect from the gateway.
232
239
  */
233
240
  disconnect(): void {
241
+ // Don't kill a connection that's mid-handshake (race with React effect cleanup)
242
+ if (this._connectInFlight) {
243
+ return;
244
+ }
234
245
  this.intentionalClose = true;
235
246
  this.awaitingPairing = false;
236
247
  this.clearReconnectTimer();
@@ -533,7 +544,19 @@ export class GatewayClient {
533
544
  }
534
545
 
535
546
  try {
536
- this.ws = new WebSocket(url);
547
+ const wsHeaders = this.options.headers;
548
+ if (wsHeaders && Object.keys(wsHeaders).length > 0) {
549
+ // React Native WebSocket accepts a 3rd argument for headers.
550
+ // Standard browser WebSocket does not, so we use a cast here.
551
+ const WS = WebSocket as unknown as new (
552
+ url: string,
553
+ protocols: null,
554
+ options: { headers: Record<string, string> },
555
+ ) => WebSocket;
556
+ this.ws = new WS(url, null, { headers: wsHeaders });
557
+ } else {
558
+ this.ws = new WebSocket(url);
559
+ }
537
560
  } catch (err) {
538
561
  this.handleConnectFailure(err as Error);
539
562
  return;
@@ -637,7 +660,6 @@ export class GatewayClient {
637
660
 
638
661
  private handleEvent(frame: EventFrame): void {
639
662
  const { event, payload, seq } = frame;
640
-
641
663
  // Sequence gap detection
642
664
  if (seq != null) {
643
665
  if (this.lastSeq >= 0 && seq > this.lastSeq + 1) {
@@ -668,15 +690,17 @@ export class GatewayClient {
668
690
  });
669
691
  break;
670
692
 
671
- case GatewayEvents.CHAT:
693
+ case GatewayEvents.CHAT: {
694
+ const chatPayload = payload as ChatEventPayload;
672
695
  this.chatEventListeners.forEach((cb) => {
673
696
  try {
674
- cb(payload as ChatEventPayload);
697
+ cb(chatPayload);
675
698
  } catch {
676
699
  // Ignore listener errors to avoid breaking event dispatch
677
700
  }
678
701
  });
679
702
  break;
703
+ }
680
704
 
681
705
  case GatewayEvents.AGENT:
682
706
  this.agentEventListeners.forEach((cb) => {
@@ -739,6 +763,7 @@ export class GatewayClient {
739
763
  }
740
764
 
741
765
  private handleHelloOk(helloOk: HelloOk): void {
766
+ this._connectInFlight = false;
742
767
  this.helloOk = helloOk;
743
768
  this.reconnectAttempt = 0;
744
769
  this.lastSeq = -1;
@@ -795,9 +820,17 @@ export class GatewayClient {
795
820
  const signedAtMs = Date.now();
796
821
  const authToken = (auth.token as string) ?? (auth.deviceToken as string);
797
822
 
823
+ // Generate a random nonce if no challenge was received from the gateway.
824
+ // OpenClaw 2026.3.x requires the nonce field in all device connect params.
825
+ const nonce =
826
+ this.challengeNonce ??
827
+ Array.from(crypto.getRandomValues(new Uint8Array(16)))
828
+ .map((b) => b.toString(16).padStart(2, "0"))
829
+ .join("");
830
+
798
831
  // Build signature payload (matches Swift implementation)
799
832
  const payload = buildSignaturePayload({
800
- nonce: this.challengeNonce ?? undefined,
833
+ nonce,
801
834
  deviceId: identity.deviceId,
802
835
  clientId,
803
836
  clientMode,
@@ -816,7 +849,7 @@ export class GatewayClient {
816
849
  publicKey,
817
850
  signature,
818
851
  signedAt: signedAtMs,
819
- nonce: this.challengeNonce ?? undefined,
852
+ nonce,
820
853
  };
821
854
  }
822
855
  }
@@ -876,6 +909,7 @@ export class GatewayClient {
876
909
  }
877
910
 
878
911
  private handleConnectFailure(error: Error): void {
912
+ this._connectInFlight = false;
879
913
  if (this.connectPromiseReject) {
880
914
  this.connectPromiseReject(error);
881
915
  this.connectPromiseResolve = null;