Bug 1329069: Upgrade kinto-http-client.js to v4.0.0, r?MattN draft
authorEthan Glasser-Camp <eglassercamp@mozilla.com>
Thu, 02 Mar 2017 17:44:18 -0500
changeset 492234 abf171ce998e8cddbd71defc4b3b85e57f04f98c
parent 487539 da42e82556eec7058c9318bfcee8fe854d35a62c
child 547684 1db63925ecc0fec693204864787b61e76c61f8e6
push id47564
push usereglassercamp@mozilla.com
push dateThu, 02 Mar 2017 22:49:32 +0000
reviewersMattN
bugs1329069, 1335519, 1333677
milestone54.0a1
Bug 1329069: Upgrade kinto-http-client.js to v4.0.0, r?MattN Version 4.0.0 removes the default five-second timeout for all HTTP requests. This should hopefully fix bug 1329069, bug 1335519, and bug 1333677. It should also make us more robust "in the wild" against laggy connections or slow servers. We don't need to worry too much about instituting our own timeout because Necko will impose a timeout for us according to the preferences network.http.response.timeout and network.http.keep-alive.timeout. Version 3.0.0 updates some dependencies and changes some polyfills around. See https://github.com/Kinto/kinto-http.js/pull/158 for more details. MozReview-Commit-ID: 4eAwghOpqfE
services/common/kinto-http-client.js
--- a/services/common/kinto-http-client.js
+++ b/services/common/kinto-http-client.js
@@ -11,22 +11,23 @@
  * 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.
  */
 
 /*
  * This file is generated from kinto-http.js - do not modify directly.
  */
