- Updated benchmark and test functions to utilize node configurations instead of pool configurations for improved clarity and consistency. - Refactored related setup functions and test cases to align with the new node-based architecture. - Adjusted error messages and documentation to reflect the transition from pool to node terminology. Made-with: Cursor
471 lines
13 KiB
Go
471 lines
13 KiB
Go
package jsapi
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"sync"
|
|
"time"
|
|
|
|
sandbox "github.com/yaoapp/yao/sandbox/v2"
|
|
wsjsapi "github.com/yaoapp/yao/workspace/jsapi"
|
|
"rogchap.com/v8go"
|
|
)
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers — shared across jsapi files
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func throwError(info *v8go.FunctionCallbackInfo, msg string) *v8go.Value {
|
|
iso := info.Context().Isolate()
|
|
e, _ := v8go.NewValue(iso, msg)
|
|
iso.ThrowException(e)
|
|
return v8go.Undefined(iso)
|
|
}
|
|
|
|
func parseStringArray(val *v8go.Value) []string {
|
|
obj, err := val.AsObject()
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
lenVal, err := obj.Get("length")
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
length := int(lenVal.Int32())
|
|
result := make([]string, 0, length)
|
|
for i := 0; i < length; i++ {
|
|
item, err := obj.GetIdx(uint32(i))
|
|
if err != nil || !item.IsString() {
|
|
continue
|
|
}
|
|
result = append(result, item.String())
|
|
}
|
|
return result
|
|
}
|
|
|
|
func parseStringMap(v8ctx *v8go.Context, val *v8go.Value) map[string]string {
|
|
result := make(map[string]string)
|
|
if !val.IsObject() {
|
|
return result
|
|
}
|
|
jsonStr, err := v8go.JSONStringify(v8ctx, val)
|
|
if err != nil {
|
|
return result
|
|
}
|
|
_ = json.Unmarshal([]byte(jsonStr), &result)
|
|
return result
|
|
}
|
|
|
|
func parseExecOptions(v8ctx *v8go.Context, args []*v8go.Value) ([]string, []sandbox.ExecOption, *v8go.Value) {
|
|
if len(args) < 1 || !args[0].IsObject() {
|
|
return nil, nil, nil
|
|
}
|
|
cmd := parseStringArray(args[0])
|
|
if len(cmd) == 0 {
|
|
return nil, nil, nil
|
|
}
|
|
var opts []sandbox.ExecOption
|
|
var callback *v8go.Value
|
|
for i := 1; i < len(args); i++ {
|
|
v := args[i]
|
|
if v.IsFunction() {
|
|
callback = v
|
|
break
|
|
}
|
|
if v.IsObject() {
|
|
optsObj, err := v.AsObject()
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if wd, e := optsObj.Get("workdir"); e == nil && wd.IsString() {
|
|
opts = append(opts, sandbox.WithWorkDir(wd.String()))
|
|
}
|
|
if env, e := optsObj.Get("env"); e == nil && env.IsObject() {
|
|
envMap := parseStringMap(v8ctx, env)
|
|
if len(envMap) > 0 {
|
|
opts = append(opts, sandbox.WithEnv(envMap))
|
|
}
|
|
}
|
|
if stdin, e := optsObj.Get("stdin"); e == nil && stdin.IsString() {
|
|
opts = append(opts, sandbox.WithStdin([]byte(stdin.String())))
|
|
}
|
|
if t, e := optsObj.Get("timeout"); e == nil && t.IsNumber() {
|
|
opts = append(opts, sandbox.WithTimeout(time.Duration(t.Number())*time.Millisecond))
|
|
}
|
|
if mo, e := optsObj.Get("max_output"); e == nil && mo.IsNumber() {
|
|
opts = append(opts, sandbox.WithMaxOutput(int64(mo.Number())))
|
|
}
|
|
}
|
|
}
|
|
return cmd, opts, callback
|
|
}
|
|
|
|
func execResultToJS(v8ctx *v8go.Context, r *sandbox.ExecResult) *v8go.Value {
|
|
data, _ := json.Marshal(map[string]interface{}{
|
|
"exit_code": r.ExitCode,
|
|
"stdout": r.Stdout,
|
|
"stderr": r.Stderr,
|
|
"duration_ms": r.DurationMs,
|
|
"error": r.Error,
|
|
"truncated": r.Truncated,
|
|
})
|
|
val, _ := v8go.JSONParse(v8ctx, string(data))
|
|
return val
|
|
}
|
|
|
|
func boxInfoToJS(v8ctx *v8go.Context, b *sandbox.BoxInfo) *v8go.Value {
|
|
data, _ := json.Marshal(map[string]interface{}{
|
|
"id": b.ID,
|
|
"container_id": b.ContainerID,
|
|
"node_id": b.NodeID,
|
|
"owner": b.Owner,
|
|
"status": b.Status,
|
|
"image": b.Image,
|
|
"vnc": b.VNC,
|
|
"policy": string(b.Policy),
|
|
"labels": b.Labels,
|
|
"created_at": b.CreatedAt.Format(time.RFC3339),
|
|
"last_active": b.LastActive.Format(time.RFC3339),
|
|
"process_count": b.ProcessCount,
|
|
})
|
|
val, _ := v8go.JSONParse(v8ctx, string(data))
|
|
return val
|
|
}
|
|
|
|
func computerInfoToJS(v8ctx *v8go.Context, c sandbox.ComputerInfo) *v8go.Value {
|
|
data, _ := json.Marshal(map[string]interface{}{
|
|
"kind": c.Kind,
|
|
"node_id": c.NodeID,
|
|
"tai_id": c.TaiID,
|
|
"machine_id": c.MachineID,
|
|
"version": c.Version,
|
|
"mode": c.Mode,
|
|
"status": c.Status,
|
|
"capabilities": c.Capabilities,
|
|
"system": map[string]interface{}{
|
|
"os": c.System.OS,
|
|
"arch": c.System.Arch,
|
|
"hostname": c.System.Hostname,
|
|
"num_cpu": c.System.NumCPU,
|
|
"total_mem": c.System.TotalMem,
|
|
},
|
|
"box_id": c.BoxID,
|
|
"container_id": c.ContainerID,
|
|
"owner": c.Owner,
|
|
"image": c.Image,
|
|
"policy": string(c.Policy),
|
|
"labels": c.Labels,
|
|
})
|
|
val, _ := v8go.JSONParse(v8ctx, string(data))
|
|
return val
|
|
}
|
|
|
|
// getComputer re-fetches a Computer from the Manager by kind + identifier.
|
|
// kind="box" → identifier is boxID, kind="host" → identifier is node ID.
|
|
func getComputer(ctx context.Context, kind, identifier string) (sandbox.Computer, error) {
|
|
m := sandbox.M()
|
|
if kind == "box" {
|
|
return m.Get(ctx, identifier)
|
|
}
|
|
return m.Host(ctx, identifier)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// sbHost — sandbox.Host(nodeID?)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func sbHost(info *v8go.FunctionCallbackInfo) *v8go.Value {
|
|
ctx := context.Background()
|
|
v8ctx := info.Context()
|
|
|
|
nodeID := ""
|
|
args := info.Args()
|
|
if len(args) > 0 && args[0].IsString() {
|
|
nodeID = args[0].String()
|
|
}
|
|
|
|
if _, err := sandbox.M().Host(ctx, nodeID); err != nil {
|
|
return throwError(info, err.Error())
|
|
}
|
|
|
|
val, err := NewComputerObject(v8ctx, "host", nodeID)
|
|
if err != nil {
|
|
return throwError(info, err.Error())
|
|
}
|
|
return val
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// NewComputerObject — unified JS Computer object factory
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// NewComputerObject creates a JS Computer object. Closures capture only
|
|
// kind (string) and identifier (string) — no Go objects cross into V8.
|
|
func NewComputerObject(v8ctx *v8go.Context, kind string, identifier string) (*v8go.Value, error) {
|
|
iso := v8ctx.Isolate()
|
|
ctx := context.Background()
|
|
|
|
// Mutable workplace binding lives in closure, not in V8 heap.
|
|
var workplaceID string
|
|
|
|
tpl := v8go.NewObjectTemplate(iso)
|
|
|
|
// -- Exec --
|
|
tpl.Set("Exec", v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value {
|
|
cmd, opts, _ := parseExecOptions(info.Context(), info.Args())
|
|
if len(cmd) == 0 {
|
|
return throwError(info, "Exec requires cmd (string[])")
|
|
}
|
|
comp, err := getComputer(ctx, kind, identifier)
|
|
if err != nil {
|
|
return throwError(info, err.Error())
|
|
}
|
|
result, err := comp.Exec(ctx, cmd, opts...)
|
|
if err != nil {
|
|
return throwError(info, err.Error())
|
|
}
|
|
return execResultToJS(info.Context(), result)
|
|
}))
|
|
|
|
// -- Stream --
|
|
tpl.Set("Stream", v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value {
|
|
cmd, opts, cbVal := parseExecOptions(info.Context(), info.Args())
|
|
if len(cmd) == 0 {
|
|
return throwError(info, "Stream requires cmd (string[]) and callback")
|
|
}
|
|
if cbVal == nil || !cbVal.IsFunction() {
|
|
return throwError(info, "Stream requires a callback function as last argument")
|
|
}
|
|
cbFn, err := cbVal.AsFunction()
|
|
if err != nil {
|
|
return throwError(info, "Stream callback is not a function")
|
|
}
|
|
comp, err := getComputer(ctx, kind, identifier)
|
|
if err != nil {
|
|
return throwError(info, err.Error())
|
|
}
|
|
stream, err := comp.Stream(ctx, cmd, opts...)
|
|
if err != nil {
|
|
return throwError(info, err.Error())
|
|
}
|
|
|
|
type chunk struct {
|
|
typ string
|
|
data interface{}
|
|
}
|
|
ch := make(chan chunk, 64)
|
|
var wg sync.WaitGroup
|
|
wg.Add(2)
|
|
go func() {
|
|
defer wg.Done()
|
|
buf := make([]byte, 4096)
|
|
for {
|
|
n, err := stream.Stdout.Read(buf)
|
|
if n > 0 {
|
|
ch <- chunk{"stdout", string(buf[:n])}
|
|
}
|
|
if err != nil {
|
|
break
|
|
}
|
|
}
|
|
}()
|
|
go func() {
|
|
defer wg.Done()
|
|
buf := make([]byte, 4096)
|
|
for {
|
|
n, err := stream.Stderr.Read(buf)
|
|
if n > 0 {
|
|
ch <- chunk{"stderr", string(buf[:n])}
|
|
}
|
|
if err != nil {
|
|
break
|
|
}
|
|
}
|
|
}()
|
|
go func() {
|
|
code, _ := stream.Wait()
|
|
wg.Wait()
|
|
ch <- chunk{"exit", code}
|
|
close(ch)
|
|
}()
|
|
|
|
v8c := info.Context()
|
|
global := v8c.Global()
|
|
for c := range ch {
|
|
var dataVal *v8go.Value
|
|
switch v := c.data.(type) {
|
|
case string:
|
|
dataVal, _ = v8go.NewValue(iso, v)
|
|
case int:
|
|
dataVal, _ = v8go.NewValue(iso, int32(v))
|
|
}
|
|
typeVal, _ := v8go.NewValue(iso, c.typ)
|
|
if typeVal != nil && dataVal != nil {
|
|
_, _ = cbFn.Call(global, typeVal, dataVal)
|
|
}
|
|
}
|
|
return v8go.Undefined(iso)
|
|
}))
|
|
|
|
// -- VNC --
|
|
tpl.Set("VNC", v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value {
|
|
comp, err := getComputer(ctx, kind, identifier)
|
|
if err != nil {
|
|
return throwError(info, err.Error())
|
|
}
|
|
url, err := comp.VNC(ctx)
|
|
if err != nil {
|
|
return throwError(info, err.Error())
|
|
}
|
|
val, _ := v8go.NewValue(iso, url)
|
|
return val
|
|
}))
|
|
|
|
// -- Proxy --
|
|
tpl.Set("Proxy", v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value {
|
|
args := info.Args()
|
|
if len(args) < 1 || !args[0].IsNumber() {
|
|
return throwError(info, "Proxy requires port (number)")
|
|
}
|
|
port := int(args[0].Int32())
|
|
path := "/"
|
|
if len(args) > 1 && args[1].IsString() {
|
|
path = args[1].String()
|
|
}
|
|
comp, err := getComputer(ctx, kind, identifier)
|
|
if err != nil {
|
|
return throwError(info, err.Error())
|
|
}
|
|
url, err := comp.Proxy(ctx, port, path)
|
|
if err != nil {
|
|
return throwError(info, err.Error())
|
|
}
|
|
val, _ := v8go.NewValue(iso, url)
|
|
return val
|
|
}))
|
|
|
|
// -- ComputerInfo --
|
|
tpl.Set("ComputerInfo", v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value {
|
|
comp, err := getComputer(ctx, kind, identifier)
|
|
if err != nil {
|
|
return throwError(info, err.Error())
|
|
}
|
|
return computerInfoToJS(info.Context(), comp.ComputerInfo())
|
|
}))
|
|
|
|
// -- BindWorkplace --
|
|
tpl.Set("BindWorkplace", v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value {
|
|
args := info.Args()
|
|
if len(args) < 1 || !args[0].IsString() {
|
|
return throwError(info, "BindWorkplace requires workspaceID (string)")
|
|
}
|
|
workplaceID = args[0].String()
|
|
comp, err := getComputer(ctx, kind, identifier)
|
|
if err != nil {
|
|
return throwError(info, err.Error())
|
|
}
|
|
comp.BindWorkplace(workplaceID)
|
|
return v8go.Undefined(iso)
|
|
}))
|
|
|
|
// -- Workplace → reuse workspace JSAPI NewFSObject --
|
|
tpl.Set("Workplace", v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value {
|
|
if workplaceID == "" {
|
|
return v8go.Null(iso)
|
|
}
|
|
val, err := wsjsapi.NewFSObject(info.Context(), workplaceID)
|
|
if err != nil {
|
|
return throwError(info, err.Error())
|
|
}
|
|
return val
|
|
}))
|
|
|
|
// -- Box-only: Info --
|
|
tpl.Set("Info", v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value {
|
|
if kind == "host" {
|
|
return throwError(info, "not supported: Info() requires a box computer")
|
|
}
|
|
comp, err := getComputer(ctx, kind, identifier)
|
|
if err != nil {
|
|
return throwError(info, err.Error())
|
|
}
|
|
box := comp.(*sandbox.Box)
|
|
bi, err := box.Info(ctx)
|
|
if err != nil {
|
|
return throwError(info, err.Error())
|
|
}
|
|
return boxInfoToJS(info.Context(), bi)
|
|
}))
|
|
|
|
// -- Box-only: Start --
|
|
tpl.Set("Start", v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value {
|
|
if kind == "host" {
|
|
return throwError(info, "not supported: Start() requires a box computer")
|
|
}
|
|
comp, err := getComputer(ctx, kind, identifier)
|
|
if err != nil {
|
|
return throwError(info, err.Error())
|
|
}
|
|
if err := comp.(*sandbox.Box).Start(ctx); err != nil {
|
|
return throwError(info, err.Error())
|
|
}
|
|
return v8go.Undefined(iso)
|
|
}))
|
|
|
|
// -- Box-only: Stop --
|
|
tpl.Set("Stop", v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value {
|
|
if kind == "host" {
|
|
return throwError(info, "not supported: Stop() requires a box computer")
|
|
}
|
|
comp, err := getComputer(ctx, kind, identifier)
|
|
if err != nil {
|
|
return throwError(info, err.Error())
|
|
}
|
|
if err := comp.(*sandbox.Box).Stop(ctx); err != nil {
|
|
return throwError(info, err.Error())
|
|
}
|
|
return v8go.Undefined(iso)
|
|
}))
|
|
|
|
// -- Box-only: Remove --
|
|
tpl.Set("Remove", v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value {
|
|
if kind == "host" {
|
|
return throwError(info, "not supported: Remove() requires a box computer")
|
|
}
|
|
comp, err := getComputer(ctx, kind, identifier)
|
|
if err != nil {
|
|
return throwError(info, err.Error())
|
|
}
|
|
if err := comp.(*sandbox.Box).Remove(ctx); err != nil {
|
|
return throwError(info, err.Error())
|
|
}
|
|
return v8go.Undefined(iso)
|
|
}))
|
|
|
|
// Instantiate and set read-only properties
|
|
obj, err := tpl.NewInstance(v8ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
obj.Set("kind", kind)
|
|
|
|
idStr := ""
|
|
ownerStr := ""
|
|
nodeIDStr := identifier
|
|
if kind == "box" {
|
|
if comp, err := getComputer(ctx, kind, identifier); err == nil {
|
|
box := comp.(*sandbox.Box)
|
|
idStr = box.ID()
|
|
ownerStr = box.Owner()
|
|
nodeIDStr = box.NodeID()
|
|
} else {
|
|
idStr = identifier
|
|
}
|
|
}
|
|
obj.Set("id", idStr)
|
|
obj.Set("owner", ownerStr)
|
|
obj.Set("node_id", nodeIDStr)
|
|
|
|
return obj.Value, nil
|
|
}
|