diff --git a/CMakeLists.txt b/CMakeLists.txt index 5d7c4e3..36dc321 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,5 +1,5 @@ cmake_minimum_required(VERSION 3.10) -project(fun VERSION 0.39.6 LANGUAGES C) +project(fun VERSION 0.39.7 LANGUAGES C) set(CMAKE_C_STANDARD 99) set(CMAKE_C_STANDARD_REQUIRED ON) diff --git a/examples/blocking/http_server_cgi.fun b/examples/blocking/http_server_cgi.fun index a7c2879..402ad30 100755 --- a/examples/blocking/http_server_cgi.fun +++ b/examples/blocking/http_server_cgi.fun @@ -13,184 +13,18 @@ /* * Minimal CGI-capable HTTP server (blocking) + * + * 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?name=Fun */ -#include -#include -#include +#include port = 8080 htdocs = "./examples/data/htdocs" -// Lightweight inline HTTP server: reuse TcpServer directly so we can inject CGI env -class HTTPCGIServer(number port) - fun _construct(this, port) - this.port = port - this.srv = TcpServer(port, 10) - this.htdocs = "./" - - fun set_htdocs(this, path) - this.htdocs = to_string(path) - - fun start(this) - if (this.srv.listen() <= 0) - print("HTTP CGI Server: failed to listen on port " + to_string(this.port)) - return 0 - print("HTTP CGI Server: serving " + this.htdocs + " on port " + to_string(this.port)) - while true - fd = this.srv.accept() - if (fd > 0) - this.handle_client(fd) - return 1 - - fun _parse_headers(this, request) - headers = {} - lines = str_split(request, "\n") - i = 1 // start after request line - while (i < len(lines)) - ln = str_trim(lines[i]) - if (len(ln) == 0) - break - colon = find(ln, ":") - if (colon > 0) - k = str_to_upper(str_trim(substr(ln, 0, colon))) - v = str_trim(substr(ln, colon + 1, len(ln) - colon - 1)) - headers[k] = v - i = i + 1 - return headers - - fun handle_client(this, fd) - request = sock_recv(fd, 65536) - if (len(request) == 0) - sock_close(fd) - return 0 - - lines = str_split(request, "\n") - if (len(lines) == 0) - sock_close(fd) - return 0 - - reqline = str_trim(lines[0]) - parts = str_split(reqline, " ") - if (len(parts) < 2) - sock_close(fd) - return 0 - - method = str_trim(parts[0]) - target = str_trim(parts[1]) - - // Split query string - 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" - - file_path = this.htdocs + path - - // Headers and body - headers = this._parse_headers(request) - - // Extract POST body - body = "" - header_end = find(request, "\r\n\r\n") - if (header_end >= 0) - body = substr(request, header_end + 4, len(request) - header_end - 4) - else - header_end = find(request, "\n\n") - if (header_end >= 0) - body = substr(request, header_end + 2, len(request) - header_end - 2) - - // Serve CGI when file ends with .fun - if (str_ends_with(path, ".fun")) - // Build env vars according to CGI conventions - host = headers["HOST"] - if (len(host) == 0) host = "localhost" - ua = headers["USER-AGENT"] - cookie = headers["COOKIE"] - ctype = headers["CONTENT-TYPE"] - clen = 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 + "'") - push(envs, "QUERY_STRING='" + query + "'") - push(envs, "SCRIPT_NAME='" + path + "'") - push(envs, "PATH_INFO='" + path + "'") - push(envs, "SERVER_NAME='" + host + "'") - push(envs, "SERVER_PORT='" + to_string(this.port) + "'") - push(envs, "SERVER_PROTOCOL='HTTP/1.1'") - push(envs, "HTTP_HOST='" + host + "'") - if (len(ua) > 0) - push(envs, "HTTP_USER_AGENT='" + ua + "'") - if (len(cookie) > 0) - push(envs, "HTTP_COOKIE='" + cookie + "'") - if (len(ctype) > 0) - push(envs, "CONTENT_TYPE='" + ctype + "'") - if (len(clen) > 0) - push(envs, "CONTENT_LENGTH='" + clen + "'") - if (len(body) > 0) - push(envs, "POST_DATA='" + body + "'") - - // Decide which Fun interpreter to use for the CGI child - // Priority: - // 1) $FUN_EXEC (explicit override) - // 2) ./build_debug/fun (when running from project root) - // 3) ./build_release/fun (release build) - // 4) fun (from PATH) - exec = env("FUN_EXEC") - if (len(exec) == 0) - // crude existence check using read_file; good enough here - 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" - - // Run the Fun script as a CGI program - cmd = join(envs, " ") + " " + exec + " " + file_path - res = proc_run(cmd) - out = res["out"] - - if (len(out) == 0) - this._send(fd, 500, "Internal Server Error", "

