约定:文件名 = 主类型名;同一个类型要拆多个文件时用 类型_子项.go。
engine.go 355 → 231 行。原来混了三件不相干的事
engine_globals.go ← bind / lazyGlobal / freeze / defineReadOnly(96 行)
它是「Go 值 → 只读 JS 全局」的转换层,跟脚本缓存毫无关系
engine_console.go ← console.go,跟上面是同一主题
script.go 325 → 100 行,只留公开方法
script_vm.go ← VM 的取、还、装载。上个 commit 合一的三条路径现在住一起
script_static.go ← static.go
errors.go 364 → 150 行
errors_goja.go ← goja 错误的翻译层
errors_hints.go ← missingGlobalHint,一份 JS 运行时知识库,跟错误分类是
两回事;拆出来之后 missing_global_test.go 才有对应源文件
bundle_finalize.go ← esmwrap.go
bundle.go 收下 validIdent / hashVersion(原来住在 engine.go)
Engine.New 原来排在所有私有函数之后,挪到导出方法那一段。
classify 83 → 54 行:四个 errors.As 分支各手搓一个 8 字段的 &Error{},脚本上下文
那三行重复了 4 遍,抽出 gojaError 构造器。
顺带修四处注释漂移:
Session/会话 代码里叫 Instance,注释里大面积残留。engine_globals.go 那条
「注入会话全局对象失败」还是用户可见文案,而公开 API 里根本
没有「会话」这个概念
Extension 文档示例写 Module() string,接口是 Module() (path, source string)。
这是唯一一段教人写扩展的文档,照抄编译不过
doc.go 的 freeze 说白名单「逐层拷贝成只读对象,不会跨 VM 共享可变的 Go map」,
但那只对 map[string]any 成立。结构体指针和 slice 是**共享同一个
对象**的——扩展走的正是这条路,不该被当成隔离保证
327 lines
10 KiB
Go
327 lines
10 KiB
Go
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 属性)。
|
||
// WithCall 的回调靠它判断脚本写的是哪种形状,见 Caller.Arity。
|
||
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 归还池子后再调用会出问题。
|
||
// 需要把脚本函数当回调用,走 WithCall——在 VM 借出期间调,别把函数带出来。
|
||
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 / *Instance / staticTarget 三方都走这里。
|
||
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 函数,只在脚本内部有效;需要回调语义请用 WithCall")
|
||
}
|
||
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 归还池子后它就失效了);需要回调语义请用 WithCall")
|
||
}
|
||
|
||
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 从池里借还,
|
||
// *Instance 独占一个 VM(acquire 会一直持锁到 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
|
||
}
|