Question

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?

Was it helpful?

Solution

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?

OTHER TIPS

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();
        }
    }
}

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()));
}

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();
    }
}
Licensed under: CC-BY-SA with attribution
Not affiliated with StackOverflow
scroll top