/**
* @license
* Copyright 2015 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.util.AjaxRequest');
goog.require('goog.Uri');
goog.require('shaka.asserts');
goog.require('shaka.features');
goog.require('shaka.util.Clock');
goog.require('shaka.util.ContentDatabaseReader');
goog.require('shaka.util.IBandwidthEstimator');
goog.require('shaka.util.PublicPromise');
goog.require('shaka.util.StringUtils');
goog.require('shaka.util.TypedBind');
goog.require('shaka.util.Uint8ArrayUtils');
/**
* Creates an asynchronous HTTP request object which manages retries
* automatically.
*
* @param {string} url The URL to request.
* @param {shaka.util.AjaxRequest.Parameters=} opt_parameters
*
* @struct
* @constructor
*/
shaka.util.AjaxRequest = function(url, opt_parameters) {
/**
* The request URL.
* @protected {string}
*/
this.url = url;
/**
* A collection of parameters which an instance of a subclass may wish to
* override.
* @protected {!shaka.util.AjaxRequest.Parameters}
*/
this.parameters = opt_parameters ||
new shaka.util.AjaxRequest.Parameters();
/**
* The number of times the request has been attempted.
* @private {number}
*/
this.attempts_ = 0;
/**
* A timestamp in milliseconds when the request began.
* @private {number}
*/
this.startTime_ = 0;
/**
* The delay, in milliseconds, before the next retry.
* @private {number}
*/
this.retryDelayMs_ = 0;
/**
* The last used delay. This is used in unit tests only.
* @private {number}
*/
this.lastDelayMs_ = 0;
/** @private {XMLHttpRequest} */
this.xhr_ = null;
/**
* Resolved when the request is completed successfully.
* Rejected if it cannot be completed.
* @private {shaka.util.PublicPromise.<!XMLHttpRequest>}
*/
this.promise_ = new shaka.util.PublicPromise();
/** @type {shaka.util.IBandwidthEstimator} */
this.estimator = null;
};
/**
* A collection of parameters which an instance of a subclass may wish to
* override.
*
* @struct
* @constructor
*/
shaka.util.AjaxRequest.Parameters = function() {
/**
* The request body, if desired.
* @type {(ArrayBuffer|?string)}
*/
this.body = null;
/**
* The maximum number of times the request should be attempted.
* @type {number}
*/
this.maxAttempts = 1;
/**
* The delay before the first retry, in milliseconds.
* @type {number}
*/
this.baseRetryDelayMs = 1000;
/**
* The multiplier for successive retry delays.
* @type {number}
*/
this.retryBackoffFactor = 2.0;
/**
* The maximum amount of fuzz to apply to each retry delay.
* For example, 0.5 means "between 50% below and 50% above the retry delay."
* @type {number}
*/
this.retryFuzzFactor = 0.5;
/**
* The request timeout, in milliseconds. Zero means "unlimited".
* @type {number}
*/
this.requestTimeoutMs = 0;
/**
* The HTTP request method, such as 'GET' or 'POST'.
* @type {string}
*/
this.method = 'GET';
/**
* The response type, corresponding to XMLHttpRequest.responseType.
* @type {string}
*/
this.responseType = 'arraybuffer';
/**
* HTTP request headers as key-value pairs.
* @type {!Object.<string, string>}
*/
this.requestHeaders = {};
/**
* Make requests with credentials. This will allow cookies in cross-site
* requests.
* @see http://goo.gl/YBRKPe
* @type {boolean}
*/
this.withCredentials = false;
/**
* Will attempt to read the server's Date header and synchronize the clock.
* @see shaka.util.Clock
* @type {boolean}
*/
this.synchronizeClock = false;
/**
* The content database used if the URI is a data URI. Required for data
* URIs.
*
* @type {shaka.util.ContentDatabaseReader}
*/
this.contentDatabase = null;
};
/** @type {boolean} */
shaka.util.AjaxRequest.enableCacheBusting = true;
/**
* Destroys the AJAX request.
* This happens automatically after the internal promise is resolved or
* rejected.
*
* @private
*/
shaka.util.AjaxRequest.prototype.destroy_ = function() {
this.cleanupRequest_();
this.parameters.body = null;
this.promise_.destroy();
this.promise_ = null;
this.estimator = null;
};
/**
* Remove |xhr_|'s references to bound functions, and set |xhr_| to null.
*
* @private
*/
shaka.util.AjaxRequest.prototype.cleanupRequest_ = function() {
if (this.xhr_) {
this.xhr_.onload = null;
this.xhr_.onreadystatechange = null;
this.xhr_.onerror = null;
this.xhr_.ontimeout = null;
}
this.xhr_ = null;
};
/**
* Sends the request. Called by subclasses.
*
* @return {Promise.<!XMLHttpRequest>}
*/
shaka.util.AjaxRequest.prototype.send = function() {
shaka.asserts.assert(this.xhr_ == null);
if (this.xhr_) {
// The request is already in-progress, so there's nothing to do.
return this.promise_;
}
// We can't request from data or idb URIs, so handle it separately.
if (this.url.lastIndexOf('data:', 0) == 0) {
return this.handleDataUri_();
} else if (this.url.lastIndexOf('idb:', 0) == 0) {
shaka.asserts.assert(shaka.features.Offline);
return this.handleOfflineUri_();
}
this.attempts_++;
this.startTime_ = Date.now();
if (!this.retryDelayMs_) {
// First try. Lock in the retry delay.
this.retryDelayMs_ = this.parameters.baseRetryDelayMs;
}
this.xhr_ = new XMLHttpRequest();
var url = this.url;
if (shaka.util.AjaxRequest.enableCacheBusting) {
if ((this.estimator && !this.estimator.supportsCaching()) ||
this.parameters.synchronizeClock) {
// We cannot detect that a response was cached after the fact, so add a
// cache-busting parameter to the request to avoid caching. There are
// other methods, but they do not work cross-origin without control over
// both client and server.
var modifiedUri = new goog.Uri(url);
modifiedUri.getQueryData().add('_', Date.now());
url = modifiedUri.toString();
}
}
this.xhr_.open(this.parameters.method, url, true);
this.xhr_.responseType = this.parameters.responseType;
this.xhr_.timeout = this.parameters.requestTimeoutMs;
this.xhr_.withCredentials = this.parameters.withCredentials;
this.xhr_.onload = this.onLoad_.bind(this);
if (this.parameters.synchronizeClock) {
this.xhr_.onreadystatechange = this.onReadyStateChange_.bind(this);
}
this.xhr_.onerror = this.onError_.bind(this);
this.xhr_.ontimeout = this.onTimeout_.bind(this);
for (var k in this.parameters.requestHeaders) {
this.xhr_.setRequestHeader(k, this.parameters.requestHeaders[k]);
}
this.xhr_.send(this.parameters.body);
return this.promise_;
};
/**
* Handles a data URI.
* This method does not modify |this|'s state.
*
* @return {!Promise}
*
* @private
*/
shaka.util.AjaxRequest.prototype.handleDataUri_ = function() {
// Alias.
var StringUtils = shaka.util.StringUtils;
var Uint8ArrayUtils = shaka.util.Uint8ArrayUtils;
// Fake the data URI request.
// https://developer.mozilla.org/en-US/docs/Web/HTTP/data_URIs
var path = this.url.split(':')[1];
var optionalTypeAndRest = path.split(';');
var rest = optionalTypeAndRest.pop();
var optionalEncodingAndData = rest.split(',');
var data = optionalEncodingAndData.pop();
var optionalEncoding = optionalEncodingAndData.pop();
if (optionalEncoding == 'base64') {
data = StringUtils.fromBase64(data);
} else {
data = window.decodeURIComponent(data);
}
if (this.parameters.responseType == 'arraybuffer') {
data = Uint8ArrayUtils.fromString(data).buffer;
}
// We can't set the response field of an XHR, although we can make a
// hacky object that will still look like an XHR.
var xhr = /** @type {!XMLHttpRequest} */ (
JSON.parse(JSON.stringify(new XMLHttpRequest())));
xhr.response = data;
xhr.responseText = data.toString();
var promise = this.promise_;
promise.resolve(xhr);
this.destroy_();
return promise;
};
if (shaka.features.Offline) {
/**
* Handles an offline URI.
* This method does not modify |this|'s state.
*
* @return {!Promise}
*
* @private
*/
shaka.util.AjaxRequest.prototype.handleOfflineUri_ = function() {
// URL should have format idb://<streamId>/<segmentId>
var ids = this.url.split('/');
shaka.asserts.assert(ids.length == 4);
var streamId = parseInt(ids[2], 10);
var segmentId = parseInt(ids[3], 10);
shaka.asserts.assert(!isNaN(streamId));
shaka.asserts.assert(!isNaN(segmentId));
shaka.asserts.assert(this.parameters.contentDatabase);
return this.parameters.contentDatabase.retrieveSegment(streamId, segmentId)
.then(shaka.util.TypedBind(this,
/** @param {ArrayBuffer} data */
function(data) {
// We can't set the response field of an XHR, although we can make a
// hacky object that will still look like an XHR.
var xhr = /** @type {!XMLHttpRequest} */ (
JSON.parse(JSON.stringify(new XMLHttpRequest())));
xhr.response = data;
var promise = this.promise_;
promise.resolve(xhr);
this.destroy_();
return promise;
}))
.catch(shaka.util.TypedBind(this,
/** @param {*} e */
function(e) {
this.destroy_();
return Promise.reject(e);
}));
};
}
/**
* Creates an error object with necessary details about the request.
* @param {string} message The error message.
* @param {string} type The error type.
* @return {!Error}
* @private
*/
shaka.util.AjaxRequest.prototype.createError_ = function(message, type) {
var error = new Error(message);
error.type = type;
error.status = this.xhr_.status;
error.url = this.url;
error.method = this.parameters.method;
error.body = this.parameters.body;
error.xhr = this.xhr_;
return error;
};
/**
* Aborts an in-progress request.
* If a request is not in-progress then this function does nothing.
*/
shaka.util.AjaxRequest.prototype.abort = function() {
if (!this.xhr_ || this.xhr_.readyState == XMLHttpRequest.DONE) {
return;
}
shaka.asserts.assert(this.xhr_.readyState != 0);
this.xhr_.abort();
var error = this.createError_('Request aborted.', 'aborted');
this.promise_.reject(error);
this.destroy_();
};
/**
* Handles a "load" event.
*
* @param {!ProgressEvent} event The ProgressEvent from the request.
*
* @private
*/
shaka.util.AjaxRequest.prototype.onLoad_ = function(event) {
shaka.asserts.assert(event.target == this.xhr_);
if (this.estimator) {
this.estimator.sample(Date.now() - this.startTime_, event.loaded);
}
if (this.xhr_.status >= 200 && this.xhr_.status <= 299) {
// All 2xx HTTP codes are success cases.
this.promise_.resolve(this.xhr_);
this.destroy_();
} else if (this.attempts_ < this.parameters.maxAttempts) {
this.resendInternal_();
} else {
var error = this.createError_('HTTP error.', 'net');
this.promise_.reject(error);
this.destroy_();
}
};
/**
* Handles a "readystatechange" event.
*
* @private
*/
shaka.util.AjaxRequest.prototype.onReadyStateChange_ = function() {
if (this.xhr_.readyState != XMLHttpRequest.HEADERS_RECEIVED) {
return;
}
shaka.asserts.assert(this.parameters.synchronizeClock);
// This may not be available, depending on server configuration and CORS.
// For clock synchronization to work cross-origin, the server explicitly
// has to allow the client access to the "Date" header using the response
// header "Access-Control-Expose-Headers".
// It is also worth noting that this is not a fancy sync mechanism and
// does not account for round-trip times or latency. For our purposes,
// subsecond synchronization is not really necessary.
var date = Date.parse(this.xhr_.getResponseHeader('Date'));
if (date) {
shaka.util.Clock.sync(date);
}
};
/**
* Handles an "error" event.
*
* @param {!ProgressEvent} event The ProgressEvent from this.xhr_.
*
* @private
*/
shaka.util.AjaxRequest.prototype.onError_ = function(event) {
// Do not try again since an "error" event is usually unrecoverable.
shaka.asserts.assert(event.target == this.xhr_);
var error = this.createError_('Network failure.', 'net');
this.promise_.reject(error);
this.destroy_();
};
/**
* Handles a "timeout" event.
*
* @param {!ProgressEvent} event The ProgressEvent from this.xhr_.
*
* @private
*/
shaka.util.AjaxRequest.prototype.onTimeout_ = function(event) {
if (this.attempts_ < this.parameters.maxAttempts) {
this.resendInternal_();
} else {
var error = this.createError_('Request timed out.', 'net');
this.promise_.reject(error);
this.destroy_();
}
};
/**
* Resends request.
*
* @private
*/
shaka.util.AjaxRequest.prototype.resendInternal_ = function() {
this.cleanupRequest_();
var sendAgain = this.send.bind(this);
// Fuzz the delay to avoid tons of clients hitting the server at once
// after it recovers from whatever is causing it to fail.
var negToPosOne = (Math.random() * 2.0) - 1.0;
var negToPosFuzzFactor = negToPosOne * this.parameters.retryFuzzFactor;
var fuzzedDelay = this.retryDelayMs_ * (1.0 + negToPosFuzzFactor);
window.setTimeout(sendAgain, fuzzedDelay);
// Store the fuzzed delay to make testing retries feasible.
this.lastDelayMs_ = fuzzedDelay;
// Back off the next delay.
this.retryDelayMs_ *= this.parameters.retryBackoffFactor;
};