godotengine / godot-proposals

Godot Improvement Proposals (GIPs)
MIT License
1.11k stars 69 forks source link

Add a Script -> JavaScript interface to improve calling JavaScript code from Godot #1852

Closed Faless closed 3 years ago

Faless commented 3 years ago

Describe the problem or limitation you are having in your project: Currently the only way to communicate with JavaScript code from Godot in HTML5 exports is by using eval, which, beside performing poorly, is also very limited.

Describe the feature / enhancement and how it helps to overcome the problem or limitation: The idea is to expose an interface in the JavaScript singleton that allows to call JS functions and get JS properties in a Godot-y way.

Ideally, the API should support almost all possible interactions with JavaScript, this include, getting/setting properties, calling functions, and handling callbacks and promises.

Describe how your proposal will work, with code, pseudocode, mockups, and/or diagrams: The idea is to expose a new get_interface method in the JavaScript singleton, which will return a JavaScriptObject.

The "interface" will be registered via JavaScript (e.g. the custom HTML include):

engine.addInterface('NAME', js_interface); // interface is a JS object

You can create your own interface, or expose an external library (e.g. d3):

<script src="https://d3js.org/d3.v6.min.js"></script>
<script>engine.addInterface("d3", d3)</script>

The interface will then be available to scripting via:

var interface = JavaScript.get_interface(name) # a JavaScriptObject reference.

This is the proposed interface for JavaScriptObject:

class JavaScriptObject:
    Variant as_variant() # Returns object as a variant (see conversion table below).
    Variant call(method, arg1, arg2, ...) # Call a method, the result is always returned as a JavaScriptObject
    Variant get(prop, default) # Returns a property, as another JavaScriptObject
        void set(prop, value) # Set a property to given value

Conversion Table

This is the minimal types conversion table between Godot and JavaScript:

Godot - JavaScript
bool <-> bool
int32_t <-> number (when Number.isInteger())
real_t <-> number (otherwise)
String <-> String (this allocates memory)

Some more specialized types (always copy for safety):

PackedByteArray <-> Uint8Array
// PoolByteArray <-> Uint8Array // 3.x
PackedInt32Array <-> Int32Array
PackedInt64Array <-> BigInt64Array
// PoolIntArray <-> Int32Array // 3.x (may truncate)
PackedFloat32Array <-> Float32Array
PackedFloat64Array <-> Float64Array
// PoolRealArray <-> Float32Array // 3.x

Promises & Callbacks

Passing callbacks to a function, or chaining asynchronous code, is a very common pattern in JavaScript, this is sadly not trivial to integrate with Godot due to scripts and application lifecycle. Following, is a proposed addition to the aforementioned API that would enable taking advantages of callbacks/promises but requiring a bit more consciousness during its usage. The idea is to add a method to the JavaScript singleton to bind a function reference:

var fref = JavaScript.create_function_ref(callable) # or (object, method) in 3.x. Returns a JavaScriptObject.

And 2 helper methods to the JavaScriptObject class:

bool is_promise();
bool is_function();

So you can interact with functions that requires callbacks this way:

# You must keep a reference of this yourself.
var callback = JavaScript.create_function_ref(Callable(self, "_compute_color"))

func _ready():
    # Assumed registered in head
    var d3 = JavaScript.get_interface("d3")
    d3.call("selectAll", "p").call("style", "color", callback)

And potentially, with promises this way:

var callback = JavaScript.create_function_ref(Callable(self, "_on_response"))
func _ready():
    var axios = JavaScript.get_interface("axios") # Assumed registered in head
    var promise = axios.call("get", "/user?ID=12345").call("then", callback)

This approach at promises could prove risky if we end up running the engine outside of the main thread in the future, due to the asynchronous nature of the calls, where a promise may throw an error or be rejected before a catch could be called. Due to this scenario, and the possibility that, in any case, a called function might throw an error and break the script, my suggestion is to always wrap the code in try/catch blocks and adding an error property to JavaScriptObject that is != OK when the catch has been evaluated. This is still suboptimal, but the best I came up with.

(Formalizes) Fixes: godotengine/godot-proposals#286 (Supersedes) Closes: godotengine/godot-proposals#1723

MickeMakaron commented 3 years ago

Just throwing an idea out there: Could promises be represented in Godot-land as resumable function state? If so, users could use yield/await syntax to wait for the resolution of a promise.

The returned value could be some special JavascriptPromiseResult object that contains a status property that is !=OK if the promise is rejected, and a value property that contains the value the promise was resolved/rejected with.

