diff --git a/Justfile b/Justfile index 5d150150..c25a00aa 100644 --- a/Justfile +++ b/Justfile @@ -24,6 +24,15 @@ tests: test: @go test -race -covermode=atomic -coverprofile coverage.txt ./... +# Build the browser WebAssembly binding (dist/numscript.wasm + wasm_exec.js). +# wasm_exec.js is version-locked to the building toolchain, so always copy it +# from GOROOT rather than pinning a checked-in copy. +wasm: + @mkdir -p dist + @GOOS=js GOARCH=wasm go build -trimpath -ldflags="-s -w" -o dist/numscript.wasm ./cmd/wasm + @cp "$(go env GOROOT)/lib/wasm/wasm_exec.js" dist/wasm_exec.js + @ls -lh dist/numscript.wasm + release-local: @goreleaser release --nightly --skip=publish --clean diff --git a/cmd/wasm/main.go b/cmd/wasm/main.go new file mode 100644 index 00000000..a914edf1 --- /dev/null +++ b/cmd/wasm/main.go @@ -0,0 +1,68 @@ +//go:build js && wasm + +// Command wasm is the browser-facing entry point of the Numscript WebAssembly +// binding. It is a thin syscall/js shim over internal/wasmbinding: it registers +// a single namespace object on globalThis.__numscript and then blocks forever so +// the Go runtime (and the registered callbacks) stay alive for the lifetime of +// the page. +// +// All the real work — parsing, static analysis, execution, JSON shaping, panic +// recovery — lives in internal/wasmbinding, which is pure Go and unit-tested on +// the host toolchain. Each registered function takes a single string argument +// and returns a JSON string the TS binding parses. +package main + +import ( + "syscall/js" + + "github.com/formancehq/numscript/internal/wasmbinding" +) + +func stringArg(args []js.Value) (string, bool) { + if len(args) < 1 || args[0].Type() != js.TypeString { + return "", false + } + return args[0].String(), true +} + +func main() { + ns := js.Global().Get("Object").New() + + // check(source) -> JSON { diagnostics: [...] } + ns.Set("check", js.FuncOf(func(_ js.Value, args []js.Value) any { + source, ok := stringArg(args) + if !ok { + return `{"diagnostics":[]}` + } + return string(wasmbinding.Check(source)) + })) + + // run(inputJSON) -> JSON { ok, value } | { ok:false, error } + ns.Set("run", js.FuncOf(func(_ js.Value, args []js.Value) any { + input, ok := stringArg(args) + if !ok { + return `{"ok":false,"error":"run expects a JSON string argument"}` + } + return string(wasmbinding.Run([]byte(input))) + })) + + // getNeededVariables(source) -> JSON { ok, variables } | { ok:false, error } + ns.Set("getNeededVariables", js.FuncOf(func(_ js.Value, args []js.Value) any { + source, ok := stringArg(args) + if !ok { + return `{"ok":false,"error":"getNeededVariables expects a string argument"}` + } + return string(wasmbinding.NeededVariables(source)) + })) + + // version() -> the binding's ABI version string + ns.Set("version", js.FuncOf(func(_ js.Value, _ []js.Value) any { + return wasmbinding.Version + })) + + js.Global().Set("__numscript", ns) + + // Block forever: if main returned, the Go runtime would exit and every + // registered js.Func would become invalid. + select {} +} diff --git a/internal/wasmbinding/binding.go b/internal/wasmbinding/binding.go new file mode 100644 index 00000000..3c332cb7 --- /dev/null +++ b/internal/wasmbinding/binding.go @@ -0,0 +1,273 @@ +// Package wasmbinding is the host-testable core of the Numscript WebAssembly +// binding. It is deliberately free of syscall/js so its JSON/shape logic can be +// unit-tested with the normal Go toolchain; the js/wasm shim in ../../cmd/wasm +// is a thin wrapper that calls these functions and marshals the result across +// the JS boundary. +// +// Every exported function takes and returns plain bytes/strings and never +// panics: a panic in any of them is recovered and turned into an error +// envelope, so a malformed input can never take down the long-lived Go runtime +// that backs an editor. +package wasmbinding + +import ( + "context" + "encoding/json" + "fmt" + "math/big" + + "github.com/formancehq/numscript" + "github.com/formancehq/numscript/internal/analysis" +) + +// ---- Static analysis (check) -------------------------------------------------- + +type position struct { + Line int `json:"line"` + Character int `json:"character"` +} + +type rng struct { + Start position `json:"start"` + End position `json:"end"` +} + +type diagnostic struct { + Range rng `json:"range"` + Severity byte `json:"severity"` // LSP-aligned: 1=Error 2=Warning 3=Info 4=Hint + Message string `json:"message"` + Id int32 `json:"id"` +} + +type checkEnvelope struct { + Diagnostics []diagnostic `json:"diagnostics"` +} + +// Check runs the full static-analysis pass (parse + type checking) over the +// given source and returns a JSON envelope of diagnostics. Ranges are 0-based +// {line, character}, faithful to the Go contract; the TS binding converts them +// to 1-based Monaco markers. Severities are the LSP-aligned values returned by +// the analysis package verbatim. +func Check(source string) (out []byte) { + defer func() { + if r := recover(); r != nil { + // Surface the internal failure as a single error diagnostic rather + // than silently swallowing it, so the editor still shows something. + out = mustMarshal(checkEnvelope{Diagnostics: []diagnostic{{ + Severity: byte(analysis.ErrorSeverity), + Message: fmt.Sprintf("internal error: %v", r), + }}}) + } + }() + + result := analysis.CheckSource(source) + + diagnostics := make([]diagnostic, 0, len(result.Diagnostics)) + for _, d := range result.Diagnostics { + diagnostics = append(diagnostics, diagnostic{ + Range: rng{ + Start: position{Line: d.Range.Start.Line, Character: d.Range.Start.Character}, + End: position{Line: d.Range.End.Line, Character: d.Range.End.Character}, + }, + Severity: d.Kind.Severity(), + Message: d.Kind.Message(), + Id: d.Id, + }) + } + + return mustMarshal(checkEnvelope{Diagnostics: diagnostics}) +} + +// ---- Execution (run) ---------------------------------------------------------- + +// runInput mirrors the playground's existing request payload so the UI call +// site barely changes. balances/metadata use the nested-object shape the +// playground already produces (not the CLI's array shape). +type runInput struct { + Script string `json:"script"` + Variables map[string]string `json:"variables"` + Balances map[string]map[string]json.Number `json:"balances"` + Metadata map[string]map[string]string `json:"metadata"` + FeatureFlags []string `json:"featureFlags"` +} + +// runValue is the success payload. Fields carry numscript's own JSON tags, and +// the slices/maps are guaranteed non-nil so the JSON never contains `null` +// where the UI expects an array/object. +type runValue struct { + Postings []numscript.Posting `json:"postings"` + TxMeta map[string]string `json:"txMeta"` + AccountsMeta numscript.SetAccountsMetadata `json:"accountsMeta"` +} + +type runEnvelope struct { + Ok bool `json:"ok"` + Value *runValue `json:"value,omitempty"` + Error string `json:"error,omitempty"` +} + +// Run parses and executes the given script against an in-memory store built +// entirely from the input (no network). It returns a JSON envelope: +// +// { "ok": true, "value": { "postings", "txMeta", "accountsMeta" } } +// { "ok": false, "error": "\n" } +func Run(inputJSON []byte) (out []byte) { + defer func() { + if r := recover(); r != nil { + out = mustMarshal(runEnvelope{Ok: false, Error: fmt.Sprintf("internal error: %v", r)}) + } + }() + + var input runInput + if err := json.Unmarshal(inputJSON, &input); err != nil { + return mustMarshal(runEnvelope{Ok: false, Error: fmt.Sprintf("invalid input: %v", err)}) + } + + balances, err := toBalances(input.Balances) + if err != nil { + return mustMarshal(runEnvelope{Ok: false, Error: err.Error()}) + } + + store := numscript.StaticStore{ + Balances: balances, + Meta: toAccountsMetadata(input.Metadata), + } + + flags := make(map[string]struct{}, len(input.FeatureFlags)) + for _, flag := range input.FeatureFlags { + flags[flag] = struct{}{} + } + + vars := numscript.VariablesMap(input.Variables) + if vars == nil { + vars = numscript.VariablesMap{} + } + + parsed := numscript.Parse(input.Script) + result, iErr := parsed.RunWithFeatureFlags(context.Background(), vars, store, flags) + if iErr != nil { + // Reuse the CLI's annotated-source formatting: the error message + // followed by the offending source span. Useful for runtime errors + // (missing funds, etc.) that the static pass can't catch. + msg := iErr.Error() + "\n" + iErr.GetRange().ShowOnSource(input.Script) + return mustMarshal(runEnvelope{Ok: false, Error: msg}) + } + + value := &runValue{ + Postings: result.Postings, + TxMeta: result.Metadata, + AccountsMeta: result.AccountsMetadata, + } + if value.Postings == nil { + value.Postings = []numscript.Posting{} + } + if value.TxMeta == nil { + value.TxMeta = map[string]string{} + } + if value.AccountsMeta == nil { + value.AccountsMeta = numscript.SetAccountsMetadata{} + } + + return mustMarshal(runEnvelope{Ok: true, Value: value}) +} + +// toBalances flattens the nested {account: {asset: amount}} object into the +// interpreter's row shape, preserving integer precision via big.Int (the +// amounts arrive as json.Number, never float64). +func toBalances(nested map[string]map[string]json.Number) (numscript.Balances, error) { + balances := numscript.Balances{} + for account, assets := range nested { + for asset, amount := range assets { + n, ok := parseIntAmount(amount.String()) + if !ok { + return nil, fmt.Errorf("invalid balance amount for %s %s: %q", account, asset, amount.String()) + } + balances = append(balances, numscript.BalanceRow{ + Account: account, + Asset: asset, + Amount: n, + }) + } + } + return balances, nil +} + +// parseIntAmount parses an integer balance amount. Plain base-10 integers are +// taken exactly. A JS number can also reach us in scientific notation (e.g. +// "1e+21" for a large integer that overflowed float64's exact range), so we fall +// back to high-precision float parsing and accept it only when it is exactly +// integral — genuine fractional amounts like "10.5" are still rejected. +func parseIntAmount(s string) (*big.Int, bool) { + if n, ok := new(big.Int).SetString(s, 10); ok { + return n, true + } + + f, _, err := big.ParseFloat(s, 10, 256, big.ToNearestEven) + if err != nil { + return nil, false + } + n, acc := f.Int(nil) + if acc != big.Exact { + return nil, false + } + return n, true +} + +// toAccountsMetadata flattens the nested {account: {key: value}} object into the +// interpreter's row shape. +func toAccountsMetadata(nested map[string]map[string]string) numscript.AccountsMetadata { + meta := numscript.AccountsMetadata{} + for account, kv := range nested { + for key, value := range kv { + meta = append(meta, numscript.AccountMetadataRow{ + Account: account, + Key: key, + Value: value, + }) + } + } + return meta +} + +// ---- Needed variables --------------------------------------------------------- + +type neededVarsEnvelope struct { + Ok bool `json:"ok"` + Variables map[string]string `json:"variables,omitempty"` + Error string `json:"error,omitempty"` +} + +// NeededVariables returns the variables a script declares but does not define +// inline (name -> type), so a host can pre-populate an inputs editor. +func NeededVariables(source string) (out []byte) { + defer func() { + if r := recover(); r != nil { + out = mustMarshal(neededVarsEnvelope{Ok: false, Error: fmt.Sprintf("internal error: %v", r)}) + } + }() + + vars := numscript.Parse(source).GetNeededVariables() + if vars == nil { + vars = map[string]string{} + } + return mustMarshal(neededVarsEnvelope{Ok: true, Variables: vars}) +} + +// ---- Version ------------------------------------------------------------------ + +// Version reports the binding's own ABI version. It is independent of the +// numscript library version and lets a host detect an incompatible wasm asset. +const Version = "1" + +// ---- helpers ------------------------------------------------------------------ + +// mustMarshal marshals values whose shape is fully controlled here, so an error +// is a programmer mistake, not a runtime condition. If it somehow fails we still +// return a valid JSON error envelope rather than panicking. +func mustMarshal(v any) []byte { + b, err := json.Marshal(v) + if err != nil { + return fmt.Appendf(nil, `{"ok":false,"error":%q}`, "failed to encode result: "+err.Error()) + } + return b +} diff --git a/internal/wasmbinding/binding_test.go b/internal/wasmbinding/binding_test.go new file mode 100644 index 00000000..15f83667 --- /dev/null +++ b/internal/wasmbinding/binding_test.go @@ -0,0 +1,198 @@ +package wasmbinding_test + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/formancehq/numscript/internal/wasmbinding" + "github.com/stretchr/testify/require" +) + +// ---- Check -------------------------------------------------------------------- + +type checkEnvelope struct { + Diagnostics []struct { + Range struct { + Start struct { + Line int `json:"line"` + Character int `json:"character"` + } `json:"start"` + End struct { + Line int `json:"line"` + Character int `json:"character"` + } `json:"end"` + } `json:"range"` + Severity byte `json:"severity"` + Message string `json:"message"` + Id int32 `json:"id"` + } `json:"diagnostics"` +} + +func TestCheckValidScriptHasNoDiagnostics(t *testing.T) { + out := wasmbinding.Check(`send [COIN 10] ( + source = @world + destination = @dest +)`) + + var env checkEnvelope + require.NoError(t, json.Unmarshal(out, &env)) + require.Empty(t, env.Diagnostics) +} + +func TestCheckUnboundVariableReportsErrorWithRange(t *testing.T) { + out := wasmbinding.Check(`send [COIN 10] ( + source = $unbound + destination = @dest +)`) + + var env checkEnvelope + require.NoError(t, json.Unmarshal(out, &env)) + require.NotEmpty(t, env.Diagnostics) + + d := env.Diagnostics[0] + require.EqualValues(t, 1, d.Severity, "unbound variable should be an Error") + require.Contains(t, strings.ToLower(d.Message), "unbound") + // The range must be non-degenerate and sit on the second line (0-based). + require.Equal(t, 1, d.Range.Start.Line) + require.Greater(t, d.Range.End.Character, d.Range.Start.Character) +} + +// ---- Run ---------------------------------------------------------------------- + +type runEnvelope struct { + Ok bool `json:"ok"` + Value *struct { + Postings []struct { + Source string `json:"source"` + Destination string `json:"destination"` + Amount json.Number `json:"amount"` + Asset string `json:"asset"` + } `json:"postings"` + TxMeta map[string]string `json:"txMeta"` + AccountsMeta []struct { + Account string `json:"account"` + Key string `json:"key"` + Value string `json:"value"` + } `json:"accountsMeta"` + } `json:"value"` + Error string `json:"error"` +} + +func TestRunSimpleSendFromWorld(t *testing.T) { + out := wasmbinding.Run([]byte(`{ + "script": "send [COIN 10] (\n source = @world\n destination = @dest\n)", + "variables": {}, + "balances": {}, + "metadata": {}, + "featureFlags": [] + }`)) + + var env runEnvelope + require.NoError(t, json.Unmarshal(out, &env)) + require.True(t, env.Ok, "unexpected error: %s", env.Error) + require.NotNil(t, env.Value) + require.Len(t, env.Value.Postings, 1) + + p := env.Value.Postings[0] + require.Equal(t, "world", p.Source) + require.Equal(t, "dest", p.Destination) + require.Equal(t, "COIN", p.Asset) + require.Equal(t, "10", p.Amount.String()) + + // Non-nil JSON containers so the UI never sees `null`. + require.NotNil(t, env.Value.TxMeta) + require.NotNil(t, env.Value.AccountsMeta) +} + +func TestRunPreservesLargeIntegerPrecision(t *testing.T) { + // 10^21 overflows float64's exact-integer range (2^53); json.Number + big.Int + // must carry it through losslessly. + const huge = "1000000000000000000000" + out := wasmbinding.Run([]byte(`{ + "script": "send [COIN ` + huge + `] (\n source = @alice\n destination = @dest\n)", + "variables": {}, + "balances": { "alice": { "COIN": ` + huge + ` } }, + "metadata": {}, + "featureFlags": [] + }`)) + + var env runEnvelope + require.NoError(t, json.Unmarshal(out, &env)) + require.True(t, env.Ok, "unexpected error: %s", env.Error) + require.Len(t, env.Value.Postings, 1) + require.Equal(t, huge, env.Value.Postings[0].Amount.String()) +} + +func TestRunAcceptsScientificNotationBalance(t *testing.T) { + // A JS number for a large integer stringifies as "1e+21"; the binding must + // accept it (exactly, since it is integral) rather than hard-erroring. + out := wasmbinding.Run([]byte(`{ + "script": "send [COIN 1000000000000000000000] (\n source = @alice\n destination = @dest\n)", + "balances": { "alice": { "COIN": 1e21 } } + }`)) + + var env runEnvelope + require.NoError(t, json.Unmarshal(out, &env)) + require.True(t, env.Ok, "unexpected error: %s", env.Error) + require.Len(t, env.Value.Postings, 1) + require.Equal(t, "1000000000000000000000", env.Value.Postings[0].Amount.String()) +} + +func TestRunMissingFundsReturnsAnnotatedError(t *testing.T) { + out := wasmbinding.Run([]byte(`{ + "script": "send [COIN 10] (\n source = @alice\n destination = @dest\n)", + "variables": {}, + "balances": {}, + "metadata": {}, + "featureFlags": [] + }`)) + + var env runEnvelope + require.NoError(t, json.Unmarshal(out, &env)) + require.False(t, env.Ok) + require.NotEmpty(t, env.Error) +} + +func TestRunInvalidJSONReturnsErrorEnvelope(t *testing.T) { + out := wasmbinding.Run([]byte(`not json`)) + + var env runEnvelope + require.NoError(t, json.Unmarshal(out, &env)) + require.False(t, env.Ok) + require.Contains(t, env.Error, "invalid input") +} + +func TestRunInvalidBalanceAmountReturnsErrorEnvelope(t *testing.T) { + out := wasmbinding.Run([]byte(`{ + "script": "send [COIN 10] (\n source = @alice\n destination = @dest\n)", + "balances": { "alice": { "COIN": 10.5 } } + }`)) + + var env runEnvelope + require.NoError(t, json.Unmarshal(out, &env)) + require.False(t, env.Ok) + require.Contains(t, env.Error, "invalid balance amount") +} + +// ---- NeededVariables ---------------------------------------------------------- + +func TestNeededVariables(t *testing.T) { + out := wasmbinding.NeededVariables(`vars { + monetary $amt + account $dest +} +send $amt ( + source = @world + destination = $dest +)`) + + var env struct { + Ok bool `json:"ok"` + Variables map[string]string `json:"variables"` + Error string `json:"error"` + } + require.NoError(t, json.Unmarshal(out, &env)) + require.True(t, env.Ok) + require.Equal(t, map[string]string{"amt": "monetary", "dest": "account"}, env.Variables) +}