@ferrule-io/ok-fine 0.3.7 → 0.3.9
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/README.md +1 -1
- package/dist/cli-options.js +1 -1
- package/dist/cli.js +12 -14
- package/dist/data-dir-lock.js +58 -19
- package/dist/http/ui.js +47 -33
- package/dist/local-host.js +157 -0
- package/dist/mcp/server.js +2 -2
- package/dist/server.js +33 -27
- package/dist/stdio-proxy.js +349 -0
- package/dist/ui/assets/highlight-DkgVr4Tn.js +153 -0
- package/dist/ui/assets/{index-0b2NNRC5.js → index-C2IYVK_7.js} +33 -33
- package/dist/ui/index.html +1 -1
- package/dist/version.js +1 -1
- package/package.json +3 -1
- package/dist/ui/assets/highlight-CW19vWvw.js +0 -153
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
import net from "node:net";
|
|
2
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { isProcessAlive, tryLockDataDir } from "./data-dir-lock.js";
|
|
5
|
+
import { localEndpointPath } from "./local-host.js";
|
|
6
|
+
const JsonRpcMessageSchema = z
|
|
7
|
+
.object({
|
|
8
|
+
jsonrpc: z.string().optional(),
|
|
9
|
+
id: z.union([z.string(), z.number()]).optional(),
|
|
10
|
+
method: z.string().optional(),
|
|
11
|
+
params: z.unknown().optional(),
|
|
12
|
+
result: z.unknown().optional(),
|
|
13
|
+
error: z.unknown().optional(),
|
|
14
|
+
})
|
|
15
|
+
.passthrough();
|
|
16
|
+
function tryConnectSocket(endpoint) {
|
|
17
|
+
const { promise, resolve } = Promise.withResolvers();
|
|
18
|
+
const socket = net.createConnection(endpoint);
|
|
19
|
+
const onError = () => {
|
|
20
|
+
socket.destroy();
|
|
21
|
+
resolve(null);
|
|
22
|
+
};
|
|
23
|
+
socket.once("error", onError);
|
|
24
|
+
socket.once("connect", () => {
|
|
25
|
+
socket.off("error", onError);
|
|
26
|
+
resolve(socket);
|
|
27
|
+
});
|
|
28
|
+
return promise;
|
|
29
|
+
}
|
|
30
|
+
async function connectSocket(endpoint, timeoutMs) {
|
|
31
|
+
const start = Date.now();
|
|
32
|
+
while (Date.now() - start < timeoutMs) {
|
|
33
|
+
const socket = await tryConnectSocket(endpoint);
|
|
34
|
+
if (socket)
|
|
35
|
+
return socket;
|
|
36
|
+
await sleep(25);
|
|
37
|
+
}
|
|
38
|
+
throw new Error(`Failed to connect to local endpoint at ${endpoint}`);
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Starts a stdio proxy session. Every stdio CLI process is a proxy:
|
|
42
|
+
* runs election to start the local endpoint host if it acquires the lock,
|
|
43
|
+
* or attaches to an existing host.
|
|
44
|
+
*/
|
|
45
|
+
export async function startStdioProxy(options) {
|
|
46
|
+
const { config, identity, log, startHost } = options;
|
|
47
|
+
const stdin = options.stdin ?? process.stdin;
|
|
48
|
+
const stdout = options.stdout ?? process.stdout;
|
|
49
|
+
const timeoutMs = options.electionTimeoutMs ?? 30_000;
|
|
50
|
+
const endpoint = localEndpointPath(config.dataDir);
|
|
51
|
+
const { promise: done, resolve: resolveDone } = Promise.withResolvers();
|
|
52
|
+
let shuttingDown = false;
|
|
53
|
+
let isFailingOver = false;
|
|
54
|
+
let isReconnecting = false;
|
|
55
|
+
let activeSocket = null;
|
|
56
|
+
let activeHost = null;
|
|
57
|
+
let activeLockRelease = null;
|
|
58
|
+
/** Drops the current socket and, if this process hosts, closes the host and releases the lock. */
|
|
59
|
+
async function teardownActive() {
|
|
60
|
+
const socket = activeSocket;
|
|
61
|
+
activeSocket = null;
|
|
62
|
+
if (socket) {
|
|
63
|
+
socket.removeAllListeners();
|
|
64
|
+
socket.destroy();
|
|
65
|
+
}
|
|
66
|
+
const host = activeHost;
|
|
67
|
+
activeHost = null;
|
|
68
|
+
if (host)
|
|
69
|
+
await host.close().catch((err) => log.error({ err }, "closing local host failed"));
|
|
70
|
+
const release = activeLockRelease;
|
|
71
|
+
activeLockRelease = null;
|
|
72
|
+
release?.();
|
|
73
|
+
}
|
|
74
|
+
let storedInitialize = null;
|
|
75
|
+
let storedInitialized = null;
|
|
76
|
+
const inFlightRequests = new Map();
|
|
77
|
+
const pendingStdinLines = [];
|
|
78
|
+
let replaySeq = 0;
|
|
79
|
+
let awaitingReplayId = null;
|
|
80
|
+
let onReplayResponse = null;
|
|
81
|
+
async function runElection(deadline) {
|
|
82
|
+
while (Date.now() < deadline && !shuttingDown) {
|
|
83
|
+
const lockRes = await tryLockDataDir(config.dataDir);
|
|
84
|
+
if (lockRes.acquired) {
|
|
85
|
+
activeLockRelease = lockRes.release;
|
|
86
|
+
try {
|
|
87
|
+
activeHost = await startHost();
|
|
88
|
+
const remainingMs = Math.min(5000, Math.max(100, deadline - Date.now()));
|
|
89
|
+
return await connectSocket(endpoint, remainingMs);
|
|
90
|
+
}
|
|
91
|
+
catch (err) {
|
|
92
|
+
if (activeHost) {
|
|
93
|
+
await activeHost.close().catch(() => { });
|
|
94
|
+
activeHost = null;
|
|
95
|
+
}
|
|
96
|
+
activeLockRelease();
|
|
97
|
+
activeLockRelease = null;
|
|
98
|
+
throw err;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
const holderPid = lockRes.holderPid;
|
|
102
|
+
if (holderPid !== null && isProcessAlive(holderPid)) {
|
|
103
|
+
const socket = await tryConnectSocket(endpoint);
|
|
104
|
+
if (socket)
|
|
105
|
+
return socket;
|
|
106
|
+
}
|
|
107
|
+
await sleep(50);
|
|
108
|
+
}
|
|
109
|
+
const timeoutStr = timeoutMs % 1000 === 0 ? `${timeoutMs / 1000}s` : `${timeoutMs}ms`;
|
|
110
|
+
throw new Error(`Timed out waiting for ok-fine host on ${config.dataDir} after ${timeoutStr}`);
|
|
111
|
+
}
|
|
112
|
+
function handleStdinLine(line) {
|
|
113
|
+
let msg = null;
|
|
114
|
+
try {
|
|
115
|
+
const parsed = JsonRpcMessageSchema.safeParse(JSON.parse(line));
|
|
116
|
+
if (parsed.success)
|
|
117
|
+
msg = parsed.data;
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
// Non-JSON line, forward as-is
|
|
121
|
+
}
|
|
122
|
+
if (msg) {
|
|
123
|
+
if (msg.method === "initialize" && msg.id !== undefined) {
|
|
124
|
+
storedInitialize = { raw: line, parsed: msg };
|
|
125
|
+
}
|
|
126
|
+
else if (msg.method === "notifications/initialized") {
|
|
127
|
+
storedInitialized = line;
|
|
128
|
+
}
|
|
129
|
+
if (msg.id !== undefined && msg.method !== undefined) {
|
|
130
|
+
inFlightRequests.set(msg.id, msg.id);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
if (isReconnecting || !activeSocket || activeSocket.destroyed || !activeSocket.writable) {
|
|
134
|
+
pendingStdinLines.push(line);
|
|
135
|
+
}
|
|
136
|
+
else {
|
|
137
|
+
activeSocket.write(`${line}\n`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
let stdinBuffer = "";
|
|
141
|
+
function onStdinData(chunk) {
|
|
142
|
+
stdinBuffer += typeof chunk === "string" ? chunk : chunk.toString("utf8");
|
|
143
|
+
while (true) {
|
|
144
|
+
const idx = stdinBuffer.indexOf("\n");
|
|
145
|
+
if (idx === -1)
|
|
146
|
+
break;
|
|
147
|
+
const line = stdinBuffer.slice(0, idx).replace(/\r$/, "");
|
|
148
|
+
stdinBuffer = stdinBuffer.slice(idx + 1);
|
|
149
|
+
if (line.length > 0) {
|
|
150
|
+
handleStdinLine(line);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
function handleSocketLine(line) {
|
|
155
|
+
let msg = null;
|
|
156
|
+
try {
|
|
157
|
+
const parsed = JsonRpcMessageSchema.safeParse(JSON.parse(line));
|
|
158
|
+
if (parsed.success)
|
|
159
|
+
msg = parsed.data;
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
// Non-JSON line
|
|
163
|
+
}
|
|
164
|
+
if (msg && awaitingReplayId !== null && msg.id === awaitingReplayId) {
|
|
165
|
+
const resolve = onReplayResponse;
|
|
166
|
+
awaitingReplayId = null;
|
|
167
|
+
onReplayResponse = null;
|
|
168
|
+
resolve?.();
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
if (msg && msg.id !== undefined && (msg.result !== undefined || msg.error !== undefined)) {
|
|
172
|
+
inFlightRequests.delete(msg.id);
|
|
173
|
+
}
|
|
174
|
+
stdout.write(`${line}\n`);
|
|
175
|
+
}
|
|
176
|
+
let socketBuffer = "";
|
|
177
|
+
function onSocketData(chunk) {
|
|
178
|
+
socketBuffer += typeof chunk === "string" ? chunk : chunk.toString("utf8");
|
|
179
|
+
while (true) {
|
|
180
|
+
const idx = socketBuffer.indexOf("\n");
|
|
181
|
+
if (idx === -1)
|
|
182
|
+
break;
|
|
183
|
+
const line = socketBuffer.slice(0, idx).replace(/\r$/, "");
|
|
184
|
+
socketBuffer = socketBuffer.slice(idx + 1);
|
|
185
|
+
if (line.length > 0) {
|
|
186
|
+
handleSocketLine(line);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
async function handleSocketClose() {
|
|
191
|
+
if (shuttingDown || isFailingOver)
|
|
192
|
+
return;
|
|
193
|
+
isFailingOver = true;
|
|
194
|
+
isReconnecting = true;
|
|
195
|
+
try {
|
|
196
|
+
await teardownActive();
|
|
197
|
+
// Answer every in-flight request with host restart error
|
|
198
|
+
for (const id of inFlightRequests.values()) {
|
|
199
|
+
const errorResponse = {
|
|
200
|
+
jsonrpc: "2.0",
|
|
201
|
+
id,
|
|
202
|
+
error: {
|
|
203
|
+
code: -32603,
|
|
204
|
+
message: "ok-fine host restarted; retry the request",
|
|
205
|
+
},
|
|
206
|
+
};
|
|
207
|
+
stdout.write(`${JSON.stringify(errorResponse)}\n`);
|
|
208
|
+
}
|
|
209
|
+
inFlightRequests.clear();
|
|
210
|
+
const deadline = Date.now() + timeoutMs;
|
|
211
|
+
let connectedSocket = null;
|
|
212
|
+
while (Date.now() < deadline && !shuttingDown) {
|
|
213
|
+
try {
|
|
214
|
+
const socket = await runElection(deadline);
|
|
215
|
+
attachSocket(socket);
|
|
216
|
+
socket.write(`${JSON.stringify({ okFine: 1, identity })}\n`);
|
|
217
|
+
if (storedInitialize) {
|
|
218
|
+
const replayId = `okf-replay-${++replaySeq}`;
|
|
219
|
+
awaitingReplayId = replayId;
|
|
220
|
+
const remainingMs = Math.max(1, deadline - Date.now());
|
|
221
|
+
const replayTimeoutMs = Math.min(timeoutMs, remainingMs);
|
|
222
|
+
const { promise: replayPromise, resolve: resolveReplay, reject: rejectReplay, } = Promise.withResolvers();
|
|
223
|
+
onReplayResponse = resolveReplay;
|
|
224
|
+
const onEarlyClose = () => {
|
|
225
|
+
rejectReplay(new Error("Socket closed while awaiting initialize replay response"));
|
|
226
|
+
};
|
|
227
|
+
const onEarlyError = (err) => {
|
|
228
|
+
rejectReplay(err);
|
|
229
|
+
};
|
|
230
|
+
const timer = setTimeout(() => {
|
|
231
|
+
const timeoutStr = replayTimeoutMs % 1000 === 0 ? `${replayTimeoutMs / 1000}s` : `${replayTimeoutMs}ms`;
|
|
232
|
+
rejectReplay(new Error(`Timed out awaiting initialize replay response after ${timeoutStr}`));
|
|
233
|
+
}, replayTimeoutMs);
|
|
234
|
+
socket.once("close", onEarlyClose);
|
|
235
|
+
socket.once("error", onEarlyError);
|
|
236
|
+
try {
|
|
237
|
+
const replayReq = { ...storedInitialize.parsed, id: replayId };
|
|
238
|
+
socket.write(`${JSON.stringify(replayReq)}\n`);
|
|
239
|
+
await replayPromise;
|
|
240
|
+
if (storedInitialized) {
|
|
241
|
+
socket.write(`${storedInitialized}\n`);
|
|
242
|
+
}
|
|
243
|
+
else {
|
|
244
|
+
socket.write(`${JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" })}\n`);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
finally {
|
|
248
|
+
clearTimeout(timer);
|
|
249
|
+
socket.off("close", onEarlyClose);
|
|
250
|
+
socket.off("error", onEarlyError);
|
|
251
|
+
if (awaitingReplayId === replayId) {
|
|
252
|
+
awaitingReplayId = null;
|
|
253
|
+
onReplayResponse = null;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
connectedSocket = socket;
|
|
258
|
+
break;
|
|
259
|
+
}
|
|
260
|
+
catch (attemptErr) {
|
|
261
|
+
log.debug({ err: attemptErr }, "failover attempt failed, retrying");
|
|
262
|
+
await teardownActive();
|
|
263
|
+
if (Date.now() >= deadline || shuttingDown) {
|
|
264
|
+
throw attemptErr;
|
|
265
|
+
}
|
|
266
|
+
await sleep(50);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
if (!connectedSocket && !shuttingDown) {
|
|
270
|
+
const timeoutStr = timeoutMs % 1000 === 0 ? `${timeoutMs / 1000}s` : `${timeoutMs}ms`;
|
|
271
|
+
throw new Error(`Timed out waiting for ok-fine host on ${config.dataDir} after ${timeoutStr}`);
|
|
272
|
+
}
|
|
273
|
+
isReconnecting = false;
|
|
274
|
+
const queued = pendingStdinLines.splice(0, pendingStdinLines.length);
|
|
275
|
+
for (const q of queued) {
|
|
276
|
+
connectedSocket?.write(`${q}\n`);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
catch (err) {
|
|
280
|
+
log.error({ err }, "proxy failover failed");
|
|
281
|
+
await shutdown();
|
|
282
|
+
}
|
|
283
|
+
finally {
|
|
284
|
+
isFailingOver = false;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
function attachSocket(socket) {
|
|
288
|
+
activeSocket = socket;
|
|
289
|
+
socketBuffer = "";
|
|
290
|
+
socket.on("data", onSocketData);
|
|
291
|
+
socket.on("error", (err) => log.debug({ err }, "proxy socket error"));
|
|
292
|
+
socket.once("close", () => {
|
|
293
|
+
void handleSocketClose();
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
async function shutdown() {
|
|
297
|
+
if (shuttingDown)
|
|
298
|
+
return;
|
|
299
|
+
shuttingDown = true;
|
|
300
|
+
stdin.off("data", onStdinData);
|
|
301
|
+
if (activeSocket) {
|
|
302
|
+
activeSocket.removeAllListeners();
|
|
303
|
+
activeSocket.destroy();
|
|
304
|
+
activeSocket = null;
|
|
305
|
+
}
|
|
306
|
+
if (activeHost) {
|
|
307
|
+
try {
|
|
308
|
+
await activeHost.close();
|
|
309
|
+
}
|
|
310
|
+
catch (err) {
|
|
311
|
+
log.error({ err }, "error closing host on shutdown");
|
|
312
|
+
}
|
|
313
|
+
activeHost = null;
|
|
314
|
+
}
|
|
315
|
+
if (activeLockRelease) {
|
|
316
|
+
try {
|
|
317
|
+
activeLockRelease();
|
|
318
|
+
}
|
|
319
|
+
catch (err) {
|
|
320
|
+
log.error({ err }, "error releasing lock on shutdown");
|
|
321
|
+
}
|
|
322
|
+
activeLockRelease = null;
|
|
323
|
+
}
|
|
324
|
+
resolveDone();
|
|
325
|
+
}
|
|
326
|
+
// Initial election and connection
|
|
327
|
+
const initialSocket = await runElection(Date.now() + timeoutMs);
|
|
328
|
+
attachSocket(initialSocket);
|
|
329
|
+
initialSocket.write(`${JSON.stringify({ okFine: 1, identity })}\n`);
|
|
330
|
+
stdin.on("data", onStdinData);
|
|
331
|
+
stdin.once("end", () => {
|
|
332
|
+
void shutdown();
|
|
333
|
+
});
|
|
334
|
+
stdin.once("close", () => {
|
|
335
|
+
void shutdown();
|
|
336
|
+
});
|
|
337
|
+
if (stdin.readableEnded || stdin.destroyed) {
|
|
338
|
+
void shutdown();
|
|
339
|
+
}
|
|
340
|
+
let closing;
|
|
341
|
+
return {
|
|
342
|
+
done,
|
|
343
|
+
close: () => {
|
|
344
|
+
closing ??= shutdown();
|
|
345
|
+
return closing;
|
|
346
|
+
},
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
//# sourceMappingURL=stdio-proxy.js.map
|