Files
jscriptx/errors.go
T
what 9b3509ae29 refactor: 清掉指向不存在的 Dispatch 的文档,删掉为它留的死导出
Dispatch 在仓库里出现 7 次,全是注释和错误文案,没有任何实现。三处错误文案
写着「需要回调语义请用 Dispatch」——使用者按这句去查会找不到东西,真正该指的
是 WithCall。caller.go 那句还指向不存在的子包 jscriptx/dispatch。

连带删掉四个为它留的导出(全仓库零调用):

  Target                   统一 Script/Instance 的接口。有未导出方法 owner(),
                           外部实现不了;也没有任何函数以它为参数或返回值
  ErrUnsupportedSignature  哨兵错误,库自己从不产生它
  KindSignature            错误分类,全仓库唯一一次出现就是它自己的声明。
                           留着会让写 switch 的人为一个永不出现的分支写代码
  OverlayLoader.Loaders    零调用的 getter,连测试都没有

另外删掉 Instance.IdleFor 和 lastUsed 字段:它是给「空闲回收」用的,而
doc.go 明确写着本库不代管实例生命周期、没有空闲回收——字段注释和包文档直接
对立。代价是每次 Call 白付两次 time.Now() + atomic store。业务侧真要自己回收,
记一个时间戳是一行的事。

caller 的示例原来拿 ErrUnsupportedSignature 当哨兵,改成自己声明一个——
回调签名的约定本来就是调用方定的,哨兵该归调用方。

验证:framework-v2 和 lx-bid 都仍能编译。
2026-09-10 15:15:10 +08:00

365 lines
11 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: 已关闭")
// 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" // 返回值/参数类型不匹配
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 的(比如 invoke 自己造的类型错误)原样返回。
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 ""
}