Repository navigation
Lua URL Rewriting
We embedded a scripting language in a static file server and we'd do it again.
CaseDropper has an embedded Lua 5.4.7 scripting engine that generates URL aliases at index build time. You write a Lua function called rewrite(path) that takes a file path and returns either a rewritten alias or nil. At startup (and on every hot reload), the server calls this function for every file in wwwroot. Both the original path and the Lua-generated alias are stored in the hash map. At request time, it's just a hash map lookup. No Lua. No regex. No rewrite rules evaluated per-request. Just O(1).
This is the correct architecture for URL rewriting in a static file server: do all the work once, at index time, and never think about it again. The hash map holds the truth. The Lua script is just the prophet.
walk wwwroot/
└─ for each file path:
1. lowercase(path) → store in hash map (existing behavior)
2. lua rewrite(path) → alias
3. lowercase(alias) → store in hash map (NEW — same file, different key)
browser requests /їиdєx.html
→ percent-decode: /їиdєx.html (raw UTF-8 bytes)
→ lowercase
→ hash map lookup
→ found: points to index.html
→ serve index.html
No Lua involved. The Lua engine ran at index time and went to sleep. The request handler doesn't know Lua exists. It just looks up a key in a hash map, like it always has, like God intended.
Set the LUA_SCRIPT environment variable to the path of your Lua script:
# Native
LUA_SCRIPT=scripts/smallcaps.lua WWWROOT=wwwroot ./zig-out/bin/casedropper
# Docker
docker run --rm -p 8080:8080 \
-v ./my-files:/app/wwwroot \
-e LUA_SCRIPT=/app/scripts/smallcaps.lua \
casedropperIf LUA_SCRIPT is not set, the server behaves exactly as before. No Lua. No aliases. Just case-insensitive file serving. Backwards compatibility is maintained because we're professionals who happen to have embedded a scripting language in a static file server.
Your script must define a global function called rewrite(path):
function rewrite(path)
-- path is a string like "images/photo.jpg" (no leading slash)
-- Return a rewritten alias, or nil to skip
-- Your transformation here
local alias = transform(path)
-- Return nil if nothing changed (avoids duplicate hash map entries)
if alias == path then return nil end
return alias
end-
The function is called once per file, at index time. Not per-request. Write it for correctness, not performance. Though honestly, it's Lua, it's going to be fast anyway.
-
Return
nilto skip. If your function can't or shouldn't rewrite a path, returnnil. The server will only store the original path. -
Return the same string to skip. If you return the exact same string as the input, the server detects this and doesn't create a duplicate entry. But returning
nilis clearer. -
Preserve file extensions. The server matches file extensions for MIME type detection. If you transform
.htmlinto something else, the response will have the wrong Content-Type and browsers will be confused. Don't do that. Both shipped scripts only transform the filename stem and directory segments, leaving extensions intact. -
Preserve path separators. Slashes are structural. Transform the segments between them, not the slashes themselves. If you turn
/into something else, the hash map key won't match the request URL and nothing will work and you'll blame us and we'll blame you and everyone will be sad. -
First-write-wins on collisions. If your alias collides with a real file path, the real file wins. If two of your aliases collide (two different files produce the same alias), whichever was walked first wins. This is deterministic per filesystem walk order. It is not configurable. The hash map is a democracy and the first voter wins.
-
Errors are logged and skipped. If your script throws a Lua error, the server logs it and moves on. The file still gets its original entry in the hash map. The server doesn't crash. The server doesn't care about your script's feelings. The server serves files.
Example scripts live in scripts/. We're not going to enumerate them here — that would ruin the surprise. Run ls scripts/ and discover what awaits you. Each one follows the same pattern: a lookup table, a rewrite(path) function, extension preservation, slash preservation.
Some map characters to other alphabets. Some make your URLs look like they were typeset by a graphic designer having a breakdown. All of them are deterministic, all of them produce predictable output, and all of them will make your browser's URL bar look like a QR code had a stroke. The server decodes it and serves the file. This is what computers were invented for.
The one we'll talk about. Maps lowercase Latin letters to Unicode small capital letters:
| a→ᴀ | b→ʙ | c→ᴄ | d→ᴅ | e→ᴇ | f→ꜰ | g→ɢ | h→ʜ |
|---|---|---|---|---|---|---|---|
| i→ɪ | j→ᴊ | k→ᴋ | l→ʟ | m→ᴍ | n→ɴ | o→ᴏ | p→ᴘ |
| r→ʀ | s→ꜱ | t→ᴛ | u→ᴜ | v→ᴠ | w→ᴡ | y→ʏ | z→ᴢ |
q and d are... wait, d→ᴅ is there. q is not mapped because Unicode has no widely-supported small capital Q. We could use ǫ but that's a Latin Small Letter O with Ogonek and we have some standards.
Only lowercase letters are mapped. Uppercase and digits pass through unchanged. File extensions are preserved.
Example: index.html → ɪɴᴅᴇx.html
Wait, x isn't in the table... actually, looking at the script, there's no x mapping. So: i→ɪ, n→ɴ, d→ᴅ, e→ᴇ, x→x → ɪɴᴅᴇx.html. The x survives because Unicode small caps don't include it. The x stands alone, unchanged, a monument to the incompleteness of the Unicode Consortium's small caps ambitions.
Here's a template for a new rewriting script:
-- my_rewrite.lua: [describe your transformation]
local lookup = {
-- Add your character mappings here
-- ["a"] = "replacement",
}
function rewrite(path)
-- Find the last / to isolate the filename
local dir, filename = path:match("^(.*/)([^/]+)$")
if not dir then
dir = ""
filename = path
end
-- Split filename into stem and extension
local stem, ext = filename:match("^(.+)(%.%w+)$")
if not stem then
stem = filename
ext = ""
end
-- Transform directory segments (preserve slashes)
local dir_result = process(dir, true)
-- Transform filename stem
local stem_result = process(stem, false)
local rewritten = dir_result .. stem_result .. ext
if rewritten == path then return nil end
return rewritten
end
function process(text, preserve_slash)
local result = {}
local i = 1
while i <= #text do
local ch = text:sub(i, i)
if preserve_slash and ch == "/" then
result[#result + 1] = ch
else
result[#result + 1] = lookup[ch] or ch
end
i = i + 1
end
return table.concat(result)
endKey patterns:
- Preserve file extensions: Split stem from extension, only transform the stem
-
Preserve path separators: Check for
/and pass it through -
Return
nilon no change: Compare result with input - Use a lookup table: O(1) per character, deterministic, easy to maintain
- Process directory segments too: URLs have directories, not just filenames
Lua 5.4.7 is vendored as C source in deps/lua/src/. All 32 library .c files are compiled by Zig's build system via addCSourceFiles. The Zig/Lua boundary uses @cImport to import lua.h, lualib.h, and lauxlib.h.
The Lua state is created once at startup and reused across index rebuilds. It's not recreated per-rebuild because loading and parsing the script has overhead that we'd rather not pay every time someone drops a file into wwwroot. The state persists. The map is rebuilt. The Lua function is called. Order is maintained.
Because luaL_loadfile is a C macro that expands to luaL_loadfilex(L, filename, NULL). Zig's translate-c (@cImport) has... opinions about C macros that take NULL as an argument and cast it through void* pointers. The opinions are "no." We call the underlying function directly. Same result. Two extra characters. Zero drama.
Same deal with lua_pcallk vs lua_pcall. The macro expands, Zig's translate-c contracts, and we write the function call ourselves like adults.
The Lua engine has no mutex. It doesn't need one. Here's why:
- Lua is called during index builds (startup + hot reload)
- Index builds hold the
PathMap'sRwLockin exclusive (write) mode - While the write lock is held, no HTTP handlers can access the map
- Therefore, Lua is never called concurrently with anything
- Therefore, no mutex needed
- QED
This isn't an oversight. This is architecture. The Lua engine operates in a context that's already synchronized by the RwLock. Adding a second lock would be redundant, wasteful, and an insult to the existing synchronization design.
The Lua string returned by rewrite() lives in Lua-managed memory. The Zig engine copies it into a caller-provided [4096]u8 buffer before returning. This means:
- The Lua GC can collect the string whenever it wants — we already have our copy
- The caller owns the buffer and its lifetime
- No Zig-Lua memory entanglement
- 4096 bytes is enough for any sane file path and most insane ones
If your file path is longer than 4096 bytes, you have problems that a static file server wiki can't address. Seek help.
| Situation | What Happens |
|---|---|
| Script doesn't exist | Server logs error and exits (fail-fast, not fail-silently) |
| Script has syntax error | Server logs error and exits |
rewrite() not defined |
Server logs warning, continues without aliases |
rewrite() throws runtime error |
Server logs error, skips alias for that file, continues |
rewrite() returns non-string, non-nil |
Server ignores it, continues |
rewrite() returns same string as input |
Server detects no-op, doesn't create duplicate entry |
The principle: startup errors are fatal (you misconfigured something), runtime errors are recoverable (the script is broken for one path but the server can still serve files). The server is additive — Lua can only add aliases, never remove real paths. The worst case is "no aliases." The server still works. Case-insensitive file serving is the core feature. Lua is the bonus round.
We get it. Every other URL rewriting system evaluates rules per-request. Apache has mod_rewrite. Nginx has rewrite. Express has middleware. They all run regex or function evaluation on every incoming request.
CaseDropper doesn't do that because:
-
Static files don't change URLs at runtime. The filesystem is the source of truth. If a file exists, its alias exists. If a file is deleted, the alias should be deleted too. Index-time generation + hot reload handles this perfectly.
-
O(1) beats O(n). Per-request rewriting is O(rules × requests). Index-time generation is O(files × 1) at build time and O(1) at request time. For a static file server that might handle millions of requests, this is not even a close comparison.
-
No mutex contention. If Lua ran per-request, we'd need a mutex around the Lua state (Lua is not thread-safe). That mutex would serialize all HTTP handlers through a single scripting engine. For a multi-threaded server, that's a bottleneck. For CaseDropper's architecture, that's a crime.
-
Simplicity. The request handler is a hash map lookup. That's it. It doesn't need to know about Lua, about rewriting rules, about transformation functions. It looks up a key. It finds a value. It serves a file. The complexity is front-loaded into the index build, where it belongs, where it runs once, where it's not in the critical path.
This is the correct architecture. We're not being modest. It's correct.