@pingagent/sdk 0.1.4 → 0.1.6

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/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
@@ -16,7 +16,7 @@ import {
16
16
  loadIdentity,
17
17
  saveIdentity,
18
18
  updateStoredToken
19
- } from "./chunk-YS54ADYV.js";
19
+ } from "./chunk-OHS3SA5V.js";
20
20
  export {
21
21
  A2AAdapter,
22
22
  ContactManager,
@@ -5,7 +5,7 @@ import {
5
5
  ensureTokenValid,
6
6
  loadIdentity,
7
7
  updateStoredToken
8
- } from "./chunk-YS54ADYV.js";
8
+ } from "./chunk-OHS3SA5V.js";
9
9
 
10
10
  // src/web-server.ts
11
11
  import * as fs from "fs";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pingagent/sdk",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
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",
@@ -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: 10_000,
54
+ pingIntervalMs: 15_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);
@@ -114,7 +189,19 @@ export class WsSubscription {
114
189
 
115
190
  ws.on('message', (data: Buffer | string) => {
116
191
  try {
192
+ // Any server frame counts as activity; keep connection stable and avoid unnecessary pings.
193
+ const state = this.heartbeatStates.get(conversationId);
194
+ if (state) {
195
+ state.lastRxAt = Date.now();
196
+ state.missedPongs = 0;
197
+ if (state.pongTimeout) {
198
+ clearTimeout(state.pongTimeout);
199
+ state.pongTimeout = null;
200
+ }
201
+ }
202
+
117
203
  const msg = JSON.parse(data.toString());
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,65 @@ 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
+ // Application-level keepalive for CDN/NAT idle timeouts (more reliable than ping/pong alone).
275
+ // Receiver can ignore this message.
276
+ try {
277
+ ws.send(JSON.stringify({ type: 'hb' }));
278
+ } catch {
279
+ // ignore; ping/pong + reconnect will handle dead sockets
280
+ }
281
+
282
+ ws.ping();
283
+ state.lastPingAt = now;
284
+ const jitterFactor = 1 + (Math.random() - 0.5) * 2 * jitter;
285
+ state.nextPingAt = now + Math.max(1000, pingIntervalMs * jitterFactor);
286
+
287
+ state.pongTimeout = setTimeout(() => {
288
+ state.missedPongs += 1;
289
+ state.pongTimeout = null;
290
+
291
+ if (state.missedPongs >= maxMissedPongs) {
292
+ try {
293
+ // Trigger close handler -> scheduleReconnect
294
+ ws.close(1001, 'pong timeout');
295
+ } catch {
296
+ // ignore
297
+ }
298
+ }
299
+ }, pongTimeoutMs);
300
+ }
301
+ }
302
+
143
303
  private scheduleReconnect(conversationId: string): void {
144
304
  if (this.reconnectTimers.has(conversationId)) return;
145
305