js/pool.go

318 lines
7.2 KiB
Go

package js
import (
"context"
"fmt"
"reflect"
"regexp"
"sort"
"sync"
"sync/atomic"
"apigo.cc/go/jsmod"
"apigo.cc/go/log"
"github.com/dop251/goja"
)
type scriptEntry struct {
name string
code string
version int64
}
type vmInstance struct {
runtime *goja.Runtime
version int32
}
// Pool represents an isolated JS execution environment with its own script registry and VM pool.
type Pool struct {
version int32
scripts []*scriptEntry
scriptMap map[string]*scriptEntry
functions map[string]struct{}
mu sync.RWMutex
pool sync.Pool
// Lifecycle management
ctx context.Context
cancel context.CancelFunc
wg sync.WaitGroup
closed int32
}
// NewPool creates a new isolated JS execution environment.
func NewPool() *Pool {
p := &Pool{
scriptMap: make(map[string]*scriptEntry),
functions: make(map[string]struct{}),
}
p.ctx, p.cancel = context.WithCancel(context.Background())
p.pool = sync.Pool{
New: func() any {
return &vmInstance{
runtime: createNewRuntime(),
version: 0,
}
},
}
return p
}
// DefaultPool is the global singleton pool.
var DefaultPool = NewPool()
func createNewRuntime() *goja.Runtime {
vm := goja.New()
// 1. Inject Native Modules from jsmod
goObj := vm.NewObject()
_ = vm.Set("go", goObj)
modules := jsmod.GetModules()
for modName, mod := range modules {
modObj := vm.NewObject()
for name, val := range mod.Exports {
isUnsafe := mod.UnsafeList[name]
if val != nil && reflect.TypeOf(val).Kind() == reflect.Func {
_ = modObj.Set(name, wrapGoFunc(vm, val, isUnsafe))
} else {
_ = modObj.Set(name, vm.ToValue(val))
}
}
_ = goObj.Set(modName, modObj)
}
return vm
}
var funcRegex = regexp.MustCompile(`function\s+([a-zA-Z0-9_]+)\s*\(`)
var constFuncRegex = regexp.MustCompile(`(?:const|let|var)\s+([a-zA-Z0-9_]+)\s*=\s*(?:function|\([^)]*\)\s*=>)`)
// Define adds JS code to the pool's registry.
// name and version are optional and used for CheckVersion.
func (p *Pool) Define(code string, args ...any) {
p.mu.Lock()
defer p.mu.Unlock()
name := ""
version := int64(0)
if len(args) > 0 {
if s, ok := args[0].(string); ok {
name = s
}
}
if len(args) > 1 {
if v, ok := args[1].(int64); ok {
version = v
}
}
entry := &scriptEntry{name: name, code: code, version: version}
p.scripts = append(p.scripts, entry)
if name != "" {
p.scriptMap[name] = entry
}
// Extract functions for FuncList
matches := funcRegex.FindAllStringSubmatch(code, -1)
for _, m := range matches {
if len(m) > 1 {
p.functions[m[1]] = struct{}{}
}
}
matches = constFuncRegex.FindAllStringSubmatch(code, -1)
for _, m := range matches {
if len(m) > 1 {
p.functions[m[1]] = struct{}{}
}
}
atomic.AddInt32(&p.version, 1)
}
// CheckVersion returns true if a script with the given name exists and its version is >= the provided version.
func (p *Pool) CheckVersion(name string, version int64) bool {
p.mu.RLock()
defer p.mu.RUnlock()
if entry, ok := p.scriptMap[name]; ok {
return entry.version >= version
}
return false
}
func CheckVersion(name string, version int64) bool {
return DefaultPool.CheckVersion(name, version)
}
// Call executes a JS function from the pool.
func (p *Pool) Call(ctx context.Context, funcName string, args []any, opts ...CallOption) (any, error) {
if atomic.LoadInt32(&p.closed) == 1 {
return nil, fmt.Errorf("js.Pool: pool is closed")
}
instance := p.pool.Get().(*vmInstance)
defer p.pool.Put(instance)
// Tracking active calls for graceful shutdown
p.wg.Add(1)
defer p.wg.Done()
vm := instance.runtime
vm.ClearInterrupt()
// 1. Synchronize scripts if version is behind
currentVersion := atomic.LoadInt32(&p.version)
if instance.version < currentVersion {
p.mu.RLock()
for i := int(instance.version); i < len(p.scripts); i++ {
_, err := vm.RunString(p.scripts[i].code)
if err != nil {
p.mu.RUnlock()
return nil, fmt.Errorf("js.sync error at script %d: %w", i, err)
}
}
instance.version = currentVersion
p.mu.RUnlock()
}
// 2. Set Context and default state
_ = vm.Set("__ctx__", vm.ToValue(ctx))
_ = vm.Set("__safeMode__", true) // Default is safe
_ = vm.Set("__logger__", goja.Undefined())
// Set up context interruption
if ctx != nil && ctx.Done() != nil {
stop := make(chan struct{})
defer close(stop)
go func() {
select {
case <-ctx.Done():
vm.Interrupt("context canceled")
case <-stop:
}
}()
}
// Apply Options
for _, opt := range opts {
opt(vm)
}
// 3. Get and Call JS Function
fnVal := vm.Get(funcName)
if fnVal == nil || goja.IsUndefined(fnVal) {
return nil, fmt.Errorf("js.Call: function '%s' not found", funcName)
}
callable, ok := goja.AssertFunction(fnVal)
if !ok {
return nil, fmt.Errorf("js.Call: '%s' is not a function", funcName)
}
jsArgs := make([]goja.Value, len(args))
for i, arg := range args {
jsArgs[i] = vm.ToValue(arg)
}
// 4. Execution with error capture
var result goja.Value
var err error
func() {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("js panic: %v", r)
}
}()
result, err = callable(goja.Undefined(), jsArgs...)
}()
if err != nil {
return nil, err
}
return result.Export(), nil
}
// --- Global Proxy Functions ---
func Define(code string, args ...any) {
DefaultPool.Define(code, args...)
}
func Call(ctx context.Context, funcName string, args []any, opts ...CallOption) (any, error) {
return DefaultPool.Call(ctx, funcName, args, opts...)
}
// --- Starter Interface Implementation ---
func (p *Pool) Start(ctx context.Context, logger *log.Logger) error {
// For JS engine, start is mostly for pre-warming or registry checking.
// We ensure the context is not canceled.
if p.ctx.Err() != nil {
p.ctx, p.cancel = context.WithCancel(context.Background())
}
atomic.StoreInt32(&p.closed, 0)
return nil
}
func (p *Pool) Stop(ctx context.Context) error {
atomic.StoreInt32(&p.closed, 1)
p.cancel() // Notify any long-running JS that are context-aware
// Wait for active Call() to finish or context timeout
done := make(chan struct{})
go func() {
p.wg.Wait()
close(done)
}()
select {
case <-done:
return nil
case <-ctx.Done():
return fmt.Errorf("js.Pool: shutdown timeout, active tasks may still be running")
}
}
func (p *Pool) Status() (string, error) {
p.mu.RLock()
defer p.mu.RUnlock()
return fmt.Sprintf("scripts: %d, functions: %d, version: %d, closed: %v", len(p.scripts), len(p.functions), p.version, atomic.LoadInt32(&p.closed) == 1), nil
}
// --- Helper types from original file ---
// CallOption allows configuring the JS execution environment.
type CallOption func(vm *goja.Runtime)
// WithSafeMode enables or disables safe mode for the call.
func WithSafeMode(enabled bool) CallOption {
return func(vm *goja.Runtime) {
_ = vm.Set("__safeMode__", enabled)
}
}
// WithLogger injects a custom logger for the call.
func WithLogger(logger *log.Logger) CallOption {
return func(vm *goja.Runtime) {
_ = vm.Set("__logger__", vm.ToValue(logger))
}
}
// FuncList returns the list of all defined JS function names.
func (p *Pool) FuncList() []string {
p.mu.RLock()
defer p.mu.RUnlock()
list := make([]string, 0, len(p.functions))
for name := range p.functions {
list = append(list, name)
}
sort.Strings(list)
return list
}
func FuncList() []string {
return DefaultPool.FuncList()
}