betterchess

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs

commit 5bdaeaa5a9b5893e572940faa2c78082055e3d45
Author: thing1 <thing1@seacrossedlovers.xyz>
Date:   Wed, 14 Jan 2026 10:22:12 +0000

init commit

Diffstat:
A.gitignore | 1+
AMakefile | 32++++++++++++++++++++++++++++++++
Abetterchess | 0
Achess/chess.ha | 69+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acmd/betterchess/main.ha | 53+++++++++++++++++++++++++++++++++++++++++++++++++++++
Anet/http/README | 17+++++++++++++++++
Anet/http/client.ha | 102+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Anet/http/constants.ha | 112+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Anet/http/do.ha | 148+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Anet/http/error.ha | 27+++++++++++++++++++++++++++
Anet/http/header.ha | 108+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Anet/http/request.ha | 332+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Anet/http/response.ha | 176+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Anet/http/server.ha | 283+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Anet/http/status.ha | 111+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Anet/http/transport.ha | 330+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Anet/websocket/websocket.ha | 358+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
17 files changed, 2259 insertions(+), 0 deletions(-)

diff --git a/.gitignore b/.gitignore @@ -0,0 +1 @@ +./betterchess diff --git a/Makefile b/Makefile @@ -0,0 +1,32 @@ +.POSIX: +.SUFFIXES: + +HARE=hare +HAREFLAGS=-lc + +DESTDIR= +PREFIX=/usr/local +BINDIR=$(PREFIX)/bin + +HARE_SOURCES != find . -name '*.ha' + +all: betterchess + +betterchess: $(HARE_SOURCES) + $(HARE) build $(HAREFLAGS) -o $@ cmd/$@/ + +check: + $(HARE) test $(HAREFLAGS) + +clean: + rm -f betterchess + +install: + install -Dm755 betterchess $(DESTDIR)$(BINDIR)/betterchess + +uninstall: + rm -f $(DESTDIR)$(BINDIR)/betterchess + +.PHONY: all check clean install uninstall + + diff --git a/betterchess b/betterchess Binary files differ. diff --git a/chess/chess.ha b/chess/chess.ha @@ -0,0 +1,69 @@ +use io; +use fmt; +use ascii; + +export type invalidplace = !void; + +export type ptype = enum rune { + KING = 'k', + QUEEN = 'q', + BISHOP = 'b', + KNIGHT = 'n', + ROOK = 'r', + PAWN = 'p' +}; + +export type color = enum {BLACK = 'b', WHITE = 'w'}; + +export type piece = struct { + x: size, + y: size, + ty: ptype, + team: color +}; + +export type board = struct { + w: size, + h: size, + pieces: []piece, +}; + +export fn mkboard(w: size, h: size) board = { + return board{w = w, h = h, pieces = []}; +}; + +export fn finish(b: *board) void = { + free(b.pieces); +}; + +export fn mkpiece(b: *board, x: size, y: size, ty: ptype, team: color) (*piece | invalidplace) = { + if (x < 0 || x >= b.w || y < 0 || y >= b.h) return invalidplace; + append(b.pieces, piece{x = x, y = y, ty = ty, team = team})!; + return &b.pieces[len(b.pieces) - 1]; +}; + +export fn getpiece(b: *board, x: size, y: size) (*piece | void | invalidplace) = { + if (x < 0 || x >= b.w || y < 0 || y >= b.h) return invalidplace; + for (let p &.. b.pieces) + if (p.x == x && p.y == y) + return p; +}; + +export fn print_board(b: board, f: io::handle) void = { + const w = 500z; + const h = 500z; + fmt::fprintf(f, "<canvas id=\"myCanvas\" width=\"{}\" height=\"{}\" style=\"border:1px solid #000000;\"></canvas>\n", w, h)!; + + for (let x = 0z; x < w; x += (w / b.w)) + fmt::fprintf(f, "<script>var c = document.getElementById(\"myCanvas\");\nvar ctx = c.getContext(\"2d\");\nctx.moveTo({}, 0);\nctx.lineTo({}, {});\nctx.stroke();\n</script>\n", x, x, h)!; + + for (let y = 0z; y < w; y += (w / b.w)) + fmt::fprintf(f, "<script>var c = document.getElementById(\"myCanvas\");\nvar ctx = c.getContext(\"2d\");\nctx.moveTo(0, {});\nctx.lineTo({}, {});\nctx.stroke();\n</script>\n", y, w, y)!; + + for (let p .. b.pieces) { + let x = (w / b.w) * p.x; + let y = (h / b.h) * p.y + 23; + let text = if (p.team == color::WHITE) ascii::toupper(p.ty: rune) else p.ty: rune; + fmt::fprintf(f, "<script>var c = document.getElementById(\"myCanvas\");\nvar ctx = c.getContext(\"2d\");\nctx.font = \"30px Arial\";\nctx.fillText(\"{}\", {}, {});\n</script>\n", text, x, y)!; + }; +}; diff --git a/cmd/betterchess/main.ha b/cmd/betterchess/main.ha @@ -0,0 +1,53 @@ +use fmt; +use os; +use io; +use net; +use net::ip; +use net::tcp; +use net::http; + +use chess; + + +export fn main() void = { + const port: u16 = 8080; + const addr = ip::LOCAL_V4; + + let s = match (http::listen(addr, port, tcp::reuseaddr)) { + case let s: http::server => yield s; + case let e: net::error => fmt::fatal(net::strerror(e)); + }; + defer http::server_finish(&s); + + let b = chess::mkboard(8, 8); + + let p = match (chess::mkpiece(&b, 0, 0, chess::ptype::ROOK, chess::color::WHITE)) { + case let p: *chess::piece => yield p; + case => fmt::fatal("invalid placement"); + }; + let p = match (chess::mkpiece(&b, 1, 0, chess::ptype::BISHOP, chess::color::WHITE)) { + case let p: *chess::piece => yield p; + case => fmt::fatal("invalid placement"); + }; + + for (true) { + let (req, rw) = http::serve(&s)!; + defer io::close(&rw)!; + defer http::request_parsed_finish(&req); + + if (req.method != "GET") { + http::response_set_status(&rw, http::STATUS_METHOD_NOT_ALLOWED)!; + continue; + }; + + if (req.target.path != "/") { + http::response_set_status(&rw, http::STATUS_NOT_FOUND)!; + continue; + }; + + http::response_add_header(&rw, "Content-Type", "text/html")!; + + chess::print_board(b, &rw); + chess::print_board(b, os::stdout); + }; +}; diff --git a/net/http/README b/net/http/README @@ -0,0 +1,17 @@ +net::http provides an implementation of an HTTP 1.1 client and server as defined +by RFC 9110 et al. + +TODO: Flesh me out + +Caveats: + +- No attempt is made to validate that the input for client requests or responses + are valid according to the HTTP grammar; such cases will fail when rejected by + the other party. +- Details indicated by RFC 7230 et al as "obsolete" are not implemented +- Max header length including "name: value" is 4KiB + +TODO: + +- Server stuff +- TLS diff --git a/net/http/client.ha b/net/http/client.ha @@ -0,0 +1,102 @@ +use errors; +use io; +use net::uri; + +export type client = struct { + default_header: header, + default_transport: transport, +}; + +// Creates a new HTTP [[client]] with the provided User-Agent string. +// +// The HTTP client implements a number of sane defaults, which may be tuned. The +// set of default headers is configured with [[client_default_header]], and the +// default transport behavior with [[client_default_transport]]. +// +// TODO: Implement and document the connection pool +// +// The caller must pass the client object to [[client_finish]] to free resources +// associated with this client after use. +export fn newclient(ua: str) (client | nomem) = { + let client = client { ... }; + header_add(&client, "User-Agent", ua)?; + return client; +}; + +// Frees resources associated with an HTTP [[client]]. +export fn client_finish(client: *client) void = { + header_finish(&client.default_header); +}; + +// Returns the default headers used by this HTTP client, so that the user can +// examine or modify the net::http defaults (such as User-Agent or +// Accept-Encoding), or add their own. +export fn client_default_header(client: *client) *header = { + return &client.default_header; +}; + +// Returns the default [[transport]] configuration used by this HTTP client. +export fn client_default_transport(client: *client) *transport = { + return &client.default_transport; +}; + +fn uri_origin_form(target: *uri::uri) uri::uri = { + let target = *target; + target.scheme = ""; + target.host = ""; + target.fragment = ""; + target.userinfo = ""; + target.port = 0; + if (target.path == "") { + target.path = "/"; + }; + return target; +}; + +// Performs a synchronous HTTP GET request with the given client. +export fn get( + client: *client, + target: *uri::uri +) (response | error | nomem | protoerr | io::error | errors::unsupported) = { + const req = new_request(client, GET, target)?; + defer request_finish(&req); + return do(client, &req); +}; + +// Performs a synchronous HTTP HEAD request with the given client. +export fn head( + client: *client, + target: *uri::uri +) (response | error | nomem | protoerr | io::error | errors::unsupported) = { + const req = new_request(client, HEAD, target)?; + defer request_finish(&req); + return do(client, &req); +}; + +// Performs a synchronous HTTP POST request with the given client. +// +// If the provided I/O handle is seekable, the Content-Length header is added +// automatically. Otherwise, Transfer-Encoding: chunked will be used. +export fn post( + client: *client, + target: *uri::uri, + body: io::handle, +) (response | error | nomem | protoerr | io::error | errors::unsupported) = { + const req = new_request_body(client, POST, target, body)?; + defer request_finish(&req); + return do(client, &req); +}; + +// Performs a synchronous HTTP PUT request with the given client. +// +// If the provided I/O handle is seekable, the Content-Length header is added +// automatically. Otherwise, Transfer-Encoding: chunked will be used. +export fn put( + client: *client, + target: *uri::uri, + body: io::handle, +) (response | error | nomem | protoerr | io::error | errors::unsupported) = { + const req = new_request_body(client, PUT, target, body)?; + defer request_finish(&req); + return do(client, &req); +}; diff --git a/net/http/constants.ha b/net/http/constants.ha @@ -0,0 +1,112 @@ +// HTTP "GET" method. +export def GET: str = "GET"; +// HTTP "HEAD" method. +export def HEAD: str = "HEAD"; +// HTTP "POST" method. +export def POST: str = "POST"; +// HTTP "PUT" method. +export def PUT: str = "PUT"; +// HTTP "DELETE" method. +export def DELETE: str = "DELETE"; +// HTTP "OPTIONS" method. +export def OPTIONS: str = "OPTIONS"; +// HTTP "PATCH" method. +export def PATCH: str = "PATCH"; +// HTTP "CONNECT" method. +export def CONNECT: str = "CONNECT"; + +// HTTP "Continue" response status (100). +export def STATUS_CONTINUE: uint = 100; +// HTTP "Switching Protocols" response status (101). +export def STATUS_SWITCHING_PROTOCOLS: uint = 101; + +// HTTP "OK" response status (200). +export def STATUS_OK: uint = 200; +// HTTP "Created" response status (201). +export def STATUS_CREATED: uint = 201; +// HTTP "Accepted" response status (202). +export def STATUS_ACCEPTED: uint = 202; +// HTTP "Non-authoritative Info" response status (203). +export def STATUS_NONAUTHORITATIVE_INFO: uint = 203; +// HTTP "No Content" response status (204). +export def STATUS_NO_CONTENT: uint = 204; +// HTTP "Reset Content" response status (205). +export def STATUS_RESET_CONTENT: uint = 205; +// HTTP "Partial Content" response status (206). +export def STATUS_PARTIAL_CONTENT: uint = 206; + +// HTTP "Multiple Choices" response status (300). +export def STATUS_MULTIPLE_CHOICES: uint = 300; +// HTTP "Moved Permanently" response status (301). +export def STATUS_MOVED_PERMANENTLY: uint = 301; +// HTTP "Found" response status (302). +export def STATUS_FOUND: uint = 302; +// HTTP "See Other" response status (303). +export def STATUS_SEE_OTHER: uint = 303; +// HTTP "Not Modified" response status (304). +export def STATUS_NOT_MODIFIED: uint = 304; +// HTTP "Use Proxy" response status (305). +export def STATUS_USE_PROXY: uint = 305; + +// HTTP "Temporary Redirect" response status (307). +export def STATUS_TEMPORARY_REDIRECT: uint = 307; +// HTTP "Permanent Redirect" response status (308). +export def STATUS_PERMANENT_REDIRECT: uint = 308; + +// HTTP "Bad Request" response status (400). +export def STATUS_BAD_REQUEST: uint = 400; +// HTTP "Unauthorized" response status (401). +export def STATUS_UNAUTHORIZED: uint = 401; +// HTTP "Payment Required" response status (402). +export def STATUS_PAYMENT_REQUIRED: uint = 402; +// HTTP "Forbidden" response status (403). +export def STATUS_FORBIDDEN: uint = 403; +// HTTP "Not Found" response status (404). +export def STATUS_NOT_FOUND: uint = 404; +// HTTP "Method Not Allowed" response status (405). +export def STATUS_METHOD_NOT_ALLOWED: uint = 405; +// HTTP "Not Acceptable" response status (406). +export def STATUS_NOT_ACCEPTABLE: uint = 406; +// HTTP "Proxy Authentication Required" response status (407). +export def STATUS_PROXY_AUTH_REQUIRED: uint = 407; +// HTTP "Request Timeout" response status (408). +export def STATUS_REQUEST_TIMEOUT: uint = 408; +// HTTP "Conflict" response status (409). +export def STATUS_CONFLICT: uint = 409; +// HTTP "Gone" response status (410). +export def STATUS_GONE: uint = 410; +// HTTP "Length Required" response status (411). +export def STATUS_LENGTH_REQUIRED: uint = 411; +// HTTP "Precondition Failed" response status (412). +export def STATUS_PRECONDITION_FAILED: uint = 412; +// HTTP "Request Entity Too Large" response status (413). +export def STATUS_REQUEST_ENTITY_TOO_LARGE: uint = 413; +// HTTP "Request URI Too Long" response status (414). +export def STATUS_REQUEST_URI_TOO_LONG: uint = 414; +// HTTP "Unsupported Media Type" response status (415). +export def STATUS_UNSUPPORTED_MEDIA_TYPE: uint = 415; +// HTTP "Requested Range Not Satisfiable" response status (416). +export def STATUS_REQUESTED_RANGE_NOT_SATISFIABLE: uint = 416; +// HTTP "Expectation Failed" response status (417). +export def STATUS_EXPECTATION_FAILED: uint = 417; +// HTTP "I'm a Teapot" response status (418). +export def STATUS_TEAPOT: uint = 418; +// HTTP "Misdirected Request" response status (421). +export def STATUS_MISDIRECTED_REQUEST: uint = 421; +// HTTP "Unprocessable Entity" response status (422). +export def STATUS_UNPROCESSABLE_ENTITY: uint = 422; +// HTTP "Upgrade Required" response status (426). +export def STATUS_UPGRADE_REQUIRED: uint = 426; + +// HTTP "Internal Server Error" response status (500). +export def STATUS_INTERNAL_SERVER_ERROR: uint = 500; +// HTTP "Not Implemented" response status (501). +export def STATUS_NOT_IMPLEMENTED: uint = 501; +// HTTP "Bad Gateway" response status (502). +export def STATUS_BAD_GATEWAY: uint = 502; +// HTTP "Service Unavailable" response status (503). +export def STATUS_SERVICE_UNAVAILABLE: uint = 503; +// HTTP "Gateway Timeout" response status (504). +export def STATUS_GATEWAY_TIMEOUT: uint = 504; +// HTTP "HTTP Version Not Supported" response status (505). +export def STATUS_HTTP_VERSION_NOT_SUPPORTED: uint = 505; diff --git a/net/http/do.ha b/net/http/do.ha @@ -0,0 +1,148 @@ +use bufio; +use encoding::utf8; +use errors; +use fmt; +use io; +use net::dial; +use net::uri; +use net; +use os; +use strconv; +use strings; +use types; + +// Performs an HTTP [[request]] with the given [[client]]. The request is +// performed synchronously; this function blocks until the server has returned +// the response status and all HTTP headers associated with the response. +// +// If the provided [[response]] has a non-null body, the user must pass it to +// [[io::close]] before calling [[response_finish]]. +export fn do( + client: *client, + req: *request) +(response | error | nomem | protoerr | io::error | errors::unsupported) = { + assert(req.target.scheme == "http"); // TODO: https + + if (req.body is *io::stream && req.body as *io::stream == io::empty) { + header_del(&req.header, "Transfer-Encoding"); + header_add(&req.header, "Content-Length", "0")?; + }; + + const conn = dial::dial_uri("tcp", req.target)?; + + let buf: [os::BUFSZ]u8 = [0...]; + let file = bufio::init(conn, [], buf); + bufio::setflush(&file, []); + + request_write(&file, req, client)?; + + bufio::flush(&file)?; + + const trans = match (req.transport) { + case let t: *transport => + yield t; + case => + yield &client.default_transport; + }; + // TODO: Implement None + assert(trans.request_transport == transport_mode::AUTO); + assert(trans.response_transport == transport_mode::AUTO); + assert(trans.request_content == content_mode::AUTO); + assert(trans.response_content == content_mode::AUTO); + + io::copy(conn, req.body)?; + + let resp = response { + body = io::empty, + ... + }; + const scan = bufio::newscanner(conn, 512); + defer bufio::finish(&scan); + read_statusline(&resp, &scan)?; + match (read_header(&resp.header, &scan)?) { + case void => void; + case io::EOF => return protoerr; + }; + + const response_complete = + req.method == "HEAD" || + resp.status == STATUS_NO_CONTENT || + resp.status == STATUS_NOT_MODIFIED || + (resp.status >= 100 && resp.status < 200) || + (req.method == "CONNECT" && resp.status >= 200 && resp.status < 300); + if (!response_complete) { + resp.body = new_reader(conn, &resp.header, &scan)?; + } else if (req.method != "CONNECT") { + io::close(conn)!; + }; + return resp; +}; + +fn read_statusline( + resp: *response, + scan: *bufio::scanner, +) (void | error | nomem) = { + const status = match (bufio::scan_string(scan, "\r\n")) { + case let line: const str => + yield line; + case let err: io::error => + return err; + case utf8::invalid => + return protoerr; + case io::EOF => + return protoerr; + }; + + const tok = strings::tokenize(status, " "); + + const version = match (strings::next_token(&tok)) { + case let ver: str => + yield ver; + case done => + return protoerr; + }; + + const status = match (strings::next_token(&tok)) { + case let status: str => + yield status; + case done => + return protoerr; + }; + + const reason = match (strings::next_token(&tok)) { + case let reason: str => + yield reason; + case done => + return protoerr; + }; + + const (_, version) = strings::cut(version, "/"); + const (major, minor) = strings::cut(version, "."); + + const major = match (strconv::stou(major)) { + case let u: uint => + yield u; + case => + return protoerr; + }; + const minor = match (strconv::stou(minor)) { + case let u: uint => + yield u; + case => + return protoerr; + }; + resp.version = (major, minor); + + if (resp.version.0 > 1) { + return errors::unsupported; + }; + + resp.status = match (strconv::stou(status)) { + case let u: uint => + yield u; + case => + return protoerr; + }; + + resp.reason = strings::dup(reason)?; +}; diff --git a/net/http/error.ha b/net/http/error.ha @@ -0,0 +1,27 @@ +use errors; +use io; +use net::dial; +use net; + +// Errors possible while servicing HTTP requests. Note that these errors are for +// errors related to the processing of the HTTP connection; semantic HTTP errors +// such as [[STATUS_NOT_FOUND]] are not handled by this type. +export type error = !(dial::error | io::error | net::error | errors::unsupported | protoerr); + +// An HTTP protocol error occurred, indicating that the remote party is not +// conformant with HTTP semantics. +export type protoerr = !void; + +// Converts an [[error]] to a string. +export fn strerror(err: error) const str = { + match (err) { + case let err: dial::error => + return dial::strerror(err); + case let err: io::error => + return io::strerror(err); + case errors::unsupported => + return "Unsupported HTTP feature"; + case protoerr => + return "HTTP protocol error"; + }; +}; diff --git a/net/http/header.ha b/net/http/header.ha @@ -0,0 +1,108 @@ +use bufio; +use encoding::utf8; +use fmt; +use io; +use strings; + +// List of HTTP headers. +// TODO: [](str, []str) +export type header = [](str, str); + +// Adds a given HTTP header, which may be added more than once. The name should +// be canonicalized by the caller. +export fn header_add(head: *header, name: str, val: str) (void | nomem) = { + assert(len(name) >= 1 && len(val) >= 1); + append(head, (strings::dup(name)?, strings::dup(val)?))?; +}; + +// Sets the value of a given HTTP header, removing any previous values. The name +// should be canonicalized by the caller. +export fn header_set(head: *header, name: str, val: str) (void | nomem) = { + header_del(head, name); + header_add(head, name, val)?; +}; + +// Removes an HTTP header from a list of [[header]]. If multiple headers match +// the given name, all matching headers are removed. +export fn header_del(head: *header, name: str) void = { + for (let i = 0z; i < len(head); i += 1) { + if (head[i].0 == name) { + free(head[i].0); + free(head[i].1); + delete(head[i]); + i -= 1; + }; + }; +}; + +// Retrieves a value, or values, from a header. An empty string indicates the +// absence of a header. +export fn header_get(head: *header, name: str) str = { + for (let i = 0z; i < len(head); i += 1) { + const (key, val) = head[i]; + if (key == name) { + return val; + }; + }; + return ""; +}; + +// Finish state associated with an HTTP [[header]]. +export fn header_finish(head: *header) void = { + for (let i = 0z; i < len(head); i += 1) { + free(head[i].0); + free(head[i].1); + }; + free(*head); +}; + +// Duplicates a set of HTTP headers. +export fn header_dup(head: *header) (header | nomem) = { + let new: header = []; + for (let i = 0z; i < len(head); i += 1) { + const (key, val) = head[i]; + header_add(&new, key, val)?; + }; + return new; +}; + +// Writes a list of HTTP headers to the provided I/O handle in the HTTP wire +// format. +export fn write_header(sink: io::handle, head: *header) (size | io::error) = { + let z = 0z; + for (let i = 0z; i < len(head); i += 1) { + const (name, val) = head[i]; + z += fmt::fprintf(sink, "{}: {}\r\n", name, val)?; + }; + return z; +}; + +fn read_header( + head: *header, + scan: *bufio::scanner +) (void | io::EOF | io::error | protoerr | nomem) = { + for (true) { + const item = match (bufio::scan_string(scan, "\r\n")) { + case let line: const str => + yield line; + case io::EOF => + return io::EOF; + case let err: io::error => + return err; + case utf8::invalid => + return protoerr; + }; + if (item == "") { + break; + }; + + let (name, val) = strings::cut(item, ":"); + val = strings::trim(val); + if (val == "") { + return protoerr; + }; + // TODO: validate field-name + + header_add(head, name, val)?; + }; +}; diff --git a/net/http/request.ha b/net/http/request.ha @@ -0,0 +1,332 @@ +use bufio; +use encoding::utf8; +use errors; +use fmt; +use io; +use memio; +use net::ip; +use net::uri; +use strconv; +use strings; +use types; + +// Stores state related to an HTTP request. +// +// For a request to be processable by an HTTP [[client]], i.e. via [[do]], the +// method and target must be filled in appropriately. The target must include at +// least a host, port, and scheme. The default values for other fields are +// suitable if appropriate for the request you wish to perform. +export type request = struct { + // HTTP request method, e.g. GET + method: str, + + // Request target URI. + // + // Note that the normal constraints for [[uri::parse]] are not upheld in + // the case of a request using the origin-form (e.g. /index.html), i.e. + // the scheme field may be empty. + target: *uri::uri, + + // List of HTTP request headers. + header: header, + + // Transport configuration, or null to use the [[client]] default. + transport: nullable *transport, + + // I/O reader for the request body, or [[io::empty]] if there is no + // body. + body: io::handle, +}; + +// Frees state associated with an HTTP [[request]]. +export fn request_finish(req: *request) void = { + header_finish(&req.header); + uri::finish(req.target); + io::close(req.body)!; + free(req.target); +}; + +// Creates a new HTTP [[request]] using the given HTTP [[client]] defaults. +export fn new_request( + client: *client, + method: str, + target: *uri::uri, +) (request | errors::unsupported | nomem) = { + let req = request { + method = method, + target = alloc(uri::dup(target)?)?, + header = header_dup(&client.default_header)?, + transport = null, + body = io::empty, + }; + switch (req.target.scheme) { + case "http" => + if (req.target.port == 0) { + req.target.port = 80; + }; + case "https" => + if (req.target.port == 0) { + req.target.port = 443; + }; + case "ws" => + if (req.target.port == 0) { + req.target.port = 80; + }; + case "wss" => + if (req.target.port == 0) { + req.target.port = 443; + }; + case => + return errors::unsupported; + }; + + let host = match (req.target.host) { + case let host: str => + yield host; + case let ip: ip::addr4 => + yield ip::string(ip); + case let ip: ip::addr6 => + static let buf: [64 + 2]u8 = [0...]; + yield fmt::bsprintf(buf, "[{}]", ip::string(ip))?; + }; + + if (req.target.scheme == "http" && req.target.port != 80) { + host = fmt::asprintf("{}:{}", host, req.target.port)?; + } else if (target.scheme == "https" && target.port != 443) { + host = fmt::asprintf("{}:{}", host, req.target.port)?; + } else { + host = strings::dup(host)?; + }; + defer free(host); + header_add(&req.header, "Host", host)?; + return req; +}; + +// Creates a new HTTP [[request]] using the given HTTP [[client]] defaults and +// the provided request body. +// +// If the provided I/O handle is seekable, the Content-Length header is added +// automatically. Otherwise, Transfer-Encoding: chunked will be used. +export fn new_request_body( + client: *client, + method: str, + target: *uri::uri, + body: io::handle, +) (request | errors::unsupported | nomem) = { + let req = new_request(client, method, target)?; + req.body = body; + + const offs = match (io::seek(body, 0, io::whence::CUR)) { + case let off: io::off => + yield off; + case io::error => + header_add(&req.header, "Transfer-Encoding", "chunked")?; + return req; + }; + const ln = io::seek(body, 0, io::whence::END)!; + io::seek(body, offs, io::whence::SET)!; + header_add(&req.header, "Content-Length", strconv::ztos(ln: size))?; + return req; +}; + +export type request_server = void; +export type authority = void; + +export type request_uri = ( + request_server | + authority | + *uri::uri | + str | +); + +export type request_line = struct { + method: str, + uri: request_uri, + version: version, +}; + +// Parse a [[request]] from an [[io::handle]]. +export fn request_parse( + file: io::handle +) (request | protoerr | io::error | nomem | errors::unsupported) = { + let ok = false; + + const scan = bufio::newscanner(file, types::SIZE_MAX); + defer bufio::finish(&scan); + + const req_line = request_line_parse(&scan)?; + defer request_line_finish(&req_line); + + let header: header = []; + defer if (!ok) header_finish(&header); + read_header(&header, &scan)?; + + const target = match (req_line.uri) { + case let uri: request_server => + return errors::unsupported; + case let uri: authority => + return errors::unsupported; + case let uri: *uri::uri => + yield &uri::dup(uri)?; + case let path: str => + const uri = fmt::asprintf("http://{}", path)?; + defer free(uri); + + const uri = match (uri::parse(uri)) { + case let uri: uri::uri => + yield uri; + case uri::invalid => + return protoerr; + }; + yield alloc(uri)?; + }; + defer if (!ok) free(target); + + const body = match (new_reader(file, &header, &scan)) { + case let body: io::handle => yield body; + case let err: errors::unsupported => return err; + case let err: protoerr => return err; + case nomem => return nomem; + }; + defer if (!ok) io::close(body)!; + + const host = header_get(&header, "Host"); + if (host != "") { + target.host = strings::dup(host)?; + }; + + const method = strings::dup(req_line.method)?; + defer if (!ok) free(method); + + ok = true; + return request { + method = method, + target = target, + header = header, + body = body, + ... + }; +}; + +fn request_line_parse( + scan: *bufio::scanner +) (request_line | protoerr | io::error | errors::unsupported) = { + const line = match (bufio::scan_string(scan, "\r\n")) { + case let line: const str => + yield line; + case let err: io::error => + return err; + case (utf8::invalid | io::EOF) => + return protoerr; + }; + + const tok = strings::tokenize(line, " "); + + const method = match (strings::next_token(&tok)) { + case let method: str => + yield strings::dup(method)?; + case done => + return protoerr; + }; + + const uri: request_uri = match (strings::next_token(&tok)) { + case let req_uri: str => + if ("*" == req_uri) { + yield request_server; + }; + + yield match (uri::parse(req_uri)) { + case let uri: uri::uri => yield alloc(uri)?; + case => yield strings::dup(req_uri)?; // as path + }; + case done => + return protoerr; + }; + + const version = match (strings::next_token(&tok)) { + case let ver: str => + yield ver; + case done => + return protoerr; + }; + + const (_, version) = strings::cut(version, "/"); + const (major, minor) = strings::cut(version, "."); + + const major = match (strconv::stou(major)) { + case let u: uint => + yield u; + case => + return protoerr; + }; + const minor = match (strconv::stou(minor)) { + case let u: uint => + yield u; + case => + return protoerr; + }; + + if (major > 1) { + return errors::unsupported; + }; + + if (uri is request_server && method != OPTIONS) { + return protoerr; + }; + + return request_line { + method = method, + uri = uri, + version = (major, minor), + }; +}; + +fn request_line_finish(line: *request_line) void = { + match (line.uri) { + case let path: str => free(path); + case let uri: *uri::uri => uri::finish(uri); + case => yield; + }; + free(line.method); +}; + +export fn request_parsed_finish(req: *request) void = { + free(req.method); + uri::finish(req.target); + free(req.target); + header_finish(&req.header); + + // Ignore double-close errors, in case the user closed it + io::close(req.body): void; +}; + +// Formats an HTTP [[request]] and writes it to the given [[io::handle]]. +export fn request_write( + out: io::handle, + req: *request, + cli: *client, +) (void | io::error) = { + fmt::fprintf(out, "{} ", req.method)?; + + // TODO: Support other request-targets than origin-form + const target = uri_origin_form(req.target); + uri::fmt(out, &target)?; + fmt::fprintf(out, " HTTP/1.1\r\n")?; + + write_header(out, &req.header)?; + fmt::fprintf(out, "\r\n")?; + + const trans = match (req.transport) { + case let t: *transport => + yield t; + case => + yield &cli.default_transport; + }; + // TODO: Implement None + assert(trans.request_transport == transport_mode::AUTO); + assert(trans.response_transport == transport_mode::AUTO); + assert(trans.request_content == content_mode::AUTO); + assert(trans.response_content == content_mode::AUTO); + + io::copy(out, req.body)?; +}; diff --git a/net/http/response.ha b/net/http/response.ha @@ -0,0 +1,176 @@ +use bufio; +use encoding::utf8; +use errors; +use fmt; +use io; +use os; +use strconv; +use strings; +use types; + +// HTTP protocol version (major, minor) +export type version = (uint, uint); + +// Stores state related to an HTTP response. +export type response = struct { + // HTTP protocol version (major, minor) + version: version, + // The HTTP status for this request as an integer. + status: uint, + // The HTTP status reason phrase. + reason: str, + // The HTTP headers provided by the server. + header: header, + // The response body, if any, or [[io::empty]]. + body: io::handle, +}; + +// Frees state associated with an HTTP [[response]] and closes the response +// body. +export fn response_finish(resp: *response) void = { + // Ignore errors in case the caller closed it themselves + io::close(resp.body): void; + header_finish(&resp.header); + free(resp.reason); +}; + +// Parse a [[response]] from an [[io::handle]]. Return [[protoerr]] if the +// HTTP message is incomplete. +export fn response_parse( + file: io::handle +) (response | protoerr | io::error | nomem | errors::unsupported) = { + let ok = false; + + const scan = bufio::newscanner(file, types::SIZE_MAX); + defer bufio::finish(&scan); + + const resp_line = response_line_parse(&scan)?; + defer response_line_finish(&resp_line); + + let header: header = []; + defer if (!ok) header_finish(&header); + read_header(&header, &scan)?; + + const body = match (new_reader(file, &header, &scan)) { + case let body: io::handle => yield body; + case let err: errors::unsupported => return err; + case let err: protoerr => return err; + case nomem => return nomem; + }; + defer if (!ok) io::close(body)!; + + const reason = strings::dup(resp_line.reason)?; + defer if (!ok) free(reason); + + ok = true; + return response { + version = resp_line.version, + status = resp_line.status, + reason = reason, + header = header, + body = body, + }; +}; + +// HTTP status line version, status, and reason +export type response_line = struct { + version: version, + status: uint, + reason: str, +}; + +fn response_line_parse( + scan: *bufio::scanner +) (response_line | protoerr | io::error | errors::unsupported) = { + const status = match (bufio::scan_string(scan, "\r\n")) { + case let line: const str => + yield line; + case let err: io::error => + return err; + case utf8::invalid => + return protoerr; + }; + + const tok = strings::tokenize(status, " "); + + const version = match (strings::next_token(&tok)) { + case let ver: str => + yield ver; + case done => + return protoerr; + }; + + const status = match (strings::next_token(&tok)) { + case let status: str => + yield status; + case done => + return protoerr; + }; + + const reason = match (strings::next_token(&tok)) { + case let reason: str => + yield reason; + case done => + return protoerr; + }; + + const (_, version) = strings::cut(version, "/"); + const (major, minor) = strings::cut(version, "."); + + const major = match (strconv::stou(major)) { + case let u: uint => + yield u; + case => + return protoerr; + }; + const minor = match (strconv::stou(minor)) { + case let u: uint => + yield u; + case => + return protoerr; + }; + + if (major > 1) { + return errors::unsupported; + }; + + const status = match (strconv::stou(status)) { + case let u: uint => + yield u; + case => + return protoerr; + }; + + const reason = strings::dup(reason)?; + + return response_line { + version = (major, minor), + status = status, + reason = reason, + }; +}; + +fn response_line_finish(line: *response_line) void = { + free(line.reason); +}; + +export fn response_parsed_finish(resp: *response) void = { + header_finish(&resp.header); + io::close(resp.body)!; + free(resp.reason); +}; + +// Formats an HTTP [[response]] and writes it to the given [[io::handle]]. +export fn response_write( + out: io::handle, + resp: *response, +) (void | io::error | errors::unsupported) = { + fmt::fprintf(out, "HTTP/{}.{} {} {}\r\n", + resp.version.0, resp.version.1, + resp.status, resp.reason)?; + + write_header(out, &resp.header)?; + fmt::fprint(out, "\r\n")?; + + io::copy(out, resp.body)?; +}; diff --git a/net/http/server.ha b/net/http/server.ha @@ -0,0 +1,283 @@ +use errors; +use fmt; +use io; +use net::ip; +use net::tcp; +use net::uri; +use net; +use strconv; +use strings; + +export type server = struct { + sock: net::socket, +}; + +export type response_writer = struct { + stream: io::stream, + peerer: *peerer, + closer: nullable *closer, + sink: io::handle, + wrote_header: bool, + + status: uint, + reason: str, + header: header, +}; + +export const response_writer_vt: io::vtable = io::vtable { + writer = &response_writer_write, + closer = &response_writer_close, + copier = &response_writer_copy, + ... +}; + +fn response_write_header(rw: *response_writer) (void | io::error) = { + fmt::fprintf(rw.sink, "HTTP/1.1 {} {}\r\n", rw.status, rw.reason)?; + write_header(rw.sink, &rw.header)?; + fmt::fprint(rw.sink, "\r\n")?; + rw.wrote_header = true; +}; + +fn response_writer_write(s: *io::stream, buf: []u8) (size | io::error) = { + let rw = s: *response_writer; + + const cl = header_get(&rw.header, "Content-Length"); + const te = header_get(&rw.header, "Transfer-Encoding"); + + if (!rw.wrote_header) { + if (cl == "" && te == "") { + response_add_header(rw, "Transfer-Encoding", "chunked")!; + }; + + assert(te != "chunked" || cl != ""); + response_write_header(rw)?; + te = "chunked"; + }; + + if (len(buf) == 0) { + return 0z; + }; + + if (te == "chunked") { + return write_chunk(rw.sink, buf); + } else if (cl != "") { + return io::write(rw.sink, buf); + } else { + abort("Unsupported Transport-Encoding"); + }; +}; + +fn response_writer_close(s: *io::stream) (void | io::error) = { + let rw = s: *response_writer; + if (rw.wrote_header) { + const te = header_get(&rw.header, "Transfer-Encoding"); + if (te == "chunked") { + write_chunk(rw.sink, [])?; + }; + } else { + // Send an empty response body + response_del_header(rw, "Transfer-Encoding"); + response_set_header(rw, "Content-Length", "0")?; + response_write_header(rw)?; + }; + header_finish(&rw.header); + free(rw.reason); + close(rw); +}; + +fn response_writer_copy( + rw: *io::stream, + from: io::handle, +) (size | io::error) = { + let rw = rw: *response_writer; + + // If the caller added a Content-Length just copy the body directly to + // the socket (ideally with sendfile). + if (response_get_header(rw, "Content-Length") != "") { + if (!rw.wrote_header) { + response_write_header(rw)?; + }; + return io::copy(rw.sink, from); + }; + // If the caller added Transfer-Encoding, fall back to a read/write loop + // in io::copy. + if (response_get_header(rw, "Transfer-Encoding") != "") { + return errors::unsupported; + }; + // If this isn't the first write, fall back to read/write loop in + // io::copy. + if (rw.wrote_header) { + return errors::unsupported; + }; + + // Otherwise, try to determine the length and set the Content-Length + // accordingly. + const off = io::tell(from)?; + const end = io::seek(from, 0, io::whence::END)!; + const length = end - off; + io::seek(from, off, io::whence::SET)?; + + response_set_header(rw, "Content-Length", strconv::i64tos(length))?; + response_write_header(rw)?; + return io::copy(rw.sink, from); +}; + +// Creates a [[server]] which listens for HTTP traffic on the given IP address +// and port, binding the socket as appropriate. +export fn listen( + ip: ip::addr, + port: u16, + options: net::tcp::listen_option... +) (server | net::error) = { + return server { + sock = tcp::listen(ip, port, options...)?, + }; +}; + +// Frees resources associated with a [[server]] and closes its socket. +export fn server_finish(serv: *server) void = { + net::close(serv.sock)!; +}; + +// Listens for an incoming request on a [[server]], blocking until a request is +// available and returning a ([[request]], [[response_writer]]) tuple. +// +// To write the HTTP response, the caller should perform the following steps, in +// order: +// +// - Configure headers, if necessary, via [[response_add_header]] et al +// - Set the response status (if not 200 OK) via [[response_set_status]] +// - Write the response body via [[io::write]] et al, if necessary +// - Close the response writer via [[io::close]] +// +// [[response_writer]] is an [[io::stream]] which the caller may write to using +// [[io::write]] or any standard Hare I/O functions. +// +// Upon the first write to the [[response_writer]], the headers are consulted to +// determine the response format. If the caller has not added a Content-Length +// nor Transfer-Encoding header to the response, Transfer-Encoding: chunked is +// assumed. +// +// It is recommended to use [[io::copy]] to write the response body, if +// possible. The [[response_writer]] implementation of [[io::copier]] tests if +// the "from" argument is seekable, and if so will prepare an appropriate +// Content-Length header for you. +// +// Changing the headers or status code after writing to the response body is not +// permitted, and will cause an assertion failure. +// +// The caller MUST call [[io::close]] on the response writer before calling +// [[serve]] again. +export fn serve( + serv: *server +) ((request, response_writer) | error | nomem) = { + // TODO: connection pooling + for (true) { + const sock = net::accept(serv.sock)?; + const req = match (request_parse(sock)) { + case let req: request => + yield req; + case (protoerr | io::error) => + io::close(sock)!; + continue; + case nomem => + return nomem; + }; + + const rw = response_writer { + stream = &response_writer_vt, + peerer = &tcp_peeraddr, + closer = &tcp_closer, + wrote_header = false, + sink = sock, + status = STATUS_OK, + reason = strings::dup(status_reason(STATUS_OK))?, + header = [], + }; + return (req, rw); + }; + +}; + +export type peerer = fn(rw: *response_writer) (ip::addr, u16); + +// Returns the remote peer address associated with this connection. +export fn peeraddr(rw: *response_writer) (ip::addr, u16) = { + return rw.peerer(rw); +}; + +fn tcp_peeraddr(rw: *response_writer) (ip::addr, u16) = { + return tcp::peeraddr(rw.sink: net::socket) as (ip::addr, u16); +}; + +export type closer = fn(rw: *response_writer) void; + +fn close(rw: *response_writer) void = { + if (rw.closer is *closer) { + (rw.closer as *closer)(rw); + }; +}; + +fn tcp_closer(rw: *response_writer) void = { + io::close(rw.sink)!; +}; + +// Sets the response status for an [[http::response_writer]]. This may be called +// any number of times until the first write to the response body. If you don't +// provide a status reason, it will be filled in for you via [[status_reason]]. +// +// [[net::http]] provides constants for standard status codes for your +// convenience, such as [[STATUS_OK]]. +export fn response_set_status( + rw: *response_writer, + status: uint, + reason: str = "", +) (void | nomem) = { + assert(!rw.wrote_header, "Modified response status after writing body"); + free(rw.reason); + rw.status = status; + if (reason == "") { + reason = status_reason(status); + }; + rw.reason = strings::dup(reason)?; +}; + +// Adds a header to a response. This may be called any number of times until the +// first write to the response body. See [[header_add]]. +export fn response_add_header( + rw: *response_writer, + name: str, + val: str, +) (void | nomem) = { + assert(!rw.wrote_header, "Modified response header after writing body"); + return header_add(&rw.header, name, val); +}; + +// Sets the value of a response header, replacing any previous value(s). This +// may be called any number of times until the first write to the response body. +// See [[header_set]]. +export fn response_set_header( + rw: *response_writer, + name: str, + val: str, +) (void | nomem) = { + assert(!rw.wrote_header, "Modified response header after writing body"); + return header_set(&rw.header, name, val); +}; + +// Removes a response header. This may be called any number of times until the +// first write to the response body. See [[header_del]]. +export fn response_del_header(rw: *response_writer, name: str) void = { + assert(!rw.wrote_header, "Modified response header after writing body"); + header_del(&rw.header, name); +}; + +// Gets the value of a response header. See [[header_get]]. +export fn response_get_header(rw: *response_writer, name: str) const str = { + return header_get(&rw.header, name); +}; + +// Copies the response headers for a [[response_writer]] to a new [[header]]. +export fn response_dup_header(rw: *response_writer) (header | nomem) = { + return header_dup(&rw.header); +}; diff --git a/net/http/status.ha b/net/http/status.ha @@ -0,0 +1,111 @@ +// A semantic HTTP error and its status code. +export type httperror = !uint; + +// Checks if an HTTP status code is semantically considered an error, returning +// [[httperror]] if so, or otherwise returning the original status code. +export fn check(status: uint) (uint | httperror) = { + if (status >= 400 && status < 600) { + return status: httperror; + }; + return status; +}; + +// Converts a standard HTTP status code into the reason text typically +// associated with this status code (or "Unknown Status" if the status code is +// not known to net::http). +export fn status_reason(status: uint) const str = { + switch (status) { + case STATUS_CONTINUE => + return "Continue"; + case STATUS_SWITCHING_PROTOCOLS => + return "Switching Protocols"; + case STATUS_OK => + return "OK"; + case STATUS_CREATED => + return "Created"; + case STATUS_ACCEPTED => + return "Accepted"; + case STATUS_NONAUTHORITATIVE_INFO => + return "Non-Authoritative Information"; + case STATUS_NO_CONTENT => + return "No Content"; + case STATUS_RESET_CONTENT => + return "Reset Content"; + case STATUS_PARTIAL_CONTENT => + return "Partial Content"; + case STATUS_MULTIPLE_CHOICES => + return "Multiple Choices"; + case STATUS_MOVED_PERMANENTLY => + return "Moved Permanently"; + case STATUS_FOUND => + return "Found"; + case STATUS_SEE_OTHER => + return "See Other"; + case STATUS_NOT_MODIFIED => + return "Not Modified"; + case STATUS_USE_PROXY => + return "Use Proxy"; + case STATUS_TEMPORARY_REDIRECT => + return "Temporary Redirect"; + case STATUS_PERMANENT_REDIRECT => + return "Permanent Redirect"; + case STATUS_BAD_REQUEST => + return "Bad Request"; + case STATUS_UNAUTHORIZED => + return "Unauthorized"; + case STATUS_PAYMENT_REQUIRED => + return "Payment Required"; + case STATUS_FORBIDDEN => + return "Forbidden"; + case STATUS_NOT_FOUND => + return "Not Found"; + case STATUS_METHOD_NOT_ALLOWED => + return "Method Not Allowed"; + case STATUS_NOT_ACCEPTABLE => + return "Not Acceptable"; + case STATUS_PROXY_AUTH_REQUIRED => + return "Proxy Authentication Required"; + case STATUS_REQUEST_TIMEOUT => + return "Request Timeout"; + case STATUS_CONFLICT => + return "Conflict"; + case STATUS_GONE => + return "Gone"; + case STATUS_LENGTH_REQUIRED => + return "Length Required"; + case STATUS_PRECONDITION_FAILED => + return "Precondition Failed"; + case STATUS_REQUEST_ENTITY_TOO_LARGE => + return "Request Entity Too Large"; + case STATUS_REQUEST_URI_TOO_LONG => + return "Request URI Too Long"; + case STATUS_UNSUPPORTED_MEDIA_TYPE => + return "Unsupported Media Type"; + case STATUS_REQUESTED_RANGE_NOT_SATISFIABLE => + return "Requested Range Not Satisfiable"; + case STATUS_EXPECTATION_FAILED => + return "Expectation Failed"; + case STATUS_TEAPOT => + return "I'm A Teapot"; + case STATUS_MISDIRECTED_REQUEST => + return "Misdirected Request"; + case STATUS_UNPROCESSABLE_ENTITY => + return "Unprocessable Entity"; + case STATUS_UPGRADE_REQUIRED => + return "Upgrade Required"; + case STATUS_INTERNAL_SERVER_ERROR => + return "Internal Server Error"; + case STATUS_NOT_IMPLEMENTED => + return "Not Implemented"; + case STATUS_BAD_GATEWAY => + return "Bad Gateway"; + case STATUS_SERVICE_UNAVAILABLE => + return "Service Unavailable"; + case STATUS_GATEWAY_TIMEOUT => + return "Gateway Timeout"; + case STATUS_HTTP_VERSION_NOT_SUPPORTED => + return "HTTP Version Not Supported"; + case => + return "Unknown status"; + }; +}; diff --git a/net/http/transport.ha b/net/http/transport.ha @@ -0,0 +1,330 @@ +use bufio; +use bytes; +use errors; +use fmt; +use io; +use os; +use strconv; +use strings; +use types; + +// Configures the Transport-Encoding behavior. +// +// If set to NONE, no transport decoding or encoding is performed on the message +// body, irrespective of the value of the Transport-Encoding header. The user +// must perform any required encoding or decoding themselves in this mode. If +// set to AUTO, the implementation will examine the Transport-Encoding header +// and encode the message body appropriately. +// +// Most users will want this to be set to auto. +export type transport_mode = enum { + AUTO = 0, + NONE, +}; + +// Configures the Content-Encoding behavior. +// +// If set to NONE, no transport decoding or encoding is performed on the message +// body, irrespective of the value of the Content-Encoding header. The user must +// perform any required encoding or decoding themselves in this mode. If set to +// AUTO, the implementation will examine the Content-Encoding header and encode +// the message body appropriately. +// +// Most users will want this to be set to AUTO. +export type content_mode = enum { + AUTO = 0, + NONE, +}; + +// Describes an HTTP [[client]]'s transport configuration for a given request. +// +// The default value of this type sets all parameters to "auto". +export type transport = struct { + // Desired Transport-Encoding configuration, see [[transport_mode]] for + // details. + request_transport: transport_mode, + response_transport: transport_mode, + // Desired Content-Encoding configuration, see [[content_mode]] for + // details. + request_content: content_mode, + response_content: content_mode, +}; + +fn new_reader( + conn: io::handle, + header: *header, + scan: *bufio::scanner, +) (io::handle | errors::unsupported | protoerr | nomem) = { + // TODO: Content-Encoding support + const cl = header_get(header, "Content-Length"); + const te = header_get(header, "Transfer-Encoding"); + + if (cl != "" || te == "") { + if (cl == "") { + return io::empty; + }; + const length = match (strconv::stoz(cl)) { + case let z: size => + yield z; + case => + return protoerr; + }; + return new_identity_reader(conn, scan, length)?; + }; + + // TODO: Figure out the semantics for closing the stream + // The caller should probably be required to close it + // It should close/free any intermediate transport/content decoders + // And it should not close the actual connection if it's still in the + // connection pool + // Unless it isn't in the pool, then it should! + // And this leaks, fix that too + let stream: io::handle = conn; + let buffer: []u8 = bufio::scan_buffer(scan); + const iter = strings::tokenize(te, ","); + for (true) { + const te = match (strings::next_token(&iter)) { + case let tok: str => + yield strings::trim(tok); + case done => + break; + }; + + // XXX: We could add lzw support if someone added it to + // hare-compress + const next = switch (te) { + case "chunked" => + yield new_chunked_reader(stream, buffer)?; + case "deflate" => + abort(); // TODO + case "gzip" => + abort(); // TODO + case => + return errors::unsupported; + }; + stream = next; + + buffer = []; + }; + + if (!(stream is *io::stream)) { + // Empty Transfer-Encoding header + return protoerr; + }; + return stream; +}; + +type identity_reader = struct { + vtable: io::stream, + conn: io::handle, + scan: *bufio::scanner, + src: io::limitstream, +}; + +const identity_reader_vtable = io::vtable { + reader = &identity_read, + closer = &identity_close, + ... +}; + +// Creates a new reader that reads data until the response's Content-Length is +// reached; i.e. the null Transport-Encoding. +fn new_identity_reader( + conn: io::handle, + scan: *bufio::scanner, + content_length: size, +) (*io::stream | nomem) = { + const nscan = alloc(bufio::scanner { + stream = scan.stream, + src = scan.src, + buffer = [], + maxread = scan.maxread, + start = scan.start, + pending = [], + opts = scan.opts, + })?; + append(nscan.buffer, scan.buffer...)?; + nscan.pending = nscan.buffer[nscan.start..]; + return alloc(identity_reader { + vtable = &identity_reader_vtable, + conn = conn, + scan = nscan, + src = io::limitreader(nscan, content_length), + ... + })?; +}; + +fn identity_read( + s: *io::stream, + buf: []u8, +) (size | io::EOF | io::error) = { + let rd = s: *identity_reader; + assert(rd.vtable == &identity_reader_vtable); + return io::read(&rd.src, buf)?; +}; + +fn identity_close(s: *io::stream) (void | io::error) = { + let rd = s: *identity_reader; + assert(rd.vtable == &identity_reader_vtable); + + // Flush the remainder of the response in case the caller did not read + // it out entirely + io::copy(io::empty, &rd.src)?; + + io::close(&rd.src)!; + bufio::finish(rd.scan); + free(rd.scan); + + // TODO connection pool + free(rd); +}; + +type chunk_state = enum { + HEADER, + DATA, + FOOTER, +}; + +type chunked_reader = struct { + vtable: io::stream, + conn: io::handle, + buffer: [os::BUFSZ]u8, + state: chunk_state, + // Amount of read-ahead data in buffer + pending: size, + // Length of current chunk + length: size, +}; + +fn new_chunked_reader( + conn: io::handle, + buffer: []u8, +) (*io::stream | nomem) = { + let rd = alloc(chunked_reader { + vtable = &chunked_reader_vtable, + conn = conn, + ... + })?; + rd.buffer[..len(buffer)] = buffer[..]; + rd.pending = len(buffer); + return rd; +}; + +const chunked_reader_vtable = io::vtable { + reader = &chunked_read, + closer = &chunked_close, + ... +}; + +fn chunked_read( + s: *io::stream, + buf: []u8, +) (size | io::EOF | io::error) = { + // XXX: I am not satisfied with this code + let rd = s: *chunked_reader; + assert(rd.vtable == &chunked_reader_vtable); + + for (true) switch (rd.state) { + case chunk_state::HEADER => + let crlf = 0z; + for (true) { + const n = rd.pending; + match (bytes::index(rd.buffer[..n], ['\r', '\n'])) { + case let z: size => + crlf = z; + break; + case void => + yield; + }; + if (rd.pending >= len(rd.buffer)) { + // Chunk header exceeds buffer size + return errors::overflow; + }; + + match (io::read(rd.conn, rd.buffer[rd.pending..])?) { + case let n: size => + rd.pending += n; + case io::EOF => + if (rd.pending > 0) { + return errors::invalid; + }; + return io::EOF; + }; + }; + + // XXX: Should we do anything with chunk-ext? + const header = rd.buffer[..crlf]; + const (ln, _) = bytes::cut(header, ';'); + const ln = match (strings::fromutf8(ln)) { + case let s: str => + yield s; + case => + return errors::invalid; + }; + + match (strconv::stoz(ln, strconv::base::HEX)) { + case let z: size => + rd.length = z; + case => + return errors::invalid; + }; + if (rd.length == 0) { + return io::EOF; + }; + + const n = crlf + 2; + rd.buffer[..rd.pending - n] = rd.buffer[n..rd.pending]; + rd.pending -= n; + rd.state = chunk_state::DATA; + case chunk_state::DATA => + for (rd.pending < rd.length) { + match (io::read(rd.conn, rd.buffer[rd.pending..])?) { + case let n: size => + rd.pending += n; + case io::EOF => + return io::EOF; + }; + }; + let n = len(buf); + if (n > rd.pending) { + n = rd.pending; + }; + if (n > rd.length) { + n = rd.length; + }; + buf[..n] = rd.buffer[..n]; + rd.buffer[..rd.pending - n] = rd.buffer[n..rd.pending]; + rd.pending -= n; + rd.length -= n; + rd.state = chunk_state::FOOTER; + return n; + case chunk_state::FOOTER => + for (rd.pending < 2) { + match (io::read(rd.conn, rd.buffer[rd.pending..])?) { + case let n: size => + rd.pending += n; + case io::EOF => + return io::EOF; + }; + }; + if (!bytes::equal(rd.buffer[..2], ['\r', '\n'])) { + return errors::invalid; + }; + rd.buffer[..rd.pending - 2] = rd.buffer[2..rd.pending]; + rd.pending -= 2; + rd.state = chunk_state::HEADER; + }; +}; + +fn chunked_close(s: *io::stream) (void | io::error) = { + let rd = s: *chunked_reader; + // TODO connection pool + free(rd); +}; + +fn write_chunk(sink: io::handle, buf: []u8) (size | io::error) = { + fmt::fprintf(sink, "{}\r\n", strconv::ztos(len(buf), strconv::base::HEX))?; + const wrote = io::write(sink, buf)?; + fmt::fprint(sink, "\r\n")?; + return wrote; +}; diff --git a/net/websocket/websocket.ha b/net/websocket/websocket.ha @@ -0,0 +1,358 @@ +use bufio; +use crypto::random; +use crypto::sha1; +use encoding::base64; +use errors; +use fmt; +use hash; +use io; +use net::dial; +use net::uri; +use net::http; +use net; +use strings; + +const FRAME_MAX = 0x7FFFFFFFFFFFFFFFz; +const MAGIC = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; + +export type opcode = u8; + +export const OPCODE_CONT = 0x0: opcode; +export const OPCODE_TEXT = 0x1: opcode; +export const OPCODE_BIN = 0x2: opcode; +export const OPCODE_CLOSE = 0x8: opcode; +export const OPCODE_PING = 0x9: opcode; +export const OPCODE_PONG = 0xA: opcode; + +// Represent an unserialized and unmasked websocket frame. +export type frame = struct { + op: opcode, + msg: []u8, +}; + +// Free resources associated to a [[frame]]. +export fn frame_finish(f: *frame) void = { + free(f.msg); +}; + +export type websocket = struct { + sock: io::handle, + server: bool, +}; + +// Performs a [[net::dial]] operation for a given URI, and continue with a +// websocket initial handshake, returning a [[websocket]]. The caller must free +// the associated resources with [[finish]]. +export fn connect( + client: *http::client, + targ: *uri::uri, +) (websocket | http::protoerr | net::error | dial::error | nomem | io::error) = { + const sock = dial::dial_uri("tcp", targ)?; + + const req = handshake_request(client, targ)?; + defer http::request_finish(&req); + http::request_write(sock, &req, client)!; + + const resp = http::response_parse(sock)?; + defer http::response_parsed_finish(&resp); + + const key = http::header_get(&req.header, "Sec-WebSocket-Key"); + const proof_exp = proof(key)?; + defer free(proof_exp); + const proof = http::header_get(&resp.header, "Sec-WebSocket-Accept"); + if (proof == "") { + proof = http::header_get(&resp.header, "sec-websocket-accept"); + }; + if (proof != proof_exp) { + return http::protoerr; + }; + + return websocket { + sock = sock, + server = false, + }; +}; + +// Performs a [[net::accept]] operation over a [[net::socket]], and continue +// with a websocket initial handshake, returning a [[websocket]]. The caller +// must free the associated resources with [[finish]]. +export fn accept( + sock: net::socket, +) (websocket | net::error | http::protoerr | io::error | nomem) = { + const sock = net::accept(sock)?; + + const req = http::request_parse(sock)?; + defer http::request_parsed_finish(&req); + + const resp = handshake_response(req)?; + defer http::response_finish(&resp); + + fmt::fprintf(sock, "HTTP/1.1 101 Switching Protocols\r\n")!; + http::write_header(sock, &resp.header)!; + fmt::fprint(sock, "\r\n")!; + + return websocket { + sock = sock, + server = true, + }; +}; + +// Free associated resources to a [[websocket]]. +export fn finish(ws: *websocket) void = { + io::close(ws.sock)!; +}; + +// Send a message through a [[websocket]]. To send continuation frames, use +// fin and cont respectively to indicate if the frame is final, and/or if it +// is a continuation frame. Every non-first frames must be continuations. +export fn write_msg( + ws: *websocket, + msg: []u8, + fin: bool = true, + cont: bool = false, +) (void | io::error) = { + let op = OPCODE_TEXT; + if (cont) op = OPCODE_CONT; + write_websocket( + ws.sock, + frame { + op = op, + msg = msg, + }, + !ws.server, + fin, + )?; +}; + +// Send a [[frame]] through a [[websocket]]. +export fn write( + ws: *websocket, + f: frame, + fin: bool = true, +) (void | io::error) = { + write_websocket(ws.sock, f, !ws.server, fin)?; +}; + +// Read a [[frame]] through a [[websocket]]. +export fn read( + ws: *websocket, + f: *frame, +) (size | io::EOF | io::error) = { + return read_websocket( + ws.sock, + f, + ws.server, + )?; +}; + +// Build an HTTP handshake [[net::http::request]]. +export fn handshake_request( + client: *http::client, + target: *uri::uri, +) (http::request | nomem) = { + const req = http::new_request(client, "GET", target)!; + + http::header_add(&req.header, "Upgrade", "websocket")?; + http::header_add(&req.header, "Connection", "Upgrade")?; + http::header_add(&req.header, "Sec-WebSocket-Version", "13")?; + + const key: [16]u8 = [0...]; + random::buffer(&key); + const key = base64::encodestr(&base64::std_encoding, key)?; + defer free(key); + http::header_add(&req.header, "Sec-WebSocket-Key", key)?; + + return req; +}; + +// Build an HTTP handshake [[net::http::response]] for a [[net::http::request]]. +export fn handshake_response(req: http::request) (http::response | nomem) = { + const resp = http::response { + body = io::empty, + ... + }; + + const key = http::header_get(&req.header, "Sec-WebSocket-Key"); + const proof = proof(key)?; + defer free(proof); + http::header_set( + &resp.header, + "Sec-WebSocket-Accept", + proof, + )?; + http::header_set(&resp.header, "Connection", "Upgrade")?; + http::header_set(&resp.header, "Upgrade", "websocket")?; + + return resp; +}; + +// Build the "Sec-WebSocket-Accept" response proof for a "Sec-WebSocket-Key" +// request key. +export fn proof(key: str) (str | nomem) = { + const hash = sha1::sha1(); + io::write(&hash, strings::toutf8(key))!; + io::write(&hash, strings::toutf8(MAGIC))!; + const key: [sha1::SZ]u8 = [0...]; + hash::sum(&hash, &key); + return base64::encodestr(&base64::std_encoding, key)?; +}; + +// Formats a [[frame]] and write it to the given [[io::handle]]. +export fn write_websocket( + h: io::handle, + f: frame, + mask: bool, + fin: bool = true, +) (size | io::error) = { + let first = true; + let work = 0: u8; + let remains = f.msg; + let doing: []u8 = []; + + for (true) { + defer first = false; + doing = remains; + if (len(doing) > FRAME_MAX) { + remains = doing[FRAME_MAX..]; + doing = doing[..FRAME_MAX]; + } else { + remains = []; + }; + + work = 0; + if (!fin || len(remains) > 0) { + work = 0b00000000; + } else { + work = 0b10000000; + }; + if (first) { + io::write(h, [work | f.op])?; + } else { + io::write(h, [work | OPCODE_CONT])?; + }; + + work = 0; + if (mask) work = 0b10000000; + if (len(doing) <= 125) { // u7 + io::write(h, [work | len(doing): u8])?; + } else if (len(doing) <= 65535) { // u16 + io::write(h, [ // Extended /16 + work | 126: u8, + (len(doing): u16 >> 8): u8, + (len(doing): u16 & 0x00FF): u8, + ])?; + } else { // u64 + io::write(h, [ // Extended /64 + work | 127: u8, + (len(doing): u64 >> 8*7): u8, + (len(doing): u64 >> 8*6 & 0xFF): u8, + (len(doing): u64 >> 8*5 & 0xFF): u8, + (len(doing): u64 >> 8*4 & 0xFF): u8, + (len(doing): u64 >> 8*3 & 0xFF): u8, + (len(doing): u64 >> 8*2 & 0xFF): u8, + (len(doing): u64 >> 8*1 & 0xFF): u8, + (len(doing): u64 >> 8*0 & 0xFF): u8, + ])?; + }; + + if (!mask) { + io::write(h, doing)?; + } else { + const key: [4]u8 = [0...]; + random::buffer(&key); + io::write(h, key)?; + + for (let i = 0z; i < len(doing); i += 1) { + io::write(h, [doing[i] ^ key[i % 4]])?; + }; + }; + + if (len(remains) == 0) break; + }; + + return len(f.msg); +}; + +// Read a [[frame]] from an [[io::handle]]. +export fn read_websocket( + h: io::handle, + f: *frame, + mask: bool, +) (size | io::EOF | io::error) = { + const scan = bufio::newscanner(h); + defer bufio::finish(&scan); + + let work: [1]u8 = [0]; + let first = true; + let last = false; + let s = 0z; + let paylen = 0z; + + for (!last) { + defer first = false; + if (io::readall(&scan, &work)? is io::EOF) + return io::EOF; + if (work[0] & 0b10000000 != 0) + last = true; + if (work[0] & 0b01110000 != 0) // TODO: extensions + return errors::unsupported; + + if (first) { + f.op = (work[0] & 0b00001111): opcode; + } else if (work[0] & 0b00001111 != OPCODE_CONT) { + return errors::invalid; + }; + + if (io::readall(&scan, &work)? is io::EOF) + return io::EOF; + if (work[0] & 0b10000000 != 0) { + if (!mask) return errors::invalid; + } else { + if (mask) return errors::invalid; + }; + switch (work[0] & 0b01111111) { + case 126 => + paylen = 0z; + if (io::readall(&scan, &work)? is io::EOF) + return io::EOF; + paylen += work[0]: u16 << 8; + if (io::readall(&scan, &work)? is io::EOF) + return io::EOF; + paylen += work[0]; + case 127 => + paylen = 0z; + for (let i = 7u8; i >= 0; i -= 1) { + if (io::readall(&scan, &work)? is io::EOF) + return io::EOF; + paylen += work[0]: u64 << 8 * i; + }; + case => + paylen = work[0] & 0b01111111; + }; + defer s += paylen; + + if (paylen == 0) { + continue; + }; + + append(f.msg, [0...], paylen)?; + + if (!mask) { + if (io::readall(&scan, f.msg[s..s+paylen])? is io::EOF) + return io::EOF; + } else { + const key: [4]u8 = [0...]; + if (io::readall(&scan, key)? is io::EOF) + return io::EOF; + + if (io::readall(&scan, f.msg[s..s+paylen])? is io::EOF) + return io::EOF; + for (let i = 0z; i < paylen; i += 1) { + f.msg[s+i] = f.msg[s+i] ^ key[i % 4]; + }; + }; + + }; + + return s; +};