Files
jscriptx/script.go
T
what 9b3509ae29 refactor: 清掉指向不存在的 Dispatch 的文档,删掉为它留的死导出
Dispatch 在仓库里出现 7 次,全是注释和错误文案,没有任何实现。三处错误文案
写着「需要回调语义请用 Dispatch」——使用者按这句去查会找不到东西,真正该指的
是 WithCall。caller.go 那句还指向不存在的子包 jscriptx/dispatch。

连带删掉四个为它留的导出(全仓库零调用):

  Target                   统一 Script/Instance 的接口。有未导出方法 owner(),
                           外部实现不了;也没有任何函数以它为参数或返回值
  ErrUnsupportedSignature  哨兵错误,库自己从不产生它
  KindSignature            错误分类,全仓库唯一一次出现就是它自己的声明。
                           留着会让写 switch 的人为一个永不出现的分支写代码
  OverlayLoader.Loaders    零调用的 getter,连测试都没有

另外删掉 Instance.IdleFor 和 lastUsed 字段:它是给「空闲回收」用的,而
doc.go 明确写着本库不代管实例生命周期、没有空闲回收——字段注释和包文档直接
对立。代价是每次 Call 白付两次 time.Now() + atomic store。业务侧真要自己回收,
记一个时间戳是一行的事。

caller 的示例原来拿 ErrUnsupportedSignature 当哨兵,改成自己声明一个——
回调签名的约定本来就是调用方定的,哨兵该归调用方。

验证:framework-v2 和 lx-bid 都仍能编译。
2026-09-10 15:15:10 +08:00

