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.
- package/package.json +1 -1
- package/src/core/client.ts +128 -59
package/package.json
CHANGED
package/src/core/client.ts
CHANGED
|
@@ -156,9 +156,14 @@ export class GatewayClient {
|
|
|
156
156
|
// Sequence tracking
|
|
157
157
|
private lastSeq = -1;
|
|
158
158
|
|
|
159
|
-
// Handshake
|
|
160
|
-
|
|
161
|
-
|
|
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 === "
|
|
218
|
-
this._connectionState === "
|
|
230
|
+
this._connectionState === "connecting" ||
|
|
231
|
+
this._connectionState === "reconnecting"
|
|
219
232
|
) {
|
|
220
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
567
|
-
//
|
|
568
|
-
//
|
|
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
|
-
//
|
|
752
|
-
|
|
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
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
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.
|
|
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" —
|
|
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
|
|
950
|
-
// then the UI will retry the connection
|
|
951
|
-
return;
|
|
1018
|
+
// Don't return — let auto-reconnect schedule the retry below.
|
|
952
1019
|
}
|
|
953
1020
|
|
|
954
|
-
//
|
|
955
|
-
|
|
956
|
-
|
|
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.
|
|
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
|
-
//
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
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();
|