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 +13 -2
- package/src/chat/engine.ts +10 -1
- package/src/core/client.ts +40 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "expo-openclaw-chat",
|
|
3
|
-
"version": "0.2.
|
|
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
|
}
|
package/src/chat/engine.ts
CHANGED
|
@@ -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",
|
package/src/core/client.ts
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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
|
|
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
|
|
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;
|