mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
425 lines
14 KiB
TypeScript
425 lines
14 KiB
TypeScript
import { randomBytes } from "node:crypto";
|
|
import type { IncomingMessage } from "node:http";
|
|
import type { Socket } from "node:net";
|
|
import type { Duplex } from "node:stream";
|
|
import { forbidden } from "@p2/app/v1";
|
|
import { WebSocket, WebSocketServer } from "ws";
|
|
import {
|
|
attachTerminalHost,
|
|
connectRunningTerminalHost,
|
|
type TerminalAttachment,
|
|
terminalHostVersion,
|
|
} from "./detached.ts";
|
|
import {
|
|
encodeFrame,
|
|
type HostCall,
|
|
type HostResult,
|
|
MAX_BACKLOG,
|
|
readFrames,
|
|
type TerminalEvent,
|
|
type TerminalInfo,
|
|
type TerminalRequest,
|
|
} from "./protocol.ts";
|
|
|
|
/**
|
|
* How often a viewer's socket gets `{"type":"ping"}`. An idle terminal sends nothing, so without it a dead connection
|
|
* looks open to the viewer and idle-timeouts on the way may close it. Not part of protocol.ts: that file is hashed into
|
|
* the terminal host's version, and the host never sees viewer pings.
|
|
*/
|
|
export const TERMINAL_PING_MS = 15_000;
|
|
|
|
/** One server attachment. Closing it never stops the host or its programs. */
|
|
export class TerminalView {
|
|
readonly session: string;
|
|
readonly #directory: string;
|
|
readonly #app: string;
|
|
/** The current attachment; a new one is made by the next `ready` after it ended. */
|
|
#attachment: TerminalAttachment | undefined;
|
|
readonly #version: string;
|
|
#socket?: WebSocket;
|
|
#queue: (TerminalEvent | Uint8Array)[] = [];
|
|
#queued = 0;
|
|
#list?: ReturnType<typeof Promise.withResolvers<TerminalInfo[]>>;
|
|
#serial: Promise<unknown> = Promise.resolve();
|
|
#closed = false;
|
|
|
|
constructor(directory: string, app: string, session: string, version: string) {
|
|
this.session = session;
|
|
this.#directory = directory;
|
|
this.#app = app;
|
|
this.#version = version;
|
|
}
|
|
|
|
ready(): Promise<void> {
|
|
if (!this.#attachment) {
|
|
const attachment = attachTerminalHost(
|
|
this.#directory,
|
|
this.#app,
|
|
(frame) => this.#frame(frame),
|
|
() => {
|
|
if (this.#attachment !== attachment) return;
|
|
this.#attachment = undefined;
|
|
this.#socket?.close(1011, "Terminal host disconnected");
|
|
},
|
|
);
|
|
this.#attachment = attachment;
|
|
}
|
|
return this.#attachment.ready;
|
|
}
|
|
|
|
/** Detaches from the host (it and its programs live on). */
|
|
#detach(): void {
|
|
const attachment = this.#attachment;
|
|
this.#attachment = undefined;
|
|
attachment?.detach();
|
|
}
|
|
|
|
#write(message: object | Uint8Array): void {
|
|
if (!this.#attachment) throw new Error("Terminal host is disconnected");
|
|
this.#attachment.write(message);
|
|
}
|
|
|
|
#frame(frame: unknown): void {
|
|
if (frame instanceof Uint8Array) {
|
|
this.#forward(frame);
|
|
return;
|
|
}
|
|
const event = frame as TerminalEvent;
|
|
if (event.type === "hello") {
|
|
if (event.version !== this.#version) this.#forward({ type: "outdated", running: event.running });
|
|
return;
|
|
}
|
|
if (event.type === "list" && this.#list) {
|
|
this.#list.resolve(event.terminals);
|
|
this.#list = undefined;
|
|
return;
|
|
}
|
|
this.#forward(event);
|
|
}
|
|
|
|
#forward(frame: TerminalEvent | Uint8Array): void {
|
|
const bytes = frame instanceof Uint8Array ? frame.length : Buffer.byteLength(JSON.stringify(frame));
|
|
if (this.#socket?.readyState === WebSocket.OPEN) {
|
|
if (this.#socket.bufferedAmount + bytes > MAX_BACKLOG) {
|
|
this.#socket.terminate();
|
|
return;
|
|
}
|
|
this.#socket.send(frame instanceof Uint8Array ? frame : JSON.stringify(frame));
|
|
} else {
|
|
this.#queued += bytes;
|
|
if (this.#queued > MAX_BACKLOG) {
|
|
this.#detach();
|
|
this.#queue = [];
|
|
this.#queued = 0;
|
|
return;
|
|
}
|
|
this.#queue.push(frame);
|
|
}
|
|
}
|
|
|
|
bind(socket: WebSocket): void {
|
|
this.#socket?.terminate();
|
|
this.#socket = socket;
|
|
for (const frame of this.#queue.splice(0)) this.#forward(frame);
|
|
this.#queued = 0;
|
|
// Keepalive; see TERMINAL_PING_MS.
|
|
const ping = setInterval(() => {
|
|
if (socket.readyState === WebSocket.OPEN) socket.send('{"type":"ping"}');
|
|
}, TERMINAL_PING_MS);
|
|
ping.unref();
|
|
socket.on("message", (data, binary) => {
|
|
if (!binary) {
|
|
socket.close(1003, "Terminal channel accepts bytes only");
|
|
return;
|
|
}
|
|
const bytes = Array.isArray(data) ? Buffer.concat(data) : Buffer.from(data as ArrayBuffer);
|
|
try {
|
|
this.#write(bytes);
|
|
} catch {
|
|
socket.terminate();
|
|
}
|
|
});
|
|
socket.on("error", () => socket.terminate());
|
|
socket.on("close", () => {
|
|
clearInterval(ping);
|
|
if (this.#socket !== socket) return;
|
|
this.#socket = undefined;
|
|
this.#queue = [];
|
|
this.#queued = 0;
|
|
this.#detach();
|
|
});
|
|
}
|
|
|
|
command(request: TerminalRequest): Promise<void> {
|
|
const run = async () => {
|
|
if (this.#closed) throw new Error("Terminal view is closed");
|
|
await this.ready();
|
|
if (request.type === "attach") {
|
|
const list = Promise.withResolvers<TerminalInfo[]>();
|
|
this.#list = list;
|
|
this.#write({ type: "list", sessionId: this.session });
|
|
const timeout = setTimeout(() => list.reject(new Error("Terminal list timed out")), 10_000);
|
|
let terminals: TerminalInfo[];
|
|
try {
|
|
terminals = await list.promise;
|
|
} finally {
|
|
clearTimeout(timeout);
|
|
if (this.#list === list) this.#list = undefined;
|
|
}
|
|
if (!terminals.some((terminal) => terminal.id === request.terminalId))
|
|
throw forbidden("This terminal does not belong to this session");
|
|
}
|
|
this.#write(request);
|
|
};
|
|
const result = this.#serial.then(run, run);
|
|
this.#serial = result.catch(() => {});
|
|
return result;
|
|
}
|
|
|
|
async close(): Promise<void> {
|
|
this.#closed = true;
|
|
this.#list?.reject(new Error("Terminal view closed"));
|
|
this.#socket?.terminate();
|
|
this.#socket = undefined;
|
|
this.#detach();
|
|
}
|
|
}
|
|
|
|
/** What a call to a terminal host running older code fails with: it does not know the calls. */
|
|
export const OUTDATED_HOST =
|
|
"The terminal host runs older code: restart it from the terminal panel (this ends its terminals)";
|
|
|
|
/**
|
|
* The server's connection for calls with replies (the agent's terminal tool, archiving): one attachment, made when
|
|
* the first call needs it and again after the host went away. Cancelling a call tells the host, which ends its wait.
|
|
*/
|
|
export class HostCalls {
|
|
readonly #directory: string;
|
|
readonly #app: string;
|
|
readonly #version: string;
|
|
#attachment: TerminalAttachment | undefined;
|
|
/** The attached host's version, from its hello. */
|
|
#hostVersion: string | undefined;
|
|
#next = 1;
|
|
readonly #pending = new Map<number, { resolve: (result: HostResult) => void; reject: (error: Error) => void }>();
|
|
#closed = false;
|
|
|
|
constructor(directory: string, app: string, version: string) {
|
|
this.#directory = directory;
|
|
this.#app = app;
|
|
this.#version = version;
|
|
}
|
|
|
|
async call(call: HostCall, signal?: AbortSignal): Promise<HostResult> {
|
|
signal?.throwIfAborted();
|
|
if (this.#closed) throw new Error("The terminal service is closed");
|
|
const attachment = this.#attach();
|
|
await attachment.ready;
|
|
if (this.#hostVersion !== this.#version) throw new Error(OUTDATED_HOST);
|
|
const id = this.#next++;
|
|
const reply = Promise.withResolvers<HostResult>();
|
|
this.#pending.set(id, reply);
|
|
const abort = () => {
|
|
if (!this.#pending.delete(id)) return;
|
|
try {
|
|
attachment.write({ type: "cancel", id });
|
|
} catch {}
|
|
reply.reject(signal?.reason instanceof Error ? signal.reason : new Error("Cancelled"));
|
|
};
|
|
signal?.addEventListener("abort", abort, { once: true });
|
|
try {
|
|
attachment.write({ type: "call", id, call });
|
|
return await reply.promise;
|
|
} finally {
|
|
this.#pending.delete(id);
|
|
signal?.removeEventListener("abort", abort);
|
|
}
|
|
}
|
|
|
|
#attach(): TerminalAttachment {
|
|
if (this.#attachment) return this.#attachment;
|
|
const attachment = attachTerminalHost(
|
|
this.#directory,
|
|
this.#app,
|
|
(frame) => this.#frame(frame),
|
|
() => {
|
|
if (this.#attachment !== attachment) return;
|
|
this.#attachment = undefined;
|
|
this.#fail(new Error("Terminal host disconnected"));
|
|
},
|
|
);
|
|
this.#attachment = attachment;
|
|
this.#hostVersion = undefined;
|
|
return attachment;
|
|
}
|
|
|
|
#frame(frame: unknown): void {
|
|
if (frame instanceof Uint8Array) return;
|
|
const event = frame as TerminalEvent;
|
|
if (event.type === "hello") this.#hostVersion = event.version;
|
|
if (event.type !== "reply") return;
|
|
const pending = this.#pending.get(event.id);
|
|
this.#pending.delete(event.id);
|
|
if ("error" in event) pending?.reject(new Error(event.error));
|
|
else pending?.resolve(event.result);
|
|
}
|
|
|
|
#fail(error: Error): void {
|
|
for (const pending of this.#pending.values()) pending.reject(error);
|
|
this.#pending.clear();
|
|
}
|
|
|
|
close(): void {
|
|
this.#closed = true;
|
|
const attachment = this.#attachment;
|
|
this.#attachment = undefined;
|
|
attachment?.detach();
|
|
this.#fail(new Error("The terminal service is closed"));
|
|
}
|
|
}
|
|
|
|
export class TerminalService {
|
|
readonly directory: string;
|
|
/** The p2 app directory (`Server.info.app`): where the terminal host's code is. */
|
|
readonly app: string;
|
|
readonly #sockets = new WebSocketServer({ noServer: true, maxPayload: 1024 * 1024 });
|
|
readonly #views = new Set<TerminalView>();
|
|
readonly #tokens = new Map<string, { view: TerminalView; expires: number }>();
|
|
#calls: Promise<HostCalls> | undefined;
|
|
|
|
constructor(directory: string, app: string) {
|
|
this.directory = directory;
|
|
this.app = app;
|
|
}
|
|
|
|
/** One call to the terminal host (starting it when none runs); `signal` cancels it. */
|
|
async call(call: HostCall, signal?: AbortSignal): Promise<HostResult> {
|
|
this.#calls ??= terminalHostVersion(this.app).then((version) => new HostCalls(this.directory, this.app, version));
|
|
return (await this.#calls).call(call, signal);
|
|
}
|
|
|
|
/**
|
|
* Closes the session's terminals; without a running host there are none, and none is started. A host running older
|
|
* code does not know `closeSession`: it closes them as the panel does, attaching to each and closing it.
|
|
*/
|
|
async closeSession(sessionId: string): Promise<void> {
|
|
const socket = await connectRunningTerminalHost(this.directory);
|
|
if (!socket) return;
|
|
try {
|
|
const events = hostEvents(socket);
|
|
const hello = await events.next(
|
|
(event): event is Extract<TerminalEvent, { type: "hello" }> => event.type === "hello",
|
|
);
|
|
if (hello.version !== (await terminalHostVersion(this.app)))
|
|
return await closeAsViewer(socket, events, sessionId);
|
|
} finally {
|
|
socket.destroy();
|
|
}
|
|
await this.call({ op: "closeSession", sessionId });
|
|
}
|
|
|
|
async open(session: string): Promise<TerminalView> {
|
|
const view = new TerminalView(this.directory, this.app, session, await terminalHostVersion(this.app));
|
|
this.#views.add(view);
|
|
try {
|
|
await view.ready();
|
|
} catch (error) {
|
|
await this.release(view);
|
|
throw error;
|
|
}
|
|
return view;
|
|
}
|
|
|
|
async channel(view: TerminalView): Promise<{ token: string }> {
|
|
await view.ready();
|
|
for (const [token, entry] of this.#tokens)
|
|
if (entry.view === view || entry.expires < Date.now()) this.#tokens.delete(token);
|
|
const token = randomBytes(24).toString("base64url");
|
|
this.#tokens.set(token, { view, expires: Date.now() + 30_000 });
|
|
return { token };
|
|
}
|
|
|
|
upgrade(request: IncomingMessage, socket: Duplex, head: Buffer): void {
|
|
const token = new URL(request.url ?? "/", "http://localhost").searchParams.get("token") ?? "";
|
|
const entry = this.#tokens.get(token);
|
|
this.#tokens.delete(token);
|
|
if (!entry || entry.expires < Date.now()) {
|
|
socket.end("HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n");
|
|
return;
|
|
}
|
|
this.#sockets.handleUpgrade(request, socket, head, (ws) => entry.view.bind(ws));
|
|
}
|
|
|
|
async release(view: TerminalView): Promise<void> {
|
|
this.#views.delete(view);
|
|
for (const [token, entry] of this.#tokens) if (entry.view === view) this.#tokens.delete(token);
|
|
await view.close();
|
|
}
|
|
|
|
async close(): Promise<void> {
|
|
void this.#calls?.then((calls) => calls.close());
|
|
await Promise.all([...this.#views].map((view) => this.release(view)));
|
|
await new Promise<void>((resolve) => this.#sockets.close(() => resolve()));
|
|
}
|
|
}
|
|
|
|
/** How long closing a session's terminals on a host running older code may take. */
|
|
const VIEWER_CLOSE_MS = 10_000;
|
|
|
|
/** A host connection's events, in order: `next` waits for the first one after the last taken that `wanted` accepts. */
|
|
type HostEvents = { next<E extends TerminalEvent>(wanted: (event: TerminalEvent) => event is E): Promise<E> };
|
|
|
|
function hostEvents(socket: Socket): HostEvents {
|
|
const queue: TerminalEvent[] = [];
|
|
let wake = () => {};
|
|
let ended = false;
|
|
readFrames(socket, (frame) => {
|
|
if (frame instanceof Uint8Array) return;
|
|
queue.push(frame as TerminalEvent);
|
|
wake();
|
|
});
|
|
socket.once("close", () => {
|
|
ended = true;
|
|
wake();
|
|
});
|
|
return {
|
|
async next(wanted) {
|
|
const deadline = Date.now() + VIEWER_CLOSE_MS;
|
|
for (;;) {
|
|
for (let event = queue.shift(); event; event = queue.shift()) if (wanted(event)) return event;
|
|
if (ended) throw new Error("Terminal host disconnected");
|
|
const left = deadline - Date.now();
|
|
if (left <= 0) throw new Error("The terminal host did not answer");
|
|
await new Promise<void>((resolve) => {
|
|
const timer = setTimeout(resolve, left);
|
|
wake = () => {
|
|
clearTimeout(timer);
|
|
resolve();
|
|
};
|
|
});
|
|
}
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Closes the session's terminals with the viewer protocol every host version speaks: list them, then attach to each
|
|
* and force its close; resolves once the host reported each closed.
|
|
*/
|
|
async function closeAsViewer(socket: Socket, events: HostEvents, sessionId: string): Promise<void> {
|
|
socket.write(encodeFrame({ type: "list", sessionId } satisfies TerminalRequest));
|
|
const listed = await events.next(
|
|
(event): event is Extract<TerminalEvent, { type: "list" }> => event.type === "list",
|
|
);
|
|
const open = new Set(listed.terminals.map((terminal) => terminal.id));
|
|
for (const terminalId of open) {
|
|
socket.write(encodeFrame({ type: "attach", terminalId } satisfies TerminalRequest));
|
|
socket.write(encodeFrame({ type: "close", force: true } satisfies TerminalRequest));
|
|
}
|
|
while (open.size > 0) {
|
|
const closed = await events.next(
|
|
(event): event is Extract<TerminalEvent, { type: "closed" }> => event.type === "closed",
|
|
);
|
|
open.delete(closed.terminalId);
|
|
}
|
|
}
|