27

I'm using the ASP.NET MVC 4 bundling and minifying features in the Microsoft.AspNet.Web.Optimization namespace (e.g. @Styles.Render("~/content/static/css")).

I'd like to use it in combination with a Windows Azure CDN.

I looked into writing a custom BundleTransform but the content is not optimized there yet.

I also looked into parsing and uploading the optimized stream on runtime but that feels like a hack to me and I don't really like it:

@StylesCdn.Render(Url.AbsoluteContent(
    Styles.Url("~/content/static/css").ToString()
    ));

public static IHtmlString Render(string absolutePath)
{
    // get the version hash
    string versionHash = HttpUtility.ParseQueryString(
        new Uri(absolutePath).Query
        ).Get("v");

    // only parse and upload to CDN if version hash is different
    if (versionHash != _versionHash)
    {
        _versionHash = versionHash;

        WebClient client = new WebClient();
        Stream stream = client.OpenRead(absolutePath);

        UploadStreamToAzureCdn(stream);
    }

    var styleSheetLink = String.Format(
        "<link href=\"{0}://{1}/{2}/{3}?v={4}\" rel=\"stylesheet\" type=\"text/css\" />",
        cdnEndpointProtocol, cdnEndpointUrl, cdnContainer, cdnCssFileName, versionHash
        );

    return new HtmlString(styleSheetLink);
}

How can I upload the bundled and minified versions automatically to my Windows Azure CDN?

RickAndMSFT
  • 20,912
  • 8
  • 60
  • 78
Martin Buberl
  • 45,844
  • 25
  • 100
  • 144
  • Nate Totten did something like this: https://github.com/ntotten/wa-cdnhelpers/wiki. Do visit the main page for that repository, though... it looks like he recommends other solutions these days. – user94559 Aug 25 '12 at 03:36
  • Could you tell me where this _versionHash parameter is ? Thanks. – Barbaros Alp Jun 11 '15 at 06:21
  • @BarbarosAlp `_versionHash` represents the query string `v` that gets added to your assets. In the implementation above it will compare it against the previously cached string. – Martin Buberl Jun 11 '15 at 13:29
  • It's been almost 3 years, has anyone found an OTB solution? – TWilly Jun 22 '15 at 20:47

4 Answers4

18

Following Hao's advice I Extended Bundle and IBundleTransform.

Adding AzureScriptBundle or AzureStyleBundle to bundles;

bundles.Add(new AzureScriptBundle("~/bundles/modernizr.js", "cdn").Include("~/Scripts/vendor/modernizr.custom.68789.js"));

Results in;

<script src="//127.0.0.1:10000/devstoreaccount1/cdn/modernizr.js?v=g-XPguHFgwIb6tGNcnvnI_VY_ljCYf2BDp_NS5X7sAo1"></script>

If CdnHost isn't set it will use the Uri of the blob instead of the CDN.

Class

using System;
using System.Text;
using System.Web;
using System.Web.Optimization;
using System.Security.Cryptography;
using Microsoft.WindowsAzure;
using Microsoft.WindowsAzure.ServiceRuntime;
using Microsoft.WindowsAzure.StorageClient;

namespace SiegeEngineWebRole.BundleExtentions
{
    public class AzureScriptBundle : Bundle
    {
        public AzureScriptBundle(string virtualPath, string containerName, string cdnHost = "")
            : base(virtualPath, null, new IBundleTransform[] { new JsMinify(), new AzureBlobUpload { ContainerName = containerName, CdnHost = cdnHost } })
        {
            ConcatenationToken = ";";
        }
    }

    public class AzureStyleBundle : Bundle
    {
        public AzureStyleBundle(string virtualPath, string containerName, string cdnHost = "")
            : base(virtualPath, null, new IBundleTransform[] { new CssMinify(), new AzureBlobUpload { ContainerName = containerName, CdnHost = cdnHost } })
        {
        }
    }

