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.