1
1
Fork 0
forked from fun/fun
fun/lib/net/http.fun

370 lines
12 KiB
Standard ML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
* 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 <io/socket.fun>
#include <strings.fun>
/*
* 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<string,string> (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<string,string>
* - 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: <hex-size> [;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}