-- WebSocket transport for the opencode bridge proxy. -- -- Implements the same client surface as libhttp (getJson/postJson plus the -- trimTrailingSlash/queryString helpers libai calls), but performs each call as -- a blocking websocket round-trip to the mcp-bridge opencode proxy instead of a -- direct http.get/post. ComputerCraft's http API caps requests at ~60s; a -- websocket round-trip has no such cap, so the bridge can run a synchronous -- opencode request server-side for as long as it needs. local DEFAULT_RECEIVE_TIMEOUT_SECONDS = 600; local function isBlank(s) return type(s) ~= 'string' or string.match(s, '^%s*$') ~= nil; end local function trimTrailingSlash(s) return (s:gsub('/+$', '')); end local function urlEncode(s) return (tostring(s):gsub('[^%w%-_%.~]', function(c) return string.format('%%%02X', string.byte(c)); end)); end local function queryString(params) local parts = {}; for _, item in ipairs(params) do if not isBlank(item[2]) then parts[#parts + 1] = urlEncode(item[1]) .. '=' .. urlEncode(item[2]); end end if #parts == 0 then return ''; end return '?' .. table.concat(parts, '&'); end local function createHttpWs(opts) opts = opts or {}; local httpLib = opts.http or http; local textutilsLib = opts.textutils or textutils; local bridgeUrl = opts.bridgeUrl; local receiveTimeout = tonumber(opts.receiveTimeout) or DEFAULT_RECEIVE_TIMEOUT_SECONDS; local api = { trimTrailingSlash = trimTrailingSlash, urlEncode = urlEncode, queryString = queryString, }; local activeWs = nil; local function ensureSocket() if activeWs then return activeWs, nil; end if isBlank(bridgeUrl) then return nil, 'missing opencc.bridge_url'; end local ws, err = httpLib.websocket(bridgeUrl); if not ws then return nil, 'bridge unreachable: ' .. tostring(err); end activeWs = ws; return ws, nil; end local function closeSocket() if activeWs then pcall(function() activeWs.close(); end); activeWs = nil; end end local idCounter = 0; local function nextId() idCounter = idCounter + 1; local stamp = os.epoch and os.epoch('utc') or os.clock(); return 'req_' .. tostring(stamp) .. '_' .. tostring(idCounter) .. '_' .. tostring(math.random(100000, 999999)); end local function trySend(ws, text) return pcall(function() ws.send(text); end); end local function roundtrip(method, path, payload) local ws, err = ensureSocket(); if not ws then return nil, err; end local id = nextId(); local frame = { type = 'http', id = id, method = method, path = path }; if payload ~= nil then frame.body = textutilsLib.serializeJSON(payload); end local text = textutilsLib.serializeJSON(frame); if not trySend(ws, text) then -- Socket went away; drop it and try one fresh reconnect. closeSocket(); ws, err = ensureSocket(); if not ws then return nil, err; end if not trySend(ws, text) then closeSocket(); return nil, 'bridge send failed'; end end while true do local ok, message = pcall(function() return ws.receive(receiveTimeout); end); if not ok then closeSocket(); return nil, 'bridge connection closed'; end if message == nil then return nil, 'timeout waiting for bridge response'; end local decoded = textutilsLib.unserializeJSON(message); if type(decoded) == 'table' and decoded.type == 'http-response' and decoded.id == id then if decoded.status == nil or decoded.status == 0 then return nil, decoded.error or 'bridge error'; end return decoded.body, decoded.status; end -- Ignore frames that don't correlate (stale/other ids) and keep waiting. end end function api.getJson(_cfg, path) return roundtrip('GET', path, nil); end function api.postJson(_cfg, path, payload) return roundtrip('POST', path, payload); end function api.close() closeSocket(); end return api; end return createHttpWs;