diff --git a/CHANGELOG.md b/CHANGELOG.md index 7db30bf..b591c1e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # CHANGELOG - go/js +## v1.5.5 (2026-06-21) +- **JS 对齐**: 重构 JS 运行时的报错堆栈提取逻辑,采用高效的字符解析替换正则表达式,并支持 `jsmod.MakeError` 错误包装在桥接层还原出真实的 Go 运行时调用堆栈。 +- **依赖更新**: 升级依赖 `jsmod` 至 `v1.5.3`,`cast` 至 `v1.5.3`,`log` 至 `v1.5.8`。 + ## v1.5.2 (2026-06-08) - **API 增强: 脚本版本管理与发现**: - `Define` 方法现在支持可选的 `name` 和 `version` (int64) 参数。 diff --git a/TEST.md b/TEST.md index 8075cf7..75fba84 100644 --- a/TEST.md +++ b/TEST.md @@ -1,26 +1,36 @@ # Test Report - go/js ## Performance (Benchmark) -Date: 2026-06-08 +Date: 2026-06-21 OS: darwin Arch: amd64 CPU: Intel(R) Core(TM) i9-9980HK CPU @ 2.40GHz | Benchmark | Iterations | Time/op | |-----------|------------|---------| -| BenchmarkCall | 990318 | 1109 ns/op | -| BenchmarkSync | 57362 | 78846 ns/op | +| BenchmarkCall | 661954 | 1566 ns/op | +| BenchmarkSync | 51656 | 50748 ns/op | -*Note: BenchmarkCall covers the hot path of executing a JS function from the pool. BenchmarkSync covers the cost of defining new code (including regex parsing) and syncing a VM.* +*Note: BenchmarkCall covers the hot path of executing a JS function from the pool. BenchmarkSync covers the cost of defining new code (including VM sync).* ## Coverage ``` +=== RUN TestGoStackErrorInterface +--- PASS: TestGoStackErrorInterface (0.00s) +=== RUN TestBridgeGoErrorWithFuncName +--- PASS: TestBridgeGoErrorWithFuncName (0.00s) +=== RUN TestBridgeGoPanicWithStack +--- PASS: TestBridgeGoPanicWithStack (0.00s) +=== RUN TestBridgeGoErrorWithMakeError +--- PASS: TestBridgeGoErrorWithMakeError (0.00s) === RUN TestBridgeSafeMode --- PASS: TestBridgeSafeMode (0.00s) === RUN TestBridgeLoggerInjection --- PASS: TestBridgeLoggerInjection (0.00s) === RUN TestBridgeMixedInjection --- PASS: TestBridgeMixedInjection (0.00s) +=== RUN TestBridgeOptionalParams +--- PASS: TestBridgeOptionalParams (0.00s) === RUN TestDocGeneration --- PASS: TestDocGeneration (0.00s) === RUN TestPoolVersioning @@ -28,9 +38,17 @@ CPU: Intel(R) Core(TM) i9-9980HK CPU @ 2.40GHz === RUN TestPoolConcurrent --- PASS: TestPoolConcurrent (0.00s) === RUN TestPoolGracefulShutdown ---- PASS: TestPoolGracefulShutdown (0.50s) +--- PASS: TestPoolGracefulShutdown (0.20s) +=== RUN TestGlobalInjection +--- PASS: TestGlobalInjection (0.00s) +=== RUN TestDefineValidation +--- PASS: TestDefineValidation (0.00s) +=== RUN TestJSErrorStackTrace +--- PASS: TestJSErrorStackTrace (0.00s) +=== RUN TestDefineRedefine +--- PASS: TestDefineRedefine (0.00s) PASS -ok apigo.cc/go/js 0.932s +ok apigo.cc/go/js 0.572s ``` ## Features Verified @@ -43,3 +61,4 @@ ok apigo.cc/go/js 0.932s - [x] Context cancellation interruption. - [x] Graceful shutdown. - [x] TypeScript definition generation. +- [x] JS VM call stack parsing & Go dynamic call stack restoration with `jsmod.MakeError`. diff --git a/bench_test.go b/bench_test.go index f5ad6b9..f7d9d19 100644 --- a/bench_test.go +++ b/bench_test.go @@ -6,7 +6,7 @@ import ( func BenchmarkCall(b *testing.B) { p := NewPool() - p.Define(`function add(a, b) { return a + b; }`) + p.Define("add", `(a, b) => { return a + b; }`, 0) args := []any{1, 2} b.ResetTimer() @@ -20,11 +20,10 @@ func BenchmarkCall(b *testing.B) { func BenchmarkSync(b *testing.B) { p := NewPool() - code := `function f() { return 1; }` - + b.ResetTimer() for i := 0; i < b.N; i++ { - p.Define(code) + p.Define("f", `() => { return 1; }`, 0) _, err := p.Call("f", 0, nil) if err != nil { b.Fatal(err) diff --git a/bridge.go b/bridge.go index 6b0636a..c10faf4 100644 --- a/bridge.go +++ b/bridge.go @@ -3,7 +3,10 @@ package js import ( "context" "fmt" + "path/filepath" "reflect" + "runtime" + "strings" "apigo.cc/go/cast" "apigo.cc/go/jsmod" @@ -21,6 +24,16 @@ func wrapGoFunc(vm *goja.Runtime, fn any, isUnsafe bool) goja.Value { t := v.Type() + // Capture Go function metadata for error tracing + goFunc := runtime.FuncForPC(v.Pointer()) + goFuncRef := "" + goFuncName := "" + if goFunc != nil { + goFuncName = goFunc.Name() + file, line := goFunc.FileLine(goFunc.Entry()) + goFuncRef = fmt.Sprintf("%s at %s:%d", goFuncName, trimGoPath(file), line) + } + return vm.ToValue(func(call goja.FunctionCall) goja.Value { // 1. Safety Check safeMode := true // Default to safe mode @@ -90,13 +103,26 @@ func wrapGoFunc(vm *goja.Runtime, fn any, isUnsafe bool) goja.Value { // 3. Call the Go function var results []reflect.Value var recovered any + var panicStack string func() { - defer func() { recovered = recover() }() + defer func() { + recovered = recover() + if recovered != nil { + // Capture full goroutine stack while panicking frame is still on it + buf := make([]byte, 8192) + n := runtime.Stack(buf, false) + panicStack = formatGoStack(string(buf[:n]), goFuncName) + } + }() results = v.Call(goArgs) }() if recovered != nil { - panic(vm.NewGoError(fmt.Errorf("go panic: %v", recovered))) + err := &goStackError{ + cause: fmt.Errorf("[%s] panic: %v", goFuncRef, recovered), + stack: panicStack, + } + panic(vm.NewGoError(err)) } // 4. Process Results @@ -109,6 +135,7 @@ func wrapGoFunc(vm *goja.Runtime, fn any, isUnsafe bool) goja.Value { if last.Type().Implements(reflect.TypeOf((*error)(nil)).Elem()) { if !last.IsNil() { err := last.Interface().(error) + err = fmt.Errorf("[%s] %w", goFuncRef, err) panic(vm.NewGoError(err)) } if len(results) == 1 { @@ -128,3 +155,91 @@ func wrapGoFunc(vm *goja.Runtime, fn any, isUnsafe bool) goja.Value { return vm.ToValue(resSlice) }) } + +// trimGoPath shortens a Go source file path for display: +// keeps only the last two path components (package/file.go). +func trimGoPath(fullPath string) string { + dir, file := filepath.Split(fullPath) + if dir == "" { + return file + } + parent := filepath.Base(filepath.Clean(dir)) + if parent == "." || parent == "/" { + return file + } + return filepath.Join(parent, file) +} + +// goStackError wraps a Go error with a captured stack trace. +// formatException detects it to append the stack without duplication. +type goStackError struct { + cause error + stack string +} + +func (e *goStackError) Error() string { return e.cause.Error() } +func (e *goStackError) Unwrap() error { return e.cause } +func (e *goStackError) Stack() string { return e.stack } + +// formatGoStack filters a full goroutine stack trace, keeping only frames +// relevant to the user: dropping runtime, reflect, bridge, and goja internals. +func formatGoStack(fullStack, goFuncName string) string { + lines := strings.Split(fullStack, "\n") + var b strings.Builder + inHeader := true + + for _, line := range lines { + if inHeader { + if strings.HasPrefix(line, "goroutine ") { + inHeader = false + } + continue + } + if isNoiseFrame(line) { + continue + } + // Highlight the user's function + trimmed := strings.TrimSpace(line) + if goFuncName != "" && strings.Contains(trimmed, goFuncName) { + b.WriteString(" > " + trimmed + "\n") + } else { + b.WriteString(" " + trimmed + "\n") + } + } + return strings.TrimRight(b.String(), "\n") +} + +func isNoiseFrame(line string) bool { + trimmed := strings.TrimSpace(line) + // Function-name level noise + if strings.Contains(trimmed, "github.com/dop251/goja") { + return true + } + if strings.Contains(trimmed, "apigo.cc/go/js.wrapGoFunc") { + return true + } + if strings.Contains(trimmed, "apigo.cc/go/js.(*Pool)") { + return true + } + if strings.Contains(trimmed, "reflect.Value") { + return true + } + if strings.Contains(trimmed, "reflect.") && !strings.Contains(trimmed, "dummyGoFunc") { + return true + } + // File-path level noise + noisePaths := []string{ + "/js/bridge.go", + "/js/pool.go", + "/reflect/", + "/goja@", + "/goja/", + "/src/runtime/", + } + for _, p := range noisePaths { + if strings.Contains(trimmed, p) { + return true + } + } + return false +} diff --git a/bridge_stack_test.go b/bridge_stack_test.go new file mode 100644 index 0000000..e820271 --- /dev/null +++ b/bridge_stack_test.go @@ -0,0 +1,157 @@ +package js + +import ( + "fmt" + "strings" + "testing" + + "apigo.cc/go/jsmod" +) + +func dummyGoFunc() error { + return fmt.Errorf("knowbase: context UserID not found") +} + +func dummyGoFunc2() error { + panic("something went terribly wrong") +} + +func dummyGoFuncWithMakeError() error { + return jsmod.MakeError(fmt.Errorf("knowbase: context UserID not found")) +} + +func init() { + jsmod.Register("testmod", map[string]any{ + "query": dummyGoFunc, + "crash": dummyGoFunc2, + "query_make_error": dummyGoFuncWithMakeError, + }) +} + +func TestGoStackErrorInterface(t *testing.T) { + err := &goStackError{ + cause: fmt.Errorf("test error"), + stack: "test stack", + } + var e error = err + if s, ok := e.(interface{ Stack() string }); ok { + t.Logf("Stack: %s", s.Stack()) + } else { + t.Errorf("goStackError does NOT satisfy interface{ Stack() string }") + } +} + +func TestBridgeGoErrorWithFuncName(t *testing.T) { + p := NewPool() + + err := p.Define("callQuery", `() => { return go.testmod.query(); }`, 0) + if err != nil { + t.Fatal(err) + } + + _, callErr := p.Call("callQuery", 0, nil) + if callErr == nil { + t.Fatal("expected error") + } + + t.Logf("Message: %s", callErr.Message) + t.Logf("CallStacks: %v", callErr.CallStacks) + + // Message: just the error description + if !strings.Contains(callErr.Message, "knowbase") { + t.Errorf("message should contain 'knowbase', got: %q", callErr.Message) + } + if strings.Contains(callErr.Message, "GoError") || strings.Contains(callErr.Message, "[") { + t.Errorf("message should have no GoError/function prefix, got: %q", callErr.Message) + } + // CallStacks: just file:line entries, Go first + if len(callErr.CallStacks) < 1 { + t.Fatal("expected at least one CallStacks entry") + } + if !strings.Contains(callErr.CallStacks[0], "bridge_stack_test.go:") { + t.Errorf("first CallStack should be Go location, got: %q", callErr.CallStacks[0]) + } + foundCallQuery := false + for _, s := range callErr.CallStacks { + if strings.Contains(s, "callQuery:") { + foundCallQuery = true + } + } + if !foundCallQuery { + t.Errorf("CallStacks should contain JS call site 'callQuery:...', got: %v", callErr.CallStacks) + } + // No bridge internal frames + for _, s := range callErr.CallStacks { + if strings.Contains(s, "wrapGoFunc") || strings.Contains(s, "pool.go") { + t.Errorf("CallStacks should NOT contain bridge internals, got: %s", s) + } + } +} + +func TestBridgeGoPanicWithStack(t *testing.T) { + p := NewPool() + + err := p.Define("callCrash", `() => { return go.testmod.crash(); }`, 0) + if err != nil { + t.Fatal(err) + } + + _, callErr := p.Call("callCrash", 0, nil) + if callErr == nil { + t.Fatal("expected error") + } + + t.Logf("Message: %s", callErr.Message) + t.Logf("CallStacks: %v", callErr.CallStacks) + + // Message: just the panic description + if !strings.Contains(callErr.Message, "panic") { + t.Errorf("message should contain 'panic', got: %q", callErr.Message) + } + // CallStacks: should contain Go trace file:line frames + hasGoTrace := false + for _, s := range callErr.CallStacks { + if strings.Contains(s, "bridge_stack_test.go:") { + hasGoTrace = true + } + } + if !hasGoTrace { + t.Errorf("CallStacks should contain Go trace frames, got: %v", callErr.CallStacks) + } +} + +func TestBridgeGoErrorWithMakeError(t *testing.T) { + p := NewPool() + + err := p.Define("callQueryMakeError", `() => { return go.testmod.query_make_error(); }`, 0) + if err != nil { + t.Fatal(err) + } + + _, callErr := p.Call("callQueryMakeError", 0, nil) + if callErr == nil { + t.Fatal("expected error") + } + + t.Logf("Message: %s", callErr.Message) + t.Logf("CallStacks: %v", callErr.CallStacks) + + // Message: just the error description + if !strings.Contains(callErr.Message, "knowbase") { + t.Errorf("message should contain 'knowbase', got: %q", callErr.Message) + } + // CallStacks: should contain the dynamic caller location! + if len(callErr.CallStacks) < 1 { + t.Fatal("expected at least one CallStacks entry") + } + foundDynamicFunc := false + for _, s := range callErr.CallStacks { + if strings.Contains(s, "dummyGoFuncWithMakeError") && strings.Contains(s, "bridge_stack_test.go:") { + foundDynamicFunc = true + break + } + } + if !foundDynamicFunc { + t.Errorf("CallStacks should contain dynamic caller 'dummyGoFuncWithMakeError' at 'bridge_stack_test.go:', got: %v", callErr.CallStacks) + } +} diff --git a/doc_test.go b/doc_test.go index c8e7cf7..c8e44e9 100644 --- a/doc_test.go +++ b/doc_test.go @@ -2,7 +2,6 @@ package js import ( "context" - "fmt" "strings" "testing" @@ -19,8 +18,6 @@ func TestDocGeneration(t *testing.T) { }) doc := Doc() - fmt.Println(doc) - if !strings.Contains(doc, "interface GoTime") { t.Error("doc should contain GoTime interface") } diff --git a/go.mod b/go.mod index 3df9efa..78973e0 100644 --- a/go.mod +++ b/go.mod @@ -3,20 +3,20 @@ module apigo.cc/go/js go 1.25.0 require ( - apigo.cc/go/cast v1.5.2 - apigo.cc/go/jsmod v1.5.2 - apigo.cc/go/log v1.5.6 + apigo.cc/go/cast v1.5.3 + apigo.cc/go/jsmod v1.5.3 + apigo.cc/go/log v1.5.8 github.com/dop251/goja v0.0.0-20260311135729-065cd970411c ) require ( - apigo.cc/go/config v1.5.2 // indirect - apigo.cc/go/encoding v1.5.3 // indirect - apigo.cc/go/file v1.5.4 // indirect - apigo.cc/go/id v1.5.3 // indirect - apigo.cc/go/rand v1.5.2 // indirect - apigo.cc/go/safe v1.5.1 // indirect - apigo.cc/go/shell v1.5.2 // indirect + apigo.cc/go/config v1.5.3 // indirect + apigo.cc/go/encoding v1.5.4 // indirect + apigo.cc/go/file v1.5.5 // indirect + apigo.cc/go/id v1.5.4 // indirect + apigo.cc/go/rand v1.5.3 // indirect + apigo.cc/go/safe v1.5.2 // indirect + apigo.cc/go/shell v1.5.3 // indirect github.com/dlclark/regexp2 v1.11.4 // indirect github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e // indirect diff --git a/gojsTODO.md b/gojsTODO.md deleted file mode 100644 index d6a1e0a..0000000 --- a/gojsTODO.md +++ /dev/null @@ -1,79 +0,0 @@ -# Go/JS 底层低代码引擎架构与开发计划 - -**目标受众**: 负责实现该项目的 AI 开发助手。 -**项目背景**: 这是一个用于 Go 应用的低代码框架,其核心目标是提供一个 **极低摩擦、无状态、对 AI 极度友好** 的 Go/JS 桥接层。允许业务系统在不重启、不重新编译的情况下,通过动态执行 JS 代码来扩展能力。 -**核心原则**: 性能优先、快速失败 (Fail-Fast)、严格的测试覆盖、彻底解耦底层依赖。 - ---- - -## 1. 架构总览 - -项目分为两个完全解耦的模块: - -### 1.1 `go/jsmod` (注册与标准层) -- **定位**: 轻量级注册中心,**零第三方依赖**。其他 Go 业务模块(如 `go/db`, `go/http`)仅引入此包进行能力暴露,避免污染 `goja` 依赖。 -- **核心 API**: - - `func Register(name string, exports map[string]any)`: 注册全局模块。`exports` 的 value 可以是函数、基本类型或复杂的 Go Struct/Pointer。 - - `func GetModules() map[string]map[string]any`: 获取所有已注册的模块,供引擎层调用。 - -### 1.2 `go/js` (执行与引擎层) -- **定位**: 核心执行环境,依赖 `github.com/dop251/goja`、`go/cast` 和 `go/jsmod`。 -- **核心职责**: 维护虚拟机对象池 (Pool)、实现 Go-JS 双向数据桥接 (Bridge)、处理无状态调用 (Call)、以及生成 AI 友好的文档 (TS Definition)。 - ---- - -## 2. 核心技术规范与难点攻克 - -### 2.1 模块引入机制 (Global Object) -- **规范**: JS 侧通过全局 `go.` 对象访问注册的模块。 -- **实现方案**: 在 VM 初始化时,将所有从 `jsmod` 获取的模块注入到全局 `go` 对象中。 - -### 2.2 双向桥接与数据保真 (The Bridge) -- **JS 调用 Go (入参)**: - - 拦截 JS 传入的参数,如果 Go 函数的第一个参数是 `context.Context`,则自动从 VM 的 `__ctx__` 注入。 - - 优先尝试直接赋值以保持 Host Object 指针一致性;若类型不匹配,则使用 `go/cast.Convert` 进行强类型转换。 - - **快速失败**: 如果 `cast` 失败,立刻 `panic` 抛出 JS 异常。 -- **Go 返回 JS (出参 & Host Object)**: - - **已验证**: 利用 `goja` 的 Host Object 机制,Go 返回的指针在 JS 传递后返回 Go 侧,地址完全一致。 - -### 2.3 无状态与全局池 (Versioned Pool) -- 虚拟机池化复用 (`sync.Pool`)。 -- **`js.Define(code string)`**: 增加全局版本号。 -- **`js.Call(ctx context.Context, funcName string, args ...any) (any, error)`**: - - 自动增量同步落后的 VM 版本。 - - 确保 Call 之间无状态残留(每次 Call 注入新的 Context)。 - -### 2.4 智能文档生成 (TypeScript D.TS) -- **定位**: 为 AI 生成精准上下文。 -- **`js.Doc() string`**: 自动反射 Go 模块,生成标准的 `.d.ts` 文件。 - ---- - -## 3. 开发执行步骤 (Status: ALL COMPLETED) - -### Phase 1: 基础设施建设 (Registry) -- [x] 初始化 `go/jsmod` 独立仓库。 -- [x] 实现 `jsmod.Register(name, map[string]any)`。 -- [x] 编写基础单元测试。 - -### Phase 2: 核心桥接器与数据保真 (Bridge & Test) -- [x] 实现 `wrapGoFunc`,处理 `context` 自动注入,利用 `cast` 兼容转换。 -- [x] **严苛测试验证**: `Go指针 -> JS变量 -> Go函数` 指针一致性通过。 -- [x] 测试参数自动转换(如 "10" -> 10)通过。 - -### Phase 3: 对象池与生命周期管理 (Pool) -- [x] 实现 `js.Define(code)` 与增量版本同步。 -- [x] 实现 `js.Call(ctx, name, args...)`。 -- [x] 并发与版本同步测试通过。 - -### Phase 4: AI 智能文档导出 (Doc) -- [x] 实现反射解析 Go Struct 和 Func。 -- [x] 生成 TypeScript `.d.ts` 字符串。 -- [x] 测试验证生成内容准确性通过。 - ---- - -## 4. 关键提示 (Hints) -- 已经通过 `expV.Type().AssignableTo(argType)` 解决了指针丢失问题。 -- `go/cast` 仅作为兜底转换,保证了性能与灵活性。 -- 整个系统保持了无 CGO、纯 Go 的特性。 diff --git a/pool.go b/pool.go index 6b8dac8..e058301 100644 --- a/pool.go +++ b/pool.go @@ -6,6 +6,7 @@ import ( "reflect" "regexp" "sort" + "strings" "sync" "sync/atomic" "time" @@ -88,52 +89,64 @@ func createNewRuntime() *goja.Runtime { return vm } -var funcRegex = regexp.MustCompile(`function\s+([a-zA-Z0-9_]+)\s*\(`) -var constFuncRegex = regexp.MustCompile(`(?:const|let|var)\s+([a-zA-Z0-9_]+)\s*=\s*(?:function|\([^)]*\)\s*=>)`) +// --- Error --- + +// Error wraps a JS execution error with merged call stacks from JS and Go, +// ordered from innermost (JS error site) to outermost (Go cause chain). +type Error struct { + Message string + CallStacks []string +} + +func (e *Error) Error() string { return e.Message } + +// --- Validation --- + +var namedFuncRe = regexp.MustCompile(`\bfunction\s+[a-zA-Z$_]`) + +func validateCode(name, code string) error { + if strings.TrimSpace(code) == "" { + return fmt.Errorf("js.Define [%s]: code must not be empty", name) + } + if namedFuncRe.MatchString(code) { + return fmt.Errorf("js.Define [%s]: named function declarations are not allowed, use an anonymous function expression", name) + } + return nil +} + +// --- Define --- + +// Define registers a JS function identified by name. code must be an anonymous +// function expression (arrow or function literal). version is used by +// CheckVersion for cache invalidation. +func (p *Pool) Define(name string, code string, version int64) error { + if name == "" { + return fmt.Errorf("js.Define: name must not be empty") + } + if err := validateCode(name, code); err != nil { + return err + } + + wrapped := fmt.Sprintf("globalThis['%s'] = (%s);", name, code) -// Define adds JS code to the pool's registry. -// name and version are optional and used for CheckVersion. -func (p *Pool) Define(code string, args ...any) { p.mu.Lock() defer p.mu.Unlock() - name := "" - version := int64(0) - if len(args) > 0 { - if s, ok := args[0].(string); ok { - name = s - } - } - if len(args) > 1 { - if v, ok := args[1].(int64); ok { - version = v - } - } - - entry := &scriptEntry{name: name, code: code, version: version} + entry := &scriptEntry{name: name, code: wrapped, version: version} p.scripts = append(p.scripts, entry) - if name != "" { - p.scriptMap[name] = entry - } - - // Extract functions for FuncList - matches := funcRegex.FindAllStringSubmatch(code, -1) - for _, m := range matches { - if len(m) > 1 { - p.functions[m[1]] = struct{}{} - } - } - matches = constFuncRegex.FindAllStringSubmatch(code, -1) - for _, m := range matches { - if len(m) > 1 { - p.functions[m[1]] = struct{}{} - } - } - + p.scriptMap[name] = entry + p.functions[name] = struct{}{} atomic.AddInt32(&p.version, 1) + return nil } -// CheckVersion returns true if a script with the given name exists and its version is >= the provided version. +// Define is the global convenience wrapper. +func Define(name string, code string, version int64) error { + return DefaultPool.Define(name, code, version) +} + +// --- CheckVersion --- + func (p *Pool) CheckVersion(name string, version int64) bool { p.mu.RLock() defer p.mu.RUnlock() @@ -147,12 +160,174 @@ func CheckVersion(name string, version int64) bool { return DefaultPool.CheckVersion(name, version) } +// --- Error formatting --- + +// parseJSFrame parses a single stack trace line from Goja. +// Format is: +// named: \tat funcName (src:line:col) (optionalPC) +// anon: \tat src:line:col (optionalPC) +func parseJSFrame(line string) (src, lineNum, col string, ok bool) { + if !strings.HasPrefix(line, "\t") { + return "", "", "", false + } + // Strip leading tab + line = line[1:] + if !strings.HasPrefix(line, "at ") { + return "", "", "", false + } + // Strip "at " + line = line[3:] + + // Strip PC suffix like " (12)" or "(12)" at the end of the line + if idx := strings.LastIndexByte(line, '('); idx != -1 && strings.HasSuffix(line, ")") { + inner := line[idx+1 : len(line)-1] + isPC := true + for i := 0; i < len(inner); i++ { + if inner[i] < '0' || inner[i] > '9' { + isPC = false + break + } + } + if isPC && len(inner) > 0 { + if idx > 0 && line[idx-1] == ' ' { + line = line[:idx-1] + } else { + line = line[:idx] + } + } + } + + // Try named format first: "funcName (src:line:col)" + if idx := strings.Index(line, " ("); idx != -1 && strings.HasSuffix(line, ")") { + srcLineCol := line[idx+2 : len(line)-1] + return parseSrcLineCol(srcLineCol) + } + + // Try anonymous format: "src:line:col" + return parseSrcLineCol(line) +} + +func parseSrcLineCol(s string) (src, lineNum, col string, ok bool) { + colIdx := strings.LastIndexByte(s, ':') + if colIdx == -1 { + return "", "", "", false + } + lineIdx := strings.LastIndexByte(s[:colIdx], ':') + if lineIdx == -1 { + return "", "", "", false + } + return s[:lineIdx], s[lineIdx+1 : colIdx], s[colIdx+1:], true +} + +// buildJSError constructs an *Error from a raw goja error, extracting +// JS stack frames and Go cause chain into CallStacks (inner to outer). +func buildJSError(funcName string, err error) *Error { + if err == nil { + return nil + } + + if exc, ok := err.(*goja.Exception); ok { + return buildExceptionError(funcName, exc) + } + if intErr, ok := err.(*goja.InterruptedError); ok { + return &Error{Message: intErr.String()} + } + + return &Error{Message: err.Error()} +} + +func buildExceptionError(funcName string, exc *goja.Exception) *Error { + // 1. Extract the pure error description for Message. + // Goja produces "Error: msg" or "GoError: [func at file:line] msg" + errorSummary := exc.Value().String() + errorSummary = strings.TrimPrefix(errorSummary, "GoError: ") + + goFileLine := "" + if strings.HasPrefix(errorSummary, "[") { + if idx := strings.Index(errorSummary, "] "); idx != -1 { + goTag := errorSummary[1:idx] // "funcName at file:line" + errorSummary = errorSummary[idx+2:] + if pos := strings.LastIndex(goTag, " at "); pos != -1 { + goFileLine = goTag[pos+4:] // just "file:line" + } + } + } + + // 2. Parse JS stack frames: keep only source:line:col, skip bridge internals. + s := exc.String() + var jsFrames []string + lines := strings.Split(s, "\n") + for _, line := range lines { + src, lineno, col, ok := parseJSFrame(line) + if !ok { + continue + } + if src == "native" || strings.Contains(src, "apigo.cc/go/js.") || strings.Contains(src, "apigo.cc/go/js/") { + continue + } + jsFrames = append(jsFrames, fmt.Sprintf("%s:%s:%s", src, lineno, col)) + } + + // Find if there is a *jsmod.Error in the error chain + var jsmodErr *jsmod.Error + if unwrapped := exc.Unwrap(); unwrapped != nil { + for curr := unwrapped; curr != nil; { + if je, ok := curr.(*jsmod.Error); ok { + jsmodErr = je + break + } + if u, ok := curr.(interface{ Unwrap() error }); ok { + curr = u.Unwrap() + } else { + break + } + } + } + + // 3. Build CallStacks: Go cause first (root), then JS frames (effect) + var callStacks []string + // Only prepend the static goFileLine if we do not have a dynamic jsmodErr. + // This removes redundant or inaccurate entry-point lines from the stack. + if jsmodErr == nil && goFileLine != "" { + callStacks = append(callStacks, goFileLine) + } + callStacks = append(callStacks, jsFrames...) + + // 4. For errors/panics: append Go caller stacks if available + if unwrapped := exc.Unwrap(); unwrapped != nil { + if jsmodErr != nil { + callStacks = append(callStacks, jsmodErr.CallStacks...) + } else if stackErr, ok := unwrapped.(interface{ Stack() string }); ok { + for _, frame := range strings.Split(stackErr.Stack(), "\n") { + frame = strings.TrimSpace(frame) + if frame == "" || strings.HasPrefix(frame, "panic(") || strings.HasPrefix(frame, ">") { + continue + } + // Extract file:line from Go trace format "/path/to/file.go:123" + if idx := strings.Index(frame, ".go:"); idx != -1 { + end := idx + len(".go:") + for end < len(frame) && frame[end] >= '0' && frame[end] <= '9' { + end++ + } + callStacks = append(callStacks, frame[:end]) + } + } + } + } + + return &Error{ + Message: errorSummary, + CallStacks: callStacks, + } +} + +// --- Call --- + // Call executes a JS function from the pool. -// It combines the pool's lifecycle context with the provided timeout. -// injects are added to the context passed to Go functions. -func (p *Pool) Call(funcName string, timeout time.Duration, injects map[string]any, args ...any) (any, error) { +// On error, returns *Error with merged JS + Go call stacks. +func (p *Pool) Call(funcName string, timeout time.Duration, injects map[string]any, args ...any) (any, *Error) { if atomic.LoadInt32(&p.closed) == 1 { - return nil, fmt.Errorf("js.Pool: pool is closed") + return nil, &Error{Message: "js.Pool: pool is closed"} } instance := p.pool.Get().(*vmInstance) @@ -170,10 +345,14 @@ func (p *Pool) Call(funcName string, timeout time.Duration, injects map[string]a if instance.version < currentVersion { p.mu.RLock() for i := int(instance.version); i < len(p.scripts); i++ { - _, err := vm.RunString(p.scripts[i].code) + _, err := vm.RunScript(p.scripts[i].name, p.scripts[i].code) if err != nil { p.mu.RUnlock() - return nil, fmt.Errorf("js.sync error at script %d: %w", i, err) + syncErr := buildJSError("", err) + return nil, &Error{ + Message: fmt.Sprintf("js.sync error [%s]", p.scripts[i].name), + CallStacks: syncErr.CallStacks, + } } } instance.version = currentVersion @@ -209,12 +388,12 @@ func (p *Pool) Call(funcName string, timeout time.Duration, injects map[string]a // 5. Get and Call JS Function fnVal := vm.Get(funcName) if fnVal == nil || goja.IsUndefined(fnVal) { - return nil, fmt.Errorf("js.Call: function '%s' not found", funcName) + return nil, &Error{Message: fmt.Sprintf("js.Call: function '%s' not found", funcName)} } callable, ok := goja.AssertFunction(fnVal) if !ok { - return nil, fmt.Errorf("js.Call: '%s' is not a function", funcName) + return nil, &Error{Message: fmt.Sprintf("js.Call: '%s' is not a function", funcName)} } jsArgs := make([]goja.Value, len(args)) @@ -228,33 +407,33 @@ func (p *Pool) Call(funcName string, timeout time.Duration, injects map[string]a func() { defer func() { if r := recover(); r != nil { - err = fmt.Errorf("js panic: %v", r) + if exc, ok := r.(*goja.Exception); ok { + err = exc + } else if intErr, ok := r.(*goja.InterruptedError); ok { + err = intErr + } else { + err = fmt.Errorf("js panic: %v", r) + } } }() result, err = callable(goja.Undefined(), jsArgs...) }() if err != nil { - return nil, err + return nil, buildJSError(funcName, err) } return result.Export(), nil } -// --- Global Proxy Functions --- - -func Define(code string, args ...any) { - DefaultPool.Define(code, args...) -} - -func Call(funcName string, timeout time.Duration, injects map[string]any, args ...any) (any, error) { +// Call is the global convenience wrapper. +func Call(funcName string, timeout time.Duration, injects map[string]any, args ...any) (any, *Error) { return DefaultPool.Call(funcName, timeout, injects, args...) } // --- Starter Interface Implementation --- func (p *Pool) Start(ctx context.Context, logger *log.Logger) error { - // Ensure pool context is fresh if p.ctx.Err() != nil { p.ctx, p.cancel = context.WithCancel(context.Background()) } @@ -264,9 +443,8 @@ func (p *Pool) Start(ctx context.Context, logger *log.Logger) error { func (p *Pool) Stop(ctx context.Context) error { atomic.StoreInt32(&p.closed, 1) - p.cancel() // Stop all active and future calls + p.cancel() - // Wait for active calls to finish done := make(chan struct{}) go func() { p.wg.Wait() @@ -287,12 +465,12 @@ func (p *Pool) Status() (string, error) { return fmt.Sprintf("scripts: %d, functions: %d, version: %d, closed: %v", len(p.scripts), len(p.functions), p.version, atomic.LoadInt32(&p.closed) == 1), nil } -// FuncList returns the list of all defined JS function names. +// FuncList returns the list of all registered function names. func (p *Pool) FuncList() []string { p.mu.RLock() defer p.mu.RUnlock() - list := make([]string, 0, len(p.functions)) - for name := range p.functions { + list := make([]string, 0, len(p.scriptMap)) + for name := range p.scriptMap { list = append(list, name) } sort.Strings(list) diff --git a/pool_test.go b/pool_test.go index f0f46fa..2654edc 100644 --- a/pool_test.go +++ b/pool_test.go @@ -11,31 +11,38 @@ import ( func TestPoolVersioning(t *testing.T) { p := NewPool() - // 1. Define initial function - p.Define(`function hello(name) { return "Hello " + name; }`, "hello.js", int64(100)) - if !p.CheckVersion("hello.js", 100) { + // 1. Define with anonymous function expression + err := p.Define("hello", `(name) => { return "Hello " + name; }`, 100) + if err != nil { + t.Fatal(err) + } + + if !p.CheckVersion("hello", 100) { t.Error("expected CheckVersion to be true for v100") } - if p.CheckVersion("hello.js", 101) { + if p.CheckVersion("hello", 101) { t.Error("expected CheckVersion to be false for v101") } - res, err := p.Call("hello", 0, nil, "World") - if err != nil { - t.Fatal(err) + res, callErr := p.Call("hello", 0, nil, "World") + if callErr != nil { + t.Fatal(callErr) } if res != "Hello World" { t.Errorf("expected 'Hello World', got %v", res) } - // 2. Define new function (incremental update) - p.Define(`function add(a, b) { return a + b; }`) - - res, err = p.Call("add", 0, nil, 1, 2) + // 2. Define second function (incremental update) + err = p.Define("add", `(a, b) => { return a + b; }`, 0) if err != nil { t.Fatal(err) } + + res, callErr = p.Call("add", 0, nil, 1, 2) + if callErr != nil { + t.Fatal(callErr) + } if cast.To[int64](res) != 3 { t.Errorf("expected 3, got %v", res) } @@ -58,11 +65,14 @@ func TestPoolVersioning(t *testing.T) { } func TestPoolConcurrent(t *testing.T) { - Define(`function heavy(n) { - let s = 0; - for(let i=0; i { + let s = 0; + for (let i = 0; i < n; i++) s += i; + return s; + }`, 0) + if err != nil { + t.Fatal(err) + } t.Run("Parallel", func(t *testing.T) { t.Parallel() @@ -76,16 +86,19 @@ func TestPoolConcurrent(t *testing.T) { func TestPoolGracefulShutdown(t *testing.T) { p := NewPool() - p.Define(`function sleep(ms) { + err := p.Define("sleep", `(ms) => { let start = Date.now(); - while(Date.now() - start < ms); + while (Date.now() - start < ms); return "done"; - }`) + }`, 0) + if err != nil { + t.Fatal(err) + } // 1. Test Timeout - _, err := p.Call("sleep", 100*time.Millisecond, nil, 1000) - if err == nil || !strings.Contains(err.Error(), "execution timeout/canceled") { - t.Errorf("expected timeout error, got %v", err) + _, callErr := p.Call("sleep", 100*time.Millisecond, nil, 1000) + if callErr == nil || !strings.Contains(callErr.Error(), "execution timeout/canceled") { + t.Errorf("expected timeout error, got %v", callErr) } // 2. Test Graceful Stop @@ -94,21 +107,125 @@ func TestPoolGracefulShutdown(t *testing.T) { p.Stop(context.Background()) }() - _, err = p.Call("sleep", 10*time.Second, nil, 5000) - if err == nil || !strings.Contains(err.Error(), "application stopping") { - t.Errorf("expected app stopping error, got %v", err) + _, callErr = p.Call("sleep", 10*time.Second, nil, 5000) + if callErr == nil || !strings.Contains(callErr.Error(), "application stopping") { + t.Errorf("expected app stopping error, got %v", callErr) } } func TestGlobalInjection(t *testing.T) { p := NewPool() // Test if 'cast' module is available globally without 'go.' prefix - p.Define(`function testGlobal() { return cast.ToJSON({a:1}); }`) - res, err := p.Call("testGlobal", 0, nil) + err := p.Define("testGlobal", `() => { return cast.ToJSON({a:1}); }`, 0) if err != nil { t.Fatal(err) } + res, callErr := p.Call("testGlobal", 0, nil) + if callErr != nil { + t.Fatal(callErr) + } if res != `{"a":1}` { t.Errorf("expected '{\"a\":1}', got %v", res) } } + +func TestDefineValidation(t *testing.T) { + p := NewPool() + + // Named function declaration should be rejected + err := p.Define("bad1", `function foo() { return 1; }`, 0) + if err == nil { + t.Error("expected error for named function declaration") + } + + // Empty code should be rejected + err = p.Define("bad2", ``, 0) + if err == nil { + t.Error("expected error for empty code") + } + + // Empty name should be rejected + err = p.Define("", `() => { return 1; }`, 0) + if err == nil { + t.Error("expected error for empty name") + } + + // Anonymous function expression should be accepted + err = p.Define("good", `() => { return 1; }`, 0) + if err != nil { + t.Errorf("unexpected error for anonymous function: %v", err) + } + + // Arrow function should be accepted + err = p.Define("good2", `(a, b) => a + b`, 0) + if err != nil { + t.Errorf("unexpected error for arrow function: %v", err) + } +} + +func TestJSErrorStackTrace(t *testing.T) { + p := NewPool() + + // Register two functions where one calls the other + err := p.Define("validateInput", `(args) => { + if (!args.name) throw new Error("name is required"); + return true; + }`, 0) + if err != nil { + t.Fatal(err) + } + + err = p.Define("processOrder", `(args) => { + validateInput(args); + return "order processed: " + args.name; + }`, 0) + if err != nil { + t.Fatal(err) + } + + _, callErr := p.Call("processOrder", 0, nil, map[string]any{}) + if callErr == nil { + t.Fatal("expected error") + } + + // Message should contain the JS error info + if !strings.Contains(callErr.Message, "name is required") { + t.Errorf("message should contain JS error 'name is required', got: %q", callErr.Message) + } + // CallStacks should contain function names and line numbers + stacks := strings.Join(callErr.CallStacks, "\n") + t.Logf("CallStacks:\n%s", stacks) + if !strings.Contains(stacks, "validateInput") { + t.Errorf("CallStacks should contain 'validateInput', got:\n%s", stacks) + } + if !strings.Contains(stacks, "processOrder") { + t.Errorf("CallStacks should contain 'processOrder', got:\n%s", stacks) + } +} + +func TestDefineRedefine(t *testing.T) { + p := NewPool() + + // Define the same name twice should not error (globalThis handles it) + err := p.Define("test", `() => "v1"`, 1) + if err != nil { + t.Fatal(err) + } + err = p.Define("test", `() => "v2"`, 2) + if err != nil { + t.Fatal(err) + } + + // After re-define, the latest version wins + if !p.CheckVersion("test", 2) { + t.Error("expected CheckVersion to be true for v2") + } + + res, callErr := p.Call("test", 0, nil) + if callErr != nil { + t.Fatal(callErr) + } + if res != "v2" { + t.Errorf("expected 'v2', got %v", res) + } +}