HTTP client and server for Bend 2.
It needs bend 2.0.32 or later (the server binds TCP.listen(host, port)).
ezhttp is built and checked on bend 2.0.34.
With Bend alone there is no install
step: import ezhttp by its hub name and bend fetches it from
the hub into ~/.bend/lib on the first run.
emerging-ezhttp@0.8.0.0 is ezhttp v0.8.0:
import emerging-ezhttp@0.8.0.0/main.bend as Http
That name resolves to 0xf1c957a470368870a6d1d62a8c0cbe32.
To pin by content instead, import 0xf1c957a470368870a6d1d62a8c0cbe32/main.bend.
Or with ez, which records the
package in ez.toml (ez init makes one):
ez add Emerging-Patterns/ezhttp
Shared types cover both sides: Header, structured Request / Reply
(status or method, headers, body). Bodies are empty, UTF-8 text, or an octet
list. The HTTP API does not need JSON: Request, Reply, and the client
Response stay text and octets, and encode / decode take any codec. The
hub package is the HTTP library alone. src/json.bend, the optional
helper that calls ezjson, is
in this repository but not reachable from main.bend, so it is not in the
hub package; import ezjson from the hub to read a JSON body (below).
http.get, http.head, http.post, http.put, http.delete, and
http.options return a structured client Response (status, headers, body).
Pass custom headers: bearer writes Authorization: Bearer …, and basic
writes Authorization: Basic … (RFC 7617, RFC 4648). TLS stays at the
wire/runtime layer (EZ_LIBSSL when needed). https selects that TLS path
and port 443.
import emerging-ezhttp@0.8.0.0/main.bend as Http
import emerging-ezhttp@0.8.0.0/src/client.bend as Client
def main() -> IO(Client.Response):
Http.http.get("https://example.com/")
def authed() -> IO(Client.Response):
Http.http.get_with("https://example.com/api", [Http.bearer("token")])
def create() -> IO(Client.Response):
Http.http.put("https://example.com/items", Http.text_body("hello"))
http.serve(handle, host, port, limit) listens with Base TCP on host
("127.0.0.1" for loopback only, "0.0.0.0" for every interface), accepts connections, parses one request
per connection, calls a pure handler, and writes one response
(Connection: close). http.serve_once(handle, host, port) stops after a single client. No TLS
in v0 for the server. A HEAD response is written with an empty body.
import emerging-ezhttp@0.8.0.0/main.bend as Http
import emerging-ezhttp@0.8.0.0/src/http.bend as Msg
def handle(req: Msg.Request) -> Msg.Reply:
match req:
case Msg.Bad{why}:
Msg.Reply{400, [], why}
case Msg.Request{method, target, headers, body}:
Msg.Reply{200, [], "ok"}
def main() -> IO(Unit):
Http.http.serve(handle, "127.0.0.1", 8080, 1024)
Cookies, Cache-Control, and CORS are pure helpers on the same messages.
import emerging-ezhttp@0.8.0.0/main.bend as Http
import emerging-ezhttp@0.8.0.0/src/http.bend as Msg
import emerging-ezhttp@0.8.0.0/src/cookie.bend as Cookie
import emerging-ezhttp@0.8.0.0/src/cors.bend as Cors
def authed() -> Msg.Header:
Http.basic("user", "pass")
def jar() -> Msg.Header:
Http.cookie_header([Cookie.cookie.new("a", "b")])
def cacheable(value: String) -> Bool:
Http.fresh(value, 0n)
def cross(cfg: Cors.Cfg, req: Msg.Request, reply: Msg.Reply) -> Msg.Reply:
Http.cors_reply(cfg, req, reply)
import emerging-ezjson@1.1.0.0/main.bend as Ezjson
import emerging-ezjson@1.1.0.0/src/value.bend as Value
def read.of(got: Maybe<&2, Value.Json>) -> Maybe<&2, String>:
match got:
case None{}:
None{}
case Some{j}:
Ezjson.as_str(j)
def read(text: String) -> Maybe<&2, String>:
read.of(Ezjson.parse(text))
parse_cookie reads a Set-Cookie field value. set_cookie writes one.
cache_control reads no-store, no-cache, max-age, public, and
private. cors_reply answers a simple request or an OPTIONS preflight from
allow_origins, methods, headers, credentials, and max_age. Credentials
never pair with Access-Control-Allow-Origin: *.
Closed equalities in LAWS.bend, proved in PROOF.bend
(bend PROOF.bend prints ALL PROOFS CHECK). The laws that say the
entry's re-exports and the client's header helpers equal those pure
definitions are in ENTRY.bend. main.bend and src/client.bend reach
the wire effect, so bend's verdict on that file is SOME PROOFS FAIL, with
the list of defs that rely on foreign code as its only error. The laws target:
- RFC 3986 URI: scheme before the
first colon and lowercased (§3.1),
hier-partrequiring//(§3), userinfo stripped from the authority, last@wins (§3.2.1), IPv4 host and explicit port, a non-numeric port uses the scheme default (§3.2.2, §3.2.3), path always absolute, trailing slash kept (§3.3), query kept including a later?, fragment dropped even when it holds?(§3.4, §3.5), default ports 80 / 443. - RFC 2818 HTTP over TLS: scheme
httpsselects the TLS wire path and port 443. The TLS handshake itself stays in the runtime. - RFC 9110 HTTP Semantics:
case-insensitive field names (§5.1), optional whitespace trimmed and
obs-fold rejected (§5.5),
Hoston every HTTP/1.1 client request (§7.2),Content-Lengthas an exact octet count, omitted when the body is empty (§8.6), the first of a repeated field is the one lookup returns (§5.2), safe methods GET / HEAD / OPTIONS (§9.2.1), idempotent methods adding PUT / DELETE (§9.2.2), method tokens case-sensitive (§9.3), HEAD response body omitted (§9.3.2), PUT / DELETE / OPTIONS request lines (§9.3.4, §9.3.5, §9.3.7), reason-phrases for 200, 201, 204, 400, 404, 405, and 500 (§15). A 204 or 304 response is written with an empty body (§15.3.5, §15.4.5). - RFC 9112 HTTP/1.1 Message Syntax
and Routing: header section ended by a blank line (§2.1), origin-form
request-target (§3.2.1),
OPTIONS *asterisk-form (§3.2.4), status-lineHTTP/1.1(§4),Content-Lengthvalues that disagree andContent-Lengthtogether withTransfer-Encodingare rejected, while identicalContent-Lengthvalues are that one length (§6.3), chunked bodies including a second non-empty chunk, a chunk-ext, a non-hex chunk size, and a size of 0 that ends the body before any trailer (§7.1),Connection: close(§9.6). A body shorter than itsContent-Lengthis torn (client response) or bad (server request). Absolute-form is rejected. - RFC 4648 Base64 (§4, §10) and
RFC 7617 Basic authentication
(§2):
Authorization: Basicis Base64 ofuser:password. - RFC 6750 Bearer (§2.1):
Authorization: Bearerplus a token with no whitespace. - RFC 6265 cookies:
Set-Cookiename=value and attributes Expires, Max-Age, Domain, Path, Secure, HttpOnly, SameSite (§4.1.1, canonical Lax / Strict / None; anything else is dropped), domain-match and path-match (§5.1.3, §5.1.4), theCookierequest header joined by;(§5.4). An empty cookie-name is rejected. A non-digit Max-Age is ignored. Domain is stored lowercase. Expires is stored and not compared to a clock. SameSite is stored; there is no browsing context to suppress a cross-site send. - RFC 9111 HTTP Caching:
max-agefreshness, includingmax-age=0stale (§4.2.1),Cache-Controldirectivesno-store,no-cache,max-age,public,privatewith case-insensitive names (§5.2). An unrecognized directive such ass-maxage, and a non-digitmax-age, are ignored.Expiresis recorded beside Cache-Control (§5.3). This is not a shared cache. - Fetch CORS protocol:
Access-Control-Allow-Origin,Access-Control-Allow-Methods,Access-Control-Allow-Headers,Access-Control-Allow-Credentials,Access-Control-Max-Age,Access-Control-Expose-Headers, and the request headersOrigin,Access-Control-Request-Method,Access-Control-Request-Headers. A small allow-list answers a simple request and an OPTIONS preflight. Requested header names are matched case-insensitively. A refused origin, method, or header is a 400 preflight. Credentials never emit*.
Not claimed: HTTP/2 (RFC 9113), HTTP/3 (RFC 9114), a TLS handshake in the
server, multipart bodies, pipelining, trailers, 1xx / Expect, a shared
cache or IMF-fixdate arithmetic, a full cookie jar (public suffix, eviction,
the Expires clock, SameSite cross-site suppression), allow_origin_regex,
IPv6 literals, absolute-form request-targets, and unfolding
obs-fold (it is rejected). Userinfo is stripped, not turned into
Authorization.