Added HTTP library http.fun and an examples script. (0.42.2)
This commit is contained in:
parent
0314f37f2c
commit
302caf3ce7
3 changed files with 403 additions and 0 deletions
31
examples/blocking/net/http.fun
Normal file
31
examples/blocking/net/http.fun
Normal file
|
|
@ -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 <net/http_server.fun>
|
||||
|
||||
// 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()
|
||||
370
lib/net/http.fun
Normal file
370
lib/net/http.fun
Normal file
|
|
@ -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 <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}
|
||||
|
|
@ -19,6 +19,8 @@ permalink: /
|
|||
|
||||
<h2>What is Fun?</h2>
|
||||
|
||||
<p>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.</p>
|
||||
|
||||
<p>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.</p>
|
||||
|
||||
<p>Fun is dynamically typed with optional static type annotations, <a href="/features/">featuring first-class functions</a>, 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.</p>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue