@cyanmycelium/mcp-broker 0.4.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (151) hide show
  1. package/.mcp-broker.example/CONFIGURATION-EN.md +861 -0
  2. package/.mcp-broker.example/CONFIGURATION-FR.md +871 -0
  3. package/.mcp-broker.example/README.md +8 -3
  4. package/.mcp-broker.example/config.json +86 -0
  5. package/README.md +122 -3
  6. package/dist/bin.d.ts +0 -1
  7. package/dist/bin.js +180 -208
  8. package/dist/bin.js.map +1 -1
  9. package/dist/chunk-FTDKH2C4.js +3670 -0
  10. package/dist/chunk-FTDKH2C4.js.map +1 -0
  11. package/dist/{broker/grammars → grammars}/claude/en.json +1 -1
  12. package/dist/{broker/grammars → grammars}/claude/fr.json +1 -1
  13. package/dist/index.d.ts +1790 -18
  14. package/dist/index.js +2 -14
  15. package/dist/index.js.map +1 -1
  16. package/package.json +14 -8
  17. package/scripts/copy-assets.mjs +16 -10
  18. package/scripts/gen-cert.mjs +5 -5
  19. package/scripts/pack-mcpb.mjs +5 -5
  20. package/scripts/sign-bundle.mjs +6 -6
  21. package/src/auth/auth.config.ts +98 -0
  22. package/src/auth/auth.types.ts +120 -0
  23. package/src/auth/http.auth.ts +128 -0
  24. package/src/auth/index.ts +34 -0
  25. package/src/auth/jwt.validator.ts +63 -0
  26. package/src/auth/provider.auth.ts +114 -0
  27. package/src/auth/resource.metadata.ts +34 -0
  28. package/src/authorization/audit.ts +35 -0
  29. package/src/authorization/capability.classifier.ts +114 -0
  30. package/src/authorization/index.ts +37 -0
  31. package/src/authorization/policy.engine.ts +212 -0
  32. package/src/authorization/policy.types.ts +105 -0
  33. package/src/authorization/resource.path.ts +126 -0
  34. package/src/authorization/runtime.ts +56 -0
  35. package/src/authorization/slot.resource.ts +50 -0
  36. package/src/authorization/subject.mapper.ts +81 -0
  37. package/src/bin.ts +90 -7
  38. package/src/broker/adapters/broker.adapter.info.ts +3 -3
  39. package/src/broker/adapters/broker.adapter.providers.ts +3 -3
  40. package/src/broker/aggregate/aggregate.catalog.ts +49 -21
  41. package/src/broker/aggregate/aggregate.server.ts +149 -19
  42. package/src/broker/aggregate/provider.client.session.ts +22 -22
  43. package/src/broker/behaviors/broker.behavior.info.ts +4 -4
  44. package/src/broker/behaviors/broker.behavior.providers.ts +6 -6
  45. package/src/broker/broker.context.ts +10 -4
  46. package/src/broker/broker.grammars.ts +16 -13
  47. package/src/broker/broker.server.ts +12 -9
  48. package/src/broker/grammars/claude/en.json +1 -1
  49. package/src/broker/grammars/claude/fr.json +1 -1
  50. package/src/broker/index.ts +9 -9
  51. package/src/config.ts +81 -8
  52. package/src/index.ts +121 -20
  53. package/src/{mcpb.loader.ts → mcpb/mcpb.loader.ts} +24 -21
  54. package/src/{mcpb.unzip.ts → mcpb/mcpb.unzip.ts} +2 -2
  55. package/src/remote.transports.ts +11 -8
  56. package/src/remote.upstream.ts +11 -8
  57. package/src/stdio.upstream.ts +9 -6
  58. package/src/upstream.ts +6 -3
  59. package/src/ws/ws.interfaces.ts +357 -0
  60. package/src/{ws.tunnel.builder.ts → ws/ws.tunnel.builder.ts} +112 -9
  61. package/src/{ws.tunnel.ts → ws/ws.tunnel.ts} +646 -457
  62. package/web/README.md +94 -0
  63. package/web/assets/logo.png +0 -0
  64. package/web/broker-self-mcp.html +338 -0
  65. package/web/css/styles.css +580 -0
  66. package/web/demos/DemoPlaceholder.html +258 -0
  67. package/web/demos/broker-explorer/css/app.css +393 -0
  68. package/web/demos/broker-explorer/index.html +94 -0
  69. package/web/demos/broker-explorer/js/app.js +271 -0
  70. package/web/demos/broker-explorer/js/mcp-ws-client.js +132 -0
  71. package/web/demos/oauth-lab/README.md +94 -0
  72. package/web/demos/oauth-lab/config.json +117 -0
  73. package/web/demos/oauth-lab/css/app.css +1097 -0
  74. package/web/demos/oauth-lab/index.html +323 -0
  75. package/web/demos/oauth-lab/js/app.js +654 -0
  76. package/web/demos/oauth-lab/server/auth-server.mjs +426 -0
  77. package/web/demos/oauth-lab/server/factory-provider.mjs +269 -0
  78. package/web/demos/oauth-lab/server/smoke-test.mjs +308 -0
  79. package/web/demos/oauth-lab/server/start.mjs +106 -0
  80. package/web/demos/provider-tunnel/css/app.css +384 -0
  81. package/web/demos/provider-tunnel/index.html +99 -0
  82. package/web/demos/provider-tunnel/js/app.js +226 -0
  83. package/web/demos/provider-tunnel/js/toolbox-server.js +186 -0
  84. package/web/index.html +558 -0
  85. package/web/js/lib/broker-tunnel.js +173 -0
  86. package/dist/broker/adapters/broker.adapter.info.d.ts +0 -16
  87. package/dist/broker/adapters/broker.adapter.info.js +0 -43
  88. package/dist/broker/adapters/broker.adapter.info.js.map +0 -1
  89. package/dist/broker/adapters/broker.adapter.providers.d.ts +0 -18
  90. package/dist/broker/adapters/broker.adapter.providers.js +0 -61
  91. package/dist/broker/adapters/broker.adapter.providers.js.map +0 -1
  92. package/dist/broker/aggregate/aggregate.catalog.d.ts +0 -54
  93. package/dist/broker/aggregate/aggregate.catalog.js +0 -105
  94. package/dist/broker/aggregate/aggregate.catalog.js.map +0 -1
  95. package/dist/broker/aggregate/aggregate.server.d.ts +0 -47
  96. package/dist/broker/aggregate/aggregate.server.js +0 -151
  97. package/dist/broker/aggregate/aggregate.server.js.map +0 -1
  98. package/dist/broker/aggregate/provider.client.session.d.ts +0 -52
  99. package/dist/broker/aggregate/provider.client.session.js +0 -140
  100. package/dist/broker/aggregate/provider.client.session.js.map +0 -1
  101. package/dist/broker/behaviors/broker.behavior.info.d.ts +0 -15
  102. package/dist/broker/behaviors/broker.behavior.info.js +0 -41
  103. package/dist/broker/behaviors/broker.behavior.info.js.map +0 -1
  104. package/dist/broker/behaviors/broker.behavior.providers.d.ts +0 -19
  105. package/dist/broker/behaviors/broker.behavior.providers.js +0 -69
  106. package/dist/broker/behaviors/broker.behavior.providers.js.map +0 -1
  107. package/dist/broker/broker.context.d.ts +0 -59
  108. package/dist/broker/broker.context.js +0 -2
  109. package/dist/broker/broker.context.js.map +0 -1
  110. package/dist/broker/broker.grammars.d.ts +0 -130
  111. package/dist/broker/broker.grammars.js +0 -229
  112. package/dist/broker/broker.grammars.js.map +0 -1
  113. package/dist/broker/broker.server.d.ts +0 -66
  114. package/dist/broker/broker.server.js +0 -73
  115. package/dist/broker/broker.server.js.map +0 -1
  116. package/dist/broker/index.d.ts +0 -9
  117. package/dist/broker/index.js +0 -7
  118. package/dist/broker/index.js.map +0 -1
  119. package/dist/config.d.ts +0 -136
  120. package/dist/config.js +0 -61
  121. package/dist/config.js.map +0 -1
  122. package/dist/mcpb.loader.d.ts +0 -24
  123. package/dist/mcpb.loader.js +0 -161
  124. package/dist/mcpb.loader.js.map +0 -1
  125. package/dist/mcpb.unzip.d.ts +0 -6
  126. package/dist/mcpb.unzip.js +0 -95
  127. package/dist/mcpb.unzip.js.map +0 -1
  128. package/dist/remote.transports.d.ts +0 -16
  129. package/dist/remote.transports.js +0 -297
  130. package/dist/remote.transports.js.map +0 -1
  131. package/dist/remote.upstream.d.ts +0 -36
  132. package/dist/remote.upstream.js +0 -52
  133. package/dist/remote.upstream.js.map +0 -1
  134. package/dist/stdio.upstream.d.ts +0 -45
  135. package/dist/stdio.upstream.js +0 -85
  136. package/dist/stdio.upstream.js.map +0 -1
  137. package/dist/upstream.d.ts +0 -33
  138. package/dist/upstream.js +0 -2
  139. package/dist/upstream.js.map +0 -1
  140. package/dist/version.d.ts +0 -2
  141. package/dist/version.js +0 -9
  142. package/dist/version.js.map +0 -1
  143. package/dist/ws.tunnel.builder.d.ts +0 -139
  144. package/dist/ws.tunnel.builder.js +0 -205
  145. package/dist/ws.tunnel.builder.js.map +0 -1
  146. package/dist/ws.tunnel.d.ts +0 -373
  147. package/dist/ws.tunnel.js +0 -1090
  148. package/dist/ws.tunnel.js.map +0 -1
  149. /package/dist/{broker/grammars → grammars}/default/en.json +0 -0
  150. /package/dist/{broker/grammars → grammars}/default/fr.json +0 -0
  151. /package/dist/{broker/grammars → grammars}/default/zh.json +0 -0
