NetWasm guide

Call C# from JavaScript.

Publish C# [JSExport] methods as a typed JavaScript module. A page imports it, opens an instance, and calls the exports like ordinary functions. There is no glue code to write and no server to run.

Create and publish a module

Install the templates, create a page module, and publish it with its working sample page:

terminal
$ dotnet new install NetWasm.Templates
$ dotnet new netwasm-app -n Greeter --host page
$ cd Greeter
$ dotnet publish -c Release

A JsModule project has exports instead of Main:

Program.cs
using System.Runtime.InteropServices.JavaScript;

public static partial class PageExports
{
    [JSImport("show", "app.host")]
    private static extern void Show(string message);

    [JSExport("greet")]
    public static string Greet(string name)
    {
        var message = $"Hello, {name}, from C#.";
        Show(message);
        return message;
    }
}

The project file selects the output type and the host:

Greeter.csproj
<PropertyGroup>
  <OutputType>JsModule</OutputType>
  <NetWasmJavaScriptHosts>page</NetWasmJavaScriptHosts>
  <TargetFramework>netwasm0.1</TargetFramework>
  <Nullable>enable</Nullable>
</PropertyGroup>

Publishing writes a static site to bin/Release/netwasm0.1/publish. It contains the sample index.html and app.mjs, the module browser/Greeter.mjs, and its TypeScript declarations in browser/Greeter.d.mts.

Call C# from the page

Import open from the generated module. Each call to open() starts an independent instance and resolves to an object with one method per export.

app.mjs
import { open } from "./browser/Greeter.mjs";

const app = await open({ stdout: line => console.log(line) });
app.failure.then(error => console.error(error));

const message = app.greet("browser"); // returns synchronously
console.log(message);

await app.close();

A synchronous export runs on the page's thread and returns its value directly. An export that returns Task or ValueTask returns a Promise. The declarations name each parameter after its C# parameter and follow C# nullability, so the editor shows greet(name: string): string.

  • close() releases everything the instance holds.
  • failure settles when the instance traps or fails, or after close(). Later calls throw NetWasmHostError.
  • open() options supply guest output, environment variables, import modules at runtime, and a notification handler. Every option is optional.

Call JavaScript from C#

A [JSImport] names the JavaScript module that implements it. On the page host, import modules run in the page, so they can use the DOM directly:

app.host.mjs
export function show(message) {
  document.querySelector("#result").textContent = message;
}

The project bundles it with a NetWasmJavaScriptModule item, or a page supplies it at runtime through the modules option of open():

Greeter.csproj
<ItemGroup>
  <NetWasmJavaScriptModule Include="app.host.mjs" Module="app.host" />
</ItemGroup>

JavaScript that an import runs can call an export again before the import returns, nested up to 32 levels.

Run on the page or in a worker

NetWasmJavaScriptHosts chooses where the same module runs:

  • page (default): synchronous exports return values, and import modules can use the DOM. Use it when C# drives page UI or calls are short.
  • worker: the module runs in a Web Worker behind a Promise-based client, so long work does not block the page. See Run C# in a Web Worker.
  • page;worker: publishes both, for a page that needs some calls of each kind.

What crosses the boundary

Booleans, numbers, strings, and number arrays cross directly. 64-bit integers arrive as bigint, and a number array arrives as the typed array with the same element type, in both directions. Strings and arrays are copied. Page exports can also take and return JSObject, a handle to a JavaScript object that stays in JavaScript.

A synchronous import can also take a Span<T> or ReadOnlySpan<T> of numbers. JavaScript receives the same typed array as for an array, as a view of the span's memory, so nothing is copied. The view is valid only until the import returns, and JavaScript must not write to the view of a ReadOnlySpan<T>. A synchronous import whose parameters and result are all primitive scalars, or void, compiles to a direct WebAssembly call into the JavaScript function, with nothing copied through memory; use such imports on a hot path.

Async exports accept strings, number arrays, and JSObject, and async imports can settle with them. The compiler generates the adapters for exactly the exports and imports your program declares; nothing is discovered by reflection at runtime.

Serve the published files

The publish directory is a static site. Copy it to any static host. It needs no server code and no Cross-Origin-Opener-Policy or Cross-Origin-Embedder-Policy headers.

  • Serve it over HTTPS or from localhost. Startup verifies files with crypto.subtle, which browsers provide only in secure contexts.
  • Open the page through HTTP. A file:// URL does not work, because browsers block module scripts and fetch there.
  • Serve .mjs files as text/javascript. Browsers refuse a module script with another MIME type. Python's http.server on Windows uses the type the Windows registry gives .mjs, which can be text/plain; the SDK guide shows how to start it with the right type.
  • Under a strict Content Security Policy, allow script-src 'self' blob: 'wasm-unsafe-eval'.

Current limits

A synchronous export blocks the page until it returns. Return a Task for long work, or host the module in a worker. A JSObject stays in the realm that created it, so a worker-hosted export cannot take or return one.

The boundary carries the values above, not arbitrary object graphs: there is no automatic POCO serialization, dynamic overload resolution, or sharing of managed objects between realms. Use generated JSON contracts or WIT when the boundary is better described as data. The JavaScript interop reference documents every option and the exact boundary.

Ready to build?

Put C# behind a JavaScript import.