@parall/sdk 1.12.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 +329 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +718 -0
- package/dist/constants.d.ts +161 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/constants.js +194 -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 +1130 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +4 -0
- package/dist/ws.d.ts +83 -0
- package/dist/ws.d.ts.map +1 -0
- package/dist/ws.js +354 -0
- package/package.json +31 -0
- package/src/client.ts +1069 -0
- package/src/constants.ts +265 -0
- package/src/index.ts +6 -0
- package/src/types.ts +1399 -0
- package/src/ws.ts +404 -0
package/src/ws.ts
ADDED
|
@@ -0,0 +1,404 @@
|
|
|
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 WsClientEventMap = {
|
|
8
|
+
'ping': { ts: number };
|
|
9
|
+
'typing': { chat_id: string; thread_root_id: string | null; action: 'start' | 'stop' };
|
|
10
|
+
'watch': { chat_ids: string[] };
|
|
11
|
+
'agent.heartbeat': { session_id: string; telemetry?: Record<string, unknown> };
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export type WsConnectionState = 'connecting' | 'connected' | 'disconnected' | 'reconnecting';
|
|
15
|
+
|
|
16
|
+
export interface ParallWsOptions {
|
|
17
|
+
/** Async function that fetches a one-time WS ticket from the API. */
|
|
18
|
+
getTicket: () => Promise<WsTicketResponse>;
|
|
19
|
+
/** Fallback WS URL if getTicket response doesn't include ws_url. */
|
|
20
|
+
wsUrl?: string;
|
|
21
|
+
lastSeq?: number;
|
|
22
|
+
reconnect?: boolean;
|
|
23
|
+
reconnectInterval?: number;
|
|
24
|
+
maxReconnectInterval?: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export class ParallWs {
|
|
28
|
+
private ws: WebSocket | null = null;
|
|
29
|
+
private options: ParallWsOptions & { reconnect: boolean; reconnectInterval: number; maxReconnectInterval: number };
|
|
30
|
+
private listeners = new Map<string, Set<WsEventHandler<WsEventType>>>();
|
|
31
|
+
private stateListeners = new Set<(state: WsConnectionState) => void>();
|
|
32
|
+
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
|
33
|
+
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
34
|
+
private reconnectAttempts = 0;
|
|
35
|
+
private lastSeq = 0;
|
|
36
|
+
private _state: WsConnectionState = 'disconnected';
|
|
37
|
+
private intentionalClose = false;
|
|
38
|
+
private lastReceivedAt = 0;
|
|
39
|
+
private heartbeatIntervalMs = 0;
|
|
40
|
+
private probeTimer: ReturnType<typeof setTimeout> | null = null;
|
|
41
|
+
private browserListenersActive = false;
|
|
42
|
+
|
|
43
|
+
constructor(options: ParallWsOptions) {
|
|
44
|
+
this.options = {
|
|
45
|
+
reconnect: true,
|
|
46
|
+
reconnectInterval: 1000,
|
|
47
|
+
maxReconnectInterval: 30000,
|
|
48
|
+
...options,
|
|
49
|
+
};
|
|
50
|
+
this.lastSeq = options.lastSeq ?? 0;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
get state(): WsConnectionState {
|
|
54
|
+
return this._state;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async connect() {
|
|
58
|
+
this.intentionalClose = false;
|
|
59
|
+
this.setupBrowserListeners();
|
|
60
|
+
this.setState('connecting');
|
|
61
|
+
|
|
62
|
+
let ticket: WsTicketResponse;
|
|
63
|
+
try {
|
|
64
|
+
ticket = await this.options.getTicket();
|
|
65
|
+
} catch (err) {
|
|
66
|
+
console.error('Failed to get WS ticket:', err);
|
|
67
|
+
if (this.options.reconnect) {
|
|
68
|
+
this.scheduleReconnect();
|
|
69
|
+
} else {
|
|
70
|
+
this.setState('disconnected');
|
|
71
|
+
}
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const wsUrl = ticket.ws_url || this.options.wsUrl;
|
|
76
|
+
if (!wsUrl) {
|
|
77
|
+
console.error('No WS URL available');
|
|
78
|
+
this.setState('disconnected');
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// wsUrl may already contain query params (e.g. ?swimlane=pr-42 for
|
|
83
|
+
// swimlane routing). Use URL API to merge params correctly.
|
|
84
|
+
const url = new URL(wsUrl);
|
|
85
|
+
url.searchParams.set('ticket', ticket.ticket);
|
|
86
|
+
if (this.lastSeq > 0) {
|
|
87
|
+
url.searchParams.set('last_seq', String(this.lastSeq));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
this.ws = new WebSocket(url.toString());
|
|
91
|
+
|
|
92
|
+
// Force-reconnect if not connected within 15s.
|
|
93
|
+
// Handles edge case where Node 22 WebSocket transitions to CLOSED without
|
|
94
|
+
// firing onclose (e.g. TCP reset during Cloudflare rolling update).
|
|
95
|
+
const ws = this.ws;
|
|
96
|
+
const connectTimeout = setTimeout(() => {
|
|
97
|
+
if (this._state === 'connected' || this.intentionalClose) return;
|
|
98
|
+
ws.onclose = null;
|
|
99
|
+
ws.onopen = null;
|
|
100
|
+
ws.onerror = null;
|
|
101
|
+
try { ws.close(); } catch { /* ignore */ }
|
|
102
|
+
// Only reconnect if this socket is still the active one.
|
|
103
|
+
// If connect() was called again, a newer socket owns the lifecycle.
|
|
104
|
+
if (ws !== this.ws) return;
|
|
105
|
+
if (this.options.reconnect) {
|
|
106
|
+
this.scheduleReconnect();
|
|
107
|
+
} else {
|
|
108
|
+
this.setState('disconnected');
|
|
109
|
+
}
|
|
110
|
+
}, 15_000);
|
|
111
|
+
|
|
112
|
+
this.ws.onopen = () => {
|
|
113
|
+
clearTimeout(connectTimeout);
|
|
114
|
+
this.reconnectAttempts = 0;
|
|
115
|
+
this.lastReceivedAt = Date.now();
|
|
116
|
+
this.setState('connected');
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
this.ws.onmessage = (event) => {
|
|
120
|
+
try {
|
|
121
|
+
const frame: WsFrame = JSON.parse(event.data as string);
|
|
122
|
+
this.handleFrame(frame);
|
|
123
|
+
} catch {
|
|
124
|
+
// ignore malformed frames
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
this.ws.onclose = () => {
|
|
129
|
+
clearTimeout(connectTimeout);
|
|
130
|
+
this.stopHeartbeat();
|
|
131
|
+
this.clearProbe();
|
|
132
|
+
if (this.intentionalClose) {
|
|
133
|
+
this.setState('disconnected');
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
if (this.options.reconnect) {
|
|
137
|
+
this.scheduleReconnect();
|
|
138
|
+
} else {
|
|
139
|
+
this.setState('disconnected');
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
this.ws.onerror = () => {
|
|
144
|
+
// onclose will fire after this
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
disconnect() {
|
|
149
|
+
this.intentionalClose = true;
|
|
150
|
+
this.stopHeartbeat();
|
|
151
|
+
this.clearReconnect();
|
|
152
|
+
this.clearProbe();
|
|
153
|
+
this.teardownBrowserListeners();
|
|
154
|
+
if (this.ws) {
|
|
155
|
+
this.ws.close();
|
|
156
|
+
this.ws = null;
|
|
157
|
+
}
|
|
158
|
+
this.setState('disconnected');
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ---- Client -> Server messages ----
|
|
162
|
+
|
|
163
|
+
/** Tell the server which chats the user is currently viewing (no auth implications). */
|
|
164
|
+
watch(chatIds: string[]) {
|
|
165
|
+
this.send({ type: WS_EVENTS.WATCH, data: { chat_ids: chatIds } });
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
sendTyping(chatId: string, action: 'start' | 'stop', threadRootId?: string) {
|
|
169
|
+
this.send({
|
|
170
|
+
type: WS_EVENTS.TYPING,
|
|
171
|
+
data: { chat_id: chatId, thread_root_id: threadRootId ?? null, action },
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Send an agent heartbeat with telemetry data. */
|
|
176
|
+
sendAgentHeartbeat(sessionId: string, telemetry?: Record<string, unknown>) {
|
|
177
|
+
this.send({
|
|
178
|
+
type: WS_EVENTS.AGENT_HEARTBEAT,
|
|
179
|
+
data: { session_id: sessionId, telemetry },
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
sendEvent(type: string, data: unknown) {
|
|
184
|
+
this.send({ type, data });
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ---- Event listeners ----
|
|
188
|
+
|
|
189
|
+
on<T extends WsEventType>(event: T, handler: WsEventHandler<T>) {
|
|
190
|
+
let set = this.listeners.get(event);
|
|
191
|
+
if (!set) {
|
|
192
|
+
set = new Set();
|
|
193
|
+
this.listeners.set(event, set);
|
|
194
|
+
}
|
|
195
|
+
set.add(handler as WsEventHandler<WsEventType>);
|
|
196
|
+
return () => {
|
|
197
|
+
set!.delete(handler as WsEventHandler<WsEventType>);
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
off<T extends WsEventType>(event: T, handler: WsEventHandler<T>) {
|
|
202
|
+
this.listeners.get(event)?.delete(handler as WsEventHandler<WsEventType>);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
onStateChange(handler: (state: WsConnectionState) => void) {
|
|
206
|
+
this.stateListeners.add(handler);
|
|
207
|
+
return () => {
|
|
208
|
+
this.stateListeners.delete(handler);
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ---- Internal ----
|
|
213
|
+
|
|
214
|
+
private send(frame: { type: string; data: unknown }) {
|
|
215
|
+
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
216
|
+
this.ws.send(JSON.stringify(frame));
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
private handleFrame(frame: WsFrame) {
|
|
221
|
+
this.lastReceivedAt = Date.now();
|
|
222
|
+
this.clearProbe();
|
|
223
|
+
|
|
224
|
+
// Track seq for reconnection
|
|
225
|
+
if (frame.seq !== undefined) {
|
|
226
|
+
this.lastSeq = frame.seq;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Handle hello — start heartbeat
|
|
230
|
+
if (frame.type === WS_EVENTS.HELLO) {
|
|
231
|
+
const interval = (frame.data as { heartbeat_interval: number }).heartbeat_interval;
|
|
232
|
+
this.startHeartbeat(interval);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Dispatch to listeners
|
|
236
|
+
const handlers = this.listeners.get(frame.type);
|
|
237
|
+
if (handlers) {
|
|
238
|
+
for (const handler of handlers) {
|
|
239
|
+
handler(frame.data as WsEventMap[WsEventType], frame.seq);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
private startHeartbeat(intervalSec: number) {
|
|
245
|
+
this.stopHeartbeat();
|
|
246
|
+
if (!Number.isFinite(intervalSec) || intervalSec <= 0) {
|
|
247
|
+
console.error('Invalid heartbeat interval from server:', intervalSec);
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
this.heartbeatIntervalMs = intervalSec * 1000;
|
|
251
|
+
this.heartbeatTimer = setInterval(() => {
|
|
252
|
+
// If no data received for >1.5× heartbeat interval, the connection may
|
|
253
|
+
// be stale — or the timer may have been delayed by browser throttling /
|
|
254
|
+
// event-loop stalls. Probe with a 5s timeout rather than force-
|
|
255
|
+
// reconnecting, so healthy connections that were merely timer-starved
|
|
256
|
+
// survive while genuinely dead sockets are caught quickly.
|
|
257
|
+
if (this.lastReceivedAt > 0 && Date.now() - this.lastReceivedAt > this.heartbeatIntervalMs * 1.5) {
|
|
258
|
+
this.probeConnection();
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
this.send({ type: WS_EVENTS.PING, data: { ts: Date.now() } });
|
|
262
|
+
}, intervalSec * 1000);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
private stopHeartbeat() {
|
|
266
|
+
if (this.heartbeatTimer) {
|
|
267
|
+
clearInterval(this.heartbeatTimer);
|
|
268
|
+
this.heartbeatTimer = null;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
private scheduleReconnect() {
|
|
273
|
+
this.setState('reconnecting');
|
|
274
|
+
this.clearReconnect();
|
|
275
|
+
|
|
276
|
+
const base = Math.min(
|
|
277
|
+
this.options.reconnectInterval * Math.pow(2, this.reconnectAttempts),
|
|
278
|
+
this.options.maxReconnectInterval,
|
|
279
|
+
);
|
|
280
|
+
// Jitter: 50-100% of base delay to prevent thundering herd
|
|
281
|
+
const delay = base * (0.5 + Math.random() * 0.5);
|
|
282
|
+
this.reconnectAttempts++;
|
|
283
|
+
|
|
284
|
+
this.reconnectTimer = setTimeout(() => {
|
|
285
|
+
this.connect();
|
|
286
|
+
}, delay);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
private clearReconnect() {
|
|
290
|
+
if (this.reconnectTimer) {
|
|
291
|
+
clearTimeout(this.reconnectTimer);
|
|
292
|
+
this.reconnectTimer = null;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** Force-close a dead/stale connection and trigger reconnect. */
|
|
297
|
+
private forceReconnect() {
|
|
298
|
+
this.stopHeartbeat();
|
|
299
|
+
this.clearReconnect();
|
|
300
|
+
this.clearProbe();
|
|
301
|
+
if (this.ws) {
|
|
302
|
+
this.ws.onclose = null;
|
|
303
|
+
this.ws.onopen = null;
|
|
304
|
+
this.ws.onerror = null;
|
|
305
|
+
this.ws.onmessage = null;
|
|
306
|
+
try { this.ws.close(); } catch { /* ignore */ }
|
|
307
|
+
this.ws = null;
|
|
308
|
+
}
|
|
309
|
+
if (this.options.reconnect && !this.intentionalClose) {
|
|
310
|
+
this.scheduleReconnect();
|
|
311
|
+
} else {
|
|
312
|
+
this.setState('disconnected');
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// ---- Browser event listeners for proactive reconnection ----
|
|
317
|
+
|
|
318
|
+
private setupBrowserListeners() {
|
|
319
|
+
if (this.browserListenersActive) return;
|
|
320
|
+
this.browserListenersActive = true;
|
|
321
|
+
if (typeof document !== 'undefined') {
|
|
322
|
+
document.addEventListener('visibilitychange', this.handleVisibilityChange);
|
|
323
|
+
}
|
|
324
|
+
if (typeof window !== 'undefined') {
|
|
325
|
+
window.addEventListener('online', this.handleOnline);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
private teardownBrowserListeners() {
|
|
330
|
+
if (!this.browserListenersActive) return;
|
|
331
|
+
this.browserListenersActive = false;
|
|
332
|
+
if (typeof document !== 'undefined') {
|
|
333
|
+
document.removeEventListener('visibilitychange', this.handleVisibilityChange);
|
|
334
|
+
}
|
|
335
|
+
if (typeof window !== 'undefined') {
|
|
336
|
+
window.removeEventListener('online', this.handleOnline);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Send a ping and arm a short timeout. If no frame arrives within 5s the
|
|
342
|
+
* connection is assumed dead and force-reconnected. Any received frame
|
|
343
|
+
* (including the pong) cancels the timer via clearProbe() in handleFrame.
|
|
344
|
+
*/
|
|
345
|
+
private probeConnection() {
|
|
346
|
+
if (this.probeTimer) return; // don't re-arm an active probe
|
|
347
|
+
this.send({ type: WS_EVENTS.PING, data: { ts: Date.now() } });
|
|
348
|
+
this.probeTimer = setTimeout(() => {
|
|
349
|
+
this.forceReconnect();
|
|
350
|
+
}, 5_000);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
private clearProbe() {
|
|
354
|
+
if (this.probeTimer) {
|
|
355
|
+
clearTimeout(this.probeTimer);
|
|
356
|
+
this.probeTimer = null;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** Tab returned to foreground — verify connection or accelerate reconnect. */
|
|
361
|
+
private handleVisibilityChange = () => {
|
|
362
|
+
if (typeof document !== 'undefined' && document.hidden) return;
|
|
363
|
+
|
|
364
|
+
if (this._state === 'connected') {
|
|
365
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
366
|
+
this.forceReconnect();
|
|
367
|
+
} else {
|
|
368
|
+
// Probe liveness — do NOT reset lastReceivedAt here; a zombie socket
|
|
369
|
+
// may still report OPEN. The 5s probe timeout will catch it quickly
|
|
370
|
+
// instead of deferring to the next heartbeat cycle (~60s).
|
|
371
|
+
this.probeConnection();
|
|
372
|
+
}
|
|
373
|
+
} else if (this._state === 'reconnecting') {
|
|
374
|
+
// Skip remaining backoff — try immediately
|
|
375
|
+
this.clearReconnect();
|
|
376
|
+
this.reconnectAttempts = 0;
|
|
377
|
+
this.connect();
|
|
378
|
+
}
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
/** Network restored — accelerate reconnection. */
|
|
382
|
+
private handleOnline = () => {
|
|
383
|
+
if (this.intentionalClose) return;
|
|
384
|
+
|
|
385
|
+
if (this._state === 'connected') {
|
|
386
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
387
|
+
this.forceReconnect();
|
|
388
|
+
} else {
|
|
389
|
+
this.probeConnection();
|
|
390
|
+
}
|
|
391
|
+
} else if (this._state === 'reconnecting') {
|
|
392
|
+
this.clearReconnect();
|
|
393
|
+
this.reconnectAttempts = 0;
|
|
394
|
+
this.connect();
|
|
395
|
+
}
|
|
396
|
+
};
|
|
397
|
+
|
|
398
|
+
private setState(state: WsConnectionState) {
|
|
399
|
+
this._state = state;
|
|
400
|
+
for (const listener of this.stateListeners) {
|
|
401
|
+
listener(state);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
}
|