Skip to content

Add FileDownloader service for Blazor apps #43076

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

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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 @@ -67,6 +67,7 @@ public static IServerSideBlazorBuilder AddServerSideBlazor(this IServiceCollecti
services.TryAddSingleton<ComponentParametersTypeCache>();
services.TryAddSingleton<CircuitIdFactory>();
services.TryAddScoped<IErrorBoundaryLogger, RemoteErrorBoundaryLogger>();
services.TryAddScoped<IFileDownloader, FileDownloader>();

services.TryAddScoped(s => s.GetRequiredService<ICircuitAccessor>().Circuit);
services.TryAddScoped<ICircuitAccessor, DefaultCircuitAccessor>();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@
<Compile Include="$(ComponentsSharedSourceRoot)src\ElementReferenceJsonConverter.cs" />
<Compile Include="$(ComponentsSharedSourceRoot)src\ComponentParametersTypeCache.cs" />
<Compile Include="$(ComponentsSharedSourceRoot)src\RootComponentTypeCache.cs" />
<Compile Include="$(ComponentsSharedSourceRoot)src\FileDownloader.cs" />

<Compile Include="..\..\Shared\src\BrowserNavigationManagerInterop.cs" />
<Compile Include="..\..\Shared\src\JsonSerializerOptionsProvider.cs" />
Expand Down
39 changes: 39 additions & 0 deletions src/Components/Shared/src/FileDownloader.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using Microsoft.JSInterop;

namespace Microsoft.AspNetCore.Components;

/// <summary>
/// Allows triggering a file download on the client from .NET.
/// </summary>
internal sealed class FileDownloader : IFileDownloader
{
private readonly IJSRuntime _jsRuntime;
public FileDownloader(IJSRuntime JSRuntime)
{
_jsRuntime = JSRuntime;
}

/// <inheritdoc />
public Task SaveAs(string fileName, byte[] data)
{
var fileStream = new MemoryStream(data);

return SaveAs(fileName, fileStream);
}

/// <inheritdoc />
public async Task SaveAs(string fileName, Stream data)
{
if (string.IsNullOrWhiteSpace(fileName))
{
throw new ArgumentNullException(nameof(fileName));
}

using var streamRef = new DotNetStreamReference(stream: data);

await _jsRuntime.InvokeVoidAsync("Blazor._internal.downloadFile", streamRef, fileName);
Copy link
Member

Choose a reason for hiding this comment

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

@TanayParikh, could you comment on the efficiency of sending streams versus byte arrays, both for Server and WebAssembly? In my memory, on WebAssembly at least, sending a byte[] is super efficient because it's done in a single step without even copying the data in memory.

If that is the case, it would be advantageous to have two different internal APIs, e.g., Blazor._internal.downloadFileArray and Blazor._internal.downloadFileStream, and pick between them based on the supplied data (so we don't have to normalize both cases to streams). For Server I'm not sure whether we want to prefer byte[] or Stream if we're given a byte[].

However if I'm misremembering and there isn't a perf advantage to using byte[] on WebAssembly then this looks good as-is!

Copy link
Contributor

@TanayParikh TanayParikh Aug 4, 2022

Choose a reason for hiding this comment

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

In my memory, on WebAssembly at least, sending a byte[] is super efficient because it's done in a single step without even copying the data in memory.

That's correct, byte[] interop is achieved via an InvokeUnmarshalled call in WebAssembly, making it super efficient.

InvokeUnmarshalled<int, byte[], object>("Blazor._internal.receiveByteArray", id, data);

const dataByteArray = monoPlatform.toUint8Array(data);

Streaming interop just leverages this unmarshalled interop for the transfer of the underlying byte chunks:

await runtime.InvokeVoidAsync("Blazor._internal.receiveDotNetDataStream", streamId, buffer, bytesRead, null);

For WebAssembly byte[]s can be handled separately so we can take advantage of this feature. The part which is a bit unclear is whether this performance benefit is worth having the additional complexity specifically for this case.

For Blazor Server, given the SignalR message size limitation (and assuming downloads are likely to be greater than 32kb), I don't think we're going to see too much of a benefit having a separate path for byte[] downloads.

}
}
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.

44 changes: 44 additions & 0 deletions src/Components/Web.JS/src/FileDownloader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

export async function downloadFile(data: any, fileName: string) {
// For Chromium browsers, show "Save As" dialog and then stream the data into the file without buffering it all in memory
if (typeof (window as any).showSaveFilePicker === 'function')
{
// Show the "Save As" dialog
let fileWriter;
try {
const fileHandle = await (window as any).showSaveFilePicker();
// Create a FileSystemWritableFileStream to write to.
fileWriter = await fileHandle.createWritable();
} catch {
// User pressed cancel, so abort the whole thing
return;
}

var dataStream = new ReadableStream(data);
const reader = dataStream.getReader();
while (true) {
const readResult = await reader.read();
if (readResult.done) {
break;
}
// Write the contents of the file to the stream.
await fileWriter.write(readResult.value);
}
// Close the file and write the contents to disk
await fileWriter.close();
}
else {
// The following option works for all browsers
const arrayBuffer = await data.arrayBuffer();
const blob = new Blob([arrayBuffer]);
const url = URL.createObjectURL(blob);
const anchorElement = document.createElement('a');
anchorElement.href = url;
anchorElement.download = fileName;
anchorElement.click();
anchorElement.remove();
URL.revokeObjectURL(url);
}
}
Copy link
Member

Choose a reason for hiding this comment

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

It's great that you've implemented downloadFile using Blob and createObjectURL since that's the only way that works on all browsers. We do need this.

However, this approach does have the drawback that it involves:

[1] Buffering the entire file in browser memory, even if it's gigabytes
[2] Not showing the "save as" dialog until we've got the while file data into browser memory

On Chromium browsers (and probably other browsers in the future), there's a new spec called the FileSystem API that provides a better option, whereby we can show the "save as" dialog first, and then stream the data into the file without buffering it all in memory. It works like this:

// This is actually supplied by JS interop - no need to construct a ReadableStream in the new code
const readableStream = new Response(new TextEncoder().encode('Some contents here')).body;

// Show the "save as dialog"
let fileWriter;
try {
    const fileHandle = await window.showSaveFilePicker();
    fileWriter = await fileHandle.createWritable();
} catch {
    // User pressed cancel, so abort the whole thing
    return;
}

const reader = readableStream.getReader();
while (true) {
    const readResult = await reader.read();
    if (readResult.done) {
        break;
    }

    await fileWriter.write(readResult.value);
}

await fileWriter.close();

You can detect whether or not the FileSystem API is available in the current browser by checking if typeof(window.showSaveFilePicker) === 'function'.

So, in order to provide a better experience on Chromium (and future) browsers, do you think we could detect and window.showSaveFilePicker and switch between the two implementations based on that?

Copy link
Member

@SteveSandersonMS SteveSandersonMS Aug 4, 2022

Choose a reason for hiding this comment

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

BTW I'm aware this means the user won't see the file showing up in their "Downloads" UI within the browser. But if the Blob approach doesn't really solve that either because the download doesn't happen as far as the browser is concerned until the JS code has actually downloaded all the data and buffered it in memory, so you don't see any progress during the file transfer - it's like nothing is happening. So I still think the UX is better with the FileSystem API implementation overall.

Copy link
Member

Choose a reason for hiding this comment

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

Agree, this would be nice to have.

Do we think we need some sort of way to have progress reporting? I can think that we could do this directly on the .NET side. (Technically users could do this themselves) by wrapping the Stream and firing up an event as it is being read. So, a component could register for that event and update the UI accordingly (Imagine a toast/progress bar type of thing).

That would to a degree for both cases (new API + old API). That said, if we can optionally leverage the new API, that would be great (and orthogonal to my suggestion)

Copy link
Member

Choose a reason for hiding this comment

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

Maybe this is the basis for having a component for this in the future, imagine something like <DownloadableFile Source="..." /> that displays a button to start the download, and switches to render a progress bar in place as the download is happening. (not saying we have to do it, just throwing the idea out there)

Copy link
Member

Choose a reason for hiding this comment

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

Do we think we need some sort of way to have progress reporting? ... Technically users could do this themselves

The fact that people can already do this themselves on the .NET side makes it fine to not bake in any secondary feature around progress status right now. Let's not expand the scope of the feature too much.

imagine something like that displays a button to start the download, and switches to render a progress bar in place as the download is happening ... not saying we have to do it

Agreed that would be cool, but also agreed that it doesn't have to be baked-in as a framework feature when people can do it themselves. Maybe in the long term if there's clear enough customer demand though!

Copy link
Contributor

@TanayParikh TanayParikh Aug 4, 2022

Choose a reason for hiding this comment

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

Thanks Steve, wasn't aware of that API. Took a brief look and another couple of things to note for showSaveFilePicker:

  • Chromium desktop exclusive (doesn't work on mobile)
  • Requires secure context

caniuse has this at 27% of all users and 75% of desktop users. Anecdotally, given users are probably more likely to be downloading on a Desktop than a mobile device, I still think this would be worthwhile.

3 changes: 3 additions & 0 deletions src/Components/Web.JS/src/GlobalExports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { Platform, Pointer, System_String, System_Array, System_Object, System_B
import { getNextChunk, receiveDotNetDataStream } from './StreamingInterop';
import { RootComponentsFunctions } from './Rendering/JSRootComponents';
import { attachWebRendererInterop } from './Rendering/WebRendererInteropMethods';
import { downloadFile } from './FileDownloader';

interface IBlazor {
navigateTo: (uri: string, options: NavigationOptions) => void;
Expand Down Expand Up @@ -63,6 +64,7 @@ interface IBlazor {
getJSDataStreamChunk?: (data: any, position: number, chunkSize: number) => Promise<Uint8Array>,
receiveDotNetDataStream?: (streamId: number, data: any, bytesRead: number, errorMessage: string) => void,
attachWebRendererInterop?: typeof attachWebRendererInterop,
downloadFile?: (data: any, fileName: string) => Promise<void>,

// APIs invoked by hot reload
applyHotReload?: (id: string, metadataDelta: string, ilDelta: string, pdbDelta: string | undefined) => void,
Expand All @@ -85,6 +87,7 @@ export const Blazor: IBlazor = {
getJSDataStreamChunk: getNextChunk,
receiveDotNetDataStream: receiveDotNetDataStream,
attachWebRendererInterop,
downloadFile: downloadFile,
},
};

Expand Down
24 changes: 24 additions & 0 deletions src/Components/Web/src/IFileDownloader.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

namespace Microsoft.AspNetCore.Components;

/// <summary>
/// Facilitates triggering a file download on the client using data from .NET.
/// </summary>
public interface IFileDownloader
{
/// <summary>
/// Takes in a byte[] representing file data and triggers the file download on the client.
/// </summary>
/// <param name="fileName">A <see cref="string"/> that represents the default name of the file that will be downloaded.</param>
/// <param name="data"> The <see cref="byte"/>[] data that will be downloaded by the client.</param>
Task SaveAs(string fileName, byte[] data);

/// <summary>
/// Takes in a <see cref="Stream"/> representing file data and triggers the file download on the client.
/// </summary>
/// <param name="fileName">A <see cref="string"/> that represents the default name of the file that will be downloaded.</param>
/// <param name="data"> The <see cref="Stream"/> data that will be downloaded by the client.</param>
Task SaveAs(string fileName, Stream data);
}
3 changes: 3 additions & 0 deletions src/Components/Web/src/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,6 @@ Microsoft.AspNetCore.Components.Web.MouseEventArgs.MovementX.get -> double
Microsoft.AspNetCore.Components.Web.MouseEventArgs.MovementX.set -> void
Microsoft.AspNetCore.Components.Web.MouseEventArgs.MovementY.get -> double
Microsoft.AspNetCore.Components.Web.MouseEventArgs.MovementY.set -> void
Microsoft.AspNetCore.Components.IFileDownloader
Microsoft.AspNetCore.Components.IFileDownloader.SaveAs(string! fileName, byte[]! data) -> System.Threading.Tasks.Task!
Microsoft.AspNetCore.Components.IFileDownloader.SaveAs(string! fileName, System.IO.Stream! data) -> System.Threading.Tasks.Task!
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,7 @@ internal void InitializeDefaultServices()
Services.AddSingleton<INavigationInterception>(WebAssemblyNavigationInterception.Instance);
Services.AddSingleton(new LazyAssemblyLoader(DefaultWebAssemblyJSRuntime.Instance));
Services.AddSingleton<ComponentStatePersistenceManager>();
Services.AddSingleton<IFileDownloader, FileDownloader>();
Services.AddSingleton<PersistentComponentState>(sp => sp.GetRequiredService<ComponentStatePersistenceManager>().State);
Services.AddSingleton<IErrorBoundaryLogger, WebAssemblyErrorBoundaryLogger>();
Services.AddLogging(builder =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
<Compile Include="$(ComponentsSharedSourceRoot)src\ElementReferenceJsonConverter.cs" />
<Compile Include="$(ComponentsSharedSourceRoot)src\ComponentParametersTypeCache.cs" />
<Compile Include="$(ComponentsSharedSourceRoot)src\RootComponentTypeCache.cs" />
<Compile Include="$(ComponentsSharedSourceRoot)src\FileDownloader.cs" />
<Compile Include="$(SharedSourceRoot)Components\WebAssemblyComponentSerializationSettings.cs" Link="Prerendering/WebAssemblyComponentSerializationSettings.cs" />
<Compile Include="$(SharedSourceRoot)Components\WebAssemblyComponentMarker.cs" Link="Prerendering/WebAssemblyComponentMarker.cs" />
<Compile Include="$(SharedSourceRoot)Components\ComponentParameter.cs" Link="Prerendering/ComponentParameter.cs" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ public static IServiceCollection AddBlazorWebView(this IServiceCollection servic
services.TryAddScoped<INavigationInterception, WebViewNavigationInterception>();
services.TryAddScoped<NavigationManager, WebViewNavigationManager>();
services.TryAddScoped<IErrorBoundaryLogger, WebViewErrorBoundaryLogger>();
services.TryAddScoped<IFileDownloader, FileDownloader>();
return services;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
<Compile Include="$(ComponentsSharedSourceRoot)src\TransmitDataStreamToJS.cs" LinkBase="Shared" />
<Compile Include="$(ComponentsSharedSourceRoot)src\ElementReferenceJsonConverter.cs" LinkBase="Shared" />
<Compile Include="$(ComponentsSharedSourceRoot)src\JsonSerializerOptionsProvider.cs" LinkBase="Shared" />
<Compile Include="$(ComponentsSharedSourceRoot)src\FileDownloader.cs" LinkBase="Shared" />
<Compile Include="$(SharedSourceRoot)LinkerFlags.cs" LinkBase="Shared" />
<Compile Include="$(SharedSourceRoot)StaticWebAssets\**\*.cs" LinkBase="Shared" />
<Compile Include="$(ComponentsSharedSourceRoot)src\ArrayBuilder.cs" LinkBase="Shared" />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
@using Microsoft.AspNetCore.Components

@inject IFileDownloader FileDownload

<h1>File configuration</h1>
File name:
<br />

<input type="text" @bind-value="@fileName" />
<br />

<h1>File download</h1>

<button @onclick="SaveFile"> Save </button>

@code {
private string fileName = "file.txt";

private Stream GetFileStream()
{
var data = new byte[100 * 1024];
for (var i = 0; i < data.Length; i++)
{
data[i] = (byte)(i % 256);
}
var fileStream = new MemoryStream(data);

return fileStream;
}

private async Task SaveFile()
{
var data = GetFileStream();
await FileDownload.SaveAs(fileName, data);
}
}
Copy link
Member

Choose a reason for hiding this comment

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

This looks good. Do you know if Selenium gives us any sensible way to automate a test about file downloads? I'm unsure.

Copy link
Member

Choose a reason for hiding this comment

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

We could hijack the JS on the Blazor instance to skip the part about actually showing the dialog (I do not believe there is a way to deal with it in Selenium)

Copy link
Member

Choose a reason for hiding this comment

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

Sounds a bit complicated. We could add it to CTI tests I guess, even though it's a bit unusual to test a relatively low-level feature via CTI.

Copy link
Contributor

@TanayParikh TanayParikh Aug 4, 2022

Choose a reason for hiding this comment

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

Do you know if Selenium gives us any sensible way to automate a test about file downloads?

Unfortunately, this doesn't look like something that's encouraged:
https://www.selenium.dev/documentation/test_practices/discouraged/file_downloads/

There are workarounds, but I don't think it's worth the additional long term maintenance complexity. We'll have to defer this to CTI as you mention.

1 change: 1 addition & 0 deletions src/Components/test/testassets/BasicTestApp/Index.razor
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@
<option value="BasicTestApp.VirtualizationDataChanges">Virtualization data changes</option>
<option value="BasicTestApp.VirtualizationTable">Virtualization HTML table</option>
<option value="BasicTestApp.HotReload.RenderOnHotReload">Render on hot reload</option>
<option value="BasicTestApp.FileDownloaderComponent">Save file download</option>
</select>

<span id="runtime-info"><code><tt>@System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription</tt></code></span>
Expand Down