Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Fingerprinting externally generated static content (ASP.NET + browserify)

Nodejs browserify is terrific when building modular js apps. If gulp is part of the setup as well, the workflow is further enhanced to manage and resolve dependencies, properly bundle, uglify with sourcemaps, auto-polyfill, jshint, test... This is pretty handy for css as well with pre-processing, auto-prefixing, linting, embedding resources and generating documentation.

TL;DR: with npm/bower you get access to an extensive ecosystem of front-end libraries, making nodejs perfect for building (not necessarily serving!) client-side code. In fact, using it for client-side code is so awesome that npm, bower and grunt/gulp will be supported out of the box in VS 2015. In the meantime, we have set up a gulp task that runs pre-build and writes dist js/css (bundling output).

What's a good way to reference external static content with fingerprinted urls? In the long run we would ideally be able to separate client-side content icompletely so it could be independently built and deployed to CDN without having to build the rest of the application as well.

like image 715
Oleg Avatar asked Apr 19 '15 04:04

Oleg


1 Answers

Problems with CSS

Since CSS references relative urls for images which might change as well, and you will need to compute lot of hash calculation before starting your app which will slow down signature url generation. Turns out that writing code to track last modified date does not work with CSS image urls. So if any of image referred inside css changes, css must be changed as well.

Problems with Individual File Versioning like jquery-1.11.1.js

First it breaks source code versioning, Git or any version control will identify app-script-1.11.js and app-script-1.12.js as two different files, it will be difficult to maintain history.

For jquery, it will work as they are building library and most often you will not change it while including resources on your page, but while building application, we will have many JavaScript files and changing version will require changing each page, however, single include file might do it, but consider lots of css and lots of images.

Cached Last Update date as URL Prefix

So we had to come up with versioning of static content like /cached/lastupdate/, this is nothing but just a url prefix for static asset. lastupdate is nothing but last updated date-time of the requested file. There is also a watcher which refreshes cache key if file is modified during the scope of application.

Well one of the easiest approach is to use a version key in the URL.

Define version in app settings as follow

 <appSettings>
     <add key="CDNHost" value="cdn1111.cloudfront.net"/>
 </appSettings>

 // Route configuration

 // set CDN if you have
 string cdnHost = WebConfigrationManager.AppSettings["CDNHost"];
 if(!string.IsEmpty(cdnHost)){
     CachedRoute.CDNHost = cdnHost;
 }

 // get assembly build information
 string version = typeof(RouteConfig).Assembly.GetName().Version.ToString();

 CachedRoute.CORSOrigins = "*";
 CachedRoute.Register(routes, TimeSpam.FromDays(30), version);

Now on each page, reference your static content as,

 <script src="@CachedRoute.CachedUrl("/scripts/jquery-1.11.1.js")"></script>

While rendering, your page will be rendered as (without CDN)

 <script src="/cached/2015-12-12-10-10-10-1111/scripts/jquery-1.11.1.js"></script>

With CDN as

 <script 
      src="//cdn111.cloudfront.net/cached/2015-12-12-10-10-10-1111/scripts/jquery-1.11.1.js">
 </script>

Putting version in URL path instead of query string makes CDN perform better as query strings can be ignored in CDN configuration (which is usually the default case).

CachedRoute Class from https://github.com/neurospeech/atoms-mvc.net/blob/master/src/Mvc/CachedRoute.cs

public class CachedRoute : HttpTaskAsyncHandler, IRouteHandler
{

    private CachedRoute()
    {
        // only one per app..

    }

    private string Prefix { get; set; }

    public static string Version { get; private set; }

    private TimeSpan MaxAge { get; set; }

    public static string CORSOrigins { get; set; }
    //private static CachedRoute Instance;

