Use .NET from any JavaScript app in .NET 7

Pavel Šavara

.NET 7 provides improved support for running .NET on WebAssembly in JavaScript-based apps, including a rich JavaScript interop mechanism. The WebAssembly support in .NET 7 is the basis for Blazor WebAssembly apps but can be used independently of Blazor too. Existing JavaScript apps can use the expanded WebAssembly support in .NET 7 to reuse .NET libraries from JavaScript or to build completely novel .NET-based apps and frameworks. Blazor WebAssembly apps can also use the new JavaScript interop mechanism to optimize interactions with JavaScript and the web platform. In this post, we’ll take a look at the new JavaScript interop support in .NET 7 and use it to build the classic TodoMVC sample app. We’ll also look at using the new JavaScript interop app from a Blazor WebAssembly app.

TL;DR

Fork the samples:

The new JavaScript interop is controlled by attributes from the System.Runtime.InteropServices.JavaScript namespace.

Live demos:

TodoMVC

TodoMVC is great community project which helps JavaScript developers compare the features of various UI frameworks.

To show how the new JS interop support in .NET 7 works, let’s create a C# port of TodoMVC based on the vanilla-es6 version. We won’t try to convert all the UI logic, just the app logic.

How-to

This post only shows interesting snippets, not the whole code. To follow along, please copy & paste from the sample repo on github.

To get started, install the .NET 7 RC1 SDK (or later) and run the following commands:

dotnet workload install wasm-tools
dotnet workload install wasm-experimental
dotnet new wasmbrowser

The wasm-experimental workload contains experimental project templates for getting started with .NET on WebAssembly in a browser app (WebAssembly Browser App) or in a Node.js based console app (WebAssembly Console App). Here we’re using the browser-based template. The developer experience for these project templates is still a work in progress, but the APIs used in them are fully supported in .NET 7.

Open the folder in your favorite code editor and change the Program.cs to match Program.cs from the sample.

using System;
using System.Runtime.InteropServices.JavaScript;
using System.Threading.Tasks;

namespace TodoMVC
{
    public partial class MainJS
    {
        static Controller? controller;

        public static async Task Main()
        {
            if (!OperatingSystem.IsBrowser())
            {
                throw new PlatformNotSupportedException("This demo is expected to run on browser platform");
            }

            await JSHost.ImportAsync("todoMVC/store.js", "./store.js");
            await JSHost.ImportAsync("todoMVC/view.js", "./view.js");

            var store = new Store();
            var view = new View(new Template());
            controller = new Controller(store, view);
            Console.WriteLine("Ready!");
        }
    }
}

Add Item.cs, Controller.cs, Template.cs which are C# ports of the ES6 sample code. Also add helpers.js as it is.

Store

Store.cs is also almost the same as the ES6 version, except the localStorage API is wrapped by store.js

export function setLocalStorage(todosJson) {
  window.localStorage.setItem('dotnet-wasm-todomvc', todosJson);
}

export function getLocalStorage() {
  return window.localStorage.getItem('dotnet-wasm-todomvc') || '[]';
};

Bind these JavaScript functions to C# code using JSImportAttribute in Store.cs

static partial class Interop
{
    [JSImport("setLocalStorage", "todoMVC/store.js")]
    internal static partial void _setLocalStorage(string json);

    [JSImport("getLocalStorage", "todoMVC/store.js")]
    internal static partial string _getLocalStorage();
}

The first parameter of the attribute "setLocalStorage" is a name of a JS function. The function is exported from the ES6 module named "todoMVC/store.js". The module name needs to be unique for all libraries in the application. The prefix todoMVC/ solves that. It also maps to the name used in JSHost.ImportAsync in the Main.cs.

View

The last and most complex part is the View. Most of the real implementation is left in JavaScript in view.js. Unlike with Blazor, the new wasmbrowser template doesn’t include any UI framework, so we’ll leave all the UI logic in JavaScript.

export function removeItem(id) {
  const elem = qs(`[data-id="${id}"]`);
  if (elem) {
    $todoList.removeChild(elem);
  }
}

export function bindAddItem(handler) {
  $on($newTodo, 'change', ({ target }) => {
    const title = target.value.trim();
    if (title) {
      handler(title);
    }
  });
}

View.cs binds the JS functions and makes them callable from C#. A couple of these imported functions are shown below, but there are more in the full sample.

public static partial class Interop
{
    [JSImport("removeItem", "todoMVC/view.js")]
    public static partial void removeItem([JSMarshalAs<JSType.Number>] long id);

    [JSImport("bindAddItem", "todoMVC/view.js")]
    public static partial void bindAddItem(
        [JSMarshalAs<JSType.Function<JSType.String>>] Action<string> handler);
}

