Inside RazorScriptManager

22 Jun

When you add the RazorScriptManager NuGet package to your project, three things happen: Script.cshtml is added to /App_Code, RazorScriptManager.cs is added to /Handlers, and a web.config transform is performed. The first file provides the Razor helper methods used in your views. The second provides the HttpHandler that handles the combining/compression and responds to requests for scripts.axd. The web.config transform adds a couple settings and registers the HttpHandler.

Script.cshtml

There are four Razor helpers added as part of the NuGet package. Two for adding script references to the response, and two for writing out script tags for the response. One of each type is provided for CSS and JavaScript.

Inside the two Add methods (AddCss() and AddJavaScript()), a new ScriptInfo object is created for the referenced script. That ScriptInfo object contains the script type, local path, CDN path, and whether the script is used site-wide. The ScriptInfo object is then added to a List<ScriptInfo> that's kept in Session.

@helper AddJavaScript(string localPath, string cdnPath = null, bool siteWide = false) {
	var scriptType = ScriptType.JavaScript;
	//create a session key specifically for javascript ScriptInfo objects
	var key = "__rsm__" + scriptType.ToString();
	//If the List doesn't exist, create it
	if (Session[key] == null) {
		Session[key] = new List();
	}
	//pull out the current (or new) list - it may already have other ScriptInfo objects
	var scripts = Session[key] as List;
	//add the current ScriptInfo
	scripts.Add(new ScriptInfo(Server.MapPath(localPath), cdnPath, scriptType, siteWide));
	//put the list back in Session
	Session[key] = scripts;
}

In the Output methods (OutputCss() and OutputJavaScript()), the List<ScriptInfo> is extracted from Session. Based on web.config settings, a list of CDN-hosted scripts may be extracted. The helper then writes out <script> or <link> tags for CDN-hosted scripts (if any) and for the HttpHandler path. An MD5 hash is generated from the filenames of the referenced local scripts and appended to the HttpHandler path. This MD5 is used to cache the combined/compressed output in the HttpHandler, and will be explained more in that section.

@helper OutputJavaScript() {
	var scriptType = ScriptType.JavaScript;
	//create a session key specifically for javascript ScriptInfo objects
	var key = "__rsm__" + scriptType.ToString();
	//if no scripts have been added, don't do anything
	if (Session[key] == null) return;
	//pull out the current list from Session
	var scripts = Session[key] as List;
	var cdnScripts = new List();
	//if the web.config says to use CDN-hosted scripts, extract then from the list into cdnScripts
	if (bool.Parse(System.Configuration.ConfigurationManager.AppSettings["UseCDNScripts"])) {
		//get all scripts without a CDN path
		var localScripts = scripts.Where(s => string.IsNullOrWhiteSpace(s.CDNPath)).ToList().ToList();
		//get all scripts that aren't local-only scripts
		cdnScripts = scripts.Except(localScripts).ToList();
		//put the local scripts back into session (CDN scripts are handled here, not in the HttpHandler)
		Session[key] = localScripts;
	}

	//write out the CDN scripts to the response
	foreach (var cdnScript in cdnScripts) {
<script type="text/javascript" src="@cdnScript.CDNPath"></script>}

	//generate a unique hash based on the filenames
	var hash = HttpUtility.UrlEncode(RazorScriptManager.GetHash(scripts));
	//write out a script tag for the HttpHandler using the script type and hash<script type="text/javascript" src="/scripts.axd?type=@scriptType.ToString()&hash=@hash"></script>
}

RazorScriptManager.cs

The class file for the HttpHandler also contains the definitions for ScriptInfo, ScriptType and ScriptInfoComparer. These classes represent a script reference, the type of script, and a way of comparing two scripts. The comparer is used later to eliminate duplicate script references (e.g. if you have a reference to the same jQuery file on your Layout and a partial view, it will only use one). The RazorScriptManager class itself (which is the actual HttpHandler) provides an instance method for responding to requests (ProcessRequest()) and a static method for generating a hash (GetHash()). GetHash() works by appending the distinct list of script paths into a single string, then generating a standard MD5 hash of that string.

public static string GetHash(IEnumerable scripts) {
	var input = string.Join(string.Empty, scripts.Select(s => s.LocalPath).Distinct());
	var hash = System.Security.Cryptography.MD5.Create().ComputeHash(Encoding.ASCII.GetBytes(input));
	var sb = new StringBuilder();
	for (int i = 0; i < hash.Length; i++)
		sb.Append(hash[i].ToString("X2"));
	return sb.ToString();
}

ProcessRequest() is a little more involved. First, it determines the type of script being requested from the querystring value. Based on the type, it sets the response's content type appropriately.

var scriptType = (ScriptType)Enum.Parse(typeof(ScriptType), context.Request.Params["type"]);

