@juspay/neurolink 12.48.0 → 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.
- package/CHANGELOG.md +3 -3
- package/dist/browser/neurolink.min.js +118 -118
- package/dist/cli/commands/serve.d.ts +5 -0
- package/dist/cli/commands/serve.js +28 -17
- package/dist/cli/commands/server.js +13 -6
- package/dist/cli/utils/serverUtils.d.ts +28 -0
- package/dist/cli/utils/serverUtils.js +71 -0
- package/dist/server/abstract/baseServerAdapter.d.ts +21 -1
- package/dist/server/abstract/baseServerAdapter.js +71 -3
- package/dist/server/adapters/expressAdapter.js +2 -6
- package/dist/server/adapters/fastifyAdapter.js +13 -7
- package/dist/server/adapters/honoAdapter.js +12 -7
- package/dist/server/adapters/koaAdapter.d.ts +6 -0
- package/dist/server/adapters/koaAdapter.js +28 -10
- package/dist/types/cli.d.ts +1 -0
- package/docs-site/static/search-index.json +3 -2
- package/package.json +1 -1
|
@@ -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:
|
|
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:
|
|
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:
|
|
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
|
|
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 &&
|
|
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 &&
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
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
|
-
|
|
243
|
-
|
|
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
|
-
|
|
95
|
-
|
|
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
|
-
|
|
158
|
-
|
|
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
|
*/
|