diff --git a/scripts/moduleReport.js b/scripts/moduleReport.js index ee5cadf0a8..dd1ec550f5 100644 --- a/scripts/moduleReport.js +++ b/scripts/moduleReport.js @@ -9,6 +9,9 @@ const moduleNames = [ 'XHRPolling', 'XHRStreaming', 'WebSocketTransport', + 'XHRRequest', + 'FetchRequest', + 'MessageInteractions', ]; // List of all free-standing functions exported by the library along with the diff --git a/src/common/lib/client/auth.ts b/src/common/lib/client/auth.ts index 3ed509ed6d..b4db9380c4 100644 --- a/src/common/lib/client/auth.ts +++ b/src/common/lib/client/auth.ts @@ -538,7 +538,6 @@ class Auth { const body = Utils.toQueryString(authParams).slice(1); /* slice is to remove the initial '?' */ this.client.http.doUri( HttpMethods.Post, - client, authOptions.authUrl, headers, body, @@ -548,7 +547,6 @@ class Auth { } else { this.client.http.doUri( HttpMethods.Get, - client, authOptions.authUrl, authHeaders || {}, null, @@ -594,7 +592,6 @@ class Auth { ); this.client.http.do( HttpMethods.Post, - client, tokenUri, requestHeaders, JSON.stringify(signedTokenParams), diff --git a/src/common/lib/client/baseclient.ts b/src/common/lib/client/baseclient.ts index 939257edb7..482075fa23 100644 --- a/src/common/lib/client/baseclient.ts +++ b/src/common/lib/client/baseclient.ts @@ -15,6 +15,8 @@ import { Rest } from './rest'; import { IUntypedCryptoStatic } from 'common/types/ICryptoStatic'; import { throwMissingModuleError } from '../util/utils'; import { MsgPack } from 'common/types/msgpack'; +import { HTTPRequestImplementations } from 'platform/web/lib/http/http'; +import { FilteredSubscriptions } from './filteredsubscriptions'; type BatchResult = API.Types.BatchResult; type BatchPublishSpec = API.Types.BatchPublishSpec; @@ -41,8 +43,13 @@ class BaseClient { private readonly _rest: Rest | null; readonly _Crypto: IUntypedCryptoStatic | null; readonly _MsgPack: MsgPack | null; + // Extra HTTP request implementations available to this client, in addition to those in web’s Http.bundledRequestImplementations + readonly _additionalHTTPRequestImplementations: HTTPRequestImplementations; + private readonly __FilteredSubscriptions: typeof FilteredSubscriptions | null; constructor(options: ClientOptions | string, modules: ModulesMap) { + this._additionalHTTPRequestImplementations = modules; + if (!options) { const msg = 'no options provided'; Logger.logAction(Logger.LOG_ERROR, 'BaseClient()', msg); @@ -88,11 +95,12 @@ class BaseClient { this._currentFallback = null; this.serverTimeOffset = null; - this.http = new Platform.Http(normalOptions); + this.http = new Platform.Http(this); this.auth = new Auth(this, normalOptions); this._rest = modules.Rest ? new modules.Rest(this) : null; this._Crypto = modules.Crypto ?? null; + this.__FilteredSubscriptions = modules.MessageInteractions ?? null; } private get rest(): Rest { @@ -102,6 +110,13 @@ class BaseClient { return this._rest; } + get _FilteredSubscriptions(): typeof FilteredSubscriptions { + if (!this.__FilteredSubscriptions) { + throwMissingModuleError('MessageInteractions'); + } + return this.__FilteredSubscriptions; + } + get channels() { return this.rest.channels; } diff --git a/src/common/lib/client/channel.ts b/src/common/lib/client/channel.ts index ac7efe4d94..2922de7adb 100644 --- a/src/common/lib/client/channel.ts +++ b/src/common/lib/client/channel.ts @@ -98,11 +98,11 @@ class Channel extends EventEmitter { const options = this.channelOptions; new PaginatedResource(client, this.basePath + '/messages', headers, envelope, async function ( - body: any, - headers: Record, - unpacked?: boolean + body, + headers, + unpacked ) { - return await Message.fromResponseBody(body, options, client._MsgPack, unpacked ? undefined : format); + return await Message.fromResponseBody(body as Message[], options, client._MsgPack, unpacked ? undefined : format); }).get(params as Record, callback); } diff --git a/src/common/lib/client/defaultrealtime.ts b/src/common/lib/client/defaultrealtime.ts index 2204ab71ac..1dd3d402dd 100644 --- a/src/common/lib/client/defaultrealtime.ts +++ b/src/common/lib/client/defaultrealtime.ts @@ -10,6 +10,7 @@ import { MsgPack } from 'common/types/msgpack'; import RealtimePresence from './realtimepresence'; import { DefaultPresenceMessage } from '../types/defaultpresencemessage'; import initialiseWebSocketTransport from '../transport/websockettransport'; +import { FilteredSubscriptions } from './filteredsubscriptions'; /** `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. @@ -27,6 +28,7 @@ export class DefaultRealtime extends BaseRealtime { MsgPack, RealtimePresence, WebSocketTransport: initialiseWebSocketTransport, + MessageInteractions: FilteredSubscriptions, }); } diff --git a/src/common/lib/client/filteredsubscriptions.ts b/src/common/lib/client/filteredsubscriptions.ts new file mode 100644 index 0000000000..1588fa4962 --- /dev/null +++ b/src/common/lib/client/filteredsubscriptions.ts @@ -0,0 +1,112 @@ +import * as API from '../../../../ably'; +import RealtimeChannel from './realtimechannel'; +import Message from '../types/message'; + +export class FilteredSubscriptions { + static subscribeFilter( + channel: RealtimeChannel, + filter: API.Types.MessageFilter, + listener: API.Types.messageCallback + ) { + const filteredListener = (m: Message) => { + const mapping: { [key in keyof API.Types.MessageFilter]: any } = { + name: m.name, + refTimeserial: m.extras?.ref?.timeserial, + refType: m.extras?.ref?.type, + isRef: !!m.extras?.ref?.timeserial, + clientId: m.clientId, + }; + // Check if any values are defined in the filter and if they match the value in the message object + if ( + Object.entries(filter).find(([key, value]) => + value !== undefined ? mapping[key as keyof API.Types.MessageFilter] !== value : false + ) + ) { + return; + } + listener(m); + }; + this.addFilteredSubscription(channel, filter, listener, filteredListener); + channel.subscriptions.on(filteredListener); + } + + // Adds a new filtered subscription + static addFilteredSubscription( + channel: RealtimeChannel, + filter: API.Types.MessageFilter, + realListener: API.Types.messageCallback, + filteredListener: API.Types.messageCallback + ) { + if (!channel.filteredSubscriptions) { + channel.filteredSubscriptions = new Map< + API.Types.messageCallback, + Map[]> + >(); + } + if (channel.filteredSubscriptions.has(realListener)) { + const realListenerMap = channel.filteredSubscriptions.get(realListener) as Map< + API.Types.MessageFilter, + API.Types.messageCallback[] + >; + // Add the filtered listener to the map, or append to the array if this filter has already been used + realListenerMap.set(filter, realListenerMap?.get(filter)?.concat(filteredListener) || [filteredListener]); + } else { + channel.filteredSubscriptions.set( + realListener, + new Map[]>([[filter, [filteredListener]]]) + ); + } + } + + static getAndDeleteFilteredSubscriptions( + channel: RealtimeChannel, + filter: API.Types.MessageFilter | undefined, + realListener: API.Types.messageCallback | undefined + ): API.Types.messageCallback[] { + // No filtered subscriptions map means there has been no filtered subscriptions yet, so return nothing + if (!channel.filteredSubscriptions) { + return []; + } + // Only a filter is passed in with no specific listener + if (!realListener && filter) { + // Return each listener which is attached to the specified filter object + return Array.from(channel.filteredSubscriptions.entries()) + .map(([key, filterMaps]) => { + // Get (then delete) the maps matching this filter + let listenerMaps = filterMaps.get(filter); + filterMaps.delete(filter); + // Clear the parent if nothing is left + if (filterMaps.size === 0) { + channel.filteredSubscriptions?.delete(key); + } + return listenerMaps; + }) + .reduce( + (prev, cur) => (cur ? (prev as API.Types.messageCallback[]).concat(...cur) : prev), + [] + ) as API.Types.messageCallback[]; + } + + // No subscriptions for this listener + if (!realListener || !channel.filteredSubscriptions.has(realListener)) { + return []; + } + const realListenerMap = channel.filteredSubscriptions.get(realListener) as Map< + API.Types.MessageFilter, + API.Types.messageCallback[] + >; + // If no filter is specified return all listeners using that function + if (!filter) { + // array.flat is not available unless we support es2019 or higher + const listeners = Array.from(realListenerMap.values()).reduce((prev, cur) => prev.concat(...cur), []); + // remove the listener from the map + channel.filteredSubscriptions.delete(realListener); + return listeners; + } + + let listeners = realListenerMap.get(filter); + realListenerMap.delete(filter); + + return listeners || []; + } +} diff --git a/src/common/lib/client/modulesmap.ts b/src/common/lib/client/modulesmap.ts index ada7de44ee..dab5c8b718 100644 --- a/src/common/lib/client/modulesmap.ts +++ b/src/common/lib/client/modulesmap.ts @@ -3,6 +3,9 @@ import { IUntypedCryptoStatic } from '../../types/ICryptoStatic'; import { MsgPack } from 'common/types/msgpack'; import RealtimePresence from './realtimepresence'; import { TransportInitialiser } from '../transport/connectionmanager'; +import XHRRequest from 'platform/web/lib/http/request/xhrrequest'; +import fetchRequest from 'platform/web/lib/http/request/fetchrequest'; +import { FilteredSubscriptions } from './filteredsubscriptions'; export interface ModulesMap { Rest?: typeof Rest; @@ -12,6 +15,9 @@ export interface ModulesMap { WebSocketTransport?: TransportInitialiser; XHRPolling?: TransportInitialiser; XHRStreaming?: TransportInitialiser; + XHRRequest?: typeof XHRRequest; + FetchRequest?: typeof fetchRequest; + MessageInteractions?: typeof FilteredSubscriptions; } export const allCommonModules: ModulesMap = { Rest }; diff --git a/src/common/lib/client/paginatedresource.ts b/src/common/lib/client/paginatedresource.ts index fd899c3e14..d224d17403 100644 --- a/src/common/lib/client/paginatedresource.ts +++ b/src/common/lib/client/paginatedresource.ts @@ -4,8 +4,9 @@ import Resource from './resource'; import ErrorInfo, { IPartialErrorInfo } from '../types/errorinfo'; import { PaginatedResultCallback } from '../../types/utils'; import BaseClient from './baseclient'; +import { RequestCallbackHeaders } from 'common/types/http'; -export type BodyHandler = (body: unknown, headers: Record, unpacked?: boolean) => Promise; +export type BodyHandler = (body: unknown, headers: RequestCallbackHeaders, unpacked?: boolean) => Promise; function getRelParams(linkUrl: string) { const urlMatch = linkUrl.match(/^\.\/(\w+)\?(.*)$/); @@ -135,7 +136,7 @@ class PaginatedResource { handlePage( err: IPartialErrorInfo | null, body: unknown, - headers: Record | undefined, + headers: RequestCallbackHeaders | undefined, unpacked: boolean | undefined, statusCode: number | undefined, callback: PaginatedResultCallback @@ -249,14 +250,14 @@ export class PaginatedResult { export class HttpPaginatedResponse extends PaginatedResult { statusCode: number; success: boolean; - headers: Record; + headers: RequestCallbackHeaders; errorCode?: number | null; errorMessage?: string | null; constructor( resource: PaginatedResource, items: T[], - headers: Record, + headers: RequestCallbackHeaders, statusCode: number, relParams: any, err: IPartialErrorInfo | null diff --git a/src/common/lib/client/presence.ts b/src/common/lib/client/presence.ts index ac756ef9b8..8acbed68bf 100644 --- a/src/common/lib/client/presence.ts +++ b/src/common/lib/client/presence.ts @@ -38,13 +38,9 @@ class Presence extends EventEmitter { Utils.mixin(headers, client.options.headers); const options = this.channel.channelOptions; - new PaginatedResource(client, this.basePath, headers, envelope, async function ( - body: any, - headers: Record, - unpacked?: boolean - ) { + new PaginatedResource(client, this.basePath, headers, envelope, async function (body, headers, unpacked) { return await PresenceMessage.fromResponseBody( - body, + body as Record[], options as CipherOptions, client._MsgPack, unpacked ? undefined : format @@ -83,12 +79,12 @@ class Presence extends EventEmitter { const options = this.channel.channelOptions; new PaginatedResource(client, this.basePath + '/history', headers, envelope, async function ( - body: any, - headers: Record, - unpacked?: boolean + body, + headers, + unpacked ) { return await PresenceMessage.fromResponseBody( - body, + body as Record[], options as CipherOptions, client._MsgPack, unpacked ? undefined : format diff --git a/src/common/lib/client/push.ts b/src/common/lib/client/push.ts index 33054fbd0c..3d0b7b9f44 100644 --- a/src/common/lib/client/push.ts +++ b/src/common/lib/client/push.ts @@ -151,11 +151,15 @@ class DeviceRegistrations { Utils.mixin(headers, client.options.headers); new PaginatedResource(client, '/push/deviceRegistrations', headers, envelope, async function ( - body: any, - headers: Record, - unpacked?: boolean + body, + headers, + unpacked ) { - return DeviceDetails.fromResponseBody(body, client._MsgPack, unpacked ? undefined : format); + return DeviceDetails.fromResponseBody( + body as Record[], + client._MsgPack, + unpacked ? undefined : format + ); }).get(params, callback); } @@ -269,11 +273,15 @@ class ChannelSubscriptions { Utils.mixin(headers, client.options.headers); new PaginatedResource(client, '/push/channelSubscriptions', headers, envelope, async function ( - body: any, - headers: Record, - unpacked?: boolean + body, + headers, + unpacked ) { - return PushChannelSubscription.fromResponseBody(body, client._MsgPack, unpacked ? undefined : format); + return PushChannelSubscription.fromResponseBody( + body as Record[], + client._MsgPack, + unpacked ? undefined : format + ); }).get(params, callback); } @@ -310,11 +318,7 @@ class ChannelSubscriptions { if (client.options.pushFullWait) Utils.mixin(params, { fullWait: 'true' }); - new PaginatedResource(client, '/push/channels', headers, envelope, async function ( - body: unknown, - headers: Record, - unpacked?: boolean - ) { + new PaginatedResource(client, '/push/channels', headers, envelope, async function (body, headers, unpacked) { const parsedBody = ( !unpacked && format ? Utils.decodeBody(body, client._MsgPack, format) : body ) as Array; diff --git a/src/common/lib/client/realtimechannel.ts b/src/common/lib/client/realtimechannel.ts index ee403f7bc5..898377dded 100644 --- a/src/common/lib/client/realtimechannel.ts +++ b/src/common/lib/client/realtimechannel.ts @@ -438,7 +438,7 @@ class RealtimeChannel extends Channel { // Filtered if (event && typeof event === 'object' && !Array.isArray(event)) { - this._subscribeFilter(event, listener); + this.client._FilteredSubscriptions.subscribeFilter(this, event, listener); } else { this.subscriptions.on(event, listener); } @@ -446,113 +446,14 @@ class RealtimeChannel extends Channel { return this.attach(callback || noop); } - _subscribeFilter(filter: API.Types.MessageFilter, listener: API.Types.messageCallback) { - const filteredListener = (m: Message) => { - const mapping: { [key in keyof API.Types.MessageFilter]: any } = { - name: m.name, - refTimeserial: m.extras?.ref?.timeserial, - refType: m.extras?.ref?.type, - isRef: !!m.extras?.ref?.timeserial, - clientId: m.clientId, - }; - // Check if any values are defined in the filter and if they match the value in the message object - if ( - Object.entries(filter).find(([key, value]) => - value !== undefined ? mapping[key as keyof API.Types.MessageFilter] !== value : false - ) - ) { - return; - } - listener(m); - }; - this._addFilteredSubscription(filter, listener, filteredListener); - this.subscriptions.on(filteredListener); - } - - // Adds a new filtered subscription - _addFilteredSubscription( - filter: API.Types.MessageFilter, - realListener: API.Types.messageCallback, - filteredListener: API.Types.messageCallback - ) { - if (!this.filteredSubscriptions) { - this.filteredSubscriptions = new Map< - API.Types.messageCallback, - Map[]> - >(); - } - if (this.filteredSubscriptions.has(realListener)) { - const realListenerMap = this.filteredSubscriptions.get(realListener) as Map< - API.Types.MessageFilter, - API.Types.messageCallback[] - >; - // Add the filtered listener to the map, or append to the array if this filter has already been used - realListenerMap.set(filter, realListenerMap?.get(filter)?.concat(filteredListener) || [filteredListener]); - } else { - this.filteredSubscriptions.set( - realListener, - new Map[]>([[filter, [filteredListener]]]) - ); - } - } - - _getAndDeleteFilteredSubscriptions( - filter: API.Types.MessageFilter | undefined, - realListener: API.Types.messageCallback | undefined - ): API.Types.messageCallback[] { - // No filtered subscriptions map means there has been no filtered subscriptions yet, so return nothing - if (!this.filteredSubscriptions) { - return []; - } - // Only a filter is passed in with no specific listener - if (!realListener && filter) { - // Return each listener which is attached to the specified filter object - return Array.from(this.filteredSubscriptions.entries()) - .map(([key, filterMaps]) => { - // Get (then delete) the maps matching this filter - let listenerMaps = filterMaps.get(filter); - filterMaps.delete(filter); - // Clear the parent if nothing is left - if (filterMaps.size === 0) { - this.filteredSubscriptions?.delete(key); - } - return listenerMaps; - }) - .reduce( - (prev, cur) => (cur ? (prev as API.Types.messageCallback[]).concat(...cur) : prev), - [] - ) as API.Types.messageCallback[]; - } - - // No subscriptions for this listener - if (!realListener || !this.filteredSubscriptions.has(realListener)) { - return []; - } - const realListenerMap = this.filteredSubscriptions.get(realListener) as Map< - API.Types.MessageFilter, - API.Types.messageCallback[] - >; - // If no filter is specified return all listeners using that function - if (!filter) { - // array.flat is not available unless we support es2019 or higher - const listeners = Array.from(realListenerMap.values()).reduce((prev, cur) => prev.concat(...cur), []); - // remove the listener from the map - this.filteredSubscriptions.delete(realListener); - return listeners; - } - - let listeners = realListenerMap.get(filter); - realListenerMap.delete(filter); - - return listeners || []; - } - unsubscribe(...args: unknown[] /* [event], listener */): void { const [event, listener] = RealtimeChannel.processListenerArgs(args); // If we either have a filtered listener, a filter or both we need to do additional processing to find the original function(s) if ((typeof event === 'object' && !listener) || this.filteredSubscriptions?.has(listener)) { - this._getAndDeleteFilteredSubscriptions(event, listener).forEach((l) => this.subscriptions.off(l)); + this.client._FilteredSubscriptions + .getAndDeleteFilteredSubscriptions(this, event, listener) + .forEach((l) => this.subscriptions.off(l)); return; } diff --git a/src/common/lib/client/resource.ts b/src/common/lib/client/resource.ts index 7d506d369e..3f0a8d68da 100644 --- a/src/common/lib/client/resource.ts +++ b/src/common/lib/client/resource.ts @@ -5,12 +5,12 @@ import Auth from './auth'; import HttpMethods from '../../constants/HttpMethods'; import ErrorInfo, { IPartialErrorInfo, PartialErrorInfo } from '../types/errorinfo'; import BaseClient from './baseclient'; -import { ErrnoException } from '../../types/http'; import { MsgPack } from 'common/types/msgpack'; +import { RequestCallbackHeaders } from 'common/types/http'; function withAuthDetails( client: BaseClient, - headers: Record, + headers: RequestCallbackHeaders | undefined, params: Record, errCallback: Function, opCallback: Function @@ -130,7 +130,7 @@ function logResponseHandler( export type ResourceCallback = ( err: IPartialErrorInfo | null, body?: T, - headers?: Record, + headers?: RequestCallbackHeaders, unpacked?: boolean, statusCode?: number ) => void; @@ -245,35 +245,21 @@ class Resource { ); } - client.http.do( - method, - client, - path, - headers, - body, - params, - function ( - err: ErrorInfo | ErrnoException | null | undefined, - res: any, - headers: Record, - unpacked?: boolean, - statusCode?: number - ) { - if (err && Auth.isTokenErr(err as ErrorInfo)) { - /* token has expired, so get a new one */ - client.auth.authorize(null, null, function (err: ErrorInfo) { - if (err) { - callback(err); - return; - } - /* retry ... */ - withAuthDetails(client, headers, params, callback, doRequest); - }); - return; - } - callback(err as ErrorInfo, res, headers, unpacked, statusCode); + client.http.do(method, path, headers, body, params, function (err, res, headers, unpacked, statusCode) { + if (err && Auth.isTokenErr(err as ErrorInfo)) { + /* token has expired, so get a new one */ + client.auth.authorize(null, null, function (err: ErrorInfo) { + if (err) { + callback(err); + return; + } + /* retry ... */ + withAuthDetails(client, headers, params, callback, doRequest); + }); + return; } - ); + callback(err as ErrorInfo, res as T | undefined, headers, unpacked, statusCode); + }); } withAuthDetails(client, headers, params, callback, doRequest); diff --git a/src/common/lib/client/rest.ts b/src/common/lib/client/rest.ts index f58ef1b493..a3592f9945 100644 --- a/src/common/lib/client/rest.ts +++ b/src/common/lib/client/rest.ts @@ -9,7 +9,7 @@ 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 { RequestParams } from '../../types/http'; import * as API from '../../../../ably'; import Resource from './resource'; @@ -56,11 +56,7 @@ export class Rest { Utils.mixin(headers, this.client.options.headers); - new PaginatedResource(this.client, '/stats', headers, envelope, function ( - body: unknown, - headers: Record, - unpacked?: boolean - ) { + new PaginatedResource(this.client, '/stats', headers, envelope, function (body, headers, unpacked) { 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; @@ -87,17 +83,11 @@ export class Rest { }; this.client.http.do( HttpMethods.Get, - this.client, timeUri, headers, null, params as RequestParams, - ( - err?: ErrorInfo | ErrnoException | null, - res?: unknown, - headers?: Record, - unpacked?: boolean - ) => { + (err, res, headers, unpacked) => { if (err) { _callback(err); return; @@ -160,7 +150,7 @@ export class Rest { path, headers, envelope, - async function (resbody: unknown, headers: Record, unpacked?: boolean) { + async function (resbody, headers, unpacked) { return Utils.ensureArray(unpacked ? resbody : decoder(resbody as string & Buffer)); }, /* useHttpPaginatedResponse: */ true diff --git a/src/common/platform.ts b/src/common/platform.ts index 609b232687..6d5a6245bf 100644 --- a/src/common/platform.ts +++ b/src/common/platform.ts @@ -1,5 +1,5 @@ import { IPlatformConfig } from './types/IPlatformConfig'; -import { IHttp } from './types/http'; +import { IHttpStatic } from './types/http'; import { TransportInitialiser } from './lib/transport/connectionmanager'; import IDefaults from './types/IDefaults'; import IWebStorage from './types/IWebStorage'; @@ -31,7 +31,7 @@ export default class Platform { comment above. */ static Crypto: IUntypedCryptoStatic | null; - static Http: typeof IHttp; + static Http: IHttpStatic; static Transports: { order: TransportName[]; // Transport implementations that always come with this platform diff --git a/src/common/types/http.d.ts b/src/common/types/http.ts similarity index 70% rename from src/common/types/http.d.ts rename to src/common/types/http.ts index 0727978b52..a13ba86e49 100644 --- a/src/common/types/http.d.ts +++ b/src/common/types/http.ts @@ -1,41 +1,42 @@ import HttpMethods from '../constants/HttpMethods'; -import { BaseClient } from '../lib/client/baseclient'; -import ErrorInfo from '../lib/types/errorinfo'; +import BaseClient from '../lib/client/baseclient'; +import ErrorInfo, { IPartialErrorInfo } from '../lib/types/errorinfo'; import { Agents } from 'got'; export type PathParameter = string | ((host: string) => string); +export type RequestCallbackHeaders = Partial>; export type RequestCallback = ( error?: ErrnoException | IPartialErrorInfo | null, body?: unknown, - headers?: IncomingHttpHeaders, + headers?: RequestCallbackHeaders, unpacked?: boolean, statusCode?: number ) => void; export type RequestParams = Record | null; -export declare class IHttp { - constructor(options: NormalisedClientOptions); - static methods: Array; - static methodsWithBody: Array; - static methodsWithoutBody: Array; +export interface IHttpStatic { + new (client?: BaseClient): IHttp; + methods: Array; + methodsWithBody: Array; + methodsWithoutBody: Array; +} + +export interface IHttp { supportsAuthHeaders: boolean; supportsLinkHeaders: boolean; agent?: Agents | null; - options: NormalisedClientOptions; Request?: ( method: HttpMethods, - client: BaseClient | null, uri: string, headers: Record | null, params: RequestParams, body: unknown, callback: RequestCallback ) => void; - _getHosts: (client: BaseClient | Realtime) => string[]; + _getHosts: (client: BaseClient) => string[]; do( method: HttpMethods, - client: BaseClient | null, path: PathParameter, headers: Record | null, body: unknown, @@ -44,7 +45,6 @@ export declare class IHttp { ): void; doUri( method: HttpMethods, - client: BaseClient | null, uri: string, headers: Record | null, body: unknown, diff --git a/src/platform/nativescript/index.ts b/src/platform/nativescript/index.ts index f1ea2e7285..119fdcb048 100644 --- a/src/platform/nativescript/index.ts +++ b/src/platform/nativescript/index.ts @@ -8,7 +8,7 @@ import ErrorInfo from '../../common/lib/types/errorinfo'; import BufferUtils from '../web/lib/util/bufferutils'; // @ts-ignore import { createCryptoClass } from '../web/lib/util/crypto'; -import Http from '../web/lib/util/http'; +import Http from '../web/lib/http/http'; // @ts-ignore import Config from './config'; // @ts-ignore @@ -19,6 +19,7 @@ import { getDefaults } from '../../common/lib/util/defaults'; import WebStorage from './lib/util/webstorage'; import PlatformDefaults from '../web/lib/util/defaults'; import msgpack from '../web/lib/util/msgpack'; +import { defaultBundledRequestImplementations } from '../web/lib/http/request'; const Crypto = createCryptoClass(Config, BufferUtils); @@ -34,6 +35,8 @@ for (const clientClass of [DefaultRest, DefaultRealtime]) { clientClass._MsgPack = msgpack; } +Http.bundledRequestImplementations = defaultBundledRequestImplementations; + Logger.initLogHandlers(); Platform.Defaults = getDefaults(PlatformDefaults); diff --git a/src/platform/nodejs/lib/util/http.ts b/src/platform/nodejs/lib/util/http.ts index 7f53e13638..6ed73b76d5 100644 --- a/src/platform/nodejs/lib/util/http.ts +++ b/src/platform/nodejs/lib/util/http.ts @@ -1,14 +1,20 @@ import Platform from 'common/platform'; 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 { + ErrnoException, + IHttpStatic, + PathParameter, + RequestCallback, + RequestParams, +} from '../../../../common/types/http'; import HttpMethods from '../../../../common/constants/HttpMethods'; import got, { Response, Options, CancelableRequest, Agents } from 'got'; import http from 'http'; import https from 'https'; import BaseClient from 'common/lib/client/baseclient'; import BaseRealtime from 'common/lib/client/baserealtime'; -import { NormalisedClientOptions, RestAgentOptions } from 'common/types/ClientOptions'; +import { RestAgentOptions } from 'common/types/ClientOptions'; import { isSuccessCode } from 'common/constants/HttpStatusCodes'; import { shallowEquals, throwMissingModuleError } from 'common/lib/util/utils'; @@ -91,7 +97,7 @@ function getHosts(client: BaseClient): string[] { return Defaults.getHosts(client.options); } -const Http: typeof IHttp = class { +const Http: IHttpStatic = 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]; @@ -99,22 +105,26 @@ const Http: typeof IHttp = class { _getHosts = getHosts; supportsAuthHeaders = true; supportsLinkHeaders = true; - options: NormalisedClientOptions; + private client: BaseClient | null; - constructor(options: NormalisedClientOptions) { - this.options = options || {}; + constructor(client?: BaseClient) { + this.client = client ?? null; } - /* Unlike for doUri, the 'client' param here is mandatory, as it's used to generate the hosts */ do( method: HttpMethods, - client: BaseClient, path: PathParameter, headers: Record | null, body: unknown, params: RequestParams, callback: RequestCallback ): void { + /* Unlike for doUri, the presence of `this.client` here is mandatory, as it's used to generate the hosts */ + const client = this.client; + if (!client) { + throw new Error('http.do called without client'); + } + const uriFromHost = typeof path === 'function' ? path @@ -126,23 +136,15 @@ const Http: typeof IHttp = class { if (currentFallback) { if (currentFallback.validUntil > Date.now()) { /* Use stored fallback */ - this.doUri( - method, - client, - 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 */ - client._currentFallback = null; - this.do(method, client, path, headers, body, params, callback); - return; - } - callback(err, ...args); + this.doUri(method, uriFromHost(currentFallback.host), headers, body, params, (err, ...args) => { + if (err && shouldFallback(err as ErrnoException)) { + /* unstore the fallback and start from the top with the default sequence */ + client._currentFallback = null; + this.do(method, path, headers, body, params, callback); + return; } - ); + callback(err, ...args); + }); return; } else { /* Fallback expired; remove it and fallthrough to normal sequence */ @@ -154,41 +156,32 @@ const Http: typeof IHttp = class { /* see if we have one or more than one host */ if (hosts.length === 1) { - this.doUri(method, client, uriFromHost(hosts[0]), headers, body, params, callback); + this.doUri(method, uriFromHost(hosts[0]), headers, body, params, callback); return; } const tryAHost = (candidateHosts: Array, persistOnSuccess?: boolean) => { const host = candidateHosts.shift(); - this.doUri( - method, - client, - 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 */ - client._currentFallback = { - host: host as string, - validUntil: Date.now() + client.options.timeouts.fallbackRetryTimeout, - }; - } - callback(err, ...args); + this.doUri(method, uriFromHost(host as string), headers, body, params, function (err, ...args) { + if (err && shouldFallback(err as ErrnoException) && candidateHosts.length) { + tryAHost(candidateHosts, true); + return; } - ); + if (persistOnSuccess) { + /* RSC15f */ + client._currentFallback = { + host: host as string, + validUntil: Date.now() + client.options.timeouts.fallbackRetryTimeout, + }; + } + callback(err, ...args); + }); }; tryAHost(hosts); } doUri( method: HttpMethods, - client: BaseClient, uri: string, headers: Record | null, body: unknown, @@ -198,7 +191,8 @@ const Http: typeof IHttp = class { /* 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 = (client && client.options.restAgentOptions) || (Defaults.restAgentOptions as RestAgentOptions); + const agentOptions = + (this.client && this.client.options.restAgentOptions) || (Defaults.restAgentOptions as RestAgentOptions); const doOptions: Options = { headers: headers || undefined, responseType: 'buffer' }; if (!this.agent) { @@ -225,7 +219,9 @@ const Http: typeof IHttp = class { doOptions.agent = this.agent; doOptions.url = uri; - doOptions.timeout = { request: ((client && client.options.timeouts) || Defaults.TIMEOUTS).httpRequestTimeout }; + doOptions.timeout = { + request: ((this.client && this.client.options.timeouts) || Defaults.TIMEOUTS).httpRequestTimeout, + }; // We have our own logic that retries appropriate statuscodes to fallback endpoints, // with timeouts constructed appropriately. Don't want `got` doing its own retries to // the same endpoint, inappropriately retrying 429s, etc @@ -233,40 +229,33 @@ const Http: typeof IHttp = class { (got[method](doOptions) as CancelableRequest) .then((res: Response) => { - handler(uri, params, client, callback)(null, res, res.body); + handler(uri, params, this.client, callback)(null, res, res.body); }) .catch((err: ErrnoException) => { if (err instanceof got.HTTPError) { - handler(uri, params, client, callback)(null, err.response, err.response.body); + handler(uri, params, this.client, callback)(null, err.response, err.response.body); return; } - handler(uri, params, client, callback)(err); + handler(uri, params, this.client, callback)(err); }); } checkConnectivity = (callback: (errorInfo: ErrorInfo | null, connected?: boolean) => void): void => { - if (this.options.disableConnectivityCheck) { + if (this.client?.options.disableConnectivityCheck) { callback(null, true); return; } - const connectivityCheckUrl = this.options.connectivityCheckUrl || Defaults.connectivityCheckUrl; - const connectivityCheckParams = this.options.connectivityCheckParams; - const connectivityUrlIsDefault = !this.options.connectivityCheckUrl; + const connectivityCheckUrl = this.client?.options.connectivityCheckUrl || Defaults.connectivityCheckUrl; + const connectivityCheckParams = this.client?.options.connectivityCheckParams ?? null; + const connectivityUrlIsDefault = !this.client?.options.connectivityCheckUrl; this.doUri( HttpMethods.Get, - null as any, connectivityCheckUrl, null, null, connectivityCheckParams, - function ( - err?: ErrnoException | ErrorInfo | null, - responseText?: unknown, - headers?: any, - unpacked?: boolean, - statusCode?: number - ) { + function (err, responseText, headers, unpacked, statusCode) { if (!err && !connectivityUrlIsDefault) { callback(null, isSuccessCode(statusCode as number)); return; @@ -278,7 +267,6 @@ const Http: typeof IHttp = class { Request?: ( method: HttpMethods, - client: BaseClient | null, uri: string, headers: Record | null, params: RequestParams, diff --git a/src/platform/react-native/index.ts b/src/platform/react-native/index.ts index 3b7d6debeb..e0539aa92a 100644 --- a/src/platform/react-native/index.ts +++ b/src/platform/react-native/index.ts @@ -8,7 +8,7 @@ import ErrorInfo from '../../common/lib/types/errorinfo'; import BufferUtils from '../web/lib/util/bufferutils'; // @ts-ignore import { createCryptoClass } from '../web/lib/util/crypto'; -import Http from '../web/lib/util/http'; +import Http from '../web/lib/http/http'; import configFactory from './config'; // @ts-ignore import Transports from '../web/lib/transport'; @@ -17,6 +17,7 @@ import { getDefaults } from '../../common/lib/util/defaults'; import WebStorage from '../web/lib/util/webstorage'; import PlatformDefaults from '../web/lib/util/defaults'; import msgpack from '../web/lib/util/msgpack'; +import { defaultBundledRequestImplementations } from '../web/lib/http/request'; const Config = configFactory(BufferUtils); @@ -34,6 +35,8 @@ for (const clientClass of [DefaultRest, DefaultRealtime]) { clientClass._MsgPack = msgpack; } +Http.bundledRequestImplementations = defaultBundledRequestImplementations; + Logger.initLogHandlers(); Platform.Defaults = getDefaults(PlatformDefaults); diff --git a/src/platform/web-noencryption/index.ts b/src/platform/web-noencryption/index.ts index 4b8d3ed9de..64dcf02b5e 100644 --- a/src/platform/web-noencryption/index.ts +++ b/src/platform/web-noencryption/index.ts @@ -7,7 +7,7 @@ import ErrorInfo from '../../common/lib/types/errorinfo'; // Platform Specific import BufferUtils from '../web/lib/util/bufferutils'; // @ts-ignore -import Http from '../web/lib/util/http'; +import Http from '../web/lib/http/http'; import Config from '../web/config'; // @ts-ignore import Transports from '../web/lib/transport'; @@ -16,6 +16,7 @@ import { getDefaults } from '../../common/lib/util/defaults'; import WebStorage from '../web/lib/util/webstorage'; import PlatformDefaults from '../web/lib/util/defaults'; import msgpack from '../web/lib/util/msgpack'; +import { defaultBundledRequestImplementations } from '../web/lib/http/request'; Platform.Crypto = null; Platform.BufferUtils = BufferUtils; @@ -28,6 +29,8 @@ for (const clientClass of [DefaultRest, DefaultRealtime]) { clientClass._MsgPack = msgpack; } +Http.bundledRequestImplementations = defaultBundledRequestImplementations; + Logger.initLogHandlers(); Platform.Defaults = getDefaults(PlatformDefaults); diff --git a/src/platform/web/index.ts b/src/platform/web/index.ts index d4566814d5..27d6c9556b 100644 --- a/src/platform/web/index.ts +++ b/src/platform/web/index.ts @@ -8,7 +8,7 @@ import ErrorInfo from '../../common/lib/types/errorinfo'; import BufferUtils from './lib/util/bufferutils'; // @ts-ignore import { createCryptoClass } from './lib/util/crypto'; -import Http from './lib/util/http'; +import Http from './lib/http/http'; import Config from './config'; // @ts-ignore import Transports from './lib/transport'; @@ -17,6 +17,7 @@ import { getDefaults } from '../../common/lib/util/defaults'; import WebStorage from './lib/util/webstorage'; import PlatformDefaults from './lib/util/defaults'; import msgpack from './lib/util/msgpack'; +import { defaultBundledRequestImplementations } from './lib/http/request'; const Crypto = createCryptoClass(Config, BufferUtils); @@ -32,6 +33,8 @@ for (const clientClass of [DefaultRest, DefaultRealtime]) { clientClass._MsgPack = msgpack; } +Http.bundledRequestImplementations = defaultBundledRequestImplementations; + Logger.initLogHandlers(); Platform.Defaults = getDefaults(PlatformDefaults); diff --git a/src/platform/web/lib/util/http.ts b/src/platform/web/lib/http/http.ts similarity index 59% rename from src/platform/web/lib/util/http.ts rename to src/platform/web/lib/http/http.ts index a417d5f83c..626a946758 100644 --- a/src/platform/web/lib/util/http.ts +++ b/src/platform/web/lib/http/http.ts @@ -2,17 +2,17 @@ import Platform from 'common/platform'; import * as Utils from 'common/lib/util/utils'; import Defaults from 'common/lib/util/defaults'; import ErrorInfo, { PartialErrorInfo } from 'common/lib/types/errorinfo'; -import { ErrnoException, IHttp, RequestCallback, RequestParams } from 'common/types/http'; +import { RequestCallback, RequestParams } from 'common/types/http'; import HttpMethods from 'common/constants/HttpMethods'; import BaseClient from 'common/lib/client/baseclient'; import BaseRealtime from 'common/lib/client/baserealtime'; -import XHRRequest from '../transport/xhrrequest'; import XHRStates from 'common/constants/XHRStates'; import Logger from 'common/lib/util/logger'; import { StandardCallback } from 'common/types/utils'; -import fetchRequest from '../transport/fetchrequest'; -import { NormalisedClientOptions } from 'common/types/ClientOptions'; import { isSuccessCode } from 'common/constants/HttpStatusCodes'; +import { ModulesMap } from 'common/lib/client/modulesmap'; + +export type HTTPRequestImplementations = Pick; function shouldFallback(errorInfo: ErrorInfo) { const statusCode = errorInfo.statusCode as number; @@ -40,44 +40,65 @@ function getHosts(client: BaseClient): string[] { return Defaults.getHosts(client.options); } -const Http: typeof IHttp = class { +function createMissingImplementationError() { + return new ErrorInfo( + 'No HTTP request module provided. Provide at least one of the FetchRequest or XHRRequest modules.', + 400, + 40000 + ); +} + +const Http = 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]; + // HTTP request implementations that are available even without a BaseClient object (needed by some tests which directly instantiate `Http` without a client) + static bundledRequestImplementations: HTTPRequestImplementations; checksInProgress: Array> | null = null; - options: NormalisedClientOptions; + private client: BaseClient | null; - constructor(options: NormalisedClientOptions) { - this.options = options || {}; + constructor(client?: BaseClient) { + this.client = client ?? null; + const connectivityCheckUrl = client?.options.connectivityCheckUrl || Defaults.connectivityCheckUrl; + const connectivityCheckParams = client?.options.connectivityCheckParams ?? null; + const connectivityUrlIsDefault = !client?.options.connectivityCheckUrl; + + const requestImplementations = { + ...Http.bundledRequestImplementations, + ...client?._additionalHTTPRequestImplementations, + }; + const xhrRequestImplementation = requestImplementations.XHRRequest; + const fetchRequestImplementation = requestImplementations.FetchRequest; + const hasImplementation = !!(xhrRequestImplementation || fetchRequestImplementation); + + if (!hasImplementation) { + throw createMissingImplementationError(); + } - const connectivityCheckUrl = this.options.connectivityCheckUrl || Defaults.connectivityCheckUrl; - const connectivityCheckParams = this.options.connectivityCheckParams; - const connectivityUrlIsDefault = !this.options.connectivityCheckUrl; - if (Platform.Config.xhrSupported) { + if (Platform.Config.xhrSupported && xhrRequestImplementation) { this.supportsAuthHeaders = true; this.Request = function ( method: HttpMethods, - client: BaseClient | null, uri: string, headers: Record | null, params: RequestParams, body: unknown, callback: RequestCallback ) { - const req = XHRRequest.createRequest( + const req = xhrRequestImplementation.createRequest( uri, headers, params, body, XHRStates.REQ_SEND, - client && client.options.timeouts, + (client && client.options.timeouts) ?? null, method ); req.once('complete', callback); req.exec(); return req; }; - if (this.options.disableConnectivityCheck) { + if (client?.options.disableConnectivityCheck) { this.checkConnectivity = function (callback: (err: null, connectivity: true) => void) { callback(null, true); }; @@ -90,18 +111,11 @@ const Http: typeof IHttp = class { ); this.doUri( HttpMethods.Get, - null as any, connectivityCheckUrl, null, null, connectivityCheckParams, - function ( - err?: ErrorInfo | ErrnoException | null, - responseText?: unknown, - headers?: any, - unpacked?: boolean, - statusCode?: number - ) { + function (err, responseText, headers, unpacked, statusCode) { let result = false; if (!connectivityUrlIsDefault) { result = !err && isSuccessCode(statusCode as number); @@ -114,28 +128,25 @@ const Http: typeof IHttp = class { ); }; } - } else if (Platform.Config.fetchSupported) { + } else if (Platform.Config.fetchSupported && fetchRequestImplementation) { this.supportsAuthHeaders = true; - this.Request = fetchRequest; + this.Request = (method, uri, headers, params, body, callback) => { + fetchRequestImplementation(method, client ?? null, uri, headers, params, body, callback); + }; this.checkConnectivity = function (callback: (err: ErrorInfo | null, connectivity: boolean) => void) { Logger.logAction(Logger.LOG_MICRO, '(Fetch)Http.checkConnectivity()', 'Sending; ' + connectivityCheckUrl); - this.doUri( - HttpMethods.Get, - null as any, - connectivityCheckUrl, - null, - null, - null, - function (err?: ErrorInfo | ErrnoException | null, responseText?: unknown) { - const result = !err && (responseText as string)?.replace(/\n/, '') == 'yes'; - Logger.logAction(Logger.LOG_MICRO, '(Fetch)Http.checkConnectivity()', 'Result: ' + result); - callback(null, result); - } - ); + this.doUri(HttpMethods.Get, connectivityCheckUrl, null, null, null, function (err, responseText) { + const result = !err && (responseText as string)?.replace(/\n/, '') == 'yes'; + Logger.logAction(Logger.LOG_MICRO, '(Fetch)Http.checkConnectivity()', 'Result: ' + result); + callback(null, result); + }); }; } else { - this.Request = (method, client, uri, headers, params, body, callback) => { - callback(new PartialErrorInfo('no supported HTTP transports available', null, 400), null); + this.Request = (method, uri, headers, params, body, callback) => { + const error = hasImplementation + ? new PartialErrorInfo('no supported HTTP transports available', null, 400) + : createMissingImplementationError(); + callback(error, null); }; } } @@ -143,13 +154,18 @@ const Http: typeof IHttp = class { /* Unlike for doUri, the 'client' param here is mandatory, as it's used to generate the hosts */ do( method: HttpMethods, - client: BaseClient, path: string, headers: Record | null, body: unknown, params: RequestParams, callback?: RequestCallback ): void { + /* Unlike for doUri, the presence of `this.client` here is mandatory, as it's used to generate the hosts */ + const client = this.client; + if (!client) { + throw new Error('http.do called without client'); + } + const uriFromHost = typeof path == 'function' ? path @@ -165,24 +181,16 @@ const Http: typeof IHttp = class { callback?.(new PartialErrorInfo('Request invoked before assigned to', null, 500)); return; } - this.Request( - method, - client, - uriFromHost(currentFallback.host), - headers, - params, - body, - (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 */ - client._currentFallback = null; - this.do(method, client, path, headers, body, params, callback); - return; - } - callback?.(err, ...args); + this.Request(method, uriFromHost(currentFallback.host), headers, params, body, (err?, ...args) => { + // 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 */ + client._currentFallback = null; + this.do(method, path, headers, body, params, callback); + return; } - ); + callback?.(err, ...args); + }); return; } else { /* Fallback expired; remove it and fallthrough to normal sequence */ @@ -194,43 +202,34 @@ const Http: typeof IHttp = class { /* if there is only one host do it */ if (hosts.length === 1) { - this.doUri(method, client, uriFromHost(hosts[0]), headers, body, params, callback as RequestCallback); + this.doUri(method, uriFromHost(hosts[0]), headers, body, params, callback as RequestCallback); return; } /* hosts is an array with preferred host plus at least one fallback */ const tryAHost = (candidateHosts: Array, persistOnSuccess?: boolean) => { const host = candidateHosts.shift(); - this.doUri( - method, - client, - 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 */ - client._currentFallback = { - host: host as string, - validUntil: Utils.now() + client.options.timeouts.fallbackRetryTimeout, - }; - } - callback?.(err, ...args); + this.doUri(method, uriFromHost(host as string), headers, body, params, function (err, ...args) { + // 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 */ + client._currentFallback = { + host: host as string, + validUntil: Utils.now() + client.options.timeouts.fallbackRetryTimeout, + }; } - ); + callback?.(err, ...args); + }); }; tryAHost(hosts); } doUri( method: HttpMethods, - client: BaseClient | null, uri: string, headers: Record | null, body: unknown, @@ -241,12 +240,11 @@ const Http: typeof IHttp = class { callback(new PartialErrorInfo('Request invoked before assigned to', null, 500)); return; } - this.Request(method, client, uri, headers, params, body, callback); + this.Request(method, uri, headers, params, body, callback); } Request?: ( method: HttpMethods, - client: BaseClient | null, uri: string, headers: Record | null, params: RequestParams, diff --git a/src/platform/web/lib/transport/fetchrequest.ts b/src/platform/web/lib/http/request/fetchrequest.ts similarity index 85% rename from src/platform/web/lib/transport/fetchrequest.ts rename to src/platform/web/lib/http/request/fetchrequest.ts index 7203ff62a6..68a9febf0f 100644 --- a/src/platform/web/lib/transport/fetchrequest.ts +++ b/src/platform/web/lib/http/request/fetchrequest.ts @@ -1,7 +1,7 @@ import HttpMethods from 'common/constants/HttpMethods'; import BaseClient from 'common/lib/client/baseclient'; import ErrorInfo, { PartialErrorInfo } from 'common/lib/types/errorinfo'; -import { RequestCallback, RequestParams } from 'common/types/http'; +import { RequestCallback, RequestCallbackHeaders, RequestParams } from 'common/types/http'; import Platform from 'common/platform'; import Defaults from 'common/lib/util/defaults'; import * as Utils from 'common/lib/util/utils'; @@ -17,6 +17,16 @@ function getAblyError(responseBody: unknown, headers: Headers) { } } +function convertHeaders(headers: Headers) { + const result: RequestCallbackHeaders = {}; + + headers.forEach((value, key) => { + result[key] = value; + }); + + return result; +} + export default function fetchRequest( method: HttpMethods, client: BaseClient | null, @@ -64,6 +74,7 @@ export default function fetchRequest( } prom.then((body) => { const unpacked = !!contentType && contentType.indexOf('application/x-msgpack') === -1; + const headers = convertHeaders(res.headers); if (!res.ok) { const err = getAblyError(body, res.headers) || @@ -72,9 +83,9 @@ export default function fetchRequest( null, res.status ); - callback(err, body, res.headers, unpacked, res.status); + callback(err, body, headers, unpacked, res.status); } else { - callback(null, body, res.headers, unpacked, res.status); + callback(null, body, headers, unpacked, res.status); } }); }) diff --git a/src/platform/web/lib/http/request/index.ts b/src/platform/web/lib/http/request/index.ts new file mode 100644 index 0000000000..4fccec5b3b --- /dev/null +++ b/src/platform/web/lib/http/request/index.ts @@ -0,0 +1,10 @@ +import { HTTPRequestImplementations } from '../http'; +import XHRRequest from './xhrrequest'; +import fetchRequest from './fetchrequest'; + +export const defaultBundledRequestImplementations: HTTPRequestImplementations = { + XHRRequest: XHRRequest, + FetchRequest: fetchRequest, +}; + +export const modulesBundledRequestImplementations: HTTPRequestImplementations = {}; diff --git a/src/platform/web/lib/transport/xhrrequest.ts b/src/platform/web/lib/http/request/xhrrequest.ts similarity index 100% rename from src/platform/web/lib/transport/xhrrequest.ts rename to src/platform/web/lib/http/request/xhrrequest.ts diff --git a/src/platform/web/lib/transport/xhrpollingtransport.ts b/src/platform/web/lib/transport/xhrpollingtransport.ts index 4d3b2110d3..bf0f13befd 100644 --- a/src/platform/web/lib/transport/xhrpollingtransport.ts +++ b/src/platform/web/lib/transport/xhrpollingtransport.ts @@ -1,6 +1,6 @@ import Platform from '../../../../common/platform'; import CometTransport from '../../../../common/lib/transport/comettransport'; -import XHRRequest from './xhrrequest'; +import XHRRequest from '../http/request/xhrrequest'; import ConnectionManager, { TransportParams, TransportStorage } from 'common/lib/transport/connectionmanager'; import Auth from 'common/lib/client/auth'; import { RequestParams } from 'common/types/http'; diff --git a/src/platform/web/lib/transport/xhrstreamingtransport.ts b/src/platform/web/lib/transport/xhrstreamingtransport.ts index 9ef8631fec..5dd77e6f6d 100644 --- a/src/platform/web/lib/transport/xhrstreamingtransport.ts +++ b/src/platform/web/lib/transport/xhrstreamingtransport.ts @@ -1,6 +1,6 @@ import CometTransport from '../../../../common/lib/transport/comettransport'; import Platform from '../../../../common/platform'; -import XHRRequest from './xhrrequest'; +import XHRRequest from '../http/request/xhrrequest'; import ConnectionManager, { TransportParams, TransportStorage } from 'common/lib/transport/connectionmanager'; import Auth from 'common/lib/client/auth'; import { RequestParams } from 'common/types/http'; diff --git a/src/platform/web/modules.ts b/src/platform/web/modules.ts index a3bf018afe..0465405a90 100644 --- a/src/platform/web/modules.ts +++ b/src/platform/web/modules.ts @@ -7,7 +7,7 @@ import ErrorInfo from '../../common/lib/types/errorinfo'; // Platform Specific import BufferUtils from './lib/util/bufferutils'; // @ts-ignore -import Http from './lib/util/http'; +import Http from './lib/http/http'; import Config from './config'; // @ts-ignore import { ModulesTransports } from './lib/transport'; @@ -15,6 +15,7 @@ import Logger from '../../common/lib/util/logger'; import { getDefaults } from '../../common/lib/util/defaults'; import WebStorage from './lib/util/webstorage'; import PlatformDefaults from './lib/util/defaults'; +import { modulesBundledRequestImplementations } from './lib/http/request'; Platform.BufferUtils = BufferUtils; Platform.Http = Http; @@ -22,6 +23,8 @@ Platform.Config = Config; Platform.Transports = ModulesTransports; Platform.WebStorage = WebStorage; +Http.bundledRequestImplementations = modulesBundledRequestImplementations; + Logger.initLogHandlers(); Platform.Defaults = getDefaults(PlatformDefaults); @@ -45,5 +48,7 @@ export * from './modules/presencemessage'; export * from './modules/msgpack'; export * from './modules/realtimepresence'; 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 { BaseRest, BaseRealtime, ErrorInfo }; diff --git a/src/platform/web/modules/http.ts b/src/platform/web/modules/http.ts new file mode 100644 index 0000000000..24b664f30d --- /dev/null +++ b/src/platform/web/modules/http.ts @@ -0,0 +1,2 @@ +export { default as XHRRequest } from '../lib/http/request/xhrrequest'; +export { default as FetchRequest } from '../lib/http/request/fetchrequest'; diff --git a/test/browser/modules.test.js b/test/browser/modules.test.js index 501bd09f9d..bf56cf4fda 100644 --- a/test/browser/modules.test.js +++ b/test/browser/modules.test.js @@ -17,6 +17,9 @@ import { XHRPolling, XHRStreaming, WebSocketTransport, + FetchRequest, + XHRRequest, + MessageInteractions, } from '../../build/modules/index.js'; describe('browser/modules', function () { @@ -45,23 +48,21 @@ describe('browser/modules', function () { }); describe('without any modules', () => { - 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'); + for (const clientClass of [BaseRest, BaseRealtime]) { + describe(clientClass.name, () => { + it('throws an error due to the absence of an HTTP module', () => { + expect(() => new clientClass(ablyClientOptions(), {})).to.throw( + 'No HTTP request module provided. Provide at least one of the FetchRequest or XHRRequest modules.' + ); + }); }); - }); + } }); describe('Rest', () => { describe('BaseRest without explicit Rest', () => { it('offers REST functionality', async () => { - const client = new BaseRest(ablyClientOptions(), {}); + const client = new BaseRest(ablyClientOptions(), { FetchRequest }); const time = await client.time(); expect(time).to.be.a('number'); }); @@ -69,7 +70,7 @@ describe('browser/modules', function () { describe('BaseRealtime with Rest', () => { it('offers REST functionality', async () => { - const client = new BaseRealtime(ablyClientOptions(), { WebSocketTransport, Rest }); + const client = new BaseRealtime(ablyClientOptions(), { WebSocketTransport, FetchRequest, Rest }); const time = await client.time(); expect(time).to.be.a('number'); }); @@ -77,7 +78,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(), { WebSocketTransport }); + const client = new BaseRealtime(ablyClientOptions(), { WebSocketTransport, FetchRequest }); expect(() => client.time()).to.throw('Rest module not provided'); }); }); @@ -214,10 +215,10 @@ describe('browser/modules', function () { describe('Crypto', () => { describe('without Crypto', () => { async function testThrowsAnErrorWhenGivenChannelOptionsWithACipher(clientClassConfig) { - const client = new clientClassConfig.clientClass( - ablyClientOptions(), - clientClassConfig.additionalModules ?? {} - ); + const client = new clientClassConfig.clientClass(ablyClientOptions(), { + ...clientClassConfig.additionalModules, + FetchRequest, + }); const key = await generateRandomKey(); expect(() => client.channels.get('channel', { cipher: { key } })).to.throw('Crypto module not provided'); } @@ -242,7 +243,7 @@ describe('browser/modules', function () { // 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, { WebSocketTransport }); + const rxClient = new BaseRealtime(clientOptions, { WebSocketTransport, FetchRequest }); const rxChannel = rxClient.channels.get('channel'); await rxChannel.attach(); @@ -252,7 +253,8 @@ describe('browser/modules', function () { const txMessage = { name: 'message', data: 'data' }; const txClient = new clientClassConfig.clientClass(clientOptions, { - ...(clientClassConfig.additionalModules ?? {}), + ...clientClassConfig.additionalModules, + FetchRequest, Crypto, }); const txChannel = txClient.channels.get('channel', encryptionChannelOptions); @@ -286,7 +288,7 @@ describe('browser/modules', function () { const channelName = 'channel'; const channel = rest.channels.get(channelName); const contentTypeUsedForPublishPromise = new Promise((resolve, reject) => { - rest.http.do = (method, client, path, headers, body, params, callback) => { + rest.http.do = (method, path, headers, body, params, callback) => { if (!(method == 'post' && path == `/channels/${channelName}/messages`)) { return; } @@ -318,7 +320,7 @@ describe('browser/modules', function () { describe('without MsgPack', () => { describe('BaseRest', () => { it('uses JSON', async () => { - const client = new BaseRest(ablyClientOptions({ useBinaryProtocol: true }), {}); + const client = new BaseRest(ablyClientOptions({ useBinaryProtocol: true }), { FetchRequest }); await testRestUsesContentType(client, 'application/json'); }); }); @@ -327,6 +329,7 @@ describe('browser/modules', function () { it('uses JSON', async () => { const client = new BaseRealtime(ablyClientOptions({ useBinaryProtocol: true, autoConnect: false }), { WebSocketTransport, + FetchRequest, }); await testRealtimeUsesFormat(client, 'json'); }); @@ -337,6 +340,7 @@ describe('browser/modules', function () { describe('BaseRest', () => { it('uses MessagePack', async () => { const client = new BaseRest(ablyClientOptions({ useBinaryProtocol: true }), { + FetchRequest, MsgPack, }); await testRestUsesContentType(client, 'application/x-msgpack'); @@ -347,6 +351,7 @@ describe('browser/modules', function () { it('uses MessagePack', async () => { const client = new BaseRealtime(ablyClientOptions({ useBinaryProtocol: true, autoConnect: false }), { WebSocketTransport, + FetchRequest, MsgPack, }); await testRealtimeUsesFormat(client, 'msgpack'); @@ -359,7 +364,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(), { WebSocketTransport }); + const client = new BaseRealtime(ablyClientOptions(), { WebSocketTransport, FetchRequest }); const channel = client.channels.get('channel'); expect(() => channel.presence).to.throw('RealtimePresence module not provided'); @@ -368,12 +373,15 @@ describe('browser/modules', function () { describe('BaseRealtime with RealtimePresence', () => { it('offers realtime presence functionality', async () => { - const rxChannel = new BaseRealtime(ablyClientOptions(), { WebSocketTransport, RealtimePresence }).channels.get( - 'channel' - ); + const rxChannel = new BaseRealtime(ablyClientOptions(), { + WebSocketTransport, + FetchRequest, + RealtimePresence, + }).channels.get('channel'); const txClientId = randomString(); const txChannel = new BaseRealtime(ablyClientOptions({ clientId: txClientId }), { WebSocketTransport, + FetchRequest, RealtimePresence, }).channels.get('channel'); @@ -435,6 +443,14 @@ describe('browser/modules', function () { describe('Transports', () => { describe('BaseRealtime', () => { + describe('without a transport module', () => { + it('throws an error due to absence of a transport module', () => { + expect(() => new BaseRealtime(ablyClientOptions(), { FetchRequest })).to.throw( + 'no requested transports available' + ); + }); + }); + for (const scenario of [ { moduleMapKey: 'WebSocketTransport', transportModule: WebSocketTransport, transportName: 'web_socket' }, { moduleMapKey: 'XHRPolling', transportModule: XHRPolling, transportName: 'xhr_polling' }, @@ -445,6 +461,7 @@ describe('browser/modules', function () { const realtime = new BaseRealtime( ablyClientOptions({ autoConnect: false, transports: [scenario.transportName] }), { + FetchRequest, [scenario.moduleMapKey]: scenario.transportModule, } ); @@ -468,4 +485,112 @@ describe('browser/modules', function () { } }); }); + + describe('HTTP request implementations', () => { + describe('with multiple HTTP request implementations', () => { + it('prefers XHR', async () => { + let usedXHR = false; + + const XHRRequestSpy = class XHRRequestSpy extends XHRRequest { + static createRequest(...args) { + usedXHR = true; + return super.createRequest(...args); + } + }; + + const rest = new BaseRest(ablyClientOptions(), { FetchRequest, XHRRequest: XHRRequestSpy }); + await rest.time(); + + expect(usedXHR).to.be.true; + }); + }); + }); + + describe('MessageInteractions', () => { + 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 channel = realtime.channels.get('channel'); + await channel.attach(); + + const subscribeReceivedMessagePromise = new Promise((resolve) => channel.subscribe(resolve)); + + await channel.publish('message', 'body'); + + const subscribeReceivedMessage = await subscribeReceivedMessagePromise; + expect(subscribeReceivedMessage.data).to.equal('body'); + }); + + it('throws an error when attempting to subscribe to channel events using a MessageFilter', async () => { + const realtime = new BaseRealtime(ablyClientOptions(), { WebSocketTransport, FetchRequest }); + const channel = realtime.channels.get('channel'); + + let thrownError = null; + try { + await channel.subscribe({ clientId: 'someClientId' }, () => {}); + } catch (error) { + thrownError = error; + } + + expect(thrownError).not.to.be.null; + expect(thrownError.message).to.equal('MessageInteractions module not provided'); + }); + }); + + describe('with MessageInteractions', () => { + it('can take a MessageFilter argument when subscribing to and unsubscribing from channel events', async () => { + const realtime = new BaseRealtime(ablyClientOptions(), { + WebSocketTransport, + FetchRequest, + MessageInteractions, + }); + const channel = realtime.channels.get('channel'); + + await channel.attach(); + + // Test `subscribe` with a filter: send two messages with different clientIds, and check that unfiltered subscription receives both messages but clientId-filtered subscription only receives the matching one. + const messageFilter = { clientId: 'someClientId' }; // note that `unsubscribe` compares filter by reference, I found that a bit surprising + + const filteredSubscriptionReceivedMessages = []; + channel.subscribe(messageFilter, (message) => { + filteredSubscriptionReceivedMessages.push(message); + }); + + const unfilteredSubscriptionReceivedFirstTwoMessagesPromise = new Promise((resolve) => { + const receivedMessages = []; + channel.subscribe(function listener(message) { + receivedMessages.push(message); + if (receivedMessages.length === 2) { + channel.unsubscribe(listener); + resolve(); + } + }); + }); + + await channel.publish(await decodeMessage({ clientId: 'someClientId' })); + await channel.publish(await decodeMessage({ clientId: 'someOtherClientId' })); + await unfilteredSubscriptionReceivedFirstTwoMessagesPromise; + + expect(filteredSubscriptionReceivedMessages.length).to.equal(1); + expect(filteredSubscriptionReceivedMessages[0].clientId).to.equal('someClientId'); + + // Test `unsubscribe` with a filter: call `unsubscribe` with the clientId filter, publish a message matching the filter, check that only the unfiltered listener recieves it + channel.unsubscribe(messageFilter); + + const unfilteredSubscriptionReceivedNextMessagePromise = new Promise((resolve) => { + channel.subscribe(function listener() { + channel.unsubscribe(listener); + resolve(); + }); + }); + + await channel.publish(await decodeMessage({ clientId: 'someClientId' })); + await unfilteredSubscriptionReceivedNextMessagePromise; + + expect(filteredSubscriptionReceivedMessages.length).to./* (still) */ equal(1); + }); + }); + }); + }); }); diff --git a/test/realtime/auth.test.js b/test/realtime/auth.test.js index 92dde3019a..affb68bae6 100644 --- a/test/realtime/auth.test.js +++ b/test/realtime/auth.test.js @@ -22,7 +22,7 @@ define(['ably', 'shared_helper', 'async', 'chai'], function (Ably, helper, async */ function getJWT(params, callback) { var authUrl = echoServer + '/createJWT'; - http.doUri('get', null, authUrl, null, null, params, function (err, body) { + http.doUri('get', authUrl, null, null, params, function (err, body) { if (err) { callback(err, null); } diff --git a/test/rest/http.test.js b/test/rest/http.test.js index 315f751551..dcd7ee8262 100644 --- a/test/rest/http.test.js +++ b/test/rest/http.test.js @@ -25,7 +25,7 @@ define(['ably', 'shared_helper', 'chai'], function (Ably, helper, chai) { var originalDo = rest.http.do; // Intercept Http.do with test - function testRequestHandler(method, rest, path, headers, body, params, callback) { + function testRequestHandler(method, path, headers, body, params, callback) { expect('X-Ably-Version' in headers, 'Verify version header exists').to.be.ok; expect('Ably-Agent' in headers, 'Verify agent header exists').to.be.ok; @@ -47,7 +47,7 @@ define(['ably', 'shared_helper', 'chai'], function (Ably, helper, chai) { expect(headers['Ably-Agent'].indexOf('nodejs') > -1, 'Verify agent').to.be.ok; } - originalDo.call(rest.http, method, rest, path, headers, body, params, callback); + originalDo.call(rest.http, method, path, headers, body, params, callback); } rest.http.do = testRequestHandler; diff --git a/test/rest/message.test.js b/test/rest/message.test.js index 389e0b7d74..090e6eba70 100644 --- a/test/rest/message.test.js +++ b/test/rest/message.test.js @@ -157,8 +157,8 @@ define(['ably', 'shared_helper', 'async', 'chai'], function (Ably, helper, async originalPublish.apply(channel, arguments); }; - Ably.Rest.Platform.Http.doUri = function (method, rest, uri, headers, body, params, callback) { - originalDoUri(method, rest, uri, headers, body, params, function (err) { + Ably.Rest.Platform.Http.doUri = function (method, uri, headers, body, params, callback) { + originalDoUri(method, uri, headers, body, params, function (err) { if (err) { callback(err); return; diff --git a/test/rest/request.test.js b/test/rest/request.test.js index 7055f46191..c123e94606 100644 --- a/test/rest/request.test.js +++ b/test/rest/request.test.js @@ -25,7 +25,7 @@ define(['ably', 'shared_helper', 'async', 'chai'], function (Ably, helper, async restTestOnJsonMsgpack('request_version', function (rest) { const version = 150; // arbitrarily chosen - function testRequestHandler(_, __, ___, headers) { + function testRequestHandler(_, __, headers) { try { expect('X-Ably-Version' in headers, 'Verify version header exists').to.be.ok; expect(headers['X-Ably-Version']).to.equal(version.toString(), 'Verify version number sent in request'); diff --git a/tsconfig.json b/tsconfig.json index b3a547de9d..1146fcb184 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,7 +2,7 @@ "compilerOptions": { "target": "es5", "module": "commonjs", - "lib": ["ES5", "DOM", "webworker"], + "lib": ["ES5", "DOM", "DOM.Iterable", "webworker"], "strict": true, "esModuleInterop": true, "skipLibCheck": true,