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:
11
app/api/admin-stats.prg
Normal file
11
app/api/admin-stats.prg
Normal 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
10
app/api/hello.prg
Normal 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
|
||||||
@@ -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 )
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
42
hbrtl_ext/dispatch/dispatch.go
Normal file
42
hbrtl_ext/dispatch/dispatch.go
Normal 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("")
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user