diff --git a/CMakeLists.txt b/CMakeLists.txt index 2dd8000..2a49396 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,5 +1,5 @@ cmake_minimum_required(VERSION 3.10) -project(fun VERSION 0.39.10 LANGUAGES C) +project(fun VERSION 0.39.11 LANGUAGES C) set(CMAKE_C_STANDARD 99) set(CMAKE_C_STANDARD_REQUIRED ON) diff --git a/examples/net/http_mt_server.fun b/examples/net/http_mt_server.fun new file mode 100755 index 0000000..ed60aa4 --- /dev/null +++ b/examples/net/http_mt_server.fun @@ -0,0 +1,66 @@ +#!/usr/bin/env fun + +/* + * 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-03-26 + */ + +#include +#include + +// Simple multi-threaded HTTP server (thread-per-connection) +// +// Features: +// - Blocking accept() in the main thread +// - Each client is handled by a separate Fun thread +// - Sends a minimal HTTP 200 response with a small HTML body +// +// Usage: +// ./examples/net/http_mt_server.fun +// Open http://127.0.0.1:8089/ in the browser + +PORT = 8080 +BACKLOG = 128 + +srv = TcpServer(PORT, BACKLOG) +if (srv.listen() <= 0) + print("HTTP MT server: listen failed on :" + to_string(PORT)) + return 0 +print("HTTP MT server on :" + to_string(PORT)) + +fun handle_client(fd) + req = sock_recv(fd, 8192) + if (len(req) == 0) + sock_close(fd) + return 0 + + body = "

Hello from Fun multi-threaded server

" + b = to_string(body) + resp = "HTTP/1.1 200 OK\r\n" + resp = resp + "Content-Type: text/html; charset=utf-8\r\n" + resp = resp + "Content-Length: " + to_string(len(b)) + "\r\n" + resp = resp + "Connection: close\r\n\r\n" + b + sock_send(fd, resp) + sock_close(fd) + return 1 + +th = Thread() + +// Accept loop: spawn a thread per client +while true + fd = srv.accept() + if (fd > 0) + _ = th.spawn(handle_client, fd) + +/* Expected output (on start): +HTTP MT server on :8080 + +Then open http://127.0.0.1:8089/ in a browser; it will render: +

Hello from Fun multi-threaded server

