@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.
- package/CHANGELOG.md +3 -3
- package/dist/browser/neurolink.min.js +616 -595
- package/dist/cli/commands/serve.d.ts +5 -3
- package/dist/cli/commands/serve.js +113 -20
- package/dist/cli/commands/server.js +13 -6
- package/dist/cli/commands/transcribe.d.ts +24 -0
- package/dist/cli/commands/transcribe.js +718 -0
- package/dist/cli/factories/commandFactory.d.ts +10 -0
- package/dist/cli/factories/commandFactory.js +13 -0
- package/dist/cli/parser.js +3 -0
- package/dist/cli/utils/serverUtils.d.ts +28 -0
- package/dist/cli/utils/serverUtils.js +71 -0
- package/dist/factories/mediaHandlerCatalog.js +11 -9
- package/dist/factories/sttDescriptors.d.ts +63 -0
- package/dist/factories/sttDescriptors.js +286 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.js +7 -1
- package/dist/neurolink.d.ts +68 -1
- package/dist/neurolink.js +202 -4
- package/dist/processors/media/AudioProcessor.d.ts +15 -56
- package/dist/processors/media/AudioProcessor.js +105 -276
- package/dist/processors/media/VideoProcessor.d.ts +3 -5
- package/dist/processors/media/VideoProcessor.js +52 -51
- package/dist/server/abstract/baseServerAdapter.d.ts +28 -1
- package/dist/server/abstract/baseServerAdapter.js +84 -4
- package/dist/server/adapters/expressAdapter.d.ts +1 -0
- package/dist/server/adapters/expressAdapter.js +50 -10
- package/dist/server/adapters/fastifyAdapter.d.ts +1 -0
- package/dist/server/adapters/fastifyAdapter.js +51 -8
- package/dist/server/adapters/honoAdapter.d.ts +1 -4
- package/dist/server/adapters/honoAdapter.js +95 -8
- package/dist/server/adapters/koaAdapter.d.ts +7 -0
- package/dist/server/adapters/koaAdapter.js +71 -10
- package/dist/server/index.d.ts +3 -2
- package/dist/server/index.js +3 -2
- package/dist/server/routes/index.d.ts +1 -0
- package/dist/server/routes/index.js +3 -0
- package/dist/server/routes/transcribeRoutes.d.ts +23 -0
- package/dist/server/routes/transcribeRoutes.js +506 -0
- package/dist/server/utils/rawBody.d.ts +32 -0
- package/dist/server/utils/rawBody.js +76 -0
- package/dist/server/utils/validation.d.ts +192 -0
- package/dist/server/utils/validation.js +100 -0
- package/dist/server/websocket/transcribeWebSocket.d.ts +42 -0
- package/dist/server/websocket/transcribeWebSocket.js +264 -0
- package/dist/types/cli.d.ts +47 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/index.js +1 -0
- package/dist/types/providers.d.ts +7 -1
- package/dist/types/server.d.ts +117 -1
- package/dist/types/stt.d.ts +160 -7
- package/dist/types/transcribe.d.ts +506 -0
- package/dist/types/transcribe.js +29 -0
- package/dist/utils/speakerTurns.d.ts +28 -0
- package/dist/utils/speakerTurns.js +93 -0
- package/dist/utils/sttProcessor.d.ts +20 -2
- package/dist/utils/sttProcessor.js +73 -5
- package/dist/voice/correction/dictionary.d.ts +49 -0
- package/dist/voice/correction/dictionary.js +134 -0
- package/dist/voice/correction/guard.d.ts +35 -0
- package/dist/voice/correction/guard.js +161 -0
- package/dist/voice/correction/index.d.ts +47 -0
- package/dist/voice/correction/index.js +211 -0
- package/dist/voice/correction/rewrite.d.ts +92 -0
- package/dist/voice/correction/rewrite.js +322 -0
- package/dist/voice/errors.d.ts +15 -0
- package/dist/voice/errors.js +45 -0
- package/dist/voice/index.d.ts +20 -2
- package/dist/voice/index.js +75 -9
- package/dist/voice/providers/AzureSTT.d.ts +10 -7
- package/dist/voice/providers/AzureSTT.js +23 -75
- package/dist/voice/providers/DeepgramSTT.d.ts +17 -2
- package/dist/voice/providers/DeepgramSTT.js +109 -13
- package/dist/voice/providers/ElevenLabsSTT.d.ts +12 -2
- package/dist/voice/providers/ElevenLabsSTT.js +34 -8
- package/dist/voice/providers/GoogleSTT.d.ts +10 -8
- package/dist/voice/providers/GoogleSTT.js +21 -75
- package/dist/voice/providers/OpenAISTT.d.ts +30 -9
- package/dist/voice/providers/OpenAISTT.js +196 -21
- package/dist/voice/providers/WhistleSTT.d.ts +87 -0
- package/dist/voice/providers/WhistleSTT.js +313 -0
- package/dist/voice/streaming/chunkedStreamAdapter.d.ts +19 -0
- package/dist/voice/streaming/chunkedStreamAdapter.js +519 -0
- package/dist/voice/streaming/endpointer.d.ts +81 -0
- package/dist/voice/streaming/endpointer.js +247 -0
- package/dist/voice/streaming/localAgreement.d.ts +30 -0
- package/dist/voice/streaming/localAgreement.js +44 -0
- package/dist/voice/sttEnvCredentials.d.ts +12 -0
- package/dist/voice/sttEnvCredentials.js +12 -0
- package/dist/voice/transcribe.d.ts +47 -0
- package/dist/voice/transcribe.js +519 -0
- package/dist/voice/transcribeStream.d.ts +4 -0
- package/dist/voice/transcribeStream.js +584 -0
- package/dist/voice/whistle/assets.d.ts +47 -0
- package/dist/voice/whistle/assets.js +270 -0
- package/dist/voice/whistle/audio.d.ts +32 -0
- package/dist/voice/whistle/audio.js +267 -0
- package/dist/voice/whistle/engine.d.ts +32 -0
- package/dist/voice/whistle/engine.js +193 -0
- package/dist/voice/whistle/worker.d.ts +18 -0
- package/dist/voice/whistle/worker.js +294 -0
- package/docs-site/static/search-index.json +69 -10
- package/models/whistle/README.md +18 -0
- package/models/whistle/needle.js +2 -0
- package/models/whistle/needle.wasm +0 -0
- package/models/whistle/whistle.cact +0 -0
- 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
|
|
217
|
+
/** Timeout for one transcription request in milliseconds */
|
|
215
218
|
TRANSCRIPTION_TIMEOUT_MS: 120_000,
|
|
216
219
|
/**
|
|
217
|
-
*
|
|
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
|
|
857
|
-
* `
|
|
858
|
-
*
|
|
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
|
-
|
|
894
|
-
|
|
895
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
"
|
|
915
|
-
|
|
916
|
-
|
|
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
|
|
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
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
*/
|
|
@@ -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
|
-
|
|
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
|
-
|
|
243
|
-
|
|
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
|
-
|
|
255
|
-
const
|
|
256
|
-
|
|
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
|
}
|
|
@@ -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
|
-
|
|
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
|
-
|
|
95
|
-
|
|
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;
|