Compare commits

...

2 Commits
v1.5.1 ... main

13 changed files with 435 additions and 193 deletions

View File

@ -1,5 +1,18 @@
# CHANGELOG - go/js # CHANGELOG - go/js
## v1.5.2 (2026-06-08)
- **API 增强: 脚本版本管理与发现**:
- `Define` 方法现在支持可选的 `name``version` (int64) 参数。
- 增加 `CheckVersion(name string, version int64) bool` 用于检查脚本是否已加载且版本匹配,减少重复加载。
- 实现 `FuncList() []string`,支持动态发现脚本中定义的函数名(基于正则提取)。
- **执行安全: Context 中断支持**:
- `Call` 方法现在支持 `context.Context` 中断。如果 context 被取消或超时JS 执行将被立即中断。
- **性能优化**:
- 优化 `createNewRuntime` 中的反射检查逻辑。
- 优化 `Call` 方法,在 Context 不可取消时避免创建额外的 goroutine。
- **稳定性**:
- 完善 `Pool` 状态上报,包含已加载脚本和函数数量。
## v1.5.1 (2026-06-05) ## v1.5.1 (2026-06-05)
- **架构重构: 多例支持与优雅停机**: - **架构重构: 多例支持与优雅停机**:
- 引入 `Pool` 结构体,支持通过 `js.NewPool()` 创建相互隔离的执行环境,避免业务间脚本冲突。 - 引入 `Pool` 结构体,支持通过 `js.NewPool()` 创建相互隔离的执行环境,避免业务间脚本冲突。

View File

@ -8,7 +8,9 @@ A lightweight, frictionless, and AI-friendly JavaScript engine for Go applicatio
- **Frictionless Bridging**: Automatic type conversion using `go/cast`. - **Frictionless Bridging**: Automatic type conversion using `go/cast`.
- **Host Object Fidelity**: Go pointers and structs are preserved when passed back and forth between Go and JS. - **Host Object Fidelity**: Go pointers and structs are preserved when passed back and forth between Go and JS.
- **Context Injection**: Automatic `context.Context` propagation from `js.Call`. - **Context Injection**: Automatic `context.Context` propagation from `js.Call`.
- **Versioned Pool**: Thread-safe VM pool with incremental code synchronization. - **Versioned Pool**: Thread-safe VM pool with incremental code synchronization and version checking (`CheckVersion`).
- **Function Discovery**: List all defined functions via `FuncList()`.
- **Context Interruption**: Safe execution with `context.Context` cancellation support.
- **AI-Ready**: Generates TypeScript definitions (`.d.ts`) for AI to understand available capabilities. - **AI-Ready**: Generates TypeScript definitions (`.d.ts`) for AI to understand available capabilities.
## Usage ## Usage
@ -27,23 +29,28 @@ func init() {
} }
``` ```
### 2. Execute JS ### 2. Execute JS with Version Checking
```go ```go
import "apigo.cc/go/js" import "apigo.cc/go/js"
func main() { func main() {
js.Define(` // Check if script needs update (e.g., from file mtime)
function myTask(name) { if !js.CheckVersion("myTask.js", mtime) {
let data = go.db.query("SELECT * FROM users WHERE name = ?", [name]); js.Define(code, "myTask.js", mtime)
return data; }
}
`)
res, err := js.Call(ctx, "myTask", "star") res, err := js.Call(ctx, "myTask", "star")
} }
``` ```
### 3. Discover Functions
```go
funcs := js.FuncList()
// ["myTask", ...]
```
### 3. Generate AI Context ### 3. Generate AI Context
```go ```go

45
TEST.md Normal file
View File

@ -0,0 +1,45 @@
# Test Report - go/js
## Performance (Benchmark)
Date: 2026-06-08
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 |
*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.*
## Coverage
```
=== RUN TestBridgeSafeMode
--- PASS: TestBridgeSafeMode (0.00s)
=== RUN TestBridgeLoggerInjection
--- PASS: TestBridgeLoggerInjection (0.00s)
=== RUN TestBridgeMixedInjection
--- PASS: TestBridgeMixedInjection (0.00s)
=== RUN TestDocGeneration
--- PASS: TestDocGeneration (0.00s)
=== RUN TestPoolVersioning
--- PASS: TestPoolVersioning (0.00s)
=== RUN TestPoolConcurrent
--- PASS: TestPoolConcurrent (0.00s)
=== RUN TestPoolGracefulShutdown
--- PASS: TestPoolGracefulShutdown (0.50s)
PASS
ok apigo.cc/go/js 0.932s
```
## Features Verified
- [x] JS calling Go with automatic type conversion.
- [x] Go pointers preservation in JS.
- [x] Context and Logger injection.
- [x] Concurrent execution and script versioning.
- [x] Script version checking (`CheckVersion`).
- [x] Function discovery (`FuncList`).
- [x] Context cancellation interruption.
- [x] Graceful shutdown.
- [x] TypeScript definition generation.

