// fnode — fivenode_go build/run CLI. // // Reuses Five's compiler packages to turn PRG sources into Go, then // builds them together with fivenode_go's own hbrtl_ext/* packages into // a single self-contained binary. No FFI, no Apache, no Node.js. // // Usage: // // fnode build [file2.prg ...] [-o output] [-I includedir ...] [--rtl pkg ...] // fnode run [-I includedir ...] [--rtl pkg ...] // fnode version // // --rtl blank-imports that fivenode_go package so its init() // registers RTL functions. Default is the full hbrtl_ext set (currently // just "hello"); pass --rtl explicitly to override. package main import ( "fmt" "os" "os/exec" "path/filepath" "sort" "strings" "five/compiler/analyzer" "five/compiler/ast" "five/compiler/gengo" "five/compiler/parser" "five/compiler/pp" ) const version = "fnode 0.1.0 — fivenode_go builder for Five" // defaultRTL is the blank-import list used when --rtl is not specified. // Order is irrelevant — each package's init() is independent. var defaultRTL = []string{ "fivenode_go/hbrtl_ext/hello", "fivenode_go/hbrtl_ext/httpserver", "fivenode_go/hbrtl_ext/bridge_capi", "fivenode_go/hbrtl_ext/pgrtl", "fivenode_go/hbrtl_ext/dispatch", "fivenode_go/hbrtl_ext/labdb_state", "fivenode_go/hbrtl_ext/labdb_static", } func main() { if len(os.Args) < 2 { printUsage() os.Exit(1) } 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": fmt.Println(version) case "help", "-h", "--help": printUsage() default: fmt.Fprintf(os.Stderr, "fnode: unknown command %q\n", os.Args[1]) printUsage() os.Exit(1) } } func printUsage() { fmt.Fprintln(os.Stderr, version) fmt.Fprintln(os.Stderr, "") fmt.Fprintln(os.Stderr, "Usage:") fmt.Fprintln(os.Stderr, " fnode build ... [-o out] [-I dir]... [--rtl pkg]... [--go-replace pkg=path]...") fmt.Fprintln(os.Stderr, " fnode run [-I dir]... [--rtl pkg]... [--go-replace pkg=path]...") fmt.Fprintln(os.Stderr, " fnode version") } type buildOpts struct { prgFiles []string output string includes []string rtlPkgs []string // goReplace maps a Go module path to a local filesystem path that // gets emitted as `replace => ` in the temp go.mod. // Lets PRG #pragma BEGINDUMP blocks import private modules that // `go mod tidy` would otherwise fail to fetch (e.g. a sibling // project living outside any module proxy). goReplace map[string]string // goModule overrides the default temp-build module name // ("fnode_build"). Set this to a path inside an existing module // when the BEGINDUMP block needs to import that module's // internal/* packages — Go's internal-visibility rule only lets // callers whose own module path lives under the same parent see // internal/. // --module gitea.fivego.org/kwon_ai/solmade/_fnode_build goModule string } func parseCommon(args []string) buildOpts { o := buildOpts{goReplace: map[string]string{}} for i := 0; i < len(args); i++ { a := args[i] switch { case a == "-o" && i+1 < len(args): o.output = args[i+1] i++ case a == "-I" && i+1 < len(args): o.includes = append(o.includes, args[i+1]) i++ case strings.HasPrefix(a, "-I"): o.includes = append(o.includes, a[2:]) case a == "--rtl" && i+1 < len(args): o.rtlPkgs = append(o.rtlPkgs, args[i+1]) i++ case a == "--go-replace" && i+1 < len(args): // --go-replace gitea.fivego.org/kwon_ai/solmade=/abs/path spec := args[i+1] i++ eq := strings.IndexByte(spec, '=') if eq <= 0 || eq == len(spec)-1 { fatal("--go-replace expects pkg=path, got: " + spec) } o.goReplace[spec[:eq]] = spec[eq+1:] case a == "--module" && i+1 < len(args): o.goModule = args[i+1] i++ default: o.prgFiles = append(o.prgFiles, a) } } if len(o.rtlPkgs) == 0 { o.rtlPkgs = append([]string{}, defaultRTL...) } return o } func cmdBuild(args []string) { o := parseCommon(args) if len(o.prgFiles) == 0 { fatal("build: no PRG files given") } if o.output == "" { base := strings.TrimSuffix(filepath.Base(o.prgFiles[0]), filepath.Ext(o.prgFiles[0])) o.output = base } absOut, err := filepath.Abs(o.output) if err != nil { fatal("cannot resolve output path: " + err.Error()) } tmpDir := mkBuildDir() defer cleanupBuildDir(tmpDir) emitGeneratedSources(tmpDir, o, false) writeRootGo(tmpDir, o.rtlPkgs) writeGoMod(tmpDir, o.goReplace, o.goModule) runGo(tmpDir, "mod", "tidy") runGo(tmpDir, "build", "-o", absOut, ".") 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 import "C" import ( "fmt" "os" "strconv" "sync" "unsafe" "five/hbrt" "five/hbrtl" "fivenode_go/hbrtl_ext/napibridge" ) // VM pool — supports both serial (pool=1) and parallel (pool=N) request // handling. Each in-flight request checks out its own VM, so PRG runs // concurrently across libuv worker threads without sharing VM state. The // npm bridge (C side) gives each worker thread its own slot, so one request // awaiting npm I/O does not block others. var ( capiInit sync.Once capiPool chan *hbrt.VM capiReqMu sync.Mutex capiReqByVM = map[*hbrt.VM]string{} ) // FN_NAPI_REQ returns the request JSON for the VM running this PRG. Keyed by // VM (ctx.T.VM()) so parallel requests — each on its own pooled VM — never // see each other's request. func init() { hbrt.HB_FUNC("FN_NAPI_REQ", func(ctx *hbrt.HBContext) { vm := ctx.T.VM() capiReqMu.Lock() r := capiReqByVM[vm] capiReqMu.Unlock() ctx.RetC(r) }) } // capiPoolSize: FIVENODE_VM_POOL env (default 4). 1 = serial, N = parallel. func capiPoolSize() int { if v := os.Getenv("FIVENODE_VM_POOL"); v != "" { if n, err := strconv.Atoi(v); err == nil && n > 0 { return n } } return 4 } func capiEnsure() { capiInit.Do(func() { // Snapshot the lib registry (PRG modules + every HB_FUNC) ONCE, then // install the SAME set into each pooled VM. RegisterLibModules drains // the global registry, so without snapshotting only the first VM would // have FN_HANDLE and the bridge funcs. mods, dyns := hbrt.LibRegistrySnapshotAndDrain() n := capiPoolSize() capiPool = make(chan *hbrt.VM, n) for i := 0; i < n; i++ { vm := hbrt.NewVM() hbrtl.RegisterRTL(vm) for _, m := range mods { vm.RegisterModule(m) } for j := range dyns { s := dyns[j] vm.RegisterSymbol(&s) } capiPool <- vm } }) } //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 { capiEnsure() vm := <-capiPool defer func() { capiPool <- vm }() capiReqMu.Lock() capiReqByVM[vm] = C.GoString(req) capiReqMu.Unlock() // On request end (normal or panic): auto-release this request's npm // handles (P3, PRG may skip __end__) and clear its request slot. defer func() { napibridge.ReleaseAll(vm) capiReqMu.Lock() delete(capiReqByVM, vm) capiReqMu.Unlock() }() out := "" errStr := "" func() { defer func() { if r := recover(); r != nil { errStr = fmt.Sprintf("%v", r) } }() out = vm.Run("FN_HANDLE").AsString() }() if errStr != "" { 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("") } //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) { napibridge.StoreCallbacks(cb) } func main() {} ` writeFile(filepath.Join(tmpDir, "capi_shim.go"), shim) } func cmdRun(args []string) { o := parseCommon(args) if len(o.prgFiles) == 0 { fatal("run: no PRG file given") } tmpDir := mkBuildDir() defer cleanupBuildDir(tmpDir) emitGeneratedSources(tmpDir, o, false) writeRootGo(tmpDir, o.rtlPkgs) writeGoMod(tmpDir, o.goReplace, o.goModule) runGo(tmpDir, "mod", "tidy") runGoInteractive(tmpDir, "run", ".") } // 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, allLibrary bool) { // Parse all files first so cross-file function references resolve. type parsed struct { file *ast.File prgFile string } var ps []parsed crossFile := map[string]bool{} for _, f := range o.prgFiles { file := parseOne(f, o.includes) ps = append(ps, parsed{file: file, prgFile: f}) for _, d := range file.Decls { switch decl := d.(type) { case *ast.FuncDecl: crossFile[strings.ToUpper(decl.Name)] = true case *ast.ClassDecl: crossFile[strings.ToUpper(decl.Name)] = true } } } // 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 { if d.Severity <= analyzer.SevWarning { fmt.Fprintf(os.Stderr, "%s\n", d) } } var code string if i == 0 && !allLibrary { code = gengo.Generate(p.file) } else { code = gengo.GenerateLibrary(p.file) } path := filepath.Join(tmpDir, fmt.Sprintf("prg_%d.go", i)) writeFile(path, code) } } // 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 { fatal("cannot read " + prgFile + ": " + err.Error()) } pre := pp.New() pre.AddIncludeDir(filepath.Dir(prgFile)) pre.AddIncludeDir(filepath.Join(filepath.Dir(prgFile), "include")) pre.AddIncludeDir(filepath.Join(fiveRoot(), "include")) for _, d := range includes { pre.AddIncludeDir(d) } if hbInc := os.Getenv("HB_INC"); hbInc != "" { pre.AddIncludeDir(hbInc) } for _, p := range []string{"/usr/local/include/harbour", "/usr/include/harbour"} { if _, err := os.Stat(p); err == nil { pre.AddIncludeDir(p) } } processed, ppErrs := pre.Process(prgFile, string(src)) for _, e := range ppErrs { fmt.Fprintf(os.Stderr, "pp: %s\n", e) } if len(ppErrs) > 0 { fatal(fmt.Sprintf("%d preprocessor error(s) in %s", len(ppErrs), prgFile)) } file, parseErrs := parser.ParseWithGoDumps(prgFile, processed, pre.GoDumps) if len(parseErrs) > 0 { for _, e := range parseErrs { fmt.Fprintf(os.Stderr, "%s\n", e) } fatal(fmt.Sprintf("%d parse error(s) in %s", len(parseErrs), prgFile)) } return file } // writeRootGo writes the file that anchors the build's blank imports of // hbrtl_ext packages. Each import's init() registers its RTL functions // before the generated PRG main runs. func writeRootGo(tmpDir string, rtlPkgs []string) { var sb strings.Builder sb.WriteString("package main\n\n") if len(rtlPkgs) > 0 { sb.WriteString("import (\n") for _, p := range rtlPkgs { fmt.Fprintf(&sb, "\t_ %q\n", p) } sb.WriteString(")\n") } writeFile(filepath.Join(tmpDir, "fnode_rtl.go"), sb.String()) } func writeGoMod(tmpDir string, extraReplace map[string]string, modName string) { five := mustAbs(fiveRoot()) fnode := mustAbs(fnodeRoot()) if modName == "" { modName = "fnode_build" } var sb strings.Builder fmt.Fprintf(&sb, `module %s go 1.21 require ( five v0.0.0 fivenode_go v0.0.0 ) replace five => %s replace fivenode_go => %s `, modName, five, fnode) // Sorted iteration for stable go.mod output. keys := make([]string, 0, len(extraReplace)) for k := range extraReplace { keys = append(keys, k) } sort.Strings(keys) for _, pkg := range keys { path := extraReplace[pkg] if !filepath.IsAbs(path) { path = mustAbs(path) } fmt.Fprintf(&sb, "\nrequire %s v0.0.0\n", pkg) fmt.Fprintf(&sb, "replace %s => %s\n", pkg, path) } writeFile(filepath.Join(tmpDir, "go.mod"), sb.String()) } func mustAbs(p string) string { a, err := filepath.Abs(p) if err != nil { return p } return a } func mkBuildDir() string { dir, err := os.MkdirTemp("", "fnode-build-*") if err != nil { fatal("mkdir temp: " + err.Error()) } return dir } func cleanupBuildDir(dir string) { if os.Getenv("FNODE_KEEP_BUILD") != "" { fmt.Fprintln(os.Stderr, "[FNODE_KEEP_BUILD] keeping:", dir) return } os.RemoveAll(dir) } func runGo(dir string, args ...string) { cmd := exec.Command(goBin(), args...) cmd.Dir = dir cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr if err := cmd.Run(); err != nil { fatal("go " + strings.Join(args, " ") + " failed: " + err.Error()) } } func runGoInteractive(dir string, args ...string) { cmd := exec.Command(goBin(), args...) cmd.Dir = dir cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr if err := cmd.Run(); err != nil { if ee, ok := err.(*exec.ExitError); ok { os.Exit(ee.ExitCode()) } fatal("go " + strings.Join(args, " ") + " failed: " + err.Error()) } } func goBin() string { if p, err := exec.LookPath("go"); err == nil { return p } for _, p := range []string{"/usr/local/go/bin/go", "/usr/bin/go", "/opt/homebrew/bin/go"} { if _, err := os.Stat(p); err == nil { return p } } return "go" } // fiveRoot resolves to Five's source tree. // // Order of resolution: // 1. Walk up looking for a go.mod with `module five` (works when // fnode was launched from inside the Five source tree). // 2. Read fnodeRoot's go.mod for a `replace five => ` line — // this is the common case when fnode is invoked from somewhere // else (e.g. /Users/.../solmade) with the binary living in // fivenode_go's tree; the replace pin in fivenode_go/go.mod is // the source of truth for where Five lives. // 3. Hard-coded relative fallback, resolved against the current // working directory. Only useful when fnode is run from inside // fivenode_go itself. func fiveRoot() string { if d := walkUpForModule("five"); d != "" { return d } if p := fiveFromFnodeGoMod(); p != "" { return p } return "../../fivedev/five" } // fiveFromFnodeGoMod parses fivenode_go's own go.mod for a // // replace five => // // directive and returns the path resolved against fnodeRoot when it's // relative. Empty when not found. func fiveFromFnodeGoMod() string { fr := fnodeRoot() if fr == "" { return "" } data, err := os.ReadFile(filepath.Join(fr, "go.mod")) if err != nil { return "" } for _, raw := range strings.Split(string(data), "\n") { line := strings.TrimSpace(raw) if !strings.HasPrefix(line, "replace") { continue } // Match either `replace five => path` or `replace five v0.0.0 => path`. if !strings.Contains(line, " five ") && !strings.Contains(line, " five\t") && !strings.HasSuffix(strings.TrimSpace(strings.SplitN(line, "=>", 2)[0]), " five") { continue } parts := strings.SplitN(line, "=>", 2) if len(parts) != 2 { continue } p := strings.TrimSpace(parts[1]) if filepath.IsAbs(p) { return p } if abs, err := filepath.Abs(filepath.Join(fr, p)); err == nil { return abs } } return "" } // fnodeRoot resolves to fivenode_go's source tree. func fnodeRoot() string { if d := walkUpForModule("fivenode_go"); d != "" { return d } cwd, _ := os.Getwd() return cwd } func walkUpForModule(name string) string { starts := []string{} if cwd, err := os.Getwd(); err == nil { starts = append(starts, cwd) } if exe, err := os.Executable(); err == nil { starts = append(starts, filepath.Dir(exe)) } for _, start := range starts { dir := start for { gm := filepath.Join(dir, "go.mod") if data, err := os.ReadFile(gm); err == nil { first := strings.SplitN(string(data), "\n", 2)[0] if strings.TrimSpace(first) == "module "+name { return dir } } parent := filepath.Dir(dir) if parent == dir { break } dir = parent } } return "" } func writeFile(path, content string) { if err := os.WriteFile(path, []byte(content), 0644); err != nil { fatal("write " + path + ": " + err.Error()) } } func fatal(msg string) { fmt.Fprintln(os.Stderr, "fnode:", msg) os.Exit(1) }