import { CallRequestDTO } from './CallRequestDTO' import { CallResponseDTO, CallResponseError } from './CallResponseDTO' import { ClientManager, ClientManagerOptions } from './manager/ClientManager' import { BrowserCache } from './cache/BrowserStorageCache' import { InMemoryCache } from './cache/InMemoryCache' import { HTTPTransport } from './transports/HTTPTransport' import { WebSocketTransport } from './transports/WebSocketTransport' import { CallResponseInterceptor } from './CallResponseInterceptor' import { CallRequestInterceptor } from './CallRequestInterceptor' import { Transport, TransportType } from './transports/Transport' import { RPCClientIdentity } from './RPCClientIdentity' import { cloneDeep, merge } from 'lodash-es' import { parseRequestShorthand } from './utils/request' import { Vent } from './utils/vent' import { makeCallRequestKey } from './utils/cache-utils' import { UnsubscribeCallback } from './types' import { GetDebugLogger } from './utils/debug-logger' const debug = GetDebugLogger('rpc:RPCClient') const ERRORS = { HTTP_HOST_OR_TRANSPORT_REQUIRED: `http host or transport is required`, INTERCEPTOR_MUSTBE_FUNC: `interceptors must be a function`, } export interface CallOptions { /** Milliseconds before timeout, default is 10s if unspecified */ timeout?: number /** Specify a transport (http or websocket) to be used for this rpc-call, if the transport is unavailable the call will throw */ transport?: 'http' | 'websocket' | TransportType } export interface RPCClient { call( request: CallRequestDTO | string, args?: Args, opts?: CallOptions, ): Promise> clearCache(request?: CallRequestDTO): void getCallCache( request: CallRequestDTO | string, args?: Args, ): CallResponseDTO | undefined getIdentity(): RPCClientIdentity | undefined getInFlightCallCount(): number getWebSocketConnected(): boolean makeProcedure( request: CallRequestDTO | string, ): (args?: any) => Promise> registerResponseInterceptor( responseInterceptor: CallResponseInterceptor, ): UnsubscribeCallback registerRequestInterceptor( requestInterceptor: CallRequestInterceptor, ): UnsubscribeCallback registerWebSocketConnectionStatusChangeListener( cb: (connected: boolean) => void, ): UnsubscribeCallback sendClientMessageToServer(msg: any): void setIdentity(identity?: RPCClientIdentity): void setIdentityMetadata(metadata?: Record): void /** Subscribe a callback handler for a specific RPC call */ subscribe( filter: { request: CallRequestDTO | string; args?: any }, handler: (response: CallResponseDTO) => void, ): UnsubscribeCallback subscribeToServerMessages(handler: (msg: any) => void): UnsubscribeCallback transports(): { http: HTTPTransport | undefined websocket: WebSocketTransport | undefined } /** Unsubscribe a callback handler for a specific RPC call */ unsubscribe( filter: { request: CallRequestDTO | string; args?: any }, handler: (response: CallResponseDTO) => void, ): void unsubscribeFromServerMessages(handler: (msg: any) => void): void /** Cleanup all resources. Call this when tearing down the client. */ destroy(): void } export interface RPCClientOptions { /** Defaults to memory */ cacheType?: 'browser' | 'memory' cacheMaxAgeMs?: number /** Defaults to 10000ms */ deadlineMs?: number hosts?: { // if http host URI provided, will use default HTTPTransport http?: string // if ws host URI provided, will use default WebSocketTransport websocket?: string } /** An optional callback when a Transport request fails */ onTransportRequestError?: ClientManagerOptions['onTransportRequestError'] onWebSocketConnectionStatusChange?: (connected: boolean) => void requestInterceptor?: CallRequestInterceptor responseInterceptor?: CallResponseInterceptor transports?: { // if http transport provided, will use over the default Transport http?: Transport // if ws transport provided, will use over the default Transport websocket?: Transport } transportOptions?: { preferredTransport?: TransportType websocket?: { /** * Automatically connect to the websocket when the client is instantiated * Defaults to true. * If false, the developer is responsible for calling connect() on the * WebSocketTransport. */ autoConnect?: boolean } } } export class RPCClient implements RPCClient { static DEFAULT_DEADLINE_MS = 10000 private callManager: ClientManager private identity: RPCClientIdentity | undefined private readonly httpTransport?: HTTPTransport private readonly webSocketTransport?: WebSocketTransport private webSocketConnectionChangeListeners: ((connected: boolean) => void)[] = [] private vent = new Vent() constructor(readonly options: RPCClientOptions) { if (!options?.hosts?.http && !options?.transports?.http) { throw new Error(ERRORS.HTTP_HOST_OR_TRANSPORT_REQUIRED) } if ( options.requestInterceptor && typeof options.requestInterceptor !== 'function' ) { throw new Error(ERRORS.INTERCEPTOR_MUSTBE_FUNC) } if ( options.responseInterceptor && typeof options.responseInterceptor !== 'function' ) { throw new Error(ERRORS.INTERCEPTOR_MUSTBE_FUNC) } let cache const cacheOptions = { cacheMaxAgeMs: options?.cacheMaxAgeMs } if (options.cacheType == 'browser') { cache = new BrowserCache(cacheOptions) } else { cache = new InMemoryCache(cacheOptions) } const transports: Transport[] = [] if (options.transports?.websocket) { transports.push(options.transports.websocket) } else if (options?.hosts?.websocket) { this.webSocketTransport = new WebSocketTransport({ host: options.hosts.websocket, onConnectionStatusChange: this.onWebSocketConnectionStatusChange, rpcOptions: options, }) transports.push(this.webSocketTransport) } // put HTTP last because transports list determines the order of use if (options.transports?.http) { transports.push(options.transports.http) } else if (options?.hosts?.http) { this.httpTransport = new HTTPTransport({ host: options.hosts.http, rpcOptions: options, }) transports.push(this.httpTransport) } this.callManager = new ClientManager({ cache, deadlineMs: options.deadlineMs || RPCClient.DEFAULT_DEADLINE_MS, onTransportRequestError: options.onTransportRequestError, requestInterceptor: options.requestInterceptor, responseInterceptor: options.responseInterceptor, transports, transportOptions: options.transportOptions, }) } private onWebSocketConnectionStatusChange = (connected: boolean) => { if (this.options.onWebSocketConnectionStatusChange) { this.options.onWebSocketConnectionStatusChange(connected) } this.webSocketConnectionChangeListeners.forEach((cb) => cb(connected)) } public call = async ( request: CallRequestDTO | string, args?: Args, opts?: CallOptions, ): Promise> => { if (!request) throw new Error('RPCClient.call(request) requires a "request" param') let req: CallRequestDTO if (typeof request === 'string') req = parseRequestShorthand(request) else req = request as CallRequestDTO if (args) { req.args = args } const requestDTO = new CallRequestDTO(req) if (!requestDTO.procedure && !requestDTO.identity) { throw new TypeError( 'RPCClient#call requires a "identity" or "procedure" prop and received neither', ) } if (!requestDTO.identity) requestDTO.identity = {} // don't overwrite this.identity with the requestDTO identity // Use cloneDeep to ensure nested objects (like metadata) don't share references requestDTO.identity = merge(cloneDeep(this.identity), requestDTO.identity) const callResponse = await this.callManager.manageClientRequest( requestDTO, opts, ) if (!callResponse.success) { throw new CallResponseError(callResponse, requestDTO, opts) } const callRequestKey = makeCallRequestKey(req) this.vent.publish(callRequestKey, callResponse?.data) return callResponse as CallResponseDTO } /** * Request is optional, if provided then only the cache for that request is cleared * @param request {CallRequestDTO} */ public clearCache = (request?: CallRequestDTO) => { this.callManager.clearCache(request) } public getCallCache = ( request: CallRequestDTO | string, args?: Args, ) => { let req: CallRequestDTO if (typeof request === 'string') req = parseRequestShorthand(request) else req = request as CallRequestDTO if (args) { req.args = args } const cachedResponse = this.callManager.getCachedResponse( req, ) as CallResponseDTO if (cachedResponse) return cachedResponse return undefined } public getIdentity = () => this.identity ? cloneDeep(this.identity) : this.identity public getInFlightCallCount = () => { return this.callManager.getInFlightCallCount() } public getWebSocketConnected = () => { return !!this?.webSocketTransport?.isConnected() } public makeProcedure = ( request: CallRequestDTO | string, ): ((args?: any) => Promise>) => { const self = this let req: CallRequestDTO if (typeof request === 'string') req = parseRequestShorthand(request) else req = request as CallRequestDTO return function curriedProcedure(args?: any) { return self.call({ ...req, args }) } } public registerResponseInterceptor = ( responseInterceptor: CallResponseInterceptor, ) => { return this.callManager.addResponseInterceptor(responseInterceptor) } public registerRequestInterceptor = ( requestInterceptor: CallRequestInterceptor, ) => { return this.callManager.addRequestInterceptor(requestInterceptor) } public registerWebSocketConnectionStatusChangeListener = ( cb: (connected: boolean) => void, ) => { this.webSocketConnectionChangeListeners.push(cb) return () => { this.webSocketConnectionChangeListeners = this.webSocketConnectionChangeListeners.filter((_cb) => _cb !== cb) } } public sendClientMessageToServer = (msg: any) => { if (!this.webSocketTransport) { console.warn( 'RPCClient.sendClientMessageToServer() unable to send because RPCClient has no websocket configuration', ) return } if (this.webSocketTransport.isConnected()) { this.webSocketTransport.sendClientMessageToServer(msg) } else { debug( 'RPCClient.sendClientMessageToServer() cannot send because the websocket is not connected', ) } } public setIdentity = (identity?: RPCClientIdentity) => { let newIdentity = cloneDeep(identity) this.identity = identity this.callManager.setIdentity(newIdentity) } public setIdentityMetadata = (metadata?: Record) => { this.identity ??= {} const newIdentity = cloneDeep(this.identity) newIdentity.metadata = metadata this.identity.metadata = metadata this.callManager.setIdentity(newIdentity) } public subscribe( filter: { request: CallRequestDTO | string; args?: any }, handler: (response: CallResponseDTO) => void, ) { const request = typeof filter.request === 'string' ? parseRequestShorthand(filter.request) : filter.request const callRequestKey = makeCallRequestKey({ ...request, args: filter.args }) return this.vent.subscribe(callRequestKey, handler) } transports = () => { return { http: this.httpTransport, websocket: this.webSocketTransport, } } public unsubscribe( filter: { request: CallRequestDTO | string; args?: any }, handler: (response: CallResponseDTO) => void, ) { const request = typeof filter.request === 'string' ? parseRequestShorthand(filter.request) : filter.request const callRequestKey = makeCallRequestKey({ ...request, args: filter.args }) this.vent.unsubscribe(callRequestKey, handler) } public subscribeToServerMessages(handler: (msg: any) => void) { if (!this.webSocketTransport) { console.warn( 'RPCClient.subscribeToServerMessages() cannot subscribe because RPCClient has no websocket configuration', ) return () => {} } return this.webSocketTransport.subscribeToServerMessages(handler) } public unsubscribeFromServerMessages(handler: (msg: any) => void) { this.webSocketTransport?.unsubscribeFromServerMessages(handler) } public destroy = () => { debug('destroy') this.callManager.destroy() this.webSocketTransport?.destroy() this.httpTransport?.destroy() this.webSocketConnectionChangeListeners = [] this.identity = undefined } }