cc-libs/apis/libhttpws.lua

141 lines
4.0 KiB
Lua

-- 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;