# Writing servers with Apsi (`use web`)

```apsi
use web
let app = server(name="my-api")

@app.get("/hello/{name}")
fn hello(req) = {"message": "Hello " + req.params["name"]}

@app.post("/energy")
fn energy(req):
    let b = req.json()                         # {"mass": {"value": 2, "unit": "kg"}, ...}
    return {"energy": 0.5 * b["mass"] * b["speed"] ** 2}   # units are checked on every request

app.run(host="0.0.0.0", port=8080)            # run with --allow-net
```

## Architecture

| Part | How it works |
|---|---|
| Accepting connections | one asyncio event loop holds tens of thousands of keep-alive clients with little memory |
| Processing | handlers run on a worker thread pool (`workers=`), so numpy/AI work runs truly in parallel and never blocks accepting requests. A route that proves fast (under 0.5 ms, measured by CPU time so a busy server does not mislead it) runs directly on the event loop, without a thread hand-off; a route that is slow or blocks on I/O stays on the workers |
| Several cores | `app.run(processes="auto")` (one per CPU) or `processes=N` forks processes on the same port (SO_REUSEPORT, Linux and macOS) and the kernel spreads connections between them. Each process has its own memory: keep shared state in a database or `memory.store()` |
| Event loop | uvloop (libuv) when it is installed — `pip install "apsi[web]"` or `pip install uvloop` — otherwise asyncio's loop; `APSI_UVLOOP=0` turns uvloop off. The start-up line names the loop in use |
| Per-request cost | request ids from a counter (no system call), the security headers merged in one step, routes without parameters found by a dictionary lookup, the header block validated by one regular expression, one timer per connection instead of one per request, and fast routes timed by the wall clock only |
| Shutdown | on SIGINT/SIGTERM the server stops accepting new connections and lets running requests finish (graceful shutdown) |

**Measured — a synthetic benchmark** (a route that returns a fixed 25-byte JSON object, keep-alive, the Go load
generator in `benchmarks/web` on the same machine):

| AMD EPYC 7763 GitHub Actions runner (4 vCPU), best of 3 rounds | 64 connections | 256 connections |
|---|---:|---:|
| Apsi, 1 process | 14,008–14,978 req/s | 13,671–14,830 req/s |
| Apsi, 1 process, uvloop | 15,493–16,863 req/s | 14,958–15,392 req/s |
| Apsi, `processes="auto"` | **41,718–42,085 req/s** | **44,042–44,269 req/s** |
| Node.js 22 bare `http` (1 process) | 19,872–22,198 req/s | 22,862–25,155 req/s |

Per process, the work per request went down by about 16 % in this release (measured on a 2-core VM). With one process
per core Apsi answers about 1.8× the requests of a single Node.js process; one Apsi process answers about two thirds
of Node's (Node.js itself needs its `cluster` module to use more than one core). With 1,000 open connections no
request fails (15,454 req/s); while 64 connections keep a blocking 10 ms route busy, a fast route on the same process
still answers 9,888 requests/s with a p99 of 7.9 ms (the slow route runs on worker threads, the fast one on the event
loop).

**What these numbers are and are not.** This is a synthetic test of the server's fixed cost per request: no database,
no request body to parse, no TLS, no real network (loopback), and the load generator shares the cores with the
server. It shows how much work the server itself adds. It does not predict a real application, where a request
usually spends milliseconds in the database, in other services or in the handler's own work; there, the per-request
overhead measured here (tens of microseconds) is a small part of the total, and the worker threads (which run
handlers in parallel) and `processes="auto"` matter more. Measure your own routes with `tools/loadtest.py` or
`benchmarks/web/`.

```bash
python tools/loadtest.py http://127.0.0.1:8080/health --requests 20000 --concurrency 500
```

## Security (on by default)

| Threat | Protection |
|---|---|
| Slowloris (slow headers) | `header_timeout` (10 s), `body_timeout` (30 s), `keepalive_timeout` (15 s) |
| Oversized body/headers | `max_body` (1 MB) → 413; headers over 16 KB or over 100 lines → 431 |
| Request smuggling | duplicate `Content-Length`, chunked requests and obs-fold headers are rejected |
| Header injection | headers containing line breaks are dropped |
| Brute force / DoS | `app.rate_limit(rate, per, burst)` — a token bucket per IP → 429 with `Retry-After`; connection limit (`max_connections`) |
| Stuck handlers | `handler_timeout` → 504 |
| Path traversal | `app.static()` checks the real path, so nothing outside the folder can be served (including `%2e%2e`) |
| Information leaks | clients never see a stack trace (only a `request id`); details go to the server log |
| Clickjacking / MIME sniffing | `X-Frame-Options: DENY`, `X-Content-Type-Options: nosniff`, CSP, `Referrer-Policy`, `Permissions-Policy`, HSTS with TLS |
| Authentication | `jwt_sign` / `jwt_verify` (HS256, `alg=none` refused, `exp`/`nbf` checked), `require_jwt`, `require_api_key` (constant-time compare) |
| Passwords | `use crypto` → `password_hash` (scrypt, or PBKDF2 with 600k rounds) |
| TLS | `app.run(tls_cert="cert.pem", tls_key="key.pem")` — TLS 1.2 or newer |
| Program permissions | opening a port needs `--allow-net`; serving files needs the `fs` permission |

