Skip to content

Blazor WebAssembly & WebView JS to .NET Streaming Interop Support #33986

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 43 commits into from
Jul 15, 2021
Merged
Show file tree
Hide file tree
Changes from 35 commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
e9a84a3
Prototype Large File Upload Support Blazor
TanayParikh Jun 28, 2021
fc45a1d
Blob.slice Implementation
TanayParikh Jun 28, 2021
05c0037
Allow WASM to keep working
TanayParikh Jun 28, 2021
481d564
Merge branch 'main' into taparik/largeFileInterop
TanayParikh Jun 29, 2021
c682d19
Update CircuitStreamingInterop.ts
TanayParikh Jun 29, 2021
7d4f8f8
PR Feedback
TanayParikh Jun 29, 2021
d3a5ad9
Cleanup
TanayParikh Jun 29, 2021
0e41baa
WASM
TanayParikh Jun 30, 2021
2b46a90
WebView
TanayParikh Jun 30, 2021
5ad5573
Webview Remove SendJSDataStream
TanayParikh Jun 30, 2021
f0a8866
Refactor Shared Streaming Interop Code TS
TanayParikh Jun 30, 2021
68ebb6f
WebView DotNetObjectReference Based ReceiveData
TanayParikh Jun 30, 2021
d8de554
BaseJSDataStream Refactor
TanayParikh Jul 1, 2021
fbee585
E2E tests WASM
TanayParikh Jul 1, 2021
74162f6
Merge branch 'taparik/largeFileInterop' into taparik/wasmWebViewJSToD…
TanayParikh Jul 1, 2021
d1c0d3a
Unhandled Exception
TanayParikh Jul 1, 2021
2020471
ReceiveJSDataChunk using DotNet Object Reference for WASM/WebView
TanayParikh Jul 1, 2021
577b7f6
WASM Unmarshalled Interop Support
TanayParikh Jul 1, 2021
8adbdc6
E2E tests
TanayParikh Jul 1, 2021
4cc882c
Update BaseJSDataStream.cs
TanayParikh Jul 2, 2021
857f441
Merge branch 'main' into taparik/largeFileInterop
TanayParikh Jul 9, 2021
b74b933
PR Feedback
TanayParikh Jul 9, 2021
d9e37fb
Merge branch 'main' into taparik/largeFileInterop
TanayParikh Jul 9, 2021
f3f2cbe
Merge branch 'taparik/largeFileInterop' into taparik/wasmWebViewJSToD…
TanayParikh Jul 9, 2021
21cf75f
Update Web(View, Assembly) to match interop followup items
TanayParikh Jul 9, 2021
d571a42
Merge branch 'main' into taparik/wasmWebViewJSToDotnetStreaming
TanayParikh Jul 9, 2021
97668f2
PR Feedback
TanayParikh Jul 9, 2021
32c30af
Updated Release .js
TanayParikh Jul 9, 2021
0fc102d
Create Microsoft.JSInterop.WebAssembly.WarningSuppressions.xml
TanayParikh Jul 9, 2021
dffadb9
Merge branch 'main' into taparik/wasmWebViewJSToDotnetStreaming
TanayParikh Jul 13, 2021
cffc429
Updated WASM/WebView Pull Based Implementation
TanayParikh Jul 13, 2021
a6d1842
InputFile Updated Implementation
TanayParikh Jul 13, 2021
834ecfe
Cleanup
TanayParikh Jul 13, 2021
8431126
Merge branch 'main' into taparik/wasmWebViewJSToDotnetStreaming
TanayParikh Jul 13, 2021
c01f9e3
PR Feedback
TanayParikh Jul 13, 2021
1fc7533
Remove ReadRequest
TanayParikh Jul 13, 2021
8f46b09
PR Feedback II
TanayParikh Jul 14, 2021
23bff7e
Tests
TanayParikh Jul 14, 2021
99b09e8
Merge branch 'main' into taparik/wasmWebViewJSToDotnetStreaming
TanayParikh Jul 14, 2021
33556c8
PR Feedback
TanayParikh Jul 14, 2021
de63b91
Address `JS` to `DotNet` byte[] Interop API Review Feedback (#34328)
TanayParikh Jul 15, 2021
1e94d7f
Final PR Feedback
TanayParikh Jul 15, 2021
ea0de9b
Fix WebView for large Files
TanayParikh Jul 15, 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
13 changes: 7 additions & 6 deletions src/Components/Server/src/Circuits/RemoteJSDataStream.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ internal sealed class RemoteJSDataStream : Stream
{
private readonly RemoteJSRuntime _runtime;
private readonly long _streamId;
private readonly long _totalLength;
private readonly TimeSpan _jsInteropDefaultCallTimeout;
private readonly long _totalLength;
private readonly CancellationToken _streamCancellationToken;
private readonly Stream _pipeReaderStream;
private readonly Pipe _pipe;
Expand Down Expand Up @@ -71,8 +71,8 @@ private RemoteJSDataStream(
{
_runtime = runtime;
_streamId = streamId;
_totalLength = totalLength;
_jsInteropDefaultCallTimeout = jsInteropDefaultCallTimeout;
_totalLength = totalLength;
_streamCancellationToken = cancellationToken;

_lastDataReceivedTime = DateTimeOffset.UtcNow;
Expand All @@ -94,9 +94,6 @@ private async Task<bool> ReceiveData(long chunkId, byte[] chunk, string error)
{
try
{
_lastDataReceivedTime = DateTimeOffset.UtcNow;
_ = ThrowOnTimeout();

if (!string.IsNullOrEmpty(error))
{
throw new InvalidOperationException($"An error occurred while reading the remote stream: {error}");
Expand All @@ -111,7 +108,7 @@ private async Task<bool> ReceiveData(long chunkId, byte[] chunk, string error)

if (chunk.Length == 0)
{
throw new EndOfStreamException($"The incoming data chunk cannot be empty.");
throw new EndOfStreamException("The incoming data chunk cannot be empty.");
}

_bytesRead += chunk.Length;
Expand All @@ -121,6 +118,10 @@ private async Task<bool> ReceiveData(long chunkId, byte[] chunk, string error)
throw new EndOfStreamException($"The incoming data stream declared a length {_totalLength}, but {_bytesRead} bytes were sent.");
}

// Start timeout _after_ performing validations on data.
_lastDataReceivedTime = DateTimeOffset.UtcNow;
_ = ThrowOnTimeout();

await _pipe.Writer.WriteAsync(chunk, _streamCancellationToken);

if (_bytesRead == _totalLength)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
<Reference Include="Microsoft.Extensions.Logging" />
<!-- Required for S.T.J source generation -->
<Reference Include="System.Text.Json" PrivateAssets="All" />

<Compile Include="$(SharedSourceRoot)ValueStopwatch\*.cs" />

<Compile Include="$(SharedSourceRoot)LinkerFlags.cs" LinkBase="Shared" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -286,7 +286,7 @@ private static async Task<RemoteJSDataStream> CreateRemoteJSDataStreamAsync(Test
}

private static long GetStreamId(RemoteJSDataStream stream, RemoteJSRuntime runtime) =>
runtime.RemoteJSDataStreamInstances.FirstOrDefault(kvp => kvp.Value == stream).Key;
runtime.JSDataStreamInstances.FirstOrDefault(kvp => kvp.Value == stream).Key;

class TestRemoteJSRuntime : RemoteJSRuntime, IJSRuntime
{
Expand Down
99 changes: 99 additions & 0 deletions src/Components/Shared/src/PullFromJSDataStream.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
// 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.Threading;
using System.Threading.Tasks;
using Microsoft.JSInterop;

namespace Microsoft.AspNetCore.Components
{
/// <Summary>
/// A stream that pulls each chunk on demand using JavaScript interop. This implementation is used for
/// WebAssembly and WebView applications.
/// </Summary>
internal sealed class PullFromJSDataStream : Stream
{
private readonly JSRuntime _runtime;
private readonly IJSStreamReference _jsStreamReference;
private readonly long _totalLength;
private readonly CancellationToken _streamCancellationToken;
private long _offset;

public static PullFromJSDataStream CreateJSDataStream(
JSRuntime runtime,
IJSStreamReference jsStreamReference,
long totalLength,
CancellationToken cancellationToken = default)
{
var jsDataStream = new PullFromJSDataStream(runtime, jsStreamReference, totalLength, cancellationToken);
return jsDataStream;
}

private PullFromJSDataStream(
JSRuntime runtime,
IJSStreamReference jsStreamReference,
long totalLength,
CancellationToken cancellationToken)
{
_runtime = runtime;
_jsStreamReference = jsStreamReference;
_totalLength = totalLength;
_streamCancellationToken = cancellationToken;
_offset = 0;
}

public override bool CanRead => true;

public override bool CanSeek => false;

public override bool CanWrite => false;

public override long Length => _totalLength;

public override long Position
{
get => _offset;
set => throw new NotSupportedException();
}

public override void Flush()
=> throw new NotSupportedException();

public override int Read(byte[] buffer, int offset, int count)
=> throw new NotSupportedException("Synchronous reads are not supported.");

public override long Seek(long offset, SeekOrigin origin)
=> throw new NotSupportedException();

public override void SetLength(long value)
=> throw new NotSupportedException();

public override void Write(byte[] buffer, int offset, int count)
=> throw new NotSupportedException();

public override async Task<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
=> await ReadAsync(buffer.AsMemory(offset, count), cancellationToken);

public override async ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default)
{
var bytesRead = await RequestDataFromJSAsync(buffer.Length);
bytesRead.CopyTo(buffer);

return bytesRead.Length;
}

private async ValueTask<byte[]> RequestDataFromJSAsync(int numBytesToRead)
{
numBytesToRead = (int)Math.Min(numBytesToRead, _totalLength - _offset);
var bytesRead = await _runtime.InvokeAsync<byte[]>("Blazor._internal.getJSDataStreamChunk", _jsStreamReference, _offset, numBytesToRead);
if (bytesRead.Length != numBytesToRead)
{
throw new EndOfStreamException($"Failed to read the requested number of bytes from the stream.");
}

_offset += bytesRead.Length;
return bytesRead;
}
}
}
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: 1 addition & 3 deletions src/Components/Web.JS/src/Boot.Server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import { resolveOptions, CircuitStartOptions } from './Platform/Circuits/Circuit
import { DefaultReconnectionHandler } from './Platform/Circuits/DefaultReconnectionHandler';
import { attachRootComponentToLogicalElement } from './Rendering/Renderer';
import { discoverComponents, discoverPersistedState, ServerComponentDescriptor } from './Services/ComponentDescriptorDiscovery';
import { InputFile } from './InputFile';
import { sendJSDataStream } from './Platform/Circuits/CircuitStreamingInterop';

let renderingFailed = false;
Expand All @@ -29,7 +28,6 @@ async function boot(userOptions?: Partial<CircuitStartOptions>): Promise<void> {
const options = resolveOptions(userOptions);
const logger = new ConsoleLogger(options.logLevel);
Blazor.defaultReconnectionHandler = new DefaultReconnectionHandler(logger);
Blazor._internal.InputFile = InputFile;

options.reconnectionHandler = options.reconnectionHandler || Blazor.defaultReconnectionHandler;
logger.log(LogLevel.Information, 'Starting up Blazor server-side application.');
Expand Down Expand Up @@ -126,7 +124,7 @@ async function initializeConnection(options: CircuitStartOptions, logger: Logger

Blazor._internal.forceCloseConnection = () => connection.stop();

Blazor._internal.sendJSDataStream = (data: ArrayBufferView, streamId: string, chunkSize: number) => sendJSDataStream(connection, data, streamId, chunkSize);
Blazor._internal.sendJSDataStream = (data: ArrayBufferView | Blob, streamId: number, chunkSize: number) => sendJSDataStream(connection, data, streamId, chunkSize);

try {
await connection.start();
Expand Down
7 changes: 4 additions & 3 deletions src/Components/Web.JS/src/Boot.WebAssembly.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { Pointer, System_Array, System_Boolean, System_Byte, System_Int, System_
import { WebAssemblyStartOptions } from './Platform/WebAssemblyStartOptions';
import { WebAssemblyComponentAttacher } from './Platform/WebAssemblyComponentAttacher';
import { discoverComponents, discoverPersistedState, WebAssemblyComponentDescriptor } from './Services/ComponentDescriptorDiscovery';
import { WasmInputFile } from './WasmInputFile';
import { getNextChunk } from './StreamingInterop';

declare var Module: EmscriptenModule;
let started = false;
Expand Down Expand Up @@ -43,8 +43,6 @@ async function boot(options?: Partial<WebAssemblyStartOptions>): Promise<void> {
}
});

Blazor._internal.InputFile = WasmInputFile;

Blazor._internal.applyHotReload = (id: string, metadataDelta: string, ilDeta: string) => {
DotNet.invokeMethod('Microsoft.AspNetCore.Components.WebAssembly', 'ApplyHotReloadDelta', id, metadataDelta, ilDeta);
};
Expand Down Expand Up @@ -160,6 +158,9 @@ function invokeJSFromDotNet(callInfo: Pointer, arg0: any, arg1: any, arg2: any):
case DotNet.JSCallResultType.JSObjectReference:
return DotNet.createJSObjectReference(result).__jsObjectId;
case DotNet.JSCallResultType.JSStreamReference:
const streamReference = DotNet.createJSStreamReference(result);
const resultJson = JSON.stringify(streamReference);
return BINDING.js_string_to_mono_string(resultJson);
default:
throw new Error(`Invalid JS call result type '${resultType}'.`);
}
Expand Down
3 changes: 1 addition & 2 deletions src/Components/Web.JS/src/Boot.WebView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { internalFunctions as navigationManagerFunctions } from './Services/Navi
import { setEventDispatcher } from './Rendering/Events/EventDispatcher';
import { startIpcReceiver } from './Platform/WebView/WebViewIpcReceiver';
import { sendBrowserEvent, sendAttachPage, sendBeginInvokeDotNetFromJS, sendEndInvokeJSFromDotNet, sendByteArray, sendLocationChanged } from './Platform/WebView/WebViewIpcSender';
import { InputFile } from './InputFile';
import { getNextChunk } from './StreamingInterop';

let started = false;

Expand All @@ -23,7 +23,6 @@ async function boot(): Promise<void> {
sendByteArray: sendByteArray,
});

Blazor._internal.InputFile = InputFile;
navigationManagerFunctions.enableNavigationInterception();
navigationManagerFunctions.listenForNavigationEvents(sendLocationChanged);

Expand Down
6 changes: 5 additions & 1 deletion src/Components/Web.JS/src/GlobalExports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { DefaultReconnectionHandler } from './Platform/Circuits/DefaultReconnect
import { CircuitStartOptions } from './Platform/Circuits/CircuitStartOptions';
import { WebAssemblyStartOptions } from './Platform/WebAssemblyStartOptions';
import { Platform, Pointer, System_String, System_Array, System_Object, System_Boolean, System_Byte, System_Int } from './Platform/Platform';
import { getNextChunk } from './StreamingInterop';

interface IBlazor {
navigateTo: (uri: string, options: NavigationOptions) => void;
Expand Down Expand Up @@ -48,7 +49,8 @@ interface IBlazor {
getLazyAssemblies?: any
dotNetCriticalError?: any
getSatelliteAssemblies?: any,
sendJSDataStream?: (data: any, streamId: string, chunkSize: number) => void,
sendJSDataStream?: (data: any, streamId: number, chunkSize: number) => void,
getJSDataStreamChunk?: (data: any, position: number, chunkSize: number) => Promise<Uint8Array>,

// APIs invoked by hot reload
applyHotReload?: (id: string, metadataDelta: string, ilDelta: string) => void,
Expand All @@ -64,6 +66,8 @@ export const Blazor: IBlazor = {
navigationManager: navigationManagerInternalFunctions,
domWrapper: domFunctions,
Virtualize,
InputFile,
getJSDataStreamChunk: getNextChunk,
},
};

Expand Down
33 changes: 3 additions & 30 deletions src/Components/Web.JS/src/InputFile.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
export const InputFile = {
init,
toImageFile,
ensureArrayBufferReadyForSharedMemoryInterop,
readFileData,
};

Expand All @@ -11,9 +10,8 @@ interface BrowserFile {
name: string;
size: number;
contentType: string;
readPromise: Promise<ArrayBuffer> | undefined;
arrayBuffer: ArrayBuffer | undefined;
blob: Blob;
chunk: Uint8Array | undefined;
}

export interface InputElement extends HTMLInputElement {
Expand Down Expand Up @@ -43,6 +41,7 @@ function init(callbackWrapper: any, elem: InputElement): void {
readPromise: undefined,
arrayBuffer: undefined,
blob: file,
chunk: undefined,
};

elem._blazorFilesById[result.id] = result;
Expand Down Expand Up @@ -83,21 +82,15 @@ async function toImageFile(elem: InputElement, fileId: number, format: string, m
name: originalFile.name,
size: resizedImageBlob?.size || 0,
contentType: format,
readPromise: undefined,
arrayBuffer: undefined,
blob: resizedImageBlob ? resizedImageBlob : originalFile.blob,
chunk: undefined,
};

elem._blazorFilesById[result.id] = result;

return result;
}

async function ensureArrayBufferReadyForSharedMemoryInterop(elem: InputElement, fileId: number): Promise<void> {
const arrayBuffer = await getArrayBufferFromFileAsync(elem, fileId);
getFileById(elem, fileId).arrayBuffer = arrayBuffer;
}

async function readFileData(elem: InputElement, fileId: number): Promise<Blob> {
const file = getFileById(elem, fileId);
return file.blob;
Expand All @@ -112,23 +105,3 @@ export function getFileById(elem: InputElement, fileId: number): BrowserFile {

return file;
}

function getArrayBufferFromFileAsync(elem: InputElement, fileId: number): Promise<ArrayBuffer> {
const file = getFileById(elem, fileId);

// On the first read, convert the FileReader into a Promise<ArrayBuffer>.
if (!file.readPromise) {
file.readPromise = new Promise(function(resolve: (buffer: ArrayBuffer) => void, reject): void {
const reader = new FileReader();
reader.onload = function(): void {
resolve(reader.result as ArrayBuffer);
};
reader.onerror = function(err): void {
reject(err);
};
reader.readAsArrayBuffer(file.blob);
});
}

return file.readPromise;
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { HubConnection } from '@microsoft/signalr';
import { getNextChunk } from '../../StreamingInterop';

export function sendJSDataStream(connection: HubConnection, data: ArrayBufferView | Blob, streamId: string, chunkSize: number) {
export function sendJSDataStream(connection: HubConnection, data: ArrayBufferView | Blob, streamId: number, chunkSize: number) {
// Run the rest in the background, without delaying the completion of the call to sendJSDataStream
// otherwise we'll deadlock (.NET can't begin reading until this completes, but it won't complete
// because nobody's reading the pipe)
Expand Down Expand Up @@ -50,23 +51,3 @@ export function sendJSDataStream(connection: HubConnection, data: ArrayBufferVie
}
}, 0);
};

async function getNextChunk(data: ArrayBufferView | Blob, position: number, nextChunkSize: number): Promise<Uint8Array> {
if (data instanceof Blob) {
return await getChunkFromBlob(data, position, nextChunkSize);
} else {
return getChunkFromArrayBufferView(data, position, nextChunkSize);
}
}

async function getChunkFromBlob(data: Blob, position: number, nextChunkSize: number): Promise<Uint8Array> {
const chunkBlob = data.slice(position, position + nextChunkSize);
const arrayBuffer = await chunkBlob.arrayBuffer();
const nextChunkData = new Uint8Array(arrayBuffer);
return nextChunkData;
}

function getChunkFromArrayBufferView(data: ArrayBufferView, position: number, nextChunkSize: number) {
const nextChunkData = new Uint8Array(data.buffer, data.byteOffset + position, nextChunkSize);
return nextChunkData;
}
1 change: 0 additions & 1 deletion src/Components/Web.JS/src/Platform/Mono/MonoPlatform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -488,7 +488,6 @@ function attachInteropInvoker(): void {
const dotNetDispatcherEndInvokeJSMethodHandle = bindStaticMethod('Microsoft.AspNetCore.Components.WebAssembly', 'Microsoft.AspNetCore.Components.WebAssembly.Services.DefaultWebAssemblyJSRuntime', 'EndInvokeJS');
const dotNetDispatcherNotifyByteArrayAvailableMethodHandle = bindStaticMethod('Microsoft.AspNetCore.Components.WebAssembly', 'Microsoft.AspNetCore.Components.WebAssembly.Services.DefaultWebAssemblyJSRuntime', 'NotifyByteArrayAvailable');


DotNet.attachDispatcher({
beginInvokeDotNetFromJS: (callId: number, assemblyName: string | null, methodIdentifier: string, dotNetObjectId: any | null, argsJson: string): void => {
assertHeapIsNotLocked();
Expand Down
19 changes: 19 additions & 0 deletions src/Components/Web.JS/src/StreamingInterop.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
export async function getNextChunk(data: ArrayBufferView | Blob, position: number, nextChunkSize: number): Promise<Uint8Array> {
if (data instanceof Blob) {
return await getChunkFromBlob(data, position, nextChunkSize);
} else {
return getChunkFromArrayBufferView(data, position, nextChunkSize);
}
}

async function getChunkFromBlob(data: Blob, position: number, nextChunkSize: number): Promise<Uint8Array> {
const chunkBlob = data.slice(position, position + nextChunkSize);
const arrayBuffer = await chunkBlob.arrayBuffer();
const nextChunkData = new Uint8Array(arrayBuffer);
return nextChunkData;
}

function getChunkFromArrayBufferView(data: ArrayBufferView, position: number, nextChunkSize: number) {
const nextChunkData = new Uint8Array(data.buffer, data.byteOffset + position, nextChunkSize);
return nextChunkData;
}
Loading