From d24570cb4f5a25554b818acc657a1569ce29c114 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Wed, 2 Aug 2023 14:48:58 -0300 Subject: [PATCH] Split REST functionality into a tree-shakable module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This moves the following functionality from BaseClient into a new module named Rest: - methods that wrap REST endpoints (e.g. `stats`) - the `request` method - all functionality accessed via `BaseRest.channels` and `BaseRest.push` This allows us to now construct a BaseRealtime instance that doesn’t have REST functionality. Note that the BaseRest class always includes the Rest module. This comes after discussions in which we decided that it would be quite useless without it. Resolves #1374. Co-authored-by: Owen Pearson --- scripts/moduleReport.js | 4 +- src/common/lib/client/baseclient.ts | 191 ++++--------------------- src/common/lib/client/baserealtime.ts | 8 +- src/common/lib/client/baserest.ts | 11 +- src/common/lib/client/modulesmap.ts | 8 +- src/common/lib/client/rest.ts | 197 ++++++++++++++++++++++++++ src/common/types/http.d.ts | 2 +- src/platform/web/modules.ts | 1 + test/browser/modules.test.js | 33 ++++- 9 files changed, 279 insertions(+), 176 deletions(-) create mode 100644 src/common/lib/client/rest.ts diff --git a/scripts/moduleReport.js b/scripts/moduleReport.js index 19d1071672..61bdff99e3 100644 --- a/scripts/moduleReport.js +++ b/scripts/moduleReport.js @@ -1,7 +1,7 @@ const esbuild = require('esbuild'); // List of all modules accepted in ModulesMap -const moduleNames = []; +const moduleNames = ['Rest']; function formatBytes(bytes) { const kibibytes = bytes / 1024; @@ -40,7 +40,7 @@ const errors = []; const size = getImportSize([baseClient, moduleName]); console.log(`${baseClient} + ${moduleName}: ${formatBytes(size)}`); - if (!(baseClientSize < size)) { + if (!(baseClientSize < size) && !(baseClient === 'BaseRest' && moduleName === '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 ${moduleName} to ${baseClient} does not increase the bundle size.`)); diff --git a/src/common/lib/client/baseclient.ts b/src/common/lib/client/baseclient.ts index 922a9f214b..52b225a34b 100644 --- a/src/common/lib/client/baseclient.ts +++ b/src/common/lib/client/baseclient.ts @@ -1,24 +1,18 @@ -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 PaginatedResource, { HttpPaginatedResponse, PaginatedResult } from './paginatedresource'; -import Channel from './channel'; +import { HttpPaginatedResponse, PaginatedResult } from './paginatedresource'; 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, IHttp, RequestParams } from '../../types/http'; +import { StandardCallback } from '../../types/utils'; +import { IHttp, RequestParams } from '../../types/http'; import ClientOptions, { NormalisedClientOptions } from '../../types/ClientOptions'; import Platform from '../../platform'; import Message from '../types/message'; import PresenceMessage from '../types/presencemessage'; import { ModulesMap } from './modulesmap'; - -const noop = function () {}; +import { Rest } from './rest'; /** `BaseClient` acts as the base class for all of the client classes exported by the SDK. It is an implementation detail and this class is not advertised publicly. @@ -32,14 +26,10 @@ class BaseClient { serverTimeOffset: number | null; http: IHttp; auth: Auth; - channels: Channels; - push: Push; - constructor( - options: ClientOptions | string, - // eslint-disable-next-line no-unused-vars, @typescript-eslint/no-unused-vars - modules: ModulesMap - ) { + private readonly _rest: Rest | null; + + constructor(options: ClientOptions | string, modules: ModulesMap) { if (!options) { const msg = 'no options provided'; Logger.logAction(Logger.LOG_ERROR, 'BaseClient()', msg); @@ -86,8 +76,23 @@ class BaseClient { this.serverTimeOffset = null; this.http = new Platform.Http(normalOptions); this.auth = new Auth(this, normalOptions); - this.channels = new Channels(this); - this.push = new Push(this); + + this._rest = modules.Rest ? new modules.Rest(this) : null; + } + + private get rest(): Rest { + if (!this._rest) { + throw new ErrorInfo('Rest module not provided', 400, 40000); + } + return this._rest; + } + + get channels() { + return this.rest.channels; + } + + get push() { + return this.rest.push; } baseUri(host: string) { @@ -98,78 +103,11 @@ class BaseClient { 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 { - return Utils.promisify(this, 'stats', [params]) as Promise>; - } - } - const headers = Defaults.defaultGetHeaders(this.options), - format = this.options.useBinaryProtocol ? Utils.Format.msgpack : Utils.Format.json, - envelope = this.http.supportsLinkHeaders ? undefined : format; - - 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); + return this.rest.stats(params, 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 { - return Utils.promisify(this, 'time', [params]) as Promise; - } - } - - const _callback = callback || noop; - - const headers = Defaults.defaultGetHeaders(this.options); - if (this.options.headers) Utils.mixin(headers, this.options.headers); - const timeUri = (host: string) => { - return this.baseUri(host) + '/time'; - }; - this.http.do( - HttpMethods.Get, - this, - timeUri, - headers, - null, - 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); - } - ); + return this.rest.time(params, callback); } request( @@ -181,54 +119,7 @@ class BaseClient { customHeaders: Record, callback: StandardCallback> ): Promise> | void { - const useBinary = this.options.useBinaryProtocol, - encoder = useBinary ? Platform.Config.msgpack.encode : JSON.stringify, - decoder = useBinary ? Platform.Config.msgpack.decode : JSON.parse, - format = useBinary ? Utils.Format.msgpack : Utils.Format.json, - envelope = this.http.supportsLinkHeaders ? undefined : format; - params = params || {}; - const _method = method.toLowerCase() as HttpMethods; - const headers = - _method == 'get' - ? Defaults.defaultGetHeaders(this.options, { format, protocolVersion: version }) - : Defaults.defaultPostHeaders(this.options, { format, protocolVersion: version }); - - if (callback === undefined) { - return Utils.promisify(this, 'request', [method, path, version, params, body, customHeaders]) as Promise< - HttpPaginatedResponse - >; - } - - if (typeof body !== 'string') { - body = encoder(body); - } - Utils.mixin(headers, this.options.headers); - if (customHeaders) { - Utils.mixin(headers, customHeaders); - } - const paginatedResource = new PaginatedResource( - this, - path, - headers, - envelope, - async function (resbody: unknown, headers: Record, unpacked?: boolean) { - return Utils.ensureArray(unpacked ? resbody : decoder(resbody as string & Buffer)); - }, - /* useHttpPaginatedResponse: */ true - ); - - if (!Utils.arrIn(Platform.Http.methods, _method)) { - throw new ErrorInfo('Unsupported method ' + _method, 40500, 405); - } - - if (Utils.arrIn(Platform.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 - ); - } + return this.rest.request(method, path, version, params, body, customHeaders, callback); } setLog(logOptions: LoggerOptions): void { @@ -241,32 +132,4 @@ class BaseClient { static PresenceMessage = PresenceMessage; } -class Channels { - client: BaseClient; - all: Record; - - constructor(client: BaseClient) { - this.client = client; - this.all = Object.create(null); - } - - get(name: string, channelOptions?: ChannelOptions) { - name = String(name); - let channel = this.all[name]; - if (!channel) { - this.all[name] = channel = new Channel(this.client, 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 BaseClient; diff --git a/src/common/lib/client/baserealtime.ts b/src/common/lib/client/baserealtime.ts index b4740efca4..4ff45345bc 100644 --- a/src/common/lib/client/baserealtime.ts +++ b/src/common/lib/client/baserealtime.ts @@ -18,17 +18,21 @@ import { ModulesMap } from './modulesmap'; `BaseRealtime` is an export of the tree-shakable version of the SDK, and acts as the base class for the `BaseRealtime` class exported by the non tree-shakable version. */ class BaseRealtime extends BaseClient { - channels: any; + _channels: any; connection: Connection; constructor(options: ClientOptions, modules: ModulesMap) { super(options, modules); Logger.logAction(Logger.LOG_MINOR, 'Realtime()', ''); this.connection = new Connection(this, this.options); - this.channels = new Channels(this); + this._channels = new Channels(this); if (options.autoConnect !== false) this.connect(); } + get channels() { + return this._channels; + } + connect(): void { Logger.logAction(Logger.LOG_MINOR, 'Realtime.connect()', ''); this.connection.connect(); diff --git a/src/common/lib/client/baserest.ts b/src/common/lib/client/baserest.ts index 5bf441170a..412e636c2a 100644 --- a/src/common/lib/client/baserest.ts +++ b/src/common/lib/client/baserest.ts @@ -1,6 +1,15 @@ import BaseClient from './baseclient'; +import ClientOptions from '../../types/ClientOptions'; +import { ModulesMap } from './modulesmap'; +import { Rest } from './rest'; /** `BaseRest` is an export of the tree-shakable version of the SDK, and acts as the base class for the `BaseRest` class exported by the non tree-shakable version. + + It always includes the `Rest` module. */ -export class BaseRest extends BaseClient {} +export class BaseRest extends BaseClient { + constructor(options: ClientOptions | string, modules: ModulesMap) { + super(options, { Rest, ...modules }); + } +} diff --git a/src/common/lib/client/modulesmap.ts b/src/common/lib/client/modulesmap.ts index 13b207d8c5..c56c2d98e7 100644 --- a/src/common/lib/client/modulesmap.ts +++ b/src/common/lib/client/modulesmap.ts @@ -1,3 +1,7 @@ -export interface ModulesMap {} +import { Rest } from './rest'; -export const allCommonModules: ModulesMap = {}; +export interface ModulesMap { + Rest?: typeof Rest; +} + +export const allCommonModules: ModulesMap = { Rest }; diff --git a/src/common/lib/client/rest.ts b/src/common/lib/client/rest.ts new file mode 100644 index 0000000000..505bc05edc --- /dev/null +++ b/src/common/lib/client/rest.ts @@ -0,0 +1,197 @@ +import * as Utils from '../util/utils'; +import Logger, { LoggerOptions } from '../util/logger'; +import Defaults from '../util/defaults'; +import Push from './push'; +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 Platform from '../../platform'; +import BaseClient from './baseclient'; + +const noop = function () {}; +export class Rest { + private readonly client: BaseClient; + readonly channels: Channels; + readonly push: Push; + + constructor(client: BaseClient) { + this.client = client; + this.channels = new Channels(this.client); + this.push = new Push(this.client); + } + + 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 { + return Utils.promisify(this, 'stats', [params]) as Promise>; + } + } + const headers = Defaults.defaultGetHeaders(this.client.options), + format = this.client.options.useBinaryProtocol ? Utils.Format.msgpack : Utils.Format.json, + envelope = this.client.http.supportsLinkHeaders ? undefined : format; + + Utils.mixin(headers, this.client.options.headers); + + new PaginatedResource(this.client, '/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 { + return Utils.promisify(this, 'time', [params]) as Promise; + } + } + + const _callback = callback || noop; + + const headers = Defaults.defaultGetHeaders(this.client.options); + if (this.client.options.headers) Utils.mixin(headers, this.client.options.headers); + const timeUri = (host: string) => { + return this.client.baseUri(host) + '/time'; + }; + this.client.http.do( + HttpMethods.Get, + this.client, + timeUri, + headers, + null, + 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.client.serverTimeOffset = time - Utils.now(); + _callback(null, time); + } + ); + } + + request( + method: string, + path: string, + version: number, + params: RequestParams, + body: unknown, + customHeaders: Record, + callback: StandardCallback> + ): Promise> | void { + const useBinary = this.client.options.useBinaryProtocol, + encoder = useBinary ? Platform.Config.msgpack.encode : JSON.stringify, + decoder = useBinary ? Platform.Config.msgpack.decode : JSON.parse, + format = useBinary ? Utils.Format.msgpack : Utils.Format.json, + envelope = this.client.http.supportsLinkHeaders ? undefined : format; + params = params || {}; + const _method = method.toLowerCase() as HttpMethods; + const headers = + _method == 'get' + ? Defaults.defaultGetHeaders(this.client.options, { format, protocolVersion: version }) + : Defaults.defaultPostHeaders(this.client.options, { format, protocolVersion: version }); + + if (callback === undefined) { + return Utils.promisify(this, 'request', [method, path, version, params, body, customHeaders]) as Promise< + HttpPaginatedResponse + >; + } + + if (typeof body !== 'string') { + body = encoder(body); + } + Utils.mixin(headers, this.client.options.headers); + if (customHeaders) { + Utils.mixin(headers, customHeaders); + } + const paginatedResource = new PaginatedResource( + this.client, + path, + headers, + envelope, + async function (resbody: unknown, headers: Record, unpacked?: boolean) { + return Utils.ensureArray(unpacked ? resbody : decoder(resbody as string & Buffer)); + }, + /* useHttpPaginatedResponse: */ true + ); + + if (!Utils.arrIn(Platform.Http.methods, _method)) { + throw new ErrorInfo('Unsupported method ' + _method, 40500, 405); + } + + if (Utils.arrIn(Platform.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); + } +} + +class Channels { + client: BaseClient; + all: Record; + + constructor(client: BaseClient) { + this.client = client; + this.all = Object.create(null); + } + + get(name: string, channelOptions?: ChannelOptions) { + name = String(name); + let channel = this.all[name]; + if (!channel) { + this.all[name] = channel = new Channel(this.client, 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)]; + } +} diff --git a/src/common/types/http.d.ts b/src/common/types/http.d.ts index 1cf703c540..d1931cb59c 100644 --- a/src/common/types/http.d.ts +++ b/src/common/types/http.d.ts @@ -1,5 +1,5 @@ import HttpMethods from '../constants/HttpMethods'; -import BaseClient from '../lib/client/baseclient'; +import { BaseClient } from '../lib/client/baseclient'; import ErrorInfo from '../lib/types/errorinfo'; import { Agents } from 'got'; diff --git a/src/platform/web/modules.ts b/src/platform/web/modules.ts index 182e276098..abebae04fa 100644 --- a/src/platform/web/modules.ts +++ b/src/platform/web/modules.ts @@ -46,4 +46,5 @@ if (Platform.Config.noUpgrade) { Platform.Defaults.upgradeTransports = []; } +export { Rest } from '../../common/lib/client/rest'; export { BaseRest, BaseRealtime }; diff --git a/test/browser/modules.test.js b/test/browser/modules.test.js index 755d70063f..68709f6ea9 100644 --- a/test/browser/modules.test.js +++ b/test/browser/modules.test.js @@ -1,4 +1,4 @@ -import { BaseRest, BaseRealtime } from '../../build/modules/index.js'; +import { BaseRest, BaseRealtime, Rest } from '../../build/modules/index.js'; describe('browser/modules', function () { this.timeout(10 * 1000); @@ -12,11 +12,36 @@ describe('browser/modules', function () { describe('without any modules', () => { for (const clientClass of [BaseRest, BaseRealtime]) { - it(clientClass.name, async () => { - const client = new clientClass(ablyClientOptions()); + describe(clientClass.name, () => { + it('can be constructed', async () => { + expect(() => new clientClass(ablyClientOptions(), {})).not.to.throw(); + }); + }); + } + }); + + describe('Rest', () => { + describe('BaseRest without explicit Rest', () => { + it('offers REST functionality', async () => { + const client = new BaseRest(ablyClientOptions(), {}); const time = await client.time(); expect(time).to.be.a('number'); }); - } + }); + + describe('BaseRealtime with Rest', () => { + it('offers REST functionality', async () => { + const client = new BaseRealtime(ablyClientOptions(), { Rest }); + const time = await client.time(); + expect(time).to.be.a('number'); + }); + }); + + describe('BaseRealtime without Rest', () => { + it('throws an error when attempting to use REST functionality', async () => { + const client = new BaseRealtime(ablyClientOptions(), {}); + expect(() => client.time()).to.throw(); + }); + }); }); });