HTTP

The HTTP surface is the one you already know how to hold: Request, Response, Routes, Handler, Middleware, Server, Client. Bodies on the wire are streams. body.utf8 and body.collect read any body. body.text and body.strict are Some only for a body already in memory, and None for a stream, never a throw. maxBodyBytes is a cap, not a buffer.

If you are building a hub, start at The hub and come back here for the knobs.

Routes and the path DSL

Method.GET / "users" / int("id") is a PathCodec. int, long, string, uuid, and trailing are the captures. Unmatched paths are 404; wrong method is 405.

{
  val routes = Routes(
    Method.GET / "users" / int("id") -> { id =>
      ZIO.succeed(Response.text(id.toString))
    }
  )
  for
    ok   <- routes(Request.get("/users/3"))
    body <- ok.body.utf8
    miss <- routes(Request.get("/users/ada"))
  yield (body, miss.status)
}
(3,Status(404,Not Found))

A capture is a name and a type, written left to right. -> takes those captures in that order, and the parameter names are the names in the path. long("left") / long("right") is (left, right) =>. The names are part of the check, so two captures of one type cannot be swapped and still compile. (right, left) does not compile. _ ignores that position. It does not invent a name.

{
  val routes = Routes(
    Method.GET / "p" / long("left") / "x" / long("right") -> { (left, right) =>
      ZIO.succeed(Response.text(s"$left $right"))
    }
  )
  routes(Request.get("/p/1/x/2")).flatMap(_.body.utf8)
}
1 2

The request is not a capture. handle takes the same captures and then the request. A route that does not read the request stays on -> and does not mention it.

{
  val routes = Routes(
    Method.PATCH / "p" / long("left") / long("right") handle { (left, right, req) =>
      ZIO.succeed(Response.text(s"$left $right ${req.method.render}"))
    }
  )
  routes(Request(Method.PATCH, Url.parse("/p/1/2"))).flatMap(_.body.utf8)
}
1 2 PATCH
PathCodec
GET "shows" / int("id")
/shows/1
match  id = 1

handler runs

Middleware

@@ wraps this Routes value. ++ tries the right side when the left has no matching route. A response the left produced stands, including a 404 or a 405. Gzip, decompress, CORS, auth, and Middleware.timeout attach to whoever you wrap. Global gzip is all @@ Middleware.compress(), which is still wrapping routes, not a server flag. Partial gzip is (api ++ files) @@ Middleware.compress() ++ sse.

Why that split exists is on What stays open. Metrics and traces are middleware that runs on a client too: Observability.

{
  val routes =
    Routes(Method.GET / "x" -> Handler.text("ok")) @@ Middleware.requestId()
  routes(Request.get("/x")).map { res =>
    res.header("X-Request-Id").exists(_.nonEmpty)
  }
}
true
Middleware @@
routes @@ (Middleware.requestId() ++ Middleware.cors())

request → cors → requestId → handler
cors is outer. requestId still stamps X-Request-Id.

Server

Server.install / Server.serve are ordinary ZIO fibers on every platform. The JVM runs connections on virtual threads and falls back to the default executor where it cannot. JS is Node net / tls. Native is POSIX sockets plus OpenSSL. TLS is in the type: Server.serveTls(app).provide(Server.Config.defaults, Tls.pem(certPem, keyPem)) needs a Tls, and Server.install(routes, config, tls) takes one. The layer reads the PEM at startup on every platform, so a missing block is a TlsError before the first connection, not a failed handshake later. HTTP/2 (ALPN h2) is the JVM bind. JS and Native serve HTTP/1.1 on that same Routes value. Idle, header, and connection caps live on Server.Config. See What stays open.

