531 lines
14 KiB
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())
|
|
}
|