@zhihand/mcp 0.29.0 → 0.32.0
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/bin/zhihand +448 -212
- package/dist/core/command.d.ts +5 -5
- package/dist/core/command.js +6 -8
- package/dist/core/config.d.ts +48 -21
- package/dist/core/config.js +178 -42
- package/dist/core/device.d.ts +28 -19
- package/dist/core/device.js +168 -145
- package/dist/core/logger.d.ts +17 -0
- package/dist/core/logger.js +32 -0
- package/dist/core/pair.d.ts +39 -31
- package/dist/core/pair.js +205 -77
- package/dist/core/registry.d.ts +60 -0
- package/dist/core/registry.js +415 -0
- package/dist/core/screenshot.d.ts +3 -3
- package/dist/core/screenshot.js +3 -2
- package/dist/core/sse.d.ts +40 -18
- package/dist/core/sse.js +122 -62
- package/dist/core/ws.d.ts +92 -0
- package/dist/core/ws.js +327 -0
- package/dist/daemon/dispatcher.d.ts +3 -1
- package/dist/daemon/dispatcher.js +4 -3
- package/dist/daemon/heartbeat.d.ts +4 -4
- package/dist/daemon/heartbeat.js +1 -1
- package/dist/daemon/index.js +10 -8
- package/dist/daemon/prompt-listener.d.ts +8 -7
- package/dist/daemon/prompt-listener.js +59 -99
- package/dist/index.d.ts +3 -3
- package/dist/index.js +104 -40
- package/dist/openclaw.adapter.js +10 -2
- package/dist/tools/control.d.ts +10 -3
- package/dist/tools/control.js +18 -24
- package/dist/tools/pair.d.ts +1 -1
- package/dist/tools/pair.js +22 -28
- package/dist/tools/resolve.d.ts +7 -0
- package/dist/tools/resolve.js +22 -0
- package/dist/tools/schemas.d.ts +9 -1
- package/dist/tools/schemas.js +10 -8
- package/dist/tools/screenshot.d.ts +3 -2
- package/dist/tools/screenshot.js +2 -2
- package/dist/tools/system.d.ts +3 -5
- package/dist/tools/system.js +19 -6
- package/package.json +3 -1
package/dist/core/sse.js
CHANGED
|
@@ -1,16 +1,13 @@
|
|
|
1
1
|
import { getCommand } from "./command.js";
|
|
2
|
-
import {
|
|
3
|
-
// Per-commandId callback registry for SSE-based ACK
|
|
2
|
+
import { log } from "./logger.js";
|
|
3
|
+
// Per-commandId callback registry for SSE-based ACK (global — ids are globally unique)
|
|
4
4
|
const ackCallbacks = new Map();
|
|
5
|
-
// Active SSE connection state
|
|
6
|
-
let sseAbortController = null;
|
|
7
|
-
let sseConnected = false;
|
|
8
5
|
export function handleSSEEvent(event) {
|
|
9
|
-
|
|
6
|
+
log.debug(`[sse-cmd] Event: kind=${event.kind}, command=${event.command?.id ?? "-"}`);
|
|
10
7
|
if (event.kind === "command.acked" && event.command) {
|
|
11
8
|
const callback = ackCallbacks.get(event.command.id);
|
|
12
9
|
if (callback) {
|
|
13
|
-
|
|
10
|
+
log.debug(`[sse-cmd] ACK callback for ${event.command.id}, ack_status=${event.command.ack_status}, ack_result=${JSON.stringify(event.command.ack_result ?? null)}`);
|
|
14
11
|
callback(event.command);
|
|
15
12
|
ackCallbacks.delete(event.command.id);
|
|
16
13
|
}
|
|
@@ -20,36 +17,59 @@ export function subscribeToCommandAck(commandId, callback) {
|
|
|
20
17
|
ackCallbacks.set(commandId, callback);
|
|
21
18
|
return () => { ackCallbacks.delete(commandId); };
|
|
22
19
|
}
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
20
|
+
export class UserEventStream {
|
|
21
|
+
userId;
|
|
22
|
+
controllerToken;
|
|
23
|
+
endpoint;
|
|
24
|
+
handlers;
|
|
25
|
+
abortController = null;
|
|
26
|
+
_connected = false;
|
|
27
|
+
constructor(userId, controllerToken, endpoint, handlers) {
|
|
28
|
+
this.userId = userId;
|
|
29
|
+
this.controllerToken = controllerToken;
|
|
30
|
+
this.endpoint = endpoint;
|
|
31
|
+
this.handlers = handlers;
|
|
32
|
+
}
|
|
33
|
+
get connected() {
|
|
34
|
+
return this._connected;
|
|
35
|
+
}
|
|
36
|
+
start() {
|
|
37
|
+
if (this.abortController)
|
|
38
|
+
return;
|
|
39
|
+
this.abortController = new AbortController();
|
|
40
|
+
this.runLoop(this.abortController.signal);
|
|
41
|
+
}
|
|
42
|
+
stop() {
|
|
43
|
+
this.abortController?.abort();
|
|
44
|
+
this.abortController = null;
|
|
45
|
+
this._connected = false;
|
|
46
|
+
}
|
|
47
|
+
async runLoop(signal) {
|
|
48
|
+
let backoffMs = 1000;
|
|
49
|
+
const BACKOFF_MAX = 30_000;
|
|
50
|
+
const topics = "commands,device_profile,device.online,device.offline,credential.added,credential.removed";
|
|
51
|
+
const url = `${this.endpoint}/v1/users/${encodeURIComponent(this.userId)}/events/stream?topic=${topics}`;
|
|
35
52
|
while (!signal.aborted) {
|
|
36
53
|
try {
|
|
37
54
|
const response = await fetch(url, {
|
|
38
55
|
headers: {
|
|
39
56
|
"Accept": "text/event-stream",
|
|
40
|
-
"
|
|
57
|
+
"Authorization": `Bearer ${this.controllerToken}`,
|
|
41
58
|
},
|
|
42
59
|
signal,
|
|
43
60
|
});
|
|
44
61
|
if (!response.ok) {
|
|
45
62
|
throw new Error(`SSE connect failed: ${response.status}`);
|
|
46
63
|
}
|
|
47
|
-
|
|
64
|
+
this._connected = true;
|
|
65
|
+
this.handlers.onConnected();
|
|
66
|
+
backoffMs = 1000;
|
|
48
67
|
const reader = response.body?.getReader();
|
|
49
68
|
if (!reader)
|
|
50
69
|
throw new Error("No response body for SSE");
|
|
51
70
|
const decoder = new TextDecoder();
|
|
52
71
|
let buffer = "";
|
|
72
|
+
let eventData = "";
|
|
53
73
|
while (!signal.aborted) {
|
|
54
74
|
const { done, value } = await reader.read();
|
|
55
75
|
if (done)
|
|
@@ -57,18 +77,17 @@ export function connectSSE(config) {
|
|
|
57
77
|
buffer += decoder.decode(value, { stream: true });
|
|
58
78
|
const lines = buffer.split("\n");
|
|
59
79
|
buffer = lines.pop() ?? "";
|
|
60
|
-
let eventData = "";
|
|
61
80
|
for (const line of lines) {
|
|
62
81
|
if (line.startsWith("data: ")) {
|
|
63
|
-
eventData += line.slice(6);
|
|
82
|
+
eventData += (eventData ? "\n" : "") + line.slice(6);
|
|
64
83
|
}
|
|
65
84
|
else if (line === "" && eventData) {
|
|
66
85
|
try {
|
|
67
|
-
const
|
|
68
|
-
|
|
86
|
+
const ev = JSON.parse(eventData);
|
|
87
|
+
this.dispatchEvent(ev);
|
|
69
88
|
}
|
|
70
89
|
catch {
|
|
71
|
-
//
|
|
90
|
+
// malformed, skip
|
|
72
91
|
}
|
|
73
92
|
eventData = "";
|
|
74
93
|
}
|
|
@@ -78,37 +97,68 @@ export function connectSSE(config) {
|
|
|
78
97
|
catch (err) {
|
|
79
98
|
if (signal.aborted)
|
|
80
99
|
break;
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
await new Promise((r) => setTimeout(r,
|
|
100
|
+
this._connected = false;
|
|
101
|
+
this.handlers.onDisconnected();
|
|
102
|
+
await new Promise((r) => setTimeout(r, backoffMs));
|
|
103
|
+
backoffMs = Math.min(backoffMs * 2, BACKOFF_MAX);
|
|
84
104
|
}
|
|
85
105
|
}
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
106
|
+
this._connected = false;
|
|
107
|
+
this.handlers.onDisconnected();
|
|
108
|
+
}
|
|
109
|
+
dispatchEvent(ev) {
|
|
110
|
+
// Always dispatch command ACKs globally
|
|
111
|
+
handleSSEEvent(ev);
|
|
112
|
+
switch (ev.kind) {
|
|
113
|
+
case "device.online":
|
|
114
|
+
this.handlers.onDeviceOnline(ev.credential_id);
|
|
115
|
+
break;
|
|
116
|
+
case "device.offline":
|
|
117
|
+
this.handlers.onDeviceOffline(ev.credential_id);
|
|
118
|
+
break;
|
|
119
|
+
case "device_profile.updated":
|
|
120
|
+
if (ev.device_profile) {
|
|
121
|
+
this.handlers.onDeviceProfileUpdated(ev.credential_id, ev.device_profile);
|
|
122
|
+
}
|
|
123
|
+
break;
|
|
124
|
+
case "command.acked":
|
|
125
|
+
this.handlers.onCommandAcked(ev);
|
|
126
|
+
break;
|
|
127
|
+
case "credential.added":
|
|
128
|
+
// The credential.added event carries the new credential metadata
|
|
129
|
+
// in ev.credential (with credential_id, label, platform, etc.).
|
|
130
|
+
// Fall back to the root event if ev.credential is absent, since
|
|
131
|
+
// credential_id is always on the root SSEEvent.
|
|
132
|
+
this.handlers.onCredentialAdded(ev.credential ?? { credential_id: ev.credential_id });
|
|
133
|
+
break;
|
|
134
|
+
case "credential.removed":
|
|
135
|
+
this.handlers.onCredentialRemoved(ev.credential_id);
|
|
136
|
+
break;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
96
139
|
}
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
140
|
+
export async function fetchUserCredentials(endpoint, userId, controllerToken, onlineFilter) {
|
|
141
|
+
let url = `${endpoint}/v1/users/${encodeURIComponent(userId)}/credentials`;
|
|
142
|
+
if (onlineFilter !== undefined) {
|
|
143
|
+
url += `?online=${onlineFilter}`;
|
|
144
|
+
}
|
|
145
|
+
const response = await fetch(url, {
|
|
146
|
+
headers: { "Authorization": `Bearer ${controllerToken}` },
|
|
147
|
+
signal: AbortSignal.timeout(10_000),
|
|
148
|
+
});
|
|
149
|
+
if (!response.ok) {
|
|
150
|
+
throw new Error(`Fetch credentials failed: ${response.status}`);
|
|
151
|
+
}
|
|
152
|
+
const data = (await response.json());
|
|
153
|
+
return data.items ?? [];
|
|
102
154
|
}
|
|
103
155
|
/**
|
|
104
|
-
* Wait for command ACK via SSE push
|
|
105
|
-
* Falls back to polling
|
|
156
|
+
* Wait for command ACK via SSE push (which should already be connected by the
|
|
157
|
+
* registry). Falls back to polling.
|
|
106
158
|
*/
|
|
107
159
|
export async function waitForCommandAck(config, options) {
|
|
108
160
|
const timeoutMs = options.timeoutMs ?? 15_000;
|
|
109
|
-
|
|
110
|
-
// Ensure SSE is connected for real-time ACKs
|
|
111
|
-
connectSSE(config);
|
|
161
|
+
log.debug(`[sse-cmd] Waiting for ACK: commandId=${options.commandId}, timeout=${timeoutMs}ms`);
|
|
112
162
|
return new Promise((resolve, reject) => {
|
|
113
163
|
let resolved = false;
|
|
114
164
|
let pollInterval;
|
|
@@ -123,28 +173,38 @@ export async function waitForCommandAck(config, options) {
|
|
|
123
173
|
cleanup();
|
|
124
174
|
resolve({ acked: true, command: ackedCommand });
|
|
125
175
|
});
|
|
126
|
-
//
|
|
127
|
-
|
|
176
|
+
// Delay polling startup by 2s so SSE push ACK normally wins in the
|
|
177
|
+
// registry-connected path. CLI (zhihand test) still resolves via polling
|
|
178
|
+
// after the initial delay. This avoids hammering the backend with 500ms
|
|
179
|
+
// HTTP polls for every command when SSE is healthy.
|
|
180
|
+
const POLL_START_DELAY_MS = 2000;
|
|
181
|
+
const POLL_INTERVAL_MS = 500;
|
|
182
|
+
const startPolling = setTimeout(() => {
|
|
128
183
|
if (resolved)
|
|
129
184
|
return;
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
185
|
+
pollInterval = setInterval(async () => {
|
|
186
|
+
if (resolved)
|
|
187
|
+
return;
|
|
188
|
+
try {
|
|
189
|
+
const cmd = await getCommand(config, options.commandId);
|
|
190
|
+
if (cmd.acked_at) {
|
|
191
|
+
resolved = true;
|
|
192
|
+
cleanup();
|
|
193
|
+
resolve({ acked: true, command: cmd });
|
|
194
|
+
}
|
|
136
195
|
}
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
}
|
|
141
|
-
},
|
|
196
|
+
catch {
|
|
197
|
+
// non-fatal
|
|
198
|
+
}
|
|
199
|
+
}, POLL_INTERVAL_MS);
|
|
200
|
+
}, POLL_START_DELAY_MS);
|
|
142
201
|
options.signal?.addEventListener("abort", () => {
|
|
143
202
|
cleanup();
|
|
144
203
|
reject(new Error("The operation was aborted"));
|
|
145
204
|
}, { once: true });
|
|
146
205
|
function cleanup() {
|
|
147
206
|
clearTimeout(timeout);
|
|
207
|
+
clearTimeout(startPolling);
|
|
148
208
|
unsubscribe();
|
|
149
209
|
if (pollInterval)
|
|
150
210
|
clearInterval(pollInterval);
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebSocket transport — replaces SSE for all real-time event streams.
|
|
3
|
+
*
|
|
4
|
+
* Provides:
|
|
5
|
+
* - ReconnectingWebSocket: shared base with exponential backoff + jitter,
|
|
6
|
+
* protocol-level ping/pong watchdog, and Bearer auth via HTTP upgrade header.
|
|
7
|
+
* - UserEventWebSocket: per-user stream for device registry events.
|
|
8
|
+
* - Command ACK infrastructure (handleWSEvent, subscribeToCommandAck, waitForCommandAck).
|
|
9
|
+
* - fetchUserCredentials: HTTP REST helper (unchanged from sse.ts).
|
|
10
|
+
*/
|
|
11
|
+
import type { ZhiHandRuntimeConfig } from "./config.ts";
|
|
12
|
+
import type { QueuedCommandRecord, WaitForCommandAckResult } from "./command.ts";
|
|
13
|
+
export interface ReconnectingWSOptions {
|
|
14
|
+
url: string;
|
|
15
|
+
headers?: Record<string, string>;
|
|
16
|
+
onOpen?: () => void;
|
|
17
|
+
onClose?: (code: number, reason: string) => void;
|
|
18
|
+
onMessage?: (data: unknown) => void;
|
|
19
|
+
onError?: (err: Error) => void;
|
|
20
|
+
}
|
|
21
|
+
export declare class ReconnectingWebSocket {
|
|
22
|
+
private opts;
|
|
23
|
+
private ws;
|
|
24
|
+
private backoffMs;
|
|
25
|
+
private aborted;
|
|
26
|
+
private watchdogTimer;
|
|
27
|
+
private reconnectTimer;
|
|
28
|
+
private hadOpen;
|
|
29
|
+
private consecutiveFailures;
|
|
30
|
+
private static readonly MAX_CONSECUTIVE_FAILURES;
|
|
31
|
+
constructor(opts: ReconnectingWSOptions);
|
|
32
|
+
start(): void;
|
|
33
|
+
stop(): void;
|
|
34
|
+
send(data: string): void;
|
|
35
|
+
get connected(): boolean;
|
|
36
|
+
private connect;
|
|
37
|
+
private scheduleReconnect;
|
|
38
|
+
private resetWatchdog;
|
|
39
|
+
private clearWatchdog;
|
|
40
|
+
}
|
|
41
|
+
export interface WSEvent {
|
|
42
|
+
id: string;
|
|
43
|
+
topic: string;
|
|
44
|
+
kind: string;
|
|
45
|
+
credential_id: string;
|
|
46
|
+
command?: QueuedCommandRecord;
|
|
47
|
+
device_profile?: Record<string, unknown>;
|
|
48
|
+
credential?: Record<string, unknown>;
|
|
49
|
+
sequence: number;
|
|
50
|
+
}
|
|
51
|
+
export declare function handleWSEvent(event: WSEvent): void;
|
|
52
|
+
export declare function subscribeToCommandAck(commandId: string, callback: (cmd: QueuedCommandRecord) => void): () => void;
|
|
53
|
+
export interface UserEventStreamHandlers {
|
|
54
|
+
onDeviceOnline: (credentialId: string) => void;
|
|
55
|
+
onDeviceOffline: (credentialId: string) => void;
|
|
56
|
+
onDeviceProfileUpdated: (credentialId: string, profile: Record<string, unknown>) => void;
|
|
57
|
+
onCommandAcked: (event: WSEvent) => void;
|
|
58
|
+
onCredentialAdded: (credential: Record<string, unknown>) => void;
|
|
59
|
+
onCredentialRemoved: (credentialId: string) => void;
|
|
60
|
+
onConnected: () => void;
|
|
61
|
+
onDisconnected: () => void;
|
|
62
|
+
}
|
|
63
|
+
export declare class UserEventWebSocket {
|
|
64
|
+
private handlers;
|
|
65
|
+
private rws;
|
|
66
|
+
private lastProcessedSeq;
|
|
67
|
+
constructor(userId: string, controllerToken: string, endpoint: string, handlers: UserEventStreamHandlers);
|
|
68
|
+
get connected(): boolean;
|
|
69
|
+
start(): void;
|
|
70
|
+
stop(): void;
|
|
71
|
+
private handleMessage;
|
|
72
|
+
private dispatchEvent;
|
|
73
|
+
}
|
|
74
|
+
export interface CredentialResponse {
|
|
75
|
+
credential_id: string;
|
|
76
|
+
label?: string;
|
|
77
|
+
platform?: string;
|
|
78
|
+
online?: boolean;
|
|
79
|
+
paired_at?: string;
|
|
80
|
+
last_seen_at?: string;
|
|
81
|
+
device_profile?: Record<string, unknown>;
|
|
82
|
+
}
|
|
83
|
+
export declare function fetchUserCredentials(endpoint: string, userId: string, controllerToken: string, onlineFilter?: boolean): Promise<CredentialResponse[]>;
|
|
84
|
+
/**
|
|
85
|
+
* Wait for command ACK via WS push (which should already be connected by the
|
|
86
|
+
* registry). Falls back to polling.
|
|
87
|
+
*/
|
|
88
|
+
export declare function waitForCommandAck(config: ZhiHandRuntimeConfig, options: {
|
|
89
|
+
commandId: string;
|
|
90
|
+
timeoutMs?: number;
|
|
91
|
+
signal?: AbortSignal;
|
|
92
|
+
}): Promise<WaitForCommandAckResult>;
|
package/dist/core/ws.js
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebSocket transport — replaces SSE for all real-time event streams.
|
|
3
|
+
*
|
|
4
|
+
* Provides:
|
|
5
|
+
* - ReconnectingWebSocket: shared base with exponential backoff + jitter,
|
|
6
|
+
* protocol-level ping/pong watchdog, and Bearer auth via HTTP upgrade header.
|
|
7
|
+
* - UserEventWebSocket: per-user stream for device registry events.
|
|
8
|
+
* - Command ACK infrastructure (handleWSEvent, subscribeToCommandAck, waitForCommandAck).
|
|
9
|
+
* - fetchUserCredentials: HTTP REST helper (unchanged from sse.ts).
|
|
10
|
+
*/
|
|
11
|
+
import WebSocket from "ws";
|
|
12
|
+
import { getCommand } from "./command.js";
|
|
13
|
+
import { log } from "./logger.js";
|
|
14
|
+
// ── Shared reconnecting base ─────────────────────────────
|
|
15
|
+
const BACKOFF_INITIAL_MS = 1000;
|
|
16
|
+
const BACKOFF_MAX_MS = 30_000;
|
|
17
|
+
const WATCHDOG_TIMEOUT_MS = 35_000; // slightly > server ping interval (expect ~30s)
|
|
18
|
+
export class ReconnectingWebSocket {
|
|
19
|
+
opts;
|
|
20
|
+
ws = null;
|
|
21
|
+
backoffMs = BACKOFF_INITIAL_MS;
|
|
22
|
+
aborted = false;
|
|
23
|
+
watchdogTimer = null;
|
|
24
|
+
reconnectTimer = null;
|
|
25
|
+
hadOpen = false;
|
|
26
|
+
consecutiveFailures = 0;
|
|
27
|
+
static MAX_CONSECUTIVE_FAILURES = 10;
|
|
28
|
+
constructor(opts) {
|
|
29
|
+
this.opts = opts;
|
|
30
|
+
}
|
|
31
|
+
start() {
|
|
32
|
+
this.aborted = false;
|
|
33
|
+
this.connect();
|
|
34
|
+
}
|
|
35
|
+
stop() {
|
|
36
|
+
this.aborted = true;
|
|
37
|
+
this.clearWatchdog();
|
|
38
|
+
if (this.reconnectTimer) {
|
|
39
|
+
clearTimeout(this.reconnectTimer);
|
|
40
|
+
this.reconnectTimer = null;
|
|
41
|
+
}
|
|
42
|
+
if (this.ws) {
|
|
43
|
+
this.ws.removeAllListeners();
|
|
44
|
+
if (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING) {
|
|
45
|
+
this.ws.close(4000, "client shutdown");
|
|
46
|
+
}
|
|
47
|
+
this.ws = null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
send(data) {
|
|
51
|
+
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
52
|
+
this.ws.send(data);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
get connected() {
|
|
56
|
+
return this.ws?.readyState === WebSocket.OPEN;
|
|
57
|
+
}
|
|
58
|
+
connect() {
|
|
59
|
+
if (this.aborted)
|
|
60
|
+
return;
|
|
61
|
+
try {
|
|
62
|
+
this.ws = new WebSocket(this.opts.url, {
|
|
63
|
+
headers: this.opts.headers,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
catch (err) {
|
|
67
|
+
log.error(`[ws] Failed to create WebSocket: ${err.message}`);
|
|
68
|
+
this.scheduleReconnect();
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
this.ws.on("open", () => {
|
|
72
|
+
this.hadOpen = true;
|
|
73
|
+
this.consecutiveFailures = 0;
|
|
74
|
+
this.backoffMs = BACKOFF_INITIAL_MS;
|
|
75
|
+
this.resetWatchdog();
|
|
76
|
+
this.opts.onOpen?.();
|
|
77
|
+
});
|
|
78
|
+
this.ws.on("message", (raw) => {
|
|
79
|
+
this.resetWatchdog();
|
|
80
|
+
try {
|
|
81
|
+
const data = JSON.parse(raw.toString());
|
|
82
|
+
this.opts.onMessage?.(data);
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
// malformed message — ignore
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
this.ws.on("ping", () => {
|
|
89
|
+
// Protocol-level ping from server — ws lib auto-sends pong.
|
|
90
|
+
// Reset watchdog on any activity.
|
|
91
|
+
this.resetWatchdog();
|
|
92
|
+
});
|
|
93
|
+
this.ws.on("close", (code, reason) => {
|
|
94
|
+
this.clearWatchdog();
|
|
95
|
+
const reasonStr = reason.toString();
|
|
96
|
+
// Detect HTTP upgrade rejection (401/403 → close 1006 without prior open)
|
|
97
|
+
if (!this.hadOpen) {
|
|
98
|
+
this.consecutiveFailures++;
|
|
99
|
+
if (this.consecutiveFailures >= ReconnectingWebSocket.MAX_CONSECUTIVE_FAILURES) {
|
|
100
|
+
log.error(`[ws] ${this.consecutiveFailures} consecutive connection failures — stopping retries (likely auth rejection)`);
|
|
101
|
+
this.opts.onClose?.(code, reasonStr);
|
|
102
|
+
return; // Don't reconnect
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
this.hadOpen = false;
|
|
106
|
+
this.opts.onClose?.(code, reasonStr);
|
|
107
|
+
if (!this.aborted && code !== 4001) {
|
|
108
|
+
// Don't reconnect on explicit auth failure (4001)
|
|
109
|
+
this.scheduleReconnect();
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
this.ws.on("error", (err) => {
|
|
113
|
+
this.opts.onError?.(err);
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
scheduleReconnect() {
|
|
117
|
+
if (this.aborted)
|
|
118
|
+
return;
|
|
119
|
+
// Jitter: ±25% of current backoff
|
|
120
|
+
const jitter = this.backoffMs * 0.25 * (Math.random() * 2 - 1);
|
|
121
|
+
const delay = Math.round(this.backoffMs + jitter);
|
|
122
|
+
this.reconnectTimer = setTimeout(() => {
|
|
123
|
+
this.reconnectTimer = null;
|
|
124
|
+
this.backoffMs = Math.min(this.backoffMs * 2, BACKOFF_MAX_MS);
|
|
125
|
+
this.connect();
|
|
126
|
+
}, delay);
|
|
127
|
+
}
|
|
128
|
+
resetWatchdog() {
|
|
129
|
+
this.clearWatchdog();
|
|
130
|
+
this.watchdogTimer = setTimeout(() => {
|
|
131
|
+
log.warn("[ws] Watchdog timeout — no activity in 35s, reconnecting");
|
|
132
|
+
if (this.ws) {
|
|
133
|
+
this.ws.removeAllListeners();
|
|
134
|
+
this.ws.close(4000, "watchdog timeout");
|
|
135
|
+
this.ws = null;
|
|
136
|
+
}
|
|
137
|
+
this.opts.onClose?.(4000, "watchdog timeout");
|
|
138
|
+
this.scheduleReconnect();
|
|
139
|
+
}, WATCHDOG_TIMEOUT_MS);
|
|
140
|
+
}
|
|
141
|
+
clearWatchdog() {
|
|
142
|
+
if (this.watchdogTimer) {
|
|
143
|
+
clearTimeout(this.watchdogTimer);
|
|
144
|
+
this.watchdogTimer = null;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
// ── Command ACK infrastructure (migrated from sse.ts) ────
|
|
149
|
+
const ackCallbacks = new Map();
|
|
150
|
+
export function handleWSEvent(event) {
|
|
151
|
+
log.debug(`[ws-cmd] Event: kind=${event.kind}, command=${event.command?.id ?? "-"}`);
|
|
152
|
+
if (event.kind === "command.acked" && event.command) {
|
|
153
|
+
const callback = ackCallbacks.get(event.command.id);
|
|
154
|
+
if (callback) {
|
|
155
|
+
log.debug(`[ws-cmd] ACK callback for ${event.command.id}, ack_status=${event.command.ack_status}, ack_result=${JSON.stringify(event.command.ack_result ?? null)}`);
|
|
156
|
+
callback(event.command);
|
|
157
|
+
ackCallbacks.delete(event.command.id);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
export function subscribeToCommandAck(commandId, callback) {
|
|
162
|
+
ackCallbacks.set(commandId, callback);
|
|
163
|
+
return () => { ackCallbacks.delete(commandId); };
|
|
164
|
+
}
|
|
165
|
+
export class UserEventWebSocket {
|
|
166
|
+
handlers;
|
|
167
|
+
rws;
|
|
168
|
+
lastProcessedSeq = new Map();
|
|
169
|
+
constructor(userId, controllerToken, endpoint, handlers) {
|
|
170
|
+
this.handlers = handlers;
|
|
171
|
+
const topics = "commands,device_profile,device.online,device.offline,credential.added,credential.removed";
|
|
172
|
+
const wsUrl = `${endpoint.replace(/^http/, "ws")}/v1/users/${encodeURIComponent(userId)}/ws?topic=${topics}`;
|
|
173
|
+
this.rws = new ReconnectingWebSocket({
|
|
174
|
+
url: wsUrl,
|
|
175
|
+
headers: { "Authorization": `Bearer ${controllerToken}` },
|
|
176
|
+
onOpen: () => {
|
|
177
|
+
this.handlers.onConnected();
|
|
178
|
+
},
|
|
179
|
+
onClose: (_code, _reason) => {
|
|
180
|
+
this.handlers.onDisconnected();
|
|
181
|
+
},
|
|
182
|
+
onMessage: (data) => {
|
|
183
|
+
this.handleMessage(data);
|
|
184
|
+
},
|
|
185
|
+
onError: (err) => {
|
|
186
|
+
log.error(`[ws] UserEventWebSocket error: ${err.message}`);
|
|
187
|
+
},
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
get connected() {
|
|
191
|
+
return this.rws.connected;
|
|
192
|
+
}
|
|
193
|
+
start() {
|
|
194
|
+
this.rws.start();
|
|
195
|
+
}
|
|
196
|
+
stop() {
|
|
197
|
+
this.rws.stop();
|
|
198
|
+
}
|
|
199
|
+
handleMessage(data) {
|
|
200
|
+
const msg = data;
|
|
201
|
+
// Application-level ping (if server sends these alongside protocol pings)
|
|
202
|
+
if (msg.type === "ping") {
|
|
203
|
+
this.rws.send(JSON.stringify({ type: "pong" }));
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
// Auth responses (if server uses message-based auth instead of/in addition to header auth)
|
|
207
|
+
if (msg.type === "auth_ok")
|
|
208
|
+
return;
|
|
209
|
+
if (msg.type === "auth_error") {
|
|
210
|
+
log.error(`[ws] Auth failed: ${msg.error}`);
|
|
211
|
+
this.rws.stop(); // Don't retry with invalid credentials
|
|
212
|
+
this.handlers.onDisconnected();
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
// Event dispatch
|
|
216
|
+
if (msg.type === "event" || msg.kind) {
|
|
217
|
+
const ev = msg;
|
|
218
|
+
this.dispatchEvent(ev);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
dispatchEvent(ev) {
|
|
222
|
+
// Sequence dedup per credential
|
|
223
|
+
if (ev.credential_id && ev.sequence != null) {
|
|
224
|
+
const lastSeq = this.lastProcessedSeq.get(ev.credential_id) ?? -1;
|
|
225
|
+
if (ev.sequence <= lastSeq)
|
|
226
|
+
return;
|
|
227
|
+
this.lastProcessedSeq.set(ev.credential_id, ev.sequence);
|
|
228
|
+
}
|
|
229
|
+
// Global command ACK dispatch
|
|
230
|
+
handleWSEvent(ev);
|
|
231
|
+
switch (ev.kind) {
|
|
232
|
+
case "device.online":
|
|
233
|
+
this.handlers.onDeviceOnline(ev.credential_id);
|
|
234
|
+
break;
|
|
235
|
+
case "device.offline":
|
|
236
|
+
this.handlers.onDeviceOffline(ev.credential_id);
|
|
237
|
+
break;
|
|
238
|
+
case "device_profile.updated":
|
|
239
|
+
if (ev.device_profile) {
|
|
240
|
+
this.handlers.onDeviceProfileUpdated(ev.credential_id, ev.device_profile);
|
|
241
|
+
}
|
|
242
|
+
break;
|
|
243
|
+
case "command.acked":
|
|
244
|
+
this.handlers.onCommandAcked(ev);
|
|
245
|
+
break;
|
|
246
|
+
case "credential.added":
|
|
247
|
+
this.handlers.onCredentialAdded(ev.credential ?? { credential_id: ev.credential_id });
|
|
248
|
+
break;
|
|
249
|
+
case "credential.removed":
|
|
250
|
+
this.handlers.onCredentialRemoved(ev.credential_id);
|
|
251
|
+
break;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
export async function fetchUserCredentials(endpoint, userId, controllerToken, onlineFilter) {
|
|
256
|
+
let url = `${endpoint}/v1/users/${encodeURIComponent(userId)}/credentials`;
|
|
257
|
+
if (onlineFilter !== undefined) {
|
|
258
|
+
url += `?online=${onlineFilter}`;
|
|
259
|
+
}
|
|
260
|
+
const response = await fetch(url, {
|
|
261
|
+
headers: { "Authorization": `Bearer ${controllerToken}` },
|
|
262
|
+
signal: AbortSignal.timeout(10_000),
|
|
263
|
+
});
|
|
264
|
+
if (!response.ok) {
|
|
265
|
+
throw new Error(`Fetch credentials failed: ${response.status}`);
|
|
266
|
+
}
|
|
267
|
+
const data = (await response.json());
|
|
268
|
+
return data.items ?? [];
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* Wait for command ACK via WS push (which should already be connected by the
|
|
272
|
+
* registry). Falls back to polling.
|
|
273
|
+
*/
|
|
274
|
+
export async function waitForCommandAck(config, options) {
|
|
275
|
+
const timeoutMs = options.timeoutMs ?? 15_000;
|
|
276
|
+
log.debug(`[ws-cmd] Waiting for ACK: commandId=${options.commandId}, timeout=${timeoutMs}ms`);
|
|
277
|
+
return new Promise((resolve, reject) => {
|
|
278
|
+
let resolved = false;
|
|
279
|
+
let pollInterval;
|
|
280
|
+
const timeout = setTimeout(() => {
|
|
281
|
+
cleanup();
|
|
282
|
+
resolve({ acked: false });
|
|
283
|
+
}, timeoutMs);
|
|
284
|
+
const unsubscribe = subscribeToCommandAck(options.commandId, (ackedCommand) => {
|
|
285
|
+
if (resolved)
|
|
286
|
+
return;
|
|
287
|
+
resolved = true;
|
|
288
|
+
cleanup();
|
|
289
|
+
resolve({ acked: true, command: ackedCommand });
|
|
290
|
+
});
|
|
291
|
+
// Delay polling startup by 2s so WS push ACK normally wins in the
|
|
292
|
+
// registry-connected path. CLI (zhihand test) still resolves via polling
|
|
293
|
+
// after the initial delay.
|
|
294
|
+
const POLL_START_DELAY_MS = 2000;
|
|
295
|
+
const POLL_INTERVAL_MS = 500;
|
|
296
|
+
const startPolling = setTimeout(() => {
|
|
297
|
+
if (resolved)
|
|
298
|
+
return;
|
|
299
|
+
pollInterval = setInterval(async () => {
|
|
300
|
+
if (resolved)
|
|
301
|
+
return;
|
|
302
|
+
try {
|
|
303
|
+
const cmd = await getCommand(config, options.commandId);
|
|
304
|
+
if (cmd.acked_at) {
|
|
305
|
+
resolved = true;
|
|
306
|
+
cleanup();
|
|
307
|
+
resolve({ acked: true, command: cmd });
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
catch {
|
|
311
|
+
// non-fatal
|
|
312
|
+
}
|
|
313
|
+
}, POLL_INTERVAL_MS);
|
|
314
|
+
}, POLL_START_DELAY_MS);
|
|
315
|
+
options.signal?.addEventListener("abort", () => {
|
|
316
|
+
cleanup();
|
|
317
|
+
reject(new Error("The operation was aborted"));
|
|
318
|
+
}, { once: true });
|
|
319
|
+
function cleanup() {
|
|
320
|
+
clearTimeout(timeout);
|
|
321
|
+
clearTimeout(startPolling);
|
|
322
|
+
unsubscribe();
|
|
323
|
+
if (pollInterval)
|
|
324
|
+
clearInterval(pollInterval);
|
|
325
|
+
}
|
|
326
|
+
});
|
|
327
|
+
}
|