The removeItem is passing Int64 as an argument. The marshaller is configured to translate it as JSType.Number which can only represent a 52-bit integer. The alternative would be to marshal it as JSType.BigInt which is bit more expensive. Since the runtime doesn’t want to guess which conversion is preferred in the use case, the developer needs to annotate the method parameter with [JSMarshalAs<T>].

In C# 11, it’s now possible to use generic instances as custom attributes, such as JSMarshalAsAttribute<T> in the above code. Enable the preview language version with <LangVersion>preview</LangVersion> in the project file.

The bindAddItem is even more interesting. It’s passing a strongly typed callback delegate handler. An explicit definition for the marshaller also needs to be there. It’s a Function with a first argument of type String. This callback is registered by $on helper with the DOM change event for the <input class="new-todo"/> when we call bindAddItem function.

Main

Copy the contents of index.html from the sample. The interesting part is

<script type='module' src="./main.js"/>

It’s loading main.js which will import ./dotnet.js and start the .NET runtime.

Copy the contents of main.js. The interesting part is

import { dotnet } from './dotnet.js'

await dotnet.run();

Edit the project file and add

<Project Sdk="Microsoft.NET.Sdk">
    <PropertyGroup>
        <TargetFramework>net7.0</TargetFramework>
        <WasmMainJSPath>main.js</WasmMainJSPath>
        <OutputType>Exe</OutputType>
        <Nullable>enable</Nullable>
        <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
+        <LangVersion>preview</LangVersion>
    </PropertyGroup>

    <ItemGroup>
        <WasmExtraFilesToDeploy Include="index.html" />
-        <WasmExtraFilesToDeploy Include="main.js" /> 
+        <WasmExtraFilesToDeploy Include="*.js" />
+        <WasmExtraFilesToDeploy Include="*.css" />
    </ItemGroup>
</Project>

JSExport

The code above demonstrates how to pass callback from C# to JavaScript so that events fired by the browser can be handled by managed code. There is also another possibility: static methods annotated with JSExportAttribute that are callable from JavaScript.

namespace TodoMVC
{
    public partial class MainJS
    {
        [JSExport]
        public static void OnHashchange(string url)
        {
            controller?.SetView(url);
        }
    }
}

The main.js uses getAssemblyExports JavaScript API to get all the exports of the assembly.

const exports = await getAssemblyExports(getConfig().mainAssemblyName);
exports.TodoMVC.MainJS.OnHashchange(document.location.hash);

The code uses getConfig().mainAssemblyName so that "TodoMVC.dll" doesn’t have to be hard-coded. The exports object contains the same namespace TodoMVC, the same class MainJS and the same method OnHashchange as the C# code. The parameters of the call will be marshalled using the same rules as for [JSImport]. Marshaling in both directions is governed by the C# method signature and the [JSMarshalAs<T>] annotations on the parameters and return values.

Run the app

Start the app with the following command (*) :

dotnet run

* This is unfortunately broken on Windows for RC1 and it should be fixed in RC2

For RC1 on Windows you could run following commands instead with similar results:

dotnet tool update dotnet-serve --global
dotnet serve --directory bin\Debug\net7.0\browser-wasm\AppBundle

You should see output similar to this. You can click on one of the URLs and test the application in your browser.

WasmAppHost --runtime-config C:\Dev\dotnet-wasm-todo-mvc\bin\Debug\net7.0\browser-wasm\AppBundle\TodoMVC.runtimeconfig.json
App url: http://127.0.0.1:9000/index.html
App url: https://127.0.0.1:58139/index.html

You can see a live demo of the completed app at https://pavelsavara.github.io/dotnet-wasm-todo-mvc. image

Optimize the app

It’s possible to preload the files dotnet.js and mono-config.json which are the early dependencies for the runtime to begin starting.

It’s also possible to prefetch the largest binary files. In a real production application you should measure the impact of these optimizations. Depending on the expected bandwidth and the latency of the target device’s connectivity, strike a good balance of what to prefetch.

<head>
  <script type='module' src="./dotnet.js"/>
  <link rel="preload" href="./mono-config.json" as="fetch" crossorigin="anonymous">
  <link rel="prefetch" href="./dotnet.wasm" as="fetch" crossorigin="anonymous">
  <link rel="prefetch" href="./icudt.dat" as="fetch" crossorigin="anonymous">
  <link rel="prefetch" href="./managed/System.Private.CoreLib.dll" as="fetch" crossorigin="anonymous">
</head>

In the project file enable ahead-of-time (AOT) compilation to improve speed. Enable trimming of unused code to reduce download size.

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
        <TargetFramework>net7.0</TargetFramework>
        <WasmMainJSPath>main.js</WasmMainJSPath>
        <OutputType>Exe</OutputType>
        <Nullable>enable</Nullable>
        <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
+        <PublishTrimmed>true</PublishTrimmed>
+        <TrimMode>full</TrimMode>
+        <RunAOTCompilation>true</RunAOTCompilation>
    </PropertyGroup>