328 lines
11 KiB
Go
Raw 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"
"sync/atomic"
"github.com/dop251/goja"
)
// DefaultFunc 传给 Call/CallInto/WithCall 的 fn 参数时,表示脚本的默认导出本身,
// 也就是 `export default function ...` 这种"整个脚本就是一个函数"的写法。
// 默认导出是 class 或对象时用不上它——那种要按方法名调用。
const DefaultFunc = ""
// Script 是一份编译好的脚本,内部维护一个 VM 池。它并发安全,可以长期持有。
//
// 一个 Script 对应一份不可变的编译产物;热更新时 Engine 会造一个新的 Script
// 顶替它,已经拿着旧 Script 的调用不受影响,会继续跑完旧版本。
type Script struct {
engine *Engine
name string
version string
prog *goja.Program
pool chan *vmHandle
closed atomic.Bool
created atomic.Int64 // 累计新建过多少个 VM
dropped atomic.Int64 // 累计丢弃过多少个 VM(超时/panic/池满)
}
// vmHandle 是一个 VM:一个 goja.Runtime 加上它跑完脚本后的求值结果。
// Runtime 不是并发安全的,同一时刻只能有一个 goroutine 持有它。
//
// 一个 VM 只跑一个脚本。会话下多个脚本要共享数据时靠 store(Go 侧对象,注入到各个 VM
// 里的是同一份引用),而不是共用 Runtime——共用 Runtime 会让会话内的所有脚本被迫串行。
type vmHandle struct {
rt *goja.Runtime
defFn goja.Value // 脚本求值出的函数(单函数入口写法)
exports *goja.Object // 脚本求值出的对象(class 实例写法),方法调用时绑定为 this
ctor *goja.Object // 导出的 class 本身。静态方法挂在它上面,实例的原型链上没有
// scoped 表示这个 VM 注入过某个作用域的扩展,因此**不能**回池给别的作用域用。
// 用完直接丢,见 release。
scoped bool
}
// Name 返回脚本名。
func (s *Script) Name() string { return s.name }
// Version 返回脚本版本号(Loader 给的,或者源码哈希)。
func (s *Script) Version() string { return s.version }
// Stats 是 VM 池的运行统计,用于观测。
type Stats struct {
Pooled int // 池里闲置的 VM 数
MaxVMs int // 池容量
Created int64 // 累计新建
Dropped int64 // 累计丢弃
}
// Stats 返回 VM 池的当前统计。
func (s *Script) Stats() Stats {
return Stats{
Pooled: len(s.pool),
MaxVMs: cap(s.pool),
Created: s.created.Load(),
Dropped: s.dropped.Load(),
}
}
// Has 判断脚本导出的实例上有没有这个方法(继承来的也算)。
func (s *Script) Has(fn string) bool {
inst, err := s.borrow(context.Background())
if err != nil {
return false
}
defer s.release(inst, true)
_, _, _, ok := inst.lookup(fn)
return ok
}
// Close 释放池里所有 VM。之后的调用会返回 ErrClosed。
// 已经借出去、正在执行的调用不受影响,它们的 VM 归还时直接丢弃。
//
// New 出来的实例不在这条链上——它们的生命周期归调用方,Close 之后那些实例的调用
// 会因为脚本已关闭而报错,VM 等 GC 回收。
func (s *Script) Close() {
if s.closed.Swap(true) {
return
}
for {
select {
case <-s.pool:
s.dropped.Add(1)
default:
return
}
}
}
// borrow 从池里取一个 VM;池空就新建一个。
//
// 刻意不阻塞等待:并发量超过池容量时宁可临时多造几个 VM,也不让请求在这里排队。
// 池只是复用缓存,不承担限流职责。
//
// ctx 带了作用域时**不走池**:池是所有调用共享的,而作用域的扩展是这一次调用专属的
// (store、当前请求……)。混用要么让脚本看不见扩展(顶层 import 直接 ReferenceError),
// 要么把上一个作用域的对象漏给下一个。所以为它单造一个 VM,用完丢弃。
//
// 代价是这类调用每次约 5μs 建一个 VM。同一作用域下要反复调,用 New 拿实例更划算——
// 实例把 VM 攥在手里,不必每次重建。
func (s *Script) borrow(ctx context.Context) (*vmHandle, error) {
if sc, ok := scopeOf(ctx); ok {
// 作用域至少带一个 scope.key,所以只要有作用域就必然要单造
vm, err := s.newVM(ctx, sc.vmGlobals(), nil)
if err != nil {
return nil, err
}
vm.scoped = true
return vm, nil
}
select {
case inst := <-s.pool:
return inst, nil
default:
}
return s.newVM(ctx, nil, nil)
}
// release 归还 VM。healthy 为 false(被中断过或 panic 过)时直接丢弃:
// 脚本本来就不该有跨调用状态,重建一个 VM 远比拖着一个状态可疑的 VM 划算。
func (s *Script) release(inst *vmHandle, healthy bool) {
if inst == nil {
return
}
if !healthy || s.closed.Load() || inst.scoped {
// scoped 的 VM 带着某个作用域的扩展,回池就会漏给下一个调用
s.dropped.Add(1)
return
}
select {
case s.pool <- inst:
default:
s.dropped.Add(1) // 池满,丢弃
}
}
// newVM 造一个新 VM 并在里面加载这个脚本:注入白名单(extra 是会话专属的额外全局,
// 池化调用传 nil)→ 跑一遍脚本顶层代码 → 记下求值结果。
func (s *Script) newVM(ctx context.Context, extra map[string]any, ctorArgs []any) (*vmHandle, error) {
rt, err := s.engine.newRuntime(s.name, extra)
if err != nil {
return nil, err
}
return s.loadInto(ctx, rt, ctorArgs)
}
// newRuntime 造一个空 VM 并注入白名单。scope 是它在日志和 store 里的身份:
// 池化 VM 用脚本名,会话 VM 用会话 key。
func (e *Engine) newRuntime(scope string, extra map[string]any) (*goja.Runtime, error) {
rt := goja.New()
if e.maxStack > 0 {
rt.SetMaxCallStackSize(e.maxStack)
}
if err := e.bind(rt, scope, extra); err != nil {
return nil, err
}
return rt, nil
}
// loadInto 在一个已经建好的 VM 里跑这个脚本,取出它的导出。
// 顶层代码同样受超时保护,脚本在顶层写死循环不会把调用方卡住。
//
// 同一个 rt 上可以先后加载多个脚本:打包产物是 IIFE,除了 ModuleGlobal 这一个全局名
// 之外不往外泄漏东西,而那个名字的值在这里当场就取走了,后一个脚本覆盖它也不影响。
func (s *Script) loadInto(ctx context.Context, rt *goja.Runtime, ctorArgs []any) (*vmHandle, error) {
return s.load(ctx, rt, ctorArgs, false)
}
// load 把脚本装进一个 VM。noInstance 为 true 时不构造实例——
// 只想调静态方法时用,避免白跑一遍 constructor(那是每次调用的准备工作)。
func (s *Script) load(ctx context.Context, rt *goja.Runtime, ctorArgs []any, noInstance bool) (h *vmHandle, err error) {
stop := guard(ctx, rt, s.engine.timeout)
defer stop()
defer func() {
if r := recover(); r != nil {
h, err = nil, s.panicError(DefaultFunc, nil, r)
}
}()
v, err := rt.RunProgram(s.prog)
if err != nil {
return nil, classify(err, s.name, DefaultFunc, nil)
}
h = &vmHandle{rt: rt}
// 打包产物末尾的入口表达式求值出来的东西决定了脚本的形态:
// class → 由这里 new 出实例,构造参数从 Go 侧传(export default class X {}
// 普通函数 → 单函数入口,用 DefaultFunc 调用(export default function(){}
// 非函数对象 → 直接当导出实例(export default {...},或只有命名导出时的模块对象)
if !empty(v) {
switch {
case isClass(rt, v):
h.ctor = v.ToObject(rt)
if noInstance {
break // 只要静态方法,不跑 constructor
}
obj, err := construct(rt, v, ctorArgs)
if err != nil {
return nil, classify(err, s.name, "constructor", ctorArgs)
}
h.exports = obj
case isCallable(v):
h.defFn = v
default:
if obj, ok := v.(*goja.Object); ok {
h.exports = obj
}
}
}
s.created.Add(1)
return h, nil
}
// classDetector 判断一个值是不是 class。goja 的 AssertConstructor 对普通 function
// 也返回 true,区分不了;靠规范保证的差别来判:class 的 prototype 属性不可写,
// 普通函数的可写,箭头函数和方法简写根本没有 prototype。
var classDetector = goja.MustCompile("jscriptx:isclass", `(function (x) {
if (typeof x !== "function") { return false }
var d = Object.getOwnPropertyDescriptor(x, "prototype")
return !!d && d.writable === false
})`, true)
func isClass(rt *goja.Runtime, v goja.Value) bool {
if _, ok := goja.AssertConstructor(v); !ok {
return false
}
dv, err := rt.RunProgram(classDetector)
if err != nil {
return false
}
detect, ok := goja.AssertFunction(dv)
if !ok {
return false
}
res, err := detect(goja.Undefined(), v)
if err != nil {
return false
}
return res.ToBoolean()
}
func isCallable(v goja.Value) bool {
_, ok := goja.AssertFunction(v)
return ok
}
// construct 从 Go 侧 new 一个 JS class 实例,构造参数按 goja 的规则转换。
func construct(rt *goja.Runtime, class goja.Value, args []any) (*goja.Object, error) {
ctor, ok := goja.AssertConstructor(class)
if !ok {
return nil, fmt.Errorf("不是构造器")
}
jsArgs := make([]goja.Value, len(args))
for i, a := range args {
jsArgs[i] = rt.ToValue(a)
}
return ctor(nil, jsArgs...)
}
// lookup 找要调用的函数,同时给出调用时该绑定的 this。
//
// fn 为空取默认导出本身(export default function 那种);否则找导出实例的方法,
// this 绑定到实例,这样 class 里的 this.xxx 才有意义。
//
// 最后那层全局查找基本只是兜底:脚本经打包后是 IIFE,顶层声明进不了全局,
// 只有脚本显式往 globalThis 上挂东西时才走得到。
func (i *vmHandle) lookup(fn string) (callable goja.Callable, fnVal goja.Value, this goja.Value, ok bool) {
if fn == DefaultFunc {
if i.defFn == nil {
return nil, nil, nil, false
}
callable, ok = goja.AssertFunction(i.defFn)
return callable, i.defFn, goja.Undefined(), ok
}
if i.exports != nil {
// Get 会走原型链,class 方法定义在 prototype 上也找得到
if v := i.exports.Get(fn); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) {
if callable, ok = goja.AssertFunction(v); ok {
return callable, v, i.exports, true
}
}
}
// 静态方法挂在 class 自身上,不在实例的原型链上,所以实例那边找不到才轮到这里。
// this 绑定到 class 本身,跟 JS 里 C.Startup() 的语义一致。
if i.ctor != nil {
if v := i.ctor.Get(fn); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) {
if callable, ok = goja.AssertFunction(v); ok {
return callable, v, i.ctor, true
}
}
}
v := i.rt.Get(fn)
if v == nil || goja.IsUndefined(v) || goja.IsNull(v) {
return nil, nil, nil, false
}
if callable, ok = goja.AssertFunction(v); !ok {
return nil, nil, nil, false
}
return callable, v, goja.Undefined(), true
}
// notFoundHint 在找不到函数时给一句有用的话。最常见的原因是脚本压根没写 export:
// 打包产物是 IIFE,没有导出的顶层代码会被当死代码摇掉,什么都不剩。
func (s *Script) notFoundHint(inst *vmHandle) string {
if inst.exports == nil && inst.defFn == nil {
return "脚本没有任何导出。打包产物是 IIFE,不写 export 的顶层代码会被当死代码摇掉;" +
"请用 export default 指明入口,或者用命名导出"
}
return "脚本里没有这个函数,或者它不是函数"
}