Add Surge MITM and scripts

This commit is contained in:
世界
2025-03-20 09:12:48 +08:00
parent 276584be09
commit 82bc416985
85 changed files with 7309 additions and 355 deletions

View File

@@ -0,0 +1,50 @@
package boxctx
import (
"context"
"time"
"github.com/sagernet/sing-box/script/jsc"
"github.com/sagernet/sing/common/logger"
"github.com/dop251/goja"
)
type Context struct {
class jsc.Class[*Module, *Context]
Context context.Context
Logger logger.ContextLogger
Tag string
StartedAt time.Time
ErrorHandler func(error)
}
func FromRuntime(runtime *goja.Runtime) *Context {
contextValue := runtime.Get("context")
if contextValue == nil {
return nil
}
context, isContext := contextValue.Export().(*Context)
if !isContext {
return nil
}
return context
}
func MustFromRuntime(runtime *goja.Runtime) *Context {
context := FromRuntime(runtime)
if context == nil {
panic(runtime.NewTypeError("Missing sing-box context"))
}
return context
}
func createContext(module *Module) jsc.Class[*Module, *Context] {
class := jsc.NewClass[*Module, *Context](module)
class.DefineMethod("toString", (*Context).toString)
return class
}
func (c *Context) toString(call goja.FunctionCall) any {
return "[sing-box Context]"
}

View File

@@ -0,0 +1,35 @@
package boxctx
import (
"github.com/sagernet/sing-box/script/jsc"
"github.com/sagernet/sing-box/script/modules/require"
"github.com/dop251/goja"
)
const ModuleName = "context"
type Module struct {
runtime *goja.Runtime
classContext jsc.Class[*Module, *Context]
}
func Require(runtime *goja.Runtime, module *goja.Object) {
m := &Module{
runtime: runtime,
}
m.classContext = createContext(m)
exports := module.Get("exports").(*goja.Object)
exports.Set("Context", m.classContext.ToValue())
}
func Enable(runtime *goja.Runtime, context *Context) {
exports := require.Require(runtime, ModuleName).ToObject(runtime)
classContext := jsc.GetClass[*Module, *Context](runtime, exports, "Context")
context.class = classContext
runtime.Set("context", classContext.New(context))
}
func (m *Module) Runtime() *goja.Runtime {
return m.runtime
}

View File

@@ -0,0 +1,281 @@
package console
import (
"bytes"
"context"
"encoding/xml"
"sync"
"time"
sLog "github.com/sagernet/sing-box/log"
"github.com/sagernet/sing-box/script/jsc"
"github.com/sagernet/sing-box/script/modules/boxctx"
F "github.com/sagernet/sing/common/format"
"github.com/sagernet/sing/common/logger"
"github.com/dop251/goja"
)
type Console struct {
class jsc.Class[*Module, *Console]
access sync.Mutex
countMap map[string]int
timeMap map[string]time.Time
}
func NewConsole(class jsc.Class[*Module, *Console]) goja.Value {
return class.New(&Console{
class: class,
countMap: make(map[string]int),
timeMap: make(map[string]time.Time),
})
}
func createConsole(m *Module) jsc.Class[*Module, *Console] {
class := jsc.NewClass[*Module, *Console](m)
class.DefineMethod("assert", (*Console).assert)
class.DefineMethod("clear", (*Console).clear)
class.DefineMethod("count", (*Console).count)
class.DefineMethod("countReset", (*Console).countReset)
class.DefineMethod("debug", (*Console).debug)
class.DefineMethod("dir", (*Console).dir)
class.DefineMethod("dirxml", (*Console).dirxml)
class.DefineMethod("error", (*Console).error)
class.DefineMethod("group", (*Console).stub)
class.DefineMethod("groupCollapsed", (*Console).stub)
class.DefineMethod("groupEnd", (*Console).stub)
class.DefineMethod("info", (*Console).info)
class.DefineMethod("log", (*Console)._log)
class.DefineMethod("profile", (*Console).stub)
class.DefineMethod("profileEnd", (*Console).profileEnd)
class.DefineMethod("table", (*Console).table)
class.DefineMethod("time", (*Console).time)
class.DefineMethod("timeEnd", (*Console).timeEnd)
class.DefineMethod("timeLog", (*Console).timeLog)
class.DefineMethod("timeStamp", (*Console).stub)
class.DefineMethod("trace", (*Console).trace)
class.DefineMethod("warn", (*Console).warn)
return class
}
func (c *Console) stub(call goja.FunctionCall) any {
return goja.Undefined()
}
func (c *Console) assert(call goja.FunctionCall) any {
assertion := call.Argument(0).ToBoolean()
if !assertion {
return c.log(logger.ContextLogger.ErrorContext, call.Arguments[1:])
}
return goja.Undefined()
}
func (c *Console) clear(call goja.FunctionCall) any {
return nil
}
func (c *Console) count(call goja.FunctionCall) any {
label := jsc.AssertString(c.class.Runtime(), call.Argument(0), "label", true)
if label == "" {
label = "default"
}
c.access.Lock()
newValue := c.countMap[label] + 1
c.countMap[label] = newValue
c.access.Unlock()
writeLog(c.class.Runtime(), logger.ContextLogger.InfoContext, F.ToString(label, ": ", newValue))
return goja.Undefined()
}
func (c *Console) countReset(call goja.FunctionCall) any {
label := jsc.AssertString(c.class.Runtime(), call.Argument(0), "label", true)
if label == "" {
label = "default"
}
c.access.Lock()
delete(c.countMap, label)
c.access.Unlock()
return goja.Undefined()
}
func (c *Console) log(logFunc func(logger.ContextLogger, context.Context, ...any), args []goja.Value) any {
var buffer bytes.Buffer
var formatString string
if len(args) > 0 {
formatString = args[0].String()
}
format(c.class.Runtime(), &buffer, formatString, args[1:]...)
writeLog(c.class.Runtime(), logFunc, buffer.String())
return goja.Undefined()
}
func (c *Console) debug(call goja.FunctionCall) any {
return c.log(logger.ContextLogger.DebugContext, call.Arguments)
}
func (c *Console) dir(call goja.FunctionCall) any {
object := jsc.AssertObject(c.class.Runtime(), call.Argument(0), "object", false)
var buffer bytes.Buffer
for _, key := range object.Keys() {
value := object.Get(key)
buffer.WriteString(key)
buffer.WriteString(": ")
buffer.WriteString(value.String())
buffer.WriteString("\n")
}
writeLog(c.class.Runtime(), logger.ContextLogger.InfoContext, buffer.String())
return goja.Undefined()
}
func (c *Console) dirxml(call goja.FunctionCall) any {
var buffer bytes.Buffer
encoder := xml.NewEncoder(&buffer)
encoder.Indent("", " ")
encoder.Encode(call.Argument(0).Export())
writeLog(c.class.Runtime(), logger.ContextLogger.InfoContext, buffer.String())
return goja.Undefined()
}
func (c *Console) error(call goja.FunctionCall) any {
return c.log(logger.ContextLogger.ErrorContext, call.Arguments)
}
func (c *Console) info(call goja.FunctionCall) any {
return c.log(logger.ContextLogger.InfoContext, call.Arguments)
}
func (c *Console) _log(call goja.FunctionCall) any {
return c.log(logger.ContextLogger.InfoContext, call.Arguments)
}
func (c *Console) profileEnd(call goja.FunctionCall) any {
return goja.Undefined()
}
func (c *Console) table(call goja.FunctionCall) any {
return c.dir(call)
}
func (c *Console) time(call goja.FunctionCall) any {
label := jsc.AssertString(c.class.Runtime(), call.Argument(0), "label", true)
if label == "" {
label = "default"
}
c.access.Lock()
c.timeMap[label] = time.Now()
c.access.Unlock()
return goja.Undefined()
}
func (c *Console) timeEnd(call goja.FunctionCall) any {
label := jsc.AssertString(c.class.Runtime(), call.Argument(0), "label", true)
if label == "" {
label = "default"
}
c.access.Lock()
startTime, ok := c.timeMap[label]
if !ok {
c.access.Unlock()
return goja.Undefined()
}
delete(c.timeMap, label)
c.access.Unlock()
writeLog(c.class.Runtime(), logger.ContextLogger.InfoContext, F.ToString(label, ": ", time.Since(startTime).String(), " - - timer ended"))
return goja.Undefined()
}
func (c *Console) timeLog(call goja.FunctionCall) any {
label := jsc.AssertString(c.class.Runtime(), call.Argument(0), "label", true)
if label == "" {
label = "default"
}
c.access.Lock()
startTime, ok := c.timeMap[label]
c.access.Unlock()
if !ok {
writeLog(c.class.Runtime(), logger.ContextLogger.ErrorContext, F.ToString("Timer \"", label, "\" doesn't exist."))
return goja.Undefined()
}
writeLog(c.class.Runtime(), logger.ContextLogger.InfoContext, F.ToString(label, ": ", time.Since(startTime)))
return goja.Undefined()
}
func (c *Console) trace(call goja.FunctionCall) any {
return c.log(logger.ContextLogger.TraceContext, call.Arguments)
}
func (c *Console) warn(call goja.FunctionCall) any {
return c.log(logger.ContextLogger.WarnContext, call.Arguments)
}
func writeLog(runtime *goja.Runtime, logFunc func(logger.ContextLogger, context.Context, ...any), message string) {
var (
ctx context.Context
sLogger logger.ContextLogger
)
boxCtx := boxctx.FromRuntime(runtime)
if boxCtx != nil {
ctx = boxCtx.Context
sLogger = boxCtx.Logger
} else {
ctx = context.Background()
sLogger = sLog.StdLogger()
}
logFunc(sLogger, ctx, message)
}
func format(runtime *goja.Runtime, b *bytes.Buffer, f string, args ...goja.Value) {
pct := false
argNum := 0
for _, chr := range f {
if pct {
if argNum < len(args) {
if format1(runtime, 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 format1(runtime *goja.Runtime, 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 := runtime.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
}

View File

@@ -0,0 +1,3 @@
package console
type Context struct{}

View File

@@ -0,0 +1,34 @@
package console
import (
"github.com/sagernet/sing-box/script/jsc"
"github.com/sagernet/sing-box/script/modules/require"
"github.com/dop251/goja"
)
const ModuleName = "console"
type Module struct {
runtime *goja.Runtime
console jsc.Class[*Module, *Console]
}
func Require(runtime *goja.Runtime, module *goja.Object) {
m := &Module{
runtime: runtime,
}
m.console = createConsole(m)
exports := module.Get("exports").(*goja.Object)
exports.Set("Console", m.console.ToValue())
}
func Enable(runtime *goja.Runtime) {
exports := require.Require(runtime, ModuleName).ToObject(runtime)
classConsole := jsc.GetClass[*Module, *Console](runtime, exports, "Console")
runtime.Set("console", NewConsole(classConsole))
}
func (m *Module) Runtime() *goja.Runtime {
return m.runtime
}

View 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)
}
}

View 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)"))
}

