package jscriptx import ( "context" "errors" "fmt" "log/slog" "runtime" "sync" "time" "github.com/dop251/goja" ) const ( // DefaultTimeout 是单次脚本调用的默认时限,超过就中断脚本。 DefaultTimeout = 5 * time.Second // DefaultMaxCallStackSize 限制脚本的调用栈深度,防止递归打爆 Go 栈。 DefaultMaxCallStackSize = 2000 ) // Engine 是脚本引擎,持有全局白名单、执行策略和脚本缓存。 // 一个进程通常只需要一个 Engine,它本身并发安全。 type Engine struct { globals map[string]any timeout time.Duration maxVMs int maxStack int logger *slog.Logger loader Loader autoReload bool // WithLoader 收下的层,New 里合成 loader;Option 没有出错的地方, // 校验只能推迟到那时候。 loaderLayers []Loader bundleOpts []BundleOption mu sync.Mutex scripts map[string]*Script closed bool } // New 创建引擎。全局白名单里的名字不合法时返回错误。 func New(opts ...Option) (*Engine, error) { e := &Engine{ globals: map[string]any{}, timeout: DefaultTimeout, maxVMs: runtime.GOMAXPROCS(0) * 2, maxStack: DefaultMaxCallStackSize, logger: slog.Default(), scripts: map[string]*Script{}, } for _, opt := range opts { opt(e) } if e.maxVMs < 1 { e.maxVMs = 1 } if err := e.resolveLoader(); err != nil { return nil, err } for name := range e.globals { if !validIdent(name) { return nil, fmt.Errorf("%w: 全局名 %q 不是合法的 JS 标识符", ErrBadGlobal, name) } } return e, nil } // resolveLoader 把 WithLoader 收下的层合成一个 Loader。 func (e *Engine) resolveLoader() error { switch len(e.loaderLayers) { case 0: return nil case 1: e.loader = e.loaderLayers[0] if e.loader == nil { return errors.New("jscriptx: WithLoader 收到 nil") } default: o, err := Overlay(e.loaderLayers...) if err != nil { return err } e.loader = o } e.loaderLayers = nil return nil } // Compile 用一段 ESM/TypeScript 源码注册脚本:先经 esbuild 打包,再交给 goja 编译, // 结果进缓存,之后 Script(name) 能取到。同名脚本会被替换,旧的 VM 池随即释放 // (已经借出去的调用不受影响)。 // // 源码必须有 export——产物是 IIFE,没有导出的顶层代码会被当死代码摇掉。 // 源码里要写 import 的话,得用 jscriptx/esm 子包按目录加载,或者配 WithResolveDir // 给一个解析基准目录。 func (e *Engine) Compile(name, source string) (*Script, error) { e.mu.Lock() defer e.mu.Unlock() if e.closed { return nil, newError(KindClosed, name, "", ErrClosed, "引擎已关闭") } return e.compileLocked(name, source, hashVersion(source), false) } // Script 按名字取脚本:命中缓存直接返回;没命中就走 Loader 加载并编译。 // 打开了 WithAutoReload 时,每次都会跟 Loader 核对版本号,变了就重编译。 func (e *Engine) Script(name string) (*Script, error) { e.mu.Lock() defer e.mu.Unlock() if e.closed { return nil, newError(KindClosed, name, "", ErrClosed, "引擎已关闭") } cached, ok := e.scripts[name] if ok && !e.autoReload { return cached, nil } if e.loader == nil { if ok { return cached, nil } return nil, newError(KindNotFound, name, "", ErrScriptNotFound, "没有配置 Loader,也没有通过 Compile 注册过这个脚本") } return e.loadLocked(name, cached) } // Reload 强制重新从 Loader 加载并编译,不管版本号有没有变。 func (e *Engine) Reload(name string) (*Script, error) { e.mu.Lock() defer e.mu.Unlock() if e.closed { return nil, newError(KindClosed, name, "", ErrClosed, "引擎已关闭") } if e.loader == nil { return nil, newError(KindLoad, name, "", ErrScriptNotFound, "没有配置 Loader,无法重新加载") } return e.loadLocked(name, nil) } // Invalidate 把脚本从缓存里剔除并释放它的 VM 池,下次 Script 会重新加载。 func (e *Engine) Invalidate(name string) { e.mu.Lock() defer e.mu.Unlock() if s, ok := e.scripts[name]; ok { delete(e.scripts, name) s.Close() } } // Names 返回当前缓存里的脚本名。 func (e *Engine) Names() []string { e.mu.Lock() defer e.mu.Unlock() out := make([]string, 0, len(e.scripts)) for name := range e.scripts { out = append(out, name) } return out } // Close 关闭引擎,释放所有脚本的 VM 池。之后再取脚本会报 ErrClosed。 func (e *Engine) Close() { e.mu.Lock() defer e.mu.Unlock() e.closed = true for name, s := range e.scripts { s.Close() delete(e.scripts, name) } } // New 是 Script(name) + Script.New(ctx, args...) 的快捷方式:按名字取脚本, // 实例化它导出的 class,构造参数直接传给 constructor。 // // ctrl, err := e.New(ctx, "PkgVersion/PkgImportController") // defer ctrl.Close() // got, err := ctrl.Call(ctx, "Init") func (e *Engine) New(ctx context.Context, name string, ctorArgs ...any) (*Instance, error) { s, err := e.Script(name) if err != nil { return nil, err } return s.New(ctx, ctorArgs...) } // compileLocked 编译并替换缓存里的同名脚本。调用方必须持有 e.mu。 // // prepared 为 true 表示源码已经过打包(Loader 自己做过了),跳过这一步—— // 对已经是 IIFE 的产物再打包一次是纯浪费。 func (e *Engine) compileLocked(name, source, version string, prepared bool) (*Script, error) { if !prepared { // 脚本名同时当文件名传给打包器:扩展名(.ts/.json)决定按什么语法解析, // 也是 sourcemap 和报错里显示的位置。 bundled, err := Bundle(name, source, e.bundleOpts...) if err != nil { return nil, newError(KindCompile, name, "", err, "脚本打包失败") } source = bundled } // 传 false = 不强制严格模式。 // // 注意产物里**没有** "use strict":esbuild 输出 ESM 格式时不加这个指令, // wrapESM 包成 IIFE 时也没加。所以脚本跑在非严格模式下,后果之一是给只读 // 全局赋值会**静默失败**而不是抛错(见 engine_globals_test.go 的断言)。 // // 想改成严格模式就把这里传 true,但那是行为变更:脚本里任何依赖非严格语义的 // 写法(给未声明变量赋值、with、八进制字面量……)都会开始报错。 prog, err := goja.Compile(name, source, false) if err != nil { return nil, newError(KindCompile, name, "", err, "脚本编译失败") } s := &Script{ engine: e, name: name, version: version, prog: prog, pool: make(chan *vmHandle, e.maxVMs), } if old, ok := e.scripts[name]; ok { old.Close() } e.scripts[name] = s return s, nil }