feat(api): migrate media clients and universal scripts
This commit is contained in:
@@ -0,0 +1,530 @@
|
||||
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())
|
||||
}
|
||||
Reference in New Issue
Block a user