feat(js): 优化 JS 运行时报错堆栈提取及支持还原 Go 动态堆栈(by AI)
This commit is contained in:
parent
4d19170498
commit
5ade0d0981
@ -1,5 +1,9 @@
|
|||||||
# CHANGELOG - go/js
|
# 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)
|
## v1.5.2 (2026-06-08)
|
||||||
- **API 增强: 脚本版本管理与发现**:
|
- **API 增强: 脚本版本管理与发现**:
|
||||||
- `Define` 方法现在支持可选的 `name` 和 `version` (int64) 参数。
|
- `Define` 方法现在支持可选的 `name` 和 `version` (int64) 参数。
|
||||||
|
|||||||
31
TEST.md
31
TEST.md
@ -1,26 +1,36 @@
|
|||||||
# Test Report - go/js
|
# Test Report - go/js
|
||||||
|
|
||||||
## Performance (Benchmark)
|
## Performance (Benchmark)
|
||||||
Date: 2026-06-08
|
Date: 2026-06-21
|
||||||
OS: darwin
|
OS: darwin
|
||||||
Arch: amd64
|
Arch: amd64
|
||||||
CPU: Intel(R) Core(TM) i9-9980HK CPU @ 2.40GHz
|
CPU: Intel(R) Core(TM) i9-9980HK CPU @ 2.40GHz
|
||||||
|
|
||||||
| Benchmark | Iterations | Time/op |
|
| Benchmark | Iterations | Time/op |
|
||||||
|-----------|------------|---------|
|
|-----------|------------|---------|
|
||||||
| BenchmarkCall | 990318 | 1109 ns/op |
|
| BenchmarkCall | 661954 | 1566 ns/op |
|
||||||
| BenchmarkSync | 57362 | 78846 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
|
## 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
|
=== RUN TestBridgeSafeMode
|
||||||
--- PASS: TestBridgeSafeMode (0.00s)
|
--- PASS: TestBridgeSafeMode (0.00s)
|
||||||
=== RUN TestBridgeLoggerInjection
|
=== RUN TestBridgeLoggerInjection
|
||||||
--- PASS: TestBridgeLoggerInjection (0.00s)
|
--- PASS: TestBridgeLoggerInjection (0.00s)
|
||||||
=== RUN TestBridgeMixedInjection
|
=== RUN TestBridgeMixedInjection
|
||||||
--- PASS: TestBridgeMixedInjection (0.00s)
|
--- PASS: TestBridgeMixedInjection (0.00s)
|
||||||
|
=== RUN TestBridgeOptionalParams
|
||||||
|
--- PASS: TestBridgeOptionalParams (0.00s)
|
||||||
=== RUN TestDocGeneration
|
=== RUN TestDocGeneration
|
||||||
--- PASS: TestDocGeneration (0.00s)
|
--- PASS: TestDocGeneration (0.00s)
|
||||||
=== RUN TestPoolVersioning
|
=== RUN TestPoolVersioning
|
||||||
@ -28,9 +38,17 @@ CPU: Intel(R) Core(TM) i9-9980HK CPU @ 2.40GHz
|
|||||||
=== RUN TestPoolConcurrent
|
=== RUN TestPoolConcurrent
|
||||||
--- PASS: TestPoolConcurrent (0.00s)
|
--- PASS: TestPoolConcurrent (0.00s)
|
||||||
=== RUN TestPoolGracefulShutdown
|
=== 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
|
PASS
|
||||||
ok apigo.cc/go/js 0.932s
|
ok apigo.cc/go/js 0.572s
|
||||||
```
|
```
|
||||||
|
|
||||||
## Features Verified
|
## Features Verified
|
||||||
@ -43,3 +61,4 @@ ok apigo.cc/go/js 0.932s
|
|||||||
- [x] Context cancellation interruption.
|
- [x] Context cancellation interruption.
|
||||||
- [x] Graceful shutdown.
|
- [x] Graceful shutdown.
|
||||||
- [x] TypeScript definition generation.
|
- [x] TypeScript definition generation.
|
||||||
|
- [x] JS VM call stack parsing & Go dynamic call stack restoration with `jsmod.MakeError`.
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import (
|
|||||||
|
|
||||||
func BenchmarkCall(b *testing.B) {
|
func BenchmarkCall(b *testing.B) {
|
||||||
p := NewPool()
|
p := NewPool()
|
||||||
p.Define(`function add(a, b) { return a + b; }`)
|
p.Define("add", `(a, b) => { return a + b; }`, 0)
|
||||||
args := []any{1, 2}
|
args := []any{1, 2}
|
||||||
|
|
||||||
b.ResetTimer()
|
b.ResetTimer()
|
||||||
@ -20,11 +20,10 @@ func BenchmarkCall(b *testing.B) {
|
|||||||
|
|
||||||
func BenchmarkSync(b *testing.B) {
|
func BenchmarkSync(b *testing.B) {
|
||||||
p := NewPool()
|
p := NewPool()
|
||||||
code := `function f() { return 1; }`
|
|
||||||
|
|
||||||
b.ResetTimer()
|
b.ResetTimer()
|
||||||
for i := 0; i < b.N; i++ {
|
for i := 0; i < b.N; i++ {
|
||||||
p.Define(code)
|
p.Define("f", `() => { return 1; }`, 0)
|
||||||
_, err := p.Call("f", 0, nil)
|
_, err := p.Call("f", 0, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
b.Fatal(err)
|
b.Fatal(err)
|
||||||
|
|||||||
119
bridge.go
119
bridge.go
@ -3,7 +3,10 @@ package js
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"apigo.cc/go/cast"
|
"apigo.cc/go/cast"
|
||||||
"apigo.cc/go/jsmod"
|
"apigo.cc/go/jsmod"
|
||||||
@ -21,6 +24,16 @@ func wrapGoFunc(vm *goja.Runtime, fn any, isUnsafe bool) goja.Value {
|
|||||||
|
|
||||||
t := v.Type()
|
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 {
|
return vm.ToValue(func(call goja.FunctionCall) goja.Value {
|
||||||
// 1. Safety Check
|
// 1. Safety Check
|
||||||
safeMode := true // Default to safe mode
|
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
|
// 3. Call the Go function
|
||||||
var results []reflect.Value
|
var results []reflect.Value
|
||||||
var recovered any
|
var recovered any
|
||||||
|
var panicStack string
|
||||||
func() {
|
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)
|
results = v.Call(goArgs)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
if recovered != nil {
|
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
|
// 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.Type().Implements(reflect.TypeOf((*error)(nil)).Elem()) {
|
||||||
if !last.IsNil() {
|
if !last.IsNil() {
|
||||||
err := last.Interface().(error)
|
err := last.Interface().(error)
|
||||||
|
err = fmt.Errorf("[%s] %w", goFuncRef, err)
|
||||||
panic(vm.NewGoError(err))
|
panic(vm.NewGoError(err))
|
||||||
}
|
}
|
||||||
if len(results) == 1 {
|
if len(results) == 1 {
|
||||||
@ -128,3 +155,91 @@ func wrapGoFunc(vm *goja.Runtime, fn any, isUnsafe bool) goja.Value {
|
|||||||
return vm.ToValue(resSlice)
|
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
157
bridge_stack_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,7 +2,6 @@ package js
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@ -19,8 +18,6 @@ func TestDocGeneration(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
doc := Doc()
|
doc := Doc()
|
||||||
fmt.Println(doc)
|
|
||||||
|
|
||||||
if !strings.Contains(doc, "interface GoTime") {
|
if !strings.Contains(doc, "interface GoTime") {
|
||||||
t.Error("doc should contain GoTime interface")
|
t.Error("doc should contain GoTime interface")
|
||||||
}
|
}
|
||||||
|
|||||||
20
go.mod
20
go.mod
@ -3,20 +3,20 @@ module apigo.cc/go/js
|
|||||||
go 1.25.0
|
go 1.25.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
apigo.cc/go/cast v1.5.2
|
apigo.cc/go/cast v1.5.3
|
||||||
apigo.cc/go/jsmod v1.5.2
|
apigo.cc/go/jsmod v1.5.3
|
||||||
apigo.cc/go/log v1.5.6
|
apigo.cc/go/log v1.5.8
|
||||||
github.com/dop251/goja v0.0.0-20260311135729-065cd970411c
|
github.com/dop251/goja v0.0.0-20260311135729-065cd970411c
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
apigo.cc/go/config v1.5.2 // indirect
|
apigo.cc/go/config v1.5.3 // indirect
|
||||||
apigo.cc/go/encoding v1.5.3 // indirect
|
apigo.cc/go/encoding v1.5.4 // indirect
|
||||||
apigo.cc/go/file v1.5.4 // indirect
|
apigo.cc/go/file v1.5.5 // indirect
|
||||||
apigo.cc/go/id v1.5.3 // indirect
|
apigo.cc/go/id v1.5.4 // indirect
|
||||||
apigo.cc/go/rand v1.5.2 // indirect
|
apigo.cc/go/rand v1.5.3 // indirect
|
||||||
apigo.cc/go/safe v1.5.1 // indirect
|
apigo.cc/go/safe v1.5.2 // indirect
|
||||||
apigo.cc/go/shell v1.5.2 // indirect
|
apigo.cc/go/shell v1.5.3 // indirect
|
||||||
github.com/dlclark/regexp2 v1.11.4 // indirect
|
github.com/dlclark/regexp2 v1.11.4 // indirect
|
||||||
github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect
|
github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect
|
||||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e // indirect
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e // indirect
|
||||||
|
|||||||
79
gojsTODO.md
79
gojsTODO.md
@ -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 的特性。
|
|
||||||
296
pool.go
296
pool.go
@ -6,6 +6,7 @@ import (
|
|||||||
"reflect"
|
"reflect"
|
||||||
"regexp"
|
"regexp"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
@ -88,52 +89,64 @@ func createNewRuntime() *goja.Runtime {
|
|||||||
return vm
|
return vm
|
||||||
}
|
}
|
||||||
|
|
||||||
var funcRegex = regexp.MustCompile(`function\s+([a-zA-Z0-9_]+)\s*\(`)
|
// --- Error ---
|
||||||
var constFuncRegex = regexp.MustCompile(`(?:const|let|var)\s+([a-zA-Z0-9_]+)\s*=\s*(?:function|\([^)]*\)\s*=>)`)
|
|
||||||
|
// 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()
|
p.mu.Lock()
|
||||||
defer p.mu.Unlock()
|
defer p.mu.Unlock()
|
||||||
|
|
||||||
name := ""
|
entry := &scriptEntry{name: name, code: wrapped, version: version}
|
||||||
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}
|
|
||||||
p.scripts = append(p.scripts, entry)
|
p.scripts = append(p.scripts, entry)
|
||||||
if name != "" {
|
|
||||||
p.scriptMap[name] = entry
|
p.scriptMap[name] = entry
|
||||||
}
|
p.functions[name] = struct{}{}
|
||||||
|
|
||||||
// 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{}{}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
atomic.AddInt32(&p.version, 1)
|
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 {
|
func (p *Pool) CheckVersion(name string, version int64) bool {
|
||||||
p.mu.RLock()
|
p.mu.RLock()
|
||||||
defer p.mu.RUnlock()
|
defer p.mu.RUnlock()
|
||||||
@ -147,12 +160,174 @@ func CheckVersion(name string, version int64) bool {
|
|||||||
return DefaultPool.CheckVersion(name, version)
|
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.
|
// Call executes a JS function from the pool.
|
||||||
// It combines the pool's lifecycle context with the provided timeout.
|
// On error, returns *Error with merged JS + Go call stacks.
|
||||||
// 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) {
|
||||||
func (p *Pool) Call(funcName string, timeout time.Duration, injects map[string]any, args ...any) (any, error) {
|
|
||||||
if atomic.LoadInt32(&p.closed) == 1 {
|
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)
|
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 {
|
if instance.version < currentVersion {
|
||||||
p.mu.RLock()
|
p.mu.RLock()
|
||||||
for i := int(instance.version); i < len(p.scripts); i++ {
|
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 {
|
if err != nil {
|
||||||
p.mu.RUnlock()
|
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
|
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
|
// 5. Get and Call JS Function
|
||||||
fnVal := vm.Get(funcName)
|
fnVal := vm.Get(funcName)
|
||||||
if fnVal == nil || goja.IsUndefined(fnVal) {
|
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)
|
callable, ok := goja.AssertFunction(fnVal)
|
||||||
if !ok {
|
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))
|
jsArgs := make([]goja.Value, len(args))
|
||||||
@ -228,33 +407,33 @@ func (p *Pool) Call(funcName string, timeout time.Duration, injects map[string]a
|
|||||||
func() {
|
func() {
|
||||||
defer func() {
|
defer func() {
|
||||||
if r := recover(); r != nil {
|
if r := recover(); r != nil {
|
||||||
|
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)
|
err = fmt.Errorf("js panic: %v", r)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}()
|
}()
|
||||||
result, err = callable(goja.Undefined(), jsArgs...)
|
result, err = callable(goja.Undefined(), jsArgs...)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, buildJSError(funcName, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return result.Export(), nil
|
return result.Export(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Global Proxy Functions ---
|
// Call is the global convenience wrapper.
|
||||||
|
func Call(funcName string, timeout time.Duration, injects map[string]any, args ...any) (any, *Error) {
|
||||||
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) {
|
|
||||||
return DefaultPool.Call(funcName, timeout, injects, args...)
|
return DefaultPool.Call(funcName, timeout, injects, args...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Starter Interface Implementation ---
|
// --- Starter Interface Implementation ---
|
||||||
|
|
||||||
func (p *Pool) Start(ctx context.Context, logger *log.Logger) error {
|
func (p *Pool) Start(ctx context.Context, logger *log.Logger) error {
|
||||||
// Ensure pool context is fresh
|
|
||||||
if p.ctx.Err() != nil {
|
if p.ctx.Err() != nil {
|
||||||
p.ctx, p.cancel = context.WithCancel(context.Background())
|
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 {
|
func (p *Pool) Stop(ctx context.Context) error {
|
||||||
atomic.StoreInt32(&p.closed, 1)
|
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{})
|
done := make(chan struct{})
|
||||||
go func() {
|
go func() {
|
||||||
p.wg.Wait()
|
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
|
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 {
|
func (p *Pool) FuncList() []string {
|
||||||
p.mu.RLock()
|
p.mu.RLock()
|
||||||
defer p.mu.RUnlock()
|
defer p.mu.RUnlock()
|
||||||
list := make([]string, 0, len(p.functions))
|
list := make([]string, 0, len(p.scriptMap))
|
||||||
for name := range p.functions {
|
for name := range p.scriptMap {
|
||||||
list = append(list, name)
|
list = append(list, name)
|
||||||
}
|
}
|
||||||
sort.Strings(list)
|
sort.Strings(list)
|
||||||
|
|||||||
167
pool_test.go
167
pool_test.go
@ -11,31 +11,38 @@ import (
|
|||||||
|
|
||||||
func TestPoolVersioning(t *testing.T) {
|
func TestPoolVersioning(t *testing.T) {
|
||||||
p := NewPool()
|
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")
|
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")
|
t.Error("expected CheckVersion to be false for v101")
|
||||||
}
|
}
|
||||||
|
|
||||||
res, err := p.Call("hello", 0, nil, "World")
|
res, callErr := p.Call("hello", 0, nil, "World")
|
||||||
if err != nil {
|
if callErr != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(callErr)
|
||||||
}
|
}
|
||||||
if res != "Hello World" {
|
if res != "Hello World" {
|
||||||
t.Errorf("expected 'Hello World', got %v", res)
|
t.Errorf("expected 'Hello World', got %v", res)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Define new function (incremental update)
|
// 2. Define second function (incremental update)
|
||||||
p.Define(`function add(a, b) { return a + b; }`)
|
err = p.Define("add", `(a, b) => { return a + b; }`, 0)
|
||||||
|
|
||||||
res, err = p.Call("add", 0, nil, 1, 2)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
res, callErr = p.Call("add", 0, nil, 1, 2)
|
||||||
|
if callErr != nil {
|
||||||
|
t.Fatal(callErr)
|
||||||
|
}
|
||||||
if cast.To[int64](res) != 3 {
|
if cast.To[int64](res) != 3 {
|
||||||
t.Errorf("expected 3, got %v", res)
|
t.Errorf("expected 3, got %v", res)
|
||||||
}
|
}
|
||||||
@ -58,11 +65,14 @@ func TestPoolVersioning(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestPoolConcurrent(t *testing.T) {
|
func TestPoolConcurrent(t *testing.T) {
|
||||||
Define(`function heavy(n) {
|
err := Define("heavy", `(n) => {
|
||||||
let s = 0;
|
let s = 0;
|
||||||
for(let i=0; i<n; i++) s += i;
|
for (let i = 0; i < n; i++) s += i;
|
||||||
return s;
|
return s;
|
||||||
}`)
|
}`, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
t.Run("Parallel", func(t *testing.T) {
|
t.Run("Parallel", func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
@ -76,16 +86,19 @@ func TestPoolConcurrent(t *testing.T) {
|
|||||||
|
|
||||||
func TestPoolGracefulShutdown(t *testing.T) {
|
func TestPoolGracefulShutdown(t *testing.T) {
|
||||||
p := NewPool()
|
p := NewPool()
|
||||||
p.Define(`function sleep(ms) {
|
err := p.Define("sleep", `(ms) => {
|
||||||
let start = Date.now();
|
let start = Date.now();
|
||||||
while(Date.now() - start < ms);
|
while (Date.now() - start < ms);
|
||||||
return "done";
|
return "done";
|
||||||
}`)
|
}`, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
// 1. Test Timeout
|
// 1. Test Timeout
|
||||||
_, err := p.Call("sleep", 100*time.Millisecond, nil, 1000)
|
_, callErr := p.Call("sleep", 100*time.Millisecond, nil, 1000)
|
||||||
if err == nil || !strings.Contains(err.Error(), "execution timeout/canceled") {
|
if callErr == nil || !strings.Contains(callErr.Error(), "execution timeout/canceled") {
|
||||||
t.Errorf("expected timeout error, got %v", err)
|
t.Errorf("expected timeout error, got %v", callErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Test Graceful Stop
|
// 2. Test Graceful Stop
|
||||||
@ -94,21 +107,125 @@ func TestPoolGracefulShutdown(t *testing.T) {
|
|||||||
p.Stop(context.Background())
|
p.Stop(context.Background())
|
||||||
}()
|
}()
|
||||||
|
|
||||||
_, err = p.Call("sleep", 10*time.Second, nil, 5000)
|
_, callErr = p.Call("sleep", 10*time.Second, nil, 5000)
|
||||||
if err == nil || !strings.Contains(err.Error(), "application stopping") {
|
if callErr == nil || !strings.Contains(callErr.Error(), "application stopping") {
|
||||||
t.Errorf("expected app stopping error, got %v", err)
|
t.Errorf("expected app stopping error, got %v", callErr)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGlobalInjection(t *testing.T) {
|
func TestGlobalInjection(t *testing.T) {
|
||||||
p := NewPool()
|
p := NewPool()
|
||||||
// Test if 'cast' module is available globally without 'go.' prefix
|
// Test if 'cast' module is available globally without 'go.' prefix
|
||||||
p.Define(`function testGlobal() { return cast.ToJSON({a:1}); }`)
|
err := p.Define("testGlobal", `() => { return cast.ToJSON({a:1}); }`, 0)
|
||||||
res, err := p.Call("testGlobal", 0, nil)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
res, callErr := p.Call("testGlobal", 0, nil)
|
||||||
|
if callErr != nil {
|
||||||
|
t.Fatal(callErr)
|
||||||
|
}
|
||||||
if res != `{"a":1}` {
|
if res != `{"a":1}` {
|
||||||
t.Errorf("expected '{\"a\":1}', got %v", res)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user