Thursday, May 18, 2017

Versioned JavaScript Importing

When a big JavaScript file is going to be included in a page, it should be cached at the client browser and also allow it to be updated immediately when there are changes to it.
ETag could be used to control the client side browser cache behaviors. If the JavaScript file is hosted in a  RestEasy supported web application, the following code may be used: 
public Response getJavaScripts(final Request request) throws ApiException {

    // Create cache control header
    final CacheControl cacheControl = new CacheControl();
    // Set max age to one day
    cacheControl.setMaxAge(86400);
    // Calculate the ETag on last modified date of user resource
    String eTag = this.applicationConfiguration.getPropertyValue(MY_JS_ETAG).orElse("v0.1");
    final EntityTag etag = new EntityTag(eTag);
    // Verify if it matched with etag available in http request
    final Response.ResponseBuilder responseBulider = request.evaluatePreconditions(etag);
    if (responseBulider == null) {
        // If rb is null then either it is first time request; or resource is modified,
        // get the updated representation and return with Etag attached to it
        final Object entity = this.myService.getJs();
        responseBulider = Response.ok(entity);
    }
    final Response response = responseBulider.cacheControl(cacheControl).tag(etag).build();
    return response;
}
However when it is tested in a browser, the latest changes may not be seen by page users even the Etag has been updated at the server side.
The issue is cause by the implementation of the browsers. Some browsers will not go back to server to check if there is a tag change or not. In order to have a reliable solution, more work has to be done.  One solution is using  an approach with two steps: downloading a short dynamic JavaScript first as bootstrap code, then a link in the bootstrap JavaScript downloads the more static and big JavaScript file.
A sample of the  bootstrap JavaScript:
/* dynamic.js  or use Ajax */
(function() {
    var c = document.createElement('script');
    c.type = 'text/javascript';
    c.async = true;
    c.src = ('https:' == document.location.protocol ? 'https://' : 'http://')
        + '<hostname:port>/myapp/js/v1/static.js?v=<version_number>';
    var s = document.getElementsByTagName('script')[0];
    s.parentNode.insertBefore(c, s);
})();

A link is going to be inserted to download this bootstrap JavaScript, there are two ways to keep this JavaScript from being cached at the client browser:
Attched a parameter (sessionId here) with a value it will be different when page loads:
<pre><script type="text/javascript" src="https://hostname:port/myapp/js/v1/dynamic.js?sessionId=<sessionId>">
</script></pre>

Or 
<script type="text/javascript" src="https://hostname:port/js/v1/dynamic.js"></script>
With following headers in response:
  • Expires: Sun, 19 Nov 1978 05:00:00 GMT
  • Last-Modified: Fri, 19 May 2017 08:01:46 GMT (the actual modification date)
  • Cache-Control: store, no-cache, must-revalidate, post-check=0, pre-check=0
Again, it RestEasy is used, here is the code to do this:
@Override
public Response getDynamicJavaScripts(final HttpServletRequest httpServletRequest ) throws ApiException {
    final String staticJs = getStaticJs(httpServletRequest);
    final Response.ResponseBuilder responseBulider = Response.ok().entity(staticJs);
    final Response response = getNoCacheResponse(responseBulider);
    return response;
}
private Response getNoCacheResponse(final Response.ResponseBuilder responseBulider) {
    final CacheControl cacheControl = new CacheControl();
    cacheControl.setMaxAge(0);
    cacheControl.setSMaxAge(0);
    cacheControl.setNoCache(true);
    cacheControl.setNoStore(true);
    cacheControl.setMustRevalidate(true);
    final Date expires = this.getUnixEpochStartDate();
    final Response response = responseBulider.cacheControl(cacheControl).expires(expires).header("post-check", 0).header("pre-check", 0).lastModified(new Date()).build();
    return response;
}
@Cacheable
private String getStaticJs(final HttpServletRequest request) {
    final String jsTemplate = "(function() { var c = document.createElement('script'); c.type = 'text/javascript'; c.async = true;" +
        "c.src = ('https:' == document.location.protocol ? 'https://' : 'http://') + '%s?v=%s';" +
        "var s = document.getElementsByTagName('script')[0]; s.parentNode.insertBefore(c, s); })();";
    final String jsServerUrl = new StringBuilder(request.getServerName()).append(":").append(request.getServerPort())
        .append(request.getContextPath()).append(request.getServletPath()).append(pathInfo).toString();
    final String version = this.applicationConfiguration.getPropertyValue(MY_JS_VERSION).orElse("V0.1");
    final String finalJs = String.format(jsTemplate, jsServerUrl, version);    
    return finalJs;
}
@Cacheable
private Date getUnixEpochStartDate() {
    final SimpleDateFormat isoFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss");
    isoFormat.setTimeZone(TimeZone.getTimeZone("UTC"));
    Date date;
    try {
        date = isoFormat.parse("1970-01-01T00:00:00");
    } catch (ParseException e) {
        date = new Date();
        logger.warn("date passing error.", e);
    }
    return date;
}

Another useful feature from this two steps solution is that the dynamic JavaScript bootstrap could be used as a switch to turn on the functions provided by the static JavaScript file. If an empty page returned, the client page does not need to do any changes to turn off the function the static JavaScripts file provides.
It also could be used as a throttling mechanism. If bootstrap returns the static JavaScript file link in 50% of the responses, the load to the server that hosts the static JavaScript file will reduce 50%.
Usually a flag is required to notify the server side that the functions at client side has been turned off when data has been posted back to backend services.
The bootstrap JavaScript:
/* myjs_status_code could be used to indicate that the function switch status. For pages use basic JavaScript functions. */
var _myjs_status_code = '204';
/* sets the myjs_status_code hidden field after the page loaded if it exists. */
(function() {
    function setSessionId() {
            var element = document.getElementById('myjs_status_code');
            if (typeof (element) != 'undefined' && element != null) {
                element.value = _myjs_session_id;
            }
    }
    window.addEventListener('load', setSessionId, false);
    setSessionId(); /* in case the load event already passed. */
})();
/* the section below will not be returned if the static function should be turned off. */
(function() {
    var c = document.createElement('script');
    c.type = 'text/javascript';
    c.async = true;
    c.src = ('https:' == document.location.protocol ? 'https://' : 'http://')
        + '<hostname:port>/static.js?v=<version_number>';
    var s = document.getElementsByTagName('script')[0];
    s.parentNode.insertBefore(c, s);
})();



http://localhost:8080/app/api/RestEndpoint/getMethod/extraPathInfo
request.getProtocol(): HTTP/1.1
 contextPath: /app
servletPath: /api
pathInfo: /RestEndpoint/getMethod/extraPathInfo
requestUri = request.getContextPath() + request.getServletPath() + pathInfo







see more info here:
Drupal 6 does this (which works in every browser known by me):
  • Expires: Sun, 19 Nov 1978 05:00:00 GMT
  • Last-Modified: Fri, 12 Jun 2009 08:01:46 GMT (the actual modification date)
  • Cache-Control: store, no-cache, must-revalidate, post-check=0, pre-check=0
No pragma header in this instance. I'm not sure why your example doesn't work, it might be the negative timestamps, this works on ~250.000 Drupal sites :)
        
To force the browser to recheck your page with the server, the simplest solution is to add max-age=, must-revalidate=trueto the Cache-Control header. This will let your browser keep a copy of the page in its cache, but compare it with the server's version by sending the contents of Last-Modified or ETag, as you wanted.

No comments:

Post a Comment