Source: lib/net/networking_engine.js

/**
 * @license
 * Copyright 2016 Google Inc.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

goog.provide('shaka.net.NetworkingEngine');
goog.provide('shaka.net.NetworkingEngine.PendingRequest');

goog.require('goog.Uri');
goog.require('goog.asserts');
goog.require('shaka.net.Backoff');
goog.require('shaka.util.AbortableOperation');
goog.require('shaka.util.Error');
goog.require('shaka.util.FakeEvent');
goog.require('shaka.util.FakeEventTarget');
goog.require('shaka.util.IDestroyable');
goog.require('shaka.util.ObjectUtils');
goog.require('shaka.util.OperationManager');


/**
 * @event shaka.net.NetworkingEngine.RetryEvent
 * @description Fired when the networking engine receives a recoverable error
 *   and retries.
 * @property {string} type
 *   'retry'
 * @property {?shaka.util.Error} error
 *   The error that caused the retry. If it was a non-Shaka error, this is set
 *   to null.
 * @exportDoc
 */


/**
 * NetworkingEngine wraps all networking operations.  This accepts plugins that
 * handle the actual request.  A plugin is registered using registerScheme.
 * Each scheme has at most one plugin to handle the request.
 *
 * @param {function(number, number)=} onProgressUpdated Called when a progress
 *   event is triggered. Passed the duration, in milliseconds, that the request
 *   took, and the number of bytes transferred.
 *
 * @struct
 * @constructor
 * @implements {shaka.util.IDestroyable}
 * @extends {shaka.util.FakeEventTarget}
 * @export
 */
shaka.net.NetworkingEngine = function(onProgressUpdated) {
  shaka.util.FakeEventTarget.call(this);

  /** @private {boolean} */
  this.destroyed_ = false;

  /** @private {!shaka.util.OperationManager} */
  this.operationManager_ = new shaka.util.OperationManager();

  /** @private {!Set.<shaka.extern.RequestFilter>} */
  this.requestFilters_ = new Set();

  /** @private {!Set.<shaka.extern.ResponseFilter>} */
  this.responseFilters_ = new Set();

  /** @private {?function(number, number)} */
  this.onProgressUpdated_ = onProgressUpdated || null;
};

goog.inherits(shaka.net.NetworkingEngine, shaka.util.FakeEventTarget);


/**
 * Request types.  Allows a filter to decide which requests to read/alter.
 *
 * @enum {number}
 * @export
 */
shaka.net.NetworkingEngine.RequestType = {
  'MANIFEST': 0,
  'SEGMENT': 1,
  'LICENSE': 2,
  'APP': 3,
  'TIMING': 4,
};


/**
 * Priority level for network scheme plugins.
 * If multiple plugins are provided for the same scheme, only the
 * highest-priority one is used.
 *
 * @enum {number}
 * @export
 */
shaka.net.NetworkingEngine.PluginPriority = {
  'FALLBACK': 1,
  'PREFERRED': 2,
  'APPLICATION': 3,
};


/**
 * @typedef {{
 *   plugin: shaka.extern.SchemePlugin,
 *   priority: number
 * }}
 * @property {shaka.extern.SchemePlugin} plugin
 *   The associated plugin.
 * @property {number} priority
 *   The plugin's priority.
 */
shaka.net.NetworkingEngine.SchemeObject;


/**
 * Contains the scheme plugins.
 *
 * @private {!Object.<string, shaka.net.NetworkingEngine.SchemeObject>}
 */
shaka.net.NetworkingEngine.schemes_ = {};

/**
 * @typedef {{
 *   response: shaka.extern.Response,
 *   gotProgress: boolean
 * }}
 *
 * @description
 * Defines a response wrapper object, including the response object and whether
 * progress event is fired by the scheme plugin.
 *
 * @property {shaka.extern.Response} response
 * @property {boolean} gotProgress
 * @private
 */
shaka.net.NetworkingEngine.ResponseAndGotProgress;


/**
 * Registers a scheme plugin.  This plugin will handle all requests with the
 * given scheme.  If a plugin with the same scheme already exists, it is
 * replaced, unless the existing plugin is of higher priority.
 * If no priority is provided, this defaults to the highest priority of
 * APPLICATION.
 *
 * @param {string} scheme
 * @param {shaka.extern.SchemePlugin} plugin
 * @param {number=} priority
 * @export
 */
