September 28th, 2021

Custom deployment layout for Blazor WebAssembly apps

Javier Calvarro Nelson
Senior Software Engineer

Some environments block the download and execution of DLLs from the network to prevent the potential spread of malware, which can also block downloading Blazor WebAssembly apps. To enable Blazor WebAssembly in these environments, we introduced in .NET 6 new extensibility points that allows developers to customize the published files and packaging of Blazor WebAssembly apps. These customizations can then be packaged as a reusable NuGet package.

There are two main features that make this possible:

  • JavaScript initializers that allow customizing the Blazor boot process.
  • MSBuild extensibility to transform the list of publish files and define Blazor publish extensions.

JavaScript initializers

JavaScript initializers are JavaScript modules loaded during the Blazor boot process. These modules can export two functions that get called at specific points early in the lifecycle of the host app:

  • beforeStart: Invoked by Blazor before the app is started.
  • afterStarted: Invoked by Blazor after the .NET runtime has started.

In Blazor WebAssembly apps, beforeStarts receives two pieces of data:

  • Blazor WebAssembly options that can be changed to provide a custom resource loader.
  • An extensions object that contains a collection of extensions defined for the app. Each of these extensions is an JavaScript object that contains a list of files relevant to that extension.

Blazor publish extensions

Blazor publish extensions are files that can be defined as part of the publish process and that provide an alternative representation for the set of files needed to run the published app.

For example, in this post we’ll create a Blazor publish extension that produces a multipart bundle with all the app DLLs packed into a single file so they can be downloaded together. We hope this sample will serve as a starting point for people to come up with their own strategies and custom loading processes.

Customizing the Blazor WebAssembly loading process via a NuGet package

In this example, we’re going to pack all the Blazor app resources into a bundle file as a multipart file bundle and load it on the browser via a custom JavaScript initializer. For an app consuming this package, they only need to make sure that the bundle file is being served. Everything else is handled transparently.

There are four things that we need to customize how a published Blazor app loads:

  • An MSBuild task to transform the publish files.
  • A package with MSBuild targets that hooks into the Blazor publishing process, transforms the output, and defines one or more Blazor publish extension files (in this case, a single bundle).
  • A JavaScript initializer to update the Blazor WebAssembly resource loader callback so that it loads the bundle and provides the app with the individual files.
  • A small helper on the host server app to ensure we serve the bundle.

Writing an MSBuild task to customize the list of published files and define new extensions

An MSBuild task is a public C# class that can be imported as part of an MSBuild compilation and that can interact with the build.

Before we write our C# class, we need to do the following:

  • We need to create a new class library project.
  • Change the target framework to netstandard2.0
  • Reference the MSBuild packages

After that, the csproj file should look something like this:

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>netstandard2.0</TargetFramework>
    <LangVersion>8.0</LangVersion>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.Build.Framework" Version="16.10.0" />
    <PackageReference Include="Microsoft.Build.Utilities.Core" Version="16.10.0" />
  </ItemGroup>

</Project>

Now that our project is created, we can create our MSBuild task. To do so, we create a public class extending Microsoft.Build.Utilities.Task (not System.Threading.Tasks.Task) and declare three properties:

  • PublishBlazorBootStaticWebAsset: The list of files to publish for the Blazor app
  • BundlePath: The path where we need to write the bundle.
  • Extension: The new publish extensions to include in the build.

A sketch of the code can be seen below.

namespace Microsoft.AspNetCore.Components.WebAssembly.MultipartBundle.Tasks
{
    public class BundleBlazorAssets : Task
    {
        [Required]
        public ITaskItem[] PublishBlazorBootStaticWebAsset { get; set; }

        [Required]
        public string BundlePath { get; set; }

        [Output]
        public ITaskItem[] Extension { get; set; }

        public override bool Execute()
        {
          ...
        }
    }
}

The remaining piece is to implement the Execute method, where we take the files and create the bundle. There are three types of files we are going to deal with:

  • JavaScript files (dotnet.js)
  • WASM files (dotnet.wasm)
  • App DLLs.

We are going to create a multipart/form-data bundle and add each file to the bundle with their respective descriptions via the content disposition header and the content type header. The code can be seen below:

