Skip to content

Commit 14b7712

Browse files
authored
feat(wasm): return custom object (#3130)
1 parent 6b3d42b commit 14b7712

File tree

10 files changed

+234
-87
lines changed

10 files changed

+234
-87
lines changed

cmd/scw-wasm/async.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ func asyncFunc(innerFunc fn) js.Func {
3333

3434
res, err := innerFunc(this, args)
3535
if err != nil {
36-
reject.Invoke(jshelpers.NewErrorWithCause(res, err.Error()))
36+
reject.Invoke(jshelpers.NewError(err.Error()))
3737
} else {
3838
resolve.Invoke(res)
3939
}

cmd/scw-wasm/main.go

Lines changed: 2 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -3,89 +3,18 @@
33
package main
44

55
import (
6-
"bytes"
7-
"fmt"
8-
"io"
96
"syscall/js"
107

11-
"github.com/scaleway/scaleway-cli/v2/internal/core"
128
"github.com/scaleway/scaleway-cli/v2/internal/jshelpers"
13-
"github.com/scaleway/scaleway-cli/v2/internal/namespaces"
14-
"github.com/scaleway/scaleway-cli/v2/internal/platform/web"
9+
"github.com/scaleway/scaleway-cli/v2/internal/wasm"
1510
)
1611

17-
var commands *core.Commands
18-
19-
func getCommands() *core.Commands {
20-
if commands == nil {
21-
commands = namespaces.GetCommands()
22-
}
23-
return commands
24-
}
25-
26-
type RunConfig struct {
27-
JWT string `js:"jwt"`
28-
}
29-
30-
func runCommand(cfg *RunConfig, args []string, stdout io.Writer, stderr io.Writer) chan int {
31-
ret := make(chan int, 1)
32-
go func() {
33-
exitCode, _, _ := core.Bootstrap(&core.BootstrapConfig{
34-
Args: args,
35-
Commands: getCommands(),
36-
BuildInfo: &core.BuildInfo{},
37-
Stdout: stdout,
38-
Stderr: stderr,
39-
Stdin: nil,
40-
Platform: &web.Platform{
41-
JWT: cfg.JWT,
42-
},
43-
})
44-
ret <- exitCode
45-
}()
46-
47-
return ret
48-
}
49-
50-
func wasmRun(this js.Value, args []js.Value) (any, error) {
51-
cliArgs := []string{"scw"}
52-
stdout := bytes.NewBuffer(nil)
53-
stderr := bytes.NewBuffer(nil)
54-
55-
if len(args) < 2 {
56-
return nil, fmt.Errorf("not enough arguments")
57-
}
58-
59-
runCfg, err := jshelpers.AsObject[RunConfig](args[0])
60-
if err != nil {
61-
return nil, fmt.Errorf("invalid config given: %w", err)
62-
}
63-
64-
givenArgs, err := jshelpers.AsSlice[string](args[1])
65-
if err != nil {
66-
return nil, fmt.Errorf("invalid args given: %w", err)
67-
}
68-
69-
cliArgs = append(cliArgs, givenArgs...)
70-
71-
exitCodeChan := runCommand(runCfg, cliArgs, stdout, stderr)
72-
exitCode := <-exitCodeChan
73-
if exitCode != 0 {
74-
errBody := stderr.String()
75-
return js.ValueOf(errBody), fmt.Errorf("exit code: %d", exitCode)
76-
}
77-
78-
outBody := stdout.String()
79-
80-
return js.ValueOf(outBody), nil
81-
}
82-
8312
func main() {
8413
args := getArgs()
8514

8615
if args.targetObject != "" {
8716
cliPackage := js.ValueOf(map[string]any{})
88-
cliPackage.Set("run", asyncFunc(wasmRun))
17+
cliPackage.Set("run", asyncFunc(jshelpers.AsFunction(wasm.Run)))
8918
js.Global().Set(args.targetObject, cliPackage)
9019
}
9120

cmd/scw-wasm/run.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
//go:build wasm && js
2+
3+
package main
4+
5+
import (
6+
"fmt"
7+
"syscall/js"
8+
9+
"github.com/scaleway/scaleway-cli/v2/internal/jshelpers"
10+
"github.com/scaleway/scaleway-cli/v2/internal/wasm"
11+
)
12+
13+
func wasmRun(this js.Value, args []js.Value) (any, error) {
14+
if len(args) < 2 {
15+
return nil, fmt.Errorf("not enough arguments")
16+
}
17+
18+
runCfg, err := jshelpers.AsObject[wasm.RunConfig](args[0])
19+
if err != nil {
20+
return nil, fmt.Errorf("invalid config given: %w", err)
21+
}
22+
23+
givenArgs, err := jshelpers.AsSlice[string](args[1])
24+
if err != nil {
25+
return nil, fmt.Errorf("invalid args given: %w", err)
26+
}
27+
28+
resp, err := wasm.Run(runCfg, givenArgs)
29+
30+
return jshelpers.FromObject(resp), nil
31+
}

internal/jshelpers/function.go

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
//go:build js
2+
3+
package jshelpers
4+
5+
import (
6+
"fmt"
7+
"reflect"
8+
"syscall/js"
9+
)
10+
11+
var (
12+
goErrorInterface = reflect.TypeOf((*error)(nil)).Elem()
13+
)
14+
15+
func jsValue(val any) js.Value {
16+
valType := reflect.TypeOf(val)
17+
if valType.Kind() == reflect.Pointer {
18+
valType = valType.Elem()
19+
}
20+
21+
switch valType.Kind() {
22+
case reflect.Struct:
23+
return FromObject(val)
24+
}
25+
return js.ValueOf(val)
26+
}
27+
28+
func errValue(val any) error {
29+
if val == nil {
30+
return nil
31+
}
32+
return val.(error)
33+
}
34+
35+
// AsFunction convert a classic Go function to a function taking js arguments.
36+
// arguments and return types must be types handled by this package
37+
// function must return 2 variables, second one must be an error
38+
func AsFunction(goFunc any) func(this js.Value, args []js.Value) (any, error) {
39+
goFuncValue := reflect.ValueOf(goFunc)
40+
goFuncType := goFuncValue.Type()
41+
42+
goFuncArgs := make([]reflect.Type, goFuncType.NumIn())
43+
for i := 0; i < goFuncType.NumIn(); i++ {
44+
goFuncArgs[i] = goFuncType.In(i)
45+
}
46+
47+
if goFuncType.NumOut() != 2 {
48+
panic("function must return 2 variables")
49+
}
50+
if !goFuncType.Out(1).Implements(goErrorInterface) {
51+
panic("function must return an error")
52+
}
53+
54+
return func(this js.Value, args []js.Value) (any, error) {
55+
if len(args) != len(goFuncArgs) {
56+
return nil, fmt.Errorf("invalid number of arguments, expected %d, got %d", len(goFuncArgs), len(args))
57+
}
58+
59+
argValues := make([]reflect.Value, len(goFuncArgs))
60+
for i, argType := range goFuncArgs {
61+
arg, err := goValue(argType, args[i])
62+
if err != nil {
63+
return nil, fmt.Errorf("invalid argument at index %d with type %s: %w", i, argType.String(), err)
64+
}
65+
argValues[i] = reflect.ValueOf(arg)
66+
}
67+
68+
returnValues := goFuncValue.Call(argValues)
69+
70+
return jsValue(returnValues[0].Interface()), errValue(returnValues[1].Interface())
71+
}
72+
}

internal/jshelpers/objects.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,14 @@ func asString(value js.Value) (string, error) {
1717

1818
func goValue(typ reflect.Type, value js.Value) (any, error) {
1919
switch typ.Kind() {
20+
case reflect.Pointer:
21+
return goValue(typ.Elem(), value)
2022
case reflect.String:
2123
return asString(value)
24+
case reflect.Struct:
25+
return asObject(typ, value)
26+
case reflect.Slice:
27+
return asSlice(typ.Elem(), value)
2228
}
2329
return nil, fmt.Errorf("value type is unknown")
2430
}
@@ -60,3 +66,22 @@ func AsObject[T any](value js.Value) (*T, error) {
6066

6167
return obj.(*T), nil
6268
}
69+
70+
// FromObject converts a Go struct to a JS Object
71+
// Given Go struct must have "js" tags to specify fields mapping
72+
func FromObject(from any) js.Value {
73+
fromValue := reflect.Indirect(reflect.ValueOf(from))
74+
fromType := fromValue.Type()
75+
76+
obj := jsObject.New()
77+
78+
for i := 0; i < fromValue.NumField(); i++ {
79+
field := fromType.Field(i)
80+
jsFieldName := field.Tag.Get("js")
81+
if jsFieldName != "" {
82+
obj.Set(jsFieldName, js.ValueOf(fromValue.Field(i).Interface()))
83+
}
84+
}
85+
86+
return obj
87+
}

internal/wasm/run.go

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
package wasm
2+
3+
import (
4+
"bytes"
5+
"io"
6+
7+
"github.com/scaleway/scaleway-cli/v2/internal/core"
8+
"github.com/scaleway/scaleway-cli/v2/internal/namespaces"
9+
"github.com/scaleway/scaleway-cli/v2/internal/platform/web"
10+
)
11+
12+
var commands *core.Commands
13+
14+
func getCommands() *core.Commands {
15+
if commands == nil {
16+
commands = namespaces.GetCommands()
17+
}
18+
return commands
19+
}
20+
21+
type RunConfig struct {
22+
JWT string `js:"jwt"`
23+
}
24+
25+
type RunResponse struct {
26+
Stdout string `js:"stdout"`
27+
Stderr string `js:"stderr"`
28+
ExitCode int `js:"exitCode"`
29+
}
30+
31+
func runCommand(cfg *RunConfig, args []string, stdout io.Writer, stderr io.Writer) int {
32+
exitCode, _, _ := core.Bootstrap(&core.BootstrapConfig{
33+
Args: args,
34+
Commands: getCommands(),
35+
BuildInfo: &core.BuildInfo{},
36+
Stdout: stdout,
37+
Stderr: stderr,
38+
Stdin: nil,
39+
Platform: &web.Platform{
40+
JWT: cfg.JWT,
41+
},
42+
})
43+
44+
return exitCode
45+
}
46+
47+
func Run(cfg *RunConfig, args []string) (*RunResponse, error) {
48+
cliArgs := []string{"scw"}
49+
stdout := bytes.NewBuffer(nil)
50+
stderr := bytes.NewBuffer(nil)
51+
52+
cliArgs = append(cliArgs, args...)
53+
54+
exitCode := runCommand(cfg, cliArgs, stdout, stderr)
55+
56+
return &RunResponse{
57+
Stdout: stdout.String(),
58+
Stderr: stderr.String(),
59+
ExitCode: exitCode,
60+
}, nil
61+
}

wasm/cli.d.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@ export type RunConfig = {
22
jwt: string
33
}
44

5+
export type RunResponse = {
6+
stdout: string
7+
stderr: string
8+
exitCode: string
9+
}
10+
511
export type CLI = {
6-
run(cfg: RunConfig, args: string[]): Promise<string>
12+
run(cfg: RunConfig, args: string[]): Promise<RunResponse>
713
}

wasm/index.d.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import {CLI, RunConfig, RunResponse} from "./cli";
2+
3+
export type _ = {
4+
wasmURL: URL,
5+
RunConfig,
6+
RunResponse,
7+
CLI,
8+
Go,
9+
}

wasm/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
"scripts": {
88
"test": "vitest"
99
},
10+
"types": "index.d.ts",
1011
"keywords": [],
1112
"author": "",
1213
"devDependencies": {

wasm/src/cli.test.ts

Lines changed: 25 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,6 @@ import * as fs from 'fs'
1212
const CLI_PACKAGE = 'scw'
1313
const CLI_CALLBACK = 'cliLoaded'
1414

15-
const runWithError = async (cli: CLI, runCfg: RunConfig, expected: string | RegExp, command: string[]) => {
16-
await expect((async () => await cli.run(runCfg, command))).rejects.toThrowError(expected)
17-
}
18-
1915
describe('With wasm CLI', async () => {
2016
// @ts-ignore
2117
const go = new globalThis.Go()
@@ -38,16 +34,33 @@ describe('With wasm CLI', async () => {
3834
await waitForCLI
3935
// @ts-ignore
4036
const cli = globalThis[CLI_PACKAGE] as CLI
41-
const runCfg: RunConfig = {
42-
jwt: "",
37+
38+
const run = async (expected: string | RegExp, command: string[], runCfg: RunConfig | null = null) => {
39+
if (runCfg === null) {
40+
runCfg = {
41+
jwt: "",
42+
}
43+
}
44+
45+
const resp = await cli.run(runCfg, command)
46+
expect(resp.exitCode).toBe(0)
47+
expect(resp.stdout).toMatch(expected)
4348
}
4449

45-
it('can run cli commands', async () => {
46-
const res = await cli.run(runCfg, ['info'])
47-
expect(res).toMatch(/profile.*default/)
48-
})
50+
const runWithError = async (expected: string | RegExp, command: string[], runCfg: RunConfig | null = null) => {
51+
if (runCfg === null) {
52+
runCfg = {
53+
jwt: "",
54+
}
55+
}
56+
const resp = await cli.run(runCfg, command)
57+
expect(resp.exitCode).toBeGreaterThan(0)
58+
expect(resp.stderr).toMatch(expected)
59+
}
60+
61+
it('can run cli commands', async () => run(/profile.*default/, ['info']))
4962

50-
it('can run help', async () => runWithError(cli, runCfg, /USAGE:\n.*scw <command>.*/, []))
63+
it('can run help', async () => runWithError(/USAGE:\n.*scw <command>.*/, []))
5164

52-
it('can use jwt', async () => runWithError(cli, runCfg, /.*denied authentication.*invalid JWT.*/, ['instance', 'server', 'list']))
65+
it('can use jwt', async () => runWithError(/.*denied authentication.*invalid JWT.*/, ['instance', 'server', 'list']))
5366
})

0 commit comments

Comments
 (0)