Vermell – Minimal, dependency-free C++ web framework using epoll

Sep 01, 2026 11:56 AM - 1 day ago 3
image

A minimal, zero-bloat web model designed for modern C++ environments. Fast, structural, and strictly typed.

C++20 Linux epoll Zero dependencies CMake Docker  MIT

Vermell is simply a web model for modern C++ environments: 1 header to include, 1 fixed room to link, and thing else. No runtime, nary garbage collector, nary framework-specific DSL, nary vendored limitations — what you constitute is C++, and what runs is C++.

Under the hood it is an event-driven engine: a non-blocking epoll loop sounds requests and hands activity to a excavation of worker threads. That divided is what makes Vermell accelerated nether load and resilient against slow clients.

  • Zero dependencies — only guidelines Linux APIs (sockets, epoll, pthreads, fork/exec).
  • One bid to build — g++ -std=c++20 server.cpp -o exe -lvermell.
  • Any Linux pinch g++ — x86_64, ARM (aarch64, armv7), Android via Termux, WSL, Raspberry Pi, containers.
  • Hardened by default — timeouts, petition caps, relationship limits and a render jailhouse are connected retired of the box.
  • In-tree JSON DOM — strict RFC 8259 parser and serializer, typed parameters, earthy bodies, multipart uploads.
  • C++ templates — compose() modules and render() variables.
  • Fluent configuration — 1 configure({...}) telephone aliases chainable setters, readable astatine runtime.

📚 Full documentation: vermell.cc — bilingual (EN/ES) manual covering each conception of this README pinch examples and diagrams.

  1. Installation
    • CMake
    • npx
    • Docker
  2. Quick Start
  3. Compile
  4. Routing & Handlers
    • Lambda captures
  5. Server Configuration
  6. MIME Types & File Rendering
  7. Static Directories
  8. Templates: constitute & render
  9. Render Security
  10. Process & Environment
  11. Examples
  12. Support
  13. Testing
  14. Contribution
  15. License

CMake

$ git clone https://github.com/vermellcc/vermell.git $ cd Vermell $ cmake . $ cmake --build . $ make install

NodeJS

Ready-to-use scaffold:

$ npx create-vermell-static

Docker

$ docker propulsion vermellcc/vermell

Debian

Packages for amd64, arm64 and armhf unrecorded connected GitHub Pages, signed and fresh to add:

$ sudo instal -d -m 0755 /etc/apt/keyrings $ curl -fsSL https://vermellcc.github.io/vermell/vermell-apt-key.asc | sudo gpg --dearmor --yes -o /etc/apt/keyrings/vermell.gpg $ echo "deb [signed-by=/etc/apt/keyrings/vermell.gpg] https://vermellcc.github.io/vermell unchangeable main" | sudo tee /etc/apt/sources.list.d/vermell.list $ sudo apt-get update $ sudo apt-get instal -y libvermell

Key fingerprint: 022D 56AA 7A6B 2028 B005 3629 F616 54D8 8AD1 C323

A Vermell server is simply a Router: registry a handler for a route, take a port, and telephone listen().

#include <vermell/vermell.h> int main() { Router router; router.setPort(8080); router.get("/", { [](Query &http) { http.send("Hello from Vermell"); }}); router.listen(); }

The { ... } astir the handler matter. The 2nd statement of router.get(...) is simply a MiddlewareList, truthful handlers are ever passed arsenic a braced list: router.get("/", { [](Query &http) { ... } }).

Compile and run:

$ g++ -std=c++20 server.cpp -o exe -lvermell $ ./exe

Then constituent your browser aliases curl astatine it:

$ curl http://localhost:8080/ Hello from Vermell

router.listen() blocks and serves forever. listenOne() serves a azygous petition and returns — useful for tests and one-shot servers.

A azygous g++ invocation compiles and links everything — nary other flags, nary nexus bid games:

$ g++ -std=c++20 server.cpp -o exe -lvermell

For larger projects usage CMake, but a server is ever 1 bid away.

Portability. Vermell has nary dependencies, truthful thing that derives from Linux and has a C++20 g++ tin build it: x86_64, ARM (aarch64, armv7), Android via Termux, WSL, Raspberry Pi, containers. macOS and Windows are not supported targets (epoll).

