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:

FieldShape
req.method"GET", "POST", …
req.urlAbsolute URL string (reconstructed from the Host header), so new URL(req.url) always parses
req.headersA real Headers instance, get/has/iteration all work; names lowercased
req.bodyThe 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:

  1. A string: a 200 with content-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 ``

  1. A plain object: { status, headers, body }, each optional (status defaults to 200):

``sh $ curl -i http://127.0.0.1:18441/obj HTTP/1.1 201 Created x-via: obj obj body ``

  1. 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 a TypeError.
  • options.index (optional) — the filename served when a request resolves to a directory. A string sets a custom index; false or null disables directory indexing (a directory then resolves to nothing); anything else, and the default when options is omitted, is "index.html".

It returns a resolver with one method:

  • resolver.respond(request) — takes a request (any object with a url, and optionally method and headers) and returns either a Response-shaped object for a file it can serve, or null when it cannot. null is the fall-through signal: your handler returns its own 404 (or tries another resolver). staticDir never 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 returns null, 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:

Text.html/.htm → text/html · .css → text/css · .js/.mjs → text/javascript · .json → application/json · .txt → text/plain (all charset=utf-8)
Binary.svg → image/svg+xml · .png → image/png · .jpg/.jpeg → image/jpeg · .gif → image/gif · .wasm → application/wasm
Fallbackanything else → application/octet-stream

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 headerConditionResult
If-None-Matchthe ETag matches (or *)304 Not Modified, empty body
If-Modified-Sincethe file is not newer than the date304 Not Modified, empty body
If-Matchthe ETag does not match412 Precondition Failed, empty body
If-Unmodified-Sincethe file is newer than the date412 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 as multipart/byteranges; it falls back to the full 200.
  • A small MIME table. Extensions outside the list above are application/octet-stream; there is no configurable type map yet.
  • A weak ETag derived 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

SurfaceShapeWhen
cruft:serve serve(opts)options bag, auto-listenthe default choice for Cruft-first servers
cruft:http createServer(handler)construct then .listen() yourselfwhen you need the server object before listening
node:http createServer((req, res) => …)Node's EventEmitter idiomecosystem 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.