diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..850c6a4 --- /dev/null +++ b/CHANGELOG.md @@ -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`)导致反射崩溃的问题。 diff --git a/doc.go b/doc.go index c8ac3cf..6bfa1dc 100644 --- a/doc.go +++ b/doc.go @@ -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" + // http.Header as a special Record mapping + if pkgPath == "net/http" && rawName == "Header" { + return "Record" + } + 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 +} diff --git a/doc_test.go b/doc_test.go index 81500a0..cec5448 100644 --- a/doc_test.go +++ b/doc_test.go @@ -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[];") { - 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") } } diff --git a/pool.go b/pool.go index 5a855e4..a312243 100644 --- a/pool.go +++ b/pool.go @@ -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() +} diff --git a/shutdown_test.go b/shutdown_test.go new file mode 100644 index 0000000..de37493 --- /dev/null +++ b/shutdown_test.go @@ -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) + } +}