// 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 }