From 302caf3ce7325b2a5c1409a45d0d30216c852e20 Mon Sep 17 00:00:00 2001 From: hanez Date: Sat, 4 Jul 2026 19:54:07 +0200 Subject: [PATCH] Added HTTP library http.fun and an examples script. (0.42.2) --- examples/blocking/net/http.fun | 31 +++ lib/net/http.fun | 370 +++++++++++++++++++++++++++++++++ web/index.md | 2 + 3 files changed, 403 insertions(+) create mode 100644 examples/blocking/net/http.fun create mode 100644 lib/net/http.fun diff --git a/examples/blocking/net/http.fun b/examples/blocking/net/http.fun new file mode 100644 index 0000000..0b9c6b6 --- /dev/null +++ b/examples/blocking/net/http.fun @@ -0,0 +1,31 @@ +/* + * Example: Simple HTTP server using stdlib net/http_server + * + * Run: + * $ fun examples/blocking/net/http.fun + * + * It will serve files from the ./web directory on http://127.0.0.1:8080 + * + * Test with curl in another terminal: + * $ curl -v http://127.0.0.1:8080/ + * $ curl -v http://127.0.0.1:8080/index.html + * + * You can also place a .fun script in ./web (e.g. hello.fun) and call it: + * $ curl -v 'http://127.0.0.1:8080/hello.fun?name=Fun' + */ + +#include + +// Configure +PORT = 8080 +DOC_ROOT = "./examples/data/htdocs/" + +print("Starting simple HTTP server example…") +print("Serving '" + DOC_ROOT + "' on http://127.0.0.1:" + to_string(PORT)) +print("Press Ctrl+C to stop.") + +srv = HTTPServer(PORT) +srv.set_htdocs(DOC_ROOT) + +// Start (blocking loop) +srv.start() diff --git a/lib/net/http.fun b/lib/net/http.fun new file mode 100644 index 0000000..bfb30ad --- /dev/null +++ b/lib/net/http.fun @@ -0,0 +1,370 @@ +/* + * 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} diff --git a/web/index.md b/web/index.md index a244b43..8a7b65b 100644 --- a/web/index.md +++ b/web/index.md @@ -19,6 +19,8 @@ permalink: /

What is Fun?

+

This is volunteer-maintained open-source software. Help is welcome. Personal attacks, memes and broad outrage in issue threads will not be. Please remain respectful.

+

Fun is a small, strict, and simple programming language that runs on a compact stack-based virtual machine. The C core is intentionally minimal; most functionality and standard libraries are implemented in Fun itself.

Fun is dynamically typed with optional static type annotations, featuring first-class functions, classes with inheritance, pattern matching, and a rich standard library. It supports everything from basic scripting to TCP sockets, serial communication, threading, cryptography (MD5, SHA-1/256/384/512, CRC-32, AES-256), and a built-in debugger.