High-performance HTTP framework for Swift 6.2+, inspired by Rust's axum.
~260K req/s on a 12-core AMD 5600H (loopback, wrk -t12 -c100 -d3s).
import Starlight // re-exports Routing, Core, Extractors, Server, HTTPLens, …
let app = Router(state: NoState())
.get("/") { _ in .plain("Hello, World!") }
.get("/health") { .plain("ok") }
// Ctrl-C / SIGTERM → graceful shutdown out of the box
try await serve(app, on: "0.0.0.0", port: 8080)swift run -c release hello-world
curl http://localhost:8080/- axum-inspired architecture —
Router<S>,Service<Request>,Layer,Handler, extractors - ~260K req/s — thread-per-core,
SO_REUSEPORT, epoll via mio, driven by pulsar - Zero-copy I/O —
ReadBuffer(port ofbytes::BytesMut),writev(2) - Streaming bodies —
Body.stream(AsyncSequence)+ chunked Transfer-Encoding - SSE —
Sse<Stream>structured Server-Sent Events - Graceful shutdown — SIGINT/SIGTERM → 30s drain (see below)
- Extractors — State, Path, Query, Json, Form, Bytes, String, Extension, ConnectInfo, Host, MatchedPath, OriginalUri, Method, Uri, HeaderMap, Request
- Middleware — TraceLayer, TimeoutLayer, CorsLayer, RateLimitLayer, CompressionLayer (gzip),
from_fn - Static files —
ServeDirwith MIME detection + ETag - HandlerService0-6 — up to 6 typed extractors per handler
Starlight is the umbrella crate of a 7-package workspace, each mirroring a piece of the tokio / tower / axum ecosystem:
mio → epoll primitives (Poll, Registry, Token, Events, Waker)
pulsar → tokio::runtime — SerialExecutor + TaskExecutor on epoll
http → http crate (Request, Response, Method, StatusCode, Body, HeaderMap)
http-codec → hyper H1 codec (Conn, Decoder, Encoder, Dispatcher)
http-prism → tower::{Service, Layer, ServiceBuilder, BoxService}
http-lens → tower-http (Compression, Trace, Cors, Timeout, RateLimit, from_fn)
starlight → axum (Router, Handler, Extractors, serve())
| Module / package | Rust analogue |
|---|---|
Starlight |
axum umbrella + serve() |
StarlightCore |
axum-core (Handler, IntoResponse, FromRequest) |
StarlightRouting |
axum::routing (Router |
StarlightExtractors |
axum::extract (State, Path, Query, Json, Form) |
StarlightServer |
hyper::server + tokio::net (TcpListener, serve) |
HTTPPrism (http-prism) |
tower::{Service, Layer} |
HTTPLens (http-lens) |
axum::middleware + tower-http |
HTTP (http) |
http crate |
HTTPCodec (http-codec) |
hyper H1 codec |
Pulsar (pulsar) |
tokio::runtime (epoll reactor) |
MIO (mio) |
mio (epoll primitives) |
CLinuxExt |
libc wrappers (accept4, sched_setaffinity, sigaction) |
| Framework | req/s | Notes |
|---|---|---|
| Starlight | ~260K | release, loopback, 12-core AMD 5600H |
| Hummingbird 2 | ~150K | ~1.7× slower |
| Go net/http | ~200–300K | parity |
| Rust axum | ~400–500K | below (Swift vs Rust runtime overhead) |
Optimizations:
- SWAR byte search (8 bytes/iteration for
\r\n\r\n) - Zero-copy
ReadBuffer(no memcpy betweenread(2)and parse) writev(2)— header + body in one syscall- Reusable
HeaderMap+Extensions— 0 alloc/req after warmup - Per-connection Task (not per-request) — amortized over keep-alive
@inlinablehot paths across module boundaries
Closure handlers receive the raw Request (or no args) — the axum get(|req| …)
shape. Path params land in request.extensions under MatchedPathParams, and
bodies decode via the FromRequest / FromRequestParts extractor protocols:
struct CreateUser: Decodable { let name: String }
let app = Router(state: NoState())
.get("/users/:id") { req -> Response in
let id = req.extensions.get(MatchedPathParams.self)?.params.get("id") ?? "?"
return .plain("user \(id)")
}
.post("/users") { req -> Response in
// Json<T> is FromRequest — reads + decodes the body, rejects 400 on failure
let json = try await Json<CreateUser>.fromRequest(req, state: NoState())
return .plain("creating \(json.value.name)")
}
.get("/ping") { .plain("pong") } // zero-arg closureAvailable extractors: State<S>, Path<T>, Query<T>, Json<T>,
Form<T>, Bytes, String, Extension<T>, ConnectInfo, Host,
MatchedPath, OriginalUri, Method, Uri, HeaderMap, Request.
The HandlerService0…HandlerService6 adapters wire extractors into the
Service dispatch chain (last extractor may consume the body via
FromRequest). Note: their closure-style construction currently requires
explicit generic arguments due to an unused type parameter, so the manual
Request form above is the smoother path today.
Layers are applied directly on the Router (axum-style) — no manual
ServiceBuilder needed. Each *Layer is a concrete struct that becomes a
Layer<Request, Response> via .asLayer():
let app = Router(state: NoState())
.get("/", { _ in .plain("Hello, World!") })
// outermost-first: compression wraps everything below
.layer(CompressionLayer().asLayer()) // gzip (auto by Accept-Encoding)
.layer(TraceLayer(config: .stderr).asLayer()) // request logging
.layer(CorsLayer().asLayer()) // CORS headers
.layer(TimeoutLayer(duration: .seconds(30)).asLayer()) // → 504 on timeout
.layer(RateLimitLayer(limiter: RateLimiter(maxRequests: 100)).asLayer()) // → 429Or write middleware inline with from_fn:
.layer(from_fn { request, next in
print("→ \(request.uri.pathString)")
let response = try await next.run(request)
print("← \(response.status)")
return response
})Use .route_layer(...) instead of .layer(...) to scope middleware to only
the routes added before the call.
let app = Router(state: NoState())
.fallback(BoxService(ServeDir(root: "./public")))ServeDir maps the request path under the root, detects MIME type by extension,
and emits an ETag from the file modification time.
serve(onShutdown:) blocks on the closure you pass; when it returns, the server
stops accepting and drains in-flight requests (up to drainTimeout, default 30s)
before exiting.
By default onShutdown already installs SIGINT/SIGTERM handlers and waits for
Ctrl-C / kill -TERM — no setup needed:
try await serve(app, on: "0.0.0.0", port: 8080) // graceful on Ctrl-COnly if you pass a custom onShutdown, install the handlers yourself —
the default (which does it for you) is replaced entirely:
import Starlight // StarlightServer is re-exported
installShutdownSignalHandlers() // sigaction(SIGINT, SIGTERM) — SA_RESTART
try await serve(
app, on: "0.0.0.0", port: 8080,
drainTimeout: .seconds(30),
onShutdown: { await waitForShutdownSignal() } // resumes on Ctrl-C / kill -TERM
)swift run -c release hello-world 8080
# Ctrl-C → stops accepting → drains in-flight requests → exits cleanly.package(url: "https://github.com/akvilary/starlight.git", branch: "main")The remaining packages are resolved automatically as dependencies:
.product(name: "Starlight", package: "starlight") // umbrella + serve()Linux only — uses epoll via mio.
| Component | Status |
|---|---|
| HTTP/1.1 end-to-end pipeline | ✅ |
| Router (nest, merge, layer, route_layer, withState) | ✅ |
| Extractors + HandlerService0-6 | ✅ |
| Middleware (Trace, Timeout, Cors, RateLimit, Compression, from_fn) | ✅ |
| Streaming bodies + SSE + ServeDir | ✅ |
| Graceful shutdown | ✅ |
| WebSocket | ❌ planned |
| TLS | ❌ planned |
| HTTP/2 | ❌ planned |
MIT