The primitive HTTP server
cruft:serve is a one-call web server built from a fetch-shaped handler (request in, response out) and an options bag. Covers the request and response shapes, async handlers, HTTPS, and the staticDir file resolver with its path containment, conditional-request, and byte-range behavior.
cruft:serve is the one-call web server on Cruft's primitive tier: a fetch-shaped handler (request → response) behind an options bag. It is a thin ergonomic adapter over cruft:http's createServer, which in turn shares its engine and transport with node:http, three surfaces over one engine, in the runtime's standard primitive/adapter pattern (here the primitive shape is the modern one: a function from request to response, with no (req, res) EventEmitter idiom).
The whole API
import { serve } from "cruft:serve";
const server = serve({
port: 18441, // required (number or string)
hostname: "127.0.0.1", // optional
onListen: () => console.log("listening"), // optional
tls: { key, cert }, // optional — HTTPS (see below)
handler: async (req) => { // required, callable
const url = new URL(req.url);
if (url.pathname === "/") return "hello";
return { status: 404, body: "nope" };
},
});
serve(options) builds the server, calls listen(port[, hostname]) for you, and returns the server object. A listening server keeps the process alive; server.close() ends it.
The request
The handler receives a plain, WHATWG-leaning request value:
| Field | Shape |
|---|---|
req.method | "GET", "POST", … |
req.url | Absolute URL string (reconstructed from the Host header), so new URL(req.url) always parses |
req.headers | A real Headers instance, get/has/iteration all work; names lowercased |
req.body | The buffered body as a string |
req.text() | Promise of the body string |
req.json() | Promise of the parsed body |
Example (POST with body and header):
$ curl -s -X POST -H "User-Agent: probe/1" -d '{"a":1}' http://127.0.0.1:18441/echo
{"method":"POST","got":{"a":1},"ua":"probe/1"}
where the handler was async (req) => ({ status: 200, body: JSON.stringify({ method: req.method, got: await req.json(), ua: req.headers.get("user-agent") }) }).
The response: three accepted shapes
The handler's return value (awaited if it is a promise) is interpreted as:
- A string: a
200withcontent-type: text/plain; charset=utf-8:
``sh $ curl -i http://127.0.0.1:18441/text HTTP/1.1 200 OK content-type: text/plain; charset=utf-8 ``
- A plain object:
{ status, headers, body }, each optional (statusdefaults to 200):
``sh $ curl -i http://127.0.0.1:18441/obj HTTP/1.1 201 Created x-via: obj obj body ``
- A WHATWG
Response: the canonical fetch-handler return:
``js return new Response(JSON.stringify({ ok: true }), { status: 200, headers: { "content-type": "application/json" } }); ``
Async handlers
Handlers may be async and may await anything the event loop can settle, microtasks, timers, native completions. The server pumps the loop until the handler's promise resolves. A handler awaiting a 50 ms timer responds correctly.
There is no graceful per-handler settle timeout. A handler whose promise never settles is not caught and reported: if the loop is otherwise idle, the connection simply hangs (the client eventually times out with no response), and if the pending handler keeps the loop busy (say a recursive setTimeout), the event loop hits its max-iteration safety bound and the whole server aborts with cruft: event-loop error: TypeError("run_to_completion: max-iteration safety bound exceeded"). Make sure every handler settles.
HTTPS
Pass tls: { key, cert } and the same handler is served over TLS 1.3 (Cruft's own TLS stack, with ALPN negotiating h2/http1.1), the decrypted request still arrives as the primitive fetch shape. This is cruft:http's createSecureServer({ key, cert }, handler) behind the options bag.
Static directories
staticDir serves files from a directory as a fetch-shaped resolver you drop into your handler. It is the same primitive Bun's { dir } routes lower through (below), so the path-containment and conditional-request behavior is identical whichever surface you reach it from.
import { serve, staticDir } from "cruft:serve";
const assets = staticDir("./public", { index: "index.html" });
serve({
port: 3000,
handler(request) {
const res = assets.respond(request); // Response, or null if nothing to serve
if (res) return res;
return new Response("Not Found", { status: 404 });
},
});
staticDir(root, options?)
root(string, required) — the directory to serve from. A non-string is aTypeError.options.index(optional) — the filename served when a request resolves to a directory. A string sets a custom index;falseornulldisables directory indexing (a directory then resolves to nothing); anything else, and the default whenoptionsis omitted, is"index.html".
It returns a resolver with one method:
resolver.respond(request)— takes a request (any object with aurl, and optionallymethodandheaders) and returns either aResponse-shaped object for a file it can serve, ornullwhen it cannot.nullis the fall-through signal: your handler returns its own 404 (or tries another resolver).staticDirnever invents an error response of its own, so you own the miss case, and a refused-for-safety request is indistinguishable from a plain miss (it returnsnull, it does not leak why).
Path resolution and containment
A request URL's path is taken (query and fragment stripped), percent-decoded, and resolved under root. The resolver fails closed on anything that could escape the directory, returning null:
- a path containing a NUL byte or a backslash;
- a
..parent segment, an absolute path, or a drive prefix; - a target whose canonical path does not stay under the canonical
root, so a symlink pointing outside the root is refused even though it lives inside it; - a path that resolves to a directory when indexing is disabled, or to anything that is not a regular file.
So GET /%2e%2e/secret.txt and a symlink to a file outside the root both return null (your handler serves its own 404), and nothing outside root is ever read.
Content type
The Content-Type is chosen from the file extension:
Binary files are served byte-exact. Every response also carries Accept-Ranges: bytes, a Last-Modified date, and a weak ETag derived from the file's size and modification time, both hex-encoded (W/"<size-hex>-<mtime-hex>", e.g. a 10-byte file yields W/"a-6a768cd1").
Conditional requests
The validators are honored against the file's ETag and modification time:
| Request header | Condition | Result |
|---|---|---|
If-None-Match | the ETag matches (or *) | 304 Not Modified, empty body |
If-Modified-Since | the file is not newer than the date | 304 Not Modified, empty body |
If-Match | the ETag does not match | 412 Precondition Failed, empty body |
If-Unmodified-Since | the file is newer than the date | 412 Precondition Failed, empty body |
A malformed or unparseable date is ignored (the request proceeds to a normal 200). If-Match/If-Unmodified-Since take precedence over the If-None-Match/If-Modified-Since pair.
Range requests
A single-range Range header is served:
Range: bytes=0-4 → 206, Content-Range: bytes 0-4/<len>, that slice
Range: bytes=6- → 206, from byte 6 to the end
Range: bytes=-6 → 206, the last 6 bytes
Range: bytes=99-100 → 416 Range Not Satisfiable, Content-Range: bytes */<len>
Range: bytes=nope → ignored, full 200
Range: bytes=0-1,3-4 → multi-range not supported, full 200
A 206 carries the sliced body and a Content-Range header. A HEAD request returns the full headers (including Content-Type, ETag, Last-Modified) with an empty body.
Through Bun.serve routes
Bun.serve's routes map projects { dir } entries through this exact resolver. A key of the form "/prefix/*" (it must end in /* and the prefix must end in /) whose value is { dir: "<path>" } serves that directory, with the prefix stripped from the matched path and index.html as the index:
Bun.serve({
port: 3000,
routes: {
"/static/*": { dir: "./public" }, // GET /static/app.css → ./public/app.css
},
fetch(request) { // anything the static routes don't serve
return new Response("fallback", { status: 404 });
},
});
Static routes are tried in turn; a request none of them can serve (a miss, or a containment refusal) falls through to fetch, so the same fail-closed safety holds, an escape attempt reaches your fetch as an ordinary unmatched request, not a served file.
Limitations
- Single range only. A multi-range
Range(comma-separated) is not served asmultipart/byteranges; it falls back to the full200. - A small MIME table. Extensions outside the list above are
application/octet-stream; there is no configurable type map yet. - A weak
ETagderived from size and mtime, not a content hash. - No directory listing. A directory resolves to its index file or to nothing; there is no auto-generated index page.
Relation to the other HTTP surfaces
| Surface | Shape | When |
|---|---|---|
cruft:serve serve(opts) | options bag, auto-listen | the default choice for Cruft-first servers |
cruft:http createServer(handler) | construct then .listen() yourself | when you need the server object before listening |
node:http createServer((req, res) => …) | Node's EventEmitter idiom | ecosystem compatibility (module page) |
All three run the same engine and transport; the primitive pair simply skips the Node adapter layer. Client limitations documented on the http module page (no redirects, no AbortSignal) are engine-shared and apply to outbound requests, not to serving.
Capabilities
Listening is network authority: under --sealed, grant it via --allow-net-loopback (loopback listen) or a caps net grant. Audit mode records listens and accepts like any other capability use.
For the capability-secure edge pattern built on serve + compartments, see The capability gateway.