Tuesday, December 29, 2015

.NET: Custom minified script bundles with sourcemaps

I stumbled upon a situation where I had to create minified javascript bundle with a sourcemap for this bundle. There is no default implementation at the moment, however there are classes in Microsoft.Ajax.Utilities namespace which can be used to do it.

Before jumping to custom bundles lets take a look at the way bundles work in general. Bundles live in System.Web.Optimization assembly.

When you use @Script.Render(...) directive it simply asks System.Web.Optimization.AssetManager to render <script> tag with appropriate bundle url in src. Request to bundle urls intercepted and processed by http-module BundleModule which in its own turn registered on application start due to assembly attribute in in System.Web.Optimization:

And here is the module registration itself:

BundleModule hooks http handler BundleHandler to application PostResolveRequestCache event. Now when the bundle is requested BundleHandler will pick up appropriate bundle instance from BundleTable. The rest of the job is done by bundle class itself, to be more specific - it is done by ProcessRequest method of bundle which constructs bundle content and writes it into the response. Bundle first checks for its content in cache and after that either returns cached content or constructs new:

You can see at the screenshot above method GetBundleResponse - this one checks if bundle content is in cache and either returns cached response or generates new one using GenerateBundleResponse method:

this one creates list of files included in bundle, sorts them, feeds those files to the specified content builder and at the end applies transforms to the final content. In case of ScriptBundle there is already one transform registered in bundle - JsMinify and this one is responsible for content minification.

JsMinify actually uses Microsoft.Ajax.Utilities.Minifier class in order to minify the content. The other important thing to remember is that by default minification performed on concatenated content. It means that at first all bundle files concatenated into one and after that all bundle transforms applied to that concatenated content.

In order to generate both minified bundles and sourcemaps for them we need minifier which can do both of those things. Luckily, Microsoft.Ajax.Utilities namespace provides two classes suitable for it: Minifier and V3SourceMap.

What do we need here is to create custom bundle and custom bundle builder for that bundle. But first of all we need to decide where to store .map files. In this case those will go to another bundle.

So first of all we need a map bundle builder which is simply return content we pass to it:

public class MapBundleBuilder : IBundleBuilder
{
 private string bundleContent;

 public MapBundleBuilder(string bundleContent)
 {
  this.bundleContent = bundleContent;
 }

 public string BuildBundleContent(Bundle bundle, 
  BundleContext context,
  IEnumerable<BundleFile> files)
 {
  return this.bundleContent;
 }
}

The next step is to create bundle builder for script bundles. Minification and mapping will happen there:

public class CustomBundleBuilder : IBundleBuilder
{
 private static void AddMapBundle(BundleContext context, 
  string mapVirtualPath,
  string content)
 {
  var bundle = new Bundle(mapVirtualPath);
  bundle.Builder = new MapBundleBuilder(content);
  context.BundleCollection.Add(bundle);
 }

 private static string GenerateErrorResponse(IEnumerable<object> errors)
 {
  StringBuilder stringBuilder = new StringBuilder();
  stringBuilder.Append("/* ");
  stringBuilder.Append("An error occured during minification").Append("\r\n");
  foreach (object obj in errors)
   stringBuilder.Append(obj).Append("\r\n");
  stringBuilder.Append(" */\r\n");
  return stringBuilder.ToString();
 }

 public string BuildBundleContent(Bundle bundle, 
  BundleContext context, 
  IEnumerable<BundleFile> files)
 {
  if (files == null)
   return string.Empty;
  if (context == null)
   throw new ArgumentNullException("context");
  if (bundle == null)
   throw new ArgumentNullException("bundle");

  string concatenationToken = (string) null;

  if (!string.IsNullOrEmpty(bundle.ConcatenationToken))
  {
   concatenationToken = bundle.ConcatenationToken;
  }

  if (concatenationToken == null || context.EnableInstrumentation)
  {
   concatenationToken = ";" + Environment.NewLine;
  }

  var sourceAbsolutePath = VirtualPathUtility.ToAbsolute(context.BundleVirtualPath);
  var mapVirtualPath = context.BundleVirtualPath + "map";
  var mapAbsolutePath = VirtualPathUtility.ToAbsolute(mapVirtualPath);

  var bundleContentBuilder = new StringBuilder();
  var mapBuilder = new StringBuilder();

  var minifier = new Microsoft.Ajax.Utilities.Minifier();

  using (var bundleContentWriter = new StringWriter(bundleContentBuilder))
  {
   using (var mapWriter = new StringWriter(mapBuilder))
   {
    using (var sourceMap = new V3SourceMap(mapWriter))
    {
     sourceMap.StartPackage(sourceAbsolutePath, mapAbsolutePath);

     var settings = new CodeSettings
     {
      EvalTreatment = EvalTreatment.MakeImmediateSafe,
      PreserveImportantComments = false,
      SymbolsMap = sourceMap
     };

     foreach (BundleFile file in files)
     {
      minifier.FileName = 
       VirtualPathUtility.ToAbsolute(file.IncludedVirtualPath);
      string input = file.ApplyTransforms();
      var minifiedFile = minifier.MinifyJavaScript(input, settings);
      bundleContentWriter.Write(minifiedFile);
      bundleContentWriter.Write(concatenationToken);
     }
    }
   }
  }

  if (minifier.ErrorList.Count > 0)
   return GenerateErrorResponse((IEnumerable<object>)minifier.ErrorList);

  var mapFile = mapBuilder.ToString();
  AddMapBundle(context, mapVirtualPath, mapFile);
  bundleContentBuilder.Append(string.Format("\r\n//# sourceMappingURL={0}", mapAbsolutePath));
  var output = bundleContentBuilder.ToString();

  return output;
 }
}

You might have noticed that it looks similar to the default builder: the only major difference is that minification happens for each individual file. And of course there is source map files creation as well. We make bundle content and .map bundle content at the same time and in the end append sourceMappingURL to the bundle content.

Last thing we need is to create is custom bundle with builder which is defined above and no transformations:

public class CustomScriptBundle : Bundle
{
 public CustomScriptBundle(string virtualPath)
  : this(virtualPath, (string)null)
 {
 }

 public CustomScriptBundle(string virtualPath, string cdnPath)
  : base(virtualPath, cdnPath,
  new IBundleTransform[] { }
  )
 {
  this.Builder = new CustomBundleBuilder();
  this.ConcatenationToken = ";" + Environment.NewLine;
 }
}

Now custom script bundle is ready for use in BundleConfig:

BundleTable.EnableOptimizations = true;

bundles.Add(new CustomScriptBundle("~/bundles/test").Include(
 "~/Scripts/Script1.js",
 "~/Scripts/Script2.js"));

A few things to remember:
1. browser will load .map files when you open developer tools;
2. Firefox does not like map files;
3. map files allow you to see not minified bundle content and debug it to certain extend but it's clunky;
4. minified variable names will shown instead of original source variable names since symbols mapping is not supported at the moment.

No comments :

Post a Comment