@businessmaps/bifrost 0.0.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -5,7 +5,9 @@
5
5
 
6
6
  MCP server that lets AI tools call functions running in your browser.
7
7
 
8
- The daemon sits between any MCP client and your browser tab. It speaks JSON-RPC over stdio on one side and WebSocket on the other. Your browser app connects, registers tools, and the AI can call them. No dependencies, just Node 18+.
8
+ The daemon sits between any MCP client and your browser tab. It speaks JSON-RPC over stdio on one side and WebSocket on the other. Your browser app connects, registers tools, and the AI can call them.
9
+
10
+ **Requirements:** Node.js 18+ · [`ws`](https://github.com/websockets/ws) (installed automatically)
9
11
 
10
12
  ## Install
11
13
 
@@ -13,18 +15,14 @@ The daemon sits between any MCP client and your browser tab. It speaks JSON-RPC
13
15
  npm i -g @businessmaps/bifrost
14
16
  ```
15
17
 
16
- or without npm
17
-
18
- ```bash
19
- curl -fsSL https://raw.githubusercontent.com/Business-Maps/mcp/main/install.sh | sh
20
- ```
21
-
22
18
  Then register it with your MCP client. For example:
23
19
 
24
20
  ```bash
25
- claude mcp add --transport stdio bifrost -- bifrost
21
+ claude mcp add --transport stdio bifrost -- bifrost --no-auth
26
22
  ```
27
23
 
24
+ > **Note:** The `--no-auth` flag is currently recommended because there is an unresolved issue with token authentication when MCP clients launch the daemon. Without it the client has no way to read the token printed to stderr.
25
+
28
26
  The MCP client starts the daemon automatically.
29
27
 
30
28
  ## Usage
@@ -71,21 +69,28 @@ There's a working [live demo](https://business-maps.github.io/mcp/demo/) with fi
71
69
 
72
70
  ## Auth
73
71
 
74
- The daemon generates a token on startup and prints it to stderr. Pass it to the client — the token is sent via the `Sec-WebSocket-Protocol` header during the WebSocket handshake. Auth is always enabled.
72
+ The daemon generates a token on startup and prints it to stderr. Pass it to the client — the token is sent via the `Sec-WebSocket-Protocol` header during the WebSocket handshake. Skip with `--no-auth` during local dev.
75
73
 
76
74
  ## Multiple tabs
77
75
 
78
76
  Each tab registers its own tools. Calls route to whoever owns the tool. Disconnecting a tab removes its tools.
79
77
 
80
- ## Configuration
78
+ ## Options
81
79
 
82
- The daemon reads its port from the `PORT` environment variable (default: `3099`).
80
+ ```
81
+ --port <port> WebSocket port (default: 3099)
82
+ --timeout <secs> Tool call timeout (default: 120)
83
+ --no-auth Disable token auth (dev only)
84
+ --help, -h Show this message
85
+ --version, -v Show version
86
+ ```
83
87
 
84
88
  ## Dev
85
89
 
86
90
  ```bash
87
91
  git clone https://github.com/Business-Maps/mcp.git && cd mcp
88
- npm test # no install needed, no deps
92
+ npm install
93
+ npm test
89
94
  ```
90
95
 
91
96
  [Architecture](docs/architecture.md) · [Client API](docs/client-api.md) · [Contributing](CONTRIBUTING.md)
package/bin/bifrost CHANGED
@@ -4,9 +4,158 @@ import { createInterface } from "readline";
4
4
  import { randomBytes } from "crypto";
5
5
  import { fileURLToPath } from "url";
6
6
  import { dirname, join } from "path";
7
+ import { readFileSync } from "fs";
7
8
 
8
9
  const __dirname = dirname(fileURLToPath(import.meta.url));
9
10
 
11
+ // ---------------------------------------------------------------------------
12
+ // ANSI helpers
13
+ // ---------------------------------------------------------------------------
14
+ const c = process.stderr.isTTY ? {
15
+ r: "\x1b[0m", // reset
16
+ red: "\x1b[31m",
17
+ RED: "\x1b[91m", // bright red
18
+ dim: "\x1b[2m",
19
+ bold: "\x1b[1m",
20
+ white: "\x1b[97m",
21
+ black: "\x1b[30m",
22
+ bgRed: "\x1b[41m",
23
+ gray: "\x1b[90m",
24
+ } : { r:"",red:"",RED:"",dim:"",bold:"",white:"",black:"",bgRed:"",gray:"" };
25
+
26
+ function log(msg) { process.stderr.write(msg + "\n"); }
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // ASCII globe — red sphere with black lat/long grid
30
+ // ---------------------------------------------------------------------------
31
+ function renderGlobe() {
32
+ const R = 9;
33
+ const lines = [];
34
+ for (let y = -R; y <= R; y++) {
35
+ let row = "";
36
+ const rSlice = Math.sqrt(R * R - y * y);
37
+ for (let x = -R * 2; x <= R * 2; x++) {
38
+ const nx = x / 2;
39
+ if (nx * nx + y * y > R * R) {
40
+ row += " ";
41
+ continue;
42
+ }
43
+ // spherical coords for grid
44
+ const z = Math.sqrt(Math.max(0, R * R - nx * nx - y * y));
45
+ const theta = Math.atan2(y, nx);
46
+ const phi = Math.acos(z / R);
47
+ const lat = Math.abs(y);
48
+ const lonAngle = Math.atan2(nx, z);
49
+
50
+ // grid lines: latitudes every ~3 units, longitudes every ~30 degrees
51
+ const isLat = lat % 3 < 0.8;
52
+ const lonDeg = ((lonAngle * 180 / Math.PI) + 360) % 360;
53
+ const isLon = lonDeg % 30 < 6;
54
+ // equator
55
+ const isEquator = Math.abs(y) < 1;
56
+
57
+ if (isEquator || isLat || isLon) {
58
+ row += `${c.black}${c.bgRed}+${c.r}`;
59
+ } else {
60
+ row += `${c.RED}.${c.r}`;
61
+ }
62
+ }
63
+ lines.push(row);
64
+ }
65
+ return lines;
66
+ }
67
+
68
+ // ---------------------------------------------------------------------------
69
+ // Startup banner
70
+ // ---------------------------------------------------------------------------
71
+ function printBanner(version) {
72
+ log("");
73
+
74
+ const globe = renderGlobe();
75
+ const bannerLines = [
76
+ "",
77
+ "",
78
+ "",
79
+ `${c.bold}${c.RED} ██████╗ ██╗███████╗██████╗ ██████╗ ███████╗████████╗${c.r}`,
80
+ `${c.bold}${c.RED} ██╔══██╗██║██╔════╝██╔══██╗██╔═══██╗██╔════╝╚══██╔══╝${c.r}`,
81
+ `${c.bold}${c.RED} ██████╔╝██║█████╗ ██████╔╝██║ ██║███████╗ ██║ ${c.r}`,
82
+ `${c.bold}${c.RED} ██╔══██╗██║██╔══╝ ██╔══██╗██║ ██║╚════██║ ██║ ${c.r}`,
83
+ `${c.bold}${c.RED} ██████╔╝██║██║ ██║ ██║╚██████╔╝███████║ ██║ ${c.r}`,
84
+ `${c.bold}${c.RED} ╚═════╝ ╚═╝╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝ ╚═╝ ${c.r}`,
85
+ "",
86
+ `${c.dim}${c.red} ---------------------------------------- v${version} --${c.r}`,
87
+ "",
88
+ "",
89
+ "",
90
+ "",
91
+ "",
92
+ "",
93
+ "",
94
+ "",
95
+ "",
96
+ ];
97
+
98
+ const globeW = globe.length;
99
+ const bannerW = bannerLines.length;
100
+ const rows = Math.max(globeW, bannerW);
101
+
102
+ for (let i = 0; i < rows; i++) {
103
+ const gLine = i < globe.length ? globe[i] : "";
104
+ const bLine = i < bannerLines.length ? bannerLines[i] : "";
105
+ log(` ${gLine} ${bLine}`);
106
+ }
107
+ }
108
+
109
+ // ---------------------------------------------------------------------------
110
+ // Styled help
111
+ // ---------------------------------------------------------------------------
112
+ function printHelp() {
113
+ log("");
114
+ log(` ${c.bold}${c.RED}BIFROST${c.r} ${c.dim}${c.red}----------------------${c.r}`);
115
+ log("");
116
+ log(` ${c.dim}MCP browser bridge daemon${c.r}`);
117
+ log("");
118
+ log(` ${c.white}${c.bold}USAGE${c.r}`);
119
+ log(` ${c.gray}$${c.r} bifrost ${c.dim}[options]${c.r}`);
120
+ log("");
121
+ log(` ${c.white}${c.bold}OPTIONS${c.r}`);
122
+ log(` ${c.RED}--port${c.r} ${c.dim}<port>${c.r} WebSocket port ${c.gray}(default: 3099)${c.r}`);
123
+ log(` ${c.RED}--timeout${c.r} ${c.dim}<secs>${c.r} Tool call timeout ${c.gray}(default: 120)${c.r}`);
124
+ log(` ${c.RED}--no-auth${c.r} Disable token auth ${c.gray}(dev only)${c.r}`);
125
+ log(` ${c.RED}--help${c.r}, ${c.RED}-h${c.r} Show this message`);
126
+ log(` ${c.RED}--version${c.r}, ${c.RED}-v${c.r} Show version`);
127
+ log("");
128
+ log(` ${c.dim}${c.red}----------------------------------------------------${c.r}`);
129
+ log("");
130
+ }
131
+
132
+ // ---------------------------------------------------------------------------
133
+ // CLI argument parsing
134
+ // ---------------------------------------------------------------------------
135
+ const args = process.argv.slice(2);
136
+
137
+ function flag(name) { return args.includes(name); }
138
+ function option(name, fallback) {
139
+ const idx = args.indexOf(name);
140
+ return idx !== -1 && idx + 1 < args.length ? args[idx + 1] : fallback;
141
+ }
142
+
143
+ let VERSION = "0.0.1";
144
+ try {
145
+ const pkg = JSON.parse(readFileSync(join(__dirname, "..", "package.json"), "utf8"));
146
+ VERSION = pkg.version || VERSION;
147
+ } catch {}
148
+
149
+ if (flag("--help") || flag("-h")) {
150
+ printHelp();
151
+ process.exit(0);
152
+ }
153
+
154
+ if (flag("--version") || flag("-v")) {
155
+ log(`${c.bold}${c.RED}bifrost${c.r} ${c.dim}${VERSION}${c.r}`);
156
+ process.exit(0);
157
+ }
158
+
10
159
  const { MiniWebSocketServer } = await import(
11
160
  new URL(`file://${join(__dirname, "..", "lib", "websocket.js")}`)
12
161
  ).catch(() =>
@@ -16,20 +165,26 @@ const { MiniWebSocketServer } = await import(
16
165
  // ---------------------------------------------------------------------------
17
166
  // Config
18
167
  // ---------------------------------------------------------------------------
19
- const PORT = parseInt(process.env.PORT || "3099", 10);
20
- const TIMEOUT_MS = 120_000;
168
+ const PORT = parseInt(option("--port", process.env.PORT || "3099"), 10);
169
+ const TIMEOUT_MS = parseInt(option("--timeout", "120"), 10) * 1000;
170
+ const NO_AUTH = flag("--no-auth");
21
171
  const MAX_MESSAGE_SIZE = 1 * 1024 * 1024; // 1MB
22
172
  const MAX_PENDING_CALLS = 100;
23
173
  const MAX_CALLS_PER_MIN = 120;
24
174
  const HEARTBEAT_INTERVAL = 30_000;
25
175
  const HEARTBEAT_TIMEOUT = 10_000;
26
176
 
27
- const ALLOWED_ORIGINS = new Set([
28
- "http://localhost",
29
- "http://127.0.0.1",
30
- ]);
177
+ function isLocalOrigin(origin) {
178
+ if (!origin) return true;
179
+ try {
180
+ const url = new URL(origin);
181
+ return url.hostname === "localhost" || url.hostname === "127.0.0.1";
182
+ } catch {
183
+ return false;
184
+ }
185
+ }
31
186
 
32
- const AUTH_TOKEN = randomBytes(32).toString("base64url");
187
+ const AUTH_TOKEN = NO_AUTH ? null : randomBytes(32).toString("base64url");
33
188
 
34
189
  // ---------------------------------------------------------------------------
35
190
  // State
@@ -47,6 +202,10 @@ const rateMap = new Map();
47
202
  // ---------------------------------------------------------------------------
48
203
  function now() { return Date.now(); }
49
204
 
205
+ function timestamp() {
206
+ return new Date().toLocaleTimeString("en-US", { hour12: false });
207
+ }
208
+
50
209
  function rateLimit(key) {
51
210
  const bucket = rateMap.get(key) || { count: 0, ts: now() };
52
211
  if (now() - bucket.ts > 60_000) {
@@ -170,23 +329,46 @@ function handleMcp(msg) {
170
329
  // WebSocket server
171
330
  // ---------------------------------------------------------------------------
172
331
  const wss = new MiniWebSocketServer({ port: PORT }, () => {
173
- console.error(`Secure MCP bridge running on ws://localhost:${PORT}`);
174
- console.error(`Auth token (use as header): ${AUTH_TOKEN}`);
332
+ printBanner(VERSION);
333
+
334
+ log(` ${c.dim}${c.red}────────────────────────────────────────────────────${c.r}`);
335
+ log("");
336
+ log(` ${c.gray}PORT${c.r} ${c.white}${PORT}${c.r}`);
337
+ log(` ${c.gray}TIMEOUT${c.r} ${c.white}${TIMEOUT_MS / 1000}s${c.r}`);
338
+ log(` ${c.gray}AUTH${c.r} ${AUTH_TOKEN ? `${c.RED}ENABLED${c.r}` : `${c.dim}DISABLED${c.r}`}`);
339
+ log("");
340
+
341
+ // These lines must remain parseable by tests
342
+ log(` Secure MCP bridge running on ws://localhost:${PORT}`);
343
+ if (AUTH_TOKEN) {
344
+ // Token value stays raw (no ANSI) so test regex can capture it
345
+ log(` Auth token (use as header): ${AUTH_TOKEN}`);
346
+ } else {
347
+ log(` ${c.dim}Auth disabled (--no-auth)${c.r}`);
348
+ }
349
+
350
+ log("");
351
+ log(` ${c.dim}${c.red}────────────────────────────────────────────────────${c.r}`);
352
+ log(` ${c.gray}Waiting for connections...${c.r}`);
353
+ log("");
175
354
  });
176
355
 
177
356
  wss.on("connection", (ws, req) => {
178
- // 🔐 Origin check
179
- const origin = req.headers.origin;
180
- if (origin && !ALLOWED_ORIGINS.has(origin)) {
181
- ws.close();
357
+ // Origin check — allow any localhost origin (any port)
358
+ if (!isLocalOrigin(req.headers.origin)) {
359
+ ws.terminate();
182
360
  return;
183
361
  }
184
362
 
185
- // 🔐 Header-based auth
186
- const token = req.headers["sec-websocket-protocol"];
187
- if (token !== AUTH_TOKEN) {
188
- ws.close();
189
- return;
363
+ // Auth check (unless --no-auth) — supports both query param and subprotocol header
364
+ if (AUTH_TOKEN) {
365
+ const url = new URL(req.url, `http://${req.headers.host || "localhost"}`);
366
+ const queryToken = url.searchParams.get("token");
367
+ const headerToken = req.headers["sec-websocket-protocol"];
368
+ if (queryToken !== AUTH_TOKEN && headerToken !== AUTH_TOKEN) {
369
+ ws.terminate();
370
+ return;
371
+ }
190
372
  }
191
373
 
192
374
  const connId = `conn_${++connectionIdCounter}`;
@@ -198,6 +380,7 @@ wss.on("connection", (ws, req) => {
198
380
  };
199
381
 
200
382
  connections.set(connId, conn);
383
+ log(` ${c.gray}${timestamp()}${c.r} ${c.RED}+${c.r} ${c.white}${connId}${c.r} ${c.dim}connected${c.r}`);
201
384
 
202
385
  // heartbeat
203
386
  const interval = setInterval(() => {
@@ -227,6 +410,8 @@ wss.on("connection", (ws, req) => {
227
410
  conn.tools.set(tool.name, tool);
228
411
  }
229
412
 
413
+ log(` ${c.gray}${timestamp()}${c.r} ${c.RED}*${c.r} ${c.white}${connId}${c.r} ${c.dim}registered${c.r} ${c.RED}${conn.tools.size}${c.r} ${c.dim}tools${c.r}`);
414
+
230
415
  sendMcp({
231
416
  jsonrpc: "2.0",
232
417
  method: "notifications/tools/list_changed",
@@ -267,10 +452,11 @@ wss.on("connection", (ws, req) => {
267
452
  }
268
453
 
269
454
  connections.delete(connId);
455
+ log(` ${c.gray}${timestamp()}${c.r} ${c.dim}-${c.r} ${c.dim}${connId} disconnected${c.r}`);
270
456
 
271
457
  sendMcp({
272
458
  jsonrpc: "2.0",
273
459
  method: "notifications/tools/list_changed",
274
460
  });
275
461
  });
276
- });
462
+ });
package/lib/websocket.js CHANGED
@@ -1,274 +1,63 @@
1
1
  // ============================================================================
2
- // Hardened Minimal WebSocket server (RFC 6455 compliant core)
3
- // Zero dependencies
2
+ // WebSocket server thin wrapper around 'ws' for API compatibility
4
3
  // ============================================================================
5
4
 
6
5
  import { createServer } from "http";
7
- import { createHash } from "crypto";
8
6
  import { EventEmitter } from "events";
9
-
10
- const GUID = "258EAFA5-E914-47DA-95CA-5AB5DC65C97B";
11
-
12
- const MAX_PAYLOAD_SIZE = 100 * 1024 * 1024; // 100MB
13
- const MAX_BUFFER_SIZE = 200 * 1024 * 1024; // 200MB total buffered
7
+ import { WebSocketServer, WebSocket } from "ws";
14
8
 
15
9
  // ---------------------------------------------------------------------------
16
- // MiniWebSocket
10
+ // MiniWebSocket — wraps a ws.WebSocket with the same API the daemon expects
17
11
  // ---------------------------------------------------------------------------
18
12
  export class MiniWebSocket extends EventEmitter {
19
- constructor(socket) {
13
+ constructor(ws) {
20
14
  super();
21
- this.socket = socket;
15
+ this._ws = ws;
22
16
  this.readyState = 1;
23
17
 
24
- this._buffer = Buffer.alloc(0);
25
-
26
- // fragmentation state
27
- this._fragType = null;
28
- this._fragBuffer = [];
29
-
30
- socket.on("data", (c) => this._onData(c));
31
- socket.on("close", () => this._handleClose());
32
- socket.on("end", () => this._handleClose());
33
- socket.on("error", (err) => {
34
- this._handleClose();
35
- this.emit("error", err);
18
+ ws.on("message", (data) => {
19
+ const text = typeof data === "string" ? data : data.toString();
20
+ this.emit("message", text);
36
21
  });
37
- }
38
-
39
- // -------------------------------------------------------------------------
40
- // Public API
41
- // -------------------------------------------------------------------------
42
- send(data) {
43
- if (this.readyState !== 1) return;
44
-
45
- const payload = Buffer.isBuffer(data)
46
- ? data
47
- : Buffer.from(String(data));
48
-
49
- this._writeFrame(0x1, payload);
50
- }
51
-
52
- close(code = 1000) {
53
- if (this.readyState !== 1) return;
54
- this.readyState = 2;
55
-
56
- const buf = Buffer.alloc(2);
57
- buf.writeUInt16BE(code, 0);
58
-
59
- this._writeFrame(0x8, buf);
60
- this.socket.end();
61
- }
62
-
63
- // -------------------------------------------------------------------------
64
- // Core frame processing
65
- // -------------------------------------------------------------------------
66
- _onData(chunk) {
67
- this._buffer = Buffer.concat([this._buffer, chunk]);
68
-
69
- if (this._buffer.length > MAX_BUFFER_SIZE) {
70
- this._fail(1009, "buffer overflow");
71
- return;
72
- }
73
-
74
- while (true) {
75
- const frame = this._decodeFrame(this._buffer);
76
- if (!frame) break;
77
-
78
- if (frame.error) {
79
- this._fail(frame.code || 1002, frame.error);
80
- return;
81
- }
82
-
83
- this._buffer = this._buffer.subarray(frame.totalLength);
84
- this._handleFrame(frame);
85
- }
86
- }
87
-
88
- _handleFrame(f) {
89
- const { opcode, payload, fin } = f;
90
-
91
- // control frames
92
- if (opcode === 0x8) {
93
- // close
94
- this.close();
95
- return;
96
- }
97
-
98
- if (opcode === 0x9) {
99
- // ping → pong
100
- this._writeFrame(0xA, payload);
101
- return;
102
- }
103
-
104
- if (opcode === 0xA) {
105
- this.emit("pong");
106
- return;
107
- }
108
22
 
109
- // fragmentation handling
110
- if (opcode === 0x0) {
111
- if (!this._fragType) return this._fail(1002, "unexpected continuation");
112
-
113
- this._fragBuffer.push(payload);
114
-
115
- if (fin) {
116
- const full = Buffer.concat(this._fragBuffer);
117
- this._emitMessage(this._fragType, full);
118
- this._fragType = null;
119
- this._fragBuffer = [];
120
- }
121
- return;
122
- }
123
-
124
- if (opcode === 0x1 || opcode === 0x2) {
125
- if (this._fragType) return this._fail(1002, "nested fragments");
23
+ ws.on("close", () => {
24
+ if (this.readyState === 3) return;
25
+ this.readyState = 3;
26
+ this.emit("close");
27
+ });
126
28
 
127
- if (!fin) {
128
- this._fragType = opcode;
129
- this._fragBuffer = [payload];
130
- return;
29
+ ws.on("error", (err) => {
30
+ if (this.readyState !== 3) {
31
+ this.readyState = 3;
32
+ this.emit("close");
131
33
  }
34
+ this.emit("error", err);
35
+ });
132
36
 
133
- this._emitMessage(opcode, payload);
134
- return;
135
- }
136
-
137
- this._fail(1002, "invalid opcode");
138
- }
139
-
140
- _emitMessage(opcode, payload) {
141
- if (opcode === 0x1) {
142
- // text
143
- try {
144
- const text = new TextDecoder("utf-8", { fatal: true }).decode(payload);
145
- this.emit("message", text);
146
- } catch {
147
- this._fail(1007, "invalid utf8");
148
- }
149
- } else {
150
- // binary
151
- this.emit("message", payload);
152
- }
37
+ ws.on("pong", () => this.emit("pong"));
153
38
  }
154
39
 
155
- // -------------------------------------------------------------------------
156
- // Frame decode (STRICT)
157
- // -------------------------------------------------------------------------
158
- _decodeFrame(buf) {
159
- if (buf.length < 2) return null;
160
-
161
- const b0 = buf[0];
162
- const b1 = buf[1];
163
-
164
- const fin = (b0 & 0x80) !== 0;
165
- const opcode = b0 & 0x0f;
166
-
167
- const masked = (b1 & 0x80) !== 0;
168
- let payloadLen = b1 & 0x7f;
169
-
170
- // MUST be masked (client → server)
171
- if (!masked) return { error: "unmasked frame", code: 1002 };
172
-
173
- // opcode validation
174
- const valid = [0x0, 0x1, 0x2, 0x8, 0x9, 0xA];
175
- if (!valid.includes(opcode)) {
176
- return { error: "invalid opcode", code: 1002 };
177
- }
178
-
179
- let offset = 2;
180
-
181
- if (payloadLen === 126) {
182
- if (buf.length < 4) return null;
183
- payloadLen = buf.readUInt16BE(2);
184
- offset = 4;
185
- } else if (payloadLen === 127) {
186
- if (buf.length < 10) return null;
187
- const big = buf.readBigUInt64BE(2);
188
- if (big > BigInt(MAX_PAYLOAD_SIZE)) {
189
- return { error: "too large", code: 1009 };
190
- }
191
- payloadLen = Number(big);
192
- offset = 10;
193
- }
194
-
195
- if (payloadLen > MAX_PAYLOAD_SIZE) {
196
- return { error: "too large", code: 1009 };
197
- }
198
-
199
- // control frames rules
200
- if (opcode >= 0x8) {
201
- if (!fin) return { error: "fragmented control", code: 1002 };
202
- if (payloadLen > 125) return { error: "control too large", code: 1002 };
203
- }
204
-
205
- const total = offset + 4 + payloadLen;
206
- if (buf.length < total) return null;
207
-
208
- const mask = buf.subarray(offset, offset + 4);
209
- const payload = Buffer.alloc(payloadLen);
210
-
211
- for (let i = 0; i < payloadLen; i++) {
212
- payload[i] = buf[offset + 4 + i] ^ mask[i % 4];
213
- }
40
+ get socket() { return this._ws._socket || null; }
41
+ get bufferedAmount() { return this._ws.bufferedAmount; }
214
42
 
215
- return {
216
- fin,
217
- opcode,
218
- payload,
219
- totalLength: total,
220
- };
43
+ send(data) {
44
+ if (this._ws.readyState !== WebSocket.OPEN) return;
45
+ this._ws.send(typeof data === "string" ? data : String(data));
221
46
  }
222
47
 
223
- // -------------------------------------------------------------------------
224
- // Frame encode
225
- // -------------------------------------------------------------------------
226
- _writeFrame(opcode, payload) {
227
- if (this.readyState !== 1) return;
228
-
229
- const len = payload.length;
230
- let header;
231
-
232
- if (len < 126) {
233
- header = Buffer.alloc(2);
234
- header[1] = len;
235
- } else if (len < 65536) {
236
- header = Buffer.alloc(4);
237
- header[1] = 126;
238
- header.writeUInt16BE(len, 2);
239
- } else {
240
- header = Buffer.alloc(10);
241
- header[1] = 127;
242
- header.writeBigUInt64BE(BigInt(len), 2);
243
- }
244
-
245
- header[0] = 0x80 | opcode;
246
-
247
- const frame = Buffer.concat([header, payload]);
248
-
249
- if (!this.socket.write(frame)) {
250
- this.socket.once("drain", () => {});
48
+ close(code = 1000) {
49
+ if (this._ws.readyState === WebSocket.OPEN || this._ws.readyState === WebSocket.CONNECTING) {
50
+ this._ws.close(code);
251
51
  }
252
52
  }
253
53
 
254
- _fail(code, reason) {
255
- try {
256
- const buf = Buffer.alloc(2);
257
- buf.writeUInt16BE(code, 0);
258
- this._writeFrame(0x8, buf);
259
- } catch {}
260
- this.socket.destroy();
261
- }
262
-
263
- _handleClose() {
264
- if (this.readyState === 3) return;
265
- this.readyState = 3;
266
- this.emit("close");
54
+ terminate() {
55
+ this._ws.terminate();
267
56
  }
268
57
  }
269
58
 
270
59
  // ---------------------------------------------------------------------------
271
- // Server
60
+ // MiniWebSocketServer — wraps ws.WebSocketServer, emits MiniWebSocket
272
61
  // ---------------------------------------------------------------------------
273
62
  export class MiniWebSocketServer extends EventEmitter {
274
63
  constructor({ port }, cb) {
@@ -279,51 +68,20 @@ export class MiniWebSocketServer extends EventEmitter {
279
68
  res.end("Upgrade required");
280
69
  });
281
70
 
282
- this.server.on("upgrade", (req, socket, head) => {
283
- // strict handshake validation
284
- if (
285
- req.headers["upgrade"]?.toLowerCase() !== "websocket" ||
286
- !req.headers["connection"]?.toLowerCase().includes("upgrade") ||
287
- req.headers["sec-websocket-version"] !== "13"
288
- ) {
289
- socket.destroy();
290
- return;
291
- }
292
-
293
- const key = req.headers["sec-websocket-key"];
294
- if (!key) {
295
- socket.destroy();
296
- return;
297
- }
298
-
299
- const accept = createHash("sha1")
300
- .update(key + GUID)
301
- .digest("base64");
302
-
303
- // subprotocol (used for auth in your daemon)
304
- const protocol = req.headers["sec-websocket-protocol"];
305
-
306
- socket.setNoDelay(true);
307
- socket.setTimeout(0);
308
-
309
- socket.write(
310
- "HTTP/1.1 101 Switching Protocols\r\n" +
311
- "Upgrade: websocket\r\n" +
312
- "Connection: Upgrade\r\n" +
313
- `Sec-WebSocket-Accept: ${accept}\r\n` +
314
- (protocol ? `Sec-WebSocket-Protocol: ${protocol}\r\n` : "") +
315
- "\r\n"
316
- );
317
-
318
- if (head?.length) socket.unshift(head);
71
+ this._wss = new WebSocketServer({
72
+ server: this.server,
73
+ perMessageDeflate: false,
74
+ });
319
75
 
320
- this.emit("connection", new MiniWebSocket(socket), req);
76
+ this._wss.on("connection", (ws, req) => {
77
+ this.emit("connection", new MiniWebSocket(ws), req);
321
78
  });
322
79
 
323
80
  this.server.listen(port, "127.0.0.1", cb);
324
81
  }
325
82
 
326
83
  close(cb) {
84
+ this._wss.close();
327
85
  this.server.close(cb);
328
86
  }
329
- }
87
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@businessmaps/bifrost",
3
- "version": "0.0.1",
3
+ "version": "0.1.2",
4
4
  "description": "Bridge any browser web app to Claude Code via MCP",
5
5
  "type": "module",
6
6
  "bin": {
@@ -35,5 +35,8 @@
35
35
  "lib/",
36
36
  "LICENSE",
37
37
  "README.md"
38
- ]
38
+ ],
39
+ "dependencies": {
40
+ "ws": "^8.20.0"
41
+ }
39
42
  }