Skip to content

Blazor Byte Array Interop Support #33015

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 24 commits into from
Jun 4, 2021
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
e538e9b
Blazorserver Byte Array Interop Support
TanayParikh May 25, 2021
137a5f1
PR Feedback
TanayParikh May 26, 2021
e56bf6f
Merge branch 'main' into taparik/byteArrayDirectInterop
TanayParikh May 26, 2021
66815f3
Merge branch 'main' into taparik/byteArrayDirectInterop
TanayParikh May 26, 2021
18ade7c
PR Feedback
TanayParikh May 27, 2021
4a006e6
Merge branch 'main' into taparik/byteArrayDirectInterop
TanayParikh May 28, 2021
8293e92
PR Feedback
TanayParikh May 28, 2021
99c2b2c
Byte Array Interop in WASM (#33104)
TanayParikh May 28, 2021
fc7d066
Fix CI
TanayParikh May 28, 2021
b533959
ByteArrayJsonConverterTest.cs
TanayParikh May 28, 2021
1780543
Additional Tests
TanayParikh Jun 1, 2021
72dae17
Add github issue
TanayParikh Jun 1, 2021
f74f93f
Update DefaultWebAssemblyJSRuntime.cs
TanayParikh Jun 1, 2021
d4fc26e
Merge branch 'main' into taparik/byteArrayDirectInterop
TanayParikh Jun 1, 2021
108b9f2
Update InputFile.ts
TanayParikh Jun 2, 2021
6285ba1
Update release files
TanayParikh Jun 2, 2021
4c3edd6
PR Feedback
TanayParikh Jun 2, 2021
db57f3a
Update WarningSuppression
TanayParikh Jun 2, 2021
514d320
E2E Tests
TanayParikh Jun 2, 2021
11e2ceb
Merge branch 'main' into taparik/byteArrayDirectInterop
TanayParikh Jun 2, 2021
a66eac6
Update roundTripByteArrayAsyncFromJS tests to not use some ES features
TanayParikh Jun 2, 2021
f710bd0
Revert ByteArrayJsonConverter Impl
TanayParikh Jun 3, 2021
02b2b75
PR Feedback
TanayParikh Jun 3, 2021
b6b2fdf
Merge branch 'main' into taparik/byteArrayDirectInterop
TanayParikh Jun 3, 2021
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.

using System;
using System.Buffers;
using System.IO;
using MessagePack;
using Microsoft.AspNetCore.SignalR.Protocol;
Expand Down Expand Up @@ -34,6 +35,20 @@ protected override object DeserializeObject(ref MessagePackReader reader, Type t
{
return reader.ReadSingle();
}
else if (type == typeof(byte[]))
{
var bytes = reader.ReadBytes();
if (!bytes.HasValue)
{
return null;
}
else if (bytes.Value.Length == 0)
{
return Array.Empty<byte>();
}

return bytes.Value.ToArray();
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still doing ToArray in place of passing through the ReadOnlySequence<byte>.

Concerns with using the ReadOnlySequence relate to the fact that it doesn't implicitly cast to byte[] so when we're examining method parameters we have a type mistmatch.

Additionally, as Javier mentioned in the other PR we need to verify the lifecycle/ownership of the ReadOnlySequence.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Concerns with using the ReadOnlySequence relate to the fact that it doesn't implicitly cast to byte[] so when we're examining method parameters we have a type mistmatch.

I think you'd need to change the receiving code (ComponentHub.SupplyByteArray) to accept a ReadOnlySequence<byte> instead of a byte[], so there wouldn't be a type mismatch.

Agreed on verifying the ownership of the buffer before doing that though.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, lets keep it as is for the time being and then we can do a quick check and change afterwards.

}
}
catch (Exception ex)
{
Expand Down Expand Up @@ -75,6 +90,10 @@ protected override void Serialize(ref MessagePackWriter writer, Type type, objec
writer.Write(bytes);
break;

case byte[] byteArray:
writer.Write(byteArray);
break;

default:
throw new FormatException($"Unsupported argument type {type}");
}
Expand Down
41 changes: 41 additions & 0 deletions src/Components/Server/src/Circuits/CircuitHost.cs
Original file line number Diff line number Diff line change
Expand Up @@ -398,6 +398,31 @@ await Renderer.Dispatcher.InvokeAsync(() =>
}
}

// SupplyByteArray is used in a fire-and-forget context, so it's responsible for its own
// error handling.
internal async Task SupplyByteArray(long id, byte[] data)
{
AssertInitialized();
AssertNotDisposed();

try
{
await Renderer.Dispatcher.InvokeAsync(() =>
{
Log.SupplyByteArraySuccess(_logger, id);
DotNetDispatcher.SupplyByteArray(JSRuntime, id, data);
});
}
catch (Exception ex)
{
// An error completing JS interop means that the user sent invalid data, a well-behaved
// client won't do this.
Log.SupplyByteArrayException(_logger, id, ex);
await TryNotifyClientErrorAsync(Client, GetClientErrorMessage(ex, "Invalid byte array."));
UnhandledException?.Invoke(this, new UnhandledExceptionEventArgs(ex, isTerminating: false));
}
}

// DispatchEvent is used in a fire-and-forget context, so it's responsible for its own
// error handling.
public async Task DispatchEvent(string eventDescriptorJson, string eventArgsJson)
Expand Down Expand Up @@ -613,6 +638,8 @@ private static class Log
private static readonly Action<ILogger, Exception> _endInvokeDispatchException;
private static readonly Action<ILogger, long, string, Exception> _endInvokeJSFailed;
private static readonly Action<ILogger, long, Exception> _endInvokeJSSucceeded;
private static readonly Action<ILogger, long, Exception> _supplyByteArraySuccess;
private static readonly Action<ILogger, long, Exception> _supplyByteArrayException;
private static readonly Action<ILogger, Exception> _dispatchEventFailedToParseEventData;
private static readonly Action<ILogger, string, Exception> _dispatchEventFailedToDispatchEvent;
private static readonly Action<ILogger, string, CircuitId, Exception> _locationChange;
Expand Down Expand Up @@ -655,6 +682,8 @@ private static class EventIds
public static readonly EventId LocationChangeFailed = new EventId(210, "LocationChangeFailed");
public static readonly EventId LocationChangeFailedInCircuit = new EventId(211, "LocationChangeFailedInCircuit");
public static readonly EventId OnRenderCompletedFailed = new EventId(212, "OnRenderCompletedFailed");
public static readonly EventId SupplyByteArraySucceeded = new EventId(213, "SupplyByteArraySucceeded");
public static readonly EventId SupplyByteArrayException = new EventId(214, "SupplyByteArrayException");
}

