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 在脚本里表现为抛异常,不占返回值位置。
  - 脚本能看见的全局只有白名单放行的那些,且注入是惰性的——没读到的
    全局根本不会被转换。
This commit is contained in:
2026-09-05 22:11:55 +08:00
commit 0627d49425
18 changed files with 2905 additions and 0 deletions
+86
View File
@@ -0,0 +1,86 @@
package jscriptx
import (
"log/slog"
"time"
)
// 引擎的配置项都在这个文件里,一处看全 New 能配什么。
//
// 另有两组独立的选项:BundleOption(打包源码,见 bundle.go)由 WithBundleOptions
// 带进来;ScopeOption(每次调用的作用域,见 scope.go)跟着 ctx 走,不属于引擎配置。
// Option 是 New 的配置项。
type Option func(*Engine)
// WithGlobals 追加暴露给脚本的全局对象白名单。可以多次调用,同名后者覆盖前者。
//
// value 为 map[string]any 时会被注入成一个只读的 JS 对象(逐层递归),
// 脚本改不动它,多个 VM 之间也不会共享同一个可变的 Go map。
func WithGlobals(globals map[string]any) Option {
return func(e *Engine) {
for k, v := range globals {
e.globals[k] = v
}
}
}
// WithGlobal 暴露单个全局对象。
func WithGlobal(name string, value any) Option {
return func(e *Engine) { e.globals[name] = value }
}
// WithTimeout 设置单次调用的时限,超时后脚本会被强制中断,调用方拿到 KindTimeout 错误。
// 传 0 表示不限时——只在明确知道脚本可信时才这么做。
func WithTimeout(d time.Duration) Option {
return func(e *Engine) { e.timeout = d }
}
// WithMaxVMs 设置每个脚本最多缓存多少个 VM 实例。这是缓存上限不是并发上限:
// 并发超过它时会临时新建 VM,用完直接丢弃,不会阻塞调用。
func WithMaxVMs(n int) Option {
return func(e *Engine) { e.maxVMs = n }
}
// WithMaxCallStackSize 设置脚本的最大调用栈深度,传 0 用 goja 默认值。
func WithMaxCallStackSize(n int) Option {
return func(e *Engine) { e.maxStack = n }
}
// WithLogger 设置日志器,脚本里的 console.* 会打到这里,带上 script 字段。
// 传 nil 表示不注入 console。
func WithLogger(l *slog.Logger) Option {
return func(e *Engine) { e.logger = l }
}
// WithLoader 设置脚本源码的来源,Engine.Script 会用它按名字取脚本。
// 本库只定义 Loader 接口,具体从文件、数据库还是配置中心读由调用方实现。
//
// 可以给多个,它们叠成一层层的,**后面的盖前面的**——取脚本时从最后一层往前找,
// 谁先有就用谁的。把"定制层"放最后,业务侧放一份同名脚本就能改写默认实现:
//
// base, _ := esm.NewLoader("app/src")
// custom, _ := esm.NewLoader("custom/src") // 配置可以跟 base 完全不同
//
// e, err := jscriptx.New(jscriptx.WithLoader(base, custom)) // custom 盖 base
//
// 每层是独立的 Loader,各有各的配置(入口规则、目标版本、node_modules 位置、
// 扩展模块),来源也可以不同——一层来自磁盘目录,另一层来自数据库都行。
// 多次调用 WithLoader 会继续往后叠,效果跟一次传多个一样。
//
// 叠多层时各层的 Prepared 必须一致,否则 New 报错,原因见 Overlay。
func WithLoader(loaders ...Loader) Option {
return func(e *Engine) { e.loaderLayers = append(e.loaderLayers, loaders...) }
}
// WithAutoReload 打开后,每次 Engine.Script 都会问一次 Loader 拿版本号,
// 版本变了就重新编译并换掉旧的 VM 池——这是"改脚本不重启进程"的开关。
// 代价是每次取脚本都会调一次 Loader.Load,实现方自己保证这个调用足够轻。
func WithAutoReload(on bool) Option {
return func(e *Engine) { e.autoReload = on }
}
// WithBundleOptions 配置 Compile 打包源码时的行为,比如 WithResolveDir。
func WithBundleOptions(opts ...BundleOption) Option {
return func(e *Engine) { e.bundleOpts = append(e.bundleOpts, opts...) }
}