// Package jscriptx 用嵌入式 JS 引擎承载业务回调,让业务逻辑变更不必重新编译发布 Go 程序。 // // 脚本用 ESM + TypeScript 编写、按目录组织,Go 侧按路径把它们当普通对象实例化并调用方法。 // 底层是 goja(纯 Go 的 JS 引擎)加 esbuild(纯 Go 的打包器),依赖就这两个, // 公开 API 不暴露任何 goja 类型。 // // goja 的反射会自动包装 Go 对象,框架里的链式 API 在脚本里照原样写, // 不需要为每个方法写胶水代码: // // resource.GetDBTable(user, req.WithPermission(req.ResAll)) // .Where(db.C("name").Eq("测试产品")) // .Select("name", "cost_price") // // # 基本用法 // // loader, err := esm.NewLoader("app/src") // e, err := jscriptx.New( // jscriptx.WithLoader(loader), // jscriptx.WithAutoReload(true), // jscriptx.WithGlobals(myWhitelist), // jscriptx.WithTimeout(3*time.Second), // ) // // obj, err := e.New(ctx, "Resource/ResCreateController", "产品") // defer obj.Close() // got, err := obj.Call(ctx, "Init") // // # 为什么需要打包 // // goja 的 ES6+ 支持相当完整(class 含私有字段和静态块、async/await、generator、解构、 // 可选链、Proxy、BigInt 都能直接跑),但它没有 ES module——import/export 在 goja 的 // token 表里是保留字,解析阶段就报错;TypeScript 也不在它的职责范围。 // // esbuild 只补这两件事:把 import 内联掉、把 TypeScript 转译掉。交给 goja 的最终产物 // 是普通 JS 语法,不含任何模块系统的东西。打包只在加载和热更新时发生,不在调用路径上。 // // # 入口形态 // // 产物是自包含的立即执行函数,所以脚本必须有 export——没有导出的顶层代码会被当死代码摇掉。 // 入口就是模块的导出: // // export default class DeviceHandler { // 由本库实例化,构造参数从 Go 侧传 // constructor(deviceId) { this.count = 0 } // onMessage(payload) { return ++this.count } // } // // export default new DeviceHandler() // 直接用这个实例 // export default { onMessage(p) { ... } } // 对象当实例,按方法名调用 // export default function handle(x) { ... } // 单函数入口,用 DefaultFunc 调用 // export function Options() { ... } // 只有命名导出时,整个模块当实例 // // 拿到实例的几种形态都是按方法名调用,this 绑定到实例,继承来的方法也找得到。 // // # 两种执行方式 // // 区别只有一个——脚本实例活多久: // // 方式 取 VM 实例生命周期 脚本里的 this.xxx // Script.Call 从 VM 池借 = VM 生命周期 随时可能归零,只能当缓存 // Script.New → Instance 独占一个 VM 由你 Close 决定 跨调用保持 // // 需要状态就 New 一个实例、用完 Close;不需要就直接 Call 走池。 // // 本库不代管实例的生命周期——没有按 key 复用、没有空闲回收。要长期持有(比如按设备 ID // 存着),业务侧自己拿 map 存,跟 Go 版 controller 的写法一致。 // // 并发粒度:不同实例完全并行;同一个实例的多次调用串行——那是保住 this.xxx 必需的, // 跟 Go 侧用 sync.Mutex 保护 struct 字段是一回事。 // // # 作用域:让多个脚本共享数据 // // 作用域通过 ctx 传递,只携带一段业务流程里要共享的东西,不管任何生命周期: // // st := store.New() // ctx = jscriptx.WithScope(ctx, // jscriptx.ScopeExtensions(st), // jscriptx.ScopeGlobals(map[string]any{"user": u}), // ) // // obj1, err := cartCtrl.New(ctx) // 两个 controller // obj2, err := orderCtrl.New(ctx) // 同一个 ctx → 同一份 store // // 共享靠的是 Go 侧对象,不是共用 Runtime——共用 Runtime 会让作用域内所有脚本被迫串行, // 那才是真的并发瓶颈。现在各脚本各跑各的,只是手里的 store 指向同一个 Go 对象。 // // 扩展就是「给脚本添一个全局对象」,实例由你创建(见 Extension)。Go 侧和脚本读写的 // 天然是同一份,不用再取回来。本库不带内置扩展,扩展由调用方自己定义。 // // # 多返回值约定(脚本作者唯一需要额外理解的规则) // // Go 函数的多返回值到了 JS 侧会按下面的规则转换,这是 goja 的行为,本库沿用: // // - func() T → 脚本拿到裸值 // - func() (T, error) → error 为 nil 时拿到裸值 T;非 nil 时变成 JS 异常, // 脚本可以 try/catch,不 catch 就冒泡成本包的 KindRuntime 错误 // - func() (A, B) → 脚本拿到数组 [A, B],用下标取 // - func() (A, B, error) → error 为 nil 时拿到数组 [A, B];非 nil 时抛异常 // // 也就是说 error 永远不出现在返回值里,它只会变成异常。db 的 ToSQL() 是典型例子: // // var r = sd.ToSQL() // r[0] 是 SQL 字符串,r[1] 是参数数组 // // 另外 Go 的 nil 到脚本里是 null,不是 undefined。 // // # async 可以用,但没有事件循环 // // async 方法返回的 Promise 由本库自动解包,用起来跟同步方法一样。但 goja 没有事件循环, // 脚本里等不了真正的异步(定时器、网络、IO)——那种 Promise 永远 pending,会得到 // ErrPromisePending。异步的活交给 Go 侧做,脚本只写同步逻辑。 // // # 值不能跨出脚本边界 // // Call 的返回值和 CallInto 的目标都不允许是 JS 函数/闭包:那种值绑在 VM 上,跨出边界 // 就失效了,本库会直接拒绝(ErrValueEscape)。需要回调语义用 WithCall,它在 VM 借出 // 期间完成整个交互,见 Caller 和 ExampleCaller。 // // # 安全边界 // // 脚本能看见的东西,只有 WithGlobals 显式放行的那些,加上 JS 语言自带的内置对象 // (goja 不提供文件、网络、require,也没有 setTimeout)。 // // 注入时的拷贝**只对 map[string]any 成立**:那种值会逐层拷成只读 JS 对象,脚本改不动, // 各个 VM 拿到的也是各自的副本。放行的是结构体指针、slice 或别的 Go 对象时, // **各个 VM 共享的是同一个对象**,脚本通过它的方法改到的东西是真改了。要跨 VM 共享 // 可变状态就该这么用(扩展走的正是这条路),但别把它当成隔离保证。 // // 本库不预设放行哪些 API——那取决于你的框架。原则是只放行「不带数据库连接、不能自己 // 发起查询」的纯构造器和常量:放行构造列名/表名/字面量的那些,不放行能凭空造出查询 // 数据集的(绕过资源层)、能往 SQL 里塞裸片段的(可以挂子查询探测别的表)、 // 以及任何数据库连接对象。脚本要碰数据,由 Go 侧把已经过权限包装的资源对象当参数传进去。 // // # 失控脚本 // // 每次调用都带超时(WithTimeout,默认 5 秒)。超时或调用方 context 取消时,会从另一个 // goroutine 中断脚本执行,死循环也能断掉。被中断过或 panic 过的 VM 直接丢弃不回池, // 避免状态污染。 // // 脚本执行期间的 panic(脚本里的类型错误、注入进去的 Go 方法内部 panic)都会被 recover // 成 *Error 返回,不会掀翻调用方的 goroutine。 // // # 错误 // // 所有错误都是 *Error,带 Kind 分类、脚本名、函数名、脚本侧调用栈(行列号)和调用参数 // 摘要,并实现了 slog.LogValuer: // // if err != nil { // logger.Error("脚本执行失败", slog.Any("err", err)) // } // // 行号指向 .ts 源文件而不是打包产物——esbuild 输出 inline sourcemap,goja 自带 sourcemap 支持。 // // 也可以用 errors.Is 匹配 ErrTimeout、ErrFuncNotFound、ErrScriptNotFound、ErrValueEscape // 等哨兵错误。 package jscriptx