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

View File

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