SCUA

How-to

Serve HTTP requests

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 }:

  • method and path are strings ("GET", "/health").
  • headers is a map. Header names are lowercased, so read req.headers["content-type"] even if the client sent Content-Type.
  • body is bytes — 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-typeisn'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) or Error.
  • net.accept(listener)Ok(connection) or Error — waits for the next client.
  • Read and write a connection with net.read / net.write, exactly like a net.dial connection (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.