From 21d387ccdcdc1c58888515ad899892488d2aa8f0 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Wed, 23 Aug 2023 14:21:56 -0300 Subject: [PATCH] Make transports tree-shakable We expose WebSocketTransport, XHRPolling, and XHRStreaming modules. Resolves #1394. --- scripts/moduleReport.js | 10 +- src/common/lib/client/baserealtime.ts | 21 +++ src/common/lib/client/defaultrealtime.ts | 9 +- src/common/lib/client/modulesmap.ts | 4 + src/common/lib/transport/connectionmanager.ts | 28 ++-- src/common/platform.ts | 3 +- src/platform/nodejs/lib/transport/index.ts | 6 +- src/platform/web/lib/transport/index.ts | 26 +++- src/platform/web/modules.ts | 5 +- src/platform/web/modules/transports.ts | 3 + test/browser/modules.test.js | 147 +++++++++++++----- test/browser/simple.test.js | 2 +- test/common/modules/shared_helper.js | 4 +- 13 files changed, 205 insertions(+), 63 deletions(-) create mode 100644 src/platform/web/modules/transports.ts diff --git a/scripts/moduleReport.js b/scripts/moduleReport.js index 84d6bac99b..ee5cadf0a8 100644 --- a/scripts/moduleReport.js +++ b/scripts/moduleReport.js @@ -1,7 +1,15 @@ const esbuild = require('esbuild'); // List of all modules accepted in ModulesMap -const moduleNames = ['Rest', 'Crypto', 'MsgPack', 'RealtimePresence']; +const moduleNames = [ + 'Rest', + 'Crypto', + 'MsgPack', + 'RealtimePresence', + 'XHRPolling', + 'XHRStreaming', + 'WebSocketTransport', +]; // List of all free-standing functions exported by the library along with the // ModulesMap entries that we expect them to transitively import diff --git a/src/common/lib/client/baserealtime.ts b/src/common/lib/client/baserealtime.ts index ff51a2a773..0fe9558313 100644 --- a/src/common/lib/client/baserealtime.ts +++ b/src/common/lib/client/baserealtime.ts @@ -11,24 +11,45 @@ import ClientOptions from '../../types/ClientOptions'; import * as API from '../../../../ably'; import { ModulesMap } from './modulesmap'; import RealtimePresence from './realtimepresence'; +import { TransportNames } from 'common/constants/TransportName'; +import { TransportImplementations } from 'common/platform'; /** `BaseRealtime` is an export of the tree-shakable version of the SDK, and acts as the base class for the `DefaultRealtime` class exported by the non tree-shakable version. */ class BaseRealtime extends BaseClient { readonly _RealtimePresence: typeof RealtimePresence | null; + // Extra transport implementations available to this client, in addition to those in Platform.Transports.bundledImplementations + readonly _additionalTransportImplementations: TransportImplementations; _channels: any; connection: Connection; constructor(options: ClientOptions, modules: ModulesMap) { super(options, modules); Logger.logAction(Logger.LOG_MINOR, 'Realtime()', ''); + this._additionalTransportImplementations = BaseRealtime.transportImplementationsFromModules(modules); this._RealtimePresence = modules.RealtimePresence ?? null; this.connection = new Connection(this, this.options); this._channels = new Channels(this); if (options.autoConnect !== false) this.connect(); } + private static transportImplementationsFromModules(modules: ModulesMap) { + const transports: TransportImplementations = {}; + + if (modules.WebSocketTransport) { + transports[TransportNames.WebSocket] = modules.WebSocketTransport; + } + if (modules.XHRStreaming) { + transports[TransportNames.XhrStreaming] = modules.XHRStreaming; + } + if (modules.XHRPolling) { + transports[TransportNames.XhrPolling] = modules.XHRPolling; + } + + return transports; + } + get channels() { return this._channels; } diff --git a/src/common/lib/client/defaultrealtime.ts b/src/common/lib/client/defaultrealtime.ts index 242e612288..2204ab71ac 100644 --- a/src/common/lib/client/defaultrealtime.ts +++ b/src/common/lib/client/defaultrealtime.ts @@ -9,6 +9,7 @@ import { DefaultMessage } from '../types/defaultmessage'; import { MsgPack } from 'common/types/msgpack'; import RealtimePresence from './realtimepresence'; import { DefaultPresenceMessage } from '../types/defaultpresencemessage'; +import initialiseWebSocketTransport from '../transport/websockettransport'; /** `DefaultRealtime` is the class that the non tree-shakable version of the SDK exports as `Realtime`. It ensures that this version of the SDK includes all of the functionality which is optionally available in the tree-shakable version. @@ -20,7 +21,13 @@ export class DefaultRealtime extends BaseRealtime { throw new Error('Expected DefaultRealtime._MsgPack to have been set'); } - super(options, { ...allCommonModules, Crypto: DefaultRealtime.Crypto ?? undefined, MsgPack, RealtimePresence }); + super(options, { + ...allCommonModules, + Crypto: DefaultRealtime.Crypto ?? undefined, + MsgPack, + RealtimePresence, + WebSocketTransport: initialiseWebSocketTransport, + }); } static Utils = Utils; diff --git a/src/common/lib/client/modulesmap.ts b/src/common/lib/client/modulesmap.ts index a4de0a0d51..ada7de44ee 100644 --- a/src/common/lib/client/modulesmap.ts +++ b/src/common/lib/client/modulesmap.ts @@ -2,12 +2,16 @@ import { Rest } from './rest'; import { IUntypedCryptoStatic } from '../../types/ICryptoStatic'; import { MsgPack } from 'common/types/msgpack'; import RealtimePresence from './realtimepresence'; +import { TransportInitialiser } from '../transport/connectionmanager'; export interface ModulesMap { Rest?: typeof Rest; Crypto?: IUntypedCryptoStatic; MsgPack?: MsgPack; RealtimePresence?: typeof RealtimePresence; + WebSocketTransport?: TransportInitialiser; + XHRPolling?: TransportInitialiser; + XHRStreaming?: TransportInitialiser; } export const allCommonModules: ModulesMap = { Rest }; diff --git a/src/common/lib/transport/connectionmanager.ts b/src/common/lib/transport/connectionmanager.ts index fb8311824b..335e2a01d4 100644 --- a/src/common/lib/transport/connectionmanager.ts +++ b/src/common/lib/transport/connectionmanager.ts @@ -2,7 +2,7 @@ import ProtocolMessage from 'common/lib/types/protocolmessage'; import * as Utils from 'common/lib/util/utils'; import Protocol, { PendingMessage } from './protocol'; import Defaults, { getAgentString } from 'common/lib/util/defaults'; -import Platform from 'common/platform'; +import Platform, { TransportImplementations } from 'common/platform'; import EventEmitter from '../util/eventemitter'; import MessageQueue from './messagequeue'; import Logger from '../util/logger'; @@ -12,14 +12,13 @@ import ErrorInfo, { IPartialErrorInfo, PartialErrorInfo } from 'common/lib/types import Auth from 'common/lib/client/auth'; import Message from 'common/lib/types/message'; import Multicaster, { MulticasterInstance } from 'common/lib/util/multicaster'; -import WebSocketTransport from './websockettransport'; import Transport, { TransportCtor } from './transport'; import * as API from '../../../../ably'; import { ErrCallback } from 'common/types/utils'; import HttpStatusCodes from 'common/constants/HttpStatusCodes'; import BaseRealtime from '../client/baserealtime'; import { NormalisedClientOptions } from 'common/types/ClientOptions'; -import TransportName from 'common/constants/TransportName'; +import TransportName, { TransportNames } from 'common/constants/TransportName'; let globalObject = typeof global !== 'undefined' ? global : typeof window !== 'undefined' ? window : self; @@ -227,8 +226,8 @@ class ConnectionManager extends EventEmitter { constructor(realtime: BaseRealtime, options: NormalisedClientOptions) { super(); - this.initTransports(); this.realtime = realtime; + this.initTransports(); this.options = options; const timeouts = options.timeouts; /* connectingTimeout: leave preferenceConnectTimeout (~6s) to try the @@ -401,22 +400,29 @@ class ConnectionManager extends EventEmitter { *********************/ // Used by tests - static get supportedTransports() { + static supportedTransports(additionalImplementations: TransportImplementations) { const storage: TransportStorage = { supportedTransports: {} }; - this.initTransports(storage); + this.initTransports(additionalImplementations, storage); return storage.supportedTransports; } - private static initTransports(storage: TransportStorage) { - WebSocketTransport(storage); + private static initTransports(additionalImplementations: TransportImplementations, storage: TransportStorage) { + const implementations = { ...Platform.Transports.bundledImplementations, ...additionalImplementations }; + + const initialiseWebSocketTransport = implementations[TransportNames.WebSocket]; + if (initialiseWebSocketTransport) { + initialiseWebSocketTransport(storage); + } Utils.arrForEach(Platform.Transports.order, function (transportName) { - const initFn = Platform.Transports.implementations[transportName]!; - initFn(ConnectionManager); + const initFn = implementations[transportName]; + if (initFn) { + initFn(storage); + } }); } initTransports() { - ConnectionManager.initTransports(this); + ConnectionManager.initTransports(this.realtime._additionalTransportImplementations, this); } createTransportParams(host: string | null, mode: string): TransportParams { diff --git a/src/common/platform.ts b/src/common/platform.ts index 3e103063dc..609b232687 100644 --- a/src/common/platform.ts +++ b/src/common/platform.ts @@ -34,7 +34,8 @@ export default class Platform { static Http: typeof IHttp; static Transports: { order: TransportName[]; - implementations: TransportImplementations; + // Transport implementations that always come with this platform + bundledImplementations: TransportImplementations; }; static Defaults: IDefaults; static WebStorage: IWebStorage | null; diff --git a/src/platform/nodejs/lib/transport/index.ts b/src/platform/nodejs/lib/transport/index.ts index 084b0cc955..80ab1d0115 100644 --- a/src/platform/nodejs/lib/transport/index.ts +++ b/src/platform/nodejs/lib/transport/index.ts @@ -1,7 +1,11 @@ import { TransportNames } from 'common/constants/TransportName'; import initialiseNodeCometTransport from './nodecomettransport'; +import { default as initialiseWebSocketTransport } from '../../../../common/lib/transport/websockettransport'; export default { order: [TransportNames.Comet], - implementations: { [TransportNames.Comet]: initialiseNodeCometTransport }, + bundledImplementations: { + [TransportNames.WebSocket]: initialiseWebSocketTransport, + [TransportNames.Comet]: initialiseNodeCometTransport, + }, }; diff --git a/src/platform/web/lib/transport/index.ts b/src/platform/web/lib/transport/index.ts index 8fb1447186..ced6dc4eef 100644 --- a/src/platform/web/lib/transport/index.ts +++ b/src/platform/web/lib/transport/index.ts @@ -1,11 +1,25 @@ -import { TransportNames } from 'common/constants/TransportName'; +import TransportName from 'common/constants/TransportName'; +import Platform from 'common/platform'; import initialiseXHRPollingTransport from './xhrpollingtransport'; import initialiseXHRStreamingTransport from './xhrstreamingtransport'; +import { default as initialiseWebSocketTransport } from '../../../../common/lib/transport/websockettransport'; -export default { - order: [TransportNames.XhrPolling, TransportNames.XhrStreaming], - implementations: { - [TransportNames.XhrPolling]: initialiseXHRPollingTransport, - [TransportNames.XhrStreaming]: initialiseXHRStreamingTransport, +// For reasons that I don’t understand, if we use [TransportNames.XhrStreaming] and [TransportNames.XhrPolling] for the keys in defaultTransports’s, then defaultTransports does not get tree-shaken. Hence using literals instead. They’re still correctly type-checked. + +const order: TransportName[] = ['xhr_polling', 'xhr_streaming']; + +const defaultTransports: (typeof Platform)['Transports'] = { + order, + bundledImplementations: { + web_socket: initialiseWebSocketTransport, + xhr_polling: initialiseXHRPollingTransport, + xhr_streaming: initialiseXHRStreamingTransport, }, }; + +export default defaultTransports; + +export const ModulesTransports: (typeof Platform)['Transports'] = { + order, + bundledImplementations: {}, +}; diff --git a/src/platform/web/modules.ts b/src/platform/web/modules.ts index 0488f6bdf3..a3bf018afe 100644 --- a/src/platform/web/modules.ts +++ b/src/platform/web/modules.ts @@ -10,7 +10,7 @@ import BufferUtils from './lib/util/bufferutils'; import Http from './lib/util/http'; import Config from './config'; // @ts-ignore -import Transports from './lib/transport'; +import { ModulesTransports } from './lib/transport'; import Logger from '../../common/lib/util/logger'; import { getDefaults } from '../../common/lib/util/defaults'; import WebStorage from './lib/util/webstorage'; @@ -19,7 +19,7 @@ import PlatformDefaults from './lib/util/defaults'; Platform.BufferUtils = BufferUtils; Platform.Http = Http; Platform.Config = Config; -Platform.Transports = Transports; +Platform.Transports = ModulesTransports; Platform.WebStorage = WebStorage; Logger.initLogHandlers(); @@ -44,5 +44,6 @@ export * from './modules/message'; export * from './modules/presencemessage'; export * from './modules/msgpack'; export * from './modules/realtimepresence'; +export * from './modules/transports'; export { Rest } from '../../common/lib/client/rest'; export { BaseRest, BaseRealtime, ErrorInfo }; diff --git a/src/platform/web/modules/transports.ts b/src/platform/web/modules/transports.ts new file mode 100644 index 0000000000..37c0a09cf8 --- /dev/null +++ b/src/platform/web/modules/transports.ts @@ -0,0 +1,3 @@ +export { default as XHRPolling } from '../lib/transport/xhrpollingtransport'; +export { default as XHRStreaming } from '../lib/transport/xhrstreamingtransport'; +export { default as WebSocketTransport } from '../../../common/lib/transport/websockettransport'; diff --git a/test/browser/modules.test.js b/test/browser/modules.test.js index dc640e170b..0b61066e4e 100644 --- a/test/browser/modules.test.js +++ b/test/browser/modules.test.js @@ -14,6 +14,9 @@ import { decodePresenceMessage, decodePresenceMessages, constructPresenceMessage, + XHRPolling, + XHRStreaming, + WebSocketTransport, } from '../../build/modules/index.js'; describe('browser/modules', function () { @@ -42,13 +45,17 @@ describe('browser/modules', function () { }); describe('without any modules', () => { - for (const clientClass of [BaseRest, BaseRealtime]) { - describe(clientClass.name, () => { - it('can be constructed', async () => { - expect(() => new clientClass(ablyClientOptions(), {})).not.to.throw(); - }); + describe('BaseRest', () => { + it('can be constructed', () => { + expect(() => new BaseRest(ablyClientOptions(), {})).not.to.throw(); }); - } + }); + + describe('BaseRealtime', () => { + it('throws an error due to absence of a transport module', () => { + expect(() => new BaseRealtime(ablyClientOptions(), {})).to.throw('no requested transports available'); + }); + }); }); describe('Rest', () => { @@ -62,7 +69,7 @@ describe('browser/modules', function () { describe('BaseRealtime with Rest', () => { it('offers REST functionality', async () => { - const client = new BaseRealtime(ablyClientOptions(), { Rest }); + const client = new BaseRealtime(ablyClientOptions(), { WebSocketTransport, Rest }); const time = await client.time(); expect(time).to.be.a('number'); }); @@ -70,7 +77,7 @@ describe('browser/modules', function () { describe('BaseRealtime without Rest', () => { it('throws an error when attempting to use REST functionality', async () => { - const client = new BaseRealtime(ablyClientOptions(), {}); + const client = new BaseRealtime(ablyClientOptions(), { WebSocketTransport }); expect(() => client.time()).to.throw('Rest module not provided'); }); }); @@ -206,48 +213,68 @@ describe('browser/modules', function () { describe('Crypto', () => { describe('without Crypto', () => { - for (const clientClass of [BaseRest, BaseRealtime]) { - describe(clientClass.name, () => { + async function testThrowsAnErrorWhenGivenChannelOptionsWithACipher(clientClassConfig) { + const client = new clientClassConfig.clientClass( + ablyClientOptions(), + clientClassConfig.additionalModules ?? {} + ); + const key = await generateRandomKey(); + expect(() => client.channels.get('channel', { cipher: { key } })).to.throw('Crypto module not provided'); + } + + for (const clientClassConfig of [ + { clientClass: BaseRest }, + { clientClass: BaseRealtime, additionalModules: { WebSocketTransport } }, + ]) { + describe(clientClassConfig.clientClass.name, () => { it('throws an error when given channel options with a cipher', async () => { - const client = new clientClass(ablyClientOptions(), {}); - const key = await generateRandomKey(); - expect(() => client.channels.get('channel', { cipher: { key } })).to.throw('Crypto module not provided'); + await testThrowsAnErrorWhenGivenChannelOptionsWithACipher(clientClassConfig); }); }); } }); describe('with Crypto', () => { - for (const clientClass of [BaseRest, BaseRealtime]) { - describe(clientClass.name, () => { - it('is able to publish encrypted messages', async () => { - const clientOptions = ablyClientOptions(); + async function testIsAbleToPublishEncryptedMessages(clientClassConfig) { + const clientOptions = ablyClientOptions(); - const key = await generateRandomKey(); + const key = await generateRandomKey(); - // Publish the message on a channel configured to use encryption, and receive it on one not configured to use encryption + // Publish the message on a channel configured to use encryption, and receive it on one not configured to use encryption - const rxClient = new BaseRealtime(clientOptions, {}); - const rxChannel = rxClient.channels.get('channel'); - await rxChannel.attach(); + const rxClient = new BaseRealtime(clientOptions, { WebSocketTransport }); + const rxChannel = rxClient.channels.get('channel'); + await rxChannel.attach(); - const rxMessagePromise = new Promise((resolve, _) => rxChannel.subscribe((message) => resolve(message))); + const rxMessagePromise = new Promise((resolve, _) => rxChannel.subscribe((message) => resolve(message))); - const encryptionChannelOptions = { cipher: { key } }; + const encryptionChannelOptions = { cipher: { key } }; - const txMessage = { name: 'message', data: 'data' }; - const txClient = new clientClass(clientOptions, { Crypto }); - const txChannel = txClient.channels.get('channel', encryptionChannelOptions); - await txChannel.publish(txMessage); + const txMessage = { name: 'message', data: 'data' }; + const txClient = new clientClassConfig.clientClass(clientOptions, { + ...(clientClassConfig.additionalModules ?? {}), + Crypto, + }); + const txChannel = txClient.channels.get('channel', encryptionChannelOptions); + await txChannel.publish(txMessage); + + const rxMessage = await rxMessagePromise; - const rxMessage = await rxMessagePromise; + // Verify that the message was published with encryption + expect(rxMessage.encoding).to.equal('utf-8/cipher+aes-256-cbc'); - // Verify that the message was published with encryption - expect(rxMessage.encoding).to.equal('utf-8/cipher+aes-256-cbc'); + // Verify that the message was correctly encrypted + const rxMessageDecrypted = await decodeEncryptedMessage(rxMessage, encryptionChannelOptions); + testMessageEquality(rxMessageDecrypted, txMessage); + } - // Verify that the message was correctly encrypted - const rxMessageDecrypted = await decodeEncryptedMessage(rxMessage, encryptionChannelOptions); - testMessageEquality(rxMessageDecrypted, txMessage); + for (const clientClassConfig of [ + { clientClass: BaseRest }, + { clientClass: BaseRealtime, additionalModules: { WebSocketTransport } }, + ]) { + describe(clientClassConfig.clientClass.name, () => { + it('is able to publish encrypted messages', async () => { + await testIsAbleToPublishEncryptedMessages(clientClassConfig); }); }); } @@ -298,7 +325,9 @@ describe('browser/modules', function () { describe('BaseRealtime', () => { it('uses JSON', async () => { - const client = new BaseRealtime(ablyClientOptions({ useBinaryProtocol: true, autoConnect: false }), {}); + const client = new BaseRealtime(ablyClientOptions({ useBinaryProtocol: true, autoConnect: false }), { + WebSocketTransport, + }); await testRealtimeUsesFormat(client, 'json'); }); }); @@ -307,7 +336,9 @@ describe('browser/modules', function () { describe('with MsgPack', () => { describe('BaseRest', () => { it('uses MessagePack', async () => { - const client = new BaseRest(ablyClientOptions({ useBinaryProtocol: true }), { MsgPack }); + const client = new BaseRest(ablyClientOptions({ useBinaryProtocol: true }), { + MsgPack, + }); await testRestUsesContentType(client, 'application/x-msgpack'); }); }); @@ -315,6 +346,7 @@ describe('browser/modules', function () { describe('BaseRealtime', () => { it('uses MessagePack', async () => { const client = new BaseRealtime(ablyClientOptions({ useBinaryProtocol: true, autoConnect: false }), { + WebSocketTransport, MsgPack, }); await testRealtimeUsesFormat(client, 'msgpack'); @@ -327,7 +359,7 @@ describe('browser/modules', function () { describe('RealtimePresence', () => { describe('BaseRealtime without RealtimePresence', () => { it('throws an error when attempting to access the `presence` property', () => { - const client = new BaseRealtime(ablyClientOptions(), {}); + const client = new BaseRealtime(ablyClientOptions(), { WebSocketTransport }); const channel = client.channels.get('channel'); expect(() => channel.presence).to.throw('RealtimePresence module not provided'); @@ -336,9 +368,12 @@ describe('browser/modules', function () { describe('BaseRealtime with RealtimePresence', () => { it('offers realtime presence functionality', async () => { - const rxChannel = new BaseRealtime(ablyClientOptions(), { RealtimePresence }).channels.get('channel'); + const rxChannel = new BaseRealtime(ablyClientOptions(), { WebSocketTransport, RealtimePresence }).channels.get( + 'channel' + ); const txClientId = randomString(); const txChannel = new BaseRealtime(ablyClientOptions({ clientId: txClientId }), { + WebSocketTransport, RealtimePresence, }).channels.get('channel'); @@ -397,4 +432,40 @@ describe('browser/modules', function () { }); }); }); + + describe('Transports', () => { + describe('BaseRealtime', () => { + for (const scenario of [ + { moduleMapKey: 'WebSocketTransport', transportModule: WebSocketTransport, transportName: 'web_socket' }, + { moduleMapKey: 'XHRPolling', transportModule: XHRPolling, transportName: 'xhr_polling' }, + { moduleMapKey: 'XHRStreaming', transportModule: XHRStreaming, transportName: 'xhr_streaming' }, + ]) { + describe(`with the ${scenario.moduleMapKey} module`, () => { + it(`is able to use the ${scenario.transportName} transport`, async () => { + const realtime = new BaseRealtime( + ablyClientOptions({ autoConnect: false, transports: [scenario.transportName] }), + { + [scenario.moduleMapKey]: scenario.transportModule, + } + ); + + let firstTransportCandidate; + const connectionManager = realtime.connection.connectionManager; + const originalTryATransport = connectionManager.tryATransport; + realtime.connection.connectionManager.tryATransport = (transportParams, candidate, callback) => { + if (!firstTransportCandidate) { + firstTransportCandidate = candidate; + } + originalTryATransport.bind(connectionManager)(transportParams, candidate, callback); + }; + + realtime.connect(); + + await realtime.connection.once('connected'); + expect(firstTransportCandidate).to.equal(scenario.transportName); + }); + }); + } + }); + }); }); diff --git a/test/browser/simple.test.js b/test/browser/simple.test.js index bfc22014d7..23e2667e17 100644 --- a/test/browser/simple.test.js +++ b/test/browser/simple.test.js @@ -17,7 +17,7 @@ define(['ably', 'shared_helper', 'chai'], function (Ably, helper, chai) { }); function isTransportAvailable(transport) { - return transport in Ably.Realtime.ConnectionManager.supportedTransports; + return transport in Ably.Realtime.ConnectionManager.supportedTransports(Ably.Realtime._transports); } function realtimeConnection(transports) { diff --git a/test/common/modules/shared_helper.js b/test/common/modules/shared_helper.js index 7b0edda2d4..7ad75420b5 100644 --- a/test/common/modules/shared_helper.js +++ b/test/common/modules/shared_helper.js @@ -14,7 +14,9 @@ define([ var platform = clientModule.Ably.Realtime.Platform; var BufferUtils = platform.BufferUtils; var expect = chai.expect; - var availableTransports = utils.keysArray(clientModule.Ably.Realtime.ConnectionManager.supportedTransports), + var availableTransports = utils.keysArray( + clientModule.Ably.Realtime.ConnectionManager.supportedTransports(clientModule.Ably.Realtime._transports) + ), bestTransport = availableTransports[0], /* IANA reserved; requests to it will hang forever */ unroutableHost = '10.255.255.1',