Files
jscriptx/engine.go
T
what 0627d49425 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 在脚本里表现为抛异常,不占返回值位置。
  - 脚本能看见的全局只有白名单放行的那些,且注入是惰性的——没读到的
    全局根本不会被转换。
2026-09-05 22:11:55 +08:00

356 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"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"log/slog"
"runtime"
"sync"
"time"
"github.com/dop251/goja"
)
const (
// DefaultTimeout 是单次脚本调用的默认时限,超过就中断脚本。
DefaultTimeout = 5 * time.Second
// DefaultMaxCallStackSize 限制脚本的调用栈深度,防止递归打爆 Go 栈。
DefaultMaxCallStackSize = 2000
)
// Engine 是脚本引擎,持有全局白名单、执行策略和脚本缓存。
// 一个进程通常只需要一个 Engine,它本身并发安全。
type Engine struct {
globals map[string]any
timeout time.Duration
maxVMs int
maxStack int
logger *slog.Logger
loader Loader
autoReload bool
// WithLoader 收下的层,New 里合成 loader;Option 没有出错的地方,
// 校验只能推迟到那时候。
loaderLayers []Loader
bundleOpts []BundleOption
mu sync.Mutex
scripts map[string]*Script
closed bool
}
// New 创建引擎。全局白名单里的名字不合法时返回错误。
func New(opts ...Option) (*Engine, error) {
e := &Engine{
globals: map[string]any{},
timeout: DefaultTimeout,
maxVMs: runtime.GOMAXPROCS(0) * 2,
maxStack: DefaultMaxCallStackSize,
logger: slog.Default(),
scripts: map[string]*Script{},
}
for _, opt := range opts {
opt(e)
}
if e.maxVMs < 1 {
e.maxVMs = 1
}
if err := e.resolveLoader(); err != nil {
return nil, err
}
for name := range e.globals {
if !validIdent(name) {
return nil, fmt.Errorf("%w: 全局名 %q 不是合法的 JS 标识符", ErrBadGlobal, name)
}
}
return e, nil
}
// resolveLoader 把 WithLoader 收下的层合成一个 Loader。
func (e *Engine) resolveLoader() error {
switch len(e.loaderLayers) {
case 0:
return nil
case 1:
e.loader = e.loaderLayers[0]
if e.loader == nil {
return errors.New("jscriptx: WithLoader 收到 nil")
}
default:
o, err := Overlay(e.loaderLayers...)
if err != nil {
return err
}
e.loader = o
}
e.loaderLayers = nil
return nil
}
// Compile 用一段 ESM/TypeScript 源码注册脚本:先经 esbuild 打包,再交给 goja 编译,
// 结果进缓存,之后 Script(name) 能取到。同名脚本会被替换,旧的 VM 池随即释放
// (已经借出去的调用不受影响)。
//
// 源码必须有 export——产物是 IIFE,没有导出的顶层代码会被当死代码摇掉。
// 源码里要写 import 的话,得用 jscriptx/esm 子包按目录加载,或者配 WithResolveDir
// 给一个解析基准目录。
func (e *Engine) Compile(name, source string) (*Script, error) {
e.mu.Lock()
defer e.mu.Unlock()
if e.closed {
return nil, newError(KindClosed, name, "", ErrClosed, "引擎已关闭")
}
return e.compileLocked(name, source, hashVersion(source), false)
}
// Script 按名字取脚本:命中缓存直接返回;没命中就走 Loader 加载并编译。
// 打开了 WithAutoReload 时,每次都会跟 Loader 核对版本号,变了就重编译。
func (e *Engine) Script(name string) (*Script, error) {
e.mu.Lock()
defer e.mu.Unlock()
if e.closed {
return nil, newError(KindClosed, name, "", ErrClosed, "引擎已关闭")
}
cached, ok := e.scripts[name]
if ok && !e.autoReload {
return cached, nil
}
if e.loader == nil {
if ok {
return cached, nil
}
return nil, newError(KindNotFound, name, "", ErrScriptNotFound,
"没有配置 Loader,也没有通过 Compile 注册过这个脚本")
}
return e.loadLocked(name, cached)
}
// Reload 强制重新从 Loader 加载并编译,不管版本号有没有变。
func (e *Engine) Reload(name string) (*Script, error) {
e.mu.Lock()
defer e.mu.Unlock()
if e.closed {
return nil, newError(KindClosed, name, "", ErrClosed, "引擎已关闭")
}
if e.loader == nil {
return nil, newError(KindLoad, name, "", ErrScriptNotFound, "没有配置 Loader,无法重新加载")
}
return e.loadLocked(name, nil)
}
// Invalidate 把脚本从缓存里剔除并释放它的 VM 池,下次 Script 会重新加载。
func (e *Engine) Invalidate(name string) {
e.mu.Lock()
defer e.mu.Unlock()
if s, ok := e.scripts[name]; ok {
delete(e.scripts, name)
s.Close()
}
}
// Names 返回当前缓存里的脚本名。
func (e *Engine) Names() []string {
e.mu.Lock()
defer e.mu.Unlock()
out := make([]string, 0, len(e.scripts))
for name := range e.scripts {
out = append(out, name)
}
return out
}
// Close 关闭引擎,释放所有脚本的 VM 池。之后再取脚本会报 ErrClosed。
func (e *Engine) Close() {
e.mu.Lock()
defer e.mu.Unlock()
e.closed = true
for name, s := range e.scripts {
s.Close()
delete(e.scripts, name)
}
}
// compileLocked 编译并替换缓存里的同名脚本。调用方必须持有 e.mu。
//
// prepared 为 true 表示源码已经过打包(Loader 自己做过了),跳过这一步——
// 对已经是 IIFE 的产物再打包一次是纯浪费。
func (e *Engine) compileLocked(name, source, version string, prepared bool) (*Script, error) {
if !prepared {
// 脚本名同时当文件名传给打包器:扩展名(.ts/.json)决定按什么语法解析,
// 也是 sourcemap 和报错里显示的位置。
bundled, err := Bundle(name, source, e.bundleOpts...)
if err != nil {
return nil, newError(KindCompile, name, "", err, "脚本打包失败")
}
source = bundled
}
// 传 false = 不强制严格模式。
//
// 注意产物里**没有** "use strict"esbuild 输出 ESM 格式时不加这个指令,
// wrapESM 包成 IIFE 时也没加。所以脚本跑在非严格模式下,后果之一是给只读
// 全局赋值会**静默失败**而不是抛错(见 lazyglobal_test.go 的断言)。
//
// 想改成严格模式就把这里传 true,但那是行为变更:脚本里任何依赖非严格语义的
// 写法(给未声明变量赋值、with、八进制字面量……)都会开始报错。
prog, err := goja.Compile(name, source, false)
if err != nil {
return nil, newError(KindCompile, name, "", err, "脚本编译失败")
}
s := &Script{
engine: e,
name: name,
version: version,
prog: prog,
pool: make(chan *vmHandle, e.maxVMs),
}
if old, ok := e.scripts[name]; ok {
old.Close()
}
e.scripts[name] = s
return s, nil
}
// 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)
}
// validIdent 校验全局名是不是合法的 JS 标识符(只允许 ASCII 字母、数字、_ 和 $)。
func validIdent(s string) bool {
if s == "" {
return false
}
for i, r := range s {
switch {
case r == '_' || r == '$':
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z':
case r >= '0' && r <= '9':
if i == 0 {
return false
}
default:
return false
}
}
return true
}
func hashVersion(source string) string {
sum := sha256.Sum256([]byte(source))
return hex.EncodeToString(sum[:8])
}
// New 是 Script(name) + Script.New(ctx, args...) 的快捷方式:按名字取脚本,
// 实例化它导出的 class,构造参数直接传给 constructor。
//
// ctrl, err := e.New(ctx, "PkgVersion/PkgImportController")
// defer ctrl.Close()
// got, err := ctrl.Call(ctx, "Init")
func (e *Engine) New(ctx context.Context, name string, ctorArgs ...any) (*Instance, error) {
s, err := e.Script(name)
if err != nil {
return nil, err
}
return s.New(ctx, ctorArgs...)
}