-
Notifications
You must be signed in to change notification settings - Fork 14
Expand file tree
/
Copy pathrelay-client.ts
More file actions
263 lines (240 loc) · 8.58 KB
/
Copy pathrelay-client.ts
File metadata and controls
263 lines (240 loc) · 8.58 KB
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
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
import { spawn } from "node:child_process";
import { openSync } from "node:fs";
import { readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import type { AppConfig } from "./config.js";
import { RELAY_VERSION } from "./relay-server.js";
/**
* Out-of-process client for the shared relay daemon.
*
* Every Claude session spawns its own MCP server, but only one process can bind
* the relay port (8787). Instead of binding in-process, each MCP server connects
* to a single standalone daemon (`relay-daemon.ts`) that owns the port and
* outlives all sessions. This client speaks the daemon-facing surface
* (`POST /relay`, `GET /health`) and, crucially, is self-healing:
*
* - `isConnected()` is a *cached* read of `/health`, refreshed on a background
* interval. It can be stale for a couple of seconds after the daemon dies.
* - `request()` therefore treats a connection-refused as "daemon gone" and
* respawns + retries once, so a crash is invisible rather than a thrown error.
* - On a version mismatch (a stale daemon from an older build still squatting
* the port) it evicts the daemon *we own* and respawns the current build.
*/
const HEALTH_TIMEOUT_MS = 1_500;
const HEALTH_REFRESH_MS = 2_000;
const SPAWN_WAIT_MS = 5_000;
const REQUEST_SLACK_MS = 15_000; // client waits this much past the relay's own timeout
interface HealthInfo {
connected: boolean;
version: number | null;
pid: number | null;
}
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
function isConnRefused(err: unknown): boolean {
const cause = (err as { cause?: { code?: string } } | undefined)?.cause;
if (cause?.code === "ECONNREFUSED") return true;
const msg = err instanceof Error ? err.message : String(err);
return /ECONNREFUSED|fetch failed|other side closed/i.test(msg);
}
async function probeHealth(port: number, timeoutMs = HEALTH_TIMEOUT_MS): Promise<HealthInfo | null> {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
try {
const res = await fetch(`http://127.0.0.1:${port}/health`, { signal: ctrl.signal });
if (!res.ok) return null;
const body = (await res.json()) as { connected?: boolean; version?: number; pid?: number };
return {
connected: body.connected === true,
version: typeof body.version === "number" ? body.version : null,
pid: typeof body.pid === "number" ? body.pid : null,
};
} catch {
return null;
} finally {
clearTimeout(timer);
}
}
/**
* Full `/health` payload for diagnostics (oe_health). Unlike `probeHealth`,
* returns every field the daemon reports (uptime, served, errored, …), or null
* when the daemon is unreachable.
*/
export async function fetchRelayHealth(
port: number,
timeoutMs = HEALTH_TIMEOUT_MS,
): Promise<Record<string, unknown> | null> {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
try {
const res = await fetch(`http://127.0.0.1:${port}/health`, { signal: ctrl.signal });
if (!res.ok) return null;
return (await res.json()) as Record<string, unknown>;
} catch {
return null;
} finally {
clearTimeout(timer);
}
}
function daemonEntrypoint(): string {
// Sits next to this module in dist/. Production runs the compiled .js via node.
return path.join(path.dirname(fileURLToPath(import.meta.url)), "relay-daemon.js");
}
function spawnDaemon(config: AppConfig): void {
const out = openSync(config.relayLogPath, "a");
try {
const child = spawn(process.execPath, [daemonEntrypoint()], {
detached: true,
stdio: ["ignore", out, out],
env: process.env,
});
child.unref();
} catch (err) {
process.stderr.write(`[relay] could not spawn daemon: ${String(err)}\n`);
}
}
async function waitForHealth(port: number, timeoutMs = SPAWN_WAIT_MS): Promise<boolean> {
const deadline = Date.now() + timeoutMs;
for (;;) {
const h = await probeHealth(port, 500);
if (h && h.version === RELAY_VERSION) return true;
if (Date.now() > deadline) return false;
await sleep(150);
}
}
/**
* Evict a daemon that answers `/health` with the wrong version — but only if it
* is unmistakably one we own (its pid matches our pidfile). Never blind-kill an
* unknown process holding the port.
*/
async function replaceStaleDaemon(config: AppConfig, health: HealthInfo): Promise<boolean> {
const pid = health.pid;
if (pid == null) {
process.stderr.write(
`[relay] port ${config.relayPort} held by a relay without a version/pid — ` +
`not ours, refusing to evict. Stop it manually if it is stale.\n`,
);
return false;
}
let filePid: number | null = null;
try {
filePid = parseInt((await readFile(config.relayPidPath, "utf8")).trim(), 10);
} catch {
filePid = null;
}
if (filePid !== pid) {
process.stderr.write(
`[relay] port ${config.relayPort} held by pid ${pid} but pidfile has ` +
`${filePid ?? "none"} — not evicting an unrecognised process.\n`,
);
return false;
}
process.stderr.write(`[relay] evicting stale daemon pid ${pid} (version mismatch)\n`);
try {
process.kill(pid, "SIGTERM");
} catch {
/* already gone */
}
for (let i = 0; i < 20; i += 1) {
if (!(await probeHealth(config.relayPort, 400))) break;
await sleep(150);
}
spawnDaemon(config);
return true;
}
export class RelayClient {
private healthy = false;
private timer: ReturnType<typeof setInterval> | null = null;
constructor(
private readonly port: number,
private readonly ensureDaemon: () => Promise<boolean>,
) {}
/** Prime the cached health flag and start the background refresh loop. */
async start(): Promise<void> {
await this.refresh();
if (!this.timer) {
this.timer = setInterval(() => void this.refresh(), HEALTH_REFRESH_MS);
this.timer.unref?.();
}
}
private async refresh(): Promise<void> {
const h = await probeHealth(this.port);
this.healthy = h !== null && h.connected && h.version === RELAY_VERSION;
}
isConnected(): boolean {
return this.healthy;
}
async request(
req: { method: string; path: string; body?: string },
opts?: { timeoutMs?: number },
): Promise<{ status: number; body: string }> {
try {
return await this.postRelay(req, opts?.timeoutMs);
} catch (err) {
if (!isConnRefused(err)) throw err;
// Cached health was stale: the daemon died between probes. Respawn + retry once.
this.healthy = false;
const up = await this.ensureDaemon();
if (!up) throw err;
const out = await this.postRelay(req, opts?.timeoutMs);
void this.refresh();
return out;
}
}
private async postRelay(
req: { method: string; path: string; body?: string },
timeoutMs?: number,
): Promise<{ status: number; body: string }> {
const ctrl = new AbortController();
const limit = (timeoutMs ?? 90_000) + REQUEST_SLACK_MS;
const timer = setTimeout(() => ctrl.abort(), limit);
try {
const res = await fetch(`http://127.0.0.1:${this.port}/relay`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ ...req, timeoutMs }),
signal: ctrl.signal,
});
const data = (await res.json()) as {
ok?: boolean;
status?: number;
body?: string;
error?: string;
};
if (!res.ok || data.ok === false) {
throw new Error(data.error ?? `relay daemon returned HTTP ${res.status}`);
}
return { status: data.status ?? 0, body: data.body ?? "" };
} finally {
clearTimeout(timer);
}
}
close(): void {
if (this.timer) clearInterval(this.timer);
this.timer = null;
}
}
/**
* Connect to the shared relay daemon, spawning it if absent and replacing a
* stale-version daemon we own. Always returns a started client (so `isConnected()`
* reflects live state and a later call can self-heal); returns null only when the
* relay is disabled.
*/
export async function connectSharedRelay(config: AppConfig): Promise<RelayClient | null> {
if (!config.relayEnabled) return null;
const port = config.relayPort;
const ensureDaemon = async (): Promise<boolean> => {
const h = await probeHealth(port);
if (h) {
if (h.version === RELAY_VERSION) return true;
const replaced = await replaceStaleDaemon(config, h);
return replaced ? await waitForHealth(port) : false;
}
spawnDaemon(config);
return await waitForHealth(port);
};
await ensureDaemon();
const client = new RelayClient(port, ensureDaemon);
await client.start();
return client;
}