Repository navigation
Architecture
A deep dive into CaseDropper's internals. Bring snorkeling gear; it's shallow. But it's shallow in Zig, which makes it faster, more dangerous, and spiritually pure. Also there's a Lua engine in here now, because we decided a static file server needed a scripting language and we were right.
Request comes in
|
v
Is it a file? ──(lowercased)──> Serve it
|
No
|
v
Is it a Lua alias? ──(also in the hash map)──> Serve it
|
No
|
v
404
That's the architecture. Same flowchart as before, plus one extra hash map lookup that was already there because the aliases were inserted at index time. The request handler doesn't know about Lua. The request handler doesn't need to know about Lua. The hash map knows. The hash map is the only thing that matters.
The old version ran on ASP.NET like a peasant. Now it runs on Zig, compiled to a single static binary with zero external dependencies, linked against musl libc because Lua needs it (Lua is the only reason we link libc — Zig itself needs nothing, but we made a pragmatic concession to embed a scripting language, and musl keeps it static, so the binary remains pure). Andrew Kelley didn't forge a systems language from the void so you could serve files with a garbage collector. We have standards now.
CaseDropper was originally written in C#/ASP.NET. It worked. It served files. It had a Dictionary<string, string> and a FileSystemWatcher and it ran on the .NET runtime like every other managed-language project quietly pretending it doesn't need 200MB of runtime to lowercase a string.
Then it was rewritten in Zig 0.15.2, and everything changed.
The binary went from "needs the entire .NET runtime" to "needs nothing." The container went from "based on an image" to "based on the philosophical concept of emptiness." The dependency count went from "NuGet knows" to zero. Not "zero runtime dependencies." Not "zero external packages." Zero. The number. Nothing. Zig doesn't need your packages. Zig doesn't need your runtime. Zig barely needs your operating system, and if Andrew Kelley could figure out how to serve HTTP without a kernel, Zig wouldn't need that either.
Then we embedded Lua 5.4 because apparently pure wasn't enough. We needed configurable pure. We needed users to be able to write a function that transforms URL paths at index time so the hash map contains aliases that didn't exist on the filesystem. We needed a scripting language inside a static file server and we regret nothing.
The load-bearing wall of this project, rebuilt from scratch in Zig. At startup, it walks the entire wwwroot directory using std.fs.Dir.walk and builds a std.StringHashMap mapping lowercased paths to their actual filesystem paths.
Yes, the entire directory. At startup. Into memory. Every time. In the old C# version this took 200ms and felt sluggish. In Zig it takes approximately the amount of time it takes light to travel across a small room. Zig doesn't waste cycles on garbage collection because Zig doesn't produce garbage. Manual memory management. The way the founders intended.
Lua integration: If a Lua script is configured, each path is also run through the Lua rewrite() function during the index build. If the function returns a non-nil alias that differs from the original, the alias gets lowercased and inserted into the same hash map, pointing to the same file. The hash map doesn't care whether a key came from a directory walk or from a Lua function. It's a hash map. It stores keys and values. It doesn't judge.
Collision handling: first-write-wins via getOrPut. If a Lua alias collides with an existing real file path, the real file wins. If two Lua aliases collide, whichever file was walked first wins. This is not configurable. This is not negotiable. The hash map has spoken.
URL percent-decoding: The normalizePath function decodes percent-encoded URLs before lookup. When a browser requests /%D1%97%D0%B8d%D1%94x.html (the Cyrillic-aliased version of index.html), the server decodes those percent sequences back to raw UTF-8 bytes, lowercases the result, and looks it up in the hash map. The alias was stored as raw UTF-8. The request arrives as percent-encoded UTF-8. The server bridges the gap. This is the only interesting thing the request handler does, and it was already there for standards compliance.
Thread safety is handled by std.Thread.RwLock — shared reads for HTTP handlers, exclusive write during rebuilds. No mutex contention during normal operation. Multiple threads serving files simultaneously, each one reading the path map without blocking the others. It's like a library where everyone can read at the same time but when the librarian needs to update the catalog, everyone waits politely. Except the librarian is an inotify callback and the readers are HTTP threads managed by std.Thread.Pool.
The newest resident of CaseDropper's architecture, and the most controversial. We embedded Lua 5.4.7 into a static file server. We vendored the entire Lua source tree — 32 C files — into deps/lua/src/ and told Zig to compile them. Zig compiled them. Zig didn't ask questions. Zig compiled C like it was born to do it, because it was.
The engine wraps the Lua C API via @cImport:
const c = @cImport({
@cInclude("lua.h");
@cInclude("lualib.h");
@cInclude("lauxlib.h");
});At startup, the engine loads a Lua script, executes it (which registers the rewrite() function), and then the rewritePath() method calls that function for each file path during index build. The Lua function receives a path string, returns either a rewritten alias or nil, and the engine copies the result out of Lua-managed memory into a caller-provided buffer.
Why luaL_loadfilex and lua_pcallk instead of the macros? Because Zig's translate-c has opinions about C macros and those opinions are "no." The convenience macros luaL_loadfile and lua_pcall expand to things that make @cImport unhappy. We call the underlying functions directly. It's two extra parameters. We'll survive.
Thread safety: None needed. The Lua engine is only called during index builds, which are single-threaded operations. The RwLock is held exclusively during rebuilds, so no HTTP handlers can access the map while Lua is running. The engine doesn't need its own mutex because it operates in a context that's already synchronized. This is not an accident. This is architecture.
Error handling: If Lua throws an error, we log it and skip the alias. If the script doesn't define rewrite(), we log it and skip all aliases. If the script returns something that isn't a string or nil, we ignore it. The server continues to serve files. The Lua engine is additive — it can only add aliases, never remove real paths, never break existing functionality. If the script is garbage, the worst case is "no aliases." The server still works. The hash map endures.
Uses std.http.Server from the Zig standard library. No framework. No middleware pipeline. No dependency injection container. No "oh let me just add this one NuGet package." Just a server that listens on a port, reads a request, lowercases the path, looks it up, and sends bytes. HTTP the way Postel envisioned it, if Postel had access to comptime.
The thread pool defaults to your CPU count. Every thread handles connections. There is no event loop debate here because Zig's std.http.Server made the decision for you and you will accept it with gratitude.
The old C# version used FileSystemWatcher, which is .NET's polite suggestion that files might have changed. The Zig version uses Linux inotify directly, because Zig doesn't believe in abstractions that hide what the kernel is doing. You want to watch files? Here's a file descriptor. Here are some events. Handle it.
When filesystem changes are detected — files added, removed, renamed, moved, looked at funny — the watcher triggers a complete rebuild of the PathMap after a 1-second debounce. Not an incremental update. A full rebuild. The old map is deallocated. A new map is walked and constructed from scratch. Including re-running the Lua script on every path to regenerate all aliases. The Lua engine persists across rebuilds — it's initialized once at startup and reused. Only the map is torn down and rebuilt.
This means CaseDropper handles deletions now, which the old C# version... didn't. The old FileSystemWatcher just kept accumulating paths like a hoarder. Files indexed forever. The Zig version is disciplined. It tears down and rebuilds. Clean. Efficient. Ruthless. Zig developers do not form emotional attachments to heap allocations.
Multi-stage build. Stage one pulls the official Zig Docker image and cross-compiles the binary for the target architecture using zig build -Dtarget=.... No QEMU. No architecture-specific toolchain. No "oh I need to install gcc-aarch64-linux-gnu." Zig cross-compiles to any target from any host because Zig respects your time. The Lua C sources come along for the ride in deps/lua/src/ — Zig compiles them as part of the build, cross-compiled to the target architecture, no separate C toolchain required.
Stage two is FROM scratch. The final container contains:
- The binary
- The
scripts/directory (shipped Lua scripts) - Nothing else
The binary is statically linked against musl libc (because Lua needs libc, and musl keeps it static). There are no shared libraries. There are no dynamic linker shenanigans. There is one ELF binary, some Lua scripts, and the void.
You cannot docker exec into this container. You cannot get a shell. There is no shell. There is no ls. There is no /bin. There is no /. Well, there's a / but there's nothing in it except the binary, your scripts, your files, and existential emptiness. It's the most secure container you've ever run because there's nothing in it to exploit. The attack surface is "the Zig standard library HTTP parser and the Lua 5.4 interpreter running at index time" and that's it. Compared to "the entire .NET runtime plus ASP.NET plus Kestrel plus whatever NuGet dragged in," this is a security improvement of approximately infinity percent.
| Decision | Reason |
|---|---|
| Zig 0.15.2 | Because Andrew Kelley is right about everything and we're tired of pretending otherwise |
| Lua 5.4.7 | Because we needed a scripting language and Lua is the only one small enough to vendor without shame. 32 C files. No dependencies. Compiles everywhere Zig compiles. It's the Zig of scripting languages, if Zig had garbage collection and forgave you for it. |
@cImport |
Zig's C interop. Import C headers, call C functions, no bindings generator, no FFI layer, no wrapper library. Just @cInclude("lua.h") and you're done. Zig speaks C fluently because Zig was raised bilingual. |
| musl libc | Lua needs libc. musl keeps the binary static. The binary is still a single file. The container is still FROM scratch. The purity is maintained, merely... nuanced. |
std.http.Server |
It's in the standard library. No external dependencies. Zig provides. |
std.StringHashMap |
It's a hash map. It's always a hash map. But now it's a hash map that doesn't need a garbage collector, and it stores Lua-generated aliases alongside real paths, and it doesn't know the difference. |
std.Thread.RwLock |
Reader-writer lock from stdlib. No crate. No package. No npm install. Just @import("std"). |
std.Thread.Pool |
Thread pool from stdlib. Same story. Zig's standard library is your dependency list and your dependency list is complete. |
| inotify | Direct kernel API. No wrapper library. No abstraction layer. The kernel tells us files changed and we listen. Then we rebuild the map. Then Lua runs again. Circle of life. |
FROM scratch |
Smaller is better, and "nothing" is the smallest possible thing |
| Env vars only | Configuration is four environment variables because Zig developers don't need a YAML parser to serve files |
| Cross-compilation | Zig compiles to 9 architectures from one host. Other languages need Docker buildx and QEMU. Zig needs zig build -Dtarget=. |
┌──────────────────────┐
│ Startup / Rebuild │
└──────────┬───────────┘
│
┌──────────▼───────────┐
│ Walk wwwroot dir │
└──────────┬───────────┘
│
┌──────────────▼──────────────┐
│ For each file path: │
│ 1. lowercase → store in map│
│ 2. lua rewrite → alias │
│ 3. lowercase alias → store │
└──────────────┬──────────────┘
│
┌──────────▼───────────┐
│ PathMap ready │
│ (RwLock: shared) │
└──────────┬───────────┘
│
┌────────────────────▼─────────────────────┐
│ HTTP Request │
│ 1. Percent-decode URL │
│ 2. Lowercase │
│ 3. HashMap lookup (O(1)) │
│ 4. Serve file or 404 │
└──────────────────────────────────────────┘
No Lua at request time. No regex matching. No rewrite rules evaluated per-request. Just a hash map lookup. O(1). The Lua script ran once, at index time, and its results are baked into the map like a compile-time constant that happened to be computed by an interpreted language. It's the best of both worlds: the flexibility of scripting and the performance of "we already did the work."
Someone requests a file. We percent-decode the URL. We lowercase the path. We look it up in a hash map. We send the file. The casing doesn't matter. The alphabet doesn't matter. The Lua aliases are already in the map.
There is no threat model. If someone is attacking your static file server, the files were already public. The container has no shell, no tools, no runtime, and no attack surface beyond the HTTP parser. The Lua engine runs at index time only — it's not exposed to user input. The only way to compromise this server is to find a vulnerability in Zig's std.http or to ship a malicious Lua script, and if you're shipping malicious Lua scripts to your own static file server, you have problems that architecture diagrams can't solve.