@ynhcj/xiaoyi 0.0.3-beta → 0.0.5-beta

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.
@@ -0,0 +1,39 @@
1
+ import WebSocket from "ws";
2
+ export interface HeartbeatConfig {
3
+ interval: number;
4
+ timeout: number;
5
+ message: string;
6
+ }
7
+ /**
8
+ * Manages heartbeat for a WebSocket connection.
9
+ * Supports both application-level and protocol-level heartbeats.
10
+ */
11
+ export declare class HeartbeatManager {
12
+ private ws;
13
+ private config;
14
+ private onTimeout;
15
+ private serverName;
16
+ private onHeartbeatSuccess?;
17
+ private intervalTimer;
18
+ private timeoutTimer;
19
+ private lastPongTime;
20
+ private log;
21
+ private error;
22
+ constructor(ws: WebSocket, config: HeartbeatConfig, onTimeout: () => void, serverName?: string, logFn?: (msg: string, ...args: any[]) => void, errorFn?: (msg: string, ...args: any[]) => void, onHeartbeatSuccess?: () => void);
23
+ /**
24
+ * Start heartbeat monitoring.
25
+ */
26
+ start(): void;
27
+ /**
28
+ * Stop heartbeat monitoring.
29
+ */
30
+ stop(): void;
31
+ /**
32
+ * Send a heartbeat ping.
33
+ */
34
+ private sendHeartbeat;
35
+ /**
36
+ * Check if connection is healthy based on last pong time.
37
+ */
38
+ isHealthy(): boolean;
39
+ }
@@ -0,0 +1,102 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.HeartbeatManager = void 0;
7
+ // Heartbeat management for WebSocket connections
8
+ const ws_1 = __importDefault(require("ws"));
9
+ /**
10
+ * Manages heartbeat for a WebSocket connection.
11
+ * Supports both application-level and protocol-level heartbeats.
12
+ */
13
+ class HeartbeatManager {
14
+ constructor(ws, config, onTimeout, serverName = "unknown", logFn, errorFn, onHeartbeatSuccess // ✅ 心跳成功回调,向 OpenClaw 报告健康状态
15
+ ) {
16
+ this.ws = ws;
17
+ this.config = config;
18
+ this.onTimeout = onTimeout;
19
+ this.serverName = serverName;
20
+ this.onHeartbeatSuccess = onHeartbeatSuccess;
21
+ this.intervalTimer = null;
22
+ this.timeoutTimer = null;
23
+ this.lastPongTime = 0;
24
+ this.log = logFn ?? console.log;
25
+ this.error = errorFn ?? console.error;
26
+ }
27
+ /**
28
+ * Start heartbeat monitoring.
29
+ */
30
+ start() {
31
+ this.stop(); // Clear any existing timers
32
+ this.lastPongTime = Date.now();
33
+ // Setup ping/pong for protocol-level heartbeat
34
+ this.ws.on("pong", () => {
35
+ this.lastPongTime = Date.now();
36
+ if (this.timeoutTimer) {
37
+ clearTimeout(this.timeoutTimer);
38
+ this.timeoutTimer = null;
39
+ }
40
+ // ✅ Report health: heartbeat successful - notify OpenClaw framework
41
+ this.onHeartbeatSuccess?.();
42
+ this.log(`[${this.serverName}] Heartbeat pong received, health reported to OpenClaw`);
43
+ });
44
+ // Start interval timer
45
+ this.intervalTimer = setInterval(() => {
46
+ this.sendHeartbeat();
47
+ }, this.config.interval);
48
+ this.log(`[${this.serverName}] Heartbeat started: interval=${this.config.interval}ms, timeout=${this.config.timeout}ms`);
49
+ }
50
+ /**
51
+ * Stop heartbeat monitoring.
52
+ */
53
+ stop() {
54
+ if (this.intervalTimer) {
55
+ clearInterval(this.intervalTimer);
56
+ this.intervalTimer = null;
57
+ }
58
+ if (this.timeoutTimer) {
59
+ clearTimeout(this.timeoutTimer);
60
+ this.timeoutTimer = null;
61
+ }
62
+ // Remove pong listener
63
+ this.ws.off("pong", () => { });
64
+ this.log(`[${this.serverName}] Heartbeat stopped`);
65
+ }
66
+ /**
67
+ * Send a heartbeat ping.
68
+ */
69
+ sendHeartbeat() {
70
+ if (this.ws.readyState !== ws_1.default.OPEN) {
71
+ console.warn(`[${this.serverName}] Cannot send heartbeat: WebSocket not open`);
72
+ return;
73
+ }
74
+ try {
75
+ // Send application-level heartbeat message
76
+ this.log(`[${this.serverName}] Sending heartbeat frame:`, this.config.message.substring(0, 100));
77
+ this.ws.send(this.config.message);
78
+ // Send protocol-level ping
79
+ this.ws.ping();
80
+ this.log(`[${this.serverName}] Protocol-level ping sent`);
81
+ // Setup timeout timer
82
+ this.timeoutTimer = setTimeout(() => {
83
+ this.error(`[${this.serverName}] Heartbeat timeout - no pong received`);
84
+ this.onTimeout();
85
+ }, this.config.timeout);
86
+ }
87
+ catch (error) {
88
+ this.error(`[${this.serverName}] Failed to send heartbeat:`, error);
89
+ }
90
+ }
91
+ /**
92
+ * Check if connection is healthy based on last pong time.
93
+ */
94
+ isHealthy() {
95
+ if (this.lastPongTime === 0) {
96
+ return true; // Not started yet
97
+ }
98
+ const timeSinceLastPong = Date.now() - this.lastPongTime;
99
+ return timeSinceLastPong < this.config.interval + this.config.timeout;
100
+ }
101
+ }
102
+ exports.HeartbeatManager = HeartbeatManager;
@@ -13,13 +13,21 @@ export declare class XiaoYiWebSocketManager extends EventEmitter {
13
13
  private heartbeatTimeout1?;
14
14
  private heartbeatTimeout2?;
15
15
  private appHeartbeatInterval?;
16
+ private heartbeat1?;
17
+ private heartbeat2?;
16
18
  private reconnectTimeout1?;
17
19
  private reconnectTimeout2?;
18
20
  private stableConnectionTimer1?;
19
21
  private stableConnectionTimer2?;
20
22
  private static readonly STABLE_CONNECTION_THRESHOLD;
21
23
  private activeTasks;
24
+ private onHealthEvent?;
22
25
  constructor(config: XiaoYiChannelConfig);
26
+ /**
27
+ * Set health event callback to report activity to OpenClaw framework.
28
+ * This callback is invoked on heartbeat success to update lastEventAt.
29
+ */
30
+ setHealthEventCallback(callback: () => void): void;
23
31
  /**
24
32
  * Check if URL is wss + IP format (skip certificate verification)
25
33
  */
package/dist/websocket.js CHANGED
@@ -8,6 +8,7 @@ const ws_1 = __importDefault(require("ws"));
8
8
  const events_1 = require("events");
9
9
  const url_1 = require("url");
10
10
  const auth_js_1 = require("./auth.js");
11
+ const heartbeat_js_1 = require("./heartbeat.js");
11
12
  const types_js_1 = require("./types.js");
12
13
  class XiaoYiWebSocketManager extends events_1.EventEmitter {
13
14
  constructor(config) {
@@ -42,6 +43,14 @@ class XiaoYiWebSocketManager extends events_1.EventEmitter {
42
43
  console.log(` Server 1: ${this.config.wsUrl1}`);
43
44
  console.log(` Server 2: ${this.config.wsUrl2}`);
44
45
  }
46
+ /**
47
+ * Set health event callback to report activity to OpenClaw framework.
48
+ * This callback is invoked on heartbeat success to update lastEventAt.
49
+ */
50
+ setHealthEventCallback(callback) {
51
+ this.onHealthEvent = callback;
52
+ console.log("[WS Manager] Health event callback registered");
53
+ }
45
54
  /**
46
55
  * Check if URL is wss + IP format (skip certificate verification)
47
56
  */
@@ -141,9 +150,13 @@ class XiaoYiWebSocketManager extends events_1.EventEmitter {
141
150
  async connectToServer1() {
142
151
  console.log(`[Server1] Connecting to ${this.config.wsUrl1}...`);
143
152
  try {
144
- // ✅ Close existing connection before creating new one to prevent ghost connections
153
+ // ✅ Close existing connection and heartbeat before creating new one
145
154
  if (this.ws1) {
146
155
  console.log(`[Server1] Closing existing connection before reconnect`);
156
+ if (this.heartbeat1) {
157
+ this.heartbeat1.stop();
158
+ this.heartbeat1 = undefined;
159
+ }
147
160
  try {
148
161
  this.ws1.removeAllListeners();
149
162
  this.ws1.close();
@@ -163,6 +176,26 @@ class XiaoYiWebSocketManager extends events_1.EventEmitter {
163
176
  headers: authHeaders,
164
177
  rejectUnauthorized: !skipCertVerify,
165
178
  });
179
+ // ✅ Initialize HeartbeatManager for server1
180
+ this.heartbeat1 = new heartbeat_js_1.HeartbeatManager(this.ws1, {
181
+ interval: 30000, // 30 seconds
182
+ timeout: 10000, // 10 seconds timeout
183
+ message: JSON.stringify({
184
+ msgType: "heartbeat",
185
+ agentId: this.config.agentId,
186
+ timestamp: Date.now(),
187
+ }),
188
+ }, () => {
189
+ console.log(`[Server1] Heartbeat timeout, reconnecting...`);
190
+ if (this.ws1 && (this.ws1.readyState === ws_1.default.OPEN || this.ws1.readyState === ws_1.default.CONNECTING)) {
191
+ this.ws1.close();
192
+ }
193
+ }, "server1", console.log, console.error, () => {
194
+ // ✅ Heartbeat success callback - report health to OpenClaw
195
+ this.emit("heartbeat", "server1");
196
+ // ✅ Report liveness to OpenClaw framework to prevent stale-socket detection
197
+ this.onHealthEvent?.();
198
+ });
166
199
  this.setupWebSocketHandlers(this.ws1, 'server1');
167
200
  await new Promise((resolve, reject) => {
168
201
  const timeout = setTimeout(() => reject(new Error("Connection timeout")), 30000);
@@ -183,8 +216,9 @@ class XiaoYiWebSocketManager extends events_1.EventEmitter {
183
216
  this.scheduleStableConnectionCheck('server1');
184
217
  // Send init message
185
218
  this.sendInitMessage(this.ws1, 'server1');
186
- // Start protocol heartbeat
187
- this.startProtocolHeartbeat('server1');
219
+ // Start heartbeat (replaces old startProtocolHeartbeat)
220
+ this.heartbeat1.start();
221
+ console.log(`[Server1] Heartbeat started (30s interval, 10s timeout)`);
188
222
  }
189
223
  catch (error) {
190
224
  console.error(`[Server1] Connection failed:`, error);
@@ -200,9 +234,13 @@ class XiaoYiWebSocketManager extends events_1.EventEmitter {
200
234
  async connectToServer2() {
201
235
  console.log(`[Server2] Connecting to ${this.config.wsUrl2}...`);
202
236
  try {
203
- // ✅ Close existing connection before creating new one to prevent ghost connections
237
+ // ✅ Close existing connection and heartbeat before creating new one
204
238
  if (this.ws2) {
205
239
  console.log(`[Server2] Closing existing connection before reconnect`);
240
+ if (this.heartbeat2) {
241
+ this.heartbeat2.stop();
242
+ this.heartbeat2 = undefined;
243
+ }
206
244
  try {
207
245
  this.ws2.removeAllListeners();
208
246
  this.ws2.close();
@@ -222,6 +260,26 @@ class XiaoYiWebSocketManager extends events_1.EventEmitter {
222
260
  headers: authHeaders,
223
261
  rejectUnauthorized: !skipCertVerify,
224
262
  });
263
+ // ✅ Initialize HeartbeatManager for server2
264
+ this.heartbeat2 = new heartbeat_js_1.HeartbeatManager(this.ws2, {
265
+ interval: 30000, // 30 seconds
266
+ timeout: 10000, // 10 seconds timeout
267
+ message: JSON.stringify({
268
+ msgType: "heartbeat",
269
+ agentId: this.config.agentId,
270
+ timestamp: Date.now(),
271
+ }),
272
+ }, () => {
273
+ console.log(`[Server2] Heartbeat timeout, reconnecting...`);
274
+ if (this.ws2 && (this.ws2.readyState === ws_1.default.OPEN || this.ws2.readyState === ws_1.default.CONNECTING)) {
275
+ this.ws2.close();
276
+ }
277
+ }, "server2", console.log, console.error, () => {
278
+ // ✅ Heartbeat success callback - report health to OpenClaw
279
+ this.emit("heartbeat", "server2");
280
+ // ✅ Report liveness to OpenClaw framework to prevent stale-socket detection
281
+ this.onHealthEvent?.();
282
+ });
225
283
  this.setupWebSocketHandlers(this.ws2, 'server2');
226
284
  await new Promise((resolve, reject) => {
227
285
  const timeout = setTimeout(() => reject(new Error("Connection timeout")), 30000);
@@ -242,8 +300,9 @@ class XiaoYiWebSocketManager extends events_1.EventEmitter {
242
300
  this.scheduleStableConnectionCheck('server2');
243
301
  // Send init message
244
302
  this.sendInitMessage(this.ws2, 'server2');
245
- // Start protocol heartbeat
246
- this.startProtocolHeartbeat('server2');
303
+ // Start heartbeat (replaces old startProtocolHeartbeat)
304
+ this.heartbeat2.start();
305
+ console.log(`[Server2] Heartbeat started (30s interval, 10s timeout)`);
247
306
  }
248
307
  catch (error) {
249
308
  console.error(`[Server2] Connection failed:`, error);
@@ -259,6 +318,17 @@ class XiaoYiWebSocketManager extends events_1.EventEmitter {
259
318
  disconnect() {
260
319
  console.log("[WS Manager] Disconnecting from all servers...");
261
320
  this.clearTimers();
321
+ // ✅ Stop heartbeat managers
322
+ if (this.heartbeat1) {
323
+ console.log("[Server1] Stopping heartbeat manager");
324
+ this.heartbeat1.stop();
325
+ this.heartbeat1 = undefined;
326
+ }
327
+ if (this.heartbeat2) {
328
+ console.log("[Server2] Stopping heartbeat manager");
329
+ this.heartbeat2.stop();
330
+ this.heartbeat2 = undefined;
331
+ }
262
332
  // ✅ Properly cleanup WebSocket connections to prevent ghost connections
263
333
  if (this.ws1) {
264
334
  try {
@@ -51,10 +51,11 @@ async function monitorXYProvider(opts = {}) {
51
51
  // diagnoseAllManagers();
52
52
  // Get WebSocket manager (cached)
53
53
  const wsManager = (0, xy_client_js_1.getXYWebSocketManager)(account);
54
- // // ✅ Set health event callback for heartbeat reporting
55
- // if (trackEvent) {
56
- // wsManager.setHealthEventCallback(trackEvent);
57
- // }
54
+ // ✅ Set health event callback for heartbeat reporting
55
+ // This ensures OpenClaw's health-monitor sees activity and doesn't trigger stale-socket restarts
56
+ if (trackEvent) {
57
+ wsManager.setHealthEventCallback(trackEvent);
58
+ }
58
59
  // Track logged servers to avoid duplicate logs
59
60
  const loggedServers = new Set();
60
61
  // Track active message processing to detect duplicates
@@ -125,9 +126,6 @@ async function monitorXYProvider(opts = {}) {
125
126
  };
126
127
  const cleanup = () => {
127
128
  log("XY gateway: cleaning up...");
128
- // // 🔍 Diagnose before cleanup
129
- // console.log("🔍 [DIAGNOSTICS] Checking WebSocket managers before cleanup...");
130
- // diagnoseAllManagers();
131
129
  // Stop health check interval
132
130
  if (healthCheckInterval) {
133
131
  clearInterval(healthCheckInterval);
@@ -139,17 +137,12 @@ async function monitorXYProvider(opts = {}) {
139
137
  wsManager.off("connected", connectedHandler);
140
138
  wsManager.off("disconnected", disconnectedHandler);
141
139
  wsManager.off("error", errorHandler);
142
- // ✅ Disconnect the wsManager to prevent connection leaks
143
- // This is safe because each gateway lifecycle should have clean connections
144
- wsManager.disconnect();
145
- // ✅ Remove manager from cache to prevent reusing dirty state
140
+ // ✅ Remove manager from cache - this will also disconnect
141
+ // removeXYWebSocketManager internally calls manager.disconnect()
146
142
  (0, xy_client_js_1.removeXYWebSocketManager)(account);
147
143
  loggedServers.clear();
148
144
  activeMessages.clear();
149
145
  log(`[MONITOR-HANDLER] 🧹 Cleanup complete, cleared active messages`);
150
- // // 🔍 Diagnose after cleanup
151
- // console.log("🔍 [DIAGNOSTICS] Checking WebSocket managers after cleanup...");
152
- // diagnoseAllManagers();
153
146
  };
154
147
  const handleAbort = () => {
155
148
  log("XY gateway: abort signal received, stopping");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ynhcj/xiaoyi",
3
- "version": "0.0.3-beta",
3
+ "version": "0.0.5-beta",
4
4
  "description": "XiaoYi channel plugin for OpenClaw",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",