+*/ diff --git a/examples/net/http_mt_server_cgi.fun b/examples/net/http_mt_server_cgi.fun new file mode 100755 index 0000000..459f3e0 --- /dev/null +++ b/examples/net/http_mt_server_cgi.fun @@ -0,0 +1,361 @@ +#!/usr/bin/env fun + +/* + * 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-03-26 + */ + +//#include order kept minimal to avoid global bloat +#include +#include +// Note: Avoid relying on strings.fun helpers inside threads +#include + +// Multi-threaded HTTP server with CGI support (thread-per-connection) +// +// Features: +// - Blocking accept() in the main thread +// - Each client is handled by a separate Fun thread +// - Serves static files from htdocs and executes .fun scripts via CGI helper +// +// Try: +// http://127.0.0.1:8080/ +// http://127.0.0.1:8080/hello.fun?name=Fun +// http://127.0.0.1:8080/info.fun + +// Keep top-level globals minimal to avoid hitting the VM's global limit. + +PORT = 8080 +BACKLOG = 128 +HTDOCS = "./examples/data/htdocs" + +// Minimal local string helpers that avoid 'join' internally +fun _trim(s) + src = to_string(s) + // ltrim + i = 0 + ws = " \t\r\n" + while (i < len(src)) + ch = substr(src, i, 1) + if (find(ws, ch) < 0) + break + i = i + 1 + left = substr(src, i, len(src) - i) + // rtrim + j = len(left) - 1 + while (j >= 0) + ch2 = substr(left, j, 1) + if (find(ws, ch2) < 0) + break + j = j - 1 + return substr(left, 0, j + 1) + +fun _ends_with(s, suf) + a = to_string(s) + p = to_string(suf) + la = len(a) + lp = len(p) + if (lp > la) + return 0 + return substr(a, la - lp, lp) == p + +// Split by single character delimiter (first char of delim string) +fun _split_char(s, delim) + src = to_string(s) + d = to_string(delim) + if (len(d) == 0) + return [src] + dd = substr(d, 0, 1) + parts = [] + buf = "" + i = 0 + n = len(src) + while (i < n) + ch = substr(src, i, 1) + if (ch == dd) + push(parts, buf) + buf = "" + else + buf = buf + ch + i = i + 1 + push(parts, buf) + return parts + +fun _split_space(s) + return _split_char(s, " ") + +fun _split_lines(s) + return _split_char(s, "\n") + +fun _to_upper_ascii(s) + src = to_string(s) + U = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + L = "abcdefghijklmnopqrstuvwxyz" + out = "" + i = 0 + n = len(src) + while (i < n) + ch = substr(src, i, 1) + idx = find(L, ch) + if (idx >= 0) + out = out + substr(U, idx, 1) + else + out = out + ch + i = i + 1 + return out + +// Minimal send helpers (few identifiers) +fun _send(fd, code, text, body) + b = to_string(body) + resp = "HTTP/1.1 " + to_string(code) + " " + text + "\r\n" + resp = resp + "Content-Type: text/html; charset=utf-8\r\n" + resp = resp + "Content-Length: " + to_string(len(b)) + "\r\n" + resp = resp + "Connection: close\r\n\r\n" + b + sock_send(fd, resp) + +// Convert CGI raw output (headers + body) to full HTTP/1.1 response without using strings.fun helpers +fun _cgi_to_http_response(raw) + out = to_string(raw) + // Find header/body separator + sep = find(out, "\r\n\r\n") + seplen = 4 + if (sep < 0) + sep = find(out, "\n\n") + seplen = 2 + if (sep < 0) + // No CGI headers, treat whole as body + b = out + resp = "HTTP/1.1 200 OK\r\n" + resp = resp + "Content-Type: text/html; charset=utf-8\r\n" + resp = resp + "Content-Length: " + to_string(len(b)) + "\r\n" + resp = resp + "Connection: close\r\n\r\n" + b + return resp + + header_str = substr(out, 0, sep) + body = substr(out, sep + seplen, len(out) - sep - seplen) + + // Parse headers line-by-line + lines = _split_lines(header_str) + code = 200 + text = "OK" + hh = [] // [key, value] + i = 0 + if (typeof(lines) == "Array") + while (i < len(lines)) + ln = _trim(lines[i]) + if (len(ln) > 0) + colon = find(ln, ":") + if (colon > 0) + k = _trim(substr(ln, 0, colon)) + v = _trim(substr(ln, colon + 1, len(ln) - colon - 1)) + if (_to_upper_ascii(k) == "STATUS") + sp = find(v, " ") + if (sp > 0) + code = to_number(substr(v, 0, sp)) + text = _trim(substr(v, sp + 1, len(v) - sp - 1)) + else + code = to_number(v) + if (code == 0) code = 200 + text = "OK" + else + push(hh, [k, v]) + i = i + 1 + + // Build HTTP response + b = to_string(body) + resp = "HTTP/1.1 " + to_string(code) + " " + text + "\r\n" + j = 0 + m = len(hh) + has_len = 0 + while (j < m) + p = hh[j] + if (typeof(p) == "Array" && len(p) >= 2) + hk = to_string(p[0]) + hv = to_string(p[1]) + if (_to_upper_ascii(hk) == "CONTENT-LENGTH") + has_len = 1 + resp = resp + hk + ": " + hv + "\r\n" + j = j + 1 + if (!has_len) + resp = resp + "Content-Length: " + to_string(len(b)) + "\r\n" + resp = resp + "Connection: close\r\n\r\n" + b + return resp + +fun _send_cgi(fd, raw) + resp = _cgi_to_http_response(raw) + sock_send(fd, resp) + +// Worker: parse request and serve static or .fun via CGI +fun handle_client(fd) + req = sock_recv(fd, 65536) + if (len(req) == 0) + sock_close(fd) + return 0 + + s = to_string(req) + nl = find(s, "\n") + if (nl < 0) + nl = find(s, "\r") + if (nl < 0) + sock_close(fd) + return 0 + line = _trim(substr(s, 0, nl)) + ps = _split_space(line) + if (!(typeof(ps) == "Array") || len(ps) < 2) + sock_close(fd) + return 0 + method = _trim(ps[0]) + target = _trim(ps[1]) + + // path + query + path = target + query = "" + q = find(target, "?") + if (q >= 0) + path = substr(target, 0, q) + query = substr(target, q + 1, len(target) - q - 1) + if (path == "/") + path = "/index.html" + + htdocs = env("FUN_HTDOCS") + if (htdocs == nil || len(htdocs) == 0) + htdocs = HTDOCS + file = htdocs + path + + // parse headers small loop + headers = {} + lines = _split_lines(s) + if (typeof(lines) == "Array") + i = 1 + while (i < len(lines)) + ln = _trim(lines[i]) + if (len(ln) == 0) + break + cpos = find(ln, ":") + if (cpos > 0) + k = _to_upper_ascii(_trim(substr(ln, 0, cpos))) + v = _trim(substr(ln, cpos + 1, len(ln) - cpos - 1)) + headers[k] = v + i = i + 1 + + // body (optional) + body = "" + hend = find(s, "\r\n\r\n") + if (hend >= 0) + body = substr(s, hend + 4, len(s) - hend - 4) + else + hend = find(s, "\n\n") + if (hend >= 0) + body = substr(s, hend + 2, len(s) - hend - 2) + + if (_ends_with(path, ".fun")) + host = headers["HOST"] + if (host == nil || len(host) == 0) + host = "localhost" + ua = headers["USER-AGENT"] + cookie = headers["COOKIE"] + ctype = headers["CONTENT-TYPE"] + clen = headers["CONTENT-LENGTH"] + + envs = [] + funlib = env("FUN_LIB_DIR") + if (len(funlib) == 0) + funlib = "./lib" + push(envs, "FUN_LIB_DIR='" + funlib + "'") + push(envs, "REQUEST_METHOD='" + method + "'") + push(envs, "QUERY_STRING='" + query + "'") + push(envs, "SCRIPT_NAME='" + path + "'") + push(envs, "PATH_INFO='" + path + "'") + push(envs, "SERVER_NAME='" + host + "'") + pstr = env("FUN_PORT") + if (pstr == nil || len(pstr) == 0) + pstr = to_string(PORT) + push(envs, "SERVER_PORT='" + pstr + "'") + push(envs, "SERVER_PROTOCOL='HTTP/1.1'") + push(envs, "HTTP_HOST='" + host + "'") + if (!(ua == nil) && len(ua) > 0) + push(envs, "HTTP_USER_AGENT='" + ua + "'") + if (!(cookie == nil) && len(cookie) > 0) + push(envs, "HTTP_COOKIE='" + cookie + "'") + if (!(ctype == nil) && len(ctype) > 0) + push(envs, "CONTENT_TYPE='" + ctype + "'") + if (!(clen == nil) && len(clen) > 0) + push(envs, "CONTENT_LENGTH='" + clen + "'") + if (len(body) > 0) + push(envs, "POST_DATA='" + body + "'") + + exec = env("FUN_EXEC") + if (len(exec) == 0) + if (len(read_file("./build_debug/fun")) > 0) + exec = "./build_debug/fun" + else + if (len(read_file("./build_release/fun")) > 0) + exec = "./build_release/fun" + else + exec = "fun" + + // Manually concatenate env exports to avoid join() + envs_str = "" + ei = 0 + en = len(envs) + while (ei < en) + if (ei > 0) + envs_str = envs_str + " " + envs_str = envs_str + envs[ei] + ei = ei + 1 + cmd = envs_str + " " + exec + " " + file + res = proc_run(cmd) + if (res == nil || typeof(res) != "Map") + _send(fd, 500, "Internal Server Error", "