+
 const global = this;
 
 this.EXPORTED_SYMBOLS = ["KintoHttpClient"];
 
 /*
- * Version 2.7.0 - dae7787
+ * Version 4.0.0 - d750ae1
  */
 
 (function(f){if(typeof exports==="object"&&typeof module!=="undefined"){module.exports=f()}else if(typeof define==="function"&&define.amd){define([],f)}else{var g;if(typeof window!=="undefined"){g=window}else if(typeof global!=="undefined"){g=global}else if(typeof self!=="undefined"){g=self}else{g=this}g.KintoHttpClient = f()}})(function(){var define,module,exports;return (function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);var f=new Error("Cannot find module '"+o+"'");throw f.code="MODULE_NOT_FOUND",f}var l=n[o]={exports:{}};t[o][0].call(l.exports,function(e){var n=t[o][1][e];return s(n?n:e)},l,l.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o<r.length;o++)s(r[o]);return s})({1:[function(require,module,exports){
 /*
  *
  * 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
@@ -72,108 +73,100 @@ let KintoHttpClient = class KintoHttpCli
 // This fixes compatibility with CommonJS required by browserify.
 // See http://stackoverflow.com/questions/33505992/babel-6-changes-how-it-exports-default/33683495#33683495
 
 exports.default = KintoHttpClient;
 if (typeof module === "object") {
   module.exports = KintoHttpClient;
 }
 
-},{"../src/base":4}],2:[function(require,module,exports){
-
-var rng;
+},{"../src/base":7}],2:[function(require,module,exports){
+var v1 = require('./v1');
+var v4 = require('./v4');
 
-var crypto = global.crypto || global.msCrypto; // for IE 11
-if (crypto && crypto.getRandomValues) {
-  // WHATWG crypto-based RNG - http://wiki.whatwg.org/wiki/Crypto
-  // Moderately fast, high quality
-  var _rnds8 = new Uint8Array(16);
-  rng = function whatwgRNG() {
-    crypto.getRandomValues(_rnds8);
-    return _rnds8;
-  };
-}
+var uuid = v4;
+uuid.v1 = v1;
+uuid.v4 = v4;
 
-if (!rng) {
-  // Math.random()-based (RNG)
-  //
-  // If all else fails, use Math.random().  It's fast, but is of unspecified
-  // quality.
-  var  _rnds = new Array(16);
-  rng = function() {
-    for (var i = 0, r; i < 16; i++) {
-      if ((i & 0x03) === 0) r = Math.random() * 0x100000000;
-      _rnds[i] = r >>> ((i & 0x03) << 3) & 0xff;
-    }
+module.exports = uuid;
 
-    return _rnds;
-  };
+},{"./v1":5,"./v4":6}],3:[function(require,module,exports){
+/**
+ * Convert array of 16 byte values to UUID string format of the form:
+ * XXXXXXXX-XXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX
+ */
+var byteToHex = [];
+for (var i = 0; i < 256; ++i) {
+  byteToHex[i] = (i + 0x100).toString(16).substr(1);
 }
 
-module.exports = rng;
-
-
-},{}],3:[function(require,module,exports){
-//     uuid.js
-//
-//     Copyright (c) 2010-2012 Robert Kieffer
-//     MIT License - http://opensource.org/licenses/mit-license.php
-
-// Unique ID creation requires a high quality random # generator.  We feature
-// detect to determine the best RNG source, normalizing to a function that
-// returns 128-bits of randomness, since that's what's usually required
-var _rng = require('./rng');
-
-// Maps for number <-> hex string conversion
-var _byteToHex = [];
-var _hexToByte = {};
-for (var i = 0; i < 256; i++) {
-  _byteToHex[i] = (i + 0x100).toString(16).substr(1);
-  _hexToByte[_byteToHex[i]] = i;
-}
-
-// **`parse()` - Parse a UUID into it's component bytes**
-function parse(s, buf, offset) {
-  var i = (buf && offset) || 0, ii = 0;
-
-  buf = buf || [];
-  s.toLowerCase().replace(/[0-9a-f]{2}/g, function(oct) {
-    if (ii < 16) { // Don't overflow!
-      buf[i + ii++] = _hexToByte[oct];
-    }
-  });
-
-  // Zero out remaining bytes if string was short
-  while (ii < 16) {
-    buf[i + ii++] = 0;
-  }
-
-  return buf;
-}
-
-// **`unparse()` - Convert UUID byte array (ala parse()) into a string**
-function unparse(buf, offset) {
-  var i = offset || 0, bth = _byteToHex;
+function bytesToUuid(buf, offset) {
+  var i = offset || 0;
+  var bth = byteToHex;
   return  bth[buf[i++]] + bth[buf[i++]] +
           bth[buf[i++]] + bth[buf[i++]] + '-' +
           bth[buf[i++]] + bth[buf[i++]] + '-' +
           bth[buf[i++]] + bth[buf[i++]] + '-' +
           bth[buf[i++]] + bth[buf[i++]] + '-' +
           bth[buf[i++]] + bth[buf[i++]] +
           bth[buf[i++]] + bth[buf[i++]] +
           bth[buf[i++]] + bth[buf[i++]];
 }
 
+module.exports = bytesToUuid;
+
+},{}],4:[function(require,module,exports){
+// Unique ID creation requires a high quality random # generator.  In the
+// browser this is a little complicated due to unknown quality of Math.random()
+// and inconsistent support for the `crypto` API.  We do the best we can via
+// feature-detection
+var rng;
+
+var crypto = global.crypto || global.msCrypto; // for IE 11
+if (crypto && crypto.getRandomValues) {
+  // WHATWG crypto RNG - http://wiki.whatwg.org/wiki/Crypto
+  var rnds8 = new Uint8Array(16);
+  rng = function whatwgRNG() {
+    crypto.getRandomValues(rnds8);
+    return rnds8;
+  };
+}
+
+if (!rng) {
+  // Math.random()-based (RNG)
+  //
+  // If all else fails, use Math.random().  It's fast, but is of unspecified
+  // quality.
+  var  rnds = new Array(16);
+  rng = function() {
+    for (var i = 0, r; i < 16; i++) {
+      if ((i & 0x03) === 0) r = Math.random() * 0x100000000;
+      rnds[i] = r >>> ((i & 0x03) << 3) & 0xff;
+    }
+
+    return rnds;
+  };
+}
+
+module.exports = rng;
+
+},{}],5:[function(require,module,exports){
+// Unique ID creation requires a high quality random # generator.  We feature
+// detect to determine the best RNG source, normalizing to a function that
+// returns 128-bits of randomness, since that's what's usually required
+var rng = require('./lib/rng');
+var bytesToUuid = require('./lib/bytesToUuid');
+
 // **`v1()` - Generate time-based UUID**
 //
 // Inspired by https://github.com/LiosK/UUID.js
 // and http://docs.python.org/library/uuid.html
 
 // random #'s we need to init node and clockseq
-var _seedBytes = _rng();
+var _seedBytes = rng();
 
 // Per 4.5, create and 48-bit node id, (47 random bits + multicast bit = 1)
 var _nodeId = [
   _seedBytes[0] | 0x01,
   _seedBytes[1], _seedBytes[2], _seedBytes[3], _seedBytes[4], _seedBytes[5]
 ];
 
 // Per 4.2.2, randomize (14 bit) clockseq
@@ -246,62 +239,57 @@ function v1(options, buf, offset) {
   // `clock_seq_hi_and_reserved` (Per 4.2.2 - include variant)
   b[i++] = clockseq >>> 8 | 0x80;
 
   // `clock_seq_low`
   b[i++] = clockseq & 0xff;
 
   // `node`
   var node = options.node || _nodeId;
-  for (var n = 0; n < 6; n++) {
+  for (var n = 0; n < 6; ++n) {
     b[i + n] = node[n];
   }
 
-  return buf ? buf : unparse(b);
+  return buf ? buf : bytesToUuid(b);
 }
 
-// **`v4()` - Generate random UUID**
+module.exports = v1;
 
-// See https://github.com/broofa/node-uuid for API details
+},{"./lib/bytesToUuid":3,"./lib/rng":4}],6:[function(require,module,exports){
+var rng = require('./lib/rng');
+var bytesToUuid = require('./lib/bytesToUuid');
+
 function v4(options, buf, offset) {
-  // Deprecated - 'format' argument, as supported in v1.2
   var i = buf && offset || 0;
 
   if (typeof(options) == 'string') {
     buf = options == 'binary' ? new Array(16) : null;
     options = null;
   }
   options = options || {};
 
-  var rnds = options.random || (options.rng || _rng)();
+  var rnds = options.random || (options.rng || rng)();
 
   // Per 4.4, set bits for version and `clock_seq_hi_and_reserved`
   rnds[6] = (rnds[6] & 0x0f) | 0x40;
   rnds[8] = (rnds[8] & 0x3f) | 0x80;
 
   // Copy bytes to buffer, if provided
   if (buf) {
-    for (var ii = 0; ii < 16; ii++) {
+    for (var ii = 0; ii < 16; ++ii) {
       buf[i + ii] = rnds[ii];
     }
   }
 
-  return buf || unparse(rnds);
+  return buf || bytesToUuid(rnds);
 }
 
-// Export public API
-var uuid = v4;
-uuid.v1 = v1;
-uuid.v4 = v4;
-uuid.parse = parse;
-uuid.unparse = unparse;
+module.exports = v4;
 
-module.exports = uuid;
-
-},{"./rng":2}],4:[function(require,module,exports){
+},{"./lib/bytesToUuid":3,"./lib/rng":4}],7:[function(require,module,exports){
 "use strict";
 
 Object.defineProperty(exports, "__esModule", {
   value: true
 });
 exports.default = exports.SUPPORTED_PROTOCOL_VERSION = undefined;
 
 var _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; };
@@ -385,17 +373,17 @@ let KintoClientBase = (_dec = (0, _utils
    * @param  {String}       remote  The remote URL.
    * @param  {Object}       [options={}]                  The options object.
    * @param  {Boolean}      [options.safe=true]           Adds concurrency headers to every requests.
    * @param  {EventEmitter} [options.events=EventEmitter] The events handler instance.
    * @param  {Object}       [options.headers={}]          The key-value headers to pass to each request.
    * @param  {Object}       [options.retry=0]             Number of retries when request fails (default: 0)
    * @param  {String}       [options.bucket="default"]    The default bucket to use.
    * @param  {String}       [options.requestMode="cors"]  The HTTP request mode (from ES6 fetch spec).
-   * @param  {Number}       [options.timeout=5000]        The requests timeout in ms.
+   * @param  {Number}       [options.timeout=null]        The request timeout in ms, if any.
    */
   constructor(remote, options = {}) {
     if (typeof remote !== "string" || !remote.length) {
       throw new Error("Invalid remote URL: " + remote);
     }
     if (remote[remote.length - 1] === "/") {
       remote = remote.slice(0, -1);
     }
@@ -462,17 +450,17 @@ let KintoClientBase = (_dec = (0, _utils
   set remote(url) {
     let version;
     try {
       version = url.match(/\/(v\d+)\/?$/)[1];
     } catch (err) {
       throw new Error("The remote URL must contain the version: " + url);
     }
     if (version !== SUPPORTED_PROTOCOL_VERSION) {
-      throw new Error(`Unsupported protocol version: ${ version }`);
+      throw new Error(`Unsupported protocol version: ${version}`);
     }
     this._remote = url;
     this._version = version;
   }
 
   /**
    * The current server protocol version, eg. `v1`.
    * @type {String}
@@ -712,17 +700,17 @@ let KintoClientBase = (_dec = (0, _utils
   }
 
   paginatedList(path, params, options = {}) {
     const { sort, filters, limit, pages, since } = _extends({
       sort: "-last_modified"
     }, params);
     // Safety/Consistency check on ETag value.
     if (since && typeof since !== "string") {
-      throw new Error(`Invalid value for since (${ since }), should be ETag value.`);
+      throw new Error(`Invalid value for since (${since}), should be ETag value.`);
     }
 
     const querystring = (0, _utils.qsify)(_extends({}, filters, {
       _sort: sort,
       _limit: limit,
       _since: since
     }));
     let results = [],
@@ -765,17 +753,16 @@ let KintoClientBase = (_dec = (0, _utils
       current += 1;
       if (current >= pages || !nextPage) {
         // Pagination exhausted
         return pageResults(results, nextPage, etag, totalRecords);
       }
       // Follow next page
       return processNextPage(nextPage);
     };
-
     return this.execute(_extends({
       path: path + "?" + querystring
     }, options), { raw: true }).then(handleResponse);
   }
 
   /**
    * Lists all permissions.
    *
@@ -863,17 +850,17 @@ let KintoClientBase = (_dec = (0, _utils
   deleteBuckets(options = {}) {
     const reqOptions = this._getRequestOptions(options);
     const path = (0, _endpoint2.default)("bucket");
     return this.execute(requests.deleteRequest(path, reqOptions));
   }
 }, (_applyDecoratedDescriptor(_class.prototype, "fetchServerSettings", [_dec], Object.getOwnPropertyDescriptor(_class.prototype, "fetchServerSettings"), _class.prototype), _applyDecoratedDescriptor(_class.prototype, "fetchServerCapabilities", [_dec2], Object.getOwnPropertyDescriptor(_class.prototype, "fetchServerCapabilities"), _class.prototype), _applyDecoratedDescriptor(_class.prototype, "fetchUser", [_dec3], Object.getOwnPropertyDescriptor(_class.prototype, "fetchUser"), _class.prototype), _applyDecoratedDescriptor(_class.prototype, "fetchHTTPApiVersion", [_dec4], Object.getOwnPropertyDescriptor(_class.prototype, "fetchHTTPApiVersion"), _class.prototype), _applyDecoratedDescriptor(_class.prototype, "batch", [_dec5], Object.getOwnPropertyDescriptor(_class.prototype, "batch"), _class.prototype), _applyDecoratedDescriptor(_class.prototype, "listPermissions", [_dec6], Object.getOwnPropertyDescriptor(_class.prototype, "listPermissions"), _class.prototype), _applyDecoratedDescriptor(_class.prototype, "deleteBuckets", [_dec7], Object.getOwnPropertyDescriptor(_class.prototype, "deleteBuckets"), _class.prototype)), _class));
 exports.default = KintoClientBase;
 
-},{"./batch":5,"./bucket":6,"./endpoint":8,"./http":10,"./requests":11,"./utils":12}],5:[function(require,module,exports){
+},{"./batch":8,"./bucket":9,"./endpoint":11,"./http":13,"./requests":14,"./utils":15}],8:[function(require,module,exports){
 "use strict";
 
 Object.defineProperty(exports, "__esModule", {
   value: true
 });
 exports.aggregate = aggregate;
 /**
  * Exports batch responses as a result object.
@@ -920,17 +907,17 @@ function aggregate(responses = [], reque
         sent: request,
         error: response.body
       });
     }
     return acc;
   }, results);
 }
 
-},{}],6:[function(require,module,exports){
+},{}],9:[function(require,module,exports){
 "use strict";
 
 Object.defineProperty(exports, "__esModule", {
   value: true
 });
 exports.default = undefined;
 
 var _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; };
@@ -1305,17 +1292,17 @@ let Bucket = (_dec = (0, _utils.capable)
    * @return {Promise<Object, Error>}
    */
   batch(fn, options = {}) {
     return this.client.batch(fn, this._bucketOptions(options));
   }
 }, (_applyDecoratedDescriptor(_class.prototype, "listHistory", [_dec], Object.getOwnPropertyDescriptor(_class.prototype, "listHistory"), _class.prototype)), _class));
 exports.default = Bucket;
 
-},{"./collection":7,"./endpoint":8,"./requests":11,"./utils":12}],7:[function(require,module,exports){
+},{"./collection":10,"./endpoint":11,"./requests":14,"./utils":15}],10:[function(require,module,exports){
 "use strict";
 
 Object.defineProperty(exports, "__esModule", {
   value: true
 });
 exports.default = undefined;
 
 var _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; };
@@ -1657,25 +1644,73 @@ let Collection = (_dec = (0, _utils.capa
    *
    * Paginating is done by passing a `limit` option, then calling the `next()`
    * method from the resolved result object to fetch the next page, if any.
    *
    * @param  {Object}   [options={}]                    The options object.
    * @param  {Object}   [options.headers]               The headers object option.
    * @param  {Object}   [options.filters=[]]            The filters object.
    * @param  {String}   [options.sort="-last_modified"] The sort field.
+   * @param  {String}   [options.at]                    The timestamp to get a snapshot at.
    * @param  {String}   [options.limit=null]            The limit field.
    * @param  {String}   [options.pages=1]               The number of result pages to aggregate.
    * @param  {Number}   [options.since=null]            Only retrieve records modified since the provided timestamp.
    * @return {Promise<Object, Error>}
    */
   listRecords(options = {}) {
     const path = (0, _endpoint2.default)("record", this.bucket.name, this.name);
     const reqOptions = this._collOptions(options);
-    return this.client.paginatedList(path, options, reqOptions);
+    if (options.hasOwnProperty("at")) {
+      return this._getSnapshot(options.at);
+    } else {
+      return this.client.paginatedList(path, options, reqOptions);
+    }
+  }
+
+  /**
+   * @private
+   */
+  _getSnapshot(at) {
+    if (!Number.isInteger(at) || at <= 0) {
+      throw new Error("Invalid argument, expected a positive integer.");
+    }
+    // TODO: we process history changes forward, while it would probably be more
+    // efficient and accurate to process them backward.
+    return this.bucket.listHistory({
+      pages: Infinity, // all pages up to target timestamp are required
+      sort: "-target.data.last_modified",
+      filters: {
+        resource_name: "record",
+        collection_id: this.name,
+        "max_target.data.last_modified": String(at)
+      }
+    }).then(({ data: changes }) => {
+      const seenIds = new Set();
+      let snapshot = [];
+      for (const _ref of changes) {
+        const { target: { data: record } } = _ref;
+
+        if (record.deleted) {
+          seenIds.add(record.id); // ensure not reprocessing deleted entries
+          snapshot = snapshot.filter(r => r.id !== record.id);
+        } else if (!seenIds.has(record.id)) {
+          seenIds.add(record.id);
+          snapshot.push(record);
+        }
+      }
+      return {
+        last_modified: String(at),
+        data: snapshot.sort((a, b) => b.last_modified - a.last_modified),
+        next: () => {
+          throw new Error("Snapshots don't support pagination");
+        },
+        hasNextPage: false,
+        totalRecords: snapshot.length
+      };
+    });
   }
 
   /**
    * Performs batch operations at the current collection level.
    *
    * @param  {Function} fn                   The batch operation function.
    * @param  {Object}   [options={}]         The options object.
    * @param  {Object}   [options.headers]    The headers object option.
@@ -1688,52 +1723,52 @@ let Collection = (_dec = (0, _utils.capa
     return this.client.batch(fn, _extends({}, reqOptions, {
       bucket: this.bucket.name,
       collection: this.name
     }));
   }
 }, (_applyDecoratedDescriptor(_class.prototype, "addAttachment", [_dec], Object.getOwnPropertyDescriptor(_class.prototype, "addAttachment"), _class.prototype), _applyDecoratedDescriptor(_class.prototype, "removeAttachment", [_dec2], Object.getOwnPropertyDescriptor(_class.prototype, "removeAttachment"), _class.prototype)), _class));
 exports.default = Collection;
 
-},{"./endpoint":8,"./requests":11,"./utils":12,"uuid":3}],8:[function(require,module,exports){
+},{"./endpoint":11,"./requests":14,"./utils":15,"uuid":2}],11:[function(require,module,exports){
 "use strict";
 
 Object.defineProperty(exports, "__esModule", {
   value: true
 });
 exports.default = endpoint;
 /**
  * Endpoints templates.
  * @type {Object}
  */
 const ENDPOINTS = {
   root: () => "/",
   batch: () => "/batch",
   permissions: () => "/permissions",
-  bucket: bucket => "/buckets" + (bucket ? `/${ bucket }` : ""),
-  history: bucket => `${ ENDPOINTS.bucket(bucket) }/history`,
-  collection: (bucket, coll) => `${ ENDPOINTS.bucket(bucket) }/collections` + (coll ? `/${ coll }` : ""),
-  group: (bucket, group) => `${ ENDPOINTS.bucket(bucket) }/groups` + (group ? `/${ group }` : ""),
-  record: (bucket, coll, id) => `${ ENDPOINTS.collection(bucket, coll) }/records` + (id ? `/${ id }` : ""),
-  attachment: (bucket, coll, id) => `${ ENDPOINTS.record(bucket, coll, id) }/attachment`
+  bucket: bucket => "/buckets" + (bucket ? `/${bucket}` : ""),
+  history: bucket => `${ENDPOINTS.bucket(bucket)}/history`,
+  collection: (bucket, coll) => `${ENDPOINTS.bucket(bucket)}/collections` + (coll ? `/${coll}` : ""),
+  group: (bucket, group) => `${ENDPOINTS.bucket(bucket)}/groups` + (group ? `/${group}` : ""),
+  record: (bucket, coll, id) => `${ENDPOINTS.collection(bucket, coll)}/records` + (id ? `/${id}` : ""),
+  attachment: (bucket, coll, id) => `${ENDPOINTS.record(bucket, coll, id)}/attachment`
 };
 
 /**
  * Retrieves a server enpoint by its name.
  *
  * @private
  * @param  {String}    name The endpoint name.
  * @param  {...string} args The endpoint parameters.
  * @return {String}
  */
 function endpoint(name, ...args) {
   return ENDPOINTS[name](...args);
 }
 
-},{}],9:[function(require,module,exports){
+},{}],12:[function(require,module,exports){
 "use strict";
 
 Object.defineProperty(exports, "__esModule", {
   value: true
 });
 /**
  * Kinto server error code descriptors.
  * @type {Object}
@@ -1755,17 +1790,17 @@ exports.default = {
   117: "Client has sent too many requests",
   121: "Resource access is forbidden for this user",
   122: "Another resource violates constraint",
   201: "Service Temporary unavailable due to high load",
   202: "Service deprecated",
   999: "Internal Server Error"
 };
 
-},{}],10:[function(require,module,exports){
+},{}],13:[function(require,module,exports){
 "use strict";
 
 Object.defineProperty(exports, "__esModule", {
   value: true
 });
 exports.default = undefined;
 
 var _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; };
@@ -1794,25 +1829,25 @@ let HTTP = class HTTP {
   }
 
   /**
    * Default options.
    *
    * @type {Object}
    */
   static get defaultOptions() {
-    return { timeout: 5000, requestMode: "cors" };
+    return { timeout: null, requestMode: "cors" };
   }
 
   /**
    * Constructor.
    *
    * @param {EventEmitter} events                       The event handler.
    * @param {Object}       [options={}}                 The options object.
-   * @param {Number}       [options.timeout=5000]       The request timeout in ms (default: `5000`).
+   * @param {Number}       [options.timeout=null]       The request timeout in ms, if any (default: `null`).
    * @param {String}       [options.requestMode="cors"] The HTTP request mode (default: `"cors"`).
    */
   constructor(events, options = {}) {
     // public properties
     /**
      * The event emitter instance.
      * @type {EventEmitter}
      */
@@ -1856,29 +1891,35 @@ let HTTP = class HTTP {
     // If a multipart body is provided, remove any custom Content-Type header as
     // the fetch() implementation will add the correct one for us.
     if (options.body && typeof options.body.append === "function") {
       delete options.headers["Content-Type"];
     }
     options.mode = this.requestMode;
     return new Promise((resolve, reject) => {
       // Detect if a request has timed out.
-      const _timeoutId = setTimeout(() => {
-        hasTimedout = true;
-        reject(new Error("Request timeout."));
-      }, this.timeout);
-
+      let _timeoutId;
+      if (this.timeout) {
+        _timeoutId = setTimeout(() => {
+          hasTimedout = true;
+          reject(new Error("Request timeout."));
+        }, this.timeout);
+      }
       fetch(url, options).then(res => {
         if (!hasTimedout) {
-          clearTimeout(_timeoutId);
+          if (_timeoutId) {
+            clearTimeout(_timeoutId);
+          }
           resolve(res);
         }
       }).catch(err => {
         if (!hasTimedout) {
-          clearTimeout(_timeoutId);
+          if (_timeoutId) {
+            clearTimeout(_timeoutId);
+          }
           reject(err);
         }
       });
     }).then(res => {
       response = res;
       headers = res.headers;
       status = res.status;
       statusText = res.statusText;
@@ -1900,28 +1941,28 @@ let HTTP = class HTTP {
       // Check if we have a body; if so parse it as JSON.
       .then(text => {
         if (text.length === 0) {
           return null;
         }
         // Note: we can't consume the response body twice.
         return JSON.parse(text);
       }).catch(err => {
-        const error = new Error(`HTTP ${ status || 0 }; ${ err }`);
+        const error = new Error(`HTTP ${status || 0}; ${err}`);
         error.response = response;
         error.stack = err.stack;
         throw error;
       }).then(json => {
         if (json && status >= 400) {
-          let message = `HTTP ${ status } ${ json.error || "" }: `;
+          let message = `HTTP ${status} ${json.error || ""}: `;
           if (json.errno && json.errno in _errors2.default) {
             const errnoMsg = _errors2.default[json.errno];
             message += errnoMsg;
             if (json.message && json.message !== errnoMsg) {
-              message += ` (${ json.message })`;
+              message += ` (${json.message})`;
             }
           } else {
             message += statusText || "";
           }
           const error = new Error(message.trim());
           error.response = response;
           error.data = json;
           throw error;
@@ -1966,17 +2007,17 @@ let HTTP = class HTTP {
     const delay = parseInt(retryAfter, 10) * 1000;
     retryAfter = new Date().getTime() + delay;
     this.events.emit("retry-after", retryAfter);
     return delay;
   }
 };
 exports.default = HTTP;
 
-},{"./errors":9}],11:[function(require,module,exports){
+},{"./errors":12}],14:[function(require,module,exports){
 "use strict";
 
 Object.defineProperty(exports, "__esModule", {
   value: true
 });
 
 var _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; };
 
@@ -1999,17 +2040,17 @@ const requestDefaults = {
 /**
  * @private
  */
 function safeHeader(safe, last_modified) {
   if (!safe) {
     return {};
   }
   if (last_modified) {
-    return { "If-Match": `"${ last_modified }"` };
+    return { "If-Match": `"${last_modified}"` };
   }
   return { "If-None-Match": "*" };
 }
 
 /**
  * @private
  */
 function createRequest(path, { data, permissions }, options = {}) {
@@ -2086,17 +2127,17 @@ function addAttachmentRequest(path, data
   return {
     method: "POST",
     path: customPath,
     headers: _extends({}, headers, safeHeader(safe, last_modified)),
     body: formData
   };
 }
 
-},{"./utils":12}],12:[function(require,module,exports){
+},{"./utils":15}],15:[function(require,module,exports){
 "use strict";
 
 Object.defineProperty(exports, "__esModule", {
   value: true
 });
 
 var _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; };
 
@@ -2219,17 +2260,17 @@ function qsify(obj) {
  */
 function checkVersion(version, minVersion, maxVersion) {
   const extract = str => str.split(".").map(x => parseInt(x, 10));
   const [verMajor, verMinor] = extract(version);
   const [minMajor, minMinor] = extract(minVersion);
   const [maxMajor, maxMinor] = extract(maxVersion);
   const checks = [verMajor < minMajor, verMajor === minMajor && verMinor < minMinor, verMajor > maxMajor, verMajor === maxMajor && verMinor >= maxMinor];
   if (checks.some(x => x)) {
-    throw new Error(`Version ${ version } doesn't satisfy ` + `${ minVersion } <= x < ${ maxVersion }`);
+    throw new Error(`Version ${version} doesn't satisfy ` + `${minVersion} <= x < ${maxVersion}`);
   }
 }
 
 /**
  * Generates a decorator function ensuring a version check is performed against
  * the provided requirements before executing it.
  *
  * @param  {String} min The required min version (inclusive).
@@ -2272,17 +2313,17 @@ function capable(capabilities) {
       configurable: true,
       get() {
         const wrappedMethod = (...args) => {
           // "this" is the current instance which its method is decorated.
           const client = "client" in this ? this.client : this;
           return client.fetchServerCapabilities().then(available => {
             const missing = capabilities.filter(c => !available.hasOwnProperty(c));
             if (missing.length > 0) {
-              throw new Error(`Required capabilities ${ missing.join(", ") } ` + "not present on server");
+              throw new Error(`Required capabilities ${missing.join(", ")} ` + "not present on server");
             }
           }).then(() => fn.apply(this, args));
         };
         Object.defineProperty(this, key, {
           value: wrappedMethod,
           configurable: true,
           writable: true
         });
@@ -2336,17 +2377,17 @@ function isObject(thing) {
  * Parses a data url.
  * @param  {String} dataURL The data url.
  * @return {Object}
  */
 function parseDataURL(dataURL) {
   const regex = /^data:(.*);base64,(.*)/;
   const match = dataURL.match(regex);
   if (!match) {
-    throw new Error(`Invalid data-url: ${ String(dataURL).substr(0, 32) }...`);
+    throw new Error(`Invalid data-url: ${String(dataURL).substr(0, 32)}...`);
   }
   const props = match[1];
   const base64 = match[2];
   const [type, ...rawParams] = props.split(";");
   const params = rawParams.reduce((acc, param) => {
     const [key, value] = param.split("=");
     return _extends({}, acc, { [key]: value });
   }, {});