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:
+108
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user