Files
fivenode_go/hbrtl_ext/httpserver/server.go
Charles KWON OhJun 384f957f4e 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>
2026-05-27 10:30:50 +09:00

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
}