UNPKG

grix-connector

Version:

Connect local AI coding agents (Claude, Codex, Gemini, Qwen, DeepSeek, Cursor, OpenCode, Pi, OpenHuman, Reasonix) to the Grix scheduling platform. Also serves as an OpenClaw plugin for Grix channel transport.

2 lines (1 loc) 5.15 kB
import S from"node:http";import{randomBytes as k}from"node:crypto";import{log as h}from"../log/index.js";import{PHASE1_INVOKE_ACTIONS as y}from"./tools.js";import{WebSocketServer as O,WebSocket as w}from"ws";const p=1024*1024,v=50,b=1e3,m=6e4,H=256,N=new Set([...y,"event_tool_call"]);class B{server=null;port=0;authToken;invokeHandler=null;statusLineHandler=null;activeInvokes=0;wss=null;bridgeWs=null;mcpBridgeUpHandler=null;downstreamOutbox=[];constructor(){this.authToken=k(32).toString("hex")}setInvokeHandler(e){this.invokeHandler=e}setStatusLineHandler(e){this.statusLineHandler=e}async start(e=0){return new Promise((t,s)=>{this.server=S.createServer((r,i)=>this.handleRequest(r,i)),this.wss=new O({noServer:!0}),this.server.on("upgrade",(r,i,n)=>{const a=new URL(r.url??"/",`http://127.0.0.1:${this.port}`);if(a.pathname!=="/mcp-bridge"||a.searchParams.get("token")!==this.authToken){i.destroy();return}this.wss.handleUpgrade(r,i,n,o=>{if(this.bridgeWs=o,h.info("internal-api","mcp bridge connected"),this.downstreamOutbox.length>0){const l=this.downstreamOutbox;this.downstreamOutbox=[];for(const d of l)o.send(d);h.info("internal-api",`mcp bridge flushed ${l.length} buffered downstream frame(s)`)}o.on("message",l=>{let d;try{d=JSON.parse(l.toString("utf8"))}catch{return}this.mcpBridgeUpHandler?.(d)}),o.on("close",()=>{this.bridgeWs===o&&(this.bridgeWs=null),h.info("internal-api","mcp bridge disconnected")}),o.on("error",()=>{})})}),this.server.on("error",s),this.server.listen(e,"127.0.0.1",()=>{const r=this.server.address();typeof r=="object"&&r&&(this.port=r.port),this.server.removeListener("error",s),t(this.port)})})}get url(){return`http://127.0.0.1:${this.port}?token=${this.authToken}`}get baseUrl(){return`http://127.0.0.1:${this.port}`}get mcpBridgeWsUrl(){return`ws://127.0.0.1:${this.port}/mcp-bridge?token=${this.authToken}`}setMcpBridgeUpHandler(e){this.mcpBridgeUpHandler=e}sendMcpFrameToBridge(e){const t=JSON.stringify(e);if(this.bridgeWs&&this.bridgeWs.readyState===w.OPEN){this.bridgeWs.send(t);return}this.downstreamOutbox.length>=H&&(this.downstreamOutbox.shift(),h.warn("internal-api","mcp downstream outbox full, dropping oldest frame")),this.downstreamOutbox.push(t)}get token(){return this.authToken}async stop(){if(this.server)return new Promise(e=>{this.server.close(()=>{this.server=null,e()})})}checkAuth(e){return e.headers.authorization===`Bearer ${this.authToken}`||new URL(e.url??"/",`http://127.0.0.1:${this.port}`).searchParams.get("token")===this.authToken}handleRequest(e,t){if(e.method!=="POST"){t.writeHead(405),t.end(JSON.stringify({error:"method not allowed"}));return}if(!this.checkAuth(e)){t.writeHead(401),t.end(JSON.stringify({ok:!1,error:"unauthorized"}));return}const s=new URL(e.url??"/",`http://127.0.0.1:${this.port}`);if(s.pathname==="/api/status-line"){this.handleStatusLine(e,t);return}if(!s.pathname.startsWith("/api/invoke")){t.writeHead(404),t.end(JSON.stringify({error:"not found"}));return}const r=[];let i=0;e.on("data",n=>{if(i+=n.length,i>p){t.writeHead(413),t.end(JSON.stringify({ok:!1,error:"request body too large"})),e.destroy();return}r.push(n)}),e.on("end",async()=>{let n;try{n=JSON.parse(Buffer.concat(r).toString("utf8"))}catch{t.writeHead(400),t.end(JSON.stringify({ok:!1,error:"invalid JSON"}));return}const a=n.action,o=n.params??{};if(!a||!this.invokeHandler){t.writeHead(400),t.end(JSON.stringify({ok:!1,error:"missing action or no handler"}));return}if(!N.has(a)){t.writeHead(400),t.end(JSON.stringify({ok:!1,error:`action '${a}' is not allowed`}));return}if(this.activeInvokes>=v){t.writeHead(429),t.end(JSON.stringify({ok:!1,error:"too many concurrent invokes"}));return}const l=n.timeout_ms!=null?Math.max(b,Math.min(n.timeout_ms,m)):m,d=Date.now();this.activeInvokes++;const c=new AbortController;try{const u=await Promise.race([this.invokeHandler(a,o,c.signal),new Promise((f,g)=>setTimeout(()=>{c.abort(),g(new Error("invoke timeout"))},l))]);t.writeHead(200,{"content-type":"application/json"}),t.end(JSON.stringify({ok:!0,data:u})),h.info("internal-api",`invoke ok action=${a} tool=${o.tool_name??"-"} ms=${Date.now()-d}`)}catch(u){c.abort();const f=u instanceof Error?u.message:String(u);t.writeHead(500),t.end(JSON.stringify({ok:!1,error:f})),h.error("internal-api",`invoke error action=${a} tool=${o.tool_name??"-"} ms=${Date.now()-d} err=${f}`)}finally{this.activeInvokes--}})}handleStatusLine(e,t){const s=[];let r=0;e.on("data",i=>{if(r+=i.length,r>p){t.writeHead(413),t.end(JSON.stringify({ok:!1,error:"request body too large"})),e.destroy();return}s.push(i)}),e.on("end",async()=>{let i;try{i=JSON.parse(Buffer.concat(s).toString("utf8"))}catch{t.writeHead(400),t.end(JSON.stringify({ok:!1,error:"invalid JSON"}));return}if(!this.statusLineHandler){t.writeHead(200),t.end(JSON.stringify({ok:!0}));return}try{await this.statusLineHandler(i),t.writeHead(200,{"content-type":"application/json"}),t.end(JSON.stringify({ok:!0}))}catch(n){console.error("[internal-api] statusLine handler error:",n instanceof Error?n.message:n),t.writeHead(200,{"content-type":"application/json"}),t.end(JSON.stringify({ok:!0}))}})}}export{B as InternalApiServer};