package jscriptx import "github.com/dop251/goja" // Go 值 → 只读 JS 全局的转换层。 // // 脚本能看见的全局只有这里放行的:WithGlobals 配的白名单,加上作用域带来的扩展。 // 别的一概没有——没有 require、没有 fs、没有 process,goja 本身也不提供。 // // 注入只在**建 VM 时**发生一次,对每次调用零开销;而且是惰性的(见 lazyGlobal), // 脚本没读到的全局根本不会被转换。 // bind 把白名单全局对象注入到一个新建的 VM 里。 // extra 是这个 VM 专属的额外全局(作用域带来的扩展),可以为 nil; // 它跟白名单同样按只读注入,同名时以 extra 为准。 // // 注入是**惰性**的:这里只装一个 getter,脚本第一次读到那个名字才把值转成 // JS 对象。一个脚本通常只用得上少数几个扩展,而 freeze 要把每个方法都包装成 // JS 函数——用不到的那些不该在每次建 VM 时都付一遍这个成本。见 lazyGlobal。 func (e *Engine) bind(rt *goja.Runtime, script string, extra map[string]any) error { global := rt.GlobalObject() for name, val := range e.globals { if _, overridden := extra[name]; overridden { continue } if err := e.lazyGlobal(rt, global, name, val); err != nil { return newError(KindBind, script, "", err, "注入全局对象 %q 失败", name) } } for name, val := range extra { if err := e.lazyGlobal(rt, global, name, val); err != nil { return newError(KindBind, script, "", err, "注入作用域全局对象 %q 失败", name) } } if e.logger != nil { _, taken := e.globals["console"] if _, t2 := extra["console"]; t2 { taken = true } if !taken { if err := e.lazyGlobal(rt, global, "console", newConsole(e.logger, script)); err != nil { return newError(KindBind, script, "", err, "注入 console 失败") } } } return nil } // lazyGlobal 装一个惰性只读全局:脚本第一次读它才 freeze,之后复用。 // // 为什么惰性:freeze 要把 map 里每个方法都包装成 JS 函数对象,而一个脚本通常只用 // 得上少数几个扩展。急切注入的话,每建一个 VM 都要为**所有**扩展付这份成本—— // 实测这是脚本层剩余开销里最大的一块。 // // 缓存放在闭包里,不加锁:getter 只在脚本执行期间被调用,而那时这个 VM 是被独占的 // (实例持着自己的锁,池化的 VM 同时只有一个借用者)。goja.Value 也跨不了 Runtime, // 所以这份缓存天然是每 VM 一份。 // // 只给 getter 不给 setter,效果等同原来的 writable=false:脚本赋值时没有 setter 可调。 // 产物跑在非严格模式下(见 compileLocked 那里的说明),所以赋值是**静默失败**—— // 不抛错,值也不变。configurable 同样保持 false,删不掉也重定义不了。 // // 代价是 freeze 的错误从"建 VM 时返回 Go 错误"变成"脚本读它时抛 JS 异常"。 // freeze 只在属性名不合法时才会失败,而 New() 里的 validIdent 已经挡过一道, // 实际碰不到。 func (e *Engine) lazyGlobal(rt *goja.Runtime, global *goja.Object, name string, val any) error { var ( cached goja.Value failed error ) getter := rt.ToValue(func(goja.FunctionCall) goja.Value { if cached == nil && failed == nil { cached, failed = e.freeze(rt, val) } if failed != nil { panic(rt.NewGoError(failed)) } return cached }) return global.DefineAccessorProperty(name, getter, nil, goja.FLAG_FALSE, goja.FLAG_TRUE) } // freeze 把 map[string]any 递归转成只读的 JS 对象,其他值原样交给 goja 包装。 // // 直接 rt.Set(name, someMap) 会把同一个 Go map 暴露给每个 VM:脚本一句 // db.C = null 既能污染别的 VM,又是实打实的数据竞争。这里每个 VM 都拿到 // 自己的一份不可写、不可重定义的对象。 func (e *Engine) freeze(rt *goja.Runtime, val any) (goja.Value, error) { m, ok := val.(map[string]any) if !ok { return rt.ToValue(val), nil } obj := rt.NewObject() for k, v := range m { child, err := e.freeze(rt, v) if err != nil { return nil, err } if err := defineReadOnly(obj, k, child); err != nil { return nil, err } } return obj, nil } func defineReadOnly(obj *goja.Object, name string, v goja.Value) error { return obj.DefineDataProperty(name, v, goja.FLAG_FALSE, goja.FLAG_FALSE, goja.FLAG_TRUE) }