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

633 lines (632 loc) 23.9 kB
/** * Hono Server Adapter * Primary server adapter implementation using Hono framework * Hono is chosen for its performance, TypeScript-first design, and edge compatibility */ import { Hono } from "hono"; import { cors } from "hono/cors"; import { HTTPException } from "hono/http-exception"; import { logger as honoLogger } from "hono/logger"; import { secureHeaders } from "hono/secure-headers"; import { streamSSE } from "hono/streaming"; import { timeout } from "hono/timeout"; import { logger } from "../../utils/logger.js"; import { AlreadyRunningError, ServerStopError, wrapError } from "../errors.js"; import { BaseServerAdapter } from "../abstract/baseServerAdapter.js"; import { isErrorResponse } from "../utils/validation.js"; /** * Hono-specific server adapter * Supports multiple runtimes: Bun, Deno, Node.js */ export class HonoServerAdapter extends BaseServerAdapter { app; server; rateLimitStore = new Map(); rateLimitCleanupInterval; // Store context by request ID for sharing between middleware and route handlers requestContextStore = new Map(); constructor(neurolink, config = {}) { super(neurolink, config); } /** * Create rate limiter middleware for Hono */ createRateLimiter() { return async (c, next) => { // Skip rate limiting for excluded paths if (this.config.rateLimit.skipPaths) { for (const skipPath of this.config.rateLimit.skipPaths) { if (c.req.path.startsWith(skipPath)) { return next(); } } } // Use custom keyGenerator if provided, otherwise default to IP-based key let key; if (this.config.rateLimit.keyGenerator) { // Build minimal context for keyGenerator const headers = {}; c.req.raw.headers.forEach((value, name) => { headers[name] = value; }); const minCtx = { requestId: c.req.header("X-Request-ID") || this.generateRequestId(), method: c.req.method, path: c.req.path, headers, query: this.extractQuery(c), params: c.req.param(), neurolink: this.neurolink, toolRegistry: this.toolRegistry, externalServerManager: this.externalServerManager, timestamp: Date.now(), metadata: {}, }; key = this.config.rateLimit.keyGenerator(minCtx); } else { key = c.req.header("x-forwarded-for") || c.req.header("x-real-ip") || "unknown"; } const now = Date.now(); const windowMs = this.config.rateLimit.windowMs; const maxRequests = this.config.rateLimit.maxRequests; let record = this.rateLimitStore.get(key); if (!record || now > record.resetAt) { record = { count: 0, resetAt: now + windowMs }; this.rateLimitStore.set(key, record); } record.count++; // Set rate limit headers c.header("X-RateLimit-Limit", String(maxRequests)); c.header("X-RateLimit-Remaining", String(Math.max(0, maxRequests - record.count))); c.header("X-RateLimit-Reset", String(Math.ceil(record.resetAt / 1000))); if (record.count > maxRequests) { const retryAfter = Math.ceil((record.resetAt - now) / 1000); c.header("Retry-After", String(retryAfter)); return c.json({ error: { code: "RATE_LIMIT_EXCEEDED", message: this.config.rateLimit.message, }, metadata: { timestamp: new Date().toISOString(), retryAfter, }, }, 429); } return next(); }; } /** * Periodically clean up expired rate limit entries */ cleanupRateLimitStore() { const now = Date.now(); for (const [key, entry] of this.rateLimitStore.entries()) { if (now > entry.resetAt) { this.rateLimitStore.delete(key); } } } /** * Initialize Hono framework */ initializeFramework() { this.app = new Hono(); // Add secure headers this.app.use("*", secureHeaders()); // Add CORS if enabled if (this.config.cors.enabled) { this.app.use("*", cors({ origin: this.config.cors.origins, allowMethods: this.config.cors.methods, allowHeaders: this.config.cors.headers, credentials: this.config.cors.credentials, maxAge: this.config.cors.maxAge, })); } // Add timeout middleware this.app.use("*", timeout(this.config.timeout)); // Add logging if enabled if (this.config.logging.enabled) { this.app.use("*", honoLogger()); } // Add rate limiting if enabled if (this.config.rateLimit.enabled) { this.app.use("*", this.createRateLimiter()); // Schedule periodic cleanup of expired rate limit entries (every minute) this.rateLimitCleanupInterval = setInterval(() => this.cleanupRateLimitStore(), 60000); } // Global error handler this.app.onError((error, c) => { const requestId = c.req.header("X-Request-ID") || this.generateRequestId(); logger.error("[HonoAdapter] Request error", { requestId, error: error.message, stack: error.stack, }); this.emit("error", { requestId, error, timestamp: new Date(), }); if (error instanceof HTTPException) { return c.json({ error: { code: `HTTP_${error.status}`, message: error.message, }, metadata: { requestId, timestamp: new Date().toISOString(), }, }, error.status); } return c.json({ error: { code: "INTERNAL_ERROR", message: "An internal error occurred", }, metadata: { requestId, timestamp: new Date().toISOString(), }, }, 500); }); // 404 handler this.app.notFound((c) => { return c.json({ error: { code: "NOT_FOUND", message: `Route ${c.req.method} ${c.req.path} not found`, }, }, 404); }); } /** * Register route with Hono */ registerFrameworkRoute(route) { const method = route.method.toLowerCase(); this.app[method](route.path, async (c) => { const requestId = c.req.header("X-Request-ID") || this.generateRequestId(); const connectionId = `conn-${requestId}`; const startTime = Date.now(); // Track connection for graceful shutdown this.trackConnection(connectionId, undefined, requestId); try { // Extract path parameters const params = c.req.param(); // Reuse existing context from middleware if available, otherwise create new one const existingCtx = this.requestContextStore.get(requestId); const ctx = existingCtx || this.createContext({ requestId, method: c.req.method, path: c.req.path, headers: this.extractHeaders(c), query: this.extractQuery(c), params, body: await this.extractBody(c), }); // Emit request event this.emit("request", { requestId, method: ctx.method, path: ctx.path, timestamp: new Date(), }); // Handle streaming if configured if (route.streaming?.enabled) { return await this.handleStreamingResponse(c, ctx, route); } // Execute handler const result = await route.handler(ctx); const duration = Date.now() - startTime; // Check if result is an error response if (isErrorResponse(result)) { const statusCode = result.httpStatus ?? 500; // Emit response event with error status this.emit("response", { requestId, statusCode, duration, timestamp: new Date(), }); // Apply response headers from middleware if (ctx.responseHeaders) { for (const [key, value] of Object.entries(ctx.responseHeaders)) { c.header(key, value); } } // Return error response with proper status code // Cast to ContentfulStatusCode since we know error codes are valid HTTP status codes return c.json({ error: result.error, metadata: { ...result.metadata, requestId, timestamp: new Date().toISOString(), duration, }, }, statusCode); } // Emit response event this.emit("response", { requestId, statusCode: 200, duration, timestamp: new Date(), }); // Apply response headers from middleware if (ctx.responseHeaders) { for (const [key, value] of Object.entries(ctx.responseHeaders)) { c.header(key, value); } } // Return formatted response return c.json({ data: result, metadata: { requestId, timestamp: new Date().toISOString(), duration, }, }); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); logger.error("[HonoAdapter] Handler error", { requestId, route: route.path, error: errorMessage, }); throw new HTTPException(500, { message: errorMessage }); } finally { // Untrack connection when request completes this.untrackConnection(connectionId); // Clean up context store to prevent memory leaks this.requestContextStore.delete(requestId); } }); } /** * Handle streaming response using SSE */ async handleStreamingResponse(c, ctx, route) { // Apply middleware response headers to streaming response before starting the stream if (ctx.responseHeaders) { for (const [key, value] of Object.entries(ctx.responseHeaders)) { c.header(key, value); } } return streamSSE(c, async (stream) => { try { // Get streaming result from handler const result = await route.handler(ctx); // If result is an async iterable, stream it if (result && typeof result === "object" && Symbol.asyncIterator in result) { for await (const chunk of result) { // Transform raw provider chunks into StreamEvent format // The client SDK expects { type, content, timestamp, ... } objects const streamEvent = this.toStreamEvent(chunk); await stream.writeSSE({ data: JSON.stringify(streamEvent), }); } } else if (result && isErrorResponse(result)) { // Error result, send as error event const errorResult = result; const statusCode = errorResult.httpStatus ?? 500; await stream.writeSSE({ data: JSON.stringify({ type: "error", error: { code: errorResult.error?.code ?? "STREAM_ERROR", message: errorResult.error?.message ?? "Stream error", status: statusCode, }, timestamp: Date.now(), }), }); } else { // Single result, normalize into a StreamEvent so the client can // match on the `type` field via handleEvent() const streamEvent = this.toStreamEvent(result); await stream.writeSSE({ data: JSON.stringify(streamEvent), }); } // Send [DONE] signal that the client SDK expects await stream.writeSSE({ data: "[DONE]", }); } catch (error) { await stream.writeSSE({ data: JSON.stringify({ type: "error", error: { code: "STREAM_ERROR", message: error instanceof Error ? error.message : "Stream error", status: 500, }, timestamp: Date.now(), }), }); // Still send [DONE] after error so the client terminates cleanly await stream.writeSSE({ data: "[DONE]", }); } }); } /** * Transform a raw provider chunk into a StreamEvent * Provider chunks are typically { content: string } objects; * the client SDK expects { type: "text", content: string, timestamp: number } */ toStreamEvent(chunk) { // Already a StreamEvent (has a type field) if (chunk && typeof chunk === "object" && "type" in chunk && typeof chunk.type === "string") { return { ...chunk, timestamp: chunk.timestamp ?? Date.now(), }; } // Raw text chunk from provider: { content: string } if (chunk && typeof chunk === "object" && "content" in chunk) { return { type: "text", content: String(chunk.content), timestamp: Date.now(), }; } // String chunk if (typeof chunk === "string") { return { type: "text", content: chunk, timestamp: Date.now(), }; } // Unknown shape — wrap as metadata event (StreamEventType has no "data" variant) return { type: "metadata", metadata: chunk, timestamp: Date.now(), }; } /** * Register middleware with Hono */ registerFrameworkMiddleware(middleware) { const paths = middleware.paths || ["*"]; for (const path of paths) { this.app.use(path, async (c, next) => { // Skip excluded paths if (middleware.excludePaths?.some((p) => c.req.path.startsWith(p))) { return next(); } // Extract path parameters const params = c.req.param(); // Get or generate request ID for context lookup const requestId = c.req.header("X-Request-ID") || this.generateRequestId(); // Reuse existing context from previous middleware if available let ctx = this.requestContextStore.get(requestId); if (!ctx) { // Create new context for the first middleware ctx = this.createContext({ requestId, method: c.req.method, path: c.req.path, headers: this.extractHeaders(c), query: this.extractQuery(c), params, body: await this.extractBody(c), }); // Store context for sharing between middleware and route handlers this.requestContextStore.set(requestId, ctx); } // Execute middleware return middleware.handler(ctx, next); }); } } /** * Start the Hono server */ async start() { // Validate lifecycle state this.validateLifecycleState("start", ["initialized", "stopped"]); if (this.isRunning) { throw new AlreadyRunningError(this.config.port, this.config.host); } this.lifecycleState = "starting"; const { port, host } = this.config; try { // Check if running in Bun environment if (typeof Bun !== "undefined") { this.server = Bun.serve({ port, hostname: host, fetch: this.app.fetch, }); } else if (typeof Deno !== "undefined") { // Deno runtime this.server = Deno.serve({ port, hostname: host, }, this.app.fetch); } else { // Fallback to Node.js http module via @hono/node-server const { serve } = await import("@hono/node-server"); this.server = serve({ fetch: this.app.fetch, port, hostname: host, }); } this.isRunning = true; this.startTime = new Date(); this.lifecycleState = "running"; logger.info(`[HonoAdapter] Server started on ${host}:${port}`); this.emit("started", { port, host, timestamp: this.startTime, }); } catch (error) { this.lifecycleState = "error"; throw error; } } /** * Stop the Hono server with graceful shutdown */ async stop() { if (!this.isRunning) { return; // Already stopped, return gracefully (idempotent) } const uptime = this.startTime ? Date.now() - this.startTime.getTime() : 0; try { // Use graceful shutdown from base class await this.gracefulShutdown(); // Clean up rate limit cleanup interval if (this.rateLimitCleanupInterval) { clearInterval(this.rateLimitCleanupInterval); this.rateLimitCleanupInterval = undefined; } logger.info("[HonoAdapter] Server stopped", { uptime }); this.emit("stopped", { uptime, timestamp: new Date(), }); // Reset state for restart capability this.resetServerState(); this.server = undefined; this.rateLimitStore.clear(); } catch (error) { const wrappedError = wrapError(error); throw new ServerStopError(wrappedError.message, wrappedError); } } // ============================================ // Lifecycle Methods (Framework-Specific) // ============================================ /** * Stop accepting new connections */ async stopAcceptingConnections() { // For Hono, this is handled by the underlying server logger.debug("[HonoAdapter] Stopping acceptance of new connections"); } /** * Close the underlying server */ async closeServer() { if (!this.server) { return; } // Handle different server types (Bun, Deno, Node.js) const serverObj = this.server; if (typeof serverObj.stop === "function") { // Bun server serverObj.stop(); } else if (typeof serverObj.shutdown === "function") { // Deno server await serverObj.shutdown(); } else if (typeof serverObj.close === "function") { // Node.js http server await new Promise((resolve, reject) => { serverObj.close?.((err) => { if (err) { reject(err); } else { resolve(); } }); }); } } /** * Force close all active connections */ async forceCloseConnections() { logger.info("[HonoAdapter] Force closing connections", { count: this.activeConnections.size, }); if (this.server) { const serverObj = this.server; if (typeof serverObj.closeAllConnections === "function") { serverObj.closeAllConnections(); } } this.activeConnections.clear(); } /** * Get the Hono app instance */ getFrameworkInstance() { return this.app; } // ============================================ // Helper Methods // ============================================ extractHeaders(c) { const headers = {}; c.req.raw.headers.forEach((value, key) => { headers[key] = value; }); return headers; } extractQuery(c) { const query = {}; const url = new URL(c.req.url); url.searchParams.forEach((value, key) => { query[key] = value; }); return query; } async extractBody(c) { if (!this.config.bodyParser.enabled) { return undefined; } const contentType = c.req.header("Content-Type") || ""; if (contentType.includes("application/json")) { try { return await c.req.json(); } catch { return undefined; } } if (contentType.includes("application/x-www-form-urlencoded")) { try { return await c.req.parseBody(); } catch { return undefined; } } return undefined; } }