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

ArtifactDepends onRole
heddleZIO, zio-json, neotypeHTTP types, routes, middleware, Server.install (JVM Loom, Node, Native), Schema, Endpoint, OpenAPI
heddle-mcp-protocolzio-jsonMCP wire types and codecs: JSON-RPC messages, requests, tools, results, resources
heddle-mcpheddle, heddle-mcp-protocolMCP 2026-07-28 over Api / BoundOp (HTTP/stdio also answer 2025-11-25 initialize)
heddle-mcp-appsheddle-mcpMCP Apps: sheds of typed grants, ui:// views, _meta.ui, and the policy a host clamps
heddle-oauthheddleJOSE, resource server, OAuth client, OIDC provider
heddle-brotliheddleRFC 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

SurfaceJVMJS (Node)Native
Server.install HTTP/1.1yes (Loom default)yes (node:net)yes (POSIX)
TLSjavax.net.sslnode:tlsOpenSSL
HTTP/2yesnono
Clientpooled HTTP/1.1fetchpooled HTTP/1.1 (getaddrinfo, verified OpenSSL)
Filesnionode:fs + SafePathnio + SafePath
Digest / RS256java.securitynode:cryptoOpenSSL
MCP HTTP + stdioyesyesyes
MCP Apps modelyesyes (Node and browser)yes
heddle-oauthyesyesno
HeddleAppyesnono

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, 2025 initialize)

  • sbt "example/run -- --mcp-stdio" — same Api, 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.

FieldDefaultWhy
host0.0.0.0Bind address
portPort(8080)Bind port. Port(0) asks the OS
maxHeaderBytesHeaderBytes(16384)Cap on the header block. The read buffer is at least this
maxBodyBytesBodyBytes(10485760)Cap on the request body. 0 refuses every body
chunkSizeChunkSize(8192)Read/write chunk
gracefulShutdownTimeout10sFinish in-flight work, then close
idleTimeout60sClose a quiet keep-alive
headerTimeout30sClose incomplete headers. Later bytes do not extend it
maxConnectionsMaxConnections(1024)Cap on accepted sockets
maxRequestsPerConnectionnoneOption[KeepAliveRequests]. Unset does not count requests
soBacklogBacklog(100)Kernel accept queue. Backlog(0) leaves it to the OS
reuseAddresstrueSO_REUSEADDR
tcpNoDelaytrueTCP_NODELAY
soKeepAlivetrueDead-peer cleanup
http2trueOffer HTTP/2
http2Config.maxConcurrentStreamsConcurrentStreams(100)Cap streams per connection. 0 refuses new streams
http2Config.initialWindowSizeWindowSize(65535)Stream flow-control window. 0 stalls the sender
http2Config.maxFrameSizeFrameSize(16384)Frame cap, 16384 through 16777215
http2Config.maxHeaderListSizeHeaderListSize(8192)HPACK list cap
http2Config.maxOutstandingFramesOutstandingFrames(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.

ParserError
Url.decodeUrlError: Empty, BadCharacter(char, at), UnknownScheme, BadAuthority
QueryCodec, HeaderCodec, TypedHeaderParamError: Missing, Malformed(raw, expected), Url(UrlError)
Endpoint#fromResponseCallFailure.Undecodable(status, OutputError): Body(BodyError), Header(name, ParamError)
OpArgs.arguments, OpArgs.requestOpArgsError: which parameter, where, and what was wrong
Multipart.parse, Body#asMultipartMultipartError: Undeclared, NoBoundary, Truncated
SseField.fromSseFieldError.LineBreak(at)
the HTTP/1.1 and HTTP/2 wireHttpError.Malformed(WireError): request line, chunk size, frame size, padding
ToolName.from, ExtensionId.fromToolNameError, ExtensionIdError
UiUri.from, Origin.fromUiUriError, OriginError
Jose.verify, Jose.parseJwksJoseError, carried by OAuthError.InvalidToken
Rsa, Jose.signRsaError, 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.

FieldDefaultWhy
maxConnectionsPerHostConnectionsPerHost(10)In-flight cap per host
maxIdlePerHostIdlePerHost(10)Idle sockets kept. IdlePerHost(0) keeps none
connectTimeout10sGive up connecting
idleTimeout60sGive up reading
poolIdleTimeout60sDrop idle pooled sockets
addUserAgenttrueAdd User-Agent: heddle if missing
maxHeaderBytesHeaderBytes(16384)Cap on response headers. The read buffer is this
maxBodyBytesBodyBytes(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.connectTimeout
PT10S

Middleware

MiddlewareRole
compressResponse 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
timeoutSlow 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 / requireTlsPolicy 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)