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

[API Proposal]: JavaScript interop with [JSImport] and [JSExport] attributes and Roslyn #70133

Closed
pavelsavara opened this issue Jun 2, 2022 · 69 comments · Fixed by #66304 or #71500
Closed
Assignees
Labels
api-approved API was approved in API review, it can be implemented arch-wasm WebAssembly architecture area-System.Runtime.InteropServices.JavaScript blocking Marks issues that we want to fast track in order to unblock other important work
Milestone

Comments

@pavelsavara
Copy link
Member

pavelsavara commented Jun 2, 2022

Background and motivation

When .NET is running on WebAssembly, for example as part of Blazor, developers may want to interact with the browser's JavaScript engine and JS code. Currently we don't have public C# API to do so.

We propose this API together with prototype of the implementation.
Key features are:

  • generate C# side of the marshaling stub in as partial method, Roslyn analyzer triggered by JSImportAttribute or JSExportAttribute. We re-use common code gen infrastructure from [LibraryImport]
  • allow different marshalers for the same managed type, for example Int64 could be marshaled as JSType.BigInt or as JSType.Number, configurable per parameter via JSMarshalAsAttribute similar to MarshalAsAttribute of P/Invoke
  • there is no way to create a JS instance solely by manipulations WASM memory. The marshaling depends on a library of JS helper routines to create JS values and manipulate JS object properties. That's why we need to generate JS code too.
  • generate JS side of the marshaling on runtime, to decrease download size. Provide necessary metadata during method binding.
  • marshaled types are:
    • subset of primitive numeric types and their nullable alternative
    • String, Boolean, DateTime, DateTimeOffset, Exception
    • dynamic marshaling of System.Object with mapping to well known types for some instance types and proxy via GCHandle for the rest.
    • JSObject with private legacy implementation JSObject, which is proxy via existing JSHandle concept similar to GCHandle
    • Task, Func, Action
    • byte[], int[], double[]
    • Span<byte>, Span<int>, Span<double> and ArraySegment<byte>, ArraySegment<int>, ArraySegment<double>
    • Custom P/Invoke marshaler with [MarshalUsing(typeof(NativeMarshaler))]
  • we have 2 garbage collectors to worry about
  • we do have existing private interop in System.Private.Runtime.InteropServices.JavaScript assembly and also semi-private JavaScript embedding API. These are used by Blazor and other partners and this proposal could help to phase it out gradually.

There more implementation details described on the prototype PR

API Proposal

Below are types which drive the code generator

namespace System.Runtime.InteropServices.JavaScript;

[System.AttributeUsageAttribute(System.AttributeTargets.Method, Inherited = false, AllowMultiple = false)]
[Versioning.SupportedOSPlatform("browser")]
public sealed class JSImportAttribute : System.Attribute
{
    public string FunctionName { get; }
    public string ModuleName { get; }
    public JSImportAttribute(string functionName) => throw null;
    public JSImportAttribute(string functionName, string moduleName) => throw null;
}
[System.AttributeUsageAttribute(System.AttributeTargets.Method, Inherited = false, AllowMultiple = false)]
[Versioning.SupportedOSPlatform("browser")]
public sealed class JSExportAttribute : System.Attribute
{
    public JSExportAttribute() => throw null;
}

// this is used to annotate the marshaled parameters
[System.AttributeUsageAttribute(System.AttributeTargets.Parameter | System.AttributeTargets.ReturnValue, Inherited = false, AllowMultiple = false)]
[Versioning.SupportedOSPlatform("browser")]
public sealed class JSMarshalAsAttribute<T> : System.Attribute where T : JSType
{
    public JSMarshalAsAttribute() => throw null;
}
[Versioning.SupportedOSPlatform("browser")]
public abstract class JSType
{
    internal JSType() => throw null;
    public sealed class None : JSType
    {
        internal None() => throw null;
    }
    public sealed class Void : JSType
    {
        internal Void() => throw null;
    }
    public sealed class Discard : JSType
    {
        internal Discard() => throw null;
    }
    public sealed class Boolean : JSType
    {
        internal Boolean() => throw null;
    }
    public sealed class Number : JSType
    {
        internal Number() => throw null;
    }
    public sealed class BigInt : JSType
    {
        internal BigInt() => throw null;
    }
    public sealed class Date : JSType
    {
        internal Date() => throw null;
    }
    public sealed class String : JSType
    {
        internal String() => throw null;
    }
    public sealed class Object : JSType
    {
        internal Object() => throw null;
    }
    public sealed class Error : JSType
    {
        internal Error() => throw null;
    }
    public sealed class MemoryView : JSType
    {
        internal MemoryView() => throw null;
    }
    public sealed class Array<T> : JSType where T : JSType
    {
        internal Array() => throw null;
    }
    public sealed class Promise<T> : JSType where T : JSType
    {
        internal Promise() => throw null;
    }
    public sealed class Function : JSType
    {
        internal Function() => throw null;
    }
    public sealed class Function<T> : JSType where T : JSType
    {
        internal Function() => throw null;
    }
    public sealed class Function<T1, T2> : JSType where T1 : JSType where T2 : JSType
    {
        internal Function() => throw null;
    }
    public sealed class Function<T1, T2, T3> : JSType where T1 : JSType where T2 : JSType where T3 : JSType
    {
        internal Function() => throw null;
    }
    public sealed class Function<T1, T2, T3, T4> : JSType where T1 : JSType where T2 : JSType where T3 : JSType where T4 : JSType
    {
        internal Function() => throw null;
    }
    public sealed class Any : JSType
    {
        internal Any() => throw null;
    }
}

Below are types for working with JavaScript instances

namespace System.Runtime.InteropServices.JavaScript;

[Versioning.SupportedOSPlatform("browser")]
public class JSObject : System.IDisposable
{
    internal JSObject() => throw null;
    public bool IsDisposed { get => throw null; }
    public void Dispose() => throw null;

    public bool HasProperty(string propertyName) => throw null;
    public string GetTypeOfProperty(string propertyName) => throw null;

    public bool GetPropertyAsBoolean(string propertyName) => throw null;
    public int GetPropertyAsInt32(string propertyName) => throw null;
    public double GetPropertyAsDouble(string propertyName) => throw null;
    public string? GetPropertyAsString(string propertyName) => throw null;
    public JSObject? GetPropertyAsJSObject(string propertyName) => throw null;
    public byte[]? GetPropertyAsByteArray(string propertyName) => throw null;

    public void SetProperty(string propertyName, bool value) => throw null;
    public void SetProperty(string propertyName, int value) => throw null;
    public void SetProperty(string propertyName, double value) => throw null;
    public void SetProperty(string propertyName, string? value) => throw null;
    public void SetProperty(string propertyName, JSObject? value) => throw null;
    public void SetProperty(string propertyName, byte[]? value) => throw null;
}
// when we marshal JS Error type
[Versioning.SupportedOSPlatform("browser")]
public sealed class JSException : System.Exception
{
    public JSException(string msg) => throw null;
}
[Versioning.SupportedOSPlatform("browser")]
public static class JSHost
{
    public static JSObject GlobalThis { get => throw null; }
    public static JSObject DotnetInstance { get => throw null; }
    public static System.Threading.Tasks.Task<JSObject> Import(string moduleName, string moduleUrl) => throw null;
}

Below types are used by the generated code

[Versioning.SupportedOSPlatform("browser")]
[CLSCompliant(false)]
[System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)]
// to bind and call methods
public sealed class JSFunctionBinding
{
    public static void InvokeJS(JSFunctionBinding signature, Span<JSMarshalerArgument> arguments) => throw null;
    public static JSFunctionBinding BindJSFunction(string functionName, string moduleName, System.ReadOnlySpan<JSMarshalerType> signatures) => throw null;
    public static JSFunctionBinding BindCSFunction(string fullyQualifiedName, int signatureHash, System.ReadOnlySpan<JSMarshalerType> signatures) => throw null;
}
[Versioning.SupportedOSPlatform("browser")]
[System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)]
// to create binding metadata
public sealed class JSMarshalerType
{
    private JSMarshalerType() => throw null;
    public static JSMarshalerType Void { get => throw null; }
    public static JSMarshalerType Discard { get => throw null; }
    public static JSMarshalerType Boolean { get => throw null; }
    public static JSMarshalerType Byte { get => throw null; }
    public static JSMarshalerType Char { get => throw null; }
    public static JSMarshalerType Int16 { get => throw null; }
    public static JSMarshalerType Int32 { get => throw null; }
    public static JSMarshalerType Int52 { get => throw null; }
    public static JSMarshalerType BigInt64 { get => throw null; }
    public static JSMarshalerType Double { get => throw null; }
    public static JSMarshalerType Single { get => throw null; }
    public static JSMarshalerType IntPtr { get => throw null; }
    public static JSMarshalerType JSObject { get => throw null; }
    public static JSMarshalerType Object { get => throw null; }
    public static JSMarshalerType String { get => throw null; }
    public static JSMarshalerType Exception { get => throw null; }
    public static JSMarshalerType DateTime { get => throw null; }
    public static JSMarshalerType DateTimeOffset { get => throw null; }
    public static JSMarshalerType Nullable(JSMarshalerType primitive) => throw null;
    public static JSMarshalerType Task() => throw null;
    public static JSMarshalerType Task(JSMarshalerType result) => throw null;
    public static JSMarshalerType Array(JSMarshalerType element) => throw null;
    public static JSMarshalerType ArraySegment(JSMarshalerType element) => throw null;
    public static JSMarshalerType Span(JSMarshalerType element) => throw null;
    public static JSMarshalerType Action() => throw null;
    public static JSMarshalerType Action(JSMarshalerType arg1) => throw null;
    public static JSMarshalerType Action(JSMarshalerType arg1, JSMarshalerType arg2) => throw null;
    public static JSMarshalerType Action(JSMarshalerType arg1, JSMarshalerType arg2, JSMarshalerType arg3) => throw null;
    public static JSMarshalerType Function(JSMarshalerType result) => throw null;
    public static JSMarshalerType Function(JSMarshalerType arg1, JSMarshalerType result) => throw null;
    public static JSMarshalerType Function(JSMarshalerType arg1, JSMarshalerType arg2, JSMarshalerType result) => throw null;
    public static JSMarshalerType Function(JSMarshalerType arg1, JSMarshalerType arg2, JSMarshalerType arg3, JSMarshalerType result) => throw null;
}
[Versioning.SupportedOSPlatform("browser")]
[CLSCompliant(false)]
[System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)]
// actual marshalers
public struct JSMarshalerArgument
{
    public delegate void ArgumentToManagedCallback<T>(ref JSMarshalerArgument arg, out T value);
    public delegate void ArgumentToJSCallback<T>(ref JSMarshalerArgument arg, T value);
    public void Initialize() => throw null;
    public void ToManaged(out bool value) => throw null;
    public void ToJS(bool value) => throw null;
    public void ToManaged(out bool? value) => throw null;
    public void ToJS(bool? value) => throw null;
    public void ToManaged(out byte value) => throw null;
    public void ToJS(byte value) => throw null;
    public void ToManaged(out byte? value) => throw null;
    public void ToJS(byte? value) => throw null;
    public void ToManaged(out byte[]? value) => throw null;
    public void ToJS(byte[]? value) => throw null;
    public void ToManaged(out char value) => throw null;
    public void ToJS(char value) => throw null;
    public void ToManaged(out char? value) => throw null;
    public void ToJS(char? value) => throw null;
    public void ToManaged(out short value) => throw null;
    public void ToJS(short value) => throw null;
    public void ToManaged(out short? value) => throw null;
    public void ToJS(short? value) => throw null;
    public void ToManaged(out int value) => throw null;
    public void ToJS(int value) => throw null;
    public void ToManaged(out int? value) => throw null;
    public void ToJS(int? value) => throw null;
    public void ToManaged(out int[]? value) => throw null;
    public void ToJS(int[]? value) => throw null;
    public void ToManaged(out long value) => throw null;
    public void ToJS(long value) => throw null;
    public void ToManaged(out long? value) => throw null;
    public void ToJS(long? value) => throw null;
    public void ToManagedBig(out long value) => throw null;
    public void ToJSBig(long value) => throw null;
    public void ToManagedBig(out long? value) => throw null;
    public void ToJSBig(long? value) => throw null;
    public void ToManaged(out float value) => throw null;
    public void ToJS(float value) => throw null;
    public void ToManaged(out float? value) => throw null;
    public void ToJS(float? value) => throw null;
    public void ToManaged(out double value) => throw null;
    public void ToJS(double value) => throw null;
    public void ToManaged(out double? value) => throw null;
    public void ToJS(double? value) => throw null;
    public void ToManaged(out double[]? value) => throw null;
    public void ToJS(double[]? value) => throw null;
    public void ToManaged(out IntPtr value) => throw null;
    public void ToJS(IntPtr value) => throw null;
    public void ToManaged(out IntPtr? value) => throw null;
    public void ToJS(IntPtr? value) => throw null;
    public void ToManaged(out DateTimeOffset value) => throw null;
    public void ToJS(DateTimeOffset value) => throw null;
    public void ToManaged(out DateTimeOffset? value) => throw null;
    public void ToJS(DateTimeOffset? value) => throw null;
    public void ToManaged(out DateTime value) => throw null;
    public void ToJS(DateTime value) => throw null;
    public void ToManaged(out DateTime? value) => throw null;
    public void ToJS(DateTime? value) => throw null;
    public void ToManaged(out string? value) => throw null;
    public void ToJS(string? value) => throw null;
    public void ToManaged(out string?[]? value) => throw null;
    public void ToJS(string?[]? value) => throw null;
    public void ToManaged(out Exception? value) => throw null;
    public void ToJS(Exception? value) => throw null;
    public void ToManaged(out object? value) => throw null;
    public void ToJS(object? value) => throw null;
    public void ToManaged(out object?[]? value) => throw null;
    public void ToJS(object?[]? value) => throw null;
    public void ToManaged(out JSObject? value) => throw null;
    public void ToJS(JSObject? value) => throw null;
    public void ToManaged(out JSObject?[]? value) => throw null;
    public void ToJS(JSObject?[]? value) => throw null;
    public void ToManaged(out System.Threading.Tasks.Task? value) => throw null;
    public void ToJS(System.Threading.Tasks.Task? value) => throw null;
    public void ToManaged<T>(out System.Threading.Tasks.Task<T>? value, ArgumentToManagedCallback<T> marshaler) => throw null;
    public void ToJS<T>(System.Threading.Tasks.Task<T>? value, ArgumentToJSCallback<T> marshaler) => throw null;
    public void ToManaged(out Action? value) => throw null;
    public void ToJS(Action? value) => throw null;
    public void ToManaged<T>(out Action<T>? value, ArgumentToJSCallback<T> arg1Marshaler) => throw null;
    public void ToJS<T>(Action<T>? value, ArgumentToManagedCallback<T> arg1Marshaler) => throw null;
    public void ToManaged<T1, T2>(out Action<T1, T2>? value, ArgumentToJSCallback<T1> arg1Marshaler, ArgumentToJSCallback<T2> arg2Marshaler) => throw null;
    public void ToJS<T1, T2>(Action<T1, T2>? value, ArgumentToManagedCallback<T1> arg1Marshaler, ArgumentToManagedCallback<T2> arg2Marshaler) => throw null;
    public void ToManaged<T1, T2, T3>(out Action<T1, T2, T3>? value, ArgumentToJSCallback<T1> arg1Marshaler, ArgumentToJSCallback<T2> arg2Marshaler, ArgumentToJSCallback<T3> arg3Marshaler) => throw null;
    public void ToJS<T1, T2, T3>(Action<T1, T2, T3>? value, ArgumentToManagedCallback<T1> arg1Marshaler, ArgumentToManagedCallback<T2> arg2Marshaler, ArgumentToManagedCallback<T3> arg3Marshaler) => throw null;
    public void ToManaged<TResult>(out Func<TResult>? value, ArgumentToManagedCallback<TResult> resMarshaler) => throw null;
    public void ToJS<TResult>(Func<TResult>? value, ArgumentToJSCallback<TResult> resMarshaler) => throw null;
    public void ToManaged<T, TResult>(out Func<T, TResult>? value, ArgumentToJSCallback<T> arg1Marshaler, ArgumentToManagedCallback<TResult> resMarshaler) => throw null;
    public void ToJS<T, TResult>(Func<T, TResult>? value, ArgumentToManagedCallback<T> arg1Marshaler, ArgumentToJSCallback<TResult> resMarshaler) => throw null;
    public void ToManaged<T1, T2, TResult>(out Func<T1, T2, TResult>? value, ArgumentToJSCallback<T1> arg1Marshaler, ArgumentToJSCallback<T2> arg2Marshaler, ArgumentToManagedCallback<TResult> resMarshaler) => throw null;
    public void ToJS<T1, T2, TResult>(Func<T1, T2, TResult>? value, ArgumentToManagedCallback<T1> arg1Marshaler, ArgumentToManagedCallback<T2> arg2Marshaler, ArgumentToJSCallback<TResult> resMarshaler) => throw null;
    public void ToManaged<T1, T2, T3, TResult>(out Func<T1, T2, T3, TResult>? value, ArgumentToJSCallback<T1> arg1Marshaler, ArgumentToJSCallback<T2> arg2Marshaler, ArgumentToJSCallback<T3> arg3Marshaler, ArgumentToManagedCallback<TResult> resMarshaler) => throw null;
    public void ToJS<T1, T2, T3, TResult>(Func<T1, T2, T3, TResult>? value, ArgumentToManagedCallback<T1> arg1Marshaler, ArgumentToManagedCallback<T2> arg2Marshaler, ArgumentToManagedCallback<T3> arg3Marshaler, ArgumentToJSCallback<TResult> resMarshaler) => throw null;
    public unsafe void ToManaged(out void* value) => throw null;
    public unsafe void ToJS(void* value) => throw null;
    public void ToManaged(out Span<byte> value) => throw null;
    public void ToJS(Span<byte> value) => throw null;
    public void ToManaged(out ArraySegment<byte> value) => throw null;
    public void ToJS(ArraySegment<byte> value) => throw null;
    public void ToManaged(out Span<int> value) => throw null;
    public void ToJS(Span<int> value) => throw null;
    public void ToManaged(out Span<double> value) => throw null;
    public void ToJS(Span<double> value) => throw null;
    public void ToManaged(out ArraySegment<int> value) => throw null;
    public void ToJS(ArraySegment<int> value) => throw null;
    public void ToManaged(out ArraySegment<double> value) => throw null;
    public void ToJS(ArraySegment<double> value) => throw null;
}

