CombineJS ViewComponent

June 27th, 2008

A while ago I added this issue, which is basically a spec for a viewcomponent I urged for, in the hope that someone would face the challenge and, well, just do it. My first thought was that creating something like that would be a bit complex. Here’s the issue description:

The idea is to be able to combine and allow browsers to cache a set of files:

#blockcomponent(JSCombine)
$combiner.add("$siteroot/content/jquery-1.2.3.js")
$combiner.add("$siteroot/content/jquery-someplug.js")
$combiner.add("$siteroot/content/myjs.js")
#end

Which should output something like

54720083aae131_

The hascode should be a composition of the lastmodified of each file's lastmodified attribute, so if a file is changed, the browser will request them again.

There should also be a CssCombine.

The goal is pretty simple: one request and all js is downloaded, hopefully cached, and if some file change a new hash will force the browsers to update their copy.

Today I decided to go on and implement that, was prepared to spend the whole day, but dont know how, was done in less than an hour.

Here the idea:

  1. Collect a list of the files
  2. Calculate a hash based on the last modified date of each file
  3. Create an in-memory aggregation of all files
  4. Register this content within MonoRail static resource service
  5. Output a script tag pointing to the newly created MR resource

And here’s the code that does exactly that:

[ViewComponentDetails("CombineJS")]
public class CombineJSViewComponent : ViewComponent
{
    public override void Render()
    {
        var combiner = new CombinerConfig(AppDomain.CurrentDomain.BaseDirectory);
        PropertyBag["combiner"] = combiner;

        // Evaluate the component body, without output 
        RenderBody(new StringWriter());

        string key = (string) ComponentParams["key"];
        long hash = CalculateChangeSetHash(combiner.Files);

        IStaticResourceRegistry resourceRegistry = EngineContext.Services.StaticResourceRegistry;

        if (!resourceRegistry.Exists(key, null, hash.ToString()))
        {
            var staticContentResource = 
                new StaticContentResource(CombineJSContent(combiner.Files));

            resourceRegistry.RegisterCustomResource(
                key, null, hash.ToString(), staticContentResource, 
                    "application/x-javascript", DateTime.Now);
        }

        string fullName = "/MonoRail/Files/CombinedJS?name=" + key + "&version=" + hash;
        RenderText("<script type=\"text/javascript\" src=\"" + fullName + "\"></script>");
    }

    private string CombineJSContent(IList<string> files)
    {
        StringBuilder sb = new StringBuilder(1024 * files.Count);

        foreach(var file in files)
        {
            using(StreamReader reader = File.OpenText(file))
            {
                sb.Append(reader.ReadToEnd());
            }
            sb.AppendLine();
        }

        return sb.ToString();
    }

    private static long CalculateChangeSetHash(IList<string> files)
    {
        long hash = 0;

        foreach(var file in files)
        {
            if (File.Exists(file))
            {
                DateTime dt = File.GetLastWriteTimeUtc(file);
                hash += dt.Ticks * 37;
            }
        }

        return hash;
    }

    public class CombinerConfig
    {
        private readonly string basePath;
        private readonly List<string> files = new List<string>();

        public CombinerConfig(string basePath)
        {
            this.basePath = basePath;
        }

        public IList<string> Files
        {
            get { return files; }
        }

        public void Add(string jsFile)
        {
            Add(jsFile, null);
        }

        public void Add(string jsFile, IDictionary options)
        {
            files.Add(Path.Combine(basePath, jsFile));
        }
    }
}

And this is how to use it (Nvelocity view engine example)

#blockcomponent(CombineJS with "key=deflayout")
 $combiner.Add("Content/js/jquery/jquery-1.2.6.min.js")
 $combiner.Add("Content/js/jquery/jquery.metadata.js")
 $combiner.Add("Content/js/jquery/jquery.label_over.js")
#end

Note that the key must represent an unique set of files.

Now my plan is to add js minify capabilities, either by selecting the minified file using a naming pattern, or minifying in place.

11 Responses to “CombineJS ViewComponent”

Colin Ramsay Says:

Brilliant, thank you!

Victor Kornov Says:

// Evaluate the component body, without output
RenderBody(new StringWriter());

that’s a little tricky :)

Gauthier Segay Says:

That’s nice, thanks you!

Also I didn’t found IStaticResourceRegistry before, which is also nice to have.

Gilles Bayon Says:

I have done the same but also for CSS file (combine + compress)

I don’t cache the result file as a static resource, no need but but just had an http cache header to the dynamic result file.

Ken Egozi Says:

Cool thing. IStaticResourceRegistry is a great hidden gem.

What is missing (or is it possible here?) is to do it CaptureFor like – subviews and components would be able to add more js files, and a combiner or something on the layout would spit a mixed together JS file for the whole request.

and allow .css files of course …

I’ve had a stub for such a thing a while ago but it got lost somewhere (was on an the old VSS server of a former employer) :(

Mauricio Scheffer Says:

Wow, just last week I wrote a helper and a controller (called it AssetHelper) that does about the same thing! Also planning to add js minifying :)
Didn’t know about IStaticResourceRegistry though, very nice.

John Polling Says:

That’s great, hadn’t seen your original request, but was thinking of writing something similar myself, especially after watching some Yahoo! videos and taking on board their comments regarding minimizing http requests (http://developer.yahoo.com/performance/rules.html#num_http).

theusualsuspect.com » Simple JS and CSS compression for MonoRail Says:

[...] Just came across a post about JavaScript compresion for MonoRail. These guys have written a great patch for Hammett’s CombineJS Component. [...]

Hernan Garcia Says:

@Ken Egozi:

I did implement exactly that but for asp.net mvc. You can get the code from: http://pronghorn.codeplex.com and I’m sure you will be able to adapt it to use it in monorails.

Ole Marius Løset Says:

It doesn’t seem to cache? Firebug Net returns 200 OK. I posted the problem here:

http://stackoverflow.com/questions/4836873/monorails-2-0-combinejs-doesnt-cache

ole marius Says:

Any way I can set another domain for the combine file’s path?

Leave a Reply