@juspay/neurolink 12.48.1 → 12.49.0

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.
Files changed (107) hide show
  1. package/CHANGELOG.md +3 -3
  2. package/dist/browser/neurolink.min.js +616 -595
  3. package/dist/cli/commands/serve.d.ts +5 -3
  4. package/dist/cli/commands/serve.js +113 -20
  5. package/dist/cli/commands/server.js +13 -6
  6. package/dist/cli/commands/transcribe.d.ts +24 -0
  7. package/dist/cli/commands/transcribe.js +718 -0
  8. package/dist/cli/factories/commandFactory.d.ts +10 -0
  9. package/dist/cli/factories/commandFactory.js +13 -0
  10. package/dist/cli/parser.js +3 -0
  11. package/dist/cli/utils/serverUtils.d.ts +28 -0
  12. package/dist/cli/utils/serverUtils.js +71 -0
  13. package/dist/factories/mediaHandlerCatalog.js +11 -9
  14. package/dist/factories/sttDescriptors.d.ts +63 -0
  15. package/dist/factories/sttDescriptors.js +286 -0
  16. package/dist/index.d.ts +4 -1
  17. package/dist/index.js +7 -1
  18. package/dist/neurolink.d.ts +68 -1
  19. package/dist/neurolink.js +202 -4
  20. package/dist/processors/media/AudioProcessor.d.ts +15 -56
  21. package/dist/processors/media/AudioProcessor.js +105 -276
  22. package/dist/processors/media/VideoProcessor.d.ts +3 -5
  23. package/dist/processors/media/VideoProcessor.js +52 -51
  24. package/dist/server/abstract/baseServerAdapter.d.ts +28 -1
  25. package/dist/server/abstract/baseServerAdapter.js +84 -4
  26. package/dist/server/adapters/expressAdapter.d.ts +1 -0
  27. package/dist/server/adapters/expressAdapter.js +50 -10
  28. package/dist/server/adapters/fastifyAdapter.d.ts +1 -0
  29. package/dist/server/adapters/fastifyAdapter.js +51 -8
  30. package/dist/server/adapters/honoAdapter.d.ts +1 -4
  31. package/dist/server/adapters/honoAdapter.js +95 -8
  32. package/dist/server/adapters/koaAdapter.d.ts +7 -0
  33. package/dist/server/adapters/koaAdapter.js +71 -10
  34. package/dist/server/index.d.ts +3 -2
  35. package/dist/server/index.js +3 -2
  36. package/dist/server/routes/index.d.ts +1 -0
  37. package/dist/server/routes/index.js +3 -0
  38. package/dist/server/routes/transcribeRoutes.d.ts +23 -0
  39. package/dist/server/routes/transcribeRoutes.js +506 -0
  40. package/dist/server/utils/rawBody.d.ts +32 -0
  41. package/dist/server/utils/rawBody.js +76 -0
  42. package/dist/server/utils/validation.d.ts +192 -0
  43. package/dist/server/utils/validation.js +100 -0
  44. package/dist/server/websocket/transcribeWebSocket.d.ts +42 -0
  45. package/dist/server/websocket/transcribeWebSocket.js +264 -0
  46. package/dist/types/cli.d.ts +47 -0
  47. package/dist/types/index.d.ts +1 -0
  48. package/dist/types/index.js +1 -0
  49. package/dist/types/providers.d.ts +7 -1
  50. package/dist/types/server.d.ts +117 -1
  51. package/dist/types/stt.d.ts +160 -7
  52. package/dist/types/transcribe.d.ts +506 -0
  53. package/dist/types/transcribe.js +29 -0
  54. package/dist/utils/speakerTurns.d.ts +28 -0
  55. package/dist/utils/speakerTurns.js +93 -0
  56. package/dist/utils/sttProcessor.d.ts +20 -2
  57. package/dist/utils/sttProcessor.js +73 -5
  58. package/dist/voice/correction/dictionary.d.ts +49 -0
  59. package/dist/voice/correction/dictionary.js +134 -0
  60. package/dist/voice/correction/guard.d.ts +35 -0
  61. package/dist/voice/correction/guard.js +161 -0
  62. package/dist/voice/correction/index.d.ts +47 -0
  63. package/dist/voice/correction/index.js +211 -0
  64. package/dist/voice/correction/rewrite.d.ts +92 -0
  65. package/dist/voice/correction/rewrite.js +322 -0
  66. package/dist/voice/errors.d.ts +15 -0
  67. package/dist/voice/errors.js +45 -0
  68. package/dist/voice/index.d.ts +20 -2
  69. package/dist/voice/index.js +75 -9
  70. package/dist/voice/providers/AzureSTT.d.ts +10 -7
  71. package/dist/voice/providers/AzureSTT.js +23 -75
  72. package/dist/voice/providers/DeepgramSTT.d.ts +17 -2
  73. package/dist/voice/providers/DeepgramSTT.js +109 -13
  74. package/dist/voice/providers/ElevenLabsSTT.d.ts +12 -2
  75. package/dist/voice/providers/ElevenLabsSTT.js +34 -8
  76. package/dist/voice/providers/GoogleSTT.d.ts +10 -8
  77. package/dist/voice/providers/GoogleSTT.js +21 -75
  78. package/dist/voice/providers/OpenAISTT.d.ts +30 -9
  79. package/dist/voice/providers/OpenAISTT.js +196 -21
  80. package/dist/voice/providers/WhistleSTT.d.ts +87 -0
  81. package/dist/voice/providers/WhistleSTT.js +313 -0
  82. package/dist/voice/streaming/chunkedStreamAdapter.d.ts +19 -0
  83. package/dist/voice/streaming/chunkedStreamAdapter.js +519 -0
  84. package/dist/voice/streaming/endpointer.d.ts +81 -0
  85. package/dist/voice/streaming/endpointer.js +247 -0
  86. package/dist/voice/streaming/localAgreement.d.ts +30 -0
  87. package/dist/voice/streaming/localAgreement.js +44 -0
  88. package/dist/voice/sttEnvCredentials.d.ts +12 -0
  89. package/dist/voice/sttEnvCredentials.js +12 -0
  90. package/dist/voice/transcribe.d.ts +47 -0
  91. package/dist/voice/transcribe.js +519 -0
  92. package/dist/voice/transcribeStream.d.ts +4 -0
  93. package/dist/voice/transcribeStream.js +584 -0
  94. package/dist/voice/whistle/assets.d.ts +47 -0
  95. package/dist/voice/whistle/assets.js +270 -0
  96. package/dist/voice/whistle/audio.d.ts +32 -0
  97. package/dist/voice/whistle/audio.js +267 -0
  98. package/dist/voice/whistle/engine.d.ts +32 -0
  99. package/dist/voice/whistle/engine.js +193 -0
  100. package/dist/voice/whistle/worker.d.ts +18 -0
  101. package/dist/voice/whistle/worker.js +294 -0
  102. package/docs-site/static/search-index.json +69 -10
  103. package/models/whistle/README.md +18 -0
  104. package/models/whistle/needle.js +2 -0
  105. package/models/whistle/needle.wasm +0 -0
  106. package/models/whistle/whistle.cact +0 -0
  107. package/package.json +7 -3