    public static void Register(
        RouteCollection routes,
        TimeSpan? maxAge = null,
        string version = null)
    {
        CachedRoute sc = new CachedRoute();
        sc.MaxAge = maxAge == null ? TimeSpan.FromDays(30) : maxAge.Value;

        if (string.IsNullOrWhiteSpace(version))
        {
            version = WebConfigurationManager.AppSettings["Static-Content-Version"];
            if (string.IsNullOrWhiteSpace(version))
            {
                version = Assembly.GetCallingAssembly().GetName().Version.ToString();
            }
        }

        Version = version;

        var route = new Route("cached/{version}/{*name}", sc);
        route.Defaults = new RouteValueDictionary();
        route.Defaults["version"] = "1";
        routes.Add(route);
    }

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

    public static string CDNHost { get; set; }

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

    public class CachedFileInfo
    {

        public string Version { get; set; }

        public string FilePath { get; set; }

        public CachedFileInfo(string path)
        {
            path = HttpContext.Current.Server.MapPath(path);

            FilePath = path;

            //Watch();

            Update(null, null);
        }

        private void Watch()
        {
            System.IO.FileSystemWatcher fs = new FileSystemWatcher(FilePath);
            fs.Changed += Update;
            fs.Deleted += Update;
            fs.NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.Size | NotifyFilters.FileName;
        }

        private void Update(object sender, FileSystemEventArgs e)
        {
            FileInfo f = new FileInfo(FilePath);
            if (f.Exists)
            {
                Version = f.LastWriteTimeUtc.ToString("yyyy-MM-dd-hh-mm-ss-FFFF");
            }
            else
            {
                Version = "null";
            }
        }


    }

    private static ConcurrentDictionary<string, CachedFileInfo> CacheItems = new ConcurrentDictionary<string, CachedFileInfo>();

    public static HtmlString CachedUrl(string p)
    {
        //if (!Enabled)
        //    return new HtmlString(p);
        if (!p.StartsWith("/"))
            throw new InvalidOperationException("Please provide full path starting with /");

        string v = Version;

        var cv = CacheItems.GetOrAdd(p, k => new CachedFileInfo(k));
        v = cv.Version;

        if (CDNHost != null)
        {
            return new HtmlString("//" + CDNHost + "/cached/" + v + p);
        }
        return new HtmlString("/cached/" + v + p);
    }

    public override async Task ProcessRequestAsync(HttpContext context)
    {
        var Response = context.Response;
        Response.Cache.SetCacheability(HttpCacheability.Public);
        Response.Cache.SetMaxAge(MaxAge);
        Response.Cache.SetExpires(DateTime.Now.Add(MaxAge));

        if (CORSOrigins != null)
        {
            Response.Headers.Add("Access-Control-Allow-Origin", CORSOrigins);
        }


        string FilePath = context.Items["FilePath"] as string;

        var file = new FileInfo(context.Server.MapPath("/" + FilePath));
        if (!file.Exists)
        {
            throw new FileNotFoundException(file.FullName);
        }

        Response.ContentType = MimeMapping.GetMimeMapping(file.FullName);

        using (var fs = file.OpenRead())
        {
            await fs.CopyToAsync(Response.OutputStream);
        }
    }

    IHttpHandler IRouteHandler.GetHttpHandler(RequestContext requestContext)
    {
        //FilePath = requestContext.RouteData.GetRequiredString("name");
        requestContext.HttpContext.Items["FilePath"] = requestContext.RouteData.GetRequiredString("name");
        return (IHttpHandler)this;
    }
}

Using File Modification Time instead of Version

    public static HtmlString CachedUrl(string p)
    {
        if (!p.StartsWith("/"))
            throw new InvalidOperationException("Please provide full path starting with /");
        var ft = (new System.IO.FileInfo(Server.MapPath(p)).LastModified;
        return new HtmlString(cdnPrefix + "/cached/" + ft.Ticks + p);
    }

This retains version based on last modification, but this increases call to System.IO.FileInfo on every request, however you can create another dictionary to cache this information and watch for changes, but it is lot of work.

like image 55
Akash Kava Avatar answered Oct 17 '22 21:10

Akash Kava