A web application maps an HTTP method and path to a handler. The handler reads
validated request data and builds a bounded response. Start with std/web/app
to register routes and run a server on Linux GNU x86-64 or Windows x64.
Use std/web/application for the same routing and handlers in portable,
in-process tests.
Work through the first server, several routes, and tests before configuring concurrency or TLS. The later sections explain exact routing precedence, request decoding, response limits, streaming, and static files.
| Application need | Starting point |
|---|---|
| A fixed page or health endpoint | web.text, html, json, or bytes with app.new().get(...) |
| Request-dependent output | A public struct with handle(&mut self, request, response) |
| Several routes plus shared policy | Fluent registrations and .middleware(...) |
| Repeatable behavior tests | application.builder() and site.request(...) |
| Multiple sockets progressing together | .concurrent(); synchronous handlers still share one thread |
| HTTPS | .run_with(address, https.files(certificate, key)) |
| Streaming bodies/custom execution | std/web/server and std/http/connection |
Quickstart
Save this as main.dodo:
package main
import "std/web"
import "std/web/app"
fn main() -> i32 {
return app.new()
.get(b"/", web.text(b"Hello, Dodo!\n"))
.run(b"127.0.0.1:8080")
}
Start it with dodo run, then visit http://127.0.0.1:8080 or run
curl http://127.0.0.1:8080 in another terminal. Ctrl+C stops the process.
run(address) returns an exit code for main: 0 after clean shutdown, 1 for a
setup/startup error, or 2 if serving returns with failed connections. It prints
startup errors and the last available connection failure to stderr.
Use serve(address) when you want to handle the Result and serving report
yourself. serve_until(address, cancel) supports cooperative shutdown with a
public cancelled(&self) -> bool method. Serving accepts connections until
cancelled by default; setting config.max_connections bounds the total accepted
connections. Routing rejections such as 404 and 405 are counted separately from
connection failures.
Several routes
For /hello/Ada?suffix=+friend, the method and decoded path select a route,
:name captures Ada, and the query helper returns friend. Path captures
and query values are separate inputs; the + convention applies only to query
decoding. Read request access for the exact rules.
Static responses need no custom handler. Dynamic handlers receive a request and write into a response:
package web_routes
import "std/web"
import "std/web/app"
pub struct Greeting {
pub fn handle(&mut self, request: &mut web.Request, response: &mut web.Response) -> void!web.Failure {
response.text(b"Hello, ")?
response.append(request.param(b"name")?)?
return response.append(request.query_or(b"suffix", b"!"))
}
}
fn main() -> i32 {
return app.new()
.get(b"/", web.html(b"<h1>Dodo</h1>"))
.get(b"/hello/:name", Greeting {})
.post(b"/created", web.json(b"{\"created\":true}").status(201))
.get(b"/old", web.redirect(b"/"))
.run(b"127.0.0.1:8080")
}
/hello/Ada responds with Hello, Ada!; /hello/Ada?suffix=+friend responds
with Hello, Ada friend. See the complete multi-route example
for a body echo route too.
| Registration | Purpose |
|---|---|
.get, .post, .put, .patch, .delete, .head, .options |
Register a method and path pattern |
.route(method, pattern, handler) |
Register another case-sensitive method |
.middleware(value) |
Apply middleware to every matched route |
.concurrent() |
Enable concurrent socket progress on the hosted builder |
.max_connections(count) |
Stop after this many accepted connections; zero is unlimited |
.build() |
Validate setup and obtain a reusable application/server for tests or configuration |
.run(address) |
Serve with error printing and a process exit code |
.serve(address), .serve_until(address, cancel) |
Serve with a Result and detailed report |
Registration consumes the builder and returns its new type, so chain calls or
bind their result. It retains the first registration error; build, serve,
and run report it before binding a socket. A duplicate route produces a
printable diagnostic such as:
web route #2 (GET /:second): web: AmbiguousRoute; this method and path shape are already registered
RegistrationError exposes kind, method, pattern, and the zero-based
index. Fluent serving returns app.Error, with either registration or
serving populated. build() returns application.RegistrationError directly;
serving on a built Server returns the underlying hosting.Error.
Routes use /users/:id for a segment and /files/*rest for a final wildcard.
Literal paths take precedence. HEAD falls back to GET, unmatched paths return
404, and unsupported methods on a matching path return 405. There is no automatic
OPTIONS response or trailing-slash redirect.
The fluent builder stores up to eight routes (application.ROUTE_CAPACITY).
Handlers are owned inline, retain state between requests, and use ordinary
compiler-checked methods. Dodo currently has no closures or function values;
large generic chains are costly to compile. Larger tables can use web.Router
and an explicit dispatcher. Route patterns and handler borrows must outlive the
application. No request/response borrow can escape into handler state.
Ready-made responses
web.text(body), web.html(body), web.json(body), and web.bytes(body) return
ordinary handlers. .status(code) changes their status; validation occurs when
they handle a request. web.redirect(location) defaults to 303 (See Other);
.status(301), 302, 307, and 308 select other redirect behaviors.
Text and HTML validate UTF-8. JSON accepts already serialized UTF-8 JSON; it does not serialize structs or validate JSON syntax. HTML helpers do not escape untrusted text. Body and header bytes are copied into bounded response storage.
Test without a server
Test application behavior before introducing sockets. Start with successful requests, then add missing paths, wrong methods, missing required input, invalid numeric input, and handler failures. These tests use the real router and handler, so a rejected request should assert both status and public body.
Use application.builder() for portable application tests. It offers the same
route and middleware methods; build() returns an Application without hosted
configuration. request(method, target) decodes and dispatches a request in
process, returning an owned response:
package web_test
import "std/web"
import "std/web/application"
@test
fn health_check() {
site := application.builder()
.get(b"/health", web.json(b"{\"ok\":true}"))
.build()!
site.request(b"GET", b"/health")
.expect_status(200)
.expect_header(b"Content-Type", b"application/json")
.expect_body(b"{\"ok\":true}")
site.request(b"GET", b"/missing").expect_status(404)
site.request(b"HEAD", b"/health").expect_status(200).expect_body(b"")
}
Run dodo test. Responses own their bytes and remain valid after another request
or after dropping the application. Inspect status(), body(), header(name),
or header_at(index) directly, or chain the consuming expect_* assertions.
For enumeration, initialize cursor := 0usize and call
header_next(&mut cursor) until it returns none. The cursor tracks a byte
offset, so each header is visited once. This also works on web.Response;
Fields.next and web.field_next expose the same traversal for field storage.
The response’s failure field retains a handler/decoding failure for debugging;
HTTP error bodies stay empty and partial handler output is discarded.
For headers and request bodies, import std/web/testing:
package echo_test
import "std/web"
import "std/web/application"
import "std/web/testing"
pub struct Echo {
pub fn handle(&mut self, request: &mut web.Request, response: &mut web.Response) -> void!web.Failure {
response.header(b"X-Token", request.require_header(b"X-Token")?)?
return response.bytes(request.body())
}
}
@test
fn echoes_body() {
site := application.builder().post(b"/echo", Echo {}).build()!
input := testing.Request.new(b"POST", b"/echo").body(b"hello")
input.header(b"X-Token", b"test")!
site.request_with(&input)
.expect_status(200)
.expect_header(b"X-Token", b"test")
.expect_body(b"hello")
}
Built hosted servers expose the same request and request_with methods.
Tests run the real router, decoding, handler state, and middleware. They use fixed
4 KiB bodies, 4 KiB/32-field headers, and 2 KiB path and query buffers (100 query
pairs). They do not simulate HTTP framing, socket behavior, deadlines, or custom
server limits. Request IDs are 1 for each synthetic request. Keep independent
HTTP integration tests for those boundaries.
Test validated input and failure responses
This handler requires a decimal route parameter. A malformed ID automatically
becomes a 400 response through ?; a valid but unknown ID is an explicit 404.
There is no need to build an error body for either case.
package validated_route
import "std/web"
import "std/web/application"
pub struct Item {
pub fn handle(&mut self, request: &mut web.Request,
response: &mut web.Response) -> void!web.Failure {
id := request.param_u64(b"id")?
if id != 7 { return err(web.reject(404)) }
return response.text(b"item seven")
}
}
@test
fn distinguishes_bad_input_from_missing_items() {
site := application.builder().get(b"/items/:id", Item {}).build()!
site.request(b"GET", b"/items/7").expect_status(200).expect_body(b"item seven")
site.request(b"GET", b"/items/seven").expect_status(400).expect_body(b"")
site.request(b"GET", b"/items/8").expect_status(404).expect_body(b"")
site.request(b"POST", b"/items/7").expect_status(405)
}
The 404 produced by the handler is distinct from the router’s 404 for an
unmatched path, even though the public status can be identical. Inspect a test
response’s failure or the hosted serving report when diagnosing the cause.
Existing registration API
application.new().get(pattern, handler)! and app.Server.new(routes) remain
available. Each registration returns a Result immediately. This API now also
has all seven method shortcuts and the same in-process testing methods. The
fluent builder is the simpler default for new apps.
Configuration and storage
Choose bounds from the messages your application accepts. Increasing a numeric limit alone does not add storage: the effective body limit is also constrained by the actual request/response buffers. A response body is assembled before it is sent, so large downloads belong on the streaming path described later.
app.Config.defaults() is grouped by purpose:
| Setting | Default |
|---|---|
config.execution |
app.Execution.Serial: one request per connection, one socket at a time |
config.limits.header_bytes, header_fields |
16,384 bytes, 100 fields |
config.limits.body_bytes, response_bytes |
4,096 bytes each |
config.limits.path_bytes |
2,048 bytes |
config.limits.query_bytes, query_fields |
2,048 bytes, 100 pairs |
config.limits.response_header_bytes, response_header_fields |
4,096 bytes, 32 fields |
config.timeouts.header_ms |
10,000 ms |
config.timeouts.body_ms, request_ms, write_ms |
30,000 ms each |
config.timeouts.idle_ms |
15,000 ms, concurrent mode only |
config.backlog, max_connections |
256; zero (accept until cancelled) |
config.requests_per_connection, events_per_turn |
100; 16, concurrent mode only |
Each request/response body limit is the smaller of the configured value and available storage. Raising a limit does not enlarge a buffer. Protocol/path/query/header hard caps are the same as the underlying hosted runner. Invalid settings fail before binding. Concurrent-only settings are validated when concurrent execution is selected. Zero body capacities allow empty bodies; zero query or response-header capacities disable those fields. All active timeouts, backlog, request counts, and event budgets must be nonzero.
serve(address) and serve_until(address, cancel) supply fixed local buffers:
68 KiB for serial execution (app.WORKSPACE_BYTES is 61,440 protocol bytes, plus two 4 KiB body buffers),
or 544 KiB for eight concurrent slots. Handler values, route metadata, slot
state, the route index, and stack frames add bounded storage. There is no heap
allocation, buffer growth, per-peer thread, or task queue in this layer. Ensure
the serving thread has enough stack, especially in concurrent mode; custom
storage lets the caller choose where buffers live.
serve_in(address, storage) uses caller storage and the native clock.
serve_with(address, storage, clock, cancel) also accepts a monotonic
now_ms(&mut self) -> u64 clock and cooperative cancellation. Both consume the
server and borrow the storage for the call. The storage wrapper retains its
buffer borrows until dropped. For example:
package custom_storage
import "std/web"
import "std/web/application"
import "std/web/app"
import "std/web/hosted"
import "std/net"
import "std/net/native"
fn main() -> i32 {
routes := application.new().get(b"/", hosted.Text.new(b"Hello!"))!
server := app.Server.new(routes)
server.config.limits.body_bytes = 8192
server.config.limits.response_bytes = 8192
workspace := [0u8; hosted.WORKSPACE_BYTES]
request := [0u8; 8192]
response := [0u8; 8192]
storage := app.Storage.new(&mut workspace, &mut request, &mut response)
clock := native.MonotonicClock {}
cancel := net.Cancellation.new()
cancel.cancel() // Demonstrate returning without accepting requests.
report := server.serve_with(b"127.0.0.1:8080", &mut storage, &mut clock, &cancel)!
assert(report.cancelled)
return 0
}
For custom concurrency, select app.Execution.Concurrent and use
app.Storage.concurrent(workspace, request, response, slots). Supply 1–255
reactor.Slot.new() values and at least hosted.WORKSPACE_BYTES per slot.
Request and response buffers are divided equally among slots; trailing remainders
are unused. A concurrent server given serial storage returns a workspace error.
The slot count, rather than max_connections, bounds simultaneous connections.
HTTPS
Use the same fluent routes and handlers with an explicit TLS transport:
package https_server
import "std/web"
import "std/web/app"
import "std/web/https"
fn main() -> i32 {
return app.new()
.get(b"/", web.text(b"Hello, Dodo!\n"))
.concurrent()
.run_with(b"127.0.0.1:8443", https.files("cert.pem", "key.pem"))
}
https.files reads the certificate chain and private key from PEM files once
at startup, with an 8,192-byte limit for each file. Relative paths use the
process’s working directory. Missing and oversized files report their path;
invalid PEM or a mismatched key fails before binding. Route registration is
checked first. run_with prints failures and uses the same exit codes as run:
0 on success, 1 for startup failure, and 2 for failed connections.
The HTTPS adapter uses app.Config limits and timeouts, bounded default buffers,
middleware, and the same request/response helpers. Add .concurrent() before
.run_with(...) to enable concurrent TLS handshakes, keep-alive, and ordered
HTTP/1.1 pipelining through the reactor. The request limit, idle timeout, and
per-turn event budget apply just as they do for concurrent HTTP. A stalled
handshake or socket does not block other connections; handlers run synchronously.
The header timeout bounds the TLS handshake, then a fresh header and request
budget starts for HTTP. Responses are complete only after their encrypted output
has drained. Normal closure sends TLS close_notify; cancellation aborts all
active connections immediately. The default serial mode still handles one
request per connection.
Importing std/web/https selects OpenSSL 3.5+; ordinary std/web/app programs
retain their existing dependencies.
For typed errors and cooperative cancellation, build the app and call the transport directly:
server := app.new().get(b"/", web.text(b"hello")).build()!
identity := https.files("cert.pem", "key.pem")
report := identity.serve_until(server, b"127.0.0.1:8443", &cancel)!
Here cancel supplies cancelled(&self) -> bool, as in the configuration
example above. identity.serve(server, address) uses default cancellation;
identity.serve_with(server, address, &mut storage, &mut clock, &cancel) accepts
the same app.Storage used for custom HTTP buffers. For concurrent HTTPS, use
app.Storage.concurrent with 1–255 reactor slots and at least
https.WORKSPACE_BYTES (69,632 bytes) per slot. This includes 8 KiB of TLS
ciphertext staging in addition to the HTTP workspace. Request and response
buffers are divided equally among the slots. Default concurrent HTTPS reserves
608 KiB for eight slots’ byte buffers, plus slot bookkeeping and OpenSSL’s
internal allocations. Serial storage uses app.WORKSPACE_BYTES as before.
These calls consume the server and return hosted.Report!https.Error. Errors
retain the file cause or underlying hosting.Error; a file error borrows its path from identity.
For larger credential buffers or explicit OpenSSL settings, pass
https.Provider.new(identity) to Server.serve_concurrent_with, where identity
is an openssl.Config. The existing serial acceptor API is in std/http/https.
See complete local HTTPS setup for generating test credentials and running a verified client.
Hosted server choices
The server object uses the existing serial std/web/hosted and concurrent
std/web/reactor runners. Those lower-level functions remain available for
custom dispatchers and transport adapters. Builder.run_with(address, transport)
accepts a transport with serve(server, address) -> hosted.Report!E, where E
is printable. Server.serve_serial_with exposes the serial acceptor hook with
custom storage, clock, and cancellation. Server.serve_concurrent_with exposes
the reactor transport hook. HTTPS selects the corresponding runner through the
convenience adapter above. Streaming bodies, upgrades, and
caller-driven execution use std/web/server, std/web/stream, std/web/response, and
std/http/connection; none acquire a hosted dependency. Rooted static files
remain an optional std/web/static_files import.
Concurrent HTTP/1.1 server
std/web/reactor lets other connections advance while a socket waits for input
or output. It uses one thread, bounded caller-owned storage, and native readiness
polling. It supports HTTP keep-alive and ordered pipelined requests without an
allocator, external framework, or task runtime.
The complete example is
examples/web_server_concurrent.dodo:
dodo compile examples/web_server_concurrent.dodo -O 3 -o build/web-server
./build/web-server
Select concurrent execution on the same server object:
package web_server_concurrent
import "std/web"
import "std/web/app"
fn main() -> i32 {
return app.new()
.get(b"/", web.text(b"Hello, Dodo!\n"))
.concurrent()
.run(b"127.0.0.1:8080")
}
The lower-level reactor also accepts custom slot arrays and configuration:
slots() constructs eight Slot.new() values. The slot count bounds active
connections; applications may supply another array or owned collection with
1–255 slots. Allocate WORKSPACE_BYTES * slots.len workspace bytes. Request and
response arrays are divided equally between slots, with any trailing remainder
unused. The example supplies 4 KiB per request and response, totaling 544 KiB
of byte buffers for eight connections. Slots, the route index, and stack frames
add a separate bounded amount of storage.
| Setting | Default and meaning |
|---|---|
config.server |
The same header/body/request/write limits as hosted.Config; backlog 256. |
config.server.max_connections |
Zero serves until cancelled; a positive value stops acceptance after that many connections and drains active slots. It does not set concurrency. |
config.requests_per_connection |
100; the final response advertises Connection: close. Set 1 to disable reuse. |
config.idle_timeout_ms |
15,000; bounds waiting between requests. |
config.events_per_turn |
16; bounds protocol work per ready slot before other slots run. |
All timeout durations, the request limit, and the event budget must be nonzero.
The first header deadline starts on acceptance. Reused connections start a new
request budget when their next bytes arrive or are processed from buffered
pipeline input. Bodies remain buffered before dispatch. HEAD, 204, 304,
100-continue, size limits, and error responses follow the hosted server rules.
Malformed requests and handler failures close their connection after the error
response; a failure after final output starts closes without a second response.
serve_with(..., config, clock, cancel) accepts explicit clock and cancellation
providers. Cancellation closes all active sockets, including partially read
requests. Report.completed counts delivered successful dispatches, so it can
exceed Report.accepted with keep-alive. Rejected dispatches and failed
connections have separate counters. Idle expiration and clean keep-alive EOF
are normal closure.
Handlers still run synchronously on the serving thread. A long-running handler
blocks that thread until it returns; this API provides concurrent socket progress,
not parallel application callbacks. Slots, bodies, and reuse remain bounded.
The reactor serves HTTP and, through std/web/https, HTTPS. Custom polling
transports can use reactor.serve_using; the explicit serial TLS acceptor
remains available through std/http/https.
API and contracts
std/web provides routing, request context, structural handlers, middleware and
error-response policy. It imports no sockets, TLS, files, scheduler or clock.
std/web/server composes routing with the portable HTTP connection driver;
applications supply transport, execution, time and body consumers independently.
Routing and decoding
Construct Router.new(&routes) from caller-owned Route entries, each with a
byte method, decoded UTF-8 pattern and application-selected integer ID. The router
borrows its table and pattern strings. Construction validates every route and
rejects duplicate method/pattern shapes, including /:first versus /:second.
Sorted tables containing only literal paths use binary search. Other tables use
one scan that selects path specificity and method together. Construction of an
unsorted or dynamic table with Router.new checks ambiguity pairwise.
Router.indexed(&routes, &mut index) adds an allocation-free hash index for
literal paths and the literal prefix before a route’s first parameter or wildcard.
Supply at least twice as many usize slots as routes. Construction checks
duplicate shapes while inserting; hash collisions always require full comparison.
Both the route table and index remain borrowed by the router; failed construction
may modify index storage. Dynamic lookup probes prefixes at segment boundaries
and checks candidates with those prefixes. Routes sharing a prefix still scan
within that group, including routes beginning with a parameter or wildcard.
Literal precedence, method selection, and HEAD fallback remain identical.
Application registration retains its validated index. table.router() borrows
it without rebuilding or revalidating; in-process application requests reuse this
view. testing.send_router(&router, &mut handler, &input) also accepts an existing
router, while testing.send(routes, ...) validates raw routes on each call.
Both hosted runners automatically index every table. Up to 1,024 routes use
inline storage; larger tables allocate an index once at startup and free it when
serving returns. The index has two usize slots per route; allocation or size
failure returns hosting.ErrorKind.Workspace before binding. Request routing
does not allocate. Portable Router.indexed continues to use only caller storage.
| Pattern | Meaning |
|---|---|
/users/new |
Literal match, including case and trailing slash |
/users/:id |
One nonempty segment |
/files/*rest |
Final wildcard: multiple segments or an empty remainder after /files/ |
The first differing segment determines precedence: literal, parameter, wildcard.
Registration order has no effect. Path specificity is selected before method
matching: a less-specific route never overrides a more-specific path’s method
restriction. Methods are case-sensitive. An explicit HEAD route wins; otherwise
HEAD falls back to GET with Match.head = true. Other methods need explicit
registrations. There is no automatic OPTIONS response or slash redirect.
/files/*rest matches /files/, but not /files. A path with no matching method
returns MethodNotAllowed; an unmatched path returns NotFound. Parameter names
contain ASCII letters, digits or underscore, and cannot repeat in a pattern.
parameter() returns offsets into the decoded path, not raw pointers.
Request captures the first eight named parameter spans during construction.
Repeated parameter reads reuse those offsets; routes with more parameters remain
supported and look up later names without repeating the full pattern match.
Before routing an HTTP target, call decode_path(target, output). It strips the
query and decodes percent escapes once into caller storage, respecting the
component distinction in RFC 3986.
It rejects malformed escapes, encoded slash/backslash, NUL and controls, invalid
UTF-8, fragments, backslashes and ./.. segments. + remains +. An escaped
percent remains literal: routing never decodes it again. Repeated and trailing
slashes remain significant. Patterns contain decoded UTF-8 literals, not percent
escapes. Insufficient output returns BufferFull; an error may leave a modified
prefix that must not be used as a path. Router.find accepts already-decoded
paths and validates them. Path.decode(target, output) decodes and returns a
validated borrowed path; Path.new(bytes) validates an already-decoded path.
Pass either to Router.find_path(method, &path) to reuse validation. The path
holds a checked borrow, so its bytes cannot change while that proof is in use.
The server composition also extracts paths from absolute-form HTTP targets;
its router does not implement authority/virtual-host selection or OPTIONS *.
Handlers and middleware
The buffered request lifecycle is:
- HTTP framing and bounded body collection complete.
- The target is decoded and a route/method selected.
- Middleware
beforemethods run in order. - The selected handler reads the request and writes a response.
- Middleware
aftermethods run in reverse order, subject to the failure rules below, and the runner sends the response.
Handlers retain their own state between requests, but request and response views are temporary. Copy the particular data you need into appropriately owned state; do not retain a borrowed path, header, body, or response slice.
Buffered handlers implement one ordinary, compiler-checked method:
pub fn handle(&mut self, request: &mut web.Request,
response: &mut web.Response) -> void!web.Failure
The serial, HTTPS and concurrent servers all use it. web.handle(handler, request, response) also works without a server. A response starts at status
200 with no headers and an empty body. For example:
pub struct Greeting {
pub fn handle(&mut self, request: &mut web.Request,
response: &mut web.Response) -> void!web.Failure {
response.set_status(201)?
response.header(b"X-Request-Method", request.method())?
return response.json(b"{\"hello\":\"Dodo\"}")
}
}
Register this handler with app.new().get(b"/hello/:name", Greeting {}). See the complete
web_response.dodo
example for HTML and route/query access.
Request access and decoding
| Method | Result |
|---|---|
request.method() |
Original, case-sensitive method bytes |
request.path() |
Validated UTF-8 path, percent-decoded once, without query |
request.body() |
Complete bounded body bytes, after HTTP transfer decoding |
request.header(name, occurrence) |
Option<&[u8]>; ASCII case-insensitive name |
request.query(name, occurrence) |
Option<&[u8]>; case-sensitive decoded name |
request.parameter(name) |
Option<&[u8]>; named route capture in the decoded path |
request.param(name) |
Required route capture; use ? to reject missing input with 400 |
request.param_u64(name) |
Required ASCII decimal capture, checked for overflow; invalid input returns 400 |
request.header_value(name), query_value(name) |
First occurrence as an Option |
request.require_header(name), require_query(name) |
First occurrence as a Result; missing input returns 400 |
request.query_or(name, fallback) |
First value, or fallback when absent; preserves empty values |
request.query_u64(name, fallback) |
First ASCII decimal value; missing uses fallback, empty/invalid/overflow returns 400 |
Occurrences start at zero. Headers and query pairs preserve duplicates in wire
order; lookup never combines comma-separated values or chooses a last value.
Missing values return none, while present empty values return some(b"").
Headers retain their value bytes after the HTTP parser trims surrounding spaces
and tabs. Header values need not be UTF-8. Trailers are validated by HTTP but
are not exposed as request headers. Framing headers retain the HTTP parser’s
strict duplicate rules. Bodies are arbitrary bytes; there is no automatic
JSON, form-body, multipart, charset or content-encoding conversion.
Queries split on &, then on the first =, before decoding. %HH is decoded
exactly once; + becomes space. Encoded &, =, +, # and / remain value
characters. Empty components between ampersands are skipped, a bare key has an
empty value, and an empty key is allowed. Malformed escapes, controls (including
NUL and DEL), fragments and invalid UTF-8 are rejected before dispatch.
Semicolons do not delimit pairs. decode_query percent-error positions refer to
the raw query; HTTP URI errors refer to the whole target. UTF-8 error positions
refer to the decoded key or value. Route captures are not
decoded again: /users/a%252Fb captures a%2Fb; encoded path separators such as
%2F are rejected. There is no normalization or Unicode case folding.
route_id, request_id and cancelled are public request metadata. The route
index used for captures is retained separately from the application route ID.
All accessor views borrow the request. Neither request views nor response
storage views may escape into handler state or survive a conflicting mutation.
The checker enforces this without unchecked pointers or callback lifetime casts.
Response construction
| Method | Behavior |
|---|---|
response.set_status(code) |
Select a final status, 200–599 |
response.json(bytes) |
Copy already serialized UTF-8 JSON; default type application/json |
response.append(bytes) |
Append bytes to the current body within its capacity |
response.empty(code) |
Set a status and clear the body |
response.redirect(location) |
Empty 303 response with a validated Location header |
response.redirect_to(location, code) |
Redirect with 301, 302, 303, 307, or 308 |
response.header_value(name) |
Inspect the first response header without case sensitivity |
response.header(name, value) |
Copy and append a validated header |
response.text(bytes) |
Validate UTF-8 and copy the body; default type text/plain; charset=utf-8 |
response.html(bytes) |
Validate UTF-8 and copy the body; default type text/html; charset=utf-8 |
response.bytes(bytes) |
Copy arbitrary body bytes; add no Content-Type |
response.status(), response.body() |
Read status or borrow copied body bytes |
response.header_at(index) |
Borrow a copied header by insertion index |
response.header_next(&mut cursor) |
Iterate headers in insertion order; start the byte cursor at zero |
Each body call replaces the previous body. Text/HTML add their default type only when no Content-Type has been supplied; set a custom type before calling them. A second Content-Type is rejected case-insensitively. Other duplicate response headers are appended in insertion order, including separate Set-Cookie fields; the application must respect each field’s HTTP semantics. Header names must be HTTP tokens; CR, LF, NUL and invalid field-value controls are rejected.
The runner generates Content-Length and Connection. Handlers cannot supply Content-Length, Transfer-Encoding, Connection, Trailer, TE, Upgrade, Keep-Alive or Proxy-Connection. HEAD invokes the selected handler and sends its headers and representation length, without body bytes. 204 omits Content-Length; 205 sends length zero; 304 sends the buffered representation length. None of these statuses sends body bytes. Informational responses and streaming framing remain available through the lower-level connection API.
Response.new(header_storage, header_field_limit, body_storage) borrows two
caller-owned byte buffers. Mutators copy their arguments and retain no borrow
of handler locals or request data. Failed setters leave the existing response
unchanged. Propagate errors with ? to discard the whole response; a handler may
also catch an error and deliberately build a smaller response.
Use return err(web.reject(401)) for an intentional HTTP rejection, or propagate
required/numeric input failures with ?. Valid rejection codes are 400–599;
invalid codes become internal 500 errors. Failure.status() exposes the selected
status. Ordinary web.failure(...) values remain internal 500 errors when
returned by a handler. Serial, concurrent, and in-process dispatch agree.
web.Failure keeps a kind: web.Error, plus optional underlying http.Error
and text.Error diagnostics. Response capacity and header/status validation errors retain
protocol kind and position; invalid UTF-8 retains its exact text diagnostic.
Hosted failures retain this value in hosting.Error.application, and server
Report.last_error retains the latest observed connection failure. Public error
responses are empty; internal errors and request data are never formatted into
them. serve_connection returns the original error even when it successfully
sends an error response (reason.responded == true); the serial server counts
these as rejected connections. Inspect reports as well as startup Results.
Middleware composition
Register a struct with public before and after methods using
.middleware(value). It applies to every matched route, including routes
added earlier or later. Multiple middleware values run before in registration
order and after in reverse order. Routing errors occur before middleware.
package middleware_test
import "std/web"
import "std/web/application"
pub struct RequireToken {
pub fn before(&mut self, request: &mut web.Request) -> void!web.Failure {
match request.header_value(b"Authorization") {
some(_) => { return ok() }
none => { return err(web.reject(401)) }
}
}
pub fn after(&mut self, request: &mut web.Request, status: u16) {}
}
@test
fn rejects_missing_token() {
site := application.builder()
.get(b"/private", web.text(b"Hello"))
.middleware(RequireToken {})
.build()!
site.request(b"GET", b"/private").expect_status(401).expect_body(b"")
}
This demonstrates a header-presence check; a real authentication middleware also
validates the token. For middleware on one route, register
web.with(handler, middleware) as its handler.
Chain<A,B> invokes before(request) outer-to-inner and after(request, status)
inner-to-outer. before returns void!web.Failure; a failure short-circuits
without invoking the handler or after. Handler failure invokes after with
the failure’s HTTP status (500 by default) and preserves the failure. Cancellation
before dispatch invokes neither.
Applications own any additional handler or middleware state.
The lower-level streaming Context remains separate. ErrorResponses maps its
routing errors, and std/web/response.Builder still builds borrowed HTTP framing
metadata from preinitialized header slots.
Streaming composition and execution
std/http/connection.Connection owns borrows of three caller buffers: parser
workspace, input and output. poll_event and poll_flush perform at most one
provider I/O operation per step. A body fragment must be consumed before advancing.
body() returns a checked view; after a sink accepts a prefix, release the view
and call consume_body(count). The borrow checker prevents refilling storage while
that view remains live. send_body copies accepted bytes into output; flush them
before encoding another fragment. finish_body writes final chunks/trailers or
validates the promised length. Dropping discards pending data without hidden I/O.
append_body can place body bytes after entirely unsent output, allowing a small
response head and body to share one write. Once flushing starts, finish flushing
before appending. poll_body(stream, bytes) writes fixed-length or close-delimited
body bytes directly from the caller’s slice in at most one transport operation.
It retains no borrow after return; advance the source by the reported progress.
Flush pending output first. Chunked bodies continue through send_body and
poll_flush. Hosted adapters use these paths automatically.
Connection.suspend() consumes a driver and returns its opaque State without
buffer borrows. Connection.resume(state, workspace, input, output) reattaches
the same buffer contents and checks capacities. Preserve each state’s association
with its buffers; capacity checks cannot detect unrelated or overwritten bytes.
Parser.suspend() / Parser.resume() provide the equivalent parser operation.
This lets the reactor keep per-peer protocol state while borrowing one slot’s
buffers only for its current turn.
std/web/stream.PendingBody applies the same discipline to any polling writer.
It borrows the source until delivered, and one step makes at most one writer
call. Pending means backpressure, not EOF or a retry loop.
std/web/server.Application combines routing, caller path storage and the HTTP
driver. Streaming handlers implement head(&mut Context), poll_write(body)
and complete(&mut Connection). Head dispatch happens only after all headers
pass protocol validation. Bodies go directly to the handler’s polling sink.
Completion queues a response, which the caller streams and flushes. Observers
receive numeric IDs, accepted-byte counts and head/completion/failure events;
they own their sinks. Routing errors disable reuse and permanently stop that
Application before it can deliver bytes to a stale handler. An application may
queue a configured error response, flush it, and close the transport.
Every execution loop supplies absolute monotonic milliseconds and cancellation
to Connection.check before each step. std/http/server.Budget provides header,
body, response and idle phases and deadline calculations. Limits contains
phase timeouts, protocol bounds, connection capacity and requests per connection.
Defaults are 128 connections, 100 requests/connection, 10 s headers, 30 s body/
write/shutdown, and 15 s idle. The caller applies these settings and chooses phase
transitions. Protocol buffers never grow, and no thread or queue is created.
std/http/server.Server controls admission and graceful shutdown. Admit before
owning an active connection; release exactly once on every exit path. After
begin_shutdown, stop accepting and finish active responses. Close remaining
transports when must_close(now) becomes true. drained() confirms all admissions
were released. The gate requires exclusive mutable access; concurrent execution
must select explicit synchronization. It is not an implicit worker pool.
The web server example demonstrates bounded serial execution: one connection, fixed buffers, a 30 s absolute deadline, routing/error responses, streaming, TCP half-close and cleanup. The client consumes its response with a five-second deadline. Run these in two terminals:
dodo run examples/web_server_polling.dodo
dodo run examples/http_client_polling.dodo
The server exits after one request; restart it for another. An ordinary HTTP client
can also request http://127.0.0.1:8080/. The
in-memory example
parses a fragmented chunked request without any OS imports.
Optional static files
std/web/static_files independently imports std/fs and a native rooted-open
adapter. Open an explicit Root with Symlinks.Reject, then supply an
already-decoded absolute URL path to open_file. The result is a regular
fs.File, suitable for streaming through caller scratch. There is no directory
listing, implicit index lookup, MIME database, range cache or full-file allocation.
Root selection resolves the caller’s filesystem path, including ancestor links,
but rejects a link/reparse point at the final component. The opened directory is
the authority for requests. Request traversal opens one component at a time
relative to held directory handles and rejects every symlink/reparse point.
Linux uses openat with O_NOFOLLOW; Windows uses handle-relative NtCreateFile
and denies directory write/delete sharing while resolving. Final handles must be
regular disk files. URL paths additionally reject percent signs, colon/alternate
streams, empty internal segments, trailing dot/space and dot segments. Handles
close on all failure paths and deterministic drop. Linux paths are limited to
4095 bytes; Windows conversion uses bounded 32768-unit stack workspace. Content
may still change through independent writers; rooted opens do not promise a
snapshot or prohibit hard links.
Verification and scope
Tests run routing, middleware, backpressure, compiler rejection, static boundaries and HTTP composition at O0/O3. Portable fixtures emit WebAssembly and Cortex-M0 objects. Loopback checks use independent Python HTTP decoding, Dodo client/server examples, byte-fragmented chunks, HEAD, 404, malformed framing and disconnects. Wine executes Windows x64 HTTP peers and real Windows symlink fixtures at O0/O3. Native Windows filesystem/sharing behavior still merits verification.
HTTP/2, HTTP/3/QUIC, WebSockets, cookies, multipart, compression and serialization integrations are separate extension work. HTTPS uses an independently supplied TLS transport; HTTP types and routing never select it implicitly.
Hosted convenience layer
Use std/web/app.Server for grouped application setup and bounded default
storage. Its underlying std/web/hosted runner provides serial connection,
readiness, deadline, and body-transfer loops. See Hosted HTTP and HTTPS for complete small programs,
explicit storage bounds, resolver/deadline scope, cancellation, and a generated
local HTTPS setup. Protocol and routing APIs remain usable independently.
Complete API reference
For every public type, field, constant, and function signature, see std/web, std/web/application, std/web/app, std/web/testing, std/web/https, std/web/reactor, std/web/server, std/web/static_files.