Download File from Server with Blazor App

12,829

Solution 1

Browsers don't allow scripts to write to the file system, whether written in JavaScript or WebAssembly. The download dialog is displayed by the browser only when the user clicks on a link.

Using a link button

If the final file is returned directly from the server, the easiest solution is to use a link button with a URL to the API endpoint, possibly calculated at runtime. You can use the download attribute to specify a file name. When the user clicks on the link, the file will be retrieved and saved using the download name

For example :

<a id="exportCsv" class="btn" href="api/csvProduct" download="MyFile.csv" 
   role="button" target="=_top">Export to CSV</a>

or

@if (_exportUrl != null)
{
    <a id="exportCsv" class="btn" href="@_exportUrl" download="MyFile.csv" 
       role="button" target="=_top">Export to Csv</a>
}

...
int _productId=0;
string? _exportUrl=null;

async Task Search()
{
   //Get and display a product summary
   _model=await GetProductSummary(_productId);
   //Activate the download URL 
   _exportUrl = $"api/csvProduct/{_productId}";
}

Using a dynamically generated data link

If that's not possible, you have to create a link element in JavaScript with a data URL, or a Blob, and click it. That's SLOOOOW for three reasons :

  1. You're making an in-memory copy of the downloaded file that's at least 33% larger than the original.
  2. JS interop data marshalling is slow, which means that passing the bytes from Blazor to Javascript is also slow.
  3. Byte arrays are passed as Base64 strings. These need to be decoded back into a byte array to be used as blobs.

The article Generating and efficiently exporting a file in a Blazor WebAssembly application shows how to pass the bytes without marshaling using some Blazor runtime tricks.

If you use Blazor WASM, you can use use InvokeUnmarshalled to pass a byte[] array and have it appear as a Uint8Array in JavaScript.

    byte[] file = Enumerable.Range(0, 100).Cast<byte>().ToArray();
    string fileName = "file.bin";
    string contentType = "application/octet-stream";

    // Check if the IJSRuntime is the WebAssembly implementation of the JSRuntime
    if (JSRuntime is IJSUnmarshalledRuntime webAssemblyJSRuntime)
    {
        webAssemblyJSRuntime.InvokeUnmarshalled<string, string, byte[], bool>("BlazorDownloadFileFast", fileName, contentType, file);
    }
    else
    {
        // Fall back to the slow method if not in WebAssembly
        await JSRuntime.InvokeVoidAsync("BlazorDownloadFile", fileName, contentType, file);
    }

The BlazorDownloadFileFast JavaScript method retrieves the array, converts it to a File and then, through URL.createObjectURL to a safe data URL that can be clicked :

function BlazorDownloadFileFast(name, contentType, content) {
    // Convert the parameters to actual JS types
    const nameStr = BINDING.conv_string(name);
    const contentTypeStr = BINDING.conv_string(contentType);
    const contentArray = Blazor.platform.toUint8Array(content);

    // Create the URL
    const file = new File([contentArray], nameStr, { type: contentTypeStr });
    const exportUrl = URL.createObjectURL(file);

    // Create the <a> element and click on it
    const a = document.createElement("a");
    document.body.appendChild(a);
    a.href = exportUrl;
    a.download = nameStr;
    a.target = "_self";
    a.click();

    // We don't need to keep the url, let's release the memory
    // On Safari it seems you need to comment this line... (please let me know if you know why)
    URL.revokeObjectURL(exportUrl);
    a.remove();
}

With Blazor Server, marshaling is unavoidable. In this case the slower BlazorDownloadFile method is called. The byte[] array is marshaled as a BASE64 string which has to be decoded. Unfortunately, JavaScript's atob and btoa functions can't handle every value so we need another method to decode Base64 into Uint8Array:

