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
+238
View File
@@ -0,0 +1,238 @@
package jscriptx
import (
"errors"
"fmt"
"sort"
"strconv"
"strings"
)
// VersionSep 分隔脚本名和版本标签。叠层时用 "脚本名@版本" 点名要哪一层的实现。
const VersionSep = "@"
// OverlayLoader 把若干个 Loader 叠成一个,**后面的盖前面的**。
// 由 Overlay 创建。
type OverlayLoader struct {
loaders []Loader
prepared bool // 所有成员一致,构造时已校验过
byVer map[string]int // 版本标签 -> 层号,贴了标签的层才在里面
}
// Versioned 由带版本标签的 Loader 实现(比如 esm.Loader 配 WithVersion)。
// 叠层时这个标签就是调用点用来点名的那个:Load("Foo/Bar@v1")。
//
// 没实现这个接口、或者标签是空串的层,只能通过"上层盖下层"取到,点不了名。
type Versioned interface {
// Version 返回这一层的版本标签。
Version() string
}
// Tag 给任意 Loader 贴一个版本标签,让它在叠层里能被点名。
// 自带标签的 Loader(比如 esm.NewLoader 配了 WithVersion)不用它。
//
// e, _ := jscriptx.New(jscriptx.WithLoader(
// disk,
// jscriptx.Tag("hotfix", dbLoader), // 之后可以 Load("Foo/Bar@hotfix")
// ))
func Tag(version string, l Loader) Loader {
return &taggedLoader{Loader: l, version: version}
}
type taggedLoader struct {
Loader
version string
}
func (t *taggedLoader) Version() string { return t.version }
// Prepared 透传底下那个 Loader 的取值,别让贴标签这件事改了它的性质。
func (t *taggedLoader) Prepared() bool { return isPrepared(t.Loader) }
func versionOf(l Loader) string {
v, ok := l.(Versioned)
if !ok {
return ""
}
return v.Version()
}
// Overlay 把多个 Loader 叠成一个:取脚本时从最后一个往前找,谁先有就用谁的。
// 排在后面的因此能覆盖前面的同名脚本——把"定制层"放最后,业务侧放一份同名脚本
// 就能改写默认实现,不用动被覆盖的那一份:
//
// base, _ := esm.NewLoader("app/src")
// custom, _ := esm.NewLoader("custom/src") // 配置可以跟 base 完全不同
//
// loader, err := jscriptx.Overlay(base, custom) // custom 盖 base
// e, _ := jscriptx.New(jscriptx.WithLoader(loader))
//
// 每个成员是独立的 Loader,各有各的配置(入口规则、目标版本、node_modules 位置、
// 扩展模块),来源也可以不同——一层来自磁盘目录,另一层来自数据库都行。
//
// 给某一层贴了版本标签(esm 的 WithVersion,或者 Tag),调用点就能点名要它:
//
// v1, _ := esm.NewLoader("app/v1/src", esm.WithVersion("v1"))
// v2, _ := esm.NewLoader("app/v2/src", esm.WithVersion("v2"))
//
// e.New(ctx, "Resource/ResCreateController") // 不点名:上层盖下层,拿到 v2
// e.New(ctx, "Resource/ResCreateController@v1") // 点名:只在 v1 那层找,不回落
//
// 点名是"只认这一层":那层没有这个脚本就直接报不存在,不会掉到别的层去——
// 不然点名要 v1 却跑了 v2 的实现,比报错难查得多。
//
// 成员的 Prepared 必须一致:要么都是自己打好包的(比如 jscriptx/esm 的 Loader),
// 要么都交出原始源码由引擎打包。混着来会返回错误,因为引擎只能对整个 Loader
// 做一次判断,没法分脚本区别对待。真要混,把原始源码那层用 LoaderFunc 包一下,
// 里面自己调 Bundle,它就跟其它层一样是"打好包的"了。
func Overlay(loaders ...Loader) (*OverlayLoader, error) {
if len(loaders) == 0 {
return nil, errors.New("jscriptx: Overlay 至少要给一个 Loader")
}
for i, l := range loaders {
if l == nil {
return nil, fmt.Errorf("jscriptx: Overlay 的第 %d 个 Loader 是 nil", i)
}
}
prepared := isPrepared(loaders[0])
for i, l := range loaders[1:] {
if isPrepared(l) != prepared {
return nil, fmt.Errorf(
"jscriptx: Overlay 的成员 Prepared 不一致(第 0 个是 %v,第 %d 个是 %v);"+
"要么都自己打包,要么都交出原始源码", prepared, i+1, !prepared)
}
}
byVer := map[string]int{}
for i, l := range loaders {
v := versionOf(l)
if v == "" {
continue
}
if strings.Contains(v, VersionSep) {
return nil, fmt.Errorf("jscriptx: 版本标签 %q 里不能有 %q", v, VersionSep)
}
if j, dup := byVer[v]; dup {
return nil, fmt.Errorf("jscriptx: 版本标签 %q 重了(第 %d 层和第 %d 层)", v, j, i)
}
byVer[v] = i
}
return &OverlayLoader{
loaders: append([]Loader(nil), loaders...),
prepared: prepared,
byVer: byVer,
}, nil
}
// Load 从最后一个成员往前找,返回第一个找到的脚本。
//
// 成员报"脚本不存在"就继续往前找;报别的错直接返回——加载出故障不该被
// 后面那层的结果悄悄盖掉。
func (o *OverlayLoader) Load(name string) (string, string, error) {
if bare, version, ok := splitVersion(name); ok {
return o.loadFrom(bare, version)
}
var notFound error
for i := len(o.loaders) - 1; i >= 0; i-- {
source, version, err := o.loaders[i].Load(name)
if err != nil {
if errors.Is(err, ErrScriptNotFound) {
notFound = err
continue
}
return "", "", err
}
// 版本号带上是第几层给的:覆盖层的脚本删掉后会落回下面那层,
// 两层的版本号万一撞上,不带层号就看不出脚本已经换了人。
if version != "" {
version = strconv.Itoa(i) + ":" + version
}
return source, version, nil
}
if notFound == nil {
notFound = ErrScriptNotFound
}
return "", "", fmt.Errorf("%w: %s%d 层都没有)", notFound, name, len(o.loaders))
}
// loadFrom 只在点名的那一层找,找不到就报不存在,不回落到别的层。
func (o *OverlayLoader) loadFrom(name, version string) (string, string, error) {
i, ok := o.byVer[version]
if !ok {
return "", "", fmt.Errorf("%w: %s(没有版本 %q 这一层,有的是 %v)",
ErrScriptNotFound, name, version, o.Versions())
}
source, ver, err := o.loaders[i].Load(name)
if err != nil {
return "", "", err
}
if ver != "" {
ver = strconv.Itoa(i) + ":" + ver
}
return source, ver, nil
}
// splitVersion 把 "Foo/Bar@v1" 拆成 "Foo/Bar" 和 "v1"。
// 用最后一个分隔符,脚本名里真带了 @ 也不会拆错。
func splitVersion(name string) (bare, version string, ok bool) {
i := strings.LastIndex(name, VersionSep)
if i <= 0 || i == len(name)-1 {
return name, "", false // 没有分隔符,或者两边空着
}
return name[:i], name[i+1:], true
}
// Versions 返回各层的版本标签,按叠放顺序,没贴标签的层跳过。
func (o *OverlayLoader) Versions() []string {
var out []string
for _, l := range o.loaders {
if v := versionOf(l); v != "" {
out = append(out, v)
}
}
return out
}
// Prepared 返回成员们一致的取值,见 Overlay 的说明。
func (o *OverlayLoader) Prepared() bool { return o.prepared }
// Loaders 返回成员,顺序即叠放顺序(后面的盖前面的)。
func (o *OverlayLoader) Loaders() []Loader { return append([]Loader(nil), o.loaders...) }
// Names 汇总所有成员的脚本名,去重后按字典序排列。
// 成员得有 Names() []string 方法才算得上,没有的(比如数据库来源)就跳过。
func (o *OverlayLoader) Names() []string {
seen := map[string]bool{}
var out []string
for _, l := range o.loaders {
lister, ok := l.(interface{ Names() []string })
if !ok {
continue
}
for _, n := range lister.Names() {
if !seen[n] {
seen[n] = true
out = append(out, n)
}
}
}
sort.Strings(out)
return out
}
// Rebuild 挨个让成员重建。成员没有 Rebuild() error 方法就跳过。
// 有成员失败时其余的照样会走一遍,返回的错误里带上所有失败。
func (o *OverlayLoader) Rebuild() error {
var errs []error
for i, l := range o.loaders {
r, ok := l.(interface{ Rebuild() error })
if !ok {
continue
}
if err := r.Rebuild(); err != nil {
errs = append(errs, fmt.Errorf("第 %d 层: %w", i, err))
}
}
return errors.Join(errs...)
}
var _ Prepared = (*OverlayLoader)(nil)