@pnds/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/dist/client.d.ts +117 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +282 -0
- package/dist/constants.d.ts +72 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/constants.js +88 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +4 -0
- package/dist/types.d.ts +454 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +4 -0
- package/dist/ws.d.ts +49 -0
- package/dist/ws.d.ts.map +1 -0
- package/dist/ws.js +198 -0
- package/package.json +31 -0
- package/src/client.ts +435 -0
- package/src/constants.ts +119 -0
- package/src/index.ts +6 -0
- package/src/types.ts +569 -0
- package/src/ws.ts +250 -0
package/src/ws.ts
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import { WS_EVENTS } from './constants.js';
|
|
2
|
+
import type { WsFrame, WsEventMap, WsTicketResponse } from './types.js';
|
|
3
|
+
|
|
4
|
+
export type WsEventType = keyof WsEventMap;
|
|
5
|
+
export type WsEventHandler<T extends WsEventType> = (data: WsEventMap[T], seq?: number) => void;
|
|
6
|
+
|
|
7
|
+
export type WsConnectionState = 'connecting' | 'connected' | 'disconnected' | 'reconnecting';
|
|
8
|
+
|
|
9
|
+
export interface PondWsOptions {
|
|
10
|
+
/** Async function that fetches a one-time WS ticket from the API. */
|
|
11
|
+
getTicket: () => Promise<WsTicketResponse>;
|
|
12
|
+
/** Fallback WS URL if getTicket response doesn't include ws_url. */
|
|
13
|
+
wsUrl?: string;
|
|
14
|
+
lastSeq?: number;
|
|
15
|
+
reconnect?: boolean;
|
|
16
|
+
reconnectInterval?: number;
|
|
17
|
+
maxReconnectInterval?: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export class PondWs {
|
|
21
|
+
private ws: WebSocket | null = null;
|
|
22
|
+
private options: PondWsOptions & { reconnect: boolean; reconnectInterval: number; maxReconnectInterval: number };
|
|
23
|
+
private listeners = new Map<string, Set<WsEventHandler<WsEventType>>>();
|
|
24
|
+
private stateListeners = new Set<(state: WsConnectionState) => void>();
|
|
25
|
+
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
|
26
|
+
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
27
|
+
private reconnectAttempts = 0;
|
|
28
|
+
private lastSeq = 0;
|
|
29
|
+
private _state: WsConnectionState = 'disconnected';
|
|
30
|
+
private intentionalClose = false;
|
|
31
|
+
|
|
32
|
+
constructor(options: PondWsOptions) {
|
|
33
|
+
this.options = {
|
|
34
|
+
reconnect: true,
|
|
35
|
+
reconnectInterval: 1000,
|
|
36
|
+
maxReconnectInterval: 30000,
|
|
37
|
+
...options,
|
|
38
|
+
};
|
|
39
|
+
this.lastSeq = options.lastSeq ?? 0;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
get state(): WsConnectionState {
|
|
43
|
+
return this._state;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async connect() {
|
|
47
|
+
this.intentionalClose = false;
|
|
48
|
+
this.setState('connecting');
|
|
49
|
+
|
|
50
|
+
let ticket: WsTicketResponse;
|
|
51
|
+
try {
|
|
52
|
+
ticket = await this.options.getTicket();
|
|
53
|
+
} catch (err) {
|
|
54
|
+
console.error('Failed to get WS ticket:', err);
|
|
55
|
+
if (this.options.reconnect) {
|
|
56
|
+
this.scheduleReconnect();
|
|
57
|
+
} else {
|
|
58
|
+
this.setState('disconnected');
|
|
59
|
+
}
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const wsUrl = ticket.ws_url || this.options.wsUrl;
|
|
64
|
+
if (!wsUrl) {
|
|
65
|
+
console.error('No WS URL available');
|
|
66
|
+
this.setState('disconnected');
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const params = new URLSearchParams({ ticket: ticket.ticket });
|
|
71
|
+
if (this.lastSeq > 0) {
|
|
72
|
+
params.set('last_seq', String(this.lastSeq));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
this.ws = new WebSocket(`${wsUrl}?${params.toString()}`);
|
|
76
|
+
|
|
77
|
+
this.ws.onopen = () => {
|
|
78
|
+
this.reconnectAttempts = 0;
|
|
79
|
+
this.setState('connected');
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
this.ws.onmessage = (event) => {
|
|
83
|
+
try {
|
|
84
|
+
const frame: WsFrame = JSON.parse(event.data as string);
|
|
85
|
+
this.handleFrame(frame);
|
|
86
|
+
} catch {
|
|
87
|
+
// ignore malformed frames
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
this.ws.onclose = () => {
|
|
92
|
+
this.stopHeartbeat();
|
|
93
|
+
if (this.intentionalClose) {
|
|
94
|
+
this.setState('disconnected');
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
if (this.options.reconnect) {
|
|
98
|
+
this.scheduleReconnect();
|
|
99
|
+
} else {
|
|
100
|
+
this.setState('disconnected');
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
this.ws.onerror = () => {
|
|
105
|
+
// onclose will fire after this
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
disconnect() {
|
|
110
|
+
this.intentionalClose = true;
|
|
111
|
+
this.stopHeartbeat();
|
|
112
|
+
this.clearReconnect();
|
|
113
|
+
if (this.ws) {
|
|
114
|
+
this.ws.close();
|
|
115
|
+
this.ws = null;
|
|
116
|
+
}
|
|
117
|
+
this.setState('disconnected');
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ---- Client -> Server messages ----
|
|
121
|
+
|
|
122
|
+
/** Tell the server which chats the user is currently viewing (no auth implications). */
|
|
123
|
+
watch(chatIds: string[]) {
|
|
124
|
+
this.send({ type: WS_EVENTS.WATCH, data: { chat_ids: chatIds } });
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
sendTyping(chatId: string, action: 'start' | 'stop', threadRootId?: string) {
|
|
128
|
+
this.send({
|
|
129
|
+
type: WS_EVENTS.TYPING,
|
|
130
|
+
data: { chat_id: chatId, thread_root_id: threadRootId ?? null, action },
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
sendAck(messageId: string) {
|
|
135
|
+
this.send({ type: WS_EVENTS.ACK, data: { message_id: messageId } });
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
sendRead(chatId: string, lastReadId: string) {
|
|
139
|
+
this.send({ type: WS_EVENTS.READ, data: { chat_id: chatId, last_read_id: lastReadId } });
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Send an agent heartbeat with telemetry data. */
|
|
143
|
+
sendAgentHeartbeat(sessionId: string, telemetry?: Record<string, unknown>) {
|
|
144
|
+
this.send({
|
|
145
|
+
type: WS_EVENTS.AGENT_HEARTBEAT,
|
|
146
|
+
data: { session_id: sessionId, telemetry },
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
sendEvent(type: string, data: unknown) {
|
|
151
|
+
this.send({ type, data });
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ---- Event listeners ----
|
|
155
|
+
|
|
156
|
+
on<T extends WsEventType>(event: T, handler: WsEventHandler<T>) {
|
|
157
|
+
let set = this.listeners.get(event);
|
|
158
|
+
if (!set) {
|
|
159
|
+
set = new Set();
|
|
160
|
+
this.listeners.set(event, set);
|
|
161
|
+
}
|
|
162
|
+
set.add(handler as WsEventHandler<WsEventType>);
|
|
163
|
+
return () => {
|
|
164
|
+
set!.delete(handler as WsEventHandler<WsEventType>);
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
off<T extends WsEventType>(event: T, handler: WsEventHandler<T>) {
|
|
169
|
+
this.listeners.get(event)?.delete(handler as WsEventHandler<WsEventType>);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
onStateChange(handler: (state: WsConnectionState) => void) {
|
|
173
|
+
this.stateListeners.add(handler);
|
|
174
|
+
return () => {
|
|
175
|
+
this.stateListeners.delete(handler);
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ---- Internal ----
|
|
180
|
+
|
|
181
|
+
private send(frame: { type: string; data: unknown }) {
|
|
182
|
+
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
183
|
+
this.ws.send(JSON.stringify(frame));
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
private handleFrame(frame: WsFrame) {
|
|
188
|
+
// Track seq for reconnection
|
|
189
|
+
if (frame.seq !== undefined) {
|
|
190
|
+
this.lastSeq = frame.seq;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Handle hello — start heartbeat
|
|
194
|
+
if (frame.type === WS_EVENTS.HELLO) {
|
|
195
|
+
const interval = (frame.data as { heartbeat_interval: number }).heartbeat_interval;
|
|
196
|
+
this.startHeartbeat(interval);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Dispatch to listeners
|
|
200
|
+
const handlers = this.listeners.get(frame.type);
|
|
201
|
+
if (handlers) {
|
|
202
|
+
for (const handler of handlers) {
|
|
203
|
+
handler(frame.data as WsEventMap[WsEventType], frame.seq);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
private startHeartbeat(intervalSec: number) {
|
|
209
|
+
this.stopHeartbeat();
|
|
210
|
+
this.heartbeatTimer = setInterval(() => {
|
|
211
|
+
this.send({ type: WS_EVENTS.PING, data: { ts: Date.now() } });
|
|
212
|
+
}, intervalSec * 1000);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
private stopHeartbeat() {
|
|
216
|
+
if (this.heartbeatTimer) {
|
|
217
|
+
clearInterval(this.heartbeatTimer);
|
|
218
|
+
this.heartbeatTimer = null;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
private scheduleReconnect() {
|
|
223
|
+
this.setState('reconnecting');
|
|
224
|
+
this.clearReconnect();
|
|
225
|
+
|
|
226
|
+
const delay = Math.min(
|
|
227
|
+
this.options.reconnectInterval * Math.pow(2, this.reconnectAttempts),
|
|
228
|
+
this.options.maxReconnectInterval,
|
|
229
|
+
);
|
|
230
|
+
this.reconnectAttempts++;
|
|
231
|
+
|
|
232
|
+
this.reconnectTimer = setTimeout(() => {
|
|
233
|
+
this.connect();
|
|
234
|
+
}, delay);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
private clearReconnect() {
|
|
238
|
+
if (this.reconnectTimer) {
|
|
239
|
+
clearTimeout(this.reconnectTimer);
|
|
240
|
+
this.reconnectTimer = null;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
private setState(state: WsConnectionState) {
|
|
245
|
+
this._state = state;
|
|
246
|
+
for (const listener of this.stateListeners) {
|
|
247
|
+
listener(state);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|