easyai-ai-gateway/apps/api/internal/script/executor.go

531 lines
14 KiB
Go

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 != "<nil>" {
return message
}
}
}
return strings.TrimSpace(value.String())
}