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.
It blocks the whole program, not just the line it is
on. Putting it inside spawn does not give you a
server "in the background" — the accept loop never returns, so no other
task runs again, including anything waiting in your main body. If you
want to serve and do other work, do the other work in the
handler, or run the server as its own process.
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
Write the header map inline. A key is a name: bare when it's an identifier, quoted when it isn't — and header names usually aren't, because of the hyphen:
return { status = 200, headers = { "content-type" = "text/plain", xtag = "1" }, body = b"ok" }
Quote a key only when it needs it:
{ "content-type" = … } is right,
{ "xtag" = … } is an error telling you to drop the quotes.
(Earlier versions had no way to write a hyphenated key in a literal at
all, so older code builds the map by index-assignment; that still works,
it's just no longer necessary.)
fn handle(req)
if req.path == "/echo" then
return {
status = 200,
headers = { "content-type" = "application/octet-stream" },
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.