370 lines
12 KiB
Standard ML
370 lines
12 KiB
Standard ML
/*
|
||
* 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}
|