crocket is a C++ web framework in the spirit of Rocket for Rust. Handlers are ordinary functions, typed extractors are their arguments, and the return value is the response. Fairings and managed state belong to the application. It is not a port of Rocket.
This page states what crocket is for, what it will not do, and the rules that decide whether a feature belongs. The README shows how to use it, and ENGINE.md explains how the wire engine works.
- A handler is a function.
{id}in the path is a parameter namedid. Guards such asJson<T>,AuthandState<T>are more parameters. The return type is the HTTP response. - Route metadata lives on the function (
[[= http::get("/users/{id}")]]). You mount a namespace or a controller class, not a handwritten list of functions. - Cross-cutting behavior belongs to the application.
attachadds fairings andmanageadds typed singletons. CORS, logging, metrics and pools are not repeated on every handler. - Production behavior is built in, not left to each application: request ids, structured errors, logs and metrics by route template, deadlines, readiness, limits, ignite checks, and draining on shutdown.
- One wire engine: h2o, for HTTP/1.1, HTTP/2 and TLS. Handlers never include h2o.
- An ORM, or a plugin ABI.
- Reloading application code without a restart. (Reloading the TLS certificate is supported; that is configuration, not code.)
- Discovering routes by scanning a whole translation unit.
- Reflection, string lookups or type erasure per argument on the request path.
- A second wire engine, or a second programming model for async code.
WebSockets, server-sent events and streamed bodies are not built yet. When they are, they will be engine features behind the same handler model, not a separate API.
These decide what goes in, and how:
- The signature is the configuration. Which state a route needs, whether it reads the body, how its errors are counted and how it is described in OpenAPI all come from its parameter and return types. Nothing is registered twice.
- Mistakes fail as early as possible: at compile time when the types say so (a capture without a matching parameter, an unknown extractor), at ignite when only the assembled application can tell (unmanaged state, conflicting routes, a bad certificate), and never on the first request.
- Expensive features are opt-in.
Logger,Metrics,OpenApiandCorscost nothing until attached. What is on by default must be cheap and safe:Shield's security headers, request ids, and ETags for bodies up toConfig::etag_max_bytes. - Every resource a client can make the server hold has a bound: connections, HTTP/2 streams, header and body sizes, bodies held in memory, requests in flight, and time. The defaults follow h2o and Rocket where they have one (see the limits).
- Dead work is dropped. A request whose deadline has passed, or whose client has gone, does not run its handler. A handler that ignores its deadline is visible in metrics and logs.
- Tests see what production sees.
LocalClientnormalizes and routes paths exactly as the server does, so an in-process test cannot pass on an input the server treats differently. - Untrusted input is fuzzed. Every parser that reads bytes from the network has a fuzz target (see Fuzzing).
- Performance is measured, not assumed.
./dev benchcompares crocket with Rocket and with h2o alone; a change that costs CPU per request has to say why.
namespace api {
[[= http::get("/hello/{name}/{age}")]]
auto hello(std::string_view name, std::uint8_t age) -> std::string {
return std::format("Hello, {} year old named {}!", age, name);
}
[[= http::post("/users")]]
auto create(Json<NewUser> body, Auth auth, State<Db> db) -> Result<Created<User>>;
[[= http::get("/me")]]
auto me(Auth auth) -> Json<User>;
auto helper(int) -> int; // no route annotation: not mounted
} // namespace apiLaunch:
int main() {
return crocket::build()
.manage(DbPool::connect(cfg))
.attach(Logger{})
.attach(Cors::allow_origins({"https://app.example.com"}))
.attach(Metrics{})
.mount("/api", reflect_routes<^^api>())
.launch({.port = 443, .tls_cert = "fullchain.pem", .tls_key = "privkey.pem"});
}Rules:
{name}binds to the parameter namedname. A name or type mismatch is a compile error that names both identifiers.- Parameters not in the path are extractors. An unknown type does not compile.
- The return type is a
Responder:std::string,Json<T>, status wrappers (Created<T>,NoContent),std::optional<T>(empty is 404),Result<T>(std::expected<T, ApiError>),Cacheable<T>, or an awaitable of one (Task<T>). - Handlers are not templates: reflection needs concrete parameter types.
- One route annotation per function. Two verbs are two functions.
- Routes are discovered from a named namespace or class passed to
reflect_routes<^^…>(). Unannotated functions in it are ignored. const Request&andResponse¶meters exist for edge cases. They are not the documented happy path.- A path is split on
/before it is decoded, so a capture can hold any character, and dot segments are removed before routing.
- Fairing hooks are
on_ignite,on_request,on_responseandon_shutdown. on_requestmay answer the request itself and stop the chain.on_responseruns in reverse attach order.- Fairings are short and synchronous (logging, CORS, metrics). They do not parse request bodies.
manage(T)stores oneT, andState<T>retrieves it. A mounted route that needsState<T>when noTis managed fails at ignite.Shield(security headers) is attached by default.Shield::none()turns it off.- CORS is a fairing, not a per-route annotation. Without
Cors, no cross-origin headers are sent, so browsers refuse cross-origin calls.
struct ApiError {
int status;
std::string_view code; // stable and dotted: "auth.expired"
std::string message; // safe for clients
std::string detail; // logs only (shown in error bodies in the dev profile)
std::vector<FieldError> errors; // per-field failures, e.g. from validation
};- Error bodies are RFC 9457 problem details (
application/problem+json):title,status,detail(the message),code,request_id, anderrorswhen present. - Extractor failures have their own codes:
path.invalid(404),query.invalid(422),header.missingandheader.invalid(400),json.unsupported_media_type(415),json.invalid,json.validation,json.unknown_fieldand others (422), andauth.missing,auth.invalidor the verifier's own code such asauth.expired(401). - An uncaught exception is 500
internal. Itswhat()never reaches the client.
What every crocket application gets without writing it:
- A request id, from a valid
X-Request-Idor generated, on every response, log line and error body. - One completion log line per request (with
Logger): id, method, route template, status, code, duration and sizes. - Metrics by route template (with
Metrics): counts, durations, in-flight requests, extractor failures, workers, connections, and requests dropped unrun. GET /healthzfor liveness, andGET /readyzdriven by managed state such as a pool's ability to check out a connection.- Limits on connections, HTTP/2 streams, headers, bodies, bodies in memory, requests in
flight, and pool waits (503
db.busy). - A deadline and stop token on every request, honored by extractors and pools.
- Ignite checks: the certificate and key load, required state is managed, and no two routes conflict.
- Graceful shutdown: stop accepting, drain within a timeout, then destroy managed state.
- Certificate rotation on SIGHUP or when the files change, without dropping connections.
GET /__routes, listing handlers, extractors and ranks, behind a flag.
Plain functions and Task<T> handlers use the same annotations: the router detects an
awaitable result, and there is no parallel get_async API. Task<T> handlers run on
the event loops. Plain functions run on a worker pool, or on the event loops once they
have proved fast (adaptive placement). Fairings stay synchronous.
h2o only, as the static library libh2o-evloop. FetchContent pins a commit, which
./dev h2o-update moves to the newest h2o. The certificate and key are
Config::server. ALPN chooses HTTP/1.1 or HTTP/2 inside h2o, and plain-text HTTP/2
(prior knowledge) is available for gRPC. Application code never branches on protocol.
- Application code that uses annotations and
reflect_routesneeds C++26 reflection. crocket is developed on GCC 16; Clang follows as its reflection support lands. MSVC is out until it ships^^. - Handlers live in a namespace or class the mount site can reflect.
- Modules themselves are not reflectable: reflect the exported namespace inside them.
The test suite checks each of these:
hello(name, uint8_t age): an age that does not parse is 404, and the function does not run.create(Json<NewUser>, Auth, State<Db>): missing or invalid JSON is 422, and a missing token is 401.me(Auth): a route with no body extractor.helperinapiis not a route.- A path with
{age}and a parameter namedyearsdoes not compile, and the error names both identifiers. - A route taking
State<Db>without.manage(Db)fails ignite. - A TLS launch serves HTTPS, with HTTP/2 when the client negotiates
h2over ALPN.