var bundle = new MultipartFormDataContent("--0a7e8441d64b4bf89086b85e59523b7d");
foreach (var asset in PublishBlazorBootStaticWebAsset)
{
    var name = Path.GetFileName(asset.GetMetadata("RelativePath"));
    var fileContents = File.OpenRead(asset.ItemSpec);
    var content = new StreamContent(fileContents);
    var disposition = new ContentDispositionHeaderValue("form-data");
    disposition.Name = name;
    disposition.FileName = name;
    content.Headers.ContentDisposition = disposition;
    var contentType = Path.GetExtension(name) switch
    {
        ".js" => "text/javascript",
        ".wasm" => "application/wasm",
        _ => "application/octet-stream"
    };
    content.Headers.ContentType = MediaTypeHeaderValue.Parse(contentType);
    bundle.Add(content);
}

Now that we’ve created our bundle, we need to write it to a file:

using (var output = File.Open(BundlePath, FileMode.OpenOrCreate))
{
    output.SetLength(0);
    bundle.CopyToAsync(output).ConfigureAwait(false).GetAwaiter().GetResult();
    output.Flush(true);
}

Finally, we need to let the build know about our extension. We do so by creating an extension item and adding it to the Extension property. Each extension item contains three pieces of data:

  • The path to the extension file
  • The URL path relative to the root of the Blazor WebAssembly app.
  • The name of the extension, which groups the files produced by a given extension. We’ll use this to refer to the extension later.

In our extension we define the item as follows:

var bundleItem = new TaskItem(BundlePath);
bundleItem.SetMetadata("RelativePath", "app.bundle");
bundleItem.SetMetadata("ExtensionName", "multipart");

Extension = new ITaskItem[] { bundleItem };

return true;

With that, we’ve authored an MSBuild task for customizing the Blazor publish output. Blazor will take care of gathering the extensions and making sure that they get copied to the right place in the publish output folder and will apply the same optimizations (compression) it applies to other files.

For clarity, here is the full class in one snippet:

using System.IO;
using System.Net.Http;
using System.Net.Http.Headers;
using Microsoft.Build.Framework;
using Microsoft.Build.Utilities;

namespace Microsoft.AspNetCore.Components.WebAssembly.MultipartBundle.Tasks
{
    public class BundleBlazorAssets : Task
    {
        [Required]
        public ITaskItem[] PublishBlazorBootStaticWebAsset { get; set; }

        [Required]
        public string BundlePath { get; set; }

        [Output]
        public ITaskItem[] Extension { get; set; }

        public override bool Execute()
        {
            var bundle = new MultipartFormDataContent("--0a7e8441d64b4bf89086b85e59523b7d");
            foreach (var asset in PublishBlazorBootStaticWebAsset)
            {
                var name = Path.GetFileName(asset.GetMetadata("RelativePath"));
                var fileContents = File.OpenRead(asset.ItemSpec);
                var content = new StreamContent(fileContents);
                var disposition = new ContentDispositionHeaderValue("form-data");
                disposition.Name = name;
                disposition.FileName = name;
                content.Headers.ContentDisposition = disposition;
                var contentType = Path.GetExtension(name) switch
                {
                    ".js" => "text/javascript",
                    ".wasm" => "application/wasm",
                    _ => "application/octet-stream"
                };
                content.Headers.ContentType = MediaTypeHeaderValue.Parse(contentType);
                bundle.Add(content);
            }

            using (var output = File.Open(BundlePath, FileMode.OpenOrCreate))
            {
                output.SetLength(0);
                bundle.CopyToAsync(output).ConfigureAwait(false).GetAwaiter().GetResult();
                output.Flush(true);
            }

            var bundleItem = new TaskItem(BundlePath);
            bundleItem.SetMetadata("RelativePath", "app.bundle");
            bundleItem.SetMetadata("ExtensionName", "multipart");

            Extension = new ITaskItem[] { bundleItem };

            return true;
        }
    }
}

Now that we have an MSBuild task capable of transforming the publish output, we need a bit of plumbing code to hook it to the MSBuild pipeline.

