feat: 嵌入式 JS 脚本引擎核心

用 goja 承载业务回调,让业务逻辑变更不必重新编译发布 Go 程序。脚本用
ESM + TypeScript 写,Go 侧按名字把它们当普通对象实例化并调用方法。

主要组成:

  - Engine    编译脚本、管配置,公开 API 不暴露任何 goja 类型
  - Script    一份编译好的脚本 + 它的 VM 池,热更新时整体顶替
  - Instance  独占一个 VM 的实例,状态留在 JS 侧
  - Caller    自定义调用约定,把脚本函数适配成 Go 侧要的签名
  - Scope     让同一个 ctx 下的多个脚本共享 Go 侧对象
  - Extension 扩展接口:给脚本添全局对象,配套 TS 类型
  - Overlay   多层 Loader 叠加,后面的盖前面的

几个关键取舍:

  - 源码一律先过 esbuild 打包成 ESM,再改写成立即执行函数。goja 不认
    import/export,而业务脚本要能拆文件、用 TypeScript。
  - VM 池化复用,但每个 VM 单线程。goja 的 Runtime 不是 goroutine 安全的。
  - Go 侧函数返回的 error 在脚本里表现为抛异常,不占返回值位置。
  - 脚本能看见的全局只有白名单放行的那些,且注入是惰性的——没读到的
    全局根本不会被转换。
