@ynhcj/xiaoyi 0.0.3-beta → 0.0.4-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,6 +13,8 @@ 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?;
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) {
@@ -141,9 +142,13 @@ class XiaoYiWebSocketManager extends events_1.EventEmitter {
141
142
  async connectToServer1() {
142
143
  console.log(`[Server1] Connecting to ${this.config.wsUrl1}...`);
143
144
  try {
144
- // ✅ Close existing connection before creating new one to prevent ghost connections
145
+ // ✅ Close existing connection and heartbeat before creating new one
145
146
  if (this.ws1) {
146
147
  console.log(`[Server1] Closing existing connection before reconnect`);
148
+ if (this.heartbeat1) {
149
+ this.heartbeat1.stop();
150
+ this.heartbeat1 = undefined;
151
+ }
147
152
  try {
148
153
  this.ws1.removeAllListeners();
149
154
  this.ws1.close();
@@ -163,6 +168,24 @@ class XiaoYiWebSocketManager extends events_1.EventEmitter {
163
168
  headers: authHeaders,
164
169
  rejectUnauthorized: !skipCertVerify,
165
170
  });
171
+ // ✅ Initialize HeartbeatManager for server1
172
+ this.heartbeat1 = new heartbeat_js_1.HeartbeatManager(this.ws1, {
173
+ interval: 30000, // 30 seconds
174
+ timeout: 10000, // 10 seconds timeout
175
+ message: JSON.stringify({
176
+ msgType: "heartbeat",
177
+ agentId: this.config.agentId,
178
+ timestamp: Date.now(),
179
+ }),
180
+ }, () => {
181
+ console.log(`[Server1] Heartbeat timeout, reconnecting...`);
182
+ if (this.ws1 && (this.ws1.readyState === ws_1.default.OPEN || this.ws1.readyState === ws_1.default.CONNECTING)) {
183
+ this.ws1.close();
184
+ }
185
+ }, "server1", console.log, console.error, () => {
186
+ // ✅ Heartbeat success callback - report health to OpenClaw
187
+ this.emit("heartbeat", "server1");
188
+ });
166
189
  this.setupWebSocketHandlers(this.ws1, 'server1');
167
190
  await new Promise((resolve, reject) => {
168
191
  const timeout = setTimeout(() => reject(new Error("Connection timeout")), 30000);
@@ -183,8 +206,9 @@ class XiaoYiWebSocketManager extends events_1.EventEmitter {
183
206
  this.scheduleStableConnectionCheck('server1');
184
207
  // Send init message
185
208
  this.sendInitMessage(this.ws1, 'server1');
186
- // Start protocol heartbeat
187
- this.startProtocolHeartbeat('server1');
209
+ // Start heartbeat (replaces old startProtocolHeartbeat)
210
+ this.heartbeat1.start();
211
+ console.log(`[Server1] Heartbeat started (30s interval, 10s timeout)`);
188
212
  }
189
213
  catch (error) {
190
214
  console.error(`[Server1] Connection failed:`, error);
@@ -200,9 +224,13 @@ class XiaoYiWebSocketManager extends events_1.EventEmitter {
200
224
  async connectToServer2() {
201
225
  console.log(`[Server2] Connecting to ${this.config.wsUrl2}...`);
202
226
  try {
203
- // ✅ Close existing connection before creating new one to prevent ghost connections
227
+ // ✅ Close existing connection and heartbeat before creating new one
204
228
  if (this.ws2) {
205
229
  console.log(`[Server2] Closing existing connection before reconnect`);
230
+ if (this.heartbeat2) {
231
+ this.heartbeat2.stop();
232
+ this.heartbeat2 = undefined;
233
+ }
206
234
  try {
207
235
  this.ws2.removeAllListeners();
208
236
  this.ws2.close();
@@ -222,6 +250,24 @@ class XiaoYiWebSocketManager extends events_1.EventEmitter {
222
250
  headers: authHeaders,
223
251
  rejectUnauthorized: !skipCertVerify,
224
252
  });
253
+ // ✅ Initialize HeartbeatManager for server2
254
+ this.heartbeat2 = new heartbeat_js_1.HeartbeatManager(this.ws2, {
255
+ interval: 30000, // 30 seconds
256
+ timeout: 10000, // 10 seconds timeout
257
+ message: JSON.stringify({
258
+ msgType: "heartbeat",
259
+ agentId: this.config.agentId,
260
+ timestamp: Date.now(),
261
+ }),
262
+ }, () => {
263
+ console.log(`[Server2] Heartbeat timeout, reconnecting...`);
264
+ if (this.ws2 && (this.ws2.readyState === ws_1.default.OPEN || this.ws2.readyState === ws_1.default.CONNECTING)) {
265
+ this.ws2.close();
266
+ }
267
+ }, "server2", console.log, console.error, () => {
268
+ // ✅ Heartbeat success callback - report health to OpenClaw
269
+ this.emit("heartbeat", "server2");
270
+ });
225
271
  this.setupWebSocketHandlers(this.ws2, 'server2');
226
272
  await new Promise((resolve, reject) => {
227
273
  const timeout = setTimeout(() => reject(new Error("Connection timeout")), 30000);
@@ -242,8 +288,9 @@ class XiaoYiWebSocketManager extends events_1.EventEmitter {
242
288
  this.scheduleStableConnectionCheck('server2');
243
289
  // Send init message
244
290
  this.sendInitMessage(this.ws2, 'server2');
245
- // Start protocol heartbeat
246
- this.startProtocolHeartbeat('server2');
291
+ // Start heartbeat (replaces old startProtocolHeartbeat)
292
+ this.heartbeat2.start();
293
+ console.log(`[Server2] Heartbeat started (30s interval, 10s timeout)`);
247
294
  }
248
295
  catch (error) {
249
296
  console.error(`[Server2] Connection failed:`, error);
@@ -259,6 +306,17 @@ class XiaoYiWebSocketManager extends events_1.EventEmitter {
259
306
  disconnect() {
260
307
  console.log("[WS Manager] Disconnecting from all servers...");
261
308
  this.clearTimers();
309
+ // ✅ Stop heartbeat managers
310
+ if (this.heartbeat1) {
311
+ console.log("[Server1] Stopping heartbeat manager");
312
+ this.heartbeat1.stop();
313
+ this.heartbeat1 = undefined;
314
+ }
315
+ if (this.heartbeat2) {
316
+ console.log("[Server2] Stopping heartbeat manager");
317
+ this.heartbeat2.stop();
318
+ this.heartbeat2 = undefined;
319
+ }
262
320
  // ✅ Properly cleanup WebSocket connections to prevent ghost connections
263
321
  if (this.ws1) {
264
322
  try {
@@ -125,9 +125,6 @@ async function monitorXYProvider(opts = {}) {
125
125
  };
126
126
  const cleanup = () => {
127
127
  log("XY gateway: cleaning up...");
128
- // // 🔍 Diagnose before cleanup
129
- // console.log("🔍 [DIAGNOSTICS] Checking WebSocket managers before cleanup...");
130
- // diagnoseAllManagers();
131
128
  // Stop health check interval
132
129
  if (healthCheckInterval) {
133
130
  clearInterval(healthCheckInterval);
@@ -139,17 +136,12 @@ async function monitorXYProvider(opts = {}) {
139
136
  wsManager.off("connected", connectedHandler);
140
137
  wsManager.off("disconnected", disconnectedHandler);
141
138
  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
139
+ // ✅ Remove manager from cache - this will also disconnect
140
+ // removeXYWebSocketManager internally calls manager.disconnect()
146
141
  (0, xy_client_js_1.removeXYWebSocketManager)(account);
147
142
  loggedServers.clear();
148
143
  activeMessages.clear();
149
144
  log(`[MONITOR-HANDLER] 🧹 Cleanup complete, cleared active messages`);
150
- // // 🔍 Diagnose after cleanup
151
- // console.log("🔍 [DIAGNOSTICS] Checking WebSocket managers after cleanup...");
152
- // diagnoseAllManagers();
153
145
  };
154
146
  const handleAbort = () => {
155
147
  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.4-beta",
4
4
  "description": "XiaoYi channel plugin for OpenClaw",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",