Authoring a NuGet package for automatically transforming the publish output

A good way to create a reusable solution is to generate a NuGet package with MSBuild targets that are automatically included when the package is referenced. For that, the steps are:

  • Create a new Razor class library project.
  • Create a targets file following the NuGet conventions to automatically import it in consuming projects.
  • Collect the output from the class library containing the MSBuild task and make sure it gets packed in the right location.
  • Make sure all the required files are packed in the right location.
  • Add the necessary MSBuild code to attach to the Blazor pipeline and invoke our task to generate the bundle.

First we create a Razor class library and remove all the content from it.

<Project Sdk="Microsoft.NET.Sdk.Razor">

  <PropertyGroup>
    <TargetFramework>net6.0</TargetFramework>
  </PropertyGroup>

</Project>

Next we create a file build\net6.0\<<PackageId>>.targets where <<PackageId>> is the name of our package. We’ll fill in the contents later.

After that, we need to make sure we collect all the DLLs required for the MSBuild task. We can do so by creating a custom target in the package project file. In our target we invoke MSBuild over the project with our task and capture the output in the _TasksProjectOutputs item group as follows:

<Target Name="GetTasksOutputDlls" BeforeTargets="CoreCompile">
  <MSBuild Projects="$(PathToTasksFolder)" Targets="Publish;PublishItemsOutputGroup" Properties="Configuration=Release">
    <Output TaskParameter="TargetOutputs" ItemName="_TasksProjectOutputs" />
  </MSBuild>
</Target>

The next step is to make sure that our content is included in the package. To include the targets file we use the following snippet:

<ItemGroup>
  <None Update="build\**" Pack="true" PackagePath="%(Identity)" />
</ItemGroup>

This tells NuGet to pack the file and place it on the same path in the package.

We add the task DLLs as content after we’ve invoked the MSBuild task inside the tasks subfolder.

<Target Name="GetTasksOutputDlls" BeforeTargets="CoreCompile">
  ...
  <ItemGroup>
    <Content Include="@(_TasksProjectOutputs)" Condition="'%(_TasksProjectOutputs.Extension)' == '.dll'" Pack="true" PackagePath="tasks\%(_TasksProjectOutputs.TargetPath)" KeepMetadata="Pack;PackagePath" />
  </ItemGroup>
</Target>

Finally, we need to setup some properties to keep NuGet happy, since this doesn’t include a library DLL like most packages do (we are only using it as a mechanism to deliver our targets and content).

The finished project file is displayed below:

<Project Sdk="Microsoft.NET.Sdk.Razor">

  <PropertyGroup>
    <!-- Suppress the warning about the assemblies we are putting in the task folder. -->
    <NoWarn>NU5100</NoWarn>
    <TargetFramework>net6.0</TargetFramework>
    <IncludeBuildOutput>false</IncludeBuildOutput>
  </PropertyGroup>

  <ItemGroup>
    <None Update="build\**" Pack="true" PackagePath="%(Identity)" />
    <Content Include="_._" Pack="true" PackagePath="lib\net6.0\_._" />
  </ItemGroup>

  <Target Name="GetTasksOutputDlls" BeforeTargets="CoreCompile">
    <MSBuild Projects="..\Microsoft.AspNetCore.Components.WebAssembly.MultipartBundle.Tasks\Microsoft.AspNetCore.Components.WebAssembly.MultipartBundle.Tasks.csproj" Targets="Publish;PublishItemsOutputGroup" Properties="Configuration=Release">
      <Output TaskParameter="TargetOutputs" ItemName="_TasksProjectOutputs" />
    </MSBuild>
    <ItemGroup>
      <Content Include="@(_TasksProjectOutputs)" Condition="'%(_TasksProjectOutputs.Extension)' == '.dll'" Pack="true" PackagePath="tasks\%(_TasksProjectOutputs.TargetPath)" KeepMetadata="Pack;PackagePath" />
    </ItemGroup>
  </Target>

</Project>

All that remains now is to add a .targets file to wire up our task to the build pipeline. In this file we need to do the following:

  • Import our task into the build process.
  • Attach a custom target to the Blazor WebAssembly build pipeline.
  • Invoke our task in the target to produce the results.

