golang / go

The Go programming language
https://go.dev
BSD 3-Clause "New" or "Revised" License
124.36k stars 17.71k forks source link

proposal: syscall/js: method or function to await a js.Value #69720

Open jcbhmr opened 2 months ago

jcbhmr commented 2 months ago

Proposal Details

Let's say I have an async JavaScript function:

globalThis.myAsyncFunction = async () => 42;

Ideally I would be able to do one of these:

var n int
n = js.Global().Call("myAsyncFunction").Wait().Int()
n = js.Global().Call("myAsyncFunction").Await().Int()
n = js.Await(js.Global().Call("myAsyncFunction")).Int()
n = (<-js.Global().Call("myAsyncFunction").Chan()).Int()

Creating a promise in Go code for use by JavaScript code is ok-ish (it's not great): just use the new Promise(callback) constructor with a Go-defined js.Func callback.

var jsDoThingCallback = js.FuncOf(func(this Value, args []Value) interface{} {
    r1 := <-myChannelFromSomewhere
    r2, err := someFuncThatUsesChans(r1)
    if err != nil {
        args[1].Invoke(errorConstructor.New(err.Error()))
        return nil
    }
    r3, err := someFuncThatSleeps(r2)
    if err != nil {
        args[1].Invoke(errorConstructor.New(err.Error()))
        return nil
    }
    args[0].Invoke(r3)
    return nil
})
func DoThingAsync() js.Value {
    return promiseConstructor.New(jsDoThingCallback)
}

The other direction -- unwrapping a JavaScript Promise instance on the Go-side by waiting for it -- is worse.

// Ugh. I have to manage this intermediate channel myself and remember to handle some edge cases.
var n int
p := promiseConstructor.Call("resolve", js.Global().Call("myAsyncFunction"))
type result struct {
    value js.Value
    err error
}
ch := make(chan result, 1)
onFulfilled := js.FuncOf(func(this js.Value, args []js.Value) interface{} {
    ch <- result{args[0], nil}
    close(ch)
    return nil
})
defer onFulfilled.Release()
onRejected := js.FuncOf(func(this js.Value, args []js.Value) interface{} {
    ch <- result{js.Value{}, js.Error{args[0]}}
    close(ch)
    return nil
})
defer onRejected.Release()
p.Call("then", onFulfilled, onRejected)
r := <-ch
if r.err != nil {
    panic(r.err)
}
n = r.value.Int()
// Would have to do *all that again* for another `await nextFunctionThatUsesResult(n)` 😭

Here's the algorithm for the ECMAScript 2025 Await( value ) abstract operation: https://tc39.es/ecma262/multipage/control-abstraction-objects.html#await

It seems like the .Wait() method convention is already in the standard library with sync.WaitGroup wg.Wait(). Here's an idea for how that might look in Go code. I'm not a Go channels wizard so this might be the completely wrong way to do this.

// Wait waits for the thenable v to fulfill or reject and returns the resulting value or error.
// This is equivalent to the await operator in JavaScript.
func (v js.Value) Wait() (js.Value, error) {
    p := promiseConstructor.Call("resolve", v)
    type result struct {
        value js.Value
        err error
    }
    ch := make(chan result, 1)
    onFulfilled := js.FuncOf(func(this js.Value, args []js.Value) interface{} {
        ch <- result{args[0], nil}
        close(ch)
        return nil
    })
    defer onFulfilled.Release()
    onRejected := js.FuncOf(func(this js.Value, args []js.Value) interface{} {
        ch <- result{js.Value{}, js.Error{args[0]}}
        close(ch)
        return nil
    })
    defer onRejected.Release()
    p.Call("then", onFulfilled, onRejected)
    r := <-ch
    // Unsure if want to panic on error, or return the error.
    // If a synchronous function throws an error it panics. Don't know whether to stick
    // to that convention or to return a (js.Value, error) multivalue.
    return r.value, r.err
    // OR
    if r.err == nil {
        return r.value
    } else {
        panic(r.err)
    }
}

There's already some of this promise stuff in the Go standard library https://github.com/golang/go/blob/1d0f5c478ac176fa99d0f3d6bd540e5fb422187a/src/net/http/roundtrip_js.go#L129-L245 so it seems like this is a thing that people need to do. I think that providing a .Wait() or Await(v) or something would be a good way to "one good way to do it"-ify this.

gabyhelp commented 2 months ago

Related Issues and Documentation

(Emoji vote if this was helpful or unhelpful; more detailed feedback welcome in this discussion.)

jcbhmr commented 2 months ago

That issue https://github.com/golang/go/issues/28911 that @gabyhelp linked is kinda what I want but it went unresolved. https://go-review.googlesource.com/c/go/+/150917 which was spawned from the issue linked was abandoned. The reasoning given seemed to be of the "this doesn't belong in the standard library" vibe.

I am a bit wary of adding new API like this. We can already achieve this by manually calling the "then" function ourselves, which the net/http library already does.

This is just syntactic sugar and I am afraid might add precedent for adding more helpers like this.

I'm in the same boat: I think this is API creep. And I (now) feel the same about https://go-review.googlesource.com/c/go/+/144384/, particularly if we start citing that as precedent for changes like this.

I think that a .Await() or something to get the await JavaScript keyword algorithm available to Go code is a good idea. Await is similar to but not identical to just calling .then(); there's the Promise.resolve() native-ification step too. Related reading: https://www.zhenghao.io/posts/await-vs-promise

ianlancetaylor commented 2 months ago

CC @golang/js

earthboundkid commented 2 months ago

I'm not sure why this isn't just equivalent to calling .then like this:

func await(awaitable js.Value) (ret js.Value, ok bool) {
        ch := make(chan struct{})
        go func() {
                awaitable.
                        Call("then", js.FuncOf(func(this js.Value, args []js.Value) interface{} {
                                ret = args[0]
                                ok = true
                                close(ch)
                                return nil
                        })).
                        Call("catch", js.FuncOf(func(this js.Value, args []js.Value) interface{} {
                                ret = args[0]
                                ok = false
                                close(ch)
                                return nil
                        }))
        }()
        <-ch
        return
}

(From a project I did in Go WASM.)

Yes, using await and .then are different experiences in JavaScript, but from the Go side, why wouldn't this be sufficient?

Zxilly commented 2 months ago

If we really want such an API, maybe we also need some new APIs like syscall/js.AsyncFuncOf()? Considering the contagious nature of async in js, this might be necessary.

And, as there is no distinction between synchronous and asynchronous functions in Go, we may need additional wrapping to do this.

jcbhmr commented 2 months ago

re @earthboundkid

Yes, using await and .then are different experiences in JavaScript, but from the Go side, why wouldn't this be sufficient?

This is kinda my point. The func await(awaitable) example is experiencably different than the JS await behaviour: it panics if awaitable is not .then()-able or only implements the 2-arg .then() without a .catch() helper. There's quite a few edge cases like that that differ among so many people creating the same helper function that are oh-so-similar but subtly different in panic conditions, error conditions, non-Promise values, properly .Release()-ing the js.Func things, etc.. What I would like to see is a "one good builtin way to do it" to access the JS await keyword's behavior in Go code. And since it's a keyword from the JS language syntax I figured syscall/js is the best place to put it.