CGI produced no output

") - else - // If CGI output already includes HTTP headers, pass through - // Otherwise, wrap it in a basic 200 response. - if (find(out, "\r\n\r\n") >= 0 || find(out, "\n\n") >= 0) - sock_send(fd, out) - else - this._send(fd, 200, "OK", out) - else - // Static file - content = read_file(file_path) - if (len(content) > 0) - this._send(fd, 200, "OK", content) - else - this._send(fd, 404, "Not Found", "

404 Not Found

") - - sock_close(fd) - return 1 - - fun _send(this, 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) - server = HTTPCGIServer(port) server.set_htdocs(htdocs) server.start() diff --git a/examples/blocking/http_server_cgi_lib.fun b/examples/blocking/http_server_cgi_lib.fun new file mode 100755 index 0000000..683b55b --- /dev/null +++ b/examples/blocking/http_server_cgi_lib.fun @@ -0,0 +1,31 @@ +#!/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-25 + */ + +/* + * Minimal blocking HTTP server that serves static files and + * handles a built-in CGI endpoint using the net/cgi.fun library. + * + * 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?name=Fun + */ + +#include + +port = 8080 +htdocs = "./examples/data/htdocs" + +server = HTTPCGILibServer(port) +server.set_htdocs(htdocs) +server.start() diff --git a/examples/data/htdocs/hello.fun b/examples/data/htdocs/hello.fun index 762253a..8db09f1 100644 --- a/examples/data/htdocs/hello.fun +++ b/examples/data/htdocs/hello.fun @@ -32,7 +32,22 @@ html = html + "

Hello, " + cgi.escape_html(name) + "!

" html = html + "

REQUEST_METHOD: " + cgi.escape_html(cgi.env["REQUEST_METHOD"]) + "

" html = html + "

QUERY_STRING: " + cgi.escape_html(cgi.env["QUERY_STRING"]) + "

" html = html + "

User-Agent cookie: " + cgi.escape_html(ua) + "

" -html = html + "

Params

" + to_string(cgi.params()) + "
" +// Render parsed CGI params as key => value(s) +pmap = cgi.params() +html = html + "

Params

    " +for k in keys(pmap) + vals = cgi.param_all(k) + // Join multiple values with ", " + i = 0 + n = len(vals) + joined = "" + while (i < n) + if (i > 0) + joined = joined + ", " + joined = joined + cgi.escape_html(vals[i]) + i = i + 1 + html = html + "
  • " + cgi.escape_html(k) + ": " + joined + "
  • " +html = html + "