We start by defining an empty project on the file

<Project>
</Project>

Next, we import our task. Note that the path to the DLL is relative to where this file will be in the package:

<UsingTask 
  TaskName="Microsoft.AspNetCore.Components.WebAssembly.MultipartBundle.Tasks.BundleBlazorAssets" 
  AssemblyFile="$(MSBuildThisProjectFileDirectory)..\..\tasks\Microsoft.AspNetCore.Components.WebAssembly.MultipartBundle.Tasks.dll" />

We define a target that invokes our bundling task:

<Target Name="_BundleBlazorDlls">
  <BundleBlazorAssets
    PublishBlazorBootStaticWebAsset="@(PublishBlazorBootStaticWebAsset)"
    BundlePath="$(IntermediateOutputPath)bundle.multipart"
  >
    <Output TaskParameter="Extension" ItemName="BlazorPublishExtension"/>
  </BundleBlazorAssets>
</Target>

The list of published files is provided by the Blazor WebAssembly pipeline in the PublishBlazorBootStaticWebAsset item group.

We define the bundle path using the IntermediateOutputPath (typically inside the obj folder). The bundle will later get copied automatically to the right location in the publish output folder.

Finally, we capture the Extension property on the task output and add it to BlazorPublishExtension to tell Blazor about the extension.

We can now attach our custom target to the Blazor WebAssembly pipeline:

<PropertyGroup>
  <ComputeBlazorExtensionsDependsOn>$(ComputeBlazorExtensionsDependsOn);_BundleBlazorDlls</ComputeBlazorExtensionsDependsOn>
</PropertyGroup>

With this, we have a package that when referenced will generate a bundle of the Blazor files during publish. However, we haven’t yet seen how to automatically bootstrap a Blazor WebAssembly app from that bundle instead of using the DLLs. We’ll tackle that next.

Automatically bootstrap Blazor from the bundle

This is where we will leverage JavaScript initializers. We’ll use a JavaScript initializer to change the Blazor boot resource loader and use our bundle instead.

To create a JavaScript initializer, add a JavaScript file with the name <<PackageId>>.lib.module.js to the wwwroot folder of the package project. Once we’ve done that, we can export two functions to handle the loading.

export async function beforeStart(wasmOptions, extensions) {
  ...
}

export async function afterStarted(blazor) {
  ...
}

The approach that we’re going to follow is:

  • Detect if our extension is available.
  • Download the bundle
  • Parse the contents and create a map of resources using object URLs.
  • Update the wasmOptions.loadBootResource with our own function that resolves the resources using object URLs.
  • After the app has started, revoke the object URLs to release memory.

Most of the steps happen inside beforeStart, with only the last happening in afterStarted.

To detect if our extension is available, we check the extensions argument:

if (!extensions || !extensions.multipart) {
    return;
}

Remember that multipart bit that we added in the extension definition inside the Task? It shows up here.

Next we’re going to download the bundle and parse its contents into a resources map. The resources map is defined locally at the top of the file.

try {
    const integrity = extensions.multipart['app.bundle'];
    const bundleResponse = await fetch('app.bundle', { integrity: integrity, cache: 'no-cache' });
    const bundleFromData = await bundleResponse.formData();
    for (let value of bundleFromData.values()) {
        resources.set(value, URL.createObjectURL(value));
    }
} catch (error) {
    console.log(error);
}

After that, we customize the options to use our custom boot resource loader. We do this after we’ve created the object URLs:

wasmOptions.loadBootResource = function (type, name, defaultUri, integrity) {
    return resources.get(name) ?? null;
}

Finally, we release all the object URLs after the app has started within the afterStarted function:

for (const [_, url] of resources) {
    URL.revokeObjectURL(url);
}

Here is all the code for handling the loading in one snippet:

const resources = new Map();

