agent-dir 0.4.0 → 0.4.1
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/src/server.js +78 -11
- package/dist/src/telemetry/privacy.js +1 -0
- package/dist/src/telemetry/types.d.ts +2 -1
- package/dist/src/tunnel.js +28 -6
- package/dist/src/types.d.ts +3 -0
- package/package.json +1 -1
package/dist/src/server.js
CHANGED
|
@@ -5,6 +5,11 @@ import { createMcpHandler } from "./mcp.js";
|
|
|
5
5
|
import { TelemetryRecorder } from "./telemetry/recorder.js";
|
|
6
6
|
import { deleteFile, listFiles, readFile, writeFile } from "./tools/files.js";
|
|
7
7
|
const MAX_BODY_BYTES = 10 * 1024 * 1024;
|
|
8
|
+
const REQUEST_BODY_TIMEOUT_MS = 30_000;
|
|
9
|
+
const REQUEST_TIMEOUT_MS = 120_000;
|
|
10
|
+
const HEADERS_TIMEOUT_MS = 30_000;
|
|
11
|
+
const KEEP_ALIVE_TIMEOUT_MS = 5_000;
|
|
12
|
+
const HEALTH_PATH = "/__health";
|
|
8
13
|
export function startServer({ root, port, token, commandConfig = {}, telemetry = { level: "none" }, }) {
|
|
9
14
|
const mcp = createMcpHandler(root, commandConfig, telemetry);
|
|
10
15
|
const httpTelemetry = new TelemetryRecorder({
|
|
@@ -15,13 +20,20 @@ export function startServer({ root, port, token, commandConfig = {}, telemetry =
|
|
|
15
20
|
});
|
|
16
21
|
const server = http.createServer(async (req, res) => {
|
|
17
22
|
const startedAt = performance.now();
|
|
23
|
+
const requestController = new AbortController();
|
|
24
|
+
req.once("aborted", () => requestController.abort());
|
|
25
|
+
res.once("close", () => {
|
|
26
|
+
if (!res.writableFinished)
|
|
27
|
+
requestController.abort();
|
|
28
|
+
});
|
|
18
29
|
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
|
|
19
30
|
let authenticated = false;
|
|
20
31
|
try {
|
|
21
|
-
const body = await readBody(req);
|
|
32
|
+
const body = await readBody(req, requestController.signal);
|
|
22
33
|
const requestInit = {
|
|
23
34
|
method: req.method ?? "GET",
|
|
24
35
|
headers: toFetchHeaders(req.headers),
|
|
36
|
+
signal: requestController.signal,
|
|
25
37
|
};
|
|
26
38
|
if (body.length)
|
|
27
39
|
requestInit.body = body;
|
|
@@ -39,6 +51,10 @@ export function startServer({ root, port, token, commandConfig = {}, telemetry =
|
|
|
39
51
|
recordHttpTelemetry(httpTelemetry, req.method ?? "UNKNOWN", url.pathname, 401, authenticated, startedAt);
|
|
40
52
|
return;
|
|
41
53
|
}
|
|
54
|
+
if (url.pathname === HEALTH_PATH && req.method === "HEAD") {
|
|
55
|
+
sendText(res, 200, "ok\n");
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
42
58
|
if (url.pathname === "/mcp") {
|
|
43
59
|
if (req.method !== "POST") {
|
|
44
60
|
res.setHeader("allow", "POST");
|
|
@@ -117,7 +133,12 @@ export function startServer({ root, port, token, commandConfig = {}, telemetry =
|
|
|
117
133
|
if (req.method === "DELETE") {
|
|
118
134
|
await deleteFile(root, relative);
|
|
119
135
|
sendText(res, 200, "File deleted successfully\n");
|
|
120
|
-
logRequest({
|
|
136
|
+
logRequest({
|
|
137
|
+
method: req.method,
|
|
138
|
+
path: url.pathname,
|
|
139
|
+
status: 200,
|
|
140
|
+
detail: "REST DELETE",
|
|
141
|
+
});
|
|
121
142
|
recordHttpTelemetry(httpTelemetry, req.method, url.pathname, 200, authenticated, startedAt);
|
|
122
143
|
return;
|
|
123
144
|
}
|
|
@@ -131,8 +152,16 @@ export function startServer({ root, port, token, commandConfig = {}, telemetry =
|
|
|
131
152
|
recordHttpTelemetry(httpTelemetry, req.method ?? "UNKNOWN", url.pathname, 404, authenticated, startedAt);
|
|
132
153
|
}
|
|
133
154
|
catch (error) {
|
|
155
|
+
if (requestController.signal.aborted || res.destroyed)
|
|
156
|
+
return;
|
|
134
157
|
const code = error.code;
|
|
135
|
-
const status = code === "ENOENT"
|
|
158
|
+
const status = code === "ENOENT"
|
|
159
|
+
? 404
|
|
160
|
+
: code === "PAYLOAD_TOO_LARGE"
|
|
161
|
+
? 413
|
|
162
|
+
: code === "REQUEST_TIMEOUT"
|
|
163
|
+
? 408
|
|
164
|
+
: 500;
|
|
136
165
|
sendText(res, status, `${error instanceof Error ? error.message : String(error)}\n`);
|
|
137
166
|
logRequest({
|
|
138
167
|
method: req.method ?? "UNKNOWN",
|
|
@@ -143,6 +172,9 @@ export function startServer({ root, port, token, commandConfig = {}, telemetry =
|
|
|
143
172
|
recordHttpTelemetry(httpTelemetry, req.method ?? "UNKNOWN", url.pathname, status, authenticated, startedAt);
|
|
144
173
|
}
|
|
145
174
|
});
|
|
175
|
+
server.requestTimeout = REQUEST_TIMEOUT_MS;
|
|
176
|
+
server.headersTimeout = HEADERS_TIMEOUT_MS;
|
|
177
|
+
server.keepAliveTimeout = KEEP_ALIVE_TIMEOUT_MS;
|
|
146
178
|
return new Promise((resolve, reject) => {
|
|
147
179
|
server.once("error", reject);
|
|
148
180
|
server.listen(port, "127.0.0.1", () => resolve({
|
|
@@ -531,12 +563,39 @@ function toFetchHeaders(headers) {
|
|
|
531
563
|
}
|
|
532
564
|
return result;
|
|
533
565
|
}
|
|
534
|
-
function readBody(req) {
|
|
566
|
+
function readBody(req, signal) {
|
|
535
567
|
return new Promise((resolve, reject) => {
|
|
536
568
|
const chunks = [];
|
|
537
569
|
let size = 0;
|
|
538
570
|
let tooLarge = false;
|
|
539
|
-
|
|
571
|
+
let settled = false;
|
|
572
|
+
const timer = setTimeout(() => {
|
|
573
|
+
const error = new Error(`Request body timed out after ${REQUEST_BODY_TIMEOUT_MS / 1000}s.`);
|
|
574
|
+
error.code = "REQUEST_TIMEOUT";
|
|
575
|
+
finish(error);
|
|
576
|
+
}, REQUEST_BODY_TIMEOUT_MS);
|
|
577
|
+
const cleanup = () => {
|
|
578
|
+
clearTimeout(timer);
|
|
579
|
+
req.removeListener("aborted", onAborted);
|
|
580
|
+
req.removeListener("error", onError);
|
|
581
|
+
req.removeListener("data", onData);
|
|
582
|
+
req.removeListener("end", onEnd);
|
|
583
|
+
signal.removeEventListener("abort", onSignalAbort);
|
|
584
|
+
};
|
|
585
|
+
const finish = (error, value) => {
|
|
586
|
+
if (settled)
|
|
587
|
+
return;
|
|
588
|
+
settled = true;
|
|
589
|
+
cleanup();
|
|
590
|
+
if (error)
|
|
591
|
+
reject(error);
|
|
592
|
+
else
|
|
593
|
+
resolve(value ?? Buffer.alloc(0));
|
|
594
|
+
};
|
|
595
|
+
const onAborted = () => finish(new Error("Request aborted by the client."));
|
|
596
|
+
const onError = (error) => finish(error);
|
|
597
|
+
const onSignalAbort = () => finish(new Error("Request aborted by the client."));
|
|
598
|
+
const onData = (chunk) => {
|
|
540
599
|
if (tooLarge)
|
|
541
600
|
return;
|
|
542
601
|
const buffer = Buffer.from(chunk);
|
|
@@ -546,17 +605,25 @@ function readBody(req) {
|
|
|
546
605
|
return;
|
|
547
606
|
}
|
|
548
607
|
chunks.push(buffer);
|
|
549
|
-
}
|
|
550
|
-
|
|
608
|
+
};
|
|
609
|
+
const onEnd = () => {
|
|
551
610
|
if (tooLarge) {
|
|
552
611
|
const error = new Error(`Request body exceeds the ${MAX_BODY_BYTES / 1024 / 1024} MiB limit.`);
|
|
553
612
|
error.code = "PAYLOAD_TOO_LARGE";
|
|
554
|
-
|
|
613
|
+
finish(error);
|
|
555
614
|
return;
|
|
556
615
|
}
|
|
557
|
-
|
|
558
|
-
}
|
|
559
|
-
|
|
616
|
+
finish(undefined, Buffer.concat(chunks));
|
|
617
|
+
};
|
|
618
|
+
if (signal.aborted) {
|
|
619
|
+
onSignalAbort();
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
req.once("aborted", onAborted);
|
|
623
|
+
req.once("error", onError);
|
|
624
|
+
signal.addEventListener("abort", onSignalAbort, { once: true });
|
|
625
|
+
req.on("data", onData);
|
|
626
|
+
req.on("end", onEnd);
|
|
560
627
|
});
|
|
561
628
|
}
|
|
562
629
|
function sendText(res, status, text, contentType = "text/plain; charset=utf-8") {
|
|
@@ -57,6 +57,7 @@ export function sanitizeAnonymousEvent(event) {
|
|
|
57
57
|
return {
|
|
58
58
|
event: "tunnel",
|
|
59
59
|
state: event.state,
|
|
60
|
+
...(event.healthState === undefined ? {} : { healthState: event.healthState }),
|
|
60
61
|
...(event.attempt === undefined ? {} : { attempt: boundedInteger(event.attempt) }),
|
|
61
62
|
...(event.attempts === undefined ? {} : { attempts: boundedInteger(event.attempts) }),
|
|
62
63
|
...(event.durationMs === undefined ? {} : { durationMs: boundedNumber(event.durationMs) }),
|
|
@@ -46,7 +46,8 @@ export interface TelemetryCommandEvent {
|
|
|
46
46
|
}
|
|
47
47
|
export interface TelemetryTunnelEvent {
|
|
48
48
|
event: "tunnel";
|
|
49
|
-
state: "online" | "disconnected" | "reconnecting" | "reconnected" | "failed";
|
|
49
|
+
state: "online" | "disconnected" | "reconnecting" | "reconnected" | "failed" | "health";
|
|
50
|
+
healthState?: "healthy" | "degraded" | "recovered";
|
|
50
51
|
attempt?: number;
|
|
51
52
|
attempts?: number;
|
|
52
53
|
durationMs?: number;
|
package/dist/src/tunnel.js
CHANGED
|
@@ -6,6 +6,7 @@ const HEALTH_TIMEOUT_MS = 5_000;
|
|
|
6
6
|
const HEALTH_FAILURE_THRESHOLD = 2;
|
|
7
7
|
const RECONNECT_DELAY_MS = 1_000;
|
|
8
8
|
const MAX_RECONNECT_ATTEMPTS = 5;
|
|
9
|
+
const HEALTH_PATH = "/__health";
|
|
9
10
|
const ANSI_ESCAPE = new RegExp(String.fromCharCode(0x1b) + String.raw `\[[0-?]*[ -/]*[@-~]`, "g");
|
|
10
11
|
function cleanOutput(value) {
|
|
11
12
|
return value.toString().replace(ANSI_ESCAPE, "");
|
|
@@ -55,6 +56,7 @@ export function startTunnel(options) {
|
|
|
55
56
|
let healthTimer;
|
|
56
57
|
let stopped = false;
|
|
57
58
|
let healthFailures = 0;
|
|
59
|
+
let healthState = "healthy";
|
|
58
60
|
const record = (event) => {
|
|
59
61
|
if (!telemetry)
|
|
60
62
|
return;
|
|
@@ -75,6 +77,8 @@ export function startTunnel(options) {
|
|
|
75
77
|
});
|
|
76
78
|
else if (event.type === "reconnected")
|
|
77
79
|
telemetry.record({ event: "tunnel", state: "reconnected", attempt: event.attempt });
|
|
80
|
+
else if (event.type === "health")
|
|
81
|
+
telemetry.record({ event: "tunnel", state: "health", healthState: event.state });
|
|
78
82
|
else
|
|
79
83
|
telemetry.record({
|
|
80
84
|
event: "tunnel",
|
|
@@ -106,9 +110,16 @@ export function startTunnel(options) {
|
|
|
106
110
|
writeLine(` MCP endpoint : ${event.url}/mcp`);
|
|
107
111
|
writeLine();
|
|
108
112
|
}
|
|
113
|
+
else if (event.type === "health") {
|
|
114
|
+
if (event.state === "degraded")
|
|
115
|
+
writeLine(" ⚠ TUNNEL HEALTH DEGRADED");
|
|
116
|
+
else if (event.state === "recovered")
|
|
117
|
+
writeLine(" ✓ TUNNEL HEALTH RECOVERED");
|
|
118
|
+
}
|
|
109
119
|
else {
|
|
110
|
-
writeLine(`
|
|
111
|
-
writeLine(`
|
|
120
|
+
writeLine(` ⚠ TUNNEL RECOVERY CYCLE EXHAUSTED ${event.reason}`);
|
|
121
|
+
writeLine(` Attempts completed : ${event.attempts}`);
|
|
122
|
+
writeLine(" Starting a fresh reconnect cycle.");
|
|
112
123
|
}
|
|
113
124
|
};
|
|
114
125
|
const cleanupTimers = () => {
|
|
@@ -135,17 +146,25 @@ export function startTunnel(options) {
|
|
|
135
146
|
if (!url || stopped || reconnecting)
|
|
136
147
|
return;
|
|
137
148
|
try {
|
|
138
|
-
const response = await fetch(url, {
|
|
149
|
+
const response = await fetch(new URL(HEALTH_PATH, url), {
|
|
139
150
|
method: "HEAD",
|
|
140
151
|
...(options.token ? { headers: { authorization: `Bearer ${options.token}` } } : {}),
|
|
141
152
|
signal: AbortSignal.timeout(HEALTH_TIMEOUT_MS),
|
|
142
153
|
});
|
|
154
|
+
if (!response.ok)
|
|
155
|
+
throw new Error(`Health check returned HTTP ${response.status}.`);
|
|
143
156
|
healthFailures = 0;
|
|
144
|
-
if (
|
|
145
|
-
|
|
157
|
+
if (healthState === "degraded") {
|
|
158
|
+
healthState = "healthy";
|
|
159
|
+
emit({ type: "health", state: "recovered" });
|
|
160
|
+
}
|
|
146
161
|
}
|
|
147
162
|
catch {
|
|
148
163
|
healthFailures += 1;
|
|
164
|
+
if (healthFailures === 1 && healthState === "healthy") {
|
|
165
|
+
healthState = "degraded";
|
|
166
|
+
emit({ type: "health", state: "degraded" });
|
|
167
|
+
}
|
|
149
168
|
if (healthFailures >= HEALTH_FAILURE_THRESHOLD) {
|
|
150
169
|
healthFailures = 0;
|
|
151
170
|
handleDisconnect("Public tunnel health check failed.");
|
|
@@ -157,6 +176,7 @@ export function startTunnel(options) {
|
|
|
157
176
|
const spawnTunnel = (attempt) => {
|
|
158
177
|
if (stopped)
|
|
159
178
|
return;
|
|
179
|
+
reconnecting = false;
|
|
160
180
|
const args = ["http", String(options.port)];
|
|
161
181
|
if (!options.random && options.subdomain)
|
|
162
182
|
args.push("--subdomain", options.subdomain);
|
|
@@ -257,10 +277,12 @@ export function startTunnel(options) {
|
|
|
257
277
|
if (stopped || reconnecting)
|
|
258
278
|
return;
|
|
259
279
|
reconnecting = true;
|
|
280
|
+
if (child && !child.killed)
|
|
281
|
+
child.kill("SIGTERM");
|
|
260
282
|
emit({ type: "disconnected", reason });
|
|
261
283
|
if (attempts >= MAX_RECONNECT_ATTEMPTS) {
|
|
262
284
|
emit({ type: "failed", reason, attempts });
|
|
263
|
-
|
|
285
|
+
attempts = 0;
|
|
264
286
|
}
|
|
265
287
|
attempts += 1;
|
|
266
288
|
emit({ type: "reconnecting", attempt: attempts, reason });
|
package/dist/src/types.d.ts
CHANGED
package/package.json
CHANGED