54

I've got an MVC application and I'm using the StyleBundle class for rendering out CSS files like this:

bundles.Add(new StyleBundle("~/bundles/css").Include("~/Content/*.css"));

The problem I have is that in Debug mode, the CSS urls are rendered out individually, and I have a web proxy that aggressively caches these urls. In Release mode, I know a query string is added to the final url to invalidate any caches for each release.

Is it possible to configure StyleBundle to add a random querystring in Debug mode as well to produce the following output to get around the caching issue?

<link href="/stylesheet.css?random=some_random_string" rel="stylesheet"/>
growse
  • 3,059
  • 6
  • 37
  • 54

6 Answers6

51

You just need a unique string. It doesn't have to be Hash. We use the LastModified date of the file and get the Ticks from there. Opening and reading the file is expensive as @Todd noted. Ticks is enough to output a unique number that changes when the file is changed.

internal static class BundleExtensions
{
    public static Bundle WithLastModifiedToken(this Bundle sb)
    {
        sb.Transforms.Add(new LastModifiedBundleTransform());
        return sb;
    }
    public class LastModifiedBundleTransform : IBundleTransform
    {
        public void Process(BundleContext context, BundleResponse response)
        {
            foreach (var file in response.Files)
            {
                var lastWrite = File.GetLastWriteTime(HostingEnvironment.MapPath(file.IncludedVirtualPath)).Ticks.ToString();
                file.IncludedVirtualPath = string.Concat(file.IncludedVirtualPath, "?v=", lastWrite);
            }
        }
    }
}

and how to use it:

bundles.Add(new StyleBundle("~/bundles/css")
    .Include("~/Content/*.css")
    .WithLastModifiedToken());

and this is what MVC writes:

<link href="bundles/css/site.css?v=635983900813469054" rel="stylesheet"/>

works fine with Script bundles too.

H Dog
  • 3,068
  • 29
  • 26
50

You can create a custom IBundleTransform class to do this. Here's an example that will append a v=[filehash] parameter using a hash of the file contents.

public class FileHashVersionBundleTransform: IBundleTransform
{
    public void Process(BundleContext context, BundleResponse response)
    {
        foreach(var file in response.Files)
        {
            using(FileStream fs = File.OpenRead(HostingEnvironment.MapPath(file.IncludedVirtualPath)))
            {
                //get hash of file contents
                byte[] fileHash = new SHA256Managed().ComputeHash(fs);

                //encode file hash as a query string param
                string version = HttpServerUtility.UrlTokenEncode(fileHash);
                file.IncludedVirtualPath = string.Concat(file.IncludedVirtualPath, "?v=", version);
            }                
        }
    }
}

You can then register the class by adding it to the Transforms collection of your bundles.

new StyleBundle("...").Transforms.Add(new FileHashVersionBundleTransform());

Now the version number will only change if the file contents change.

bingles
  • 9,662
  • 6
  • 65
  • 76
  • 6
    Why not just use the file LastWrite date. Then you're not having to do all that disk read and CPU for SHA256. And if you insist on hashing, MD5 is enough - you're not after security, you're after a unique hash(good enough) with low cpu cycles. – Todd Mar 19 '16 at 12:46
  • 2
    Nope that won't work, your example doesn't work for me - response.Files is an Enumarable of FileInfo objects. (Web.Optimization Version=1.0.0.0). Looks like you need to use Version 1.1+ – Todd Mar 19 '16 at 12:52
  • Thanks @bingles, for versioning I modified the code slightly, so that I use the main DLL assembly version, which gets auto-incremented after each auto-deployment. – hatsrumandcode Apr 12 '16 at 09:09
  • Do you have to create a new FileHashVersionBundleTransform for each bundle or can you reuse one? – Marie Sep 06 '16 at 14:48
  • @Marie As far as I know, you should be able to reuse a single instance. – bingles Sep 07 '16 at 01:25
  • anyone know if this proposed solution will affect googles pagespeed metrics? – andrewCanProgram Nov 08 '16 at 14:24
  • I'm not familiar with google page speed, but the general effect here is that any time a resource is modified, the url changes which invalidates the browser's cache (if it was cached). So probably depends on if google page speed is caching resources or not. – bingles Nov 08 '16 at 17:35
  • I wouldn't worry about google page speed metrics since this fix is supposed to apply to Debug mode only. – Nine Tails Feb 10 '17 at 10:50
16

This library can add the cache-busting hash to your bundle files in debug mode, as well as a few other cache-busting things: https://github.com/kemmis/System.Web.Optimization.HashCache

You can apply HashCache to all bundles in a BundlesCollection

Execute the ApplyHashCache() extension method on the BundlesCollection Instance after all bundles have been added to the collection.

BundleTable.Bundles.ApplyHashCache();

