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
+161
View File
@@ -0,0 +1,161 @@
package jscriptx
import (
"fmt"
"strings"
"github.com/evanw/esbuild/pkg/api"
)
// BundleOption 配置源码的打包方式。
type BundleOption func(*bundleOptions)
type bundleOptions struct {
resolveDir string
target api.Target
define map[string]string
nodePaths []string
alias map[string]string
}
// WithResolveDir 给源码一个解析 import 的基准目录。不设时源码里不能有 import
// (单段源码没有文件系统上下文,esbuild 解析不了相对路径)。
func WithResolveDir(dir string) BundleOption {
return func(o *bundleOptions) { o.resolveDir = dir }
}
// WithBundleTarget 设置输出的 ECMAScript 版本,默认 ES2017。
func WithBundleTarget(t api.Target) BundleOption {
return func(o *bundleOptions) { o.target = t }
}
// WithBundleDefine 设置编译期常量替换。
func WithBundleDefine(define map[string]string) BundleOption {
return func(o *bundleOptions) { o.define = define }
}
// WithNodePaths 指定额外的 node_modules 搜索目录,相当于 Node 的 NODE_PATH。
// 传进来的目录本身相当于一个 node_modules:包直接放在它下面,不要再套一层。
//
// 没有同时配 WithResolveDir 时,会拿当前工作目录当解析起点——esbuild 需要一个起点
// 才会启动模块解析,真正的查找仍然走这里给的目录。
func WithNodePaths(paths ...string) BundleOption {
return func(o *bundleOptions) { o.nodePaths = append(o.nodePaths, paths...) }
}
// WithAlias 把模块名映射到具体的文件或目录,绕过 node_modules 查找。
func WithAlias(alias map[string]string) BundleOption {
return func(o *bundleOptions) {
if o.alias == nil {
o.alias = map[string]string{}
}
for k, v := range alias {
o.alias[k] = v
}
}
}
// Bundle 把一段 ESM/TypeScript 源码打包成本库能直接执行的形式。
//
// goja 没有 ES module 支持,也不认识 TypeScript,所有脚本都要先过这一步。
// Engine.Compile 内部就是调它;自定义 Loader 如果返回的是原始源码,也用它处理。
//
// filename 是这段源码的文件名,有两个实际作用:**扩展名决定按什么语法解析**
// .ts/.mts 按 TypeScript.json 按 JSON,其余按 TypeScript——它是 JS 的超集,
// 普通 JS 照样能过),以及作为 sourcemap 和报错里显示的位置。
//
// 打包后的代码是自包含的 IIFE,模块导出挂在 ModuleGlobal 上,末尾补一句入口表达式。
// 因为产物是 IIFE**脚本必须有 export**——没有导出的顶层代码会被当成死代码摇掉。
func Bundle(filename, source string, opts ...BundleOption) (string, error) {
o := &bundleOptions{target: api.ES2017}
for _, opt := range opts {
opt(o)
}
if o.resolveDir == "" && len(o.nodePaths) > 0 {
// NodePaths 是"额外去哪儿找",esbuild 仍然需要一个起点才会启动解析流程,
// 没有起点时连 NodePaths 都不查。用当前工作目录当锚点,真正的查找还是走
// NodePaths。(Alias 是直接映射,不受这个限制。)
o.resolveDir = "."
}
res := api.Build(api.BuildOptions{
Stdin: &api.StdinOptions{
Contents: source,
Sourcefile: filename,
Loader: loaderFor(filename),
ResolveDir: o.resolveDir,
},
Bundle: true,
// 用 ESM 格式而不是 IIFEIIFE 会附带一整套 CommonJS interop helper
// 每建一个 VM 都要重跑一遍。拿到 ESM 产物后由 FinalizeBundle 自己包成
// 立即执行函数,见 esmwrap.go。
Format: api.FormatESModule,
Target: o.target,
Define: o.define,
NodePaths: o.nodePaths,
Alias: o.alias,
Write: false,
Sourcemap: api.SourceMapInline,
SourcesContent: api.SourcesContentExclude,
LogLevel: api.LogLevelSilent,
})
if len(res.Errors) > 0 {
return "", bundleError(filename, res.Errors, o.resolveDir)
}
if len(res.OutputFiles) == 0 {
return "", fmt.Errorf("打包 %s 没有产出", filename)
}
return FinalizeBundle(string(res.OutputFiles[0].Contents)), nil
}
// FinalizeBundle 把 esbuild 的 ESM 产物改写成本库能直接执行的形式:一个立即执行函数,
// 完成值就是脚本的导出(default 优先,只有命名导出时是整个模块对象)。
//
// 自定义 Loader 自己调 esbuild 打包时,产物也要过这一步,格式才对得上。
// 怎么改写、为什么不直接用 esbuild 的 IIFE 格式,见 esmwrap.go。
func FinalizeBundle(code string) string {
const marker = "//# sourceMappingURL="
i := strings.LastIndex(code, marker)
if i < 0 {
return wrapESM(code)
}
// sourcemap 注释留在最后,包装只作用于代码部分
return wrapESM(strings.TrimRight(code[:i], "\n")) + code[i:]
}
// loaderFor 按文件名的扩展名挑解析方式。
func loaderFor(filename string) api.Loader {
switch {
case strings.HasSuffix(filename, ".ts"), strings.HasSuffix(filename, ".mts"):
return api.LoaderTS
case strings.HasSuffix(filename, ".json"):
return api.LoaderJSON
default:
// 没有扩展名或是 .js 时按 TS 解析:TS 是 JS 的超集,普通 JS 照样能过,
// 顺便让不带扩展名的脚本名也能写类型注解。
return api.LoaderTS
}
}
// bundleError 把 esbuild 的报错整理成一条带位置和出路的错误。
func bundleError(filename string, errs []api.Message, resolveDir string) error {
var b strings.Builder
fmt.Fprintf(&b, "打包 %s 失败", filename)
for i, e := range errs {
if i >= 5 {
fmt.Fprintf(&b, "\n …还有 %d 条错误", len(errs)-i)
break
}
b.WriteString("\n ")
if loc := e.Location; loc != nil {
fmt.Fprintf(&b, "%s:%d:%d: ", loc.File, loc.Line, loc.Column)
}
b.WriteString(e.Text)
// 最常见的坑:单段源码里写了 import 却没有解析基准目录
if resolveDir == "" && strings.Contains(e.Text, "Could not resolve") {
b.WriteString("(这段源码没有解析 import 的基准目录:" +
"用 jscriptx/esm 子包按目录加载,或给 Compile 配 WithResolveDir")
}
}
return fmt.Errorf("%s", b.String())
}