A C++ web framework in the same spirit as Rocket for Rust. Not a port.
Handlers are functions. Path captures and extractors are arguments. The return value is the response. Routes are declared with C++26 annotations and discovered with reflection. The wire layer is h2o (HTTP/1.1, HTTP/2, TLS).
namespace api {
[[= http::get("/hello/{name}/{age}")]]
auto hello(std::string_view name, std::uint8_t age) -> std::string;
} // namespace api
crocket::build()
.attach(Logger{})
.mount("/", reflect_routes<^^api>())
.launch({.listen = "0.0.0.0:8000"});Routes, typed path parameters, JSON and query binding, OpenAPI and gRPC all come from the same function signatures, checked at compile time. Its parsers are fuzzed (Fuzzing), and conditional and range requests follow RFC 9110 (Caching and ranges).
The same route, GET /hello/{name}/{age}, in each framework:
| crocket | Crow | Drogon |
|---|---|---|
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);
}
}
crocket::build()
.mount("/", reflect_routes<^^api>())
.launch({.port = 8000}); |
crow::SimpleApp app;
CROW_ROUTE(app, "/hello/<string>/<int>")
([](std::string name, int age) {
return std::format("Hello, {} year old named {}!",
age, name);
});
app.port(8000).multithreaded().run(); |
drogon::app().registerHandler(
"/hello/{name}/{age}",
[](const drogon::HttpRequestPtr&,
std::function<void(const drogon::HttpResponsePtr&)>&& cb,
const std::string& name, int age) {
auto resp = drogon::HttpResponse::newHttpResponse();
resp->setBody(std::format(
"Hello, {} year old named {}!", age, name));
cb(resp);
},
{drogon::Get});
drogon::app().addListener("0.0.0.0", 8000).run(); |
In crocket the route is a plain, named function: no macro, no registration call, and no callback. Captures bind to parameters by name, so a typo in {age} is a compile error, and age out of std::uint8_t range is a 404 before the handler runs. Crow matches captures by position through <int>/<string> in the macro; Drogon by position in the lambda's parameter list.
git clone https://github.com/sean3z/crocket && cd crocket
./dev setup # apt packages for Ubuntu 26.04 (asks for sudo once)
./dev gcc # builds GCC 16.2 into ~/.local/gcc-16.2 (the slow part)
./dev check # release build plus the full test suite
./build/crocket_hello
curl localhost:8000/hello/Rocketeer/42 # Hello, 42 year old named Rocketeer!To use crocket in your own project, see Using crocket.
Docker, no toolchain needed:
docker run --rm -p 8000:8000 ghcr.io/sean3z/crocket-hello:stable
curl localhost:8000/hello/Rocketeer/42 # Hello, 42 year old named Rocketeer!In your browser: annotations plus reflected routes on Compiler Explorer
(GCC 16.2, -std=c++26 -freflection; a single-file sketch, examples/godbolt.cpp, without h2o).
As a dev container: ghcr.io/sean3z/crocket-toolchain:gcc-16.2 is Ubuntu 26.04 with every
./dev setup package and GCC 16.2 already built, the same image CI runs in. Point
.devcontainer/devcontainer.json at it ({"image": "ghcr.io/sean3z/crocket-toolchain:gcc-16.2"}),
or run it by hand:
docker run --rm -it -v "$PWD:/src" -p 8000:8000 ghcr.io/sean3z/crocket-toolchain:gcc-16.2 ./dev run hello(./dev run uses the dev profile, which listens on 127.0.0.1; inside a container use
./dev check and run build/crocket_hello to listen on 0.0.0.0.)
Most C++ web frameworks bind routes with runtime registration (app.get("/x", lambda)), macros or a code generator. crocket uses C++26 static reflection instead. Routes are ordinary functions: the annotation is a normal C++ value, and reflect_routes<^^api>() reads the functions, their annotations and their parameters at compile time. That buys a few things:
- Nothing to register or keep in sync. Mounting a namespace or class picks up every annotated function in it; you can't forget to add a route to a list. Unannotated helpers are ignored.
- Mistakes are compile errors, in your words. Reflection can see parameter names, which templates alone cannot. A path capture is matched to its parameter by name, so a mismatch fails the build with a message naming both identifiers ("did you mean parameter 'years'?"). The same applies to a type that can't be parsed from a path, an unknown extractor, or a return type that isn't a responder.
- No runtime cost. Reflection runs only in the compiler. Each route becomes a plain function pointer to a generated invoker, with no string lookups, RTTI or type erasure per argument on the request path.
- The signature is the configuration. Which state a route needs, whether it reads the body, and how its errors are counted are all derived from the parameter types. That is how a missing
.manage(...)is caught at ignite rather than on the first request. - One mechanism, no boilerplate. The same reflection maps JSON bodies and query strings onto your structs, with no derive macros or field lists.
- The API describes itself. The same signatures and structs produce an OpenAPI document and a
.proto, which can't drift from the code because they are read from it. - Handlers stay testable. Because they are plain functions, you can call them directly in unit tests, step into them in a debugger, and navigate to them in an IDE.
The trade-off is toolchain reach and build time. You need a compiler with C++26 reflection (developed on GCC 16), and heavily annotated translation units compile more slowly than hand-registered routes.
See docs/DESIGN.md for crocket's goals, non-goals and principles, and docs/ENGINE.md for how the wire engine works.
Add crocket to a CMake project with FetchContent:
include(FetchContent)
FetchContent_Declare(crocket
GIT_REPOSITORY https://github.com/sean3z/crocket.git
GIT_TAG 0.3.0) # the current stable release
FetchContent_MakeAvailable(crocket)
target_link_libraries(my_app PRIVATE crocket::crocket)0.3.0 is the current stable release. To update, change GIT_TAG to a newer release
from CHANGELOG.md. For the bleeding edge, opt in to GIT_TAG main
(the latest channel); for reproducible builds off main, pin a full commit SHA.
crocket uses Semantic Versioning. While on 0.x, a minor bump (0.1 → 0.2) may break the API, and patch releases are fixes and additions only. Each release is in CHANGELOG.md and on GitHub Releases.
| Channel | Git ref | Docker tags (ghcr.io/sean3z/crocket-hello) |
For |
|---|---|---|---|
| stable (default) | the latest X.Y.Z tag, now 0.3.0 |
:0.3.0, :0.3, :stable |
Everyone |
| latest (opt-in) | main, every merge |
:latest, :sha-<short> |
Trying what's next |
| experimental (opt-in) | X.Y.Z-rc.N pre-release tags |
:X.Y.Z-rc.N, :experimental |
Testing risky changes |
The version is in <crocket/version.hpp> (included by <crocket/crocket.hpp>):
crocket::version::string ("0.3.0"), major/minor/patch, commit (the short git
commit at configure time, or -DCROCKET_GIT_COMMIT=...) and compiler.
For a vendored copy or a git submodule, use
FetchContent_Declare(crocket SOURCE_DIR path/to/crocket) instead.
Linking crocket::crocket adds -std=c++26 -freflection to your target. crocket's
own examples and tests are not built, and h2o stays private: it is not in your
headers or your CMake cache.
You need:
- GCC 16.2 or later. Ubuntu 26.04's
g++-16package is too old (see Building). Clang is not tested yet. - CMake 3.28 or later, and Ninja or Make.
- OpenSSL and zlib development headers (
libssl-dev,zlib1g-dev), andperl, which h2o's build runs to generate a header. - Network access on the first configure, to fetch h2o (a 7 MB tarball) from GitHub.
Set
FETCHCONTENT_SOURCE_DIR_H2Oto a local checkout to build offline.
There are no install() rules yet, so find_package(crocket) does not work.
If you know Rocket, almost every concept carries over. The table maps Rocket 0.5 concepts to crocket.
| Rocket | crocket |
|---|---|
#[get("/hello/<name>/<age>")] |
[[= http::get("/hello/{name}/{age}")]] |
routes![a, b, c] |
reflect_routes<^^api>(): every annotated function in a namespace or class |
rocket::build().mount("/", ...) |
crocket::build().mount("/", ...) |
#[launch] |
.launch({...}) |
FromParam |
crocket::FromParam<T> |
Forwarding and rank = N |
Forwarding and {.rank = N} |
Request guards (FromRequest) |
Extractors: crocket::FromRequest<T> |
Data guards (FromData), Json<T> |
Json<T> (and any extractor with consumes_body = true) |
Query params / FromForm for queries |
Query<T> |
Responder |
crocket::Responder<T> |
Option<T>, Result<T, E>, status::Created |
std::optional<T>, std::expected<T, E>, Created<T> |
.manage(x) and &State<T> |
.manage(x) and State<T> |
Sentinels (State<T> aborts launch if unmanaged) |
Ignite checks (same guarantee, plus route conflicts and fairing checks) |
Fairings (on_ignite, on_request, on_response, on_shutdown) |
Fairings, same names, as plain structs |
async fn handlers |
Handlers returning Task<T> |
rocket::local::blocking::Client |
LocalClient |
Shutdown and grace period |
SIGINT/SIGTERM drain with drain_timeout |
Four things crocket adds that Rocket has no direct equivalent for:
- Controllers: classes whose member functions are routes, run on a managed instance.
- OpenAPI from the code: the document, and an interactive docs page, generated from the handlers and structs themselves (see OpenAPI).
- gRPC: unary methods next to your HTTP routes, with protobuf messages that are
plain structs and a
.protogenerated from them (see gRPC and protobuf). - A production kit in the box: request ids, structured logs, Prometheus metrics,
/healthzand/readyz, deadlines with cancellation, and resource pools.
Things Rocket has that crocket does not (yet) are listed at the end of this section.
A route is a function with exactly one route annotation. Each {capture} in the path
binds to the parameter with the same name. That parameter's type decides whether the
segment is acceptable. If parsing fails, the handler is not called.
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);
}
} // namespace api/hello/Ada/36 calls hello. /hello/Ada/400 does not, because 400 isn't a
uint8_t. It returns 404 path.invalid instead. A floating-point capture must be a
finite number: nan and inf don't match.
Paths are split on / before they are decoded, so a capture can hold any character:
/hello/a%2Fb/36 calls hello with name = a/b, and can't be routed as
/hello/a/b/36. Dot segments (. and .., encoded or not) are removed first, and never
climb above /. A path that decodes to a NUL byte is 400. LocalClient routes exactly
as the server does.
A mismatch between the path and the signature is a compile error, not a runtime surprise:
error: static assertion failed: crocket: handler bad::hello [GET /hello/{name}/{age}]:
path capture '{age}' has no parameter named 'age'; did you mean parameter 'years'?
(parameters: name, years)
Built-in path types are std::string_view, std::string, bool, all integer types
(range-checked) and floating-point types. Add your own by specializing FromParam:
struct Hex { std::uint32_t value; };
template <>
struct crocket::FromParam<Hex> {
static std::optional<Hex> parse(std::string_view s) {
std::uint32_t v{};
auto [p, ec] = std::from_chars(s.data(), s.data() + s.size(), v, 16);
if (s.empty() || ec != std::errc{} || p != s.data() + s.size()) return std::nullopt;
return Hex{v};
}
};
[[= http::get("/color/{rgb}")]]
auto color(Hex rgb) -> std::string; // /color/ff8000 -> 200, /color/xyz -> 404Routes for every method: http::get, head, post, put, patch, del, options.
- HEAD falls back to the GET route and drops the body.
- Wrong method on a path that exists gets 405 with an
Allowheader.
As in Rocket, a path that fails to parse doesn't stop routing; the request is forwarded to the next candidate route. Candidates are tried in this order:
- Lower
rankfirst. - Within a rank, literal segments before captures.
- Then mount order.
If every candidate forwards, the response is 404 path.invalid.
[[= http::get("/user/{id}", {.rank = 1})]]
auto user_by_id(std::uint64_t id) -> std::string; // /user/42
[[= http::get("/user/{name}", {.rank = 2})]]
auto user_by_name(std::string_view name) -> std::string; // /user/adaTwo routes with the same method, path shape and rank are a conflict. They fail at ignite with both handlers named.
Routes are compiled at ignite into a tree keyed by path segment, so finding the candidates takes the same time with 10 routes or 1,000 and allocates nothing. A route may have up to 64 segments and 16 captures; longer paths are 404.
Every parameter that isn't a path capture is an extractor. Extractors are
crocket's request guards. They run before the handler, and if one fails, its
ApiError becomes the response and the handler never runs.
| Extractor | Gives you | Fails with |
|---|---|---|
Json<T> |
Request body decoded into T via reflection (see JSON) |
415 json.unsupported_media_type, 422 json.invalid / json.validation / json.unknown_field / json.duplicate_key / json.limit_exceeded / json.path_mismatch / json.read_only |
Query<T> |
Query string mapped onto the members of T |
422 query.invalid |
Header<"name", T> |
Header name parsed as T (default std::string; std::optional<U> if it may be absent) |
400 header.missing / header.invalid |
Auth |
Bearer token verified by the managed Authenticator |
401 auth.missing / auth.invalid / verifier's code |
State<T> |
The managed T |
(checked at ignite, never at request time) |
Deadline |
Absolute deadline plus a std::stop_token |
(none) |
RequestId |
The request's id | (none) |
Client |
The client's address and scheme (see Behind a proxy) | (none) |
const Request& / Response& |
Raw access, when you need it | (none) |
A custom guard is a FromRequest specialization. This is Rocket's ApiKey example in
crocket:
struct ApiKey { std::string value; };
template <>
struct crocket::FromRequest<ApiKey> {
static constexpr std::string_view kind = "api_key"; // label in metrics
static constexpr bool consumes_body = false;
static std::expected<ApiKey, ApiError> extract(Request& rq) {
auto key = rq.header("x-api-key");
if (!key) return std::unexpected(ApiError::unauthorized("api_key.missing", "x-api-key header required"));
if (*key != "s3cret") return std::unexpected(ApiError::forbidden("api_key.invalid", "unknown API key"));
return ApiKey{std::string(*key)};
}
};
[[= http::get("/sensitive")]]
auto sensitive(ApiKey key) -> std::string; // 401 without the header, 403 with a wrong oneA parameter type that is neither a path type nor an extractor is a compile error that names the parameter and its type. The same applies to a handler with two body-consuming extractors.
Failures are counted per route and extractor kind in
crocket_extractor_failures_total{route,kind}.
struct NewUser { std::string email; std::optional<std::string> name; };
struct Page { int page = 1; std::optional<std::string> q; };
[[= http::post("/users")]]
auto create(Json<NewUser> body, Auth auth) -> Result<Created<User>, ApiError>;
[[= http::get("/search")]]
auto search(Query<Page> p) -> std::string { // /search?page=2&q=rust
return std::format("page {} of results for {}", p->page, p->q.value_or("*"));
}Single headers are read the same way, by name in the type:
[[= http::get("/reports")]]
auto reports(Header<"x-tenant"> tenant, // required: 400 header.missing
Header<"x-page-size", std::optional<int>> size) // optional, parsed like a path value
-> std::string {
return std::format("{} {}", *tenant, size->value_or(50));
}Every request header reaches the handler, over HTTP/1.1 and HTTP/2 alike.
Any plain struct works as JSON as it is, with no annotations, derive macros or registration:
struct NewUser {
std::string email; // required
std::optional<std::string> name; // may be absent or null
int age = 0; // may be absent: keeps its default
};
[[= http::post("/users")]]
auto create(Json<NewUser> body) -> Json<NewUser> { return body; }By default, JSON names are the member names, a member is required unless it is
optional or has a default initializer, unknown fields are ignored, and empty optionals
are written as null. Annotations change those defaults where you need
to, and validation adds checks, but neither is required.
Json<T> reads the body straight into T, with no document tree in between, and a
responder writes T back out. The strict reading rules below apply to every struct,
annotated or not.
| C++ | JSON |
|---|---|
bool, integers, floating point |
true/false, number (NaN and infinity are written as null) |
std::string |
string |
| an enum | string: the enumerator's name |
| an aggregate struct | object, one member per data member |
std::optional<T> |
T or null |
std::vector<T>, std::array<T, N> |
array (std::array: exactly N elements) |
std::map<std::string, T> |
object |
std::variant<T...> |
untagged by default; see json::tag |
std::chrono::sys_time<D> |
RFC 3339 "2026-10-05T14:03:07.250Z", with digits for D's precision; "2026-10-05" when D is days |
std::chrono::year_month_day |
"2026-10-05" |
std::chrono::duration<R, P> |
number of ticks |
json::Value |
any JSON |
Errors name the field with a JSON Pointer:
field '/items/2/qty': expected integer, got string.
Reading is strict, because a lenient parser is one that disagrees with the next one in line:
- UTF-8 is checked. Overlong forms, surrogates and stray bytes are 422
json.invalid. Output is always valid UTF-8: astd::stringholding invalid bytes is written with U+FFFD in their place. - Duplicate keys are rejected (422
json.duplicate_key) in every object, including ones the struct ignores.{"a":1,"\u0061":2}counts as a duplicate. - Every dimension is limited by
Config::json(json::ReadOptions): nesting depth (64), string length (1 MiB), members per object (1024) and elements per array (100,000). Exceeding one is 422json.limit_exceeded. - Integers keep every bit.
std::uint64_treads all 64 bits, and a value that does not fit the member's type is an error, never a rounded number.
struct [[= json::rename_all(json::camel_case), = json::deny_unknown_fields]] Signup {
std::string display_name; // "displayName"
[[= json::rename("e-mail")]] std::string email;
[[= json::as_string]] std::uint64_t referrer_id = 0; // "9007199254740993"
[[= json::omit_null]] std::optional<std::string> note; // left out when empty
[[= json::unix_seconds]] std::chrono::sys_seconds joined{}; // 1759672987
[[= json::tag("type")]] std::variant<Card, BankTransfer> payment;
};| Annotation | On | Effect |
|---|---|---|
json::rename("name") |
member, enumerator, struct | The JSON name. On a struct, it is the struct's json::tag value. |
json::rename_all(json::camel_case) |
struct, enum | Names for every member or enumerator without a rename: snake_case, camel_case, pascal_case, kebab_case or screaming_snake_case. |
json::deny_unknown_fields |
struct | A member T does not have is 422 json.unknown_field instead of being ignored. Config::json.deny_unknown_fields turns this on everywhere. |
json::omit_null |
optional member, struct | An empty optional is left out instead of written as null. |
json::as_string |
integer member, struct | Written as a string, so JavaScript clients (exact only up to 2^53) see every digit. Reading accepts a string or a number. |
json::unix_seconds, json::unix_millis |
sys_time member |
An integer timestamp instead of RFC 3339. |
json::tag("type") |
std::variant of structs |
An internally tagged union: {"type":"Card","number":"…"}. The tag can be anywhere in the object. |
An untagged std::variant reads the first alternative whose JSON type fits and that
reads without error, so std::variant<std::int64_t, std::string> takes 5 or "5".
std::monostate is null. An alternative that fails is re-read as the next one, and
when struct alternatives nest inside each other, the re-reading multiplies with depth.
Re-reading is capped at 8 times the body's size, and beyond that the request is 422
json.limit_exceeded. Use json::tag for unions of structs, especially recursive ones.
Two members with the same JSON name, or an annotation on a member it cannot apply to, are compile errors.
struct NewUser {
[[= json::email]] std::string email;
[[= json::min_len(1), = json::max_len(64)]] std::string name;
[[= json::pattern("^[a-z0-9_]{3,20}$")]] std::string handle;
[[= json::min(13), = json::max(130)]] std::optional<int> age;
[[= json::max_len(10)]] std::vector<std::string> tags = {};
};| Annotation | On | Checks |
|---|---|---|
json::min(n), json::max(n) |
numbers | Inclusive bounds. |
json::min_len(n), json::max_len(n) |
strings, arrays, maps | Length in code points (strings) or elements. |
json::pattern("re") |
strings | A regular expression that must match somewhere; anchor it with ^…$. |
json::email |
strings | An address of the form local@domain.tld, within RFC 5321's lengths. |
Checks on an optional apply when it holds a value. A failing check does not stop
reading. Every failure is collected and returned as one 422 json.validation, with an
errors entry per field:
{"title":"Unprocessable Content","status":422,
"detail":"field '/email': must be an email address; field '/age': must be at least 13",
"code":"json.validation","request_id":"5f0c…",
"errors":[{"pointer":"/email","detail":"must be an email address"},
{"pointer":"/age","detail":"must be at least 13"}]}json::pattern compiles the expression when the program compiles, so a bad pattern is a
compile error. It matches in time linear in the input, without backtracking, so no
request can make it slow. The syntax is a subset of ECMAScript's: classes, \d \w \s,
anchors, groups, | and the usual quantifiers, but no backreferences or lookaround (see
detail/regex.hpp). json::validate(value) runs the
same checks on a value built in code.
Outside a handler, json::read(text, value), json::from_string<T>(text) and
json::to_string(value) do the same mapping, and json::parse(text) returns a
json::Value when the shape is not known.
A resource's id is usually assigned by the server on POST /users and named by the URL
on PUT /users/{id}. Instead of writing two structs, mark the id with
[[= json::from_path]]:
struct User {
[[= json::from_path]] std::optional<std::uint64_t> id;
std::string email;
std::optional<std::string> name;
};
[[= http::post("/users")]]
auto create(Json<User> body, State<Db> db) -> Created<Json<User>>; // body->id is empty
[[= http::put("/users/{id}")]]
auto replace(std::uint64_t id, Json<User> body, State<Db> db) -> Json<User>; // body->id == idJson<T> applies the rule per route, after decoding:
| Route | Body id |
Result |
|---|---|---|
Has an {id} capture (PUT, PATCH) |
Absent or null |
Filled from the URL |
| Same value as the URL | Accepted, so a client can PUT back what it GOT | |
| Different value | 422 json.path_mismatch |
|
No {id} capture (POST) |
Absent or null |
Stays empty for the server to assign |
| Any value | 422 json.read_only |
The field is matched to the capture by name, and responses encode it like any other member.
Some constraints are checked at compile time. The member must be:
std::optional<P>, because it is absent on routes without the capture;- of a type
Pthat can be parsed from a path segment (anyFromParamtype, e.g. a string slug); - equality-comparable, so it can be checked against the URL.
Only members of the top-level body struct are checked. Nested structs and the elements
of Json<std::vector<T>> bodies are decoded as usual.
The return type is the response. Built-in responders:
| Return type | Response |
|---|---|
std::string, std::string_view, const char* |
200 text/plain |
A struct T, std::vector<T>, any JSON-encodable value |
200 application/json, as if wrapped in Json<T> |
Json<T> |
200 application/json, explicitly |
Proto<T> |
200 application/x-protobuf (the protobuf encoding of T) |
std::optional<T> |
T, or 404 not_found when empty |
std::expected<T, ApiError> (alias Result<T>) |
T, or the error as a problem+json body |
Result<void> / void |
204 |
Created<T>, Accepted<T> |
201 / 202, with an optional Location header |
Status<T> |
Runtime status plus payload |
NoContent |
204 |
Cacheable<T> |
T with Last-Modified and byte ranges (see Caching and ranges) |
Response |
Exactly what you built |
Task<T> |
Any of the above, produced asynchronously |
T can be any responder or any JSON-encodable type, so std::optional<Hero>,
std::vector<Hero> and Created<Hero> need no Json<...> around the struct. A return
type that is neither is a compile error. On a gRPC route the same
plain T is sent as protobuf instead; there Proto<T> says so explicitly and Json<T>
replies with JSON (application/grpc+json). OpenAPI describes each one as it is sent.
[[= http::post("/items")]]
auto add(Json<Message> msg, State<Store> db) -> Created<Json<Message>> {
auto id = db->insert(msg->contents);
return {Json<Message>{*msg}, std::format("/items/{}", id)}; // 201, location: /items/1
}
[[= http::get("/items/{id}")]]
auto item(std::uint64_t id, State<Store> db) -> std::optional<Json<Message>>; // 404 if absent
[[= http::get("/teapot")]]
auto teapot() -> Status<std::string> { return {418, "short and stout"}; }Custom responders are a Responder specialization:
struct Csv { std::vector<std::vector<std::string>> rows; };
template <>
struct crocket::Responder<Csv> {
static void respond(Csv&& csv, const Request&, Response& rs) {
for (auto& row : csv.rows) {
for (std::size_t i = 0; i < row.size(); ++i) rs.body += (i ? "," : "") + row[i];
rs.body += '\n';
}
rs.set_content_type("text/csv");
}
};
[[= http::get("/report.csv")]]
auto report() -> Csv { return {{{"name", "age"}, {"Ada", "36"}}}; }Every 200 answer to GET or HEAD carries an ETag, a hash of the body, unless the
handler set its own. A client that sends it back in If-None-Match gets 304 Not Modified with no body, so unchanged responses cost no bandwidth. Nothing needs
configuring.
Wrap the payload in Cacheable for the rest:
[[= http::get("/reports/{id}")]]
auto report(std::uint64_t id, State<Store> store) -> Cacheable<std::string> {
auto r = store->find(id);
return {r.contents, {.last_modified = r.updated_at, .ranges = true}};
}last_modifiedis sent asLast-Modified, and answersIf-Modified-Sincewith 304 when the client's copy is as new.If-None-Match, when sent, decides instead.rangesserves one byte range (Range: bytes=0-499,500-or-500) as206 Partial Content. A range past the end is 416range.unsatisfiable, and a request for several ranges gets the whole body.If-Rangemakes the range conditional on the client still having the current version.
Every error, whether from an extractor, a responder, the router or an exception, is an
RFC 9457 problem (application/problem+json)
and carries the request id:
{"title":"Unprocessable Content","status":422,"detail":"email must contain '@'",
"code":"user.email","request_id":"5f0c…"}ApiError{status, code, message, detail} is the type behind it:
messageis the problem'sdetail, written for the client.codeis a stable, dotted identifier that clients can switch on.errorslists failing fields as{"pointer", "detail"}pairs.Json<T>fills it from validation and decoding errors.typeis an optional problem-type URI. It is left out when empty, which RFC 9457 reads asabout:blank, andtitleis the status's reason phrase.detailgoes to the logs only, never to the client. The one exception is the dev profile, which adds it to error bodies asdebug.- An uncaught exception becomes 500
internal. Itswhat()is logged, never sent. - A response header with CR, LF or NUL in its value, or an invalid name, also becomes
500
internal, so aLocationbuilt from user input cannot inject headers. If anon_responsefairing adds such a header, the engine drops it and reports it on stderr.
manage a value once and ask for it with State<T> anywhere. As with Rocket's
sentinels, a route that needs state nobody manages fails at ignite, before the
server accepts a single connection:
crocket::build().mount("/", reflect_routes<^^api>()).ignite();ignite failed:
- GET /items/{id} (api::item) requires State<Store>, but Store is not managed; add .manage(Store{...})
Ignite also rejects conflicting routes and anything a fairing's on_ignite flags.
launch adds one more check: TLS certificate and key files must exist and be readable.
Every problem is reported at once, not just the first.
Managed state is destroyed in reverse manage order at shutdown, after requests have
drained. A managed type with a bool ready() const member, such as Pool<T>, is
automatically part of /readyz.
Pool<Conn> pool(8, [&] { return Conn{table}; }, {.max_wait = 2s});
[[= http::get("/users/{id}")]]
auto get_user(std::uint64_t id, State<Pool<Conn>> db, Deadline d) -> Result<std::optional<Json<User>>> {
auto conn = db->checkout(d); // waits until the deadline; 503 db.busy after that
if (!conn) return std::unexpected(conn.error());
/* ... */
}The crocket::db API (Db&, db::Pool, find, insert, where, ...) is generic;
each database is a backend you link. Today there is one, crocket::pg, for Postgres
on libpqxx. It is built when libpqxx is found
(apt install libpqxx-dev): target_link_libraries(app PRIVATE crocket::pg).
-DCROCKET_PG=OFF skips it and -DCROCKET_PG=ON makes a missing libpqxx an error.
Programs that link only crocket::crocket are unaffected. Handlers name Db&, never
the backend, so a later backend (crocket::sqlite, say) needs no handler changes.
Your structs stay plain: no annotations and no base class.
#include <crocket/db.hpp>
struct Hero {
std::int64_t id; // the key: the member named id
std::string name; // "Barry Allen"
std::string alias; // "The Flash"
std::optional<std::string> mentor; // NULL when empty
};
[[= http::get("/heroes/{id}")]]
auto get(Db& db, std::int64_t id) -> std::optional<Hero> {
return db.find<Hero>(id); // nullopt: 404
}
[[= http::post("/heroes")]]
auto create(Db& db, Json<Hero> body) -> Created<Hero> {
return {db.insert(*body)}; // fills in id; a unique violation is 409 db.conflict
}
[[= http::get("/robins")]]
auto robins(Db& db) -> std::vector<Hero> {
return db.where(&Hero::mentor, "Bruce Wayne").order_by(&Hero::id);
}
int main() {
auto cfg = Config::from_env(); // CROCKET_DATABASE_URL, CROCKET_DATABASE_POOL_SIZE, ...
return build(cfg)
.manage(db::Pool(cfg.database))
.attach(Logger{}).attach(Metrics{})
.mount("/", reflect_routes<^^api>())
.launch();
}Reflection reads the struct while compiling:
- The table is the type's name in plural snake_case:
Heroisheroes,HeroTeamishero_teams,Categoryiscategories. To use another name, specializetable_namenext to the struct:template <> constexpr auto crocket::db::table_name<Hero> = "justice_league"; - The columns are the data members, by name and in declaration order. The member
named
idis the key; the database fills it in on insert. std::optional<T>members read and writeNULL.idmay be left out of a JSON body ({"name":"Barry Allen","alias":"The Flash"}): it reads as 0,insert()never sends it, and the stored row comes back with the id the database assigned. Anida client does send is ignored on insert. OpenAPI does not listidas required. (The core hook isjson::omittable<T>.)
| Call | Does |
|---|---|
db.find<Hero>(id) |
std::optional<Hero> |
db.insert(hero) |
inserts every member but id, sets hero.id, returns the stored Hero |
db.update(hero) |
writes every member to the row with hero.id; false if there is none |
db.erase<Hero>(id) |
deletes it; false if there was none |
db.where(&Hero::mentor, "Bruce Wayne") |
equality; std::nullopt is IS NULL |
db.where(&Hero::alias, db::like("Robin%")) |
also db::ilike, ne, lt, le, gt, ge, db::in({...}) |
.where(...), .order_by(&Hero::id, db::desc), .limit(1), .offset(n) |
chain them (conditions are ANDed) |
.all(), .first(), .count() |
run it; a range-for or converting to std::vector<Hero> runs it too |
db.all<Hero>() |
every row, to chain from |
db.sql<Hero>("select ... where ...", args...) |
the escape hatch: any SQL, columns decoded into Hero by position |
db.exec("update ...", args...), db.connection() |
raw pqxx::result, and the libpqxx connection itself (transactions, COPY) |
Filters name columns with member pointers, so a typo does not compile. The SQL for
find, insert, update and erase is built while compiling; every statement is
prepared once per connection and rows are decoded straight into the members.
Db& db is managed state like any other: a route that takes it fails at ignite
without a db::Pool, and stays on workers, since it may wait for a connection. The
connection is checked out at the request's first query (waiting at most
database.pool_wait and the request's deadline, then 503 db.busy) and returned
when the handler ends. Outside a request, pool.handle() gives you a Db for
seeding, migrations and jobs. The pool is part of /readyz (select 1).
What it tells you, with no code of your own:
crocket_db_seconds{table,op}, a histogram of query time (decoding included) per table and operation (find,insert,update,erase,where,count,sql), andcrocket_db_pool_connections{state="idle"|"in_use"}, inMetrics;db_ms,db_wait_ms(waiting for a pool connection) anddb_querieson the request'sLoggerline, soduration_ms - db_msis time spent outside the database.
Each query is timed with two steady_clock reads and three relaxed atomic adds,
about 70 ns, against a database round trip of tens of microseconds. Routes that never
take Db& pay nothing. See examples/heroes.cpp for a full CRUD API.
A class can be a route scope. Static members are ordinary handlers. Non-static members
run on the instance you manage, and ignite checks that you did.
class Info {
public:
explicit Info(std::string version) : version_(std::move(version)) {}
[[= http::get("/version")]]
auto version() const -> std::string { return version_; }
private:
std::string version_;
};
app.manage(Info{"1.4.2"}).mount("/", reflect_routes<^^Info>());Fairings are crocket's structured middleware, with Rocket's hook names. A fairing is
any movable type that has one or more of these members; there is no trait to implement
and no info():
| Hook | When | Can |
|---|---|---|
void on_ignite(Ignite&) |
During ignite(), in attach order |
Inspect state, config and routes; ig.fail("...") aborts launch |
void on_request(Request&) |
Before routing, in attach order | Rewrite the request |
std::optional<Response> on_request(Request&) |
Same | Also answer immediately and stop the chain |
void on_response(const Request&, Response&) |
After the response exists, in reverse attach order | Rewrite the response |
void on_shutdown() |
After the drain, in reverse attach order, before state is destroyed | Flush, close, say goodbye |
Differences from Rocket:
on_requestcan answer the request. Returning aResponsestops the chain, which is useful for maintenance mode and CORS preflights. Rocket's request fairings can only modify the request.on_responseruns in reverse attach order. Fairings nest like layers: the first attached sees the request first and the response last.on_responseruns on every response, early rejections included. That covers the engine's 413, 503 and 504, so logs and metrics never miss a request.
A response timer (compare Rocket's RequestTimer):
struct Timer {
void on_response(const Request& rq, Response& rs) {
auto us = std::chrono::duration_cast<std::chrono::microseconds>(Clock::now() - rq.received).count();
rs.headers.set("x-response-time", std::format("{} us", us));
}
};Maintenance mode, answering early:
struct Maintenance {
std::shared_ptr<std::atomic<bool>> on = std::make_shared<std::atomic<bool>>(false);
std::optional<Response> on_request(Request& rq) {
if (!*on || rq.path == "/healthz") return std::nullopt; // carry on
Response rs;
rs.status = 503;
rs.headers.set("retry-after", "120");
rs.body = "down for maintenance\n";
return rs; // handler never runs
}
};Launch checks and cleanup:
struct RequireKeyInProd {
bool prod;
void on_ignite(Ignite& ig) {
if (prod && !ig.state<ApiKey>()) ig.fail("RequireKeyInProd: production needs a managed ApiKey");
}
void on_shutdown() { std::puts("bye"); }
};A custom 404 page, standing in for a Rocket #[catch(404)]:
struct NotFoundPage {
void on_response(const Request& rq, Response& rs) {
if (rs.status == 404 && !rq.header("accept").value_or("").contains("json")) {
rs.body = "<h1>404</h1><p>Nothing here.</p>";
rs.set_content_type("text/html; charset=utf-8");
}
}
};
crocket::build().attach(Timer{}).attach(Maintenance{}).attach(RequireKeyInProd{false}).attach(NotFoundPage{});Fairings run concurrently on worker threads, so keep any shared state thread-safe.
Maintenance above uses an atomic behind a shared_ptr so you can keep a handle to
flip it.
Built-in fairings:
Logger: one line per request with the request and trace ids, method, route template (never the raw path), handler, status, error code, duration, bytes in and out, protocol, client address, user agent, authenticated subject and log-only detail. Also lines at ignite and shutdown. See Logging for where lines go.Cors: deny-by-default CORS.Cors::allow_origins({...})withallow_methods,allow_headers,expose_headers,allow_credentialsandmax_age.- Answers preflights itself, or 403
cors.deniedfor origins not on the list. - Refuses at ignite to combine credentials with
*.
Shield: security headers on every response, attached by default:X-Content-Type-Options: nosniff,X-Frame-Options: DENY,Content-Security-Policy: default-src 'none'; frame-ancestors 'none'andReferrer-Policy: no-referrer, plusStrict-Transport-Security: max-age=31536000on https (never for localhost). A header the handler set itself is left alone. Attach your own to change it, orShield::none()to send nothing:app.attach(Shield{}.set("content-security-policy", "default-src 'self'").remove("x-frame-options"));Metrics: Prometheus text atGET /metrics, labelled by route template.crocket_http_requests_totaland a duration histogram.crocket_http_requests_in_flight.crocket_extractor_failures_total.crocket_connections,crocket_connections_per_ip_closed_totalandcrocket_request_body_bytes(see Limits).crocket_workers,crocket_workers_busyandcrocket_workers_stuck(still running after their request's deadline), andcrocket_requests_abandoned_total. See Deadlines and cancellation.crocket_build_info{version,commit,compiler,hostname} 1, which says what is running where.- Constant labels on every series from
CROCKET_METRICS_LABELS, a comma-separatedname=valuelist (or pass them:Metrics{"/metrics", {{"pod", "web-1"}}}). In Kubernetes, fill it from the Downward API:env: - name: POD_NAME valueFrom: {fieldRef: {fieldPath: metadata.name}} - name: CROCKET_METRICS_LABELS value: pod=$(POD_NAME)
- Scrape each pod directly, never through a load balancer or Service: each scrape
would land on a different instance, so counters jump between them and
rate()sees fake resets. Prometheus' Kubernetes service discovery (or a ServiceMonitor) scrapes pod IPs and addsinstance/poditself. Keep/metricsoff the public listener, or behind auth.
OpenApi: the OpenAPI document atGET /openapi.json, and with.docs, a page to try the API. See OpenAPI.
log::debug, log::info, log::warn and log::error work anywhere: in a handler, in
code it calls, in a fairing, or outside any request.
[[= http::post("/users")]]
auto create(Json<NewUser> body, Auth auth, State<Db> db) -> Result<Created<User>> {
auto user = db->insert(*body);
log::info("created user {user_id} on plan {plan}", user.id, body->plan);
return Created<User>{user, std::format("/users/{}", user.id)};
}{"ts":"2026-10-05T14:03:07.250Z","level":"info","msg":"created user 42 on plan pro",
"request_id":"5f0c…","trace_id":"4bf92f35…","route":"/users","handler":"api::create",
"subject":"alice","user_id":42,"plan":"pro"}- Each
{name}is a field as well as part of the text. Arguments may be anything JSON can encode, structs included. A count that does not match the placeholders, a repeated name, or a name crocket writes itself (request_id,msg, ...) is a compile error. Write{{and}}for literal braces. - The request comes along by itself. While crocket runs a handler and its fairings,
every line carries that request's id, trace id (from a W3C
traceparentheader), route, handler and authenticated subject, at any depth of the call stack. Outside a request, lines carry none of these. A thread the handler starts takes the context with it explicitly:auto ctx = log::context(); std::thread([ctx] { log::Scope scope{ctx}; log::info("indexing {count} rows", n); }).detach();
- Lines are written by a background thread, so a slow terminal or log shipper never
holds up a request. Under load it collects lines in batches, about a millisecond
apart, and writes them in the order they were logged across all threads.
LocalClientwaits for a request's lines beforedispatch()returns, andapp.flush_logs()waits for everything so far.
Config::log controls everything:
cfg.log.level = log::Level::debug; // default info; Config::dev() uses debug
cfg.log.sample = 0.1; // keep 10% of successful request lines
cfg.log.redact = {"password", "card"}; // fields shown as "[redacted]"
cfg.log.buffer = 10'000; // lines waiting for the writer
cfg.log.when_full = log::WhenFull::drop; // or block
cfg.log.sink = [](std::string_view line) { ship(line); }; // default: stderr| Option | Default | Meaning |
|---|---|---|
level |
info |
Lines below it are skipped, request lines included (successes are info, 4xx warn, 5xx error). |
sample |
1.0 |
Share of request lines kept for responses below 400, chosen by request id, so the same request is kept on every instance. Errors are always kept. |
redact |
password, secret, token, authorization, cookie, api_key |
A field whose name contains one of these, ignoring case, is written as "[redacted]", in the message too and inside structs. |
buffer |
10'000 |
Lines waiting for the writer. |
when_full |
drop |
drop discards new lines while the buffer is full, then writes a warning with the count and adds it to crocket_log_lines_dropped_total. block makes the logging thread wait instead. |
sink |
stderr | Called on the writer thread with each line. |
In the dev profile, lines are readable text instead of JSON:
14:03:07.250 INFO created user 42 on plan pro [5f0c…].
Return Task<T> from the same annotations. A plain function and a Task differ in where
they run:
- A plain function may block: a synchronous database call, a file read, a lock. It starts on a worker, so the network threads carry on meanwhile. See adaptive placement for the fast ones.
- A
Taskruns on the event loop of its connection, with no handoff to a worker, so it is the fastest kind of handler. While it waits on aco_await, the loop serves other connections. It must not block. It awaits instead, because blocking a loop stalls every connection on it. This is the same rule as async code in Rocket, Node and FastAPI.
[[= http::get("/quote/{sym}")]]
auto quote(std::string sym, State<PriceClient> prices) -> Task<Json<Quote>> {
auto [price, resolve] = callback<double>();
prices->fetch(sym, [resolve](double p) { resolve(p); }); // any callback-based client
co_return Json{Quote{sym, co_await std::move(price)}};
}co_await sleep_for(d)waits without holding a thread. It ends early when the request is cancelled (deadline, client gone, shutdown), so checkDeadline::expired()afterwards when that matters.callback<T>()adapts a library that reports results through a callback: callresolve(value)once, from any thread. If everyresolveis destroyed unused, theco_awaitthrows and the request is a 500.- Other awaitables work too: another
Task, or an async client's own awaitable. - Code in a task always runs on its connection's event loop. If a library resumes
the task on its own thread, it moves back to the loop before continuing. So log lines
keep their request context, and
Deadlinekeeps working. - Extractors and
on_requestfairings run on the loop too for aTaskroute, so anAuthenticatorthat verifies a token must not block either. - Blocking inside a
Taskis reported. A task that holds its loop for more than 10 ms is named in awarnlog line, once, so a synchronous call that slipped in is found:api::quote held its event loop for 48 ms, stalling the other connections on it. Move blocking work into a plain function, or await it. - A mutex must not be held across a
co_await.
If an on_request fairing rewrites a request bound for a Task route so that it reaches
a plain function, that request moves to a worker before the function runs. LocalClient
waits for an async handler like any other.
A plain function that turns out to be fast doesn't need a worker. Crocket times every
run, and moves a route to the event loops once 1,000 runs in a row have each taken under
100 µs. There it skips the handoff to a worker and back, as a Task does. When it turns
out to block there, it moves back to workers: one run over 50 ms does it, and so do 8 runs
over 1 ms within a second. A single run over 1 ms is not enough, because a run's time also
counts moments when the operating system gave the thread's core to something else. It can
try the loops again after 10 s, then 20 s, and so on, so a route that is only sometimes
slow settles on workers. Both moves are logged:
INFO api::get_user runs on the event loops from now on: 1000 runs in a row each took under 100 us
WARN api::get_user blocked its event loop (48 ms, after 8 slow runs within 1 s), stalling the other connections on it, so it runs on workers again; it may move back after 10 s of fast runs
- Routes that may wait never move. A route taking a
State<Pool<T>>stays on workers however fast it is, because a checkout is quick until the pool runs dry. A managed type of your own can say the same withstatic constexpr bool crocket_may_block = true;. - The stalls it allows are the runs that get a route moved back: a route that has been fast 1,000 times and then blocks holds its loop once for a long call, or up to 8 times for short ones.
GET /__routesshows where each route runs now ("runs_on").Config::adaptive_placement = falsekeeps every plain function on workers.
Timing a run costs two clock reads, about 40 ns.
Every request has a deadline (timeouts.request, default 30 s) and a stop
token. The token fires when the deadline passes or the client disconnects.
- Extractors and pools such as
Pool::checkouthonour the token automatically. - Handlers doing long work can take a
Deadlineparameter and pollexpired(). - At the deadline the client gets 504
deadline.exceededregardless of what the handler is doing. - A request still waiting for a worker when its deadline passes, or when its client
goes away (an HTTP/2 stream reset included), is dropped there: its handler never runs.
crocket_requests_abandoned_totalcounts these. - A handler that ignores its deadline keeps its worker until it returns. While it
runs late,
crocket_workers_stuckcounts it. When it returns, crocket logs a warning naming the route and how late it was.
app.attach(OpenApi{{.title = "Orders", .version = "1.4.0", .docs = "/docs"}});GET /openapi.json then serves an OpenAPI 3.1 document for every HTTP route, built at
ignite from the handlers themselves, and GET /docs an interactive page (Swagger UI,
loaded from cdn.jsdelivr.net) to read and try it. Nothing is written twice:
| In the handler | In the document |
|---|---|
{id} and its parameter's type |
A required path parameter: integer with the type's range, number, boolean or string |
Query<T> |
One query parameter per member; required unless optional or defaulted |
Header<"x-tenant">, Header<"x-page-size", std::optional<int>> |
Header parameters, required unless optional |
Json<T> |
A required application/json body; 415 and 422 responses |
Auth |
A bearer security requirement; a 401 response |
T, Json<T>, std::string, NoContent, Created<T>, std::optional<T>, Result<T>, Cacheable<T>, Task<T> |
The success response: status, media type and schema; 404 for an optional, 304 for a cacheable |
| Any failure | default: application/problem+json with the shared Problem schema |
Structs become schemas in components/schemas, following exactly what Json<T> reads
and writes: json::rename and rename_all, as_string, time formats,
enums as their names, maps, recursion, untagged variants (anyOf) and json::tag
variants (oneOf with a discriminator). Validation annotations become their JSON
Schema keywords (minimum, maxLength, minItems, pattern, format: email), and
json::from_path members are readOnly. A member is required when a request must send
it: neither std::optional nor given a default. Two structs with the same name get
qualified names (billing.Order).
Each operation has its handler's name as operationId (shop.create_order), its scope as
the tag, and a summary from the function name ("Create order"). gRPC methods are left
out (their contract is the .proto), as is /__routes.
To write the document at build time instead, or to check it into a repository:
if (argc > 1 && argv[1] == "--openapi"sv) {
std::cout << crocket::openapi_document(app, {.title = "Orders", .version = "1.4.0"});
return 0;
}Unary gRPC methods are functions too. Messages are plain structs, encoded as proto3 by
reflection, so the C++ declarations are the schema: there is no .proto to keep in sync
and no generated code.
struct HelloRequest {
std::string name; // field 1
std::optional<std::string> title; // field 2
[[= proto::field(4)]] std::vector<std::string> tags; // field 4 (3 was retired)
};
struct HelloReply { std::string message; };
namespace greeter {
[[= grpc::rpc]]
auto say_hello(HelloRequest req, Auth auth) -> Result<HelloReply> {
if (req.name.empty()) return std::unexpected(ApiError::bad_request("hello.name", "name is required"));
return HelloReply{"Hello, " + req.name};
}
} // namespace greeter
crocket::build()
.mount("helloworld", reflect_routes<^^greeter>(), Mode::Grpc) // POST /helloworld.Greeter/SayHello
.mount("/api", reflect_routes<^^api>()) // HTTP routes on the same port
.launch({.port = 50051, .h2_prior_knowledge = true}); // or CROCKET_* via Config::from_env()- Mounting:
Mode::Grpcmakes the first argument ofmounta protobuf package ("helloworld","acme.v1", or""for none) instead of a path. The service is the namespace or class and the method the function, both in PascalCase (greeter::say_helloishelloworld.Greeter/SayHello). Mounting gRPC methods withoutMode::Grpc, or HTTP routes with it, fails ignite. - Field numbers follow declaration order from 1.
[[= proto::field(n)]]pins a member ton, and the members after it continue fromn + 1. Duplicate or reserved numbers are compile errors. Renumbering breaks existing clients, so pin numbers before reordering or removing members. - Types:
bool,intN_t,uintN_t,float,double,std::string,proto::Bytes, enums, nested structs, andstd::optional,std::vectorandstd::mapof those. Unknown fields are skipped when decoding. - Parameters: at most one request message (none means
google.protobuf.Empty), plus any extractor that does not read the body:Auth,State<T>,Deadline,Header<...>and so on. - Returns: a message,
Result<T>,std::optional<T>(empty isNOT_FOUND),void(Empty), or aTaskof one of those. Only unary calls are supported: no streaming. - Errors: an
ApiErrorbecomes a gRPC status derived from its HTTP status (401UNAUTHENTICATED, 404NOT_FOUND, 422INVALID_ARGUMENT, 504DEADLINE_EXCEEDED, ...), withmessageingrpc-messageand the dotted code in acrocket-error-codetrailer.grpc::error(grpc::Code::AlreadyExists, "user.exists", "...")picks the code exactly. Extractor failures, limits and deadlines map the same way, and an unknown method isUNIMPLEMENTED. - Deadlines: a
grpc-timeoutheader can shortentimeouts.requestbut not extend it. - Metadata: custom metadata arrives as request headers, so
Header<"x-...">reads it.
gRPC needs HTTP/2. Over TLS it is negotiated with ALPN. A gRPC client on an insecure
channel speaks h2 without TLS, which server.h2_prior_knowledge serves. The
same port keeps serving HTTP/1.1.
The .proto for clients in other languages comes from the same declarations:
std::fputs(grpc::proto_file<^^greeter>("helloworld").c_str(), stdout);It prints a service Greeter block and every message and enum the methods use. Enum
values get their enum's prefix, as proto3 requires (Mood::grumpy is MOOD_GRUMPY), and
a missing zero value is added as <ENUM>_UNSPECIFIED. examples/grpc.cpp is a complete
service: crocket_grpc --proto prints its schema, and grpcurl can call it.
To test without sockets, LocalClient::grpc sends one framed message and
grpc::read_reply<T> decodes the answer:
auto res = client.grpc("/helloworld.Greeter/SayHello", proto::encode(HelloRequest{.name = "Ada"})).dispatch();
assert(grpc::read_reply<HelloReply>(res)->message == "Hello, Ada");
assert(grpc::status_of(client.grpc("/helloworld.Greeter/Nope", "").dispatch()).code == grpc::Code::Unimplemented);LocalClient dispatches requests through the full pipeline without sockets: fairings,
routing, extractors, responders and error mapping. It plays the role of Rocket's
local::blocking::Client.
Crocket app = make_app();
LocalClient client(app);
auto res = client.post("/users").bearer("alice").json(R"({"email":"a@example.com"})").dispatch();
assert(res.status == 201);
assert(*res.headers.get("location") == "/users/1");
auto bad = client.get("/hello/Ada/400").dispatch();
assert(bad.status == 404 && bad.error_code == "path.invalid");tests/acceptance.cpp is a larger example. Compile-time guarantees are tested too:
tests/compile_fail/ contains programs that must not compile, each checked for the
expected diagnostic.
Config cfg = Config::from_env(); // CROCKET_PROFILE, CROCKET_PORT, ... (see below)
cfg.timeouts.request = 10s; // 504 deadline.exceeded
cfg.timeouts.drain = 5s; // graceful shutdown budget
cfg.limits.max_body_bytes = 1 << 20; // 413 body.too_large
cfg.limits.max_in_flight = 1024; // 503 server.busy
cfg.debug_routes = true; // GET /__routes lists every route
return crocket::build(cfg)
.manage(std::move(pool))
.attach(Logger{})
.attach(Metrics{})
.mount("/api", reflect_routes<^^api>())
.launch({.listen = "8443", .tls_cert = "cert.pem", .tls_key = "key.pem"}); // h2 via ALPNOne Config holds everything, in groups: server (listen or host and port, TLS,
HTTP/2, threads), limits, timeouts and proxy, plus profile, debug_routes,
json, log and adaptive_placement. It is applied in layers, each beating the one
before: the defaults, the dev profile, the
environment, your code, and last whatever you pass to
launch({...}). launch() with no argument serves on cfg.server; launch({...})
overrides just the server fields you name, and .listen takes "8080", ":8080",
"0.0.0.0:8080" or "[::1]:8080". To give your program its own defaults that the
environment can still override, pass them to from_env:
Config::from_env({.server = {.port = 50051}}).
What you get:
- Protocols: HTTP/1.1, HTTPS, and HTTP/2 when the client negotiates it over TLS.
Unary gRPC over HTTP/2, either with TLS or on a plain-text port with
h2_prior_knowledge = true. - Built-in routes:
GET /healthzandGET /readyz. - Request ids: an
x-request-idon every response, honouring the client's value when valid. - Graceful shutdown on SIGINT/SIGTERM: stop accepting, drain in-flight requests,
run
on_shutdown, then destroy managed state. - Certificate rotation without a restart: with TLS on, crocket loads the certificate and key again on SIGHUP, and by itself when either file changes (as cert-manager and ACME clients replace them), once the files have stayed the same for a second. New connections get the new certificate; open ones keep theirs. A certificate that does not load, or does not match its key, is logged and the previous one stays in use.
Network I/O runs on one event loop per physical core (server.event_loops), each
with its own listening socket on the port. Hyperthreads of a core don't get a loop each:
two loops sharing a core compete for it, and a loop that waits for a CPU stalls every
connection it owns. CPUs outside the process's affinity mask (a container's cpuset,
taskset) are not counted. Task<T> handlers run on the loops, and plain functions
on a pool of server.workers threads, or on the loops once they have proved fast
(see adaptive placement).
Every resource a client can make the server hold has a bound. The defaults follow h2o's (for connections and HTTP/2) and Rocket's (for a JSON body):
| Limit | Default | Past it |
|---|---|---|
limits.max_connections |
1024 | New connections wait in the kernel's accept queue until one closes; they are not refused |
limits.max_connections_per_ip |
0 (none) | A new connection from that address is closed at once. Off by default: behind a load balancer every connection comes from the balancer's address. Set it when clients connect directly |
limits.http2_max_streams |
100 | Concurrent requests on one HTTP/2 connection, as advertised in its SETTINGS |
limits.max_header_bytes, max_header_count |
8 KiB, 100 | 431 headers.too_large |
limits.max_body_bytes |
1 MiB | 413 body.too_large |
limits.max_body_memory |
256 MiB | 503 server.busy: request bodies held in memory, every request together |
limits.etag_max_bytes |
1 MiB | Bodies longer than this get no automatic ETag (hashing is about a microsecond per 10 KiB); set one yourself, or 0 for none |
limits.max_in_flight |
1024 | 503 server.busy |
timeouts.request |
30 s | 504 deadline.exceeded |
| Request headers, from h2o | 10 s to arrive, 5 s between reads | The connection is closed |
| An idle HTTP/2 connection, from h2o | 10 s | Closed with GOAWAY |
Metrics reports crocket_connections, crocket_connections_per_ip_closed_total and
crocket_request_body_bytes (bodies held now). The threading model is in
docs/ENGINE.md.
The Client extractor describes the client: its address, and whether it connected with
https or http. Behind a load balancer, list the proxies you run so crocket takes both
from the headers they add:
Config cfg;
cfg.proxy.trusted = {"10.0.0.0/8"}; // addresses or CIDR ranges
cfg.proxy.header = ProxyHeader::XForwardedFor; // the default; or ProxyHeader::Forwarded (RFC 7239)
cfg.proxy.allowed_hosts = {"api.example.com", "*.example.com"};
[[= http::get("/whoami")]]
auto whoami(Client client) -> std::string { return client.addr + " " + std::string(client.scheme); }- Only trusted proxies are believed. A request from any other address keeps its
own address and scheme, whatever headers it sends. Behind a chain of trusted proxies,
the client is the first address that no trusted proxy is behind, so a value the
client wrote into
X-Forwarded-Foritself is never picked. - Only the configured header is read. Set
proxy.headerto the one your proxies write. The other is ignored, so a client cannot supply it. allowed_hostslists the host names the server answers to. Any otherHost(:authorityin HTTP/2) is 400host.invalidbefore routing, which stops host-header injection in links and redirects. Ports are not compared, and*.example.commatches subdomains but notexample.comitself. An empty list allows every host.- Malformed entries in either list fail ignite.
In tests, LocalClient sets the peer with .remote():
auto r = client.get("/whoami").remote("10.0.0.5").header("x-forwarded-for", "203.0.113.9").dispatch();Config::dev() is for running on your own machine. Choose it in code, or read
CROCKET_PROFILE with Config::from_env(), which gives dev() for dev, the release
defaults when unset or release, and throws std::invalid_argument for anything else
(it reads the other variables too):
return crocket::build(Config::from_env()) // CROCKET_PROFILE=dev ./my_app
.attach(Logger{})
.mount("/", reflect_routes<^^api>())
.launch();Compared with the release defaults, the dev profile:
- Error bodies carry
debug(ApiError::detail), including an uncaught exception'swhat(), so you see the cause in the client without reading logs. Release never sends it. - Logs are readable lines, coloured on a terminal unless
NO_COLORis set, anddebuglines are on:14:02:11.504 GET /hello/Ada/400 404 0.21ms api::hello path.invalid: …. launch()prints every route before it starts.GET /__routesis on, the request deadline is an hour (room for a debugger breakpoint), and the drain on shutdown is one second.- An empty
server.hostbinds127.0.0.1instead of0.0.0.0. allowed_hostsalso acceptslocalhost,127.0.0.1and::1, so a server configured for its public name still answers locally.
Rocket features without an equivalent today:
| Rocket feature | crocket today |
|---|---|
Form<T>, TempFile |
Use Json<T> |
| Cookies and private cookies | Read headers via Request; set via Response |
| Templates | Return std::string or a custom responder |
FileServer |
Not implemented |
Typed URIs (uri!) |
Not implemented |
| WebSockets, SSE and streaming responses | Not implemented; responses are fully buffered |
Error catchers (#[catch]) |
ApiError plus an on_response fairing, as in NotFoundPage above |
on_liftoff |
Not implemented |
Config profiles (Rocket.toml / Figment) |
A release and a dev profile chosen with CROCKET_PROFILE; every other setting from CROCKET_* variables with Config::from_env() (Environment variables); one Config struct in code |
| Mutual TLS | Not implemented |
The toolchain used for development is GCC 16.2 (-std=c++26 -freflection, added
automatically as a PUBLIC compile option of the crocket target), CMake 4.2 and
Ninja on Ubuntu 26.04. You also need OpenSSL and zlib development headers and perl
for h2o (./dev setup installs them).
GCC 16.2 or later is required. Ubuntu 26.04's g++-16 package is a pre-release
snapshot (16-20260322) whose reflection bugs break any translation unit that mounts
routes. Its errors start with accessing uninitialized member 'crocket::detail::Binding::kind'.
CMake links with lld when it can, then mold, then the default linker. Each candidate is tried with a real test link, because mold 2.40 cannot link with GCC 16.
h2o is pinned to a commit in cmake/h2o.cmake, because it has had no release tag since
2019. ./dev h2o-update moves the pin to the newest h2o and runs the full test suite;
see docs/ENGINE.md. An application that links with
-Wl,--gc-sections drops the parts of h2o crocket never calls, about 300 KB.
Options:
CROCKET_BUILD_EXAMPLESbuildscrocket_helloandcrocket_serve.CROCKET_BUILD_TESTSbuilds the tests.
Both are ON when crocket is the top-level project and OFF when it is a dependency.
./dev is the entry point for developing crocket itself. Applications using crocket
don't need it.
./dev setup # apt packages (asks for sudo)
./dev gcc # build GCC 16.2 into ~/.local/gcc-16.2 (about 10 minutes on 32 cores)
./dev watch hello # run an example; rebuild and restart it on every save
./dev check # release build plus the full ctest, before calling a change done./dev help lists every command. CI (.github/workflows/ci.yml) runs ./dev setup,
./dev gcc and ./dev check in an ubuntu:26.04 container on every push to main
and every pull request, and caches the GCC build. Builds go to build-dev/ (Debug), build/
(release) and build-fuzz/ (see Fuzzing), configured from CMakePresets.json. ./dev picks the compiler in this
order: $CROCKET_CXX, GCC 16.2 in ~/.local/gcc-16.2, then g++-16.
./dev bench --save main # on main: load and micro benchmarks, kept as a baseline
./dev bench --compare main # on a branch: the change from main; exits 1 if >10% worse
./dev bench micro # in-process only (seconds)
./dev bench load router wait # chosen load scenarios
./dev bench --vs-rocket load # the same scenarios against Rocket 0.5 too (needs cargo)
./dev bench --vs-h2o load # and against h2o alone: what crocket adds per requestload runs h2load against
crocket_bench (bench/server.cpp) over HTTP/1.1 and h2c, and reports
requests per second, p50 and p99 latency, non-2xx responses, and the server's CPU time per
request (server_us_per_req: user plus kernel, all threads):
| Scenario | Request | Measures |
|---|---|---|
plaintext |
GET /plaintext |
The pipeline with the least work |
json |
GET /json |
Encoding a small struct |
orders |
POST /orders |
Decoding and encoding a 1 KB order |
router |
GET /api/v39/users/7/orders/9 |
A route matched last among about 200 |
wait |
GET /wait |
A handler that blocks its worker for 20 ms |
wait_async |
GET /wait_async |
A handler that suspends for 20 ms without holding a worker |
plaintext_async, json_async, orders_async, router_async |
the same under /async |
The same handlers as Task<T>, which run on the event loop with no handoff |
micro (bench/micro.cpp) times routing, the in-process request
pipeline and JSON, and counts heap allocations per operation. Results are saved in
bench/results/, which git ignores: numbers compare only on the same machine, with the
same load on it. The load generator shares the machine with the server, so treat
differences of a few percent as noise.
When h2load itself runs out of CPU, requests per second measures the client, not the
server, and ./dev bench warns: h2load's threads were 98% busy, so req/s is the client's limit. CPU per request does not depend on the client, so compare that instead. --duration, --clients and --threads (h2load's) change the load. --realistic
attaches Logger and Metrics to the server, as most production apps do.
--vs-rocket builds bench/rocket (Rocket 0.5, the same routes and bodies)
with cargo, runs the same scenarios against it over HTTP/1.1 (Rocket serves HTTP/2 only
over TLS), and prints the two side by side. The load is closed-loop: each connection waits
for its response before sending the next request. So with few connections the numbers
mostly reflect per-request latency, and with many (--clients 256 or more) the server's
throughput, until the client runs out of CPU. The CPU per request columns compare the two
servers whatever the client's limit.
--vs-h2o runs bench/h2o.cpp, h2o with crocket's engine setup (one event
loop per physical core, the same listeners, h2c with prior knowledge) and a handler that
answers from constants with only a content-type header. It covers the plaintext,
json, orders and router scenarios and their _async twins, over HTTP/1.1 and h2c.
The last column, crocket's CPU per request minus h2o's, is what crocket adds to a request:
routing, extractors, JSON, fairings, and the headers it sends by default (x-request-id,
an ETag and Shield's four).
./dev fuzz # every target, 60 s each
./dev fuzz request --seconds 600 # one target, for longer
./dev fuzz json --save # keep inputs that reach new code in tests/fuzz/corpus
./dev fuzz h2 --replay build-fuzz/crashes/crash-h2-… # run one input againA fuzzer feeds generated inputs to code that parses untrusted bytes, looking for crashes, memory errors, hangs and broken invariants. tests/fuzz has a target per parser:
| Target | Input | Also checks |
|---|---|---|
json |
Any JSON document | It writes out and parses back to the same text |
json_typed |
JSON read into a struct with every kind of member, annotation and variant | It writes out and reads back the same |
proto |
A protobuf message with every field kind | It encodes the same twice |
query |
Query strings and percent-encoding | Decoding never lengthens the text |
route |
Paths against a route table, and the same text as a template | A literal template matches its own path |
request |
Whole requests (method, target, headers, body, client address) through the pipeline, with every extractor, Cors, Metrics, trusted proxies, conditional and range requests, and gRPC |
Status is 100–599, response headers are valid, JSON bodies parse |
h2 |
HTTP/2 frame sequences sent to a running server over h2c: bodies with and without content-length, padding, CONTINUATION, window updates, resets, settings |
The server keeps answering, and no request is left in flight |
Each target starts from its seed inputs in tests/fuzz/corpus/<target>/ and mutates
them. ./dev fuzz builds build-fuzz/ with AddressSanitizer and coverage
instrumentation, so an input that reaches new code is kept and mutated further. (crocket
needs GCC, so libFuzzer, which needs clang, is out; the harness in tests/fuzz/fuzz.cpp
does the same job.) A problem leaves the input in build-fuzz/crashes/; fix it, then add
the input to the target's corpus so it stays fixed. In ctest, each target runs its
corpus and two seconds of mutations, without coverage.
| Test | What it checks |
|---|---|
acceptance |
The request pipeline in-process via LocalClient, with no sockets. Covers acceptance criteria 1–4 and 6, plus routing, responders, request ids, log lines, application logging (templates, context, redaction, sampling, levels, a full buffer), metrics, health checks, CORS, pools, the dev profile, header validation, trusted proxies, allowed hosts, Shield, ETags, 304s and ranges. |
json |
JSON in-process: round trips, field paths, the 64-bit range and as_string, UTF-8 checking and repair, duplicate keys, limits, every annotation, chrono, variants, validation, the regex engine (including inputs that make backtracking engines hang) and problem+json bodies from Json<T>. |
cpus |
The default number of event loops: physical cores from CPU topology, counting hyperthread siblings once, honouring the affinity mask, and falling back when topology is missing. |
router |
The route tree against a linear scan of every route, over 2,000 random route tables: the same candidates in the same order, the same captures, HEAD falling back to GET, and the same methods for 405. |
async |
Task<T> handlers in-process: suspension and resumption, request context after a resume, the hop back from a foreign library's thread, callback<T>() (later, now, twice, abandoned), exceptions after a resume, async gRPC methods, and 200 concurrent sleeping handlers. |
compile_fail.* |
Programs that must not compile. Covers acceptance item 5, where the {age} vs years diagnostic must name both identifiers, plus other misuses and a control file that must compile. |
consumer |
tests/consumer, a small application that adds crocket with FetchContent, builds with only crocket::crocket linked (no C++ standard of its own), and serves one request. It also fails if crocket's targets or h2o's options leak into the application's cache. |
tls_h2 |
Real sockets, self-signed TLS, ALPN h2 and http/1.1, and the handler suite over both, including custom headers, X-Request-Id, CORS preflight, 431 for oversized headers and too many headers, a 304 for a matching If-None-Match, security headers, 20 async handlers sharing 2 workers, and a deadline waking a sleeping task, requests queued past their deadline never running their handler, and crocket_workers_stuck for handlers that ignore the deadline. Also adaptive placement: a route promoted after 1,000 fast runs, demoted by one slow run, and a Pool route that is never promoted. Also h2 bodies larger than the flow-control window, chunked uploads, a 413 that keeps the h1 connection, a handler longer than the h2 idle timeout, TLS certificate reload (a replaced file, a broken one, SIGHUP, a request in flight across it), connection limits (waiting at max_connections, closed past max_connections_per_ip), http2_max_streams in SETTINGS, max_body_memory, and graceful drain (acceptance item 7). Needs openssl and a curl built with HTTP/2. Uses ports 18443 and 8000. |
grpc |
Protobuf encoding byte for byte, decode errors, unary calls, status mapping, the generated .proto and mount validation, in-process. |
openapi |
The OpenAPI document for an API using every parameter, body and return type, checked piece by piece (schemas for every member kind and annotation, variants, recursion, name clashes, every $ref resolving), and served by the OpenApi fairing with its docs page. |
db |
crocket::db against a real Postgres: table names, the generated SQL, insert, find, update, erase, every filter, the escape hatch, 409 on a unique violation, a table_name override, and Db& in handlers with its log fields, metrics and /readyz. Needs CROCKET_TEST_DATABASE_URL; skipped without it. |
fuzz.* |
Each fuzz target: its corpus, then two seconds of mutations. |
grpc_h2 |
crocket_grpc over real sockets, driven by curl: h2 with prior knowledge and over TLS, trailers, trailers-only errors, custom metadata, HTTP/1.1 on the h2 port, and 300 KB requests and replies with and without Content-Length. Uses ports 18551 and 18552. |
Config::from_env() reads these, on top of the profile. Unset or empty leaves the
setting alone; a value that does not parse throws std::invalid_argument naming the
variable (CROCKET_PORT=tcp://10.43.0.12:8000: expected a whole number from 0 to 65535),
so a typo stops the program at startup instead of being ignored. Sizes take K, M or
G (powers of 1024); durations take ms, s, m or h, and a bare number is seconds;
booleans are true/false, 1/0, yes/no or on/off; lists are comma-separated.
| Variable | Sets | Default |
|---|---|---|
CROCKET_PROFILE |
dev or release |
release |
CROCKET_LISTEN |
server.listen: host:port, :port or port (replaces host and port) |
unset |
CROCKET_HOST |
server.host |
0.0.0.0 (127.0.0.1 in dev) |
CROCKET_PORT |
server.port |
8000 |
CROCKET_TLS_CERT, CROCKET_TLS_KEY |
server.tls_cert, server.tls_key; TLS needs both |
unset |
CROCKET_HTTP2 |
server.http2 |
true |
CROCKET_H2_PRIOR_KNOWLEDGE |
server.h2_prior_knowledge |
false |
CROCKET_WORKERS |
server.workers |
one per core |
CROCKET_EVENT_LOOPS |
server.event_loops |
one per physical core |
CROCKET_MAX_BODY_BYTES |
limits.max_body_bytes |
1M |
CROCKET_MAX_BODY_MEMORY |
limits.max_body_memory |
256M |
CROCKET_MAX_IN_FLIGHT |
limits.max_in_flight |
1024 |
CROCKET_MAX_HEADER_BYTES |
limits.max_header_bytes |
8K |
CROCKET_MAX_HEADER_COUNT |
limits.max_header_count |
100 |
CROCKET_MAX_CONNECTIONS |
limits.max_connections |
1024 |
CROCKET_MAX_CONNECTIONS_PER_IP |
limits.max_connections_per_ip |
0 (none) |
CROCKET_HTTP2_MAX_STREAMS |
limits.http2_max_streams |
100 |
CROCKET_ETAG_MAX_BYTES |
limits.etag_max_bytes |
1M |
CROCKET_REQUEST_TIMEOUT |
timeouts.request |
30s (an hour in dev) |
CROCKET_DRAIN_TIMEOUT |
timeouts.drain |
10s (1s in dev) |
CROCKET_TRUSTED_PROXIES |
proxy.trusted |
none |
CROCKET_PROXY_HEADER |
proxy.header: x-forwarded-for or forwarded |
x-forwarded-for |
CROCKET_ALLOWED_HOSTS |
proxy.allowed_hosts |
any host |
CROCKET_DEBUG_ROUTES |
debug_routes (GET /__routes) |
false (true in dev) |
CROCKET_ADAPTIVE_PLACEMENT |
adaptive_placement |
true |
CROCKET_LOG_LEVEL |
log.level: debug, info, warn or error |
info (debug in dev) |
CROCKET_DATABASE_URL |
database.url, for crocket::db |
unset (libpq's PG* variables) |
CROCKET_DATABASE_POOL_SIZE |
database.pool_size |
8 |
CROCKET_DATABASE_POOL_WAIT |
database.pool_wait |
5s |
CROCKET_METRICS_LABELS is read by Metrics. crocket_serve
uses Config::from_env() as is, with a 10 s request deadline and 5 s drain as its own
defaults.
crocket is released under the MIT license.
It links h2o statically into your program. h2o is
MIT-licensed, and so are the libraries it bundles into it (picotls, quicly,
picohttpparser, hiredis, libyrmcds, libgkc and cloexec). A binary you distribute must
include their notices: LICENSE in the h2o source, and each library's license under
its deps/ directory. OpenSSL is linked dynamically under the Apache License 2.0, and
zlib under the zlib license.