@juspay/neurolink
Version:
The pipe layer of an AI nervous system: one interface connecting provider neurons to your application, across three inference types — generate, stream and decide. `decide` returns typed, calibrated judgements from a non-generative model (~400ms, ~$0.00002
731 lines (730 loc) • 28.8 kB
JavaScript
/**
* Hono Server Adapter
* Primary server adapter implementation using Hono framework
* Hono is chosen for its performance, TypeScript-first design, and edge compatibility
*/
import { logger } from "../../utils/logger.js";
import { AlreadyRunningError, ServerAdapterError, ServerStopError, wrapError, } from "../errors.js";
import { BaseServerAdapter } from "../abstract/baseServerAdapter.js";
import { isErrorResponse } from "../utils/validation.js";
import { isRawBodyContentType, isWebResponse, parseByteLimit, RawBodyTooLargeError, readRawBody, } from "../utils/rawBody.js";
/**
* Hono-specific server adapter
* Supports multiple runtimes: Bun, Deno, Node.js
*/
/** Raw bodies already read for a request, so a second extraction never hits a consumed stream. */
const rawBodies = new WeakMap();
/** A web `ReadableStream` of bytes as an async iterable of Buffers, releasing the reader on exit. */
async function* webStreamChunks(stream) {
const reader = stream.getReader();
try {
for (;;) {
const { value, done } = await reader.read();
if (done) {
return;
}
if (value) {
yield Buffer.from(value.buffer, value.byteOffset, value.byteLength);
}
}
}
finally {
reader.releaseLock();
}
}
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();
// Populated by initializeFramework's dynamic imports; used by methods that
// run after initialization (route registration, streaming) and so cannot
// rely on initializeFramework's own local closure variables.
HTTPExceptionCtor;
streamSSEFn;
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
* Dynamically imports Hono and its middleware so requiring only another
* adapter (Express/Fastify/Koa) never pulls Hono's import graph in.
*/
async initializeFramework() {
const [{ Hono }, { cors }, { HTTPException }, { logger: honoLogger }, { secureHeaders }, { streamSSE }, { timeout },] = await Promise.all([
this.importFrameworkDependency("hono", "hono"),
this.importFrameworkDependency("hono/cors", "hono"),
this.importFrameworkDependency("hono/http-exception", "hono"),
this.importFrameworkDependency("hono/logger", "hono"),
this.importFrameworkDependency("hono/secure-headers", "hono"),
this.importFrameworkDependency("hono/streaming", "hono"),
this.importFrameworkDependency("hono/timeout", "hono"),
]);
this.HTTPExceptionCtor = HTTPException;
this.streamSSEFn = streamSSE;
this.app = new Hono();
// Add secure headers
this.app.use("*", secureHeaders());
// Add CORS if enabled
if (this.config.cors.enabled) {
this.app.use("*", cors({
// Hono matches a list of origins literally, so `["*"]` (the default)
// matches nothing and no CORS header is sent; the string "*" is the
// wildcard. Pass it as such when the list contains it.
origin: this.config.cors.origins.includes("*")
? "*"
: 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();
this.logRequestError("HonoAdapter", requestId, error);
this.emitRequestError({
requestId,
error,
timestamp: new Date(),
});
if (error instanceof ServerAdapterError) {
return c.json({
error: { code: error.code, message: error.message },
metadata: {
requestId,
timestamp: new Date().toISOString(),
},
}, error.getHttpStatus());
}
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;
// A route that answers in its own wire format returns a web Response.
if (isWebResponse(result)) {
this.emit("response", {
requestId,
statusCode: result.status,
duration,
timestamp: new Date(),
});
for (const [key, value] of Object.entries(ctx.responseHeaders ?? {})) {
if (!result.headers.has(key)) {
result.headers.set(key, value);
}
}
return result;
}
// 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 this.HTTPExceptionCtor(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 this.streamSSEFn(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;
}
getNativeServer() {
// @hono/node-server's serve() returns the http.Server; Bun/Deno do not.
return this.server;
}
// ============================================
// 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;
}
}
// Uploads (multipart, octet-stream, audio/*) reach the route as bytes.
// Over the size limit the body is the error itself rather than a throw:
// a throw from the middleware path would land in onError, which answers
// 500. The route answers 413 (or a validation error) from it.
if (isRawBodyContentType(contentType)) {
const limit = parseByteLimit(this.config.bodyParser.maxSize);
if (Number(c.req.header("Content-Length") ?? 0) > limit) {
return new RawBodyTooLargeError(limit);
}
// Read the web stream chunk by chunk and stop at the limit, so a
// chunked upload without a Content-Length cannot be buffered whole
// (and that before any route logic, including the API-key check, runs).
// The body is extracted more than once per request (middleware and
// route); a web stream can only be read once, so the outcome is kept
// per request the way Hono caches `arrayBuffer()`.
const cached = rawBodies.get(c.req.raw);
if (cached !== undefined) {
return cached;
}
const stream = c.req.raw.body;
if (!stream) {
return Buffer.alloc(0);
}
let outcome;
try {
outcome = await readRawBody(webStreamChunks(stream), limit);
}
catch (error) {
if (!(error instanceof RawBodyTooLargeError)) {
throw error;
}
await stream.cancel().catch(() => undefined);
outcome = error;
}
rawBodies.set(c.req.raw, outcome);
return outcome;
}
return undefined;
}
}