feat(napi P2): fnode capi mode — c-shared libfivenode runs real PRG

`fnode capi <entry.prg> -o libfivenode.dylib` generates all PRG as library
funcs + a cgo shim exporting the N-API C ABI, and builds buildmode=c-shared.
hb_bridge_handle_request stashes the request (read by PRG via FN_NAPI_REQ,
registered with HB_FUNC) and runs the PRG FN_HANDLE via capiVM.Run, returning
its JSON. capiEnsure calls RegisterLibModules so HB_FUNC/--rtl symbols install
into the VM (vm.Run drains libModules but not dynamicFuncs).

Verified: node → addon → Go libfivenode → PRG parses the request and runs
string/hash/JSON ops (Upper/Len/HB_ISHASH/hb_jsonDecode/Encode), round-trips a
200 response. Supersedes the P1 hand-written cmd/libfivenode (removed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
CharlesKWON
2026-06-15 22:54:09 +09:00
parent f2eada1a33
commit ce39c6c8e0
3 changed files with 157 additions and 66 deletions

16
capi/napi_handler.prg Normal file
View File

@@ -0,0 +1,16 @@
FUNCTION FN_HANDLE()
LOCAL hReq := hb_jsonDecode( FN_NAPI_REQ() )
LOCAL cPath := ""
IF HB_ISHASH( hReq )
cPath := hb_CStr( hb_HGetDef( hReq, "path", "" ) )
ENDIF
RETURN hb_jsonEncode( { ;
"status" => 200, ;
"headers" => { "Content-Type" => "application/json; charset=utf-8" }, ;
"body" => hb_jsonEncode( { ;
"msg" => "PRG ran in Go libfivenode via N-API", ;
"path" => cPath, ;
"upper" => Upper( cPath ), ;
"len" => Len( cPath ) ;
} ) ;
} )

View File

@@ -52,6 +52,8 @@ func main() {
switch os.Args[1] {
case "build":
cmdBuild(os.Args[2:])
case "capi":
cmdCapi(os.Args[2:])
case "run":
cmdRun(os.Args[2:])
case "version", "-v", "--version":
@@ -149,7 +151,7 @@ func cmdBuild(args []string) {
tmpDir := mkBuildDir()
defer cleanupBuildDir(tmpDir)
emitGeneratedSources(tmpDir, o)
emitGeneratedSources(tmpDir, o, false)
writeRootGo(tmpDir, o.rtlPkgs)
writeGoMod(tmpDir, o.goReplace, o.goModule)
@@ -158,6 +160,141 @@ func cmdBuild(args []string) {
fmt.Fprintf(os.Stderr, "Built: %s\n", absOut)
}
// cmdCapi builds a c-shared library (libfivenode) exposing the N-API C ABI
// the fivenode addon expects. All PRG is generated as library functions; the
// entry app must define FUNCTION FN_HANDLE() -> cRespJson, which reads the
// request via FN_NAPI_REQ(). The Go runtime hosts the PRG VM in-process; node
// hosts the addon. (faithful port of the C++ fivenode topology, Harbour→Go)
func cmdCapi(args []string) {
o := parseCommon(args)
if len(o.prgFiles) == 0 {
fatal("capi: no PRG files given")
}
if o.output == "" {
o.output = "libfivenode.dylib"
}
absOut, err := filepath.Abs(o.output)
if err != nil {
fatal("cannot resolve output path: " + err.Error())
}
tmpDir := mkBuildDir()
defer cleanupBuildDir(tmpDir)
emitGeneratedSources(tmpDir, o, true) // all library — no main()
writeCapiShim(tmpDir)
writeRootGo(tmpDir, o.rtlPkgs)
writeGoMod(tmpDir, o.goReplace, o.goModule)
installName := "@rpath/" + filepath.Base(absOut)
runGo(tmpDir, "mod", "tidy")
runGo(tmpDir, "build", "-buildmode=c-shared",
"-ldflags", "-extldflags=-Wl,-install_name,"+installName,
"-o", absOut, ".")
fmt.Fprintf(os.Stderr, "Built (c-shared): %s\n", absOut)
}
// writeCapiShim emits the cgo file that exports the libfivenode C ABI and
// drives the PRG VM in-process. The addon calls hb_bridge_handle_request with
// the request JSON; we stash it (read by PRG via FN_NAPI_REQ) and run the
// PRG FN_HANDLE function, returning its string result.
func writeCapiShim(tmpDir string) {
const shim = `package main
// #include <stdlib.h>
import "C"
import (
"fmt"
"sync"
"unsafe"
"five/hbrt"
"five/hbrtl"
)
var (
capiInit sync.Once
capiVM *hbrt.VM
capiMu sync.Mutex
capiReq string
capiErr string
capiNpm unsafe.Pointer
)
// FN_NAPI_REQ is registered via HB_FUNC (same mechanism as core/ext RTL) so
// PRG direct calls resolve it. RegisterDynamicFunc lands in a separate table
// that direct symbol calls don't consult.
func init() {
hbrt.HB_FUNC("FN_NAPI_REQ", func(ctx *hbrt.HBContext) {
ctx.RetC(capiReq)
})
}
func capiEnsure() {
capiInit.Do(func() {
capiVM = hbrt.NewVM()
hbrtl.RegisterRTL(capiVM)
// Install dynamic funcs (HB_FUNC from this shim + --rtl ext packages).
// vm.Run drains libModules but NOT dynamicFuncs, so do it explicitly.
capiVM.RegisterLibModules()
})
}
//export hb_bridge_init
func hb_bridge_init() C.int {
capiEnsure()
return 1
}
//export hb_bridge_shutdown
func hb_bridge_shutdown() {}
//export hb_bridge_handle_request
func hb_bridge_handle_request(req *C.char) *C.char {
capiMu.Lock()
defer capiMu.Unlock()
capiEnsure()
capiReq = C.GoString(req)
capiErr = ""
out := ""
func() {
defer func() {
if r := recover(); r != nil {
capiErr = fmt.Sprintf("%v", r)
}
}()
out = capiVM.Run("FN_HANDLE").AsString()
}()
if capiErr != "" {
out = "{\"status\":500,\"headers\":{\"Content-Type\":\"text/plain\"},\"body\":\"PRG error\"}"
}
return C.CString(out)
}
//export hb_bridge_last_error
func hb_bridge_last_error() *C.char { return C.CString(capiErr) }
//export hb_bridge_set_auth
func hb_bridge_set_auth(a *C.char) {}
//export hb_bridge_clear_auth
func hb_bridge_clear_auth() {}
//export hb_bridge_free
func hb_bridge_free(p *C.char) {
if p != nil {
C.free(unsafe.Pointer(p))
}
}
//export tfn_register_callbacks
func tfn_register_callbacks(cb unsafe.Pointer) { capiNpm = cb }
func main() {}
`
writeFile(filepath.Join(tmpDir, "capi_shim.go"), shim)
}
func cmdRun(args []string) {
o := parseCommon(args)
if len(o.prgFiles) == 0 {
@@ -166,7 +303,7 @@ func cmdRun(args []string) {
tmpDir := mkBuildDir()
defer cleanupBuildDir(tmpDir)
emitGeneratedSources(tmpDir, o)
emitGeneratedSources(tmpDir, o, false)
writeRootGo(tmpDir, o.rtlPkgs)
writeGoMod(tmpDir, o.goReplace, o.goModule)
@@ -177,7 +314,7 @@ func cmdRun(args []string) {
// emitGeneratedSources preprocesses, parses, analyzes, and emits Go for
// every PRG file. The first file is treated as the entry module
// (gengo.Generate emits a main()), the rest as library modules.
func emitGeneratedSources(tmpDir string, o buildOpts) {
func emitGeneratedSources(tmpDir string, o buildOpts, allLibrary bool) {
// Parse all files first so cross-file function references resolve.
type parsed struct {
file *ast.File
@@ -227,7 +364,7 @@ func emitGeneratedSources(tmpDir string, o buildOpts) {
}
}
var code string
if i == 0 {
if i == 0 && !allLibrary {
code = gengo.Generate(p.file)
} else {
code = gengo.GenerateLibrary(p.file)

View File

@@ -1,62 +0,0 @@
// cmd/libfivenode — Go c-shared 구현의 libfivenode (P1 스켈레톤).
//
// 기존 C++ fivenode N-API 애드온(fivenode.node)이 기대하는 C ABI 를 Go 가
// CGO //export 로 제공한다. Harbour-C 코어를 Go 런타임으로 교체하는 첫 단계.
//
// go build -buildmode=c-shared -o ../../native/output/darwin/libfivenode.dylib ./cmd/libfivenode
//
// P1 목표: 심볼 export + 애드온 로드·왕복(canned 응답). 실제 PRG 디스패치/
// npm 콜백은 P2~P4 에서.
package main
/*
#include <stdlib.h>
*/
import "C"
import "unsafe"
//export hb_bridge_init
func hb_bridge_init() C.int {
return 1
}
//export hb_bridge_shutdown
func hb_bridge_shutdown() {}
//export hb_bridge_handle_request
func hb_bridge_handle_request(reqJSON *C.char) *C.char {
// P1: 고정 응답으로 node↔Go 왕복만 증명.
_ = C.GoString(reqJSON)
resp := `{"status":200,"headers":{"Content-Type":"text/plain; charset=utf-8"},"body":"hello from Go-implemented libfivenode (N-API P1)"}`
return C.CString(resp)
}
//export hb_bridge_last_error
func hb_bridge_last_error() *C.char {
return C.CString("")
}
//export hb_bridge_set_auth
func hb_bridge_set_auth(authJSON *C.char) {}
//export hb_bridge_clear_auth
func hb_bridge_clear_auth() {}
//export hb_bridge_free
func hb_bridge_free(ptr *C.char) {
if ptr != nil {
C.free(unsafe.Pointer(ptr))
}
}
// tfn_register_callbacks — 애드온이 npm 콜백 묶음을 등록(P3 에서 저장·사용).
// P1 에선 void* 로 받아 보관만(구조체 레이아웃은 P3 에서 정의).
//
//export tfn_register_callbacks
func tfn_register_callbacks(cb unsafe.Pointer) {
npmCallbacks = cb
}
var npmCallbacks unsafe.Pointer
func main() {}