Serving HTTP from a component
Class: recompile. You're writing a wasm component that handles inbound
HTTP requests — cargo plus the pre-flight loop. The aether.http.server
capability (ADR-0108) binds the listening socket; you write the handler that
receives aether.http.server.request and replies
aether.http.server.response.
1. Configure the server
The HTTP server is opt-in (off by default). Set AETHER_HTTP_SERVER_ENABLED=1
to turn it on and bind the listening socket:
AETHER_HTTP_SERVER_ENABLED=1 \
AETHER_HTTP_SERVER_BIND_ADDR=127.0.0.1:8080 \
cargo run -p aether-chassis-headless --bin aether-headless
AETHER_HTTP_SERVER_BIND_ADDR defaults to 127.0.0.1:8080; use port 0 to
let the OS pick a free port. The server has no default-handler config knob: a
component receives requests by claiming a route (ADR-0130), and a handler that
wants every request registers the / catch-all from its wire hook —
register_route_self { prefix: "/" } (shown in §3, and in §Claiming routes
for narrower prefixes). The registration is runtime-bound, so the handler can
load or reload without restarting the server, and a request matching no route
is answered 503.
Over MCP (spawn_substrate)
spawn_substrate forwards its args array to the substrate's argv, with no
env field — so the MCP-spawn path configures the server with flags instead of
the env vars above. The #[derive(Config)] on HttpServerConfig is tagged
cli_prefix = "http-server" (ADR-0090), which mints one flag per field —
enabled becomes the bare presence flag --http-server-enabled, and every
other field becomes --http-server-<field>=<value>:
// spawn_substrate (omit selector for the stored default headless binary)
{
"args": [
"--http-server-enabled",
"--http-server-bind-addr=127.0.0.1:8080"
]
}
To use a non-default chassis binary, call upload_binary first and pass the
returned registry selector. spawn_substrate does not accept binary_path.
2. Set up the crate
The http server cap needs no marker feature — unlike render / audio /
text / ui, aether_http and its kinds are always-on, so a
default-features-off wasm build sees them with no extra feature wiring:
# crates/my-http-component/Cargo.toml
[package]
name = "my-http-component"
version.workspace = true
edition.workspace = true
[lib]
crate-type = ["cdylib"]
[dependencies]
aether-actor = { path = "../aether-actor" }
aether-http = { path = "../aether-http", default-features = false }
3. Write the handler
A handler is a wasm component with one #[handler::request] for
aether.http.server.request that returns HttpRouterResult: every route holder
covers the HttpRouter protocol, whose one row replies
aether.http.server.router_result. A buffered answer is its Response variant,
an aether.http.server.response with a status code, optional headers, and a
byte body. The server writes the formatted
HTTP/1.1 response to the client socket. On HTTP/1.1 the connection is kept alive
by default and serves the next request on the same socket; a client that sends
Connection: close (and HTTP/1.0, which closes by default) terminates it, and
an idle kept-alive connection is closed after keep_alive_timeout_millis.
#![allow(unused)] fn main() { use aether_actor::{ActorInitError, WasmActor, WasmCtx, WasmInitCtx, actor}; use aether_http::HttpServerCapability; use aether_http::kinds::{HttpRouterResult, HttpServerRequest, HttpServerResponse, MethodFilter, RegisterRouteSelf}; pub struct Web; #[actor(root, depends(HttpServerCapability))] impl WasmActor for Web { const NAMESPACE: &'static str = "web"; fn init(_ctx: &mut WasmInitCtx<'_>) -> Result<Self, ActorInitError> { Ok(Web) } // Claim the `/` catch-all so every request dispatches here. Register a // narrower prefix instead (see §Claiming routes) to own just one path // family and leave the rest to other handlers. fn wire(&mut self, ctx: &mut WireCtx<'_, '_>) -> Result<(), ActorInitError> { ctx.send::<HttpServerCapability>(&RegisterRouteSelf { prefix: "/".to_string(), method: MethodFilter::Any, shared: false, }); Ok(()) } #[handler::request] fn on_request(&mut self, _ctx: &mut WasmCtx<'_>, req: HttpServerRequest) -> HttpRouterResult { let (status, body): (u16, &[u8]) = match req.path.as_str() { "/" => (200, b"hello"), _ => (404, b"not found"), }; HttpRouterResult::Response(HttpServerResponse { status, headers: Vec::new(), body: body.to_vec(), }) } } aether_actor::export!(public = [Web]); }
HttpRouterResult names the three answers the server accepts: Response (a
buffered HttpServerResponse), Stream (an HttpResponseStreamOpen, see
"Streaming a response" below), and WebSocket (a WebSocketAccept). One
handler can return any of them per request (see "Mixing buffered and streamed
routes" below), and returning any other kind is a compile error.
The register_route_self registration casts the sender to HttpRouter; a
component with no aether.http.server.request handler replying
HttpRouterResult is answered register_route_result::Err. A handler may omit
its actor, and the macro types the ctx by it — WasmCtx<'_> reads as
WasmCtx<'_, Self>, reaching only the actors the component declares with
depends(R). Spell WasmCtx<'_, Erased> for the untyped view.
The component registers at web, its NAMESPACE const: a singleton guest is
named by its own namespace
(ADR-0241
§5). Its wire hook
claims the / catch-all, so every request the server can't match to a more
specific route dispatches here. req.peer_addr carries the connecting
client's address (ip:port, IPv6 bracketed) for logging, rate-limiting, or
allowlisting.
4. Load the handler
load_component resolves against the hub's content-addressed component
registry (ADR-0116), so stage the compiled wasm with upload_component first:
cargo build --target wasm32-unknown-unknown -p my-http-component
The artifact is normally
target/wasm32-unknown-unknown/debug/my_http_component.wasm. Rebuild it after
every handler change; an upload selector continues to name the bytes that were
actually uploaded, not whatever source is now on disk.
// upload_component
{
"staged_path": "/path/to/my_http_component.wasm"
}
// → { "hash": "<hash>", "name": null }
Then load it by selector over the MCP harness once the substrate is up:
// load_component
{
"engine_id": "<engine>",
"selector": "<hash-or-name>"
}
load_component replies with the component's registered address
(web). After that, any inbound HTTP request
on the bound port routes to your handler.
5. Send a request
From a shell, or from any HTTP client that speaks HTTP/1.1:
curl http://127.0.0.1:8080/
# → hello
The server reads the request, dispatches aether.http.server.request to the
handler mailbox, waits for the aether.http.server.router_result reply, and writes
the formatted response to the client. The server adds the Connection header
(keep-alive on a persistent HTTP/1.1 connection, close otherwise) and an
appropriate Content-Length header; your handler sets the status code,
optional extra headers, and the body.
Claiming routes
Several components can each own a path family on the same server (ADR-0130).
A component claims a prefix from its wire hook by mailing
aether.http.server.register_route_self to the server capability; the server
then dispatches matching requests to that component directly. A request
matching no route is answered 503, unless some component claimed the /
catch-all (as the §3 handler does) — then everything unmatched goes there.
#![allow(unused)] fn main() { use aether_http::HttpServerCapability; use aether_http::kinds::{HttpMethod, MethodFilter, RegisterRouteSelf}; // In an `#[actor(depends(HttpServerCapability))]` block. fn wire(&mut self, ctx: &mut WireCtx<'_, '_>) -> Result<(), ActorInitError> { ctx.send::<HttpServerCapability>(&RegisterRouteSelf { prefix: "/api".to_string(), method: MethodFilter::Any, // or MethodFilter::Only(HttpMethod::Get) shared: false, // true joins an ADR-0136 member set }); Ok(()) } }
Matching is by path segment: /api claims /api and everything under
/api/…, and leaves /apiary alone; / claims everything as a catch-all.
When prefixes overlap, the longest match wins, and a route filtered to one
method beats a method-agnostic route at the same prefix. A prefix already
claimed by another component is answered
aether.http.server.register_route_result::Err — first claimant keeps it.
Routes follow the component: the route holds a proof of the registrant that
survives a republish, and it is released automatically when the
component drops, or explicitly via
aether.http.server.unregister_route_self. External callers (an MCP session,
a test) use the register_route / unregister_route forms, which name the
handler by its canonical path.
Every route dispatches as aether.http.server.request to the holder's one
request handler, so a component serving several prefixes tells them apart by
req.path (or through the typed surface below, which does that for it).
Registering a route for another actor
register_route_self resolves the registrant from the sender's in-process
Source; an MCP session or a test has no such source, so it uses the named
form instead — register_route / unregister_route, which name the handler
by its canonical actor path. RegisterRoute carries prefix (String),
method (MethodFilter — {"Only": "Get"} for one method, "Any"
to match every method; the seven variants are Get, Post, Put, Delete,
Patch, Head, Options), handler (the path text), and shared (the
ADR-0136 member-set flag — false claims the prefix exclusively, true joins
the round-robin set on it).
The handler path must be canonical — the path a load_component reply
returns, api for a singleton component whose NAMESPACE is api. The named actor has to take aether.http.server.request and reply
HttpRouterResult: the path is ProtocolPath<HttpRouter>, so a path no
route has stood at, or whose route does not publish that row, is refused when
the mail is decoded, and answered Err(Handler(..)) naming the path and why,
rather than accepted and then answering 502 on every request. A path whose
handler has closed decodes, and gets the same answer from the receipt. In Rust the same path
is written ActorPath::<Handler>::root().narrow::<HttpRouter>(), which will
not compile unless Handler has the row. Every route holder has it, including
a streaming, websocket, or deferred handler and a #[http::router] actor.
// send_mail → aether.http.server (kind: aether.http.server.register_route)
{
"prefix": "/api",
"method": { "Only": "Get" },
"handler": "api",
"shared": false
}
The reply is aether.http.server.register_route_result — "Ok",
{ "Err": { "Rejected": { "error": "…" } } } when the server refuses the
claim, or { "Err": { "Handler": { "path": "…", "reason": "Unpublished" } } }
when the handler path did not prove — the same type register_route_self replies,
which is why the named form exists: an external caller (an MCP session, a
test) has no in-process Source to resolve, so register_route_self always
answers it Err.
Releasing the route mirrors the registration, keeping method so a
method-specific route and a method-agnostic route at the same prefix release
independently. Its handler is a plain actor path rather than a protocol one
— a release needs only the identity the route table is keyed by — so any
spelling the engine resolves to the handler works, an ADR-0166 short path
included:
// send_mail → aether.http.server (kind: aether.http.server.unregister_route)
{
"prefix": "/api",
"method": { "Only": "Get" },
"handler": "api"
}
Typed route authoring
The typed surface writes that whole registration for you (ADR-0131). Put
#[http::router] on the actor's impl block, above #[actor], and
#[http::route(<Method|any>, "<prefix>")] on a method; the macros inject the
register_route_self send into wire and emit the router's one
#[handler::request] for aether.http.server.request, which picks the route and
answers HttpRouterResult::Response with what the method returns. A routed method takes an
http::Ctx<'_, C> — the transport ctx (WasmCtx here) plus the request and
matched route, dereffing to the ctx so mail sends read as usual — and returns
HttpServerResponse. The actor must declare depends(HttpServerCapability)
on its #[actor] attribute, because the injected registration mails the
server; without it, the actor fails to compile at #[http::router]:
#![allow(unused)] fn main() { use aether_http as http; use aether_http::HttpServerCapability; use aether_http::kinds::{HttpServerRequest, HttpServerResponse}; #[http::router] #[actor(root, depends(HttpServerCapability))] impl WasmActor for ApiHandler { const NAMESPACE: &'static str = "api"; fn init(_ctx: &mut WasmInitCtx<'_>) -> Result<Self, ActorInitError> { Ok(ApiHandler) } #[http::route(Get, "/api/users")] fn list_users(&mut self, ctx: http::Ctx<'_, WasmCtx<'_>>) -> HttpServerResponse { HttpServerResponse { status: 200, headers: Vec::new(), body: format!("path: {}", ctx.request().path).into_bytes(), } } } }
To parse a request into domain values, add parameters that implement
http::FromRequest. Each runs in declaration order before the method body; the
first one that returns Err becomes the reply — the boundary where a malformed
request turns into a 400 instead of ad-hoc parsing inside the handler:
#![allow(unused)] fn main() { struct UserId(u64); impl http::FromRequest for UserId { fn from_request(request: &HttpServerRequest) -> Result<Self, HttpServerResponse> { request .path .rsplit('/') .next() .and_then(|seg| seg.parse().ok()) .map(UserId) .ok_or(HttpServerResponse { status: 400, headers: Vec::new(), body: b"expected a numeric user id".to_vec(), }) } } #[http::route(Get, "/api/users")] fn get_user(&mut self, _ctx: http::Ctx<'_, WasmCtx<'_>>, id: UserId) -> HttpServerResponse { // `id` is already parsed; a bad id never reaches here. HttpServerResponse { status: 200, headers: Vec::new(), body: format!("user {}", id.0).into_bytes() } } }
An HttpServerRequest parameter hands the method the whole request (the
identity extractor). The same surface serves native actors — a routed method on
an impl NativeActor takes http::Ctx<'_, NativeCtx<'_>> with a
state: &mut YourState first parameter, and the macros write the native wire.
The router types a route's ctx by its actor: http::Ctx<'_, WasmCtx<'_>> reads
as http::Ctx<'_, WasmCtx<'_, Self>>. A route therefore reaches the peers its
actor declares with depends(..) through the flat verbs — ctx.send::<R>(..)
and its siblings, on both transports — and a route that sends to R needs
depends(R) like any other handler.
Drop to the raw register_route_self surface above for a streaming route
(HttpRouterResult::Stream) — the typed surface returns HttpServerResponse,
so a streamed response keeps its own hand-written request handler.
A typed route returns HttpServerResponse and answers at once. A handler that
forwards to a peer and answers when the peer replies is a hand-written
HttpServerRequest handler that holds its reply (ADR-0243): it returns
Pending<HttpRouterResult> from ctx.hold::<HttpRouterResult>(), forwards
with send_with_context, carrying the Held in the request context, and
answers from the peer's #[handler::response], whose context parameter hands
the Held back. The peer is a declared dependency (depends(.., Peer)), and the held
reply is native, so this handler is a native actor's:
#![allow(unused)] fn main() { #[aether_data::kind(name = "my_app.forward_context")] struct ForwardContext { held: Held<HttpRouterResult>, } #[handler::request] fn on_request(_state: &mut State, ctx: &mut NativeCtx<'_>, request: HttpServerRequest) -> Pending<HttpRouterResult> { let (pending, held) = ctx.hold::<HttpRouterResult>(); let _ = ctx.send_with_context::<Peer>(&Ask { path: request.path }, ForwardContext { held }); pending } #[handler::response] fn on_answer(_state: &mut State, ctx: &mut NativeCtx<'_>, answer: Answer, ForwardContext { held }: ForwardContext) { held.answer(ctx, &HttpRouterResult::Response(answer.into_response())); } }
Scaling one handler to N instances
#[http::router(shared)] — the bare ident shared in place of no argument —
registers every route on the impl shared: true instead of the default
exclusive claim. Load N instances of a component written this way and they
join one round-robin member set on their shared prefixes, so "scale this
handler to 4" is one attribute plus a replicas: 4 on the load spec, with no
hand-written register_route_self sends. Any argument other than the bare
shared ident is a compile error naming the two accepted forms (no argument,
or shared).
What happens when the handler doesn't reply
A request handler always answers: it returns its HttpRouterResult, or holds it
and answers later. A router that closes while it still holds a reply answers
502 Bad Gateway at once (router closed before answering). A chain that
settles with no answer, such as a streamed upload whose request_stream_end
handler replies nothing, triggers the server's own 502 safety net. If the
answer takes longer than AETHER_HTTP_SERVER_REQUEST_TIMEOUT_MILLIS (default
30 000 ms), as a held reply whose peer never replies does, the server sends
504 Gateway Timeout. A request matching no route (nothing has
claimed it — e.g. no handler loaded yet) returns 503 Service Unavailable.
Adding response headers
Pass a Vec<HttpHeader> in the returned HttpServerResponse:
#![allow(unused)] fn main() { use aether_http::kinds::HttpHeader; HttpServerResponse { status: 200, headers: vec![HttpHeader { name: "content-type".to_string(), value: "application/json".to_string(), }], body: br#"{"ok":true}"#.to_vec(), } }
The server sends these after its own Connection and Content-Length
headers.
Streaming a response
A handler that serves a large download or a long-lived event stream returns
HttpRouterResult::Stream in place of HttpRouterResult::Response, then emits the body
across many HttpResponseChunk mails and terminates with
HttpResponseStreamEnd (ADR-0128). The server renders the response as chunked
transfer-encoding, so the whole body never resides in memory at once.
The pace is a windowed credit protocol. When the handler opens a stream, the
server grants it an initial credit window (AETHER_HTTP_SERVER_RESPONSE_STREAM_WINDOW,
default 16) as an HttpStreamCredit mail, and replenishes one credit each time
its per-connection writer thread drains a chunk to the socket. A chunk consumes
one credit; when credit reaches zero the handler pauses until the next
HttpStreamCredit arrives. So a slow client blocks the writer thread, not the
scheduler, and the handler cannot outrun the socket.
The data phase answers whoever dispatched to the handler — the same invariant the
HttpResponseStreamOpen reply already honours (ADR-0133). The handler captures a
ResponseStream handle from its first credit mail — the counterparty that paced
the stream, plus the stream_id — and emits every chunk through it. The stream
flows back to that counterparty, so a test mock or a middleware forwarding in
front of the server receives it exactly as the real server does. Each send is a
detached chain root, so a chunk settles on its own causal chain instead of the
credit grant that triggered it. The handler reads the proven sender of the credit
mail with ctx.sender(), casts it once to the ResponseSink protocol with
ctx.cast::<ResponseSink> (ADR-0231 §4), and hands the typed reference to
from_credit. The cast answers None for a sender whose published rows do not
take the chunk and terminator kinds, so a misrouted stream is refused when the
handle is armed instead of warn-dropped chunk by chunk, and every emit compiles
only for the sink's kinds. The credit handler is an ordinary
#[handler::tell]:
#![allow(unused)] fn main() { use aether_actor::{WasmCtx, WasmInitCtx}; use aether_http::{ResponseSink, ResponseStream}; use aether_http::kinds::{ HttpResponseStreamOpen, HttpRouterResult, HttpServerRequest, HttpStreamCredit, }; pub struct Feed { // The stream this handler is feeding, captured from the first credit mail. stream: Option<ResponseStream>, next: u32, done: bool, } #[actor(root)] impl WasmActor for Feed { const NAMESPACE: &'static str = "feed"; fn init(_ctx: &mut WasmInitCtx<'_>) -> Result<Self, ActorInitError> { Ok(Feed { stream: None, next: 0, done: false }) } // Open the stream. The body arrives later, one chunk per unit of credit. #[handler::request] fn on_request(&mut self, _ctx: &mut WasmCtx<'_>, _req: HttpServerRequest) -> HttpRouterResult { self.next = 0; self.done = false; HttpRouterResult::Stream(HttpResponseStreamOpen { status: 200, headers: Vec::new() }) } // Spend the granted credit, then terminate once the body is exhausted. // The first credit mail arms the stream handle — its counterparty is // whoever paced the stream, cast to the sink it covers, and every chunk // flows back through it. #[handler::tell] fn on_credit(&mut self, ctx: &mut WasmCtx<'_>, credit: HttpStreamCredit) { let stream = match self.stream { Some(stream) => stream, None => { let Some(sink) = ctx.sender().and_then(|sender| ctx.cast::<ResponseSink>(sender)) else { return; }; *self.stream.insert(ResponseStream::from_credit(sink, &credit)) } }; let mut budget = credit.credit; while budget > 0 && self.next < 100 { stream.chunk(ctx, format!("line {}\n", self.next).into_bytes()); self.next += 1; budget -= 1; } if self.next >= 100 && !self.done { stream.end(ctx); self.done = true; } } } }
The buffered HttpRouterResult::Response path is unchanged — a handler that
returns it gets a single Content-Length-framed response exactly as before. Streaming is
purely opt-in per reply.
Mixing buffered and streamed routes
"Stream one route, buffer the rest" is one request handler choosing between two
HttpRouterResult variants per request, returning whichever one the request
calls for:
#![allow(unused)] fn main() { use aether_actor::WasmCtx; use aether_http::kinds::{HttpResponseStreamOpen, HttpRouterResult, HttpServerRequest, HttpServerResponse}; #[handler::request] fn on_request(&mut self, _ctx: &mut WasmCtx<'_>, req: HttpServerRequest) -> HttpRouterResult { match req.path.as_str() { "/download" => HttpRouterResult::Stream(HttpResponseStreamOpen { status: 200, headers: Vec::new(), }), _ => HttpRouterResult::Response(HttpServerResponse { status: 404, headers: Vec::new(), body: b"not found".to_vec(), }), } } }
Verify against current code
This recipe names the env keys and kind names live in the source. Before
following it, confirm AETHER_HTTP_SERVER_ENABLED, HttpServerRequest,
HttpServerResponse, HttpRouterResult, HttpServerConfig, the --http-server-* argv flags
(cli_prefix = "http-server" on HttpServerConfig), RegisterRoute /
UnregisterRoute / HttpRouter / HttpMethod, the http::{router, route, FromRequest, Ctx} authoring surface, and http::ResponseStream still exist
where named — grep the crates, and if a name has drifted, fix the recipe as
part of your work.