adllm Insights logo adllm Insights logo

ASP.NET Core: Serving Embedded Resources from Dynamically Loaded Assemblies

Published on by The adllm Team. Last modified: . Tags: ASP.NET Core IFileProvider Embedded Resources Dynamic Assembly Loading Plugins Static Files C# .NET

Modern ASP.NET Core applications often benefit from a modular or plugin-based architecture. This allows for extending functionality by loading assemblies at runtime. A common requirement in such scenarios is for these dynamically loaded plugins to serve their own static assets, such as CSS, JavaScript, and images, which are ideally encapsulated within the plugin assembly itself as embedded resources.

ASP.NET Core’s IFileProvider interface provides a powerful abstraction for accessing files and directories, regardless of their physical location. By leveraging this interface, specifically with implementations like ManifestEmbeddedFileProvider, we can seamlessly serve embedded resources from assemblies loaded on the fly.

This article provides a detailed guide on how to implement this functionality, enabling your ASP.NET Core applications to serve static content from dynamically discovered and loaded plugin assemblies.

Core Concepts

Before diving into the implementation, let’s clarify the key components involved:

  • IFileProvider: An interface in Microsoft.Extensions.FileProviders that abstracts file system access. ASP.NET Core uses it internally for various tasks, including serving static files. More details can be found in the Microsoft.Extensions.FileProviders.IFileProvider documentation.
  • Embedded Resources: Files (e.g., .css, .js, images) included directly within a compiled assembly. They are marked with the EmbeddedResource build action in the project file. The resource name typically follows the pattern AssemblyName.FolderPath.FileName.
  • Dynamically Loaded Assembly: A .NET assembly (DLL) that is not referenced at compile time but is loaded into the application’s process at runtime. This is fundamental for plugin systems, often managed using AssemblyLoadContext.
  • ManifestEmbeddedFileProvider: A built-in IFileProvider implementation designed to read files embedded in an assembly. It relies on a manifest that maps logical file paths to embedded resource streams. See the ManifestEmbeddedFileProvider API documentation for more.
  • CompositeFileProvider: An IFileProvider that combines multiple other file providers into a single logical view. This is useful for serving files from various sources. Details are available in the CompositeFileProvider API documentation.

Step 1: Creating a Plugin with Embedded Resources

First, let’s create a simple class library project that will act as our plugin. This plugin will contain some embedded static assets.

1. Create the Plugin Project: Create a new .NET Class Library project (e.g., MyPlugin).

2. Add Static Assets: Inside the MyPlugin project, create a folder structure for your static assets, for example, wwwroot/css/plugin-styles.css and wwwroot/js/plugin-script.js.

MyPlugin/wwwroot/css/plugin-styles.css:

1
2
3
4
5
6
7
8
/* plugin-styles.css */
body {
    background-color: lightblue; /* Example style from plugin */
}
.plugin-text {
    color: navy;
    font-weight: bold;
}

MyPlugin/wwwroot/js/plugin-script.js:

1
2
3
4
5
// plugin-script.js
console.log("Plugin script loaded!");
function greetFromPlugin() {
    alert("Hello from the dynamically loaded plugin!");
}

3. Configure for Embedded Resources: Edit the MyPlugin.csproj file to embed the files in the wwwroot folder and generate the manifest:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework> <!-- Or your target framework -->
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
    <!-- This is crucial for ManifestEmbeddedFileProvider -->
    <GenerateEmbeddedFilesManifest>true</GenerateEmbeddedFilesManifest>
  </PropertyGroup>

  <ItemGroup>
    <!-- Embed all files in the wwwroot folder -->
    <EmbeddedResource Include="wwwroot\**\*" />
  </ItemGroup>

</Project>

The GenerateEmbeddedFilesManifest MSBuild property (true by default in SDK-style projects targeting .NET Core 3.0+ but good to be explicit) instructs MSBuild to generate a manifest of embedded files, which ManifestEmbeddedFileProvider uses. The EmbeddedResource item includes all files within the plugin’s wwwroot directory.

Build the MyPlugin project. The output DLL (e.g., MyPlugin.dll) will contain these embedded resources.

Step 2: Dynamically Loading the Plugin Assembly

In your main ASP.NET Core application, you’ll need a mechanism to discover and load plugin assemblies. For simplicity, we’ll assume the plugin DLL is placed in a known directory.

The following code snippet demonstrates loading an assembly. This typically happens during application startup.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
using System.Reflection;
using System.Runtime.Loader;

// ... in your Program.cs or a service configuration class

// Example: Load from a 'plugins' subdirectory
string pluginDirectory = Path.Combine(AppContext.BaseDirectory, "plugins");
if (!Directory.Exists(pluginDirectory))
{
    Directory.CreateDirectory(pluginDirectory);
    // You would typically copy your plugin DLLs here.
}

string pluginAssemblyPath = Path.Combine(pluginDirectory, "MyPlugin.dll");
Assembly? loadedAssembly = null;

