bluejax
Version:
jQuery AJAX wrapped in Bluebird promises.
426 lines (369 loc) • 14.9 kB
JavaScript
/**
* @author Louis-Dominique Dubeau
* @license MPL 2.0
* @copyright 2016 Louis-Dominique Dubeau
*/
/* global define module require */
(function boot(root, factory) {
"use strict";
if (typeof define === "function" && define.amd) {
define(["bluejax.try", "jquery", "bluebird"], factory);
}
else if (typeof module === "object" && module.exports) {
/* eslint-disable global-require */
module.exports = factory(require("bluejax.try"),
require("jquery"), require("bluebird"));
}
else {
/* global jQuery Promise */
root.bluejax = factory(root.bluejax.try, jQuery, Promise);
}
}(this, function factory(bluetry, $, Promise) {
"use strict";
// Utility function for class inheritance.
function inherit(inheritor, inherited) {
inheritor.prototype = Object.create(inherited.prototype);
inheritor.prototype.constructor = inheritor;
}
// Utility function for classes that are derived from Error. The prototype
// name is initially set to some generic value which is not particularly
// useful. This fixes the problem. We have to pass an explicit string through
// `name` because on some platforms we cannot count on `cls.name`.
function rename(cls, name) {
try {
Object.defineProperty(cls, "name", { value: name });
}
catch (ex) {
// Trying to defineProperty on `name` fails on Safari with a TypeError.
if (!(ex instanceof TypeError)) {
throw ex;
}
}
cls.prototype.name = name;
}
// Base class of all errors raised by this library. All errors raised by this
// library are subclasses of this class. The library never creates instances
// of this class that are not instances of children of this class (i.e. no
// ``new GeneralAjaxError(...)``).
function GeneralAjaxError(jqXHR, textStatus, errorThrown, options) {
this.jqXHR = jqXHR;
this.textStatus = textStatus;
this.errorThrown = errorThrown;
// ``captureStackTrace`` is not always available.
if (Error.captureStackTrace) {
Error.captureStackTrace(this, this.constructor);
}
else {
// This will work on many platforms.
var fakeError = new Error();
if (fakeError.stack) {
this.stack = fakeError.stack;
}
// However, IE11 and some other platforms do not set the stack until
// the error is thrown.
else {
try {
throw fakeError;
}
catch (ex) {
this.stack = ex.stack;
}
}
}
// We try to produce a message that says something useful.
var message = "Ajax operation failed";
if (errorThrown) {
message += ": " + errorThrown + " (" + jqXHR.status + ")";
}
else if (textStatus) {
message += ": " + textStatus;
}
message += ".";
// The user wanted verbose errors to be thrown, so be verbose.
if (options) {
message += " Called with: " + JSON.stringify(options);
}
this.message = message;
}
inherit(GeneralAjaxError, Error);
rename(GeneralAjaxError, "GeneralAjaxError");
//
// The possible values of ``jqXHR.textStatus`` are: ``"success"``,
// ``"notmodified"``, ``"nocontent"``, ``"error"``, ``"timeout"``,
// ``"abort"``, or ``"parsererror"``.
//
// The values "success" or "notmodified" cannot happen when we raise errors
// because they are successes.
//
// We do not create a more specialized error class for nocontent because
// that's a kind of buggy state anyway. See this bug report:
// https://bugs.jquery.com/ticket/13654
//
// So the remaining cases are ``"timeout"``, ``"abort"``,
// ``"parsererror"``. The ``error`` case is special. It is either an HTTP
// error (an HTTP status different from 200), ``"http"`` in the list here, or
// it is some other sort of error, ``"ajax"`` in the list.
var names = ["timeout", "abort", "parsererror", "ajax", "http"];
// A convenient map used to convert status text to error class.
var statusToError = {};
// A map of error class name to actual class.
var errors = {};
for (var i = 0; i < names.length; ++i) {
var name = names[i];
var className;
// We convert the name from our array into the real name we want to give to
// the class (e.g. ``"timeout"`` > ``TimeoutError``.
if (name !== "parsererror") {
className = name[0].toUpperCase() + name.slice(1) + "Error";
}
else {
// The default code would yield ParsererrorError.
className = "ParserError";
}
// eslint-disable-next-line func-names
var cls = function () {
GeneralAjaxError.apply(this, arguments);
};
statusToError[name] = cls;
errors[className] = cls;
inherit(cls, GeneralAjaxError);
rename(cls, className);
}
// Given a ``jqXHR`` that failed, create an error object.
function makeError(jqXHR, textStatus, errorThrown, options) {
var Constructor = statusToError[textStatus];
// We did not find anything in the map, which would happen if the textStatus
// was "error". Determine whether an ``HttpError`` or an ``AjaxError`` must
// be thrown.
if (!Constructor) {
Constructor = statusToError[(jqXHR.status !== 0) ? "http" : "ajax"];
}
return new Constructor(jqXHR, textStatus, errorThrown, options);
}
// Base class for all errors raised that have to do with network connectivity.
function ConnectivityError(message, original) {
GeneralAjaxError.call(this);
this.message = message;
this.originalError = original;
}
errors.ConnectivityError = ConnectivityError;
inherit(ConnectivityError, GeneralAjaxError);
rename(ConnectivityError, "ConnectivityError");
function BrowserOfflineError(original) {
ConnectivityError.call(this, "your browser is offline", original);
}
errors.BrowserOfflineError = BrowserOfflineError;
inherit(BrowserOfflineError, ConnectivityError);
rename(BrowserOfflineError, "BrowserOfflineError");
function ServerDownError(original) {
ConnectivityError.call(this, "the server appears to be down", original);
}
errors.ServerDownError = ServerDownError;
inherit(ServerDownError, ConnectivityError);
rename(ServerDownError, "ServerDownError");
function NetworkDownError(original) {
ConnectivityError.call(this, "the network appears to be down", original);
}
errors.NetworkDownError = NetworkDownError;
inherit(NetworkDownError, ConnectivityError);
rename(NetworkDownError, "NetworkDownError");
// For the ``ajax()`` function cannot use:
//
// return Promise.resolve($.ajax.apply($.ajax, arguments))
// .catch(function (e) {
// throw new GeneralAjaxError(e.jqXHR, e.textStatus, e.errorThrown);
// });
//
// Because what is passed to ``.catch`` is the ``jqXHR`` (so the
// code above cannot work). ``textStatus`` and ``errorThrown`` are
// lost.
//
// We furthermore cannot use:
//
// return Promise.resolve(
// $.ajax.apply($.ajax, arguments)
// .fail(function (...) {
// throw new GeneralAjaxError(...);
// }));
//
// Because there exist conditions under which $.ajax will fail
// immediately, call the ``.fail`` handler immediately and cause
// the exception to be raised before ``Promise.resolve`` has been
// given a chance to work. This means that some ajax errors won't
// be catchable through ``Promise.catch``.
//
// Make sure ``url`` is unique. We do this by appending a query parameter with
// the current time. This is done to bust caches.
function dedupURL(url) {
// If there is no query yet, we just add a query, otherwise we add a
// parameter to the query.
url += (url.indexOf("?") < 0) ? "?" : "&_=";
return url + Date.now();
}
// If ``url`` ends with a forward slash and does not have a query yet, make it
// point to ``favicon.ico``, which is an easy URL to use for checking if a
// server is up. The favicon would normally be relatively small. The root of a
// site like google.com is likely to be much bigger than the favicon. Some
// sites may have specialized URLs for this, which is why if ``url`` does not
// end with a forward slash or a query, we do not modify
// it. (E.g. http://example.com/ping would not be modified.)
function normalizeURL(url) {
if (url[url.length - 1] === "/" && url.indexOf("?") < 0) {
url += "favicon.ico";
}
return url;
}
// This is called once we know a) the browser is not offline but b) we cannot
// reach the server that should serve our request.
function connectionCheck(error, diagnose) {
var servers = diagnose.knownServers;
// Nothing to check so we fail immediately.
if (!servers || servers.length === 0) {
throw new ServerDownError(error);
}
// We check all the servers that the user asked to check. If none respond,
// we blame the network. Otherwise, we blame the server.
return Promise.all(servers.map(function urlToAjax(url) {
// eslint-disable-next-line no-use-before-define
return ajax({ url: dedupURL(normalizeURL(url)), timeout: 1000 })
.reflect();
})).filter(function filterSuccessfulServers(result) {
return result.isFulfilled();
}).then(function checkAnyFullfilled(fulfilled) {
if (fulfilled.length === 0) {
throw new NetworkDownError(error);
}
throw new ServerDownError(error);
});
}
// This is called when our tries all failed. This function attempts to figure
// out where the issue is.
function diagnoseIt(error, diagnose) {
// The browser reports being offline, blame the problem on this.
if (("onLine" in navigator) && !navigator.onLine) {
throw new BrowserOfflineError(error);
}
var serverURL = diagnose.serverURL;
var check;
// If the user gave us a server URL to check whether the server is up at
// all, use it. If that failed, then we need to check the connection. If we
// do not have a server URL, then we need to check the connection right
// away.
if (serverURL) {
// eslint-disable-next-line no-use-before-define
check = ajax({ url: dedupURL(normalizeURL(serverURL)) })
.catch(function failed() {
return connectionCheck(error, diagnose);
});
}
else {
check = connectionCheck(error, diagnose);
}
return check.then(function success() {
// All of our checks passed... and we have no tries left, so just rethrow
// what we would have thrown in the first place.
throw error;
});
}
// Determine whether the error is due to a network problem. We do not perform
// diagnosis on errors like an HTTP status code of 400 because errors like
// these are an indication that the application was not queried properly
// rather than a problem with the server being inaccessible or a network
// issue. So we need to distinguish network issues from the rest.
function isNetworkIssue(error) {
// We don't want to retry when a HTTP error occurred.
return !(error instanceof errors.HttpError) &&
!(error instanceof errors.ParserError) &&
!(error instanceof errors.AbortError);
}
// This is the core of the functionality provided by Bluejax.
function doit(originalArgs, originalSettings, jqOptions, bjOptions) {
var xhr;
var p = new Promise(function resolver(resolve, reject) {
xhr = bluetry.perform.call(this, originalSettings, jqOptions, bjOptions);
function succeded(data, textStatus, jqXHR) {
resolve(bjOptions.verboseResults ? [data, textStatus, jqXHR] : data);
}
function failed(jqXHR, textStatus, errorThrown) {
var error = makeError(
jqXHR, textStatus, errorThrown,
bjOptions.verboseExceptions ? originalArgs : null);
if (!isNetworkIssue(error)) {
// As mentioned earlier, errors that are not due to the network cause
// an immediate rejection: no diagnosis.
reject(error);
}
else {
// Move to perhaps diagnosing what could be the problem.
var diagnose = bjOptions.diagnose;
if (!diagnose || !diagnose.on) {
// The user did not request diagnosis: fail now.
reject(error);
}
else {
// Otherwise, we perform the requested diagnosis. We cannot just
// call ``reject`` with the return value of ``diagnoseIt``, as the
// rejection value would be a promise and not an error. (``resolve``
// assimilates promises, ``reject`` does not).
resolve(diagnoseIt(error, diagnose));
}
}
}
xhr.fail(failed).done(succeded);
});
return { xhr: xhr, promise: p };
}
// We need this so that we can use ``make``. The ``override`` parameter is
// used solely by ``make`` to pass the options that the user specified on
// ``make``.
function _ajax$(url, settings, override) {
// We just need to split up the arguments and pass them to ``doit``.
var originalArgs = settings ? [url, settings] : [url];
var originalSettings = settings || url;
var extracted = bluetry.extractBluejaxOptions(originalArgs);
// We need a copy here so that we do not mess up what the user passes to us.
var bluejaxOptions = $.extend({}, override, extracted[0]);
var cleanedOptions = extracted[1];
return doit(originalArgs, originalSettings, cleanedOptions, bluejaxOptions);
}
function _ajax(url, settings, override) {
return _ajax$(url, settings, override).promise;
}
// The public face of ``_ajax``.
function ajax(url, settings) {
return _ajax(url, settings);
}
function ajax$(url, settings) {
return _ajax$(url, settings);
}
function make(options, field) {
return function customAjax(url, settings) {
var ret = _ajax$(url, settings, options);
if (!field) {
return ret;
}
return ret[field];
};
}
var exports = {
try: bluetry,
ajax: ajax,
ajax$: ajax$,
GeneralAjaxError: GeneralAjaxError,
make: make,
};
// ``semver-sync`` detects an assignment to ``exports.version`` and uses the
// string literal for matching. Messing with this line could make
// ``semver-sync`` fail.
exports.version = "1.1.0";
// Export the errors.
for (var x in errors) { // eslint-disable-line guard-for-in
exports[x] = errors[x];
}
return exports;
}));
// LocalWords: jquery eslint jQuery GeneralAjaxError captureStackTrace jqXHR
// LocalWords: textStatus notmodified nocontent parsererror http ajax func
// LocalWords: TimeoutError ParsererrorError ParserError HttpError AjaxError
// LocalWords: Bluejax url bluejaxOptions defaultOptions cleanedOptions MPL
// LocalWords: errorThrown onLine favicon ico google diagnoseIt doit semver