function BlazorDownloadFile(filename, contentType, content) {
    // Blazor marshall byte[] to a base64 string, so we first need to convert the string (content) to a Uint8Array to create the File
    const data = base64DecToArr(content);

    // Create the URL
    const file = new File([data], filename, { type: contentType });
    const exportUrl = URL.createObjectURL(file);

    // Create the <a> element and click on it
    const a = document.createElement("a");
    document.body.appendChild(a);
    a.href = exportUrl;
    a.download = filename;
    a.target = "_self";
    a.click();

    // We don't need to keep the url, let's release the memory
    // On Safari it seems you need to comment this line... (please let me know if you know why)
    URL.revokeObjectURL(exportUrl);
    a.remove();
}

And the decoder function, borrowed from Mozilla's Base64 documentation

// Convert a base64 string to a Uint8Array. This is needed to create a blob object from the base64 string.
// The code comes from: https://developer.mozilla.org/fr/docs/Web/API/WindowBase64/D%C3%A9coder_encoder_en_base64
function b64ToUint6(nChr) {
  return nChr > 64 && nChr < 91 ? nChr - 65 : nChr > 96 && nChr < 123 ? nChr - 71 : nChr > 47 && nChr < 58 ? nChr + 4 : nChr === 43 ? 62 : nChr === 47 ? 63 : 0;
}

function base64DecToArr(sBase64, nBlocksSize) {
  var
    sB64Enc = sBase64.replace(/[^A-Za-z0-9\+\/]/g, ""),
    nInLen = sB64Enc.length,
    nOutLen = nBlocksSize ? Math.ceil((nInLen * 3 + 1 >> 2) / nBlocksSize) * nBlocksSize : nInLen * 3 + 1 >> 2,
    taBytes = new Uint8Array(nOutLen);

  for (var nMod3, nMod4, nUint24 = 0, nOutIdx = 0, nInIdx = 0; nInIdx < nInLen; nInIdx++) {
    nMod4 = nInIdx & 3;
    nUint24 |= b64ToUint6(sB64Enc.charCodeAt(nInIdx)) << 18 - 6 * nMod4;
    if (nMod4 === 3 || nInLen - nInIdx === 1) {
      for (nMod3 = 0; nMod3 < 3 && nOutIdx < nOutLen; nMod3++, nOutIdx++) {
        taBytes[nOutIdx] = nUint24 >>> (16 >>> nMod3 & 24) & 255;
      }
      nUint24 = 0;
    }
  }
  return taBytes;
}

Blazor 6

The ASP.NET Core 6 Preview 6 that was released recently no longer marshals byte[] as a Base64 string. It should be possible to use the following function

function BlazorDownloadFile(filename, contentType, data) {

    // Create the URL
    const file = new File([data], filename, { type: contentType });
    const exportUrl = URL.createObjectURL(file);

    // Create the <a> element and click on it
    const a = document.createElement("a");
    document.body.appendChild(a);
    a.href = exportUrl;
    a.download = filename;
    a.target = "_self";
    a.click();

    // We don't need to keep the url, let's release the memory
    // On Safari it seems you need to comment this line... (please let me know if you know why)
    URL.revokeObjectURL(exportUrl);
    a.remove();
}

Solution 2

In order to download file you have to use Microsoft JSInterop. There are many ways to implement your request. One way that i use, is to get the file as byte array then convert it to base64string. Finally call the function that you created in javascript.

In server side

js.InvokeVoidAsync("jsSaveAsFile",
                        filename,
                        Convert.ToBase64String(GetFileByteArrayFunction())
                        );

And in javascript file in wwwroot you create a function

function jsSaveAsFile(filename, byteBase64) {
var link = document.createElement('a');
link.download = filename;
link.href = "data:application/octet-stream;base64," + byteBase64;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);}
Share:
12,829
BennoDual
Author by

BennoDual

I am a C# Developer interested on Best Practice Frameworks.

Updated on June 11, 2022