if (File.Exists(pluginAssemblyPath))
{
    // AssemblyLoadContext.Default.LoadFromAssemblyPath is a common way.
    // For more advanced scenarios like unloading, a custom AssemblyLoadContext
    // might be needed. See:
    // https://learn.microsoft.com/en-us/dotnet/standard/assembly/load-context
    loadedAssembly = AssemblyLoadContext.Default.LoadFromAssemblyPath(
        pluginAssemblyPath
    );
    Console.WriteLine($"Successfully loaded plugin: {loadedAssembly.FullName}");
}
else
{
    Console.WriteLine($"Plugin DLL not found at: {pluginAssemblyPath}");
}

Place this logic where it makes sense in your application’s startup sequence. After execution, loadedAssembly will hold a reference to the dynamically loaded MyPlugin assembly.

Step 3: Serving Embedded Resources with ManifestEmbeddedFileProvider

Once the plugin assembly is loaded, you can create a ManifestEmbeddedFileProvider instance to serve its embedded resources.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
using Microsoft.Extensions.FileProviders;
using System.Reflection; // For Assembly

// Assuming 'loadedAssembly' is the Assembly object from Step 2
IFileProvider? pluginFileProvider = null;
if (loadedAssembly != null)
{
    // The second argument "wwwroot" is the root relative path within the
    // assembly where resources are stored. If your EmbeddedResource paths
    // were MyPlugin.css.plugin-styles.css (no wwwroot), you'd pass null
    // or string.Empty, and adjust the resource names accordingly.
    pluginFileProvider = new ManifestEmbeddedFileProvider(
        assembly: loadedAssembly,
        root: "wwwroot" // Matches the folder structure in MyPlugin
    );
    Console.WriteLine("ManifestEmbeddedFileProvider created for plugin.");
}

The root parameter in the ManifestEmbeddedFileProvider constructor is crucial. It specifies the base path within the embedded resources manifest. If you embedded files under a wwwroot folder in your plugin project like MyPlugin.wwwroot.css.plugin-styles.css, then root: "wwwroot" makes the provider serve css/plugin-styles.css relative to this root.

Step 4: Configuring Static File Middleware

To make these embedded resources accessible via HTTP requests, you need to configure the Static File Middleware in ASP.NET Core.

Serving Plugin Assets from a Specific Request Path

It’s good practice to serve plugin assets under a unique request path to avoid conflicts (e.g., /plugins/myplugin/css/plugin-styles.css).

Modify your Program.cs (or Startup.cs):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
// ... other usings
using Microsoft.Extensions.FileProviders; // For IFileProvider
using Microsoft.Extensions.Primitives; // For StringSegment (RequestPath)
using System.Reflection; // For Assembly
using System.Runtime.Loader; // For AssemblyLoadContext

var builder = WebApplication.CreateBuilder(args);

// Add services to the container.
builder.Services.AddRazorPages(); // Or AddControllersWithViews, etc.

var app = builder.Build();

// Configure the HTTP request pipeline.
if (!app.Environment.IsDevelopment())
{
    app.UseExceptionHandler("/Error");
    app.UseHsts();
}

app.UseHttpsRedirection();

// Serve static files from the main application's wwwroot
app.UseStaticFiles();

// --- Plugin Static Files Configuration ---
// Path to plugin DLL (ensure this is copied to your app's output/plugins)
string pluginAssemblyPath = Path.Combine(
    AppContext.BaseDirectory,
    "plugins",
    "MyPlugin.dll"
);

if (File.Exists(pluginAssemblyPath))
{
    Assembly pluginAssembly = AssemblyLoadContext.Default.LoadFromAssemblyPath(
        pluginAssemblyPath
    );

    var pluginFileProvider = new ManifestEmbeddedFileProvider(
        pluginAssembly,
        "wwwroot" // Root path within the assembly
    );

    // Serve plugin files under "/plugins/MyPlugin"
    // e.g., /plugins/MyPlugin/css/plugin-styles.css
    app.UseStaticFiles(new StaticFileOptions
    {
        FileProvider = pluginFileProvider,
        RequestPath = new PathString("/plugins/MyPlugin")
    });
    Console.WriteLine("Configured static files for MyPlugin at /plugins/MyPlugin");
}
else
{
    Console.WriteLine($"MyPlugin.dll not found at {pluginAssemblyPath}");
}
// --- End Plugin Static Files Configuration ---

app.UseRouting();
app.UseAuthorization();
app.MapRazorPages(); // Or app.MapDefaultControllerRoute();

app.Run();

With this setup:

  • A request to /plugins/MyPlugin/css/plugin-styles.css will serve the embedded plugin-styles.css from MyPlugin.dll.
  • A request to /plugins/MyPlugin/js/plugin-script.js will serve the embedded plugin-script.js.

Using CompositeFileProvider for Multiple Sources

If you have multiple plugins or want to combine the plugin’s file provider with the application’s default wwwroot provider (though app.UseStaticFiles() already handles the main wwwroot), you can use CompositeFileProvider.

This is particularly useful if you want a single IFileProvider instance for a specific purpose (e.g., for Razor view discovery from plugins, which is beyond static files but uses the same IFileProvider abstraction). For static files, multiple app.UseStaticFiles() calls as shown above is often simpler.

