UNPKG

@juspay/neurolink

Version:

Universal AI Development Platform with working MCP integration, multi-provider support, voice (TTS/STT/realtime), and professional CLI. 58+ external MCP servers discoverable, multimodal file processing, RAG pipelines. Build, test, and deploy AI applicatio

647 lines 27.9 kB
/** * Serve CLI Commands for NeuroLink * Simplified HTTP server management commands for Server Adapters feature * * Usage: * neurolink serve --framework <hono|express|fastify|koa> --port <n> * neurolink serve --config <file> * neurolink serve --cors --rate-limit * neurolink serve status */ import chalk from "chalk"; import fs from "fs"; import ora from "ora"; import path from "path"; import { NeuroLink } from "../../lib/neurolink.js"; import { ConfigurationError, ServerStartError, } from "../../lib/server/errors.js"; import { withTimeout } from "../../lib/utils/errorHandling.js"; import { logger } from "../../lib/utils/logger.js"; import { formatUptime, isProcessRunning, StateFileManager, } from "../utils/serverUtils.js"; // ============================================ // State Management // ============================================ // Use StateFileManager for serve state persistence const serveStateManager = new StateFileManager("serve-state.json"); function saveServeState(state) { serveStateManager.save(state); } function loadServeState() { return serveStateManager.load(); } function clearServeState() { serveStateManager.clear(); } function loadConfigFile(configPath) { const absolutePath = path.isAbsolute(configPath) ? configPath : path.resolve(process.cwd(), configPath); if (!fs.existsSync(absolutePath)) { throw new ConfigurationError(`Config file not found: ${absolutePath}`, { configPath, absolutePath, }); } const content = fs.readFileSync(absolutePath, "utf8"); // Support both JSON and JS/TS module format (for JSON only at runtime) if (absolutePath.endsWith(".json")) { return JSON.parse(content); } throw new ConfigurationError("Only JSON config files are supported. Use .json extension.", { configPath, absolutePath, }); } // ============================================ // Watch Mode Utilities // ============================================ /** * Directories to watch for changes in watch mode */ const WATCH_DIRS = ["src", "lib"]; /** * File extensions to watch for changes */ const WATCH_EXTENSIONS = [".ts", ".js", ".json"]; /** * Debounce time for file changes (ms) */ const WATCH_DEBOUNCE_MS = 500; /** * Create a file watcher for watch mode * Returns a cleanup function to stop watching */ function createFileWatcher(onRestart, quiet) { const watchers = []; let debounceTimer = null; let isRestarting = false; const handleChange = (eventType, filename) => { // Skip if no filename or if it doesn't match our extensions if (!filename) { return; } const ext = path.extname(filename); if (!WATCH_EXTENSIONS.includes(ext)) { return; } // Debounce rapid changes if (debounceTimer) { clearTimeout(debounceTimer); } debounceTimer = setTimeout(async () => { if (isRestarting) { return; } isRestarting = true; if (!quiet) { logger.always(""); logger.always(chalk.yellow(`File changed: ${filename}. Restarting server...`)); } try { await onRestart(); } finally { isRestarting = false; } }, WATCH_DEBOUNCE_MS); }; // Watch each directory const cwd = process.cwd(); for (const dir of WATCH_DIRS) { const watchPath = path.join(cwd, dir); if (fs.existsSync(watchPath)) { try { const watcher = fs.watch(watchPath, { recursive: true }, handleChange); watchers.push(watcher); } catch { // Ignore errors for directories that can't be watched } } } // Return cleanup function return () => { if (debounceTimer) { clearTimeout(debounceTimer); } for (const watcher of watchers) { watcher.close(); } }; } // ============================================ // Serve Command Factory // ============================================ /** * Serve CLI command factory */ export class ServeCommandFactory { /** * Create the main serve command */ static createServeCommands() { return { command: "serve [subcommand]", describe: "Start NeuroLink HTTP server with server adapters", builder: (yargs) => { return yargs .command("status", "Show server status", (yargs) => ServeCommandFactory.buildStatusOptions(yargs), (argv) => ServeCommandFactory.executeStatus(argv)) .command("voice", "Start the real-time voice assistant server (OpenAI Realtime / Gemini Live)", (yargs) => yargs.option("port", { alias: "p", type: "number", default: 3000, describe: "Port to listen on", }), async (argv) => { const { startVoiceServer } = await import("../../lib/server/voice/voiceServerApp.js"); await startVoiceServer(argv.port); }) .option("port", { type: "number", alias: "p", default: 3000, description: "Port to listen on", }) .option("host", { type: "string", alias: "H", default: "0.0.0.0", description: "Host to bind to", }) .option("framework", { type: "string", alias: "f", choices: ["hono", "express", "fastify", "koa"], default: "hono", description: "Web framework to use (hono recommended)", }) .option("basePath", { type: "string", alias: "b", default: "/api", description: "Base path for all routes", }) .option("cors", { type: "boolean", default: true, description: "Enable CORS middleware", }) .option("rate-limit", { type: "number", alias: "rateLimit", default: 100, description: "Rate limit (requests per 15 min window, 0 to disable)", }) .option("swagger", { type: "boolean", default: false, description: "Enable OpenAPI/Swagger documentation", }) .option("config", { type: "string", alias: "c", description: "Path to server config file (JSON)", }) .option("watch", { type: "boolean", alias: "w", default: false, description: "Watch mode (restart server on file changes)", }) .option("quiet", { type: "boolean", alias: "q", default: false, description: "Suppress non-essential output", }) .option("debug", { type: "boolean", alias: "d", default: false, description: "Enable debug output", }) .example("neurolink serve", "Start server with default settings (Hono on port 3000)") .example("neurolink serve --framework express --port 8080", "Start Express server on port 8080") .example("neurolink serve --config server.config.json", "Start server with config file") .example("neurolink serve --cors --rate-limit 50", "Start server with CORS and rate limiting (50 req/15min)") .example("neurolink serve --swagger", "Start server with OpenAPI documentation enabled") .example("neurolink serve --watch", "Start server in watch mode (restart on changes)") .example("neurolink serve status", "Show server status") .help(); }, handler: async (argv) => { // If subcommand is provided (like 'status'), it will be handled by the subcommand // Otherwise, start the server if (!argv.subcommand || argv.subcommand === "serve") { await ServeCommandFactory.executeServe(argv); } }, }; } static buildStatusOptions(yargs) { return yargs .option("format", { type: "string", choices: ["text", "json"], default: "text", description: "Output format", }) .option("quiet", { type: "boolean", alias: "q", default: false, description: "Suppress non-essential output", }) .example("neurolink serve status", "Show server status") .example("neurolink serve status --format json", "Show status as JSON"); } // ============================================ // Command Executors // ============================================ static async executeServe(argv) { const spinner = argv.quiet ? null : ora("Starting NeuroLink server...").start(); try { // Check if server is already running ServeCommandFactory.guardAlreadyRunning(spinner); // Load config file if provided const fileConfig = ServeCommandFactory.loadFileConfig(argv, spinner); // Merge CLI args with file config (CLI takes precedence) const port = argv.port ?? fileConfig.port ?? 3000; const host = argv.host ?? fileConfig.host ?? "0.0.0.0"; const framework = argv.framework ?? fileConfig.framework ?? "hono"; const basePath = argv.basePath ?? fileConfig.basePath ?? "/api"; // Build server adapter config from merged values const serverConfig = ServeCommandFactory.buildServerConfig(argv, fileConfig, { port, host, basePath, }); if (spinner) { spinner.text = `Creating ${framework} server...`; } // Create, register routes, initialize and start server const serverRef = await ServeCommandFactory.createAndStartServer({ framework, serverConfig, basePath, port, host }, spinner); // Save state and print startup banner ServeCommandFactory.saveAndPrintStartupInfo({ argv, port, host, framework, basePath, serverConfig }, spinner); // Set up watch mode if enabled const stopWatcher = ServeCommandFactory.setupWatchMode(argv, { serverRef, framework, serverConfig, basePath, port, host, }); // Register signal handlers for graceful shutdown ServeCommandFactory.registerSignalHandlers(serverRef, stopWatcher); } catch (error) { ServeCommandFactory.handleStartupError(error, argv, spinner); } } // ============================================ // executeServe Helpers // ============================================ /** * Guard against a server that is already running. Exits the process if so. */ static guardAlreadyRunning(spinner) { const existingState = loadServeState(); if (existingState && isProcessRunning(existingState.pid)) { if (spinner) { spinner.fail(chalk.red(`Server already running on port ${existingState.port} (PID: ${existingState.pid})`)); } logger.always(chalk.yellow("Use 'neurolink server stop' or kill the process to stop it first")); process.exit(1); } } /** * Load a config file if one was specified in argv. Returns empty object otherwise. */ static loadFileConfig(argv, spinner) { if (!argv.config) { return {}; } try { const config = loadConfigFile(argv.config); if (spinner) { spinner.text = `Loading config from ${argv.config}...`; } return config; } catch (configError) { if (spinner) { spinner.fail(chalk.red("Failed to load config file")); } logger.error(chalk.red(`Error: ${configError instanceof Error ? configError.message : String(configError)}`)); process.exit(1); } } /** * Merge CLI args with file config to produce a ServerAdapterConfig. */ static buildServerConfig(argv, fileConfig, merged) { const defaultConfig = { cors: { enabled: true }, rateLimit: { enabled: true, maxRequests: 100 }, }; return { port: merged.port, host: merged.host, basePath: merged.basePath, cors: { ...defaultConfig.cors, ...(fileConfig.cors || {}), enabled: argv.cors !== undefined ? argv.cors : (fileConfig.cors?.enabled ?? defaultConfig.cors.enabled), }, rateLimit: { ...defaultConfig.rateLimit, ...(fileConfig.rateLimit || {}), enabled: argv.rateLimit !== undefined ? argv.rateLimit > 0 : (fileConfig.rateLimit?.enabled ?? defaultConfig.rateLimit.enabled), maxRequests: argv.rateLimit !== undefined ? argv.rateLimit : (fileConfig.rateLimit?.maxRequests ?? defaultConfig.rateLimit.maxRequests), }, bodyParser: fileConfig.bodyParser, logging: fileConfig.logging, timeout: fileConfig.timeout, enableMetrics: fileConfig.enableMetrics ?? true, enableSwagger: argv.swagger !== undefined ? argv.swagger : (fileConfig.enableSwagger ?? false), disableBuiltInHealth: true, // We register health routes separately }; } /** * Create server, register routes, initialize and start it. * Returns a mutable reference wrapper so signal handlers always access the current server. */ static async createAndStartServer(opts, spinner) { const { createServer, registerAllRoutes } = await import("../../lib/server/index.js"); const neurolink = new NeuroLink(); const serverRef = { current: await createServer(neurolink, { framework: opts.framework, config: opts.serverConfig, }), }; registerAllRoutes(serverRef.current, opts.basePath); if (spinner) { spinner.text = "Initializing server..."; } await withTimeout(serverRef.current.initialize(), 30000, new ServerStartError("Server initialization timed out after 30 seconds", undefined, opts.port, opts.host)); await withTimeout(serverRef.current.start(), 30000, new ServerStartError("Server startup timed out after 30 seconds", undefined, opts.port, opts.host)); return serverRef; } /** * Save server state and print the startup info banner. */ static saveAndPrintStartupInfo(opts, spinner) { const state = { pid: process.pid, port: opts.port, host: opts.host, framework: opts.framework, startTime: new Date().toISOString(), basePath: opts.basePath, configFile: opts.argv.config, }; saveServeState(state); if (spinner) { spinner.succeed(chalk.green("NeuroLink server started successfully")); } const url = `http://${opts.host === "0.0.0.0" ? "localhost" : opts.host}:${opts.port}`; const corsEnabled = opts.argv.cors ?? opts.serverConfig.cors?.enabled ?? true; const rateLimitValue = opts.argv.rateLimit ?? opts.serverConfig.rateLimit?.maxRequests ?? 100; const rateLimitEnabled = rateLimitValue > 0; const swaggerEnabled = opts.argv.swagger ?? opts.serverConfig.enableSwagger ?? false; ServeCommandFactory.printStartupBanner({ url, framework: opts.framework, basePath: opts.basePath, pid: state.pid, configFile: opts.argv.config, corsEnabled, rateLimitEnabled, rateLimitValue, swaggerEnabled, watchEnabled: opts.argv.watch ?? false, }); return state; } /** * Print the server startup banner with server info, middleware status and endpoints. */ static printStartupBanner(info) { logger.always(""); logger.always(chalk.bold.cyan("NeuroLink Server")); logger.always(chalk.gray("=".repeat(50))); logger.always(""); logger.always(` ${chalk.bold("URL:")} ${chalk.cyan(info.url)}`); logger.always(` ${chalk.bold("Framework:")} ${chalk.cyan(info.framework)}`); logger.always(` ${chalk.bold("Base Path:")} ${chalk.cyan(info.basePath)}`); logger.always(` ${chalk.bold("PID:")} ${chalk.cyan(info.pid)}`); if (info.configFile) { logger.always(` ${chalk.bold("Config:")} ${chalk.cyan(info.configFile)}`); } logger.always(""); logger.always(chalk.bold("Middleware:")); logger.always(` CORS: ${info.corsEnabled ? chalk.green("enabled") : chalk.yellow("disabled")}`); logger.always(` Rate Limit: ${info.rateLimitEnabled ? chalk.green(`enabled (${info.rateLimitValue} req/15min)`) : chalk.yellow("disabled")}`); logger.always(` Swagger: ${info.swaggerEnabled ? chalk.green("enabled") : chalk.yellow("disabled")}`); if (info.watchEnabled) { logger.always(` Watch Mode: ${chalk.green("enabled")}`); } logger.always(""); logger.always(chalk.bold("Available Endpoints:")); logger.always(chalk.gray(" Health & Monitoring:")); logger.always(` ${chalk.green("GET")} ${info.basePath}/health`); logger.always(` ${chalk.green("GET")} ${info.basePath}/ready`); logger.always(` ${chalk.green("GET")} ${info.basePath}/metrics`); logger.always(""); logger.always(chalk.gray(" Agent API:")); logger.always(` ${chalk.blue("POST")} ${info.basePath}/agent/execute`); logger.always(` ${chalk.blue("POST")} ${info.basePath}/agent/stream`); logger.always(` ${chalk.green("GET")} ${info.basePath}/agent/providers`); logger.always(""); logger.always(chalk.gray(" Tools & MCP:")); logger.always(` ${chalk.green("GET")} ${info.basePath}/tools`); logger.always(` ${chalk.blue("POST")} ${info.basePath}/tools/:name/execute`); logger.always(` ${chalk.green("GET")} ${info.basePath}/mcp/servers`); logger.always(""); logger.always(chalk.gray(" Memory:")); logger.always(` ${chalk.green("GET")} ${info.basePath}/memory/sessions`); logger.always(` ${chalk.green("GET")} ${info.basePath}/memory/sessions/:id`); if (info.swaggerEnabled) { logger.always(""); logger.always(chalk.gray(" OpenAPI Documentation:")); logger.always(` ${chalk.green("GET")} ${info.basePath}/openapi.json`); logger.always(` ${chalk.cyan("INFO")} Swagger UI available at ${info.url}${info.basePath}/docs`); } logger.always(""); logger.always(chalk.gray("Press Ctrl+C to stop the server")); logger.always(""); } /** * Set up watch mode if enabled. Returns the stop-watcher function, or null if not enabled. */ static setupWatchMode(argv, ctx) { if (!argv.watch) { return null; } const restartServer = async () => { try { // Stop current server with timeout await withTimeout(ctx.serverRef.current.stop(), 30000, new ServerStartError("Server stop timed out during restart", undefined, ctx.port, ctx.host)); // Re-import server module with cache busting for watch mode const timestamp = Date.now(); const { createServer: createNewServer, registerAllRoutes: registerNewRoutes, } = await import(`../../lib/server/index.js?t=${timestamp}`); // Create new server const newServer = await createNewServer(new NeuroLink(), { framework: ctx.framework, config: ctx.serverConfig, }); registerNewRoutes(newServer, ctx.basePath); // Initialize and start with timeouts await withTimeout(newServer.initialize(), 30000, new ServerStartError("Server initialization timed out during restart", undefined, ctx.port, ctx.host)); await withTimeout(newServer.start(), 30000, new ServerStartError("Server startup timed out during restart", undefined, ctx.port, ctx.host)); // Update the reference so signal handlers use the new server instance ctx.serverRef.current = newServer; logger.always(chalk.green("Server restarted successfully")); } catch (restartError) { logger.error(chalk.red(`Error restarting server: ${restartError instanceof Error ? restartError.message : String(restartError)}`)); } }; const stopWatcher = createFileWatcher(restartServer, argv.quiet ?? false); logger.always(chalk.gray("Watching for file changes in src/ and lib/...")); logger.always(""); return stopWatcher; } /** * Register SIGINT and SIGTERM handlers for graceful shutdown. */ static registerSignalHandlers(serverRef, stopWatcher) { process.on("SIGINT", async () => { logger.always(""); logger.always(chalk.yellow("Shutting down server...")); try { if (stopWatcher) { stopWatcher(); } await serverRef.current.stop(); clearServeState(); logger.always(chalk.green("Server stopped gracefully")); process.exit(0); } catch (error) { logger.error(chalk.red(`Error stopping server: ${error instanceof Error ? error.message : String(error)}`)); process.exit(1); } }); process.on("SIGTERM", async () => { try { if (stopWatcher) { stopWatcher(); } await withTimeout(serverRef.current.stop(), 30000, new Error("Server stop timed out during SIGTERM")); clearServeState(); process.exit(0); } catch (error) { logger.error(chalk.red(`Error stopping server: ${error instanceof Error ? error.message : String(error)}`)); clearServeState(); process.exit(1); } }); } /** * Handle errors during server startup: print error, troubleshooting tips, and exit. */ static handleStartupError(error, argv, spinner) { if (spinner) { spinner.fail(chalk.red("Failed to start server")); } logger.error(chalk.red(`Error: ${error instanceof Error ? error.message : String(error)}`)); if (argv.debug && error instanceof Error && error.stack) { logger.error(chalk.gray(error.stack)); } logger.always(""); logger.always(chalk.bold("Troubleshooting:")); logger.always(" 1. Check if the port is already in use"); logger.always(" 2. Verify the framework is installed (npm install hono/express/fastify/koa)"); logger.always(" 3. Check your config file format if using --config"); logger.always(" 4. Run with --debug for more information"); logger.always(""); process.exit(1); } static async executeStatus(argv) { try { const state = loadServeState(); const status = { running: false, pid: null, port: null, host: null, framework: null, basePath: null, uptime: null, startTime: null, configFile: null, url: null, }; if (state && isProcessRunning(state.pid)) { status.running = true; status.pid = state.pid; status.port = state.port; status.host = state.host; status.framework = state.framework; status.basePath = state.basePath; status.startTime = state.startTime; status.uptime = Date.now() - new Date(state.startTime).getTime(); status.configFile = state.configFile ?? null; status.url = `http://${state.host === "0.0.0.0" ? "localhost" : state.host}:${state.port}`; } if (argv.format === "json") { logger.always(JSON.stringify(status, null, 2)); return; } // Text format logger.always(""); logger.always(chalk.bold.cyan("NeuroLink Server Status")); logger.always(chalk.gray("=".repeat(50))); logger.always(""); if (status.running) { logger.always(` ${chalk.bold("Status:")} ${chalk.green("RUNNING")}`); logger.always(` ${chalk.bold("PID:")} ${chalk.cyan(status.pid)}`); logger.always(` ${chalk.bold("URL:")} ${chalk.cyan(status.url)}`); logger.always(` ${chalk.bold("Framework:")} ${chalk.cyan(status.framework)}`); logger.always(` ${chalk.bold("Base Path:")} ${chalk.cyan(status.basePath)}`); logger.always(` ${chalk.bold("Started:")} ${chalk.cyan(status.startTime)}`); logger.always(` ${chalk.bold("Uptime:")} ${chalk.cyan(formatUptime(status.uptime ?? 0))}`); if (status.configFile) { logger.always(` ${chalk.bold("Config:")} ${chalk.cyan(status.configFile)}`); } } else { logger.always(` ${chalk.bold("Status:")} ${chalk.yellow("NOT RUNNING")}`); logger.always(""); logger.always(chalk.gray(" Start the server with: neurolink serve")); logger.always(chalk.gray(" Or with custom options: neurolink serve --port 8080 --framework express")); } logger.always(""); } catch (error) { logger.error(chalk.red(`Error: ${error instanceof Error ? error.message : String(error)}`)); process.exit(1); } } } export default ServeCommandFactory; //# sourceMappingURL=serve.js.map