http
stdlib/extra/http.hk: HTTP/1.1 client and server over extra/net.
A pure-Hanki layer on top of net's blocking TCP sockets. Three pieces:
- Framing:
parse_request/parse_responseturn a complete byte buffer into aRequest/Response;render_request/render_responseturn one back into bytes (aResult, since a header carrying a bare CR/LF is rejected to block injection).read_request!/read_response!/write_response!do the same against a live transport: anyS: Stream, which is aTcpStreamtoday and a TLS connection when one lands. Those bear!because they touch it. Parsing itself is pure: a framing parser performs no I/O, and is callable from a puredef, awhere, or ametablock.- Client:
get!andrequest!open a connection to anhttp://URL, send the request, and read the response. - Server:
serve!accepts connections and runs a handler one at a time. True concurrency is the caller's: spawn a per-connection worker actor andmovethe accepted socket in (HANKI.md §15), reading and writing with the framing functions here.
- Client:
v0 wire subset: bodies are delimited by Content-Length or by Transfer-Encoding: chunked, which is decoded here: a size line, that many bytes, repeating to a zero size, extensions dropped and the trailer discarded. It is the one transfer coding an HTTP/1.1 recipient may not skip (RFC 9112 7.1), and the one a server reaches for whenever it cannot know the length up front. Every other coding is refused as UnsupportedTransferEncoding and never handed back with its framing as the body, since that corruption surfaces in whatever parses the payload next and blames itself. A message declaring both a coding and a length is ConflictingFraming, refused outright (RFC 9112 6.3). Every message sends Connection: close (no keep-alive); header blocks and bodies are size-bounded, and an over-large one fails with HttpError in place of allocating without limit. The bound is on the decoded size, which leaves chunking no way past it.
A decoded message retains its Transfer-Encoding field: the headers report what arrived, the body is the payload. render_request / render_response drop the field, since they frame by the Content-Length they compute.
Both ends speak TLS. https:// URLs work in get! / request!: the connection is TLS 1.3, or 1.2 for a peer limited to that, the peer is verified against the system trust store, and there is no way to turn that off. The server half is serve_tls!, over a tls.listen! listener. A http:// URL and a serve! listener are still cleartext, and everything on that wire, a bearer token in an Authorization header as much as the body, is readable and alterable by whatever sits on the path.
Which TLS version an https:// request negotiated is absent from Response by design: a Response is the message (status, headers, body), and transport already sits on the URL (Url.tls). A caller that must observe or bound the version opens the connection itself with tls.connect!, asks TlsStream.version!, and speaks HTTP over it with the framing functions here.
The two serve entry points differ only in what they refuse. serve! takes a TcpListener, speaks cleartext, and refuses any bind but loopback, a credential sent to a reachable port travelling in the open. serve_exposed! is the sanctioned way past that refusal for a process behind a terminating proxy. serve_tls! takes a TlsListener and bears no such check: over TLS a reachable bind is the intended deployment.
Header names are case-insensitive: they are stored lower-cased, which keys a parsed Headers in lower case throughout (headers.get("content-type")). A name may repeat, Set-Cookie doing so on nearly every login response, and every value is kept: get reads the first, get_all reads them all.
Method
type Method
Get
Post
Put
Delete
Head
Patch
Options
Other(string)
end
The HTTP request method. Unknown tokens are preserved verbatim in Other and never rejected.
impl Display<Method>
to_string
def to_string(self) -> string
Renders the method as its uppercase HTTP token.
Get.to_string() => "GET"
Delete.to_string() => "DELETE"
Other("PURGE").to_string() => "PURGE"
Headers
opaque Headers
fields: Map<string, List<string>>
end
A message's header fields. HTTP lets a field name repeat, and a name therefore maps to the list of its values in order of appearance and never to one value: collapsing them would lose every Set-Cookie past the first, and would let two Content-Length fields disagree unnoticed.
Field names are case-insensitive (RFC 9110 5.1), and every name-taking method lower-cases its argument. Behaviour depends on it: render_* computes and sets content-length, and under a case-sensitive lookup a caller's Content-Length would survive beside it, two on the wire.
impl Headers
empty
def empty() -> Headers
A header section with no fields.
Headers.empty().names.length => 0
get
def get(self, name: string) -> Option<string>
The first value for name, or None when the field is absent. This is the common case; a field that legitimately repeats wants get_all.
Headers.empty().add("x-a", "1").get("X-A").unwrap_or("?") => "1"
Headers.empty().get("x-a").unwrap_or("?") => "?"
get_all
def get_all(self, name: string) -> List<string>
Every value for name, in order of appearance; empty when absent.
h = Headers.empty().add("Set-Cookie", "a=1").add("set-cookie", "b=2")
h.get_all("SET-COOKIE").length => 2
set
def set(self, name: string, value: string) -> Headers
self with name set to value, dropping any values already there. A computed field wants this, Content-Length being one that must not accumulate; a repeatable one wants add.
Headers.empty().add("x-a", "1").set("X-A", "2").get_all("x-a").length => 1
add
def add(self, name: string, value: string) -> Headers
self with value appended to name's values, keeping any already there. This is how a repeated header line accumulates.
Headers.empty().add("x-a", "1").add("X-A", "2").get_all("x-a").length => 2
names
prop names(self) -> List<string>
Every field name present, in Map iteration order (hash order, never the order they arrived in).
Headers.empty().set("x-a", "1").names.length => 1
Request
struct Request
method: Method
target: string
headers: Headers
body: bytes
end
A parsed request. target is the request-target as received (path plus any query) and no full URL.
Response
struct Response
status: i32
headers: Headers
body: bytes
end
A response. status is the numeric status code; the reason phrase is derived on render and discarded on parse.
Url
struct Url
host: string
port: u16
path: string
tls: bool
end
The pieces of a URL the client needs to open a connection. tls is the scheme: https:// sets it, http:// clears it, and it is what request! routes on. A bool and no scheme string: there are two transports, and a string would invite a third that nothing implements.
HttpError
type HttpError
Net(sys.NetError)
Tls(sys.TlsFailure)
BadUrl
BadStartLine
BadHeader
HeadersTooLarge
BodyTooLarge
BadContentLength
UnsupportedTransferEncoding(string)
BadChunk
ConflictingFraming
Incomplete
BadUtf8
ExposedBind(string)
end
Why an HTTP operation failed.
impl Display<HttpError>
to_string
def to_string(self) -> string
The reason, lowercase and without a trailing stop, which fits it inside a larger sentence as well as alone: "serve failed: #{e}". Net forwards the socket's own reason and never restates that a socket was involved.
ExposedBind is the one that says more than what went wrong. Every other variant reports a message that arrived malformed, where naming it is the whole job; that one reports a refusal, and a reader meeting it has usually not thought about the wire yet, and it names both ways forward.
BadUrl.to_string() => "malformed or unsupported-scheme URL"
Net(sys.ConnectionRefused).to_string() => "connection refused"
_Head
struct _Head
head: bytes
rest: bytes
end
The internal split of a head read off the wire: the header block up to and including the blank-line terminator, and any body bytes that arrived in the same read.
maxhead
def _max_head() -> int
Largest header block (request/status line + headers + terminator) read before giving up; bounds the framing buffer against a peer that never sends the blank line.
maxbody
def _max_body() -> int
Largest message body accepted.
maxheaders
def _max_headers() -> int
Largest number of header lines parsed; bounds the header-parse recursion. A chunked body's trailer section is a header block too, and shares it.
maxchunk_line
def _max_chunk_line() -> int
Largest chunk-size or trailer line accepted. A peer that never sends the CRLF ending one would otherwise make the reader buffer without limit.
maxchunksizedigits
def _max_chunk_size_digits() -> int
Largest chunk-size field accepted, in hexadecimal digits. 16 is a 64-bit size, past any body this module would accept anyway; the cap is here so a 4 KiB line of digits cannot be turned into an astronomical bignum before the body cap gets a chance to reject it.
byteat
def _byte_at(b: bytes, i: int) -> i32
The byte at i as an i32 in [0, 255], or -1 past the end.
indexof
def _index_of(b: bytes, target: i32, from: int) -> int
Index of the first byte equal to target at or after from, or -1.
indexof_crlf
def _index_of_crlf(b: bytes, from: int) -> int
Index of the first CRLF at or after from, or -1.
indexof_crlfcrlf
def _index_of_crlfcrlf(b: bytes, from: int) -> int
Index of the first blank-line terminator (CRLF CRLF) at or after from, or -1. Points at the first CR.
trimbytes
def _trim_bytes(b: bytes) -> bytes
Drop leading and trailing spaces and tabs.
_decode
def _decode(b: bytes) -> Result<string, HttpError>
methodof
def _method_of(s: string) -> Method
requestmethod
def _request_method(start_line: bytes) -> Result<Method, HttpError>
The method token (bytes before the first space of the request line).
requesttarget
def _request_target(start_line: bytes) -> Result<string, HttpError>
The request-target (bytes between the first and second spaces).
responsestatus
def _response_status(start_line: bytes) -> Result<i32, HttpError>
The numeric status code from a status line (HTTP/1.1 <code> <reason>). A missing reason phrase is tolerated.
parseheaders
def _parse_headers(head: bytes, from: int, acc: Headers, budget: int) -> Result<Headers, HttpError>
Parse header lines from head starting at byte from, folding into acc. budget counts remaining lines. Stops at the blank line.
_Coding
type _Coding
CodingNone
CodingChunked
CodingUnsupported(string)
end
What a message's Transfer-Encoding field asks of a recipient.
_Framing
type _Framing
FrameLength(int)
FrameChunked
FrameToEnd
end
How a message's body is delimited on the wire.
transfercoding
def _transfer_coding(headers: Headers) -> _Coding
Classify the Transfer-Encoding field. Several header lines are one list, so they are joined before splitting, and identity drops out wherever it sits, being a no-op coding that HTTP/1.1 does not define as a transfer coding at all.
What remains must be chunked alone. RFC 9112 6.1 makes the list ordered, with the last coding the one applied to the payload, but that only says which to strip first: a gzip, chunked body is still gzip once the chunk framing comes off, and this module has no gzip. Reading the last coding alone would also wave through a chunked, gzip head as if plain.
The whole value comes back, and never the offending coding alone: a caller reporting gzip, chunked should see both, and the header is what they would go looking for on the wire.
bodyframing
def _body_framing(headers: Headers) -> Result<_Framing, HttpError>
How the body of a message with these headers is framed, or a refusal.
The transfer-coding is settled first, outranking Content-Length (RFC 9112 6.3) and because the failure mode is the worse of the two: a refused length is a loud error, while framing returned as a payload is a 200 with a plausible body that breaks the parser above.
A message declaring both is refused outright and framed by neither (RFC 9112 6.3, the CL.TE request-smuggling shape, CWE-444): the two say different things about where this message ends, and any recipient in the chain is free to believe the other one and read a second message out of this one's body.
chunksize
def _chunk_size(line: bytes) -> Result<int, HttpError>
The value of one chunk-size line: hexadecimal in either case, with any ;-introduced extensions dropped. Nothing here reads an extension, and RFC 9112 7.1.1 lets a recipient ignore ones it does not recognise.
hexdigit
def _hex_digit(c: i32) -> int
The value of one hexadecimal digit, or -1 for any other byte.
decodechunked
def _decode_chunked(raw: bytes, max_body: int) -> Result<bytes, HttpError>
Decode a chunked body out of a buffer that has all of it: a size line, CRLF, that many bytes, CRLF, repeating until a zero size, then a trailer section ending at a blank line.
The cap applies to the decoded total and is checked as chunks accumulate, which leaves a chunked body unable to spend more than a Content-Length one by arriving in many small pieces. Decoding is byte-level throughout: a multi-byte character may straddle a chunk boundary, and nothing here looks at what the bytes mean.
Trailer fields are discarded. Nothing in this module reads one, and a caller that needs them needs an API that separates them from the header block and no body that grew fields unannounced.
takebody
def _take_body(rest: bytes, framing: _Framing) -> Result<bytes, HttpError>
A request body as its framing delimits it. A Content-Length message with fewer bytes than declared is Incomplete and no shorter body: a framing parser's whole job is to say whether a message ended, and a caller that cannot tell truncation from completion has to reimplement the framing itself. A chunked one that stops mid-chunk is Incomplete for the same reason, and needs it more: without a declared length, truncation is otherwise indistinguishable from a complete small response.
takebody_eof
def _take_body_eof(rest: bytes, framing: _Framing) -> Result<bytes, HttpError>
A response body as its framing delimits it, FrameToEnd meaning everything received (read-until-close).
parse_request
def parse_request(buf: bytes) -> Result<Request, HttpError>
Parse a complete request from a buffer holding the head and body. A buffer holding less than a whole message is Incomplete and no short read, which lets a caller tell "not yet" from "malformed".
whole = "GET /x HTTP/1.1\r\nHost: h\r\n\r\n".to_bytes()
parse_request(whole).map(|q| q.target).unwrap_or("?") => "/x"
parse_request("GET /x HTTP".to_bytes()).map(|q| q.target).unwrap_or("?") => "?"
parse_response
def parse_response(buf: bytes) -> Result<Response, HttpError>
Parse a complete response from a buffer holding the head and body. The reason phrase is discarded, holding no meaning the status code does not.
parse_response("HTTP/1.1 204 No Content\r\n\r\n".to_bytes()).map(|r| r.status).unwrap_or(0i32) => 204i32
_reason
def _reason(status: i32) -> string
_authority
def _authority(u: Url) -> string
host for the default port, host:port otherwise.
headerlines
def _header_lines(k: string, h: Headers) -> string
One line per value, which returns a repeated field to the wire the way it arrived and never as one folded line; Set-Cookie may not be folded.
renderheaders
def _render_headers(h: Headers) -> string
headersafe?
def _header_safe?(s: string) -> bool
A header name or value must not contain a bare CR or LF: a value like "x\r\nX-Injected: 1" would otherwise inject an extra header line (request/response splitting).
headersclean?
def _headers_clean?(h: Headers) -> bool
_without
def _without(h: Headers, name: string) -> Headers
h without name. Inside the module, since Headers is opaque and the one caller is the renderer dropping a field it is about to contradict.
framedheaders
def _framed_headers(h: Headers, body: bytes) -> Headers
The framing headers a renderer owns. Content-Length is computed from the body, and any Transfer-Encoding the caller left on the value goes: rendering both emits the message _body_framing refuses to read, and the body in hand is already the payload and no coded form of it.
defaultuser_agent
def _default_user_agent() -> string
What a request without its own User-Agent sends.
Not cosmetic. A request with no User-Agent is a common bot signal, and what a CDN or WAF answers is a status the caller then misreads: the same request answered 200 with the header and 402 without it, which cost an hour reading as "this host wants payment" (hanki-ahuwk).
hanki/<version> is the conventional shape and what an operator grepping their logs would expect, with no tier and no platform after it: every extra field is fingerprint the caller did not ask to emit, and Headers.set lets one who wants it say so.
methoddefines_content?
def _method_defines_content?(method: Method) -> bool
Whether method's semantics define content, which frames an empty body with Content-Length: 0 even so.
RFC 9110 §8.6: a user agent SHOULD NOT send Content-Length on a request with no content whose method does not anticipate any. An unknown method counts as anticipating it: what it defines is unknowable here, and framing it explicitly is the answer that cannot be misread.
withdefault_agent
def _with_default_agent(h: Headers) -> Headers
h carrying the default User-Agent unless the caller set one.
render_request
def render_request(method: Method, u: Url, headers: Headers, body: bytes) -> Result<bytes, HttpError>
Serialise a request to bytes, filling in Host, User-Agent, Connection: close and, where the method's semantics define content, Content-Length, over the caller's headers and dropping any Transfer-Encoding there. A header name or value carrying a bare CR/LF is rejected as BadHeader (injection guard).
out = match parse_url("http://h/x")
Ok(u) -> render_request(Get, u, Headers.empty(), "".to_bytes()).and_then(|b| parse_request(b)).map(|q| q.target).unwrap_or("?")
Err(_) -> "?"
end
out => "/x"
render_response
def render_response(resp: Response) -> Result<bytes, HttpError>
Serialise a response to bytes, filling in Content-Length and Connection: close over the caller's headers and dropping any Transfer-Encoding there. A header name or value carrying a bare CR/LF is rejected as BadHeader (injection guard).
resp = Response(status=204i32, headers=Headers.empty(), body="".to_bytes())
render_response(resp).and_then(|b| parse_response(b)).map(|r| r.status).unwrap_or(0i32) => 204i32
authorityof
def _authority_of(text: string) -> string
The authority of text as written: what follows // up to the first /, ? or #, or the end.
parse_url
def parse_url(text: string) -> Result<Url, HttpError>
The pieces of an http://host[:port][/path][?query] or https://... URL the client needs: url.parse takes the text apart, and this retains what the transport can open. A missing port defaults to the scheme's (80 or 443) and a missing path to /; a query remains on the path, that being the request target; a fragment is the client's and is not sent. Everything else is BadUrl: any other scheme (the spelling must be http or https), userinfo (user@host) and bracketed IPv6 literals ([::1]), which v0 does not connect to and refuses here in place of at connect!, an empty host, an empty port after : (which RFC 3986 treats as no port; this module treats it as a mistake), and a byte no URL may bear unencoded, a control character would let a crafted URL inject extra request lines, and a space would split the request line.
parse_url("http://a.com/x").map(|u| u.host).unwrap_or("?") => "a.com"
parse_url("http://a.com/x").map(|u| u.path).unwrap_or("?") => "/x"
parse_url("http://a.com").map(|u| u.path).unwrap_or("?") => "/"
parse_url("http://a.com?k=v").map(|u| u.path).unwrap_or("?") => "/?k=v"
parse_url("http://a.com/x#top").map(|u| u.path).unwrap_or("?") => "/x"
parse_url("https://a.com").map(|u| u.host).unwrap_or("!") => "a.com"
parse_url("https://a.com").map(|u| u.port).unwrap_or(0u16) => 443u16
parse_url("https://a.com").map(|u| u.tls).unwrap_or(false) => true
parse_url("http://a.com").map(|u| u.tls).unwrap_or(true) => false
parse_url("ftp://a.com").map(|u| u.host).unwrap_or("!") => "!"
parse_url("http://a.com:/x").map(|u| u.host).unwrap_or("!") => "!"
parse_url("http://a.com:0080/x").map(|u| u.port).unwrap_or(0u16) => 80u16
readhead!
def _read_head!<S: Stream>(s: S, max_head: int) -> Result<_Head, HttpError> [net]
Read off the stream until the blank-line terminator, splitting the head from any body bytes that arrived with it.
readbody!
def _read_body!<S: Stream>(s: S, already: bytes, content_length: int, max_body: int) -> Result<bytes, HttpError> [net]
Read until already plus further reads reach content_length bytes.
readbody_eof!
def _read_body_eof!<S: Stream>(s: S, already: bytes, max_body: int) -> Result<bytes, HttpError> [net]
Read until the peer closes the connection.
fillline!
def _fill_line!<S: Stream>(s: S, pending: bytes, max_line: int) -> Result<bytes, HttpError> [net]
Read until pending has a whole CRLF-terminated line, which lets a chunk-size or trailer line be read off it. Bounded, since a peer that never sends the CRLF would otherwise be free to make the reader buffer without limit.
_fill!
def _fill!<S: Stream>(s: S, pending: bytes, n: int) -> Result<bytes, HttpError> [net]
Read until pending has at least n bytes. Only ever asked for the two that close a chunk, which bounds the buffer it grows.
readchunked_body!
def _read_chunked_body!<S: Stream>(s: S, already: bytes, max_body: int) -> Result<bytes, HttpError> [net]
Read and decode a chunked body off the stream.
The decoded bytes go straight into the builder while only the unconsumed raw tail is carried along, and pending never grows past one line plus whatever overran the last read. Buffering the whole coded body instead, and handing it to _decode_chunked, would be shorter, and would rebuild that buffer on every 4 KiB read: the O(n²) shape _read_body! was already moved off.
readtrailer!
def _read_trailer!<S: Stream>(s: S, pending: bytes) -> Result<(), HttpError> [net]
Consume the trailer section after the zero-sized chunk: field lines up to a blank one, all discarded. Bounded by the header-line budget, which leaves a peer cannot stream fields forever in place of ending the message.
_Framed
struct _Framed
headers: Headers
framing: _Framing
end
The parsed head of a message: its header fields and how they frame the body.
headframing
def _head_framing(head: bytes) -> Result<_Framed, HttpError>
read_request!
def read_request!<S: Stream>(s: S) -> Result<Request, HttpError> [net]
Read one request from a connected stream.
The message is built here and never re-parsed out of a rejoined buffer: a decoded chunked body no longer matches the framing its own head declares, and handing the two back to parse_request would ask it to decode what is already decoded. @no-doctest: reads a connected stream; parse_request is the same message from a buffer, and it has the example
read_response!
def read_response!<S: Stream>(s: S) -> Result<Response, HttpError> [net]
Read one response from a connected stream. @no-doctest: reads a connected stream; see parse_response for the same message from a buffer
write_response!
def write_response!<S: Stream>(s: S, resp: Response) -> Result<(), HttpError> [net]
Write a response to a connected stream. @no-doctest: writes to a connected stream; render_response produces the same bytes, and it has the example
_exchange!
def _exchange!<S: Stream>(sock: S, wire: bytes) -> Result<Response, HttpError> [net]
Write the request and read the response back over an already-connected transport. Generic over S: Stream, which is the whole reason that trait exists: the framing is written once and both transports run it.
request!
def request!(method: Method, url: string, headers: Headers, body: bytes) -> Result<Response, HttpError> [net]
Send method url with the given headers and body, returning the response. The request is rendered (and its headers validated) before the socket is opened, and a bad URL or header therefore fails without a wasted connection. An https:// URL connects over TLS; the framing after that is identical. @no-doctest: performs a real request; a doctest cannot reach the network, and one that could would be asserting on someone else's server
get!
def get!(url: string) -> Result<Response, HttpError> [net]
Send a GET to url. @no-doctest: performs a network request; needs a live server, cannot assert in a doctest
serveone!
def _serve_one!<S: Stream>(sock: S, handle: (Request) -> Response [e]) -> () [net, e]
Serve handle over one connection: read the request, answer with the handler's response (or a 400 if the request didn't parse), then close.
TcpListener
TcpListener, or net.TcpListener, is a runtime-managed native resource handle (HANKI.md §4): live native state the per-actor memory manager owns, released when the last handle drops. It is single-owner - never copied, moved across a send - and has no fields of its own, so its methods are its whole surface. A handle is minted by an API that opens one; it is never constructed.
acceptand_serve!
def _accept_and_serve!(l: TcpListener, handle: (Request) -> Response [e]) -> Result<(), HttpError> [net, e]
Accept one connection and serve it, reporting whether to keep going: Ok(()) after a handled connection, Err when the accept itself failed.
serveloop!
def _serve_loop!(l: TcpListener, handle: (Request) -> Response [e]) -> Result<(), HttpError> [net, e]
Accept and serve until an accept fails, returning that failure.
_loopback?
def _loopback?(host: string) -> bool
Is host an address only this machine can reach? 127.0.0.0/8 and ::1, including the IPv4-mapped ::ffff:127.0.0.1 spelling. A wildcard bind (0.0.0.0, ::) is not one: it accepts on every interface.
serve!
def serve!(l: TcpListener, handle: (Request) -> Response [e]) -> Result<(), HttpError> [net, e]
Accept connections on l and run handle for each, one at a time. Returns the accept failure that ended the loop. For concurrency, spawn a worker actor per connection and move the accepted socket into it (HANKI.md §15), framing with the functions above.
Refuses a listener bound anywhere but loopback, with ExposedBind, because this module speaks cleartext: a credential sent to a port the network can reach travels in the open, and a service author who has not thought about that has usually not meant to. Bind 127.0.0.1 and put a TLS-terminating reverse proxy in front. When the exposure is the intended deployment, serve_exposed! is the same loop without the check. @no-doctest: accepts connections until the listener closes; there is no value to assert
serve_exposed!
def serve_exposed!(l: TcpListener, handle: (Request) -> Response [e]) -> Result<(), HttpError> [net, e]
serve! on a listener the network can reach, cleartext and all: a process behind a TLS-terminating proxy, or one bound to 0.0.0.0 inside a container, which is what serve! refuses. Everything the module header says about what is readable on that wire still applies. The name is the acknowledgement. @no-doctest: the same accept loop as serve!, and the same reason
TlsListener
TlsListener, or tls.TlsListener, is a runtime-managed native resource handle (HANKI.md §4): live native state the per-actor memory manager owns, released when the last handle drops. It is single-owner - never copied, moved across a send - and has no fields of its own, so its methods are its whole surface. A handle is minted by an API that opens one; it is never constructed.
acceptandservetls!
def _accept_and_serve_tls!(l: TlsListener, handle: (Request) -> Response [e]) -> Result<(), HttpError> [net, e]
Accept one TLS connection and serve it. The TcpListener twin above, on the encrypted transport: _serve_one! is generic over S: Stream and TlsStream implements it, which leaves the framing unrestated here.
Ok(()) continues the loop, Err ends it, and the seam decides which: a rejected peer is Ok(sys.Rejected(_)) and an Err from accept! is the listener itself. Reading the failure's variant instead would end the server on the first client that resets mid-handshake, since that and a dead listening socket are both TlsTransport.
serve_tls!
def serve_tls!(l: TlsListener, handle: (Request) -> Response [e]) -> Result<(), HttpError> [net, e]
Accept TLS connections on l and run handle for each, one at a time. Returns the transport failure that ended the loop.
There is no ExposedBind refusal here, by design: serve! refuses a non-loopback bind because it speaks cleartext, and a credential on a reachable port would travel in the open. Over TLS that reasoning does not apply, a reachable bind being the intended deployment, and the check is absent here in place of serve! losing it. @no-doctest: accepts connections until the listener closes; there is no value to assert