Basic servers and requests
serve(options, handler) starts the accept loop in the background and returns a Server handle immediately. Await server.addr to learn the bound address once listening has started.
import { serve } from "runtime:http"; const server = serve({ port: 8080 }, (request) => { return new Response("Hello from esrun!"); }); const { hostname, port } = await server.addr; console.log(`listening on http://${hostname}:${port}`);
serve(handler) with no options picks an ephemeral port — useful in tests. Read the port from await server.addr after starting.
Routing
There is no built-in router. Match on request.method and new URL(request.url) inside your handler, or hand requests to a framework (see Hono below).
import { serve } from "runtime:http"; const server = serve({ port: 8080 }, async (request) => { const url = new URL(request.url); if (request.method === "GET" && url.pathname === "/health") { return Response.json({ ok: true }); } if (request.method === "POST" && url.pathname === "/echo") { const body = await request.text(); return new Response(body, { headers: { "content-type": request.headers.get("content-type") ?? "text/plain" }, }); } return new Response("Not found", { status: 404 }); }); await server.addr;
For pathname patterns, pair this with the global URLPattern or a small routing helper.
Request and response bodies
Request and response bodies stream in both directions with backpressure. request.body is a ReadableStream; nothing is held in memory until you call request.text(), request.json(), or read the stream incrementally. On the response side, string and byte bodies use the buffered fast path (Content-Length); a ReadableStream is sent with chunked transfer-encoding as you produce it:
serve({ port: 8080 }, () => { const enc = new TextEncoder(); let n = 0; const body = new ReadableStream({ async pull(c) { if (n >= 10) return c.close(); c.enqueue(enc.encode(`tick ${n++}\n`)); }, }); return new Response(body, { headers: { "content-type": "text/plain" } }); });
new Response(request.body) pipes an upload straight back out without buffering either side.
Cookies
request.cookies holds the request's cookies. Read from it, and set or delete on it: the changes go out as Set-Cookie headers with whatever Response the handler returns.
import { serve } from "runtime:http"; serve(async (request) => { const url = new URL(request.url); if (url.pathname === "/login" && request.method === "POST") { const session = await createSession(await request.formData()); request.cookies.set("__Host-session", session.id, { secure: true, httpOnly: true, maxAge: 60 * 60 * 24, }); return Response.redirect(new URL("/", url), 303); } if (url.pathname === "/logout") { request.cookies.delete("__Host-session", { secure: true }); return Response.redirect(new URL("/", url), 303); } const id = request.cookies.get("__Host-session"); // null when absent return new Response(id ? "signed in" : "signed out"); });
A new cookie is Path=/; SameSite=Lax unless you say otherwise. For a session cookie, the __Host- prefix is the strongest choice: the browser only accepts it over HTTPS, for this host alone, on every path.
A cookie the browser would reject throws a TypeError at the set or delete that made it, naming the rule it breaks. A browser drops such a cookie without reporting anything, which makes a login fail with no clue why:
SameSite=NoneorPartitionedwithoutsecure;a
__Host-cookie with adomainor a path other than/;a
__Secure-or__Host-cookie withoutsecure, which includes deleting one.a cookie over the browser's size limit: 4096 bytes for the name and value.
A plain-HTTP development server cannot set secure cookies that a browser will keep. Use a name without the prefix locally, or serve HTTPS.
Sessions without a database
A user can read and edit every cookie. To keep session data in the cookie itself, seal it: setSealed encrypts the value, and getSealed returns it only if nobody changed it, it was sealed with one of your keys, and it has not expired.
import { serve } from "runtime:http"; import { env } from "runtime:process"; const secrets = [env.SESSION_KEY]; // at least 32 bytes serve(async (request) => { const raw = request.cookies.getSealed("__Host-session", { secrets }); const session = raw ? JSON.parse(raw) : { visits: 0 }; session.visits += 1; request.cookies.setSealed("__Host-session", JSON.stringify(session), { secrets, secure: true, httpOnly: true, maxAge: 60 * 60 * 24, }); return new Response(`visit ${session.visits}`); });
getSealed returns null for anything that is not a valid sealed cookie, so a forged or expired cookie is treated as no session. The maxAge is also checked on the server: a copied cookie stops working when it expires, even if the browser kept it.
When the value may be visible but must not be changed, such as a user ID or a preference, use setSigned and getSigned instead. Same arguments; the value stays readable.
To change keys, put the new one first and keep the old one until the cookies it sealed have expired:
const secrets = [env.SESSION_KEY_2026, env.SESSION_KEY_2025];
New cookies use the first key; cookies sealed with either are read.
To set cookies outside a handler, build them with Cookie or CookieMap and add toSetCookieHeaders() to the response yourself. See the runtime:http reference for every option.
CSRF
Another site can make a signed-in user's browser send a POST to your app, and the browser attaches the user's cookies. To refuse it, put a token in every form and check it on every request that changes something. CSRF from runtime:http issues and checks the token.
import { serve, CSRF } from "runtime:http"; import { env } from "runtime:process"; const secret = env.CSRF_KEY; // at least 32 bytes serve(async (request) => { const sessionId = currentSessionId(request); // see below if (request.method === "GET") { const token = CSRF.generate(secret, { sessionId }); return new Response( `<form method="post"><input type="hidden" name="csrf" value="${token}">…</form>`, { headers: { "content-type": "text/html" } }, ); } const form = await request.formData(); if (!CSRF.verify(form.get("csrf"), { secret, sessionId, request })) { return new Response("Forbidden", { status: 403 }); } // …change state return new Response("Saved"); });
sessionIdis required. The token is bound to it, so a token taken from one user's page does not work for another. Use the ID of the session cookie, or of a pre-login session on a login form, so that the login form is protected too.Passing
requestalso checks what the browser reports about where the request came from. A request markedSec-Fetch-Site: cross-siteorsame-site, or with another site'sOrigin, fails even with a valid token. Allow a known partner withtrustedOrigins: ["https://partner.example"].verifyreturnsfalsefor a missing, changed, expired or foreign token. It does not throw, so a forged request is a 403, not a 500.Check every method that changes state.
GETshould never change state.
From JavaScript, send the token in a header instead of a form field:
await fetch("/api/items", { method: "POST", headers: { "x-csrf-token": token }, body }); // on the server: CSRF.verify(request.headers.get("x-csrf-token"), { secret, sessionId, request });
Tokens last 24 hours unless you set expiresIn (milliseconds). To change the secret, pass [newSecret, oldSecret] to verify until old tokens have expired.
The session ID itself can live in a signed cookie, so it cannot be swapped:
function currentSessionId(request) { const secrets = [env.SESSION_KEY]; let id = request.cookies.getSigned("sid", { secrets }); if (id === null) { id = crypto.randomUUID(); request.cookies.setSigned("sid", id, { secrets, httpOnly: true }); } return id; }
CSRF follows Bun's Bun.CSRF, but requires the secret and the session ID, which Bun leaves optional. See the runtime:http reference.
Client disconnects
request.signal aborts when the client hangs up before your handler produced a response — drop expensive work nobody will read.
serve(async (request) => { const upstream = await fetch(slowUrl, { signal: request.signal }); return new Response(upstream.body); });
Reading request.signal starts the watch, so a handler that never asks costs nothing. It covers the window before the response is handed over; a client that vanishes mid-stream ends the response body stream instead.
Errors
If your handler throws or returns a value that is not a Response, the runtime answers with 500 Internal Server Error. Wrap risky logic in try/catch when you want a specific status or JSON error body.
serve({ port: 8080 }, async (request) => { try { const data = await request.json(); return Response.json({ received: data }); } catch { return Response.json({ error: "invalid JSON" }, { status: 400 }); } });
Invalid Request / Response construction (bad status codes, malformed headers) throws TypeError at construction time — the same Fetch API rules as in browsers.