Click here to Skip to main content
15,879,095 members
Articles / Web Development / CSS

Combres - WebForm & MVC Client-side Resource Combine Library

Rate me:
Please Sign up or sign in to vote.
4.50/5 (8 votes)
1 Nov 2009Apache13 min read 52K   3.1K   22  
A .NET library which enables minification, compression, combination, and caching of JavaScript and CSS resources for ASP.NET and ASP.NET MVC web applications. Simply put, it helps your applications rank better with YSlow and PageSpeed.
#region License
// Copyright 2009 Buu Nguyen (http://www.buunguyen.net/blog)
// 
// Licensed under the Apache License, Version 2.0 (the "License"); 
// you may not use this file except in compliance with the License. 
// You may obtain a copy of the License at 
// 
// http://www.apache.org/licenses/LICENSE-2.0 
// 
// Unless required by applicable law or agreed to in writing, software 
// distributed under the License is distributed on an "AS IS" BASIS, 
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 
// See the License for the specific language governing permissions and 
// limitations under the License.
// 
// The latest version of this file can be found at http://combres.codeplex.com
#endregion

using System;
using System.Globalization;
using System.IO;
using System.IO.Compression;
using System.Net;
using System.Text;
using System.Web;
using Fasterflect;
using log4net;
using Yahoo.Yui.Compressor;

namespace Combres
{
    internal class RequestHandler
    {
        private static readonly ILog Log = LogManager.GetLogger(
                       System.Reflection.MethodBase.GetCurrentMethod().DeclaringType);

        private HttpContext Context { get; set; }
        private Settings Settings { get; set; }
        private String CacheKey { get; set; }
        private String DateCacheKey { get; set; }
        private bool CanCompress { get; set; }
        private ResourceSet ResourceSet { get; set; }

        public RequestHandler(HttpContext context, Settings settings, 
                                string setName, string version)
        {
            Context = context;
            Settings = settings; 
            ResourceSet = Settings[setName];
            if (ResourceSet == null)
            {
                throw new ResourceSetNotFoundException("Resource set not found");
            }
            CanCompress = ClientAcceptsCompression(Context.Request);
            CacheKey = string.Concat(typeof(RequestHandler), setName, "_", version, "_", CanCompress);
            DateCacheKey = string.Concat(CacheKey, "_date");
        }

        public void Execute()
        {
            if (ResourceSet.DebugEnabled)
            {
                ProcessAndWrite();
                return;
            }

            if (IsInBrowserCache()) 
                return;

            if (WriteFromServerCache()) 
                return;

            ProcessAndWrite();
        }

        private void ProcessAndWrite()
        {
            using (var memoryStream = new MemoryStream(4096))
            {
                var combinedContent = GetCombinedContents();
                if (!ResourceSet.DebugEnabled)
                    combinedContent = MinifyContent(combinedContent);
                
                ZipContentToStream(combinedContent, memoryStream);
                var responseBytes = memoryStream.ToArray();
                if (!ResourceSet.DebugEnabled)
                    CacheNewResponse(responseBytes);
                SendOutputToClient(responseBytes, !ResourceSet.DebugEnabled);
            }
        }

        private string MinifyContent(string combinedContent)
        {
            if (Log.IsDebugEnabled)
                Log.Debug("Minifying combined content...");
            combinedContent = ResourceSet.Type == ResourceType.JS
                                  ? CompressJs(combinedContent)
                                  : CompressCss(combinedContent);

            Settings.FilterTypes.ForEach(filterType =>
                {
                    if (Log.IsDebugEnabled)
                        Log.Debug("Performing minified content filtering with " + filterType + " for set " + ResourceSet.Name + "...");
                    combinedContent = filterType.Construct().Invoke<string>("TransformMinifiedContent",
                        new[] { typeof(Settings), typeof(ResourceSet), typeof(string) },
                        new object[] { Settings, ResourceSet, combinedContent });
                });

            return combinedContent;
        }

        /// <summary>
        /// Browsers supporting gzip also supporting deflate and gzip is usually more
        /// efficient.  Thus, there's no need to handle deflate separately.
        /// </summary>
        private void ZipContentToStream(string content, Stream stream)
        {
            using (var writer = CanCompress
                                    ? new GZipStream(stream, CompressionMode.Compress)
                                    : stream)
            {
                var bytes = Encoding.UTF8.GetBytes(content);
                writer.Write(bytes, 0, bytes.Length);
            }
        }

        private void CacheNewResponse(byte[] responseBytes)
        {
            Context.Cache.Insert(CacheKey,
                                 responseBytes, 
                                 null /* cache dependencies */, 
                                 System.Web.Caching.Cache.NoAbsoluteExpiration,
                                 ResourceSet.DurationInDays);
            Context.Cache[DateCacheKey] = DateTime.Now;
            var etag = GetEtagFromCache(Context, DateCacheKey);
            Context.Response.Cache.SetETag(etag);
        }

        private bool WriteFromServerCache()
        {
            var responseBytes = Context.Cache[CacheKey] as byte[];
            if (responseBytes == null || responseBytes.Length == 0)
                return false;

            if (Log.IsDebugEnabled) 
                Log.Debug("Writing to client from server's cache...");
            SendOutputToClient(responseBytes, true);
            return true;
        }

