expo-openclaw-chat 0.2.1 → 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.1",
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
  }
@@ -73,6 +84,6 @@
73
84
  "homepage": "https://github.com/brunobar79/expo-openclaw-chat",
74
85
  "repository": {
75
86
  "type": "git",
76
- "url": "https://github.com/brunobar79/expo-openclaw-chat"
87
+ "url": "git+https://github.com/brunobar79/expo-openclaw-chat.git"
77
88
  }
78
89
  }
@@ -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();
@@ -268,6 +279,32 @@ export class GatewayClient {
268
279
  ): Promise<T> {
269
280
  return new Promise<T>((resolve, reject) => {
270
281
  if (!this.ws || this._connectionState !== "connected") {
282
+ // If autoReconnect is enabled, wait up to 5s for reconnection
283
+ // instead of immediately rejecting. This prevents transient
284
+ // "Not connected" errors during WS flaps (e.g. group chat fan-out).
285
+ if (this.options.autoReconnect !== false && this._connectionState !== "disconnected") {
286
+ const waitTimeout = 5000;
287
+ let settled = false;
288
+ const unsub = this.onConnectionStateChange((state) => {
289
+ if (settled) return;
290
+ if (state === "connected") {
291
+ settled = true;
292
+ unsub();
293
+ this.request<T>(method, params, timeoutMs).then(resolve, reject);
294
+ } else if (state === "disconnected") {
295
+ settled = true;
296
+ unsub();
297
+ reject(new Error("Not connected"));
298
+ }
299
+ });
300
+ setTimeout(() => {
301
+ if (settled) return;
302
+ settled = true;
303
+ unsub();
304
+ reject(new Error("Not connected"));
305
+ }, waitTimeout);
306
+ return;
307
+ }
271
308
  return reject(new Error("Not connected"));
272
309
  }
273
310
 
@@ -507,7 +544,19 @@ export class GatewayClient {
507
544
  }
508
545
 
509
546
  try {
510
- 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
+ }
511
560
  } catch (err) {
512
561
  this.handleConnectFailure(err as Error);
513
562
  return;
@@ -611,7 +660,6 @@ export class GatewayClient {
611
660
 
612
661
  private handleEvent(frame: EventFrame): void {
613
662
  const { event, payload, seq } = frame;
614
-
615
663
  // Sequence gap detection
616
664
  if (seq != null) {
617
665
  if (this.lastSeq >= 0 && seq > this.lastSeq + 1) {
@@ -642,15 +690,17 @@ export class GatewayClient {
642
690
  });
643
691
  break;
644
692
 
645
- case GatewayEvents.CHAT:
693
+ case GatewayEvents.CHAT: {
694
+ const chatPayload = payload as ChatEventPayload;
646
695
  this.chatEventListeners.forEach((cb) => {
647
696
  try {
648
- cb(payload as ChatEventPayload);
697
+ cb(chatPayload);
649
698
  } catch {
650
699
  // Ignore listener errors to avoid breaking event dispatch
651
700
  }
652
701
  });
653
702
  break;
703
+ }
654
704
 
655
705
  case GatewayEvents.AGENT:
656
706
  this.agentEventListeners.forEach((cb) => {
@@ -699,7 +749,6 @@ export class GatewayClient {
699
749
 
700
750
  if (payload.decision === "approved") {
701
751
  // Retry connect now that we're approved
702
- console.log("[GatewayClient] Device approved, retrying connect...");
703
752
  this.sendConnectFrame();
704
753
  } else {
705
754
  // Rejected - fail the connection
@@ -714,6 +763,7 @@ export class GatewayClient {
714
763
  }
715
764
 
716
765
  private handleHelloOk(helloOk: HelloOk): void {
766
+ this._connectInFlight = false;
717
767
  this.helloOk = helloOk;
718
768
  this.reconnectAttempt = 0;
719
769
  this.lastSeq = -1;
@@ -770,9 +820,17 @@ export class GatewayClient {
770
820
  const signedAtMs = Date.now();
771
821
  const authToken = (auth.token as string) ?? (auth.deviceToken as string);
772
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
+
773
831
  // Build signature payload (matches Swift implementation)
774
832
  const payload = buildSignaturePayload({
775
- nonce: this.challengeNonce ?? undefined,
833
+ nonce,
776
834
  deviceId: identity.deviceId,
777
835
  clientId,
778
836
  clientMode,
@@ -791,7 +849,7 @@ export class GatewayClient {
791
849
  publicKey,
792
850
  signature,
793
851
  signedAt: signedAtMs,
794
- nonce: this.challengeNonce ?? undefined,
852
+ nonce,
795
853
  };
796
854
  }
797
855
  }
@@ -851,6 +909,7 @@ export class GatewayClient {
851
909
  }
852
910
 
853
911
  private handleConnectFailure(error: Error): void {
912
+ this._connectInFlight = false;
854
913
  if (this.connectPromiseReject) {
855
914
  this.connectPromiseReject(error);
856
915
  this.connectPromiseResolve = null;
@@ -69,7 +69,7 @@ export interface ChatInstance {
69
69
  export function createChat(config: CreateChatConfig): ChatInstance {
70
70
  const {
71
71
  gatewayUrl,
72
- sessionKey = `chat-${Date.now().toString(36)}`,
72
+ sessionKey = `agent:main:chat-${Date.now().toString(36)}`,
73
73
  title,
74
74
  placeholder,
75
75
  showImagePicker,