File size: 5,059 Bytes
d530cd8 bc9c65c d530cd8 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 | /**
* Friend HTTP server — lightweight Bun.serve for VRM frontend.
*
* Runs in the main CLI process alongside the Ink TUI.
* Serves the friend frontend static files, API routes, and SSE.
*
* Architecture:
* - No WebSocket (frontend uses SSE for server→client, HTTP for client→server)
* - No separate CLI SDK session (uses FriendService → enqueue() → main CLI)
* - No arecord/parecord subprocess (uses cpal in-process)
*/
import { createSseResponse } from './sse.js';
import { handleFriendStaticRequest } from '../server/staticFriend.js';
import { handleFriendApi } from '../server/api/friend.js';
let server: ReturnType<typeof Bun.serve> | null = null;
let serverPort = 3456;
export function getServerPort(): number {
return serverPort;
}
/**
* Try to kill any existing process listening on the given port.
* Returns true if the port became free.
*/
function freePort(port: number): boolean {
try {
// Find PID on the port
const ss = Bun.spawnSync(['ss', '-tlnp', 'sport', `= :${port}`]);
const out = ss.stdout.toString();
const pidMatch = out.match(/pid=(\d+)/);
if (!pidMatch) return true; // port already free
const pid = parseInt(pidMatch[1]!, 10);
if (pid === process.pid) return true; // we own it
// Only kill bun/VersperClaw processes — don't touch unknown services
const proc = Bun.spawnSync(['ps', '-p', String(pid), '-o', 'comm=']);
const comm = proc.stdout.toString().trim();
if (!comm) return false; // process doesn't exist
const baseName = comm.split('/').pop() || comm;
if (baseName !== 'bun' && baseName !== 'VersperClaw' && !baseName.startsWith('claude-') && !baseName.includes('node')) {
console.warn(`[FriendServer] Port ${port} is occupied by non-VersperClaw process: ${comm}`);
return false;
}
// Send SIGTERM politely
process.kill(pid, 'SIGTERM');
// Wait up to 3s for it to die
for (let i = 0; i < 30; i++) {
Bun.sleepSync(100);
try { process.kill(pid, 0); } catch { return true; } // dead
}
// Force kill
try { process.kill(pid, 'SIGKILL'); } catch { /* */ }
Bun.sleepSync(200);
return true;
} catch {
return false;
}
}
/**
* Start the friend HTTP server in-process.
* Safe to call multiple times — no-op if already running.
* If the port is already in use, attempts to free it first.
*/
export function startFriendServer(port = 3456, host = '127.0.0.1'): ReturnType<typeof Bun.serve> {
if (server) return server;
serverPort = port;
// If port is in use, try to free it
const check = Bun.spawnSync(['ss', '-tlnp', 'sport', `= :${port}`]);
if (check.stdout.toString().includes('LISTEN')) {
console.log(`[FriendServer] Port ${port} is in use, attempting to free it...`);
if (!freePort(port)) {
console.warn(`[FriendServer] Could not free port ${port}. Please stop the existing server manually.`);
throw new Error(`Port ${port} is already in use`);
}
console.log(`[FriendServer] Port ${port} freed successfully.`);
}
server = Bun.serve<undefined>({
port,
hostname: host,
idleTimeout: 60, // seconds — allow slow STT provider init (Python/PyTorch imports)
async fetch(req) {
const url = new URL(req.url);
// CORS preflight
if (req.method === 'OPTIONS') {
return new Response(null, {
status: 204,
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
},
});
}
// SSE events (GET /plugins/friend/events)
if (url.pathname === '/plugins/friend/events' && req.method === 'GET') {
return createSseResponse();
}
// Friend API routes (/plugins/friend/*)
if (url.pathname.startsWith('/plugins/friend/')) {
return handleFriendApi(req, url);
}
// WebSocket upgrade — not needed for friend (uses SSE + HTTP)
// but handle gracefully
if (req.headers.get('upgrade')?.toLowerCase() === 'websocket') {
return new Response('WebSocket not supported via friend server', { status: 426 });
}
// Static files (/friend/*)
if (url.pathname.startsWith('/friend/') || url.pathname === '/friend') {
const staticResponse = await handleFriendStaticRequest(req, url);
if (staticResponse) return staticResponse;
}
return new Response('Not Found', { status: 404 });
},
error(err) {
console.error('[FriendServer] Error:', err);
return new Response('Internal Server Error', { status: 500 });
},
});
console.log(`[FriendServer] Listening on http://${host}:${port}`);
return server;
}
/**
* Stop the friend HTTP server.
*/
export function stopFriendServer(): void {
if (server) {
try {
server.stop();
server = null;
console.log('[FriendServer] Stopped');
} catch (err) {
console.error('[FriendServer] Error stopping:', err);
}
}
}
|