commit aeccfe5c48a63ed6c4caa34572aa21d50597ab7c Author: Charles KWON OhJun Date: Wed May 27 10:07:47 2026 +0900 Initial bootstrap: fnode CLI + hbrtl_ext pipeline * cmd/fnode — build/run CLI that drives Five's compiler packages (pp, parser, analyzer, gengo) and stitches generated prg_*.go together with fivenode_go's own hbrtl_ext/* packages in a temp module. Result is one self-contained Go binary; no FFI, no Node. * hbrtl_ext/hello — bootstrap RTL extension proving the blank-import-init() registration path works end-to-end. Exposes FNODE_HELLO() to PRG. * app/hello.prg — minimum end-to-end test: calls Date() (Five RTL) and FNODE_HELLO() (fivenode_go RTL) from the same binary. Verified: ./fnode build app/hello.prg -o hello_app → 17 MB single binary that prints both lines. The same pattern will host the HTTP server, bridge capi helpers, and PostgreSQL client coming in 1a.2b–1a.4. Co-Authored-By: Claude Opus 4.7 (1M context) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9d1e6cf --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +/fnode +/fnode-* +/out/ +/dist/ +*.exe +*.dll +*.so +*.dylib +.DS_Store +*.frb +*.pgo +*.log diff --git a/README.md b/README.md new file mode 100644 index 0000000..cacd17d --- /dev/null +++ b/README.md @@ -0,0 +1,41 @@ +# fivenode_go + +FiveNode for [Five](https://gitea.fivego.org/fivedev/five) — a Harbour-compatible +web framework that compiles to a **single Go binary**. No Node.js, no FFI, +no Apache. PRG sources go in, one executable comes out. + +Successor to the koffi/N-API based [fivenode](https://gitea.fivego.org/fivenode/fivenode) +framework, rebuilt on the Five Pure-Go runtime. + +## Status + +Early bootstrap — Phase 1a in progress. + +## Architecture + +``` +Browser ──── HTTP/HTTPS ──── fivenode_go single binary + ├─ Five hbrt VM (PRG interpreter / compiled) + ├─ Five hbrtl (483 standard RTL functions) + ├─ hbrtl_ext/httpserver — HTTP server RTL + ├─ hbrtl_ext/capi — bridge_*.prg helpers + ├─ hbrtl_ext/pgrtl — PostgreSQL client RTL + ├─ app/ — bridge_*.prg + app PRG + └─ go:embed — static assets +``` + +`fnode build api/*.prg --extra-rtl=hbrtl_ext/... -o myapp` produces a +self-contained binary. No external dependencies beyond what the app code +itself opens (e.g. a Postgres connection). + +## Build + +```bash +go build -o fnode ./cmd/fnode +./fnode build app/hello.prg -o hello +./hello +``` + +## License + +Copyright (c) 2026 Charles KWON OhJun. All rights reserved. diff --git a/app/hello.prg b/app/hello.prg new file mode 100644 index 0000000..1647aa6 --- /dev/null +++ b/app/hello.prg @@ -0,0 +1,7 @@ +// app/hello.prg — bootstrap end-to-end test. +// Calls a Five RTL function (Date) plus a fivenode_go-supplied RTL +// function (FNODE_HELLO) to prove both registration paths work. +FUNCTION Main() + ? "Five says :", DToC(Date()) + ? "fivenode_go :", FNODE_HELLO() +RETURN NIL diff --git a/cmd/fnode/main.go b/cmd/fnode/main.go new file mode 100644 index 0000000..18ef616 --- /dev/null +++ b/cmd/fnode/main.go @@ -0,0 +1,372 @@ +// 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" + "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", +} + +func main() { + if len(os.Args) < 2 { + printUsage() + os.Exit(1) + } + switch os.Args[1] { + case "build": + cmdBuild(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]...") + fmt.Fprintln(os.Stderr, " fnode run [-I dir]... [--rtl pkg]...") + fmt.Fprintln(os.Stderr, " fnode version") +} + +type buildOpts struct { + prgFiles []string + output string + includes []string + rtlPkgs []string +} + +func parseCommon(args []string) buildOpts { + var o buildOpts + 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++ + 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) + writeRootGo(tmpDir, o.rtlPkgs) + writeGoMod(tmpDir) + + runGo(tmpDir, "mod", "tidy") + runGo(tmpDir, "build", "-o", absOut, ".") + fmt.Fprintf(os.Stderr, "Built: %s\n", absOut) +} + +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) + writeRootGo(tmpDir, o.rtlPkgs) + writeGoMod(tmpDir) + + 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) { + // 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 + } + } + } + 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 { + code = gengo.Generate(p.file) + } else { + code = gengo.GenerateLibrary(p.file) + } + path := filepath.Join(tmpDir, fmt.Sprintf("prg_%d.go", i)) + writeFile(path, code) + } +} + +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) { + five := mustAbs(fiveRoot()) + fnode := mustAbs(fnodeRoot()) + mod := fmt.Sprintf(`module fnode_build + +go 1.21 + +require ( + five v0.0.0 + fivenode_go v0.0.0 +) + +replace five => %s + +replace fivenode_go => %s +`, five, fnode) + writeFile(filepath.Join(tmpDir, "go.mod"), mod) +} + +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 (walk up from this binary or +// from cwd until a directory with module name "five" is found). +func fiveRoot() string { + if d := walkUpForModule("five"); d != "" { + return d + } + return "../../fivedev/five" +} + +// 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) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..e9c888e --- /dev/null +++ b/go.mod @@ -0,0 +1,7 @@ +module fivenode_go + +go 1.25.0 + +require five v0.0.0 + +replace five => ../../fivedev/five diff --git a/hbrtl_ext/hello/hello.go b/hbrtl_ext/hello/hello.go new file mode 100644 index 0000000..fb977e1 --- /dev/null +++ b/hbrtl_ext/hello/hello.go @@ -0,0 +1,20 @@ +// Package hello is a bootstrap RTL extension that proves fivenode_go's +// custom-RTL pipeline works end-to-end. +// +// Blank-importing this package registers FNODE_HELLO via init() so PRG +// code in the same binary can call it without any further wiring. +package hello + +import "five/hbrt" + +func init() { + hbrt.RegisterDynamicFunc("FNODE_HELLO", fnodeHello) +} + +// FNODE_HELLO() — returns a constant greeting; bootstrap verification only. +func fnodeHello(t *hbrt.Thread) { + t.Frame(0, 0) + defer t.EndProc() + t.PushString("Hello from fivenode_go hbrtl_ext!") + t.RetValue() +}