This commit is contained in:
2026-09-05 22:11:55 +08:00
commit 0627d49425
18 changed files with 2905 additions and 0 deletions
+326
View File
@@ -0,0 +1,326 @@
package jscriptx
import (
"context"
"fmt"
"reflect"
"runtime/debug"
"sync"
"time"
"github.com/dop251/goja"
)
// frame 是一次调用的上下文:拿到了 VM、目标函数,可以真正发起调用。
// 它只在 withCall 的回调里有效,不能存下来跨调用使用。
type frame struct {
script *Script
rt *goja.Runtime
fn goja.Callable
fnVal goja.Value
this goja.Value // 方法调用时绑定的 this(导出实例),默认导出函数为 undefined
name string
}
// arity 返回 JS 函数声明的形参个数(函数对象的 length 属性),
// Dispatch 靠它判断脚本写的是哪种回调形状。
func (f *frame) arity() int64 {
return f.fnVal.ToObject(f.rt).Get("length").ToInteger()
}
// call 调用目标函数,this 按 lookup 的结果绑定。
func (f *frame) call(args ...goja.Value) (goja.Value, error) {
return f.fn(f.this, args...)
}
// callWith 用 Go 值直接调用目标函数。
func (f *frame) callWith(args []any) (goja.Value, error) {
jsArgs := make([]goja.Value, len(args))
for i, a := range args {
jsArgs[i] = f.rt.ToValue(a)
}
return f.call(jsArgs...)
}
// export 把脚本返回值转成 Go 值,写进 target 指向的变量。
func (f *frame) export(v goja.Value, target any) error {
return f.rt.ExportTo(v, target)
}
// empty 判断脚本返回值是不是"什么都没返回"。
func empty(v goja.Value) bool {
return v == nil || goja.IsUndefined(v) || goja.IsNull(v)
}
// Call 调用脚本里的函数,返回值导出成 Go 值(JS 对象变 map[string]any,数组变 []any
// 传进去的 Go 对象原样回来)。fn 传 DefaultFunc 表示调用脚本自身求值出的那个函数。
//
// 脚本返回函数/闭包会被拒绝:那种值只在 VM 内部有效,VM 归还池子后再调用会出问题。
// 需要把脚本函数当回调用,走 Dispatch。
func (s *Script) Call(ctx context.Context, fn string, args ...any) (any, error) {
return callAny(ctx, s, fn, args)
}
// CallInto 调用脚本里的函数,并把返回值转换进 out 指向的变量(out 必须是非 nil 指针),
// 相当于带类型的 Call:目标是 int 就按 int 转,是某个接口就要求返回值实现它。
func (s *Script) CallInto(ctx context.Context, fn string, out any, args ...any) error {
return callInto(ctx, s, fn, out, args)
}
// callAny 是 Call 的共用实现,*Script 和 *Session 都走这里。
func callAny(ctx context.Context, r runner, fn string, args []any) (any, error) {
var out any
err := invoke(ctx, r, fn, args, func(f *frame) error {
res, err := f.callWith(args)
if err != nil {
return err
}
res, err = unwrapPromise(f, res, r.owner().name, fn)
if err != nil {
return err
}
if empty(res) {
return nil
}
if _, isFunc := goja.AssertFunction(res); isFunc {
return newError(KindType, r.owner().name, fn, ErrValueEscape,
"返回值是 JS 函数,只在脚本内部有效;需要回调语义请用 Dispatch")
}
out = res.Export()
return nil
})
return out, err
}
// callInto 是 CallInto 的共用实现。
func callInto(ctx context.Context, r runner, fn string, out any, args []any) error {
name := r.owner().name
rv := reflect.ValueOf(out)
if !rv.IsValid() || rv.Kind() != reflect.Pointer || rv.IsNil() {
return newError(KindType, name, fn, nil, "CallInto 的 out 必须是非 nil 指针,当前是 %T", out)
}
// 导出成 Go 函数意味着把一个绑定在 VM 上的闭包带出脚本边界,
// 而这个 VM 马上就要还回池子给别的请求用了。
if rv.Type().Elem().Kind() == reflect.Func {
return newError(KindType, name, fn, ErrValueEscape,
"不能把脚本函数导出成 Go 函数(VM 归还池子后它就失效了);需要回调语义请用 Dispatch")
}
return invoke(ctx, r, fn, args, func(f *frame) error {
res, err := f.callWith(args)
if err != nil {
return err
}
res, err = unwrapPromise(f, res, name, fn)
if err != nil {
return err
}
if empty(res) {
return nil
}
if err := f.export(res, out); err != nil {
return newError(KindType, name, fn, err, "返回值无法转换成 %T", out)
}
return nil
})
}
// runner 抽象"这次调用用哪个 VM、用完怎么处理":*Script 从池里借还,
// *Session 独占一个 VMacquire 会一直持锁到 finish,保证同一会话串行执行)。
type runner interface {
owner() *Script
acquire(ctx context.Context) (*vmHandle, error)
finish(inst *vmHandle, healthy bool)
}
func (s *Script) owner() *Script { return s }
func (s *Script) acquire(ctx context.Context) (*vmHandle, error) { return s.borrow(ctx) }
func (s *Script) finish(inst *vmHandle, healthy bool) { s.release(inst, healthy) }
// invoke 是所有脚本调用的骨架:取 VM → 找函数 → 装超时哨兵 → 兜 panic → 分类错误 → 交还 VM。
// do 里只管发起调用和处理返回值,异常处理交给这里。
func invoke(ctx context.Context, r runner, fn string, args []any, do func(*frame) error) (err error) {
s := r.owner()
if s.closed.Load() {
return newError(KindClosed, s.name, fn, ErrClosed, "脚本已关闭")
}
if ctx == nil {
ctx = context.Background()
}
inst, err := r.acquire(ctx)
if err != nil {
return err
}
healthy := true
// defer 是后进先出:recover 最先跑(把 healthy 置回 false),
// 然后 stop 清掉可能迟到的中断标志,最后才交还 VM。
defer func() { r.finish(inst, healthy) }()
callable, fnVal, this, ok := inst.lookup(fn)
if !ok {
return newError(KindNotFound, s.name, fn, ErrFuncNotFound, "%s", s.notFoundHint(inst))
}
stop := guard(ctx, inst.rt, s.engine.timeout)
defer stop()
defer func() {
if r := recover(); r != nil {
healthy = false
err = s.panicError(fn, args, r)
}
}()
f := &frame{script: s, rt: inst.rt, fn: callable, fnVal: fnVal, this: this, name: fn}
if e := do(f); e != nil {
err = classify(e, s.name, fn, args)
healthy = !fatal(err)
return err
}
return nil
}
// panicError 把 recover 到的 panic 包成带现场的错误。脚本里的类型错误、
// 注入进去的 Go 方法内部 panic,都会走到这里,不会掀翻调用方的 goroutine。
func (s *Script) panicError(fn string, args []any, r any) *Error {
return &Error{
Kind: KindPanic,
Script: s.name,
Func: fn,
Msg: fmt.Sprintf("脚本执行过程中发生 panic: %v", r),
Args: summarize(args),
GoStack: string(debug.Stack()),
Cause: toError(r),
}
}
// interruptGuard 保证"中断"和"收尾"两件事不会打架:收尾之后再迟到的中断信号必须被丢掉,
// 否则它会毒死下一次用到这个 VM 的调用。
type interruptGuard struct {
mu sync.Mutex
stopped bool
rt *goja.Runtime
}
func (g *interruptGuard) fire(reason error) {
g.mu.Lock()
defer g.mu.Unlock()
if g.stopped {
return
}
g.rt.Interrupt(reason)
}
// stop 关掉哨兵并清掉可能已经发出的中断标志。返回后保证不会再有新的中断落到这个 VM 上。
func (g *interruptGuard) stop() {
g.mu.Lock()
g.stopped = true
g.mu.Unlock()
// 到这里要么 fire 已经跑完(中断标志由下面清掉),要么它以后永远不会再发。
g.rt.ClearInterrupt()
}
// guard 装一个中断哨兵:超时或者调用方 context 取消时,从另一个 goroutine 调
// Interrupt 打断正在执行的脚本(死循环也能断掉)。返回的函数负责收尾。
func guard(ctx context.Context, rt *goja.Runtime, timeout time.Duration) func() {
watchCtx := ctx.Done() != nil
if timeout <= 0 && !watchCtx {
return func() {}
}
g := &interruptGuard{rt: rt}
if !watchCtx {
// 快路径:没有 context 取消要盯,一个定时器就够了,不用为每次调用起 goroutine。
// MQTT 消息级这种高频调用走的就是这条路。
timer := time.AfterFunc(timeout, func() { g.fire(context.DeadlineExceeded) })
return func() {
g.stop()
timer.Stop()
}
}
var cancel context.CancelFunc
if timeout > 0 {
ctx, cancel = context.WithTimeout(ctx, timeout)
}
done := make(chan struct{})
exited := make(chan struct{})
go func() {
defer close(exited)
select {
case <-ctx.Done():
g.fire(ctx.Err())
case <-done:
}
}()
return func() {
close(done)
<-exited
if cancel != nil {
cancel()
}
g.stop()
}
}
// unwrapPromise 把 async 方法返回的 Promise 解开。
//
// goja 在调用返回前会把 microtask 队列跑干净,所以只要脚本里没有真正需要等待外部
// 事件的操作(goja 没有事件循环,也没有 setTimeout),Promise 到这里已经是完成态了。
// 解开它有两个理由:一是 *goja.Promise 属于引擎类型,不该出现在本库的返回值里;
// 二是调用方拿到一个未解包的 Promise 什么也做不了。
func unwrapPromise(f *frame, v goja.Value, script, fn string) (goja.Value, error) {
p, ok := promiseOf(v)
if !ok {
return v, nil
}
switch p.State() {
case goja.PromiseStateFulfilled:
return p.Result(), nil
case goja.PromiseStateRejected:
reason := p.Result()
e := &Error{
Kind: KindRuntime,
Script: script,
Func: fn,
Msg: "async 方法的 Promise 被 reject",
Cause: ErrPromiseRejected,
}
if reason != nil {
e.Msg = reason.String()
e.Value = reason.Export()
}
return nil, e
default:
return nil, newError(KindRuntime, script, fn, ErrPromisePending,
"async 方法返回的 Promise 一直没完成。goja 没有事件循环,"+
"脚本里没法等待真正的异步操作(定时器、网络、IO);"+
"异步的活交给 Go 侧做,脚本只写同步逻辑")
}
}
// promiseOf 判断一个返回值是不是 Promise。
//
// *goja.Promise 只能从 Export() 拿到,但对普通对象调 Export 会把整个对象转成 map,
// 那个开销不能加在每次调用上。先看有没有 then 属性——一次廉价的属性查找就能排除掉
// 绝大多数返回值,只有 thenable 才走到 Export。
func promiseOf(v goja.Value) (*goja.Promise, bool) {
obj, ok := v.(*goja.Object)
if !ok {
return nil, false
}
if then := obj.Get("then"); then == nil || goja.IsUndefined(then) {
return nil, false
}
p, ok := obj.Export().(*goja.Promise)
return p, ok
}