Skip to content

Commit

Permalink
Create a tree-shakable module for realtime publishing
Browse files Browse the repository at this point in the history
Owen mentioned that we have many browser use cases which only require
subscriptions, and no publishing. He suggested that we create a separate
tree-shakable module for this functionality.

This commit introduces the API, but the bundle size savings are minimal
since it only pulls out the very low-hanging fruit. I think that we
could return to this at some point to see what further size savings we
could achieve, but I didn’t want to spend too much time on this now.

Resolves #1491.
  • Loading branch information
lawrence-forooghian committed Nov 27, 2023
1 parent c183041 commit 71eedc2
Show file tree
Hide file tree
Showing 11 changed files with 287 additions and 78 deletions.
19 changes: 19 additions & 0 deletions modules.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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;
}

/**
Expand Down
108 changes: 108 additions & 0 deletions scripts/moduleReport.js
Original file line number Diff line number Diff line change
@@ -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);
}
1 change: 1 addition & 0 deletions scripts/moduleReport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const moduleNames = [
'XHRRequest',
'FetchRequest',
'MessageInteractions',
'RealtimePublishing',
];

// List of all free-standing functions exported by the library along with the
Expand Down
10 changes: 10 additions & 0 deletions src/common/lib/client/baserealtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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();
Expand All @@ -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();
Expand Down
2 changes: 2 additions & 0 deletions src/common/lib/client/defaultrealtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -37,6 +38,7 @@ export class DefaultRealtime extends BaseRealtime {
},
WebSocketTransport: initialiseWebSocketTransport,
MessageInteractions: FilteredSubscriptions,
RealtimePublishing: RealtimePublishing,
});
}

Expand Down
2 changes: 2 additions & 0 deletions src/common/lib/client/modulesmap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
fromValues as presenceMessageFromValues,
fromValuesArray as presenceMessagesFromValuesArray,
} from '../types/presencemessage';
import { RealtimePublishing } from './realtimepublishing';

export interface PresenceMessageModule {
presenceMessageFromValues: typeof presenceMessageFromValues;
Expand All @@ -31,6 +32,7 @@ export interface ModulesMap {
XHRRequest?: typeof XHRRequest;
FetchRequest?: typeof fetchRequest;
MessageInteractions?: typeof FilteredSubscriptions;
RealtimePublishing?: typeof RealtimePublishing;
}

export const allCommonModules: ModulesMap = { Rest };
75 changes: 3 additions & 72 deletions src/common/lib/client/realtimechannel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -228,73 +221,11 @@ class RealtimeChannel extends EventEmitter {
}

publish(...args: any[]): void | Promise<void> {
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<Message>, 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<any>): void {
Expand Down
85 changes: 85 additions & 0 deletions src/common/lib/client/realtimepublishing.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<Message>, 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;
}
}
}
}
Loading

0 comments on commit 71eedc2

Please sign in to comment.