@pingagent/sdk 0.1.3 → 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 +34 -4
- package/dist/chunk-V4IHFEJC.js +1447 -0
- package/dist/chunk-Y67SYMD4.js +1456 -0
- package/dist/chunk-YS54ADYV.js +1350 -0
- package/dist/index.d.ts +33 -0
- package/dist/index.js +1 -1
- package/dist/web-server.js +1 -1
- package/package.json +1 -1
- package/src/history.ts +43 -1
- package/src/ws-subscription.ts +152 -0
package/dist/index.d.ts
CHANGED
|
@@ -67,7 +67,18 @@ declare class HistoryManager {
|
|
|
67
67
|
limit?: number;
|
|
68
68
|
beforeSeq?: number;
|
|
69
69
|
afterSeq?: number;
|
|
70
|
+
beforeTsMs?: number;
|
|
71
|
+
afterTsMs?: number;
|
|
70
72
|
}): StoredMessage[];
|
|
73
|
+
/**
|
|
74
|
+
* List the most recent N messages *before* a seq (paging older history).
|
|
75
|
+
* Only compares rows where seq is not null.
|
|
76
|
+
*/
|
|
77
|
+
listBeforeSeq(conversationId: string, beforeSeq: number, limit: number): StoredMessage[];
|
|
78
|
+
/**
|
|
79
|
+
* List the most recent N messages *before* a timestamp (paging older history).
|
|
80
|
+
*/
|
|
81
|
+
listBeforeTs(conversationId: string, beforeTsMs: number, limit: number): StoredMessage[];
|
|
71
82
|
/** Returns the N most recent messages (chronological order, oldest to newest of those N). */
|
|
72
83
|
listRecent(conversationId: string, limit: number): StoredMessage[];
|
|
73
84
|
search(query: string, opts?: {
|
|
@@ -477,6 +488,25 @@ interface WsSubscriptionOptions {
|
|
|
477
488
|
onError?: (err: Error) => void;
|
|
478
489
|
/** Called when a WebSocket opens or reopens; e.g. Skill can run catchUp to fill gaps. */
|
|
479
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
|
+
};
|
|
480
510
|
}
|
|
481
511
|
declare class WsSubscription {
|
|
482
512
|
private opts;
|
|
@@ -487,6 +517,8 @@ declare class WsSubscription {
|
|
|
487
517
|
private stopped;
|
|
488
518
|
/** Conversation IDs that were explicitly stopped (e.g. revoke); do not reconnect. */
|
|
489
519
|
private stoppedConversations;
|
|
520
|
+
private heartbeatTimer;
|
|
521
|
+
private heartbeatStates;
|
|
490
522
|
constructor(opts: WsSubscriptionOptions);
|
|
491
523
|
start(): void;
|
|
492
524
|
stop(): void;
|
|
@@ -497,6 +529,7 @@ declare class WsSubscription {
|
|
|
497
529
|
stopConversation(conversationId: string): void;
|
|
498
530
|
private wsUrl;
|
|
499
531
|
private connectAsync;
|
|
532
|
+
private heartbeatTick;
|
|
500
533
|
private scheduleReconnect;
|
|
501
534
|
private connectAll;
|
|
502
535
|
private syncConnections;
|
package/dist/index.js
CHANGED
package/dist/web-server.js
CHANGED
package/package.json
CHANGED
package/src/history.ts
CHANGED
|
@@ -82,7 +82,10 @@ export class HistoryManager {
|
|
|
82
82
|
return count;
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
-
list(
|
|
85
|
+
list(
|
|
86
|
+
conversationId: string,
|
|
87
|
+
opts?: { limit?: number; beforeSeq?: number; afterSeq?: number; beforeTsMs?: number; afterTsMs?: number },
|
|
88
|
+
): StoredMessage[] {
|
|
86
89
|
const conditions = ['conversation_id = ?'];
|
|
87
90
|
const params: any[] = [conversationId];
|
|
88
91
|
|
|
@@ -94,6 +97,14 @@ export class HistoryManager {
|
|
|
94
97
|
conditions.push('seq > ?');
|
|
95
98
|
params.push(opts.afterSeq);
|
|
96
99
|
}
|
|
100
|
+
if (opts?.beforeTsMs !== undefined) {
|
|
101
|
+
conditions.push('ts_ms < ?');
|
|
102
|
+
params.push(opts.beforeTsMs);
|
|
103
|
+
}
|
|
104
|
+
if (opts?.afterTsMs !== undefined) {
|
|
105
|
+
conditions.push('ts_ms > ?');
|
|
106
|
+
params.push(opts.afterTsMs);
|
|
107
|
+
}
|
|
97
108
|
|
|
98
109
|
const limit = opts?.limit ?? 100;
|
|
99
110
|
const rows = this.store.getDb()
|
|
@@ -102,6 +113,37 @@ export class HistoryManager {
|
|
|
102
113
|
return rows.map(rowToMessage);
|
|
103
114
|
}
|
|
104
115
|
|
|
116
|
+
/**
|
|
117
|
+
* List the most recent N messages *before* a seq (paging older history).
|
|
118
|
+
* Only compares rows where seq is not null.
|
|
119
|
+
*/
|
|
120
|
+
listBeforeSeq(conversationId: string, beforeSeq: number, limit: number): StoredMessage[] {
|
|
121
|
+
const rows = this.store.getDb()
|
|
122
|
+
.prepare(
|
|
123
|
+
`SELECT * FROM messages
|
|
124
|
+
WHERE conversation_id = ? AND seq IS NOT NULL AND seq < ?
|
|
125
|
+
ORDER BY seq DESC
|
|
126
|
+
LIMIT ?`,
|
|
127
|
+
)
|
|
128
|
+
.all(conversationId, beforeSeq, limit) as MessageRow[];
|
|
129
|
+
return rows.map(rowToMessage).reverse();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* List the most recent N messages *before* a timestamp (paging older history).
|
|
134
|
+
*/
|
|
135
|
+
listBeforeTs(conversationId: string, beforeTsMs: number, limit: number): StoredMessage[] {
|
|
136
|
+
const rows = this.store.getDb()
|
|
137
|
+
.prepare(
|
|
138
|
+
`SELECT * FROM messages
|
|
139
|
+
WHERE conversation_id = ? AND ts_ms < ?
|
|
140
|
+
ORDER BY ts_ms DESC
|
|
141
|
+
LIMIT ?`,
|
|
142
|
+
)
|
|
143
|
+
.all(conversationId, beforeTsMs, limit) as MessageRow[];
|
|
144
|
+
return rows.map(rowToMessage).reverse();
|
|
145
|
+
}
|
|
146
|
+
|
|
105
147
|
/** Returns the N most recent messages (chronological order, oldest to newest of those N). */
|
|
106
148
|
listRecent(conversationId: string, limit: number): StoredMessage[] {
|
|
107
149
|
const rows = this.store.getDb()
|
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
|
|