diff --git a/.jshintrc b/.jshintrc index dabc7ef988..9be934a0dd 100644 --- a/.jshintrc +++ b/.jshintrc @@ -42,7 +42,7 @@ "PresenceMessage": true, "ProtocolMessage": true, "Stats": true, - "ConnectionError": true, + "ConnectionErrors": true, "MessageQueue": true, "Protocol": true, "ConnectionManager": true, diff --git a/Gruntfile.js b/Gruntfile.js index e9590b1e44..3416d2ae32 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -48,7 +48,9 @@ module.exports = function (grunt) { dirs: dirs, pkgVersion: grunt.file.readJSON('package.json').version, webpack: { - config: webpackConfig + all: Object.values(webpackConfig), + node: webpackConfig.node, + browser: [webpackConfig.browser, webpackConfig.browserMin] } }; @@ -110,7 +112,17 @@ module.exports = function (grunt) { grunt.registerTask('build', [ 'checkGitSubmodules', - 'webpack' + 'webpack:all' + ]); + + grunt.registerTask('build:node', [ + 'checkGitSubmodules', + 'webpack:node' + ]); + + grunt.registerTask('build:browser', [ + 'checkGitSubmodules', + 'webpack:browser' ]); grunt.registerTask('check-closure-compiler', [ @@ -126,12 +138,12 @@ module.exports = function (grunt) { grunt.registerTask('test', ['test:node']); grunt.registerTask('test:node', 'Build the library and run the node test suite\nOptions\n --test [tests] e.g. --test test/rest/auth.js', - ['build', 'mocha'] + ['build:node', 'mocha'] ); grunt.registerTask('test:webserver', 'Launch the Mocha test web server on http://localhost:3000/', - ['build', 'checkGitSubmodules', 'mocha:webserver'] + ['build:browser', 'checkGitSubmodules', 'mocha:webserver'] ); grunt.registerTask('release:refresh-pkgVersion', diff --git a/ably.d.ts b/ably.d.ts index 270262dfc9..394b03fbde 100644 --- a/ably.d.ts +++ b/ably.d.ts @@ -130,7 +130,7 @@ declare namespace Types { }, callback: (shouldRecover: boolean) => void) => void); /** - * Use a non-secure connection connection. By default, a TLS connection is used to connect to Ably + * Use a non-secure connection. By default, a TLS connection is used to connect to Ably */ tls?: boolean; tlsPort?: number; @@ -153,6 +153,7 @@ declare namespace Types { httpOpenTimeout?: number; httpRequestTimeout?: number; + plugins?: { vcdiff?: any }; } interface AuthOptions { @@ -178,7 +179,7 @@ declare namespace Types { /** * Optional clientId that can be used to specify the identity for this client. In most cases - * it is preferable to instead specift a clientId in the token issued to this client. + * it is preferable to instead specify a clientId in the token issued to this client. */ clientId?: string; } @@ -265,7 +266,7 @@ declare namespace Types { type ChannelParams = { [key: string]: string }; - type ChannelMode = 'PUBLISH' | 'SUBSCRIBE' | 'PRESENCE' | 'PRESENCE_SUBSCRIBE'; + type ChannelMode = 'PUBLISH' | 'SUBSCRIBE' | 'PRESENCE' | 'PRESENCE_SUBSCRIBE' | 'ATTACH_RESUME'; type ChannelModes = Array; interface ChannelOptions { diff --git a/browser/fragments/platform-browser.js b/browser/fragments/platform-browser.ts similarity index 72% rename from browser/fragments/platform-browser.js rename to browser/fragments/platform-browser.ts index 64d1882fd5..bfaa895e1e 100644 --- a/browser/fragments/platform-browser.js +++ b/browser/fragments/platform-browser.ts @@ -1,4 +1,8 @@ import msgpack from '../lib/util/msgpack'; +import { TypedArray, IPlatform } from '../../common/types/IPlatform'; + +declare var MozWebSocket: typeof WebSocket; // For Chrome 14 and Firefox 7 +declare var msCrypto: typeof crypto; // for IE11 if(typeof Window === 'undefined' && typeof WorkerGlobalScope === 'undefined') { console.log("Warning: this distribution of Ably is intended for browsers. On nodejs, please use the 'ably' package on npm"); @@ -8,21 +12,21 @@ function allowComet() { /* xhr requests from local files are unreliable in some browsers, such as Chrome 65 and higher -- see eg * https://stackoverflow.com/questions/49256429/chrome-65-unable-to-make-post-requests-from-local-files-to-flask * So if websockets are supported, then just forget about comet transports and use that */ - var loc = global.location; + const loc = global.location; return (!global.WebSocket || !loc || !loc.origin || loc.origin.indexOf("http") > -1); } -var userAgent = global.navigator && global.navigator.userAgent.toString(); -var currentUrl = global.location && global.location.href; +const userAgent = global.navigator && global.navigator.userAgent.toString(); +const currentUrl = global.location && global.location.href; -var Platform = { - agent: 'browser', +const Platform: IPlatform = { + agent: 'browser', logTimestamps: true, userAgent: userAgent, currentUrl: currentUrl, - noUpgrade: userAgent && userAgent.match(/MSIE\s8\.0/), + noUpgrade: userAgent && !!userAgent.match(/MSIE\s8\.0/), binaryType: 'arraybuffer', - WebSocket: global.WebSocket || global.MozWebSocket, + WebSocket: global.WebSocket || MozWebSocket, xhrSupported: global.XMLHttpRequest && 'withCredentials' in new XMLHttpRequest(), jsonpSupported: typeof(document) !== 'undefined', allowComet: allowComet(), @@ -34,10 +38,10 @@ var Platform = { preferBinary: false, ArrayBuffer: global.ArrayBuffer, atob: global.atob, - nextTick: typeof global.setImmediate !== 'undefined' ? global.setImmediate.bind(global) : function(f) { setTimeout(f, 0); }, + nextTick: typeof global.setImmediate !== 'undefined' ? global.setImmediate.bind(global) : function(f: () => void) { setTimeout(f, 0); }, addEventListener: global.addEventListener, inspect: JSON.stringify, - stringByteSize: function(str) { + stringByteSize: function(str: string) { /* str.length will be an underestimate for non-ascii strings. But if we're * in a browser too old to support TextDecoder, not much we can do. Better * to underestimate, so if we do go over-size, the server will reject the @@ -53,13 +57,13 @@ var Platform = { if (crypto === undefined) { return undefined; } - return function(arr, callback) { + return function(arr: TypedArray, callback?: (error: Error | null) => void) { crypto.getRandomValues(arr); if(callback) { callback(null); } }; - })(global.crypto || global.msCrypto) // mscrypto for IE11 + })(global.crypto || msCrypto) }; export default Platform; diff --git a/browser/fragments/platform-reactnative.js b/browser/fragments/platform-reactnative.ts similarity index 71% rename from browser/fragments/platform-reactnative.js rename to browser/fragments/platform-reactnative.ts index 2e2615bcb1..82e61e9d49 100644 --- a/browser/fragments/platform-reactnative.js +++ b/browser/fragments/platform-reactnative.ts @@ -1,27 +1,28 @@ import msgpack from '../lib/util/msgpack'; import { parse as parseBase64 } from 'crypto-js/build/enc-base64'; +import { IPlatform } from '../../common/types/IPlatform'; -var Platform = { - agent: 'reactnative', +const Platform: IPlatform = { + agent: 'reactnative', logTimestamps: true, noUpgrade: false, binaryType: 'arraybuffer', WebSocket: WebSocket, - xhrSupported: XMLHttpRequest, + xhrSupported: true, allowComet: true, jsonpSupported: false, streamingSupported: true, useProtocolHeartbeats: true, createHmac: null, msgpack: msgpack, - supportsBinary: (typeof TextDecoder !== 'undefined') && TextDecoder, + supportsBinary: ((typeof TextDecoder !== 'undefined') && TextDecoder) ? true : false, preferBinary: false, ArrayBuffer: (typeof ArrayBuffer !== 'undefined') && ArrayBuffer, atob: global.atob, - nextTick: function(f) { setTimeout(f, 0); }, + nextTick: function(f: Function) { setTimeout(f, 0); }, addEventListener: null, inspect: JSON.stringify, - stringByteSize: function(str) { + stringByteSize: function(str: string) { /* str.length will be an underestimate for non-ascii strings. But if we're * in a browser too old to support TextDecoder, not much we can do. Better * to underestimate, so if we do go over-size, the server will reject the @@ -34,8 +35,8 @@ var Platform = { TextDecoder: global.TextDecoder, Promise: global.Promise, getRandomWordArray: (function(RNRandomBytes) { - return function(byteLength, callback) { - RNRandomBytes.randomBytes(byteLength, function(err, base64String) { + return function(byteLength: number, callback: Function) { + RNRandomBytes.randomBytes(byteLength, function(err: Error, base64String: string) { callback(err, !err && parseBase64(base64String)); }); }; diff --git a/browser/lib/transport/jsonptransport.js b/browser/lib/transport/jsonptransport.js index 647c5005dc..b4e6bb6c40 100644 --- a/browser/lib/transport/jsonptransport.js +++ b/browser/lib/transport/jsonptransport.js @@ -1,4 +1,4 @@ -import Utils from '../../../common/lib/util/utils'; +import * as Utils from '../../../common/lib/util/utils'; import CometTransport from '../../../common/lib/transport/comettransport'; import Platform from 'platform'; import EventEmitter from '../../../common/lib/util/eventemitter'; @@ -6,6 +6,8 @@ import Http from 'platform-http'; import ErrorInfo from '../../../common/lib/types/errorinfo'; import Defaults from '../../../common/lib/util/defaults'; import Logger from '../../../common/lib/util/logger'; +import HttpStatusCodes from '../../../common/constants/HttpStatusCodes'; +import XHRStates from '../../../common/constants/XHRStates'; var JSONPTransport = function(connectionManager) { var noop = function() {}; @@ -128,7 +130,7 @@ var JSONPTransport = function(connectionManager) { if(message.statusCode) { /* Handle as enveloped jsonp, as all jsonp transport uses should be */ var response = message.response; - if(message.statusCode == 204) { + if(message.statusCode == HttpStatusCodes.NoContent) { self.complete(null, null, null, message.statusCode); } else if(!response) { self.complete(new ErrorInfo('Invalid server response: no envelope detected', null, 500)); @@ -148,7 +150,7 @@ var JSONPTransport = function(connectionManager) { } }; - var timeout = (this.requestMode == CometTransport.REQ_SEND) ? this.timeouts.httpRequestTimeout : this.timeouts.recvTimeout; + var timeout = (this.requestMode == XHRStates.REQ_SEND) ? this.timeouts.httpRequestTimeout : this.timeouts.recvTimeout; this.timer = setTimeout(function() { self.abort(); }, timeout); head.insertBefore(script, head.firstChild); }; @@ -187,7 +189,7 @@ var JSONPTransport = function(connectionManager) { if(Platform.jsonpSupported && !Http.Request) { Http.Request = function(method, rest, uri, headers, params, body, callback) { - var req = createRequest(uri, headers, params, body, CometTransport.REQ_SEND, rest && rest.options.timeouts, method); + var req = createRequest(uri, headers, params, body, XHRStates.REQ_SEND, rest && rest.options.timeouts, method); req.once('complete', callback); Utils.nextTick(function() { req.exec(); @@ -205,7 +207,7 @@ var JSONPTransport = function(connectionManager) { checksInProgress = [callback]; Logger.logAction(Logger.LOG_MICRO, '(JSONP)Http.checkConnectivity()', 'Sending; ' + upUrl); - var req = new Request('isTheInternetUp', upUrl, null, null, null, CometTransport.REQ_SEND, Defaults.TIMEOUTS); + var req = new Request('isTheInternetUp', upUrl, null, null, null, XHRStates.REQ_SEND, Defaults.TIMEOUTS); req.once('complete', function(err, response) { var result = !err && response; Logger.logAction(Logger.LOG_MICRO, '(JSONP)Http.checkConnectivity()', 'Result: ' + result); diff --git a/browser/lib/transport/xhrpollingtransport.js b/browser/lib/transport/xhrpollingtransport.js index 95b27138a4..7a87f842ef 100644 --- a/browser/lib/transport/xhrpollingtransport.js +++ b/browser/lib/transport/xhrpollingtransport.js @@ -1,4 +1,4 @@ -import Utils from '../../../common/lib/util/utils'; +import * as Utils from '../../../common/lib/util/utils'; import Logger from '../../../common/lib/util/logger'; import Platform from 'platform'; import CometTransport from '../../../common/lib/transport/comettransport'; diff --git a/browser/lib/transport/xhrrequest.js b/browser/lib/transport/xhrrequest.js deleted file mode 100644 index 9609469bdd..0000000000 --- a/browser/lib/transport/xhrrequest.js +++ /dev/null @@ -1,334 +0,0 @@ -import Utils from '../../../common/lib/util/utils'; -import EventEmitter from '../../../common/lib/util/eventemitter'; -import Platform from 'platform'; -import ErrorInfo from '../../../common/lib/types/errorinfo'; -import Http from 'platform-http'; -import Logger from '../../../common/lib/util/logger'; -import Defaults from '../../../common/lib/util/defaults'; -import BufferUtils from 'platform-bufferutils'; -import DomEvent from '../util/domevent'; - -function getAblyError(responseBody, headers) { - if (Utils.arrIn(Utils.allToLowerCase(Utils.keysArray(headers)), 'x-ably-errorcode')) { - return responseBody.error && ErrorInfo.fromValues(responseBody.error); - } -} - -var XHRRequest = (function() { - var noop = function() {}; - var idCounter = 0; - var pendingRequests = {}; - - var REQ_SEND = 0, - REQ_RECV = 1, - REQ_RECV_POLL = 2, - REQ_RECV_STREAM = 3; - - function clearPendingRequests() { - for(var id in pendingRequests) - pendingRequests[id].dispose(); - } - - var isIE = typeof global !== 'undefined' && global.XDomainRequest; - - function ieVersion() { - var match = navigator.userAgent.toString().match(/MSIE\s([\d.]+)/); - return match && Number(match[1]); - } - - function needJsonEnvelope() { - /* IE 10 xhr bug: http://stackoverflow.com/a/16320339 */ - var version; - return isIE && (version = ieVersion()) && version === 10; - } - - function getHeader(xhr, header) { - return xhr.getResponseHeader && xhr.getResponseHeader(header); - } - - /* Safari mysteriously returns 'Identity' for transfer-encoding when in fact - * it is 'chunked'. So instead, decide that it is chunked when - * transfer-encoding is present or content-length is absent. ('or' because - * when using http2 streaming, there's no transfer-encoding header, but can - * still deduce streaming from lack of content-length) */ - function isEncodingChunked(xhr) { - return xhr.getResponseHeader - && (xhr.getResponseHeader('transfer-encoding') - || !xhr.getResponseHeader('content-length')); - } - - function getHeadersAsObject(xhr) { - var headerPairs = Utils.trim(xhr.getAllResponseHeaders()).split('\r\n'), - headers = {}; - for (var i = 0; i < headerPairs.length; i++) { - var parts = Utils.arrMap(headerPairs[i].split(':'), Utils.trim); - headers[parts[0].toLowerCase()] = parts[1]; - } - return headers; - } - - function XHRRequest(uri, headers, params, body, requestMode, timeouts, method) { - EventEmitter.call(this); - params = params || {}; - params.rnd = Utils.cheapRandStr(); - if(needJsonEnvelope() && !params.envelope) - params.envelope = 'json'; - this.uri = uri + Utils.toQueryString(params); - this.headers = headers || {}; - this.body = body; - this.method = method ? method.toUpperCase() : (Utils.isEmptyArg(body) ? 'GET' : 'POST'); - this.requestMode = requestMode; - this.timeouts = timeouts; - this.timedOut = false; - this.requestComplete = false; - pendingRequests[this.id = String(++idCounter)] = this; - } - Utils.inherits(XHRRequest, EventEmitter); - - var createRequest = XHRRequest.createRequest = function(uri, headers, params, body, requestMode, timeouts, method) { - /* XHR requests are used either with the context being a realtime - * transport, or with timeouts passed in (for when used by a rest client), - * or completely standalone. Use the appropriate timeouts in each case */ - timeouts = timeouts || Defaults.TIMEOUTS; - return new XHRRequest(uri, headers, Utils.copy(params), body, requestMode, timeouts, method); - }; - - XHRRequest.prototype.complete = function(err, body, headers, unpacked, statusCode) { - if(!this.requestComplete) { - this.requestComplete = true; - if(!err && body) { - this.emit('data', body); - } - this.emit('complete', err, body, headers, unpacked, statusCode); - this.dispose(); - } - }; - - XHRRequest.prototype.abort = function() { - this.dispose(); - }; - - XHRRequest.prototype.exec = function() { - var timeout = (this.requestMode == REQ_SEND) ? this.timeouts.httpRequestTimeout : this.timeouts.recvTimeout, - self = this, - timer = this.timer = setTimeout(function() { - self.timedOut = true; - xhr.abort(); - }, timeout), - body = this.body, - method = this.method, - headers = this.headers, - xhr = this.xhr = new XMLHttpRequest(), - accept = headers['accept'], - responseType = 'text'; - - if(!accept) { - headers['accept'] = 'application/json'; - } else if(accept.indexOf('application/x-msgpack') === 0) { - responseType = 'arraybuffer'; - } - - if(body) { - var contentType = headers['content-type'] || (headers['content-type'] = 'application/json'); - if(contentType.indexOf('application/json') > -1 && typeof(body) != 'string') - body = JSON.stringify(body); - } - - // Can probably remove this directive if https://github.com/nodesecurity/eslint-plugin-security/issues/26 is resolved - // eslint-disable-next-line security/detect-non-literal-fs-filename - xhr.open(method, this.uri, true); - xhr.responseType = responseType; - - if ('authorization' in headers) { - xhr.withCredentials = true; - } - - for(var h in headers) - xhr.setRequestHeader(h, headers[h]); - - var errorHandler = function(errorEvent, message, code, statusCode) { - var errorMessage = message + ' (event type: ' + errorEvent.type + ')' + (self.xhr.statusText ? ', current statusText is ' + self.xhr.statusText : ''); - Logger.logAction(Logger.LOG_ERROR, 'Request.on' + errorEvent.type + '()', errorMessage); - self.complete(new ErrorInfo(errorMessage, code, statusCode)); - }; - xhr.onerror = function(errorEvent) { - errorHandler(errorEvent, 'XHR error occurred', null, 400); - } - xhr.onabort = function(errorEvent) { - if(self.timedOut) { - errorHandler(errorEvent, 'Request aborted due to request timeout expiring', null, 408); - } else { - errorHandler(errorEvent, 'Request cancelled', null, 400); - } - }; - xhr.ontimeout = function(errorEvent) { - errorHandler(errorEvent, 'Request timed out', null, 408); - }; - - var streaming, - statusCode, - responseBody, - contentType, - successResponse, - streamPos = 0, - unpacked = false; - - function onResponse() { - clearTimeout(timer); - successResponse = (statusCode < 400); - if(statusCode == 204) { - self.complete(null, null, null, null, statusCode); - return; - } - streaming = (self.requestMode == REQ_RECV_STREAM && successResponse && isEncodingChunked(xhr)); - } - - function onEnd() { - try { - var contentType = getHeader(xhr, 'content-type'), - headers, - responseBody, - /* Be liberal in what we accept; buggy auth servers may respond - * without the correct contenttype, but assume they're still - * responding with json */ - json = contentType ? (contentType.indexOf('application/json') >= 0) : (xhr.responseType == 'text'); - - if(json) { - /* If we requested msgpack but server responded with json, then since - * we set the responseType expecting msgpack, the response will be - * an ArrayBuffer containing json */ - responseBody = (xhr.responseType === 'arraybuffer') ? BufferUtils.utf8Decode(xhr.response) : String(xhr.responseText); - if(responseBody.length) { - responseBody = JSON.parse(responseBody); - } - unpacked = true; - } else { - responseBody = xhr.response; - } - - if(responseBody.response !== undefined) { - /* unwrap JSON envelope */ - statusCode = responseBody.statusCode; - successResponse = (statusCode < 400); - headers = responseBody.headers; - responseBody = responseBody.response; - } else { - headers = getHeadersAsObject(xhr); - } - } catch(e) { - self.complete(new ErrorInfo('Malformed response body from server: ' + e.message, null, 400)); - return; - } - - /* If response is an array, it's an array of protocol messages -- even if - * is contains an error action (hence the nonsuccess statuscode), we can - * consider the request to have succeeded, just pass it on to - * onProtocolMessage to decide what to do */ - if(successResponse || Utils.isArray(responseBody)) { - self.complete(null, responseBody, headers, unpacked, statusCode); - return; - } - - var err = getAblyError(responseBody, headers); - if(!err) { - err = new ErrorInfo('Error response received from server: ' + statusCode + ' body was: ' + Utils.inspect(responseBody), null, statusCode); - } - self.complete(err, responseBody, headers, unpacked, statusCode); - } - - function onProgress() { - responseBody = xhr.responseText; - var bodyEnd = responseBody.length - 1, idx, chunk; - while((streamPos < bodyEnd) && (idx = responseBody.indexOf('\n', streamPos)) > -1) { - chunk = responseBody.slice(streamPos, idx); - streamPos = idx + 1; - onChunk(chunk); - } - } - - function onChunk(chunk) { - try { - chunk = JSON.parse(chunk); - } catch(e) { - self.complete(new ErrorInfo('Malformed response body from server: ' + e.message, null, 400)); - return; - } - self.emit('data', chunk); - } - - function onStreamEnd() { - onProgress(); - self.streamComplete = true; - Utils.nextTick(function() { - self.complete(); - }); - } - - xhr.onreadystatechange = function() { - var readyState = xhr.readyState; - if(readyState < 3) return; - if(xhr.status !== 0) { - if(statusCode === undefined) { - statusCode = xhr.status; - /* IE returns 1223 for 204: http://bugs.jquery.com/ticket/1450 */ - if(statusCode === 1223) statusCode = 204; - onResponse(); - } - if(readyState == 3 && streaming) { - onProgress(); - } else if(readyState == 4) { - if(streaming) - onStreamEnd(); - else - onEnd(); - } - } - }; - xhr.send(body); - }; - - XHRRequest.prototype.dispose = function() { - var xhr = this.xhr; - if(xhr) { - xhr.onreadystatechange = xhr.onerror = xhr.onabort = xhr.ontimeout = noop; - this.xhr = null; - var timer = this.timer; - if(timer) { - clearTimeout(timer); - this.timer = null; - } - if(!this.requestComplete) - xhr.abort(); - } - delete pendingRequests[this.id]; - }; - - if(Platform.xhrSupported) { - if(typeof DomEvent === 'object') { - DomEvent.addUnloadListener(clearPendingRequests); - } - if(typeof(Http) !== 'undefined') { - Http.supportsAuthHeaders = true; - Http.Request = function(method, rest, uri, headers, params, body, callback) { - var req = createRequest(uri, headers, params, body, REQ_SEND, rest && rest.options.timeouts, method); - req.once('complete', callback); - req.exec(); - return req; - }; - - Http.checkConnectivity = function(callback) { - var upUrl = Defaults.internetUpUrl; - Logger.logAction(Logger.LOG_MICRO, '(XHRRequest)Http.checkConnectivity()', 'Sending; ' + upUrl); - Http.getUri(null, upUrl, null, null, function(err, responseText) { - var result = (!err && responseText.replace(/\n/, '') == 'yes'); - Logger.logAction(Logger.LOG_MICRO, '(XHRRequest)Http.checkConnectivity()', 'Result: ' + result); - callback(null, result); - }); - }; - } - } - - return XHRRequest; -})(); - -export default XHRRequest; diff --git a/browser/lib/transport/xhrrequest.ts b/browser/lib/transport/xhrrequest.ts new file mode 100644 index 0000000000..79390e2f8f --- /dev/null +++ b/browser/lib/transport/xhrrequest.ts @@ -0,0 +1,346 @@ +import * as Utils from '../../../common/lib/util/utils'; +import EventEmitter from '../../../common/lib/util/eventemitter'; +import Platform from 'platform'; +import ErrorInfo from '../../../common/lib/types/errorinfo'; +import Http from 'platform-http'; +import Logger from '../../../common/lib/util/logger'; +import Defaults from '../../../common/lib/util/defaults'; +import * as BufferUtils from 'platform-bufferutils'; +import HttpMethods from '../../../common/constants/HttpMethods'; +import IXHRRequest from '../../../common/types/IXHRRequest'; +import { ErrnoException, RequestCallback, RequestParams } from '../../../common/types/http'; +import XHRStates from '../../../common/constants/XHRStates'; +import Rest from '../../../common/lib/client/rest'; + +function isAblyError(responseBody: unknown, headers: Record): responseBody is { error?: ErrorInfo } { + return Utils.arrIn(Utils.allToLowerCase(Utils.keysArray(headers)), 'x-ably-errorcode'); +} + +function getAblyError(responseBody: unknown, headers: Record) { + if (isAblyError(responseBody, headers)) { + return responseBody.error && ErrorInfo.fromValues(responseBody.error); + } +} + +declare const global: { + XDomainRequest: unknown; +} + +const noop = function() {}; +let idCounter = 0; +const pendingRequests: Record = {}; + +const isIE = typeof global !== 'undefined' && global.XDomainRequest; + +function ieVersion() { + const match = navigator.userAgent.toString().match(/MSIE\s([\d.]+)/); + return match && Number(match[1]); +} + +function needJsonEnvelope() { + /* IE 10 xhr bug: http://stackoverflow.com/a/16320339 */ + let version; + return isIE && (version = ieVersion()) && version === 10; +} + +function getHeader(xhr: XMLHttpRequest, header: string) { + return xhr.getResponseHeader && xhr.getResponseHeader(header); +} + +/* Safari mysteriously returns 'Identity' for transfer-encoding when in fact + * it is 'chunked'. So instead, decide that it is chunked when + * transfer-encoding is present or content-length is absent. ('or' because + * when using http2 streaming, there's no transfer-encoding header, but can + * still deduce streaming from lack of content-length) */ +function isEncodingChunked(xhr: XMLHttpRequest) { + return xhr.getResponseHeader + && (xhr.getResponseHeader('transfer-encoding') + || !xhr.getResponseHeader('content-length')); +} + +function getHeadersAsObject(xhr: XMLHttpRequest) { + const headerPairs = Utils.trim(xhr.getAllResponseHeaders()).split('\r\n'); + const headers: Record = {}; + for (let i = 0; i < headerPairs.length; i++) { + const parts = (headerPairs[i].split(':')).map(Utils.trim); + headers[parts[0].toLowerCase()] = parts[1]; + } + return headers; +} + +class XHRRequest extends EventEmitter implements IXHRRequest { + uri: string; + headers: Record; + body: unknown; + method: string; + requestMode: number; + timeouts: Record; + timedOut: boolean; + requestComplete: boolean; + id: string; + streamComplete?: boolean; + xhr?: XMLHttpRequest | null; + timer?: NodeJS.Timeout | number | null; + + constructor(uri: string, headers: Record | null, params: Record, body: unknown, requestMode: number, timeouts: Record, method?: HttpMethods) { + super(); + params = params || {}; + params.rnd = Utils.cheapRandStr(); + if(needJsonEnvelope() && !params.envelope) + params.envelope = 'json'; + this.uri = uri + Utils.toQueryString(params); + this.headers = headers || {}; + this.body = body; + this.method = method ? method.toUpperCase() : (Utils.isEmptyArg(body) ? 'GET' : 'POST'); + this.requestMode = requestMode; + this.timeouts = timeouts; + this.timedOut = false; + this.requestComplete = false; + this.id = String(++idCounter); + pendingRequests[this.id] = this; + } + + static createRequest (uri: string, headers: Record | null, params: RequestParams, body: unknown, requestMode: number, timeouts: Record | null, method?: HttpMethods): XHRRequest { + /* XHR requests are used either with the context being a realtime + * transport, or with timeouts passed in (for when used by a rest client), + * or completely standalone. Use the appropriate timeouts in each case */ + const _timeouts = timeouts || Defaults.TIMEOUTS; + return new XHRRequest(uri, headers, Utils.copy(params) as Record, body, requestMode, _timeouts, method); + } + + complete(err?: ErrorInfo | null, body?: unknown, headers?: Record | null, unpacked?: boolean | null, statusCode?: number): void { + if(!this.requestComplete) { + this.requestComplete = true; + if(!err && body) { + this.emit('data', body); + } + this.emit('complete', err, body, headers, unpacked, statusCode); + this.dispose(); + } + } + + abort(): void { + this.dispose(); + } + + exec(): void { + let headers = this.headers; + const timeout = (this.requestMode == XHRStates.REQ_SEND) ? this.timeouts.httpRequestTimeout : this.timeouts.recvTimeout, + timer = this.timer = setTimeout(() => { + this.timedOut = true; + xhr.abort(); + }, timeout), + method = this.method, + xhr = this.xhr = new XMLHttpRequest(), + accept = headers['accept']; + let body = this.body; + let responseType: XMLHttpRequestResponseType = 'text'; + + if(!accept) { + // Default to JSON + headers['accept'] = 'application/json'; + } else if(accept.indexOf('application/x-msgpack') === 0) { + // Msgpack responses will be typed as ArrayBuffer + responseType = 'arraybuffer'; + } + + if(body) { + const contentType = headers['content-type'] || (headers['content-type'] = 'application/json'); + if(contentType.indexOf('application/json') > -1 && typeof(body) != 'string') + body = JSON.stringify(body); + } + + // Can probably remove this directive if https://github.com/nodesecurity/eslint-plugin-security/issues/26 is resolved + // eslint-disable-next-line security/detect-non-literal-fs-filename + xhr.open(method, this.uri, true); + xhr.responseType = responseType; + + if ('authorization' in headers) { + xhr.withCredentials = true; + } + + for(const h in headers) + xhr.setRequestHeader(h, headers[h]); + + const errorHandler = (errorEvent: ProgressEvent, message: string, code: number | null, statusCode: number) => { + let errorMessage = message + ' (event type: ' + errorEvent.type + ')'; + if (this?.xhr?.statusText) errorMessage += ', current statusText is ' + this.xhr.statusText; + Logger.logAction(Logger.LOG_ERROR, 'Request.on' + errorEvent.type + '()', errorMessage); + this.complete(new ErrorInfo(errorMessage, code, statusCode)); + }; + xhr.onerror = function(errorEvent) { + errorHandler(errorEvent, 'XHR error occurred', null, 400); + } + xhr.onabort = (errorEvent) => { + if(this.timedOut) { + errorHandler(errorEvent, 'Request aborted due to request timeout expiring', null, 408); + } else { + errorHandler(errorEvent, 'Request cancelled', null, 400); + } + }; + xhr.ontimeout = function(errorEvent) { + errorHandler(errorEvent, 'Request timed out', null, 408); + }; + + let streaming: boolean | string; + let statusCode: number; + let successResponse: boolean; + let streamPos = 0; + let unpacked = false; + + const onResponse = () => { + clearTimeout(timer); + successResponse = (statusCode < 400); + if(statusCode == 204) { + this.complete(null, null, null, null, statusCode); + return; + } + streaming = (this.requestMode == XHRStates.REQ_RECV_STREAM && successResponse && isEncodingChunked(xhr)); + } + + const onEnd = () => { + let parsedResponse: any; + try { + const contentType = getHeader(xhr, 'content-type'); + /* Be liberal in what we accept; buggy auth servers may respond + * without the correct contenttype, but assume they're still + * responding with json */ + const json = contentType ? (contentType.indexOf('application/json') >= 0) : (xhr.responseType == 'text'); + + if(json) { + /* If we requested msgpack but server responded with json, then since + * we set the responseType expecting msgpack, the response will be + * an ArrayBuffer containing json */ + const jsonResponseBody = (xhr.responseType === 'arraybuffer') ? BufferUtils.utf8Decode(xhr.response) : String(xhr.responseText); + if(jsonResponseBody.length) { + parsedResponse = JSON.parse(jsonResponseBody); + } else { + parsedResponse = jsonResponseBody; + } + unpacked = true; + } else { + parsedResponse = xhr.response; + } + + if(parsedResponse.response !== undefined) { + /* unwrap JSON envelope */ + statusCode = parsedResponse.statusCode; + successResponse = (statusCode < 400); + headers = parsedResponse.headers; + parsedResponse = parsedResponse.response; + } else { + headers = getHeadersAsObject(xhr); + } + } catch(e) { + this.complete(new ErrorInfo('Malformed response body from server: ' + (e as Error).message, null, 400)); + return; + } + + /* If response is an array, it's an array of protocol messages -- even if + * is contains an error action (hence the nonsuccess statuscode), we can + * consider the request to have succeeded, just pass it on to + * onProtocolMessage to decide what to do */ + if(successResponse || Utils.isArray(parsedResponse)) { + this.complete(null, parsedResponse, headers, unpacked, statusCode); + return; + } + + let err = getAblyError(parsedResponse, headers); + if(!err) { + err = new ErrorInfo('Error response received from server: ' + statusCode + ' body was: ' + Utils.inspect(parsedResponse), null, statusCode); + } + this.complete(err, parsedResponse, headers, unpacked, statusCode); + } + + function onProgress() { + const responseText = xhr.responseText; + const bodyEnd = responseText.length - 1; + let idx, chunk; + while((streamPos < bodyEnd) && (idx = responseText.indexOf('\n', streamPos)) > -1) { + chunk = responseText.slice(streamPos, idx); + streamPos = idx + 1; + onChunk(chunk); + } + } + + const onChunk = (chunk: string) => { + try { + chunk = JSON.parse(chunk); + } catch(e) { + this.complete(new ErrorInfo('Malformed response body from server: ' + (e as Error).message, null, 400)); + return; + } + this.emit('data', chunk); + } + + const onStreamEnd = () => { + onProgress(); + this.streamComplete = true; + Utils.nextTick(() => { + this.complete(); + }); + } + + xhr.onreadystatechange = function() { + const readyState = xhr.readyState; + if(readyState < 3) return; + if(xhr.status !== 0) { + if(statusCode === undefined) { + statusCode = xhr.status; + /* IE returns 1223 for 204: http://bugs.jquery.com/ticket/1450 */ + if(statusCode === 1223) statusCode = 204; + onResponse(); + } + if(readyState == 3 && streaming) { + onProgress(); + } else if(readyState == 4) { + if(streaming) + onStreamEnd(); + else + onEnd(); + } + } + }; + xhr.send(body as any); + } + + dispose(): void { + const xhr = this.xhr; + if(xhr) { + xhr.onreadystatechange = xhr.onerror = xhr.onabort = xhr.ontimeout = noop; + this.xhr = null; + const timer = this.timer; + if(timer) { + clearTimeout(timer as NodeJS.Timeout); + this.timer = null; + } + if(!this.requestComplete) + xhr.abort(); + } + delete pendingRequests[this.id]; + } +} + +if(Platform.xhrSupported) { + if(typeof(Http) !== 'undefined') { + Http.supportsAuthHeaders = true; + Http.Request = function(method: HttpMethods, rest: Rest | null, uri: string, headers: Record | null, params: RequestParams, body: unknown, callback: RequestCallback) { + const req = XHRRequest.createRequest(uri, headers, params, body, XHRStates.REQ_SEND, rest && rest.options.timeouts, method); + req.once('complete', callback); + req.exec(); + return req; + }; + + Http.checkConnectivity = function(callback: (err?: ErrorInfo | null, connectivity?: boolean) => void) { + const upUrl = Defaults.internetUpUrl; + Logger.logAction(Logger.LOG_MICRO, '(XHRRequest)Http.checkConnectivity()', 'Sending; ' + upUrl); + Http.getUri(null, upUrl, null, null, function(err?: ErrorInfo | ErrnoException | null, responseText?: unknown) { + const result = (!err && (responseText as string)?.replace(/\n/, '') == 'yes'); + Logger.logAction(Logger.LOG_MICRO, '(XHRRequest)Http.checkConnectivity()', 'Result: ' + result); + callback(null, result); + }); + }; + } +} + +export default XHRRequest; diff --git a/browser/lib/transport/xhrstreamingtransport.js b/browser/lib/transport/xhrstreamingtransport.js index 4bee9cee51..1c0ed27ba4 100644 --- a/browser/lib/transport/xhrstreamingtransport.js +++ b/browser/lib/transport/xhrstreamingtransport.js @@ -1,4 +1,4 @@ -import Utils from '../../../common/lib/util/utils'; +import * as Utils from '../../../common/lib/util/utils'; import CometTransport from '../../../common/lib/transport/comettransport'; import Logger from '../../../common/lib/util/logger'; import Platform from 'platform'; diff --git a/browser/lib/util/bufferutils.js b/browser/lib/util/bufferutils.js deleted file mode 100644 index cbfca52840..0000000000 --- a/browser/lib/util/bufferutils.js +++ /dev/null @@ -1,216 +0,0 @@ -import { parse as parseHex, stringify as stringifyHex } from 'crypto-js/build/enc-hex'; -import { parse as parseUtf8, stringify as stringifyUtf8 } from 'crypto-js/build/enc-utf8'; -import { parse as parseBase64, stringify as stringifyBase64 } from 'crypto-js/build/enc-base64'; -import WordArray from 'crypto-js/build/lib-typedarrays'; -import Platform from 'platform'; - -var BufferUtils = (function() { - var ArrayBuffer = Platform.ArrayBuffer; - var atob = Platform.atob; - var TextEncoder = Platform.TextEncoder; - var TextDecoder = Platform.TextDecoder; - var base64CharSet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; - var hexCharSet = '0123456789abcdef'; - - function isWordArray(ob) { return ob !== null && ob !== undefined && ob.sigBytes !== undefined; } - function isArrayBuffer(ob) { return ob !== null && ob !== undefined && ob.constructor === ArrayBuffer; } - function isTypedArray(ob) { return ArrayBuffer && ArrayBuffer.isView && ArrayBuffer.isView(ob); } - - // https://gist.githubusercontent.com/jonleighton/958841/raw/f200e30dfe95212c0165ccf1ae000ca51e9de803/gistfile1.js - function uint8ViewToBase64(bytes) { - var base64 = '' - var encodings = base64CharSet; - - var byteLength = bytes.byteLength - var byteRemainder = byteLength % 3 - var mainLength = byteLength - byteRemainder - - var a, b, c, d - var chunk - - // Main loop deals with bytes in chunks of 3 - for (var i = 0; i < mainLength; i = i + 3) { - // Combine the three bytes into a single integer - chunk = (bytes[i] << 16) | (bytes[i + 1] << 8) | bytes[i + 2] - - // Use bitmasks to extract 6-bit segments from the triplet - a = (chunk & 16515072) >> 18 // 16515072 = (2^6 - 1) << 18 - b = (chunk & 258048) >> 12 // 258048 = (2^6 - 1) << 12 - c = (chunk & 4032) >> 6 // 4032 = (2^6 - 1) << 6 - d = chunk & 63 // 63 = 2^6 - 1 - - // Convert the raw binary segments to the appropriate ASCII encoding - base64 += encodings[a] + encodings[b] + encodings[c] + encodings[d] - } - - // Deal with the remaining bytes and padding - if (byteRemainder == 1) { - chunk = bytes[mainLength] - - a = (chunk & 252) >> 2 // 252 = (2^6 - 1) << 2 - - // Set the 4 least significant bits to zero - b = (chunk & 3) << 4 // 3 = 2^2 - 1 - - base64 += encodings[a] + encodings[b] + '==' - } else if (byteRemainder == 2) { - chunk = (bytes[mainLength] << 8) | bytes[mainLength + 1] - - a = (chunk & 64512) >> 10 // 64512 = (2^6 - 1) << 10 - b = (chunk & 1008) >> 4 // 1008 = (2^6 - 1) << 4 - - // Set the 2 least significant bits to zero - c = (chunk & 15) << 2 // 15 = 2^4 - 1 - - base64 += encodings[a] + encodings[b] + encodings[c] + '=' - } - - return base64 - } - - function base64ToArrayBuffer(base64) { - var binary_string = atob(base64); - var len = binary_string.length; - var bytes = new Uint8Array( len ); - for (var i = 0; i < len; i++) { - var ascii = binary_string.charCodeAt(i); - bytes[i] = ascii; - } - return bytes.buffer; - } - - /* Most BufferUtils methods that return a binary object return an ArrayBuffer - * if supported, else a CryptoJS WordArray. The exception is toBuffer, which - * returns a Uint8Array (and won't work on browsers too old to support it) */ - function BufferUtils() {} - - BufferUtils.base64CharSet = base64CharSet; - BufferUtils.hexCharSet = hexCharSet; - - var isBuffer = BufferUtils.isBuffer = function(buf) { return isArrayBuffer(buf) || isWordArray(buf) || isTypedArray(buf); }; - - /* In browsers, returns a Uint8Array */ - var toBuffer = BufferUtils.toBuffer = function(buf) { - if(!ArrayBuffer) { - throw new Error("Can't convert to Buffer: browser does not support the necessary types"); - } - - if(isArrayBuffer(buf)) { - return new Uint8Array(buf); - } - - if(isTypedArray(buf)) { - return new Uint8Array(buf.buffer); - } - - if(isWordArray(buf)) { - /* Backported from unreleased CryptoJS - * https://code.google.com/p/crypto-js/source/browse/branches/3.x/src/lib-typedarrays.js?r=661 */ - var arrayBuffer = new ArrayBuffer(buf.sigBytes); - var uint8View = new Uint8Array(arrayBuffer); - - for (var i = 0; i < buf.sigBytes; i++) { - uint8View[i] = (buf.words[i >>> 2] >>> (24 - (i % 4) * 8)) & 0xff; - } - - return uint8View; - }; - - throw new Error("BufferUtils.toBuffer expected an arraybuffer, typed array, or CryptoJS wordarray"); - }; - - BufferUtils.toArrayBuffer = function(buf) { - if(isArrayBuffer(buf)) { - return buf; - } - return toBuffer(buf).buffer; - }; - - BufferUtils.toWordArray = function(buf) { - if(isTypedArray(buf)) { - buf = buf.buffer; - } - return isWordArray(buf) ? buf : WordArray.create(buf); - }; - - BufferUtils.base64Encode = function(buf) { - if(isWordArray(buf)) { - return stringifyBase64(buf); - } - return uint8ViewToBase64(toBuffer(buf)); - }; - - BufferUtils.base64Decode = function(str) { - if(ArrayBuffer && atob) { - return base64ToArrayBuffer(str); - } - return parseBase64(str); - }; - - BufferUtils.hexEncode = function(buf) { - buf = BufferUtils.toWordArray(buf); - return stringifyHex(buf); - }; - - BufferUtils.hexDecode = function(string) { - var wordArray = parseHex(string); - return ArrayBuffer ? BufferUtils.toArrayBuffer(wordArray) : wordArray; - }; - - BufferUtils.utf8Encode = function(string) { - if(TextEncoder) { - return (new TextEncoder()).encode(string).buffer; - } - return parseUtf8(string); - }; - - /* For utf8 decoding we apply slightly stricter input validation than to - * hexEncode/base64Encode/etc: in those we accept anything that Buffer.from - * can take (in particular allowing strings, which are just interpreted as - * binary); here we ensure that the input is actually a buffer since trying - * to utf8-decode a string to another string is almost certainly a mistake */ - BufferUtils.utf8Decode = function(buf) { - if(!isBuffer(buf)) { - throw new Error("Expected input of utf8decode to be an arraybuffer, typed array, or CryptoJS wordarray"); - } - if(TextDecoder && !isWordArray(buf)) { - return (new TextDecoder()).decode(buf); - } - buf = BufferUtils.toWordArray(buf); - return stringifyUtf8(buf); - }; - - BufferUtils.bufferCompare = function(buf1, buf2) { - if(!buf1) return -1; - if(!buf2) return 1; - buf1 = BufferUtils.toWordArray(buf1); - buf2 = BufferUtils.toWordArray(buf2); - buf1.clamp(); buf2.clamp(); - - var cmp = buf1.sigBytes - buf2.sigBytes; - if(cmp != 0) return cmp; - buf1 = buf1.words; buf2 = buf2.words; - for(var i = 0; i < buf1.length; i++) { - cmp = buf1[i] - buf2[i]; - if(cmp != 0) return cmp; - } - return 0; - }; - - BufferUtils.byteLength = function(buf) { - if(isArrayBuffer(buf) || isTypedArray(buf)) { - return buf.byteLength - } else if(isWordArray(buf)) { - return buf.sigBytes; - } - }; - - /* Returns ArrayBuffer on browser and Buffer on Node.js */ - BufferUtils.typedArrayToBuffer = function(typedArray) { - return typedArray.buffer; - }; - - return BufferUtils; -})(); - -export default BufferUtils; diff --git a/browser/lib/util/bufferutils.ts b/browser/lib/util/bufferutils.ts new file mode 100644 index 0000000000..57bcdf51f8 --- /dev/null +++ b/browser/lib/util/bufferutils.ts @@ -0,0 +1,207 @@ +import { parse as parseHex, stringify as stringifyHex } from 'crypto-js/build/enc-hex'; +import { parse as parseUtf8, stringify as stringifyUtf8 } from 'crypto-js/build/enc-utf8'; +import { parse as parseBase64, stringify as stringifyBase64 } from 'crypto-js/build/enc-base64'; +import WordArray from 'crypto-js/build/lib-typedarrays'; +import Platform from 'platform'; +import { TypedArray } from '../../../common/types/IPlatform'; + +/* Most BufferUtils methods that return a binary object return an ArrayBuffer + * if supported, else a CryptoJS WordArray. The exception is toBuffer, which + * returns a Uint8Array (and won't work on browsers too old to support it) */ + +const ArrayBuffer = Platform.ArrayBuffer; +const atob = Platform.atob; +const TextEncoder = Platform.TextEncoder; +const TextDecoder = Platform.TextDecoder; +export const base64CharSet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; +export const hexCharSet = '0123456789abcdef'; + +function isWordArray(ob: unknown): ob is WordArray { return ob !== null && ob !== undefined && (ob as WordArray).sigBytes !== undefined; } +function isArrayBuffer(ob: unknown): ob is ArrayBuffer { return ob !== null && ob !== undefined && (ob as ArrayBuffer).constructor === ArrayBuffer; } +function isTypedArray(ob: unknown): ob is TypedArray { return !!ArrayBuffer && ArrayBuffer.isView && ArrayBuffer.isView(ob); } + +// https://gist.githubusercontent.com/jonleighton/958841/raw/f200e30dfe95212c0165ccf1ae000ca51e9de803/gistfile1.js +function uint8ViewToBase64(bytes: Uint8Array) { + let base64 = '' + const encodings = base64CharSet; + + const byteLength = bytes.byteLength; + const byteRemainder = byteLength % 3; + const mainLength = byteLength - byteRemainder; + + let a, b, c, d; + let chunk; + + // Main loop deals with bytes in chunks of 3 + for (let i = 0; i < mainLength; i = i + 3) { + // Combine the three bytes into a single integer + chunk = (bytes[i] << 16) | (bytes[i + 1] << 8) | bytes[i + 2] + + // Use bitmasks to extract 6-bit segments from the triplet + a = (chunk & 16515072) >> 18 // 16515072 = (2^6 - 1) << 18 + b = (chunk & 258048) >> 12 // 258048 = (2^6 - 1) << 12 + c = (chunk & 4032) >> 6 // 4032 = (2^6 - 1) << 6 + d = chunk & 63 // 63 = 2^6 - 1 + + // Convert the raw binary segments to the appropriate ASCII encoding + base64 += encodings[a] + encodings[b] + encodings[c] + encodings[d] + } + + // Deal with the remaining bytes and padding + if (byteRemainder == 1) { + chunk = bytes[mainLength] + + a = (chunk & 252) >> 2 // 252 = (2^6 - 1) << 2 + + // Set the 4 least significant bits to zero + b = (chunk & 3) << 4 // 3 = 2^2 - 1 + + base64 += encodings[a] + encodings[b] + '==' + } else if (byteRemainder == 2) { + chunk = (bytes[mainLength] << 8) | bytes[mainLength + 1] + + a = (chunk & 64512) >> 10 // 64512 = (2^6 - 1) << 10 + b = (chunk & 1008) >> 4 // 1008 = (2^6 - 1) << 4 + + // Set the 2 least significant bits to zero + c = (chunk & 15) << 2 // 15 = 2^4 - 1 + + base64 += encodings[a] + encodings[b] + encodings[c] + '=' + } + + return base64 +} + +function base64ToArrayBuffer(base64: string) { + const binary_string = atob?.(base64) as string; // this will always be defined in browser so it's safe to cast + const len = binary_string.length; + const bytes = new Uint8Array( len ); + for (let i = 0; i < len; i++) { + const ascii = binary_string.charCodeAt(i); + bytes[i] = ascii; + } + return bytes.buffer; +} + +export function isBuffer(buffer: unknown): buffer is (ArrayBuffer | WordArray | TypedArray) { return isArrayBuffer(buffer) || isWordArray(buffer) || isTypedArray(buffer); }; + +/* In browsers, returns a Uint8Array */ +export function toBuffer(buffer: WordArray | TypedArray | ArrayBuffer) { + if(!ArrayBuffer) { + throw new Error("Can't convert to Buffer: browser does not support the necessary types"); + } + + if(isArrayBuffer(buffer)) { + return new Uint8Array(buffer as ArrayBuffer); + } + + if(isTypedArray(buffer)) { + return new Uint8Array((buffer as TypedArray).buffer); + } + + if(isWordArray(buffer)) { + /* Backported from unreleased CryptoJS + * https://code.google.com/p/crypto-js/source/browse/branches/3.x/src/lib-typedarrays.js?r=661 */ + var arrayBuffer = new ArrayBuffer(buffer.sigBytes); + var uint8View = new Uint8Array(arrayBuffer); + + for (var i = 0; i < buffer.sigBytes; i++) { + uint8View[i] = (buffer.words[i >>> 2] >>> (24 - (i % 4) * 8)) & 0xff; + } + + return uint8View; + }; + + throw new Error("BufferUtils.toBuffer expected an arraybuffer, typed array, or CryptoJS wordarray"); +}; + +export function toArrayBuffer(buffer: ArrayBuffer | WordArray) { + if(isArrayBuffer(buffer)) { + return buffer; + } + return toBuffer(buffer).buffer; +}; + +export function toWordArray(buffer: TypedArray | WordArray | number[] | ArrayBuffer) { + if(isTypedArray(buffer)) { + buffer = buffer.buffer; + } + return isWordArray(buffer) ? buffer : WordArray.create(buffer as number[]); +}; + +export function base64Encode(buffer: WordArray | ArrayBuffer) { + if(isWordArray(buffer)) { + return stringifyBase64(buffer); + } + return uint8ViewToBase64(toBuffer(buffer)); +}; + +export function base64Decode(str: string) { + if(ArrayBuffer && atob) { + return base64ToArrayBuffer(str); + } + return parseBase64(str); +}; + +export function hexEncode(buffer: ArrayBuffer) { + return stringifyHex(toWordArray(buffer)); +}; + +export function hexDecode(string: string) { + var wordArray = parseHex(string); + return ArrayBuffer ? toArrayBuffer(wordArray) : wordArray; +}; + +export function utf8Encode(string: string) { + if(TextEncoder) { + return (new TextEncoder()).encode(string).buffer; + } + return parseUtf8(string); +}; + +/* For utf8 decoding we apply slightly stricter input validation than to + * hexEncode/base64Encode/etc: in those we accept anything that Buffer.from + * can take (in particular allowing strings, which are just interpreted as + * binary); here we ensure that the input is actually a buffer since trying + * to utf8-decode a string to another string is almost certainly a mistake */ +export function utf8Decode(buffer: ArrayBuffer | WordArray) { + if(!isBuffer(buffer)) { + throw new Error("Expected input of utf8decode to be an arraybuffer, typed array, or CryptoJS wordarray"); + } + if(TextDecoder && !isWordArray(buffer)) { + return (new TextDecoder()).decode(buffer); + } + buffer = toWordArray(buffer); + return stringifyUtf8(buffer); +}; + +export function bufferCompare(buffer1: TypedArray, buffer2: TypedArray) { + if(!buffer1) return -1; + if(!buffer2) return 1; + const wordArray1 = toWordArray(buffer1); + const wordArray2 = toWordArray(buffer2); + wordArray1.clamp(); wordArray2.clamp(); + + var cmp = wordArray1.sigBytes - wordArray2.sigBytes; + if(cmp != 0) return cmp; + const words1 = wordArray1.words; + const words2 = wordArray2.words; + for(var i = 0; i < words1.length; i++) { + cmp = words1[i] - words2[i]; + if(cmp != 0) return cmp; + } + return 0; +}; + +export function byteLength(buffer: ArrayBuffer | TypedArray | WordArray) { + if(isArrayBuffer(buffer) || isTypedArray(buffer)) { + return buffer.byteLength + } else if(isWordArray(buffer)) { + return buffer.sigBytes; + } +}; + +/* Returns ArrayBuffer on browser and Buffer on Node.js */ +export function typedArrayToBuffer(typedArray: TypedArray) { + return typedArray.buffer; +}; diff --git a/browser/lib/util/crypto.js b/browser/lib/util/crypto.js index 7a8ed11213..322825a3ed 100644 --- a/browser/lib/util/crypto.js +++ b/browser/lib/util/crypto.js @@ -3,7 +3,7 @@ import { parse as parseBase64 } from 'crypto-js/build/enc-base64'; import CryptoJS from 'crypto-js/build'; import Platform from 'platform'; import Logger from '../../../common/lib/util/logger'; -import BufferUtils from 'platform-bufferutils'; +import * as BufferUtils from 'platform-bufferutils'; var Crypto = (function() { var DEFAULT_ALGORITHM = 'aes'; diff --git a/browser/lib/util/defaults.js b/browser/lib/util/defaults.ts similarity index 59% rename from browser/lib/util/defaults.js rename to browser/lib/util/defaults.ts index ed51046865..ba1a2000ba 100644 --- a/browser/lib/util/defaults.js +++ b/browser/lib/util/defaults.ts @@ -1,6 +1,8 @@ import Platform from 'platform'; +import IDefaults from '../../../common/types/IDefaults'; +import TransportNames from '../../../common/constants/TransportNames'; -var Defaults = { +const Defaults: IDefaults = { internetUpUrl: 'https://internet-up.ably-realtime.com/is-the-internet-up.txt', jsonpInternetUpUrl: 'https://internet-up.ably-realtime.com/is-the-internet-up-0-9.js', /* Order matters here: the base transport is the leftmost one in the @@ -8,10 +10,10 @@ var Defaults = { * supported. This is not quite the same as the preference order -- e.g. * xhr_polling is preferred to jsonp, but for browsers that support it we want * the base transport to be xhr_polling, not jsonp */ - defaultTransports: ['xhr_polling', 'xhr_streaming', 'jsonp', 'web_socket'], - baseTransportOrder: ['xhr_polling', 'xhr_streaming', 'jsonp', 'web_socket'], - transportPreferenceOrder: ['jsonp', 'xhr_polling', 'xhr_streaming', 'web_socket'], - upgradeTransports: ['xhr_streaming', 'web_socket'] + defaultTransports: [TransportNames.XhrPolling, TransportNames.XhrStreaming, TransportNames.JsonP, TransportNames.WebSocket], + baseTransportOrder: [TransportNames.XhrPolling, TransportNames.XhrStreaming, TransportNames.JsonP, TransportNames.WebSocket], + transportPreferenceOrder: [TransportNames.JsonP, TransportNames.XhrPolling, TransportNames.XhrStreaming, TransportNames.WebSocket], + upgradeTransports: [TransportNames.XhrStreaming, TransportNames.WebSocket] }; /* If using IE8, don't attempt to upgrade from xhr_polling to xhr_streaming - diff --git a/browser/lib/util/http.js b/browser/lib/util/http.js deleted file mode 100644 index 3844b8b71c..0000000000 --- a/browser/lib/util/http.js +++ /dev/null @@ -1,144 +0,0 @@ -import Utils from '../../../common/lib/util/utils'; -import Defaults from '../../../common/lib/util/defaults'; - -var Http = (function() { - var noop = function() {}; - - function Http() {} - - var now = Date.now || function() { - /* IE 8 */ - return new Date().getTime(); - }; - - function shouldFallback(err) { - var statusCode = err.statusCode; - /* 400 + no code = a generic xhr onerror. Browser doesn't give us enough - * detail to know whether it's fallback-fixable, but it may be (eg if a - * network issue), so try just in case */ - return (statusCode === 408 && !err.code) || - (statusCode === 400 && !err.code) || - (statusCode >= 500 && statusCode <= 504); - } - - function getHosts(client) { - /* If we're a connected realtime client, try the endpoint we're connected - * to first -- but still have fallbacks, being connected is not an absolute - * guarantee that a datacenter has free capacity to service REST requests. */ - var connection = client.connection, - connectionHost = connection && connection.connectionManager.host; - - if(connectionHost) { - return [connectionHost].concat(Defaults.getFallbackHosts(client.options)); - } - - return Defaults.getHosts(client.options); - } - Http._getHosts = getHosts; - - Http.methods = ['get', 'delete', 'post', 'put', 'patch']; - Http.methodsWithoutBody = ['get', 'delete']; - Http.methodsWithBody = Utils.arrSubtract(Http.methods, Http.methodsWithoutBody); - - /* - Http.get, Http.post, Http.put, ... - * Perform an HTTP request for a given path against prime and fallback Ably hosts - * @param rest - * @param path the full path - * @param headers optional hash of headers - * [only for methods with body: @param body object or buffer containing request body] - * @param params optional hash of params - * @param callback (err, response) - * - * - Http.getUri, Http.postUri, Http.putUri, ... - * Perform an HTTP request for a given full URI - * @param rest - * @param uri the full URI - * @param headers optional hash of headers - * [only for methods with body: @param body object or buffer containing request body] - * @param params optional hash of params - * @param callback (err, response) - */ - Utils.arrForEach(Http.methodsWithoutBody, function(method) { - Http[method] = function(rest, path, headers, params, callback) { - Http['do'](method, rest, path, headers, null, params, callback); - }; - Http[method + 'Uri'] = function(rest, uri, headers, params, callback) { - Http.doUri(method, rest, uri, headers, null, params, callback); - }; - }); - - Utils.arrForEach(Http.methodsWithBody, function(method) { - Http[method] = function(rest, path, headers, body, params, callback) { - Http['do'](method, rest, path, headers, body, params, callback); - }; - Http[method + 'Uri'] = function(rest, uri, headers, body, params, callback) { - Http.doUri(method, rest, uri, headers, body, params, callback); - }; - }); - - /* Unlike for doUri, the 'rest' param here is mandatory, as it's used to generate the hosts */ - Http['do'] = function(method, rest, path, headers, body, params, callback) { - callback = callback || noop; - var uriFromHost = (typeof(path) == 'function') ? path : function(host) { return rest.baseUri(host) + path; }; - var binary = (headers && headers.accept != 'application/json'); - var doArgs = arguments; - - var currentFallback = rest._currentFallback; - if(currentFallback) { - if(currentFallback.validUntil > now()) { - /* Use stored fallback */ - Http.Request(method, rest, uriFromHost(currentFallback.host), headers, params, body, function(err) { - if(err && shouldFallback(err)) { - /* unstore the fallback and start from the top with the default sequence */ - rest._currentFallback = null; - Http['do'].apply(Http, doArgs); - return; - } - callback.apply(null, arguments); - }); - return; - } else { - /* Fallback expired; remove it and fallthrough to normal sequence */ - rest._currentFallback = null; - } - } - - var hosts = getHosts(rest); - - /* if there is only one host do it */ - if(hosts.length == 1) { - Http.doUri(method, rest, uriFromHost(hosts[0]), headers, body, params, callback); - return; - } - - /* hosts is an array with preferred host plus at least one fallback */ - var tryAHost = function(candidateHosts, persistOnSuccess) { - var host = candidateHosts.shift(); - Http.doUri(method, rest, uriFromHost(host), headers, body, params, function(err) { - if(err && shouldFallback(err) && candidateHosts.length) { - tryAHost(candidateHosts, true); - return; - } - if(persistOnSuccess) { - /* RSC15f */ - rest._currentFallback = { - host: host, - validUntil: now() + rest.options.timeouts.fallbackRetryTimeout - }; - } - callback.apply(null, arguments); - }); - }; - tryAHost(hosts); - }; - - Http.doUri = function(method, rest, uri, headers, body, params, callback) { - Http.Request(method, rest, uri, headers, params, body, callback); - }; - - Http.supportsAuthHeaders = false; - Http.supportsLinkHeaders = false; - return Http; -})(); - -export default Http; diff --git a/browser/lib/util/http.ts b/browser/lib/util/http.ts new file mode 100644 index 0000000000..4c537dca1a --- /dev/null +++ b/browser/lib/util/http.ts @@ -0,0 +1,174 @@ +import * as Utils from '../../../common/lib/util/utils'; +import Defaults from '../../../common/lib/util/defaults'; +import ErrorInfo from '../../../common/lib/types/errorinfo'; +import { ErrnoException, IHttp, RequestCallback, RequestParams } from '../../../common/types/http'; +import HttpMethods from '../../../common/constants/HttpMethods'; +import Rest from '../../../common/lib/client/rest'; +import Realtime from '../../../common/lib/client/realtime'; + +function shouldFallback(errorInfo: ErrorInfo) { + const statusCode = errorInfo.statusCode as number; + /* 400 + no code = a generic xhr onerror. Browser doesn't give us enough + * detail to know whether it's fallback-fixable, but it may be (eg if a + * network issue), so try just in case */ + return (statusCode === 408 && !errorInfo.code) || + (statusCode === 400 && !errorInfo.code) || + (statusCode >= 500 && statusCode <= 504); +} + +function getHosts(client: Rest | Realtime): string[] { + /* If we're a connected realtime client, try the endpoint we're connected + * to first -- but still have fallbacks, being connected is not an absolute + * guarantee that a datacenter has free capacity to service REST requests. */ + const connection = (client as Realtime).connection, + connectionHost = connection && connection.connectionManager.host; + + if(connectionHost) { + return [connectionHost].concat(Defaults.getFallbackHosts(client.options)); + } + + return Defaults.getHosts(client.options); +} + +const Http: typeof IHttp = class { + static methods = [HttpMethods.Get, HttpMethods.Delete, HttpMethods.Post, HttpMethods.Put, HttpMethods.Patch]; + static methodsWithoutBody = [HttpMethods.Get, HttpMethods.Delete]; + static methodsWithBody = [HttpMethods.Post, HttpMethods.Put, HttpMethods.Patch]; + + /* Unlike for doUri, the 'rest' param here is mandatory, as it's used to generate the hosts */ + static do(method: HttpMethods, rest: Rest, path: string, headers: Record | null, body: unknown, params: RequestParams, callback?: RequestCallback): void { + const uriFromHost = (typeof(path) == 'function') ? path : function(host: string) { return rest.baseUri(host) + path; }; + + const currentFallback = rest._currentFallback; + if(currentFallback) { + if(currentFallback.validUntil > Utils.now()) { + /* Use stored fallback */ + if (!Http.Request) { + callback?.(new ErrorInfo('Request invoked before assigned to', null, 500)); + return; + } + Http.Request(method, rest, uriFromHost(currentFallback.host), headers, params, body, function(err?: ErrnoException | ErrorInfo | null, ...args: unknown[]) { + // This typecast is safe because ErrnoExceptions are only thrown in NodeJS + if(err && shouldFallback(err as ErrorInfo)) { + /* unstore the fallback and start from the top with the default sequence */ + rest._currentFallback = null; + Http.do(method, rest, path, headers, body, params, callback); + return; + } + callback?.(err, ...args); + }); + return; + } else { + /* Fallback expired; remove it and fallthrough to normal sequence */ + rest._currentFallback = null; + } + } + + const hosts = getHosts(rest); + + /* if there is only one host do it */ + if(hosts.length === 1) { + Http.doUri(method, rest, uriFromHost(hosts[0]), headers, body, params, callback); + return; + } + + /* hosts is an array with preferred host plus at least one fallback */ + const tryAHost = function(candidateHosts: Array, persistOnSuccess?: boolean) { + const host = candidateHosts.shift(); + Http.doUri(method, rest, uriFromHost(host as string), headers, body, params, function(err?: ErrnoException | ErrorInfo | null, ...args: unknown[]) { + // This typecast is safe because ErrnoExceptions are only thrown in NodeJS + if(err && shouldFallback(err as ErrorInfo) && candidateHosts.length) { + tryAHost(candidateHosts, true); + return; + } + if(persistOnSuccess) { + /* RSC15f */ + rest._currentFallback = { + host: host as string, + validUntil: Utils.now() + rest.options.timeouts.fallbackRetryTimeout + }; + } + callback?.(err, ...args); + }); + }; + tryAHost(hosts); + } + + static doUri(method: HttpMethods, rest: Rest | null, uri: string, headers: Record | null, body: unknown, params: RequestParams, callback: RequestCallback): void { + if (!Http.Request) { + callback(new ErrorInfo('Request invoked before assigned to', null, 500)); + return; + } + Http.Request(method, rest, uri, headers, params, body, callback); + } + + /** Http.get, Http.post, Http.put, ... + * Perform an HTTP request for a given path against prime and fallback Ably hosts + * @param rest + * @param path the full path + * @param headers optional hash of headers + * [only for methods with body: @param body object or buffer containing request body] + * @param params optional hash of params + * @param callback (err, response) + * + ** Http.getUri, Http.postUri, Http.putUri, ... + * Perform an HTTP request for a given full URI + * @param rest + * @param uri the full URI + * @param headers optional hash of headers + * [only for methods with body: @param body object or buffer containing request body] + * @param params optional hash of params + * @param callback (err, response) + */ + + static get(rest: Rest | null, path: string, headers: Record | null, params: RequestParams, callback: RequestCallback): void { + Http.do(HttpMethods.Get, rest, path, headers, null, params, callback); + } + + static getUri(rest: Rest | null, uri: string, headers: Record | null, params: RequestParams, callback: RequestCallback): void { + Http.doUri(HttpMethods.Get, rest, uri, headers, null, params, callback); + } + + static delete(rest: Rest | null, path: string, headers: Record | null, params: RequestParams, callback: RequestCallback): void { + Http.do(HttpMethods.Delete, rest, path, headers, null, params, callback); + } + + static deleteUri(rest: Rest | null, uri: string, headers: Record | null, params: RequestParams, callback: RequestCallback): void { + Http.doUri(HttpMethods.Delete, rest, uri, headers, null, params, callback); + } + + static post(rest: Rest | null, path: string, headers: Record | null, body: unknown, params: RequestParams, callback: RequestCallback): void { + Http.do(HttpMethods.Post, rest, path, headers, body, params, callback); + } + + static postUri(rest: Rest | null, uri: string, headers: Record | null, body: unknown, params: RequestParams, callback: RequestCallback): void { + Http.doUri(HttpMethods.Post, rest, uri, headers, body, params, callback); + } + + static put(rest: Rest | null, path: string, headers: Record | null, body: unknown, params: RequestParams, callback: RequestCallback): void { + Http.do(HttpMethods.Put, rest, path, headers, body, params, callback); + } + + static putUri(rest: Rest | null, uri: string, headers: Record | null, body: unknown, params: RequestParams, callback: RequestCallback): void { + Http.doUri(HttpMethods.Put, rest, uri, headers, body, params, callback); + } + + static patch(rest: Rest | null, path: string, headers: Record | null, body: unknown, params: RequestParams, callback: RequestCallback): void { + Http.do(HttpMethods.Patch, rest, path, headers, body, params, callback); + } + + static patchUri(rest: Rest | null, uri: string, headers: Record | null, body: unknown, params: RequestParams, callback: RequestCallback): void { + Http.doUri(HttpMethods.Patch, rest, uri, headers, body, params, callback); + } + + static Request?: (method: HttpMethods, rest: Rest | null, uri: string, headers: Record | null, params: RequestParams, body: unknown, callback: RequestCallback) => void; + + static checkConnectivity?: (callback: (err: ErrorInfo | null, connectivity: boolean) => void) => void = undefined; + + static supportsAuthHeaders = false; + static supportsLinkHeaders = false; + + static _getHosts = getHosts; +} + +export default Http; diff --git a/browser/lib/util/msgpack.js b/browser/lib/util/msgpack.js deleted file mode 100644 index 486d013602..0000000000 --- a/browser/lib/util/msgpack.js +++ /dev/null @@ -1,838 +0,0 @@ -var msgpack = (function() { - "use strict"; - - var exports = {}; - - exports.inspect = inspect; - function inspect(buffer) { - if (buffer === undefined) - return "undefined"; - var view; - var type; - if ( buffer instanceof ArrayBuffer) { - type = "ArrayBuffer"; - view = new DataView(buffer); - } else if ( buffer instanceof DataView) { - type = "DataView"; - view = buffer; - } - if (!view) - return JSON.stringify(buffer); - var bytes = []; - for (var i = 0; i < buffer.byteLength; i++) { - if (i > 20) { - bytes.push("..."); - break; - } - var byte_ = view.getUint8(i).toString(16); - if (byte_.length === 1) - byte_ = "0" + byte_; - bytes.push(byte_); - } - return "<" + type + " " + bytes.join(" ") + ">"; - } - - // Encode string as utf8 into dataview at offset - exports.utf8Write = utf8Write; - function utf8Write(view, offset, string) { - var byteLength = view.byteLength; - for (var i = 0, l = string.length; i < l; i++) { - var codePoint = string.charCodeAt(i); - - // One byte of UTF-8 - if (codePoint < 0x80) { - view.setUint8(offset++, codePoint >>> 0 & 0x7f | 0x00); - continue; - } - - // Two bytes of UTF-8 - if (codePoint < 0x800) { - view.setUint8(offset++, codePoint >>> 6 & 0x1f | 0xc0); - view.setUint8(offset++, codePoint >>> 0 & 0x3f | 0x80); - continue; - } - - // Three bytes of UTF-8. - if (codePoint < 0x10000) { - view.setUint8(offset++, codePoint >>> 12 & 0x0f | 0xe0); - view.setUint8(offset++, codePoint >>> 6 & 0x3f | 0x80); - view.setUint8(offset++, codePoint >>> 0 & 0x3f | 0x80); - continue; - } - - // Four bytes of UTF-8 - if (codePoint < 0x110000) { - view.setUint8(offset++, codePoint >>> 18 & 0x07 | 0xf0); - view.setUint8(offset++, codePoint >>> 12 & 0x3f | 0x80); - view.setUint8(offset++, codePoint >>> 6 & 0x3f | 0x80); - view.setUint8(offset++, codePoint >>> 0 & 0x3f | 0x80); - continue; - } - throw new Error("bad codepoint " + codePoint); - } - } - - - exports.utf8Read = utf8Read; - function utf8Read(view, offset, length) { - var string = ""; - for (var i = offset, end = offset + length; i < end; i++) { - var byte_ = view.getUint8(i); - // One byte character - if ((byte_ & 0x80) === 0x00) { - string += String.fromCharCode(byte_); - continue; - } - // Two byte character - if ((byte_ & 0xe0) === 0xc0) { - string += String.fromCharCode(((byte_ & 0x0f) << 6) | (view.getUint8(++i) & 0x3f)); - continue; - } - // Three byte character - if ((byte_ & 0xf0) === 0xe0) { - string += String.fromCharCode(((byte_ & 0x0f) << 12) | ((view.getUint8(++i) & 0x3f) << 6) | ((view.getUint8(++i) & 0x3f) << 0)); - continue; - } - // Four byte character - if ((byte_ & 0xf8) === 0xf0) { - string += String.fromCharCode(((byte_ & 0x07) << 18) | ((view.getUint8(++i) & 0x3f) << 12) | ((view.getUint8(++i) & 0x3f) << 6) | ((view.getUint8(++i) & 0x3f) << 0)); - continue; - } - throw new Error("Invalid byte " + byte_.toString(16)); - } - return string; - } - - - exports.utf8ByteCount = utf8ByteCount; - function utf8ByteCount(string) { - var count = 0; - for (var i = 0, l = string.length; i < l; i++) { - var codePoint = string.charCodeAt(i); - if (codePoint < 0x80) { - count += 1; - continue; - } - if (codePoint < 0x800) { - count += 2; - continue; - } - if (codePoint < 0x10000) { - count += 3; - continue; - } - if (codePoint < 0x110000) { - count += 4; - continue; - } - throw new Error("bad codepoint " + codePoint); - } - return count; - } - - - exports.encode = function(value, sparse) { - var size = sizeof(value, sparse); - if(size == 0) - return undefined; - var buffer = new ArrayBuffer(size); - var view = new DataView(buffer); - encode(value, view, 0, sparse); - return buffer; - }; - - exports.decode = decode; - - var SH_L_32 = (1 << 16) * (1 << 16), SH_R_32 = 1 / SH_L_32; - function getInt64(view, offset) { - offset = offset || 0; - return view.getInt32(offset) * SH_L_32 + view.getUint32(offset + 4); - } - - function getUint64(view, offset) { - offset = offset || 0; - return view.getUint32(offset) * SH_L_32 + view.getUint32(offset + 4); - } - - function setInt64(view, offset, val) { - if (val < 0x8000000000000000) { - view.setInt32(offset, Math.floor(val * SH_R_32)); - view.setInt32(offset + 4, val & -1); - } else { - view.setUint32(offset, 0x7fffffff); - view.setUint32(offset + 4, 0x7fffffff); - } - } - - function setUint64(view, offset, val) { - if (val < 0x10000000000000000) { - view.setUint32(offset, Math.floor(val * SH_R_32)); - view.setInt32(offset + 4, val & -1); - } else { - view.setUint32(offset, 0xffffffff); - view.setUint32(offset + 4, 0xffffffff); - } - } - -// https://gist.github.com/frsyuki/5432559 - v5 spec -// -// I've used one extension point from `fixext 1` to store `undefined`. On the wire this -// should translate to exactly 0xd40000 -// -// +--------+--------+--------+ -// | 0xd4 | 0x00 | 0x00 | -// +--------+--------+--------+ -// ^ fixext | ^ value part unused (fixed to be 0) -// ^ indicates undefined value -// - - function Decoder(view, offset) { - this.offset = offset || 0; - this.view = view; - } - - - Decoder.prototype.map = function(length) { - var value = {}; - for (var i = 0; i < length; i++) { - var key = this.parse(); - value[key] = this.parse(); - } - return value; - }; - - Decoder.prototype.bin = Decoder.prototype.buf = function(length) { - var value = new ArrayBuffer(length); - (new Uint8Array(value)).set(new Uint8Array(this.view.buffer, this.offset, length), 0); - this.offset += length; - return value; - }; - - Decoder.prototype.str = function(length) { - var value = utf8Read(this.view, this.offset, length); - this.offset += length; - return value; - }; - - Decoder.prototype.array = function(length) { - var value = new Array(length); - for (var i = 0; i < length; i++) { - value[i] = this.parse(); - } - return value; - }; - - Decoder.prototype.ext = function(length) { - var value = {}; - // Get the type byte - value['type'] = this.view.getInt8(this.offset); - this.offset++; - // Get the data array (length) - value['data'] = this.buf(length); - this.offset += length; - return value; - }; - - Decoder.prototype.parse = function() { - var type = this.view.getUint8(this.offset); - var value, length; - - // Positive FixInt - 0xxxxxxx - if ((type & 0x80) === 0x00) { - this.offset++; - return type; - } - - // FixMap - 1000xxxx - if ((type & 0xf0) === 0x80) { - length = type & 0x0f; - this.offset++; - return this.map(length); - } - - // FixArray - 1001xxxx - if ((type & 0xf0) === 0x90) { - length = type & 0x0f; - this.offset++; - return this.array(length); - } - - // FixStr - 101xxxxx - if ((type & 0xe0) === 0xa0) { - length = type & 0x1f; - this.offset++; - return this.str(length); - } - - // Negative FixInt - 111xxxxx - if ((type & 0xe0) === 0xe0) { - value = this.view.getInt8(this.offset); - this.offset++; - return value; - } - - switch (type) { - - // nil - case 0xc0: - this.offset++; - return null; - - // 0xc1 never used - use for undefined (NON-STANDARD) - case 0xc1: - this.offset++; - return undefined; - - // false - case 0xc2: - this.offset++; - return false; - - // true - case 0xc3: - this.offset++; - return true; - - // bin 8 - case 0xc4: - length = this.view.getUint8(this.offset + 1); - this.offset += 2; - return this.bin(length); - - // bin 16 - case 0xc5: - length = this.view.getUint16(this.offset + 1); - this.offset += 3; - return this.bin(length); - - // bin 32 - case 0xc6: - length = this.view.getUint32(this.offset + 1); - this.offset += 5; - return this.bin(length); - - // ext 8 - case 0xc7: - length = this.view.getUint8(this.offset + 1); - this.offset += 2; - return this.ext(length); - - // ext 16 - case 0xc8: - length = this.view.getUint16(this.offset + 1); - this.offset += 3; - return this.ext(length); - - // ext 32 - case 0xc9: - length = this.view.getUint32(this.offset + 1); - this.offset += 5; - return this.ext(length); - - // float 32 - case 0xca: - value = this.view.getFloat32(this.offset + 1); - this.offset += 5; - return value; - - // float 64 - case 0xcb: - value = this.view.getFloat64(this.offset + 1); - this.offset += 9; - return value; - - // uint8 - case 0xcc: - value = this.view.getUint8(this.offset + 1); - this.offset += 2; - return value; - - // uint 16 - case 0xcd: - value = this.view.getUint16(this.offset + 1); - this.offset += 3; - return value; - - // uint 32 - case 0xce: - value = this.view.getUint32(this.offset + 1); - this.offset += 5; - return value; - - // uint 64 - case 0xcf: - value = getUint64(this.view, this.offset + 1); - this.offset += 9; - return value; - - // int 8 - case 0xd0: - value = this.view.getInt8(this.offset + 1); - this.offset += 2; - return value; - - // int 16 - case 0xd1: - value = this.view.getInt16(this.offset + 1); - this.offset += 3; - return value; - - // int 32 - case 0xd2: - value = this.view.getInt32(this.offset + 1); - this.offset += 5; - return value; - - // int 64 - case 0xd3: - value = getInt64(this.view, this.offset + 1); - this.offset += 9; - return value; - - // fixext 1 - case 0xd4: - length = 1; - this.offset++; - return this.ext(length); - - // fixext 2 - case 0xd5: - length = 2; - this.offset++; - return this.ext(length); - - // fixext 4 - case 0xd6: - length = 4; - this.offset++; - return this.ext(length); - - // fixext 8 - case 0xd7: - length = 8; - this.offset++; - return this.ext(length); - - // fixext 16 - case 0xd8: - length = 16; - this.offset++; - return this.ext(length); - - // str8 - case 0xd9: - length = this.view.getUint8(this.offset + 1); - this.offset += 2; - return this.str(length); - - // str 16 - case 0xda: - length = this.view.getUint16(this.offset + 1); - this.offset += 3; - return this.str(length); - - // str 32 - case 0xdb: - length = this.view.getUint32(this.offset + 1); - this.offset += 5; - return this.str(length); - - // array 16 - case 0xdc: - length = this.view.getUint16(this.offset + 1); - this.offset += 3; - return this.array(length); - - // array 32 - case 0xdd: - length = this.view.getUint32(this.offset + 1); - this.offset += 5; - return this.array(length); - - // map 16 - case 0xde: - length = this.view.getUint16(this.offset + 1); - this.offset += 3; - return this.map(length); - - // map 32 - case 0xdf: - length = this.view.getUint32(this.offset + 1); - this.offset += 5; - return this.map(length); - } - throw new Error("Unknown type 0x" + type.toString(16)); - }; - - function decode(buffer) { - var view = new DataView(buffer); - var decoder = new Decoder(view); - var value = decoder.parse(); - if (decoder.offset !== buffer.byteLength) - throw new Error((buffer.byteLength - decoder.offset) + " trailing bytes"); - return value; - } - - function encodeableKeys(value, sparse) { - var keys = []; // TODO: use Object.keys when we are able to transpile to ES3 - for (var key in value) { - if (!value.hasOwnProperty(key)) continue; - keys.push(key); - } - return keys.filter(function (e) { - var val = value[e], type = typeof(val); - return (!sparse || (val !== undefined && val !== null)) && ('function' !== type || !!val.toJSON); - }) - } - - function encode(value, view, offset, sparse) { - var type = typeof value; - - // Strings Bytes - // There are four string types: fixstr/str8/str16/str32 - if (type === "string") { - var length = utf8ByteCount(value); - - // fixstr - if (length < 0x20) { - view.setUint8(offset, length | 0xa0); - utf8Write(view, offset + 1, value); - return 1 + length; - } - - // str8 - if (length < 0x100) { - view.setUint8(offset, 0xd9); - view.setUint8(offset + 1, length); - utf8Write(view, offset + 2, value); - return 2 + length; - } - - // str16 - if (length < 0x10000) { - view.setUint8(offset, 0xda); - view.setUint16(offset + 1, length); - utf8Write(view, offset + 3, value); - return 3 + length; - } - // str32 - if (length < 0x100000000) { - view.setUint8(offset, 0xdb); - view.setUint32(offset + 1, length); - utf8Write(view, offset + 5, value); - return 5 + length; - } - } - - if(ArrayBuffer.isView && ArrayBuffer.isView(value)) { - // extract the arraybuffer and fallthrough - value = value.buffer; - } - - // There are three bin types: bin8/bin16/bin32 - if (value instanceof ArrayBuffer) { - var length = value.byteLength; - - // bin8 - if (length < 0x100) { - view.setUint8(offset, 0xc4); - view.setUint8(offset + 1, length); - (new Uint8Array(view.buffer)).set(new Uint8Array(value), offset + 2); - return 2 + length; - } - - // bin16 - if (length < 0x10000) { - view.setUint8(offset, 0xc5); - view.setUint16(offset + 1, length); - (new Uint8Array(view.buffer)).set(new Uint8Array(value), offset + 3); - return 3 + length; - } - - // bin 32 - if (length < 0x100000000) { - view.setUint8(offset, 0xc6); - view.setUint32(offset + 1, length); - (new Uint8Array(view.buffer)).set(new Uint8Array(value), offset + 5); - return 5 + length; - } - } - - if (type === "number") { - - // Floating Point - // NOTE: We're always using float64 - if (Math.floor(value) !== value) { - view.setUint8(offset, 0xcb); - view.setFloat64(offset + 1, value); - return 9; - } - - // Integers - if (value >= 0) { - // positive fixnum - if (value < 0x80) { - view.setUint8(offset, value); - return 1; - } - // uint 8 - if (value < 0x100) { - view.setUint8(offset, 0xcc); - view.setUint8(offset + 1, value); - return 2; - } - // uint 16 - if (value < 0x10000) { - view.setUint8(offset, 0xcd); - view.setUint16(offset + 1, value); - return 3; - } - // uint 32 - if (value < 0x100000000) { - view.setUint8(offset, 0xce); - view.setUint32(offset + 1, value); - return 5; - } - // uint 64 - if (value < 0x10000000000000000) { - view.setUint8(offset, 0xcf); - setUint64(view, offset + 1, value); - return 9; - } - throw new Error("Number too big 0x" + value.toString(16)); - } - - // negative fixnum - if (value >= -0x20) { - view.setInt8(offset, value); - return 1; - } - // int 8 - if (value >= -0x80) { - view.setUint8(offset, 0xd0); - view.setInt8(offset + 1, value); - return 2; - } - // int 16 - if (value >= -0x8000) { - view.setUint8(offset, 0xd1); - view.setInt16(offset + 1, value); - return 3; - } - // int 32 - if (value >= -0x80000000) { - view.setUint8(offset, 0xd2); - view.setInt32(offset + 1, value); - return 5; - } - // int 64 - if (value >= -0x8000000000000000) { - view.setUint8(offset, 0xd3); - setInt64(view, offset + 1, value); - return 9; - } - throw new Error("Number too small -0x" + (-value).toString(16).substr(1)); - } - - // undefined - use d4 (NON-STANDARD) - if (type === "undefined") { - if(sparse) return 0; - view.setUint8(offset, 0xd4); - view.setUint8(offset + 1, 0x00); - view.setUint8(offset + 2, 0x00); - return 3; - } - - // null - if (value === null) { - if(sparse) return 0; - view.setUint8(offset, 0xc0); - return 1; - } - - // Boolean - if (type === "boolean") { - view.setUint8(offset, value ? 0xc3 : 0xc2); - return 1; - } - - if('function' === typeof value.toJSON) - return encode(value.toJSON(), view, offset, sparse); - - // Container Types - if (type === "object") { - var length, size = 0; - var isArray = Array.isArray(value); - - if (isArray) { - length = value.length; - } else { - var keys = encodeableKeys(value, sparse); - length = keys.length; - } - - var size; - if (length < 0x10) { - view.setUint8(offset, length | ( isArray ? 0x90 : 0x80)); - size = 1; - } else if (length < 0x10000) { - view.setUint8(offset, isArray ? 0xdc : 0xde); - view.setUint16(offset + 1, length); - size = 3; - } else if (length < 0x100000000) { - view.setUint8(offset, isArray ? 0xdd : 0xdf); - view.setUint32(offset + 1, length); - size = 5; - } - - if (isArray) { - for (var i = 0; i < length; i++) { - size += encode(value[i], view, offset + size, sparse); - } - } else { - for (var i = 0; i < length; i++) { - var key = keys[i]; - size += encode(key, view, offset + size); - size += encode(value[key], view, offset + size, sparse); - } - } - - return size; - } - if(type === "function") - return 0; - - throw new Error("Unknown type " + type); - } - - function sizeof(value, sparse) { - var type = typeof value; - - // fixstr or str8 or str16 or str32 - if (type === "string") { - var length = utf8ByteCount(value); - if (length < 0x20) { - return 1 + length; - } - if (length < 0x100) { - return 2 + length; - } - if (length < 0x10000) { - return 3 + length; - } - if (length < 0x100000000) { - return 5 + length; - } - } - - if(ArrayBuffer.isView && ArrayBuffer.isView(value)) { - // extract the arraybuffer and fallthrough - value = value.buffer; - } - - // bin8 or bin16 or bin32 - if (value instanceof ArrayBuffer) { - var length = value.byteLength; - if (length < 0x100) { - return 2 + length; - } - if (length < 0x10000) { - return 3 + length; - } - if (length < 0x100000000) { - return 5 + length; - } - } - - if (type === "number") { - // Floating Point (32 bits) - // double - if (Math.floor(value) !== value) - return 9; - - // Integers - if (value >= 0) { - // positive fixint - if (value < 0x80) - return 1; - // uint 8 - if (value < 0x100) - return 2; - // uint 16 - if (value < 0x10000) - return 3; - // uint 32 - if (value < 0x100000000) - return 5; - // uint 64 - if (value < 0x10000000000000000) - return 9; - // Too big - throw new Error("Number too big 0x" + value.toString(16)); - } - // negative fixint - if (value >= -0x20) - return 1; - // int 8 - if (value >= -0x80) - return 2; - // int 16 - if (value >= -0x8000) - return 3; - // int 32 - if (value >= -0x80000000) - return 5; - // int 64 - if (value >= -0x8000000000000000) - return 9; - // Too small - throw new Error("Number too small -0x" + value.toString(16).substr(1)); - } - - // Boolean - if (type === "boolean") return 1; - - // undefined, null - if (value === null) return sparse ? 0 : 1; - if (value === undefined) return sparse ? 0 : 3; - - if('function' === typeof value.toJSON) - return sizeof(value.toJSON(), sparse); - - // Container Types - if (type === "object") { - var length, size = 0; - if (Array.isArray(value)) { - length = value.length; - for (var i = 0; i < length; i++) { - size += sizeof(value[i], sparse); - } - } else { - var keys = encodeableKeys(value, sparse) - length = keys.length; - for (var i = 0; i < length; i++) { - var key = keys[i]; - size += sizeof(key) + sizeof(value[key], sparse); - } - } - if (length < 0x10) { - return 1 + size; - } - if (length < 0x10000) { - return 3 + size; - } - if (length < 0x100000000) { - return 5 + size; - } - throw new Error("Array or object too long 0x" + length.toString(16)); - } - if(type === "function") - return 0; - - throw new Error("Unknown type " + type); - } - - return exports; -})(); - -export default msgpack; diff --git a/browser/lib/util/msgpack.ts b/browser/lib/util/msgpack.ts new file mode 100644 index 0000000000..504cbc872c --- /dev/null +++ b/browser/lib/util/msgpack.ts @@ -0,0 +1,837 @@ +function inspect(buffer: undefined | ArrayBuffer | DataView) { + if (buffer === undefined) + return "undefined"; + let view; + let type; + if ( buffer instanceof ArrayBuffer) { + type = "ArrayBuffer"; + view = new DataView(buffer); + } else if ( buffer instanceof DataView) { + type = "DataView"; + view = buffer; + } + if (!view) + return JSON.stringify(buffer); + const bytes = []; + for (let i = 0; i < buffer.byteLength; i++) { + if (i > 20) { + bytes.push("..."); + break; + } + let byte_ = view.getUint8(i).toString(16); + if (byte_.length === 1) + byte_ = "0" + byte_; + bytes.push(byte_); + } + return "<" + type + " " + bytes.join(" ") + ">"; +} + +// Encode string as utf8 into dataview at offset +function utf8Write(view: DataView, offset: number, string: string) { + for (let i = 0, l = string.length; i < l; i++) { + const codePoint = string.charCodeAt(i); + + // One byte of UTF-8 + if (codePoint < 0x80) { + view.setUint8(offset++, codePoint >>> 0 & 0x7f | 0x00); + continue; + } + + // Two bytes of UTF-8 + if (codePoint < 0x800) { + view.setUint8(offset++, codePoint >>> 6 & 0x1f | 0xc0); + view.setUint8(offset++, codePoint >>> 0 & 0x3f | 0x80); + continue; + } + + // Three bytes of UTF-8. + if (codePoint < 0x10000) { + view.setUint8(offset++, codePoint >>> 12 & 0x0f | 0xe0); + view.setUint8(offset++, codePoint >>> 6 & 0x3f | 0x80); + view.setUint8(offset++, codePoint >>> 0 & 0x3f | 0x80); + continue; + } + + // Four bytes of UTF-8 + if (codePoint < 0x110000) { + view.setUint8(offset++, codePoint >>> 18 & 0x07 | 0xf0); + view.setUint8(offset++, codePoint >>> 12 & 0x3f | 0x80); + view.setUint8(offset++, codePoint >>> 6 & 0x3f | 0x80); + view.setUint8(offset++, codePoint >>> 0 & 0x3f | 0x80); + continue; + } + throw new Error("bad codepoint " + codePoint); + } +} + + +function utf8Read(view: DataView, offset: number, length: number) { + let string = ""; + for (let i = offset, end = offset + length; i < end; i++) { + const byte_ = view.getUint8(i); + // One byte character + if ((byte_ & 0x80) === 0x00) { + string += String.fromCharCode(byte_); + continue; + } + // Two byte character + if ((byte_ & 0xe0) === 0xc0) { + string += String.fromCharCode(((byte_ & 0x0f) << 6) | (view.getUint8(++i) & 0x3f)); + continue; + } + // Three byte character + if ((byte_ & 0xf0) === 0xe0) { + string += String.fromCharCode(((byte_ & 0x0f) << 12) | ((view.getUint8(++i) & 0x3f) << 6) | ((view.getUint8(++i) & 0x3f) << 0)); + continue; + } + // Four byte character + if ((byte_ & 0xf8) === 0xf0) { + string += String.fromCharCode(((byte_ & 0x07) << 18) | ((view.getUint8(++i) & 0x3f) << 12) | ((view.getUint8(++i) & 0x3f) << 6) | ((view.getUint8(++i) & 0x3f) << 0)); + continue; + } + throw new Error("Invalid byte " + byte_.toString(16)); + } + return string; +} + + +function utf8ByteCount(string: string) { + let count = 0; + for (let i = 0, l = string.length; i < l; i++) { + const codePoint = string.charCodeAt(i); + if (codePoint < 0x80) { + count += 1; + continue; + } + if (codePoint < 0x800) { + count += 2; + continue; + } + if (codePoint < 0x10000) { + count += 3; + continue; + } + if (codePoint < 0x110000) { + count += 4; + continue; + } + throw new Error("bad codepoint " + codePoint); + } + return count; +} + + +function encode(value: unknown, sparse?: boolean) { + const size = sizeof(value, sparse); + if(size === 0) + return undefined; + const buffer = new ArrayBuffer(size); + const view = new DataView(buffer); + _encode(value, view, 0, sparse); + return buffer; +}; + +const SH_L_32 = (1 << 16) * (1 << 16), SH_R_32 = 1 / SH_L_32; +function getInt64(view: DataView, offset: number) { + offset = offset || 0; + return view.getInt32(offset) * SH_L_32 + view.getUint32(offset + 4); +} + +function getUint64(view: DataView, offset: number) { + offset = offset || 0; + return view.getUint32(offset) * SH_L_32 + view.getUint32(offset + 4); +} + +function setInt64(view: DataView, offset: number, val: number) { + if (val < 0x8000000000000000) { + view.setInt32(offset, Math.floor(val * SH_R_32)); + view.setInt32(offset + 4, val & -1); + } else { + view.setUint32(offset, 0x7fffffff); + view.setUint32(offset + 4, 0x7fffffff); + } +} + +function setUint64(view: DataView, offset: number, val: number) { + if (val < 0x10000000000000000) { + view.setUint32(offset, Math.floor(val * SH_R_32)); + view.setInt32(offset + 4, val & -1); + } else { + view.setUint32(offset, 0xffffffff); + view.setUint32(offset + 4, 0xffffffff); + } +} + +// https://gist.github.com/frsyuki/5432559 - v5 spec +// +// I've used one extension point from `fixext 1` to store `undefined`. On the wire this +// should translate to exactly 0xd40000 +// +// +--------+--------+--------+ +// | 0xd4 | 0x00 | 0x00 | +// +--------+--------+--------+ +// ^ fixext | ^ value part unused (fixed to be 0) +// ^ indicates undefined value +// + +class Decoder { + offset: number; + view: DataView; + + constructor(view: DataView, offset?: number) { + this.offset = offset || 0; + this.view = view; + } + + + map = (length: number) => { + const value: { [key: string]: ArrayBuffer } = {}; + for (let i = 0; i < length; i++) { + const key = this.parse(); + value[key as string] = this.parse() as ArrayBuffer; + } + return value; + }; + + bin = (length: number) => { + const value = new ArrayBuffer(length); + (new Uint8Array(value)).set(new Uint8Array(this.view.buffer, this.offset, length), 0); + this.offset += length; + return value; + }; + + buf = this.bin; + + str = (length: number) => { + const value = utf8Read(this.view, this.offset, length); + this.offset += length; + return value; + }; + + array = (length: number) => { + const value = new Array(length); + for (let i = 0; i < length; i++) { + value[i] = this.parse(); + } + return value; + }; + + ext = (length: number) => { + this.offset += length; + return { + type: this.view.getInt8(this.offset), + data: this.buf(length), + } + }; + + parse = (): unknown => { + const type = this.view.getUint8(this.offset); + let value, length; + + // Positive FixInt - 0xxxxxxx + if ((type & 0x80) === 0x00) { + this.offset++; + return type; + } + + // FixMap - 1000xxxx + if ((type & 0xf0) === 0x80) { + length = type & 0x0f; + this.offset++; + return this.map(length); + } + + // FixArray - 1001xxxx + if ((type & 0xf0) === 0x90) { + length = type & 0x0f; + this.offset++; + return this.array(length); + } + + // FixStr - 101xxxxx + if ((type & 0xe0) === 0xa0) { + length = type & 0x1f; + this.offset++; + return this.str(length); + } + + // Negative FixInt - 111xxxxx + if ((type & 0xe0) === 0xe0) { + value = this.view.getInt8(this.offset); + this.offset++; + return value; + } + + switch (type) { + + // nil + case 0xc0: + this.offset++; + return null; + + // 0xc1 never used - use for undefined (NON-STANDARD) + case 0xc1: + this.offset++; + return undefined; + + // false + case 0xc2: + this.offset++; + return false; + + // true + case 0xc3: + this.offset++; + return true; + + // bin 8 + case 0xc4: + length = this.view.getUint8(this.offset + 1); + this.offset += 2; + return this.bin(length); + + // bin 16 + case 0xc5: + length = this.view.getUint16(this.offset + 1); + this.offset += 3; + return this.bin(length); + + // bin 32 + case 0xc6: + length = this.view.getUint32(this.offset + 1); + this.offset += 5; + return this.bin(length); + + // ext 8 + case 0xc7: + length = this.view.getUint8(this.offset + 1); + this.offset += 2; + return this.ext(length); + + // ext 16 + case 0xc8: + length = this.view.getUint16(this.offset + 1); + this.offset += 3; + return this.ext(length); + + // ext 32 + case 0xc9: + length = this.view.getUint32(this.offset + 1); + this.offset += 5; + return this.ext(length); + + // float 32 + case 0xca: + value = this.view.getFloat32(this.offset + 1); + this.offset += 5; + return value; + + // float 64 + case 0xcb: + value = this.view.getFloat64(this.offset + 1); + this.offset += 9; + return value; + + // uint8 + case 0xcc: + value = this.view.getUint8(this.offset + 1); + this.offset += 2; + return value; + + // uint 16 + case 0xcd: + value = this.view.getUint16(this.offset + 1); + this.offset += 3; + return value; + + // uint 32 + case 0xce: + value = this.view.getUint32(this.offset + 1); + this.offset += 5; + return value; + + // uint 64 + case 0xcf: + value = getUint64(this.view, this.offset + 1); + this.offset += 9; + return value; + + // int 8 + case 0xd0: + value = this.view.getInt8(this.offset + 1); + this.offset += 2; + return value; + + // int 16 + case 0xd1: + value = this.view.getInt16(this.offset + 1); + this.offset += 3; + return value; + + // int 32 + case 0xd2: + value = this.view.getInt32(this.offset + 1); + this.offset += 5; + return value; + + // int 64 + case 0xd3: + value = getInt64(this.view, this.offset + 1); + this.offset += 9; + return value; + + // fixext 1 + case 0xd4: + length = 1; + this.offset++; + return this.ext(length); + + // fixext 2 + case 0xd5: + length = 2; + this.offset++; + return this.ext(length); + + // fixext 4 + case 0xd6: + length = 4; + this.offset++; + return this.ext(length); + + // fixext 8 + case 0xd7: + length = 8; + this.offset++; + return this.ext(length); + + // fixext 16 + case 0xd8: + length = 16; + this.offset++; + return this.ext(length); + + // str8 + case 0xd9: + length = this.view.getUint8(this.offset + 1); + this.offset += 2; + return this.str(length); + + // str 16 + case 0xda: + length = this.view.getUint16(this.offset + 1); + this.offset += 3; + return this.str(length); + + // str 32 + case 0xdb: + length = this.view.getUint32(this.offset + 1); + this.offset += 5; + return this.str(length); + + // array 16 + case 0xdc: + length = this.view.getUint16(this.offset + 1); + this.offset += 3; + return this.array(length); + + // array 32 + case 0xdd: + length = this.view.getUint32(this.offset + 1); + this.offset += 5; + return this.array(length); + + // map 16 + case 0xde: + length = this.view.getUint16(this.offset + 1); + this.offset += 3; + return this.map(length); + + // map 32 + case 0xdf: + length = this.view.getUint32(this.offset + 1); + this.offset += 5; + return this.map(length); + } + throw new Error("Unknown type 0x" + type.toString(16)); + }; +} + +function decode(buffer: ArrayBuffer) { + const view = new DataView(buffer); + const decoder = new Decoder(view); + const value = decoder.parse(); + if (decoder.offset !== buffer.byteLength) + throw new Error((buffer.byteLength - decoder.offset) + " trailing bytes"); + return value; +} + +function encodeableKeys(value: { [key: string]: unknown }, sparse?: boolean) { + return Object.keys(value).filter(function (e) { + const val = value[e], type = typeof(val); + return (!sparse || (val !== undefined && val !== null)) && ('function' !== type || !!(val as Date).toJSON); + }) +} + +function _encode(value: unknown, view: DataView, offset: number, sparse?: boolean): number { + const type = typeof value; + + switch (typeof value) { + case 'string': + console.log(value.length); + break; + case 'number': + console.log(Math.sqrt(value)); + } + + // Strings Bytes + // There are four string types: fixstr/str8/str16/str32 + if (typeof value === 'string') { + const length = utf8ByteCount(value); + + // fixstr + if (length < 0x20) { + view.setUint8(offset, length | 0xa0); + utf8Write(view, offset + 1, value); + return 1 + length; + } + + // str8 + if (length < 0x100) { + view.setUint8(offset, 0xd9); + view.setUint8(offset + 1, length); + utf8Write(view, offset + 2, value); + return 2 + length; + } + + // str16 + if (length < 0x10000) { + view.setUint8(offset, 0xda); + view.setUint16(offset + 1, length); + utf8Write(view, offset + 3, value); + return 3 + length; + } + // str32 + if (length < 0x100000000) { + view.setUint8(offset, 0xdb); + view.setUint32(offset + 1, length); + utf8Write(view, offset + 5, value); + return 5 + length; + } + } + + if(ArrayBuffer.isView && ArrayBuffer.isView(value)) { + // extract the arraybuffer and fallthrough + value = value.buffer; + } + + // There are three bin types: bin8/bin16/bin32 + if (value instanceof ArrayBuffer) { + const length = value.byteLength; + + // bin8 + if (length < 0x100) { + view.setUint8(offset, 0xc4); + view.setUint8(offset + 1, length); + (new Uint8Array(view.buffer)).set(new Uint8Array(value), offset + 2); + return 2 + length; + } + + // bin16 + if (length < 0x10000) { + view.setUint8(offset, 0xc5); + view.setUint16(offset + 1, length); + (new Uint8Array(view.buffer)).set(new Uint8Array(value), offset + 3); + return 3 + length; + } + + // bin 32 + if (length < 0x100000000) { + view.setUint8(offset, 0xc6); + view.setUint32(offset + 1, length); + (new Uint8Array(view.buffer)).set(new Uint8Array(value), offset + 5); + return 5 + length; + } + } + + if (typeof value === 'number') { + + // Floating Point + // NOTE: We're always using float64 + if (Math.floor(value) !== value) { + view.setUint8(offset, 0xcb); + view.setFloat64(offset + 1, value); + return 9; + } + + // Integers + if (value >= 0) { + // positive fixnum + if (value < 0x80) { + view.setUint8(offset, value); + return 1; + } + // uint 8 + if (value < 0x100) { + view.setUint8(offset, 0xcc); + view.setUint8(offset + 1, value); + return 2; + } + // uint 16 + if (value < 0x10000) { + view.setUint8(offset, 0xcd); + view.setUint16(offset + 1, value); + return 3; + } + // uint 32 + if (value < 0x100000000) { + view.setUint8(offset, 0xce); + view.setUint32(offset + 1, value); + return 5; + } + // uint 64 + if (value < 0x10000000000000000) { + view.setUint8(offset, 0xcf); + setUint64(view, offset + 1, value); + return 9; + } + throw new Error("Number too big 0x" + value.toString(16)); + } + + // negative fixnum + if (value >= -0x20) { + view.setInt8(offset, value); + return 1; + } + // int 8 + if (value >= -0x80) { + view.setUint8(offset, 0xd0); + view.setInt8(offset + 1, value); + return 2; + } + // int 16 + if (value >= -0x8000) { + view.setUint8(offset, 0xd1); + view.setInt16(offset + 1, value); + return 3; + } + // int 32 + if (value >= -0x80000000) { + view.setUint8(offset, 0xd2); + view.setInt32(offset + 1, value); + return 5; + } + // int 64 + if (value >= -0x8000000000000000) { + view.setUint8(offset, 0xd3); + setInt64(view, offset + 1, value); + return 9; + } + throw new Error("Number too small -0x" + (-value).toString(16).substr(1)); + } + + // undefined - use d4 (NON-STANDARD) + if (type === "undefined") { + if(sparse) return 0; + view.setUint8(offset, 0xd4); + view.setUint8(offset + 1, 0x00); + view.setUint8(offset + 2, 0x00); + return 3; + } + + // null + if (value === null) { + if(sparse) return 0; + view.setUint8(offset, 0xc0); + return 1; + } + + // Boolean + if (type === "boolean") { + view.setUint8(offset, value ? 0xc3 : 0xc2); + return 1; + } + + if('function' === typeof (value as Date).toJSON) + return _encode((value as Date).toJSON(), view, offset, sparse); + + // Container Types + if (type === "object") { + let length: number, size = 0; + let keys: string[] | undefined; + const isArray = Array.isArray(value); + + if (isArray) { + length = (value as unknown[]).length; + } else { + keys = encodeableKeys(value as { [key: string]: unknown }, sparse); + length = keys.length; + } + + if (length < 0x10) { + view.setUint8(offset, length | ( isArray ? 0x90 : 0x80)); + size = 1; + } else if (length < 0x10000) { + view.setUint8(offset, isArray ? 0xdc : 0xde); + view.setUint16(offset + 1, length); + size = 3; + } else if (length < 0x100000000) { + view.setUint8(offset, isArray ? 0xdd : 0xdf); + view.setUint32(offset + 1, length); + size = 5; + } + + if (isArray) { + for (let i = 0; i < length; i++) { + size += _encode((value as unknown[])[i], view, offset + size, sparse); + } + } else if (keys) { + for (let i = 0; i < length; i++) { + const key = keys[i]; + size += _encode(key, view, offset + size); + size += _encode((value as { [key: string]: unknown })[key], view, offset + size, sparse); + } + } + + return size; + } + if(type === "function") + return 0; + + throw new Error("Unknown type " + type); +} + +function sizeof(value: unknown, sparse?: boolean): number { + const type = typeof value; + + // fixstr or str8 or str16 or str32 + if (type === "string") { + const length = utf8ByteCount(value as string); + if (length < 0x20) { + return 1 + length; + } + if (length < 0x100) { + return 2 + length; + } + if (length < 0x10000) { + return 3 + length; + } + if (length < 0x100000000) { + return 5 + length; + } + } + + if(ArrayBuffer.isView && ArrayBuffer.isView(value)) { + // extract the arraybuffer and fallthrough + value = value.buffer; + } + + // bin8 or bin16 or bin32 + if (value instanceof ArrayBuffer) { + const length = value.byteLength; + if (length < 0x100) { + return 2 + length; + } + if (length < 0x10000) { + return 3 + length; + } + if (length < 0x100000000) { + return 5 + length; + } + } + + if (typeof value === 'number') { + // Floating Point (32 bits) + // double + if (Math.floor(value) !== value) + return 9; + + // Integers + if (value >= 0) { + // positive fixint + if (value < 0x80) + return 1; + // uint 8 + if (value < 0x100) + return 2; + // uint 16 + if (value < 0x10000) + return 3; + // uint 32 + if (value < 0x100000000) + return 5; + // uint 64 + if (value < 0x10000000000000000) + return 9; + // Too big + throw new Error("Number too big 0x" + value.toString(16)); + } + // negative fixint + if (value >= -0x20) + return 1; + // int 8 + if (value >= -0x80) + return 2; + // int 16 + if (value >= -0x8000) + return 3; + // int 32 + if (value >= -0x80000000) + return 5; + // int 64 + if (value >= -0x8000000000000000) + return 9; + // Too small + throw new Error("Number too small -0x" + value.toString(16).substr(1)); + } + + // Boolean + if (type === "boolean") return 1; + + // undefined, null + if (value === null) return sparse ? 0 : 1; + if (value === undefined) return sparse ? 0 : 3; + + if('function' === typeof (value as Date).toJSON) + return sizeof((value as Date).toJSON(), sparse); + + // Container Types + if (type === "object") { + let length: number, size = 0; + if (Array.isArray(value)) { + length = value.length; + for (let i = 0; i < length; i++) { + size += sizeof(value[i], sparse); + } + } else { + const keys = encodeableKeys(value as { [key: string]: unknown }, sparse) + length = keys.length; + for (let i = 0; i < length; i++) { + const key = keys[i]; + size += sizeof(key) + sizeof((value as { [key: string]: unknown })[key], sparse); + } + } + if (length < 0x10) { + return 1 + size; + } + if (length < 0x10000) { + return 3 + size; + } + if (length < 0x100000000) { + return 5 + size; + } + throw new Error("Array or object too long 0x" + length.toString(16)); + } + if(type === "function") + return 0; + + throw new Error("Unknown type " + type); +} + +export default { + encode, + decode, + inspect, + utf8Write, + utf8Read, + utf8ByteCount, +} diff --git a/browser/lib/util/nativescript-webstorage.js b/browser/lib/util/nativescript-webstorage.js index b9916739d0..e25b29eace 100644 --- a/browser/lib/util/nativescript-webstorage.js +++ b/browser/lib/util/nativescript-webstorage.js @@ -1,4 +1,4 @@ -import Utils from '../../../common/lib/util/utils'; +import * as Utils from '../../../common/lib/util/utils'; var WebStorage = (function() { var appSettings = require("application-settings"); diff --git a/browser/lib/util/webstorage.js b/browser/lib/util/webstorage.js deleted file mode 100644 index 659744a540..0000000000 --- a/browser/lib/util/webstorage.js +++ /dev/null @@ -1,73 +0,0 @@ -import Utils from '../../../common/lib/util/utils'; - -var WebStorage = (function() { - var sessionSupported, - localSupported, - test = 'ablyjs-storage-test'; - - /* Even just accessing the session/localStorage object can throw a - * security exception in some circumstances with some browsers. In - * others, calling setItem will throw. So have to check in this - * somewhat roundabout way. (If unsupported or no global object, - * will throw on accessing a property of undefined) */ - try { - global.sessionStorage.setItem(test, test); - global.sessionStorage.removeItem(test); - sessionSupported = true; - } catch(e) { - sessionSupported = false; - } - - try { - global.localStorage.setItem(test, test); - global.localStorage.removeItem(test); - localSupported = true; - } catch(e) { - localSupported = false; - } - - function WebStorage() {} - - function storageInterface(session) { - return session ? global.sessionStorage : global.localStorage; - } - - function set(name, value, ttl, session) { - var wrappedValue = {value: value}; - if(ttl) { - wrappedValue.expires = Utils.now() + ttl; - } - return storageInterface(session).setItem(name, JSON.stringify(wrappedValue)); - } - - function get(name, session) { - var rawItem = storageInterface(session).getItem(name); - if(!rawItem) return null; - var wrappedValue = JSON.parse(rawItem); - if(wrappedValue.expires && (wrappedValue.expires < Utils.now())) { - storageInterface(session).removeItem(name); - return null; - } - return wrappedValue.value; - } - - function remove(name, session) { - return storageInterface(session).removeItem(name); - } - - if(localSupported) { - WebStorage.set = function(name, value, ttl) { return set(name, value, ttl, false); }; - WebStorage.get = function(name) { return get(name, false); }; - WebStorage.remove = function(name) { return remove(name, false); }; - } - - if(sessionSupported) { - WebStorage.setSession = function(name, value, ttl) { return set(name, value, ttl, true); }; - WebStorage.getSession = function(name) { return get(name, true); }; - WebStorage.removeSession = function(name) { return remove(name, true); }; - } - - return WebStorage; -})(); - -export default WebStorage; diff --git a/browser/lib/util/webstorage.ts b/browser/lib/util/webstorage.ts new file mode 100644 index 0000000000..9432418ed8 --- /dev/null +++ b/browser/lib/util/webstorage.ts @@ -0,0 +1,81 @@ +import * as Utils from '../../../common/lib/util/utils'; + +let sessionSupported: boolean; +let localSupported: boolean; +const test = 'ablyjs-storage-test'; + +/* Even just accessing the session/localStorage object can throw a + * security exception in some circumstances with some browsers. In + * others, calling setItem will throw. So have to check in this + * somewhat roundabout way. (If unsupported or no global object, + * will throw on accessing a property of undefined) */ +try { + global.sessionStorage.setItem(test, test); + global.sessionStorage.removeItem(test); + sessionSupported = true; +} catch(e) { + sessionSupported = false; +} + +try { + global.localStorage.setItem(test, test); + global.localStorage.removeItem(test); + localSupported = true; +} catch(e) { + localSupported = false; +} + +function storageInterface(session: any) { + return session ? global.sessionStorage : global.localStorage; +} + +function _set(name: string, value: string, ttl: number | undefined, session: any) { + const wrappedValue: Record = {value: value}; + if(ttl) { + wrappedValue.expires = Utils.now() + ttl; + } + return storageInterface(session).setItem(name, JSON.stringify(wrappedValue)); +} + +function _get(name: string, session: any) { + const rawItem = storageInterface(session).getItem(name); + if(!rawItem) return null; + const wrappedValue = JSON.parse(rawItem); + if(wrappedValue.expires && (wrappedValue.expires < Utils.now())) { + storageInterface(session).removeItem(name); + return null; + } + return wrappedValue.value; +} + +function _remove(name: string, session: any) { + return storageInterface(session).removeItem(name); +} + +let set: (name: string, value: string, ttl?: number) => void; +let get: (name: string) => any; +let remove: (name: string) => void; +let setSession: (name: string, value: string, ttl?: number) => void; +let getSession: (name: string) => any; +let removeSession: (name: string) => void; + +if(localSupported) { + set = function(name: string, value: string, ttl?: number) { return _set(name, value, ttl, false); }; + get = function(name) { return _get(name, false); }; + remove = function(name: string) { return _remove(name, false); }; +} + +if(sessionSupported) { + setSession = function(name: string, value: string, ttl?: number) { return _set(name, value, ttl, true); }; + getSession = function(name: string) { return _get(name, true); }; + removeSession = function(name: string) { return _remove(name, true); }; +} + +export { + set, + get, + remove, + setSession, + getSession, + removeSession, +}; diff --git a/common/constants/HttpMethods.ts b/common/constants/HttpMethods.ts new file mode 100644 index 0000000000..616a31fa73 --- /dev/null +++ b/common/constants/HttpMethods.ts @@ -0,0 +1,9 @@ +enum HttpMethods { + Get = 'get', + Delete = 'delete', + Post = 'post', + Put = 'put', + Patch = 'patch', +} + +export default HttpMethods; diff --git a/common/constants/HttpStatusCodes.ts b/common/constants/HttpStatusCodes.ts new file mode 100644 index 0000000000..3accad84e9 --- /dev/null +++ b/common/constants/HttpStatusCodes.ts @@ -0,0 +1,15 @@ +enum HttpStatusCodes { + Success = 200, + NoContent = 204, + BadRequest = 400, + Unauthorized = 401, + Forbidden = 403, + RequestTimeout = 408, + InternalServerError = 500, +} + +export function isSuccessCode(statusCode: number) { + return statusCode >= HttpStatusCodes.Success && statusCode < HttpStatusCodes.BadRequest; +} + +export default HttpStatusCodes; diff --git a/common/constants/TransportNames.ts b/common/constants/TransportNames.ts new file mode 100644 index 0000000000..d03279423e --- /dev/null +++ b/common/constants/TransportNames.ts @@ -0,0 +1,9 @@ +enum TransportNames { + WebSocket = 'web_socket', + Comet = 'comet', + XhrStreaming = 'xhr_streaming', + XhrPolling = 'xhr_polling', + JsonP = 'jsonp', +} + +export default TransportNames; diff --git a/common/constants/XHRStates.ts b/common/constants/XHRStates.ts new file mode 100644 index 0000000000..a10b985e83 --- /dev/null +++ b/common/constants/XHRStates.ts @@ -0,0 +1,8 @@ +enum XHRStates { + REQ_SEND = 0, + REQ_RECV = 1, + REQ_RECV_POLL = 2, + REQ_RECV_STREAM = 3, +} + +export default XHRStates; diff --git a/common/lib/client/auth.js b/common/lib/client/auth.ts similarity index 58% rename from common/lib/client/auth.js rename to common/lib/client/auth.ts index 92a7d9b2a5..ce320c97ba 100644 --- a/common/lib/client/auth.js +++ b/common/lib/client/auth.ts @@ -1,112 +1,137 @@ import Logger from '../util/logger'; import Platform from 'platform'; -import Utils from '../util/utils'; +import * as Utils from '../util/utils'; import Http from 'platform-http'; import Multicaster from '../util/multicaster'; -import BufferUtils from 'platform-bufferutils'; +import * as BufferUtils from 'platform-bufferutils'; import ErrorInfo from '../types/errorinfo'; import Base64 from 'platform-base64'; import HmacSHA256 from 'crypto-js/build/hmac-sha256'; import { stringify as stringifyBase64 } from 'crypto-js/build/enc-base64'; - -var Auth = (function() { - var MAX_TOKEN_LENGTH = Math.pow(2, 17); - function noop() {} - function random() { return ('000000' + Math.floor(Math.random() * 1E16)).slice(-16); } - function normaliseAuthcallbackError(err) { - /* A client auth callback may give errors in any number of formats; normalise to an errorinfo */ - if(!Utils.isErrorInfo(err)) { - return new ErrorInfo(Utils.inspectError(err), err.code || 40170, err.statusCode || 401); - } - /* network errors will not have an inherent error code */ - if(!err.code) { - if(err.statusCode === 403) { - err.code = 40300; - } else { - err.code = 40170; - /* normalise statusCode to 401 per RSA4e */ - err.statusCode = 401; - } - } - return err; - } - - var hmac, toBase64; - if(Platform.createHmac) { - toBase64 = function(str) { return (Buffer.from(str, 'ascii')).toString('base64'); }; - hmac = function(text, key) { - var inst = Platform.createHmac('SHA256', key); - inst.update(text); - return inst.digest('base64'); - }; - } else { - toBase64 = Base64.encode; - hmac = function(text, key) { - return stringifyBase64(HmacSHA256(text, key)); - }; +import { createHmac } from 'crypto'; +import { ErrnoException, RequestCallback, RequestParams } from '../../types/http'; +import * as API from '../../../ably'; +import { StandardCallback } from '../../types/utils'; +import Rest from './rest'; +import Realtime from './realtime'; +import ClientOptions from '../../types/ClientOptions'; + +const MAX_TOKEN_LENGTH = Math.pow(2, 17); +function noop() {} +function random() { return ('000000' + Math.floor(Math.random() * 1E16)).slice(-16); } + +function isRealtime(client: Rest | Realtime): client is Realtime { + return !!(client as Realtime).connection; +} + +/* A client auth callback may give errors in any number of formats; normalise to an errorinfo */ +function normaliseAuthcallbackError(err: any) { + if(!Utils.isErrorInfo(err)) { + return new ErrorInfo(Utils.inspectError(err), err.code || 40170, err.statusCode || 401); } - - function c14n(capability) { - if(!capability) - return ''; - - if(typeof(capability) == 'string') - capability = JSON.parse(capability); - - var c14nCapability = Object.create(null); - var keys = Utils.keysArray(capability, true); - if(!keys) - return ''; - keys.sort(); - for(var i = 0; i < keys.length; i++) { - c14nCapability[keys[i]] = capability[keys[i]].sort(); - } - return JSON.stringify(c14nCapability); - } - - function logAndValidateTokenAuthMethod(authOptions) { - if(authOptions.authCallback) { - Logger.logAction(Logger.LOG_MINOR, 'Auth()', 'using token auth with authCallback'); - } else if(authOptions.authUrl) { - Logger.logAction(Logger.LOG_MINOR, 'Auth()', 'using token auth with authUrl'); - } else if(authOptions.key) { - Logger.logAction(Logger.LOG_MINOR, 'Auth()', 'using token auth with client-side signing'); - } else if(authOptions.tokenDetails) { - Logger.logAction(Logger.LOG_MINOR, 'Auth()', 'using token auth with supplied token only'); + /* network errors will not have an inherent error code */ + if(!err.code) { + if(err.statusCode === 403) { + err.code = 40300; } else { - var msg = 'authOptions must include valid authentication parameters'; - Logger.logAction(Logger.LOG_ERROR, 'Auth()', msg); - throw new Error(msg); + err.code = 40170; + /* normalise statusCode to 401 per RSA4e */ + err.statusCode = 401; } } - - function basicAuthForced(options) { - return 'useTokenAuth' in options && !options.useTokenAuth; - } - - /* RSA4 */ - function useTokenAuth(options) { - return options.useTokenAuth || - (!basicAuthForced(options) && - (options.authCallback || - options.authUrl || - options.token || - options.tokenDetails)) - } - - /* RSA4a */ - function noWayToRenew(options) { - return !options.key && - !options.authCallback && - !options.authUrl; + return err; +} + +let hmac: (text: string, key: string) => string; +let toBase64: typeof Base64.encode; +if(Platform.createHmac) { + toBase64 = function(str: string) { return (Buffer.from(str, 'ascii')).toString('base64'); }; + hmac = function(text, key) { + const inst = (Platform.createHmac as typeof createHmac) ('SHA256', key); + inst.update(text); + return inst.digest('base64'); + }; +} else { + toBase64 = Base64.encode; + hmac = function(text, key) { + return stringifyBase64(HmacSHA256(text, key)); + }; +} + +function c14n(capability?: string | Record>) { + if(!capability) + return ''; + + if(typeof(capability) == 'string') + capability = JSON.parse(capability); + + const c14nCapability: Record> = {}; + const keys = Utils.keysArray(capability as Record>, true); + if(!keys) + return ''; + keys.sort(); + for(let i = 0; i < keys.length; i++) { + c14nCapability[keys[i]] = (capability as Record>)[keys[i]].sort(); } - - var trId = 0; - function getTokenRequestId() { - return trId++; + return JSON.stringify(c14nCapability); +} + +function logAndValidateTokenAuthMethod(authOptions: API.Types.AuthOptions) { + if(authOptions.authCallback) { + Logger.logAction(Logger.LOG_MINOR, 'Auth()', 'using token auth with authCallback'); + } else if(authOptions.authUrl) { + Logger.logAction(Logger.LOG_MINOR, 'Auth()', 'using token auth with authUrl'); + } else if(authOptions.key) { + Logger.logAction(Logger.LOG_MINOR, 'Auth()', 'using token auth with client-side signing'); + } else if(authOptions.tokenDetails) { + Logger.logAction(Logger.LOG_MINOR, 'Auth()', 'using token auth with supplied token only'); + } else { + const msg = 'authOptions must include valid authentication parameters'; + Logger.logAction(Logger.LOG_ERROR, 'Auth()', msg); + throw new Error(msg); } - - function Auth(client, options) { +} + +function basicAuthForced(options: ClientOptions) { + return 'useTokenAuth' in options && !options.useTokenAuth; +} + +/* RSA4 */ +function useTokenAuth(options: ClientOptions) { + return options.useTokenAuth || + (!basicAuthForced(options) && + (options.authCallback || + options.authUrl || + options.token || + options.tokenDetails)) +} + +/* RSA4a */ +function noWayToRenew(options: ClientOptions) { + return !options.key && + !options.authCallback && + !options.authUrl; +} + +let trId = 0; +function getTokenRequestId() { + return trId++; +} + +class Auth { + client: Rest | Realtime; + tokenParams: API.Types.TokenParams; + currentTokenRequestId: number | null; + waitingForTokenRequest: ReturnType | null; + // This initialization is always overwritten and only used to prevent a TypeScript compiler error + authOptions: API.Types.AuthOptions = {} as API.Types.AuthOptions; + tokenDetails?: API.Types.TokenDetails | null; + method?: string; + key?: string; + basicKey?: string; + clientId?: string | null; + + constructor(client: Rest | Realtime, options: ClientOptions) { this.client = client; this.tokenParams = options.defaultTokenParams || {}; /* The id of the current token request if one is in progress, else null */ @@ -116,19 +141,19 @@ var Auth = (function() { if(useTokenAuth(options)) { /* Token auth */ if(options.key && !hmac) { - var msg = 'client-side token request signing not supported'; + const msg = 'client-side token request signing not supported'; Logger.logAction(Logger.LOG_ERROR, 'Auth()', msg); throw new Error(msg); } if(noWayToRenew(options)) { Logger.logAction(Logger.LOG_ERROR, 'Auth()', 'Warning: library initialized with a token literal without any way to renew the token when it expires (no authUrl, authCallback, or key). See https://help.ably.io/error/40171 for help'); } - this._saveTokenOptions(options.defaultTokenParams, options); + this._saveTokenOptions(options.defaultTokenParams as API.Types.TokenDetails, options); logAndValidateTokenAuthMethod(this.authOptions); } else { /* Basic auth */ if(!options.key) { - var msg = 'No authentication options provided; need one of: key, authUrl, or authCallback (or for testing only, token or tokenDetails)'; + const msg = 'No authentication options provided; need one of: key, authUrl, or authCallback (or for testing only, token or tokenDetails)'; Logger.logAction(Logger.LOG_ERROR, 'Auth()', msg); throw new ErrorInfo(msg, 40160, 401); } @@ -137,6 +162,41 @@ var Auth = (function() { } } + /** + * Instructs the library to get a token immediately and ensures Token Auth + * is used for all future requests, storing the tokenParams and authOptions + * given as the new defaults for subsequent use. + * + * @param callback (err, tokenDetails) + */ + authorize(callback: Function): void; + + /** + * Instructs the library to get a token immediately and ensures Token Auth + * is used for all future requests, storing the tokenParams and authOptions + * given as the new defaults for subsequent use. + * + * @param tokenParams + * an object containing the parameters for the requested token: + * + * - ttl: (optional) the requested life of any new token in ms. If none + * is specified a default of 1 hour is provided. The maximum lifetime + * is 24hours; any request exceeding that lifetime will be rejected + * with an error. + * + * - capability: (optional) the capability to associate with the access token. + * If none is specified, a token will be requested with all of the + * capabilities of the specified key. + * + * - clientId: (optional) a client ID to associate with the token + * + * - timestamp: (optional) the time in ms since the epoch. If none is specified, + * the system will be queried for a time value to use. + * + * @param callback (err, tokenDetails) + */ + authorize(tokenParams: API.Types.TokenParams | null, callback: Function): void; + /** * Instructs the library to get a token immediately and ensures Token Auth * is used for all future requests, storing the tokenParams and authOptions @@ -147,14 +207,14 @@ var Auth = (function() { * * - ttl: (optional) the requested life of any new token in ms. If none * is specified a default of 1 hour is provided. The maximum lifetime - * is 24hours; any request exceeeding that lifetime will be rejected + * is 24hours; any request exceeding that lifetime will be rejected * with an error. * * - capability: (optional) the capability to associate with the access token. * If none is specified, a token will be requested with all of the * capabilities of the specified key. * - * - clientId: (optional) a client Id to associate with the token + * - clientId: (optional) a client ID to associate with the token * * - timestamp: (optional) the time in ms since the epoch. If none is specified, * the system will be queried for a time value to use. @@ -191,74 +251,75 @@ var Auth = (function() { * * @param callback (err, tokenDetails) */ - Auth.prototype.authorize = function(tokenParams, authOptions, callback) { + authorize(tokenParams: API.Types.TokenParams | null, authOptions: API.Types.AuthOptions | null, callback: Function): void; + + authorize(tokenParams: Record | Function | null, authOptions?: API.Types.AuthOptions | null | Function, callback?: Function): void | Promise { + let _authOptions: API.Types.AuthOptions | null; /* shuffle and normalise arguments as necessary */ if(typeof(tokenParams) == 'function' && !callback) { callback = tokenParams; - authOptions = tokenParams = null; + _authOptions = tokenParams = null; } else if(typeof(authOptions) == 'function' && !callback) { callback = authOptions; - authOptions = null; + _authOptions = null; + } else { + _authOptions = authOptions as API.Types.AuthOptions; } if(!callback) { if(this.client.options.promises) { return Utils.promisify(this, 'authorize', arguments); } - callback = noop; } - var self = this; /* RSA10a: authorize() call implies token auth. If a key is passed it, we * just check if it doesn't clash and assume we're generating a token from it */ - if(authOptions && authOptions.key && (this.authOptions.key !== authOptions.key)) { + if(_authOptions && _authOptions.key && (this.authOptions.key !== _authOptions.key)) { throw new ErrorInfo('Unable to update auth options with incompatible key', 40102, 401); } - if(authOptions && ('force' in authOptions)) { + if(_authOptions && ('force' in _authOptions)) { Logger.logAction(Logger.LOG_ERROR, 'Auth.authorize', 'Deprecation warning: specifying {force: true} in authOptions is no longer necessary, authorize() now always gets a new token. Please remove this, as in version 1.0 and later, having a non-null authOptions will overwrite stored library authOptions, which may not be what you want'); /* Emulate the old behaviour: if 'force' was the only member of authOptions, * set it to null so it doesn't overwrite stored. TODO: remove in version 1.0 */ - if(Utils.isOnlyPropIn(authOptions, 'force')) { - authOptions = null; + if(Utils.isOnlyPropIn(_authOptions, 'force')) { + _authOptions = null; } } - this._forceNewToken(tokenParams, authOptions, function(err, tokenDetails) { + this._forceNewToken(tokenParams as API.Types.TokenParams, _authOptions, (err: ErrorInfo, tokenDetails: API.Types.TokenDetails) => { if(err) { - if(self.client.connection) { + if((this.client as Realtime).connection) { /* We interpret RSA4d as including requests made by a client lib to * authenticate triggered by an explicit authorize() or an AUTH received from * ably, not just connect-sequence-triggered token fetches */ - self.client.connection.connectionManager.actOnErrorFromAuthorize(err); + (this.client as Realtime).connection.connectionManager.actOnErrorFromAuthorize(err); } - callback(err); + callback?.(err); return; } /* RTC8 * - When authorize called by an end user and have a realtime connection, * don't call back till new token has taken effect. - * - Use self.client.connection as a proxy for (self.client instanceof Realtime), + * - Use this.client.connection as a proxy for (this.client instanceof Realtime), * which doesn't work in node as Realtime isn't part of the vm context for Rest clients */ - if(self.client.connection) { - self.client.connection.connectionManager.onAuthUpdated(tokenDetails, callback); + if(isRealtime(this.client)) { + this.client.connection.connectionManager.onAuthUpdated(tokenDetails, callback || noop); } else { - callback(null, tokenDetails); + callback?.(null, tokenDetails); } }) - }; + } - Auth.prototype.authorise = function() { + authorise(tokenParams: API.Types.TokenParams | null, authOptions: API.Types.AuthOptions, callback: Function): void { Logger.deprecated('Auth.authorise', 'Auth.authorize'); - this.authorize.apply(this, arguments); - }; + this.authorize(tokenParams, authOptions, callback); + } /* For internal use, eg by connectionManager - useful when want to call back * as soon as we have the new token, rather than waiting for it to take * effect on the connection as #authorize does */ - Auth.prototype._forceNewToken = function(tokenParams, authOptions, callback) { - var self = this; - + _forceNewToken(tokenParams: API.Types.TokenParams | null, authOptions: API.Types.AuthOptions | null, callback: Function) { /* get rid of current token even if still valid */ this.tokenDetails = null; @@ -269,16 +330,60 @@ var Auth = (function() { logAndValidateTokenAuthMethod(this.authOptions); - this._ensureValidAuthCredentials(true, function(err, tokenDetails) { + this._ensureValidAuthCredentials(true, (err: ErrorInfo | null, tokenDetails?: API.Types.TokenDetails) => { /* RSA10g */ - delete self.tokenParams.timestamp; - delete self.authOptions.queryTime; + delete this.tokenParams.timestamp; + delete this.authOptions.queryTime; callback(err, tokenDetails); }); } /** * Request an access token + * @param callback (err, tokenDetails) + */ + requestToken(callback: StandardCallback): void; + + /** + * Request an access token + * @param tokenParams + * an object containing the parameters for the requested token: + * - ttl: (optional) the requested life of the token in milliseconds. If none is specified + * a default of 1 hour is provided. The maximum lifetime is 24hours; any request + * exceeding that lifetime will be rejected with an error. + * + * - capability: (optional) the capability to associate with the access token. + * If none is specified, a token will be requested with all of the + * capabilities of the specified key. + * + * - clientId: (optional) a client ID to associate with the token; if not + * specified, a clientId passed in constructing the Rest interface will be used + * + * - timestamp: (optional) the time in ms since the epoch. If none is specified, + * the system will be queried for a time value to use. + * + * @param callback (err, tokenDetails) + */ + requestToken(tokenParams: API.Types.TokenParams | null, callback: StandardCallback): void; + + /** + * Request an access token + * @param tokenParams + * an object containing the parameters for the requested token: + * - ttl: (optional) the requested life of the token in milliseconds. If none is specified + * a default of 1 hour is provided. The maximum lifetime is 24hours; any request + * exceeding that lifetime will be rejected with an error. + * + * - capability: (optional) the capability to associate with the access token. + * If none is specified, a token will be requested with all of the + * capabilities of the specified key. + * + * - clientId: (optional) a client ID to associate with the token; if not + * specified, a clientId passed in constructing the Rest interface will be used + * + * - timestamp: (optional) the time in ms since the epoch. If none is specified, + * the system will be queried for a time value to use. + * * @param authOptions * an object containing the request options: * - key: the key to use. @@ -305,25 +410,11 @@ var Auth = (function() { * - requestHeaders (optional, unsupported, for testing only) extra headers to add to the * requestToken request * - * @param tokenParams - * an object containing the parameters for the requested token: - * - ttl: (optional) the requested life of the token in milliseconds. If none is specified - * a default of 1 hour is provided. The maximum lifetime is 24hours; any request - * exceeeding that lifetime will be rejected with an error. - * - * - capability: (optional) the capability to associate with the access token. - * If none is specified, a token will be requested with all of the - * capabilities of the specified key. - * - * - clientId: (optional) a client Id to associate with the token; if not - * specified, a clientId passed in constructing the Rest interface will be used - * - * - timestamp: (optional) the time in ms since the epoch. If none is specified, - * the system will be queried for a time value to use. - * * @param callback (err, tokenDetails) */ - Auth.prototype.requestToken = function(tokenParams, authOptions, callback) { + requestToken(tokenParams: API.Types.TokenParams | null, authOptions: API.Types.AuthOptions, callback: StandardCallback): void; + + requestToken(tokenParams: API.Types.TokenParams | StandardCallback | null, authOptions?: any | StandardCallback, callback?: StandardCallback): void | Promise { /* shuffle and normalise arguments as necessary */ if(typeof(tokenParams) == 'function' && !callback) { callback = tokenParams; @@ -340,23 +431,23 @@ var Auth = (function() { /* RSA8e: if authOptions passed in, they're used instead of stored, don't merge them */ authOptions = authOptions || this.authOptions; tokenParams = tokenParams || Utils.copy(this.tokenParams); - callback = callback || noop; + const _callback = callback || noop; /* first set up whatever callback will be used to get signed * token requests */ - var tokenRequestCallback, client = this.client; + let tokenRequestCallback, client = this.client; if(authOptions.authCallback) { Logger.logAction(Logger.LOG_MINOR, 'Auth.requestToken()', 'using token auth with authCallback'); tokenRequestCallback = authOptions.authCallback; } else if(authOptions.authUrl) { Logger.logAction(Logger.LOG_MINOR, 'Auth.requestToken()', 'using token auth with authUrl'); - tokenRequestCallback = function(params, cb) { - var authHeaders = Utils.mixin({accept: 'application/json, text/plain'}, authOptions.authHeaders), - usePost = authOptions.authMethod && authOptions.authMethod.toLowerCase() === 'post', - providedQsParams; + tokenRequestCallback = function(params: Record, cb: Function) { + const authHeaders = Utils.mixin({accept: 'application/json, text/plain'}, authOptions.authHeaders) as Record; + const usePost = authOptions.authMethod && authOptions.authMethod.toLowerCase() === 'post'; + let providedQsParams; /* Combine authParams with any qs params given in the authUrl */ - var queryIdx = authOptions.authUrl.indexOf('?'); + const queryIdx = authOptions.authUrl.indexOf('?'); if(queryIdx > -1) { providedQsParams = Utils.parseQueryString(authOptions.authUrl.slice(queryIdx)); authOptions.authUrl = authOptions.authUrl.slice(0, queryIdx); @@ -366,9 +457,9 @@ var Auth = (function() { } } /* RSA8c2 */ - var authParams = Utils.mixin({}, authOptions.authParams || {}, params); - var authUrlRequestCallback = function(err, body, headers, unpacked) { - var contentType; + const authParams = Utils.mixin({}, authOptions.authParams || {}, params) as RequestParams; + const authUrlRequestCallback = function(err: ErrorInfo, body: string, headers: Record, unpacked: any) { + let contentType; if (err) { Logger.logAction(Logger.LOG_MICRO, 'Auth.requestToken().tokenRequestCallback', 'Received Error: ' + Utils.inspectError(err)); } else { @@ -381,7 +472,7 @@ var Auth = (function() { cb(new ErrorInfo('authUrl response is missing a content-type header', 40170, 401)); return; } - var json = contentType.indexOf('application/json') > -1, + const json = contentType.indexOf('application/json') > -1, text = contentType.indexOf('text/plain') > -1 || contentType.indexOf('application/jwt') > -1; if(!json && !text) { cb(new ErrorInfo('authUrl responded with unacceptable content-type ' + contentType + ', should be either text/plain, application/jwt or application/json', 40170, 401)); @@ -395,7 +486,7 @@ var Auth = (function() { try { body = JSON.parse(body); } catch(e) { - cb(new ErrorInfo('Unexpected error processing authURL response; err = ' + e.message, 40170, 401)); + cb(new ErrorInfo('Unexpected error processing authURL response; err = ' + (e as Error).message, 40170, 401)); return; } } @@ -404,110 +495,108 @@ var Auth = (function() { Logger.logAction(Logger.LOG_MICRO, 'Auth.requestToken().tokenRequestCallback', 'Requesting token from ' + authOptions.authUrl + '; Params: ' + JSON.stringify(authParams) + '; method: ' + (usePost ? 'POST' : 'GET')); if(usePost) { /* send body form-encoded */ - var headers = authHeaders || {}; + const headers = authHeaders || {}; headers['content-type'] = 'application/x-www-form-urlencoded'; - var body = Utils.toQueryString(authParams).slice(1); /* slice is to remove the initial '?' */ - Http.postUri(client, authOptions.authUrl, headers, body, providedQsParams, authUrlRequestCallback); + const body = Utils.toQueryString(authParams).slice(1); /* slice is to remove the initial '?' */ + Http.postUri(client, authOptions.authUrl, headers, body, providedQsParams as Record, authUrlRequestCallback as RequestCallback); } else { - Http.getUri(client, authOptions.authUrl, authHeaders || {}, authParams, authUrlRequestCallback); + Http.getUri(client, authOptions.authUrl, authHeaders || {}, authParams, authUrlRequestCallback as RequestCallback); } }; } else if(authOptions.key) { - var self = this; Logger.logAction(Logger.LOG_MINOR, 'Auth.requestToken()', 'using token auth with client-side signing'); - tokenRequestCallback = function(params, cb) { self.createTokenRequest(params, authOptions, cb); }; + tokenRequestCallback = (params: any, cb: Function) => { this.createTokenRequest(params, authOptions, cb); }; } else { - var msg = "Need a new token, but authOptions does not include any way to request one (no authUrl, authCallback, or key)"; + const msg = "Need a new token, but authOptions does not include any way to request one (no authUrl, authCallback, or key)"; Logger.logAction(Logger.LOG_ERROR, 'Auth()', 'library initialized with a token literal without any way to renew the token when it expires (no authUrl, authCallback, or key). See https://help.ably.io/error/40171 for help'); - callback(new ErrorInfo(msg, 40171, 403)); + _callback(new ErrorInfo(msg, 40171, 403)); return; } /* normalise token params */ - if('capability' in tokenParams) - tokenParams.capability = c14n(tokenParams.capability); + if('capability' in (tokenParams as Record)) + (tokenParams as Record).capability = c14n((tokenParams as Record).capability); - var tokenRequest = function(signedTokenParams, tokenCb) { - var keyName = signedTokenParams.keyName, + const tokenRequest = function(signedTokenParams: Record, tokenCb: Function) { + const keyName = signedTokenParams.keyName, path = '/keys/' + keyName + '/requestToken', - tokenUri = function(host) { return client.baseUri(host) + path; }; + tokenUri = function(host: string) { return client.baseUri(host) + path; }; - var requestHeaders = Utils.defaultPostHeaders(); + const requestHeaders = Utils.defaultPostHeaders(); if(authOptions.requestHeaders) Utils.mixin(requestHeaders, authOptions.requestHeaders); Logger.logAction(Logger.LOG_MICRO, 'Auth.requestToken().requestToken', 'Sending POST to ' + path + '; Token params: ' + JSON.stringify(signedTokenParams)); - signedTokenParams = JSON.stringify(signedTokenParams); - Http.post(client, tokenUri, requestHeaders, signedTokenParams, null, tokenCb); + Http.post(client, tokenUri, requestHeaders, JSON.stringify(signedTokenParams), null, tokenCb as RequestCallback); }; - var tokenRequestCallbackTimeoutExpired = false, + let tokenRequestCallbackTimeoutExpired = false, timeoutLength = this.client.options.timeouts.realtimeRequestTimeout, tokenRequestCallbackTimeout = setTimeout(function() { tokenRequestCallbackTimeoutExpired = true; - var msg = 'Token request callback timed out after ' + (timeoutLength / 1000) + ' seconds'; + const msg = 'Token request callback timed out after ' + (timeoutLength / 1000) + ' seconds'; Logger.logAction(Logger.LOG_ERROR, 'Auth.requestToken()', msg); - callback(new ErrorInfo(msg, 40170, 401)); + _callback(new ErrorInfo(msg, 40170, 401)); }, timeoutLength); - tokenRequestCallback(tokenParams, function(err, tokenRequestOrDetails, contentType) { + tokenRequestCallback(tokenParams, function(err: ErrorInfo, tokenRequestOrDetails: any, contentType: string) { if(tokenRequestCallbackTimeoutExpired) return; clearTimeout(tokenRequestCallbackTimeout); if(err) { Logger.logAction(Logger.LOG_ERROR, 'Auth.requestToken()', 'token request signing call returned error; err = ' + Utils.inspectError(err)); - callback(normaliseAuthcallbackError(err)); + _callback(normaliseAuthcallbackError(err)); return; } /* the response from the callback might be a token string, a signed request or a token details */ if(typeof(tokenRequestOrDetails) === 'string') { if(tokenRequestOrDetails.length === 0) { - callback(new ErrorInfo('Token string is empty', 40170, 401)); + _callback(new ErrorInfo('Token string is empty', 40170, 401)); } else if(tokenRequestOrDetails.length > MAX_TOKEN_LENGTH) { - callback(new ErrorInfo('Token string exceeded max permitted length (was ' + tokenRequestOrDetails.length + ' bytes)', 40170, 401)); + _callback(new ErrorInfo('Token string exceeded max permitted length (was ' + tokenRequestOrDetails.length + ' bytes)', 40170, 401)); } else if(tokenRequestOrDetails === 'undefined' || tokenRequestOrDetails === 'null') { /* common failure mode with poorly-implemented authCallbacks */ - callback(new ErrorInfo('Token string was literal null/undefined', 40170, 401)); + _callback(new ErrorInfo('Token string was literal null/undefined', 40170, 401)); } else if((tokenRequestOrDetails[0] === '{') && !(contentType && contentType.indexOf('application/jwt') > -1)) { - callback(new ErrorInfo('Token was double-encoded; make sure you\'re not JSON-encoding an already encoded token request or details', 40170, 401)); + _callback(new ErrorInfo('Token was double-encoded; make sure you\'re not JSON-encoding an already encoded token request or details', 40170, 401)); } else { - callback(null, {token: tokenRequestOrDetails}); + _callback(null, {token: tokenRequestOrDetails} as API.Types.TokenDetails); } return; } if(typeof(tokenRequestOrDetails) !== 'object') { - var msg = 'Expected token request callback to call back with a token string or token request/details object, but got a ' + typeof(tokenRequestOrDetails); + const msg = 'Expected token request callback to call back with a token string or token request/details object, but got a ' + typeof(tokenRequestOrDetails); Logger.logAction(Logger.LOG_ERROR, 'Auth.requestToken()', msg); - callback(new ErrorInfo(msg, 40170, 401)); + _callback(new ErrorInfo(msg, 40170, 401)); return; } - var objectSize = JSON.stringify(tokenRequestOrDetails).length; + const objectSize = JSON.stringify(tokenRequestOrDetails).length; if(objectSize > MAX_TOKEN_LENGTH && !authOptions.suppressMaxLengthCheck) { - callback(new ErrorInfo('Token request/details object exceeded max permitted stringified size (was ' + objectSize + ' bytes)', 40170, 401)); + _callback(new ErrorInfo('Token request/details object exceeded max permitted stringified size (was ' + objectSize + ' bytes)', 40170, 401)); return; } if('issued' in tokenRequestOrDetails) { /* a tokenDetails object */ - callback(null, tokenRequestOrDetails); + _callback(null, tokenRequestOrDetails); return; } if(!('keyName' in tokenRequestOrDetails)) { - var msg = 'Expected token request callback to call back with a token string, token request object, or token details object'; + const msg = 'Expected token request callback to call back with a token string, token request object, or token details object'; Logger.logAction(Logger.LOG_ERROR, 'Auth.requestToken()', msg); - callback(new ErrorInfo(msg, 40170, 401)); + _callback(new ErrorInfo(msg, 40170, 401)); return; } /* it's a token request, so make the request */ - tokenRequest(tokenRequestOrDetails, function(err, tokenResponse, headers, unpacked) { + tokenRequest(tokenRequestOrDetails, function(err?: ErrorInfo | ErrnoException | null, tokenResponse?: API.Types.TokenDetails | string, headers?: Record, unpacked?: boolean) { if(err) { Logger.logAction(Logger.LOG_ERROR, 'Auth.requestToken()', 'token request API call returned error; err = ' + Utils.inspectError(err)); - callback(normaliseAuthcallbackError(err)); + _callback(normaliseAuthcallbackError(err)); return; } - if(!unpacked) tokenResponse = JSON.parse(tokenResponse); + if(!unpacked) tokenResponse = JSON.parse(tokenResponse as string); Logger.logAction(Logger.LOG_MINOR, 'Auth.getToken()', 'token received'); - callback(null, tokenResponse); + _callback(null, tokenResponse as API.Types.TokenDetails); }); }); - }; + } /** * Create and sign a token request based on the given options. @@ -530,20 +619,21 @@ var Auth = (function() { * an object containing the parameters for the requested token: * - ttl: (optional) the requested life of the token in ms. If none is specified * a default of 1 hour is provided. The maximum lifetime is 24hours; any request - * exceeeding that lifetime will be rejected with an error. + * exceeding that lifetime will be rejected with an error. * * - capability: (optional) the capability to associate with the access token. * If none is specified, a token will be requested with all of the * capabilities of the specified key. * - * - clientId: (optional) a client Id to associate with the token; if not + * - clientId: (optional) a client ID to associate with the token; if not * specified, a clientId passed in constructing the Rest interface will be used * * - timestamp: (optional) the time in ms since the epoch. If none is specified, * the system will be queried for a time value to use. * + * @param callback */ - Auth.prototype.createTokenRequest = function(tokenParams, authOptions, callback) { + createTokenRequest(tokenParams: API.Types.TokenParams | null, authOptions: any, callback: Function) { /* shuffle and normalise arguments as necessary */ if(typeof(tokenParams) == 'function' && !callback) { callback = tokenParams; @@ -558,14 +648,14 @@ var Auth = (function() { /* RSA9h: if authOptions passed in, they're used instead of stored, don't merge them */ authOptions = authOptions || this.authOptions; - tokenParams = tokenParams || Utils.copy(this.tokenParams); + tokenParams = tokenParams || Utils.copy(this.tokenParams); - var key = authOptions.key; + const key = authOptions.key; if(!key) { callback(new ErrorInfo('No key specified', 40101, 403)); return; } - var keyParts = key.split(':'), + const keyParts = key.split(':'), keyName = keyParts[0], keySecret = keyParts[1]; @@ -583,18 +673,17 @@ var Auth = (function() { tokenParams.capability = c14n(tokenParams.capability); } - var request = Utils.mixin({ keyName: keyName }, tokenParams), + const request = Utils.mixin({ keyName: keyName }, tokenParams), clientId = tokenParams.clientId || '', ttl = tokenParams.ttl || '', - capability = tokenParams.capability || '', - self = this; + capability = tokenParams.capability || ''; - (function(authoriseCb) { + ((authoriseCb) => { if(request.timestamp) { authoriseCb(); return; - }; - self.getTimestamp(authOptions && authOptions.queryTime, function(err, time) { + } + this.getTimestamp(authOptions && authOptions.queryTime, function(err?: ErrorInfo | null, time?: number) { if(err) {callback(err); return;} request.timestamp = time; authoriseCb(); @@ -605,10 +694,10 @@ var Auth = (function() { * specifies the nonce; this is done by the library * However, this can be overridden by the client * simply for testing purposes. */ - var nonce = request.nonce || (request.nonce = random()), + const nonce = request.nonce || (request.nonce = random()), timestamp = request.timestamp; - var signText + const signText = request.keyName + '\n' + ttl + '\n' + capability + '\n' @@ -626,42 +715,48 @@ var Auth = (function() { Logger.logAction(Logger.LOG_MINOR, 'Auth.getTokenRequest()', 'generated signed request'); callback(null, request); }); - }; + } /** * Get the auth query params to use for a websocket connection, * based on the current auth parameters */ - Auth.prototype.getAuthParams = function(callback) { + getAuthParams(callback: Function) { if(this.method == 'basic') callback(null, {key: this.key}); else - this._ensureValidAuthCredentials(false, function(err, tokenDetails) { + this._ensureValidAuthCredentials(false, function(err: ErrorInfo | null, tokenDetails?: API.Types.TokenDetails) { if(err) { callback(err); return; } + if(!tokenDetails) { + throw new Error('Auth.getAuthParams(): _ensureValidAuthCredentials returned no error or tokenDetails'); + } callback(null, {access_token: tokenDetails.token}); }); - }; + } /** * Get the authorization header to use for a REST or comet request, * based on the current auth parameters */ - Auth.prototype.getAuthHeaders = function(callback) { + getAuthHeaders(callback: Function) { if(this.method == 'basic') { callback(null, {authorization: 'Basic ' + this.basicKey}); } else { - this._ensureValidAuthCredentials(false, function(err, tokenDetails) { + this._ensureValidAuthCredentials(false, function(err: ErrorInfo | null, tokenDetails?: API.Types.TokenDetails) { if(err) { callback(err); return; } + if(!tokenDetails) { + throw new Error('Auth.getAuthParams(): _ensureValidAuthCredentials returned no error or tokenDetails'); + } callback(null, {authorization: 'Bearer ' + toBase64(tokenDetails.token)}); }); } - }; + } /** * Get the current time based on the local clock, @@ -669,33 +764,33 @@ var Auth = (function() { * The server time offset from the local time is stored so that * only one request to the server to get the time is ever needed */ - Auth.prototype.getTimestamp = function(queryTime, callback) { + getTimestamp(queryTime: boolean, callback: StandardCallback): void { if (!this.isTimeOffsetSet() && (queryTime || this.authOptions.queryTime)) { this.client.time(callback); } else { callback(null, this.getTimestampUsingOffset()); } - }; + } - Auth.prototype.getTimestampUsingOffset = function() { + getTimestampUsingOffset() { return Utils.now() + (this.client.serverTimeOffset || 0); - }; + } - Auth.prototype.isTimeOffsetSet = function() { + isTimeOffsetSet() { return this.client.serverTimeOffset !== null; - }; + } - Auth.prototype._saveBasicOptions = function(authOptions) { + _saveBasicOptions(authOptions: API.Types.AuthOptions) { this.method = 'basic'; this.key = authOptions.key; - this.basicKey = toBase64(authOptions.key); + this.basicKey = toBase64(authOptions.key as string); this.authOptions = authOptions || {}; if('clientId' in authOptions) { this._userSetClientId(authOptions.clientId); } } - Auth.prototype._saveTokenOptions = function(tokenParams, authOptions) { + _saveTokenOptions(tokenParams: API.Types.TokenParams | null, authOptions: API.Types.AuthOptions | null) { this.method = 'token'; if(tokenParams) { @@ -709,7 +804,7 @@ var Auth = (function() { /* normalise */ if(authOptions.token) { /* options.token may contain a token string or, for convenience, a TokenDetails */ - authOptions.tokenDetails = (typeof(authOptions.token) === 'string') ? {token: authOptions.token} : authOptions.token; + authOptions.tokenDetails = (typeof(authOptions.token) === 'string') ? {token: authOptions.token} as API.Types.TokenDetails : authOptions.token; } if(authOptions.tokenDetails) { @@ -722,13 +817,12 @@ var Auth = (function() { this.authOptions = authOptions; } - }; + } /* @param forceSupersede: force a new token request even if there's one in * progress, making all pending callbacks wait for the new one */ - Auth.prototype._ensureValidAuthCredentials = function(forceSupersede, callback) { - var self = this, - token = this.tokenDetails; + _ensureValidAuthCredentials(forceSupersede: boolean, callback: (err: ErrorInfo | null, token?: API.Types.TokenDetails) => void) { + const token = this.tokenDetails; if(token) { if(this._tokenClientIdMismatch(token.clientId)) { @@ -737,7 +831,7 @@ var Auth = (function() { return; } /* RSA4b1 -- if we have a server time offset set already, we can - * autoremove expired tokens. Else just use the cached token. If it is + * automatically remove expired tokens. Else just use the cached token. If it is * expired Ably will tell us and we'll discard it then. */ if(!this.isTimeOffsetSet() || !token.expires || (token.expires >= this.getTimestampUsingOffset())) { Logger.logAction(Logger.LOG_MINOR, 'Auth.getToken()', 'using cached token; expires = ' + token.expires); @@ -749,49 +843,49 @@ var Auth = (function() { this.tokenDetails = null; } - (this.waitingForTokenRequest || (this.waitingForTokenRequest = Multicaster())).push(callback); + (this.waitingForTokenRequest || (this.waitingForTokenRequest = Multicaster.create())).push(callback); if(this.currentTokenRequestId !== null && !forceSupersede) { return; } /* Request a new token */ - var tokenRequestId = this.currentTokenRequestId = getTokenRequestId(); - this.requestToken(this.tokenParams, this.authOptions, function(err, tokenResponse) { - if(self.currentTokenRequestId > tokenRequestId) { + const tokenRequestId = this.currentTokenRequestId = getTokenRequestId(); + this.requestToken(this.tokenParams, this.authOptions, (err: Function, tokenResponse?: API.Types.TokenDetails) => { + if((this.currentTokenRequestId as number) > tokenRequestId) { Logger.logAction(Logger.LOG_MINOR, 'Auth._ensureValidAuthCredentials()', 'Discarding token request response; overtaken by newer one'); return; } - self.currentTokenRequestId = null; - var callbacks = self.waitingForTokenRequest || noop; - self.waitingForTokenRequest = null; + this.currentTokenRequestId = null; + const callbacks = this.waitingForTokenRequest || noop; + this.waitingForTokenRequest = null; if(err) { callbacks(err); return; } - callbacks(null, (self.tokenDetails = tokenResponse)); + callbacks(null, (this.tokenDetails = tokenResponse)); }); - }; + } /* User-set: check types, '*' is disallowed, throw any errors */ - Auth.prototype._userSetClientId = function(clientId) { + _userSetClientId(clientId: string | undefined) { if(!(typeof(clientId) === 'string' || clientId === null)) { throw new ErrorInfo('clientId must be either a string or null', 40012, 400); } else if(clientId === '*') { throw new ErrorInfo('Can’t use "*" as a clientId as that string is reserved. (To change the default token request behaviour to use a wildcard clientId, instantiate the library with {defaultTokenParams: {clientId: "*"}}), or if calling authorize(), pass it in as a tokenParam: authorize({clientId: "*"}, authOptions)', 40012, 400); } else { - var err = this._uncheckedSetClientId(clientId); + const err = this._uncheckedSetClientId(clientId); if(err) throw err; } - }; + } /* Ably-set: no typechecking, '*' is allowed but not set on this.clientId), return errors to the caller */ - Auth.prototype._uncheckedSetClientId = function(clientId) { + _uncheckedSetClientId(clientId: string | undefined) { if(this._tokenClientIdMismatch(clientId)) { /* Should never happen in normal circumstances as realtime should * recognise mismatch and return an error */ - var msg = 'Unexpected clientId mismatch: client has ' + this.clientId + ', requested ' + clientId; - var err = new ErrorInfo(msg, 40102, 401); + const msg = 'Unexpected clientId mismatch: client has ' + this.clientId + ', requested ' + clientId; + const err = new ErrorInfo(msg, 40102, 401); Logger.logAction(Logger.LOG_ERROR, 'Auth._uncheckedSetClientId()', msg); return err; } else { @@ -800,21 +894,19 @@ var Auth = (function() { this.clientId = this.tokenParams.clientId = clientId; return null; } - }; + } - Auth.prototype._tokenClientIdMismatch = function(tokenClientId) { - return this.clientId && + _tokenClientIdMismatch(tokenClientId?: string | null): boolean { + return !!(this.clientId && (this.clientId !== '*') && tokenClientId && (tokenClientId !== '*') && - (this.clientId !== tokenClientId); - }; + (this.clientId !== tokenClientId)); + } - Auth.isTokenErr = function(error) { + static isTokenErr(error: ErrorInfo) { return error.code && (error.code >= 40140) && (error.code < 40150); - }; - - return Auth; -})(); + } +} export default Auth; diff --git a/common/lib/client/channel.js b/common/lib/client/channel.js deleted file mode 100644 index 71a94498e3..0000000000 --- a/common/lib/client/channel.js +++ /dev/null @@ -1,159 +0,0 @@ -import Utils from '../util/utils'; -import EventEmitter from '../util/eventemitter'; -import Logger from '../util/logger'; -import Presence from './presence'; -import Crypto from 'platform-crypto'; -import Message from '../types/message'; -import ErrorInfo from '../types/errorinfo'; -import PaginatedResource from './paginatedresource'; -import Http from 'platform-http'; -import Resource from './resource'; - -var Channel = (function() { - function noop() {} - var MSG_ID_ENTROPY_BYTES = 9; - - /* public constructor */ - function Channel(rest, name, channelOptions) { - Logger.logAction(Logger.LOG_MINOR, 'Channel()', 'started; name = ' + name); - EventEmitter.call(this); - this.rest = rest; - this.name = name; - this.basePath = '/channels/' + encodeURIComponent(name); - this.presence = new Presence(this); - this.setOptions(channelOptions); - } - Utils.inherits(Channel, EventEmitter); - - Channel.prototype.setOptions = function(options) { - this.channelOptions = options = options || {}; - if(options.cipher) { - if(!Crypto) throw new Error('Encryption not enabled; use ably.encryption.js instead'); - var cipher = Crypto.getCipher(options.cipher); - options.cipher = cipher.cipherParams; - options.channelCipher = cipher.cipher; - } else if('cipher' in options) { - /* Don't deactivate an existing cipher unless options - * has a 'cipher' key that's falsey */ - options.cipher = null; - options.channelCipher = null; - } - }; - - Channel.prototype.history = function(params, callback) { - Logger.logAction(Logger.LOG_MICRO, 'Channel.history()', 'channel = ' + this.name); - /* params and callback are optional; see if params contains the callback */ - if(callback === undefined) { - if(typeof(params) == 'function') { - callback = params; - params = null; - } else { - if(this.rest.options.promises) { - return Utils.promisify(this, 'history', arguments); - } - callback = noop; - } - } - - this._history(params, callback); - }; - - Channel.prototype._history = function(params, callback) { - var rest = this.rest, - format = rest.options.useBinaryProtocol ? 'msgpack' : 'json', - envelope = Http.supportsLinkHeaders ? undefined : format, - headers = Utils.defaultGetHeaders(format), - channel = this; - - if(rest.options.headers) - Utils.mixin(headers, rest.options.headers); - - var options = this.channelOptions; - (new PaginatedResource(rest, this.basePath + '/messages', headers, envelope, function(body, headers, unpacked) { - return Message.fromResponseBody(body, options, !unpacked && format); - })).get(params, callback); - }; - - function allEmptyIds(messages) { - return Utils.arrEvery(messages, function(message) { - return !message.id; - }); - } - - Channel.prototype.publish = function() { - var argCount = arguments.length, - first = arguments[0], - second = arguments[1], - callback = arguments[argCount - 1], - messages, - params, - self = this; - - if(typeof(callback) !== 'function') { - if(this.rest.options.promises) { - return Utils.promisify(this, 'publish', arguments); - } - callback = noop; - } - - if(typeof first === 'string' || first === null) { - /* (name, data, ...) */ - messages = [Message.fromValues({name: first, data: second})]; - params = arguments[2]; - } else if(Utils.isObject(first)) { - messages = [Message.fromValues(first)]; - params = arguments[1]; - } else if(Utils.isArray(first)) { - messages = Message.fromValuesArray(first); - params = arguments[1]; - } else { - throw new ErrorInfo('The single-argument form of publish() expects a message object or an array of message objects', 40013, 400); - } - - if(typeof params !== 'object' || !params) { - /* No params supplied (so after-message argument is just the callback or undefined) */ - params = {}; - } - - var rest = this.rest, - options = rest.options, - format = options.useBinaryProtocol ? 'msgpack' : 'json', - idempotentRestPublishing = rest.options.idempotentRestPublishing, - headers = Utils.defaultPostHeaders(format); - - if(options.headers) - Utils.mixin(headers, options.headers); - - if(idempotentRestPublishing && allEmptyIds(messages)) { - var msgIdBase = Utils.randomString(MSG_ID_ENTROPY_BYTES); - Utils.arrForEach(messages, function(message, index) { - message.id = msgIdBase + ':' + index.toString(); - }); - } - - Message.encodeArray(messages, this.channelOptions, function(err) { - if(err) { - callback(err); - return; - } - - /* RSL1i */ - var size = Message.getMessagesSize(messages), - maxMessageSize = options.maxMessageSize; - if(size > maxMessageSize) { - callback(new ErrorInfo('Maximum size of messages that can be published at once exceeded ( was ' + size + ' bytes; limit is ' + maxMessageSize + ' bytes)', 40009, 400)); - return; - } - - self._publish(Message.serialize(messages, format), headers, params, callback); - }); - }; - - Channel.prototype._publish = function(requestBody, headers, params, callback) { - Resource.post(this.rest, this.basePath + '/messages', requestBody, headers, params, false, callback); - }; - - return Channel; -})(); - -export default Channel; diff --git a/common/lib/client/channel.ts b/common/lib/client/channel.ts new file mode 100644 index 0000000000..028925b514 --- /dev/null +++ b/common/lib/client/channel.ts @@ -0,0 +1,175 @@ +import * as Utils from '../util/utils'; +import EventEmitter from '../util/eventemitter'; +import Logger from '../util/logger'; +import Presence from './presence'; +import Crypto from 'platform-crypto'; +import Message, { CipherOptions } from '../types/message'; +import ErrorInfo from '../types/errorinfo'; +import PaginatedResource, { PaginatedResult } from './paginatedresource'; +import Http from 'platform-http'; +import Resource from './resource'; +import { ChannelOptions } from '../../types/channel'; +import { PaginatedResultCallback } from '../../types/utils'; +import Rest from './rest'; +import Realtime from './realtime'; + +interface RestHistoryParams { + start?: number; + end?: number; + direction?: string; + limit?: number; +} + +function noop() {} +const MSG_ID_ENTROPY_BYTES = 9; + +function allEmptyIds(messages: Array) { + return Utils.arrEvery(messages, function(message: Message) { + return !message.id; + }); +} + +function normaliseChannelOptions(options?: ChannelOptions) { + const channelOptions = options || {}; + if(channelOptions.cipher) { + if(!Crypto) throw new Error('Encryption not enabled; use ably.encryption.js instead'); + const cipher = Crypto.getCipher(channelOptions.cipher); + channelOptions.cipher = cipher.cipherParams; + channelOptions.channelCipher = cipher.cipher; + } else if('cipher' in channelOptions) { + /* Don't deactivate an existing cipher unless options + * has a 'cipher' key that's falsey */ + channelOptions.cipher = undefined; + channelOptions.channelCipher = null; + } + return channelOptions; +} + +class Channel extends EventEmitter { + rest: Rest | Realtime; + name: string; + basePath: string; + presence: Presence; + channelOptions: ChannelOptions; + + constructor(rest: Rest | Realtime, name: string, channelOptions?: ChannelOptions) { + super(); + Logger.logAction(Logger.LOG_MINOR, 'Channel()', 'started; name = ' + name); + this.rest = rest; + this.name = name; + this.basePath = '/channels/' + encodeURIComponent(name); + this.presence = new Presence(this); + this.channelOptions = normaliseChannelOptions(channelOptions); + } + + setOptions(options: ChannelOptions): void { + this.channelOptions = normaliseChannelOptions(options); + } + + history(params: RestHistoryParams | null, callback: PaginatedResultCallback): Promise> | void { + Logger.logAction(Logger.LOG_MICRO, 'Channel.history()', 'channel = ' + this.name); + /* params and callback are optional; see if params contains the callback */ + if(callback === undefined) { + if(typeof(params) == 'function') { + callback = params; + params = null; + } else { + if(this.rest.options.promises) { + return Utils.promisify(this, 'history', arguments); + } + callback = noop; + } + } + + this._history(params, callback); + } + + _history(params: RestHistoryParams | null, callback: PaginatedResultCallback): void { + const rest = this.rest, + format = rest.options.useBinaryProtocol ? Utils.Format.msgpack : Utils.Format.json, + envelope = Http.supportsLinkHeaders ? undefined : format, + headers = Utils.defaultGetHeaders(format); + + if(rest.options.headers) + Utils.mixin(headers, rest.options.headers); + + const options = this.channelOptions; + (new PaginatedResource(rest, this.basePath + '/messages', headers, envelope, function(body: any, headers: Record, unpacked?: boolean) { + return Message.fromResponseBody(body, options, unpacked ? undefined : format); + })).get(params as Record, callback); + } + + publish(): void | Promise { + const argCount = arguments.length, + first = arguments[0], + second = arguments[1]; + let callback = arguments[argCount - 1]; + let messages: Array; + let params: any; + + if(typeof(callback) !== 'function') { + if(this.rest.options.promises) { + return Utils.promisify(this, 'publish', arguments); + } + callback = noop; + } + + if(typeof first === 'string' || first === null) { + /* (name, data, ...) */ + messages = [Message.fromValues({name: first, data: second})]; + params = arguments[2]; + } else if(Utils.isObject(first)) { + messages = [Message.fromValues(first)]; + params = arguments[1]; + } else if(Utils.isArray(first)) { + messages = Message.fromValuesArray(first); + params = arguments[1]; + } else { + throw new ErrorInfo('The single-argument form of publish() expects a message object or an array of message objects', 40013, 400); + } + + if(typeof params !== 'object' || !params) { + /* No params supplied (so after-message argument is just the callback or undefined) */ + params = {}; + } + + const rest = this.rest, + options = rest.options, + format = options.useBinaryProtocol ? Utils.Format.msgpack : Utils.Format.json, + idempotentRestPublishing = rest.options.idempotentRestPublishing, + headers = Utils.defaultPostHeaders(format); + + if(options.headers) + Utils.mixin(headers, options.headers); + + if(idempotentRestPublishing && allEmptyIds(messages)) { + const msgIdBase = Utils.randomString(MSG_ID_ENTROPY_BYTES); + Utils.arrForEach(messages, function(message, index) { + message.id = msgIdBase + ':' + index.toString(); + }); + } + + Message.encodeArray(messages, this.channelOptions as CipherOptions, (err: Error) => { + if(err) { + callback(err); + return; + } + + /* RSL1i */ + const size = Message.getMessagesSize(messages), + maxMessageSize = options.maxMessageSize; + if(size > maxMessageSize) { + callback(new ErrorInfo('Maximum size of messages that can be published at once exceeded ( was ' + size + ' bytes; limit is ' + maxMessageSize + ' bytes)', 40009, 400)); + return; + } + + this._publish(Message.serialize(messages, format), headers, params, callback); + }); + } + + _publish(requestBody: unknown, headers: Record, params: any, callback: Function): void { + Resource.post(this.rest, this.basePath + '/messages', requestBody, headers, params, null, callback); + } +} + +export default Channel; diff --git a/common/lib/client/channelstatechange.js b/common/lib/client/channelstatechange.js deleted file mode 100644 index 5a5ddd9a82..0000000000 --- a/common/lib/client/channelstatechange.js +++ /dev/null @@ -1,14 +0,0 @@ -var ChannelStateChange = (function() { - - /* public constructor */ - function ChannelStateChange(previous, current, resumed, reason) { - this.previous = previous; - this.current = current; - if(current === 'attached') this.resumed = resumed; - if(reason) this.reason = reason; - } - - return ChannelStateChange; -})(); - -export default ChannelStateChange; diff --git a/common/lib/client/channelstatechange.ts b/common/lib/client/channelstatechange.ts new file mode 100644 index 0000000000..07c996e10d --- /dev/null +++ b/common/lib/client/channelstatechange.ts @@ -0,0 +1,17 @@ +import ErrorInfo from "../types/errorinfo"; + +class ChannelStateChange { + previous: string; + current: string; + resumed?: boolean; + reason?: string | Error | ErrorInfo; + + constructor(previous: string, current: string, resumed?: boolean, reason?: string | Error | ErrorInfo | null) { + this.previous = previous; + this.current = current; + if(current === 'attached') this.resumed = resumed; + if(reason) this.reason = reason; + } +} + +export default ChannelStateChange; diff --git a/common/lib/client/connection.js b/common/lib/client/connection.ts similarity index 51% rename from common/lib/client/connection.js rename to common/lib/client/connection.ts index c77745b7ce..52c0f78190 100644 --- a/common/lib/client/connection.js +++ b/common/lib/client/connection.ts @@ -1,15 +1,27 @@ -import Utils from '../util/utils'; +import * as Utils from '../util/utils'; import EventEmitter from '../util/eventemitter'; import ConnectionManager from '../transport/connectionmanager'; import Logger from '../util/logger'; import ConnectionStateChange from './connectionstatechange'; +import ErrorInfo from '../types/errorinfo'; +import { NormalisedClientOptions } from '../../types/ClientOptions'; +import Realtime from './realtime'; -var Connection = (function() { - function noop() {} +function noop() {} - /* public constructor */ - function Connection(ably, options) { - EventEmitter.call(this); +class Connection extends EventEmitter { + ably: Realtime; + connectionManager: ConnectionManager; + state: string; + key?: never; + id?: string; + serial: undefined; + timeSerial: undefined; + recoveryKey?: string | null; + errorReason: ErrorInfo | null; + + constructor(ably: Realtime, options: NormalisedClientOptions) { + super(); this.ably = ably; this.connectionManager = new ConnectionManager(ably, options); this.state = this.connectionManager.state.state; @@ -20,47 +32,43 @@ var Connection = (function() { this.recoveryKey = undefined; this.errorReason = null; - var self = this; - this.connectionManager.on('connectionstate', function(stateChange) { - var state = self.state = stateChange.current; - Utils.nextTick(function() { - self.emit(state, stateChange); + this.connectionManager.on('connectionstate', (stateChange: ConnectionStateChange) => { + const state = this.state = stateChange.current as string; + Utils.nextTick(() => { + this.emit(state, stateChange); }); }); - this.connectionManager.on('update', function(stateChange) { - Utils.nextTick(function() { - self.emit('update', stateChange); + this.connectionManager.on('update', (stateChange: ConnectionStateChange) => { + Utils.nextTick(() => { + this.emit('update', stateChange); }); }); } - Utils.inherits(Connection, EventEmitter); - Connection.prototype.whenState = function(state, listener) { + whenState = (((state: string, listener: Function) => { return EventEmitter.prototype.whenState.call(this, state, this.state, listener, new ConnectionStateChange(undefined, state)); - } + }) as any) - Connection.prototype.connect = function() { + connect(): void { Logger.logAction(Logger.LOG_MINOR, 'Connection.connect()', ''); this.connectionManager.requestState({state: 'connecting'}); - }; + } - Connection.prototype.ping = function(callback) { + ping(callback: Function): Promise | void { Logger.logAction(Logger.LOG_MINOR, 'Connection.ping()', ''); if(!callback) { if(this.ably.options.promises) { - return Utils.promisify(this, 'ping', arguments); + return Utils.promisify(this, 'ping', [callback]); } callback = noop; } this.connectionManager.ping(null, callback); - }; + } - Connection.prototype.close = function() { + close(): void { Logger.logAction(Logger.LOG_MINOR, 'Connection.close()', 'connectionKey = ' + this.key); this.connectionManager.requestState({state: 'closing'}); - }; - - return Connection; -})(); + } +} export default Connection; diff --git a/common/lib/client/connectionstatechange.js b/common/lib/client/connectionstatechange.js deleted file mode 100644 index 2a6ca92b14..0000000000 --- a/common/lib/client/connectionstatechange.js +++ /dev/null @@ -1,14 +0,0 @@ -var ConnectionStateChange = (function() { - - /* public constructor */ - function ConnectionStateChange(previous, current, retryIn, reason) { - this.previous = previous; - this.current = current; - if(retryIn) this.retryIn = retryIn; - if(reason) this.reason = reason; - } - - return ConnectionStateChange; -})(); - -export default ConnectionStateChange; diff --git a/common/lib/client/connectionstatechange.ts b/common/lib/client/connectionstatechange.ts new file mode 100644 index 0000000000..2c96973d09 --- /dev/null +++ b/common/lib/client/connectionstatechange.ts @@ -0,0 +1,17 @@ +import ErrorInfo from "../types/errorinfo"; + +class ConnectionStateChange { + previous?: string; + current?: string; + retryIn?: number; + reason?: ErrorInfo; + + constructor(previous?: string, current?: string, retryIn?: number | null, reason?: ErrorInfo) { + this.previous = previous; + this.current = current; + if(retryIn) this.retryIn = retryIn; + if(reason) this.reason = reason; + } +} + +export default ConnectionStateChange; diff --git a/common/lib/client/paginatedresource.js b/common/lib/client/paginatedresource.js deleted file mode 100644 index 8a628f34be..0000000000 --- a/common/lib/client/paginatedresource.js +++ /dev/null @@ -1,153 +0,0 @@ -import Utils from '../util/utils'; -import Logger from '../util/logger'; -import Resource from './resource'; -import Http from 'platform-http'; - -var PaginatedResource = (function() { - - function getRelParams(linkUrl) { - var urlMatch = linkUrl.match(/^\.\/(\w+)\?(.*)$/); - return urlMatch && Utils.parseQueryString(urlMatch[2]); - } - - function parseRelLinks(linkHeader) { - if(typeof(linkHeader) == 'string') - linkHeader = linkHeader.split(','); - - var relParams = {}; - for(var i = 0; i < linkHeader.length; i++) { - var linkMatch = linkHeader[i].match(/^\s*<(.+)>;\s*rel="(\w+)"$/); - if(linkMatch) { - var params = getRelParams(linkMatch[1]); - if(params) - relParams[linkMatch[2]] = params; - } - } - return relParams; - } - - function PaginatedResource(rest, path, headers, envelope, bodyHandler, useHttpPaginatedResponse) { - this.rest = rest; - this.path = path; - this.headers = headers; - this.envelope = envelope; - this.bodyHandler = bodyHandler; - this.useHttpPaginatedResponse = useHttpPaginatedResponse || false; - } - - Utils.arrForEach(Http.methodsWithoutBody, function(method) { - PaginatedResource.prototype[method] = function(params, callback) { - var self = this; - Resource[method](self.rest, self.path, self.headers, params, self.envelope, function(err, body, headers, unpacked, statusCode) { - self.handlePage(err, body, headers, unpacked, statusCode, callback); - }); - }; - }) - - Utils.arrForEach(Http.methodsWithBody, function(method) { - PaginatedResource.prototype[method] = function(params, body, callback) { - var self = this; - Resource[method](self.rest, self.path, body, self.headers, params, self.envelope, function(err, resbody, headers, unpacked, statusCode) { - if(callback) { - self.handlePage(err, resbody, headers, unpacked, statusCode, callback); - } - }); - }; - }); - - function returnErrOnly(err, body, useHPR) { - /* If using httpPaginatedResponse, errors from Ably are returned as part of - * the HPR, only do callback(err) for network errors etc. which don't - * return a body and/or have no ably-originated error code (non-numeric - * error codes originate from node) */ - return !(useHPR && (body || typeof err.code === 'number')); - } - - PaginatedResource.prototype.handlePage = function(err, body, headers, unpacked, statusCode, callback) { - if(err && returnErrOnly(err, body, this.useHttpPaginatedResponse)) { - Logger.logAction(Logger.LOG_ERROR, 'PaginatedResource.handlePage()', 'Unexpected error getting resource: err = ' + Utils.inspectError(err)); - callback(err); - return; - } - var items, linkHeader, relParams; - try { - items = this.bodyHandler(body, headers, unpacked); - } catch(e) { - /* If we got an error, the failure to parse the body is almost certainly - * due to that, so cb with that in preference to the parse error */ - callback(err || e); - return; - } - - if(headers && (linkHeader = (headers['Link'] || headers['link']))) { - relParams = parseRelLinks(linkHeader); - } - - if(this.useHttpPaginatedResponse) { - callback(null, new HttpPaginatedResponse(this, items, headers, statusCode, relParams, err)); - } else { - callback(null, new PaginatedResult(this, items, relParams)); - } - }; - - function PaginatedResult(resource, items, relParams) { - this.resource = resource; - this.items = items; - - if(relParams) { - var self = this; - if('first' in relParams) { - this.first = function(cb) { - if(!cb && self.resource.rest.options.promises) { - return Utils.promisify(self, 'first', []); - } - self.get(relParams.first, cb); - }; - } - if('current' in relParams) { - this.current = function(cb) { - if(!cb && self.resource.rest.options.promises) { - return Utils.promisify(self, 'current', []); - } - self.get(relParams.current, cb); - }; - } - this.next = function(cb) { - if(!cb && self.resource.rest.options.promises) { - return Utils.promisify(self, 'next', []); - } - if('next' in relParams) { - self.get(relParams.next, cb); - } else { - cb(null, null); - } - }; - - this.hasNext = function() { return ('next' in relParams) }; - this.isLast = function() { return !this.hasNext(); } - } - } - - /* We assume that only the initial request can be a POST, and that accessing - * the rest of a multipage set of results can always be done with GET */ - PaginatedResult.prototype.get = function(params, callback) { - var res = this.resource; - Resource.get(res.rest, res.path, res.headers, params, res.envelope, function(err, body, headers, unpacked, statusCode) { - res.handlePage(err, body, headers, unpacked, statusCode, callback); - }); - }; - - function HttpPaginatedResponse(resource, items, headers, statusCode, relParams, err) { - PaginatedResult.call(this, resource, items, relParams); - this.statusCode = statusCode; - this.success = statusCode < 300 && statusCode >= 200; - this.headers = headers; - this.errorCode = err && err.code; - this.errorMessage = err && err.message; - } - Utils.inherits(HttpPaginatedResponse, PaginatedResult); - - return PaginatedResource; -})(); - -export default PaginatedResource; diff --git a/common/lib/client/paginatedresource.ts b/common/lib/client/paginatedresource.ts new file mode 100644 index 0000000000..d48a64c8ce --- /dev/null +++ b/common/lib/client/paginatedresource.ts @@ -0,0 +1,193 @@ +import * as Utils from '../util/utils'; +import Logger from '../util/logger'; +import Resource from './resource'; +import ErrorInfo from '../types/errorinfo'; +import { PaginatedResultCallback } from '../../types/utils'; +import Rest from './rest'; + +export type BodyHandler = (body: unknown, headers: Record, packed?: boolean) => any; + +function getRelParams(linkUrl: string) { + const urlMatch = linkUrl.match(/^\.\/(\w+)\?(.*)$/); + return urlMatch && urlMatch[2] && Utils.parseQueryString(urlMatch[2]); +} + +function parseRelLinks(linkHeader: string | Array) { + if(typeof(linkHeader) == 'string') + linkHeader = linkHeader.split(','); + + const relParams: Record> = {}; + for(let i = 0; i < linkHeader.length; i++) { + const linkMatch = linkHeader[i].match(/^\s*<(.+)>;\s*rel="(\w+)"$/); + if(linkMatch) { + const params = getRelParams(linkMatch[1]); + if(params) + relParams[linkMatch[2]] = params; + } + } + return relParams; +} + +function returnErrOnly(err: ErrorInfo, body: unknown, useHPR?: boolean) { + /* If using httpPaginatedResponse, errors from Ably are returned as part of + * the HPR, only do callback(err) for network errors etc. which don't + * return a body and/or have no ably-originated error code (non-numeric + * error codes originate from node) */ + return !(useHPR && (body || typeof err.code === 'number')); +} + +class PaginatedResource { + rest: Rest; + path: string; + headers: Record; + envelope: Utils.Format | null; + bodyHandler: BodyHandler; + useHttpPaginatedResponse: boolean; + + constructor(rest: Rest, path: string, headers: Record, envelope: Utils.Format | undefined, bodyHandler: BodyHandler, useHttpPaginatedResponse?: boolean) { + this.rest = rest; + this.path = path; + this.headers = headers; + this.envelope = envelope ?? null; + this.bodyHandler = bodyHandler; + this.useHttpPaginatedResponse = useHttpPaginatedResponse || false; + } + + get(params: Record, callback: PaginatedResultCallback): void { + Resource.get(this.rest, this.path, this.headers, params, this.envelope, (err: ErrorInfo, body: unknown, headers: Record, unpacked: boolean, statusCode: number) => { + this.handlePage(err, body, headers, unpacked, statusCode, callback); + }); + } + + delete(params: Record, callback: PaginatedResultCallback): void { + Resource.delete(this.rest, this.path, this.headers, params, this.envelope, (err: ErrorInfo, body: unknown, headers: Record, unpacked: boolean, statusCode: number) => { + this.handlePage(err, body, headers, unpacked, statusCode, callback); + }); + } + + post(params: Record, body: unknown, callback: PaginatedResultCallback): void { + Resource.post(this.rest, this.path, body, this.headers, params, this.envelope, (err: ErrorInfo, resbody: unknown, headers: Record, unpacked: boolean, statusCode: number) => { + if(callback) { + this.handlePage(err, resbody, headers, unpacked, statusCode, callback); + } + }); + } + + put(params: Record, body: unknown, callback: PaginatedResultCallback): void { + Resource.put(this.rest, this.path, body, this.headers, params, this.envelope, (err: ErrorInfo, resbody: unknown, headers: Record, unpacked: boolean, statusCode: number) => { + if(callback) { + this.handlePage(err, resbody, headers, unpacked, statusCode, callback); + } + }); + } + + patch(params: Record, body: unknown, callback: PaginatedResultCallback): void { + Resource.patch(this.rest, this.path, body, this.headers, params, this.envelope, (err: ErrorInfo, resbody: unknown, headers: Record, unpacked: boolean, statusCode: number) => { + if(callback) { + this.handlePage(err, resbody, headers, unpacked, statusCode, callback); + } + }); + } + + handlePage(err: ErrorInfo, body: unknown, headers: Record, unpacked: boolean, statusCode: number, callback: PaginatedResultCallback): void { + if(err && returnErrOnly(err, body, this.useHttpPaginatedResponse)) { + Logger.logAction(Logger.LOG_ERROR, 'PaginatedResource.handlePage()', 'Unexpected error getting resource: err = ' + Utils.inspectError(err)); + callback(err); + return; + } + let items, linkHeader, relParams; + try { + items = this.bodyHandler(body, headers, unpacked); + } catch(e) { + /* If we got an error, the failure to parse the body is almost certainly + * due to that, so callback with that in preference over the parse error */ + callback(err || e); + return; + } + + if(headers && (linkHeader = (headers['Link'] || headers['link']))) { + relParams = parseRelLinks(linkHeader); + } + + if(this.useHttpPaginatedResponse) { + callback(null, new HttpPaginatedResponse(this, items, headers, statusCode, relParams, err)); + } else { + callback(null, new PaginatedResult(this, items, relParams)); + } + } +} + +export class PaginatedResult { + resource: PaginatedResource; + items: T[]; + first?: (results: PaginatedResultCallback) => void; + next?: (results: PaginatedResultCallback) => void; + current?: (results: PaginatedResultCallback) => void; + hasNext?: () => boolean; + isLast?: () => boolean; + + constructor(resource: PaginatedResource, items: T[], relParams?: Record) { + this.resource = resource; + this.items = items; + + if(relParams) { + if('first' in relParams) { + this.first = (callback: (result?: ErrorInfo | null) => void) => { + if(!callback && this.resource.rest.options.promises) { + return Utils.promisify(this, 'first', []); + } + this.get(relParams.first, callback); + }; + } + if('current' in relParams) { + this.current = (callback: (results?: ErrorInfo | null) => void) => { + if(!callback && this.resource.rest.options.promises) { + return Utils.promisify(this, 'current', []); + } + this.get(relParams.current, callback); + }; + } + this.next = (callback: (results?: ErrorInfo | null) => void) => { + if(!callback && this.resource.rest.options.promises) { + return Utils.promisify(this, 'next', []); + } + if('next' in relParams) { + this.get(relParams.next, callback); + } else { + callback(null); + } + }; + + this.hasNext = function() { return ('next' in relParams) }; + this.isLast = () => { return !this.hasNext?.(); } + } + } + + /* We assume that only the initial request can be a POST, and that accessing + * the rest of a multipage set of results can always be done with GET */ + get(params: any, callback: PaginatedResultCallback): void { + const res = this.resource; + Resource.get(res.rest, res.path, res.headers, params, res.envelope, function(err: ErrorInfo, body: any, headers: Record, unpacked: boolean, statusCode: number) { + res.handlePage(err, body, headers, unpacked, statusCode, callback); + }); + } +} + +export class HttpPaginatedResponse extends PaginatedResult { + statusCode: number; + success: boolean; + headers: Record; + errorCode?: number | null; + errorMessage?: string; + + constructor(resource: PaginatedResource, items: T[], headers: Record, statusCode: number, relParams: any, err: ErrorInfo) { + super(resource, items, relParams); + this.statusCode = statusCode; + this.success = statusCode < 300 && statusCode >= 200; + this.headers = headers; + this.errorCode = err && err.code; + this.errorMessage = err && err.message; + } +} + +export default PaginatedResource; diff --git a/common/lib/client/presence.js b/common/lib/client/presence.ts similarity index 50% rename from common/lib/client/presence.js rename to common/lib/client/presence.ts index 2e453436a7..fdf4cfe49b 100644 --- a/common/lib/client/presence.js +++ b/common/lib/client/presence.ts @@ -1,19 +1,27 @@ -import Utils from '../util/utils'; +import * as Utils from '../util/utils'; import EventEmitter from '../util/eventemitter'; import Logger from '../util/logger'; import Http from 'platform-http'; import PaginatedResource from './paginatedresource'; import PresenceMessage from '../types/presencemessage'; +import { CipherOptions } from '../types/message'; +import { PaginatedResultCallback } from '../../types/utils'; +import Channel from './channel'; +import RealtimeChannel from './realtimechannel'; -var Presence = (function() { - function noop() {} - function Presence(channel) { +function noop() {} + +class Presence extends EventEmitter { + channel: RealtimeChannel | Channel; + basePath: string; + + constructor(channel: RealtimeChannel | Channel) { + super(); this.channel = channel; this.basePath = channel.basePath + '/presence'; } - Utils.inherits(Presence, EventEmitter); - Presence.prototype.get = function(params, callback) { + get(params: any, callback: PaginatedResultCallback): void | Promise { Logger.logAction(Logger.LOG_MICRO, 'Presence.get()', 'channel = ' + this.channel.name); /* params and callback are optional; see if params contains the callback */ if(callback === undefined) { @@ -22,31 +30,31 @@ var Presence = (function() { params = null; } else { if(this.channel.rest.options.promises) { - return Utils.promisify(this, 'get', arguments); + return Utils.promisify(this, 'get', [params, callback]); } callback = noop; } } - var rest = this.channel.rest, - format = rest.options.useBinaryProtocol ? 'msgpack' : 'json', + const rest = this.channel.rest, + format = rest.options.useBinaryProtocol ? Utils.Format.msgpack : Utils.Format.json, envelope = Http.supportsLinkHeaders ? undefined : format, headers = Utils.defaultGetHeaders(format); if(rest.options.headers) Utils.mixin(headers, rest.options.headers); - var options = this.channel.channelOptions; - (new PaginatedResource(rest, this.basePath, headers, envelope, function(body, headers, unpacked) { - return PresenceMessage.fromResponseBody(body, options, !unpacked && format); + const options = this.channel.channelOptions; + (new PaginatedResource(rest, this.basePath, headers, envelope, function(body: any, headers: Record, unpacked?: boolean) { + return PresenceMessage.fromResponseBody(body, options as CipherOptions, unpacked ? undefined : format); })).get(params, callback); - }; + } - Presence.prototype.history = function(params, callback) { + history(params: any, callback: PaginatedResultCallback): void { Logger.logAction(Logger.LOG_MICRO, 'Presence.history()', 'channel = ' + this.channel.name); this._history(params, callback); - }; + } - Presence.prototype._history = function(params, callback) { + _history(params: any, callback: PaginatedResultCallback): void | Promise { /* params and callback are optional; see if params contains the callback */ if(callback === undefined) { if(typeof(params) == 'function') { @@ -54,27 +62,24 @@ var Presence = (function() { params = null; } else { if(this.channel.rest.options.promises) { - return Utils.promisify(this, '_history', arguments); + return Utils.promisify(this, '_history', [params, callback]); } callback = noop; } } - var rest = this.channel.rest, - format = rest.options.useBinaryProtocol ? 'msgpack' : 'json', + const rest = this.channel.rest, + format = rest.options.useBinaryProtocol ? Utils.Format.msgpack : Utils.Format.json, envelope = Http.supportsLinkHeaders ? undefined : format, - headers = Utils.defaultGetHeaders(format), - channel = this.channel; + headers = Utils.defaultGetHeaders(format); if(rest.options.headers) Utils.mixin(headers, rest.options.headers); - var options = this.channel.channelOptions; - (new PaginatedResource(rest, this.basePath + '/history', headers, envelope, function(body, headers, unpacked) { - return PresenceMessage.fromResponseBody(body, options, !unpacked && format); + const options = this.channel.channelOptions; + (new PaginatedResource(rest, this.basePath + '/history', headers, envelope, function(body: any, headers: Record, unpacked?: boolean) { + return PresenceMessage.fromResponseBody(body, options as CipherOptions, unpacked ? undefined : format); })).get(params, callback); - }; - - return Presence; -})(); + } +} export default Presence; diff --git a/common/lib/client/push.js b/common/lib/client/push.ts similarity index 55% rename from common/lib/client/push.js rename to common/lib/client/push.ts index 52d4cf4e73..af98273b53 100644 --- a/common/lib/client/push.js +++ b/common/lib/client/push.ts @@ -1,31 +1,42 @@ -import Utils from '../util/utils'; +import * as Utils from '../util/utils'; import DeviceDetails from '../types/devicedetails'; import Resource from './resource'; import PaginatedResource from './paginatedresource'; import ErrorInfo from '../types/errorinfo'; import Http from 'platform-http'; import PushChannelSubscription from '../types/pushchannelsubscription'; +import { ErrCallback, PaginatedResultCallback, StandardCallback } from '../../types/utils'; +import Rest from './rest'; -var Push = (function() { - var noop = function() {}; +const noop = function() {}; - function Push(rest) { +class Push { + rest: Rest; + admin: Admin; + + constructor(rest: Rest) { this.rest = rest; this.admin = new Admin(rest); } +} + +class Admin { + rest: Rest; + deviceRegistrations: DeviceRegistrations; + channelSubscriptions: ChannelSubscriptions; - function Admin(rest) { + constructor(rest: Rest) { this.rest = rest; this.deviceRegistrations = new DeviceRegistrations(rest); this.channelSubscriptions = new ChannelSubscriptions(rest); } - Admin.prototype.publish = function(recipient, payload, callback) { - var rest = this.rest; - var format = rest.options.useBinaryProtocol ? 'msgpack' : 'json', - requestBody = Utils.mixin({recipient: recipient}, payload), + publish(recipient: any, payload: any, callback: ErrCallback) { + const rest = this.rest; + const format = rest.options.useBinaryProtocol ? Utils.Format.msgpack : Utils.Format.json, headers = Utils.defaultPostHeaders(format), params = {}; + const body = Utils.mixin({recipient: recipient}, payload); if(typeof callback !== 'function') { if(this.rest.options.promises) { @@ -40,18 +51,22 @@ var Push = (function() { if(rest.options.pushFullWait) Utils.mixin(params, {fullWait: 'true'}); - requestBody = Utils.encodeBody(requestBody, format); - Resource.post(rest, '/push/publish', requestBody, headers, params, false, function(err) { callback(err); }); - }; + const requestBody = Utils.encodeBody(body, format); + Resource.post(rest, '/push/publish', requestBody, headers, params, null, function(err: Error) { callback(err); }); + } +} - function DeviceRegistrations(rest) { +class DeviceRegistrations { + rest: Rest; + + constructor(rest: Rest) { this.rest = rest; } - DeviceRegistrations.prototype.save = function(device, callback) { - var rest = this.rest; - var format = rest.options.useBinaryProtocol ? 'msgpack' : 'json', - requestBody = DeviceDetails.fromValues(device), + save(device: any, callback: StandardCallback) { + const rest = this.rest; + const body = DeviceDetails.fromValues(device); + const format = rest.options.useBinaryProtocol ? Utils.Format.msgpack : Utils.Format.json, headers = Utils.defaultPostHeaders(format), params = {}; @@ -68,15 +83,15 @@ var Push = (function() { if(rest.options.pushFullWait) Utils.mixin(params, {fullWait: 'true'}); - requestBody = Utils.encodeBody(requestBody, format); - Resource.put(rest, '/push/deviceRegistrations/' + encodeURIComponent(device.id), requestBody, headers, params, false, function(err, body, headers, unpacked) { - callback(err, !err && DeviceDetails.fromResponseBody(body, !unpacked && format)); + const requestBody = Utils.encodeBody(body, format); + Resource.put(rest, '/push/deviceRegistrations/' + encodeURIComponent(device.id), requestBody, headers, params, null, function(err: Error, body: any, headers: Record, unpacked: boolean) { + callback(err, (!err || undefined) && DeviceDetails.fromResponseBody(body, unpacked ? undefined : format)); }); - }; + } - DeviceRegistrations.prototype.get = function(deviceIdOrDetails, callback) { - var rest = this.rest, - format = rest.options.useBinaryProtocol ? 'msgpack' : 'json', + get(deviceIdOrDetails: any, callback: StandardCallback) { + const rest = this.rest, + format = rest.options.useBinaryProtocol ? Utils.Format.msgpack : Utils.Format.json, headers = Utils.defaultGetHeaders(format), deviceId = deviceIdOrDetails.id || deviceIdOrDetails; @@ -95,14 +110,14 @@ var Push = (function() { if(rest.options.headers) Utils.mixin(headers, rest.options.headers); - Resource.get(rest, '/push/deviceRegistrations/' + encodeURIComponent(deviceId), headers, {}, false, function(err, body, headers, unpacked) { - callback(err, !err && DeviceDetails.fromResponseBody(body, !unpacked && format)); + Resource.get(rest, '/push/deviceRegistrations/' + encodeURIComponent(deviceId), headers, {}, null, function(err: Error, body: any, headers: Record, unpacked: boolean) { + callback(err, (!err || undefined) && DeviceDetails.fromResponseBody(body, unpacked ? undefined : format)); }); - }; + } - DeviceRegistrations.prototype.list = function(params, callback) { - var rest = this.rest, - format = rest.options.useBinaryProtocol ? 'msgpack' : 'json', + list(params: any, callback: PaginatedResultCallback) { + const rest = this.rest, + format = rest.options.useBinaryProtocol ? Utils.Format.msgpack : Utils.Format.json, envelope = Http.supportsLinkHeaders ? undefined : format, headers = Utils.defaultGetHeaders(format); @@ -116,14 +131,14 @@ var Push = (function() { if(rest.options.headers) Utils.mixin(headers, rest.options.headers); - (new PaginatedResource(rest, '/push/deviceRegistrations', headers, envelope, function(body, headers, unpacked) { - return DeviceDetails.fromResponseBody(body, !unpacked && format); + (new PaginatedResource(rest, '/push/deviceRegistrations', headers, envelope, function(body: any, headers: Record, unpacked?: boolean) { + return DeviceDetails.fromResponseBody(body, unpacked ? undefined : format); })).get(params, callback); - }; + } - DeviceRegistrations.prototype.remove = function(deviceIdOrDetails, callback) { - var rest = this.rest, - format = rest.options.useBinaryProtocol ? 'msgpack' : 'json', + remove(deviceIdOrDetails: any, callback: ErrCallback) { + const rest = this.rest, + format = rest.options.useBinaryProtocol ? Utils.Format.msgpack : Utils.Format.json, headers = Utils.defaultGetHeaders(format), params = {}, deviceId = deviceIdOrDetails.id || deviceIdOrDetails; @@ -146,12 +161,12 @@ var Push = (function() { if(rest.options.pushFullWait) Utils.mixin(params, {fullWait: 'true'}); - Resource['delete'](rest, '/push/deviceRegistrations/' + encodeURIComponent(deviceId), headers, params, false, function(err) { callback(err); }); - }; + Resource['delete'](rest, '/push/deviceRegistrations/' + encodeURIComponent(deviceId), headers, params, null, function(err: Error) { callback(err); }); + } - DeviceRegistrations.prototype.removeWhere = function(params, callback) { - var rest = this.rest, - format = rest.options.useBinaryProtocol ? 'msgpack' : 'json', + removeWhere(params: any, callback: ErrCallback) { + const rest = this.rest, + format = rest.options.useBinaryProtocol ? Utils.Format.msgpack : Utils.Format.json, headers = Utils.defaultGetHeaders(format); if(typeof callback !== 'function') { @@ -167,17 +182,21 @@ var Push = (function() { if(rest.options.pushFullWait) Utils.mixin(params, {fullWait: 'true'}); - Resource['delete'](rest, '/push/deviceRegistrations', headers, params, false, function(err) { callback(err); }); - }; + Resource['delete'](rest, '/push/deviceRegistrations', headers, params, null, function(err: Error) { callback(err); }); + } +} - function ChannelSubscriptions(rest) { +class ChannelSubscriptions { + rest: Rest; + + constructor(rest: Rest) { this.rest = rest; } - ChannelSubscriptions.prototype.save = function(subscription, callback) { - var rest = this.rest; - var format = rest.options.useBinaryProtocol ? 'msgpack' : 'json', - requestBody = PushChannelSubscription.fromValues(subscription), + save(subscription: Record, callback: PaginatedResultCallback) { + const rest = this.rest; + const body = PushChannelSubscription.fromValues(subscription); + const format = rest.options.useBinaryProtocol ? Utils.Format.msgpack : Utils.Format.json, headers = Utils.defaultPostHeaders(format), params = {}; @@ -194,15 +213,15 @@ var Push = (function() { if(rest.options.pushFullWait) Utils.mixin(params, {fullWait: 'true'}); - requestBody = Utils.encodeBody(requestBody, format); - Resource.post(rest, '/push/channelSubscriptions', requestBody, headers, params, false, function(err, body, headers, unpacked) { - callback(err, !err && PushChannelSubscription.fromResponseBody(body, !unpacked && format)); + const requestBody = Utils.encodeBody(body, format); + Resource.post(rest, '/push/channelSubscriptions', requestBody, headers, params, null, function(err: Error, body: Record, headers: Record, unpacked: boolean) { + callback(err, (!err || undefined) && PushChannelSubscription.fromResponseBody(body, unpacked ? undefined : format)); }); - }; + } - ChannelSubscriptions.prototype.list = function(params, callback) { - var rest = this.rest, - format = rest.options.useBinaryProtocol ? 'msgpack' : 'json', + list(params: any, callback: PaginatedResultCallback) { + const rest = this.rest, + format = rest.options.useBinaryProtocol ? Utils.Format.msgpack : Utils.Format.json, envelope = Http.supportsLinkHeaders ? undefined : format, headers = Utils.defaultGetHeaders(format); @@ -216,14 +235,14 @@ var Push = (function() { if(rest.options.headers) Utils.mixin(headers, rest.options.headers); - (new PaginatedResource(rest, '/push/channelSubscriptions', headers, envelope, function(body, headers, unpacked) { - return PushChannelSubscription.fromResponseBody(body, !unpacked && format); + (new PaginatedResource(rest, '/push/channelSubscriptions', headers, envelope, function(body: any, headers: Record, unpacked?: boolean) { + return PushChannelSubscription.fromResponseBody(body, unpacked ? undefined : format); })).get(params, callback); - }; + } - ChannelSubscriptions.prototype.removeWhere = function(params, callback) { - var rest = this.rest, - format = rest.options.useBinaryProtocol ? 'msgpack' : 'json', + removeWhere(params: any, callback: PaginatedResultCallback) { + const rest = this.rest, + format = rest.options.useBinaryProtocol ? Utils.Format.msgpack : Utils.Format.json, headers = Utils.defaultGetHeaders(format); if(typeof callback !== 'function') { @@ -239,15 +258,15 @@ var Push = (function() { if(rest.options.pushFullWait) Utils.mixin(params, {fullWait: 'true'}); - Resource['delete'](rest, '/push/channelSubscriptions', headers, params, false, function(err) { callback(err); }); - }; + Resource['delete'](rest, '/push/channelSubscriptions', headers, params, null, function(err: Error) { callback(err); }); + } /* ChannelSubscriptions have no unique id; removing one is equivalent to removeWhere by its properties */ - ChannelSubscriptions.prototype.remove = ChannelSubscriptions.prototype.removeWhere; + remove = ChannelSubscriptions.prototype.removeWhere; - ChannelSubscriptions.prototype.listChannels = function(params, callback) { - var rest = this.rest, - format = rest.options.useBinaryProtocol ? 'msgpack' : 'json', + listChannels(params: any, callback: PaginatedResultCallback) { + const rest = this.rest, + format = rest.options.useBinaryProtocol ? Utils.Format.msgpack : Utils.Format.json, envelope = Http.supportsLinkHeaders ? undefined : format, headers = Utils.defaultGetHeaders(format); @@ -264,21 +283,15 @@ var Push = (function() { if(rest.options.pushFullWait) Utils.mixin(params, {fullWait: 'true'}); - (new PaginatedResource(rest, '/push/channels', headers, envelope, function(body, headers, unpacked) { - var f = !unpacked && format; + (new PaginatedResource(rest, '/push/channels', headers, envelope, function(body: unknown, headers: Record, unpacked?: boolean) { + const parsedBody = ((!unpacked && format) ? Utils.decodeBody(body, format) : body) as Array - if(f) { - body = Utils.decodeBody(body, format); + for(let i = 0; i < parsedBody.length; i++) { + parsedBody[i] = String(parsedBody[i]); } - - for(var i = 0; i < body.length; i++) { - body[i] = String(body[i]); - } - return body; + return parsedBody; })).get(params, callback); - }; - - return Push; -})(); + } +} export default Push; diff --git a/common/lib/client/realtime.js b/common/lib/client/realtime.ts similarity index 60% rename from common/lib/client/realtime.js rename to common/lib/client/realtime.ts index eb00dd66c7..83a8d3488c 100644 --- a/common/lib/client/realtime.js +++ b/common/lib/client/realtime.ts @@ -1,4 +1,4 @@ -import Utils from '../util/utils'; +import * as Utils from '../util/utils'; import Rest from './rest'; import EventEmitter from '../util/eventemitter'; import Logger from '../util/logger'; @@ -7,130 +7,144 @@ import RealtimeChannel from './realtimechannel'; import Defaults from '../util/defaults'; import ErrorReporter from '../util/errorreporter'; import ErrorInfo from '../types/errorinfo'; - -var Realtime = (function() { - - function Realtime(options) { - if(!(this instanceof Realtime)){ - return new Realtime(options); - } - +import ProtocolMessage from '../types/protocolmessage'; +import { ChannelOptions } from '../../types/channel'; +import { ErrCallback } from '../../types/utils'; +import ClientOptions, { DeprecatedClientOptions } from '../../types/ClientOptions'; +import * as API from '../../../ably'; + +class Realtime extends Rest { + channels: any; + connection: Connection; + + constructor(options: ClientOptions) { + super(options); Logger.logAction(Logger.LOG_MINOR, 'Realtime()', ''); - Rest.call(this, options); this.connection = new Connection(this, this.options); this.channels = new Channels(this); if(options.autoConnect !== false) this.connect(); } - Utils.inherits(Realtime, Rest); - Realtime.prototype.connect = function() { + connect(): void { Logger.logAction(Logger.LOG_MINOR, 'Realtime.connect()', ''); this.connection.connect(); - }; + } - Realtime.prototype.close = function() { + close(): void { Logger.logAction(Logger.LOG_MINOR, 'Realtime.close()', ''); this.connection.close(); + } + + static Promise = function(options: DeprecatedClientOptions): Realtime { + options = Defaults.objectifyOptions(options); + options.promises = true; + return new Realtime(options); }; - function Channels(realtime) { - EventEmitter.call(this); + static Callbacks = Realtime; +} + +class Channels extends EventEmitter { + realtime: Realtime; + all: Record; + inProgress: Record; + + constructor(realtime: Realtime) { + super(); this.realtime = realtime; - this.all = Object.create(null); - this.inProgress = Object.create(null); - var self = this; - realtime.connection.connectionManager.on('transport.active', function() { - self.onTransportActive(); + this.all = {}; + this.inProgress = {}; + realtime.connection.connectionManager.on('transport.active', () => { + this.onTransportActive(); }); } - Utils.inherits(Channels, EventEmitter); - Channels.prototype.onChannelMessage = function(msg) { - var channelName = msg.channel; + onChannelMessage(msg: ProtocolMessage) { + const channelName = msg.channel; if(channelName === undefined) { Logger.logAction(Logger.LOG_ERROR, 'Channels.onChannelMessage()', 'received event unspecified channel, action = ' + msg.action); return; } - var channel = this.all[channelName]; + const channel = this.all[channelName]; if(!channel) { Logger.logAction(Logger.LOG_ERROR, 'Channels.onChannelMessage()', 'received event for non-existent channel: ' + channelName); return; } channel.onMessage(msg); - }; + } /* called when a transport becomes connected; reattempt attach/detach * for channels that are attaching or detaching. * Note that this does not use inProgress as inProgress is only channels which have already made * at least one attempt to attach/detach */ - Channels.prototype.onTransportActive = function() { - for(var channelName in this.all) { - var channel = this.all[channelName]; + onTransportActive() { + for(const channelName in this.all) { + const channel = this.all[channelName]; if(channel.state === 'attaching' || channel.state === 'detaching') { channel.checkPendingState(); } else if(channel.state === 'suspended') { channel.attach(); } } - }; + } - Channels.prototype.reattach = function(reason) { - for(var channelId in this.all) { - var channel = this.all[channelId]; + reattach(reason: ErrorInfo) { + for(const channelId in this.all) { + const channel = this.all[channelId]; /* NB this should not trigger for merely attaching channels, as they will * be reattached anyway through the onTransportActive checkPendingState */ if(channel.state === 'attached') { channel.requestState('attaching', reason); } } - }; + } - Channels.prototype.resetAttachedMsgIndicators = function() { - for(var channelId in this.all) { - var channel = this.all[channelId]; + resetAttachedMsgIndicators() { + for(const channelId in this.all) { + const channel = this.all[channelId]; if(channel.state === 'attached') { channel._attachedMsgIndicator = false; } } - }; + } - Channels.prototype.checkAttachedMsgIndicators = function(connectionId) { - for(var channelId in this.all) { - var channel = this.all[channelId]; + checkAttachedMsgIndicators(connectionId: string) { + for(const channelId in this.all) { + const channel = this.all[channelId]; if(channel.state === 'attached' && channel._attachedMsgIndicator === false) { - var msg = '30s after a resume, found channel which has not received an attached; channelId = ' + channelId + '; connectionId = ' + connectionId; + const msg = '30s after a resume, found channel which has not received an attached; channelId = ' + channelId + '; connectionId = ' + connectionId; Logger.logAction(Logger.LOG_ERROR, 'Channels.checkAttachedMsgIndicators()', msg); ErrorReporter.report('error', msg, 'channel-no-attached-after-resume'); channel.requestState('attaching'); - }; + } } - }; + } /* Connection interruptions (ie when the connection will no longer queue * events) imply connection state changes for any channel which is either * attached, pending, or will attempt to become attached in the future */ - Channels.prototype.propogateConnectionInterruption = function(connectionState, reason) { - var connectionStateToChannelState = { + propogateConnectionInterruption(connectionState: string, reason: ErrorInfo) { + const connectionStateToChannelState: Record = { 'closing' : 'detached', 'closed' : 'detached', 'failed' : 'failed', 'suspended': 'suspended' }; - var fromChannelStates = ['attaching', 'attached', 'detaching', 'suspended']; - var toChannelState = connectionStateToChannelState[connectionState]; + const fromChannelStates = ['attaching', 'attached', 'detaching', 'suspended']; + const toChannelState = connectionStateToChannelState[connectionState]; - for(var channelId in this.all) { - var channel = this.all[channelId]; + for(const channelId in this.all) { + const channel = this.all[channelId]; if(Utils.arrIn(fromChannelStates, channel.state)) { - channel.notifyState(toChannelState, reason); + channel.notifyState(toChannelState, reason); } } - }; + } - Channels.prototype.get = function(name, channelOptions) { + get(name: string, channelOptions: ChannelOptions) { name = String(name); - var channel = this.all[name]; + let channel = this.all[name]; if(!channel) { channel = this.all[name] = new RealtimeChannel(this.realtime, name, channelOptions); } else if(channelOptions) { @@ -140,60 +154,50 @@ var Realtime = (function() { channel.setOptions(channelOptions); } return channel; - }; + } /* Included to support certain niche use-cases; most users should ignore this. * Please do not use this unless you know what you're doing */ - Channels.prototype.release = function(name) { + release(name: string) { name = String(name); - var channel = this.all[name]; + const channel = this.all[name]; if(!channel) { return; } - var releaseErr = channel.getReleaseErr(); + const releaseErr = channel.getReleaseErr(); if(releaseErr) { throw releaseErr; } delete this.all[name]; delete this.inProgress[name]; - }; + } /* Records operations currently pending on a transport; used by connectionManager to decide when * it's safe to upgrade. Note that a channel might be in the attaching state without any pending * operations (eg if attached while the connection state is connecting) - such a channel must not * hold up an upgrade, so is not considered inProgress. * Operation is currently one of either 'statechange' or 'sync' */ - Channels.prototype.setInProgress = function(channel, operation, inProgress) { + setInProgress(channel: RealtimeChannel, operation: string, inProgress: boolean) { this.inProgress[channel.name] = this.inProgress[channel.name] || {}; - this.inProgress[channel.name][operation] = inProgress; + (this.inProgress[channel.name] as any)[operation] = inProgress; if(!inProgress && this.hasNopending()) { this.emit('nopending'); } - }; + } - Channels.prototype.onceNopending = function(listener) { + onceNopending(listener: ErrCallback) { if(this.hasNopending()) { listener(); return; } this.once('nopending', listener); - }; + } - Channels.prototype.hasNopending = function() { - return Utils.arrEvery(Utils.valuesArray(this.inProgress, true), function(operations) { + hasNopending() { + return Utils.arrEvery(Utils.valuesArray(this.inProgress, true) as any, function(operations: Record) { return !Utils.containsValue(operations, true); }); - }; - - return Realtime; -})(); - -Realtime.Promise = function(options) { - options = Defaults.objectifyOptions(options); - options.promises = true; - return new Realtime(options); -}; - -Realtime.Callbacks = Realtime; + } +} export default Realtime; diff --git a/common/lib/client/realtimechannel.js b/common/lib/client/realtimechannel.ts similarity index 64% rename from common/lib/client/realtimechannel.js rename to common/lib/client/realtimechannel.ts index df7b4d57f8..5cfe40588d 100644 --- a/common/lib/client/realtimechannel.js +++ b/common/lib/client/realtimechannel.ts @@ -1,27 +1,78 @@ import ProtocolMessage from '../types/protocolmessage'; import EventEmitter from '../util/eventemitter'; -import Utils from '../util/utils'; +import * as Utils from '../util/utils'; import Channel from './channel'; import Logger from '../util/logger'; import RealtimePresence from './realtimepresence'; -import Message from '../types/message'; +import Message, { CipherOptions } from '../types/message'; import ChannelStateChange from './channelstatechange'; import ErrorInfo from '../types/errorinfo'; import PresenceMessage from '../types/presencemessage'; -import ConnectionError from '../transport/connectionerror'; - -var RealtimeChannel = (function() { - var actions = ProtocolMessage.Action; - var noop = function() {}; - var statechangeOp = 'statechange'; - var syncOp = 'sync'; - - /* public constructor */ - function RealtimeChannel(realtime, name, options) { +import ConnectionErrors from '../transport/connectionerrors'; +import * as API from '../../../ably'; +import ConnectionManager from '../transport/connectionmanager'; +import ConnectionStateChange from './connectionstatechange'; +import { ErrCallback, PaginatedResultCallback } from '../../types/utils'; +import Realtime from './realtime'; + +interface RealtimeHistoryParams { + start?: number; + end?: number; + direction?: string; + limit?: number; + untilAttach?: boolean; + from_serial?: number; +} + +const actions = ProtocolMessage.Action; +const noop = function() {}; +const statechangeOp = 'statechange'; +const syncOp = 'sync'; + +function validateChannelOptions(options: API.Types.ChannelOptions) { + if(options && 'params' in options && !Utils.isObject(options.params)) { + return new ErrorInfo('options.params must be an object', 40000, 400); + } + if(options && 'modes' in options){ + if(!Utils.isArray(options.modes)){ + return new ErrorInfo('options.modes must be an array', 40000, 400); + } + for(let i = 0; i < options.modes.length; i++){ + const currentMode = options.modes[i]; + if(!currentMode || typeof currentMode !== 'string' || !Utils.arrIn(ProtocolMessage.channelModes, String.prototype.toUpperCase.call(currentMode))){ + return new ErrorInfo('Invalid channel mode: ' + currentMode, 40000, 400); + } + } + } +} + + +class RealtimeChannel extends Channel { + realtime: Realtime; + presence: RealtimePresence; + connectionManager: ConnectionManager; + state: API.Types.ChannelState; + subscriptions: EventEmitter; + syncChannelSerial?: number | null; + properties: { attachSerial: number | null | undefined; }; + errorReason: ErrorInfo | string | null; + _requestedFlags: Array | null; + _mode?: null | number; + _attachedMsgIndicator: boolean; + _attachResume: boolean; + _decodingContext: { channelOptions: API.Types.ChannelOptions; plugins: any; baseEncodedPreviousPayload: undefined; }; + _lastPayload: { messageId?: string | null; protocolMessageChannelSerial?: number | null; decodeFailureRecoveryInProgress: null | boolean; }; + _allChannelChanges: EventEmitter; + params?: Record; + modes: string[] | undefined; + stateTimer?: number | NodeJS.Timeout | null; + retryTimer?: number | NodeJS.Timeout | null; + + constructor(realtime: Realtime, name: string, options: API.Types.ChannelOptions) { + super(realtime, name, options); Logger.logAction(Logger.LOG_MINOR, 'RealtimeChannel()', 'started; name = ' + name); - Channel.call(this, realtime, name, options); this.realtime = realtime; - this.presence = new RealtimePresence(this, realtime.options); + this.presence = new RealtimePresence(this); this.connectionManager = realtime.connection.connectionManager; this.state = 'initialized'; this.subscriptions = new EventEmitter(); @@ -50,22 +101,21 @@ var RealtimeChannel = (function() { * update event for all ATTACHEDs, whether resumed or not */ this._allChannelChanges = new EventEmitter(); } - Utils.inherits(RealtimeChannel, Channel); - RealtimeChannel.invalidStateError = function(state) { + static invalidStateError (state: string): ErrorInfo { return { statusCode: 400, code: 90001, message: 'Channel operation failed as channel state is ' + state }; - }; + } - RealtimeChannel.progressOps = { + static progressOps = { statechange: statechangeOp, sync: syncOp }; - RealtimeChannel.processListenerArgs = function(args) { + static processListenerArgs (args: unknown[]): any[] { /* [event], listener, [callback] */ args = Array.prototype.slice.call(args); if(typeof args[0] === 'function') { @@ -75,23 +125,22 @@ var RealtimeChannel = (function() { args.pop(); } return args; - }; + } - RealtimeChannel.prototype.setOptions = function(options, callback) { + setOptions(options: API.Types.ChannelOptions, callback?: ErrCallback): void | Promise { if(!callback) { if (this.rest.options.promises) { return Utils.promisify(this, 'setOptions', arguments); } - - callback = function(err){ - if(err) { + } + const _callback = callback || function (err?: ErrorInfo | null) { + if (err) { Logger.logAction(Logger.LOG_ERROR, 'RealtimeChannel.setOptions()', 'Set options failed: ' + err.toString()); } }; - } - var err = validateChannelOptions(options); + const err = validateChannelOptions(options); if(err) { - callback(err); + _callback(err); return; } Channel.prototype.setOptions.call(this, options); @@ -105,47 +154,30 @@ var RealtimeChannel = (function() { * rejecting messages until we have confirmation that the options have changed, * which would unnecessarily lose message continuity. */ this.attachImpl(); - this._allChannelChanges.once(function(stateChange) { + this._allChannelChanges.once(function(this: { event: string }, stateChange: ConnectionStateChange) { switch(this.event) { case 'update': case 'attached': - callback(null); + _callback?.(null); return; default: - callback(stateChange.reason); + _callback?.(stateChange.reason); return; } }); } else { - callback(); - } - }; - - function validateChannelOptions(options) { - if(options && 'params' in options && !Utils.isObject(options.params)) { - return new ErrorInfo('options.params must be an object', 40000, 400); - } - if(options && 'modes' in options){ - if(!Utils.isArray(options.modes)){ - return new ErrorInfo('options.modes must be an array', 40000, 400); - } - for(var i = 0; i < options.modes.length; i++){ - var currentMode = options.modes[i]; - if(!currentMode || typeof currentMode !== 'string' || !Utils.arrIn(ProtocolMessage.channelModes, String.prototype.toUpperCase.call(currentMode))){ - return new ErrorInfo('Invalid channel mode: ' + currentMode, 40000, 400); - } - } + _callback(); } } - RealtimeChannel.prototype._shouldReattachToSetOptions = function(options) { + _shouldReattachToSetOptions(options: API.Types.ChannelOptions) { return (this.state === 'attached' || this.state === 'attaching') && (options.params || options.modes); - }; + } - RealtimeChannel.prototype.publish = function() { - var argCount = arguments.length, - messages = arguments[0], - callback = arguments[argCount - 1]; + publish(...args: any[]): void | Promise { + let messages = args[0]; + let argCount = args.length; + let callback = args[argCount - 1]; if(typeof(callback) !== 'function') { if(this.realtime.options.promises) { @@ -166,28 +198,28 @@ var RealtimeChannel = (function() { else throw new ErrorInfo('The single-argument form of publish() expects a message object or an array of message objects', 40013, 400); } else { - messages = [Message.fromValues({name: arguments[0], data: arguments[1]})]; + messages = [Message.fromValues({name: args[0], data: args[1]})]; } - var self = this, - maxMessageSize = this.realtime.options.maxMessageSize; - Message.encodeArray(messages, this.channelOptions, function(err) { + const maxMessageSize = this.realtime.options.maxMessageSize; + Message.encodeArray(messages, this.channelOptions as CipherOptions, (err: Error | null) => { if (err) { callback(err); return; } /* RSL1i */ - var size = Message.getMessagesSize(messages); + const size = Message.getMessagesSize(messages); if(size > maxMessageSize) { callback(new ErrorInfo('Maximum size of messages that can be published at once exceeded ( was ' + size + ' bytes; limit is ' + maxMessageSize + ' bytes)', 40009, 400)); return; } - self._publish(messages, callback); + this.__publish(messages, callback); }); - }; + } - RealtimeChannel.prototype._publish = function(messages, callback) { + // Double underscore used to prevent type conflict with underlying Channel._publish method + __publish (messages: Array, callback: ErrCallback) { Logger.logAction(Logger.LOG_MICRO, 'RealtimeChannel.publish()', 'message count = ' + messages.length); - var state = this.state; + const state = this.state; switch(state) { case 'failed': case 'suspended': @@ -195,7 +227,7 @@ var RealtimeChannel = (function() { break; default: Logger.logAction(Logger.LOG_MICRO, 'RealtimeChannel.publish()', 'sending message; channel state is ' + state); - var msg = new ProtocolMessage(); + const msg = new ProtocolMessage(); msg.action = actions.MESSAGE; msg.channel = this.name; msg.messages = messages; @@ -204,53 +236,56 @@ var RealtimeChannel = (function() { } }; - RealtimeChannel.prototype.onEvent = function(messages) { + onEvent(messages: Array): void { Logger.logAction(Logger.LOG_MICRO, 'RealtimeChannel.onEvent()', 'received message'); - var subscriptions = this.subscriptions; - for(var i = 0; i < messages.length; i++) { - var message = messages[i]; + const subscriptions = this.subscriptions; + for(let i = 0; i < messages.length; i++) { + const message = messages[i]; subscriptions.emit(message.name, message); } - }; + } - RealtimeChannel.prototype.attach = function(flags, callback) { + attach(flags?: API.Types.ChannelMode[] | ErrCallback, callback?: ErrCallback): void | Promise { + let _flags: API.Types.ChannelMode[] | null | undefined; if(typeof(flags) === 'function') { callback = flags; - flags = null; + _flags = null; + } else { + _flags = flags; } if(!callback) { if(this.realtime.options.promises) { return Utils.promisify(this, 'attach', arguments); } - callback = function(err) { + callback = function(err?: ErrorInfo | null) { if(err) { Logger.logAction(Logger.LOG_MAJOR, 'RealtimeChannel.attach()', 'Channel attach failed: ' + err.toString()); } } } - if(flags) { + if(_flags) { Logger.deprecated('channel.attach() with flags', 'channel.setOptions() with channelOptions.params'); /* If flags requested, always do a re-attach. TODO only do this if * current mode differs from requested mode */ - this._requestedFlags = flags; + this._requestedFlags = _flags as API.Types.ChannelMode[]; } else if (this.state === 'attached') { callback(); return; } this._attach(false, null, callback); - }; + } - RealtimeChannel.prototype._attach = function(forceReattach, attachReason, callback) { + _attach(forceReattach: boolean, attachReason: ErrorInfo | null, callback?: ErrCallback): void { if(!callback) { - callback = function(err) { + callback = function(err?: ErrorInfo | null) { if (err) { Logger.logAction(Logger.LOG_ERROR, 'RealtimeChannel._attach()', 'Channel attach failed: ' + err.toString()); } } } - var connectionManager = this.connectionManager; + const connectionManager = this.connectionManager; if(!connectionManager.activeState()) { callback(connectionManager.getError()); return; @@ -260,31 +295,31 @@ var RealtimeChannel = (function() { this.requestState('attaching', attachReason); } - this.once(function(stateChange) { + this.once(function(this: { event: string }, stateChange: ChannelStateChange) { switch(this.event) { case 'attached': - callback(); + callback?.(); break; case 'detached': case 'suspended': case 'failed': - callback(stateChange.reason || connectionManager.getError() || new ErrorInfo('Unable to attach; reason unknown; state = ' + this.event, 90000, 500)); + callback?.(stateChange.reason || connectionManager.getError() || new ErrorInfo('Unable to attach; reason unknown; state = ' + this.event, 90000, 500)); break; case 'detaching': - callback(new ErrorInfo('Attach request superseded by a subsequent detach request', 90000, 409)); + callback?.(new ErrorInfo('Attach request superseded by a subsequent detach request', 90000, 409)); break; } }); - }; + } - RealtimeChannel.prototype.attachImpl = function() { + attachImpl(): void { Logger.logAction(Logger.LOG_MICRO, 'RealtimeChannel.attachImpl()', 'sending ATTACH message'); this.setInProgress(statechangeOp, true); - var attachMsg = ProtocolMessage.fromValues({action: actions.ATTACH, channel: this.name, params: this.channelOptions.params}); + const attachMsg = ProtocolMessage.fromValues({action: actions.ATTACH, channel: this.name, params: this.channelOptions.params}); if(this._requestedFlags) { attachMsg.encodeModesToFlags(this._requestedFlags); } else if(this.channelOptions.modes) { - attachMsg.encodeModesToFlags(Utils.allToUpperCase(this.channelOptions.modes)); + attachMsg.encodeModesToFlags(Utils.allToUpperCase(this.channelOptions.modes) as API.Types.ChannelMode[]); } if(this._attachResume) { attachMsg.setFlag('ATTACH_RESUME'); @@ -293,25 +328,25 @@ var RealtimeChannel = (function() { attachMsg.channelSerial = this._lastPayload.protocolMessageChannelSerial; } this.sendMessage(attachMsg, noop); - }; + } - RealtimeChannel.prototype.detach = function(callback) { + detach(callback: ErrCallback): void | Promise { if(!callback) { if(this.realtime.options.promises) { return Utils.promisify(this, 'detach', arguments); } callback = noop; } - var connectionManager = this.connectionManager; + const connectionManager = this.connectionManager; if(!connectionManager.activeState()) { callback(connectionManager.getError()); return; } switch(this.state) { - case 'suspended': - this.notifyState('detached'); - callback(); - break; + case 'suspended': + this.notifyState('detached'); + callback(); + break; case 'detached': callback(); break; @@ -321,7 +356,7 @@ var RealtimeChannel = (function() { default: this.requestState('detaching'); case 'detaching': - this.once(function(stateChange) { + this.once(function(this: { event: string }, stateChange: ChannelStateChange) { switch(this.event) { case 'detached': callback(); @@ -337,49 +372,43 @@ var RealtimeChannel = (function() { } }); } - }; + } - RealtimeChannel.prototype.detachImpl = function(callback) { + detachImpl(callback?: ErrCallback): void { if (this.connectionManager.mostRecentMsg && this.connectionManager.mostRecentMsg.channel === this.name) { this.connectionManager.mostRecentMsg = null; } Logger.logAction(Logger.LOG_MICRO, 'RealtimeChannel.detach()', 'sending DETACH message'); this.setInProgress(statechangeOp, true); - var msg = ProtocolMessage.fromValues({action: actions.DETACH, channel: this.name}); + const msg = ProtocolMessage.fromValues({action: actions.DETACH, channel: this.name}); this.sendMessage(msg, (callback || noop)); - }; + } - RealtimeChannel.prototype.subscribe = function(/* [event], listener, [callback] */) { - var args = RealtimeChannel.processListenerArgs(arguments); - var event = args[0]; - var listener = args[1]; - var callback = args[2]; + subscribe(...args: unknown[]/* [event], listener, [callback] */): void | Promise { + const [event, listener, callback] = RealtimeChannel.processListenerArgs(args); - if(!callback) { - if(this.realtime.options.promises) { - return Utils.promisify(this, 'subscribe', [event, listener]); - } - callback = noop; + if(!callback && this.realtime.options.promises) { + return Utils.promisify(this, 'subscribe', arguments); } if(this.state === 'failed') { - callback(ErrorInfo.fromValues(RealtimeChannel.invalidStateError('failed'))); + callback?.(ErrorInfo.fromValues(RealtimeChannel.invalidStateError('failed'))); return; } this.subscriptions.on(event, listener); - return this.attach(callback); - }; + return this.attach(callback || noop); + } - RealtimeChannel.prototype.unsubscribe = function(/* [event], listener */) { - var args = RealtimeChannel.processListenerArgs(arguments); - var event = args[0]; - var listener = args[1]; + unsubscribe(...args: unknown[]/* [event], listener */): void { + const _args = RealtimeChannel.processListenerArgs(args); + const event = _args[0]; + const listener = _args[1]; this.subscriptions.off(event, listener); - }; + } - RealtimeChannel.prototype.sync = function() { + sync(): void { /* check preconditions */ switch(this.state) { case 'initialized': @@ -388,25 +417,25 @@ var RealtimeChannel = (function() { throw new ErrorInfo("Unable to sync to channel; not attached", 40000); default: } - var connectionManager = this.connectionManager; + const connectionManager = this.connectionManager; if(!connectionManager.activeState()) { throw connectionManager.getError(); } /* send sync request */ - var syncMessage = ProtocolMessage.fromValues({action: actions.SYNC, channel: this.name}); + const syncMessage = ProtocolMessage.fromValues({action: actions.SYNC, channel: this.name}); if(this.syncChannelSerial) { syncMessage.channelSerial = this.syncChannelSerial; } connectionManager.send(syncMessage); - }; + } - RealtimeChannel.prototype.sendMessage = function(msg, callback) { + sendMessage(msg: ProtocolMessage, callback?: ErrCallback): void { this.connectionManager.send(msg, this.realtime.options.queueMessages, callback); - }; + } - RealtimeChannel.prototype.sendPresence = function(presence, callback) { - var msg = ProtocolMessage.fromValues({ + sendPresence(presence: PresenceMessage | PresenceMessage[], callback?: ErrCallback): void { + const msg = ProtocolMessage.fromValues({ action: actions.PRESENCE, channel: this.name, presence: (Utils.isArray(presence) ? @@ -414,20 +443,20 @@ var RealtimeChannel = (function() { [PresenceMessage.fromValues(presence)]) }); this.sendMessage(msg, callback); - }; + } - RealtimeChannel.prototype.onMessage = function(message) { - var syncChannelSerial, isSync = false; + onMessage(message: ProtocolMessage): void { + let syncChannelSerial, isSync = false; switch(message.action) { case actions.ATTACHED: this._attachedMsgIndicator = true; this.properties.attachSerial = message.channelSerial; this._mode = message.getMode(); - this.params = message.params || {}; - var modesFromFlags = message.decodeModesFromFlags(); + this.params = (message as any).params || {}; + const modesFromFlags = message.decodeModesFromFlags(); this.modes = (modesFromFlags && Utils.allToLowerCase(modesFromFlags)) || undefined; - var resumed = message.hasFlag('RESUMED'); - var hasPresence = message.hasFlag('HAS_PRESENCE'); + const resumed = message.hasFlag('RESUMED'); + const hasPresence = message.hasFlag('HAS_PRESENCE'); if(this.state === 'attached') { /* attached operations to change options set the inprogress mutex, but leave * channel in the attached state */ @@ -436,7 +465,7 @@ var RealtimeChannel = (function() { /* On a loss of continuity, the presence set needs to be re-synced */ this.presence.onAttached(hasPresence); } - var change = new ChannelStateChange(this.state, this.state, resumed, message.error); + const change = new ChannelStateChange(this.state, this.state, resumed, message.error); this._allChannelChanges.emit('update', change); if(!resumed || this.channelOptions.updateOnAttached) { this.emit('update', change); @@ -450,16 +479,16 @@ var RealtimeChannel = (function() { break; case actions.DETACHED: - var err = message.error ? ErrorInfo.fromValues(message.error) : new ErrorInfo('Channel detached', 90001, 404); + const detachErr = message.error ? ErrorInfo.fromValues(message.error) : new ErrorInfo('Channel detached', 90001, 404); if(this.state === 'detaching') { - this.notifyState('detached', err); + this.notifyState('detached', detachErr); } else if(this.state === 'attaching') { /* Only retry immediately if we were previously attached. If we were * attaching, go into suspended, fail messages, and wait a few seconds * before retrying */ - this.notifyState('suspended', err); + this.notifyState('suspended', detachErr); } else { - this.requestState('attaching', err); + this.requestState('attaching', detachErr); } break; @@ -470,27 +499,26 @@ var RealtimeChannel = (function() { /* syncs can happen on channels with no presence data as part of connection * resuming, in which case protocol message has no presence property */ if(!message.presence) break; - case actions.PRESENCE: - var presence = message.presence, - id = message.id, - connectionId = message.connectionId, - timestamp = message.timestamp; + case actions.PRESENCE: { + const presence = message.presence as Array; + const { id, connectionId, timestamp } = message; - var options = this.channelOptions; - for(var i = 0; i < presence.length; i++) { + const options = this.channelOptions; + let presenceMsg: PresenceMessage; + for(let i = 0; i < presence.length; i++) { try { - var presenceMsg = presence[i]; + presenceMsg = presence[i]; PresenceMessage.decode(presenceMsg, options); + if(!presenceMsg.connectionId) presenceMsg.connectionId = connectionId; + if(!presenceMsg.timestamp) presenceMsg.timestamp = timestamp; + if(!presenceMsg.id) presenceMsg.id = id + ':' + i; } catch (e) { - Logger.logAction(Logger.LOG_ERROR, 'RealtimeChannel.onMessage()', e.toString()); + Logger.logAction(Logger.LOG_ERROR, 'RealtimeChannel.onMessage()', (e as Error).toString()); } - if(!presenceMsg.connectionId) presenceMsg.connectionId = connectionId; - if(!presenceMsg.timestamp) presenceMsg.timestamp = timestamp; - if(!presenceMsg.id) presenceMsg.id = id + ':' + i; } - this.presence.setPresence(presence, isSync, syncChannelSerial); + this.presence.setPresence(presence, isSync, syncChannelSerial as any); break; - + } case actions.MESSAGE: //RTL17 @@ -499,7 +527,7 @@ var RealtimeChannel = (function() { return; } - var messages = message.messages, + const messages = message.messages as Array, firstMessage = messages[0], lastMessage = messages[messages.length - 1], id = message.id, @@ -507,29 +535,29 @@ var RealtimeChannel = (function() { timestamp = message.timestamp; if(firstMessage.extras && firstMessage.extras.delta && firstMessage.extras.delta.from !== this._lastPayload.messageId) { - var msg = 'Delta message decode failure - previous message not available for message "' + message.id + '" on this channel "' + this.name + '".'; + const msg = 'Delta message decode failure - previous message not available for message "' + message.id + '" on this channel "' + this.name + '".'; Logger.logAction(Logger.LOG_ERROR, 'RealtimeChannel.onMessage()', msg); this._startDecodeFailureRecovery(new ErrorInfo(msg, 40018, 400)); break; } - for(var i = 0; i < messages.length; i++) { - var msg = messages[i]; + for(let i = 0; i < messages.length; i++) { + const msg = messages[i]; try { Message.decode(msg, this._decodingContext); } catch (e) { /* decrypt failed .. the most likely cause is that we have the wrong key */ - Logger.logAction(Logger.LOG_ERROR, 'RealtimeChannel.onMessage()', e.toString()); - switch(e.code) { + Logger.logAction(Logger.LOG_ERROR, 'RealtimeChannel.onMessage()', (e as Error).toString()); + switch((e as ErrorInfo).code) { case 40018: /* decode failure */ - this._startDecodeFailureRecovery(e); + this._startDecodeFailureRecovery(e as ErrorInfo); return; case 40019: /* No vcdiff plugin passed in - no point recovering, give up */ case 40021: /* Browser does not support deltas, similarly no point recovering */ - this.notifyState('failed', e); + this.notifyState('failed', e as ErrorInfo); return; } } @@ -544,7 +572,7 @@ var RealtimeChannel = (function() { case actions.ERROR: /* there was a channel-specific error */ - var err = message.error; + const err = message.error as ErrorInfo; if(err && err.code == 80016) { /* attach/detach operation attempted on superseded transport handle */ this.checkPendingState(); @@ -555,26 +583,25 @@ var RealtimeChannel = (function() { default: Logger.logAction(Logger.LOG_ERROR, 'RealtimeChannel.onMessage()', 'Fatal protocol error: unrecognised action (' + message.action + ')'); - this.connectionManager.abort(ConnectionError.unknownChannelErr); + this.connectionManager.abort(ConnectionErrors.unknownChannelErr); } - }; + } - RealtimeChannel.prototype._startDecodeFailureRecovery = function(reason) { - var self = this; + _startDecodeFailureRecovery(reason: ErrorInfo): void { if(!this._lastPayload.decodeFailureRecoveryInProgress) { Logger.logAction(Logger.LOG_MAJOR, 'RealtimeChannel.onMessage()', 'Starting decode failure recovery process.'); this._lastPayload.decodeFailureRecoveryInProgress = true; - this._attach(true, reason, function() { - self._lastPayload.decodeFailureRecoveryInProgress = false; + this._attach(true, reason, () => { + this._lastPayload.decodeFailureRecoveryInProgress = false; }); } - }; + } - RealtimeChannel.prototype.onAttached = function() { + onAttached(): void { Logger.logAction(Logger.LOG_MINOR, 'RealtimeChannel.onAttached', 'activating channel; name = ' + this.name); - }; + } - RealtimeChannel.prototype.notifyState = function(state, reason, resumed, hasPresence) { + notifyState(state: API.Types.ChannelState, reason?: ErrorInfo | null, resumed?: boolean, hasPresence?: boolean): void { Logger.logAction(Logger.LOG_MICRO, 'RealtimeChannel.notifyState', 'name = ' + this.name + ', current state = ' + this.state + ', notifying state ' + state); this.clearStateTimer(); @@ -590,8 +617,8 @@ var RealtimeChannel = (function() { if(reason) { this.errorReason = reason; } - var change = new ChannelStateChange(this.state, state, resumed, reason); - var logLevel = state === 'failed' ? Logger.LOG_ERROR : Logger.LOG_MAJOR; + const change = new ChannelStateChange(this.state, state, resumed, reason); + const logLevel = state === 'failed' ? Logger.LOG_ERROR : Logger.LOG_MAJOR; Logger.logAction(logLevel, 'Channel state for channel "' + this.name + '"', state + (reason ? ('; reason: ' + reason) : '')); /* Note: we don't set inProgress for pending states until the request is actually in progress */ @@ -613,18 +640,18 @@ var RealtimeChannel = (function() { this.state = state; this._allChannelChanges.emit(state, change); this.emit(state, change); - }; + } - RealtimeChannel.prototype.requestState = function(state, reason) { + requestState(state: API.Types.ChannelState, reason?: ErrorInfo | null): void { Logger.logAction(Logger.LOG_MINOR, 'RealtimeChannel.requestState', 'name = ' + this.name + ', state = ' + state); this.notifyState(state, reason); /* send the event and await response */ this.checkPendingState(); - }; + } - RealtimeChannel.prototype.checkPendingState = function() { + checkPendingState(): void { /* if can't send events, do nothing */ - var cmState = this.connectionManager.state; + const cmState = this.connectionManager.state; /* Allow attach messages to queue up when synchronizing, since this will be * the state we'll be in when upgrade transport.active triggers a checkpendingstate */ if(!(cmState.sendEvents || cmState.forceQueueEvents)) { @@ -646,73 +673,74 @@ var RealtimeChannel = (function() { case 'attached': /* resume any sync operation that was in progress */ this.sync(); + break; default: break; } - }; + } - RealtimeChannel.prototype.timeoutPendingState = function() { + timeoutPendingState(): void { switch(this.state) { - case 'attaching': - var err = new ErrorInfo('Channel attach timed out', 90007, 408); + case 'attaching': { + const err = new ErrorInfo('Channel attach timed out', 90007, 408); this.notifyState('suspended', err); break; - case 'detaching': - var err = new ErrorInfo('Channel detach timed out', 90007, 408); + } + case 'detaching': { + const err = new ErrorInfo('Channel detach timed out', 90007, 408); this.notifyState('attached', err); break; + } default: this.checkPendingState(); break; } - }; + } - RealtimeChannel.prototype.startStateTimerIfNotRunning = function() { - var self = this; + startStateTimerIfNotRunning(): void { if(!this.stateTimer) { - this.stateTimer = setTimeout(function() { + this.stateTimer = setTimeout(() => { Logger.logAction(Logger.LOG_MINOR, 'RealtimeChannel.startStateTimerIfNotRunning', 'timer expired'); - self.stateTimer = null; - self.timeoutPendingState(); + this.stateTimer = null; + this.timeoutPendingState(); }, this.realtime.options.timeouts.realtimeRequestTimeout); } - }; + } - RealtimeChannel.prototype.clearStateTimer = function() { - var stateTimer = this.stateTimer; + clearStateTimer(): void { + const stateTimer = this.stateTimer; if(stateTimer) { clearTimeout(stateTimer); this.stateTimer = null; } - }; + } - RealtimeChannel.prototype.startRetryTimer = function() { - var self = this; + startRetryTimer(): void { if(this.retryTimer) return; - this.retryTimer = setTimeout(function() { + this.retryTimer = setTimeout(() => { /* If connection is not connected, just leave in suspended, a reattach * will be triggered once it connects again */ - if(self.state === 'suspended' && self.connectionManager.state.sendEvents) { - self.retryTimer = null; + if(this.state === 'suspended' && this.connectionManager.state.sendEvents) { + this.retryTimer = null; Logger.logAction(Logger.LOG_MINOR, 'RealtimeChannel retry timer expired', 'attempting a new attach'); - self.requestState('attaching'); + this.requestState('attaching'); } }, this.realtime.options.timeouts.channelRetryTimeout); - }; + } - RealtimeChannel.prototype.cancelRetryTimer = function() { + cancelRetryTimer(): void { if(this.retryTimer) { - clearTimeout(this.retryTimer); - this.suspendTimer = null; + clearTimeout(this.retryTimer as NodeJS.Timeout); + this.retryTimer = null; } - }; + } - RealtimeChannel.prototype.setInProgress = function(operation, value) { + setInProgress(operation: string, value: unknown): void { this.rest.channels.setInProgress(this, operation, value); - }; + } - RealtimeChannel.prototype.history = function(params, callback) { + history = ((function (this: RealtimeChannel, params: RealtimeHistoryParams | null, callback: PaginatedResultCallback): void | Promise> { Logger.logAction(Logger.LOG_MICRO, 'RealtimeChannel.history()', 'channel = ' + this.name); /* params and callback are optional; see if params contains the callback */ if(callback === undefined) { @@ -741,22 +769,20 @@ var RealtimeChannel = (function() { } Channel.prototype._history.call(this, params, callback); - }; + }) as any) - RealtimeChannel.prototype.whenState = function(state, listener) { + whenState = (((state: string, listener: ErrCallback) => { return EventEmitter.prototype.whenState.call(this, state, this.state, listener); - } + }) as any) /* @returns null (if can safely be released) | ErrorInfo (if cannot) */ - RealtimeChannel.prototype.getReleaseErr = function() { - var s = this.state; + getReleaseErr(): ErrorInfo | null { + const s = this.state; if(s === 'initialized' || s === 'detached' || s === 'failed') { return null; } return new ErrorInfo('Can only release a channel in a state where there is no possibility of further updates from the server being received (initialized, detached, or failed); was ' + s, 90001, 400); } - - return RealtimeChannel; -})(); +} export default RealtimeChannel; diff --git a/common/lib/client/realtimepresence.js b/common/lib/client/realtimepresence.ts similarity index 58% rename from common/lib/client/realtimepresence.js rename to common/lib/client/realtimepresence.ts index 8f49558e8d..2dcd218841 100644 --- a/common/lib/client/realtimepresence.js +++ b/common/lib/client/realtimepresence.ts @@ -1,111 +1,154 @@ -import Utils from '../util/utils'; +import * as Utils from '../util/utils'; import Presence from './presence'; import EventEmitter from '../util/eventemitter'; import Logger from '../util/logger'; import PresenceMessage from '../types/presencemessage'; import ErrorInfo from '../types/errorinfo'; import RealtimeChannel from './realtimechannel'; -import ConnectionError from '../transport/connectionerror'; +import ConnectionErrors from '../transport/connectionerrors'; import Multicaster from '../util/multicaster'; import ChannelStateChange from './channelstatechange'; - -var RealtimePresence = (function() { - var noop = function() {}; - - function memberKey(item) { - return item.clientId + ':' + item.connectionId; +import { CipherOptions } from '../types/message'; +import { ErrCallback, PaginatedResultCallback, StandardCallback } from '../../types/utils'; +import { PaginatedResult } from './paginatedresource'; + +interface RealtimePresenceParams { + waitForSync?: boolean; + clientId?: string; + connectionId?: string; +} + +interface RealtimeHistoryParams { + start?: number; + end?: number; + direction?: string; + limit?: number; + untilAttach?: boolean; + from_serial?: number | null; +} + +const noop = function() {}; + +function memberKey(item: PresenceMessage) { + return item.clientId + ':' + item.connectionId; +} + +function getClientId(realtimePresence: RealtimePresence) { + return realtimePresence.channel.realtime.auth.clientId; +} + +function isAnonymousOrWildcard(realtimePresence: RealtimePresence) { + const realtime = realtimePresence.channel.realtime; + /* If not currently connected, we can't assume that we're an anonymous + * client, as realtime may inform us of our clientId in the CONNECTED + * message. So assume we're not anonymous and leave it to realtime to + * return an error if we are */ + const clientId = realtime.auth.clientId; + return (!clientId || (clientId === '*')) && realtime.connection.state === 'connected'; +} + +/* Callback is called only in the event of an error */ +function waitAttached(channel: RealtimeChannel, callback: ErrCallback, action: () => void) { + switch(channel.state) { + case 'attached': + case 'suspended': + action(); + break; + case 'initialized': + case 'detached': + case 'detaching': + case 'attaching': + channel.attach(function(err: Error) { + if(err) callback(err); + else action(); + }); + break; + default: + callback(ErrorInfo.fromValues(RealtimeChannel.invalidStateError(channel.state))); } +} - function getClientId(realtimePresence) { - return realtimePresence.channel.realtime.auth.clientId; +function newerThan(item: PresenceMessage, existing: PresenceMessage) { + /* RTP2b1: if either is synthesised, compare by timestamp */ + if(item.isSynthesized() || existing.isSynthesized()) { + return (item.timestamp as number) > (existing.timestamp as number); } - function isAnonymousOrWildcard(realtimePresence) { - var realtime = realtimePresence.channel.realtime; - /* If not currently connected, we can't assume that we're an anonymous - * client, as realtime may inform us of our clientId in the CONNECTED - * message. So assume we're not anonymous and leave it to realtime to - * return an error if we are */ - var clientId = realtime.auth.clientId; - return (!clientId || (clientId === '*')) && realtime.connection.state === 'connected'; + /* RTP2b2 */ + const itemOrderings = item.parseId(), + existingOrderings = existing.parseId(); + if(itemOrderings.msgSerial === existingOrderings.msgSerial) { + return itemOrderings.index > existingOrderings.index; + } else { + return itemOrderings.msgSerial > existingOrderings.msgSerial; } +} - /* Callback is called only in the event of an error */ - function waitAttached(channel, callback, action) { - switch(channel.state) { - case 'attached': - case 'suspended': - action(); - break; - case 'initialized': - case 'detached': - case 'detaching': - case 'attaching': - channel.attach(function(err) { - if(err) callback(err); - else action(); - }); - break; - default: - callback(ErrorInfo.fromValues(RealtimeChannel.invalidStateError(channel.state))); - } - } - function RealtimePresence(channel, options) { - Presence.call(this, channel); +class RealtimePresence extends Presence { + channel: RealtimeChannel; + pendingPresence: { presence: PresenceMessage, callback: ErrCallback }[]; + syncComplete: boolean; + members: PresenceMap; + _myMembers: PresenceMap; + subscriptions: EventEmitter; + name?: string; + + constructor(channel: RealtimeChannel) { + super(channel); + this.channel = channel; this.syncComplete = false; this.members = new PresenceMap(this); this._myMembers = new PresenceMap(this); this.subscriptions = new EventEmitter(); this.pendingPresence = []; } - Utils.inherits(RealtimePresence, Presence); - RealtimePresence.prototype.enter = function(data, callback) { + enter(data: unknown, callback: ErrCallback): void | Promise { if(isAnonymousOrWildcard(this)) { throw new ErrorInfo('clientId must be specified to enter a presence channel', 40012, 400); } return this._enterOrUpdateClient(undefined, data, 'enter', callback); - }; + } - RealtimePresence.prototype.update = function(data, callback) { + update(data: unknown, callback: ErrCallback): void | Promise { if(isAnonymousOrWildcard(this)) { throw new ErrorInfo('clientId must be specified to update presence data', 40012, 400); } return this._enterOrUpdateClient(undefined, data, 'update', callback); - }; + } - RealtimePresence.prototype.enterClient = function(clientId, data, callback) { + enterClient(clientId: string, data: unknown, callback: ErrCallback): void | Promise { return this._enterOrUpdateClient(clientId, data, 'enter', callback); - }; + } - RealtimePresence.prototype.updateClient = function(clientId, data, callback) { + updateClient(clientId: string, data: unknown, callback: ErrCallback): void | Promise { return this._enterOrUpdateClient(clientId, data, 'update', callback); - }; + } - RealtimePresence.prototype._enterOrUpdateClient = function(clientId, data, action, callback) { + _enterOrUpdateClient(clientId: string | undefined, data: unknown, action: string, callback: ErrCallback): void | Promise { if (!callback) { if (typeof(data)==='function') { - callback = data; + callback = data as ErrCallback; data = null; } else { if(this.channel.realtime.options.promises) { - return Utils.promisify(this, '_enterOrUpdateClient', [clientId, data, action]); + return Utils.promisify(this, '_enterOrUpdateClient', arguments); } callback = noop; } } - var channel = this.channel; + const channel = this.channel; if(!channel.connectionManager.activeState()) { callback(channel.connectionManager.getError()); return; } Logger.logAction(Logger.LOG_MICRO, 'RealtimePresence.' + action + 'Client()', - 'channel = ' + channel.name + ', client = ' + (clientId || '(implicit) ' + getClientId(this))); + 'channel = ' + channel.name + ', client = ' + (clientId || '(implicit) ' + getClientId(this))); - var presence = PresenceMessage.fromValues({ + const presence = PresenceMessage.fromValues({ action : action, data : data }); @@ -113,8 +156,7 @@ var RealtimePresence = (function() { presence.clientId = clientId; } - var self = this; - PresenceMessage.encode(presence, channel.channelOptions, function(err) { + PresenceMessage.encode(presence, channel.channelOptions as CipherOptions, (err: ErrorInfo) => { if (err) { callback(err); return; @@ -127,7 +169,7 @@ var RealtimePresence = (function() { case 'detached': channel.attach(); case 'attaching': - self.pendingPresence.push({ + this.pendingPresence.push({ presence : presence, callback : callback }); @@ -138,36 +180,36 @@ var RealtimePresence = (function() { callback(err); } }); - }; + } - RealtimePresence.prototype.leave = function(data, callback) { + leave(data: unknown, callback: ErrCallback): void | Promise { if(isAnonymousOrWildcard(this)) { throw new ErrorInfo('clientId must have been specified to enter or leave a presence channel', 40012, 400); } return this.leaveClient(undefined, data, callback); - }; + } - RealtimePresence.prototype.leaveClient = function(clientId, data, callback) { + leaveClient(clientId?: string, data?: unknown, callback?: ErrCallback): void | Promise { if (!callback) { if (typeof(data)==='function') { - callback = data; + callback = data as ErrCallback; data = null; } else { if(this.channel.realtime.options.promises) { - return Utils.promisify(this, 'leaveClient', [clientId, data]); + return Utils.promisify(this, 'leaveClient', arguments); } callback = noop; } } - var channel = this.channel; + const channel = this.channel; if(!channel.connectionManager.activeState()) { - callback(channel.connectionManager.getError()); + callback?.(channel.connectionManager.getError()); return; } Logger.logAction(Logger.LOG_MICRO, 'RealtimePresence.leaveClient()', 'leaving; channel = ' + this.channel.name + ', client = ' + clientId); - var presence = PresenceMessage.fromValues({ + const presence = PresenceMessage.fromValues({ action : 'leave', data : data }); @@ -187,24 +229,25 @@ var RealtimePresence = (function() { case 'failed': /* we're not attached; therefore we let any entered status * timeout by itself instead of attaching just in order to leave */ - var err = new ErrorInfo('Unable to leave presence channel (incompatible state)', 90001); - callback(err); + const err = new ErrorInfo('Unable to leave presence channel (incompatible state)', 90001); + callback?.(err); break; default: /* there is no connection; therefore we let * any entered status timeout by itself */ - callback(ConnectionError.failed); + callback?.(ConnectionErrors.failed); } - }; + } - RealtimePresence.prototype.get = function(/* params, callback */) { - var args = Array.prototype.slice.call(arguments); + // Return type is any to avoid conflict with base Presence class + get(this: RealtimePresence, params: RealtimePresenceParams, callback: StandardCallback): any { + const args = Array.prototype.slice.call(arguments); if(args.length == 1 && typeof(args[0]) == 'function') args.unshift(null); - var params = args[0], - callback = args[1], - waitForSync = !params || ('waitForSync' in params ? params.waitForSync : true); + params = args[0] as RealtimePresenceParams; + callback = args[1] as StandardCallback; + const waitForSync = !params || ('waitForSync' in params ? params.waitForSync : true); if(!callback) { if(this.channel.realtime.options.promises) { @@ -213,7 +256,7 @@ var RealtimePresence = (function() { callback = noop; } - function returnMembers(members) { + function returnMembers(members: PresenceMap) { callback(null, params ? members.list(params) : members.values()); } @@ -231,9 +274,8 @@ var RealtimePresence = (function() { return; } - var self = this; - waitAttached(this.channel, callback, function() { - var members = self.members; + waitAttached(this.channel, callback, () => { + const members = this.members; if(waitForSync) { members.waitSync(function() { returnMembers(members); @@ -242,9 +284,9 @@ var RealtimePresence = (function() { returnMembers(members); } }); - }; + } - RealtimePresence.prototype.history = function(params, callback) { + history(params: RealtimeHistoryParams | null, callback: PaginatedResultCallback): void | Promise> { Logger.logAction(Logger.LOG_MICRO, 'RealtimePresence.history()', 'channel = ' + this.name); /* params and callback are optional; see if params contains the callback */ if(callback === undefined) { @@ -253,7 +295,7 @@ var RealtimePresence = (function() { params = null; } else { if(this.channel.realtime.options.promises) { - return Utils.promisify(this, 'history', arguments); + return Utils.promisify(this, 'history', [params, callback]); } callback = noop; } @@ -269,22 +311,23 @@ var RealtimePresence = (function() { } Presence.prototype._history.call(this, params, callback); - }; + } - RealtimePresence.prototype.setPresence = function(presenceSet, isSync, syncChannelSerial) { + setPresence(presenceSet: PresenceMessage[], isSync: boolean, syncChannelSerial?: string): void { Logger.logAction(Logger.LOG_MICRO, 'RealtimePresence.setPresence()', 'received presence for ' + presenceSet.length + ' participants; syncChannelSerial = ' + syncChannelSerial); - var syncCursor, match, members = this.members, myMembers = this._myMembers, + let syncCursor, match; + const members = this.members, myMembers = this._myMembers, broadcastMessages = [], connId = this.channel.connectionManager.connectionId; if(isSync) { this.members.startSync(); - if(syncChannelSerial && (match = syncChannelSerial.match(/^[\w\-]+:(.*)$/))) { + if(syncChannelSerial && (match = syncChannelSerial.match(/^[\w-]+:(.*)$/))) { syncCursor = match[1]; } } - for(var i = 0; i < presenceSet.length; i++) { - var presence = PresenceMessage.fromValues(presenceSet[i]); + for(let i = 0; i < presenceSet.length; i++) { + const presence = PresenceMessage.fromValues(presenceSet[i]); switch(presence.action) { case 'leave': if(members.remove(presence)) { @@ -316,13 +359,13 @@ var RealtimePresence = (function() { } /* broadcast to listeners */ - for(var i = 0; i < broadcastMessages.length; i++) { - var presence = broadcastMessages[i]; - this.subscriptions.emit(presence.action, presence); + for(let i = 0; i < broadcastMessages.length; i++) { + const presence = broadcastMessages[i]; + this.subscriptions.emit(presence.action as string, presence); } - }; + } - RealtimePresence.prototype.onAttached = function(hasPresence) { + onAttached(hasPresence?: boolean): void { Logger.logAction(Logger.LOG_MINOR, 'RealtimePresence.onAttached()', 'channel = ' + this.channel.name + ', hasPresence = ' + hasPresence); if(hasPresence) { @@ -334,24 +377,24 @@ var RealtimePresence = (function() { } /* NB this must be after the _ensureMyMembersPresent call, which may add items to pendingPresence */ - var pendingPresence = this.pendingPresence, + const pendingPresence = this.pendingPresence, pendingPresCount = pendingPresence.length; if(pendingPresCount) { this.pendingPresence = []; - var presenceArray = []; - var multicaster = Multicaster(); + const presenceArray = []; + const multicaster = Multicaster.create(); Logger.logAction(Logger.LOG_MICRO, 'RealtimePresence.onAttached', 'sending ' + pendingPresCount + ' queued presence messages'); - for(var i = 0; i < pendingPresCount; i++) { - var event = pendingPresence[i]; + for(let i = 0; i < pendingPresCount; i++) { + const event = pendingPresence[i]; presenceArray.push(event.presence); multicaster.push(event.callback); } this.channel.sendPresence(presenceArray, multicaster); } - }; + } - RealtimePresence.prototype.actOnChannelState = function(state, hasPresence, err) { + actOnChannelState(state: string, hasPresence?: boolean, err?: ErrorInfo | null): void { switch(state) { case 'attached': this.onAttached(hasPresence); @@ -365,49 +408,49 @@ var RealtimePresence = (function() { this.failPendingPresence(err); break; } - }; + } - RealtimePresence.prototype.failPendingPresence = function(err) { + failPendingPresence(err?: ErrorInfo | null): void { if(this.pendingPresence.length) { Logger.logAction(Logger.LOG_MINOR, 'RealtimeChannel.failPendingPresence', 'channel; name = ' + this.channel.name + ', err = ' + Utils.inspectError(err)); - for(var i = 0; i < this.pendingPresence.length; i++) + for(let i = 0; i < this.pendingPresence.length; i++) try { this.pendingPresence[i].callback(err); } catch(e) {} this.pendingPresence = []; } - }; + } - RealtimePresence.prototype._clearMyMembers = function() { + _clearMyMembers(): void { this._myMembers.clear(); - }; + } - RealtimePresence.prototype._ensureMyMembersPresent = function() { - var self = this, members = this.members, myMembers = this._myMembers, - reenterCb = function(err) { + _ensureMyMembersPresent(): void { + const members = this.members, myMembers = this._myMembers, + reenterCb = (err?: ErrorInfo | null) => { if(err) { - var msg = 'Presence auto-re-enter failed: ' + err.toString(); - var wrappedErr = new ErrorInfo(msg, 91004, 400); + const msg = 'Presence auto-re-enter failed: ' + err.toString(); + const wrappedErr = new ErrorInfo(msg, 91004, 400); Logger.logAction(Logger.LOG_ERROR, 'RealtimePresence._ensureMyMembersPresent()', msg); - var change = new ChannelStateChange(self.channel.state, self.channel.state, true, wrappedErr); - self.channel.emit('update', change); + const change = new ChannelStateChange(this.channel.state, this.channel.state, true, wrappedErr); + this.channel.emit('update', change); } }; - - for(var memberKey in myMembers.map) { + + for(const memberKey in myMembers.map) { if(!(memberKey in members.map)) { - var entry = myMembers.map[memberKey]; + const entry = myMembers.map[memberKey]; Logger.logAction(Logger.LOG_MICRO, 'RealtimePresence._ensureMyMembersPresent()', 'Auto-reentering clientId "' + entry.clientId + '" into the presence set'); this._enterOrUpdateClient(entry.clientId, entry.data, 'enter', reenterCb); delete myMembers.map[memberKey]; } } - }; + } - RealtimePresence.prototype._synthesizeLeaves = function(items) { - var subscriptions = this.subscriptions; + _synthesizeLeaves(items: PresenceMessage[]): void { + const subscriptions = this.subscriptions; Utils.arrForEach(items, function(item) { - var presence = PresenceMessage.fromValues({ + const presence = PresenceMessage.fromValues({ action: 'leave', connectionId: item.connectionId, clientId: item.clientId, @@ -417,27 +460,26 @@ var RealtimePresence = (function() { }); subscriptions.emit('leave', presence); }); - }; + } /* Deprecated */ - RealtimePresence.prototype.on = function() { + on(...args: unknown[]): void { Logger.deprecated('presence.on', 'presence.subscribe'); - this.subscribe.apply(this, arguments); - }; + this.subscribe(...args) + } /* Deprecated */ - RealtimePresence.prototype.off = function() { + off(...args: unknown[]): void { Logger.deprecated('presence.off', 'presence.unsubscribe'); - this.unsubscribe.apply(this, arguments); - }; + this.unsubscribe(...args); + } - RealtimePresence.prototype.subscribe = function(/* [event], listener, [callback] */) { - var args = RealtimeChannel.processListenerArgs(arguments); - var event = args[0]; - var listener = args[1]; - var callback = args[2]; - var channel = this.channel; - var self = this; + subscribe(..._args: unknown[]/* [event], listener, [callback] */): void | Promise { + const args = RealtimeChannel.processListenerArgs(_args); + const event = args[0]; + const listener = args[1]; + let callback = args[2]; + const channel = this.channel; if(!callback) { if(this.channel.realtime.options.promises) { @@ -453,103 +495,93 @@ var RealtimePresence = (function() { this.subscriptions.on(event, listener); channel.attach(callback); - }; + } - RealtimePresence.prototype.unsubscribe = function(/* [event], listener */) { - var args = RealtimeChannel.processListenerArgs(arguments); - var event = args[0]; - var listener = args[1]; + unsubscribe(..._args: unknown[]/* [event], listener */): void { + const args = RealtimeChannel.processListenerArgs(_args); + const event = args[0]; + const listener = args[1]; this.subscriptions.off(event, listener); - }; + } +} + +class PresenceMap extends EventEmitter { + map: Record; + residualMembers: Record | null; + syncInProgress: boolean; + presence: RealtimePresence; - function PresenceMap(presence) { - EventEmitter.call(this); + constructor(presence: RealtimePresence) { + super(); this.presence = presence; - this.map = Object.create(null); + this.map = {}; this.syncInProgress = false; this.residualMembers = null; } - Utils.inherits(PresenceMap, EventEmitter); - PresenceMap.prototype.get = function(key) { + get(key: string) { return this.map[key]; - }; + } - PresenceMap.prototype.getClient = function(clientId) { - var map = this.map, result = []; - for(var key in map) { - var item = map[key]; + getClient(clientId: string) { + const map = this.map, result = []; + for(const key in map) { + const item = map[key]; if(item.clientId == clientId && item.action != 'absent') result.push(item); } return result; - }; + } - PresenceMap.prototype.list = function(params) { - var map = this.map, + list(params: RealtimePresenceParams) { + const map = this.map, clientId = params && params.clientId, connectionId = params && params.connectionId, result = []; - for(var key in map) { - var item = map[key]; + for(const key in map) { + const item = map[key]; if(item.action === 'absent') continue; if(clientId && clientId != item.clientId) continue; if(connectionId && connectionId != item.connectionId) continue; result.push(item); } return result; - }; - - function newerThan(item, existing) { - /* RTP2b1: if either is synthesised, compare by timestamp */ - if(item.isSynthesized() || existing.isSynthesized()) { - return item.timestamp > existing.timestamp; - } - - /* RTP2b2 */ - var itemOrderings = item.parseId(), - existingOrderings = existing.parseId(); - if(itemOrderings.msgSerial === existingOrderings.msgSerial) { - return itemOrderings.index > existingOrderings.index; - } else { - return itemOrderings.msgSerial > existingOrderings.msgSerial; - } } - PresenceMap.prototype.put = function(item) { + put(item: PresenceMessage) { if(item.action === 'enter' || item.action === 'update') { item = PresenceMessage.fromValues(item); item.action = 'present'; } - var map = this.map, key = memberKey(item); + const map = this.map, key = memberKey(item); /* we've seen this member, so do not remove it at the end of sync */ if(this.residualMembers) delete this.residualMembers[key]; /* compare the timestamp of the new item with any existing member (or ABSENT witness) */ - var existingItem = map[key]; + const existingItem = map[key]; if(existingItem && !newerThan(item, existingItem)) { return false; } map[key] = item; return true; - }; + } - PresenceMap.prototype.values = function() { - var map = this.map, result = []; - for(var key in map) { - var item = map[key]; + values() { + const map = this.map, result = []; + for(const key in map) { + const item = map[key]; if(item.action != 'absent') result.push(item); } return result; - }; + } - PresenceMap.prototype.remove = function(item) { - var map = this.map, key = memberKey(item); - var existingItem = map[key]; + remove(item: PresenceMessage) { + const map = this.map, key = memberKey(item); + const existingItem = map[key]; if(existingItem && !newerThan(item, existingItem)) { return false; @@ -565,34 +597,34 @@ var RealtimePresence = (function() { } return true; - }; + } - PresenceMap.prototype.startSync = function() { - var map = this.map, syncInProgress = this.syncInProgress; + startSync() { + const map = this.map, syncInProgress = this.syncInProgress; Logger.logAction(Logger.LOG_MINOR, 'PresenceMap.startSync()', 'channel = ' + this.presence.channel.name + '; syncInProgress = ' + syncInProgress); /* we might be called multiple times while a sync is in progress */ if(!this.syncInProgress) { this.residualMembers = Utils.copy(map); this.setInProgress(true); } - }; + } - PresenceMap.prototype.endSync = function() { - var map = this.map, syncInProgress = this.syncInProgress; + endSync() { + const map = this.map, syncInProgress = this.syncInProgress; Logger.logAction(Logger.LOG_MINOR, 'PresenceMap.endSync()', 'channel = ' + this.presence.channel.name + '; syncInProgress = ' + syncInProgress); if(syncInProgress) { /* we can now strip out the ABSENT members, as we have * received all of the out-of-order sync messages */ - for(var memberKey in map) { - var entry = map[memberKey]; + for(const memberKey in map) { + const entry = map[memberKey]; if(entry.action === 'absent') { delete map[memberKey]; } } /* any members that were present at the start of the sync, * and have not been seen in sync, can be removed, and leave events emitted */ - this.presence._synthesizeLeaves(Utils.valuesArray(this.residualMembers)); - for(var memberKey in this.residualMembers) { + this.presence._synthesizeLeaves(Utils.valuesArray(this.residualMembers as Record)); + for(const memberKey in this.residualMembers) { delete map[memberKey]; } this.residualMembers = null; @@ -601,31 +633,29 @@ var RealtimePresence = (function() { this.setInProgress(false); } this.emit('sync'); - }; + } - PresenceMap.prototype.waitSync = function(callback) { - var syncInProgress = this.syncInProgress; + waitSync(callback: () => void) { + const syncInProgress = this.syncInProgress; Logger.logAction(Logger.LOG_MINOR, 'PresenceMap.waitSync()', 'channel = ' + this.presence.channel.name + '; syncInProgress = ' + syncInProgress); if(!syncInProgress) { callback(); return; } this.once('sync', callback); - }; + } - PresenceMap.prototype.clear = function(callback) { + clear() { this.map = {}; this.setInProgress(false); this.residualMembers = null; - }; + } - PresenceMap.prototype.setInProgress = function(inProgress) { + setInProgress(inProgress: boolean) { Logger.logAction(Logger.LOG_MICRO, 'PresenceMap.setInProgress()', 'inProgress = ' + inProgress); this.syncInProgress = inProgress; this.presence.syncComplete = !inProgress; - }; - - return RealtimePresence; -})(); + } +} export default RealtimePresence; diff --git a/common/lib/client/resource.js b/common/lib/client/resource.js deleted file mode 100644 index d6dd9290fd..0000000000 --- a/common/lib/client/resource.js +++ /dev/null @@ -1,164 +0,0 @@ -import Platform from 'platform'; -import Http from 'platform-http'; -import Utils from '../util/utils'; -import Logger from '../util/logger'; -import Auth from './auth'; -import BufferUtils from 'platform-bufferutils'; - -var Resource = (function() { - var msgpack = Platform.msgpack; - - function Resource() {} - - function withAuthDetails(rest, headers, params, errCallback, opCallback) { - if (Http.supportsAuthHeaders) { - rest.auth.getAuthHeaders(function(err, authHeaders) { - if(err) - errCallback(err); - else - opCallback(Utils.mixin(authHeaders, headers), params); - }); - } else { - rest.auth.getAuthParams(function(err, authParams) { - if(err) - errCallback(err); - else - opCallback(headers, Utils.mixin(authParams, params)); - }); - } - } - - function unenvelope(callback, format) { - return function(err, body, outerHeaders, unpacked, outerStatusCode) { - if(err && !body) { - callback(err); - return; - } - - if(!unpacked) { - try { - body = Utils.decodeBody(body, format); - } catch(e) { - callback(e); - return; - } - } - - if(body.statusCode === undefined) { - /* Envelope already unwrapped by the transport */ - callback(err, body, outerHeaders, true, outerStatusCode); - return; - } - - var wrappedStatusCode = body.statusCode, - response = body.response, - wrappedHeaders = body.headers; - - if(wrappedStatusCode < 200 || wrappedStatusCode >= 300) { - /* handle wrapped errors */ - var wrappedErr = (response && response.error) || err; - if(!wrappedErr) { - wrappedErr = new Error("Error in unenveloping " + body); - wrappedErr.statusCode = wrappedStatusCode; - } - callback(wrappedErr, response, wrappedHeaders, true, wrappedStatusCode); - return; - } - - callback(err, response, wrappedHeaders, true, wrappedStatusCode); - }; - } - - function paramString(params) { - var paramPairs = []; - if (params) { - for (var needle in params) { - paramPairs.push(needle + '=' + params[needle]); - } - } - return paramPairs.join('&'); - } - - function urlFromPathAndParams(path, params) { - return path + (params ? '?' : '') + paramString(params); - } - - function logResponseHandler(callback, method, path, params) { - return function(err, body, headers, unpacked, statusCode) { - if (err) { - Logger.logAction(Logger.LOG_MICRO, 'Resource.' + method + '()', 'Received Error; ' + urlFromPathAndParams(path, params) + '; Error: ' + Utils.inspectError(err)); - } else { - Logger.logAction(Logger.LOG_MICRO, 'Resource.' + method + '()', - 'Received; ' + urlFromPathAndParams(path, params) + '; Headers: ' + paramString(headers) + '; StatusCode: ' + statusCode + '; Body: ' + (BufferUtils.isBuffer(body) ? body.toString() : body)); - } - if (callback) { callback(err, body, headers, unpacked, statusCode); } - } - } - - Utils.arrForEach(Http.methodsWithoutBody, function(method) { - Resource[method] = function(rest, path, origheaders, origparams, envelope, callback) { - Resource['do'](method, rest, path, null, origheaders, origparams, envelope, callback); - }; - }); - - Utils.arrForEach(Http.methodsWithBody, function(method) { - Resource[method] = function(rest, path, body, origheaders, origparams, envelope, callback) { - Resource['do'](method, rest, path, body, origheaders, origparams, envelope, callback); - }; - }); - - Resource['do'] = function(method, rest, path, body, origheaders, origparams, envelope, callback) { - if (Logger.shouldLog(Logger.LOG_MICRO)) { - callback = logResponseHandler(callback, method, path, origparams); - } - - if(envelope) { - callback = (callback && unenvelope(callback, envelope)); - (origparams = (origparams || {}))['envelope'] = envelope; - } - - function doRequest(headers, params) { - if (Logger.shouldLog(Logger.LOG_MICRO)) { - Logger.logAction(Logger.LOG_MICRO, 'Resource.' + method + '()', 'Sending; ' + urlFromPathAndParams(path, params)); - } - - var args = [rest, path, headers, body, params, function(err, res, headers, unpacked, statusCode) { - if(err && Auth.isTokenErr(err)) { - /* token has expired, so get a new one */ - rest.auth.authorize(null, null, function(err) { - if(err) { - callback(err); - return; - } - /* retry ... */ - withAuthDetails(rest, origheaders, origparams, callback, doRequest); - }); - return; - } - callback(err, res, headers, unpacked, statusCode); - }]; - if (!body) { - args.splice(3, 1); - } - - if (Logger.shouldLog(Logger.LOG_MICRO)) { - var decodedBody = body; - if ((headers['content-type'] || '').indexOf('msgpack') > 0) { - try { - decodedBody = msgpack.decode(body); - } catch (decodeErr) { - Logger.logAction(Logger.LOG_MICRO, 'Resource.' + method + '()', 'Sending MsgPack Decoding Error: ' + Utils.inspectError(decodeErr)); - } - } - Logger.logAction(Logger.LOG_MICRO, 'Resource.' + method + '()', 'Sending; ' + urlFromPathAndParams(path, params) + '; Body: ' + decodedBody); - } - Http[method].apply(this, args); - } - - withAuthDetails(rest, origheaders, origparams, callback, doRequest); - }; - - return Resource; -})(); - -export default Resource; diff --git a/common/lib/client/resource.ts b/common/lib/client/resource.ts new file mode 100644 index 0000000000..a8c221c029 --- /dev/null +++ b/common/lib/client/resource.ts @@ -0,0 +1,172 @@ +import Platform from 'platform'; +import Http from 'platform-http'; +import * as Utils from '../util/utils'; +import Logger from '../util/logger'; +import Auth from './auth'; +import * as BufferUtils from 'platform-bufferutils'; +import HttpMethods from '../../constants/HttpMethods'; +import ErrorInfo from '../types/errorinfo'; +import Rest from './rest'; + +const msgpack = Platform.msgpack; + +function withAuthDetails(rest: Rest, headers: Record, params: Record, errCallback: Function, opCallback: Function) { + if (Http.supportsAuthHeaders) { + rest.auth.getAuthHeaders(function(err: Error, authHeaders: Record) { + if(err) + errCallback(err); + else + opCallback(Utils.mixin(authHeaders, headers), params); + }); + } else { + rest.auth.getAuthParams(function(err: Error, authParams: Record) { + if(err) + errCallback(err); + else + opCallback(headers, Utils.mixin(authParams, params)); + }); + } +} + +function unenvelope(callback: Function, format: Utils.Format | null) { + return function(err: Error, body: Record, outerHeaders: Record, unpacked: boolean, outerStatusCode: number) { + if(err && !body) { + callback(err); + return; + } + + if(!unpacked) { + try { + body = Utils.decodeBody(body, format); + } catch(e) { + callback(e); + return; + } + } + + if(body.statusCode === undefined) { + /* Envelope already unwrapped by the transport */ + callback(err, body, outerHeaders, true, outerStatusCode); + return; + } + + const wrappedStatusCode = body.statusCode, + response = body.response, + wrappedHeaders = body.headers; + + if(wrappedStatusCode < 200 || wrappedStatusCode >= 300) { + /* handle wrapped errors */ + let wrappedErr = (response && response.error) || err; + if(!wrappedErr) { + wrappedErr = new Error("Error in unenveloping " + body); + wrappedErr.statusCode = wrappedStatusCode; + } + callback(wrappedErr, response, wrappedHeaders, true, wrappedStatusCode); + return; + } + + callback(err, response, wrappedHeaders, true, wrappedStatusCode); + }; +} + +function paramString(params: Record) { + const paramPairs = []; + if (params) { + for (const needle in params) { + paramPairs.push(needle + '=' + params[needle]); + } + } + return paramPairs.join('&'); +} + +function urlFromPathAndParams(path: string, params: Record) { + return path + (params ? '?' : '') + paramString(params); +} + +function logResponseHandler(callback: Function, method: HttpMethods, path: string, params: Record) { + return function(err: Error, body: unknown, headers: Record, unpacked: boolean, statusCode: number) { + if (err) { + Logger.logAction(Logger.LOG_MICRO, 'Resource.' + method + '()', 'Received Error; ' + urlFromPathAndParams(path, params) + '; Error: ' + Utils.inspectError(err)); + } else { + Logger.logAction(Logger.LOG_MICRO, 'Resource.' + method + '()', + 'Received; ' + urlFromPathAndParams(path, params) + '; Headers: ' + paramString(headers) + '; StatusCode: ' + statusCode + '; Body: ' + (BufferUtils.isBuffer(body) ? body.toString() : body)); + } + if (callback) { callback(err, body, headers, unpacked, statusCode); } + } +} + +class Resource { + static get(rest: Rest, path: string, origheaders: Record, origparams: Record, envelope: Utils.Format | null, callback: Function): void { + Resource.do(HttpMethods.Get, rest, path, null, origheaders, origparams, envelope, callback); + } + + static delete(rest: Rest, path: string, origheaders: Record, origparams: Record, envelope: Utils.Format | null, callback: Function): void { + Resource.do(HttpMethods.Delete, rest, path, null, origheaders, origparams, envelope, callback); + } + + static post(rest: Rest, path: string, body: unknown, origheaders: Record, origparams: Record, envelope: Utils.Format | null, callback: Function): void { + Resource.do(HttpMethods.Post, rest, path, body, origheaders, origparams, envelope, callback); + } + + static patch(rest: Rest, path: string, body: unknown, origheaders: Record, origparams: Record, envelope: Utils.Format | null, callback: Function): void { + Resource.do(HttpMethods.Patch, rest, path, body, origheaders, origparams, envelope, callback); + } + + static put(rest: Rest, path: string, body: unknown, origheaders: Record, origparams: Record, envelope: Utils.Format | null, callback: Function): void { + Resource.do(HttpMethods.Put, rest, path, body, origheaders, origparams, envelope, callback); + } + + static do(method: HttpMethods, rest: Rest, path: string, body: unknown, origheaders: Record, origparams: Record, envelope: Utils.Format | null, callback: Function): void { + if (Logger.shouldLog(Logger.LOG_MICRO)) { + callback = logResponseHandler(callback, method, path, origparams); + } + + if(envelope) { + callback = (callback && unenvelope(callback, envelope)); + (origparams = (origparams || {}))['envelope'] = envelope; + } + + function doRequest(this: any, headers: Record, params: Record) { + if (Logger.shouldLog(Logger.LOG_MICRO)) { + Logger.logAction(Logger.LOG_MICRO, 'Resource.' + method + '()', 'Sending; ' + urlFromPathAndParams(path, params)); + } + + const args = [rest, path, headers, body, params, function(err: ErrorInfo, res: any, headers: Record, unpacked: boolean, statusCode: number) { + if(err && Auth.isTokenErr(err)) { + /* token has expired, so get a new one */ + rest.auth.authorize(null, null, function(err: Error) { + if(err) { + callback(err); + return; + } + /* retry ... */ + withAuthDetails(rest, origheaders, origparams, callback, doRequest); + }); + return; + } + callback(err, res, headers, unpacked, statusCode); + }]; + if (!body) { + // Removes the third argument (body) from the args array + args.splice(3, 1); + } + + if (Logger.shouldLog(Logger.LOG_MICRO)) { + let decodedBody = body; + if ((headers['content-type'] || '').indexOf('msgpack') > 0) { + try { + decodedBody = msgpack.decode(body as Buffer); + } catch (decodeErr) { + Logger.logAction(Logger.LOG_MICRO, 'Resource.' + method + '()', 'Sending MsgPack Decoding Error: ' + Utils.inspectError(decodeErr)); + } + } + Logger.logAction(Logger.LOG_MICRO, 'Resource.' + method + '()', 'Sending; ' + urlFromPathAndParams(path, params) + '; Body: ' + decodedBody); + } + (Http[method] as Function).apply(this, args); + } + + withAuthDetails(rest, origheaders, origparams, callback, doRequest); + } +} + +export default Resource; diff --git a/common/lib/client/rest.js b/common/lib/client/rest.js deleted file mode 100644 index d32ae35745..0000000000 --- a/common/lib/client/rest.js +++ /dev/null @@ -1,210 +0,0 @@ -import Platform from 'platform'; -import Utils from '../util/utils'; -import Logger from '../util/logger'; -import Defaults from '../util/defaults'; -import Auth from './auth'; -import Push from './push'; -import Http from 'platform-http'; -import PaginatedResource from './paginatedresource'; -import Channel from './channel'; -import ErrorInfo from '../types/errorinfo'; -import Stats from '../types/stats'; - -var Rest = (function() { - var noop = function() {}; - var msgpack = Platform.msgpack; - - function Rest(options) { - if(!(this instanceof Rest)){ - return new Rest(options); - } - - /* normalise options */ - if(!options) { - var msg = 'no options provided'; - Logger.logAction(Logger.LOG_ERROR, 'Rest()', msg); - throw new Error(msg); - } - options = Defaults.objectifyOptions(options); - - if(options.log) { - Logger.setLog(options.log.level, options.log.handler); - } - Logger.logAction(Logger.LOG_MICRO, 'Rest()', 'initialized with clientOptions ' + Utils.inspect(options)); - - this.options = Defaults.normaliseOptions(options); - - /* process options */ - if(options.key) { - var keyMatch = options.key.match(/^([^:\s]+):([^:.\s]+)$/); - if(!keyMatch) { - var msg = 'invalid key parameter'; - Logger.logAction(Logger.LOG_ERROR, 'Rest()', msg); - throw new Error(msg); - } - options.keyName = keyMatch[1]; - options.keySecret = keyMatch[2]; - } - - if('clientId' in options) { - if(!(typeof(options.clientId) === 'string' || options.clientId === null)) - throw new ErrorInfo('clientId must be either a string or null', 40012, 400); - else if(options.clientId === '*') - throw new ErrorInfo('Can’t use "*" as a clientId as that string is reserved. (To change the default token request behaviour to use a wildcard clientId, use {defaultTokenParams: {clientId: "*"}})', 40012, 400); - } - - Logger.logAction(Logger.LOG_MINOR, 'Rest()', 'started; version = ' + Defaults.libstring); - - this.baseUri = this.authority = function(host) { return Defaults.getHttpScheme(options) + host + ':' + Defaults.getPort(options, false); }; - this._currentFallback = null; - - this.serverTimeOffset = null; - this.auth = new Auth(this, options); - this.channels = new Channels(this); - this.push = new Push(this); - } - - Rest.prototype.stats = function(params, callback) { - /* params and callback are optional; see if params contains the callback */ - if(callback === undefined) { - if(typeof(params) == 'function') { - callback = params; - params = null; - } else { - if(this.options.promises) { - return Utils.promisify(this, 'stats', arguments); - } - callback = noop; - } - } - var headers = Utils.defaultGetHeaders(), - format = this.options.useBinaryProtocol ? 'msgpack' : 'json', - envelope = Http.supportsLinkHeaders ? undefined : format; - - if(this.options.headers) - Utils.mixin(headers, this.options.headers); - - (new PaginatedResource(this, '/stats', headers, envelope, function(body, headers, unpacked) { - var statsValues = (unpacked ? body : JSON.parse(body)); - for(var i = 0; i < statsValues.length; i++) statsValues[i] = Stats.fromValues(statsValues[i]); - return statsValues; - })).get(params, callback); - }; - - Rest.prototype.time = function(params, callback) { - /* params and callback are optional; see if params contains the callback */ - if(callback === undefined) { - if(typeof(params) == 'function') { - callback = params; - params = null; - } else { - if(this.options.promises) { - return Utils.promisify(this, 'time', arguments); - } - callback = noop; - } - } - var headers = Utils.defaultGetHeaders(); - if(this.options.headers) - Utils.mixin(headers, this.options.headers); - var self = this; - var timeUri = function(host) { return self.authority(host) + '/time' }; - Http.get(this, timeUri, headers, params, function(err, res, headers, unpacked) { - if(err) { - callback(err); - return; - } - if(!unpacked) res = JSON.parse(res); - var time = res[0]; - if(!time) { - err = new Error('Internal error (unexpected result type from GET /time)'); - err.statusCode = 500; - callback(err); - return; - } - /* calculate time offset only once for this device by adding to the prototype */ - self.serverTimeOffset = (time - Utils.now()); - callback(null, time); - }); - }; - - Rest.prototype.request = function(method, path, params, body, customHeaders, callback) { - var useBinary = this.options.useBinaryProtocol, - encoder = useBinary ? msgpack.encode: JSON.stringify, - decoder = useBinary ? msgpack.decode : JSON.parse, - format = useBinary ? 'msgpack' : 'json', - envelope = Http.supportsLinkHeaders ? undefined : format; - params = params || {}; - method = method.toLowerCase(); - var headers = method == 'get' ? Utils.defaultGetHeaders(format) : Utils.defaultPostHeaders(format); - - if(callback === undefined) { - if(this.options.promises) { - return Utils.promisify(this, 'request', [method, path, params, body, customHeaders]); - } - callback = noop; - } - - if(typeof body !== 'string') { - body = encoder(body); - } - if(this.options.headers) { - Utils.mixin(headers, this.options.headers); - } - if(customHeaders) { - Utils.mixin(headers, customHeaders); - } - var paginatedResource = new PaginatedResource(this, path, headers, envelope, function(resbody, headers, unpacked) { - return Utils.ensureArray(unpacked ? resbody : decoder(resbody)); - }, /* useHttpPaginatedResponse: */ true); - - if(!Utils.arrIn(Http.methods, method)) { - throw new ErrorInfo('Unsupported method ' + method, 40500, 405); - } - - if(Utils.arrIn(Http.methodsWithBody, method)) { - paginatedResource[method](params, body, callback); - } else { - paginatedResource[method](params, callback); - } - }; - - Rest.prototype.setLog = function(logOptions) { - Logger.setLog(logOptions.level, logOptions.handler); - }; - - function Channels(rest) { - this.rest = rest; - this.all = Object.create(null); - } - - Channels.prototype.get = function(name, channelOptions) { - name = String(name); - var channel = this.all[name]; - if(!channel) { - this.all[name] = channel = new Channel(this.rest, name, channelOptions); - } else if(channelOptions) { - channel.setOptions(channelOptions); - } - - return channel; - }; - - /* Included to support certain niche use-cases; most users should ignore this. - * Please do not use this unless you know what you're doing */ - Channels.prototype.release = function(name) { - delete this.all[String(name)]; - }; - - return Rest; -})(); - -Rest.Promise = function(options) { - options = Defaults.objectifyOptions(options); - options.promises = true; - return new Rest(options); -}; - -Rest.Callbacks = Rest; - -export default Rest; diff --git a/common/lib/client/rest.ts b/common/lib/client/rest.ts new file mode 100644 index 0000000000..39592f191c --- /dev/null +++ b/common/lib/client/rest.ts @@ -0,0 +1,224 @@ +import Platform from 'platform'; +import * as Utils from '../util/utils'; +import Logger, { LoggerOptions } from '../util/logger'; +import Defaults from '../util/defaults'; +import Auth from './auth'; +import Push from './push'; +import Http from 'platform-http'; +import PaginatedResource, { HttpPaginatedResponse, PaginatedResult } from './paginatedresource'; +import Channel from './channel'; +import ErrorInfo from '../types/errorinfo'; +import Stats from '../types/stats'; +import HttpMethods from '../../constants/HttpMethods'; +import { ChannelOptions } from '../../types/channel'; +import { PaginatedResultCallback, StandardCallback } from '../../types/utils'; +import { ErrnoException, RequestParams } from '../../types/http'; +import ClientOptions, { DeprecatedClientOptions, NormalisedClientOptions } from '../../types/ClientOptions'; + +const noop = function() {}; +const msgpack = Platform.msgpack; + +class Rest { + options: NormalisedClientOptions; + baseUri: (host: string) => string; + authority: (host: string) => string; + _currentFallback: null | { + host: string, + validUntil: number, + }; + serverTimeOffset: number | null; + auth: Auth; + channels: Channels; + push: Push; + + constructor(options: ClientOptions | string) { + if(!options) { + const msg = 'no options provided'; + Logger.logAction(Logger.LOG_ERROR, 'Rest()', msg); + throw new Error(msg); + } + const optionsObj = Defaults.objectifyOptions(options); + + if(optionsObj.log) { + Logger.setLog(optionsObj.log.level, optionsObj.log.handler); + } + Logger.logAction(Logger.LOG_MICRO, 'Rest()', 'initialized with clientOptions ' + Utils.inspect(options)); + + const normalOptions = this.options = Defaults.normaliseOptions(optionsObj); + + /* process options */ + if(normalOptions.key) { + const keyMatch = normalOptions.key.match(/^([^:\s]+):([^:.\s]+)$/); + if(!keyMatch) { + const msg = 'invalid key parameter'; + Logger.logAction(Logger.LOG_ERROR, 'Rest()', msg); + throw new Error(msg); + } + normalOptions.keyName = keyMatch[1]; + normalOptions.keySecret = keyMatch[2]; + } + + if('clientId' in normalOptions) { + if(!(typeof(normalOptions.clientId) === 'string' || normalOptions.clientId === null)) + throw new ErrorInfo('clientId must be either a string or null', 40012, 400); + else if(normalOptions.clientId === '*') + throw new ErrorInfo('Can’t use "*" as a clientId as that string is reserved. (To change the default token request behaviour to use a wildcard clientId, use {defaultTokenParams: {clientId: "*"}})', 40012, 400); + } + + Logger.logAction(Logger.LOG_MINOR, 'Rest()', 'started; version = ' + Defaults.version); + + this.baseUri = this.authority = function(host) { return Defaults.getHttpScheme(normalOptions) + host + ':' + Defaults.getPort(normalOptions, false); }; + this._currentFallback = null; + + this.serverTimeOffset = null; + this.auth = new Auth(this, normalOptions); + this.channels = new Channels(this); + this.push = new Push(this); + } + + stats(params: RequestParams, callback: StandardCallback>): Promise> | void { + /* params and callback are optional; see if params contains the callback */ + if(callback === undefined) { + if(typeof(params) == 'function') { + callback = params; + params = null; + } else { + if(this.options.promises) { + return Utils.promisify(this, 'stats', arguments) as Promise>; + } + callback = noop; + } + } + const headers = Utils.defaultGetHeaders(), + format = this.options.useBinaryProtocol ? Utils.Format.msgpack : Utils.Format.json, + envelope = Http.supportsLinkHeaders ? undefined : format; + + if(this.options.headers) + Utils.mixin(headers, this.options.headers); + + (new PaginatedResource(this, '/stats', headers, envelope, function(body: unknown, headers: Record, unpacked?: boolean) { + const statsValues = (unpacked ? body : JSON.parse(body as string)); + for(let i = 0; i < statsValues.length; i++) statsValues[i] = Stats.fromValues(statsValues[i]); + return statsValues; + })).get(params as Record, callback); + } + + time(params?: RequestParams | StandardCallback, callback?: StandardCallback): Promise | void { + /* params and callback are optional; see if params contains the callback */ + if(callback === undefined) { + if(typeof(params) == 'function') { + callback = params; + params = null; + } else { + if(this.options.promises) { + return Utils.promisify(this, 'time', arguments) as Promise; + } + } + } + + const _callback = callback || noop; + + const headers = Utils.defaultGetHeaders(); + if(this.options.headers) + Utils.mixin(headers, this.options.headers); + const timeUri = (host: string) => { return this.authority(host) + '/time' }; + Http.get(this, timeUri, headers, params as RequestParams, (err?: ErrorInfo | ErrnoException | null, res?: unknown, headers?: Record, unpacked?: boolean) => { + if(err) { + _callback(err); + return; + } + if(!unpacked) res = JSON.parse(res as string); + const time = (res as number[])[0]; + if(!time) { + _callback(new ErrorInfo('Internal error (unexpected result type from GET /time)', 50000, 500)); + return; + } + /* calculate time offset only once for this device by adding to the prototype */ + this.serverTimeOffset = (time - Utils.now()); + _callback(null, time); + }); + } + + request(method: string, path: string, params: RequestParams, body: unknown, customHeaders: Record, callback: StandardCallback>): Promise> | void { + const useBinary = this.options.useBinaryProtocol, + encoder = useBinary ? msgpack.encode: JSON.stringify, + decoder = useBinary ? msgpack.decode : JSON.parse, + format = useBinary ? Utils.Format.msgpack : Utils.Format.json, + envelope = Http.supportsLinkHeaders ? undefined : format; + params = params || {}; + const _method = method.toLowerCase() as HttpMethods; + const headers = _method == 'get' ? Utils.defaultGetHeaders(format) : Utils.defaultPostHeaders(format); + + if(callback === undefined) { + if(this.options.promises) { + return Utils.promisify(this, 'request', arguments) as Promise>; + } + callback = noop; + } + + if(typeof body !== 'string') { + body = encoder(body); + } + if(this.options.headers) { + Utils.mixin(headers, this.options.headers); + } + if(customHeaders) { + Utils.mixin(headers, customHeaders); + } + const paginatedResource = new PaginatedResource(this, path, headers, envelope, function(resbody: unknown, headers: Record, unpacked?: boolean) { + return Utils.ensureArray(unpacked ? resbody : decoder(resbody as string & Buffer)); + }, /* useHttpPaginatedResponse: */ true); + + if(!Utils.arrIn(Http.methods, _method)) { + throw new ErrorInfo('Unsupported method ' + _method, 40500, 405); + } + + if(Utils.arrIn(Http.methodsWithBody, _method)) { + paginatedResource[_method as HttpMethods.Post](params, body, callback as PaginatedResultCallback); + } else { + paginatedResource[_method as (HttpMethods.Get | HttpMethods.Delete)](params, callback as PaginatedResultCallback); + } + } + + setLog(logOptions: LoggerOptions): void { + Logger.setLog(logOptions.level, logOptions.handler); + } + + static Promise = function(options: DeprecatedClientOptions): Rest { + options = Defaults.objectifyOptions(options); + options.promises = true; + return new Rest(options); + }; + + static Callbacks = Rest; +} + +class Channels { + rest: Rest; + all: Record; + + constructor(rest: Rest) { + this.rest = rest; + this.all = {}; + } + + get(name: string, channelOptions?: ChannelOptions) { + name = String(name); + let channel = this.all[name]; + if(!channel) { + this.all[name] = channel = new Channel(this.rest, name, channelOptions); + } else if(channelOptions) { + channel.setOptions(channelOptions); + } + + return channel; + } + + /* Included to support certain niche use-cases; most users should ignore this. + * Please do not use this unless you know what you're doing */ + release(name: string) { + delete this.all[String(name)]; + } +} + +export default Rest; diff --git a/common/lib/index.js b/common/lib/index.js index c3c137db2d..ff1e6c8042 100644 --- a/common/lib/index.js +++ b/common/lib/index.js @@ -1,7 +1,7 @@ import Rest from './client/rest'; import Realtime from './client/realtime'; -import Utils from './util/utils'; -import BufferUtils from 'platform-bufferutils'; +import * as Utils from './util/utils'; +import * as BufferUtils from 'platform-bufferutils'; import Crypto from 'platform-crypto'; import Defaults from '../lib/util/defaults'; import Http from 'platform-http'; diff --git a/common/lib/transport/comettransport.js b/common/lib/transport/comettransport.ts similarity index 52% rename from common/lib/transport/comettransport.js rename to common/lib/transport/comettransport.ts index 790f5827ed..a6dbe41969 100644 --- a/common/lib/transport/comettransport.js +++ b/common/lib/transport/comettransport.ts @@ -1,165 +1,166 @@ -import Utils from '../util/utils'; +import * as Utils from '../util/utils'; import ProtocolMessage from '../types/protocolmessage'; -import Transport from '../transport/transport'; +import Transport from './transport'; import Logger from '../util/logger'; import Defaults from '../util/defaults'; -import ConnectionError from './connectionerror'; +import ConnectionErrors from './connectionerrors'; import Auth from '../client/auth'; import ErrorInfo from '../types/errorinfo'; - -var CometTransport = (function() { - - var REQ_SEND = 0, - REQ_RECV = 1, - REQ_RECV_POLL = 2, - REQ_RECV_STREAM = 3; - - /* TODO: can remove once realtime sends protocol message responses for comet errors */ - function shouldBeErrorAction(err) { - var UNRESOLVABLE_ERROR_CODES = [80015, 80017, 80030]; - if(err.code) { - if(Auth.isTokenErr(err)) return false; - if(Utils.arrIn(UNRESOLVABLE_ERROR_CODES, err.code)) return true; - return (err.code >= 40000 && err.code < 50000); - } else { - /* Likely a network or transport error of some kind. Certainly not fatal to the connection */ - return false; - } +import IXHRRequest from '../../types/IXHRRequest'; +import * as API from '../../../ably'; +import ConnectionManager, { TransportParams } from './connectionmanager'; +import XHRStates from '../../constants/XHRStates'; + +/* TODO: can remove once realtime sends protocol message responses for comet errors */ +function shouldBeErrorAction(err: ErrorInfo) { + const UNRESOLVABLE_ERROR_CODES = [80015, 80017, 80030]; + if(err.code) { + if(Auth.isTokenErr(err)) return false; + if(Utils.arrIn(UNRESOLVABLE_ERROR_CODES, err.code)) return true; + return (err.code >= 40000 && err.code < 50000); + } else { + /* Likely a network or transport error of some kind. Certainly not fatal to the connection */ + return false; } - - function protocolMessageFromRawError(err) { - /* err will be either a legacy (non-protocolmessage) comet error response - * (which will have an err.code), or a xhr/network error (which won't). */ - if(shouldBeErrorAction(err)) { - return [ProtocolMessage.fromValues({action: ProtocolMessage.Action.ERROR, error: err})]; - } else { - return [ProtocolMessage.fromValues({action: ProtocolMessage.Action.DISCONNECTED, error: err})]; - } +} + +function protocolMessageFromRawError(err: ErrorInfo) { + /* err will be either a legacy (non-protocolmessage) comet error response + * (which will have an err.code), or a xhr/network error (which won't). */ + if(shouldBeErrorAction(err)) { + return [ProtocolMessage.fromValues({action: ProtocolMessage.Action.ERROR, error: err})]; + } else { + return [ProtocolMessage.fromValues({action: ProtocolMessage.Action.DISCONNECTED, error: err})]; } +} /* * A base comet transport class */ - function CometTransport(connectionManager, auth, params) { - /* binary not supported for comet, so just fall back to default */ - params.format = undefined; - params.heartbeats = true; - Transport.call(this, connectionManager, auth, params); - /* streaming defaults to true */ +abstract class CometTransport extends Transport { + stream: string | boolean; + sendRequest: IXHRRequest | null; + recvRequest: null | IXHRRequest; + pendingCallback: null; + pendingItems: null | Array; + baseUri?: string; + authParams?: Record; + closeUri?: string; + disconnectUri?: string; + sendUri?: string; + recvUri?: string; + + constructor(connectionManager: ConnectionManager, auth: Auth, params: TransportParams) { + super(connectionManager, auth, params, /* binary not supported for comet so force JSON protocol */ true); this.stream = ('stream' in params) ? params.stream : true; this.sendRequest = null; this.recvRequest = null; this.pendingCallback = null; this.pendingItems = null; } - Utils.inherits(CometTransport, Transport); - CometTransport.REQ_SEND = REQ_SEND; - CometTransport.REQ_RECV = REQ_RECV; - CometTransport.REQ_RECV_POLL = REQ_RECV_POLL; - CometTransport.REQ_RECV_STREAM = REQ_RECV_STREAM; + abstract createRequest(uri: string, headers: Record | null, params?: Record | null, body?: unknown, requestMode?: number): IXHRRequest; - /* public instance methods */ - CometTransport.prototype.connect = function() { + connect(): void { Logger.logAction(Logger.LOG_MINOR, 'CometTransport.connect()', 'starting'); Transport.prototype.connect.call(this); - var self = this, params = this.params, options = params.options; - var host = Defaults.getHost(options, params.host); - var port = Defaults.getPort(options); - var cometScheme = options.tls ? 'https://' : 'http://'; + const params = this.params; + const options = params.options; + const host = Defaults.getHost(options, params.host); + const port = Defaults.getPort(options); + const cometScheme = options.tls ? 'https://' : 'http://'; this.baseUri = cometScheme + host + ':' + port + '/comet/'; - var connectUri = this.baseUri + 'connect'; + const connectUri = this.baseUri + 'connect'; Logger.logAction(Logger.LOG_MINOR, 'CometTransport.connect()', 'uri: ' + connectUri); - this.auth.getAuthParams(function(err, authParams) { + this.auth.getAuthParams((err: Error, authParams: Record) => { if(err) { - self.disconnect(err); + this.disconnect(err); return; } - if(self.isDisposed) { + if(this.isDisposed) { return; } - self.authParams = authParams; - var connectParams = self.params.getConnectParams(authParams); - if('stream' in connectParams) self.stream = connectParams.stream; + this.authParams = authParams; + const connectParams = this.params.getConnectParams(authParams); + if('stream' in connectParams) this.stream = connectParams.stream; Logger.logAction(Logger.LOG_MINOR, 'CometTransport.connect()', 'connectParams:' + Utils.toQueryString(connectParams)); /* this will be the 'recvRequest' so this connection can stream messages */ - var preconnected = false, - connectRequest = self.recvRequest = self.createRequest(connectUri, null, connectParams, null, (self.stream ? REQ_RECV_STREAM : REQ_RECV)); + let preconnected = false; + const connectRequest = this.recvRequest = this.createRequest(connectUri, null, connectParams, null, (this.stream ? XHRStates.REQ_RECV_STREAM : XHRStates.REQ_RECV)); - connectRequest.on('data', function(data) { - if(!self.recvRequest) { + connectRequest.on('data', (data: any) => { + if(!this.recvRequest) { /* the transport was disposed before we connected */ return; } if(!preconnected) { preconnected = true; - self.emit('preconnect'); + this.emit('preconnect'); } - self.onData(data); + this.onData(data); }); - connectRequest.on('complete', function(err, _body, headers) { - if(!self.recvRequest) { + connectRequest.on('complete', (err: ErrorInfo, _body: unknown, headers: Record) => { + if(!this.recvRequest) { /* the transport was disposed before we connected */ err = err || new ErrorInfo('Request cancelled', 80003, 400); } - self.recvRequest = null; + this.recvRequest = null; /* Connect request may complete without a emitting 'data' event since that is not * emitted for e.g. a non-streamed error response. Still implies preconnect. */ if(!preconnected && !err) { preconnected = true; - self.emit('preconnect'); + this.emit('preconnect'); } - self.onActivity(); + this.onActivity(); if(err) { if(err.code) { /* A protocol error received from realtime. TODO: once realtime * consistendly sends errors wrapped in protocol messages, should be * able to remove this */ - self.onData(protocolMessageFromRawError(err)); + this.onData(protocolMessageFromRawError(err)); } else { /* A network/xhr error. Don't bother wrapping in a protocol message, * just disconnect the transport */ - self.disconnect(err); + this.disconnect(err); } return; } - Utils.nextTick(function() { - self.recv(); + Utils.nextTick(() => { + this.recv(); }); }); connectRequest.exec(); }); - }; + } - CometTransport.prototype.requestClose = function() { + requestClose(): void { Logger.logAction(Logger.LOG_MINOR, 'CometTransport.requestClose()'); this._requestCloseOrDisconnect(true); - }; + } - CometTransport.prototype.requestDisconnect = function() { + requestDisconnect(): void { Logger.logAction(Logger.LOG_MINOR, 'CometTransport.requestDisconnect()'); this._requestCloseOrDisconnect(false); - }; + } - CometTransport.prototype._requestCloseOrDisconnect = function(closing) { - var closeOrDisconnectUri = closing ? this.closeUri : this.disconnectUri; + _requestCloseOrDisconnect(closing: boolean): void { + const closeOrDisconnectUri = closing ? this.closeUri : this.disconnectUri; if(closeOrDisconnectUri) { - var self = this, - request = this.createRequest(closeOrDisconnectUri, null, this.authParams, null, REQ_SEND); + const request = this.createRequest(closeOrDisconnectUri, null, this.authParams, null, XHRStates.REQ_SEND); - request.on('complete', function (err) { + request.on('complete', (err: ErrorInfo) => { if(err) { Logger.logAction(Logger.LOG_ERROR, 'CometTransport.request' + (closing ? 'Close()' : 'Disconnect()'), 'request returned err = ' + Utils.inspectError(err)); - self.finish('disconnected', err); + this.finish('disconnected', err); } }); request.exec(); } - }; + } - CometTransport.prototype.dispose = function() { + dispose(): void { Logger.logAction(Logger.LOG_MINOR, 'CometTransport.dispose()', ''); if(!this.isDisposed) { this.isDisposed = true; @@ -170,15 +171,14 @@ var CometTransport = (function() { } /* In almost all cases the transport will be finished before it's * disposed. Finish here just to make sure. */ - this.finish('disconnected', ConnectionError.disconnected); - var self = this; - Utils.nextTick(function() { - self.emit('disposed'); + this.finish('disconnected', ConnectionErrors.disconnected); + Utils.nextTick(() => { + this.emit('disposed'); }); } - }; + } - CometTransport.prototype.onConnect = function(message) { + onConnect(message: ProtocolMessage): void { /* if this transport has been disposed whilst awaiting connection, do nothing */ if(this.isDisposed) { return; @@ -186,18 +186,18 @@ var CometTransport = (function() { /* the connectionKey in a comet connected response is really * - */ - var connectionStr = message.connectionKey; + const connectionStr = message.connectionKey; Transport.prototype.onConnect.call(this, message); - var baseConnectionUri = this.baseUri + connectionStr; + const baseConnectionUri = (this.baseUri as string) + connectionStr; Logger.logAction(Logger.LOG_MICRO, 'CometTransport.onConnect()', 'baseUri = ' + baseConnectionUri + '; connectionKey = ' + message.connectionKey); this.sendUri = baseConnectionUri + '/send'; this.recvUri = baseConnectionUri + '/recv'; this.closeUri = baseConnectionUri + '/close'; this.disconnectUri = baseConnectionUri + '/disconnect'; - }; + } - CometTransport.prototype.send = function(message) { + send(message: ProtocolMessage): void { if(this.sendRequest) { /* there is a pending send, so queue this message */ this.pendingItems = this.pendingItems || []; @@ -205,15 +205,15 @@ var CometTransport = (function() { return; } /* send this, plus any pending, now */ - var pendingItems = this.pendingItems || []; + const pendingItems = this.pendingItems || []; pendingItems.push(message); this.pendingItems = null; this.sendItems(pendingItems); - }; + } - CometTransport.prototype.sendAnyPending = function() { - var pendingItems = this.pendingItems; + sendAnyPending(): void { + const pendingItems = this.pendingItems; if(!pendingItems) { return; @@ -223,13 +223,12 @@ var CometTransport = (function() { this.sendItems(pendingItems); } - CometTransport.prototype.sendItems = function(items) { - var self = this, - sendRequest = this.sendRequest = self.createRequest(self.sendUri, null, self.authParams, this.encodeRequest(items), REQ_SEND); + sendItems(items: Array): void { + const sendRequest = this.sendRequest = this.createRequest(this.sendUri as string, null, this.authParams, this.encodeRequest(items), XHRStates.REQ_SEND); - sendRequest.on('complete', function(err, data) { + sendRequest.on('complete', (err: ErrorInfo, data: string) => { if(err) Logger.logAction(Logger.LOG_ERROR, 'CometTransport.sendItems()', 'on complete: err = ' + Utils.inspectError(err)); - self.sendRequest = null; + this.sendRequest = null; /* the result of the request, even if a nack, is usually a protocol response * contained in the data. An err is anomolous, and indicates some issue with the @@ -239,34 +238,34 @@ var CometTransport = (function() { /* A protocol error received from realtime. TODO: once realtime * consistendly sends errors wrapped in protocol messages, should be * able to remove this */ - self.onData(protocolMessageFromRawError(err)); + this.onData(protocolMessageFromRawError(err)); } else { /* A network/xhr error. Don't bother wrapping in a protocol message, * just disconnect the transport */ - self.disconnect(err); + this.disconnect(err); } return; } if(data) { - self.onData(data); + this.onData(data); } - if(self.pendingItems) { - Utils.nextTick(function() { + if(this.pendingItems) { + Utils.nextTick(() => { /* If there's a new send request by now, any pending items will have * been picked up by that; any new ones added since then will be * picked up after that one completes */ - if(!self.sendRequest) { - self.sendAnyPending(); + if(!this.sendRequest) { + this.sendAnyPending(); } }); } }); sendRequest.exec(); - }; + } - CometTransport.prototype.recv = function() { + recv(): void { /* do nothing if there is an active request, which might be streaming */ if(this.recvRequest) return; @@ -275,57 +274,56 @@ var CometTransport = (function() { if(!this.isConnected) return; - var self = this, - recvRequest = this.recvRequest = this.createRequest(this.recvUri, null, this.authParams, null, (self.stream ? REQ_RECV_STREAM : REQ_RECV_POLL)); + const recvRequest = this.recvRequest = this.createRequest(this.recvUri as string, null, this.authParams, null, (this.stream ? XHRStates.REQ_RECV_STREAM : XHRStates.REQ_RECV_POLL)); - recvRequest.on('data', function(data) { - self.onData(data); + recvRequest.on('data', (data: string) => { + this.onData(data); }); - recvRequest.on('complete', function(err) { - self.recvRequest = null; + recvRequest.on('complete', (err: ErrorInfo) => { + this.recvRequest = null; /* A request completing must be considered activity, as realtime sends * heartbeats every 15s since a request began, not every 15s absolutely */ - self.onActivity(); + this.onActivity(); if(err) { if(err.code) { /* A protocol error received from realtime. TODO: once realtime - * consistendly sends errors wrapped in protocol messages, should be + * consistently sends errors wrapped in protocol messages, should be * able to remove this */ - self.onData(protocolMessageFromRawError(err)); + this.onData(protocolMessageFromRawError(err)); } else { /* A network/xhr error. Don't bother wrapping in a protocol message, * just disconnect the transport */ - self.disconnect(err); + this.disconnect(err); } return; } - Utils.nextTick(function() { - self.recv(); + Utils.nextTick(() => { + this.recv(); }); }); recvRequest.exec(); - }; + } - CometTransport.prototype.onData = function(responseData) { + onData(responseData: string | Record): void { try { - var items = this.decodeResponse(responseData); + const items = this.decodeResponse(responseData); if(items && items.length) - for(var i = 0; i < items.length; i++) + for(let i = 0; i < items.length; i++) this.onProtocolMessage(ProtocolMessage.fromDeserialized(items[i])); } catch (e) { - Logger.logAction(Logger.LOG_ERROR, 'CometTransport.onData()', 'Unexpected exception handing channel event: ' + e.stack); + Logger.logAction(Logger.LOG_ERROR, 'CometTransport.onData()', 'Unexpected exception handing channel event: ' + (e as Error).stack); } - }; + } - CometTransport.prototype.encodeRequest = function(requestItems) { + encodeRequest(requestItems: Array): string { return JSON.stringify(requestItems); - }; + } - CometTransport.prototype.decodeResponse = function(responseData) { + decodeResponse(responseData: string | Record): Record { if(typeof(responseData) == 'string') - responseData = JSON.parse(responseData); + return JSON.parse(responseData); return responseData; - }; + } /* For comet, we could do the auth update by aborting the current recv and * starting a new one with the new token, that'd be sufficient for realtime. @@ -333,11 +331,9 @@ var CometTransport = (function() { * we need to send an AUTH for jsonp. In which case it's simpler to keep all * comet transports the same and do it for all of them. So we send the AUTH * instead, and don't need to abort the recv */ - CometTransport.prototype.onAuthUpdated = function(tokenDetails) { + onAuthUpdated = (tokenDetails: API.Types.TokenDetails): void => { this.authParams = {access_token: tokenDetails.token}; - }; - - return CometTransport; -})(); + } +} export default CometTransport; diff --git a/common/lib/transport/connectionerror.js b/common/lib/transport/connectionerrors.ts similarity index 81% rename from common/lib/transport/connectionerror.js rename to common/lib/transport/connectionerrors.ts index 73ea3dbb6e..c79ef2878c 100644 --- a/common/lib/transport/connectionerror.js +++ b/common/lib/transport/connectionerrors.ts @@ -1,7 +1,6 @@ import ErrorInfo from '../types/errorinfo'; -import Utils from '../util/utils'; -var ConnectionError = { +const ConnectionErrors = { disconnected: ErrorInfo.fromValues({ statusCode: 400, code: 80003, @@ -39,17 +38,17 @@ var ConnectionError = { }) }; -ConnectionError.isRetriable = function(err) { +export function isRetriable(err: ErrorInfo) { if (!err.statusCode || !err.code || err.statusCode >= 500) { return true; } - var retriable = false; - Utils.valuesArray(ConnectionError).forEach(function(connErr) { + let retriable = false; + Object.values(ConnectionErrors).forEach(function(connErr) { if (connErr.code && connErr.code == err.code) { retriable = true; } - }); + }) return retriable; -}; +} -export default ConnectionError; +export default ConnectionErrors; diff --git a/common/lib/transport/connectionmanager.js b/common/lib/transport/connectionmanager.ts similarity index 70% rename from common/lib/transport/connectionmanager.js rename to common/lib/transport/connectionmanager.ts index e50035c4ec..bb42d5512f 100644 --- a/common/lib/transport/connectionmanager.js +++ b/common/lib/transport/connectionmanager.ts @@ -1,81 +1,133 @@ import ProtocolMessage from '../types/protocolmessage'; -import Utils from '../util/utils'; -import Protocol from './protocol'; +import * as Utils from '../util/utils'; +import Protocol, { PendingMessage } from './protocol'; import Defaults from '../util/defaults'; import Platform from 'platform'; import EventEmitter from '../util/eventemitter'; import MessageQueue from './messagequeue'; import Logger from '../util/logger'; import ConnectionStateChange from '../client/connectionstatechange'; -import ConnectionError from '../transport/connectionerror'; +import ConnectionErrors, { isRetriable } from './connectionerrors'; import ErrorInfo from '../types/errorinfo'; import Auth from '../client/auth'; import Http from 'platform-http'; import Message from '../types/message'; -import Multicaster from '../util/multicaster'; +import Multicaster, { MulticasterInstance } from '../util/multicaster'; import ErrorReporter from '../util/errorreporter'; -import WebStorage from 'platform-webstorage'; +import * as WebStorage from 'platform-webstorage'; import PlatformTransports from 'platform-transports'; import WebSocketTransport from './websockettransport'; - -var ConnectionManager = (function() { - var haveWebStorage = !!(typeof(WebStorage) !== 'undefined' && WebStorage.get); - var haveSessionStorage = !!(typeof(WebStorage) !== 'undefined' && WebStorage.getSession); - var actions = ProtocolMessage.Action; - var PendingMessage = Protocol.PendingMessage; - var noop = function() {}; - var transportPreferenceOrder = Defaults.transportPreferenceOrder; - var optimalTransport = transportPreferenceOrder[transportPreferenceOrder.length - 1]; - var transportPreferenceName = 'ably-transport-preference'; - - var sessionRecoveryName = 'ably-connection-recovery'; - function getSessionRecoverData() { - return haveSessionStorage && WebStorage.getSession(sessionRecoveryName); +import Transport from './transport'; +import * as API from '../../../ably'; +import { ErrCallback } from '../../types/utils'; +import HttpStatusCodes from '../../constants/HttpStatusCodes'; + +type Realtime = any; +type ClientOptions = any; + +const haveWebStorage = !!(typeof(WebStorage) !== 'undefined' && WebStorage.get); +const haveSessionStorage = !!(typeof(WebStorage) !== 'undefined' && WebStorage.getSession); +const actions = ProtocolMessage.Action; +const noop = function() {}; +const transportPreferenceOrder = Defaults.transportPreferenceOrder; +const optimalTransport = transportPreferenceOrder[transportPreferenceOrder.length - 1]; +const transportPreferenceName = 'ably-transport-preference'; + +const sessionRecoveryName = 'ably-connection-recovery'; +function getSessionRecoverData() { + return haveSessionStorage && WebStorage?.getSession?.(sessionRecoveryName); +} +function setSessionRecoverData(value: any) { + return haveSessionStorage && WebStorage?.setSession?.(sessionRecoveryName, value); +} +function clearSessionRecoverData() { + return haveSessionStorage && WebStorage?.removeSession?.(sessionRecoveryName); +} + +function betterTransportThan(a: Transport, b: Transport) { + return Utils.arrIndexOf(transportPreferenceOrder, a.shortName) > + Utils.arrIndexOf(transportPreferenceOrder, b.shortName); +} + +function bundleWith(dest: ProtocolMessage, src: ProtocolMessage, maxSize: number) { + let action; + if(dest.channel !== src.channel) { + /* RTL6d3 */ + return false; } - function setSessionRecoverData(value) { - return haveSessionStorage && WebStorage.setSession(sessionRecoveryName, value); + if((action = dest.action) !== actions.PRESENCE && action !== actions.MESSAGE) { + /* RTL6d - can only bundle messages or presence */ + return false; } - function clearSessionRecoverData() { - return haveSessionStorage && WebStorage.removeSession(sessionRecoveryName); + if(action !== src.action) { + /* RTL6d4 */ + return false; } - - function betterTransportThan(a, b) { - return Utils.arrIndexOf(transportPreferenceOrder, a.shortName) > - Utils.arrIndexOf(transportPreferenceOrder, b.shortName); + const kind = (action === actions.PRESENCE) ? 'presence' : 'messages', + proposed = (dest as Record)[kind].concat((src as Record)[kind]), + size = Message.getMessagesSize(proposed); + if(size > maxSize) { + /* RTL6d1 */ + return false; } - - function TransportParams(options, host, mode, connectionKey) { + if(!Utils.allSame(proposed, 'clientId')) { + /* RTL6d2 */ + return false; + } + if(!Utils.arrEvery(proposed, function(msg: Message) { + return !msg.id; + })) { + /* RTL6d7 */ + return false; + } + /* we're good to go! */ + (dest as Record)[kind] = proposed; + return true; +} + +export class TransportParams { + options: ClientOptions; + host: string | null; + mode: string; + format?: Utils.Format; + connectionKey?: string; + connectionSerial?: number; + timeSerial?: string; + stream?: any; + heartbeats?: boolean; + + constructor(options: ClientOptions, host: string | null, mode: string, connectionKey?: string) { this.options = options; this.host = host; this.mode = mode; this.connectionKey = connectionKey; - this.format = options.useBinaryProtocol ? 'msgpack' : 'json'; + this.format = options.useBinaryProtocol ? Utils.Format.msgpack : Utils.Format.json; this.connectionSerial = undefined; this.timeSerial = undefined; } - TransportParams.prototype.getConnectParams = function(authParams) { - var params = authParams ? Utils.copy(authParams) : {}; - var options = this.options; + getConnectParams(authParams: Record): Record { + const params = authParams ? Utils.copy(authParams) : {}; + const options = this.options; switch(this.mode) { case 'upgrade': - params.upgrade = this.connectionKey; + params.upgrade = this.connectionKey as string; break; case 'resume': - params.resume = this.connectionKey; + params.resume = this.connectionKey as string; if(this.timeSerial !== undefined) { - params.timeSerial = this.timeSerial; + params.timeSerial = this.timeSerial as string; } else if(this.connectionSerial !== undefined) { params.connectionSerial = this.connectionSerial; } break; case 'recover': - var match = options.recover.split(':'); + const match = (options.recover as string).split(':'); if(match) { params.recover = match[0]; - var recoverSerial = match[1]; - if(isNaN(recoverSerial)) { + const recoverSerial = match[1]; + if(isNaN(Number(recoverSerial))) { params.timeSerial = recoverSerial; } else { params.connectionSerial = recoverSerial; @@ -104,11 +156,11 @@ var ConnectionManager = (function() { if(options.transportParams !== undefined) { Utils.mixin(params, options.transportParams); } - return params; - }; + return params as Record; + } - TransportParams.prototype.toString = function() { - var result = '[mode=' + this.mode; + toString(): string { + let result = '[mode=' + this.mode; if(this.host) { result += (',host=' + this.host); } if(this.connectionKey) { result += (',connectionKey=' + this.connectionKey); } if(this.connectionSerial !== undefined) { result += (',connectionSerial=' + this.connectionSerial); } @@ -117,19 +169,64 @@ var ConnectionManager = (function() { result += ']'; return result; - }; - - /* public constructor */ - function ConnectionManager(realtime, options) { - EventEmitter.call(this); + } +} + +type ConnectionState = { + state: string; + terminal?: boolean; + queueEvents?: boolean; + sendEvents?: boolean; + failState?: string; + retryDelay?: number; + forceQueueEvents?: boolean; + retryImmediately?: boolean; + error?: ErrorInfo; +} + +class ConnectionManager extends EventEmitter { + realtime: Realtime; + options: ClientOptions; + states: Record; + state: ConnectionState; + errorReason: ErrorInfo | string | null; + queuedMessages: MessageQueue; + msgSerial: number; + connectionDetails?: Record; + connectionId?: string; + connectionKey?: string; + timeSerial?: number; + connectionSerial?: number; + connectionStateTtl: number; + maxIdleInterval: number | null; + transports: string[]; + baseTransport: string; + upgradeTransports: string[]; + transportPreference: string | null; + httpHosts: string[]; + activeProtocol: null | Protocol; + proposedTransports: Transport[]; + pendingTransports: Transport[]; + host: string | null; + lastAutoReconnectAttempt: number | null; + lastActivity: number | null; + mostRecentMsg: ProtocolMessage | null; + forceFallbackHost: boolean; + connectCounter: number; + channelResumeCheckTimer?: number | NodeJS.Timeout; + transitionTimer?: number | NodeJS.Timeout | null; + suspendTimer?: number | NodeJS.Timeout | null; + retryTimer?: number | NodeJS.Timeout | null; + + constructor(realtime: Realtime, options: ClientOptions) { + super(); this.realtime = realtime; this.options = options; - var timeouts = options.timeouts; - var self = this; + const timeouts = options.timeouts; /* connectingTimeout: leave preferenceConnectTimeout (~6s) to try the * preference transport, then realtimeRequestTimeout (~10s) to establish * the base transport in case that fails */ - var connectingTimeout = timeouts.preferenceConnectTimeout + timeouts.realtimeRequestTimeout; + const connectingTimeout = timeouts.preferenceConnectTimeout + timeouts.realtimeRequestTimeout; this.states = { initialized: {state: 'initialized', terminal: false, queueEvents: true, sendEvents: false, failState: 'disconnected'}, connecting: {state: 'connecting', terminal: false, queueEvents: true, sendEvents: false, retryDelay: connectingTimeout, failState: 'disconnected'}, @@ -180,12 +277,12 @@ var ConnectionManager = (function() { Logger.logAction(Logger.LOG_MICRO, 'Realtime.ConnectionManager()', 'http hosts = [' + this.httpHosts + ']'); if(!this.transports.length) { - var msg = 'no requested transports available'; + const msg = 'no requested transports available'; Logger.logAction(Logger.LOG_ERROR, 'realtime.ConnectionManager()', msg); throw new Error(msg); } - var addEventListener = Platform.addEventListener; + const addEventListener = Platform.addEventListener; if(addEventListener) { /* intercept close event in browser to persist connection id if requested */ if(haveSessionStorage && typeof options.recover === 'function') { @@ -194,74 +291,67 @@ var ConnectionManager = (function() { } if(options.closeOnUnload === true) { - addEventListener('beforeunload', function() { + addEventListener('beforeunload', () => { Logger.logAction(Logger.LOG_MAJOR, 'Realtime.ConnectionManager()', 'beforeunload event has triggered the connection to close as closeOnUnload is true'); - self.requestState({state: 'closing'}); + this.requestState({state: 'closing'}); }); } /* Listen for online and offline events */ - addEventListener('online', function() { - if(self.state == self.states.disconnected || self.state == self.states.suspended) { + addEventListener('online', () => { + if(this.state == this.states.disconnected || this.state == this.states.suspended) { Logger.logAction(Logger.LOG_MINOR, 'ConnectionManager caught browser ‘online’ event', 'reattempting connection'); - self.requestState({state: 'connecting'}); + this.requestState({state: 'connecting'}); } }); - addEventListener('offline', function() { - if(self.state == self.states.connected) { + addEventListener('offline', () => { + if(this.state == this.states.connected) { Logger.logAction(Logger.LOG_MINOR, 'ConnectionManager caught browser ‘offline’ event', 'disconnecting active transport'); // Not sufficient to just go to the 'disconnected' state, want to // force all transports to reattempt the connection. Will immediately // retry. - self.disconnectAllTransports(); + this.disconnectAllTransports(); } }); } } - Utils.inherits(ConnectionManager, EventEmitter); /********************* * transport management *********************/ - ConnectionManager.supportedTransports = {}; + static supportedTransports: Record = {}; - WebSocketTransport(ConnectionManager); - Utils.arrForEach(PlatformTransports, function (initFn) { - initFn(ConnectionManager); - }); - ConnectionManager.prototype.createTransportParams = function(host, mode) { - var params = new TransportParams(this.options, host, mode, this.connectionKey); + createTransportParams(host: string | null, mode: string): TransportParams { + const params = new TransportParams(this.options, host, mode, this.connectionKey); if(this.timeSerial) { - params.timeSerial = this.timeSerial; + params.timeSerial = String(this.timeSerial); } else if(this.connectionSerial !== undefined) { params.connectionSerial = this.connectionSerial; } return params; - }; - - ConnectionManager.prototype.getTransportParams = function(callback) { - var self = this; + } - function decideMode(modeCb) { - if(self.connectionKey) { + getTransportParams(callback: Function): void { + const decideMode = (modeCb: Function) => { + if(this.connectionKey) { modeCb('resume'); return; } - if(typeof self.options.recover === 'string') { + if(typeof this.options.recover === 'string') { modeCb('recover'); return; } - var recoverFn = self.options.recover, + const recoverFn = this.options.recover, lastSessionData = getSessionRecoverData(); if(lastSessionData && typeof(recoverFn) === 'function') { Logger.logAction(Logger.LOG_MINOR, 'ConnectionManager.getTransportParams()', 'Calling clientOptions-provided recover function with last session data'); - recoverFn(lastSessionData, function(shouldRecover) { + recoverFn(lastSessionData, (shouldRecover?: boolean) => { if(shouldRecover) { - self.options.recover = lastSessionData.recoveryKey; + this.options.recover = lastSessionData.recoveryKey; modeCb('recover'); } else { modeCb('clean'); @@ -272,20 +362,20 @@ var ConnectionManager = (function() { modeCb('clean'); } - decideMode(function(mode) { - var transportParams = self.createTransportParams(null, mode); + decideMode((mode: string) => { + const transportParams = this.createTransportParams(null, mode); if(mode === 'recover') { - Logger.logAction(Logger.LOG_MINOR, 'ConnectionManager.getTransportParams()', 'Transport recovery mode = recover; recoveryKey = ' + self.options.recover); - var match = self.options.recover.split(':'); + Logger.logAction(Logger.LOG_MINOR, 'ConnectionManager.getTransportParams()', 'Transport recovery mode = recover; recoveryKey = ' + this.options.recover); + const match = (this.options.recover as string).split(':'); if(match && match[2]) { - self.msgSerial = match[2]; + this.msgSerial = Number(match[2]); } } else { Logger.logAction(Logger.LOG_MINOR, 'ConnectionManager.getTransportParams()', 'Transport params = ' + transportParams.toString()); } callback(transportParams); }); - }; + } /** * Attempt to connect using a given transport @@ -293,12 +383,11 @@ var ConnectionManager = (function() { * @param candidate, the transport to try * @param callback */ - ConnectionManager.prototype.tryATransport = function(transportParams, candidate, callback) { - var self = this, host = transportParams.host; + tryATransport(transportParams: TransportParams, candidate: string, callback: Function): void { Logger.logAction(Logger.LOG_MICRO, 'ConnectionManager.tryATransport()', 'trying ' + candidate); - (ConnectionManager.supportedTransports[candidate]).tryConnect(this, this.realtime.auth, transportParams, function(wrappedErr, transport) { - var state = self.state; - if(state == self.states.closing || state == self.states.closed || state == self.states.failed) { + (ConnectionManager.supportedTransports[candidate]).tryConnect?.(this, this.realtime.auth, transportParams, (wrappedErr: { error: ErrorInfo, event: string} | null, transport?: Transport) => { + const state = this.state; + if(state == this.states.closing || state == this.states.closed || state == this.states.failed) { if(transport) { Logger.logAction(Logger.LOG_MINOR, 'ConnectionManager.tryATransport()', 'connection ' + state.state + ' while we were attempting the transport; closing ' + transport); transport.close(); @@ -313,24 +402,24 @@ var ConnectionManager = (function() { /* Comet transport onconnect token errors can be dealt with here. * Websocket ones only happen after the transport claims to be viable, * so are dealt with as non-onconnect token errors */ - if(Auth.isTokenErr(wrappedErr.error) && !(self.errorReason && Auth.isTokenErr(self.errorReason))) { - self.errorReason = wrappedErr.error; + if(Auth.isTokenErr(wrappedErr.error) && !(this.errorReason && Auth.isTokenErr(this.errorReason as ErrorInfo))) { + this.errorReason = wrappedErr.error; /* re-get a token and try again */ - self.realtime.auth._forceNewToken(null, null, function(err) { + this.realtime.auth._forceNewToken(null, null, (err: ErrorInfo) => { if(err) { - self.actOnErrorFromAuthorize(err); + this.actOnErrorFromAuthorize(err); return; } - self.tryATransport(transportParams, candidate, callback); + this.tryATransport(transportParams, candidate, callback); }); } else if(wrappedErr.event === 'failed') { /* Error that's fatal to the connection */ - self.notifyState({state: 'failed', error: wrappedErr.error}); + this.notifyState({state: 'failed', error: wrappedErr.error}); callback(true); } else if(wrappedErr.event === 'disconnected') { - if(!ConnectionError.isRetriable(wrappedErr.error)) { + if(!isRetriable(wrappedErr.error)) { /* Error received from the server that does not call for trying a fallback host, eg a rate limit */ - self.notifyState({state: self.states.connecting.failState, error: wrappedErr.error}); + this.notifyState({state: this.states.connecting.failState as string, error: wrappedErr.error}); callback(true); } else { /* Error with that transport only; continue trying other fallback hosts */ @@ -341,60 +430,60 @@ var ConnectionManager = (function() { } Logger.logAction(Logger.LOG_MICRO, 'ConnectionManager.tryATransport()', 'viable transport ' + candidate + '; setting pending'); - self.setTransportPending(transport, transportParams); + this.setTransportPending(transport as Transport, transportParams); callback(null, transport); }); - }; + } /** - * Called when a transport is indicated to be viable, and the connectionmanager + * Called when a transport is indicated to be viable, and the ConnectionManager * expects to activate this transport as soon as it is connected. - * @param host + * @param transport * @param transportParams */ - ConnectionManager.prototype.setTransportPending = function(transport, transportParams) { - var mode = transportParams.mode; + setTransportPending(transport: Transport, transportParams: TransportParams): void { + const mode = transportParams.mode; Logger.logAction(Logger.LOG_MINOR, 'ConnectionManager.setTransportPending()', 'transport = ' + transport + '; mode = ' + mode); Utils.arrDeleteValue(this.proposedTransports, transport); this.pendingTransports.push(transport); - var self = this; - transport.once('connected', function(error, connectionId, connectionDetails, connectionPosition) { - if(mode == 'upgrade' && self.activeProtocol) { + transport.once('connected', (error: ErrorInfo, connectionId: string, connectionDetails: Record, connectionPosition: ConnectionManager) => { + if(mode == 'upgrade' && this.activeProtocol) { /* if ws and xhrs are connecting in parallel, delay xhrs activation to let ws go ahead */ - if(transport.shortName !== optimalTransport && Utils.arrIn(self.getUpgradePossibilities(), optimalTransport)) { - setTimeout(function() { - self.scheduleTransportActivation(error, transport, connectionId, connectionDetails, connectionPosition); - }, self.options.timeouts.parallelUpgradeDelay); + if(transport.shortName !== optimalTransport && Utils.arrIn(this.getUpgradePossibilities(), optimalTransport)) { + setTimeout(() => { + this.scheduleTransportActivation(error, transport, connectionId, connectionDetails, connectionPosition); + }, this.options.timeouts.parallelUpgradeDelay); } else { - self.scheduleTransportActivation(error, transport, connectionId, connectionDetails, connectionPosition); + this.scheduleTransportActivation(error, transport, connectionId, connectionDetails, connectionPosition); } } else { - self.activateTransport(error, transport, connectionId, connectionDetails, connectionPosition); + this.activateTransport(error, transport, connectionId, connectionDetails, connectionPosition); /* allow connectImpl to start the upgrade process if needed, but allow * other event handlers, including activating the transport, to run first */ - Utils.nextTick(function() { - self.connectImpl(transportParams); + Utils.nextTick(() => { + this.connectImpl(transportParams); }); } - if(mode === 'recover' && self.options.recover) { + if(mode === 'recover' && this.options.recover) { /* After a successful recovery, we unpersist, as a recovery key cannot * be used more than once */ - self.options.recover = null; - self.unpersistConnection(); + this.options.recover = null; + this.unpersistConnection(); } }); - transport.on(['disconnected', 'closed', 'failed'], function(error) { + const self = this; + transport.on(['disconnected', 'closed', 'failed'], function (this: { event: string }, error: ErrorInfo) { self.deactivateTransport(transport, this.event, error); }); this.emit('transport.pending', transport); - }; + } /** * Called when an upgrade transport is connected, @@ -403,18 +492,17 @@ var ConnectionManager = (function() { * @param transport * @param connectionId * @param connectionDetails - * @param connectedMessage + * @param upgradeConnectionPosition */ - ConnectionManager.prototype.scheduleTransportActivation = function(error, transport, connectionId, connectionDetails, upgradeConnectionPosition) { - var self = this, - currentTransport = this.activeProtocol && this.activeProtocol.getTransport(), - abandon = function() { + scheduleTransportActivation(error: ErrorInfo, transport: Transport, connectionId: string, connectionDetails: Record, upgradeConnectionPosition?: ConnectionManager): void { + const currentTransport = this.activeProtocol && this.activeProtocol.getTransport(), + abandon = () => { transport.disconnect(); - Utils.arrDeleteValue(self.pendingTransports, transport); + Utils.arrDeleteValue(this.pendingTransports, transport); }; if(this.state !== this.states.connected && this.state !== this.states.connecting) { - /* This is most likely to happen for the delayed xhrs, when xhrs and ws are scheduled in parallel*/ + /* This is most likely to happen for the delayed XHRs, when XHRs and ws are scheduled in parallel*/ Logger.logAction(Logger.LOG_MINOR, 'ConnectionManager.scheduleTransportActivation()', 'Current connection state (' + this.state.state + (this.state === this.states.synchronizing ? ', but with an upgrade already in progress' : '') + ') is not valid to upgrade in; abandoning upgrade to ' + transport.shortName); abandon(); return; @@ -428,8 +516,8 @@ var ConnectionManager = (function() { Logger.logAction(Logger.LOG_MINOR, 'ConnectionManager.scheduleTransportActivation()', 'Scheduling transport upgrade; transport = ' + transport); - this.realtime.channels.onceNopending(function(err) { - var oldProtocol; + this.realtime.channels.onceNopending((err: ErrorInfo) => { + let oldProtocol: Protocol | null; if(err) { Logger.logAction(Logger.LOG_ERROR, 'ConnectionManager.scheduleTransportActivation()', 'Unable to activate transport; transport = ' + transport + '; err = ' + err); return; @@ -442,15 +530,15 @@ var ConnectionManager = (function() { return; } - if(self.state === self.states.connected) { + if(this.state === this.states.connected) { Logger.logAction(Logger.LOG_MICRO, 'ConnectionManager.scheduleTransportActivation()', 'Currently connected, so temporarily pausing events until the upgrade is complete'); - self.state = self.states.synchronizing; - oldProtocol = self.activeProtocol; - } else if(self.state !== self.states.connecting) { + this.state = this.states.synchronizing; + oldProtocol = this.activeProtocol; + } else if(this.state !== this.states.connecting) { /* Note: upgrading from the connecting state is valid if the old active * transport was deactivated after the upgrade transport first connected; * see logic in deactivateTransport */ - Logger.logAction(Logger.LOG_MINOR, 'ConnectionManager.scheduleTransportActivation()', 'Current connection state (' + self.state.state + (self.state === self.states.synchronizing ? ', but with an upgrade already in progress' : '') + ') is not valid to upgrade in; abandoning upgrade to ' + transport.shortName); + Logger.logAction(Logger.LOG_MINOR, 'ConnectionManager.scheduleTransportActivation()', 'Current connection state (' + this.state.state + (this.state === this.states.synchronizing ? ', but with an upgrade already in progress' : '') + ') is not valid to upgrade in; abandoning upgrade to ' + transport.shortName); abandon(); return; } @@ -459,43 +547,43 @@ var ConnectionManager = (function() { * it's still an upgrade, realtime still expects a sync - it just needs to * be a sync with the new connection position. (And it * needs to be set in the library, which is done by activateTransport). */ - var connectionReset = connectionId !== self.connectionId, - syncPosition = connectionReset ? upgradeConnectionPosition : self; + const connectionReset = connectionId !== this.connectionId, + syncPosition = connectionReset ? upgradeConnectionPosition : this; if(connectionReset) { - Logger.logAction(Logger.LOG_ERROR, 'ConnectionManager.scheduleTransportActivation()', 'Upgrade resulted in new connectionId; resetting library connection position from ' + (self.timeSerial || self.connectionSerial) + ' to ' + (syncPosition.timeSerial || syncPosition.connectionSerial) + '; upgrade error was ' + error); + Logger.logAction(Logger.LOG_ERROR, 'ConnectionManager.scheduleTransportActivation()', 'Upgrade resulted in new connectionId; resetting library connection position from ' + (this.timeSerial || this.connectionSerial) + ' to ' + ((syncPosition as ConnectionManager).timeSerial || (syncPosition as ConnectionManager).connectionSerial) + '; upgrade error was ' + error); } Logger.logAction(Logger.LOG_MINOR, 'ConnectionManager.scheduleTransportActivation()', 'Syncing transport; transport = ' + transport); - self.sync(transport, syncPosition, function(syncErr, connectionId, postSyncPosition) { + this.sync(transport, syncPosition as ConnectionManager, (syncErr: Error, connectionId: string, postSyncPosition: ConnectionManager) => { /* If there's been some problem with syncing (and the connection hasn't * closed or something in the meantime), we have a problem -- we can't * just fall back on the old transport, as we don't know whether * realtime got the sync -- if it did, the old transport is no longer * valid. To be safe, we disconnect both and start again from scratch. */ if(syncErr) { - if(self.state === self.states.synchronizing) { + if(this.state === this.states.synchronizing) { Logger.logAction(Logger.LOG_ERROR, 'ConnectionManager.scheduleTransportActivation()', 'Unexpected error attempting to sync transport; transport = ' + transport + '; err = ' + syncErr); - self.disconnectAllTransports(); + this.disconnectAllTransports(); } return; } - var finishUpgrade = function() { + const finishUpgrade = () => { Logger.logAction(Logger.LOG_MINOR, 'ConnectionManager.scheduleTransportActivation()', 'Activating transport; transport = ' + transport); - self.activateTransport(error, transport, connectionId, connectionDetails, postSyncPosition); + this.activateTransport(error, transport, connectionId, connectionDetails, postSyncPosition); /* Restore pre-sync state. If state has changed in the meantime, * don't touch it -- since the websocket transport waits a tick before * disposing itself, it's possible for it to have happily synced * without err while, unknown to it, the connection has closed in the * meantime and the ws transport is scheduled for death */ - if(self.state === self.states.synchronizing) { + if(this.state === this.states.synchronizing) { Logger.logAction(Logger.LOG_MICRO, 'ConnectionManager.scheduleTransportActivation()', 'Pre-upgrade protocol idle, sending queued messages on upgraded transport; transport = ' + transport); - self.state = self.states.connected; + this.state = this.states.connected; } else { - Logger.logAction(Logger.LOG_MINOR, 'ConnectionManager.scheduleTransportActivation()', 'Pre-upgrade protocol idle, but state is now ' + self.state.state + ', so leaving unchanged'); + Logger.logAction(Logger.LOG_MINOR, 'ConnectionManager.scheduleTransportActivation()', 'Pre-upgrade protocol idle, but state is now ' + this.state.state + ', so leaving unchanged'); } - if(self.state.sendEvents) { - self.sendQueuedMessages(); + if(this.state.sendEvents) { + this.sendQueuedMessages(); } }; @@ -512,15 +600,15 @@ var ConnectionManager = (function() { * finish the upgrade. If we're actually in closing/failed rather than connecting, that's * fine, activatetransport will deal with that. */ if(oldProtocol) { - /* Most of the time this will be already true: the new-transport sync will have given - * enough time for in-flight messages on the old transport to complete. */ + /* Most of the time this will be already true: the new-transport sync will have given + * enough time for in-flight messages on the old transport to complete. */ oldProtocol.onceIdle(finishUpgrade); } else { finishUpgrade(); } }); }); - }; + } /** * Called when a transport is connected, and the connectionmanager decides that @@ -531,7 +619,7 @@ var ConnectionManager = (function() { * @param connectionDetails the details of the new active connection * @param connectionPosition the position at the point activation; either {connectionSerial: } or {timeSerial: } */ - ConnectionManager.prototype.activateTransport = function(error, transport, connectionId, connectionDetails, connectionPosition) { + activateTransport(error: ErrorInfo, transport: Transport, connectionId: string, connectionDetails: Record, connectionPosition: ConnectionManager): boolean { Logger.logAction(Logger.LOG_MINOR, 'ConnectionManager.activateTransport()', 'transport = ' + transport); if(error) { Logger.logAction(Logger.LOG_ERROR, 'ConnectionManager.activateTransport()', 'error = ' + error); @@ -550,7 +638,7 @@ var ConnectionManager = (function() { /* if the connectionmanager moved to the closing/closed state before this * connection event, then we won't activate this transport */ - var existingState = this.state, + const existingState = this.state, connectedState = this.states.connected.state; Logger.logAction(Logger.LOG_MINOR, 'ConnectionManager.activateTransport()', 'current state = ' + existingState.state); if(existingState.state == this.states.closing.state || existingState.state == this.states.closed.state || existingState.state == this.states.failed.state) { @@ -571,11 +659,11 @@ var ConnectionManager = (function() { /* the given transport is connected; this will immediately * take over as the active transport */ - var existingActiveProtocol = this.activeProtocol; + const existingActiveProtocol = this.activeProtocol; this.activeProtocol = new Protocol(transport); this.host = transport.params.host; - var connectionKey = connectionDetails.connectionKey; + const connectionKey = connectionDetails.connectionKey; if(connectionKey && this.connectionKey != connectionKey) { this.setConnection(connectionId, connectionDetails, connectionPosition, !!error); } @@ -586,11 +674,10 @@ var ConnectionManager = (function() { * callback at the moment; if we add it now we'll be adding it to the end * of the listeners array and it'll be called immediately) */ this.onConnectionDetailsUpdate(connectionDetails, transport); - var self = this; - Utils.nextTick(function() { - transport.on('connected', function(connectedErr, _connectionId, connectionDetails) { - self.onConnectionDetailsUpdate(connectionDetails, transport); - self.emit('update', new ConnectionStateChange(connectedState, connectedState, null, connectedErr)); + Utils.nextTick(() => { + transport.on('connected', (connectedErr: ErrorInfo, _connectionId: string, connectionDetails: Record) => { + this.onConnectionDetailsUpdate(connectionDetails, transport); + this.emit('update', new ConnectionStateChange(connectedState, connectedState, null, connectedErr)); }); }) @@ -623,7 +710,7 @@ var ConnectionManager = (function() { Logger.logAction(Logger.LOG_ERROR, 'ConnectionManager.activateTransport()', 'Previous active protocol (for transport ' + existingActiveProtocol.transport.shortName + ', new one is ' + transport.shortName + ') finishing with ' + existingActiveProtocol.messageQueue.count() + ' messages still pending'); } if(existingActiveProtocol.transport === transport) { - var msg = 'Assumption violated: activating a transport that was also the transport for the previous active protocol; transport = ' + transport.shortName + '; stack = ' + new Error().stack; + const msg = 'Assumption violated: activating a transport that was also the transport for the previous active protocol; transport = ' + transport.shortName + '; stack = ' + new Error().stack; Logger.logAction(Logger.LOG_ERROR, 'ConnectionManager.activateTransport()', msg); ErrorReporter.report('error', msg, 'transport-previously-active'); } else { @@ -633,37 +720,37 @@ var ConnectionManager = (function() { /* Terminate any other pending transport(s), and * abort any not-yet-pending transport attempts */ - Utils.safeArrForEach(this.pendingTransports, function(pendingTransport) { + Utils.safeArrForEach(this.pendingTransports, (pendingTransport) => { if(pendingTransport === transport) { - var msg = 'Assumption violated: activating a transport that is still marked as a pending transport; transport = ' + transport.shortName + '; stack = ' + new Error().stack; + const msg = 'Assumption violated: activating a transport that is still marked as a pending transport; transport = ' + transport.shortName + '; stack = ' + new Error().stack; Logger.logAction(Logger.LOG_ERROR, 'ConnectionManager.activateTransport()', msg); ErrorReporter.report('error', msg, 'transport-activating-pending'); - Utils.arrDeleteValue(self.pendingTransports, transport); + Utils.arrDeleteValue(this.pendingTransports, transport); } else { pendingTransport.disconnect(); } }); - Utils.safeArrForEach(this.proposedTransports, function(proposedTransport) { + Utils.safeArrForEach(this.proposedTransports, (proposedTransport: Transport) => { if(proposedTransport === transport) { - var msg = 'Assumption violated: activating a transport that is still marked as a proposed transport; transport = ' + transport.shortName + '; stack = ' + new Error().stack; + const msg = 'Assumption violated: activating a transport that is still marked as a proposed transport; transport = ' + transport.shortName + '; stack = ' + new Error().stack; Logger.logAction(Logger.LOG_ERROR, 'ConnectionManager.activateTransport()', msg); ErrorReporter.report('error', msg, 'transport-activating-proposed'); - Utils.arrDeleteValue(self.proposedTransports, transport); + Utils.arrDeleteValue(this.proposedTransports, transport); } else { proposedTransport.dispose(); } }); return true; - }; + } /** * Called when a transport is no longer the active transport. This can occur * in any transport connection state. * @param transport */ - ConnectionManager.prototype.deactivateTransport = function(transport, state, error) { - var currentProtocol = this.activeProtocol, + deactivateTransport(transport: Transport, state: string, error: ErrorInfo): void { + const currentProtocol = this.activeProtocol, wasActive = currentProtocol && currentProtocol.getTransport() === transport, wasPending = Utils.arrDeleteValue(this.pendingTransports, transport), wasProposed = Utils.arrDeleteValue(this.proposedTransports, transport), @@ -675,17 +762,17 @@ var ConnectionManager = (function() { Logger.logAction(Logger.LOG_MICRO, 'ConnectionManager.deactivateTransport()', 'reason = ' + error.message); if(wasActive) { - Logger.logAction(Logger.LOG_MICRO, 'ConnectionManager.deactivateTransport()', 'Getting, clearing, and requeuing ' + this.activeProtocol.messageQueue.count() + ' pending messages'); - this.queuePendingMessages(currentProtocol.getPendingMessages()); + Logger.logAction(Logger.LOG_MICRO, 'ConnectionManager.deactivateTransport()', 'Getting, clearing, and requeuing ' + (this.activeProtocol as Protocol).messageQueue.count() + ' pending messages'); + this.queuePendingMessages((currentProtocol as Protocol).getPendingMessages()); /* Clear any messages we requeue to allow the protocol to become idle. * In case of an upgrade, this will trigger an immediate activation of * the upgrade transport, so delay a tick so this transport can finish * deactivating */ Utils.nextTick(function() { - currentProtocol.clearPendingMessages(); + (currentProtocol as Protocol).clearPendingMessages(); }); this.activeProtocol = this.host = null; - clearTimeout(this.channelResumeCheckTimer); + clearTimeout(this.channelResumeCheckTimer as number); } this.emit('transport.inactive', transport); @@ -713,7 +800,7 @@ var ConnectionManager = (function() { * setting an instance variable to force fallback hosts to be used (if * any) here. Bit of a kludge, but no real better alternatives without * rewriting the entire thing */ - if(state === 'disconnected' && error && error.statusCode > 500 && this.httpHosts.length > 1) { + if(state === 'disconnected' && error && error.statusCode as number > 500 && this.httpHosts.length > 1) { this.unpersistTransportPreference(); this.forceFallbackHost = true; /* and try to connect again to try a fallback host without waiting for the usual 15s disconnectedRetryTimeout */ @@ -722,7 +809,7 @@ var ConnectionManager = (function() { } /* TODO remove below line once realtime sends token errors as DISCONNECTEDs */ - var newConnectionState = (state === 'failed' && Auth.isTokenErr(error)) ? 'disconnected' : state; + const newConnectionState = (state === 'failed' && Auth.isTokenErr(error)) ? 'disconnected' : state; this.notifyState({state: newConnectionState, error: error}); return; } @@ -741,30 +828,30 @@ var ConnectionManager = (function() { this.startTransitionTimer(this.states.connecting); this.notifyState({state: 'connecting', error: error}); } - }; + } /* Helper that returns true if there are no transports which are pending, * have been connected, and are just waiting for onceNoPending to fire before * being activated */ - ConnectionManager.prototype.noTransportsScheduledForActivation = function() { + noTransportsScheduledForActivation(): boolean { return Utils.isEmpty(this.pendingTransports) || this.pendingTransports.every(function(transport) { return !transport.isConnected; }); - }; + } /** * Called when activating a new transport, to ensure message delivery * on the new transport synchronises with the messages already received */ - ConnectionManager.prototype.sync = function(transport, requestedSyncPosition, callback) { - var timeout = setTimeout(function () { + sync(transport: Transport, requestedSyncPosition: ConnectionManager, callback: Function): void { + const timeout = setTimeout(function () { transport.off('sync'); callback(new ErrorInfo('Timeout waiting for sync response', 50000, 500)); }, this.options.timeouts.realtimeRequestTimeout); /* send sync request */ - var syncMessage = ProtocolMessage.fromValues({ + const syncMessage = ProtocolMessage.fromValues({ action: actions.SYNC, connectionKey: this.connectionKey }); @@ -776,21 +863,20 @@ var ConnectionManager = (function() { } transport.send(syncMessage); - transport.once('sync', function(connectionId, syncPosition) { + transport.once('sync', function(connectionId: string, syncPosition: ConnectionManager) { clearTimeout(timeout); callback(null, connectionId, syncPosition); }); - }; + } - ConnectionManager.prototype.setConnection = function(connectionId, connectionDetails, connectionPosition, hasConnectionError) { + setConnection(connectionId: string, connectionDetails: Record, connectionPosition: ConnectionManager, hasConnectionError?: boolean): void { /* if connectionKey changes but connectionId stays the same, then just a * transport change on the same connection. If connectionId changes, we're * on a new connection, with implications for msgSerial and channel state, * and resetting the connectionSerial position */ - var self = this; /* If no previous connectionId, don't reset the msgSerial as it may have * been set by recover data (unless the recover failed) */ - var prevConnId = this.connectionid, + const prevConnId = this.connectionId, connIdChanged = prevConnId && (prevConnId !== connectionId), recoverFailure = !prevConnId && hasConnectionError; if(connIdChanged || recoverFailure) { @@ -806,8 +892,8 @@ var ConnectionManager = (function() { * state will be updated and so that it will be applied after * Channels#onTransportUpdate, else channels will not have an ATTACHED * sent twice (once from this and once from that). */ - Utils.nextTick(function() { - self.realtime.channels.reattach(); + Utils.nextTick(() => { + this.realtime.channels.reattach(); }); } else if(this.options.checkChannelsOnResume) { /* For attached channels, set the attached msg indicator variable to false, @@ -816,35 +902,35 @@ var ConnectionManager = (function() { * time, in an attempt to avoid false positives due to a transport * silently failing immediately after a resume */ Logger.logAction(Logger.LOG_MINOR, 'ConnectionManager.setConnection()', 'Same connectionId; checkChannelsOnResume is enabled'); - clearTimeout(this.channelResumeCheckTimer); + clearTimeout(this.channelResumeCheckTimer as number); this.realtime.channels.resetAttachedMsgIndicators(); - this.channelResumeCheckTimer = setTimeout(function() { - self.realtime.channels.checkAttachedMsgIndicators(connectionId); + this.channelResumeCheckTimer = setTimeout(() => { + this.realtime.channels.checkAttachedMsgIndicators(connectionId); }, 30000); } this.realtime.connection.id = this.connectionId = connectionId; this.realtime.connection.key = this.connectionKey = connectionDetails.connectionKey; - var forceResetMessageSerial = connIdChanged || !prevConnId; + const forceResetMessageSerial = connIdChanged || !prevConnId; this.setConnectionSerial(connectionPosition, forceResetMessageSerial); - }; + } - ConnectionManager.prototype.clearConnection = function() { + clearConnection(): void { this.realtime.connection.id = this.connectionId = undefined; this.realtime.connection.key = this.connectionKey = undefined; this.clearConnectionSerial(); this.msgSerial = 0; this.unpersistConnection(); - }; + } /* force: set the connectionSerial even if it's less than the current * connectionSerial. Used for new connections. * Returns true iff the message was rejected as a duplicate. */ - ConnectionManager.prototype.setConnectionSerial = function(connectionPosition, force) { - var timeSerial = connectionPosition.timeSerial, + setConnectionSerial(connectionPosition: any, force?: boolean): void | true { + const timeSerial = connectionPosition.timeSerial, connectionSerial = connectionPosition.connectionSerial; Logger.logAction(Logger.LOG_MICRO, 'ConnectionManager.setConnectionSerial()', 'Updating connection serial; serial = ' + connectionSerial + '; timeSerial = ' + timeSerial + '; force = ' + force + '; previous = ' + this.connectionSerial); if(timeSerial !== undefined) { - if(timeSerial <= this.timeSerial && !force) { + if(timeSerial <= (this.timeSerial as number) && !force) { Logger.logAction(Logger.LOG_ERROR, 'ConnectionManager.setConnectionSerial()', 'received message with timeSerial ' + timeSerial + ', but current timeSerial is ' + this.timeSerial + '; assuming message is a duplicate and discarding it'); return true; } @@ -853,88 +939,88 @@ var ConnectionManager = (function() { return; } if(connectionSerial !== undefined) { - if(connectionSerial <= this.connectionSerial && !force) { + if(connectionSerial <= (this.connectionSerial as number) && !force) { Logger.logAction(Logger.LOG_ERROR, 'ConnectionManager.setConnectionSerial()', 'received message with connectionSerial ' + connectionSerial + ', but current connectionSerial is ' + this.connectionSerial + '; assuming message is a duplicate and discarding it'); return true; } this.realtime.connection.serial = this.connectionSerial = connectionSerial; this.setRecoveryKey(); } - }; + } - ConnectionManager.prototype.clearConnectionSerial = function() { + clearConnectionSerial(): void { this.realtime.connection.serial = this.connectionSerial = undefined; this.realtime.connection.timeSerial = this.timeSerial = undefined; this.clearRecoveryKey(); - }; + } - ConnectionManager.prototype.setRecoveryKey = function() { + setRecoveryKey(): void { this.realtime.connection.recoveryKey = this.connectionKey + ':' + (this.timeSerial || this.connectionSerial) + ':' + this.msgSerial; - }; + } - ConnectionManager.prototype.clearRecoveryKey = function() { + clearRecoveryKey(): void { this.realtime.connection.recoveryKey = null; - }; + } - ConnectionManager.prototype.checkConnectionStateFreshness = function() { + checkConnectionStateFreshness(): void { if(!this.lastActivity || !this.connectionId) { return; } - var sinceLast = Utils.now() - this.lastActivity; - if(sinceLast > this.connectionStateTtl + this.maxIdleInterval) { + const sinceLast = Utils.now() - this.lastActivity; + if(sinceLast > this.connectionStateTtl + (this.maxIdleInterval as number)) { Logger.logAction(Logger.LOG_MINOR, 'ConnectionManager.checkConnectionStateFreshness()', 'Last known activity from realtime was ' + sinceLast + 'ms ago; discarding connection state'); this.clearConnection(); this.states.connecting.failState = 'suspended'; this.states.connecting.queueEvents = false; } - }; + } /** * Called when the connectionmanager wants to persist transport * state for later recovery. Only applicable in the browser context. */ - ConnectionManager.prototype.persistConnection = function() { + persistConnection(): void { if(haveSessionStorage) { - var recoveryKey = this.realtime.connection.recoveryKey; + const recoveryKey = this.realtime.connection.recoveryKey; if(recoveryKey) { setSessionRecoverData({ recoveryKey: recoveryKey, disconnectedAt: Utils.now(), location: global.location, clientId: this.realtime.auth.clientId - }, this.connectionStateTtl); + }); } } - }; + } /** * Called when the connectionmanager wants to persist transport * state for later recovery. Only applicable in the browser context. */ - ConnectionManager.prototype.unpersistConnection = function() { + unpersistConnection(): void { clearSessionRecoverData(); - }; + } /********************* * state management *********************/ - ConnectionManager.prototype.getError = function() { + getError(): ErrorInfo | string { return this.errorReason || this.getStateError(); - }; + } - ConnectionManager.prototype.getStateError = function() { - return ConnectionError[this.state.state]; - }; + getStateError(): ErrorInfo { + return (ConnectionErrors as Record)[this.state.state]; + } - ConnectionManager.prototype.activeState = function() { + activeState(): boolean | void { return this.state.queueEvents || this.state.sendEvents; - }; + } - ConnectionManager.prototype.enactStateChange = function(stateChange) { - var logLevel = stateChange.current === 'failed' ? Logger.LOG_ERROR : Logger.LOG_MAJOR; + enactStateChange(stateChange: ConnectionStateChange): void { + const logLevel = stateChange.current === 'failed' ? Logger.LOG_ERROR : Logger.LOG_MAJOR; Logger.logAction(logLevel, 'Connection state', stateChange.current + (stateChange.reason ? ('; reason: ' + stateChange.reason) : '')); - Logger.logAction(Logger.LOG_MINOR, 'ConnectionManager.enactStateChange', 'setting new state: ' + stateChange.current + '; reason = ' + (stateChange.reason && stateChange.reason.message)); - var newState = this.state = this.states[stateChange.current]; + Logger.logAction(Logger.LOG_MINOR, 'ConnectionManager.enactStateChange', 'setting new state: ' + stateChange.current + '; reason = ' + (stateChange.reason && (stateChange.reason as ErrorInfo).message)); + const newState = this.state = this.states[stateChange.current as string]; if(stateChange.reason) { this.errorReason = stateChange.reason; this.realtime.connection.errorReason = stateChange.reason; @@ -946,86 +1032,82 @@ var ConnectionManager = (function() { this.clearConnection(); } this.emit('connectionstate', stateChange); - }; + } /**************************************** * ConnectionManager connection lifecycle ****************************************/ - ConnectionManager.prototype.startTransitionTimer = function(transitionState) { + startTransitionTimer(transitionState: ConnectionState): void { Logger.logAction(Logger.LOG_MINOR, 'ConnectionManager.startTransitionTimer()', 'transitionState: ' + transitionState.state); if(this.transitionTimer) { Logger.logAction(Logger.LOG_MINOR, 'ConnectionManager.startTransitionTimer()', 'clearing already-running timer'); - clearTimeout(this.transitionTimer); + clearTimeout(this.transitionTimer as number); } - var self = this; - this.transitionTimer = setTimeout(function() { - if(self.transitionTimer) { - self.transitionTimer = null; + this.transitionTimer = setTimeout(() => { + if(this.transitionTimer) { + this.transitionTimer = null; Logger.logAction(Logger.LOG_MINOR, 'ConnectionManager ' + transitionState.state + ' timer expired', 'requesting new state: ' + transitionState.failState); - self.notifyState({state: transitionState.failState}); + this.notifyState({state: transitionState.failState as string}); } }, transitionState.retryDelay); - }; + } - ConnectionManager.prototype.cancelTransitionTimer = function() { + cancelTransitionTimer(): void { Logger.logAction(Logger.LOG_MINOR, 'ConnectionManager.cancelTransitionTimer()', ''); if(this.transitionTimer) { - clearTimeout(this.transitionTimer); + clearTimeout(this.transitionTimer as number); this.transitionTimer = null; } - }; + } - ConnectionManager.prototype.startSuspendTimer = function() { - var self = this; + startSuspendTimer(): void { if(this.suspendTimer) return; - this.suspendTimer = setTimeout(function() { - if(self.suspendTimer) { - self.suspendTimer = null; + this.suspendTimer = setTimeout(() => { + if(this.suspendTimer) { + this.suspendTimer = null; Logger.logAction(Logger.LOG_MINOR, 'ConnectionManager suspend timer expired', 'requesting new state: suspended'); - self.states.connecting.failState = 'suspended'; - self.states.connecting.queueEvents = false; - self.notifyState({state: 'suspended'}); + this.states.connecting.failState = 'suspended'; + this.states.connecting.queueEvents = false; + this.notifyState({state: 'suspended'}); } }, this.connectionStateTtl); - }; + } - ConnectionManager.prototype.checkSuspendTimer = function(state) { + checkSuspendTimer(state: string): void { if(state !== 'disconnected' && state !== 'suspended' && state !== 'connecting') this.cancelSuspendTimer(); - }; + } - ConnectionManager.prototype.cancelSuspendTimer = function() { + cancelSuspendTimer(): void { this.states.connecting.failState = 'disconnected'; this.states.connecting.queueEvents = true; if(this.suspendTimer) { - clearTimeout(this.suspendTimer); + clearTimeout(this.suspendTimer as number); this.suspendTimer = null; } - }; + } - ConnectionManager.prototype.startRetryTimer = function(interval) { - var self = this; - this.retryTimer = setTimeout(function() { + startRetryTimer(interval: number): void { + this.retryTimer = setTimeout(() => { Logger.logAction(Logger.LOG_MINOR, 'ConnectionManager retry timer expired', 'retrying'); - self.retryTimer = null; - self.requestState({state: 'connecting'}); + this.retryTimer = null; + this.requestState({state: 'connecting'}); }, interval); - }; + } - ConnectionManager.prototype.cancelRetryTimer = function() { + cancelRetryTimer(): void { if(this.retryTimer) { - clearTimeout(this.retryTimer); + clearTimeout(this.retryTimer as NodeJS.Timeout); this.retryTimer = null; } - }; + } - ConnectionManager.prototype.notifyState = function(indicated) { - var state = indicated.state, - self = this; + notifyState(indicated: ConnectionState): void { + const state = indicated.state; /* We retry immediately if: * - something disconnects us while we're connected, or @@ -1035,13 +1117,13 @@ var ConnectionManager = (function() { * then there has been at least one previous attempt to connect that also * failed for a token error, so by RTN14b we go to DISCONNECTED and wait * before trying again */ - var retryImmediately = (state === 'disconnected' && + const retryImmediately = (state === 'disconnected' && (this.state === this.states.connected || - this.state === this.states.synchronizing || - indicated.retryImmediately || + this.state === this.states.synchronizing || + indicated.retryImmediately || (this.state === this.states.connecting && indicated.error && Auth.isTokenErr(indicated.error) && - !(this.errorReason && Auth.isTokenErr(this.errorReason))))); + !(this.errorReason && Auth.isTokenErr(this.errorReason as ErrorInfo))))); Logger.logAction(Logger.LOG_MINOR, 'ConnectionManager.notifyState()', 'new state: ' + state + (retryImmediately ? '; will retry connection immediately' : '')); /* do nothing if we're already in the indicated state */ @@ -1059,17 +1141,17 @@ var ConnectionManager = (function() { return; /* process new state */ - var newState = this.states[indicated.state], - change = new ConnectionStateChange(this.state.state, newState.state, newState.retryDelay, (indicated.error || ConnectionError[newState.state])); + const newState = this.states[indicated.state], + change = new ConnectionStateChange(this.state.state, newState.state, newState.retryDelay, (indicated.error || (ConnectionErrors as Record)[newState.state])); if(retryImmediately) { - var autoReconnect = function() { - if(self.state === self.states.disconnected) { - self.lastAutoReconnectAttempt = Utils.now(); - self.requestState({state: 'connecting'}); + const autoReconnect = () => { + if(this.state === this.states.disconnected) { + this.lastAutoReconnectAttempt = Utils.now(); + this.requestState({state: 'connecting'}); } }; - var sinceLast = this.lastAutoReconnectAttempt && (Utils.now() - this.lastAutoReconnectAttempt + 1); + const sinceLast = this.lastAutoReconnectAttempt && (Utils.now() - this.lastAutoReconnectAttempt + 1); if(sinceLast && (sinceLast < 1000)) { Logger.logAction(Logger.LOG_MICRO, 'ConnectionManager.notifyState()', 'Last reconnect attempt was only ' + sinceLast + 'ms ago, waiting another ' + (1000 - sinceLast) + 'ms before trying again'); setTimeout(autoReconnect, 1000 - sinceLast); @@ -1077,20 +1159,20 @@ var ConnectionManager = (function() { Utils.nextTick(autoReconnect); } } else if(state === 'disconnected' || state === 'suspended') { - this.startRetryTimer(newState.retryDelay); + this.startRetryTimer(newState.retryDelay as number); } - /* If going into disconnect/suspended (and not retrying immediately), or a + /* If going into disconnect/suspended (and not retrying immediately), or a * terminal state, ensure there are no orphaned transports hanging around. */ if((state === 'disconnected' && !retryImmediately) || - (state === 'suspended') || - newState.terminal) { - /* Wait till the next tick so the connection state change is enacted, + (state === 'suspended') || + newState.terminal) { + /* Wait till the next tick so the connection state change is enacted, * so aborting transports doesn't trigger redundant state changes */ - Utils.nextTick(function() { - self.disconnectAllTransports(); - }); - } + Utils.nextTick(() => { + this.disconnectAllTransports(); + }); + } if(state == 'connected' && !this.activeProtocol) { Logger.logAction(Logger.LOG_ERROR, 'ConnectionManager.notifyState()', 'Broken invariant: attempted to go into connected state, but there is no active protocol'); @@ -1102,12 +1184,12 @@ var ConnectionManager = (function() { this.sendQueuedMessages(); } else if(!this.state.queueEvents) { this.realtime.channels.propogateConnectionInterruption(state, change.reason); - this.failQueuedMessages(change.reason); // RTN7c + this.failQueuedMessages(change.reason as ErrorInfo); // RTN7c } - }; + } - ConnectionManager.prototype.requestState = function(request) { - var state = request.state, self = this; + requestState(request: any): void { + const state = request.state; Logger.logAction(Logger.LOG_MINOR, 'ConnectionManager.requestState()', 'requested state: ' + state + '; current state: ' + this.state.state); if(state == this.state.state) return; /* silently do nothing */ @@ -1122,28 +1204,27 @@ var ConnectionManager = (function() { if(state == 'connecting' && this.state.state == 'connected') return; if(state == 'closing' && this.state.state == 'closed') return; - var newState = this.states[state], - change = new ConnectionStateChange(this.state.state, newState.state, null, (request.error || ConnectionError[newState.state])); + const newState = this.states[state], + change = new ConnectionStateChange(this.state.state, newState.state, null, (request.error || (ConnectionErrors as Record)[newState.state])); this.enactStateChange(change); if(state == 'connecting') { - Utils.nextTick(function() { self.startConnect(); }); + Utils.nextTick(() => { this.startConnect(); }); } if(state == 'closing') { this.closeImpl(); } - }; + } - ConnectionManager.prototype.startConnect = function() { + startConnect(): void { if(this.state !== this.states.connecting) { Logger.logAction(Logger.LOG_MINOR, 'ConnectionManager.startConnect()', 'Must be in connecting state to connect, but was ' + this.state.state); return; } - var auth = this.realtime.auth, - self = this; + const auth = this.realtime.auth; /* The point of the connectCounter mechanism is to ensure that the * connection procedure can be cancelled. We want disconnectAllTransports @@ -1151,15 +1232,15 @@ var ConnectionManager = (function() { * the stage of having a pending (or even a proposed) transport that it can * dispose() of. So we check that it's still current after any async stage, * up until the stage that is synchronous with instantiating a transport */ - var connectCount = ++this.connectCounter; + const connectCount = ++this.connectCounter; - var connect = function() { - self.checkConnectionStateFreshness(); - self.getTransportParams(function(transportParams) { - if(connectCount !== self.connectCounter) { + const connect = () => { + this.checkConnectionStateFreshness(); + this.getTransportParams((transportParams: TransportParams) => { + if(connectCount !== this.connectCounter) { return; } - self.connectImpl(transportParams, connectCount); + this.connectImpl(transportParams, connectCount); }); }; @@ -1170,24 +1251,24 @@ var ConnectionManager = (function() { if(auth.method === 'basic') { connect(); } else { - var authCb = function(err) { - if(connectCount !== self.connectCounter) { + const authCb = (err: ErrorInfo | null) => { + if(connectCount !== this.connectCounter) { return; } if(err) { - self.actOnErrorFromAuthorize(err); + this.actOnErrorFromAuthorize(err); } else { connect(); } }; - if(this.errorReason && Auth.isTokenErr(this.errorReason)) { + if(this.errorReason && Auth.isTokenErr(this.errorReason as ErrorInfo)) { /* Force a refetch of a new token */ auth._forceNewToken(null, null, authCb); } else { auth._ensureValidAuthCredentials(false, authCb); } } - }; + } /** * There are three stages in connecting: @@ -1208,8 +1289,8 @@ var ConnectionManager = (function() { * and dispatches accordingly. After a transport has been set pending, * tryATransport calls connectImpl to see if there's another stage to be done. * */ - ConnectionManager.prototype.connectImpl = function(transportParams, connectCount) { - var state = this.state.state; + connectImpl(transportParams: TransportParams, connectCount?: number): void { + const state = this.state.state; if(state !== this.states.connecting.state && state !== this.states.connected.state) { /* Only keep trying as long as in the 'connecting' state (or 'connected' @@ -1225,13 +1306,12 @@ var ConnectionManager = (function() { } else { this.connectBase(transportParams, connectCount); } - }; + } - ConnectionManager.prototype.connectPreference = function(transportParams) { - var preference = this.getTransportPreference(), - self = this, - preferenceTimeoutExpired = false; + connectPreference(transportParams: TransportParams): void { + const preference = this.getTransportPreference(); + let preferenceTimeoutExpired = false; if(!Utils.arrIn(this.transports, preference)) { this.unpersistTransportPreference(); @@ -1240,23 +1320,23 @@ var ConnectionManager = (function() { Logger.logAction(Logger.LOG_MINOR, 'ConnectionManager.connectPreference()', 'Trying to connect with stored transport preference ' + preference); - var preferenceTimeout = setTimeout(function() { + const preferenceTimeout = setTimeout(() => { preferenceTimeoutExpired = true; - if(!(self.state.state === self.states.connected.state)) { + if(!(this.state.state === this.states.connected.state)) { Logger.logAction(Logger.LOG_MINOR, 'ConnectionManager.connectPreference()', 'Shortcircuit connection attempt with ' + preference + ' failed; clearing preference and trying from scratch'); /* Abort all connection attempts. (This also disconnects the active * protocol, but none exists if we're not in the connected state) */ - self.disconnectAllTransports(); + this.disconnectAllTransports(); /* Be quite agressive about clearing the stored preference if ever it doesn't work */ - self.unpersistTransportPreference(); + this.unpersistTransportPreference(); } - self.connectImpl(transportParams); + this.connectImpl(transportParams); }, this.options.timeouts.preferenceConnectTimeout); /* For connectPreference, just use the main host. If host fallback is needed, do it in connectBase. * The wstransport it will substitute the httphost for an appropriate wshost */ - transportParams.host = self.httpHosts[0]; - self.tryATransport(transportParams, preference, function(fatal, transport) { + transportParams.host = this.httpHosts[0]; + this.tryATransport(transportParams, preference, (fatal: boolean, transport: Transport) => { clearTimeout(preferenceTimeout); if(preferenceTimeoutExpired && transport) { /* Viable, but too late - connectImpl() will already be trying @@ -1267,12 +1347,12 @@ var ConnectionManager = (function() { Utils.arrDeleteValue(this.pendingTransports, transport); } else if(!transport && !fatal) { /* Preference failed in a transport-specific way. Try more */ - self.unpersistTransportPreference(); - self.connectImpl(transportParams); + this.unpersistTransportPreference(); + this.connectImpl(transportParams); } /* If suceeded, or failed fatally, nothing to do */ }); - }; + } /** @@ -1282,14 +1362,13 @@ var ConnectionManager = (function() { * fallback hosts if applicable. * @param transportParams */ - ConnectionManager.prototype.connectBase = function(transportParams, connectCount) { - var self = this, - giveUp = function(err) { - self.notifyState({state: self.states.connecting.failState, error: err}); - }, - candidateHosts = this.httpHosts.slice(), - hostAttemptCb = function(fatal, transport) { - if(connectCount !== self.connectCounter) { + connectBase(transportParams: TransportParams, connectCount?: number): void { + const giveUp = (err: ErrorInfo) => { + this.notifyState({state: this.states.connecting.failState as string, error: err}); + }; + const candidateHosts = this.httpHosts.slice(); + const hostAttemptCb = (fatal: boolean, transport: Transport) => { + if(connectCount !== this.connectCounter) { return; } if(!transport && !fatal) { @@ -1300,7 +1379,7 @@ var ConnectionManager = (function() { Logger.logAction(Logger.LOG_MINOR, 'ConnectionManager.connectBase()', 'Trying to connect with base transport ' + this.baseTransport); /* first try to establish a connection with the priority host with http transport */ - var host = candidateHosts.shift(); + const host = candidateHosts.shift(); if(!host) { giveUp(new ErrorInfo('Unable to connect (no available host)', 80003, 404)); return; @@ -1308,7 +1387,7 @@ var ConnectionManager = (function() { transportParams.host = host; /* this is what we'll be doing if the attempt for the main host fails */ - function tryFallbackHosts() { + const tryFallbackHosts = () => { /* if there aren't any fallback hosts, fail */ if(!candidateHosts.length) { giveUp(new ErrorInfo('Unable to connect (and no more fallback hosts to try)', 80003, 404)); @@ -1317,8 +1396,12 @@ var ConnectionManager = (function() { /* before trying any fallback (or any remaining fallback) we decide if * there is a problem with the ably host, or there is a general connectivity * problem */ - Http.checkConnectivity(function(err, connectivity) { - if(connectCount !== self.connectCounter) { + if (!Http.checkConnectivity) { + giveUp(new ErrorInfo('Internal error: Http.checkConnectivity not set', null, 500)); + return; + } + Http.checkConnectivity((err?: ErrorInfo | null, connectivity?: boolean) => { + if(connectCount !== this.connectCounter) { return; } /* we know err won't happen but handle it here anyway */ @@ -1335,7 +1418,7 @@ var ConnectionManager = (function() { * its dns. Try the fallback hosts. We could try them simultaneously but * that would potentially cause a huge spike in load on the load balancer */ transportParams.host = Utils.arrPopRandomElement(candidateHosts); - self.tryATransport(transportParams, self.baseTransport, hostAttemptCb); + this.tryATransport(transportParams, this.baseTransport, hostAttemptCb); }); } @@ -1346,37 +1429,36 @@ var ConnectionManager = (function() { } this.tryATransport(transportParams, this.baseTransport, hostAttemptCb); - }; + } - ConnectionManager.prototype.getUpgradePossibilities = function() { + getUpgradePossibilities(): string[] { /* returns the subset of upgradeTransports to the right of the current * transport in upgradeTransports (if it's in there - if not, currentPosition * will be -1, so return upgradeTransports.slice(0) == upgradeTransports */ - var current = this.activeProtocol.getTransport().shortName; - var currentPosition = Utils.arrIndexOf(this.upgradeTransports, current); - return this.upgradeTransports.slice(currentPosition + 1); - }; + const current = (this.activeProtocol as Protocol).getTransport().shortName; + const currentPosition = Utils.arrIndexOf(this.upgradeTransports, current); + return this.upgradeTransports.slice(currentPosition + 1) as string[]; + } - ConnectionManager.prototype.upgradeIfNeeded = function(transportParams) { - var upgradePossibilities = this.getUpgradePossibilities(), - self = this; + upgradeIfNeeded(transportParams: Record): void { + const upgradePossibilities = this.getUpgradePossibilities(); Logger.logAction(Logger.LOG_MINOR, 'ConnectionManager.upgradeIfNeeded()', 'upgrade possibilities: ' + Utils.inspect(upgradePossibilities)); if(!upgradePossibilities.length) { return; } - Utils.arrForEach(upgradePossibilities, function(upgradeTransport) { + Utils.arrForEach(upgradePossibilities, (upgradeTransport: string) => { /* Note: the transport may mutate the params, so give each transport a fresh one */ - var upgradeTransportParams = self.createTransportParams(transportParams.host, 'upgrade'); - self.tryATransport(upgradeTransportParams, upgradeTransport, noop); + const upgradeTransportParams = this.createTransportParams(transportParams.host, 'upgrade'); + this.tryATransport(upgradeTransportParams, upgradeTransport, noop); }); - }; + } - ConnectionManager.prototype.closeImpl = function() { + closeImpl(): void { Logger.logAction(Logger.LOG_MINOR, 'ConnectionManager.closeImpl()', 'closing connection'); this.cancelSuspendTimer(); this.startTransitionTimer(this.states.closing); @@ -1399,10 +1481,9 @@ var ConnectionManager = (function() { /* If there was an active transport, this will probably be * preempted by the notifyState call in deactivateTransport */ this.notifyState({state: 'closed'}); - }; + } - ConnectionManager.prototype.onAuthUpdated = function(tokenDetails, callback) { - var self = this; + onAuthUpdated(tokenDetails: API.Types.TokenDetails, callback: Function): void { switch(this.state.state) { case 'connected': Logger.logAction(Logger.LOG_MICRO, 'ConnectionManager.onAuthUpdated()', 'Sending AUTH message on active transport'); @@ -1412,20 +1493,23 @@ var ConnectionManager = (function() { * avoid a race condition. (If it has started syncing, the AUTH will be * queued until the upgrade is complete, so everything's fine) */ if((this.pendingTransports.length || this.proposedTransports.length) && - self.state !== self.states.synchronizing) { + this.state !== this.states.synchronizing) { this.disconnectAllTransports(/* exceptActive: */true); - var transportParams = this.activeProtocol.getTransport().params; - Utils.nextTick(function() { - if(self.state.state === 'connected') { - self.upgradeIfNeeded(transportParams); + const transportParams = (this.activeProtocol as Protocol).getTransport().params; + Utils.nextTick(() => { + if(this.state.state === 'connected') { + this.upgradeIfNeeded(transportParams); } }); } /* Do any transport-specific new-token action */ - this.activeProtocol.getTransport().onAuthUpdated(tokenDetails); + const activeTransport = this.activeProtocol?.getTransport(); + if (activeTransport && activeTransport.onAuthUpdated) { + activeTransport.onAuthUpdated(tokenDetails); + } - var authMsg = ProtocolMessage.fromValues({ + const authMsg = ProtocolMessage.fromValues({ action: actions.AUTH, auth: { accessToken: tokenDetails.token @@ -1434,17 +1518,17 @@ var ConnectionManager = (function() { this.send(authMsg); /* The answer will come back as either a connectiondetails event - * (realtime sends a CONNECTED to asknowledge the reauth) or a + * (realtime sends a CONNECTED to acknowledge the reauth) or a * statechange to failed */ - var successListener = function() { - self.off(failureListener); + const successListener = () => { + this.off(failureListener); callback(null, tokenDetails); }; - var failureListener = function(stateChange) { + const failureListener = (stateChange: ConnectionStateChange) => { if(stateChange.current === 'failed') { - self.off(successListener); - self.off(failureListener); - callback(stateChange.reason || self.getStateError()); + this.off(successListener); + this.off(failureListener); + callback(stateChange.reason || this.getStateError()); } }; this.once('connectiondetails', successListener); @@ -1460,35 +1544,35 @@ var ConnectionManager = (function() { default: Logger.logAction(Logger.LOG_MICRO, 'ConnectionManager.onAuthUpdated()', 'Connection state is ' + this.state.state + '; waiting until either connected or failed'); - var listener = function(stateChange) { + const listener = (stateChange: ConnectionStateChange) => { switch(stateChange.current) { case 'connected': - self.off(listener); + this.off(listener); callback(null, tokenDetails); break; case 'failed': case 'closed': case 'suspended': - self.off(listener); - callback(stateChange.reason || self.getStateError()); + this.off(listener); + callback(stateChange.reason || this.getStateError()); break; default: /* ignore till we get either connected or failed */ break; } }; - self.on('connectionstate', listener); + this.on('connectionstate', listener); if(this.state.state === 'connecting') { /* can happen if in the connecting state but no transport was pending * yet, so disconnectAllTransports did not trigger a disconnected state */ - self.startConnect(); + this.startConnect(); } else { - self.requestState({state: 'connecting'}); + this.requestState({state: 'connecting'}); } } - }; + } - ConnectionManager.prototype.disconnectAllTransports = function(exceptActive) { + disconnectAllTransports(exceptActive?: boolean): void { Logger.logAction(Logger.LOG_MINOR, 'ConnectionManager.disconnectAllTransports()', 'Disconnecting all transports' + (exceptActive ? ' except the active transport' : '')); /* This will prevent any connection procedure in an async part of one of its early stages from continuing */ @@ -1512,24 +1596,24 @@ var ConnectionManager = (function() { } /* No need to notify state disconnected; disconnecting the active transport * will have that effect */ - }; + } /****************** * event queueing ******************/ - ConnectionManager.prototype.send = function(msg, queueEvent, callback) { + send(msg: ProtocolMessage, queueEvent?: boolean, callback?: ErrCallback): void { callback = callback || noop; - var state = this.state; + const state = this.state; if(state.sendEvents) { Logger.logAction(Logger.LOG_MICRO, 'ConnectionManager.send()', 'sending event'); this.sendImpl(new PendingMessage(msg, callback)); return; } - var shouldQueue = (queueEvent && state.queueEvents) || state.forceQueueEvents; + const shouldQueue = (queueEvent && state.queueEvents) || state.forceQueueEvents; if(!shouldQueue) { - var err = 'rejecting event, queueEvent was ' + queueEvent + ', state was ' + state.state; + const err = 'rejecting event, queueEvent was ' + queueEvent + ', state was ' + state.state; Logger.logAction(Logger.LOG_MICRO, 'ConnectionManager.send()', err); callback(this.errorReason || new ErrorInfo(err, 90000, 400)); return; @@ -1538,10 +1622,10 @@ var ConnectionManager = (function() { Logger.logAction(Logger.LOG_MICRO, 'ConnectionManager.send()', 'queueing msg; ' + ProtocolMessage.stringify(msg)); } this.queue(msg, callback); - }; + } - ConnectionManager.prototype.sendImpl = function(pendingMessage) { - var msg = pendingMessage.message; + sendImpl(pendingMessage: PendingMessage): void { + const msg = pendingMessage.message; /* If have already attempted to send this, resend with the same msgSerial, * so Ably can dedup if the previous send succeeded */ if(pendingMessage.ackRequired && !pendingMessage.sendAttempted) { @@ -1549,90 +1633,54 @@ var ConnectionManager = (function() { this.setRecoveryKey(); } try { - this.activeProtocol.send(pendingMessage); + (this.activeProtocol as Protocol).send(pendingMessage); } catch(e) { - Logger.logAction(Logger.LOG_ERROR, 'ConnectionManager.sendImpl()', 'Unexpected exception in transport.send(): ' + e.stack); - } - }; - - function bundleWith(dest, src, maxSize) { - var action; - if(dest.channel !== src.channel) { - /* RTL6d3 */ - return false; - } - if((action = dest.action) !== actions.PRESENCE && action !== actions.MESSAGE) { - /* RTL6d - can only bundle messages or presence */ - return false; - } - if(action !== src.action) { - /* RTL6d4 */ - return false; - } - var kind = (action === actions.PRESENCE) ? 'presence' : 'messages', - proposed = dest[kind].concat(src[kind]), - size = Message.getMessagesSize(proposed); - if(size > maxSize) { - /* RTL6d1 */ - return false; - } - if(!Utils.allSame(proposed, 'clientId')) { - /* RTL6d2 */ - return false; - } - if(!Utils.arrEvery(proposed, function(msg) { - return !msg.id; - })) { - /* RTL6d7 */ - return false; + Logger.logAction(Logger.LOG_ERROR, 'ConnectionManager.sendImpl()', 'Unexpected exception in transport.send(): ' + (e as Error).stack); } - /* we're good to go! */ - dest[kind] = proposed; - return true; - }; + } - ConnectionManager.prototype.queue = function(msg, callback) { + queue(msg: ProtocolMessage, callback: ErrCallback): void { Logger.logAction(Logger.LOG_MICRO, 'ConnectionManager.queue()', 'queueing event'); - var lastQueued = this.queuedMessages.last(); - var maxSize = this.options.maxMessageSize; + const lastQueued = this.queuedMessages.last(); + const maxSize = this.options.maxMessageSize; /* If have already attempted to send a message, don't merge more messages * into it, as if the previous send actually succeeded and realtime ignores * the dup, they'll be lost */ if(lastQueued && !lastQueued.sendAttempted && bundleWith(lastQueued.message, msg, maxSize)) { if(!lastQueued.merged) { - lastQueued.callback = Multicaster([lastQueued.callback]); + lastQueued.callback = Multicaster.create([lastQueued.callback as any]); lastQueued.merged = true; } - lastQueued.callback.push(callback); + (lastQueued.callback as MulticasterInstance).push(callback as any); } else { this.queuedMessages.push(new PendingMessage(msg, callback)); } - }; + } - ConnectionManager.prototype.sendQueuedMessages = function() { + sendQueuedMessages(): void { Logger.logAction(Logger.LOG_MICRO, 'ConnectionManager.sendQueuedMessages()', 'sending ' + this.queuedMessages.count() + ' queued messages'); - var pendingMessage; + let pendingMessage; while(pendingMessage = this.queuedMessages.shift()) this.sendImpl(pendingMessage); - }; + } - ConnectionManager.prototype.queuePendingMessages = function(pendingMessages) { + queuePendingMessages(pendingMessages: Array): void { if(pendingMessages && pendingMessages.length) { Logger.logAction(Logger.LOG_MICRO, 'ConnectionManager.queuePendingMessages()', 'queueing ' + pendingMessages.length + ' pending messages'); this.queuedMessages.prepend(pendingMessages); } - }; + } - ConnectionManager.prototype.failQueuedMessages = function(err) { - var numQueued = this.queuedMessages.count(); + failQueuedMessages(err: ErrorInfo): void { + const numQueued = this.queuedMessages.count(); if(numQueued > 0) { Logger.logAction(Logger.LOG_ERROR, 'ConnectionManager.failQueuedMessages()', 'failing ' + numQueued + ' queued messages, err = ' + Utils.inspectError(err)); this.queuedMessages.completeAllMessages(err); } - }; + } - ConnectionManager.prototype.onChannelMessage = function(message, transport) { - var onActiveTransport = this.activeProtocol && transport === this.activeProtocol.getTransport(), + onChannelMessage(message: ProtocolMessage, transport: Transport): void { + const onActiveTransport = this.activeProtocol && transport === this.activeProtocol.getTransport(), onUpgradeTransport = Utils.arrIn(this.pendingTransports, transport) && this.state == this.states.synchronizing, notControlMsg = message.action === actions.MESSAGE || message.action === actions.PRESENCE; @@ -1641,7 +1689,7 @@ var ConnectionManager = (function() { * idle), message can validly arrive on it even though it isn't active */ if(onActiveTransport || onUpgradeTransport) { if(notControlMsg) { - var suppressed = this.setConnectionSerial(message); + const suppressed = this.setConnectionSerial(message); if(suppressed) { return; } @@ -1662,31 +1710,31 @@ var ConnectionManager = (function() { Logger.logAction(Logger.LOG_MICRO, 'ConnectionManager.onChannelMessage()', 'received message ' + JSON.stringify(message) + 'on defunct transport; discarding'); } } - }; + } - ConnectionManager.prototype.ping = function(transport, callback) { + ping(transport: Transport | null, callback: Function): void { /* if transport is specified, try that */ if(transport) { Logger.logAction(Logger.LOG_MINOR, 'ConnectionManager.ping()', 'transport = ' + transport); - var onTimeout = function () { + const onTimeout = function () { transport.off('heartbeat', onHeartbeat); callback(new ErrorInfo('Timeout waiting for heartbeat response', 50000, 500)); }; - var pingStart = Utils.now(), + const pingStart = Utils.now(), id = Utils.cheapRandStr(); - var onHeartbeat = function (responseId) { + const onHeartbeat = function (responseId: string) { if(responseId === id) { transport.off('heartbeat', onHeartbeat); clearTimeout(timer); - var responseTime = Utils.now() - pingStart; + const responseTime = Utils.now() - pingStart; callback(null, responseTime); } }; - var timer = setTimeout(onTimeout, this.options.timeouts.realtimeRequestTimeout); + const timer = setTimeout(onTimeout, this.options.timeouts.realtimeRequestTimeout); transport.on('heartbeat', onHeartbeat); transport.ping(id); @@ -1701,78 +1749,78 @@ var ConnectionManager = (function() { /* no transport was specified, so use the current (connected) one * but ensure that we retry if the transport is superseded before we complete */ - var completed = false, self = this; + let completed = false; - var onPingComplete = function(err, responseTime) { - self.off('transport.active', onTransportActive); + const onPingComplete = (err: Error, responseTime: number) => { + this.off('transport.active', onTransportActive); if(!completed) { completed = true; callback(err, responseTime); } }; - var onTransportActive = function() { + const onTransportActive = () => { if(!completed) { /* ensure that no callback happens for the currently outstanding operation */ completed = true; /* repeat but picking up the new transport */ - Utils.nextTick(function() { - self.ping(null, callback); + Utils.nextTick(() => { + this.ping(null, callback); }); } }; this.on('transport.active', onTransportActive); - this.ping(this.activeProtocol.getTransport(), onPingComplete); - }; + this.ping((this.activeProtocol as Protocol).getTransport(), onPingComplete); + } - ConnectionManager.prototype.abort = function(error) { - this.activeProtocol.getTransport().fail(error); - }; + abort(error: ErrorInfo): void { + (this.activeProtocol as Protocol).getTransport().fail(error); + } - ConnectionManager.prototype.registerProposedTransport = function(transport) { + registerProposedTransport(transport: Transport): void { this.proposedTransports.push(transport); - }; + } - ConnectionManager.prototype.getTransportPreference = function() { - return this.transportPreference || (haveWebStorage && WebStorage.get(transportPreferenceName)); - }; + getTransportPreference(): string { + return this.transportPreference || (haveWebStorage && WebStorage.get?.(transportPreferenceName)); + } - ConnectionManager.prototype.persistTransportPreference = function(transport) { + persistTransportPreference(transport: Transport): void { if(Utils.arrIn(Defaults.upgradeTransports, transport.shortName)) { this.transportPreference = transport.shortName; if(haveWebStorage) { - WebStorage.set(transportPreferenceName, transport.shortName); + WebStorage.set?.(transportPreferenceName, transport.shortName); } } - }; + } - ConnectionManager.prototype.unpersistTransportPreference = function() { + unpersistTransportPreference(): void { this.transportPreference = null; if(haveWebStorage) { - WebStorage.remove(transportPreferenceName); + WebStorage.remove?.(transportPreferenceName); } - }; + } /* This method is only used during connection attempts, so implements RSA4c1, - * RSA4c2, and RSA4d. In particular it is not invoked for + * RSA4c2, and RSA4d. In particular, it is not invoked for * serverside-triggered reauths or manual reauths, so RSA4c3 does not apply */ - ConnectionManager.prototype.actOnErrorFromAuthorize = function(err) { + actOnErrorFromAuthorize(err: ErrorInfo): void { if(err.code === 40171) { /* No way to reauth */ this.notifyState({state: 'failed', error: err}); - } else if(err.statusCode === 403) { - var msg = 'Client configured authentication provider returned 403; failing the connection'; + } else if(err.statusCode === HttpStatusCodes.Forbidden) { + const msg = 'Client configured authentication provider returned 403; failing the connection'; Logger.logAction(Logger.LOG_ERROR, 'ConnectionManager.actOnErrorFromAuthorize()', msg); this.notifyState({state: 'failed', error: new ErrorInfo(msg, 80019, 403, err)}); } else { - var msg = 'Client configured authentication provider request failed'; + const msg = 'Client configured authentication provider request failed'; Logger.logAction(Logger.LOG_MINOR, 'ConnectionManager.actOnErrorFromAuthorize', msg); - this.notifyState({state: this.state.failState, error: new ErrorInfo(msg, 80019, 401, err)}); + this.notifyState({state: this.state.failState as string, error: new ErrorInfo(msg, 80019, 401, err)}); } - }; + } - ConnectionManager.prototype.onConnectionDetailsUpdate = function(connectionDetails, transport) { + onConnectionDetailsUpdate(connectionDetails: Record, transport: Transport): void { if(!connectionDetails) { return; } @@ -1780,9 +1828,9 @@ var ConnectionManager = (function() { if(connectionDetails.maxMessageSize) { this.options.maxMessageSize = connectionDetails.maxMessageSize; } - var clientId = connectionDetails.clientId; + const clientId = connectionDetails.clientId; if(clientId) { - var err = this.realtime.auth._uncheckedSetClientId(clientId); + const err = this.realtime.auth._uncheckedSetClientId(clientId); if(err) { Logger.logAction(Logger.LOG_ERROR, 'ConnectionManager.onConnectionDetailsUpdate()', err.message); /* Errors setting the clientId are fatal to the connection */ @@ -1790,15 +1838,18 @@ var ConnectionManager = (function() { return; } } - var connectionStateTtl = connectionDetails.connectionStateTtl; + const connectionStateTtl = connectionDetails.connectionStateTtl; if(connectionStateTtl) { this.connectionStateTtl = connectionStateTtl; } this.maxIdleInterval = connectionDetails.maxIdleInterval; this.emit('connectiondetails', connectionDetails); - }; + } +} - return ConnectionManager; -})(); +WebSocketTransport(ConnectionManager); +Utils.arrForEach(PlatformTransports, function (initFn) { + initFn(ConnectionManager); +}); export default ConnectionManager; diff --git a/common/lib/transport/messagequeue.js b/common/lib/transport/messagequeue.js deleted file mode 100644 index 9d853041a9..0000000000 --- a/common/lib/transport/messagequeue.js +++ /dev/null @@ -1,72 +0,0 @@ -import Utils from '../util/utils'; -import EventEmitter from '../util/eventemitter'; -import Logger from '../util/logger'; - -var MessageQueue = (function() { - function MessageQueue() { - EventEmitter.call(this); - this.messages = []; - } - Utils.inherits(MessageQueue, EventEmitter); - - MessageQueue.prototype.count = function() { - return this.messages.length; - }; - - MessageQueue.prototype.push = function(message) { - this.messages.push(message); - }; - - MessageQueue.prototype.shift = function() { - return this.messages.shift(); - }; - - MessageQueue.prototype.last = function() { - return this.messages[this.messages.length - 1]; - }; - - MessageQueue.prototype.copyAll = function() { - return this.messages.slice(); - }; - - MessageQueue.prototype.append = function(messages) { - this.messages.push.apply(this.messages, messages); - }; - - MessageQueue.prototype.prepend = function(messages) { - this.messages.unshift.apply(this.messages, messages); - }; - - MessageQueue.prototype.completeMessages = function(serial, count, err) { - Logger.logAction(Logger.LOG_MICRO, 'MessageQueue.completeMessages()', 'serial = ' + serial + '; count = ' + count); - err = err || null; - var messages = this.messages; - var first = messages[0]; - if(first) { - var startSerial = first.message.msgSerial; - var endSerial = serial + count; /* the serial of the first message that is *not* the subject of this call */ - if(endSerial > startSerial) { - var completeMessages = messages.splice(0, (endSerial - startSerial)); - for(var i = 0; i < completeMessages.length; i++) { - completeMessages[i].callback(err); - } - } - if(messages.length == 0) - this.emit('idle'); - } - }; - - MessageQueue.prototype.completeAllMessages = function(err) { - this.completeMessages(0, Number.MAX_SAFE_INTEGER || Number.MAX_VALUE, err); - }; - - MessageQueue.prototype.clear = function() { - Logger.logAction(Logger.LOG_MICRO, 'MessageQueue.clear()', 'clearing ' + this.messages.length + ' messages'); - this.messages = []; - this.emit('idle'); - }; - - return MessageQueue; -})(); - -export default MessageQueue; diff --git a/common/lib/transport/messagequeue.ts b/common/lib/transport/messagequeue.ts new file mode 100644 index 0000000000..59141a8c7d --- /dev/null +++ b/common/lib/transport/messagequeue.ts @@ -0,0 +1,75 @@ +import ErrorInfo from '../types/errorinfo'; +import EventEmitter from '../util/eventemitter'; +import Logger from '../util/logger'; +import { PendingMessage } from './protocol'; + +class MessageQueue extends EventEmitter { + messages: Array; + + constructor() { + super(); + this.messages = []; + } + + count(): number { + return this.messages.length; + } + + push(message: PendingMessage): void { + this.messages.push(message); + } + + shift(): PendingMessage | undefined { + return this.messages.shift(); + } + + last(): PendingMessage { + return this.messages[this.messages.length - 1]; + } + + copyAll(): PendingMessage[] { + return this.messages.slice(); + } + + append(messages: Array): void { + this.messages.push.apply(this.messages, messages); + } + + prepend(messages: Array): void { + this.messages.unshift.apply(this.messages, messages); + } + + completeMessages(serial: number, count: number, err?: ErrorInfo | null): void { + Logger.logAction(Logger.LOG_MICRO, 'MessageQueue.completeMessages()', 'serial = ' + serial + '; count = ' + count); + err = err || null; + const messages = this.messages; + if (messages.length === 0) { + throw new Error('MessageQueue.completeMessages(): completeMessages called on any empty MessageQueue'); + } + const first = messages[0]; + if(first) { + const startSerial = first.message.msgSerial as number; + const endSerial = serial + count; /* the serial of the first message that is *not* the subject of this call */ + if(endSerial > startSerial) { + const completeMessages = messages.splice(0, (endSerial - startSerial)); + for(const message of completeMessages) { + (message.callback as Function)(err); + } + } + if(messages.length == 0) + this.emit('idle'); + } + } + + completeAllMessages(err: ErrorInfo): void { + this.completeMessages(0, Number.MAX_SAFE_INTEGER || Number.MAX_VALUE, err); + } + + clear(): void { + Logger.logAction(Logger.LOG_MICRO, 'MessageQueue.clear()', 'clearing ' + this.messages.length + ' messages'); + this.messages = []; + this.emit('idle'); + } +} + +export default MessageQueue; diff --git a/common/lib/transport/protocol.js b/common/lib/transport/protocol.ts similarity index 57% rename from common/lib/transport/protocol.js rename to common/lib/transport/protocol.ts index 4131621d78..c237f3de42 100644 --- a/common/lib/transport/protocol.js +++ b/common/lib/transport/protocol.ts @@ -1,46 +1,66 @@ import ProtocolMessage from '../types/protocolmessage'; -import Utils from '../util/utils'; +import * as Utils from '../util/utils'; import EventEmitter from '../util/eventemitter'; import Logger from '../util/logger'; import MessageQueue from './messagequeue'; import ErrorInfo from '../types/errorinfo'; +import Transport from './transport'; +import { ErrCallback } from '../../types/utils'; -var Protocol = (function() { - var actions = ProtocolMessage.Action; +const actions = ProtocolMessage.Action; - function Protocol(transport) { - EventEmitter.call(this); +export class PendingMessage { + message: ProtocolMessage; + callback?: ErrCallback; + merged: boolean; + sendAttempted: boolean; + ackRequired: boolean; + + constructor(message: ProtocolMessage, callback?: ErrCallback) { + this.message = message; + this.callback = callback; + this.merged = false; + const action = message.action; + this.sendAttempted = false; + this.ackRequired = (action == actions.MESSAGE || action == actions.PRESENCE); + } +} + +class Protocol extends EventEmitter { + transport: Transport; + messageQueue: MessageQueue; + + constructor(transport: Transport) { + super(); this.transport = transport; this.messageQueue = new MessageQueue(); - var self = this; - transport.on('ack', function(serial, count) { self.onAck(serial, count); }); - transport.on('nack', function(serial, count, err) { self.onNack(serial, count, err); }); + transport.on('ack', (serial: number, count: number) => { this.onAck(serial, count); }); + transport.on('nack', (serial: number, count: number, err: ErrorInfo) => { this.onNack(serial, count, err); }); } - Utils.inherits(Protocol, EventEmitter); - Protocol.prototype.onAck = function(serial, count) { + onAck(serial: number, count: number): void { Logger.logAction(Logger.LOG_MICRO, 'Protocol.onAck()', 'serial = ' + serial + '; count = ' + count); this.messageQueue.completeMessages(serial, count); - }; + } - Protocol.prototype.onNack = function(serial, count, err) { + onNack(serial: number, count: number, err: ErrorInfo): void { Logger.logAction(Logger.LOG_ERROR, 'Protocol.onNack()', 'serial = ' + serial + '; count = ' + count + '; err = ' + Utils.inspectError(err)); if(!err) { err = new ErrorInfo('Unable to send message; channel not responding', 50001, 500); } this.messageQueue.completeMessages(serial, count, err); - }; + } - Protocol.prototype.onceIdle = function(listener) { - var messageQueue = this.messageQueue; + onceIdle(listener: ErrCallback): void { + const messageQueue = this.messageQueue; if(messageQueue.count() === 0) { listener(); return; } messageQueue.once('idle', listener); - }; + } - Protocol.prototype.send = function(pendingMessage) { + send(pendingMessage: PendingMessage): void { if(pendingMessage.ackRequired) { this.messageQueue.push(pendingMessage); } @@ -49,38 +69,26 @@ var Protocol = (function() { } pendingMessage.sendAttempted = true; this.transport.send(pendingMessage.message); - }; + } - Protocol.prototype.getTransport = function() { + getTransport(): Transport { return this.transport; - }; + } - Protocol.prototype.getPendingMessages = function() { + getPendingMessages(): PendingMessage[] { return this.messageQueue.copyAll(); - }; + } - Protocol.prototype.clearPendingMessages = function() { + clearPendingMessages(): void { return this.messageQueue.clear(); - }; + } - Protocol.prototype.finish = function() { - var transport = this.transport; + finish(): void { + const transport = this.transport; this.onceIdle(function() { transport.disconnect(); }); - }; - - function PendingMessage(message, callback) { - this.message = message; - this.callback = callback; - this.merged = false; - var action = message.action; - this.sendAttempted = false; - this.ackRequired = (action == actions.MESSAGE || action == actions.PRESENCE); } - Protocol.PendingMessage = PendingMessage; - - return Protocol; -})(); +} export default Protocol; diff --git a/common/lib/transport/transport.js b/common/lib/transport/transport.ts similarity index 56% rename from common/lib/transport/transport.js rename to common/lib/transport/transport.ts index a477d54c9e..86cbfadd5d 100644 --- a/common/lib/transport/transport.js +++ b/common/lib/transport/transport.ts @@ -1,31 +1,50 @@ import ProtocolMessage from '../types/protocolmessage'; -import Utils from '../util/utils'; +import * as Utils from '../util/utils'; import EventEmitter from '../util/eventemitter'; import Logger from '../util/logger'; -import ConnectionError from '../transport/connectionerror'; +import ConnectionErrors from './connectionerrors'; import ErrorInfo from '../types/errorinfo'; - -var Transport = (function() { - var actions = ProtocolMessage.Action; - var closeMessage = ProtocolMessage.fromValues({action: actions.CLOSE}); - var disconnectMessage = ProtocolMessage.fromValues({action: actions.DISCONNECT}); - var noop = function() {}; - - /* - * EventEmitter, generates the following events: - * - * event name data - * closed error - * failed error - * disposed - * connected null error, connectionSerial, connectionId, connectionDetails - * sync connectionSerial, connectionId - * event channel message object - */ - - /* public constructor */ - function Transport(connectionManager, auth, params) { - EventEmitter.call(this); +import Auth from '../client/auth'; +import * as API from '../../../ably'; +import ConnectionManager, { TransportParams } from './connectionmanager'; + +export type TryConnectCallback = (wrappedErr: { error: ErrorInfo, event: string } | null, transport?: Transport) => void; + +const actions = ProtocolMessage.Action; +const closeMessage = ProtocolMessage.fromValues({action: actions.CLOSE}); +const disconnectMessage = ProtocolMessage.fromValues({action: actions.DISCONNECT}); + +/* + * Transport instances inherit from EventEmitter and emit the following events: + * + * event name data + * closed error + * failed error + * disposed + * connected null error, connectionSerial, connectionId, connectionDetails + * sync connectionSerial, connectionId + * event channel message object + */ + +abstract class Transport extends EventEmitter { + connectionManager: ConnectionManager; + auth: Auth; + params: TransportParams; + timeouts: Record; + format?: Utils.Format; + isConnected: boolean; + isFinished: boolean; + isDisposed: boolean; + maxIdleInterval: number | null; + idleTimer: NodeJS.Timeout | number | null; + lastActivity: number | null; + + constructor(connectionManager: ConnectionManager, auth: Auth, params: TransportParams, forceJsonProtocol?: boolean) { + super(); + if (forceJsonProtocol) { + params.format = undefined; + params.heartbeats = true; + } this.connectionManager = connectionManager; connectionManager.registerProposedTransport(this); this.auth = auth; @@ -39,35 +58,37 @@ var Transport = (function() { this.idleTimer = null; this.lastActivity = null; } - Utils.inherits(Transport, EventEmitter); - Transport.prototype.connect = function() {}; + abstract shortName: string; + abstract send(message: ProtocolMessage): void; - Transport.prototype.close = function() { + connect(): void {} + + close(): void { if(this.isConnected) { this.requestClose(); } - this.finish('closed', ConnectionError.closed); + this.finish('closed', ConnectionErrors.closed); }; - Transport.prototype.disconnect = function(err) { + disconnect(err?: Error | ErrorInfo): void { /* Used for network/transport issues that need to result in the transport - * being disconnected, but should not affect the connection */ + * being disconnected, but should not transition the connection to 'failed' */ if(this.isConnected) { this.requestDisconnect(); } - this.finish('disconnected', err || ConnectionError.disconnected); + this.finish('disconnected', err || ConnectionErrors.disconnected); }; - Transport.prototype.fail = function(err) { + fail(err: ErrorInfo): void { /* Used for client-side-detected fatal connection issues */ if(this.isConnected) { this.requestDisconnect(); } - this.finish('failed', err || ConnectionError.failed); + this.finish('failed', err || ConnectionErrors.failed); }; - Transport.prototype.finish = function(event, err) { + finish(event: string, err?: Error | ErrorInfo): void { if(this.isFinished) { return; } @@ -75,13 +96,13 @@ var Transport = (function() { this.isFinished = true; this.isConnected = false; this.maxIdleInterval = null; - clearTimeout(this.idleTimer); + clearTimeout(this.idleTimer ?? undefined); this.idleTimer = null; this.emit(event, err); this.dispose(); - }; + } - Transport.prototype.onProtocolMessage = function(message) { + onProtocolMessage(message: ProtocolMessage): void { if (Logger.shouldLog(Logger.LOG_MICRO)) { Logger.logAction(Logger.LOG_MICRO, 'Transport.onProtocolMessage()', 'received on ' + this.shortName + ': ' + ProtocolMessage.stringify(message) + '; connectionId = ' + this.connectionManager.connectionId); } @@ -118,7 +139,7 @@ var Transport = (function() { this.connectionManager.onChannelMessage(message, this); break; case actions.AUTH: - this.auth.authorize(function(err) { + this.auth.authorize(function(err: ErrorInfo) { if(err) { Logger.logAction(Logger.LOG_ERROR, 'Transport.onProtocolMessage()', 'Ably requested re-authentication, but unable to obtain a new token: ' + Utils.inspectError(err)); } @@ -137,94 +158,98 @@ var Transport = (function() { /* all other actions are channel-specific */ this.connectionManager.onChannelMessage(message, this); } - }; + } - Transport.prototype.onConnect = function(message) { + onConnect(message: ProtocolMessage): void { this.isConnected = true; - var maxPromisedIdle = message.connectionDetails.maxIdleInterval; + if (!message.connectionDetails) { + throw new Error('Transport.onConnect(): Connect message recieved without connectionDetails'); + } + const maxPromisedIdle = message.connectionDetails.maxIdleInterval as number; if(maxPromisedIdle) { this.maxIdleInterval = maxPromisedIdle + this.timeouts.realtimeRequestTimeout; this.onActivity(); } /* else Realtime declines to guarantee any maximum idle interval - CD2h */ - }; + } - Transport.prototype.onDisconnect = function(message) { + onDisconnect(message: ProtocolMessage): void { /* Used for when the server has disconnected the client (usually with a - * DISCONNECTED action) */ - var err = message && message.error; + * DISCONNECTED action) */ + const err = message && message.error; Logger.logAction(Logger.LOG_MINOR, 'Transport.onDisconnect()', 'err = ' + Utils.inspectError(err)); this.finish('disconnected', err); - }; + } - Transport.prototype.onFatalError = function(message) { + onFatalError(message: ProtocolMessage): void { /* On receipt of a fatal connection error, we can assume that the server - * will close the connection and the transport, and do not need to request - * a disconnection - RTN15i */ - var err = message && message.error; + * will close the connection and the transport, and do not need to request + * a disconnection - RTN15i */ + const err = message && message.error; Logger.logAction(Logger.LOG_MINOR, 'Transport.onFatalError()', 'err = ' + Utils.inspectError(err)); this.finish('failed', err); - }; + } - Transport.prototype.onClose = function(message) { - var err = message && message.error; + onClose(message: ProtocolMessage): void { + const err = message && message.error; Logger.logAction(Logger.LOG_MINOR, 'Transport.onClose()', 'err = ' + Utils.inspectError(err)); this.finish('closed', err); - }; + } - Transport.prototype.requestClose = function() { + requestClose(): void { Logger.logAction(Logger.LOG_MINOR, 'Transport.requestClose()', ''); this.send(closeMessage); - }; + } - Transport.prototype.requestDisconnect = function() { + requestDisconnect(): void { Logger.logAction(Logger.LOG_MINOR, 'Transport.requestDisconnect()', ''); this.send(disconnectMessage); - }; + } - Transport.prototype.ping = function(id) { - var msg = {action: ProtocolMessage.Action.HEARTBEAT}; + ping(id: string): void { + const msg: Record = {action: ProtocolMessage.Action.HEARTBEAT}; if(id) msg.id = id; this.send(ProtocolMessage.fromValues(msg)); - }; + } - Transport.prototype.dispose = function() { + dispose(): void { Logger.logAction(Logger.LOG_MINOR, 'Transport.dispose()', ''); this.isDisposed = true; this.off(); - }; + } - Transport.prototype.onActivity = function() { + onActivity(): void { if(!this.maxIdleInterval) { return; } this.lastActivity = this.connectionManager.lastActivity = Utils.now(); this.setIdleTimer(this.maxIdleInterval + 100); - }; + } - Transport.prototype.setIdleTimer = function(timeout) { - var self = this; + setIdleTimer(timeout: number): void { if(!this.idleTimer) { - this.idleTimer = setTimeout(function() { - self.onIdleTimerExpire(); + this.idleTimer = setTimeout(() => { + this.onIdleTimerExpire(); }, timeout); } - }; + } - Transport.prototype.onIdleTimerExpire = function() { + onIdleTimerExpire(): void { + if (!this.lastActivity || !this.maxIdleInterval) { + throw new Error('Transport.onIdleTimerExpire(): lastActivity/maxIdleInterval not set'); + } this.idleTimer = null; - var sinceLast = Utils.now() - this.lastActivity, - timeRemaining = this.maxIdleInterval - sinceLast; + const sinceLast = Utils.now() - this.lastActivity; + const timeRemaining = this.maxIdleInterval - sinceLast; if(timeRemaining <= 0) { - var msg = 'No activity seen from realtime in ' + sinceLast + 'ms; assuming connection has dropped'; + const msg = 'No activity seen from realtime in ' + sinceLast + 'ms; assuming connection has dropped'; Logger.logAction(Logger.LOG_ERROR, 'Transport.onIdleTimerExpire()', msg); this.disconnect(new ErrorInfo(msg, 80003, 408)); } else { this.setIdleTimer(timeRemaining + 100); } - }; - - Transport.prototype.onAuthUpdated = function() {}; + } - return Transport; -})(); + static tryConnect?: (connectionManager: ConnectionManager, auth: Auth, transportParams: TransportParams, callback: TryConnectCallback) => void; + onAuthUpdated?: (tokenDetails: API.Types.TokenDetails) => void; +} export default Transport; diff --git a/common/lib/transport/websockettransport.js b/common/lib/transport/websockettransport.ts similarity index 57% rename from common/lib/transport/websockettransport.js rename to common/lib/transport/websockettransport.ts index 18510accb5..0b830cb38b 100644 --- a/common/lib/transport/websockettransport.js +++ b/common/lib/transport/websockettransport.ts @@ -1,35 +1,41 @@ import Platform from 'platform'; -import Utils from '../util/utils'; -import Transport from './transport'; +import * as Utils from '../util/utils'; +import Transport, { TryConnectCallback } from './transport'; import Defaults from '../util/defaults'; import Logger from '../util/logger'; import ProtocolMessage from '../types/protocolmessage'; import ErrorInfo from '../types/errorinfo'; +import NodeWebSocket from 'ws'; +import ConnectionManager, { TransportParams } from './connectionmanager'; +import Auth from '../client/auth'; -var WebSocketTransport = function(connectionManager) { - var WebSocket = Platform.WebSocket; - var shortName = 'web_socket'; +const WebSocket = Platform.WebSocket; +const shortName = 'web_socket'; - /* public constructor */ - function WebSocketTransport(connectionManager, auth, params) { - this.shortName = shortName; +function isNodeWebSocket(ws: WebSocket | NodeWebSocket): ws is NodeWebSocket { + return !!((ws as NodeWebSocket).on); +} + +class WebSocketTransport extends Transport { + shortName = shortName; + wsHost: string; + uri?: string; + wsConnection?: WebSocket | NodeWebSocket; + + constructor(connectionManager: ConnectionManager, auth: Auth, params: TransportParams) { + super(connectionManager, auth, params); /* If is a browser, can't detect pings, so request protocol heartbeats */ params.heartbeats = Platform.useProtocolHeartbeats; - Transport.call(this, connectionManager, auth, params); this.wsHost = Defaults.getHost(params.options, params.host, true); } - Utils.inherits(WebSocketTransport, Transport); - WebSocketTransport.isAvailable = function() { + static isAvailable() { return !!WebSocket; - }; - - if(WebSocketTransport.isAvailable()) - connectionManager.supportedTransports[shortName] = WebSocketTransport; + } - WebSocketTransport.tryConnect = function(connectionManager, auth, params, callback) { - var transport = new WebSocketTransport(connectionManager, auth, params); - var errorCb = function(err) { callback({event: this.event, error: err}); }; + static tryConnect(connectionManager: ConnectionManager, auth: Auth, params: TransportParams, callback: TryConnectCallback) { + const transport = new WebSocketTransport(connectionManager, auth, params); + const errorCb = function(this: { event: string }, err: ErrorInfo) { callback({event: this.event, error: err}); }; transport.on(['failed', 'disconnected'], errorCb); transport.on('wsopen', function() { Logger.logAction(Logger.LOG_MINOR, 'WebSocketTransport.tryConnect()', 'viable transport ' + transport); @@ -37,56 +43,61 @@ var WebSocketTransport = function(connectionManager) { callback(null, transport); }); transport.connect(); - }; + } - WebSocketTransport.prototype.createWebSocket = function(uri, connectParams) { - this.uri = uri + Utils.toQueryString(connectParams) - return new WebSocket(this.uri); - }; + createWebSocket(uri: string, connectParams: Record) { + let paramCount = 0; + if(connectParams) { + for(const key in connectParams) + uri += (paramCount++ ? '&' : '?') + key + '=' + connectParams[key]; + } + this.uri = uri; + return new WebSocket(uri); + } - WebSocketTransport.prototype.toString = function() { + toString() { return 'WebSocketTransport; uri=' + this.uri; - }; + } - WebSocketTransport.prototype.connect = function() { + connect() { Logger.logAction(Logger.LOG_MINOR, 'WebSocketTransport.connect()', 'starting'); Transport.prototype.connect.call(this); - var self = this, params = this.params, options = params.options; - var wsScheme = options.tls ? 'wss://' : 'ws://'; - var wsUri = wsScheme + this.wsHost + ':' + Defaults.getPort(options) + '/'; + const self = this, params = this.params, options = params.options; + const wsScheme = options.tls ? 'wss://' : 'ws://'; + const wsUri = wsScheme + this.wsHost + ':' + Defaults.getPort(options) + '/'; Logger.logAction(Logger.LOG_MINOR, 'WebSocketTransport.connect()', 'uri: ' + wsUri); - this.auth.getAuthParams(function(err, authParams) { + this.auth.getAuthParams(function(err: ErrorInfo, authParams: Record) { if(self.isDisposed) { return; } - var paramStr = ''; for(var param in authParams) paramStr += ' ' + param + ': ' + authParams[param] + ';'; + let paramStr = ''; for(const param in authParams) paramStr += ' ' + param + ': ' + authParams[param] + ';'; Logger.logAction(Logger.LOG_MINOR, 'WebSocketTransport.connect()', 'authParams:' + paramStr + ' err: ' + err); if(err) { self.disconnect(err); return; } - var connectParams = params.getConnectParams(authParams); + const connectParams = params.getConnectParams(authParams); try { - var wsConnection = self.wsConnection = self.createWebSocket(wsUri, connectParams); + const wsConnection = self.wsConnection = self.createWebSocket(wsUri, connectParams); wsConnection.binaryType = Platform.binaryType; wsConnection.onopen = function() { self.onWsOpen(); }; - wsConnection.onclose = function(ev) { self.onWsClose(ev); }; - wsConnection.onmessage = function(ev) { self.onWsData(ev.data); }; - wsConnection.onerror = function(ev) { self.onWsError(ev); }; - if(wsConnection.on) { + wsConnection.onclose = function(ev: CloseEvent) { self.onWsClose(ev); }; + wsConnection.onmessage = function(ev: MessageEvent) { self.onWsData(ev.data); }; + wsConnection.onerror = function(ev: Event) { self.onWsError(ev as ErrorEvent); }; + if(isNodeWebSocket(wsConnection)) { /* node; browsers currently don't have a general eventemitter and can't detect * pings. Also, no need to reply with a pong explicitly, ws lib handles that */ wsConnection.on('ping', function() { self.onActivity(); }); } } catch(e) { - Logger.logAction(Logger.LOG_ERROR, 'WebSocketTransport.connect()', 'Unexpected exception creating websocket: err = ' + (e.stack || e.message)); - self.disconnect(e); + Logger.logAction(Logger.LOG_ERROR, 'WebSocketTransport.connect()', 'Unexpected exception creating websocket: err = ' + ((e as Error).stack || (e as Error).message)); + self.disconnect(e as Error); } }); - }; + } - WebSocketTransport.prototype.send = function(message) { - var wsConnection = this.wsConnection; + send(message: ProtocolMessage) { + const wsConnection = this.wsConnection; if(!wsConnection) { Logger.logAction(Logger.LOG_ERROR, 'WebSocketTransport.send()', 'No socket connection'); return; @@ -94,30 +105,30 @@ var WebSocketTransport = function(connectionManager) { try { wsConnection.send(ProtocolMessage.serialize(message, this.params.format)); } catch (e) { - var msg = 'Exception from ws connection when trying to send: ' + Utils.inspectError(e); + const msg = 'Exception from ws connection when trying to send: ' + Utils.inspectError(e); Logger.logAction(Logger.LOG_ERROR, 'WebSocketTransport.send()', msg); /* Don't try to request a disconnect, that'll just involve sending data * down the websocket again. Just finish the transport. */ this.finish('disconnected', new ErrorInfo(msg, 50000, 500)); } - }; + } - WebSocketTransport.prototype.onWsData = function(data) { + onWsData(data: string) { Logger.logAction(Logger.LOG_MICRO, 'WebSocketTransport.onWsData()', 'data received; length = ' + data.length + '; type = ' + typeof(data)); try { this.onProtocolMessage(ProtocolMessage.deserialize(data, this.format)); } catch (e) { - Logger.logAction(Logger.LOG_ERROR, 'WebSocketTransport.onWsData()', 'Unexpected exception handing channel message: ' + e.stack); + Logger.logAction(Logger.LOG_ERROR, 'WebSocketTransport.onWsData()', 'Unexpected exception handing channel message: ' + (e as Error).stack); } - }; + } - WebSocketTransport.prototype.onWsOpen = function() { + onWsOpen() { Logger.logAction(Logger.LOG_MINOR, 'WebSocketTransport.onWsOpen()', 'opened WebSocket'); this.emit('wsopen'); - }; + } - WebSocketTransport.prototype.onWsClose = function(ev) { - var wasClean, code, reason; + onWsClose(ev: number | CloseEvent) { + let wasClean, code; if(typeof(ev) == 'object') { /* W3C spec-compatible */ wasClean = ev.wasClean; @@ -130,32 +141,31 @@ var WebSocketTransport = function(connectionManager) { delete this.wsConnection; if(wasClean) { Logger.logAction(Logger.LOG_MINOR, 'WebSocketTransport.onWsClose()', 'Cleanly closed WebSocket'); - var err = new ErrorInfo('Websocket closed', 80003, 400); + const err = new ErrorInfo('Websocket closed', 80003, 400); this.finish('disconnected', err); } else { - var msg = 'Unclean disconnection of WebSocket ; code = ' + code, + const msg = 'Unclean disconnection of WebSocket ; code = ' + code, err = new ErrorInfo(msg, 80003, 400); Logger.logAction(Logger.LOG_MINOR, 'WebSocketTransport.onWsClose()', msg); this.finish('disconnected', err); } this.emit('disposed'); - }; + } - WebSocketTransport.prototype.onWsError = function(err) { + onWsError(err: ErrorEvent) { Logger.logAction(Logger.LOG_MINOR, 'WebSocketTransport.onError()', 'Error from WebSocket: ' + err.message); /* Wait a tick before aborting: if the websocket was connected, this event * will be immediately followed by an onclose event with a close code. Allow * that to close it (so we see the close code) rather than anticipating it */ - var self = this; - Utils.nextTick(function() { - self.disconnect(err); + Utils.nextTick(() => { + this.disconnect(Error(err.message)); }); - }; + } - WebSocketTransport.prototype.dispose = function() { + dispose() { Logger.logAction(Logger.LOG_MINOR, 'WebSocketTransport.dispose()', ''); this.isDisposed = true; - var wsConnection = this.wsConnection; + const wsConnection = this.wsConnection; if(wsConnection) { /* Ignore any messages that come through after dispose() is called but before * websocket is actually closed. (mostly would be harmless, but if it's a @@ -166,12 +176,20 @@ var WebSocketTransport = function(connectionManager) { * giving some implementations the opportunity to send any outstanding close message */ Utils.nextTick(function() { Logger.logAction(Logger.LOG_MICRO, 'WebSocketTransport.dispose()', 'closing websocket'); + if (!wsConnection) { + throw new Error('WebSocketTransport.dispose(): wsConnection is not defined'); + } wsConnection.close(); }); } - }; + } +} + +function initialiseTransport(connectionManager: any): typeof WebSocketTransport { + if(WebSocketTransport.isAvailable()) + connectionManager.supportedTransports[shortName] = WebSocketTransport; return WebSocketTransport; -}; +} -export default WebSocketTransport; +export default initialiseTransport; diff --git a/common/lib/types/devicedetails.js b/common/lib/types/devicedetails.js deleted file mode 100644 index 9de630127d..0000000000 --- a/common/lib/types/devicedetails.js +++ /dev/null @@ -1,95 +0,0 @@ -import Utils from '../util/utils'; - -var DeviceDetails = (function() { - - function DeviceDetails() { - this.id = undefined; - this.deviceSecret = undefined; - this.platform = undefined; - this.formFactor = undefined; - this.clientId = undefined; - this.metadata = undefined; - this.deviceIdentityToken = undefined; - this.push = { - recipient: undefined, - state: undefined, - error: undefined - }; - } - - /** - * Overload toJSON() to intercept JSON.stringify() - * @return {*} - */ - DeviceDetails.prototype.toJSON = function() { - return { - id: this.id, - deviceSecret: this.deviceSecret, - platform: this.platform, - formFactor: this.formFactor, - clientId: this.clientId, - metadata: this.metadata, - deviceIdentityToken: this.deviceIdentityToken, - push: { - recipient: this.push.recipient, - state: this.push.state, - error: this.push.error - } - }; - }; - - DeviceDetails.prototype.toString = function() { - var result = '[DeviceDetails'; - if(this.id) - result += '; id=' + this.id; - if(this.platform) - result += '; platform=' + this.platform; - if(this.formFactor) - result += '; formFactor=' + this.formFactor; - if(this.clientId) - result += '; clientId=' + this.clientId; - if(this.metadata) - result += '; metadata=' + this.metadata; - if(this.deviceIdentityToken) - result += '; deviceIdentityToken=' + JSON.stringify(this.deviceIdentityToken); - if(this.push.recipient) - result += '; push.recipient=' + JSON.stringify(this.push.recipient); - if(this.push.state) - result += '; push.state=' + this.push.state; - if(this.push.error) - result += '; push.error=' + JSON.stringify(this.push.error); - if(this.push.metadata) - result += '; push.metadata=' + this.push.metadata; - result += ']'; - return result; - }; - - DeviceDetails.toRequestBody = Utils.encodeBody; - - DeviceDetails.fromResponseBody = function(body, format) { - if(format) { - body = Utils.decodeBody(body, format); - } - - if(Utils.isArray(body)) { - return DeviceDetails.fromValuesArray(body); - } else { - return DeviceDetails.fromValues(body); - } - }; - - DeviceDetails.fromValues = function(values) { - values.error = values.error && ErrorInfo.fromValues(values.error); - return Utils.mixin(new DeviceDetails(), values); - }; - - DeviceDetails.fromValuesArray = function(values) { - var count = values.length, result = new Array(count); - for(var i = 0; i < count; i++) result[i] = DeviceDetails.fromValues(values[i]); - return result; - }; - - return DeviceDetails; -})(); - -export default DeviceDetails; diff --git a/common/lib/types/devicedetails.ts b/common/lib/types/devicedetails.ts new file mode 100644 index 0000000000..789a654626 --- /dev/null +++ b/common/lib/types/devicedetails.ts @@ -0,0 +1,109 @@ +import * as Utils from '../util/utils'; +import ErrorInfo from './errorinfo'; + +enum DeviceFormFactor { + Phone = 'phone', + Tablet = 'tablet', + Desktop = 'desktop', + TV = 'tv', + Watch = 'watch', + Car = 'car', + Embedded = 'embedded', + Other = 'other', +} + +enum DevicePlatform { + Android = 'android', + IOS = 'ios', + Browser = 'browser', +} + +type DevicePushState = 'ACTIVE' | 'FAILING' | 'FAILED'; + +type DevicePushDetails = { + error?: ErrorInfo; + recipient?: string; + state?: DevicePushState; + metadata?: string; +} + +class DeviceDetails { + id?: string; + clientId?: string; + deviceSecret?: string; + formFactor?: DeviceFormFactor; + platform?: DevicePlatform; + push?: DevicePushDetails; + metadata?: string; + deviceIdentityToken?: string; + + toJSON(): DeviceDetails { + return { + id: this.id, + deviceSecret: this.deviceSecret, + platform: this.platform, + formFactor: this.formFactor, + clientId: this.clientId, + metadata: this.metadata, + deviceIdentityToken: this.deviceIdentityToken, + push: { + recipient: this.push?.recipient, + state: this.push?.state, + error: this.push?.error + } + } as DeviceDetails; + } + + toString(): string { + let result = '[DeviceDetails'; + if(this.id) + result += '; id=' + this.id; + if(this.platform) + result += '; platform=' + this.platform; + if(this.formFactor) + result += '; formFactor=' + this.formFactor; + if(this.clientId) + result += '; clientId=' + this.clientId; + if(this.metadata) + result += '; metadata=' + this.metadata; + if(this.deviceIdentityToken) + result += '; deviceIdentityToken=' + JSON.stringify(this.deviceIdentityToken); + if(this.push?.recipient) + result += '; push.recipient=' + JSON.stringify(this.push.recipient); + if(this.push?.state) + result += '; push.state=' + this.push.state; + if(this.push?.error) + result += '; push.error=' + JSON.stringify(this.push.error); + if(this.push?.metadata) + result += '; push.metadata=' + this.push.metadata; + result += ']'; + return result; + } + + static toRequestBody = Utils.encodeBody; + + static fromResponseBody(body: Array> | Record, format?: Utils.Format): DeviceDetails | DeviceDetails[] { + if(format) { + body = Utils.decodeBody(body, format); + } + + if(Utils.isArray(body)) { + return DeviceDetails.fromValuesArray(body); + } else { + return DeviceDetails.fromValues(body); + } + } + + static fromValues(values: Record): DeviceDetails { + values.error = values.error && ErrorInfo.fromValues(values.error as Error); + return Object.assign(new DeviceDetails(), values); + } + + static fromValuesArray(values: Array>): DeviceDetails[] { + const count = values.length, result = new Array(count); + for(let i = 0; i < count; i++) result[i] = DeviceDetails.fromValues(values[i]); + return result + } +} + +export default DeviceDetails; diff --git a/common/lib/types/errorinfo.js b/common/lib/types/errorinfo.js deleted file mode 100644 index a13f97fd73..0000000000 --- a/common/lib/types/errorinfo.js +++ /dev/null @@ -1,39 +0,0 @@ -import Utils from '../util/utils'; - -var ErrorInfo = (function() { - - function ErrorInfo(message, code, statusCode, cause) { - this.message = message; - this.code = code; - this.statusCode = statusCode; - this.cause = cause; - this.href = undefined; - } - - ErrorInfo.prototype.toString = function() { - var result = '[' + this.constructor.name; - if(this.message) result += ': ' + this.message; - if(this.statusCode) result += '; statusCode=' + this.statusCode; - if(this.code) result += '; code=' + this.code; - if(this.cause) result += '; cause=' + Utils.inspectError(this.cause); - if(this.href && !(this.message && this.message.indexOf('help.ably.io') > -1)) result += '; see ' + this.href + ' '; - result += ']'; - return result; - }; - - ErrorInfo.fromValues = function(values) { - var result = Utils.mixin(new ErrorInfo(), values); - if (values instanceof Error) { - /* Error.message is not enumerable, so mixin loses the message */ - result.message = values.message; - } - if(result.code && !result.href) { - result.href = 'https://help.ably.io/error/' + result.code; - } - return result; - }; - - return ErrorInfo; -})(); - -export default ErrorInfo; diff --git a/common/lib/types/errorinfo.ts b/common/lib/types/errorinfo.ts new file mode 100644 index 0000000000..3ebbf0e50e --- /dev/null +++ b/common/lib/types/errorinfo.ts @@ -0,0 +1,39 @@ +import * as Utils from "../util/utils"; + +export default class ErrorInfo { + message: string; + code: number | null; + statusCode?: number; + cause?: string | Error | ErrorInfo; + href?: string; + + constructor(message: string, code: number | null, statusCode?: number, cause?: string | Error | ErrorInfo) { + this.message = message; + this.code = code; + this.statusCode = statusCode; + this.cause = cause; + } + + toString(): string { + let result = '[' + this.constructor.name; + if(this.message) result += ': ' + this.message; + if(this.statusCode) result += '; statusCode=' + this.statusCode; + if(this.code) result += '; code=' + this.code; + if(this.cause) result += '; cause=' + Utils.inspectError(this.cause); + if(this.href && !(this.message && this.message.indexOf('help.ably.io') > -1)) result += '; see ' + this.href + ' '; + result += ']'; + return result; + } + + static fromValues(values: Record | ErrorInfo | Error): ErrorInfo { + const { message, code, statusCode } = values as ErrorInfo; + if ((typeof message !== 'string') || (typeof code !== 'number') || (typeof statusCode !== 'number')) { + throw new Error('ErrorInfo.fromValues(): invalid values: ' + Utils.inspect(values)); + } + const result = Object.assign(new ErrorInfo(message, code, statusCode), values); + if(result.code && !result.href) { + result.href = 'https://help.ably.io/error/' + result.code; + } + return result; + } +} diff --git a/common/lib/types/message.js b/common/lib/types/message.ts similarity index 54% rename from common/lib/types/message.js rename to common/lib/types/message.ts index 4b25fd632f..13442099e2 100644 --- a/common/lib/types/message.js +++ b/common/lib/types/message.ts @@ -1,48 +1,99 @@ -import BufferUtils from 'platform-bufferutils'; -import Utils from '../util/utils'; +import * as BufferUtils from 'platform-bufferutils'; import Logger from '../util/logger'; import Crypto from 'platform-crypto'; import ErrorInfo from './errorinfo'; +import { ChannelOptions } from '../../types/channel'; +import PresenceMessage from './presencemessage'; +import * as Utils from '../util/utils'; -var Message = (function() { +export type CipherOptions = { + channelCipher: { + encrypt: Function; + algorithm: 'aes'; + }, + cipher?: { + channelCipher: { + encrypt: Function; + algorithm: 'aes'; + } + } +}; + +type EncodingDecodingContext = { + channelOptions: ChannelOptions; + plugins: { vcdiff?: { + encrypt: Function; + decode: Function; + } }; + baseEncodedPreviousPayload?: Buffer; +} + +function normaliseContext(context: CipherOptions | EncodingDecodingContext | ChannelOptions): EncodingDecodingContext { + if(!context || !(context as EncodingDecodingContext).channelOptions) { + return { + channelOptions: context as ChannelOptions, + plugins: { }, + baseEncodedPreviousPayload: undefined + }; + } + return context as EncodingDecodingContext; +} - function Message() { - this.name = undefined; - this.id = undefined; - this.timestamp = undefined; - this.clientId = undefined; - this.connectionId = undefined; - this.connectionKey = undefined; - this.data = undefined; - this.encoding = undefined; - this.extras = undefined; - this.size = undefined; +function normalizeCipherOptions(options: CipherOptions): CipherOptions { + if(options && options.cipher && !options.cipher.channelCipher) { + if(!Crypto) throw new Error('Encryption not enabled; use ably.encryption.js instead'); + const cipher = Crypto.getCipher(options.cipher); + return { + cipher: cipher.cipherParams, + channelCipher: cipher.cipher + } + } + return options; +} + +function getMessageSize(msg: Message) { + let size = 0; + if(msg.name) { + size += msg.name.length; + } + if(msg.clientId) { + size += msg.clientId.length; + } + if(msg.extras) { + size += JSON.stringify(msg.extras).length; + } + if(msg.data) { + size += Utils.dataSizeBytes(msg.data); } + return size; +} + +class Message { + name?: string; + id?: string; + timestamp?: number; + clientId?: string; + connectionId?: string; + connectionKey?: string; + data?: any; + encoding?: string | null; + extras?: any; + size?: number; /** * Overload toJSON() to intercept JSON.stringify() * @return {*} */ - Message.prototype.toJSON = function() { - var result = { - name: this.name, - id: this.id, - clientId: this.clientId, - connectionId: this.connectionId, - connectionKey: this.connectionKey, - encoding: this.encoding, - extras: this.extras - }; - + toJSON() { /* encode data to base64 if present and we're returning real JSON; * although msgpack calls toJSON(), we know it is a stringify() * call if it has a non-empty arguments list */ - var data = this.data; + let encoding = this.encoding; + let data = this.data; if(data && BufferUtils.isBuffer(data)) { if(arguments.length > 0) { /* stringify call */ - var encoding = this.encoding; - result.encoding = encoding ? (encoding + '/base64') : 'base64'; + encoding = encoding ? (encoding + '/base64') : 'base64'; data = BufferUtils.base64Encode(data); } else { /* Called by msgpack. toBuffer returns a datatype understandable by @@ -51,12 +102,20 @@ var Message = (function() { data = BufferUtils.toBuffer(data); } } - result.data = data; - return result; - }; + return { + name: this.name, + id: this.id, + clientId: this.clientId, + connectionId: this.connectionId, + connectionKey: this.connectionKey, + extras: this.extras, + encoding, + data + }; + } - Message.prototype.toString = function() { - var result = '[Message'; + toString(): string { + let result = '[Message'; if(this.name) result += '; name=' + this.name; if(this.id) @@ -83,10 +142,10 @@ var Message = (function() { result += '; extras=' + JSON.stringify(this.extras); result += ']'; return result; - }; + } - Message.encrypt = function(msg, options, callback) { - var data = msg.data, + static encrypt(msg: Message | PresenceMessage, options: CipherOptions, callback: Function) { + let data = msg.data, encoding = msg.encoding, cipher = options.channelCipher; @@ -95,7 +154,7 @@ var Message = (function() { data = BufferUtils.utf8Encode(String(data)); encoding = encoding + 'utf-8/'; } - cipher.encrypt(data, function(err, data) { + cipher.encrypt(data, function(err: Error, data: unknown) { if (err) { callback(err); return; @@ -104,11 +163,12 @@ var Message = (function() { msg.encoding = encoding + 'cipher+' + cipher.algorithm; callback(null, msg); }); - }; + } - Message.encode = function(msg, options, callback) { - var data = msg.data, encoding, - nativeDataType = typeof(data) == 'string' || BufferUtils.isBuffer(data) || data === null || data === undefined; + static encode(msg: Message | PresenceMessage, options: CipherOptions, callback: Function): void { + const data = msg.data; + let encoding; + const nativeDataType = typeof(data) == 'string' || BufferUtils.isBuffer(data) || data === null || data === undefined; if (!nativeDataType) { if (Utils.isObject(data) || Utils.isArray(data)) { @@ -124,12 +184,12 @@ var Message = (function() { } else { callback(null, msg); } - }; + } - Message.encodeArray = function(messages, options, callback) { - var processed = 0; - for (var i = 0; i < messages.length; i++) { - Message.encode(messages[i], options, function(err, msg) { + static encodeArray(messages: Array, options: CipherOptions, callback: Function): void { + let processed = 0; + for (let i = 0; i < messages.length; i++) { + Message.encode(messages[i], options, function(err: Error) { if (err) { callback(err); return; @@ -140,33 +200,26 @@ var Message = (function() { } }); } - }; + } - Message.serialize = Utils.encodeBody; + static serialize = Utils.encodeBody; - Message.decode = function(message, context) { - /* The second argument could be either EncodingDecodingContext that contains ChannelOptions or ChannelOptions */ - if(!context || !context.channelOptions) { - var channelOptions = context; - context = { - channelOptions: channelOptions, - plugins: { }, - baseEncodedPreviousPayload: undefined - }; - } + static decode(message: Message | PresenceMessage, inputContext: CipherOptions | EncodingDecodingContext | ChannelOptions): void { + const context = normaliseContext(inputContext); - var lastPayload = message.data; - var encoding = message.encoding; + let lastPayload = message.data; + const encoding = message.encoding; if(encoding) { - var xforms = encoding.split('/'), - lastProcessedEncodingIndex, encodingsToProcess = xforms.length, + const xforms = encoding.split('/'); + let lastProcessedEncodingIndex, encodingsToProcess = xforms.length, data = message.data; + let xform = ''; try { while((lastProcessedEncodingIndex = encodingsToProcess) > 0) { - var match = xforms[--encodingsToProcess].match(/([\-\w]+)(\+([\w\-]+))?/); + const match = xforms[--encodingsToProcess].match(/([-\w]+)(\+([\w-]+))?/); if(!match) break; - var xform = match[1]; + xform = match[1]; switch(xform) { case 'base64': data = BufferUtils.base64Decode(String(data)); @@ -181,8 +234,8 @@ var Message = (function() { data = JSON.parse(data); continue; case 'cipher': - if(context.channelOptions != null && context.channelOptions.cipher) { - var xformAlgorithm = match[3], cipher = context.channelOptions.channelCipher; + if(context.channelOptions != null && context.channelOptions.cipher && context.channelOptions.channelCipher) { + const xformAlgorithm = match[3], cipher = context.channelOptions.channelCipher; /* don't attempt to decrypt unless the cipher params are compatible */ if(xformAlgorithm != cipher.algorithm) { throw new Error('Unable to decrypt message with given cipher; incompatible cipher params'); @@ -200,7 +253,7 @@ var Message = (function() { throw new ErrorInfo('Delta decoding not supported on this browser (need ArrayBuffer & Uint8Array)', 40020, 400); } try { - var deltaBase = context.baseEncodedPreviousPayload; + let deltaBase = context.baseEncodedPreviousPayload; if(typeof deltaBase === 'string') { deltaBase = BufferUtils.utf8Encode(deltaBase); } @@ -208,7 +261,7 @@ var Message = (function() { /* vcdiff expects Uint8Arrays, can't copy with ArrayBuffers. (also, if we * don't have a TextDecoder, deltaBase might be a WordArray here, so need * to process it into a buffer anyway) */ - deltaBase = BufferUtils.toBuffer(deltaBase); + deltaBase = BufferUtils.toBuffer(deltaBase as Buffer); data = BufferUtils.toBuffer(data); data = BufferUtils.typedArrayToBuffer(context.plugins.vcdiff.decode(data, deltaBase)); @@ -223,99 +276,73 @@ var Message = (function() { break; } } catch(e) { - throw new ErrorInfo('Error processing the ' + xform + ' encoding, decoder returned ‘' + e.message + '’', e.code || 40013, 400); + const err = e as ErrorInfo; + throw new ErrorInfo('Error processing the ' + xform + ' encoding, decoder returned ‘' + err.message + '’', err.code || 40013, 400); } finally { - message.encoding = (lastProcessedEncodingIndex <= 0) ? null : xforms.slice(0, lastProcessedEncodingIndex).join('/'); + message.encoding = (lastProcessedEncodingIndex as number <= 0) ? null : xforms.slice(0, lastProcessedEncodingIndex).join('/'); message.data = data; } } context.baseEncodedPreviousPayload = lastPayload; - }; + } - Message.fromResponseBody = function(body, options, format) { + static fromResponseBody(body: Array, options: ChannelOptions | EncodingDecodingContext, format?: Utils.Format): Message[] { if(format) { body = Utils.decodeBody(body, format); } - for(var i = 0; i < body.length; i++) { - var msg = body[i] = Message.fromValues(body[i]); + for(let i = 0; i < body.length; i++) { + const msg = body[i] = Message.fromValues(body[i]); try { Message.decode(msg, options); } catch (e) { - Logger.logAction(Logger.LOG_ERROR, 'Message.fromResponseBody()', e.toString()); + Logger.logAction(Logger.LOG_ERROR, 'Message.fromResponseBody()', (e as Error).toString()); } } return body; - }; + } - Message.fromValues = function(values) { - return Utils.mixin(new Message(), values); - }; + static fromValues(values: unknown): Message { + return Object.assign(new Message(), values); + } - Message.fromValuesArray = function(values) { - var count = values.length, result = new Array(count); - for(var i = 0; i < count; i++) result[i] = Message.fromValues(values[i]); + static fromValuesArray (values: unknown[]): Message[] { + const count = values.length, result = new Array(count); + for(let i = 0; i < count; i++) result[i] = Message.fromValues(values[i]); return result; - }; - - function normalizeCipherOptions(options) { - if(options && options.cipher && !options.cipher.channelCipher) { - if(!Crypto) throw new Error('Encryption not enabled; use ably.encryption.js instead'); - var cipher = Crypto.getCipher(options.cipher); - options.cipher = cipher.cipherParams; - options.channelCipher = cipher.cipher; - } } - Message.fromEncoded = function(encoded, options) { - var msg = Message.fromValues(encoded); - normalizeCipherOptions(options); + static fromEncoded (encoded: unknown, inputOptions: CipherOptions): Message { + const msg = Message.fromValues(encoded); + const options = normalizeCipherOptions(inputOptions); /* if decoding fails at any point, catch and return the message decoded to * the fullest extent possible */ try { Message.decode(msg, options); } catch(e) { - Logger.logAction(Logger.LOG_ERROR, 'Message.fromEncoded()', e.toString()); + Logger.logAction(Logger.LOG_ERROR, 'Message.fromEncoded()', (e as Error).toString()); } return msg; - }; + } - Message.fromEncodedArray = function(encodedArray, options) { + static fromEncodedArray (encodedArray: Array, options: CipherOptions): Message[] { normalizeCipherOptions(options); - return Utils.arrMap(encodedArray, function(encoded) { + return encodedArray.map(function(encoded) { return Message.fromEncoded(encoded, options); - }); - }; - - function getMessageSize(msg) { - var size = 0; - if(msg.name) { - size += msg.name.length; - } - if(msg.clientId) { - size += msg.clientId.length; - } - if(msg.extras) { - size += JSON.stringify(msg.extras).length; - } - if(msg.data) { - size += Utils.dataSizeBytes(msg.data); - } - return size; - }; + }) + } /* This should be called on encode()d (and encrypt()d) Messages (as it * assumes the data is a string or buffer) */ - Message.getMessagesSize = function(messages) { - var msg, total = 0; - for(var i=0; i 0) { /* stringify call */ - var encoding = this.encoding; - result.encoding = encoding ? (encoding + '/base64') : 'base64'; + encoding = encoding ? (encoding + '/base64') : 'base64'; data = BufferUtils.base64Encode(data); } else { /* Called by msgpack. toBuffer returns a datatype understandable by @@ -78,12 +73,17 @@ var PresenceMessage = (function() { data = BufferUtils.toBuffer(data); } } - result.data = data; - return result; - }; + return { + clientId: this.clientId, + /* Convert presence action back to an int for sending to Ably */ + action: toActionValue(this.action as string), + data: data, + encoding: encoding + } + } - PresenceMessage.prototype.toString = function() { - var result = '[PresenceMessage'; + toString(): string { + let result = '[PresenceMessage'; result += '; action=' + this.action; if(this.id) result += '; id=' + this.id; @@ -105,61 +105,60 @@ var PresenceMessage = (function() { } result += ']'; return result; - }; - PresenceMessage.encode = Message.encode; - PresenceMessage.decode = Message.decode; + } + + static encode = Message.encode; + static decode = Message.decode; - PresenceMessage.fromResponseBody = function(body, options, format) { + static fromResponseBody(body: Record[], options: CipherOptions, format?: Utils.Format): PresenceMessage[] { + const messages: PresenceMessage[] = []; if(format) { body = Utils.decodeBody(body, format); } - for(var i = 0; i < body.length; i++) { - var msg = body[i] = PresenceMessage.fromValues(body[i], true); + for(let i = 0; i < body.length; i++) { + const msg = messages[i] = PresenceMessage.fromValues(body[i], true); try { PresenceMessage.decode(msg, options); } catch (e) { - Logger.logAction(Logger.LOG_ERROR, 'PresenceMessage.fromResponseBody()', e.toString()); + Logger.logAction(Logger.LOG_ERROR, 'PresenceMessage.fromResponseBody()', (e as Error).toString()); } } - return body; - }; - - /* Creates a PresenceMessage from specified values, with a string presence action */ - PresenceMessage.fromValues = function(values, stringifyAction) { + return messages; + } + + static fromValues(values: PresenceMessage | Record, stringifyAction?: boolean): PresenceMessage { if(stringifyAction) { - values.action = PresenceMessage.Actions[values.action] + values.action = PresenceMessage.Actions[values.action as number] } - return Utils.mixin(new PresenceMessage(), values); - }; - - PresenceMessage.fromValuesArray = function(values) { - var count = values.length, result = new Array(count); - for(var i = 0; i < count; i++) result[i] = PresenceMessage.fromValues(values[i]); + return Object.assign(new PresenceMessage(), values); + } + + static fromValuesArray(values: unknown[]): PresenceMessage[] { + const count = values.length, result = new Array(count); + for(let i = 0; i < count; i++) result[i] = PresenceMessage.fromValues(values[i] as Record); return result; - }; + } - PresenceMessage.fromEncoded = function(encoded, options) { - var msg = PresenceMessage.fromValues(encoded, true); + static fromEncoded(encoded: Record, options: CipherOptions): PresenceMessage { + const msg = PresenceMessage.fromValues(encoded, true); /* if decoding fails at any point, catch and return the message decoded to * the fullest extent possible */ try { PresenceMessage.decode(msg, options); } catch(e) { - Logger.logAction(Logger.LOG_ERROR, 'PresenceMessage.fromEncoded()', e.toString()); + Logger.logAction(Logger.LOG_ERROR, 'PresenceMessage.fromEncoded()', (e as Error).toString()); } return msg; - }; + } - PresenceMessage.fromEncodedArray = function(encodedArray, options) { - return Utils.arrMap(encodedArray, function(encoded) { + static fromEncodedArray(encodedArray: Record[], options: CipherOptions): PresenceMessage[] { + return encodedArray.map(function(encoded) { return PresenceMessage.fromEncoded(encoded, options); }); - }; - - PresenceMessage.getMessagesSize = Message.getMessagesSize; + } - return PresenceMessage; -})(); + static getMessagesSize = Message.getMessagesSize; +} export default PresenceMessage; diff --git a/common/lib/types/protocolmessage.js b/common/lib/types/protocolmessage.js deleted file mode 100644 index 2944ff22f4..0000000000 --- a/common/lib/types/protocolmessage.js +++ /dev/null @@ -1,204 +0,0 @@ -import Utils from '../util/utils'; -import ErrorInfo from './errorinfo'; -import Message from './message'; -import PresenceMessage from './presencemessage'; - -var ProtocolMessage = (function() { - - function ProtocolMessage() { - this.action = undefined; - this.flags = undefined; - this.id = undefined; - this.timestamp = undefined; - this.count = undefined; - this.error = undefined; - this.connectionId = undefined; - this.connectionKey = undefined; - this.connectionSerial = undefined; - this.channel = undefined; - this.channelSerial = undefined; - this.msgSerial = undefined; - this.messages = undefined; - this.presence = undefined; - this.auth = undefined; - this.params = undefined; - } - - var actions = ProtocolMessage.Action = { - 'HEARTBEAT' : 0, - 'ACK' : 1, - 'NACK' : 2, - 'CONNECT' : 3, - 'CONNECTED' : 4, - 'DISCONNECT' : 5, - 'DISCONNECTED' : 6, - 'CLOSE' : 7, - 'CLOSED' : 8, - 'ERROR' : 9, - 'ATTACH' : 10, - 'ATTACHED' : 11, - 'DETACH' : 12, - 'DETACHED' : 13, - 'PRESENCE' : 14, - 'MESSAGE' : 15, - 'SYNC' : 16, - 'AUTH' : 17 - }; - - ProtocolMessage.channelModes = [ 'PRESENCE', 'PUBLISH', 'SUBSCRIBE', 'PRESENCE_SUBSCRIBE' ]; - - ProtocolMessage.ActionName = []; - Utils.arrForEach(Utils.keysArray(ProtocolMessage.Action, true), function(name) { - ProtocolMessage.ActionName[actions[name]] = name; - }); - - var flags = { - /* Channel attach state flags */ - 'HAS_PRESENCE': 1 << 0, - 'HAS_BACKLOG': 1 << 1, - 'RESUMED': 1 << 2, - 'TRANSIENT': 1 << 4, - 'ATTACH_RESUME': 1 << 5, - /* Channel mode flags */ - 'PRESENCE': 1 << 16, - 'PUBLISH': 1 << 17, - 'SUBSCRIBE': 1 << 18, - 'PRESENCE_SUBSCRIBE': 1 << 19 - }; - var flagNames = Utils.keysArray(flags); - flags.MODE_ALL = flags.PRESENCE | flags.PUBLISH | flags.SUBSCRIBE | flags.PRESENCE_SUBSCRIBE; - - ProtocolMessage.prototype.hasFlag = function(flag) { - return ((this.flags & flags[flag]) > 0); - }; - - ProtocolMessage.prototype.setFlag = function(flag) { - return this.flags = this.flags | flags[flag]; - }; - - ProtocolMessage.prototype.getMode = function() { - return this.flags && (this.flags & flags.MODE_ALL); - }; - - ProtocolMessage.prototype.encodeModesToFlags = function(modes) { - var self = this; - Utils.arrForEach(modes, function(mode) { - self.setFlag(mode); - }); - }; - - ProtocolMessage.prototype.decodeModesFromFlags = function() { - var modes = [], - self = this; - Utils.arrForEach(ProtocolMessage.channelModes, function(mode) { - if(self.hasFlag(mode)) { - modes.push(mode); - } - }); - return modes.length > 0 ? modes : undefined; - }; - - ProtocolMessage.serialize = Utils.encodeBody; - - ProtocolMessage.deserialize = function(serialized, format) { - var deserialized = Utils.decodeBody(serialized, format); - return ProtocolMessage.fromDeserialized(deserialized); - }; - - ProtocolMessage.fromDeserialized = function(deserialized) { - var error = deserialized.error; - if(error) deserialized.error = ErrorInfo.fromValues(error); - var messages = deserialized.messages; - if(messages) for(var i = 0; i < messages.length; i++) messages[i] = Message.fromValues(messages[i]); - var presence = deserialized.presence; - if(presence) for(var i = 0; i < presence.length; i++) presence[i] = PresenceMessage.fromValues(presence[i], true); - return Utils.mixin(new ProtocolMessage(), deserialized); - }; - - ProtocolMessage.fromValues = function(values) { - return Utils.mixin(new ProtocolMessage(), values); - }; - - function toStringArray(array) { - var result = []; - if (array) { - for (var i = 0; i < array.length; i++) { - result.push(array[i].toString()); - } - } - return '[ ' + result.join(', ') + ' ]'; - } - - var simpleAttributes = 'id channel channelSerial connectionId connectionKey connectionSerial count msgSerial timestamp'.split(' '); - - ProtocolMessage.stringify = function(msg) { - var result = '[ProtocolMessage'; - if(msg.action !== undefined) - result += '; action=' + ProtocolMessage.ActionName[msg.action] || msg.action; - - var attribute; - for (var attribIndex = 0; attribIndex < simpleAttributes.length; attribIndex++) { - attribute = simpleAttributes[attribIndex]; - if(msg[attribute] !== undefined) - result += '; ' + attribute + '=' + msg[attribute]; - } - - if(msg.messages) - result += '; messages=' + toStringArray(Message.fromValuesArray(msg.messages)); - if(msg.presence) - result += '; presence=' + toStringArray(PresenceMessage.fromValuesArray(msg.presence)); - if(msg.error) - result += '; error=' + ErrorInfo.fromValues(msg.error).toString(); - if(msg.auth && msg.auth.accessToken) - result += '; token=' + msg.auth.accessToken; - if(msg.flags) - result += '; flags=' + Utils.arrFilter(flagNames, function(flag) { - return msg.hasFlag(flag); - }).join(','); - if(msg.params) { - var stringifiedParams = ''; - Utils.forInOwnNonNullProps(msg.params, function(prop) { - if (stringifiedParams.length > 0) { - stringifiedParams += '; '; - } - stringifiedParams += prop + '=' + msg.params[prop]; - }); - if (stringifiedParams.length > 0) { - result += '; params=[' + stringifiedParams + ']'; - } - } - result += ']'; - return result; - }; - - /* Only valid for channel messages */ - ProtocolMessage.isDuplicate = function(a, b) { - if (a && b) { - if ((a.action === actions.MESSAGE || a.action === actions.PRESENCE) && - (a.action === b.action) && - (a.channel === b.channel) && - (a.id === b.id)) { - if (a.action === actions.PRESENCE) { - return true; - } else if (a.messages.length === b.messages.length) { - for (var i = 0; i < a.messages.length; i++) { - var aMessage = a.messages[i]; - var bMessage = b.messages[i]; - if ((aMessage.extras && aMessage.extras.delta && aMessage.extras.delta.format) !== - (bMessage.extras && bMessage.extras.delta && bMessage.extras.delta.format)) { - return false; - } - } - - return true; - } - } - } - - return false; - }; - - return ProtocolMessage; -})(); - -export default ProtocolMessage; diff --git a/common/lib/types/protocolmessage.ts b/common/lib/types/protocolmessage.ts new file mode 100644 index 0000000000..a54c43be6d --- /dev/null +++ b/common/lib/types/protocolmessage.ts @@ -0,0 +1,199 @@ +import { Types } from '../../../ably'; +import * as Utils from '../util/utils'; +import ErrorInfo from './errorinfo'; +import Message from './message'; +import PresenceMessage from './presencemessage'; + +const actions = { + 'HEARTBEAT' : 0, + 'ACK' : 1, + 'NACK' : 2, + 'CONNECT' : 3, + 'CONNECTED' : 4, + 'DISCONNECT' : 5, + 'DISCONNECTED' : 6, + 'CLOSE' : 7, + 'CLOSED' : 8, + 'ERROR' : 9, + 'ATTACH' : 10, + 'ATTACHED' : 11, + 'DETACH' : 12, + 'DETACHED' : 13, + 'PRESENCE' : 14, + 'MESSAGE' : 15, + 'SYNC' : 16, + 'AUTH' : 17 +}; + +const ActionName: string[] = []; +Object.keys(actions).forEach(function(name) { + ActionName[(actions as { [key: string]: number })[name]] = name; +}) + +const flags: {[key: string] : number} = { + /* Channel attach state flags */ + 'HAS_PRESENCE': 1 << 0, + 'HAS_BACKLOG': 1 << 1, + 'RESUMED': 1 << 2, + 'TRANSIENT': 1 << 4, + 'ATTACH_RESUME': 1 << 5, + /* Channel mode flags */ + 'PRESENCE': 1 << 16, + 'PUBLISH': 1 << 17, + 'SUBSCRIBE': 1 << 18, + 'PRESENCE_SUBSCRIBE': 1 << 19 +}; +const flagNames = Object.keys(flags); +flags.MODE_ALL = flags.PRESENCE | flags.PUBLISH | flags.SUBSCRIBE | flags.PRESENCE_SUBSCRIBE; + +function toStringArray(array?: any[]): string { + const result = []; + if (array) { + for (let i = 0; i < array.length; i++) { + result.push(array[i].toString()); + } + } + return '[ ' + result.join(', ') + ' ]'; +} + +const simpleAttributes = 'id channel channelSerial connectionId connectionKey connectionSerial count msgSerial timestamp'.split(' '); + +class ProtocolMessage { + action?: number; + flags?: number; + id?: string; + timestamp?: number; + count?: number; + error?: ErrorInfo; + connectionId?: string; + connectionKey?: string; + connectionSerial?: number; + channel?: string; + channelSerial?: number | null; + msgSerial?: number; + messages?: Message[]; + presence?: PresenceMessage[]; + auth?: unknown; + connectionDetails?: Record; + timeSerial?: number; + + static Action = actions; + + static channelModes = [ 'PRESENCE', 'PUBLISH', 'SUBSCRIBE', 'PRESENCE_SUBSCRIBE' ]; + + static ActionName = ActionName; + + hasFlag = (flag: string): boolean => { + return (((this.flags as number) & flags[flag]) > 0); + }; + + setFlag(flag: Types.ChannelMode): number { + return this.flags = (this.flags as number) | flags[flag]; + } + + getMode(): number | undefined { + return this.flags && (this.flags & flags.MODE_ALL); + } + + encodeModesToFlags(modes: Types.ChannelMode[]): void { + modes.forEach(mode => this.setFlag(mode)); + } + + decodeModesFromFlags(): string[] | undefined { + const modes: string[] = []; + ProtocolMessage.channelModes.forEach(mode => { + if (this.hasFlag(mode)) { + modes.push(mode); + } + }); + return modes.length > 0 ? modes : undefined; + } + + static serialize = Utils.encodeBody; + + static deserialize = function(serialized: unknown, format?: Utils.Format): ProtocolMessage { + const deserialized = Utils.decodeBody>(serialized, format); + return ProtocolMessage.fromDeserialized(deserialized); + }; + + static fromDeserialized = function(deserialized: Record): ProtocolMessage { + const error = deserialized.error; + if(error) deserialized.error = ErrorInfo.fromValues(error as ErrorInfo); + const messages = deserialized.messages as Message[]; + if(messages) for(let i = 0; i < messages.length; i++) messages[i] = Message.fromValues(messages[i]); + const presence = deserialized.presence as PresenceMessage[]; + if(presence) for(let i = 0; i < presence.length; i++) presence[i] = PresenceMessage.fromValues(presence[i], true); + return Object.assign(new ProtocolMessage(), deserialized); + }; + + static fromValues (values: unknown): ProtocolMessage { + return Object.assign(new ProtocolMessage(), values); + } + + static stringify = function(msg: any): string { + let result = '[ProtocolMessage'; + if(msg.action !== undefined) + result += '; action=' + ProtocolMessage.ActionName[msg.action] || msg.action; + + let attribute; + for (let attribIndex = 0; attribIndex < simpleAttributes.length; attribIndex++) { + attribute = simpleAttributes[attribIndex]; + if(msg[attribute] !== undefined) + result += '; ' + attribute + '=' + msg[attribute]; + } + + if(msg.messages) + result += '; messages=' + toStringArray(Message.fromValuesArray(msg.messages)); + if(msg.presence) + result += '; presence=' + toStringArray(PresenceMessage.fromValuesArray(msg.presence)); + if(msg.error) + result += '; error=' + ErrorInfo.fromValues(msg.error).toString(); + if(msg.auth && msg.auth.accessToken) + result += '; token=' + msg.auth.accessToken; + if(msg.flags) + result += '; flags=' + flagNames.filter(msg.hasFlag).join(','); + if(msg.params) { + let stringifiedParams = ''; + Utils.forInOwnNonNullProperties(msg.params, function(prop: string) { + if (stringifiedParams.length > 0) { + stringifiedParams += '; '; + } + stringifiedParams += prop + '=' + msg.params[prop]; + }); + if (stringifiedParams.length > 0) { + result += '; params=[' + stringifiedParams + ']'; + } + } + result += ']'; + return result; + }; + + /* Only valid for channel messages */ + static isDuplicate = function(a: any, b: any): boolean { + if (a && b) { + if ((a.action === actions.MESSAGE || a.action === actions.PRESENCE) && + (a.action === b.action) && + (a.channel === b.channel) && + (a.id === b.id)) { + if (a.action === actions.PRESENCE) { + return true; + } else if (a.messages.length === b.messages.length) { + for (let i = 0; i < a.messages.length; i++) { + const aMessage = a.messages[i]; + const bMessage = b.messages[i]; + if ((aMessage.extras && aMessage.extras.delta && aMessage.extras.delta.format) !== + (bMessage.extras && bMessage.extras.delta && bMessage.extras.delta.format)) { + return false; + } + } + + return true; + } + } + } + + return false; + }; +} + +export default ProtocolMessage; diff --git a/common/lib/types/pushchannelsubscription.js b/common/lib/types/pushchannelsubscription.js deleted file mode 100644 index 161c27b357..0000000000 --- a/common/lib/types/pushchannelsubscription.js +++ /dev/null @@ -1,62 +0,0 @@ -import Utils from '../util/utils'; - -var PushChannelSubscription = (function() { - - function PushChannelSubscription() { - this.channel = undefined; - this.deviceId = undefined; - this.clientId = undefined; - } - - /** - * Overload toJSON() to intercept JSON.stringify() - * @return {*} - */ - PushChannelSubscription.prototype.toJSON = function() { - return { - channel: this.channel, - deviceId: this.deviceId, - clientId: this.clientId - }; - }; - - PushChannelSubscription.prototype.toString = function() { - var result = '[PushChannelSubscription'; - if(this.channel) - result += '; channel=' + this.channel; - if(this.deviceId) - result += '; deviceId=' + this.deviceId; - if(this.clientId) - result += '; clientId=' + this.clientId; - result += ']'; - return result; - }; - - PushChannelSubscription.toRequestBody = Utils.encodeBody; - - PushChannelSubscription.fromResponseBody = function(body, format) { - if(format) { - body = Utils.decodeBody(body, format); - } - - if(Utils.isArray(body)) { - return PushChannelSubscription.fromValuesArray(body); - } else { - return PushChannelSubscription.fromValues(body); - } - }; - - PushChannelSubscription.fromValues = function(values) { - return Utils.mixin(new PushChannelSubscription(), values); - }; - - PushChannelSubscription.fromValuesArray = function(values) { - var count = values.length, result = new Array(count); - for(var i = 0; i < count; i++) result[i] = PushChannelSubscription.fromValues(values[i]); - return result; - }; - - return PushChannelSubscription; -})(); - -export default PushChannelSubscription; diff --git a/common/lib/types/pushchannelsubscription.ts b/common/lib/types/pushchannelsubscription.ts new file mode 100644 index 0000000000..1cc3a2c39c --- /dev/null +++ b/common/lib/types/pushchannelsubscription.ts @@ -0,0 +1,63 @@ +import * as Utils from "../util/utils"; + +type PushChannelSubscriptionObject = { + channel?: string, + deviceId?: string, + clientId?: string, +} + +class PushChannelSubscription { + channel?: string; + deviceId?: string; + clientId?: string; + + /** + * Overload toJSON() to intercept JSON.stringify() + * @return {*} + */ + toJSON(): PushChannelSubscriptionObject { + return { + channel: this.channel, + deviceId: this.deviceId, + clientId: this.clientId + }; + } + + toString(): string { + let result = '[PushChannelSubscription'; + if(this.channel) + result += '; channel=' + this.channel; + if(this.deviceId) + result += '; deviceId=' + this.deviceId; + if(this.clientId) + result += '; clientId=' + this.clientId; + result += ']'; + return result; + } + + static toRequestBody = Utils.encodeBody; + + static fromResponseBody(body: Array> | Record, format?: Utils.Format): PushChannelSubscription | PushChannelSubscription[] { + if(format) { + body = Utils.decodeBody(body, format) as Record; + } + + if(Utils.isArray(body)) { + return PushChannelSubscription.fromValuesArray(body); + } else { + return PushChannelSubscription.fromValues(body); + } + } + + static fromValues(values: Record): PushChannelSubscription { + return Object.assign(new PushChannelSubscription(), values); + } + + static fromValuesArray(values: Array>): PushChannelSubscription[] { + const count = values.length, result = new Array(count); + for(let i = 0; i < count; i++) result[i] = PushChannelSubscription.fromValues(values[i]); + return result; + } +} + +export default PushChannelSubscription; diff --git a/common/lib/types/stats.js b/common/lib/types/stats.js deleted file mode 100644 index 61adb862fe..0000000000 --- a/common/lib/types/stats.js +++ /dev/null @@ -1,126 +0,0 @@ -import Utils from '../util/utils'; - -var Stats = (function() { - - function MessageCount(values) { - this.count = (values && values.count) || 0; - this.data = (values && values.data) || 0; - this.uncompressedData = (values && values.uncompressedData) || 0; - this.failed = (values && values.failed) || 0; - this.refused = (values && values.refused) || 0; - } - - function MessageCategory(values) { - var self = this; - MessageCount.call(this, values); - this.category = undefined; - if (values && values.category) { - this.category = { }; - Utils.forInOwnNonNullProps(values.category, function(prop) { - self.category[prop] = new MessageCount(values.category[prop]); - }); - } - } - - function ResourceCount(values) { - this.peak = (values && values.peak) || 0; - this.min = (values && values.min) || 0; - this.mean = (values && values.mean) || 0; - this.opened = (values && values.opened) || 0; - this.refused = (values && values.refused) || 0; - } - - function RequestCount(values) { - this.succeeded = (values && values.succeeded) || 0; - this.failed = (values && values.failed) || 0; - this.refused = (values && values.refused) || 0; - } - - function ConnectionTypes(values) { - this.plain = new ResourceCount(values && values.plain); - this.tls = new ResourceCount(values && values.tls); - this.all = new ResourceCount(values && values.all); - } - - function MessageTypes(values) { - this.messages = new MessageCategory(values && values.messages); - this.presence = new MessageCategory(values && values.presence); - this.all = new MessageCategory(values && values.all); - } - - function MessageTraffic(values) { - this.realtime = new MessageTypes(values && values.realtime); - this.rest = new MessageTypes(values && values.rest); - this.webhook = new MessageTypes(values && values.webhook); - this.sharedQueue = new MessageTypes(values && values.sharedQueue); - this.externalQueue = new MessageTypes(values && values.externalQueue); - this.httpEvent = new MessageTypes(values && values.httpEvent); - this.push = new MessageTypes(values && values.push); - this.all = new MessageTypes(values && values.all); - } - - function MessageDirections(values) { - this.all = new MessageTypes(values && values.all); - this.inbound = new MessageTraffic(values && values.inbound); - this.outbound = new MessageTraffic(values && values.outbound); - } - - function XchgMessages(values) { - this.all = new MessageTypes(values && values.all); - this.producerPaid = new MessageDirections(values && values.producerPaid); - this.consumerPaid = new MessageDirections(values && values.consumerPaid); - } - - function PushStats(values) { - this.messages = (values && values.messages) || 0; - var notifications = values && values.notifications; - this.notifications = { - invalid: notifications && notifications.invalid || 0, - attempted: notifications && notifications.attempted || 0, - successful: notifications && notifications.successful || 0, - failed: notifications && notifications.failed || 0 - }; - this.directPublishes = (values && values.directPublishes) || 0; - } - - function ProcessedCount(values) { - this.succeeded = (values && values.succeeded) || 0; - this.skipped = (values && values.skipped) || 0; - this.failed = (values && values.failed) || 0; - } - - function ProcessedMessages(values) { - var self = this; - this.delta = undefined; - if (values && values.delta) { - this.delta = { }; - Utils.forInOwnNonNullProps(values.delta, function(prop) { - self.delta[prop] = new ProcessedCount(values.delta[prop]); - }); - } - } - - function Stats(values) { - MessageDirections.call(this, values); - this.persisted = new MessageTypes(values && values.persisted); - this.connections = new ConnectionTypes(values && values.connections); - this.channels = new ResourceCount(values && values.channels); - this.apiRequests = new RequestCount(values && values.apiRequests); - this.tokenRequests = new RequestCount(values && values.tokenRequests); - this.xchgProducer = new XchgMessages(values && values.xchgProducer); - this.xchgConsumer = new XchgMessages(values && values.xchgConsumer); - this.push = new PushStats(values && values.pushStats); - this.processed = new ProcessedMessages(values && values.processed); - this.inProgress = (values && values.inProgress) || undefined; - this.unit = (values && values.unit) || undefined; - this.intervalId = (values && values.intervalId) || undefined; - } - - Stats.fromValues = function(values) { - return new Stats(values); - }; - - return Stats; -})(); - -export default Stats; diff --git a/common/lib/types/stats.ts b/common/lib/types/stats.ts new file mode 100644 index 0000000000..7130d5c65f --- /dev/null +++ b/common/lib/types/stats.ts @@ -0,0 +1,308 @@ +import * as Utils from "../util/utils" + +type MessageValues = { + count?: number; + data?: number; + uncompressedData?: number; + failed?: number; + refused?: number; + category?: Record; +} + +type ResourceValues = { + peak?: number; + min?: number; + mean?: number; + opened?: number; + refused?: number; +} + +type RequestValues = { + succeeded?: number; + failed?: number; + refused?: number; +} + +type ConnectionTypesValues = { + plain?: ResourceValues; + tls?: ResourceValues; + all?: ResourceValues; +} + +type MessageTypesValues = { + messages?: MessageValues; + presence?: MessageValues; + all?: MessageValues; +} + +type MessageTrafficValues = { + realtime?: MessageTypesValues; + rest?: MessageTypesValues; + webhook?: MessageTypesValues; + sharedQueue?: MessageTypesValues; + externalQueue?: MessageTypesValues; + httpEvent?: MessageTypesValues; + push?: MessageTypesValues; + all?: MessageTypesValues; +} + +type MessageDirectionsValues = { + all?: MessageTypesValues; + inbound?: MessageTrafficValues; + outbound?: MessageTrafficValues; +} + +type XchgMessagesValues = { + all?: MessageTypesValues; + producerPaid?: MessageDirectionsValues; + consumerPaid?: MessageDirectionsValues; +} + +type NotificationsValues = { + invalid?: number; + attempted?: number; + successful?: number; + failed?: number; +} + +type PushValues = { + messages?: number; + notifications?: NotificationsValues; + directPublishes?: number; +} + +type ProcessedCountValues = { + succeeded?: number; + skipped?: number; + failed?: number; +} + +type ProcessedMessagesValues = { + delta?: Record; +} + +type StatsValues = { + all?: MessageTypesValues; + inbound?: MessageTrafficValues; + outbound?: MessageTrafficValues; + persisted?: MessageTypesValues; + connections?: ConnectionTypesValues; + channels?: ResourceValues; + apiRequests?: RequestValues; + tokenRequests?: RequestValues; + xchgProducer?: XchgMessagesValues; + xchgConsumer?: XchgMessagesValues; + pushStats?: PushValues; + processed?: ProcessedMessagesValues; + inProgress?: never; + unit?: never; + intervalId?: never; +} + +class MessageCount { + count?: number; + data?: number; + uncompressedData?: number; + failed?: number; + refused?: number; + + constructor(values?: MessageValues) { + this.count = (values && values.count) || 0; + this.data = (values && values.data) || 0; + this.uncompressedData = (values && values.uncompressedData) || 0; + this.failed = (values && values.failed) || 0; + this.refused = (values && values.refused) || 0; + } +} + +class MessageCategory extends MessageCount { + category?: Record; + constructor(values?: MessageValues) { + super(values); + if (values && values.category) { + this.category = {}; + Utils.forInOwnNonNullProperties(values.category, (prop: string) => { + (this.category as Record)[prop] = new MessageCount((values.category as Record)[prop]); + }); + } + } +} + +class ResourceCount { + peak?: number; + min?: number; + mean?: number; + opened?: number; + refused?: number; + + constructor(values?: ResourceValues) { + this.peak = (values && values.peak) || 0; + this.min = (values && values.min) || 0; + this.mean = (values && values.mean) || 0; + this.opened = (values && values.opened) || 0; + this.refused = (values && values.refused) || 0; + } +} + +class RequestCount { + succeeded?: number; + failed?: number; + refused?: number; + + constructor(values?: RequestValues) { + this.succeeded = (values && values.succeeded) || 0; + this.failed = (values && values.failed) || 0; + this.refused = (values && values.refused) || 0; + } +} + +class ConnectionTypes { + plain?: ResourceCount; + tls?: ResourceCount; + all?: ResourceCount; + + constructor(values?: ConnectionTypesValues) { + this.plain = new ResourceCount(values && values.plain); + this.tls = new ResourceCount(values && values.tls); + this.all = new ResourceCount(values && values.all); + } +} + +class MessageTypes { + messages?: MessageCategory; + presence?: MessageCategory; + all?: MessageCategory; + + constructor(values?: MessageTypesValues) { + this.messages = new MessageCategory(values && values.messages); + this.presence = new MessageCategory(values && values.presence); + this.all = new MessageCategory(values && values.all); + } +} + +class MessageTraffic { + realtime?: MessageTypes; + rest?: MessageTypes; + webhook?: MessageTypes; + sharedQueue?: MessageTypes; + externalQueue?: MessageTypes; + httpEvent?: MessageTypes; + push?: MessageTypes; + all?: MessageTypes; + + constructor(values?: MessageTrafficValues) { + this.realtime = new MessageTypes(values && values.realtime); + this.rest = new MessageTypes(values && values.rest); + this.webhook = new MessageTypes(values && values.webhook); + this.sharedQueue = new MessageTypes(values && values.sharedQueue); + this.externalQueue = new MessageTypes(values && values.externalQueue); + this.httpEvent = new MessageTypes(values && values.httpEvent); + this.push = new MessageTypes(values && values.push); + this.all = new MessageTypes(values && values.all); + } +} + +class MessageDirections { + all?: MessageTypes; + inbound?: MessageTraffic; + outbound?: MessageTraffic; + + constructor(values?: MessageDirectionsValues) { + this.all = new MessageTypes(values && values.all); + this.inbound = new MessageTraffic(values && values.inbound); + this.outbound = new MessageTraffic(values && values.outbound); + } +} + +class XchgMessages { + all?: MessageTypes; + producerPaid?: MessageDirections; + consumerPaid?: MessageDirections; + + constructor(values?: XchgMessagesValues) { + this.all = new MessageTypes(values && values.all); + this.producerPaid = new MessageDirections(values && values.producerPaid); + this.consumerPaid = new MessageDirections(values && values.consumerPaid); + } +} + +class PushStats { + messages?: number; + notifications?: NotificationsValues; + directPublishes?: number; + + constructor(values?: PushValues) { + this.messages = (values && values.messages) || 0; + const notifications = values && values.notifications; + this.notifications = { + invalid: notifications && notifications.invalid || 0, + attempted: notifications && notifications.attempted || 0, + successful: notifications && notifications.successful || 0, + failed: notifications && notifications.failed || 0 + }; + this.directPublishes = (values && values.directPublishes) || 0; + } +} + +class ProcessedCount { + succeeded?: number; + skipped?: number; + failed?: number; + + constructor(values: ProcessedCountValues) { + this.succeeded = (values && values.succeeded) || 0; + this.skipped = (values && values.skipped) || 0; + this.failed = (values && values.failed) || 0; + } +} + +class ProcessedMessages { + delta?: Record; + + constructor(values?: ProcessedMessagesValues) { + this.delta = undefined; + if (values && values.delta) { + this.delta = { }; + Utils.forInOwnNonNullProperties(values.delta, (prop: string) => { + (this.delta as Record)[prop] = new ProcessedCount((values.delta as Record)[prop]); + }); + } + } +} + +class Stats extends MessageDirections { + persisted?: MessageTypes; + connections?: ConnectionTypes; + channels?: ResourceCount; + apiRequests?: RequestCount; + tokenRequests?: RequestCount; + xchgProducer?: XchgMessages; + xchgConsumer?: XchgMessages; + push?: PushStats; + processed?: ProcessedMessages; + inProgress?: never; + unit?: never; + intervalId?: never; + + constructor(values?: StatsValues) { + super(values as MessageDirectionsValues); + this.persisted = new MessageTypes(values && values.persisted); + this.connections = new ConnectionTypes(values && values.connections); + this.channels = new ResourceCount(values && values.channels); + this.apiRequests = new RequestCount(values && values.apiRequests); + this.tokenRequests = new RequestCount(values && values.tokenRequests); + this.xchgProducer = new XchgMessages(values && values.xchgProducer); + this.xchgConsumer = new XchgMessages(values && values.xchgConsumer); + this.push = new PushStats(values && values.pushStats); + this.processed = new ProcessedMessages(values && values.processed); + this.inProgress = (values && values.inProgress) || undefined; + this.unit = (values && values.unit) || undefined; + this.intervalId = (values && values.intervalId) || undefined; + } + + static fromValues(values: StatsValues): Stats { + return new Stats(values); + } +} + +export default Stats; diff --git a/common/lib/util/defaults.js b/common/lib/util/defaults.ts similarity index 51% rename from common/lib/util/defaults.js rename to common/lib/util/defaults.ts index 926f500a93..c6da6cbc68 100644 --- a/common/lib/util/defaults.js +++ b/common/lib/util/defaults.ts @@ -1,69 +1,82 @@ -import Defaults from 'platform-defaults'; +import PlatformDefaults from 'platform-defaults'; import Platform from 'platform'; -import Utils from './utils'; -import BufferUtils from 'platform-bufferutils'; +import * as BufferUtils from 'platform-bufferutils'; +import * as Utils from './utils'; import Logger from './logger'; import ErrorInfo from '../types/errorinfo'; import { version } from '../../../package.json'; +import ClientOptions, { DeprecatedClientOptions, NormalisedClientOptions } from '../../types/ClientOptions'; -Defaults.ENVIRONMENT = ''; -Defaults.REST_HOST = 'rest.ably.io'; -Defaults.REALTIME_HOST = 'realtime.ably.io'; -Defaults.FALLBACK_HOSTS = ['A.ably-realtime.com', 'B.ably-realtime.com', 'C.ably-realtime.com', 'D.ably-realtime.com', 'E.ably-realtime.com']; -Defaults.PORT = 80; -Defaults.TLS_PORT = 443; -Defaults.TIMEOUTS = { - /* Documented as options params: */ - disconnectedRetryTimeout : 15000, - suspendedRetryTimeout : 30000, - /* Undocumented, but part of the api and can be used by customers: */ - httpRequestTimeout : 15000, - channelRetryTimeout : 15000, - fallbackRetryTimeout : 600000, - /* For internal / test use only: */ - connectionStateTtl : 120000, - realtimeRequestTimeout : 10000, - recvTimeout : 90000, - preferenceConnectTimeout : 6000, - parallelUpgradeDelay : 6000 -}; -Defaults.httpMaxRetryCount = 3; -Defaults.maxMessageSize = 65536; - -Defaults.errorReportingUrl = 'https://errors.ably.io/api/15/store/'; -Defaults.errorReportingHeaders = { - "X-Sentry-Auth": "Sentry sentry_version=7, sentry_key=a04e33c8674c451f8a310fbec029acf5, sentry_client=ably-js/0.1", - "Content-Type": "application/json" -}; - -Defaults.version = version; -Defaults.apiVersion = '1.2'; - -var agent = 'ably-js/' + Defaults.version; +let agent = 'ably-js/' + version; if (Platform.agent) { agent += ' ' + Platform.agent; -} -Defaults.agent = agent; +} -Defaults.getHost = function(options, host, ws) { +const Defaults = { + ...PlatformDefaults, + ENVIRONMENT : '', + REST_HOST : 'rest.ably.io', + REALTIME_HOST : 'realtime.ably.io', + FALLBACK_HOSTS : ['A.ably-realtime.com', 'B.ably-realtime.com', 'C.ably-realtime.com', 'D.ably-realtime.com', 'E.ably-realtime.com'], + PORT : 80, + TLS_PORT : 443, + TIMEOUTS : { + /* Documented as options params: */ + disconnectedRetryTimeout : 15000, + suspendedRetryTimeout : 30000, + /* Undocumented, but part of the api and can be used by customers: */ + httpRequestTimeout : 15000, + channelRetryTimeout : 15000, + fallbackRetryTimeout : 600000, + /* For internal / test use only: */ + connectionStateTtl : 120000, + realtimeRequestTimeout : 10000, + recvTimeout : 90000, + preferenceConnectTimeout : 6000, + parallelUpgradeDelay : 6000 + }, + httpMaxRetryCount : 3, + maxMessageSize : 65536, + + errorReportingUrl : 'https://errors.ably.io/api/15/store/', + errorReportingHeaders : { + "X-Sentry-Auth": "Sentry sentry_version=7, sentry_key=a04e33c8674c451f8a310fbec029acf5, sentry_client=ably-js/0.1", + "Content-Type": "application/json" + }, + + version, + apiVersion : '1.2', + agent, + getHost, + getPort, + getHttpScheme, + environmentFallbackHosts, + getFallbackHosts, + getHosts, + checkHost, + objectifyOptions, + normaliseOptions, +} + +export function getHost(options: ClientOptions, host?: string | null, ws?: boolean): string { if(ws) host = ((host == options.restHost) && options.realtimeHost) || host || options.realtimeHost; else host = host || options.restHost; - return host; -}; + return host as string; +} -Defaults.getPort = function(options, tls) { +export function getPort(options: ClientOptions, tls?: boolean): number | undefined { return (tls || options.tls) ? options.tlsPort : options.port; -}; +} -Defaults.getHttpScheme = function(options) { +export function getHttpScheme (options: ClientOptions): string { return options.tls ? 'https://' : 'http://'; -}; +} // construct environment fallback hosts as per RSC15i -Defaults.environmentFallbackHosts = function(environment) { +export function environmentFallbackHosts (environment: string): string[] { return [ environment + '-a-fallback.ably-realtime.com', environment + '-b-fallback.ably-realtime.com', @@ -71,36 +84,56 @@ Defaults.environmentFallbackHosts = function(environment) { environment + '-d-fallback.ably-realtime.com', environment + '-e-fallback.ably-realtime.com' ]; -}; +} -Defaults.getFallbackHosts = function(options) { - var fallbackHosts = options.fallbackHosts, +export function getFallbackHosts (options: NormalisedClientOptions): string[] { + const fallbackHosts = options.fallbackHosts, httpMaxRetryCount = typeof(options.httpMaxRetryCount) !== 'undefined' ? options.httpMaxRetryCount : Defaults.httpMaxRetryCount; return fallbackHosts ? Utils.arrChooseN(fallbackHosts, httpMaxRetryCount) : []; -}; +} -Defaults.getHosts = function(options) { - return [options.restHost].concat(Defaults.getFallbackHosts(options)); -}; +export function getHosts (options: NormalisedClientOptions): string[] { + return [options.restHost].concat(getFallbackHosts(options)); +} -function checkHost(host) { +function checkHost(host: string): void { if(typeof host !== 'string') { throw new ErrorInfo('host must be a string; was a ' + typeof host, 40000, 400); - }; + } if(!host.length) { throw new ErrorInfo('host must not be zero-length', 40000, 400); - }; + } +} + +function getRealtimeHost(options: ClientOptions, production: boolean, environment: string): string { + if(options.realtimeHost) return options.realtimeHost; + /* prefer setting realtimeHost to restHost as a custom restHost typically indicates + * a development environment is being used that can't be inferred by the library */ + if(options.restHost) { + Logger.logAction(Logger.LOG_MINOR, 'Defaults.normaliseOptions', 'restHost is set to "' + options.restHost + '" but realtimeHost is not set, so setting realtimeHost to "' + options.restHost + '" too. If this is not what you want, please set realtimeHost explicitly.'); + return options.restHost + } + return production ? Defaults.REALTIME_HOST : environment + '-' + Defaults.REALTIME_HOST; } -Defaults.objectifyOptions = function(options) { +function getTimeouts(options: ClientOptions) { + /* Allow values passed in options to override default timeouts */ + const timeouts: Record = {}; + for(const prop in Defaults.TIMEOUTS) { + timeouts[prop] = (options as Record)[prop] || (Defaults.TIMEOUTS as Record)[prop]; + } + return timeouts; +} + +export function objectifyOptions(options: ClientOptions | string): ClientOptions { if(typeof options == 'string') { return (options.indexOf(':') == -1) ? {token: options} : {key: options}; } return options; -}; +} -Defaults.normaliseOptions = function(options) { +export function normaliseOptions(options: DeprecatedClientOptions): NormalisedClientOptions { /* Deprecated options */ if(options.host) { Logger.deprecated('host', 'restHost'); @@ -118,14 +151,14 @@ Defaults.normaliseOptions = function(options) { if(options.fallbackHostsUseDefault) { /* fallbackHostsUseDefault and fallbackHosts are mutually exclusive as per TO3k7 */ if(options.fallbackHosts) { - var msg = 'fallbackHosts and fallbackHostsUseDefault cannot both be set'; + const msg = 'fallbackHosts and fallbackHostsUseDefault cannot both be set'; Logger.logAction(Logger.LOG_ERROR, 'Defaults.normaliseOptions', msg); throw new ErrorInfo(msg, 40000, 400); } /* default fallbacks can't be used with custom ports */ if(options.port || options.tlsPort) { - var msg = 'fallbackHostsUseDefault cannot be set when port or tlsPort are set'; + const msg = 'fallbackHostsUseDefault cannot be set when port or tlsPort are set'; Logger.logAction(Logger.LOG_ERROR, 'Defaults.normaliseOptions', msg); throw new ErrorInfo(msg, 40000, 400); } @@ -141,14 +174,15 @@ Defaults.normaliseOptions = function(options) { options.fallbackHosts = Defaults.FALLBACK_HOSTS; } - if(options.recover === true) { + /* options.recover as a boolean is deprecated, and therefore is not part of the public typing */ + if(options.recover as any === true) { Logger.deprecated('{recover: true}', '{recover: function(lastConnectionDetails, cb) { cb(true); }}'); - options.recover = function(lastConnectionDetails, cb) { cb(true); }; + options.recover = function(lastConnectionDetails: unknown, cb: (shouldRecover: boolean) => void) { cb(true); }; } if(typeof options.recover === 'function' && options.closeOnUnload === true) { Logger.logAction(Logger.LOG_ERROR, 'Defaults.normaliseOptions', 'closeOnUnload was true and a session recovery function was set - these are mutually exclusive, so unsetting the latter'); - options.recover = null; + options.recover = undefined; } if(!('closeOnUnload' in options)) { @@ -167,40 +201,23 @@ Defaults.normaliseOptions = function(options) { options.queueMessages = true; /* infer hosts and fallbacks based on the configured environment */ - var environment = (options.environment && String(options.environment).toLowerCase()) || Defaults.ENVIRONMENT; - var production = !environment || (environment === 'production'); + const environment = (options.environment && String(options.environment).toLowerCase()) || Defaults.ENVIRONMENT; + const production = !environment || (environment === 'production'); if(!options.fallbackHosts && !options.restHost && !options.realtimeHost && !options.port && !options.tlsPort) { - options.fallbackHosts = production ? Defaults.FALLBACK_HOSTS : Defaults.environmentFallbackHosts(environment); + options.fallbackHosts = production ? Defaults.FALLBACK_HOSTS : environmentFallbackHosts(environment); } - if(!options.realtimeHost) { - /* prefer setting realtimeHost to restHost as a custom restHost typically indicates - * a development environment is being used that can't be inferred by the library */ - if(options.restHost) { - Logger.logAction(Logger.LOG_WARN, 'Defaults.normaliseOptions', 'restHost is set to "' + options.restHost + '" but realtimeHost is not set, so setting realtimeHost to "' + options.restHost + '" too. If this is not what you want, please set realtimeHost explicitly.'); - options.realtimeHost = options.restHost - } else { - options.realtimeHost = production ? Defaults.REALTIME_HOST : environment + '-' + Defaults.REALTIME_HOST; - } - } + const restHost = options.restHost || (production ? Defaults.REST_HOST : environment + '-' + Defaults.REST_HOST); + const realtimeHost = getRealtimeHost(options, production, environment); - if(!options.restHost) { - options.restHost = production ? Defaults.REST_HOST : environment + '-' + Defaults.REST_HOST; - } - - Utils.arrForEach((options.fallbackHosts || []).concat(options.restHost, options.realtimeHost), checkHost); + Utils.arrForEach((options.fallbackHosts || []).concat(restHost, realtimeHost), checkHost); options.port = options.port || Defaults.PORT; options.tlsPort = options.tlsPort || Defaults.TLS_PORT; - options.maxMessageSize = options.maxMessageSize || Defaults.maxMessageSize; if(!('tls' in options)) options.tls = true; - /* Allow values passed in options to override default timeouts */ - options.timeouts = {}; - for(var prop in Defaults.TIMEOUTS) { - options.timeouts[prop] = options[prop] || Defaults.TIMEOUTS[prop]; - }; + const timeouts = getTimeouts(options); if('useBinaryProtocol' in options) { options.useBinaryProtocol = Platform.supportsBinary && options.useBinaryProtocol; @@ -209,7 +226,7 @@ Defaults.normaliseOptions = function(options) { } if(options.clientId) { - var headers = options.headers = options.headers || {}; + const headers = options.headers = options.headers || {}; headers['X-Ably-ClientId'] = BufferUtils.base64Encode(BufferUtils.utf8Encode(options.clientId)); } @@ -222,13 +239,20 @@ Defaults.normaliseOptions = function(options) { options.promises = false; } - if(options.agents) { - for(var key in options.agents) { - Defaults.agent += ' ' + key + '/' + options.agents[key]; - } - } + if(options.agents) { + for (var key in options.agents) { + Defaults.agent += ' ' + key + '/' + options.agents[key]; + } + } - return options; + return { + ...options, + useBinaryProtocol: ('useBinaryProtocol' in options) ? Platform.supportsBinary && options.useBinaryProtocol : Platform.preferBinary, + realtimeHost, + restHost, + maxMessageSize: options.maxMessageSize || Defaults.maxMessageSize, + timeouts + }; }; export default Defaults; diff --git a/common/lib/util/errorreporter.js b/common/lib/util/errorreporter.ts similarity index 57% rename from common/lib/util/errorreporter.js rename to common/lib/util/errorreporter.ts index ba80861914..a4a2f56595 100644 --- a/common/lib/util/errorreporter.js +++ b/common/lib/util/errorreporter.ts @@ -1,25 +1,26 @@ -import Utils from './utils'; +import * as Utils from './utils'; import Platform from 'platform'; -import Defaults from '../util/defaults'; +import Defaults from './defaults'; import Logger from './logger'; import Http from 'platform-http'; +import ErrorInfo from '../types/errorinfo'; +import { ErrnoException } from '../../types/http'; -var ErrorReporter = (function() { - function ErrorReporter() {} +const levels = [ + 'fatal', + 'error', + 'warning', + 'info', + 'debug' +]; - var levels = ErrorReporter.levels = [ - 'fatal', - 'error', - 'warning', - 'info', - 'debug' - ]; +class ErrorReporter { + static levels = levels; - /* (level: typeof ErrorReporter.levels[number], message: string, fingerprint?: string, tags?: {[key: string]: string}): void */ - ErrorReporter.report = function(level, message, fingerprint, tags) { - var eventId = Utils.randomHexString(16); + static report (level: string, message: string, fingerprint?: string, tags?: Record): void { + const eventId = Utils.randomHexString(16); - var event = { + const event = { event_id: eventId, tags: Utils.mixin({ ablyAgent: Defaults.agent @@ -38,14 +39,12 @@ var ErrorReporter = (function() { }; Logger.logAction(Logger.LOG_MICRO, 'ErrorReporter', 'POSTing to error reporter: ' + message); - Http.postUri(null, Defaults.errorReportingUrl, Defaults.errorReportingHeaders, JSON.stringify(event), {}, function(err, res) { + Http.postUri(null, Defaults.errorReportingUrl, Defaults.errorReportingHeaders, JSON.stringify(event), {}, function(err?: ErrorInfo | ErrnoException | null, res?: unknown) { Logger.logAction(Logger.LOG_MICRO, 'ErrorReporter', 'POSTing to error reporter resulted in: ' + (err ? Utils.inspectError(err) : Utils.inspectBody(res)) ); }); - }; - - return ErrorReporter; -})(); + } +} export default ErrorReporter; diff --git a/common/lib/util/eventemitter.js b/common/lib/util/eventemitter.js deleted file mode 100644 index 6330da880c..0000000000 --- a/common/lib/util/eventemitter.js +++ /dev/null @@ -1,238 +0,0 @@ -import Utils from './utils'; -import Logger from './logger'; -import Platform from 'platform'; - -var hasOwnProperty = Object.prototype.hasOwnProperty; - -var EventEmitter = (function() { - - /* public constructor */ - function EventEmitter() { - this.any = []; - this.events = Object.create(null); - this.anyOnce = []; - this.eventsOnce = Object.create(null); - } - - /* Call the listener, catch any exceptions and log, but continue operation*/ - function callListener(eventThis, listener, args) { - try { - listener.apply(eventThis, args); - } catch(e) { - Logger.logAction(Logger.LOG_ERROR, 'EventEmitter.emit()', 'Unexpected listener exception: ' + e + '; stack = ' + (e && e.stack)); - } - } - - /** - * Remove listeners that match listener - * @param targetListeners is an array of listener arrays or event objects with arrays of listeners - * @param listener the listener callback to remove - * @param eventFilter (optional) event name instructing the function to only remove listeners for the specified event - */ - function removeListener(targetListeners, listener, eventFilter) { - var listeners, idx, eventName, targetListenersIndex; - - for (targetListenersIndex = 0; targetListenersIndex < targetListeners.length; targetListenersIndex++) { - listeners = targetListeners[targetListenersIndex]; - if (eventFilter) { listeners = listeners[eventFilter]; } - - if (Utils.isArray(listeners)) { - while ((idx = Utils.arrIndexOf(listeners, listener)) !== -1) { - listeners.splice(idx, 1); - } - /* If events object has an event name key with no listeners then - remove the key to stop the list growing indefinitely */ - if (eventFilter && (listeners.length === 0)) { - delete targetListeners[targetListenersIndex][eventFilter]; - } - } else if (Utils.isObject(listeners)) { - /* events */ - for (eventName in listeners) { - if (hasOwnProperty.call(listeners, eventName) && Utils.isArray(listeners[eventName])) { - removeListener([listeners], listener, eventName); - } - } - } - } - } - - /** - * Add an event listener - * @param event (optional) the name of the event to listen to - * if not supplied, all events trigger a call to the listener - * @param listener the listener to be called - */ - EventEmitter.prototype.on = function(event, listener) { - if(arguments.length == 1 && typeof(event) == 'function') { - this.any.push(event); - } else if(Utils.isEmptyArg(event)) { - this.any.push(listener); - } else if(Utils.isArray(event)) { - var self = this; - Utils.arrForEach(event, function(ev) { - self.on(ev, listener); - }); - } else { - var listeners = (this.events[event] || (this.events[event] = [])); - listeners.push(listener); - } - }; - - /** - * Remove one or more event listeners - * @param event (optional) the name of the event whose listener - * is to be removed. If not supplied, the listener is - * treated as an 'any' listener - * @param listener (optional) the listener to remove. If not - * supplied, all listeners are removed. - */ - EventEmitter.prototype.off = function(event, listener) { - if(arguments.length == 0 || (Utils.isEmptyArg(event) && Utils.isEmptyArg(listener))) { - this.any = []; - this.events = Object.create(null); - this.anyOnce = []; - this.eventsOnce = Object.create(null); - return; - } - if(arguments.length == 1) { - if(typeof(event) == 'function') { - /* we take this to be the listener and treat the event as "any" .. */ - listener = event; - event = null; - } - /* ... or we take event to be the actual event name and listener to be all */ - } - - if(listener && Utils.isEmptyArg(event)) { - removeListener([this.any, this.events, this.anyOnce, this.eventsOnce], listener); - return; - } - - if(Utils.isArray(event)) { - var self = this; - Utils.arrForEach(event, function(ev) { - self.off(ev, listener); - }); - } - - /* "normal" case where event is an actual event */ - if(listener) { - removeListener([this.events, this.eventsOnce], listener, event); - } else { - delete this.events[event]; - delete this.eventsOnce[event]; - } - }; - - /** - * Get the array of listeners for a given event; excludes once events - * @param event (optional) the name of the event, or none for 'any' - * @return array of events, or null if none - */ - EventEmitter.prototype.listeners = function(event) { - if(event) { - var listeners = (this.events[event] || []); - if(this.eventsOnce[event]) - Array.prototype.push.apply(listeners, this.eventsOnce[event]); - return listeners.length ? listeners : null; - } - return this.any.length ? this.any : null; - }; - - /** - * Emit an event - * @param event the event name - * @param args the arguments to pass to the listener - */ - EventEmitter.prototype.emit = function(event /* , args... */) { - var args = Array.prototype.slice.call(arguments, 1); - var eventThis = {event:event}; - var listeners = []; - - if(this.anyOnce.length) { - Array.prototype.push.apply(listeners, this.anyOnce); - this.anyOnce = []; - } - if(this.any.length) { - Array.prototype.push.apply(listeners, this.any); - } - var eventsOnceListeners = this.eventsOnce[event]; - if(eventsOnceListeners) { - Array.prototype.push.apply(listeners, eventsOnceListeners); - delete this.eventsOnce[event]; - } - var eventsListeners = this.events[event]; - if(eventsListeners) { - Array.prototype.push.apply(listeners, eventsListeners); - } - - Utils.arrForEach(listeners, function(listener) { - callListener(eventThis, listener, args); - }); - }; - - /** - * Listen for a single occurrence of an event - * @param event the name of the event to listen to - * @param listener the listener to be called - */ - EventEmitter.prototype.once = function(event, listener) { - var argCount = arguments.length, self = this; - if((argCount === 0 || (argCount === 1 && typeof event !== 'function')) && Platform.Promise) { - return new Platform.Promise(function(resolve) { - self.once(event, resolve); - }); - } - if(arguments.length == 1 && typeof(event) == 'function') { - this.anyOnce.push(event); - } else if(Utils.isEmptyArg(event)) { - this.anyOnce.push(listener); - } else if(Utils.isArray(event)){ - var listenerWrapper = function() { - var args = Array.prototype.slice.call(arguments); - Utils.arrForEach(event, function(ev) { - self.off(ev, listenerWrapper); - }); - listener.apply(this, args); - }; - Utils.arrForEach(event, function(ev) { - self.on(ev, listenerWrapper); - }); - } else { - var listeners = (this.eventsOnce[event] || (this.eventsOnce[event] = [])); - listeners.push(listener); - } - }; - - /** - * Private API - * - * Listen for a single occurrence of a state event and fire immediately if currentState matches targetState - * @param targetState the name of the state event to listen to - * @param currentState the name of the current state of this object - * @param listener the listener to be called - */ - EventEmitter.prototype.whenState = function(targetState, currentState, listener /* ...listenerArgs */) { - var eventThis = {event:targetState}, - self = this, - listenerArgs = Array.prototype.slice.call(arguments, 3); - - if((typeof(targetState) !== 'string') || (typeof(currentState) !== 'string')) { - throw("whenState requires a valid event String argument"); - } - if(typeof listener !== 'function' && Platform.Promise) { - return new Platform.Promise(function(resolve) { - EventEmitter.prototype.whenState.apply(self, [targetState, currentState, resolve].concat(listenerArgs)); - }); - } - if(targetState === currentState) { - callListener(eventThis, listener, listenerArgs); - } else { - this.once(targetState, listener); - } - } - - return EventEmitter; -})(); - -export default EventEmitter; diff --git a/common/lib/util/eventemitter.ts b/common/lib/util/eventemitter.ts new file mode 100644 index 0000000000..17a4ed0bea --- /dev/null +++ b/common/lib/util/eventemitter.ts @@ -0,0 +1,314 @@ +import * as Utils from './utils'; +import Logger from './logger'; +import Platform from 'platform'; + +/* Call the listener, catch any exceptions and log, but continue operation*/ +function callListener(eventThis: { event: string }, listener: Function, args: unknown[]) { + try { + listener.apply(eventThis, args); + } catch(e) { + Logger.logAction(Logger.LOG_ERROR, 'EventEmitter.emit()', 'Unexpected listener exception: ' + e + '; stack = ' + (e && (e as Error).stack)); + } +} + +/** + * Remove listeners that match listener + * @param targetListeners is an array of listener arrays or event objects with arrays of listeners + * @param listener the listener callback to remove + * @param eventFilter (optional) event name instructing the function to only remove listeners for the specified event + */ +function removeListener(targetListeners: any, listener: Function, eventFilter?: string) { + let listeners: Record; + let index; + let eventName; + + for (let targetListenersIndex = 0; targetListenersIndex < targetListeners.length; targetListenersIndex++) { + listeners = targetListeners[targetListenersIndex]; + if (eventFilter) { listeners = listeners[eventFilter] as Record; } + + if (Utils.isArray(listeners)) { + while ((index = Utils.arrIndexOf(listeners, listener)) !== -1) { + listeners.splice(index, 1); + } + /* If events object has an event name key with no listeners then + remove the key to stop the list growing indefinitely */ + if (eventFilter && (listeners.length === 0)) { + delete targetListeners[targetListenersIndex][eventFilter]; + } + } else if (Utils.isObject(listeners)) { + /* events */ + for (eventName in listeners) { + if (Object.prototype.hasOwnProperty.call(listeners, eventName) && Utils.isArray(listeners[eventName])) { + removeListener([listeners], listener, eventName); + } + } + } + } +} + +class EventEmitter { + any: Array; + events: Record>; + anyOnce: Array; + eventsOnce: Record>; + + constructor() { + this.any = []; + this.events = Object.create(null); + this.anyOnce = []; + this.eventsOnce = Object.create(null); + } + + /** + * Add an event listener + * @param listener the listener to be called + */ + on(listener: Function): void; + + /** + * Add an event listener + * @param event (optional) the name of the event to listen to + * @param listener the listener to be called + */ + on(event: null | string | string[], listener: Function): void; + + on (...args: unknown[]) { + if (args.length === 1) { + const listener = args[0] + if (typeof listener === 'function') { + this.any.push(listener); + } else { + throw new Error('EventListener.on(): Invalid arguments: ' + Utils.inspect(args)); + } + } + if (args.length === 2) { + const [event, listener] = args; + if (typeof listener !== 'function') { + throw new Error('EventListener.on(): Invalid arguments: ' + Utils.inspect(args)); + } + if (Utils.isEmptyArg(event)) { + this.any.push(listener); + } else if (Utils.isArray(event)) { + event.forEach(eventName => { + this.on(eventName, listener); + }); + } else { + if (typeof event !== 'string') { + throw new Error('EventListener.on(): Invalid arguments: ' + Utils.inspect(args)); + } + const listeners = (this.events[event] || (this.events[event] = [])); + listeners.push(listener); + } + } + }; + + /** + * Remove one or more event listeners + * @param listener (optional) the listener to remove. If not + * supplied, all listeners are removed. + */ + off(listener?: Function): void; + + /** + * Remove one or more event listeners + * @param event (optional) the name of the event whose listener + * is to be removed. If not supplied, the listener is + * treated as an 'any' listener + * @param listener (optional) the listener to remove. If not + * supplied, all listeners are removed. + */ + off(event: string | string[] | null, listener?: Function | null): void; + + off(...args: unknown[]) { + if(args.length == 0 || (Utils.isEmptyArg(args[0]) && Utils.isEmptyArg(args[1]))) { + this.any = []; + this.events = Object.create(null); + this.anyOnce = []; + this.eventsOnce = Object.create(null); + return; + } + const [firstArg, secondArg] = args; + let listener: Function | null = null; + let event: unknown = null; + if(args.length === 1 || !secondArg) { + if (typeof firstArg === 'function') { + /* we take this to be the listener and treat the event as "any" .. */ + listener = firstArg; + } else { + event = firstArg; + } + /* ... or we take event to be the actual event name and listener to be all */ + } else { + if (typeof secondArg !== 'function') { + throw new Error('EventEmitter.off(): invalid arguments:' + Utils.inspect(args)); + } + [event, listener] = [firstArg, secondArg]; + } + + if(listener && Utils.isEmptyArg(event)) { + removeListener([this.any, this.events, this.anyOnce, this.eventsOnce], listener); + return; + } + + if(Utils.isArray(event)) { + event.forEach((eventName) => { + this.off(eventName, listener); + }); + return; + } + + /* "normal" case where event is an actual event */ + if (typeof event !== 'string') { + throw new Error('EventEmitter.off(): invalid arguments:' + Utils.inspect(args)); + } + if(listener) { + removeListener([this.events, this.eventsOnce], listener, event); + } else { + delete this.events[event]; + delete this.eventsOnce[event]; + } + }; + + /** + * Get the array of listeners for a given event; excludes once events + * @param event (optional) the name of the event, or none for 'any' + * @return array of events, or null if none + */ + listeners(event: string) { + if(event) { + const listeners = (this.events[event] || []); + if(this.eventsOnce[event]) + Array.prototype.push.apply(listeners, this.eventsOnce[event]); + return listeners.length ? listeners : null; + } + return this.any.length ? this.any : null; + }; + + /** + * Emit an event + * @param event the event name + * @param args the arguments to pass to the listener + */ + emit (event: string, ...args: unknown[] /* , args... */) { + const eventThis = { event }; + const listeners: Function[] = []; + + if(this.anyOnce.length) { + Array.prototype.push.apply(listeners, this.anyOnce); + this.anyOnce = []; + } + if(this.any.length) { + Array.prototype.push.apply(listeners, this.any); + } + const eventsOnceListeners = this.eventsOnce[event]; + if(eventsOnceListeners) { + Array.prototype.push.apply(listeners, eventsOnceListeners); + delete this.eventsOnce[event]; + } + const eventsListeners = this.events[event]; + if(eventsListeners) { + Array.prototype.push.apply(listeners, eventsListeners); + } + + Utils.arrForEach(listeners, function(listener) { + callListener(eventThis, listener, args); + }); + }; + + /** + * Listen for a single occurrence of an event + * @param event the name of the event to listen to + */ + once(event: string): Promise; + + /** + * Listen for a single occurrence of any event + * @param listener the listener to be called + */ + once(listener: Function): void; + + /** + * Listen for a single occurrence of an event + * @param event the name of the event to listen to + * @param listener the listener to be called + */ + once(event?: string | string[] | null, listener?: Function): void; + + once(...args: unknown[]): void | Promise { + const argCount = args.length; + if((argCount === 0 || (argCount === 1 && typeof args[0] !== 'function')) && Platform.Promise) { + const event = args[0]; + if (typeof event !== 'string') { + throw new Error('EventEmitter.once(): Invalid arguments:' + Utils.inspect(args)) + } + return new Platform.Promise((resolve) => { + this.once(event, resolve); + }); + } + + const [firstArg, secondArg] = args; + if(args.length === 1 && typeof(firstArg) === 'function') { + this.anyOnce.push(firstArg); + } else if(Utils.isEmptyArg(firstArg)) { + if (typeof secondArg !== 'function') { + throw new Error('EventEmitter.once(): Invalid arguments:' + Utils.inspect(args)) + } + this.anyOnce.push(secondArg); + } else if(Utils.isArray(firstArg)){ + const self = this; + const listenerWrapper = function(this: any) { + const innerArgs = Array.prototype.slice.call(arguments); + Utils.arrForEach(firstArg, function(eventName) { + self.off(eventName, listenerWrapper); + }); + if (typeof secondArg !== 'function') { + throw new Error('EventEmitter.once(): Invalid arguments:' + Utils.inspect(args)) + } + secondArg.apply(this, innerArgs); + }; + Utils.arrForEach(firstArg, function(eventName) { + self.on(eventName, listenerWrapper); + }); + } else { + if (typeof firstArg !== 'string') { + throw new Error('EventEmitter.once(): Invalid arguments:' + Utils.inspect(args)) + } + const listeners = (this.eventsOnce[firstArg] || (this.eventsOnce[firstArg] = [])); + if (secondArg) { + if (typeof secondArg !== 'function') { + throw new Error('EventEmitter.once(): Invalid arguments:' + Utils.inspect(args)) + } + listeners.push(secondArg); + } + } + }; + + /** + * Private API + * + * Listen for a single occurrence of a state event and fire immediately if currentState matches targetState + * @param targetState the name of the state event to listen to + * @param currentState the name of the current state of this object + * @param listener the listener to be called + * @param listenerArgs + */ + whenState(targetState: string, currentState: string, listener: Function, ...listenerArgs: unknown[]) { + const eventThis = { event: targetState }; + + if((typeof(targetState) !== 'string') || (typeof(currentState) !== 'string')) { + throw("whenState requires a valid event String argument"); + } + if(typeof listener !== 'function' && Platform.Promise) { + return new Platform.Promise(function(resolve) { + EventEmitter.prototype.whenState.apply(self, [targetState, currentState, resolve].concat(listenerArgs as any[]) as any); + }); + } + if(targetState === currentState) { + callListener(eventThis, listener, listenerArgs); + } else { + this.once(targetState, listener); + } + } +} + +export default EventEmitter; diff --git a/common/lib/util/logger.js b/common/lib/util/logger.js deleted file mode 100644 index 0c48c45f94..0000000000 --- a/common/lib/util/logger.js +++ /dev/null @@ -1,90 +0,0 @@ -import Platform from 'platform'; - -var Logger = (function() { - var consoleLogger, errorLogger; - - /* Can't just check for console && console.log; fails in IE <=9 */ - if((typeof Window === 'undefined' && typeof WorkerGlobalScope === 'undefined') /* node */ || - (global.console && global.console.log && (typeof global.console.log.apply === 'function')) /* sensible browsers */) { - consoleLogger = function() { console.log.apply(console, arguments); }; - errorLogger = console.warn ? function() { console.warn.apply(console, arguments); } : consoleLogger; - } else if(global.console && global.console.log) { - /* IE <= 9 with the console open -- console.log does not - * inherit from Function, so has no apply method */ - consoleLogger = errorLogger = function() { Function.prototype.apply.call(console.log, console, arguments); }; - } else { - /* IE <= 9 when dev tools are closed - window.console not even defined */ - consoleLogger = errorLogger = function() {}; - } - - function pad(str, three) { - return ('000' + str).slice(-2-(three || 0)); - } - - var LOG_NONE = 0, - LOG_ERROR = 1, - LOG_MAJOR = 2, - LOG_MINOR = 3, - LOG_MICRO = 4; - - var LOG_DEFAULT = LOG_ERROR, - LOG_DEBUG = LOG_MICRO; - - var logLevel = LOG_DEFAULT; - - function getHandler(logger) { - return Platform.logTimestamps ? - function(msg) { - var time = new Date(); - logger(pad(time.getHours()) + ':' + pad(time.getMinutes()) + ':' + pad(time.getSeconds()) + '.' + pad(time.getMilliseconds(), true) + ' ' + msg); - } : logger; - } - - var logHandler = getHandler(consoleLogger), - logErrorHandler = getHandler(errorLogger); - - /* public constructor */ - function Logger(args) {} - - /* public constants */ - Logger.LOG_NONE = LOG_NONE, - Logger.LOG_ERROR = LOG_ERROR, - Logger.LOG_MAJOR = LOG_MAJOR, - Logger.LOG_MINOR = LOG_MINOR, - Logger.LOG_MICRO = LOG_MICRO; - - Logger.LOG_DEFAULT = LOG_DEFAULT, - Logger.LOG_DEBUG = LOG_DEBUG; - - /* public static functions */ - Logger.logAction = function(level, action, message) { - if (Logger.shouldLog(level)) { - (level === LOG_ERROR ? logErrorHandler : logHandler)('Ably: ' + action + ': ' + message); - } - }; - - Logger.deprecated = function(original, replacement) { - Logger.deprecatedWithMsg(original, "Please use '" + replacement + "' instead."); - } - - Logger.deprecatedWithMsg = function(funcName, msg) { - if (Logger.shouldLog(LOG_ERROR)) { - logErrorHandler("Ably: Deprecation warning - '" + funcName + "' is deprecated and will be removed from a future version. " + msg); - } - } - - /* Where a logging operation is expensive, such as serialisation of data, use shouldLog will prevent - the object being serialised if the log level will not output the message */ - Logger.shouldLog = function(level) { - return level <= logLevel; - }; - - Logger.setLog = function(level, handler) { - if(level !== undefined) logLevel = level; - if(handler !== undefined) logHandler = logErrorHandler = handler; - }; - - return Logger; -})(); - -export default Logger; diff --git a/common/lib/util/logger.ts b/common/lib/util/logger.ts new file mode 100644 index 0000000000..b7f4cfc5bf --- /dev/null +++ b/common/lib/util/logger.ts @@ -0,0 +1,100 @@ +import Platform from 'platform'; + +export type LoggerOptions = { + handler: LoggerFunction, + level: LogLevels, +} +type LoggerFunction = (...args: string[]) => void; + +enum LogLevels { + None = 0, + Error = 1, + Major = 2, + Minor = 3, + Micro = 4, +} + +function pad(timeSegment: number, three?: number) { + return `${timeSegment}`.padStart(three ? 3 : 2, '0'); +} + +function getHandler(logger: Function): Function { + return Platform.logTimestamps ? + function(msg: unknown) { + const time = new Date(); + logger(pad(time.getHours()) + ':' + pad(time.getMinutes()) + ':' + pad(time.getSeconds()) + '.' + pad(time.getMilliseconds(), 1) + ' ' + msg); + } : logger; +} + +const getDefaultLoggers = (): [Function, Function] => { + let consoleLogger; + let errorLogger; + + /* Can't just check for console && console.log; fails in IE <=9 */ + if((typeof Window === 'undefined' && typeof WorkerGlobalScope === 'undefined') /* node */ || + (global.console && global.console.log && (typeof global.console.log.apply === 'function')) /* sensible browsers */) { + consoleLogger = function(...args: unknown[]) { console.log.apply(console, args); }; + errorLogger = console.warn ? function(...args: unknown[]) { console.warn.apply(console, args); } : consoleLogger; + } else if(global.console && global.console.log as unknown) { + /* IE <= 9 with the console open -- console.log does not + * inherit from Function, so has no apply method */ + consoleLogger = errorLogger = function() { Function.prototype.apply.call(console.log, console, arguments); }; + } else { + /* IE <= 9 when dev tools are closed - window.console not even defined */ + consoleLogger = errorLogger = function() {}; + } + + return [consoleLogger, errorLogger].map(getHandler) as [Function, Function]; +}; + +const [logHandler, logErrorHandler] = getDefaultLoggers(); + +class Logger { + private static logLevel: LogLevels = LogLevels.Error; // default logLevel + private static logHandler: Function = logHandler; + private static logErrorHandler: Function = logErrorHandler; + + // public constants + static readonly LOG_NONE: LogLevels = LogLevels.None; + static readonly LOG_ERROR: LogLevels = LogLevels.Error; + static readonly LOG_MAJOR: LogLevels = LogLevels.Major; + static readonly LOG_MINOR: LogLevels = LogLevels.Minor; + static readonly LOG_MICRO: LogLevels = LogLevels.Micro; + // aliases + static readonly LOG_DEFAULT: LogLevels = LogLevels.Error; + static readonly LOG_DEBUG: LogLevels = LogLevels.Micro; + + constructor () { + Logger.logLevel = Logger.LOG_DEFAULT; + } + + /* public static functions */ + static logAction = (level: LogLevels, action: string, message?: string) => { + if (Logger.shouldLog(level)) { + (level === LogLevels.Error ? Logger.logErrorHandler : Logger.logHandler)('Ably: ' + action + ': ' + message); + } + }; + + static deprecated = function(original: string, replacement: string) { + Logger.deprecatedWithMsg(original, "Please use '" + replacement + "' instead."); + } + + static deprecatedWithMsg = (funcName: string, msg: string) => { + if (Logger.shouldLog(LogLevels.Error)) { + Logger.logErrorHandler("Ably: Deprecation warning - '" + funcName + "' is deprecated and will be removed from a future version. " + msg); + } + } + + /* Where a logging operation is expensive, such as serialisation of data, use shouldLog will prevent + the object being serialised if the log level will not output the message */ + static shouldLog = (level: LogLevels) => { + return level <= Logger.logLevel; + }; + + static setLog = (level: LogLevels | undefined, handler: Function | undefined) => { + if(level !== undefined) Logger.logLevel = level; + if(handler !== undefined) Logger.logHandler = Logger.logErrorHandler = handler; + }; +} + +export default Logger; diff --git a/common/lib/util/multicaster.js b/common/lib/util/multicaster.js deleted file mode 100644 index f031db11c3..0000000000 --- a/common/lib/util/multicaster.js +++ /dev/null @@ -1,30 +0,0 @@ -import Logger from './logger'; - -var Multicaster = (function() { - - function Multicaster(members) { - members = members || []; - - var handler = function() { - for(var i = 0; i < members.length; i++) { - var member = members[i]; - if(member) { - try { - member.apply(null, arguments); - } catch(e){ - Logger.logAction(Logger.LOG_ERROR, 'Multicaster multiple callback handler', 'Unexpected exception: ' + e + '; stack = ' + e.stack); - } - } - } - }; - - handler.push = function() { - Array.prototype.push.apply(members, arguments); - }; - return handler; - } - - return Multicaster; -})(); - -export default Multicaster; diff --git a/common/lib/util/multicaster.ts b/common/lib/util/multicaster.ts new file mode 100644 index 0000000000..e3e7b2abf6 --- /dev/null +++ b/common/lib/util/multicaster.ts @@ -0,0 +1,49 @@ +import Logger from './logger'; + +type AnyFunction = (...args: any[]) => unknown; + +export interface MulticasterInstance extends Function { + (...args: unknown[]): void; + push: (fn: AnyFunction) => void; +} + +class Multicaster { + members: Array; + + // Private constructor; use static Multicaster.create instead + private constructor(members?: Array) { + this.members = members as Array || []; + } + + call(...args: unknown[]): void { + for (const member of this.members) { + if (member) { + try { + member(...args); + } catch (e) { + Logger.logAction( + Logger.LOG_ERROR, + 'Multicaster multiple callback handler', + 'Unexpected exception: ' + e + '; stack = ' + (e as Error).stack + ); + } + } + } + } + + push(...args: Array): void { + this.members.push(...args); + } + + static create(members?: Array): MulticasterInstance { + const instance = new Multicaster(members); + return Object.assign( + (...args: unknown[]) => instance.call(...args), + { + push: (fn: AnyFunction) => instance.push(fn) + } + ); + } +} + +export default Multicaster; diff --git a/common/lib/util/utils.js b/common/lib/util/utils.js deleted file mode 100644 index 006f7fc7cc..0000000000 --- a/common/lib/util/utils.js +++ /dev/null @@ -1,522 +0,0 @@ -import Platform from 'platform'; -import Defaults from './defaults'; -import BufferUtils from 'platform-bufferutils'; - -var hasOwnProperty = Object.prototype.hasOwnProperty; - -var Utils = (function() { - var msgpack = Platform.msgpack; - - function Utils() {} - - function randomPosn(arrOrStr) { - return Math.floor(Math.random() * arrOrStr.length); - } - - /* - * Add a set of properties to a target object - * target: the target object - * props: an object whose enumerable properties are - * added, by reference only - */ - Utils.mixin = function(target) { - for(var i = 1; i < arguments.length; i++) { - var source = arguments[i]; - if(!source) { break; } - for(var key in source) { - if(hasOwnProperty.call(source, key)) { - target[key] = source[key]; - } - } - } - return target; - }; - - /* - * Add a set of properties to a target object - * target: the target object - * props: an object whose enumerable properties are - * added, by reference only - */ - Utils.copy = function(src) { - return Utils.mixin({}, src); - }; - - /* - * Determine whether or not a given object is - * an array. - */ - Utils.isArray = Array.isArray || function(ob) { - return Object.prototype.toString.call(ob) == '[object Array]'; - }; - - /* - * Ensures that an Array object is always returned - * returning the original Array of obj is an Array - * else wrapping the obj in a single element Array - */ - Utils.ensureArray = function(obj) { - if(Utils.isEmptyArg(obj)) { - return []; - } - if(Utils.isArray(obj)) { - return obj; - } - return [obj]; - } - - /* ...Or an Object (in the narrow sense) */ - Utils.isObject = function(ob) { - return Object.prototype.toString.call(ob) == '[object Object]'; - }; - - /* - * Determine whether or not an object contains - * any enumerable properties. - * ob: the object - */ - Utils.isEmpty = function(ob) { - for(var prop in ob) - return false; - return true; - }; - - Utils.isOnlyPropIn = function(ob, property) { - for(var prop in ob) { - if(prop !== property) { - return false; - } - } - return true; - }; - - /* - * Determine whether or not an argument to an overloaded function is - * undefined (missing) or null. - * This method is useful when constructing functions such as (WebIDL terminology): - * off([TreatUndefinedAs=Null] DOMString? event) - * as you can then confirm the argument using: - * Utils.isEmptyArg(event) - */ - - Utils.isEmptyArg = function(arg) { - return arg === null || arg === undefined; - } - - /* - * Perform a simple shallow clone of an object. - * Result is an object irrespective of whether - * the input is an object or array. All - * enumerable properties are copied. - * ob: the object - */ - Utils.shallowClone = function(ob) { - var result = new Object(); - for(var prop in ob) - result[prop] = ob[prop]; - return result; - }; - - /* - * Clone an object by creating a new object with the - * given object as its prototype. Optionally - * a set of additional own properties can be - * supplied to be added to the newly created clone. - * ob: the object to be cloned - * ownProperties: optional object with additional - * properties to add - */ - Utils.prototypicalClone = function(ob, ownProperties) { - function F() {} - F.prototype = ob; - var result = new F(); - if(ownProperties) - Utils.mixin(result, ownProperties); - return result; - }; - - /* - * Declare a constructor to represent a subclass - * of another constructor - * If platform has a built-in version we use that from Platform, else we - * define here (so can make use of other Utils fns) - * See node.js util.inherits - */ - Utils.inherits = Platform.inherits || function(ctor, superCtor) { - ctor.super_ = superCtor; - ctor.prototype = Utils.prototypicalClone(superCtor.prototype, { constructor: ctor }); - }; - - /* - * Determine whether or not an object has an enumerable - * property whose value equals a given value. - * ob: the object - * val: the value to find - */ - Utils.containsValue = function(ob, val) { - for(var i in ob) { - if(ob[i] == val) - return true; - } - return false; - }; - - Utils.intersect = function(arr, ob) { return Utils.isArray(ob) ? Utils.arrIntersect(arr, ob) : Utils.arrIntersectOb(arr, ob); }; - - Utils.arrIntersect = function(arr1, arr2) { - var result = []; - for(var i = 0; i < arr1.length; i++) { - var member = arr1[i]; - if(Utils.arrIndexOf(arr2, member) != -1) - result.push(member); - } - return result; - }; - - Utils.arrIntersectOb = function(arr, ob) { - var result = []; - for(var i = 0; i < arr.length; i++) { - var member = arr[i]; - if(member in ob) - result.push(member); - } - return result; - }; - - Utils.arrSubtract = function(arr1, arr2) { - var result = []; - for(var i = 0; i < arr1.length; i++) { - var element = arr1[i]; - if(Utils.arrIndexOf(arr2, element) == -1) - result.push(element); - } - return result; - }; - - Utils.arrIndexOf = Array.prototype.indexOf - ? function(arr, elem, fromIndex) { - return arr.indexOf(elem, fromIndex); - } - : function(arr, elem, fromIndex) { - fromIndex = fromIndex || 0; - var len = arr.length; - for(;fromIndex < len; fromIndex++) { - if(arr[fromIndex] === elem) { - return fromIndex; - } - } - return -1; - }; - - Utils.arrIn = function(arr, val) { - return Utils.arrIndexOf(arr, val) !== -1; - }; - - Utils.arrDeleteValue = function(arr, val) { - var idx = Utils.arrIndexOf(arr, val); - var res = (idx != -1); - if(res) - arr.splice(idx, 1); - return res; - }; - - Utils.arrWithoutValue = function(arr, val) { - var newArr = arr.slice(); - Utils.arrDeleteValue(newArr, val); - return newArr; - }; - - /* - * Construct an array of the keys of the enumerable - * properties of a given object, optionally limited - * to only the own properties. - * ob: the object - * ownOnly: boolean, get own properties only - */ - Utils.keysArray = function(ob, ownOnly) { - var result = []; - for(var prop in ob) { - if(ownOnly && !hasOwnProperty.call(ob, prop)) continue; - result.push(prop); - } - return result; - }; - - /* - * Construct an array of the values of the enumerable - * properties of a given object, optionally limited - * to only the own properties. - * ob: the object - * ownOnly: boolean, get own properties only - */ - Utils.valuesArray = function(ob, ownOnly) { - var result = []; - for(var prop in ob) { - if(ownOnly && !hasOwnProperty.call(ob, prop)) continue; - result.push(ob[prop]); - } - return result; - }; - - Utils.forInOwnNonNullProps = function(ob, fn) { - for (var prop in ob) { - if (hasOwnProperty.call(ob, prop) && ob[prop]) { - fn(prop); - } - } - }; - - Utils.arrForEach = Array.prototype.forEach ? - function(arr, fn) { - arr.forEach(fn); - } : - function(arr, fn) { - var len = arr.length; - for(var i = 0; i < len; i++) { - fn(arr[i], i, arr); - } - }; - - /* Useful when the function may mutate the array */ - Utils.safeArrForEach = function(arr, fn) { - return Utils.arrForEach(arr.slice(), fn); - }; - - Utils.arrMap = Array.prototype.map ? - function(arr, fn) { - return arr.map(fn); - } : - function(arr, fn) { - var result = [], - len = arr.length; - for(var i = 0; i < len; i++) { - result.push(fn(arr[i], i, arr)); - } - return result; - }; - - Utils.arrFilter = Array.prototype.filter ? - function(arr, fn) { - return arr.filter(fn); - } : - function(arr, fn) { - var result = [], - len = arr.length; - for(var i = 0; i < len; i++) { - if(fn(arr[i])) { - result.push(arr[i]); - } - } - return result; - }; - - Utils.arrEvery = Array.prototype.every ? - function(arr, fn) { - return arr.every(fn); - } : function(arr, fn) { - var len = arr.length; - for(var i = 0; i < len; i++) { - if(!fn(arr[i], i, arr)) { - return false; - }; - } - return true; - }; - - Utils.allSame = function(arr, prop) { - if(arr.length === 0) { - return true; - } - var first = arr[0][prop]; - return Utils.arrEvery(arr, function(item) { - return item[prop] === first; - }); - }; - - Utils.nextTick = Platform.nextTick; - - var contentTypes = { - json: 'application/json', - jsonp: 'application/javascript', - xml: 'application/xml', - html: 'text/html', - msgpack: 'application/x-msgpack' - }; - - Utils.defaultGetHeaders = function(format) { - var accept = contentTypes[format || 'json']; - return { - accept: accept, - 'X-Ably-Version': Defaults.apiVersion, - 'Ably-Agent': Defaults.agent - }; - }; - - Utils.defaultPostHeaders = function(format) { - var accept, contentType; - accept = contentType = contentTypes[format || 'json']; - - return { - accept: accept, - 'content-type': contentType, - 'X-Ably-Version': Defaults.apiVersion, - 'Ably-Agent': Defaults.agent - }; - }; - - Utils.arrPopRandomElement = function(arr) { - return arr.splice(randomPosn(arr), 1)[0]; - }; - - Utils.toQueryString = function(params) { - var parts = []; - if(params) { - for(var key in params) - parts.push(encodeURIComponent(key) + '=' + encodeURIComponent(params[key])); - } - return parts.length ? '?' + parts.join('&') : ''; - }; - - Utils.parseQueryString = function(query) { - var match, - search = /([^?&=]+)=?([^&]*)/g, - result = {}; - - while (match = search.exec(query)) - result[decodeURIComponent(match[1])] = decodeURIComponent(match[2]); - - return result; - }; - - Utils.now = Date.now || function() { - /* IE 8 */ - return new Date().getTime(); - }; - - Utils.inspect = Platform.inspect; - - Utils.isErrorInfo = function(err) { - return err.constructor.name == 'ErrorInfo' - }; - - Utils.inspectError = function(x) { - /* redundant, but node vmcontext issue makes instanceof unreliable, and - * can't use just constructor test as could be a TypeError constructor etc. */ - return (x && (Utils.isErrorInfo(x) || - x.constructor.name == 'Error' || - x instanceof Error)) ? - x.toString() : - Utils.inspect(x); - }; - - Utils.inspectBody = function(body) { - if(BufferUtils.isBuffer(body)) { - return body.toString(); - } else if(typeof body === 'string') { - return body; - } else { - return Platform.inspect(body); - } - }; - - /* Data is assumed to be either a string or a buffer. */ - Utils.dataSizeBytes = function(data) { - if(BufferUtils.isBuffer(data)) { - return BufferUtils.byteLength(data); - } - if(typeof data === 'string') { - return Platform.stringByteSize(data); - } - throw new Error("Expected input of Utils.dataSizeBytes to be a buffer or string, but was: " + (typeof data)); - }; - - Utils.cheapRandStr = function() { - return String(Math.random()).substr(2); - }; - - /* Takes param the minimum number of bytes of entropy the string must - * include, not the length of the string. String length produced is not - * guaranteed. */ - Utils.randomString = (Platform.getRandomValues && typeof Uint8Array !== 'undefined') ? - function(numBytes) { - var uIntArr = new Uint8Array(numBytes); - Platform.getRandomValues(uIntArr); - return BufferUtils.base64Encode(uIntArr); - } : function(numBytes) { - /* Old browser; fall back to Math.random. Could just use a - * CryptoJS version of the above, but want this to still work in nocrypto - * versions of the library */ - var charset = BufferUtils.base64CharSet; - /* base64 has 33% overhead; round length up */ - var length = Math.round(numBytes * 4/3); - var result = ''; - for(var i=0; i | string) { + return Math.floor(Math.random() * arrOrStr.length); +} + +/* +* Add a set of properties to a target object +* target: the target object +* props: an object whose enumerable properties are +* added, by reference only +*/ +export function mixin(target: Record, ...args: Array): Record { + for (let i = 0; i < args.length; i++) { + const source = args[i]; + if (!source) { + break; + } + const hasOwnProperty = Object.prototype.hasOwnProperty; + for (const key in source) { + if (!hasOwnProperty || hasOwnProperty.call(source, key)) { + target[key] = (source as Record)[key]; + } + } + } + return target; +} + +/* +* Add a set of properties to a target object +* target: the target object +* props: an object whose enumerable properties are +* added, by reference only +*/ +export function copy>(src: T | Record | null | undefined): T { + return mixin({}, src as Record) as T; +} + +/* +* Determine whether or not a given object is +* an array. +*/ +export const isArray = + Array.isArray || + function (value: unknown): value is Array { + return Object.prototype.toString.call(value) == '[object Array]'; + }; + +/* +* Ensures that an Array object is always returned +* returning the original Array of obj is an Array +* else wrapping the obj in a single element Array +*/ +export function ensureArray(obj: Record): unknown[] { + if (isEmptyArg(obj)) { + return []; + } + if (isArray(obj)) { + return obj; + } + return [obj]; +} + +export function isObject(ob: unknown): ob is Record { + return Object.prototype.toString.call(ob) == '[object Object]'; +} + +/* +* Determine whether or not an object contains +* any enumerable properties. +* ob: the object +*/ +export function isEmpty(ob: Record | unknown[]): boolean { + for (const prop in ob) return false; + return true; +} + +export function isOnlyPropIn(ob: Record, property: string): boolean { + for (const prop in ob) { + if (prop !== property) { + return false; + } + } + return true; +} + +/* +* Determine whether or not an argument to an overloaded function is +* undefined (missing) or null. +* This method is useful when constructing functions such as (WebIDL terminology): +* off([TreatUndefinedAs=Null] DOMString? event) +* as you can then confirm the argument using: +* Utils.isEmptyArg(event) +*/ + +export function isEmptyArg(arg: unknown): arg is (null | undefined) { + return arg === null || arg === undefined; +} + +/* +* Perform a simple shallow clone of an object. +* Result is an object irrespective of whether +* the input is an object or array. All +* enumerable properties are copied. +* ob: the object +*/ +export function shallowClone(ob: Record): Record { + const result = new Object() as Record; + for (const prop in ob) result[prop] = ob[prop]; + return result; +} + +/* +* Clone an object by creating a new object with the +* given object as its prototype. Optionally +* a set of additional own properties can be +* supplied to be added to the newly created clone. +* ob: the object to be cloned +* ownProperties: optional object with additional +* properties to add +*/ +export function prototypicalClone(ob: Record, ownProperties: Record): Record { + class F {} + F.prototype = ob; + const result = new F() as Record; + if (ownProperties) mixin(result, ownProperties); + return result; +} + +/* +* Declare a constructor to represent a subclass +* of another constructor +* If platform has a built-in version we use that from Platform, else we +* define here (so can make use of other Utils fns) +* See node.js util.inherits +*/ +export const inherits = + Platform.inherits || + function (ctor: any, superCtor: Function) { + ctor.super_ = superCtor; + ctor.prototype = prototypicalClone(superCtor.prototype, { constructor: ctor }); + }; + +/* +* Determine whether or not an object has an enumerable +* property whose value equals a given value. +* ob: the object +* val: the value to find +*/ +export function containsValue(ob: Record, val: unknown): boolean { + for (const i in ob) { + if (ob[i] == val) return true; + } + return false; +} + +export function intersect(arr: Array, ob: string[] | Record): string[] { + return isArray(ob) ? arrIntersect(arr, ob) : arrIntersectOb(arr, ob); +} + +export function arrIntersect(arr1: Array, arr2: Array): Array { + const result = []; + for (let i = 0; i < arr1.length; i++) { + const member = arr1[i]; + if (arrIndexOf(arr2, member) != -1) result.push(member); + } + return result; +} + +export function arrIntersectOb(arr: Array, ob: Record): T[] { + const result = []; + for (let i = 0; i < arr.length; i++) { + const member = arr[i]; + if (member in ob) result.push(member); + } + return result; +} + +export function arrSubtract(arr1: Array, arr2: Array): Array { + const result = []; + for (let i = 0; i < arr1.length; i++) { + const element = arr1[i]; + if (arrIndexOf(arr2, element) == -1) result.push(element); + } + return result; +} + +export const arrIndexOf = (Array.prototype.indexOf as unknown) + ? function (arr: Array, elem: unknown, fromIndex?: number) { + return arr.indexOf(elem, fromIndex); + } + : function (arr: Array, elem: unknown, fromIndex?: number) { + fromIndex = fromIndex || 0; + const len = arr.length; + for (; fromIndex < len; fromIndex++) { + if (arr[fromIndex] === elem) { + return fromIndex; + } + } + return -1; + }; + +export function arrIn(arr: Array, val: unknown): boolean { + return arrIndexOf(arr, val) !== -1; +} + +export function arrDeleteValue(arr: Array, val: T): boolean { + const idx = arrIndexOf(arr, val); + const res = idx != -1; + if (res) arr.splice(idx, 1); + return res; +} + +export function arrWithoutValue(arr: Array, val: T): Array { + const newArr = arr.slice(); + arrDeleteValue(newArr, val); + return newArr; +} + +/* +* Construct an array of the keys of the enumerable +* properties of a given object, optionally limited +* to only the own properties. +* ob: the object +* ownOnly: boolean, get own properties only +*/ +export function keysArray(ob: Record, ownOnly?: boolean): Array { + const result = []; + for (const prop in ob) { + if (ownOnly && !Object.prototype.hasOwnProperty.call(ob, prop)) continue; + result.push(prop); + } + return result; +} + +/* +* Construct an array of the values of the enumerable +* properties of a given object, optionally limited +* to only the own properties. +* ob: the object +* ownOnly: boolean, get own properties only +*/ +export function valuesArray(ob: Record, ownOnly?: boolean): T[] { + const result = []; + for (const prop in ob) { + if (ownOnly && !Object.prototype.hasOwnProperty.call(ob, prop)) continue; + result.push(ob[prop]); + } + return result; +} + +export function forInOwnNonNullProperties(ob: Record, fn: (prop: string) => void): void { + for (const prop in ob) { + if (Object.prototype.hasOwnProperty.call(ob, prop) && ob[prop]) { + fn(prop); + } + } +} + +export const arrForEach = (Array.prototype.forEach as unknown) + ? function (arr: Array, fn: (value: T, index: number, arr: Array) => unknown) { + arr.forEach(fn); + } + : function (arr: Array, fn: (value: T, index: number, arr: Array) => unknown) { + const len = arr.length; + for (let i = 0; i < len; i++) { + fn(arr[i], i, arr); + } + }; + +/* Useful when the function may mutate the array */ +export function safeArrForEach(arr: Array, fn: (value: T, index: number, arr: Array) => unknown): void { + return arrForEach(arr.slice(), fn); +} + +export const arrMap = (Array.prototype.map as unknown) + ? function (arr: Array, fn: (value: T1, index?: number, arr?: Array) => T2) { + return arr.map(fn); + } + : function (arr: Array, fn: (value: T, index?: number, arr?: Array) => unknown) { + const result = []; + const len = arr.length; + for (let i = 0; i < len; i++) { + result.push(fn(arr[i], i, arr)); + } + return result; + }; + +export const arrFilter = (Array.prototype.filter as unknown) + ? function(arr: Array, fn: (value: T, index?: number, arr?: Array) => boolean) { + return arr.filter(fn); + } + : function(arr: Array, fn: (value: T, index?: number, arr?: Array) => boolean) { + const result = [], + len = arr.length; + for (let i = 0; i < len; i++) { + if (fn(arr[i])) { + result.push(arr[i]); + } + } + return result; + }; + +export const arrEvery = (Array.prototype.every as unknown) + ? function(arr: Array, fn: (value: T, index?: number, arr?: Array) => boolean) { + return arr.every(fn); + } + : function(arr: Array, fn: (value: T, index?: number, arr?: Array) => boolean) { + const len = arr.length; + for (let i = 0; i < len; i++) { + if (!fn(arr[i], i, arr)) { + return false; + } + } + return true; + }; + +export function allSame(arr: Array>, prop: string): boolean { + if (arr.length === 0) { + return true; + } + const first = arr[0][prop]; + return arrEvery(arr, function (item) { + return item[prop] === first; + }); +} + +export const nextTick = Platform.nextTick; + +const contentTypes = { + json: 'application/json', + jsonp: 'application/javascript', + xml: 'application/xml', + html: 'text/html', + msgpack: 'application/x-msgpack' +}; + +export function defaultGetHeaders(format?: Format): Record { + const accept = contentTypes[format || Format.json]; + return { + accept: accept, + 'X-Ably-Version': Defaults.apiVersion, + 'Ably-Agent': Defaults.agent + }; +} + +export function defaultPostHeaders(format?: Format): Record { + let contentType; + const accept = contentType = contentTypes[format || Format.json]; + + return { + accept: accept, + 'content-type': contentType, + 'X-Ably-Version': Defaults.apiVersion, + 'Ably-Agent': Defaults.agent + }; +} + +export function arrPopRandomElement(arr: Array): T { + return arr.splice(randomPosn(arr), 1)[0]; +} + +export function toQueryString(params?: Record | null): string { + const parts = []; + if (params) { + for (const key in params) parts.push(encodeURIComponent(key) + '=' + encodeURIComponent(params[key])); + } + return parts.length ? '?' + parts.join('&') : ''; +} + +export function parseQueryString(query: string): Record { + let match; + const search = /([^?&=]+)=?([^&]*)/g; + const result: Record = {}; + + while ((match = search.exec(query))) result[decodeURIComponent(match[1])] = decodeURIComponent(match[2]); + + return result; +} + +export const now = + Date.now || + function () { + /* IE 8 */ + return new Date().getTime(); + }; + +export const inspect = Platform.inspect; + +export function isErrorInfo(err: Error | ErrorInfo): err is ErrorInfo { + return err.constructor.name == 'ErrorInfo'; +} + +export function inspectError(err: unknown): string { + if (err instanceof Error || (err as ErrorInfo)?.constructor?.name === 'ErrorInfo') return Platform.inspect(err); + return (err as Error).toString(); +} + +export function inspectBody(body: unknown): string { + if (BufferUtils.isBuffer(body)) { + return body.toString(); + } else if (typeof body === 'string') { + return body; + } else { + return Platform.inspect(body); + } +} + +/* Data is assumed to be either a string or a buffer. */ +export function dataSizeBytes(data: string | Buffer): number { + if(BufferUtils.isBuffer(data)) { + return BufferUtils.byteLength(data); + } + if(typeof data === 'string') { + return Platform.stringByteSize(data); + } + throw new Error("Expected input of Utils.dataSizeBytes to be a buffer or string, but was: " + (typeof data)); +} + +export function cheapRandStr(): string { + return String(Math.random()).substr(2); +} + +/* Takes param the minimum number of bytes of entropy the string must +* include, not the length of the string. String length produced is not +* guaranteed. */ +export const randomString = + Platform.getRandomValues && typeof Uint8Array !== 'undefined' + ? function (numBytes: number) { + const uIntArr = new Uint8Array(numBytes); + (Platform.getRandomValues as Function)(uIntArr); + return BufferUtils.base64Encode(uIntArr); + } + : function (numBytes: number) { + /* Old browser; fall back to Math.random. Could just use a + * CryptoJS version of the above, but want this to still work in nocrypto + * versions of the library */ + const charset = BufferUtils.base64CharSet; + /* base64 has 33% overhead; round length up */ + const length = Math.round((numBytes * 4) / 3); + let result = ''; + for (let i = 0; i < length; i++) { + result += charset[randomPosn(charset)]; + } + return result; + }; + +export const randomHexString = + Platform.getRandomValues && typeof Uint8Array !== 'undefined' + ? function (numBytes: number) { + const uIntArr = new Uint8Array(numBytes); + (Platform.getRandomValues as Function)(uIntArr); + return BufferUtils.hexEncode(uIntArr); + } + : function (numBytes: number) { + const charset = BufferUtils.hexCharSet; + const length = numBytes * 2; + let result = ''; + for (let i = 0; i < length; i++) { + result += charset[randomPosn(charset)]; + } + return result; + }; + +/* Pick n elements at random without replacement from an array */ +export function arrChooseN(arr: Array, n: number): Array { + const numItems = Math.min(n, arr.length), + mutableArr = arr.slice(), + result: Array = []; + for (let i = 0; i < numItems; i++) { + result.push(arrPopRandomElement(mutableArr)); + } + return result; +} + +export const trim = (String.prototype.trim as unknown) + ? function (str: string) { + return str.trim(); + } + : function (str: string) { + return str.replace(/^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g, ''); + }; + +export function promisify(ob: Record, fnName: string, args: IArguments | unknown[]): Promise { + return new Promise(function (resolve, reject) { + ob[fnName](...(args as unknown[]), function(err: Error, res: unknown) { + err ? reject(err) : resolve(res as T); + }); + }); +} + +export enum Format { + msgpack = 'msgpack', + json = 'json', +} + +export function decodeBody(body: unknown, format?: Format | null): T { + return (format == 'msgpack') ? Platform.msgpack.decode(body as Buffer) : JSON.parse(String(body)); +} + +export function encodeBody(body: unknown, format?: Format): string | Buffer { + return (format == 'msgpack') ? Platform.msgpack.encode(body, true) as Buffer : JSON.stringify(body); +} + +export function allToLowerCase(arr: Array): Array { + return arr.map(function(element) { + return element && element.toLowerCase(); + }); +} + +export function allToUpperCase(arr: Array): Array { + return arr.map(function(element) { + return element && element.toUpperCase(); + }); +} diff --git a/common/types/ClientOptions.ts b/common/types/ClientOptions.ts new file mode 100644 index 0000000000..90f1e5b541 --- /dev/null +++ b/common/types/ClientOptions.ts @@ -0,0 +1,28 @@ +import { Modify } from "./utils"; +import * as API from '../../ably'; + +export default interface ClientOptions extends API.Types.ClientOptions { + restAgentOptions?: { keepAlive: boolean, maxSockets: number }; + pushFullWait?: boolean; + checkChannelsOnResume?: boolean; + agents?: string[]; +} + +export type DeprecatedClientOptions = Modify; + maxMessageSize?: number; + timeouts?: Record; +}> + +export type NormalisedClientOptions = Modify; + maxMessageSize: number; +}> diff --git a/common/types/IDefaults.d.ts b/common/types/IDefaults.d.ts new file mode 100644 index 0000000000..d09896728b --- /dev/null +++ b/common/types/IDefaults.d.ts @@ -0,0 +1,11 @@ +import TransportNames from '../constants/TransportNames'; + +export default interface IDefaults { + internetUpUrl: string; + jsonpInternetUpUrl?: string; + defaultTransports: TransportNames[]; + baseTransportOrder: TransportNames[]; + transportPreferenceOrder: TransportNames[]; + upgradeTransports: TransportNames[]; + restAgentOptions?: { keepAlive: boolean, maxSockets: number }; +} diff --git a/common/types/IPlatform.d.ts b/common/types/IPlatform.d.ts new file mode 100644 index 0000000000..2cf052ada8 --- /dev/null +++ b/common/types/IPlatform.d.ts @@ -0,0 +1,38 @@ +export type TypedArray = Int8Array | Uint8Array | Int16Array | Uint16Array | Int32Array | Uint32Array | Uint8ClampedArray | Float32Array | Float64Array; + +interface MsgPack { + encode(value: any, sparse?: boolean): Buffer | ArrayBuffer | undefined; + decode(buffer: Buffer): any; +} + +export interface IPlatform { + agent: string; + logTimestamps: boolean; + binaryType: BinaryType; + WebSocket: typeof WebSocket | typeof import('ws'); + useProtocolHeartbeats: boolean; + createHmac: ((algorithm: string, key: import('crypto').BinaryLike | import('crypto').KeyObject) => import('crypto').Hmac) | null; + msgpack: MsgPack; + supportsBinary: boolean; + preferBinary: boolean; + nextTick: process.nextTick; + inspect: (value: unknown) => string; + stringByteSize: Buffer.byteLength; + addEventListener: typeof global.addEventListener | null; + Promise: typeof Promise; + getRandomValues?: ((arr: TypedArray, callback?: (error?: Error | null) => void) => void); + userAgent?: string | null; + inherits?: typeof import('util').inherits; + addEventListener?: typeof window.addEventListener; + currentUrl?: string; + noUpgrade?: boolean | string; + xhrSupported?: boolean; + jsonpSupported?: boolean; + allowComet?: boolean; + streamingSupported?: boolean; + ArrayBuffer?: typeof ArrayBuffer | false; + atob?: typeof atob | null; + TextEncoder?: typeof TextEncoder; + TextDecoder?: typeof TextDecoder; + getRandomWordArray?: (byteLength: number, callback: (err: Error, result: boolean | CryptoJS.lib.WordArray) => void) => void; +} diff --git a/common/types/IXHRRequest.d.ts b/common/types/IXHRRequest.d.ts new file mode 100644 index 0000000000..ff4e1b7abc --- /dev/null +++ b/common/types/IXHRRequest.d.ts @@ -0,0 +1,9 @@ +import EventEmitter from '../lib/util/eventemitter'; + +/** + * A common interface shared by the browser and NodeJS XHRRequest implementations + */ +export default interface IXHRRequest extends EventEmitter { + exec(): void; + abort(): void; +} diff --git a/common/types/channel.d.ts b/common/types/channel.d.ts new file mode 100644 index 0000000000..e25f5044a0 --- /dev/null +++ b/common/types/channel.d.ts @@ -0,0 +1,10 @@ +import * as API from '../../ably'; + +export interface ChannelOptions extends API.Types.ChannelOptions { + channelCipher?: { + algorithm: string; + encrypt: Function; + decrypt: Function; + } | null; + updateOnAttached?: boolean; +} diff --git a/common/types/crypto-js.d.ts b/common/types/crypto-js.d.ts new file mode 100644 index 0000000000..a396188a31 --- /dev/null +++ b/common/types/crypto-js.d.ts @@ -0,0 +1,27 @@ +declare module 'crypto-js/build/enc-base64' { + import CryptoJS from 'crypto-js'; + export const parse: typeof CryptoJS.enc.Base64.parse; + export const stringify: typeof CryptoJS.enc.Base64.stringify; +} + +declare module 'crypto-js/build/enc-hex' { + import CryptoJS from 'crypto-js'; + export const parse: typeof CryptoJS.enc.Hex.parse; + export const stringify: typeof CryptoJS.enc.Hex.stringify; +} + +declare module 'crypto-js/build/enc-utf8' { + import CryptoJS from 'crypto-js'; + export const parse: typeof CryptoJS.enc.Utf8.parse; + export const stringify: typeof CryptoJS.enc.Utf8.stringify; +} + +declare module 'crypto-js/build/lib-typedarrays' { + import CryptoJS from 'crypto-js'; + export default CryptoJS.lib.WordArray; +} + +declare module 'crypto-js/build/hmac-sha256' { + import CryptoJS from 'crypto-js'; + export default CryptoJS.HmacSHA256; +} diff --git a/common/types/globals.d.ts b/common/types/globals.d.ts new file mode 100644 index 0000000000..b80684030f --- /dev/null +++ b/common/types/globals.d.ts @@ -0,0 +1,2 @@ +// The signature of clearTimeout varies between browser and NodeJS. This typing essentially just merges the two for compatibility. +declare function clearTimeout(timer?: NodeJS.Timeout | number | null): void; diff --git a/common/types/http.d.ts b/common/types/http.d.ts new file mode 100644 index 0000000000..5b609a29e2 --- /dev/null +++ b/common/types/http.d.ts @@ -0,0 +1,42 @@ +import HttpMethods from '../constants/HttpMethods'; +import Rest from '../lib/client/rest'; +import ErrorInfo from '../lib/types/errorinfo'; +import http from 'http'; +import https from 'https'; + +export type PathParameter = string | ((host: string) => string); +export type RequestCallback = (error?: ErrnoException | ErrorInfo | null, body?: unknown, headers?: IncomingHttpHeaders, packed?: boolean, statusCode?: number) => void; +export type RequestParams = Record | null; + +export declare class IHttp { + static methods: Array; + static methodsWithBody: Array; + static methodsWithoutBody: Array; + static do(method: HttpMethods, rest: Rest | null, path: PathParameter, headers: Record | null, body: unknown, params: RequestParams, callback?: RequestCallback): void; + static doUri(method: HttpMethods, rest: Rest | null, uri: string, headers: Record | null, body: unknown, params: RequestParams, callback?: RequestCallback): void; + static get(rest: Rest | null, path: PathParameter, headers: Record | null, params: RequestParams, callback: RequestCallback): void; + static getUri(rest: Rest | null, uri: string, headers: Record | null, params: RequestParams, callback: RequestCallback): void; + static delete(rest: Rest | null, path: PathParameter, headers: Record | null, params: RequestParams, callback: RequestCallback): void; + static deleteUri(rest: Rest | null, uri: string, headers: Record | null, params: RequestParams, callback: RequestCallback): void; + static post(rest: Rest | null, path: PathParameter, headers: Record | null, body: unknown, params: RequestParams, callback: RequestCallback): void; + static postUri(rest: Rest | null, uri: string, headers: Record | null, body: unknown, params: RequestParams, callback: RequestCallback): void; + static put(rest: Rest | null, path: PathParameter, headers: Record | null, body: unknown, params: RequestParams, callback: RequestCallback): void; + static putUri(rest: Rest | null, uri: string, headers: Record | null, body: unknown, params: RequestParams, callback: RequestCallback): void; + static patch(rest: Rest | null, path: PathParameter, headers: Record | null, body: unknown, params: RequestParams, callback: RequestCallback): void; + static patchUri(rest: Rest | null, uri: string, headers: Record | null, body: unknown, params: RequestParams, callback: RequestCallback): void; + static checkConnectivity?: (callback: (err?: ErrorInfo | null, connected?: boolean) => void) => void; + static Request?: (method: HttpMethods, rest: Rest | null, uri: string, headers: Record | null, params: RequestParams, body: unknown, callback: RequestCallback) => void; + static _getHosts: (client: Rest | Realtime) => string[]; + static supportsAuthHeaders: boolean; + static supportsLinkHeaders: boolean; + static agent?: { http: http.Agent, https: https.Agent } | null; +} + +export interface ErrnoException extends Error { + errno?: number; + code?: string; + path?: string; + syscall?: string; + stack?: string; + statusCode: number; +} diff --git a/common/types/platform-base64.d.ts b/common/types/platform-base64.d.ts new file mode 100644 index 0000000000..775d5b5872 --- /dev/null +++ b/common/types/platform-base64.d.ts @@ -0,0 +1,6 @@ +declare module 'platform-base64' { + const Base64: { + encode: (data: string) => string; + } + export default Base64; +} diff --git a/common/types/platform-bufferutils.d.ts b/common/types/platform-bufferutils.d.ts new file mode 100644 index 0000000000..476d6932a1 --- /dev/null +++ b/common/types/platform-bufferutils.d.ts @@ -0,0 +1,16 @@ +declare module 'platform-bufferutils' { + export const base64CharSet: string; + export const hexCharSet: string; + export const isBuffer: (buffer: unknown) => buffer is Buffer | ArrayBuffer | DataView; + export const toBuffer: (buffer: Buffer | TypedArray) => Buffer; + export const toArrayBuffer: (buffer: Buffer) => ArrayBuffer; + export const base64Encode: (buffer: Buffer | TypedArray) => string; + export const base64Decode: (string: string) => Buffer; + export const hexEncode: (buffer: Buffer | TypedArray) => string; + export const hexDecode: (string: string) => Buffer; + export const utf8Encode: (string: string) => Buffer; + export const utf8Decode: (buffer: Buffer) => string; + export const bufferCompare: (buffer1: Buffer, buffer2: Buffer) => number; + export const byteLength: (buffer: Buffer | ArrayBuffer | DataView) => number; + export const typedArrayToBuffer: (typedArray: TypedArray) => Buffer +} diff --git a/common/types/platform-crypto.d.ts b/common/types/platform-crypto.d.ts new file mode 100644 index 0000000000..cda5ea8153 --- /dev/null +++ b/common/types/platform-crypto.d.ts @@ -0,0 +1,3 @@ +declare module 'platform-crypto' { + export const getCipher: Function; +} diff --git a/common/types/platform-http.d.ts b/common/types/platform-http.d.ts new file mode 100644 index 0000000000..81aca4b8b2 --- /dev/null +++ b/common/types/platform-http.d.ts @@ -0,0 +1,4 @@ +declare module 'platform-http' { + const Http: typeof import('./http').IHttp; + export default Http; +} diff --git a/common/types/platform-transports.d.ts b/common/types/platform-transports.d.ts new file mode 100644 index 0000000000..f892e727d1 --- /dev/null +++ b/common/types/platform-transports.d.ts @@ -0,0 +1,6 @@ +declare module 'platform-transports' { + type Transport = import('../lib/transport/transport').default; + type ConnectionManager = import('../lib/transport/connectionmanager').default; + const PlatformTransports: Array<(connectionManager: typeof ConnectionManager) => Transport>; + export default PlatformTransports; +} diff --git a/common/types/platform-webstorage.d.ts b/common/types/platform-webstorage.d.ts new file mode 100644 index 0000000000..532f8258a9 --- /dev/null +++ b/common/types/platform-webstorage.d.ts @@ -0,0 +1,8 @@ +declare module 'platform-webstorage' { + export const get: typeof import('../../browser/lib/util/webstorage').get; + export const getSession: typeof import('../../browser/lib/util/webstorage').getSession; + export const set: typeof import('../../browser/lib/util/webstorage').set; + export const setSession: typeof import('../../browser/lib/util/webstorage').setSession; + export const remove: typeof import('../../browser/lib/util/webstorage').remove; + export const removeSession: typeof import('../../browser/lib/util/webstorage').removeSession; +} diff --git a/common/types/utils.d.ts b/common/types/utils.d.ts new file mode 100644 index 0000000000..5408d61094 --- /dev/null +++ b/common/types/utils.d.ts @@ -0,0 +1,7 @@ +export type StandardCallback = (err?: ErrorInfo | null, result?: T) => void; +export type ErrCallback = (err?: ErrorInfo | null) => void; +export type PaginatedResultCallback = StandardCallback>; +/** + * Use this to override specific property typings on an existing object type + */ +export type Modify = Omit & R; diff --git a/nodejs/lib/transport/nodecomettransport.js b/nodejs/lib/transport/nodecomettransport.js index 99f9964935..ea4ca68c83 100644 --- a/nodejs/lib/transport/nodecomettransport.js +++ b/nodejs/lib/transport/nodecomettransport.js @@ -1,9 +1,11 @@ "use strict"; import CometTransport from '../../../common/lib/transport/comettransport'; import Logger from '../../../common/lib/util/logger'; -import Utils from '../../../common/lib/util/utils'; +import * as Utils from '../../../common/lib/util/utils'; import ErrorInfo from '../../../common/lib/types/errorinfo'; import EventEmitter from '../../../common/lib/util/eventemitter'; +import HttpStatusCodes from '../../../common/constants/HttpStatusCodes'; +import XHRStates from '../../../common/constants/XHRStates'; var NodeCometTransport = function(connectionManager) { var http = require('http'); @@ -136,7 +138,7 @@ var NodeCometTransport = function(connectionManager) { Utils.inherits(Request, EventEmitter); Request.prototype.exec = function() { - var timeout = (this.requestMode == CometTransport.REQ_SEND) ? this.timeouts.httpRequestTimeout : this.timeouts.recvTimeout, + var timeout = (this.requestMode == XHRStates.REQ_SEND) ? this.timeouts.httpRequestTimeout : this.timeouts.recvTimeout, self = this; var timer = this.timer = setTimeout(function() { self.abort(); }, timeout), @@ -154,7 +156,7 @@ var NodeCometTransport = function(connectionManager) { self.timer = null; var statusCode = res.statusCode; - if(statusCode == 204) { + if(statusCode == HttpStatusCodes.NoContent) { /* cause the stream to flow, and thus end */ res.resume(); self.complete(); @@ -168,7 +170,7 @@ var NodeCometTransport = function(connectionManager) { self.res = res; /* responses with an non-success statusCode are never streamed */ - if(self.requestMode == CometTransport.REQ_RECV_STREAM && statusCode < 400) { + if(self.requestMode == XHRStates.REQ_RECV_STREAM && statusCode < 400) { self.readStream(); } else { self.readFully(); diff --git a/nodejs/lib/util/bufferutils.js b/nodejs/lib/util/bufferutils.js deleted file mode 100644 index 87f98a91ec..0000000000 --- a/nodejs/lib/util/bufferutils.js +++ /dev/null @@ -1,59 +0,0 @@ -var BufferUtils = (function() { - function BufferUtils() {} - - function isArrayBuffer(ob) { return ob !== null && ob !== undefined && ob.constructor === ArrayBuffer; } - - /* In node, BufferUtils methods that return binary objects return a Buffer - * for historical reasons; the browser equivalents return ArrayBuffers */ - var isBuffer = BufferUtils.isBuffer = function(buf) { return Buffer.isBuffer(buf) || isArrayBuffer(buf) || ArrayBuffer.isView(buf); }; - - var toBuffer = BufferUtils.toBuffer = function(buf) { - if(Buffer.isBuffer(buf)) { - return buf; - } - return Buffer.from(buf); - }; - - BufferUtils.toArrayBuffer = function(buf) { return toBuffer(buf).buffer; }; - - BufferUtils.base64Encode = function(buf) { return toBuffer(buf).toString('base64'); }; - - BufferUtils.base64Decode = function(string) { return Buffer.from(string, 'base64'); }; - - BufferUtils.hexEncode = function(buf) { return toBuffer(buf).toString('hex'); }; - - BufferUtils.hexDecode = function(string) { return Buffer.from(string, 'hex'); }; - - BufferUtils.utf8Encode = function(string) { return Buffer.from(string, 'utf8'); }; - - /* For utf8 decoding we apply slightly stricter input validation than to - * hexEncode/base64Encode/etc: in those we accept anything that Buffer.from - * can take (in particular allowing strings, which are just interpreted as - * binary); here we ensure that the input is actually a buffer since trying - * to utf8-decode a string to another string is almost certainly a mistake */ - BufferUtils.utf8Decode = function(buf) { - if(!isBuffer(buf)) { - throw new Error("Expected input of utf8Decode to be a buffer, arraybuffer, or view"); - } - return toBuffer(buf).toString('utf8'); - }; - - BufferUtils.bufferCompare = function(buf1, buf2) { - if(!buf1) return -1; - if(!buf2) return 1; - return buf1.compare(buf2); - }; - - BufferUtils.byteLength = function(buffer) { - return buffer.byteLength; - }; - - /* Returns ArrayBuffer on browser and Buffer on Node.js */ - BufferUtils.typedArrayToBuffer = function(typedArray) { - return toBuffer(typedArray.buffer); - }; - - return BufferUtils; -})(); - -export default BufferUtils; diff --git a/nodejs/lib/util/bufferutils.ts b/nodejs/lib/util/bufferutils.ts new file mode 100644 index 0000000000..bc1217dcce --- /dev/null +++ b/nodejs/lib/util/bufferutils.ts @@ -0,0 +1,53 @@ +import { TypedArray } from '../../../common/types/IPlatform'; + +function isArrayBuffer(ob: unknown) { return ob !== null && ob !== undefined && (ob as ArrayBuffer).constructor === ArrayBuffer; } + +/* In node, BufferUtils methods that return binary objects return a Buffer + * for historical reasons; the browser equivalents return ArrayBuffers */ +export const isBuffer = function(buffer: Buffer | string): buffer is Buffer { return Buffer.isBuffer(buffer) || isArrayBuffer(buffer) || ArrayBuffer.isView(buffer); }; + + export const toBuffer = function(buffer: Buffer) { + if(Buffer.isBuffer(buffer)) { + return buffer; + } + return Buffer.from(buffer); +}; + +export const toArrayBuffer = function(buffer: Buffer) { return toBuffer(buffer).buffer; }; + +export const base64Encode = function(buffer: Buffer) { return toBuffer(buffer).toString('base64'); }; + +export const base64Decode = function(string: string) { return Buffer.from(string, 'base64'); }; + +export const hexEncode = function(buffer: Buffer) { return toBuffer(buffer).toString('hex'); }; + +export const hexDecode = function(string: string) { return Buffer.from(string, 'hex'); }; + +export const utf8Encode = function(string: string) { return Buffer.from(string, 'utf8'); }; + +/* For utf8 decoding we apply slightly stricter input validation than to + * hexEncode/base64Encode/etc: in those we accept anything that Buffer.from + * can take (in particular allowing strings, which are just interpreted as + * binary); here we ensure that the input is actually a buffer since trying + * to utf8-decode a string to another string is almost certainly a mistake */ +export const utf8Decode = function(buffer: Buffer) { + if(!isBuffer(buffer)) { + throw new Error("Expected input of utf8Decode to be a buffer, arraybuffer, or view"); + } + return toBuffer(buffer).toString('utf8'); +}; + +export const bufferCompare = function(buffer1: Buffer, buffer2: Buffer) { + if(!buffer1) return -1; + if(!buffer2) return 1; + return buffer1.compare(buffer2); +}; + +export const byteLength = function(buffer: Buffer) { + return buffer.byteLength; +}; + +/* Returns ArrayBuffer on browser and Buffer on Node.js */ +export const typedArrayToBuffer = function(typedArray: TypedArray) { + return toBuffer(typedArray.buffer as Buffer); +}; diff --git a/nodejs/lib/util/crypto.js b/nodejs/lib/util/crypto.js index 105878ac02..e368a22dfe 100644 --- a/nodejs/lib/util/crypto.js +++ b/nodejs/lib/util/crypto.js @@ -1,6 +1,6 @@ "use strict"; import Logger from '../../../common/lib/util/logger'; -import BufferUtils from 'platform-bufferutils'; +import * as BufferUtils from 'platform-bufferutils'; var Crypto = (function() { var crypto = require('crypto'); diff --git a/nodejs/lib/util/defaults.ts b/nodejs/lib/util/defaults.ts index dd6b6d8fb0..19b51a5633 100644 --- a/nodejs/lib/util/defaults.ts +++ b/nodejs/lib/util/defaults.ts @@ -1,15 +1,17 @@ -const Defaults = { +import IDefaults from '../../../common/types/IDefaults'; +import TransportNames from '../../../common/constants/TransportNames'; + +const Defaults: IDefaults = { internetUpUrl: 'https://internet-up.ably-realtime.com/is-the-internet-up.txt', /* Note: order matters here: the base transport is the leftmost one in the * intersection of baseTransportOrder and the transports clientOption that's supported. * (For node this is the same as the transportPreferenceOrder, but for * browsers it's different*/ - defaultTransports: ['web_socket'], - baseTransportOrder: ['comet', 'web_socket'], - transportPreferenceOrder: ['comet', 'web_socket'], - upgradeTransports: ['web_socket'], - restAgentOptions: {maxSockets: 40, keepAlive: true}, - agent: 'nodejs/' + process.versions.node + defaultTransports: [TransportNames.WebSocket], + baseTransportOrder: [TransportNames.Comet, TransportNames.WebSocket], + transportPreferenceOrder: [TransportNames.Comet, TransportNames.WebSocket], + upgradeTransports: [TransportNames.WebSocket], + restAgentOptions: {maxSockets: 40, keepAlive: true} }; export default Defaults; diff --git a/nodejs/lib/util/http.js b/nodejs/lib/util/http.js deleted file mode 100644 index 264230507f..0000000000 --- a/nodejs/lib/util/http.js +++ /dev/null @@ -1,229 +0,0 @@ -"use strict"; -import Platform from 'platform'; -import Utils from '../../../common/lib/util/utils'; -import Defaults from '../../../common/lib/util/defaults'; -import ErrorInfo from '../../../common/lib/types/errorinfo'; -import got from 'got'; -import http from 'http'; -import https from 'https'; - -var Http = (function() { - var msgpack = Platform.msgpack; - var noop = function() {}; - - /*************************************************** - * - * These Http ops are used for REST operations - * and assume that the system is stateless - ie - * there is no connection state that tells us - * anything about the state of the network or the - * viability of any of the hosts we know about. - * Therefore all requests will respond to specific - * errors by attempting the fallback hosts, and no - * assumptions about host or network is retained to - * influence the handling of any subsequent request. - * - ***************************************************/ - - var handler = function(uri, params, callback) { - callback = callback || noop; - return function(err, response, body) { - if(err) { - callback(err); - return; - } - var statusCode = response.statusCode, headers = response.headers; - if(statusCode >= 300) { - switch(headers['content-type']) { - case 'application/json': - body = JSON.parse(body); - break; - case 'application/x-msgpack': - body = msgpack.decode(body); - } - var error = body.error ? ErrorInfo.fromValues(body.error) : new ErrorInfo( - headers['x-ably-errormessage'] || 'Error response received from server: ' + statusCode + ' body was: ' + Utils.inspect(body), - headers['x-ably-errorcode'], - statusCode - ); - callback(error, body, headers, true, statusCode); - return; - } - callback(null, body, headers, false, statusCode); - }; - }; - - function Http() {} - - function shouldFallback(err) { - var code = err.code, - statusCode = err.statusCode; - return code === 'ENETUNREACH' || - code === 'EHOSTUNREACH' || - code === 'EHOSTDOWN' || - code === 'ETIMEDOUT' || - code === 'ESOCKETTIMEDOUT' || - code === 'ENOTFOUND' || - code === 'ECONNRESET' || - code === 'ECONNREFUSED' || - (statusCode >= 500 && statusCode <= 504); - } - - function getHosts(client) { - /* If we're a connected realtime client, try the endpoint we're connected - * to first -- but still have fallbacks, being connected is not an absolute - * guarantee that a datacenter has free capacity to service REST requests. */ - var connection = client.connection, - connectionHost = connection && connection.connectionManager.host; - - if(connectionHost) { - return [connectionHost].concat(Defaults.getFallbackHosts(client.options)); - } - - return Defaults.getHosts(client.options); - } - Http._getHosts = getHosts; - - Http.methods = ['get', 'delete', 'post', 'put', 'patch']; - Http.methodsWithoutBody = ['get', 'delete']; - Http.methodsWithBody = Utils.arrSubtract(Http.methods, Http.methodsWithoutBody); - - /** Http.get, Http.post, Http.put, ... - * Perform an HTTP request for a given path against prime and fallback Ably hosts - * @param rest - * @param path the full path - * @param headers optional hash of headers - * [only for methods with body: @param body object or buffer containing request body] - * @param params optional hash of params - * @param callback (err, response) - * - ** Http.getUri, Http.postUri, Http.putUri, ... - * Perform an HTTP request for a given full URI - * @param rest - * @param uri the full URI - * @param headers optional hash of headers - * [only for methods with body: @param body object or buffer containing request body] - * @param params optional hash of params - * @param callback (err, response) - */ - Utils.arrForEach(Http.methodsWithoutBody, function(method) { - Http[method] = function(rest, path, headers, params, callback) { - Http['do'](method, rest, path, headers, null, params, callback); - }; - Http[method + 'Uri'] = function(rest, uri, headers, params, callback) { - Http.doUri(method, rest, uri, headers, null, params, callback); - }; - }); - - Utils.arrForEach(Http.methodsWithBody, function(method) { - Http[method] = function(rest, path, headers, body, params, callback) { - Http['do'](method, rest, path, headers, body, params, callback); - }; - Http[method + 'Uri'] = function(rest, uri, headers, body, params, callback) { - Http.doUri(method, rest, uri, headers, body, params, callback); - }; - }); - - /* Unlike for doUri, the 'rest' param here is mandatory, as it's used to generate the hosts */ - Http['do'] = function(method, rest, path, headers, body, params, callback) { - var uriFromHost = (typeof(path) == 'function') ? path : function(host) { return rest.baseUri(host) + path; }; - var doArgs = arguments; - - var currentFallback = rest._currentFallback; - if(currentFallback) { - if(currentFallback.validUntil > Date.now()) { - /* Use stored fallback */ - Http.doUri(method, rest, uriFromHost(currentFallback.host), headers, body, params, function(err) { - if(err && shouldFallback(err)) { - /* unstore the fallback and start from the top with the default sequence */ - rest._currentFallback = null; - Http['do'].apply(Http, doArgs); - return; - } - callback.apply(null, arguments); - }); - return; - } else { - /* Fallback expired; remove it and fallthrough to normal sequence */ - rest._currentFallback = null; - } - } - - var hosts = getHosts(rest); - - /* see if we have one or more than one host */ - if(hosts.length == 1) { - Http.doUri(method, rest, uriFromHost(hosts[0]), headers, body, params, callback); - return; - } - - var tryAHost = function(candidateHosts, persistOnSuccess) { - var host = candidateHosts.shift(); - Http.doUri(method, rest, uriFromHost(host), headers, body, params, function(err) { - if(err && shouldFallback(err) && candidateHosts.length) { - tryAHost(candidateHosts, true); - return; - } - if(persistOnSuccess) { - /* RSC15f */ - rest._currentFallback = { - host: host, - validUntil: Date.now() + rest.options.timeouts.fallbackRetryTimeout - }; - } - callback.apply(null, arguments); - }); - }; - tryAHost(hosts); - }; - - Http.doUri = function(method, rest, uri, headers, body, params, callback) { - /* Will generally be making requests to one or two servers exclusively - * (Ably and perhaps an auth server), so for efficiency, use the - * foreverAgent to keep the TCP stream alive between requests where possible */ - var agentOptions = (rest && rest.options.restAgentOptions) || Defaults.restAgentOptions; - var doOptions = { headers: headers || undefined, responseType: 'buffer' }; - if (body) { - doOptions.body = body; - } - if(params) - doOptions.searchParams = params; - - if (!Http.agent) { - Http.agent = { - http: new http.Agent(agentOptions), - https: new https.Agent(agentOptions) - } - } - - doOptions.agent = Http.agent; - - - doOptions.url = uri; - doOptions.timeout = { request: (rest && rest.options.timeouts || Defaults.TIMEOUTS).httpRequestTimeout }; - - got[method](doOptions).then(res => { - handler(uri, params, callback)(null, res, res.body); - }).catch(err => { - if (err instanceof got.HTTPError) { - handler(uri, params, callback)(null, err.response, err.response.body); - return; - } - handler(uri, params, callback)(err); - }); - }; - - Http.supportsAuthHeaders = true; - Http.supportsLinkHeaders = true; - - Http.checkConnectivity = function(callback) { - var upUrl = Defaults.internetUpUrl; - Http.getUri(null, upUrl, null, null, function(err, responseText) { - callback(null, (!err && responseText.toString().trim() === 'yes')); - }); - }; - - return Http; -})(); - -export default Http; diff --git a/nodejs/lib/util/http.ts b/nodejs/lib/util/http.ts new file mode 100644 index 0000000000..5ec3e60b40 --- /dev/null +++ b/nodejs/lib/util/http.ts @@ -0,0 +1,251 @@ +import Platform from 'platform'; +import * as Utils from '../../../common/lib/util/utils'; +import Defaults from '../../../common/lib/util/defaults'; +import ErrorInfo from '../../../common/lib/types/errorinfo'; +import { ErrnoException, IHttp, PathParameter, RequestCallback, RequestParams } from '../../../common/types/http'; +import HttpMethods from '../../../common/constants/HttpMethods'; +import got, { Response, Options, CancelableRequest } from 'got'; +import http from 'http'; +import https from 'https'; +import Rest from '../../../common/lib/client/rest'; +import Realtime from '../../../common/lib/client/realtime'; + +const msgpack = Platform.msgpack; + +/*************************************************** + * + * These Http operations are used for REST operations + * and assume that the system is stateless - ie + * there is no connection state that tells us + * anything about the state of the network or the + * viability of any of the hosts we know about. + * Therefore all requests will respond to specific + * errors by attempting the fallback hosts, and no + * assumptions about host or network is retained to + * influence the handling of any subsequent request. + * + ***************************************************/ + +const handler = function(uri: string, params: unknown, callback?: RequestCallback) { + return function(err: ErrnoException | null, response?: Response, body?: unknown) { + if(err) { + callback?.(err); + return; + } + const statusCode = (response as Response).statusCode, headers = (response as Response).headers; + if(statusCode >= 300) { + switch(headers['content-type']) { + case 'application/json': + body = JSON.parse(body as string); + break; + case 'application/x-msgpack': + body = msgpack.decode(body as Buffer); + } + const error = (body as { error: ErrorInfo }).error ? ErrorInfo.fromValues((body as { error: ErrorInfo }).error) : new ErrorInfo( + (headers['x-ably-errormessage'] as string) || 'Error response received from server: ' + statusCode + ' body was: ' + Utils.inspect(body), + Number(headers['x-ably-errorcode']), + statusCode + ); + callback?.(error, body, headers, true, statusCode); + return; + } + callback?.(null, body, headers, false, statusCode); + }; +}; + +function shouldFallback(err: ErrnoException) { + const { code, statusCode } = err; + return code === 'ENETUNREACH' || + code === 'EHOSTUNREACH' || + code === 'EHOSTDOWN' || + code === 'ETIMEDOUT' || + code === 'ESOCKETTIMEDOUT' || + code === 'ENOTFOUND' || + code === 'ECONNRESET' || + code === 'ECONNREFUSED' || + (statusCode >= 500 && statusCode <= 504); +} + + +function getHosts(client: Rest | Realtime): string[] { + /* If we're a connected realtime client, try the endpoint we're connected + * to first -- but still have fallbacks, being connected is not an absolute + * guarantee that a datacenter has free capacity to service REST requests. */ + const connection = (client as Realtime).connection; + const connectionHost = connection && connection.connectionManager.host; + + if(connectionHost) { + return [connectionHost].concat(Defaults.getFallbackHosts(client.options)); + } + + return Defaults.getHosts(client.options); +} + +const Http: typeof IHttp = class { + static methods = [HttpMethods.Get, HttpMethods.Delete, HttpMethods.Post, HttpMethods.Put, HttpMethods.Patch]; + static methodsWithoutBody = [HttpMethods.Get, HttpMethods.Delete]; + static methodsWithBody = [HttpMethods.Post, HttpMethods.Put, HttpMethods.Patch]; + static agent = null; + + /* Unlike for doUri, the 'rest' param here is mandatory, as it's used to generate the hosts */ + static do(method: HttpMethods, rest: Rest, path: PathParameter, headers: Record | null, body: unknown, params: RequestParams, callback: RequestCallback): void { + const uriFromHost = (typeof(path) === 'function') ? path : function(host: string) { return rest.baseUri(host) + path; }; + + const currentFallback = rest._currentFallback; + if(currentFallback) { + if(currentFallback.validUntil > Date.now()) { + /* Use stored fallback */ + Http.doUri(method, rest, uriFromHost(currentFallback.host), headers, body, params, (err?: ErrnoException | ErrorInfo | null, ...args: unknown[]) => { + if(err && shouldFallback(err as ErrnoException)) { + /* unstore the fallback and start from the top with the default sequence */ + rest._currentFallback = null; + Http.do(method, rest, path, headers, body, params, callback); + return; + } + callback(err, ...args); + }); + return; + } else { + /* Fallback expired; remove it and fallthrough to normal sequence */ + rest._currentFallback = null; + } + } + + const hosts = getHosts(rest); + + /* see if we have one or more than one host */ + if(hosts.length === 1) { + Http.doUri(method, rest, uriFromHost(hosts[0]), headers, body, params, callback); + return; + } + + const tryAHost = (candidateHosts: Array, persistOnSuccess?: boolean) => { + const host = candidateHosts.shift(); + Http.doUri(method, rest, uriFromHost(host as string), headers, body, params, function(err?: ErrnoException | ErrorInfo | null, ...args: unknown[]) { + if(err && shouldFallback(err as ErrnoException) && candidateHosts.length) { + tryAHost(candidateHosts, true); + return; + } + if(persistOnSuccess) { + /* RSC15f */ + rest._currentFallback = { + host: host as string, + validUntil: Date.now() + rest.options.timeouts.fallbackRetryTimeout + }; + } + callback(err, ...args); + }); + }; + tryAHost(hosts); + } + + static doUri(method: HttpMethods, rest: Rest, uri: string, headers: Record | null, body: unknown, params: RequestParams, callback: RequestCallback): void { + /* Will generally be making requests to one or two servers exclusively + * (Ably and perhaps an auth server), so for efficiency, use the + * foreverAgent to keep the TCP stream alive between requests where possible */ + const agentOptions = (rest && rest.options.restAgentOptions) || Defaults.restAgentOptions; + // const doOptions: RequestOptions = {uri, headers: headers ?? undefined, encoding: null, agentOptions: agentOptions}; + const doOptions: Options = { headers: headers || undefined, responseType: 'buffer' }; + if (!Http.agent) { + Http.agent = { + http: new http.Agent(agentOptions), + https: new https.Agent(agentOptions) + } + } + + if (body) { + doOptions.body = body as Buffer; + } + if(params) + doOptions.searchParams = params; + + doOptions.agent = Http.agent; + + doOptions.url = uri; + doOptions.timeout = { request: (rest && rest.options.timeouts || Defaults.TIMEOUTS).httpRequestTimeout }; + + (got[method](doOptions) as CancelableRequest).then((res: Response) => { + handler(uri, params, callback)(null, res, res.body); + }).catch((err: ErrnoException) => { + if (err instanceof got.HTTPError) { + handler(uri, params, callback)(null, err.response, err.response.body); + return; + } + handler(uri, params, callback)(err); + }); + } + + /** Http.get, Http.post, Http.put, ... + * Perform an HTTP request for a given path against prime and fallback Ably hosts + * @param rest + * @param path the full path + * @param headers optional hash of headers + * [only for methods with body: @param body object or buffer containing request body] + * @param params optional hash of params + * @param callback (err, response) + * + ** Http.getUri, Http.postUri, Http.putUri, ... + * Perform an HTTP request for a given full URI + * @param rest + * @param uri the full URI + * @param headers optional hash of headers + * [only for methods with body: @param body object or buffer containing request body] + * @param params optional hash of params + * @param callback (err, response) + */ + + static get(rest: Rest, path: string, headers: Record | null, params: RequestParams, callback: RequestCallback): void { + Http.do(HttpMethods.Get, rest, path, headers, null, params, callback); + } + + static getUri(rest: Rest, uri: string, headers: Record | null, params: RequestParams, callback: RequestCallback): void { + Http.doUri(HttpMethods.Get, rest, uri, headers, null, params, callback); + } + + static delete(rest: Rest, path: string, headers: Record | null, params: RequestParams, callback: RequestCallback): void { + Http.do(HttpMethods.Delete, rest, path, headers, null, params, callback); + } + + static deleteUri(rest: Rest, uri: string, headers: Record | null, params: RequestParams, callback: RequestCallback): void { + Http.doUri(HttpMethods.Delete, rest, uri, headers, null, params, callback); + } + + static post(rest: Rest, path: string, headers: Record | null, body: unknown, params: RequestParams, callback: RequestCallback): void { + Http.do(HttpMethods.Post, rest, path, headers, body, params, callback); + } + + static postUri(rest: Rest, uri: string, headers: Record | null, body: unknown, params: RequestParams, callback: RequestCallback): void { + Http.doUri(HttpMethods.Post, rest, uri, headers, body, params, callback); + } + + static put(rest: Rest, path: string, headers: Record | null, body: unknown, params: RequestParams, callback: RequestCallback): void { + Http.do(HttpMethods.Put, rest, path, headers, body, params, callback); + } + + static putUri(rest: Rest, uri: string, headers: Record | null, body: unknown, params: RequestParams, callback: RequestCallback): void { + Http.doUri(HttpMethods.Put, rest, uri, headers, body, params, callback); + } + + static patch(rest: Rest, path: string, headers: Record | null, body: unknown, params: RequestParams, callback: RequestCallback): void { + Http.do(HttpMethods.Patch, rest, path, headers, body, params, callback); + } + + static patchUri(rest: Rest, uri: string, headers: Record | null, body: unknown, params: RequestParams, callback: RequestCallback): void { + Http.doUri(HttpMethods.Patch, rest, uri, headers, body, params, callback); + } + + static checkConnectivity = function (callback: (errorInfo: ErrorInfo | null, connected?: boolean) => void): void { + Http.getUri(null, Defaults.internetUpUrl, null, null, function(err?: ErrnoException | ErrorInfo | null, responseText?: unknown) { + callback(null, !err && (responseText as Buffer | string)?.toString().trim() === 'yes'); + }); + } + + static Request?: (method: HttpMethods, rest: Rest | null, uri: string, headers: Record | null, params: RequestParams, body: unknown, callback: RequestCallback) => void = undefined; + + static _getHosts = getHosts; + + static supportsAuthHeaders = true; + static supportsLinkHeaders = true; +} + +export default Http; diff --git a/nodejs/platform.js b/nodejs/platform.js deleted file mode 100644 index 297290b8d9..0000000000 --- a/nodejs/platform.js +++ /dev/null @@ -1,27 +0,0 @@ -var Platform = { - agent: 'nodejs/' + process.versions.node, - logTimestamps: true, - userAgent: null, - binaryType: 'nodebuffer', - WebSocket: require('ws'), - useProtocolHeartbeats: false, - createHmac: require('crypto').createHmac, - msgpack: require('@ably/msgpack-js'), - supportsBinary: true, - preferBinary: true, - nextTick: process.nextTick, - inspect: require('util').inspect, - stringByteSize: Buffer.byteLength, - inherits: require('util').inherits, - addEventListener: null, - getRandomValues: function(arr, callback) { - var bytes = require('crypto').randomBytes(arr.length); - arr.set(bytes); - if(callback) { - callback(null); - } - }, - Promise: global && global.Promise -}; - -export default Platform; diff --git a/nodejs/platform.ts b/nodejs/platform.ts new file mode 100644 index 0000000000..ced0841911 --- /dev/null +++ b/nodejs/platform.ts @@ -0,0 +1,32 @@ +import { TypedArray, IPlatform } from '../common/types/IPlatform'; +import crypto from 'crypto'; +import WebSocket from 'ws'; +import util from 'util'; + +const Platform: IPlatform = { + agent: 'nodejs/' + process.versions.node, + logTimestamps: true, + userAgent: null, + binaryType: 'nodebuffer' as BinaryType, + WebSocket, + useProtocolHeartbeats: false, + createHmac: crypto.createHmac, + msgpack: require('@ably/msgpack-js'), + supportsBinary: true, + preferBinary: true, + nextTick: process.nextTick, + inspect: util.inspect, + stringByteSize: Buffer.byteLength, + inherits: util.inherits, + addEventListener: null, + getRandomValues: function(arr: TypedArray, callback?: (err?: Error | null) => void): void { + const bytes = crypto.randomBytes(arr.length); + arr.set(bytes); + if(callback) { + callback(null); + } + }, + Promise: global && global.Promise +}; + +export default Platform; diff --git a/package-lock.json b/package-lock.json index db864ab778..5e7426b380 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,13 +9,16 @@ "version": "1.2.17", "license": "Apache-2.0", "dependencies": { - "@ably/msgpack-js": "^0.3.3", + "@ably/msgpack-js": "^0.4.0", "got": "^11.8.2", "ws": "^5.1" }, "devDependencies": { "@ably/vcdiff-decoder": "1.0.4", + "@types/crypto-js": "^4.0.1", "@types/node": "^15.0.0", + "@types/request": "^2.48.7", + "@types/ws": "^8.2.0", "async": "ably-forks/async#requirejs", "chai": "^4.2.0", "copy-webpack-plugin": "^6.4.1", @@ -43,6 +46,7 @@ "shelljs": "~0.8", "source-map-explorer": "^2.5.2", "ts-loader": "^8.2.0", + "tslib": "^2.3.1", "typescript": "^4.2.4", "webpack": "^4.44.2", "webpack-cli": "^4.2.0" @@ -52,11 +56,11 @@ } }, "node_modules/@ably/msgpack-js": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/@ably/msgpack-js/-/msgpack-js-0.3.3.tgz", - "integrity": "sha512-H7oWg97VyA1JhWUP7YN7zwp9W1ozCqMSsqCcXNz4XLmZNdJKT2ntF/6DPgbviFgUpShjQlbPC/iamisTjwLHdQ==", + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@ably/msgpack-js/-/msgpack-js-0.4.0.tgz", + "integrity": "sha512-IPt/BoiQwCWubqoNik1aw/6M/DleMdrxJOUpSja6xmMRbT2p1TA8oqKWgfZabqzrq8emRNeSl/+4XABPNnW5pQ==", "dependencies": { - "bops": "~0.0.6" + "bops": "^1.0.1" } }, "node_modules/@ably/vcdiff-decoder": { @@ -222,9 +226,9 @@ } }, "node_modules/@sindresorhus/is": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.2.0.tgz", - "integrity": "sha512-VkE3KLBmJwcCaVARtQpfuKcKv8gcBmUubrfHGF84dXuuW6jgsRYxPtzcIhPyK9WAPpRt2/xY6zkD9MnRaJzSyw==", + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.4.0.tgz", + "integrity": "sha512-QppPM/8l3Mawvh4rn9CNEYIU9bxpXUCRMaX9yUpvBk1nMKusLKpfXGDEKExKaPhLzcn3lzil7pR6rnJ11HgeRQ==", "engines": { "node": ">=10" }, @@ -254,15 +258,27 @@ "@types/responselike": "*" } }, + "node_modules/@types/caseless": { + "version": "0.12.2", + "resolved": "https://registry.npmjs.org/@types/caseless/-/caseless-0.12.2.tgz", + "integrity": "sha512-6ckxMjBBD8URvjB6J3NcnuAn5Pkl7t3TizAg+xdlzzQGSPSmBcXf8KoIH0ua/i+tio+ZRUHEXp0HEmvaR4kt0w==", + "dev": true + }, + "node_modules/@types/crypto-js": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@types/crypto-js/-/crypto-js-4.1.1.tgz", + "integrity": "sha512-BG7fQKZ689HIoc5h+6D2Dgq1fABRa0RbBWKBd9SP/MVRVXROflpm5fhwyATX5duFmbStzyzyycPB8qUYKDH3NA==", + "dev": true + }, "node_modules/@types/http-cache-semantics": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.1.tgz", "integrity": "sha512-SZs7ekbP8CN0txVG2xVRH6EgKmEm31BOxA07vkFaETzZz1xh+cbt8BcI0slpymvwhx5dlFnQG2rTlPVQn+iRPQ==" }, "node_modules/@types/json-schema": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.6.tgz", - "integrity": "sha512-3c+yGKvVP5Y9TYBEibGNR+kLtijnj7mYrXRg+WpFb2X9xm04g/DXYkfg4hmzJQosc9snFNUPkbYIhu+KAm6jJw==", + "version": "7.0.9", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.9.tgz", + "integrity": "sha512-qcUXuemtEu+E5wZSJHNxUXeCZhAfXKQ41D+duX+VYPde7xyEVZci+/oXKJL13tnRs9lR2pr4fod59GT6/X1/yQ==", "dev": true }, "node_modules/@types/keyv": { @@ -278,6 +294,18 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-15.0.0.tgz", "integrity": "sha512-YN1d+ae2MCb4U0mMa+Zlb5lWTdpFShbAj5nmte6lel27waMMBfivrm0prC16p/Di3DyTrmerrYUT8/145HXxVw==" }, + "node_modules/@types/request": { + "version": "2.48.8", + "resolved": "https://registry.npmjs.org/@types/request/-/request-2.48.8.tgz", + "integrity": "sha512-whjk1EDJPcAR2kYHRbFl/lKeeKYTi05A15K9bnLInCVroNDCtXce57xKdI0/rQaA3K+6q0eFyUBPmqfSndUZdQ==", + "dev": true, + "dependencies": { + "@types/caseless": "*", + "@types/node": "*", + "@types/tough-cookie": "*", + "form-data": "^2.5.0" + } + }, "node_modules/@types/responselike": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.0.tgz", @@ -286,6 +314,21 @@ "@types/node": "*" } }, + "node_modules/@types/tough-cookie": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.1.tgz", + "integrity": "sha512-Y0K95ThC3esLEYD6ZuqNek29lNX2EM1qxV8y2FTLUB0ff5wWrk7az+mLrnNFUnaXcgKye22+sFBRXOgpPILZNg==", + "dev": true + }, + "node_modules/@types/ws": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.0.tgz", + "integrity": "sha512-mTClfhq5cuGyW4jthaFuig6Q8OVfB3IRyZfN/9SCyJtiM5H0SubwM89cHoT9UngO6HyUFic88HvT1zSNLNyxWA==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/yauzl": { "version": "2.9.1", "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.9.1.tgz", @@ -832,6 +875,12 @@ "resolved": "https://registry.npmjs.org/async-limiter/-/async-limiter-1.0.1.tgz", "integrity": "sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==" }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=", + "dev": true + }, "node_modules/atob": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/atob/-/atob-2.1.2.tgz", @@ -919,9 +968,9 @@ } }, "node_modules/base64-js": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.2.tgz", - "integrity": "sha1-Ak8Pcq+iW3X5wO5zzU9V7Bvtl4Q=", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.0.2.tgz", + "integrity": "sha1-R0IRyV5s8qVH20YeT2d4tR0I+mU=", "engines": { "node": ">= 0.4" } @@ -955,6 +1004,12 @@ "file-uri-to-path": "1.0.0" } }, + "node_modules/bluebird": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", + "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", + "dev": true + }, "node_modules/bn.js": { "version": "5.1.3", "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.1.3.tgz", @@ -1007,11 +1062,11 @@ } }, "node_modules/bops": { - "version": "0.0.7", - "resolved": "https://registry.npmjs.org/bops/-/bops-0.0.7.tgz", - "integrity": "sha1-tKClqDmkBkVK8P4FqLkaenZqVOI=", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/bops/-/bops-1.0.1.tgz", + "integrity": "sha512-qCMBuZKP36tELrrgXpAfM+gHzqa0nLsWZ+L37ncsb8txYlnAoxOPpVp+g7fK0sGkMXfA0wl8uQkESqw3v4HNag==", "dependencies": { - "base64-js": "0.0.2", + "base64-js": "1.0.2", "to-utf8": "0.0.1" } }, @@ -1259,12 +1314,6 @@ "y18n": "^4.0.0" } }, - "node_modules/cacache/node_modules/bluebird": { - "version": "3.7.2", - "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", - "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", - "dev": true - }, "node_modules/cacache/node_modules/glob": { "version": "7.1.6", "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz", @@ -1483,6 +1532,12 @@ "node": ">=6.0" } }, + "node_modules/chrome-trace-event/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "dev": true + }, "node_modules/cipher-base": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/cipher-base/-/cipher-base-1.0.4.tgz", @@ -1647,6 +1702,18 @@ "node": ">=0.1.90" } }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/command-line-usage": { "version": "6.1.1", "resolved": "https://registry.npmjs.org/command-line-usage/-/command-line-usage-6.1.1.tgz", @@ -2167,10 +2234,9 @@ } }, "node_modules/debug": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.0.tgz", - "integrity": "sha512-jjO6JD2rKfiZQnBoRzhRTbXjHLGLfH+UtGkWLc/UXAh/rzZMyjbgn0NcfFpqT8nd1kTtFnDiJcrIFkq4UKeJVg==", - "deprecated": "Debug versions >=3.2.0 <3.2.7 || >=4 <4.3.1 have a low-severity ReDos regression when used in a Node.js environment. It is recommended you upgrade to 3.2.7 or 4.3.1. (https://github.com/visionmedia/debug/issues/797)", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.2.tgz", + "integrity": "sha512-mOp8wKcvj7XxC78zLgw/ZA+6TSgkoE2C/ienthhRD298T7UNwAg9diBpLRxC0mOezLl4B0xV7M0cCO6P/O0Xhw==", "dev": true, "dependencies": { "ms": "2.1.2" @@ -2322,6 +2388,15 @@ "node": ">=0.10.0" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=", + "dev": true, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/depd": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", @@ -2741,9 +2816,9 @@ } }, "node_modules/esquery": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.3.1.tgz", - "integrity": "sha512-olpvt9QG0vniUBZspVRN6lwB7hOZoTRtT+jzR+tS4ffYx2mzbw+z0XCOk44aaLYKApNX5nMm+E+P6o25ip/DHQ==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.4.0.tgz", + "integrity": "sha512-cCDispWt5vHHtwMY2YrAQ4ibFkAL8RbH5YGBnZBc90MolvvfkkQcJro/aZiAQUlQ3qgrYS6D6v8Gc5G5CQsc9w==", "dev": true, "dependencies": { "estraverse": "^5.1.0" @@ -3559,6 +3634,20 @@ "node": ">=0.10.0" } }, + "node_modules/form-data": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.1.tgz", + "integrity": "sha512-m21N3WOmEEURgk6B9GLOE4RuWOFf28Lhh9qGYeNlGq4VDXUlJy2th2slBNU8Gp8EzloYZOibZJ7t5ecIrFSjVA==", + "dev": true, + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.6", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 0.12" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -3814,9 +3903,9 @@ } }, "node_modules/globby": { - "version": "11.0.3", - "resolved": "https://registry.npmjs.org/globby/-/globby-11.0.3.tgz", - "integrity": "sha512-ffdmosjA807y7+lA1NM0jELARVmYul/715xiILEjo3hBLPTcirgQNnXECn5g3mtR8TOLCVbkfua1Hpen25/Xcg==", + "version": "11.0.4", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.0.4.tgz", + "integrity": "sha512-9O4MVG9ioZJ08ffbcyVYyLOJLk5JQ688pJ4eMGLpdWLHq/Wr1D9BlriLQyL0E+jbkuePVZXYFj47QM/v093wHg==", "dev": true, "dependencies": { "array-union": "^2.1.0", @@ -3915,16 +4004,16 @@ } }, "node_modules/got": { - "version": "11.8.2", - "resolved": "https://registry.npmjs.org/got/-/got-11.8.2.tgz", - "integrity": "sha512-D0QywKgIe30ODs+fm8wMZiAcZjypcCodPNuMz5H9Mny7RJ+IjJ10BdmGW7OM7fHXP+O7r6ZwapQ/YQmMSvB0UQ==", + "version": "11.8.3", + "resolved": "https://registry.npmjs.org/got/-/got-11.8.3.tgz", + "integrity": "sha512-7gtQ5KiPh1RtGS9/Jbv1ofDpBFuq42gyfEib+ejaRBJuj/3tQFeR5+gw57e4ipaU8c/rCjvX6fkQz2lyDlGAOg==", "dependencies": { "@sindresorhus/is": "^4.0.0", "@szmarczak/http-timer": "^4.0.5", "@types/cacheable-request": "^6.0.1", "@types/responselike": "^1.0.0", "cacheable-lookup": "^5.0.3", - "cacheable-request": "^7.0.1", + "cacheable-request": "^7.0.2", "decompress-response": "^6.0.0", "http2-wrapper": "^1.0.0-beta.5.2", "lowercase-keys": "^2.0.0", @@ -4900,9 +4989,9 @@ } }, "node_modules/is-glob": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.1.tgz", - "integrity": "sha512-5G0tKtBTFImOqDnLB2hG6Bp2qcKEFduo4tZu9MT/H6NQv/ghhy30o55ufafxJ/LdH79LLs2Kfrn85TLKyA7BUg==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", "dev": true, "dependencies": { "is-extglob": "^2.1.1" @@ -5141,9 +5230,9 @@ } }, "node_modules/keyv": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.0.3.tgz", - "integrity": "sha512-zdGa2TOpSZPq5mU6iowDARnMBZgtCqJ11dJROFi6tg6kTn4nuUdU09lFyLFSaHrWqpIJ+EBq4E8/Dc0Vx5vLdA==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.1.1.tgz", + "integrity": "sha512-tGv1yP6snQVDSM4X6yxrv2zzq/EvpW+oYiUz6aueW1u9CtS8RzUQYxxmFwgZlO2jSgCxQbchhxaqXXp2hnKGpQ==", "dependencies": { "json-buffer": "3.0.1" } @@ -6626,9 +6715,9 @@ } }, "node_modules/pathval": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz", - "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.0.tgz", + "integrity": "sha1-uULm1L3mUwBe9rcTYd74cn0GReA=", "dev": true, "engines": { "node": "*" @@ -7213,9 +7302,9 @@ } }, "node_modules/regexpp": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-3.1.0.tgz", - "integrity": "sha512-ZOIzd8yVsQQA7j8GCSlPGXwg5PfmA1mrq0JP4nGhh54LaKN3xdai/vHUDu74pKwV8OxseMS65u2NImosQcSD0Q==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-3.2.0.tgz", + "integrity": "sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg==", "dev": true, "engines": { "node": ">=8" @@ -8749,9 +8838,9 @@ } }, "node_modules/tslib": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz", + "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==", "dev": true }, "node_modules/tty-browserify": { @@ -9835,11 +9924,11 @@ }, "dependencies": { "@ably/msgpack-js": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/@ably/msgpack-js/-/msgpack-js-0.3.3.tgz", - "integrity": "sha512-H7oWg97VyA1JhWUP7YN7zwp9W1ozCqMSsqCcXNz4XLmZNdJKT2ntF/6DPgbviFgUpShjQlbPC/iamisTjwLHdQ==", + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@ably/msgpack-js/-/msgpack-js-0.4.0.tgz", + "integrity": "sha512-IPt/BoiQwCWubqoNik1aw/6M/DleMdrxJOUpSja6xmMRbT2p1TA8oqKWgfZabqzrq8emRNeSl/+4XABPNnW5pQ==", "requires": { - "bops": "~0.0.6" + "bops": "^1.0.1" } }, "@ably/vcdiff-decoder": { @@ -9973,9 +10062,9 @@ } }, "@sindresorhus/is": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.2.0.tgz", - "integrity": "sha512-VkE3KLBmJwcCaVARtQpfuKcKv8gcBmUubrfHGF84dXuuW6jgsRYxPtzcIhPyK9WAPpRt2/xY6zkD9MnRaJzSyw==" + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.4.0.tgz", + "integrity": "sha512-QppPM/8l3Mawvh4rn9CNEYIU9bxpXUCRMaX9yUpvBk1nMKusLKpfXGDEKExKaPhLzcn3lzil7pR6rnJ11HgeRQ==" }, "@szmarczak/http-timer": { "version": "4.0.6", @@ -9996,15 +10085,27 @@ "@types/responselike": "*" } }, + "@types/caseless": { + "version": "0.12.2", + "resolved": "https://registry.npmjs.org/@types/caseless/-/caseless-0.12.2.tgz", + "integrity": "sha512-6ckxMjBBD8URvjB6J3NcnuAn5Pkl7t3TizAg+xdlzzQGSPSmBcXf8KoIH0ua/i+tio+ZRUHEXp0HEmvaR4kt0w==", + "dev": true + }, + "@types/crypto-js": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@types/crypto-js/-/crypto-js-4.1.1.tgz", + "integrity": "sha512-BG7fQKZ689HIoc5h+6D2Dgq1fABRa0RbBWKBd9SP/MVRVXROflpm5fhwyATX5duFmbStzyzyycPB8qUYKDH3NA==", + "dev": true + }, "@types/http-cache-semantics": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.1.tgz", "integrity": "sha512-SZs7ekbP8CN0txVG2xVRH6EgKmEm31BOxA07vkFaETzZz1xh+cbt8BcI0slpymvwhx5dlFnQG2rTlPVQn+iRPQ==" }, "@types/json-schema": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.6.tgz", - "integrity": "sha512-3c+yGKvVP5Y9TYBEibGNR+kLtijnj7mYrXRg+WpFb2X9xm04g/DXYkfg4hmzJQosc9snFNUPkbYIhu+KAm6jJw==", + "version": "7.0.9", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.9.tgz", + "integrity": "sha512-qcUXuemtEu+E5wZSJHNxUXeCZhAfXKQ41D+duX+VYPde7xyEVZci+/oXKJL13tnRs9lR2pr4fod59GT6/X1/yQ==", "dev": true }, "@types/keyv": { @@ -10020,6 +10121,18 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-15.0.0.tgz", "integrity": "sha512-YN1d+ae2MCb4U0mMa+Zlb5lWTdpFShbAj5nmte6lel27waMMBfivrm0prC16p/Di3DyTrmerrYUT8/145HXxVw==" }, + "@types/request": { + "version": "2.48.8", + "resolved": "https://registry.npmjs.org/@types/request/-/request-2.48.8.tgz", + "integrity": "sha512-whjk1EDJPcAR2kYHRbFl/lKeeKYTi05A15K9bnLInCVroNDCtXce57xKdI0/rQaA3K+6q0eFyUBPmqfSndUZdQ==", + "dev": true, + "requires": { + "@types/caseless": "*", + "@types/node": "*", + "@types/tough-cookie": "*", + "form-data": "^2.5.0" + } + }, "@types/responselike": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.0.tgz", @@ -10028,6 +10141,21 @@ "@types/node": "*" } }, + "@types/tough-cookie": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.1.tgz", + "integrity": "sha512-Y0K95ThC3esLEYD6ZuqNek29lNX2EM1qxV8y2FTLUB0ff5wWrk7az+mLrnNFUnaXcgKye22+sFBRXOgpPILZNg==", + "dev": true + }, + "@types/ws": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.0.tgz", + "integrity": "sha512-mTClfhq5cuGyW4jthaFuig6Q8OVfB3IRyZfN/9SCyJtiM5H0SubwM89cHoT9UngO6HyUFic88HvT1zSNLNyxWA==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, "@types/yauzl": { "version": "2.9.1", "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.9.1.tgz", @@ -10497,6 +10625,12 @@ "resolved": "https://registry.npmjs.org/async-limiter/-/async-limiter-1.0.1.tgz", "integrity": "sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==" }, + "asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=", + "dev": true + }, "atob": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/atob/-/atob-2.1.2.tgz", @@ -10565,9 +10699,9 @@ } }, "base64-js": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.2.tgz", - "integrity": "sha1-Ak8Pcq+iW3X5wO5zzU9V7Bvtl4Q=" + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.0.2.tgz", + "integrity": "sha1-R0IRyV5s8qVH20YeT2d4tR0I+mU=" }, "big.js": { "version": "5.2.2", @@ -10592,6 +10726,12 @@ "file-uri-to-path": "1.0.0" } }, + "bluebird": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", + "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", + "dev": true + }, "bn.js": { "version": "5.1.3", "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.1.3.tgz", @@ -10640,11 +10780,11 @@ } }, "bops": { - "version": "0.0.7", - "resolved": "https://registry.npmjs.org/bops/-/bops-0.0.7.tgz", - "integrity": "sha1-tKClqDmkBkVK8P4FqLkaenZqVOI=", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/bops/-/bops-1.0.1.tgz", + "integrity": "sha512-qCMBuZKP36tELrrgXpAfM+gHzqa0nLsWZ+L37ncsb8txYlnAoxOPpVp+g7fK0sGkMXfA0wl8uQkESqw3v4HNag==", "requires": { - "base64-js": "0.0.2", + "base64-js": "1.0.2", "to-utf8": "0.0.1" } }, @@ -10863,12 +11003,6 @@ "y18n": "^4.0.0" }, "dependencies": { - "bluebird": { - "version": "3.7.2", - "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", - "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", - "dev": true - }, "glob": { "version": "7.1.6", "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz", @@ -11038,6 +11172,14 @@ "dev": true, "requires": { "tslib": "^1.9.0" + }, + "dependencies": { + "tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "dev": true + } } }, "cipher-base": { @@ -11181,6 +11323,15 @@ "integrity": "sha1-FopHAXVran9RoSzgyXv6KMCE7WM=", "dev": true }, + "combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "requires": { + "delayed-stream": "~1.0.0" + } + }, "command-line-usage": { "version": "6.1.1", "resolved": "https://registry.npmjs.org/command-line-usage/-/command-line-usage-6.1.1.tgz", @@ -11603,9 +11754,9 @@ "dev": true }, "debug": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.0.tgz", - "integrity": "sha512-jjO6JD2rKfiZQnBoRzhRTbXjHLGLfH+UtGkWLc/UXAh/rzZMyjbgn0NcfFpqT8nd1kTtFnDiJcrIFkq4UKeJVg==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.2.tgz", + "integrity": "sha512-mOp8wKcvj7XxC78zLgw/ZA+6TSgkoE2C/ienthhRD298T7UNwAg9diBpLRxC0mOezLl4B0xV7M0cCO6P/O0Xhw==", "dev": true, "requires": { "ms": "2.1.2" @@ -11714,6 +11865,12 @@ } } }, + "delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=", + "dev": true + }, "depd": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", @@ -12054,9 +12211,9 @@ "dev": true }, "esquery": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.3.1.tgz", - "integrity": "sha512-olpvt9QG0vniUBZspVRN6lwB7hOZoTRtT+jzR+tS4ffYx2mzbw+z0XCOk44aaLYKApNX5nMm+E+P6o25ip/DHQ==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.4.0.tgz", + "integrity": "sha512-cCDispWt5vHHtwMY2YrAQ4ibFkAL8RbH5YGBnZBc90MolvvfkkQcJro/aZiAQUlQ3qgrYS6D6v8Gc5G5CQsc9w==", "dev": true, "requires": { "estraverse": "^5.1.0" @@ -12727,6 +12884,17 @@ "for-in": "^1.0.1" } }, + "form-data": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.1.tgz", + "integrity": "sha512-m21N3WOmEEURgk6B9GLOE4RuWOFf28Lhh9qGYeNlGq4VDXUlJy2th2slBNU8Gp8EzloYZOibZJ7t5ecIrFSjVA==", + "dev": true, + "requires": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.6", + "mime-types": "^2.1.12" + } + }, "forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -12925,9 +13093,9 @@ } }, "globby": { - "version": "11.0.3", - "resolved": "https://registry.npmjs.org/globby/-/globby-11.0.3.tgz", - "integrity": "sha512-ffdmosjA807y7+lA1NM0jELARVmYul/715xiILEjo3hBLPTcirgQNnXECn5g3mtR8TOLCVbkfua1Hpen25/Xcg==", + "version": "11.0.4", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.0.4.tgz", + "integrity": "sha512-9O4MVG9ioZJ08ffbcyVYyLOJLk5JQ688pJ4eMGLpdWLHq/Wr1D9BlriLQyL0E+jbkuePVZXYFj47QM/v093wHg==", "dev": true, "requires": { "array-union": "^2.1.0", @@ -13000,16 +13168,16 @@ } }, "got": { - "version": "11.8.2", - "resolved": "https://registry.npmjs.org/got/-/got-11.8.2.tgz", - "integrity": "sha512-D0QywKgIe30ODs+fm8wMZiAcZjypcCodPNuMz5H9Mny7RJ+IjJ10BdmGW7OM7fHXP+O7r6ZwapQ/YQmMSvB0UQ==", + "version": "11.8.3", + "resolved": "https://registry.npmjs.org/got/-/got-11.8.3.tgz", + "integrity": "sha512-7gtQ5KiPh1RtGS9/Jbv1ofDpBFuq42gyfEib+ejaRBJuj/3tQFeR5+gw57e4ipaU8c/rCjvX6fkQz2lyDlGAOg==", "requires": { "@sindresorhus/is": "^4.0.0", "@szmarczak/http-timer": "^4.0.5", "@types/cacheable-request": "^6.0.1", "@types/responselike": "^1.0.0", "cacheable-lookup": "^5.0.3", - "cacheable-request": "^7.0.1", + "cacheable-request": "^7.0.2", "decompress-response": "^6.0.0", "http2-wrapper": "^1.0.0-beta.5.2", "lowercase-keys": "^2.0.0", @@ -13739,9 +13907,9 @@ "dev": true }, "is-glob": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.1.tgz", - "integrity": "sha512-5G0tKtBTFImOqDnLB2hG6Bp2qcKEFduo4tZu9MT/H6NQv/ghhy30o55ufafxJ/LdH79LLs2Kfrn85TLKyA7BUg==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", "dev": true, "requires": { "is-extglob": "^2.1.1" @@ -13931,9 +14099,9 @@ } }, "keyv": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.0.3.tgz", - "integrity": "sha512-zdGa2TOpSZPq5mU6iowDARnMBZgtCqJ11dJROFi6tg6kTn4nuUdU09lFyLFSaHrWqpIJ+EBq4E8/Dc0Vx5vLdA==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.1.1.tgz", + "integrity": "sha512-tGv1yP6snQVDSM4X6yxrv2zzq/EvpW+oYiUz6aueW1u9CtS8RzUQYxxmFwgZlO2jSgCxQbchhxaqXXp2hnKGpQ==", "requires": { "json-buffer": "3.0.1" } @@ -15076,9 +15244,9 @@ "dev": true }, "pathval": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz", - "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.0.tgz", + "integrity": "sha1-uULm1L3mUwBe9rcTYd74cn0GReA=", "dev": true }, "pbkdf2": { @@ -15529,9 +15697,9 @@ } }, "regexpp": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-3.1.0.tgz", - "integrity": "sha512-ZOIzd8yVsQQA7j8GCSlPGXwg5PfmA1mrq0JP4nGhh54LaKN3xdai/vHUDu74pKwV8OxseMS65u2NImosQcSD0Q==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-3.2.0.tgz", + "integrity": "sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg==", "dev": true }, "remove-trailing-separator": { @@ -16745,9 +16913,9 @@ } }, "tslib": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz", + "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==", "dev": true }, "tty-browserify": { diff --git a/package.json b/package.json index 782f21e1be..d38c5b886f 100644 --- a/package.json +++ b/package.json @@ -25,13 +25,16 @@ "resources/**" ], "dependencies": { - "@ably/msgpack-js": "^0.3.3", + "@ably/msgpack-js": "^0.4.0", "got": "^11.8.2", "ws": "^5.1" }, "devDependencies": { "@ably/vcdiff-decoder": "1.0.4", + "@types/crypto-js": "^4.0.1", "@types/node": "^15.0.0", + "@types/request": "^2.48.7", + "@types/ws": "^8.2.0", "async": "ably-forks/async#requirejs", "chai": "^4.2.0", "copy-webpack-plugin": "^6.4.1", @@ -59,6 +62,7 @@ "shelljs": "~0.8", "source-map-explorer": "^2.5.2", "ts-loader": "^8.2.0", + "tslib": "^2.3.1", "typescript": "^4.2.4", "webpack": "^4.44.2", "webpack-cli": "^4.2.0" @@ -81,7 +85,9 @@ "test:webserver": "grunt test:webserver", "test:playwright": "node spec/support/runPlaywrightTests.js", "concat": "grunt concat", - "build": "grunt build", + "build": "grunt build:all", + "build:node": "grunt build:node", + "build:browser": "grunt build:browser", "requirejs": "grunt requirejs", "lint": "eslint nodejs/**/*.js common/**/*.js browser/lib/**/*.js", "lint:fix": "eslint --fix nodejs/**/*.js common/**/*.js browser/lib/**/*.js", diff --git a/spec/rest/init.test.js b/spec/rest/init.test.js index b01024e6dd..3ab101b3b7 100644 --- a/spec/rest/init.test.js +++ b/spec/rest/init.test.js @@ -85,10 +85,12 @@ define(['ably', 'shared_helper', 'chai'], function (Ably, helper, chai) { expect(rest.options.promises, 'Check promises default to true with promise constructor').to.be.ok; if (!isBrowser && typeof require == 'function') { - rest = new require('../../promises').Rest(keyStr); + var AblyPromises = require('../../promises'); + rest = new AblyPromises.Rest(keyStr); expect(rest.options.promises, 'Check promises default to true with promise require target').to.be.ok; - rest = new require('../../callbacks').Rest(keyStr); + var AblyCallbacks = require('../../callbacks'); + rest = new AblyCallbacks.Rest(keyStr); expect(!rest.options.promises, 'Check promises default to false with callback require target').to.be.ok; } }); diff --git a/tsconfig.json b/tsconfig.json index e5fbe8576a..9535613b65 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,10 +2,16 @@ "compilerOptions": { "target": "es3", "module": "commonjs", - "lib": ["ES5"], + "lib": ["ES5", "DOM", "webworker"], "strict": true, "esModuleInterop": true, "skipLibCheck": true, - "forceConsistentCasingInFileNames": true + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "importHelpers": true, + "paths": { + "platform": ["./nodejs/platform", "./browser/fragments/platform-browser", "./browser/fragments/platform-reactnative"], + "platform-defaults": ["./nodejs/lib/util/defaults", "./browser/lib/util/defaults"] + } } } diff --git a/webpack.config.js b/webpack.config.js index 44e1844d01..ca894a2c7c 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -159,7 +159,7 @@ const reactNativeConfig = { filename: 'ably-reactnative.js', }, resolve: { - extensions: ['.js'], + extensions: ['.js', '.ts'], alias: { platform: path.resolve(browserPath, 'fragments', 'platform-reactnative'), 'platform-http': path.resolve(browserPath, 'lib', 'util', 'http'), @@ -282,15 +282,15 @@ const commonJsNoEncryptionConfig = { }, }; -module.exports = [ - nodeConfig, - browserConfig, - browserMinConfig, - webworkerConfig, - nativeScriptConfig, - reactNativeConfig, - noEncryptionConfig, - noEncryptionMinConfig, - commonJsConfig, - commonJsNoEncryptionConfig, -]; +module.exports = { + node: nodeConfig, + browser: browserConfig, + browserMin: browserMinConfig, + webworker: webworkerConfig, + nativeScript: nativeScriptConfig, + reactNative: reactNativeConfig, + noEncryption: noEncryptionConfig, + noEncryptionMin: noEncryptionMinConfig, + commonJs: commonJsConfig, + commonJsNoEncryption: commonJsNoEncryptionConfig, +};