</Project>

Please note that while AOT code runs much faster than interpreted IL, it increases size of the binary download.

When trimming unused code, the components which are used dynamically (for example via reflection) need to be protected from trimming.

In order to publish with AOT compilation, install the wasm-tools workload and then publish the app.

dotnet workload install wasm-tools
dotnet publish -c Release

We can use the dotnet-serve tool to host the published app locally. Using the correct MIME type for .wasm allows the browser to use streaming instantiation of the WebAssembly module.

dotnet tool install --global dotnet-serve
dotnet serve --mime .wasm=application/wasm --mime .js=text/javascript --mime .json=application/json --directory bin\Release\net7.0\browser-wasm\AppBundle\

Compressing the binary assets is also good idea but it’s out of the scope of this article.

Interop performance

We test the performance of the new JS interop model by running a set of microbenchmarks regularly. For example, this graph shows 10000 calls to a trivial JavaScript method using the C# signature [JSImport] int Echo(int value). Currently we measure various build configurations and browsers on a small ODROID-N2+. image

Marshalling of primitive numbers and IntPtr is fast. Note in the graph the difference AOT can make. Marshalling string requires us to allocate memory and copy the bits, so it’s slower. Marshaling Span<byte> is cheap but the MemoryView on JavaScript side is valid only for the duration of the call. Marshalling a proxy of object involves an object allocation, GCHandle, and two GCs. Marshaling Task, Exception and also ArraySegment<byte> is similar as they also allocate a GCHandle.

Blazor WebAssembly

The new interop with [JSImport] and [JSExport] is also available in Blazor WebAssembly apps. It’s useful when you need to integrate with the browser on the client side only. When you need to call your JavaScript also from the server side or in a native hybrid app, please use the existing IJSRuntime interface, which does JSON serialization and a remote dispatch.

For a simple sample, please have a look at the hands demo of 3rd party video processing JavaScript library integrated into Blazor WASM.

You can see a live demo of the app is at https://pavelsavara.github.io/blazor-wasm-hands-pose image

Legacy interop

Prior to .NET 7, to perform low-level JavaScript interop in Blazor WebAssembly apps you may have used the undocumented APIs grouped in the MONO and BINDING JavaScript namespaces. Those APIs are still there in .NET 7 for backward compatibility reason, but please consider them deprecated. They expose the user to raw pointers to managed objects and are not protected from GC and WASM memory resize. In Blazor the IJSUnmarshalledRuntime interface has similar downsides and is now also deprecated. The new interop with [JSImport] and [JSExport] should be a faster and safer replacement.

If your use case can’t be implemented by the new API or if you found a bug, please let us know by creating a new issue on GitHub.

Conclusion

We hope that these new features will allow developers to create better integration between the JavaScript ecosystem and .NET. With these new features .NET developers can now wrap and use existing JavaScript libraries within existing frameworks like Blazor or Uno, or use them directly, like in this demo.

10 comments

Discussion is closed. Login to edit/delete existing comments.

  • Tony Henrique 0

    This is very cool!

  • Ammar Shaukat 0

    this is great.

  • Shahid Roofi Khan 1

    This thing can eventually replace javascript completely

  • Nuno Cruz 0

    I was studying this as an alternative to use clearscript to run SSR of JavaScript libraries on the Dotnet asp server (no blazor) but seems very hard to do it.

  • Richard Collette 1

    Without digging into it, one might think that .NET is doing the heavy lifting for the hand recognition, but that isn’t the case. Interestingly, it is done with a JavaScript library. I would really like to see the performance difference of running the hand recognition with .NET WASM. However, there isn’t an official implementation of MediaPipe in .NET

    • pavelsavaraMicrosoft employee 0

      Hi Richard, you are right that the heavy lifting of video processing is the done in the 3rd party library.
      This article is about the 3rd party library integration and video processing is one of the nice things, which make sense to do on the client side.
      BTW: The awesome MediaPipe is also implemented in WASM and on emscripten as we are.
      I give them full credit in the app UI and in the git repo, it’s even in the screenshot here.

      It would be cool if somebody tried to implement the same with ML.NET and port it to .NET WASM!

  • Jaromír “Kerray” Matýšek 0

    Wow!

  • Chris Hansen 0

    Unfortunately (for me), the wasm-experimental install failed with the following:

    Workload ID wasm-experimental is not recognized.

    Any decent workaround?

  • Alberto Monteiro 0

    Compressing the binary assets is also good idea but it’s out of the scope of this article.

    Could you send some content that covers that topic?

  • Shawinder Sekhon 0

    I was playing with the ToDo app. Looks like it takes some time to load the assemblies and then goes into ready state. If you try to create a todo right away then nothing happens. Showing a loader while the browser is getting ready will solve the problem.

Feedback usabilla icon