    public class AzureBlobUpload : IBundleTransform
    {
        public string ContainerName { get; set; }
        public string CdnHost { get; set; }

        static AzureBlobUpload()
        {
        }

        public virtual void Process(BundleContext context, BundleResponse response)
        {
            var file = VirtualPathUtility.GetFileName(context.BundleVirtualPath);

            if (!context.BundleCollection.UseCdn)
            {
                return;
            }
            if (string.IsNullOrWhiteSpace(ContainerName))
            {
                throw new Exception("ContainerName Not Set");
            }

            var conn = CloudStorageAccount.Parse(RoleEnvironment.GetConfigurationSettingValue("DataConnectionString"));
            var blob = conn.CreateCloudBlobClient()
                .GetContainerReference(ContainerName)
                .GetBlobReference(file);

            blob.Properties.ContentType = response.ContentType;
            blob.UploadText(response.Content);

            var uri = string.IsNullOrWhiteSpace(CdnHost) ? blob.Uri.AbsoluteUri.Replace("http:", "").Replace("https:", "") : string.Format("//{0}/{1}/{2}", CdnHost, ContainerName, file);

            using (var hashAlgorithm = CreateHashAlgorithm())
            {
                var hash = HttpServerUtility.UrlTokenEncode(hashAlgorithm.ComputeHash(Encoding.Unicode.GetBytes(response.Content)));
                context.BundleCollection.GetBundleFor(context.BundleVirtualPath).CdnPath = string.Format("{0}?v={1}", uri, hash);
            }
        }

        private static SHA256 CreateHashAlgorithm()
        {
            if (CryptoConfig.AllowOnlyFipsAlgorithms)
            {
                return new SHA256CryptoServiceProvider();
            }

            return new SHA256Managed();
        }
    }
}
Daniel
  • 184
  • 2
  • 8
  • Thanks, this is a really good solution. I took it and modified it to use generically with a static host DNS (Amazon CloudFront). – Zack Z. Jun 18 '14 at 20:21
  • @ZackZ. can you post that class? – manishKungwani Jun 20 '14 at 13:56
  • Everytime you call Process, do you create a new Hash? What if the file doesn't change, doesn't it mean that the browser will re download the file while it shouldnt ? – Barbaros Alp Jun 11 '15 at 06:25
  • @BarbarosAlp It's been a long time since I looked at Bundling, so I'm not 100% sure what effect pushing the files to blob storage will have on bundling with regard to a new hash. Maybe someone how knows can chime in with the answer or test it and leave a comment. – Daniel Jun 13 '15 at 03:11
  • 1
    Sorry, you hash the content of the minified css or js. So if there are no changes on the content the hashed string stays the same. Thanks. – Barbaros Alp Jun 25 '15 at 12:40
14

So there isn't a great way to do this currently. The longer term workflow we are envisioning is adding build-time bundling support. Then you would run a build task (or run an exe if you prefer) to generate the bundles and then be able to upload these to the AzureCDN. Finally, you just turn on UseCDN on the BundleCollection, and the Script/Style helpers would just automatically switch to rendering out links to your AzureCDN with proper fallback to your local bundles.

For the short term, what I think you are trying to do is upload your bundle to the AzureCDN when the bundle is first constructed?

A BundleTransform is one way to do it I guess, its a bit of a hack, but you could add a BundleTransform last in your bundle. Since its last, the BundleResponse.Content is effectively the final bundle response. At that point in time you can upload it to your CDN. Does that make sense?

