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) <noreply@anthropic.com>
This commit is contained in:
2026-05-27 11:16:44 +09:00
parent 176f4e5cf5
commit b213f594aa
6 changed files with 161 additions and 60 deletions

11
app/api/admin-stats.prg Normal file
View File

@@ -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

10
app/api/hello.prg Normal file
View File

@@ -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

View File

@@ -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 // Translates the HTTP request hash into the ctx fields the mod_harbour
// handler written in the mod_harbour AP_* style runs inside the // AP_* layer expects, then calls the per-path Main symbol that
// fivenode_go single binary exactly as it would have run inside the // fnode auto-renamed (app/api/foo.prg → FOO__MAIN). PRG handlers in
// koffi/N-API fivenode bridge. // app/api/ stay unmodified — they keep their `FUNCTION Main()` shape
// // and use AP_METHOD/AP_BODY/AP_JSONRESPONSE/ctx_get/... freely.
// 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.
FUNCTION Main() FUNCTION Main()
LOCAL cErr := HTTP_SERVER_START( ":8090", "BRIDGEDISPATCH" ) LOCAL cErr := HTTP_SERVER_START( ":8090", "BRIDGEDISPATCH" )
@@ -16,13 +13,9 @@ FUNCTION Main()
ENDIF ENDIF
RETURN NIL RETURN NIL
// Entry point invoked by httpserver. hReq comes from
// buildRequestHash in hbrtl_ext/httpserver/server.go.
FUNCTION BridgeDispatch( hReq ) 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 := { ; hCtx := { ;
"method" => hReq[ "method" ], ; "method" => hReq[ "method" ], ;
"filename" => hReq[ "path" ], ; "filename" => hReq[ "path" ], ;
@@ -36,49 +29,59 @@ FUNCTION BridgeDispatch( hReq )
_CTX_SET_JSON( hb_jsonEncode( hCtx ) ) _CTX_SET_JSON( hb_jsonEncode( hCtx ) )
_OUT_CLEAR() _OUT_CLEAR()
// Hard-coded route dispatch. Replace with file-name dispatch in cFunc := PathToFunc( hReq[ "path" ] )
// 1a.4 once we want to land the labdb API surface unchanged. IF Empty( cFunc )
DO CASE
CASE hReq[ "path" ] == "/api/hello"
ApiHello()
CASE hReq[ "path" ] == "/api/echo"
ApiEcho()
OTHERWISE
ctx_set( "status", 404 ) ctx_set( "status", 404 )
AP_JSONRESPONSE( { "error" => "not found", "path" => hReq[ "path" ] } ) AP_JSONRESPONSE( { "error" => "no handler for path", "path" => hReq[ "path" ] } )
ENDCASE 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 { ; RETURN { ;
"status" => ctx_get( "status", 200 ), ; "status" => ctx_get( "status", 200 ), ;
"headers" => ctx_get( "headers_out", { => } ), ; "headers" => ctx_get( "headers_out", { => } ), ;
"body" => _OUT_GET() ; "body" => _OUT_GET() ;
} }
FUNCTION ApiHello() // /api/admin-stats.prg -> ADMIN_STATS__MAIN
AP_JSONRESPONSE( { ; // /api/hello.prg -> HELLO__MAIN
"ok" => .t., ; // /api/hello -> HELLO__MAIN (extension optional)
"msg" => "hello from fivenode_go bridge layer", ; // Returns "" when the path can't be mapped (caller turns into 404).
"method" => AP_METHOD(), ; FUNCTION PathToFunc( cPath )
"ip" => AP_USERIP() ; LOCAL cBase, n
} ) IF SubStr( cPath, 1, 5 ) != "/api/"
RETURN NIL 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() FUNCTION IsAlnum( c )
LOCAL cBody := AP_BODY() RETURN ( c >= "0" .AND. c <= "9" ) .OR. ;
LOCAL hPayload := IIF( Empty( cBody ), { => }, hb_jsonDecode( cBody ) ) ( c >= "A" .AND. c <= "Z" ) .OR. ;
AP_JSONRESPONSE( { ; ( c >= "a" .AND. c <= "z" )
"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
// Strip the ":port" portion of a Go RemoteAddr so AP_USERIP returns
// the bare IP the way mod_harbour does.
FUNCTION RemoteIPOnly( cAddr ) FUNCTION RemoteIPOnly( cAddr )
LOCAL n LOCAL n
IF Empty( cAddr ) IF Empty( cAddr )

View File

@@ -29,16 +29,3 @@ FUNCTION Main()
? "result:", cResult ? "result:", cResult
RETURN NIL 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

View File

@@ -38,6 +38,7 @@ var defaultRTL = []string{
"fivenode_go/hbrtl_ext/httpserver", "fivenode_go/hbrtl_ext/httpserver",
"fivenode_go/hbrtl_ext/bridge_capi", "fivenode_go/hbrtl_ext/bridge_capi",
"fivenode_go/hbrtl_ext/pgrtl", "fivenode_go/hbrtl_ext/pgrtl",
"fivenode_go/hbrtl_ext/dispatch",
} }
func main() { 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 { for i, p := range ps {
diags := analyzer.Analyze(p.file, crossFile) diags := analyzer.Analyze(p.file, crossFile)
for _, d := range diags { 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 { func parseOne(prgFile string, includes []string) *ast.File {
src, err := os.ReadFile(prgFile) src, err := os.ReadFile(prgFile)
if err != nil { if err != nil {

View File

@@ -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("")
}