← Sunil Khorwal

Building a Web Framework in C: Linus Server + Custom DSL Compiler

Published on January 23, 2025  ·  Systems Programming, C, HTTP, DSL, Compilers

Most web frameworks are built on top of other frameworks. Express sits on Node's http module. Laravel sits on PHP's runtime. I wanted to go further down — build a server that speaks HTTP from raw POSIX sockets, has its own routing syntax parsed by a hand-written scanner, and scaffolds new projects through a compiled CLI tool. No runtime. No interpreter. Just C, file descriptors, and fork().

This is the story of Linus — two linked repos: linus_framework (the server) and linus_compiler (the scaffolding tool and DSL parser).


The Server — Raw Sockets, No Dependencies

The core of linus_framework is a TCP server built with getaddrinfo(), bind(), listen(), and accept() — the raw Berkeley socket API. No libevent, no libuv, nothing in between. The main loop accepts connections in a round-robin slot array and spawns a child process per connection via fork():

linus.c — accept loop
while (1) { addrlen = sizeof(clientaddr); clients[slot] = accept(listenfd, (struct sockaddr *)&clientaddr, &addrlen); inet_ntop(AF_INET, &(clientaddr.sin_addr), buffer, len); systemReInit(buffer); // per-connection state reset if (fork() == 0) { respond(slot); // child handles the request exit(0); } while (clients[slot] != -1) slot = (slot + 1) % CONNMAX; }

The respond() function reads the raw HTTP request into a buffer, uses strtok() to split the request line on spaces, checks for GET, validates the HTTP version string, and serves files directly from disk using read() + write() onto the socket file descriptor. There is no JSON parser, no middleware stack, no event loop. The request lifecycle is three system calls.


The Routing DSL — .hk Files

Rather than define routes in C code directly, Linus uses a small custom file format — .hk files. The application descriptor (main.hk) declares the app name and the paths for routes, controllers, and storage:

main.hk — application descriptor
app = "linus"; app.register = [ r => app.path()+"/route", c => app.path()+"/controller", s => app.path()+"/story" ];

Route definitions live in r.hk files inside the route/ directory. The syntax is a custom DSL:

route/r.hk — route definition syntax
GET:/users@UserController;index

That single line encodes four fields: HTTP method (GET), path (/users), controller name (UserController), and handler function (index). The Route.c parser reads this character by character, using state flags toggled by the delimiter characters :, @, and ;:

Route.c — hand-written route scanner
while ((c = fgetc(f)) != EOF) { if (c == ':' && method_flag == 0) { method_flag = 1; // end of method token method[n++] = '\0'; n = 0; } else if (c == ':' && method_flag == 1 && path_flag == 0) { path_flag = 1; // end of path token path[n++] = '\0'; n = 0; } else if (c == '@' && path_flag == 1 && controller_flag == 0) { controller_flag = 1; // end of controller token controller[n++] = '\0'; n = 0; } else if (c == ';' && controller_flag == 1) { function_flag = 1; // end of function token function[n++] = '\0'; break; } else { // append character to whichever token is currently open if (!method_flag) method[n++] = c; if (method_flag && !path_flag) path[n++] = c; if (path_flag && !controller_flag) controller[n++] = c; if (controller_flag && !function_flag) function[n++] = c; } }

This is essentially a four-state finite automaton, advancing state on each delimiter character. No regex, no sscanf, no string splitting — just a single fgetc loop. It's the kind of parser you write when you understand what a lexer actually is, not just how to use one.


The Compiler / Scaffolding Tool

linus_compiler is the CLI scaffolding tool linked to the framework. When you run it with a project name, it creates the project directory structure and writes a skeleton r.c file with a default route already in place — the equivalent of rails new, but compiled to a native binary from C.

linus_framework/linus.c — project scaffolding
// Creates the project directory char *Pr(char *argv[], char *cwd) { char *path = concat(cwd, argv[1]); mkdir(path, S_IRWXU | S_IRWXG | S_IRWXO); return path; } // Writes the starter route file void Rc(char *path) { FILE *f = fopen(createFile(path, "/r.c"), "w"); fprintf(f, "/*r.c file is the main file*/\n" " r.g['/',function(){\n" "return 'hello world';\n" "}];"); fclose(f); }

The server side is wired to invoke the compiled compiler binary via popen(), feeding it route file paths and reading back the output — a pipeline that connects the runtime server to the build tool at the process level.


Why C for a Web Framework?

The standard answer for web frameworks is "because Python/Ruby/Go is expressive enough." But that abstraction has a cost — you never actually understand what happens when a browser makes a connection. Writing Linus forced me to confront every layer: the TCP three-way handshake landing in the kernel's accept queue, the recv() syscall blocking until bytes arrive, strtok being destructive (it modifies the buffer in place, which matters when you're juggling multiple request fields), and why fork() gives you per-request isolation without threads.

The DSL parser taught me something more specific: a hand-written character scanner with explicit state flags is not primitive — it's exactly how the first pass of every real compiler works. The Linus route parser is structurally identical to the tokenizer phase of GCC or Clang, just with a different token grammar.

73, Sunil