From b213f594aa2b3cd7d36a743a9d24eb42560d509c Mon Sep 17 00:00:00 2001 From: Charles KWON OhJun Date: Wed, 27 May 2026 11:16:44 +0900 Subject: [PATCH] feat(dispatch): file-name routing via auto-renamed Main symbols (AOT) Lets app/api/foo.prg keep its idiomatic `FUNCTION Main()` shape while multiple such files compile into one binary. fnode auto-renames each library file's Main into a unique symbol derived from the basename: app/api/hello.prg -> HELLO__MAIN app/api/admin-stats.prg -> ADMIN_STATS__MAIN (hyphen -> underscore) Three moving parts: cmd/fnode/main.go parseOne for every PRG, then rename Main on every file except the first (the entry). crossFile map updated so the analyzer treats the renamed symbol as declared. hbrtl_ext/dispatch/dispatch.go New HB_FUNC FNODE_CALL(cFuncName) that does VM.FindSymbol + PushSymbol/Function dance and discards the return value. Same pattern pgserver's callPRG helper uses internally. app/bridge_server.prg BridgeDispatch now derives the symbol name from hReq["path"] ( /api/foo[.prg] -> FOO__MAIN ), invokes FNODE_CALL, and maps "not found" errors to HTTP 404 (other errors -> 500). Hardcoded /api/hello and /api/echo handlers replaced by the path-driven model. Verified end-to-end with app/api/hello.prg and app/api/admin-stats.prg: GET /api/hello.prg -> 200 + JSON from HELLO__MAIN GET /api/hello -> 200 (extension optional) GET /api/admin-stats.prg?from=2026 -> 200 from ADMIN_STATS__MAIN with query string echoed GET /api/nope -> 404 "function not found" Co-Authored-By: Claude Opus 4.7 (1M context) --- app/api/admin-stats.prg | 11 ++++ app/api/hello.prg | 10 ++++ app/bridge_server.prg | 97 ++++++++++++++++++---------------- app/capi_test.prg | 13 ----- cmd/fnode/main.go | 48 +++++++++++++++++ hbrtl_ext/dispatch/dispatch.go | 42 +++++++++++++++ 6 files changed, 161 insertions(+), 60 deletions(-) create mode 100644 app/api/admin-stats.prg create mode 100644 app/api/hello.prg create mode 100644 hbrtl_ext/dispatch/dispatch.go diff --git a/app/api/admin-stats.prg b/app/api/admin-stats.prg new file mode 100644 index 0000000..d0bdab7 --- /dev/null +++ b/app/api/admin-stats.prg @@ -0,0 +1,11 @@ +// app/api/admin-stats.prg — renamed to ADMIN_STATS__MAIN. Verifies +// hyphenated filenames map cleanly to underscore symbols. +FUNCTION Main() + AP_JSONRESPONSE( { ; + "ok" => .t., ; + "endpoint" => "admin-stats", ; + "symbol" => "ADMIN_STATS__MAIN", ; + "method" => AP_METHOD(), ; + "query_string" => AP_ARGS() ; + } ) +RETURN NIL diff --git a/app/api/hello.prg b/app/api/hello.prg new file mode 100644 index 0000000..9edde0f --- /dev/null +++ b/app/api/hello.prg @@ -0,0 +1,10 @@ +// app/api/hello.prg — fnode auto-renames this Main to HELLO__MAIN +// so bridge_server.prg's PathToFunc("/api/hello.prg") finds it. +FUNCTION Main() + AP_JSONRESPONSE( { ; + "ok" => .t., ; + "msg" => "hello from api/hello.prg (auto-renamed Main)", ; + "method" => AP_METHOD(), ; + "ip" => AP_USERIP() ; + } ) +RETURN NIL diff --git a/app/bridge_server.prg b/app/bridge_server.prg index fc8f3bb..f608b5e 100644 --- a/app/bridge_server.prg +++ b/app/bridge_server.prg @@ -1,13 +1,10 @@ -// app/bridge_server.prg — 1a.3-3 end-to-end glue. +// app/bridge_server.prg — file-name dispatcher. // -// Wires httpserver (Go) → bridge_capi (Go) → bridge_*.prg so a PRG -// handler written in the mod_harbour AP_* style runs inside the -// fivenode_go single binary exactly as it would have run inside the -// koffi/N-API fivenode bridge. -// -// Demo routing is hard-coded to two paths so we can curl the binary -// and see the AP_* surface working end-to-end. File-name dispatch -// (POST /api/foo.prg → app/api/foo.prg) is sub-phase 1a.4 work. +// Translates the HTTP request hash into the ctx fields the mod_harbour +// AP_* layer expects, then calls the per-path Main symbol that +// fnode auto-renamed (app/api/foo.prg → FOO__MAIN). PRG handlers in +// app/api/ stay unmodified — they keep their `FUNCTION Main()` shape +// and use AP_METHOD/AP_BODY/AP_JSONRESPONSE/ctx_get/... freely. FUNCTION Main() LOCAL cErr := HTTP_SERVER_START( ":8090", "BRIDGEDISPATCH" ) @@ -16,13 +13,9 @@ FUNCTION Main() ENDIF RETURN NIL -// Entry point invoked by httpserver. hReq comes from -// buildRequestHash in hbrtl_ext/httpserver/server.go. FUNCTION BridgeDispatch( hReq ) - LOCAL hCtx + LOCAL hCtx, cFunc, cErr - // Translate the Go-side request hash into the ctx fields the - // mod_harbour AP_* PRG layer expects to read. hCtx := { ; "method" => hReq[ "method" ], ; "filename" => hReq[ "path" ], ; @@ -36,49 +29,59 @@ FUNCTION BridgeDispatch( hReq ) _CTX_SET_JSON( hb_jsonEncode( hCtx ) ) _OUT_CLEAR() - // Hard-coded route dispatch. Replace with file-name dispatch in - // 1a.4 once we want to land the labdb API surface unchanged. - DO CASE - CASE hReq[ "path" ] == "/api/hello" - ApiHello() - CASE hReq[ "path" ] == "/api/echo" - ApiEcho() - OTHERWISE + cFunc := PathToFunc( hReq[ "path" ] ) + IF Empty( cFunc ) ctx_set( "status", 404 ) - AP_JSONRESPONSE( { "error" => "not found", "path" => hReq[ "path" ] } ) - ENDCASE + AP_JSONRESPONSE( { "error" => "no handler for path", "path" => hReq[ "path" ] } ) + ELSE + cErr := FNODE_CALL( cFunc ) + IF ! Empty( cErr ) + _OUT_CLEAR() + IF "not found" $ cErr + ctx_set( "status", 404 ) + ELSE + ctx_set( "status", 500 ) + ENDIF + AP_JSONRESPONSE( { "error" => cErr, "func" => cFunc } ) + ENDIF + ENDIF - // Assemble hResp from the buffered AP_* output. RETURN { ; "status" => ctx_get( "status", 200 ), ; "headers" => ctx_get( "headers_out", { => } ), ; "body" => _OUT_GET() ; } -FUNCTION ApiHello() - AP_JSONRESPONSE( { ; - "ok" => .t., ; - "msg" => "hello from fivenode_go bridge layer", ; - "method" => AP_METHOD(), ; - "ip" => AP_USERIP() ; - } ) -RETURN NIL +// /api/admin-stats.prg -> ADMIN_STATS__MAIN +// /api/hello.prg -> HELLO__MAIN +// /api/hello -> HELLO__MAIN (extension optional) +// Returns "" when the path can't be mapped (caller turns into 404). +FUNCTION PathToFunc( cPath ) + LOCAL cBase, n + IF SubStr( cPath, 1, 5 ) != "/api/" + RETURN "" + ENDIF + cBase := SubStr( cPath, 6 ) + IF Right( cBase, 4 ) == ".prg" + cBase := Left( cBase, Len( cBase ) - 4 ) + ENDIF + IF Empty( cBase ) + RETURN "" + ENDIF + cBase := StrTran( cBase, "-", "_" ) + // Defensive: refuse anything outside [A-Z0-9_]. + FOR n := 1 TO Len( cBase ) + IF ! ( IsAlnum( SubStr( cBase, n, 1 ) ) .OR. SubStr( cBase, n, 1 ) == "_" ) + RETURN "" + ENDIF + NEXT +RETURN Upper( cBase ) + "__MAIN" -FUNCTION ApiEcho() - LOCAL cBody := AP_BODY() - LOCAL hPayload := IIF( Empty( cBody ), { => }, hb_jsonDecode( cBody ) ) - AP_JSONRESPONSE( { ; - "ok" => .t., ; - "method" => AP_METHOD(), ; - "query" => AP_ARGS(), ; - "body_len" => Len( cBody ), ; - "body_parsed" => hPayload, ; - "user_agent" => hb_HGetDef( ctx_get( "headers_in", { => } ), "user-agent", "?" ) ; - } ) -RETURN NIL +FUNCTION IsAlnum( c ) +RETURN ( c >= "0" .AND. c <= "9" ) .OR. ; + ( c >= "A" .AND. c <= "Z" ) .OR. ; + ( c >= "a" .AND. c <= "z" ) -// Strip the ":port" portion of a Go RemoteAddr so AP_USERIP returns -// the bare IP the way mod_harbour does. FUNCTION RemoteIPOnly( cAddr ) LOCAL n IF Empty( cAddr ) diff --git a/app/capi_test.prg b/app/capi_test.prg index 9f4a2d3..307faca 100644 --- a/app/capi_test.prg +++ b/app/capi_test.prg @@ -29,16 +29,3 @@ FUNCTION Main() ? "result:", cResult RETURN NIL - -// Need ctx_get here because bridge_context.prg isn't linked in yet. -FUNCTION ctx_get( cKey, xDefault ) - LOCAL cJson := _CTX_GET_JSON() - LOCAL hCtx - IF Empty( cJson ) - RETURN xDefault - ENDIF - hb_jsonDecode( cJson, @hCtx ) - IF ValType( hCtx ) == "H" .AND. hb_HHasKey( hCtx, cKey ) - RETURN hCtx[ cKey ] - ENDIF -RETURN xDefault diff --git a/cmd/fnode/main.go b/cmd/fnode/main.go index 02b8964..2ff5380 100644 --- a/cmd/fnode/main.go +++ b/cmd/fnode/main.go @@ -38,6 +38,7 @@ var defaultRTL = []string{ "fivenode_go/hbrtl_ext/httpserver", "fivenode_go/hbrtl_ext/bridge_capi", "fivenode_go/hbrtl_ext/pgrtl", + "fivenode_go/hbrtl_ext/dispatch", } func main() { @@ -167,6 +168,28 @@ func emitGeneratedSources(tmpDir string, o buildOpts) { } } } + // Auto-rename the Main() of each library file (i > 0) to a + // unique name derived from its file basename so multiple .prg + // files that all define `FUNCTION Main()` can be linked into + // one binary. The dispatcher (FNODE_CALL or HTTP path router) + // looks up the renamed symbol by the same convention: + // app/api/admin-stats.prg → ADMIN_STATS__MAIN + for i := 1; i < len(ps); i++ { + newName := mainNameFor(ps[i].prgFile) + renameMain(ps[i].file, newName) + crossFile[newName] = true + delete(crossFile, "MAIN") // only the entry file owns MAIN + } + // Re-add MAIN if the entry file declares it (it almost always + // does); without this the analyzer would warn about the entry + // file's Main looking undeclared. + for _, d := range ps[0].file.Decls { + if fd, ok := d.(*ast.FuncDecl); ok && strings.EqualFold(fd.Name, "Main") { + crossFile["MAIN"] = true + break + } + } + for i, p := range ps { diags := analyzer.Analyze(p.file, crossFile) for _, d := range diags { @@ -185,6 +208,31 @@ func emitGeneratedSources(tmpDir string, o buildOpts) { } } +// renameMain finds the file's top-level FUNCTION Main (case-insensitive) +// and rewrites its Name to newName. Other functions / classes are left +// alone so cross-file calls keep working. Returns whether a Main was +// found and renamed. +func renameMain(f *ast.File, newName string) bool { + for _, d := range f.Decls { + if fd, ok := d.(*ast.FuncDecl); ok && strings.EqualFold(fd.Name, "Main") { + fd.Name = newName + return true + } + } + return false +} + +// mainNameFor derives the unique Main-symbol name from a .prg path. +// `app/api/admin-stats.prg` → `ADMIN_STATS__MAIN`. Hyphens become +// underscores so Harbour's identifier rules accept the result; the +// "__MAIN" suffix lets the dispatcher recognise these as entry points +// rather than helper functions. +func mainNameFor(prgPath string) string { + base := strings.TrimSuffix(filepath.Base(prgPath), filepath.Ext(prgPath)) + base = strings.ReplaceAll(base, "-", "_") + return strings.ToUpper(base) + "__MAIN" +} + func parseOne(prgFile string, includes []string) *ast.File { src, err := os.ReadFile(prgFile) if err != nil { diff --git a/hbrtl_ext/dispatch/dispatch.go b/hbrtl_ext/dispatch/dispatch.go new file mode 100644 index 0000000..0eb7dcb --- /dev/null +++ b/hbrtl_ext/dispatch/dispatch.go @@ -0,0 +1,42 @@ +// Package dispatch exposes one helper for PRG code: a dynamic +// "call this function by name" hook that pairs with fnode's auto- +// renamed Main symbols. Lets the bridge dispatcher route an HTTP +// path to the right Main without needing a hard-coded `DO CASE`. +// +// PRG surface +// +// cErr := FNODE_CALL(cFuncName) -> "" on success, error text on failure +// +// The called function runs on the same Thread; output is left in the +// bridge_capi per-thread output buffer (use _OUT_GET to read it). +// Return values from the called function are discarded — call the +// generated symbol directly when you need its return value. +package dispatch + +import ( + "strings" + + "five/hbrt" +) + +func init() { + hbrt.HB_FUNC("FNODE_CALL", fnodeCall) +} + +func fnodeCall(ctx *hbrt.HBContext) { + if ctx.PCount() < 1 || !ctx.IsChar(1) { + ctx.RetC("FNODE_CALL: function name required") + return + } + name := strings.ToUpper(ctx.ParC(1)) + sym := ctx.T.VM().FindSymbol(name) + if sym == nil { + ctx.RetC("function not found: " + ctx.ParC(1)) + return + } + ctx.T.PushSymbol(sym) + ctx.T.PushNil() // self placeholder + ctx.T.Function(0) + _ = ctx.T.Pop2() // discard return value + ctx.RetC("") +}