However, if needed, a CompositeFileProvider can be constructed:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// In Program.cs or Startup.cs
// Assume 'webHostEnvironment' is IWebHostEnvironment
// Assume 'pluginFileProvidersList' is a List<IFileProvider> from all plugins

// Default physical provider for the app's wwwroot
var physicalProvider = webHostEnvironment.WebRootFileProvider;

var providers = new List<IFileProvider> { physicalProvider };
// Add providers from all loaded plugins
// providers.Add(pluginFileProvider1);
// providers.Add(pluginFileProvider2);
// ...

if (pluginFileProvider != null) // From previous example
{
    providers.Add(pluginFileProvider);
}

var compositeProvider = new CompositeFileProvider(providers);

// Then, you could use this compositeProvider:
// For serving all under the root path (less common for plugins to override root)
// app.UseStaticFiles(new StaticFileOptions
// {
//     FileProvider = compositeProvider
// });

// Or for a specific section, if all plugins share a common base request path
// app.UseStaticFiles(new StaticFileOptions
// {
//     FileProvider = compositeProvider,
//     RequestPath = "/shared-plugins-assets"
// });

When using CompositeFileProvider, the order of providers in the list matters. If multiple providers can satisfy a file request, the first one in the list wins.

Step 5: Testing the Setup

  1. Ensure MyPlugin.dll is copied to the plugins directory of your main application’s output (e.g., bin/Debug/net8.0/plugins/MyPlugin.dll).
  2. Create a simple HTML page in your main application’s wwwroot (e.g., wwwroot/index.html) or a Razor page to test:

MainApp/wwwroot/index.html:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8" />
    <title>Test Plugin Assets</title>
    <!-- Reference the plugin's CSS -->
    <link rel="stylesheet" href="/plugins/MyPlugin/css/plugin-styles.css" />
</head>
<body>
    <h1>Testing Embedded Assets from Plugin</h1>
    <p class="plugin-text">This text should be styled by the plugin!</p>

    <!-- Reference the plugin's JavaScript -->
    <script src="/plugins/MyPlugin/js/plugin-script.js"></script>
    <button onclick="greetFromPlugin()">Greet from Plugin</button>
</body>
</html>
  1. Run your main ASP.NET Core application.
  2. Navigate to /index.html. You should see the styles from plugin-styles.css applied, and the JavaScript from plugin-script.js should execute (check browser console and try the button).

Key Considerations and Best Practices

  • Resource Naming: Embedded resource names are case-sensitive and typically follow AssemblyName.Folder.SubFolder.FileName.Extension. ManifestEmbeddedFileProvider uses these names. Ensure your EmbeddedResource paths and the root parameter are consistent.
  • GenerateEmbeddedFilesManifest: Essential for ManifestEmbeddedFileProvider. Ensure it’s set to true in your plugin’s .csproj file. See the MSBuild property documentation for details.
  • Assembly Load Contexts: For simple cases, AssemblyLoadContext.Default.LoadFromAssemblyPath() is sufficient. For more advanced scenarios like plugin isolation, dependency management, or unloading plugins, you should investigate creating custom AssemblyLoadContext instances.
  • Path Uniqueness: Always use unique RequestPath segments for each plugin when configuring UseStaticFiles to prevent conflicts.
  • Debugging:
    • To verify embedded resource names, you can inspect the loaded assembly:
      1
      2
      3
      4
      5
      6
      
      // In your main app, after loading pluginAssembly
      // string[] resourceNames = pluginAssembly.GetManifestResourceNames();
      // foreach (string name in resourceNames)
      // {
      //     Console.WriteLine($"Embedded Resource: {name}");
      // }
      
      This helps confirm the exact names and paths as they exist in the manifest.
    • Use browser developer tools (Network tab) to check for 404 errors and verify requested URLs.
  • Case Sensitivity: File systems can vary in case sensitivity. Embedded resource names are generally case-sensitive. Keep your paths and names consistent.

Common Pitfalls

  • Incorrect Assembly for ManifestEmbeddedFileProvider: Passing the host assembly instead of the dynamically loaded plugin assembly.
  • Mismatched root Parameter: The root parameter in ManifestEmbeddedFileProvider must match the base folder structure you used for EmbeddedResource items (e.g., “wwwroot”).
  • Forgetting GenerateEmbeddedFilesManifest: The provider won’t find resources without the manifest.
  • Typos in Resource Paths or RequestPath: URLs are exact. Double-check for typos or case mismatches.
  • Order of Middleware: Ensure UseStaticFiles() is called before UseRouting() and UseEndpoints()/Map...() calls that might handle the request differently. The ASP.NET Core middleware order guidance is important here.

Conclusion

Serving embedded resources from dynamically loaded assemblies is a powerful technique for building modular and extensible ASP.NET Core applications. By correctly using IFileProvider, ManifestEmbeddedFileProvider, and configuring the static file middleware, you can ensure that your plugins are truly self-contained, packaging their own necessary static assets. This approach promotes cleaner architecture, easier deployment of modules, and a more robust system overall. Remember to manage resource naming and request paths carefully to create a seamless experience.