feat(js): support multi-instance pools, graceful shutdown and improved DTS generation

This commit is contained in:
AI Engineer 2026-06-05 19:05:20 +08:00
parent 08d95ac2f2
commit a2089b0c17
5 changed files with 446 additions and 93 deletions

14
CHANGELOG.md Normal file
View File

@ -0,0 +1,14 @@
# CHANGELOG - go/js
## v1.5.1 (2026-06-05)
- **架构重构: 多例支持与优雅停机**:
- 引入 `Pool` 结构体,支持通过 `js.NewPool()` 创建相互隔离的执行环境,避免业务间脚本冲突。
- 实现 `starter.Service` 接口Start, Stop, Status支持集成到基础设施生命周期管理中。
- 优化优雅停机逻辑:`Stop` 会自动阻塞并等待所有活跃的 JS 调用完成,或直到 Context 超时。
- **文档增强: AI/IDE 丝滑对齐**:
- 彻底重写 `js.Doc()`,采用 `declare const go` 全局声明,支持 VSCode 零配置代码提示。
- **穿透防护**: 自动识别并拦截非项目路径下的结构体,统一映射为 `GoPkg_Name` 不透明句柄。
- **方法提纯**: 自动过滤涉及 `io.Reader/Writer``reflect``sync` 等 JS 无法处理的底层方法。
- **放行 time.Time**: 开放时间对象的方法反射,支持 JS 直接调用 `Unix()`, `Format()` 等业务方法。
- **隐式类型导出**: 支持 `__export` 命名前缀,用于导出类型结构而不暴露工厂函数。
- **稳定性**: 修复了基于原始类型别名(如 `time.Month`)导致反射崩溃的问题。

286
doc.go
View File

