--[[ 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. -- By default prefers wired modems (for reliable LAN communication). -- @param opts table|boolean|nil Options table { preferWireless = bool }, -- or legacy boolean (true = prefer wireless) -- @return table|nil Modem peripheral handle -- @return string|nil Peripheral name/side function WebBridge.findModem(opts) -- Legacy support: boolean arg means preferWireless if type(opts) == 'boolean' then opts = { preferWireless = opts } end opts = opts or {} -- Try Opus device registry first if _G.device then if opts.preferWireless and _G.device.wireless_modem then return _G.device.wireless_modem, 'wireless_modem' end -- Two-pass: prefer wired modems first, then any modem -- (pairs() order is non-deterministic; without two passes, -- a wireless modem might be returned even when wired is available) local fallback, fallbackName for name, dev in pairs(_G.device) do if dev.type == 'wired_modem' then return dev, name elseif dev.type == 'modem' or dev.type == 'wireless_modem' then if not fallback then fallback, fallbackName = dev, name end end end if fallback then return fallback, fallbackName end end -- Fallback: scan peripherals (prefer wired) local wirelessDev, wirelessName for _, name in ipairs(peripheral.getNames()) do if peripheral.getType(name) == 'modem' then local dev = peripheral.wrap(name) if dev.isWireless and not dev.isWireless() then return dev, name -- wired modem found elseif not wirelessDev then wirelessDev, wirelessName = dev, name end end end if wirelessDev then return wirelessDev, wirelessName 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 -- Use async API to avoid unfiltered os.pullEvent() in http.websocket() local aok, aerr = http.websocketAsync(wsUrl) if not aok then if handlers.onError then handlers.onError(aerr) end os.sleep(reconnectDelay) else -- Wait for websocket_success or websocket_failure (filtered) local ws = nil while true do local event, url, param = os.pullEvent() if event == 'websocket_success' and url == wsUrl then ws = param break elseif event == 'websocket_failure' and url == wsUrl then if handlers.onError then handlers.onError(param) end break end end if ws then if handlers.onConnect then handlers.onConnect(ws) end -- Event-based message loop — uses os.pullEvent() with awareness -- of websocket_message, websocket_closed, and timer events. -- Unlike ws.receive(), this loop does NOT swallow unrelated events -- because CC:Tweaked's parallel scheduler delivers each event to -- all coroutines whose filter matches (or filter is nil). local keepaliveTimer = os.startTimer(receiveTimeout) while true do local event, p1, p2, p3 = os.pullEvent() if event == 'websocket_message' and p1 == wsUrl then -- Reset keepalive timer on each message os.cancelTimer(keepaliveTimer) keepaliveTimer = os.startTimer(receiveTimeout) local data = textutils.unserialiseJSON(p2) if data then if data.type == 'pong' then -- Keepalive response, handled elseif handlers.onMessage then handlers.onMessage(ws, data) end end elseif event == 'websocket_closed' and p1 == wsUrl then break elseif event == 'timer' and p1 == keepaliveTimer then -- No message received within timeout — send keepalive ping local pok = pcall(ws.send, textutils.serialiseJSON({ type = 'ping' })) if not pok then break end keepaliveTimer = os.startTimer(receiveTimeout) end end pcall(ws.close) if handlers.onDisconnect then handlers.onDisconnect() end end os.sleep(reconnectDelay) end 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