@juspay/neurolink 12.48.1 → 12.48.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -36,6 +36,11 @@ export declare class ServeCommandFactory {
36
36
  * Returns a mutable reference wrapper so signal handlers always access the current server.
37
37
  */
38
38
  private static createAndStartServer;
39
+ /**
40
+ * Put every route except health behind the API key(s) in
41
+ * NEUROLINK_SERVER_API_KEY. Must run before the routes are registered.
42
+ */
43
+ private static applyApiKeyAuth;
39
44
  /**
40
45
  * Save server state and print the startup info banner.
41
46
  */
@@ -16,7 +16,7 @@ import { NeuroLink } from "../../neurolink.js";
16
16
  import { ConfigurationError, ServerStartError, } from "../../server/errors.js";
17
17
  import { withTimeout } from "../../utils/errorHandling.js";
18
18
  import { logger } from "../../utils/logger.js";
19
- import { formatUptime, isProcessRunning, StateFileManager, } from "../utils/serverUtils.js";
19
+ import { clearStateOnExit, createServerApiKeyMiddleware, formatUptime, isOtherServerRunning, isProcessRunning, readServerApiKeys, resolveServerPort, SERVER_API_KEY_ENV, StateFileManager, } from "../utils/serverUtils.js";
20
20
  // ============================================
21
21
  // State Management
22
22
  // ============================================
@@ -143,6 +143,8 @@ export class ServeCommandFactory {
143
143
  command: "serve [subcommand]",
144
144
  describe: "Start NeuroLink HTTP server with server adapters",
145
145
  builder: (yargs) => {
146
+ // The server options carry no yargs defaults: a default would always
147
+ // win over the --config file. Defaults are applied after merging.
146
148
  return yargs
147
149
  .command("status", "Show server status", (yargs) => ServeCommandFactory.buildStatusOptions(yargs), (argv) => ServeCommandFactory.executeStatus(argv))
148
150
  .command("voice", "Start the real-time voice assistant server (OpenAI Realtime / Gemini Live)", (yargs) => yargs.option("port", {
@@ -157,43 +159,36 @@ export class ServeCommandFactory {
157
159
  .option("port", {
158
160
  type: "number",
159
161
  alias: "p",
160
- default: 3000,
161
- description: "Port to listen on",
162
+ description: "Port to listen on (default: config file, then $PORT, then 3000)",
162
163
  })
163
164
  .option("host", {
164
165
  type: "string",
165
166
  alias: "H",
166
- default: "0.0.0.0",
167
- description: "Host to bind to",
167
+ description: "Host to bind to (default: 0.0.0.0)",
168
168
  })
169
169
  .option("framework", {
170
170
  type: "string",
171
171
  alias: "f",
172
172
  choices: ["hono", "express", "fastify", "koa"],
173
- default: "hono",
174
- description: "Web framework to use (hono recommended)",
173
+ description: "Web framework to use (default: hono, recommended)",
175
174
  })
176
175
  .option("basePath", {
177
176
  type: "string",
178
177
  alias: "b",
179
- default: "/api",
180
- description: "Base path for all routes",
178
+ description: "Base path for all routes (default: /api)",
181
179
  })
182
180
  .option("cors", {
183
181
  type: "boolean",
184
- default: true,
185
- description: "Enable CORS middleware",
182
+ description: "Enable CORS middleware (default: true)",
186
183
  })
187
184
  .option("rate-limit", {
188
185
  type: "number",
189
186
  alias: "rateLimit",
190
- default: 100,
191
- description: "Rate limit (requests per 15 min window, 0 to disable)",
187
+ description: "Rate limit (requests per 15 min window, 0 to disable; default: 100)",
192
188
  })
193
189
  .option("swagger", {
194
190
  type: "boolean",
195
- default: false,
196
- description: "Enable OpenAPI/Swagger documentation",
191
+ description: "Enable OpenAPI/Swagger documentation (default: false)",
197
192
  })
198
193
  .option("config", {
199
194
  type: "string",
@@ -224,6 +219,7 @@ export class ServeCommandFactory {
224
219
  .example("neurolink serve --cors --rate-limit 50", "Start server with CORS and rate limiting (50 req/15min)")
225
220
  .example("neurolink serve --swagger", "Start server with OpenAPI documentation enabled")
226
221
  .example("neurolink serve --watch", "Start server in watch mode (restart on changes)")
222
+ .example("NEUROLINK_SERVER_API_KEY=secret neurolink serve", "Require 'Authorization: Bearer secret' (or X-API-Key) on every route except health")
227
223
  .example("neurolink serve status", "Show server status")
228
224
  .help();
229
225
  },
@@ -266,7 +262,7 @@ export class ServeCommandFactory {
266
262
  // Load config file if provided
267
263
  const fileConfig = ServeCommandFactory.loadFileConfig(argv, spinner);
268
264
  // Merge CLI args with file config (CLI takes precedence)
269
- const port = argv.port ?? fileConfig.port ?? 3000;
265
+ const port = resolveServerPort(argv.port, fileConfig.port);
270
266
  const host = argv.host ?? fileConfig.host ?? "0.0.0.0";
271
267
  const framework = argv.framework ?? fileConfig.framework ?? "hono";
272
268
  const basePath = argv.basePath ?? fileConfig.basePath ?? "/api";
@@ -307,7 +303,7 @@ export class ServeCommandFactory {
307
303
  */
308
304
  static guardAlreadyRunning(spinner) {
309
305
  const existingState = loadServeState();
310
- if (existingState && isProcessRunning(existingState.pid)) {
306
+ if (existingState && isOtherServerRunning(existingState.pid)) {
311
307
  if (spinner) {
312
308
  spinner.fail(chalk.red(`Server already running on port ${existingState.port} (PID: ${existingState.pid})`));
313
309
  }
@@ -391,6 +387,7 @@ export class ServeCommandFactory {
391
387
  config: opts.serverConfig,
392
388
  }),
393
389
  };
390
+ ServeCommandFactory.applyApiKeyAuth(serverRef.current, opts.basePath);
394
391
  registerAllRoutes(serverRef.current, opts.basePath);
395
392
  if (spinner) {
396
393
  spinner.text = "Initializing server...";
@@ -399,6 +396,16 @@ export class ServeCommandFactory {
399
396
  await withTimeout(serverRef.current.start(), 30000, new ServerStartError("Server startup timed out after 30 seconds", undefined, opts.port, opts.host));
400
397
  return serverRef;
401
398
  }
399
+ /**
400
+ * Put every route except health behind the API key(s) in
401
+ * NEUROLINK_SERVER_API_KEY. Must run before the routes are registered.
402
+ */
403
+ static applyApiKeyAuth(server, basePath) {
404
+ const apiKeys = readServerApiKeys();
405
+ if (apiKeys.length > 0) {
406
+ server.registerMiddleware(createServerApiKeyMiddleware(apiKeys, basePath));
407
+ }
408
+ }
402
409
  /**
403
410
  * Save server state and print the startup info banner.
404
411
  */
@@ -413,6 +420,7 @@ export class ServeCommandFactory {
413
420
  configFile: opts.argv.config,
414
421
  };
415
422
  saveServeState(state);
423
+ clearStateOnExit(clearServeState);
416
424
  if (spinner) {
417
425
  spinner.succeed(chalk.green("NeuroLink server started successfully"));
418
426
  }
@@ -432,6 +440,7 @@ export class ServeCommandFactory {
432
440
  rateLimitValue,
433
441
  swaggerEnabled,
434
442
  watchEnabled: opts.argv.watch ?? false,
443
+ authEnabled: readServerApiKeys().length > 0,
435
444
  });
436
445
  return state;
437
446
  }
@@ -458,6 +467,7 @@ export class ServeCommandFactory {
458
467
  if (info.watchEnabled) {
459
468
  logger.always(` Watch Mode: ${chalk.green("enabled")}`);
460
469
  }
470
+ logger.always(` API Key: ${info.authEnabled ? chalk.green("required (health routes exempt)") : chalk.yellow(`disabled — set ${SERVER_API_KEY_ENV} before exposing this server`)}`);
461
471
  logger.always("");
462
472
  logger.always(chalk.bold("Available Endpoints:"));
463
473
  logger.always(chalk.gray(" Health & Monitoring:"));
@@ -507,6 +517,7 @@ export class ServeCommandFactory {
507
517
  framework: ctx.framework,
508
518
  config: ctx.serverConfig,
509
519
  });
520
+ ServeCommandFactory.applyApiKeyAuth(newServer, ctx.basePath);
510
521
  registerNewRoutes(newServer, ctx.basePath);
511
522
  // Initialize and start with timeouts
512
523
  await withTimeout(newServer.initialize(), 30000, new ServerStartError("Server initialization timed out during restart", undefined, ctx.port, ctx.host));
@@ -9,7 +9,7 @@ import path from "path";
9
9
  import { logger } from "../../utils/logger.js";
10
10
  import { NeuroLink } from "../../neurolink.js";
11
11
  import { withTimeout } from "../../utils/errorHandling.js";
12
- import { isProcessRunning, formatUptime, ensureStateDir, getNeuroLinkDir, } from "../utils/serverUtils.js";
12
+ import { isProcessRunning, formatUptime, ensureStateDir, getNeuroLinkDir, clearStateOnExit, createServerApiKeyMiddleware, isOtherServerRunning, readServerApiKeys, resolveServerPort, SERVER_API_KEY_ENV, } from "../utils/serverUtils.js";
13
13
  // ============================================
14
14
  // State Management
15
15
  // ============================================
@@ -160,8 +160,7 @@ export class ServerCommandFactory {
160
160
  .option("port", {
161
161
  type: "number",
162
162
  alias: "p",
163
- default: 3000,
164
- description: "Port to listen on",
163
+ description: "Port to listen on (default: $PORT, then 3000)",
165
164
  })
166
165
  .option("host", {
167
166
  type: "string",
@@ -344,7 +343,7 @@ export class ServerCommandFactory {
344
343
  try {
345
344
  // Check if server is already running
346
345
  const existingState = loadServerState();
347
- if (existingState && isProcessRunning(existingState.pid)) {
346
+ if (existingState && isOtherServerRunning(existingState.pid)) {
348
347
  if (spinner) {
349
348
  spinner.fail(chalk.red(`Server already running on port ${existingState.port} (PID: ${existingState.pid})`));
350
349
  }
@@ -355,12 +354,13 @@ export class ServerCommandFactory {
355
354
  const neurolink = new NeuroLink();
356
355
  // Dynamically import server module
357
356
  const { createServer, registerAllRoutes } = await import("../../server/index.js");
357
+ const port = resolveServerPort(argv.port);
358
358
  const framework = (argv.framework ?? "hono");
359
359
  // Create server
360
360
  const server = await createServer(neurolink, {
361
361
  framework,
362
362
  config: {
363
- port: argv.port ?? 3000,
363
+ port,
364
364
  host: argv.host ?? "0.0.0.0",
365
365
  basePath: argv.basePath ?? "/api",
366
366
  cors: {
@@ -372,6 +372,11 @@ export class ServerCommandFactory {
372
372
  disableBuiltInHealth: true, // We register health routes separately
373
373
  },
374
374
  });
375
+ // API key (if configured) must be registered ahead of the routes it guards
376
+ const apiKeys = readServerApiKeys();
377
+ if (apiKeys.length > 0) {
378
+ server.registerMiddleware(createServerApiKeyMiddleware(apiKeys, argv.basePath ?? "/api"));
379
+ }
375
380
  // Register all routes
376
381
  registerAllRoutes(server, argv.basePath ?? "/api");
377
382
  // Initialize and start with timeout
@@ -380,13 +385,14 @@ export class ServerCommandFactory {
380
385
  // Save state
381
386
  const state = {
382
387
  pid: process.pid,
383
- port: argv.port ?? 3000,
388
+ port,
384
389
  host: argv.host ?? "0.0.0.0",
385
390
  framework,
386
391
  startTime: new Date().toISOString(),
387
392
  basePath: argv.basePath ?? "/api",
388
393
  };
389
394
  saveServerState(state);
395
+ clearStateOnExit(clearServerState);
390
396
  if (spinner) {
391
397
  spinner.succeed(chalk.green("Server started successfully"));
392
398
  }
@@ -396,6 +402,7 @@ export class ServerCommandFactory {
396
402
  logger.always(` Framework: ${chalk.cyan(framework)}`);
397
403
  logger.always(` Base Path: ${chalk.cyan(state.basePath)}`);
398
404
  logger.always(` PID: ${chalk.cyan(state.pid)}`);
405
+ logger.always(` API Key: ${apiKeys.length > 0 ? chalk.green("required (health routes exempt)") : chalk.yellow(`disabled — set ${SERVER_API_KEY_ENV} before exposing this server`)}`);
399
406
  logger.always(chalk.bold("\nAvailable Endpoints:"));
400
407
  logger.always(` ${chalk.green("GET")} ${state.basePath}/health`);
401
408
  logger.always(` ${chalk.green("GET")} ${state.basePath}/ready`);
@@ -2,6 +2,22 @@
2
2
  * Server Utilities for NeuroLink CLI
3
3
  * Shared utility functions for server management commands (serve.ts and server.ts)
4
4
  */
5
+ import type { MiddlewareDefinition } from "../../types/index.js";
6
+ export declare const DEFAULT_SERVER_PORT = 3000;
7
+ /** Comma-separated API keys; when set, every route except health needs one. */
8
+ export declare const SERVER_API_KEY_ENV = "NEUROLINK_SERVER_API_KEY";
9
+ /**
10
+ * Resolve the listen port: --port, then the config file, then the PORT
11
+ * environment variable (what container platforms set), then 3000.
12
+ */
13
+ export declare function resolveServerPort(cliPort?: number, filePort?: number): number;
14
+ export declare function readServerApiKeys(): string[];
15
+ /**
16
+ * Require one of `apiKeys` as `Authorization: Bearer <key>` or `X-API-Key`.
17
+ * Health probes stay open so orchestrators can reach them, and the dev
18
+ * playground header cannot bypass a key the operator configured.
19
+ */
20
+ export declare function createServerApiKeyMiddleware(apiKeys: string[], basePath: string): MiddlewareDefinition;
5
21
  /**
6
22
  * Get the base directory for NeuroLink state files
7
23
  * @returns Path to ~/.neurolink directory
@@ -29,6 +45,18 @@ export declare function ensureStateDir(): void;
29
45
  * @returns true if the process is running, false otherwise
30
46
  */
31
47
  export declare function isProcessRunning(pid: number): boolean;
48
+ /**
49
+ * Whether a saved server state belongs to a different, still-running process.
50
+ * A state naming our own PID is stale: a restarted container is PID 1 again
51
+ * and would otherwise refuse to start, believing it is already running.
52
+ */
53
+ export declare function isOtherServerRunning(pid: number): boolean;
54
+ /**
55
+ * Remove this process's state file however it exits. A graceful stop can end
56
+ * the process before its signal handler gets to clean up (once the listener
57
+ * closes, the event loop may drain), so this runs from the "exit" event.
58
+ */
59
+ export declare function clearStateOnExit(clearState: () => void): void;
32
60
  /**
33
61
  * Format a duration in milliseconds to a human-readable uptime string
34
62
  * @param ms - Duration in milliseconds
@@ -2,9 +2,64 @@
2
2
  * Server Utilities for NeuroLink CLI
3
3
  * Shared utility functions for server management commands (serve.ts and server.ts)
4
4
  */
5
+ import { timingSafeEqual } from "crypto";
5
6
  import fs from "fs";
6
7
  import os from "os";
7
8
  import path from "path";
9
+ import { ConfigurationError } from "../../server/errors.js";
10
+ import { createAuthMiddleware } from "../../server/middleware/auth.js";
11
+ // ============================================
12
+ // Listen Address & Access Control
13
+ // ============================================
14
+ export const DEFAULT_SERVER_PORT = 3000;
15
+ /** Comma-separated API keys; when set, every route except health needs one. */
16
+ export const SERVER_API_KEY_ENV = "NEUROLINK_SERVER_API_KEY";
17
+ /**
18
+ * Resolve the listen port: --port, then the config file, then the PORT
19
+ * environment variable (what container platforms set), then 3000.
20
+ */
21
+ export function resolveServerPort(cliPort, filePort) {
22
+ const envPort = process.env.PORT?.trim();
23
+ const port = cliPort ?? filePort ?? (envPort ? Number(envPort) : DEFAULT_SERVER_PORT);
24
+ if (!Number.isInteger(port) || port < 0 || port > 65535) {
25
+ throw new ConfigurationError(`Invalid port "${cliPort ?? filePort ?? envPort}": expected an integer between 0 and 65535`);
26
+ }
27
+ return port;
28
+ }
29
+ export function readServerApiKeys() {
30
+ return (process.env[SERVER_API_KEY_ENV] ?? "")
31
+ .split(",")
32
+ .map((key) => key.trim())
33
+ .filter((key) => key.length > 0);
34
+ }
35
+ /**
36
+ * Constant-time for equal lengths; a length mismatch returns early, which
37
+ * reveals only the key's length, never its content.
38
+ */
39
+ const matchesKey = (key, token) => key.length === token.length && timingSafeEqual(key, token);
40
+ /**
41
+ * Require one of `apiKeys` as `Authorization: Bearer <key>` or `X-API-Key`.
42
+ * Health probes stay open so orchestrators can reach them, and the dev
43
+ * playground header cannot bypass a key the operator configured.
44
+ */
45
+ export function createServerApiKeyMiddleware(apiKeys, basePath) {
46
+ const keys = apiKeys.map((key) => Buffer.from(key, "utf8"));
47
+ return createAuthMiddleware({
48
+ type: "custom",
49
+ extractToken: (ctx) => {
50
+ const bearer = /^Bearer\s+(.+)$/i.exec(ctx.headers["authorization"] ?? "");
51
+ return bearer?.[1] ?? ctx.headers["x-api-key"] ?? null;
52
+ },
53
+ validate: async (token) => {
54
+ const candidate = Buffer.from(token, "utf8");
55
+ return keys.some((key) => matchesKey(key, candidate))
56
+ ? { id: "api-key" }
57
+ : null;
58
+ },
59
+ skipPaths: [`${basePath.replace(/\/+$/, "")}/health`],
60
+ skipDevPlayground: false,
61
+ });
62
+ }
8
63
  // ============================================
9
64
  // State Directory Management
10
65
  // ============================================
@@ -56,6 +111,22 @@ export function isProcessRunning(pid) {
56
111
  return code === "EPERM";
57
112
  }
58
113
  }
114
+ /**
115
+ * Whether a saved server state belongs to a different, still-running process.
116
+ * A state naming our own PID is stale: a restarted container is PID 1 again
117
+ * and would otherwise refuse to start, believing it is already running.
118
+ */
119
+ export function isOtherServerRunning(pid) {
120
+ return pid !== process.pid && isProcessRunning(pid);
121
+ }
122
+ /**
123
+ * Remove this process's state file however it exits. A graceful stop can end
124
+ * the process before its signal handler gets to clean up (once the listener
125
+ * closes, the event loop may drain), so this runs from the "exit" event.
126
+ */
127
+ export function clearStateOnExit(clearState) {
128
+ process.once("exit", clearState);
129
+ }
59
130
  // ============================================
60
131
  // Time Formatting
61
132
  // ============================================
@@ -7,7 +7,7 @@ import { EventEmitter } from "events";
7
7
  import type { ExternalServerManager } from "../../mcp/externalServerManager.js";
8
8
  import type { MCPToolRegistry } from "../../mcp/toolRegistry.js";
9
9
  import type { NeuroLink } from "../../neurolink.js";
10
- import type { MiddlewareDefinition, RedactionConfig, RequiredServerAdapterConfig, RequiredShutdownConfig, RouteDefinition, ServerAdapterConfig, ServerContext, ServerLifecycleState, ServerStatus, TrackedConnection } from "../../types/index.js";
10
+ import type { MiddlewareDefinition, RedactionConfig, RequiredServerAdapterConfig, RequiredShutdownConfig, RouteDefinition, ServerAdapterConfig, ServerAdapterEvents, ServerContext, ServerLifecycleState, ServerStatus, TrackedConnection } from "../../types/index.js";
11
11
  /**
12
12
  * Abstract base class for server adapters
13
13
  * Provides common functionality and defines the interface for framework-specific implementations
@@ -22,6 +22,9 @@ export declare abstract class BaseServerAdapter extends EventEmitter {
22
22
  protected middlewares: MiddlewareDefinition[];
23
23
  protected isRunning: boolean;
24
24
  protected startTime?: Date;
25
+ private frameworkReady;
26
+ private pendingMiddlewares;
27
+ private pendingRouteKeys;
25
28
  protected lifecycleState: ServerLifecycleState;
26
29
  protected activeConnections: Map<string, TrackedConnection>;
27
30
  protected readonly shutdownConfig: RequiredShutdownConfig;
@@ -107,6 +110,23 @@ export declare abstract class BaseServerAdapter extends EventEmitter {
107
110
  * Register custom middleware
108
111
  */
109
112
  registerMiddleware(middleware: MiddlewareDefinition): void;
113
+ /**
114
+ * Log a failed request. A client error (a rejected API key, a failed
115
+ * validation) is routine traffic, so it is a one-line warning rather than
116
+ * an error with a stack trace that anyone could flood the logs with.
117
+ */
118
+ protected logRequestError(source: string, requestId: string | undefined, error: Error): void;
119
+ /**
120
+ * Notify "error" listeners about a failed request. EventEmitter throws when
121
+ * "error" has no listener, which inside a framework's error handler turns
122
+ * every failure into an empty 500, so emit only when someone is listening.
123
+ */
124
+ protected emitRequestError(payload: ServerAdapterEvents["error"]): void;
125
+ /**
126
+ * Hand queued registrations to the framework. A route re-registered under
127
+ * the same key before initialize() is replayed once, in its final form.
128
+ */
129
+ private flushPendingRegistrations;
110
130
  /**
111
131
  * Create request context from incoming request
112
132
  */
@@ -8,7 +8,7 @@ import { getMetricsAggregator, SpanSerializer, SpanStatus, SpanType, } from "../
8
8
  import { withTimeout } from "../../utils/errorHandling.js";
9
9
  import { logger } from "../../utils/logger.js";
10
10
  import { tryImport } from "../../utils/tryImport.js";
11
- import { DrainTimeoutError, InvalidLifecycleStateError, MissingDependencyError, ShutdownTimeoutError, } from "../errors.js";
11
+ import { DrainTimeoutError, InvalidLifecycleStateError, MissingDependencyError, ServerAdapterError, ShutdownTimeoutError, } from "../errors.js";
12
12
  /**
13
13
  * Abstract base class for server adapters
14
14
  * Provides common functionality and defines the interface for framework-specific implementations
@@ -23,6 +23,12 @@ export class BaseServerAdapter extends EventEmitter {
23
23
  middlewares = [];
24
24
  isRunning = false;
25
25
  startTime;
26
+ // Adapters build their framework app lazily in initialize(), so routes and
27
+ // middleware registered before that are queued here and replayed once the
28
+ // app exists — middleware first, so it still guards those routes.
29
+ frameworkReady = false;
30
+ pendingMiddlewares = [];
31
+ pendingRouteKeys = new Set();
26
32
  // Lifecycle management properties
27
33
  lifecycleState = "uninitialized";
28
34
  activeConnections = new Map();
@@ -146,8 +152,11 @@ export class BaseServerAdapter extends EventEmitter {
146
152
  try {
147
153
  // Initialize framework-specific setup
148
154
  await this.initializeFramework();
155
+ this.frameworkReady = true;
149
156
  // Register built-in middleware
150
157
  this.registerBuiltInMiddleware();
158
+ // Replay what callers registered before the framework app existed
159
+ this.flushPendingRegistrations();
151
160
  // Register built-in routes
152
161
  await this.registerBuiltInRoutes();
153
162
  this.lifecycleState = "initialized";
@@ -183,7 +192,12 @@ export class BaseServerAdapter extends EventEmitter {
183
192
  logger.warn(`[ServerAdapter] Route ${routeKey} already exists, replacing`);
184
193
  }
185
194
  this.routes.set(routeKey, route);
186
- this.registerFrameworkRoute(route);
195
+ if (this.frameworkReady) {
196
+ this.registerFrameworkRoute(route);
197
+ }
198
+ else {
199
+ this.pendingRouteKeys.add(routeKey);
200
+ }
187
201
  logger.debug(`[ServerAdapter] Registered route: ${routeKey}`, {
188
202
  description: route.description,
189
203
  streaming: route.streaming?.enabled,
@@ -231,12 +245,66 @@ export class BaseServerAdapter extends EventEmitter {
231
245
  */
232
246
  registerMiddleware(middleware) {
233
247
  this.middlewares.push(middleware);
234
- this.registerFrameworkMiddleware(middleware);
248
+ if (this.frameworkReady) {
249
+ this.registerFrameworkMiddleware(middleware);
250
+ }
251
+ else {
252
+ this.pendingMiddlewares.push(middleware);
253
+ }
235
254
  logger.debug(`[ServerAdapter] Registered middleware: ${middleware.name}`, {
236
255
  order: middleware.order,
237
256
  paths: middleware.paths,
238
257
  });
239
258
  }
259
+ /**
260
+ * Log a failed request. A client error (a rejected API key, a failed
261
+ * validation) is routine traffic, so it is a one-line warning rather than
262
+ * an error with a stack trace that anyone could flood the logs with.
263
+ */
264
+ logRequestError(source, requestId, error) {
265
+ if (error instanceof ServerAdapterError && error.getHttpStatus() < 500) {
266
+ logger.warn(`[${source}] Request rejected`, {
267
+ requestId,
268
+ code: error.code,
269
+ error: error.message,
270
+ });
271
+ return;
272
+ }
273
+ logger.error(`[${source}] Request error`, {
274
+ requestId,
275
+ error: error.message,
276
+ stack: error.stack,
277
+ });
278
+ }
279
+ /**
280
+ * Notify "error" listeners about a failed request. EventEmitter throws when
281
+ * "error" has no listener, which inside a framework's error handler turns
282
+ * every failure into an empty 500, so emit only when someone is listening.
283
+ */
284
+ emitRequestError(payload) {
285
+ if (this.listenerCount("error") > 0) {
286
+ this.emit("error", payload);
287
+ }
288
+ }
289
+ /**
290
+ * Hand queued registrations to the framework. A route re-registered under
291
+ * the same key before initialize() is replayed once, in its final form.
292
+ */
293
+ flushPendingRegistrations() {
294
+ const middlewares = this.pendingMiddlewares;
295
+ const routeKeys = this.pendingRouteKeys;
296
+ this.pendingMiddlewares = [];
297
+ this.pendingRouteKeys = new Set();
298
+ for (const middleware of middlewares) {
299
+ this.registerFrameworkMiddleware(middleware);
300
+ }
301
+ for (const routeKey of routeKeys) {
302
+ const route = this.routes.get(routeKey);
303
+ if (route) {
304
+ this.registerFrameworkRoute(route);
305
+ }
306
+ }
307
+ }
240
308
  /**
241
309
  * Create request context from incoming request
242
310
  */
@@ -239,12 +239,8 @@ export class ExpressServerAdapter extends BaseServerAdapter {
239
239
  this.errorHandlerRegistered = true;
240
240
  this.app.use((error, req, res, _next) => {
241
241
  const requestId = req.headers["x-request-id"];
242
- logger.error("[ExpressAdapter] Request error", {
243
- requestId,
244
- error: error.message,
245
- stack: error.stack,
246
- });
247
- this.emit("error", {
242
+ this.logRequestError("ExpressAdapter", requestId, error);
243
+ this.emitRequestError({
248
244
  requestId,
249
245
  error,
250
246
  timestamp: new Date(),
@@ -4,7 +4,7 @@
4
4
  * Fastify is known for its high performance and low overhead
5
5
  */
6
6
  import { logger } from "../../utils/logger.js";
7
- import { AlreadyRunningError, ServerStartError, ServerStopError, wrapError, } from "../errors.js";
7
+ import { AlreadyRunningError, ServerAdapterError, ServerStartError, ServerStopError, wrapError, } from "../errors.js";
8
8
  import { withTimeout } from "../../utils/errorHandling.js";
9
9
  import { BaseServerAdapter } from "../abstract/baseServerAdapter.js";
10
10
  import { isErrorResponse } from "../utils/validation.js";
@@ -91,17 +91,23 @@ export class FastifyServerAdapter extends BaseServerAdapter {
91
91
  // Global error handler
92
92
  this.app.setErrorHandler((error, request, reply) => {
93
93
  const requestId = request.id;
94
- logger.error("[FastifyAdapter] Request error", {
95
- requestId,
96
- error: error.message,
97
- stack: error.stack,
98
- });
99
- this.emit("error", {
94
+ this.logRequestError("FastifyAdapter", requestId, error);
95
+ this.emitRequestError({
100
96
  requestId,
101
97
  error,
102
98
  timestamp: new Date(),
103
99
  });
104
100
  // Handle different error types
101
+ if (error instanceof ServerAdapterError) {
102
+ reply.status(error.getHttpStatus()).send({
103
+ error: { code: error.code, message: error.message },
104
+ metadata: {
105
+ requestId,
106
+ timestamp: new Date().toISOString(),
107
+ },
108
+ });
109
+ return;
110
+ }
105
111
  if (error.statusCode) {
106
112
  reply.status(error.statusCode).send({
107
113
  error: {
@@ -4,7 +4,7 @@
4
4
  * Hono is chosen for its performance, TypeScript-first design, and edge compatibility
5
5
  */
6
6
  import { logger } from "../../utils/logger.js";
7
- import { AlreadyRunningError, ServerStopError, wrapError } from "../errors.js";
7
+ import { AlreadyRunningError, ServerAdapterError, ServerStopError, wrapError, } from "../errors.js";
8
8
  import { BaseServerAdapter } from "../abstract/baseServerAdapter.js";
9
9
  import { isErrorResponse } from "../utils/validation.js";
10
10
  /**
@@ -154,16 +154,21 @@ export class HonoServerAdapter extends BaseServerAdapter {
154
154
  // Global error handler
155
155
  this.app.onError((error, c) => {
156
156
  const requestId = c.req.header("X-Request-ID") || this.generateRequestId();
157
- logger.error("[HonoAdapter] Request error", {
158
- requestId,
159
- error: error.message,
160
- stack: error.stack,
161
- });
162
- this.emit("error", {
157
+ this.logRequestError("HonoAdapter", requestId, error);
158
+ this.emitRequestError({
163
159
  requestId,
164
160
  error,
165
161
  timestamp: new Date(),
166
162
  });
163
+ if (error instanceof ServerAdapterError) {
164
+ return c.json({
165
+ error: { code: error.code, message: error.message },
166
+ metadata: {
167
+ requestId,
168
+ timestamp: new Date().toISOString(),
169
+ },
170
+ }, error.getHttpStatus());
171
+ }
167
172
  if (error instanceof HTTPException) {
168
173
  return c.json({
169
174
  error: {
@@ -18,6 +18,7 @@ export declare class KoaServerAdapter extends BaseServerAdapter {
18
18
  private rateLimitStore;
19
19
  private rateLimitCleanupInterval?;
20
20
  private sockets;
21
+ private registeredMiddleware;
21
22
  constructor(neurolink: NeuroLink, config?: ServerAdapterConfig);
22
23
  /**
23
24
  * Initialize Koa framework
@@ -52,6 +53,11 @@ export declare class KoaServerAdapter extends BaseServerAdapter {
52
53
  * Register middleware with Koa
53
54
  */
54
55
  protected registerFrameworkMiddleware(middleware: MiddlewareDefinition): void;
56
+ /**
57
+ * Run registered middleware in registration order, then continue to the
58
+ * router. Reads the list per request, so later registrations apply too.
59
+ */
60
+ private runRegisteredMiddleware;
55
61
  /**
56
62
  * Start the Koa server
57
63
  */