diff --git a/cmd/poiesis/main.go b/cmd/poiesis/main.go index 8caf775..4ee3ccc 100644 --- a/cmd/poiesis/main.go +++ b/cmd/poiesis/main.go @@ -5,9 +5,8 @@ import ( "fmt" "os" - "reichard.io/poiesis/internal/builtin" + "reichard.io/poiesis/internal/functions" "reichard.io/poiesis/internal/runtime" - _ "reichard.io/poiesis/internal/standard" ) func main() { @@ -19,7 +18,7 @@ func main() { // Print Types if os.Args[1] == "-print-types" { - fmt.Println(builtin.GetBuiltinsDeclarations()) + fmt.Println(functions.GetFunctionDeclarations()) return } @@ -31,7 +30,7 @@ func main() { // Run File filePath := os.Args[1] - if err := rt.RunFile(filePath, os.Stdout, os.Stderr); err != nil { + if err := rt.RunFile(filePath); err != nil { panic(err) } } diff --git a/go.mod b/go.mod index 293f500..c32f1cd 100644 --- a/go.mod +++ b/go.mod @@ -11,11 +11,13 @@ require ( require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect + github.com/fastschema/qjs v0.0.6 // indirect github.com/google/uuid v1.6.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/tetratelabs/wazero v1.9.0 // indirect golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect golang.org/x/sys v0.37.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index 2cd52f5..dfa0220 100644 --- a/go.sum +++ b/go.sum @@ -4,6 +4,8 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/evanw/esbuild v0.27.2 h1:3xBEws9y/JosfewXMM2qIyHAi+xRo8hVx475hVkJfNg= github.com/evanw/esbuild v0.27.2/go.mod h1:D2vIQZqV/vIf/VRHtViaUtViZmG7o+kKmlBfVQuRi48= +github.com/fastschema/qjs v0.0.6 h1:C45KMmQMd21UwsUAmQHxUxiWOfzwTg1GJW0DA0AbFEE= +github.com/fastschema/qjs v0.0.6/go.mod h1:bbg36wxXnx8g0FdKIe5+nCubrQvHa7XEVWqUptjHt/A= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= @@ -18,6 +20,8 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tetratelabs/wazero v1.9.0 h1:IcZ56OuxrtaEz8UYNRHBrUa9bYeX9oVY93KspZZBf/I= +github.com/tetratelabs/wazero v1.9.0/go.mod h1:TSbcXCfFP0L2FGkRPxHphadXPjo1T6W+CseNNY7EkjM= golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk= diff --git a/internal/functions/functions_test.go b/internal/functions/functions_test.go index a44165b..abb9a09 100644 --- a/internal/functions/functions_test.go +++ b/internal/functions/functions_test.go @@ -6,8 +6,6 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - - "modernc.org/quickjs" ) type TestArgs struct { @@ -19,77 +17,17 @@ func (t TestArgs) Validate() error { } func TestAsyncFunction(t *testing.T) { + registryMutex.RLock() + defer registryMutex.RUnlock() + RegisterAsyncFunction("testAsync", func(_ context.Context, args TestArgs) (string, error) { return "result: " + args.Field1, nil }) - registryMutex.RLock() fn, ok := functionRegistry["testAsync"] - registryMutex.RUnlock() require.True(t, ok, "testAsync should be registered") assert.Contains(t, fn.Definition(), "Promise", "definition should include Promise") } -func TestAsyncFunctionResolution(t *testing.T) { - RegisterAsyncFunction("resolveTest", func(_ context.Context, args TestArgs) (string, error) { - return "test-result", nil - }) - - vm, err := quickjs.NewVM() - require.NoError(t, err) - defer func() { - _ = vm.Close() - }() - vm.SetCanBlock(true) - - RegisterFunctions(context.Background(), vm) - - result, err := vm.Eval(`resolveTest("hello")`, quickjs.EvalGlobal) - require.NoError(t, err) - assert.NotNil(t, result) -} - -func TestAsyncFunctionRejection(t *testing.T) { - RegisterAsyncFunction("rejectTest", func(_ context.Context, args TestArgs) (string, error) { - return "", assert.AnError - }) - - vm, err := quickjs.NewVM() - require.NoError(t, err) - defer func() { - _ = vm.Close() - }() - vm.SetCanBlock(true) - - RegisterFunctions(context.Background(), vm) - - result, err := vm.Eval(`rejectTest({field1: "hello"})`, quickjs.EvalGlobal) - require.NoError(t, err) - assert.NotNil(t, result) -} - -func TestNonPromise(t *testing.T) { - RegisterFunction("nonPromiseTest", func(_ context.Context, args TestArgs) (string, error) { - return "sync-result", nil - }) - - vm, err := quickjs.NewVM() - require.NoError(t, err) - defer func() { - _ = vm.Close() - }() - vm.SetCanBlock(true) - - RegisterFunctions(context.Background(), vm) - - result, err := vm.Eval(`nonPromiseTest({field1: "hello"})`, quickjs.EvalGlobal) - require.NoError(t, err) - - if obj, ok := result.(*quickjs.Object); ok { - var arr []any - if err := obj.Into(&arr); err == nil && len(arr) > 0 { - assert.Equal(t, "sync-result", arr[0]) - } - } -} +// TOOD: Test Normal Function diff --git a/internal/functions/registry.go b/internal/functions/registry.go index 48af53f..6843a6c 100644 --- a/internal/functions/registry.go +++ b/internal/functions/registry.go @@ -13,7 +13,7 @@ var ( collector *typeCollector ) -func registerFunction[A Args, R any](name string, isAsync bool, fn RawFunc[A, R]) { +func registerFunction[A Args, R any](name string, isAsync bool, fn GoFunc[A, R]) { registryMutex.Lock() defer registryMutex.Unlock() @@ -35,6 +35,7 @@ func registerFunction[A Args, R any](name string, isAsync bool, fn RawFunc[A, R] fn: fn, types: types, definition: generateTypeScriptDefinition(name, tType, fnType, isAsync, paramTypes), + isAsync: isAsync, } } @@ -69,10 +70,10 @@ func GetRegisteredFunctions() map[string]Function { return functionRegistry } -func RegisterFunction[T Args, R any](name string, fn RawFunc[T, R]) { +func RegisterFunction[T Args, R any](name string, fn GoFunc[T, R]) { registerFunction(name, false, fn) } -func RegisterAsyncFunction[T Args, R any](name string, fn RawFunc[T, R]) { +func RegisterAsyncFunction[T Args, R any](name string, fn GoFunc[T, R]) { registerFunction(name, true, fn) } diff --git a/internal/functions/types.go b/internal/functions/types.go index 6d14b7e..21330bd 100644 --- a/internal/functions/types.go +++ b/internal/functions/types.go @@ -10,10 +10,11 @@ type Function interface { Name() string Types() []string Definition() string - WrapFn(context.Context) func(...any) (any, error) + IsAsync() bool + Call(context.Context, []any) (any, error) } -type RawFunc[A Args, R any] func(context.Context, A) (R, error) +type GoFunc[A Args, R any] func(context.Context, A) (R, error) type Args interface { Validate() error @@ -21,9 +22,10 @@ type Args interface { type functionImpl[A Args, R any] struct { name string - fn RawFunc[A, R] + fn GoFunc[A, R] definition string types []string + isAsync bool } func (b *functionImpl[A, R]) Name() string { @@ -38,39 +40,38 @@ func (b *functionImpl[A, R]) Definition() string { return b.definition } -func (b *functionImpl[A, R]) WrapFn(ctx context.Context) func(...any) (any, error) { - return func(allArgs ...any) (any, error) { - // Populate Arguments - var fnArgs A - aVal := reflect.ValueOf(&fnArgs).Elem() - - // Populate Fields - for i := range min(aVal.NumField(), len(allArgs)) { - field := aVal.Field(i) - - if !field.CanSet() { - return nil, errors.New("cannot set field") - } - - argVal := reflect.ValueOf(allArgs[i]) - if !argVal.Type().AssignableTo(field.Type()) { - return nil, errors.New("cannot assign field") - } - - field.Set(argVal) - } - - // Validate - if err := fnArgs.Validate(); err != nil { - return nil, errors.New("cannot validate args") - } - - // Call Function - resp, err := b.fn(ctx, fnArgs) - if err != nil { - return nil, err - } - - return resp, nil - } +func (b *functionImpl[A, R]) IsAsync() bool { + return b.isAsync +} + +func (b *functionImpl[A, R]) Function() any { + return b.fn +} + +func (b *functionImpl[A, R]) Call(ctx context.Context, allArgs []any) (any, error) { + return b.CallGeneric(ctx, allArgs) +} + +func (b *functionImpl[A, R]) CallGeneric(ctx context.Context, allArgs []any) (zeroR R, err error) { + // Populate Arguments + var fnArgs A + aVal := reflect.ValueOf(&fnArgs).Elem() + + // Populate Fields + for i := range min(aVal.NumField(), len(allArgs)) { + field := aVal.Field(i) + + if !field.CanSet() { + return zeroR, errors.New("cannot set field") + } + + argVal := reflect.ValueOf(allArgs[i]) + if !argVal.Type().AssignableTo(field.Type()) { + return zeroR, errors.New("cannot assign field") + } + + field.Set(argVal) + } + + return b.fn(ctx, fnArgs) } diff --git a/internal/runtime/options.go b/internal/runtime/options.go new file mode 100644 index 0000000..c8df36c --- /dev/null +++ b/internal/runtime/options.go @@ -0,0 +1,17 @@ +package runtime + +import "io" + +type RuntimeOption func(*Runtime) + +func WithStdout(stdout io.Writer) RuntimeOption { + return func(r *Runtime) { + r.stdout = stdout + } +} + +func WithStderr(stderr io.Writer) RuntimeOption { + return func(r *Runtime) { + r.stderr = stderr + } +} diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index 6e1c4bc..93d2240 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -5,10 +5,13 @@ import ( "fmt" "io" "os" + "strings" "github.com/evanw/esbuild/pkg/api" "modernc.org/quickjs" + "reichard.io/poiesis/internal/functions" + _ "reichard.io/poiesis/internal/stdlib" ) type Runtime struct { @@ -19,7 +22,7 @@ type Runtime struct { stderr io.Writer } -func New(ctx context.Context) (*Runtime, error) { +func New(ctx context.Context, opts ...RuntimeOption) (*Runtime, error) { // Create VM vm, err := quickjs.NewVM() if err != nil { @@ -33,6 +36,11 @@ func New(ctx context.Context) (*Runtime, error) { return nil, err } + // Apply Options + for _, opt := range opts { + opt(r) + } + return r, nil } @@ -59,27 +67,18 @@ func (r *Runtime) populateGlobals() error { } // Register Custom Functions - for name, builtin := range functions.GetRegisteredFunctions() { + for name, fn := range functions.GetRegisteredFunctions() { // Register Main Function - if err := r.vm.RegisterFunc(name, builtin.WrapFn(r.ctx), false); err != nil { + if err := r.vm.RegisterFunc(name, func(allArgs ...any) (any, error) { + return fn.Call(r.ctx, allArgs) + }, false); err != nil { return err } // Wrap Exception - The QuickJS library does not allow us to throw exceptions, so we // wrap the function with native JS to appropriately throw on error. - if _, err := r.vm.Eval(fmt.Sprintf(` - (function() { - const original = globalThis[%q]; - - globalThis[%q] = function(...args) { - const [result, error] = original.apply(this, args); - if (error) { - throw new Error(error); - } - return result; - }; - })(); - `, name, name), quickjs.EvalGlobal); err != nil { + wrappedFunc := wrapFunc(fn.Name(), fn.IsAsync()) + if _, err := r.vm.Eval(wrappedFunc, quickjs.EvalGlobal); err != nil { return err } } @@ -87,89 +86,80 @@ func (r *Runtime) populateGlobals() error { return nil } -func (r *Runtime) RunFile(filePath string, stdout, stderr io.Writer) error { - r.stdout = stdout - r.stderr = stderr - - content, err := r.transformFile(filePath) - if err != nil { - _, _ = fmt.Fprintf(stderr, "Error: %v\n", err) - return err - } - - if len(content.errors) > 0 { - _, _ = fmt.Fprintf(stderr, "Transpilation errors:\n") - for _, err := range content.errors { - _, _ = fmt.Fprintf(stderr, " %s\n", err.Text) - } - return fmt.Errorf("transpilation failed") - } - - _, err = r.vm.Eval(content.code, quickjs.EvalGlobal) - if err != nil { - _, _ = fmt.Fprintf(stderr, "Execution error: %v\n", err) - return err - } - - return nil -} - -func (r *Runtime) RunCode(tsCode string, stdout, stderr io.Writer) error { - r.stdout = stdout - r.stderr = stderr - - content := r.transformCode(tsCode) - - if len(content.errors) > 0 { - _, _ = fmt.Fprintf(stderr, "Transpilation errors:\n") - for _, err := range content.errors { - _, _ = fmt.Fprintf(stderr, " %s\n", err.Text) - } - return fmt.Errorf("transpilation failed") - } - - _, err := r.vm.Eval(content.code, quickjs.EvalGlobal) - if err != nil { - _, _ = fmt.Fprintf(stderr, "Execution error: %v\n", err) - return err - } - - return nil -} - -type transformResult struct { - code string - errors []api.Message -} - -func (r *Runtime) transformFile(filePath string) (*transformResult, error) { +func (r *Runtime) RunFile(filePath string) error { tsFileContent, err := os.ReadFile(filePath) if err != nil { - return nil, fmt.Errorf("error reading file: %w", err) + return fmt.Errorf("error reading file: %w", err) } - return r.transformCode(string(tsFileContent)), nil + return r.RunCode(string(tsFileContent)) } -func (r *Runtime) transformCode(tsCode string) *transformResult { - // wrappedCode := `(async () => { - // try { - // ` + tsCode + ` - // } catch (err) { - // console.error(err); - // } - // })()` +func (r *Runtime) RunCode(tsCode string) error { + transformedCode, err := r.transformCode(tsCode) + if err != nil { + return err + } + _, err = r.vm.Eval(string(transformedCode), quickjs.EvalGlobal) + return err +} + +func (r *Runtime) transformCode(tsCode string) ([]byte, error) { result := api.Transform(tsCode, api.TransformOptions{ - Loader: api.LoaderTS, - Target: api.ES2022, - Format: api.FormatIIFE, + Loader: api.LoaderTS, + Target: api.ES2022, + // Format: api.FormatIIFE, Sourcemap: api.SourceMapNone, TreeShaking: api.TreeShakingFalse, }) - return &transformResult{ - code: string(result.Code), - errors: result.Errors, + if len(result.Errors) > 0 { + var allErrs []string + for _, e := range result.Errors { + allErrs = append(allErrs, e.Text) + } + return nil, fmt.Errorf("transpilation failed: %s", strings.Join(allErrs, ", ")) } + + return result.Code, nil +} + +func wrapFunc(funcName string, isAsync bool) string { + if isAsync { + return fmt.Sprintf(` + (function() { + const original = globalThis[%q]; + + globalThis[%q] = function(...args) { + console.log("calling") + return new Promise((resolve, reject) => { + const [result, error] = original.apply(this, args); + console.log("result", result) + if (error) { + console.log("reject") + reject(new Error(error)); + } else { + console.log("resolve") + resolve(result); + } + }); + }; + })(); + `, funcName, funcName) + } + + return fmt.Sprintf(` + (function() { + const original = globalThis[%q]; + + globalThis[%q] = function(...args) { + const [result, error] = original.apply(this, args); + if (error) { + throw new Error(error); + } + return result; + }; + })(); + `, funcName, funcName) } diff --git a/internal/runtime/runtime_test.go b/internal/runtime/runtime_test.go index 219215d..d985e8d 100644 --- a/internal/runtime/runtime_test.go +++ b/internal/runtime/runtime_test.go @@ -6,19 +6,27 @@ import ( "strings" "testing" - _ "reichard.io/poiesis/internal/standard" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "modernc.org/quickjs" + "reichard.io/poiesis/internal/functions" ) +type TestArgs struct { + Field1 string `json:"field1"` +} + +func (t TestArgs) Validate() error { + return nil +} + func TestExecuteTypeScript(t *testing.T) { var stdout, stderr bytes.Buffer - rt, err := New(context.Background()) + rt, err := New(context.Background(), WithStderr(&stderr), WithStdout(&stdout)) assert.NoError(t, err, "Expected no error") - err = rt.RunFile("../../test_data/test.ts", &stdout, &stderr) + err = rt.RunFile("../../test_data/test.ts") assert.NoError(t, err, "Expected no error") assert.Empty(t, stderr.String(), "Expected no error output") @@ -34,17 +42,47 @@ func TestExecuteTypeScript(t *testing.T) { assert.GreaterOrEqual(t, len(lines), 3, "Should have at least 3 output lines") } -func TestFetchBuiltinIntegration(t *testing.T) { - rt, err := New(context.Background()) - assert.NoError(t, err, "Expected no error") +func TestAsyncFunctionResolution(t *testing.T) { + functions.RegisterAsyncFunction("resolveTest", func(_ context.Context, args TestArgs) (string, error) { + return "test-result", nil + }) - tsContent := ` - const result = add({a: 5, b: 10}); - console.log("Result:", result); - ` - - var stdout, stderr bytes.Buffer - err = rt.RunCode(tsContent, &stdout, &stderr) + r, err := New(context.Background()) require.NoError(t, err) - assert.Contains(t, stdout.String(), "Result:") + + result, err := r.vm.Eval(`resolveTest("hello")`, quickjs.EvalGlobal) + require.NoError(t, err) + assert.NotNil(t, result) +} + +func TestAsyncFunctionRejection(t *testing.T) { + functions.RegisterAsyncFunction("rejectTest", func(_ context.Context, args TestArgs) (string, error) { + return "", assert.AnError + }) + + r, err := New(context.Background()) + require.NoError(t, err) + + result, err := r.vm.Eval(`rejectTest({field1: "hello"})`, quickjs.EvalGlobal) + require.NoError(t, err) + assert.NotNil(t, result) +} + +func TestNonPromise(t *testing.T) { + functions.RegisterFunction("nonPromiseTest", func(_ context.Context, args TestArgs) (string, error) { + return "sync-result", nil + }) + + r, err := New(context.Background()) + require.NoError(t, err) + + result, err := r.vm.Eval(`nonPromiseTest({field1: "hello"})`, quickjs.EvalGlobal) + require.NoError(t, err) + + if obj, ok := result.(*quickjs.Object); ok { + var arr []any + if err := obj.Into(&arr); err == nil && len(arr) > 0 { + assert.Equal(t, "sync-result", arr[0]) + } + } } diff --git a/internal/stdlib/fetch_test.go b/internal/stdlib/fetch_test.go index 065e3f1..931d3c7 100644 --- a/internal/stdlib/fetch_test.go +++ b/internal/stdlib/fetch_test.go @@ -8,8 +8,6 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "modernc.org/quickjs" - "reichard.io/poiesis/internal/functions" ) func TestFetch(t *testing.T) { @@ -98,48 +96,3 @@ func TestFetchDefaults(t *testing.T) { require.NoError(t, err) assert.True(t, result.OK) } - -func TestFetchReturnsPromise(t *testing.T) { - vm, err := quickjs.NewVM() - require.NoError(t, err) - defer func() { - _ = vm.Close() - }() - vm.SetCanBlock(true) - - functions.RegisterBuiltins(context.Background(), vm) - - result, err := vm.Eval(`fetch({input: "https://example.com"})`, quickjs.EvalGlobal) - require.NoError(t, err) - assert.NotNil(t, result) -} - -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, err := quickjs.NewVM() - require.NoError(t, err) - defer func() { - _ = vm.Close() - }() - vm.SetCanBlock(true) - - functions.RegisterBuiltins(context.Background(), vm) - - result, err := vm.Eval(`fetch({input: "`+server.URL+`"})`, quickjs.EvalGlobal) - require.NoError(t, err) - - if obj, ok := result.(*quickjs.Object); ok { - var arr []any - if err := obj.Into(&arr); err == nil && len(arr) > 0 { - if response, ok := arr[0].(map[string]any); ok { - assert.True(t, response["ok"].(bool)) - } - } - } -} diff --git a/test_data/fetch.ts b/test_data/fetch.ts index cf00e81..58836ee 100644 --- a/test_data/fetch.ts +++ b/test_data/fetch.ts @@ -1,8 +1,7 @@ var done = false; async function main() { try { - console.log(11); - const response = fetch("https://httpbin.org/get"); + const response = await fetch("https://httpbin.org/get"); console.log(response); console.log("OK:", response.ok); @@ -10,6 +9,7 @@ async function main() { console.log("Body:", response.body); console.log("Content-Type:", response.headers["content-type"]); } catch (e) { + console.log("error"); console.log(e); } done = true;