" html = html + "" // Send CGI headers + body diff --git a/lib/net/cgi.fun b/lib/net/cgi.fun index 10212e1..557eaa8 100644 --- a/lib/net/cgi.fun +++ b/lib/net/cgi.fun @@ -127,6 +127,81 @@ class CGI() a = str_replace_all(a, "'", "'") return a + // Translate CGI output (headers + body) into a full HTTP/1.1 response string + // Input: raw string as emitted by a CGI script (e.g., via CGI.send()), containing + // CGI headers followed by an empty line and then the body. + // Output: a single HTTP/1.1 response string ready to be written to a socket. + fun cgi_to_http_response(this, 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 + lines = str_split(header_str, "\n") + code = 200 + text = "OK" + // Accumulate headers (excluding Status) + hh = [] // array of [k, v] + i = 0 + n = len(lines) + while (i < n) + ln = str_trim(lines[i]) + if (len(ln) > 0) + colon = find(ln, ":") + if (colon > 0) + k = str_trim(substr(ln, 0, colon)) + v = str_trim(substr(ln, colon + 1, len(ln) - colon - 1)) + if (str_to_upper(k) == "STATUS") + // Expect like: 200 OK + sp = find(v, " ") + if (sp > 0) + code = to_number(substr(v, 0, sp)) + text = str_trim(substr(v, sp + 1, len(v) - sp - 1)) + else + code = to_number(v) + if (code == 0) code = 200 + text = "OK" + else + // Keep other headers + push(hh, [k, v]) + i = i + 1 + + // Build HTTP response + b = to_string(body) + resp = "HTTP/1.1 " + to_string(code) + " " + text + "\r\n" + // Emit collected headers + j = 0 + m = len(hh) + has_len = false + while (j < m) + p = hh[j] + if (typeof(p) == "Array" && len(p) >= 2) + hk = to_string(p[0]) + hv = to_string(p[1]) + if (str_to_upper(hk) == "CONTENT-LENGTH") + has_len = true + 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 + // Minimal url-decoder: '+' -> space; %XX for ASCII printable fun url_decode(this, s) src = to_string(s) @@ -174,7 +249,7 @@ class CGI() push(out, [key, val]) i = i + 1 return out - + fun _parse_cookies(this, cookie_str) out = {} if (len(cookie_str) == 0) diff --git a/lib/net/http_cgi_lib_server.fun b/lib/net/http_cgi_lib_server.fun new file mode 100644 index 0000000..8da0df9 --- /dev/null +++ b/lib/net/http_cgi_lib_server.fun @@ -0,0 +1,185 @@ +/* + * 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-25 + */ + +// Blocking HTTP server that uses Fun interpreter to execute .fun CGI files + +#include +#include +#include + +class HTTPCGILibServer(number port) + fun _construct(this, port) + this.port = port + this.srv = TcpServer(port, 10) + this.htdocs = "./" + + fun set_htdocs(this, path) + this.htdocs = to_string(path) + + fun start(this) + if (this.srv.listen() <= 0) + print("HTTP CGI(lib) Server: failed to listen on port " + to_string(this.port)) + return 0 + print("HTTP CGI(lib) Server: serving " + this.htdocs + " on port " + to_string(this.port)) + while true + fd = this.srv.accept() + if (fd > 0) + this.handle_client(fd) + return 1 + + fun _parse_headers(this, request) + headers = {} + lines = str_split(request, "\n") + i = 1 + while (i < len(lines)) + ln = str_trim(lines[i]) + if (len(ln) == 0) + break + colon = find(ln, ":") + if (colon > 0) + k = str_to_upper(str_trim(substr(ln, 0, colon))) + v = str_trim(substr(ln, colon + 1, len(ln) - colon - 1)) + headers[k] = v + i = i + 1 + return headers + + fun handle_client(this, fd) + request = sock_recv(fd, 65536) + if (len(request) == 0) + sock_close(fd) + return 0 + + lines = str_split(request, "\n") + if (len(lines) == 0) + sock_close(fd) + return 0 + + reqline = str_trim(lines[0]) + parts = str_split(reqline, " ") + if (len(parts) < 2) + sock_close(fd) + return 0 + + method = str_trim(parts[0]) + target = str_trim(parts[1]) + + // Split query string + 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" + + file_path = this.htdocs + path + + // Headers and body + headers = this._parse_headers(request) + + // Extract POST body + body = "" + header_end = find(request, "\r\n\r\n") + if (header_end >= 0) + body = substr(request, header_end + 4, len(request) - header_end - 4) + else + header_end = find(request, "\n\n") + if (header_end >= 0) + body = substr(request, header_end + 2, len(request) - header_end - 2) + + // Treat any .fun under htdocs as a CGI script executed by the Fun interpreter + if (str_ends_with(path, ".fun")) + // Build env vars according to CGI conventions + host = headers["HOST"] + if (len(host) == 0) host = "localhost" + ua = headers["USER-AGENT"] + cookie = headers["COOKIE"] + ctype = headers["CONTENT-TYPE"] + clen = 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 + "'") + push(envs, "QUERY_STRING='" + query + "'") + push(envs, "SCRIPT_NAME='" + path + "'") + push(envs, "PATH_INFO='" + path + "'") + push(envs, "SERVER_NAME='" + host + "'") + push(envs, "SERVER_PORT='" + to_string(this.port) + "'") + push(envs, "SERVER_PROTOCOL='HTTP/1.1'") + push(envs, "HTTP_HOST='" + host + "'") + if (len(ua) > 0) + push(envs, "HTTP_USER_AGENT='" + ua + "'") + if (len(cookie) > 0) + push(envs, "HTTP_COOKIE='" + cookie + "'") + if (len(ctype) > 0) + push(envs, "CONTENT_TYPE='" + ctype + "'") + if (len(clen) > 0) + push(envs, "CONTENT_LENGTH='" + clen + "'") + if (len(body) > 0) + push(envs, "POST_DATA='" + body + "'") + + // Decide which Fun interpreter to use for the CGI child + // Priority: + // 1) $FUN_EXEC (explicit override) + // 2) ./build_debug/fun (when running from project root) + // 3) ./build_release/fun (release build) + // 4) fun (from PATH) + exec = env("FUN_EXEC") + if (len(exec) == 0) + // crude existence check using read_file; good enough here + 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" + + // Run the Fun script as a CGI program + cmd = join(envs, " ") + " " + exec + " " + file_path + res = proc_run(cmd) + out = res["out"] + + if (len(out) == 0) + 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) + if (len(content) > 0) + this._send(fd, 200, "OK", content) + else + this._send(fd, 404, "Not Found", "