API Usage

Trivial example

// here we bind to well known console.log on the blobal JS namespace
[JSImport("console.log")]
// there is no return value marshaling, but exception would be marshaled
internal static partial void Log(
    // this one will marshal C# string to JavaScript native string by value (with some optimizations)
    string message);

This is code generated by Roslyn, simplified for brevity

[System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Interop.JavaScript.JSImportGenerator", "42.42.42.42")]
public static partial void Log(string message)
{
    if (__signature_Log_20494476 == null)
    {
        __signature_Log_20494476 = JSFunctionBinding.BindJSFunction("console.log", null,
            new JSMarshalerType[]{
                JSMarshalerType.Discard, 
                JSMarshalerType.String});
    }

    System.Span<JSMarshalerArgument> __arguments_buffer = stackalloc JSMarshalerArgument[3];
    ref JSMarshalerArgument __arg_exception = ref __arguments_buffer[0];
    __arg_exception.Initialize();
    ref JSMarshalerArgument __arg_return = ref __arguments_buffer[1];
    __arg_return.Initialize();

    ref JSMarshalerArgument __message_native__js_arg = ref __arguments_buffer[2];

    __message_native__js_arg.ToJS(in message);
    // this will also marshal exception
    JSFunctionBinding.InvokeJS(__signature_Log_20494476, __arguments_buffer);
}

static volatile JSFunctionBinding __signature_Log_20494476;

This will be generated on the runtime for the JavaScript marshaling stub

function factory(closure) {
    //# sourceURL=https://mono-wasm.invalid/_bound_js_console_log
    const { signature, fn, marshal_exception_to_cs, converter2 } = closure;
    return function _bound_js_console_log(args) {
        try {
            const arg0 = converter2(args + 32, signature + 72); //  String
            // fn is reference to console.log here
            const js_result = fn(arg0);
            if (js_result !== undefined) throw new Error('Function console.log returned unexpected value, C# signature is void');
        } catch (ex) {
            marshal_exception_to_cs(args, ex);
        }
    }
}

More examples

// from the rewrite of the runtime's implementation of Http wrapper on WASM.
[JSImport("INTERNAL.http_wasm_get_response_header_names")]
private static partial string[] _GetResponseHeaderNames(
    JSObject fetchResponse);

[JSImport("INTERNAL.http_wasm_fetch_bytes")]
private static partial Task<JSObject> FetchBytes(
    string uri,
    string[] headerNames,
    string[] headerValues,
    string[] optionNames,
    [JSMarshalAs<JSType.Array<JSType.Any>] object?[] optionValues,
    JSObject abortControler,
    IntPtr bodyPtr,
    int bodyLength
    );

[JSImport("INTERNAL.http_wasm_get_response_bytes")]
public static partial int GetResponseBytes(
    JSObject fetchResponse,
    [JSMarshalAs<JSType.MemoryView>] Span<byte> buffer);

// from the rewrite of the runtime's implementation of WebSocket wrapper on WASM.
[JSImport("INTERNAL.ws_wasm_create")]
public static partial JSObject WebSocketCreate(
    string uri,
    string?[]? subProtocols,
    [JSMarshalAs<JSType.Function<JSType.Number, JSType.String>>] Action<int, string> onClosed);

[JSImport("INTERNAL.ws_wasm_send")]
public static partial Task? WebSocketSend(
    JSObject webSocket,
    [JSMarshalAs<JSType.MemoryView>] ArraySegment<byte> buffer,
    int messageType,
    bool endOfMessage);

// this is how to marshal strongly typed function
[JSImport("INTERNAL.create_function")]
[return: JSMarshalAs<JSType.Function<JSType.Number, JSType.Number, JSType.Number>]
public static partial Func<double, double, double> CreateFunctionDoubleDoubleDouble(
    string arg1Name, 
    string arg2Name, 
    string code);

// this is sample how to export managed method to be consumable by JS
// the JS side wrapper would be exported into EXPORTS JS API object
// all arguments are natural JS types for the caller
[JSExport]
public static async Task<string> SlowFailure(Task<int> promisedNumber)
{
    var delayMs = await promisedNumber;
    // this would be marshled as JS promise rejection
    if (promisedNumber<0) throw new ArgumentException("delayMs");

    await Task.Delay(delayMs);
    return "Slow hello";
}

Alternative Designs

  • We have existing private interop. It has few design flaws, the worst of them is that it gives to JS code naked pointers to managed objects. They could move on GC making it fragile.
  • We could do full dynamic marshaling on runtime, but it would need lot of reflection and it's not trimming friendly

Open questions:

  • we consider that maybe we could marshal more dynamic combinations of parameters in the future. JavaScript is dynamic language after all. We made JSType as flags to prepare for it as it would be difficult to change in the future.
  • Should we have GetProperty and SetProperty directly on the JSObject answered
  • The JSMarshalerArgument has marshalers on it. For primitive types we do both nullable and non-nullable alternative. In JS world everything is nullable. Shall we enforce nullability constraint on runtime ? answered
  • We made JSMarshalerArgument.ToManaged(out Task value) non-nullable, but in fact you can pass null Promise. Reason: forcing user to check null before calling await felt akward. Passing null promise is useful on synchronous returns from JS. answered

Risks

  • this proposal is not improving CSP compliance, we may want to evolve the solution in the future to generate JS files during compile time.
  • the quality of the generator in the prototype is low. It doesn't handle all negative scenarios and the diagnostic messages are just sketch.
  • We validated the design from perf perspective with the team, but we have to measure it yet.
  • Same for memory leaks, there are 2 GCs involved.
@pavelsavara pavelsavara added the api-suggestion Early API idea and discussion, it is NOT ready for implementation label Jun 2, 2022
@pavelsavara pavelsavara self-assigned this Jun 2, 2022
@ghost ghost added the untriaged New issue has not been triaged by the area owner label Jun 2, 2022
@ghost
Copy link

ghost commented Jun 2, 2022

Tagging subscribers to this area: @dotnet/interop-contrib
See info in area-owners.md if you want to be subscribed.

Issue Details

Background and motivation

When .NET is running on WebAssembly, for example as part of Blazor, developers may want to interact with the browser's JavaScript engine and native components. Currently we don't have public C# API to do so.

We propose this API together with prototype of the implementation.
Key features are:

  • generate C# side of the marshaling stub in as partial method, Roslyn analyzer triggered by JSImportAttribute or JSExportAttribute. We re-use common code gen infrastructure from [LibraryImport]
  • Allow different marshalers for the same managed type, for example Int64 could be marshaled as JSType.BigInt or as JSType.Number, configurable per parameter via JSMarshalAsAttribute similar to MarshalAsAttribute or P/Invoke
  • the JavaScript doesn't have natural concept of memory, instead useful marshaling needs to create JS native types. Wasm native code could not create them, and so we have to always have marshaling code also on the JS side.
  • generate JS side of the marshaling on runtime, do decrease download size. Provide necessary metadata during method binding.
  • marshaled types are:
    • subset of primitive numeric types and their nullable alternative
    • String, Boolean, DateTime, DateTimeOffset, Exception
    • dynamic marshaling of System.Object with mapping to well known types for some instance types and proxy via GCHandle for the rest.
    • IJSObject with private legacy implementation JSObject, which is proxy via existing JSHandle concept similar to GCHandle
    • Task, Func, Action
    • byte[], int[], double[]
    • Span<byte>, Span<int>, Span<double> and ArraySegment<byte>, ArraySegment<int>, ArraySegment<double>
    • Custom marshaler to and from managed and JS objects of any shape
    • Custom P/Invoke marshaler with [MarshalUsing(typeof(NativeMarshaler))]
  • we have 2 garbage collectors to worry about
  • we do have existing private interop in System.Private.Runtime.InteropServices.JavaScript assembly and also semi-private JavaScript embedding API. These are used by Blazor and other partners and this proposal could help to phase it out gradually.

There more implementation details described on the prototype PR

API Proposal

Below are types which drive the code generator

namespace System.Runtime.InteropServices.JavaScript;


// these are the attributes which trigger code-gen
[System.AttributeUsageAttribute(System.AttributeTargets.Method, Inherited = false, AllowMultiple = false)]
[Versioning.SupportedOSPlatform("browser")]
public sealed class JSImportAttribute : System.Attribute
{
    public string FunctionName { get; }
    public JSImportAttribute(string functionName) => throw null;
}
[System.AttributeUsageAttribute(System.AttributeTargets.Method, Inherited = false, AllowMultiple = false)]
[Versioning.SupportedOSPlatform("browser")]
public sealed class JSExportAttribute : System.Attribute
{
    public string FunctionName { get; }
    public JSExportAttribute() => throw null;
    public JSExportAttribute(string functionName) => throw null;
}

// this is used to annotate the marshaled parameters
[System.AttributeUsageAttribute(System.AttributeTargets.Parameter | System.AttributeTargets.ReturnValue, Inherited = false, AllowMultiple = false)]
[Versioning.SupportedOSPlatform("browser")]
public sealed class JSMarshalAsAttribute : System.Attribute
{
    public JSType Type { get { throw null; } }
    public JSType[] TypeArguments { get; }
    public System.Type? CustomMarshaler { get { throw null; } }
    public JSMarshalAsAttribute(JSType type) => throw null;
    public JSMarshalAsAttribute(JSType type, JSType typeArgument1) => throw null;
    public JSMarshalAsAttribute(JSType type, JSType typeArgument1, JSType typeArgument2) => throw null;
    public JSMarshalAsAttribute(JSType type, JSType typeArgument1, JSType typeArgument2, JSType typeArgument3) => throw null;
    public JSMarshalAsAttribute(JSType type, JSType typeArgument1, JSType typeArgument2, JSType typeArgument3, JSType typeArgument4) => throw null;
    public JSMarshalAsAttribute(JSType type, System.Type customMarshaler) => throw null;
}
[Versioning.SupportedOSPlatform("browser")]
[System.Flags]
public enum JSType : int
{
    None = 0x0,
    Void = 0x1,
    Boolean = 0x2,
    Number = 0x4, // max 52 integral bits
    BigInt = 0x8,
    Date = 0x10,
    String = 0x20,
    Function = 0x40,
    Array = 0x80,
    Object = 0x100,
    Promise = 0x200,
    Error = 0x400,
    MemoryView = 0x800,
    Custom = 0x1000,
    Any = 0x2000,
}

Below are types which drive the code generator for custom marshler

namespace System.Runtime.InteropServices.JavaScript;
// when you use [JSMarshalAs(JSType.Custom, typeof(YourMarshaler))] you mark YourMarshaler with [JSCustomMarshaller]
[System.AttributeUsageAttribute(System.AttributeTargets.Struct, Inherited = false, AllowMultiple = false)]
[Versioning.SupportedOSPlatform("browser")]
public sealed class JSCustomMarshallerAttribute : System.Attribute
{
    public System.Type ManagedType { get { throw null; } }
    public JSCustomMarshallerAttribute(System.Type managedType) => throw null;
}
// IJSCustomMarshaller and IJSCustomMarshaller<T> helps the developer to stick to proper shape
[Versioning.SupportedOSPlatform("browser")]
public interface IJSCustomMarshaller
{
    string JavaScriptCode { get; }
}
[Versioning.SupportedOSPlatform("browser")]
[CLSCompliant(false)]
public interface IJSCustomMarshaller<T> : IJSCustomMarshaller
{
    void ToManaged(in JavaScriptMarshalerArgument arg, out T value);
    void ToJavaScript(ref JavaScriptMarshalerArgument arg, in T value);
}

Below are types for working with instances of JavaScript instances

namespace System.Runtime.InteropServices.JavaScript;

// IJSObject is public face of the internal legacy JSObject, it represents the proxy of JavaScript object instance
[Versioning.SupportedOSPlatform("browser")]
public interface IJSObject : IDisposable
{
    public bool IsDisposed { get; }
}
// when we marshal JS Error type
[Versioning.SupportedOSPlatform("browser")]
public sealed class JSException : Exception
{
    public JSException(string msg) => throw null;
}
// delegates need to be marshaled as strongly typed and  we need to generate code for marshaling the parameters on the actual call
// below is guess on parameter types combinations which would be useful on creating JS function from string of the JS code
// see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Function/Function
// user can create same factory themselves (up to 3 generic type arguments for now), this is just convinience
[Versioning.SupportedOSPlatform("browser")]
public static class JSFunction
{
    public static void New(string code, out Action function) => throw null;
    public static void New(string code, out Func<bool> function) => throw null;
    public static void New(string code, out Func<int> function) => throw null;
    public static void New(string code, out Func<long> function) => throw null;
    public static void New(string code, out Func<double> function) => throw null;
    public static void New(string code, out Func<string> function) => throw null;
    public static void New(string code, out Func<IJSObject> function) => throw null;
    public static void New(string arg1Name, string code, out Action<bool> function) => throw null;
    public static void New(string arg1Name, string code, out Action<int> function) => throw null;
    public static void New(string arg1Name, string code, out Action<long> function) => throw null;
    public static void New(string arg1Name, string code, out Action<double> function) => throw null;
    public static void New(string arg1Name, string code, out Action<string> function) => throw null;
    public static void New(string arg1Name, string code, out Action<IJSObject> function) => throw null;
    public static void New(string arg1Name, string code, out Func<bool, bool> function) => throw null;
    public static void New(string arg1Name, string code, out Func<int, int> function) => throw null;
    public static void New(string arg1Name, string code, out Func<long, long> function) => throw null;
    public static void New(string arg1Name, string code, out Func<double, double> function) => throw null;
    public static void New(string arg1Name, string code, out Func<string, string> function) => throw null;
    public static void New(string arg1Name, string code, out Func<IJSObject, IJSObject> function) => throw null;
    public static void New(string arg1Name, string code, out Func<bool, IJSObject> function) => throw null;
    public static void New(string arg1Name, string code, out Func<int, IJSObject> function) => throw null;
    public static void New(string arg1Name, string code, out Func<long, IJSObject> function) => throw null;
    public static void New(string arg1Name, string code, out Func<string, IJSObject> function) => throw null;
    public static void New(string arg1Name, string code, out Func<double, IJSObject> function) => throw null;
    public static void New(string arg1Name, string arg2Name, string code, out Action<int, int> function) => throw null;
    public static void New(string arg1Name, string arg2Name, string code, out Action<long, long> function) => throw null;
    public static void New(string arg1Name, string arg2Name, string code, out Action<double, double> function) => throw null;
    public static void New(string arg1Name, string arg2Name, string code, out Action<string, string> function) => throw null;
    public static void New(string arg1Name, string arg2Name, string code, out Func<int, int, int> function) => throw null;
    public static void New(string arg1Name, string arg2Name, string code, out Func<long, long, long> function) => throw null;
    public static void New(string arg1Name, string arg2Name, string code, out Func<double, double, double> function) => throw null;
    public static void New(string arg1Name, string arg2Name, string code, out Func<string, string, string> function) => throw null;
}
// there are many things that you can call on JavaScript object
// here are few handy helpers, user will be able to create more using [JSImport]
[Versioning.SupportedOSPlatform("browser")]
public static class JavaScriptExtensions
{
    public static void GetProperty(this IJSObject self, string propertyName, out bool? value) => throw null;
    public static void SetProperty(this IJSObject self, string propertyName, bool? value) => throw null;
    public static void GetProperty(this IJSObject self, string propertyName, out int? value) => throw null;
    public static void SetProperty(this IJSObject self, string propertyName, int? value) => throw null;
    public static void GetProperty(this IJSObject self, string propertyName, out long? value) => throw null;
    public static void SetProperty(this IJSObject self, string propertyName, long? value) => throw null;
    public static void GetProperty(this IJSObject self, string propertyName, out double? value) => throw null;
    public static void SetProperty(this IJSObject self, string propertyName, double? value) => throw null;
    public static void GetProperty(this IJSObject self, string propertyName, out string? value) => throw null;
    public static void SetProperty(this IJSObject self, string propertyName, string? value) => throw null;
    public static void GetProperty(this IJSObject self, string propertyName, out IJSObject? value) => throw null;
    public static void SetProperty(this IJSObject self, string propertyName, IJSObject? value) => throw null;
}

Below are used by generated code

namespace System.Runtime.InteropServices.JavaScript;

// to bind and call methods
[Versioning.SupportedOSPlatform("browser")]
[CLSCompliant(false)]
public sealed class JavaScriptMarshalerSignature
{
    public static void InvokeBoundJSFunction(JavaScriptMarshalerSignature signature, Span<JavaScriptMarshalerArgument> arguments) => throw null;
    public static JavaScriptMarshalerSignature BindJSFunction(string functionName, JavaScriptMarshalerType[] signatures) => throw null;
    public static JavaScriptMarshalerSignature BindCSFunction(string fullyQualifiedName, int signatureHash, string? exportAsName, JavaScriptMarshalerType[] signatures) => throw null;
}
// to create binding metadata
[Versioning.SupportedOSPlatform("browser")]
[StructLayout(LayoutKind.Sequential, Pack = 4, Size = 32)]
public struct JavaScriptMarshalerType
{
    public static JavaScriptMarshalerType Void { get => throw null; }
    public static JavaScriptMarshalerType Boolean { get => throw null; }
    public static JavaScriptMarshalerType Byte { get => throw null; }
    public static JavaScriptMarshalerType Char { get => throw null; }
    public static JavaScriptMarshalerType Int16 { get => throw null; }
    public static JavaScriptMarshalerType Int32 { get => throw null; }
    public static JavaScriptMarshalerType Int52 { get => throw null; }
    public static JavaScriptMarshalerType BigInt64 { get => throw null; }
    public static JavaScriptMarshalerType Double { get => throw null; }
    public static JavaScriptMarshalerType Single { get => throw null; }
    public static JavaScriptMarshalerType IntPtr { get => throw null; }
    public static JavaScriptMarshalerType JSObject { get => throw null; }
    public static JavaScriptMarshalerType Object { get => throw null; }
    public static JavaScriptMarshalerType String { get => throw null; }
    public static JavaScriptMarshalerType Exception { get => throw null; }
    public static JavaScriptMarshalerType DateTime { get => throw null; }
    public static JavaScriptMarshalerType DateTimeOffset { get => throw null; }
    public static JavaScriptMarshalerType Nullable(JavaScriptMarshalerType primitive) => throw null;
    public static JavaScriptMarshalerType Task() => throw null;
    public static JavaScriptMarshalerType Task(JavaScriptMarshalerType result) => throw null;
    public static JavaScriptMarshalerType Array(JavaScriptMarshalerType element) => throw null;
    public static JavaScriptMarshalerType ArraySegment(JavaScriptMarshalerType element) => throw null;
    public static JavaScriptMarshalerType Span(JavaScriptMarshalerType element) => throw null;
    public static JavaScriptMarshalerType Action() => throw null;
    public static JavaScriptMarshalerType Action(JavaScriptMarshalerType arg1) => throw null;
    public static JavaScriptMarshalerType Action(JavaScriptMarshalerType arg1, JavaScriptMarshalerType arg2) => throw null;
    public static JavaScriptMarshalerType Action(JavaScriptMarshalerType arg1, JavaScriptMarshalerType arg2, JavaScriptMarshalerType arg3) => throw null;
    public static JavaScriptMarshalerType Function(JavaScriptMarshalerType result) => throw null;
    public static JavaScriptMarshalerType Function(JavaScriptMarshalerType arg1, JavaScriptMarshalerType result) => throw null;
    public static JavaScriptMarshalerType Function(JavaScriptMarshalerType arg1, JavaScriptMarshalerType arg2, JavaScriptMarshalerType result) => throw null;
    public static JavaScriptMarshalerType Function(JavaScriptMarshalerType arg1, JavaScriptMarshalerType arg2, JavaScriptMarshalerType arg3, JavaScriptMarshalerType result) => throw null;
    public static JavaScriptMarshalerType Custom<TMarshaled, TMarshaler>() where TMarshaler : struct => throw null;
    public static JavaScriptMarshalerType NativeMarshalling<TMarshaled, TMarshaler>() where TMarshaler : struct => throw null;
}
// actual marshalers
[Versioning.SupportedOSPlatform("browser")]
[StructLayout(LayoutKind.Explicit, Pack = 16, Size = 16)]
[CLSCompliant(false)]
public struct JavaScriptMarshalerArgument
{
    public delegate void ArgumentToManagedCallback<T>(ref JavaScriptMarshalerArgument arg, out T value);
    public delegate void ArgumentToJavaScriptCallback<T>(ref JavaScriptMarshalerArgument arg, in T value);
    public void Initialize() => throw null;
    public void ToManaged(out bool value) => throw null;
    public void ToJavaScript(in bool value) => throw null;
    public void ToManaged(out bool? value) => throw null;
    public void ToJavaScript(in bool? value) => throw null;
    public void ToManaged(out byte value) => throw null;
    public void ToJavaScript(in byte value) => throw null;
    public void ToManaged(out byte? value) => throw null;
    public void ToJavaScript(in byte? value) => throw null;
    public void ToManaged(out byte[]? value) => throw null;
    public void ToJavaScript(in byte[]? value) => throw null;
    public void ToManaged(out char value) => throw null;
    public void ToJavaScript(in char value) => throw null;
    public void ToManaged(out char? value) => throw null;
    public void ToJavaScript(in char? value) => throw null;
    public void ToManaged(out short value) => throw null;
    public void ToJavaScript(in short value) => throw null;
    public void ToManaged(out short? value) => throw null;
    public void ToJavaScript(in short? value) => throw null;
    public void ToManaged(out int value) => throw null;
    public void ToJavaScript(in int value) => throw null;
    public void ToManaged(out int? value) => throw null;
    public void ToJavaScript(in int? value) => throw null;
    public void ToManaged(out int[]? value) => throw null;
    public void ToJavaScript(in int[]? value) => throw null;
    public void ToManaged(out long value) => throw null;
    public void ToJavaScript(in long value) => throw null;
    public void ToManaged(out long? value) => throw null;
    public void ToJavaScript(in long? value) => throw null;
    public void ToManagedBig(out long value) => throw null;
    public void ToJavaScriptBig(in long value) => throw null;
    public void ToManagedBig(out long? value) => throw null;
    public void ToJavaScriptBig(in long? value) => throw null;
    public void ToManaged(out float value) => throw null;
    public void ToJavaScript(in float value) => throw null;
    public void ToManaged(out float? value) => throw null;
    public void ToJavaScript(in float? value) => throw null;
    public void ToManaged(out double value) => throw null;
    public void ToJavaScript(in double value) => throw null;
    public void ToManaged(out double? value) => throw null;
    public void ToJavaScript(in double? value) => throw null;
    public void ToManaged(out double[]? value) => throw null;
    public void ToJavaScript(in double[]? value) => throw null;
    public void ToManaged(out IntPtr value) => throw null;
    public void ToJavaScript(in IntPtr value) => throw null;
    public void ToManaged(out IntPtr? value) => throw null;
    public void ToJavaScript(in IntPtr? value) => throw null;
    public void ToManaged(out DateTimeOffset value) => throw null;
    public void ToJavaScript(in DateTimeOffset value) => throw null;
    public void ToManaged(out DateTimeOffset? value) => throw null;
    public void ToJavaScript(in DateTimeOffset? value) => throw null;
    public void ToManaged(out DateTime value) => throw null;
    public void ToJavaScript(in DateTime value) => throw null;
    public void ToManaged(out DateTime? value) => throw null;
    public void ToJavaScript(in DateTime? value) => throw null;
    public void ToManaged(out string? value) => throw null;
    public void ToJavaScript(in string? value) => throw null;
    public void ToManaged(out string?[]? value) => throw null;
    public void ToJavaScript(in string?[]? value) => throw null;
    public void ToManaged(out Exception? value) => throw null;
    public void ToJavaScript(in Exception? value) => throw null;
    public void ToManaged(out object? value) => throw null;
    public void ToJavaScript(in object? value) => throw null;
    public void ToManaged(out object?[]? value) => throw null;
    public void ToJavaScript(in object?[]? value) => throw null;
    public void ToManaged(out IJSObject? value) => throw null;
    public void ToJavaScript(in IJSObject? value) => throw null;
    public void ToManaged(out IJSObject?[] value) => throw null;
    public void ToJavaScript(in IJSObject?[] value) => throw null;
    public void ToManaged(out System.Threading.Tasks.Task value) => throw null;
    public void ToJavaScript(in System.Threading.Tasks.Task value) => throw null;
    public void ToManaged<T>(out System.Threading.Tasks.Task<T> value, ArgumentToManagedCallback<T> marshaler) => throw null;
    public void ToJavaScript<T>(in System.Threading.Tasks.Task<T> value, ArgumentToJavaScriptCallback<T> marshaler) => throw null;
    public void ToManaged(out Action? value) => throw null;
    public void ToJavaScript(in Action? value) => throw null;
    public void ToManaged<T>(out Action<T>? value, ArgumentToJavaScriptCallback<T> arg1Marshaler) => throw null;
    public void ToJavaScript<T>(in Action<T>? value, ArgumentToManagedCallback<T> arg1Marshaler) => throw null;
    public void ToManaged<T1, T2>(out Action<T1, T2>? value, ArgumentToJavaScriptCallback<T1> arg1Marshaler, ArgumentToJavaScriptCallback<T2> arg2Marshaler) => throw null;
    public void ToJavaScript<T1, T2>(in Action<T1, T2>? value, ArgumentToManagedCallback<T1> arg1Marshaler, ArgumentToManagedCallback<T2> arg2Marshaler) => throw null;
    public void ToManaged<T1, T2, T3>(out Action<T1, T2, T3>? value, ArgumentToJavaScriptCallback<T1> arg1Marshaler, ArgumentToJavaScriptCallback<T2> arg2Marshaler, ArgumentToJavaScriptCallback<T3> arg3Marshaler) => throw null;
    public void ToJavaScript<T1, T2, T3>(in Action<T1, T2, T3>? value, ArgumentToManagedCallback<T1> arg1Marshaler, ArgumentToManagedCallback<T2> arg2Marshaler, ArgumentToManagedCallback<T3> arg3Marshaler) => throw null;
    public void ToManaged<TResult>(out Func<TResult>? value, ArgumentToManagedCallback<TResult> resMarshaler) => throw null;
    public void ToJavaScript<TResult>(in Func<TResult>? value, ArgumentToJavaScriptCallback<TResult> resMarshaler) => throw null;
    public void ToManaged<T, TResult>(out Func<T, TResult>? value, ArgumentToJavaScriptCallback<T> arg1Marshaler, ArgumentToManagedCallback<TResult> resMarshaler) => throw null;
    public void ToJavaScript<T, TResult>(in Func<T, TResult>? value, ArgumentToManagedCallback<T> arg1Marshaler, ArgumentToJavaScriptCallback<TResult> resMarshaler) => throw null;
    public void ToManaged<T1, T2, TResult>(out Func<T1, T2, TResult>? value, ArgumentToJavaScriptCallback<T1> arg1Marshaler, ArgumentToJavaScriptCallback<T2> arg2Marshaler, ArgumentToManagedCallback<TResult> resMarshaler) => throw null;
    public void ToJavaScript<T1, T2, TResult>(in Func<T1, T2, TResult>? value, ArgumentToManagedCallback<T1> arg1Marshaler, ArgumentToManagedCallback<T2> arg2Marshaler, ArgumentToJavaScriptCallback<TResult> resMarshaler) => throw null;
    public void ToManaged<T1, T2, T3, TResult>(out Func<T1, T2, T3, TResult>? value, ArgumentToJavaScriptCallback<T1> arg1Marshaler, ArgumentToJavaScriptCallback<T2> arg2Marshaler, ArgumentToJavaScriptCallback<T3> arg3Marshaler, ArgumentToManagedCallback<TResult> resMarshaler) => throw null;
    public void ToJavaScript<T1, T2, T3, TResult>(in Func<T1, T2, T3, TResult>? value, ArgumentToManagedCallback<T1> arg1Marshaler, ArgumentToManagedCallback<T2> arg2Marshaler, ArgumentToManagedCallback<T3> arg3Marshaler, ArgumentToJavaScriptCallback<TResult> resMarshaler) => throw null;
    public unsafe void ToManaged(out void* value) => throw null;
    public unsafe void ToJavaScript(in void* value) => throw null;
    public void ToManaged(out Span<byte> value) => throw null;
    public void ToJavaScript(in Span<byte> value) => throw null;
    public void ToManaged(out ArraySegment<byte> value) => throw null;
    public void ToJavaScript(in ArraySegment<byte> value) => throw null;
    public void ToManaged(out Span<int> value) => throw null;
    public void ToJavaScript(in Span<int> value) => throw null;
    public void ToManaged(out Span<double> value) => throw null;
    public void ToJavaScript(in Span<double> value) => throw null;
    public void ToManaged(out ArraySegment<int> value) => throw null;
    public void ToJavaScript(in ArraySegment<int> value) => throw null;
    public void ToManaged(out ArraySegment<double> value) => throw null;
    public void ToJavaScript(in ArraySegment<double> value) => throw null;
    public void JavaScriptToNative<T>(ref T nativeMarshaler) where T : struct => throw null;
    public void NativeToJavaScript<T>(ref T nativeMarshaler) where T : struct => throw null;
}

API Usage

// trivial example, here we bind to well known console.log on the blobal JS namespace
[JSImport("console.log")]
// there is no return value marshaling, but exception would be marshaled
internal static partial void Log(
    // the generator enforces that all parameters have explicit `JSMarshalAs` annotation
    // this one will marshal C# string to JavaScript native string by value (with some optimizations)
    [JSMarshalAs(JSType.String)] string message);
// code simplified for brevity
[System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Interop.JavaScript.JSImportGenerator", "42.42.42.42")]
public static partial void Log(string message)
{
    if (__signature_Log_20494476 == null)
    {
        __signature_Log_20494476 = JavaScriptMarshalerSignature.BindJSFunction("console.log", 
            new JavaScriptMarshalerType[]{
                JavaScriptMarshalerType.Void, 
                JavaScriptMarshalerType.String});
    }

    System.Span<JavaScriptMarshalerArgument> __arguments_buffer = stackalloc JavaScriptMarshalerArgument[3];
    ref JavaScriptMarshalerArgument __arg_exception = ref __arguments_buffer[0];
    __arg_exception.Initialize();
    ref JavaScriptMarshalerArgument __arg_return = ref __arguments_buffer[1];
    __arg_return.Initialize();

    ref JavaScriptMarshalerArgument __message_native__js_arg = ref __arguments_buffer[2];

    __message_native__js_arg.ToJavaScript(in message);
    // this will also marshal exception
    JavaScriptMarshalerSignature.InvokeBoundJSFunction(__signature_Log_20494476, __arguments_buffer);
}

static volatile JavaScriptMarshalerSignature __signature_Log_20494476;
// this will be generated on the runtime for the JavaScript marshaling stub
function factory(closure) {
    //# sourceURL=https://mono-wasm.invalid/_bound_js_console_log
    const { signature, fn, marshal_exception_to_cs, converter2 } = closure;
    return function _bound_js_console_log(args) {
        try {
            const arg0 = converter2(args + 32, signature + 72); //  String
            // fn is reference to console.log here
            const js_result = fn(arg0);
            if (js_result !== undefined) throw new Error('Function console.log returned unexpected value, C# signature is void');
        } catch (ex) {
            marshal_exception_to_cs(args, ex);
        }
    }
}
// More examples, from the rewrite of the runtime's implementation of Http and WebSocket wrappers on WASM.
[JSImport("INTERNAL.http_wasm_get_response_header_names")]
[return: JSMarshalAs(JSType.Array, JSType.String)]
private static partial string[] _GetResponseHeaderNames(
    [JSMarshalAs(JSType.Object)] IJSObject fetchResponse);

[JSImport("INTERNAL.ws_wasm_send")]
[return: JSMarshalAs(JSType.Promise)]
public static partial Task? WebSocketSend(
    [JSMarshalAs(JSType.Object)] IJSObject webSocket,
    [JSMarshalAs(JSType.MemoryView)] ArraySegment<byte> buffer,
    [JSMarshalAs(JSType.Number)] int messageType,
    [JSMarshalAs(JSType.Boolean)] bool endOfMessage);

// this is how to marshal strongly typed function
[JSImport("INTERNAL.create_function")]
[return: JSMarshalAs(JSType.Function, JSType.Number, JSType.Number, JSType.Number)]
public static partial Func<double, double, double> CreateFunctionDoubleDoubleDouble(
    [JSMarshalAs(JSType.String)] string arg1Name, 
    [JSMarshalAs(JSType.String)] string arg2Name, 
    [JSMarshalAs(JSType.String)] string code);

// this is sample how to export managed method to be consumable by JS
// the JS side wrapper would be exported into JS global namespace as JavaScriptTestHelper.AwaitTaskOfObject
// all arguments are natural JS types for the caller
[JSExport("JavaScriptTestHelper.AwaitTaskOfObject")]
[return: JSMarshalAs(JSType.Promise, JSType.String)]
public static async Task<string> SlowFailure([JSMarshalAs(JSType.Promise, JSType.Number)] Task<int> promisedNumber)
{
    var delayMs = await promisedNumber;
    // this would be marshled as JS promise rejection
    if (promisedNumber<0) throw new ArgumentException("delayMs");

    await Task.Delay(delayMs);
    return "Slow hello";
}

Alternative Designs

  • We have existing private interop. It has few design flaws, the worst of them is that it gives to JS code naked pointers to managed objects. They could move on GC making it fragile.
  • We could do full dynamic marshaling on runtime, but it would need lot of reflection and it's not trimming friendly

Risks

Open questions:

  • we consider that maybe we could marshal more dynamic combinations of parameters in the future. JavaScript is dynamic language after all. We made JSType as flags to prepare for it as it would be difficult to change in the future.
  • Should we have GetProperty and SetProperty as extension method rather than directly on the IJSObject interface ? There are many more things you could call on JS object proxy. We may also add more marker interfaces in the future, IJSArray comes to mind.
  • The JavaScriptMarshalerArgument has marshalers on it. For primitive types we do both nullable and non-nullable alternative. In JS world everything is nullable. Shall we enforce nullability constraint on runtime ?
  • We made JavaScriptMarshalerArgument.ToManaged(out Task value) non-nullable, but in fact you can pass null Promise. Reason: forcing user to check null before calling await felt akward. Passing null promise is useful on synchronous returns from JS.

TODO:

  • the quality of the generator in the prototype is low. It doesn't handle all negative scenarios and the diagnostic messages are just sketch.
  • We validated the design from perf perspective with the team, but we have to measure it yet.
  • Same for memory leaks, there are 2 GC's involved.
Author: pavelsavara
Assignees: pavelsavara
Labels:

api-suggestion, area-System.Runtime.InteropServices, untriaged

Milestone: -

@pavelsavara pavelsavara added this to the 7.0.0 milestone Jun 2, 2022
@ghost ghost removed the untriaged New issue has not been triaged by the area owner label Jun 2, 2022
@pavelsavara
Copy link
Member Author

@pavelsavara pavelsavara added the arch-wasm WebAssembly architecture label Jun 2, 2022
@ghost
Copy link

ghost commented Jun 2, 2022

Tagging subscribers to 'arch-wasm': @lewing
See info in area-owners.md if you want to be subscribed.

Issue Details

Background and motivation

When .NET is running on WebAssembly, for example as part of Blazor, developers may want to interact with the browser's JavaScript engine and native components. Currently we don't have public C# API to do so.

We propose this API together with prototype of the implementation.
Key features are:

  • generate C# side of the marshaling stub in as partial method, Roslyn analyzer triggered by JSImportAttribute or JSExportAttribute. We re-use common code gen infrastructure from [LibraryImport]
  • Allow different marshalers for the same managed type, for example Int64 could be marshaled as JSType.BigInt or as JSType.Number, configurable per parameter via JSMarshalAsAttribute similar to MarshalAsAttribute or P/Invoke
  • the JavaScript doesn't have natural concept of memory, instead useful marshaling needs to create JS native types. Wasm native code could not create them, and so we have to always have marshaling code also on the JS side.
  • generate JS side of the marshaling on runtime, do decrease download size. Provide necessary metadata during method binding.
  • marshaled types are:
    • subset of primitive numeric types and their nullable alternative
    • String, Boolean, DateTime, DateTimeOffset, Exception
    • dynamic marshaling of System.Object with mapping to well known types for some instance types and proxy via GCHandle for the rest.
    • IJSObject with private legacy implementation JSObject, which is proxy via existing JSHandle concept similar to GCHandle
    • Task, Func, Action
    • byte[], int[], double[]
    • Span<byte>, Span<int>, Span<double> and ArraySegment<byte>, ArraySegment<int>, ArraySegment<double>
    • Custom marshaler to and from managed and JS objects of any shape
    • Custom P/Invoke marshaler with [MarshalUsing(typeof(NativeMarshaler))]
  • we have 2 garbage collectors to worry about
  • we do have existing private interop in System.Private.Runtime.InteropServices.JavaScript assembly and also semi-private JavaScript embedding API. These are used by Blazor and other partners and this proposal could help to phase it out gradually.

There more implementation details described on the prototype PR

API Proposal

Below are types which drive the code generator

namespace System.Runtime.InteropServices.JavaScript;


// these are the attributes which trigger code-gen
[System.AttributeUsageAttribute(System.AttributeTargets.Method, Inherited = false, AllowMultiple = false)]
[Versioning.SupportedOSPlatform("browser")]
public sealed class JSImportAttribute : System.Attribute
{
    public string FunctionName { get; }
    public JSImportAttribute(string functionName) => throw null;
}
[System.AttributeUsageAttribute(System.AttributeTargets.Method, Inherited = false, AllowMultiple = false)]
[Versioning.SupportedOSPlatform("browser")]
public sealed class JSExportAttribute : System.Attribute
{
    public string FunctionName { get; }
    public JSExportAttribute() => throw null;
    public JSExportAttribute(string functionName) => throw null;
}

// this is used to annotate the marshaled parameters
[System.AttributeUsageAttribute(System.AttributeTargets.Parameter | System.AttributeTargets.ReturnValue, Inherited = false, AllowMultiple = false)]
[Versioning.SupportedOSPlatform("browser")]
public sealed class JSMarshalAsAttribute : System.Attribute
{
    public JSType Type { get { throw null; } }
    public JSType[] TypeArguments { get; }
    public System.Type? CustomMarshaler { get { throw null; } }
    public JSMarshalAsAttribute(JSType type) => throw null;
    public JSMarshalAsAttribute(JSType type, JSType typeArgument1) => throw null;
    public JSMarshalAsAttribute(JSType type, JSType typeArgument1, JSType typeArgument2) => throw null;
    public JSMarshalAsAttribute(JSType type, JSType typeArgument1, JSType typeArgument2, JSType typeArgument3) => throw null;
    public JSMarshalAsAttribute(JSType type, JSType typeArgument1, JSType typeArgument2, JSType typeArgument3, JSType typeArgument4) => throw null;
    public JSMarshalAsAttribute(JSType type, System.Type customMarshaler) => throw null;
}
[Versioning.SupportedOSPlatform("browser")]
[System.Flags]
public enum JSType : int
{
    None = 0x0,
    Void = 0x1,
    Boolean = 0x2,
    Number = 0x4, // max 52 integral bits
    BigInt = 0x8,
    Date = 0x10,
    String = 0x20,
    Function = 0x40,
    Array = 0x80,
    Object = 0x100,
    Promise = 0x200,
    Error = 0x400,
    MemoryView = 0x800,
    Custom = 0x1000,
    Any = 0x2000,
}

Below are types which drive the code generator for custom marshler

namespace System.Runtime.InteropServices.JavaScript;
// when you use [JSMarshalAs(JSType.Custom, typeof(YourMarshaler))] you mark YourMarshaler with [JSCustomMarshaller]
[System.AttributeUsageAttribute(System.AttributeTargets.Struct, Inherited = false, AllowMultiple = false)]
[Versioning.SupportedOSPlatform("browser")]
public sealed class JSCustomMarshallerAttribute : System.Attribute
{
    public System.Type ManagedType { get { throw null; } }
    public JSCustomMarshallerAttribute(System.Type managedType) => throw null;
}
// IJSCustomMarshaller and IJSCustomMarshaller<T> helps the developer to stick to proper shape
[Versioning.SupportedOSPlatform("browser")]
public interface IJSCustomMarshaller
{
    string JavaScriptCode { get; }
}
[Versioning.SupportedOSPlatform("browser")]
[CLSCompliant(false)]
public interface IJSCustomMarshaller<T> : IJSCustomMarshaller
{
    void ToManaged(in JavaScriptMarshalerArgument arg, out T value);
    void ToJavaScript(ref JavaScriptMarshalerArgument arg, in T value);
}

Below are types for working with JavaScript instances

namespace System.Runtime.InteropServices.JavaScript;

// IJSObject is public face of the internal legacy JSObject, it represents the proxy of JavaScript object instance
[Versioning.SupportedOSPlatform("browser")]
public interface IJSObject : IDisposable
{
    public bool IsDisposed { get; }
}
// when we marshal JS Error type
[Versioning.SupportedOSPlatform("browser")]
public sealed class JSException : Exception
{
    public JSException(string msg) => throw null;
}
// delegates need to be marshaled as strongly typed and  we need to generate code for marshaling the parameters on the actual call
// below is guess on parameter types combinations which would be useful on creating JS function from string of the JS code
// see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Function/Function
// user can create same factory themselves (up to 3 generic type arguments for now), this is just convinience
[Versioning.SupportedOSPlatform("browser")]
public static class JSFunction
{
    public static void New(string code, out Action function) => throw null;
    public static void New(string code, out Func<bool> function) => throw null;
    public static void New(string code, out Func<int> function) => throw null;
    public static void New(string code, out Func<long> function) => throw null;
    public static void New(string code, out Func<double> function) => throw null;
    public static void New(string code, out Func<string> function) => throw null;
    public static void New(string code, out Func<IJSObject> function) => throw null;
    public static void New(string arg1Name, string code, out Action<bool> function) => throw null;
    public static void New(string arg1Name, string code, out Action<int> function) => throw null;
    public static void New(string arg1Name, string code, out Action<long> function) => throw null;
    public static void New(string arg1Name, string code, out Action<double> function) => throw null;
    public static void New(string arg1Name, string code, out Action<string> function) => throw null;
    public static void New(string arg1Name, string code, out Action<IJSObject> function) => throw null;
    public static void New(string arg1Name, string code, out Func<bool, bool> function) => throw null;
    public static void New(string arg1Name, string code, out Func<int, int> function) => throw null;
    public static void New(string arg1Name, string code, out Func<long, long> function) => throw null;
    public static void New(string arg1Name, string code, out Func<double, double> function) => throw null;
    public static void New(string arg1Name, string code, out Func<string, string> function) => throw null;
    public static void New(string arg1Name, string code, out Func<IJSObject, IJSObject> function) => throw null;
    public static void New(string arg1Name, string code, out Func<bool, IJSObject> function) => throw null;
    public static void New(string arg1Name, string code, out Func<int, IJSObject> function) => throw null;
    public static void New(string arg1Name, string code, out Func<long, IJSObject> function) => throw null;
    public static void New(string arg1Name, string code, out Func<string, IJSObject> function) => throw null;
    public static void New(string arg1Name, string code, out Func<double, IJSObject> function) => throw null;
    public static void New(string arg1Name, string arg2Name, string code, out Action<int, int> function) => throw null;
    public static void New(string arg1Name, string arg2Name, string code, out Action<long, long> function) => throw null;
    public static void New(string arg1Name, string arg2Name, string code, out Action<double, double> function) => throw null;
    public static void New(string arg1Name, string arg2Name, string code, out Action<string, string> function) => throw null;
    public static void New(string arg1Name, string arg2Name, string code, out Func<int, int, int> function) => throw null;
    public static void New(string arg1Name, string arg2Name, string code, out Func<long, long, long> function) => throw null;
    public static void New(string arg1Name, string arg2Name, string code, out Func<double, double, double> function) => throw null;
    public static void New(string arg1Name, string arg2Name, string code, out Func<string, string, string> function) => throw null;
}
// there are many things that you can call on JavaScript object
// here are few handy helpers, user will be able to create more using [JSImport]
[Versioning.SupportedOSPlatform("browser")]
public static class JavaScriptExtensions
{
    public static void GetProperty(this IJSObject self, string propertyName, out bool? value) => throw null;
    public static void SetProperty(this IJSObject self, string propertyName, bool? value) => throw null;
    public static void GetProperty(this IJSObject self, string propertyName, out int? value) => throw null;
    public static void SetProperty(this IJSObject self, string propertyName, int? value) => throw null;
    public static void GetProperty(this IJSObject self, string propertyName, out long? value) => throw null;
    public static void SetProperty(this IJSObject self, string propertyName, long? value) => throw null;
    public static void GetProperty(this IJSObject self, string propertyName, out double? value) => throw null;
    public static void SetProperty(this IJSObject self, string propertyName, double? value) => throw null;
    public static void GetProperty(this IJSObject self, string propertyName, out string? value) => throw null;
    public static void SetProperty(this IJSObject self, string propertyName, string? value) => throw null;
    public static void GetProperty(this IJSObject self, string propertyName, out IJSObject? value) => throw null;
    public static void SetProperty(this IJSObject self, string propertyName, IJSObject? value) => throw null;
}

Below types are used by the generated code

namespace System.Runtime.InteropServices.JavaScript;

// to bind and call methods
[Versioning.SupportedOSPlatform("browser")]
[CLSCompliant(false)]
public sealed class JavaScriptMarshalerSignature
{
    public static void InvokeBoundJSFunction(JavaScriptMarshalerSignature signature, Span<JavaScriptMarshalerArgument> arguments) => throw null;
    public static JavaScriptMarshalerSignature BindJSFunction(string functionName, JavaScriptMarshalerType[] signatures) => throw null;
    public static JavaScriptMarshalerSignature BindCSFunction(string fullyQualifiedName, int signatureHash, string? exportAsName, JavaScriptMarshalerType[] signatures) => throw null;
}
// to create binding metadata
[Versioning.SupportedOSPlatform("browser")]
[StructLayout(LayoutKind.Sequential, Pack = 4, Size = 32)]
public struct JavaScriptMarshalerType
{
    public static JavaScriptMarshalerType Void { get => throw null; }
    public static JavaScriptMarshalerType Boolean { get => throw null; }
    public static JavaScriptMarshalerType Byte { get => throw null; }
    public static JavaScriptMarshalerType Char { get => throw null; }
    public static JavaScriptMarshalerType Int16 { get => throw null; }
    public static JavaScriptMarshalerType Int32 { get => throw null; }
    public static JavaScriptMarshalerType Int52 { get => throw null; }
    public static JavaScriptMarshalerType BigInt64 { get => throw null; }
    public static JavaScriptMarshalerType Double { get => throw null; }
    public static JavaScriptMarshalerType Single { get => throw null; }
    public static JavaScriptMarshalerType IntPtr { get => throw null; }
    public static JavaScriptMarshalerType JSObject { get => throw null; }
    public static JavaScriptMarshalerType Object { get => throw null; }
    public static JavaScriptMarshalerType String { get => throw null; }
    public static JavaScriptMarshalerType Exception { get => throw null; }
    public static JavaScriptMarshalerType DateTime { get => throw null; }
    public static JavaScriptMarshalerType DateTimeOffset { get => throw null; }
    public static JavaScriptMarshalerType Nullable(JavaScriptMarshalerType primitive) => throw null;
    public static JavaScriptMarshalerType Task() => throw null;
    public static JavaScriptMarshalerType Task(JavaScriptMarshalerType result) => throw null;
    public static JavaScriptMarshalerType Array(JavaScriptMarshalerType element) => throw null;
    public static JavaScriptMarshalerType ArraySegment(JavaScriptMarshalerType element) => throw null;
    public static JavaScriptMarshalerType Span(JavaScriptMarshalerType element) => throw null;
    public static JavaScriptMarshalerType Action() => throw null;
    public static JavaScriptMarshalerType Action(JavaScriptMarshalerType arg1) => throw null;
    public static JavaScriptMarshalerType Action(JavaScriptMarshalerType arg1, JavaScriptMarshalerType arg2) => throw null;
    public static JavaScriptMarshalerType Action(JavaScriptMarshalerType arg1, JavaScriptMarshalerType arg2, JavaScriptMarshalerType arg3) => throw null;
    public static JavaScriptMarshalerType Function(JavaScriptMarshalerType result) => throw null;
    public static JavaScriptMarshalerType Function(JavaScriptMarshalerType arg1, JavaScriptMarshalerType result) => throw null;
    public static JavaScriptMarshalerType Function(JavaScriptMarshalerType arg1, JavaScriptMarshalerType arg2, JavaScriptMarshalerType result) => throw null;
    public static JavaScriptMarshalerType Function(JavaScriptMarshalerType arg1, JavaScriptMarshalerType arg2, JavaScriptMarshalerType arg3, JavaScriptMarshalerType result) => throw null;
    public static JavaScriptMarshalerType Custom<TMarshaled, TMarshaler>() where TMarshaler : struct => throw null;
    public static JavaScriptMarshalerType NativeMarshalling<TMarshaled, TMarshaler>() where TMarshaler : struct => throw null;
}
// actual marshalers
[Versioning.SupportedOSPlatform("browser")]
[StructLayout(LayoutKind.Explicit, Pack = 16, Size = 16)]
[CLSCompliant(false)]
public struct JavaScriptMarshalerArgument
{
    public delegate void ArgumentToManagedCallback<T>(ref JavaScriptMarshalerArgument arg, out T value);
    public delegate void ArgumentToJavaScriptCallback<T>(ref JavaScriptMarshalerArgument arg, in T value);
    public void Initialize() => throw null;
    public void ToManaged(out bool value) => throw null;
    public void ToJavaScript(in bool value) => throw null;
    public void ToManaged(out bool? value) => throw null;
    public void ToJavaScript(in bool? value) => throw null;
    public void ToManaged(out byte value) => throw null;
    public void ToJavaScript(in byte value) => throw null;
    public void ToManaged(out byte? value) => throw null;
    public void ToJavaScript(in byte? value) => throw null;
    public void ToManaged(out byte[]? value) => throw null;
    public void ToJavaScript(in byte[]? value) => throw null;
    public void ToManaged(out char value) => throw null;
    public void ToJavaScript(in char value) => throw null;
    public void ToManaged(out char? value) => throw null;
    public void ToJavaScript(in char? value) => throw null;
    public void ToManaged(out short value) => throw null;
    public void ToJavaScript(in short value) => throw null;
    public void ToManaged(out short? value) => throw null;
    public void ToJavaScript(in short? value) => throw null;
    public void ToManaged(out int value) => throw null;
    public void ToJavaScript(in int value) => throw null;
    public void ToManaged(out int? value) => throw null;
    public void ToJavaScript(in int? value) => throw null;
    public void ToManaged(out int[]? value) => throw null;
    public void ToJavaScript(in int[]? value) => throw null;
    public void ToManaged(out long value) => throw null;
    public void ToJavaScript(in long value) => throw null;
    public void ToManaged(out long? value) => throw null;
    public void ToJavaScript(in long? value) => throw null;
    public void ToManagedBig(out long value) => throw null;
    public void ToJavaScriptBig(in long value) => throw null;
    public void ToManagedBig(out long? value) => throw null;
    public void ToJavaScriptBig(in long? value) => throw null;
    public void ToManaged(out float value) => throw null;
    public void ToJavaScript(in float value) => throw null;
    public void ToManaged(out float? value) => throw null;
    public void ToJavaScript(in float? value) => throw null;
    public void ToManaged(out double value) => throw null;
    public void ToJavaScript(in double value) => throw null;
    public void ToManaged(out double? value) => throw null;
    public void ToJavaScript(in double? value) => throw null;
    public void ToManaged(out double[]? value) => throw null;
    public void ToJavaScript(in double[]? value) => throw null;
    public void ToManaged(out IntPtr value) => throw null;
    public void ToJavaScript(in IntPtr value) => throw null;
    public void ToManaged(out IntPtr? value) => throw null;
    public void ToJavaScript(in IntPtr? value) => throw null;
    public void ToManaged(out DateTimeOffset value) => throw null;
    public void ToJavaScript(in DateTimeOffset value) => throw null;
    public void ToManaged(out DateTimeOffset? value) => throw null;
    public void ToJavaScript(in DateTimeOffset? value) => throw null;
    public void ToManaged(out DateTime value) => throw null;
    public void ToJavaScript(in DateTime value) => throw null;
    public void ToManaged(out DateTime? value) => throw null;
    public void ToJavaScript(in DateTime? value) => throw null;
    public void ToManaged(out string? value) => throw null;
    public void ToJavaScript(in string? value) => throw null;
    public void ToManaged(out string?[]? value) => throw null;
    public void ToJavaScript(in string?[]? value) => throw null;
    public void ToManaged(out Exception? value) => throw null;
    public void ToJavaScript(in Exception? value) => throw null;
    public void ToManaged(out object? value) => throw null;
    public void ToJavaScript(in object? value) => throw null;
    public void ToManaged(out object?[]? value) => throw null;
    public void ToJavaScript(in object?[]? value) => throw null;
    public void ToManaged(out IJSObject? value) => throw null;
    public void ToJavaScript(in IJSObject? value) => throw null;
    public void ToManaged(out IJSObject?[] value) => throw null;
    public void ToJavaScript(in IJSObject?[] value) => throw null;
    public void ToManaged(out System.Threading.Tasks.Task value) => throw null;
    public void ToJavaScript(in System.Threading.Tasks.Task value) => throw null;
    public void ToManaged<T>(out System.Threading.Tasks.Task<T> value, ArgumentToManagedCallback<T> marshaler) => throw null;
    public void ToJavaScript<T>(in System.Threading.Tasks.Task<T> value, ArgumentToJavaScriptCallback<T> marshaler) => throw null;
    public void ToManaged(out Action? value) => throw null;
    public void ToJavaScript(in Action? value) => throw null;
    public void ToManaged<T>(out Action<T>? value, ArgumentToJavaScriptCallback<T> arg1Marshaler) => throw null;
    public void ToJavaScript<T>(in Action<T>? value, ArgumentToManagedCallback<T> arg1Marshaler) => throw null;
    public void ToManaged<T1, T2>(out Action<T1, T2>? value, ArgumentToJavaScriptCallback<T1> arg1Marshaler, ArgumentToJavaScriptCallback<T2> arg2Marshaler) => throw null;
    public void ToJavaScript<T1, T2>(in Action<T1, T2>? value, ArgumentToManagedCallback<T1> arg1Marshaler, ArgumentToManagedCallback<T2> arg2Marshaler) => throw null;
    public void ToManaged<T1, T2, T3>(out Action<T1, T2, T3>? value, ArgumentToJavaScriptCallback<T1> arg1Marshaler, ArgumentToJavaScriptCallback<T2> arg2Marshaler, ArgumentToJavaScriptCallback<T3> arg3Marshaler) => throw null;
    public void ToJavaScript<T1, T2, T3>(in Action<T1, T2, T3>? value, ArgumentToManagedCallback<T1> arg1Marshaler, ArgumentToManagedCallback<T2> arg2Marshaler, ArgumentToManagedCallback<T3> arg3Marshaler) => throw null;
    public void ToManaged<TResult>(out Func<TResult>? value, ArgumentToManagedCallback<TResult> resMarshaler) => throw null;
    public void ToJavaScript<TResult>(in Func<TResult>? value, ArgumentToJavaScriptCallback<TResult> resMarshaler) => throw null;
    public void ToManaged<T, TResult>(out Func<T, TResult>? value, ArgumentToJavaScriptCallback<T> arg1Marshaler, ArgumentToManagedCallback<TResult> resMarshaler) => throw null;
    public void ToJavaScript<T, TResult>(in Func<T, TResult>? value, ArgumentToManagedCallback<T> arg1Marshaler, ArgumentToJavaScriptCallback<TResult> resMarshaler) => throw null;
    public void ToManaged<T1, T2, TResult>(out Func<T1, T2, TResult>? value, ArgumentToJavaScriptCallback<T1> arg1Marshaler, ArgumentToJavaScriptCallback<T2> arg2Marshaler, ArgumentToManagedCallback<TResult> resMarshaler) => throw null;
    public void ToJavaScript<T1, T2, TResult>(in Func<T1, T2, TResult>? value, ArgumentToManagedCallback<T1> arg1Marshaler, ArgumentToManagedCallback<T2> arg2Marshaler, ArgumentToJavaScriptCallback<TResult> resMarshaler) => throw null;
    public void ToManaged<T1, T2, T3, TResult>(out Func<T1, T2, T3, TResult>? value, ArgumentToJavaScriptCallback<T1> arg1Marshaler, ArgumentToJavaScriptCallback<T2> arg2Marshaler, ArgumentToJavaScriptCallback<T3> arg3Marshaler, ArgumentToManagedCallback<TResult> resMarshaler) => throw null;
    public void ToJavaScript<T1, T2, T3, TResult>(in Func<T1, T2, T3, TResult>? value, ArgumentToManagedCallback<T1> arg1Marshaler, ArgumentToManagedCallback<T2> arg2Marshaler, ArgumentToManagedCallback<T3> arg3Marshaler, ArgumentToJavaScriptCallback<TResult> resMarshaler) => throw null;
    public unsafe void ToManaged(out void* value) => throw null;
    public unsafe void ToJavaScript(in void* value) => throw null;
    public void ToManaged(out Span<byte> value) => throw null;
    public void ToJavaScript(in Span<byte> value) => throw null;
    public void ToManaged(out ArraySegment<byte> value) => throw null;
    public void ToJavaScript(in ArraySegment<byte> value) => throw null;
    public void ToManaged(out Span<int> value) => throw null;
    public void ToJavaScript(in Span<int> value) => throw null;
    public void ToManaged(out Span<double> value) => throw null;
    public void ToJavaScript(in Span<double> value) => throw null;
    public void ToManaged(out ArraySegment<int> value) => throw null;
    public void ToJavaScript(in ArraySegment<int> value) => throw null;
    public void ToManaged(out ArraySegment<double> value) => throw null;
    public void ToJavaScript(in ArraySegment<double> value) => throw null;
    public void JavaScriptToNative<T>(ref T nativeMarshaler) where T : struct => throw null;
    public void NativeToJavaScript<T>(ref T nativeMarshaler) where T : struct => throw null;
}

API Usage

Trivial example

// here we bind to well known console.log on the blobal JS namespace
[JSImport("console.log")]
// there is no return value marshaling, but exception would be marshaled
internal static partial void Log(
    // the generator enforces that all parameters have explicit `JSMarshalAs` annotation
    // this one will marshal C# string to JavaScript native string by value (with some optimizations)
    [JSMarshalAs(JSType.String)] string message);

This is code generated by Roslyn, simplified for brevity

[System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Interop.JavaScript.JSImportGenerator", "42.42.42.42")]
public static partial void Log(string message)
{
    if (__signature_Log_20494476 == null)
    {
        __signature_Log_20494476 = JavaScriptMarshalerSignature.BindJSFunction("console.log", 
            new JavaScriptMarshalerType[]{
                JavaScriptMarshalerType.Void, 
                JavaScriptMarshalerType.String});
    }

    System.Span<JavaScriptMarshalerArgument> __arguments_buffer = stackalloc JavaScriptMarshalerArgument[3];
    ref JavaScriptMarshalerArgument __arg_exception = ref __arguments_buffer[0];
    __arg_exception.Initialize();
    ref JavaScriptMarshalerArgument __arg_return = ref __arguments_buffer[1];
    __arg_return.Initialize();

    ref JavaScriptMarshalerArgument __message_native__js_arg = ref __arguments_buffer[2];

    __message_native__js_arg.ToJavaScript(in message);
    // this will also marshal exception
    JavaScriptMarshalerSignature.InvokeBoundJSFunction(__signature_Log_20494476, __arguments_buffer);
}

static volatile JavaScriptMarshalerSignature __signature_Log_20494476;

This will be generated on the runtime for the JavaScript marshaling stub

function factory(closure) {
    //# sourceURL=https://mono-wasm.invalid/_bound_js_console_log
    const { signature, fn, marshal_exception_to_cs, converter2 } = closure;
    return function _bound_js_console_log(args) {
        try {
            const arg0 = converter2(args + 32, signature + 72); //  String
            // fn is reference to console.log here
            const js_result = fn(arg0);
            if (js_result !== undefined) throw new Error('Function console.log returned unexpected value, C# signature is void');
        } catch (ex) {
            marshal_exception_to_cs(args, ex);
        }
    }
}

More examples

// More examples, from the rewrite of the runtime's implementation of Http and WebSocket wrappers on WASM.
[JSImport("INTERNAL.http_wasm_get_response_header_names")]
[return: JSMarshalAs(JSType.Array, JSType.String)]
private static partial string[] _GetResponseHeaderNames(
    [JSMarshalAs(JSType.Object)] IJSObject fetchResponse);

[JSImport("INTERNAL.ws_wasm_send")]
[return: JSMarshalAs(JSType.Promise)]
public static partial Task? WebSocketSend(
    [JSMarshalAs(JSType.Object)] IJSObject webSocket,
    [JSMarshalAs(JSType.MemoryView)] ArraySegment<byte> buffer,
    [JSMarshalAs(JSType.Number)] int messageType,
    [JSMarshalAs(JSType.Boolean)] bool endOfMessage);

// this is how to marshal strongly typed function
[JSImport("INTERNAL.create_function")]
[return: JSMarshalAs(JSType.Function, JSType.Number, JSType.Number, JSType.Number)]
public static partial Func<double, double, double> CreateFunctionDoubleDoubleDouble(
    [JSMarshalAs(JSType.String)] string arg1Name, 
    [JSMarshalAs(JSType.String)] string arg2Name, 
    [JSMarshalAs(JSType.String)] string code);

// this is sample how to export managed method to be consumable by JS
// the JS side wrapper would be exported into JS global namespace as JavaScriptTestHelper.AwaitTaskOfObject
// all arguments are natural JS types for the caller
[JSExport("JavaScriptTestHelper.AwaitTaskOfObject")]
[return: JSMarshalAs(JSType.Promise, JSType.String)]
public static async Task<string> SlowFailure([JSMarshalAs(JSType.Promise, JSType.Number)] Task<int> promisedNumber)
{
    var delayMs = await promisedNumber;
    // this would be marshled as JS promise rejection
    if (promisedNumber<0) throw new ArgumentException("delayMs");

    await Task.Delay(delayMs);
    return "Slow hello";
}

Alternative Designs

  • We have existing private interop. It has few design flaws, the worst of them is that it gives to JS code naked pointers to managed objects. They could move on GC making it fragile.
  • We could do full dynamic marshaling on runtime, but it would need lot of reflection and it's not trimming friendly

Risks

Open questions:

  • we consider that maybe we could marshal more dynamic combinations of parameters in the future. JavaScript is dynamic language after all. We made JSType as flags to prepare for it as it would be difficult to change in the future.
  • Should we have GetProperty and SetProperty as extension method rather than directly on the IJSObject interface ? There are many more things you could call on JS object proxy. We may also add more marker interfaces in the future, IJSArray comes to mind.
  • The JavaScriptMarshalerArgument has marshalers on it. For primitive types we do both nullable and non-nullable alternative. In JS world everything is nullable. Shall we enforce nullability constraint on runtime ?
  • We made JavaScriptMarshalerArgument.ToManaged(out Task value) non-nullable, but in fact you can pass null Promise. Reason: forcing user to check null before calling await felt akward. Passing null promise is useful on synchronous returns from JS.

TODO:

  • the quality of the generator in the prototype is low. It doesn't handle all negative scenarios and the diagnostic messages are just sketch.
  • We validated the design from perf perspective with the team, but we have to measure it yet.
  • Same for memory leaks, there are 2 GC's involved.
Author: pavelsavara
Assignees: pavelsavara
Labels:

api-suggestion, arch-wasm, area-System.Runtime.InteropServices

Milestone: 7.0.0

@pavelsavara
Copy link
Member Author

Updated with names unified to short JS instead of JavaScript as part of various names. Our legacy JSObject sets the precedent. Thanks @maraf

@jeromelaban
Copy link
Contributor

jeromelaban commented Jun 2, 2022

Very nice work!

There's a typo here:

namespace System.Runtime.InteropServices.Jav4aScript;

I'd avoid using generic delegates here:

public static void New(string code, out Func<bool> function) => throw null;

As the generic invocation and creation performance is not particularly good at this time when using AOT, at least with net6. Would using interfaces make more sense (one per type)?

@jkoritzinsky

This comment was marked as outdated.

@lambdageek
Copy link
Member

lambdageek commented Jun 2, 2022

What if JSMarshalAs used a Type to specify the JS type. something like:

// this is used to annotate the marshaled parameters
[System.AttributeUsageAttribute(System.AttributeTargets.Parameter | System.AttributeTargets.ReturnValue, Inherited = false, AllowMultiple = false)]
[Versioning.SupportedOSPlatform("browser")]
public sealed class JSMarshalAsAttribute : System.Attribute
{
    public System.Type Type { get { throw null; } }
    public System.Type? CustomMarshaler { get { throw null; } }
    /// pass typeof(JSType.XYZ) for the type argument to specify the shape of the JS value 
    public JSMarshalAsAttribute(System.Type type) => throw null;
    public JSMarshalAsAttribute(System.Type type, System.Type customMarshaler) => throw null;
}
[Versioning.SupportedOSPlatform("browser")]
public static sealed class JSType
{
    public sealed interface None {}
    public sealed interface Void {}
    public sealed interface Boolean {}
    public sealed interface Number {}
    ...
    public sealed interface Function<T> {}
    public sealed interface Function<T1, T2> {}
    public sealed interface Array<T> {}
    public sealed interface Object {}
    public sealed interface Promise<T> {}
    ...
}

and then you could write [JSMarshalAs(typeof(JSType.Promise<JSType.Number>))] and [JSMarshalAs(typeof(JSType.Function<JSType.Object>))] etc

@lambdageek
Copy link
Member

@pavelsavara for the JSMarshalerType I wonder if we can avoid exposing the struct as the implementation of how signatures are represented. Ultimately that's an implementation detail of the marshaler that we might want to change in the future. What if we instead make JSMarshalerType a builder that just populates some Span<byte> with the serialized signature that is passed to BindJSFunction(string functionName, Span<byte> signatures)

Something like this:

using System;

public struct JSMarshalerType {
    public static JSMarshalerType Create() { throw null; }
    public int Size {get; }

    public void WriteTo (Span<byte> dest) { }
}

public static class ExtensionMethods {
    public static ref JSMarshalerType BeginGroup(this ref JSMarshalerType t) { return ref t; }
    public static ref JSMarshalerType EndGroup(this ref JSMarshalerType t) { return ref t; }
    
    // primitive types
    public static ref JSMarshalerType Void (this ref JSMarshalerType t) { return ref t; }
    public static ref JSMarshalerType Boolean (this ref JSMarshalerType t) { return ref t; }
    public static ref JSMarshalerType Double (this ref JSMarshalerType t) { return ref t; }
    public static ref JSMarshalerType Int32 (this ref JSMarshalerType t) { return ref t; }

    // the "generics" must be first in a group followed by the type arguments and an EndGroup
    public static ref JSMarshalerType Nullable(this ref JSMarshalerType t) { return ref t; }
    public static ref JSMarshalerType Task(this ref JSMarshalerType t) { return ref t; }
}

So then to serialize a signature for something like Task<bool> MyFunc (Nullable<int> x, double y) the generated code would do:

    ...
    var builder = JSMarshalerType.Create ();
    builder.BeginGroup().Task().Boolean().EndGroup(); // return type is Task<bool>
    builder.BeginGroup().Nullable().Int32().EndGroup(); // first arg is. Nullable<Int32>
    builder.Double();  // second arg is double

    Span<byte> signature = stackalloc byte[builder.Size];
    builder.WriteTo(signature);
    ...
    JSFunctionSignature function = JSFunctionSignature.BindJSFunction(functionName, signature);
    ...

I tried to make JSMarshalerType a struct but maybe that's not necessary (and I guess the ref assembly would leak out some of its implementation details anyway) so maybe it can just be a class and we pay for the builder allocation.

@SteveSandersonMS
Copy link
Member

SteveSandersonMS commented Jun 3, 2022

Minor API style comment: for all the out param APIs like this:

public static void GetProperty(this IJSObject self, string propertyName, out long? value) => throw null;

I'm guessing the out (and not a return type) is just to avoid the use of generics (like GetProperty<long>(...)). If so, there's prior art in the JSON serializer libs to achieve this disambiguation using different method names instead of out, e.g.:

var result = jsObject.GetLongProperty("propName");

... which is a bit more flexible in terms of using this expression inside a larger expression.

@SteveSandersonMS
Copy link
Member

Another area to consider is what happens for class libraries that sometimes run in browser and sometimes run in non-browser environments. It should be possible to compile without targeting browser and then at runtime do something like:

if (RuntimeInformation.IsOSPlatform("browser"))
{
    SomeJSImportedMethod(...);
}
else
{
    await jsRuntime.InvokeAsync("mymethod", ...);
}

@SteveSandersonMS
Copy link
Member

    // the generator enforces that all parameters have explicit `JSMarshalAs` annotation
    // this one will marshal C# string to JavaScript native string by value (with some optimizations)
    [JSMarshalAs(JSType.String)] string message

It's probably OK because this is so low-level, but in the specific case of strings, I'd expect 99% of the time people would want the obvious mapping of .NET string to JS string, and vice-versa, so it feels like the annotation could be omitted. Same with all numeric types in the .NET-to-JS direction, as there's only one common JS-side numeric type.

I know there are edge cases, like maybe marshalling a .NET string to a JS-side Uint8Array containing UTF8 or UTF16 or something, but this would be pretty uncommon. It would be ideal if that could be specified optionally.

This is a minor detail and could be done as a future enhancement if there is demand for it.

@SteveSandersonMS
Copy link
Member

        if (js_result !== undefined) throw new Error('Function console.log returned unexpected value, C# signature is void');

This is interesting. Should there be a way of declaring "please discard the return value"? It's common in JS world for functions that are normally used as if void to actually return something. Developers might not be interested in marshalling the result back into .NET in some cases.

Example: many DOM APIs, e.g., element.removeChild(otherElement) will return otherElement even though you normally discard it since you obviously already have that value. Is there any perf gain in knowing not to marshal this return value back to .NET?

@SteveSandersonMS
Copy link
Member

Could you also give examples of how the memory management works?

  • Presumably if .NET receives an IJSObject, it has to dispose it explicitly to avoid leaking. Is that right, or are you doing something with a finalizer?
  • What about when JS receives a reference to a .NET object? How does the .NET side know when it's safe to release it? Do we rely on the JS side explicitly disposing, or does the .NET side explicitly dispose something?

@SteveSandersonMS
Copy link
Member

// this is sample how to export managed method to be consumable by JS
// the JS side wrapper would be exported into JS global namespace as JavaScriptTestHelper.AwaitTaskOfObject
// all arguments are natural JS types for the caller
[JSExport("JavaScriptTestHelper.AwaitTaskOfObject")]

Dropping things into global namespace is a bit of a concern, as it destroys usage scenarios like "more than one .NET runtime in a single document".

Another possibility would be that these exports become available as properties on some "runtime" instance which we can return when the runtime is first started up, so the usage would be more like:

const dotNet = startRuntime(); // Can't remember what your latest API looks like for starting the runtime, but something goes here
...
await dotNet.exports.JavaScriptTestHelper.AwaitTaskOfObject(...);

What do you think?

@pavelsavara
Copy link
Member Author

I'd expect 99% of the time people would want the obvious mapping of .NET string to JS string, and vice-versa, so it feels like the annotation could be omitted.

There is indeed default marshaling for most types and we don't have to force the user to specify it. I do enforce it based on feedback from interop group, where they told me that I should force user to be as explicit as possible to improve future compatibility. Perhaps I misunderstood ? Anyway, I'm open to relax it.

Same with all numeric types in the .NET-to-JS direction, as there's only one common JS-side numeric type.

There is actually bigint on JS side. And also Int64 doesn't always fit into Number's 52 integral bits.

@SteveSandersonMS
Copy link
Member

SteveSandersonMS commented Jun 3, 2022

I'm not too worried either way. If these were more commonly-used APIs I think we'd want to make the 99%-common case for both strings and numbers easier by having a default. But it's certainly fine to err on the side of being hyper-explicit for the first version of this, as we could loosen it later but not tighten it.

@pavelsavara
Copy link
Member Author

This is interesting. Should there be a way of declaring "please discard the return value"?

I think importing 3rd party API would be rare and you should match signature on your own functions.
We could easily not emit that assert and just ignore it, if we agree that's more useful for now.

Also in the future we could add JSType.Assert to be used as JSType.Void | JSType.Assert. Same idea could apply for nullability constraints or range checks. Or assert could be the default and we could relax it with JSType.Unchecked

@SteveSandersonMS
Copy link
Member

SteveSandersonMS commented Jun 3, 2022

I think importing 3rd party API would be rare and you should match signature on your own functions.

What about DOM APIs or other built-in browser APIs, such as the removeChild example?

Also, I think importing 3rd-party APIs will be super common. One of the key use cases for JS interop is to work with arbitrary 3rd-party JS libraries.

I was thinking the key point here is whether or not there's a perf and usability cost to marshalling something back that you don't want. Does it force the developer to explicitly dispose it? Or maybe it holds JS memory until some .NET finalizer runs?

@pavelsavara
Copy link
Member Author

Could you also give examples of how the memory management works?

Already in Net6 we have proxies both directions. We hook into finalizers on both sides. IJSObject could be disposed manually.
We do not change any of it with this proposal.

GC story for Promise/Task is more complex and we will slightly improve it.
Let's take that conversation on the prototype PR if you are interested in details.

@pavelsavara
Copy link
Member Author

pavelsavara commented Jun 3, 2022

I think importing 3rd party API would be rare and you should match signature on your own functions.

What about DOM APIs or other built-in browser APIs, such as the removeChild example?

OOP interop interfaces tend to create chatty interactions and therefore are expensive and slow overall.
For DOM API, user would have to create thousands of proxies to achieve anything interesting.
Blazor avoided that perf pitfall by passing diff DTOs on single call per page render.

External libraries could use JSImport to create OOP style APIs, where comfort is way more important than performance.

@terrajobst terrajobst added api-needs-work API needs work before it is approved, it is NOT ready for implementation and removed api-ready-for-review API is ready for review, it is NOT ready for implementation labels Jun 16, 2022
@bartonjs
Copy link
Member

I updated it with things I remembered (removing JSHost.Import, removing the JSObject get/set using long, and making the JSObject.SetProperty with "primitives values" not take nullable inputs.

I remember having an opinion that the GetProperty methods for primitives should either not return nullable, or add the word Nullable to their name, but don't think we settled on anything.

@pavelsavara
Copy link
Member Author

pavelsavara commented Jun 20, 2022

I would like to make JSHost.Import work, because it's a way how I could allow C# developer to load ES6 module without manipulating .html files or doing their own .js files, for example in a component.

Currently I'm thinking to add Module parameter to the JSImportAttribute like:

public partial class Foo
{
    [JSImport("barMethod", "fooAlias")]
    public static partial void BarMethod();

    [JSImport("Goo.Foo.barMethod", "fooAlias")]
    public static partial void BarFromNamespace();
}

public static void Main()
{
    JSHost.Import("fooAlias", "http://my.com/foo.js")
    Foo.BarMethod();
}

I like it because it allows the developer to construct the URL dynamically before first call.

I thought also about URL in the attribute. But it has a problem that it could NOT be downloaded (and bound) synchronously on the browser. [JSImport("barMethod", "http://my.com/foo.js")]

Any of it should be OK for CSP I think. Thoughts ? @SteveSandersonMS @kg

@pavelsavara
Copy link
Member Author

  • implemented generic JSMarshalAsAttribute<T>,
  • relaxed [JSMarshalAs<>] enforcement for all numbers except Int64, also for JSObject and for most Task<> and arrays
  • removed support for custom native marshaler [MarshalUsing]
  • removed in keyword for ToJS(XXX value) methods
  • implemented better dynamic import via JSHost.Import() and [JSImport(string functionName, string moduleName)]
    • adding JSImportAttribute.ModuleName
  • made JSObject public abstract class with HasProperty, GetTypeOfProperty, GetPropertyAsXXX
  • changed JSObject.GetPropertyAsBoolean, GetPropertyAsInt32, GetPropertyAsDouble to not nullable
  • added JSObject.GetPropertyAsByteArray and JSObject.SetProperty(, byte[] value)
  • added JSHost.DotnetInstance which is root of the runtime in ES6 module. We could have multiple runtimes on the JS page in the future.
  • [JSMarshalAs<JSType.Any[]>] doesn't work as JSType.Any[] is not JSType and so it doesn't fit the generic constraint JSMarshalAsAttribute<T> where T : JSType.

@pavelsavara pavelsavara added api-ready-for-review API is ready for review, it is NOT ready for implementation and removed api-needs-work API needs work before it is approved, it is NOT ready for implementation labels Jun 22, 2022
@pavelsavara
Copy link
Member Author

I learned that we could do TypeForwardedTo for JSObject, which allows me to make it not abstract and still keep the old interop working. I updated the proposal with it.

@terrajobst
Copy link
Member

terrajobst commented Jun 28, 2022

Video

  • JSHost
    • Import should be ImportAsync and take a CancellationToken
  • JSFunctionBinding
    • BindCSFunction should be BindManagedFunction
namespace System.Runtime.InteropServices.JavaScript;

[AttributeUsage(AttributeTargets.Method, Inherited = false, AllowMultiple = false)]
[SupportedOSPlatform("browser")]
public sealed class JSImportAttribute : Attribute
{
    public JSImportAttribute(string functionName);
    public JSImportAttribute(string functionName, string moduleName);
    public string FunctionName { get; }
    public string? ModuleName { get; }
}

[AttributeUsage(AttributeTargets.Method, Inherited = false, AllowMultiple = false)]
[SupportedOSPlatform("browser")]
public sealed class JSExportAttribute : Attribute
{
    public JSExportAttribute();
}

[AttributeUsage(AttributeTargets.Parameter | AttributeTargets.ReturnValue, Inherited = false, AllowMultiple = false)]
[SupportedOSPlatform("browser")]
public sealed class JSMarshalAsAttribute<T> : Attribute where T : JSType
{
    public JSMarshalAsAttribute();
}

[SupportedOSPlatform("browser")]
public abstract class JSType
{
    internal JSType();
    public sealed class None : JSType
    {
        internal None();
    }
    public sealed class Void : JSType
    {
        internal Void();
    }
    public sealed class Discard : JSType
    {
        internal Discard();
    }
    public sealed class Boolean : JSType
    {
        internal Boolean();
    }
    public sealed class Number : JSType
    {
        internal Number();
    }
    public sealed class BigInt : JSType
    {
        internal BigInt();
    }
    public sealed class Date : JSType
    {
        internal Date();
    }
    public sealed class String : JSType
    {
        internal String();
    }
    public sealed class Object : JSType
    {
        internal Object();
    }
    public sealed class Error : JSType
    {
        internal Error();
    }
    public sealed class MemoryView : JSType
    {
        internal MemoryView();
    }
    public sealed class Array<T> : JSType where T : JSType
    {
        internal Array();
    }
    public sealed class Promise<T> : JSType where T : JSType
    {
        internal Promise();
    }
    public sealed class Function : JSType
    {
        internal Function();
    }
    public sealed class Function<T> : JSType where T : JSType
    {
        internal Function();
    }
    public sealed class Function<T1, T2> : JSType where T1 : JSType where T2 : JSType
    {
        internal Function();
    }
    public sealed class Function<T1, T2, T3> : JSType where T1 : JSType where T2 : JSType where T3 : JSType
    {
        internal Function();
    }
    public sealed class Function<T1, T2, T3, T4> : JSType where T1 : JSType where T2 : JSType where T3 : JSType where T4 : JSType
    {
        internal Function();
    }
    public sealed class Any : JSType
    {
        internal Any();
    }
}

[SupportedOSPlatform("browser")]
public class JSObject : IDisposable
{
    internal JSObject();
    public bool IsDisposed { get; }
    public void Dispose();

    public bool HasProperty(string propertyName);
    public string GetTypeOfProperty(string propertyName);

    public bool GetPropertyAsBoolean(string propertyName);
    public int GetPropertyAsInt32(string propertyName);
    public double GetPropertyAsDouble(string propertyName);
    public string? GetPropertyAsString(string propertyName);
    public JSObject? GetPropertyAsJSObject(string propertyName);
    public byte[]? GetPropertyAsByteArray(string propertyName);

    public void SetProperty(string propertyName, bool value);
    public void SetProperty(string propertyName, int value);
    public void SetProperty(string propertyName, double value);
    public void SetProperty(string propertyName, string? value);
    public void SetProperty(string propertyName, JSObject? value);
    public void SetProperty(string propertyName, byte[]? value);
}

[SupportedOSPlatform("browser")]
public sealed class JSException : Exception
{
    public JSException(string msg);
}

[SupportedOSPlatform("browser")]
public static class JSHost
{
    public static JSObject GlobalThis { get; }
    public static JSObject DotnetInstance { get; }
    public static Task<JSObject> ImportAsync(string moduleName, string moduleUrl, CancellationToken cancellationToken = default);
}

[SupportedOSPlatform("browser")]
[CLSCompliant(false)]
[EditorBrowsable(EditorBrowsableState.Never)]
public sealed class JSFunctionBinding
{
    public static void InvokeJS(JSFunctionBinding signature, Span<JSMarshalerArgument> arguments);
    public static JSFunctionBinding BindJSFunction(string functionName, string moduleName, ReadOnlySpan<JSMarshalerType> signatures);
    public static JSFunctionBinding BindManagedFunction(string fullyQualifiedName, int signatureHash, ReadOnlySpan<JSMarshalerType> signatures);
}

[SupportedOSPlatform("browser")]
[EditorBrowsable(EditorBrowsableState.Never)]
public sealed class JSMarshalerType
{
    private JSMarshalerType();
    public static JSMarshalerType Void { get; }
    public static JSMarshalerType Discard { get; }
    public static JSMarshalerType Boolean { get; }
    public static JSMarshalerType Byte { get; }
    public static JSMarshalerType Char { get; }
    public static JSMarshalerType Int16 { get; }
    public static JSMarshalerType Int32 { get; }
    public static JSMarshalerType Int52 { get; }
    public static JSMarshalerType BigInt64 { get; }
    public static JSMarshalerType Double { get; }
    public static JSMarshalerType Single { get; }
    public static JSMarshalerType IntPtr { get; }
    public static JSMarshalerType JSObject { get; }
    public static JSMarshalerType Object { get; }
    public static JSMarshalerType String { get; }
    public static JSMarshalerType Exception { get; }
    public static JSMarshalerType DateTime { get; }
    public static JSMarshalerType DateTimeOffset { get; }
    public static JSMarshalerType Nullable(JSMarshalerType primitive);
    public static JSMarshalerType Task();
    public static JSMarshalerType Task(JSMarshalerType result);
    public static JSMarshalerType Array(JSMarshalerType element);
    public static JSMarshalerType ArraySegment(JSMarshalerType element);
    public static JSMarshalerType Span(JSMarshalerType element);
    public static JSMarshalerType Action();
    public static JSMarshalerType Action(JSMarshalerType arg1);
    public static JSMarshalerType Action(JSMarshalerType arg1, JSMarshalerType arg2);
    public static JSMarshalerType Action(JSMarshalerType arg1, JSMarshalerType arg2, JSMarshalerType arg3);
    public static JSMarshalerType Function(JSMarshalerType result);
    public static JSMarshalerType Function(JSMarshalerType arg1, JSMarshalerType result);
    public static JSMarshalerType Function(JSMarshalerType arg1, JSMarshalerType arg2, JSMarshalerType result);
    public static JSMarshalerType Function(JSMarshalerType arg1, JSMarshalerType arg2, JSMarshalerType arg3, JSMarshalerType result);
}

[SupportedOSPlatform("browser")]
[CLSCompliant(false)]
[EditorBrowsable(EditorBrowsableState.Never)]
public struct JSMarshalerArgument
{
    public delegate void ArgumentToManagedCallback<T>(ref JSMarshalerArgument arg, out T value);
    public delegate void ArgumentToJSCallback<T>(ref JSMarshalerArgument arg, T value);
    public void Initialize();
    public void ToManaged(out bool value);
    public void ToJS(bool value);
    public void ToManaged(out bool? value);
    public void ToJS(bool? value);
    public void ToManaged(out byte value);
    public void ToJS(byte value);
    public void ToManaged(out byte? value);
    public void ToJS(byte? value);
    public void ToManaged(out byte[]? value);
    public void ToJS(byte[]? value);
    public void ToManaged(out char value);
    public void ToJS(char value);
    public void ToManaged(out char? value);
    public void ToJS(char? value);
    public void ToManaged(out short value);
    public void ToJS(short value);
    public void ToManaged(out short? value);
    public void ToJS(short? value);
    public void ToManaged(out int value);
    public void ToJS(int value);
    public void ToManaged(out int? value);
    public void ToJS(int? value);
    public void ToManaged(out int[]? value);
    public void ToJS(int[]? value);
    public void ToManaged(out long value);
    public void ToJS(long value);
    public void ToManaged(out long? value);
    public void ToJS(long? value);
    public void ToManagedBig(out long value);
    public void ToJSBig(long value);
    public void ToManagedBig(out long? value);
    public void ToJSBig(long? value);
    public void ToManaged(out float value);
    public void ToJS(float value);
    public void ToManaged(out float? value);
    public void ToJS(float? value);
    public void ToManaged(out double value);
    public void ToJS(double value);
    public void ToManaged(out double? value);
    public void ToJS(double? value);
    public void ToManaged(out double[]? value);
    public void ToJS(double[]? value);
    public void ToManaged(out IntPtr value);
    public void ToJS(IntPtr value);
    public void ToManaged(out IntPtr? value);
    public void ToJS(IntPtr? value);
    public void ToManaged(out DateTimeOffset value);
    public void ToJS(DateTimeOffset value);
    public void ToManaged(out DateTimeOffset? value);
    public void ToJS(DateTimeOffset? value);
    public void ToManaged(out DateTime value);
    public void ToJS(DateTime value);
    public void ToManaged(out DateTime? value);
    public void ToJS(DateTime? value);
    public void ToManaged(out string? value);
    public void ToJS(string? value);
    public void ToManaged(out string?[]? value);
    public void ToJS(string?[]? value);
    public void ToManaged(out Exception? value);
    public void ToJS(Exception? value);
    public void ToManaged(out object? value);
    public void ToJS(object? value);
    public void ToManaged(out object?[]? value);
    public void ToJS(object?[]? value);
    public void ToManaged(out JSObject? value);
    public void ToJS(JSObject? value);
    public void ToManaged(out JSObject?[]? value);
    public void ToJS(JSObject?[]? value);
    public void ToManaged(out Task? value);
    public void ToJS(Task? value);
    public void ToManaged<T>(out Task<T>? value, ArgumentToManagedCallback<T> marshaler);
    public void ToJS<T>(Task<T>? value, ArgumentToJSCallback<T> marshaler);
    public void ToManaged(out Action? value);
    public void ToJS(Action? value);
    public void ToManaged<T>(out Action<T>? value, ArgumentToJSCallback<T> arg1Marshaler);
    public void ToJS<T>(Action<T>? value, ArgumentToManagedCallback<T> arg1Marshaler);
    public void ToManaged<T1, T2>(out Action<T1, T2>? value, ArgumentToJSCallback<T1> arg1Marshaler, ArgumentToJSCallback<T2> arg2Marshaler);
    public void ToJS<T1, T2>(Action<T1, T2>? value, ArgumentToManagedCallback<T1> arg1Marshaler, ArgumentToManagedCallback<T2> arg2Marshaler);
    public void ToManaged<T1, T2, T3>(out Action<T1, T2, T3>? value, ArgumentToJSCallback<T1> arg1Marshaler, ArgumentToJSCallback<T2> arg2Marshaler, ArgumentToJSCallback<T3> arg3Marshaler);
    public void ToJS<T1, T2, T3>(Action<T1, T2, T3>? value, ArgumentToManagedCallback<T1> arg1Marshaler, ArgumentToManagedCallback<T2> arg2Marshaler, ArgumentToManagedCallback<T3> arg3Marshaler);
    public void ToManaged<TResult>(out Func<TResult>? value, ArgumentToManagedCallback<TResult> resMarshaler);
    public void ToJS<TResult>(Func<TResult>? value, ArgumentToJSCallback<TResult> resMarshaler);
    public void ToManaged<T, TResult>(out Func<T, TResult>? value, ArgumentToJSCallback<T> arg1Marshaler, ArgumentToManagedCallback<TResult> resMarshaler);
    public void ToJS<T, TResult>(Func<T, TResult>? value, ArgumentToManagedCallback<T> arg1Marshaler, ArgumentToJSCallback<TResult> resMarshaler);
    public void ToManaged<T1, T2, TResult>(out Func<T1, T2, TResult>? value, ArgumentToJSCallback<T1> arg1Marshaler, ArgumentToJSCallback<T2> arg2Marshaler, ArgumentToManagedCallback<TResult> resMarshaler);
    public void ToJS<T1, T2, TResult>(Func<T1, T2, TResult>? value, ArgumentToManagedCallback<T1> arg1Marshaler, ArgumentToManagedCallback<T2> arg2Marshaler, ArgumentToJSCallback<TResult> resMarshaler);
    public void ToManaged<T1, T2, T3, TResult>(out Func<T1, T2, T3, TResult>? value, ArgumentToJSCallback<T1> arg1Marshaler, ArgumentToJSCallback<T2> arg2Marshaler, ArgumentToJSCallback<T3> arg3Marshaler, ArgumentToManagedCallback<TResult> resMarshaler);
    public void ToJS<T1, T2, T3, TResult>(Func<T1, T2, T3, TResult>? value, ArgumentToManagedCallback<T1> arg1Marshaler, ArgumentToManagedCallback<T2> arg2Marshaler, ArgumentToManagedCallback<T3> arg3Marshaler, ArgumentToJSCallback<TResult> resMarshaler);
    public unsafe void ToManaged(out void* value);
    public unsafe void ToJS(void* value);
    public void ToManaged(out Span<byte> value);
    public void ToJS(Span<byte> value);
    public void ToManaged(out ArraySegment<byte> value);
    public void ToJS(ArraySegment<byte> value);
    public void ToManaged(out Span<int> value);
    public void ToJS(Span<int> value);
    public void ToManaged(out Span<double> value);
    public void ToJS(Span<double> value);
    public void ToManaged(out ArraySegment<int> value);
    public void ToJS(ArraySegment<int> value);
    public void ToManaged(out ArraySegment<double> value);
    public void ToJS(ArraySegment<double> value);
}

@terrajobst terrajobst added api-approved API was approved in API review, it can be implemented and removed api-ready-for-review API is ready for review, it is NOT ready for implementation api-suggestion Early API idea and discussion, it is NOT ready for implementation labels Jun 28, 2022
@deeprobin
Copy link
Contributor

deeprobin commented Jun 28, 2022

Some question to the naming:

Why INTERNAL.ws_wasm_send?
See https://developer.mozilla.org/en-US/docs/MDN/Guidelines/Code_guidelines/JavaScript, https://google.github.io/styleguide/jsguide.html, shouldn't this be something like wsWasmSend? Every JavaScript style guide I saw is lowerCamelCase instead of snake_case.

@pavelsavara
Copy link
Member Author

Why INTERNAL.ws_wasm_send

This is sample from mono internal functions. Mono follows snake_case even in JS codebase, same as in C codebase.

@deeprobin
Copy link
Contributor

Mono follows snake_case even in JS codebase

This should be a sin...

@deeprobin
Copy link
Contributor

I have relatively little to do with this proposal but I would be interested to know if this is only Mono related or if WASM/JS support is also planned for the CoreCLR in the long term?

@lambdageek
Copy link
Member

I have relatively little to do with this proposal but I would be interested to know if this is only Mono related or if WASM/JS support is also planned for the CoreCLR in the long term?

Our current approach with unifying Mono and CoreCLR has been to use whichever runtime best fits a particular environment or use-case while providing a single set of libraries and APIs that can be used on either runtime. It's conceivable that as WebAssembly matures and gains features, the decision whether Mono's strengths (portability, a fast interpreter, fewer or more flexible expectations of the underlying host platform) still make sense on wasm may shift.

@ghost ghost added the in-pr There is an active PR which will close this issue when it is merged label Jun 30, 2022
@ghost ghost removed the in-pr There is an active PR which will close this issue when it is merged label Jul 7, 2022
@egil
Copy link

egil commented Jul 29, 2022

I see I am jumping in here fashionably late, but I am worried about how testable a Blazor component that uses [JSImpor] and [JSExport] directly will be in bUnit tests (tests that only run in C#). bUnit tests does not run any browser, only runs the C# parts of a Blazor component.

With the current Blazor JavaScript interop options, there is a family of IJSRuntime interfaces that testers can mock to emulate the interaction with JavaScript. It is not clear to me how it is possible to mock the JavaScript interaction when using [JSImpor] and [JSExport] directly in a component under test.

cc. @SteveSandersonMS

@pavelsavara
Copy link
Member Author

pavelsavara commented Jul 29, 2022

[JSImport] and [JSExport] are comparable to [DllImport] or [LibraryImport]. They are not intended to be directly mockable on runtime level.

We are aware of that issue in Blazor. I attempted to replace the internal usage of old interop in Blazor here dotnet/aspnetcore#41665, and run into that same mocking problem too.

We will have to wrap these new static methods into component with interface that could be mocked on higher level. It would mean that the components would not be dependent on IJSUnmarshalledRuntime anymore. And therefore not mockable by replacing it. It's probably too late to do large changes in Net7 now and that's why we postponed it.

In my opinion the design of hiding whole platform behind single low level interface should not have been public API in the first place. There were probably historic reasons for that design, thought.

@egil I would like to hear more about your use cases. Are they integration tests ? If so, could you replace your dependencies on higher level ? Mock blazor components, instead of mocking the runtime. You are testing your application code after all, not the Blazor code, right ?

@egil
Copy link

egil commented Jul 29, 2022

@egil I would like to hear more about your use cases. Are they integration tests ? If so, could you replace your dependencies on higher level ? Mock blazor components, instead of mocking the runtime. You are testing your application code after all, not the Blazor code, right ?

bUnit (https://bunit.dev) allows you to render a component with parameters (or a RenderFragment), inspect the component instance, the produced markup, and invoke event handlers in the component, inject services into components under test, among other things.

The services that a Blazor component depends on can thus be replaced during testing if the service allows this, like IJSRuntime.

I.e., bUnit is about testing component logic and markup.

Its up to the tester whether they are testing a single component (unit testing) or a deep render tree (integration testing).

bUnit is different from Playwright or Selenium it runs entirely in C#, there is no browser involved.

Let me know if you need more details. bunit.dev docs section is also full of samples if you want to learn more.

@kg
Copy link
Member

kg commented Jul 29, 2022

I see I am jumping in here fashionably late, but I am worried about how testable a Blazor component that uses [JSImpor] and [JSExport] directly will be in bUnit tests (tests that only run in C#). bUnit tests does not run any browser, only runs the C# parts of a Blazor component.

With the current Blazor JavaScript interop options, there is a family of IJSRuntime interfaces that testers can mock to emulate the interaction with JavaScript. It is not clear to me how it is possible to mock the JavaScript interaction when using [JSImpor] and [JSExport] directly in a component under test.

cc. @SteveSandersonMS

Mocking the underlying platform interfaces in this way is not feasible without imposing a performance tax on everyone who uses the platform interfaces, much like the P/Invoke examples that Pavel cited above. It's unfortunate that you're losing the ability to do this, but it was the wrong approach to begin with, so it is unlikely you will be able to get it back.

One option would be to do IL rewriting to insert shims, I believe there is tooling out there for this. I wouldn't advise it however because I think it would need to apply rewriting to the bcl and blazor.

If you want to run tests in C# without the wasm/js infrastructure underneath at all, I think the correct solution would be to replace the wasm source generator with one that generates a mocking compatible implementation. This would not be trivial, but since the wasm source generator is open source and available in the repo, you could perhaps modify it.

@egil
Copy link

egil commented Jul 29, 2022

Mocking the underlying platform interfaces in this way is not feasible without imposing a performance tax on everyone who uses the platform interfaces, much like the P/Invoke examples that Pavel cited above. It's unfortunate that you're losing the ability to do this, but it was the wrong approach to begin with, so it is unlikely you will be able to get it back.

I hear what you are saying. That said, with Blazor apps this feature is going to be used quite a bit (people will use this instead of IJSRuntime). P/Invoke is used much less by comparison, and it is less of a burden to hide P/Invoke code behind an abstraction that can be replaced at runtime.

It's going to be more annoying to Blazor devs to do this, but that would be the recommended approach, i.e., wrap your JSInvoke code into a mockable type and don't use the JSImport directly in components.

But I get this is the general purpose wasm runtime in .net and not Blazor specific, and perf is paramount.

One option would be to do IL rewriting to insert shims, I believe there is tooling out there for this. I wouldn't advise it however because I think it would need to apply rewriting to the bcl and blazor.

If you want to run tests in C# without the wasm/js infrastructure underneath at all, I think the correct solution would be to replace the wasm source generator with one that generates a mocking compatible implementation. This would not be trivial, but since the wasm source generator is open source and available in the repo, you could perhaps modify it.

Would it be possible to define a custom "os platform", [Versioning.SupportedOSPlatform("bunit")], and have bUnit include the needed implementation that would intercept the calls?

@SteveSandersonMS
Copy link
Member

Blazor apps this feature is going to be used quite a bit (people will use this instead of IJSRuntime)

These new APIs should only be used as a replacement for IJSUnmarshalledRuntime, which is not very commonly used.

We wouldn't advise people to stop using IJSRuntime because then they will lose portability of their code. IJSRuntime should be the preferred choice for Blazor devs in the great majority of cases because then they can seamlessly work across WebAssembly, Server, and WebView.

@egil
Copy link

egil commented Jul 29, 2022

Blazor apps this feature is going to be used quite a bit (people will use this instead of IJSRuntime)

These new APIs should only be used as a replacement for IJSUnmarshalledRuntime, which is not very commonly used.

We wouldn't advise people to stop using IJSRuntime because then they will lose portability of their code. IJSRuntime should be the preferred choice for Blazor devs in the great majority of cases because then they can seamlessly work across WebAssembly, Server, and WebView.

Ahh ok. That makes it much less of an issue. In that case I don't mind telling bUnit users that they should wrap their JSImport code in something they can mock if they want to test code that uses it.

Thanks for addressing my concerns everyone.

@ghost ghost locked as resolved and limited conversation to collaborators Aug 28, 2022
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
api-approved API was approved in API review, it can be implemented arch-wasm WebAssembly architecture area-System.Runtime.InteropServices.JavaScript blocking Marks issues that we want to fast track in order to unblock other important work
Projects
None yet
12 participants