Files
cc-platform-core/apis/webbridge.lua
MayaTheShy 00ac2d7d6f fix: prefer wired modems in findModem() to prevent wireless/wired mismatch
WebBridge.findModem() iterated _G.device with pairs() which has
non-deterministic order. On computers with both wired and wireless
modems, it could return the wireless modem while the manager uses
a wired modem - messages never cross the wired/wireless boundary.

Now uses two-pass iteration: first looks for wired modems, then
falls back to any modem. Peripheral scan fallback also prefers wired.
Backward-compatible: boolean arg (preferWireless) still works.
2026-03-29 01:32:37 -04:00

385 lines
12 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.
-- 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