Everything you need to know, in one page
A guided tour of serverino: endpoints, routing, requests, responses, configuration, WebSockets, HTTPS and deployment. For the exhaustive list of types and members, see the API reference.
Install
You need a D compiler (DMD 2.109 or newer, or a recent LDC) and dub. The fastest way to start is the project template:
dub init your_app -t serverino cd your_app dub run
To add serverino to an existing project:
dub add serverino
Your first endpoint
An endpoint is a free function that takes a Request and an Output.
mixin ServerinoMain writes main() for you.
import serverino;
mixin ServerinoMain;
void hello(Request request, Output output)
{
auto name = request.get.read("name", "world");
output ~= "Hello, " ~ name ~ "!";
}
With a single handler you can leave the attribute out. As soon as there is a second one,
every endpoint must be tagged with @endpoint — otherwise serverino would not
know which functions to call.
Endpoints run in separate processes. A global variable you set in one request may or may not be there in the next: it belongs to that worker only. Anything that must be shared belongs in a file, a database or a cache.
Routing and priority
@route decides which requests reach an endpoint. With a string it is an exact
match; with a delegate it is whatever you can express in D.
@endpoint @route!"/about"
void about(Request r, Output o) { o ~= "About us"; }
@endpoint @route!(r => r.path.startsWith("/api/"))
void api(Request r, Output o) { o.addHeader("content-type", "application/json"); o ~= "{}"; }
Endpoints are tried in descending @priority order (zero by default). The first
one that writes something to the output ends the chain — unless it returns
Fallthrough.Yes, which means "I am done, but keep going".
@endpoint @priority(100)
auto onlyGet(Request r, Output o)
{
if (r.method == Request.Method.Get) return Fallthrough.Yes; // let the others answer
o.status = 405;
return Fallthrough.No; // stop here
}
This is how you write authentication, logging or CORS: a high priority endpoint that either answers or steps aside. See the priority example.
The request
| Member | What you get |
|---|---|
request.path | The requested path, decoded. |
request.method | Request.Method.Get, .Post, .Put, … |
request.get | Query string parameters. |
request.post | Fields of an urlencoded body. |
request.form | Multipart fields and uploaded files. |
request.body | The raw body and its content type. |
request.header | Request headers, lowercase names. |
request.cookie | Cookies sent by the client. |
request.host, request.isSecure | Host, and whether the listener is TLS. |
request.dump() | Everything above, formatted. Handy while developing. |
All of those give you the same small interface: read(key, default),
readAll(key), has(key) and data for the underlying array of tuples.
if (request.get.has("page"))
page = request.get.read("page").to!uint;
foreach(name, value; request.header)
info(name, ": ", value);
An uploaded file is a FormData: filename, contentType and
path, the temporary file serverino saved for you.
auto upload = request.form.read("avatar");
if (upload.isFile)
copy(upload.path, "/var/www/avatars/" ~ upload.filename.baseName);
The response
output ~= something appends to the body; the default content type is
text/html.
output.status = 201;
output.addHeader("content-type", "application/json");
output.addHeader("cache-control", "public, max-age=3600");
output.setCookie(Cookie("session", id).path("/").httpOnly.maxAge(1.hours));
output ~= `{"ok": true}`;
Some headers are managed by serverino and cannot be set by hand:
content-length, date, server, status and
transfer-encoding.
To send a file, use serveFile: it streams the file instead of loading it in memory,
and picks the content type from the extension. It returns false if the file is not
there — the 404 is up to you.
@endpoint @route!"/manual.pdf"
void manual(Request r, Output o)
{
if (!o.serveFile("docs/manual.pdf"))
o.status = 404;
}
Serving a whole directory? serveFile does not check the path
for you. Normalise what came from the request and make sure it is still inside your web root
before opening it — that is exactly what
this
website does.
Configuration
A function marked @onServerInit returns the configuration. It runs in the daemon,
before anything is listening.
@onServerInit
ServerinoConfig configure()
{
return ServerinoConfig
.create()
.addListener("0.0.0.0", 8080)
.setMaxWorkers(10)
.setMaxRequestTime(5.seconds)
.setMaxRequestSize(1024 * 1024);
}
| Setting | Meaning |
|---|---|
addListener(addr, port) | Where to listen. Call it more than once for more listeners. |
setMaxWorkers, setMinWorkers | The size of the worker pool. Leave the minimum at zero and let serverino grow and shrink it. |
setMaxRequestTime | How long an endpoint may take before its worker is killed. |
setMaxRequestSize | Body size limit, uploads included. |
setMaxWorkerLifetime, setMaxWorkerIdling | When to recycle a worker. |
enableKeepAlive, setHttpTimeout | Connection handling. |
setWorkerUser, setWorkerGroup | Drop privileges after binding a low port (POSIX). |
setLogLevel, enableServerSignature | Logging, and whether to advertise the server. |
To read the command line, take it as a parameter — serverino passes it in:
@onServerInit
ServerinoConfig configure(string[] args) { /* getopt(args, ...) */ }
Lifecycle hooks
| Attribute | Runs |
|---|---|
@onServerInit | Once, in the daemon, to build the configuration. |
@onDaemonStart / @onDaemonStop | In the daemon, around the server's life. |
@onWorkerStart / @onWorkerStop | In every worker: open your database connection here. |
@onWorkerException | When an endpoint throws. |
@onWebSocketUpgrade | Before accepting an upgrade: return false to refuse it. |
@onWebSocketStart / @onWebSocketStop | In the process dedicated to one WebSocket connection. |
@requestScope | Marks state that must be reset between two requests. |
WebSockets
Take a WebSocket instead of an Output. The upgrade handler decides
whether the connection is accepted at all; after that, your function runs in a process of its own,
for as long as the connection lasts.
@onWebSocketUpgrade
bool upgrade(Request request) { return request.path == "/echo"; }
@endpoint @route!"/echo"
void echo(Request request, WebSocket ws)
{
while(true)
if (WebSocketMessage msg = ws.receiveMessage())
ws.send("you said: " ~ msg.asString);
}
If you prefer callbacks to a loop, set ws.onTextMessage,
ws.onBinaryMessage or ws.onCloseMessage. You can send strings, basic
types and arrays of basic types; ws.send() with no argument flushes what is left in
the buffer. Both styles are in the examples:
receive loop and
callbacks.
Keeping connections under control
An accepted upgrade is a process, and serverino does not cap how many of them may exist:
that decision is yours, and @onWebSocketUpgrade is where you make it. Refusing is
just returning false — the client gets a 403.
@onWebSocketUpgrade
bool upgrade(Request request)
{
if (openConnections() >= 24) return false; // site-wide
return allow(request.header.read("x-remote-ip")); // and per visitor
}
Give the connection an end, too: a loop that runs forever keeps its process alive for as long as an abandoned tab stays open. Close it yourself when the time is up.
immutable until = Clock.currTime + 10.minutes;
while(Clock.currTime < until)
{
// ... receive, send, sleep ...
}
ws.sendClose();
Counting the open connections needs a number that every worker agrees on, and workers share no memory: keep it in a file, in a database, or count the processes themselves. This site does the latter.
Rate limiting
There is no rate limiter in serverino: a high priority endpoint is one, and it is a dozen lines. The only thing to remember is that workers are processes, so the count cannot live in a variable — a counter in memory would only ever see one worker's share of the traffic.
@endpoint @priority(800)
@route!(r => r.path.startsWith("/api/"))
auto rateLimit(Request request, Output output)
{
if (allow(request.header.read("x-remote-ip")))
return Fallthrough.Yes; // out of the way, let the real endpoint answer
output.status = 429;
output.addHeader("retry-after", "1");
return Fallthrough.No;
}
Enable enableRemoteIp() so the daemon adds the x-remote-ip header:
unlike x-forwarded-for, the client cannot set it, so it is the one to trust when
nothing is proxying in front of you.
For the counter itself, appending a byte to a file per visitor and per time window is enough, and it has a property a read-modify-write does not: appends are atomic, so nothing is lost when several workers count at the same moment.
HTTPS and certificates
HTTPS is a build configuration (POSIX only, it links OpenSSL):
dub build --config=https
or, in your dub.json:
"subConfigurations": { "serverino": "https" }
A certificate belongs to a listener, so plain and encrypted listeners live happily in the same
process, and request.isSecure tells them apart:
sc.addListener("0.0.0.0", 80);
sc.addListener("0.0.0.0", 443, Https("cert.pem", "key.pem"));
Certificates are reloaded without restarting anything and without dropping connections, either
by calling Daemon.reloadCertificates() or by sending SIGHUP. That is
enough to renew a Let's Encrypt certificate from inside the server itself: answer the ACME
http-01 challenge on the plain listener, write the new files, reload. The
Let's Encrypt example does exactly that.
This website does none of it: it speaks plain http behind a reverse proxy, for the dullest of reasons — it shares a machine with other sites, so something has to hold port 443 and sort out who gets what. On a host of its own it would carry its own certificate, as the example does. Both arrangements are ordinary; the section below is about the second one.
Hot reload
Rebuild your executable, then ask the daemon to restart its workers. Requests being served are finished first, and no connection is dropped.
| Platform | How |
|---|---|
| POSIX | kill -SIGUSR1 <daemon pid> |
| Windows | Delete the canary file serverino-<pid>-<hash>.canary from the temp directory. |
Deploying
A serverino application is one executable. Run it under systemd, give it the ports it needs and you are done:
[Service] ExecStart=/srv/myapp/myapp Restart=always AmbientCapabilities=CAP_NET_BIND_SERVICE
A reverse proxy is optional — with the https configuration serverino terminates TLS itself — but if you already have nginx or caddy, putting the application behind it is ordinary. This site does exactly that.
Behind nginx or caddy
Two things matter. The client address, because everything reaching your endpoints will otherwise come from the proxy and look like a single very busy visitor; and the WebSocket upgrade, which needs saying out loud in nginx.
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $remote_addr; # overwrite, do not append
proxy_set_header X-Forwarded-Proto $scheme;
# WebSockets, and enough patience for a long lived one
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 900s;
}
Caddy needs less said: reverse_proxy 127.0.0.1:8080 forwards WebSockets and sets
the forwarded headers on its own, and header_up X-Forwarded-For {remote_host} makes
the address explicit.
Which address to trust. Read the last entry of
x-forwarded-for: your proxy appends the address it really saw, and anything before
it came from the client, who may write whatever they like there. If you overwrite the header at
the proxy, as above, there is only ever one entry. With no proxy, use
enableRemoteIp() and read x-remote-ip, which the daemon fills in
itself and a client cannot set.
How it works inside
One daemon process owns the listeners and the event loop (epoll on Linux, kqueue on the BSDs and macOS, select elsewhere). It never runs your code.
A communicator pre-parses the request headers, applies the size and time limits, and only when a request is complete does it hand the socket to a worker — a separate process, which receives the file descriptor and runs your endpoint. Routing tables are built at compile time from the attributes on your functions.
Because workers are processes, a crash costs one request: the daemon still owns the client
socket, so when a worker dies holding a request it answers 500 Internal Server Error
in its place and starts a replacement. Because workers are pre-forked and reused, the cost of that
isolation is paid once, not per request. WebSocket connections get a process of their own, so a
thousand idle sockets never occupy the HTTP pool.
The full write-up is in ARCHITECTURE.md.