Or you can apply HashCache to a single Bundle

Create an instance of the HashCacheTransform and add it to the bundle instance you want to apply HashCache to.

var myBundle = new ScriptBundle("~/bundle_virtual_path").Include("~/scripts/jsfile.js");
myBundle.Transforms.Add(new HashCacheTransform());
Rafe
  • 6,589
  • 5
  • 41
  • 61
  • I tried this but it required updating our version of WebGrease from 1.1.0 to 1.5.2, which introduced another bug, so I rolled it back. The initial revision of this package (Version 1.0.0) would not have required us to update WebGrease, however instead of installing that version I decided to go with the accepted answer from @bingles as it given us complete control. – Nine Tails Feb 13 '17 at 11:25
  • Except now I see that the accepted answer also requires WebGrease 1.5.2. – Nine Tails Feb 13 '17 at 11:32
9

I've had the same problem but with cached versions in client browsers after an upgrade. My solution is to wrap the call to @Styles.Render("~/Content/css") in my own renderer that appends our version number in the query string like this:

    public static IHtmlString RenderCacheSafe(string path)
    {
        var html = Styles.Render(path);
        var version = VersionHelper.GetVersion();
        var stringContent = html.ToString();

        // The version should be inserted just before the closing quotation mark of the href attribute.
        var versionedHtml = stringContent.Replace("\" rel=", string.Format("?v={0}\" rel=", version));
        return new HtmlString(versionedHtml);
    }

And then in the view I do like this:

@RenderHelpers.RenderCacheSafe("~/Content/css")
Johan Gov
  • 922
  • 1
  • 9
  • 23
2

Not currently but this is slated to be added soon (right now scheduled for the 1.1 stable release, you can track this issue here: Codeplex

Hao Kung
  • 27,364
  • 6
  • 81
  • 93
1

Note this is written for Scripts but also works for Styles (just change those key words)

Building on @Johan's answer:

public static IHtmlString RenderBundle(this HtmlHelper htmlHelper, string path)
{
    var context = new BundleContext(htmlHelper.ViewContext.HttpContext, BundleTable.Bundles, string.Empty);
    var bundle = System.Web.Optimization.BundleTable.Bundles.GetBundleFor(path);
    var html = System.Web.Optimization.Scripts.Render(path).ToString();
    foreach (var item in bundle.EnumerateFiles(context))
    {
        if (!html.Contains(item.Name))
            continue;

        html = html.Replace(item.Name, item.Name + "?" + item.LastWriteTimeUtc.ToString("yyyyMMddHHmmss"));
    }

    return new HtmlString(html);
}

public static IHtmlString RenderStylesBundle(this HtmlHelper htmlHelper, string path)
{
    var context = new BundleContext(htmlHelper.ViewContext.HttpContext, BundleTable.Bundles, string.Empty);
    var bundle = System.Web.Optimization.BundleTable.Bundles.GetBundleFor(path);
    var html = System.Web.Optimization.Styles.Render(path).ToString();
    foreach (var item in bundle.EnumerateFiles(context))
    {
        if (!html.Contains(item.Name))
            continue;

        html = html.Replace(item.Name, item.Name + "?" + item.LastWriteTimeUtc.ToString("yyyyMMddHHmmss"));
    }

    return new HtmlString(html);
}

Usage:

@Html.RenderBundle("...")
@Html.RenderStylesBundle("...")

Replacing

@Scripts.Render("...")
@Styles.Render("...")

Benefits:

  • Works for v1.0.0.0 of System.Web.Optimizations
  • Works on multiple files in the bundle
  • Gets the file modification date, rather than hashing, of each file, rather than a group

Also, when you need to quickly workaround Bundler:

public static MvcHtmlString ResolveUrl(this HtmlHelper htmlHelper, string url)
{
    var urlHelper = new UrlHelper(htmlHelper.ViewContext.RequestContext);
    var resolvedUrl = urlHelper.Content(url);

    if (resolvedUrl.ToLower().EndsWith(".js") || resolvedUrl.ToLower().EndsWith(".css"))
    {
        var localPath = HostingEnvironment.MapPath(resolvedUrl);
        var fileInfo = new FileInfo(localPath);
        resolvedUrl += "?" + fileInfo.LastWriteTimeUtc.ToString("yyyyMMddHHmmss");
    }

    return MvcHtmlString.Create(resolvedUrl);
}

Usage:

<script type="text/javascript" src="@Html.ResolveUrl("~/Scripts/jquery-1.9.1.min.js")"></script>

Replacing:

<script type="text/javascript" src="@Url.Content("~/Scripts/jquery-1.9.1.min.js")"></script>

(Also replaces many other alternative lookups)

Todd
  • 14,946
  • 6
  • 42
  • 56