diff --git a/CHANGELOG.md b/CHANGELOG.md index 0db1619..995ca1c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # CHANGELOG - go/js +## v1.5.8 (2026-07-13) +- **桥接一致性修复**: Go 函数返回的 API 门面对象,其导出方法也统一经由桥接层调用。 + JavaScript 的 lower-camel 对象字段会与顶层 API 一样自动转换为 Go struct 字段。 +- **回归测试**: 覆盖 `table().Query({filter: [...]})` 形式的返回对象方法调用。 + ## v1.5.7 (2026-06-28) - **低代码 TS 文档对齐**: - `Doc()` 生成的模块入口改为顶层全局变量声明,如 `declare const api: Api_Module;`,不再使用 `declare const go: { ... }` 包裹。 diff --git a/README.md b/README.md index b2ab57b..01fc702 100644 --- a/README.md +++ b/README.md @@ -36,11 +36,11 @@ import "apigo.cc/go/js" func main() { // Check if script needs update (e.g., from file mtime) - if !js.CheckVersion("myTask.js", mtime) { - js.Define(code, "myTask.js", mtime) + if !js.CheckVersion("myTask", mtime) { + js.Define("myTask", code, mtime) } - res, err := js.Call(ctx, "myTask", "star") + res, err := js.Call("myTask", time.Second, nil, "star") } ``` @@ -79,9 +79,18 @@ interface Logger { ## Internal Bridge Details -The engine uses `goja`'s Host Object mechanism. When a Go struct/pointer is returned to JS, it remains a Go object. When passed back to a Go function, the original pointer is preserved, ensuring zero data loss and state consistency. +The engine preserves ordinary Go data values. API facade objects returned to JavaScript expose their exported methods through the same bridge as top-level module functions, ensuring identical context injection, safe-mode checking, error conversion, and struct conversion. Types are automatically coerced: - JS `string` -> Go `int` (via `go/cast`) - JS `Object` -> Go `Struct` - Go `error` -> JS `Exception` + +Therefore both styles use normal JavaScript lower-camel fields: + +```js +knowbase.Table("Account").Query({ + filter: [{ field: "Name", operator: "=", value: "Admin" }], + limit: 1000 +}) +``` diff --git a/TEST.md b/TEST.md index 0facdcf..d7c3453 100644 --- a/TEST.md +++ b/TEST.md @@ -33,6 +33,8 @@ CPU: Intel(R) Core(TM) i9-9980HK CPU @ 2.40GHz --- PASS: TestBridgeOptionalParams (0.00s) === RUN TestBridgeVariadic --- PASS: TestBridgeVariadic (0.00s) +=== RUN TestBridgeMapsLowerCamelCaseForReturnedObjectMethods +--- PASS: TestBridgeMapsLowerCamelCaseForReturnedObjectMethods (0.00s) === RUN TestDocGeneration --- PASS: TestDocGeneration (0.00s) === RUN TestPoolVersioning @@ -55,6 +57,7 @@ ok apigo.cc/go/js 0.800s ## Features Verified - [x] JS calling Go with automatic type conversion. +- [x] Returned Go API object methods use the same lower-camel struct conversion. - [x] Go pointers preservation in JS. - [x] Context and Logger injection. - [x] Concurrent execution and script versioning. diff --git a/bridge.go b/bridge.go index 4d587b2..9878629 100644 --- a/bridge.go +++ b/bridge.go @@ -164,17 +164,58 @@ func wrapGoFunc(vm *goja.Runtime, fn any, isUnsafe bool) goja.Value { } if len(results) == 1 { - return vm.ToValue(results[0].Interface()) + return toJSValue(vm, results[0].Interface()) } resSlice := make([]any, len(results)) for i, r := range results { - resSlice[i] = r.Interface() + resSlice[i] = toJSValue(vm, r.Interface()) } return vm.ToValue(resSlice) }) } +// toJSValue exposes Go API objects through bridge-wrapped methods rather than +// goja's direct reflection. Directly reflected methods bypass cast.Convert, so +// nested lower-camel JavaScript objects would not be converted to Go structs. +// Plain data structs retain goja's normal value export behavior. +func toJSValue(vm *goja.Runtime, value any) goja.Value { + reflectValue := reflect.ValueOf(value) + if !reflectValue.IsValid() { + return goja.Null() + } + reflectType := reflectValue.Type() + structType := reflectType + if structType.Kind() == reflect.Pointer { + if reflectValue.IsNil() { + return goja.Null() + } + structType = structType.Elem() + } + if structType.Kind() != reflect.Struct || reflectType.NumMethod() == 0 || hasExportedField(structType) { + return vm.ToValue(value) + } + + object := vm.NewObject() + for index := 0; index < reflectType.NumMethod(); index++ { + method := reflectType.Method(index) + boundMethod := reflectValue.Method(index) + if boundMethod.IsValid() { + _ = object.Set(method.Name, wrapGoFunc(vm, boundMethod.Interface(), false)) + } + } + return object +} + +func hasExportedField(structType reflect.Type) bool { + for index := 0; index < structType.NumField(); index++ { + if structType.Field(index).IsExported() { + return true + } + } + return false +} + // trimGoPath shortens a Go source file path for display: // keeps only the last two path components (package/file.go). func trimGoPath(fullPath string) string { @@ -198,7 +239,7 @@ type goStackError struct { 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 } +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. diff --git a/bridge_test.go b/bridge_test.go index 10acb78..1f7e98b 100644 --- a/bridge_test.go +++ b/bridge_test.go @@ -13,9 +13,30 @@ import ( "github.com/dop251/goja" ) +type bridgeFacadeFilter struct { + Field string + Operator string + Value any +} + +type bridgeFacadeRequest struct { + Filter []bridgeFacadeFilter + Limit int +} + +// bridgeFacade intentionally follows the shape used by Go APIs exposed to JS: +// private state and exported methods. +type bridgeFacade struct { + received bridgeFacadeRequest +} + +func (f *bridgeFacade) Query(request bridgeFacadeRequest) { + f.received = request +} + func TestBridgeSafeMode(t *testing.T) { vm := goja.New() - + // Set up safe context injects := map[string]any{"SafeMode": true} ctx := jsmod.NewContext(context.Background(), injects) @@ -37,7 +58,7 @@ func TestBridgeLoggerInjection(t *testing.T) { var buf bytes.Buffer 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) @@ -56,7 +77,7 @@ func TestBridgeLoggerInjection(t *testing.T) { func TestBridgeMixedInjection(t *testing.T) { vm := goja.New() - + // Create context with multiple values injects := map[string]any{ "UserID": "user123", @@ -84,22 +105,22 @@ func TestBridgeMixedInjection(t *testing.T) { 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" { @@ -145,3 +166,23 @@ func TestBridgeVariadic(t *testing.T) { t.Errorf("Variadic args content wrong, got %v", result) } } + +func TestBridgeMapsLowerCamelCaseForReturnedObjectMethods(t *testing.T) { + vm := goja.New() + facade := &bridgeFacade{} + if err := vm.Set("table", wrapGoFunc(vm, func() *bridgeFacade { return facade }, false)); err != nil { + t.Fatal(err) + } + + _, err := vm.RunString(`table().Query({filter: [{field: "Name", operator: "=", value: "Admin"}], limit: 1000})`) + if err != nil { + t.Fatalf("JS execution failed: %v", err) + } + if facade.received.Limit != 1000 || len(facade.received.Filter) != 1 { + t.Fatalf("lower-camel request was not converted: %#v", facade.received) + } + filter := facade.received.Filter[0] + if filter.Field != "Name" || filter.Operator != "=" || filter.Value != "Admin" { + t.Errorf("unexpected converted filter: %#v", filter) + } +}