View 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
}

View 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()
}

View File

@@ -0,0 +1,65 @@
package surge
import (
"runtime"
C "github.com/sagernet/sing-box/constant"
"github.com/sagernet/sing-box/experimental/locale"
"github.com/sagernet/sing-box/script/jsc"
"github.com/dop251/goja"
)
type Environment struct {
class jsc.Class[*Module, *Environment]
}
func createEnvironment(module *Module) jsc.Class[*Module, *Environment] {
class := jsc.NewClass[*Module, *Environment](module)
class.DefineField("system", (*Environment).getSystem, nil)
class.DefineField("surge-build", (*Environment).getSurgeBuild, nil)
class.DefineField("surge-version", (*Environment).getSurgeVersion, nil)
class.DefineField("language", (*Environment).getLanguage, nil)
class.DefineField("device-model", (*Environment).getDeviceModel, nil)
class.DefineMethod("toString", (*Environment).toString)
return class
}
func (e *Environment) getSystem() any {
switch runtime.GOOS {
case "ios":
return "iOS"
case "darwin":
return "macOS"
case "tvos":
return "tvOS"
case "linux":
return "Linux"
case "android":
return "Android"
case "windows":
return "Windows"
default:
return runtime.GOOS
}
}
func (e *Environment) getSurgeBuild() any {
return "N/A"
}
func (e *Environment) getSurgeVersion() any {
return "sing-box " + C.Version
}
func (e *Environment) getLanguage() any {
return locale.Current().Locale
}
func (e *Environment) getDeviceModel() any {
return "N/A"
}
func (e *Environment) toString(call goja.FunctionCall) any {
return "[sing-box Surge environment"
}

View File

@@ -0,0 +1,150 @@
package surge
import (
"bytes"
"crypto/tls"
"io"
"net/http"
"net/http/cookiejar"
"time"
"github.com/sagernet/sing-box/script/jsc"
"github.com/sagernet/sing-box/script/modules/boxctx"
"github.com/sagernet/sing/common"
F "github.com/sagernet/sing/common/format"
"github.com/dop251/goja"
"golang.org/x/net/publicsuffix"
)
type HTTP struct {
class jsc.Class[*Module, *HTTP]
cookieJar *cookiejar.Jar
httpTransport *http.Transport
}
func createHTTP(module *Module) jsc.Class[*Module, *HTTP] {
class := jsc.NewClass[*Module, *HTTP](module)
class.DefineConstructor(newHTTP)
class.DefineMethod("get", httpRequest(http.MethodGet))
class.DefineMethod("post", httpRequest(http.MethodPost))
class.DefineMethod("put", httpRequest(http.MethodPut))
class.DefineMethod("delete", httpRequest(http.MethodDelete))
class.DefineMethod("head", httpRequest(http.MethodHead))
class.DefineMethod("options", httpRequest(http.MethodOptions))
class.DefineMethod("patch", httpRequest(http.MethodPatch))
class.DefineMethod("trace", httpRequest(http.MethodTrace))
class.DefineMethod("toString", (*HTTP).toString)
return class
}
func newHTTP(class jsc.Class[*Module, *HTTP], call goja.ConstructorCall) *HTTP {
return &HTTP{
class: class,
cookieJar: common.Must1(cookiejar.New(&cookiejar.Options{
PublicSuffixList: publicsuffix.List,
})),
httpTransport: &http.Transport{
ForceAttemptHTTP2: true,
TLSClientConfig: &tls.Config{},
},
}
}
func httpRequest(method string) func(s *HTTP, call goja.FunctionCall) any {
return func(s *HTTP, call goja.FunctionCall) any {
if len(call.Arguments) != 2 {
panic(s.class.Runtime().NewTypeError("invalid arguments"))
}
context := boxctx.MustFromRuntime(s.class.Runtime())
var (
url string
headers http.Header
body []byte
timeout = 5 * time.Second
insecure bool
autoCookie bool = true
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.class.Runtime(), optionsValue.Get("url"), "options.url", false)
headers = jsc.AssertHTTPHeader(s.class.Runtime(), optionsValue.Get("headers"), "option.headers")
body = jsc.AssertStringBinary(s.class.Runtime(), optionsValue.Get("body"), "options.body", true)
timeoutInt := jsc.AssertInt(s.class.Runtime(), optionsValue.Get("timeout"), "options.timeout", true)
if timeoutInt > 0 {
timeout = time.Duration(timeoutInt) * time.Second
}
insecure = jsc.AssertBool(s.class.Runtime(), optionsValue.Get("insecure"), "options.insecure", true)
autoCookie = jsc.AssertBool(s.class.Runtime(), optionsValue.Get("auto-cookie"), "options.auto-cookie", true)
autoRedirect = jsc.AssertBool(s.class.Runtime(), optionsValue.Get("auto-redirect"), "options.auto-redirect", true)
// policy = jsc.AssertString(s.class.Runtime(), optionsValue.Get("policy"), "options.policy", true)
binaryMode = jsc.AssertBool(s.class.Runtime(), optionsValue.Get("binary-mode"), "options.binary-mode", true)
default:
panic(s.class.Runtime().NewTypeError(F.ToString("invalid argument: options: expected string or object, but got ", optionsValue)))
}
callback := jsc.AssertFunction(s.class.Runtime(), call.Argument(1), "callback")
s.httpTransport.TLSClientConfig.InsecureSkipVerify = insecure
httpClient := &http.Client{
Timeout: timeout,
Transport: s.httpTransport,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if autoRedirect {
return nil
}
return http.ErrUseLastResponse
},
}
if autoCookie {
httpClient.Jar = s.cookieJar
}
request, err := http.NewRequestWithContext(context.Context, 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.class.Runtime().NewGoError(err))
}
go func() {
defer s.httpTransport.CloseIdleConnections()
response, executeErr := httpClient.Do(request)
if err != nil {
_, err = callback(nil, s.class.Runtime().NewGoError(executeErr), nil, nil)
if err != nil {
context.ErrorHandler(err)
}
return
}
defer response.Body.Close()
var content []byte
content, err = io.ReadAll(response.Body)
if err != nil {
_, err = callback(nil, s.class.Runtime().NewGoError(err), nil, nil)
if err != nil {
context.ErrorHandler(err)
}
}
responseObject := s.class.Runtime().NewObject()
responseObject.Set("status", response.StatusCode)
responseObject.Set("headers", jsc.HeadersToValue(s.class.Runtime(), response.Header))
var bodyValue goja.Value
if binaryMode {
bodyValue = jsc.NewUint8Array(s.class.Runtime(), content)
} else {
bodyValue = s.class.Runtime().ToValue(string(content))
}
_, err = callback(nil, nil, responseObject, bodyValue)
}()
return nil
}
}
func (h *HTTP) toString(call goja.FunctionCall) any {
return "[sing-box Surge HTTP]"
}

