std.web is a small, opinionated layer over std.net.http. it owns two things:
routing and the accept loop. the request and response types still come from
std.net.http, so there is one set of builders to learn, not two.
import std.web as web
import std.net.http as http
fn main() -> Int!:
app := web.new().get("/", fn(req: web.Request) => http.text(200, "hello"))
app.listen("0.0.0.0", 8080)!
return 0
web.new() gives you an app that already answers GET /healthz with 200 ok, so a
load balancer has something to poll before you have written a single route. listen
binds the socket and serves forever.
add routes with get, post, put, and delete. each one returns a new app, so
calls chain:
app := web.new().get("/", home).post("/users", create)
a chain has to stay on one line. once you have more than a couple of routes, add them one per line instead:
mut app := web.new()
app = app.get("/", home)
app = app.get("/users/:id", show)
app = app.post("/users", create)
routes match in the order you add them and the first match wins, so put your most specific paths first.
a segment that starts with : is a parameter. the router pulls it out of the request
path and hands it to the handler by name:
fn show(req: web.Request) -> http.HttpResponse:
return http.text(200, "user " + req.param("id"))
app := web.new().get("/users/:id", show)
GET /users/42 calls show with req.param("id") equal to "42". a trailing slash
is ignored, so /users/42 and /users/42/ hit the same route.
a handler receives a web.Request. the common accessors are right there:
req.param(name)— a path parameter, or""req.query(name)— a query-string value, or""req.query_or(name, fallback)— a query-string value, orfallbackwhen it is missing or emptyreq.header(name)— a request header, or""req.body()— the body as textreq.is_json()— true when theContent-Typeisapplication/jsonreq.method()andreq.path()
when you need more, like cookies, multipart parts, or the raw bytes, reach through
req.raw, which is the underlying std.net.http request.
most json apis read a struct out of the request body. web.parse[T] does exactly
that: give it the request and the struct you expect, and it hands back a typed value
or a decode error. the struct's fields have to be pub so the decoder can fill them
in.
struct NewUser:
pub name: String
pub age: Int
fn create_user(req: web.Request) -> http.HttpResponse:
if not req.is_json():
return http.bad_request_response()
parsed := web.parse[NewUser](req)
if parsed.is_err:
return http.bad_request_response()
user := parsed.ok
return http.json(201, user_json(user))
a handler returns a response rather than propagating an error, so check
parsed.is_err and answer with a 400 instead of using !. req.is_json() turns
away anything that is not application/json before you even try to decode, and a
malformed body still lands in the is_err branch, so both cases come back as a clean
400.
under the hood web.parse[T] is just json.decode_text[T](req.body()). reach for
json.decode_text directly when you already have the body text and not a request.
going the other way, build the response body with the std.json constructors and
send it with http.json:
fn user_json(user: NewUser) -> String:
obj := json.make_object()
json.object_set(obj, "name", json.make_string(user.name))
json.object_set(obj, "age", json.make_int(user.age))
return json.encode(obj)
examples/web_json_api.pith is a complete, self-checking version of this: a
GET /users/:id that echoes the path parameter as json and a POST /users that
decodes a NewUser from the body and echoes it back.
json.parse does not return a tree of objects. it returns an Int handle into
a node pool that std.json keeps in per-task storage, and nothing in that pool
is freed when the handle goes out of scope. on a keep-alive connection — one
task, many requests, a body parsed per request — that adds up.
servers do not have to think about it. http.serve_connection, the other
http.serve_* entry points, and the http/2 stream handler all run your handler
inside a pool scope and reclaim it when the handler returns, so each request
starts from an empty pool. the same is true of web.parse[T] and every other
typed decode: the compiler brackets the lowered body and gives the nodes back
once the struct is built.
what is left is a long-lived task that calls json.parse on its own, outside a
request. bracket it yourself:
scope := json.open_scope()
defer json.close_scope(scope)
root := json.parse(text)
name := json.object_get_string(root, "name")
a scope reclaims everything parsed since it opened, so scopes nest but they have
to close innermost-first — closing an outer scope also closes anything opened
inside it. copy the values you want out before the scope closes; strings and
numbers pulled out of the tree are independent, but a handle is not. reading a
handle after its scope closed reports invalid rather than another document's
value, because node ids are never reused.
std.toml has the same pool and the same open_scope/close_scope, for
configuration that gets reloaded rather than read once at startup.
std.web does not invent its own response type. you return an http.HttpResponse,
built with the same helpers you would use anywhere:
http.text(200, "ok")
http.json(200, body)
http.html(200, "<h1>hi</h1>")
http.not_found_response()
a request that matches no route gets http.not_found_response() automatically.
middleware wraps every matched route. a middleware is a function that takes next
— the rest of the chain — and the request, and returns a response:
fn logging(next: fn(web.Request) -> http.HttpResponse, req: web.Request) -> http.HttpResponse:
print(req.method() + " " + req.path())
return next(req)
register it with use_mw, which returns a new app just like the route builders:
app := web.new().use_mw(logging).get("/", home)
because a middleware receives next, it decides what happens around it. run code
before calling next to inspect or rewrite the request; run code after to touch the
response; or skip next entirely to answer on your own — an auth check that rejects a
request never has to reach the handler.
order is the thing to keep straight. the first middleware you register runs outermost, so it sees the request first and the response last:
app := web.new().use_mw(logging).use_mw(auth).get("/", home)
here logging wraps auth wraps home. a request goes logging → auth → home,
and the response comes back the other way. middleware wraps every route, including the
default GET /healthz.
write middleware as a named function, as above. examples/web_middleware.pith is a
complete, self-checking example: two middleware bracket the response body with markers
so the nesting is visible in the output.
two middleware come built: web.rate_limit(limiter) answers 429 when a shared
token bucket runs dry, and web.circuit(breaker) answers 503 while a circuit
breaker is open, counting the handler's 5xx responses as failures. both take
their state from std.resilience — see docs/resilience.md.
use_mw wraps every route, but a guard usually belongs to some routes and
not others: a service splits into a public handful — login, health, metrics —
and a protected rest. group binds a middleware to a path prefix, so the
split is spelled at one site instead of as an allowlist inside the guard:
app := web.new()
.post("/login", handle_login)
.group("/api", require_token)
.get("/api/me", handle_me)
/login and /healthz stay open; everything under /api runs through
require_token, inside whatever app-wide middleware is registered. the
prefix covers itself and anything below it — /api scopes /api and
/api/me, not /apiary.
a guard usually learns something the handler wants — who the caller is,
which tenant, what role. with_value attaches it to the request that flows
down the chain, and value reads it back:
fn require_token(next: fn(web.Request) -> http.HttpResponse, req: web.Request) -> http.HttpResponse:
session := jwt.verify_hs256(req.raw.bearer_token() catch "", secret, expected) catch:
return http.unauthorized_response()
return next(req.with_value("user", session.subject()))
fn handle_me(req: web.Request) -> http.HttpResponse:
return http.response(200).json_body(json.of("user", req.value("user")))
examples/web_auth.pith runs this exact shape end to end — login, group
guard, and the requests a bad token gets back.
std.web.session keeps the state a request carries about who is on the other
end of it. the browser holds an id and nothing else; everything real lives on
the server, so revoking a session is a delete rather than a hope that a token
expires soon.
import std.web.session as session
fn main() -> Int!:
store := session.store(bytes.from_string_utf8(env("SESSION_SECRET")))!
app := web.new().use_mw(session.middleware(store)).post("/login", log_in)
app.listen("0.0.0.0", 8080)!
return 0
a handler only ever takes the request, so the store it reads and writes is
closed over — build it once in main and capture it in the handlers, the way
examples/web_session.pith does.
store.set(req, "user", "u-7")
store.get(req, "user") # "" when there is no session
store.get_or(req, "theme", "dark")
store.has(req, "user")
store.remove(req, "theme") # one key; the session stays
store.keys(req)
store.active(req)
store.rotate(req) # a new id, same contents
store.destroy(req) # the session ends here
store.count() and store.sweep() are the housekeeping: how many sessions
are live, and dropping the expired ones now instead of waiting for traffic to
do it.
the cookie carries the id and a mac over it, and nothing else. a session
cookie that carries data is a session cookie whose data the browser can be
talked into replaying. the id is 32 bytes from the os random source
(std.crypto.random), base64url encoded; the mac is hmac-sha256 under the
store's secret and is compared with std.crypto.subtle. the mac buys nothing
against guessing — 256 bits already handles that — but a forged or truncated
cookie is thrown out on arithmetic before it becomes a store lookup, so a
flood of made-up cookies cannot be used to probe the store.
it is HttpOnly, Secure and SameSite=Lax, at Path=/, with a Max-Age
of the store's ttl. the builder moves each of those:
.ttl(seconds)— the idle timeout, one day by default. a request that arrives with a live session slides the window forward.cookie_name(name)— defaults topith_session.path(value),.same_site(value).insecure_over_http()— drops theSecureflag, for local development, and is the only way to drop it
the secret must be at least 32 bytes and belongs in the environment, not the
source. store() refuses anything shorter. changing it invalidates every
cookie already issued, which is the blunt way to sign everybody out.
call store.rotate(req) at the top of a successful login, before writing who
the caller is. without it, an attacker plants a session id in a victim's
browser — a crafted link, an injected cookie — waits for them to sign in, and
finds the id they already hold has become an authenticated one. rotating
retires that id at the exact moment it would become worth having, and carries
the session's contents across to a fresh one.
the store is this process's memory behind one mutex, shared by every serving task — green or os thread, one store, as many tasks as you like. what it is not is shared between processes. two instances behind a load balancer each hold their own sessions, so a request that lands on the other one finds a signature it accepts and an id it has never heard of, and treats the caller as signed out. run one instance, pin sessions to it with a sticky load balancer, or put the state somewhere both can reach.
a visitor who never signs in never occupies a row: an id is minted per request
but only written to the store when a handler actually stores something, and a
request that reads nothing and writes nothing gets no Set-Cookie back. the
store holds at most 100,000 sessions and refuses to grow past that rather than
running the process out of memory; expired sessions are swept in the
background of ordinary traffic.
std.web.auth is the binding between a session and an identity: the three
moments of signing in, guarding, and signing out.
import std.web.auth as auth
guard := auth.guard(store)
app = app.use_mw(session.middleware(store))
app = app.post("/login", log_in)
app = app.group("/app", auth.require(guard))
app = app.get("/app/me", me)
fn log_in(req: web.Request) -> http.HttpResponse:
if not password.verify(submitted, stored_hash):
return http.unauthorized_response()
guard.sign_in(req, "u-7")
return http.text(200, "welcome")
fn me(req: web.Request) -> http.HttpResponse:
return http.text(200, "hello " + guard.user(req))
guard.sign_in(req, subject)— rotate the session id, then record who the caller is. this is what a successful password check ends withguard.sign_out(req)— end the session on the serverguard.user(req)— the subject, or""guard.signed_in(req)— whether anybody isauth.require(guard)— the middleware. it must sit inside the session middleware, since what it reads is the session
require also attaches the subject to the request, so a handler that already
says req.value("user") — the shape the token guard above uses — works
unchanged when it moves onto sessions.
an unauthenticated caller gets a 401. guard.redirect_to("/login") changes
that to a 303 at the login page, which is what a browser app wants and what a
fetch() caller cannot do anything useful with — leave it alone for a json
api.
sign_in rotates before it writes, which is the session fixation defence
spelled once in the one place every login goes through. having a session is
not being signed in: an anonymous visitor with a shopping cart has one, and
the guard turns them away all the same.
it is not a user database and it does not check passwords. where the
credentials live, what a subject means, and how a password is checked belong
to the application, and std.crypto.password already does the last one
properly — see docs/auth.md. an api that took a password here
would have to take a user lookup with it, and would be wrong for every
application that keeps its users somewhere other than where it guessed.
put a rate limiter in front of the login route. the argon2id cost makes each attempt expensive; the limiter is what bounds how many are made:
app = app.group("/login", web.rate_limit(resilience.rate_limiter(5, 10)))
examples/web_login.pith runs the whole stack end to end: the login page
with its csrf token, a wrong password, a right one, the rotation, the
protected page, three ways of faking the cookie, and a sign-out.
a browser attaches a site's cookies to a request whatever page caused it,
which is what lets another site put a form on its own page, point it at
yours, and have it arrive fully authenticated. std.web.csrf demands
something on every state-changing request that a cross-site page has no way
to read: a token that lives in the session and has to be sent back.
import std.web.csrf as csrf
protection := csrf.guard(store)
app = app.use_mw(session.middleware(store))
app = app.use_mw(csrf.middleware(protection)) # inside the session
register it inside the session middleware, since the token it compares against lives in the session.
GET, HEAD, OPTIONS and TRACE pass through untouched. everything else
must present the token, in the X-CSRF-Token header or in a csrf_token
form field — both a urlencoded and a multipart body are read — and gets a 403
if it does not. the comparison goes through std.crypto.subtle, so it looks
at every byte whether or not the first one matched.
a handler that renders a form asks for the token:
fn render_form(req: web.Request) -> http.HttpResponse:
return http.html(200, "<input type=hidden name=csrf_token value=" + protection.token(req) + ">")
protection.token(req) returns the session's token, minting one — and with
it the session — if there is none yet. asking twice returns the same token,
and it survives store.rotate(req), so a login in the middle of a form's
life does not strand it. the middleware then mirrors the token into a
readable cookie on the way out, so a fetch-based front end can read it from
document.cookie and put it in the header without a round trip.
.header_name(n), .field_name(n) and .cookie_name(n) rename the three
places, for a front end that expects other spellings.
handing out a readable cookie and comparing it against the header is the "double submit" pattern, and on its own it leans on the attacker not being able to write a cookie for your domain — which a sibling subdomain, or anything else that gets to set one, can do. here the cookie is a copy for convenience and the session is the authority: the comparison is always against what the store holds, so a cookie the attacker planted alongside a matching header matches nothing.
SameSite=Lax on the session cookie — the default in std.web.session —
turns away most of this on its own. the token is the layer underneath, for
what it does not cover: a top-level POST from another site, a browser that
ignores SameSite, and a session deliberately set to SameSite=None.
a urlencoded body larger than 64 KiB is not scanned for the token, because
splitting a ten megabyte body on & to find one field is work an attacker
can ask for repeatedly. a form that big should send the token in the header,
which costs nothing to read.
examples/web_csrf.pith runs both halves: the honest post with the token in
a header and in a form field, and then the cross-site version — session
cookie attached, no token — plus a token borrowed from another session and a
near miss.
a browser will not let page javascript on one origin read a response from
another unless the server says it may. std.web.cors is the middleware that
says so, and the preflight handler that answers the OPTIONS request the
browser sends first.
import std.web.cors as cors
policy := cors.origins(["https://app.example.com"]).max_age(600)
app := web.new().use_mw(cors.middleware(policy)).get("/api/items", list_items)
there are three constructors, and which one you pick is the whole security decision:
cors.any_origin()— answersAccess-Control-Allow-Origin: *. the policy for a public, read-only api. no credentials.cors.origins(list)— an explicit allowlist, compared whole: scheme, host and port.https://app.example.comdoes not match a subdomain of itself, a different port, or the same host over http. no credentials.cors.credentialed_origins(list)— the same allowlist, and the browser may attach the user's cookies. every origin on the list can act as the signed-in user, so keep it short.
the classic cors hole is to reflect whatever Origin arrived back in
Access-Control-Allow-Origin and send Access-Control-Allow-Credentials: true next to it, which tells every site on the internet that it may read this
one's responses with the user's session attached. that cannot be written here:
credentials come only from credentialed_origins, which has no wildcard, and
any_origin has no credential switch. they are separate constructors rather
than two builder methods so there is no way to combine them by accident. an
entry of "*" in a credentialed list is compared literally, so it matches an
origin spelled exactly *, which no browser sends — it allows nothing.
the builder narrows the rest:
.methods(names)— what a preflight may ask for. defaults toGET,HEAD,POST,PUT,PATCH,DELETE,OPTIONS.headers(names)— which request headers a preflight may ask for. defaults toContent-TypeandAuthorization.expose(names)— which response headers page javascript is allowed to read.max_age(seconds)— how long the browser may cache the preflight answer
register it outermost, before any guard that can answer 401, so a rejected request still carries the headers that let the browser read the rejection — otherwise a plain 401 shows up in the console as a cors error and sends whoever is debugging it looking in the wrong place.
what happens to a request from an origin that is not allowed depends on which
one it is. a preflight is refused with 403: it exists only to ask permission,
and the answer is no. a real request still runs — the server has no standing
to refuse it, and the same request without an Origin header would be served
— but the response carries no Access-Control-Allow-Origin, so the browser
withholds the body from the page. that is the same-origin policy working as
designed.
examples/web_cors.pith runs the whole thing: a real request from an allowed
origin and from somewhere else, the preflight for each, and the headers that
come back.
use_mw middleware only wraps a route the router matched, and a preflight
arrives as OPTIONS on a path registered for GET or POST — so before cors
existed those requests fell straight through to a 404 with no middleware in
sight. std.web now answers an OPTIONS request to any path some route
claims with 204 and an Allow header listing that path's methods, and runs
it through the middleware chain like any other request. cors middleware
intercepts the preflight ones; the rest get the honest Allow answer. an
OPTIONS to a path no route claims is still a 404.
a server built with web.new() is observable out of the box. every route is wrapped
in a built-in middleware that records metrics and opens a trace span, and web.new()
serves a /metrics endpoint — none of which you write. this built-in middleware sits
outside any middleware you add, so it measures the whole request.
for every request the middleware records three series, labeled by method and route
pattern. the label is the pattern (/users/:id), not the concrete path, so a busy
route stays one series instead of thousands:
http_server_requests_total{method,route,status}— a request counterhttp_server_requests_in_flight{method,route}— a gauge of in-flight requestshttp_server_duration_ms{method,route}— a request-duration histogram
the accept loop records two more, unlabeled, about connections rather than
requests — http_server_connections and
http_server_connections_refused_total. those come from listen itself, so a
web.bare() app has them even though it has no request middleware; how many at
once covers what they mean.
these live in memory and are always on: they need no setup and cost a map update per
request. web.new() also registers GET /metrics, which renders every std.metrics
series as prometheus text, so a scraper reads them straight off your server:
app := web.new().get("/users/:id", show)
# GET /metrics now returns the prometheus exposition, including
# http_server_requests_total{method="GET",route="/users/:id",status="200"} 12
examples/web_observability.pith runs the whole loop: it makes a few requests to a
route, then reads the request count back out of /metrics — with no instrumentation
code in the handler.
the same middleware opens a server span per request, tagged with the method, path, and
status. it reads an inbound traceparent header, so a request that arrives already in
a trace joins it. until you turn tracing on the span is a cheap no-op: it takes no ids
and records nothing, so an app without an exporter pays nothing for it.
to export spans, initialize std.obs before you start serving. obs.init() reads the
standard OTEL_* environment variables and, when OTEL_EXPORTER_OTLP_ENDPOINT is set,
turns tracing on and starts the OTLP exporter:
import std.obs as obs
fn main() -> Int!:
obs.init()
web.new().get("/", home).listen("0.0.0.0", 8080)!
return 0
spans then flow to your collector; docs/telemetry.md covers the exporter and the
sampler in full.
access logging is opt-in rather than on by default. metrics and traces carry the observable-by-default story quietly, and a log line per request would clutter an example or a benchmark, so you add it when you want it:
app := web.new().use_mw(web.access_log).get("/", home)
web.access_log writes one structured line per request to std.log (which goes to
stderr) with the method, route, path, status, and elapsed time.
observability adds a little work to every request, so for a benchmark or a bare-bones
server, build the app with web.bare() instead of web.new(). a bare app has only the
default /healthz route: no observability middleware and no /metrics.
listen accepts connections in a loop and hands each one to its own task with
spawn, so a slow client only ever holds up itself. this works the same whether you
run on os threads or on the green runtime:
./your_server # green on linux, os threads elsewhere
PITH_GREEN=0 ./your_server # one os thread per connection, anywhere
on the green runtime those per-connection tasks are green threads, so a server can carry many more connections than it has os threads.
whatever goes wrong on one connection stays on its task. a client that fails a tls handshake, hangs up mid-request, or sends something unparseable costs itself its own connection and nothing else. the loop itself only gives up when the listener stops producing connections at all: it backs off after a failed accept rather than spinning, and stops after a bounded run of failures with nothing accepted in between, which is as much as it can tell from the outside. that matters because a listener that has quietly stopped accepting still answers a tcp health check — the socket is open, the kernel is filling the backlog, and nobody is serving.
listen and listen_tls serve at most 512 connections at a time, between them.
a task costs a stack and its buffers, so a loop that spawns one per accept with
no ceiling lets a client trade cheap sockets for expensive server memory: open
them faster than they complete and the server runs out.
past the cap the loop keeps accepting and refuses immediately — 503 Service Unavailable with Retry-After: 1, written by the accept loop itself, no task
spawned. over tls the socket is closed instead, before the handshake: saying 503
in http would mean completing the key exchange first, which is the expensive work
being refused.
the 503 is best-effort. closing a socket that still holds unread data resets it rather than finishing it cleanly, and a client that gets a reset drops what it had buffered — so a peer that had already sent its request, which is most of them when a cap is firing, sees a connection error instead of the status line. draining the request first is what would fix that, and it cannot be done without a read that has no deadline under the green runtime, which would park the accept loop for good. so treat the refusal metric as the signal and the 503 as a courtesy to the clients that happen to be waiting.
this is deliberately not what the http/2 server does, which waits for a
connection to end before accepting another. the difference is the protocol. an
http/2 connection multiplexes, so requests keep arriving on connections that are
already open and a loop that stops accepting still serves at full rate. http/1.1
has no such decoupling: a request needs a connection, so a loop that stops
accepting stops answering everything, including the load balancer's probe and
your /healthz. an orchestrator then kills the process for being unresponsive at
exactly the moment it is healthy and full, and the replacement comes up into the
same load. refusing keeps the server reachable, and a load balancer that gets an
immediate answer — a 503 or a reset — sheds to another instance instead of
waiting on a socket that will never reply.
listen_h2c is not part of this budget — it delegates to std.net.http2.server
and is bounded by that module's own cap of 512.
two metrics make the cap visible. both are recorded by the accept loop rather
than the request middleware, so a web.bare() app has them too:
http_server_connections— a gauge of connections being served right nowhttp_server_connections_refused_total— a counter of connections turned away
a refusal counter that is climbing is the cap shedding load, which is what it is for. a gauge pinned at 512 with no requests flowing is the other case: slots held by connections that are not going anywhere. under the green runtime that is worth knowing about, because the idle timeout that would otherwise reclaim them does not fire there — the green read path waits on the reactor with no deadline — so a client that connects and then says nothing holds its slot until it disconnects. the gauge is what tells you, and the server keeps answering every new connection either way, which it would not if the loop had blocked.
the same app serves http/2 with a different call. listen_h2c speaks cleartext
http/2 (h2c), and listen_tls speaks http/2 over tls with an http/1.1 fallback.
either way the request runs through the same routing, middleware, and
observability as listen. only the transport changes, so nothing in your app
has to know which one it is.
# cleartext http/2, no certificate needed
app.listen_h2c("0.0.0.0", 8080)!
listen_tls offers alpn ["h2", "http/1.1"] and branches per connection. a
client that negotiates h2 is served over http/2; anything else, including a
client that sends no alpn, is served as http/1.1 over the same tls session. one
listener handles a modern http/2 client and a plain https client alike.
# http/2 over tls, falling back to http/1.1 for clients that ask for it
app.listen_tls("0.0.0.0", 8443, "cert.pem", "key.pem")!
cert and key are paths to pem files: the server certificate and its private
key. h2c needs neither, which makes it the easy choice behind a load balancer
that terminates tls. reach for listen_tls when the pith server terminates tls
itself.
listen and listen_tls serve forever until a shutdown is requested. one call
wires that to SIGTERM:
import std.shutdown as shutdown
fn main() -> Int!:
shutdown.on_signals()!
web.new().get("/", home).listen("0.0.0.0", 8080)!
return 0
on SIGTERM (or SIGINT/SIGHUP) the listener stops accepting and releases its
port, the in-flight requests are given a grace period to finish, and listen
returns the work that was still unfinished when that period expired — 0 for a
clean drain. a rolling deploy stops severing responses mid-flight. without the
on_signals() call nothing changes and listen blocks forever, as before.
docs/signals.md covers the drain, the coordinator, and what becomes of a stream that would otherwise never end.
examples/web_hello.pith is a complete, self-checking server: it defines a couple of
routes, spawns the server, makes a few requests against itself, and prints the
replies. examples/web_cors.pith puts a cors policy in front of an api and drives it
from two origins, one allowed and one not. examples/web_session.pith runs the whole
life of one session: an anonymous visit, a sign-in that rotates the id, a forged
cookie, and a sign-out. examples/web_csrf.pith posts a form the honest way and then
the way another site would. examples/web_login.pith puts the whole stack together —
argon2id, a session, a csrf token and a guard — and drives a login, a protected page
and a sign-out through it. examples/web_observability.pith does the
same and then scrapes /metrics to show the request counter the framework kept on its
own. examples/web_h2.pith serves
the same kind of app over http/2 (h2c) and drives it with a small built-in h2c client.
bench/web_hello.pith is the same idea as a real blocking server, wired up for
bench/http_bench.sh. examples/graceful_shutdown.pith serves, signals itself,
and prints the drain outcome.
http.html(200, body) will send whatever body you hand it, so what goes into
that body is the whole of your xss story. anything the caller controls — a
query parameter, a path parameter, a header, a stored value that arrived from a
request once — goes through std.html on the way in:
import std.html as html
fn greet(req: web.Request) -> http.HttpResponse:
return http.html(200, "<h1>hello, " + html.escape(req.param("name")) + "</h1>")
for a whole page rather than a fragment, std.template renders from a context
and escapes every interpolated value on the way out, so the safe thing is what
happens when you do not think about it:
import std.template as template
fn greet(req: web.Request) -> http.HttpResponse:
ctx := template.context().set("name", req.param("name"))
return http.html(200, template.render_string("<h1>hello, <%= name %></h1>", ctx) catch "")
a page served per request should be compiled once at startup rather than on
every call, so that a syntax error is a failure to boot instead of a 500.
docs/html.md is the full account of both: the five characters, why
there is no separate attribute escaper, why a url in an href needs
html.escape_url rather than html.escape, the contexts — inside <script>,
inside <style>, an unquoted attribute — where escaping is not the fix, and
the template syntax with its one greppable opt-out.