@pingagent/sdk 0.1.0
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/LICENSE +21 -0
- package/__tests__/cli.test.ts +225 -0
- package/__tests__/identity.test.ts +47 -0
- package/__tests__/store.test.ts +332 -0
- package/bin/pingagent.js +1125 -0
- package/dist/chunk-4SRPVWK4.js +1316 -0
- package/dist/chunk-AI3NCJTH.js +1289 -0
- package/dist/chunk-BGMBF5ZM.js +1222 -0
- package/dist/chunk-DN7SKV2D.js +1237 -0
- package/dist/chunk-JQVK24KX.js +1231 -0
- package/dist/chunk-O4EHWIKD.js +1291 -0
- package/dist/chunk-PXMADBHD.js +1275 -0
- package/dist/chunk-TMAANDH6.js +1240 -0
- package/dist/index.d.ts +501 -0
- package/dist/index.js +38 -0
- package/dist/web-server.d.ts +26 -0
- package/dist/web-server.js +1036 -0
- package/package.json +46 -0
- package/src/a2a-adapter.ts +159 -0
- package/src/auth.ts +50 -0
- package/src/client.ts +580 -0
- package/src/contacts.ts +210 -0
- package/src/history.ts +227 -0
- package/src/identity.ts +86 -0
- package/src/index.ts +25 -0
- package/src/paths.ts +52 -0
- package/src/store.ts +62 -0
- package/src/transport.ts +141 -0
- package/src/web-server.ts +1106 -0
- package/src/ws-subscription.ts +198 -0
- package/tsconfig.json +8 -0
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebSocket subscription for real-time inbox messages.
|
|
3
|
+
* Connects to Gateway /v1/ws per conversation and receives ws_message pushes.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import WebSocket from 'ws';
|
|
7
|
+
import type { ConversationEntry } from './client.js';
|
|
8
|
+
|
|
9
|
+
export interface WsControlPayload {
|
|
10
|
+
action: string;
|
|
11
|
+
target?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface WsSubscriptionOptions {
|
|
15
|
+
serverUrl: string;
|
|
16
|
+
getAccessToken: () => string | Promise<string>;
|
|
17
|
+
myDid: string;
|
|
18
|
+
listConversations: () => Promise<ConversationEntry[]>;
|
|
19
|
+
onMessage: (envelope: any, conversationId: string) => void;
|
|
20
|
+
onControl?: (control: WsControlPayload, conversationId: string) => void;
|
|
21
|
+
onError?: (err: Error) => void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const RECONNECT_BASE_MS = 1000;
|
|
25
|
+
const RECONNECT_MAX_MS = 30_000;
|
|
26
|
+
const RECONNECT_JITTER = 0.2;
|
|
27
|
+
const LIST_CONVERSATIONS_INTERVAL_MS = 60_000;
|
|
28
|
+
|
|
29
|
+
export class WsSubscription {
|
|
30
|
+
private opts: WsSubscriptionOptions;
|
|
31
|
+
private connections = new Map<string, WebSocket>();
|
|
32
|
+
private reconnectTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
33
|
+
private reconnectAttempts = new Map<string, number>();
|
|
34
|
+
private listInterval: ReturnType<typeof setInterval> | null = null;
|
|
35
|
+
private stopped = false;
|
|
36
|
+
/** Conversation IDs that were explicitly stopped (e.g. revoke); do not reconnect. */
|
|
37
|
+
private stoppedConversations = new Set<string>();
|
|
38
|
+
|
|
39
|
+
constructor(opts: WsSubscriptionOptions) {
|
|
40
|
+
this.opts = opts;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
start(): void {
|
|
44
|
+
this.stopped = false;
|
|
45
|
+
this.connectAll();
|
|
46
|
+
this.listInterval = setInterval(() => this.syncConnections(), LIST_CONVERSATIONS_INTERVAL_MS);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
stop(): void {
|
|
50
|
+
this.stopped = true;
|
|
51
|
+
if (this.listInterval) {
|
|
52
|
+
clearInterval(this.listInterval);
|
|
53
|
+
this.listInterval = null;
|
|
54
|
+
}
|
|
55
|
+
for (const timer of this.reconnectTimers.values()) {
|
|
56
|
+
clearTimeout(timer);
|
|
57
|
+
}
|
|
58
|
+
this.reconnectTimers.clear();
|
|
59
|
+
for (const [convId, ws] of this.connections) {
|
|
60
|
+
ws.removeAllListeners();
|
|
61
|
+
if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
|
|
62
|
+
ws.close();
|
|
63
|
+
}
|
|
64
|
+
this.connections.delete(convId);
|
|
65
|
+
}
|
|
66
|
+
this.reconnectAttempts.clear();
|
|
67
|
+
this.stoppedConversations.clear();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Stop a single conversation's WebSocket and do not reconnect.
|
|
72
|
+
* Used when the conversation is revoked or the client no longer wants to subscribe.
|
|
73
|
+
*/
|
|
74
|
+
stopConversation(conversationId: string): void {
|
|
75
|
+
this.stoppedConversations.add(conversationId);
|
|
76
|
+
const timer = this.reconnectTimers.get(conversationId);
|
|
77
|
+
if (timer) {
|
|
78
|
+
clearTimeout(timer);
|
|
79
|
+
this.reconnectTimers.delete(conversationId);
|
|
80
|
+
}
|
|
81
|
+
const ws = this.connections.get(conversationId);
|
|
82
|
+
if (ws) {
|
|
83
|
+
ws.removeAllListeners();
|
|
84
|
+
if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
|
|
85
|
+
ws.close();
|
|
86
|
+
}
|
|
87
|
+
this.connections.delete(conversationId);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
private wsUrl(conversationId: string): string {
|
|
92
|
+
const base = this.opts.serverUrl.replace(/^http/, 'ws').replace(/\/$/, '');
|
|
93
|
+
return `${base}/v1/ws?conversation_id=${encodeURIComponent(conversationId)}`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
private async connectAsync(conversationId: string): Promise<void> {
|
|
97
|
+
if (this.stopped || this.stoppedConversations.has(conversationId) || this.connections.has(conversationId)) return;
|
|
98
|
+
|
|
99
|
+
const token = await Promise.resolve(this.opts.getAccessToken());
|
|
100
|
+
const url = this.wsUrl(conversationId);
|
|
101
|
+
const ws = new WebSocket(url, {
|
|
102
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
this.connections.set(conversationId, ws);
|
|
106
|
+
|
|
107
|
+
ws.on('open', () => {
|
|
108
|
+
this.reconnectAttempts.set(conversationId, 0);
|
|
109
|
+
// ws_connected will arrive as first message
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
ws.on('message', (data: Buffer | string) => {
|
|
113
|
+
try {
|
|
114
|
+
const msg = JSON.parse(data.toString());
|
|
115
|
+
if (msg.type === 'ws_message' && msg.envelope) {
|
|
116
|
+
const env = msg.envelope;
|
|
117
|
+
if (env.sender_did !== this.opts.myDid) {
|
|
118
|
+
this.opts.onMessage(env, conversationId);
|
|
119
|
+
}
|
|
120
|
+
} else if (msg.type === 'ws_control' && msg.control) {
|
|
121
|
+
this.opts.onControl?.(msg.control, conversationId);
|
|
122
|
+
}
|
|
123
|
+
} catch {
|
|
124
|
+
// ignore parse errors
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
ws.on('close', () => {
|
|
129
|
+
this.connections.delete(conversationId);
|
|
130
|
+
if (!this.stopped && !this.stoppedConversations.has(conversationId)) {
|
|
131
|
+
this.scheduleReconnect(conversationId);
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
ws.on('error', (err) => {
|
|
136
|
+
this.opts.onError?.(err);
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
private scheduleReconnect(conversationId: string): void {
|
|
141
|
+
if (this.reconnectTimers.has(conversationId)) return;
|
|
142
|
+
|
|
143
|
+
const attempt = this.reconnectAttempts.get(conversationId) ?? 0;
|
|
144
|
+
this.reconnectAttempts.set(conversationId, attempt + 1);
|
|
145
|
+
|
|
146
|
+
const tryConnect = () => {
|
|
147
|
+
this.reconnectTimers.delete(conversationId);
|
|
148
|
+
if (this.stopped) return;
|
|
149
|
+
void this.connectAsync(conversationId);
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const delay = Math.min(
|
|
153
|
+
RECONNECT_BASE_MS * Math.pow(2, attempt) + (Math.random() - 0.5) * RECONNECT_JITTER * RECONNECT_BASE_MS,
|
|
154
|
+
RECONNECT_MAX_MS,
|
|
155
|
+
);
|
|
156
|
+
const timer = setTimeout(tryConnect, delay);
|
|
157
|
+
this.reconnectTimers.set(conversationId, timer);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
private async connectAll(): Promise<void> {
|
|
161
|
+
try {
|
|
162
|
+
const convos = await this.opts.listConversations();
|
|
163
|
+
const subscribable = convos.filter((c) => c.type === 'dm' || c.type === 'channel');
|
|
164
|
+
for (const c of subscribable) {
|
|
165
|
+
void this.connectAsync(c.conversation_id);
|
|
166
|
+
}
|
|
167
|
+
} catch (err) {
|
|
168
|
+
this.opts.onError?.(err instanceof Error ? err : new Error(String(err)));
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
private async syncConnections(): Promise<void> {
|
|
173
|
+
if (this.stopped) return;
|
|
174
|
+
try {
|
|
175
|
+
const convos = await this.opts.listConversations();
|
|
176
|
+
const subscribableIds = new Set(
|
|
177
|
+
convos.filter((c) => c.type === 'dm' || c.type === 'channel').map((c) => c.conversation_id),
|
|
178
|
+
);
|
|
179
|
+
for (const convId of subscribableIds) {
|
|
180
|
+
if (!this.stoppedConversations.has(convId) && !this.connections.has(convId)) {
|
|
181
|
+
void this.connectAsync(convId);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
for (const convId of this.connections.keys()) {
|
|
185
|
+
if (!subscribableIds.has(convId) || this.stoppedConversations.has(convId)) {
|
|
186
|
+
const ws = this.connections.get(convId);
|
|
187
|
+
if (ws) {
|
|
188
|
+
ws.removeAllListeners();
|
|
189
|
+
ws.close();
|
|
190
|
+
this.connections.delete(convId);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
} catch {
|
|
195
|
+
// ignore
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|