Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Closed
Faless opened this issue Nov 17, 2020 · 14 comments
Closed

Comments

@Faless
Copy link

Faless commented Nov 17, 2020

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: #286
(Supersedes) Closes: #1723

@Calinou Calinou changed the title [HTML5] Script -> JavaScript interface Add a Script -> JavaScript interface to improve calling JavaScript code from Godot Nov 18, 2020
@MickeMakaron
Copy link

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
Copy link

MickeMakaron commented Nov 18, 2020

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
Copy link
Author

Faless commented Nov 19, 2020

@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
Copy link

MickeMakaron commented Nov 19, 2020

@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
Copy link
Author

Faless commented Nov 19, 2020

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 😸

@MickeMakaron
Copy link

MickeMakaron commented Nov 20, 2020

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

@Faless
Copy link
Author

Faless commented Nov 20, 2020 via email

@Thaina
Copy link

Thaina commented Dec 21, 2020

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
Copy link

Thaina commented Feb 9, 2021

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
Copy link

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

@Thaina
Copy link

Thaina commented Feb 9, 2021

@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
Copy link
Author

Faless commented Feb 9, 2021

@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
Copy link
Author

Faless commented Aug 4, 2021

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

@Faless Faless closed this as completed Aug 4, 2021
@Calinou Calinou added this to the 3.x milestone Aug 4, 2021
@Thaina
Copy link

Thaina commented Aug 13, 2021

@Faless Are there any sample for C#

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

5 participants