Perch
Lua APINet

Net

perch.net.http for https requests that answer through a callback, perch.net.websocket for a live socket, and the net types. Hosts are pinned by the manifest.

3 min readUpdated Sep 9, 2026

Everything that leaves the machine lives under perch.net. Access is opt-in per host: each net.http:<host> or net.websocket:<host> entry in mod.json pins exactly one host, and the Workshop page shows every pinned host before a user subscribes.

{ "scopes": ["net.http:api.github.com", "net.websocket:stream.example.com"] }

Before pinning a host, check whether Perch already fetches what you need: Steam and Weather come with no network scope.

perch.net.http.get

perch.net.http.get(url: string, opts: http_options_t?, callback: http_callback_t): ()
ArgumentMeaning
urlmust start with https://, and the host must equal a pinned host exactly (no subdomains)
optsoptional. ua sets the User-Agent (default perch-mod/<id>), timeout_ms the deadline. A function in this position is taken as the callback
callbackcalled exactly once, on the main thread, with (res, err)

Returns nothing. Anything but a function as the last argument raises.

perch.net.http.get("https://api.github.com/repos/hlpdev/perch", { ua = "my-mod/1.0" },
  function(res: http_response_t?, err: string?)
    if not res then perch.log("fetch failed: " .. (err or "")) return end
    local repo = perch.json.decode(res.body)
    if repo then stars:push({ count = tostring(repo.stargazers_count) }) end
  end)

perch.net.http.post

perch.net.http.post(url: string, body: string, opts: http_options_t?, callback: http_callback_t): ()

Same rules as get. body must be a string; encode a table with perch.json.encode first. opts.content_type defaults to application/json.

perch.net.http.post("https://api.example.com/events", perch.json.encode({ kind = "deploy", ok = true }),
  function(res: http_response_t?, err: string?)
    perch.log(if res then "posted " .. res.status else "post failed: " .. (err or ""))
  end)

The answer

Outcomereserr
status 200 to 399{ status, body }, the body capped at 8 MiBnil
status 400 and upnil"http <status>"
not https, or the host is not pinnednil"scope"
more than 30 requests in the rolling minutenil"rate"
no HTTP client in this buildnil"unavailable"
DNS, TLS or timeout failurenil""

The refusals ("scope", "rate", "unavailable") call back synchronously, before get returns. If the mod stops while a request is in flight, the callback never runs.

Limits: 30 requests per rolling minute per mod across every host and both methods; 8 MiB body; headers limited to the User-Agent and, for POST, the Content-Type.

perch.net.websocket.connect

perch.net.websocket.connect(url: string, opts: websocket_options_t?): socket_t

A live connection for a feed that pushes. url is wss:// only, on a host pinned by net.websocket:<host>; opts.headers are sent with the handshake. Returns the socket at once; connecting happens in the background, and a failure to connect arrives as on_close, so there is one path for failure and shutdown alike. A refusal Perch can see immediately (a bad scheme, an unpinned host, a fifth socket) raises.

MemberMeaning
socket.on_messagesignal_t<string>; one fire per complete message
socket.on_closesignal_t<number, string>; fires (code, reason) exactly once
socket:send(text)send one text frame; false once the socket has closed
socket:close()ask for a clean close; on_close still fires
socket:connected()whether the socket is open

At most 4 sockets per mod. Sockets close when the mod stops.

--!strict
local retry = timer.new(5000, { once = true })

local function open()
  local s = perch.net.websocket.connect("wss://stream.example.com/ticks")
  s.on_message:connect(function(text: string)
    local tick = perch.json.decode(text)
    if tick then w:push({ price = tick.price }) end
  end)
  s.on_close:connect(function(code: number, reason: string)
    retry:start()
  end)
end

retry.on_fire:connect(open)
perch.on_start:connect(open)

Types

export type http_response_t = { status: number, body: string }
export type http_options_t = { ua: string?, content_type: string?, timeout_ms: number? }
export type http_callback_t = (res: http_response_t?, err: string?) -> ()

The answer, the options and the callback shape of get and post.

export type websocket_options_t = { headers: { [string]: string }? }
export type socket_t = {
	on_message: signal_t<string>,
	on_close: signal_t<number, string>,
	send: (self: socket_t, text: string) -> boolean,
	close: (self: socket_t) -> (),
	connected: (self: socket_t) -> boolean,
}

The handshake options and the socket handle.

esc
Type to search
navigate open