static Log()
Expand Down Expand Up @@ -774,6 +803,16 @@ static Log()
EventIds.EndInvokeJSSucceeded,
"The JS interop call with callback id '{AsyncCall}' succeeded.");

_supplyByteArraySuccess = LoggerMessage.Define<long>(
LogLevel.Debug,
EventIds.SupplyByteArraySucceeded,
"The SupplyByteArray call with id '{id}' succeeded.");

_supplyByteArrayException = LoggerMessage.Define<long>(
LogLevel.Debug,
EventIds.SupplyByteArrayException,
"The SupplyByteArray call with id '{id}' failed.");

_dispatchEventFailedToParseEventData = LoggerMessage.Define(
LogLevel.Debug,
EventIds.DispatchEventFailedToParseEventData,
Expand Down Expand Up @@ -836,6 +875,8 @@ public static void CircuitHandlerFailed(ILogger logger, CircuitHandler handler,
public static void EndInvokeDispatchException(ILogger logger, Exception ex) => _endInvokeDispatchException(logger, ex);
public static void EndInvokeJSFailed(ILogger logger, long asyncHandle, string arguments) => _endInvokeJSFailed(logger, asyncHandle, arguments, null);
public static void EndInvokeJSSucceeded(ILogger logger, long asyncCall) => _endInvokeJSSucceeded(logger, asyncCall, null);
internal static void SupplyByteArraySuccess(ILogger logger, long id) => _supplyByteArraySuccess(logger, id, null);
internal static void SupplyByteArrayException(ILogger logger, long id, Exception ex) => _supplyByteArrayException(logger, id, ex);
public static void DispatchEventFailedToParseEventData(ILogger logger, Exception ex) => _dispatchEventFailedToParseEventData(logger, ex);
public static void DispatchEventFailedToDispatchEvent(ILogger logger, string eventHandlerId, Exception ex) => _dispatchEventFailedToDispatchEvent(logger, eventHandlerId ?? "", ex);

Expand Down
5 changes: 5 additions & 0 deletions src/Components/Server/src/Circuits/RemoteJSRuntime.cs
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,11 @@ protected override void EndInvokeDotNet(DotNetInvocationInfo invocationInfo, in
}
}

protected override void SupplyByteArray(long id, byte[] data)
{
_clientProxy.SendAsync("JS.SupplyByteArray", id, data);
}

protected override void BeginInvokeJS(long asyncHandle, string identifier, string argsJson, JSCallResultType resultType, long targetInstanceId)
{
if (_clientProxy is null)
Expand Down
11 changes: 11 additions & 0 deletions src/Components/Server/src/ComponentHub.cs
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,17 @@ public async ValueTask EndInvokeJSFromDotNet(long asyncHandle, bool succeeded, s
_ = circuitHost.EndInvokeJSFromDotNet(asyncHandle, succeeded, arguments);
}

public async ValueTask SupplyByteArray(long id, byte[] data)
{
var circuitHost = await GetActiveCircuitAsync();
if (circuitHost == null)
{
return;
}

await circuitHost.SupplyByteArray(id, data);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@pranavkm it's fine if we don't await things here. The methods in CircuitHost should not throw exceptions and messages will be "serialized" as they enter the sync context. That said, I don't think awaiting here has any drawback other than slightly reduced performance.

}

public async ValueTask DispatchBrowserEvent(string eventDescriptor, string eventArgs)
{
var circuitHost = await GetActiveCircuitAsync();
Expand Down
2 changes: 2 additions & 0 deletions src/Components/Shared/src/ArrayBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ namespace Ignitor
namespace Microsoft.AspNetCore.Components.WebView
#elif COMPONENTS_SERVER
namespace Microsoft.AspNetCore.Components.Server.Circuits
#elif JS_INTEROP
namespace Microsoft.JSInterop.Infrastructure
#else
namespace Microsoft.AspNetCore.Components.RenderTree
#endif
Expand Down
2 changes: 1 addition & 1 deletion src/Components/Web.JS/dist/Release/blazor.server.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion src/Components/Web.JS/dist/Release/blazor.webview.js

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions src/Components/Web.JS/src/Boot.Server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ async function initializeConnection(options: CircuitStartOptions, logger: Logger
connection.on('JS.AttachComponent', (componentId, selector) => attachRootComponentToLogicalElement(0, circuit.resolveElement(selector), componentId));
connection.on('JS.BeginInvokeJS', DotNet.jsCallDispatcher.beginInvokeJSFromDotNet);
connection.on('JS.EndInvokeDotNet', DotNet.jsCallDispatcher.endInvokeDotNetFromJS);
connection.on('JS.SupplyByteArray', DotNet.jsCallDispatcher.supplyByteArray);

const renderQueue = RenderQueue.getOrCreate(logger);
connection.on('JS.RenderBatch', (batchId: number, batchData: Uint8Array) => {
Expand Down Expand Up @@ -134,6 +135,9 @@ async function initializeConnection(options: CircuitStartOptions, logger: Logger
endInvokeJSFromDotNet: (asyncHandle, succeeded, argsJson): void => {
connection.send('EndInvokeJSFromDotNet', asyncHandle, succeeded, argsJson);
},
supplyByteArray: (id: number, data: Uint8Array): void => {
connection.send('SupplyByteArray', id, data);
},
});

return connection;
Expand Down
3 changes: 2 additions & 1 deletion src/Components/Web.JS/src/Boot.WebView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { shouldAutoStart } from './BootCommon';
import { internalFunctions as navigationManagerFunctions } from './Services/NavigationManager';
import { setEventDispatcher } from './Rendering/Events/EventDispatcher';
import { startIpcReceiver } from './Platform/WebView/WebViewIpcReceiver';
import { sendBrowserEvent, sendAttachPage, sendBeginInvokeDotNetFromJS, sendEndInvokeJSFromDotNet, sendLocationChanged } from './Platform/WebView/WebViewIpcSender';
import { sendBrowserEvent, sendAttachPage, sendBeginInvokeDotNetFromJS, sendEndInvokeJSFromDotNet, sendSupplyByteArray, sendLocationChanged } from './Platform/WebView/WebViewIpcSender';
import { InputFile } from './InputFile';

let started = false;
Expand All @@ -20,6 +20,7 @@ async function boot(): Promise<void> {
DotNet.attachDispatcher({
beginInvokeDotNetFromJS: sendBeginInvokeDotNetFromJS,
endInvokeJSFromDotNet: sendEndInvokeJSFromDotNet,
supplyByteArray: sendSupplyByteArray,
});

Blazor._internal.InputFile = InputFile;
Expand Down
8 changes: 5 additions & 3 deletions src/Components/Web.JS/src/Platform/Mono/MonoPlatform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -485,6 +485,7 @@ function attachInteropInvoker(): void {
const dotNetDispatcherInvokeMethodHandle = bindStaticMethod('Microsoft.AspNetCore.Components.WebAssembly', 'Microsoft.AspNetCore.Components.WebAssembly.Services.DefaultWebAssemblyJSRuntime', 'InvokeDotNet');
const dotNetDispatcherBeginInvokeMethodHandle = bindStaticMethod('Microsoft.AspNetCore.Components.WebAssembly', 'Microsoft.AspNetCore.Components.WebAssembly.Services.DefaultWebAssemblyJSRuntime', 'BeginInvokeDotNet');
const dotNetDispatcherEndInvokeJSMethodHandle = bindStaticMethod('Microsoft.AspNetCore.Components.WebAssembly', 'Microsoft.AspNetCore.Components.WebAssembly.Services.DefaultWebAssemblyJSRuntime', 'EndInvokeJS');
const dotNetDispatcherSupplyByteArrayMethodHandle = bindStaticMethod('Microsoft.AspNetCore.Components.WebAssembly', 'Microsoft.AspNetCore.Components.WebAssembly.Services.DefaultWebAssemblyJSRuntime', 'SupplyByteArray');

DotNet.attachDispatcher({
beginInvokeDotNetFromJS: (callId: number, assemblyName: string | null, methodIdentifier: string, dotNetObjectId: any | null, argsJson: string): void => {
Expand All @@ -506,9 +507,10 @@ function attachInteropInvoker(): void {
);
},
endInvokeJSFromDotNet: (asyncHandle, succeeded, serializedArgs): void => {
dotNetDispatcherEndInvokeJSMethodHandle(
serializedArgs
);
dotNetDispatcherEndInvokeJSMethodHandle(serializedArgs);
},
supplyByteArray: (id: number, data: Uint8Array): void => {
dotNetDispatcherSupplyByteArrayMethodHandle(id, data);
},
invokeDotNetFromJS: (assemblyName, methodIdentifier, dotNetObjectId, argsJson) => {
assertHeapIsNotLocked();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ export function sendEndInvokeJSFromDotNet(asyncHandle: number, succeeded: boolea
send('EndInvokeJS', asyncHandle, succeeded, argsJson);
}

export function sendSupplyByteArray(id: number, data: Uint8Array) {
send('SupplyByteArray', id, data);
}

export function sendLocationChanged(uri: string, intercepted: boolean) {
send('OnLocationChanged', uri, intercepted);
return Promise.resolve(); // Like in Blazor Server, we only issue the notification here - there's no need to wait for a response
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export module DotNet {

export type JsonReviver = ((key: any, value: any) => any);
const jsonRevivers: JsonReviver[] = [];
const byteArraysToBeRevived = new Map<number, Uint8Array>();

class JSObject {
_cachedFunctions: Map<string, Function>;
Expand Down Expand Up @@ -169,7 +170,7 @@ export module DotNet {
function invokePossibleInstanceMethod<T>(assemblyName: string | null, methodIdentifier: string, dotNetObjectId: number | null, args: any[] | null): T {
const dispatcher = getRequiredDispatcher();
if (dispatcher.invokeDotNetFromJS) {
const argsJson = JSON.stringify(args, argReplacer);
const argsJson = stringifyArgs(args);
const resultJson = dispatcher.invokeDotNetFromJS(assemblyName, methodIdentifier, dotNetObjectId, argsJson);
return resultJson ? parseJsonWithRevivers(resultJson) : null;
} else {
Expand All @@ -188,7 +189,7 @@ export module DotNet {
});

try {
const argsJson = JSON.stringify(args, argReplacer);
const argsJson = stringifyArgs(args);
getRequiredDispatcher().beginInvokeDotNetFromJS(asyncCallId, assemblyName, methodIdentifier, dotNetObjectId, argsJson);
} catch (ex) {
// Synchronous failure
Expand Down Expand Up @@ -267,6 +268,13 @@ export module DotNet {
* @param resultOrError The serialized result or the serialized error from the async operation.
*/
endInvokeJSFromDotNet(callId: number, succeeded: boolean, resultOrError: any): void;

/**
* Receives notification that a byte array is available for transfer.
* @param id The identifier for the byte array used during revival.
* @param data The byte array being transferred for eventual revival.
*/
supplyByteArray(id: number, data: Uint8Array): void;
}

/**
Expand Down Expand Up @@ -304,7 +312,7 @@ export module DotNet {

return result === null || result === undefined
? null
: JSON.stringify(result, argReplacer);
: stringifyArgs(result);
},

/**
Expand All @@ -329,7 +337,7 @@ export module DotNet {
// On completion, dispatch result back to .NET
// Not using "await" because it codegens a lot of boilerplate
promise.then(
result => getRequiredDispatcher().endInvokeJSFromDotNet(asyncHandle, true, JSON.stringify([asyncHandle, true, createJSCallResult(result, resultType)], argReplacer)),
result => getRequiredDispatcher().endInvokeJSFromDotNet(asyncHandle, true, stringifyArgs([asyncHandle, true, createJSCallResult(result, resultType)])),
error => getRequiredDispatcher().endInvokeJSFromDotNet(asyncHandle, false, JSON.stringify([asyncHandle, false, formatError(error)]))
);
}
Expand All @@ -346,6 +354,15 @@ export module DotNet {
? parseJsonWithRevivers(resultJsonOrExceptionMessage)
: new Error(resultJsonOrExceptionMessage);
completePendingCall(parseInt(asyncCallId), success, resultOrError);
},

/**
* Receives notification that a byte array is available for transfer.
* @param id The identifier for the byte array used during revival.
* @param data The byte array being transferred for eventual revival.
*/
supplyByteArray: (id: number, data: Uint8Array): void => {
byteArraysToBeRevived.set(id, data);
}
}

Expand Down Expand Up @@ -394,24 +411,28 @@ export module DotNet {
}

const dotNetObjectRefKey = '__dotNetObject';
attachReviver(function reviveDotNetObject(key: any, value: any) {
if (value && typeof value === 'object' && value.hasOwnProperty(dotNetObjectRefKey)) {
return new DotNetObject(value.__dotNetObject);
}

// Unrecognized - let another reviver handle it
return value;
});

attachReviver(function reviveJSObjectReference(key: any, value: any) {
if (value && typeof value === 'object' && value.hasOwnProperty(jsObjectIdKey)) {
const id = value[jsObjectIdKey];
const jsObject = cachedJSObjectsById[id];
const byteArrayRefKey = '__byte[]';
attachReviver(function reviveReference(key: any, value: any) {
if (value && typeof value === 'object') {
if (value.hasOwnProperty(dotNetObjectRefKey)) {
return new DotNetObject(value.__dotNetObject);
} else if (value.hasOwnProperty(jsObjectIdKey)) {
const id = value[jsObjectIdKey];
const jsObject = cachedJSObjectsById[id];

if (jsObject) {
return jsObject.getWrappedObject();
} else {
throw new Error(`JS object instance with ID ${id} does not exist (has it been disposed?).`);
}
} else if (value.hasOwnProperty(byteArrayRefKey)) {
const index = value[byteArrayRefKey];
const byteArray = byteArraysToBeRevived.get(index);
if (byteArray === undefined) {
throw new Error(`Byte array index '${index}' does not exist.`);
}

if (jsObject) {
return jsObject.getWrappedObject();
} else {
throw new Error(`JS object instance with ID ${id} does not exist (has it been disposed?).`);
return byteArray;
}
}

Expand All @@ -430,7 +451,22 @@ export module DotNet {
}
}

function argReplacer(key: string, value: any) {
return value instanceof DotNetObject ? value.serializeAsArg() : value;
function stringifyArgs(args: any[] | null) {
let byteArrayIndex = 0;

return JSON.stringify(args, argReplacer);

function argReplacer(key: string, value: any) {
if (value instanceof DotNetObject) {
return value.serializeAsArg();
} else if (value instanceof Uint8Array) {
dotNetDispatcher!.supplyByteArray(byteArrayIndex, value);
const jsonValue = { [byteArrayRefKey]: byteArrayIndex };
byteArrayIndex++;
return jsonValue;
}

return value;
}
}
}
Loading