package/dist/ws.tunnel.js DELETED
@@ -1,1090 +0,0 @@
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 { WebSocket, WebSocketServer } from "ws";
7
- import { StdioUpstream } from "./stdio.upstream.js";
8
- import { RemoteUpstream } from "./remote.upstream.js";
9
- import { startBrokerServer, BROKER_PROVIDER_NAME } from "./broker/index.js";
10
- import { AggregateServer } from "./broker/aggregate/aggregate.server.js";
11
- import { VERSION, PACKAGE_NAME } from "./version.js";
12
- // ---------------------------------------------------------------------------
13
- // Static-file helpers
14
- // ---------------------------------------------------------------------------
15
- /** Maps file extensions to their HTTP Content-Type values. */
16
- const MIME = {
17
- ".html": "text/html; charset=utf-8",
18
- ".js": "application/javascript; charset=utf-8",
19
- ".mjs": "application/javascript; charset=utf-8",
20
- ".css": "text/css; charset=utf-8",
21
- ".json": "application/json; charset=utf-8",
22
- ".map": "application/json; charset=utf-8",
23
- ".svg": "image/svg+xml",
24
- ".png": "image/png",
25
- ".ico": "image/x-icon",
26
- ".woff2": "font/woff2",
27
- ".woff": "font/woff",
28
- };
29
- // ---------------------------------------------------------------------------
30
- // WsTunnel
31
- // ---------------------------------------------------------------------------
32
- /**
33
- * A multi-provider relay that bridges any number of MCP server instances
34
- * (the **providers**) with their respective MCP clients.
35
- *
36
- * ## Transport overview
37
- * ```
38
- * Provider "<name>"
39
- * ws://host/provider/<name> ← WebSocket registration
40
- *
41
- * MCP Inspector (Streamable HTTP, 2025-03-26)
42
- * GET http://host/<name>/mcp ← persistent SSE notification stream
43
- * POST http://host/<name>/mcp → JSON-RPC requests
44
- *
45
- * Claude (legacy SSE transport)
46
- * GET http://host/<name>/sse ← SSE notification stream
47
- * POST http://host/<name>/messages → JSON-RPC requests
48
- * ```
49
- *
50
- * Each provider gets its own isolated set of sessions, pending requests, and
51
- * notification streams. Multiple providers can be connected simultaneously.
52
- */
53
- export class WsTunnel {
54
- _options;
55
- _httpServer = null;
56
- _wss = null;
57
- /**
58
- * Per-provider state, keyed by provider name.
59
- * Created lazily: a slot is allocated the first time any client references
60
- * a provider name, even before the provider WebSocket connects.
61
- */
62
- _providers = new Map();
63
- /** Maps a multiplexed WebSocket to the set of provider names it feeds. */
64
- _multiplexSockets = new Map();
65
- /** Upstream providers (stdio child processes and remote URL servers), keyed by name. */
66
- _upstreams = new Map();
67
- /**
68
- * In-process loopback transports registered as provider slots.
69
- * Used by the embedded broker server (`_broker`) and any other component
70
- * that wants to expose itself as a provider without going through a network.
71
- */
72
- _loopbackProviders = new Map();
73
- /** The embedded broker MCP server, when {@link WsTunnelOptions.enableBrokerProvider} is on. */
74
- _brokerServer = null;
75
- /** The aggregate MCP server (`_all` slot), when {@link WsTunnelOptions.enableAggregateProvider} is on. */
76
- _aggregateServer = null;
77
- /** Provider name that the stdio client transport is bridged to, or null when disabled. */
78
- _stdioClientProvider = null;
79
- /** Buffered partial line from stdin (stdio client transport). */
80
- _stdioClientBuffer = "";
81
- /** Timestamp of the most recent successful `start()`. */
82
- _startedAt = null;
83
- constructor(options) {
84
- this._options = options;
85
- }
86
- // -------------------------------------------------------------------------
87
- // BrokerContext implementation
88
- // -------------------------------------------------------------------------
89
- get version() {
90
- return VERSION;
91
- }
92
- get name() {
93
- return this._options.brokerName ?? PACKAGE_NAME;
94
- }
95
- get startedAt() {
96
- return this._startedAt;
97
- }
98
- get uptimeSeconds() {
99
- if (!this._startedAt)
100
- return 0;
101
- return Math.floor((Date.now() - this._startedAt.getTime()) / 1000);
102
- }
103
- get host() {
104
- return this._options.host;
105
- }
106
- get port() {
107
- return this._options.port;
108
- }
109
- get tls() {
110
- return !!this._options.tls;
111
- }
112
- get paths() {
113
- const o = this._options;
114
- return {
115
- provider: o.providerPath ?? "/provider",
116
- providers: o.providersPath ?? "/providers",
117
- client: o.clientPath ?? "/",
118
- mcp: o.mcpPath ?? "/mcp",
119
- sse: o.ssePath ?? "/sse",
120
- messages: o.messagesPath ?? "/messages",
121
- };
122
- }
123
- getProvidersInfo() {
124
- const out = [];
125
- for (const [name, state] of this._providers) {
126
- out.push(this._buildProviderInfo(name, state));
127
- }
128
- return out;
129
- }
130
- getProviderInfo(name) {
131
- const state = this._providers.get(name);
132
- if (!state)
133
- return undefined;
134
- return this._buildProviderInfo(name, state);
135
- }
136
- _buildProviderInfo(name, state) {
137
- let transport;
138
- let connected;
139
- if (this._loopbackProviders.get(name)?.isOpen) {
140
- transport = "loopback";
141
- connected = true;
142
- }
143
- else if (this._upstreams.get(name)?.isOpen) {
144
- transport = "stdio";
145
- connected = true;
146
- }
147
- else if (state.ws?.readyState === WebSocket.OPEN) {
148
- transport = this._multiplexSockets.has(state.ws) ? "ws-multiplex" : "ws";
149
- connected = true;
150
- }
151
- else {
152
- transport = "none";
153
- connected = false;
154
- }
155
- return {
156
- name,
157
- transport,
158
- connected,
159
- clientCount: state.wsClients.size,
160
- sessionCount: state.sseSessions.size + state.mcpGetSessions.size,
161
- pendingCount: state.pending.size,
162
- };
163
- }
164
- // -------------------------------------------------------------------------
165
- // Loopback provider registration (in-process transports)
166
- // -------------------------------------------------------------------------
167
- /**
168
- * Registers an in-process transport as a provider slot. Used by the embedded
169
- * broker server and may be used by application code that wants to host an
170
- * MCP server inside the same process without opening a real WebSocket.
171
- *
172
- * @throws if the name is already used by a stdio upstream or another loopback.
173
- */
174
- registerLoopbackProvider(name, transport) {
175
- if (this._loopbackProviders.has(name)) {
176
- throw new Error(`Loopback provider "${name}" is already registered.`);
177
- }
178
- if (this._upstreams.has(name)) {
179
- throw new Error(`Cannot register loopback "${name}": a stdio upstream with the same name already exists.`);
180
- }
181
- const state = this._getOrCreateProviderState(name);
182
- this._loopbackProviders.set(name, transport);
183
- transport.onMessage = (data) => this._routeFromProvider(state, data);
184
- transport.onClose = () => {
185
- this._loopbackProviders.delete(name);
186
- this._failProviderDisconnected(state, name);
187
- };
188
- }
189
- /**
190
- * Opens an in-process client to a provider slot. The returned handle can
191
- * issue MCP requests and receives both the responses and the provider's
192
- * broadcast notifications. Used by the aggregate server to fan a single
193
- * in-process client out to every aggregated provider.
194
- *
195
- * The slot does not need a provider attached yet — `send` returns a
196
- * JSON-RPC error while the provider is disconnected.
197
- */
198
- openInternalClient(providerName) {
199
- const state = this._getOrCreateProviderState(providerName);
200
- let closed = false;
201
- const client = {
202
- onMessage: null,
203
- onClose: null,
204
- send: (message) => {
205
- if (closed)
206
- return;
207
- let id = null;
208
- try {
209
- const parsed = JSON.parse(message);
210
- if (parsed?.id != null)
211
- id = parsed.id;
212
- }
213
- catch {
214
- /* forward as-is */
215
- }
216
- if (this._isProviderConnected(providerName, state)) {
217
- if (id != null)
218
- state.pending.set(id, { type: "internal", client });
219
- this._sendToProvider(state, providerName, message);
220
- }
221
- else if (id != null) {
222
- client.onMessage?.(JSON.stringify({
223
- jsonrpc: "2.0",
224
- id,
225
- error: { code: -32000, message: `Provider "${providerName}" not connected` },
226
- }));
227
- }
228
- },
229
- close: () => {
230
- if (closed)
231
- return;
232
- closed = true;
233
- state.internalClients.delete(client);
234
- for (const [id, sink] of state.pending) {
235
- if (sink.type === "internal" && sink.client === client)
236
- state.pending.delete(id);
237
- }
238
- },
239
- };
240
- state.internalClients.add(client);
241
- return client;
242
- }
243
- // -------------------------------------------------------------------------
244
- // Public state
245
- // -------------------------------------------------------------------------
246
- get isListening() {
247
- return this._httpServer?.listening ?? false;
248
- }
249
- /** Total number of connected MCP clients across all providers. */
250
- get clientCount() {
251
- let n = 0;
252
- for (const s of this._providers.values()) {
253
- n += s.wsClients.size + s.sseSessions.size + s.mcpGetSessions.size;
254
- }
255
- return n;
256
- }
257
- /** Names of all providers that currently have an active connection. */
258
- get providerNames() {
259
- return [...this._providers.entries()].filter(([name, s]) => this._isProviderConnected(name, s)).map(([name]) => name);
260
- }
261
- /** @deprecated Check `providerNames.length > 0` instead. */
262
- get hasProvider() {
263
- return this.providerNames.length > 0;
264
- }
265
- // -------------------------------------------------------------------------
266
- // Lifecycle
267
- // -------------------------------------------------------------------------
268
- /**
269
- * Starts the broker. Resolves once the HTTP server is listening.
270
- */
271
- start() {
272
- return new Promise((resolve) => {
273
- const handler = (req, res) => this._handleHttp(req, res);
274
- this._httpServer = this._options.tls ? https.createServer({ cert: this._options.tls.cert, key: this._options.tls.key }, handler) : http.createServer(handler);
275
- // Disable perMessageDeflate: payloads may be large base64-encoded blobs
276
- // (snapshots, images) that are already compressed. Deflating them wastes
277
- // CPU without reducing size, and caused multi-second stalls in practice.
278
- this._wss = new WebSocketServer({ server: this._httpServer, perMessageDeflate: false });
279
- this._wss.on("connection", (ws, req) => {
280
- const url = req.url ?? "/";
281
- const providerPath = this._options.providerPath ?? "/provider";
282
- const providersPath = this._options.providersPath ?? "/providers";
283
- if (url === providersPath || url.startsWith(providersPath + "?")) {
284
- // Multiplexed provider: one WebSocket carries N providers via envelopes.
285
- this._onMultiplexProviderConnect(ws);
286
- }
287
- else if (url.startsWith(providerPath + "/") || url === providerPath) {
288
- // Extract name: everything after "<providerPath>/"
289
- const raw = url.slice(providerPath.length).replace(/^\//, "");
290
- const name = decodeURIComponent(raw.split("?")[0]) || "(unnamed)";
291
- this._onProviderConnect(ws, name);
292
- }
293
- else {
294
- // Raw WS MCP client: URL is "/<providerName>" or "/"
295
- const raw = url.replace(/^\//, "").split("?")[0];
296
- const name = decodeURIComponent(raw) || "";
297
- this._onClientConnect(ws, name);
298
- }
299
- });
300
- this._httpServer.listen(this._options.port, this._options.host ?? "0.0.0.0", () => {
301
- // Bring the aggregate `_all` slot up before any upstream connects
302
- // (a Streamable HTTP upstream opens synchronously on connect()).
303
- this._maybeStartAggregateServer();
304
- // Attach configured upstreams (stdio child processes + remote URL
305
- // servers). Both implement the Upstream contract, so the wiring
306
- // into a provider slot is identical.
307
- const wireUpstream = (cfg, upstream) => {
308
- upstream.onMessage = (data) => {
309
- const state = this._getOrCreateProviderState(cfg.name);
310
- this._routeFromProvider(state, data);
311
- };
312
- upstream.onError = (err) => {
313
- console.error(`[broker] ${err.message}`);
314
- };
315
- upstream.onClose = () => {
316
- const state = this._providers.get(cfg.name);
317
- if (state)
318
- this._failProviderDisconnected(state, cfg.name);
319
- };
320
- if (cfg.aggregate) {
321
- upstream.onOpen = () => void this._aggregateServer?.addProvider(cfg.name);
322
- }
323
- this._upstreams.set(cfg.name, upstream);
324
- upstream.connect();
325
- };
326
- for (const cfg of this._options.stdioUpstreams ?? [])
327
- wireUpstream(cfg, new StdioUpstream(cfg));
328
- for (const cfg of this._options.remoteUpstreams ?? [])
329
- wireUpstream(cfg, new RemoteUpstream(cfg));
330
- // Attach stdio client transport if configured.
331
- // stdin carries Claude Desktop's JSON-RPC requests; stdout carries responses.
332
- if (this._options.stdioClient) {
333
- this._stdioClientProvider = this._options.stdioClient.providerName;
334
- process.stdin.setEncoding("utf8");
335
- process.stdin.on("data", (chunk) => {
336
- this._stdioClientBuffer += chunk;
337
- let nl;
338
- while ((nl = this._stdioClientBuffer.indexOf("\n")) !== -1) {
339
- const line = this._stdioClientBuffer.slice(0, nl).trim();
340
- this._stdioClientBuffer = this._stdioClientBuffer.slice(nl + 1);
341
- if (line) {
342
- const state = this._getOrCreateProviderState(this._stdioClientProvider);
343
- this._routeFromStdioClient(state, line);
344
- }
345
- }
346
- });
347
- process.stdin.on("end", () => {
348
- // Client disconnected — nothing to clean up; pending sinks will time out.
349
- });
350
- }
351
- this._startedAt = new Date();
352
- // Spawn the embedded broker server last so it can already report
353
- // accurate state in its first `broker_info` call.
354
- void this._maybeStartBrokerServer().then(() => resolve(), (err) => {
355
- console.error("[broker] embedded broker server failed to start:", err);
356
- // Keep the tunnel up even if the introspection server fails.
357
- resolve();
358
- });
359
- });
360
- });
361
- }
362
- /**
363
- * Starts the in-process MCP server that exposes the broker's own behaviors
364
- * (`broker_info`, `providers_list`, `provider_status`) under the reserved
365
- * provider slot `_broker`. No-op when {@link WsTunnelOptions.enableBrokerProvider}
366
- * is `false`.
367
- */
368
- async _maybeStartBrokerServer() {
369
- if (this._options.enableBrokerProvider === false)
370
- return;
371
- const { server, clientTransport } = await startBrokerServer(this, {
372
- grammarResolverOptions: this._options.brokerGrammarResolverOptions,
373
- localGrammarsDir: this._options.brokerLocalGrammarsDir,
374
- });
375
- this._brokerServer = server;
376
- this.registerLoopbackProvider(BROKER_PROVIDER_NAME, clientTransport);
377
- // Aggregate the broker's own introspection tools into `_all`, so a stdio
378
- // host pinned to `_all` still reaches broker_info / providers_list /
379
- // provider_status alongside the other aggregated providers.
380
- void this._aggregateServer?.addProvider(BROKER_PROVIDER_NAME);
381
- }
382
- /**
383
- * Starts the aggregate MCP server and registers it on the reserved `_all`
384
- * slot. No-op when {@link WsTunnelOptions.enableAggregateProvider} is `false`.
385
- */
386
- _maybeStartAggregateServer() {
387
- if (this._options.enableAggregateProvider === false)
388
- return;
389
- try {
390
- const server = new AggregateServer((providerName) => this.openInternalClient(providerName));
391
- server.start();
392
- this.registerLoopbackProvider(AggregateServer.SLOT, server);
393
- this._aggregateServer = server;
394
- }
395
- catch (err) {
396
- console.error(`[broker] aggregate server failed to start: ${err.message}`);
397
- }
398
- }
399
- /**
400
- * Gracefully closes all connections and stops the HTTP server.
401
- */
402
- async stop() {
403
- // Stop the embedded broker first so it does not see its loopback close
404
- // as an unexpected disconnect (and to flush any pending broker responses).
405
- const brokerServer = this._brokerServer;
406
- this._brokerServer = null;
407
- if (brokerServer) {
408
- try {
409
- await brokerServer.stop();
410
- }
411
- catch {
412
- /* best-effort; continue tearing down */
413
- }
414
- }
415
- // Close the aggregate server so its provider sessions and internal
416
- // clients detach before the provider slots are torn down.
417
- const aggregateServer = this._aggregateServer;
418
- this._aggregateServer = null;
419
- if (aggregateServer) {
420
- try {
421
- aggregateServer.close();
422
- }
423
- catch {
424
- /* best-effort; continue tearing down */
425
- }
426
- }
427
- return new Promise((resolve, reject) => {
428
- for (const state of this._providers.values()) {
429
- for (const res of state.sseSessions.values())
430
- res.end();
431
- state.sseSessions.clear();
432
- for (const res of state.mcpGetSessions.values())
433
- res.end();
434
- state.mcpGetSessions.clear();
435
- for (const client of state.wsClients)
436
- client.close();
437
- state.wsClients.clear();
438
- state.ws?.close();
439
- }
440
- this._providers.clear();
441
- this._multiplexSockets.clear();
442
- for (const upstream of this._upstreams.values())
443
- upstream.close();
444
- this._upstreams.clear();
445
- for (const loopback of this._loopbackProviders.values())
446
- loopback.close();
447
- this._loopbackProviders.clear();
448
- this._startedAt = null;
449
- this._wss?.close();
450
- this._httpServer?.close((err) => (err ? reject(err) : resolve()));
451
- });
452
- }
453
- // -------------------------------------------------------------------------
454
- // HTTP dispatcher
455
- // -------------------------------------------------------------------------
456
- _handleHttp(req, res) {
457
- const method = req.method ?? "GET";
458
- const rawUrl = (req.url ?? "/").split("?")[0];
459
- // CORS
460
- res.setHeader("Access-Control-Allow-Origin", "*");
461
- res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
462
- res.setHeader("Access-Control-Allow-Headers", req.headers["access-control-request-headers"] ?? "Content-Type, Accept, Mcp-Session-Id");
463
- res.setHeader("Access-Control-Expose-Headers", "Mcp-Session-Id");
464
- if (method === "OPTIONS") {
465
- res.writeHead(204);
466
- res.end();
467
- return;
468
- }
469
- // Samples index (no provider prefix)
470
- const samplesIndexPath = this._options.samplesIndexPath ?? "/__samples_index__";
471
- if (method === "GET" && rawUrl === samplesIndexPath) {
472
- this._handleSamplesIndex(res);
473
- return;
474
- }
475
- // Route /<providerName>/<endpoint>
476
- const route = this._parseProviderRoute(rawUrl);
477
- if (route) {
478
- const { providerName, endpoint } = route;
479
- const mcpSuffix = (this._options.mcpPath ?? "/mcp").replace(/^\//, "");
480
- const sseSuffix = (this._options.ssePath ?? "/sse").replace(/^\//, "");
481
- const messagesSuffix = (this._options.messagesPath ?? "/messages").replace(/^\//, "");
482
- if (endpoint === mcpSuffix) {
483
- if (method === "GET") {
484
- this._handleMcpGetStream(req, res, providerName);
485
- return;
486
- }
487
- if (method === "POST") {
488
- this._handleMcpPost(req, res, providerName);
489
- return;
490
- }
491
- }
492
- if (endpoint === sseSuffix && method === "GET") {
493
- this._handleSseConnect(req, res, providerName);
494
- return;
495
- }
496
- if (endpoint === messagesSuffix && method === "POST") {
497
- this._handleSseMessage(req, res, providerName);
498
- return;
499
- }
500
- }
501
- // Static files
502
- if (this._options.staticMounts?.length) {
503
- this._serveStatic(req, res);
504
- }
505
- else {
506
- res.writeHead(404);
507
- res.end();
508
- }
509
- }
510
- /**
511
- * Parses `/<providerName>/<endpoint>` from a URL path.
512
- * Returns `null` if the URL does not match this two-segment pattern.
513
- */
514
- _parseProviderRoute(rawUrl) {
515
- const parts = rawUrl.split("/").filter(Boolean);
516
- if (parts.length !== 2)
517
- return null;
518
- const providerName = decodeURIComponent(parts[0]);
519
- const endpoint = decodeURIComponent(parts[1]);
520
- if (!providerName || !endpoint)
521
- return null;
522
- return { providerName, endpoint };
523
- }
524
- // -------------------------------------------------------------------------
525
- // MCP / SSE transport (per provider)
526
- // -------------------------------------------------------------------------
527
- /**
528
- * Handles `GET /<providerName>/sse` — opens a long-lived SSE stream for Claude.
529
- * Sends an `endpoint` event so Claude knows where to POST its requests.
530
- */
531
- _handleSseConnect(req, res, providerName) {
532
- const sessionId = randomUUID();
533
- const messagesSuffix = (this._options.messagesPath ?? "/messages").replace(/^\//, "");
534
- const messagesUrl = `/${encodeURIComponent(providerName)}/${messagesSuffix}`;
535
- res.writeHead(200, {
536
- "Content-Type": "text/event-stream",
537
- "Cache-Control": "no-cache, no-transform",
538
- Connection: "keep-alive",
539
- });
540
- res.write(`event: endpoint\ndata: ${messagesUrl}?sessionId=${sessionId}\n\n`);
541
- const state = this._getOrCreateProviderState(providerName);
542
- state.sseSessions.set(sessionId, res);
543
- req.on("close", () => {
544
- state.sseSessions.delete(sessionId);
545
- for (const [id, sink] of state.pending) {
546
- if (sink.type === "sse" && sink.sessionId === sessionId)
547
- state.pending.delete(id);
548
- }
549
- });
550
- }
551
- /**
552
- * Handles `POST /<providerName>/messages?sessionId=…` — receives a JSON-RPC
553
- * request from Claude and forwards it to the provider.
554
- * Always responds 202 Accepted; the real response arrives over SSE.
555
- */
556
- _handleSseMessage(req, res, providerName) {
557
- const params = new URL(req.url ?? "", "http://localhost").searchParams;
558
- const sessionId = params.get("sessionId") ?? "";
559
- const state = this._getOrCreateProviderState(providerName);
560
- if (!state.sseSessions.has(sessionId)) {
561
- res.writeHead(400, { "Content-Type": "text/plain" });
562
- res.end("Unknown or expired session");
563
- return;
564
- }
565
- let body = "";
566
- req.on("data", (chunk) => {
567
- body += chunk.toString();
568
- });
569
- req.on("end", () => {
570
- try {
571
- const msg = JSON.parse(body);
572
- if (msg.id != null)
573
- state.pending.set(msg.id, { type: "sse", sessionId });
574
- }
575
- catch {
576
- /* malformed — forward anyway */
577
- }
578
- if (this._isProviderConnected(providerName, state)) {
579
- this._sendToProvider(state, providerName, body);
580
- }
581
- else {
582
- const sseRes = state.sseSessions.get(sessionId);
583
- if (sseRes) {
584
- let errId = null;
585
- try {
586
- errId = JSON.parse(body).id ?? null;
587
- }
588
- catch {
589
- /* */
590
- }
591
- this._sendSseEvent(sseRes, JSON.stringify({
592
- jsonrpc: "2.0",
593
- id: errId,
594
- error: { code: -32000, message: `Provider "${providerName}" not connected` },
595
- }));
596
- }
597
- }
598
- res.writeHead(202);
599
- res.end();
600
- });
601
- }
602
- /**
603
- * Handles `POST /<providerName>/mcp` — Streamable HTTP transport (MCP 2025-03-26).
604
- * Forwards the JSON-RPC request to the provider and holds the HTTP response
605
- * open until the reply arrives, then writes it as `application/json`.
606
- */
607
- _handleMcpPost(req, res, providerName) {
608
- let body = "";
609
- req.on("data", (chunk) => {
610
- body += chunk.toString();
611
- });
612
- req.on("end", () => {
613
- let msg = {};
614
- try {
615
- msg = JSON.parse(body);
616
- }
617
- catch {
618
- res.writeHead(400, { "Content-Type": "text/plain" });
619
- res.end("Invalid JSON");
620
- return;
621
- }
622
- const state = this._getOrCreateProviderState(providerName);
623
- if (!this._isProviderConnected(providerName, state)) {
624
- res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
625
- res.end(JSON.stringify({
626
- jsonrpc: "2.0",
627
- id: msg.id ?? null,
628
- error: { code: -32000, message: `Provider "${providerName}" not connected` },
629
- }));
630
- return;
631
- }
632
- if (msg.id != null) {
633
- // Request: hold the response open; reply arrives in _routeFromProvider.
634
- state.pending.set(msg.id, { type: "http", res });
635
- }
636
- else {
637
- // Notification: forward and acknowledge immediately.
638
- res.writeHead(202);
639
- res.end();
640
- }
641
- this._sendToProvider(state, providerName, body);
642
- });
643
- }
644
- /**
645
- * Handles `GET /<providerName>/mcp` — opens a persistent SSE stream per MCP 2025-03-26.
646
- * Streamable HTTP clients (e.g. MCP Inspector) use this to receive
647
- * server-initiated notifications without re-polling.
648
- */
649
- _handleMcpGetStream(req, res, providerName) {
650
- const sessionId = req.headers["mcp-session-id"] ?? randomUUID();
651
- res.writeHead(200, {
652
- "Content-Type": "text/event-stream",
653
- "Cache-Control": "no-cache, no-transform",
654
- Connection: "keep-alive",
655
- "Mcp-Session-Id": sessionId,
656
- });
657
- res.write(": stream open\n\n");
658
- const state = this._getOrCreateProviderState(providerName);
659
- state.mcpGetSessions.set(sessionId, res);
660
- req.on("close", () => {
661
- state.mcpGetSessions.delete(sessionId);
662
- });
663
- }
664
- /** Writes one JSON-RPC message as an SSE `message` event. */
665
- _sendSseEvent(res, data) {
666
- // data is already a compact JSON string — no need to parse+re-serialize.
667
- res.write(`event: message\ndata: ${data}\n\n`);
668
- }
669
- // -------------------------------------------------------------------------
670
- // WebSocket connection handlers
671
- // -------------------------------------------------------------------------
672
- _onProviderConnect(ws, name) {
673
- if (this._upstreams.has(name)) {
674
- console.warn(`[broker] WARNING: WebSocket provider "${name}" rejected — a stdio upstream with the same name is already configured. ` +
675
- `Rename one of them to avoid the conflict.`);
676
- ws.close(1008, `Provider "${name}" is managed by a stdio upstream`);
677
- return;
678
- }
679
- if (this._loopbackProviders.has(name)) {
680
- console.warn(`[broker] WARNING: WebSocket provider "${name}" rejected — the slot is held by an in-process loopback (reserved system slot).`);
681
- ws.close(1008, `Provider "${name}" is reserved by the broker`);
682
- return;
683
- }
684
- const existing = this._providers.get(name);
685
- if (existing?.ws?.readyState === WebSocket.OPEN) {
686
- ws.close(1008, `Provider "${name}" is already connected`);
687
- return;
688
- }
689
- const state = this._getOrCreateProviderState(name);
690
- state.ws = ws;
691
- // A provider MAY send a registration control frame as its very first
692
- // message (see _tryHandleRegistration). Any other first message —
693
- // including a normal MCP frame — is routed and leaves the provider
694
- // non-aggregated, so every pre-existing provider keeps working.
695
- let registrationChecked = false;
696
- ws.on("message", (data) => {
697
- const text = data.toString();
698
- if (!registrationChecked) {
699
- registrationChecked = true;
700
- if (this._tryHandleRegistration(name, text))
701
- return;
702
- }
703
- this._routeFromProvider(state, text);
704
- });
705
- ws.on("close", () => {
706
- state.ws = null;
707
- this._failProviderDisconnected(state, name);
708
- });
709
- }
710
- /**
711
- * Inspects a provider's first WebSocket message for an optional registration
712
- * control frame `{ "type": "register", "aggregate": boolean }`. Returns
713
- * `true` when the message was a registration frame — and thus consumed, not
714
- * routed as MCP traffic. A normal MCP frame always carries `jsonrpc`, so it
715
- * returns `false` and the provider stays non-aggregated.
716
- */
717
- _tryHandleRegistration(name, text) {
718
- let frame;
719
- try {
720
- frame = JSON.parse(text);
721
- }
722
- catch {
723
- return false;
724
- }
725
- if (frame.jsonrpc !== undefined || frame.type !== "register")
726
- return false;
727
- if (frame.aggregate === true) {
728
- void this._aggregateServer?.addProvider(name);
729
- }
730
- return true;
731
- }
732
- _onClientConnect(ws, providerName) {
733
- const state = this._getOrCreateProviderState(providerName);
734
- state.wsClients.add(ws);
735
- ws.on("message", (data) => this._routeFromClient(ws, state, providerName, data.toString()));
736
- ws.on("close", () => {
737
- state.wsClients.delete(ws);
738
- for (const [id, sink] of state.pending) {
739
- if (sink.type === "ws" && sink.socket === ws)
740
- state.pending.delete(id);
741
- }
742
- });
743
- }
744
- /**
745
- * Handles a multiplexed provider WebSocket (`/providers`).
746
- * A single socket carries traffic for multiple providers using the
747
- * envelope format `{ provider: string, payload: object }`.
748
- * Provider names are registered lazily on first message.
749
- */
750
- _onMultiplexProviderConnect(ws) {
751
- const providerNames = new Set();
752
- this._multiplexSockets.set(ws, providerNames);
753
- ws.on("message", (data) => {
754
- let envelope;
755
- try {
756
- envelope = JSON.parse(data.toString());
757
- }
758
- catch {
759
- return; // malformed — drop
760
- }
761
- const name = envelope.provider;
762
- if (!name || envelope.payload === undefined)
763
- return;
764
- // Register provider name lazily on first encounter.
765
- if (!providerNames.has(name)) {
766
- if (this._upstreams.has(name)) {
767
- console.warn(`[broker] WARNING: Multiplexed WebSocket provider "${name}" rejected — a stdio upstream with the same name is already configured. ` +
768
- `Rename one of them to avoid the conflict.`);
769
- ws.send(JSON.stringify({
770
- provider: name,
771
- payload: {
772
- jsonrpc: "2.0",
773
- id: null,
774
- error: { code: -32000, message: `Provider "${name}" is managed by a stdio upstream` },
775
- },
776
- }));
777
- return;
778
- }
779
- if (this._loopbackProviders.has(name)) {
780
- ws.send(JSON.stringify({
781
- provider: name,
782
- payload: {
783
- jsonrpc: "2.0",
784
- id: null,
785
- error: { code: -32000, message: `Provider "${name}" is reserved by the broker` },
786
- },
787
- }));
788
- return;
789
- }
790
- const existing = this._providers.get(name);
791
- if (existing?.ws?.readyState === WebSocket.OPEN) {
792
- // Provider already connected via another socket — reject this name.
793
- ws.send(JSON.stringify({
794
- provider: name,
795
- payload: {
796
- jsonrpc: "2.0",
797
- id: null,
798
- error: { code: -32000, message: `Provider "${name}" is already connected` },
799
- },
800
- }));
801
- return;
802
- }
803
- providerNames.add(name);
804
- const state = this._getOrCreateProviderState(name);
805
- state.ws = ws;
806
- }
807
- const state = this._providers.get(name);
808
- this._routeFromProvider(state, JSON.stringify(envelope.payload));
809
- });
810
- ws.on("close", () => {
811
- for (const name of providerNames) {
812
- const state = this._providers.get(name);
813
- if (state && state.ws === ws) {
814
- state.ws = null;
815
- this._failProviderDisconnected(state, name);
816
- }
817
- }
818
- this._multiplexSockets.delete(ws);
819
- });
820
- }
821
- // -------------------------------------------------------------------------
822
- // Message routing
823
- // -------------------------------------------------------------------------
824
- /**
825
- * Sends a raw JSON-RPC message to a provider, wrapping it in a multiplex
826
- * envelope when the provider's WebSocket is a multiplexed connection.
827
- */
828
- _sendToProvider(state, providerName, data) {
829
- // Upstreams (stdio child processes and remote URL servers) take priority.
830
- const upstream = this._upstreams.get(providerName);
831
- if (upstream?.isOpen) {
832
- upstream.send(data);
833
- return;
834
- }
835
- // In-process loopback (e.g. the embedded `_broker`) takes the same priority.
836
- const loopback = this._loopbackProviders.get(providerName);
837
- if (loopback?.isOpen) {
838
- loopback.send(data);
839
- return;
840
- }
841
- if (!state.ws || state.ws.readyState !== WebSocket.OPEN)
842
- return;
843
- if (this._multiplexSockets.has(state.ws)) {
844
- // Wrap in envelope for the multiplexed socket.
845
- const payload = JSON.parse(data);
846
- state.ws.send(JSON.stringify({ provider: providerName, payload }));
847
- }
848
- else {
849
- state.ws.send(data);
850
- }
851
- }
852
- _routeFromStdioClient(state, data) {
853
- try {
854
- const msg = JSON.parse(data);
855
- if (msg?.id != null)
856
- state.pending.set(msg.id, { type: "stdio" });
857
- }
858
- catch {
859
- /* forward as-is */
860
- }
861
- if (this._isProviderConnected(this._stdioClientProvider, state)) {
862
- this._sendToProvider(state, this._stdioClientProvider, data);
863
- }
864
- else {
865
- let errId = null;
866
- try {
867
- errId = JSON.parse(data).id ?? null;
868
- }
869
- catch {
870
- /* */
871
- }
872
- if (errId != null) {
873
- process.stdout.write(JSON.stringify({
874
- jsonrpc: "2.0",
875
- id: errId,
876
- error: { code: -32000, message: `Provider "${this._stdioClientProvider}" not connected` },
877
- }) + "\n");
878
- }
879
- }
880
- }
881
- _routeFromClient(client, state, providerName, data) {
882
- try {
883
- const msg = JSON.parse(data);
884
- if (msg?.id != null)
885
- state.pending.set(msg.id, { type: "ws", socket: client });
886
- }
887
- catch {
888
- /* forward as-is */
889
- }
890
- if (this._isProviderConnected(providerName, state)) {
891
- this._sendToProvider(state, providerName, data);
892
- }
893
- else {
894
- client.send(JSON.stringify({
895
- jsonrpc: "2.0",
896
- id: null,
897
- error: { code: -32000, message: "No provider connected" },
898
- }));
899
- }
900
- }
901
- _routeFromProvider(state, data) {
902
- try {
903
- const msg = JSON.parse(data);
904
- if (msg.id != null) {
905
- // Response: route to the specific sink that made the request.
906
- const sink = state.pending.get(msg.id);
907
- if (sink?.type === "ws" && sink.socket.readyState === WebSocket.OPEN) {
908
- sink.socket.send(data);
909
- }
910
- else if (sink?.type === "sse") {
911
- const sseRes = state.sseSessions.get(sink.sessionId);
912
- if (sseRes)
913
- this._sendSseEvent(sseRes, data);
914
- }
915
- else if (sink?.type === "http") {
916
- sink.res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
917
- sink.res.end(data);
918
- }
919
- else if (sink?.type === "stdio") {
920
- process.stdout.write(data + "\n");
921
- }
922
- else if (sink?.type === "internal") {
923
- sink.client.onMessage?.(data);
924
- }
925
- state.pending.delete(msg.id);
926
- }
927
- else {
928
- // Notification (no id): broadcast to all clients of this provider.
929
- this._broadcast(state, data);
930
- }
931
- }
932
- catch {
933
- this._broadcast(state, data);
934
- }
935
- }
936
- /** Sends a message to all clients connected to one provider. */
937
- _broadcast(state, data) {
938
- for (const client of state.wsClients) {
939
- if (client.readyState === WebSocket.OPEN)
940
- client.send(data);
941
- }
942
- for (const sseRes of state.sseSessions.values()) {
943
- this._sendSseEvent(sseRes, data);
944
- }
945
- for (const mcpRes of state.mcpGetSessions.values()) {
946
- this._sendSseEvent(mcpRes, data);
947
- }
948
- for (const ic of state.internalClients) {
949
- ic.onMessage?.(data);
950
- }
951
- // Forward notifications to the stdio client if it is watching this provider.
952
- if (this._stdioClientProvider && this._providers.get(this._stdioClientProvider) === state) {
953
- process.stdout.write(data + "\n");
954
- }
955
- }
956
- /**
957
- * Notifies every pending sink and internal client that the provider slot
958
- * has disconnected, then clears the pending map. Shared by all provider
959
- * close handlers (dedicated WS, multiplexed WS, loopback).
960
- */
961
- _failProviderDisconnected(state, name) {
962
- const error = JSON.stringify({
963
- jsonrpc: "2.0",
964
- id: null,
965
- error: { code: -32000, message: `Provider "${name}" disconnected` },
966
- });
967
- for (const sink of state.pending.values()) {
968
- if (sink.type === "ws" && sink.socket.readyState === WebSocket.OPEN) {
969
- sink.socket.send(error);
970
- }
971
- else if (sink.type === "sse") {
972
- const sseRes = state.sseSessions.get(sink.sessionId);
973
- if (sseRes)
974
- this._sendSseEvent(sseRes, error);
975
- }
976
- else if (sink.type === "http") {
977
- sink.res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
978
- sink.res.end(error);
979
- }
980
- else if (sink.type === "internal") {
981
- sink.client.onMessage?.(error);
982
- }
983
- }
984
- state.pending.clear();
985
- for (const ic of state.internalClients)
986
- ic.onClose?.();
987
- }
988
- // -------------------------------------------------------------------------
989
- // Provider state helpers
990
- // -------------------------------------------------------------------------
991
- /**
992
- * Returns `true` if the provider is reachable — via a WebSocket connection,
993
- * a stdio upstream, or an in-process loopback transport.
994
- */
995
- _isProviderConnected(providerName, state) {
996
- if (this._upstreams.get(providerName)?.isOpen)
997
- return true;
998
- if (this._loopbackProviders.get(providerName)?.isOpen)
999
- return true;
1000
- if (state.ws?.readyState === WebSocket.OPEN)
1001
- return true;
1002
- return false;
1003
- }
1004
- /** Returns the state for `name`, creating it lazily if it doesn't exist yet. */
1005
- _getOrCreateProviderState(name) {
1006
- let state = this._providers.get(name);
1007
- if (!state) {
1008
- state = {
1009
- ws: null,
1010
- pending: new Map(),
1011
- sseSessions: new Map(),
1012
- mcpGetSessions: new Map(),
1013
- wsClients: new Set(),
1014
- internalClients: new Set(),
1015
- };
1016
- this._providers.set(name, state);
1017
- }
1018
- return state;
1019
- }
1020
- // -------------------------------------------------------------------------
1021
- // Samples index
1022
- // -------------------------------------------------------------------------
1023
- _handleSamplesIndex(res) {
1024
- const rootMount = (this._options.staticMounts ?? []).find((m) => m.urlPrefix === "/");
1025
- let files = [];
1026
- if (rootMount) {
1027
- const samplesDir = nodePath.join(rootMount.dir, "samples");
1028
- try {
1029
- if (fs.existsSync(samplesDir) && fs.statSync(samplesDir).isDirectory()) {
1030
- files = fs.readdirSync(samplesDir).filter((name) => fs.statSync(nodePath.join(samplesDir, name)).isFile());
1031
- }
1032
- }
1033
- catch {
1034
- /* return empty list on any I/O error */
1035
- }
1036
- }
1037
- res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
1038
- res.end(JSON.stringify({ files }));
1039
- }
1040
- // -------------------------------------------------------------------------
1041
- // Static file serving
1042
- // -------------------------------------------------------------------------
1043
- _serveStatic(req, res) {
1044
- const rawUrl = (req.url ?? "/").split("?")[0].split("#")[0];
1045
- const mounts = this._options.staticMounts ?? [];
1046
- const mount = [...mounts]
1047
- .filter((m) => {
1048
- const prefix = m.urlPrefix.endsWith("/") ? m.urlPrefix : m.urlPrefix + "/";
1049
- return rawUrl === m.urlPrefix || rawUrl.startsWith(prefix);
1050
- })
1051
- .sort((a, b) => b.urlPrefix.length - a.urlPrefix.length)[0];
1052
- if (!mount) {
1053
- res.writeHead(404, { "Content-Type": "text/plain" });
1054
- res.end("Not found");
1055
- return;
1056
- }
1057
- const relative = rawUrl.slice(mount.urlPrefix.length) || "/";
1058
- const normalized = nodePath.normalize(relative);
1059
- if (normalized.startsWith("..")) {
1060
- res.writeHead(403);
1061
- res.end("Forbidden");
1062
- return;
1063
- }
1064
- const mountAbs = nodePath.resolve(mount.dir);
1065
- let filePath = nodePath.join(mountAbs, normalized);
1066
- if (!filePath.startsWith(mountAbs + nodePath.sep) && filePath !== mountAbs) {
1067
- res.writeHead(403);
1068
- res.end("Forbidden");
1069
- return;
1070
- }
1071
- try {
1072
- if (fs.statSync(filePath).isDirectory())
1073
- filePath = nodePath.join(filePath, "index.html");
1074
- }
1075
- catch {
1076
- res.writeHead(404);
1077
- res.end("Not found");
1078
- return;
1079
- }
1080
- if (!fs.existsSync(filePath)) {
1081
- res.writeHead(404);
1082
- res.end("Not found");
1083
- return;
1084
- }
1085
- const ext = nodePath.extname(filePath).toLowerCase();
1086
- res.writeHead(200, { "Content-Type": MIME[ext] ?? "application/octet-stream" });
1087
- fs.createReadStream(filePath).pipe(res);
1088
- }
1089
- }
1090
- //# sourceMappingURL=ws.tunnel.js.map