/* * This file is part of the Fun programming language. * https://fun-lang.xyz/ * * Copyright 2026 Johannes Findeisen * Licensed under the terms of the Apache-2.0 license. * https://opensource.org/license/apache-2-0 * * Added: 2026-07-04 */ #include #include /* * HTTP — minimal HTTP/1.1 server library (Phase 1) * * Goals for Phase 1: * - HTTP/1.1 request parsing (request-line, headers) * - Message body handling via Content-Length and Transfer-Encoding: chunked * - Persistent connections (keep-alive) with Connection: close handling * - Simple handler callback API: handler(req) -> res * - No TLS, no pipelining, no compression, no auth (to be added later) * * API overview: * class HTTP(number port) * fun start(this, handler) * - handler: function taking `req` map and returning `res` map * * Request map `req` fields: * - method: string * - target: raw request-target (string) * - path: path part without query (string) * - query: query string without '?' (string) * - version: "HTTP/1.1" or "HTTP/1.0" * - headers: map (lower-cased header names) * - body: string (for Phase 1; streaming may arrive later) * - remote_fd: number (client socket fd) * * Response map `res` fields: * - status: number (e.g. 200) * - reason: optional string (defaults from common codes) * - headers: optional map * - body: optional string (defaults to empty) */ // Utility: common status text fun _http_reason(status) code = to_number(status) if (code == 200) return "OK" if (code == 201) return "Created" if (code == 202) return "Accepted" if (code == 204) return "No Content" if (code == 301) return "Moved Permanently" if (code == 302) return "Found" if (code == 303) return "See Other" if (code == 304) return "Not Modified" if (code == 307) return "Temporary Redirect" if (code == 308) return "Permanent Redirect" if (code == 400) return "Bad Request" if (code == 401) return "Unauthorized" if (code == 403) return "Forbidden" if (code == 404) return "Not Found" if (code == 405) return "Method Not Allowed" if (code == 408) return "Request Timeout" if (code == 411) return "Length Required" if (code == 413) return "Payload Too Large" if (code == 414) return "URI Too Long" if (code == 415) return "Unsupported Media Type" if (code == 417) return "Expectation Failed" if (code == 426) return "Upgrade Required" if (code == 431) return "Request Header Fields Too Large" if (code == 500) return "Internal Server Error" if (code == 501) return "Not Implemented" if (code == 503) return "Service Unavailable" if (code == 505) return "HTTP Version Not Supported" return "" // Utility: write full response and return whether connection should stay open fun _http_write_response(fd, res, keep_alive) status = to_number(res["status"]) reason = res["reason"] if (len(to_string(reason)) == 0) reason = _http_reason(status) if (len(reason) == 0) reason = "OK" body = res["body"] if (typeof(body) != "string") body = to_string(body) blen = len(body) headers = res["headers"] if (typeof(headers) != "map") headers = {} // Ensure Content-Length for simple bodies (no streaming in Phase 1) headers["content-length"] = to_string(blen) if (len(to_string(headers["content-type"])) == 0) headers["content-type"] = "text/plain; charset=utf-8" // Connection header per keep-alive flag if (keep_alive) headers["connection"] = "keep-alive" else headers["connection"] = "close" // Build response string start = "HTTP/1.1 " + to_string(status) + " " + reason + "\r\n" head = "" // Normalize: send headers with canonical casing best-effort // but here we just send lower-case names as kept for (k in keys(headers)) v = to_string(headers[k]) // join: "name: value\r\n" head = head + k + ": " + v + "\r\n" resp = start + head + "\r\n" + body sock_send(fd, resp) return keep_alive // Utility: decode chunked transfer-encoding body from a buffer and fd (if needs more) fun _http_read_chunked(fd, after_headers, max_body) // after_headers: initial buffer containing any bytes after CRLFCRLF data = after_headers body = "" // Read line by line: [;ext] CRLF, then that many bytes, then CRLF, repeat, then trailer part ends with CRLF CRLF // For simplicity, we ignore chunk extensions and trailers in Phase 1. while true // ensure we have a full line while find(data, "\r\n") < 0 more = sock_recv(fd, 4096) if (len(more) == 0) return {"ok": 0, "body": body} data = data + more if (max_body > 0 && len(body) + len(data) > max_body) return {"ok": 0, "body": body} pos = find(data, "\r\n") line = substr(data, 0, pos) data = substr(data, pos + 2, len(data) - pos - 2) semi = find(line, ";") size_str = line if (semi >= 0) size_str = substr(line, 0, semi) size_str = str_trim(size_str) // hex to number // Fun doesn’t have hex parse; do manual n = 0 i = 0 while i < len(size_str) ch = substr(size_str, i, 1) cl = str_to_lower(ch) d = -1 if (cl >= "0" && cl <= "9") d = to_number(cl) - to_number("0") else if (cl >= "a" && cl <= "f") d = 10 + (to_number(cl) - to_number("a")) if (d < 0) return {"ok": 0, "body": body} n = n * 16 + d i = i + 1 // read exactly n bytes while len(data) < n + 2 more2 = sock_recv(fd, 4096) if (len(more2) == 0) return {"ok": 0, "body": body} data = data + more2 if (max_body > 0 && len(body) + len(data) > max_body) return {"ok": 0, "body": body} chunk = substr(data, 0, n) crlf = substr(data, n, 2) // Must be CRLF after chunk if (crlf != "\r\n") return {"ok": 0, "body": body} data = substr(data, n + 2, len(data) - n - 2) body = body + chunk if (max_body > 0 && len(body) > max_body) return {"ok": 0, "body": body} if (n == 0) // reach end; ignore trailers (until CRLFCRLF) // optional: consume trailers // try to find CRLFCRLF in remaining data; if not there, read minimal amount // but for Phase 1 we stop here return {"ok": 1, "body": body} class HTTP(number port) // Configuration limits (conservative defaults) max_header_bytes = 32 * 1024 max_start_line_bytes = 8 * 1024 max_body_bytes = 8 * 1024 * 1024 // 8 MiB for Phase 1 read_chunk_size = 4096 fun _construct(this, port) this.port = port this.server = TcpServer(port, 128) // Start the HTTP server. `handler` is a function: handler(req) -> res fun start(this, handler) if (this.server.listen() <= 0) print("HTTP: failed to listen on port " + to_string(this.port)) return 0 print("HTTP: listening on port " + to_string(this.port)) while true cfd = this.server.accept() if (cfd > 0) this._handle_connection(cfd, handler) return 1 // Connection loop with keep-alive fun _handle_connection(this, fd, handler) // read-buffer that may carry over data between requests on same socket carry = "" while true parsed = this._read_and_parse_request(fd, carry) if (parsed["ok"] != 1) break req = parsed["req"] carry = parsed["carry"] // handler returns res map res = handler(req) if (typeof(res) != "map") res = {"status": 500, "reason": "Internal Server Error", "body": "Handler error"} // decide keep-alive h_conn = req["headers"]["connection"] ver = req["version"] // default keep-alive for HTTP/1.1 unless Connection: close keep = (ver == "HTTP/1.1") && (str_to_lower(to_string(h_conn)) != "close") if (str_to_lower(to_string(h_conn)) == "keep-alive") keep = true if (str_to_lower(to_string(h_conn)) == "close") keep = false stayed = _http_write_response(fd, res, keep) if (!stayed) break sock_close(fd) return 1 // Reads from fd until a full request is available or error; returns {ok, req, carry} fun _read_and_parse_request(this, fd, carry) data = carry // Accumulate until we see end of headers (CRLFCRLF or LFLF) // also enforce size limits while true // Check if headers end exists p = find(data, "\r\n\r\n") if (p < 0) p = find(data, "\n\n") if (p >= 0) break if (len(data) > this.max_header_bytes + this.max_start_line_bytes) return {"ok": 0} more = sock_recv(fd, this.read_chunk_size) if (len(more) == 0) return {"ok": 0} data = data + more // Split start-line and headers // Find first line break lf = find(data, "\n") if (lf < 0 || lf > this.max_start_line_bytes) return {"ok": 0} // Handle CRLF or LF first_line = substr(data, 0, (lf > 0 && substr(data, lf-1, 1) == "\r") ? lf-1 : lf) headers_block = substr(data, (substr(data, lf-1, 1) == "\r") ? lf+1 : lf+1, // start after first line CRLF/LF 0) // placeholder, will correct below // Find header-body separator sep = find(data, "\r\n\r\n") sep_len = 4 if (sep < 0) sep = find(data, "\n\n") sep_len = 2 headers_raw = substr(data, (substr(data, lf-1, 1) == "\r") ? lf+1 : lf+1, sep - ((substr(data, lf-1, 1) == "\r") ? (lf+1) : (lf+1))) after = substr(data, sep + sep_len, len(data) - sep - sep_len) // Parse start-line: METHOD SP request-target SP HTTP/version parts = str_split(first_line, " ") if (len(parts) < 3) return {"ok": 0} method = str_trim(parts[0]) target = str_trim(parts[1]) version = str_trim(parts[2]) if (version != "HTTP/1.1" && version != "HTTP/1.0") return {"ok": 0} // Parse headers into map with lower-cased names; ignore obs-fold hdr_lines = str_split(headers_raw, "\n") headers = {} i = 0 while i < len(hdr_lines) line = hdr_lines[i] line = str_trim(line) if (len(line) == 0) i = i + 1 continue colon = find(line, ":") if (colon <= 0) // invalid header; reject return {"ok": 0} name = str_to_lower(str_trim(substr(line, 0, colon))) value = str_trim(substr(line, colon + 1, len(line) - colon - 1)) // merge duplicates with comma prev = headers[name] if (len(to_string(prev)) > 0) headers[name] = prev + ", " + value else headers[name] = value i = i + 1 // Handle request-target: split path and query path = target query = "" qpos = find(target, "?") if (qpos >= 0) path = substr(target, 0, qpos) query = substr(target, qpos + 1, len(target) - qpos - 1) // Determine body body = "" // transfer-encoding has precedence te = str_to_lower(to_string(headers["transfer-encoding"])) cl = to_string(headers["content-length"]) if (len(te) > 0) if (te == "chunked") dec = _http_read_chunked(fd, after, this.max_body_bytes) if (dec["ok"] != 1) return {"ok": 0} body = dec["body"] after = "" else // unsupported TE return {"ok": 0} else if (len(cl) > 0) // parse numeric Content-Length n = 0 j = 0 while j < len(cl) ch = substr(cl, j, 1) if (!(ch >= "0" && ch <= "9")) return {"ok": 0} n = n * 10 + (to_number(ch) - to_number("0")) j = j + 1 if (n > this.max_body_bytes) return {"ok": 0} // ensure we have n bytes in `after`, otherwise read while len(after) < n moreb = sock_recv(fd, this.read_chunk_size) if (len(moreb) == 0) return {"ok": 0} after = after + moreb if (len(after) > this.max_body_bytes) return {"ok": 0} body = substr(after, 0, n) after = substr(after, n, len(after) - n) else // no body; leave `after` as carry for potential next requests body = "" req = { "method": method, "target": target, "path": path, "query": query, "version": version, "headers": headers, "body": body, "remote_fd": fd } return {"ok": 1, "req": req, "carry": after}