Friday, January 15, 2010

Combine JavaScript and CSS files

Todays websites implement sophisticated design and functionality. That results in a lot of CSS and JavaScript included in the page content. More files means more requests and slower load time.

Here is simple solution - combine the resource files and compress them with GZip.

The first part of the article is about how to combine AJAX Control Toolkit resource files. The second part demonstrates how to combine JavaScript or CSS files, strip the white space, compress the result with GZip and cache it.

1. How to combine WebResource.axd and ScriptResource.axd files:

#region Using

using System;
using System.IO;
using System.Web;
using System.Web.UI;
using System.Text.RegularExpressions;
using System.Collections.Generic;

#endregion

///
/// Find scripts and change the src to the ScriptCompressorHandler.
///

public class ScriptCompressorModule : IHttpModule
{

#region IHttpModule Members

void IHttpModule.Dispose()
{
// Nothing to dispose;
}

void IHttpModule.Init(HttpApplication context)
{
context.PostRequestHandlerExecute += new EventHandler(context_BeginRequest);
}

#endregion

void context_BeginRequest(object sender, EventArgs e)
{
HttpApplication app = sender as HttpApplication;
if (app.Context.CurrentHandler is Page && !app.Request.RawUrl.Contains("serviceframe"))
{
if (!app.Context.Request.Url.Scheme.Contains("https"))
{
app.Response.Filter = new WebResourceFilter(app.Response.Filter);
}
}
}

#region Stream filter

private class WebResourceFilter : Stream
{

public WebResourceFilter(Stream sink)
{
_sink = sink;
}

private Stream _sink;

#region Properites

public override bool CanRead
{
get { return true; }
}

public override bool CanSeek
{
get { return true; }
}

public override bool CanWrite
{
get { return true; }
}

public override void Flush()
{
_sink.Flush();
}

public override long Length
{
get { return 0; }
}

private long _position;
public override long Position
{
get { return _position; }
set { _position = value; }
}

#endregion

#region Methods

public override int Read(byte[] buffer, int offset, int count)
{
return _sink.Read(buffer, offset, count);
}

public override long Seek(long offset, SeekOrigin origin)
{
return _sink.Seek(offset, origin);
}

public override void SetLength(long value)
{
_sink.SetLength(value);
}

public override void Close()
{
_sink.Close();
}

public override void Write(byte[] buffer, int offset, int count)
{
byte[] data = new byte[count];
Buffer.BlockCopy(buffer, offset, data, 0, count);
string html = System.Text.Encoding.Default.GetString(buffer);
int index = 0;
List list = new List();

Regex regex = new Regex("]*>[^<]*(?:)?", RegexOptions.IgnoreCase);
foreach (Match match in regex.Matches(html))
{
if (index == 0)
index = html.IndexOf(match.Value);

string relative = match.Groups[1].Value;
list.Add(relative);
html = html.Replace(match.Value, string.Empty);
}

if (index > 0)
{
string script = "";
string path = string.Empty;
foreach (string s in list)
{
if (path.Length + HttpUtility.UrlEncode(s).Length > 1800)
{
html = html.Insert(index, string.Format(script, path));

index += script.Length + path.Length - 3;
path = String.Empty;
}

path += HttpUtility.UrlEncode(s) + ",";
}

html = html.Insert(index, string.Format(script, path));
}

byte[] outdata = System.Text.Encoding.Default.GetBytes(html);
_sink.Write(outdata, 0, outdata.GetLength(0));
}

#endregion
}

#endregion
}

Web.Config settings:






2. How to combine script and stylesheet files:

<%@ WebHandler Language="C#" Class="ResourceHandler" %>

using System;
using System.Configuration;
using System.IO;
using System.Net;
using System.IO.Compression;
using System.Reflection;
using System.Text.RegularExpressions;
using System.Web;

