Writing an HTTP server
12 March 2025
Introduction
I had "write an HTTP server" on my programming bucket list for quite some time. I've also been wanting to learn a bit more about web development, since I program for desktop most of the time, so this seemed like a good opportunity to combine the two.
I am not the first to write an HTTP server in Jai. In fact, it seems like quite a popular project since you already have modules like Jai-HTTP-Server and jai-simplehttp, and probably more, but after looking at these libraries, they didn't necessarily have the features or API design that I would like, which means I have a green light to reinvent the wheel!
The API for my server is very much inspired by the Go http package. I quite like the API since it is very simple and flexible. So before getting into the details, what does a simple "Hello world" server look like?
#import "Basic";
#import "Omen";
HTTP_PORT :: 80;
HTTPS_PORT :: 443;
route_root :: (req: *HTTP_Request, res: *HTTP_Response) {
respond_status_page(200, res);
}
@ServeRoute("/")
main :: () {
http_set_nofile_limit();
http_stop_on_interrupt();
http: HTTP_Server;
http.router = serve_router_init();
register_routes(http.router);
http_enable_ssl(*http, FULLCHAIN, PRIVKEY, CHAIN, ssl_port=HTTPS_PORT);
http_listen_and_serve(*http, port=HTTP_PORT);
}
Quite simple!
Serving HTML rapidly
While my goal was not to specifically "make the fastest HTTP server", I did care about performance. From time to time I see HTTP servers advertising themselves as "the fastest", but then looking closer, it's either doing the bare minimum of HTTP parsing to pass some benchmark, or generally doesn't give you any features that you really want.
Before going into the methods I used, let's take a look at some numbers.
I decided to use bun-http-framework-benchmark as a base, since it seemed easy enough to add custom servers. I then added a server using Go's http package and a Jai server using my framework.
The results on my machine are as follows:
| Framework | Runtime | Average | Ping | Query | Body |
|---|---|---|---|---|---|
| omen | omen | 568,588.75 | 593,233.4 | 578,178.34 | 534,354.51 |
| go | go | 420,275.313 | 413,733.63 | 364,656.74 | 482,435.57 |
| elysia | node | 54,829.067 | 60,485.45 | 57,658.79 | 46,342.96 |
| fastify | node | 48,057.23 | 53,649.02 | 51,104.42 | 39,418.25 |
| h3 | node | 46,914.4 | 62,647.44 | 58,265.76 | 19,830 |
| hono | node | 41,560.31 | 47,649.08 | 44,030.33 | 33,001.52 |
| express | node | 12,724.063 | 13,670 | 12,157.09 | 12,345.1 |
Here omen is using 23 worker threads (on my 24 core CPU). I would like to use a better and more varied benchmark in the future, since ironically this benchmark is currently not even running bun on the latest release, but it should still hopefully showcase that this framework is pretty fast.
Processing requests
To process requests efficiently, we make use of Linux's epoll interface. This allows us to asynchronously wait for events on a specified file descriptor.
On top of epoll, the server is also multi-threaded. It creates an epoll instance for every thread. The general flow is as follows:
- The main thread is listening asynchronously for incoming connections
- Once there is an incoming request, the connection is accepted and round-robin assigned to a thread
- The worker will optionally do an SSL handshake, otherwise it will start listening for incoming data
- The worker thread is now responsible for the whole lifetime of the connection
With this approach, every thread has its own epoll instance with its own connections. There is no posibility of a data race between threads on the same connection.
One thing that can happen if you share a single epoll instance across the thread group is that a request is broken up into multiple writes and now multiple threads can get woken up by these seperate writes.
Memory management
Jai is a language that has manual memory management. It has a sophisticated allocator system where you can define how memory is allocated for your program or parts of your program.
Every worker thread has a Flat_Pool, which is a kind of Arena allocator that works on reserved memory pages. It reserved a certain amount of memory, and whenever you request more memory, it will commit from the reserved pages.
The nice part of this is that all of the memory can be freed with a single call to reset the pointer head back to the base of the committed memory. What this means is that every request can allocate memory as they please, and you don't have to worry about freeing it. As soon as the request is fully sent off, the pool is reset and ready for the next request.
This did have certain design implications. There is a procedure called parse_request(), which takes the header string and parses it into an HTTP_Request. When I was still using the malloc-style allocator, I was using a string builder to read the headers, then parse them, and then read the body, and then get the resulting string.
This worked but I realized that it was slower than it needed to be. Instead we can read the request directly into our pool and save the overhead of allocating the string builder.
One structural change I had to make to account for the Flat_Pool, is that I could not allocate any memory until the full body has been read, since any allocations would show up in the request body. I used to call parse_request() after receiving the headers so that I knew the Content-Length, however now I would have to add pre_parse_request(), to just parse the Content-Length without allocating any data.
Afterwards I can call parse_request() like I used to and allocate as I please.
Features
My HTTP server has the following features:
- Routing
- HTTPS/SSL
- Form files
- Templates
- Compression
- Accept-Ranges (only single range at the moment)
- More (cookies, cache, fetch)
All core HTTP 1.1 features are supported.
Routers
There is a generic routing interface. It comes with a few routers, but the one you probably want to use is the Serve_Router.
The serve router is similar to how routing is done in Go. You can register a route like so:
// In your main function:
// Router interface needs to be initialized to serve router before registering
http.router = serve_router_init();
serve_route(http.router, "/", (req: *HTTP_Request, res: *HTTP_Response) {
set_header(*res.header, "Content-Type", "text/plain");
res.body = "Hello world!";
});
There is also an alternative way using the metaprogram, where you can define it like this:
serve_root :: (req: *HTTP_Request, res: *HTTP_Response) {
set_header(*res.header, "Content-Type", "text/plain");
res.body = "Hello world!";
}
@ServeRoute("/");
// In your main function:
http.router = serve_router_init();
register_routes(http.router);
The serve router supports wildcard, like /img/*, or URL parameters, like /profile/:id.
HTTPS/SSL
Omen supports SSL through OpenSSL. Like shown above, you can enable SSL by calling http_enable_ssl(). Any HTTP connections will automatically be redirected to HTTPS. This behaviour can be disabled by setting HTTP_Server.auto_redirect_to_https = false.
Getting OpenSSL to work properly was quite the journey, and probably one of the hardest things to get right in the whole project.
Juggling HTTP and HTTPS
One problem arose when implementing HTTPS. We can have connections come in from both the HTTP port (usually 80), and the HTTPS port (usually 443). We don't want to accidentally do an SSL_read() or SSL_write() on an HTTP connection and vice-versa.
epoll does give us a generic data field in the form of the following union:
union epoll_data {
void *ptr;
int fd;
uint32_t u32;
uint64_t u64;
};
When it was just HTTP, I just stored the file descriptor into the fd field and left it at that. The slightly cursed solution that I came up with is taking advantage of the fact that file descriptors are only 32-bit.
I have the following magic number:
FD_NO_SSL :: cast(u32) 0xFDFDFD;
In the ptr field, we now either store the pointer to the OpenSSL *SSL struct, or we store FD_NO_SSL in the high bits and then the file descriptor in the low bits. The achilles heel here is that if somehow the *SSL pointer returned to us from OpenSSL equals FD_NO_SSL in the high bits, it will think that the HTTPS connection is actually HTTP.
Form data
Usually, POST paramters are passed in the same format as GET parameters, which is using application/x-www-form-urlencoded, however, for things like files, or other pieces of data that can have &, multipart/form-data is used instead.
If you are expecting form data to be present, you can parse it using parse_form_data(). This will parse both of the types mentioned above.
Templates
This is where things get interesting. There is a sort of "templating" system. From the usage side, it might look like this:
<!DOCTYPE html>
<html>
<head>
<!-- ... -->
</head>
<body>
<|navbar current-page="home"|>
<!-- ... -->
</body>
</html>
You can pass the above file or string to either render_html() or render_html_from_file(). When it encounters navbar, it will search for a template renderer.
A template renderer is defined as the following:
navbar_template_render :: (props: *HTTP_Params) -> string {
current_page := get_prop(props, "current-page", fallback="undefined");
return tprint("<p>Current page is: %</p>", current_page);
}
@TemplateRenderer("navbar")
As you can see, the template rendering itself is quite pure. You don't have access to either the request or the response. The only thing you have access to is the nonce, using context.nonce.
This might change in the future, but for now it is recommended to expose the data you want accessible through functions.
Jai in HTML
Making these template renderers could be a bit cumbersome. Luckily there is a way to reverse the roles and have mostly HTML files with some Jai code in between for echoing HTML à la PHP.
The way to do this is by creating a .jhtml file. Let's go back to the navbar example. It might look like this:
<p>{{current-page}}</p>
In jhtml, anything that is surrounded by two curly braces, gets replaced by a property of the same name. Since we passed current-page="home", {{current-page}} gets replaced by home.
Let's say that we want to return different HTML if we were actually home. jhtml supports code tags using <% and %>.
<div>
<%
current_page := get_prop("current-page");
if current_page == "home" {
echo("<h1>You are home!</h1>");
} else {
echo("<h2>Not home, but you are %...</h2>", current_page);
}
%>
</div>
When you call generate_template_renderer_from_file(), it will convert the following file, into the following Jai function (slightly abbreviated for clarity):
// Template generated from public/templates/navbar.jhtml
_render_navbar_template :: (_props: *HTTP_Params) -> string {
_html_builder: String_Builder;
append(*_html_builder, #string _OMEN_HTML
<div>
_OMEN_HTML);
// Inserting template code -->
current_page := get_prop("current-page");
if current_page == "home" {
echo("<h1>You are home!</h1>");
} else {
echo("<h2>Not home, but you are %...</h2>", current_page);
}
// <-- Inserted template code
append(*_html_builder, #string _OMEN_HTML
</div>
_OMEN_HTML);
_html := builder_to_string(*_html_builder);
for _props {
_html = replace(_html, tprint("{{%}}", it_index), it,, temp);
}
return _html;
}
@TemplateRenderer("navbar")
It will parse the file until it finds a <%, and everything afterwards is inserted like regular code. Then once it reached %>, everything is considered HTML again and inserted as a string.
This is quite a simple addition, but it is very convenient. It is all rendered on the server and should be quite fast. If your page relies on something slow (like a database read), you should probably just cache the resulting HTML and serve that intead.
Since this is a compiled language, this function is generated at compile-time. If you change the template, you will have to recompile.
Compression
It is possible to compress the response body using Brotli. You can set the theshold for size in bytes and it will automatically compress it before sending it out.
More
Cookies
Nothing much to say here, but it supports reading and writing Cookies. It's quite simple with get_cookie(), set_cookie() and delete_cookie().
Cache
There is an abstract caching interface similar to the Router interface. The cache has 4 operations:
- Deinit: free the cache and any user data
- Put: put something into the cache. Specify parameters like overwriting/time expiriy/etc
- Get: gets something from the cache
- Invalidate: invalidate something from the cache
By default it only comes with a thread-safe LRU cache that supports constraints like number of entries or a max overall size in bytes.
It's quite a simple yet effective cache for a lot of needs.
Other
There are some other nice features as well, such as adding MD5-based ETag headers, limiting the size of a request (including header size), fetching using Curl, and probably smaller things that I am forgetting.
I quite like how it turned out and I still tinker on it from time to time. This very blog is running on my server and it's quite fast (though it is just a static blog site to be fair)
There are a few things I would like to explore in the future. One is Websockets, but the protocal seems kinda wacky. I will probably only look into this once I have a use-case for it.
The other is HTTP 2.0. I am not sure how much I stand to gain by updating from HTTP 1.1 to 2.0, or how difficult it is to conform to it, but I'll give it a cursory glance.
And that's all I have to say!