export async function beforeStart(wasmOptions, extensions) {
    // Simple way of detecting we are in web assembly
    if (!extensions || !extensions.multipart) {
        return;
    }

    try {
        const integrity = extensions.multipart['app.bundle'];
        const bundleResponse = await fetch('app.bundle', { integrity: integrity, cache: 'no-cache' });
        const bundleFromData = await bundleResponse.formData();
        for (let value of bundleFromData.values()) {
            resources.set(value, URL.createObjectURL(value));
        }
        wasmOptions.loadBootResource = function (type, name, defaultUri, integrity) {
            return resources.get(name) ?? null;
        }
    } catch (error) {
        console.log(error);
    }
}

export async function afterStarted(blazor) {
    for (const [_, url] of resources) {
        URL.revokeObjectURL(url);
    }
}

Serving the bundle from the host server app

We have our app.bundle file (and app.bundle.gz and app.bundle.br, since we transparently apply the same optimizations to the extensions that we do for the app files) but ASP.NET Core doesn’t know how to serve it (and won’t do so by default for security reasons) so we need a small helper to make that happen. Thankfully, we can do this in a few lines of code using minimal APIs.

Add an endpoint for serving app.bundle in Program.cs:

app.MapGet("app.bundle", (HttpContext context) =>
{
    string? contentEncoding = null;
    var contentType = "multipart/form-data; boundary=\"--0a7e8441d64b4bf89086b85e59523b7d\"";
    var fileName = "app.bundle";

    var acceptEncodings = context.Request.Headers.AcceptEncoding;
    if (StringWithQualityHeaderValue.TryParseList(acceptEncodings, out var encodings))
    {
        if (encodings.Any(e => e.Value == "br"))
        {
            contentEncoding = "br";
            fileName += ".br";
        }
        else if (encodings.Any(e => e.Value == "gzip"))
        {
            contentEncoding = "gzip";
            fileName += ".gz";
        }
    }

    if (contentEncoding != null)
    {
        context.Response.Headers.ContentEncoding = contentEncoding;
    }
    return Results.File(
        app.Environment.WebRootFileProvider.GetFileInfo(fileName).CreateReadStream(),
        contentType);
});

Notice the content type matches the one we defined in the build task. The endpoint checks for the content encodings accepted by the browser and serves the most optimal file.

And that’s a wrap! We can now reference our NuGet package from an app, add a small helper to our server host and completely change how the Blazor WebAssembly app loads.

image

You can find the full code for this sample on GitHub.

Using the MultipartBundle package

A prebuilt version of the experimental Microsoft.AspNetCore.Components.WebAssembly.MultipartBundle package is available on NuGet to try out with existing apps.

To try multipart bundling in an existing ASP.NET Core hosted Blazor WebAssembly app:

  1. Add a reference to the Microsoft.AspNetCore.Components.WebAssembly.MultipartBundle package from the Blazor WebAssembly client project.
  2. Update the server project to add the endpoint for serving the bundle.
  3. Publish the app.

If you’ve had issues with the download of Blazor WebAssembly apps getting blocked we’d love to know how well this approach works for you. Leave us a comment on the GitHub with your thoughts.

Thanks!

Author

Javier Calvarro Nelson
Senior Software Engineer

Senior Software Engineer on the ASP.NET team.

16 comments

