Files
cc-platform-core/apis/webbridge.lua
2026-03-26 15:00:49 -04:00

333 lines
10 KiB
Lua

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