Failed to execute CGI

") + sock_close(fd) + return 0 + out = res["out"] + if (out == nil) + out = "" + code = res["code"] + if (code == nil) + code = 0 + if (len(out) == 0) + if (to_string(code) != "0") + _send(fd, 500, "Internal Server Error", "

CGI failed (exit " + to_string(code) + ")

") + else + _send(fd, 500, "Internal Server Error", "

CGI produced no output

") + else + _send_cgi(fd, out) + else + content = read_file(file) + if (len(content) > 0) + _send(fd, 200, "OK", content) + else + _send(fd, 404, "Not Found", "

404 Not Found

") + + sock_close(fd) + return 1 + +// Setup server and accept loop +srv = TcpServer(PORT, BACKLOG) +if (srv.listen() <= 0) + print("HTTP MT CGI server: listen failed on :" + to_string(PORT)) + return 0 +print("HTTP MT CGI server: serving " + HTDOCS + " on :" + to_string(PORT)) + +th = Thread() +while true + fd = srv.accept() + if (fd > 0) + _ = th.spawn(handle_client, fd) + +/* +Expected output (on start): +HTTP MT CGI server: serving ./examples/data/htdocs on :8080 + +Then open a browser: + - http://127.0.0.1:8080/ + - http://127.0.0.1:8080/hello.fun?name=Fun +*/ diff --git a/examples/blocking/http_server.fun b/examples/net/http_server.fun similarity index 100% rename from examples/blocking/http_server.fun rename to examples/net/http_server.fun diff --git a/examples/blocking/http_server_cgi.fun b/examples/net/http_server_cgi.fun similarity index 100% rename from examples/blocking/http_server_cgi.fun rename to examples/net/http_server_cgi.fun diff --git a/examples/blocking/http_server_cgi_lib.fun b/examples/net/http_server_cgi_lib.fun similarity index 100% rename from examples/blocking/http_server_cgi_lib.fun rename to examples/net/http_server_cgi_lib.fun diff --git a/examples/blocking/http_server_test.fun b/examples/net/http_server_test.fun similarity index 100% rename from examples/blocking/http_server_test.fun rename to examples/net/http_server_test.fun diff --git a/lib/net/http_cgi_server.fun b/lib/net/http_cgi_server.fun index b828069..fa9f5bf 100644 --- a/lib/net/http_cgi_server.fun +++ b/lib/net/http_cgi_server.fun @@ -35,9 +35,23 @@ class HTTPCGIServer(number port) this.handle_client(fd) return 1 + // Safe header getter: returns string or empty string when missing/invalid + fun _hget(this, hdrs, key) + if (typeof(hdrs) == "Map") + v = hdrs[key] + if (typeof(v) == "String") + return v + if (v != nil) + // Coerce non-strings to string just in case + return to_string(v) + return "" + fun _parse_headers(this, request) headers = {} lines = str_split(request, "\n") + // str_split should return an Array; if not, bail out with empty headers + if (!(typeof(lines) == "Array")) + return headers i = 1 // start after request line while (i < len(lines)) ln = str_trim(lines[i]) @@ -57,19 +71,29 @@ class HTTPCGIServer(number port) sock_close(fd) return 0 - lines = str_split(request, "\n") - if (len(lines) == 0) + // Robustly extract request line without relying on array indexing + reqstr = to_string(request) + // debug: mark start of handle_client + // print("[DBG] handle_client: received request bytes=" + to_string(len(reqstr))) + nl = find(reqstr, "\n") + if (nl < 0) + nl = find(reqstr, "\r") + if (nl < 0) sock_close(fd) return 0 + reqline = str_trim(substr(reqstr, 0, nl)) + // print("[DBG] request line=\"" + reqline + "\"") - reqline = str_trim(lines[0]) parts = str_split(reqline, " ") - if (len(parts) < 2) + // Guard against unexpected splitter behavior + if (!(typeof(parts) == "Array") || len(parts) < 2) sock_close(fd) return 0 + // print("[DBG] parts ok, count=" + to_string(len(parts)) + ", t0=" + typeof(parts)) method = str_trim(parts[0]) target = str_trim(parts[1]) + // print("[DBG] method=" + method + ", target=" + target) // Split query string path = target @@ -85,7 +109,12 @@ class HTTPCGIServer(number port) file_path = this.htdocs + path // Headers and body - headers = this._parse_headers(request) + // Parse headers; if anything goes wrong, fall back to an empty map + headers = {} + tmp_headers = this._parse_headers(request) + if (typeof(tmp_headers) == "Map") + headers = tmp_headers + // print("[DBG] headers parsed, type=" + typeof(headers)) // Extract POST body body = "" @@ -99,19 +128,19 @@ class HTTPCGIServer(number port) // Serve CGI when file ends with .fun if (str_ends_with(path, ".fun")) - // Build env vars according to CGI conventions - host = headers["HOST"] + // Build env vars according to CGI conventions. + // Extract selected request headers safely (map lookups may yield nil). + host = this._hget(headers, "HOST") if (len(host) == 0) host = "localhost" - ua = headers["USER-AGENT"] - cookie = headers["COOKIE"] - ctype = headers["CONTENT-TYPE"] - clen = headers["CONTENT-LENGTH"] + ua = this._hget(headers, "USER-AGENT") + cookie = this._hget(headers, "COOKIE") + ctype = this._hget(headers, "CONTENT-TYPE") + clen = this._hget(headers, "CONTENT-LENGTH") envs = [] // Ensure Fun stdlib is available to CGI child process funlib = env("FUN_LIB_DIR") if (len(funlib) == 0) - // Best-effort default when running from project root funlib = "./lib" push(envs, "FUN_LIB_DIR='" + funlib + "'") push(envs, "REQUEST_METHOD='" + method + "'") @@ -146,16 +175,41 @@ class HTTPCGIServer(number port) // Run the Fun script as a CGI program cmd = join(envs, " ") + " " + exec + " " + file_path + // print("[DBG] CGI exec: " + cmd) res = proc_run(cmd) - out = res["out"] + + // Be defensive: proc_run() may fail or return a non-map depending on platform + if (res == nil || typeof(res) != "Map") + print("[CGI] proc_run failed for: " + file_path) + this._send(fd, 500, "Internal Server Error", "

Failed to execute CGI

") + sock_close(fd) + return 0 + + // Safely extract fields from result map; guard each access + out = "" + code = 0 + tmp = nil + if (typeof(res) == "Map") + tmp = res["out"] + if (tmp != nil) + out = tmp + tmpc = nil + if (typeof(res) == "Map") + tmpc = res["code"] + if (tmpc != nil) + code = tmpc if (len(out) == 0) - this._send(fd, 500, "Internal Server Error", "

CGI produced no output

") + if (to_string(code) != "0") + this._send(fd, 500, "Internal Server Error", "

CGI failed (exit " + to_string(code) + ")

") + else + this._send(fd, 500, "Internal Server Error", "

CGI produced no output

") else this._send_cgi_response(fd, out) else // Static file content = read_file(file_path) + // print("[DBG] static path: " + file_path + ", content_len=" + to_string(len(content))) if (len(content) > 0) this._send(fd, 200, "OK", content) else