Overview
Target Frameworks
Prerequisites
Installation
Concepts
Usage
API
Extensibility
Performance
Building
Related Projects
Contributing
About
This library provides ways to invoke javascript in NodeJS, from .Net applications. On top of providing a way to invoke javascript from .js files on disk,
this library provides ways to invoke in-memory javascript in string or Stream form, as well as logic in the NodeJS cache.
- .NET Standard 1.3
- .NET Standard 2.0
NodeJS must be installed and node.exe's directory must be added to the Path environment variable.
Using Package Manager:
PM> Install-Package Jering.Javascript.NodeJS
Using .Net CLI:
> dotnet add package Jering.Javascript.NodeJS
Familiarity with the following concepts will make it easier to use this library effectively. I've included this section for the benefit of those who haven't had much experience with NodeJS, if you're already familiar with the following concepts, feel free to skip this section.
NodeJS is a javascript runtime. Essentially, it provides some built-in libraries and executes javascript. Similarities can be drawn to the Core Common Language Runtime (CoreCLR), which provides a set of base libraries and executes .NET Intermediate Language (typically generated by compiling C# or some other .NET language).
Under the hood, NodeJS uses V8 to execute javascript. While this library could have been built to invoke javascript directly in V8, invoking javascript in NodeJS affords both access to NodeJS's built-in modules and the ability to use most of the modules hosted by npm.
NodeJS modules are a kind of javascript module. The concept of javascript modules can seem far more complicated than it really is, not least because of the existence of competing specifications (CommonJS, AMD, ES6, ...), and the existence of multiple implementations of each specification (SystemJS, RequireJS, Dojo, NodeJS, ...). In reality, javascript modules such as NodeJS modules are really simple. In the following sections, we will go through the what, how and why of NodeJS modules.
The following is a valid NodeJS module. Lets imagine that it exists in a file, flavours.js:
// Note that the module variable isn't declared (no "var module = {}")
module.exports = ['chocolate', 'strawberry', 'vanilla'];The following is another valid NodeJS module, we will use it as an entry script (to be supplied to node on the command line). Lets imagine that it exists in a file, printer.js,
in the same directory as flavours.js:
var flavours = require('./flavours.js');
flavours.forEach((flavour) => console.log(flavour));If we run node printer.js on the command line, the flavours get printed:
PS C:\NodeJS_Modules_Example> node printer.js
chocolate
strawberry
vanillaIn general, a NodeJS module is simply a block of javascript with module.exports and/or require statements. These statements are explained in the next section.
NodeJS's logic for managing modules is contained in its require function. In the example above, require('./flavours.js') executes the following steps:
- Resolves the absolute path of
flavours.jstoC:/NodeJS_Modules_Example/flavours.js. - Checks whether the NodeJS module cache (a simple javascript object) has a property with name
C:/NodeJS_Modules_Example/flavours.js, and finds that it does not (the module has not been cached). - Reads the contents of
C:/NodeJS_Modules_Example/flavours.jsinto memory. - Wraps the contents of
C:/NodeJS_Modules_Example/flavour.jsin a function by appending and prepending strings. The resulting function looks like the following:// Note how the require function and a module object are supplied by the wrapper. function (exports, require, module, __filename, __dirname){ module.exports = ['chocolate', 'strawberry', 'vanilla']; }
- Creates the
moduleobject and passes it to the generated function. - Adds the
moduleobject (now containing an array as itsexportsproperty) to the NodeJS module cache using the property nameC:/NodeJS_Modules_Example/flavours.js. - Returns
module.exports.
If the flavours module is required again, the cached module object is retrieved in step 2, and its exports object is returned. This means that module exports are not immutable, for example,
if we replace the contents of printer.js with the following:
var flavours = require('./flavours.js');
flavours.forEach((flavour) => console.log(flavour));
// Clear the array
flavours.length = 0;
// Add three new flavours
flavours.push('apple');
flavours.push('green tea');
flavours.push('sea salt');
// Require the module again, turns out that require returns a reference to the same array
flavours = require('./flavours.js');
flavours.forEach((flavour) => console.log(flavour));Running node printer.js on the command line prints the following flavours:
PS C:\Users\Jeremy\Desktop\JSTest> node entry.js
chocolate
strawberry
vanilla
apple
green tea
sea saltTo answer this question, lets consider the impetus for the creation of javascript modules in general. Web pages used to include scripts like so:
<html>
...
<script type="text/javascript" src="path/to/coolLibrary.js"></script>
<script type="text/javascript" src="path/to/myScript.js"></script>
...
</html>Browsers would load the scripts like so:
// Contents of coolLibrary.js
var coolLibraryPrivateObject = ...;
function CoolLibraryPublicFunction(){
... // Do something with coolLibraryPrivateObject, and return some value
}
// Contents of myScript.js
var myVar = CoolLibraryPublicFunction();
... // Do something with myVarNote how everything in the example above is in the same scope. coolLibraryPrivateObject can be accessed in myscript.js. How
can we hide the private object? We can encapsulate cool library in a function:
var module = {};
// This is an immediately invoked function expression, shorthand for assigning the function to a variable then calling it - https://developer.mozilla.org/en-US/docs/Glossary/IIFE
(function(module){
// Contents of coolLibrary.js
var coolLibraryPrivateObject = ...;
function CoolLibraryPublicFunction(){
... // Do something with coolLibraryPrivateObject, and return some value
}
module.exports = CoolLibraryPublicFunction;
})(module)
// Contents of myScript.js
var myVar = module.exports(); // We assigned CoolLibraryPublicFunction to module.exports
... // Do something with myVarWe've successfully hidden coolLibraryPrivateObject from the global scope using a module-esque pattern. Apart from hiding private objects, this pattern also prevents global namespace pollution.
NodeJS modules serve a similar purpose. By wrapping modules in functions, NodeJS creates a closure for each module so internal details can be kept private.
This library uses depedency injection (DI) to facilitate extensibility and testability. You can use any DI framework that has adapters for Microsoft.Extensions.DependencyInjection. Here, we'll use the vanilla Microsoft.Extensions.DependencyInjection framework:
var services = new ServiceCollection();
services.AddNodeJS();
ServiceProvider serviceProvider = services.BuildServiceProvider();
INodeJSService nodeJSService = serviceProvider.GetRequiredService<INodeJSService>();The default implementation of INodeJSService is HttpNodeJSService. It starts a Http server in a NodeJS process and sends invocation requests
over Http. For simplicty's sake, this ReadMe assumes that INodeJSService's default implementation is used.
INodeJSService is a singleton service and INodeJSService's members are thread safe.
Where possible, inject INodeJSService into your types or keep a reference to a shared INodeJSService instance.
Try to avoid creating multiple INodeJSService instances since by default, each instance spawns a NodeJS process.
When you're done, you can manually dispose of an INodeJSService instance by calling
nodeJSService.Dispose();or
serviceProvider.Dispose(); // Calls Dispose on objects it has instantiated that are disposableDispose kills the spawned NodeJS process.
Note that even if Dispose isn't called manually, INodeJSService will kill the
NodeJS process when the application shuts down - if the application shuts down gracefully. If the application doesn't shutdown gracefully, the NodeJS process will kill
itself when it detects that its parent has been killed.
Essentially, manually disposing of INodeJSService instances is not mandatory.
To invoke javascript, we'll first need to create a NodeJS module that exports a function or an object containing functions. These functions must take a callback as their first argument, and they must call the callback. The callback takes two optional arguments:
- The first argument must be an error or an error message. It must be an instance of type
Erroror astring. - The second argument is the result. It must be an instance of a JSON-serializable type, a
string, or astream.Readable.
This sort of callback is known as an error-first callback. Such callbacks are commonly used for error handling in NodeJS asynchronous code (check out the NodeJS event loop if you'd like to learn more about how asynchrony works in NodeJS).
This is a module that exports a valid function:
module.exports = (callback) => {
... // Do something
callback(null, result);
}And this is a module that exports an object containing valid functions:
module.exports = {
doSomething: (callback) => {
... // Do something
callback(null, result);
},
doSomethingElse: (callback) => {
... // Do something else
callback(null, result);
}
}If we have a file named exampleModule.js (located in NodeJSProcessOptions.ProjectPath), with contents:
module.exports = (callback, message) => callback(null, { resultMessage: message });And we have the class Result:
public class Result
{
public string ResultMessage { get; set; }
}We can invoke the javascript using InvokeFromFileAsync:
Result result = await nodeJSService.InvokeFromFileAsync<Result>("exampleModule.js", args: new[] { "success" });
Assert.Equal("success", result.ResultMessage);If we change exampleModule.js to export an object containing functions:
module.exports = {
appendExclamationMark: (callback, message) => callback(null, { resultMessage: message + '!' }),
appendFullStop: (callback, message) => callback(null, { resultMessage: message + '.' })
}We can invoke javascript by providing an export's name to InvokeFromFileAsync:
Result result = await nodeJSService.InvokeFromFileAsync<Result>("exampleModule.js", "appendExclamationMark", args: new[] { "success" });
Assert.Equal("success!", result.ResultMessage);When using InvokeFromFileAsync, NodeJS always caches the module, using the absolute path of the .js file as the module's cache identifier. This is great for
performance, since the file will not be read more than once.
We can invoke javascript in string form using InvokeFromStringAsync :
Result result = await nodeJSService.InvokeFromStringAsync<Result>("module.exports = (callback, message) => callback(null, { resultMessage: message });",
args: new[] { "success" });
Assert.Equal("success", result.ResultMessage);If we're going to invoke the module repeatedly, it would make sense to have NodeJS cache the module so that it doesn't need to be kept in memory and sent with every invocation. To cache the module, we must specify a custom cache identifier, since unlike a file, a string has no "absolute file path" for NodeJS to use as a cache identifier. Once NodeJS has cached the module, we should invoke logic directly from the NodeJS cache:
string cacheIdentifier = "exampleModule";
// Try to invoke from the NodeJS cache
(bool success, Result result) = await nodeJSService.TryInvokeFromCacheAsync<Result>(cacheIdentifier, args: new[] { "success" });
// If the NodeJS process dies and restarts, the module will have to be re-cached, so we must always check whether success is false
if(!success)
{
// Retrieve the module string, this is a trivialized example for demonstration purposes
string moduleString = "module.exports = (callback, message) => callback(null, { resultMessage: message });";
// Cache and invoke the module
result = await nodeJSService.InvokeFromStringAsync<Result>(moduleString, cacheIdentifier, args: new[] { "success" });
}
Assert.Equal("success", result.ResultMessage);Like when invoking javascript form a file, if the module exports an object containing functions, we can invoke a function by specifying an export's name.
We can invoke javascript in Stream form using InvokeFromStreamAsync :
using (var memoryStream = new MemoryStream())
using (var streamWriter = new StreamWriter(memoryStream))
{
// Write the module to a MemoryStream for demonstration purposes.
streamWriter.Write("module.exports = (callback, message) => callback(null, {resultMessage: message});");
streamWriter.Flush();
memoryStream.Position = 0;
Result result = await nodeJSService.InvokeFromStreamAsync<Result>(memoryStream, args: new[] { "success" });
Assert.Equal("success", result.ResultMessage);
}InvokeFromStreamAsync behaves in a similar manner to InvokeFromStringAsync, refer to Invoking Javascript in String Form for details on caching and more.
The utility of this method is in providing a way to avoid allocating a string if the source of the module is a Stream. Avoiding string allocations can improve performance.
This library uses the ASP.NET Core options pattern. While developed for ASP.NET Core, this pattern can be used by other types of applications. The NodeJS process and the service that manages the process are both configurable, for example:
var services = new ServiceCollection();
services.AddNodeJS();
// Options for the NodeJSProcess, here we enable debugging
services.Configure<NodeJSProcessOptions>(options => options.NodeAndV8Options = "--inspect-brk");
// Options for the service that manages the process, here we make its timeout infinite
services.Configure<OutOfProcessNodeJSServiceOptions>(options => options.TimeoutMS = -1);
ServiceProvider serviceProvider = services.BuildServiceProvider();
INodeJSService nodeJSService = serviceProvider.GetRequiredService<INodeJSService>();The next two sections list all available options.
| Option | Type | Description | Default |
|---|---|---|---|
| ProjectPath | string |
The base path for resolving paths of NodeJS modules on disk. | If the application is an ASP.NET Core application, this value defaults to IHostingEnvironment.ContentRootPath. Otherwise, it defaults to the current working directory. |
| NodeAndV8Options | string |
NodeJS and V8 options in the form "[NodeJS options] [V8 options]". The full list of NodeJS options can be found here: https://nodejs.org/api/cli.html#cli_options. | null |
| Port | int |
The port that the server running on NodeJS will listen on. If set to 0, the OS will choose the port. | 0 |
| EnvironmentVariables | IDictionary<string, string> |
The environment variables for the NodeJS process. The full list of NodeJS environment variables can be found here: https://nodejs.org/api/cli.html#cli_environment_variables. | null |
| Option | Type | Description | Default |
|---|---|---|---|
| TimeoutMS | int |
The maximum duration to wait for the NodeJS process to initialize and to wait for responses to invocations. If set to -1, the maximum duration will be infinite. | 10000 |
These are the steps for debugging javascript invoked using INodeJSService:
- Create an INodeJSService using the example options in the previous section (
NodeJSProcessOptions.NodeAndV8Options=--inspect-brkandOutOfProcessNodeJSServiceOptions.TimeoutMS=-1). - Add
debuggerstatements to your javascript module. - Call a javascript invoking method.
- Navigate to
chrome://inspect/#devicein Chrome. - Click "Open dedicated DevTools for Node".
- Click continue to advance to your
debuggerstatements.
Task<T> InvokeFromFileAsync<T>(string modulePath, string exportName = null, object[] args = null, CancellationToken cancellationToken = default(CancellationToken));Invokes a function exported by a NodeJS module on disk.
-
T- Description: The type of object this method will return. It can be a JSON-serializable type,
string, orStream.
- Description: The type of object this method will return. It can be a JSON-serializable type,
-
modulePath- Type:
string - Description: The path to the NodeJS module (i.e., JavaScript file) relative to
NodeJSProcessOptions.ProjectPath.
- Type:
-
exportName- Type:
string - Description: The function in the module's exports to be invoked. If unspecified, the module's exports object is assumed to be a function, and is invoked.
- Type:
-
args- Type:
object[] - Description: The sequence of JSON-serializable and/or
stringarguments to be passed to the function to invoke.
- Type:
-
cancellationToken- Type:
CancellationToken - Description: The cancellation token for the asynchronous operation.
- Type:
The task object representing the asynchronous operation.
InvocationException- Thrown if a NodeJS error occurs.
- Thrown if the invocation request times out.
- Thrown if NodeJS cannot be initialized.
If we have a file named exampleModule.js (located in NodeJSProcessOptions.ProjectPath), with contents:
module.exports = (callback, message) => callback(null, { resultMessage: message });And we have the class Result:
public class Result
{
public string ResultMessage { get; set; }
}The following assertion will pass:
Result result = await nodeJSService.InvokeFromFileAsync<Result>("exampleModule.js", args: new[] { "success" });
Assert.Equal("success", result.ResultMessage);Task<T> InvokeFromStringAsync<T>(string moduleString, string newCacheIdentifier = null, string exportName = null, object[] args = null, CancellationToken cancellationToken = default(CancellationToken));Invokes a function exported by a NodeJS module in string form.
-
T- Description: The type of object this method will return. It can be a JSON-serializable type,
string, orStream.
- Description: The type of object this method will return. It can be a JSON-serializable type,
-
moduleString- Type:
string - Description: The module in
stringform.
- Type:
-
newCacheIdentifier- Type:
string - Description: The modules's cache identifier in the NodeJS module cache. If unspecified, the module will not be cached.
- Type:
-
exportName- Type:
string - Description: The function in the module's exports to be invoked. If unspecified, the module's exports object is assumed to be a function, and is invoked.
- Type:
-
args- Type:
object[] - Description: The sequence of JSON-serializable and/or
stringarguments to be passed to the function to invoke.
- Type:
-
cancellationToken- Type:
CancellationToken - Description: The cancellation token for the asynchronous operation.
- Type:
The task object representing the asynchronous operation.
InvocationException- Thrown if a NodeJS error occurs.
- Thrown if the invocation request times out.
- Thrown if NodeJS cannot be initialized.
Using the class Result:
public class Result
{
public string ResultMessage { get; set; }
}The following assertion will pass:
Result result = await nodeJSService.InvokeFromStringAsync<Result>("module.exports = (callback, message) => callback(null, { resultMessage: message });",
args: new[] { "success" });
Assert.Equal("success", result.ResultMessage);Task<T> InvokeFromStreamAsync<T>(Stream moduleStream, string newCacheIdentifier = null, string exportName = null, object[] args = null, CancellationToken cancellationToken = default(CancellationToken));Invokes a function exported by a NodeJS module in Stream form.
-
T- Description: The type of object this method will return. It can be a JSON-serializable type,
string, orStream.
- Description: The type of object this method will return. It can be a JSON-serializable type,
-
moduleStream- Type:
Stream - Description: The module in
Streamform.
- Type:
-
newCacheIdentifier- Type:
string - Description: The modules's cache identifier in the NodeJS module cache. If unspecified, the module will not be cached.
- Type:
-
exportName- Type:
string - Description: The function in the module's exports to be invoked. If unspecified, the module's exports object is assumed to be a function, and is invoked.
- Type:
-
args- Type:
object[] - Description: The sequence of JSON-serializable and/or
stringarguments to be passed to the function to invoke.
- Type:
-
cancellationToken- Type:
CancellationToken - Description: The cancellation token for the asynchronous operation.
- Type:
The task object representing the asynchronous operation.
InvocationException- Thrown if a NodeJS error occurs.
- Thrown if the invocation request times out.
- Thrown if NodeJS cannot be initialized.
Using the class Result:
public class Result
{
public string ResultMessage { get; set; }
}The following assertion will pass:
using (var memoryStream = new MemoryStream())
using (var streamWriter = new StreamWriter(memoryStream))
{
// Write the module to a MemoryStream for demonstration purposes.
streamWriter.Write("module.exports = (callback, message) => callback(null, {resultMessage: message});");
streamWriter.Flush();
memoryStream.Position = 0;
Result result = await nodeJSService.InvokeFromStreamAsync<Result>(memoryStream, args: new[] { "success" });
Assert.Equal("success", result.ResultMessage);
}Task<(bool, T)> TryInvokeFromCacheAsync<T>(string moduleCacheIdentifier, string exportName = null, object[] args = null, CancellationToken cancellationToken = default(CancellationToken));Attempts to invoke a function exported by a NodeJS module cached by NodeJS.
-
T- Description: The type of object this method will return. It can be a JSON-serializable type,
string, orStream.
- Description: The type of object this method will return. It can be a JSON-serializable type,
-
moduleCacheIdentifier- Type:
string - Description: The cache identifier of the module.
- Type:
-
exportName- Type:
string - Description: The function in the module's exports to be invoked. If unspecified, the module's exports object is assumed to be a function, and is invoked.
- Type:
-
args- Type:
object[] - Description: The sequence of JSON-serializable and/or
stringarguments to be passed to the function to invoke.
- Type:
-
cancellationToken- Type:
CancellationToken - Description: The cancellation token for the asynchronous operation.
- Type:
The task object representing the asynchronous operation. On completion, the task returns a (bool, T) with the bool set to true on
success and false otherwise.
InvocationException- Thrown if a NodeJS error occurs.
- Thrown if the invocation request times out.
- Thrown if NodeJS cannot be initialized.
Using the class Result:
public class Result
{
public string ResultMessage { get; set; }
}The following assertion will pass:
// Cache the module
string cacheIdentifier = "exampleModule";
await nodeJSService.InvokeFromStringAsync<Result>("module.exports = (callback, message) => callback(null, { resultMessage: message });",
cacheIdentifier,
args: new[] { "success" });
// Invoke from cache
(bool success, Result result) = await nodeJSService.TryInvokeFromCacheAsync<Result>(cacheIdentifier, args: new[] { "success" });
Assert.True(success);
Assert.Equal("success", result.ResultMessage);This library's behaviour can be customized by implementing public interfaces and overwriting their default DI services. For example, if we have objects that
can't be serialized using the default JSON serialization logic, we can implement IJsonService:
// Create a custom implementation of IJsonService
public class MyJsonService : IJsonService
{
public T Deserialize<T>(JsonReader jsonReader)
{
... // Custom deserializetion logic
}
public void Serialize(JsonWriter jsonWriter, object value)
{
... // Custom serialization logic
}
}And overwrite its default DI service:
var services = new ServiceCollection();
services.AddNodeJS();
// Overwrite the default DI service
services.AddSingleton<IJsonService, MyJsonService>();
ServiceProvider serviceProvider = services.BuildServiceProvider();
INodeJSService nodeJSService = serviceProvider.GetRequiredService<INodeJSService>();This is the list of implementable interfaces:
| Interface | Description |
|---|---|
IJsonService |
An abstraction for JSON serialization/deserialization. |
IHttpClientService |
An abstraction for HttpClient. |
INodeJSProcessFactory |
An abstraction for NodeJS process creation. |
IHttpContentFactory |
An abstraction for HttpContent creation. |
INodeJSService |
An abstraction for invoking code in NodeJS. |
IEmbeddedResourcesService |
An abstraction for reading of embedded resources. |
This library is heavily inspired by Microsoft.AspNetCore.NodeServices. While the main additions to this library are ways to invoke in-memory javascript, this library also provides better performance (note that INodeServices has only 1 benchmark because it only supports invoking javascript from a file):
| Method | Mean | Error | StdDev | Gen 0 | Allocated |
|---|---|---|---|---|---|
| INodeJSService_InvokeFromFile | 0.1148 ms | 0.001062 ms | 0.0009932 ms | 2.1973 | 3.36 KB |
| INodeJSService_InvokeFromCache | 0.1084 ms | 0.002043 ms | 0.0020064 ms | 2.3193 | 3.28 KB |
| INodeServices | 0.1326 ms | 0.001228 ms | 0.0011483 ms | 1.9531 | 4.14 KB |
The benchmarks.
This project can be built using Visual Studio 2017.
Jering.Web.SyntaxHighlighters.HighlightJS - Use the Syntax Highlighter, HighlightJS, from C#. Jering.Web.SyntaxHighlighters.Prism - Use the Syntax Highlighter, Prism, from C#.
Contributions are welcome!
Follow @JeremyTCD for updates and more.