Server
The engine binary — a zero-dependency, std-only static HTTP server that backs chroma dev and Chroma Desktop.
Chroma-Server is a single Rust binary, engine, built from Chroma-Server/src/main.rs — 214 lines, no external crates. It does one thing: serve static files from a directory over HTTP/1.1, on 127.0.0.1 only.
Two things in the Chroma pipeline use this exact design:
chroma devexecs the prebuiltenginebinary directly, rooted at your project directory.- Chroma Desktop compiles an independent Rust module (
src/server.rs) built to the same design, rooted at its whole apps directory instead of a single project.
This page documents the design from the actual source in Chroma-Server/src/main.rs; where Chroma Desktop's copy differs, it's called out explicitly.
Package
[package]
name = "chroma-server"
version = "0.1.0"
edition = "2024"
description = "Single-file HTTP static server used by the Chroma CLI to serve scaffolded apps."
[[bin]]
name = "engine"
path = "src/main.rs"
[profile.release]
opt-level = "z"
lto = true
strip = trueThe crate is named chroma-server, but the binary it produces is named engine — that's the name chroma install copies to $CHROMA_HOME/bin/engine (engine.exe on Windows). The release profile is tuned for a small binary (opt-level = "z", LTO, symbol stripping) rather than raw throughput, appropriate for a tool that's launched once per chroma dev session and does very little per request.
There are no [dependencies] at all — everything below is built with std::net, std::fs, std::io, and std::thread.
Startup and binding
engine --port 4173 --dir ./my-appparse_args walks env::args() looking for --port <n> and --dir <path>, defaulting to port 4173 and . (the current directory) if either is omitted or unparseable:
fn parse_args() -> (u16, PathBuf) {
let mut port: u16 = 4173;
let mut root = PathBuf::from(".");
// ...matches "--port" and "--dir", falls through everything else
}Before binding, the root directory is canonicalized (fs::canonicalize) — resolving ./.. segments and symlinks into an absolute path. If that fails (directory doesn't exist, permissions, etc.), engine prints an error to stderr and exits with status 1. This canonical path is also the value every served request is later checked against, so traversal protection is anchored to a resolved, symlink-free root from the start.
The listener binds with:
TcpListener::bind(("127.0.0.1", port))Binding is hardcoded to 127.0.0.1 — there is no flag or configuration path to bind 0.0.0.0 or any other interface. A chroma dev server (or an engine you run standalone) is never reachable from another machine on the network, only from processes on the same host. If the bind fails (port already in use, no permission on the port, etc.), engine prints the OS error and exits 1.
On success it prints engine: serving <root> at http://localhost:<port> and starts accepting connections.
Chroma Desktop's src/server.rs binds ("127.0.0.1", 0) instead of a fixed port — port 0 tells the OS to assign any free ephemeral port, which TcpListener::local_addr() then reads back. That's how Desktop can run any number of imported apps without a port conflict, without the user ever choosing a port.
Concurrency model
for connection in listener.incoming() {
match connection {
Ok(stream) => {
let root = root.clone();
thread::spawn(move || {
let _ = handle_connection(stream, &root);
});
}
Err(err) => eprintln!("engine: accept error: {err}"),
}
}The accept loop runs on the main thread; every accepted connection is handed to a fresh OS thread via thread::spawn, which handles exactly one request/response and then returns (dropping the TcpStream, which closes the socket). There's no thread pool, no connection reuse, and no keep-alive — Connection: close is sent on every response (see Response format below), so browsers open a new TCP connection (and therefore a new thread) per request. For a local dev server or a handful of imported desktop apps, this trade-off favors simplicity over throughput, and there's no meaningful limit on concurrent connections beyond what the OS/thread scheduler will tolerate.
Errors returned by handle_connection (a malformed request, a write failure mid-response, etc.) are silently discarded with let _ = ... — a broken individual connection never crashes the server or affects other in-flight requests.
Request parsing
fn handle_connection(mut stream: TcpStream, root: &Path) -> std::io::Result<()> {
let mut buf = [0u8; 8192];
let read = stream.read(&mut buf)?;
let request = String::from_utf8_lossy(&buf[..read]);
let request_line = request.lines().next().unwrap_or("");
let mut parts = request_line.split_whitespace();
let method = parts.next().unwrap_or("");
let raw_path = parts.next().unwrap_or("/");
...
}A single read() into a fixed 8 KB stack buffer is the entire request parsing step — there's no loop to read more data if the request line and headers exceed 8 KB, and request bodies are never read at all. This is intentional: GET/HEAD requests for static assets fit comfortably within 8 KB of headers, and the server never needs to look past the request line (the HTTP method and path) to answer one. Everything after the first line — headers, any body — is read into the buffer (if it fits) but never inspected.
Only GET and HEAD are accepted:
if method != "GET" && method != "HEAD" {
return write_response(&mut stream, 405, "text/plain; charset=utf-8", b"Method Not Allowed", false);
}Any other method (POST, PUT, OPTIONS, …) gets a 405 Method Not Allowed with no further processing.
The raw request-target is split on ?/# to drop any query string or fragment before path resolution — engine has no notion of query parameters; /index.html?v=2 and /index.html resolve identically.
Path resolution
fn resolve(root: &Path, url_path: &str) -> (u16, &'static str, Vec<u8>) {
let relative = url_path.trim_start_matches('/');
let mut target = if relative.is_empty() {
root.join("index.html")
} else {
root.join(relative)
};
if target.is_dir() {
target = target.join("index.html");
}
let canonical = match fs::canonicalize(&target) {
Ok(path) => path,
Err(_) => return (404, "text/plain; charset=utf-8", b"Not Found".to_vec()),
};
if !canonical.starts_with(root) {
return (403, "text/plain; charset=utf-8", b"Forbidden".to_vec());
}
match fs::read(&canonical) {
Ok(bytes) => (200, content_type_for(&canonical), bytes),
Err(_) => (404, "text/plain; charset=utf-8", b"Not Found".to_vec()),
}
}Before path resolution, the raw URL path is percent-decoded (%20 → space, etc. — see Percent-decoding below). Resolution then proceeds in four steps:
- Empty path →
index.html./(or an empty path after decoding) maps toroot/index.html. - Directory →
index.html. If the resolved target is a directory (e.g./pages/→root/pages),index.htmlis appended inside it. There's no directory listing fallback — a directory with noindex.htmlsimply falls through to the next step and 404s. - Canonicalize, or 404.
fs::canonicalizeresolves the target to an absolute, symlink-free path. If the path doesn't exist, this fails and the response is404 Not Found. - Root check, or 403. The canonical path must start with the canonical root computed at startup (
canonical.starts_with(root)). Any resolved path that lands outside the root — via..segments, an absolute path smuggled through decoding, or a symlink that points outside the served directory — is rejected with403 Forbiddeninstead of being served. Only if it passes doesfs::readreturn the file's bytes as200 OK; a read failure at this point (permissions, race with a deleted file) still falls back to404.
This is the traversal protection referenced throughout the CLI and Desktop docs: it isn't string matching on .. in the URL, it's canonicalizing the resolved filesystem path and checking it's still inside the root — which also catches encoded traversal attempts and symlink escapes, not just literal ../ in the request.
This exact logic is covered by a unit test in main.rs (resolve_rejects_paths_that_escape_the_root): it serves a public/ subdirectory, confirms / correctly returns public/index.html, and confirms /../secret.txt (a file one level above the served root) does not return 200.
Content-type inference
Content-Type is inferred purely from the file extension — there's no magic-byte sniffing:
| Extension | Content-Type |
|---|---|
html | text/html; charset=utf-8 |
js, mjs | text/javascript; charset=utf-8 |
css | text/css; charset=utf-8 |
json, map | application/json; charset=utf-8 |
wasm | application/wasm |
svg | image/svg+xml |
png | image/png |
jpg, jpeg | image/jpeg |
ico | image/x-icon |
txt | text/plain; charset=utf-8 |
| anything else / no extension | application/octet-stream |
This covers everything a chroma build output needs: HTML entry, JS (bundled or minified-as-written), CSS, source maps, the chroma_bg.wasm engine binary, and common static assets.
Response format
let header = format!(
"HTTP/1.1 {status} {reason}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
body.len(),
);
stream.write_all(header.as_bytes())?;
if !head_only {
stream.write_all(body)?;
}Every response is HTTP/1.1, with exactly three headers — Content-Type, Content-Length, and Connection: close — and no others (no Date, no Server, no caching headers, no CORS headers). HEAD requests get the full header block with a correct Content-Length but no body, per the HTTP spec.
Status codes are limited to four, with a fixed reason phrase for each:
| Status | Reason | When |
|---|---|---|
200 | OK | File resolved and read successfully. |
403 | Forbidden | Resolved path canonicalizes outside the served root. |
404 | Not Found | Path doesn't exist, or (after the directory→index.html fallback) still doesn't resolve to a file. |
405 | Method Not Allowed | Method isn't GET or HEAD. |
All four error bodies are plain text (b"Not Found", b"Forbidden", b"Method Not Allowed") — there's no HTML error page, no custom 404 page support.
Percent-decoding
fn percent_decode(input: &str) -> String {
let bytes = input.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' && i + 2 < bytes.len() {
if let Ok(byte) = u8::from_str_radix(&input[i + 1..i + 3], 16) {
out.push(byte);
i += 3;
continue;
}
}
out.push(bytes[i]);
i += 1;
}
String::from_utf8_lossy(&out).into_owned()
}A minimal, hand-rolled %XX decoder: it walks the path byte by byte, and whenever it sees a % followed by two valid hex digits, decodes that byte; everything else passes through unchanged. Since escaped sequences are always ASCII, operating on raw bytes and re-validating as UTF-8 only at the end (String::from_utf8_lossy) is safe. Covered by a unit test (percent_decode_handles_escaped_and_plain_segments) exercising %20 → space and %25 → %.
Running it standalone
engine is a normal Cargo binary — you don't need the chroma CLI to build or run it. From a checkout of Chroma-Server:
cargo build --release
./target/release/engine --port 4173 --dir ./dist # macOS/Linux
.\target\release\engine.exe --port 4173 --dir .\dist # WindowsBoth flags are optional: omitting --port serves on 4173, omitting --dir serves the current directory. This is exactly what chroma dev does under the hood — it execs the installed copy of this same binary with --port <chroma.json's dev.port, or --port override> --dir ..
cargo testruns the two unit tests described above: content_type_for_maps_known_extensions, percent_decode_handles_escaped_and_plain_segments, and the traversal-protection test resolve_rejects_paths_that_escape_the_root.
Design summary
| Property | Value |
|---|---|
| Dependencies | None — std only |
| Protocol | HTTP/1.1, no TLS |
| Bind address | 127.0.0.1 only, never 0.0.0.0 |
| Methods | GET, HEAD — everything else is 405 |
| Concurrency | One OS thread per connection, no pooling |
| Keep-alive | None — every response sends Connection: close |
| Directory listing | None — directories fall back to index.html or 404 |
| Path safety | Canonicalize + starts_with(root) check on every request |
| Query strings | Stripped before resolution, never inspected |
| Request size | Single 8 KB read; larger requests/headers are truncated |
Where this fits in the pipeline
engine is the one piece of infrastructure shared, by design, across both ends of the Chroma tooling flow:
- While you're writing an app,
chroma devruns this exact binary against your project directory. - Once you've run
chroma buildand imported the resulting.zipinto Chroma Desktop, a second implementation of this same design serves the imported app instead — same request handling, same traversal protection, different root (the whole apps directory, multiplexed by/<app-id>/prefix) and different port-selection strategy (OS-assigned instead of fixed/configurable).