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.

JavaScript
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).

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

JavaScript
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.

JavaScript
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=None or Partitioned without secure;

  • a __Host- cookie with a domain or a path other than /;

  • a __Secure- or __Host- cookie without secure, 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.

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

JavaScript
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.

JavaScript
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");
});
  • sessionId is 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 request also checks what the browser reports about where the request came from. A request marked Sec-Fetch-Site: cross-site or same-site, or with another site's Origin, fails even with a valid token. Allow a known partner with trustedOrigins: ["https://partner.example"].

  • verify returns false for 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. GET should never change state.

From JavaScript, send the token in a header instead of a form field:

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

JavaScript
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.

JavaScript
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.

JavaScript
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.

Last updated on
Edit this page