Source: cacheStrategies.js

/**
 * Copyright (c) 2017, Oracle and/or its affiliates.
 * All rights reserved.
 */

define(['./persistenceManager', './persistenceUtils', './impl/logger'], function (persistenceManager, persistenceUtils, logger) {
  'use strict';
  
  /**
   * @class cacheStrategies
   * @classdesc Contains out of the box Cache Strategies which includes the HTTP Cache Header
   * strategy. The cache strategy is applied to GET/HEAD requests by the defaultResponseProxy right after the
   * fetch strategy is applied and the response is obtained. The cache strategy should be
   * a function which takes the request and response as parameter and returns a Promise
   * which resolves to the response. In the case the fetch strategy returns a server response.
   * the cache strategy should determine whether the request/response should be cached
   * and if so, persist the request/response by calling persistenceManager.getCache().put(). The
   * cache strategy should also handle cached responses returned by the fetch strategy.
   * @export
   * @hideconstructor
   */
  
  /**
   * Returns the HTTP Cache Header strategy
   * @method
   * @name getHttpCacheHeaderStrategy
   * @memberof cacheStrategies
   * @static
   * @return {Function} Returns the HTTP Cache Header strategy which conforms
   * to the Cache Strategy API.
   */
  function getHttpCacheHeaderStrategy() {
    return function (request, response) {
      // process the headers in order. Order matters, you want to
      // do things like re-validation before you bother persist to
      // the cache. Also, max-age takes precedence over Expires.
      _handleExpires(request, response);
      _handleMaxAge(request, response);
      return _handleIfCondMatch(request, response).then(function (response) {
        return _handleMustRevalidate(request, response);
      }).then(function (response) {
        return _handleNoCache(request, response);
      }).then(function (response) {
        return _handleNoStore(request, response);
      });
    };
  };
  
  function _handleExpires(request, response) {
    // expires header - Contains a UTC Datetime value
    // which can be used to directly populate x-oracle-jscpt-cache-expiration-date
    // if x-oracle-jscpt-cache-expiration-date is already populated then that wins
    var expiresDate = response.headers.get('Expires');
    var cacheExpirationDate = response.headers.get('x-oracle-jscpt-cache-expiration-date');
    
    if (expiresDate &&
      persistenceUtils.isCachedResponse(response) &&
      (!cacheExpirationDate || cacheExpirationDate.length == 0)) {
      response.headers.set('x-oracle-jscpt-cache-expiration-date', expiresDate);
      logger.log("Offline Persistence Toolkit cacheStrategies: Set x-oracle-jscpt-cache-expiration-date header based on HTTP Expires header");
    }
    return;
  };
  
  function _handleMaxAge(request, response) {
    // max-age cache header - Use it to calculate and populate cacheExpirationDate.
    // Takes precendence over Expires so should be called after processing Expires.
    // Also, unlike  Expires it's relative to the Date of the request
    var cacheControlMaxAge = _getCacheControlDirective(response.headers, 'max-age');

    if (cacheControlMaxAge != null) {
      if (persistenceUtils.isCachedResponse(response)) {
        var requestDate = request.headers.get('Date');
        if (!requestDate) {
          requestDate = (new Date()).toUTCString();
        }
        var requestTime = (new Date(requestDate)).getTime();
        var expirationTime = requestTime + 1000 * cacheControlMaxAge;
        var expirationDate = new Date(expirationTime);
        response.headers.set('x-oracle-jscpt-cache-expiration-date', expirationDate.toUTCString());
        logger.log("Offline Persistence Toolkit cacheStrategies: Set x-oracle-jscpt-cache-expiration-date header based on HTTP max-age header");
      }
    }
    return;
  };
  
  function _handleIfCondMatch(request, response) {
    // If-Match or If-None-Match headers
    var ifMatch = request.headers.get('If-Match');
    var ifNoneMatch = request.headers.get('If-None-Match');
    
    if (ifMatch || ifNoneMatch) {
      if (!persistenceManager.isOnline()) {
        var etag = response.headers.get('ETag');

        if (ifMatch &&
          etag.indexOf(ifMatch) < 0) {
          // If we are offline then we MUST return 412 if no match as per
          // spec
          return persistenceUtils.responseToJSON(response).then(function (responseData) {
            responseData.status = 412;
            responseData.statusText = 'If-Match failed due to no matching ETag while offline';
            logger.log("Offline Persistence Toolkit cacheStrategies: Returning Response status 412 based on ETag and HTTP If-Match header");
            return persistenceUtils.responseFromJSON(responseData);
          });
        } else if (ifNoneMatch &&
          etag.indexOf(ifNoneMatch) >= 0) {
          // If we are offline then we MUST return 412 if match as per
          // spec
          return persistenceUtils.responseToJSON(response).then(function (responseData) {
            responseData.status = 412;
            responseData.statusText = 'If-None-Match failed due to matching ETag while offline';
            logger.log("Offline Persistence Toolkit cacheStrategies: Returning Response status 412 based on ETag and HTTP If-None-Match header");
            return persistenceUtils.responseFromJSON(responseData);
          });
        }
      } else {
        // If we are online then we have to revalidate
        return _handleRevalidate(request, response, false);
      }
    }
    return Promise.resolve(response);
  };
  
  function _handleMustRevalidate(request, response) {
    // must-revalidate MUST revalidate stale info. If we're offline or
    // server cannot be reached then client MUST return a 504 error:
    // https://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html
    var mustRevalidate = _getCacheControlDirective(response.headers, 'must-revalidate');
    if (mustRevalidate) {
      var cacheExpirationDate = response.headers.get('x-oracle-jscpt-cache-expiration-date');
      if (cacheExpirationDate) {
        var cacheExpirationTime = (new Date(cacheExpirationDate)).getTime();
        var currentTime = (new Date()).getTime();

        if (currentTime > cacheExpirationTime) {
          logger.log("Offline Persistence Toolkit cacheStrategies: Handling revalidation HTTP must-revalidate header");
          return _handleRevalidate(request, response, true);
        }
      }
    }
    return Promise.resolve(response);
  };
  
  function _handleNoCache(request, response) {
    if (!_isNoCache(request, response)) {
      return Promise.resolve(response);
    } else {
      // no-cache must always revalidate
      return _handleRevalidate(request, response);
    }
  };
  
  function _isNoCache(request, response) {
    if (_getCacheControlDirective(response.headers, 'no-cache')) {
      logger.log("Offline Persistence Toolkit cacheStrategies: Has HTTP no-cache header");
      return true;
    }
    // pragma: no-cache in the request header has the same effect
    var pragmaNoCache = request.headers.get('Pragma');
    var isPragmeNoCache = pragmaNoCache && (pragmaNoCache.trim() === 'no-cache');
    
    if (isPragmeNoCache) {
      logger.log("Offline Persistence Toolkit cacheStrategies: Has HTTP Pragma no-cache header");
    }
    return isPragmeNoCache;
  };
  
  function _handleNoStore(request, response) {
    // no-store is the only one which can prevent storage in the cache
    // so have it control the cacheResponse call
    var noStore = _getCacheControlDirective(response.headers, 'no-store');

    if (noStore != null) {
      // remove the header if we're not going to store in the cache
      if (persistenceUtils.isCachedResponse(response)) {
        response.headers.delete('x-oracle-jscpt-cache-expiration-date');
      }
      logger.log("Offline Persistence Toolkit cacheStrategies: Has HTTP no-store header");
      return Promise.resolve(response);
    } else {
      return _cacheResponse(request, response);
    }
  };
  
  function _getCacheControlDirective(headers, directive) {
    // Retrieve the Cache-Control headers and parse
    var cacheControl = headers.get('Cache-Control');

    if (cacheControl) {
      var cacheControlValues = cacheControl.split(',');

      var i;
      var cacheControlVal;
      var splitVal;
      for (i = 0; i < cacheControlValues.length; i++) {
        cacheControlVal = cacheControlValues[i].trim();
        // we only care about cache-control values which
        // start with the directive
        if (cacheControlVal.indexOf(directive) === 0) {
          splitVal = cacheControlVal.split('=');
          return (splitVal.length > 1) ?
            splitVal[1].trim() :
            true;
        }
      }
    }

    return null;
  };

  function _handleRevalidate(request, response, mustRevalidate) {
    // If we are offline, we can't revalidate so just return the cached response
    // unless mustRevalidate is true, in which case reject with error.
    // If we are online then if the response is a cached Response, we need to
    // revalidate. If the revalidation returns 304 then we can just return the
    // cached version
    // _handleRevalidate can be called multiple times due to different cache
    // headers requiring it however a server call will, if needed, only be made
    // once because after that we will have a server response and any subsequent
    // _handleRevalidate calls will just resolve.
    if (persistenceUtils.isCachedResponse(response)) {
      if (!persistenceManager.isOnline()) {
        // If we must revalidate then we MUST return a 504 when offline
        // https://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.9.4
        // must-revalidate is the only case under which we CANNOT return a stale
        // response. If must-revalidate is not set then according to spec it's ok
        // to return a stale response.
        if (mustRevalidate) {
          return persistenceUtils.responseToJSON(response).then(function (responseData) {
            responseData.status = 504;
            responseData.statusText = 'cache-control: must-revalidate failed due to application being offline';
            logger.log("Offline Persistence Toolkit cacheStrategies: Returning Response status 504 based HTTP revalidation");
            return persistenceUtils.responseFromJSON(responseData);
          });
        } else {
          return Promise.resolve(response);
        }
      } else {
        return persistenceManager.browserFetch(request).then(function (serverResponse) {
          if (serverResponse.status == 304) {
            return response;
          } else {
            // revalidation succeeded so we should remove the old entry from the
            // cache
            return persistenceManager.getCache().delete(request).then(function () {
              logger.log("Offline Persistence Toolkit cacheStrategies: Removing old entry based on HTTP revalidation");
              return serverResponse;
            });
          }
        });
      }
    }
    // If it's not a cached Response then it's already from the server so
    // just resolve the response
    return Promise.resolve(response);
  };

  function _cacheResponse(request, response) {
    // persist the Request/Response in our cache
    if (response != null &&
      !persistenceUtils.isCachedResponse(response) &&
      (request.method == 'GET' ||
      request.method == 'HEAD')) {
      var responseClone = response.clone();
      return persistenceManager.getCache().put(request, response).then(function () {
        logger.log("Offline Persistence Toolkit cacheStrategies: Cached Request/Response");
        return responseClone;
      });
    } else {
      return Promise.resolve(response);
    }
  };

  return {'getHttpCacheHeaderStrategy': getHttpCacheHeaderStrategy};
});