Discussion is closed. Login to edit/delete existing comments.

  • Dean Bullen

    Could I ask what the symptom is, when having this problem? I have a couple of customers getting the error below - is that what this is?

    <code>

    Note: The message actually contains '' exactly as shown - not any actual URL

    Read more
    • Daniel RothMicrosoft employee

      Hi Dean. That sounds different. That sounds like something in the app tried to do a fetch request over HTTP even though the app was loaded over HTTPS, and as a result the request was blocked. The issues with DLLs getting blocked I believe usually manifests as failed requests for the DLLs.

  • Laughing John

    Thanks for this, it's a really great start! And I'd like to echo Roberto's point about an out of the box solution.

    Couple of points:
    1) A lot of the article above seems to be about creating a Nuget package. Am I correct in thinking we don't have to do that part, we can just build the MSBuild task and reference the DLL without all of that pain?
    2) I think this problem is a bit...

    Read more
    • Daniel RothMicrosoft employee

      Hi John. There's a prebuilt experimental NuGet package that you can use to try out this functionality: Microsoft.AspNetCore.Components.WebAssembly.MultipartBundle. You then also need to configure your host to serve the published app.bundle file. No need to build the package yourself if you just want to try out the functionality. We walked through the steps of how the package was built in case some users need to customize publishing further. I've update the blog post to try...

      Read more
      • Roberto Mencia Franco

        Hi Daniel,

        Have you discussed with the team to add a Task to Azure DevOps build Tasks that could be added after the build step is finished and you can pass some parameters to it to basically do the same as described in the post but during the build instead of having to implement it in the code?

        It could be easier to just prepare the result of the standard compilation and update the required files to...

        Read more
  • Paul Guz

    Thanks for coming up with a solution.

    We have a customer who has come across this problem. There may be more users than you think who face this – you had a github issue regarding it, asking for examples, but closed it very quickly.

  • Matt

    I was thinking exactly the same as Roberto as I read this, would be good to have a handle on how many users this might affect, although of course that’s not a simple question to answer. Presumably for consumer users it’s a virtual non-issue, internal business users (in theory) we can discuss/resolve with IT, other b2b users who knows?

    It would of course be great to have an option somewhere to turn this behaviour on!

    Read more
    • Daniel RothMicrosoft employee · Edited

      Hi Matt. We think this only impacts a small subset of Blazor WebAssembly users, not the majority, but enough users reported the problem that we're exploring ways to address it. You're correct that sometimes users can work with their IT departments to remove the restriction for their apps. When this isn't feasible, users can repackage the app with different files name extensions, which we provide instructions on how to do. In other cases the...

      Read more
      • Matt

        Fair enough, I read through the post again and this doesn’t seem to bad should it be needed, good to see these discussions happening as well.

        Presumably frontend monitoring tools like Sentry would in theory be capable of picking up on this kind of issue, during the initial bootstrap of the app? Users obviously don’t (or can’t) always tell you when they’ve been unable to start your app.

        I’ve used blazor in a small project so far...

        Read more
  • Roberto Mencia Franco

    This seems like a very poor fix for a design flaw in MS blazor webassembly.

    The fact is that firewalls are blocking the application and blazor was not designed to deal with it from the beginning. All the people watching the demos and believe that this is so easy and then half of their customers can't access the app and need to deal with lots of unhappy customers and your boss. Not good enough.

    Do we really...

    Read more
    • Peter N. Moore

      I'm not happy about this issue either - no one is - but calling it a "design flaw" is nonsense. One never knows what firewalls are going to block. They could easily start blocking WASM files at some point (in fact I'd be surprised if many aren't already). Our current production app is an Angular SPA and I've had firewalls block it just because they didn't like that there were too many "suspicious" API calls....

      Read more
    • Daniel RothMicrosoft employee

      Hi Roberto. We do aspire to provide an out of the box solution for Blazor WebAssembly apps that works in all environments, but everyone's environment is a bit different and we're not sure yet what solution would work for everyone. So as a first step, we've added these extensibility features in .NET 6 that enable customizing the app packaging, which is what this blog post covers. This should enable Blazor users who are hitting these...

      Read more
      • Roberto Mencia Franco

        Thanks for the explanation, it makes sense.

        The whole thing for me is that we are a small team of 2 and there are lots of small teams of 1-3 that can't afford having a DevOps guy dealing with deployment issues and complex builds and we don't have too much time to get into some complicated solutions that may be required for large organisations.

        My point is that it would be greatly appreciated that there was an...

        Read more
      • Javier Calvarro NelsonMicrosoft employee Author

        Thanks Roberto,

        As Dan mentioned here, our main concern is that we don't control the heuristics used by different vendors to decide what works and what doesn't, and there are no guarantees that it won't change. At the same time, this is not a problem that affects a majority of users (that we know of) and so doing something out of the box imposes a potential performance penalty on all other users.

        It is for that reason...

        Read more
      • Roberto Mencia Franco

        How would that work with static web sites? In this scenario we can’t really serve the files because our web site is not running code to serve any DLLs.

  • Andrzej Włoszczyński

    Is this solution smart enough to download the bundle only once and cache it in the browser? Will it download it again when new app version is published?

    Do you have any information on how effective this solution can be? Have you discussed the problem with firewall vendors?