From c3a16c9e923ebcc1da084ad1518253d90db43dcb Mon Sep 17 00:00:00 2001 From: Evan Reichard Date: Tue, 27 Jan 2026 16:20:46 -0500 Subject: [PATCH] asd --- .gitignore | 1 - AGENTS.md | 66 ++- README.md | 2 +- cmd/poiesis/main.go | 33 ++ examples/builtin_example.go.txt | 62 --- flake.nix | 2 + internal/builtin/builtin_test.go | 77 +++ internal/builtin/collector.go | 170 +++++++ internal/builtin/convert.go | 204 ++++++++ internal/builtin/registry.go | 90 ++++ internal/builtin/types.go | 27 + internal/builtin/typescript.go | 64 +++ internal/builtin/typescript_test.go | 273 +++++++++++ internal/builtin/wrapper.go | 72 +++ internal/runtime/pkg/builtin/builtin.go | 463 ------------------ internal/runtime/runtime.go | 11 +- internal/runtime/runtime_test.go | 2 + internal/{runtime => }/standard/fetch.go | 51 +- internal/standard/fetch_promise_test.go | 51 ++ internal/{runtime => }/standard/fetch_test.go | 37 +- test_data/fetch_promise_test.ts | 11 + 21 files changed, 1192 insertions(+), 577 deletions(-) delete mode 100644 .gitignore create mode 100644 cmd/poiesis/main.go delete mode 100644 examples/builtin_example.go.txt create mode 100644 internal/builtin/builtin_test.go create mode 100644 internal/builtin/collector.go create mode 100644 internal/builtin/convert.go create mode 100644 internal/builtin/registry.go create mode 100644 internal/builtin/types.go create mode 100644 internal/builtin/typescript.go create mode 100644 internal/builtin/typescript_test.go create mode 100644 internal/builtin/wrapper.go delete mode 100644 internal/runtime/pkg/builtin/builtin.go rename internal/{runtime => }/standard/fetch.go (59%) create mode 100644 internal/standard/fetch_promise_test.go rename internal/{runtime => }/standard/fetch_test.go (70%) create mode 100644 test_data/fetch_promise_test.ts diff --git a/.gitignore b/.gitignore deleted file mode 100644 index f74c6bd..0000000 --- a/.gitignore +++ /dev/null @@ -1 +0,0 @@ -poiesis diff --git a/AGENTS.md b/AGENTS.md index 9982a42..c3a8a2e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,7 +6,7 @@ ## Overview -Go tool that transpiles TypeScript to JavaScript using esbuild API and executes it with goja. Features a flexible builtin system for exposing Go functions to TypeScript. +Go tool that transpiles TypeScript to JavaScript using esbuild API and executes it with goja. Features a flexible builtin system for exposing Go functions to TypeScript with support for both synchronous and asynchronous (Promise-based) operations. ## Build & Test @@ -23,23 +23,63 @@ reichard.io/poiesis/ ├── cmd/poiesis/ # CLI entry point │ └── main.go ├── internal/ -│ └── runtime/ -│ ├── pkg/ -│ │ └── builtin/ # Builtin framework (framework only, no implementations) -│ │ └── builtin.go -│ ├── standard/ # Standard builtin implementations -│ │ ├── fetch.go -│ │ └── fetch_test.go -│ ├── runtime.go # Runtime management, transpilation, execution -│ └── runtime_test.go +│ ├── runtime/ # Runtime management, transpilation, execution +│ │ ├── runtime.go +│ │ └── runtime_test.go +│ ├── builtin/ # Builtin registration framework +│ │ ├── types.go +│ │ ├── registry.go +│ │ ├── wrapper.go +│ │ ├── convert.go +│ │ ├── typescript.go +│ │ └── builtin_test.go +│ └── standard/ # Standard builtin implementations +│ ├── fetch.go +│ ├── fetch_test.go +│ └── fetch_promise_test.go └── test_data/ # Test TypeScript files ``` ## Key Packages -- `reichard.io/poiesis/internal/runtime` - Runtime management, TypeScript transpilation, execution -- `reichard.io/poiesis/internal/runtime/pkg/builtin` - Generic builtin registration framework (framework only) -- `reichard.io/poiesis/internal/runtime/standard` - Standard builtin implementations (fetch, add, greet, etc.) +- `reichard.io/poiesis/internal/runtime` - Runtime management, TypeScript transpilation, JavaScript execution +- `reichard.io/poiesis/internal/builtin` - Generic builtin registration framework (sync/async wrappers, JS/Go conversion, type definition generation) +- `reichard.io/poiesis/internal/standard` - Standard builtin implementations (fetch, add, greet, etc.) + +## Builtin System + +### Registration + +Two types of builtins: +- **Sync**: `RegisterBuiltin[T, R](name, fn)` - executes synchronously, returns value +- **Async**: `RegisterAsyncBuiltin[T, R](name, fn)` - runs in goroutine, returns Promise + +### Requirements + +- Args must be a struct implementing `Args` interface with `Validate() error` method +- Use JSON tags for TypeScript type definitions +- Async builtins automatically generate `Promise` return types + +### Example + +```go +type AddArgs struct { + A int `json:"a"` + B int `json:"b"` +} + +func (a AddArgs) Validate() error { return nil } + +func Add(_ context.Context, args AddArgs) (int, error) { + return args.A + args.B, nil +} + +// Register sync builtin +builtin.RegisterBuiltin[AddArgs, int]("add", Add) + +// Register async builtin +builtin.RegisterAsyncBuiltin[FetchArgs, *FetchResult]("fetch", Fetch) +``` ## Testing Patterns diff --git a/README.md b/README.md index f06a7aa..a5fb975 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,6 @@ reichard.io/poiesis/ │ │ └── fetch_test.go # Tests for fetch │ ├── runtime.go # Transpilation & execution │ └── runtime_test.go # Runtime tests -└── examples/ # Example TypeScript files ``` ## Architecture @@ -81,6 +80,7 @@ func init() { ``` That's it! The framework automatically: + - Converts TypeScript values to Go types - Handles errors (panics as JS errors) - Generates TypeScript definitions diff --git a/cmd/poiesis/main.go b/cmd/poiesis/main.go new file mode 100644 index 0000000..64a6689 --- /dev/null +++ b/cmd/poiesis/main.go @@ -0,0 +1,33 @@ +package main + +import ( + "fmt" + "os" + + "reichard.io/poiesis/internal/builtin" + "reichard.io/poiesis/internal/runtime" + _ "reichard.io/poiesis/internal/standard" +) + +func main() { + if len(os.Args) < 2 { + fmt.Fprintln(os.Stderr, "Usage: poiesis ") + fmt.Fprintln(os.Stderr, " poiesis -print-types") + os.Exit(1) + } + + // Print Types + if os.Args[1] == "-print-types" { + fmt.Println(builtin.GetBuiltinsDeclarations()) + return + } + + // Get File + filePath := os.Args[1] + + // Run File + rt := runtime.New() + if err := rt.RunFile(filePath, os.Stdout, os.Stderr); err != nil { + os.Exit(1) + } +} diff --git a/examples/builtin_example.go.txt b/examples/builtin_example.go.txt deleted file mode 100644 index 847c741..0000000 --- a/examples/builtin_example.go.txt +++ /dev/null @@ -1,62 +0,0 @@ -// Example: How to add builtins to the framework -// Just write a Go function and register it - that's all! - -package standard - -import ( - "fmt" - - "github.com/dop251/goja" - "reichard.io/poiesis/internal/runtime/pkg/builtin" -) - -// Simple function - just register it! -func multiply(a, b int) int { - return a * b -} - -// Function returning multiple values with error -func divide(a, b int) (int, error) { - if b == 0 { - return 0, fmt.Errorf("cannot divide by zero") - } - return a / b, nil -} - -// Complex example with struct -type User struct { - Name string - Email string - Age int -} - -func getUser(id int) (User, error) { - return User{ - Name: "John Doe", - Email: "john@example.com", - Age: 30, - }, nil -} - -// Optional: Register custom converter for User type -func convertUser(vm *goja.Runtime, user User) goja.Value { - obj := vm.NewObject() - _ = obj.Set("name", user.Name) - _ = obj.Set("email", user.Email) - _ = obj.Set("age", user.Age) - return obj -} - -// In a real file, you'd put this in init(): -// -// func init() { -// builtin.RegisterCustomConverter(convertUser) -// builtin.RegisterBuiltin("multiply", multiply) -// builtin.RegisterBuiltin("divide", divide) -// builtin.RegisterBuiltin("getUser", getUser) -// } - -// That's it! TypeScript definitions are auto-generated: -// declare function multiply(arg0: number, arg1: number): number; -// declare function divide(arg0: number, arg1: number): number; -// declare function getUser(arg0: number): User; diff --git a/flake.nix b/flake.nix index aa471fb..2105dc7 100644 --- a/flake.nix +++ b/flake.nix @@ -28,6 +28,8 @@ go gopls golangci-lint + + tree ]; }; } diff --git a/internal/builtin/builtin_test.go b/internal/builtin/builtin_test.go new file mode 100644 index 0000000..aa3d4bf --- /dev/null +++ b/internal/builtin/builtin_test.go @@ -0,0 +1,77 @@ +package builtin + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/dop251/goja" +) + +type TestArgs struct { + Field1 string `json:"field1"` +} + +func (t TestArgs) Validate() error { + return nil +} + +func TestAsyncBuiltin(t *testing.T) { + RegisterAsyncBuiltin[TestArgs, string]("testAsync", func(_ context.Context, args TestArgs) (string, error) { + return "result: " + args.Field1, nil + }) + + registryMutex.RLock() + builtin, ok := builtinRegistry["testAsync"] + registryMutex.RUnlock() + + require.True(t, ok, "testAsync should be registered") + assert.Contains(t, builtin.Definition, "Promise", "definition should include Promise") +} + +func TestAsyncBuiltinResolution(t *testing.T) { + RegisterAsyncBuiltin[TestArgs, string]("resolveTest", func(_ context.Context, args TestArgs) (string, error) { + return "test-result", nil + }) + + vm := goja.New() + RegisterBuiltins(vm) + + result, err := vm.RunString(`resolveTest({field1: "hello"})`) + require.NoError(t, err) + + promise, ok := result.Export().(*goja.Promise) + require.True(t, ok, "should return a Promise") + assert.NotNil(t, promise) +} + +func TestAsyncBuiltinRejection(t *testing.T) { + RegisterAsyncBuiltin[TestArgs, string]("rejectTest", func(_ context.Context, args TestArgs) (string, error) { + return "", assert.AnError + }) + + vm := goja.New() + RegisterBuiltins(vm) + + result, err := vm.RunString(`rejectTest({field1: "hello"})`) + require.NoError(t, err) + + promise, ok := result.Export().(*goja.Promise) + require.True(t, ok, "should return a Promise") + assert.NotNil(t, promise) +} + +func TestNonPromise(t *testing.T) { + RegisterBuiltin[TestArgs, string]("nonPromiseTest", func(_ context.Context, args TestArgs) (string, error) { + return "sync-result", nil + }) + + vm := goja.New() + RegisterBuiltins(vm) + + result, err := vm.RunString(`nonPromiseTest({field1: "hello"})`) + require.NoError(t, err) + assert.Equal(t, "sync-result", result.Export()) +} diff --git a/internal/builtin/collector.go b/internal/builtin/collector.go new file mode 100644 index 0000000..58bfab4 --- /dev/null +++ b/internal/builtin/collector.go @@ -0,0 +1,170 @@ +package builtin + +import ( + "fmt" + "reflect" + "strings" + "sync" +) + +type typeCollector struct { + mu sync.Mutex + types map[string]string + paramTypes map[string]bool +} + +func newTypeCollector() *typeCollector { + return &typeCollector{ + types: make(map[string]string), + paramTypes: make(map[string]bool), + } +} + +func (tc *typeCollector) collectTypes(argsType reflect.Type, fnType reflect.Type) []string { + tc.mu.Lock() + defer tc.mu.Unlock() + tc.types = make(map[string]string) + tc.paramTypes = make(map[string]bool) + + var result []string + + tc.collectStruct(argsType, argsType.Name()) + + for i := 0; i < argsType.NumField(); i++ { + field := argsType.Field(i) + if field.Type.Kind() == reflect.Pointer || strings.Contains(field.Tag.Get("json"), ",omitempty") { + tc.collectParamType(field.Type) + } + } + + if fnType.Kind() == reflect.Func && fnType.NumOut() > 0 { + lastIndex := fnType.NumOut() - 1 + lastType := fnType.Out(lastIndex) + + if lastType.Implements(reflect.TypeOf((*error)(nil)).Elem()) { + if fnType.NumOut() > 1 { + tc.collectType(fnType.Out(0)) + } + } else { + tc.collectType(lastType) + } + } + + for _, t := range tc.types { + result = append(result, t) + } + + return result +} + +func (tc *typeCollector) collectParamType(t reflect.Type) { + if t.Kind() == reflect.Pointer { + tc.collectParamType(t.Elem()) + return + } + + if t.Kind() == reflect.Struct && t.Name() != "" { + tc.paramTypes[t.Name()+" | null"] = true + } +} + +func (tc *typeCollector) getParamTypes() map[string]bool { + return tc.paramTypes +} + +func (tc *typeCollector) collectType(t reflect.Type) { + if t.Kind() == reflect.Pointer { + tc.collectType(t.Elem()) + return + } + + if t.Kind() == reflect.Struct { + name := t.Name() + if _, exists := tc.types[name]; !exists { + tc.collectStruct(t, name) + } + } +} + +func (tc *typeCollector) collectStruct(t reflect.Type, name string) { + if t.Kind() != reflect.Struct { + return + } + + var fields []string + for i := 0; i < t.NumField(); i++ { + field := t.Field(i) + + if field.Anonymous || !field.IsExported() { + continue + } + + fieldName := getFieldName(field) + + var tsType string + var isOptional bool + isPointer := field.Type.Kind() == reflect.Pointer + + if isPointer { + isOptional = true + tsType = goTypeToTSType(field.Type, false) + } else { + isOptional = strings.Contains(field.Tag.Get("json"), ",omitempty") + tsType = goTypeToTSType(field.Type, false) + } + + if isOptional { + fieldName += "?" + } + + fields = append(fields, fmt.Sprintf("%s: %s", fieldName, tsType)) + + tc.collectType(field.Type) + } + + tc.types[name] = fmt.Sprintf("interface %s {%s}", name, strings.Join(fields, "; ")) +} + +func goTypeToTSType(t reflect.Type, isPointer bool) string { + if t.Kind() == reflect.Pointer { + return goTypeToTSType(t.Elem(), true) + } + + baseType := "" + switch t.Kind() { + case reflect.String: + baseType = "string" + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + baseType = "number" + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + baseType = "number" + case reflect.Float32, reflect.Float64: + baseType = "number" + case reflect.Bool: + baseType = "boolean" + case reflect.Interface: + baseType = "any" + case reflect.Slice: + baseType = fmt.Sprintf("%s[]", goTypeToTSType(t.Elem(), false)) + case reflect.Map: + if t.Key().Kind() == reflect.String && t.Elem().Kind() == reflect.Interface { + baseType = "Record" + } else { + baseType = "Record" + } + case reflect.Struct: + name := t.Name() + if name == "" { + baseType = "{}" + } else { + baseType = name + } + default: + baseType = "any" + } + + if isPointer { + baseType += " | null" + } + return baseType +} diff --git a/internal/builtin/convert.go b/internal/builtin/convert.go new file mode 100644 index 0000000..9aec16c --- /dev/null +++ b/internal/builtin/convert.go @@ -0,0 +1,204 @@ +package builtin + +import ( + "fmt" + "reflect" + "strings" + + "github.com/dop251/goja" +) + +func convertJSValueToGo(vm *goja.Runtime, jsValue goja.Value, targetType reflect.Type) (any, error) { + if goja.IsNull(jsValue) { + if targetType.Kind() == reflect.Pointer || targetType.Kind() == reflect.Map { + return nil, nil + } + return nil, fmt.Errorf("cannot convert null/undefined to %v", targetType) + } + + if goja.IsUndefined(jsValue) { + if targetType.Kind() == reflect.Pointer || targetType.Kind() == reflect.Map { + return nil, nil + } + return nil, fmt.Errorf("cannot convert null/undefined to %v", targetType) + } + + switch targetType.Kind() { + case reflect.String: + return jsValue.String(), nil + + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + n, ok := jsValue.Export().(int64) + if !ok { + return nil, fmt.Errorf("expected int, got %T", jsValue.Export()) + } + return reflect.ValueOf(n).Convert(targetType).Interface(), nil + + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + n, ok := jsValue.Export().(int64) + if !ok { + return nil, fmt.Errorf("expected uint, got %T", jsValue.Export()) + } + return reflect.ValueOf(uint(n)).Convert(targetType).Interface(), nil + + case reflect.Float32, reflect.Float64: + n, ok := jsValue.Export().(float64) + if !ok { + return nil, fmt.Errorf("expected float, got %T", jsValue.Export()) + } + return reflect.ValueOf(n).Convert(targetType).Interface(), nil + + case reflect.Bool: + return jsValue.ToBoolean(), nil + + case reflect.Interface: + return jsValue.Export(), nil + + case reflect.Map: + if goja.IsUndefined(jsValue) || goja.IsNull(jsValue) { + return nil, nil + } + + if targetType.Key().Kind() == reflect.String { + obj := jsValue.ToObject(vm) + if obj == nil { + return nil, fmt.Errorf("not an object") + } + + if targetType.Elem().Kind() == reflect.Interface { + result := make(map[string]any) + for _, key := range obj.Keys() { + result[key] = obj.Get(key).Export() + } + return result, nil + } else if targetType.Elem().Kind() == reflect.String { + result := make(map[string]string) + for _, key := range obj.Keys() { + v := obj.Get(key) + result[key] = v.String() + } + return result, nil + } + } + return nil, fmt.Errorf("unsupported map type: %v", targetType) + + case reflect.Struct: + obj := jsValue.ToObject(vm) + if obj == nil { + return nil, fmt.Errorf("not an object") + } + + result := reflect.New(targetType).Elem() + for i := 0; i < targetType.NumField(); i++ { + field := targetType.Field(i) + fieldName := getFieldName(field) + + jsField := obj.Get(fieldName) + + var err error + var converted any + func() { + defer func() { + if r := recover(); r != nil { + err = nil + converted = nil + } + }() + converted, err = convertJSValueToGo(vm, jsField, field.Type) + }() + + if err != nil { + return nil, fmt.Errorf("field %s: %v", fieldName, err) + } + + if converted == nil { + if field.Type.Kind() == reflect.Pointer || field.Type.Kind() == reflect.Map { + continue + } + } else { + result.Field(i).Set(reflect.ValueOf(converted)) + } + } + return result.Interface(), nil + + case reflect.Pointer: + if goja.IsNull(jsValue) || goja.IsUndefined(jsValue) { + return nil, nil + } + + elemType := targetType.Elem() + converted, err := convertJSValueToGo(vm, jsValue, elemType) + if err != nil { + return nil, err + } + + ptr := reflect.New(elemType) + ptr.Elem().Set(reflect.ValueOf(converted)) + return ptr.Interface(), nil + + default: + return nil, fmt.Errorf("unsupported type: %v", targetType) + } +} + +func convertGoValueToJS(vm *goja.Runtime, goValue reflect.Value) goja.Value { + value := goValue.Interface() + + switch v := value.(type) { + case string, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64, bool: + return vm.ToValue(v) + + case error: + return vm.ToValue(v.Error()) + + case map[string]string: + obj := vm.NewObject() + for key, val := range v { + _ = obj.Set(key, val) + } + return obj + + case map[string]any: + obj := vm.NewObject() + for key, val := range v { + _ = obj.Set(key, convertGoValueToJS(vm, reflect.ValueOf(val))) + } + return obj + + case []any: + arr := make([]goja.Value, len(v)) + for i, item := range v { + arr[i] = convertGoValueToJS(vm, reflect.ValueOf(item)) + } + return vm.ToValue(arr) + + default: + if goValue.Kind() == reflect.Pointer { + if goValue.IsNil() { + return goja.Null() + } + return convertGoValueToJS(vm, goValue.Elem()) + } + + if goValue.Kind() == reflect.Struct { + obj := vm.NewObject() + for i := 0; i < goValue.NumField(); i++ { + field := goValue.Type().Field(i) + fieldName := getFieldName(field) + _ = obj.Set(fieldName, convertGoValueToJS(vm, goValue.Field(i))) + } + return obj + } + + return vm.ToValue(v) + } +} + +func getFieldName(field reflect.StructField) string { + jsonTag := field.Tag.Get("json") + if jsonTag != "" && jsonTag != "-" { + name, _, _ := strings.Cut(jsonTag, ",") + return name + } + return field.Name +} diff --git a/internal/builtin/registry.go b/internal/builtin/registry.go new file mode 100644 index 0000000..6237412 --- /dev/null +++ b/internal/builtin/registry.go @@ -0,0 +1,90 @@ +package builtin + +import ( + "fmt" + "reflect" + "strings" + "sync" + + "github.com/dop251/goja" +) + +var ( + builtinRegistry = make(map[string]Builtin) + registryMutex sync.RWMutex + collector *typeCollector +) + +func registerBuiltin[T Args, R any](name string, isAsync bool, fn Func[T, R]) { + if collector == nil { + collector = newTypeCollector() + } + + var zeroT T + tType := reflect.TypeOf(zeroT) + + if tType.Kind() != reflect.Struct { + panic(fmt.Sprintf("builtin %s: argument must be a struct type, got %v", name, tType)) + } + + fnType := reflect.TypeOf(fn) + + wrapper := createWrapper(fn, isAsync) + types := collector.collectTypes(tType, fnType) + paramTypes := collector.getParamTypes() + + registryMutex.Lock() + b := Builtin{ + Name: name, + Function: wrapper, + Definition: generateTypeScriptDefinition(name, tType, fnType, isAsync, paramTypes), + Types: types, + ParamTypes: paramTypes, + } + builtinRegistry[name] = b + registryMutex.Unlock() +} + +func GetBuiltinsDeclarations() string { + registryMutex.RLock() + defer registryMutex.RUnlock() + + typeDefinitions := make(map[string]bool) + var typeDefs []string + var functionDecls []string + + for _, builtin := range builtinRegistry { + for _, t := range builtin.Types { + if !typeDefinitions[t] { + typeDefinitions[t] = true + typeDefs = append(typeDefs, t) + } + } + functionDecls = append(functionDecls, builtin.Definition) + } + + result := strings.Join(typeDefs, "\n\n") + if len(result) > 0 && len(functionDecls) > 0 { + result += "\n\n" + } + result += strings.Join(functionDecls, "\n") + + return result +} + +func RegisterBuiltin[T Args, R any](name string, fn Func[T, R]) { + registerBuiltin(name, false, fn) +} + +func RegisterAsyncBuiltin[T Args, R any](name string, fn Func[T, R]) { + registerBuiltin(name, true, fn) +} + +func RegisterBuiltins(vm *goja.Runtime) { + registryMutex.RLock() + defer registryMutex.RUnlock() + + for name, builtin := range builtinRegistry { + _ = vm.Set(name, builtin.Function(vm)) + } +} diff --git a/internal/builtin/types.go b/internal/builtin/types.go new file mode 100644 index 0000000..0403116 --- /dev/null +++ b/internal/builtin/types.go @@ -0,0 +1,27 @@ +package builtin + +import ( + "context" + + "github.com/dop251/goja" +) + +type Builtin struct { + Name string + Function func(*goja.Runtime) func(goja.FunctionCall) goja.Value + Definition string + Types []string + ParamTypes map[string]bool +} + +func (b *Builtin) HasParamType(typeName string) bool { + return b.ParamTypes[typeName] +} + +type EmptyArgs struct{} + +type Args interface { + Validate() error +} + +type Func[T Args, R any] func(ctx context.Context, args T) (R, error) diff --git a/internal/builtin/typescript.go b/internal/builtin/typescript.go new file mode 100644 index 0000000..3cc8020 --- /dev/null +++ b/internal/builtin/typescript.go @@ -0,0 +1,64 @@ +package builtin + +import ( + "fmt" + "reflect" + "strings" +) + +func generateTypeScriptDefinition(name string, argsType reflect.Type, fnType reflect.Type, isPromise bool, paramTypes map[string]bool) string { + if argsType.Kind() != reflect.Struct { + return "" + } + + var params []string + for i := 0; i < argsType.NumField(); i++ { + field := argsType.Field(i) + fieldName := getFieldName(field) + goType := field.Type + + var tsType string + var isOptional bool + isPointer := goType.Kind() == reflect.Pointer + + if isPointer { + isOptional = true + tsType = goTypeToTSType(goType, true) + if !strings.Contains(tsType, " | null") { + tsType += " | null" + } + } else { + isOptional = strings.Contains(field.Tag.Get("json"), ",omitempty") + tsType = goTypeToTSType(goType, false) + if isOptional && paramTypes[tsType+" | null"] { + tsType += " | null" + } + } + + if isOptional { + fieldName += "?" + } + params = append(params, fmt.Sprintf("%s: %s", fieldName, tsType)) + } + + returnSignature := "any" + if fnType.Kind() == reflect.Func && fnType.NumOut() > 0 { + lastIndex := fnType.NumOut() - 1 + lastType := fnType.Out(lastIndex) + + if lastType.Implements(reflect.TypeOf((*error)(nil)).Elem()) { + if fnType.NumOut() > 1 { + returnType := fnType.Out(0) + returnSignature = goTypeToTSType(returnType, returnType.Kind() == reflect.Pointer) + } + } else { + returnSignature = goTypeToTSType(lastType, lastType.Kind() == reflect.Pointer) + } + } + + if isPromise { + returnSignature = fmt.Sprintf("Promise<%s>", returnSignature) + } + + return fmt.Sprintf("declare function %s(%s): %s;", name, strings.Join(params, ", "), returnSignature) +} diff --git a/internal/builtin/typescript_test.go b/internal/builtin/typescript_test.go new file mode 100644 index 0000000..84b16fe --- /dev/null +++ b/internal/builtin/typescript_test.go @@ -0,0 +1,273 @@ +package builtin + +import ( + "context" + "reflect" + "sync" + "testing" + + "github.com/stretchr/testify/assert" +) + +type TestBasicArgs struct { + Name string `json:"name"` + Age int `json:"age"` +} + +func (t TestBasicArgs) Validate() error { return nil } + +func TestBasicType(t *testing.T) { + resetRegistry() + RegisterBuiltin[TestBasicArgs, string]("basic", func(ctx context.Context, args TestBasicArgs) (string, error) { + return args.Name, nil + }) + + defs := GetBuiltinsDeclarations() + assert.Contains(t, defs, "declare function basic(name: string, age: number): string;") + assert.Contains(t, defs, "interface TestBasicArgs") +} + +func resetRegistry() { + registryLock.Lock() + defer registryLock.Unlock() + builtinRegistry = make(map[string]Builtin) +} + +var ( + registryLock sync.Mutex +) + +type TestComplexArgs struct { + Items []int `json:"items"` + Data map[string]any `json:"data"` + Flag bool `json:"flag"` +} + +func (t TestComplexArgs) Validate() error { return nil } + +func TestComplexTypes(t *testing.T) { + resetRegistry() + RegisterBuiltin[TestComplexArgs, bool]("complex", func(ctx context.Context, args TestComplexArgs) (bool, error) { + return args.Flag, nil + }) + + defs := GetBuiltinsDeclarations() + assert.Contains(t, defs, "declare function complex(items: number[], data: Record, flag: boolean): boolean;") +} + +type TestNestedArgs struct { + User struct { + FirstName string `json:"firstName"` + LastName string `json:"lastName"` + } `json:"user"` +} + +func (t TestNestedArgs) Validate() error { return nil } + +func TestNestedStruct(t *testing.T) { + resetRegistry() + RegisterBuiltin[TestNestedArgs, string]("nested", func(ctx context.Context, args TestNestedArgs) (string, error) { + return args.User.FirstName, nil + }) + + defs := GetBuiltinsDeclarations() + assert.Contains(t, defs, "declare function nested(user: {}): string;") +} + +type TestOptionalArgs struct { + Name string `json:"name"` + Age *int `json:"age,omitempty"` + Score *int `json:"score"` +} + +func (t TestOptionalArgs) Validate() error { return nil } + +func TestOptionalFields(t *testing.T) { + resetRegistry() + RegisterBuiltin[TestOptionalArgs, string]("optional", func(ctx context.Context, args TestOptionalArgs) (string, error) { + return args.Name, nil + }) + + defs := GetBuiltinsDeclarations() + assert.Contains(t, defs, "declare function optional(name: string, age?: number | null, score?: number | null): string;") +} + +type TestResult struct { + ID int `json:"id"` + Data []byte `json:"data"` +} + +type TestResultArgs struct { + Input string `json:"input"` +} + +func (t TestResultArgs) Validate() error { return nil } + +func TestResultStruct(t *testing.T) { + resetRegistry() + RegisterBuiltin[TestResultArgs, TestResult]("result", func(ctx context.Context, args TestResultArgs) (TestResult, error) { + return TestResult{ID: 1}, nil + }) + + defs := GetBuiltinsDeclarations() + assert.Contains(t, defs, "declare function result(input: string): TestResult;") + assert.Contains(t, defs, "interface TestResult {id: number; data: number[]}") +} + +type TestAsyncArgs struct { + URL string `json:"url"` +} + +func (t TestAsyncArgs) Validate() error { return nil } + +type TestAsyncResult struct { + Status int `json:"status"` +} + +func TestAsyncPromise(t *testing.T) { + resetRegistry() + RegisterAsyncBuiltin[TestAsyncArgs, *TestAsyncStatus]("async", func(ctx context.Context, args TestAsyncArgs) (*TestAsyncStatus, error) { + return &TestAsyncStatus{Code: 200}, nil + }) + + defs := GetBuiltinsDeclarations() + assert.Contains(t, defs, "declare function async(url: string): Promise;") + assert.Contains(t, defs, "interface TestAsyncStatus") +} + +type TestAsyncStatus struct { + Code int `json:"code"` +} + +type TestNestedPointerResult struct { + Value string `json:"value"` +} + +type TestNestedPointerArgs struct { + ID int `json:"id"` +} + +func (t TestNestedPointerArgs) Validate() error { return nil } + +func TestNestedPointerInResult(t *testing.T) { + resetRegistry() + RegisterBuiltin[TestNestedPointerArgs, *TestNestedPointerResult]("pointerResult", func(ctx context.Context, args TestNestedPointerArgs) (*TestNestedPointerResult, error) { + return &TestNestedPointerResult{Value: "test"}, nil + }) + + defs := GetBuiltinsDeclarations() + assert.Contains(t, defs, "declare function pointerResult(id: number): TestNestedPointerResult | null;") +} + +type TestUintArgs struct { + Value uint `json:"value"` +} + +func (t TestUintArgs) Validate() error { return nil } + +func TestUintType(t *testing.T) { + resetRegistry() + RegisterBuiltin[TestUintArgs, uint]("uint", func(ctx context.Context, args TestUintArgs) (uint, error) { + return args.Value, nil + }) + + defs := GetBuiltinsDeclarations() + assert.Contains(t, defs, "declare function uint(value: number): number;") +} + +type TestFloatArgs struct { + Amount float64 `json:"amount"` +} + +func (t TestFloatArgs) Validate() error { return nil } + +func TestFloatType(t *testing.T) { + resetRegistry() + RegisterBuiltin[TestFloatArgs, float32]("float", func(ctx context.Context, args TestFloatArgs) (float32, error) { + return float32(args.Amount), nil + }) + + defs := GetBuiltinsDeclarations() + assert.Contains(t, defs, "declare function float(amount: number): number;") +} + +type TestPointerInArgs struct { + User *struct { + Name string `json:"name"` + } `json:"user"` +} + +func (t TestPointerInArgs) Validate() error { return nil } + +func TestNestedPointerStruct(t *testing.T) { + resetRegistry() + RegisterBuiltin[TestPointerInArgs, string]("nestedPointer", func(ctx context.Context, args TestPointerInArgs) (string, error) { + return "test", nil + }) + + defs := GetBuiltinsDeclarations() + assert.Contains(t, defs, "declare function nestedPointer(user?: {} | null): string;") +} + +type TestErrorOnlyArgs struct { + Input string `json:"input"` +} + +func (t TestErrorOnlyArgs) Validate() error { return nil } + +func TestErrorOnlyReturn(t *testing.T) { + resetRegistry() + RegisterBuiltin[TestErrorOnlyArgs, struct{}]("errorOnly", func(ctx context.Context, args TestErrorOnlyArgs) (struct{}, error) { + return struct{}{}, nil + }) + + defs := GetBuiltinsDeclarations() + assert.Contains(t, defs, "declare function errorOnly(input: string): {};") +} + +func TestGoTypeToTSTypeBasic(t *testing.T) { + tests := []struct { + input reflect.Type + inputPtr bool + expected string + }{ + {reflect.TypeOf(""), false, "string"}, + {reflect.TypeOf(0), false, "number"}, + {reflect.TypeOf(int64(0)), false, "number"}, + {reflect.TypeOf(uint(0)), false, "number"}, + {reflect.TypeOf(3.14), false, "number"}, + {reflect.TypeOf(float32(0.0)), false, "number"}, + {reflect.TypeOf(true), false, "boolean"}, + {reflect.TypeOf([]string{}), false, "string[]"}, + {reflect.TypeOf([]int{}), false, "number[]"}, + {reflect.TypeOf(map[string]any{}), false, "Record"}, + {reflect.TypeOf(map[string]int{}), false, "Record"}, + } + + for _, tt := range tests { + t.Run(tt.expected, func(t *testing.T) { + result := goTypeToTSType(tt.input, tt.inputPtr) + assert.Equal(t, tt.expected, result) + }) + } +} + +type TestNestedStructField struct { + Inner struct { + Name string `json:"name"` + } `json:"inner"` +} + +func TestGoTypeToTSTypeNestedStruct(t *testing.T) { + result := goTypeToTSType(reflect.TypeOf(TestNestedStructField{}), false) + assert.Equal(t, "TestNestedStructField", result) +} + +type TestArrayField struct { + Items []string `json:"items"` +} + +func TestGoTypeToTSTypeArray(t *testing.T) { + result := goTypeToTSType(reflect.TypeOf(TestArrayField{}), false) + assert.Equal(t, "TestArrayField", result) +} diff --git a/internal/builtin/wrapper.go b/internal/builtin/wrapper.go new file mode 100644 index 0000000..5531fa2 --- /dev/null +++ b/internal/builtin/wrapper.go @@ -0,0 +1,72 @@ +package builtin + +import ( + "context" + "fmt" + "reflect" + + "github.com/dop251/goja" +) + +func createWrapper[T Args, R any](fn Func[T, R], isAsync bool) func(*goja.Runtime) func(goja.FunctionCall) goja.Value { + return func(vm *goja.Runtime) func(goja.FunctionCall) goja.Value { + return func(call goja.FunctionCall) goja.Value { + var args T + argsValue := reflect.ValueOf(&args).Elem() + + for i := 0; i < argsValue.NumField() && i < len(call.Arguments); i++ { + jsArg := call.Arguments[i] + field := argsValue.Field(i) + + if goja.IsUndefined(jsArg) || goja.IsNull(jsArg) { + if field.Kind() == reflect.Pointer { + continue + } + } + + converted, err := convertJSValueToGo(vm, jsArg, field.Type()) + if err != nil { + panic(fmt.Sprintf("argument %d (%s): %v", i, getFieldName(argsValue.Type().Field(i)), err)) + } + + if converted != nil { + field.Set(reflect.ValueOf(converted)) + } + } + + if err := args.Validate(); err != nil { + panic(fmt.Sprintf("argument validation failed: %v", err)) + } + + if isAsync { + return createAsyncPromise(vm, fn, args) + } + + ctx := context.Background() + result, err := fn(ctx, args) + + if err != nil { + panic(err) + } + + return convertGoValueToJS(vm, reflect.ValueOf(result)) + } + } +} + +func createAsyncPromise[T Args, R any](vm *goja.Runtime, fn Func[T, R], args T) goja.Value { + promise, resolve, reject := vm.NewPromise() + + go func() { + ctx := context.Background() + result, err := fn(ctx, args) + + if err != nil { + _ = reject(vm.ToValue(err.Error())) + } else { + _ = resolve(convertGoValueToJS(vm, reflect.ValueOf(result))) + } + }() + + return vm.ToValue(promise) +} diff --git a/internal/runtime/pkg/builtin/builtin.go b/internal/runtime/pkg/builtin/builtin.go deleted file mode 100644 index 439de41..0000000 --- a/internal/runtime/pkg/builtin/builtin.go +++ /dev/null @@ -1,463 +0,0 @@ -package builtin - -import ( - "fmt" - "reflect" - "strings" - "sync" - - "github.com/dop251/goja" -) - -type Builtin struct { - Name string - Function func(*goja.Runtime) func(goja.FunctionCall) goja.Value - Definition string - isPromise bool -} - -type EmptyArgs struct{} - -type RegisterOption func(*Builtin) error - -func WithPromise() RegisterOption { - return func(b *Builtin) error { - b.isPromise = true - return nil - } -} - -var ( - builtinRegistry = make(map[string]Builtin) - registryMutex sync.RWMutex -) - -func RegisterBuiltin[T any, R any](name string, fn any, opts ...RegisterOption) { - var zeroT T - tType := reflect.TypeOf(zeroT) - - if tType.Kind() != reflect.Struct { - panic(fmt.Sprintf("builtin %s: argument must be a struct type, got %v", name, tType)) - } - - fnType := reflect.TypeOf(fn) - - isPromise := false - for _, opt := range opts { - if opt != nil { - isPromise = true - break - } - } - - wrapper := createWrapper[T](fn, fnType, isPromise) - - registryMutex.Lock() - b := Builtin{ - Name: name, - Function: wrapper, - Definition: generateTypeScriptDefinition(name, tType, fnType, isPromise), - } - for _, opt := range opts { - if opt != nil { - if err := opt(&b); err != nil { - panic(fmt.Sprintf("builtin %s: option error: %v", name, err)) - } - } - } - builtinRegistry[name] = b - registryMutex.Unlock() -} - -func createWrapper[T any](fn any, fnType reflect.Type, isPromise bool) func(*goja.Runtime) func(goja.FunctionCall) goja.Value { - return func(vm *goja.Runtime) func(goja.FunctionCall) goja.Value { - return func(call goja.FunctionCall) goja.Value { - var args T - argsValue := reflect.ValueOf(&args).Elem() - - for i := 0; i < argsValue.NumField() && i < len(call.Arguments); i++ { - jsArg := call.Arguments[i] - field := argsValue.Field(i) - - if goja.IsUndefined(jsArg) || goja.IsNull(jsArg) { - if field.Kind() == reflect.Pointer { - continue - } - } - - converted, err := convertJSValueToGo(vm, jsArg, field.Type()) - if err != nil { - panic(fmt.Sprintf("argument %d (%s): %v", i, getFieldName(argsValue.Type().Field(i)), err)) - } - - if converted != nil { - field.Set(reflect.ValueOf(converted)) - } - } - - if defaults, ok := any(args).(interface{ Defaults() T }); ok { - args = defaults.Defaults() - } - - fnValue := reflect.ValueOf(fn) - firstParamType := fnType.In(0) - argValue := reflect.ValueOf(args).Convert(firstParamType) - results := fnValue.Call([]reflect.Value{argValue}) - - if len(results) == 0 { - return goja.Undefined() - } - - if err, isError := results[len(results)-1].Interface().(error); isError { - if err != nil { - if isPromise { - return createRejectedPromise(vm, err) - } - panic(err) - } - if len(results) == 1 { - if isPromise { - return createResolvedPromise(vm) - } - return goja.Undefined() - } - if isPromise { - return createResolvedPromise(vm, results[0]) - } - return convertGoValueToJS(vm, results[0]) - } - - if isPromise { - return createResolvedPromise(vm, results[0]) - } - - return convertGoValueToJS(vm, results[0]) - } - } -} - -func createResolvedPromise(vm *goja.Runtime, value ...reflect.Value) goja.Value { - promise, resolve, _ := vm.NewPromise() - go func() { - if len(value) > 0 { - jsValue := convertGoValueToJS(vm, value[0]) - _ = resolve(jsValue) - } else { - _ = resolve(goja.Undefined()) - } - }() - return vm.ToValue(promise) -} - -func createRejectedPromise(vm *goja.Runtime, err error) goja.Value { - promise, _, reject := vm.NewPromise() - go func() { - _ = reject(vm.ToValue(err.Error())) - }() - return vm.ToValue(promise) -} - -func convertJSValueToGo(vm *goja.Runtime, jsValue goja.Value, targetType reflect.Type) (any, error) { - if goja.IsNull(jsValue) { - if targetType.Kind() == reflect.Pointer || targetType.Kind() == reflect.Map { - return nil, nil - } - return nil, fmt.Errorf("cannot convert null/undefined to %v", targetType) - } - - if goja.IsUndefined(jsValue) { - if targetType.Kind() == reflect.Pointer || targetType.Kind() == reflect.Map { - return nil, nil - } - return nil, fmt.Errorf("cannot convert null/undefined to %v", targetType) - } - - switch targetType.Kind() { - case reflect.String: - return jsValue.String(), nil - - case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: - n, ok := jsValue.Export().(int64) - if !ok { - return nil, fmt.Errorf("expected int, got %T", jsValue.Export()) - } - return reflect.ValueOf(n).Convert(targetType).Interface(), nil - - case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: - n, ok := jsValue.Export().(int64) - if !ok { - return nil, fmt.Errorf("expected uint, got %T", jsValue.Export()) - } - return reflect.ValueOf(uint(n)).Convert(targetType).Interface(), nil - - case reflect.Float32, reflect.Float64: - n, ok := jsValue.Export().(float64) - if !ok { - return nil, fmt.Errorf("expected float, got %T", jsValue.Export()) - } - return reflect.ValueOf(n).Convert(targetType).Interface(), nil - - case reflect.Bool: - return jsValue.ToBoolean(), nil - - case reflect.Interface: - return jsValue.Export(), nil - - case reflect.Map: - if goja.IsUndefined(jsValue) || goja.IsNull(jsValue) { - return nil, nil - } - - if targetType.Key().Kind() == reflect.String { - obj := jsValue.ToObject(vm) - if obj == nil { - return nil, fmt.Errorf("not an object") - } - - if targetType.Elem().Kind() == reflect.Interface { - result := make(map[string]any) - for _, key := range obj.Keys() { - result[key] = obj.Get(key).Export() - } - return result, nil - } else if targetType.Elem().Kind() == reflect.String { - result := make(map[string]string) - for _, key := range obj.Keys() { - v := obj.Get(key) - result[key] = v.String() - } - return result, nil - } - } - return nil, fmt.Errorf("unsupported map type: %v", targetType) - - case reflect.Struct: - obj := jsValue.ToObject(vm) - if obj == nil { - return nil, fmt.Errorf("not an object") - } - - result := reflect.New(targetType).Elem() - for i := 0; i < targetType.NumField(); i++ { - field := targetType.Field(i) - fieldName := getFieldName(field) - - jsField := obj.Get(fieldName) - - var err error - var converted any - func() { - defer func() { - if r := recover(); r != nil { - // goja.Value was zero - treat as undefined - err = nil - converted = nil - } - }() - converted, err = convertJSValueToGo(vm, jsField, field.Type) - }() - - if err != nil { - return nil, fmt.Errorf("field %s: %v", fieldName, err) - } - - if converted == nil { - if field.Type.Kind() == reflect.Pointer || field.Type.Kind() == reflect.Map { - continue - } - } else { - result.Field(i).Set(reflect.ValueOf(converted)) - } - } - return result.Interface(), nil - - case reflect.Pointer: - if goja.IsNull(jsValue) || goja.IsUndefined(jsValue) { - return nil, nil - } - - elemType := targetType.Elem() - converted, err := convertJSValueToGo(vm, jsValue, elemType) - if err != nil { - return nil, err - } - - ptr := reflect.New(elemType) - ptr.Elem().Set(reflect.ValueOf(converted)) - return ptr.Interface(), nil - - default: - return nil, fmt.Errorf("unsupported type: %v", targetType) - } -} - -func convertGoValueToJS(vm *goja.Runtime, goValue reflect.Value) goja.Value { - value := goValue.Interface() - - switch v := value.(type) { - case string, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64, bool: - return vm.ToValue(v) - - case error: - return vm.ToValue(v.Error()) - - case map[string]string: - obj := vm.NewObject() - for key, val := range v { - _ = obj.Set(key, val) - } - return obj - - case map[string]any: - obj := vm.NewObject() - for key, val := range v { - _ = obj.Set(key, convertGoValueToJS(vm, reflect.ValueOf(val))) - } - return obj - - case []any: - arr := make([]goja.Value, len(v)) - for i, item := range v { - arr[i] = convertGoValueToJS(vm, reflect.ValueOf(item)) - } - return vm.ToValue(arr) - - default: - if goValue.Kind() == reflect.Pointer { - if goValue.IsNil() { - return goja.Null() - } - return convertGoValueToJS(vm, goValue.Elem()) - } - - if goValue.Kind() == reflect.Struct { - obj := vm.NewObject() - for i := 0; i < goValue.NumField(); i++ { - field := goValue.Type().Field(i) - fieldName := getFieldName(field) - _ = obj.Set(fieldName, convertGoValueToJS(vm, goValue.Field(i))) - } - return obj - } - - return vm.ToValue(v) - } -} - -func getFieldName(field reflect.StructField) string { - jsonTag := field.Tag.Get("json") - if jsonTag != "" && jsonTag != "-" { - name, _, _ := strings.Cut(jsonTag, ",") - return name - } - return field.Name -} - -func generateTypeScriptDefinition(name string, argsType reflect.Type, fnType reflect.Type, isPromise bool) string { - if argsType.Kind() != reflect.Struct { - return "" - } - - var params []string - for i := 0; i < argsType.NumField(); i++ { - field := argsType.Field(i) - fieldName := getFieldName(field) - goType := field.Type - - tsType := goTypeToTSType(goType, goType.Kind() == reflect.Pointer) - params = append(params, fmt.Sprintf("%s: %s", fieldName, tsType)) - } - - returnSignature := "any" - if fnType.Kind() == reflect.Func && fnType.NumOut() > 0 { - lastIndex := fnType.NumOut() - 1 - lastType := fnType.Out(lastIndex) - - if lastType.Implements(reflect.TypeOf((*error)(nil)).Elem()) { - if fnType.NumOut() > 1 { - returnSignature = goTypeToTSType(fnType.Out(0), false) - } - } else { - returnSignature = goTypeToTSType(lastType, false) - } - } - - if isPromise { - returnSignature = fmt.Sprintf("Promise<%s>", returnSignature) - } - - return fmt.Sprintf("declare function %s(%s): %s;", name, strings.Join(params, ", "), returnSignature) -} - -func goTypeToTSType(t reflect.Type, isPointer bool) string { - if isPointer { - if t.Kind() == reflect.Pointer { - return goTypeToTSType(t.Elem(), false) + " | null" - } - return goTypeToTSType(t, false) + " | null" - } - - switch t.Kind() { - case reflect.String: - return "string" - case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: - return "number" - case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: - return "number" - case reflect.Float32, reflect.Float64: - return "number" - case reflect.Bool: - return "boolean" - case reflect.Interface: - return "any" - case reflect.Slice: - return fmt.Sprintf("%s[]", goTypeToTSType(t.Elem(), false)) - case reflect.Map: - if t.Key().Kind() == reflect.String && t.Elem().Kind() == reflect.Interface { - return "Record" - } - return "Record" - case reflect.Struct: - fields := make([]string, 0, t.NumField()) - for i := 0; i < t.NumField(); i++ { - field := t.Field(i) - name := getFieldName(field) - tsType := goTypeToTSType(field.Type, field.Type.Kind() == reflect.Pointer) - if field.Type.Kind() == reflect.Pointer { - tsType = strings.TrimSuffix(tsType, " | null") - tsType += "?" - } else if strings.Contains(field.Tag.Get("json"), ",omitempty") { - tsType += "?" - } - fields = append(fields, fmt.Sprintf("%s: %s", name, tsType)) - } - return fmt.Sprintf("{ %s }", strings.Join(fields, "; ")) - case reflect.Pointer: - if t.Elem().Kind() == reflect.Struct { - return goTypeToTSType(t.Elem(), false) - } - return "any" - default: - return "any" - } -} - -func GetBuiltinsDeclarations() string { - registryMutex.RLock() - defer registryMutex.RUnlock() - - var decls []string - for _, builtin := range builtinRegistry { - decls = append(decls, builtin.Definition) - } - return strings.Join(decls, "\n") -} - -func RegisterBuiltins(vm *goja.Runtime) { - registryMutex.RLock() - defer registryMutex.RUnlock() - - for name, builtin := range builtinRegistry { - _ = vm.Set(name, builtin.Function(vm)) - } -} diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index 5544d08..a504779 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -7,8 +7,7 @@ import ( "github.com/dop251/goja" "github.com/evanw/esbuild/pkg/api" - "reichard.io/poiesis/internal/runtime/pkg/builtin" - _ "reichard.io/poiesis/internal/runtime/standard" + "reichard.io/poiesis/internal/builtin" ) type Runtime struct { @@ -18,12 +17,12 @@ type Runtime struct { } func New() *Runtime { - vm := goja.New() - - r := &Runtime{vm: vm, stdout: os.Stdout, stderr: os.Stderr} + // Create Runtime + r := &Runtime{vm: goja.New(), stdout: os.Stdout, stderr: os.Stderr} r.setupConsole() - builtin.RegisterBuiltins(vm) + // Register Builtins + builtin.RegisterBuiltins(r.vm) return r } diff --git a/internal/runtime/runtime_test.go b/internal/runtime/runtime_test.go index c85c1a7..7c919f9 100644 --- a/internal/runtime/runtime_test.go +++ b/internal/runtime/runtime_test.go @@ -5,6 +5,8 @@ import ( "strings" "testing" + _ "reichard.io/poiesis/internal/standard" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) diff --git a/internal/runtime/standard/fetch.go b/internal/standard/fetch.go similarity index 59% rename from internal/runtime/standard/fetch.go rename to internal/standard/fetch.go index 1150f37..1c02b2e 100644 --- a/internal/runtime/standard/fetch.go +++ b/internal/standard/fetch.go @@ -1,34 +1,39 @@ package standard import ( + "context" "fmt" "io" "maps" "net/http" "strings" - "reichard.io/poiesis/internal/runtime/pkg/builtin" + "reichard.io/poiesis/internal/builtin" ) type FetchArgs struct { - Input string `json:"input"` - Init *FetchOptions `json:"init,omitempty"` + Input string `json:"input"` + Init *RequestInit `json:"init,omitempty"` } -type FetchOptions struct { +func (f FetchArgs) Validate() error { + return nil +} + +type RequestInit struct { Method string `json:"method,omitempty"` Headers map[string]string `json:"headers,omitempty"` Body *string `json:"body,omitempty"` } -func (o *FetchOptions) Defaults() *FetchOptions { +func (o *RequestInit) Validate() error { if o.Method == "" { o.Method = "GET" } - return o + return nil } -type FetchResult struct { +type Response struct { OK bool `json:"ok"` Status int `json:"status"` Body string `json:"body"` @@ -40,11 +45,19 @@ type AddArgs struct { B int `json:"b"` } +func (a AddArgs) Validate() error { + return nil +} + type GreetArgs struct { Name string `json:"name"` } -func Fetch(args FetchArgs) (*FetchResult, error) { +func (g GreetArgs) Validate() error { + return nil +} + +func Fetch(_ context.Context, args FetchArgs) (Response, error) { method := "GET" headers := make(map[string]string) @@ -57,7 +70,7 @@ func Fetch(args FetchArgs) (*FetchResult, error) { req, err := http.NewRequest(method, args.Input, nil) if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) + return Response{}, fmt.Errorf("failed to create request: %w", err) } for k, v := range headers { @@ -66,7 +79,7 @@ func Fetch(args FetchArgs) (*FetchResult, error) { resp, err := http.DefaultClient.Do(req) if err != nil { - return nil, fmt.Errorf("failed to fetch: %w", err) + return Response{}, fmt.Errorf("failed to fetch: %w", err) } defer func() { _ = resp.Body.Close() @@ -74,7 +87,7 @@ func Fetch(args FetchArgs) (*FetchResult, error) { body, err := io.ReadAll(resp.Body) if err != nil { - return nil, fmt.Errorf("failed to read body: %w", err) + return Response{}, fmt.Errorf("failed to read body: %w", err) } resultHeaders := make(map[string]string) @@ -86,7 +99,7 @@ func Fetch(args FetchArgs) (*FetchResult, error) { } } - return &FetchResult{ + return Response{ OK: resp.StatusCode >= 200 && resp.StatusCode < 300, Status: resp.StatusCode, Body: string(body), @@ -94,16 +107,16 @@ func Fetch(args FetchArgs) (*FetchResult, error) { }, nil } -func add(args AddArgs) int { - return args.A + args.B +func Add(_ context.Context, args AddArgs) (int, error) { + return args.A + args.B, nil } -func greet(args GreetArgs) string { - return fmt.Sprintf("Hello, %s!", args.Name) +func Greet(_ context.Context, args GreetArgs) (string, error) { + return fmt.Sprintf("Hello, %s!", args.Name), nil } func init() { - builtin.RegisterBuiltin[FetchArgs, *FetchResult]("fetch", Fetch, builtin.WithPromise()) - builtin.RegisterBuiltin[AddArgs, int]("add", add) - builtin.RegisterBuiltin[GreetArgs, string]("greet", greet) + builtin.RegisterAsyncBuiltin("fetch", Fetch) + builtin.RegisterBuiltin("add", Add) + builtin.RegisterBuiltin("greet", Greet) } diff --git a/internal/standard/fetch_promise_test.go b/internal/standard/fetch_promise_test.go new file mode 100644 index 0000000..495e810 --- /dev/null +++ b/internal/standard/fetch_promise_test.go @@ -0,0 +1,51 @@ +package standard + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/dop251/goja" + + "reichard.io/poiesis/internal/builtin" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFetchReturnsPromise(t *testing.T) { + vm := goja.New() + builtin.RegisterBuiltins(vm) + + result, err := vm.RunString(`fetch({input: "https://example.com"})`) + require.NoError(t, err) + + promise, ok := result.Export().(*goja.Promise) + require.True(t, ok, "fetch should return a Promise") + assert.NotNil(t, promise) +} + +func TestFetchAsyncAwait(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"status":"ok"}`)) + })) + defer server.Close() + + vm := goja.New() + builtin.RegisterBuiltins(vm) + + result, err := vm.RunString(` + async function testFetch() { + const response = await fetch({input: "` + server.URL + `"}); + return response.ok; + } + testFetch(); + `) + require.NoError(t, err) + + promise, ok := result.Export().(*goja.Promise) + require.True(t, ok, "async function should return a Promise") + assert.NotNil(t, promise) +} diff --git a/internal/runtime/standard/fetch_test.go b/internal/standard/fetch_test.go similarity index 70% rename from internal/runtime/standard/fetch_test.go rename to internal/standard/fetch_test.go index d7f7201..2a7b605 100644 --- a/internal/runtime/standard/fetch_test.go +++ b/internal/standard/fetch_test.go @@ -1,6 +1,7 @@ package standard import ( + "context" "net/http" "net/http/httptest" "testing" @@ -10,6 +11,7 @@ import ( ) func TestFetch(t *testing.T) { + ctx := context.Background() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.Header().Set("X-Custom-Header", "test-value") @@ -18,7 +20,7 @@ func TestFetch(t *testing.T) { })) defer server.Close() - result, err := Fetch(FetchArgs{Input: server.URL}) + result, err := Fetch(ctx, FetchArgs{Input: server.URL}) require.NoError(t, err) assert.True(t, result.OK) @@ -31,8 +33,9 @@ func TestFetch(t *testing.T) { func TestFetchHTTPBin(t *testing.T) { t.Skip("httpbin.org test is flaky") + ctx := context.Background() - result, err := Fetch(FetchArgs{Input: "https://httpbin.org/get"}) + result, err := Fetch(ctx, FetchArgs{Input: "https://httpbin.org/get"}) require.NoError(t, err) assert.True(t, result.OK) @@ -42,7 +45,8 @@ func TestFetchHTTPBin(t *testing.T) { } func TestFetchWith404(t *testing.T) { - result, err := Fetch(FetchArgs{Input: "https://httpbin.org/status/404"}) + ctx := context.Background() + result, err := Fetch(ctx, FetchArgs{Input: "https://httpbin.org/status/404"}) require.NoError(t, err) assert.False(t, result.OK) @@ -50,12 +54,14 @@ func TestFetchWith404(t *testing.T) { } func TestFetchWithInvalidURL(t *testing.T) { - _, err := Fetch(FetchArgs{Input: "http://this-domain-does-not-exist-12345.com"}) + ctx := context.Background() + _, err := Fetch(ctx, FetchArgs{Input: "http://this-domain-does-not-exist-12345.com"}) assert.Error(t, err) assert.Contains(t, err.Error(), "failed to fetch") } func TestFetchWithHeaders(t *testing.T) { + ctx := context.Background() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization")) assert.Equal(t, "GET", r.Method) @@ -67,16 +73,17 @@ func TestFetchWithHeaders(t *testing.T) { headers := map[string]string{ "Authorization": "Bearer test-token", } - options := &FetchOptions{ + options := &RequestInit{ Method: "GET", Headers: headers, } - result, err := Fetch(FetchArgs{Input: server.URL, Init: options}) + result, err := Fetch(ctx, FetchArgs{Input: server.URL, Init: options}) require.NoError(t, err) assert.True(t, result.OK) } func TestFetchDefaults(t *testing.T) { + ctx := context.Background() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "GET", r.Method, "default method should be GET") w.WriteHeader(http.StatusOK) @@ -84,24 +91,30 @@ func TestFetchDefaults(t *testing.T) { })) defer server.Close() - options := &FetchOptions{} - result, err := Fetch(FetchArgs{Input: server.URL, Init: options}) + options := &RequestInit{} + result, err := Fetch(ctx, FetchArgs{Input: server.URL, Init: options}) require.NoError(t, err) assert.True(t, result.OK) } func TestAdd(t *testing.T) { - result := add(AddArgs{A: 5, B: 10}) + ctx := context.Background() + result, err := Add(ctx, AddArgs{A: 5, B: 10}) + require.NoError(t, err) assert.Equal(t, 15, result) - result = add(AddArgs{A: -3, B: 7}) + result, err = Add(ctx, AddArgs{A: -3, B: 7}) + require.NoError(t, err) assert.Equal(t, 4, result) } func TestGreet(t *testing.T) { - result := greet(GreetArgs{Name: "World"}) + ctx := context.Background() + result, err := Greet(ctx, GreetArgs{Name: "World"}) + require.NoError(t, err) assert.Equal(t, "Hello, World!", result) - result = greet(GreetArgs{Name: "Alice"}) + result, err = Greet(ctx, GreetArgs{Name: "Alice"}) + require.NoError(t, err) assert.Equal(t, "Hello, Alice!", result) } diff --git a/test_data/fetch_promise_test.ts b/test_data/fetch_promise_test.ts new file mode 100644 index 0000000..debca69 --- /dev/null +++ b/test_data/fetch_promise_test.ts @@ -0,0 +1,11 @@ +async function logPromiseResult() { + try { + const response = await fetch({input: "https://httpbin.org/get"}); + console.log("Fetch successful, OK:", response.ok); + console.log("Status:", response.status); + } catch (error) { + console.log("Fetch failed:", error); + } +} + +logPromiseResult();