switch (scriptType) {
	case ScriptType.JavaScript:
		context.Response.ContentType = @"application/javascript";
		break;
	case ScriptType.Stylesheet:
		context.Response.ContentType = @"text/css";
		break;
}

After setting content type, the method checks the application cache to see if a combined/compress script has already been generated for that particular set of files. To do this, it uses the hash as a cache key. If the script output already exists, the method immediately returns the cached output and no further processing is required.

var hashString = context.Request.Params["hash"];
if (!String.IsNullOrWhiteSpace(hashString)) {
	var result = cache[HttpUtility.UrlDecode(hashString)] as string;
	if (!string.IsNullOrWhiteSpace(result)) {
		context.Response.Write(result);
		return;
	}
}

If the output wasn't already cached, the method pulls the List<ScriptInfo> for the current type out of Session. It then obtains the distinct scripts from the list using the ScriptInfoComparer and reorders them based on whether or not they were marked as site-wide. Site-wide scripts (like jQuery) need to be loaded first, so that other script can take advantage of their methods. At this point, the contents of each file are added to a single string. This string will become the combined and compressed single script that's returned by the handler.

var scripts = context.Session["__rsm__" + scriptType.ToString()] as IEnumerable;
context.Session["__rsm__" + scriptType.ToString()] = null;
if (scripts == null) return;
var scriptbody = new StringBuilder();

scripts = scripts.Distinct(new ScriptInfoComparer());

//add sitewide scripts FIRST, so they're accessible to local scripts
var siteScripts = scripts.Where(s => s.SiteWide);
var localScripts = scripts.Where(s => !s.SiteWide).Except(siteScripts, new ScriptInfoComparer());
var scriptPaths = siteScripts.Concat(localScripts).Select(s => s.LocalPath);
var minify = bool.Parse(ConfigurationManager.AppSettings["CompressScripts"]);

foreach (var script in scriptPaths) {
	if (!String.IsNullOrWhiteSpace(script)) {
		using (var file = new System.IO.StreamReader(script)) {
			var fileContent = file.ReadToEnd();
			if (scriptType == ScriptType.Stylesheet) {
				var fromUri = new Uri(context.Server.MapPath("~/"));
				var toUri = new Uri(new FileInfo(script).DirectoryName);
				fileContent = fileContent.Replace("url(", "url(/" + fromUri.MakeRelativeUri(toUri).ToString() + "/");
			}
			if (!minify) scriptbody.AppendLine(String.Format("/* {0} */", script));
			scriptbody.AppendLine(fileContent);
		}
	}
}
string scriptOutput = scriptbody.ToString();

If CompressScripts is set to true in the web.config, run the appropriate minifier for the current script type. Side note: there's some interesting asymmetry within the YUI Compressor: for JavaScript the compress method is an instance method, while for CSS the compress method is a static method.

string scriptOutput = scriptbody.ToString();
if (minify) {
	switch (scriptType) {
		case ScriptType.JavaScript:
			var jscompressor = new Yahoo.Yui.Compressor.JavaScriptCompressor(scriptOutput);
			scriptOutput = jscompressor.Compress();
			break;
		case ScriptType.Stylesheet:
			scriptOutput = Yahoo.Yui.Compressor.CssCompressor.Compress(scriptOutput);
			break;
	}
}

Finally, save the output to the cache (for next time!) and send the output as the reponse.

var hash = GetHash(scripts);
cache[hash] = scriptOutput;
context.Response.Write(scriptOutput);

web.config.transform

Two appSettings are added to the web.config: UseCDNScripts and CompressScripts. The first determines whether or not the Output Razor helpers write out tags for the CDN paths. The second determines whether or not the <code>HttpHandler</code> compresses the combined output before returning the response.

<appSettings>
  <add key="UseCDNScripts" value="false" />
  <add key="CompressScripts" value="false" />
</appSettings>

The HttpHandler is also registered in the web.config. One version for IIS6, another for IIS7.

<system.web>
  <httpHandlers>
    <add verb="*" path="scripts.axd" type="RazorScriptManager.RazorScriptManager"/>
  </httpHandlers>
</system.web>
<system.webServer>
  <handlers>
    <add name="ScriptManager" verb="*" path="scripts.axd" type="RazorScriptManager.RazorScriptManager"/>
  </handlers>
</system.webServer>
About these ads

2 Responses to “Inside RazorScriptManager”

  1. Michiel August 3, 2011 at 8:49 AM #

    Nice code, however, what I don’t like is that it depends on the Session object. I always try to avoid the Session object, it can cause trouble with locking requests etc. Would it be possible to create the same functionality without using sessions?

  2. Michiel August 3, 2011 at 9:06 AM #

    It will probably work using HttpContext.Current.Items instead of Session. Will try it out.

Comments are closed.

Follow

Get every new post delivered to your Inbox.

%d bloggers like this: