Every web framework has middleware. Express chains functions with app.use(). Django has MIDDLEWARE settings. Rails has before_action. Koa chains generators. The concept is universal: code that runs before (or after) your route handler to perform cross-cutting concerns like logging, authentication, CORS, and rate limiting.
FLIN's middleware system is different in one fundamental way: middleware is defined by file location, not by code registration. You create a file called _middleware.flin in a directory, and it automatically applies to every route in that directory and all subdirectories. No registration call. No configuration array. No ordering bugs.
The _middleware.flin Convention
A middleware file is always named _middleware.flin. The underscore prefix signals to the router that this file is not a route -- it is a processing step that runs before routes in its directory.
// app/_middleware.flin -- applies to ALL routesmiddleware { log_info("{request.method} {request.path} from {request.ip}") response.headers["X-Request-ID"] = generate_uuid() next() } ```
The middleware { ... } block defines the processing logic. The next() call passes control to the next middleware or route handler. If next() is not called, the chain stops and the response (if any) is sent back to the client.
Hierarchical Middleware Execution
Middleware files form a hierarchy based on directory depth. When a request arrives, FLIN executes middleware from the root to the most specific directory:
Request: GET /admin/users/42Execution order: 1. app/_middleware.flin (global: logging, request ID) 2. app/admin/_middleware.flin (admin: auth check) 3. app/admin/users/[id].flin (handler: fetch and return user) ```
This is not configurable. The order is always root-first, depth-increasing. This eliminates the single most common source of middleware bugs: ordering. In Express, putting app.use(cors()) after app.use(authMiddleware) produces different behavior than putting it before. In FLIN, the order is determined by directory structure, which is visible and deterministic.
// app/_middleware.flin
middleware {
// Runs first for every request
log_info("Request: {request.method} {request.path}")
response.headers["X-Request-ID"] = generate_uuid()
next()
}// app/api/_middleware.flin
middleware {
// Runs second for /api/* requests
// CORS headers
if request.method == "OPTIONS" {
return response {
status: 204
headers: {
"Access-Control-Allow-Origin": env("ALLOWED_ORIGIN", "*"),
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, PATCH",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
"Access-Control-Max-Age": "86400"
}
}
}response.headers["Access-Control-Allow-Origin"] = env("ALLOWED_ORIGIN", "*") rate_limit(request.ip, limit: 100, window: 60) next() } ```
// app/admin/_middleware.flin
middleware {
// Runs for /admin/* requests
if session.user == none {
redirect("/login")
}user = User.where(email == session.user).first if user == none || user.role != "Admin" { redirect("/") }
request.user = user next() } ```
Middleware Actions
A middleware can do four things:
1. Pass Through
Call next() to continue the chain. Optionally modify the request or response before and after:
middleware {
start = now()
next()
duration = now() - start
log_info("Request took {duration}ms")
}2. Short-Circuit with a Response
Return a response to stop the chain immediately:
middleware {
if request.headers["X-API-Key"] != env("API_KEY") {
return response {
status: 401
body: { error: "Invalid API key" }
}
}
next()
}3. Redirect
Use redirect() to send the client elsewhere:
middleware {
if session.user == none {
redirect("/login")
}
next()
}4. Enrich the Request
Attach computed values to request for downstream handlers:
middleware {
token = headers["Authorization"]
if token != "" && token.starts_with("Bearer ") {
jwt = token.slice(7)
claims = verify_token(jwt)
if claims != none {
request.user = claims
request.user_id = claims.sub
}
}
next()
}Matcher and Exclude Patterns
Sometimes a middleware should apply to most routes in its directory but skip a few. The matcher and exclude properties handle this:
// app/_middleware.flinmiddleware { matcher: ["/tasks/", "/people/", "/settings/", "/files/"] exclude: ["/", "/login", "/register", "/auth/", "/api/"]
if session.user == none { redirect("/login") } next() } ```
The matcher property specifies which paths this middleware applies to. The exclude property specifies paths to skip. If neither is specified, the middleware applies to everything in its directory and below.
Pattern syntax:
- /tasks -- exact match
- /tasks/** -- matches /tasks and everything under it
- /api/* -- matches one level under /api but not deeper
The Implementation: Middleware Chain
Under the hood, FLIN builds a middleware chain for each route at startup time:
pub struct MiddlewareChain {
handlers: Vec<CompiledMiddleware>,
}impl MiddlewareChain {
pub async fn execute(
&self,
ctx: &mut RequestContext,
route_handler: &CompiledFunction,
vm: &mut VirtualMachine,
) -> Result
async fn execute_at(
&self,
index: usize,
ctx: &mut RequestContext,
route_handler: &CompiledFunction,
vm: &mut VirtualMachine,
) -> Result
let middleware = &self.handlers[index];
// Check matcher/exclude patterns if !middleware.matches(&ctx.request.path) { return self.execute_at(index + 1, ctx, route_handler, vm).await; }
// Execute middleware with next() callback let next = || self.execute_at(index + 1, ctx, route_handler, vm); vm.execute_middleware(middleware, ctx, next).await } } ```
The chain is recursive: each middleware receives a next() function that, when called, executes the next middleware in the chain. The route handler sits at the bottom of the chain and executes only if all middleware calls next().
Common Middleware Patterns
Authentication Middleware
// app/admin/_middleware.flinmiddleware { exclude: ["/admin/login"]
if session.user == none { redirect("/admin/login") }
admin = User.where(email == session.user && role == "Admin").first if admin == none { session.user = none redirect("/admin/login") }
request.admin = admin next() } ```
Rate Limiting Middleware
// app/api/_middleware.flinmiddleware { rate_limit(request.ip, limit: 100, window: 60)
response.headers["X-RateLimit-Limit"] = "100" response.headers["X-RateLimit-Remaining"] = to_text(rate_remaining(request.ip))
next() } ```
Logging Middleware
// app/_middleware.flinmiddleware { request_id = generate_uuid() response.headers["X-Request-ID"] = request_id
start = now() next() duration = now() - start
log_info("[{request_id}] {request.method} {request.path} -> {response.status} ({duration}ms)") } ```
CORS Middleware
// app/api/_middleware.flinmiddleware { origin = env("ALLOWED_ORIGIN", "*")
if request.method == "OPTIONS" { return response { status: 204 headers: { "Access-Control-Allow-Origin": origin, "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, PATCH, OPTIONS", "Access-Control-Allow-Headers": "Content-Type, Authorization, X-API-Key", "Access-Control-Max-Age": "86400" } } }
response.headers["Access-Control-Allow-Origin"] = origin next() } ```
Why File-Based Middleware
The file-based middleware approach has three significant advantages over registration-based systems:
Visibility. You can see the middleware that applies to a route by looking at the directory tree. In Express, you must trace through app.use() calls across multiple files to understand what runs before a handler. In FLIN, the _middleware.flin files in the path from root to the handler ARE the middleware chain.
Locality. Authentication middleware lives in the admin/ directory because it applies to admin routes. API middleware lives in the api/ directory because it applies to API routes. The middleware is close to the code it protects, not in a distant configuration file.
Impossibility of forgetting. You cannot accidentally expose an admin route by forgetting to register middleware. If the route is in the admin/ directory and admin/_middleware.flin checks authentication, every route in that directory is protected. Adding a new admin page means creating a file in the admin/ directory -- the middleware applies automatically.
The trade-off, as with file-based routing, is that middleware cannot be composed dynamically at runtime. For the use cases that FLIN targets -- web applications with predictable request flows -- this is a feature, not a limitation.
In the next article, we explore guards -- FLIN's declarative security system that complements middleware with per-route access control.
---
This is Part 101 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: - [99] Auto JSON and Form Body Parsing - [100] Request Context Injection - [101] The Middleware System (you are here) - [102] Guards: Declarative Security for Routes