15

I'm trying to implement a generic approach for providing the possibility for different assemblies in my web solution to use embedded JavaScript and CSS files from embedded resources. This blog post shows a technique using a VirtualPathProvider. This works fine, but the VirtualPathProvider needs to be included in each assembly containing embedded resources.

I tried to enhance the VirtualPathProvider from the blog post, so that an assembly can be passed into it and it loads the resource from its assembly:

public EmbeddedVirtualPathProvider(VirtualPathProvider previous, Assembly assembly)
{
    this.previous = previous;
    this.assembly = assembly;
}

On initialization it reads all embedded resources from the passed assembly:

protected override void Initialize()
{
    base.Initialize();

    this.assemblyResourceNames = this.assembly.GetManifestResourceNames();
    this.assemblyName = this.assembly.GetName().Name;
}

And the GetFilereads the content from the passed assembly:

public override VirtualFile GetFile(string virtualPath)
{
    if (IsEmbeddedPath(virtualPath))
    {
        if (virtualPath.StartsWith("~", System.StringComparison.OrdinalIgnoreCase))
        {
            virtualPath = virtualPath.Substring(1);
        }

        if (!virtualPath.StartsWith("/", System.StringComparison.OrdinalIgnoreCase))
        {
            virtualPath = string.Concat("/", virtualPath);
        }

        var resourceName = string.Concat(this.assembly.GetName().Name, virtualPath.Replace("/", "."));
        var stream = this.assembly.GetManifestResourceStream(resourceName);

        if (stream != null)
        {
            return new EmbeddedVirtualFile(virtualPath, stream);
        }
        else
        {
            return _previous.GetFile(virtualPath);
        }
    }
    else
        return _previous.GetFile(virtualPath);
}

Checking if resource is an embedded resource of this assembly is by checking the resource names read in the Initialize method:

private bool IsEmbeddedPath(string path)
{
    var resourceName = string.Concat(this.assemblyName, path.TrimStart('~').Replace("/", "."));
    return this.assemblyResourceNames.Contains(resourceName, StringComparer.OrdinalIgnoreCase);
}

I moved the EmbeddedVirtualPathProvider class to the main web project (ProjectA), so that it doesn't need to be included in each assembly containing embedded resources and registered it using the following code in Global.asax:

HostingEnvironment.RegisterVirtualPathProvider(
    new EmbeddedVirtualPathProvider(
        HostingEnvironment.VirtualPathProvider,
        typeof(ProjectB.SomeType).Assembly));

In the project containing the embedded resources (ProjectB) I still create the following bundle in a PostApplicationStartMethod:

 BundleTable.Bundles.Add(new ScriptBundle("~/Embedded/Js")
     .Include("~/Scripts/SomeFolder/MyScript.js")
 );

Scripts/MyScript.js is the embedded resource in ProjectB.

With this I receive the following exception:

Directory 'C:\webs\ProjectA\Scripts\SomeFolder\' does not exist. Failed to start monitoring file changes.

Update Full stack trace available in this Gist.

Update Also the VirtualPathProvider itself seems to work fine. If I load the file directly and not through the bundle and set the following entry in the web.config it loads the embedded javascript from ProjectB:

<system.webServer>
  <handlers>
    <add name="MyStaticFileHandler" path="*.js" verb="GET,HEAD" type="System.Web.StaticFileHandler"/>
  </handlers>
