Skip to main content

Custom deployment layout for Blazor WebAssembly apps

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";

    if (context.Request.Headers.AcceptEncoding.Contains("br"))
    {
        contentEncoding = "br";
        fileName += ".br";
    }
    else if (context.Request.Headers.AcceptEncoding.Contains("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 and the experimental Microsoft.AspNetCore.Components.WebAssembly.MultipartBundle package is available to try out on NuGet. We’d love to know how well this approach works for you. Leave us a comment on the GitHub with your thoughts.

Thanks!

The post Custom deployment layout for Blazor WebAssembly apps appeared first on ASP.NET Blog.



Comments

Popular posts from this blog

Fake CVR Generator Denmark

What Is Danish CVR The Central Business Register (CVR) is the central register of the state with information on all Danish companies. Since 1999, the Central Business Register has been the authoritative register for current and historical basic data on all registered companies in Denmark. Data comes from the companies' own registrations on Virk Report. There is also information on associations and public authorities in the CVR. As of 2018, CVR also contains information on Greenlandic companies, associations and authorities. In CVR at Virk you can do single lookups, filtered searches, create extracts and subscriptions, and retrieve a wide range of company documents and transcripts. Generate Danish CVR For Test (Fake) Click the button below to generate the valid CVR number for Denmark. You can click multiple times to generate several numbers. These numbers can be used to Test your sofware application that uses CVR, or Testing CVR APIs that Danish Govt provide. Generate

How To Iterate Dictionary Object

Dictionary is a object that can store values in Key-Value pair. its just like a list, the only difference is: List can be iterate using index(0-n) but not the Dictionary . Generally when we try to iterate the dictionary we get below error: " Collection was modified; enumeration operation may not execute. " So How to parse a dictionary and modify its values?? To iterate dictionary we must loop through it's keys or key - value pair. Using keys

How To Append Data to HTML5 localStorage or sessionStorage?

The localStorage property allows you to access a local Storage object. localStorage is similar to sessionStorage. The only difference is that, while data stored in localStorage has no expiration time untill unless user deletes his cache, data stored in sessionStorage gets cleared when the originating window or tab get closed. These are new HTML5 objects and provide these methods to deal with it: The following snippet accesses the current domain's local Storage object and adds a data item to it using Storage.setItem() . localStorage.setItem('myFav', 'Taylor Swift'); or you can use the keyname directly as : localStorage.myFav = 'Taylor Swift'; To grab the value set in localStorage or sessionStorage, we can use localStorage.getItem("myFav"); or localStorage.myFav There's no append function for localStorage or sessionStorage objects. It's not hard to write one though.The simplest solution goes here: But we can kee