Skip to main content
Glama
bridge.ts6.83 kB
import { randomUUID } from "node:crypto"; import type { IncomingMessage } from "node:http"; import { WebSocketServer, WebSocket } from "ws"; import type { RawData } from "ws"; import type { BridgeClientMessage, BridgeServerMessage, CommandName, CommandPayload, CommandResult, } from "@yetidevworks/shared"; type PendingRequest = { resolve: (value: unknown) => void; reject: (error: Error) => void; timeout: NodeJS.Timeout; command: CommandName; }; const DEFAULT_TIMEOUT_MS = 30_000; export interface ExtensionBridgeOptions { port: number; requestTimeoutMs?: number; } export class ExtensionBridge { private readonly options: Required<ExtensionBridgeOptions>; private wss: WebSocketServer | undefined; private socket: WebSocket | undefined; private pending = new Map<string, PendingRequest>(); private hello: { client: string; version?: string } | undefined; constructor(options: ExtensionBridgeOptions) { this.options = { requestTimeoutMs: DEFAULT_TIMEOUT_MS, ...options, }; } async start(): Promise<void> { if (this.wss) { return; } const wss = new WebSocketServer({ port: this.options.port }); this.wss = wss; wss.on("connection", (socket, request) => this.handleConnection(socket, request)); const listenPromise = new Promise<void>((resolve, reject) => { const handleError = (error: unknown) => { const err = error as NodeJS.ErrnoException; if (err?.code === "EADDRINUSE") { reject( new Error( `WebSocket port ${this.options.port} is already in use. Another YetiBrowser MCP instance might be running. Use --ws-port to pick a different port.`, ), ); } else { reject(err instanceof Error ? err : new Error(String(err))); } }; wss.once("error", handleError); wss.once("listening", () => { wss.off("error", handleError); resolve(); }); }); try { await listenPromise; } catch (error) { this.wss = undefined; wss.removeAllListeners(); try { wss.close(); } catch (closeError) { console.error("Failed to close WebSocket server after startup error", closeError); } throw error; } wss.on("error", (error) => { console.error("WebSocket server error", error); }); console.error(`[yetibrowser] Waiting for extension on ws://localhost:${this.options.port}`); } isConnected(): boolean { return !!this.socket && this.socket.readyState === WebSocket.OPEN; } getHelloInfo(): { client: string; version?: string } | undefined { return this.hello; } getPort(): number { return this.options.port; } async close(): Promise<void> { this.rejectAllPending(new Error("Extension bridge shutting down")); if (this.socket) { try { this.socket.terminate(); } catch (error) { console.error("Failed to terminate WebSocket", error); } this.socket.removeAllListeners(); this.socket = undefined; } if (this.wss) { await new Promise<void>((resolve) => this.wss?.close(() => resolve())); this.wss.removeAllListeners(); this.wss = undefined; } } async send<K extends CommandName>( command: K, payload: CommandPayload<K>, ): Promise<CommandResult<K>> { if (!this.socket || this.socket.readyState !== WebSocket.OPEN) { throw new Error( "YetiBrowser extension not connected. Click the extension icon and connect a tab before using this tool.", ); } const id = randomUUID(); const message: BridgeServerMessage = { type: "call", id, command, payload, }; const raw = JSON.stringify(message); return await new Promise<CommandResult<K>>((resolve, reject) => { const timeout = setTimeout(() => { this.pending.delete(id); reject(new Error(`Extension response timed out for command "${command}"`)); }, this.options.requestTimeoutMs); this.pending.set(id, { resolve: (value) => resolve(value as CommandResult<K>), reject, timeout, command, }); try { this.socket!.send(raw); } catch (error) { clearTimeout(timeout); this.pending.delete(id); reject(error instanceof Error ? error : new Error(String(error))); } }); } private handleConnection(socket: WebSocket, request: IncomingMessage): void { if (this.socket) { console.warn("Existing extension connection detected. Closing previous socket."); this.socket.terminate(); this.rejectAllPending(new Error("Previous connection was replaced by a new socket")); } this.socket = socket; console.error(`[yetibrowser] Extension connected from ${request.socket.remoteAddress ?? "unknown"}`); socket.on("message", (data: RawData) => this.handleMessage(data)); socket.on("error", (error: Error) => { console.error("Extension socket error", error); this.rejectAllPending(new Error("Extension socket error")); }); socket.on("close", () => { console.error("[yetibrowser] Extension disconnected"); this.socket = undefined; this.rejectAllPending(new Error("Extension disconnected")); }); } private handleMessage(data: RawData): void { let message: BridgeClientMessage; try { message = JSON.parse(data.toString()); } catch (error) { console.error("Failed to parse message from extension", error); return; } if (message.type === "hello") { this.hello = { client: message.client, version: message.version }; console.error( `[yetibrowser] Extension hello from ${message.client}${message.version ? ` v${message.version}` : ""}`, ); return; } if (message.type === "event") { console.error("[yetibrowser] extension event", message.event, message.payload); return; } if (message.type === "result") { const pending = this.pending.get(message.id); if (!pending) { console.warn(`Received result for unknown id ${message.id}`); return; } const { resolve, reject, timeout, command } = pending; clearTimeout(timeout); this.pending.delete(message.id); if (!message.ok) { reject(new Error(message.error ?? `Command "${command}" failed`)); return; } resolve(message.result as unknown); return; } console.warn("Received unsupported message from extension", message); } private rejectAllPending(error: Error): void { for (const [id, pending] of this.pending.entries()) { clearTimeout(pending.timeout); pending.reject(error); this.pending.delete(id); } } }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/yetidevworks/yetibrowser-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server