</system.webServer>
Pascal Berger
  • 4,262
  • 2
  • 30
  • 54
  • Where is your Startup class?In ProjectA or in ProjectB? – George Vovos Jan 21 '16 at 23:25
  • ProjectA is a NuGet Package containing the VirtualPathProvider. ProjectB another NuGet Package providing some functionality with views (there are multiple of it). The NuGet Package ProjectB has a dependency on the NuGetPackage ProjectA. Applications install the ProjectB NuGetPackages. Therefore Startup is outside of ProjectA and ProjectB, but ProjectA and ProjectB can hook into PreApplicationStartMethod. – Pascal Berger Jan 22 '16 at 06:50
  • It seems that `IsEmbeddedPath` method return `false` whereas it should return `true`. Could you tell us the value of `path` and `resourceName` before the error occured ? – Cyril Durand Jan 22 '16 at 09:49
  • If I debug `IsEmbeddedPath` returns `true` and `GetFile` is also called and returns the stream of the embedded resource. – Pascal Berger Jan 22 '16 at 10:55
  • @PascalBerger `IsEmbeddedPath` always return `true`. By looking at the call stack, we can see that `GetCacheDependency` is called and then the base `GetCacheDependency` is called which means that `IsEmbeddedPath` return `false` one time. – Cyril Durand Jan 22 '16 at 13:17
  • If I set a breakpoint in `FileExists` with a condition of `virtualPath.Contains("MyScript.js")`, if it is called `virtualPath` is set to `~/Scripts/SomeFolder/MyScript.js`. In `IsEmbeddedPath` the resourceName is `ProjectA.SomeFolder.MyScript.js` which exists in `assemblyResourceNames` and therefore `true` is returned. I've also another breakpoint with the same condition set in `GetFile` which is called afterwards. And the `VirtualFile` object for the resource is returned from there. Afterwards no further calls to either `FileExists` or `GetFile` happen – Pascal Berger Jan 22 '16 at 13:50
  • @CyrilDurand The VirtualPathProvider itself seems to work fine (see updated question). If I directly load the JS file, don't use a bundle, and declare all *.js files as static files it finds the embedded JavaScript file. – Pascal Berger Jan 22 '16 at 14:06

2 Answers2

1

When ASP.net optimization create the bundle it call GetCacheDependency for the virtual directory of the script. Your GetCacheDependency implementation only check virtual file, for virtual directory it relies on the base VirtualPathProvider which check if directory exists and failed.

To solve this issue, you have to check if the path is a directory of one of your script and return null for the GetCacheDependency.

To safely determine if virtualPath is a bundle directory, you can use the BundleTable.Bundles collection or using a convention (ie: every bundle should starts with ~/Embedded).

public override CacheDependency GetCacheDependency(
    string virtualPath, 
    IEnumerable virtualPathDependencies, 
    DateTime utcStart)
{
    // if(virtualPath.StartsWith("~/Embedded"))
    if(BundleTables.Bundles.Any(b => b.Path == virtualPath))
    {
        return null; 
    }
    if (this.IsEmbeddedPath(virtualPath))
    {
        return null;
    }
    else
    {
        return this._previous
                   .GetCacheDependency(virtualPath, virtualPathDependencies, utcStart);
    }
}
Cyril Durand
  • 15,834
  • 5
  • 54
  • 62
  • Thanks. I tried this but `GetCacheDependency` is not called for `C:\webs\ProjectA\Scripts\SomeFolder\` only for `~/Embedded/Js`. – Pascal Berger Jan 22 '16 at 15:34
  • @PascalBerger When the exception occurs what is the value of `virtualPath` ? – Cyril Durand Jan 22 '16 at 15:39
  • Ah, sorry. Was an error in my file/path detection. If I fix check for `~/Embedded/Js` and return `null` it works. Now I need to only find a safe way to determine if `virtualPath` is a directory or file :) – Pascal Berger Jan 22 '16 at 15:45
1

Regarding below error

Directory 'C:\webs\ProjectA\Scripts\SomeFolder\' does not exist. Failed to start monitoring file changes.

This happens specifically if all resource files of the SomeFolder are embedded and thus in published site - it does not have this folder created.

In case of bundle - it keeps timestamp when the bundle is created and it monitors the folder for any file change to trigger update in the bundle file.

Here - no files in the SomeFolder to monitor - as all are embedded. Didn't find to prevent the folder monitoring - but by handling this specific exception, it can be ignored.