Answering HTTP requests — running a server — needs the
serve capability. It's a different authority from
making requests: --allow-net lets a script call out, but it
never lets one open a port. Serving is its own grant, off by default,
and until the host gives it, http.serve and
net.listen simply don't exist — there's no name to call. So
a script can't start listening by accident, and can't leak that power
into an embedded build unless someone deliberately turns it on.
#Granting the capability
From the CLI, the grant names the exact address the script may bind:
scua --allow-serve=127.0.0.1:8080 serve.scua # bind only this address:port
Without it, http.serve is an undefined
name — the function isn't there to call. And the grant is a
boundary: a script may only bind the address you named, not any port it
likes. --allow-net never grants listening, however broad it
is; the two capabilities don't overlap.
The host owns the hard parts — accepting connections, parsing requests, framing responses. Your script owns exactly one thing: the handler.
#The minimal server
import http
fn handle(req)
if req.method == "GET" and req.path == "/health" then
return { status = 200, body = b"ok" }
end
return { status = 404, body = b"not found" }
end
http.serve(handle, { bind = "127.0.0.1:8080" })
http.serve(handler, { bind = "ADDR:PORT" })
blocks, serving requests, until you stop the process
(Ctrl-C). The handler is a plain function
fn(req) -> resp: SCUA calls it once per request. The
bind must be within the grant.
Run it, then in another terminal:
$ curl 127.0.0.1:8080/health
ok
#The request and the response
req is a record
{ method, path, headers, body }:
methodandpathare strings ("GET","/health").headersis a map. Header names are lowercased, so readreq.headers["content-type"]even if the client sentContent-Type.bodyisbytes— the raw request payload.
The response you return is a record
{ status, headers, body }, and every field has a default:
{ status = 200, headers = {}, body = b"" }. Return as
little or as much as you need.
#Bodies are bytes
The response body is bytes, not a
string. Write it with a b"..." byte-string
literal, or convert a string you already have with
bytes.from_string:
return { status = 200, body = b"ok" } -- a byte literal
return { status = 200, body = bytes.from_string(message) } -- a string you have
A string body is a mistake the runtime catches: it becomes a 500, never a silently coerced response. Being explicit about bytes is what lets a handler serve an image or any binary payload with the same shape as text.
#Setting headers
Identifier-shaped keys work as a record literal:
return { status = 200, headers = { xtag = "1" }, body = b"ok" }
But a header name with a hyphen — like content-type —
isn't a valid record-literal key (it's not an
identifier). Build that map by index-assignment instead:
fn handle(req)
if req.path == "/echo" then
let headers = {}
headers["content-type"] = "application/octet-stream"
return { status = 200, headers = headers, body = req.body }
end
return { status = 404, body = b"not found" }
end
#404s, 500s, and --serve-debug
A 404 is just a response you return, like any other status — the server answered, it simply said "not found".
A 500 is what happens when your handler can't answer: an
uncaught fault, or a returned Error, becomes an HTTP 500.
By default the fault text is withheld from clients —
production bodies stay quiet, so a stack detail or an internal message
never leaks to whoever is calling. When you're developing locally and
want that detail in the 500 body, pass --serve-debug:
scua --allow-serve=127.0.0.1:8080 --serve-debug serve.scua
Leave it off in anything real.
#Raw TCP with net.listen
When you need to speak a protocol http doesn't — a
line-based service, a custom wire format — the same serve
grant gives you a raw TCP listener through the net module.
net.listen and net.accept are gated exactly
like http.serve: both need --allow-serve.
import net
match net.listen("127.0.0.1", 9000)
Ok(listener) -> do
match net.accept(listener)
Ok(conn) -> do
net.write(conn, "hello\n")
net.close(conn)
end
Error(why) -> print(`accept failed: {why}`)
end
net.close(listener)
end
Error(why) -> print(`could not listen: {why}`)
end
net.listen(addr, port)→Ok(listener)orError.net.accept(listener)→Ok(connection)orError— waits for the next client.- Read and write a connection with
net.read/net.write, exactly like anet.dialconnection (see Fetch data over HTTP). net.close(x)closes either a connection or a listener.
Reach for http.serve for ordinary request/response work;
reach for net.listen when you genuinely need the bytes.
See the runnable examples/serve.scua
for the server above.
#Related
- A handler that calls out (
http.get,fs.read) can be snapshotted mid-request — see Write handlers that survive snapshots for the one habit that keeps it correct. - To fan a handler's own outbound work out in parallel, see Run many things at once.