@pingagent/sdk 0.1.4 → 0.1.5
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.
- package/bin/pingagent.js +11 -3
- package/dist/chunk-V4IHFEJC.js +1447 -0
- package/dist/chunk-Y67SYMD4.js +1456 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.js +1 -1
- package/dist/web-server.js +1 -1
- package/package.json +3 -3
- package/src/ws-subscription.ts +152 -0
package/dist/index.d.ts
CHANGED
|
@@ -488,6 +488,25 @@ interface WsSubscriptionOptions {
|
|
|
488
488
|
onError?: (err: Error) => void;
|
|
489
489
|
/** Called when a WebSocket opens or reopens; e.g. Skill can run catchUp to fill gaps. */
|
|
490
490
|
onOpen?: (conversationId: string) => void;
|
|
491
|
+
/**
|
|
492
|
+
* Keepalive heartbeat for idle connections to reduce "WS silently dropped" periods.
|
|
493
|
+
* This is client-side only (WS ping/pong control frames) and does not add inbox HTTP fetch cost.
|
|
494
|
+
*/
|
|
495
|
+
heartbeat?: {
|
|
496
|
+
enable?: boolean;
|
|
497
|
+
/** If idle time >= this value, start pinging. */
|
|
498
|
+
idleThresholdMs?: number;
|
|
499
|
+
/** Minimum interval between pings while connection is idle. */
|
|
500
|
+
pingIntervalMs?: number;
|
|
501
|
+
/** How long to wait for pong after sending ping. If timeout, close the socket. */
|
|
502
|
+
pongTimeoutMs?: number;
|
|
503
|
+
/** Max consecutive missed pongs before closing. */
|
|
504
|
+
maxMissedPongs?: number;
|
|
505
|
+
/** Heartbeat scheduler tick frequency. */
|
|
506
|
+
tickMs?: number;
|
|
507
|
+
/** Jitter for ping interval to avoid synchronized bursts across many connections. */
|
|
508
|
+
jitter?: number;
|
|
509
|
+
};
|
|
491
510
|
}
|
|
492
511
|
declare class WsSubscription {
|
|
493
512
|
private opts;
|
|
@@ -498,6 +517,8 @@ declare class WsSubscription {
|
|
|
498
517
|
private stopped;
|
|
499
518
|
/** Conversation IDs that were explicitly stopped (e.g. revoke); do not reconnect. */
|
|
500
519
|
private stoppedConversations;
|
|
520
|
+
private heartbeatTimer;
|
|
521
|
+
private heartbeatStates;
|
|
501
522
|
constructor(opts: WsSubscriptionOptions);
|
|
502
523
|
start(): void;
|
|
503
524
|
stop(): void;
|
|
@@ -508,6 +529,7 @@ declare class WsSubscription {
|
|
|
508
529
|
stopConversation(conversationId: string): void;
|
|
509
530
|
private wsUrl;
|
|
510
531
|
private connectAsync;
|
|
532
|
+
private heartbeatTick;
|
|
511
533
|
private scheduleReconnect;
|
|
512
534
|
private connectAll;
|
|
513
535
|
private syncConnections;
|
package/dist/index.js
CHANGED
package/dist/web-server.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pingagent/sdk",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
@@ -26,9 +26,9 @@
|
|
|
26
26
|
"commander": "^13.0.0",
|
|
27
27
|
"uuid": "^11.0.0",
|
|
28
28
|
"ws": "^8.0.0",
|
|
29
|
+
"@pingagent/protocol": "0.1.1",
|
|
29
30
|
"@pingagent/schemas": "0.1.1",
|
|
30
|
-
"@pingagent/a2a": "0.1.1"
|
|
31
|
-
"@pingagent/protocol": "0.1.1"
|
|
31
|
+
"@pingagent/a2a": "0.1.1"
|
|
32
32
|
},
|
|
33
33
|
"devDependencies": {
|
|
34
34
|
"@types/better-sqlite3": "^7.6.0",
|
package/src/ws-subscription.ts
CHANGED
|
@@ -21,6 +21,26 @@ export interface WsSubscriptionOptions {
|
|
|
21
21
|
onError?: (err: Error) => void;
|
|
22
22
|
/** Called when a WebSocket opens or reopens; e.g. Skill can run catchUp to fill gaps. */
|
|
23
23
|
onOpen?: (conversationId: string) => void;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Keepalive heartbeat for idle connections to reduce "WS silently dropped" periods.
|
|
27
|
+
* This is client-side only (WS ping/pong control frames) and does not add inbox HTTP fetch cost.
|
|
28
|
+
*/
|
|
29
|
+
heartbeat?: {
|
|
30
|
+
enable?: boolean;
|
|
31
|
+
/** If idle time >= this value, start pinging. */
|
|
32
|
+
idleThresholdMs?: number;
|
|
33
|
+
/** Minimum interval between pings while connection is idle. */
|
|
34
|
+
pingIntervalMs?: number;
|
|
35
|
+
/** How long to wait for pong after sending ping. If timeout, close the socket. */
|
|
36
|
+
pongTimeoutMs?: number;
|
|
37
|
+
/** Max consecutive missed pongs before closing. */
|
|
38
|
+
maxMissedPongs?: number;
|
|
39
|
+
/** Heartbeat scheduler tick frequency. */
|
|
40
|
+
tickMs?: number;
|
|
41
|
+
/** Jitter for ping interval to avoid synchronized bursts across many connections. */
|
|
42
|
+
jitter?: number;
|
|
43
|
+
};
|
|
24
44
|
}
|
|
25
45
|
|
|
26
46
|
const RECONNECT_BASE_MS = 1000;
|
|
@@ -28,6 +48,16 @@ const RECONNECT_MAX_MS = 30_000;
|
|
|
28
48
|
const RECONNECT_JITTER = 0.2;
|
|
29
49
|
const LIST_CONVERSATIONS_INTERVAL_MS = 60_000;
|
|
30
50
|
|
|
51
|
+
const DEFAULT_HEARTBEAT = {
|
|
52
|
+
enable: true,
|
|
53
|
+
idleThresholdMs: 25_000,
|
|
54
|
+
pingIntervalMs: 30_000,
|
|
55
|
+
pongTimeoutMs: 10_000,
|
|
56
|
+
maxMissedPongs: 2,
|
|
57
|
+
tickMs: 5_000,
|
|
58
|
+
jitter: 0.2,
|
|
59
|
+
};
|
|
60
|
+
|
|
31
61
|
export class WsSubscription {
|
|
32
62
|
private opts: WsSubscriptionOptions;
|
|
33
63
|
private connections = new Map<string, WebSocket>();
|
|
@@ -38,6 +68,18 @@ export class WsSubscription {
|
|
|
38
68
|
/** Conversation IDs that were explicitly stopped (e.g. revoke); do not reconnect. */
|
|
39
69
|
private stoppedConversations = new Set<string>();
|
|
40
70
|
|
|
71
|
+
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
|
72
|
+
private heartbeatStates = new Map<
|
|
73
|
+
string,
|
|
74
|
+
{
|
|
75
|
+
lastRxAt: number;
|
|
76
|
+
lastPingAt: number;
|
|
77
|
+
nextPingAt: number;
|
|
78
|
+
missedPongs: number;
|
|
79
|
+
pongTimeout: ReturnType<typeof setTimeout> | null;
|
|
80
|
+
}
|
|
81
|
+
>();
|
|
82
|
+
|
|
41
83
|
constructor(opts: WsSubscriptionOptions) {
|
|
42
84
|
this.opts = opts;
|
|
43
85
|
}
|
|
@@ -46,6 +88,12 @@ export class WsSubscription {
|
|
|
46
88
|
this.stopped = false;
|
|
47
89
|
this.connectAll();
|
|
48
90
|
this.listInterval = setInterval(() => this.syncConnections(), LIST_CONVERSATIONS_INTERVAL_MS);
|
|
91
|
+
|
|
92
|
+
// Start heartbeat scheduler (idle-triggered) once per process.
|
|
93
|
+
const hb = this.opts.heartbeat ?? {};
|
|
94
|
+
if (hb.enable ?? DEFAULT_HEARTBEAT.enable) {
|
|
95
|
+
this.heartbeatTimer = setInterval(() => this.heartbeatTick(), hb.tickMs ?? DEFAULT_HEARTBEAT.tickMs);
|
|
96
|
+
}
|
|
49
97
|
}
|
|
50
98
|
|
|
51
99
|
stop(): void {
|
|
@@ -54,6 +102,10 @@ export class WsSubscription {
|
|
|
54
102
|
clearInterval(this.listInterval);
|
|
55
103
|
this.listInterval = null;
|
|
56
104
|
}
|
|
105
|
+
if (this.heartbeatTimer) {
|
|
106
|
+
clearInterval(this.heartbeatTimer);
|
|
107
|
+
this.heartbeatTimer = null;
|
|
108
|
+
}
|
|
57
109
|
for (const timer of this.reconnectTimers.values()) {
|
|
58
110
|
clearTimeout(timer);
|
|
59
111
|
}
|
|
@@ -67,6 +119,11 @@ export class WsSubscription {
|
|
|
67
119
|
}
|
|
68
120
|
this.reconnectAttempts.clear();
|
|
69
121
|
this.stoppedConversations.clear();
|
|
122
|
+
|
|
123
|
+
for (const state of this.heartbeatStates.values()) {
|
|
124
|
+
if (state.pongTimeout) clearTimeout(state.pongTimeout);
|
|
125
|
+
}
|
|
126
|
+
this.heartbeatStates.clear();
|
|
70
127
|
}
|
|
71
128
|
|
|
72
129
|
/**
|
|
@@ -80,6 +137,11 @@ export class WsSubscription {
|
|
|
80
137
|
clearTimeout(timer);
|
|
81
138
|
this.reconnectTimers.delete(conversationId);
|
|
82
139
|
}
|
|
140
|
+
|
|
141
|
+
const hbState = this.heartbeatStates.get(conversationId);
|
|
142
|
+
if (hbState?.pongTimeout) clearTimeout(hbState.pongTimeout);
|
|
143
|
+
this.heartbeatStates.delete(conversationId);
|
|
144
|
+
|
|
83
145
|
const ws = this.connections.get(conversationId);
|
|
84
146
|
if (ws) {
|
|
85
147
|
ws.removeAllListeners();
|
|
@@ -106,6 +168,19 @@ export class WsSubscription {
|
|
|
106
168
|
|
|
107
169
|
this.connections.set(conversationId, ws);
|
|
108
170
|
|
|
171
|
+
const hb = this.opts.heartbeat ?? {};
|
|
172
|
+
const hbEnabled = hb.enable ?? DEFAULT_HEARTBEAT.enable;
|
|
173
|
+
if (hbEnabled) {
|
|
174
|
+
const now = Date.now();
|
|
175
|
+
this.heartbeatStates.set(conversationId, {
|
|
176
|
+
lastRxAt: now,
|
|
177
|
+
lastPingAt: 0,
|
|
178
|
+
nextPingAt: now + (hb.idleThresholdMs ?? DEFAULT_HEARTBEAT.idleThresholdMs),
|
|
179
|
+
missedPongs: 0,
|
|
180
|
+
pongTimeout: null,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
109
184
|
ws.on('open', () => {
|
|
110
185
|
this.reconnectAttempts.set(conversationId, 0);
|
|
111
186
|
this.opts.onOpen?.(conversationId);
|
|
@@ -115,6 +190,18 @@ export class WsSubscription {
|
|
|
115
190
|
ws.on('message', (data: Buffer | string) => {
|
|
116
191
|
try {
|
|
117
192
|
const msg = JSON.parse(data.toString());
|
|
193
|
+
|
|
194
|
+
// Any server frame counts as activity; keep connection stable and avoid unnecessary pings.
|
|
195
|
+
const state = this.heartbeatStates.get(conversationId);
|
|
196
|
+
if (state) {
|
|
197
|
+
state.lastRxAt = Date.now();
|
|
198
|
+
state.missedPongs = 0;
|
|
199
|
+
if (state.pongTimeout) {
|
|
200
|
+
clearTimeout(state.pongTimeout);
|
|
201
|
+
state.pongTimeout = null;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
118
205
|
if (msg.type === 'ws_message' && msg.envelope) {
|
|
119
206
|
const env = msg.envelope;
|
|
120
207
|
if (env.sender_did !== this.opts.myDid) {
|
|
@@ -128,8 +215,22 @@ export class WsSubscription {
|
|
|
128
215
|
}
|
|
129
216
|
});
|
|
130
217
|
|
|
218
|
+
ws.on('pong', () => {
|
|
219
|
+
const state = this.heartbeatStates.get(conversationId);
|
|
220
|
+
if (!state) return;
|
|
221
|
+
state.lastRxAt = Date.now();
|
|
222
|
+
state.missedPongs = 0;
|
|
223
|
+
if (state.pongTimeout) {
|
|
224
|
+
clearTimeout(state.pongTimeout);
|
|
225
|
+
state.pongTimeout = null;
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
|
|
131
229
|
ws.on('close', () => {
|
|
132
230
|
this.connections.delete(conversationId);
|
|
231
|
+
const state = this.heartbeatStates.get(conversationId);
|
|
232
|
+
if (state?.pongTimeout) clearTimeout(state.pongTimeout);
|
|
233
|
+
this.heartbeatStates.delete(conversationId);
|
|
133
234
|
if (!this.stopped && !this.stoppedConversations.has(conversationId)) {
|
|
134
235
|
this.scheduleReconnect(conversationId);
|
|
135
236
|
}
|
|
@@ -140,6 +241,57 @@ export class WsSubscription {
|
|
|
140
241
|
});
|
|
141
242
|
}
|
|
142
243
|
|
|
244
|
+
private heartbeatTick(): void {
|
|
245
|
+
const hb = this.opts.heartbeat ?? {};
|
|
246
|
+
const hbEnabled = hb.enable ?? DEFAULT_HEARTBEAT.enable;
|
|
247
|
+
if (!hbEnabled) return;
|
|
248
|
+
|
|
249
|
+
const idleThresholdMs = hb.idleThresholdMs ?? DEFAULT_HEARTBEAT.idleThresholdMs;
|
|
250
|
+
const pingIntervalMs = hb.pingIntervalMs ?? DEFAULT_HEARTBEAT.pingIntervalMs;
|
|
251
|
+
const pongTimeoutMs = hb.pongTimeoutMs ?? DEFAULT_HEARTBEAT.pongTimeoutMs;
|
|
252
|
+
const maxMissedPongs = hb.maxMissedPongs ?? DEFAULT_HEARTBEAT.maxMissedPongs;
|
|
253
|
+
const jitter = hb.jitter ?? DEFAULT_HEARTBEAT.jitter;
|
|
254
|
+
|
|
255
|
+
const now = Date.now();
|
|
256
|
+
|
|
257
|
+
for (const [conversationId, ws] of this.connections) {
|
|
258
|
+
if (ws.readyState !== WebSocket.OPEN) continue;
|
|
259
|
+
const state = this.heartbeatStates.get(conversationId);
|
|
260
|
+
if (!state) continue;
|
|
261
|
+
|
|
262
|
+
// If we are waiting for a pong, do not send another ping.
|
|
263
|
+
if (state.pongTimeout) continue;
|
|
264
|
+
|
|
265
|
+
const idleFor = now - state.lastRxAt;
|
|
266
|
+
if (idleFor < idleThresholdMs) continue;
|
|
267
|
+
|
|
268
|
+
if (now < state.nextPingAt) continue;
|
|
269
|
+
if (state.lastPingAt !== 0 && now - state.lastPingAt < pingIntervalMs * 0.5) {
|
|
270
|
+
// Extra guard against edge cases (should be rare).
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
ws.ping();
|
|
275
|
+
state.lastPingAt = now;
|
|
276
|
+
const jitterFactor = 1 + (Math.random() - 0.5) * 2 * jitter;
|
|
277
|
+
state.nextPingAt = now + Math.max(1000, pingIntervalMs * jitterFactor);
|
|
278
|
+
|
|
279
|
+
state.pongTimeout = setTimeout(() => {
|
|
280
|
+
state.missedPongs += 1;
|
|
281
|
+
state.pongTimeout = null;
|
|
282
|
+
|
|
283
|
+
if (state.missedPongs > maxMissedPongs) {
|
|
284
|
+
try {
|
|
285
|
+
// Trigger close handler -> scheduleReconnect
|
|
286
|
+
ws.close(1001, 'pong timeout');
|
|
287
|
+
} catch {
|
|
288
|
+
// ignore
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}, pongTimeoutMs);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
143
295
|
private scheduleReconnect(conversationId: string): void {
|
|
144
296
|
if (this.reconnectTimers.has(conversationId)) return;
|
|
145
297
|
|