Files
what 7e1893b246 refactor: 文件按主类型划分,一个 struct 一个功能
约定:文件名 = 主类型名;同一个类型要拆多个文件时用 类型_子项.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 是**共享同一个
                 对象**的——扩展走的正是这条路,不该被当成隔离保证
2026-09-10 15:28:59 +08:00

327 lines
10 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 独占一个 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
}