aspnetcore/src/JSInterop/Microsoft.JSInterop/src/JSRuntimeBase.cs

197 lines
9.3 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.Runtime.ExceptionServices;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.JSInterop.Internal;
namespace Microsoft.JSInterop
{
/// <summary>
/// Abstract base class for a JavaScript runtime.
/// </summary>
public abstract class JSRuntimeBase : IJSRuntime
{
private long _nextPendingTaskId = 1; // Start at 1 because zero signals "no response needed"
private readonly ConcurrentDictionary<long, object> _pendingTasks
= new ConcurrentDictionary<long, object>();
private readonly ConcurrentDictionary<long, CancellationTokenRegistration> _cancellationRegistrations =
new ConcurrentDictionary<long, CancellationTokenRegistration>();
internal DotNetObjectRefManager ObjectRefManager { get; } = new DotNetObjectRefManager();
/// <summary>
/// Gets or sets the default timeout for asynchronous JavaScript calls.
/// </summary>
protected TimeSpan? DefaultAsyncTimeout { get; set; }
/// <summary>
/// Invokes the specified JavaScript function asynchronously.
/// </summary>
/// <typeparam name="T">The JSON-serializable return type.</typeparam>
/// <param name="identifier">An identifier for the function to invoke. For example, the value <code>"someScope.someFunction"</code> will invoke the function <code>window.someScope.someFunction</code>.</param>
/// <param name="args">JSON-serializable arguments.</param>
/// <param name="cancellationToken">A cancellation token to signal the cancellation of the operation.</param>
/// <returns>An instance of <typeparamref name="T"/> obtained by JSON-deserializing the return value.</returns>
public Task<T> InvokeAsync<T>(string identifier, IEnumerable<object> args, CancellationToken cancellationToken = default)
{
var taskId = Interlocked.Increment(ref _nextPendingTaskId);
var tcs = new TaskCompletionSource<T>(TaskContinuationOptions.RunContinuationsAsynchronously);
if (cancellationToken != default)
{
_cancellationRegistrations[taskId] = cancellationToken.Register(() =>
{
tcs.TrySetCanceled(cancellationToken);
CleanupTasksAndRegistrations(taskId);
});
}
_pendingTasks[taskId] = tcs;
try
{
if (cancellationToken.IsCancellationRequested)
{
tcs.TrySetCanceled(cancellationToken);
CleanupTasksAndRegistrations(taskId);
return tcs.Task;
}
var argsJson = args?.Any() == true ?
JsonSerializer.Serialize(args, JsonSerializerOptionsProvider.Options) :
null;
BeginInvokeJS(taskId, identifier, argsJson);
return tcs.Task;
}
catch
{
CleanupTasksAndRegistrations(taskId);
throw;
}
}
private void CleanupTasksAndRegistrations(long taskId)
{
_pendingTasks.TryRemove(taskId, out _);
if (_cancellationRegistrations.TryRemove(taskId, out var registration))
{
registration.Dispose();
}
}
/// <summary>
/// Invokes the specified JavaScript function asynchronously.
/// </summary>
/// <typeparam name="T">The JSON-serializable return type.</typeparam>
/// <param name="identifier">An identifier for the function to invoke. For example, the value <code>"someScope.someFunction"</code> will invoke the function <code>window.someScope.someFunction</code>.</param>
/// <param name="args">JSON-serializable arguments.</param>
/// <returns>An instance of <typeparamref name="T"/> obtained by JSON-deserializing the return value.</returns>
public Task<T> InvokeAsync<T>(string identifier, params object[] args)
{
if (!DefaultAsyncTimeout.HasValue)
{
return InvokeAsync<T>(identifier, args, default);
}
else
{
return InvokeWithDefaultCancellation<T>(identifier, args);
}
}
private async Task<T> InvokeWithDefaultCancellation<T>(string identifier, IEnumerable<object> args)
{
using (var cts = new CancellationTokenSource(DefaultAsyncTimeout.Value))
{
// We need to await here due to the using
return await InvokeAsync<T>(identifier, args, cts.Token);
}
}
/// <summary>
/// Begins an asynchronous function invocation.
/// </summary>
/// <param name="taskId">The identifier for the function invocation, or zero if no async callback is required.</param>
/// <param name="identifier">The identifier for the function to invoke.</param>
/// <param name="argsJson">A JSON representation of the arguments.</param>
protected abstract void BeginInvokeJS(long taskId, string identifier, string argsJson);
/// <summary>
/// Allows derived classes to configure the information about an exception in a JS interop call that gets sent to JavaScript.
/// </summary>
/// <remarks>
/// This callback can be used in remote JS interop scenarios to sanitize exceptions that happen on the server to avoid disclosing
/// sensitive information to remote browser clients.
/// </remarks>
/// <param name="exception">The exception that occurred.</param>
/// <param name="assemblyName">The assembly for the invoked .NET method.</param>
/// <param name="methodIdentifier">The identifier for the invoked .NET method.</param>
/// <returns>An object containing information about the exception.</returns>
protected virtual object OnDotNetInvocationException(Exception exception, string assemblyName, string methodIdentifier) => exception.ToString();
internal void EndInvokeDotNet(string callId, bool success, object resultOrException, string assemblyName, string methodIdentifier)
{
// For failures, the common case is to call EndInvokeDotNet with the Exception object.
// For these we'll serialize as something that's useful to receive on the JS side.
// If the value is not an Exception, we'll just rely on it being directly JSON-serializable.
if (!success && resultOrException is Exception ex)
{
resultOrException = OnDotNetInvocationException(ex, assemblyName, methodIdentifier);
}
else if (!success && resultOrException is ExceptionDispatchInfo edi)
{
resultOrException = OnDotNetInvocationException(edi.SourceException, assemblyName, methodIdentifier);
}
// We pass 0 as the async handle because we don't want the JS-side code to
// send back any notification (we're just providing a result for an existing async call)
var args = JsonSerializer.Serialize(new[] { callId, success, resultOrException }, JsonSerializerOptionsProvider.Options);
BeginInvokeJS(0, "DotNet.jsCallDispatcher.endInvokeDotNetFromJS", args);
}
internal void EndInvokeJS(long taskId, bool succeeded, JSAsyncCallResult asyncCallResult)
{
using (asyncCallResult?.JsonDocument)
{
if (!_pendingTasks.TryRemove(taskId, out var tcs))
{
// We should simply return if we can't find an id for the invocation.
// This likely means that the method that initiated the call defined a timeout and stopped waiting.
return;
}
CleanupTasksAndRegistrations(taskId);
if (succeeded)
{
var resultType = TaskGenericsUtil.GetTaskCompletionSourceResultType(tcs);
try
{
var result = asyncCallResult != null ?
JsonSerializer.Deserialize(asyncCallResult.JsonElement.GetRawText(), resultType, JsonSerializerOptionsProvider.Options) :
null;
TaskGenericsUtil.SetTaskCompletionSourceResult(tcs, result);
}
catch (Exception exception)
{
var message = $"An exception occurred executing JS interop: {exception.Message}. See InnerException for more details.";
TaskGenericsUtil.SetTaskCompletionSourceException(tcs, new JSException(message, exception));
}
}
else
{
var exceptionText = asyncCallResult?.JsonElement.ToString() ?? string.Empty;
TaskGenericsUtil.SetTaskCompletionSourceException(tcs, new JSException(exceptionText));
}
}
}
}
}