--[[ platform.webbridge — Shared webbridge infrastructure for cc-platform-core Provides the common infrastructure pattern used by all services that bridge modem communication to an external web server. Extracted from the structurally identical patterns in remoteturtle/webbridge.lua and Inventory-Manager-CC/inventoryWebBridge.lua. Components: - Config loading (JSON file with path resolution) - HTTP helpers (POST/GET with error handling) - WebSocket connection manager (connect, reconnect, keepalive) - HTTP poll→process→ack cycle (fallback transport) - Modem channel management - parallel.waitForAny main loop skeleton Usage: local WebBridge = require('platform.webbridge') local config = WebBridge.loadConfig({ serverUrl = 'http://localhost:3001', }, { 'usr/config/myservice/.webbridge_config', '.webbridge_config', }) -- HTTP helpers local body = WebBridge.httpGet(url, headers) local resp = WebBridge.httpPost(url, data, headers) -- WebSocket with auto-reconnect WebBridge.wsConnect(wsUrl, { onConnect = function(ws) ... end, onMessage = function(ws, data) ... end, onDisconnect = function() ... end, }) ]] local textutils = _G.textutils local http = _G.http local fs = _G.fs local os = _G.os local peripheral = _G.peripheral local Channels = require('platform.channels') local WebBridge = {} --------------------------------------------------------------------------- -- Config loading --------------------------------------------------------------------------- --- Load a JSON config file, trying multiple paths in order. -- Merges found config over the provided defaults. -- @param defaults table Default config values -- @param configPaths table Array of file paths to try (first match wins) -- @return table Merged config -- @return string|nil Path of the file that was loaded, or nil if none found function WebBridge.loadConfig(defaults, configPaths) defaults = defaults or {} configPaths = configPaths or {} for _, path in ipairs(configPaths) do if fs.exists(path) then local f = fs.open(path, 'r') if f then local raw = f.readAll() f.close() local ok, cfg = pcall(textutils.unserialiseJSON, raw) if ok and type(cfg) == 'table' then for k, v in pairs(cfg) do defaults[k] = v end return defaults, path end end end end return defaults, nil end --- Save a config table to a JSON file. -- Creates parent directories if needed. -- @param path string File path -- @param config table Config to save -- @return boolean true on success function WebBridge.saveConfig(path, config) local dir = fs.getDir(path) if dir and dir ~= '' and not fs.isDir(dir) then fs.makeDir(dir) end local ok = pcall(function() local f = fs.open(path, 'w') f.write(textutils.serialiseJSON(config)) f.close() end) return ok end --------------------------------------------------------------------------- -- HTTP helpers --------------------------------------------------------------------------- --- Perform an HTTP POST with JSON body. -- @param url string Target URL -- @param data table Data to serialize as JSON -- @param headers table|nil Additional headers -- @return string|nil Response body, or nil on error -- @return string|nil Error message on failure function WebBridge.httpPost(url, data, headers) headers = headers or {} headers['Content-Type'] = headers['Content-Type'] or 'application/json' local body = textutils.serialiseJSON(data) local ok, result = pcall(function() local response = http.post(url, body, headers) if response then local responseBody = response.readAll() response.close() return responseBody end return nil end) if not ok then return nil, tostring(result) end return result end --- Perform an HTTP GET request. -- @param url string Target URL -- @param headers table|nil Additional headers -- @return string|nil Response body, or nil on error -- @return string|nil Error message on failure function WebBridge.httpGet(url, headers) local ok, result = pcall(function() local response = http.get(url, headers) if response then local body = response.readAll() response.close() return body end return nil end) if not ok then return nil, tostring(result) end return result end --- Build authorization headers for API key authentication. -- Returns an empty table if no key is provided. -- @param apiKey string|nil API key -- @return table Headers table function WebBridge.authHeaders(apiKey) if apiKey and #apiKey > 0 then return { ['Authorization'] = 'Bearer ' .. apiKey } end return {} end --------------------------------------------------------------------------- -- Modem management --------------------------------------------------------------------------- --- Find a modem peripheral. -- Prefers the Opus device registry, falls back to peripheral scan. -- @param preferWireless boolean|nil If true, prefer wireless modem -- @return table|nil Modem peripheral handle -- @return string|nil Peripheral name/side function WebBridge.findModem(preferWireless) -- Try Opus device registry first if _G.device then if preferWireless and _G.device.wireless_modem then return _G.device.wireless_modem, 'wireless_modem' end -- Find any modem in device registry for name, dev in pairs(_G.device) do if dev.type == 'modem' or dev.type == 'wireless_modem' or dev.type == 'wired_modem' then return dev, name end end end -- Fallback: scan peripherals for _, name in ipairs(peripheral.getNames()) do if peripheral.getType(name) == 'modem' then return peripheral.wrap(name), name end end return nil end --- Open a list of named channels on a modem. -- Respects dual-mode migration (opens both current and target channels). -- @param modem table Modem peripheral handle -- @param channelNames table Array of logical channel names function WebBridge.openChannels(modem, channelNames) for _, name in ipairs(channelNames) do for _, ch in ipairs(Channels.getBoth(name)) do modem.open(ch) end end end --------------------------------------------------------------------------- -- WebSocket connection manager --------------------------------------------------------------------------- --- Manage a persistent WebSocket connection with auto-reconnect. -- Blocks indefinitely, reconnecting on failure with a configurable delay. -- Handles ping/pong keepalive automatically. -- @param wsUrl string WebSocket URL -- @param handlers table Callbacks: -- onConnect(ws) Called when connection is established -- onMessage(ws, data) Called for each parsed JSON message (except pong) -- onDisconnect() Called when connection drops -- onError(err) Called on connection error (optional) -- @param opts table|nil Options: -- reconnectDelay number Seconds between reconnect attempts (default 5) -- receiveTimeout number Seconds before ping on idle (default 30) function WebBridge.wsConnect(wsUrl, handlers, opts) handlers = handlers or {} opts = opts or {} local reconnectDelay = opts.reconnectDelay or 5 local receiveTimeout = opts.receiveTimeout or 30 while true do local ok, ws = pcall(http.websocket, wsUrl) if ok and ws then if handlers.onConnect then handlers.onConnect(ws) end -- Message receive loop while true do local rok, msg = pcall(ws.receive, receiveTimeout) if not rok then -- Connection error break end if not msg then -- Timeout — send keepalive ping local pok = pcall(ws.send, textutils.serialiseJSON({ type = 'ping' })) if not pok then break end else local data = textutils.unserialiseJSON(msg) if data then if data.type == 'pong' then -- Keepalive response, handled elseif handlers.onMessage then handlers.onMessage(ws, data) end end end end pcall(ws.close) if handlers.onDisconnect then handlers.onDisconnect() end else if handlers.onError then handlers.onError(ws) -- ws contains the error string on failure end end os.sleep(reconnectDelay) end end --------------------------------------------------------------------------- -- HTTP poll→process→ack cycle (WebSocket fallback transport) --------------------------------------------------------------------------- --- Run a poll→process→ack loop for HTTP-based command polling. -- Blocks indefinitely, polling at the specified interval. -- Uses monotonic ID-based acknowledgment for deduplication safety. -- @param opts table Options: -- pollUrl string URL to GET pending commands -- ackUrl string URL to POST acknowledgment -- headers table Auth/custom headers -- interval number Poll interval in seconds (default 1) -- onCommand function Called for each received command -- shouldPoll function Optional gate — return false to skip this cycle function WebBridge.pollCommands(opts) local pollUrl = opts.pollUrl local ackUrl = opts.ackUrl local headers = opts.headers or {} local interval = opts.interval or 1 local onCommand = opts.onCommand local shouldPoll = opts.shouldPoll local lastId = 0 while true do if not shouldPoll or shouldPoll() then local body = WebBridge.httpGet(pollUrl, headers) if body then local ok, commands = pcall(textutils.unserialiseJSON, body) if ok and type(commands) == 'table' then for _, cmd in ipairs(commands) do if onCommand then onCommand(cmd) end -- Track highest processed ID for dedup-safe ack if cmd.id and cmd.id > lastId then lastId = cmd.id end end if #commands > 0 and ackUrl then WebBridge.httpPost(ackUrl, { lastProcessedId = lastId }, headers) end end end end os.sleep(interval) end end return WebBridge