404 Not Found

") + + sock_close(fd) + return 1 + + fun _send(this, 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) + + // Translate CGI output (headers + body) into a proper HTTP/1.1 response + fun _send_cgi_response(this, fd, raw) + tmpcgi = CGI() + resp = tmpcgi.cgi_to_http_response(raw) + sock_send(fd, resp) diff --git a/lib/net/http_cgi_server.fun b/lib/net/http_cgi_server.fun new file mode 100644 index 0000000..b828069 --- /dev/null +++ b/lib/net/http_cgi_server.fun @@ -0,0 +1,179 @@ +/* + * 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-25 + */ + +// Blocking HTTP server with CGI support + +#include +#include +#include + +class HTTPCGIServer(number port) + fun _construct(this, port) + this.port = port + this.srv = TcpServer(port, 10) + this.htdocs = "./" + + fun set_htdocs(this, path) + this.htdocs = to_string(path) + + fun start(this) + if (this.srv.listen() <= 0) + print("HTTP CGI Server: failed to listen on port " + to_string(this.port)) + return 0 + print("HTTP CGI Server: serving " + this.htdocs + " on port " + to_string(this.port)) + while true + fd = this.srv.accept() + if (fd > 0) + this.handle_client(fd) + return 1 + + fun _parse_headers(this, request) + headers = {} + lines = str_split(request, "\n") + i = 1 // start after request line + while (i < len(lines)) + ln = str_trim(lines[i]) + if (len(ln) == 0) + break + colon = find(ln, ":") + if (colon > 0) + k = str_to_upper(str_trim(substr(ln, 0, colon))) + v = str_trim(substr(ln, colon + 1, len(ln) - colon - 1)) + headers[k] = v + i = i + 1 + return headers + + fun handle_client(this, fd) + request = sock_recv(fd, 65536) + if (len(request) == 0) + sock_close(fd) + return 0 + + lines = str_split(request, "\n") + if (len(lines) == 0) + sock_close(fd) + return 0 + + reqline = str_trim(lines[0]) + parts = str_split(reqline, " ") + if (len(parts) < 2) + sock_close(fd) + return 0 + + method = str_trim(parts[0]) + target = str_trim(parts[1]) + + // Split query string + 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" + + file_path = this.htdocs + path + + // Headers and body + headers = this._parse_headers(request) + + // Extract POST body + body = "" + header_end = find(request, "\r\n\r\n") + if (header_end >= 0) + body = substr(request, header_end + 4, len(request) - header_end - 4) + else + header_end = find(request, "\n\n") + if (header_end >= 0) + body = substr(request, header_end + 2, len(request) - header_end - 2) + + // Serve CGI when file ends with .fun + if (str_ends_with(path, ".fun")) + // Build env vars according to CGI conventions + host = headers["HOST"] + if (len(host) == 0) host = "localhost" + ua = headers["USER-AGENT"] + cookie = headers["COOKIE"] + ctype = headers["CONTENT-TYPE"] + clen = 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 + "'") + push(envs, "QUERY_STRING='" + query + "'") + push(envs, "SCRIPT_NAME='" + path + "'") + push(envs, "PATH_INFO='" + path + "'") + push(envs, "SERVER_NAME='" + host + "'") + push(envs, "SERVER_PORT='" + to_string(this.port) + "'") + push(envs, "SERVER_PROTOCOL='HTTP/1.1'") + push(envs, "HTTP_HOST='" + host + "'") + if (len(ua) > 0) + push(envs, "HTTP_USER_AGENT='" + ua + "'") + if (len(cookie) > 0) + push(envs, "HTTP_COOKIE='" + cookie + "'") + if (len(ctype) > 0) + push(envs, "CONTENT_TYPE='" + ctype + "'") + if (len(clen) > 0) + push(envs, "CONTENT_LENGTH='" + clen + "'") + if (len(body) > 0) + push(envs, "POST_DATA='" + body + "'") + + // Decide which Fun interpreter to use for the CGI child + 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" + + // Run the Fun script as a CGI program + cmd = join(envs, " ") + " " + exec + " " + file_path + res = proc_run(cmd) + out = res["out"] + + if (len(out) == 0) + 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) + if (len(content) > 0) + this._send(fd, 200, "OK", content) + else + this._send(fd, 404, "Not Found", "

404 Not Found

") + + sock_close(fd) + return 1 + + fun _send(this, 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) + + // Translate CGI output (headers + body) into a proper HTTP/1.1 response + fun _send_cgi_response(this, fd, raw) + tmpcgi = CGI() + resp = tmpcgi.cgi_to_http_response(raw) + sock_send(fd, resp)