Skip to content

Web Server

Sema includes a built-in HTTP server powered by axum, with data-driven routing, middleware as function composition, SSE streaming, and WebSocket support. The server runs on a background thread with a Tokio runtime while keeping all Sema evaluation single-threaded — the same model as Node.js.

Quick Start

sema
(define (handler req)
  (http/ok {:message "Hello from Sema!"}))

(http/serve handler {:port 3000})
bash
$ curl http://localhost:3000
{"message":"Hello from Sema!"}

Serving

http/serve

Start an HTTP server. Takes a handler function and an optional options map. The handler receives a request map and returns a response map. This function blocks — it becomes the server's run loop.

sema
(http/serve handler)
(http/serve handler {:port 3000})
(http/serve handler {:port 8080 :host "127.0.0.1"})
OptionDefaultDescription
:port3000TCP port to bind
:host"0.0.0.0"Address to bind to
:port-fallbackfalseIf the port is taken, bind the next free port instead of failing
:on-listenFunction called once bound with {:host :port :url}

The handler is any function (request-map -> response-map). This can be a plain function, a router, or a middleware-wrapped stack.

Automatic port fallback

By default http/serve fails fast when the port is in use. Pass :port-fallback true to walk to the next free port instead. Since the bound port may then differ from the one requested, use :on-listen to learn where the server ended up:

sema
(http/serve handler
  {:port 3000
   :port-fallback true
   :on-listen (fn (info) (println (string-append "Ready at " (:url info))))})

:on-listen runs once, on the main thread, right after the socket binds.

Routing

http/router

Create a handler function from a list of route definitions. Each route is a vector of [method pattern handler].

sema
(define routes
  [[:get  "/"            handle-home]
   [:get  "/users/:id"   handle-user]
   [:post "/users"       handle-create]
   [:any  "/echo"        handle-echo]])

(define app (http/router routes))
(http/serve app {:port 3000})

Supported methods: :get, :post, :put, :patch, :delete, :any (matches all methods), :ws (WebSocket upgrade), and :static (static file directory).

Routes are matched top-to-bottom — first match wins. Unmatched routes return 404.

Path Parameters

Use :param syntax to capture path segments. Extracted values appear in the request's :params map.

sema
;; Route: [:get "/users/:id" handle-user]
;; Request: GET /users/42

(define (handle-user req)
  (let ((id (:id (:params req))))
    (http/ok {:user-id id})))
; => {"user-id":"42"}

Multiple parameters work as expected:

sema
[:get "/users/:uid/posts/:pid" handler]
;; GET /users/1/posts/99 → {:uid "1" :pid "99"}

Wildcard Routes

Use * to capture the rest of the path.

sema
[:get "/files/*" handle-files]
;; GET /files/docs/readme.md → {:* "docs/readme.md"}

Request Map

Every handler receives a request map with the following fields:

sema
{:method  :get                                    ; HTTP method as keyword
 :path    "/users/42"                             ; Request path
 :headers {"content-type" "application/json" ...} ; Headers (string keys)
 :query   {:search "term" :page "1"}              ; Query params (keyword keys)
 :params  {:id "42"}                              ; Route params (keyword keys)
 :body    "{\"name\": \"Ada\"}"                   ; Raw body string
 :json    {:name "Ada"}}                          ; Parsed JSON body (if applicable)

The :json field is automatically populated when the request has Content-Type: application/json.

Request body limit. Request bodies are capped at 16 MiB. A larger body is rejected with 413 Payload Too Large instead of being buffered into memory, so a client can't exhaust the server's memory with an oversized upload.

Accessing Request Data

sema
;; Method
(:method req)         ; => :get

;; Path
(:path req)           ; => "/users/42"

;; A specific header
(get (:headers req) "authorization")  ; => "Bearer ..."

;; Query parameter
(:page (:query req))  ; => "2"

;; Route parameter
(:id (:params req))   ; => "42"

;; JSON body field
(:name (:json req))   ; => "Ada"

Response Map

Handlers return a response map with :status, :headers, and :body:

sema
{:status  200
 :headers {"content-type" "application/json"}
 :body    "{\"message\": \"ok\"}"}

You can construct these by hand, but the response helpers below are more convenient.

Response Helpers

http/ok

Return 200 with a JSON-encoded body.

sema
(pprint (http/ok {:message "success"}))
; => {:body "{"message":"success"}"
;     :headers {"content-type" "application/json"}
;     :status 200}

(pprint (http/ok [1 2 3]))
; => {:body "[1,2,3]" :headers {"content-type" "application/json"} :status 200}

http/created

Return 201 with a JSON-encoded body.

