@cyanmycelium/mcp-broker 0.1.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/.mcp-broker.example/README.md +53 -0
- package/.mcp-broker.example/config.json +32 -0
- package/.mcp-broker.example/grammars/claude/fr.json +7 -0
- package/LICENSE +201 -0
- package/README.md +253 -0
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +208 -0
- package/dist/bin.js.map +1 -0
- package/dist/broker/adapters/broker.adapter.info.d.ts +16 -0
- package/dist/broker/adapters/broker.adapter.info.js +43 -0
- package/dist/broker/adapters/broker.adapter.info.js.map +1 -0
- package/dist/broker/adapters/broker.adapter.providers.d.ts +18 -0
- package/dist/broker/adapters/broker.adapter.providers.js +61 -0
- package/dist/broker/adapters/broker.adapter.providers.js.map +1 -0
- package/dist/broker/behaviors/broker.behavior.info.d.ts +15 -0
- package/dist/broker/behaviors/broker.behavior.info.js +41 -0
- package/dist/broker/behaviors/broker.behavior.info.js.map +1 -0
- package/dist/broker/behaviors/broker.behavior.providers.d.ts +19 -0
- package/dist/broker/behaviors/broker.behavior.providers.js +69 -0
- package/dist/broker/behaviors/broker.behavior.providers.js.map +1 -0
- package/dist/broker/broker.context.d.ts +59 -0
- package/dist/broker/broker.context.js +2 -0
- package/dist/broker/broker.context.js.map +1 -0
- package/dist/broker/broker.grammars.d.ts +166 -0
- package/dist/broker/broker.grammars.js +258 -0
- package/dist/broker/broker.grammars.js.map +1 -0
- package/dist/broker/broker.server.d.ts +64 -0
- package/dist/broker/broker.server.js +111 -0
- package/dist/broker/broker.server.js.map +1 -0
- package/dist/broker/grammars/claude/en.json +16 -0
- package/dist/broker/grammars/claude/fr.json +16 -0
- package/dist/broker/grammars/default/en.json +32 -0
- package/dist/broker/grammars/default/fr.json +32 -0
- package/dist/broker/grammars/default/zh.json +32 -0
- package/dist/broker/index.d.ts +9 -0
- package/dist/broker/index.js +7 -0
- package/dist/broker/index.js.map +1 -0
- package/dist/config.d.ts +101 -0
- package/dist/config.js +61 -0
- package/dist/config.js.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -0
- package/dist/stdio.upstream.d.ts +42 -0
- package/dist/stdio.upstream.js +85 -0
- package/dist/stdio.upstream.js.map +1 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.js +9 -0
- package/dist/version.js.map +1 -0
- package/dist/ws.tunnel.builder.d.ts +133 -0
- package/dist/ws.tunnel.builder.js +197 -0
- package/dist/ws.tunnel.builder.js.map +1 -0
- package/dist/ws.tunnel.d.ts +310 -0
- package/dist/ws.tunnel.js +971 -0
- package/dist/ws.tunnel.js.map +1 -0
- package/package.json +86 -0
- package/scripts/copy-assets.mjs +34 -0
- package/scripts/gen-cert.mjs +94 -0
- package/src/bin.ts +231 -0
- package/src/broker/adapters/broker.adapter.info.ts +46 -0
- package/src/broker/adapters/broker.adapter.providers.ts +67 -0
- package/src/broker/behaviors/broker.behavior.info.ts +46 -0
- package/src/broker/behaviors/broker.behavior.providers.ts +82 -0
- package/src/broker/broker.context.ts +75 -0
- package/src/broker/broker.grammars.ts +336 -0
- package/src/broker/broker.server.ts +168 -0
- package/src/broker/grammars/claude/en.json +16 -0
- package/src/broker/grammars/claude/fr.json +16 -0
- package/src/broker/grammars/default/en.json +32 -0
- package/src/broker/grammars/default/fr.json +32 -0
- package/src/broker/grammars/default/zh.json +32 -0
- package/src/broker/index.ts +24 -0
- package/src/config.ts +155 -0
- package/src/index.ts +26 -0
- package/src/stdio.upstream.ts +114 -0
- package/src/version.ts +10 -0
- package/src/ws.tunnel.builder.ts +214 -0
- package/src/ws.tunnel.ts +1269 -0
package/src/ws.tunnel.ts
ADDED
|
@@ -0,0 +1,1269 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as http from "http";
|
|
3
|
+
import * as https from "https";
|
|
4
|
+
import * as nodePath from "path";
|
|
5
|
+
import { randomUUID } from "crypto";
|
|
6
|
+
import type { IncomingMessage, ServerResponse } from "http";
|
|
7
|
+
import { WebSocket, WebSocketServer } from "ws";
|
|
8
|
+
import type { IMessageTransport, IMcpServer } from "@cyanmycelium/mcp-core";
|
|
9
|
+
import { StdioUpstream, type StdioUpstreamConfig } from "./stdio.upstream.js";
|
|
10
|
+
import { startBrokerServer, BROKER_PROVIDER_NAME } from "./broker/index.js";
|
|
11
|
+
import type { BrokerContext, BrokerLocaleResolver, BrokerProviderInfo, BrokerProviderTransport, BrokerUserAgentResolver } from "./broker/index.js";
|
|
12
|
+
import { VERSION, PACKAGE_NAME } from "./version.js";
|
|
13
|
+
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// Static-file helpers
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
/** Maps file extensions to their HTTP Content-Type values. */
|
|
19
|
+
const MIME: Readonly<Record<string, string>> = {
|
|
20
|
+
".html": "text/html; charset=utf-8",
|
|
21
|
+
".js": "application/javascript; charset=utf-8",
|
|
22
|
+
".mjs": "application/javascript; charset=utf-8",
|
|
23
|
+
".css": "text/css; charset=utf-8",
|
|
24
|
+
".json": "application/json; charset=utf-8",
|
|
25
|
+
".map": "application/json; charset=utf-8",
|
|
26
|
+
".svg": "image/svg+xml",
|
|
27
|
+
".png": "image/png",
|
|
28
|
+
".ico": "image/x-icon",
|
|
29
|
+
".woff2": "font/woff2",
|
|
30
|
+
".woff": "font/woff",
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// Internal types
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Where a JSON-RPC response should be delivered.
|
|
39
|
+
* Either a WebSocket socket (raw WS client), an SSE session (legacy MCP/HTTP),
|
|
40
|
+
* a held-open HTTP response (Streamable HTTP transport, MCP 2025-03-26),
|
|
41
|
+
* or the process stdout (stdio transport for Claude Desktop).
|
|
42
|
+
*/
|
|
43
|
+
type ResponseSink = { type: "ws"; socket: WebSocket } | { type: "sse"; sessionId: string } | { type: "http"; res: ServerResponse } | { type: "stdio" };
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* All mutable state for one named provider slot.
|
|
47
|
+
* Created lazily on first client connection; the WebSocket field is set when
|
|
48
|
+
* the provider actually connects (and cleared on disconnect).
|
|
49
|
+
*/
|
|
50
|
+
interface ProviderState {
|
|
51
|
+
/** The active provider WebSocket, or `null` when the provider is not connected. */
|
|
52
|
+
ws: WebSocket | null;
|
|
53
|
+
/** Pending JSON-RPC request ids → response sinks waiting for a reply. */
|
|
54
|
+
readonly pending: Map<string | number, ResponseSink>;
|
|
55
|
+
/** Active legacy SSE sessions (Claude), keyed by session id. */
|
|
56
|
+
readonly sseSessions: Map<string, ServerResponse>;
|
|
57
|
+
/** Active Streamable HTTP GET streams (MCP Inspector), keyed by session id. */
|
|
58
|
+
readonly mcpGetSessions: Map<string, ServerResponse>;
|
|
59
|
+
/** Raw WebSocket MCP clients connected to this provider. */
|
|
60
|
+
readonly wsClients: Set<WebSocket>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
// Public types
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* A single static-file mount: serves the contents of `dir` under `urlPrefix`.
|
|
69
|
+
*
|
|
70
|
+
* @example
|
|
71
|
+
* { urlPrefix: "/", dir: "/absolute/path/to/www" }
|
|
72
|
+
* { urlPrefix: "/bundle", dir: "/absolute/path/to/bundle" }
|
|
73
|
+
*/
|
|
74
|
+
export interface StaticMount {
|
|
75
|
+
/** URL prefix that triggers this mount (e.g. `"/"` or `"/bundle"`). */
|
|
76
|
+
urlPrefix: string;
|
|
77
|
+
/** Absolute path to the directory to serve. */
|
|
78
|
+
dir: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Configuration options for a {@link WsTunnel} instance.
|
|
83
|
+
*/
|
|
84
|
+
export interface WsTunnelOptions {
|
|
85
|
+
/** TCP port to listen on. */
|
|
86
|
+
port: number;
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Host/interface to bind to.
|
|
90
|
+
* @default "0.0.0.0"
|
|
91
|
+
*/
|
|
92
|
+
host?: string;
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* URL path **prefix** the MCP provider connects to via WebSocket.
|
|
96
|
+
* Each provider appends its name: `<providerPath>/<encodedName>`.
|
|
97
|
+
* @default "/provider"
|
|
98
|
+
*/
|
|
99
|
+
providerPath?: string;
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* URL path for **multiplexed** provider connections.
|
|
103
|
+
* A single WebSocket carries traffic for multiple providers using the
|
|
104
|
+
* envelope protocol `{ provider: string, payload: object }`.
|
|
105
|
+
* @default "/providers"
|
|
106
|
+
*/
|
|
107
|
+
providersPath?: string;
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* URL path raw WebSocket MCP clients connect to.
|
|
111
|
+
* @default "/"
|
|
112
|
+
*/
|
|
113
|
+
clientPath?: string;
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* **Suffix** appended to a provider name for the SSE endpoint.
|
|
117
|
+
* Full URL: `/<providerName>/sse`
|
|
118
|
+
* @default "/sse"
|
|
119
|
+
*/
|
|
120
|
+
ssePath?: string;
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* **Suffix** appended to a provider name for the legacy SSE POST endpoint.
|
|
124
|
+
* Full URL: `/<providerName>/messages`
|
|
125
|
+
* @default "/messages"
|
|
126
|
+
*/
|
|
127
|
+
messagesPath?: string;
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* **Suffix** appended to a provider name for the Streamable HTTP endpoint (MCP 2025-03-26).
|
|
131
|
+
* Full URL: `/<providerName>/mcp`
|
|
132
|
+
* MCP Inspector connects here.
|
|
133
|
+
* @default "/mcp"
|
|
134
|
+
*/
|
|
135
|
+
mcpPath?: string;
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* URL path that returns a `{ files: string[] }` JSON listing of every file
|
|
139
|
+
* inside the `samples/` subdirectory of the root static mount.
|
|
140
|
+
* @default "/__samples_index__"
|
|
141
|
+
*/
|
|
142
|
+
samplesIndexPath?: string;
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Optional static-file mounts served over plain HTTP.
|
|
146
|
+
* Matched by longest URL prefix; directory requests fall back to `index.html`.
|
|
147
|
+
*/
|
|
148
|
+
staticMounts?: StaticMount[];
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Stdio upstream providers. Each entry spawns a child process and wires its
|
|
152
|
+
* stdin/stdout as an MCP transport. Clients reach the process using its `name`
|
|
153
|
+
* directly.
|
|
154
|
+
*
|
|
155
|
+
* If a WebSocket provider connects with the same name as a stdio upstream, the
|
|
156
|
+
* connection is rejected and a warning is logged — stdio takes priority.
|
|
157
|
+
* @default undefined — no stdio providers
|
|
158
|
+
*/
|
|
159
|
+
stdioUpstreams?: StdioUpstreamConfig[];
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Stdio client transport. When set, the broker reads JSON-RPC from
|
|
163
|
+
* `process.stdin` and writes responses to `process.stdout`, bridging an
|
|
164
|
+
* external MCP client (e.g. Claude Desktop) to the named provider.
|
|
165
|
+
*
|
|
166
|
+
* In this mode ALL logging is redirected to stderr so stdout stays clean
|
|
167
|
+
* for the JSON-RPC stream.
|
|
168
|
+
*
|
|
169
|
+
* Claude Desktop config example:
|
|
170
|
+
* ```json
|
|
171
|
+
* {
|
|
172
|
+
* "command": "npx",
|
|
173
|
+
* "args": ["-y", "@cyanmycelium/mcp-broker"],
|
|
174
|
+
* "env": { "MCP_BROKER_STDIO_PROVIDER": "my-provider" }
|
|
175
|
+
* }
|
|
176
|
+
* ```
|
|
177
|
+
* @default undefined — stdio client transport disabled
|
|
178
|
+
*/
|
|
179
|
+
stdioClient?: { providerName: string };
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* TLS configuration. When provided, the server uses HTTPS and WSS instead of HTTP and WS.
|
|
183
|
+
* Both `cert` and `key` must be PEM-encoded strings (file contents, not file paths).
|
|
184
|
+
* Use {@link WsTunnelBuilder.withTlsFiles} to load from disk paths.
|
|
185
|
+
* @default undefined — plain HTTP/WS
|
|
186
|
+
*/
|
|
187
|
+
tls?: {
|
|
188
|
+
/** PEM-encoded TLS certificate. */
|
|
189
|
+
cert: string;
|
|
190
|
+
/** PEM-encoded private key. */
|
|
191
|
+
key: string;
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* When `true` (default), the broker exposes itself as an MCP server under the
|
|
196
|
+
* reserved slot `_broker`. Tier-1 behaviors (`broker_info`, `providers_list`,
|
|
197
|
+
* `provider_status`) become callable at `<host>/_broker/mcp`.
|
|
198
|
+
*
|
|
199
|
+
* Set to `false` to keep the broker invisible to MCP clients.
|
|
200
|
+
* @default true
|
|
201
|
+
*/
|
|
202
|
+
enableBrokerProvider?: boolean;
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Logical name reported by `broker_info`. Useful when running multiple
|
|
206
|
+
* broker instances and you want to tell them apart from the agent side
|
|
207
|
+
* (e.g. `"broker-eu-west"`).
|
|
208
|
+
* @default PACKAGE_NAME — `@cyanmycelium/mcp-broker`
|
|
209
|
+
*/
|
|
210
|
+
brokerName?: string;
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Custom resolver picking the grammar locale for the embedded broker
|
|
214
|
+
* server. Defaults to `defaultBrokerLocaleResolver` (keeps the ISO 639-1
|
|
215
|
+
* prefix of a BCP-47 tag read from `MCP_BROKER_LOCALE`).
|
|
216
|
+
*/
|
|
217
|
+
brokerLocaleResolver?: BrokerLocaleResolver;
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Custom resolver mapping a connecting client's identity to a user-agent
|
|
221
|
+
* family. Defaults to `defaultBrokerUserAgentResolver` (substring match on
|
|
222
|
+
* `clientInfo.name` against known LLM families).
|
|
223
|
+
*/
|
|
224
|
+
brokerUserAgentResolver?: BrokerUserAgentResolver;
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Custom source of the raw locale string fed to the locale resolver.
|
|
228
|
+
* Defaults to `() => process.env.MCP_BROKER_LOCALE`. Override when the
|
|
229
|
+
* locale should come from a config file, HTTP header, etc.
|
|
230
|
+
*/
|
|
231
|
+
brokerLocaleSource?: () => string | undefined;
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Path to a user-supplied grammars directory whose `<userAgent>/<locale>.json`
|
|
235
|
+
* files are merged **on top of** the packaged grammars used by the embedded
|
|
236
|
+
* broker server. Typically pointed at `.mcp-broker/grammars/`.
|
|
237
|
+
*/
|
|
238
|
+
brokerLocalGrammarsDir?: string;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ---------------------------------------------------------------------------
|
|
242
|
+
// WsTunnel
|
|
243
|
+
// ---------------------------------------------------------------------------
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* A multi-provider relay that bridges any number of MCP server instances
|
|
247
|
+
* (the **providers**) with their respective MCP clients.
|
|
248
|
+
*
|
|
249
|
+
* ## Transport overview
|
|
250
|
+
* ```
|
|
251
|
+
* Provider "<name>"
|
|
252
|
+
* ws://host/provider/<name> ← WebSocket registration
|
|
253
|
+
*
|
|
254
|
+
* MCP Inspector (Streamable HTTP, 2025-03-26)
|
|
255
|
+
* GET http://host/<name>/mcp ← persistent SSE notification stream
|
|
256
|
+
* POST http://host/<name>/mcp → JSON-RPC requests
|
|
257
|
+
*
|
|
258
|
+
* Claude (legacy SSE transport)
|
|
259
|
+
* GET http://host/<name>/sse ← SSE notification stream
|
|
260
|
+
* POST http://host/<name>/messages → JSON-RPC requests
|
|
261
|
+
* ```
|
|
262
|
+
*
|
|
263
|
+
* Each provider gets its own isolated set of sessions, pending requests, and
|
|
264
|
+
* notification streams. Multiple providers can be connected simultaneously.
|
|
265
|
+
*/
|
|
266
|
+
export class WsTunnel implements BrokerContext {
|
|
267
|
+
private readonly _options: WsTunnelOptions;
|
|
268
|
+
private _httpServer: http.Server | https.Server | null = null;
|
|
269
|
+
private _wss: WebSocketServer | null = null;
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Per-provider state, keyed by provider name.
|
|
273
|
+
* Created lazily: a slot is allocated the first time any client references
|
|
274
|
+
* a provider name, even before the provider WebSocket connects.
|
|
275
|
+
*/
|
|
276
|
+
private readonly _providers = new Map<string, ProviderState>();
|
|
277
|
+
|
|
278
|
+
/** Maps a multiplexed WebSocket to the set of provider names it feeds. */
|
|
279
|
+
private readonly _multiplexSockets = new Map<WebSocket, Set<string>>();
|
|
280
|
+
|
|
281
|
+
/** Stdio upstream providers, keyed by provider name. */
|
|
282
|
+
private readonly _stdioUpstreams = new Map<string, StdioUpstream>();
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* In-process loopback transports registered as provider slots.
|
|
286
|
+
* Used by the embedded broker server (`_broker`) and any other component
|
|
287
|
+
* that wants to expose itself as a provider without going through a network.
|
|
288
|
+
*/
|
|
289
|
+
private readonly _loopbackProviders = new Map<string, IMessageTransport>();
|
|
290
|
+
|
|
291
|
+
/** The embedded broker MCP server, when {@link WsTunnelOptions.enableBrokerProvider} is on. */
|
|
292
|
+
private _brokerServer: IMcpServer | null = null;
|
|
293
|
+
|
|
294
|
+
/** Provider name that the stdio client transport is bridged to, or null when disabled. */
|
|
295
|
+
private _stdioClientProvider: string | null = null;
|
|
296
|
+
|
|
297
|
+
/** Buffered partial line from stdin (stdio client transport). */
|
|
298
|
+
private _stdioClientBuffer = "";
|
|
299
|
+
|
|
300
|
+
/** Timestamp of the most recent successful `start()`. */
|
|
301
|
+
private _startedAt: Date | null = null;
|
|
302
|
+
|
|
303
|
+
constructor(options: WsTunnelOptions) {
|
|
304
|
+
this._options = options;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// -------------------------------------------------------------------------
|
|
308
|
+
// BrokerContext implementation
|
|
309
|
+
// -------------------------------------------------------------------------
|
|
310
|
+
|
|
311
|
+
get version(): string {
|
|
312
|
+
return VERSION;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
get name(): string {
|
|
316
|
+
return this._options.brokerName ?? PACKAGE_NAME;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
get startedAt(): Date | null {
|
|
320
|
+
return this._startedAt;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
get uptimeSeconds(): number {
|
|
324
|
+
if (!this._startedAt) return 0;
|
|
325
|
+
return Math.floor((Date.now() - this._startedAt.getTime()) / 1000);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
get host(): string | undefined {
|
|
329
|
+
return this._options.host;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
get port(): number {
|
|
333
|
+
return this._options.port;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
get tls(): boolean {
|
|
337
|
+
return !!this._options.tls;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
get paths(): BrokerContext["paths"] {
|
|
341
|
+
const o = this._options;
|
|
342
|
+
return {
|
|
343
|
+
provider: o.providerPath ?? "/provider",
|
|
344
|
+
providers: o.providersPath ?? "/providers",
|
|
345
|
+
client: o.clientPath ?? "/",
|
|
346
|
+
mcp: o.mcpPath ?? "/mcp",
|
|
347
|
+
sse: o.ssePath ?? "/sse",
|
|
348
|
+
messages: o.messagesPath ?? "/messages",
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
public getProvidersInfo(): BrokerProviderInfo[] {
|
|
353
|
+
const out: BrokerProviderInfo[] = [];
|
|
354
|
+
for (const [name, state] of this._providers) {
|
|
355
|
+
out.push(this._buildProviderInfo(name, state));
|
|
356
|
+
}
|
|
357
|
+
return out;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
public getProviderInfo(name: string): BrokerProviderInfo | undefined {
|
|
361
|
+
const state = this._providers.get(name);
|
|
362
|
+
if (!state) return undefined;
|
|
363
|
+
return this._buildProviderInfo(name, state);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
private _buildProviderInfo(name: string, state: ProviderState): BrokerProviderInfo {
|
|
367
|
+
let transport: BrokerProviderTransport;
|
|
368
|
+
let connected: boolean;
|
|
369
|
+
|
|
370
|
+
if (this._loopbackProviders.get(name)?.isOpen) {
|
|
371
|
+
transport = "loopback";
|
|
372
|
+
connected = true;
|
|
373
|
+
} else if (this._stdioUpstreams.get(name)?.isOpen) {
|
|
374
|
+
transport = "stdio";
|
|
375
|
+
connected = true;
|
|
376
|
+
} else if (state.ws?.readyState === WebSocket.OPEN) {
|
|
377
|
+
transport = this._multiplexSockets.has(state.ws) ? "ws-multiplex" : "ws";
|
|
378
|
+
connected = true;
|
|
379
|
+
} else {
|
|
380
|
+
transport = "none";
|
|
381
|
+
connected = false;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
return {
|
|
385
|
+
name,
|
|
386
|
+
transport,
|
|
387
|
+
connected,
|
|
388
|
+
clientCount: state.wsClients.size,
|
|
389
|
+
sessionCount: state.sseSessions.size + state.mcpGetSessions.size,
|
|
390
|
+
pendingCount: state.pending.size,
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// -------------------------------------------------------------------------
|
|
395
|
+
// Loopback provider registration (in-process transports)
|
|
396
|
+
// -------------------------------------------------------------------------
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Registers an in-process transport as a provider slot. Used by the embedded
|
|
400
|
+
* broker server and may be used by application code that wants to host an
|
|
401
|
+
* MCP server inside the same process without opening a real WebSocket.
|
|
402
|
+
*
|
|
403
|
+
* @throws if the name is already used by a stdio upstream or another loopback.
|
|
404
|
+
*/
|
|
405
|
+
public registerLoopbackProvider(name: string, transport: IMessageTransport): void {
|
|
406
|
+
if (this._loopbackProviders.has(name)) {
|
|
407
|
+
throw new Error(`Loopback provider "${name}" is already registered.`);
|
|
408
|
+
}
|
|
409
|
+
if (this._stdioUpstreams.has(name)) {
|
|
410
|
+
throw new Error(`Cannot register loopback "${name}": a stdio upstream with the same name already exists.`);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const state = this._getOrCreateProviderState(name);
|
|
414
|
+
this._loopbackProviders.set(name, transport);
|
|
415
|
+
|
|
416
|
+
transport.onMessage = (data: string) => this._routeFromProvider(state, data);
|
|
417
|
+
transport.onClose = () => {
|
|
418
|
+
this._loopbackProviders.delete(name);
|
|
419
|
+
// Tell every pending sink that the provider is gone, same as for a WS close.
|
|
420
|
+
const error = JSON.stringify({
|
|
421
|
+
jsonrpc: "2.0",
|
|
422
|
+
id: null,
|
|
423
|
+
error: { code: -32000, message: `Provider "${name}" disconnected` },
|
|
424
|
+
});
|
|
425
|
+
for (const sink of state.pending.values()) {
|
|
426
|
+
if (sink.type === "ws" && sink.socket.readyState === WebSocket.OPEN) {
|
|
427
|
+
sink.socket.send(error);
|
|
428
|
+
} else if (sink.type === "sse") {
|
|
429
|
+
const sseRes = state.sseSessions.get(sink.sessionId);
|
|
430
|
+
if (sseRes) this._sendSseEvent(sseRes, error);
|
|
431
|
+
} else if (sink.type === "http") {
|
|
432
|
+
sink.res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
|
|
433
|
+
sink.res.end(error);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
state.pending.clear();
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// -------------------------------------------------------------------------
|
|
441
|
+
// Public state
|
|
442
|
+
// -------------------------------------------------------------------------
|
|
443
|
+
|
|
444
|
+
get isListening(): boolean {
|
|
445
|
+
return this._httpServer?.listening ?? false;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/** Total number of connected MCP clients across all providers. */
|
|
449
|
+
get clientCount(): number {
|
|
450
|
+
let n = 0;
|
|
451
|
+
for (const s of this._providers.values()) {
|
|
452
|
+
n += s.wsClients.size + s.sseSessions.size + s.mcpGetSessions.size;
|
|
453
|
+
}
|
|
454
|
+
return n;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/** Names of all providers that currently have an active connection. */
|
|
458
|
+
get providerNames(): readonly string[] {
|
|
459
|
+
return [...this._providers.entries()].filter(([name, s]) => this._isProviderConnected(name, s)).map(([name]) => name);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/** @deprecated Check `providerNames.length > 0` instead. */
|
|
463
|
+
get hasProvider(): boolean {
|
|
464
|
+
return this.providerNames.length > 0;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// -------------------------------------------------------------------------
|
|
468
|
+
// Lifecycle
|
|
469
|
+
// -------------------------------------------------------------------------
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* Starts the broker. Resolves once the HTTP server is listening.
|
|
473
|
+
*/
|
|
474
|
+
start(): Promise<void> {
|
|
475
|
+
return new Promise((resolve) => {
|
|
476
|
+
const handler = (req: IncomingMessage, res: ServerResponse) => this._handleHttp(req, res);
|
|
477
|
+
this._httpServer = this._options.tls ? https.createServer({ cert: this._options.tls.cert, key: this._options.tls.key }, handler) : http.createServer(handler);
|
|
478
|
+
// Disable perMessageDeflate: payloads may be large base64-encoded blobs
|
|
479
|
+
// (snapshots, images) that are already compressed. Deflating them wastes
|
|
480
|
+
// CPU without reducing size, and caused multi-second stalls in practice.
|
|
481
|
+
this._wss = new WebSocketServer({ server: this._httpServer, perMessageDeflate: false });
|
|
482
|
+
|
|
483
|
+
this._wss.on("connection", (ws: WebSocket, req: IncomingMessage) => {
|
|
484
|
+
const url = req.url ?? "/";
|
|
485
|
+
const providerPath = this._options.providerPath ?? "/provider";
|
|
486
|
+
const providersPath = this._options.providersPath ?? "/providers";
|
|
487
|
+
|
|
488
|
+
if (url === providersPath || url.startsWith(providersPath + "?")) {
|
|
489
|
+
// Multiplexed provider: one WebSocket carries N providers via envelopes.
|
|
490
|
+
this._onMultiplexProviderConnect(ws);
|
|
491
|
+
} else if (url.startsWith(providerPath + "/") || url === providerPath) {
|
|
492
|
+
// Extract name: everything after "<providerPath>/"
|
|
493
|
+
const raw = url.slice(providerPath.length).replace(/^\//, "");
|
|
494
|
+
const name = decodeURIComponent(raw.split("?")[0]) || "(unnamed)";
|
|
495
|
+
this._onProviderConnect(ws, name);
|
|
496
|
+
} else {
|
|
497
|
+
// Raw WS MCP client: URL is "/<providerName>" or "/"
|
|
498
|
+
const raw = url.replace(/^\//, "").split("?")[0];
|
|
499
|
+
const name = decodeURIComponent(raw) || "";
|
|
500
|
+
this._onClientConnect(ws, name);
|
|
501
|
+
}
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
this._httpServer.listen(this._options.port, this._options.host ?? "0.0.0.0", () => {
|
|
505
|
+
// Spawn configured stdio upstream providers.
|
|
506
|
+
for (const cfg of this._options.stdioUpstreams ?? []) {
|
|
507
|
+
const upstream = new StdioUpstream(cfg);
|
|
508
|
+
upstream.onMessage = (data) => {
|
|
509
|
+
const state = this._getOrCreateProviderState(cfg.name);
|
|
510
|
+
this._routeFromProvider(state, data);
|
|
511
|
+
};
|
|
512
|
+
upstream.onError = (err) => {
|
|
513
|
+
console.error(`[broker] ${err.message}`);
|
|
514
|
+
};
|
|
515
|
+
this._stdioUpstreams.set(cfg.name, upstream);
|
|
516
|
+
upstream.connect();
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// Attach stdio client transport if configured.
|
|
520
|
+
// stdin carries Claude Desktop's JSON-RPC requests; stdout carries responses.
|
|
521
|
+
if (this._options.stdioClient) {
|
|
522
|
+
this._stdioClientProvider = this._options.stdioClient.providerName;
|
|
523
|
+
process.stdin.setEncoding("utf8");
|
|
524
|
+
process.stdin.on("data", (chunk: string) => {
|
|
525
|
+
this._stdioClientBuffer += chunk;
|
|
526
|
+
let nl: number;
|
|
527
|
+
while ((nl = this._stdioClientBuffer.indexOf("\n")) !== -1) {
|
|
528
|
+
const line = this._stdioClientBuffer.slice(0, nl).trim();
|
|
529
|
+
this._stdioClientBuffer = this._stdioClientBuffer.slice(nl + 1);
|
|
530
|
+
if (line) {
|
|
531
|
+
const state = this._getOrCreateProviderState(this._stdioClientProvider!);
|
|
532
|
+
this._routeFromStdioClient(state, line);
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
});
|
|
536
|
+
process.stdin.on("end", () => {
|
|
537
|
+
// Client disconnected — nothing to clean up; pending sinks will time out.
|
|
538
|
+
});
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
this._startedAt = new Date();
|
|
542
|
+
|
|
543
|
+
// Spawn the embedded broker server last so it can already report
|
|
544
|
+
// accurate state in its first `broker_info` call.
|
|
545
|
+
void this._maybeStartBrokerServer().then(
|
|
546
|
+
() => resolve(),
|
|
547
|
+
(err: unknown) => {
|
|
548
|
+
console.error("[broker] embedded broker server failed to start:", err);
|
|
549
|
+
// Keep the tunnel up even if the introspection server fails.
|
|
550
|
+
resolve();
|
|
551
|
+
}
|
|
552
|
+
);
|
|
553
|
+
});
|
|
554
|
+
});
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* Starts the in-process MCP server that exposes the broker's own behaviors
|
|
559
|
+
* (`broker_info`, `providers_list`, `provider_status`) under the reserved
|
|
560
|
+
* provider slot `_broker`. No-op when {@link WsTunnelOptions.enableBrokerProvider}
|
|
561
|
+
* is `false`.
|
|
562
|
+
*/
|
|
563
|
+
private async _maybeStartBrokerServer(): Promise<void> {
|
|
564
|
+
if (this._options.enableBrokerProvider === false) return;
|
|
565
|
+
const { server, clientTransport } = await startBrokerServer(this, {
|
|
566
|
+
localeResolver: this._options.brokerLocaleResolver,
|
|
567
|
+
userAgentResolver: this._options.brokerUserAgentResolver,
|
|
568
|
+
localeSource: this._options.brokerLocaleSource,
|
|
569
|
+
localGrammarsDir: this._options.brokerLocalGrammarsDir,
|
|
570
|
+
});
|
|
571
|
+
this._brokerServer = server;
|
|
572
|
+
this.registerLoopbackProvider(BROKER_PROVIDER_NAME, clientTransport);
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/**
|
|
576
|
+
* Gracefully closes all connections and stops the HTTP server.
|
|
577
|
+
*/
|
|
578
|
+
async stop(): Promise<void> {
|
|
579
|
+
// Stop the embedded broker first so it does not see its loopback close
|
|
580
|
+
// as an unexpected disconnect (and to flush any pending broker responses).
|
|
581
|
+
const brokerServer = this._brokerServer;
|
|
582
|
+
this._brokerServer = null;
|
|
583
|
+
if (brokerServer) {
|
|
584
|
+
try {
|
|
585
|
+
await brokerServer.stop();
|
|
586
|
+
} catch {
|
|
587
|
+
/* best-effort; continue tearing down */
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
return new Promise((resolve, reject) => {
|
|
592
|
+
for (const state of this._providers.values()) {
|
|
593
|
+
for (const res of state.sseSessions.values()) res.end();
|
|
594
|
+
state.sseSessions.clear();
|
|
595
|
+
for (const res of state.mcpGetSessions.values()) res.end();
|
|
596
|
+
state.mcpGetSessions.clear();
|
|
597
|
+
for (const client of state.wsClients) client.close();
|
|
598
|
+
state.wsClients.clear();
|
|
599
|
+
state.ws?.close();
|
|
600
|
+
}
|
|
601
|
+
this._providers.clear();
|
|
602
|
+
this._multiplexSockets.clear();
|
|
603
|
+
for (const upstream of this._stdioUpstreams.values()) upstream.close();
|
|
604
|
+
this._stdioUpstreams.clear();
|
|
605
|
+
for (const loopback of this._loopbackProviders.values()) loopback.close();
|
|
606
|
+
this._loopbackProviders.clear();
|
|
607
|
+
this._startedAt = null;
|
|
608
|
+
this._wss?.close();
|
|
609
|
+
this._httpServer?.close((err) => (err ? reject(err) : resolve()));
|
|
610
|
+
});
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
// -------------------------------------------------------------------------
|
|
614
|
+
// HTTP dispatcher
|
|
615
|
+
// -------------------------------------------------------------------------
|
|
616
|
+
|
|
617
|
+
private _handleHttp(req: IncomingMessage, res: ServerResponse): void {
|
|
618
|
+
const method = req.method ?? "GET";
|
|
619
|
+
const rawUrl = (req.url ?? "/").split("?")[0];
|
|
620
|
+
|
|
621
|
+
// CORS
|
|
622
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
623
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
|
|
624
|
+
res.setHeader("Access-Control-Allow-Headers", req.headers["access-control-request-headers"] ?? "Content-Type, Accept, Mcp-Session-Id");
|
|
625
|
+
res.setHeader("Access-Control-Expose-Headers", "Mcp-Session-Id");
|
|
626
|
+
|
|
627
|
+
if (method === "OPTIONS") {
|
|
628
|
+
res.writeHead(204);
|
|
629
|
+
res.end();
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// Samples index (no provider prefix)
|
|
634
|
+
const samplesIndexPath = this._options.samplesIndexPath ?? "/__samples_index__";
|
|
635
|
+
if (method === "GET" && rawUrl === samplesIndexPath) {
|
|
636
|
+
this._handleSamplesIndex(res);
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
// Route /<providerName>/<endpoint>
|
|
641
|
+
const route = this._parseProviderRoute(rawUrl);
|
|
642
|
+
if (route) {
|
|
643
|
+
const { providerName, endpoint } = route;
|
|
644
|
+
const mcpSuffix = (this._options.mcpPath ?? "/mcp").replace(/^\//, "");
|
|
645
|
+
const sseSuffix = (this._options.ssePath ?? "/sse").replace(/^\//, "");
|
|
646
|
+
const messagesSuffix = (this._options.messagesPath ?? "/messages").replace(/^\//, "");
|
|
647
|
+
|
|
648
|
+
if (endpoint === mcpSuffix) {
|
|
649
|
+
if (method === "GET") {
|
|
650
|
+
this._handleMcpGetStream(req, res, providerName);
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
653
|
+
if (method === "POST") {
|
|
654
|
+
this._handleMcpPost(req, res, providerName);
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
if (endpoint === sseSuffix && method === "GET") {
|
|
659
|
+
this._handleSseConnect(req, res, providerName);
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
if (endpoint === messagesSuffix && method === "POST") {
|
|
663
|
+
this._handleSseMessage(req, res, providerName);
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// Static files
|
|
669
|
+
if (this._options.staticMounts?.length) {
|
|
670
|
+
this._serveStatic(req, res);
|
|
671
|
+
} else {
|
|
672
|
+
res.writeHead(404);
|
|
673
|
+
res.end();
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
/**
|
|
678
|
+
* Parses `/<providerName>/<endpoint>` from a URL path.
|
|
679
|
+
* Returns `null` if the URL does not match this two-segment pattern.
|
|
680
|
+
*/
|
|
681
|
+
private _parseProviderRoute(rawUrl: string): { providerName: string; endpoint: string } | null {
|
|
682
|
+
const parts = rawUrl.split("/").filter(Boolean);
|
|
683
|
+
if (parts.length !== 2) return null;
|
|
684
|
+
const providerName = decodeURIComponent(parts[0]);
|
|
685
|
+
const endpoint = decodeURIComponent(parts[1]);
|
|
686
|
+
if (!providerName || !endpoint) return null;
|
|
687
|
+
return { providerName, endpoint };
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
// -------------------------------------------------------------------------
|
|
691
|
+
// MCP / SSE transport (per provider)
|
|
692
|
+
// -------------------------------------------------------------------------
|
|
693
|
+
|
|
694
|
+
/**
|
|
695
|
+
* Handles `GET /<providerName>/sse` — opens a long-lived SSE stream for Claude.
|
|
696
|
+
* Sends an `endpoint` event so Claude knows where to POST its requests.
|
|
697
|
+
*/
|
|
698
|
+
private _handleSseConnect(req: IncomingMessage, res: ServerResponse, providerName: string): void {
|
|
699
|
+
const sessionId = randomUUID();
|
|
700
|
+
const messagesSuffix = (this._options.messagesPath ?? "/messages").replace(/^\//, "");
|
|
701
|
+
const messagesUrl = `/${encodeURIComponent(providerName)}/${messagesSuffix}`;
|
|
702
|
+
|
|
703
|
+
res.writeHead(200, {
|
|
704
|
+
"Content-Type": "text/event-stream",
|
|
705
|
+
"Cache-Control": "no-cache, no-transform",
|
|
706
|
+
Connection: "keep-alive",
|
|
707
|
+
});
|
|
708
|
+
res.write(`event: endpoint\ndata: ${messagesUrl}?sessionId=${sessionId}\n\n`);
|
|
709
|
+
|
|
710
|
+
const state = this._getOrCreateProviderState(providerName);
|
|
711
|
+
state.sseSessions.set(sessionId, res);
|
|
712
|
+
|
|
713
|
+
req.on("close", () => {
|
|
714
|
+
state.sseSessions.delete(sessionId);
|
|
715
|
+
for (const [id, sink] of state.pending) {
|
|
716
|
+
if (sink.type === "sse" && sink.sessionId === sessionId) state.pending.delete(id);
|
|
717
|
+
}
|
|
718
|
+
});
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
/**
|
|
722
|
+
* Handles `POST /<providerName>/messages?sessionId=…` — receives a JSON-RPC
|
|
723
|
+
* request from Claude and forwards it to the provider.
|
|
724
|
+
* Always responds 202 Accepted; the real response arrives over SSE.
|
|
725
|
+
*/
|
|
726
|
+
private _handleSseMessage(req: IncomingMessage, res: ServerResponse, providerName: string): void {
|
|
727
|
+
const params = new URL(req.url ?? "", "http://localhost").searchParams;
|
|
728
|
+
const sessionId = params.get("sessionId") ?? "";
|
|
729
|
+
const state = this._getOrCreateProviderState(providerName);
|
|
730
|
+
|
|
731
|
+
if (!state.sseSessions.has(sessionId)) {
|
|
732
|
+
res.writeHead(400, { "Content-Type": "text/plain" });
|
|
733
|
+
res.end("Unknown or expired session");
|
|
734
|
+
return;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
let body = "";
|
|
738
|
+
req.on("data", (chunk: Buffer) => {
|
|
739
|
+
body += chunk.toString();
|
|
740
|
+
});
|
|
741
|
+
req.on("end", () => {
|
|
742
|
+
try {
|
|
743
|
+
const msg = JSON.parse(body) as { id?: string | number };
|
|
744
|
+
if (msg.id != null) state.pending.set(msg.id, { type: "sse", sessionId });
|
|
745
|
+
} catch {
|
|
746
|
+
/* malformed — forward anyway */
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
if (this._isProviderConnected(providerName, state)) {
|
|
750
|
+
this._sendToProvider(state, providerName, body);
|
|
751
|
+
} else {
|
|
752
|
+
const sseRes = state.sseSessions.get(sessionId);
|
|
753
|
+
if (sseRes) {
|
|
754
|
+
let errId: string | number | null = null;
|
|
755
|
+
try {
|
|
756
|
+
errId = (JSON.parse(body) as { id?: string | number }).id ?? null;
|
|
757
|
+
} catch {
|
|
758
|
+
/* */
|
|
759
|
+
}
|
|
760
|
+
this._sendSseEvent(
|
|
761
|
+
sseRes,
|
|
762
|
+
JSON.stringify({
|
|
763
|
+
jsonrpc: "2.0",
|
|
764
|
+
id: errId,
|
|
765
|
+
error: { code: -32000, message: `Provider "${providerName}" not connected` },
|
|
766
|
+
})
|
|
767
|
+
);
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
res.writeHead(202);
|
|
772
|
+
res.end();
|
|
773
|
+
});
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
/**
|
|
777
|
+
* Handles `POST /<providerName>/mcp` — Streamable HTTP transport (MCP 2025-03-26).
|
|
778
|
+
* Forwards the JSON-RPC request to the provider and holds the HTTP response
|
|
779
|
+
* open until the reply arrives, then writes it as `application/json`.
|
|
780
|
+
*/
|
|
781
|
+
private _handleMcpPost(req: IncomingMessage, res: ServerResponse, providerName: string): void {
|
|
782
|
+
let body = "";
|
|
783
|
+
req.on("data", (chunk: Buffer) => {
|
|
784
|
+
body += chunk.toString();
|
|
785
|
+
});
|
|
786
|
+
req.on("end", () => {
|
|
787
|
+
let msg: { id?: string | number } = {};
|
|
788
|
+
try {
|
|
789
|
+
msg = JSON.parse(body) as { id?: string | number };
|
|
790
|
+
} catch {
|
|
791
|
+
res.writeHead(400, { "Content-Type": "text/plain" });
|
|
792
|
+
res.end("Invalid JSON");
|
|
793
|
+
return;
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
const state = this._getOrCreateProviderState(providerName);
|
|
797
|
+
|
|
798
|
+
if (!this._isProviderConnected(providerName, state)) {
|
|
799
|
+
res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
|
|
800
|
+
res.end(
|
|
801
|
+
JSON.stringify({
|
|
802
|
+
jsonrpc: "2.0",
|
|
803
|
+
id: msg.id ?? null,
|
|
804
|
+
error: { code: -32000, message: `Provider "${providerName}" not connected` },
|
|
805
|
+
})
|
|
806
|
+
);
|
|
807
|
+
return;
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
if (msg.id != null) {
|
|
811
|
+
// Request: hold the response open; reply arrives in _routeFromProvider.
|
|
812
|
+
state.pending.set(msg.id, { type: "http", res });
|
|
813
|
+
} else {
|
|
814
|
+
// Notification: forward and acknowledge immediately.
|
|
815
|
+
res.writeHead(202);
|
|
816
|
+
res.end();
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
this._sendToProvider(state, providerName, body);
|
|
820
|
+
});
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
/**
|
|
824
|
+
* Handles `GET /<providerName>/mcp` — opens a persistent SSE stream per MCP 2025-03-26.
|
|
825
|
+
* Streamable HTTP clients (e.g. MCP Inspector) use this to receive
|
|
826
|
+
* server-initiated notifications without re-polling.
|
|
827
|
+
*/
|
|
828
|
+
private _handleMcpGetStream(req: IncomingMessage, res: ServerResponse, providerName: string): void {
|
|
829
|
+
const sessionId = (req.headers["mcp-session-id"] as string | undefined) ?? randomUUID();
|
|
830
|
+
|
|
831
|
+
res.writeHead(200, {
|
|
832
|
+
"Content-Type": "text/event-stream",
|
|
833
|
+
"Cache-Control": "no-cache, no-transform",
|
|
834
|
+
Connection: "keep-alive",
|
|
835
|
+
"Mcp-Session-Id": sessionId,
|
|
836
|
+
});
|
|
837
|
+
res.write(": stream open\n\n");
|
|
838
|
+
|
|
839
|
+
const state = this._getOrCreateProviderState(providerName);
|
|
840
|
+
state.mcpGetSessions.set(sessionId, res);
|
|
841
|
+
|
|
842
|
+
req.on("close", () => {
|
|
843
|
+
state.mcpGetSessions.delete(sessionId);
|
|
844
|
+
});
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
/** Writes one JSON-RPC message as an SSE `message` event. */
|
|
848
|
+
private _sendSseEvent(res: ServerResponse, data: string): void {
|
|
849
|
+
// data is already a compact JSON string — no need to parse+re-serialize.
|
|
850
|
+
res.write(`event: message\ndata: ${data}\n\n`);
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
// -------------------------------------------------------------------------
|
|
854
|
+
// WebSocket connection handlers
|
|
855
|
+
// -------------------------------------------------------------------------
|
|
856
|
+
|
|
857
|
+
private _onProviderConnect(ws: WebSocket, name: string): void {
|
|
858
|
+
if (this._stdioUpstreams.has(name)) {
|
|
859
|
+
console.warn(
|
|
860
|
+
`[broker] WARNING: WebSocket provider "${name}" rejected — a stdio upstream with the same name is already configured. ` +
|
|
861
|
+
`Rename one of them to avoid the conflict.`
|
|
862
|
+
);
|
|
863
|
+
ws.close(1008, `Provider "${name}" is managed by a stdio upstream`);
|
|
864
|
+
return;
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
if (this._loopbackProviders.has(name)) {
|
|
868
|
+
console.warn(`[broker] WARNING: WebSocket provider "${name}" rejected — the slot is held by an in-process loopback (reserved system slot).`);
|
|
869
|
+
ws.close(1008, `Provider "${name}" is reserved by the broker`);
|
|
870
|
+
return;
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
const existing = this._providers.get(name);
|
|
874
|
+
if (existing?.ws?.readyState === WebSocket.OPEN) {
|
|
875
|
+
ws.close(1008, `Provider "${name}" is already connected`);
|
|
876
|
+
return;
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
const state = this._getOrCreateProviderState(name);
|
|
880
|
+
state.ws = ws;
|
|
881
|
+
|
|
882
|
+
ws.on("message", (data: Buffer) => this._routeFromProvider(state, data.toString()));
|
|
883
|
+
|
|
884
|
+
ws.on("close", () => {
|
|
885
|
+
state.ws = null;
|
|
886
|
+
// Notify all pending sinks that the provider is gone.
|
|
887
|
+
const error = JSON.stringify({
|
|
888
|
+
jsonrpc: "2.0",
|
|
889
|
+
id: null,
|
|
890
|
+
error: { code: -32000, message: `Provider "${name}" disconnected` },
|
|
891
|
+
});
|
|
892
|
+
for (const sink of state.pending.values()) {
|
|
893
|
+
if (sink.type === "ws" && sink.socket.readyState === WebSocket.OPEN) {
|
|
894
|
+
sink.socket.send(error);
|
|
895
|
+
} else if (sink.type === "sse") {
|
|
896
|
+
const sseRes = state.sseSessions.get(sink.sessionId);
|
|
897
|
+
if (sseRes) this._sendSseEvent(sseRes, error);
|
|
898
|
+
} else if (sink.type === "http") {
|
|
899
|
+
sink.res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
|
|
900
|
+
sink.res.end(error);
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
state.pending.clear();
|
|
904
|
+
});
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
private _onClientConnect(ws: WebSocket, providerName: string): void {
|
|
908
|
+
const state = this._getOrCreateProviderState(providerName);
|
|
909
|
+
state.wsClients.add(ws);
|
|
910
|
+
|
|
911
|
+
ws.on("message", (data: Buffer) => this._routeFromClient(ws, state, providerName, data.toString()));
|
|
912
|
+
|
|
913
|
+
ws.on("close", () => {
|
|
914
|
+
state.wsClients.delete(ws);
|
|
915
|
+
for (const [id, sink] of state.pending) {
|
|
916
|
+
if (sink.type === "ws" && sink.socket === ws) state.pending.delete(id);
|
|
917
|
+
}
|
|
918
|
+
});
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
/**
|
|
922
|
+
* Handles a multiplexed provider WebSocket (`/providers`).
|
|
923
|
+
* A single socket carries traffic for multiple providers using the
|
|
924
|
+
* envelope format `{ provider: string, payload: object }`.
|
|
925
|
+
* Provider names are registered lazily on first message.
|
|
926
|
+
*/
|
|
927
|
+
private _onMultiplexProviderConnect(ws: WebSocket): void {
|
|
928
|
+
const providerNames = new Set<string>();
|
|
929
|
+
this._multiplexSockets.set(ws, providerNames);
|
|
930
|
+
|
|
931
|
+
ws.on("message", (data: Buffer) => {
|
|
932
|
+
let envelope: { provider?: string; payload?: unknown };
|
|
933
|
+
try {
|
|
934
|
+
envelope = JSON.parse(data.toString()) as { provider?: string; payload?: unknown };
|
|
935
|
+
} catch {
|
|
936
|
+
return; // malformed — drop
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
const name = envelope.provider;
|
|
940
|
+
if (!name || envelope.payload === undefined) return;
|
|
941
|
+
|
|
942
|
+
// Register provider name lazily on first encounter.
|
|
943
|
+
if (!providerNames.has(name)) {
|
|
944
|
+
if (this._stdioUpstreams.has(name)) {
|
|
945
|
+
console.warn(
|
|
946
|
+
`[broker] WARNING: Multiplexed WebSocket provider "${name}" rejected — a stdio upstream with the same name is already configured. ` +
|
|
947
|
+
`Rename one of them to avoid the conflict.`
|
|
948
|
+
);
|
|
949
|
+
ws.send(
|
|
950
|
+
JSON.stringify({
|
|
951
|
+
provider: name,
|
|
952
|
+
payload: {
|
|
953
|
+
jsonrpc: "2.0",
|
|
954
|
+
id: null,
|
|
955
|
+
error: { code: -32000, message: `Provider "${name}" is managed by a stdio upstream` },
|
|
956
|
+
},
|
|
957
|
+
})
|
|
958
|
+
);
|
|
959
|
+
return;
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
if (this._loopbackProviders.has(name)) {
|
|
963
|
+
ws.send(
|
|
964
|
+
JSON.stringify({
|
|
965
|
+
provider: name,
|
|
966
|
+
payload: {
|
|
967
|
+
jsonrpc: "2.0",
|
|
968
|
+
id: null,
|
|
969
|
+
error: { code: -32000, message: `Provider "${name}" is reserved by the broker` },
|
|
970
|
+
},
|
|
971
|
+
})
|
|
972
|
+
);
|
|
973
|
+
return;
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
const existing = this._providers.get(name);
|
|
977
|
+
if (existing?.ws?.readyState === WebSocket.OPEN) {
|
|
978
|
+
// Provider already connected via another socket — reject this name.
|
|
979
|
+
ws.send(
|
|
980
|
+
JSON.stringify({
|
|
981
|
+
provider: name,
|
|
982
|
+
payload: {
|
|
983
|
+
jsonrpc: "2.0",
|
|
984
|
+
id: null,
|
|
985
|
+
error: { code: -32000, message: `Provider "${name}" is already connected` },
|
|
986
|
+
},
|
|
987
|
+
})
|
|
988
|
+
);
|
|
989
|
+
return;
|
|
990
|
+
}
|
|
991
|
+
providerNames.add(name);
|
|
992
|
+
const state = this._getOrCreateProviderState(name);
|
|
993
|
+
state.ws = ws;
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
const state = this._providers.get(name)!;
|
|
997
|
+
this._routeFromProvider(state, JSON.stringify(envelope.payload));
|
|
998
|
+
});
|
|
999
|
+
|
|
1000
|
+
ws.on("close", () => {
|
|
1001
|
+
for (const name of providerNames) {
|
|
1002
|
+
const state = this._providers.get(name);
|
|
1003
|
+
if (state && state.ws === ws) {
|
|
1004
|
+
state.ws = null;
|
|
1005
|
+
// Notify pending sinks that the provider is gone.
|
|
1006
|
+
const error = JSON.stringify({
|
|
1007
|
+
jsonrpc: "2.0",
|
|
1008
|
+
id: null,
|
|
1009
|
+
error: { code: -32000, message: `Provider "${name}" disconnected` },
|
|
1010
|
+
});
|
|
1011
|
+
for (const sink of state.pending.values()) {
|
|
1012
|
+
if (sink.type === "ws" && sink.socket.readyState === WebSocket.OPEN) {
|
|
1013
|
+
sink.socket.send(error);
|
|
1014
|
+
} else if (sink.type === "sse") {
|
|
1015
|
+
const sseRes = state.sseSessions.get(sink.sessionId);
|
|
1016
|
+
if (sseRes) this._sendSseEvent(sseRes, error);
|
|
1017
|
+
} else if (sink.type === "http") {
|
|
1018
|
+
sink.res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
|
|
1019
|
+
sink.res.end(error);
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
state.pending.clear();
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
this._multiplexSockets.delete(ws);
|
|
1026
|
+
});
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
// -------------------------------------------------------------------------
|
|
1030
|
+
// Message routing
|
|
1031
|
+
// -------------------------------------------------------------------------
|
|
1032
|
+
|
|
1033
|
+
/**
|
|
1034
|
+
* Sends a raw JSON-RPC message to a provider, wrapping it in a multiplex
|
|
1035
|
+
* envelope when the provider's WebSocket is a multiplexed connection.
|
|
1036
|
+
*/
|
|
1037
|
+
private _sendToProvider(state: ProviderState, providerName: string, data: string): void {
|
|
1038
|
+
// Stdio upstreams take priority for exact name matches.
|
|
1039
|
+
const stdioUpstream = this._stdioUpstreams.get(providerName);
|
|
1040
|
+
if (stdioUpstream?.isOpen) {
|
|
1041
|
+
stdioUpstream.send(data);
|
|
1042
|
+
return;
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
// In-process loopback (e.g. the embedded `_broker`) takes the same priority.
|
|
1046
|
+
const loopback = this._loopbackProviders.get(providerName);
|
|
1047
|
+
if (loopback?.isOpen) {
|
|
1048
|
+
loopback.send(data);
|
|
1049
|
+
return;
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
if (!state.ws || state.ws.readyState !== WebSocket.OPEN) return;
|
|
1053
|
+
|
|
1054
|
+
if (this._multiplexSockets.has(state.ws)) {
|
|
1055
|
+
// Wrap in envelope for the multiplexed socket.
|
|
1056
|
+
const payload = JSON.parse(data) as unknown;
|
|
1057
|
+
state.ws.send(JSON.stringify({ provider: providerName, payload }));
|
|
1058
|
+
} else {
|
|
1059
|
+
state.ws.send(data);
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
private _routeFromStdioClient(state: ProviderState, data: string): void {
|
|
1064
|
+
try {
|
|
1065
|
+
const msg = JSON.parse(data) as { id?: string | number };
|
|
1066
|
+
if (msg?.id != null) state.pending.set(msg.id, { type: "stdio" });
|
|
1067
|
+
} catch {
|
|
1068
|
+
/* forward as-is */
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
if (this._isProviderConnected(this._stdioClientProvider!, state)) {
|
|
1072
|
+
this._sendToProvider(state, this._stdioClientProvider!, data);
|
|
1073
|
+
} else {
|
|
1074
|
+
let errId: string | number | null = null;
|
|
1075
|
+
try {
|
|
1076
|
+
errId = (JSON.parse(data) as { id?: string | number }).id ?? null;
|
|
1077
|
+
} catch {
|
|
1078
|
+
/* */
|
|
1079
|
+
}
|
|
1080
|
+
if (errId != null) {
|
|
1081
|
+
process.stdout.write(
|
|
1082
|
+
JSON.stringify({
|
|
1083
|
+
jsonrpc: "2.0",
|
|
1084
|
+
id: errId,
|
|
1085
|
+
error: { code: -32000, message: `Provider "${this._stdioClientProvider}" not connected` },
|
|
1086
|
+
}) + "\n"
|
|
1087
|
+
);
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
private _routeFromClient(client: WebSocket, state: ProviderState, providerName: string, data: string): void {
|
|
1093
|
+
try {
|
|
1094
|
+
const msg = JSON.parse(data) as { id?: string | number };
|
|
1095
|
+
if (msg?.id != null) state.pending.set(msg.id, { type: "ws", socket: client });
|
|
1096
|
+
} catch {
|
|
1097
|
+
/* forward as-is */
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
if (this._isProviderConnected(providerName, state)) {
|
|
1101
|
+
this._sendToProvider(state, providerName, data);
|
|
1102
|
+
} else {
|
|
1103
|
+
client.send(
|
|
1104
|
+
JSON.stringify({
|
|
1105
|
+
jsonrpc: "2.0",
|
|
1106
|
+
id: null,
|
|
1107
|
+
error: { code: -32000, message: "No provider connected" },
|
|
1108
|
+
})
|
|
1109
|
+
);
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
private _routeFromProvider(state: ProviderState, data: string): void {
|
|
1114
|
+
try {
|
|
1115
|
+
const msg = JSON.parse(data) as { id?: string | number };
|
|
1116
|
+
|
|
1117
|
+
if (msg.id != null) {
|
|
1118
|
+
// Response: route to the specific sink that made the request.
|
|
1119
|
+
const sink = state.pending.get(msg.id);
|
|
1120
|
+
if (sink?.type === "ws" && sink.socket.readyState === WebSocket.OPEN) {
|
|
1121
|
+
sink.socket.send(data);
|
|
1122
|
+
} else if (sink?.type === "sse") {
|
|
1123
|
+
const sseRes = state.sseSessions.get(sink.sessionId);
|
|
1124
|
+
if (sseRes) this._sendSseEvent(sseRes, data);
|
|
1125
|
+
} else if (sink?.type === "http") {
|
|
1126
|
+
sink.res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
|
|
1127
|
+
sink.res.end(data);
|
|
1128
|
+
} else if (sink?.type === "stdio") {
|
|
1129
|
+
process.stdout.write(data + "\n");
|
|
1130
|
+
}
|
|
1131
|
+
state.pending.delete(msg.id);
|
|
1132
|
+
} else {
|
|
1133
|
+
// Notification (no id): broadcast to all clients of this provider.
|
|
1134
|
+
this._broadcast(state, data);
|
|
1135
|
+
}
|
|
1136
|
+
} catch {
|
|
1137
|
+
this._broadcast(state, data);
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
/** Sends a message to all clients connected to one provider. */
|
|
1142
|
+
private _broadcast(state: ProviderState, data: string): void {
|
|
1143
|
+
for (const client of state.wsClients) {
|
|
1144
|
+
if (client.readyState === WebSocket.OPEN) client.send(data);
|
|
1145
|
+
}
|
|
1146
|
+
for (const sseRes of state.sseSessions.values()) {
|
|
1147
|
+
this._sendSseEvent(sseRes, data);
|
|
1148
|
+
}
|
|
1149
|
+
for (const mcpRes of state.mcpGetSessions.values()) {
|
|
1150
|
+
this._sendSseEvent(mcpRes, data);
|
|
1151
|
+
}
|
|
1152
|
+
// Forward notifications to the stdio client if it is watching this provider.
|
|
1153
|
+
if (this._stdioClientProvider && this._providers.get(this._stdioClientProvider) === state) {
|
|
1154
|
+
process.stdout.write(data + "\n");
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
// -------------------------------------------------------------------------
|
|
1159
|
+
// Provider state helpers
|
|
1160
|
+
// -------------------------------------------------------------------------
|
|
1161
|
+
|
|
1162
|
+
/**
|
|
1163
|
+
* Returns `true` if the provider is reachable — via a WebSocket connection,
|
|
1164
|
+
* a stdio upstream, or an in-process loopback transport.
|
|
1165
|
+
*/
|
|
1166
|
+
private _isProviderConnected(providerName: string, state: ProviderState): boolean {
|
|
1167
|
+
if (this._stdioUpstreams.get(providerName)?.isOpen) return true;
|
|
1168
|
+
if (this._loopbackProviders.get(providerName)?.isOpen) return true;
|
|
1169
|
+
if (state.ws?.readyState === WebSocket.OPEN) return true;
|
|
1170
|
+
return false;
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
/** Returns the state for `name`, creating it lazily if it doesn't exist yet. */
|
|
1174
|
+
private _getOrCreateProviderState(name: string): ProviderState {
|
|
1175
|
+
let state = this._providers.get(name);
|
|
1176
|
+
if (!state) {
|
|
1177
|
+
state = {
|
|
1178
|
+
ws: null,
|
|
1179
|
+
pending: new Map(),
|
|
1180
|
+
sseSessions: new Map(),
|
|
1181
|
+
mcpGetSessions: new Map(),
|
|
1182
|
+
wsClients: new Set(),
|
|
1183
|
+
};
|
|
1184
|
+
this._providers.set(name, state);
|
|
1185
|
+
}
|
|
1186
|
+
return state;
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
// -------------------------------------------------------------------------
|
|
1190
|
+
// Samples index
|
|
1191
|
+
// -------------------------------------------------------------------------
|
|
1192
|
+
|
|
1193
|
+
private _handleSamplesIndex(res: ServerResponse): void {
|
|
1194
|
+
const rootMount = (this._options.staticMounts ?? []).find((m) => m.urlPrefix === "/");
|
|
1195
|
+
|
|
1196
|
+
let files: string[] = [];
|
|
1197
|
+
if (rootMount) {
|
|
1198
|
+
const samplesDir = nodePath.join(rootMount.dir, "samples");
|
|
1199
|
+
try {
|
|
1200
|
+
if (fs.existsSync(samplesDir) && fs.statSync(samplesDir).isDirectory()) {
|
|
1201
|
+
files = fs.readdirSync(samplesDir).filter((name) => fs.statSync(nodePath.join(samplesDir, name)).isFile());
|
|
1202
|
+
}
|
|
1203
|
+
} catch {
|
|
1204
|
+
/* return empty list on any I/O error */
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
|
|
1209
|
+
res.end(JSON.stringify({ files }));
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
// -------------------------------------------------------------------------
|
|
1213
|
+
// Static file serving
|
|
1214
|
+
// -------------------------------------------------------------------------
|
|
1215
|
+
|
|
1216
|
+
private _serveStatic(req: IncomingMessage, res: ServerResponse): void {
|
|
1217
|
+
const rawUrl = (req.url ?? "/").split("?")[0].split("#")[0];
|
|
1218
|
+
const mounts = this._options.staticMounts ?? [];
|
|
1219
|
+
|
|
1220
|
+
const mount = [...mounts]
|
|
1221
|
+
.filter((m) => {
|
|
1222
|
+
const prefix = m.urlPrefix.endsWith("/") ? m.urlPrefix : m.urlPrefix + "/";
|
|
1223
|
+
return rawUrl === m.urlPrefix || rawUrl.startsWith(prefix);
|
|
1224
|
+
})
|
|
1225
|
+
.sort((a, b) => b.urlPrefix.length - a.urlPrefix.length)[0];
|
|
1226
|
+
|
|
1227
|
+
if (!mount) {
|
|
1228
|
+
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
1229
|
+
res.end("Not found");
|
|
1230
|
+
return;
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
const relative = rawUrl.slice(mount.urlPrefix.length) || "/";
|
|
1234
|
+
const normalized = nodePath.normalize(relative);
|
|
1235
|
+
|
|
1236
|
+
if (normalized.startsWith("..")) {
|
|
1237
|
+
res.writeHead(403);
|
|
1238
|
+
res.end("Forbidden");
|
|
1239
|
+
return;
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
const mountAbs = nodePath.resolve(mount.dir);
|
|
1243
|
+
let filePath = nodePath.join(mountAbs, normalized);
|
|
1244
|
+
|
|
1245
|
+
if (!filePath.startsWith(mountAbs + nodePath.sep) && filePath !== mountAbs) {
|
|
1246
|
+
res.writeHead(403);
|
|
1247
|
+
res.end("Forbidden");
|
|
1248
|
+
return;
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
try {
|
|
1252
|
+
if (fs.statSync(filePath).isDirectory()) filePath = nodePath.join(filePath, "index.html");
|
|
1253
|
+
} catch {
|
|
1254
|
+
res.writeHead(404);
|
|
1255
|
+
res.end("Not found");
|
|
1256
|
+
return;
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
if (!fs.existsSync(filePath)) {
|
|
1260
|
+
res.writeHead(404);
|
|
1261
|
+
res.end("Not found");
|
|
1262
|
+
return;
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
const ext = nodePath.extname(filePath).toLowerCase();
|
|
1266
|
+
res.writeHead(200, { "Content-Type": MIME[ext] ?? "application/octet-stream" });
|
|
1267
|
+
fs.createReadStream(filePath).pipe(res);
|
|
1268
|
+
}
|
|
1269
|
+
}
|