aspnetcore/src/Microsoft.JSInterop/DotNetDispatcher.cs

192 lines
10 KiB
C#

// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Threading.Tasks;
namespace Microsoft.JSInterop
{
/// <summary>
/// Provides methods that receive incoming calls from JS to .NET.
/// </summary>
public static class DotNetDispatcher
{
private static ConcurrentDictionary<string, IReadOnlyDictionary<string, (MethodInfo, Type[])>> _cachedMethodsByAssembly
= new ConcurrentDictionary<string, IReadOnlyDictionary<string, (MethodInfo, Type[])>>();
/// <summary>
/// Receives a call from JS to .NET, locating and invoking the specified method.
/// </summary>
/// <param name="assemblyName">The assembly containing the method to be invoked.</param>
/// <param name="methodIdentifier">The identifier of the method to be invoked. The method must be annotated with a <see cref="JSInvokableAttribute"/> matching this identifier string.</param>
/// <param name="argsJson">A JSON representation of the parameters.</param>
/// <returns>A JSON representation of the return value, or null.</returns>
public static string Invoke(string assemblyName, string methodIdentifier, string argsJson)
{
// This method doesn't need [JSInvokable] because the platform is responsible for having
// some way to dispatch calls here. The logic inside here is the thing that checks whether
// the targeted method has [JSInvokable]. It is not itself subject to that restriction,
// because there would be nobody to police that. This method *is* the police.
var syncResult = InvokeSynchronously(assemblyName, methodIdentifier, argsJson);
return syncResult == null ? null : Json.Serialize(syncResult);
}
/// <summary>
/// Receives a call from JS to .NET, locating and invoking the specified method asynchronously.
/// </summary>
/// <param name="callId">A value identifying the asynchronous call that should be passed back with the result, or null if no result notification is required.</param>
/// <param name="assemblyName">The assembly containing the method to be invoked.</param>
/// <param name="methodIdentifier">The identifier of the method to be invoked. The method must be annotated with a <see cref="JSInvokableAttribute"/> matching this identifier string.</param>
/// <param name="argsJson">A JSON representation of the parameters.</param>
/// <returns>A JSON representation of the return value, or null.</returns>
public static void BeginInvoke(string callId, string assemblyName, string methodIdentifier, string argsJson)
{
// This method doesn't need [JSInvokable] because the platform is responsible for having
// some way to dispatch calls here. The logic inside here is the thing that checks whether
// the targeted method has [JSInvokable]. It is not itself subject to that restriction,
// because there would be nobody to police that. This method *is* the police.
var syncResult = InvokeSynchronously(assemblyName, methodIdentifier, argsJson);
// If there was no callId, the caller does not want to be notified about the result
if (callId != null)
{
// Invoke and coerce the result to a Task so the caller can use the same async API
// for both synchronous and asynchronous methods
var task = syncResult is Task syncResultTask ? syncResultTask : Task.FromResult(syncResult);
task.ContinueWith(completedTask =>
{
// DotNetDispatcher only works with JSRuntimeBase instances.
// If the developer wants to use a totally custom IJSRuntime, then their JS-side
// code has to implement its own way of returning async results.
var jsRuntimeBaseInstance = (JSRuntimeBase)JSRuntime.Current;
try
{
var result = TaskGenericsUtil.GetTaskResult(completedTask);
jsRuntimeBaseInstance.EndInvokeDotNet(callId, true, result);
}
catch (Exception ex)
{
ex = UnwrapException(ex);
jsRuntimeBaseInstance.EndInvokeDotNet(callId, false, ex);
}
});
}
}
private static object InvokeSynchronously(string assemblyName, string methodIdentifier, string argsJson)
{
var (methodInfo, parameterTypes) = GetCachedMethodInfo(assemblyName, methodIdentifier);
// There's no direct way to say we want to deserialize as an array with heterogenous
// entry types (e.g., [string, int, bool]), so we need to deserialize in two phases.
// First we deserialize as object[], for which SimpleJson will supply JsonObject
// instances for nonprimitive values.
var suppliedArgs = (object[])null;
var suppliedArgsLength = 0;
if (argsJson != null)
{
suppliedArgs = Json.Deserialize<SimpleJson.JsonArray>(argsJson).ToArray<object>();
suppliedArgsLength = suppliedArgs.Length;
}
if (suppliedArgsLength != parameterTypes.Length)
{
throw new ArgumentException($"In call to '{methodIdentifier}', expected {parameterTypes.Length} parameters but received {suppliedArgsLength}.");
}
// Second, convert each supplied value to the type expected by the method
var serializerStrategy = SimpleJson.SimpleJson.CurrentJsonSerializerStrategy;
for (var i = 0; i < suppliedArgsLength; i++)
{
suppliedArgs[i] = serializerStrategy.DeserializeObject(
suppliedArgs[i], parameterTypes[i]);
}
try
{
return methodInfo.Invoke(null, suppliedArgs);
}
catch (Exception ex)
{
throw UnwrapException(ex);
}
}
/// <summary>
/// Receives notification that a call from .NET to JS has finished, marking the
/// associated <see cref="Task"/> as completed.
/// </summary>
/// <param name="asyncHandle">The identifier for the function invocation.</param>
/// <param name="succeeded">A flag to indicate whether the invocation succeeded.</param>
/// <param name="resultOrException">If <paramref name="succeeded"/> is <c>true</c>, specifies the invocation result. If <paramref name="succeeded"/> is <c>false</c>, gives the <see cref="Exception"/> corresponding to the invocation failure.</param>
[JSInvokable(nameof(DotNetDispatcher) + "." + nameof(EndInvoke))]
public static void EndInvoke(long asyncHandle, bool succeeded, object resultOrException)
=> ((JSRuntimeBase)JSRuntime.Current).EndInvokeJS(asyncHandle, succeeded, resultOrException);
private static (MethodInfo, Type[]) GetCachedMethodInfo(string assemblyName, string methodIdentifier)
{
if (string.IsNullOrWhiteSpace(assemblyName))
{
throw new ArgumentException("Cannot be null, empty, or whitespace.", nameof(assemblyName));
}
if (string.IsNullOrWhiteSpace(methodIdentifier))
{
throw new ArgumentException("Cannot be null, empty, or whitespace.", nameof(methodIdentifier));
}
var assemblyMethods = _cachedMethodsByAssembly.GetOrAdd(assemblyName, ScanAssemblyForCallableMethods);
if (assemblyMethods.TryGetValue(methodIdentifier, out var result))
{
return result;
}
else
{
throw new ArgumentException($"The assembly '{assemblyName}' does not contain a public method with [{nameof(JSInvokableAttribute)}(\"{methodIdentifier}\")].");
}
}
private static IReadOnlyDictionary<string, (MethodInfo, Type[])> ScanAssemblyForCallableMethods(string assemblyName)
{
// TODO: Consider looking first for assembly-level attributes (i.e., if there are any,
// only use those) to avoid scanning, especially for framework assemblies.
return GetRequiredLoadedAssembly(assemblyName)
.GetExportedTypes()
.SelectMany(type => type.GetMethods())
.Where(method => method.IsDefined(typeof(JSInvokableAttribute), inherit: false))
.ToDictionary(
method => method.GetCustomAttribute<JSInvokableAttribute>(false).Identifier,
method => (method, method.GetParameters().Select(p => p.ParameterType).ToArray())
);
}
private static Assembly GetRequiredLoadedAssembly(string assemblyName)
{
// We don't want to load assemblies on demand here, because we don't necessarily trust
// "assemblyName" to be something the developer intended to load. So only pick from the
// set of already-loaded assemblies.
// In some edge cases this might force developers to explicitly call something on the
// target assembly (from .NET) before they can invoke its allowed methods from JS.
var loadedAssemblies = AppDomain.CurrentDomain.GetAssemblies();
return loadedAssemblies.FirstOrDefault(a => a.GetName().Name.Equals(assemblyName, StringComparison.Ordinal))
?? throw new ArgumentException($"There is no loaded assembly with the name '{assemblyName}'.");
}
private static Exception UnwrapException(Exception ex)
{
while ((ex is AggregateException || ex is TargetInvocationException) && ex.InnerException != null)
{
ex = ex.InnerException;
}
return ex;
}
}
}