Building rex-serve: A Developer Experience Report
This page documents the experience of building rex-serve — embedding the Rex interpreter inside an HTTP server. What was powerful, what was surprising, and what made things harder than expected.
What Worked Beautifully
Existence-based semantics are perfect for HTTP
Rex's core insight — only none represents absence, while false,
null, 0, and "" are all real values — eliminates an entire
class of bugs in request handling. Missing headers, absent query params, and optional
fields all behave correctly without special-casing:
/* This just works. No truthiness bugs. */ api-key = headers.authorization unless api-key do /* only fires if truly absent */ res.status = 401 end max = query.limit or 100 /* 0 is a valid limit, won't fall through */
In most languages you'd need if api_key is not None or
?? default operators. In Rex, or means exactly what you want.
The HostObject trait is a great embedding API
The Rust HostObject trait (get, set, call,
delete, iter_*) maps perfectly to HTTP concepts. Request headers
became a HostObject with case-insensitive get(). Response headers became a
mutable HostObject with set(). The interpreter handles property chains like
res.headers.content-type = "text/html; charset=utf-8" by navigating through nested
host objects — no special HTTP-aware code needed in the interpreter.
Compact, self-contained programs
Rex programs are refreshingly short. A complete CRUD handler for articles is about 40 lines. The middleware for auth is 15 lines. There's no boilerplate — no imports, no class definitions, no async/await ceremony. The program is just expressions that transform request data into a response.
Comprehensions for data transformation
Transforming API data is concise and readable:
items = [json.parse(a.value) for a in db.list("article:")] {ok: true, articles: [{slug: a.slug, title: a.title} for a in items]}
This replaces what would be map() chains or explicit loops in other languages.
Gas-bounded execution
Every Rex program runs with a gas limit. If a handler hits an infinite loop or
runaway recursion, it terminates cleanly with a GasLimitExceeded error
instead of hanging the server. This is critical for running user-provided code safely.
What Was Painful
Lazy evaluation of object literals (fixed in v2)
This was the biggest obstacle. The v1 bytecode format emitted all object
literals as lazy containers — bytecode spans only evaluated on access. When passed
to opcodes, they arrived as opaque blobs the host couldn't read. The fix required
adding force_value() to the interpreter at multiple points.
template.render(layout, {title: title, body: html}) /* ^^^^^^^^^^^^^^^^^^^^^^^^^^^ This object is passed as Lazy(span) to the opcode. The opcode can't access the interpreter to resolve the variable references inside it. */
The v2 bytecode migration solved this properly: containers are now eager by default. Laziness is opt-in via an explicit index marker. Object literals in handler code evaluate immediately — no workarounds needed. This was the single biggest improvement from the v2 migration.
Pointer deduplication interacts badly with skipped branches (fixed)
The interpreter had two bugs triggered by pointer dedup: object keys deduped as
pointers were misidentified as schema pointers, and navigation places deduped as
pointers silently skipped writes. Both were interpreter bugs, not encoder bugs — the
pointers were correct. Fixed with 13 regression tests.
compile_no_dedup() workaround removed.
No early return (fixed: return keyword)
This was the second biggest pain point. Without return, every handler
needed when/else chains because the last expression's value wins. The
return keyword now enables clean guard-style dispatch — sequential
when blocks with early exit. Every rex-serve handler and middleware has
been rewritten to use it.
No closure or callback model
Rex programs are linear scripts, not event-driven. There's no way to define a
function and call it later, or register a callback. Every middleware and handler
is a separate program with separate compilation. Variables flow between them only
because the server manually chains RunResult.vars into the next program's
context. This works, but it means the middleware can't define helper functions that
handlers inherit.
Opcode namespace wiring (improved: explicit shortcodes)
The Rex compiler treats time.uuid() as a variable navigation:
$time.uuid. But opcodes are registered as short codes like %tu.
The original approach used HostObject "namespace" objects that return opcode strings
when navigated — a layer of indirection the compiler can now eliminate.
The .rexd file now supports explicit shortcode strings:
extern "tu" time.uuid() -> str tells the compiler to rewrite
time.uuid() directly to %tu at compile time. This bypasses
the namespace indirection entirely. The shortcodes also apply to bindings —
extern "M" method: HttpMethod compiles method to a
read-only ref ^M instead of a variable lookup. The trade-off is that
the shortcode strings are manually maintained and must match the runtime's opcode
registry — there's no auto-derivation, so a mismatch fails silently.
Keywords can't be method names
Rex reserves delete as a keyword (unary operator). This means
db.delete(key) doesn't compile — the parser sees delete as
the start of a delete expression, not a method name. The fix was renaming to
db.del(key). The parser does accept keywords after . in
navigation, so obj.delete reads fine — but calling it as a function
breaks because the call's compiled form doesn't match the shortcode rewrite pattern.
Any host API that wants a method named after a keyword needs a workaround.
String concatenation for HTML (now solved)
Before template literals, building HTML meant lots of string concatenation with escaped quotes:
body = body + "<li><a href=" + url + ">" + title + "</a></li>"
Template literals and tagged templates now solve this. The html
tag auto-escapes interpolated values, preventing XSS while keeping static HTML clean:
list = list + html`<li>${name}</li>` `<ul>${list}</ul>`
Untagged backtick templates handle composition of already-safe HTML fragments. This page's static-files tour stop demonstrates the pattern.
Architecture Insights
The interpreter is fast enough
The zero-copy cursor interpreter evaluates bytecode directly without building an
AST. For typical handlers (10-50 expressions), execution takes microseconds.
SQLite I/O dominates. The spawn_blocking approach — running synchronous
Rex on Tokio's blocking thread pool — works well because programs are so short-lived.
The type file (.rexd) is a good idea
Separating the type interface from the runtime means the LSP can provide
completions and diagnostics without running the server. The rex-serve.rexd
file declares every opcode, global, and type — one file gives you full IDE support
for the entire server API.
Filesystem routing is genuinely simple
No router configuration. No decorator syntax. No manifest file. Create a file,
it becomes a route. The _middleware.rex convention for middleware is
immediately understandable. The _ prefix for private directories is clean.
This is the part of the DX that feels most polished.
Verdict
Rex's core language semantics — existence-based logic, unified navigation, type predicates, comprehensions — are genuinely well-suited for edge function scripting. Many early pain points (lazy eval, pointer dedup, namespace wiring) have been fixed or improved. The remaining friction is in lexical edge cases (keywords blocking method names, identifier-digit ambiguity) and the manual nature of shortcode maintenance. The type checker now catches real bugs — optional access without narrowing, unused variables from broken string escaping — which is a genuine improvement over untyped scripting.