From 384f957f4e22979d42f1f47d50288478e1a13be3 Mon Sep 17 00:00:00 2001 From: Charles KWON OhJun Date: Wed, 27 May 2026 10:30:50 +0900 Subject: [PATCH] feat(httpserver): HTTP_SERVER_START / _STOP with PRG handler dispatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- app/echo_server.prg | 33 +++++ cmd/fnode/main.go | 1 + hbrtl_ext/httpserver/server.go | 246 +++++++++++++++++++++++++++++++++ 3 files changed, 280 insertions(+) create mode 100644 app/echo_server.prg create mode 100644 hbrtl_ext/httpserver/server.go diff --git a/app/echo_server.prg b/app/echo_server.prg new file mode 100644 index 0000000..ff3b573 --- /dev/null +++ b/app/echo_server.prg @@ -0,0 +1,33 @@ +// app/echo_server.prg — 1a.2b end-to-end test. +// +// Starts the built-in HTTP server on :8089 with OnRequest as the +// dispatcher. The handler echoes the request back as JSON so we can +// curl it and verify request parsing + response writing both work. + +FUNCTION Main() + LOCAL cErr := HTTP_SERVER_START(":8089", "OnRequest") + IF cErr != NIL + ? "httpserver:", cErr + ENDIF +RETURN NIL + +// hReq fields (set by httpserver/server.go buildRequestHash): +// method, path, query, headers (hash), body, remote_addr +// +// Reply with a JSON envelope listing every field plus a roundtripped +// header so we know the hash walk is intact. +FUNCTION OnRequest( hReq ) + LOCAL hPayload := {; + "ok" => .t.,; + "method" => hReq[ "method" ],; + "path" => hReq[ "path" ],; + "query" => hReq[ "query" ],; + "body_len" => Len( hReq[ "body" ] ),; + "remote_addr" => hReq[ "remote_addr" ],; + "user_agent" => hb_HGetDef( hReq[ "headers" ], "user-agent", "?" ); + } + RETURN {; + "status" => 200,; + "headers" => { "Content-Type" => "application/json; charset=utf-8" },; + "body" => hb_jsonEncode( hPayload ); + } diff --git a/cmd/fnode/main.go b/cmd/fnode/main.go index 18ef616..dd58ecc 100644 --- a/cmd/fnode/main.go +++ b/cmd/fnode/main.go @@ -35,6 +35,7 @@ const version = "fnode 0.1.0 — fivenode_go builder for Five" // Order is irrelevant — each package's init() is independent. var defaultRTL = []string{ "fivenode_go/hbrtl_ext/hello", + "fivenode_go/hbrtl_ext/httpserver", } func main() { diff --git a/hbrtl_ext/httpserver/server.go b/hbrtl_ext/httpserver/server.go new file mode 100644 index 0000000..65e5783 --- /dev/null +++ b/hbrtl_ext/httpserver/server.go @@ -0,0 +1,246 @@ +// 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 " 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 +}