Files
jscriptx/errors.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

368 lines
12 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 (
"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 = "<anonymous>"
}
return fmt.Sprintf("%s (%s:%d:%d)", name, f.Source, f.Line, f.Column)
}
// Error 是本库对外抛出的统一错误类型,带齐排查现场需要的上下文:
// 哪个脚本、哪个函数、脚本里哪一行、当时的调用参数是什么。
// 它实现了 slog.LogValuerslog.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 ""
}