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
+104
View File
@@ -0,0 +1,104 @@
package jscriptx
// Loader 负责按名字提供脚本源码。脚本存在文件、数据库表还是配置中心,由实现方决定;
// 本库只定义这个接口,不预设来源。
//
// 给出的源码默认会由 Engine 交给 esbuild 打包(ESM/TypeScript 都在那一步抹平),
// 所以直接返回原始源码即可。已经自己打过包的实现(比如 jscriptx/esm 的 Loader
// 再实现 Prepared 接口,Engine 就会跳过这一步。
//
// version 用来判断脚本有没有变:Engine 拿它跟缓存里的版本比对,
// 一致就复用已编译的 Program 和 VM 池,不一致才重新编译。取值随实现方便,
// 比如文件的 mtime+size、数据库行的 updated_at、源码哈希都行;
// 返回空字符串表示"我不提供版本号",Engine 会退化成拿源码算哈希。
//
// 打开 WithAutoReload 后每次取脚本都会调一次 Load,实现方要保证这个调用足够轻
// (能只查版本就别每次全量读源码,或者自己加一层短 TTL 缓存)。
//
// 脚本不存在时,返回的错误要能被 errors.Is(err, ErrScriptNotFound) 匹配上,
// 调用方才好区分"脚本没配"和"加载出故障"。
//
// 实现必须并发安全。
type Loader interface {
Load(name string) (source string, version string, err error)
}
// LoaderFunc 让普通函数直接当 Loader 用:
//
// loader := jscriptx.LoaderFunc(func(name string) (string, string, error) {
// row, err := db.QueryScript(name)
// if err != nil {
// return "", "", err
// }
// return row.Source, row.UpdatedAt, nil
// })
type LoaderFunc func(name string) (source string, version string, err error)
func (f LoaderFunc) Load(name string) (string, string, error) { return f(name) }
// PreparedFunc 跟 LoaderFunc 一样是把函数当 Loader 用,区别是它声明"源码已经打好包了"
// (见 Prepared)。多层叠放时各层的 Prepared 必须一致,拿它就能让一个自定义来源
// 跟 jscriptx/esm 那样的层对齐:
//
// db := jscriptx.PreparedFunc(func(name string) (string, string, error) {
// row, err := db.QueryScript(name)
// if err != nil {
// return "", "", err
// }
// bundled, err := jscriptx.Bundle(name, row.Source) // 自己打包
// if err != nil {
// return "", "", err
// }
// return bundled, row.UpdatedAt, nil
// })
//
// e, _ := jscriptx.New(jscriptx.WithLoader(esmLoader, db))
//
// 名副其实是你自己的责任:说了打好包,交出去的就必须是打包产物,
// 否则脚本里的 import/export 会原样进 goja,直接编译失败。
type PreparedFunc func(name string) (source string, version string, err error)
func (f PreparedFunc) Load(name string) (string, string, error) { return f(name) }
// Prepared 恒为 true,见 PreparedFunc 的说明。
func (f PreparedFunc) Prepared() bool { return true }
var _ Prepared = PreparedFunc(nil)
// Prepared 由那些自己已经把源码打包好的 Loader 实现(比如 jscriptx/esm 的 Loader),
// Engine 见到它就跳过打包这一步。
//
// 这不是性能优化,是正确性要求:打包产物是 IIFE,里面已经没有 export 了,
// 再打包一遍会被 esbuild 当死代码整段摇空,脚本变成什么都不剩。
// 所以自己打过包的 Loader 必须实现它。
//
// Engine 靠类型断言识别,方法签名写错了不会有编译错误。自己实现时最好钉一行
//
// var _ jscriptx.Prepared = (*MyLoader)(nil)
type Prepared interface {
// Prepared 返回 true 表示 Load 给出的源码已经可以直接交给引擎编译。
Prepared() bool
}
func isPrepared(l Loader) bool {
p, ok := l.(Prepared)
return ok && p.Prepared()
}
// loadLocked 走 Loader 拿源码;cached 非空且版本一致时直接复用缓存。调用方必须持有 e.mu。
func (e *Engine) loadLocked(name string, cached *Script) (*Script, error) {
source, version, err := e.loader.Load(name)
if err != nil {
return nil, newError(KindLoad, name, "", err, "加载脚本源码失败")
}
if cached != nil && version != "" && cached.version == version {
return cached, nil
}
if version == "" {
version = hashVersion(source)
if cached != nil && cached.version == version {
return cached, nil
}
}
return e.compileLocked(name, source, version, isPrepared(e.loader))
}