shaka.net.NetworkingEngine.registerScheme =
    function(scheme, plugin, priority) {
  goog.asserts.assert(priority == undefined || priority > 0,
      'explicit priority must be > 0');
  priority =
      priority || shaka.net.NetworkingEngine.PluginPriority.APPLICATION;
  let existing = shaka.net.NetworkingEngine.schemes_[scheme];
  if (!existing || priority >= existing.priority) {
    shaka.net.NetworkingEngine.schemes_[scheme] = {
      priority: priority,
      plugin: plugin,
    };
  }
};


/**
 * Removes a scheme plugin.
 *
 * @param {string} scheme
 * @export
 */
shaka.net.NetworkingEngine.unregisterScheme = function(scheme) {
  delete shaka.net.NetworkingEngine.schemes_[scheme];
};


/**
 * Registers a new request filter.  All filters are applied in the order they
 * are registered.
 *
 * @param {shaka.extern.RequestFilter} filter
 * @export
 */
shaka.net.NetworkingEngine.prototype.registerRequestFilter = function(filter) {
  this.requestFilters_.add(filter);
};


/**
 * Removes a request filter.
 *
 * @param {shaka.extern.RequestFilter} filter
 * @export
 */
shaka.net.NetworkingEngine.prototype.unregisterRequestFilter =
    function(filter) {
  this.requestFilters_.delete(filter);
};


/**
 * Clears all request filters.
 *
 * @export
 */
shaka.net.NetworkingEngine.prototype.clearAllRequestFilters = function() {
  this.requestFilters_.clear();
};


/**
 * Registers a new response filter.  All filters are applied in the order they
 * are registered.
 *
 * @param {shaka.extern.ResponseFilter} filter
 * @export
 */
shaka.net.NetworkingEngine.prototype.registerResponseFilter = function(filter) {
  this.responseFilters_.add(filter);
};


/**
 * Removes a response filter.
 *
 * @param {shaka.extern.ResponseFilter} filter
 * @export
 */
shaka.net.NetworkingEngine.prototype.unregisterResponseFilter =
    function(filter) {
  this.responseFilters_.delete(filter);
};


/**
 * Clears all response filters.
 *
 * @export
 */
shaka.net.NetworkingEngine.prototype.clearAllResponseFilters = function() {
  this.responseFilters_.clear();
};


/**
 * Gets a copy of the default retry parameters.
 *
 * @return {shaka.extern.RetryParameters}
 *
 * NOTE: The implementation moved to shaka.net.Backoff to avoid a circular
 * dependency between the two classes.
 *
 * @export
 */
shaka.net.NetworkingEngine.defaultRetryParameters = function() {
  return shaka.net.Backoff.defaultRetryParameters();
};


/**
 * Makes a simple network request for the given URIs.
 *
 * @param {!Array.<string>} uris
 * @param {shaka.extern.RetryParameters} retryParams
 * @return {shaka.extern.Request}
   * @export
 */
shaka.net.NetworkingEngine.makeRequest = function(uris, retryParams) {
  return {
    uris: uris,
    method: 'GET',
    body: null,
    headers: {},
    allowCrossSiteCredentials: false,
    retryParameters: retryParams,
    licenseRequestType: null,
    sessionId: null,
  };
};


/**
 * @override
 * @export
 */
shaka.net.NetworkingEngine.prototype.destroy = function() {
  this.destroyed_ = true;
  this.requestFilters_.clear();
  this.responseFilters_.clear();
  return this.operationManager_.destroy();
};


/**
 * Makes a network request and returns the resulting data.
 *
 * @param {shaka.net.NetworkingEngine.RequestType} type
 * @param {shaka.extern.Request} request
 * @return {!shaka.net.NetworkingEngine.PendingRequest}
 * @export
 */