public class ResourceHandler : IHttpHandler {

#region Public Properties

public static bool UseFileSet
{
get { return Convert.ToBoolean(ConfigurationManager.AppSettings["UseFileSet"]); }
}

public static string VersionNo
{
get { return ConfigurationManager.AppSettings["VersionNo"]; }
}

public bool IsReusable
{
get { return true; }
}

#endregion

#region Private Methods

private static void Cache(TimeSpan duration)
{
HttpCachePolicy cache = HttpContext.Current.Response.Cache;

FieldInfo maxAgeField = cache.GetType().GetField("_maxAge", BindingFlags.Instance | BindingFlags.NonPublic);
maxAgeField.SetValue(cache, duration);

cache.SetCacheability(HttpCacheability.Public);
cache.SetExpires(DateTime.Now.Add(duration));
cache.SetMaxAge(duration);
cache.AppendCacheExtension("must-revalidate, proxy-revalidate");
}

private static byte[] GZip(string source)
{
byte[] result;

using (MemoryStream ms = new MemoryStream())
{
byte[] buffer = System.Text.Encoding.ASCII.GetBytes(source);

GZipStream GZip = new GZipStream(ms, CompressionMode.Compress);

GZip.Write(buffer, 0, buffer.Length);
GZip.Close();

result = ms.ToArray();
ms.Close();
}

return result;
}

private static string Minimize(string source)
{
string result = Regex.Replace(source.Trim(), @"(\s+)|((\r?\n)+)|(\t+)", " ");

return result;
}

private static string RetrieveScript(string file)
{
string script = null;

try
{
Uri url = new Uri(file, UriKind.Absolute);

HttpWebRequest request = (HttpWebRequest)WebRequest.Create(url);
request.Method = "GET";
request.AutomaticDecompression = DecompressionMethods.GZip;

using (HttpWebResponse response = (HttpWebResponse)request.GetResponse())
using (StreamReader reader = new StreamReader(response.GetResponseStream()))
{
script = reader.ReadToEnd();
}
}
catch (System.Net.Sockets.SocketException)
{
// The remote site is currently down. Try again next time.
}
catch (UriFormatException)
{
// Only valid absolute URLs are accepted
}

return script;
}

#endregion

public void ProcessRequest(HttpContext context)
{
HttpRequest request = context.Request;
HttpResponse response = context.Response;

string root = request.Url.GetLeftPart(UriPartial.Authority);

string contetType = request.QueryString["type"] ?? "application/x-javascript";

string[] fileNames = new string[0];

if (!string.IsNullOrEmpty(request.QueryString["fileSet"]))
{
string fileSet = request.QueryString["fileSet"];
//Basic Validation
if (string.IsNullOrEmpty(fileSet))
return;

if (string.IsNullOrEmpty(contetType))
return;

string files = ConfigurationManager.AppSettings["FileSet_" + fileSet];

if (string.IsNullOrEmpty(files))
return;

//Get the list of files specified in the FileSet
fileNames = files.Split(',');
}
else if (!string.IsNullOrEmpty(request.QueryString["path"]))
{
string files = HttpUtility.UrlDecode(request.QueryString["path"]);

//Get the list of files specified in the FileSet
fileNames = files.Split(',');
}

if ((fileNames == null) || (fileNames.Length == 0)) return;

bool compress = Convert.ToBoolean(request.QueryString["compress"] ?? "true");

// Check whether GZip is supported by the client
string acceptEncoding = request.Headers["Accept-Encoding"];

bool gZip = (!string.IsNullOrEmpty(acceptEncoding) && acceptEncoding.ToLower().IndexOf("gzip") > -1);

//Set the content type
response.ContentType = contetType;

string content = String.Empty;

foreach (string fileName in fileNames)
if (!string.IsNullOrEmpty(fileName))
if (fileName.ToUpperInvariant().Contains("RESOURCE.AXD"))
content += RetrieveScript(root + fileName) + Environment.NewLine;
else if (File.Exists(context.Server.MapPath(fileName)))
content += File.ReadAllText(context.Server.MapPath(fileName)) + Environment.NewLine;

if (compress)
if (gZip)
{
response.AppendHeader("Content-Encoding", "gzip");
response.BinaryWrite(GZip(content));
}
else if (contetType != "application/x-javascript")
response.Write(Minimize(content));
else
response.Write(content);
else
response.Write(content);

response.End();

// Cache the resource for 10 minutes
Cache(TimeSpan.FromMinutes(10));
}
}

Web.Config Settings:






Combining AJAX Control Toolkit Resource Files

It is common to use quite a few AJAX Control Toolkit control on a single page. Each one creates a reference to a WebResource.axd or ScriptResource.axd files. Although they are small in size, the browser makes a separate request for each and every one of them. Given the limitations of only two concurrent connections/requests it results in a long load time.

The good news is there different solutions but essentially the result is the same.
1. Do it manually via IHttpModule and IHttpHandler
Read more

2. Use AJAX Control Toolkit

Just replace with in your ASPX page and that would do the trick. ToolkitScriptManager inherits from the ScriptManager therefore no errors will occur in the code behind.

A few properties that need to be included:
EnableScriptLocalization="true" EnablePageMethods="true" EnableScriptGlobalization="true" ScriptMode="Release" CombineScripts="true"

You might run into issue with turning on the CombineScripts="true". IE sometimes throws "Out of Memory" error message caused by infinite loop in the TextBoxWatermark Control JavaScript code:

AjaxControlToolkit.TextBoxWrapper.registerClass('AjaxControlToolkit.TextBoxWrapper', Sys.UI.Behavior);AjaxControlToolkit.TextBoxWrapper.validatorGetValue = function(id) { var control = $get(id);if (control && control.AjaxControlToolkitTextBoxWrapper) { return control.AjaxControlToolkitTextBoxWrapper.get_Value();} return AjaxControlToolkit.TextBoxWrapper._originalValidatorGetValue(id);}

The last line (which calls _originalValidatorGetValue) basically calls back this exact function over and over because control.AjaxControlToolkitTextBoxWrapper is undefined.

If you encounter the above error you should set the CombineScripts to "false".