@@ -51,6 +51,9 @@ import { Readable } from "stream";
51
51
  import { pipeline } from "stream/promises";
52
52
  import { BaseFileProcessor } from "../base/BaseFileProcessor.js";
53
53
  import { formatKeyframeTimestamp, formatMediaDuration, } from "../../utils/mediaDuration.js";
54
+ import { getSTTProviderDescriptor, resolveDefaultSTTProvider, STT_PROVIDER_DESCRIPTORS, } from "../../factories/sttDescriptors.js";
55
+ import { STTProcessor } from "../../utils/sttProcessor.js";
56
+ import { freshEnvSTTCredentials } from "../../voice/sttEnvCredentials.js";
54
57
  import { SIZE_LIMITS_MB } from "../config/index.js";
55
58
  import { extensionsForModality, mimeTypesForModality, } from "../config/fileTypeRegistry.js";
56
59
  import { FileErrorCode } from "../errors/index.js";
@@ -211,10 +214,11 @@ const VIDEO_CONFIG = {
211
214
  * seeking to a handful of offsets, and a meeting recording is long.
212
215
  */
213
216
  AUDIO_EXTRACT_TIMEOUT_MS: 180_000,
214
- /** Timeout for one Whisper transcription request in milliseconds */
217
+ /** Timeout for one transcription request in milliseconds */
215
218
  TRANSCRIPTION_TIMEOUT_MS: 120_000,
216
219
  /**
217
- * Whisper's upload ceiling in MB. The extracted track is mono 16 kHz MP3,
220
+ * Transcription upload ceiling in MB (Whisper's documented limit, and a
221
+ * floor for the other engines). The extracted track is mono 16 kHz MP3,
218
222
  * so this is roughly six hours of speech — a clip that breaches it is
219
223
  * unusual enough to be worth saying so rather than silently truncating.
220
224
  */
@@ -853,11 +857,9 @@ export class VideoProcessor extends BaseFileProcessor {
853
857
  * working implementation of the same thing, and `--transcribe-audio` would
854
858
  * still do nothing.
855
859
  *
856
- * The Whisper call is reproduced here rather than shared with
857
- * `AudioProcessor`: extracting it into a common module is the better
858
- * long-term shape, but that file is being edited concurrently, and a
859
- * merge conflict in the audio pipeline is a worse outcome than fifty
860
- * duplicated lines. The duplication is worth removing once both land.
860
+ * The call goes through `STTProcessor`, like `AudioProcessor` and
861
+ * `transcribe()`, so the default-provider rule, `OPENAI_STT_*` and every
862
+ * STT handler apply to video too.
861
863
  *
862
864
  * ## Failure behaviour
863
865
  *
@@ -890,11 +892,35 @@ export class VideoProcessor extends BaseFileProcessor {
890
892
  if (!metadata.audioCodec) {
891
893
  return skipped("the video has no audio track");
892
894
  }
893
- const apiKey = process.env.OPENAI_API_KEY;
894
- if (!apiKey) {
895
- return skipped("OPENAI_API_KEY is not set, and Whisper is the only transcription backend wired up");
895
+ // The same provider rule as every other transcription: the provider named
896
+ // by NEUROLINK_STT_PROVIDER, else the first configured one, else the
897
+ // built-in local engine when it can run here.
898
+ const provider = resolveDefaultSTTProvider();
899
+ const descriptor = getSTTProviderDescriptor(provider);
900
+ // Same slice AudioProcessor builds, from one shared helper, so audio
901
+ // attachments and video audio tracks can never resolve different endpoints.
902
+ const credentials = descriptor
903
+ ? freshEnvSTTCredentials(descriptor.credentialsKey)
904
+ : {};
905
+ let handler;
906
+ try {
907
+ handler = await STTProcessor.resolveHandler(provider, credentials);
908
+ }
909
+ catch (error) {
910
+ const detail = error instanceof Error ? error.message : String(error);
911
+ return skipped(`the transcription backend could not be loaded — ${detail}`);
896
912
  }
897
- const audioPath = join(tempDir, "audio.mp3");
913
+ if (!handler?.isConfigured()) {
914
+ return skipped(descriptor?.capabilities.local
915
+ ? `no transcription backend is configured — set one of: ${STT_PROVIDER_DESCRIPTORS.flatMap((d) => d.envVars).join(", ")}`
916
+ : `transcription provider "${provider}" is not configured`);
917
+ }
918
+ const label = provider === "whisper" ? "openai-whisper" : provider;
919
+ // MP3 keeps a long recording under the upload ceiling; an engine that
920
+ // does not read MP3 gets 16-bit PCM WAV instead.
921
+ const supported = handler.getSupportedFormats();
922
+ const audioFormat = supported.length === 0 || supported.includes("mp3") ? "mp3" : "wav";
923
+ const audioPath = join(tempDir, `audio.${audioFormat}`);
898
924
  try {
899
925
  await runFfmpeg([
900
926
  "-y",
@@ -905,16 +931,15 @@ export class VideoProcessor extends BaseFileProcessor {
905
931
  // Drop the video stream outright. Without -vn ffmpeg tries to carry
906
932
  // it into an MP3 container as cover art and fails on most inputs.
907
933
  "-vn",
908
- // Mono at 16 kHz is what Whisper resamples to anyway, and it keeps
909
- // an hour-long recording comfortably under the upload ceiling.
934
+ // Mono at 16 kHz is what speech engines resample to anyway, and it
935
+ // keeps an hour-long recording comfortably under the upload ceiling.
910
936
  "-ac",
911
937
  "1",
912
938
  "-ar",
913
939
  "16000",
914
- "-c:a",
915
- "libmp3lame",
916
- "-q:a",
917
- "4",
940
+ ...(audioFormat === "mp3"
941
+ ? ["-c:a", "libmp3lame", "-q:a", "4"]
942
+ : ["-c:a", "pcm_s16le"]),
918
943
  audioPath,
919
944
  ], { timeoutMs: VIDEO_CONFIG.AUDIO_EXTRACT_TIMEOUT_MS });
920
945
  }
@@ -934,52 +959,28 @@ export class VideoProcessor extends BaseFileProcessor {
934
959
  }
935
960
  const sizeMB = audioBuffer.length / (1024 * 1024);
936
961
  if (sizeMB > VIDEO_CONFIG.WHISPER_MAX_SIZE_MB) {
937
- return skipped(`the extracted audio is ${sizeMB.toFixed(1)}MB, over Whisper's ` +
938
- `${VIDEO_CONFIG.WHISPER_MAX_SIZE_MB}MB limit — split the recording`);
962
+ return skipped(`the extracted audio is ${sizeMB.toFixed(1)}MB, over the ` +
963
+ `${VIDEO_CONFIG.WHISPER_MAX_SIZE_MB}MB transcription limit — split the recording`);
939
964
  }
940
- const baseUrl = (process.env.OPENAI_BASE_URL ?? "https://api.openai.com/v1").replace(/\/+$/, "");
941
- const form = new FormData();
942
- form.append("file", new Blob([new Uint8Array(audioBuffer)], { type: "audio/mpeg" }), "audio.mp3");
943
- form.append("model", "whisper-1");
944
- form.append("response_format", "verbose_json");
945
- // `withTimeout` only races a promise against a timer — it cannot cancel
946
- // the request. Without the abort, a timed-out upload keeps its socket and
947
- // its in-flight body alive after the caller has already moved on.
948
- const abort = new AbortController();
949
- const timer = setTimeout(() => abort.abort(), VIDEO_CONFIG.TRANSCRIPTION_TIMEOUT_MS);
950
965
  try {
951
- const response = await withTimeout(fetch(`${baseUrl}/audio/transcriptions`, {
952
- method: "POST",
953
- headers: { Authorization: `Bearer ${apiKey}` },
954
- body: form,
955
- signal: abort.signal,
956
- }), VIDEO_CONFIG.TRANSCRIPTION_TIMEOUT_MS);
957
- if (!response.ok) {
958
- const detail = await response.text().catch(() => "");
959
- return skipped(`the transcription request failed — HTTP ${response.status}` +
960
- `${detail ? `: ${detail.slice(0, 200)}` : ""}`);
961
- }
962
- const payload = await response.json();
963
- const text = typeof payload === "object" &&
964
- payload !== null &&
965
- typeof payload.text === "string"
966
- ? payload.text.trim()
967
- : "";
966
+ const result = await withTimeout(STTProcessor.transcribe(audioBuffer, provider, {
967
+ format: audioFormat,
968
+ timeoutMs: VIDEO_CONFIG.TRANSCRIPTION_TIMEOUT_MS,
969
+ maxAudioBytes: VIDEO_CONFIG.WHISPER_MAX_SIZE_MB * 1024 * 1024,
970
+ }, credentials), VIDEO_CONFIG.TRANSCRIPTION_TIMEOUT_MS);
971
+ const text = result.text.trim();
968
972
  if (text.length === 0) {
969
973
  // A successful call returning nothing is a real outcome — silence,
970
974
  // music, no speech — and not the same as a failure.
971
- return skipped("Whisper returned an empty transcript for this audio");
975
+ return skipped(`${provider === "whisper" ? "Whisper" : label} returned an empty transcript for this audio`);
972
976
  }
973
- logger.debug(`[VideoProcessor] Transcribed ${filename} via openai-whisper (${text.length} chars)`);
977
+ logger.debug(`[VideoProcessor] Transcribed ${filename} via ${label} (${text.length} chars)`);
974
978
  return { transcript: text };
975
979
  }
976
980
  catch (error) {
977
981
  const detail = error instanceof Error ? error.message : String(error);
978
982
  return skipped(`the transcription request failed — ${detail}`);
979
983
  }
980
- finally {
981
- clearTimeout(timer);
982
- }
983
984
  }
984
985
  /**
985
986
  * Extract keyframes from a video at calculated intervals.
@@ -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;
@@ -54,6 +57,13 @@ export declare abstract class BaseServerAdapter extends EventEmitter {
54
57
  * Get the underlying framework instance (for advanced usage)
55
58
  */
56
59
  abstract getFrameworkInstance(): unknown;
60
+ /**
61
+ * The listening Node `http.Server`, once `start()` has run and the runtime
62
+ * is Node (Bun and Deno serve without one). A WebSocket upgrade handler
63
+ * such as `attachTranscribeWebSocket` attaches to it; `undefined` means
64
+ * there is nothing to attach to.
65
+ */
66
+ getNativeServer(): unknown;
57
67
  /**
58
68
  * Stop accepting new connections
59
69
  * Called during graceful shutdown to prevent new requests
@@ -107,6 +117,23 @@ export declare abstract class BaseServerAdapter extends EventEmitter {
107
117
  * Register custom middleware
108
118
  */
109
119
  registerMiddleware(middleware: MiddlewareDefinition): void;
120
+ /**
121
+ * Log a failed request. A client error (a rejected API key, a failed
122
+ * validation) is routine traffic, so it is a one-line warning rather than
123
+ * an error with a stack trace that anyone could flood the logs with.
124
+ */
125
+ protected logRequestError(source: string, requestId: string | undefined, error: Error): void;
126
+ /**
127
+ * Notify "error" listeners about a failed request. EventEmitter throws when
128
+ * "error" has no listener, which inside a framework's error handler turns
129
+ * every failure into an empty 500, so emit only when someone is listening.
130
+ */
131
+ protected emitRequestError(payload: ServerAdapterEvents["error"]): void;
132
+ /**
133
+ * Hand queued registrations to the framework. A route re-registered under
134
+ * the same key before initialize() is replayed once, in its final form.
135
+ */
136
+ private flushPendingRegistrations;
110
137
  /**
111
138
  * Create request context from incoming request
112
139
  */
@@ -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();
@@ -47,7 +53,10 @@ export class BaseServerAdapter extends EventEmitter {
47
53
  basePath: config.basePath ?? "/api",
48
54
  cors: {
49
55
  enabled: config.cors?.enabled ?? true,
50
- origins: config.cors?.origins ?? ["*"],
56
+ // No origin by default: a browser page may call this server only
57
+ // from an origin the operator listed, or from any origin with an
58
+ // explicit ["*"]. The adapters honour the wildcard, so it is opt-in.
59
+ origins: config.cors?.origins ?? [],
51
60
  methods: config.cors?.methods ?? [
52
61
  "GET",
53
62
  "POST",
@@ -92,6 +101,15 @@ export class BaseServerAdapter extends EventEmitter {
92
101
  shutdown: this.shutdownConfig,
93
102
  };
94
103
  }
104
+ /**
105
+ * The listening Node `http.Server`, once `start()` has run and the runtime
106
+ * is Node (Bun and Deno serve without one). A WebSocket upgrade handler
107
+ * such as `attachTranscribeWebSocket` attaches to it; `undefined` means
108
+ * there is nothing to attach to.
109
+ */
110
+ getNativeServer() {
111
+ return undefined;
112
+ }
95
113
  // ============================================
96
114
  // Common Methods (Shared Implementation)
97
115
  // ============================================
@@ -146,8 +164,11 @@ export class BaseServerAdapter extends EventEmitter {
146
164
  try {
147
165
  // Initialize framework-specific setup
148
166
  await this.initializeFramework();
167
+ this.frameworkReady = true;
149
168
  // Register built-in middleware
150
169
  this.registerBuiltInMiddleware();
170
+ // Replay what callers registered before the framework app existed
171
+ this.flushPendingRegistrations();
151
172
  // Register built-in routes
152
173
  await this.registerBuiltInRoutes();
153
174
  this.lifecycleState = "initialized";
@@ -183,7 +204,12 @@ export class BaseServerAdapter extends EventEmitter {
183
204
  logger.warn(`[ServerAdapter] Route ${routeKey} already exists, replacing`);
184
205
  }
185
206
  this.routes.set(routeKey, route);
186
- this.registerFrameworkRoute(route);
207
+ if (this.frameworkReady) {
208
+ this.registerFrameworkRoute(route);
209
+ }
210
+ else {
211
+ this.pendingRouteKeys.add(routeKey);
212
+ }
187
213
  logger.debug(`[ServerAdapter] Registered route: ${routeKey}`, {
188
214
  description: route.description,
189
215
  streaming: route.streaming?.enabled,
@@ -231,12 +257,66 @@ export class BaseServerAdapter extends EventEmitter {
231
257
  */
232
258
  registerMiddleware(middleware) {
233
259
  this.middlewares.push(middleware);
234
- this.registerFrameworkMiddleware(middleware);
260
+ if (this.frameworkReady) {
261
+ this.registerFrameworkMiddleware(middleware);
262
+ }
263
+ else {
264
+ this.pendingMiddlewares.push(middleware);
265
+ }
235
266
  logger.debug(`[ServerAdapter] Registered middleware: ${middleware.name}`, {
236
267
  order: middleware.order,
237
268
  paths: middleware.paths,
238
269
  });
239
270
  }
271
+ /**
272
+ * Log a failed request. A client error (a rejected API key, a failed
273
+ * validation) is routine traffic, so it is a one-line warning rather than
274
+ * an error with a stack trace that anyone could flood the logs with.
275
+ */
276
+ logRequestError(source, requestId, error) {
277
+ if (error instanceof ServerAdapterError && error.getHttpStatus() < 500) {
278
+ logger.warn(`[${source}] Request rejected`, {
279
+ requestId,
280
+ code: error.code,
281
+ error: error.message,
282
+ });
283
+ return;
284
+ }
285
+ logger.error(`[${source}] Request error`, {
286
+ requestId,
287
+ error: error.message,
288
+ stack: error.stack,
289
+ });
290
+ }
291
+ /**
292
+ * Notify "error" listeners about a failed request. EventEmitter throws when
293
+ * "error" has no listener, which inside a framework's error handler turns
294
+ * every failure into an empty 500, so emit only when someone is listening.
295
+ */
296
+ emitRequestError(payload) {
297
+ if (this.listenerCount("error") > 0) {
298
+ this.emit("error", payload);
299
+ }
300
+ }
301
+ /**
302
+ * Hand queued registrations to the framework. A route re-registered under
303
+ * the same key before initialize() is replayed once, in its final form.
304
+ */
305
+ flushPendingRegistrations() {
306
+ const middlewares = this.pendingMiddlewares;
307
+ const routeKeys = this.pendingRouteKeys;
308
+ this.pendingMiddlewares = [];
309
+ this.pendingRouteKeys = new Set();
310
+ for (const middleware of middlewares) {
311
+ this.registerFrameworkMiddleware(middleware);
312
+ }
313
+ for (const routeKey of routeKeys) {
314
+ const route = this.routes.get(routeKey);
315
+ if (route) {
316
+ this.registerFrameworkRoute(route);
317
+ }
318
+ }
319
+ }
240
320
  /**
241
321
  * Create request context from incoming request
242
322
  */
@@ -67,4 +67,5 @@ export declare class ExpressServerAdapter extends BaseServerAdapter {
67
67
  * Get the Express app instance
68
68
  */
69
69
  getFrameworkInstance(): unknown;
70
+ getNativeServer(): unknown;
70
71
  }
@@ -6,6 +6,7 @@ import { logger } from "../../utils/logger.js";
6
6
  import { AlreadyRunningError, ServerAdapterError, ServerStartError, ServerStopError, wrapError, } from "../errors.js";
7
7
  import { BaseServerAdapter } from "../abstract/baseServerAdapter.js";
8
8
  import { isErrorResponse } from "../utils/validation.js";
9
+ import { isRawBodyContentType, isWebResponse, readWebResponse, } from "../utils/rawBody.js";
9
10
  /**
10
11
  * Express-specific server adapter
11
12
  */
@@ -42,13 +43,23 @@ export class ExpressServerAdapter extends BaseServerAdapter {
42
43
  "10mb";
43
44
  this.app.use(express.json({ limit: this.config.bodyParser.jsonLimit || bodyLimit }));
44
45
  this.app.use(express.urlencoded({ extended: true, limit: bodyLimit }));
46
+ // Uploads (multipart, octet-stream, audio/*) reach the route as bytes.
47
+ this.app.use(express.raw({
48
+ type: (req) => isRawBodyContentType(req.headers["content-type"]),
49
+ limit: this.config.bodyParser.maxSize || "10mb",
50
+ }));
45
51
  }
46
52
  // CORS
47
53
  if (this.config.cors.enabled) {
48
54
  const corsModule = await this.importFrameworkDependency("cors", "Express");
49
55
  const cors = corsModule.default;
50
56
  this.app.use(cors({
51
- origin: this.config.cors.origins,
57
+ // A list is matched literally, so the default `["*"]` would match
58
+ // nothing; the wildcard has to be the string "*" (Koa handles it in
59
+ // its origin callback).
60
+ origin: this.config.cors.origins.includes("*")
61
+ ? "*"
62
+ : this.config.cors.origins,
52
63
  methods: this.config.cors.methods,
53
64
  allowedHeaders: this.config.cors.headers,
54
65
  credentials: this.config.cors.credentials,
@@ -170,6 +181,24 @@ export class ExpressServerAdapter extends BaseServerAdapter {
170
181
  // Execute handler
171
182
  const result = await route.handler(ctx);
172
183
  const duration = Date.now() - startTime;
184
+ // A route that answers in its own wire format returns a web Response.
185
+ if (isWebResponse(result)) {
186
+ const raw = await readWebResponse(result);
187
+ this.emit("response", {
188
+ requestId,
189
+ statusCode: raw.status,
190
+ duration,
191
+ timestamp: new Date(),
192
+ });
193
+ for (const [key, value] of Object.entries({
194
+ ...ctx.responseHeaders,
195
+ ...raw.headers,
196
+ })) {
197
+ res.setHeader(key, value);
198
+ }
199
+ res.status(raw.status).send(raw.body);
200
+ return;
201
+ }
173
202
  // Check if result is an error response
174
203
  if (isErrorResponse(result)) {
175
204
  const statusCode = result.httpStatus ?? 500;
@@ -239,21 +268,29 @@ export class ExpressServerAdapter extends BaseServerAdapter {
239
268
  this.errorHandlerRegistered = true;
240
269
  this.app.use((error, req, res, _next) => {
241
270
  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", {
271
+ this.logRequestError("ExpressAdapter", requestId, error);
272
+ this.emitRequestError({
248
273
  requestId,
249
274
  error,
250
275
  timestamp: new Date(),
251
276
  });
252
277
  // Use dynamic status code from ServerAdapterError if available
253
278
  const isServerAdapterError = error instanceof ServerAdapterError;
254
- const statusCode = isServerAdapterError ? error.getHttpStatus() : 500;
255
- const errorCode = isServerAdapterError ? error.code : "INTERNAL_ERROR";
256
- const errorMessage = isServerAdapterError
279
+ // body-parser marks an over-limit body; it is the client's error.
280
+ const tooLarge = !isServerAdapterError &&
281
+ "type" in error &&
282
+ error.type === "entity.too.large";
283
+ const statusCode = isServerAdapterError
284
+ ? error.getHttpStatus()
285
+ : tooLarge
286
+ ? 413
287
+ : 500;
288
+ const errorCode = isServerAdapterError
289
+ ? error.code
290
+ : tooLarge
291
+ ? "PAYLOAD_TOO_LARGE"
292
+ : "INTERNAL_ERROR";
293
+ const errorMessage = isServerAdapterError || tooLarge
257
294
  ? error.message
258
295
  : "An internal error occurred";
259
296
  res.status(statusCode).json({
@@ -493,4 +530,7 @@ export class ExpressServerAdapter extends BaseServerAdapter {
493
530
  getFrameworkInstance() {
494
531
  return this.app;
495
532
  }
533
+ getNativeServer() {
534
+ return this.server;
535
+ }
496
536
  }
@@ -67,4 +67,5 @@ export declare class FastifyServerAdapter extends BaseServerAdapter {
67
67
  * Get the Fastify instance
68
68
  */
69
69
  getFrameworkInstance(): unknown;
70
+ getNativeServer(): unknown;
70
71
  }
@@ -4,10 +4,11 @@
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";
11
+ import { isRawBodyContentType, isWebResponse, readWebResponse, } from "../utils/rawBody.js";
11
12
  /**
12
13
  * Fastify-specific server adapter
13
14
  * Provides high-performance HTTP server with schema validation
@@ -46,11 +47,25 @@ export class FastifyServerAdapter extends BaseServerAdapter {
46
47
  genReqId: () => this.generateRequestId(),
47
48
  bodyLimit: this.parseBodyLimit(this.config.bodyParser.maxSize),
48
49
  });
50
+ // Uploads (multipart, octet-stream, audio/*) reach the route as bytes;
51
+ // without a parser Fastify refuses these content types with a 415.
52
+ if (this.config.bodyParser.enabled) {
53
+ this.app.addContentTypeParser(/^\s*(multipart\/form-data|application\/octet-stream|audio\/)/i, { parseAs: "buffer" }, (request, body, done) => {
54
+ done(null, isRawBodyContentType(request.headers["content-type"])
55
+ ? body
56
+ : undefined);
57
+ });
58
+ }
49
59
  // Register CORS plugin if enabled
50
60
  if (this.config.cors.enabled) {
51
61
  const corsModule = await this.importFrameworkDependency("@fastify/cors", "Fastify");
52
62
  await this.app.register(corsModule.default, {
53
- origin: this.config.cors.origins,
63
+ // A list is matched literally, so the default `["*"]` would match
64
+ // nothing; the wildcard has to be the string "*" (Koa handles it in
65
+ // its origin callback).
66
+ origin: this.config.cors.origins.includes("*")
67
+ ? "*"
68
+ : this.config.cors.origins,
54
69
  methods: this.config.cors.methods,
55
70
  allowedHeaders: this.config.cors.headers,
56
71
  credentials: this.config.cors.credentials,
@@ -91,17 +106,23 @@ export class FastifyServerAdapter extends BaseServerAdapter {
91
106
  // Global error handler
92
107
  this.app.setErrorHandler((error, request, reply) => {
93
108
  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", {
109
+ this.logRequestError("FastifyAdapter", requestId, error);
110
+ this.emitRequestError({
100
111
  requestId,
101
112
  error,
102
113
  timestamp: new Date(),
103
114
  });
104
115
  // Handle different error types
116
+ if (error instanceof ServerAdapterError) {
117
+ reply.status(error.getHttpStatus()).send({
118
+ error: { code: error.code, message: error.message },
119
+ metadata: {
120
+ requestId,
121
+ timestamp: new Date().toISOString(),
122
+ },
123
+ });
124
+ return;
125
+ }
105
126
  if (error.statusCode) {
106
127
  reply.status(error.statusCode).send({
107
128
  error: {
@@ -229,6 +250,24 @@ export class FastifyServerAdapter extends BaseServerAdapter {
229
250
  // Execute handler
230
251
  const result = await route.handler(ctx);
231
252
  const duration = Date.now() - startTime;
253
+ // A route that answers in its own wire format returns a web Response.
254
+ if (isWebResponse(result)) {
255
+ const raw = await readWebResponse(result);
256
+ this.emit("response", {
257
+ requestId,
258
+ statusCode: raw.status,
259
+ duration,
260
+ timestamp: new Date(),
261
+ });
262
+ for (const [key, value] of Object.entries({
263
+ ...ctx.responseHeaders,
264
+ ...raw.headers,
265
+ })) {
266
+ reply.header(key, value);
267
+ }
268
+ reply.status(raw.status);
269
+ return reply.send(raw.body);
270
+ }
232
271
  // Check if result is an error response
233
272
  if (isErrorResponse(result)) {
234
273
  const statusCode = result.httpStatus ?? 500;
@@ -479,4 +518,8 @@ export class FastifyServerAdapter extends BaseServerAdapter {
479
518
  getFrameworkInstance() {
480
519
  return this.app;
481
520
  }
521
+ getNativeServer() {
522
+ // Fastify owns its http.Server and exposes it as `app.server`.
523
+ return this.app?.server;
524
+ }
482
525
  }
@@ -6,10 +6,6 @@
6
6
  import type { NeuroLink } from "../../neurolink.js";
7
7
  import { BaseServerAdapter } from "../abstract/baseServerAdapter.js";
8
8
  import type { MiddlewareDefinition, RouteDefinition, ServerAdapterConfig } from "../../types/index.js";
9
- /**
10
- * Hono-specific server adapter
11
- * Supports multiple runtimes: Bun, Deno, Node.js
12
- */
13
9
  export declare class HonoServerAdapter extends BaseServerAdapter {
14
10
  private app;
15
11
  private server?;
@@ -75,6 +71,7 @@ export declare class HonoServerAdapter extends BaseServerAdapter {
75
71
  * Get the Hono app instance
76
72
  */
77
73
  getFrameworkInstance(): unknown;
74
+ getNativeServer(): unknown;
78
75
  private extractHeaders;
79
76
  private extractQuery;
80
77
  private extractBody;