microsoft / node-api-dotnet

Advanced interoperability between .NET and JavaScript in the same process.
MIT License
426 stars 49 forks source link

Node API for .NET: JavaScript + .NET Interop

This project enables advanced interoperability between .NET and JavaScript in the same process.

Interop is high-performance and supports TypeScript type-definitions generation, async (tasks/promises), streams, and more. It uses Node API so it is compatible with any Node.js version (without recompiling) or other JavaScript runtime that supports Node API.

:warning: Status: Public Preview - Most functionality works well, though there are some known limitations around the edges, and there may still be minor breaking API changes.

Instructions for getting started are below.

Minimal example - JS calling .NET

// JavaScript
const Console = require('node-api-dotnet').System.Console;
Console.WriteLine('Hello from .NET!');

Minimal example - .NET calling JS

// C#
interface IConsole { void Log(string message); }

var nodejs = new NodejsPlatform(libnodePath).CreateEnvironment();
nodejs.Run(() => {
    var console = nodejs.Import<IConsole>("global", "console");
    console.Log("Hello from JS!");
});

For more examples, see the examples directory.

Feature Highlights

Load and call .NET assemblies from JS

The node-api-dotnet package manages hosting the .NET runtime in the JS process (if not using AOT - see below). The .NET core library types are available immediately; additional .NET assemblies can be loaded by file path:

// JavaScript
const dotnet = require('node-api-dotnet');
dotnet.load('path/to/ExampleAssembly.dll');
const exampleObj = new dotnet.ExampleNamespace.ExampleClass(...args);

All .NET namespaces are projected onto the top-level dotnet object. When loading multiple .NET assemblies, types from all assemblies are merged into the same namespace hierarchy.

Load and call JavaScript packages from .NET

Calling JavaScript from .NET requires hosting a JS runtime such as Node.js in the .NET app. Then JS packages can be imported either directly as JS values or by declaring C# interfaces for the JS types and using automatic marshalling.

All interaction with a JavaScript environment must be from its thread, via the Run(), RunAsync(), or Post() methods on the JS environment object.

// C#
interface IExample
{
    void ExampleMethod();
}

var nodejsPlatform = new NodejsPlatform(libnodePath);
var nodejs = nodejsPlatform.CreateEnvironment();

nodejs.Run(() => {
    // Import a module property, then call a function on it.
    var example1 = nodejs.Import("example-npm-package", "ExampleObject");
    example1.CallMethod("exampleMethod");

    // Import the module property using an interface, and call the same function.
    var example2 = nodejs.Import<IExample>("example-npm-package", "ExampleObject");
    example2.ExampleMethod();
});

In the future, it may be possible to automatically generate .NET API definitions from TypeScript type definitions.

Generate TS type definitions for .NET APIs

If writing TypeScript, or type-checked JavaScript, there is a tool to generate type .d.ts type definitions for .NET APIs. It also generates a small .js file that makes it simple to load the assembly DLL and type-definitions together.

$ npm exec node-api-dotnet-generator --assembly ExampleAssembly.dll --typedefs ExampleAssembly.d.ts
// TypeScript
import './ExampleAssembly.js'; // TS also loads the adjacent .d.ts file.
dotnet.ExampleNamespace.ExampleClass.ExampleMethod(...args); // This call is type-checked!

(CommonJS modules must use require() instead of import.)

For reference, there is a list of C# type projections to TypeScript.

Full async support

JavaScript code can await a call to a .NET method that returns a Task. The marshaller automatically sets up a SynchronizationContext so that the .NET result is returned back to the JS thread.

// TypeScript
import { ExampleClass } from './ExampleAssembly';
const asyncResult = await ExampleClass.GetSomethingAsync(...args);

.NET Tasks are seamlessly marshaled to & from JS Promises. So JS code can work naturally with a Promise returned from a .NET async method, and a JS Promise passed to .NET becomes a JSPromise that can be awaited in the C# code.

Error propagation

Exceptions/errors thrown in .NET or JS are propagated across the boundary with stack traces. An unhandled .NET exception is thrown back to a JS caller as an Error with a stack trace that includes both .NET and JS frames, and source line numbers if symbols are available. For example:

Error: Test error thrown by JS.
    at Microsoft.JavaScript.NodeApi.TestCases.Errors.ThrowDotnetError(String message) in D:\node-api-dotnet\test\TestCases\Errors.cs:line 13
    at Microsoft.JavaScript.NodeApi.Generated.Module.Errors_ThrowDotnetError(JSCallbackArgs __args) in napi-dotnet.NodeApi.g.cs:line 357
    at Microsoft.JavaScript.NodeApi.JSNativeApi.InvokeCallback[TDescriptor](napi_env env, napi_callback_info callbackInfo, JSValueScopeType scopeType, Func`2 getCallbackDescriptor) in JSNativeApi.cs:line 1070
    at catchDotnetError (D:\node-api-dotnet\test\TestCases\errors.js:14:12)
    at Object.<anonymous> (D:\node-api-dotnet\test\TestCases\errors.js:41:1)

Similarly, an unhandled JS Error is thrown back to a .NET caller as a JSException with a combined stack trace.

Develop Node.js addons with C

A C# class library project can use the [JSExport] attribute to tag (and rename) APIs that are exported when the library is built as a JavaScript module. A C# Source Generator runs as part of the compilation and generates code to export the tagged APIs and marshal values between JavaScript and C#.

// C#
[JSExport] // Export class and all public members to JS.
public class ExampleClass { ... }

public static class ExampleStaticClass
{
    [JSExport("exampleFunction")] // Export as a module-level function.
    public static string StaticMethod(ExampleClass obj) { ... }

    // (Other public members in this class are not exported by default.)
}

The [JSExport] source generator enables faster startup time because the marshaling code is generated at build time rather than dynamically emitted at runtime (as when calling a pre-built assembly). The source generator also enables building ahead-of-time compiled libraries in C# that can be called by JavaScript without depending on the .NET Runtime. (More on that below.)

Optionally work directly with JS types in C

The class library includes an object model for the JavaScript type system. JSValue represents a value of any type, and there are more types like JSObject, JSArray, JSMap, JSPromise, etc. C# code can work directly with those types if desired:

// C#
[JSExport]
public static JSPromise JSAsyncExample(JSValue input)
{
    // Example of integration between C# async/await and JS promises.
    string greeter = (string)input;
    return new JSPromise(async (resolve) =>
    {
        await Task.Delay(50);
        resolve((JSValue)$"Hey {greeter}!");
    });
}

Automatic efficient marshaling

There are two ways to get automatic marshaling between C# and JavaScript types:

  1. Compile a C# class library with [JSExport] attributes like the examples above. The source generator produces marshaling code that is compiled with the assembly.

  2. Load a pre-built .NET assembly, as in the earlier examples. The loader will use reflection to scan the APIs, then emit marshaling code on-demand for each type that is referenced by JS. The dynamic marshalling code is derived from the same expression trees that are used for compile-time source-generation, but is generated and at runtime and compiled with LambdaExpression.Compile(). So there is a small startup cost from that reflection and compilation, but subsequent calls to the same APIs may be just as fast as the pre-compiled marshaling code (and are just as likely to be JITted).

The marshaller uses the strong typing information from the C# API declarations as hints about how to convert values beteen JavaScript and C#. Here's a general summary of conversions:

Stream across .NET and JS

.NET Streams are automatically marshalled to and from Node.js Duplex (or Readable or Writable) streams. That means JS code can seamlessly read from or write to streams created by .NET. Or .NET code can read from or write to streams created by JS. Streamed data is transferred using shared memory (without any additional sockets or pipes), so memory allocation and copying is minimized.

Optional .NET native AOT compilation

This library supports hosting the .NET Runtime in the same process as the JavaScript runtime. Alternatively, it also supports building native ahead-of-time (AOT) compiled C# libraries that are loadable as a JavaScript module without depending on the .NET Runtime.

There are advantages and disadvantages to either approach: .NET Runtime .NET Native AOT
API compatibility Broad compatibility with .NET APIs Limited compatibility with APIs designed to support AOT
Ease of deployment Requires a matching version of .NET to be installed on the target system A .NET installation is not required (though some platform libs may be required on Linux/Mac)
Size of deployment Compact - only IL assemblies need to be deployed Larger due to bundling necessary runtime code - minimum ~3 MB per platform
Performance Slightly slower startup (JIT) Slightly faster startup (no JIT)
Runtime limitations Full .NET functionality Some .NET features like reflection and code-generation aren't supported

High performance

The project is designed to be as performant as possible when bridging between .NET and JavaScript. Techniques benefitting performance include:

Thanks to these design choices, JS to .NET calls are more than twice as fast when compared to edge-js using that project's benchmark.

Generics

Even though the JavaScript runtime type system lacks generics, it is possible to work with .NET generic types and methods from JavaScript:

// JavaScript
System.Enum.Parse$(System.DayOfWeek)('Tuesday');

For details, see Using .NET Generics in JavaScript.

Getting Started

Requirements

Instructions

For calling .NET from JS, choose between one of the following scenarios:

For calling JS from .NET, more documentation will be added soon. For now, see the winui-fluid example code.

Generated TypeScript type definitions can be utilized with any of these aproaches.

Development

For information about building, testing, and contributing changes to this project, see README-DEV.md.

Contributing

This project welcomes contributions and suggestions. Most contributions require you to agree to a Contributor License Agreement (CLA) declaring that you have the right to, and actually do, grant us the rights to use your contribution. For details, visit https://cla.opensource.microsoft.com.

When you submit a pull request, a CLA bot will automatically determine whether you need to provide a CLA and decorate the PR appropriately (e.g., status check, comment). Simply follow the instructions provided by the bot. You will only need to do this once across all repos using our CLA.

This project has adopted the Microsoft Open Source Code of Conduct. For more information see the Code of Conduct FAQ or contact opencode@microsoft.com with any additional questions or comments.

Trademarks

This project may contain trademarks or logos for projects, products, or services. Authorized use of Microsoft trademarks or logos is subject to and must follow Microsoft's Trademark & Brand Guidelines. Use of Microsoft trademarks or logos in modified versions of this project must not cause confusion or imply Microsoft sponsorship. Any use of third-party trademarks or logos are subject to those third-party's policies.



.NET + JS scene