feat(httpserver): HTTP_SERVER_START / _STOP with PRG handler dispatch
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>
This commit is contained in:
33
app/echo_server.prg
Normal file
33
app/echo_server.prg
Normal file
@@ -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 );
|
||||||
|
}
|
||||||
@@ -35,6 +35,7 @@ const version = "fnode 0.1.0 — fivenode_go builder for Five"
|
|||||||
// Order is irrelevant — each package's init() is independent.
|
// Order is irrelevant — each package's init() is independent.
|
||||||
var defaultRTL = []string{
|
var defaultRTL = []string{
|
||||||
"fivenode_go/hbrtl_ext/hello",
|
"fivenode_go/hbrtl_ext/hello",
|
||||||
|
"fivenode_go/hbrtl_ext/httpserver",
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
|||||||
246
hbrtl_ext/httpserver/server.go
Normal file
246
hbrtl_ext/httpserver/server.go
Normal file
@@ -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 <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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user