feat(rtl): nodebridge — handle-based Node module bridge in pure Go
Faithful Go port of the C++ fivenode Require() mechanism: persistent node sidecar + JSON handle protocol (require→handle, call→method with auto-await Promise, end), driven by pure-Go glue (os/exec + stdio). No C/CGO, not `node -e` string eval. NODE_PATH points at the app's node_modules. PRG: FN_REQUIRE / FN_CALL / FN_END / FN_LASTERROR. Replaces the earlier nodertl (NODE_EVAL string-eval), removed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
275
hbrtl_ext/nodebridge/nodebridge.go
Normal file
275
hbrtl_ext/nodebridge/nodebridge.go
Normal file
@@ -0,0 +1,275 @@
|
||||
// hbrtl_ext/nodebridge — fivenode 의 Node 모듈 브리지를 순수 Go 로 구현.
|
||||
//
|
||||
// 기존 C++ fivenode 는 Harbour ⇄ C 글루 ⇄ Node(npm) 구조였다(JS 는 항상
|
||||
// 진짜 Node 에서 실행, C 는 JSON 을 주고받는 글루일 뿐). FIVE 는 C→Go 재구현
|
||||
// 이므로 그 'C 글루'를 'Go 글루'로 바꾼다:
|
||||
//
|
||||
// Five(Go) ⇄ 이 RTL(Go, os/exec+stdio JSON) ⇄ node bridge.js ⇄ npm
|
||||
//
|
||||
// node -e 문자열 평가가 아니라, C++ 판과 동일한 '핸들 기반' 프로토콜
|
||||
// (require→handle, call→메서드 호출[Promise 자동 await], end)을 쓴다.
|
||||
// node 프로세스는 한 번만 띄워 상주시킨다. C/CGO 한 줄 없음.
|
||||
//
|
||||
// PRG surface:
|
||||
// FN_REQUIRE(cModule) -> nHandle (-1 on error)
|
||||
// FN_CALL(nHandle, cMethod[, cArgsJson]) -> cJson {"ok","type","value","err"}
|
||||
// FN_END(nHandle) -> NIL
|
||||
// FN_LASTERROR() -> cErr
|
||||
package nodebridge
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"five/hbrt"
|
||||
)
|
||||
|
||||
func init() {
|
||||
hbrt.HB_FUNC("FN_REQUIRE", fnRequire)
|
||||
hbrt.HB_FUNC("FN_CALL", fnCall)
|
||||
hbrt.HB_FUNC("FN_END", fnEnd)
|
||||
hbrt.HB_FUNC("FN_LASTERROR", fnLastError)
|
||||
}
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
proc *exec.Cmd
|
||||
stdin *bufio.Writer
|
||||
stdout *bufio.Scanner
|
||||
lastErr string
|
||||
bridgeJS = `
|
||||
'use strict';
|
||||
const handles = new Map(); let next = 1;
|
||||
function out(o){ process.stdout.write(JSON.stringify(o) + "\n"); }
|
||||
function enc(v){
|
||||
if (v === null || v === undefined) return { t: "null" };
|
||||
if (Buffer.isBuffer(v)) return { t: "buffer", v: v.toString("base64") };
|
||||
const tp = typeof v;
|
||||
if (tp === "string") return { t: "string", v: v };
|
||||
if (tp === "number") return { t: "number", v: String(v) };
|
||||
if (tp === "boolean") return { t: "boolean", v: v ? "true" : "false" };
|
||||
try { return { t: "json", v: JSON.stringify(v) }; } catch (e) { return { t: "string", v: String(v) }; }
|
||||
}
|
||||
async function handle(req){
|
||||
try {
|
||||
if (req.cmd === "require") {
|
||||
const m = require(req.module);
|
||||
const h = next++; handles.set(h, m);
|
||||
return { ok: true, handle: h };
|
||||
}
|
||||
if (req.cmd === "call") {
|
||||
const obj = handles.get(req.handle);
|
||||
if (obj === undefined) return { ok: false, err: "invalid handle" };
|
||||
const fn = obj[req.method];
|
||||
let val;
|
||||
if (typeof fn !== "function") {
|
||||
val = obj[req.method]; // property access
|
||||
} else {
|
||||
const args = req.args || [];
|
||||
if (/^[A-Z]/.test(req.method)) val = new fn(...args); // constructor
|
||||
else val = fn.apply(obj, args);
|
||||
}
|
||||
if (val && typeof val.then === "function") val = await val; // auto-await Promise
|
||||
return { ok: true, value: enc(val) };
|
||||
}
|
||||
if (req.cmd === "end") { handles.delete(req.handle); return { ok: true }; }
|
||||
return { ok: false, err: "unknown cmd" };
|
||||
} catch (e) { return { ok: false, err: String((e && e.message) || e) }; }
|
||||
}
|
||||
let buf = "";
|
||||
process.stdin.on("data", async (d) => {
|
||||
buf += d;
|
||||
let i;
|
||||
while ((i = buf.indexOf("\n")) >= 0) {
|
||||
const line = buf.slice(0, i); buf = buf.slice(i + 1);
|
||||
if (!line.trim()) continue;
|
||||
let req; try { req = JSON.parse(line); } catch (e) { out({ ok: false, err: "bad json" }); continue; }
|
||||
out(await handle(req));
|
||||
}
|
||||
});
|
||||
console.log = (...a) => process.stderr.write(a.join(" ") + "\n"); // keep stdout clean
|
||||
process.stdin.resume();
|
||||
`
|
||||
)
|
||||
|
||||
func nodeBin() string {
|
||||
if p, err := exec.LookPath("node"); err == nil {
|
||||
return p
|
||||
}
|
||||
for _, p := range []string{"/opt/homebrew/bin/node", "/usr/local/bin/node", "/usr/bin/node"} {
|
||||
if _, err := os.Stat(p); err == nil {
|
||||
return p
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func workDir() string {
|
||||
if d := os.Getenv("SOLMADE_NODE_DIR"); d != "" {
|
||||
return d
|
||||
}
|
||||
return "/Users/charleskwon/solmade/node"
|
||||
}
|
||||
|
||||
// ensure starts the persistent node bridge once. Caller holds mu.
|
||||
func ensure() bool {
|
||||
if proc != nil {
|
||||
return true
|
||||
}
|
||||
bin := nodeBin()
|
||||
if bin == "" {
|
||||
lastErr = "node not found"
|
||||
return false
|
||||
}
|
||||
dir := workDir()
|
||||
jsPath := filepath.Join(os.TempDir(), "fivenode_bridge.js")
|
||||
if err := os.WriteFile(jsPath, []byte(bridgeJS), 0o644); err != nil {
|
||||
lastErr = "write bridge: " + err.Error()
|
||||
return false
|
||||
}
|
||||
cmd := exec.Command(bin, jsPath)
|
||||
cmd.Dir = dir
|
||||
// require() 는 스크립트 위치 기준이라 /tmp 의 bridge.js 에선 node_modules 를
|
||||
// 못 찾는다 → NODE_PATH 로 작업 디렉터리의 node_modules 를 지정.
|
||||
cmd.Env = append(os.Environ(), "NODE_PATH="+filepath.Join(dir, "node_modules"))
|
||||
cmd.Stderr = os.Stderr
|
||||
wp, err := cmd.StdinPipe()
|
||||
if err != nil {
|
||||
lastErr = err.Error()
|
||||
return false
|
||||
}
|
||||
rp, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
lastErr = err.Error()
|
||||
return false
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
lastErr = "start node: " + err.Error()
|
||||
return false
|
||||
}
|
||||
proc = cmd
|
||||
stdin = bufio.NewWriter(wp)
|
||||
stdout = bufio.NewScanner(rp)
|
||||
stdout.Buffer(make([]byte, 0, 64*1024), 8*1024*1024) // allow large results
|
||||
return true
|
||||
}
|
||||
|
||||
func reset() {
|
||||
if proc != nil {
|
||||
_ = proc.Process.Kill()
|
||||
}
|
||||
proc, stdin, stdout = nil, nil, nil
|
||||
}
|
||||
|
||||
// rpc sends one request and reads one response line. Caller holds mu.
|
||||
func rpc(req map[string]interface{}) (map[string]interface{}, bool) {
|
||||
if !ensure() {
|
||||
return nil, false
|
||||
}
|
||||
b, _ := json.Marshal(req)
|
||||
b = append(b, '\n')
|
||||
if _, err := stdin.Write(b); err != nil {
|
||||
lastErr = "write: " + err.Error()
|
||||
reset()
|
||||
return nil, false
|
||||
}
|
||||
if err := stdin.Flush(); err != nil {
|
||||
lastErr = "flush: " + err.Error()
|
||||
reset()
|
||||
return nil, false
|
||||
}
|
||||
if !stdout.Scan() {
|
||||
lastErr = "node bridge closed"
|
||||
reset()
|
||||
return nil, false
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &resp); err != nil {
|
||||
lastErr = "bad response: " + err.Error()
|
||||
return nil, false
|
||||
}
|
||||
return resp, true
|
||||
}
|
||||
|
||||
// FN_REQUIRE(cModule) -> nHandle (-1 on error)
|
||||
func fnRequire(ctx *hbrt.HBContext) {
|
||||
if ctx.PCount() < 1 || !ctx.IsChar(1) {
|
||||
ctx.RetNI(-1)
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
resp, ok := rpc(map[string]interface{}{"cmd": "require", "module": ctx.ParC(1)})
|
||||
if !ok || resp["ok"] != true {
|
||||
if ok {
|
||||
lastErr = str(resp["err"])
|
||||
}
|
||||
ctx.RetNI(-1)
|
||||
return
|
||||
}
|
||||
if h, ok := resp["handle"].(float64); ok {
|
||||
ctx.RetNI(int(h))
|
||||
return
|
||||
}
|
||||
ctx.RetNI(-1)
|
||||
}
|
||||
|
||||
// FN_CALL(nHandle, cMethod[, cArgsJson]) -> {"ok","type","value","err"} JSON
|
||||
func fnCall(ctx *hbrt.HBContext) {
|
||||
if ctx.PCount() < 2 || !ctx.IsNum(1) || !ctx.IsChar(2) {
|
||||
ctx.RetC(envelope(false, "", "", "FN_CALL(nHandle, cMethod[, cArgsJson])"))
|
||||
return
|
||||
}
|
||||
handle := ctx.ParNI(1)
|
||||
method := ctx.ParC(2)
|
||||
var args interface{}
|
||||
if ctx.PCount() >= 3 && ctx.IsChar(3) {
|
||||
_ = json.Unmarshal([]byte(ctx.ParC(3)), &args)
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
resp, ok := rpc(map[string]interface{}{"cmd": "call", "handle": handle, "method": method, "args": args})
|
||||
if !ok {
|
||||
ctx.RetC(envelope(false, "", "", lastErr))
|
||||
return
|
||||
}
|
||||
if resp["ok"] != true {
|
||||
ctx.RetC(envelope(false, "", "", str(resp["err"])))
|
||||
return
|
||||
}
|
||||
val, _ := resp["value"].(map[string]interface{})
|
||||
ctx.RetC(envelope(true, str(val["t"]), str(val["v"]), ""))
|
||||
}
|
||||
|
||||
// FN_END(nHandle)
|
||||
func fnEnd(ctx *hbrt.HBContext) {
|
||||
if ctx.PCount() >= 1 && ctx.IsNum(1) {
|
||||
mu.Lock()
|
||||
_, _ = rpc(map[string]interface{}{"cmd": "end", "handle": ctx.ParNI(1)})
|
||||
mu.Unlock()
|
||||
}
|
||||
ctx.RetNil()
|
||||
}
|
||||
|
||||
func fnLastError(ctx *hbrt.HBContext) {
|
||||
mu.Lock()
|
||||
e := lastErr
|
||||
mu.Unlock()
|
||||
ctx.RetC(e)
|
||||
}
|
||||
|
||||
func str(v interface{}) string {
|
||||
if s, ok := v.(string); ok {
|
||||
return s
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func envelope(ok bool, t, v, err string) string {
|
||||
b, _ := json.Marshal(map[string]interface{}{"ok": ok, "type": t, "value": v, "err": err})
|
||||
return string(b)
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
// hbrtl_ext/nodertl — 순수 Go 로 Node.js 서브프로세스를 호출해 npm 생태계를
|
||||
// PRG 에서 쓰게 하는 fivenode 런타임 RTL. C/CGO/Harbour 네이티브 한 줄 없이
|
||||
// (Five = C→Go 재구현 철학 유지) Node 의 전체 라이브러리 생태계에 접근한다.
|
||||
//
|
||||
// Five(Go 바이너리) ──exec──▶ node -e <js> ──require──▶ npm 모듈
|
||||
//
|
||||
// PRG surface:
|
||||
// NODE_EVAL(cJs [, cWorkdir [, cInput]]) -> cJson
|
||||
// cJs : 실행할 JS (결과를 process.stdout 으로 출력)
|
||||
// cWorkdir : require() 해석 기준 디렉터리(node_modules 위치). 비우면
|
||||
// 환경변수 SOLMADE_NODE_DIR 사용.
|
||||
// cInput : 사용자 입력. JS 에 코드로 박지 말고 env FIVE_NODE_INPUT 으로
|
||||
// 전달된다(코드 인젝션 방지). JS 에서 process.env.FIVE_NODE_INPUT 로 읽음.
|
||||
// 반환: {"ok":bool,"out":string,"err":string} JSON 문자열.
|
||||
package nodertl
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"os/exec"
|
||||
|
||||
"five/hbrt"
|
||||
)
|
||||
|
||||
func init() {
|
||||
hbrt.HB_FUNC("NODE_EVAL", nodeEval)
|
||||
}
|
||||
|
||||
// nodeBin: PATH 에 node 가 없어도(예: launchd 의 좁은 PATH) 흔한 위치를 폴백.
|
||||
func nodeBin() string {
|
||||
if p, err := exec.LookPath("node"); err == nil {
|
||||
return p
|
||||
}
|
||||
for _, p := range []string{"/opt/homebrew/bin/node", "/usr/local/bin/node", "/usr/bin/node"} {
|
||||
if _, err := os.Stat(p); err == nil {
|
||||
return p
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func resJSON(ok bool, out, errs string) string {
|
||||
b, _ := json.Marshal(map[string]interface{}{"ok": ok, "out": out, "err": errs})
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func nodeEval(ctx *hbrt.HBContext) {
|
||||
if ctx.PCount() < 1 || !ctx.IsChar(1) {
|
||||
ctx.RetC(resJSON(false, "", "NODE_EVAL(cJs[,cWorkdir[,cInput]]) — missing js"))
|
||||
return
|
||||
}
|
||||
js := ctx.ParC(1)
|
||||
|
||||
workdir := ""
|
||||
if ctx.PCount() >= 2 && ctx.IsChar(2) {
|
||||
workdir = ctx.ParC(2)
|
||||
}
|
||||
if workdir == "" {
|
||||
workdir = os.Getenv("SOLMADE_NODE_DIR")
|
||||
}
|
||||
|
||||
input := ""
|
||||
if ctx.PCount() >= 3 && ctx.IsChar(3) {
|
||||
input = ctx.ParC(3)
|
||||
}
|
||||
|
||||
bin := nodeBin()
|
||||
if bin == "" {
|
||||
ctx.RetC(resJSON(false, "", "node not found (install node; or set PATH)"))
|
||||
return
|
||||
}
|
||||
|
||||
cmd := exec.Command(bin, "-e", js)
|
||||
if workdir != "" {
|
||||
cmd.Dir = workdir
|
||||
}
|
||||
cmd.Env = append(os.Environ(), "FIVE_NODE_INPUT="+input)
|
||||
|
||||
var out, errb bytes.Buffer
|
||||
cmd.Stdout = &out
|
||||
cmd.Stderr = &errb
|
||||
runErr := cmd.Run()
|
||||
|
||||
ctx.RetC(resJSON(runErr == nil, out.String(), errb.String()))
|
||||
}
|
||||
Reference in New Issue
Block a user