mcard-js
Version:
MCard - Content-addressable storage with cryptographic hashing, handle resolution, and vector search for Node.js and browsers
265 lines • 10.6 kB
JavaScript
/**
* WebSocket Server builtin for CLM execution.
*
* Provides deploy, status, and stop actions for WebSocket servers.
* Uses Node.js 'ws' library.
*/
import { execSync, spawn } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
/**
* Resolve environment variables in config values like '${VAR}'.
*/
function resolveConfigValue(val) {
if (typeof val === 'string' && val.startsWith('${') && val.endsWith('}')) {
const envVar = val.slice(2, -1);
return process.env[envVar] || val;
}
return val;
}
/**
* Check if a port is in use using lsof (handles both IPv4 and IPv6).
*/
function isPortInUse(port) {
try {
const result = execSync(`lsof -ti :${port}`, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] });
return result.trim().length > 0;
}
catch {
return false;
}
}
/**
* Get PID of process using a port.
*/
function getPidOnPort(port) {
try {
const result = execSync(`lsof -ti :${port}`, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] });
const pid = parseInt(result.trim().split('\n')[0], 10);
return isNaN(pid) ? null : pid;
}
catch {
return null;
}
}
/**
* Execute WebSocket server builtin.
*/
export async function executeWebSocketServer(config, context, chapterDir) {
const action = context.action || config.action || 'status';
// Default to WS_PORT from .env matching the project configuration (5321)
const defaultPort = parseInt(process.env.WS_PORT || '5321', 10);
const rawPort = context.port || config.port || defaultPort;
const port = parseInt(String(resolveConfigValue(rawPort)), 10);
const host = context.host || config.host || 'localhost';
// Resolve project root
let projectRoot = chapterDir;
while (projectRoot !== path.dirname(projectRoot)) {
if (fs.existsSync(path.join(projectRoot, 'package.json')) ||
fs.existsSync(path.join(projectRoot, '.git'))) {
break;
}
projectRoot = path.dirname(projectRoot);
}
if (projectRoot === path.dirname(projectRoot)) {
projectRoot = process.cwd();
}
const pidFile = path.join(projectRoot, `.websocket_server_${port}.pid`);
if (action === 'deploy') {
// Check if already running
if (isPortInUse(port)) {
const pid = getPidOnPort(port);
return {
success: true,
message: 'WebSocket server already running',
pid,
port,
url: `ws://${host}:${port}/`,
status: 'already_running'
};
}
try {
// Locate the worker script
// In development (src), it's in ./server-scripts/websocket-server-worker.ts
// In production (dist), it's in ./server-scripts/websocket-server-worker.js
// Current file is .../builtins/websocket-server.ts
const currentDir = path.dirname(new URL(import.meta.url).pathname);
// Construct path to worker script
// We look for .js first (production), then fallback (though .ts won't run directly without loader)
let workerScript = path.join(currentDir, 'server-scripts', 'websocket-server-worker.js');
// Verify if exists, otherwise try checking source location if running via tsx/ts-node (dev mode)
if (!fs.existsSync(workerScript)) {
// Try .ts for dev mode
const workerScriptTs = path.join(currentDir, 'server-scripts', 'websocket-server-worker.ts');
if (fs.existsSync(workerScriptTs)) {
workerScript = workerScriptTs;
}
else {
return { success: false, error: `Worker script not found: ${workerScript}` };
}
}
// Create log file for WebSocket server output
const logFile = path.join(projectRoot, `.websocket_server_${port}.log`);
// Open log file with file descriptor for stdio
const logFd = fs.openSync(logFile, 'a');
// Spawn Node.js WebSocket server as detached background process
// Passing environment variables for configuration
const env = { ...process.env, PORT: String(port), HOST: host };
// If running a .ts file (dev mode with tsx), we might need to use the loader
// But easiest way for now is to rely on compilation.
// If checking fails, we assume standard node execution on the resolved path.
// For tsx support (dev), we'd need to spawn with tsx if the extension is .ts
const isTs = workerScript.endsWith('.ts');
const execArgv = isTs && process.execArgv.includes('--loader') ? process.execArgv : [];
// Better: use process.execPath and let it handle it, or use 'tsx' if available?
// "spawn(process.execPath...)" runs 'node'. 'node file.ts' fails.
// We should rely on the build step having run, OR use tsx if we are in dev.
let child;
if (isTs) {
// Dev mode: assume tsx is available or we are in a ts-node env
// We'll try to use 'npx tsx' or similar if we really had to, but the requirement is "native implementation".
// Since we are compiling, we should target the .js file.
// However, the `demo:clm` command uses `tsx`.
// If we run `tsx examples/run-clm.ts`, we are in dev mode.
// We should assume proper environment.
// For robustness, let's try to detect if we should use tsx
child = spawn('npx', ['tsx', workerScript], {
detached: true,
stdio: ['ignore', logFd, logFd],
env
});
}
else {
// Production mode: node running .js
child = spawn(process.execPath, [workerScript], {
detached: true,
stdio: ['ignore', logFd, logFd],
env
});
}
// Log any errors from the child process
child.on('error', (err) => {
console.error(`[WebSocket Server] Process error: ${err}`);
fs.appendFileSync(logFile, `\nProcess error: ${err}\n`);
});
child.on('exit', (code, signal) => {
if (code !== null && code !== 0) {
console.error(`[WebSocket Server] Process exited with code ${code}`);
fs.appendFileSync(logFile, `\nProcess exited with code ${code}\n`);
}
// Close file descriptor on exit
try {
fs.closeSync(logFd);
}
catch { }
});
child.unref();
// Close file descriptor in parent process
try {
fs.closeSync(logFd);
}
catch { }
// Save PID
fs.writeFileSync(pidFile, String(child.pid));
// Wait for server to start
await new Promise(resolve => setTimeout(resolve, 3000));
if (isPortInUse(port)) {
console.log(`[WebSocket Server] Successfully started on port ${port}`);
return {
success: true,
message: 'WebSocket server deployed successfully',
pid: child.pid,
port,
url: `ws://${host}:${port}/`,
status: 'running',
logFile
};
}
else {
// Read log file to get error details
let errorDetails = '';
try {
errorDetails = fs.readFileSync(logFile, 'utf8');
}
catch { }
return {
success: false,
error: "Server started but not responding. Check log file for details.",
logFile,
details: errorDetails.slice(-500)
};
}
}
catch (error) {
return { success: false, error: `Failed to start server: ${error}` };
}
}
if (action === 'status') {
const isRunning = isPortInUse(port);
const pid = getPidOnPort(port);
let savedPid = null;
if (fs.existsSync(pidFile)) {
try {
savedPid = parseInt(fs.readFileSync(pidFile, 'utf8').trim(), 10);
}
catch { }
}
return {
success: true,
running: isRunning,
pid,
saved_pid: savedPid,
port,
url: isRunning ? `ws://${host}:${port}/` : null,
status: isRunning ? 'running' : 'stopped'
};
}
if (action === 'stop') {
const pid = getPidOnPort(port);
if (!pid) {
if (fs.existsSync(pidFile)) {
fs.unlinkSync(pidFile);
}
return {
success: true,
message: 'No WebSocket server running on this port',
port,
status: 'stopped'
};
}
try {
process.kill(pid, 'SIGTERM');
await new Promise(resolve => setTimeout(resolve, 500));
if (isPortInUse(port)) {
process.kill(pid, 'SIGKILL');
await new Promise(resolve => setTimeout(resolve, 500));
}
if (fs.existsSync(pidFile)) {
fs.unlinkSync(pidFile);
}
return {
success: true,
message: 'WebSocket server stopped',
pid,
port,
status: 'stopped'
};
}
catch (error) {
if (error.code === 'ESRCH') {
if (fs.existsSync(pidFile)) {
fs.unlinkSync(pidFile);
}
return {
success: true,
message: 'WebSocket server was not running',
port,
status: 'stopped'
};
}
return { success: false, error: `Failed to stop server: ${error}` };
}
}
return { success: false, error: `Unknown action: ${action}` };
}
//# sourceMappingURL=websocket-server.js.map