Rex has a working Rust compiler and interpreter (rex-core) with a HostObject trait, refs system, opcodes, and gas-bounded execution -- all the primitives needed to run untrusted Rex programs as HTTP handlers. The existing samples/http-domain/ already demonstrates HTTP routing patterns in Rex. This plan designs the server that makes those patterns real: a filesystem-routed HTTP server where .rex files are edge functions.
Built on axum (hyper/tokio). On startup, scans a routes/ directory, compiles .rex files to REXC bytecode, builds a route table, and dispatches incoming requests through middleware chains to handlers.
crates/rex-serve/
Cargo.toml
src/
main.rs CLI entry, arg parsing, server startup
server.rs axum app, graceful shutdown
router.rs filesystem scan, route table, pattern matching
handler.rs request → Context → run() → response
middleware.rs _middleware.rex chain loading and execution
refs.rs HostObject impls for req/res
opcodes.rs built-in opcode registrations (db, json, log, crypto, time, http, fs, markdown, template)
static_files.rs static file serving with content-type detection
reload.rs filesystem watcher (notify crate), atomic table swap
config.rs rex-serve.toml parsing
rex-core(workspace),axum0.7+,tokio,clap,notify7+,rusqlite+r2d2,serde+serde_json,toml,pulldown-cmark(markdown),mime_guess(content-type detection)
routes/
_middleware.rex → middleware for ALL routes
index.rex → /
health.rex → /health
api/
_middleware.rex → middleware for /api/**
keys.rex → /api/keys
keys/
[id].rex → /api/keys/:id
webhooks/
_middleware.rex → middleware for /api/webhooks/**
[provider].rex → /api/webhooks/:provider
[provider]/
events.rex → /api/webhooks/:provider/events
events/
[event-id].rex → /api/webhooks/:provider/events/:event-id
- One file per route, all methods in one file. Branch on
req.methodinside the handler (matches existing Rex HTTP samples). index.rex→ maps to the directory path itself[param].rex→ dynamic segment, value available asparams.param[...rest].rex→ catch-all, value available asparams.rest(array)_middleware.rex→ middleware for directory + all descendants (never a route handler)_prefixed dirs/files (other than_middleware.rex) are private -- never served or routed, but readable by handlers viafs.read(). Used for layouts, content, shared data:_layouts/— HTML template files_content/— markdown or data files_data/— JSON seed data, config
- All other non-
.rexfiles are static assets, served directly with auto-detected content-type
Any file in the route tree that isn't .rex and doesn't start with _ is a static asset:
routes/
style.css → GET /style.css (static)
favicon.ico → GET /favicon.ico (static)
images/
logo.svg → GET /images/logo.svg (static)
index.rex → GET / (dynamic)
about.html → GET /about.html (static)
api/
keys.rex → * /api/keys (dynamic)
Resolution priority for a given URL path:
.rexhandler (dynamic) — highest priority- Static file — exact path match
index.rexin directory — if path is a directoryindex.htmlin directory — static directory index- 404
Middleware runs for static files too. The full middleware chain executes before serving a static file. This lets middleware add security headers, check auth, do CORS, etc. for all assets. If middleware short-circuits (status >= 400), the static file is not served.
Directories and files starting with _ are never routable or servable, but handlers can read them with fs.read():
routes/
_layouts/
page.html ← not served, but handlers can fs.read("_layouts/page.html")
admin.html
_content/
blog/
hello-world.md ← not served, but handlers can read + render it
getting-started.md
_data/
nav.json ← shared navigation data
blog/
[slug].rex → reads from _content/blog/, renders with _layouts/page.html
- Static segments beat dynamic (
/api/keys>/api/[id]) - Named params beat catch-all (
/api/[id]>/api/[...rest]) - Longer paths beat shorter paths
Uses the existing .config.rex short codes from samples/http-domain/:
| Ref | Aliases | Type | Description |
|---|---|---|---|
H |
req.headers, headers |
case-insensitive map (HostObject) | Request headers |
M |
req.method, method |
string | HTTP method |
P |
req.path, path |
string | URL path |
Q |
req.query, query |
string | object | Query string |
C |
req.cookies, cookies |
object | Cookie map |
I |
req.ip, ip |
string | Client IP |
B |
req.body, body |
string | Request body |
D |
req.domain, domain |
string | Host header |
S |
res.status, status |
integer | Response status (default 200) |
RH |
res.headers |
object (HostObject) | Response headers |
EC |
config |
object (read-only HostObject) | Edge config |
SC |
secrets |
object (read-only HostObject) | Secrets |
New ref for the server:
| Ref | Aliases | Type | Description |
|---|---|---|---|
PA |
req.params, params |
object | Route params from [param] segments |
res.statusandres.headersare set via ref mutation (HostObjectset())- The return value of the program is the response body
- Object/Array return → JSON-serialized,
content-type: application/jsonauto-set - String return → sent as-is with whatever content-type was set
- Undefined return → empty body
Middleware sets vars (e.g., principal = key-data). These persist into the handler's context because the server chains RunResult.vars into the next program's Context.vars.
After each middleware runs, if res.status >= 400, the chain stops and the middleware's return value becomes the response body.
Registered on every Context before execution:
// Already established in .config.rex
json.parse(text) → object
json.stringify(value) → string
log.info(msg) → undefined
log.warning(msg) → undefined
log.error(msg) → undefined
res.redirect(url, status) → (sets status + Location header, halts)
res.rewrite(url) → (internal rewrite, re-runs routing)
res.proxy(url) → (proxies request)
// New for the server — storage
db.get(key) → string | undefined
db.set(key, value) → true
db.delete(key) → true
db.list(prefix) → [{key, value}, ...]
// Filesystem (sandboxed to project root, read-only)
fs.read(path) → string | undefined (reads file relative to routes/ root)
fs.glob(pattern) → [path, ...] (list matching files)
fs.meta(path) → {size, modified} | undefined
// Content transformation
markdown.render(text) → string (HTML)
template.render(tmpl, data) → string (mustache-style {{key}} substitution)
// Utilities
time.now() → unix timestamp ms (integer)
time.uuid() → UUIDv7 string
crypto.hash(algo, data) → hex string
crypto.hmac(algo, key, data) → hex string
crypto.random(bytes) → hex string
http.fetch(url, options) → {status, headers, body}
Handlers can read any file in the project tree (including _ prefixed private files). Paths are relative to the project root (where rex-serve.toml lives). The sandbox prevents path traversal -- fs.read("../../etc/passwd") resolves to the project root and fails.
This is how dynamic handlers reference static content:
/* routes/blog/[slug].rex — render a blog post */
content = fs.read("_content/blog/" + params.slug + ".md")
unless content do
res.status = 404
res.headers.content-type = "text/html"
"<h1>Not Found</h1>"
end
when content do
html-body = markdown.render(content)
layout = fs.read("_layouts/page.html")
res.headers.content-type = "text/html"
template.render(layout, {title: params.slug, body: html-body})
endMustache-style substitution. {{key}} is replaced with the HTML-escaped value from the data object. {{{key}}} (triple braces) is unescaped (for injecting pre-rendered HTML like markdown output).
<!-- _layouts/page.html -->
<!DOCTYPE html>
<html>
<head>
<title>{{title}} — My Site</title>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<nav>{{nav}}</nav>
<main>{{{body}}}</main>
<footer>Built with Rex</footer>
</body>
</html>/* Handler fills in the template */
template.render(layout, {
title: "Hello World"
nav: nav-html
body: markdown.render(content) /* unescaped via {{{ }}} in template */
})For async opcodes (db, http.fetch): the Rex interpreter is synchronous. These block within spawn_blocking. Safe because Rex programs are gas-bounded and short-lived.
Request arrives for GET /articles/getting-started
1. Look up route:
a. Check .rex handlers → routes/articles/[slug].rex matches, params={slug: "getting-started"}
b. (If no .rex match, check static files → serve if found, skip to step 11)
2. Collect middleware: [routes/_middleware.rex] (articles/ has no _middleware.rex)
3. Build initial refs from HTTP request (H, M, P, Q, B, C, I, D, PA)
4. Create ResponseHostObject (status=200, headers={})
For each middleware:
5. Build Context { refs, vars (accumulated), host_objects, opcodes, gas_limit }
6. rex_core::interpret::run(middleware_bytecode, ctx)
7. Accumulate vars from RunResult
8. If ResponseHostObject.status >= 400 → return early with middleware return value as body
9. Build Context for handler (with accumulated vars from middleware)
10. rex_core::interpret::run(handler_bytecode, ctx)
- Handler calls fs.read("_content/getting-started.md") → reads private file
- Handler calls markdown.render(content) → HTML string
- Handler calls fs.read("_layouts/page.html") → reads private layout
- Handler calls template.render(layout, {title, body}) → final HTML
- Handler returns the HTML string
11. Build HTTP response:
- Status from ResponseHostObject
- Headers from ResponseHostObject (including content-type: text/html set by handler)
- Body from RunResult.value (string → sent as-is)
Static file example: GET /style.css
1. No .rex match
2. Static file found: routes/style.css
3. Run middleware chain (same steps 2-8 above — security headers get added)
4. Serve file bytes with content-type: text/css (from mime_guess)
- Each request → tokio task →
spawn_blockingfor Rex execution - Route table shared via
Arc<RwLock<RouteTable>>(read lock for dispatch, write lock for hot reload) - DB connection pool via
r2d2(SQLite) ordeadpool(Redis) - Hot reload:
notifywatchesroutes/, recompiles changed files, atomic table swap
[server]
host = "0.0.0.0"
port = 3000
max_body_bytes = 1_048_576
gas_limit = 1_000_000
[routes]
dir = "routes"
[db]
backend = "sqlite" # or "redis", "memory"
path = "data.db"
[secrets]
env_prefix = "REX_SECRET_"Every rex-serve project includes a .rexd file so the LSP provides completions, hover docs, and type checking for all server globals, functions, and types. This file ships with rex-serve and is copied into new projects (or referenced via config).
// ── Types ──────────────────────────────────────────────────────────────
Headers = {string | [string]}
HttpMethod = "GET" | "POST" | "PUT" | "DELETE" | "PATCH" | "OPTIONS" | "HEAD"
FileMeta = {size: integer, modified: integer}
FetchOptions = {method: string, headers: {string}, body: string}
FetchResult = {status: integer, headers: Headers, body: string}
DbEntry = {key: string, value: string}
// ── Request (read-only) ────────────────────────────────────────────────
// HTTP request data. Read-only — populated by the server before the handler runs.
req = {
// HTTP method (GET, POST, etc.)
method: HttpMethod
// URL path (e.g. "/api/users/123")
path: string | [string]
// Request headers as a case-insensitive map
headers: Headers
// Parsed query string parameters
query: {string | [string]}
// Parsed cookie values
cookies: {string}
// Client IP address
ip: string
// Request body as a string
body: string
// Host/domain from the Host header
domain: string
// Route parameters from [param] segments (e.g. {slug: "hello-world"})
params: {string | [string]}
}
// Short aliases for common request fields
// HTTP method
method: HttpMethod
// URL path
path: string | [string]
// Request headers
headers: Headers
// Parsed query parameters
query: {string | [string]}
// Parsed cookies
cookies: {string}
// Request body
body: string
// Route parameters
params: {string | [string]}
// ── Response (mutable) ─────────────────────────────────────────────────
// HTTP response. Mutable — set status, headers, and body to control the response.
mut res = {
// Response status code (default: 200)
status: integer
// Response headers
headers: Headers
}
// Response status code (shorthand for res.status)
mut status: integer
// ── Edge Config (read-only) ────────────────────────────────────────────
// Host-managed project configuration. Read-only, set outside of Rex.
config: unknown
// Host-managed secrets store. Read-only.
secrets: {string}
// ── Logging ────────────────────────────────────────────────────────────
// Log an informational message
log.info(message: unknown)
// Log a warning
log.warning(message: unknown)
// Log an error
log.error(message: unknown)
// ── Response Control ───────────────────────────────────────────────────
// Internal rewrite to another path. Restarts routing.
res.rewrite(url: string): never
// HTTP redirect with status code (default 302)
res.redirect(url: string, status: integer): never
// Proxy the request to another URL
res.proxy(url: string): unknown
// ── JSON ───────────────────────────────────────────────────────────────
// Parse a JSON string into a value
json.parse(text: string): unknown
// Serialize a value to a JSON string
json.stringify(value: unknown): string
// ── Database (KV) ──────────────────────────────────────────────────────
// Get a value by key. Returns undefined if not found.
db.get(key: string): string
// Set a key-value pair. Returns true.
db.set(key: string, value: string): boolean
// Delete a key. Returns true.
db.delete(key: string): boolean
// List entries matching a key prefix.
db.list(prefix: string): [DbEntry]
// ── Filesystem (read-only, sandboxed to project root) ──────────────────
// Read a file's contents as a string. Path is relative to project root.
fs.read(path: string): string
// List files matching a glob pattern relative to project root.
fs.glob(pattern: string): [string]
// Get file metadata (size in bytes, modified as unix timestamp ms).
fs.meta(path: string): FileMeta
// ── Content Transformation ─────────────────────────────────────────────
// Render markdown text to HTML
markdown.render(text: string): string
// Render a mustache-style template with data. {{key}} escapes HTML, {{{key}}} is raw.
template.render(template: string, data: unknown): string
// ── Utilities ──────────────────────────────────────────────────────────
// Current time as unix timestamp in milliseconds
time.now(): integer
// Generate a UUIDv7 string
time.uuid(): string
// Hash data with the given algorithm (e.g. "sha256"). Returns hex string.
crypto.hash(algorithm: string, data: string): string
// HMAC with the given algorithm, key, and data. Returns hex string.
crypto.hmac(algorithm: string, key: string, data: string): string
// Generate random bytes. Returns hex string.
crypto.random(bytes: integer): string
// Make an HTTP request. Returns response with status, headers, and body.
http.fetch(url: string, options: FetchOptions): FetchResultThis file lives at knowledge-base/rex-serve.rexd (or whichever project root). The LSP picks it up automatically and provides full IDE support for all server APIs.
A content site with an admin API. Exercises CRUD, auth middleware, dynamic routing, body parsing, JSON responses, static assets, markdown rendering, HTML templates, and multiple middleware layers.
knowledge-base/
rex-serve.toml
routes/
_middleware.rex # Global: request-id, security headers
_layouts/
page.html # HTML shell for content pages
admin.html # HTML shell for admin pages
_content/
getting-started.md # Seed article
api-reference.md # Seed article
style.css # Static: GET /style.css
favicon.ico # Static: GET /favicon.ico
index.rex # Homepage: list all articles
articles/
[slug].rex # Render single article from _content/ or db
api/
_middleware.rex # Auth: API key validation
articles.rex # GET list / POST create
articles/
[slug].rex # GET / PUT / DELETE single article
health.rex # GET /health
/* Security headers + request ID for all requests (including static files) */
request-id = headers.x-request-id or time.uuid()
res.headers.x-request-id = request-id
res.headers.x-content-type-options = "nosniff"
res.headers.x-frame-options = "DENY"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>{{title}} — Knowledge Base</title>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<header><a href="/">Knowledge Base</a></header>
<main>{{{body}}}</main>
<footer>Powered by Rex</footer>
</body>
</html>/* List all articles: merge _content/ files with db-stored articles */
res.headers.content-type = "text/html"
layout = fs.read("_layouts/page.html")
// Articles from the filesystem
file-slugs = fs.glob("_content/*.md")
// Articles from the database
db-articles = db.list("article:")
db-items = [json.parse(a.value) for a in db-articles]
// Build article list HTML
list-html = "<ul>"
for slug in file-slugs do
name = slug.replace("_content/", "").replace(".md", "")
list-html += "<li><a href=\"/articles/" + name + "\">" + name + "</a></li>"
end
for item in db-items do
list-html += "<li><a href=\"/articles/" + item.slug + "\">" + item.title + "</a></li>"
end
list-html += "</ul>"
template.render(layout, {
title: "Home"
body: "<h1>Articles</h1>" + list-html
})/* Render a single article from _content/ or from the database */
res.headers.content-type = "text/html"
layout = fs.read("_layouts/page.html")
slug = params.slug
// Try filesystem first
file-content = fs.read("_content/" + slug + ".md")
// Fall back to database
db-record = unless file-content do db.get("article:" + slug) end
db-article = when db-record do json.parse(db-record) end
content = file-content or (db-article and db-article.body)
title = (db-article and db-article.title) or slug
unless content do
res.status = 404
template.render(layout, {title: "Not Found", body: "<h1>Article not found</h1>"})
end
when content do
html-body = markdown.render(content)
template.render(layout, {title: title, body: html-body})
end/* API key auth for all /api routes */
api-key = headers.authorization
unless api-key do
res.status = 401
{ok: false, error: "missing_api_key"}
end
when api-key do
key-valid = db.get("keys:" + api-key)
unless key-valid do
res.status = 401
{ok: false, error: "invalid_api_key"}
end
principal = api-key
end/* Article management API */
when method == "GET" do
articles = db.list("article:")
items = [json.parse(a.value) for a in articles]
{ok: true, articles: [{slug: a.slug, title: a.title, updated: a.updated} for a in items]}
end
when method == "POST" do
input = json.parse(body)
unless input and input.slug and input.title and input.body do
res.status = 422
{ok: false, error: "slug_title_body_required"}
end
when input and input.slug and input.title and input.body do
record = {
slug: input.slug
title: input.title
body: input.body
created: time.now()
updated: time.now()
}
db.set("article:" + input.slug, json.stringify(record))
res.status = 201
{ok: true, slug: input.slug}
end
end
unless method == "GET" or method == "POST" do
res.status = 405
{ok: false, error: "method_not_allowed"}
end/* Single article: GET, PUT, DELETE */
slug = params.slug
when method == "GET" do
record = db.get("article:" + slug)
unless record do
res.status = 404
{ok: false, error: "not_found"}
end
when record do
{ok: true, article: json.parse(record)}
end
end
when method == "PUT" do
input = json.parse(body)
existing = db.get("article:" + slug)
unless existing do
res.status = 404
{ok: false, error: "not_found"}
end
when existing do
old = json.parse(existing)
updated = {
slug: slug
title: input.title or old.title
body: input.body or old.body
created: old.created
updated: time.now()
}
db.set("article:" + slug, json.stringify(updated))
{ok: true, slug: slug}
end
end
when method == "DELETE" do
db.delete("article:" + slug)
{ok: true, deleted: slug}
end
unless method == "GET" or method == "PUT" or method == "DELETE" do
res.status = 405
{ok: false, error: "method_not_allowed"}
end| Feature | Where |
|---|---|
| Static file serving | style.css, favicon.ico served directly |
| Dynamic routing | [slug].rex in both articles/ and api/articles/ |
Private dirs (_) |
_layouts/, _content/ — not routable, read by handlers |
fs.read() + fs.glob() |
Homepage lists _content/*.md, article page reads markdown |
markdown.render() |
Article pages render .md → HTML |
template.render() |
All HTML pages use _layouts/page.html |
| Filesystem + DB fallback | Article page checks _content/ first, then database |
| CRUD API | /api/articles + /api/articles/[slug] |
| Auth middleware | /api/_middleware.rex protects all API routes |
| Middleware on static | Global middleware adds security headers to CSS/favicon too |
| JSON responses | API routes return objects (auto-serialized) |
| HTML responses | Content routes set content-type: text/html, return strings |
- Create
crates/rex-serve/with Cargo.toml router.rs: scan directory, compile.rex→ bytecode, build route tablerefs.rs:RequestHostObject,ResponseHostObjectimplementingHostObjecthandler.rs: match route → build Context →rex_core::interpret::run()→ extract responseserver.rs: axum fallback handler- Test with
health.rexreturning{ok: true}
static_files.rs: serve non-.rexfiles withmime_guesscontent-type detection- Integrate into handler:
.rexmatch → dynamic, else → static file, else → 404 - Skip
_prefixed files/dirs in static serving - Test with
style.cssserved at/style.css
middleware.rs: scan for_middleware.rex, sort root→leaf- Chain execution in handler: run each, check short-circuit, propagate vars
- Middleware runs for both dynamic AND static requests
- Test with global middleware adding headers to static files
- Parse
[param]and[...rest]from filenames - Inject
paramsref (PA) - Route specificity sorting
opcodes.rs: register db., json., log., time., crypto.*- SQLite backend for db.* via rusqlite + r2d2
fs.read(),fs.glob(),fs.meta()— sandboxed to project rootmarkdown.render()via pulldown-cmarktemplate.render()— mustache-style{{key}}/{{{raw}}}substitution- Test with a
.rexhandler that reads markdown + renders through a template
reload.rs: notify watcher, atomic route table swap- Graceful shutdown, request logging, error recovery
rex-serve.tomlconfig
- Write
rex-serve.rexd— the domain type interface for IDE support - Write all the route files, layouts, content, and static assets shown above
- End-to-end test: homepage lists articles, article page renders markdown, API CRUD works, static CSS served, middleware adds headers everywhere
packages/rusty-rex/Cargo.toml— addrex-serveto workspace memberspackages/rusty-rex/crates/rex-core/src/interpret.rs—Context,HostObject,run(),RunResultpackages/rusty-rex/crates/rex-core/src/lib.rs—compile()functionpackages/rusty-rex/crates/rex-cli/src/main.rs— reference for Context setup (line 247)samples/http-domain/.config.rex— canonical ref definitions (short codes)rex-types.md—.rexdtype file specification
cargo build -p rex-servecompiles- Start server:
cargo run -p rex-serve -- --dir knowledge-base/routes curl localhost:3000/health→{"ok": true}curl localhost:3000/style.css→ CSS content withcontent-type: text/css+ security headers from middlewarecurl localhost:3000/→ HTML page listing articles from_content/directorycurl localhost:3000/articles/getting-started→ rendered markdown article in HTML layoutcurl localhost:3000/api/articleswithout auth → 401curl -X POST localhost:3000/api/articles -H "Authorization: <key>" -d '{"slug":"new","title":"New","body":"# New\nHello"}'→ 201curl localhost:3000/articles/new→ newly created article rendered from DB- Hot reload: edit
_layouts/page.html→ change reflected on next request