Socket-level tests for all of this live in `tests/test_web.py`.

## API

```apsi
let app = server(name="api", max_body=1048576, workers=64, max_connections=10000, log=true,
                 handler_timeout=60[s], header_timeout=10[s], body_timeout=30[s],
                 keepalive_timeout=15[s], security_headers=true)

app.get(path, handler)  /  @app.get(path)          # also post, put, patch, delete, route(method, path)
app.before(fn(req) => none | response)             # middleware before the handler
app.after(fn(req, resp) => ...)                    # middleware after the handler
app.rate_limit(100, per=1[s], burst=200)
app.cors(["https://example.com"])                  # or "*"
app.static("/assets", "public")
app.health()            # GET /health
app.expose_metrics()    # GET /metrics: requests, errors, status codes, p50/p90/p99, req/s
app.stats()             # the same numbers inside your program
app.handle("GET", "/path", body, headers)          # call a route directly without the network (for tests)
app.run(host, port, tls_cert, tls_key, processes, workers)
app.run_background(port=0)                         # run on a separate thread; returns record(port, url, stop)
```

**Path patterns:** `/users/{id:int}`, `/files/{rest:path}`, `/x/{v:float}`, `/name/{s}`

**Request:** `req.method`, `req.path`, `req.params`, `req.query`, `req.headers`, `req.header(name)`,
`req.body` (bytes), `req.text`, `req.json()`, `req.client`, `req.id`, `req.user` (set by `require_jwt`)

**Handler return values:** dict / list / tensor / quantity → JSON; `Str` → text;
`response(body, status, headers, type)`; `redirect(url)`; `abort(status, message)` to stop with an error; `none` → 204

**Errors:** a UnitError/ShapeError/TypeError caused by the client's data → 400; any other error → 500

## WebSocket

```apsi
use web
let app = web.server()

@app.websocket("/echo")
fn echo(ws, msg):                      # message mode: called once per message; no thread waits per connection,
    ws.send(msg)                       # so tens of thousands of idle connections cost almost nothing

@app.websocket("/chat", origins=["https://example.com"], max_message=1_000_000, ping=20)
fn chat(ws):                           # session mode: one conversation per connection
    ws.join("lobby")
    for msg in ws:                     # ends when the client closes
        app.room("lobby").send({"from": ws.id, "text": msg})    # dicts and lists are sent as JSON

app.run(port=8080)
```

* `app.websocket(path, handler, origins=, max_message=, ping=, on_open=, on_close=, subprotocols=)` — the handler
  takes `(ws, msg)` for message mode or `(ws)` for session mode.
* `ws.send(text | bytes | dict | list)`, `ws.receive(timeout=)`, `ws.close(code, reason)`, `ws.join(room)`,
  `ws.leave(room)`, `ws.receive_json()`, `ws.id`, `ws.request` (the upgrade request: path, headers, query).
* `app.room(name).send(msg, exclude=ws)` broadcasts to every member; `len(app.room(name))` counts them.
* Messages of one connection are handled in order; slow receivers get back-pressure instead of unbounded buffers.
* RFC 6455 in full: text and binary, fragmentation, ping/pong keep-alive (dead peers are dropped), the closing
  handshake, UTF-8 checking of text, client masking enforced, a size limit per message (16 MB by default) and Origin
  checking.

Client (needs `--allow-net` for the host):

```apsi
let c = web.websocket_connect("ws://localhost:8080/echo")
c.send("hi")
print(c.receive())
c.close()
```

Measured on an AMD EPYC 7763 GitHub Actions runner (4 vCPU) with a Node.js 22 client: a round trip takes 225 µs; a burst of 20,000 messages is
echoed at 50,591 messages/s, in order; 1,000 concurrent connections all answered. Reproduce with
`benchmarks/websocket/`.

## Talking to Next.js / JavaScript / Java

See [`INTEROP.md`](/docs/2.4.4/interop). Values with units always travel as `{"value": 9.0, "unit": "J"}`.
