feat(script): add http() and json to Lua and JS scripts

This commit is contained in:
aykhans committed 2026-09-20 23:02:51 +04:00
1 parent 5ca9cda2b9
commit 8b437b487c
17 files changed
+990 -140

No files matched your search

+10
View File
@@ -91,6 +91,16 @@ func (t *Transformer) Transform(req *RequestData) error {
return nil
}
// SetHTTPDoer sets the doer that the scripts' http.* calls are sent through.
func (t *Transformer) SetHTTPDoer(doer HTTPDoer) {
for _, engine := range t.luaEngines {
engine.SetHTTPDoer(doer)
}
for _, engine := range t.jsEngines {
engine.SetHTTPDoer(doer)
}
}
// Close releases all engine resources.
func (t *Transformer) Close() {
for _, engine := range t.luaEngines {
+112
View File
@@ -0,0 +1,112 @@
package script
import (
"net/http"
"strings"
"time"
"go.aykhans.me/sarin/internal/types"
)
// HTTPRequest is a request made by a script through the http.* functions.
type HTTPRequest struct {
Method string
URL string
Headers map[string][]string
Params map[string][]string
Cookies map[string][]string
Body string
Timeout time.Duration // zero means the doer's default timeout
Insecure bool
FollowRedirects bool
}
// HTTPResponse is the response returned to a script by the http.* functions.
type HTTPResponse struct {
Status int
Headers map[string][]string
Body string
}
// Header returns the first value of the named header, matching the name case-insensitively.
func (r *HTTPResponse) Header(name string) (string, bool) {
for key, values := range r.Headers {
if strings.EqualFold(key, name) && len(values) > 0 {
return values[0], true
}
}
return "", false
}
// Cookie returns the value of the named cookie from the Set-Cookie headers.
func (r *HTTPResponse) Cookie(name string) (string, bool) {
for key, values := range r.Headers {
if !strings.EqualFold(key, "Set-Cookie") {
continue
}
for _, line := range values {
if cookie, err := http.ParseSetCookie(line); err == nil && cookie.Name == name {
return cookie.Value, true
}
}
}
return "", false
}
// HTTPDoer sends the HTTP requests that scripts make.
type HTTPDoer interface {
Do(req *HTTPRequest) (*HTTPResponse, error)
}
// httpDefaultMethod is used when the options set no method.
const httpDefaultMethod = http.MethodGet
// Option keys accepted by the http function.
const (
httpOptionMethod = "method"
httpOptionHeaders = "headers"
httpOptionParams = "params"
httpOptionCookies = "cookies"
httpOptionBody = "body"
httpOptionTimeout = "timeout"
httpOptionInsecure = "insecure"
httpOptionFollowRedirects = "followRedirects"
)
// httpBridge connects http to the doer. Calls only work while transform runs.
type httpBridge struct {
doer HTTPDoer
active bool
}
// It can return the following errors:
// - types.ErrScriptHTTPOutsideTransform
// - types.ErrScriptHTTPUnavailable
// - types.ErrScriptHTTPMethodEmpty
// - any error returned by the doer
func (b *httpBridge) do(req *HTTPRequest) (*HTTPResponse, error) {
if !b.active {
return nil, types.ErrScriptHTTPOutsideTransform
}
if b.doer == nil {
return nil, types.ErrScriptHTTPUnavailable
}
if req.Method == "" {
return nil, types.ErrScriptHTTPMethodEmpty
}
return b.doer.Do(req)
}
// parseHTTPTimeout parses a Go duration string such as "500ms" or "2s".
// It can return the following errors:
// - types.ScriptHTTPOptionError
func parseHTTPTimeout(value string) (time.Duration, error) {
timeout, err := time.ParseDuration(value)
if err != nil {
return 0, types.NewScriptHTTPOptionError(httpOptionTimeout, err)
}
if timeout <= 0 {
return 0, types.NewScriptHTTPOptionError(httpOptionTimeout, types.NewScriptTypeError("a duration greater than 0", value))
}
return timeout, nil
}
+35 -7
View File
@@ -11,6 +11,10 @@ import (
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
}
// NewJsEngine creates a new JavaScript script engine with the given script content.
@@ -29,11 +33,17 @@ type JsEngine struct {
// - 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
_, err := vm.RunString(scriptContent)
if err != nil {
return nil, types.NewScriptExecutionError("JavaScript", err)
return nil, types.NewScriptExecutionError("JavaScript", jsExceptionError(err))
}
// Get the transform function
@@ -47,23 +57,30 @@ func NewJsEngine(scriptContent string) (*JsEngine, error) {
return nil, types.NewScriptExecutionError("JavaScript", errors.New("'transform' must be a function"))
}
return &JsEngine{
runtime: vm,
transform: transform,
}, nil
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", err)
return types.NewScriptExecutionError("JavaScript", jsExceptionError(err))
}
// Update RequestData from the returned object
@@ -79,6 +96,8 @@ 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).
@@ -190,9 +209,18 @@ func (e *JsEngine) objectToStringSliceMap(obj *goja.Object) map[string][]string
}
result[key] = values
} else {
// Single value - wrap in slice
// Single value, wrap it in a slice
result[key] = []string{v.String()}
}
}
return result
}
// jsExceptionError keeps the thrown value's message without goja's stack frame.
func jsExceptionError(err error) error {
var exception *goja.Exception
if !errors.As(err, &exception) {
return err
}
return errors.New(exception.Value().String())
}
+192
View File
@@ -0,0 +1,192 @@
package script
import (
"errors"
"github.com/dop251/goja"
"go.aykhans.me/sarin/internal/types"
)
// registerHTTP defines the global http function.
func (e *JsEngine) registerHTTP() error {
rt := e.runtime
jsonParse, ok := goja.AssertFunction(rt.Get("JSON").ToObject(rt).Get("parse"))
if !ok {
return errors.New("JSON.parse is not available")
}
e.jsonParse = jsonParse
e.errorConstructor = rt.Get("Error")
return rt.Set("http", func(call goja.FunctionCall) goja.Value {
return e.httpCall(call.Argument(0), call.Argument(1))
})
}
func (e *JsEngine) httpCall(urlValue, optsValue goja.Value) goja.Value {
rt := e.runtime
url, ok := urlValue.Export().(string)
if !ok {
panic(rt.NewTypeError("http: url must be a string, got %s", jsTypeName(urlValue)))
}
req := &HTTPRequest{Method: httpDefaultMethod, URL: url}
if !goja.IsUndefined(optsValue) && !goja.IsNull(optsValue) {
opts, ok := optsValue.(*goja.Object)
if !ok {
panic(rt.NewTypeError("http: options must be an object, got %s", jsTypeName(optsValue)))
}
if err := e.parseHTTPOptions(opts, req); err != nil {
e.throw(err)
}
}
resp, err := e.http.do(req)
if err != nil {
e.throw(err)
}
return e.httpResponseToObject(resp)
}
// parseHTTPOptions fills req from a JavaScript options object.
// It can return the following errors:
// - types.ScriptHTTPOptionError
func (e *JsEngine) parseHTTPOptions(opts *goja.Object, req *HTTPRequest) error {
for _, name := range opts.Keys() {
value := opts.Get(name)
if goja.IsUndefined(value) {
continue
}
switch name {
case httpOptionMethod:
method, ok := value.Export().(string)
if !ok {
return types.NewScriptHTTPOptionError(name, types.NewScriptTypeError("string", jsTypeName(value)))
}
req.Method = method
case httpOptionHeaders, httpOptionParams, httpOptionCookies:
object, ok := value.(*goja.Object)
if !ok {
return types.NewScriptHTTPOptionError(name, types.NewScriptTypeError("object", jsTypeName(value)))
}
values := e.objectToStringSliceMap(object)
switch name {
case httpOptionHeaders:
req.Headers = values
case httpOptionParams:
req.Params = values
default:
req.Cookies = values
}
case httpOptionBody:
body, ok := value.Export().(string)
if !ok {
return types.NewScriptHTTPOptionError(name, types.NewScriptTypeError("string", jsTypeName(value)))
}
req.Body = body
case httpOptionTimeout:
timeout, ok := value.Export().(string)
if !ok {
return types.NewScriptHTTPOptionError(name, types.NewScriptTypeError("duration string", jsTypeName(value)))
}
var err error
if req.Timeout, err = parseHTTPTimeout(timeout); err != nil {
return err
}
case httpOptionInsecure, httpOptionFollowRedirects:
flag, ok := value.Export().(bool)
if !ok {
return types.NewScriptHTTPOptionError(name, types.NewScriptTypeError("boolean", jsTypeName(value)))
}
if name == httpOptionInsecure {
req.Insecure = flag
} else {
req.FollowRedirects = flag
}
default:
return types.NewScriptHTTPOptionError(name, types.ErrScriptHTTPUnknownOption)
}
}
return nil
}
// httpResponseToObject converts a response to a JavaScript object.
func (e *JsEngine) httpResponseToObject(resp *HTTPResponse) *goja.Object {
rt := e.runtime
obj := rt.NewObject()
headers := rt.NewObject()
for key, values := range resp.Headers {
_ = headers.Set(key, e.stringSliceToArray(values))
}
lookup := func(find func(string) (string, bool)) func(goja.FunctionCall) goja.Value {
return func(call goja.FunctionCall) goja.Value {
name, ok := call.Argument(0).Export().(string)
if !ok {
panic(rt.NewTypeError("name must be a string, got %s", jsTypeName(call.Argument(0))))
}
if value, ok := find(name); ok {
return rt.ToValue(value)
}
return goja.Null()
}
}
_ = obj.Set("status", resp.Status)
_ = obj.Set("headers", headers)
_ = obj.Set("body", resp.Body)
_ = obj.Set("header", lookup(resp.Header))
_ = obj.Set("cookie", lookup(resp.Cookie))
_ = obj.Set("json", func(goja.FunctionCall) goja.Value {
value, err := e.jsonParse(goja.Undefined(), rt.ToValue(resp.Body))
if err != nil {
panic(err)
}
return value
})
return obj
}
// jsTypeName returns a short type name for error messages.
func jsTypeName(value goja.Value) string {
switch {
case value == nil || goja.IsUndefined(value):
return "undefined"
case goja.IsNull(value):
return "null"
}
switch value.Export().(type) {
case string:
return "string"
case bool:
return "boolean"
case int64, float64:
return "number"
case []any:
return "array"
default:
if _, ok := goja.AssertFunction(value); ok {
return "function"
}
return "object"
}
}
// throw raises err as a plain JavaScript Error instead of a GoError.
func (e *JsEngine) throw(err error) {
errorValue, newErr := e.runtime.New(e.errorConstructor, e.runtime.ToValue(err.Error()))
if newErr != nil {
panic(e.runtime.NewGoError(err))
}
panic(errorValue)
}
+29 -33
View File
@@ -11,6 +11,7 @@ import (
type LuaEngine struct {
state *lua.LState
transform *lua.LFunction
http httpBridge
}
// NewLuaEngine creates a new Lua script engine with the given script content.
@@ -29,6 +30,11 @@ type LuaEngine struct {
// - types.ScriptExecutionError
func NewLuaEngine(scriptContent string) (*LuaEngine, error) {
L := lua.NewState()
engine := &LuaEngine{state: L}
// Register the globals before running the script so its functions can use them
engine.registerHTTP()
registerLuaJSON(L)
// Execute the script to define the transform function
if err := L.DoString(scriptContent); err != nil {
@@ -42,17 +48,23 @@ func NewLuaEngine(scriptContent string) (*LuaEngine, error) {
L.Close()
return nil, types.ErrScriptTransformMissing
}
engine.transform = transform.(*lua.LFunction)
return &LuaEngine{
state: L,
transform: transform.(*lua.LFunction),
}, nil
return engine, nil
}
// SetHTTPDoer sets the doer that the script's http.* calls are sent through.
func (e *LuaEngine) SetHTTPDoer(doer HTTPDoer) {
e.http.doer = doer
}
// Transform executes the Lua transform function with the given request data.
// It can return the following errors:
// - types.ScriptExecutionError
func (e *LuaEngine) Transform(req *RequestData) error {
e.http.active = true
defer func() { e.http.active = false }()
// Convert RequestData to Lua table
reqTable := e.requestDataToTable(req)
@@ -93,39 +105,23 @@ func (e *LuaEngine) requestDataToTable(req *RequestData) *lua.LTable {
t.RawSetString("path", lua.LString(req.Path))
t.RawSetString("body", lua.LString(req.Body))
// Headers (map[string][]string -> table of arrays)
headers := L.NewTable()
for k, values := range req.Headers {
arr := L.NewTable()
t.RawSetString("headers", stringSliceMapToTable(L, req.Headers))
t.RawSetString("params", stringSliceMapToTable(L, req.Params))
t.RawSetString("cookies", stringSliceMapToTable(L, req.Cookies))
return t
}
// stringSliceMapToTable converts a Go map[string][]string to a Lua table of arrays.
func stringSliceMapToTable(state *lua.LState, m map[string][]string) *lua.LTable {
t := state.NewTable()
for k, values := range m {
arr := state.NewTable()
for _, v := range values {
arr.Append(lua.LString(v))
}
headers.RawSetString(k, arr)
t.RawSetString(k, arr)
}
t.RawSetString("headers", headers)
// Params (map[string][]string -> table of arrays)
params := L.NewTable()
for k, values := range req.Params {
arr := L.NewTable()
for _, v := range values {
arr.Append(lua.LString(v))
}
params.RawSetString(k, arr)
}
t.RawSetString("params", params)
// Cookies (map[string][]string -> table of arrays)
cookies := L.NewTable()
for k, values := range req.Cookies {
arr := L.NewTable()
for _, v := range values {
arr.Append(lua.LString(v))
}
cookies.RawSetString(k, arr)
}
t.RawSetString("cookies", cookies)
return t
}
+139
View File
@@ -0,0 +1,139 @@
package script
import (
lua "github.com/yuin/gopher-lua"
"go.aykhans.me/sarin/internal/types"
)
// registerHTTP defines the global http function.
func (e *LuaEngine) registerHTTP() {
L := e.state
L.SetGlobal("http", L.NewFunction(func(L *lua.LState) int {
return e.httpCall(L, 1)
}))
}
// httpCall sends the request whose URL is at stack position urlIndex and pushes the response.
func (e *LuaEngine) httpCall(state *lua.LState, urlIndex int) int {
req := &HTTPRequest{
Method: httpDefaultMethod,
URL: state.CheckString(urlIndex),
}
switch opts := state.Get(urlIndex + 1).(type) {
case *lua.LNilType:
case *lua.LTable:
if err := e.parseHTTPOptions(opts, req); err != nil {
state.RaiseError("%s", err.Error())
}
default:
state.ArgError(urlIndex+1, "options must be a table")
}
resp, err := e.http.do(req)
if err != nil {
state.RaiseError("%s", err.Error())
}
state.Push(httpResponseToTable(state, resp))
return 1
}
// parseHTTPOptions fills req from a Lua options table.
// It can return the following errors:
// - types.ScriptHTTPOptionError
func (e *LuaEngine) parseHTTPOptions(opts *lua.LTable, req *HTTPRequest) error {
var err error
opts.ForEach(func(k, v lua.LValue) {
if err != nil {
return
}
key, ok := k.(lua.LString)
if !ok {
err = types.NewScriptHTTPOptionError(k.String(), types.ErrScriptHTTPUnknownOption)
return
}
switch name := string(key); name {
case httpOptionMethod:
method, ok := v.(lua.LString)
if !ok {
err = types.NewScriptHTTPOptionError(name, types.NewScriptTypeError("string", v.Type().String()))
return
}
req.Method = string(method)
case httpOptionHeaders, httpOptionParams, httpOptionCookies:
table, ok := v.(*lua.LTable)
if !ok {
err = types.NewScriptHTTPOptionError(name, types.NewScriptTypeError("table", v.Type().String()))
return
}
values := e.tableToStringSliceMap(table)
switch name {
case httpOptionHeaders:
req.Headers = values
case httpOptionParams:
req.Params = values
default:
req.Cookies = values
}
case httpOptionBody:
body, ok := v.(lua.LString)
if !ok {
err = types.NewScriptHTTPOptionError(name, types.NewScriptTypeError("string", v.Type().String()))
return
}
req.Body = string(body)
case httpOptionTimeout:
timeout, ok := v.(lua.LString)
if !ok {
err = types.NewScriptHTTPOptionError(name, types.NewScriptTypeError("duration string", v.Type().String()))
return
}
req.Timeout, err = parseHTTPTimeout(string(timeout))
case httpOptionInsecure, httpOptionFollowRedirects:
flag, ok := v.(lua.LBool)
if !ok {
err = types.NewScriptHTTPOptionError(name, types.NewScriptTypeError("boolean", v.Type().String()))
return
}
if name == httpOptionInsecure {
req.Insecure = bool(flag)
} else {
req.FollowRedirects = bool(flag)
}
default:
err = types.NewScriptHTTPOptionError(name, types.ErrScriptHTTPUnknownOption)
}
})
return err
}
// httpResponseToTable converts a response to a Lua table.
func httpResponseToTable(state *lua.LState, resp *HTTPResponse) *lua.LTable {
t := state.NewTable()
t.RawSetString("status", lua.LNumber(resp.Status))
t.RawSetString("headers", stringSliceMapToTable(state, resp.Headers))
t.RawSetString("body", lua.LString(resp.Body))
lookup := func(find func(string) (string, bool)) *lua.LFunction {
return state.NewFunction(func(L *lua.LState) int {
// Last argument, so both res:header() and res.header() work.
top := L.GetTop()
if top == 0 {
L.ArgError(1, "name expected")
}
if value, ok := find(L.CheckString(top)); ok {
L.Push(lua.LString(value))
} else {
L.Push(lua.LNil)
}
return 1
})
}
t.RawSetString("header", lookup(resp.Header))
t.RawSetString("cookie", lookup(resp.Cookie))
return t
}
+148
View File
@@ -0,0 +1,148 @@
package script
import (
"bytes"
"encoding/json"
"math"
"strings"
lua "github.com/yuin/gopher-lua"
"go.aykhans.me/sarin/internal/types"
)
// registerLuaJSON defines the global json table with encode and decode.
func registerLuaJSON(state *lua.LState) {
module := state.NewTable()
module.RawSetString("encode", state.NewFunction(luaJSONEncode))
module.RawSetString("decode", state.NewFunction(luaJSONDecode))
state.SetGlobal("json", module)
}
func luaJSONEncode(state *lua.LState) int {
value, err := luaToGo(state.CheckAny(1), make(map[*lua.LTable]bool))
if err != nil {
state.RaiseError("%s", types.NewScriptJSONError("encode", err).Error())
}
var buf bytes.Buffer
encoder := json.NewEncoder(&buf)
encoder.SetEscapeHTML(false)
if err := encoder.Encode(value); err != nil {
state.RaiseError("%s", types.NewScriptJSONError("encode", err).Error())
}
state.Push(lua.LString(strings.TrimSuffix(buf.String(), "\n")))
return 1
}
func luaJSONDecode(state *lua.LState) int {
var value any
if err := json.Unmarshal([]byte(state.CheckString(1)), &value); err != nil {
state.RaiseError("%s", types.NewScriptJSONError("decode", err).Error())
}
state.Push(goToLua(state, value))
return 1
}
// luaToGo converts a Lua value to a JSON-encodable Go value.
// It can return the following errors:
// - types.ErrScriptJSONCycle
// - types.ScriptTypeError
func luaToGo(value lua.LValue, visiting map[*lua.LTable]bool) (any, error) {
switch v := value.(type) {
case *lua.LNilType:
return nil, nil
case lua.LBool:
return bool(v), nil
case lua.LNumber:
return float64(v), nil
case lua.LString:
return string(v), nil
case *lua.LTable:
if visiting[v] {
return nil, types.ErrScriptJSONCycle
}
visiting[v] = true
defer delete(visiting, v)
if length, ok := luaArrayLength(v); ok {
array := make([]any, length)
for i := range length {
item, err := luaToGo(v.RawGetInt(i+1), visiting)
if err != nil {
return nil, err
}
array[i] = item
}
return array, nil
}
object := make(map[string]any)
var err error
v.ForEach(func(key, item lua.LValue) {
if err != nil {
return
}
switch key.(type) {
case lua.LString, lua.LNumber:
default:
err = types.NewScriptTypeError("string or number object key", key.Type().String())
return
}
object[key.String()], err = luaToGo(item, visiting)
})
if err != nil {
return nil, err
}
return object, nil
default:
return nil, types.NewScriptTypeError("nil, boolean, number, string or table", value.Type().String())
}
}
// luaArrayLength reports whether t is a non-empty sequence with keys exactly 1..n.
func luaArrayLength(t *lua.LTable) (int, bool) {
count := 0
isArray := true
maxKey := 0.0
t.ForEach(func(key, _ lua.LValue) {
count++
number, ok := key.(lua.LNumber)
if !ok || float64(number) < 1 || float64(number) != math.Trunc(float64(number)) {
isArray = false
return
}
maxKey = max(maxKey, float64(number))
})
if !isArray || count == 0 || maxKey != float64(count) {
return 0, false
}
return count, true
}
// goToLua converts a value produced by encoding/json to a Lua value.
func goToLua(state *lua.LState, value any) lua.LValue {
switch v := value.(type) {
case bool:
return lua.LBool(v)
case float64:
return lua.LNumber(v)
case string:
return lua.LString(v)
case []any:
t := state.CreateTable(len(v), 0)
for i, item := range v {
t.RawSetInt(i+1, goToLua(state, item))
}
return t
case map[string]any:
t := state.CreateTable(0, len(v))
for key, item := range v {
t.RawSetString(key, goToLua(state, item))
}
return t
default:
return lua.LNil
}
}
+6 -3
View File
@@ -31,6 +31,9 @@ type Engine interface {
// The script should modify the RequestData and return it.
Transform(req *RequestData) error
// SetHTTPDoer sets the doer that the script's http.* calls are sent through.
SetHTTPDoer(doer HTTPDoer)
// Close releases any resources held by the engine.
Close()
}
@@ -69,7 +72,7 @@ func LoadSource(ctx context.Context, source string, engineType EngineType) (*Sou
switch {
case strings.HasPrefix(source, "@@"):
// Escaped @ - it's an inline script starting with literal @
// Escaped @: an inline script starting with a literal @
content = source[1:] // Remove first @, keep the rest
case strings.HasPrefix(source, "@"):
// File or URL reference
@@ -125,7 +128,7 @@ func ValidateScript(ctx context.Context, source string, engineType EngineType) e
return err
}
// Try to create an engine - this validates syntax and transform function
// Creating an engine validates the syntax and the transform function
var engine Engine
switch engineType {
case EngineTypeLua:
@@ -140,7 +143,7 @@ func ValidateScript(ctx context.Context, source string, engineType EngineType) e
return err
}
// Clean up the engine - we only needed it for validation
// The engine was only needed for validation
engine.Close()
return nil
}