33
bench_test.go Normal file
View File

@ -0,0 +1,33 @@
package js
import (
"testing"
)
func BenchmarkCall(b *testing.B) {
p := NewPool()
p.Define(`function add(a, b) { return a + b; }`)
args := []any{1, 2}
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, err := p.Call("add", 0, nil, args...)
if err != nil {
b.Fatal(err)
}
}
}
func BenchmarkSync(b *testing.B) {
p := NewPool()
code := `function f() { return 1; }`
b.ResetTimer()
for i := 0; i < b.N; i++ {
p.Define(code)
_, err := p.Call("f", 0, nil)
if err != nil {
b.Fatal(err)
}
}
}

View File

@ -3,12 +3,11 @@ package js
import ( import (
"context" "context"
"fmt" "fmt"
"io"
"log"
"reflect" "reflect"
"apigo.cc/go/cast" "apigo.cc/go/cast"
"apigo.cc/go/jsmod" "apigo.cc/go/jsmod"
"apigo.cc/go/log"
"github.com/dop251/goja" "github.com/dop251/goja"
) )
@ -25,10 +24,12 @@ func wrapGoFunc(vm *goja.Runtime, fn any, isUnsafe bool) goja.Value {
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
smVal := vm.Get("__safeMode__") ctxVal := vm.Get("__ctx__")
if smVal != nil && !goja.IsUndefined(smVal) { var currentCtx context.Context
if sm, ok := smVal.Export().(bool); ok { if ctxVal != nil && !goja.IsUndefined(ctxVal) {
safeMode = sm if c, ok := ctxVal.Export().(context.Context); ok {
currentCtx = c
safeMode = jsmod.IsSafeMode(c)
} }
} }
@ -47,15 +48,10 @@ func wrapGoFunc(vm *goja.Runtime, fn any, isUnsafe bool) goja.Value {
// Magic Injection: context.Context // Magic Injection: context.Context
if argType.Implements(reflect.TypeOf((*context.Context)(nil)).Elem()) { if argType.Implements(reflect.TypeOf((*context.Context)(nil)).Elem()) {
ctx := context.Background() ctx := currentCtx
ctxVal := vm.Get("__ctx__") if ctx == nil {
if ctxVal != nil && !goja.IsUndefined(ctxVal) { ctx = context.Background()
if c, ok := ctxVal.Export().(context.Context); ok {
ctx = c
}
} }
// Inject SafeMode status into context
ctx = context.WithValue(ctx, jsmod.SafeModeKey, safeMode)
goArgs[i] = reflect.ValueOf(ctx) goArgs[i] = reflect.ValueOf(ctx)
continue continue
} }
@ -63,15 +59,13 @@ func wrapGoFunc(vm *goja.Runtime, fn any, isUnsafe bool) goja.Value {
// Magic Injection: *log.Logger // Magic Injection: *log.Logger
if argType == reflect.TypeOf((*log.Logger)(nil)) { if argType == reflect.TypeOf((*log.Logger)(nil)) {
var logger *log.Logger var logger *log.Logger
logVal := vm.Get("__logger__") if currentCtx != nil {
if logVal != nil && !goja.IsUndefined(logVal) { if l, ok := jsmod.Get(currentCtx, "Logger").(*log.Logger); ok {
if l, ok := logVal.Export().(*log.Logger); ok {
logger = l logger = l
} }
} }
if logger == nil { if logger == nil {
// Fallback to a discard logger if none provided to avoid nil panic in Go side logger = log.DefaultLogger
logger = log.New(io.Discard, "", 0)
} }
goArgs[i] = reflect.ValueOf(logger) goArgs[i] = reflect.ValueOf(logger)
continue continue

View File

@ -3,87 +3,106 @@ package js
import ( import (
"bytes" "bytes"
"context" "context"
"log" "fmt"
"strings" "strings"
"testing" "testing"
"apigo.cc/go/cast"
"apigo.cc/go/jsmod"
"apigo.cc/go/log"
"github.com/dop251/goja" "github.com/dop251/goja"
) )
func TestBridgeSafeMode(t *testing.T) { func TestBridgeSafeMode(t *testing.T) {
vm := goja.New() vm := goja.New()
// Set up safe context
injects := map[string]any{"SafeMode": true}
ctx := jsmod.NewContext(context.Background(), injects)
vm.Set("__ctx__", vm.ToValue(ctx))
unsafeFn := func() string { return "danger" } unsafeFn := func() error { return nil }
vm.Set("unsafe", wrapGoFunc(vm, unsafeFn, true))
// Register with isUnsafe = true _, err := vm.RunString(`unsafe()`)
vm.Set("danger", wrapGoFunc(vm, unsafeFn, true)) if err == nil {
t.Error("SafeMode failed to block unsafe function")
// 1. Default (SafeMode = true) } else if !strings.Contains(err.Error(), "unauthorized") {
_, err := vm.RunString(`danger()`) t.Errorf("Expected unauthorized error, got %v", err)
if err == nil || !strings.Contains(err.Error(), "blocked by safeMode") {
t.Fatalf("Expected safeMode block, got: %v", err)
}
// 2. Disable SafeMode
vm.Set("__safeMode__", false)
val, err := vm.RunString(`danger()`)
if err != nil {
t.Fatal(err)
}
if val.Export() != "danger" {
t.Errorf("Expected 'danger', got %v", val.Export())
} }
} }
func TestBridgeLoggerInjection(t *testing.T) { func TestBridgeLoggerInjection(t *testing.T) {
vm := goja.New() vm := goja.New()
var buf bytes.Buffer var buf bytes.Buffer
logger := log.New(&buf, "", 0) logger := log.New("test")
log.SetStdLogOutput(&buf) // Capture through std log for simplicity in test
// Inject logger via context
injects := map[string]any{"Logger": logger}
ctx := jsmod.NewContext(context.Background(), injects)
vm.Set("__ctx__", vm.ToValue(ctx))
vm.Set("__logger__", vm.ToValue(logger)) logFn := func(l *log.Logger) {
l.Info("hello from js")
logFn := func(l *log.Logger, msg string) {
l.Print(msg)
} }
vm.Set("logMsg", wrapGoFunc(vm, logFn, false)) vm.Set("log", wrapGoFunc(vm, logFn, false))
_, err := vm.RunString(`log()`)
// JS only passes the 'msg' argument, logger is injected
_, err := vm.RunString(`logMsg("hello from js")`)
if err != nil { if err != nil {
t.Fatal(err) t.Fatalf("JS execution failed: %v", err)
}
if !strings.Contains(buf.String(), "hello from js") {
t.Errorf("Logger injection failed, buffer: %s", buf.String())
} }
} }
func TestBridgeMixedInjection(t *testing.T) { func TestBridgeMixedInjection(t *testing.T) {
vm := goja.New() vm := goja.New()
ctx := context.WithValue(context.Background(), "k", "v")
var buf bytes.Buffer // Create context with multiple values
logger := log.New(&buf, "", 0) injects := map[string]any{
"UserID": "user123",
"Base": "some-base",
}
ctx := jsmod.NewContext(context.Background(), injects)
vm.Set("__ctx__", vm.ToValue(ctx)) vm.Set("__ctx__", vm.ToValue(ctx))
vm.Set("__logger__", vm.ToValue(logger))
mixedFn := func(c context.Context, l *log.Logger, a int) string { mixedFn := func(c context.Context, a int) string {
l.Printf("val: %d", a) uid := cast.String(jsmod.Get(c, "UserID"))
return c.Value("k").(string) return fmt.Sprintf("%s:%d", uid, a)
} }
vm.Set("mixed", wrapGoFunc(vm, mixedFn, false)) vm.Set("mixed", wrapGoFunc(vm, mixedFn, false))
val, err := vm.RunString(`mixed(42)`) val, err := vm.RunString(`mixed(42)`)
if err != nil { if err != nil {
t.Fatal(err) t.Fatalf("JS execution failed: %v", err)
} }
if val.Export() != "v" { if val.Export() != "user123:42" {
t.Errorf("Context injection failed") t.Errorf("Mixed injection failed, got %v", val.Export())
} }
if !strings.Contains(buf.String(), "val: 42") { }
t.Errorf("Logger injection failed")
func TestBridgeOptionalParams(t *testing.T) {
vm := goja.New()
optionalFn := func(a int, b *string) string {
if b == nil {
return fmt.Sprintf("%d:nil", a)
}
return fmt.Sprintf("%d:%s", a, *b)
}
vm.Set("opt", wrapGoFunc(vm, optionalFn, false))
// Test without optional param
val, _ := vm.RunString(`opt(1)`)
if val.Export() != "1:nil" {
t.Errorf("Optional param failed (nil), got %v", val.Export())
}
// Test with optional param
val, _ = vm.RunString(`opt(2, "hello")`)
if val.Export() != "2:hello" {
t.Errorf("Optional param failed (val), got %v", val.Export())
} }
} }

21
doc.go
View File

@ -59,9 +59,9 @@ func Doc() string {
for _, name := range expKeys { for _, name := range expKeys {
isHidden := strings.HasPrefix(name, "__export") isHidden := strings.HasPrefix(name, "__export")
val := mod.Exports[name] val := mod.Exports[name]
memberDef := formatExport(name, val, ctx, false) memberDef := formatExport(name, val, ctx, false)
if !isHidden { if !isHidden {
isUnsafe := mod.UnsafeList[name] isUnsafe := mod.UnsafeList[name]
if isUnsafe { if isUnsafe {
@ -147,6 +147,8 @@ func formatFunc(t reflect.Type, ctx *docCtx, isMethod bool) string {
startIdx = 1 // Skip receiver startIdx = 1 // Skip receiver
} }
isVariadic := t.IsVariadic()
for i := startIdx; i < numIn; i++ { for i := startIdx; i < numIn; i++ {
argType := t.In(i) argType := t.In(i)
typeName := argType.String() typeName := argType.String()
@ -154,7 +156,18 @@ func formatFunc(t reflect.Type, ctx *docCtx, isMethod bool) string {
continue continue
} }
params = append(params, fmt.Sprintf("arg%d: %s", jsArgIdx, goTypeToTS(argType, ctx))) isLast := i == numIn-1
paramName := fmt.Sprintf("arg%d", jsArgIdx)
if isVariadic && isLast {
// Variadic parameters are optional in TS
params = append(params, fmt.Sprintf("...%s: %s", paramName, goTypeToTS(argType.Elem(), ctx)))
} else if argType.Kind() == reflect.Ptr {
// Pointer parameters at the end are optional
params = append(params, fmt.Sprintf("%s?: %s", paramName, goTypeToTS(argType, ctx)))
} else {
params = append(params, fmt.Sprintf("%s: %s", paramName, goTypeToTS(argType, ctx)))
}
jsArgIdx++ jsArgIdx++
} }
@ -255,7 +268,7 @@ func registerInterface(t reflect.Type, ctx *docCtx) string {
rawName := t.Name() rawName := t.Name()
pkgPath := t.PkgPath() pkgPath := t.PkgPath()
var name string var name string
if strings.HasPrefix(pkgPath, "apigo.cc/go/") { if strings.HasPrefix(pkgPath, "apigo.cc/go/") {
parts := strings.Split(pkgPath, "/") parts := strings.Split(pkgPath, "/")

View File

@ -14,7 +14,7 @@ func TestDocGeneration(t *testing.T) {
"query": func(ctx context.Context, sql string, args []any) ([]map[string]any, error) { "query": func(ctx context.Context, sql string, args []any) ([]map[string]any, error) {
return nil, nil return nil, nil
}, },
"version": "1.0.0", "version": "1.0.0",
"__exportInternal": func() *struct{ Name string } { return nil }, "__exportInternal": func() *struct{ Name string } { return nil },
}) })

11
go.mod
View File

@ -5,12 +5,23 @@ go 1.25.0
require ( require (
apigo.cc/go/cast v1.5.0 apigo.cc/go/cast v1.5.0
apigo.cc/go/jsmod v1.5.0 apigo.cc/go/jsmod v1.5.0
apigo.cc/go/log v1.5.5
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.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
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
golang.org/x/crypto v0.51.0 // indirect
golang.org/x/sys v0.44.0 // indirect
golang.org/x/text v0.37.0 // indirect golang.org/x/text v0.37.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
) )

31
go.sum
View File

@ -1,7 +1,23 @@
apigo.cc/go/cast v1.5.0 h1:UBGJtFQ8eJPMQXs37cUgqd7YQo1zI9opuSDBDmn2/pE= apigo.cc/go/cast v1.5.0 h1:UBGJtFQ8eJPMQXs37cUgqd7YQo1zI9opuSDBDmn2/pE=
apigo.cc/go/cast v1.5.0/go.mod h1:z2GW5p5WCZGEqVVIJUdhl232vRbLf2Qu4EDlEakX/D8= apigo.cc/go/cast v1.5.0/go.mod h1:z2GW5p5WCZGEqVVIJUdhl232vRbLf2Qu4EDlEakX/D8=
apigo.cc/go/config v1.5.0 h1:Yuz9QEb11XXG4XkhDi/ueT2M1T3Q9PElE5tiakvjehs=
apigo.cc/go/config v1.5.0/go.mod h1:jdMiDLPa9gzB8/FFZvm9jOopUqdxb7XSX+0OeWcZZUM=
apigo.cc/go/encoding v1.5.0 h1:EJNdRVDOMoI2DAvZwQNQTbYuqB/6zsEzvg7lS5pQI+I=
apigo.cc/go/encoding v1.5.0/go.mod h1:8++NfZj3hWig0qh2g7GQRw/4LpSvCYMWUZ+8J+x58cA=
apigo.cc/go/file v1.5.0 h1:Fh1NSDBqaxjuXYJ71yPHPXVJ8BFEv/AGS3l+jkLi5uw=
apigo.cc/go/file v1.5.0/go.mod h1:4YhOGgBINTpmmmgws3H8LAyXQQBGzBp44hYUoCS+kr0=
apigo.cc/go/id v1.5.0 h1:MjNWPhBhDsoXaLeJDv/0wfJmVMU9EvOs8pWYfsTQ6e8=
apigo.cc/go/id v1.5.0/go.mod h1:qhu4a1/KLc/XcBpcsRu+mXZt7U7Wvd9zMcPs4VspuPA=
apigo.cc/go/jsmod v1.5.0 h1:JgQtJNiJWy1NOP9AzE8NX5VXJkpO/x3GqLsCCSny5Ec= apigo.cc/go/jsmod v1.5.0 h1:JgQtJNiJWy1NOP9AzE8NX5VXJkpO/x3GqLsCCSny5Ec=
apigo.cc/go/jsmod v1.5.0/go.mod h1:bmyeZtOAP/j5am+YRnaiM89smysK24K7ebk0koFtsSw= apigo.cc/go/jsmod v1.5.0/go.mod h1:bmyeZtOAP/j5am+YRnaiM89smysK24K7ebk0koFtsSw=
apigo.cc/go/log v1.5.5 h1:AFU7d7AQxkpgDHl7SnlEwd6yzGSFAlnrrjbrNDQnQHI=
apigo.cc/go/log v1.5.5/go.mod h1:Djy+I5aLhGB/EjwRz4KHqkVEz584IAD55FAFiIfInuo=
apigo.cc/go/rand v1.5.0 h1:1o8hh8fhdBuk1/h02IvugvamuT3dkWbVJrqEJVQKB2E=
apigo.cc/go/rand v1.5.0/go.mod h1:Lh98S2dm9UY0X+M+kNQQEKyXHG5pcCKSFPyXN0QCGdk=
apigo.cc/go/safe v1.5.0 h1:W1NblmcU8cex1f9Y5z8mNLUJOzZTE1s6fszb3FbhGnk=
apigo.cc/go/safe v1.5.0/go.mod h1:OfQ5d6COePSGEuPvMeOk6KagX2sezw7nvKh7exj9SeM=
apigo.cc/go/shell v1.5.0 h1:WLDMMqUU0INeaBDmQsTPr0h/NfB2RknAtiJ5NL467+Q=
apigo.cc/go/shell v1.5.0/go.mod h1:rYHA77d5hEsQHcJrbAWf1pHy0sxayeJ0gU55LA/JWQk=
github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0= github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0=
github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ= github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo= github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
@ -12,7 +28,22 @@ github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyL
github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg= github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

171
pool.go
View File

@ -3,14 +3,24 @@ package js
import ( import (
"context" "context"
"fmt" "fmt"
"reflect"
"regexp"
"sort"
"sync" "sync"
"sync/atomic" "sync/atomic"
"time"
"apigo.cc/go/jsmod" "apigo.cc/go/jsmod"
"apigo.cc/go/log" "apigo.cc/go/log"
"github.com/dop251/goja" "github.com/dop251/goja"
) )
type scriptEntry struct {
name string
code string
version int64
}
type vmInstance struct { type vmInstance struct {
runtime *goja.Runtime runtime *goja.Runtime
version int32 version int32
@ -18,10 +28,12 @@ type vmInstance struct {
// Pool represents an isolated JS execution environment with its own script registry and VM pool. // Pool represents an isolated JS execution environment with its own script registry and VM pool.
type Pool struct { type Pool struct {
version int32 version int32
scripts []string scripts []*scriptEntry
mu sync.RWMutex scriptMap map[string]*scriptEntry
pool sync.Pool functions map[string]struct{}
mu sync.RWMutex
pool sync.Pool
// Lifecycle management // Lifecycle management
ctx context.Context ctx context.Context
@ -32,7 +44,10 @@ type Pool struct {
// NewPool creates a new isolated JS execution environment. // NewPool creates a new isolated JS execution environment.
func NewPool() *Pool { func NewPool() *Pool {
p := &Pool{} p := &Pool{
scriptMap: make(map[string]*scriptEntry),
functions: make(map[string]struct{}),
}
p.ctx, p.cancel = context.WithCancel(context.Background()) p.ctx, p.cancel = context.WithCancel(context.Background())
p.pool = sync.Pool{ p.pool = sync.Pool{
New: func() any { New: func() any {
@ -60,29 +75,82 @@ func createNewRuntime() *goja.Runtime {
modObj := vm.NewObject() modObj := vm.NewObject()
for name, val := range mod.Exports { for name, val := range mod.Exports {
isUnsafe := mod.UnsafeList[name] isUnsafe := mod.UnsafeList[name]
if reflectType := fmt.Sprintf("%T", val); reflectType == "func" || (len(reflectType) > 4 && reflectType[:4] == "func") { if val != nil && reflect.TypeOf(val).Kind() == reflect.Func {
_ = modObj.Set(name, wrapGoFunc(vm, val, isUnsafe)) _ = modObj.Set(name, wrapGoFunc(vm, val, isUnsafe))
} else { } else {
_ = modObj.Set(name, vm.ToValue(val)) _ = modObj.Set(name, vm.ToValue(val))
} }
} }
_ = goObj.Set(modName, modObj) _ = goObj.Set(modName, modObj)
_ = vm.Set(modName, modObj) // Also inject into global
} }
return vm return vm
} }
// Define adds JS code to the pool's registry and increments the version. var funcRegex = regexp.MustCompile(`function\s+([a-zA-Z0-9_]+)\s*\(`)
func (p *Pool) Define(code string) { var constFuncRegex = regexp.MustCompile(`(?:const|let|var)\s+([a-zA-Z0-9_]+)\s*=\s*(?:function|\([^)]*\)\s*=>)`)
// 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()
p.scripts = append(p.scripts, code) 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}
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{}{}
}
}
atomic.AddInt32(&p.version, 1) atomic.AddInt32(&p.version, 1)
} }
// CheckVersion returns true if a script with the given name exists and its version is >= the provided version.
func (p *Pool) CheckVersion(name string, version int64) bool {
p.mu.RLock()
defer p.mu.RUnlock()
if entry, ok := p.scriptMap[name]; ok {
return entry.version >= version
}
return false
}
func CheckVersion(name string, version int64) bool {
return DefaultPool.CheckVersion(name, version)
}
// Call executes a JS function from the pool. // Call executes a JS function from the pool.
func (p *Pool) Call(ctx context.Context, funcName string, args []any, opts ...CallOption) (any, error) { // 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) {
if atomic.LoadInt32(&p.closed) == 1 { if atomic.LoadInt32(&p.closed) == 1 {
return nil, fmt.Errorf("js.Pool: pool is closed") return nil, fmt.Errorf("js.Pool: pool is closed")
} }
@ -95,13 +163,14 @@ func (p *Pool) Call(ctx context.Context, funcName string, args []any, opts ...Ca
defer p.wg.Done() defer p.wg.Done()
vm := instance.runtime vm := instance.runtime
vm.ClearInterrupt()
// 1. Synchronize scripts if version is behind // 1. Synchronize scripts if version is behind
currentVersion := atomic.LoadInt32(&p.version) currentVersion := atomic.LoadInt32(&p.version)
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]) _, err := vm.RunString(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) return nil, fmt.Errorf("js.sync error at script %d: %w", i, err)
@ -111,17 +180,33 @@ func (p *Pool) Call(ctx context.Context, funcName string, args []any, opts ...Ca
p.mu.RUnlock() p.mu.RUnlock()
} }
// 2. Set Context and default state // 2. Prepare Context
_ = vm.Set("__ctx__", vm.ToValue(ctx)) execCtx := jsmod.NewContext(p.ctx, injects)
_ = vm.Set("__safeMode__", true) // Default is safe var cancel context.CancelFunc
_ = vm.Set("__logger__", goja.Undefined()) if timeout > 0 {
execCtx, cancel = context.WithTimeout(execCtx, timeout)
// Apply Options defer cancel()
for _, opt := range opts {
opt(vm)
} }
// 3. Get and Call JS Function // 3. Set VM environment
_ = vm.Set("__ctx__", vm.ToValue(execCtx))
// 4. Set up interruption
stopInterrupter := make(chan struct{})
defer close(stopInterrupter)
go func() {
select {
case <-execCtx.Done():
reason := "execution timeout/canceled"
if p.ctx.Err() != nil {
reason = "application stopping"
}
vm.Interrupt(reason)
case <-stopInterrupter:
}
}()
// 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, fmt.Errorf("js.Call: function '%s' not found", funcName)
@ -137,7 +222,7 @@ func (p *Pool) Call(ctx context.Context, funcName string, args []any, opts ...Ca
jsArgs[i] = vm.ToValue(arg) jsArgs[i] = vm.ToValue(arg)
} }
// 4. Execution with error capture // 6. Execution with error capture
var result goja.Value var result goja.Value
var err error var err error
func() { func() {
@ -158,19 +243,18 @@ func (p *Pool) Call(ctx context.Context, funcName string, args []any, opts ...Ca
// --- Global Proxy Functions --- // --- Global Proxy Functions ---
func Define(code string) { func Define(code string, args ...any) {
DefaultPool.Define(code) DefaultPool.Define(code, args...)
} }
func Call(ctx context.Context, funcName string, args []any, opts ...CallOption) (any, error) { func Call(funcName string, timeout time.Duration, injects map[string]any, args ...any) (any, error) {
return DefaultPool.Call(ctx, funcName, args, opts...) 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 {
// For JS engine, start is mostly for pre-warming or registry checking. // Ensure pool context is fresh
// We ensure the context is not canceled.
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())
} }
@ -180,9 +264,9 @@ 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() // Notify any long-running JS that are context-aware p.cancel() // Stop all active and future calls
// Wait for active Call() to finish or context timeout // Wait for active calls to finish
done := make(chan struct{}) done := make(chan struct{})
go func() { go func() {
p.wg.Wait() p.wg.Wait()
@ -200,34 +284,19 @@ func (p *Pool) Stop(ctx context.Context) error {
func (p *Pool) Status() (string, error) { func (p *Pool) Status() (string, error) {
p.mu.RLock() p.mu.RLock()
defer p.mu.RUnlock() defer p.mu.RUnlock()
return fmt.Sprintf("scripts: %d, version: %d, closed: %v", len(p.scripts), 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
}
// --- Helper types from original file ---
// CallOption allows configuring the JS execution environment.
type CallOption func(vm *goja.Runtime)
// WithSafeMode enables or disables safe mode for the call.
func WithSafeMode(enabled bool) CallOption {
return func(vm *goja.Runtime) {
_ = vm.Set("__safeMode__", enabled)
}
}
// WithLogger injects a custom logger for the call.
func WithLogger(logger *log.Logger) CallOption {
return func(vm *goja.Runtime) {
_ = vm.Set("__logger__", vm.ToValue(logger))
}
} }
// FuncList returns the list of all defined JS function names. // FuncList returns the list of all defined JS 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()
// Reflection to list functions in the latest script set could be added here list := make([]string, 0, len(p.functions))
return []string{} for name := range p.functions {
list = append(list, name)
}
sort.Strings(list)
return list
} }
func FuncList() []string { func FuncList() []string {

View File

@ -2,14 +2,26 @@ package js
import ( import (
"context" "context"
"strings"
"testing" "testing"
"time"
"apigo.cc/go/cast"
) )
func TestPoolVersioning(t *testing.T) { func TestPoolVersioning(t *testing.T) {
p := NewPool()
// 1. Define initial function // 1. Define initial function
Define(`function hello(name) { return "Hello " + name; }`) p.Define(`function hello(name) { return "Hello " + name; }`, "hello.js", int64(100))
res, err := Call(context.Background(), "hello", []any{"World"}) if !p.CheckVersion("hello.js", 100) {
t.Error("expected CheckVersion to be true for v100")
}
if p.CheckVersion("hello.js", 101) {
t.Error("expected CheckVersion to be false for v101")
}
res, err := p.Call("hello", 0, nil, "World")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@ -18,23 +30,30 @@ func TestPoolVersioning(t *testing.T) {
} }
// 2. Define new function (incremental update) // 2. Define new function (incremental update)
Define(`function add(a, b) { return a + b; }`) p.Define(`function add(a, b) { return a + b; }`)
res, err = Call(context.Background(), "add", []any{1, 2}) res, err = p.Call("add", 0, nil, 1, 2)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if res.(int64) != 3 { if cast.To[int64](res) != 3 {
t.Errorf("expected 3, got %v", res) t.Errorf("expected 3, got %v", res)
} }
// 3. Ensure old function still works // 3. Check FuncList
res, err = Call(context.Background(), "hello", []any{"Again"}) funcs := p.FuncList()
if err != nil { foundHello := false
t.Fatal(err) foundAdd := false
for _, f := range funcs {
if f == "hello" {
foundHello = true
}
if f == "add" {
foundAdd = true
}
} }
if res != "Hello Again" { if !foundHello || !foundAdd {
t.Errorf("expected 'Hello Again', got %v", res) t.Errorf("FuncList missing functions: %v", funcs)
} }
} }
@ -49,8 +68,47 @@ func TestPoolConcurrent(t *testing.T) {
t.Parallel() t.Parallel()
for i := 0; i < 10; i++ { for i := 0; i < 10; i++ {
go func() { go func() {
_, _ = Call(context.Background(), "heavy", []any{1000}) _, _ = Call("heavy", 0, nil, 1000)
}() }()
} }
}) })
} }
func TestPoolGracefulShutdown(t *testing.T) {
p := NewPool()
p.Define(`function sleep(ms) {
let start = Date.now();
while(Date.now() - start < ms);
return "done";
}`)
// 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)
}
// 2. Test Graceful Stop
go func() {
time.Sleep(100 * time.Millisecond)
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)
}
}
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)
if err != nil {
t.Fatal(err)
}
if res != `{"a":1}` {
t.Errorf("expected '{\"a\":1}', got %v", res)
}
}

View File

@ -1,51 +0,0 @@
package js
import (
"context"
"testing"
"time"
)
func TestPoolGracefulShutdown(t *testing.T) {
p := NewPool()
p.Define(`function sleep(ms) {
var start = Date.now();
while (Date.now() - start < ms);
return "done";
}`)
// Start a long running task
errChan := make(chan error, 1)
go func() {
_, err := p.Call(context.Background(), "sleep", []any{500})
errChan <- err
}()
// Give it a moment to start
time.Sleep(100 * time.Millisecond)
// Try to stop the pool
stopCtx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
defer cancel()
startStop := time.Now()
if err := p.Stop(stopCtx); err != nil {
t.Fatalf("Stop failed: %v", err)
}
stopDuration := time.Since(startStop)
if stopDuration < 300*time.Millisecond {
t.Errorf("Stop returned too early, expected it to wait for task. Duration: %v", stopDuration)
}
err := <-errChan
if err != nil {
t.Errorf("Call failed: %v", err)
}
// New calls should fail
_, err = p.Call(context.Background(), "sleep", []any{10})
if err == nil || err.Error() != "js.Pool: pool is closed" {
t.Errorf("Expected 'pool is closed' error, got: %v", err)
}
}