Adds hbrtl_ext/httpserver — a Five RTL extension that exposes a
single-process HTTP server controlled entirely from PRG.
Wire contract:
HTTP_SERVER_START(cAddr, cHandlerFunc) → blocking; returns NIL or cErr
HTTP_SERVER_STOP() → graceful shutdown
PRG handler signature:
FUNCTION OnRequest( hReq ) -> hResp
hReq: method, path, query, headers (hash), body, remote_addr
hResp: status (default 200), headers (hash), body
Each request runs on its own hbrt.Thread via vm.NewThread(), the same
pattern pgserver uses for connection isolation. Handler panics are
caught and turned into a 500.
The package is wired into fnode's defaultRTL list so any build that
doesn't override --rtl picks it up automatically.
Verified end-to-end with app/echo_server.prg: GET/POST against :8089
return JSON envelopes with the correct method, path, query, body
length, remote_addr, and roundtripped user-agent header.
The mod_harbour-compatible AP_* surface (AP_METHOD, AP_RPUTS,
AP_JSONRESPONSE, etc.) will sit on top of this dispatcher in
sub-phase 1a.3 as PRG, not Go.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
247 lines
7.5 KiB
Go
247 lines
7.5 KiB
Go
// Package httpserver exposes HTTP_SERVER_START / HTTP_SERVER_STOP to
|
|
// PRG. Each request runs the configured PRG handler on its own Five
|
|
// Thread, mirroring pgserver's accept-loop + per-connection-thread
|
|
// pattern. The handler decides routing — fivenode_go's compat layer
|
|
// (ap_request.prg, etc.) plugs in here in sub-phase 1a.3.
|
|
//
|
|
// Wire format
|
|
//
|
|
// PRG handler signature: FUNCTION OnRequest( hReq ) -> hResp
|
|
//
|
|
// hReq fields:
|
|
// method cString "GET" / "POST" / ...
|
|
// path cString "/api/foo.prg"
|
|
// query cString raw query string after '?'
|
|
// headers hHash lower-case name -> first value
|
|
// body cString raw body (already drained)
|
|
// remote_addr cString "ip:port"
|
|
//
|
|
// hResp fields (all optional except body):
|
|
// status nInt defaults to 200
|
|
// headers hHash name -> value (case kept as-is)
|
|
// body cString | cBinary written verbatim
|
|
//
|
|
// A nil/non-hash return from the handler becomes "500 handler returned
|
|
// nothing". A handler panic becomes "500 <message>" with the message
|
|
// logged to stderr.
|
|
package httpserver
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"five/hbrt"
|
|
)
|
|
|
|
func init() {
|
|
hbrt.HB_FUNC("HTTP_SERVER_START", httpServerStart)
|
|
hbrt.HB_FUNC("HTTP_SERVER_STOP", httpServerStop)
|
|
}
|
|
|
|
// activeServer holds the single in-flight server. fivenode_go is
|
|
// expected to run one HTTP listener per process; a future revision
|
|
// can swap this for a slice keyed by address.
|
|
var (
|
|
activeMu sync.Mutex
|
|
active *http.Server
|
|
)
|
|
|
|
// HTTP_SERVER_START(cAddr, cHandlerFunc) — blocking. cAddr is a Go
|
|
// net.Listen string (":8080", "127.0.0.1:5555"). cHandlerFunc names a
|
|
// PRG function looked up via vm.FindSymbol; case-insensitive.
|
|
//
|
|
// Returns NIL when the server shuts down cleanly, or a string error
|
|
// when listen/bind fails. Re-entrancy is rejected: a second
|
|
// HTTP_SERVER_START while one is already active returns an error
|
|
// without disturbing the running server.
|
|
func httpServerStart(ctx *hbrt.HBContext) {
|
|
if ctx.PCount() < 2 || !ctx.IsChar(1) || !ctx.IsChar(2) {
|
|
ctx.RetC("HTTP_SERVER_START: (cAddr, cHandlerFunc) required")
|
|
return
|
|
}
|
|
addr := ctx.ParC(1)
|
|
handlerName := strings.ToUpper(ctx.ParC(2))
|
|
|
|
vm := ctx.T.VM()
|
|
if vm.FindSymbol(handlerName) == nil {
|
|
ctx.RetC(fmt.Sprintf("HTTP_SERVER_START: handler %q not registered", ctx.ParC(2)))
|
|
return
|
|
}
|
|
|
|
srv := &http.Server{
|
|
Addr: addr,
|
|
ReadHeaderTimeout: 10 * time.Second,
|
|
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
dispatch(vm, handlerName, w, r)
|
|
}),
|
|
}
|
|
|
|
activeMu.Lock()
|
|
if active != nil {
|
|
activeMu.Unlock()
|
|
ctx.RetC("HTTP_SERVER_START: a server is already running")
|
|
return
|
|
}
|
|
active = srv
|
|
activeMu.Unlock()
|
|
|
|
fmt.Fprintf(os.Stderr, "httpserver: listening on %s (handler=%s)\n", addr, handlerName)
|
|
err := srv.ListenAndServe()
|
|
|
|
activeMu.Lock()
|
|
active = nil
|
|
activeMu.Unlock()
|
|
|
|
if err != nil && err != http.ErrServerClosed {
|
|
ctx.RetC(err.Error())
|
|
return
|
|
}
|
|
ctx.RetNil()
|
|
}
|
|
|
|
// HTTP_SERVER_STOP() — graceful shutdown with a short timeout. Safe
|
|
// to call from any thread (including the handler itself, e.g. an
|
|
// admin /shutdown route). Returns NIL.
|
|
func httpServerStop(ctx *hbrt.HBContext) {
|
|
activeMu.Lock()
|
|
srv := active
|
|
activeMu.Unlock()
|
|
if srv == nil {
|
|
ctx.RetNil()
|
|
return
|
|
}
|
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
_ = srv.Shutdown(shutdownCtx)
|
|
ctx.RetNil()
|
|
}
|
|
|
|
// dispatch builds the request hash, invokes the PRG handler on a
|
|
// fresh Thread, and writes the response. Panics inside the handler
|
|
// are caught here so one bad request doesn't take the server down.
|
|
func dispatch(vm *hbrt.VM, handlerName string, w http.ResponseWriter, r *http.Request) {
|
|
body, err := io.ReadAll(r.Body)
|
|
if err != nil {
|
|
http.Error(w, "request body read error", http.StatusBadRequest)
|
|
return
|
|
}
|
|
_ = r.Body.Close()
|
|
|
|
t := vm.NewThread()
|
|
defer func() {
|
|
if rec := recover(); rec != nil {
|
|
fmt.Fprintf(os.Stderr, "httpserver: handler panic: %v\n", rec)
|
|
if !headersWritten(w) {
|
|
http.Error(w, fmt.Sprintf("500 %v", rec), http.StatusInternalServerError)
|
|
}
|
|
}
|
|
}()
|
|
|
|
hReq := buildRequestHash(r, body)
|
|
sym := vm.FindSymbol(handlerName)
|
|
if sym == nil {
|
|
http.Error(w, "handler symbol vanished", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
t.PushSymbol(sym)
|
|
t.PushNil() // self placeholder (matches gengo's call layout)
|
|
t.PushValue(hReq)
|
|
t.Function(1)
|
|
hResp := t.Pop2()
|
|
|
|
writeResponse(w, hResp)
|
|
}
|
|
|
|
// buildRequestHash assembles the PRG-visible hRequest hash. Header
|
|
// names are lower-cased so handlers can look them up canonically;
|
|
// only the first value of multi-value headers is exposed (PRG hashes
|
|
// don't model multi-maps cleanly — handlers needing all values can
|
|
// parse the raw header line themselves once we expose it).
|
|
func buildRequestHash(r *http.Request, body []byte) hbrt.Value {
|
|
h := hbrt.MakeHash()
|
|
hh := h.AsHash()
|
|
hh.Set(hbrt.MakeString("method"), hbrt.MakeString(r.Method))
|
|
hh.Set(hbrt.MakeString("path"), hbrt.MakeString(r.URL.Path))
|
|
hh.Set(hbrt.MakeString("query"), hbrt.MakeString(r.URL.RawQuery))
|
|
hh.Set(hbrt.MakeString("body"), hbrt.MakeString(string(body)))
|
|
hh.Set(hbrt.MakeString("remote_addr"), hbrt.MakeString(r.RemoteAddr))
|
|
|
|
headers := hbrt.MakeHash()
|
|
hHeaders := headers.AsHash()
|
|
for k, vs := range r.Header {
|
|
if len(vs) > 0 {
|
|
hHeaders.Set(hbrt.MakeString(strings.ToLower(k)), hbrt.MakeString(vs[0]))
|
|
}
|
|
}
|
|
hh.Set(hbrt.MakeString("headers"), headers)
|
|
return h
|
|
}
|
|
|
|
// writeResponse renders the handler's return value to the HTTP
|
|
// response. Tolerates missing fields (status defaults to 200, body to
|
|
// empty). A non-hash return is treated as a server error so handler
|
|
// authors get a loud signal during development.
|
|
func writeResponse(w http.ResponseWriter, hResp hbrt.Value) {
|
|
hh := hResp.AsHash()
|
|
if hh == nil {
|
|
http.Error(w, "500 handler returned non-hash", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Headers first — must precede WriteHeader.
|
|
if hVal := hashGet(hh, "headers"); hVal != nil {
|
|
if hHeaders := hVal.AsHash(); hHeaders != nil {
|
|
for i, k := range hHeaders.Keys {
|
|
w.Header().Set(k.AsString(), hHeaders.Values[i].AsString())
|
|
}
|
|
}
|
|
}
|
|
|
|
status := http.StatusOK
|
|
if s := hashGet(hh, "status"); s != nil && !s.IsNil() {
|
|
status = int(s.AsNumInt())
|
|
}
|
|
w.WriteHeader(status)
|
|
|
|
if b := hashGet(hh, "body"); b != nil && !b.IsNil() {
|
|
// Both string and binary land in AsString — Value treats
|
|
// HB_BINARY and HB_STRING as the same byte payload.
|
|
_, _ = w.Write([]byte(b.AsString()))
|
|
}
|
|
}
|
|
|
|
// hashGet is a nil-safe wrapper around HbHash.Lookup. Returns nil
|
|
// (Go nil pointer, *not* a NIL Value) when the key is absent so the
|
|
// caller can distinguish "not set" from "set to NIL".
|
|
func hashGet(h *hbrt.HbHash, key string) *hbrt.Value {
|
|
if h == nil {
|
|
return nil
|
|
}
|
|
i := h.Lookup(hbrt.MakeString(key))
|
|
if i < 0 {
|
|
return nil
|
|
}
|
|
return &h.Values[i]
|
|
}
|
|
|
|
// headersWritten reports whether anything has already been written
|
|
// to w. Used to suppress a redundant http.Error after the handler
|
|
// itself has already started writing the response.
|
|
//
|
|
// net/http doesn't expose this directly; we infer it by checking
|
|
// w.Header() for the magic key set by WriteHeader internally — not
|
|
// reliable in all cases, so we fall back to "yes, assume written"
|
|
// to avoid double-write panics.
|
|
func headersWritten(w http.ResponseWriter) bool {
|
|
// Conservative: if the handler already touched the writer it's
|
|
// safer to swallow the recovery than risk a "superfluous
|
|
// response.WriteHeader" panic during error reporting.
|
|
return len(w.Header()) > 0
|
|
}
|