diff --git a/hbrtl_ext/nodebridge/nodebridge.go b/hbrtl_ext/nodebridge/nodebridge.go new file mode 100644 index 0000000..deae3a2 --- /dev/null +++ b/hbrtl_ext/nodebridge/nodebridge.go @@ -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) +} diff --git a/hbrtl_ext/nodertl/nodertl.go b/hbrtl_ext/nodertl/nodertl.go deleted file mode 100644 index 669d3c1..0000000 --- a/hbrtl_ext/nodertl/nodertl.go +++ /dev/null @@ -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 ──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())) -}