{
  val routes = Routes(Method.GET / "health" -> Handler.text("ok"))
  ZIO.scoped {
    Server
      .install(routes, Server.Config.default.copy(host = "127.0.0.1", port = Port(0)))
      .flatMap { server =>
        server.port.flatMap { port =>
          Client.get(s"http://127.0.0.1:$port/health").flatMap { res =>
            res.body.utf8.map((res.status, _))
          }
        }
      }
  }
}
(Status(200,OK),ok)

Client

Client.batched sends a request and reads the whole body. Client.streaming returns once the head arrives and reads the body from the connection until its Scope closes. Client.sse is a text/event-stream GET. All of them fail with ClientError, a closed enum of transport facts: InvalidTarget, Connect, ConnectTimeout, Tls, ReadTimeout, Io, Protocol, PoolExhausted, NotAnEventStream, InvalidTrust, BodyFailed. A response with any status is a success. The cases carry data: InvalidTarget holds a TargetError (NoHost, NotHttp, or a Malformed URL with its UrlError), and Protocol holds the HttpError, down to the WireError that named which byte broke the framing.

JVM and Native share one pooled HTTP/1.1 client. Every exchange settles its connection on success, failure, and interruption, so a timed-out call gives its slot back. A close-delimited body never returns to the pool. A GET, HEAD, PUT, DELETE, OPTIONS, or TRACE whose pooled connection the server already dropped is sent once more on a fresh one; a POST is not. JS is Node's fetch. TLS verifies the peer and its name on every platform. ClientTls.trusting(pem) (JVM, Native) pins a private CA; Node reads NODE_EXTRA_CA_CERTS.

ZIO.attemptBlocking(java.net.ServerSocket(0)).flatMap { ss =>
  val port = ss.getLocalPort
  ss.close()
  Client.get(s"http://127.0.0.1:$port/").either
}
Left(Connect to 127.0.0.1:41699 failed: java.net.ConnectException: Connection refused (connect failed))

Client.call(endpoint)(in) is the typed call. It fails with CallFailure[E]: Domain(e) for one of the endpoint's own errors, Transport for a ClientError, Undecodable for a body or response header the codecs reject. The reason is an OutputError: Body for the body, Header naming the header and its ParamError. Unexpected is a status the endpoint never declared. Nothing becomes a defect.

{
  val ep  = Endpoint.get("hello" / string("name")).out[String].outError[String](Status.NotFound)
  val api =
    Api("hello", "1").bind(ep)(name => if name == "ada" then ZIO.succeed(s"hi $name") else ZIO.fail("nobody"))
  (Client.call(ep)("ada") <*> Client.call(ep)("bob").either).provideLayer(Client.inMemory(api.routes))
}
(hi ada,Left(Domain(nobody)))

Files and compression

A caller-given file is Files.fromPath (jailed under SafePath for directory roots). It fails with a FileError (NotFound, IsDirectory, Unreadable), and toResponse is the answer a client should see: 404 for the first two, 500 when the disk fails. Compression is @@ Middleware.compress (gzip in core). Add heddle-brotli and pass Brotli.compressor when you want br. Incoming Content-Encoding is @@ Middleware.decompress(maxBytes = ...). Inflation stops at maxBytes: a small gzip bomb gets 413 before it can fill memory, and corrupt input gets 400.

{
  val bomb   = Compressor.gzip.compress(zio.Chunk.fill(8 * 1024 * 1024)(0.toByte))
  val routes = Routes(Method.POST / "in" -> Handler.text("reached")) @@ Middleware.decompress(maxBytes = 64.K)
  routes(Request.post("/in", Body.fromBytes(bomb)).withHeader("Content-Encoding", "gzip"))
    .map(res => (bomb.length, res.status))
}
(8175,Status(413,Content Too Large))
Brotli.encode(zio.Chunk.fromArray("hi".getBytes("UTF-8"))).nonEmpty
true
{
  val routes = Routes(Method.GET / "gone" -> Handler.fromFile("/no/such/file.txt").mapError(_.toResponse))
  routes(Request.get("/gone")).merge.map(_.status)
}
Status(404,Not Found)