Hao Kung
  • 28,040
  • 6
  • 84
  • 93
  • Thanks for your answer. Build-time bundling won't support the re-minifaction with a different hash in case the underlying files changed. I'd be cool with that if CDN support is configurable on an environment level so that only prod/int environments are using the bundles from the CDN. Yes, I tried to upload it to the CDN when the bundle is first constructed. I'll try what you suggested and add another BundleTransform to only address that. – Martin Buberl Aug 24 '12 at 20:00
  • @MartinBuberl did you ever get this working? What was the working solution here? – Adam Tuliper Nov 09 '12 at 14:29
  • What would be great is if the Azure CDN supports pull-through (like other providers) so instead of uploading anything it will just request your servers. So if you request my.cdn.com/myfile.jpg and it is not cached the cdn will fetch it from your server using website.com/myfile.jpg. Very simple and easy to support using the Bundling feature. – MartinF Aug 12 '13 at 10:31
  • 1
    Is this implemented in Mvc 5? – systempuntoout Aug 01 '14 at 20:30
3

You can define origin domain as Azure's website (this probably was added long after the original question).

Once you have CDN endpoint, you will need to allow query string for it and then you can reference directly to bundles via CDN:

<link href="//az888888.vo.msecnd.net/Content/css-common?v=ioYVnAg-Q3qYl3Pmki-qdKwT20ESkdREhi4DsEehwCY1" rel="stylesheet"/>

I've also created this helper to append CDN host name:

public static IHtmlString RenderScript(string virtualPath)
{
    if (HttpContext.Current.IsDebuggingEnabled)
        return Scripts.Render(virtualPath);
    else
        return new HtmlString(String.Format(
            CultureInfo.InvariantCulture, 
            Scripts.DefaultTagFormat, 
            "//CDN_HOST" + Scripts.Url(virtualPath).ToHtmlString()));
}
Jenya Y.
  • 2,980
  • 1
  • 16
  • 21
2

For @manishKungwani requested in previous comment. Just set UseCdn and then use the cdnHost overload to crate the bundle. I used this to put in an AWS CloudFront domain (xxx.cloudfront.net) but in hindsight it should have been named more generically to use with any other CDN provider.

public class CloudFrontScriptBundle : Bundle
{
    public CloudFrontScriptBundle(string virtualPath, string cdnHost = "")
        : base(virtualPath, null, new IBundleTransform[] { new JsMinify(), new CloudFrontBundleTransformer { CdnHost = cdnHost } })
    {
        ConcatenationToken = ";";
    }
}

public class CloudFrontStyleBundle : Bundle
{
    public CloudFrontStyleBundle(string virtualPath, string cdnHost = "")
        : base(virtualPath, null, new IBundleTransform[] { new CssMinify(), new CloudFrontBundleTransformer { CdnHost = cdnHost } })
    {
    }
}

public class CloudFrontBundleTransformer : IBundleTransform
{
    public string CdnHost { get; set; }

    static CloudFrontBundleTransformer()
    {
    }

    public virtual void Process(BundleContext context, BundleResponse response)
    {
        if (context.BundleCollection.UseCdn && !String.IsNullOrWhiteSpace(CdnHost))
        {
            var virtualFileName = VirtualPathUtility.GetFileName(context.BundleVirtualPath);
            var virtualDirectory = VirtualPathUtility.GetDirectory(context.BundleVirtualPath);

            if (!String.IsNullOrEmpty(virtualDirectory))
                virtualDirectory = virtualDirectory.Trim('~');

            var uri = string.Format("//{0}{1}{2}", CdnHost, virtualDirectory, virtualFileName);
            using (var hashAlgorithm = CreateHashAlgorithm())
            {
                var hash = HttpServerUtility.UrlTokenEncode(hashAlgorithm.ComputeHash(Encoding.Unicode.GetBytes(response.Content)));
                context.BundleCollection.GetBundleFor(context.BundleVirtualPath).CdnPath = string.Format("{0}?v={1}", uri, hash);
            }
        }
    }

    private static SHA256 CreateHashAlgorithm()
    {
        if (CryptoConfig.AllowOnlyFipsAlgorithms)
        {
            return new SHA256CryptoServiceProvider();
        }

        return new SHA256Managed();
    }
}
Zack Z.
  • 238
  • 3
  • 10