sema
(http/created {:id 42 :name "Ada"})

http/no-content

Return 204 with an empty body.

sema
(http/no-content)

http/not-found

Return 404 with a JSON-encoded body.

sema
(http/not-found {:error "User not found"})

http/error

Return a custom status code with a JSON-encoded body.

sema
(http/error 422 {:errors ["Invalid email" "Name required"]})
(http/error 503 {:error "Service unavailable"})

http/redirect

Return a 302 redirect to a URL.

sema
(http/redirect "https://example.com/login")

http/html

Return 200 with Content-Type: text/html.

sema
(http/html "<h1>Hello</h1><p>Welcome to Sema.</p>")

http/text

Return 200 with Content-Type: text/plain.

sema
(http/text "OK")

http/file

Return a file from disk with automatic MIME type detection. The file is read on the I/O thread (not the evaluator), so it handles binary files efficiently.

sema
(http/file "public/index.html")
(http/file "data/report.pdf" "application/pdf")  ; explicit content type

The path is resolved relative to the current working directory. If the file doesn't exist, an error is raised. The MIME type is guessed from the file extension (e.g. .htmltext/html, .csstext/css, .jsapplication/javascript).

Static File Serving

:static Routes

Serve an entire directory of static files using the :static route type in http/router. Files are served with automatic MIME types, cache headers, and path traversal protection.

sema
(define routes
  [[:static "/assets" "./public"]
   [:get    "/*"      handle-spa]])

(http/serve (http/router routes) {:port 3000})
bash
$ curl http://localhost:3000/assets/style.css
body { color: red; }

$ curl -I http://localhost:3000/assets/style.css
Content-Type: text/css
Cache-Control: public, max-age=3600

The :static route takes a URL prefix and a directory path. Requests matching the prefix are mapped to files in the directory:

  • GET /assets/style.css → reads ./public/style.css
  • GET /assets/js/app.js → reads ./public/js/app.js
  • GET /assets/ → reads ./public/index.html (directory index)

Fallthrough: If a file doesn't exist, the route does not match — the router continues to the next route. This enables SPA (single-page application) patterns where a catch-all route serves index.html for client-side routing:

sema
(define routes
  [[:static "/assets" "./dist/assets"]
   [:get    "/*"      (fn (_) (http/file "./dist/index.html"))]])

(http/serve (http/router routes) {:port 3000})

Security: Path traversal attempts (e.g. ../etc/passwd) are rejected with a 400 response. Only GET and HEAD methods are accepted.

Middleware

Middleware in Sema is just function composition — a function that takes a handler and returns a new handler. No special framework needed.

Writing Middleware

sema
;; Logging middleware
(define (with-logging handler)
  (fn (req)
    (let ((resp (handler req)))
      (println (:method req) (:path req) "->" (:status resp))
      resp)))
sema
;; CORS middleware
(define (with-cors handler)
  (fn (req)
    (let ((resp (handler req)))
      (assoc resp :headers
        (merge (or (:headers resp) {})
          {"access-control-allow-origin" "*"
           "access-control-allow-methods" "GET, POST, PUT, DELETE"})))))
sema
;; Auth middleware
(define (with-auth handler)
  (fn (req)
    (let ((token (get (:headers req) "authorization")))
      (if token
        (handler req)
        (http/error 401 {:error "Unauthorized"})))))

Composing Middleware

Stack middleware by nesting function calls. The outermost middleware runs first.

sema
(define app
  (with-logging
    (with-cors
      (with-auth
        (http/router routes)))))

(http/serve app {:port 3000})

Or use the threading macro for a cleaner pipeline:

sema
(define app
  (-> (http/router routes)
      with-auth
      with-cors
      with-logging))

SSE Streaming

http/stream

Return a Server-Sent Events stream. Takes a handler function that receives a send callback.

sema
(define (handle-events req)
  (http/stream
    (fn (send)
      (send "connected")
      (sleep 1000)
      (send "update 1")
      (sleep 1000)
      (send "update 2"))))

The stream stays open as long as the handler is running. When the handler returns, the stream closes.

sema
;; Route it like any other handler
(define routes
  [[:get "/events" handle-events]])
bash
$ curl -N http://localhost:3000/events
data: connected

data: update 1

data: update 2

Streaming LLM Responses

SSE is particularly useful for streaming LLM completions to the browser:

sema
(define (handle-chat req)
  (http/stream
    (fn (send)
      (let ((prompt (:prompt (:json req))))
        ;; Stream each token as an SSE event
        (llm/stream prompt (fn (token) (send token)))))))

WebSocket

http/websocket

Handle bidirectional WebSocket connections. Takes a handler function that receives a connection map with :send, :recv, and :close functions.

