@ynhcj/xiaoyi 0.0.2-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,6 +142,22 @@ class XiaoYiWebSocketManager extends events_1.EventEmitter {
141
142
  async connectToServer1() {
142
143
  console.log(`[Server1] Connecting to ${this.config.wsUrl1}...`);
143
144
  try {
145
+ // ✅ Close existing connection and heartbeat before creating new one
146
+ if (this.ws1) {
147
+ console.log(`[Server1] Closing existing connection before reconnect`);
148
+ if (this.heartbeat1) {
149
+ this.heartbeat1.stop();
150
+ this.heartbeat1 = undefined;
151
+ }
152
+ try {
153
+ this.ws1.removeAllListeners();
154
+ this.ws1.close();
155
+ }
156
+ catch (err) {
157
+ console.warn(`[Server1] Error closing old connection:`, err);
158
+ }
159
+ this.ws1 = null;
160
+ }
144
161
  const authHeaders = this.auth.generateAuthHeaders();
145
162
  // Check if URL is wss + IP format, skip certificate verification
146
163
  const skipCertVerify = this.isWssWithIp(this.config.wsUrl1);
@@ -151,6 +168,24 @@ class XiaoYiWebSocketManager extends events_1.EventEmitter {
151
168
  headers: authHeaders,
152
169
  rejectUnauthorized: !skipCertVerify,
153
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
+ });
154
189
  this.setupWebSocketHandlers(this.ws1, 'server1');
155
190
  await new Promise((resolve, reject) => {
156
191
  const timeout = setTimeout(() => reject(new Error("Connection timeout")), 30000);
@@ -171,8 +206,9 @@ class XiaoYiWebSocketManager extends events_1.EventEmitter {
171
206
  this.scheduleStableConnectionCheck('server1');
172
207
  // Send init message
173
208
  this.sendInitMessage(this.ws1, 'server1');
174
- // Start protocol heartbeat
175
- this.startProtocolHeartbeat('server1');
209
+ // Start heartbeat (replaces old startProtocolHeartbeat)
210
+ this.heartbeat1.start();
211
+ console.log(`[Server1] Heartbeat started (30s interval, 10s timeout)`);
176
212
  }
177
213
  catch (error) {
178
214
  console.error(`[Server1] Connection failed:`, error);
@@ -188,6 +224,22 @@ class XiaoYiWebSocketManager extends events_1.EventEmitter {
188
224
  async connectToServer2() {
189
225
  console.log(`[Server2] Connecting to ${this.config.wsUrl2}...`);
190
226
  try {
227
+ // ✅ Close existing connection and heartbeat before creating new one
228
+ if (this.ws2) {
229
+ console.log(`[Server2] Closing existing connection before reconnect`);
230
+ if (this.heartbeat2) {
231
+ this.heartbeat2.stop();
232
+ this.heartbeat2 = undefined;
233
+ }
234
+ try {
235
+ this.ws2.removeAllListeners();
236
+ this.ws2.close();
237
+ }
238
+ catch (err) {
239
+ console.warn(`[Server2] Error closing old connection:`, err);
240
+ }
241
+ this.ws2 = null;
242
+ }
191
243
  const authHeaders = this.auth.generateAuthHeaders();
192
244
  // Check if URL is wss + IP format, skip certificate verification
193
245
  const skipCertVerify = this.isWssWithIp(this.config.wsUrl2);
@@ -198,6 +250,24 @@ class XiaoYiWebSocketManager extends events_1.EventEmitter {
198
250
  headers: authHeaders,
199
251
  rejectUnauthorized: !skipCertVerify,
200
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
+ });
201
271
  this.setupWebSocketHandlers(this.ws2, 'server2');
202
272
  await new Promise((resolve, reject) => {
203
273
  const timeout = setTimeout(() => reject(new Error("Connection timeout")), 30000);
@@ -218,8 +288,9 @@ class XiaoYiWebSocketManager extends events_1.EventEmitter {
218
288
  this.scheduleStableConnectionCheck('server2');
219
289
  // Send init message
220
290
  this.sendInitMessage(this.ws2, 'server2');
221
- // Start protocol heartbeat
222
- this.startProtocolHeartbeat('server2');
291
+ // Start heartbeat (replaces old startProtocolHeartbeat)
292
+ this.heartbeat2.start();
293
+ console.log(`[Server2] Heartbeat started (30s interval, 10s timeout)`);
223
294
  }
224
295
  catch (error) {
225
296
  console.error(`[Server2] Connection failed:`, error);
@@ -235,12 +306,42 @@ class XiaoYiWebSocketManager extends events_1.EventEmitter {
235
306
  disconnect() {
236
307
  console.log("[WS Manager] Disconnecting from all servers...");
237
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
+ }
320
+ // ✅ Properly cleanup WebSocket connections to prevent ghost connections
238
321
  if (this.ws1) {
239
- this.ws1.close();
322
+ try {
323
+ console.log("[Server1] Removing all listeners and closing connection");
324
+ this.ws1.removeAllListeners();
325
+ if (this.ws1.readyState === ws_1.default.OPEN || this.ws1.readyState === ws_1.default.CONNECTING) {
326
+ this.ws1.close();
327
+ }
328
+ }
329
+ catch (err) {
330
+ console.warn("[Server1] Error during disconnect:", err);
331
+ }
240
332
  this.ws1 = null;
241
333
  }
242
334
  if (this.ws2) {
243
- this.ws2.close();
335
+ try {
336
+ console.log("[Server2] Removing all listeners and closing connection");
337
+ this.ws2.removeAllListeners();
338
+ if (this.ws2.readyState === ws_1.default.OPEN || this.ws2.readyState === ws_1.default.CONNECTING) {
339
+ this.ws2.close();
340
+ }
341
+ }
342
+ catch (err) {
343
+ console.warn("[Server2] Error during disconnect:", err);
344
+ }
244
345
  this.ws2 = null;
245
346
  }
246
347
  this.state1.connected = false;
@@ -257,6 +358,7 @@ class XiaoYiWebSocketManager extends events_1.EventEmitter {
257
358
  }
258
359
  this.sessionCleanupStateMap.clear();
259
360
  this.emit("disconnected");
361
+ console.log("[WS Manager] Disconnect complete");
260
362
  }
261
363
  /**
262
364
  * Send init message to specific server
@@ -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,12 +1,24 @@
1
1
  {
2
2
  "name": "@ynhcj/xiaoyi",
3
- "version": "0.0.2-beta",
3
+ "version": "0.0.4-beta",
4
4
  "description": "XiaoYi channel plugin for OpenClaw",
5
- "main": "dist/index.js",
6
- "types": "dist/index.d.ts",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "import": "./dist/index.js",
11
+ "types": "./dist/index.d.ts"
12
+ }
13
+ },
14
+ "files": [
15
+ "dist",
16
+ "xiaoyi.js",
17
+ "openclaw.plugin.json",
18
+ "README.md"
19
+ ],
7
20
  "scripts": {
8
- "build": "tsc && node fix-imports.js",
9
- "prepublishOnly": "npm run build"
21
+ "build": "tsc"
10
22
  },
11
23
  "keywords": [
12
24
  "openclaw",
@@ -43,7 +55,7 @@
43
55
  }
44
56
  },
45
57
  "peerDependencies": {
46
- "openclaw": "*"
58
+ "openclaw": ">=2026.3.1"
47
59
  },
48
60
  "peerDependenciesMeta": {
49
61
  "openclaw": {
@@ -61,13 +73,6 @@
61
73
  "@types/node-fetch": "^2.6.13",
62
74
  "@types/uuid": "^10.0.0",
63
75
  "@types/ws": "^8.5.10",
64
- "openclaw": "^2026.3.13",
65
76
  "typescript": "^5.3.3"
66
- },
67
- "files": [
68
- "dist",
69
- "xiaoyi.js",
70
- "openclaw.plugin.json",
71
- "README.md"
72
- ]
77
+ }
73
78
  }