Identity, limits, and deployment

The handler's second argument carries the connection's peer — the same shape Deno.serve passes, so a handler ports either way. Taking it is optional.

JavaScript
serve({ port: 8080 }, (request, info) => {
  const ip = info.remoteAddr.hostname; // "203.0.113.7"
  return new Response(`hello ${ip}`);
});
Behind a proxy, this is the proxy

remoteAddr is the other end of the socket and nothing else. esrun never reads X-Forwarded-For for you: resolving it takes knowing which hop to trust, and a header anyone can send is not an identity. The header arrives untouched, so a deployment that knows its own topology can do it explicitly — request.headers.get("x-forwarded-for")?.split(",")[0].trim().

It is null when there is no peer to report, rather than a blank address.

Timeouts and connection limits

A connection that is not making progress is closed, and you can cap how many are served at once. Nothing here has to be turned on:

OptionDefaultWhat it bounds
timeouts.handshake10000msAccept → ready to carry requests
timeouts.headerRead30000msA request head arriving in full; on HTTP/1.1, the idle keep-alive limit too
timeouts.h2KeepAlive20000msPING probes on an idle HTTP/2 connection
timeouts.bodyRead30000msA request body arriving, before what bodyMinRate earns it
timeouts.bodyMinRate1024 B/sBytes per second that extend bodyRead
maxConnectionsunlimitedHow many connections are served at once
maxConnectionsPerIpunlimitedHow many one peer address may hold — over it, refused rather than held
JavaScript
// Timeouts are milliseconds; `null` disables one; omit it to keep the default.
serve({ port: 8080, timeouts: { headerRead: 5000 }, maxConnections: 10_000 }, handler);

They bound only connections that are idle or stalled. A request in flight and a response still streaming are never interrupted, however long they take — a live feed is unaffected by headerRead no matter how far past it runs. A connection over maxConnections is held rather than refused: it waits, and is served as soon as a slot frees. maxConnectionsPerIp is the half that says whose connections fill that budget — without it one peer can take every slot — and a connection over it is refused rather than held. Leave it off behind a proxy, where every connection carries the same source address.

A request body is bounded differently, because elapsed time cannot tell a large upload on a slow link from a peer dribbling a byte a minute. The deadline is earnedbodyRead + received / bodyMinRate — so an upload extends its own by uploading, and a dribbler does not send enough to extend it. At the defaults a 100 MiB upload has over a day; a byte-a-minute peer is closed at ~30s. The handler sees the body stream error with ERR_TIMED_OUT.

How this behaves, and why

Why the HTTP/1.1 idle limit and the request-head limit are the same timer, why the connection cap ships off when the timeouts ship on, what a single connection costs in memory, and how all of it compares to Node, Bun and Deno on measured numbers: Internals: the HTTP server.

Usage with Hono

Because esrun uses standard Web APIs, frameworks like Hono work when their package is on disk (install with npm, bun, or pnpm — esrun resolves node_modules but does not install packages for you).

JavaScript
import { serve } from "runtime:http";
import { Hono } from "hono";

const app = new Hono();
app.get("/", (c) => c.text("Hello from Hono on esrun!"));

const server = serve({ port: 8080 }, app.fetch);
const { port } = await server.addr;
console.log(`Hono listening on http://127.0.0.1:${port}`);

See also

Last updated on
Edit this page