From 0627d49425cfb576fa014cdb9d255adf9d5c12ad Mon Sep 17 00:00:00 2001 From: what Date: Sat, 5 Sep 2026 22:11:55 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=B5=8C=E5=85=A5=E5=BC=8F=20JS=20?= =?UTF-8?q?=E8=84=9A=E6=9C=AC=E5=BC=95=E6=93=8E=E6=A0=B8=E5=BF=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用 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 在脚本里表现为抛异常,不占返回值位置。 - 脚本能看见的全局只有白名单放行的那些,且注入是惰性的——没读到的 全局根本不会被转换。 --- bundle.go | 161 +++++++++++++++++++++ caller.go | 140 ++++++++++++++++++ console.go | 41 ++++++ doc.go | 150 +++++++++++++++++++ engine.go | 355 +++++++++++++++++++++++++++++++++++++++++++++ engine_option.go | 86 +++++++++++ errors.go | 367 +++++++++++++++++++++++++++++++++++++++++++++++ esmwrap.go | 108 ++++++++++++++ extension.go | 48 +++++++ go.mod | 16 +++ go.sum | 19 +++ instance.go | 174 ++++++++++++++++++++++ invoke.go | 326 +++++++++++++++++++++++++++++++++++++++++ loader.go | 104 ++++++++++++++ overlay.go | 238 ++++++++++++++++++++++++++++++ scope.go | 159 ++++++++++++++++++++ script.go | 327 +++++++++++++++++++++++++++++++++++++++++ static.go | 86 +++++++++++ 18 files changed, 2905 insertions(+) create mode 100644 bundle.go create mode 100644 caller.go create mode 100644 console.go create mode 100644 doc.go create mode 100644 engine.go create mode 100644 engine_option.go create mode 100644 errors.go create mode 100644 esmwrap.go create mode 100644 extension.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 instance.go create mode 100644 invoke.go create mode 100644 loader.go create mode 100644 overlay.go create mode 100644 scope.go create mode 100644 script.go create mode 100644 static.go diff --git a/bundle.go b/bundle.go new file mode 100644 index 0000000..504aa4d --- /dev/null +++ b/bundle.go @@ -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 格式而不是 IIFE:IIFE 会附带一整套 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()) +} diff --git a/caller.go b/caller.go new file mode 100644 index 0000000..a0e510f --- /dev/null +++ b/caller.go @@ -0,0 +1,140 @@ +package jscriptx + +import ( + "context" + + "github.com/dop251/goja" +) + +// Caller 是在 VM 借出期间对脚本函数的操作入口。 +// +// 它存在的理由是:有些调用模式没法用 Call/CallInto 表达——典型的是「回调 + next」, +// 需要先看脚本函数声明了几个形参,再决定怎么调它、把哪个 Go 闭包传进去。这些都得在 +// 同一次 VM 借出期间完成,因为传给脚本的 Go 闭包只在那段时间里有效。 +// +// 拿它写一个自定义的调用约定: +// +// err := target.WithCall(ctx, "handle", func(c jscriptx.Caller) error { +// if c.Arity() == 2 { +// res, err := c.Call(model, next) // next 是 Go 闭包,脚本能直接调 +// … +// } +// return nil +// }) +// +// 框架自己的回调约定(比如 orm 那套三种签名)就是这么实现的,见 jscriptx/dispatch。 +// +// Caller 只在 do 回调执行期间有效,别存下来跨调用用。 +type Caller interface { + // Arity 返回脚本函数声明的形参个数(JS 函数的 length 属性)。 + // 靠它判断脚本写的是哪种形状,脚本就不用额外声明签名。 + Arity() int + + // Call 调用脚本函数。参数按 goja 的规则转换:Go 对象反射包装成脚本对象, + // Go 函数变成脚本能直接调的函数——「把 next 传给脚本」就是这么实现的。 + Call(args ...any) (Result, error) + + // Script 返回脚本名,拼错误信息时用得上。 + Script() string +} + +// Result 是脚本函数一次调用的返回值。 +// +// 它只在下一次 Call 之前有效——同一个 Caller 的多次调用复用同一个对象, +// 要留着以后用就先 Value() 或 Into() 取出来。 +type Result interface { + // IsEmpty 判断脚本有没有返回东西(undefined 或 null)。 + // 「1 个形参、没有返回值」这种纯副作用的写法靠它识别。 + IsEmpty() bool + + // Value 返回导出成 Go 值的结果。空返回值时是 nil。 + Value() any + + // Into 把返回值转换进 out 指向的变量(out 必须是非 nil 指针), + // 目标是接口时要求返回值实现它。 + Into(out any) error +} + +// WithCall 借一个 VM,在借出期间把控制权交给 do。 +// +// 超时中断、panic 恢复、错误分类、VM 归还这些都跟普通调用一样,do 里只管发起调用。 +// do 返回的错误会被包成 *Error;想让调用方 errors.Is 得到,wrap 一个哨兵错误进去, +// 比如 ErrUnsupportedSignature。 +func (s *Script) WithCall(ctx context.Context, fn string, do func(Caller) error) error { + return withCaller(ctx, s, fn, do) +} + +// WithCall 同 Script.WithCall,只是在这个实例独占的 VM 上执行。 +func (i *Instance) WithCall(ctx context.Context, fn string, do func(Caller) error) error { + return withCaller(ctx, i, fn, do) +} + +func withCaller(ctx context.Context, r runner, fn string, do func(Caller) error) error { + return invoke(ctx, r, fn, nil, func(f *frame) error { + return do(&caller{f: f}) + }) +} + +type caller struct { + f *frame + // 复用同一个 result:一次 WithCall 里可能调好几次脚本函数, + // 每次都分配一个返回值对象不划算。所以 Result 只在下一次 Call 之前有效。 + res result +} + +func (c *caller) Arity() int { return int(c.f.arity()) } +func (c *caller) Script() string { return c.f.script.name } + +func (c *caller) Call(args ...any) (Result, error) { + v, err := c.f.callWith(args) + if err != nil { + return nil, err + } + c.res = result{f: c.f, v: v} + return &c.res, nil +} + +type result struct { + f *frame + v goja.Value +} + +func (r *result) IsEmpty() bool { return empty(r.v) } + +func (r *result) Value() any { + if empty(r.v) { + return nil + } + return r.v.Export() +} + +func (r *result) Into(out any) error { + if empty(r.v) { + return nil + } + if err := r.f.export(r.v, out); err != nil { + return newError(KindType, r.f.script.name, r.f.name, err, + "返回值无法转换成 %T(拿到的是 %T)", out, r.v.Export()) + } + return nil +} + +// Target 是能发起脚本调用的对象:*Script(从 VM 池借用,脚本无跨调用状态) +// 或 *Instance(独占一个 VM,脚本里的状态跨调用保持)。 +// +// 这个接口不对外开放实现,只是让 dispatch 这类函数能同时接受两者。 +type Target interface { + // Name 返回脚本名。 + Name() string + // Has 判断脚本里有没有这个函数。 + Has(fn string) bool + // Call 调用脚本函数,返回值导出成 Go 值。 + Call(ctx context.Context, fn string, args ...any) (any, error) + // CallInto 调用脚本函数,并把返回值转换进 out 指向的变量。 + CallInto(ctx context.Context, fn string, out any, args ...any) error + // WithCall 借一个 VM,在借出期间把控制权交给 do,用来实现自定义的调用约定。 + WithCall(ctx context.Context, fn string, do func(Caller) error) error + + // 未导出方法,接口不对外开放实现。 + owner() *Script +} diff --git a/console.go b/console.go new file mode 100644 index 0000000..13345f2 --- /dev/null +++ b/console.go @@ -0,0 +1,41 @@ +package jscriptx + +import ( + "context" + "fmt" + "log/slog" + "strings" +) + +// newConsole 造一个绑定到指定脚本的 console 对象,脚本里的 console.log 等 +// 直接落到结构化日志里,带上 script 字段,方便按脚本名检索。 +func newConsole(logger *slog.Logger, script string) map[string]any { + at := func(level slog.Level) func(args ...any) { + return func(args ...any) { + if !logger.Enabled(context.Background(), level) { + return + } + logger.LogAttrs(context.Background(), level, joinArgs(args), + slog.String("script", script), slog.String("source", "console")) + } + } + return map[string]any{ + "log": at(slog.LevelInfo), + "info": at(slog.LevelInfo), + "debug": at(slog.LevelDebug), + "warn": at(slog.LevelWarn), + "error": at(slog.LevelError), + } +} + +// joinArgs 按 console 的习惯用空格拼接参数。 +func joinArgs(args []any) string { + if len(args) == 0 { + return "" + } + parts := make([]string, 0, len(args)) + for _, a := range args { + parts = append(parts, fmt.Sprintf("%v", a)) + } + return strings.Join(parts, " ") +} diff --git a/doc.go b/doc.go new file mode 100644 index 0000000..509d70b --- /dev/null +++ b/doc.go @@ -0,0 +1,150 @@ +// 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)。白名单对象注入时会逐层拷贝成 +// 只读 JS 对象,脚本改不动,也不会跨 VM 共享同一个可变的 Go map。 +// +// 本库不预设放行哪些 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 diff --git a/engine.go b/engine.go new file mode 100644 index 0000000..3e81ce1 --- /dev/null +++ b/engine.go @@ -0,0 +1,355 @@ +package jscriptx + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "log/slog" + "runtime" + "sync" + "time" + + "github.com/dop251/goja" +) + +const ( + // DefaultTimeout 是单次脚本调用的默认时限,超过就中断脚本。 + DefaultTimeout = 5 * time.Second + // DefaultMaxCallStackSize 限制脚本的调用栈深度,防止递归打爆 Go 栈。 + DefaultMaxCallStackSize = 2000 +) + +// Engine 是脚本引擎,持有全局白名单、执行策略和脚本缓存。 +// 一个进程通常只需要一个 Engine,它本身并发安全。 +type Engine struct { + globals map[string]any + timeout time.Duration + maxVMs int + maxStack int + logger *slog.Logger + + loader Loader + autoReload bool + + // WithLoader 收下的层,New 里合成 loader;Option 没有出错的地方, + // 校验只能推迟到那时候。 + loaderLayers []Loader + + bundleOpts []BundleOption + + mu sync.Mutex + scripts map[string]*Script + closed bool +} + +// New 创建引擎。全局白名单里的名字不合法时返回错误。 +func New(opts ...Option) (*Engine, error) { + e := &Engine{ + globals: map[string]any{}, + timeout: DefaultTimeout, + maxVMs: runtime.GOMAXPROCS(0) * 2, + maxStack: DefaultMaxCallStackSize, + logger: slog.Default(), + scripts: map[string]*Script{}, + } + for _, opt := range opts { + opt(e) + } + if e.maxVMs < 1 { + e.maxVMs = 1 + } + if err := e.resolveLoader(); err != nil { + return nil, err + } + for name := range e.globals { + if !validIdent(name) { + return nil, fmt.Errorf("%w: 全局名 %q 不是合法的 JS 标识符", ErrBadGlobal, name) + } + } + return e, nil +} + +// resolveLoader 把 WithLoader 收下的层合成一个 Loader。 +func (e *Engine) resolveLoader() error { + switch len(e.loaderLayers) { + case 0: + return nil + case 1: + e.loader = e.loaderLayers[0] + if e.loader == nil { + return errors.New("jscriptx: WithLoader 收到 nil") + } + default: + o, err := Overlay(e.loaderLayers...) + if err != nil { + return err + } + e.loader = o + } + e.loaderLayers = nil + return nil +} + +// Compile 用一段 ESM/TypeScript 源码注册脚本:先经 esbuild 打包,再交给 goja 编译, +// 结果进缓存,之后 Script(name) 能取到。同名脚本会被替换,旧的 VM 池随即释放 +// (已经借出去的调用不受影响)。 +// +// 源码必须有 export——产物是 IIFE,没有导出的顶层代码会被当死代码摇掉。 +// 源码里要写 import 的话,得用 jscriptx/esm 子包按目录加载,或者配 WithResolveDir +// 给一个解析基准目录。 +func (e *Engine) Compile(name, source string) (*Script, error) { + e.mu.Lock() + defer e.mu.Unlock() + if e.closed { + return nil, newError(KindClosed, name, "", ErrClosed, "引擎已关闭") + } + return e.compileLocked(name, source, hashVersion(source), false) +} + +// Script 按名字取脚本:命中缓存直接返回;没命中就走 Loader 加载并编译。 +// 打开了 WithAutoReload 时,每次都会跟 Loader 核对版本号,变了就重编译。 +func (e *Engine) Script(name string) (*Script, error) { + e.mu.Lock() + defer e.mu.Unlock() + if e.closed { + return nil, newError(KindClosed, name, "", ErrClosed, "引擎已关闭") + } + + cached, ok := e.scripts[name] + if ok && !e.autoReload { + return cached, nil + } + if e.loader == nil { + if ok { + return cached, nil + } + return nil, newError(KindNotFound, name, "", ErrScriptNotFound, + "没有配置 Loader,也没有通过 Compile 注册过这个脚本") + } + return e.loadLocked(name, cached) +} + +// Reload 强制重新从 Loader 加载并编译,不管版本号有没有变。 +func (e *Engine) Reload(name string) (*Script, error) { + e.mu.Lock() + defer e.mu.Unlock() + if e.closed { + return nil, newError(KindClosed, name, "", ErrClosed, "引擎已关闭") + } + if e.loader == nil { + return nil, newError(KindLoad, name, "", ErrScriptNotFound, "没有配置 Loader,无法重新加载") + } + return e.loadLocked(name, nil) +} + +// Invalidate 把脚本从缓存里剔除并释放它的 VM 池,下次 Script 会重新加载。 +func (e *Engine) Invalidate(name string) { + e.mu.Lock() + defer e.mu.Unlock() + if s, ok := e.scripts[name]; ok { + delete(e.scripts, name) + s.Close() + } +} + +// Names 返回当前缓存里的脚本名。 +func (e *Engine) Names() []string { + e.mu.Lock() + defer e.mu.Unlock() + out := make([]string, 0, len(e.scripts)) + for name := range e.scripts { + out = append(out, name) + } + return out +} + +// Close 关闭引擎,释放所有脚本的 VM 池。之后再取脚本会报 ErrClosed。 +func (e *Engine) Close() { + e.mu.Lock() + defer e.mu.Unlock() + e.closed = true + for name, s := range e.scripts { + s.Close() + delete(e.scripts, name) + } +} + +// compileLocked 编译并替换缓存里的同名脚本。调用方必须持有 e.mu。 +// +// prepared 为 true 表示源码已经过打包(Loader 自己做过了),跳过这一步—— +// 对已经是 IIFE 的产物再打包一次是纯浪费。 +func (e *Engine) compileLocked(name, source, version string, prepared bool) (*Script, error) { + if !prepared { + // 脚本名同时当文件名传给打包器:扩展名(.ts/.json)决定按什么语法解析, + // 也是 sourcemap 和报错里显示的位置。 + bundled, err := Bundle(name, source, e.bundleOpts...) + if err != nil { + return nil, newError(KindCompile, name, "", err, "脚本打包失败") + } + source = bundled + } + + // 传 false = 不强制严格模式。 + // + // 注意产物里**没有** "use strict":esbuild 输出 ESM 格式时不加这个指令, + // wrapESM 包成 IIFE 时也没加。所以脚本跑在非严格模式下,后果之一是给只读 + // 全局赋值会**静默失败**而不是抛错(见 lazyglobal_test.go 的断言)。 + // + // 想改成严格模式就把这里传 true,但那是行为变更:脚本里任何依赖非严格语义的 + // 写法(给未声明变量赋值、with、八进制字面量……)都会开始报错。 + prog, err := goja.Compile(name, source, false) + if err != nil { + return nil, newError(KindCompile, name, "", err, "脚本编译失败") + } + + s := &Script{ + engine: e, + name: name, + version: version, + prog: prog, + pool: make(chan *vmHandle, e.maxVMs), + } + if old, ok := e.scripts[name]; ok { + old.Close() + } + e.scripts[name] = s + return s, nil +} + +// bind 把白名单全局对象注入到一个新建的 VM 里。 +// extra 是这个 VM 专属的额外全局(作用域带来的扩展),可以为 nil; +// 它跟白名单同样按只读注入,同名时以 extra 为准。 +// +// 注入是**惰性**的:这里只装一个 getter,脚本第一次读到那个名字才把值转成 +// JS 对象。一个脚本通常只用得上少数几个扩展,而 freeze 要把每个方法都包装成 +// JS 函数——用不到的那些不该在每次建 VM 时都付一遍这个成本。见 lazyGlobal。 +func (e *Engine) bind(rt *goja.Runtime, script string, extra map[string]any) error { + global := rt.GlobalObject() + for name, val := range e.globals { + if _, overridden := extra[name]; overridden { + continue + } + if err := e.lazyGlobal(rt, global, name, val); err != nil { + return newError(KindBind, script, "", err, "注入全局对象 %q 失败", name) + } + } + for name, val := range extra { + if err := e.lazyGlobal(rt, global, name, val); err != nil { + return newError(KindBind, script, "", err, "注入会话全局对象 %q 失败", name) + } + } + if e.logger != nil { + _, taken := e.globals["console"] + if _, t2 := extra["console"]; t2 { + taken = true + } + if !taken { + if err := e.lazyGlobal(rt, global, "console", newConsole(e.logger, script)); err != nil { + return newError(KindBind, script, "", err, "注入 console 失败") + } + } + } + return nil +} + +// lazyGlobal 装一个惰性只读全局:脚本第一次读它才 freeze,之后复用。 +// +// 为什么惰性:freeze 要把 map 里每个方法都包装成 JS 函数对象,而一个脚本通常只用 +// 得上少数几个扩展。急切注入的话,每建一个 VM 都要为**所有**扩展付这份成本—— +// 实测这是脚本层剩余开销里最大的一块。 +// +// 缓存放在闭包里,不加锁:getter 只在脚本执行期间被调用,而那时这个 VM 是被独占的 +// (实例持着自己的锁,池化的 VM 同时只有一个借用者)。goja.Value 也跨不了 Runtime, +// 所以这份缓存天然是每 VM 一份。 +// +// 只给 getter 不给 setter,效果等同原来的 writable=false:脚本赋值时没有 setter 可调。 +// 产物跑在非严格模式下(见 compileLocked 那里的说明),所以赋值是**静默失败**—— +// 不抛错,值也不变。configurable 同样保持 false,删不掉也重定义不了。 +// +// 代价是 freeze 的错误从"建 VM 时返回 Go 错误"变成"脚本读它时抛 JS 异常"。 +// freeze 只在属性名不合法时才会失败,而 New() 里的 validIdent 已经挡过一道, +// 实际碰不到。 +func (e *Engine) lazyGlobal(rt *goja.Runtime, global *goja.Object, name string, val any) error { + var ( + cached goja.Value + failed error + ) + getter := rt.ToValue(func(goja.FunctionCall) goja.Value { + if cached == nil && failed == nil { + cached, failed = e.freeze(rt, val) + } + if failed != nil { + panic(rt.NewGoError(failed)) + } + return cached + }) + return global.DefineAccessorProperty(name, getter, nil, goja.FLAG_FALSE, goja.FLAG_TRUE) +} + +// freeze 把 map[string]any 递归转成只读的 JS 对象,其他值原样交给 goja 包装。 +// +// 直接 rt.Set(name, someMap) 会把同一个 Go map 暴露给每个 VM:脚本一句 +// db.C = null 既能污染别的 VM,又是实打实的数据竞争。这里每个 VM 都拿到 +// 自己的一份不可写、不可重定义的对象。 +func (e *Engine) freeze(rt *goja.Runtime, val any) (goja.Value, error) { + m, ok := val.(map[string]any) + if !ok { + return rt.ToValue(val), nil + } + obj := rt.NewObject() + for k, v := range m { + child, err := e.freeze(rt, v) + if err != nil { + return nil, err + } + if err := defineReadOnly(obj, k, child); err != nil { + return nil, err + } + } + return obj, nil +} + +func defineReadOnly(obj *goja.Object, name string, v goja.Value) error { + return obj.DefineDataProperty(name, v, goja.FLAG_FALSE, goja.FLAG_FALSE, goja.FLAG_TRUE) +} + +// validIdent 校验全局名是不是合法的 JS 标识符(只允许 ASCII 字母、数字、_ 和 $)。 +func validIdent(s string) bool { + if s == "" { + return false + } + for i, r := range s { + switch { + case r == '_' || r == '$': + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z': + case r >= '0' && r <= '9': + if i == 0 { + return false + } + default: + return false + } + } + return true +} + +func hashVersion(source string) string { + sum := sha256.Sum256([]byte(source)) + return hex.EncodeToString(sum[:8]) +} + +// New 是 Script(name) + Script.New(ctx, args...) 的快捷方式:按名字取脚本, +// 实例化它导出的 class,构造参数直接传给 constructor。 +// +// ctrl, err := e.New(ctx, "PkgVersion/PkgImportController") +// defer ctrl.Close() +// got, err := ctrl.Call(ctx, "Init") +func (e *Engine) New(ctx context.Context, name string, ctorArgs ...any) (*Instance, error) { + s, err := e.Script(name) + if err != nil { + return nil, err + } + return s.New(ctx, ctorArgs...) +} diff --git a/engine_option.go b/engine_option.go new file mode 100644 index 0000000..6272709 --- /dev/null +++ b/engine_option.go @@ -0,0 +1,86 @@ +package jscriptx + +import ( + "log/slog" + "time" +) + +// 引擎的配置项都在这个文件里,一处看全 New 能配什么。 +// +// 另有两组独立的选项:BundleOption(打包源码,见 bundle.go)由 WithBundleOptions +// 带进来;ScopeOption(每次调用的作用域,见 scope.go)跟着 ctx 走,不属于引擎配置。 + +// Option 是 New 的配置项。 +type Option func(*Engine) + +// WithGlobals 追加暴露给脚本的全局对象白名单。可以多次调用,同名后者覆盖前者。 +// +// value 为 map[string]any 时会被注入成一个只读的 JS 对象(逐层递归), +// 脚本改不动它,多个 VM 之间也不会共享同一个可变的 Go map。 +func WithGlobals(globals map[string]any) Option { + return func(e *Engine) { + for k, v := range globals { + e.globals[k] = v + } + } +} + +// WithGlobal 暴露单个全局对象。 +func WithGlobal(name string, value any) Option { + return func(e *Engine) { e.globals[name] = value } +} + +// WithTimeout 设置单次调用的时限,超时后脚本会被强制中断,调用方拿到 KindTimeout 错误。 +// 传 0 表示不限时——只在明确知道脚本可信时才这么做。 +func WithTimeout(d time.Duration) Option { + return func(e *Engine) { e.timeout = d } +} + +// WithMaxVMs 设置每个脚本最多缓存多少个 VM 实例。这是缓存上限不是并发上限: +// 并发超过它时会临时新建 VM,用完直接丢弃,不会阻塞调用。 +func WithMaxVMs(n int) Option { + return func(e *Engine) { e.maxVMs = n } +} + +// WithMaxCallStackSize 设置脚本的最大调用栈深度,传 0 用 goja 默认值。 +func WithMaxCallStackSize(n int) Option { + return func(e *Engine) { e.maxStack = n } +} + +// WithLogger 设置日志器,脚本里的 console.* 会打到这里,带上 script 字段。 +// 传 nil 表示不注入 console。 +func WithLogger(l *slog.Logger) Option { + return func(e *Engine) { e.logger = l } +} + +// WithLoader 设置脚本源码的来源,Engine.Script 会用它按名字取脚本。 +// 本库只定义 Loader 接口,具体从文件、数据库还是配置中心读由调用方实现。 +// +// 可以给多个,它们叠成一层层的,**后面的盖前面的**——取脚本时从最后一层往前找, +// 谁先有就用谁的。把"定制层"放最后,业务侧放一份同名脚本就能改写默认实现: +// +// base, _ := esm.NewLoader("app/src") +// custom, _ := esm.NewLoader("custom/src") // 配置可以跟 base 完全不同 +// +// e, err := jscriptx.New(jscriptx.WithLoader(base, custom)) // custom 盖 base +// +// 每层是独立的 Loader,各有各的配置(入口规则、目标版本、node_modules 位置、 +// 扩展模块),来源也可以不同——一层来自磁盘目录,另一层来自数据库都行。 +// 多次调用 WithLoader 会继续往后叠,效果跟一次传多个一样。 +// +// 叠多层时各层的 Prepared 必须一致,否则 New 报错,原因见 Overlay。 +func WithLoader(loaders ...Loader) Option { + return func(e *Engine) { e.loaderLayers = append(e.loaderLayers, loaders...) } +} + +// WithAutoReload 打开后,每次 Engine.Script 都会问一次 Loader 拿版本号, +// 版本变了就重新编译并换掉旧的 VM 池——这是"改脚本不重启进程"的开关。 +// 代价是每次取脚本都会调一次 Loader.Load,实现方自己保证这个调用足够轻。 +func WithAutoReload(on bool) Option { + return func(e *Engine) { e.autoReload = on } +} + +// WithBundleOptions 配置 Compile 打包源码时的行为,比如 WithResolveDir。 +func WithBundleOptions(opts ...BundleOption) Option { + return func(e *Engine) { e.bundleOpts = append(e.bundleOpts, opts...) } +} diff --git a/errors.go b/errors.go new file mode 100644 index 0000000..a498e49 --- /dev/null +++ b/errors.go @@ -0,0 +1,367 @@ +package jscriptx + +import ( + "context" + "errors" + "fmt" + "log/slog" + "strconv" + "strings" + + "github.com/dop251/goja" +) + +// 哨兵错误,配合 errors.Is 使用。 +var ( + // ErrScriptNotFound 找不到脚本(Loader 没有这个名字,也没被 Compile 注册过)。 + ErrScriptNotFound = errors.New("jscriptx: 脚本不存在") + // ErrFuncNotFound 脚本里找不到要调用的函数,或者那个名字不是函数。 + ErrFuncNotFound = errors.New("jscriptx: 脚本里没有这个函数") + // ErrTimeout 脚本执行超过配置时限,已被强制中断。 + ErrTimeout = errors.New("jscriptx: 脚本执行超时") + // ErrInterrupted 脚本被中断,但拿不到更具体的原因。 + ErrInterrupted = errors.New("jscriptx: 脚本被中断") + // ErrClosed 脚本或引擎已经关闭。 + ErrClosed = errors.New("jscriptx: 已关闭") + // ErrUnsupportedSignature 回调函数的形参个数不在 Dispatch 支持的范围内。 + ErrUnsupportedSignature = errors.New("jscriptx: 不支持的回调签名") + // ErrValueEscape 脚本试图把只在 VM 内部有效的值(函数/闭包)传到 Go 侧。 + ErrValueEscape = errors.New("jscriptx: 该值不能跨出脚本边界") + // ErrBadGlobal 全局白名单配置不合法。 + ErrBadGlobal = errors.New("jscriptx: 全局白名单配置不合法") + // ErrPromiseRejected async 方法返回的 Promise 被 reject。 + ErrPromiseRejected = errors.New("jscriptx: Promise 被 reject") + // ErrPromisePending async 方法返回的 Promise 一直没完成(goja 没有事件循环)。 + ErrPromisePending = errors.New("jscriptx: Promise 未完成") +) + +// Kind 是错误分类,用于结构化日志过滤和调用方分流处理。 +type Kind string + +const ( + KindLoad Kind = "load" // 加载脚本源码失败 + KindCompile Kind = "compile" // 编译(语法解析)失败 + KindBind Kind = "bind" // 注入全局白名单失败 + KindNotFound Kind = "not_found" // 脚本或函数不存在 + KindRuntime Kind = "runtime" // 脚本运行期抛出异常 + KindTimeout Kind = "timeout" // 超时被中断 + KindCanceled Kind = "canceled" // 调用方 context 被取消 + KindPanic Kind = "panic" // Go 侧 panic,已兜住转成 error + KindType Kind = "type" // 返回值/参数类型不匹配 + KindSignature Kind = "signature" // 回调签名不受支持 + KindClosed Kind = "closed" // 脚本已关闭 +) + +// Frame 是脚本调用栈的一帧。从 goja 的栈信息里摘出来重新包装, +// 避免把 goja 类型暴露到本库的公开 API 上。 +type Frame struct { + Source string // 脚本名 + Func string // 函数名,匿名函数为空 + Line int + Column int +} + +func (f Frame) String() string { + name := f.Func + if name == "" { + name = "" + } + return fmt.Sprintf("%s (%s:%d:%d)", name, f.Source, f.Line, f.Column) +} + +// Error 是本库对外抛出的统一错误类型,带齐排查现场需要的上下文: +// 哪个脚本、哪个函数、脚本里哪一行、当时的调用参数是什么。 +// 它实现了 slog.LogValuer,slog.Any("err", err) 就能摊平成结构化字段。 +type Error struct { + Kind Kind // 错误分类 + Script string // 脚本名 + Func string // 被调用的函数名,可能为空 + Msg string // 人类可读的说明 + Args []string // 调用参数摘要(已截断,只用于排查,不保证可反序列化) + Stack []Frame // 脚本侧调用栈,可能为空 + Value any // 脚本 throw 出来的原始值(已导出成 Go 值) + GoStack string // Go 侧调用栈,仅 KindPanic 时填充 + Cause error // 底层错误,errors.Is/As 沿这条链走 +} + +func (e *Error) Error() string { + var b strings.Builder + b.WriteString("jscriptx: 脚本 ") + b.WriteString(strconv.Quote(e.Script)) + if e.Func != "" { + b.WriteString(" 函数 ") + b.WriteString(strconv.Quote(e.Func)) + } + b.WriteString(" [") + b.WriteString(string(e.Kind)) + b.WriteString("]") + if e.Msg != "" { + b.WriteString(": ") + b.WriteString(e.Msg) + } + if len(e.Stack) > 0 { + b.WriteString(" at ") + b.WriteString(e.Stack[0].String()) + } + if e.Cause != nil && e.Cause.Error() != e.Msg { + b.WriteString(" (") + b.WriteString(e.Cause.Error()) + b.WriteString(")") + } + return b.String() +} + +func (e *Error) Unwrap() error { return e.Cause } + +// LogValue 实现 slog.LogValuer。 +func (e *Error) LogValue() slog.Value { + attrs := []slog.Attr{ + slog.String("kind", string(e.Kind)), + slog.String("script", e.Script), + } + if e.Func != "" { + attrs = append(attrs, slog.String("func", e.Func)) + } + if e.Msg != "" { + attrs = append(attrs, slog.String("msg", e.Msg)) + } + if len(e.Stack) > 0 { + attrs = append(attrs, + slog.Int("line", e.Stack[0].Line), + slog.Int("column", e.Stack[0].Column), + slog.String("at", e.Stack[0].String()), + ) + } + if len(e.Args) > 0 { + attrs = append(attrs, slog.Any("args", e.Args)) + } + if e.GoStack != "" { + attrs = append(attrs, slog.String("go_stack", e.GoStack)) + } + if e.Cause != nil { + attrs = append(attrs, slog.String("cause", e.Cause.Error())) + } + return slog.GroupValue(attrs...) +} + +// newError 造一个带脚本上下文的错误。 +func newError(kind Kind, script, fn string, cause error, format string, a ...any) *Error { + return &Error{ + Kind: kind, + Script: script, + Func: fn, + Msg: fmt.Sprintf(format, a...), + Cause: cause, + } +} + +// classify 把 goja 抛出来的各种错误翻译成 *Error 并补上脚本上下文。 +// 已经是 *Error 的(比如 Dispatch 内部自己造的类型错误)原样返回。 +func classify(err error, script, fn string, args []any) error { + if err == nil { + return nil + } + + var known *Error + if errors.As(err, &known) { + return err + } + + // 超时/取消:goja.Runtime.Interrupt 打断脚本后返回的就是这个。 + var interrupted *goja.InterruptedError + if errors.As(err, &interrupted) { + cause := interrupted.Unwrap() + kind, msg := KindTimeout, "脚本执行超时,已强制中断" + switch { + case errors.Is(cause, context.Canceled): + kind, msg = KindCanceled, "调用方 context 被取消,脚本已中断" + case errors.Is(cause, context.DeadlineExceeded): + cause = fmt.Errorf("%w (%w)", ErrTimeout, cause) + case cause == nil: + cause = ErrInterrupted + } + return &Error{ + Kind: kind, + Script: script, + Func: fn, + Msg: msg, + Args: summarize(args), + Stack: framesOf(interrupted.Stack()), + Cause: cause, + } + } + + var overflow *goja.StackOverflowError + if errors.As(err, &overflow) { + return &Error{ + Kind: KindRuntime, + Script: script, + Func: fn, + Msg: "脚本调用栈溢出(多半是无限递归)", + Args: summarize(args), + Stack: framesOf(overflow.Stack()), + Cause: err, + } + } + + // 脚本里没被 catch 的异常。 + var exception *goja.Exception + if errors.As(err, &exception) { + e := &Error{ + Kind: KindRuntime, + Script: script, + Func: fn, + Msg: "脚本抛出异常", + Args: summarize(args), + Stack: framesOf(exception.Stack()), + Cause: err, + } + if v := exception.Value(); v != nil { + e.Msg = v.String() + e.Value = v.Export() + if hint := missingGlobalHint(e.Msg); hint != "" { + e.Msg += "。" + hint + } + } + // Go 侧函数返回的 error 透到 JS 又没被 catch 时,这里能把原始 Go error 取回来, + // 让调用方的 errors.Is 还能匹配到自己的哨兵错误。 + if inner := exception.Unwrap(); inner != nil { + e.Cause = inner + } + return e + } + + return &Error{ + Kind: KindRuntime, + Script: script, + Func: fn, + Msg: err.Error(), + Args: summarize(args), + Cause: err, + } +} + +func framesOf(stack []goja.StackFrame) []Frame { + if len(stack) == 0 { + return nil + } + out := make([]Frame, 0, len(stack)) + for i := range stack { + pos := stack[i].Position() + out = append(out, Frame{ + Source: stack[i].SrcName(), + Func: stack[i].FuncName(), + Line: pos.Line, + Column: pos.Column, + }) + } + return out +} + +const ( + maxSummaryArgs = 8 // 最多记录几个参数 + maxSummaryLen = 256 // 单个参数摘要的最大长度 +) + +// summarize 把调用参数压成可以安全写进日志的短字符串。 +// 只在出错路径上调用,正常调用不付这个格式化开销。 +func summarize(args []any) []string { + if len(args) == 0 { + return nil + } + n := min(len(args), maxSummaryArgs) + out := make([]string, 0, n+1) + for _, a := range args[:n] { + s := fmt.Sprintf("%v", a) + if len(s) > maxSummaryLen { + s = s[:maxSummaryLen] + "…" + } + out = append(out, fmt.Sprintf("%T=%s", a, s)) + } + if len(args) > n { + out = append(out, fmt.Sprintf("…还有 %d 个参数", len(args)-n)) + } + return out +} + +// fatal 判断这个错误是否说明 VM 已处于不确定状态,不该再放回池子复用。 +func fatal(err error) bool { + var e *Error + if errors.As(err, &e) { + switch e.Kind { + case KindTimeout, KindCanceled, KindPanic: + return true + } + return false + } + var interrupted *goja.InterruptedError + if errors.As(err, &interrupted) { + return true + } + var overflow *goja.StackOverflowError + return errors.As(err, &overflow) +} + +// toError 把 recover() 拿到的任意值转成 error。 +func toError(r any) error { + if err, ok := r.(error); ok { + return err + } + return fmt.Errorf("%v", r) +} + +// strconvQuote 是 strconv.Quote 的短名字,给错误信息拼接用。 +func strconvQuote(s string) string { return strconv.Quote(s) } + +// missingGlobalHint 针对几个"goja 没有、但脚本作者以为一定有"的全局, +// 在 ReferenceError 后面补一句人话。 +// +// 为什么是补错误信息,而不是把这些全局定义成"一调就报错"的桩: +// 库里 `typeof setTimeout !== "undefined"` 这种特性探测很常见,一旦定义了, +// 探测就会走进定时器分支,本来能优雅降级的库反而被弄坏。让它保持 undefined, +// 探测正常工作;真直接调用了,就在错误里说清楚。 +// +// 只认两种原样输出:goja 的 "ReferenceError: xxx is not defined", +// 以及 esbuild 给动态 require 埋的那句运行期错误。 +func missingGlobalHint(msg string) string { + // require 不会以 ReferenceError 的形式出现——esbuild 在打包期就接管了它: + // 静态的 require("x") 直接当模块解析(解析不到就打包失败), + // 动态的 require(变量) 换成下面这句运行期错误。 + if strings.HasPrefix(msg, "Error: Dynamic require of ") { + return "脚本走的是 ES 模块,用 import 而不是 require;" + + "而且 import 的路径必须是字面量,不能是变量拼出来的" + } + + const prefix = "ReferenceError: " + const suffix = " is not defined" + if !strings.HasPrefix(msg, prefix) || !strings.HasSuffix(msg, suffix) { + return "" + } + name := msg[len(prefix) : len(msg)-len(suffix)] + + switch name { + case "setTimeout", "setInterval", "setImmediate", + "clearTimeout", "clearInterval", "clearImmediate": + return "脚本是同步的,没有事件循环——定时器用不了。" + + "要延迟或周期执行,把这段逻辑放到 Go 侧的 Job / Cron;" + + "如果是第三方库里的 debounce / throttle 之类,换一个不依赖定时器的实现" + + case "structuredClone": + return "goja 没有这个函数。深拷贝用 JSON.parse(JSON.stringify(x))," + + "或者换一个不依赖它的库(remeda 的 clone 就依赖它)" + + case "module", "exports": + return "脚本走的是 ES 模块,没有 CommonJS 那套;导出用 export default" + + case "process", "Buffer", "__dirname", "__filename": + return "脚本不跑在 Node 里,没有这些东西。要读配置用扩展提供的接口" + + case "window", "document", "navigator", "localStorage": + return "脚本不跑在浏览器里,没有 DOM" + + case "fetch", "XMLHttpRequest", "WebSocket": + return "脚本里发不了网络请求——那是异步的,而脚本是同步执行的。" + + "要调外部服务,在 Go 侧做好再通过扩展交给脚本" + } + return "" +} diff --git a/esmwrap.go b/esmwrap.go new file mode 100644 index 0000000..e34c219 --- /dev/null +++ b/esmwrap.go @@ -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 +} diff --git a/extension.go b/extension.go new file mode 100644 index 0000000..da2fa25 --- /dev/null +++ b/extension.go @@ -0,0 +1,48 @@ +package jscriptx + +// Extension 是一个作用域级扩展:给脚本添一个全局对象。 +// +// 扩展实例由调用方自己创建,跟着 ctx 走: +// +// st := jscriptx.NewStore() +// tx := myTx(db) +// ctx = jscriptx.WithScope(ctx, jscriptx.ScopeExtensions(st, tx)) +// +// obj, err := ctrl.New(ctx) // 脚本里能用 store 和 tx +// defer obj.Close() +// +// st.Get("count") // Go 侧拿的就是同一份,不用再取回 +// +// 同一个 ctx 下 New 出来的所有实例共享同一份扩展对象——这就是多个 controller +// 共享数据的方式:共享的是 Go 侧对象,不是共用 Runtime。 +// +// 业务写自己的扩展只要实现这三个方法: +// +// type tx struct{ conn *sql.Tx } +// +// func (t *tx) Name() string { return "tx" } +// func (t *tx) Bindings() map[string]any { +// return map[string]any{"commit": t.conn.Commit, "rollback": t.conn.Rollback} +// } +// func (t *tx) Module() string { return txTypings } +type Extension interface { + // Name 是它在脚本里的全局名,必须是合法的 JS 标识符。 + // 跟白名单(WithGlobals)同名时以白名单为准。 + Name() string + + // Bindings 是暴露给脚本的方法集。方法名**大写开头**,跟脚本里能碰到的 + // 其它东西保持一致——传进来的 Go 对象(m.GetCode()、res.GetDBTable()) + // 用的都是 Go 的方法名,扩展再用小写的话,同一行代码里两种风格混着写。 + Bindings() map[string]any + + // Module 返回配套 TypeScript 模块的 import 路径和源码,让脚本能拿到类型: + // + // import store from "@jscriptx/store" + // + // 路径要加 scope 前缀,免得跟 node_modules 里的包撞名。用什么 scope 自己定, + // 框架层那套用的是 @jscriptx/,业务自己的可以用 @fsdpf/ 之类。 + // 路径返回空字符串表示不提供模块,脚本只能用全局变量的写法。 + // + // 模块本身只是个门面:把全局对象转发出来,附上类型声明。真正的实现在 Go 侧。 + Module() (path, source string) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..b111363 --- /dev/null +++ b/go.mod @@ -0,0 +1,16 @@ +module git.fsdpf.net/go/jscriptx + +go 1.25.5 + +require ( + github.com/dop251/goja v0.0.0-20260826204918-8f1c0696a37b + github.com/evanw/esbuild v0.28.2 +) + +require ( + github.com/dlclark/regexp2/v2 v2.5.2 // indirect + github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect + github.com/google/pprof v0.0.0-20230207041349-798e818bf904 // indirect + golang.org/x/sys v0.46.0 // indirect + golang.org/x/text v0.39.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..aeb2d17 --- /dev/null +++ b/go.sum @@ -0,0 +1,19 @@ +github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE= +github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/dlclark/regexp2/v2 v2.5.2 h1:HAsucWRhsqcDzl6Ua9aR8JwYOTzrZyPrF0/FNxJVAI0= +github.com/dlclark/regexp2/v2 v2.5.2/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU= +github.com/dop251/goja v0.0.0-20260826204918-8f1c0696a37b h1:mYHoARbZ0mUYXXsaNeHoDFBft3TK4PpFEe3KU7hdDgg= +github.com/dop251/goja v0.0.0-20260826204918-8f1c0696a37b/go.mod h1:u8yZRUavu+N4EnFFy6J5fVtjE7lEcZ2YyV2GcBXY9c8= +github.com/evanw/esbuild v0.28.2 h1:A2uETn4jrQTcXaT/shwTDTYBxDjl7fV7nXmUrJxfA2w= +github.com/evanw/esbuild v0.28.2/go.mod h1:D2vIQZqV/vIf/VRHtViaUtViZmG7o+kKmlBfVQuRi48= +github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU= +github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg= +github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/google/pprof v0.0.0-20230207041349-798e818bf904 h1:4/hN5RUoecvl+RmJRE2YxKWtnnQls6rQjjW5oV7qg2U= +github.com/google/pprof v0.0.0-20230207041349-798e818bf904/go.mod h1:uglQLonpP8qtYCYyzA+8c/9qtqgA3qsXGYqCPKARAFg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus= +golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM= diff --git a/instance.go b/instance.go new file mode 100644 index 0000000..9738ba3 --- /dev/null +++ b/instance.go @@ -0,0 +1,174 @@ +package jscriptx + +import ( + "context" + "sync" + "sync/atomic" + "time" +) + +// Instance 是脚本导出的 class 的一个实例,独占一个 VM,用起来跟普通 Go 对象差不多: +// +// obj, err := s.New(ctx, "dev-1", "温控器") +// defer obj.Close() +// got, err := obj.Call(ctx, "onMessage", payload) +// +// 跟从 VM 池借用的 Script.Call 不同,实例在整个生命周期里绑定同一个 VM, +// 所以脚本里的 this.xxx 能跨调用保持: +// +// export default class DeviceHandler { +// constructor(deviceId, model) { this.count = 0 } +// onMessage(payload) { return ++this.count } // 跨调用累加 +// } +// +// # 必须知道的三件事 +// +// 1. 生命周期归你管。Close 之前它一直占着一个 VM(约 16 KB),本库不会替你回收, +// 也没有按 key 复用那一套——要长期持有(比如按设备 ID 存着),业务侧自己拿 map 存。 +// 2. 同一个实例的调用是串行的。goja 的 Runtime 不是并发安全的,多个 goroutine +// 同时调同一个实例会排队;不同实例之间并行。 +// 3. 脚本出错会丢状态。一次超时或 panic 会让这个 VM 被丢弃,下次调用时用一份 +// 全新的实例重建(构造参数会重新传一遍,但 this 上攒的状态归零)。 +// Resets 能查到发生过几次。真正不能丢的东西放作用域的扩展里(比如 store)。 +type Instance struct { + script *Script + ctorArgs []any + label string // 错误信息里怎么称呼它 + scope *scope // ctx 带来的作用域:扩展和额外全局从这里拿,没有作用域时为 nil + + mu sync.Mutex // 保证串行执行,acquire 持锁直到 finish + vm *vmHandle + done bool + + lastUsed atomic.Int64 // UnixNano,空闲回收用 + calls atomic.Int64 + resets atomic.Int64 +} + +// New 实例化脚本导出的 class,构造参数直接传给 constructor。返回的实例独占一个 VM, +// 脚本里的 this.xxx 在它活着期间跨调用保持,用完 Close。 +// +// ctx 里带了作用域(WithScope)时,作用域里的扩展和全局对象会注入给这个实例—— +// 同一个 ctx 下 New 出来的多个实例因此共享同一份扩展(比如 store),但各有各的 VM, +// 互相并行、this.xxx 互不干扰。 +// +// 脚本导出的不是 class 而是实例或函数时也能用,只是构造参数没有去处会被忽略。 +// constructor 里抛异常会在这里就报出来,不用等到第一次调用。 +func (s *Script) New(ctx context.Context, ctorArgs ...any) (*Instance, error) { + if s.closed.Load() { + return nil, newError(KindClosed, s.name, "", ErrClosed, "脚本已关闭") + } + i := &Instance{script: s, ctorArgs: ctorArgs, label: "实例"} + if sc, ok := scopeOf(ctx); ok { + i.scope = sc + i.label = "作用域 " + strconvQuote(sc.key) + " 的实例" + } + i.touch() + if err := i.warmup(ctx); err != nil { + return nil, err + } + return i, nil +} + +// Name 返回脚本名。 +func (i *Instance) Name() string { return i.script.name } + +// Calls 返回这个实例累计发起过多少次调用。 +func (i *Instance) Calls() int64 { return i.calls.Load() } + +// Resets 返回这个实例的 VM 被重建过几次。每重建一次,脚本里 this 上的状态就归零一次。 +func (i *Instance) Resets() int64 { return i.resets.Load() } + +// IdleFor 返回这个实例空闲了多久。 +func (i *Instance) IdleFor() time.Duration { + return time.Since(time.Unix(0, i.lastUsed.Load())) +} + +// Call 在这个实例上调用方法,语义跟 Script.Call 一致,只是 this 绑定到本实例。 +func (i *Instance) Call(ctx context.Context, fn string, args ...any) (any, error) { + return callAny(ctx, i, fn, args) +} + +// CallInto 在这个实例上调用方法,并把返回值转换进 out 指向的变量。 +func (i *Instance) CallInto(ctx context.Context, fn string, out any, args ...any) error { + return callInto(ctx, i, fn, out, args) +} + +// Has 判断实例上有没有这个方法(继承来的也算)。 +func (i *Instance) Has(fn string) bool { + i.mu.Lock() + defer i.mu.Unlock() + if err := i.ensure(context.Background()); err != nil { + return false + } + _, _, _, ok := i.vm.lookup(fn) + return ok +} + +// Close 释放这个实例占用的 VM。之后的调用返回 ErrClosed。重复调用是安全的。 +// +// 作用域里的扩展不归它管——那些是你自己创建的对象,Close 只释放这个实例的 VM。 +func (i *Instance) Close() { i.shutdown() } + +func (i *Instance) owner() *Script { return i.script } +func (i *Instance) touch() { i.lastUsed.Store(time.Now().UnixNano()) } + +// warmup 提前建好 VM,让 constructor 的错误在 New 阶段就暴露出来。 +func (i *Instance) warmup(ctx context.Context) error { + i.mu.Lock() + defer i.mu.Unlock() + return i.ensure(ctx) +} + +// ensure 保证 VM 就绪,调用方必须持有 i.mu。 +func (i *Instance) ensure(ctx context.Context) error { + if i.done || i.script.closed.Load() { + return newError(KindClosed, i.script.name, "", ErrClosed, "%s已关闭", i.label) + } + if i.vm != nil { + return nil + } + // 作用域带来的额外全局(扩展 + ScopeGlobals)现取现用,不在 Instance 上留副本。 + var extra map[string]any + if i.scope != nil { + extra = i.scope.vmGlobals() + } + vm, err := i.script.newVM(ctx, extra, i.ctorArgs) + if err != nil { + return err + } + i.vm = vm + return nil +} + +// acquire 锁住实例并交出它独占的 VM。 +// 注意:锁一直持到 finish 才释放,这正是"同一实例串行执行"的保证。 +func (i *Instance) acquire(ctx context.Context) (*vmHandle, error) { + i.mu.Lock() + if err := i.ensure(ctx); err != nil { + i.mu.Unlock() + return nil, err + } + i.touch() + i.calls.Add(1) + return i.vm, nil +} + +// finish 交还 VM 并解锁。VM 状态不确定时(超时/panic)直接丢弃, +// 下次调用会重建一个全新实例——脚本里 this 上的状态也就跟着归零了。 +func (i *Instance) finish(_ *vmHandle, healthy bool) { + if !healthy { + i.vm = nil + i.resets.Add(1) + } + i.touch() + i.mu.Unlock() +} + +// shutdown 真正释放 VM。 +func (i *Instance) shutdown() { + i.mu.Lock() + defer i.mu.Unlock() + i.done = true + i.vm = nil +} diff --git a/invoke.go b/invoke.go new file mode 100644 index 0000000..f9bcf76 --- /dev/null +++ b/invoke.go @@ -0,0 +1,326 @@ +package jscriptx + +import ( + "context" + "fmt" + "reflect" + "runtime/debug" + "sync" + "time" + + "github.com/dop251/goja" +) + +// frame 是一次调用的上下文:拿到了 VM、目标函数,可以真正发起调用。 +// 它只在 withCall 的回调里有效,不能存下来跨调用使用。 +type frame struct { + script *Script + rt *goja.Runtime + fn goja.Callable + fnVal goja.Value + this goja.Value // 方法调用时绑定的 this(导出实例),默认导出函数为 undefined + name string +} + +// arity 返回 JS 函数声明的形参个数(函数对象的 length 属性), +// Dispatch 靠它判断脚本写的是哪种回调形状。 +func (f *frame) arity() int64 { + return f.fnVal.ToObject(f.rt).Get("length").ToInteger() +} + +// call 调用目标函数,this 按 lookup 的结果绑定。 +func (f *frame) call(args ...goja.Value) (goja.Value, error) { + return f.fn(f.this, args...) +} + +// callWith 用 Go 值直接调用目标函数。 +func (f *frame) callWith(args []any) (goja.Value, error) { + jsArgs := make([]goja.Value, len(args)) + for i, a := range args { + jsArgs[i] = f.rt.ToValue(a) + } + return f.call(jsArgs...) +} + +// export 把脚本返回值转成 Go 值,写进 target 指向的变量。 +func (f *frame) export(v goja.Value, target any) error { + return f.rt.ExportTo(v, target) +} + +// empty 判断脚本返回值是不是"什么都没返回"。 +func empty(v goja.Value) bool { + return v == nil || goja.IsUndefined(v) || goja.IsNull(v) +} + +// Call 调用脚本里的函数,返回值导出成 Go 值(JS 对象变 map[string]any,数组变 []any, +// 传进去的 Go 对象原样回来)。fn 传 DefaultFunc 表示调用脚本自身求值出的那个函数。 +// +// 脚本返回函数/闭包会被拒绝:那种值只在 VM 内部有效,VM 归还池子后再调用会出问题。 +// 需要把脚本函数当回调用,走 Dispatch。 +func (s *Script) Call(ctx context.Context, fn string, args ...any) (any, error) { + return callAny(ctx, s, fn, args) +} + +// CallInto 调用脚本里的函数,并把返回值转换进 out 指向的变量(out 必须是非 nil 指针), +// 相当于带类型的 Call:目标是 int 就按 int 转,是某个接口就要求返回值实现它。 +func (s *Script) CallInto(ctx context.Context, fn string, out any, args ...any) error { + return callInto(ctx, s, fn, out, args) +} + +// callAny 是 Call 的共用实现,*Script 和 *Session 都走这里。 +func callAny(ctx context.Context, r runner, fn string, args []any) (any, error) { + var out any + err := invoke(ctx, r, fn, args, func(f *frame) error { + res, err := f.callWith(args) + if err != nil { + return err + } + res, err = unwrapPromise(f, res, r.owner().name, fn) + if err != nil { + return err + } + if empty(res) { + return nil + } + if _, isFunc := goja.AssertFunction(res); isFunc { + return newError(KindType, r.owner().name, fn, ErrValueEscape, + "返回值是 JS 函数,只在脚本内部有效;需要回调语义请用 Dispatch") + } + out = res.Export() + return nil + }) + return out, err +} + +// callInto 是 CallInto 的共用实现。 +func callInto(ctx context.Context, r runner, fn string, out any, args []any) error { + name := r.owner().name + + rv := reflect.ValueOf(out) + if !rv.IsValid() || rv.Kind() != reflect.Pointer || rv.IsNil() { + return newError(KindType, name, fn, nil, "CallInto 的 out 必须是非 nil 指针,当前是 %T", out) + } + // 导出成 Go 函数意味着把一个绑定在 VM 上的闭包带出脚本边界, + // 而这个 VM 马上就要还回池子给别的请求用了。 + if rv.Type().Elem().Kind() == reflect.Func { + return newError(KindType, name, fn, ErrValueEscape, + "不能把脚本函数导出成 Go 函数(VM 归还池子后它就失效了);需要回调语义请用 Dispatch") + } + + return invoke(ctx, r, fn, args, func(f *frame) error { + res, err := f.callWith(args) + if err != nil { + return err + } + res, err = unwrapPromise(f, res, name, fn) + if err != nil { + return err + } + if empty(res) { + return nil + } + if err := f.export(res, out); err != nil { + return newError(KindType, name, fn, err, "返回值无法转换成 %T", out) + } + return nil + }) +} + +// runner 抽象"这次调用用哪个 VM、用完怎么处理":*Script 从池里借还, +// *Session 独占一个 VM(acquire 会一直持锁到 finish,保证同一会话串行执行)。 +type runner interface { + owner() *Script + acquire(ctx context.Context) (*vmHandle, error) + finish(inst *vmHandle, healthy bool) +} + +func (s *Script) owner() *Script { return s } + +func (s *Script) acquire(ctx context.Context) (*vmHandle, error) { return s.borrow(ctx) } + +func (s *Script) finish(inst *vmHandle, healthy bool) { s.release(inst, healthy) } + +// invoke 是所有脚本调用的骨架:取 VM → 找函数 → 装超时哨兵 → 兜 panic → 分类错误 → 交还 VM。 +// do 里只管发起调用和处理返回值,异常处理交给这里。 +func invoke(ctx context.Context, r runner, fn string, args []any, do func(*frame) error) (err error) { + s := r.owner() + if s.closed.Load() { + return newError(KindClosed, s.name, fn, ErrClosed, "脚本已关闭") + } + if ctx == nil { + ctx = context.Background() + } + + inst, err := r.acquire(ctx) + if err != nil { + return err + } + + healthy := true + // defer 是后进先出:recover 最先跑(把 healthy 置回 false), + // 然后 stop 清掉可能迟到的中断标志,最后才交还 VM。 + defer func() { r.finish(inst, healthy) }() + + callable, fnVal, this, ok := inst.lookup(fn) + if !ok { + return newError(KindNotFound, s.name, fn, ErrFuncNotFound, "%s", s.notFoundHint(inst)) + } + + stop := guard(ctx, inst.rt, s.engine.timeout) + defer stop() + + defer func() { + if r := recover(); r != nil { + healthy = false + err = s.panicError(fn, args, r) + } + }() + + f := &frame{script: s, rt: inst.rt, fn: callable, fnVal: fnVal, this: this, name: fn} + if e := do(f); e != nil { + err = classify(e, s.name, fn, args) + healthy = !fatal(err) + return err + } + return nil +} + +// panicError 把 recover 到的 panic 包成带现场的错误。脚本里的类型错误、 +// 注入进去的 Go 方法内部 panic,都会走到这里,不会掀翻调用方的 goroutine。 +func (s *Script) panicError(fn string, args []any, r any) *Error { + return &Error{ + Kind: KindPanic, + Script: s.name, + Func: fn, + Msg: fmt.Sprintf("脚本执行过程中发生 panic: %v", r), + Args: summarize(args), + GoStack: string(debug.Stack()), + Cause: toError(r), + } +} + +// interruptGuard 保证"中断"和"收尾"两件事不会打架:收尾之后再迟到的中断信号必须被丢掉, +// 否则它会毒死下一次用到这个 VM 的调用。 +type interruptGuard struct { + mu sync.Mutex + stopped bool + rt *goja.Runtime +} + +func (g *interruptGuard) fire(reason error) { + g.mu.Lock() + defer g.mu.Unlock() + if g.stopped { + return + } + g.rt.Interrupt(reason) +} + +// stop 关掉哨兵并清掉可能已经发出的中断标志。返回后保证不会再有新的中断落到这个 VM 上。 +func (g *interruptGuard) stop() { + g.mu.Lock() + g.stopped = true + g.mu.Unlock() + // 到这里要么 fire 已经跑完(中断标志由下面清掉),要么它以后永远不会再发。 + g.rt.ClearInterrupt() +} + +// guard 装一个中断哨兵:超时或者调用方 context 取消时,从另一个 goroutine 调 +// Interrupt 打断正在执行的脚本(死循环也能断掉)。返回的函数负责收尾。 +func guard(ctx context.Context, rt *goja.Runtime, timeout time.Duration) func() { + watchCtx := ctx.Done() != nil + if timeout <= 0 && !watchCtx { + return func() {} + } + + g := &interruptGuard{rt: rt} + + if !watchCtx { + // 快路径:没有 context 取消要盯,一个定时器就够了,不用为每次调用起 goroutine。 + // MQTT 消息级这种高频调用走的就是这条路。 + timer := time.AfterFunc(timeout, func() { g.fire(context.DeadlineExceeded) }) + return func() { + g.stop() + timer.Stop() + } + } + + var cancel context.CancelFunc + if timeout > 0 { + ctx, cancel = context.WithTimeout(ctx, timeout) + } + done := make(chan struct{}) + exited := make(chan struct{}) + go func() { + defer close(exited) + select { + case <-ctx.Done(): + g.fire(ctx.Err()) + case <-done: + } + }() + + return func() { + close(done) + <-exited + if cancel != nil { + cancel() + } + g.stop() + } +} + +// unwrapPromise 把 async 方法返回的 Promise 解开。 +// +// goja 在调用返回前会把 microtask 队列跑干净,所以只要脚本里没有真正需要等待外部 +// 事件的操作(goja 没有事件循环,也没有 setTimeout),Promise 到这里已经是完成态了。 +// 解开它有两个理由:一是 *goja.Promise 属于引擎类型,不该出现在本库的返回值里; +// 二是调用方拿到一个未解包的 Promise 什么也做不了。 +func unwrapPromise(f *frame, v goja.Value, script, fn string) (goja.Value, error) { + p, ok := promiseOf(v) + if !ok { + return v, nil + } + switch p.State() { + case goja.PromiseStateFulfilled: + return p.Result(), nil + + case goja.PromiseStateRejected: + reason := p.Result() + e := &Error{ + Kind: KindRuntime, + Script: script, + Func: fn, + Msg: "async 方法的 Promise 被 reject", + Cause: ErrPromiseRejected, + } + if reason != nil { + e.Msg = reason.String() + e.Value = reason.Export() + } + return nil, e + + default: + return nil, newError(KindRuntime, script, fn, ErrPromisePending, + "async 方法返回的 Promise 一直没完成。goja 没有事件循环,"+ + "脚本里没法等待真正的异步操作(定时器、网络、IO);"+ + "异步的活交给 Go 侧做,脚本只写同步逻辑") + } +} + +// promiseOf 判断一个返回值是不是 Promise。 +// +// *goja.Promise 只能从 Export() 拿到,但对普通对象调 Export 会把整个对象转成 map, +// 那个开销不能加在每次调用上。先看有没有 then 属性——一次廉价的属性查找就能排除掉 +// 绝大多数返回值,只有 thenable 才走到 Export。 +func promiseOf(v goja.Value) (*goja.Promise, bool) { + obj, ok := v.(*goja.Object) + if !ok { + return nil, false + } + if then := obj.Get("then"); then == nil || goja.IsUndefined(then) { + return nil, false + } + p, ok := obj.Export().(*goja.Promise) + return p, ok +} diff --git a/loader.go b/loader.go new file mode 100644 index 0000000..f86f64f --- /dev/null +++ b/loader.go @@ -0,0 +1,104 @@ +package jscriptx + +// Loader 负责按名字提供脚本源码。脚本存在文件、数据库表还是配置中心,由实现方决定; +// 本库只定义这个接口,不预设来源。 +// +// 给出的源码默认会由 Engine 交给 esbuild 打包(ESM/TypeScript 都在那一步抹平), +// 所以直接返回原始源码即可。已经自己打过包的实现(比如 jscriptx/esm 的 Loader) +// 再实现 Prepared 接口,Engine 就会跳过这一步。 +// +// version 用来判断脚本有没有变:Engine 拿它跟缓存里的版本比对, +// 一致就复用已编译的 Program 和 VM 池,不一致才重新编译。取值随实现方便, +// 比如文件的 mtime+size、数据库行的 updated_at、源码哈希都行; +// 返回空字符串表示"我不提供版本号",Engine 会退化成拿源码算哈希。 +// +// 打开 WithAutoReload 后每次取脚本都会调一次 Load,实现方要保证这个调用足够轻 +// (能只查版本就别每次全量读源码,或者自己加一层短 TTL 缓存)。 +// +// 脚本不存在时,返回的错误要能被 errors.Is(err, ErrScriptNotFound) 匹配上, +// 调用方才好区分"脚本没配"和"加载出故障"。 +// +// 实现必须并发安全。 +type Loader interface { + Load(name string) (source string, version string, err error) +} + +// LoaderFunc 让普通函数直接当 Loader 用: +// +// loader := jscriptx.LoaderFunc(func(name string) (string, string, error) { +// row, err := db.QueryScript(name) +// if err != nil { +// return "", "", err +// } +// return row.Source, row.UpdatedAt, nil +// }) +type LoaderFunc func(name string) (source string, version string, err error) + +func (f LoaderFunc) Load(name string) (string, string, error) { return f(name) } + +// PreparedFunc 跟 LoaderFunc 一样是把函数当 Loader 用,区别是它声明"源码已经打好包了" +// (见 Prepared)。多层叠放时各层的 Prepared 必须一致,拿它就能让一个自定义来源 +// 跟 jscriptx/esm 那样的层对齐: +// +// db := jscriptx.PreparedFunc(func(name string) (string, string, error) { +// row, err := db.QueryScript(name) +// if err != nil { +// return "", "", err +// } +// bundled, err := jscriptx.Bundle(name, row.Source) // 自己打包 +// if err != nil { +// return "", "", err +// } +// return bundled, row.UpdatedAt, nil +// }) +// +// e, _ := jscriptx.New(jscriptx.WithLoader(esmLoader, db)) +// +// 名副其实是你自己的责任:说了打好包,交出去的就必须是打包产物, +// 否则脚本里的 import/export 会原样进 goja,直接编译失败。 +type PreparedFunc func(name string) (source string, version string, err error) + +func (f PreparedFunc) Load(name string) (string, string, error) { return f(name) } + +// Prepared 恒为 true,见 PreparedFunc 的说明。 +func (f PreparedFunc) Prepared() bool { return true } + +var _ Prepared = PreparedFunc(nil) + +// Prepared 由那些自己已经把源码打包好的 Loader 实现(比如 jscriptx/esm 的 Loader), +// Engine 见到它就跳过打包这一步。 +// +// 这不是性能优化,是正确性要求:打包产物是 IIFE,里面已经没有 export 了, +// 再打包一遍会被 esbuild 当死代码整段摇空,脚本变成什么都不剩。 +// 所以自己打过包的 Loader 必须实现它。 +// +// Engine 靠类型断言识别,方法签名写错了不会有编译错误。自己实现时最好钉一行 +// +// var _ jscriptx.Prepared = (*MyLoader)(nil) +type Prepared interface { + // Prepared 返回 true 表示 Load 给出的源码已经可以直接交给引擎编译。 + Prepared() bool +} + +func isPrepared(l Loader) bool { + p, ok := l.(Prepared) + return ok && p.Prepared() +} + +// loadLocked 走 Loader 拿源码;cached 非空且版本一致时直接复用缓存。调用方必须持有 e.mu。 +func (e *Engine) loadLocked(name string, cached *Script) (*Script, error) { + source, version, err := e.loader.Load(name) + if err != nil { + return nil, newError(KindLoad, name, "", err, "加载脚本源码失败") + } + if cached != nil && version != "" && cached.version == version { + return cached, nil + } + if version == "" { + version = hashVersion(source) + if cached != nil && cached.version == version { + return cached, nil + } + } + return e.compileLocked(name, source, version, isPrepared(e.loader)) +} diff --git a/overlay.go b/overlay.go new file mode 100644 index 0000000..8309896 --- /dev/null +++ b/overlay.go @@ -0,0 +1,238 @@ +package jscriptx + +import ( + "errors" + "fmt" + "sort" + "strconv" + "strings" +) + +// VersionSep 分隔脚本名和版本标签。叠层时用 "脚本名@版本" 点名要哪一层的实现。 +const VersionSep = "@" + +// OverlayLoader 把若干个 Loader 叠成一个,**后面的盖前面的**。 +// 由 Overlay 创建。 +type OverlayLoader struct { + loaders []Loader + prepared bool // 所有成员一致,构造时已校验过 + byVer map[string]int // 版本标签 -> 层号,贴了标签的层才在里面 +} + +// Versioned 由带版本标签的 Loader 实现(比如 esm.Loader 配 WithVersion)。 +// 叠层时这个标签就是调用点用来点名的那个:Load("Foo/Bar@v1")。 +// +// 没实现这个接口、或者标签是空串的层,只能通过"上层盖下层"取到,点不了名。 +type Versioned interface { + // Version 返回这一层的版本标签。 + Version() string +} + +// Tag 给任意 Loader 贴一个版本标签,让它在叠层里能被点名。 +// 自带标签的 Loader(比如 esm.NewLoader 配了 WithVersion)不用它。 +// +// e, _ := jscriptx.New(jscriptx.WithLoader( +// disk, +// jscriptx.Tag("hotfix", dbLoader), // 之后可以 Load("Foo/Bar@hotfix") +// )) +func Tag(version string, l Loader) Loader { + return &taggedLoader{Loader: l, version: version} +} + +type taggedLoader struct { + Loader + version string +} + +func (t *taggedLoader) Version() string { return t.version } + +// Prepared 透传底下那个 Loader 的取值,别让贴标签这件事改了它的性质。 +func (t *taggedLoader) Prepared() bool { return isPrepared(t.Loader) } + +func versionOf(l Loader) string { + v, ok := l.(Versioned) + if !ok { + return "" + } + return v.Version() +} + +// Overlay 把多个 Loader 叠成一个:取脚本时从最后一个往前找,谁先有就用谁的。 +// 排在后面的因此能覆盖前面的同名脚本——把"定制层"放最后,业务侧放一份同名脚本 +// 就能改写默认实现,不用动被覆盖的那一份: +// +// base, _ := esm.NewLoader("app/src") +// custom, _ := esm.NewLoader("custom/src") // 配置可以跟 base 完全不同 +// +// loader, err := jscriptx.Overlay(base, custom) // custom 盖 base +// e, _ := jscriptx.New(jscriptx.WithLoader(loader)) +// +// 每个成员是独立的 Loader,各有各的配置(入口规则、目标版本、node_modules 位置、 +// 扩展模块),来源也可以不同——一层来自磁盘目录,另一层来自数据库都行。 +// +// 给某一层贴了版本标签(esm 的 WithVersion,或者 Tag),调用点就能点名要它: +// +// v1, _ := esm.NewLoader("app/v1/src", esm.WithVersion("v1")) +// v2, _ := esm.NewLoader("app/v2/src", esm.WithVersion("v2")) +// +// e.New(ctx, "Resource/ResCreateController") // 不点名:上层盖下层,拿到 v2 +// e.New(ctx, "Resource/ResCreateController@v1") // 点名:只在 v1 那层找,不回落 +// +// 点名是"只认这一层":那层没有这个脚本就直接报不存在,不会掉到别的层去—— +// 不然点名要 v1 却跑了 v2 的实现,比报错难查得多。 +// +// 成员的 Prepared 必须一致:要么都是自己打好包的(比如 jscriptx/esm 的 Loader), +// 要么都交出原始源码由引擎打包。混着来会返回错误,因为引擎只能对整个 Loader +// 做一次判断,没法分脚本区别对待。真要混,把原始源码那层用 LoaderFunc 包一下, +// 里面自己调 Bundle,它就跟其它层一样是"打好包的"了。 +func Overlay(loaders ...Loader) (*OverlayLoader, error) { + if len(loaders) == 0 { + return nil, errors.New("jscriptx: Overlay 至少要给一个 Loader") + } + for i, l := range loaders { + if l == nil { + return nil, fmt.Errorf("jscriptx: Overlay 的第 %d 个 Loader 是 nil", i) + } + } + prepared := isPrepared(loaders[0]) + for i, l := range loaders[1:] { + if isPrepared(l) != prepared { + return nil, fmt.Errorf( + "jscriptx: Overlay 的成员 Prepared 不一致(第 0 个是 %v,第 %d 个是 %v);"+ + "要么都自己打包,要么都交出原始源码", prepared, i+1, !prepared) + } + } + byVer := map[string]int{} + for i, l := range loaders { + v := versionOf(l) + if v == "" { + continue + } + if strings.Contains(v, VersionSep) { + return nil, fmt.Errorf("jscriptx: 版本标签 %q 里不能有 %q", v, VersionSep) + } + if j, dup := byVer[v]; dup { + return nil, fmt.Errorf("jscriptx: 版本标签 %q 重了(第 %d 层和第 %d 层)", v, j, i) + } + byVer[v] = i + } + return &OverlayLoader{ + loaders: append([]Loader(nil), loaders...), + prepared: prepared, + byVer: byVer, + }, nil +} + +// Load 从最后一个成员往前找,返回第一个找到的脚本。 +// +// 成员报"脚本不存在"就继续往前找;报别的错直接返回——加载出故障不该被 +// 后面那层的结果悄悄盖掉。 +func (o *OverlayLoader) Load(name string) (string, string, error) { + if bare, version, ok := splitVersion(name); ok { + return o.loadFrom(bare, version) + } + var notFound error + for i := len(o.loaders) - 1; i >= 0; i-- { + source, version, err := o.loaders[i].Load(name) + if err != nil { + if errors.Is(err, ErrScriptNotFound) { + notFound = err + continue + } + return "", "", err + } + // 版本号带上是第几层给的:覆盖层的脚本删掉后会落回下面那层, + // 两层的版本号万一撞上,不带层号就看不出脚本已经换了人。 + if version != "" { + version = strconv.Itoa(i) + ":" + version + } + return source, version, nil + } + if notFound == nil { + notFound = ErrScriptNotFound + } + return "", "", fmt.Errorf("%w: %s(%d 层都没有)", notFound, name, len(o.loaders)) +} + +// loadFrom 只在点名的那一层找,找不到就报不存在,不回落到别的层。 +func (o *OverlayLoader) loadFrom(name, version string) (string, string, error) { + i, ok := o.byVer[version] + if !ok { + return "", "", fmt.Errorf("%w: %s(没有版本 %q 这一层,有的是 %v)", + ErrScriptNotFound, name, version, o.Versions()) + } + source, ver, err := o.loaders[i].Load(name) + if err != nil { + return "", "", err + } + if ver != "" { + ver = strconv.Itoa(i) + ":" + ver + } + return source, ver, nil +} + +// splitVersion 把 "Foo/Bar@v1" 拆成 "Foo/Bar" 和 "v1"。 +// 用最后一个分隔符,脚本名里真带了 @ 也不会拆错。 +func splitVersion(name string) (bare, version string, ok bool) { + i := strings.LastIndex(name, VersionSep) + if i <= 0 || i == len(name)-1 { + return name, "", false // 没有分隔符,或者两边空着 + } + return name[:i], name[i+1:], true +} + +// Versions 返回各层的版本标签,按叠放顺序,没贴标签的层跳过。 +func (o *OverlayLoader) Versions() []string { + var out []string + for _, l := range o.loaders { + if v := versionOf(l); v != "" { + out = append(out, v) + } + } + return out +} + +// Prepared 返回成员们一致的取值,见 Overlay 的说明。 +func (o *OverlayLoader) Prepared() bool { return o.prepared } + +// Loaders 返回成员,顺序即叠放顺序(后面的盖前面的)。 +func (o *OverlayLoader) Loaders() []Loader { return append([]Loader(nil), o.loaders...) } + +// Names 汇总所有成员的脚本名,去重后按字典序排列。 +// 成员得有 Names() []string 方法才算得上,没有的(比如数据库来源)就跳过。 +func (o *OverlayLoader) Names() []string { + seen := map[string]bool{} + var out []string + for _, l := range o.loaders { + lister, ok := l.(interface{ Names() []string }) + if !ok { + continue + } + for _, n := range lister.Names() { + if !seen[n] { + seen[n] = true + out = append(out, n) + } + } + } + sort.Strings(out) + return out +} + +// Rebuild 挨个让成员重建。成员没有 Rebuild() error 方法就跳过。 +// 有成员失败时其余的照样会走一遍,返回的错误里带上所有失败。 +func (o *OverlayLoader) Rebuild() error { + var errs []error + for i, l := range o.loaders { + r, ok := l.(interface{ Rebuild() error }) + if !ok { + continue + } + if err := r.Rebuild(); err != nil { + errs = append(errs, fmt.Errorf("第 %d 层: %w", i, err)) + } + } + return errors.Join(errs...) +} + +var _ Prepared = (*OverlayLoader)(nil) diff --git a/scope.go b/scope.go new file mode 100644 index 0000000..d8dfeb9 --- /dev/null +++ b/scope.go @@ -0,0 +1,159 @@ +package jscriptx + +import ( + "context" + "crypto/rand" + "encoding/hex" + "time" +) + +// 作用域通过 context 传递:它只携带一段业务流程里要共享的东西(扩展和全局对象), +// 不管任何生命周期——实例的生死由你 Close 决定。 +// +// st := jscriptx.NewStore() +// ctx = jscriptx.WithScope(ctx, jscriptx.ScopeExtensions(st)) +// +// obj, err := resCtrl.New(ctx, arg) // 从 ctx 拿扩展和全局 +// defer obj.Close() +// _, err = obj.Call(ctx, "Store", cfg) // this.xxx 在 obj 活着期间保持 +// +// obj2, err := queryCtrl.New(ctx) // 同一个 ctx → 同一份 store +// defer obj2.Close() +// +// 每个实例独占一个 VM,所以: +// +// - 不同请求的实例互不相干,完全并行 +// - 同一个 ctx 下的不同 controller 各有各的 VM,也是并行的,只是共享扩展 +// - 同一个实例的多次调用串行——这是保住 this.xxx 必需的 +// +// 共享数据走扩展(Go 侧对象,注入到各个 VM 的是同一份引用),而不是共用 Runtime: +// 共用 Runtime 会让所有脚本被迫串行,那才是真的并发瓶颈。 +// +// 设备连接这类长期会话,业务侧自己拿 map 存 Instance 就行,跟 Go 版 controller 的 +// 写法一致;本库不代管这个生命周期。 + +type scopeCtxKey struct{} + +// scope 是一个作用域:一个标识、一批专属的全局对象、若干扩展。 +// 它整个装在 ctx 里传递,不持有任何 VM,也不管实例的生命周期。 +type scope struct { + key string + globals map[string]any + exts []Extension +} + +// ScopeOption 配置 WithScope 装进 ctx 的作用域。 +type ScopeOption func(*scope) + +// ScopeKey 给作用域一个标识,脚本里可以通过 scope.key 读到,也会出现在日志里。 +// 不给就生成一个随机的。它只是个标识,不参与任何查找或复用。 +func ScopeKey(key string) ScopeOption { + return func(s *scope) { s.key = key } +} + +// ScopeExtensions 把扩展带进这个作用域,它们会成为脚本里的全局对象。 +// +// 实例由你自己创建,所以 Go 侧和脚本读写的天然是同一份,不用再取回来: +// +// st := jscriptx.NewStore() +// ctx = jscriptx.WithScope(ctx, jscriptx.ScopeExtensions(st)) +// // …脚本里 store.Set("k", v)… +// st.Get("k") +// +// 同名的后者覆盖前者;跟白名单(WithGlobals)同名时以白名单为准。 +func ScopeExtensions(exts ...Extension) ScopeOption { + return func(s *scope) { s.exts = append(s.exts, exts...) } +} + +// ScopeGlobals 给这个作用域注入专属的全局对象,同名时盖过扩展。 +// 这个 ctx 下 New 出来的实例都能看到它,适合放当前用户、当前设备这类东西。 +// +// 注意它是**建 VM 时**注入的,也就是 New 的那一刻。实例建好之后再改 ctx 不会影响它, +// 每次调用都可能变的东西当调用参数传。 +func ScopeGlobals(globals map[string]any) ScopeOption { + return func(s *scope) { + if s.globals == nil { + s.globals = map[string]any{} + } + for k, v := range globals { + s.globals[k] = v + } + } +} + +// WithScope 把一个作用域装进 ctx。没给 ScopeKey 时生成一个随机标识。 +func WithScope(ctx context.Context, opts ...ScopeOption) context.Context { + sc := &scope{} + for _, opt := range opts { + opt(sc) + } + if sc.key == "" { + sc.key = randomKey() + } + if ctx == nil { + ctx = context.Background() + } + return context.WithValue(ctx, scopeCtxKey{}, sc) +} + +// ScopeKeyOf 取回 ctx 里的作用域标识,没有作用域时第二个返回值是 false。 +func ScopeKeyOf(ctx context.Context) (string, bool) { + sc, ok := scopeOf(ctx) + if !ok { + return "", false + } + return sc.key, true +} + +// ScopeExtensionOf 按名字取回 ctx 里的扩展,没有时返回 nil。 +// +// 通常用不上——扩展实例是你自己创建的,直接拿着那个变量就行。这个函数是给 +// 「ctx 从别处传来、手上没有原对象」的场合准备的。 +func ScopeExtensionOf(ctx context.Context, name string) Extension { + sc, ok := scopeOf(ctx) + if !ok { + return nil + } + // 倒着找,同名时后注册的生效 + for i := len(sc.exts) - 1; i >= 0; i-- { + if sc.exts[i].Name() == name { + return sc.exts[i] + } + } + return nil +} + +func scopeOf(ctx context.Context) (*scope, bool) { + if ctx == nil { + return nil, false + } + sc, ok := ctx.Value(scopeCtxKey{}).(*scope) + return sc, ok +} + +// vmGlobals 拼出这个作用域下每个 VM 都要注入的全局:各个扩展的方法集、 +// 调用方给的 ScopeGlobals,加上一个描述作用域身份的 scope 对象。 +// +// 注入到各个 VM 里的扩展是同一个 Go 对象,所以同一个 ctx 下的脚本天然共享数据, +// 又不必共用 Runtime。 +func (sc *scope) vmGlobals() map[string]any { + g := make(map[string]any, len(sc.globals)+len(sc.exts)+1) + for _, ext := range sc.exts { + g[ext.Name()] = ext.Bindings() + } + // ScopeGlobals 优先级高于扩展 + for k, v := range sc.globals { + g[k] = v + } + g["scope"] = map[string]any{"key": sc.key} + return g +} + +func randomKey() string { + var b [12]byte + if _, err := rand.Read(b[:]); err != nil { + // crypto/rand 出问题时退回时间戳,重复的概率仍然极低 + return "t" + hex.EncodeToString([]byte(time.Now().Format(time.RFC3339Nano))) + } + return hex.EncodeToString(b[:]) +} diff --git a/script.go b/script.go new file mode 100644 index 0000000..6c5d3fb --- /dev/null +++ b/script.go @@ -0,0 +1,327 @@ +package jscriptx + +import ( + "context" + "fmt" + "sync/atomic" + + "github.com/dop251/goja" +) + +// DefaultFunc 传给 Call/Dispatch 的 fn 参数时,表示脚本的默认导出本身, +// 也就是 `export default function ...` 这种"整个脚本就是一个函数"的写法。 +// 默认导出是 class 或对象时用不上它——那种要按方法名调用。 +const DefaultFunc = "" + +// Script 是一份编译好的脚本,内部维护一个 VM 池。它并发安全,可以长期持有。 +// +// 一个 Script 对应一份不可变的编译产物;热更新时 Engine 会造一个新的 Script +// 顶替它,已经拿着旧 Script 的调用不受影响,会继续跑完旧版本。 +type Script struct { + engine *Engine + name string + version string + prog *goja.Program + + pool chan *vmHandle + closed atomic.Bool + + created atomic.Int64 // 累计新建过多少个 VM + dropped atomic.Int64 // 累计丢弃过多少个 VM(超时/panic/池满) +} + +// vmHandle 是一个 VM:一个 goja.Runtime 加上它跑完脚本后的求值结果。 +// Runtime 不是并发安全的,同一时刻只能有一个 goroutine 持有它。 +// +// 一个 VM 只跑一个脚本。会话下多个脚本要共享数据时靠 store(Go 侧对象,注入到各个 VM +// 里的是同一份引用),而不是共用 Runtime——共用 Runtime 会让会话内的所有脚本被迫串行。 +type vmHandle struct { + rt *goja.Runtime + defFn goja.Value // 脚本求值出的函数(单函数入口写法) + exports *goja.Object // 脚本求值出的对象(class 实例写法),方法调用时绑定为 this + ctor *goja.Object // 导出的 class 本身。静态方法挂在它上面,实例的原型链上没有 + + // scoped 表示这个 VM 注入过某个作用域的扩展,因此**不能**回池给别的作用域用。 + // 用完直接丢,见 release。 + scoped bool +} + +// Name 返回脚本名。 +func (s *Script) Name() string { return s.name } + +// Version 返回脚本版本号(Loader 给的,或者源码哈希)。 +func (s *Script) Version() string { return s.version } + +// Stats 是 VM 池的运行统计,用于观测。 +type Stats struct { + Pooled int // 池里闲置的 VM 数 + MaxVMs int // 池容量 + Created int64 // 累计新建 + Dropped int64 // 累计丢弃 +} + +// Stats 返回 VM 池的当前统计。 +func (s *Script) Stats() Stats { + return Stats{ + Pooled: len(s.pool), + MaxVMs: cap(s.pool), + Created: s.created.Load(), + Dropped: s.dropped.Load(), + } +} + +// Has 判断脚本导出的实例上有没有这个方法(继承来的也算)。 +func (s *Script) Has(fn string) bool { + inst, err := s.borrow(context.Background()) + if err != nil { + return false + } + defer s.release(inst, true) + _, _, _, ok := inst.lookup(fn) + return ok +} + +// Close 释放池里所有 VM。之后的调用会返回 ErrClosed。 +// 已经借出去、正在执行的调用不受影响,它们的 VM 归还时直接丢弃。 +// +// New 出来的实例不在这条链上——它们的生命周期归调用方,Close 之后那些实例的调用 +// 会因为脚本已关闭而报错,VM 等 GC 回收。 +func (s *Script) Close() { + if s.closed.Swap(true) { + return + } + for { + select { + case <-s.pool: + s.dropped.Add(1) + default: + return + } + } +} + +// borrow 从池里取一个 VM;池空就新建一个。 +// +// 刻意不阻塞等待:并发量超过池容量时宁可临时多造几个 VM,也不让请求在这里排队。 +// 池只是复用缓存,不承担限流职责。 +// +// ctx 带了作用域时**不走池**:池是所有调用共享的,而作用域的扩展是这一次调用专属的 +// (store、当前请求……)。混用要么让脚本看不见扩展(顶层 import 直接 ReferenceError), +// 要么把上一个作用域的对象漏给下一个。所以为它单造一个 VM,用完丢弃。 +// +// 代价是这类调用每次约 5μs 建一个 VM。同一作用域下要反复调,用 New 拿实例更划算—— +// 实例把 VM 攥在手里,不必每次重建。 +func (s *Script) borrow(ctx context.Context) (*vmHandle, error) { + if sc, ok := scopeOf(ctx); ok { + // 作用域至少带一个 scope.key,所以只要有作用域就必然要单造 + vm, err := s.newVM(ctx, sc.vmGlobals(), nil) + if err != nil { + return nil, err + } + vm.scoped = true + return vm, nil + } + + select { + case inst := <-s.pool: + return inst, nil + default: + } + return s.newVM(ctx, nil, nil) +} + +// release 归还 VM。healthy 为 false(被中断过或 panic 过)时直接丢弃: +// 脚本本来就不该有跨调用状态,重建一个 VM 远比拖着一个状态可疑的 VM 划算。 +func (s *Script) release(inst *vmHandle, healthy bool) { + if inst == nil { + return + } + if !healthy || s.closed.Load() || inst.scoped { + // scoped 的 VM 带着某个作用域的扩展,回池就会漏给下一个调用 + s.dropped.Add(1) + return + } + select { + case s.pool <- inst: + default: + s.dropped.Add(1) // 池满,丢弃 + } +} + +// newVM 造一个新 VM 并在里面加载这个脚本:注入白名单(extra 是会话专属的额外全局, +// 池化调用传 nil)→ 跑一遍脚本顶层代码 → 记下求值结果。 +func (s *Script) newVM(ctx context.Context, extra map[string]any, ctorArgs []any) (*vmHandle, error) { + rt, err := s.engine.newRuntime(s.name, extra) + if err != nil { + return nil, err + } + return s.loadInto(ctx, rt, ctorArgs) +} + +// newRuntime 造一个空 VM 并注入白名单。scope 是它在日志和 store 里的身份: +// 池化 VM 用脚本名,会话 VM 用会话 key。 +func (e *Engine) newRuntime(scope string, extra map[string]any) (*goja.Runtime, error) { + rt := goja.New() + if e.maxStack > 0 { + rt.SetMaxCallStackSize(e.maxStack) + } + if err := e.bind(rt, scope, extra); err != nil { + return nil, err + } + return rt, nil +} + +// loadInto 在一个已经建好的 VM 里跑这个脚本,取出它的导出。 +// 顶层代码同样受超时保护,脚本在顶层写死循环不会把调用方卡住。 +// +// 同一个 rt 上可以先后加载多个脚本:打包产物是 IIFE,除了 ModuleGlobal 这一个全局名 +// 之外不往外泄漏东西,而那个名字的值在这里当场就取走了,后一个脚本覆盖它也不影响。 +func (s *Script) loadInto(ctx context.Context, rt *goja.Runtime, ctorArgs []any) (*vmHandle, error) { + return s.load(ctx, rt, ctorArgs, false) +} + +// load 把脚本装进一个 VM。noInstance 为 true 时不构造实例—— +// 只想调静态方法时用,避免白跑一遍 constructor(那是每次调用的准备工作)。 +func (s *Script) load(ctx context.Context, rt *goja.Runtime, ctorArgs []any, noInstance bool) (h *vmHandle, err error) { + stop := guard(ctx, rt, s.engine.timeout) + defer stop() + defer func() { + if r := recover(); r != nil { + h, err = nil, s.panicError(DefaultFunc, nil, r) + } + }() + + v, err := rt.RunProgram(s.prog) + if err != nil { + return nil, classify(err, s.name, DefaultFunc, nil) + } + + h = &vmHandle{rt: rt} + // 打包产物末尾的入口表达式求值出来的东西决定了脚本的形态: + // class → 由这里 new 出实例,构造参数从 Go 侧传(export default class X {}) + // 普通函数 → 单函数入口,用 DefaultFunc 调用(export default function(){}) + // 非函数对象 → 直接当导出实例(export default {...},或只有命名导出时的模块对象) + if !empty(v) { + switch { + case isClass(rt, v): + h.ctor = v.ToObject(rt) + if noInstance { + break // 只要静态方法,不跑 constructor + } + obj, err := construct(rt, v, ctorArgs) + if err != nil { + return nil, classify(err, s.name, "constructor", ctorArgs) + } + h.exports = obj + case isCallable(v): + h.defFn = v + default: + if obj, ok := v.(*goja.Object); ok { + h.exports = obj + } + } + } + s.created.Add(1) + return h, nil +} + +// classDetector 判断一个值是不是 class。goja 的 AssertConstructor 对普通 function +// 也返回 true,区分不了;靠规范保证的差别来判:class 的 prototype 属性不可写, +// 普通函数的可写,箭头函数和方法简写根本没有 prototype。 +var classDetector = goja.MustCompile("jscriptx:isclass", `(function (x) { + if (typeof x !== "function") { return false } + var d = Object.getOwnPropertyDescriptor(x, "prototype") + return !!d && d.writable === false +})`, true) + +func isClass(rt *goja.Runtime, v goja.Value) bool { + if _, ok := goja.AssertConstructor(v); !ok { + return false + } + dv, err := rt.RunProgram(classDetector) + if err != nil { + return false + } + detect, ok := goja.AssertFunction(dv) + if !ok { + return false + } + res, err := detect(goja.Undefined(), v) + if err != nil { + return false + } + return res.ToBoolean() +} + +func isCallable(v goja.Value) bool { + _, ok := goja.AssertFunction(v) + return ok +} + +// construct 从 Go 侧 new 一个 JS class 实例,构造参数按 goja 的规则转换。 +func construct(rt *goja.Runtime, class goja.Value, args []any) (*goja.Object, error) { + ctor, ok := goja.AssertConstructor(class) + if !ok { + return nil, fmt.Errorf("不是构造器") + } + jsArgs := make([]goja.Value, len(args)) + for i, a := range args { + jsArgs[i] = rt.ToValue(a) + } + return ctor(nil, jsArgs...) +} + +// lookup 找要调用的函数,同时给出调用时该绑定的 this。 +// +// fn 为空取默认导出本身(export default function 那种);否则找导出实例的方法, +// this 绑定到实例,这样 class 里的 this.xxx 才有意义。 +// +// 最后那层全局查找基本只是兜底:脚本经打包后是 IIFE,顶层声明进不了全局, +// 只有脚本显式往 globalThis 上挂东西时才走得到。 +func (i *vmHandle) lookup(fn string) (callable goja.Callable, fnVal goja.Value, this goja.Value, ok bool) { + if fn == DefaultFunc { + if i.defFn == nil { + return nil, nil, nil, false + } + callable, ok = goja.AssertFunction(i.defFn) + return callable, i.defFn, goja.Undefined(), ok + } + + if i.exports != nil { + // Get 会走原型链,class 方法定义在 prototype 上也找得到 + if v := i.exports.Get(fn); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) { + if callable, ok = goja.AssertFunction(v); ok { + return callable, v, i.exports, true + } + } + } + + // 静态方法挂在 class 自身上,不在实例的原型链上,所以实例那边找不到才轮到这里。 + // this 绑定到 class 本身,跟 JS 里 C.Startup() 的语义一致。 + if i.ctor != nil { + if v := i.ctor.Get(fn); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) { + if callable, ok = goja.AssertFunction(v); ok { + return callable, v, i.ctor, true + } + } + } + + v := i.rt.Get(fn) + if v == nil || goja.IsUndefined(v) || goja.IsNull(v) { + return nil, nil, nil, false + } + if callable, ok = goja.AssertFunction(v); !ok { + return nil, nil, nil, false + } + return callable, v, goja.Undefined(), true +} + +// notFoundHint 在找不到函数时给一句有用的话。最常见的原因是脚本压根没写 export: +// 打包产物是 IIFE,没有导出的顶层代码会被当死代码摇掉,什么都不剩。 +func (s *Script) notFoundHint(inst *vmHandle) string { + if inst.exports == nil && inst.defFn == nil { + return "脚本没有任何导出。打包产物是 IIFE,不写 export 的顶层代码会被当死代码摇掉;" + + "请用 export default 指明入口,或者用命名导出" + } + return "脚本里没有这个函数,或者它不是函数" +} diff --git a/static.go b/static.go new file mode 100644 index 0000000..adbbceb --- /dev/null +++ b/static.go @@ -0,0 +1,86 @@ +package jscriptx + +import "context" + +// CallStatic 调用脚本导出的 class 上的**静态方法**。 +// +// export default class PkgImportController { +// static Startup() { store.Set("pkg.registry", "https://…") } +// Execute(g) { … } +// } +// +// _, err := script.CallStatic(ctx, "Startup") +// +// 它跟 Call 有两点不同,都是"静态"这个语义要求的: +// +// - **不构造实例**:constructor 不会跑。构造函数做的是"这一次调用的准备", +// 跟脚本级的初始化无关,跑它只会白费一遍还可能有副作用。 +// - **VM 用完就丢**,不回池。静态方法一般只在脚本生命周期里跑一两次, +// 为它留一个 VM 不划算;而且它做的事通常是往 Go 侧扩展里写东西, +// 那些副作用留在 Go 那边,VM 本身没有保留的价值。 +// +// 脚本没写这个静态方法时返回的错误可以被 errors.Is(err, ErrFuncNotFound) 匹配上 +// ——生命周期钩子多半是可选的,用它来区分"没写"和"写了但炸了": +// +// if _, err := script.CallStatic(ctx, "Startup"); err != nil && +// !errors.Is(err, ErrFuncNotFound) { +// return err // 真的出错了 +// } +// +// 静态方法里能用 ctx 带进来的作用域扩展(WithScope),这正是它的用武之地: +// 把配置写进 store、往 Go 侧注册东西。但**它建的 JS 对象活不下来**—— +// VM 一丢就没了,别指望后续调用能看到。 +func (s *Script) CallStatic(ctx context.Context, fn string, args ...any) (any, error) { + if fn == DefaultFunc { + return nil, newError(KindNotFound, s.name, fn, ErrFuncNotFound, + "CallStatic 要给一个静态方法名") + } + return callAny(ctx, staticTarget{s}, fn, args) +} + +// HasStatic 判断脚本导出的 class 上有没有这个静态方法。 +// +// 它要建一个 VM 才能回答,所以别在热路径上反复调;只想"有就调"的话, +// 直接 CallStatic 然后判 ErrFuncNotFound 更省。 +func (s *Script) HasStatic(fn string) bool { + if fn == DefaultFunc { + return false + } + vm, err := s.borrowStatic(context.Background()) + if err != nil { + return false + } + _, _, _, ok := vm.lookup(fn) + return ok && vm.ctor != nil +} + +// staticTarget 让静态调用复用 invoke 那一整套(超时中断、panic 恢复、错误分类), +// 只是换一种取 VM 的方式:不构造实例,用完丢弃。 +type staticTarget struct{ s *Script } + +func (t staticTarget) owner() *Script { return t.s } + +func (t staticTarget) acquire(ctx context.Context) (*vmHandle, error) { + return t.s.borrowStatic(ctx) +} + +// finish 直接丢弃,不回池——见 CallStatic 的说明。 +func (t staticTarget) finish(*vmHandle, bool) { t.s.dropped.Add(1) } + +// borrowStatic 造一个只装了 class、没有实例的 VM。 +func (s *Script) borrowStatic(ctx context.Context) (*vmHandle, error) { + if s.closed.Load() { + return nil, newError(KindClosed, s.name, "", ErrClosed, "脚本已关闭") + } + + var extra map[string]any + if sc, ok := scopeOf(ctx); ok { + extra = sc.vmGlobals() + } + + rt, err := s.engine.newRuntime(s.name, extra) + if err != nil { + return nil, err + } + return s.load(ctx, rt, nil, true) +}