        private string GetCombinedContents()
        {
            var allResourcesContent = new StringBuilder();

            if (Log.IsDebugEnabled)
                Log.Debug("Combining resources' content...");
            foreach (var resource in ResourceSet)
            {
                var content = ReadResourceContent(resource);
                Settings.FilterTypes.ForEach(filterType =>
                    {
                        if (Log.IsDebugEnabled)
                            Log.Debug("Performing single content filtering with " + filterType + " for " + resource.Path + "...");
                        content = filterType.Construct().Invoke<string>("TransformSingleContent",
                            new[] { typeof(Settings), typeof(Resource), typeof(string) },
                            new object[] { Settings, resource, content });   
                    });

                allResourcesContent.Append(content);
            }

            var allResourcesContentString = allResourcesContent.ToString();
            Settings.FilterTypes.ForEach(filterType =>
                {
                    if (Log.IsDebugEnabled)
                        Log.Debug("Performing combined content filtering with " + filterType + " for set " + ResourceSet.Name + "...");
                    allResourcesContentString = filterType.Construct().Invoke<string>("TransformCombinedContent",
                        new[] { typeof(Settings), typeof(ResourceSet), typeof(string) },
                        new object[] { Settings, ResourceSet, allResourcesContentString });
                });
            return allResourcesContentString;
        }

        private string ReadResourceContent(Resource resource)
        {
            if (Log.IsDebugEnabled)
                Log.Debug("Reading content of " + resource.Path + "...");

            switch (resource.Mode)
            {
                case ResourceMode.Remote:
                case ResourceMode.LocalDynamic:
                    var absoluteUrl = resource.Mode == ResourceMode.Remote 
                        ? resource.Path
                        : resource.Path.ToAbsoluteUrl();
                    if (absoluteUrl == null)
                        throw new ResourceNotFoundException(resource.Path + " cannot be found");
                    try
                    {
                        var webClient = new WebClient();
                        webClient.Headers["Cookie"] = Context.Request.Headers["Cookie"];
                        var stream = webClient.OpenRead(absoluteUrl);
                        var reader = new StreamReader(stream, Encoding.UTF8);
                        return reader.ReadToEnd();
                    }
                    catch (WebException)
                    {
                        throw new ResourceNotFoundException(resource.Path + " cannot be found");
                    }
                default:
                    var fullPath = Context.Server.MapPath(resource.Path);
                    if (!File.Exists(fullPath))
                        throw new ResourceNotFoundException(resource.Path + " cannot be found");
                    return File.ReadAllText(fullPath);
            }
        }

        private void SendOutputToClient(byte[] bytes, bool insertCacheHeaders)
        {
            if (Log.IsDebugEnabled)
                Log.Debug("Writing content to browser...");

            var response = Context.Response;

            response.AppendHeader("Content-Length", bytes.Length.ToString());
            response.ContentType = ResourceSet.Type == ResourceType.JS ? "application/x-javascript" : "text/css";
            response.AppendHeader("Content-Encoding", CanCompress ? "gzip" : "utf-8");
            response.ContentEncoding = Encoding.Unicode;

            if (insertCacheHeaders)
            {
                /* Tell proxy to cache different versions depending on Accept-Encoding */
                response.Cache.VaryByHeaders["Accept-Encoding"] = true;
                response.Cache.SetOmitVaryStar(true);
                response.Cache.SetMaxAge(ResourceSet.DurationInDays);
                response.Cache.SetLastModified(DateTime.Now);
                response.Cache.SetExpires(DateTime.Now.Add(ResourceSet.DurationInDays)); /* For HTTP 1.0 browsers */
                response.Cache.SetValidUntilExpires(true);
                response.Cache.SetCacheability(HttpCacheability.Public);
                response.Cache.SetRevalidation(HttpCacheRevalidation.AllCaches);
            }

            response.OutputStream.Write(bytes, 0, bytes.Length);
            response.Flush();
        }

        private bool IsInBrowserCache()
        {
            string etag = GetEtagFromCache(Context, DateCacheKey);
            if (etag == null)
                return false;

            string incomingEtag = Context.Request.Headers["If-None-Match"];
            if (String.Equals(incomingEtag, etag, StringComparison.Ordinal))
            {
                if (Log.IsDebugEnabled)
                    Log.Debug("ETag matches, ending request...");

                Context.Response.Cache.SetETag(etag);
                Context.Response.AppendHeader("Content-Length", "0");
                Context.Response.StatusCode = (int)HttpStatusCode.NotModified;
                Context.Response.End();
                return true;
            }
            return false;
        }

        private static string CompressCss(string content)
        {
            return CssCompressor.Compress(content);
        }

        private static string CompressJs(string content)
        {
            return JavaScriptCompressor.Compress(content,
                                                 false  /* isVerboseLogging */,
                                                 true   /* isObfuscateJavascript */,
                                                 false  /* preserveAllSemicolons */,
                                                 false  /* disableOptimizations */,
                                                 -1     /* lineBreakPosition */,
                                                 Encoding.UTF8,
                                                 CultureInfo.CurrentCulture);
        }

        private static string GetEtagFromCache(HttpContext context, string dateCacheKey)
        {
            var lastModified = context.Cache[dateCacheKey] as DateTime?;
            return lastModified == null 
                ? null 
                : string.Concat("\"", lastModified.GetHashCode(), "\"");
        }

        private static bool ClientAcceptsCompression(HttpRequest request)
        {
            var acceptEncoding = request.Headers["Accept-Encoding"];
            return !string.IsNullOrEmpty(acceptEncoding) &&
                   (acceptEncoding.Contains("gzip") || acceptEncoding.Contains("deflate"));
        }
    }
}

By viewing downloads associated with this article you agree to the Terms of Service and the article's licence.

If a file you wish to view isn't highlighted, and is a text file (not binary), please let us know and we'll add colourisation support for it.

License

This article, along with any associated source code and files, is licensed under The Apache License, Version 2.0


Written By
Chief Technology Officer KMS Technology
Vietnam Vietnam
You can visit Buu's blog at http://www.buunguyen.net/blog to read about his thoughts on software development.

Comments and Discussions