Reference
Published artifacts, the facade, the example hub, and the knobs that are not a tutorial. How-to lives in The hub and Domain is data.
Modules
| Artifact | Depends on | Role |
|---|---|---|
heddle | ZIO, zio-json, neotype | HTTP types, routes, middleware, Server.install (JVM Loom, Node, Native), Schema, Endpoint, OpenAPI |
heddle-mcp-protocol | zio-json | MCP wire types and codecs: JSON-RPC messages, requests, tools, results, resources |
heddle-mcp | heddle, heddle-mcp-protocol | MCP 2026-07-28 over Api / BoundOp (HTTP/stdio also answer 2025-11-25 initialize) |
heddle-mcp-apps | heddle-mcp | MCP Apps: sheds of typed grants, ui:// views, _meta.ui, and the policy a host clamps |
heddle-oauth | heddle | JOSE, resource server, OAuth client, OIDC provider |
heddle-brotli | heddle | RFC 7932 br encoder / decoder (no JNI) |
import heddle.* is a curated facade over heddle.http, heddle.route, heddle.endpoint,
heddle.server, heddle.client, and heddle.error. SSE, WebSocket, and Datastar stay in their
packages. Datastar readSignals is an extension on Request.
Platforms
| Surface | JVM | JS (Node) | Native |
|---|---|---|---|
Server.install HTTP/1.1 | yes (Loom default) | yes (node:net) | yes (POSIX) |
| TLS | javax.net.ssl | node:tls | OpenSSL |
| HTTP/2 | yes | no | no |
Client | pooled HTTP/1.1 | fetch | pooled HTTP/1.1 (getaddrinfo, verified OpenSSL) |
Files | nio | node:fs + SafePath | nio + SafePath |
| Digest / RS256 | java.security | node:crypto | OpenSSL |
| MCP HTTP + stdio | yes | yes | yes |
| MCP Apps model | yes | yes (Node and browser) | yes |
heddle-oauth | yes | yes | no |
HeddleApp | yes | no | no |
docs-js mounts Hub widgets. It does not import BoundOp or Mcp.handle.
Example hub
sbt example/run
http://localhost:8080/docs — box office API + Swagger, Authorize against the embedded OP
http://localhost:8080/preview — box office UI (same
Api)http://localhost:8080/mcp — Streamable HTTP MCP (
POST; 2026 native, 2025initialize)sbt "example/run -- --mcp-stdio"— sameApi, stdio JSON-RPC
Seed users: ada / ada. Machine client machine / secret.
Server config
zio.Config keys nest under heddle.server. Numeric caps are neotypes: a value outside
its range does not construct, and a present invalid value fails to load. A missing
maxRequestsPerConnection is unlimited. Forever is Duration.Infinity. Why each clock
exists: What stays open.
| Field | Default | Why |
|---|---|---|
host | 0.0.0.0 | Bind address |
port | Port(8080) | Bind port. Port(0) asks the OS |
maxHeaderBytes | HeaderBytes(16384) | Cap on the header block. The read buffer is at least this |
maxBodyBytes | BodyBytes(10485760) | Cap on the request body. 0 refuses every body |
chunkSize | ChunkSize(8192) | Read/write chunk |
gracefulShutdownTimeout | 10s | Finish in-flight work, then close |
idleTimeout | 60s | Close a quiet keep-alive |
headerTimeout | 30s | Close incomplete headers. Later bytes do not extend it |
maxConnections | MaxConnections(1024) | Cap on accepted sockets |
maxRequestsPerConnection | none | Option[KeepAliveRequests]. Unset does not count requests |
soBacklog | Backlog(100) | Kernel accept queue. Backlog(0) leaves it to the OS |
reuseAddress | true | SO_REUSEADDR |
tcpNoDelay | true | TCP_NODELAY |
soKeepAlive | true | Dead-peer cleanup |
http2 | true | Offer HTTP/2 |
http2Config.maxConcurrentStreams | ConcurrentStreams(100) | Cap streams per connection. 0 refuses new streams |
http2Config.initialWindowSize | WindowSize(65535) | Stream flow-control window. 0 stalls the sender |
http2Config.maxFrameSize | FrameSize(16384) | Frame cap, 16384 through 16777215 |
http2Config.maxHeaderListSize | HeaderListSize(8192) | HPACK list cap |
http2Config.maxOutstandingFrames | OutstandingFrames(64) | Bound writer and body queues |
Server.serve(app).provide(Server.Config.defaults)
Server.serve(
app,
Server.Config.default.copy(
port = Port(8080),
maxBodyBytes = BodyBytes(1024L * 1024),
idleTimeout = Duration.Infinity,
),
)
Server.serve(app).provide(Server.Config.layer)
Gzip is not a field. Wrap the Routes: app @@ Middleware.compress().
JVM is JDK 21+ (Loom is the default scheduler, not a bind requirement). JS is Node. Native
is POSIX plus OpenSSL. HeddleApp is the JVM ZIOAppDefault that serves routes and
exits 0 on Ctrl-C under sbt 2.
(
Server.Config.default.port,
Server.Config.default.idleTimeout,
Server.Config.default.maxConnections,
)(8080,PT1M,1024)A Config value is already servable: Port, ChunkSize, and KeepAliveRequests refuse
an out-of-range number in make, and the same check runs when zio.Config loads a present
value. Server.install does not check again. A missing maxRequestsPerConnection stays
None.
(
Port.make(70_000),
ChunkSize.make(0),
KeepAliveRequests.make(0),
KeepAliveRequests.make(10),
)(Left(a port must be 0 to 65535, got 70000),Left(chunk size must be a positive int, got 0),Left(keep-alive requests must be a positive int, got 0),Right(10)){
val provider = ConfigProvider.fromMap(Map("heddle.server.http2.maxFrameSize" -> "100"))
ZIO.config(Server.Config.descriptor).withConfigProvider(provider).flip
}(Invalid data at heddle.server.http2: a frame size must be 16384 to 16777215, got 100)Errors
Every parser in heddle is total and says why it refused. A from(raw) or decode(raw)
returns Either[SomeError, A], never Either[String, A], never an exception. Each error is a
Scala 3 enum whose cases carry the offending piece (the character and its index, the scheme, the
declared and actual status), and every one has a message for people. Match on the case, show
the message.
| Parser | Error |
|---|---|
Url.decode | UrlError: Empty, BadCharacter(char, at), UnknownScheme, BadAuthority |
QueryCodec, HeaderCodec, TypedHeader | ParamError: Missing, Malformed(raw, expected), Url(UrlError) |
Endpoint#fromResponse | CallFailure.Undecodable(status, OutputError): Body(BodyError), Header(name, ParamError) |
OpArgs.arguments, OpArgs.request | OpArgsError: which parameter, where, and what was wrong |
Multipart.parse, Body#asMultipart | MultipartError: Undeclared, NoBoundary, Truncated |
SseField.from | SseFieldError.LineBreak(at) |
| the HTTP/1.1 and HTTP/2 wire | HttpError.Malformed(WireError): request line, chunk size, frame size, padding |
ToolName.from, ExtensionId.from | ToolNameError, ExtensionIdError |
UiUri.from, Origin.from | UiUriError, OriginError |
Jose.verify, Jose.parseJwks | JoseError, carried by OAuthError.InvalidToken |
Rsa, Jose.sign | RsaError, carried by OAuthError.Crypto |
A literal checks the same rules at compile time: ToolName("no spaces") does not compile, and
the compiler prints the message the runtime from would have returned.
(
Url.decode("http://exam ple.com/"),
Url.decode("gopher://example.com/"),
QueryCodec[Int].decode(Chunk("seven")),
HeaderCodec[Url].decode(Chunk("http://exam ple.com/")),
HeaderCodec[Int].decode(Chunk("seven")),
)(Left(' ' at 11 cannot appear in a URL),Left(gopher is not a scheme heddle knows),Left(expected an int, got 'seven'),Left(' ' at 11 cannot appear in a URL),Left(expected an int, got 'seven'))A wire error answers the peer and closes the connection. The server reads Content-Length as
RFC 9110's 1*DIGIT and a chunk size as 1*HEXDIG, ASCII only, so +5, 0x5, and ٣ are
a 400, never a length another hop might read differently.
Client config
zio.Config keys nest under heddle.client. Client.get / Client.request / Client.sse
take Client.Config (the default is Client.Config.default). Client.live uses that default
on the pooled client.
| Field | Default | Why |
|---|---|---|
maxConnectionsPerHost | ConnectionsPerHost(10) | In-flight cap per host |
maxIdlePerHost | IdlePerHost(10) | Idle sockets kept. IdlePerHost(0) keeps none |
connectTimeout | 10s | Give up connecting |
idleTimeout | 60s | Give up reading |
poolIdleTimeout | 60s | Drop idle pooled sockets |
addUserAgent | true | Add User-Agent: heddle if missing |
maxHeaderBytes | HeaderBytes(16384) | Cap on response headers. The read buffer is this |
maxBodyBytes | BodyBytes(10485760) | Cap on response body |
Accept-Encoding is not added for you. Set the header if you want gzip. The client inflates
only when that request asked for gzip.
heddle.exports$package.Client.Config.default.connectTimeoutPT10SMiddleware
| Middleware | Role |
|---|---|
compress | Response Content-Encoding: the coding the client rates highest among those the server has; q=0 is a refusal |
decompress(maxBytes) | Inflate a request body, capped; a coding it cannot decode is 415 |
timeout | Slow handler becomes 504. Does not close the socket |
cors(CorsConfig) | CorsOrigins.Any answers * and offers no credentials; CorsOrigins.Only(origins, credentials) echoes a listed origin, with Vary: Origin |
requestId / requireTls | Policy on the wrapped Routes |
CorsOrigins.Any never reflects the request's Origin: reflecting it with credentials would let any site read
a signed-in user's responses. Name the origins that may do that.
{
val policy = Middleware.CorsOrigins.Only(Set("https://app.example"), Middleware.CorsCredentials.Include)
val app = Routes(Method.GET / "me" -> Handler.text("ada")) @@
Middleware.cors(Middleware.CorsConfig(origins = policy)) @@ Middleware.compress()
for
listed <- app(Request.get("/me").withHeader("Origin", "https://app.example"))
stranger <- app(Request.get("/me").withHeader("Origin", "https://evil.example"))
refused <- app(Request.get("/me").withHeader("Accept-Encoding", "gzip;q=0"))
yield (
listed.header("Access-Control-Allow-Origin"),
stranger.header("Access-Control-Allow-Origin"),
refused.header("Content-Encoding"),
)
}(Some(https://app.example),None,None)