using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Text;
using System.Web;
using System.Web.UI;
using System.Web.UI.WebControls;
using System.IO;
using System.Text.RegularExpressions;
namespace HighPerformanceScript {
/// <summary>
/// A replacement script tag control that applies high performance tricks to reduce
/// bandwidth, latency and number of requests when loading web pages.
/// </summary>
[ParseChildrenAttribute(ChildrenAsProperties = true, DefaultProperty = "JavaScript")]
[ToolboxData("<{0}:Script runat=server></{0}:Script>")]
public class Script : WebControl {
/// <summary>
/// On load, add the control to the ScriptsInPage for later processing.
/// </summary>
protected override void OnLoad(EventArgs e) {
base.OnLoad(e);
ScriptsInPage.Add(this);
}
/// <summary>
/// The inline JavaScript of the control, mutually exclusive with the Src property.
/// </summary>
[PersistenceMode(PersistenceMode.InnerDefaultProperty)]
public string JavaScript {
get {
return myJavaScript;
}
set {
myJavaScript = value;
}
}
private string myJavaScript = "";
/// <summary>
/// The Src of the file for the external JavaScript, mutually exclusive with the JavaScript property.
/// </summary>
public string Src {
get {
return mySrc;
}
set {
mySrc = value;
}
}
private string mySrc = "";
/// <summary>
/// Indicates whether the OUTPUT of the control will be inline or external. This is only
/// applicable if the INPUT to the control is inline. If external (the default) the inline
/// script is served up as if it were an external file.
/// </summary>
public ScriptLocation Location {
get {
return myLocation;
}
set {
myLocation = value;
}
}
private ScriptLocation myLocation = ScriptLocation.External;
/// <summary>
/// All script controls in the page are aggregated into a single script tag.
/// The first control to pre-render will collect all controls, emit the script tag and clear the list.
/// Subsequent controls attempting to pre-render will find an empty list and will do nothing.
/// Each control will not render at the point it is declared, so rendering is suppressed.
/// </summary>
protected override void Render(HtmlTextWriter writer) {
if(String.IsNullOrEmpty(Src) == false && String.IsNullOrEmpty(JavaScript) == false) {
throw new Exception("Script tag may declare either a source file or inline JavaScript, but not both.");
}
if(ScriptsInPage.Count > 0) {
JavaScriptResource aggregateResource = GetAggregatedPageScripts();
if(Page.Request.UserHostName == "127.0.0.1") {
foreach(string resourceName in aggregateResource.FileUrl.Split('|')) {
JavaScriptResource resource = JavaScriptResource.AllResources[resourceName];
string script = String.Format("<script src=\"{0}?File={1}&Hash={2}&Output=Full\" type=\"text/javascript\"></script>\n",
ResolveUrl("~/ScriptResource.aspx"), resource.FileUrl, resource.JavaScript.GetHashCode());
Page.ClientScript.RegisterStartupScript(typeof(Script), resourceName, script);
}
}
else {
string script = String.Format(@"<script src=""{0}?File={1}&Hash={2}"" type=""text/javascript""></script>",
ResolveUrl("~/ScriptResource.aspx"), aggregateResource.FileUrl, aggregateResource.MinifiedJavaScript.GetHashCode());
Page.ClientScript.RegisterStartupScript(typeof(Script), "singleton?", script);
ScriptsInPage.Clear();
}
}
if(Location == ScriptLocation.Inline) {
writer.WriteBeginTag("script");
writer.WriteAttribute("type", "text/javascript");
writer.Write(">");
writer.Write(JavaScript);
writer.WriteEndTag("script");
}
}
/// <summary>
/// ScriptsInPage provides a list of all scripts that are defined in the page.
/// The life of this needs to span across the page and into any user or custom controls,
/// so it is stored in the 'request-cache' provided by HttpContext.Current.Items.
/// </summary>
private static List<Script> ScriptsInPage {
get {
string dictionaryKey = "ScriptWebControls";
if(HttpContext.Current.Items.Contains(dictionaryKey) == false) {
HttpContext.Current.Items.Add(dictionaryKey, new List<Script>());
}
return (List<Script>)HttpContext.Current.Items[dictionaryKey];
}
}
/// <summary>
/// Creates a single JavaScript resource out of all scripts declared in this
/// page or on any control in the page's Controls tree.
/// </summary>
private JavaScriptResource GetAggregatedPageScripts() {
string cachedPageScriptsResource = "cachedPageScriptsResource";
if(HttpContext.Current.Items.Contains("cachedPageScriptsResource") == true) {
return (JavaScriptResource)HttpContext.Current.Items["cachedPageScriptsResource"];
}
List<JavaScriptResource> currentPageScripts = new List<JavaScriptResource>();
JavaScriptResource.RemoveFromCache(Page.Request.Path);
int cachedFiles = JavaScriptResource.AllResources.Count;
// Iterate through all scripts for file based scripts.
foreach(Script scriptControl in ScriptsInPage) {
if(scriptControl.Src != "") {
string scriptName = ResolveUrl(scriptControl.Src);
JavaScriptResource resource = JavaScriptResource.GetResourceFromFile(scriptName);
if(currentPageScripts.Contains(resource) == false) {
currentPageScripts.Add(resource);
}
AddPrerequisites(resource, currentPageScripts);
}
}
currentPageScripts.Sort();
// Iterate through all scripts for non-file based scripts.
foreach(Script scriptControl in ScriptsInPage) {
if(scriptControl.Location == ScriptLocation.Inline) {
continue;
}
if(scriptControl.Src == "") {
JavaScriptResource resource = JavaScriptResource.GetResourceForPage(Page);
resource.JavaScript += "\n" + scriptControl.JavaScript;
if(currentPageScripts.Contains(resource) == false) {
currentPageScripts.Add(resource);
}
}
}
StringBuilder aggregateScriptName = new StringBuilder();
foreach(JavaScriptResource file in currentPageScripts) {
aggregateScriptName.Append(file.FileUrl);
aggregateScriptName.Append("|");
}
aggregateScriptName.Length--; // trim last pipe from name.
string name = aggregateScriptName.ToString();
JavaScriptResource aggregateResource = JavaScriptResource.GetAggregateResource(name);
HttpContext.Current.Items.Add(cachedPageScriptsResource, aggregateResource);
return aggregateResource;
}
/// <summary>
/// Given a JavaScript resource and a list of resources, add all of the prerequisites
/// of the resource to the list. Also, if any prerequisites have prerequisites, those
/// are added as well.
/// </summary>
private void AddPrerequisites(JavaScriptResource dependantFile, List<JavaScriptResource> files) {
foreach(JavaScriptResource prerequisite in dependantFile.Prerequisites) {
if(files.Contains(prerequisite) == false) {
files.Add(prerequisite);
AddPrerequisites(prerequisite, files);
}
}
}
/// <summary>
/// Registers a given URL as a script include that is managed by the high performance script
/// control. Absolute or application root relative URLs are required.
/// </summary>
public static void RegisterClientScriptInclude(Control controlOrPage, string fileUrl) {
Script script = new Script();
script.Src = fileUrl;
script.Visible = true;
ScriptsInPage.Add(script);
controlOrPage.Controls.Add(script);
}
/// <summary>
/// Registers a given URL as a script include that is managed by the high performance script
/// control. Absolute or application root relative URLs are required.
/// </summary>
public static void RegisterClientScriptBlock(Control controlOrPage, string javascript) {
Script script = new Script();
script.JavaScript = javascript;
script.Visible = true;
ScriptsInPage.Add(script);
controlOrPage.Controls.Add(script);
}
/// <summary>
/// Provides access to cached version of simple includes or aggregated scripts.
/// AggregateName is a pipe-separated list of full paths to scripts. For example:
/// "/MyRootScript.js|/Scripts/MyScript.js". If the aggregated version does not
/// yet exist, it is created. This is therefore safe to call from web farms.
/// </summary>
public static string GetJavaScriptContent(string aggregateName, ScriptOutput output) {
JavaScriptResource file = JavaScriptResource.GetAggregateResource(aggregateName);
if(output == ScriptOutput.Full) {
return file.JavaScript;
}
else {
return file.MinifiedJavaScript;
}
}
/// <summary>
/// Private internal class that manages JavaScript resources, whether they are
/// external files, inline code or aggregated from other resources.
/// </summary>
private class JavaScriptResource : IComparable<JavaScriptResource> {
/// <summary>
/// No public constructors for JavaScriptResource, use one of: GetResourceFromFile,
/// GetResourceForPage or GetAggregatedResource.
/// </summary>
private JavaScriptResource() {
}
/// <summary>
/// Loads a JavaScript resource from the indicated file. If the file has already
/// been loaded and is not out of date, the cached version is returned. Processes
/// any include directives in the file and loads them as well. All resources
/// are cached and then the specified resource is returned.
/// </summary>
public static JavaScriptResource GetResourceFromFile(string fileUrl) {
FileInfo info = new FileInfo(HttpContext.Current.Request.MapPath(fileUrl));
if(AllResources.ContainsKey(fileUrl) && AllResources[fileUrl].LastUpdate < info.LastWriteTime) {
// Have an out of date file, remove cached version.
RemoveFromCache(fileUrl);
}
if(AllResources.ContainsKey(fileUrl) == true) {
// Cache hit, check for out of date pre-requisites and then return cached version.
JavaScriptResource currentResource = AllResources[fileUrl];
foreach(JavaScriptResource prereqResource in currentResource.Prerequisites) {
GetResourceFromFile(prereqResource.FileUrl);
}
return currentResource;
}
JavaScriptResource resource = new JavaScriptResource();
resource.myFileUrl = VirtualPathUtility.ToAbsolute(fileUrl);
resource.myLastUpdate = info.LastWriteTime;
try {
using(Stream inputStream = info.OpenRead()) {
using(StreamReader reader = new StreamReader(inputStream)) {
resource.JavaScript = reader.ReadToEnd();
}
}
}
catch(FileNotFoundException) {
string message = String.Format("Could not find file '{1}', referenced by URL '{0}'.",
fileUrl, info.FullName);
throw new FileNotFoundException(message);
}
Regex includeMatcher = new Regex(@"include\W*\(\W*""(.+\.js)""\W*\)");
string currentPath = VirtualPathUtility.GetDirectory(resource.myFileUrl);
foreach(Match match in includeMatcher.Matches(resource.JavaScript)) {
string prereqUrl = match.Groups[1].Value;
string prereqPath;
if(VirtualPathUtility.IsAppRelative(prereqUrl) == false) {
prereqUrl = currentPath + prereqUrl;
}
prereqPath = VirtualPathUtility.ToAbsolute(prereqUrl);
JavaScriptResource prereqResource = GetResourceFromFile(prereqPath);
resource.Prerequisites.Add(prereqResource);
}
AllResources.Add(fileUrl, resource);
ClearGraphDepths();
return resource;
}
/// <summary>
/// Takes a name of an aggregate resource (e.g. /File1.js|/File2.js) and creates a cached
/// version of the aggregate from the previously-cached components (i.e. File1.js and File2.js).
/// </summary>
public static JavaScriptResource GetAggregateResource(string aggregateName) {
if(AllResources.ContainsKey(aggregateName) == false) {
StringBuilder body = new StringBuilder();
foreach(string fileUrl in aggregateName.Split('|')) {
body.Append(AllResources[fileUrl].JavaScript);
body.Append("\n");
}
JavaScriptResource file = new JavaScriptResource();
file.myFileUrl = aggregateName;
file.myGraphDepth = 1; // aggregated files have not prereqs.
file.JavaScript = body.ToString();
AllResources.Add(aggregateName, file);
}
return AllResources[aggregateName];
}
/// <summary>
/// Gets a JavaScript resource for the indicated ASPX page. This resource
/// is used to manage all of the inline scripts.
/// </summary>
public static JavaScriptResource GetResourceForPage(Page page) {
JavaScriptResource resource;
if(AllResources.ContainsKey(page.Request.Path) == false) {
resource = new JavaScriptResource();
resource.myFileUrl = page.Request.Path;
resource.myGraphDepth = 1; // No dependencies allowed in inline javascript.
FileInfo fi = new FileInfo(page.Request.PhysicalPath);
resource.myLastUpdate = fi.LastWriteTime;
AllResources.Add(page.Request.Path, resource);
}
return AllResources[page.Request.Path];
}
/// <summary>
/// The absolute file URL for the resource.
/// </summary>
public string FileUrl {
get {
return myFileUrl;
}
}
private string myFileUrl;
/// <summary>
/// List of all resources that are a prerequisite for this resource. This
/// only applies to resources loaded from file that had a 'include' directive.
/// </summary>
public List<JavaScriptResource> Prerequisites {
get {
return myPrerequisites;
}
}
private List<JavaScriptResource> myPrerequisites = new List<JavaScriptResource>();
/// <summary>
/// For file based scripts and inline scripts, the date the file was last written.
/// Used to check and expire cache items when files have changed.
/// </summary>
public DateTime LastUpdate {
get {
return myLastUpdate;
}
}
private DateTime myLastUpdate;
/// <summary>
/// The longest distance from the current resource, through prerequisites
/// to a independent resources. Used to enable quick and dirty graph sorting.
/// </summary>
public int GraphDepth {
get {
SetGraphDepths();
return myGraphDepth;
}
}
private int myGraphDepth = 0;
private static bool graphDepthsValid = false;
/// <summary>
/// Clear graph depths whenever a file is re-read that could change the
/// prerequisite graph.
/// </summary>
private static void ClearGraphDepths() {
graphDepthsValid = false;
}
/// <summary>
/// When a graph depth is requested, ensure that they are all loaded.
/// </summary>
private static void SetGraphDepths() {
if(graphDepthsValid == false) {
foreach(JavaScriptResource file in JavaScriptResource.AllResources.Values) {
file.myGraphDepth = 0;
}
foreach(JavaScriptResource file in JavaScriptResource.AllResources.Values) {
file.SetGraphDepth();
}
graphDepthsValid = true;
}
}
/// <summary>
/// Set the current graph depth as one greater than the large prerequisite.
/// </summary>
private int SetGraphDepth() {
if(myGraphDepth > 0) {
// Already calculated, do nothing.
}
else if(Prerequisites == null || Prerequisites.Count == 0) {
// No prereqs, depth is 1.
myGraphDepth = 1;
}
else {
int maxPrereqDepth = 0;
foreach(JavaScriptResource prereq in Prerequisites) {
int currentPrereqDepth = prereq.SetGraphDepth();
maxPrereqDepth = Math.Max(maxPrereqDepth, currentPrereqDepth);
}
myGraphDepth = maxPrereqDepth + 1;
}
return myGraphDepth;
}
/// <summary>
/// The JavaScript that is associated with this script tag, either read from file or
/// declared inline.
/// </summary>
public string JavaScript {
get {
return myJavaScript;
}
set {
myJavaScript = value;
myMinifiedJavaScript = null;
myGraphDepth = 0;
}
}
private string myJavaScript = "";
/// <summary>
/// The minified version of the JavaScript. This is read-only, set JavaScript property
/// of object and this property will represent the minified version of the JavaScript.
/// </summary>
/// <remarks>
/// Lazy evaluated property. Only compute it if it is needed and then save the information
/// unless the JavaScript property changes.
/// </remarks>
public string MinifiedJavaScript {
get {
if(myMinifiedJavaScript == null) {
JavaScriptSupport.JavaScriptMinifier minifier = new JavaScriptSupport.JavaScriptMinifier();
StringBuilder builder = new StringBuilder();
using(StringReader reader = new StringReader(myJavaScript)) {
using(StringWriter writer = new StringWriter(builder)) {
minifier.Minify(reader, writer);
}
}
myMinifiedJavaScript = builder.ToString();
}
return myMinifiedJavaScript;
}
}
private string myMinifiedJavaScript = null;
/// <summary>
/// CompareTo is overridden to provide the order of JavaScript files as determined
/// by their dependencies. That is, if a object 'file2' has object 'file1' as a
/// dependency through an 'include' setting, then 'file1' will compare less than 'file2'.
/// The second part of the compare is alphabetic so that a unique ordering is defined.
/// </summary>
public int CompareTo(JavaScriptResource other) {
if(GraphDepth == other.GraphDepth) {
return myFileUrl.CompareTo(other.myFileUrl);
}
else {
return GraphDepth.CompareTo(other.GraphDepth);
}
}
/// <summary>
/// List of all javascript resources. This is cached with each application.
/// </summary>
public static Dictionary<string, JavaScriptResource> AllResources {
get {
string dictionaryKey = "JavaScriptFiles";
Dictionary<string, JavaScriptResource> files = HttpContext.Current.Application[dictionaryKey] as Dictionary<string, JavaScriptResource>;
if(files == null) {
files = new Dictionary<string, JavaScriptResource>();
HttpContext.Current.Application.Add(dictionaryKey, files);
}
return files;
}
}
/// <summary>
/// When a file is identified as having been changed, it is necessary to
/// remove that file from the cache as well as any aggregate that may have
/// used it.
/// </summary>
public static void RemoveFromCache(string FileUrl) {
List<string> toDelete = new List<string>();
foreach(string resourceUrl in AllResources.Keys) {
if(resourceUrl.Contains(FileUrl) == true) {
toDelete.Add(resourceUrl);
}
}
foreach(string resourceUrl in toDelete) {
AllResources.Remove(resourceUrl);
}
}
}
/// <summary>
/// Indicates whether the script is in an external file through the SRC attribte, or was
/// declared inline in the script tag.
/// </summary>
public enum ScriptLocation {
/// <summary>
/// The script will be rendered in the web page at the point of the script tag.
/// </summary>
Inline,
/// <summary>
/// The script will be aggregated with others and included at the end of the page.
/// </summary>
External
}
/// <summary>
/// Indicates how to output the script, for debuggin purposes on localhost this will be
/// full, but during testing and production it will be minified for performance.
/// </summary>
public enum ScriptOutput {
/// <summary>
/// Output the complete body of all aggregated scripts, retaining comments, whitespace and line numbers.
/// </summary>
Full,
/// <summary>
/// Output a minified version suitable for production.
/// </summary>
Minified
}
}
}