@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 +17 -12
- package/bin/bifrost +205 -19
- package/lib/websocket.js +39 -281
- package/package.json +5 -2
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.
|
|
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.
|
|
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
|
-
##
|
|
78
|
+
## Options
|
|
81
79
|
|
|
82
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
174
|
-
|
|
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
|
-
//
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
//
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
//
|
|
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(
|
|
13
|
+
constructor(ws) {
|
|
20
14
|
super();
|
|
21
|
-
this.
|
|
15
|
+
this._ws = ws;
|
|
22
16
|
this.readyState = 1;
|
|
23
17
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
this.
|
|
130
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
|
|
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
|
-
|
|
255
|
-
|
|
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
|
-
//
|
|
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.
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
}
|