View File

@@ -0,0 +1,63 @@
package surge
import (
"github.com/sagernet/sing-box/script/jsc"
"github.com/sagernet/sing-box/script/modules/require"
"github.com/sagernet/sing/common"
"github.com/dop251/goja"
)
const ModuleName = "surge"
type Module struct {
runtime *goja.Runtime
classScript jsc.Class[*Module, *Script]
classEnvironment jsc.Class[*Module, *Environment]
classPersistentStore jsc.Class[*Module, *PersistentStore]
classHTTP jsc.Class[*Module, *HTTP]
classUtils jsc.Class[*Module, *Utils]
classNotification jsc.Class[*Module, *Notification]
}
func Require(runtime *goja.Runtime, module *goja.Object) {
m := &Module{
runtime: runtime,
}
m.classScript = createScript(m)
m.classEnvironment = createEnvironment(m)
m.classPersistentStore = createPersistentStore(m)
m.classHTTP = createHTTP(m)
m.classUtils = createUtils(m)
m.classNotification = createNotification(m)
exports := module.Get("exports").(*goja.Object)
exports.Set("Script", m.classScript.ToValue())
exports.Set("Environment", m.classEnvironment.ToValue())
exports.Set("PersistentStore", m.classPersistentStore.ToValue())
exports.Set("HTTP", m.classHTTP.ToValue())
exports.Set("Utils", m.classUtils.ToValue())
exports.Set("Notification", m.classNotification.ToValue())
}
func Enable(runtime *goja.Runtime, scriptType string, args []string) {
exports := require.Require(runtime, ModuleName).ToObject(runtime)
classScript := jsc.GetClass[*Module, *Script](runtime, exports, "Script")
classEnvironment := jsc.GetClass[*Module, *Environment](runtime, exports, "Environment")
classPersistentStore := jsc.GetClass[*Module, *PersistentStore](runtime, exports, "PersistentStore")
classHTTP := jsc.GetClass[*Module, *HTTP](runtime, exports, "HTTP")
classUtils := jsc.GetClass[*Module, *Utils](runtime, exports, "Utils")
classNotification := jsc.GetClass[*Module, *Notification](runtime, exports, "Notification")
runtime.Set("$script", classScript.New(&Script{class: classScript, ScriptType: scriptType}))
runtime.Set("$environment", classEnvironment.New(&Environment{class: classEnvironment}))
runtime.Set("$persistentStore", newPersistentStore(classPersistentStore))
runtime.Set("$http", classHTTP.New(newHTTP(classHTTP, goja.ConstructorCall{})))
runtime.Set("$utils", classUtils.New(&Utils{class: classUtils}))
runtime.Set("$notification", newNotification(classNotification))
runtime.Set("$argument", runtime.NewArray(common.Map(args, func(it string) any {
return it
})...))
}
func (m *Module) Runtime() *goja.Runtime {
return m.runtime
}

View File

