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
+150
View File
@@ -0,0 +1,150 @@
// Package jscriptx 用嵌入式 JS 引擎承载业务回调,让业务逻辑变更不必重新编译发布 Go 程序。
//
// 脚本用 ESM + TypeScript 编写、按目录组织,Go 侧按路径把它们当普通对象实例化并调用方法。
// 底层是 goja(纯 Go 的 JS 引擎)加 esbuild(纯 Go 的打包器),依赖就这两个,
// 公开 API 不暴露任何 goja 类型。
//
// goja 的反射会自动包装 Go 对象,框架里的链式 API 在脚本里照原样写,
// 不需要为每个方法写胶水代码:
//
// resource.GetDBTable(user, req.WithPermission(req.ResAll))
// .Where(db.C("name").Eq("测试产品"))
// .Select("name", "cost_price")
//
// # 基本用法
//
// loader, err := esm.NewLoader("app/src")
// e, err := jscriptx.New(
// jscriptx.WithLoader(loader),
// jscriptx.WithAutoReload(true),
// jscriptx.WithGlobals(myWhitelist),
// jscriptx.WithTimeout(3*time.Second),
// )
//
// obj, err := e.New(ctx, "Resource/ResCreateController", "产品")
// defer obj.Close()
// got, err := obj.Call(ctx, "Init")
//
// # 为什么需要打包
//
// goja 的 ES6+ 支持相当完整(class 含私有字段和静态块、async/await、generator、解构、
// 可选链、Proxy、BigInt 都能直接跑),但它没有 ES module——import/export 在 goja 的
// token 表里是保留字,解析阶段就报错;TypeScript 也不在它的职责范围。
//
// esbuild 只补这两件事:把 import 内联掉、把 TypeScript 转译掉。交给 goja 的最终产物
// 是普通 JS 语法,不含任何模块系统的东西。打包只在加载和热更新时发生,不在调用路径上。
//
// # 入口形态
//
// 产物是自包含的立即执行函数,所以脚本必须有 export——没有导出的顶层代码会被当死代码摇掉。
// 入口就是模块的导出:
//
// export default class DeviceHandler { // 由本库实例化,构造参数从 Go 侧传
// constructor(deviceId) { this.count = 0 }
// onMessage(payload) { return ++this.count }
// }
//
// export default new DeviceHandler() // 直接用这个实例
// export default { onMessage(p) { ... } } // 对象当实例,按方法名调用
// export default function handle(x) { ... } // 单函数入口,用 DefaultFunc 调用
// export function Options() { ... } // 只有命名导出时,整个模块当实例
//
// 拿到实例的几种形态都是按方法名调用,this 绑定到实例,继承来的方法也找得到。
//
// # 两种执行方式
//
// 区别只有一个——脚本实例活多久:
//
// 方式 取 VM 实例生命周期 脚本里的 this.xxx
// Script.Call 从 VM 池借 = VM 生命周期 随时可能归零,只能当缓存
// Script.New → Instance 独占一个 VM 由你 Close 决定 跨调用保持
//
// 需要状态就 New 一个实例、用完 Close;不需要就直接 Call 走池。
//
// 本库不代管实例的生命周期——没有按 key 复用、没有空闲回收。要长期持有(比如按设备 ID
// 存着),业务侧自己拿 map 存,跟 Go 版 controller 的写法一致。
//
// 并发粒度:不同实例完全并行;同一个实例的多次调用串行——那是保住 this.xxx 必需的,
// 跟 Go 侧用 sync.Mutex 保护 struct 字段是一回事。
//
// # 作用域:让多个脚本共享数据
//
// 作用域通过 ctx 传递,只携带一段业务流程里要共享的东西,不管任何生命周期:
//
// st := store.New()
// ctx = jscriptx.WithScope(ctx,
// jscriptx.ScopeExtensions(st),
// jscriptx.ScopeGlobals(map[string]any{"user": u}),
// )
//
// obj1, err := cartCtrl.New(ctx) // 两个 controller
// obj2, err := orderCtrl.New(ctx) // 同一个 ctx → 同一份 store
//
// 共享靠的是 Go 侧对象,不是共用 Runtime——共用 Runtime 会让作用域内所有脚本被迫串行,
// 那才是真的并发瓶颈。现在各脚本各跑各的,只是手里的 store 指向同一个 Go 对象。
//
// 扩展就是「给脚本添一个全局对象」,实例由你创建(见 Extension)。Go 侧和脚本读写的
// 天然是同一份,不用再取回来。本库不带内置扩展,扩展由调用方自己定义。
//
// # 多返回值约定(脚本作者唯一需要额外理解的规则)
//
// Go 函数的多返回值到了 JS 侧会按下面的规则转换,这是 goja 的行为,本库沿用:
//
// - func() T → 脚本拿到裸值
// - func() (T, error) → error 为 nil 时拿到裸值 T;非 nil 时变成 JS 异常,
// 脚本可以 try/catch,不 catch 就冒泡成本包的 KindRuntime 错误
// - func() (A, B) → 脚本拿到数组 [A, B],用下标取
// - func() (A, B, error) → error 为 nil 时拿到数组 [A, B];非 nil 时抛异常
//
// 也就是说 error 永远不出现在返回值里,它只会变成异常。db 的 ToSQL() 是典型例子:
//
// var r = sd.ToSQL() // r[0] 是 SQL 字符串,r[1] 是参数数组
//
// 另外 Go 的 nil 到脚本里是 null,不是 undefined。
//
// # async 可以用,但没有事件循环
//
// async 方法返回的 Promise 由本库自动解包,用起来跟同步方法一样。但 goja 没有事件循环,
// 脚本里等不了真正的异步(定时器、网络、IO)——那种 Promise 永远 pending,会得到
// ErrPromisePending。异步的活交给 Go 侧做,脚本只写同步逻辑。
//
// # 值不能跨出脚本边界
//
// Call 的返回值和 CallInto 的目标都不允许是 JS 函数/闭包:那种值绑在 VM 上,跨出边界
// 就失效了,本库会直接拒绝(ErrValueEscape)。需要回调语义用 WithCall,它在 VM 借出
// 期间完成整个交互,见 Caller 和 ExampleCaller。
//
// # 安全边界
//
// 脚本能看见的东西,只有 WithGlobals 显式放行的那些,加上 JS 语言自带的内置对象
// goja 不提供文件、网络、require,也没有 setTimeout)。白名单对象注入时会逐层拷贝成
// 只读 JS 对象,脚本改不动,也不会跨 VM 共享同一个可变的 Go map。
//
// 本库不预设放行哪些 API——那取决于你的框架。原则是只放行「不带数据库连接、不能自己
// 发起查询」的纯构造器和常量:放行构造列名/表名/字面量的那些,不放行能凭空造出查询
// 数据集的(绕过资源层)、能往 SQL 里塞裸片段的(可以挂子查询探测别的表)、
// 以及任何数据库连接对象。脚本要碰数据,由 Go 侧把已经过权限包装的资源对象当参数传进去。
//
// # 失控脚本
//
// 每次调用都带超时(WithTimeout,默认 5 秒)。超时或调用方 context 取消时,会从另一个
// goroutine 中断脚本执行,死循环也能断掉。被中断过或 panic 过的 VM 直接丢弃不回池,
// 避免状态污染。
//
// 脚本执行期间的 panic(脚本里的类型错误、注入进去的 Go 方法内部 panic)都会被 recover
// 成 *Error 返回,不会掀翻调用方的 goroutine。
//
// # 错误
//
// 所有错误都是 *Error,带 Kind 分类、脚本名、函数名、脚本侧调用栈(行列号)和调用参数
// 摘要,并实现了 slog.LogValuer
//
// if err != nil {
// logger.Error("脚本执行失败", slog.Any("err", err))
// }
//
// 行号指向 .ts 源文件而不是打包产物——esbuild 输出 inline sourcemapgoja 自带 sourcemap 支持。
//
// 也可以用 errors.Is 匹配 ErrTimeout、ErrFuncNotFound、ErrScriptNotFound、ErrValueEscape
// 等哨兵错误。
package jscriptx