@co0ontty/wand 1.26.0 → 1.29.1

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.
@@ -10,10 +10,20 @@ export declare class WsBroadcastManager {
10
10
  private clients;
11
11
  private outputDebounceCache;
12
12
  private eventEmitter;
13
+ private heartbeatTimer?;
13
14
  private getCardDefaults;
14
15
  constructor(wss: WebSocketServer, getCardDefaults?: () => CardExpandDefaults);
15
16
  /** Set up connection handling. Should be called once during server startup. */
16
17
  setup(getSession: (id: string) => SessionSnapshot | null): void;
18
+ /**
19
+ * 心跳 tick:对每个 client 执行 stale 判定 + 主动 ping。
20
+ * - 超过 HEARTBEAT_STALE_MS 没消息 → 视为半开 / 死连接,直接 terminate()。
21
+ * terminate() 不发 Close 帧,立刻断开 socket;前端 onclose 触发后会按
22
+ * 重连退避梯度自动重连。
23
+ * - 否则:应用层 send `{type:"ping", t}`(给前端拿来更新 lastWsMessageAt
24
+ * 和测 RTT),同时 ws.ping() 发协议层 ping(浏览器/CDN 友好,保 NAT)。
25
+ */
26
+ private runHeartbeatTick;
17
27
  /** Emit a process event to all subscribed WebSocket clients. */
18
28
  emitEvent(event: ProcessEvent): void;
19
29
  /** Flush any pending debounced output for a session (e.g., before session close). */
@@ -9,12 +9,24 @@ import { truncateMessagesForTransport } from "./message-truncator.js";
9
9
  // ── Constants ──
10
10
  const MAX_QUEUE_SIZE = 500;
11
11
  const OUTPUT_DEBOUNCE_MS = 16;
12
+ /**
13
+ * 服务端心跳节奏。20s 一次,比常见 NAT/代理空闲超时(30~60s)更短,可以保活;
14
+ * 也不至于让 idle 连接每秒都在跑 timer。前后端在心跳间窗内任何方向消息都会
15
+ * 重置"上次见到"计时。
16
+ */
17
+ const HEARTBEAT_INTERVAL_MS = 20_000;
18
+ /**
19
+ * 超过这个时长没收到对端任何消息(应用层 / 协议层 pong / 任意 frame),就视为
20
+ * 半开连接并 terminate。45s = 两个心跳周期再加 5s 容忍,可以避开偶发抖动。
21
+ */
22
+ const HEARTBEAT_STALE_MS = 45_000;
12
23
  // ── Manager ──
13
24
  export class WsBroadcastManager {
14
25
  wss;
15
26
  clients = new Set();
16
27
  outputDebounceCache = new Map();
17
28
  eventEmitter = new EventEmitter();
29
+ heartbeatTimer;
18
30
  getCardDefaults;
19
31
  constructor(wss, getCardDefaults) {
20
32
  this.wss = wss;
@@ -36,6 +48,7 @@ export class WsBroadcastManager {
36
48
  lastOutputBySession: new Map(),
37
49
  outputSeqBySession: new Map(),
38
50
  pendingResyncSessions: new Set(),
51
+ lastSeenAt: Date.now(),
39
52
  };
40
53
  this.clients.add(client);
41
54
  ws.on("close", () => {
@@ -44,7 +57,14 @@ export class WsBroadcastManager {
44
57
  ws.on("error", () => {
45
58
  // Already closed, ignore
46
59
  });
60
+ // 协议层 pong(浏览器对服务端 ws.ping() 的自动响应,不经过 JS)。
61
+ // 也算"对端还活着"的信号,刷新 lastSeenAt。
62
+ ws.on("pong", () => {
63
+ client.lastSeenAt = Date.now();
64
+ });
47
65
  ws.on("message", (data) => {
66
+ // 任意应用层消息都说明对端还在,先刷新心跳计时。
67
+ client.lastSeenAt = Date.now();
48
68
  try {
49
69
  const msg = JSON.parse(data.toString());
50
70
  if (msg.type === "subscribe" && msg.sessionId) {
@@ -65,12 +85,67 @@ export class WsBroadcastManager {
65
85
  if (snapshot)
66
86
  this.sendInit(client, msg.sessionId, snapshot, true);
67
87
  }
88
+ else if (msg.type === "pong") {
89
+ // 应用层 pong(对我们下发的 {type:"ping"} 的响应)。lastSeenAt
90
+ // 已经在函数顶部刷新过了,这里不需要再做事;分支留着是为了
91
+ // 把 pong 显式排除在"未知消息"之外。
92
+ }
68
93
  }
69
94
  catch {
70
95
  // Ignore malformed messages
71
96
  }
72
97
  });
73
98
  });
99
+ // 启动心跳轮询。每个 tick 检查所有 client:sleep 太久就 terminate,否则
100
+ // 发应用层 ping 让前端有机会做 stale 检测。
101
+ this.heartbeatTimer = setInterval(() => {
102
+ this.runHeartbeatTick();
103
+ }, HEARTBEAT_INTERVAL_MS);
104
+ // 不要让心跳 timer 阻止 Node 进程退出(restart / 测试场景)。
105
+ this.heartbeatTimer.unref?.();
106
+ // 在 wss 关闭时停心跳。restart 路由会先 close 所有 client、再 server.close(),
107
+ // wss 会跟着关,这里清掉 interval 防止泄漏。
108
+ this.wss.on("close", () => {
109
+ if (this.heartbeatTimer) {
110
+ clearInterval(this.heartbeatTimer);
111
+ this.heartbeatTimer = undefined;
112
+ }
113
+ });
114
+ }
115
+ /**
116
+ * 心跳 tick:对每个 client 执行 stale 判定 + 主动 ping。
117
+ * - 超过 HEARTBEAT_STALE_MS 没消息 → 视为半开 / 死连接,直接 terminate()。
118
+ * terminate() 不发 Close 帧,立刻断开 socket;前端 onclose 触发后会按
119
+ * 重连退避梯度自动重连。
120
+ * - 否则:应用层 send `{type:"ping", t}`(给前端拿来更新 lastWsMessageAt
121
+ * 和测 RTT),同时 ws.ping() 发协议层 ping(浏览器/CDN 友好,保 NAT)。
122
+ */
123
+ runHeartbeatTick() {
124
+ const now = Date.now();
125
+ for (const client of this.clients) {
126
+ if (client.ws.readyState !== WebSocket.OPEN) {
127
+ // 已经不是 OPEN 了,close handler 通常会清理;这里兜底防止集合里
128
+ // 留下僵尸 entry。
129
+ this.clients.delete(client);
130
+ continue;
131
+ }
132
+ if (now - client.lastSeenAt > HEARTBEAT_STALE_MS) {
133
+ try {
134
+ client.ws.terminate();
135
+ }
136
+ catch { /* ignore */ }
137
+ this.clients.delete(client);
138
+ continue;
139
+ }
140
+ try {
141
+ client.ws.send(JSON.stringify({ type: "ping", t: now }));
142
+ }
143
+ catch { /* ignore */ }
144
+ try {
145
+ client.ws.ping();
146
+ }
147
+ catch { /* ignore */ }
148
+ }
74
149
  }
75
150
  /** Emit a process event to all subscribed WebSocket clients. */
76
151
  emitEvent(event) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@co0ontty/wand",
3
- "version": "1.26.0",
3
+ "version": "1.29.1",
4
4
  "description": "A web terminal for local CLI tools like Claude.",
5
5
  "type": "module",
6
6
  "bin": {