Every web framework in existence makes you install a server. Express requires Node.js. Flask requires Python and Werkzeug. Rails requires Ruby and Puma. Django requires WSGI and Gunicorn. Even Go, which prides itself on having "batteries included," still makes you import net/http and write boilerplate to get a server running.
FLIN ships the server inside the language runtime itself. There is nothing to install, nothing to configure, and nothing to import. You write a .flin file with a view section, run flin dev, and a production-grade HTTP server starts on port 3000. That is the entire workflow.
This article explains how we built that server, why embedding it was the only sane choice for a language designed to eliminate complexity, and what happens under the hood when a request arrives at a FLIN application.
Why Not Just Use Hyper or Axum?
The question came up immediately during the design phase. Rust has excellent HTTP server libraries. Hyper is battle-tested. Axum provides elegant routing. Actix-web delivers raw performance. Why not lean on one of them?
The answer is that FLIN is not a Rust web framework. FLIN is a programming language with its own runtime, its own bytecode interpreter, and its own reactivity system. An HTTP request in FLIN does not call a Rust function directly -- it triggers bytecode execution inside the FLIN virtual machine, which reads reactive variables, evaluates view templates, and produces HTML output. Bolting Axum onto this would mean bridging two completely different execution models with an FFI layer that adds complexity, latency, and debugging nightmares.
Instead, we built a minimal HTTP server directly into the FLIN runtime. The server is approximately 400 lines of Rust code. It handles TCP connections, parses HTTP/1.1 requests, routes them through the middleware chain, executes the appropriate FLIN bytecode, and writes the response. No framework. No middleware crate. No router library.
pub struct HttpServer {
listener: TcpListener,
router: Router,
middleware_chain: Vec<MiddlewareHandler>,
static_dir: Option<PathBuf>,
session_store: SessionStore,
}impl HttpServer {
pub fn new(port: u16) -> Result
Ok(Self { listener, router: Router::new(), middleware_chain: Vec::new(), static_dir: None, session_store: SessionStore::new(), }) } } ```
Five fields. A TCP listener, a router, a middleware chain, an optional static file directory, and a session store. This is the entire state of a FLIN web server.
The Request Lifecycle
When a browser sends a request to a FLIN application, it passes through a clearly defined pipeline. Understanding this pipeline is essential for understanding how FLIN turns .flin files into web pages.
Step 1: Connection Acceptance
The TCP listener accepts the connection and spawns an async task to handle it. FLIN uses Rust's tokio runtime for async I/O, but this is an implementation detail invisible to FLIN developers.
pub async fn serve(&self, vm: &mut VirtualMachine) -> Result<(), ServerError> {
loop {
let (stream, addr) = self.listener.accept().await?;
let router = self.router.clone();
let middleware = self.middleware_chain.clone();
let sessions = self.session_store.clone();tokio::spawn(async move { if let Err(e) = handle_connection(stream, addr, router, middleware, sessions).await { eprintln!("Request error from {}: {}", addr, e); } }); } } ```
Step 2: Request Parsing
The raw TCP stream is parsed into a structured Request object. FLIN parses the HTTP method, path, query parameters, headers, and body. JSON bodies are automatically deserialized into FLIN values. Form-encoded bodies are parsed into key-value pairs. Multipart bodies are handled for file uploads.
pub struct Request {
pub method: Method,
pub path: String,
pub query: HashMap<String, String>,
pub headers: HashMap<String, String>,
pub body: RequestBody,
pub params: HashMap<String, String>, // URL parameters from router
pub ip: String,
pub cookies: HashMap<String, String>,
pub session: Session,
}pub enum RequestBody {
None,
Json(Value), // Parsed JSON
Form(HashMap
This automatic body parsing eliminates an entire category of boilerplate. In Express, you need body-parser or express.json(). In Flask, you call request.get_json(). In FLIN, the body is already parsed and available as body inside your route handler.
Step 3: Routing
The parsed request is matched against the file-based route table. FLIN builds this table at startup by scanning the app/ directory:
app/index.flin -> GET /
app/about.flin -> GET /about
app/api/users.flin -> /api/users (route blocks)
app/api/users/[id].flin -> /api/users/:id (route blocks)
app/blog/[...slug].flin -> /blog/* (catch-all)The router performs this matching in O(log n) time using a trie structure. Dynamic segments like [id] are extracted into params.id. Catch-all segments like [...slug] capture the entire remaining path.
Step 4: Middleware Execution
Before the route handler runs, the request passes through the middleware chain. Middleware files are named _middleware.flin and apply to their directory and all subdirectories. They execute in order from the root to the most specific directory.
Step 5: Route Handling
For view files, the FLIN VM executes the file's bytecode with the request context injected. Reactive variables are evaluated, the view template is rendered to HTML, and the result is sent as the response. For API routes, the matching route block is executed and its return value is serialized to JSON.
Step 6: Response Writing
The response is written back to the TCP stream with appropriate headers, status code, and body.
What Zero-Config Actually Means
In every other framework, getting a server running requires a configuration ritual. Here is what it takes to serve a "Hello, World" page in popular frameworks:
In Express.js, you create package.json, run npm init, install Express, create index.js, import Express, call express(), define a route, and call app.listen(). That is 7 steps and at least 15 lines of code before "Hello, World" appears.
In FLIN:
// app/index.flin
<h1>Hello, World</h1>One line. Run flin dev. The server starts on port 3000. The page renders. There is no package.json. There is no npm install. There is no import statement. There is no configuration file. The HTTP server is part of the language, not a library you bolt on.
This is not a toy server. It handles concurrent connections, parses all standard content types, serves static files, manages sessions, and includes security headers by default. The simplicity is in the developer experience, not in the implementation.
Static File Serving
FLIN serves static files from the public/ directory at the root of the project. Images, CSS files, JavaScript files, fonts -- anything in public/ is served directly without passing through the FLIN runtime.
async fn serve_static(path: &str, static_dir: &Path) -> Option<Response> {
let file_path = static_dir.join(path.trim_start_matches('/'));// Prevent directory traversal if !file_path.starts_with(static_dir) { return Some(Response::forbidden()); }
if file_path.is_file() { let content = tokio::fs::read(&file_path).await.ok()?; let mime = mime_from_extension(&file_path);
Some(Response::ok() .header("Content-Type", mime) .header("Cache-Control", "public, max-age=86400") .body(content)) } else { None } } ```
Notice the directory traversal check on line 5. A request for ../../etc/passwd resolves to a path outside static_dir and is rejected immediately. This is one of many security measures baked into the server at the implementation level, invisible to FLIN developers but protecting every application by default.
Server-Sent Events for Hot Reload
During development, FLIN injects a Server-Sent Events (SSE) endpoint into the application. When a .flin file changes on disk, the runtime recompiles it and sends an event to the browser, which triggers a page reload. The entire cycle -- file save to browser update -- takes under 50 milliseconds on typical hardware.
pub async fn sse_handler(stream: &mut TcpStream, rx: Receiver<ReloadEvent>) {
// Write SSE headers
let headers = "HTTP/1.1 200 OK\r\n\
Content-Type: text/event-stream\r\n\
Cache-Control: no-cache\r\n\
Connection: keep-alive\r\n\r\n";
stream.write_all(headers.as_bytes()).await.unwrap();// Stream reload events while let Ok(event) = rx.recv().await { let data = format!("event: reload\ndata: {}\n\n", event.path); if stream.write_all(data.as_bytes()).await.is_err() { break; // Client disconnected } } } ```
This SSE connection is only active in development mode. In production, the endpoint does not exist and no JavaScript is injected into the page. The development experience is fast without compromising the production build.
Session Management
The embedded server includes a session system that stores state in an encrypted cookie called flin_session. Sessions are a HashMap -- simple key-value pairs where both keys and values are strings.
This design choice was deliberate. Sessions in FLIN are not a general-purpose storage mechanism. They hold authentication state: the user's email, their display name, their user ID. Strings are sufficient for this. Making sessions type-safe would add complexity without meaningful benefit for the use case.
The session cookie is encrypted with AES-256-GCM using a key derived from the application's secret. It is marked HttpOnly (no JavaScript access), SameSite=Lax (CSRF protection), and Secure in production (HTTPS only). These are not configurable defaults you might forget to enable. They are the only behavior.
Performance Characteristics
The embedded server is not the fastest HTTP server in the Rust ecosystem. It is not trying to be. It is trying to be fast enough that performance is never a concern for FLIN applications, while being simple enough to maintain and debug.
On a standard development machine, the server handles approximately 15,000 requests per second for static JSON responses and 3,000 requests per second for dynamic pages that involve FLIN bytecode execution. For context, a typical web application receives fewer than 100 requests per second. The server is 30 times faster than it needs to be.
Memory usage starts at approximately 8 MB and grows linearly with the number of concurrent connections. Each connection adds roughly 64 KB of memory for the request buffer, response buffer, and parsed state. A server handling 1,000 concurrent connections uses about 72 MB -- well within the 50 MB baseline target for emerging-market devices, once you account for the fact that 1,000 concurrent connections is an extraordinary load for a single-server deployment.
Why This Matters
The embedded HTTP server is not just a convenience feature. It is a statement about what a programming language for the web should provide. The web is the deployment target for FLIN applications. HTTP is the protocol. HTML is the output format. Asking developers to install, configure, and manage a separate HTTP server is like asking them to install a separate compiler for each source file.
By embedding the server, FLIN guarantees that every application starts the same way, behaves the same way, and has the same security baseline. There is no "I forgot to add helmet" or "I didn't configure CORS" or "I used the wrong body parser." The server does the right thing by default because the language designers made those decisions once, correctly, for every application that will ever be built with FLIN.
In the next article, we explore how file-based routing turns the app/ directory structure into a complete URL scheme -- no route definitions, no configuration, no boilerplate.
---
This is Part 96 of the "How We Built FLIN" series, documenting how a CEO in Abidjan and an AI CTO designed and built a programming language from scratch.
Series Navigation: - [96] FLIN's Embedded HTTP Server (you are here) - [97] File-Based Routing in FLIN - [98] API Routes: Backend and Frontend in One File - [99] Auto JSON and Form Body Parsing