feat(ipc): port 3g IPC + bgbash — framed Unix-socket server/client + background job registry, wire bash tools

This commit is contained in:
asepharyana
2026-09-02 22:50:38 +07:00
parent fd58b4661f
commit 4b4730cff7
12 changed files with 523 additions and 32 deletions
@@ -0,0 +1,43 @@
/**
* IPC client — connects to the daemon's Unix socket and sends/receives
* framed JSON messages.
* Mirrors `apps/infrastructure/src/ipc/client.rs`.
*/
import * as net from "node:net";
import { Connection } from "./conn.ts";
/** A thread-safe IPC client connected to a Zesdex daemon over a Unix socket. */
export class IpcClient {
private conn: Connection;
private constructor(conn: Connection) {
this.conn = conn;
}
/** Connect to a Unix socket at `path`. */
static connectUnix(path: string): Promise<IpcClient> {
return new Promise((resolve, reject) => {
const socket = net.createConnection(path);
socket.once("error", reject);
socket.once("connect", () => {
socket.removeListener("error", reject);
resolve(new IpcClient(new Connection(socket)));
});
});
}
/** Send a JSON-serializable message as one framed frame. */
send<T>(msg: T): void {
this.conn.send(msg);
}
/** Receive the next message and JSON-decode it (null on close). */
async receive<T>(): Promise<T | null> {
return this.conn.receiveJson<T>();
}
/** Close the connection. */
close(): void {
this.conn.close();
}
}
@@ -0,0 +1,90 @@
/**
* IPC connection — a framed, JSON-message socket over node:net.
* Mirrors `apps/infrastructure/src/ipc/conn.rs`.
*
* Wraps a `net.Socket` (or `net.Server` connection) with the length-prefixed
* framing from `frame.ts`. Provides `send` (write one framed message) and
* an async-style receive via an event emitter / queue.
*/
import * as net from "node:net";
import { FrameReassembler, encodeFrame } from "./frame.ts";
/** A connection over which framed JSON messages are exchanged. */
export class Connection {
private socket: net.Socket;
private reassembler = new FrameReassembler();
private messageQueue: Buffer[] = [];
private waiters: Array<(buf: Buffer | null) => void> = [];
constructor(socket: net.Socket) {
this.socket = socket;
socket.on("data", (chunk: Buffer) => {
let frames: Buffer[];
try {
frames = this.reassembler.feed(chunk);
} catch (e) {
// oversized frame — fail the connection
const err = e as Error;
this._wakeWaiters(null);
this.socket.destroy(new Error(err.message));
return;
}
for (const frame of frames) {
this._enqueue(frame);
}
});
socket.on("end", () => {
this.messageQueue = [];
this._wakeWaiters(null);
});
socket.on("error", () => {
this._wakeWaiters(null);
});
}
/** Serialize and send a message as one framed frame. */
send<T>(msg: T): void {
this.socket.write(encodeFrame(JSON.stringify(msg)));
}
/**
* Receive the next complete message as a Buffer (parsed JSON).
* Returns `null` when the connection closes.
*/
receive(): Promise<Buffer | null> {
if (this.messageQueue.length > 0) {
return Promise.resolve(this.messageQueue.shift()!);
}
return new Promise((resolve) => {
this.waiters.push(resolve);
});
}
/** Receive the next message and JSON-decode it. */
async receiveJson<T>(): Promise<T | null> {
const buf = await this.receive();
if (buf === null) return null;
return JSON.parse(buf.toString("utf8")) as T;
}
/** Close the underlying socket. */
close(): void {
this.socket.end();
}
private _enqueue(buf: Buffer): void {
const waiter = this.waiters.shift();
if (waiter) {
waiter(buf);
} else {
this.messageQueue.push(buf);
}
}
private _wakeWaiters(buf: Buffer | null): void {
while (this.waiters.length > 0) {
const w = this.waiters.shift()!;
w(buf);
}
}
}
@@ -0,0 +1,101 @@
/**
* Length-prefixed framing for Unix-socket IPC.
* Mirrors `apps/infrastructure/src/ipc/frame.rs`.
*
* Every message on the wire is encoded as:
* ```text
* [ 4-byte big-endian payload length ][ payload bytes (JSON) ]
* ```
*
* In TS this operates over `node:net` Socket (byte stream). A helper tracks
* a partial buffer so a single `data` event may contain multiple frames or a
* partial frame.
*/
/** Maximum payload size accepted (64 MiB). */
export const MAX_PAYLOAD = 64 * 1024 * 1024;
/** Buffer for accumulating a frame across multiple `data` events. */
export class FrameReassembler {
private chunks: Buffer[] = [];
private buffered = 0;
private pendingLength: number | null = null;
/**
* Feed raw bytes and return any complete frames that were buffered.
* Each returned element is one complete JSON payload.
*/
feed(chunk: Buffer): Buffer[] {
this.chunks.push(chunk);
this.buffered += chunk.length;
return this.tryFrames();
}
private tryFrames(): Buffer[] {
const frames: Buffer[] = [];
for (;;) {
// Ensure we have at least the 4-byte length prefix.
if (this.buffered < 4) break;
if (this.pendingLength === null) {
this.pendingLength = this.peekLength();
}
const len = this.pendingLength;
if (len === null) break;
if (len > MAX_PAYLOAD) {
throw new Error(`frame payload too large: ${len} bytes (max ${MAX_PAYLOAD})`);
}
if (this.buffered < 4 + len) break; // wait for full payload
// Consume the full frame.
const frame = this.take(4 + len);
const payload = frame.subarray(4, 4 + len);
frames.push(Buffer.from(payload));
this.pendingLength = null;
}
return frames;
}
private peekLength(): number | null {
if (this.buffered < 4) return null;
const first = this.take(4);
// Put the 4 bytes back.
this.unshift(first);
return first.readUInt32BE(0);
}
/** Take `n` bytes from the front of the buffer. */
private take(n: number): Buffer {
let remaining = n;
const parts: Buffer[] = [];
while (remaining > 0 && this.chunks.length > 0) {
const head = this.chunks[0]!;
if (head.length <= remaining) {
parts.push(head);
this.chunks.shift();
remaining -= head.length;
} else {
parts.push(head.subarray(0, remaining));
this.chunks[0] = head.subarray(remaining);
remaining = 0;
}
}
this.buffered -= n;
return Buffer.concat(parts);
}
/** Push `n` bytes back to the front of the buffer. */
private unshift(buf: Buffer): void {
this.chunks.unshift(buf);
this.buffered += buf.length;
}
}
/** Encode a JSON payload into a single length-prefixed frame buffer. */
export function encodeFrame(plainText: string | Buffer): Buffer {
const payload = typeof plainText === "string" ? Buffer.from(plainText, "utf8") : plainText;
if (payload.length > MAX_PAYLOAD) {
throw new Error(`frame payload too large: ${payload.length} bytes (max ${MAX_PAYLOAD})`);
}
const lenBuf = Buffer.alloc(4);
lenBuf.writeUInt32BE(payload.length, 0);
return Buffer.concat([lenBuf, payload]);
}
@@ -0,0 +1,17 @@
/**
* IPC module — frame, protocol, connection, server, client.
* Mirrors `apps/infrastructure/src/ipc/mod.rs`.
*/
export { FrameReassembler, encodeFrame, MAX_PAYLOAD } from "./frame.ts";
export { Connection } from "./conn.ts";
export { IpcServer } from "./server.ts";
export { IpcClient } from "./client.ts";
export type {
KeyAction,
KeyActionName,
ClientRequest,
MessageEntry,
ToastEntry,
StatePayload,
DaemonFrame,
} from "./protocol.ts";
@@ -0,0 +1,75 @@
/**
* Wire types for the Zesdex IPC protocol.
* Mirrors `apps/infrastructure/src/ipc/protocol.rs`.
*/
/** A resolved key press sent from the daemon to the client. */
export type KeyActionName =
| "Char"
| "Enter"
| "Escape"
| "Backspace"
| "Delete"
| "Tab"
| "Up"
| "Down"
| "Left"
| "Right"
| "Home"
| "End"
| "PageUp"
| "PageDown"
| "Function";
export interface KeyAction {
action: KeyActionName;
char?: string;
n?: number;
}
/** A message sent from the TUI client to the daemon over the IPC socket. */
export type ClientRequest =
| { kind: "Tick" }
| { kind: "KeyPress"; key: KeyAction; ctrl: boolean; alt: boolean; shift: boolean }
| { kind: "Submit"; text: string }
| { kind: "Paste"; text: string }
| { kind: "Resize"; cols: number; rows: number }
| { kind: "Close" }
| { kind: "ScrollUp" }
| { kind: "ScrollDown" };
/** A single chat message within a session. */
export interface MessageEntry {
role: string;
content: string;
timestamp: number;
}
/** A transient toast notification sent to the client. */
export interface ToastEntry {
kind: string;
message: string;
created_at: number;
lifetime_ms: number;
}
/** Full UI state snapshot pushed from the daemon to the client. */
export interface StatePayload {
session_id: string;
messages: MessageEntry[];
edit_count: number;
message_count: number;
overlay: string | null;
toasts: ToastEntry[];
dirty: boolean;
input_buffer: string;
input_cursor: number;
}
/** A frame sent from the daemon to the client. */
export type DaemonFrame =
| { kind: "StateUpdate"; payload: StatePayload }
| { kind: "StreamToken"; token: string }
| { kind: "SystemNote"; noteKind: string; message: string }
| { kind: "ClipboardCopy"; text: string }
| { kind: "Closed" };
@@ -0,0 +1,60 @@
/**
* IPC server — binds a Unix socket and accepts incoming client connections.
* Mirrors `apps/infrastructure/src/ipc/server.rs`.
*/
import * as net from "node:net";
import * as fs from "node:fs";
import { Connection } from "./conn.ts";
/** A Unix-socket IPC server. */
export class IpcServer {
private server: net.Server;
private path: string;
private constructor(path: string, server: net.Server) {
this.path = path;
this.server = server;
}
/** Bind a Unix-socket server at `path`, removing any stale socket file. */
static bindUnix(path: string): Promise<IpcServer> {
return new Promise((resolve, reject) => {
if (fs.existsSync(path)) {
fs.unlinkSync(path);
}
const server = net.createServer();
server.once("error", reject);
server.listen(path, () => {
resolve(new IpcServer(path, server));
});
});
}
/** Accept the next incoming connection as a `Connection`. */
accept(): Promise<Connection> {
return new Promise((resolve) => {
const onConnection = (socket: net.Socket) => {
serverCleanup();
resolve(new Connection(socket));
};
const serverCleanup = () => {
this.server.removeListener("connection", onConnection);
};
this.server.once("connection", onConnection);
});
}
/** Close the server and remove the socket file. */
close(): Promise<void> {
return new Promise((resolve) => {
this.server.close(() => {
try {
fs.unlinkSync(this.path);
} catch {
/* ignore */
}
resolve();
});
});
}
}