Targ Apps Docs
Chroma

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 dev execs the prebuilt engine binary 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 = true

The 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-app

parse_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:

  1. Empty path → index.html. / (or an empty path after decoding) maps to root/index.html.
  2. Directory → index.html. If the resolved target is a directory (e.g. /pages/root/pages), index.html is appended inside it. There's no directory listing fallback — a directory with no index.html simply falls through to the next step and 404s.
  3. Canonicalize, or 404. fs::canonicalize resolves the target to an absolute, symlink-free path. If the path doesn't exist, this fails and the response is 404 Not Found.
  4. 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 with 403 Forbidden instead of being served. Only if it passes does fs::read return the file's bytes as 200 OK; a read failure at this point (permissions, race with a deleted file) still falls back to 404.

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:

ExtensionContent-Type
htmltext/html; charset=utf-8
js, mjstext/javascript; charset=utf-8
csstext/css; charset=utf-8
json, mapapplication/json; charset=utf-8
wasmapplication/wasm
svgimage/svg+xml
pngimage/png
jpg, jpegimage/jpeg
icoimage/x-icon
txttext/plain; charset=utf-8
anything else / no extensionapplication/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:

StatusReasonWhen
200OKFile resolved and read successfully.
403ForbiddenResolved path canonicalizes outside the served root.
404Not FoundPath doesn't exist, or (after the directory→index.html fallback) still doesn't resolve to a file.
405Method Not AllowedMethod 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   # Windows

Both 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 test

runs 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

PropertyValue
DependenciesNone — std only
ProtocolHTTP/1.1, no TLS
Bind address127.0.0.1 only, never 0.0.0.0
MethodsGET, HEAD — everything else is 405
ConcurrencyOne OS thread per connection, no pooling
Keep-aliveNone — every response sends Connection: close
Directory listingNone — directories fall back to index.html or 404
Path safetyCanonicalize + starts_with(root) check on every request
Query stringsStripped before resolution, never inspected
Request sizeSingle 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 dev runs this exact binary against your project directory.
  • Once you've run chroma build and imported the resulting .zip into 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).

On this page