@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.
Files changed (42) hide show
  1. package/bin/zhihand +448 -212
  2. package/dist/core/command.d.ts +5 -5
  3. package/dist/core/command.js +6 -8
  4. package/dist/core/config.d.ts +48 -21
  5. package/dist/core/config.js +178 -42
  6. package/dist/core/device.d.ts +28 -19
  7. package/dist/core/device.js +168 -145
  8. package/dist/core/logger.d.ts +17 -0
  9. package/dist/core/logger.js +32 -0
  10. package/dist/core/pair.d.ts +39 -31
  11. package/dist/core/pair.js +205 -77
  12. package/dist/core/registry.d.ts +60 -0
  13. package/dist/core/registry.js +415 -0
  14. package/dist/core/screenshot.d.ts +3 -3
  15. package/dist/core/screenshot.js +3 -2
  16. package/dist/core/sse.d.ts +40 -18
  17. package/dist/core/sse.js +122 -62
  18. package/dist/core/ws.d.ts +92 -0
  19. package/dist/core/ws.js +327 -0
  20. package/dist/daemon/dispatcher.d.ts +3 -1
  21. package/dist/daemon/dispatcher.js +4 -3
  22. package/dist/daemon/heartbeat.d.ts +4 -4
  23. package/dist/daemon/heartbeat.js +1 -1
  24. package/dist/daemon/index.js +10 -8
  25. package/dist/daemon/prompt-listener.d.ts +8 -7
  26. package/dist/daemon/prompt-listener.js +59 -99
  27. package/dist/index.d.ts +3 -3
  28. package/dist/index.js +104 -40
  29. package/dist/openclaw.adapter.js +10 -2
  30. package/dist/tools/control.d.ts +10 -3
  31. package/dist/tools/control.js +18 -24
  32. package/dist/tools/pair.d.ts +1 -1
  33. package/dist/tools/pair.js +22 -28
  34. package/dist/tools/resolve.d.ts +7 -0
  35. package/dist/tools/resolve.js +22 -0
  36. package/dist/tools/schemas.d.ts +9 -1
  37. package/dist/tools/schemas.js +10 -8
  38. package/dist/tools/screenshot.d.ts +3 -2
  39. package/dist/tools/screenshot.js +2 -2
  40. package/dist/tools/system.d.ts +3 -5
  41. package/dist/tools/system.js +19 -6
  42. package/package.json +3 -1
package/dist/core/sse.js CHANGED
@@ -1,16 +1,13 @@
1
1
  import { getCommand } from "./command.js";
2
- import { dbg } from "../daemon/logger.js";
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
- dbg(`[sse-cmd] Event: kind=${event.kind}, command=${event.command?.id ?? "-"}`);
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
- dbg(`[sse-cmd] ACK callback for ${event.command.id}, ack_status=${event.command.ack_status}, ack_result=${JSON.stringify(event.command.ack_result ?? null)}`);
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
- * Connect to the SSE event stream for command ACKs.
25
- * Maintains a persistent connection that dispatches events to registered callbacks.
26
- * Reconnects automatically on connection loss.
27
- */
28
- export function connectSSE(config) {
29
- if (sseAbortController)
30
- return; // Already connected
31
- sseAbortController = new AbortController();
32
- const { signal } = sseAbortController;
33
- const url = `${config.controlPlaneEndpoint}/v1/credentials/${encodeURIComponent(config.credentialId)}/events/stream?topic=commands`;
34
- (async () => {
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
- "x-zhihand-controller-token": config.controllerToken,
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
- sseConnected = true;
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 event = JSON.parse(eventData);
68
- handleSSEEvent(event);
86
+ const ev = JSON.parse(eventData);
87
+ this.dispatchEvent(ev);
69
88
  }
70
89
  catch {
71
- // Malformed event, skip
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
- sseConnected = false;
82
- // Backoff before reconnect
83
- await new Promise((r) => setTimeout(r, 3000));
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
- sseConnected = false;
87
- })();
88
- }
89
- /**
90
- * Disconnect the SSE event stream.
91
- */
92
- export function disconnectSSE() {
93
- sseAbortController?.abort();
94
- sseAbortController = null;
95
- sseConnected = false;
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
- * Whether the SSE stream is currently connected.
99
- */
100
- export function isSSEConnected() {
101
- return sseConnected;
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 if SSE is not active.
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
- dbg(`[sse-cmd] Waiting for ACK: commandId=${options.commandId}, timeout=${timeoutMs}ms`);
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
- // Also poll as fallback (SSE may not be connected yet or may be reconnecting)
127
- pollInterval = setInterval(async () => {
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
- try {
131
- const cmd = await getCommand(config, options.commandId);
132
- if (cmd.acked_at) {
133
- resolved = true;
134
- cleanup();
135
- resolve({ acked: true, command: cmd });
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
- catch {
139
- // Polling failure is non-fatal; SSE or next poll may succeed
140
- }
141
- }, 500);
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>;
@@ -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
+ }