initial commit
This commit is contained in:
332
apis/webbridge.lua
Normal file
332
apis/webbridge.lua
Normal file
@@ -0,0 +1,332 @@
|
||||
--[[
|
||||
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
|
||||
Reference in New Issue
Block a user