1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
|
import { config } from "./config.js";
import type { AgentEvent, ConnectionStatus } from "./types.js";
type EventCallback = (event: AgentEvent) => void;
// Close any stale WebSocket from HMR reloads
if (import.meta.hot) {
import.meta.hot.dispose((data: Record<string, unknown>) => {
const old = data._ws as WebSocket | undefined;
if (old && old.readyState === WebSocket.OPEN) {
old.close();
}
});
}
function createWebSocketClient(url: string) {
let connectionStatus: ConnectionStatus = $state("disconnected");
let ws: WebSocket | null = null;
let reconnectDelay = 1000;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
let manualDisconnect = false;
const callbacks = new Set<EventCallback>();
function connect() {
if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) {
return;
}
manualDisconnect = false;
connectionStatus = "connecting";
ws = new WebSocket(url);
// Store ref for HMR cleanup
if (import.meta.hot) {
import.meta.hot.data._ws = ws;
}
ws.onopen = () => {
connectionStatus = "connected";
reconnectDelay = 1000;
};
ws.onmessage = (event: MessageEvent) => {
let data: AgentEvent;
try {
data = JSON.parse(event.data as string) as AgentEvent;
} catch (err) {
// Genuinely malformed WS payload — these are rare and harmless.
console.warn("[ws] ignored malformed message:", err);
return;
}
// Run callbacks OUTSIDE the parse try so callback throws are visible.
// The previous catch-everything wrapper silently swallowed bugs in
// downstream handlers (notably the `structuredClone` of a Svelte 5
// $state proxy throwing DataCloneError), making them undiagnosable.
for (const cb of callbacks) {
try {
cb(data);
} catch (err) {
console.error("[ws] callback threw for event:", data, err);
}
}
};
ws.onclose = () => {
connectionStatus = "disconnected";
ws = null;
if (!manualDisconnect) {
scheduleReconnect();
}
};
ws.onerror = () => {
ws?.close();
};
}
function scheduleReconnect() {
if (reconnectTimer) clearTimeout(reconnectTimer);
reconnectTimer = setTimeout(() => {
reconnectTimer = null;
connect();
}, reconnectDelay);
reconnectDelay = Math.min(reconnectDelay * 2, 10000);
}
function disconnect() {
manualDisconnect = true;
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
ws?.close();
ws = null;
connectionStatus = "disconnected";
}
function onEvent(callback: EventCallback) {
callbacks.add(callback);
return () => {
callbacks.delete(callback);
};
}
/** Remove all registered event callbacks (used for HMR safety). */
function clearCallbacks() {
callbacks.clear();
}
function send(data: unknown): void {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(data));
}
}
return {
get connectionStatus() {
return connectionStatus;
},
connect,
disconnect,
onEvent,
clearCallbacks,
send,
};
}
export const wsClient = createWebSocketClient(config.wsUrl);
|