--!strict
-- net/api.luau — cliente HTTP/JSON da API do daemon.
--
-- Encapsula base_url + header Authorization e expõe `rpc(cmd)`: faz
-- POST /api/rpc com o Command em JSON e devolve a Response decodificada. Fala
-- pelo cliente `http()` do glacier (glacier-ui 0.145+), que SUSPENDE a corrotina
-- da ação (async/await) — a UI não trava enquanto espera a resposta. O cliente
-- guarda base_url, Authorization e o teto de tempo num lugar só; a tradução de
-- "falhou" para texto (`failure_text`) também.
--
-- `rpc` é POST: o cliente NUNCA repete um POST sozinho (o primeiro pode ter
-- chegado ao daemon — repetir um `ServiceDelete` ou um deploy seria perigoso).
-- Só as leituras puras (GET, como o `/api/version`) usam `retries`.
--
-- Convenção de Command (serde externally-tagged):
-- • variante unitária → string: "DaemonStatus", "StopAllManaged"
-- • variante com campos → tabela: { ProjectDelete = { id = "..." } }
-- Convenção de Response (idem): { Projects = {...} }, { DaemonStatus = {...}
-- }.
local M = {}
M.__index = M
-- Teto de cada requisição (ms). Sem ele um daemon que não responde deixaria a
-- ação suspensa para sempre — e o botão (busy.luau) bloqueado junto. Estourar
-- vira `(false, erro)` como qualquer outra falha de transporte.
M.RPC_TIMEOUT_MS = 60000
M.UPLOAD_TIMEOUT_MS = 600000
function M.auth_headers(token: string?): { [string]: string }
if token == nil or token == "" then
return {}
end
return { Authorization = "Bearer " .. token }
end
function M.failure_text(http_response: any): string
if http_response.error ~= nil and http_response.error ~= "" then
return tostring(http_response.error)
end
return "HTTP " .. tostring(http_response.status)
end
local JSON_HEADERS = { ["Content-Type"] = "application/json" }
local function decode_body(body: string?): (boolean, any)
return pcall(json.decode, body or "")
end
function M.login(
base_url: string,
access_key: string
): (boolean, string, number?)
local anonymous = http(base_url, { timeout = M.RPC_TIMEOUT_MS })
local http_response = anonymous:post("/api/login", { key = access_key })
local decode_ok, decoded = decode_body(http_response.body)
if
http_response.ok
and decode_ok
and type(decoded) == "table"
and type(decoded.access_token) == "string"
then
return true, decoded.access_token, http_response.status
end
if
decode_ok
and type(decoded) == "table"
and type(decoded.error) == "table"
and decoded.error.message
then
return false, tostring(decoded.error.message), http_response.status
end
return false, M.failure_text(http_response), http_response.status
end
function M:rpc(command: any): (boolean, any)
-- `json.encode` aqui (e não o corpo-tabela do `http()`): um Command
-- unitário é a STRING "DaemonStatus", que o cliente mandaria sem aspas.
local http_response = self.client:post(
"/api/rpc",
json.encode(command),
{ headers = JSON_HEADERS }
)
if not http_response.ok then
return false, M.failure_text(http_response)
end
local json_decode_ok, decoded = decode_body(http_response.body)
if not json_decode_ok then
return false, i18n.tr("resposta inválida do daemon")
end
return true, decoded
end
function M:rpc_checked(command: any): (boolean, any)
local rpc_ok, rpc_response = self:rpc(command)
if not rpc_ok then
return false, rpc_response
end
if type(rpc_response) == "table" and rpc_response.Err ~= nil then
return false,
tostring(rpc_response.Err.code) .. ": " .. tostring(
rpc_response.Err.message
)
end
return true, rpc_response
end
function M:upload_archive(
service_id: string,
filename: string,
path: string,
opts: {
on_progress: ((progress: any) -> ())?,
signal: any?,
}?
): (boolean, any)
local http_response =
self.client:post("/api/services/" .. service_id .. "/archive", nil, {
headers = {
["Content-Type"] = "application/zip",
["X-Rustploy-Filename"] = filename,
},
body_file = path,
timeout = M.UPLOAD_TIMEOUT_MS,
on_progress = if opts then opts.on_progress else nil,
signal = if opts then opts.signal else nil,
})
if not http_response.ok then
return false, M.failure_text(http_response)
end
local json_decode_ok, decoded = decode_body(http_response.body)
if not json_decode_ok then
return false, i18n.tr("resposta inválida do daemon")
end
if type(decoded) == "table" and decoded.Err ~= nil then
return false,
tostring(decoded.Err.code) .. ": " .. tostring(decoded.Err.message)
end
return true, decoded
end
function M.probe(base_url: string): any
return http(base_url, { timeout = 8000, retries = 1 }):get("/api/version")
end
export type Client = typeof(setmetatable(
{} :: {
base_url: string,
token: string?,
client: any, -- o `http()` do glacier, com base_url + Authorization
},
M
))
function M.new(base_url: string, token: string?): Client
local clean_token = if token ~= nil and token ~= "" then token else nil
return setmetatable({
base_url = base_url,
token = clean_token,
client = http(base_url, {
headers = M.auth_headers(clean_token),
timeout = M.RPC_TIMEOUT_MS,
}),
}, M)
end
return M