@ -2,6 +2,7 @@ package js
import (
"fmt"
"path/filepath"
"reflect"
"sort"
"strings"
@ -9,23 +10,45 @@ import (
"apigo.cc/go/jsmod"
)
// Doc generates a TypeScript definition (.d.ts) for all registered Go modules.
// This is designed to be fed into an AI (LLM) to provide context for low-code development.
// Doc generates a comprehensive TypeScript definition (.d.ts) for the Go/JS environment.
func Doc() string {
var sb strings.Builder
sb.WriteString("// TypeScript Definitions for Go/JS Low-Code Environment\n\n")
sb.WriteString("/**\n * Go/JS Low-Code Environment Type Definitions\n")
sb.WriteString(" * Generated by js.Doc(). DO NOT EDIT.\n */\n\n")
// 1. Basic Opaque types (Manual fallback for critical non-project types)
sb.WriteString("/** Opaque handle to Go context.Context */\n")
sb.WriteString("interface GoContext { _isGoContext: true; }\n")
sb.WriteString("/** Opaque handle to Go log.Logger */\n")
sb.WriteString("interface GoLogger { _isGoLogger: true; }\n")
sb.WriteString("/** Opaque handle to Go net/http.Request */\n")
sb.WriteString("interface GoHttp_Request { _isGoHttpReq: true; }\n")
sb.WriteString("/** Opaque handle to Go net/http.Response */\n")
sb.WriteString("interface GoHttp_Response { _isGoHttpRes: true; }\n")
sb.WriteString("/** Opaque handle to Go net/url.URL */\n")
sb.WriteString("interface GoNet_URL { _isGoNetURL: true; }\n\n")
modules := jsmod.GetModules()
keys := make([]string, 0, len(modules))
modNames := make([]string, 0, len(modules))
for k := range modules {
keys = append(keys, k)
modNames = append(modNames, k)
}
sort.Strings(keys)
sort.Strings(modNames)
sb.WriteString("declare namespace go {\n")
for _, modName := range keys {
ctx := &docCtx{
seenTypes: make(map[reflect.Type]string),
interfaces: make(map[string]string),
opaqueList: make(map[string]bool),
}
// 2. Build Module Interfaces
moduleDefs := make(map[string]string)
for _, modName := range modNames {
mod := modules[modName]
sb.WriteString(fmt.Sprintf(" namespace %s {\n", modName))
ctx.currentMod = modName
var msb strings.Builder
msb.WriteString(fmt.Sprintf("interface %s_Module {\n", strings.Title(modName)))
expKeys := make([]string, 0, len(mod.Exports))
for k := range mod.Exports {
@ -34,57 +57,112 @@ func Doc() string {
sort.Strings(expKeys)
for _, name := range expKeys {
isHidden := strings.HasPrefix(name, "__export")
val := mod.Exports[name]
isUnsafe := mod.UnsafeList[name]
if isUnsafe {
sb.WriteString(" /** @unsafe */\n")
memberDef := formatExport(name, val, ctx, false)
if !isHidden {
isUnsafe := mod.UnsafeList[name]
if isUnsafe {
msb.WriteString(" /** @unsafe */\n")
}
msb.WriteString(fmt.Sprintf(" %s\n", memberDef))
}
sb.WriteString(fmt.Sprintf(" %s\n", formatExport(name, val)))
}
sb.WriteString(" }\n")
msb.WriteString("}")
moduleDefs[modName] = msb.String()
}
sb.WriteString("}\n")
// 2. Output Opaque Interfaces (Dynamic)
opaqueNames := make([]string, 0, len(ctx.opaqueList))
for name := range ctx.opaqueList {
opaqueNames = append(opaqueNames, name)
}
sort.Strings(opaqueNames)
for _, name := range opaqueNames {
sb.WriteString(fmt.Sprintf("interface %s { _is%s: true; }\n", name, name))
}
sb.WriteString("\n")
// 3. Supporting Interfaces
if len(ctx.interfaces) > 0 {
sb.WriteString("// --- Supporting Interfaces ---\n\n")
ifaceNames := make([]string, 0, len(ctx.interfaces))
for name := range ctx.interfaces {
ifaceNames = append(ifaceNames, name)
}
sort.Strings(ifaceNames)
for _, name := range ifaceNames {
sb.WriteString(ctx.interfaces[name])
sb.WriteString("\n\n")
}
}
// 4. Module Interfaces
sb.WriteString("// --- Module Definitions ---\n\n")
for _, modName := range modNames {
sb.WriteString(moduleDefs[modName])
sb.WriteString("\n\n")
}
// 5. Global 'go' declaration
sb.WriteString("/** Global entry point for Go bridged modules */\n")
sb.WriteString("declare const go: {\n")
for _, modName := range modNames {
sb.WriteString(fmt.Sprintf(" readonly %s: %s_Module;\n", modName, strings.Title(modName)))
}
sb.WriteString("};\n")
return sb.String()
}
func formatExport(name string, val any) string {
type docCtx struct {
currentMod string
seenTypes map[reflect.Type]string
interfaces map[string]string
opaqueList map[string]bool
}
func formatExport(name string, val any, ctx *docCtx, isMethod bool) string {
t := reflect.TypeOf(val)
if t == nil {
return fmt.Sprintf("const %s: any;", name)
return fmt.Sprintf("%s: any;", name)
}
if t.Kind() == reflect.Func {
return fmt.Sprintf("function %s%s;", name, formatFunc(t))
return fmt.Sprintf("%s%s;", name, formatFunc(t, ctx, isMethod))
}
return fmt.Sprintf("const %s: %s;", name, goTypeToTS(t))
return fmt.Sprintf("%s: %s;", name, goTypeToTS(t, ctx))
}
func formatFunc(t reflect.Type) string {
func formatFunc(t reflect.Type, ctx *docCtx, isMethod bool) string {
var params []string
numIn := t.NumIn()
jsArgIdx := 0
for i := 0; i < numIn; i++ {
startIdx := 0
if isMethod {
startIdx = 1 // Skip receiver
}
for i := startIdx; i < numIn; i++ {
argType := t.In(i)
// Skip Context and Logger in TS doc
typeName := argType.String()
if typeName == "context.Context" || typeName == "*log.Logger" {
if typeName == "context.Context" || strings.Contains(typeName, "log.Logger") {
continue
}
params = append(params, fmt.Sprintf("arg%d: %s", jsArgIdx, goTypeToTS(argType)))
params = append(params, fmt.Sprintf("arg%d: %s", jsArgIdx, goTypeToTS(argType, ctx)))
jsArgIdx++
}
// Handle return values
numOut := t.NumOut()
var retType string
if numOut == 0 {
retType = "void"
} else {
// If last return is error, we only care about the first part for TS doc
realOut := numOut
if numOut > 0 && t.Out(numOut-1).Implements(reflect.TypeOf((*error)(nil)).Elem()) {
realOut--
@ -93,7 +171,7 @@ func formatFunc(t reflect.Type) string {
if realOut <= 0 {
retType = "void"
} else if realOut == 1 {
retType = goTypeToTS(t.Out(0))
retType = goTypeToTS(t.Out(0), ctx)
} else {
retType = "any[]"
}
@ -102,43 +180,169 @@ func formatFunc(t reflect.Type) string {
return fmt.Sprintf("(%s): %s", strings.Join(params, ", "), retType)
}
func goTypeToTS(t reflect.Type) string {
func goTypeToTS(t reflect.Type, ctx *docCtx) string {
if t == nil {
return "any"
}
// Handle known standard library types to avoid deep recursion and provide clear naming
typeName := t.String()
switch typeName {
case "time.Time", "*time.Time":
return "time.Time"
case "time.Duration", "*time.Duration":
return "time.Duration"
}
// Handle pointers
for t.Kind() == reflect.Ptr {
t = t.Elem()
}
pkgPath := t.PkgPath()
rawName := t.Name()
// 1. Blacklist / Filter
if isTypeUnusable(t) {
return "any"
}
// 2. Map primitive-like types (even with PkgPath) to TS primitives
switch t.Kind() {
case reflect.String:
return "string"
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64,
reflect.Float32, reflect.Float64:
// time.Duration is an Int64, but we treat it as number (ms)
return "number"
case reflect.Bool:
return "boolean"
case reflect.Slice, reflect.Array:
return goTypeToTS(t.Elem()) + "[]"
return goTypeToTS(t.Elem(), ctx) + "[]"
case reflect.Map:
return "Record<string, any>"
// http.Header as a special Record mapping
if pkgPath == "net/http" && rawName == "Header" {
return "Record<string, string[]>"
}
return fmt.Sprintf("Record<%s, %s>", goTypeToTS(t.Key(), ctx), goTypeToTS(t.Elem(), ctx))
}
// 3. Special Mappings for remaining named types (mostly Structs/Interfaces)
if pkgPath == "time" && rawName == "Time" {
return registerInterface(t, ctx)
}
// 4. Strict Penetration Blocking for named non-project types
isProject := strings.HasPrefix(pkgPath, "apigo.cc/go/")
isAnonymous := rawName == ""
if pkgPath != "" && !isProject {
base := filepath.Base(pkgPath)
opaqueName := "Go" + strings.Title(base) + "_" + rawName
ctx.opaqueList[opaqueName] = true
return opaqueName
}
// 5. Recursive Struct/Interface Parsing
switch t.Kind() {
case reflect.Struct:
return "{ [key: string]: any }"
if isAnonymous {
return "{ [key: string]: any }"
}
return registerInterface(t, ctx)
case reflect.Interface:
return "any"
default:
return "any"
}
}
func registerInterface(t reflect.Type, ctx *docCtx) string {
if name, ok := ctx.seenTypes[t]; ok {
return name
}
rawName := t.Name()
pkgPath := t.PkgPath()
var name string
if strings.HasPrefix(pkgPath, "apigo.cc/go/") {
parts := strings.Split(pkgPath, "/")
name = fmt.Sprintf("%s_%s", strings.Title(parts[len(parts)-1]), rawName)
} else if pkgPath != "" {
// Standard lib types like time.Time
base := filepath.Base(pkgPath)
name = fmt.Sprintf("Go%s_%s", strings.Title(base), rawName)
} else {
name = fmt.Sprintf("%s_%s", strings.Title(ctx.currentMod), rawName)
}
ctx.seenTypes[t] = name
var sb strings.Builder
sb.WriteString(fmt.Sprintf("interface %s {\n", name))
// Fields with Tag check
for i := 0; i < t.NumField(); i++ {
field := t.Field(i)
if field.PkgPath != "" || field.Tag.Get("js") == "-" {
continue
}
sb.WriteString(fmt.Sprintf(" %s: %s;\n", field.Name, goTypeToTS(field.Type, ctx)))
}
// Methods check
ptrType := reflect.PtrTo(t)
methods := make(map[string]reflect.Method)
for i := 0; i < t.NumMethod(); i++ {
m := t.Method(i)
methods[m.Name] = m
}
for i := 0; i < ptrType.NumMethod(); i++ {
m := ptrType.Method(i)
methods[m.Name] = m
}
mNames := make([]string, 0, len(methods))
for n := range methods {
mNames = append(mNames, n)
}
sort.Strings(mNames)
for _, n := range mNames {
m := methods[n]
if m.PkgPath != "" {
continue
}
if isMethodUnusable(m.Type) {
continue
}
sb.WriteString(fmt.Sprintf(" %s%s;\n", n, formatFunc(m.Type, ctx, true)))
}
sb.WriteString("}")
ctx.interfaces[name] = sb.String()
return name
}
func isMethodUnusable(t reflect.Type) bool {
for i := 0; i < t.NumIn(); i++ {
if isTypeUnusable(t.In(i)) {
return true
}
}
for i := 0; i < t.NumOut(); i++ {
if isTypeUnusable(t.Out(i)) {
return true
}
}
return false
}
func isTypeUnusable(t reflect.Type) bool {
for t.Kind() == reflect.Ptr || t.Kind() == reflect.Slice {
t = t.Elem()
}
pkgPath := t.PkgPath()
rawName := t.Name()
if pkgPath == "reflect" || pkgPath == "runtime" || pkgPath == "unsafe" || pkgPath == "sync" {
return true
}
if pkgPath == "io" && (rawName == "Reader" || rawName == "Writer" || rawName == "Closer") {
return true
}
return false
}

View File

@ -15,18 +15,25 @@ func TestDocGeneration(t *testing.T) {
return nil, nil
},
"version": "1.0.0",
"__exportInternal": func() *struct{ Name string } { return nil },
})
doc := Doc()
fmt.Println(doc)
if !strings.Contains(doc, "namespace db") {
t.Error("doc should contain namespace db")
if !strings.Contains(doc, "interface GoTime") {
t.Error("doc should contain GoTime interface")
}
if !strings.Contains(doc, "function query(arg0: string, arg1: any[]): Record<string, any>[];") {
t.Error("doc should contain query function with correct signature")
if !strings.Contains(doc, "interface Db_Module") {
t.Error("doc should contain Db_Module interface")
}
if !strings.Contains(doc, "const version: string;") {
t.Error("doc should contain version constant")
if !strings.Contains(doc, "declare const go:") {
t.Error("doc should contain global go declaration")
}
if strings.Contains(doc, "__exportInternal") {
t.Error("doc should NOT contain __exportInternal")
}
if !strings.Contains(doc, "interface Db_") {
t.Error("doc should contain supporting interface for internal struct")
}
}

169
pool.go
View File

@ -3,11 +3,11 @@ package js
import (
"context"
"fmt"
"log"
"sync"
"sync/atomic"
"apigo.cc/go/jsmod"
"apigo.cc/go/log"
"github.com/dop251/goja"
)
@ -16,12 +16,25 @@ type vmInstance struct {
version int32
}
var (
globalVersion int32
scripts []string
scriptsMu sync.RWMutex
// Pool represents an isolated JS execution environment with its own script registry and VM pool.
type Pool struct {
version int32
scripts []string
mu sync.RWMutex
pool sync.Pool
pool = sync.Pool{
// Lifecycle management
ctx context.Context
cancel context.CancelFunc
wg sync.WaitGroup
closed int32
}
// NewPool creates a new isolated JS execution environment.
func NewPool() *Pool {
p := &Pool{}
p.ctx, p.cancel = context.WithCancel(context.Background())
p.pool = sync.Pool{
New: func() any {
return &vmInstance{
runtime: createNewRuntime(),
@ -29,7 +42,11 @@ var (
}
},
}
)
return p
}
// DefaultPool is the global singleton pool.
var DefaultPool = NewPool()
func createNewRuntime() *goja.Runtime {
vm := goja.New()
@ -55,54 +72,43 @@ func createNewRuntime() *goja.Runtime {
return vm
}
// Define adds JS code to the global registry and increments the version.
// All VMs in the pool will eventually synchronize to this version.
func Define(code string) {
scriptsMu.Lock()
defer scriptsMu.Unlock()
// Define adds JS code to the pool's registry and increments the version.
func (p *Pool) Define(code string) {
p.mu.Lock()
defer p.mu.Unlock()
scripts = append(scripts, code)
atomic.AddInt32(&globalVersion, 1)
}
// 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))
}
p.scripts = append(p.scripts, code)
atomic.AddInt32(&p.version, 1)
}
// Call executes a JS function from the pool.
// It automatically synchronizes the VM to the latest version.
func Call(ctx context.Context, funcName string, args []any, opts ...CallOption) (any, error) {
instance := pool.Get().(*vmInstance)
defer pool.Put(instance)
func (p *Pool) Call(ctx context.Context, funcName string, args []any, opts ...CallOption) (any, error) {
if atomic.LoadInt32(&p.closed) == 1 {
return nil, fmt.Errorf("js.Pool: pool is closed")
}
instance := p.pool.Get().(*vmInstance)
defer p.pool.Put(instance)
// Tracking active calls for graceful shutdown
p.wg.Add(1)
defer p.wg.Done()
vm := instance.runtime
// 1. Synchronize scripts if version is behind
currentGlobalVersion := atomic.LoadInt32(&globalVersion)
if instance.version < currentGlobalVersion {
scriptsMu.RLock()
for i := int(instance.version); i < len(scripts); i++ {
_, err := vm.RunString(scripts[i])
currentVersion := atomic.LoadInt32(&p.version)
if instance.version < currentVersion {
p.mu.RLock()
for i := int(instance.version); i < len(p.scripts); i++ {
_, err := vm.RunString(p.scripts[i])
if err != nil {
scriptsMu.RUnlock()
p.mu.RUnlock()
return nil, fmt.Errorf("js.sync error at script %d: %w", i, err)
}
}
instance.version = currentGlobalVersion
scriptsMu.RUnlock()
instance.version = currentVersion
p.mu.RUnlock()
}
// 2. Set Context and default state
@ -150,9 +156,80 @@ func Call(ctx context.Context, funcName string, args []any, opts ...CallOption)
return result.Export(), nil
}
// --- Global Proxy Functions ---
func Define(code string) {
DefaultPool.Define(code)
}
func Call(ctx context.Context, funcName string, args []any, opts ...CallOption) (any, error) {
return DefaultPool.Call(ctx, funcName, args, opts...)
}
// --- Starter Interface Implementation ---
func (p *Pool) Start(ctx context.Context, logger *log.Logger) error {
// For JS engine, start is mostly for pre-warming or registry checking.
// We ensure the context is not canceled.
if p.ctx.Err() != nil {
p.ctx, p.cancel = context.WithCancel(context.Background())
}
atomic.StoreInt32(&p.closed, 0)
return nil
}
func (p *Pool) Stop(ctx context.Context) error {
atomic.StoreInt32(&p.closed, 1)
p.cancel() // Notify any long-running JS that are context-aware
// Wait for active Call() to finish or context timeout
done := make(chan struct{})
go func() {
p.wg.Wait()
close(done)
}()
select {
case <-done:
return nil
case <-ctx.Done():
return fmt.Errorf("js.Pool: shutdown timeout, active tasks may still be running")
}
}
func (p *Pool) Status() (string, error) {
p.mu.RLock()
defer p.mu.RUnlock()
return fmt.Sprintf("scripts: %d, version: %d, closed: %v", len(p.scripts), 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.
func FuncList() []string {
scriptsMu.RLock()
defer scriptsMu.RUnlock()
func (p *Pool) FuncList() []string {
p.mu.RLock()
defer p.mu.RUnlock()
// Reflection to list functions in the latest script set could be added here
return []string{}
}
func FuncList() []string {
return DefaultPool.FuncList()
}

51
shutdown_test.go Normal file
View File

@ -0,0 +1,51 @@
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)
}
}