An Autumn app is a native binary at one origin. Some of what it serves does not need to be: a marketing page, a product listing, a public profile — reads whose answer is a function of the request and, at most, a cached value. Those can run at a CDN edge, close to the reader, while the origin keeps serving everything else.
Autumn's answer is an edge capsule: a portable wasm32-wasip1 artifact
built from your existing handlers by the same autumn build, with no second
codebase, no vendor SDK, and no rewrite. The origin binary stays the authority —
it still serves every route, including the edge ones — so anything the capsule
cannot answer falls through to it, and you write no glue for that.
Worked example: examples/edge-greeting.
Design record: ADR-0011.
Experimental (first slice). The
#[edge]macro surface, theautumn-edgeAPI, and the wire protocol are not covered by the stability policy yet. See STABILITY.md.
In one page
// src/handlers.rs — compiles for the host AND for wasm32-wasip1
use autumn_edge::prelude::*;
#[get("/greet/{name}")]
#[edge]
pub async fn greet(Path(name): Path<String>) -> String {
format!("Hello, {name}!")
}
pub fn edge_routes() -> Vec<EdgeRoute> {
edge_routes![greet]
}
// src/bin/edge-capsule.rs — the whole of a capsule's main
fn main() {
autumn_edge::serve(my_app::handlers::edge_routes());
}
// src/main.rs — the origin, unchanged except that it also mounts `greet`
autumn_web::app()
.routes(routes![my_app::handlers::greet, /* … */])
.run()
.await;
autumn build # native binary + target/wasm32-wasip1/release/edge-capsule.wasm
That is the whole opt-in: an attribute, a registration macro, a three-line bin.
The two lanes
┌──────────── edge (wasm32-wasip1) ────────────┐
request → │ capsule: same axum router, same handler code │ → response
└───────────────────┬──────────────────────────┘
│ cannot serve this
▼
┌──────────── origin (native binary) ──────────┐
request → │ the whole app: db, sessions, auth, writes │ → response
└──────────────────────────────────────────────┘
The edge lane is a subset router built from the same axum (and therefore the
same matchit) the origin uses, from the same path patterns. Path matching
parity is true by construction rather than by a second implementation that has
to be kept honest: percent-encoding, %2F inside a segment, trailing slashes
and {param} capture all behave identically because it is literally the same
matcher.
The origin still mounts every edge route. That is what makes fallthrough free: a request the edge declines is forwarded upstream unchanged and lands on a route that was always there.
Writing an edge route
The edge-safe module rule
An #[edge] handler lives in a module that compiles for wasm32-wasip1, where
autumn-web is not in the dependency graph at all. Two rules follow, and
the compiler enforces both:
- Only
#[edge]routes in that module. A plain#[get]emits a native companion that names::autumn_web, and the wasm build stops on it. Put origin-only routes in a different module, gated with#[cfg(not(target_arch = "wasm32"))]. - Import from
autumn_edge.autumn_edge::preludecarries the route macros and exactly the extractors the edge can mediate.
// src/lib.rs
pub mod handlers; // edge-safe: #[edge] GET routes only
#[cfg(not(target_arch = "wasm32"))]
pub mod origin; // anything autumn-web offers
The manifest splits the same way — this is what keeps tokio, hyper and diesel out of the capsule:
[dependencies]
autumn-edge = "0.10"
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
autumn-web = { version = "0.10", features = ["edge"] }
What an edge handler may use
| Available | Not available |
|---|---|
Path, Query, HeaderMap | sessions, auth, CSRF, flash, step-up |
EdgeCache (a mediated KV read) | a database, a repository, writes of any kind |
| any pure rendering, including maud markup you pass through | Clock, Rng, outbound HTTP, the filesystem |
This is enforced by the type system, not by review. An edge handler must be an
axum handler over the unit EdgeState, so an extractor that needs real
application state cannot satisfy the EdgeHandler bound, and the diagnostic
names the fix:
error[E0277]: `fn(Db) -> impl Future {dashboard}` cannot serve as an `#[edge]` handler
= note: edge handlers may use extractors that work for any state (e.g. `Path`,
`Query`, `HeaderMap`, `EdgeCache`) and must not use native-only
extractors (e.g. `Db`, `Session`, `Clock`, `Rng`)
= note: remove `#[edge]` from this route, or replace the offending extractor
What the macro refuses
#[edge] is a marker like #[public]: it injects no runtime guard and does not
rewrite your handler. It refuses, at compile time, every combination the edge
lane could not honour:
| You wrote | Why it is refused |
|---|---|
#[post] / #[put] / #[delete] + #[edge] | the edge lane is read-path only; the origin is the authority for every write |
#[secured] / #[authorize] / #[step_up] / #[throttle] + #[edge] | the capsule has no session, no auth state, and no shared rate counter |
#[intercept(...)] + #[edge] | interceptor layers are origin-only tower middleware; the capsule mounts the bare handler, so the two lanes would serve different bytes |
an Extension<T> parameter + #[edge] | the capsule installs no request extensions (EdgeCache is the one mediated seam); a missing extension would be served as a 500 instead of falling through |
#[static_get] + #[edge] | the route is already pre-rendered and served CDN-side; a capsule adds nothing |
#[ws] / #[oauth2_callback] + #[edge] | neither is a read-path GET |
#[edge(needs(db))] | kv is the only capability the host mediates today |
Attribute order does not matter: #[edge] above or below the route macro
produces the same route.
The one mediated seam: EdgeCache
An edge handler that could only render from its request would be a template engine. The seam that makes the lane useful — and the one that makes one handler source run on two substrates — is a key/value read:
#[get("/note/{key}")]
#[edge(needs(kv))]
pub async fn note(Path(key): Path<String>, cache: EdgeCache) -> String {
match cache.get_string(&key) {
Some(note) => format!("{key}: {note}"),
None => format!("{key}: (not cached at this replica)"),
}
}
EdgeCache arrives through a request extension, so it works for any state type:
| Substrate | What is behind it | Installed by |
|---|---|---|
| Origin | CacheEdgeKv over the app's own Cache | AppBuilder::with_edge_kv(...) (feature edge) |
| Edge | a dialogue-backed reader — each get is a round trip to the host | the capsule runtime, when the host provides kv |
// src/main.rs
autumn_web::app()
.routes(routes![/* … */])
.with_edge_kv(Arc::new(CacheEdgeKv::new(cache)) as Arc<dyn EdgeKv>)
.run()
.await;
EdgeKv is not a database
It is a replica-local, opportunistic read accelerator in the sense of
ADR-0004 category 2, and
the trait's shape says so — one method, get, returning bytes:
- Reads only. There is no
put. The origin owns every write. - A miss is always legal.
Noneis a normal answer, not an error. A correct handler renders something sensible on a miss, because a different edge replica may not have the key yet. - Staleness is expected. No coherence protocol, no invalidation broadcast, no read-your-writes across replicas.
- Never authoritative. Anything a user's money, permissions or safety depends on is a read the origin must serve.
The origin publishes to the seam by doing what it already does: CacheEdgeKv
reads through the same insert_cached / get_cached serde path the rest of the
framework writes through, so caching bytes under a key is publishing them.
The capability check runs before dispatch
#[edge(needs(kv))] travels with the route and is checked against the host's
provided_capabilities before the handler runs. A route whose seam this host
cannot mediate never executes a single line of handler code — it falls through
instead. Half-executing a handler and then discovering it needs a KV is how you
get a duplicated side effect.
Failure is a fallthrough
Every way the edge can fail to answer lands in one channel, and the host forwards the original request upstream:
| Reason | When | What the origin does |
|---|---|---|
unknown_route | no edge route matches the path | serves it, or 404s — its answer either way |
method_not_edge_eligible | anything but GET/HEAD | runs the real handler |
missing_capability | the route needs a seam this host did not provide | serves it, because the origin has the seam |
capsule_error | a trap (a panic), a malformed frame, an unsupported wire version | its normal error handling — a panicking handler becomes the origin's 500 page, not a broken edge response |
None of these require author-written glue. autumn edge serve (below) is a
host that does the forwarding. A handler can also decline
explicitly by setting the x-autumn-edge-fallthrough response header to one of
those reasons; the runtime converts it and never lets the header — or that
response's body — reach the wire.
Byte-identity: what is actually guaranteed
The claim. For a request the edge serves, the capsule and the origin binary of the same build produce the same status, the same body bytes, and the same headers after projection.
Taken literally, "identical headers" is unachievable and, worse, untestable: the
origin stamps a Date, a request id and server-timing spans onto every
response, none of which the edge has any business inventing. So the guarantee is
stated precisely, in one constant both the tests and this page read from —
autumn_edge::conformance::VOLATILE_HEADERS:
content-security-policy · date · server-timing · set-cookie · x-request-id
Those are dropped from both sides before comparison, names are lowercased and canonically sorted, and everything else must match exactly. A header your handler set is compared value-for-value — the projection only excuses the headers the origin's middleware stack adds and the edge lane structurally cannot emit.
The origin's security middleware also sets static headers. The capsule does
not. The host sets them — conformance::SECURITY_HEADERS, and the CSP when
the CSP nonce is off:
permissions-policy · referrer-policy · strict-transport-security
x-content-type-options · x-frame-options · x-xss-protection
A CORS layer can also set headers. When the origin's CORS policy is the same
for every route, the host can set them too (autumn edge serve --response-header) — conformance::CORS_HEADERS:
access-control-allow-credentials · access-control-allow-origin
access-control-expose-headers
So there are two comparisons:
| Comparison | Excused | Function |
|---|---|---|
| raw capsule vs origin | volatile, security and CORS headers | compare_capsule |
| what the client gets from the host vs origin | volatile headers only | compare |
Both check headers in both directions. A header only one side sends is a
divergence. compare excuses the CSP, because a nonce changes it on each
request. With the nonce off, the conformance suite compares the CSP too.
The guarantee is scoped to one build. Two Autumn versions may render the same handler differently (that is what a release is for); the promise is that your origin and your capsule, built together, agree.
Determinism is your half of the contract
Byte-identity only holds if the handler is a function of its request. The
framework removes the usual sources of drift — the Clock and Rng extractors
do not exist at the edge, and the reference host pins the WASI clock to zero
and seeds random_get deterministically — but a real edge host makes no such
promise, and a handler can still be non-deterministic:
HashMap/HashSetiteration order. Rendering a map's entries in iteration order produces different bytes on different runs and different targets. UseBTreeMap, or sort before rendering. (Query<Vec<(String, String)>>rather thanQuery<HashMap<..>>also preserves repeated keys.)- Floats. Formatting is deterministic in Rust and identical on both targets; accumulating floats in a different order is not. Fix the order.
usize. Both targets in this slice are 32-bit-vs-64-bit different (wasm32has 32-bit pointers). Rendering ausizeis fine; relying onusize::MAX, on a hash of a pointer, or onsize_of::<usize>()is not.- Anything address-derived. Pointer values,
{:p}, and defaultHashseeds differ per process. - Ambient time and randomness.
SystemTime::now(),getrandom, and anything built on them (Utc::now(),Uuid::new_v4()) compile at the edge — WASI provides both syscalls — and return whatever the host decides. The reference host pins them; a CDN host will not. Keep them out of#[edge]handlers.
The conformance suite runs each side twice and compares it with itself before comparing lanes, so a handler that manages to be non-deterministic is reported as that, not as a divergence.
Typed path helpers
The path helper of an #[edge] route compiles for wasm32-wasip1. It uses
autumn_edge::paths, which encodes the same bytes as autumn_web::paths.
Collect the helpers with autumn_edge::paths![]:
use autumn_edge::paths::PathExt as _;
#[get("/greet/{name}")]
#[edge]
pub async fn greet(Path(name): Path<String>) -> String {
format!("Hello, {name}!")
}
#[get("/link/{name}")]
#[edge]
pub async fn link(Path(name): Path<String>) -> String {
paths::greet(&name).with_query("from", "link")
}
autumn_edge::paths![greet, link];
The helper of a plain route still calls autumn_web. Do not list it in an
edge-safe module's paths![].
autumn_edge::paths::PathExt and autumn_web::paths::PathExt are two
traits. Do not import both in one module, or .with_query is ambiguous.
The wire protocol (version 1)
Autumn ships an artifact and a protocol, not a vendor binding. The host and the
capsule talk NDJSON over the capsule's stdio: one JSON object per line,
\n-terminated, op-tagged. A CDN shim in any language can implement it from
this section alone.
host → guest {"op":"request","wire_version":1,"provided_capabilities":["kv"],
"method":"GET","uri":"/x?y=z","headers":[["accept","text/html"]],"body_b64":""}
guest → host {"op":"kv_get","key":"greeting"}
host → guest {"op":"kv_value","value_b64":"aGk="}
guest → host {"op":"response","status":200,"headers":[["content-type","text/plain"]],"body_b64":"aGk="}
guest → host {"op":"fallthrough","reason":"unknown_route","detail":"no edge route matches /nope"}
The dialogue is request-scoped and half-duplex. The guest reads one request
frame, may interleave any number of kv_get/kv_value exchanges, and closes
the exchange with exactly one response or one fallthrough. The loop
repeats until stdin reaches EOF, so one process serves many requests.
- Header names cross the wire lowercased and canonically sorted, insertion order preserved within a repeated name.
- Bodies are base64 so a binary response survives a line-oriented protocol.
wire_versionmismatch is acapsule_errorfallthrough. A host and an artifact from different Autumn versions degrade to origin-serving rather than guessing.- Anything the capsule writes after its terminal frame is ignored.
Two host implementations you can read top to bottom: autumn-edge/src/host.rs
(Rust, wasmi) and autumn-edge/js/autumn-edge-host.mjs (JavaScript,
web-standard). The conformance suite runs both and requires the same outcome.
Security posture
- These credentials never reach a capsule:
cookie,authorizationandproxy-authorization. They are stripped by the host before the request frame is sent, and stripped again by the guest on receipt — a defensive double-strip, because a capsule cannot audit the host it runs under. - No session, no auth, no CSRF, no database. Not "discouraged": absent from
the dependency graph. A route that needs any of them cannot compile as an
#[edge]route. - No ambient authority. A capsule imports only what the dialogue needs. The
reference host provides
fd_read/fd_write(stdio),environ_*,random_get,proc_exitand a few inert calls — and nopath_open, no resolvingfd_prestat_*, and no socket import. There is no filesystem and no network; the only way out is the dialogue. The conformance suite asserts the artifact's import list against that allowlist, so it stays true. - Bounded execution. The reference host meters every request with a wasmi
fuel budget (
autumn_edge::host::FUEL_BUDGET, ~10⁹ instructions; host work forfd_write, includingkv_getanswers, also costs fuel): a capsule stuck in a loop becomes acapsule_errorfallthrough — the origin serves the request — instead of a hung host. Unread host answers are capped at 64 MiB (STDIN_BUDGET_BYTES). More ends the request as acapsule_error. Any production shim should impose its own wall-clock or instruction limit the same way. - Fallthrough details never leak. A declining response's body is a message for a developer; it is not forwarded, and neither is the sentinel header.
- No cookies from the edge. A handler response carrying
set-cookieis declined at the wire (acapsule_errorfallthrough): cookies are session state, sessions are origin-only, and a cached edge response with a cookie would smear one client's state across everyone behind the CDN. - The artifact is a program, not an asset.
autumn buildnever copies the.wasmintodist/orstatic/, and you should not either: publishing it as a static asset would serve your capsule to browsers instead of running it at the edge.
The artifact does embed panic-location strings from its dependencies, which
include the build machine's Cargo paths. If a hermetic artifact matters to you,
build with --config 'profile.release.trim-paths="all"'.
Building and shipping
autumn build
autumn build # release: native binary, then the capsule
autumn build --edge # force the capsule step in a debug build
autumn build --embed # embedded binary, then the capsule
The edge step runs when the project has #[edge] routes and the build is a
release build (or --edge was passed). It is a second
cargo build --target wasm32-wasip1 --release --bin edge-capsule and it prints
what it produced:
🍂 Edge capsule: 2 route(s) (greet, note) → target/wasm32-wasip1/release/edge-capsule.wasm (312 KB)
Detection is a source scan, not a compile: the CLI looks for #[edge]
attributes and edge_routes![] invocations under src/. A handler that is
marked but never registered is reported as a warning naming the function — the
one failure mode the type system cannot catch.
Before it compiles, autumn build names each #[edge] route that needs
something the edge cannot provide. The text is the same as doctor's
edge_capabilities check. The compiler then stops on the route:
⚠ 1 #[edge] route(s) need what the edge cannot provide. The compiler stops on each one:
dashboard @ src/routes.rs:4: needs(db) is not an edge capability; takes `Db`, which needs a database
Remove #[edge] from the route, or use only what the edge provides: GET, needs(kv) or needs(identity), ...
An app with #[edge] routes and no static routes builds without an error. The
capsule is the build's output. Thus the static renderer accepts an empty route
set.
autumn build --embed also builds the capsule, after the embedded binary.
The capsule build gets the app's embed-assets feature too, so a route gated
on that feature is in both lanes. The app must declare that feature. Keep
native-only dependencies out of it, or the capsule build fails. The capsule
does not compile autumn-web, so autumn-web/embed-assets does nothing there.
A build that makes a capsule checks for the wasm32-wasip1 target before any
compile. If the target is missing, the build stops with the rustup hint. A
Docker builder image for an app with #[edge] routes needs
rustup target add wasm32-wasip1 before autumn build --embed.
autumn doctor
| Check | Result | When |
|---|---|---|
edge_target | Fail | the project has #[edge] routes and wasm32-wasip1 is not installed — hinting Run `rustup target add wasm32-wasip1` |
edge_routes | Fail | an #[edge] handler also carries an auth/rate guard or #[intercept] (the build would fail too; doctor catches it first) |
edge_routes | Warn | a handler is marked but never registered with edge_routes![], or src/bin/edge-capsule.rs is missing |
edge_capabilities | Warn | an #[edge] route needs what the edge cannot provide: an unknown needs(...), a write method (#[post], …), a route kind the edge refuses (#[static_get], #[ws], …), an origin-only extractor (Db, Session, Clock, Extension, …), or EdgeIdentity without needs(identity) |
edge_routes and edge_capabilities report handler @ file:line for the
handler at fault; edge_target names the files that carry edge routes. All
three pass with "no #[edge] routes" on a project that has none.
edge_capabilities is a warning, not a failure. The scan is a best guess: it
reads names, not types, it reads a file that no mod declares, and it does
not resolve target cfgs. The compiler is the authority and stops every real
case (see "What an edge handler may use"), so autumn build fails on it.
autumn doctor --strict exits non-zero on the warning.
Deploying
What you deploy is the artifact and a host for it. The host loads the
.wasm, speaks the NDJSON dialogue on stdio, answers kv_get from its own
replica, and forwards any fallthrough to your origin unchanged. Autumn ships
two hosts you can deploy, and you write neither:
| Host | Where it runs | How |
|---|---|---|
| The JS edge host | a CDN: Cloudflare Workers, and any runtime with WebAssembly and the Fetch API | autumn edge bundle (below) |
| The edge node | a server you run near your users | autumn edge serve (below) |
Neither is a vendor binding (see ADR-0024): the JS host uses only web-standard APIs, so no Autumn release waits on a CDN's SDK.
Cloudflare Workers: autumn edge bundle
autumn build
autumn edge bundle --target cloudflare --origin https://origin.example.com
cd edge/cloudflare && npx wrangler deploy
🍂 Edge bundle (cloudflare) in edge/cloudflare — capsule 312 KB
wrote edge-capsule.wasm
wrote autumn-edge-host.mjs
wrote worker.mjs
wrote .gitignore
wrote wrangler.toml (yours to edit; later bundles keep it)
| File | What it is | Rewritten by each bundle |
|---|---|---|
edge-capsule.wasm | the capsule autumn build made | yes |
autumn-edge-host.mjs | the JS edge host | yes |
worker.mjs | the Worker entry: passes env to the host | yes |
wrangler.toml | the Worker config: name, ORIGIN_URL, KV binding | no — written once, then yours |
Commit wrangler.toml. The generated .gitignore keeps the other three out of
git.
Before it writes anything, the bundle checks the capsule's imports against what the host provides and names any import it cannot link. Flags:
--origin URL: the origin, written toORIGIN_URLin a newwrangler.toml. A keptwrangler.tomlkeeps its own value.--response-header 'NAME: VALUE': set a header on every edge response, for example a CORS policy that is the same for every route. Repeat it.--probe-path PATH/--no-probe: the Worker copies the origin's static security headers (and a CSP without a nonce) from twoGETs of this path, once per isolate, asautumn edge servedoes.--name NAME: the Worker name for a newwrangler.toml. Defaults to<package>-edge.--out DIR: defaults toedge/cloudflare.
The kv capability is a KV namespace bound as EDGE_KV. Without
the binding a needs(kv) route falls through to the origin.
npx wrangler kv namespace create EDGE_KV # put the id in wrangler.toml
The origin publishes into the namespace; the capsule only reads.
The JS edge host
autumn-edge-host.mjs is one ES module with no dependencies. It is
autumn_edge::host and autumn_edge::gateway written for WebAssembly and
the Fetch API: the same WASI imports, the same dialogue, the same fixed clock
and entropy, the same checks on a capsule's answer, the same forwarding rules.
The edge-greeting suite's Tier J runs it and the reference host over the
corpus and 10,000 generated requests and requires the same outcome for each.
CI also runs the generated bundle in workerd, Cloudflare's runtime.
Use it directly when the Worker does more than serve the capsule, for example a cron trigger or routes of its own. Pass a function as the origin, and your own routes become the fallthrough target:
import capsule from "./edge-capsule.wasm";
import { createEdgeGateway } from "./autumn-edge-host.mjs";
let gateway = null;
export default {
async fetch(request, env) {
gateway ??= createEdgeGateway({
capsule,
origin: (request) => myOwnRoutes(request, env), // or a URL string
kv: env.STATUS_KV, // a Workers KV binding
clientIp: (r) => r.headers.get("cf-connecting-ip"),
});
return gateway.fetch(request);
},
async scheduled(event, env, ctx) { /* … */ },
};
| Option | Meaning |
|---|---|
capsule | a WebAssembly.Module (a Workers .wasm import), bytes, or a promise of either |
origin | a base URL, or (request) => Response for an origin in the same Worker |
kv | (key) => value, a Map, or a Workers KV binding; gives kv |
preload | KV keys to read before the first run |
identity | (request) => { user_id, roles } | null, after you verify the request; a throw goes to the origin |
responseHeaders | [[name, value]] set on every edge response |
probeOrigin, probePath, probeTimeoutMs | copy the origin's static security headers (URL origin only). Each probe GET may take 5 s by default; after a failed probe, edge responses go out without them for 30 s, then the gateway probes again |
clientIp | (request) => ip for x-forwarded-for |
onLane | ({ lane, reason, request, response }) => void, for a log or a counter |
gateway.handle(request) returns { response, lane, reason }, where lane is
edge, fallthrough, origin_only or refused. gateway.fetch(request)
returns the response only.
KV reads are asynchronous; the capsule's are not. The host answers
kv_get from a per-request snapshot. When the capsule asks for a key the
snapshot does not have, the host stops the instance, reads the key, and runs
the request again. Because the host fixes the clock and the entropy, the run
replays exactly. A request that reads N distinct keys costs N + 1 runs, and
more than 32 keys falls through. preload reads known keys before the first
run. A failed read is a miss.
What the Fetch API makes different from the Rust gateway:
- No instruction budget. JavaScript cannot meter a WebAssembly call. The platform's CPU limit (on Workers, the per-request CPU time) stops a runaway capsule.
- Memory is checked after instantiation. A capsule whose memory is over the
256 MiB budget falls through, but the host cannot refuse each
memory.grow; the platform's memory limit applies. - Compression outside Workers.
fetchdecompresses a compressed origin answer. Workers compresses it again; on Node, Deno and Bun the gateway sends it uncompressed, withoutcontent-encodingandcontent-length. - WebSocket upgrades to a URL origin need Workers. Only Workers'
fetchforwards an upgrade handshake. Elsewhere the gateway answers such a request with501; use a function origin with the runtime's own upgrade API, orautumn edge serve, which tunnels upgrades. - Repeated request headers are joined.
Headersjoins twox-taglines into onea, bvalue before the capsule sees them. A header value with a non-ASCII character goes to the origin only.
The reference gateway
autumn_edge::gateway::EdgeGateway (feature host) is the reference host's
specification as code. It puts a capsule in front of any origin tower::Service, for example
your app's axum::Router:
use std::sync::Arc;
use autumn_edge::gateway::{EdgeGateway, Lane};
use autumn_edge::host::EdgeArtifact;
let artifact = Arc::new(EdgeArtifact::from_bytes(&std::fs::read(wasm_path)?)?);
let gateway = EdgeGateway::new(artifact, origin_router)
.with_kv(kv) // provide `kv`
.with_response_headers(security_headers); // the origin's static security headers
let response = gateway.handle(request).await;
let lane = response.extensions().get::<Lane>(); // Edge, Fallthrough(reason), OriginOnly
What it does:
- It offers each
GET/HEADrequest to the capsule. The capsule gets no body and nocookie,authorizationorproxy-authorizationheader. - When the capsule serves the request, it returns those bytes and does not ask the origin.
- When the capsule declines, it sends the original request (body and credentials included) to the origin and returns the origin's response unchanged. The fallthrough detail does not reach the client.
- It sends a write, an
upgradehandshake (WebSocket), or a request with a header value that is not UTF-8, to the origin. It does not ask the capsule. - It does not trust the capsule. A status outside 200-599,
set-cookie, the fallthrough header, a hop-by-hop header, a body on 204/205/304, or acontent-lengththat does not match the body is acapsule_errorfallthrough. - It attaches no identity. A
needs(identity)route falls through withmissing_capability. - It records the lane in the response extensions, not in a header, so the bytes stay the origin's.
The capsule runs on the calling task, before handle returns. Thus a
tower::timeout layer around the gateway cannot stop it. Only the fuel budget
does. A production shim runs the capsule off the request thread and sets a
time limit.
The edge node: autumn edge serve
The edge node is an HTTP server. It puts the capsule in front of your origin. Run it on a server near your users. Keep the origin where it is.
autumn build
autumn edge serve --origin https://origin.example.com --listen 0.0.0.0:8787
🍂 Edge node on http://0.0.0.0:8787 → origin https://origin.example.com (capsule target/wasm32-wasip1/release/edge-capsule.wasm, 312 KB; kv: off; 6 response header(s))
GET /greet/ada 200 edge 3.1ms
POST /feedback 200 origin (method_not_edge_eligible) 151.4ms
For each request, the node does these steps:
- It gives a
GETorHEADto the capsule. - When the capsule serves it, the node sends that response.
- When the capsule declines, the node sends the original request to the origin over HTTP. It sends the origin's response without change.
- It writes one line: method, path (no query), status, lane, time.
| Option | Default | Use |
|---|---|---|
--capsule | target/wasm32-wasip1/release/edge-capsule.wasm | the artifact from autumn build |
--origin | (required) | the origin base URL, with no user name or password; a path prefixes each forwarded path |
--listen | 127.0.0.1:8787 | the address to listen on |
--kv | off | a JSON file with one object of string values; it gives the kv capability |
--probe-path | / | the origin path for the header requests at start |
--no-probe | off | do not send the header requests |
--response-header | none | add name: value to each edge response; repeat it |
--trusted-proxy | none | an address or CIDR range whose x-forwarded-* headers the node keeps; repeat it |
--quiet | off | no line for each request |
Rules:
-
At start, the node sends
GET <probe-path>to the origin two times. It copies each security and CSP header that has the same value in both responses. It sets these headers on each edge response. Autumn's security middleware sets them on every response, so one path shows them for all. It does not copy a CSP nonce. -
It does not copy CORS headers: they can differ per route. When the origin's CORS policy is the same for every route, set it with
--response-header, for example--response-header 'access-control-allow-origin: *'. -
If the origin does not answer at start, the node does not start. Use
--no-probeto start without the header requests. -
It does not follow redirects. It does not use
HTTP_PROXYorHTTPS_PROXY. -
It removes hop-by-hop headers in both directions, also the ones a client names in
connection, before the capsule gets the request. -
Both lanes see the origin's
host. The public host is inx-forwarded-host. -
It sends an
upgraderequest (WebSocket) to the origin, never to the capsule. When the origin answers101, the node copies bytes both ways until one side closes. -
It sets the forwarded headers before the capsule and the origin get the request, so both lanes see the same values. It removes
forwarded.- From a
--trusted-proxypeer, it keepsx-forwarded-host, keeps anx-forwarded-protoofhttpandhttpstokens (for examplehttps, http), and appends the peer tox-forwarded-for. - From any other peer, it is the first proxy:
x-forwarded-foris the peer,x-forwarded-hostis the requesthost, andx-forwarded-protoishttp. It removesx-real-ip.
- From a
-
Configure the origin to trust every proxy hop between it and the client: the node, and each
--trusted-proxyin front of the node. The origin readsx-forwarded-forfrom the right and stops at the first address it does not trust:TOML# autumn.toml on the ORIGIN [security.trusted_proxies] ranges = ["10.0.1.5/32", # the edge node "10.0.2.0/24"] # the TLS terminator in front of itIf you trust only the node, the origin sees the terminator as the client.
trusted_hops(the number of proxies) is the other option. -
It refuses a path with a
.or..segment (also%2e) or a\with a400. An HTTP client resolves these segments, so the origin would get a different path. -
It streams request and response bodies to and from the origin.
-
It runs each capsule on a blocking thread. At most one capsule for each CPU runs at the same time. Other
GETs wait. Writes and upgrades do not wait. -
An origin that does not connect in 10 s, or stops sending for 60 s, gives a
502(or a cut body). -
Without
--kv, aneeds(kv)route goes to the origin. -
It stops on Ctrl-C or SIGTERM. Open requests then have 10 s to finish.
The node serves plain HTTP. Put a TLS terminator or a load balancer in
front of it, and give its address with --trusted-proxy. When the origin
also trusts that address (see above), it sees the client's scheme and
address. That server also limits slow clients
and connection counts.
In Rust, the same node is autumn_edge::node::EdgeNode (feature node).
Measuring TTFB: autumn edge ttfb
The success metric: for a distant client, the median TTFB at the edge node is at least 50% lower than at the origin. No pair diverges. Run the probe from a distant client:
autumn edge ttfb --edge https://edge.example.com --origin https://origin.example.com \
--path /greet/ada --path /note/greeting --rounds 100
TTFB: 200 request(s) per side
median p90
edge 4.0 ms 5.6 ms
origin 153.8 ms 155.0 ms
reduction 97.4% (minimum 50.0%)
divergences 0
✓ pass
The probe sends the same GETs to the edge node and to the origin. For
each path, it changes which side goes first on each round. On each origin
request it sets x-forwarded-host and x-forwarded-proto to the edge
node's host and scheme, as the node does. It cannot set the same
x-forwarded-for: only the node knows the address it sees. It compares
each pair with conformance::compare, without the hop-by-hop headers. One
divergence fails the run.
Give paths that the capsule serves. Three cases diverge by design:
- A
needs(kv)route, when the node's--kvvalue is not the origin's. Edge KV is a replica. Its values can be old. - A response that shows request headers, for example a debug error page.
The origin gets
x-forwarded-*from the node. - A response with an absolute URL (
location, a canonical link), when the origin trustsx-forwarded-host.
The probe sends no Origin header. Thus it does not check a CORS policy
that changes for each request Origin.
| Exit code | Meaning |
|---|---|
| 0 | zero divergence, and the reduction is at least --min-reduction (default 50) |
| 1 | a pair diverged, or the reduction is too small |
| 2 | the probe could not run (bad URL, a side does not answer) |
--divergence-only checks the bytes and not the reduction. Use it when the
client is near the origin.
Proving it, in your own app
The edge-greeting example ships the harness this framework uses on itself, and
it is copyable. It drives one request corpus through four lanes — the native
edge lane, a real wasm artifact, the full origin app, and the gateway — and
compares them:
cargo test -p edge-greeting --test conformance -- --ignored --test-threads=1 --nocapture
Tier A — native edge lane vs wasm capsule
happy path served 200 (11 bytes)
kv hit served 200 (53 bytes)
percent-encoded slash inside a segment served 200 (15 bytes)
duplicate query keys served 200 (49 bytes)
trailing slash Fallthrough(UnknownRoute)
write method Fallthrough(MethodNotEdgeEligible)
kv route without the kv capability Fallthrough(MissingCapability)
panicking handler trap → Fallthrough(CapsuleError)
Tier B — origin app vs wasm capsule
happy path both 200 (11 bytes)
kv hit both 200 (53 bytes)
write method edge method_not_edge_eligible → origin 200
panicking handler edge capsule_error → origin 500
Tier C — gateway (capsule + origin) vs the lanes behind it
happy path Edge
write method Fallthrough(MethodNotEdgeEligible)
panicking handler Fallthrough(CapsuleError)
Tier D — 10000 generated requests (seed 0x1790000000000001) in 41.1s
Edge 5903
Fallthrough(MethodNotEdgeEligible) 1968
Fallthrough(MissingCapability) 336
Fallthrough(UnknownRoute) 1793
Tier C puts EdgeGateway in front of the origin. For a served request, the
client must get the origin's bytes. For a declined request, the gateway must
return the origin's response unchanged.
Tier D sends 10,000 seeded requests through Tiers A to C. The requests include
unicode and invalid percent-encoding, repeated query keys, HEAD, writes,
credentials, and kv on and off. No request may diverge.
Tier E — edge node vs origin over HTTP (origin RTT simulated: 150ms)
edge median 3.9ms p90 5.5ms (60 requests)
origin median 153.7ms p90 154.4ms (60 requests)
reduction 97.5%, divergences 0
POST /feedback origin 200
GET /no/such/route origin 404
Tier E runs the edge node over real HTTP. The origin is the full app. It
waits 150 ms before each response. The delay simulates the round trip from
a distant client, because CI cannot place a distant client. The median TTFB
at the edge node must be at least 50% lower, with zero divergence. The
access log must show the edge lane for each probed request. A fallthrough
over HTTP must return the origin's response. For the real metric, run
autumn edge ttfb from a distant client.
CI runs it on every push in the edge-conformance job. The same job runs the
real autumn build --debug --edge on the example and checks that the .wasm
exists. Then it starts the origin and autumn edge serve, runs
autumn edge ttfb --divergence-only against them, and checks that the
access log shows the edge lane for each probed path. It is not path-filtered:
byte-identity is a property of the whole framework, and a change to the router,
a middleware, a macro or a dependency is exactly what could break it.
Limitations of the first slice
GET/HEADonly. Writes stay at the origin, by design.- One capability:
kv. No database, no outbound HTTP, no storage. - No compression, no i18n locale prefix, no sessions in the edge lane. The origin's middleware stack does not run there — the capsule serves exactly what the handler produced.
- One bundle target: Cloudflare. The JS host runs on Deno, Bun, Vercel
and Netlify edge functions too;
autumn edge bundlewrites the entry and config for Workers only. Use the host directly elsewhere. - CI simulates the distance. Tier E proves the speed-up against a
simulated origin round trip. For your deployment, run
autumn edge ttfbfrom a distant client. - One capsule per node. The node does not reload the capsule. Restart it
after
autumn build. - No
OPTIONS *. The node answers the asterisk form with501. Its HTTP client cannot send it, andOPTIONS /is a different request. - The node adds
accept: */*to a forwarded request that has noacceptheader. The HTTP client does this. - The wire protocol and the host API are experimental. They will change; the version field is there so a mismatch degrades safely instead of guessing.
See also
examples/edge-greeting— the worked example and its conformance suite- ADR-0011 — why a separate crate, and what was rejected
- ADR-0024 — the JS edge host and
autumn edge bundle - ADR-0004 — the
category-2 framing
EdgeKvinherits - Conditional GET — and
#[static_get], for pages that can be pre-rendered outright, which is cheaper than any capsule
Authenticated edge routes
Identity is resolved on the host before capsule execution. Install a custom
EdgeIdentityProvider with AppBuilder::with_edge_identity_provider; return
Ok(Some(EdgeIdentity)) only after authoritative verification, Ok(None) for
missing/invalid authentication, and a typed error for store or network failure.
Only normalized user and role claims cross the wire. Cookies, session ids and
maps, signing secrets, and backend credentials remain host-only.
A handler that takes EdgeIdentity must declare it — #[edge(needs(identity))]
— or the build fails. The declaration travels in the route's needs like
kv, and the capsule checks it before dispatch: a request whose frame carries
no identity falls through to origin with missing_capability, and not one
extractor or line of handler code runs. Unlike kv, identity is per request —
it counts as provided exactly when the host attached verified claims, whatever
the host lists in provided_capabilities. Identity infrastructure errors also
fall through to origin. See ADR-0005.
#[get("/me")]
#[edge(needs(identity))]
pub async fn whoami(identity: EdgeIdentity) -> String {
identity.user_id().as_str().to_owned()
}