mirror of
https://github.com/SagerNet/sing-box.git
synced 2026-04-17 13:23:06 +10:00
Add Surge MITM and scripts
This commit is contained in:
23
script/jsc/array.go
Normal file
23
script/jsc/array.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package jsc
|
||||
|
||||
import (
|
||||
_ "unsafe"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
func NewUint8Array(runtime *goja.Runtime, data []byte) goja.Value {
|
||||
buffer := runtime.NewArrayBuffer(data)
|
||||
ctor, loaded := goja.AssertConstructor(runtimeGetUint8Array(runtime))
|
||||
if !loaded {
|
||||
panic(runtime.NewTypeError("missing UInt8Array constructor"))
|
||||
}
|
||||
array, err := ctor(nil, runtime.ToValue(buffer))
|
||||
if err != nil {
|
||||
panic(runtime.NewGoError(err))
|
||||
}
|
||||
return array
|
||||
}
|
||||
|
||||
//go:linkname runtimeGetUint8Array github.com/dop251/goja.(*Runtime).getUint8Array
|
||||
func runtimeGetUint8Array(r *goja.Runtime) *goja.Object
|
||||
18
script/jsc/array_test.go
Normal file
18
script/jsc/array_test.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package jsc_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/sagernet/sing-box/script/jsc"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewUInt8Array(t *testing.T) {
|
||||
runtime := goja.New()
|
||||
runtime.Set("hello", jsc.NewUint8Array(runtime, []byte("world")))
|
||||
result, err := runtime.RunString("hello instanceof Uint8Array")
|
||||
require.NoError(t, err)
|
||||
require.True(t, result.ToBoolean())
|
||||
}
|
||||
121
script/jsc/assert.go
Normal file
121
script/jsc/assert.go
Normal file
@@ -0,0 +1,121 @@
|
||||
package jsc
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
F "github.com/sagernet/sing/common/format"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
func IsNil(value goja.Value) bool {
|
||||
return value == nil || goja.IsUndefined(value) || goja.IsNull(value)
|
||||
}
|
||||
|
||||
func AssertObject(vm *goja.Runtime, value goja.Value, name string, nilable bool) *goja.Object {
|
||||
if IsNil(value) {
|
||||
if nilable {
|
||||
return nil
|
||||
}
|
||||
panic(vm.NewTypeError(F.ToString("invalid argument: missing ", name)))
|
||||
}
|
||||
objectValue, isObject := value.(*goja.Object)
|
||||
if !isObject {
|
||||
panic(vm.NewTypeError(F.ToString("invalid argument: ", name, ": expected object, but got ", value)))
|
||||
}
|
||||
return objectValue
|
||||
}
|
||||
|
||||
func AssertString(vm *goja.Runtime, value goja.Value, name string, nilable bool) string {
|
||||
if IsNil(value) {
|
||||
if nilable {
|
||||
return ""
|
||||
}
|
||||
panic(vm.NewTypeError(F.ToString("invalid argument: missing ", name)))
|
||||
}
|
||||
stringValue, isString := value.Export().(string)
|
||||
if !isString {
|
||||
panic(vm.NewTypeError(F.ToString("invalid argument: ", name, ": expected string, but got ", value)))
|
||||
}
|
||||
return stringValue
|
||||
}
|
||||
|
||||
func AssertInt(vm *goja.Runtime, value goja.Value, name string, nilable bool) int64 {
|
||||
if IsNil(value) {
|
||||
if nilable {
|
||||
return 0
|
||||
}
|
||||
panic(vm.NewTypeError(F.ToString("invalid argument: missing ", name)))
|
||||
}
|
||||
integerValue, isNumber := value.Export().(int64)
|
||||
if !isNumber {
|
||||
panic(vm.NewTypeError(F.ToString("invalid argument: ", name, ": expected integer, but got ", value)))
|
||||
}
|
||||
return integerValue
|
||||
}
|
||||
|
||||
func AssertBool(vm *goja.Runtime, value goja.Value, name string, nilable bool) bool {
|
||||
if IsNil(value) {
|
||||
if nilable {
|
||||
return false
|
||||
}
|
||||
panic(vm.NewTypeError(F.ToString("invalid argument: missing ", name)))
|
||||
}
|
||||
boolValue, isBool := value.Export().(bool)
|
||||
if !isBool {
|
||||
panic(vm.NewTypeError(F.ToString("invalid argument: ", name, ": expected boolean, but got ", value)))
|
||||
}
|
||||
return boolValue
|
||||
}
|
||||
|
||||
func AssertBinary(vm *goja.Runtime, value goja.Value, name string, nilable bool) []byte {
|
||||
if IsNil(value) {
|
||||
if nilable {
|
||||
return nil
|
||||
}
|
||||
panic(vm.NewTypeError(F.ToString("invalid argument: missing ", name)))
|
||||
}
|
||||
switch exportedValue := value.Export().(type) {
|
||||
case []byte:
|
||||
return exportedValue
|
||||
case goja.ArrayBuffer:
|
||||
return exportedValue.Bytes()
|
||||
default:
|
||||
panic(vm.NewTypeError(F.ToString("invalid argument: ", name, ": expected Uint8Array or ArrayBuffer, but got ", value)))
|
||||
}
|
||||
}
|
||||
|
||||
func AssertStringBinary(vm *goja.Runtime, value goja.Value, name string, nilable bool) []byte {
|
||||
if IsNil(value) {
|
||||
if nilable {
|
||||
return nil
|
||||
}
|
||||
panic(vm.NewTypeError(F.ToString("invalid argument: missing ", name)))
|
||||
}
|
||||
switch exportedValue := value.Export().(type) {
|
||||
case string:
|
||||
return []byte(exportedValue)
|
||||
case []byte:
|
||||
return exportedValue
|
||||
case goja.ArrayBuffer:
|
||||
return exportedValue.Bytes()
|
||||
default:
|
||||
panic(vm.NewTypeError(F.ToString("invalid argument: ", name, ": expected string, Uint8Array or ArrayBuffer, but got ", value)))
|
||||
}
|
||||
}
|
||||
|
||||
func AssertFunction(vm *goja.Runtime, value goja.Value, name string) goja.Callable {
|
||||
functionValue, isFunction := goja.AssertFunction(value)
|
||||
if !isFunction {
|
||||
panic(vm.NewTypeError(F.ToString("invalid argument: ", name, ": expected function, but got ", value)))
|
||||
}
|
||||
return functionValue
|
||||
}
|
||||
|
||||
func AssertHTTPHeader(vm *goja.Runtime, value goja.Value, name string) http.Header {
|
||||
headersObject := AssertObject(vm, value, name, true)
|
||||
if headersObject == nil {
|
||||
return nil
|
||||
}
|
||||
return ObjectToHeaders(vm, headersObject, name)
|
||||
}
|
||||
56
script/jsc/headers.go
Normal file
56
script/jsc/headers.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package jsc
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"reflect"
|
||||
|
||||
"github.com/sagernet/sing/common"
|
||||
F "github.com/sagernet/sing/common/format"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
func HeadersToValue(runtime *goja.Runtime, headers http.Header) goja.Value {
|
||||
object := runtime.NewObject()
|
||||
for key, value := range headers {
|
||||
if len(value) == 1 {
|
||||
object.Set(key, value[0])
|
||||
} else {
|
||||
object.Set(key, ArrayToValue(runtime, value))
|
||||
}
|
||||
}
|
||||
return object
|
||||
}
|
||||
|
||||
func ArrayToValue[T any](runtime *goja.Runtime, values []T) goja.Value {
|
||||
return runtime.NewArray(common.Map(values, func(it T) any { return it })...)
|
||||
}
|
||||
|
||||
func ObjectToHeaders(vm *goja.Runtime, object *goja.Object, name string) http.Header {
|
||||
headers := make(http.Header)
|
||||
for _, key := range object.Keys() {
|
||||
valueObject := object.Get(key)
|
||||
switch headerValue := valueObject.(type) {
|
||||
case goja.String:
|
||||
headers.Set(key, headerValue.String())
|
||||
case *goja.Object:
|
||||
values := headerValue.Export()
|
||||
valueArray, isArray := values.([]any)
|
||||
if !isArray {
|
||||
panic(vm.NewTypeError(F.ToString("invalid value: ", name, ".", key, "expected string or string array, got ", valueObject.String())))
|
||||
}
|
||||
newValues := make([]string, 0, len(valueArray))
|
||||
for _, value := range valueArray {
|
||||
stringValue, isString := value.(string)
|
||||
if !isString {
|
||||
panic(vm.NewTypeError(F.ToString("invalid value: ", name, ".", key, " expected string or string array, got array item type: ", reflect.TypeOf(value))))
|
||||
}
|
||||
newValues = append(newValues, stringValue)
|
||||
}
|
||||
headers[key] = newValues
|
||||
default:
|
||||
panic(vm.NewTypeError(F.ToString("invalid value: ", name, ".", key, " expected string or string array, got ", valueObject.String())))
|
||||
}
|
||||
}
|
||||
return headers
|
||||
}
|
||||
31
script/jsc/headers_test.go
Normal file
31
script/jsc/headers_test.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package jsc_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/sagernet/sing-box/script/jsc"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestHeaders(t *testing.T) {
|
||||
runtime := goja.New()
|
||||
runtime.Set("headers", jsc.HeadersToValue(runtime, http.Header{
|
||||
"My-Header": []string{"My-Value1", "My-Value2"},
|
||||
}))
|
||||
headers := runtime.Get("headers").(*goja.Object).Get("My-Header").(*goja.Object)
|
||||
fmt.Println(reflect.ValueOf(headers.Export()).Type().String())
|
||||
}
|
||||
|
||||
func TestBody(t *testing.T) {
|
||||
runtime := goja.New()
|
||||
_, err := runtime.RunString(`
|
||||
var responseBody = new Uint8Array([1, 2, 3, 4, 5])
|
||||
`)
|
||||
require.NoError(t, err)
|
||||
fmt.Println(reflect.TypeOf(runtime.Get("responseBody").Export()))
|
||||
}
|
||||
18
script/jsc/time.go
Normal file
18
script/jsc/time.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package jsc
|
||||
|
||||
import (
|
||||
"time"
|
||||
_ "unsafe"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
func TimeToValue(runtime *goja.Runtime, time time.Time) goja.Value {
|
||||
return runtimeNewDateObject(runtime, time, true, runtimeGetDatePrototype(runtime))
|
||||
}
|
||||
|
||||
//go:linkname runtimeNewDateObject github.com/dop251/goja.(*Runtime).newDateObject
|
||||
func runtimeNewDateObject(r *goja.Runtime, t time.Time, isSet bool, proto *goja.Object) *goja.Object
|
||||
|
||||
//go:linkname runtimeGetDatePrototype github.com/dop251/goja.(*Runtime).getDatePrototype
|
||||
func runtimeGetDatePrototype(r *goja.Runtime) *goja.Object
|
||||
20
script/jsc/time_test.go
Normal file
20
script/jsc/time_test.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package jsc_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/sagernet/sing-box/script/jsc"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestTimeToValue(t *testing.T) {
|
||||
t.Parallel()
|
||||
runtime := goja.New()
|
||||
now := time.Now()
|
||||
err := runtime.Set("now", jsc.TimeToValue(runtime, now))
|
||||
require.NoError(t, err)
|
||||
println(runtime.Get("now").String())
|
||||
}
|
||||
107
script/manager.go
Normal file
107
script/manager.go
Normal file
@@ -0,0 +1,107 @@
|
||||
package script
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/sagernet/sing-box/adapter"
|
||||
"github.com/sagernet/sing-box/common/taskmonitor"
|
||||
C "github.com/sagernet/sing-box/constant"
|
||||
"github.com/sagernet/sing-box/log"
|
||||
"github.com/sagernet/sing-box/option"
|
||||
E "github.com/sagernet/sing/common/exceptions"
|
||||
F "github.com/sagernet/sing/common/format"
|
||||
"github.com/sagernet/sing/common/logger"
|
||||
"github.com/sagernet/sing/common/task"
|
||||
)
|
||||
|
||||
var _ adapter.ScriptManager = (*Manager)(nil)
|
||||
|
||||
type Manager struct {
|
||||
ctx context.Context
|
||||
logger logger.ContextLogger
|
||||
scripts []adapter.Script
|
||||
// scriptByName map[string]adapter.Script
|
||||
}
|
||||
|
||||
func NewManager(ctx context.Context, logFactory log.Factory, scripts []option.Script) (*Manager, error) {
|
||||
manager := &Manager{
|
||||
ctx: ctx,
|
||||
logger: logFactory.NewLogger("script"),
|
||||
// scriptByName: make(map[string]adapter.Script),
|
||||
}
|
||||
for _, scriptOptions := range scripts {
|
||||
script, err := NewScript(ctx, logFactory.NewLogger(F.ToString("script/", scriptOptions.Type, "[", scriptOptions.Tag, "]")), scriptOptions)
|
||||
if err != nil {
|
||||
return nil, E.Cause(err, "initialize script: ", scriptOptions.Tag)
|
||||
}
|
||||
manager.scripts = append(manager.scripts, script)
|
||||
// manager.scriptByName[scriptOptions.Tag] = script
|
||||
}
|
||||
return manager, nil
|
||||
}
|
||||
|
||||
func (m *Manager) Start(stage adapter.StartStage) error {
|
||||
monitor := taskmonitor.New(m.logger, C.StartTimeout)
|
||||
switch stage {
|
||||
case adapter.StartStateStart:
|
||||
var cacheContext *adapter.HTTPStartContext
|
||||
if len(m.scripts) > 0 {
|
||||
monitor.Start("initialize rule-set")
|
||||
cacheContext = adapter.NewHTTPStartContext(m.ctx)
|
||||
var scriptStartGroup task.Group
|
||||
for _, script := range m.scripts {
|
||||
scriptInPlace := script
|
||||
scriptStartGroup.Append0(func(ctx context.Context) error {
|
||||
err := scriptInPlace.StartContext(ctx, cacheContext)
|
||||
if err != nil {
|
||||
return E.Cause(err, "initialize script/", scriptInPlace.Type(), "[", scriptInPlace.Tag(), "]")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
scriptStartGroup.Concurrency(5)
|
||||
scriptStartGroup.FastFail()
|
||||
err := scriptStartGroup.Run(m.ctx)
|
||||
monitor.Finish()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if cacheContext != nil {
|
||||
cacheContext.Close()
|
||||
}
|
||||
case adapter.StartStatePostStart:
|
||||
for _, script := range m.scripts {
|
||||
monitor.Start(F.ToString("post start script/", script.Type(), "[", script.Tag(), "]"))
|
||||
err := script.PostStart()
|
||||
monitor.Finish()
|
||||
if err != nil {
|
||||
return E.Cause(err, "post start script/", script.Type(), "[", script.Tag(), "]")
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) Close() error {
|
||||
monitor := taskmonitor.New(m.logger, C.StopTimeout)
|
||||
var err error
|
||||
for _, script := range m.scripts {
|
||||
monitor.Start(F.ToString("close start script/", script.Type(), "[", script.Tag(), "]"))
|
||||
err = E.Append(err, script.Close(), func(err error) error {
|
||||
return E.Cause(err, "close script/", script.Type(), "[", script.Tag(), "]")
|
||||
})
|
||||
monitor.Finish()
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (m *Manager) Scripts() []adapter.Script {
|
||||
return m.scripts
|
||||
}
|
||||
|
||||
/*
|
||||
func (m *Manager) Script(name string) (adapter.Script, bool) {
|
||||
script, loaded := m.scriptByName[name]
|
||||
return script, loaded
|
||||
}*/
|
||||
108
script/modules/console/module.go
Normal file
108
script/modules/console/module.go
Normal file
@@ -0,0 +1,108 @@
|
||||
package console
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
|
||||
"github.com/sagernet/sing-box/script/modules/require"
|
||||
"github.com/sagernet/sing/common/logger"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
const ModuleName = "console"
|
||||
|
||||
type Console struct {
|
||||
vm *goja.Runtime
|
||||
}
|
||||
|
||||
func (c *Console) log(ctx context.Context, p func(ctx context.Context, values ...any)) func(goja.FunctionCall) goja.Value {
|
||||
return func(call goja.FunctionCall) goja.Value {
|
||||
var buffer bytes.Buffer
|
||||
var format string
|
||||
if arg := call.Argument(0); !goja.IsUndefined(arg) {
|
||||
format = arg.String()
|
||||
}
|
||||
var args []goja.Value
|
||||
if len(call.Arguments) > 0 {
|
||||
args = call.Arguments[1:]
|
||||
}
|
||||
c.Format(&buffer, format, args...)
|
||||
p(ctx, buffer.String())
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Console) Format(b *bytes.Buffer, f string, args ...goja.Value) {
|
||||
pct := false
|
||||
argNum := 0
|
||||
for _, chr := range f {
|
||||
if pct {
|
||||
if argNum < len(args) {
|
||||
if c.format(chr, args[argNum], b) {
|
||||
argNum++
|
||||
}
|
||||
} else {
|
||||
b.WriteByte('%')
|
||||
b.WriteRune(chr)
|
||||
}
|
||||
pct = false
|
||||
} else {
|
||||
if chr == '%' {
|
||||
pct = true
|
||||
} else {
|
||||
b.WriteRune(chr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, arg := range args[argNum:] {
|
||||
b.WriteByte(' ')
|
||||
b.WriteString(arg.String())
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Console) format(f rune, val goja.Value, w *bytes.Buffer) bool {
|
||||
switch f {
|
||||
case 's':
|
||||
w.WriteString(val.String())
|
||||
case 'd':
|
||||
w.WriteString(val.ToNumber().String())
|
||||
case 'j':
|
||||
if json, ok := c.vm.Get("JSON").(*goja.Object); ok {
|
||||
if stringify, ok := goja.AssertFunction(json.Get("stringify")); ok {
|
||||
res, err := stringify(json, val)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
w.WriteString(res.String())
|
||||
}
|
||||
}
|
||||
case '%':
|
||||
w.WriteByte('%')
|
||||
return false
|
||||
default:
|
||||
w.WriteByte('%')
|
||||
w.WriteRune(f)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func Require(ctx context.Context, logger logger.ContextLogger) require.ModuleLoader {
|
||||
return func(runtime *goja.Runtime, module *goja.Object) {
|
||||
c := &Console{
|
||||
vm: runtime,
|
||||
}
|
||||
o := module.Get("exports").(*goja.Object)
|
||||
o.Set("log", c.log(ctx, logger.DebugContext))
|
||||
o.Set("error", c.log(ctx, logger.ErrorContext))
|
||||
o.Set("warn", c.log(ctx, logger.WarnContext))
|
||||
o.Set("info", c.log(ctx, logger.InfoContext))
|
||||
o.Set("debug", c.log(ctx, logger.DebugContext))
|
||||
}
|
||||
}
|
||||
|
||||
func Enable(runtime *goja.Runtime) {
|
||||
runtime.Set("console", require.Require(runtime, ModuleName))
|
||||
}
|
||||
489
script/modules/eventloop/eventloop.go
Normal file
489
script/modules/eventloop/eventloop.go
Normal file
@@ -0,0 +1,489 @@
|
||||
package eventloop
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
type job struct {
|
||||
cancel func() bool
|
||||
fn func()
|
||||
idx int
|
||||
|
||||
cancelled bool
|
||||
}
|
||||
|
||||
type Timer struct {
|
||||
job
|
||||
timer *time.Timer
|
||||
}
|
||||
|
||||
type Interval struct {
|
||||
job
|
||||
ticker *time.Ticker
|
||||
stopChan chan struct{}
|
||||
}
|
||||
|
||||
type Immediate struct {
|
||||
job
|
||||
}
|
||||
|
||||
type EventLoop struct {
|
||||
vm *goja.Runtime
|
||||
jobChan chan func()
|
||||
jobs []*job
|
||||
jobCount int32
|
||||
canRun int32
|
||||
|
||||
auxJobsLock sync.Mutex
|
||||
wakeupChan chan struct{}
|
||||
|
||||
auxJobsSpare, auxJobs []func()
|
||||
|
||||
stopLock sync.Mutex
|
||||
stopCond *sync.Cond
|
||||
running bool
|
||||
terminated bool
|
||||
|
||||
errorHandler func(error)
|
||||
}
|
||||
|
||||
func Enable(runtime *goja.Runtime, errorHandler func(error)) *EventLoop {
|
||||
loop := &EventLoop{
|
||||
vm: runtime,
|
||||
jobChan: make(chan func()),
|
||||
wakeupChan: make(chan struct{}, 1),
|
||||
errorHandler: errorHandler,
|
||||
}
|
||||
loop.stopCond = sync.NewCond(&loop.stopLock)
|
||||
runtime.Set("setTimeout", loop.setTimeout)
|
||||
runtime.Set("setInterval", loop.setInterval)
|
||||
runtime.Set("setImmediate", loop.setImmediate)
|
||||
runtime.Set("clearTimeout", loop.clearTimeout)
|
||||
runtime.Set("clearInterval", loop.clearInterval)
|
||||
runtime.Set("clearImmediate", loop.clearImmediate)
|
||||
return loop
|
||||
}
|
||||
|
||||
func (loop *EventLoop) schedule(call goja.FunctionCall, repeating bool) goja.Value {
|
||||
if fn, ok := goja.AssertFunction(call.Argument(0)); ok {
|
||||
delay := call.Argument(1).ToInteger()
|
||||
var args []goja.Value
|
||||
if len(call.Arguments) > 2 {
|
||||
args = append(args, call.Arguments[2:]...)
|
||||
}
|
||||
f := func() {
|
||||
_, err := fn(nil, args...)
|
||||
if err != nil {
|
||||
loop.errorHandler(err)
|
||||
}
|
||||
}
|
||||
loop.jobCount++
|
||||
var job *job
|
||||
var ret goja.Value
|
||||
if repeating {
|
||||
interval := loop.newInterval(f)
|
||||
interval.start(loop, time.Duration(delay)*time.Millisecond)
|
||||
job = &interval.job
|
||||
ret = loop.vm.ToValue(interval)
|
||||
} else {
|
||||
timeout := loop.newTimeout(f)
|
||||
timeout.start(loop, time.Duration(delay)*time.Millisecond)
|
||||
job = &timeout.job
|
||||
ret = loop.vm.ToValue(timeout)
|
||||
}
|
||||
job.idx = len(loop.jobs)
|
||||
loop.jobs = append(loop.jobs, job)
|
||||
return ret
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (loop *EventLoop) setTimeout(call goja.FunctionCall) goja.Value {
|
||||
return loop.schedule(call, false)
|
||||
}
|
||||
|
||||
func (loop *EventLoop) setInterval(call goja.FunctionCall) goja.Value {
|
||||
return loop.schedule(call, true)
|
||||
}
|
||||
|
||||
func (loop *EventLoop) setImmediate(call goja.FunctionCall) goja.Value {
|
||||
if fn, ok := goja.AssertFunction(call.Argument(0)); ok {
|
||||
var args []goja.Value
|
||||
if len(call.Arguments) > 1 {
|
||||
args = append(args, call.Arguments[1:]...)
|
||||
}
|
||||
f := func() {
|
||||
_, err := fn(nil, args...)
|
||||
if err != nil {
|
||||
loop.errorHandler(err)
|
||||
}
|
||||
}
|
||||
loop.jobCount++
|
||||
return loop.vm.ToValue(loop.addImmediate(f))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetTimeout schedules to run the specified function in the context
|
||||
// of the loop as soon as possible after the specified timeout period.
|
||||
// SetTimeout returns a Timer which can be passed to ClearTimeout.
|
||||
// The instance of goja.Runtime that is passed to the function and any Values derived
|
||||
// from it must not be used outside the function. SetTimeout is
|
||||
// safe to call inside or outside the loop.
|
||||
// If the loop is terminated (see Terminate()) returns nil.
|
||||
func (loop *EventLoop) SetTimeout(fn func(*goja.Runtime), timeout time.Duration) *Timer {
|
||||
t := loop.newTimeout(func() { fn(loop.vm) })
|
||||
if loop.addAuxJob(func() {
|
||||
t.start(loop, timeout)
|
||||
loop.jobCount++
|
||||
t.idx = len(loop.jobs)
|
||||
loop.jobs = append(loop.jobs, &t.job)
|
||||
}) {
|
||||
return t
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClearTimeout cancels a Timer returned by SetTimeout if it has not run yet.
|
||||
// ClearTimeout is safe to call inside or outside the loop.
|
||||
func (loop *EventLoop) ClearTimeout(t *Timer) {
|
||||
loop.addAuxJob(func() {
|
||||
loop.clearTimeout(t)
|
||||
})
|
||||
}
|
||||
|
||||
// SetInterval schedules to repeatedly run the specified function in
|
||||
// the context of the loop as soon as possible after every specified
|
||||
// timeout period. SetInterval returns an Interval which can be
|
||||
// passed to ClearInterval. The instance of goja.Runtime that is passed to the
|
||||
// function and any Values derived from it must not be used outside
|
||||
// the function. SetInterval is safe to call inside or outside the
|
||||
// loop.
|
||||
// If the loop is terminated (see Terminate()) returns nil.
|
||||
func (loop *EventLoop) SetInterval(fn func(*goja.Runtime), timeout time.Duration) *Interval {
|
||||
i := loop.newInterval(func() { fn(loop.vm) })
|
||||
if loop.addAuxJob(func() {
|
||||
i.start(loop, timeout)
|
||||
loop.jobCount++
|
||||
i.idx = len(loop.jobs)
|
||||
loop.jobs = append(loop.jobs, &i.job)
|
||||
}) {
|
||||
return i
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClearInterval cancels an Interval returned by SetInterval.
|
||||
// ClearInterval is safe to call inside or outside the loop.
|
||||
func (loop *EventLoop) ClearInterval(i *Interval) {
|
||||
loop.addAuxJob(func() {
|
||||
loop.clearInterval(i)
|
||||
})
|
||||
}
|
||||
|
||||
func (loop *EventLoop) setRunning() {
|
||||
loop.stopLock.Lock()
|
||||
defer loop.stopLock.Unlock()
|
||||
if loop.running {
|
||||
panic("Loop is already started")
|
||||
}
|
||||
loop.running = true
|
||||
atomic.StoreInt32(&loop.canRun, 1)
|
||||
loop.auxJobsLock.Lock()
|
||||
loop.terminated = false
|
||||
loop.auxJobsLock.Unlock()
|
||||
}
|
||||
|
||||
// Run calls the specified function, starts the event loop and waits until there are no more delayed jobs to run
|
||||
// after which it stops the loop and returns.
|
||||
// The instance of goja.Runtime that is passed to the function and any Values derived from it must not be used
|
||||
// outside the function.
|
||||
// Do NOT use this function while the loop is already running. Use RunOnLoop() instead.
|
||||
// If the loop is already started it will panic.
|
||||
func (loop *EventLoop) Run(fn func(*goja.Runtime)) {
|
||||
loop.setRunning()
|
||||
fn(loop.vm)
|
||||
loop.run(false)
|
||||
}
|
||||
|
||||
// Start the event loop in the background. The loop continues to run until Stop() is called.
|
||||
// If the loop is already started it will panic.
|
||||
func (loop *EventLoop) Start() {
|
||||
loop.setRunning()
|
||||
go loop.run(true)
|
||||
}
|
||||
|
||||
// StartInForeground starts the event loop in the current goroutine. The loop continues to run until Stop() is called.
|
||||
// If the loop is already started it will panic.
|
||||
// Use this instead of Start if you want to recover from panics that may occur while calling native Go functions from
|
||||
// within setInterval and setTimeout callbacks.
|
||||
func (loop *EventLoop) StartInForeground() {
|
||||
loop.setRunning()
|
||||
loop.run(true)
|
||||
}
|
||||
|
||||
// Stop the loop that was started with Start(). After this function returns there will be no more jobs executed
|
||||
// by the loop. It is possible to call Start() or Run() again after this to resume the execution.
|
||||
// Note, it does not cancel active timeouts (use Terminate() instead if you want this).
|
||||
// It is not allowed to run Start() (or Run()) and Stop() or Terminate() concurrently.
|
||||
// Calling Stop() on a non-running loop has no effect.
|
||||
// It is not allowed to call Stop() from the loop, because it is synchronous and cannot complete until the loop
|
||||
// is not running any jobs. Use StopNoWait() instead.
|
||||
// return number of jobs remaining
|
||||
func (loop *EventLoop) Stop() int {
|
||||
loop.stopLock.Lock()
|
||||
for loop.running {
|
||||
atomic.StoreInt32(&loop.canRun, 0)
|
||||
loop.wakeup()
|
||||
loop.stopCond.Wait()
|
||||
}
|
||||
loop.stopLock.Unlock()
|
||||
return int(loop.jobCount)
|
||||
}
|
||||
|
||||
// StopNoWait tells the loop to stop and returns immediately. Can be used inside the loop. Calling it on a
|
||||
// non-running loop has no effect.
|
||||
func (loop *EventLoop) StopNoWait() {
|
||||
loop.stopLock.Lock()
|
||||
if loop.running {
|
||||
atomic.StoreInt32(&loop.canRun, 0)
|
||||
loop.wakeup()
|
||||
}
|
||||
loop.stopLock.Unlock()
|
||||
}
|
||||
|
||||
// Terminate stops the loop and clears all active timeouts and intervals. After it returns there are no
|
||||
// active timers or goroutines associated with the loop. Any attempt to submit a task (by using RunOnLoop(),
|
||||
// SetTimeout() or SetInterval()) will not succeed.
|
||||
// After being terminated the loop can be restarted again by using Start() or Run().
|
||||
// This method must not be called concurrently with Stop*(), Start(), or Run().
|
||||
func (loop *EventLoop) Terminate() {
|
||||
loop.Stop()
|
||||
|
||||
loop.auxJobsLock.Lock()
|
||||
loop.terminated = true
|
||||
loop.auxJobsLock.Unlock()
|
||||
|
||||
loop.runAux()
|
||||
|
||||
for i := 0; i < len(loop.jobs); i++ {
|
||||
job := loop.jobs[i]
|
||||
if !job.cancelled {
|
||||
job.cancelled = true
|
||||
if job.cancel() {
|
||||
loop.removeJob(job)
|
||||
i--
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for len(loop.jobs) > 0 {
|
||||
(<-loop.jobChan)()
|
||||
}
|
||||
}
|
||||
|
||||
// RunOnLoop schedules to run the specified function in the context of the loop as soon as possible.
|
||||
// The order of the runs is preserved (i.e. the functions will be called in the same order as calls to RunOnLoop())
|
||||
// The instance of goja.Runtime that is passed to the function and any Values derived from it must not be used
|
||||
// outside the function. It is safe to call inside or outside the loop.
|
||||
// Returns true on success or false if the loop is terminated (see Terminate()).
|
||||
func (loop *EventLoop) RunOnLoop(fn func(*goja.Runtime)) bool {
|
||||
return loop.addAuxJob(func() { fn(loop.vm) })
|
||||
}
|
||||
|
||||
func (loop *EventLoop) runAux() {
|
||||
loop.auxJobsLock.Lock()
|
||||
jobs := loop.auxJobs
|
||||
loop.auxJobs = loop.auxJobsSpare
|
||||
loop.auxJobsLock.Unlock()
|
||||
for i, job := range jobs {
|
||||
job()
|
||||
jobs[i] = nil
|
||||
}
|
||||
loop.auxJobsSpare = jobs[:0]
|
||||
}
|
||||
|
||||
func (loop *EventLoop) run(inBackground bool) {
|
||||
loop.runAux()
|
||||
if inBackground {
|
||||
loop.jobCount++
|
||||
}
|
||||
LOOP:
|
||||
for loop.jobCount > 0 {
|
||||
select {
|
||||
case job := <-loop.jobChan:
|
||||
job()
|
||||
case <-loop.wakeupChan:
|
||||
loop.runAux()
|
||||
if atomic.LoadInt32(&loop.canRun) == 0 {
|
||||
break LOOP
|
||||
}
|
||||
}
|
||||
}
|
||||
if inBackground {
|
||||
loop.jobCount--
|
||||
}
|
||||
|
||||
loop.stopLock.Lock()
|
||||
loop.running = false
|
||||
loop.stopLock.Unlock()
|
||||
loop.stopCond.Broadcast()
|
||||
}
|
||||
|
||||
func (loop *EventLoop) wakeup() {
|
||||
select {
|
||||
case loop.wakeupChan <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func (loop *EventLoop) addAuxJob(fn func()) bool {
|
||||
loop.auxJobsLock.Lock()
|
||||
if loop.terminated {
|
||||
loop.auxJobsLock.Unlock()
|
||||
return false
|
||||
}
|
||||
loop.auxJobs = append(loop.auxJobs, fn)
|
||||
loop.auxJobsLock.Unlock()
|
||||
loop.wakeup()
|
||||
return true
|
||||
}
|
||||
|
||||
func (loop *EventLoop) newTimeout(f func()) *Timer {
|
||||
t := &Timer{
|
||||
job: job{fn: f},
|
||||
}
|
||||
t.cancel = t.doCancel
|
||||
|
||||
return t
|
||||
}
|
||||
|
||||
func (t *Timer) start(loop *EventLoop, timeout time.Duration) {
|
||||
t.timer = time.AfterFunc(timeout, func() {
|
||||
loop.jobChan <- func() {
|
||||
loop.doTimeout(t)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (loop *EventLoop) newInterval(f func()) *Interval {
|
||||
i := &Interval{
|
||||
job: job{fn: f},
|
||||
stopChan: make(chan struct{}),
|
||||
}
|
||||
i.cancel = i.doCancel
|
||||
|
||||
return i
|
||||
}
|
||||
|
||||
func (i *Interval) start(loop *EventLoop, timeout time.Duration) {
|
||||
// https://nodejs.org/api/timers.html#timers_setinterval_callback_delay_args
|
||||
if timeout <= 0 {
|
||||
timeout = time.Millisecond
|
||||
}
|
||||
i.ticker = time.NewTicker(timeout)
|
||||
go i.run(loop)
|
||||
}
|
||||
|
||||
func (loop *EventLoop) addImmediate(f func()) *Immediate {
|
||||
i := &Immediate{
|
||||
job: job{fn: f},
|
||||
}
|
||||
loop.addAuxJob(func() {
|
||||
loop.doImmediate(i)
|
||||
})
|
||||
return i
|
||||
}
|
||||
|
||||
func (loop *EventLoop) doTimeout(t *Timer) {
|
||||
loop.removeJob(&t.job)
|
||||
if !t.cancelled {
|
||||
t.cancelled = true
|
||||
loop.jobCount--
|
||||
t.fn()
|
||||
}
|
||||
}
|
||||
|
||||
func (loop *EventLoop) doInterval(i *Interval) {
|
||||
if !i.cancelled {
|
||||
i.fn()
|
||||
}
|
||||
}
|
||||
|
||||
func (loop *EventLoop) doImmediate(i *Immediate) {
|
||||
if !i.cancelled {
|
||||
i.cancelled = true
|
||||
loop.jobCount--
|
||||
i.fn()
|
||||
}
|
||||
}
|
||||
|
||||
func (loop *EventLoop) clearTimeout(t *Timer) {
|
||||
if t != nil && !t.cancelled {
|
||||
t.cancelled = true
|
||||
loop.jobCount--
|
||||
if t.doCancel() {
|
||||
loop.removeJob(&t.job)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (loop *EventLoop) clearInterval(i *Interval) {
|
||||
if i != nil && !i.cancelled {
|
||||
i.cancelled = true
|
||||
loop.jobCount--
|
||||
i.doCancel()
|
||||
}
|
||||
}
|
||||
|
||||
func (loop *EventLoop) removeJob(job *job) {
|
||||
idx := job.idx
|
||||
if idx < 0 {
|
||||
return
|
||||
}
|
||||
if idx < len(loop.jobs)-1 {
|
||||
loop.jobs[idx] = loop.jobs[len(loop.jobs)-1]
|
||||
loop.jobs[idx].idx = idx
|
||||
}
|
||||
loop.jobs[len(loop.jobs)-1] = nil
|
||||
loop.jobs = loop.jobs[:len(loop.jobs)-1]
|
||||
job.idx = -1
|
||||
}
|
||||
|
||||
func (loop *EventLoop) clearImmediate(i *Immediate) {
|
||||
if i != nil && !i.cancelled {
|
||||
i.cancelled = true
|
||||
loop.jobCount--
|
||||
}
|
||||
}
|
||||
|
||||
func (i *Interval) doCancel() bool {
|
||||
close(i.stopChan)
|
||||
return false
|
||||
}
|
||||
|
||||
func (t *Timer) doCancel() bool {
|
||||
return t.timer.Stop()
|
||||
}
|
||||
|
||||
func (i *Interval) run(loop *EventLoop) {
|
||||
L:
|
||||
for {
|
||||
select {
|
||||
case <-i.stopChan:
|
||||
i.ticker.Stop()
|
||||
break L
|
||||
case <-i.ticker.C:
|
||||
loop.jobChan <- func() {
|
||||
loop.doInterval(i)
|
||||
}
|
||||
}
|
||||
}
|
||||
loop.jobChan <- func() {
|
||||
loop.removeJob(&i.job)
|
||||
}
|
||||
}
|
||||
231
script/modules/require/module.go
Normal file
231
script/modules/require/module.go
Normal file
@@ -0,0 +1,231 @@
|
||||
package require
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sync"
|
||||
"syscall"
|
||||
"text/template"
|
||||
|
||||
js "github.com/dop251/goja"
|
||||
"github.com/dop251/goja/parser"
|
||||
)
|
||||
|
||||
type ModuleLoader func(*js.Runtime, *js.Object)
|
||||
|
||||
// SourceLoader represents a function that returns a file data at a given path.
|
||||
// The function should return ModuleFileDoesNotExistError if the file either doesn't exist or is a directory.
|
||||
// This error will be ignored by the resolver and the search will continue. Any other errors will be propagated.
|
||||
type SourceLoader func(path string) ([]byte, error)
|
||||
|
||||
var (
|
||||
InvalidModuleError = errors.New("Invalid module")
|
||||
IllegalModuleNameError = errors.New("Illegal module name")
|
||||
NoSuchBuiltInModuleError = errors.New("No such built-in module")
|
||||
ModuleFileDoesNotExistError = errors.New("module file does not exist")
|
||||
)
|
||||
|
||||
// Registry contains a cache of compiled modules which can be used by multiple Runtimes
|
||||
type Registry struct {
|
||||
sync.Mutex
|
||||
native map[string]ModuleLoader
|
||||
builtin map[string]ModuleLoader
|
||||
compiled map[string]*js.Program
|
||||
|
||||
srcLoader SourceLoader
|
||||
globalFolders []string
|
||||
fsEnabled bool
|
||||
}
|
||||
|
||||
type RequireModule struct {
|
||||
r *Registry
|
||||
runtime *js.Runtime
|
||||
modules map[string]*js.Object
|
||||
nodeModules map[string]*js.Object
|
||||
}
|
||||
|
||||
func NewRegistry(opts ...Option) *Registry {
|
||||
r := &Registry{}
|
||||
|
||||
for _, opt := range opts {
|
||||
opt(r)
|
||||
}
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
type Option func(*Registry)
|
||||
|
||||
// WithLoader sets a function which will be called by the require() function in order to get a source code for a
|
||||
// module at the given path. The same function will be used to get external source maps.
|
||||
// Note, this only affects the modules loaded by the require() function. If you need to use it as a source map
|
||||
// loader for code parsed in a different way (such as runtime.RunString() or eval()), use (*Runtime).SetParserOptions()
|
||||
func WithLoader(srcLoader SourceLoader) Option {
|
||||
return func(r *Registry) {
|
||||
r.srcLoader = srcLoader
|
||||
}
|
||||
}
|
||||
|
||||
// WithGlobalFolders appends the given paths to the registry's list of
|
||||
// global folders to search if the requested module is not found
|
||||
// elsewhere. By default, a registry's global folders list is empty.
|
||||
// In the reference Node.js implementation, the default global folders
|
||||
// list is $NODE_PATH, $HOME/.node_modules, $HOME/.node_libraries and
|
||||
// $PREFIX/lib/node, see
|
||||
// https://nodejs.org/api/modules.html#modules_loading_from_the_global_folders.
|
||||
func WithGlobalFolders(globalFolders ...string) Option {
|
||||
return func(r *Registry) {
|
||||
r.globalFolders = globalFolders
|
||||
}
|
||||
}
|
||||
|
||||
func WithFsEnable(enabled bool) Option {
|
||||
return func(r *Registry) {
|
||||
r.fsEnabled = enabled
|
||||
}
|
||||
}
|
||||
|
||||
// Enable adds the require() function to the specified runtime.
|
||||
func (r *Registry) Enable(runtime *js.Runtime) *RequireModule {
|
||||
rrt := &RequireModule{
|
||||
r: r,
|
||||
runtime: runtime,
|
||||
modules: make(map[string]*js.Object),
|
||||
nodeModules: make(map[string]*js.Object),
|
||||
}
|
||||
|
||||
runtime.Set("require", rrt.require)
|
||||
return rrt
|
||||
}
|
||||
|
||||
func (r *Registry) RegisterNodeModule(name string, loader ModuleLoader) {
|
||||
r.Lock()
|
||||
defer r.Unlock()
|
||||
|
||||
if r.builtin == nil {
|
||||
r.builtin = make(map[string]ModuleLoader)
|
||||
}
|
||||
name = filepathClean(name)
|
||||
r.builtin[name] = loader
|
||||
}
|
||||
|
||||
func (r *Registry) RegisterNativeModule(name string, loader ModuleLoader) {
|
||||
r.Lock()
|
||||
defer r.Unlock()
|
||||
|
||||
if r.native == nil {
|
||||
r.native = make(map[string]ModuleLoader)
|
||||
}
|
||||
name = filepathClean(name)
|
||||
r.native[name] = loader
|
||||
}
|
||||
|
||||
// DefaultSourceLoader is used if none was set (see WithLoader()). It simply loads files from the host's filesystem.
|
||||
func DefaultSourceLoader(filename string) ([]byte, error) {
|
||||
fp := filepath.FromSlash(filename)
|
||||
f, err := os.Open(fp)
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
err = ModuleFileDoesNotExistError
|
||||
} else if runtime.GOOS == "windows" {
|
||||
if errors.Is(err, syscall.Errno(0x7b)) { // ERROR_INVALID_NAME, The filename, directory name, or volume label syntax is incorrect.
|
||||
err = ModuleFileDoesNotExistError
|
||||
}
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
defer f.Close()
|
||||
// On some systems (e.g. plan9 and FreeBSD) it is possible to use the standard read() call on directories
|
||||
// which means we cannot rely on read() returning an error, we have to do stat() instead.
|
||||
if fi, err := f.Stat(); err == nil {
|
||||
if fi.IsDir() {
|
||||
return nil, ModuleFileDoesNotExistError
|
||||
}
|
||||
} else {
|
||||
return nil, err
|
||||
}
|
||||
return io.ReadAll(f)
|
||||
}
|
||||
|
||||
func (r *Registry) getSource(p string) ([]byte, error) {
|
||||
srcLoader := r.srcLoader
|
||||
if srcLoader == nil {
|
||||
srcLoader = DefaultSourceLoader
|
||||
}
|
||||
return srcLoader(p)
|
||||
}
|
||||
|
||||
func (r *Registry) getCompiledSource(p string) (*js.Program, error) {
|
||||
r.Lock()
|
||||
defer r.Unlock()
|
||||
|
||||
prg := r.compiled[p]
|
||||
if prg == nil {
|
||||
buf, err := r.getSource(p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s := string(buf)
|
||||
|
||||
if path.Ext(p) == ".json" {
|
||||
s = "module.exports = JSON.parse('" + template.JSEscapeString(s) + "')"
|
||||
}
|
||||
|
||||
source := "(function(exports, require, module) {" + s + "\n})"
|
||||
parsed, err := js.Parse(p, source, parser.WithSourceMapLoader(r.srcLoader))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
prg, err = js.CompileAST(parsed, false)
|
||||
if err == nil {
|
||||
if r.compiled == nil {
|
||||
r.compiled = make(map[string]*js.Program)
|
||||
}
|
||||
r.compiled[p] = prg
|
||||
}
|
||||
return prg, err
|
||||
}
|
||||
return prg, nil
|
||||
}
|
||||
|
||||
func (r *RequireModule) require(call js.FunctionCall) js.Value {
|
||||
ret, err := r.Require(call.Argument(0).String())
|
||||
if err != nil {
|
||||
if _, ok := err.(*js.Exception); !ok {
|
||||
panic(r.runtime.NewGoError(err))
|
||||
}
|
||||
panic(err)
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
func filepathClean(p string) string {
|
||||
return path.Clean(p)
|
||||
}
|
||||
|
||||
// Require can be used to import modules from Go source (similar to JS require() function).
|
||||
func (r *RequireModule) Require(p string) (ret js.Value, err error) {
|
||||
module, err := r.resolve(p)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
ret = module.Get("exports")
|
||||
return
|
||||
}
|
||||
|
||||
func Require(runtime *js.Runtime, name string) js.Value {
|
||||
if r, ok := js.AssertFunction(runtime.Get("require")); ok {
|
||||
mod, err := r(js.Undefined(), runtime.ToValue(name))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return mod
|
||||
}
|
||||
panic(runtime.NewTypeError("Please enable require for this runtime using new(require.Registry).Enable(runtime)"))
|
||||
}
|
||||
277
script/modules/require/resolve.go
Normal file
277
script/modules/require/resolve.go
Normal file
@@ -0,0 +1,277 @@
|
||||
package require
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
js "github.com/dop251/goja"
|
||||
)
|
||||
|
||||
const NodePrefix = "node:"
|
||||
|
||||
// NodeJS module search algorithm described by
|
||||
// https://nodejs.org/api/modules.html#modules_all_together
|
||||
func (r *RequireModule) resolve(modpath string) (module *js.Object, err error) {
|
||||
origPath, modpath := modpath, filepathClean(modpath)
|
||||
if modpath == "" {
|
||||
return nil, IllegalModuleNameError
|
||||
}
|
||||
|
||||
var start string
|
||||
err = nil
|
||||
if path.IsAbs(origPath) {
|
||||
start = "/"
|
||||
} else {
|
||||
start = r.getCurrentModulePath()
|
||||
}
|
||||
|
||||
p := path.Join(start, modpath)
|
||||
if isFileOrDirectoryPath(origPath) && r.r.fsEnabled {
|
||||
if module = r.modules[p]; module != nil {
|
||||
return
|
||||
}
|
||||
module, err = r.loadAsFileOrDirectory(p)
|
||||
if err == nil && module != nil {
|
||||
r.modules[p] = module
|
||||
}
|
||||
} else {
|
||||
module, err = r.loadNative(origPath)
|
||||
if err == nil {
|
||||
return
|
||||
} else {
|
||||
if err == InvalidModuleError {
|
||||
err = nil
|
||||
} else {
|
||||
return
|
||||
}
|
||||
}
|
||||
if module = r.nodeModules[p]; module != nil {
|
||||
return
|
||||
}
|
||||
if r.r.fsEnabled {
|
||||
module, err = r.loadNodeModules(modpath, start)
|
||||
if err == nil && module != nil {
|
||||
r.nodeModules[p] = module
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if module == nil && err == nil {
|
||||
err = InvalidModuleError
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (r *RequireModule) loadNative(path string) (*js.Object, error) {
|
||||
module := r.modules[path]
|
||||
if module != nil {
|
||||
return module, nil
|
||||
}
|
||||
|
||||
var ldr ModuleLoader
|
||||
if r.r.native != nil {
|
||||
ldr = r.r.native[path]
|
||||
}
|
||||
var isBuiltIn, withPrefix bool
|
||||
if ldr == nil {
|
||||
if r.r.builtin != nil {
|
||||
ldr = r.r.builtin[path]
|
||||
}
|
||||
if ldr == nil && strings.HasPrefix(path, NodePrefix) {
|
||||
ldr = r.r.builtin[path[len(NodePrefix):]]
|
||||
if ldr == nil {
|
||||
return nil, NoSuchBuiltInModuleError
|
||||
}
|
||||
withPrefix = true
|
||||
}
|
||||
isBuiltIn = true
|
||||
}
|
||||
|
||||
if ldr != nil {
|
||||
module = r.createModuleObject()
|
||||
r.modules[path] = module
|
||||
if isBuiltIn {
|
||||
if withPrefix {
|
||||
r.modules[path[len(NodePrefix):]] = module
|
||||
} else {
|
||||
if !strings.HasPrefix(path, NodePrefix) {
|
||||
r.modules[NodePrefix+path] = module
|
||||
}
|
||||
}
|
||||
}
|
||||
ldr(r.runtime, module)
|
||||
return module, nil
|
||||
}
|
||||
|
||||
return nil, InvalidModuleError
|
||||
}
|
||||
|
||||
func (r *RequireModule) loadAsFileOrDirectory(path string) (module *js.Object, err error) {
|
||||
if module, err = r.loadAsFile(path); module != nil || err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
return r.loadAsDirectory(path)
|
||||
}
|
||||
|
||||
func (r *RequireModule) loadAsFile(path string) (module *js.Object, err error) {
|
||||
if module, err = r.loadModule(path); module != nil || err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
p := path + ".js"
|
||||
if module, err = r.loadModule(p); module != nil || err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
p = path + ".json"
|
||||
return r.loadModule(p)
|
||||
}
|
||||
|
||||
func (r *RequireModule) loadIndex(modpath string) (module *js.Object, err error) {
|
||||
p := path.Join(modpath, "index.js")
|
||||
if module, err = r.loadModule(p); module != nil || err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
p = path.Join(modpath, "index.json")
|
||||
return r.loadModule(p)
|
||||
}
|
||||
|
||||
func (r *RequireModule) loadAsDirectory(modpath string) (module *js.Object, err error) {
|
||||
p := path.Join(modpath, "package.json")
|
||||
buf, err := r.r.getSource(p)
|
||||
if err != nil {
|
||||
return r.loadIndex(modpath)
|
||||
}
|
||||
var pkg struct {
|
||||
Main string
|
||||
}
|
||||
err = json.Unmarshal(buf, &pkg)
|
||||
if err != nil || len(pkg.Main) == 0 {
|
||||
return r.loadIndex(modpath)
|
||||
}
|
||||
|
||||
m := path.Join(modpath, pkg.Main)
|
||||
if module, err = r.loadAsFile(m); module != nil || err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
return r.loadIndex(m)
|
||||
}
|
||||
|
||||
func (r *RequireModule) loadNodeModule(modpath, start string) (*js.Object, error) {
|
||||
return r.loadAsFileOrDirectory(path.Join(start, modpath))
|
||||
}
|
||||
|
||||
func (r *RequireModule) loadNodeModules(modpath, start string) (module *js.Object, err error) {
|
||||
for _, dir := range r.r.globalFolders {
|
||||
if module, err = r.loadNodeModule(modpath, dir); module != nil || err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
for {
|
||||
var p string
|
||||
if path.Base(start) != "node_modules" {
|
||||
p = path.Join(start, "node_modules")
|
||||
} else {
|
||||
p = start
|
||||
}
|
||||
if module, err = r.loadNodeModule(modpath, p); module != nil || err != nil {
|
||||
return
|
||||
}
|
||||
if start == ".." { // Dir('..') is '.'
|
||||
break
|
||||
}
|
||||
parent := path.Dir(start)
|
||||
if parent == start {
|
||||
break
|
||||
}
|
||||
start = parent
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (r *RequireModule) getCurrentModulePath() string {
|
||||
var buf [2]js.StackFrame
|
||||
frames := r.runtime.CaptureCallStack(2, buf[:0])
|
||||
if len(frames) < 2 {
|
||||
return "."
|
||||
}
|
||||
return path.Dir(frames[1].SrcName())
|
||||
}
|
||||
|
||||
func (r *RequireModule) createModuleObject() *js.Object {
|
||||
module := r.runtime.NewObject()
|
||||
module.Set("exports", r.runtime.NewObject())
|
||||
return module
|
||||
}
|
||||
|
||||
func (r *RequireModule) loadModule(path string) (*js.Object, error) {
|
||||
module := r.modules[path]
|
||||
if module == nil {
|
||||
module = r.createModuleObject()
|
||||
r.modules[path] = module
|
||||
err := r.loadModuleFile(path, module)
|
||||
if err != nil {
|
||||
module = nil
|
||||
delete(r.modules, path)
|
||||
if errors.Is(err, ModuleFileDoesNotExistError) {
|
||||
err = nil
|
||||
}
|
||||
}
|
||||
return module, err
|
||||
}
|
||||
return module, nil
|
||||
}
|
||||
|
||||
func (r *RequireModule) loadModuleFile(path string, jsModule *js.Object) error {
|
||||
prg, err := r.r.getCompiledSource(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
f, err := r.runtime.RunProgram(prg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if call, ok := js.AssertFunction(f); ok {
|
||||
jsExports := jsModule.Get("exports")
|
||||
jsRequire := r.runtime.Get("require")
|
||||
|
||||
// Run the module source, with "jsExports" as "this",
|
||||
// "jsExports" as the "exports" variable, "jsRequire"
|
||||
// as the "require" variable and "jsModule" as the
|
||||
// "module" variable (Nodejs capable).
|
||||
_, err = call(jsExports, jsExports, jsRequire, jsModule)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
return InvalidModuleError
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func isFileOrDirectoryPath(path string) bool {
|
||||
result := path == "." || path == ".." ||
|
||||
strings.HasPrefix(path, "/") ||
|
||||
strings.HasPrefix(path, "./") ||
|
||||
strings.HasPrefix(path, "../")
|
||||
|
||||
if runtime.GOOS == "windows" {
|
||||
result = result ||
|
||||
strings.HasPrefix(path, `.\`) ||
|
||||
strings.HasPrefix(path, `..\`) ||
|
||||
filepath.IsAbs(path)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
147
script/modules/sghttp/module.go
Normal file
147
script/modules/sghttp/module.go
Normal file
@@ -0,0 +1,147 @@
|
||||
package sghttp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/sagernet/sing-box/script/jsc"
|
||||
F "github.com/sagernet/sing/common/format"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
"golang.org/x/net/publicsuffix"
|
||||
)
|
||||
|
||||
type SurgeHTTP struct {
|
||||
vm *goja.Runtime
|
||||
ctx context.Context
|
||||
cookieAccess sync.RWMutex
|
||||
cookieJar *cookiejar.Jar
|
||||
errorHandler func(error)
|
||||
}
|
||||
|
||||
func Enable(vm *goja.Runtime, ctx context.Context, errorHandler func(error)) {
|
||||
sgHTTP := &SurgeHTTP{
|
||||
vm: vm,
|
||||
ctx: ctx,
|
||||
errorHandler: errorHandler,
|
||||
}
|
||||
httpObject := vm.NewObject()
|
||||
httpObject.Set("get", sgHTTP.request(http.MethodGet))
|
||||
httpObject.Set("post", sgHTTP.request(http.MethodPost))
|
||||
httpObject.Set("put", sgHTTP.request(http.MethodPut))
|
||||
httpObject.Set("delete", sgHTTP.request(http.MethodDelete))
|
||||
httpObject.Set("head", sgHTTP.request(http.MethodHead))
|
||||
httpObject.Set("options", sgHTTP.request(http.MethodOptions))
|
||||
httpObject.Set("patch", sgHTTP.request(http.MethodPatch))
|
||||
httpObject.Set("trace", sgHTTP.request(http.MethodTrace))
|
||||
vm.Set("$http", httpObject)
|
||||
}
|
||||
|
||||
func (s *SurgeHTTP) request(method string) func(call goja.FunctionCall) goja.Value {
|
||||
return func(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) != 2 {
|
||||
panic(s.vm.NewTypeError("invalid arguments"))
|
||||
}
|
||||
var (
|
||||
url string
|
||||
headers http.Header
|
||||
body []byte
|
||||
timeout = 5 * time.Second
|
||||
insecure bool
|
||||
autoCookie bool
|
||||
autoRedirect bool
|
||||
// policy string
|
||||
binaryMode bool
|
||||
)
|
||||
switch optionsValue := call.Argument(0).(type) {
|
||||
case goja.String:
|
||||
url = optionsValue.String()
|
||||
case *goja.Object:
|
||||
url = jsc.AssertString(s.vm, optionsValue.Get("url"), "options.url", false)
|
||||
headers = jsc.AssertHTTPHeader(s.vm, optionsValue.Get("headers"), "option.headers")
|
||||
body = jsc.AssertStringBinary(s.vm, optionsValue.Get("body"), "options.body", true)
|
||||
timeoutInt := jsc.AssertInt(s.vm, optionsValue.Get("timeout"), "options.timeout", true)
|
||||
if timeoutInt > 0 {
|
||||
timeout = time.Duration(timeoutInt) * time.Second
|
||||
}
|
||||
insecure = jsc.AssertBool(s.vm, optionsValue.Get("insecure"), "options.insecure", true)
|
||||
autoCookie = jsc.AssertBool(s.vm, optionsValue.Get("auto-cookie"), "options.auto-cookie", true)
|
||||
autoRedirect = jsc.AssertBool(s.vm, optionsValue.Get("auto-redirect"), "options.auto-redirect", true)
|
||||
// policy = jsc.AssertString(s.vm, optionsValue.Get("policy"), "options.policy", true)
|
||||
binaryMode = jsc.AssertBool(s.vm, optionsValue.Get("binary-mode"), "options.binary-mode", true)
|
||||
default:
|
||||
panic(s.vm.NewTypeError(F.ToString("invalid argument: options: expected string or object, but got ", optionsValue)))
|
||||
}
|
||||
callback := jsc.AssertFunction(s.vm, call.Argument(1), "callback")
|
||||
httpClient := &http.Client{
|
||||
Timeout: timeout,
|
||||
Transport: &http.Transport{
|
||||
TLSClientConfig: &tls.Config{
|
||||
InsecureSkipVerify: insecure,
|
||||
},
|
||||
ForceAttemptHTTP2: true,
|
||||
},
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
if autoRedirect {
|
||||
return nil
|
||||
}
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
}
|
||||
if autoCookie {
|
||||
s.cookieAccess.Lock()
|
||||
if s.cookieJar == nil {
|
||||
s.cookieJar, _ = cookiejar.New(&cookiejar.Options{
|
||||
PublicSuffixList: publicsuffix.List,
|
||||
})
|
||||
}
|
||||
httpClient.Jar = s.cookieJar
|
||||
s.cookieAccess.Lock()
|
||||
}
|
||||
request, err := http.NewRequestWithContext(s.ctx, method, url, bytes.NewReader(body))
|
||||
if host := headers.Get("Host"); host != "" {
|
||||
request.Host = host
|
||||
headers.Del("Host")
|
||||
}
|
||||
request.Header = headers
|
||||
if err != nil {
|
||||
panic(s.vm.NewGoError(err))
|
||||
}
|
||||
go func() {
|
||||
response, executeErr := httpClient.Do(request)
|
||||
if err != nil {
|
||||
_, err = callback(nil, s.vm.NewGoError(executeErr), nil, nil)
|
||||
if err != nil {
|
||||
s.errorHandler(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
defer response.Body.Close()
|
||||
var content []byte
|
||||
content, err = io.ReadAll(response.Body)
|
||||
if err != nil {
|
||||
_, err = callback(nil, s.vm.NewGoError(err), nil, nil)
|
||||
if err != nil {
|
||||
s.errorHandler(err)
|
||||
}
|
||||
}
|
||||
responseObject := s.vm.NewObject()
|
||||
responseObject.Set("status", response.StatusCode)
|
||||
responseObject.Set("headers", jsc.HeadersToValue(s.vm, response.Header))
|
||||
var bodyValue goja.Value
|
||||
if binaryMode {
|
||||
bodyValue = jsc.NewUint8Array(s.vm, content)
|
||||
} else {
|
||||
bodyValue = s.vm.ToValue(string(content))
|
||||
}
|
||||
_, err = callback(nil, nil, responseObject, bodyValue)
|
||||
}()
|
||||
return goja.Undefined()
|
||||
}
|
||||
}
|
||||
111
script/modules/sgnotification/module.go
Normal file
111
script/modules/sgnotification/module.go
Normal file
@@ -0,0 +1,111 @@
|
||||
package sgnotification
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"strings"
|
||||
|
||||
"github.com/sagernet/sing-box/experimental/libbox/platform"
|
||||
"github.com/sagernet/sing-box/script/jsc"
|
||||
E "github.com/sagernet/sing/common/exceptions"
|
||||
"github.com/sagernet/sing/common/logger"
|
||||
"github.com/sagernet/sing/service"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
type SurgeNotification struct {
|
||||
vm *goja.Runtime
|
||||
logger logger.Logger
|
||||
platformInterface platform.Interface
|
||||
scriptTag string
|
||||
}
|
||||
|
||||
func Enable(vm *goja.Runtime, ctx context.Context, logger logger.Logger) {
|
||||
platformInterface := service.FromContext[platform.Interface](ctx)
|
||||
notification := &SurgeNotification{
|
||||
vm: vm,
|
||||
logger: logger,
|
||||
platformInterface: platformInterface,
|
||||
}
|
||||
notificationObject := vm.NewObject()
|
||||
notificationObject.Set("post", notification.js_post)
|
||||
vm.Set("$notification", notificationObject)
|
||||
}
|
||||
|
||||
func (s *SurgeNotification) js_post(call goja.FunctionCall) goja.Value {
|
||||
var (
|
||||
title string
|
||||
subtitle string
|
||||
body string
|
||||
openURL string
|
||||
clipboard string
|
||||
mediaURL string
|
||||
mediaData []byte
|
||||
mediaType string
|
||||
autoDismiss int
|
||||
)
|
||||
title = jsc.AssertString(s.vm, call.Argument(0), "title", true)
|
||||
subtitle = jsc.AssertString(s.vm, call.Argument(1), "subtitle", true)
|
||||
body = jsc.AssertString(s.vm, call.Argument(2), "body", true)
|
||||
options := jsc.AssertObject(s.vm, call.Argument(3), "options", true)
|
||||
if options != nil {
|
||||
action := jsc.AssertString(s.vm, options.Get("action"), "options.action", true)
|
||||
switch action {
|
||||
case "open-url":
|
||||
openURL = jsc.AssertString(s.vm, options.Get("url"), "options.url", false)
|
||||
case "clipboard":
|
||||
clipboard = jsc.AssertString(s.vm, options.Get("clipboard"), "options.clipboard", false)
|
||||
}
|
||||
mediaURL = jsc.AssertString(s.vm, options.Get("media-url"), "options.media-url", true)
|
||||
mediaBase64 := jsc.AssertString(s.vm, options.Get("media-base64"), "options.media-base64", true)
|
||||
if mediaBase64 != "" {
|
||||
mediaBinary, err := base64.StdEncoding.DecodeString(mediaBase64)
|
||||
if err != nil {
|
||||
panic(s.vm.NewGoError(E.Cause(err, "decode media-base64")))
|
||||
}
|
||||
mediaData = mediaBinary
|
||||
mediaType = jsc.AssertString(s.vm, options.Get("media-base64-mime"), "options.media-base64-mime", false)
|
||||
}
|
||||
autoDismiss = int(jsc.AssertInt(s.vm, options.Get("auto-dismiss"), "options.auto-dismiss", true))
|
||||
}
|
||||
if title != "" && subtitle == "" && body == "" {
|
||||
body = title
|
||||
title = ""
|
||||
} else if title != "" && subtitle != "" && body == "" {
|
||||
body = subtitle
|
||||
subtitle = ""
|
||||
}
|
||||
var builder strings.Builder
|
||||
if title != "" {
|
||||
builder.WriteString("[")
|
||||
builder.WriteString(title)
|
||||
if subtitle != "" {
|
||||
builder.WriteString(" - ")
|
||||
builder.WriteString(subtitle)
|
||||
}
|
||||
builder.WriteString("]: ")
|
||||
}
|
||||
builder.WriteString(body)
|
||||
s.logger.Info("notification: " + builder.String())
|
||||
if s.platformInterface != nil {
|
||||
err := s.platformInterface.SendNotification(&platform.Notification{
|
||||
Identifier: "surge-script-notification-" + s.scriptTag,
|
||||
TypeName: "Surge Script Notification (" + s.scriptTag + ")",
|
||||
TypeID: 11,
|
||||
Title: title,
|
||||
Subtitle: subtitle,
|
||||
Body: body,
|
||||
OpenURL: openURL,
|
||||
Clipboard: clipboard,
|
||||
MediaURL: mediaURL,
|
||||
MediaData: mediaData,
|
||||
MediaType: mediaType,
|
||||
Timeout: autoDismiss,
|
||||
})
|
||||
if err != nil {
|
||||
s.logger.Error(E.Cause(err, "send notification"))
|
||||
}
|
||||
}
|
||||
return goja.Undefined()
|
||||
}
|
||||
76
script/modules/sgstore/module.go
Normal file
76
script/modules/sgstore/module.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package sgstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/sagernet/sing-box/adapter"
|
||||
"github.com/sagernet/sing-box/script/jsc"
|
||||
"github.com/sagernet/sing/service"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
type SurgePersistentStore struct {
|
||||
vm *goja.Runtime
|
||||
cacheFile adapter.CacheFile
|
||||
data map[string]string
|
||||
tag string
|
||||
}
|
||||
|
||||
func Enable(vm *goja.Runtime, ctx context.Context) {
|
||||
object := vm.NewObject()
|
||||
cacheFile := service.FromContext[adapter.CacheFile](ctx)
|
||||
tag := vm.Get("$script").(*goja.Object).Get("name").String()
|
||||
store := &SurgePersistentStore{
|
||||
vm: vm,
|
||||
cacheFile: cacheFile,
|
||||
tag: tag,
|
||||
}
|
||||
if cacheFile == nil {
|
||||
store.data = make(map[string]string)
|
||||
}
|
||||
object.Set("read", store.js_read)
|
||||
object.Set("write", store.js_write)
|
||||
vm.Set("$persistentStore", object)
|
||||
}
|
||||
|
||||
func (s *SurgePersistentStore) js_read(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) > 1 {
|
||||
panic(s.vm.NewTypeError("invalid arguments"))
|
||||
}
|
||||
key := jsc.AssertString(s.vm, call.Argument(0), "key", true)
|
||||
if key == "" {
|
||||
key = s.tag
|
||||
}
|
||||
var value string
|
||||
if s.cacheFile != nil {
|
||||
value = s.cacheFile.SurgePersistentStoreRead(key)
|
||||
} else {
|
||||
value = s.data[key]
|
||||
}
|
||||
if value == "" {
|
||||
return goja.Null()
|
||||
} else {
|
||||
return s.vm.ToValue(value)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SurgePersistentStore) js_write(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) == 0 || len(call.Arguments) > 2 {
|
||||
panic(s.vm.NewTypeError("invalid arguments"))
|
||||
}
|
||||
data := jsc.AssertString(s.vm, call.Argument(0), "data", true)
|
||||
key := jsc.AssertString(s.vm, call.Argument(1), "key", true)
|
||||
if key == "" {
|
||||
key = s.tag
|
||||
}
|
||||
if s.cacheFile != nil {
|
||||
err := s.cacheFile.SurgePersistentStoreWrite(key, data)
|
||||
if err != nil {
|
||||
panic(s.vm.NewGoError(err))
|
||||
}
|
||||
} else {
|
||||
s.data[key] = data
|
||||
}
|
||||
return goja.Undefined()
|
||||
}
|
||||
45
script/modules/sgutils/module.go
Normal file
45
script/modules/sgutils/module.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package sgutils
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"io"
|
||||
|
||||
"github.com/sagernet/sing-box/script/jsc"
|
||||
E "github.com/sagernet/sing/common/exceptions"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
type SurgeUtils struct {
|
||||
vm *goja.Runtime
|
||||
}
|
||||
|
||||
func Enable(runtime *goja.Runtime) {
|
||||
utils := &SurgeUtils{runtime}
|
||||
object := runtime.NewObject()
|
||||
object.Set("geoip", utils.js_stub)
|
||||
object.Set("ipasn", utils.js_stub)
|
||||
object.Set("ipaso", utils.js_stub)
|
||||
object.Set("ungzip", utils.js_ungzip)
|
||||
}
|
||||
|
||||
func (u *SurgeUtils) js_stub(call goja.FunctionCall) goja.Value {
|
||||
panic(u.vm.NewGoError(E.New("not implemented")))
|
||||
}
|
||||
|
||||
func (u *SurgeUtils) js_ungzip(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) != 1 {
|
||||
panic(u.vm.NewGoError(E.New("invalid argument")))
|
||||
}
|
||||
binary := jsc.AssertBinary(u.vm, call.Argument(0), "binary", false)
|
||||
reader, err := gzip.NewReader(bytes.NewReader(binary))
|
||||
if err != nil {
|
||||
panic(u.vm.NewGoError(err))
|
||||
}
|
||||
binary, err = io.ReadAll(reader)
|
||||
if err != nil {
|
||||
panic(u.vm.NewGoError(err))
|
||||
}
|
||||
return jsc.NewUint8Array(u.vm, binary)
|
||||
}
|
||||
26
script/script.go
Normal file
26
script/script.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package script
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/sagernet/sing-box/adapter"
|
||||
C "github.com/sagernet/sing-box/constant"
|
||||
"github.com/sagernet/sing-box/option"
|
||||
E "github.com/sagernet/sing/common/exceptions"
|
||||
"github.com/sagernet/sing/common/logger"
|
||||
)
|
||||
|
||||
func NewScript(ctx context.Context, logger logger.ContextLogger, options option.Script) (adapter.Script, error) {
|
||||
switch options.Type {
|
||||
case C.ScriptTypeSurgeGeneric:
|
||||
return NewSurgeGenericScript(ctx, logger, options)
|
||||
case C.ScriptTypeSurgeHTTPRequest:
|
||||
return NewSurgeHTTPRequestScript(ctx, logger, options)
|
||||
case C.ScriptTypeSurgeHTTPResponse:
|
||||
return NewSurgeHTTPResponseScript(ctx, logger, options)
|
||||
case C.ScriptTypeSurgeCron:
|
||||
return NewSurgeCronScript(ctx, logger, options)
|
||||
default:
|
||||
return nil, E.New("unknown script type: ", options.Type)
|
||||
}
|
||||
}
|
||||
119
script/script_surge_cron.go
Normal file
119
script/script_surge_cron.go
Normal file
@@ -0,0 +1,119 @@
|
||||
package script
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/sagernet/sing-box/adapter"
|
||||
C "github.com/sagernet/sing-box/constant"
|
||||
"github.com/sagernet/sing-box/log"
|
||||
"github.com/sagernet/sing-box/option"
|
||||
E "github.com/sagernet/sing/common/exceptions"
|
||||
"github.com/sagernet/sing/common/logger"
|
||||
|
||||
"github.com/adhocore/gronx"
|
||||
)
|
||||
|
||||
var _ adapter.GenericScript = (*SurgeCronScript)(nil)
|
||||
|
||||
type SurgeCronScript struct {
|
||||
GenericScript
|
||||
ctx context.Context
|
||||
expression string
|
||||
timer *time.Timer
|
||||
}
|
||||
|
||||
func NewSurgeCronScript(ctx context.Context, logger logger.ContextLogger, options option.Script) (*SurgeCronScript, error) {
|
||||
source, err := NewSource(ctx, logger, options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !gronx.IsValid(options.CronOptions.Expression) {
|
||||
return nil, E.New("invalid cron expression: ", options.CronOptions.Expression)
|
||||
}
|
||||
return &SurgeCronScript{
|
||||
GenericScript: GenericScript{
|
||||
logger: logger,
|
||||
tag: options.Tag,
|
||||
timeout: time.Duration(options.Timeout),
|
||||
arguments: options.Arguments,
|
||||
source: source,
|
||||
},
|
||||
ctx: ctx,
|
||||
expression: options.CronOptions.Expression,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *SurgeCronScript) Type() string {
|
||||
return C.ScriptTypeSurgeCron
|
||||
}
|
||||
|
||||
func (s *SurgeCronScript) Tag() string {
|
||||
return s.tag
|
||||
}
|
||||
|
||||
func (s *SurgeCronScript) StartContext(ctx context.Context, startContext *adapter.HTTPStartContext) error {
|
||||
return s.source.StartContext(ctx, startContext)
|
||||
}
|
||||
|
||||
func (s *SurgeCronScript) PostStart() error {
|
||||
err := s.source.PostStart()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
go s.loop()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SurgeCronScript) loop() {
|
||||
s.logger.Debug("starting event")
|
||||
err := s.Run(s.ctx)
|
||||
if err != nil {
|
||||
s.logger.Error(E.Cause(err, "running event"))
|
||||
}
|
||||
nextTick, err := gronx.NextTick(s.expression, false)
|
||||
if err != nil {
|
||||
s.logger.Error(E.Cause(err, "determine next tick"))
|
||||
return
|
||||
}
|
||||
s.timer = time.NewTimer(nextTick.Sub(time.Now()))
|
||||
s.logger.Debug("next event at: ", nextTick.Format(log.DefaultTimeFormat))
|
||||
for {
|
||||
select {
|
||||
case <-s.ctx.Done():
|
||||
return
|
||||
case <-s.timer.C:
|
||||
s.logger.Debug("starting event")
|
||||
err = s.Run(s.ctx)
|
||||
if err != nil {
|
||||
s.logger.Error(E.Cause(err, "running event"))
|
||||
}
|
||||
nextTick, err = gronx.NextTick(s.expression, false)
|
||||
if err != nil {
|
||||
s.logger.Error(E.Cause(err, "determine next tick"))
|
||||
return
|
||||
}
|
||||
s.timer.Reset(nextTick.Sub(time.Now()))
|
||||
s.logger.Debug("next event at: ", nextTick)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SurgeCronScript) Close() error {
|
||||
return s.source.Close()
|
||||
}
|
||||
|
||||
func (s *SurgeCronScript) Run(ctx context.Context) error {
|
||||
program := s.source.Program()
|
||||
if program == nil {
|
||||
return E.New("invalid script")
|
||||
}
|
||||
ctx, cancel := context.WithCancelCause(ctx)
|
||||
defer cancel(nil)
|
||||
vm := NewRuntime(ctx, s.logger, cancel)
|
||||
err := SetSurgeModules(vm, ctx, s.logger, cancel, s.Tag(), s.Type(), s.arguments)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ExecuteSurgeGeneral(vm, program, ctx, s.timeout)
|
||||
}
|
||||
183
script/script_surge_generic.go
Normal file
183
script/script_surge_generic.go
Normal file
@@ -0,0 +1,183 @@
|
||||
package script
|
||||
|
||||
import (
|
||||
"context"
|
||||
"runtime"
|
||||
"time"
|
||||
|
||||
"github.com/sagernet/sing-box/adapter"
|
||||
C "github.com/sagernet/sing-box/constant"
|
||||
"github.com/sagernet/sing-box/experimental/locale"
|
||||
"github.com/sagernet/sing-box/option"
|
||||
"github.com/sagernet/sing-box/script/jsc"
|
||||
"github.com/sagernet/sing-box/script/modules/console"
|
||||
"github.com/sagernet/sing-box/script/modules/eventloop"
|
||||
"github.com/sagernet/sing-box/script/modules/require"
|
||||
"github.com/sagernet/sing-box/script/modules/sghttp"
|
||||
"github.com/sagernet/sing-box/script/modules/sgnotification"
|
||||
"github.com/sagernet/sing-box/script/modules/sgstore"
|
||||
"github.com/sagernet/sing-box/script/modules/sgutils"
|
||||
"github.com/sagernet/sing/common"
|
||||
E "github.com/sagernet/sing/common/exceptions"
|
||||
F "github.com/sagernet/sing/common/format"
|
||||
"github.com/sagernet/sing/common/logger"
|
||||
"github.com/sagernet/sing/common/ntp"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
"github.com/dop251/goja/parser"
|
||||
)
|
||||
|
||||
const defaultScriptTimeout = 10 * time.Second
|
||||
|
||||
var _ adapter.GenericScript = (*GenericScript)(nil)
|
||||
|
||||
type GenericScript struct {
|
||||
logger logger.ContextLogger
|
||||
tag string
|
||||
timeout time.Duration
|
||||
arguments []any
|
||||
source Source
|
||||
}
|
||||
|
||||
func NewSurgeGenericScript(ctx context.Context, logger logger.ContextLogger, options option.Script) (*GenericScript, error) {
|
||||
source, err := NewSource(ctx, logger, options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &GenericScript{
|
||||
logger: logger,
|
||||
tag: options.Tag,
|
||||
timeout: time.Duration(options.Timeout),
|
||||
arguments: options.Arguments,
|
||||
source: source,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *GenericScript) Type() string {
|
||||
return C.ScriptTypeSurgeGeneric
|
||||
}
|
||||
|
||||
func (s *GenericScript) Tag() string {
|
||||
return s.tag
|
||||
}
|
||||
|
||||
func (s *GenericScript) StartContext(ctx context.Context, startContext *adapter.HTTPStartContext) error {
|
||||
return s.source.StartContext(ctx, startContext)
|
||||
}
|
||||
|
||||
func (s *GenericScript) PostStart() error {
|
||||
return s.source.PostStart()
|
||||
}
|
||||
|
||||
func (s *GenericScript) Close() error {
|
||||
return s.source.Close()
|
||||
}
|
||||
|
||||
func (s *GenericScript) Run(ctx context.Context) error {
|
||||
program := s.source.Program()
|
||||
if program == nil {
|
||||
return E.New("invalid script")
|
||||
}
|
||||
ctx, cancel := context.WithCancelCause(ctx)
|
||||
defer cancel(nil)
|
||||
vm := NewRuntime(ctx, s.logger, cancel)
|
||||
err := SetSurgeModules(vm, ctx, s.logger, cancel, s.Tag(), s.Type(), s.arguments)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ExecuteSurgeGeneral(vm, program, ctx, s.timeout)
|
||||
}
|
||||
|
||||
func NewRuntime(ctx context.Context, logger logger.ContextLogger, cancel context.CancelCauseFunc) *goja.Runtime {
|
||||
vm := goja.New()
|
||||
if timeFunc := ntp.TimeFuncFromContext(ctx); timeFunc != nil {
|
||||
vm.SetTimeSource(timeFunc)
|
||||
}
|
||||
vm.SetParserOptions(parser.WithDisableSourceMaps)
|
||||
registry := require.NewRegistry(require.WithLoader(func(path string) ([]byte, error) {
|
||||
return nil, E.New("unsupported usage")
|
||||
}))
|
||||
registry.Enable(vm)
|
||||
registry.RegisterNodeModule(console.ModuleName, console.Require(ctx, logger))
|
||||
console.Enable(vm)
|
||||
eventloop.Enable(vm, cancel)
|
||||
return vm
|
||||
}
|
||||
|
||||
func SetSurgeModules(vm *goja.Runtime, ctx context.Context, logger logger.Logger, errorHandler func(error), tag string, scriptType string, arguments []any) error {
|
||||
script := vm.NewObject()
|
||||
script.Set("name", F.ToString("script:", tag))
|
||||
script.Set("startTime", jsc.TimeToValue(vm, time.Now()))
|
||||
script.Set("type", scriptType)
|
||||
vm.Set("$script", script)
|
||||
|
||||
environment := vm.NewObject()
|
||||
var system string
|
||||
switch runtime.GOOS {
|
||||
case "ios":
|
||||
system = "iOS"
|
||||
case "darwin":
|
||||
system = "macOS"
|
||||
case "tvos":
|
||||
system = "tvOS"
|
||||
case "linux":
|
||||
system = "Linux"
|
||||
case "android":
|
||||
system = "Android"
|
||||
case "windows":
|
||||
system = "Windows"
|
||||
default:
|
||||
system = runtime.GOOS
|
||||
}
|
||||
environment.Set("system", system)
|
||||
environment.Set("surge-build", "N/A")
|
||||
environment.Set("surge-version", "sing-box "+C.Version)
|
||||
environment.Set("language", locale.Current().Locale)
|
||||
environment.Set("device-model", "N/A")
|
||||
vm.Set("$environment", environment)
|
||||
|
||||
sgstore.Enable(vm, ctx)
|
||||
sgutils.Enable(vm)
|
||||
sghttp.Enable(vm, ctx, errorHandler)
|
||||
sgnotification.Enable(vm, ctx, logger)
|
||||
|
||||
vm.Set("$argument", arguments)
|
||||
return nil
|
||||
}
|
||||
|
||||
func ExecuteSurgeGeneral(vm *goja.Runtime, program *goja.Program, ctx context.Context, timeout time.Duration) error {
|
||||
if timeout == 0 {
|
||||
timeout = defaultScriptTimeout
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
|
||||
vm.ClearInterrupt()
|
||||
done := make(chan struct{})
|
||||
doneFunc := common.OnceFunc(func() {
|
||||
close(done)
|
||||
})
|
||||
vm.Set("done", func(call goja.FunctionCall) goja.Value {
|
||||
doneFunc()
|
||||
return goja.Undefined()
|
||||
})
|
||||
var err error
|
||||
go func() {
|
||||
_, err = vm.RunProgram(program)
|
||||
if err != nil {
|
||||
doneFunc()
|
||||
}
|
||||
}()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
vm.Interrupt(ctx.Err())
|
||||
return ctx.Err()
|
||||
case <-done:
|
||||
if err != nil {
|
||||
vm.Interrupt(err)
|
||||
} else {
|
||||
vm.Interrupt("script done")
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
165
script/script_surge_http_request.go
Normal file
165
script/script_surge_http_request.go
Normal file
@@ -0,0 +1,165 @@
|
||||
package script
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
"github.com/sagernet/sing-box/adapter"
|
||||
C "github.com/sagernet/sing-box/constant"
|
||||
"github.com/sagernet/sing-box/option"
|
||||
"github.com/sagernet/sing-box/script/jsc"
|
||||
"github.com/sagernet/sing/common"
|
||||
E "github.com/sagernet/sing/common/exceptions"
|
||||
F "github.com/sagernet/sing/common/format"
|
||||
"github.com/sagernet/sing/common/logger"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
var _ adapter.HTTPRequestScript = (*SurgeHTTPRequestScript)(nil)
|
||||
|
||||
type SurgeHTTPRequestScript struct {
|
||||
GenericScript
|
||||
pattern *regexp.Regexp
|
||||
requiresBody bool
|
||||
maxSize int64
|
||||
binaryBodyMode bool
|
||||
}
|
||||
|
||||
func NewSurgeHTTPRequestScript(ctx context.Context, logger logger.ContextLogger, options option.Script) (*SurgeHTTPRequestScript, error) {
|
||||
source, err := NewSource(ctx, logger, options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pattern, err := regexp.Compile(options.HTTPOptions.Pattern)
|
||||
if err != nil {
|
||||
return nil, E.Cause(err, "parse pattern")
|
||||
}
|
||||
return &SurgeHTTPRequestScript{
|
||||
GenericScript: GenericScript{
|
||||
logger: logger,
|
||||
tag: options.Tag,
|
||||
timeout: time.Duration(options.Timeout),
|
||||
arguments: options.Arguments,
|
||||
source: source,
|
||||
},
|
||||
pattern: pattern,
|
||||
requiresBody: options.HTTPOptions.RequiresBody,
|
||||
maxSize: options.HTTPOptions.MaxSize,
|
||||
binaryBodyMode: options.HTTPOptions.BinaryBodyMode,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *SurgeHTTPRequestScript) Type() string {
|
||||
return C.ScriptTypeSurgeHTTPRequest
|
||||
}
|
||||
|
||||
func (s *SurgeHTTPRequestScript) Tag() string {
|
||||
return s.tag
|
||||
}
|
||||
|
||||
func (s *SurgeHTTPRequestScript) StartContext(ctx context.Context, startContext *adapter.HTTPStartContext) error {
|
||||
return s.source.StartContext(ctx, startContext)
|
||||
}
|
||||
|
||||
func (s *SurgeHTTPRequestScript) PostStart() error {
|
||||
return s.source.PostStart()
|
||||
}
|
||||
|
||||
func (s *SurgeHTTPRequestScript) Close() error {
|
||||
return s.source.Close()
|
||||
}
|
||||
|
||||
func (s *SurgeHTTPRequestScript) Match(requestURL string) bool {
|
||||
return s.pattern.MatchString(requestURL)
|
||||
}
|
||||
|
||||
func (s *SurgeHTTPRequestScript) RequiresBody() bool {
|
||||
return s.requiresBody
|
||||
}
|
||||
|
||||
func (s *SurgeHTTPRequestScript) MaxSize() int64 {
|
||||
return s.maxSize
|
||||
}
|
||||
|
||||
func (s *SurgeHTTPRequestScript) Run(ctx context.Context, request *http.Request, body []byte) (*adapter.HTTPRequestScriptResult, error) {
|
||||
program := s.source.Program()
|
||||
if program == nil {
|
||||
return nil, E.New("invalid script")
|
||||
}
|
||||
ctx, cancel := context.WithCancelCause(ctx)
|
||||
defer cancel(nil)
|
||||
vm := NewRuntime(ctx, s.logger, cancel)
|
||||
err := SetSurgeModules(vm, ctx, s.logger, cancel, s.Tag(), s.Type(), s.arguments)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ExecuteSurgeHTTPRequest(vm, program, ctx, s.timeout, request, body, s.binaryBodyMode)
|
||||
}
|
||||
|
||||
func ExecuteSurgeHTTPRequest(vm *goja.Runtime, program *goja.Program, ctx context.Context, timeout time.Duration, request *http.Request, body []byte, binaryBody bool) (*adapter.HTTPRequestScriptResult, error) {
|
||||
if timeout == 0 {
|
||||
timeout = defaultScriptTimeout
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
|
||||
vm.ClearInterrupt()
|
||||
requestObject := vm.NewObject()
|
||||
requestObject.Set("url", request.URL.String())
|
||||
requestObject.Set("method", request.Method)
|
||||
requestObject.Set("headers", jsc.HeadersToValue(vm, request.Header))
|
||||
if !binaryBody {
|
||||
requestObject.Set("body", string(body))
|
||||
} else {
|
||||
requestObject.Set("body", jsc.NewUint8Array(vm, body))
|
||||
}
|
||||
requestObject.Set("id", F.ToString(uintptr(unsafe.Pointer(request))))
|
||||
vm.Set("request", requestObject)
|
||||
done := make(chan struct{})
|
||||
doneFunc := common.OnceFunc(func() {
|
||||
close(done)
|
||||
})
|
||||
var result adapter.HTTPRequestScriptResult
|
||||
vm.Set("done", func(call goja.FunctionCall) goja.Value {
|
||||
defer doneFunc()
|
||||
resultObject := jsc.AssertObject(vm, call.Argument(0), "done() argument", true)
|
||||
if resultObject == nil {
|
||||
panic(vm.NewGoError(E.New("request rejected by script")))
|
||||
}
|
||||
result.URL = jsc.AssertString(vm, resultObject.Get("url"), "url", true)
|
||||
result.Headers = jsc.AssertHTTPHeader(vm, resultObject.Get("headers"), "headers")
|
||||
result.Body = jsc.AssertStringBinary(vm, resultObject.Get("body"), "body", true)
|
||||
responseObject := jsc.AssertObject(vm, resultObject.Get("response"), "response", true)
|
||||
if responseObject != nil {
|
||||
result.Response = &adapter.HTTPRequestScriptResponse{
|
||||
Status: int(jsc.AssertInt(vm, responseObject.Get("status"), "status", true)),
|
||||
Headers: jsc.AssertHTTPHeader(vm, responseObject.Get("headers"), "headers"),
|
||||
Body: jsc.AssertStringBinary(vm, responseObject.Get("body"), "body", true),
|
||||
}
|
||||
}
|
||||
return goja.Undefined()
|
||||
})
|
||||
var err error
|
||||
go func() {
|
||||
_, err = vm.RunProgram(program)
|
||||
if err != nil {
|
||||
doneFunc()
|
||||
}
|
||||
}()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
vm.Interrupt(ctx.Err())
|
||||
return nil, ctx.Err()
|
||||
case <-done:
|
||||
if err != nil {
|
||||
vm.Interrupt(err)
|
||||
} else {
|
||||
vm.Interrupt("script done")
|
||||
}
|
||||
}
|
||||
return &result, err
|
||||
}
|
||||
175
script/script_surge_http_response.go
Normal file
175
script/script_surge_http_response.go
Normal file
@@ -0,0 +1,175 @@
|
||||
package script
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"sync"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
"github.com/sagernet/sing-box/adapter"
|
||||
C "github.com/sagernet/sing-box/constant"
|
||||
"github.com/sagernet/sing-box/option"
|
||||
"github.com/sagernet/sing-box/script/jsc"
|
||||
"github.com/sagernet/sing/common"
|
||||
E "github.com/sagernet/sing/common/exceptions"
|
||||
F "github.com/sagernet/sing/common/format"
|
||||
"github.com/sagernet/sing/common/logger"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
var _ adapter.HTTPResponseScript = (*SurgeHTTPResponseScript)(nil)
|
||||
|
||||
type SurgeHTTPResponseScript struct {
|
||||
GenericScript
|
||||
pattern *regexp.Regexp
|
||||
requiresBody bool
|
||||
maxSize int64
|
||||
binaryBodyMode bool
|
||||
}
|
||||
|
||||
func NewSurgeHTTPResponseScript(ctx context.Context, logger logger.ContextLogger, options option.Script) (*SurgeHTTPResponseScript, error) {
|
||||
source, err := NewSource(ctx, logger, options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pattern, err := regexp.Compile(options.HTTPOptions.Pattern)
|
||||
if err != nil {
|
||||
return nil, E.Cause(err, "parse pattern")
|
||||
}
|
||||
return &SurgeHTTPResponseScript{
|
||||
GenericScript: GenericScript{
|
||||
logger: logger,
|
||||
tag: options.Tag,
|
||||
timeout: time.Duration(options.Timeout),
|
||||
arguments: options.Arguments,
|
||||
source: source,
|
||||
},
|
||||
pattern: pattern,
|
||||
requiresBody: options.HTTPOptions.RequiresBody,
|
||||
maxSize: options.HTTPOptions.MaxSize,
|
||||
binaryBodyMode: options.HTTPOptions.BinaryBodyMode,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *SurgeHTTPResponseScript) Type() string {
|
||||
return C.ScriptTypeSurgeHTTPResponse
|
||||
}
|
||||
|
||||
func (s *SurgeHTTPResponseScript) Tag() string {
|
||||
return s.tag
|
||||
}
|
||||
|
||||
func (s *SurgeHTTPResponseScript) StartContext(ctx context.Context, startContext *adapter.HTTPStartContext) error {
|
||||
return s.source.StartContext(ctx, startContext)
|
||||
}
|
||||
|
||||
func (s *SurgeHTTPResponseScript) PostStart() error {
|
||||
return s.source.PostStart()
|
||||
}
|
||||
|
||||
func (s *SurgeHTTPResponseScript) Close() error {
|
||||
return s.source.Close()
|
||||
}
|
||||
|
||||
func (s *SurgeHTTPResponseScript) Match(requestURL string) bool {
|
||||
return s.pattern.MatchString(requestURL)
|
||||
}
|
||||
|
||||
func (s *SurgeHTTPResponseScript) RequiresBody() bool {
|
||||
return s.requiresBody
|
||||
}
|
||||
|
||||
func (s *SurgeHTTPResponseScript) MaxSize() int64 {
|
||||
return s.maxSize
|
||||
}
|
||||
|
||||
func (s *SurgeHTTPResponseScript) Run(ctx context.Context, request *http.Request, response *http.Response, body []byte) (*adapter.HTTPResponseScriptResult, error) {
|
||||
program := s.source.Program()
|
||||
if program == nil {
|
||||
return nil, E.New("invalid script")
|
||||
}
|
||||
ctx, cancel := context.WithCancelCause(ctx)
|
||||
defer cancel(nil)
|
||||
vm := NewRuntime(ctx, s.logger, cancel)
|
||||
err := SetSurgeModules(vm, ctx, s.logger, cancel, s.Tag(), s.Type(), s.arguments)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ExecuteSurgeHTTPResponse(vm, program, ctx, s.timeout, request, response, body, s.binaryBodyMode)
|
||||
}
|
||||
|
||||
func ExecuteSurgeHTTPResponse(vm *goja.Runtime, program *goja.Program, ctx context.Context, timeout time.Duration, request *http.Request, response *http.Response, body []byte, binaryBody bool) (*adapter.HTTPResponseScriptResult, error) {
|
||||
if timeout == 0 {
|
||||
timeout = defaultScriptTimeout
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
|
||||
vm.ClearInterrupt()
|
||||
requestObject := vm.NewObject()
|
||||
requestObject.Set("url", request.URL.String())
|
||||
requestObject.Set("method", request.Method)
|
||||
requestObject.Set("headers", jsc.HeadersToValue(vm, request.Header))
|
||||
requestObject.Set("id", F.ToString(uintptr(unsafe.Pointer(request))))
|
||||
vm.Set("request", requestObject)
|
||||
|
||||
responseObject := vm.NewObject()
|
||||
responseObject.Set("status", response.StatusCode)
|
||||
responseObject.Set("headers", jsc.HeadersToValue(vm, response.Header))
|
||||
if !binaryBody {
|
||||
responseObject.Set("body", string(body))
|
||||
} else {
|
||||
responseObject.Set("body", jsc.NewUint8Array(vm, body))
|
||||
}
|
||||
vm.Set("response", responseObject)
|
||||
|
||||
done := make(chan struct{})
|
||||
doneFunc := common.OnceFunc(func() {
|
||||
close(done)
|
||||
})
|
||||
var (
|
||||
access sync.Mutex
|
||||
result adapter.HTTPResponseScriptResult
|
||||
)
|
||||
vm.Set("done", func(call goja.FunctionCall) goja.Value {
|
||||
resultObject := jsc.AssertObject(vm, call.Argument(0), "done() argument", true)
|
||||
if resultObject == nil {
|
||||
panic(vm.NewGoError(E.New("response rejected by script")))
|
||||
}
|
||||
access.Lock()
|
||||
defer access.Unlock()
|
||||
result.Status = int(jsc.AssertInt(vm, resultObject.Get("status"), "status", true))
|
||||
result.Headers = jsc.AssertHTTPHeader(vm, resultObject.Get("headers"), "headers")
|
||||
result.Body = jsc.AssertStringBinary(vm, resultObject.Get("body"), "body", true)
|
||||
doneFunc()
|
||||
return goja.Undefined()
|
||||
})
|
||||
var scriptErr error
|
||||
go func() {
|
||||
_, err := vm.RunProgram(program)
|
||||
if err != nil {
|
||||
access.Lock()
|
||||
scriptErr = err
|
||||
access.Unlock()
|
||||
doneFunc()
|
||||
}
|
||||
}()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
println(1)
|
||||
vm.Interrupt(ctx.Err())
|
||||
return nil, ctx.Err()
|
||||
case <-done:
|
||||
access.Lock()
|
||||
defer access.Unlock()
|
||||
if scriptErr != nil {
|
||||
vm.Interrupt(scriptErr)
|
||||
} else {
|
||||
vm.Interrupt("script done")
|
||||
}
|
||||
return &result, scriptErr
|
||||
}
|
||||
}
|
||||
31
script/source.go
Normal file
31
script/source.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package script
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/sagernet/sing-box/adapter"
|
||||
C "github.com/sagernet/sing-box/constant"
|
||||
"github.com/sagernet/sing-box/option"
|
||||
E "github.com/sagernet/sing/common/exceptions"
|
||||
"github.com/sagernet/sing/common/logger"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
type Source interface {
|
||||
StartContext(ctx context.Context, startContext *adapter.HTTPStartContext) error
|
||||
PostStart() error
|
||||
Program() *goja.Program
|
||||
Close() error
|
||||
}
|
||||
|
||||
func NewSource(ctx context.Context, logger logger.Logger, options option.Script) (Source, error) {
|
||||
switch options.Source {
|
||||
case C.ScriptSourceLocal:
|
||||
return NewLocalSource(ctx, logger, options)
|
||||
case C.ScriptSourceRemote:
|
||||
return NewRemoteSource(ctx, logger, options)
|
||||
default:
|
||||
return nil, E.New("unknown source type: ", options.Source)
|
||||
}
|
||||
}
|
||||
92
script/source_local.go
Normal file
92
script/source_local.go
Normal file
@@ -0,0 +1,92 @@
|
||||
package script
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/sagernet/fswatch"
|
||||
"github.com/sagernet/sing-box/adapter"
|
||||
"github.com/sagernet/sing-box/option"
|
||||
E "github.com/sagernet/sing/common/exceptions"
|
||||
"github.com/sagernet/sing/common/logger"
|
||||
"github.com/sagernet/sing/service/filemanager"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
var _ Source = (*LocalSource)(nil)
|
||||
|
||||
type LocalSource struct {
|
||||
ctx context.Context
|
||||
logger logger.Logger
|
||||
tag string
|
||||
program *goja.Program
|
||||
watcher *fswatch.Watcher
|
||||
}
|
||||
|
||||
func NewLocalSource(ctx context.Context, logger logger.Logger, options option.Script) (*LocalSource, error) {
|
||||
script := &LocalSource{
|
||||
ctx: ctx,
|
||||
logger: logger,
|
||||
tag: options.Tag,
|
||||
}
|
||||
filePath := filemanager.BasePath(ctx, options.LocalOptions.Path)
|
||||
filePath, _ = filepath.Abs(options.LocalOptions.Path)
|
||||
err := script.reloadFile(filePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
watcher, err := fswatch.NewWatcher(fswatch.Options{
|
||||
Path: []string{filePath},
|
||||
Callback: func(path string) {
|
||||
uErr := script.reloadFile(path)
|
||||
if uErr != nil {
|
||||
logger.Error(E.Cause(uErr, "reload script ", path))
|
||||
}
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
script.watcher = watcher
|
||||
return script, nil
|
||||
}
|
||||
|
||||
func (s *LocalSource) StartContext(ctx context.Context, startContext *adapter.HTTPStartContext) error {
|
||||
if s.watcher != nil {
|
||||
err := s.watcher.Start()
|
||||
if err != nil {
|
||||
s.logger.Error(E.Cause(err, "watch script file"))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *LocalSource) reloadFile(path string) error {
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
program, err := goja.Compile("script:"+s.tag, string(content), false)
|
||||
if err != nil {
|
||||
return E.Cause(err, "compile ", path)
|
||||
}
|
||||
if s.program != nil {
|
||||
s.logger.Info("reloaded from ", path)
|
||||
}
|
||||
s.program = program
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *LocalSource) PostStart() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *LocalSource) Program() *goja.Program {
|
||||
return s.program
|
||||
}
|
||||
|
||||
func (s *LocalSource) Close() error {
|
||||
return s.watcher.Close()
|
||||
}
|
||||
224
script/source_remote.go
Normal file
224
script/source_remote.go
Normal file
@@ -0,0 +1,224 @@
|
||||
package script
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"runtime"
|
||||
"time"
|
||||
|
||||
"github.com/sagernet/sing-box/adapter"
|
||||
C "github.com/sagernet/sing-box/constant"
|
||||
"github.com/sagernet/sing-box/option"
|
||||
E "github.com/sagernet/sing/common/exceptions"
|
||||
F "github.com/sagernet/sing/common/format"
|
||||
"github.com/sagernet/sing/common/logger"
|
||||
M "github.com/sagernet/sing/common/metadata"
|
||||
N "github.com/sagernet/sing/common/network"
|
||||
"github.com/sagernet/sing/common/ntp"
|
||||
"github.com/sagernet/sing/service"
|
||||
"github.com/sagernet/sing/service/pause"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
var _ Source = (*RemoteSource)(nil)
|
||||
|
||||
type RemoteSource struct {
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
logger logger.Logger
|
||||
outbound adapter.OutboundManager
|
||||
options option.Script
|
||||
updateInterval time.Duration
|
||||
dialer N.Dialer
|
||||
program *goja.Program
|
||||
lastUpdated time.Time
|
||||
lastEtag string
|
||||
updateTicker *time.Ticker
|
||||
cacheFile adapter.CacheFile
|
||||
pauseManager pause.Manager
|
||||
}
|
||||
|
||||
func NewRemoteSource(ctx context.Context, logger logger.Logger, options option.Script) (*RemoteSource, error) {
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
var updateInterval time.Duration
|
||||
if options.RemoteOptions.UpdateInterval > 0 {
|
||||
updateInterval = time.Duration(options.RemoteOptions.UpdateInterval)
|
||||
} else {
|
||||
updateInterval = 24 * time.Hour
|
||||
}
|
||||
return &RemoteSource{
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
logger: logger,
|
||||
outbound: service.FromContext[adapter.OutboundManager](ctx),
|
||||
options: options,
|
||||
updateInterval: updateInterval,
|
||||
pauseManager: service.FromContext[pause.Manager](ctx),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *RemoteSource) StartContext(ctx context.Context, startContext *adapter.HTTPStartContext) error {
|
||||
s.cacheFile = service.FromContext[adapter.CacheFile](s.ctx)
|
||||
var dialer N.Dialer
|
||||
if s.options.RemoteOptions.DownloadDetour != "" {
|
||||
outbound, loaded := s.outbound.Outbound(s.options.RemoteOptions.DownloadDetour)
|
||||
if !loaded {
|
||||
return E.New("download detour not found: ", s.options.RemoteOptions.DownloadDetour)
|
||||
}
|
||||
dialer = outbound
|
||||
} else {
|
||||
dialer = s.outbound.Default()
|
||||
}
|
||||
s.dialer = dialer
|
||||
if s.cacheFile != nil {
|
||||
if savedSet := s.cacheFile.LoadScript(s.options.Tag); savedSet != nil {
|
||||
err := s.loadBytes(savedSet.Content)
|
||||
if err != nil {
|
||||
return E.Cause(err, "restore cached rule-set")
|
||||
}
|
||||
s.lastUpdated = savedSet.LastUpdated
|
||||
s.lastEtag = savedSet.LastEtag
|
||||
}
|
||||
}
|
||||
if s.lastUpdated.IsZero() {
|
||||
err := s.fetchOnce(ctx, startContext)
|
||||
if err != nil {
|
||||
return E.Cause(err, "initial rule-set: ", s.options.Tag)
|
||||
}
|
||||
}
|
||||
s.updateTicker = time.NewTicker(s.updateInterval)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *RemoteSource) PostStart() error {
|
||||
go s.loopUpdate()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *RemoteSource) Program() *goja.Program {
|
||||
return s.program
|
||||
}
|
||||
|
||||
func (s *RemoteSource) loadBytes(content []byte) error {
|
||||
program, err := goja.Compile(F.ToString("script:", s.options.Tag), string(content), false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.program = program
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *RemoteSource) loopUpdate() {
|
||||
if time.Since(s.lastUpdated) > s.updateInterval {
|
||||
err := s.fetchOnce(s.ctx, nil)
|
||||
if err != nil {
|
||||
s.logger.Error("fetch rule-set ", s.options.Tag, ": ", err)
|
||||
}
|
||||
}
|
||||
for {
|
||||
runtime.GC()
|
||||
select {
|
||||
case <-s.ctx.Done():
|
||||
return
|
||||
case <-s.updateTicker.C:
|
||||
s.pauseManager.WaitActive()
|
||||
err := s.fetchOnce(s.ctx, nil)
|
||||
if err != nil {
|
||||
s.logger.Error("fetch rule-set ", s.options.Tag, ": ", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *RemoteSource) fetchOnce(ctx context.Context, startContext *adapter.HTTPStartContext) error {
|
||||
s.logger.Debug("updating script ", s.options.Tag, " from URL: ", s.options.RemoteOptions.URL)
|
||||
var httpClient *http.Client
|
||||
if startContext != nil {
|
||||
httpClient = startContext.HTTPClient(s.options.RemoteOptions.DownloadDetour, s.dialer)
|
||||
} else {
|
||||
httpClient = &http.Client{
|
||||
Transport: &http.Transport{
|
||||
ForceAttemptHTTP2: true,
|
||||
TLSHandshakeTimeout: C.TCPTimeout,
|
||||
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
return s.dialer.DialContext(ctx, network, M.ParseSocksaddr(addr))
|
||||
},
|
||||
TLSClientConfig: &tls.Config{
|
||||
Time: ntp.TimeFuncFromContext(s.ctx),
|
||||
RootCAs: adapter.RootPoolFromContext(s.ctx),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
request, err := http.NewRequest("GET", s.options.RemoteOptions.URL, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if s.lastEtag != "" {
|
||||
request.Header.Set("If-None-Match", s.lastEtag)
|
||||
}
|
||||
response, err := httpClient.Do(request.WithContext(ctx))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch response.StatusCode {
|
||||
case http.StatusOK:
|
||||
case http.StatusNotModified:
|
||||
s.lastUpdated = time.Now()
|
||||
if s.cacheFile != nil {
|
||||
savedRuleSet := s.cacheFile.LoadScript(s.options.Tag)
|
||||
if savedRuleSet != nil {
|
||||
savedRuleSet.LastUpdated = s.lastUpdated
|
||||
err = s.cacheFile.SaveScript(s.options.Tag, savedRuleSet)
|
||||
if err != nil {
|
||||
s.logger.Error("save script updated time: ", err)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
s.logger.Info("update script ", s.options.Tag, ": not modified")
|
||||
return nil
|
||||
default:
|
||||
return E.New("unexpected status: ", response.Status)
|
||||
}
|
||||
content, err := io.ReadAll(response.Body)
|
||||
if err != nil {
|
||||
response.Body.Close()
|
||||
return err
|
||||
}
|
||||
err = s.loadBytes(content)
|
||||
if err != nil {
|
||||
response.Body.Close()
|
||||
return err
|
||||
}
|
||||
response.Body.Close()
|
||||
eTagHeader := response.Header.Get("Etag")
|
||||
if eTagHeader != "" {
|
||||
s.lastEtag = eTagHeader
|
||||
}
|
||||
s.lastUpdated = time.Now()
|
||||
if s.cacheFile != nil {
|
||||
err = s.cacheFile.SaveScript(s.options.Tag, &adapter.SavedBinary{
|
||||
LastUpdated: s.lastUpdated,
|
||||
Content: content,
|
||||
LastEtag: s.lastEtag,
|
||||
})
|
||||
if err != nil {
|
||||
s.logger.Error("save script cache: ", err)
|
||||
}
|
||||
}
|
||||
s.logger.Info("updated script ", s.options.Tag)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *RemoteSource) Close() error {
|
||||
if s.updateTicker != nil {
|
||||
s.updateTicker.Stop()
|
||||
}
|
||||
s.cancel()
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user