shaka.net.NetworkingEngine.prototype.request = function(type, request) {
  const ObjectUtils = shaka.util.ObjectUtils;
  const numBytesRemainingObj =
      new shaka.net.NetworkingEngine.NumBytesRemainingClass();

  // Reject all requests made after destroy is called.
  if (this.destroyed_) {
    const p = Promise.reject(new shaka.util.Error(
        shaka.util.Error.Severity.CRITICAL,
        shaka.util.Error.Category.PLAYER,
        shaka.util.Error.Code.OPERATION_ABORTED));
    // Silence uncaught rejection errors, which may otherwise occur any place
    // we don't explicitly handle aborted operations.
    p.catch(() => {});
    return new shaka.net.NetworkingEngine.PendingRequest(
        p, () => Promise.resolve(), numBytesRemainingObj);
  }

  goog.asserts.assert(request.uris && request.uris.length,
                      'Request without URIs!');

  // If a request comes from outside the library, some parameters may be left
  // undefined.  To make it easier for application developers, we will fill them
  // in with defaults if necessary.
  //
  // We clone retryParameters and uris so that if a filter modifies the request,
  // it doesn't contaminate future requests.
  request.method = request.method || 'GET';
  request.headers = request.headers || {};
  request.retryParameters = request.retryParameters ?
      ObjectUtils.cloneObject(request.retryParameters) :
      shaka.net.NetworkingEngine.defaultRetryParameters();
  request.uris = ObjectUtils.cloneObject(request.uris);

  // Apply the registered filters to the request.
  let requestFilterOperation = this.filterRequest_(type, request);
  let requestOperation = requestFilterOperation.chain(
      () => this.makeRequestWithRetry_(type, request, numBytesRemainingObj));
  let responseFilterOperation = requestOperation.chain(
      (responseAndGotProgress) =>
          this.filterResponse_(type, responseAndGotProgress));

  // Keep track of time spent in filters.
  let requestFilterStartTime = Date.now();
  let requestFilterMs = 0;
  requestFilterOperation.promise.then(() => {
    requestFilterMs = Date.now() - requestFilterStartTime;
  }, () => {});  // Silence errors in this fork of the Promise chain.

  let responseFilterStartTime = 0;
  requestOperation.promise.then(() => {
    responseFilterStartTime = Date.now();
  }, () => {});  // Silence errors in this fork of the Promise chain.

  const operation = responseFilterOperation.chain((responseAndGotProgress) => {
    const responseFilterMs = Date.now() - responseFilterStartTime;
    const response = responseAndGotProgress.response;
    response.timeMs += requestFilterMs;
    response.timeMs += responseFilterMs;
    if (!responseAndGotProgress.gotProgress &&
        this.onProgressUpdated_ &&
        !response.fromCache &&
        type == shaka.net.NetworkingEngine.RequestType.SEGMENT) {
      this.onProgressUpdated_(response.timeMs, response.data.byteLength);
    }
    return response;
  }, (e) => {
    // Any error thrown from elsewhere should be recategorized as CRITICAL here.
    // This is because by the time it gets here, we've exhausted retries.
    if (e) {
      goog.asserts.assert(e instanceof shaka.util.Error, 'Wrong error type');
      e.severity = shaka.util.Error.Severity.CRITICAL;
    }

    throw e;
  });

  // Return the pending request, which carries the response operation, and the
  // number of bytes remaining to be downloaded, updated by the progress events.
  // Add the operation to the manager for later cleanup.
  const pendingRequest =
      new shaka.net.NetworkingEngine.PendingRequest(operation.promise,
      operation.onAbort_, numBytesRemainingObj);
  this.operationManager_.manage(pendingRequest);
  return pendingRequest;
};


/**
 * @param {shaka.net.NetworkingEngine.RequestType} type
 * @param {shaka.extern.Request} request
 * @return {!shaka.extern.IAbortableOperation.<undefined>}
 * @private
 */
shaka.net.NetworkingEngine.prototype.filterRequest_ = function(type, request) {
  let filterOperation = shaka.util.AbortableOperation.completed(undefined);

  for (const requestFilter of this.requestFilters_) {
    // Request filters are run sequentially.
    filterOperation =
        filterOperation.chain(() => requestFilter(type, request));
  }

  // Catch any errors thrown by request filters, and substitute
  // them with a Shaka-native error.
  return filterOperation.chain(undefined, (e) => {
    if (e && e.code == shaka.util.Error.Code.OPERATION_ABORTED) {
      // Don't change anything if the operation was aborted.
      throw e;
    }

    throw new shaka.util.Error(
        shaka.util.Error.Severity.CRITICAL,
        shaka.util.Error.Category.NETWORK,
        shaka.util.Error.Code.REQUEST_FILTER_ERROR, e);
  });
};


/**
 * @param {shaka.net.NetworkingEngine.RequestType} type
 * @param {shaka.extern.Request} request
 * @param {shaka.net.NetworkingEngine.NumBytesRemainingClass}
 *            numBytesRemainingObj
 * @return {!shaka.extern.IAbortableOperation.<
 *            shaka.net.NetworkingEngine.ResponseAndGotProgress>}
 * @private
 */