MickeMakaron commented 3 years ago

To specifically address the issue of the promise potentially rejecting before catch is called: If the promise rejects and no catch had been called, note the rejection down. When catch is called, check if the promise has been rejected before and invoke the catch callback if so (and potentially clear the noted rejection to prevent multiple invocations of catch callbacks).

Faless commented 3 years ago

@MickeMakaron I had originally thought about having a specialized JavascriptPromiseResult, but I fear the more we try to interpret results programmatically the more dangerous it is as many libraries relies on overriding, monkey patching and polyfills. Additionally, the function state is not really a concept that exists in Godot (only in gdscript). But I totally agree that using yield/await for promises would be great, so maybe the API could be adjusted this way:

Instead of is_promise the method will be as_promise, which returns another JavaScriptObject that will emit completed (with the result) when the Promise resolves or rejects.

var req = axios.call("get", "/user?ID=12345")
var result = await req.as_promise().completed
if result.error:
    print("Error: ", result.as_variant())
else:
    print(result.as_variant())

What do you think?

MickeMakaron commented 3 years ago

@Faless Sounds good!

Would it be possible to allow the user to await the return value without as_promise? I.e.

var result = await axios.call("get", "/user?ID=12345").completed

Or maybe that's still risky, as you said?

I guess that would require something along these lines?

  1. On call, check if return value is promise.
  2. If promise, add a callback to the promise that triggers the completed signal.
  3. If not promise, do nothing (or trigger completed immediately?).

If doing the above on every call comes with issues, maybe it could be done sneakily only when the completed property is geted by the user. 😅

Faless commented 3 years ago

only when the completed property is geted by the user. sweat_smile

I don't think this is possible.

Or maybe that's still risky, as you said?

I fear it might still be risky, but it could actually work, since we'll have to try/catch anyway. It would be wasteful cases where the user registers a then callback, but hey, JavaScript is all about being wasteful so that's okay :smile_cat:

MickeMakaron commented 3 years ago

Will it be possible to call GdScript functions from javascript by registering methods via e.g. JavaScript.add_interface? I noticed you mention godotengine/godot-proposals#286 but you don't mention js->gd interop.

Faless commented 3 years ago

You don't really need that method, because you can call JavaScript.get_interface("my handler").call("register_callback", cb) to register callback that can be called from JS

On Fri, Nov 20, 2020, 07:26 Mikael Hernvall notifications@github.com wrote:

Will it be possible to call GdScript functions from javascript by registering methods via e.g. JavaScript.add_interface? I noticed you mention godotengine/godot-proposals#286 https://github.com/godotengine/godot-proposals/issues/286 but you don't mention gd->js interop.

Sonething I've noticed being asked about numerous times in various Godot forums is how to call a GDScript function from JavaScript.

Since you're already in the process of making engine.addInterface, would it be possible to also add a JavaScript.add_interface,

— You are receiving this because you were mentioned. Reply to this email directly, view it on GitHub https://github.com/godotengine/godot-proposals/issues/1852#issuecomment-730881615, or unsubscribe https://github.com/notifications/unsubscribe-auth/AAM4C3RTYYM5OAF5OEE7TATSQYDYNANCNFSM4TYX32TQ .

Thaina commented 3 years ago

In C# we have Task object that would perfectly mapped to js Promise. for GDScript you should also adopt this system as you like. You should also introduce async/await into your language

And for callback you should adopt Observable and reactive extension paradigm (which also align with my godotengine/godot-visual-script#20). There was also streaming paradigm, IAsyncEnumerable and operator like await foreach in C# that you could also considered

Thaina commented 3 years ago

There are https://github.com/WebAssembly/reference-types support for WASM to allow get and pass reference from JS around in WASM system. It would make interop with js more natural if we have support for it in C#

Zireael07 commented 3 years ago

@Thaina: It's the proposal, I don't think it's gone anywhere yet.

Thaina commented 3 years ago

@Zireael07 It was already supported in some browser. At least firefox since 79. I am quite sure it supported in chrome too but not sure since when. And don't know about other. But investigating for such time I think it was solidated to be supported in all browser eventually

Faless commented 3 years ago

@Thaina reference types are still being worked on, but implementations are slowly catching up. The real problem is that the whole spec if very theoretical regarding improving interop. It's true, but wouldn't really change much in our context, only that the "reference type" would no longer be an integer like it is now.

Faless commented 3 years ago

Closing, implemented in master and 3.4. See this blog post

Thaina commented 3 years ago

@Faless Are there any sample for C#