diff --git a/modules.d.ts b/modules.d.ts index 0ea246a164..e56c0b6563 100644 --- a/modules.d.ts +++ b/modules.d.ts @@ -162,6 +162,20 @@ export declare const FetchRequest: unknown; */ export declare const MessageInteractions: unknown; +/** + * Provides a {@link BaseRealtime} instance with the ability to publish messages. + * + * To create a client that includes this module, include it in the `ModulesMap` that you pass to the {@link BaseRealtime.constructor}: + * + * ```javascript + * import { BaseRealtime, WebSocketTransport, FetchRequest, RealtimePublishing } from 'ably/modules'; + * const realtime = new BaseRealtime(options, { WebSocketTransport, FetchRequest, RealtimePublishing }); + * ``` + * + * If this module is not provided, then calling {@link Types.RealtimeChannel.publish} on a channel will cause a runtime error. + */ +export declare const RealtimePublishing: unknown; + /** * Pass a `ModulesMap` to { @link BaseRest.constructor | the constructor of BaseRest } or {@link BaseRealtime.constructor | that of BaseRealtime} to specify which functionality should be made available to that client. */ @@ -215,6 +229,11 @@ export interface ModulesMap { * See {@link MessageInteractions | documentation for the `MessageInteractions` module}. */ MessageInteractions?: typeof MessageInteractions; + + /** + * See {@link RealtimePublishing | documentation for the `RealtimePublishing` module}. + */ + RealtimePublishing?: typeof RealtimePublishing; } /** diff --git a/scripts/moduleReport.js b/scripts/moduleReport.js new file mode 100644 index 0000000000..31e1b35a5f --- /dev/null +++ b/scripts/moduleReport.js @@ -0,0 +1,108 @@ +const esbuild = require('esbuild'); + +// List of all modules accepted in ModulesMap +const moduleNames = [ + 'Rest', + 'Crypto', + 'MsgPack', + 'RealtimePresence', + 'XHRPolling', + 'XHRStreaming', + 'WebSocketTransport', + 'XHRRequest', + 'FetchRequest', + 'MessageInteractions', + 'RealtimePublishing', +]; + +// List of all free-standing functions exported by the library along with the +// ModulesMap entries that we expect them to transitively import +const functions = [ + { name: 'generateRandomKey', transitiveImports: ['Crypto'] }, + { name: 'getDefaultCryptoParams', transitiveImports: ['Crypto'] }, + { name: 'decodeMessage', transitiveImports: [] }, + { name: 'decodeEncryptedMessage', transitiveImports: ['Crypto'] }, + { name: 'decodeMessages', transitiveImports: [] }, + { name: 'decodeEncryptedMessages', transitiveImports: ['Crypto'] }, + { name: 'decodePresenceMessage', transitiveImports: [] }, + { name: 'decodePresenceMessages', transitiveImports: [] }, + { name: 'constructPresenceMessage', transitiveImports: [] }, +]; + +function formatBytes(bytes) { + const kibibytes = bytes / 1024; + const formatted = kibibytes.toFixed(2); + return `${formatted} KiB`; +} + +// Gets the bundled size in bytes of an array of named exports from 'ably/modules' +function getImportSize(modules) { + const outfile = modules.join(''); + const result = esbuild.buildSync({ + stdin: { + contents: `export { ${modules.join(', ')} } from './build/modules'`, + resolveDir: '.', + }, + metafile: true, + minify: true, + bundle: true, + outfile, + write: false, + }); + + return result.metafile.outputs[outfile].bytes; +} + +const errors = []; + +['BaseRest', 'BaseRealtime'].forEach((baseClient) => { + const baseClientSize = getImportSize([baseClient]); + + // First display the size of the base client + console.log(`${baseClient}: ${formatBytes(baseClientSize)}`); + + // Then display the size of each export together with the base client + [...moduleNames, ...Object.values(functions).map((functionData) => functionData.name)].forEach((exportName) => { + const size = getImportSize([baseClient, exportName]); + console.log(`${baseClient} + ${exportName}: ${formatBytes(size)}`); + + if (!(baseClientSize < size) && !(baseClient === 'BaseRest' && exportName === 'Rest')) { + // Emit an error if adding the module does not increase the bundle size + // (this means that the module is not being tree-shaken correctly). + errors.push(new Error(`Adding ${exportName} to ${baseClient} does not increase the bundle size.`)); + } + }); +}); + +for (const functionData of functions) { + const { name: functionName, transitiveImports } = functionData; + + // First display the size of the function + const standaloneSize = getImportSize([functionName]); + console.log(`${functionName}: ${formatBytes(standaloneSize)}`); + + // Then display the size of the function together with the modules we expect + // it to transitively import + if (transitiveImports.length > 0) { + const withTransitiveImportsSize = getImportSize([functionName, ...transitiveImports]); + console.log(`${functionName} + ${transitiveImports.join(' + ')}: ${formatBytes(withTransitiveImportsSize)}`); + + if (withTransitiveImportsSize > standaloneSize) { + // Emit an error if the bundle size is increased by adding the modules + // that we expect this function to have transitively imported anyway. + // This seemed like a useful sense check, but it might need tweaking in + // the future if we make future optimisations that mean that the + // standalone functions don’t necessarily import the whole module. + errors.push( + new Error(`Adding ${transitiveImports.join(' + ')} to ${functionName} unexpectedly increases the bundle size.`) + ); + } + } +} + +if (errors.length > 0) { + for (const error of errors) { + console.log(error.message); + } + process.exit(1); +} diff --git a/scripts/moduleReport.ts b/scripts/moduleReport.ts index f3deda7b30..a217a80899 100644 --- a/scripts/moduleReport.ts +++ b/scripts/moduleReport.ts @@ -17,6 +17,7 @@ const moduleNames = [ 'XHRRequest', 'FetchRequest', 'MessageInteractions', + 'RealtimePublishing', ]; // List of all free-standing functions exported by the library along with the diff --git a/src/common/lib/client/baserealtime.ts b/src/common/lib/client/baserealtime.ts index 3787709326..6e4cd67058 100644 --- a/src/common/lib/client/baserealtime.ts +++ b/src/common/lib/client/baserealtime.ts @@ -12,12 +12,14 @@ import * as API from '../../../../ably'; import { ModulesMap, RealtimePresenceModule } from './modulesmap'; import { TransportNames } from 'common/constants/TransportName'; import { TransportImplementations } from 'common/platform'; +import { RealtimePublishing } from './realtimepublishing'; /** `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: RealtimePresenceModule | null; + readonly __RealtimePublishing: typeof RealtimePublishing | null; // Extra transport implementations available to this client, in addition to those in Platform.Transports.bundledImplementations readonly _additionalTransportImplementations: TransportImplementations; _channels: any; @@ -28,6 +30,7 @@ class BaseRealtime extends BaseClient { Logger.logAction(Logger.LOG_MINOR, 'Realtime()', ''); this._additionalTransportImplementations = BaseRealtime.transportImplementationsFromModules(modules); this._RealtimePresence = modules.RealtimePresence ?? null; + this.__RealtimePublishing = modules.RealtimePublishing ?? null; this.connection = new Connection(this, this.options); this._channels = new Channels(this); if (options.autoConnect !== false) this.connect(); @@ -53,6 +56,13 @@ class BaseRealtime extends BaseClient { return this._channels; } + get _RealtimePublishing(): typeof RealtimePublishing { + if (!this.__RealtimePublishing) { + Utils.throwMissingModuleError('RealtimePublishing'); + } + return this.__RealtimePublishing; + } + connect(): void { Logger.logAction(Logger.LOG_MINOR, 'Realtime.connect()', ''); this.connection.connect(); diff --git a/src/common/lib/client/defaultrealtime.ts b/src/common/lib/client/defaultrealtime.ts index 9e5f581dff..8c0354dc07 100644 --- a/src/common/lib/client/defaultrealtime.ts +++ b/src/common/lib/client/defaultrealtime.ts @@ -15,6 +15,7 @@ import { fromValues as presenceMessageFromValues, fromValuesArray as presenceMessagesFromValuesArray, } from '../types/presencemessage'; +import { RealtimePublishing } from './realtimepublishing'; /** `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. @@ -37,6 +38,7 @@ export class DefaultRealtime extends BaseRealtime { }, WebSocketTransport: initialiseWebSocketTransport, MessageInteractions: FilteredSubscriptions, + RealtimePublishing: RealtimePublishing, }); } diff --git a/src/common/lib/client/modulesmap.ts b/src/common/lib/client/modulesmap.ts index e52730aa32..70d5a83d46 100644 --- a/src/common/lib/client/modulesmap.ts +++ b/src/common/lib/client/modulesmap.ts @@ -10,6 +10,7 @@ import { fromValues as presenceMessageFromValues, fromValuesArray as presenceMessagesFromValuesArray, } from '../types/presencemessage'; +import { RealtimePublishing } from './realtimepublishing'; export interface PresenceMessageModule { presenceMessageFromValues: typeof presenceMessageFromValues; @@ -31,6 +32,7 @@ export interface ModulesMap { XHRRequest?: typeof XHRRequest; FetchRequest?: typeof fetchRequest; MessageInteractions?: typeof FilteredSubscriptions; + RealtimePublishing?: typeof RealtimePublishing; } export const allCommonModules: ModulesMap = { Rest }; diff --git a/src/common/lib/client/realtimechannel.ts b/src/common/lib/client/realtimechannel.ts index 6b6f7bad67..67ebc138e4 100644 --- a/src/common/lib/client/realtimechannel.ts +++ b/src/common/lib/client/realtimechannel.ts @@ -7,14 +7,7 @@ import EventEmitter from '../util/eventemitter'; import * as Utils from '../util/utils'; import Logger from '../util/logger'; import RealtimePresence from './realtimepresence'; -import Message, { - fromValues as messageFromValues, - fromValuesArray as messagesFromValuesArray, - encodeArray as encodeMessagesArray, - decode as decodeMessage, - getMessagesSize, - CipherOptions, -} from '../types/message'; +import Message, { decode as decodeMessage } from '../types/message'; import ChannelStateChange from './channelstatechange'; import ErrorInfo, { IPartialErrorInfo, PartialErrorInfo } from '../types/errorinfo'; import PresenceMessage, { decode as decodePresenceMessage } from '../types/presencemessage'; @@ -228,73 +221,11 @@ class RealtimeChannel extends EventEmitter { } publish(...args: any[]): void | Promise { - let messages = args[0]; - let argCount = args.length; - let callback = args[argCount - 1]; - - if (typeof callback !== 'function') { - return Utils.promisify(this, 'publish', arguments); - } - if (!this.connectionManager.activeState()) { - callback(this.connectionManager.getError()); - return; - } - if (argCount == 2) { - if (Utils.isObject(messages)) messages = [messageFromValues(messages)]; - else if (Utils.isArray(messages)) messages = messagesFromValuesArray(messages); - else - throw new ErrorInfo( - 'The single-argument form of publish() expects a message object or an array of message objects', - 40013, - 400 - ); - } else { - messages = [messageFromValues({ name: args[0], data: args[1] })]; - } - const maxMessageSize = this.client.options.maxMessageSize; - encodeMessagesArray(messages, this.channelOptions as CipherOptions, (err: Error | null) => { - if (err) { - callback(err); - return; - } - /* RSL1i */ - const size = 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; - } - this._publish(messages, callback); - }); + return this.client._RealtimePublishing.publish(this, ...args); } _publish(messages: Array, callback: ErrCallback) { - Logger.logAction(Logger.LOG_MICRO, 'RealtimeChannel.publish()', 'message count = ' + messages.length); - const state = this.state; - switch (state) { - case 'failed': - case 'suspended': - callback(ErrorInfo.fromValues(this.invalidStateError())); - break; - default: { - Logger.logAction(Logger.LOG_MICRO, 'RealtimeChannel.publish()', 'sending message; channel state is ' + state); - const msg = new ProtocolMessage(); - msg.action = actions.MESSAGE; - msg.channel = this.name; - msg.messages = messages; - this.sendMessage(msg, callback); - break; - } - } + this.client._RealtimePublishing._publish(this, messages, callback); } onEvent(messages: Array): void { diff --git a/src/common/lib/client/realtimepublishing.ts b/src/common/lib/client/realtimepublishing.ts new file mode 100644 index 0000000000..3ba04cb410 --- /dev/null +++ b/src/common/lib/client/realtimepublishing.ts @@ -0,0 +1,85 @@ +import RealtimeChannel from './realtimechannel'; +import ProtocolMessage, { actions } from '../types/protocolmessage'; +import * as Utils from '../util/utils'; +import Logger from '../util/logger'; +import Message, { + fromValues as messageFromValues, + fromValuesArray as messagesFromValuesArray, + encodeArray as encodeMessagesArray, + getMessagesSize, + CipherOptions, +} from '../types/message'; +import ErrorInfo from '../types/errorinfo'; +import { ErrCallback } from '../../types/utils'; + +export class RealtimePublishing { + static publish(channel: RealtimeChannel, ...args: any[]): void | Promise { + let messages = args[0]; + let argCount = args.length; + let callback = args[argCount - 1]; + + if (typeof callback !== 'function') { + return Utils.promisify(this, 'publish', arguments); + } + if (!channel.connectionManager.activeState()) { + callback(channel.connectionManager.getError()); + return; + } + if (argCount == 2) { + if (Utils.isObject(messages)) messages = [messageFromValues(messages)]; + else if (Utils.isArray(messages)) messages = messagesFromValuesArray(messages); + else + throw new ErrorInfo( + 'The single-argument form of publish() expects a message object or an array of message objects', + 40013, + 400 + ); + } else { + messages = [messageFromValues({ name: args[0], data: args[1] })]; + } + const maxMessageSize = channel.client.options.maxMessageSize; + encodeMessagesArray(messages, channel.channelOptions as CipherOptions, (err: Error | null) => { + if (err) { + callback(err); + return; + } + /* RSL1i */ + const size = 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; + } + this._publish(channel, messages, callback); + }); + } + + static _publish(channel: RealtimeChannel, messages: Array, callback: ErrCallback) { + Logger.logAction(Logger.LOG_MICRO, 'RealtimeChannel.publish()', 'message count = ' + messages.length); + const state = channel.state; + switch (state) { + case 'failed': + case 'suspended': + callback(ErrorInfo.fromValues(channel.invalidStateError())); + break; + default: { + Logger.logAction(Logger.LOG_MICRO, 'RealtimeChannel.publish()', 'sending message; channel state is ' + state); + const msg = new ProtocolMessage(); + msg.action = actions.MESSAGE; + msg.channel = channel.name; + msg.messages = messages; + channel.sendMessage(msg, callback); + break; + } + } + } +} diff --git a/src/platform/web/modules.ts b/src/platform/web/modules.ts index 0465405a90..683dace27d 100644 --- a/src/platform/web/modules.ts +++ b/src/platform/web/modules.ts @@ -51,4 +51,5 @@ export * from './modules/transports'; export * from './modules/http'; export { Rest } from '../../common/lib/client/rest'; export { FilteredSubscriptions as MessageInteractions } from '../../common/lib/client/filteredsubscriptions'; +export { RealtimePublishing } from '../../common/lib/client/realtimepublishing'; export { BaseRest, BaseRealtime, ErrorInfo }; diff --git a/test/browser/modules.test.js b/test/browser/modules.test.js index c7bdc90432..2b06ad7198 100644 --- a/test/browser/modules.test.js +++ b/test/browser/modules.test.js @@ -20,6 +20,7 @@ import { FetchRequest, XHRRequest, MessageInteractions, + RealtimePublishing, } from '../../build/modules/index.js'; describe('browser/modules', function () { @@ -141,7 +142,7 @@ describe('browser/modules', function () { describe('BaseRealtime without Rest', () => { it('still allows publishing and subscribing', async () => { - const client = new BaseRealtime(ablyClientOptions(), { WebSocketTransport, FetchRequest }); + const client = new BaseRealtime(ablyClientOptions(), { WebSocketTransport, FetchRequest, RealtimePublishing }); const channel = client.channels.get('channel'); await channel.attach(); @@ -368,7 +369,7 @@ describe('browser/modules', function () { for (const clientClassConfig of [ { clientClass: BaseRest }, - { clientClass: BaseRealtime, additionalModules: { WebSocketTransport } }, + { clientClass: BaseRealtime, additionalModules: { WebSocketTransport, RealtimePublishing } }, ]) { describe(clientClassConfig.clientClass.name, () => { it('is able to publish encrypted messages', async () => { @@ -478,6 +479,7 @@ describe('browser/modules', function () { WebSocketTransport, FetchRequest, RealtimePresence, + RealtimePublishing, }); const txChannel = txClient.channels.get('channel'); @@ -629,7 +631,11 @@ describe('browser/modules', function () { describe('BaseRealtime', () => { describe('without MessageInteractions', () => { it('is able to subscribe to and unsubscribe from channel events, as long as a MessageFilter isn’t passed', async () => { - const realtime = new BaseRealtime(ablyClientOptions(), { WebSocketTransport, FetchRequest }); + const realtime = new BaseRealtime(ablyClientOptions(), { + WebSocketTransport, + FetchRequest, + RealtimePublishing, + }); const channel = realtime.channels.get('channel'); await channel.attach(); @@ -642,7 +648,11 @@ describe('browser/modules', function () { }); it('throws an error when attempting to subscribe to channel events using a MessageFilter', async () => { - const realtime = new BaseRealtime(ablyClientOptions(), { WebSocketTransport, FetchRequest }); + const realtime = new BaseRealtime(ablyClientOptions(), { + WebSocketTransport, + FetchRequest, + RealtimePublishing, + }); const channel = realtime.channels.get('channel'); let thrownError = null; @@ -662,6 +672,7 @@ describe('browser/modules', function () { const realtime = new BaseRealtime(ablyClientOptions(), { WebSocketTransport, FetchRequest, + RealtimePublishing, MessageInteractions, }); const channel = realtime.channels.get('channel'); @@ -712,4 +723,33 @@ describe('browser/modules', function () { }); }); }); + + describe('RealtimePublishing', () => { + describe('BaseRealtime', () => { + describe('without RealtimePublishing', () => { + it('throws an error when attempting to publish a message', async () => { + const realtime = new BaseRealtime(ablyClientOptions(), { + WebSocketTransport, + FetchRequest, + }); + + const channel = realtime.channels.get('channel'); + expect(() => channel.publish('message', { foo: 'bar' })).to.throw('RealtimePublishing module not provided'); + }); + }); + + describe('with RealtimePublishing', () => { + it('can publish a message', async () => { + const realtime = new BaseRealtime(ablyClientOptions(), { + WebSocketTransport, + FetchRequest, + RealtimePublishing, + }); + + const channel = realtime.channels.get('channel'); + await channel.publish('message', { foo: 'bar' }); + }); + }); + }); + }); }); diff --git a/test/package/browser/template/src/index-modules.ts b/test/package/browser/template/src/index-modules.ts index 07a7751e3d..9b28f5606f 100644 --- a/test/package/browser/template/src/index-modules.ts +++ b/test/package/browser/template/src/index-modules.ts @@ -1,4 +1,11 @@ -import { BaseRealtime, Types, WebSocketTransport, FetchRequest, generateRandomKey } from 'ably/modules'; +import { + BaseRealtime, + Types, + WebSocketTransport, + FetchRequest, + RealtimePublishing, + generateRandomKey, +} from 'ably/modules'; import { createSandboxAblyAPIKey } from './sandbox'; // This function exists to check that we can import the Types namespace and refer to its types. @@ -17,7 +24,10 @@ async function checkStandaloneFunction() { globalThis.testAblyPackage = async function () { const key = await createSandboxAblyAPIKey(); - const realtime = new BaseRealtime({ key, environment: 'sandbox' }, { WebSocketTransport, FetchRequest }); + const realtime = new BaseRealtime( + { key, environment: 'sandbox' }, + { WebSocketTransport, FetchRequest, RealtimePublishing } + ); const channel = realtime.channels.get('channel'); await attachChannel(channel);