shaka.net.NetworkingEngine.prototype.makeRequestWithRetry_ =
    function(type, request, numBytesRemainingObj) {
  let backoff = new shaka.net.Backoff(
      request.retryParameters, /* autoReset */ false);
  let index = 0;
  return this.send_(type, request, backoff, index, /* lastError */ null,
                    numBytesRemainingObj);
};


/**
 * Sends the given request to the correct plugin and retry using Backoff.
 *
 * @param {shaka.net.NetworkingEngine.RequestType} type
 * @param {shaka.extern.Request} request
 * @param {!shaka.net.Backoff} backoff
 * @param {number} index
 * @param {?shaka.util.Error} lastError
 * @param {shaka.net.NetworkingEngine.NumBytesRemainingClass}
 *     numBytesRemainingObj
 * @return {!shaka.extern.IAbortableOperation.<
 *               shaka.net.NetworkingEngine.ResponseAndGotProgress>}
 * @private
 */
shaka.net.NetworkingEngine.prototype.send_ = function(
    type, request, backoff, index, lastError, numBytesRemainingObj) {
  let uri = new goog.Uri(request.uris[index]);
  let scheme = uri.getScheme();
  // Whether it got a progress event.
  let gotProgress = false;
  if (!scheme) {
    // If there is no scheme, infer one from the location.
    scheme = shaka.net.NetworkingEngine.getLocationProtocol_();
    goog.asserts.assert(scheme[scheme.length - 1] == ':',
                        'location.protocol expected to end with a colon!');
    // Drop the colon.
    scheme = scheme.slice(0, -1);

    // Override the original URI to make the scheme explicit.
    uri.setScheme(scheme);
    request.uris[index] = uri.toString();
  }

  // Schemes are meant to be case-insensitive.
  // See https://github.com/google/shaka-player/issues/2173
  // and https://tools.ietf.org/html/rfc3986#section-3.1
  scheme = scheme.toLowerCase();

  let object = shaka.net.NetworkingEngine.schemes_[scheme];
  let plugin = object ? object.plugin : null;
  if (!plugin) {
    return shaka.util.AbortableOperation.failed(
        new shaka.util.Error(
            shaka.util.Error.Severity.CRITICAL,
            shaka.util.Error.Category.NETWORK,
            shaka.util.Error.Code.UNSUPPORTED_SCHEME,
            uri));
  }


  // Every attempt must have an associated backoff.attempt() call so that the
  // accounting is correct.
  let backoffOperation =
      shaka.util.AbortableOperation.notAbortable(backoff.attempt());

  let startTimeMs;
  let sendOperation = backoffOperation.chain(() => {
    if (this.destroyed_) {
      return shaka.util.AbortableOperation.aborted();
    }

    startTimeMs = Date.now();
    const segment = shaka.net.NetworkingEngine.RequestType.SEGMENT;

    return plugin(request.uris[index],
         request,
         type,
         // The following function is passed to plugin.
         (time, bytes, numBytesRemaining) => {
           if (this.onProgressUpdated_ && type == segment) {
             this.onProgressUpdated_(time, bytes);
             gotProgress = true;
             numBytesRemainingObj.setBytes(numBytesRemaining);
           }
        });
  }).chain((response) => {
    if (response.timeMs == undefined) {
      response.timeMs = Date.now() - startTimeMs;
    }
    let responseAndGotProgress = {
      response: response,
      gotProgress: gotProgress,
    };

    return responseAndGotProgress;
  }, (error) => {
    if (error && error.code == shaka.util.Error.Code.OPERATION_ABORTED) {
      // Don't change anything if the operation was aborted.
      throw error;
    }

    if (this.destroyed_) {
      return shaka.util.AbortableOperation.aborted();
    }

    if (error && error.severity == shaka.util.Error.Severity.RECOVERABLE) {
      // Don't pass in a non-shaka error, even if one is somehow thrown;
      // instead, call the listener with a null error.
      const errorOrNull = error instanceof shaka.util.Error ? error : null;
      let event = new shaka.util.FakeEvent('retry', {'error': errorOrNull});
      this.dispatchEvent(event);

      // Move to the next URI.
      index = (index + 1) % request.uris.length;
      const shakaError = /** @type {shaka.util.Error} */(error);
      return this.send_(type, request, backoff, index, shakaError,
                        numBytesRemainingObj);
    }

    // The error was not recoverable, so do not try again.
    // Rethrow the error so the Promise chain stays rejected.
    throw error || lastError;
  });

  return sendOperation;
};


