package script import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "mime/multipart" "net/http" "regexp" "strings" "sync" "time" "github.com/dop251/goja" "github.com/dop251/goja_nodejs/eventloop" ) const ( DefaultTimeout = 30 * time.Second PreprocessTimeout = 10 * time.Second ) type Logger interface { Debug(msg string, args ...any) Info(msg string, args ...any) Warn(msg string, args ...any) Error(msg string, args ...any) } type Executor struct { HTTPClient *http.Client Logger Logger } type Options struct { Script string Args []any ContextData map[string]any ScriptName string PreferredEntryNames []string Timeout time.Duration HTTPClient *http.Client } type Error struct { Code string Message string } func (e *Error) Error() string { if e == nil { return "" } if strings.TrimSpace(e.Message) != "" { return e.Message } return e.Code } func (e *Error) ErrorCode() string { if e == nil || strings.TrimSpace(e.Code) == "" { return "script_error" } return e.Code } type result struct { value any err error } var ( functionDeclarationPattern = regexp.MustCompile(`(?:^|\n)\s*(?:async\s+)?function\s+([A-Za-z_$][\w$]*)\s*\(`) assignedFunctionPattern = regexp.MustCompile(`(?:^|\n)\s*(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s*)?(?:function\b|\([^)]*\)\s*=>|[A-Za-z_$][\w$]*\s*=>)`) ) func (e Executor) Execute(ctx context.Context, opts Options) (any, error) { scriptText := strings.TrimSpace(opts.Script) if scriptText == "" { return nil, &Error{Code: "script_empty", Message: "script is empty"} } scriptName := strings.TrimSpace(opts.ScriptName) if scriptName == "" { scriptName = "script" } timeout := opts.Timeout if timeout <= 0 { timeout = DefaultTimeout } ctx, cancel := context.WithTimeout(ctx, timeout) defer cancel() loop := eventloop.NewEventLoop(eventloop.EnableConsole(false)) loop.Start() defer loop.Terminate() resultCh := make(chan result, 1) var once sync.Once finish := func(value any, err error) { once.Do(func() { resultCh <- result{value: value, err: err} loop.StopNoWait() }) } ok := loop.RunOnLoop(func(vm *goja.Runtime) { e.installRuntime(ctx, loop, vm, opts.HTTPClient, scriptName) for key, value := range opts.ContextData { _ = vm.Set(key, value) } value, err := e.invoke(vm, scriptText, opts.Args, opts.PreferredEntryNames, scriptName) if err != nil { finish(nil, err) return } e.resolveValue(vm, value, finish) }) if !ok { return nil, &Error{Code: "script_runtime_error", Message: "script event loop is not available"} } select { case out := <-resultCh: if out.err != nil { return nil, out.err } return normalizeExport(out.value), nil case <-ctx.Done(): loop.Terminate() code := "script_timeout" if errors.Is(ctx.Err(), context.Canceled) { code = "script_cancelled" } return nil, &Error{Code: code, Message: fmt.Sprintf("%s exceeded %s", scriptName, timeout)} } } func (e Executor) invoke(vm *goja.Runtime, scriptText string, args []any, preferred []string, scriptName string) (goja.Value, error) { if fnValue, err := vm.RunString("(" + scriptText + ")"); err == nil { if fn, ok := goja.AssertFunction(fnValue); ok { return fn(goja.Undefined(), values(vm, args)...) } } if _, err := vm.RunString(scriptText); err != nil { return nil, &Error{Code: "script_compile_error", Message: err.Error()} } for _, name := range entryCandidates(scriptText, preferred) { fnValue, err := vm.RunString(fmt.Sprintf("(typeof %s === 'function' ? %s : undefined)", name, name)) if err != nil || goja.IsUndefined(fnValue) || goja.IsNull(fnValue) { continue } fn, ok := goja.AssertFunction(fnValue) if !ok { continue } return fn(goja.Undefined(), values(vm, args)...) } return nil, &Error{Code: "script_entry_missing", Message: fmt.Sprintf("%s must expose an executable function", scriptName)} } func (e Executor) resolveValue(vm *goja.Runtime, value goja.Value, finish func(any, error)) { if value == nil { finish(nil, nil) return } if promise, ok := value.Export().(*goja.Promise); ok { switch promise.State() { case goja.PromiseStateFulfilled: finish(exportValue(promise.Result()), nil) case goja.PromiseStateRejected: finish(nil, &Error{Code: "script_error", Message: stringify(promise.Result())}) default: obj := value.ToObject(vm) thenFn, ok := goja.AssertFunction(obj.Get("then")) if !ok { finish(nil, &Error{Code: "script_error", Message: "promise.then is not callable"}) return } onResolve := func(call goja.FunctionCall) goja.Value { finish(exportValue(call.Argument(0)), nil) return goja.Undefined() } onReject := func(call goja.FunctionCall) goja.Value { finish(nil, &Error{Code: "script_error", Message: stringify(call.Argument(0))}) return goja.Undefined() } _, _ = thenFn(obj, vm.ToValue(onResolve), vm.ToValue(onReject)) } return } finish(exportValue(value), nil) } func (e Executor) installRuntime(ctx context.Context, loop *eventloop.EventLoop, vm *goja.Runtime, client *http.Client, scriptName string) { vm.SetFieldNameMapper(goja.TagFieldNameMapper("json", true)) e.installConsole(vm, scriptName) e.installHTTP(ctx, loop, vm, firstHTTPClient(client, e.HTTPClient), scriptName) _ = vm.Set("FormData", formDataConstructor(vm)) _, _ = vm.RunString(` function __easyaiGotRequest(method, url, options) { return { json: function() { return __easyaiHTTP(method, url, options || {}).then(function(resp) { return resp.json(); }); }, text: function() { return __easyaiHTTP(method, url, options || {}).then(function(resp) { return resp.text(); }); } }; } var got = { get: function(url, options) { return __easyaiGotRequest("GET", url, options); }, post: function(url, options) { return __easyaiGotRequest("POST", url, options); }, put: function(url, options) { return __easyaiGotRequest("PUT", url, options); }, patch: function(url, options) { return __easyaiGotRequest("PATCH", url, options); }, delete: function(url, options) { return __easyaiGotRequest("DELETE", url, options); }, extend: function() { return this; } }; function fetch(url, options) { options = options || {}; return __easyaiHTTP(options.method || "GET", url, options); } `) } func (e Executor) installConsole(vm *goja.Runtime, scriptName string) { log := func(level string, args ...any) { if e.Logger == nil { return } values := make([]any, 0, len(args)+1) values = append(values, "script", scriptName) values = append(values, args...) switch level { case "error": e.Logger.Error("script console", values...) case "warn": e.Logger.Warn("script console", values...) case "info": e.Logger.Info("script console", values...) default: e.Logger.Debug("script console", values...) } } _ = vm.Set("console", map[string]any{ "log": func(args ...any) { log("debug", args...) }, "debug": func(args ...any) { log("debug", args...) }, "info": func(args ...any) { log("info", args...) }, "warn": func(args ...any) { log("warn", args...) }, "error": func(args ...any) { log("error", args...) }, }) } func (e Executor) installHTTP(ctx context.Context, loop *eventloop.EventLoop, vm *goja.Runtime, client *http.Client, scriptName string) { _ = vm.Set("__easyaiHTTP", func(call goja.FunctionCall) goja.Value { method := strings.ToUpper(strings.TrimSpace(call.Argument(0).String())) if method == "" { method = http.MethodGet } url := strings.TrimSpace(call.Argument(1).String()) options := exportMap(call.Argument(2)) promise, resolve, reject := vm.NewPromise() go func() { response, err := doHTTPRequest(ctx, client, method, url, options) loop.RunOnLoop(func(runtime *goja.Runtime) { if err != nil { _ = reject(err.Error()) return } _ = resolve(httpResponseObject(runtime, response)) }) }() return vm.ToValue(promise) }) } func doHTTPRequest(ctx context.Context, client *http.Client, method string, url string, options map[string]any) (httpScriptResponse, error) { if strings.TrimSpace(url) == "" { return httpScriptResponse{}, errors.New("url is required") } var body io.Reader headers := map[string]string{} if rawHeaders, ok := options["headers"].(map[string]any); ok { for key, value := range rawHeaders { if text := strings.TrimSpace(fmt.Sprint(value)); text != "" { headers[key] = text } } } if jsonBody, ok := options["json"]; ok { raw, err := json.Marshal(jsonBody) if err != nil { return httpScriptResponse{}, err } body = bytes.NewReader(raw) if _, ok := headers["Content-Type"]; !ok { headers["Content-Type"] = "application/json" } } else if rawBody, ok := options["body"]; ok { body, headers = requestBody(rawBody, headers) } req, err := http.NewRequestWithContext(ctx, method, url, body) if err != nil { return httpScriptResponse{}, err } for key, value := range headers { req.Header.Set(key, value) } resp, err := client.Do(req) if err != nil { return httpScriptResponse{}, err } defer resp.Body.Close() raw, _ := io.ReadAll(io.LimitReader(resp.Body, 16*1024*1024)) out := httpScriptResponse{ Status: resp.Status, StatusCode: resp.StatusCode, OK: resp.StatusCode >= 200 && resp.StatusCode < 300, Headers: map[string]any{}, Body: string(raw), } for key, values := range resp.Header { if len(values) == 1 { out.Headers[key] = values[0] } else { out.Headers[key] = values } } if len(raw) > 0 { var parsed any if json.Unmarshal(raw, &parsed) == nil { out.JSON = parsed } } return out, nil } type httpScriptResponse struct { Status string StatusCode int OK bool Headers map[string]any Body string JSON any } func httpResponseObject(vm *goja.Runtime, response httpScriptResponse) map[string]any { return map[string]any{ "status": response.StatusCode, "statusCode": response.StatusCode, "ok": response.OK, "headers": response.Headers, "text": func() string { return response.Body }, "json": func() any { if response.JSON != nil { return response.JSON } var parsed any if json.Unmarshal([]byte(response.Body), &parsed) == nil { return parsed } panic(vm.NewTypeError("response body is not valid JSON")) }, } } func requestBody(value any, headers map[string]string) (io.Reader, map[string]string) { switch typed := value.(type) { case string: return strings.NewReader(typed), headers case []byte: return bytes.NewReader(typed), headers case map[string]any: if fields, ok := typed["__easyaiFormData"].([]any); ok { var buf bytes.Buffer writer := multipart.NewWriter(&buf) for _, rawField := range fields { field, ok := rawField.(map[string]any) if !ok { continue } _ = writer.WriteField(strings.TrimSpace(fmt.Sprint(field["name"])), fmt.Sprint(field["value"])) } _ = writer.Close() headers["Content-Type"] = writer.FormDataContentType() return &buf, headers } raw, _ := json.Marshal(typed) headers["Content-Type"] = "application/json" return bytes.NewReader(raw), headers default: raw, _ := json.Marshal(typed) headers["Content-Type"] = "application/json" return bytes.NewReader(raw), headers } } func formDataConstructor(vm *goja.Runtime) func(goja.ConstructorCall) *goja.Object { return func(call goja.ConstructorCall) *goja.Object { obj := call.This _ = obj.Set("__easyaiFormData", []any{}) _ = obj.Set("append", func(name string, value any) { fields := exportSlice(obj.Get("__easyaiFormData")) fields = append(fields, map[string]any{"name": name, "value": value}) _ = obj.Set("__easyaiFormData", fields) }) return obj } } func entryCandidates(scriptText string, preferred []string) []string { values := make([]string, 0, len(preferred)+4) appendUnique := func(value string) { value = strings.TrimSpace(value) if value == "" { return } for _, existing := range values { if existing == value { return } } values = append(values, value) } for _, value := range preferred { appendUnique(value) } for _, match := range functionDeclarationPattern.FindAllStringSubmatch(scriptText, -1) { appendUnique(match[1]) } for _, match := range assignedFunctionPattern.FindAllStringSubmatch(scriptText, -1) { appendUnique(match[1]) } appendUnique("main") appendUnique("handler") return values } func values(vm *goja.Runtime, input []any) []goja.Value { out := make([]goja.Value, 0, len(input)) for _, item := range input { out = append(out, toValue(vm, item)) } return out } func toValue(vm *goja.Runtime, item any) goja.Value { if values, ok := item.(map[string]any); ok { copied := map[string]any{} for key, value := range values { if key == "__easyaiScriptContext" { continue } copied[key] = value } obj := vm.ToValue(copied).ToObject(vm) if marker, _ := values["__easyaiScriptContext"].(bool); marker { _ = obj.Set("got", vm.Get("got")) _ = obj.Set("fetch", vm.Get("fetch")) _ = obj.Set("FormData", vm.Get("FormData")) } return obj } return vm.ToValue(item) } func exportValue(value goja.Value) any { if value == nil || goja.IsUndefined(value) || goja.IsNull(value) { return nil } return value.Export() } func exportMap(value goja.Value) map[string]any { if value == nil || goja.IsUndefined(value) || goja.IsNull(value) { return map[string]any{} } if typed, ok := normalizeExport(value.Export()).(map[string]any); ok { return typed } return map[string]any{} } func exportSlice(value goja.Value) []any { if value == nil || goja.IsUndefined(value) || goja.IsNull(value) { return []any{} } if typed, ok := normalizeExport(value.Export()).([]any); ok { return typed } return []any{} } func normalizeExport(value any) any { raw, err := json.Marshal(value) if err != nil { return value } var out any if json.Unmarshal(raw, &out) != nil { return value } return out } func firstHTTPClient(values ...*http.Client) *http.Client { for _, value := range values { if value != nil { return value } } return http.DefaultClient } func stringify(value goja.Value) string { if value == nil || goja.IsUndefined(value) || goja.IsNull(value) { return "script rejected" } if exported, ok := normalizeExport(value.Export()).(map[string]any); ok { for _, key := range []string{"message", "error", "error_message"} { if message := strings.TrimSpace(fmt.Sprint(exported[key])); message != "" && message != "" { return message } } } return strings.TrimSpace(value.String()) }