mirror of
https://github.com/aykhans/sarin.git
synced 2026-10-13 21:28:52 +00:00
324 lines
9.5 KiB
Go
324 lines
9.5 KiB
Go
package script
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"reflect"
|
|
"strconv"
|
|
|
|
"github.com/dop251/goja"
|
|
"go.aykhans.me/sarin/internal/types"
|
|
)
|
|
|
|
// JsEngine implements the Engine interface using goja (JavaScript).
|
|
type JsEngine struct {
|
|
runtime *goja.Runtime
|
|
transform goja.Callable
|
|
jsonParse goja.Callable
|
|
// errorConstructor is the built-in Error, captured before the script can replace it.
|
|
errorConstructor goja.Value
|
|
http httpBridge
|
|
}
|
|
|
|
// promiseType is what a transform returns when it is declared async or as a generator.
|
|
var promiseType = reflect.TypeFor[*goja.Promise]()
|
|
|
|
// NewJsEngine creates a new JavaScript script engine with the given script content.
|
|
// The script must define a global `transform` function that takes a request object
|
|
// and returns the modified request object.
|
|
//
|
|
// Example JavaScript script:
|
|
//
|
|
// function transform(req) {
|
|
// req.headers["X-Custom"] = ["value"];
|
|
// return req;
|
|
// }
|
|
//
|
|
// It can return the following errors:
|
|
// - types.ErrScriptTransformMissing
|
|
// - types.ScriptExecutionError
|
|
func NewJsEngine(scriptContent string) (*JsEngine, error) {
|
|
vm := goja.New()
|
|
engine := &JsEngine{runtime: vm}
|
|
|
|
// Register the globals before running the script so its functions can use them
|
|
if err := engine.registerHTTP(); err != nil {
|
|
return nil, types.NewScriptExecutionError("JavaScript", err)
|
|
}
|
|
|
|
// Execute the script to define the transform function
|
|
if _, err := vm.RunString(scriptContent); err != nil {
|
|
return nil, types.NewScriptExecutionError("JavaScript", engine.exceptionError(err))
|
|
}
|
|
|
|
// Get the transform function
|
|
transformVal := vm.Get("transform")
|
|
if transformVal == nil || goja.IsUndefined(transformVal) || goja.IsNull(transformVal) {
|
|
return nil, types.ErrScriptTransformMissing
|
|
}
|
|
|
|
transform, ok := goja.AssertFunction(transformVal)
|
|
if !ok {
|
|
return nil, types.NewScriptExecutionError("JavaScript", errors.New("'transform' must be a function"))
|
|
}
|
|
|
|
// A generator returns an iterator, which is an ordinary object to Go, so the
|
|
// call cannot tell it apart and the declaration is the only thing left to read.
|
|
if isGeneratorFunction(vm, transformVal) {
|
|
return nil, types.ErrScriptTransformAsync
|
|
}
|
|
|
|
engine.transform = transform
|
|
|
|
return engine, nil
|
|
}
|
|
|
|
// SetHTTPDoer sets the doer that the script's http.* calls are sent through.
|
|
func (e *JsEngine) SetHTTPDoer(doer HTTPDoer) {
|
|
e.http.doer = doer
|
|
}
|
|
|
|
// Transform executes the JavaScript transform function with the given request data.
|
|
// It can return the following errors:
|
|
// - types.ScriptExecutionError
|
|
func (e *JsEngine) Transform(req *RequestData) error {
|
|
e.http.active = true
|
|
defer func() { e.http.active = false }()
|
|
|
|
// Convert RequestData to JavaScript object
|
|
reqObj := e.requestDataToObject(req)
|
|
|
|
// Call transform(req)
|
|
result, err := e.transform(goja.Undefined(), reqObj)
|
|
if err != nil {
|
|
return types.NewScriptExecutionError("JavaScript", e.exceptionError(err))
|
|
}
|
|
|
|
// An async or generator transform returns a promise nothing ever awaits, and a promise
|
|
// carries none of the request fields, so the request would go out untransformed.
|
|
// ExportType reads the type alone, while Export would run every getter on the result.
|
|
if result.ExportType() == promiseType {
|
|
return types.ErrScriptTransformAsync
|
|
}
|
|
|
|
// Update RequestData from the returned object
|
|
if err := e.objectToRequestData(result, req); err != nil {
|
|
return types.NewScriptExecutionError("JavaScript", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Close releases the JavaScript runtime resources.
|
|
func (e *JsEngine) Close() {
|
|
// goja doesn't have an explicit close method, but we can help GC
|
|
e.runtime = nil
|
|
e.transform = nil
|
|
e.jsonParse = nil
|
|
e.errorConstructor = nil
|
|
}
|
|
|
|
// requestDataToObject converts RequestData to a goja Value (JavaScript object).
|
|
func (e *JsEngine) requestDataToObject(req *RequestData) goja.Value {
|
|
obj := e.runtime.NewObject()
|
|
|
|
_ = obj.Set("method", req.Method)
|
|
_ = obj.Set("path", req.Path)
|
|
_ = obj.Set("body", req.Body)
|
|
|
|
// Headers (map[string][]string -> object of arrays)
|
|
headers := e.runtime.NewObject()
|
|
for k, values := range req.Headers {
|
|
_ = headers.Set(k, e.stringSliceToArray(values))
|
|
}
|
|
_ = obj.Set("headers", headers)
|
|
|
|
// Params (map[string][]string -> object of arrays)
|
|
params := e.runtime.NewObject()
|
|
for k, values := range req.Params {
|
|
_ = params.Set(k, e.stringSliceToArray(values))
|
|
}
|
|
_ = obj.Set("params", params)
|
|
|
|
// Cookies (map[string][]string -> object of arrays)
|
|
cookies := e.runtime.NewObject()
|
|
for k, values := range req.Cookies {
|
|
_ = cookies.Set(k, e.stringSliceToArray(values))
|
|
}
|
|
_ = obj.Set("cookies", cookies)
|
|
|
|
return obj
|
|
}
|
|
|
|
// objectToRequestData updates RequestData from a JavaScript object.
|
|
func (e *JsEngine) objectToRequestData(val goja.Value, req *RequestData) error {
|
|
if val == nil || goja.IsUndefined(val) || goja.IsNull(val) {
|
|
return types.ErrScriptTransformReturnObject
|
|
}
|
|
|
|
obj := val.ToObject(e.runtime)
|
|
if obj == nil {
|
|
return types.ErrScriptTransformReturnObject
|
|
}
|
|
|
|
// Reading a field runs the script's own getters and toString, which throw as a Go
|
|
// panic outside goja's execution context, so the whole read runs inside Try.
|
|
var fieldErr error
|
|
if thrown := e.runtime.Try(func() {
|
|
// Method
|
|
if v := obj.Get("method"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) {
|
|
req.Method = v.String()
|
|
}
|
|
|
|
// Path
|
|
if v := obj.Get("path"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) {
|
|
req.Path = v.String()
|
|
}
|
|
|
|
// Body
|
|
if v := obj.Get("body"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) {
|
|
req.Body = v.String()
|
|
}
|
|
|
|
// Headers
|
|
if v := obj.Get("headers"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) {
|
|
values, err := objectField("headers", v)
|
|
if err != nil {
|
|
fieldErr = err
|
|
return
|
|
}
|
|
req.Headers = e.objectToStringSliceMap(values)
|
|
}
|
|
|
|
// Params
|
|
if v := obj.Get("params"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) {
|
|
values, err := objectField("params", v)
|
|
if err != nil {
|
|
fieldErr = err
|
|
return
|
|
}
|
|
req.Params = e.objectToStringSliceMap(values)
|
|
}
|
|
|
|
// Cookies
|
|
if v := obj.Get("cookies"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) {
|
|
values, err := objectField("cookies", v)
|
|
if err != nil {
|
|
fieldErr = err
|
|
return
|
|
}
|
|
req.Cookies = e.objectToStringSliceMap(values)
|
|
}
|
|
}); thrown != nil {
|
|
return e.exceptionError(thrown)
|
|
}
|
|
|
|
return fieldErr
|
|
}
|
|
|
|
// objectField rejects anything that is not a plain object, since reading keys off an
|
|
// array or a boxed string yields headers named after indexes and off a Date yields none.
|
|
// A Map, a Set and a typed array still pass, so the field is emptied rather than refused.
|
|
func objectField(name string, value goja.Value) (*goja.Object, error) {
|
|
obj, ok := value.(*goja.Object)
|
|
if !ok || obj.ClassName() != "Object" {
|
|
return nil, fmt.Errorf("%s must be an object", name)
|
|
}
|
|
return obj, nil
|
|
}
|
|
|
|
// stringSliceToArray converts a Go []string to a JavaScript array.
|
|
func (e *JsEngine) stringSliceToArray(values []string) *goja.Object {
|
|
ifaces := make([]any, len(values))
|
|
for i, v := range values {
|
|
ifaces[i] = v
|
|
}
|
|
return e.runtime.NewArray(ifaces...)
|
|
}
|
|
|
|
// objectToStringSliceMap converts a JavaScript object to a Go map[string][]string.
|
|
// Supports both single string values and array values.
|
|
func (e *JsEngine) objectToStringSliceMap(obj *goja.Object) map[string][]string {
|
|
if obj == nil {
|
|
return make(map[string][]string)
|
|
}
|
|
|
|
result := make(map[string][]string)
|
|
for _, key := range obj.Keys() {
|
|
v := obj.Get(key)
|
|
|
|
// Check if it's an array
|
|
if arr, ok := v.(*goja.Object); ok && arr.ClassName() == "Array" {
|
|
length := int(arr.Get("length").ToInteger())
|
|
values := make([]string, 0, length)
|
|
for i := range length {
|
|
if text, ok := jsStringValue(arr.Get(strconv.Itoa(i))); ok {
|
|
values = append(values, text)
|
|
}
|
|
}
|
|
result[key] = values
|
|
continue
|
|
}
|
|
|
|
// Single value, wrap it in a slice
|
|
if text, ok := jsStringValue(v); ok {
|
|
result[key] = []string{text}
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
// exceptionError keeps the thrown value's message without goja's stack frame.
|
|
// Stringifying runs through Runtime.Try because a thrown value with no usable
|
|
// toString makes goja panic outside its own execution context.
|
|
func (e *JsEngine) exceptionError(err error) error {
|
|
var exception *goja.Exception
|
|
if !errors.As(err, &exception) {
|
|
return err
|
|
}
|
|
|
|
var message string
|
|
if thrown := e.runtime.Try(func() { message = exception.Value().String() }); thrown != nil {
|
|
message = "unprintable thrown value"
|
|
}
|
|
return errors.New(message)
|
|
}
|
|
|
|
// jsStringValue renders a JavaScript primitive the way the script would see it.
|
|
// Objects, arrays and functions have no useful text form, so they are skipped.
|
|
func jsStringValue(v goja.Value) (string, bool) {
|
|
if v == nil || goja.IsUndefined(v) || goja.IsNull(v) {
|
|
return "", false
|
|
}
|
|
if _, isObject := v.(*goja.Object); isObject {
|
|
return "", false
|
|
}
|
|
return v.String(), true
|
|
}
|
|
|
|
// isGeneratorFunction reports whether a function is declared with a star.
|
|
// The name comes from the script's own prototype chain, so a script can falsify it either way.
|
|
// Reading it can run a script getter, so the whole walk stays inside Try.
|
|
func isGeneratorFunction(runtime *goja.Runtime, value goja.Value) bool {
|
|
var name string
|
|
thrown := runtime.Try(func() {
|
|
obj := value.ToObject(runtime)
|
|
if obj == nil {
|
|
return
|
|
}
|
|
constructor := obj.Get("constructor")
|
|
if constructor == nil || goja.IsUndefined(constructor) || goja.IsNull(constructor) {
|
|
return
|
|
}
|
|
constructorObj := constructor.ToObject(runtime)
|
|
if constructorObj == nil {
|
|
return
|
|
}
|
|
if value := constructorObj.Get("name"); value != nil {
|
|
name = value.String()
|
|
}
|
|
})
|
|
|
|
return thrown == nil && name == "GeneratorFunction"
|
|
}
|