std::net::http
HTTP/1.1, over the sockets in std::net::socket. Both directions.
The specification does not define std::net::http; the shape here was
reconstructed from how the reference application uses it, and every line
below it is Khora over String::slice, String::index_of and Map.
Answering: a request line with its query string, headers, a body read
to the length the headers promised, routing on method and path with :name
segments, and a response that can carry headers of its own. On a fiber per
connection, which took a language feature to reach rather than a line here
— see Router::serve_once.
Asking: HttpClient, a capability, from Call to Answer. A service
that cannot call another service is not one, and this half arrived a long
way after the first.
What neither does: content negotiation, multipart bodies, compression, or following a redirect. Each is absent deliberately rather than forgotten. Chunked transfer is read by the client and not written by the server, which is the split real traffic has: a response of unknown length is ordinary and a request of unknown length is not.
A connection is reached through a Transport, which is three closures, so
that the serving path is written once, Router::listen and
Router::listen_tls differ only in what they hand it, and the client
reuses both without knowing which it has.
API at a glance
Section titled “API at a glance”Types: Method · Params · Request · Header · Response · HttpError · RequestLine · Transport · Connection · Incoming · Route · Router · Url · Call · Answer · CallError
Effects: HttpClient
Methods: Method · Params · Request · Response · Connection · Router · Call · Answer · HttpClient
Functions: percent_decode · parse · default_limit · over_socket · over_tls · request_length · matches · default_answer_limit · default_answer_timeout · parse_url
Method
Section titled “Method”pub type Method = | Get | Post | Put | Patch | Delete;The five verbs this server routes on.
Not every verb HTTP defines: HEAD, OPTIONS and TRACE are answered by
the server rather than routed to a handler, and a method nobody can mount a
route for is not worth a case here. of returns None for the rest, which
becomes a 400 rather than a panic.
Params
Section titled “Params”pub type Params = { bound: Map<String, String>,};The path parameters a route matched: /analyze/:account_id against
/analyze/acc_9921 binds account_id.
Request
Section titled “Request”pub type Request = { method: Method, path: String, params: Params, queries: Map<String, String>, headers: Map<String, String>, body: String,};One request, parsed: the verb, the path, what the route bound, the query, the headers and the body.
Everything is decoded on arrival, once. A handler reading three query parameters and two headers does no parsing at all — the maps below are already built, and the header names are already lowercased.
path: String
The path with the query string taken off: /analyze/acc_1?deep=true has
a path of /analyze/acc_1. Routes match against this, so mounting
/analyze/:id does not stop working the moment a client adds a
parameter.
queries
Section titled “queries”queries: Map<String, String>
Decoded once, when the request arrives, rather than on every lookup — a handler that reads three parameters should not decode the query three times.
headers
Section titled “headers”headers: Map<String, String>
Keyed by the lowercased name, because HTTP header names are
case-insensitive and a map is not. Folding the case here rather than in
header means two headers differing only in case collide on arrival,
which is the correct reading: they are the same header.
Header
Section titled “Header”pub type Header = { name: String, value: String,};One header on the way out.
Response
Section titled “Response”pub type Response = { status: Int, body: String, content_type: String, headers: List<Header>,};What a handler answers with: a status, a body, its type, and any extra headers.
json and text build the two common ones. with_header adds to either,
and returns a new response rather than changing the one it was given.
headers
Section titled “headers”headers: List
Anything beyond the three a response always carries. A list rather than a
map, because Set-Cookie may legitimately appear more than once and a
map would silently keep only the last.
HttpError
Section titled “HttpError”derive(Show, Eq)pub type HttpError = | BindFailed(port: Int) | MalformedRequest(details: String);What can go wrong that is the server’s problem rather than a client’s.
A malformed request from a client is answered with a 400 and never reaches here — that is a response, not an error. These two are the cases where there is nobody to answer: the port would not bind, or a request could not be read at all.
RequestLine
Section titled “RequestLine”pub type RequestLine = { method: Method, target: String,};The first line of a request, before the target has been split.
Public because a caller parsing a request for its own reasons wants the
target as the client wrote it; Request carries the split version.
target
Section titled “target”target: String
The request target as written, query string and all. parse splits it;
keeping it whole here means a caller reading a request line for its own
reasons gets what the client actually sent.
Transport
Section titled “Transport”pub type Transport = { receive: (Array<U8>) -> Int, transmit: (String) -> Int, shut: () -> (),};How to speak to one client, whatever is underneath.
Three closures rather than a socket handle, because a plain connection
and a TLS one differ in exactly these three operations and in nothing else
the router does. Before this the serving path took an Int, which is a
socket, and there was no way to put a handshake in front of it without
writing the whole path twice.
The cost is two indirect calls per request — one read, one write — against a parse that performs several hundred reference-count operations. It did not move the benchmark.
A record of closures is what an effect compiles to anyway
(the effect-runtime design note §2), and this is not one only because a
transport belongs to a connection rather than to a computation: two fibers
serve two clients at once, and a capability installed by with would be
one of them.
receive
Section titled “receive”receive: (Array
Bytes into the buffer: how many, 0 when the peer closed, or -1.
transmit
Section titled “transmit”transmit: (String) -> Int
The text, all of it, or -1.
shut: () ->()
Ends the conversation and releases whatever is underneath.
Connection
Section titled “Connection”pub type Connection = { transport: Transport, buffer: Array<U8>, mut carried: Int,};One connection, read a request at a time.
This is the layer between a socket and a router, and it is public
because a router is a matter of taste and this is not. Framing a request
correctly — reading until it is whole, honouring Content-Length, holding
what a pipelined client sent early, refusing one that will not fit — is the
same work whatever shape the framework above it has, and it is the work that
fails in production rather than in testing.
Router is written against this and has no privileged access to anything
below it. A framework with a different shape — middleware chain, effect
handler, one function per method — starts here and owes nothing to Router.
Nothing here spawns. A connection is read by whoever calls next, on
whatever fiber that is. Router gives each connection a fiber in its accept
loop and that is a decision of the accept loop, not of this type: a server
that answers one caller at a time, or hands connections to a pool, is
written against exactly this.
carried
Section titled “carried”mut carried: Int
Bytes of the next request that arrived with the last one. A pipelining client sends two without waiting, and the second must not be dropped.
Incoming
Section titled “Incoming”pub type Incoming = | Arrived(request: Request, keep: Bool) | Ended | Rejected(response: Response);What reading a connection produced.
Arrived
Section titled “Arrived”| Arrived(request: Request, keep: Bool)A whole request, and whether the client asked to keep the connection open.
The flag is read from the Connection header here rather than by the
caller, because the caller would have to fold the header’s case and this
has already done it.
| EndedThe peer closed, or the read deadline passed. There is nothing to answer and nothing to send.
Rejected
Section titled “Rejected”| Rejected(response: Response)Nothing usable arrived: send this and close.
A client that could not be understood cannot be trusted about where its
next request begins, so the connection cannot be reused whatever the
Connection header said.
pub type Route<'er> = { method: Method, pattern: String, handler: SharedFn<Request, Response, 'er>,};One mounted route.
The handler is a SharedFn rather than a plain closure, and that is what
lets a router be answered on a fiber of its own. A closure’s captures are
not in its type, so a record holding one can never be handed to another
fiber; SharedFn::of checks the captures where the closure is written and
hands back something that has forgotten it was ever a closure.
It also takes the capabilities out of the router’s type. A handler closes
over what it needs at the mount site, so there is no 'r left for a Route
to carry — Router<'er> says only how its handlers can fail.
Router
Section titled “Router”pub type Router<'er> = { routes: List<Route<'er>>,};A router carries its handlers’ failures, so mounting a route that can fail against the database widens the router’s own error row.
pub type Url = { secure: Bool, host: String, port: Int, target: String,};Where a call is going.
secure
Section titled “secure”secure: Bool
Whether to put TLS in front of the socket.
target
Section titled “target”target: String
Path and query, together, beginning with /. What goes on the request
line unchanged — a client that re-encodes what it was given is a client
that breaks somebody’s signed URL.
pub type Call = { method: Method, url: String, headers: List<Header>, body: String,};A request this program is making.
headers
Section titled “headers”headers: List
A list rather than a map, matching Response: two headers of the same
name are legal on the way out and a map would keep one.
Answer
Section titled “Answer”pub type Answer = { status: Int, headers: Map<String, String>, body: String,};What came back.
Headers keyed by the lowercased name, as Request’s are and for the
same reason: HTTP header names are case-insensitive and a map is not.
CallError
Section titled “CallError”derive(Show, Eq)pub type CallError = | BadUrl(String) | Unreachable(String) | Insecure(String) | Closed(String) | Malformed(String) | TooLarge(Int) | Denied(String);What can go wrong reaching somebody else.
Separate from HttpError, which is what goes wrong being a server —
a port that would not bind, a request that could not be read. Nothing that
matches one has any use for the cases of the other.
BadUrl
Section titled “BadUrl”| BadUrl(String)The URL could not be read.
Unreachable
Section titled “Unreachable”| Unreachable(String)No connection: the name did not resolve, or nothing answered.
Insecure
Section titled “Insecure”| Insecure(String)The TLS handshake did not complete.
Closed
Section titled “Closed”| Closed(String)The peer went away before the answer was whole.
Malformed
Section titled “Malformed”| Malformed(String)Something arrived and it was not an HTTP answer this can read.
TooLarge
Section titled “TooLarge”| TooLarge(Int)The answer was longer than the client was willing to hold.
Denied
Section titled “Denied”| Denied(String)The manifest does not grant this host.
Its own case, not Unreachable. The two send a reader to different
places: Unreachable is DNS, a firewall, a service that is down, and
Denied is [permissions] network in khora.toml. Reported as
host:port so the line that would allow it can be copied out of the
message.
Effects
Section titled “Effects”HttpClient
Section titled “HttpClient”pub effect HttpClient { send: (Call) -> Result<Answer, CallError>,}Making requests, as a capability.
An effect rather than a function, for the reason std::db’s Db is
one. A program that says with { http: HttpClient } can be handed a
recording double in a test, a client that only reaches one host, or one
wrapped for tracing — and every caller above is unchanged, because a
capability is an interception point. The observability design note shows
the wrapper.
One operation. Call carries everything a request is, so a second would
only be a shorter way to build one, and Call::get is already that.
send: (Call) -> Result<Answer, CallError>
Methods
Section titled “Methods”Method
Section titled “Method”impl Methodpub fn of(text: String) -> Option<Method>The method this text names, or None.
Case-sensitive, because HTTP methods are: get is not GET, and a
server that accepted it would be lenient about the one part of the
request line that has never been ambiguous.
pub fn same(self, other: Method) -> BoolWhether these are the same method.
Method predates derive(Eq) reaching this file; rank is what it
compares on.
Params
Section titled “Params”impl Paramspub fn empty() -> ParamsNo parameters bound. What a route with no :name in it matches with.
pub fn get(self, name: String) -> Option<String>Option::None when the route did not declare that parameter, which is a
programming error the type makes you notice rather than an empty string.
Request
Section titled “Request”impl Requestpub fn get(path: String) -> RequestA GET request for path, with no parameters matched yet. Chiefly for
tests: a real one arrives from a socket.
pub fn post(path: String, body: String) -> RequestA POST to path carrying body. For tests, like get.
pub fn of(method: Method, target: String, body: String) -> RequestThe query string is honoured here too, so a hand-built request behaves
like one off the wire. A test that writes Request::get("/x?a=1") and
finds no a would be testing the constructor rather than the code.
pub fn query(self, name: String) -> Option<String>A query parameter, already percent-decoded.
A parameter written with no = — /x?verbose — is present with an empty
value, which is how every server reads it and what lets a handler ask
“was this requested” rather than “what does it say”.
header
Section titled “header”pub fn header(self, name: String) -> Option<String>A header, by a name in any case.
Host, host and HOST are one header. The RFC says so, and this is
the half of it that everybody gets wrong.
Response
Section titled “Response”impl Responsepub fn json<A: Encode>(status: Int, body: A) -> ResponseEncodes body as JSON and sends it with the given status.
A: Encode, so a derived record, a Json built by hand and a list of
Rejections all serve: Response::json(422, problems) is the body a
client reads a refused request from. A record holding a secret has no
Encode, so it cannot be sent by accident.
pub fn text(status: Int, body: String) -> Responsebody as text/plain, with the given status.
with_header
Section titled “with_header”pub fn with_header(self, name: String, value: String) -> ResponseThe same response with one more header on it.
A new response rather than a mutated one, like Prompt’s builders and
for the same reason: a half-built response cannot be shared and then
changed underneath its other holder.
rendered
Section titled “rendered”pub fn rendered(self) -> StringThe response, as bytes on the wire.
rendered_keeping
Section titled “rendered_keeping”pub fn rendered_keeping(self, keep: Bool) -> StringThe same, saying whether the connection is staying open.
Reuse is worth two orders of magnitude and nothing else here is. Measured on this machine, one server answering the same request: 6,116 a second on a fresh connection each time, 1,129,782 on one held open. The handshake, the teardown and the port churn were the whole cost; the server was never the bottleneck. That is also why comparing this to a server benchmarked with keep-alive was comparing two different questions.
Content-Length is on every response already, which is what makes this
safe: a client knows where the body ends and where the next answer
begins without the connection closing to tell it.
reason
Section titled “reason”pub fn reason(status: Int) -> StringThe reason phrase for a status.
The ones a service actually sends, which has twice turned out to be more
than the list had. The link shortener answered a redirect and got
HTTP/1.1 302 Unknown on the wire; later a service under load answered
503 and got the same. A phrase is advisory — every client reads the
number — but one that says Unknown about a status the server
deliberately chose reads as a bug to whoever is holding the packet
capture, and it was.
The whole of 5xx is here now, because that is the family a server sends about itself: 503 under backpressure, 502 and 504 from anything with a proxy in front of it, 501 for a method it does not implement. Those are the lines somebody reads at three in the morning.
A match rather than a chain of else ifs. Twenty-seven of them in a
row is a table pretending to be control flow, and a table should look
like one – the numbers are in order here, so a missing status is
something a reader can see rather than something they have to search for.
413 stays “Payload Too Large” although RFC 9110 renames it “Content Too Large”. That is a wire string somebody may be matching on, and changing it is a decision of its own rather than something to slip into a list of additions.
Connection
Section titled “Connection”impl Connectionpub fn over(transport: Transport) -> ConnectionOver a transport, holding at most [default_limit] bytes per request.
holding
Section titled “holding”pub fn holding(transport: Transport, most: Int) -> ConnectionThe same, with a cap of your own.
The buffer is allocated once for the connection rather than once per request, so this is what one caller costs in memory for as long as they are connected.
pub fn next(self) -> IncomingReads until one request is whole, and says what arrived.
Blocks. Whether that blocks a thread, a fiber or the only thread there is
belongs to the caller — see the note on [Connection].
understood
Section titled “understood”pub fn understood(text: String) -> IncomingParses one request, or the 400 to answer it with.
Everything a connection needs to decide before a handler runs: whether the bytes were a request at all, and — from the headers the parser has already folded — whether the client asked to keep the connection.
pub fn reply(self, response: Response, keep: Bool) -> IntRenders response and sends it.
keep decides the Connection header, and is what [Incoming::Arrived]
reported unless the caller has its own reason to close.
The two travel together because they are decided together: the answer’s
status and the request’s Connection header are both known once, at the
point the request has been parsed, and working either out again afterwards
means reading the request a second time.
pub fn shut(self) ->()Ends the conversation and releases the transport.
Router
Section titled “Router”impl RouterMounting is written router |> Router::post(..), so the router is the
first parameter of each. They are the type’s own functions rather than free
ones so that the pipeline reads as a chain against Router and cannot
collide with another module’s get.
One per method, over a general on that takes the method as a value. Both
are worth having: the named five are what a pipeline reads best with, and
on is what stops the set being closed — a router with only the verbs
somebody thought to write helpers for is the shape this had before, and it
made a REST service unwritable.
pub fn new<'er>() -> Router<'er>A router with no routes. The start of the pipeline.
pub fn on<'er>(router: Router<'er>, method: Method, route: String, handler: SharedFn<Request, Response, 'er>) -> Router<'er>Mounts handler at route for method.
The general one, and the five below are it with a name. A router that
could only mount the methods somebody had written a helper for is a
router that stops at whatever its first caller needed — which is what
this one did, for months, with get and post and no way to say PUT
at all. The named five exist because Router::get(..) reads better in a
pipeline than Router::on(Method::Get, ..), not because they are the
only ones allowed.
Later mounts are tried first, because each prepends. Mounting the same route twice is allowed and the second one wins, which is worth knowing rather than an invariant anything enforces.
pub fn get<'er>(router: Router<'er>, route: String, handler: SharedFn<Request, Response, 'er>) -> Router<'er>Mounts handler at route for GET.
pub fn post<'er>(router: Router<'er>, route: String, handler: SharedFn<Request, Response, 'er>) -> Router<'er>Mounts handler at route for POST.
pub fn put<'er>(router: Router<'er>, route: String, handler: SharedFn<Request, Response, 'er>) -> Router<'er>Mounts handler at route for PUT.
pub fn patch<'er>(router: Router<'er>, route: String, handler: SharedFn<Request, Response, 'er>) -> Router<'er>Mounts handler at route for PATCH.
delete
Section titled “delete”pub fn delete<'er>(router: Router<'er>, route: String, handler: SharedFn<Request, Response, 'er>) -> Router<'er>Mounts handler at route for DELETE.
delete rather than remove, because the method is spelled DELETE and
a router’s job is to say what the wire says.
listen
Section titled “listen”pub fn listen<'er>(router: Router<'er>, port: Int) ->() raises 'er + HttpError + ChildFailedServes until the process stops, on a fiber per connection.
This writes one line to standard output — listening on <port> — once
the port is open. It is a readiness signal: something started this process
and wants to know when it can connect, and a line on stdout is what every
supervisor and test harness can already read without being taught a
protocol.
A server whose output is structured logs does not want it, because it
arrives on the same stream in a different format and everything parsing
that stream has to be taught about it. listen_quietly is this without
the line.
listen_quietly
Section titled “listen_quietly”pub fn listen_quietly<'er>(router: Router<'er>, port: Int) ->() raises 'er + HttpError + ChildFailedlisten, without the line on standard output.
For a server whose output is structured. One unsolicited line is not much, but it is on the same stream as the logs and is not in their format. A caller here announces readiness however the rest of its output does — or not at all, for a service behind a health check that answers the question better anyway.
listen_tls
Section titled “listen_tls”pub fn listen_tls<'er>(router: Router<'er>, port: Int, certificate: String, key: String) ->() with { scope: Scope } raises 'er + HttpError + TlsError + ChildFailedThe same, over TLS.
Takes the certificate chain and the key as PEM rather than as paths: a server that reads its own files has decided where they live, and the two deployments that matter — a file on disk and a secret handed in by the platform — disagree about that.
The configuration is released when the caller’s scope ends, which for a server is usually the program’s.
pub fn bound<'ef>(port: Int) -> Int raises HttpErrorA listening socket on port, announced by the caller.
serve_secured
Section titled “serve_secured”pub fn serve_secured<'er>(router: Router<'er>, server: Int, settings: TlsServer) ->() with { nursery: Nursery } raises 'er + HttpErrorserve_forever over TLS: accepts, handshakes, and serves on a fiber
each.
A failed handshake ends that fiber quietly rather than the loop — on a public port it is the ordinary case, not an incident.
serve_forever
Section titled “serve_forever”pub fn serve_forever<'er>(router: Router<'er>, server: Int) ->() with { nursery: Nursery } raises 'er + HttpErrorAccepts connections on server until the process stops.
listen is the one to call: it binds the port and then this. Taking an
already-bound socket is for a caller that wants to bind it themselves —
a test on port zero, a socket inherited from a supervisor.
answer_on
Section titled “answer_on”pub fn answer_on<'er>(router: Router<'er>, transport: Transport) ->() raises 'erAnswers every request on one connection, until the client stops.
Written against the public [Connection], with nothing reserved. A
framework of a different shape writes this loop itself and is not missing
anything: the reading, the framing and the refusals are all above.
impl Callpub fn get(url: String) -> CallA GET of url, with no headers of its own.
pub fn post(url: String, body: String) -> CallA POST of body, with no content type.
Deliberately none: a POST of form data and a POST of JSON differ in
exactly that header, and guessing it for the caller is how a client ends
up sending application/json over a form. json below is the one that
guesses, because there the name says what it guessed.
pub fn json(url: String, body: String) -> CallA POST of body as application/json.
pub fn of(method: Method, url: String, body: String) -> CallAny method, which is what the four above are shorthand for.
with_header
Section titled “with_header”pub fn with_header(self, name: String, value: String) -> CallThe same call with one more header on it.
A new call rather than a changed one, like Response::with_header and
for the same reason: a half-built request cannot be shared and then
altered underneath whoever else is holding it.
Answer
Section titled “Answer”impl Answerheader
Section titled “header”pub fn header(self, name: String) -> Option<String>The header under name, matched without regard to case.
pub fn ok(self) -> BoolWhether the status is one of the 2xx.
Not “did the call work” — a 404 arrived successfully and is not ok,
and a CallError is the other thing entirely.
HttpClient
Section titled “HttpClient”impl HttpClientpub fn real() -> HttpClientA client that really connects, holding up to default_answer_limit.
bounded
Section titled “bounded”pub fn bounded(limit: Int) -> HttpClientThe same, holding up to limit bytes of answer.
Functions
Section titled “Functions”percent_decode
Section titled “percent_decode”pub fn percent_decode(text: String) -> String%20 becomes a space, and so does +.
A % not followed by two hex digits stays a literal %. Refusing the
whole parameter would be defensible and is not what any browser or server
does, and a query string is not the place to be the only strict one.
The decoded form is never longer than the encoded one, so a buffer of the input’s length is always enough and there is nothing to grow.
pub fn parse(text: String) -> Option<Request>A request read off the wire.
default_limit
Section titled “default_limit”pub fn default_limit() -> IntThe most a request may be, headers and body together, unless a caller says otherwise.
Eight kilobytes is what a buffer costs per connection, and a connection is
what a fiber costs — so this is the number that decides how many callers a
server can hold, not a guess about how big a request is. Connection::holding
takes another.
The buffer is allocated once at this size and never grows, which is what
makes a lying Content-Length harmless: the header is a promise about what
is coming, not an instruction to allocate.
Eight kilobytes is a policy, not a limit of the parser. Nothing here recurses per byte or per line, so a larger request is refused because the limit says so rather than because the parser cannot manage it: a 39,808-byte request carrying 2,001 headers parses and answers when the limit is raised to admit it.
The number stays at eight kilobytes anyway, which is what nginx and most others allow, because the question it answers changed rather than disappeared. It is no longer “how much can we survive” but “how much may an unauthenticated client make a server hold”, and that is a smaller number than the one the stack would now permit.
over_socket
Section titled “over_socket”pub fn over_socket(connection: Int) -> TransportA transport over a plain socket.
over_tls
Section titled “over_tls”pub fn over_tls(connection: TlsConnection) -> TransportA transport over a secured connection.
The socket is the TLS connection’s now — secure took it and shut closes
it — so there is nothing here that closes it a second time.
request_length
Section titled “request_length”pub fn request_length(bytes: Array<U8>, filled: Int) -> IntHow long the whole request should be, or negative while the headers are still arriving.
The framing primitive. A reader needs to know when to stop, and HTTP
answers that in two parts: the headers end at a blank line, and
Content-Length says how much follows. Getting it wrong is not a bug that
shows up in testing — it is a request truncated at a packet boundary, or two
requests read as one — which is why this is exported rather than left for
each framework to derive again.
[Connection] is this with a buffer around it, and is what most callers
want.
matches
Section titled “matches”pub fn matches(pattern: String, path: String) -> Option<Params>Whether path matches pattern, and what its :name segments bound.
/analyze/:id against /analyze/acc_9921 binds id. None when the
shapes disagree, which is how the router moves on to the next route.
Exported so a program can route somewhere other than through Router —
a WebSocket upgrade, a second listener — without reimplementing the one
piece of matching that has to agree with the server’s.
default_answer_limit
Section titled “default_answer_limit”pub fn default_answer_limit() -> IntHow much of an answer this client will hold, when nobody says otherwise.
64 KiB, and it is a buffer allocated per call rather than per connection —
a hundred fibers each making a request is a hundred of these at once, which
is why it is not measured in megabytes. HttpClient::bounded raises it for
a caller who knows they are fetching something bigger.
default_answer_timeout
Section titled “default_answer_timeout”pub fn default_answer_timeout() -> IntHow long to wait for bytes that are not arriving, in milliseconds.
On both paths now. It was the plain one only, because
set_receive_timeout takes a socket and a TLS session owns its socket
rather than handing it back – so a TLS server that accepted the connection
and then said nothing held the fiber until the process ended.
std::net::tls sets the deadline on the socket underneath, and dial uses
it for https exactly as it does for http.
parse_url
Section titled “parse_url”pub fn parse_url(text: String) -> Option<Url>Reads https://host:port/path?query.
Only what a client needs to dial and ask. No fragment, because a fragment never leaves the browser; no userinfo, because credentials in a URL are deprecated and accepting them would mean deciding how to encode them; and no normalisation of the path, because a server may have signed exactly the bytes it was given.
Khora 0.1.0built frombd964738afb6on 2026-10-17 20:41ZVersioningSecurityContributingReleasesSource