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
+108
View File
@@ -0,0 +1,108 @@
package jscriptx
import (
"strings"
)
// esbuild 按 IIFE 格式输出时,会在产物里塞一整套 CommonJS interop helper
// __defProp / __export / __copyProps / __toCommonJS…)。那套东西是为了模拟
// __esModule 语义,本库根本用不上:我们只要拿到导出对象。
//
// 但它的代价是实打实的——每建一个 VM 都要重新创建那 7 个函数、再遍历一遍属性装
// getter。实测建一个实例 22μs / 472 allocs,而不带 helper 的等价产物只要 4μs / 119。
//
// 所以改成按 ESM 格式打包(import 照样内联),再自己把末尾那句 export 改写掉:
//
// // p.ts (() => {// p.ts
// var H = class {…}; ───► var H = class {…};
// export { return H;})()
// H as default
// };
//
// 改写有两条硬约束:
//
// - 开头的 (() => { 必须紧贴原第一行,不能另起一行,否则所有行号下移一位,
// sourcemap 就对不上了,报错定位不回 .ts 源码
// - export 语句在产物末尾,把它整段换掉不影响前面任何行
// wrapESM 把 esbuild 的 ESM 产物改写成 goja 能直接执行的形式:
// 一个立即执行函数,完成值就是脚本的导出。
//
// 认不出末尾的 export 语句时(脚本压根没有导出,或者 esbuild 换了输出格式),
// 返回的产物求值为 undefined——交给"脚本没有任何导出"那条报错去解释。
func wrapESM(code string) string {
body, entry, ok := splitESMExports(code)
if !ok {
// 没有导出:让它求值成 undefined,报错由 lookup 那边给
return "(() => {" + code + "\nreturn void 0;})()\n"
}
return "(() => {" + body + "return " + entry + ";})()\n"
}
// splitESMExports 从产物末尾切下 export 语句,返回前面的代码和入口表达式。
//
// esbuild 的 ESM 输出格式很规整,末尾总是这样:
//
// export {
// H as default,
// extra
// };
func splitESMExports(code string) (body, entry string, ok bool) {
i := strings.LastIndex(code, "\nexport {")
if i < 0 {
return "", "", false
}
j := strings.Index(code[i:], "\n};")
if j < 0 {
return "", "", false
}
body = code[:i+1]
tail := code[i+len("\nexport {") : i+j]
names := parseExportNames(tail)
if len(names) == 0 {
return "", "", false
}
// 有 default 就用它——这跟"脚本导出 class/函数/实例"的入口约定对得上;
// 只有命名导出时,把它们拼成一个对象,按方法名调用。
if local, has := names["default"]; has {
return body, local, true
}
var b strings.Builder
b.WriteByte('{')
first := true
for exported, local := range names {
if !first {
b.WriteByte(',')
}
first = false
b.WriteString(exported)
b.WriteByte(':')
b.WriteString(local)
}
b.WriteByte('}')
return body, b.String(), true
}
// parseExportNames 解析 export 语句体,返回 导出名 -> 本地名。
// 每项形如 "H as default" 或 "foo"(导出名跟本地名相同)。
func parseExportNames(tail string) map[string]string {
out := map[string]string{}
for _, item := range strings.Split(tail, ",") {
item = strings.TrimSpace(item)
if item == "" {
continue
}
local, exported := item, item
if k := strings.Index(item, " as "); k >= 0 {
local = strings.TrimSpace(item[:k])
exported = strings.TrimSpace(item[k+len(" as "):])
}
if !validIdent(local) || !validIdent(exported) {
return nil // 格式不认识,交给调用方走兜底
}
out[exported] = local
}
return out
}