Comments

  • BennoDual
    BennoDual almost 2 years

    I have created an HttpGet in my Server-API which creates a CSV-File and returns it with FileStreamResult:

    [HttpGet]
    public IActionResult Get() {
        // do logic to create csv in memoryStream
    
        return new FileStreamResult(memoryStream, "text/csv;charset=utf-8") {
            FileDownloadName = "products.csv",
        };
    }
    

    In my Blazor-Client App, I have created a Button with a handler:

    private async Task DownloadCatalog() {
        var file = HttpClient.GetAsync("api/csvProduct");
    
        // ... how do I download the file in the browser?
    }
    

    The Get in the Controller is called, but I don't know what to do so that the file is downloaded in the browser after the api call.

    • Lei Yang
      Lei Yang almost 3 years
      did some search, you can either invoke javascript or Navigation.NavigateTo(the api endpoint teturning File)
  • Tim
    Tim over 2 years
    Is there any way to modify this code so that the file is opened for viewing instead of downloaded? So, if the file happens to be an image, it is opened in the user's default image viewer, and if it is an XML, it is opened in the default browser (or another app depending on the settings), etc.?
  • Tim Davis
    Tim Davis over 2 years
    This is genius.. Must be the _top or role=button... saved me from doing all that nastiness you posted above
  • Panagiotis Kanavos
    Panagiotis Kanavos over 2 years
    @TimDavis I didn't invent this and the code is still nasty. There's a draft File System API but few browsers support it yet. showSaveFilePicker is supported only on recent Chrome and Edge versions
  • Allie
    Allie over 2 years
    thank you, this works very good. Small note: this only worked for me when I placed this code inside the @code { } section on the razor page. It did not work when placed in the code behind.
  • Baskovli
    Baskovli over 2 years
    The function jsSaveAsFile is placed on <scritps> section and it is called on Index.html It is not necessary to call it from code section, it is up to you. I call it from class where i generate the file.
  • Erik Thysell
    Erik Thysell over 2 years
    Thank you so much for sharing this, especially the excellent update for .NET 6
  • Erik Thysell
    Erik Thysell over 2 years
    I would just recommend to a.remove(); after the file is downloaded, just for house keeping.
  • Erik Thysell
    Erik Thysell over 2 years
    @PanagiotisKanavos but when I don't remove the a element, the element remains in the DOM with an invalid url to the removed object.
  • Mmm
    Mmm over 2 years
    This is much faster than the Microsoft Recommended Way of doing it. Kudos!
  • Panagiotis Kanavos
    Panagiotis Kanavos over 2 years
    @Mmm the basic technique is the same. If you check the code, all snippets pass the bytes to Javascript which then creates a blob, then a data URL and clicks it. The unmarshalled trick was written by meziantou, not me. The only question is how to pass the data without double/triple-buffering. The MS article works for both WASM and Server and uses the newer stream support to pass the data from server to browser without converting it to BASE64. For large files read from disk this will be better than loading the entire file in memory, then sending it to the browser
  • Panagiotis Kanavos
    Panagiotis Kanavos over 2 years
    @Mmm imagine you have a server-generated PDF or Excel file for example. It's a lot better to open a stream on that file and "send" the stream to the browser than buffering the entire file in memory and then .... have ASP.NET Core buffer the response again if you're not careful. PS: in the MS example the MemoryStream is used as a mock for a file stream in the GetFileStream() method. They explain that in the paragraph above the method
  • Mmm
    Mmm over 2 years
    The technique may be the same, however the MS version is significantly slower from my experience.
  • Sebastian
    Sebastian almost 2 years
    Any idea how to overcome the size limitation of byte array ? Can export records up to 1.5 lakh records , but trying to export more records no actions occuring
  • Panagiotis Kanavos
    Panagiotis Kanavos almost 2 years
    Don't use such a blob to begin with. Browsers don't like big blobs. Use a download link. Until browsers (and Blazor) allow saving to a local file, the only way to trigger a save is to dynamically construct a download link. That means the same data ends up cached on the browser multiple times - in Blazor and the JS object URL at least
  • Sebastian
    Sebastian almost 2 years
    @PanagiotisKanavos Is it the approach using Link Button on this answer?
  • Panagiotis Kanavos
    Panagiotis Kanavos almost 2 years
    It's the very first one. If preparing the file takes a long time you could start with a hidden link and display it to the user, perhaps even in a Modal, when the file preparation is done.