import { readFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { StringDecoder } from "node:string_decoder"; import type { Binding, Bindings } from "./bindings"; import type { Clip } from "./clip"; import type { Stream } from "./handler"; import { hubInvoke } from "./hub"; import { createIPCManifest, type IPCManifest } from "./manifest"; // === IPC Protocol Types === type MessageType = "register" | "registered" | "invoke" | "result" | "error" | "chunk" | "done" | "list_clips" | "list_clips_result" | "data" | "data_result"; interface BaseMessage { type: MessageType; id?: string; } interface RegisterMessage extends BaseMessage { type: "register"; manifest: IPCManifest; } interface RegisteredMessage extends BaseMessage { type: "registered"; alias?: string; } interface InvokeMessage extends BaseMessage { type: "invoke"; id: string; command?: string; clip?: string; input?: unknown; hub?: string; hub_token?: string; clip_token?: string; } interface ResultMessage extends BaseMessage { type: "result"; id: string; output?: unknown; } interface ErrorMessage extends BaseMessage { type: "error"; id: string; error: string; } interface ListClipsResultMessage extends BaseMessage { type: "list_clips_result"; id: string; clips: RuntimeClipInfo[]; } export interface RuntimeClipInfo { name: string; package: string; version: string; domain?: string; description?: string; patterns?: string[]; commands: { name: string; description?: string; input?: string; output?: string }[]; } // === State === let registeredAlias: string | undefined; const bindings = loadBindings(); const pendingInvokes = new Map void; reject: (e: Error) => void }>(); const pendingListClips = new Map void; reject: (e: Error) => void }>(); const pendingData = new Map void; reject: (e: Error) => void }>(); let idCounter = 0; function nextId(): string { return `c${++idCounter}`; } function loadBindings(): Bindings { const bindingsPath = join(dirname(Bun.main), "bindings.json"); try { const parsed = JSON.parse(readFileSync(bindingsPath, "utf8")) as unknown; if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { return {}; } const loadedBindings: Bindings = {}; for (const [slot, value] of Object.entries(parsed)) { const binding = normalizeBinding(value); if (binding) { loadedBindings[slot] = binding; } } return loadedBindings; } catch { return {}; } } function normalizeBinding(value: unknown): Binding | null { if (!value || typeof value !== "object" || Array.isArray(value)) { return null; } const candidate = value as Record; const alias = asNonEmptyString(candidate.alias); if (!alias) { return null; } const binding: Binding = { alias }; const hub = asNonEmptyString(candidate.hub); const hubToken = asNonEmptyString(candidate.hub_token); const clipToken = asNonEmptyString(candidate.clip_token); if (hub) { binding.hub = hub; } if (hubToken) { binding.hub_token = hubToken; } if (clipToken) { binding.clip_token = clipToken; } return binding; } function asNonEmptyString(value: unknown): string | undefined { return typeof value === "string" && value.length > 0 ? value : undefined; } function send(msg: Record): void { process.stdout.write(JSON.stringify(msg) + "\n"); } // === Public API === export function getAlias(): string | undefined { return registeredAlias; } export async function dataRequest( operation: string, path: string, content?: string, mime?: string, clipName?: string, ): Promise { const id = nextId(); return new Promise((resolve, reject) => { pendingData.set(id, { resolve, reject }); const msg: Record = { id, type: "data", operation, path }; if (content !== undefined) msg.content = content; if (mime !== undefined) msg.mime = mime; if (clipName !== undefined) msg.clip = clipName; send(msg); }); } export async function invoke(slot: string, command: string, input: unknown): Promise { const binding = bindings[slot]; const clipName = binding?.alias ?? slot; if (binding?.hub) { // Cross-Hub: connect directly to the target Hub return hubInvoke(clipName, command, input, binding.clip_token, binding.hub, binding.hub_token); } // Same runtime: IPC → pinixd routes to the target clip const id = nextId(); return new Promise((resolve, reject) => { pendingInvokes.set(id, { resolve, reject }); send({ id, type: "invoke", clip: clipName, command, input, clip_token: binding?.clip_token }); }); } export async function listClips(): Promise { const id = nextId(); return new Promise((resolve, reject) => { pendingListClips.set(id, { resolve, reject }); send({ id, type: "list_clips" }); }); } export async function invokeClip(alias: string, command: string, input: unknown): Promise { const id = nextId(); return new Promise((resolve, reject) => { pendingInvokes.set(id, { resolve, reject }); send({ id, type: "invoke", clip: alias, command, input }); }); } // === Stdout Protection === export function redirectConsoleToStderr(): void { const write = (...args: unknown[]) => { process.stderr.write(args.map(String).join(" ") + "\n"); }; console.log = write; console.info = write; console.debug = write; } // === IPC Server === export async function serveIPC(clip: Clip): Promise { // Redirect console.log to stderr so stdout is reserved for IPC redirectConsoleToStderr(); // Register with pinixd const manifest = createIPCManifest(clip); send({ type: "register", manifest }); // Idle timeout management const idleTimeout = (clip as Clip & { idleTimeout?: number }).idleTimeout ?? 30_000; let inflight = 0; let idleTimer: ReturnType | null = null; function clearIdleTimer(): void { if (idleTimer !== null) { clearTimeout(idleTimer); idleTimer = null; } } function resetIdleTimer(): void { clearIdleTimer(); if (inflight > 0) return; if (!Number.isFinite(idleTimeout) || idleTimeout < 0) return; idleTimer = setTimeout(() => process.exit(0), idleTimeout); } // Read messages from stdin const reader = createLineReader(process.stdin); const commands = clip.getCommands(); // Start idle timer after registration resetIdleTimer(); for await (const line of reader) { let msg: BaseMessage; try { msg = JSON.parse(line) as BaseMessage; } catch { process.stderr.write(`[ipc] invalid JSON: ${line}\n`); continue; } if (!msg.type) { process.stderr.write(`[ipc] message missing type: ${line}\n`); continue; } switch (msg.type) { case "registered": registeredAlias = asNonEmptyString((msg as RegisteredMessage).alias); break; case "invoke": { clearIdleTimer(); inflight++; const inv = msg as InvokeMessage; handleInvoke(inv, commands).finally(() => { inflight--; resetIdleTimer(); }); break; } case "result": { const res = msg as ResultMessage; const pending = pendingInvokes.get(res.id!); if (pending) { pendingInvokes.delete(res.id!); pending.resolve(res.output); } break; } case "error": { const err = msg as ErrorMessage; const pendingInvoke = pendingInvokes.get(err.id!); if (pendingInvoke) { pendingInvokes.delete(err.id!); pendingInvoke.reject(new Error(err.error)); } const pendingList = pendingListClips.get(err.id!); if (pendingList) { pendingListClips.delete(err.id!); pendingList.reject(new Error(err.error)); } break; } case "list_clips_result": { const res = msg as ListClipsResultMessage; const pending = pendingListClips.get(res.id!); if (pending) { pendingListClips.delete(res.id!); pending.resolve(res.clips ?? []); } break; } case "data_result": { const res = msg as BaseMessage & Record; const pendingReq = pendingData.get(res.id!); if (pendingReq) { pendingData.delete(res.id!); pendingReq.resolve(res); } break; } default: process.stderr.write(`[ipc] unknown message type: ${msg.type}\n`); } } clearIdleTimer(); } async function handleInvoke( msg: InvokeMessage, commands: ReturnType, ): Promise { const commandName = (msg.command ?? "").replace(/\//g, " "); const cmd = commands.get(commandName); if (!cmd) { send({ id: msg.id, type: "error", error: `unknown command: ${msg.command}` }); return; } try { const rawInput = (msg.input ?? {}) as Record; const stdin = typeof rawInput.stdin === "string" ? rawInput.stdin : ""; const parsed = await cmd.input.parseAsync(rawInput); let streamed = false; const stream: Stream = { chunk(data: unknown): void { streamed = true; send({ id: msg.id, type: "chunk", output: data }); }, }; const output = await cmd.fn(parsed, stdin, stream); if (streamed) { send({ id: msg.id, type: "done" }); return; } const validatedOutput = await cmd.output.parseAsync(output); send({ id: msg.id, type: "result", output: validatedOutput }); } catch (err) { send({ id: msg.id, type: "error", error: err instanceof Error ? err.message : String(err) }); } } // === Line Reader === async function* createLineReader(stream: NodeJS.ReadableStream): AsyncGenerator { const decoder = new StringDecoder("utf8"); let buffer = ""; for await (const chunk of stream) { buffer += decoder.write(chunk as Buffer); const lines = buffer.split("\n"); buffer = lines.pop() ?? ""; for (const line of lines) { if (line.trim()) yield line; } } const remaining = decoder.end(); if (remaining) buffer += remaining; if (buffer.trim()) yield buffer; }