runtime:http
An HTTP/1.1 and HTTP/2 server: serve((request) => response). The handler takes a web Request and returns a web Response — the same Fetch API objects fetch uses.
Exposed as an ES module under the runtime: scheme. Status: Available.
A handler error or a non-Response return becomes a 500. Request and response bodies stream with backpressure; string/byte bodies use the buffered fast path (Content-Length). HTTP/1.1 and HTTP/2 are both served on one port — ALPN over TLS, h2c by prior knowledge on cleartext — and the version never reaches the handler. TLS terminates in-process with secureTransport: "on". See the HTTP server guide for patterns and examples.
Import
import { serve, trailersOf, withTrailers, Cookie, CookieMap, CSRF, Webhook, WebhookVerificationError } from "runtime:http";
Functions
| Function | Type | Description | Example |
|---|---|---|---|
serve(handler) | (Handler) => Server | Start a server on an ephemeral port (read it from server.addr). handler is (request) => response. | serve((req) => new Response("hi")) |
serve(options, handler) | ({ hostname?, port?, secureTransport?, cert?, key?, alpn?, timeouts?, maxConnections?, maxConnectionsPerIp?, reusePort?, trustTraceHeaders? }, Handler) => Server | Start a server bound to options. hostname defaults to 0.0.0.0 (all interfaces — pass 127.0.0.1 for a loopback-only server; a locked-down host may refuse a wildcard bind). port 0 picks an ephemeral port. secureTransport: "on" terminates TLS (needs inline PEM cert + key; alpn defaults to ["h2", "http/1.1"]). timeouts bounds connections that stall — see below. | serve({ port: 443, secureTransport: "on", cert, key }, handler) |
Serve
import { serve } from "runtime:http"; // The handler takes a web Request and returns a web Response — // the same Fetch API objects fetch() uses. const server = serve({ port: 8080 }, async (request) => { const url = new URL(request.url); if (url.pathname === "/echo") { return new Response(await request.text(), { status: 200 }); } return Response.json({ method: request.method, path: url.pathname }); }); const { hostname, port } = await server.addr; console.log(`listening on http://${hostname}:${port}`);
Trailers
Header fields that follow the body — where gRPC carries the status of a call.
| Export | Signature | Notes |
|---|---|---|
withTrailers(response, trailers) | (Response, HeadersInit | Promise<HeadersInit>) => Response | Sends them after the body. Returns the same Response. |
trailersOf(response) | (Response) => Promise<Headers> | Reads them, once the body has been consumed. Empty when there are none. |
import { serve, withTrailers, trailersOf } from "runtime:http"; serve({ port: 8080 }, () => withTrailers(new Response(body), { "grpc-status": "0" })); const response = await fetch(url); await response.text(); // trailers follow the body (await trailersOf(response)).get("grpc-status");
Exports rather than members of Response: trailers are not part of the Fetch API, and no runtime exposes them there. Sent on both HTTP versions. On HTTP/1.1 the response must be chunked and only fields named in a Trailer header travel — that header is added automatically when the names are known before the head goes out.
Connection info
The handler's second argument describes the connection. Taking it is optional — a one-parameter handler is unaffected.
serve({ port: 8080 }, (request, info) => { // info.remoteAddr = { transport: "tcp", hostname: "203.0.113.7", port: 54321 } return new Response(`hello ${info.remoteAddr.hostname}`); });
| Field | Type | Notes |
|---|---|---|
remoteAddr | { transport, hostname, port } | null | The socket peer. null when the host has no peer to report. Same shape Deno.serve passes. |
remoteAddr is the socket peer and only that: behind a reverse proxy it is the proxy, and X-Forwarded-For is never consulted — a header anyone can send is not an identity until something says which hop to trust. It is delivered untouched, so a deployment that knows can resolve it itself. On HTTP/2 every stream of one connection reports the same peer.
Timeouts
timeouts bounds connections that are idle or stalled. A request in flight, a body still arriving, and a response still streaming are never interrupted.
| Key | Default | Bounds |
|---|---|---|
handshake | 10000 | Accept → ready to carry requests: the TLS handshake, and the wait for the first byte |
headerRead | 30000 | A request head arriving in full; on HTTP/1.1 also the idle keep-alive limit |
h2KeepAlive | 20000 | PING probes on an idle HTTP/2 connection; a dead peer goes within twice this |
bodyRead | 30000 | A request body arriving, before the allowance bodyMinRate earns it |
bodyMinRate | 1024 | Bytes per second that extend bodyRead; 0 makes it a flat cap |
Each is a number of milliseconds (bodyMinRate, bytes per second). null disables one, omitting it keeps the default, and a non-number is a TypeError at the call rather than a bound port.
A body's deadline is bodyRead + received / bodyMinRate: an upload extends its own by arriving, a peer dribbling a byte a minute does not. It reaches the handler as the body stream erroring with ERR_TIMED_OUT.
serve({ port: 8080, timeouts: { headerRead: 5000, h2KeepAlive: null } }, handler);
Connection limit
Trace ids
Every request runs in its own runtime:context root scope with a freshly minted W3C trace id.
import { currentTask } from "runtime:context"; serve({ port: 8080 }, () => new Response(currentTask().traceId));
trustTraceHeaders: true adopts the id from an inbound traceparent instead. Off by default — the header comes from whoever opened the connection, so believing it lets any client stitch its requests into another tenant's trace. Set it only behind a proxy that overwrites the header. A malformed or all-zero id is never adopted.
serve({ port: 8080, trustTraceHeaders: true }, handler);
Nothing is minted until the program imports runtime:context.
Sharing a port
reusePort: true lets several processes bind the same address; the kernel balances new connections across them. Unix only.
// Each of N processes; every one must set it. serve({ port: 8080, reusePort: true }, handler);
A plain bind on a held port is still ERR_ADDRESS_IN_USE.
maxConnections caps how many connections are served at once. Unlimited by default — the right number follows from your file-descriptor budget and the memory a connection costs, neither of which the runtime can read.
serve({ port: 8080, maxConnections: 10_000 }, handler);
A connection over the cap is held, not refused: the server stops accepting, so it waits in the kernel's backlog and is served as soon as a slot frees. Nothing is spent on it while it waits.
maxConnectionsPerIp caps how many of those one peer address may hold. Without it, maxConnections says nothing about whose connections fill it — one peer opening every slot fills the server exactly as a thousand peers opening one each do.
serve({ port: 8080, maxConnections: 10_000, maxConnectionsPerIp: 64 }, handler);
A connection over this one is refused, not held: an excess against the whole-server cap is legitimate traffic queueing for a slot, while an excess here is one client past its share, already accepted and holding a descriptor it decides when to release.
The count is per address, and every connection through a load balancer or NAT gateway carries the same source — so a cap here caps the whole service. Use the proxy's own per-client limits. That is why it is off by default.
The per-connection limits it multiplies against are fixed:
| Limit | Value |
|---|---|
| HTTP/1.1 header fields | 100 |
| HTTP/1.1 read buffer | ~408KB |
| HTTP/2 header list | 16KB (advertised in SETTINGS) |
| HTTP/2 concurrent streams | 256 (advertised in SETTINGS) |
Bodies
| Direction | ReadableStream | string / bytes |
|---|---|---|
| Request | request.body pulls chunks as they arrive | request.text() / .json() / .arrayBuffer() materialize the full body |
| Response | Chunked transfer-encoding, backpressured | Content-Length buffered fast path |
A runtime:fs file is a body too: new Response(file(path)) sends the file without reading it into JavaScript. Sending it needs the read permission. A file that can't be read is a 500 with an empty body, and the error is reported. No Content-Type is inferred; set it in the response headers.
import { file } from "runtime:fs"; serve({ port: 8080 }, () => new Response(file("public/index.html"), { headers: { "content-type": "text/html; charset=utf-8" }, }));
Lifecycle
serve() returns a Server immediately; the accept loop runs in the background.
const server = serve(handler); // ephemeral port const { port } = await server.addr; // resolved once listening // … handle requests … await server.stop(); // stop accepting; finished resolves
Server
| Member | Type | Description |
|---|---|---|
addr | Promise<{ hostname, port }> | The bound address; resolves once the server is listening. |
finished | Promise<void> | Resolves when the accept loop has ended (after stop()). |
stop() | Promise<void> | Stop accepting and shut down; resolves once stopped. |
Cookies
import { serve } from "runtime:http"; serve((req) => { const session = req.cookies.get("session"); // null when absent req.cookies.set("visited", "yes", { httpOnly: true }); req.cookies.delete("legacy"); return new Response(session ? "welcome back" : "hello"); });
request.cookies is a CookieMap of the request's Cookie header. Anything you set or delete on it is sent as a Set-Cookie header with whatever Response you return. Outside a handler, build Cookie and CookieMap yourself and use toSetCookieHeaders(). The API follows Bun's.
| Member | Description |
|---|---|
new Cookie(name, value, options?), new Cookie(init), new Cookie(setCookieString) | One cookie. options: domain, path (default "/"), expires (Date or ms), maxAge (integer seconds), secure, httpOnly, sameSite ("strict" | "lax" | "none", any case, default "lax"), partitioned. |
Cookie.parse(text), Cookie.from(name, value, options?) | From a Set-Cookie value (percent-decoded), or from parts. |
cookie.serialize() / toString() | The Set-Cookie value, with the value percent-encoded. |
cookie.isExpired(), cookie.toJSON() | maxAge decides expiry when there is one. |
new CookieMap(init?) | From a Cookie header string, an object, or [name, value] pairs. Malformed pairs are skipped, the first of a duplicated name wins, and values are percent-decoded. |
get, has, size, entries, keys, values, forEach, iteration | Read the cookies by name. get returns null when absent. |
set(name, value, options?), set(init), set(cookie) | Record a cookie to send. |
delete(name, options?), delete({ name, path?, domain?, secure?, partitioned? }) | Record an expired cookie with that path and domain. A __Host-/__Secure- cookie needs secure: true. |
toSetCookieHeaders() | What changed, as Set-Cookie values. |
setSigned(name, value, { secrets, ...options }) | Record a cookie whose value stays readable but cannot be changed (HMAC-SHA256). |
getSigned(name, { secrets }) | The signed value, or null. |
setSealed(name, value, { secrets, ...options }) | Record a cookie whose value is encrypted and authenticated (AES-256-GCM). Needs Entropy for its nonce. |
getSealed(name, { secrets }) | The sealed value, or null. |
A cookie a browser would discard throws a TypeError naming the rule. This happens when it is serialized or recorded with set/delete, and covers:
a name that is not a token;
a non-integer
maxAge, or an invalidexpires;;or a control character indomainorpath;SameSite=NoneorPartitionedwithoutSecure;a
__Secure-cookie withoutSecure;a
__Host-cookie withoutSecure, with aDomain, or with aPathother than/;a name and value over 4096 bytes together, measured after percent-encoding, or a
DomainorPathover 1024 bytes (RFC 6265bis §5.6).
Bun emits these cookies anyway, and the browser drops them without an error.
Signed and sealed cookies
secrets is a non-empty array of strings, bytes or Secrets from runtime:process, each at least 32 bytes. The first signs or seals; every one is tried when reading, so a key is rotated by putting the new one first.
The cookie name and an expiry are protected with the value. The expiry comes from maxAge or expires and is checked when reading, even if the browser kept the cookie longer; a cookie with neither has none. getSigned and getSealed return null when the cookie is missing, malformed, changed, under none of the secrets, moved from another name, or expired.
| Format | Overhead | |
|---|---|---|
| Signed | <value>.<expiry>.<tag>, the tag base64url | about 45 bytes |
| Sealed | base64url of nonce, ciphertext and tag | 36 bytes, then a third for base64url |
A short or missing secret, or a value that is not a string, is a TypeError. The size limit above applies to the signed or sealed text, so a sealed value has about 3 KB of room.
In a handler, request.cookies is a CookieMap of the request's cookies, parsed on first read. Its set and delete calls are sent with the response as Set-Cookie headers, next to the response's own.
CSRF
CSRF (D162) follows Bun's Bun.CSRF. A token is an HMAC-SHA256 over the session ID, a random nonce, its issue time and its lifetime; nothing is stored.
| Function | Description |
|---|---|
CSRF.generate(secret, { sessionId, expiresIn? }) | A token (86 characters, base64url) for sessionId, valid for expiresIn milliseconds (default 24 hours). Draws a nonce, so it needs Entropy. |
CSRF.verify(token, { secret, sessionId, maxAge?, request?, trustedOrigins? }) | true if token was issued for sessionId under the secret and is younger than both its lifetime and maxAge (default 24 hours). |
secret is a string, bytes or Secret of at least 32 bytes, or an array of them for rotation: generate uses the first, verify accepts any.
With request, verify also fails when the browser reports another site: a Sec-Fetch-Site of cross-site or same-site, or, without that header, an Origin other than the request's own. An Origin listed in trustedOrigins passes. A request with neither header is judged by the token alone.
verify returns false, and never throws, for a missing, non-string, malformed, changed, foreign or expired token. These are TypeErrors: a missing secret or sessionId, a secret under 32 bytes, an unknown option, a non-positive expiresIn or maxAge, a request that is not a Request, and an encoding other than "base64url" or an algorithm other than "sha256" (Bun's other formats).
Different from Bun: the secret and sessionId are required, verify does not throw for a bad token, and request is checked.
Webhooks
Webhook (D163) signs and verifies webhooks by the Standard Webhooks specification, with the API and error messages of its reference library, standardwebhooks.
| Member | Description |
|---|---|
new Webhook(secret), new Webhook([secret, …]) | One key, or several newest first for rotation. A key is a whsec_ HMAC secret (base64, at least 24 bytes; the prefix is optional), a whsk_ Ed25519 secret key (32-byte seed, or 64 bytes of seed then public key), a whpk_ Ed25519 public key, or a Secret holding one. |
new Webhook(bytes, { format: "raw" }) | An HMAC key given as its bytes (or a string's UTF-8), at least 24 bytes. |
Webhook.generateSecret() | A whsec_ secret of 32 random bytes. Needs Entropy. |
Webhook.generateKeyPair() | { secretKey, publicKey }: whsk_ (64 bytes) and whpk_. Needs Entropy. |
webhook.sign(msgId, timestamp, payload) | The webhook-signature value: v1,<base64> for each HMAC key and v1a,<base64> for each Ed25519 secret key, space-separated. timestamp is a Date; payload a string or bytes. |
webhook.verify(payload, headers, { jsonParse?, tolerance? }) | Checks webhook-id, webhook-timestamp and webhook-signature (from a Headers or an object) against the raw payload. Returns the parsed JSON, or undefined for an empty body or with jsonParse: false. tolerance is in seconds, default 300. |
The signed content is ${msgId}.${timestamp}.${payload}, with the timestamp in whole seconds. verify accepts the delivery if any v1 signature matches an HMAC key or any v1a signature verifies with an Ed25519 key; signatures of other versions are ignored.
verify throws WebhookVerificationError with one of these messages:
| Message | When |
|---|---|
Missing required headers | A webhook-* header is absent or empty. |
Invalid Signature Headers | webhook-timestamp is not a whole number. |
Message timestamp too old / too new | The timestamp is more than tolerance seconds from now. |
No matching signature found | No signature matches any key. |
These are TypeErrors: a secret under 24 bytes, invalid base64, a whpk_ key that is not 32 bytes, a whsk_ key whose two halves disagree, an unknown option, sign with only public keys, an empty msgId, a timestamp that is not a valid Date, and a negative tolerance.
Beyond the reference library: v1a signatures, several keys, a Headers for verify, tolerance, key generation, and the specification's 24-byte minimum (the reference library refuses only an empty secret).
Errors
| Error | When |
|---|---|
TypeError | The serve() handler isn't a function; secureTransport is "on" without both cert and key, or is a value other than "on"/"off"; or a Request / Response is built with an invalid init (the Fetch API's own checks). |
Error | code "ERR_TLS" — the cert or key could not be parsed. Raised by serve() itself, so a bad cert never becomes a port that rejects every handshake. |
DOMException | name "NotAllowedError" — the NetListen capability is not granted. |
TypeError | A Cookie a browser would discard, when serialized or recorded with CookieMap.set/delete (see Cookies); a cookie name that is not a token. |
RangeError | A cookie expires that is not a valid date. |
See also
HTTP server guide — routing, streaming bodies, lifecycle, HTTPS