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) <noreply@anthropic.com>
This commit is contained in:
12
.gitignore
vendored
Normal file
12
.gitignore
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
/fnode
|
||||
/fnode-*
|
||||
/out/
|
||||
/dist/
|
||||
*.exe
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
.DS_Store
|
||||
*.frb
|
||||
*.pgo
|
||||
*.log
|
||||
41
README.md
Normal file
41
README.md
Normal file
@@ -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.
|
||||
7
app/hello.prg
Normal file
7
app/hello.prg
Normal file
@@ -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
|
||||
372
cmd/fnode/main.go
Normal file
372
cmd/fnode/main.go
Normal file
@@ -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 <file.prg> [file2.prg ...] [-o output] [-I includedir ...] [--rtl pkg ...]
|
||||
// fnode run <file.prg> [-I includedir ...] [--rtl pkg ...]
|
||||
// fnode version
|
||||
//
|
||||
// --rtl <pkg> 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 <file.prg>... [-o out] [-I dir]... [--rtl pkg]...")
|
||||
fmt.Fprintln(os.Stderr, " fnode run <file.prg> [-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)
|
||||
}
|
||||
7
go.mod
Normal file
7
go.mod
Normal file
@@ -0,0 +1,7 @@
|
||||
module fivenode_go
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require five v0.0.0
|
||||
|
||||
replace five => ../../fivedev/five
|
||||
20
hbrtl_ext/hello/hello.go
Normal file
20
hbrtl_ext/hello/hello.go
Normal file
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user