/**
 * @param {shaka.net.NetworkingEngine.RequestType} type
 * @param {shaka.net.NetworkingEngine.ResponseAndGotProgress}
 *        responseAndGotProgress
 * @return {!shaka.extern.IAbortableOperation.<
 *               shaka.net.NetworkingEngine.ResponseAndGotProgress>}
 * @private
 */
shaka.net.NetworkingEngine.prototype.filterResponse_ =
    function(type, responseAndGotProgress) {
  let filterOperation = shaka.util.AbortableOperation.completed(undefined);
  for (const responseFilter of this.responseFilters_) {
    // Response filters are run sequentially.
    filterOperation = filterOperation.chain(
      responseFilter.bind(null, type, responseAndGotProgress.response));
  }
  // If successful, return the filtered response with whether it got progress.
  return filterOperation.chain(() => {
    return responseAndGotProgress;
  }, (e) => {
    // Catch any errors thrown by request filters, and substitute
    // them with a Shaka-native error.

    if (e && e.code == shaka.util.Error.Code.OPERATION_ABORTED) {
      // Don't change anything if the operation was aborted.
      throw e;
    }

    // The error is assumed to be critical if the original wasn't a Shaka error.
    let severity = shaka.util.Error.Severity.CRITICAL;
    if (e instanceof shaka.util.Error) {
      severity = e.severity;
    }

    throw new shaka.util.Error(
        severity,
        shaka.util.Error.Category.NETWORK,
        shaka.util.Error.Code.RESPONSE_FILTER_ERROR, e);
  });
};


/**
 * This is here only for testability.  We can't mock location in our tests on
 * all browsers, so instead we mock this.
 *
 * @return {string} The value of location.protocol.
 * @private
 */
shaka.net.NetworkingEngine.getLocationProtocol_ = function() {
  return location.protocol;
};

/**
 * A wrapper class for the number of bytes remaining to be downloaded for the
 * request.
 * Instead of using PendingRequest directly, this class is needed to be sent to
 * plugin as a parameter, and a Promise is returned, before PendingRequest is
 * created.
 *
 * @export
 */
shaka.net.NetworkingEngine.NumBytesRemainingClass = class {
  /**
   * Constructor
   */
  constructor() {
    /** @private {number} */
    this.bytesToLoad_ = 0;
  }

  /**
   * @param {number} bytesToLoad
   */
  setBytes(bytesToLoad) {
    this.bytesToLoad_ = bytesToLoad;
  }

  /**
   * @return {number}
   */
  getBytes() {
    return this.bytesToLoad_;
  }
};

/**
 * A pending network request. This can track the current progress of the
 * download, and allows the request to be aborted if the network is slow.
 *
 * @implements {shaka.extern.IAbortableOperation.<shaka.extern.Response>}
 * @extends {shaka.util.AbortableOperation}
 * @export
 */
shaka.net.NetworkingEngine.PendingRequest =
    class extends shaka.util.AbortableOperation {
  /**
   * @param {!Promise} promise
   *   A Promise which represents the underlying operation.  It is resolved when
   *   the operation is complete, and rejected if the operation fails or is
   *   aborted.  Aborted operations should be rejected with a shaka.util.Error
   *   object using the error code OPERATION_ABORTED.
   * @param {function():!Promise} onAbort
   *   Will be called by this object to abort the underlying operation.
   *   This is not cancelation, and will not necessarily result in any work
   *   being undone.  abort() should return a Promise which is resolved when the
   *   underlying operation has been aborted.  The returned Promise should never
   *   be rejected.
   * @param {shaka.net.NetworkingEngine.NumBytesRemainingClass}
   *   numBytesRemainingObj
   */
  constructor(promise, onAbort, numBytesRemainingObj) {
    super(promise, onAbort);

    /** @private {shaka.net.NetworkingEngine.NumBytesRemainingClass} */
    this.bytesRemaining_ = numBytesRemainingObj;
  }

 /**
  * @return {number}
  */
  getBytesRemaining() {
    return this.bytesRemaining_.getBytes();
  }
};