Files
jscriptx/esmwrap.go
T
what 0627d49425 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 在脚本里表现为抛异常,不占返回值位置。
  - 脚本能看见的全局只有白名单放行的那些,且注入是惰性的——没读到的
    全局根本不会被转换。
2026-09-05 22:11:55 +08:00

109 lines
3.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}