sema
(define (handle-ws conn)
  (let ((msg ((:recv conn))))
    (when msg
      ((:send conn) (string/append "echo: " msg))
      (handle-ws conn))))

The connection map:

KeyDescription
:send(send message) — send a string (text frame) or a bytevector (binary frame)
:recv(recv) — block until a message arrives; a text frame returns a string, a binary frame a bytevector, nil on close
:close(close) — Close the connection

WebSocket Routes

Use the :ws method in the router:

sema
(define routes
  [[:get "/api/status" handle-status]
   [:ws  "/ws/chat"    handle-ws]])

(http/serve (http/router routes) {:port 3000})

Chat Room Example

sema
(define clients (atom '()))

(define (broadcast msg)
  (for-each (fn (send) (send msg))
            @clients))

(define (handle-ws conn)
  ;; Add this client's send function to the list
  (swap! clients (fn (lst) (cons (:send conn) lst)))
  ;; Read loop
  (let loop ((msg ((:recv conn))))
    (when msg
      (broadcast msg)
      (loop ((:recv conn))))))

(define routes
  [[:ws "/chat" handle-ws]])

(http/serve (http/router routes) {:port 3000})

WebSocket Client

Connect to a WebSocket server with ws/connect. A connection is a closeable stream, so with-open closes it automatically — on both the normal and the error path.

sema
(with-open (sock (ws/connect "wss://echo.websocket.events"))
  (ws/send sock "hello")
  (match (ws/recv sock)
    {:text msg}   (println msg)
    {:binary buf} (handle-bytes buf)
    {:close info} :done))

ws/connect

(ws/connect url) / (ws/connect url opts) — open a connection to a ws:// or wss:// URL, returning a connection value. Blocks until the handshake completes (or fails). Requires the network capability. Inside an async/spawn task it yields cooperatively, so sibling tasks run while the handshake and later receives are in flight.

opts is an optional map:

KeyMeaning
:headersmap of extra HTTP headers on the upgrade (e.g. auth tokens)
:subprotocolslist of Sec-WebSocket-Protocol values to offer
:timeouthandshake timeout in milliseconds
:retriesretry a failed handshake this many times (default 0)
:retry-backoff-msbase backoff, doubled each retry and capped at 30s (default 500)
sema
(ws/connect "wss://api.example.com/socket"
  {:headers {"Authorization" "Bearer …"}
   :subprotocols ["chat"]
   :timeout 5000
   :retries 3})

ws/send

(ws/send conn msg) — send a message. The frame type follows msg:

msgFrame sent
stringtext frame
bytevectorbinary frame
{:text s}text frame (explicit)
{:binary bv}binary frame (explicit)
{:json v}text frame: v encoded as JSON
any other maptext frame: the map encoded as JSON

ws/recv and ws/recv-timeout

(ws/recv conn) — receive the next message, blocking until one arrives. Returns a single-key tagged map so a match can dispatch on the frame type:

Return valueMeaning
{:text "…"}a text frame
{:binary #u8(…)}a binary frame
{:close {:code :reason}}the server closed the connection
nilthe connection is fully drained and closed

(ws/recv-timeout conn ms) is the same but returns the keyword :timeout if no message arrives within ms milliseconds (distinct from nil, which means closed). A protocol error surfaces as a thrown error you can try/catch.

ws/ping

(ws/ping conn) / (ws/ping conn payload) — send a ping frame (optional string or bytevector payload); the server replies with a matching pong. Incoming pings are answered automatically.

ws/close and ws/connected?

(ws/close conn) closes the connection (idempotent; also done for you by with-open). (ws/connected? conn) reports whether the socket is still live.

ws/listen

(ws/listen conn handlers) drives a receive loop, dispatching each frame to the matching handler. It spawns an async task and returns its promise — async/await it (or run the scheduler) to drive the loop. All handlers are optional:

HandlerCalled withWhen
:on-open(conn)once, before the loop
:on-message(conn msg)each text (string) or binary (bytevector) frame
:on-close(conn info)the connection closed (info is {:code :reason})
:on-error(conn err)a recv/protocol error (loop then stops)
sema
(with-open (sock (ws/connect "wss://stream.example.com"))
  (async/await
    (ws/listen sock
      {:on-message (fn (conn msg) (println msg))
       :on-close   (fn (conn info) (println "closed"))})))

Browser support. The ws/* client also runs in the browser (Sema Web / WASM), backed by the browser's native WebSocket: ws/connect, ws/send (text/binary/JSON + {:text}/{:binary}/{:json} framing), ws/close, ws/connected?, and ws/listen all work there. Because the browser main thread cannot block, the pull-based ws/recv and ws/recv-timeout are native-only — in the browser, receive with the evented ws/listen (:on-message / :on-open / :on-close / :on-error), which mirrors how browser SSE and llm/chat-stream deliver data. Connection :headers, :timeout, and retry options are native-only too (the browser WebSocket API only supports :subprotocols). See the Sema Web WebSocket guide.

Complete Examples

REST API

A JSON API with CRUD operations, middleware, and error handling.

sema
;; In-memory data store
(define db (atom {}))
(define next-id (atom 0))

(define (gen-id)
  (swap! next-id (fn (n) (+ n 1)))
  @next-id)

;; Handlers
(define (list-users _)
  (http/ok (vals @db)))

(define (get-user req)
  (let ((id (:id (:params req)))
        (user (get @db id)))
    (if user
      (http/ok user)
      (http/not-found {:error "User not found"}))))

(define (create-user req)
  (let ((data (:json req))
        (id   (str (gen-id)))
        (user (assoc data :id id)))
    (swap! db (fn (d) (assoc d id user)))
    (http/created user)))

(define (delete-user req)
  (let ((id (:id (:params req))))
    (swap! db (fn (d) (dissoc d id)))
    (http/no-content)))

;; Middleware
(define (with-json-errors handler)
  (fn (req)
    (let ((resp (handler req)))
      (if (map? resp) resp
        (http/error 500 {:error "Internal server error"})))))

(define (with-cors handler)
  (fn (req)
    (let ((resp (handler req)))
      (assoc resp :headers
        (merge (or (:headers resp) {})
          {"access-control-allow-origin" "*"
           "access-control-allow-methods" "GET, POST, DELETE"})))))

;; Routes
(define routes
  [[:get    "/users"     list-users]
   [:get    "/users/:id" get-user]
   [:post   "/users"     create-user]
   [:delete "/users/:id" delete-user]])

;; Start
(define app
  (-> (http/router routes)
      with-json-errors
      with-cors))

(http/serve app {:port 3000})

LLM-Powered API

An API endpoint that uses Sema's built-in LLM primitives to generate responses.

sema
(define (handle-summarize req)
  (let ((text (:text (:json req))))
    (if text
      (http/ok {:summary (llm/complete (str "Summarize this:\n\n" text))})
      (http/error 400 {:error "Missing 'text' field"}))))

(define (handle-extract req)
  (let ((text (:text (:json req))))
    ;; llm/extract takes the schema first, then the text.
    (http/ok (llm/extract {:name "string"
                           :date "string"
                           :amount "number"}
                          text))))

(define routes
  [[:post "/summarize" handle-summarize]
   [:post "/extract"   handle-extract]
   [:get  "/health"    (fn (_) (http/ok {:status "up"}))]])

(http/serve (http/router routes) {:port 3000})

HTML Application

Serve dynamic HTML pages.

sema
(define (page title body)
  (http/html
    (str "<!DOCTYPE html><html><head><title>" title "</title>"
         "<style>body{font-family:sans-serif;max-width:800px;margin:0 auto;padding:2rem}</style>"
         "</head><body>" body "</body></html>")))

(define (handle-home _)
  (page "Home" "<h1>Welcome</h1><p>Built with Sema.</p>"))

(define (handle-greet req)
  (let ((name (or (:name (:params req)) "world")))
    (page "Greeting" (str "<h1>Hello, " name "!</h1>"))))

(define routes
  [[:get "/"            handle-home]
   [:get "/greet/:name" handle-greet]])

(http/serve (http/router routes) {:port 3000})

SPA with Static Assets

Serve a single-page application with static assets and a catch-all for client-side routing.

sema
(define routes
  [[:get    "/api/health" (fn (_) (http/ok {:status "up"}))]
   [:static "/assets"     "./dist/assets"]
   [:get    "/*"          (fn (_) (http/file "./dist/index.html"))]])

(http/serve (http/router routes) {:port 3000})

CSS, JS, and images under ./dist/assets/ are served with correct MIME types and cache headers. All other GET requests serve index.html for client-side routing.

Architecture Notes

  • Single-threaded evaluation: All Sema code runs on the main thread. HTTP I/O runs on a background Tokio runtime. Requests are bridged via channels.
  • Concurrency model: Requests are processed sequentially by the evaluator. For LLM-backed services (where each request takes 1–5s of LLM latency), this is fine. For high-throughput APIs, consider a reverse proxy.
  • Graceful shutdown: Ctrl+C breaks the channel and the server exits cleanly.
  • Sandbox-aware: http/serve requires the NETWORK capability when running in sandbox mode.

See Also