diff --git a/.gitignore b/.gitignore index 3c53b00..ca8dd9d 100644 --- a/.gitignore +++ b/.gitignore @@ -2,5 +2,3 @@ node_modules/ .idea/ -lib/ - diff --git a/lib/helpers.js b/lib/helpers.js new file mode 100644 index 0000000..fa7443e --- /dev/null +++ b/lib/helpers.js @@ -0,0 +1,98 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); + +var _createClass = function () { function defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } return function (Constructor, protoProps, staticProps) { if (protoProps) defineProperties(Constructor.prototype, protoProps); if (staticProps) defineProperties(Constructor, staticProps); return Constructor; }; }(); + +function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } } + +/* eslint-disable no-param-reassign */ + +var Helpers = function () { + function Helpers() { + _classCallCheck(this, Helpers); + } + + _createClass(Helpers, null, [{ + key: "moveParamsIntoPath", + + + /** + * For each `/:param` fragment in path, move the value in params + * at that key to path. If the key is not found in params, throw. + * Modifies both params and path values. + * + * @param {Object} params + * @param {String} path + * @return {XML|string|void|*} + */ + value: function moveParamsIntoPath(params, path) { + var rgxParam = /\/:(\w+)/g; + + path = path.replace(rgxParam, function (hit) { + var paramName = hit.slice(2); + var suppliedVal = params[paramName]; + + if (!suppliedVal) { + throw new Error("Mastodon: Params object is missing a required parameter for this request: " + paramName); + } + + delete params[paramName]; + return "/" + suppliedVal; + }); + return path; + } + + /** + * When Mastodon returns a response that looks like an error response, + * use this function to attach the error info in the response body to `err`. + * + * @param {Error} err + * @param {Object} body + */ + + }, { + key: "attachBodyInfoToError", + value: function attachBodyInfoToError(err, body) { + err.mastodonReply = body; + + if (!body) return err; + + if (body.error) { + // the body itself is an error object + err.message = body.error; + err.allErrors = err.allErrors.concat([body]); + } else if (body.errors && body.errors.length) { + // body contains multiple error objects + err.message = body.errors[0].message; + err.code = body.errors[0].code; + err.allErrors = err.allErrors.concat(body.errors); + } + return err; + } + + /** + * Mastodon error object + * + * @param {String} message + * @return {Error} + */ + + }, { + key: "makeMastodonError", + value: function makeMastodonError(message) { + var err = Error(); + if (message) err.message = message; + err.code = null; + err.allErrors = []; + err.mastodonReply = null; + return err; + } + }]); + + return Helpers; +}(); + +exports.default = Helpers; \ No newline at end of file diff --git a/lib/mastodon.js b/lib/mastodon.js new file mode 100644 index 0000000..1b6c997 --- /dev/null +++ b/lib/mastodon.js @@ -0,0 +1,438 @@ +'use strict'; + +var _typeof = typeof Symbol === "function" && typeof Symbol.iterator === "symbol" ? function (obj) { return typeof obj; } : function (obj) { return obj && typeof Symbol === "function" && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj; }; + +var _createClass = function () { function defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } return function (Constructor, protoProps, staticProps) { if (protoProps) defineProperties(Constructor.prototype, protoProps); if (staticProps) defineProperties(Constructor, staticProps); return Constructor; }; }(); + +var _assert = require('assert'); + +var _assert2 = _interopRequireDefault(_assert); + +var _util = require('util'); + +var _util2 = _interopRequireDefault(_util); + +var _oauth = require('oauth'); + +var _request = require('request'); + +var _request2 = _interopRequireDefault(_request); + +var _jsonBigint = require('json-bigint'); + +var _jsonBigint2 = _interopRequireDefault(_jsonBigint); + +var _helpers = require('./helpers'); + +var _helpers2 = _interopRequireDefault(_helpers); + +var _streamingApiConnection = require('./streaming-api-connection'); + +var _streamingApiConnection2 = _interopRequireDefault(_streamingApiConnection); + +var _settings = require('./settings'); + +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } + +function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } } + +var Mastodon = function () { + function Mastodon(config) { + _classCallCheck(this, Mastodon); + + this.apiUrl = config.api_url || _settings.DEFAULT_REST_ROOT; + + Mastodon._validateConfigOrThrow(config); + + this.config = config; + this._mastodon_time_minus_local_time_ms = 0; + } + + _createClass(Mastodon, [{ + key: 'get', + value: function get(path, params, callback) { + return this.request('GET', path, params, callback); + } + }, { + key: 'patch', + value: function patch(path, params, callback) { + return this.request('PATCH', path, params, callback); + } + }, { + key: 'post', + value: function post(path, params, callback) { + return this.request('POST', path, params, callback); + } + }, { + key: 'put', + value: function put(path, params, callback) { + return this.request('PUT', path, params, callback); + } + }, { + key: 'delete', + value: function _delete(path, params, callback) { + return this.request('DELETE', path, params, callback); + } + }, { + key: 'request', + value: function request(method, path, params, callback) { + var self = this; + (0, _assert2.default)(method === 'GET' || method === 'PATCH' || method === 'POST' || method === 'PUT' || method === 'DELETE'); + // if no `params` is specified but a callback is, use default params + if (typeof params === 'function') { + callback = params; + params = {}; + } + + return new Promise(function (resolve, reject) { + var _returnErrorToUser = function _returnErrorToUser(err) { + if (callback && typeof callback === 'function') { + callback(err, null, null); + } + reject(err); + }; + + self._buildRequestOptions(method, path, params, function (err, requestOptions) { + if (err) { + _returnErrorToUser(err); + return; + } + + var mastodonOptions = params && params.masto_options || {}; + + process.nextTick(function () { + // ensure all HTTP i/o occurs after the user + // has a chance to bind their event handlers + Mastodon._doRESTAPIRequest(requestOptions, mastodonOptions, method, function (reqerr, parsedBody, response) { + self._updateClockOffsetFromResponse(response); + + if (self.config.trusted_cert_fingerprints) { + if (!response.socket.authorized) { + // The peer certificate was not signed + // by one of the authorized CA's. + var authErrMsg = response.socket.authorizationError.toString(); + var merr = _helpers2.default.makeMastodonError('The peer certificate was not signed; ' + authErrMsg); + _returnErrorToUser(merr); + return; + } + var fingerprint = response.socket.getPeerCertificate().fingerprint; + var trustedFingerprints = self.config.trusted_cert_fingerprints; + if (!trustedFingerprints.includes(fingerprint)) { + var errMsg = _util2.default.format('Certificate untrusted. Trusted fingerprints are: %s. Got fingerprint: %s.', trustedFingerprints.join(','), fingerprint); + var _merr = new Error(errMsg); + _returnErrorToUser(_merr); + return; + } + } + + if (callback && typeof callback === 'function') { + callback(reqerr, parsedBody, response); + } + + resolve({ data: parsedBody, resp: response }); + }); + }); + }); + }); + } + }, { + key: '_updateClockOffsetFromResponse', + value: function _updateClockOffsetFromResponse(response) { + if (response && response.headers && response.headers.date) { + var date = new Date(response.headers.date); + if (date.toString() === 'Invalid Date') return; + this._mastodon_time_minus_local_time_ms = date.getTime() - Date.now(); + } + } + + /** + * Builds and returns an options object ready to pass to `request()` + * @param {String} method "GET", "POST", or "DELETE" + * @param {String} path REST API resource uri (eg. "statuses/destroy/:id") + * @param {Object} params user's params object + * @param {Function} callback + * @returns {Undefined} + * + * Calls `callback` with Error, Object + * where Object is an options object ready to pass to `request()`. + * + * Returns error raised (if any) by `helpers.moveParamsIntoPath()` + */ + + }, { + key: '_buildRequestOptions', + value: function _buildRequestOptions(method, path, params, callback) { + var finalParams = params || {}; + delete finalParams.mastodon_options; + + // the options object passed to `request` used to perform the HTTP request + var requestOptions = { + headers: { + Accept: '*/*', + 'User-Agent': 'node-mastodon-client', + Authorization: 'Bearer ' + this.config.access_token + }, + gzip: true, + encoding: null + }; + + if (typeof this.config.timeout_ms !== 'undefined') { + requestOptions.timeout_ms = this.config.timeout_ms; + } + + try { + // finalize the `path` value by building it using user-supplied params + path = _helpers2.default.moveParamsIntoPath(finalParams, path); + } catch (e) { + callback(e, null, null); + return; + } + + if (path.match(/^https?:\/\//i)) { + // This is a full url request + requestOptions.url = path; + } else { + // This is a REST API request. + requestOptions.url = '' + this.apiUrl + path; + } + + if (finalParams.file) { + // If we're sending a file + requestOptions.headers['Content-type'] = 'multipart/form-data'; + requestOptions.formData = finalParams; + } else if (Object.keys(finalParams).length > 0) { + // Non-file-upload params should be url-encoded + requestOptions.url += Mastodon.formEncodeParams(finalParams); + } + + callback(null, requestOptions); + } + + /** + * Make HTTP request to Mastodon REST API. + * + * @param {Object} requestOptions + * @param {Object} mastodonOptions + * @param {String} method "GET", "POST", or "DELETE" + * @param {Function} callback + * @private + */ + + }, { + key: 'stream', + value: function stream(path, params) { + var mastodonOptions = params && params.mastodon_options || {}; + + var streamingConnection = new _streamingApiConnection2.default(); + this._buildRequestOptions('GET', path, params, function (err, requestOptions) { + if (err) { + // surface this on the streamingConnection instance + // (where a user may register their error handler) + streamingConnection.emit('error', err); + return; + } + // set the properties required to start the connection + streamingConnection.requestOptions = requestOptions; + streamingConnection.mastodonOptions = mastodonOptions; + + process.nextTick(function () { + streamingConnection.start(); + }); + }); + return streamingConnection; + } + }, { + key: 'auth', + set: function set(auth) { + var self = this; + _settings.REQUIRED_KEYS_FOR_AUTH.forEach(function (k) { + if (auth[k]) { + self.config[k] = auth[k]; + } + }); + }, + get: function get() { + return this.config; + } + }], [{ + key: '_doRESTAPIRequest', + value: function _doRESTAPIRequest(requestOptions, mastodonOptions, method, callback) { + var requestMethod = _request2.default[method.toLowerCase()]; + var request = requestMethod(requestOptions); + + var body = ''; + var response = void 0; + + request.on('response', function (res) { + response = res; + // read data from `request` object which contains the decompressed HTTP response body, + // `response` is the unmodified http.IncomingMessage object + // which may contain compressed data + request.on('data', function (chunk) { + body += chunk.toString('utf8'); + }); + // we're done reading the response + request.on('end', function () { + if (body !== '') { + try { + body = _jsonBigint2.default.parse(body); + } catch (jsonDecodeError) { + // there was no transport-level error, + // but a JSON object could not be decoded from the request body + // surface this to the caller + var err = _helpers2.default.makeMastodonError('JSON decode error: Mastodon HTTP response body was not valid JSON'); + err.statusCode = response ? response.statusCode : null; + err.allErrors.concat({ error: jsonDecodeError.toString() }); + callback(err, body, response); + return; + } + } + + if ((typeof body === 'undefined' ? 'undefined' : _typeof(body)) === 'object' && (body.error || body.errors)) { + // we got a Mastodon API-level error response + // place the errors in the HTTP response body + // into the Error object and pass control to caller + var _err = _helpers2.default.makeMastodonError('Mastodon API Error'); + _err.statusCode = response ? response.statusCode : null; + _err = _helpers2.default.attachBodyInfoToError(_err, body); + callback(_err, body, response); + return; + } + + // success case - no errors in HTTP response body + callback(null, body, response); + }); + + request.on('error', function (err) { + // transport-level error occurred - likely a socket error + if (mastodonOptions.retry && _settings.STATUS_CODES_TO_ABORT_ON.includes(err.statusCode)) { + // retry the request since retries were specified + // and we got a status code we should retry on + // FIXME + // this.request(method, path, params, callback); + } else { + // pass the transport-level error to the caller + err.statusCode = null; + err.code = null; + err.allErrors = []; + err = _helpers2.default.attachBodyInfoToError(err, body); + callback(err, body, response); + } + }); + }); + } + }, { + key: 'formEncodeParams', + value: function formEncodeParams(params, noQuestionMark) { + var encoded = ''; + Object.keys(params).forEach(function (key) { + var value = params[key]; + if (encoded === '' && !noQuestionMark) { + encoded = '?'; + } else { + encoded += '&'; + } + + if (Array.isArray(value)) { + value.forEach(function (v) { + encoded += encodeURIComponent(key) + '[]=' + encodeURIComponent(v) + '&'; + }); + } else { + encoded += encodeURIComponent(key) + '=' + encodeURIComponent(value); + } + }); + return encoded; + } + }, { + key: '_validateConfigOrThrow', + value: function _validateConfigOrThrow(config) { + if ((typeof config === 'undefined' ? 'undefined' : _typeof(config)) !== 'object') { + throw new TypeError('config must be object, got ' + (typeof config === 'undefined' ? 'undefined' : _typeof(config)) + '.'); + } + + if (typeof config.timeout_ms !== 'undefined' && isNaN(Number(config.timeout_ms))) { + throw new TypeError('config parameter \'timeout_ms\' must be a Number, got ' + config.timeout_ms + '.'); + } + + _settings.REQUIRED_KEYS_FOR_AUTH.forEach(function (reqKey) { + if (!config[reqKey]) { + throw new Error('Mastodon config must include \'' + reqKey + '\' when using \'user_auth\''); + } + }); + } + }, { + key: 'createOAuthApp', + value: function createOAuthApp() { + var url = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : _settings.DEFAULT_OAUTH_APPS_ENDPOINT; + var clientName = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 'mastodon-node'; + var scopes = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : 'read write follow'; + var redirectUri = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : 'urn:ietf:wg:oauth:2.0:oob'; + var webSite = arguments.length > 4 && arguments[4] !== undefined ? arguments[4] : null; + + return new Promise(function (resolve, reject) { + _request2.default.post({ + url: url, + form: { + client_name: clientName, + website: webSite, + redirect_uris: redirectUri, + scopes: scopes + } + }, function (err, res, body) { + if (err) { + reject(err); + return; + } + try { + body = _jsonBigint2.default.parse(body); + } catch (e) { + reject(new Error('Error parsing body ' + body)); + } + resolve(body); + }); + }); + } + }, { + key: 'getAuthorizationUrl', + value: function getAuthorizationUrl(clientId, clientSecret) { + var baseUrl = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : _settings.DEFAULT_REST_BASE; + var scope = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : 'read write follow'; + var redirectUri = arguments.length > 4 && arguments[4] !== undefined ? arguments[4] : 'urn:ietf:wg:oauth:2.0:oob'; + + return new Promise(function (resolve) { + var oauth = new _oauth.OAuth2(clientId, clientSecret, baseUrl, null, '/oauth/token'); + var url = oauth.getAuthorizeUrl({ + redirect_uri: redirectUri, + response_type: 'code', + client_id: clientId, + scope: scope + }); + resolve(url); + }); + } + }, { + key: 'getAccessToken', + value: function getAccessToken(clientId, clientSecret, authorizationCode) { + var baseUrl = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : _settings.DEFAULT_REST_BASE; + var redirectUri = arguments.length > 4 && arguments[4] !== undefined ? arguments[4] : 'urn:ietf:wg:oauth:2.0:oob'; + + return new Promise(function (resolve, reject) { + var oauth = new _oauth.OAuth2(clientId, clientSecret, baseUrl, null, '/oauth/token'); + oauth.getOAuthAccessToken(authorizationCode, { + grant_type: 'authorization_code', + redirect_uri: redirectUri + }, function (err, accessToken /* , refreshToken, res */) { + if (err) { + reject(err); + return; + } + resolve(accessToken); + }); + }); + } + }]); + + return Mastodon; +}(); + +module.exports = Mastodon; \ No newline at end of file diff --git a/lib/parser.js b/lib/parser.js new file mode 100644 index 0000000..ccacc77 --- /dev/null +++ b/lib/parser.js @@ -0,0 +1,97 @@ +'use strict'; + +Object.defineProperty(exports, "__esModule", { + value: true +}); + +var _createClass = function () { function defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } return function (Constructor, protoProps, staticProps) { if (protoProps) defineProperties(Constructor.prototype, protoProps); if (staticProps) defineProperties(Constructor, staticProps); return Constructor; }; }(); + +var _events = require('events'); + +var _jsonBigint = require('json-bigint'); + +var _jsonBigint2 = _interopRequireDefault(_jsonBigint); + +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } + +function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } } + +function _possibleConstructorReturn(self, call) { if (!self) { throw new ReferenceError("this hasn't been initialised - super() hasn't been called"); } return call && (typeof call === "object" || typeof call === "function") ? call : self; } + +function _inherits(subClass, superClass) { if (typeof superClass !== "function" && superClass !== null) { throw new TypeError("Super expression must either be null or a function, not " + typeof superClass); } subClass.prototype = Object.create(superClass && superClass.prototype, { constructor: { value: subClass, enumerable: false, writable: true, configurable: true } }); if (superClass) Object.setPrototypeOf ? Object.setPrototypeOf(subClass, superClass) : subClass.__proto__ = superClass; } + +var Parser = function (_EventEmitter) { + _inherits(Parser, _EventEmitter); + + function Parser() { + _classCallCheck(this, Parser); + + var _this = _possibleConstructorReturn(this, (Parser.__proto__ || Object.getPrototypeOf(Parser)).call(this)); + + _this.message = ''; + return _this; + } + + _createClass(Parser, [{ + key: 'parse', + value: function parse(chunk) { + // skip heartbeats + if (chunk === ':thump\n') { + this.emit('heartbeat', {}); + return; + } + + this.message += chunk; + chunk = this.message; + + var size = chunk.length; + var start = 0; + var offset = 0; + var curr = void 0; + var next = void 0; + + while (offset < size) { + curr = chunk[offset]; + next = chunk[offset + 1]; + + if (curr === '\n' && next === '\n') { + var piece = chunk.slice(start, offset); + + offset += 2; + start = offset; + + /* eslint-disable no-continue */ + if (!piece.length) continue; // empty object + + var root = piece.split('\n'); + + // should never happen, as long as mastodon doesn't change API messages + if (root.length !== 2) continue; + + // remove event and data markers + var event = root[0].substr(7); + var data = root[1].substr(6); + + try { + data = _jsonBigint2.default.parse(data); + } catch (err) { + this.emit('error', new Error('Error parsing API reply: \'' + piece + '\', error message: \'' + err + '\'')); + } finally { + if (data) { + // filter + this.emit('element', { event: event, data: data }); + } + // eslint-disable-next-line no-unsafe-finally + continue; + } + } + offset++; + } + this.message = chunk.slice(start, size); + } + }]); + + return Parser; +}(_events.EventEmitter); + +exports.default = Parser; \ No newline at end of file diff --git a/lib/settings.js b/lib/settings.js new file mode 100644 index 0000000..da3a0e9 --- /dev/null +++ b/lib/settings.js @@ -0,0 +1,13 @@ +'use strict'; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +var STATUS_CODES_TO_ABORT_ON = exports.STATUS_CODES_TO_ABORT_ON = [400, 401, 403, 404, 406, 410, 422]; +var REQUIRED_KEYS_FOR_AUTH = exports.REQUIRED_KEYS_FOR_AUTH = ['access_token']; + +var DEFAULT_REST_BASE = exports.DEFAULT_REST_BASE = 'https://mastodon.social'; +var DEFAULT_REST_API_POSTFIX = exports.DEFAULT_REST_API_POSTFIX = '/api/v1/'; +var DEFAULT_REST_ROOT = exports.DEFAULT_REST_ROOT = DEFAULT_REST_BASE + DEFAULT_REST_API_POSTFIX; + +var DEFAULT_OAUTH_APPS_ENDPOINT = exports.DEFAULT_OAUTH_APPS_ENDPOINT = DEFAULT_REST_ROOT + 'apps'; \ No newline at end of file diff --git a/lib/streaming-api-connection.js b/lib/streaming-api-connection.js new file mode 100644 index 0000000..8631ac5 --- /dev/null +++ b/lib/streaming-api-connection.js @@ -0,0 +1,313 @@ +'use strict'; + +Object.defineProperty(exports, "__esModule", { + value: true +}); + +var _createClass = function () { function defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } return function (Constructor, protoProps, staticProps) { if (protoProps) defineProperties(Constructor.prototype, protoProps); if (staticProps) defineProperties(Constructor, staticProps); return Constructor; }; }(); + +var _request = require('request'); + +var _request2 = _interopRequireDefault(_request); + +var _events = require('events'); + +var _jsonBigint = require('json-bigint'); + +var _jsonBigint2 = _interopRequireDefault(_jsonBigint); + +var _helpers = require('./helpers'); + +var _helpers2 = _interopRequireDefault(_helpers); + +var _parser = require('./parser'); + +var _parser2 = _interopRequireDefault(_parser); + +var _settings = require('./settings'); + +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } + +function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } } + +function _possibleConstructorReturn(self, call) { if (!self) { throw new ReferenceError("this hasn't been initialised - super() hasn't been called"); } return call && (typeof call === "object" || typeof call === "function") ? call : self; } + +function _inherits(subClass, superClass) { if (typeof superClass !== "function" && superClass !== null) { throw new TypeError("Super expression must either be null or a function, not " + typeof superClass); } subClass.prototype = Object.create(superClass && superClass.prototype, { constructor: { value: subClass, enumerable: false, writable: true, configurable: true } }); if (superClass) Object.setPrototypeOf ? Object.setPrototypeOf(subClass, superClass) : subClass.__proto__ = superClass; } + +var StreamingAPIConnection = function (_EventEmitter) { + _inherits(StreamingAPIConnection, _EventEmitter); + + function StreamingAPIConnection(requestOptions, mastodonOptions) { + _classCallCheck(this, StreamingAPIConnection); + + var _this = _possibleConstructorReturn(this, (StreamingAPIConnection.__proto__ || Object.getPrototypeOf(StreamingAPIConnection)).call(this)); + + _this.requestOptions = requestOptions; + _this.mastodonOptions = mastodonOptions; + return _this; + } + + /** + * Resets the connection. + * - clears request, response, parser + * - removes scheduled reconnect handle (if one was scheduled) + * - stops the stall abort timeout handle (if one was scheduled) + */ + + + _createClass(StreamingAPIConnection, [{ + key: '_resetConnection', + value: function _resetConnection() { + if (this.request) { + // clear our reference to the `request` instance + this.request.removeAllListeners(); + this.request.destroy(); + } + + if (this.response) { + // clear our reference to the http.IncomingMessage instance + this.response.removeAllListeners(); + this.response.destroy(); + } + + if (this.parser) { + this.parser.removeAllListeners(); + } + + // ensure a scheduled reconnect does not occur (if one was scheduled) + // this can happen if we get a close event before .stop() is called + clearTimeout(this._scheduledReconnect); + delete this._scheduledReconnect; + + // clear our stall abort timeout + this._stopStallAbortTimeout(); + } + + /** + * Resets the parameters used in determining the next reconnect time + */ + + }, { + key: '_resetRetryParams', + value: function _resetRetryParams() { + // delay for next reconnection attempt + this._connectInterval = 0; + // flag indicating whether we used a 0-delay reconnect + this._usedFirstReconnect = false; + } + }, { + key: '_startPersistentConnection', + value: function _startPersistentConnection() { + var self = this; + self._resetConnection(); + self._setupParser(); + self._resetStallAbortTimeout(); + + self.request = _request2.default.get(self.requestOptions); + self.emit('connect', self.request); + self.request.on('response', function (response) { + // reset our reconnection attempt flag so next attempt goes through with 0 delay + // if we get a transport-level error + self._usedFirstReconnect = false; + // start a stall abort timeout handle + self._resetStallAbortTimeout(); + self.response = response; + if (_settings.STATUS_CODES_TO_ABORT_ON.includes(response.statusCode)) { + var body = ''; + + self.response.on('data', function (chunk) { + body += chunk.toString('utf8'); + + try { + body = _jsonBigint2.default.parse(body); + } catch (jsonDecodeError) { + // if non-JSON text was returned, we'll just attach it to the error as-is + } + + var error = _helpers2.default.makeMastodonError('Bad Streaming API request: ' + self.response.statusCode); + error.statusCode = self.response.statusCode; + error = _helpers2.default.attachBodyInfoToError(error, body); + self.emit('error', error); + // stop the stream explicitly so we don't reconnect + self.stop(); + body = null; + }); + } else { + self.response.on('data', function (chunk) { + self._connectInterval = 0; + + self._resetStallAbortTimeout(); + self.parser.parse(chunk.toString('utf8')); + }); + + self.response.on('error', function (err) { + // expose response errors on twit instance + self.emit('error', err); + }); + + // connected without an error response from Dweet.io, emit `connected` event + // this must be emitted after all its event handlers are bound + // so the reference to `self.response` is not + // interfered-with by the user until it is emitted + self.emit('connected', self.response); + } + }); + self.request.on('close', self._onClose.bind(self)); + self.request.on('error', function () { + self._scheduleReconnect.bind(self); + }); + return self; + } + + /** + * Handle when the request or response closes. + * Schedule a reconnect + * + */ + + }, { + key: '_onClose', + value: function _onClose() { + var self = this; + self._stopStallAbortTimeout(); + if (self._scheduledReconnect) { + // if we already have a reconnect scheduled, don't schedule another one. + // this race condition can happen if the http.ClientRequest + // and http.IncomingMessage both emit `close` + return; + } + self._scheduleReconnect(); + } + + /** + * Kick off the http request, and persist the connection + */ + + }, { + key: 'start', + value: function start() { + this._resetRetryParams(); + this._startPersistentConnection(); + return this; + } + + /** + * Abort the http request, stop scheduled reconnect (if one was scheduled) and clear state + */ + + }, { + key: 'stop', + value: function stop() { + // clear connection variables and timeout handles + this._resetConnection(); + this._resetRetryParams(); + return this; + } + + /** + * Stop and restart the stall abort timer (called when new data is received) + * + * If we go 90s without receiving data from dweet.io, we abort the request & reconnect. + */ + + }, { + key: '_resetStallAbortTimeout', + value: function _resetStallAbortTimeout() { + var self = this; + // stop the previous stall abort timer + self._stopStallAbortTimeout(); + // start a new 90s timeout to trigger a close & reconnect if no data received + self._stallAbortTimeout = setTimeout(function () { + self._scheduleReconnect(); + }, 90000); + return this; + } + }, { + key: '_stopStallAbortTimeout', + value: function _stopStallAbortTimeout() { + clearTimeout(this._stallAbortTimeout); + // mark the timer as `null` so it is clear + // via introspection that the timeout is not scheduled + delete this._stallAbortTimeout; + return this; + } + + /** + * Computes the next time a reconnect should occur (based on the last HTTP response received) + * and starts a timeout handle to begin reconnecting after `self._connectInterval` passes. + * + * @return {Undefined} + */ + + }, { + key: '_scheduleReconnect', + value: function _scheduleReconnect() { + var self = this; + if (self.response && self.response.statusCode === 420) { + // start with a 1 minute wait and double each attempt + if (!self._connectInterval) { + self._connectInterval = 60000; + } else { + self._connectInterval *= 2; + } + } else if (self.response && String(self.response.statusCode).charAt(0) === '5') { + // 5xx errors + // start with a 5s wait, double each attempt up to 320s + if (!self._connectInterval) { + self._connectInterval = 5000; + } else if (self._connectInterval < 320000) { + self._connectInterval *= 2; + } else { + self._connectInterval = 320000; + } + } else { + // we did not get an HTTP response from our last connection attempt. + // DNS/TCP error, or a stall in the stream (and stall timer closed the connection) + // eslint-disable-next-line no-lonely-if + if (!self._usedFirstReconnect) { + // first reconnection attempt on a valid connection should occur immediately + self._connectInterval = 0; + self._usedFirstReconnect = true; + } else if (self._connectInterval < 16000) { + // linearly increase delay by 250ms up to 16s + self._connectInterval += 250; + } else { + // cap out reconnect interval at 16s + self._connectInterval = 16000; + } + } + + // schedule the reconnect + self._scheduledReconnect = setTimeout(function () { + self._startPersistentConnection(); + }, self._connectInterval); + self.emit('reconnect', self.request, self.response, self._connectInterval); + } + }, { + key: '_setupParser', + value: function _setupParser() { + var self = this; + self.parser = new _parser2.default(); + + self.parser.on('element', function (msg) { + self.emit('message', msg); + }); + self.parser.on('error', function (err) { + self.emit('parser-error', err); + }); + self.parser.on('connection-limit-exceeded', function (err) { + self.emit('error', err); + }); + } + }, { + key: '_handleDisconnect', + value: function _handleDisconnect(msg) { + this.emit('disconnect', msg); + this.stop(); + } + }]); + + return StreamingAPIConnection; +}(_events.EventEmitter); + +exports.default = StreamingAPIConnection; \ No newline at end of file diff --git a/package.json b/package.json index 27fce2d..2916310 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ ], "dependencies": { "gulp-eslint": "^3.0.1", + "json-bigint": "^0.3.0", "mime": "^1.3.4", "oauth": "^0.9.15", "readline": "^1.3.0", diff --git a/src/mastodon.js b/src/mastodon.js index e2a11aa..eb48081 100644 --- a/src/mastodon.js +++ b/src/mastodon.js @@ -2,6 +2,7 @@ import assert from 'assert' import util from 'util' import { OAuth2 } from 'oauth' import Request from 'request' +import JSONbig from 'json-bigint' import Helpers from './helpers' import StreamingAPIConnection from './streaming-api-connection' @@ -204,7 +205,7 @@ class Mastodon { request.on('end', () => { if (body !== '') { try { - body = JSON.parse(body) + body = JSONbig.parse(body) } catch (jsonDecodeError) { // there was no transport-level error, // but a JSON object could not be decoded from the request body @@ -324,7 +325,7 @@ class Mastodon { return } try { - body = JSON.parse(body) + body = JSONbig.parse(body) } catch (e) { reject(new Error(`Error parsing body ${body}`)) } diff --git a/src/parser.js b/src/parser.js index a94c9d4..d351998 100644 --- a/src/parser.js +++ b/src/parser.js @@ -1,4 +1,5 @@ import { EventEmitter } from 'events' +import JSONbig from 'json-bigint' class Parser extends EventEmitter { @@ -46,7 +47,7 @@ class Parser extends EventEmitter { let data = root[1].substr(6) try { - data = JSON.parse(data) + data = JSONbig.parse(data) } catch (err) { this.emit('error', new Error(`Error parsing API reply: '${piece}', error message: '${err}'`)) } finally { diff --git a/src/streaming-api-connection.js b/src/streaming-api-connection.js index 4b4e908..b326beb 100644 --- a/src/streaming-api-connection.js +++ b/src/streaming-api-connection.js @@ -1,12 +1,13 @@ import Request from 'request' - import { EventEmitter } from 'events' +import JSONbig from 'json-bigint' import Helpers from './helpers' import Parser from './parser' import { STATUS_CODES_TO_ABORT_ON } from './settings' + class StreamingAPIConnection extends EventEmitter { constructor(requestOptions, mastodonOptions) { @@ -79,7 +80,7 @@ class StreamingAPIConnection extends EventEmitter { body += chunk.toString('utf8') try { - body = JSON.parse(body) + body = JSONbig.parse(body) } catch (jsonDecodeError) { // if non-JSON text was returned, we'll just attach it to the error as-is } diff --git a/yarn.lock b/yarn.lock index c9f0759..abeb19b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -630,6 +630,11 @@ beeper@^1.0.0: version "1.1.1" resolved "https://registry.yarnpkg.com/beeper/-/beeper-1.1.1.tgz#e6d5ea8c5dad001304a70b22638447f69cb2f809" +bignumber.js@^7.0.0: + version "7.2.1" + resolved "https://registry.yarnpkg.com/bignumber.js/-/bignumber.js-7.2.1.tgz#80c048759d826800807c4bfd521e50edbba57a5f" + integrity sha512-S4XzBk5sMB+Rcb/LNcpzXr57VRTxgAvaAEDAl1AwRx27j00hT84O6OkteE7u8UB3NuaaygCRrEpqox4uDOrbdQ== + boom@2.x.x: version "2.10.1" resolved "https://registry.yarnpkg.com/boom/-/boom-2.10.1.tgz#39c8918ceff5799f83f9492a848f625add0c766f" @@ -1885,6 +1890,13 @@ jsesc@~0.5.0: version "0.5.0" resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-0.5.0.tgz#e7dee66e35d6fc16f710fe91d5cf69f70f08911d" +json-bigint@^0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/json-bigint/-/json-bigint-0.3.0.tgz#0ccd912c4b8270d05f056fbd13814b53d3825b1e" + integrity sha1-DM2RLEuCcNBfBW+9E4FLU9OCWx4= + dependencies: + bignumber.js "^7.0.0" + json-schema@0.2.3: version "0.2.3" resolved "https://registry.yarnpkg.com/json-schema/-/json-schema-0.2.3.tgz#b480c892e59a2f05954ce727bd3f2a4e882f9e13" @@ -2302,9 +2314,10 @@ mute-stream@0.0.5: version "0.0.5" resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.5.tgz#8fbfabb0a98a253d3184331f9e8deb7372fac6c0" -natives@1.1.3, natives@^1.1.0: - version "1.1.3" - resolved "https://registry.yarnpkg.com/natives/-/natives-1.1.3.tgz#44a579be64507ea2d6ed1ca04a9415915cf75558" +natives@1.1.6, natives@^1.1.0: + version "1.1.6" + resolved "https://registry.yarnpkg.com/natives/-/natives-1.1.6.tgz#a603b4a498ab77173612b9ea1acdec4d980f00bb" + integrity sha512-6+TDFewD4yxY14ptjKaS63GVdtKiES1pTPyxn9Jb0rBqPMZ7VcCiooEhPNsr+mqHtMGxa/5c/HhcC4uPEUw/nA== natural-compare@^1.4.0: version "1.4.0"