Compare commits

..

2 Commits

10 changed files with 699 additions and 192 deletions

View File

@ -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) 参数。

31
TEST.md
View File

@ -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`.

View File

@ -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)

119
bridge.go
View File

@ -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 := "<unknown>"
goFuncName := "<unknown>"
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
}

157
bridge_stack_test.go Normal file
View File

@ -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)
}
}

View File

@ -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")
}

20
go.mod
View File

@ -3,20 +3,20 @@ module apigo.cc/go/js
go 1.25.0
require (
apigo.cc/go/cast v1.5.0
apigo.cc/go/jsmod v1.5.0
apigo.cc/go/log v1.5.5
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.0 // indirect
apigo.cc/go/encoding v1.5.0 // indirect
apigo.cc/go/file v1.5.0 // indirect
apigo.cc/go/id v1.5.0 // indirect
apigo.cc/go/rand v1.5.0 // indirect
apigo.cc/go/safe v1.5.0 // indirect
apigo.cc/go/shell v1.5.0 // 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

View File

@ -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.<moduleName>` 对象访问注册的模块。
- **实现方案**: 在 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 的特性。

300
pool.go
View File

@ -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)

View File

@ -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) {
err := Define("heavy", `(n) => {
let s = 0;
for(let i=0; i<n; i++) s += i;
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)
}
}