pi-remote-control/extensions/remote-control/spike.ts

207 lines
5.5 KiB
TypeScript

/**
* spike.ts — Phase 0 Spike: tmux Stream PoC
*
* Spawns a tmux session running pi, pipes the output via pipe-pane to a FIFO,
* and streams it over WebSocket.
*
* This is throwaway PoC code to verify the foundational assumption:
* - pi runs cleanly in tmux
* - pipe-pane captures ANSI output accurately
* - WebSocket streaming has acceptable latency
* - SSH attach and WS stream stay in sync
*/
import * as fs from "node:fs";
import * as path from "node:path";
import * as os from "node:os";
import { execSync } from "node:child_process";
import { WebSocketServer } from "ws";
const SPIKE_SESSION = "pi-spike";
const WS_PORT = 7799;
const FIFO_PATH = path.join(os.tmpdir(), `${SPIKE_SESSION}.fifo`);
/**
* Check if a tmux session exists
*/
function sessionExists(sessionName: string): boolean {
try {
execSync(`tmux has-session -t ${sessionName} 2>/dev/null`);
return true;
} catch {
return false;
}
}
/**
* Create a new tmux session running pi
*/
function createSession(sessionName: string): void {
console.log(`[spike] Creating tmux session: ${sessionName}`);
execSync(`tmux new-session -d -s ${sessionName} -x 120 -y 40 'pi'`);
}
/**
* Setup pipe-pane to stream to a FIFO
*/
function setupPipePane(sessionName: string, fifoPath: string): void {
// Remove existing FIFO if present
if (fs.existsSync(fifoPath)) {
fs.unlinkSync(fifoPath);
}
// Create new FIFO
execSync(`mkfifo ${fifoPath}`);
console.log(`[spike] Created FIFO: ${fifoPath}`);
// Setup pipe-pane
execSync(`tmux pipe-pane -t ${sessionName} -o "cat > ${fifoPath}"`);
console.log(`[spike] Attached pipe-pane to session ${sessionName}`);
}
/**
* Start the WebSocket server and stream from FIFO
* Uses a single FIFO reader that broadcasts to all connected clients
*/
function startWebSocketServer(fifoPath: string): { wss: WebSocketServer, cleanup: () => void } {
const wss = new WebSocketServer({ port: WS_PORT, host: "127.0.0.1" });
const clients = new Set<any>();
console.log(`[spike] WebSocket server listening on ws://127.0.0.1:${WS_PORT}/spike`);
// Single FIFO reader that broadcasts to all clients
const stream = fs.createReadStream(fifoPath);
stream.on("data", (chunk: Buffer) => {
for (const ws of clients) {
if (ws.readyState === 1) { // WebSocket.OPEN
ws.send(chunk, { binary: true });
}
}
});
stream.on("error", (err) => {
console.error(`[spike] FIFO stream error:`, err);
});
stream.on("end", () => {
console.log("[spike] FIFO stream ended");
});
wss.on("connection", (ws, req) => {
const clientAddr = req.socket.remoteAddress;
console.log(`[spike] Client connected: ${clientAddr}`);
clients.add(ws);
ws.on("close", () => {
console.log(`[spike] Client disconnected: ${clientAddr}`);
clients.delete(ws);
});
ws.on("error", (err) => {
console.error(`[spike] WebSocket error:`, err);
});
});
const cleanup = () => {
stream.destroy();
wss.close();
};
return { wss, cleanup };
}
/**
* Print instructions for connecting to the session
*/
function printInstructions(sessionName: string): void {
console.log("");
console.log("=== Spike Server Running ===");
console.log("");
console.log("To attach to the tmux session (in another terminal):");
console.log(` tmux attach -t ${sessionName}`);
console.log("");
console.log("WebSocket endpoint:");
console.log(` ws://127.0.0.1:${WS_PORT}/spike`);
console.log("");
console.log("To test with the HTML client:");
const clientPath = path.join(path.dirname(new URL(import.meta.url).pathname), "spike-client.html");
console.log(` open ${clientPath}`);
console.log("");
console.log("To stop: Ctrl+C in this terminal");
console.log("");
}
/**
* Cleanup function
*/
function cleanup(cleanupFn: (() => void) | null): void {
console.log("\n[spike] Cleaning up...");
if (cleanupFn) {
cleanupFn();
}
// Remove FIFO
if (fs.existsSync(FIFO_PATH)) {
try {
fs.unlinkSync(FIFO_PATH);
console.log("[spike] Removed FIFO");
} catch (err) {
console.error("[spike] Failed to remove FIFO:", err);
}
}
console.log("[spike] Cleanup complete");
process.exit(0);
}
/**
* Main spike entry point
*/
export async function runSpike(): Promise<void> {
console.log("=== Phase 0 Spike: tmux Stream PoC ===\n");
let cleanupFn: (() => void) | null = null;
// Setup cleanup handlers
const cleanupHandler = () => cleanup(cleanupFn);
process.on("SIGINT", cleanupHandler);
process.on("SIGTERM", cleanupHandler);
try {
// Step 1: Create or reuse tmux session
if (sessionExists(SPIKE_SESSION)) {
console.log(`[spike] Session ${SPIKE_SESSION} already exists, reusing it`);
} else {
createSession(SPIKE_SESSION);
}
// Step 2: Setup pipe-pane to FIFO
setupPipePane(SPIKE_SESSION, FIFO_PATH);
// Step 3: Start WebSocket server
const server = startWebSocketServer(FIFO_PATH);
cleanupFn = server.cleanup;
// Give the server a moment to start
await new Promise(resolve => setTimeout(resolve, 500));
// Step 4: Print instructions
printInstructions(SPIKE_SESSION);
// Keep the process alive
// User can Ctrl+C to stop
await new Promise(() => {}); // Never resolves
} catch (err) {
console.error("[spike] Error:", err);
cleanup(cleanupFn);
}
}
// Run if invoked directly
if (import.meta.url === `file://${process.argv[1]}`) {
runSpike();
}