DupeNukem - WebView attachable full-duplex asynchronous interoperable independent messaging library between .NET and JavaScript.
Package | NuGet |
---|---|
DupeNukem | |
DupeNukem.Core |
General purpose WebView
attachable independent messaging (RPC like) library.
This library is intended for use with a browser component called WebView
(Edge2, CefSharp, Android, Celenium and etc) where asynchronous interoperation is not possible or is limited.
It is also independent of any specific WebView
implementation, so it can be applied to any WebView
you use.
The only requirement is to be able to send and receive strings to and from each other.
This is a diagrammatic representation of the message transfer performed by DupeNukem.
.NET side to call a function on the JavaScript side, the InvokePeerMethodAsync
method returns a Task
, so it can wait asynchronously:
Similarly, JavaScript side to call a method on the .NET side, the invokeHostMethod
function returns Promise
, so it can wait asynchronously too:
It is complemental design. Both .NET and JavaScript, we can design methods and functions assuming a nearly identical structure.
And with DupeNukem, you can use it for multi-platform WebView
based applications without having to use different implementations for each WebView
interface. The implementation can be standardized.
This may seem simple at first glance, but there are some difficult issues to be addressed, such as the following:
- Each call must be distinguished individually.
DupeNukem manages each call and correctly distinguishes between them, even if multiple calls exist in parallel. (Yes, it is ready for asynchronous parallelism using
Task.WhenAll
andPromise.all
and like.) - On
WebView
, only strings must be used as a means of communication. DupeNukem uses JSON as the communication format, but the user does not need to be aware of it, except for custom type conversions. This can be thought of as the same as the custom type constraints used for sending and receiving in ASP.NET WebAPI, etc.
Really? Now let's look at the actual calling code both side.
Invoke JavaScript functions from .NET side:
var result_add = await messenger.InvokePeerMethodAsync<int>(
"js_add", 1, 2);
var result_sub = await messenger.InvokePeerMethodAsync<int>(
"js_sub", 1, 2);
Invoke .NET methods from JavaScript side (using proxy objects):
// `Add` method
const result_Add = await dupeNukem.viewModels.calculator.add(1, 2);
// `dotnet_add` delegate
const result_add = await dotnet_add(1, 2);
Here is an example using:
Microsoft.Web.WebView2
on WPF. (Fully sample code is here)CefSharp.Wpf
on WPF. (Fully sample code is here)Xamarin.Forms
(Xam.Plugin.WebView
). (Fully sample code is here).NET MAUI
. (Fully sample code is here)
Setup sequence is gluing between WebView
and DupeNukem WebViewMessenger
.
DupeNukem uses only "strings" to exchange messages.
In the code example below (Edge WebView2 on WPF), Step 2 and Step 3 are also set up to mutually exchange message strings.
(Another browser components maybe same as setup process. See Gluing browsers section below.)
First time, you need to install DupeNukem package from NuGet. Then write initial sequence:
// Startup sequence.
// Bound between Edge WebView2 and DupeNukem WebViewMessenger.
// Step 1: Construct DupeNukem WebViewMessenger.
// Default timeout duration is 30sec.
var messenger = new WebViewMessenger();
//////////////////////////////////////////
// Step 2: Hook up .NET --> JavaScript message handler.
messenger.SendRequest += (s, e) =>
webView2.CoreWebView2.PostWebMessageAsString(e.Message);
// Step 3: Hook up JavaScript --> .NET message handler.
var serializer = Messenger.GetDefaultJsonSerializer();
webView2.CoreWebView2.WebMessageReceived += (s, e) =>
{
if (serializer.Deserialize(
new StringReader(e.WebMessageAsJson),
typeof(object))?.ToString() is { } m)
{
messenger.ReceivedRequest(m);
}
};
// Step 4: Injected Messenger script.
await webView2.CoreWebView2.AddScriptToExecuteOnDocumentCreatedAsync(
messenger.GetInjectionScript().ToString());
Bulk register methods on an object:
- Easy way, recommended.
- All methods automatically inject proxy functions in JavaScript side.
// Apply `CallableTarget` attribute on target callee method.
public class Calculator
{
[CallableTarget] // Automatic trimmed naming 'add'
public Task<int> AddAsync(int a, int b)
{
// ...
}
[CallableTarget("Subtract")] // Strictly naming
public Task<int> __Sub__123(int a, int b)
{
// ...
}
}
////////////////////////////////////////
// JS: `const result = await dupeNukem.viewModels.calculator.add(1, 2);`
// JS: `const result = await dupeNukem.viewModels.calculator.Subtract(1, 2);`
var calculator = new Calculator();
messenger.RegisterObject(calculator);
// JS: `const result = await calc.add(1, 2);`
// JS: `const result = await calc.Subtract(1, 2);`
messenger.RegisterObject("calc", calculator);
// JS: `const result = await add(1, 2);` // (Put on `window.add`)
// JS: `const result = await Subtract(1, 2);` // (Put on `window.Subtract`)
messenger.RegisterObject("", calculator);
Register methods around .NET side:
- Strict declarative each methods.
// JS: `const result = await dupeNukem.viewModels.mainWindowViewModel.add`
messenger.RegisterFunc<int, int, int>(this.Add);
// JS: `const result = await dupeNukem.viewModels.mainWindowViewModel.Subtract`
messenger.RegisterFunc<int, int, int>(this.__Sub__123);
// Or, register directly delegate with method name.
// JS: `const result = await dotnet_add(1, 2);`
messenger.RegisterFunc<int, int, int>(
"dotnet_add", (a, b) => Task.FromResult(a + b));
// JS: `const result = await dotnet_sub(1, 2);`
messenger.RegisterFunc<int, int, int>(
"dotnet_sub", (a, b) => Task.FromResult(a - b));
Declare functions around JavaScript side:
// Global functions:
// .NET: `var result = await messenger.InvokePeerMethodAsync("js_add", 1, 2);`
async function js_add(a, b) {
return a + b;
}
// .NET: `var result = await messenger.InvokePeerMethodAsync("js_sub", 1, 2);`
async function js_sub(a, b) {
return a - b;
}
// Member functions:
class Foo
{
async add(a, b) {
return a + b;
}
async sub(a, b) {
return a - b;
}
}
// .NET: `var result = await messenger.InvokePeerMethodAsync("foo.add", 1, 2);`
// .NET: `var result = await messenger.InvokePeerMethodAsync("foo.sub", 1, 2);`
var foo = new Foo();
NOTE: We have to put JavaScript object instance with var
keyword.
DupeNukem will fail invoking when use const
or let
keyword.
It is limitation for JavaScript specification.
DupeNukem can propagate callbacks passed as arguments. In effect, it allows for bidirectional method and function calls:
// .NET: Delegate callback functions to be passed to JS.
static Task<string> CallbackMethodAsync(int a, int b)
{
return Task.FromResult($"{a} and {b}");
}
// (Result: "Passed: 1 and 2")
var result = await messenger.InvokePeerMethodAsync<string>(
"js_callback", 1, 2, CallbackMethodAsync);`
// JS: Call the .NET callback.
async function js_callback(a, b, cb) {
const str = await cb(a, b);
return "Passed: " + str;
}
Callback calls in the opposite direction of the above are also possible:
// JS: Callback functions to be passed to .NET.
var result = await dotnet_callback(1, 2,
async (a, b) => {
return a + " and " + b;
});
// .NET: Call the JS callback.
public async Task<string> dotnet_callback(
int a, int b, Func<int, int, Task<string>> cb)
{
var str = await cb(a, b);
return $"Passed: {str}";
}
Delegates and functions can take up to 6 arguments.
The return value must be Task
or Promise
type.
It is possible to return the value of the result of asynchronous processing.
That is, Task<T>
and Promise<T>
are allowed.
Callback delegates and functions that take AbortSignal
type as
an argument can also contain AbortSignal
.
In that case, use that AbortController
and AbortSignal
for asynchronous processing cancellation of DupeNukem.
Callback delegates and functions are automatically collected by the both garbage collectors when they are no longer referenced by anyone. Therefore, there is no need to think about managing these objects. You can also store these objects somewhere when you receive them and call the callback when you need them.
DupeNukem can propagate exceptions to each other as exceptions without having to do anything. .NET and JavaScript, however, have different ways of expressing exceptions.
When an exception is raised on .NET:
public Task foo()
{
throw new ArgumentException("bar");
}
try {
dotnet.foo();
}
catch (e) { // <-- Error object
console.log(e.name + ": " + e.message);
// console.log(e.detail)
}
When an exception is raised on JavaScript:
async foo() {
throw new Error("foo");
}
try
{
await js.foo();
}
catch (PeerInvocationException ex)
{
Console.WriteLine(ex.Message);
// Console.WriteLine(ex.Detail);
}
The differences are shown below:
- .NET exception class and does not create an exception object on the JavaScript side with the same name.
On the JavaScript side,
Error
object is always thrown. - On the JavaScript side, you can refer to the type name by
Error.name
. - You can refer to the message (
Exception.Message
property) byError.message
. - On the .NET side, an instance of the
PeerInvocationException
class is always thrown. - .NET exception stack traces are not combined by default on the JavaScript side.
Because .NET side and JavaScript side stack traces are different, so cannot be combined.
However, by setting
Messenger.SendExceptionWithStackTrace
totrue
, .NET stack trace as a string to the JavaScript side. This value isfalse
by default, for safety reasons. The stack trace is stored inError.detail
. - Similarly, the stack trace on the JavaScript side cannot be combined on the .NET. Combined when be provided by the hosted JavaScript engine.
Additional information can be placed in the exception, but there are conditions for propagating this information:
- Valid only for .NET exception classes.
- The
ExceptionProperty
attribute must be applied.
public class FooException : Exception
{
// Indicating additional information to the JavaScript side.
[ExceptionProperty]
public int StatusCode { get; }
public FooException(int statusCode, string message) :
base(message) =>
this.StatusCode = statusCode;
}
try {
dotnet.foo();
}
catch (e) {
console.log(e.props.statusCode);
}
On the JavaScript side, you can access Error.props
as above to get the relevant additional information.
.NET has the CancellationToken
type as the standard infrastructure for
asynchronous processing.
JavaScript has an AbortSignal
that can be used to notify cancellation.
DupeNukem automatically converts AbortSignal
to CancellationToken
.
AbortSignal
is an ECMAScript standard type. See AbortSignal - MDN for details.
// Prepare a AbortController
const ac = new AbortController();
// Setup canceler:
document.getElementById("cancelButton").onclick =
// Calls abort() function to signal cancelling.
() => ac.abort();
try {
// Invoke .NET method asynchronously:
const resut = await
dupeNukem.viewModels.mainWindowViewModel.
longAwaitedMethod(1, 2, ac.signal); // <-- Send AbortSignal object.
}
catch (e) {
// An exception is thrown when a cancellation occurs.
}
.NET implementation:
[CallableTarget]
public async Task<int> LongAwaitedMethodAsync(
int a, int b, CancellationToken ct) // <-- Automatic conversion from AbortSignal.
{
// Pass a CancellationToken to a time-consuming asynchronous process:
await Task.Delay(1000, ct);
return a + b;
}
NOTE:
AbortSignal
argument(s) can be defined anywhere in the argument set.- Includes inside nested object fields.
- The above example is a call in the JavaScript --> .NET direction.
.NET --> JavaScript direction calls are not yet allowed to use
CancellationToken
in 0.26.0.
In JavaScript --> .NET method invoking, the following JavaScript debugging aids are available
if the Obsolete
attribute is applied to the .NET method.
If the normal Obsolete
attribute is applied,
the following warning message will appear in the JavaScript console output:
[CallableTarget]
[Obsolete("This method will be obsoleted, switch to use `add_ng`.")]
public static Task<int> AddAsync(int a, int b)
{
// ...
}
calc.add is obsoleted: This method will be obsoleted, switch to use `add_ng`.
Also, if an error flag is applied to the Obsolete
attribute,
an exception will be thrown on the fly:
[CallableTarget]
[Obsolete("This method is obsoleted, have to switch `add_ng`.", true)]
public static Task<int> AddAsync(int a, int b) =>
// ...
try {
consr r = await calc.add(1, 2);
}
catch (e) {
// Raise error: calc.add is obsoleted: This method is obsoleted, have to switch `add_ng`.
}
Note: This function is only available for proxy access,
and will not work if called using the invokeHostMethod()
function.
There are examples for gluing sample code between your app and browser components.
// WebView2 webView2;
// Step 2: Hook up .NET --> JavaScript message handler.
messenger.SendRequest += (s, e) =>
Dispatcher.CurrentDispatcher.Invoke(() =>
webView2.CoreWebView2.PostWebMessageAsString(e.JsonString));
// Step 3: Hook up JavaScript --> .NET message handler.
var serializer = Messenger.GetDefaultJsonSerializer();
webView2.CoreWebView2.WebMessageReceived += (s, e) =>
{
if (serializer.Deserialize(
new StringReader(e.WebMessageAsJson),
typeof(object))?.ToString() is { } m)
{
messenger.ReceivedRequest(m);
}
};
// Step 4: Injected Messenger script.
await webView2.CoreWebView2.AddScriptToExecuteOnDocumentCreatedAsync(
messenger.GetInjectionScript().ToString());
The only difference between Windows Forms and WPF is the marshalling method to the main thread.
// Step 2: Hook up .NET --> JavaScript message handler.
messenger.SendRequest += (s, e) =>
this.Invoke(() =>
webView2.CoreWebView2.PostWebMessageAsString(e.JsonString));
// ChromiumWebBrowser cefSharp;
// Step 2: Hook up .NET --> JavaScript message handler.
messenger.SendRequest += (s, e) =>
Dispatcher.CurrentDispatcher.Invoke(() =>
cefSharp.BrowserCore.MainFrame.ExecuteJavaScriptAsync(
e.ToJavaScript()));
// Step 3: Attached JavaScript --> .NET message handler.
cefSharp.JavascriptMessageReceived += (s, e) =>
messenger.ReceivedRequest(e.Message.ToString());
// Step 4: Injected Messenger script.
var script = messenger.GetInjectionScript();
cefSharp.FrameLoadEnd += (s, e) =>
{
if (e.Frame.IsMain)
{
cefSharp.BrowserCore.MainFrame.ExecuteJavaScriptAsync(
script.ToString());
}
};
Xamarin Forms provides a WebView
control as a common basis for displaying a web browser.
However, interoperating with JavaScript requires different implementations for each platform, such as Android and iOS.
I assume that this is because the same web browsers are used, Chrome for Android and Safari for iOS.
One package that alleviates such cumbersome implementation is Xam.Plugin.Webview project. Here is an example of using this package:
// FormsWebView formsWebView;
// Step 2: Hook up .NET --> JavaScript message handler.
messenger.SendRequest += (s, e) =>
Application.Current.Dispatcher.BeginInvokeOnMainThread(() =>
formsWebView.InjectJavascriptAsync(e.ToJavaScript()));
// Step 3: Attached JavaScript --> .NET message handler.
formsWebView.AddLocalCallback(
WebViewMessenger.PostMessageSymbolName,
messenger.ReceivedRequest);
// Step 4: Injected Messenger script.
var script = messenger.GetInjectionScript();
formsWebView.OnNavigationCompleted += (s, url) =>
formsWebView.InjectJavascriptAsync(script.ToString());
.NET MAUI has a standard WebView
control.
However, this control lacks for sending messages from JavaScript to .NET.
Therefore, you will need to implement these glue codes for each platform yourself. Examples for Windows and Android are placed in the sample code for your reference.
The following is a rough outline of the work required to achieve this:
- implement a
JavaScriptMultiplexedWebView
control derived fromWebView
that can receive messages from JavaScript. - implement a platform specific handler
JavaScriptMultiplexedWebViewHandler
for the above control. - Register the above handler at application startup.
Once these are in place, you can set up DupeNukem as follows:
// Step 2: Hook up .NET --> JavaScript message handler.
messenger.SendRequest += async (s, e) =>
{
// Marshal to main thread.
if (await UIThread.TryBind())
{
await webView.InvokeJavaScriptAsync(e.ToJavaScript());
}
};
// Step 3: Attached JavaScript --> .NET message handler.
webView.MessageReceived += (s, e) => messenger.ReceivedRequest(e.Message);
// Step 4: Injected Messenger script.
var script = messenger.GetInjectionScript(true);
webView.Navigated += (s, e) =>
{
if (e.Source is UrlWebViewSource eu &&
webView.Source is UrlWebViewSource wu &&
eu.Url == wu.Url)
{
webView.InvokeJavaScriptAsync(script.ToString());
}
};
TODO: WIP
In the case of Celenium WebDriver, there is no standard way to notify message strings from the browser component to .NET side.
In this example (Step 3), the alert()
function is used to notify a message strings.
.NET side, the message is passed to DupeNukem when the alert occurs.
// IWebDriver driver;
// Step 2: Hook up .NET --> JavaScript message handler.
messenger.SendRequest += (s, e) =>
driver.ExecuteJavaScript(e.ToJavaScript());
// Step 3: Attached JavaScript --> .NET message handler.
var alert = wait.Until(ExpectedConditions.AlertIsPresent());
messenger.ReceivedRequest(alert.Text);
alert.Accept();
// Step 4: Injected Messenger script.
var script = messenger.GetInjectionScript();
driver.Navigated += (s, e) =>
{
if (e.Result == WebNavigationResult.Success &&
e.Url == webView.Source)
{
driver.ExecuteJavaScript(script.ToString());
}
};
Apache-v2.
- 0.31.0:
- Improved stability for invoking with AbortSignals.
- 0.30.0:
- Fixed base64 conversion problem.
- 0.29.0:
- Renamed
JsonToken
toJsonElement
, because VS intellisense is crashed by symbol confiction. - Improved
JsonElement
conversions.
- Renamed
- 0.28.0:
- Supported
JsonToken
type sets. If you want to handle untyped JSON using theJToken
type, use it instead and the serialization will be correct.
- Supported
- 0.27.0:
- Supported serialization for JavaScript
ArrayBuffer
,Uint8Array
andUint8ClampedArray
from/to .NETbyte[]
. - Refactored object referencing handlers (In
AbortSignal
and function closures.)
- Supported serialization for JavaScript
- 0.26.0:
- Added MAUI sample project.
- Switched cancellation object to
AbortSignal
ECMAScript standard object instead ofCancellationToken
.- You can continue to use
CancellationToken
now, but marked obsoleted and will be removed in future release.
- You can continue to use
- Rolled back full-duplex cancellation infrastructure (in 0.23.0), because it is buggy.
- Replaced implementation on 0.22 branch based.
- 0.25.0, 0.22.10:
- Fixed race condition when DupeNukem GC trimmer has arrived.
- 0.24.0:
- Improved avoidance for another message processor confliction. #18
- Fixed causing duplicate OperationCancelledError symbol.
- Fixed ignoring closure discarder message.
- 0.23.0:
- Re-implemented full-duplex cancellation infrastructure.
- 0.22.0:
- Supported callback delegates/functions on the arguments.
- 0.21.0:
- Added
ExceptionProperty
attribute.
- Added
- 0.20.0:
- Removed obsoleted fragments.
- 0.19.0:
- Changed
PeerInvocationException
instead ofJavaScriptException
. - Added some deconstructor for entity classes.
- Changed
- 0.18.0:
- Added trim 'Async' from method name feature.
- 0.17.2:
- Exposed control message interface on core library.
- 0.17.1:
- Adjusted more signature and scope attributes.
- 0.17.0:
- Implemented
IMessenger
neutral interface. Please fix indicating at obsolete warnings:InvokeClientFunctionAsync(...)
==>InvokePeerMethodAsync(...)
- Fixed some method signature type nullability.
- Implemented
- 0.16.0:
- Splitted core library into new
DupeNukem.Core
package, because need to be usable pure interoperation infrastructure. Please fix indicating at obsolete warnings:new Messenger(...)
==>new WebViewMessenger(...)
JavaScriptTargetAttribute
==>CallableTargetAttribute
- Fixed failing to notify caught exception on JavaScript side before promise context.
- Changed sample Edge WebView2 gluing code.
- Splitted core library into new
- 0.15.0:
- Upgraded
Newtonsoft.Json
to 13.0.1 (See Vulnerability: Improper Handling of Exceptional Conditions in Newtonsoft.Json - GitHub Advisory Database)
- Upgraded
- 0.14.0:
- Fixed causing duplicated key exception when derived class has same named overrided expose method.
- 0.13.0:
- Changed showing trace message instead raise exception when SendRequest aren't hooked.
- 0.12.0:
- Unhooked any events on calling Dispose method. (avoid memory leaks)
- 0.11.0:
- Help debugging by warning log and raise exception at JavaScript when , .NET method is marked with
Obsolete
attribute. - Fixed registering implicit proxy methods at around browser reloading.
- Help debugging by warning log and raise exception at JavaScript when , .NET method is marked with
- 0.10.0:
- Supported
CancellationToken
when JavaScript --> .NET direction calling.
- Supported
- 0.9.0:
- Fixed didn't initialize on XF iOS.
- 0.8.0:
- Fixed causing InvalidMethodName exception when use longer method name.
- 0.7.0:
- Supported CefSharp and Xamarin Forms.
- 0.6.0:
- Supported proxy object on JavaScript side.
- Implemented automatic thread marshaling (No need for marshalling to UI threads as manually.)
- 0.5.0:
- Supported customize json format with
JsonSerializer
and made defaults with camel-casing serialization. - Made defaults for all symbol naming to camel case.
- Added more target platforms.
- Supported customize json format with
- 0.4.0:
- New bulk register methods on an object by
RegisterObject(obj)
method. - Fixed invoking silent result with invalid method name.
- New bulk register methods on an object by
- 0.3.0:
- Implemented flexible argument type handling.
- 0.2.0:
- Improved enum type handling.
- 0.1.0:
- Initial release.