DEV Community

Nikolay Belykh
Nikolay Belykh

Posted on

Using .NET as WebAssembly from JavaScript (React)

Motivation

I maintain a small project with quirky tools for dinosaurs. The project’s code is fully open and available on GitHub. It can process Microsoft Office (Visio) files without having it installed, using OpenXml and PdfSharp libraries. File processing is done in .NET (C#).

Before, the document processing was done on the server (i.e., the file was sent to the server via the internet). However, this has at least the following drawbacks:

  • File transfer over the network takes time,
  • Server hosting isn’t free, even if it’s Azure Function or AWS Lambda,
  • Users are afraid to send their valuable files to an unknown location.

So, I decided to try using the new .NET 7 features for compiling .NET code to WebAssembly. Earlier, something similar could be done, but Blazor was everywhere, meaning it’s a special type of app, not that easy to add to an existing one. Also, call me biased, but I don’t really like “hyped” things. Now, in .NET 7, they’ve “detached” Blazor, and it can compile to WASM without weird extras.

Essentially, all I had to do was replace the server API call with a local WASM code call (from a React component). I’ll explain the process step-by-step next.

featured image

.NET Project

Detailed instructions can be found on Microsoft’s website or in this blog. Here’s a quick rundown. It’s assumed you’re using the command line (personally I am using Visual Studio Code as an editor). So:

Install WASM compilation support. This is installed as a separate workload. It works starting from .NET 7:

    dotnet workload install wasm-tools
Enter fullscreen mode Exit fullscreen mode

Create a new project:

    dotnet new console
Enter fullscreen mode Exit fullscreen mode

Modify it for WASM compilation. Alternatively, you can install wasm-experimental, which adds a project template. But basically, to change a console app project to compile to WASM, you just need to add a few lines to the project. Details can be found again on Microsoft's website. In addition to modifying the project, you also need to create an (empty is fine) "main.js" file (needed for the toolset to work properly; this might change in the future). In my case, I just created an empty file.

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net7.0</TargetFramework>

    <!-- add this piece -->
    <RuntimeIdentifier>browser-wasm</RuntimeIdentifier>
    <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
    <WasmMainJSPath>main.js</WasmMainJSPath>
    <!-- end -->

    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

</Project>
Enter fullscreen mode Exit fullscreen mode

Next, write your actual code in Program.cs. For this example, I’m just returning the length of the given data (a “file” in this case). Obviously, there should be some actual processing. You can see what I really do in the GitHub repository (files are processed with OpenXml and PdfSharp).

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

// one more artifct to please the toolset
return;

public partial class FileProcessor
{
    // Экспортируем метод
    [JSExport]
    internal static async Task<int> ProcessFile(byte[] file)
    {
  await Task.Delay(100); // эмулируем работу
        return file.Length;
    }
}
Enter fullscreen mode Exit fullscreen mode

Compile (or directly publish for a smaller size):

    dotnet publish -c Release
Enter fullscreen mode Exit fullscreen mode

You’ll get an output folder with files in “bin/Release/net7.0/browser-wasm/AppBundle”. All DLL files are in the “managed” folder. On the frontend, you’re only interested in the “dotnet.js” file, which is intended for integration into a JavaScript application. But you upload the entire folder to hosting, including all its files and subdirectories. The size is not too bad; for a “Hello World,” it’s just a few megabytes.

Files after compilation:
file structure

Web Project (vite/react)

For VisioWebTools, I use Astro, but for the sake of this article, let’s assume our frontend is vite + react. Below, I simply create a new project “myapp” for illustration.

npm create vite@latest myapp --template react-ts
cd myapp
npm install
npm run dev
Enter fullscreen mode Exit fullscreen mode

Now, we actually complete the remaining part and call our function from .NET. The code simply displays the size of the selected file by calling the .NET function from the above project. The file selection button is disabled during loading.

import { useDotNet } from './useDotNet'

function App() {

  const { dotnet, loading } = 
      useDotNet('/path/to/your/AppBundle/dotnet.js')

  const fileSelected = async (e: any) => {

    const file = e.target.files[0];
    const data = new Uint8Array(await file.arrayBuffer());

    const result = await dotnet.FileProcessor.ProcessFile(data)

    alert(`Result: ${result}`);
  }

  return (
    <>
    <input disabled={loading} type="file" onChange={fileSelected}></input>
    </>
  )
}

export default App
Enter fullscreen mode Exit fullscreen mode

For convenience, I created a custom hook (“loader”), useDotNet (code below). I couldn’t get the bundler (neither vite nor webpack) to properly package the code from AppBundle (including DLLs and other “interesting” files), so dynamic loading of all this stuff is done via await import(url). The “url” parameter should point to the “dotnet.js” generated by .NET.

import { useEffect, useRef, useState } from 'react';

export const useDotNet = (url: string) => {

  const dotnetUrl = useRef('');
  const [dotnet, setDotNet] = useState<any>(null);
  const [loading, setLoading] = useState(true);

  const load = async (currentUrl: string): Promise<any> => {

    const module = await import(/* @vite-ignore */ currentUrl);

    const { getAssemblyExports, getConfig } = await module
      .dotnet
      .withDiagnosticTracing(false)
      .create();

    const config = getConfig();
    const exports = await getAssemblyExports(config.mainAssemblyName);
    return exports;
  }

  useEffect(() => {
    if (dotnetUrl.current !== url) { // safeguard to prevent double-loading
      setLoading(true);
      dotnetUrl.current = url;
      load(url)
        .then(exports => setDotNet(exports))
        .finally(() => setLoading(false))
    }
  }, [url]);
  return { dotnet, loading };
}
Enter fullscreen mode Exit fullscreen mode

Building (CI) and hosting the project on GitHub

My goal was partly to provide completely free hosting for the VisioWebTools project while still being able to run .NET code.

For building the project, I used GitHub Actions; for hosting, GitHub Pages. GitHub provides 2000 free build minutes per month, which is more than enough for me. The logic is:

  1. Build the .NET project,

  2. Upload the contents of the “AppBundle” folder to the “public” folder of the React/Vite project. The contents of this folder will simply be copied to the root of the built application (i.e., into the “dist” folder in this case),

  3. Build the React application in “production” mode. The URL for loading dotnet.js is set to “/AppBundle/dotnet.js”, i.e., a link to the application root.

GitHub Pages handles this fine; it doesn’t complain about strange file types like “.dll” or “.blat” (IIS does, and you have to add them explicitly there). You can see an example configuration for GitHub Actions here.

Conclusion

You can find a test (“pet”) repository here. Everything works on my machine :) I don’t claim it’s an impeccable method, and I’m totally open to any feedback or suggestions. I’ll try to answer any questions to the best of my understanding. I hope you find this article useful.

Top comments (0)