@@ -0,0 +1,120 @@
package surge
import (
"encoding/base64"
"strings"
"github.com/sagernet/sing-box/experimental/libbox/platform"
"github.com/sagernet/sing-box/script/jsc"
"github.com/sagernet/sing-box/script/modules/boxctx"
E "github.com/sagernet/sing/common/exceptions"
"github.com/sagernet/sing/common/logger"
"github.com/sagernet/sing/service"
"github.com/dop251/goja"
)
type Notification struct {
class jsc.Class[*Module, *Notification]
logger logger.ContextLogger
tag string
platformInterface platform.Interface
}
func createNotification(module *Module) jsc.Class[*Module, *Notification] {
class := jsc.NewClass[*Module, *Notification](module)
class.DefineMethod("post", (*Notification).post)
class.DefineMethod("toString", (*Notification).toString)
return class
}
func newNotification(class jsc.Class[*Module, *Notification]) goja.Value {
context := boxctx.MustFromRuntime(class.Runtime())
return class.New(&Notification{
class: class,
logger: context.Logger,
tag: context.Tag,
platformInterface: service.FromContext[platform.Interface](context.Context),
})
}
func (s *Notification) post(call goja.FunctionCall) any {
var (
title string
subtitle string
body string
openURL string
clipboard string
mediaURL string
mediaData []byte
mediaType string
autoDismiss int
)
title = jsc.AssertString(s.class.Runtime(), call.Argument(0), "title", true)
subtitle = jsc.AssertString(s.class.Runtime(), call.Argument(1), "subtitle", true)
body = jsc.AssertString(s.class.Runtime(), call.Argument(2), "body", true)
options := jsc.AssertObject(s.class.Runtime(), call.Argument(3), "options", true)
if options != nil {
action := jsc.AssertString(s.class.Runtime(), options.Get("action"), "options.action", true)
switch action {
case "open-url":
openURL = jsc.AssertString(s.class.Runtime(), options.Get("url"), "options.url", false)
case "clipboard":
clipboard = jsc.AssertString(s.class.Runtime(), options.Get("clipboard"), "options.clipboard", false)
}
mediaURL = jsc.AssertString(s.class.Runtime(), options.Get("media-url"), "options.media-url", true)
mediaBase64 := jsc.AssertString(s.class.Runtime(), options.Get("media-base64"), "options.media-base64", true)
if mediaBase64 != "" {
mediaBinary, err := base64.StdEncoding.DecodeString(mediaBase64)
if err != nil {
panic(s.class.Runtime().NewGoError(E.Cause(err, "decode media-base64")))
}
mediaData = mediaBinary
mediaType = jsc.AssertString(s.class.Runtime(), options.Get("media-base64-mime"), "options.media-base64-mime", false)
}
autoDismiss = int(jsc.AssertInt(s.class.Runtime(), 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.tag,
TypeName: "Surge Script Notification (" + s.tag + ")",
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 nil
}
func (s *Notification) toString(call goja.FunctionCall) any {
return "[sing-box Surge notification]"
}

View File

@@ -0,0 +1,78 @@
package surge
import (
"github.com/sagernet/sing-box/adapter"
"github.com/sagernet/sing-box/script/jsc"
"github.com/sagernet/sing-box/script/modules/boxctx"
"github.com/sagernet/sing/service"
"github.com/dop251/goja"
)
type PersistentStore struct {
class jsc.Class[*Module, *PersistentStore]
cacheFile adapter.CacheFile
inMemoryCache *adapter.SurgeInMemoryCache
tag string
}
func createPersistentStore(module *Module) jsc.Class[*Module, *PersistentStore] {
class := jsc.NewClass[*Module, *PersistentStore](module)
class.DefineMethod("get", (*PersistentStore).get)
class.DefineMethod("set", (*PersistentStore).set)
class.DefineMethod("toString", (*PersistentStore).toString)
return class
}
func newPersistentStore(class jsc.Class[*Module, *PersistentStore]) goja.Value {
boxCtx := boxctx.MustFromRuntime(class.Runtime())
return class.New(&PersistentStore{
class: class,
cacheFile: service.FromContext[adapter.CacheFile](boxCtx.Context),
inMemoryCache: service.FromContext[adapter.ScriptManager](boxCtx.Context).SurgeCache(),
tag: boxCtx.Tag,
})
}
func (s *PersistentStore) get(call goja.FunctionCall) any {
key := jsc.AssertString(s.class.Runtime(), call.Argument(0), "key", true)
if key == "" {
key = s.tag
}
var value string
if s.cacheFile != nil {
value = s.cacheFile.SurgePersistentStoreRead(key)
} else {
s.inMemoryCache.RLock()
value = s.inMemoryCache.Data[key]
s.inMemoryCache.RUnlock()
}
if value == "" {
return goja.Null()
} else {
return value
}
}
func (s *PersistentStore) set(call goja.FunctionCall) any {
data := jsc.AssertString(s.class.Runtime(), call.Argument(0), "data", true)
key := jsc.AssertString(s.class.Runtime(), 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.class.Runtime().NewGoError(err))
}
} else {
s.inMemoryCache.Lock()
s.inMemoryCache.Data[key] = data
s.inMemoryCache.Unlock()
}
return goja.Undefined()
}
func (s *PersistentStore) toString(call goja.FunctionCall) any {
return "[sing-box Surge persistentStore]"
}

View File

@@ -0,0 +1,32 @@
package surge
import (
"github.com/sagernet/sing-box/script/jsc"
"github.com/sagernet/sing-box/script/modules/boxctx"
F "github.com/sagernet/sing/common/format"
)
type Script struct {
class jsc.Class[*Module, *Script]
ScriptType string
}
func createScript(module *Module) jsc.Class[*Module, *Script] {
class := jsc.NewClass[*Module, *Script](module)
class.DefineField("name", (*Script).getName, nil)
class.DefineField("type", (*Script).getType, nil)
class.DefineField("startTime", (*Script).getStartTime, nil)
return class
}
func (s *Script) getName() any {
return F.ToString("script:", boxctx.MustFromRuntime(s.class.Runtime()).Tag)
}
func (s *Script) getType() any {
return s.ScriptType
}
func (s *Script) getStartTime() any {
return boxctx.MustFromRuntime(s.class.Runtime()).StartedAt
}

View File

@@ -0,0 +1,50 @@
package surge
import (
"bytes"
"compress/gzip"
"io"
"github.com/sagernet/sing-box/script/jsc"
E "github.com/sagernet/sing/common/exceptions"
"github.com/dop251/goja"
)
type Utils struct {
class jsc.Class[*Module, *Utils]
}
func createUtils(module *Module) jsc.Class[*Module, *Utils] {
class := jsc.NewClass[*Module, *Utils](module)
class.DefineMethod("geoip", (*Utils).stub)
class.DefineMethod("ipasn", (*Utils).stub)
class.DefineMethod("ipaso", (*Utils).stub)
class.DefineMethod("ungzip", (*Utils).ungzip)
class.DefineMethod("toString", (*Utils).toString)
return class
}
func (u *Utils) stub(call goja.FunctionCall) any {
return nil
}
func (u *Utils) ungzip(call goja.FunctionCall) any {
if len(call.Arguments) != 1 {
panic(u.class.Runtime().NewGoError(E.New("invalid argument")))
}
binary := jsc.AssertBinary(u.class.Runtime(), call.Argument(0), "binary", false)
reader, err := gzip.NewReader(bytes.NewReader(binary))
if err != nil {
panic(u.class.Runtime().NewGoError(err))
}
binary, err = io.ReadAll(reader)
if err != nil {
panic(u.class.Runtime().NewGoError(err))
}
return jsc.NewUint8Array(u.class.Runtime(), binary)
}
func (u *Utils) toString(call goja.FunctionCall) any {
return "[sing-box Surge utils]"
}

View File

@@ -0,0 +1,55 @@
package url
import "strings"
var tblEscapeURLQuery = [128]byte{
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 1, 0, 0, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1,
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 0, 1,
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0,
}
// The code below is mostly borrowed from the standard Go url package
const upperhex = "0123456789ABCDEF"
func escape(s string, table *[128]byte, spaceToPlus bool) string {
spaceCount, hexCount := 0, 0
for i := 0; i < len(s); i++ {
c := s[i]
if c > 127 || table[c] == 0 {
if c == ' ' && spaceToPlus {
spaceCount++
} else {
hexCount++
}
}
}
if spaceCount == 0 && hexCount == 0 {
return s
}
var sb strings.Builder
hexBuf := [3]byte{'%', 0, 0}
sb.Grow(len(s) + 2*hexCount)
for i := 0; i < len(s); i++ {
switch c := s[i]; {
case c == ' ' && spaceToPlus:
sb.WriteByte('+')
case c > 127 || table[c] == 0:
hexBuf[1] = upperhex[c>>4]
hexBuf[2] = upperhex[c&15]
sb.Write(hexBuf[:])
default:
sb.WriteByte(c)
}
}
return sb.String()
}

View File

@@ -0,0 +1,41 @@
package url
import (
"github.com/sagernet/sing-box/script/jsc"
"github.com/sagernet/sing-box/script/modules/require"
"github.com/dop251/goja"
)
const ModuleName = "url"
var _ jsc.Module = (*Module)(nil)
type Module struct {
runtime *goja.Runtime
classURL jsc.Class[*Module, *URL]
classURLSearchParams jsc.Class[*Module, *URLSearchParams]
classURLSearchParamsIterator jsc.Class[*Module, *jsc.Iterator[*Module, searchParam]]
}
func Require(runtime *goja.Runtime, module *goja.Object) {
m := &Module{
runtime: runtime,
}
m.classURL = createURL(m)
m.classURLSearchParams = createURLSearchParams(m)
m.classURLSearchParamsIterator = jsc.CreateIterator[*Module, searchParam](m)
exports := module.Get("exports").(*goja.Object)
exports.Set("URL", m.classURL.ToValue())
exports.Set("URLSearchParams", m.classURLSearchParams.ToValue())
}
func Enable(runtime *goja.Runtime) {
exports := require.Require(runtime, ModuleName).ToObject(runtime)
runtime.Set("URL", exports.Get("URL"))
runtime.Set("URLSearchParams", exports.Get("URLSearchParams"))
}
func (m *Module) Runtime() *goja.Runtime {
return m.runtime
}

View File

@@ -0,0 +1,37 @@
package url_test
import (
_ "embed"
"testing"
"github.com/sagernet/sing-box/script/jstest"
"github.com/sagernet/sing-box/script/modules/url"
"github.com/dop251/goja"
)
var (
//go:embed testdata/url_test.js
urlTest string
//go:embed testdata/url_search_params_test.js
urlSearchParamsTest string
)
func TestURL(t *testing.T) {
registry := jstest.NewRegistry()
registry.RegisterNodeModule(url.ModuleName, url.Require)
vm := goja.New()
registry.Enable(vm)
url.Enable(vm)
vm.RunScript("url_test.js", urlTest)
}
func TestURLSearchParams(t *testing.T) {
registry := jstest.NewRegistry()
registry.RegisterNodeModule(url.ModuleName, url.Require)
vm := goja.New()
registry.Enable(vm)
url.Enable(vm)
vm.RunScript("url_search_params_test.js", urlSearchParamsTest)
}

View File

@@ -0,0 +1,385 @@
"use strict";
const assert = require("assert.js");
let params;
function testCtor(value, expected) {
assert.sameValue(new URLSearchParams(value).toString(), expected);
}
testCtor("user=abc&query=xyz", "user=abc&query=xyz");
testCtor("?user=abc&query=xyz", "user=abc&query=xyz");
testCtor(
{
num: 1,
user: "abc",
query: ["first", "second"],
obj: { prop: "value" },
b: true,
},
"num=1&user=abc&query=first%2Csecond&obj=%5Bobject+Object%5D&b=true"
);
const map = new Map();
map.set("user", "abc");
map.set("query", "xyz");
testCtor(map, "user=abc&query=xyz");
testCtor(
[
["user", "abc"],
["query", "first"],
["query", "second"],
],
"user=abc&query=first&query=second"
);
// Each key-value pair must have exactly two elements
assert.throwsNodeError(() => new URLSearchParams([["single_value"]]), TypeError, "ERR_INVALID_TUPLE");
assert.throwsNodeError(() => new URLSearchParams([["too", "many", "values"]]), TypeError, "ERR_INVALID_TUPLE");
params = new URLSearchParams("a=b&cc=d");
params.forEach((value, name, searchParams) => {
if (name === "a") {
assert.sameValue(value, "b");
}
if (name === "cc") {
assert.sameValue(value, "d");
}
assert.sameValue(searchParams, params);
});
params.forEach((value, name, searchParams) => {
if (name === "a") {
assert.sameValue(value, "b");
searchParams.set("cc", "d1");
}
if (name === "cc") {
assert.sameValue(value, "d1");
}
assert.sameValue(searchParams, params);
});
assert.throwsNodeError(() => params.forEach(123), TypeError, "ERR_INVALID_ARG_TYPE");
assert.throwsNodeError(() => params.forEach.call(1, 2), TypeError, "ERR_INVALID_THIS");
params = new URLSearchParams("a=1=2&b=3");
assert.sameValue(params.size, 2);
assert.sameValue(params.get("a"), "1=2");
assert.sameValue(params.get("b"), "3");
params = new URLSearchParams("&");
assert.sameValue(params.size, 0);
params = new URLSearchParams("& ");
assert.sameValue(params.size, 1);
assert.sameValue(params.get(" "), "");
params = new URLSearchParams(" &");
assert.sameValue(params.size, 1);
assert.sameValue(params.get(" "), "");
params = new URLSearchParams("=");
assert.sameValue(params.size, 1);
assert.sameValue(params.get(""), "");
params = new URLSearchParams("&=2");
assert.sameValue(params.size, 1);
assert.sameValue(params.get(""), "2");
params = new URLSearchParams("?user=abc");
assert.throwsNodeError(() => params.append(), TypeError, "ERR_MISSING_ARGS");
params.append("query", "first");
assert.sameValue(params.toString(), "user=abc&query=first");
params = new URLSearchParams("first=one&second=two&third=three");
assert.throwsNodeError(() => params.delete(), TypeError, "ERR_MISSING_ARGS");
params.delete("second", "fake-value");
assert.sameValue(params.toString(), "first=one&second=two&third=three");
params.delete("third", "three");
assert.sameValue(params.toString(), "first=one&second=two");
params.delete("second");
assert.sameValue(params.toString(), "first=one");
params = new URLSearchParams("user=abc&query=xyz");
assert.throwsNodeError(() => params.get(), TypeError, "ERR_MISSING_ARGS");
assert.sameValue(params.get("user"), "abc");
assert.sameValue(params.get("non-existant"), null);
params = new URLSearchParams("query=first&query=second");
assert.throwsNodeError(() => params.getAll(), TypeError, "ERR_MISSING_ARGS");
const all = params.getAll("query");
assert.sameValue(all.includes("first"), true);
assert.sameValue(all.includes("second"), true);
assert.sameValue(all.length, 2);
const getAllUndefined = params.getAll(undefined);
assert.sameValue(getAllUndefined.length, 0);
const getAllNonExistant = params.getAll("does_not_exists");
assert.sameValue(getAllNonExistant.length, 0);
params = new URLSearchParams("user=abc&query=xyz");
assert.throwsNodeError(() => params.has(), TypeError, "ERR_MISSING_ARGS");
assert.sameValue(params.has(undefined), false);
assert.sameValue(params.has("user"), true);
assert.sameValue(params.has("user", "abc"), true);
assert.sameValue(params.has("user", "abc", "extra-param"), true);
assert.sameValue(params.has("user", "efg"), false);
assert.sameValue(params.has("user", undefined), true);
params = new URLSearchParams();
params.append("foo", "bar");
params.append("foo", "baz");
params.append("abc", "def");
assert.sameValue(params.toString(), "foo=bar&foo=baz&abc=def");
params.set("foo", "def");
params.set("xyz", "opq");
assert.sameValue(params.toString(), "foo=def&abc=def&xyz=opq");
params = new URLSearchParams("query=first&query=second&user=abc&double=first,second");
const URLSearchIteratorPrototype = params.entries().__proto__;
assert.sameValue(typeof URLSearchIteratorPrototype, "object");
assert.sameValue(params[Symbol.iterator], params.entries);
{
const entries = params.entries();
assert.sameValue(entries.toString(), "[object URLSearchParams Iterator]");
assert.sameValue(entries.__proto__, URLSearchIteratorPrototype);
let item = entries.next();
assert.sameValue(item.value.toString(), ["query", "first"].toString());
assert.sameValue(item.done, false);
item = entries.next();
assert.sameValue(item.value.toString(), ["query", "second"].toString());
assert.sameValue(item.done, false);
item = entries.next();
assert.sameValue(item.value.toString(), ["user", "abc"].toString());
assert.sameValue(item.done, false);
item = entries.next();
assert.sameValue(item.value.toString(), ["double", "first,second"].toString());
assert.sameValue(item.done, false);
item = entries.next();
assert.sameValue(item.value, undefined);
assert.sameValue(item.done, true);
}
params = new URLSearchParams("query=first&query=second&user=abc");
{
const keys = params.keys();
assert.sameValue(keys.__proto__, URLSearchIteratorPrototype);
let item = keys.next();
assert.sameValue(item.value, "query");
assert.sameValue(item.done, false);
item = keys.next();
assert.sameValue(item.value, "query");
assert.sameValue(item.done, false);
item = keys.next();
assert.sameValue(item.value, "user");
assert.sameValue(item.done, false);
item = keys.next();
assert.sameValue(item.value, undefined);
assert.sameValue(item.done, true);
}
params = new URLSearchParams("query=first&query=second&user=abc");
{
const values = params.values();
assert.sameValue(values.__proto__, URLSearchIteratorPrototype);
let item = values.next();
assert.sameValue(item.value, "first");
assert.sameValue(item.done, false);
item = values.next();
assert.sameValue(item.value, "second");
assert.sameValue(item.done, false);
item = values.next();
assert.sameValue(item.value, "abc");
assert.sameValue(item.done, false);
item = values.next();
assert.sameValue(item.value, undefined);
assert.sameValue(item.done, true);
}
params = new URLSearchParams("query[]=abc&type=search&query[]=123");
params.sort();
assert.sameValue(params.toString(), "query%5B%5D=abc&query%5B%5D=123&type=search");
params = new URLSearchParams("query=first&query=second&user=abc");
assert.sameValue(params.size, 3);
params = new URLSearchParams("%");
assert.sameValue(params.has("%"), true);
assert.sameValue(params.toString(), "%25=");
{
const params = new URLSearchParams("");
assert.sameValue(params.size, 0);
assert.sameValue(params.toString(), "");
assert.sameValue(params.get(undefined), null);
params.set(undefined, true);
assert.sameValue(params.has(undefined), true);
assert.sameValue(params.has("undefined"), true);
assert.sameValue(params.get("undefined"), "true");
assert.sameValue(params.get(undefined), "true");
assert.sameValue(params.getAll(undefined).toString(), ["true"].toString());
params.delete(undefined);
assert.sameValue(params.has(undefined), false);
assert.sameValue(params.has("undefined"), false);
assert.sameValue(params.has(null), false);
params.set(null, "nullval");
assert.sameValue(params.has(null), true);
assert.sameValue(params.has("null"), true);
assert.sameValue(params.get(null), "nullval");
assert.sameValue(params.get("null"), "nullval");
params.delete(null);
assert.sameValue(params.has(null), false);
assert.sameValue(params.has("null"), false);
}
function* functionGeneratorExample() {
yield ["user", "abc"];
yield ["query", "first"];
yield ["query", "second"];
}
params = new URLSearchParams(functionGeneratorExample());
assert.sameValue(params.toString(), "user=abc&query=first&query=second");
assert.sameValue(params.__proto__.constructor, URLSearchParams);
assert.sameValue(params instanceof URLSearchParams, true);
{
const params = new URLSearchParams("1=2&1=3");
assert.sameValue(params.get(1), "2");
assert.sameValue(params.getAll(1).toString(), ["2", "3"].toString());
assert.sameValue(params.getAll("x").toString(), [].toString());
}
// Sync
{
const url = new URL("https://test.com/");
const params = url.searchParams;
assert.sameValue(params.size, 0);
url.search = "a=1";
assert.sameValue(params.size, 1);
assert.sameValue(params.get("a"), "1");
}
{
const url = new URL("https://test.com/?a=1");
const params = url.searchParams;
assert.sameValue(params.size, 1);
url.search = "";
assert.sameValue(params.size, 0);
url.search = "b=2";
assert.sameValue(params.size, 1);
}
{
const url = new URL("https://test.com/");
const params = url.searchParams;
params.append("a", "1");
assert.sameValue(url.toString(), "https://test.com/?a=1");
}
{
const url = new URL("https://test.com/");
url.searchParams.append("a", "1");
url.searchParams.append("b", "1");
assert.sameValue(url.toString(), "https://test.com/?a=1&b=1");
}
{
const url = new URL("https://test.com/");
const params = url.searchParams;
url.searchParams.append("a", "1");
assert.sameValue(url.search, "?a=1");
}
{
const url = new URL("https://test.com/?a=1");
const params = url.searchParams;
params.append("a", "2");
assert.sameValue(url.search, "?a=1&a=2");
}
{
const url = new URL("https://test.com/");
const params = url.searchParams;
params.set("a", "1");
assert.sameValue(url.search, "?a=1");
}
{
const url = new URL("https://test.com/");
url.searchParams.set("a", "1");
url.searchParams.set("b", "1");
assert.sameValue(url.toString(), "https://test.com/?a=1&b=1");
}
{
const url = new URL("https://test.com/?a=1&b=2");
const params = url.searchParams;
params.delete("a");
assert.sameValue(url.search, "?b=2");
}
{
const url = new URL("https://test.com/?b=2&a=1");
const params = url.searchParams;
params.sort();
assert.sameValue(url.search, "?a=1&b=2");
}
{
const url = new URL("https://test.com/?a=1");
const params = url.searchParams;
params.delete("a");
assert.sameValue(url.search, "");
params.set("a", 2);
assert.sameValue(url.search, "?a=2");
}
// FAILING: no custom properties on wrapped Go structs
/*
{
const params = new URLSearchParams("");
assert.sameValue(Object.isExtensible(params), true);
assert.sameValue(Reflect.defineProperty(params, "customField", {value: 42, configurable: true}), true);
assert.sameValue(params.customField, 42);
const desc = Reflect.getOwnPropertyDescriptor(params, "customField");
assert.sameValue(desc.value, 42);
assert.sameValue(desc.writable, false);
assert.sameValue(desc.enumerable, false);
assert.sameValue(desc.configurable, true);
}
*/
// Escape
{
const myURL = new URL('https://example.org/abc?fo~o=~ba r%z');
assert.sameValue(myURL.search, "?fo~o=~ba%20r%z");
// Modify the URL via searchParams...
myURL.searchParams.sort();
assert.sameValue(myURL.search, "?fo%7Eo=%7Eba+r%25z");
}

229
script/modules/url/testdata/url_test.js vendored Normal file
View File

@@ -0,0 +1,229 @@
"use strict";
const assert = require("assert.js");
function testURLCtor(str, expected) {
assert.sameValue(new URL(str).toString(), expected);
}
function testURLCtorBase(ref, base, expected, message) {
assert.sameValue(new URL(ref, base).toString(), expected, message);
}
testURLCtorBase("https://example.org/", undefined, "https://example.org/");
testURLCtorBase("/foo", "https://example.org/", "https://example.org/foo");
testURLCtorBase("http://Example.com/", "https://example.org/", "http://example.com/");
testURLCtorBase("https://Example.com/", "https://example.org/", "https://example.com/");
testURLCtorBase("foo://Example.com/", "https://example.org/", "foo://Example.com/");
testURLCtorBase("foo:Example.com/", "https://example.org/", "foo:Example.com/");
testURLCtorBase("#hash", "https://example.org/", "https://example.org/#hash");
testURLCtor("HTTP://test.com", "http://test.com/");
testURLCtor("HTTPS://á.com", "https://xn--1ca.com/");
testURLCtor("HTTPS://á.com:123", "https://xn--1ca.com:123/");
testURLCtor("https://test.com#asdfá", "https://test.com/#asdf%C3%A1");
testURLCtor("HTTPS://á.com:123/á", "https://xn--1ca.com:123/%C3%A1");
testURLCtor("fish://á.com", "fish://%C3%A1.com");
testURLCtor("https://test.com/?a=1 /2", "https://test.com/?a=1%20/2");
testURLCtor("https://test.com/á=1?á=1&ü=2#é", "https://test.com/%C3%A1=1?%C3%A1=1&%C3%BC=2#%C3%A9");
assert.throws(() => new URL("test"), TypeError);
assert.throws(() => new URL("ssh://EEE:ddd"), TypeError);
{
let u = new URL("https://example.org/");
assert.sameValue(u.__proto__.constructor, URL);
assert.sameValue(u instanceof URL, true);
}
{
let u = new URL("https://example.org/");
assert.sameValue(u.searchParams, u.searchParams);
}
let myURL;
// Hash
myURL = new URL("https://example.org/foo#bar");
myURL.hash = "baz";
assert.sameValue(myURL.href, "https://example.org/foo#baz");
myURL.hash = "#baz";
assert.sameValue(myURL.href, "https://example.org/foo#baz");
myURL.hash = "#á=1 2";
assert.sameValue(myURL.href, "https://example.org/foo#%C3%A1=1%202");
myURL.hash = "#a/#b";
// FAILING: the second # gets escaped
//assert.sameValue(myURL.href, "https://example.org/foo#a/#b");
assert.sameValue(myURL.search, "");
// FAILING: the second # gets escaped
//assert.sameValue(myURL.hash, "#a/#b");
// Host
myURL = new URL("https://example.org:81/foo");
myURL.host = "example.com:82";
assert.sameValue(myURL.href, "https://example.com:82/foo");
// Hostname
myURL = new URL("https://example.org:81/foo");
myURL.hostname = "example.com:82";
assert.sameValue(myURL.href, "https://example.org:81/foo");
myURL.hostname = "á.com";
assert.sameValue(myURL.href, "https://xn--1ca.com:81/foo");
// href
myURL = new URL("https://example.org/foo");
myURL.href = "https://example.com/bar";
assert.sameValue(myURL.href, "https://example.com/bar");
// Password
myURL = new URL("https://abc:xyz@example.com");
myURL.password = "123";
assert.sameValue(myURL.href, "https://abc:123@example.com/");
// pathname
myURL = new URL("https://example.org/abc/xyz?123");
myURL.pathname = "/abcdef";
assert.sameValue(myURL.href, "https://example.org/abcdef?123");
myURL.pathname = "";
assert.sameValue(myURL.href, "https://example.org/?123");
myURL.pathname = "á";
assert.sameValue(myURL.pathname, "/%C3%A1");
assert.sameValue(myURL.href, "https://example.org/%C3%A1?123");
// port
myURL = new URL("https://example.org:8888");
assert.sameValue(myURL.port, "8888");
function testSetPort(port, expected) {
const url = new URL("https://example.org:8888");
url.port = port;
assert.sameValue(url.port, expected);
}
testSetPort(0, "0");
testSetPort(-0, "0");
// Default ports are automatically transformed to the empty string
// (HTTPS protocol's default port is 443)
testSetPort("443", "");
testSetPort(443, "");
// Empty string is the same as default port
testSetPort("", "");
// Completely invalid port strings are ignored
testSetPort("abcd", "8888");
testSetPort("-123", "");
testSetPort(-123, "");
testSetPort(-123.45, "");
testSetPort(undefined, "8888");
testSetPort(null, "8888");
testSetPort(+Infinity, "8888");
testSetPort(-Infinity, "8888");
testSetPort(NaN, "8888");
// Leading numbers are treated as a port number
testSetPort("5678abcd", "5678");
testSetPort("a5678abcd", "");
// Non-integers are truncated
testSetPort(1234.5678, "1234");
// Out-of-range numbers which are not represented in scientific notation
// will be ignored.
testSetPort(1e10, "8888");
testSetPort("123456", "8888");
testSetPort(123456, "8888");
testSetPort(4.567e21, "4");
// toString() takes precedence over valueOf(), even if it returns a valid integer
testSetPort(
{
toString() {
return "2";
},
valueOf() {
return 1;
},
},
"2"
);
// Protocol
function testSetProtocol(url, protocol, expected) {
url.protocol = protocol;
assert.sameValue(url.protocol, expected);
}
testSetProtocol(new URL("https://example.org"), "ftp", "ftp:");
testSetProtocol(new URL("https://example.org"), "ftp:", "ftp:");
testSetProtocol(new URL("https://example.org"), "FTP:", "ftp:");
testSetProtocol(new URL("https://example.org"), "ftp: blah", "ftp:");
// special to non-special
testSetProtocol(new URL("https://example.org"), "foo", "https:");
// non-special to special
testSetProtocol(new URL("fish://example.org"), "https", "fish:");
// Search
myURL = new URL("https://example.org/abc?123");
myURL.search = "abc=xyz";
assert.sameValue(myURL.href, "https://example.org/abc?abc=xyz");
myURL.search = "a=1 2";
assert.sameValue(myURL.href, "https://example.org/abc?a=1%202");
myURL.search = "á=ú";
assert.sameValue(myURL.search, "?%C3%A1=%C3%BA");
assert.sameValue(myURL.href, "https://example.org/abc?%C3%A1=%C3%BA");
myURL.hash = "hash";
myURL.search = "a=#b";
assert.sameValue(myURL.href, "https://example.org/abc?a=%23b#hash");
assert.sameValue(myURL.search, "?a=%23b");
assert.sameValue(myURL.hash, "#hash");
// Username
myURL = new URL("https://abc:xyz@example.com/");
myURL.username = "123";
assert.sameValue(myURL.href, "https://123:xyz@example.com/");
// Origin, read-only
assert.throws(() => {
myURL.origin = "abc";
}, TypeError);
// href
myURL = new URL("https://example.org");
myURL.href = "https://example.com";
assert.sameValue(myURL.href, "https://example.com/");
assert.throws(() => {
myURL.href = "test";
}, TypeError);
// Search Params
myURL = new URL("https://example.com/");
myURL.searchParams.append("user", "abc");
assert.sameValue(myURL.toString(), "https://example.com/?user=abc");
myURL.searchParams.append("first", "one");
assert.sameValue(myURL.toString(), "https://example.com/?user=abc&first=one");
myURL.searchParams.delete("user");
assert.sameValue(myURL.toString(), "https://example.com/?first=one");
{
const url = require("url");
assert.sameValue(url.domainToASCII('español.com'), "xn--espaol-zwa.com");
assert.sameValue(url.domainToASCII('中文.com'), "xn--fiq228c.com");
assert.sameValue(url.domainToASCII('xn--iñvalid.com'), "");
assert.sameValue(url.domainToUnicode('xn--espaol-zwa.com'), "español.com");
assert.sameValue(url.domainToUnicode('xn--fiq228c.com'), "中文.com");
assert.sameValue(url.domainToUnicode('xn--iñvalid.com'), "");
}

315
script/modules/url/url.go Normal file
View File

@@ -0,0 +1,315 @@
package url
import (
"net"
"net/url"
"strings"
"github.com/sagernet/sing-box/script/jsc"
E "github.com/sagernet/sing/common/exceptions"
"github.com/dop251/goja"
"golang.org/x/net/idna"
)
type URL struct {
class jsc.Class[*Module, *URL]
url *url.URL
params *URLSearchParams
paramsValue goja.Value
}
func newURL(c jsc.Class[*Module, *URL], call goja.ConstructorCall) *URL {
var (
u, base *url.URL
err error
)
switch argURL := call.Argument(0).Export().(type) {
case *URL:
u = argURL.url
default:
u, err = parseURL(call.Argument(0).String())
if err != nil {
panic(c.Runtime().NewGoError(E.Cause(err, "parse URL")))
}
}
if len(call.Arguments) == 2 {
switch argBaseURL := call.Argument(1).Export().(type) {
case *URL:
base = argBaseURL.url
default:
base, err = parseURL(call.Argument(1).String())
if err != nil {
panic(c.Runtime().NewGoError(E.Cause(err, "parse base URL")))
}
}
}
if base != nil {
u = base.ResolveReference(u)
}
return &URL{class: c, url: u}
}
func createURL(module *Module) jsc.Class[*Module, *URL] {
class := jsc.NewClass[*Module, *URL](module)
class.DefineConstructor(newURL)
class.DefineField("hash", (*URL).getHash, (*URL).setHash)
class.DefineField("host", (*URL).getHost, (*URL).setHost)
class.DefineField("hostname", (*URL).getHostName, (*URL).setHostName)
class.DefineField("href", (*URL).getHref, (*URL).setHref)
class.DefineField("origin", (*URL).getOrigin, nil)
class.DefineField("password", (*URL).getPassword, (*URL).setPassword)
class.DefineField("pathname", (*URL).getPathname, (*URL).setPathname)
class.DefineField("port", (*URL).getPort, (*URL).setPort)
class.DefineField("protocol", (*URL).getProtocol, (*URL).setProtocol)
class.DefineField("search", (*URL).getSearch, (*URL).setSearch)
class.DefineField("searchParams", (*URL).getSearchParams, (*URL).setSearchParams)
class.DefineField("username", (*URL).getUsername, (*URL).setUsername)
class.DefineMethod("toString", (*URL).toString)
class.DefineMethod("toJSON", (*URL).toJSON)
class.DefineStaticMethod("canParse", canParse)
// class.DefineStaticMethod("createObjectURL", createObjectURL)
class.DefineStaticMethod("parse", parse)
// class.DefineStaticMethod("revokeObjectURL", revokeObjectURL)
return class
}
func canParse(class jsc.Class[*Module, *URL], call goja.FunctionCall) any {
switch call.Argument(0).Export().(type) {
case *URL:
default:
_, err := parseURL(call.Argument(0).String())
if err != nil {
return false
}
}
if len(call.Arguments) == 2 {
switch call.Argument(1).Export().(type) {
case *URL:
default:
_, err := parseURL(call.Argument(1).String())
if err != nil {
return false
}
}
}
return true
}
func parse(class jsc.Class[*Module, *URL], call goja.FunctionCall) any {
var (
u, base *url.URL
err error
)
switch argURL := call.Argument(0).Export().(type) {
case *URL:
u = argURL.url
default:
u, err = parseURL(call.Argument(0).String())
if err != nil {
return goja.Null()
}
}
if len(call.Arguments) == 2 {
switch argBaseURL := call.Argument(1).Export().(type) {
case *URL:
base = argBaseURL.url
default:
base, err = parseURL(call.Argument(1).String())
if err != nil {
return goja.Null()
}
}
}
if base != nil {
u = base.ResolveReference(u)
}
return &URL{class: class, url: u}
}
func (r *URL) getHash() any {
if r.url.Fragment != "" {
return "#" + r.url.EscapedFragment()
}
return ""
}
func (r *URL) setHash(value goja.Value) {
r.url.RawFragment = strings.TrimPrefix(value.String(), "#")
}
func (r *URL) getHost() any {
return r.url.Host
}
func (r *URL) setHost(value goja.Value) {
r.url.Host = strings.TrimSuffix(value.String(), ":")
}
func (r *URL) getHostName() any {
return r.url.Hostname()
}
func (r *URL) setHostName(value goja.Value) {
r.url.Host = joinHostPort(value.String(), r.url.Port())
}
func (r *URL) getHref() any {
return r.url.String()
}
func (r *URL) setHref(value goja.Value) {
newURL, err := url.Parse(value.String())
if err != nil {
panic(r.class.Runtime().NewGoError(err))
}
r.url = newURL
r.params = nil
}
func (r *URL) getOrigin() any {
return r.url.Scheme + "://" + r.url.Host
}
func (r *URL) getPassword() any {
if r.url.User != nil {
password, _ := r.url.User.Password()
return password
}
return ""
}
func (r *URL) setPassword(value goja.Value) {
if r.url.User == nil {
r.url.User = url.UserPassword("", value.String())
} else {
r.url.User = url.UserPassword(r.url.User.Username(), value.String())
}
}
func (r *URL) getPathname() any {
return r.url.EscapedPath()
}
func (r *URL) setPathname(value goja.Value) {
r.url.RawPath = value.String()
}
func (r *URL) getPort() any {
return r.url.Port()
}
func (r *URL) setPort(value goja.Value) {
r.url.Host = joinHostPort(r.url.Hostname(), value.String())
}
func (r *URL) getProtocol() any {
return r.url.Scheme + ":"
}
func (r *URL) setProtocol(value goja.Value) {
r.url.Scheme = strings.TrimSuffix(value.String(), ":")
}
func (r *URL) getSearch() any {
if r.params != nil {
if len(r.params.params) > 0 {
return "?" + generateQuery(r.params.params)
}
} else if r.url.RawQuery != "" {
return "?" + r.url.RawQuery
}
return ""
}
func (r *URL) setSearch(value goja.Value) {
params, err := parseQuery(value.String())
if err == nil {
if r.params != nil {
r.params.params = params
} else {
r.url.RawQuery = generateQuery(params)
}
}
}
func (r *URL) getSearchParams() any {
var params []searchParam
if r.url.RawQuery != "" {
params, _ = parseQuery(r.url.RawQuery)
}
if r.params == nil {
r.params = &URLSearchParams{
class: r.class.Module().classURLSearchParams,
params: params,
}
r.paramsValue = r.class.Module().classURLSearchParams.New(r.params)
}
return r.paramsValue
}
func (r *URL) setSearchParams(value goja.Value) {
if params, ok := value.Export().(*URLSearchParams); ok {
r.params = params
r.paramsValue = value
}
}
func (r *URL) getUsername() any {
if r.url.User != nil {
return r.url.User.Username()
}
return ""
}
func (r *URL) setUsername(value goja.Value) {
if r.url.User == nil {
r.url.User = url.User(value.String())
} else {
password, _ := r.url.User.Password()
r.url.User = url.UserPassword(value.String(), password)
}
}
func (r *URL) toString(call goja.FunctionCall) any {
if r.params != nil {
r.url.RawQuery = generateQuery(r.params.params)
}
return r.url.String()
}
func (r *URL) toJSON(call goja.FunctionCall) any {
return r.toString(call)
}
func parseURL(s string) (*url.URL, error) {
u, err := url.Parse(s)
if err != nil {
return nil, E.Cause(err, "invalid URL")
}
switch u.Scheme {
case "https", "http", "ftp", "wss", "ws":
if u.Path == "" {
u.Path = "/"
}
hostname := u.Hostname()
asciiHostname, err := idna.Punycode.ToASCII(strings.ToLower(hostname))
if err != nil {
return nil, E.Cause(err, "invalid hostname")
}
if asciiHostname != hostname {
u.Host = joinHostPort(asciiHostname, u.Port())
}
}
if u.RawQuery != "" {
u.RawQuery = escape(u.RawQuery, &tblEscapeURLQuery, false)
}
return u, nil
}
func joinHostPort(hostname, port string) string {
if port == "" {
return hostname
}
return net.JoinHostPort(hostname, port)
}

View File

@@ -0,0 +1,244 @@
package url
import (
"fmt"
"net/url"
"sort"
"strings"
"github.com/sagernet/sing-box/script/jsc"
F "github.com/sagernet/sing/common/format"
"github.com/dop251/goja"
)
type URLSearchParams struct {
class jsc.Class[*Module, *URLSearchParams]
params []searchParam
}
func createURLSearchParams(module *Module) jsc.Class[*Module, *URLSearchParams] {
class := jsc.NewClass[*Module, *URLSearchParams](module)
class.DefineConstructor(newURLSearchParams)
class.DefineField("size", (*URLSearchParams).getSize, nil)
class.DefineMethod("append", (*URLSearchParams).append)
class.DefineMethod("delete", (*URLSearchParams).delete)
class.DefineMethod("entries", (*URLSearchParams).entries)
class.DefineMethod("forEach", (*URLSearchParams).forEach)
class.DefineMethod("get", (*URLSearchParams).get)
class.DefineMethod("getAll", (*URLSearchParams).getAll)
class.DefineMethod("has", (*URLSearchParams).has)
class.DefineMethod("keys", (*URLSearchParams).keys)
class.DefineMethod("set", (*URLSearchParams).set)
class.DefineMethod("sort", (*URLSearchParams).sort)
class.DefineMethod("toString", (*URLSearchParams).toString)
class.DefineMethod("values", (*URLSearchParams).values)
return class
}
func newURLSearchParams(class jsc.Class[*Module, *URLSearchParams], call goja.ConstructorCall) *URLSearchParams {
var (
params []searchParam
err error
)
switch argInit := call.Argument(0).Export().(type) {
case *URLSearchParams:
params = argInit.params
case string:
params, err = parseQuery(argInit)
if err != nil {
panic(class.Runtime().NewGoError(err))
}
case [][]string:
for _, pair := range argInit {
if len(pair) != 2 {
panic(class.Runtime().NewTypeError("Each query pair must be an iterable [name, value] tuple"))
}
params = append(params, searchParam{pair[0], pair[1]})
}
case map[string]any:
for name, value := range argInit {
stringValue, isString := value.(string)
if !isString {
panic(class.Runtime().NewTypeError("Invalid query value"))
}
params = append(params, searchParam{name, stringValue})
}
}
return &URLSearchParams{class, params}
}
func (s *URLSearchParams) getSize() any {
return len(s.params)
}
func (s *URLSearchParams) append(call goja.FunctionCall) any {
name := jsc.AssertString(s.class.Runtime(), call.Argument(0), "name", false)
value := call.Argument(1).String()
s.params = append(s.params, searchParam{name, value})
return goja.Undefined()
}
func (s *URLSearchParams) delete(call goja.FunctionCall) any {
name := jsc.AssertString(s.class.Runtime(), call.Argument(0), "name", false)
argValue := call.Argument(1)
if !jsc.IsNil(argValue) {
value := argValue.String()
for i, param := range s.params {
if param.Key == name && param.Value == value {
s.params = append(s.params[:i], s.params[i+1:]...)
break
}
}
} else {
for i, param := range s.params {
if param.Key == name {
s.params = append(s.params[:i], s.params[i+1:]...)
break
}
}
}
return goja.Undefined()
}
func (s *URLSearchParams) entries(call goja.FunctionCall) any {
return jsc.NewIterator[*Module, searchParam](s.class.Module().classURLSearchParamsIterator, s.params, func(this searchParam) any {
return s.class.Runtime().NewArray(this.Key, this.Value)
})
}
func (s *URLSearchParams) forEach(call goja.FunctionCall) any {
callback := jsc.AssertFunction(s.class.Runtime(), call.Argument(0), "callbackFn")
thisValue := call.Argument(1)
for _, param := range s.params {
for _, value := range param.Value {
_, err := callback(thisValue, s.class.Runtime().ToValue(value), s.class.Runtime().ToValue(param.Key), call.This)
if err != nil {
panic(s.class.Runtime().NewGoError(err))
}
}
}
return goja.Undefined()
}
func (s *URLSearchParams) get(call goja.FunctionCall) any {
name := jsc.AssertString(s.class.Runtime(), call.Argument(0), "name", false)
for _, param := range s.params {
if param.Key == name {
return param.Value
}
}
return goja.Null()
}
func (s *URLSearchParams) getAll(call goja.FunctionCall) any {
name := jsc.AssertString(s.class.Runtime(), call.Argument(0), "name", false)
var values []any
for _, param := range s.params {
if param.Key == name {
values = append(values, param.Value)
}
}
return s.class.Runtime().NewArray(values...)
}
func (s *URLSearchParams) has(call goja.FunctionCall) any {
name := jsc.AssertString(s.class.Runtime(), call.Argument(0), "name", false)
argValue := call.Argument(1)
if !jsc.IsNil(argValue) {
value := argValue.String()
for _, param := range s.params {
if param.Key == name && param.Value == value {
return true
}
}
} else {
for _, param := range s.params {
if param.Key == name {
return true
}
}
}
return false
}
func (s *URLSearchParams) keys(call goja.FunctionCall) any {
return jsc.NewIterator[*Module, searchParam](s.class.Module().classURLSearchParamsIterator, s.params, func(this searchParam) any {
return this.Key
})
}
func (s *URLSearchParams) set(call goja.FunctionCall) any {
name := jsc.AssertString(s.class.Runtime(), call.Argument(0), "name", false)
value := call.Argument(1).String()
for i, param := range s.params {
if param.Key == name {
s.params[i].Value = value
return goja.Undefined()
}
}
s.params = append(s.params, searchParam{name, value})
return goja.Undefined()
}
func (s *URLSearchParams) sort(call goja.FunctionCall) any {
sort.SliceStable(s.params, func(i, j int) bool {
return s.params[i].Key < s.params[j].Key
})
return goja.Undefined()
}
func (s *URLSearchParams) toString(call goja.FunctionCall) any {
return generateQuery(s.params)
}
func (s *URLSearchParams) values(call goja.FunctionCall) any {
return jsc.NewIterator[*Module, searchParam](s.class.Module().classURLSearchParamsIterator, s.params, func(this searchParam) any {
return this.Value
})
}
type searchParam struct {
Key string
Value string
}
func parseQuery(query string) (params []searchParam, err error) {
query = strings.TrimPrefix(query, "?")
for query != "" {
var key string
key, query, _ = strings.Cut(query, "&")
if strings.Contains(key, ";") {
err = fmt.Errorf("invalid semicolon separator in query")
continue
}
if key == "" {
continue
}
key, value, _ := strings.Cut(key, "=")
key, err1 := url.QueryUnescape(key)
if err1 != nil {
if err == nil {
err = err1
}
continue
}
value, err1 = url.QueryUnescape(value)
if err1 != nil {
if err == nil {
err = err1
}
continue
}
params = append(params, searchParam{key, value})
}
return
}
func generateQuery(params []searchParam) string {
var parts []string
for _, param := range params {
parts = append(parts, F.ToString(param.Key, "=", url.QueryEscape(param.Value)))
}
return strings.Join(parts, "&")
}