179 lines
5.7 KiB
TypeScript
179 lines
5.7 KiB
TypeScript
|
|
import { createConnection, isIP, Socket } from "node:net";
|
||
|
|
import { TextDecoder } from "node:util";
|
||
|
|
import {
|
||
|
|
ActorSnapshot,
|
||
|
|
decodeResponse,
|
||
|
|
isActorRef,
|
||
|
|
MAX_LINE_BYTES,
|
||
|
|
PROTOCOL_VERSION,
|
||
|
|
ProtocolError,
|
||
|
|
ReadCommand,
|
||
|
|
ReadReply,
|
||
|
|
} from "./protocol";
|
||
|
|
|
||
|
|
export function parseEndpoint(endpoint: string): { host: string; port: number } {
|
||
|
|
const match = /^(?:\[([^\]]+)\]|([^:]+)):(\d+)$/.exec(endpoint);
|
||
|
|
if (!match) throw new Error("Use a numeric loopback address, e.g. 127.0.0.1:3737 or [::1]:3737");
|
||
|
|
const host = match[1] ?? match[2];
|
||
|
|
if (
|
||
|
|
host === undefined ||
|
||
|
|
!((isIP(host) === 4 && host.startsWith("127.")) || (isIP(host) === 6 && host === "::1"))
|
||
|
|
) {
|
||
|
|
throw new Error("Only numeric IPv4 loopback or [::1] endpoints are supported");
|
||
|
|
}
|
||
|
|
const port = Number(match[3]);
|
||
|
|
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error("Invalid TCP port");
|
||
|
|
return { host, port };
|
||
|
|
}
|
||
|
|
|
||
|
|
interface Pending {
|
||
|
|
requestId: string;
|
||
|
|
command: ReadCommand;
|
||
|
|
resolve: (reply: ReadReply) => void;
|
||
|
|
reject: (error: Error) => void;
|
||
|
|
timer: NodeJS.Timeout;
|
||
|
|
}
|
||
|
|
|
||
|
|
export class ImageClient {
|
||
|
|
private buffer = Buffer.alloc(0);
|
||
|
|
private pending?: Pending;
|
||
|
|
private queue: Promise<unknown> = Promise.resolve();
|
||
|
|
private sequence = 0;
|
||
|
|
private closed = false;
|
||
|
|
|
||
|
|
private constructor(
|
||
|
|
private readonly socket: Socket,
|
||
|
|
private readonly onDisconnect: (error?: Error) => void,
|
||
|
|
private readonly requestTimeout: number,
|
||
|
|
) {
|
||
|
|
socket.on("data", (chunk: Buffer) => this.receive(chunk));
|
||
|
|
socket.on("error", (error) => this.close(error));
|
||
|
|
socket.on("close", () => this.close(new Error("Image connection closed")));
|
||
|
|
}
|
||
|
|
|
||
|
|
static async connect(
|
||
|
|
endpoint: string,
|
||
|
|
onDisconnect: (error?: Error) => void,
|
||
|
|
timeouts = { connect: 5000, request: 30000 },
|
||
|
|
): Promise<ImageClient> {
|
||
|
|
const address = parseEndpoint(endpoint);
|
||
|
|
const socket = createConnection(address);
|
||
|
|
await new Promise<void>((resolve, reject) => {
|
||
|
|
const cleanup = () => {
|
||
|
|
clearTimeout(timer);
|
||
|
|
socket.off("error", fail);
|
||
|
|
socket.off("connect", connected);
|
||
|
|
};
|
||
|
|
const fail = (error: Error) => {
|
||
|
|
cleanup();
|
||
|
|
socket.destroy();
|
||
|
|
reject(error);
|
||
|
|
};
|
||
|
|
const connected = () => {
|
||
|
|
cleanup();
|
||
|
|
resolve();
|
||
|
|
};
|
||
|
|
const timer = setTimeout(
|
||
|
|
() => fail(new Error("Image connection timed out")),
|
||
|
|
timeouts.connect,
|
||
|
|
);
|
||
|
|
socket.once("error", fail);
|
||
|
|
socket.once("connect", connected);
|
||
|
|
});
|
||
|
|
return new ImageClient(socket, onDisconnect, timeouts.request);
|
||
|
|
}
|
||
|
|
|
||
|
|
async listActors(): Promise<ActorSnapshot[]> {
|
||
|
|
const reply = await this.request({ operation: "list_actors" });
|
||
|
|
if (reply.kind !== "actors") throw new Error("Expected actor listing");
|
||
|
|
return reply.data;
|
||
|
|
}
|
||
|
|
|
||
|
|
async inspectActor(actorRef: string): Promise<ActorSnapshot> {
|
||
|
|
if (!isActorRef(actorRef)) throw new Error("Invalid actor reference");
|
||
|
|
const reply = await this.request({
|
||
|
|
operation: "inspect_actor",
|
||
|
|
parameters: { actor_ref: actorRef },
|
||
|
|
});
|
||
|
|
if (reply.kind !== "actor") throw new Error("Expected actor snapshot");
|
||
|
|
return reply.data;
|
||
|
|
}
|
||
|
|
|
||
|
|
private request(command: ReadCommand): Promise<ReadReply> {
|
||
|
|
const request = this.queue.then(
|
||
|
|
() =>
|
||
|
|
new Promise<ReadReply>((resolve, reject) => {
|
||
|
|
if (this.closed) {
|
||
|
|
reject(new Error("Not connected to an image"));
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const requestId = `runa-vscode-${++this.sequence}`;
|
||
|
|
const line = JSON.stringify({
|
||
|
|
version: PROTOCOL_VERSION,
|
||
|
|
request_id: requestId,
|
||
|
|
command,
|
||
|
|
});
|
||
|
|
const timer = setTimeout(
|
||
|
|
() => this.close(new Error("Image request timed out; connection closed without retry")),
|
||
|
|
this.requestTimeout,
|
||
|
|
);
|
||
|
|
this.pending = { requestId, command, resolve, reject, timer };
|
||
|
|
this.socket.write(line + "\n", (error) => {
|
||
|
|
if (error) this.close(error);
|
||
|
|
});
|
||
|
|
}),
|
||
|
|
);
|
||
|
|
// A failed request must not prevent later requests; each caller retains its own rejection.
|
||
|
|
this.queue = request.then(
|
||
|
|
() => undefined,
|
||
|
|
() => undefined,
|
||
|
|
);
|
||
|
|
return request;
|
||
|
|
}
|
||
|
|
|
||
|
|
private receive(chunk: Buffer): void {
|
||
|
|
if (this.closed) return;
|
||
|
|
let offset = 0;
|
||
|
|
while (offset < chunk.length && !this.closed) {
|
||
|
|
if (!this.pending) {
|
||
|
|
this.close(new Error("Unexpected unsolicited image response"));
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const newline = chunk.indexOf(10, offset);
|
||
|
|
const end = newline === -1 ? chunk.length : newline;
|
||
|
|
if (this.buffer.length + end - offset > MAX_LINE_BYTES) {
|
||
|
|
this.close(new Error("Image response exceeds the 1 MiB line limit"));
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
this.buffer = Buffer.concat([this.buffer, chunk.subarray(offset, end)]);
|
||
|
|
if (newline === -1) return;
|
||
|
|
const frame = this.buffer;
|
||
|
|
this.buffer = Buffer.alloc(0);
|
||
|
|
offset = newline + 1;
|
||
|
|
const pending = this.pending;
|
||
|
|
this.pending = undefined;
|
||
|
|
clearTimeout(pending.timer);
|
||
|
|
try {
|
||
|
|
const line = new TextDecoder("utf-8", { fatal: true }).decode(frame);
|
||
|
|
pending.resolve(decodeResponse(line, pending.requestId, pending.command));
|
||
|
|
} catch (error) {
|
||
|
|
const failure = error instanceof Error ? error : new Error(String(error));
|
||
|
|
pending.reject(failure);
|
||
|
|
if (!(failure instanceof ProtocolError)) this.close(failure);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
close(error?: Error): void {
|
||
|
|
if (this.closed) return;
|
||
|
|
this.closed = true;
|
||
|
|
this.socket.destroy();
|
||
|
|
if (this.pending) {
|
||
|
|
clearTimeout(this.pending.timer);
|
||
|
|
this.pending.reject(error ?? new Error("Disconnected from image"));
|
||
|
|
this.pending = undefined;
|
||
|
|
}
|
||
|
|
this.onDisconnect(error);
|
||
|
|
}
|
||
|
|
}
|