No root? No problem. On Termux (or immoderate strategy without root) you cannot make instal into /usr/local. Include the header by comparative way (#include "../include/vermell/vermell.h") and nexus the fixed room straight — transcript libvermell.a adjacent to your sources and compile pinch -L. -lvermell:

// Termux / no-root build: header referenced by comparative path #include "../include/vermell/vermell.h" int main() { Router router; router.setPort(8080); router.get("/", { [](Query &http) { http.send("hi from termux"); }}); router.listen(); }
$ cp libvermell.a . # fixed room adjacent to the sources $ g++ -std=c++20 server.cpp -o exe -L. -lvermell $ ./exe

The router exposes 1 registration method per HTTP verb. Static routes dispatch successful O(1) done a transparent-hash way map.

router.get("/users", { [](Query &web) { web.send("list"); } }); router.post("/users", { [](Query &web) { web.send("create"); } }); router.put("/users/:id", { [](Query &web) { web.send("update"); } }); router.deleteX("/users/:id", { [](Query &web) { web.send("delete"); } }); router.patch("/users/:id", { [](Query &web) { web.send("patch"); } }); router.head("/status", { [](Query &web) { web.send("head"); } }); router.options("/ping", { [](Query &web) { web.send("options"); } }); router.link("/rel", { [](Query &web) { web.send("link"); } }); router.unlink("/unlink", { [](Query &web) { web.send("unlink"); } }); router.purge("/cache", { [](Query &web) { web.send("purge"); } });

Note the deleteX() name: delete is simply a C++ keyword. For larger applications, state routes separately and equine them pinch router.use():

// routes.cpp — separated declaration Route_t users_routes("/users/:id", { [](Query &web) { web.json(R"({"op":"get"})"); } }, GET_TYPE); // main.cpp — mounting router.use(users_routes); router.use(admin_routes);

Every handler is simply a C++ lambda void(Query&). The seizure database betwixt [ and ] decides really extracurricular authorities reaches it:

string app_name = "vermell-demo"; int larboard = 8080; // [] — thing captured: the handler only sees the Query router.get("/ping", { [](Query &web) { web.json(R"({"pong":true})"); }}); // [=] — extracurricular values get BY COPY: a backstage snapshot router.get("/name", { [=](Query &web) { web.send(app_name); // sounds a transcript made astatine registration }}); // [&] — extracurricular variables get BY REFERENCE: a unrecorded view router.get("/info", { [&](Query &web) { web.send(app_name + ":" + std::to_string(port)); }}); // named captures — only what you need: // [port] -> transcript of larboard [&port] -> reference to port // [this] -> enclosing entity [=, &port] -> each by copy, larboard by ref
Capture Meaning
[] No seizure — the handler only receives the Query.
[=] Every utilized extracurricular adaptable by copy (snapshot astatine creation).
[&] Every utilized extracurricular adaptable by reference (live aliases).
[x] / [&x] Named capture: transcript of x, aliases reference to x.
[this] Capture the enclosing people (members by reference).
[=, &x] Everything by copy, isolated from x by reference.

Thread safety. Handlers tally connected worker threads and unrecorded for the full server lifetime. [&] captures are references to the registering scope: good for variables that outlive listen(), but ne'er seizure stack locals that dice earlier — that is simply a dangling reference. Because requests tally concurrently, shared mutable authorities captured by reference needs a mutex; for illustration [=] for immutable snapshots.

Every knob of the request/response pipeline lives successful vermell::Config (include/vermell/config.hpp). Pass it full pinch router.configure({...}) (defaults sphere the bequest behavior):

router.configure({ // network .backlog = SOMAXCONN, // pending connections queue of listen() .reuse_port = false, // SO_REUSEPORT: OFF by default (a same-UID // process could different hindrance the larboard and // intercept a stock of the traffic) // petition reading .read_timeout = std::chrono::seconds{30}, // inactivity betwixt chunks .request_timeout = std::chrono::seconds{60}, // full deadline for the whole // petition to get (slowloris cure) .write_timeout = std::chrono::seconds{10}, // inactivity while responding .max_request_size = 16UL * 1024UL * 1024UL, // bigger => 413 Payload Too Large .read_chunk = 32UL * 1024UL, // bytes publication per recv() call // concurrency / epoll .threads = 4, // worker threads; 0 = car (hardware_concurrency) .max_events = 1024, // epoll arena batch size .max_queue_size = 512, // queued tasks earlier the dispatcher sheds load .max_connections = 1024, // difficult headdress connected unfastened connections; 0 = unlimited .epoll_timeout = std::chrono::milliseconds{1000}, });

Hardening defaults: read_chunk is clamped to [1, 1 MiB], max_events to [1, 65536], threads to [0, 256] and each timeout to [1ms, INT_MAX ms] — absurd values are a memory/DoS foot-gun, not a feature. Requests to HTTP/1.1 (or newer) without precisely 1 Host header are rejected pinch 400 (RFC 9112 §3.2, proxy desync / request-smuggling vector); HTTP/1.0 bequest clients support working. max_connections is bounded by default (1024) truthful a relationship flood cannot exhaust memory.

Slowloris is not a DoS anymore: petition bytes are publication connected the arena loop (non-blocking), truthful a trickling customer occupies an epoll fd — bounded by max_connections and the read_timeout/request_timeout deadlines — ne'er a worker thread. A customer that sends 1 byte each fewer seconds for hours is dropped pinch 408 arsenic soon arsenic the full petition exceeds request_timeout. When the task queue is afloat the dispatcher sheds the relationship (503) alternatively of stalling the judge loop.

Or usage the chainable setters:

router.setThreads(4) .setMaxRequestSize(16UL * 1024UL * 1024UL) .setReadTimeout(std::chrono::seconds{30}); // setWriteTimeout, setRequestTimeout, setReadChunkSize, setMaxEvents, // setMaxQueueSize, setMaxConnections, setBacklog, setBufferSize, // setPort, setReusePort

configure() replaces the WHOLE configuration (designated initializers recommended): settings made earlier pinch the setters are discarded, truthful walk everything successful 1 call. configure() besides applies to a running server — timeouts, limits and the thread count are picked up unrecorded by the arena loop and the worker excavation (RequestIO::ApplyConfig); only the network-side knobs (port, backlog, reuse_port) request a restart.

The progressive configuration is readable astatine runtime pinch router.config(). A afloat annotated illustration lives successful examples/configuration.

MIME Types & File Rendering

Vermell detects the Content-Type from the last hold of a file. This intends kevin.txt.html is served arsenic text/html, and matching is case-insensitive. Query strings and fragments are ignored erstwhile determining the type. Unknown extensions usage application/octet-stream.

web.readFile("public/data.json"); // application/json web.file("public/assets/app.js"); // application/javascript web.send("{}", vermell::mime::json); // reusable communal MIME constants

An definitive type passed to readFile ever takes precedence. The registry includes communal text, data, document, image, audio, video, font, archive and executable formats.

Node's express.static arsenic a Vermell mount: hindrance a disk directory — a Vue, React aliases Angular dist folder, plain assets, thing — to a URL prefix pinch 1 call. fixed is simply a C++ keyword, hence the X suffix (same normal arsenic deleteX).

// SPA dist astatine the tract root: heavy links and refreshes autumn backmost to index.html router.staticX("/", "./dist", { .spa = true, .max_age = std::chrono::days{30}, // Cache-Control: public, max-age=2592000 }); // classical mount: only ./public/assets is served astatine /assets router.staticX("/assets", "./public/assets");
  • Routes ever win. Static mounts are the fallback layer: an nonstop way for illustration /api/health is ne'er shadowed, and the most specific equine answers (a /assets equine thumps a guidelines / equine for /assets/...; ties spell to the first registered).
  • The equine directory IS the jail. No petition way tin flight it — percent-encoded .. (/%2e%2e/...), NUL bytes and symlinked escapes are rejected pinch 403. Files are served pinch the aforesaid hardening arsenic readFile: regular files only, O_NOFOLLOW, size headdress (StaticOptions::max_file_bytes).
  • Directories reply their scale file (index.html by default). With .spa = existent immoderate missing record answers the scale alternatively of 404, truthful Vue/React/Angular client-side routes activity connected refresh and heavy links. .spa is OFF by default: a missing plus is simply a 404, ne'er silently HTML.
  • Caching connected by default: Cache-Control: public, max-age=N (N = StaticOptions::max_age; 0 = revalidate each request, the definitive default), a beardown ETag (size + mtime) and conditional GET → 304 Not Modified. .cache = mendacious disables each caching header.
  • GET/HEAD only. Other methods support the generic 404 semantics.

Full options unrecorded successful vermell::StaticOptions (include/vermell/util/static_files.h). A complete illustration — SPA dist + classical equine + a JSON API broadside by broadside — is successful examples/static.

Templates: constitute & render

compose() assembles an HTML page from modules referenced arsenic #[name]; wrong the template. A page that (transitively) includes itself answers 413 alternatively of exhausting memory:

// index.html: <body> #[header]; #[main]; </body> router.get("/", { [](Query &web) { web.compose("./index.html", 2); // 2 module passes }});

render() fills [[variable]] placeholders successful an HTML template done a dataRender callback:

// data.html: <h1>[[name]]</h1> <p>age: [[age]]</p> router.get("/", { [](Query &web) { web.render("./data.html", [&](dataRender &Data) { Data("name", "kevin"); // [[name]] successful the html file Data("age", "21"); // [[age]] return Data; }); }});

The file-rendering methods (readFile, file, compose, render) are hardened done Config::render:

router.configure({ .render = { .root = "public/", // jail: nary way escapes this directory .max_file_bytes = 32UL * 1024 * 1024, }, });
  • All readers service regular files only (no FIFOs/devices, symlinks are rejected via O_NOFOLLOW), headdress the size successful memory, and ne'er leak soul errors to the client.
  • The jailhouse is ON moreover without .root: an quiet render.root falls backmost to the moving directory, truthful a server that ne'er configured a guidelines tin still not service files from extracurricular its motorboat directory (no much open-by-default Local File Inclusion). Set .root to a dedicated public/ directory successful production.
  • compose() module names (#[name];) are restricted to bare record names, truthful #[../../etc/passwd]; is rejected, and the composed page is capped astatine max_file_bytes per walk — a module that (transitively) includes itself answers 413 alternatively of exhausting memory.

Node.js-style runtime accusation and configuration, disposable conscionable by including vermell/vermell.h.

vermell::process captures the process information erstwhile (first use):

vermell::process.pwd // directory containing the executable vermell::process.cwd // moving directory it was launched from vermell::process.exec_path // absolute way of the executable vermell::process.pid // process id (also ppid, argv, hostname, // username, platform, arch) vermell::process.uptime() // seconds since the process started vermell::process.memory_usage() // resident representation successful bytes vermell::process.path(".env") // way resolved against the executable directory

vermell::environment loads the .env record sitting next to the executable automatically, and besides holds runtime "session" values. Values from the record and set() return precedence complete the OS environment; each method is thread-safe.

vermell::environment.get("TOKEN") // .env / set(), other OS env, other "" vermell::environment.get("TOKEN", "fallback") vermell::environment.get_as<int>("PORT", 8080) // typed: arithmetic, bool, string vermell::environment["TOKEN"] vermell::environment.set("request_count", "1") // runtime convention value vermell::environment.reload() // re-read the .env file vermell::environment.load("config/.env") // aliases load different file

The .env syntax supports # comments, export KEY=VALUE, quoted values and trailing comments. See examples/process and examples/environment.

In the examples/ files you'll find self-contained servers for the different usage cases:

  • basics: hello-world, types-routes (all HTTP methods), callbacks
  • requests: parameters-methods (typed as<T>(), fallbacks), request-body (raw JSON/text bodies), upload (multipart files), headers
  • responses: simple-json, files (auto-MIME pinch file()), static (dist folders pinch router.staticX: SPA fallback, cache, ETag/304), file-template (compose), data-template (render)
  • server: configuration (router.configure, thread pool, timeouts), route-cooling (web.guard), graceful-shutdown, middlewares, router (route separation pinch Route_t + use), process (vermell::process runtime info), environment (.env + convention values pinch vermell::environment)

Linux

CMake / CTest:

$ cmake -DTESTING=ON -S. -B build $ cmake --build build/ $ cd build $ ctest

with NPM:

$ npm tally build $ npm tally test

tests/debug.cpp is simply a scratchpad for testing Vermell against the installed library — it is deliberately not portion of the CMake build. Copy libvermell.a next to it and compile it by hand:

$ g++ -std=c++20 tests/debug.cpp -o debug -L. -lvermell -pthread

and edit the record tests/debug.cpp freely.

Contributions are welcome! If you want to lend to Vermell, please travel these guidelines:

  • Fork the repository.
  • Create a branch for your caller characteristic (git checkout -b feature/new-feature).
  • Make your changes and perpetrate meaningful messages (see COMMIT_FORMAT.MD).
  • Push your branch (git push root feature/new-feature).
  • Create a propulsion request.

Please publication CODE_OF_CONDUCT.md earlier contributing, and usage the rumor templates successful .github/ISSUE_TEMPLATE for bug reports and characteristic requests.

This task is licensed nether the MIT License.

More