265 lines
7.1 KiB
TypeScript
265 lines
7.1 KiB
TypeScript
import * as net from 'net';
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
import * as os from 'os';
|
|
import { BrowserManager } from './browser.js';
|
|
import { parseCommand, serializeResponse, errorResponse } from './protocol.js';
|
|
import { executeCommand } from './actions.js';
|
|
|
|
// Platform detection
|
|
const isWindows = process.platform === 'win32';
|
|
|
|
// Session support - each session gets its own socket/pid
|
|
let currentSession = process.env.AGENT_BROWSER_SESSION || 'default';
|
|
|
|
/**
|
|
* Set the current session
|
|
*/
|
|
export function setSession(session: string): void {
|
|
currentSession = session;
|
|
}
|
|
|
|
/**
|
|
* Get the current session
|
|
*/
|
|
export function getSession(): string {
|
|
return currentSession;
|
|
}
|
|
|
|
/**
|
|
* Get port number for TCP mode (Windows)
|
|
* Uses a hash of the session name to get a consistent port
|
|
*/
|
|
function getPortForSession(session: string): number {
|
|
let hash = 0;
|
|
for (let i = 0; i < session.length; i++) {
|
|
hash = (hash << 5) - hash + session.charCodeAt(i);
|
|
hash |= 0;
|
|
}
|
|
// Port range 49152-65535 (dynamic/private ports)
|
|
return 49152 + (Math.abs(hash) % 16383);
|
|
}
|
|
|
|
/**
|
|
* Get the socket path for the current session (Unix) or port (Windows)
|
|
*/
|
|
export function getSocketPath(session?: string): string {
|
|
const sess = session ?? currentSession;
|
|
if (isWindows) {
|
|
return String(getPortForSession(sess));
|
|
}
|
|
return path.join(os.tmpdir(), `agent-browser-${sess}.sock`);
|
|
}
|
|
|
|
/**
|
|
* Get the port file path for Windows (stores the port number)
|
|
*/
|
|
export function getPortFile(session?: string): string {
|
|
const sess = session ?? currentSession;
|
|
return path.join(os.tmpdir(), `agent-browser-${sess}.port`);
|
|
}
|
|
|
|
/**
|
|
* Get the PID file path for the current session
|
|
*/
|
|
export function getPidFile(session?: string): string {
|
|
const sess = session ?? currentSession;
|
|
return path.join(os.tmpdir(), `agent-browser-${sess}.pid`);
|
|
}
|
|
|
|
/**
|
|
* Check if daemon is running for the current session
|
|
*/
|
|
export function isDaemonRunning(session?: string): boolean {
|
|
const pidFile = getPidFile(session);
|
|
if (!fs.existsSync(pidFile)) return false;
|
|
|
|
try {
|
|
const pid = parseInt(fs.readFileSync(pidFile, 'utf8').trim(), 10);
|
|
// Check if process exists (works on both Unix and Windows)
|
|
process.kill(pid, 0);
|
|
return true;
|
|
} catch {
|
|
// Process doesn't exist, clean up stale files
|
|
cleanupSocket(session);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get connection info for the current session
|
|
* Returns { type: 'unix', path: string } or { type: 'tcp', port: number }
|
|
*/
|
|
export function getConnectionInfo(
|
|
session?: string
|
|
): { type: 'unix'; path: string } | { type: 'tcp'; port: number } {
|
|
const sess = session ?? currentSession;
|
|
if (isWindows) {
|
|
return { type: 'tcp', port: getPortForSession(sess) };
|
|
}
|
|
return { type: 'unix', path: path.join(os.tmpdir(), `agent-browser-${sess}.sock`) };
|
|
}
|
|
|
|
/**
|
|
* Clean up socket and PID file for the current session
|
|
*/
|
|
export function cleanupSocket(session?: string): void {
|
|
const pidFile = getPidFile(session);
|
|
try {
|
|
if (fs.existsSync(pidFile)) fs.unlinkSync(pidFile);
|
|
if (isWindows) {
|
|
const portFile = getPortFile(session);
|
|
if (fs.existsSync(portFile)) fs.unlinkSync(portFile);
|
|
} else {
|
|
const socketPath = getSocketPath(session);
|
|
if (fs.existsSync(socketPath)) fs.unlinkSync(socketPath);
|
|
}
|
|
} catch {
|
|
// Ignore cleanup errors
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Start the daemon server
|
|
*/
|
|
export async function startDaemon(): Promise<void> {
|
|
// Clean up any stale socket
|
|
cleanupSocket();
|
|
|
|
const browser = new BrowserManager();
|
|
let shuttingDown = false;
|
|
|
|
const server = net.createServer((socket) => {
|
|
let buffer = '';
|
|
|
|
socket.on('data', async (data) => {
|
|
buffer += data.toString();
|
|
|
|
// Process complete lines
|
|
while (buffer.includes('\n')) {
|
|
const newlineIdx = buffer.indexOf('\n');
|
|
const line = buffer.substring(0, newlineIdx);
|
|
buffer = buffer.substring(newlineIdx + 1);
|
|
|
|
if (!line.trim()) continue;
|
|
|
|
try {
|
|
const parseResult = parseCommand(line);
|
|
|
|
if (!parseResult.success) {
|
|
const resp = errorResponse(parseResult.id ?? 'unknown', parseResult.error);
|
|
socket.write(serializeResponse(resp) + '\n');
|
|
continue;
|
|
}
|
|
|
|
// Auto-launch browser if not already launched and this isn't a launch command
|
|
if (
|
|
!browser.isLaunched() &&
|
|
parseResult.command.action !== 'launch' &&
|
|
parseResult.command.action !== 'close'
|
|
) {
|
|
await browser.launch({ id: 'auto', action: 'launch', headless: true });
|
|
}
|
|
|
|
// Handle close command specially
|
|
if (parseResult.command.action === 'close') {
|
|
const response = await executeCommand(parseResult.command, browser);
|
|
socket.write(serializeResponse(response) + '\n');
|
|
|
|
if (!shuttingDown) {
|
|
shuttingDown = true;
|
|
setTimeout(() => {
|
|
server.close();
|
|
cleanupSocket();
|
|
process.exit(0);
|
|
}, 100);
|
|
}
|
|
return;
|
|
}
|
|
|
|
const response = await executeCommand(parseResult.command, browser);
|
|
socket.write(serializeResponse(response) + '\n');
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
socket.write(serializeResponse(errorResponse('error', message)) + '\n');
|
|
}
|
|
}
|
|
});
|
|
|
|
socket.on('error', () => {
|
|
// Client disconnected, ignore
|
|
});
|
|
});
|
|
|
|
const pidFile = getPidFile();
|
|
|
|
// Write PID file before listening
|
|
fs.writeFileSync(pidFile, process.pid.toString());
|
|
|
|
if (isWindows) {
|
|
// Windows: use TCP socket on localhost
|
|
const port = getPortForSession(currentSession);
|
|
const portFile = getPortFile();
|
|
fs.writeFileSync(portFile, port.toString());
|
|
server.listen(port, '127.0.0.1', () => {
|
|
// Daemon is ready on TCP port
|
|
});
|
|
} else {
|
|
// Unix: use Unix domain socket
|
|
const socketPath = getSocketPath();
|
|
server.listen(socketPath, () => {
|
|
// Daemon is ready
|
|
});
|
|
}
|
|
|
|
server.on('error', (err) => {
|
|
console.error('Server error:', err);
|
|
cleanupSocket();
|
|
process.exit(1);
|
|
});
|
|
|
|
// Handle shutdown signals
|
|
const shutdown = async () => {
|
|
if (shuttingDown) return;
|
|
shuttingDown = true;
|
|
await browser.close();
|
|
server.close();
|
|
cleanupSocket();
|
|
process.exit(0);
|
|
};
|
|
|
|
process.on('SIGINT', shutdown);
|
|
process.on('SIGTERM', shutdown);
|
|
process.on('SIGHUP', shutdown);
|
|
|
|
// Handle unexpected errors - always cleanup
|
|
process.on('uncaughtException', (err) => {
|
|
console.error('Uncaught exception:', err);
|
|
cleanupSocket();
|
|
process.exit(1);
|
|
});
|
|
|
|
process.on('unhandledRejection', (reason) => {
|
|
console.error('Unhandled rejection:', reason);
|
|
cleanupSocket();
|
|
process.exit(1);
|
|
});
|
|
|
|
// Cleanup on normal exit
|
|
process.on('exit', () => {
|
|
cleanupSocket();
|
|
});
|
|
|
|
// Keep process alive
|
|
process.stdin.resume();
|
|
}
|
|
|
|
// Run daemon if this is the entry point
|
|
if (process.argv[1]?.endsWith('daemon.js') || process.env.AGENT_BROWSER_DAEMON === '1') {
|
|
startDaemon().catch((err) => {
|
|
console.error('Daemon error:', err);
|
|
cleanupSocket();
|
|
process.exit(1);
|
|
});
|
|
}
|