UNPKG

@dasf/dasf-messaging

Version:

Typescript bindings for the DASF RPC messaging protocol.

622 lines (557 loc) 20.1 kB
// SPDX-FileCopyrightText: 2022-2024 Helmholtz Centre Potsdam GFZ German Research Centre for Geosciences, Potsdam, Germany // SPDX-FileCopyrightText: 2024 Helmholtz-Zentrum hereon GmbH // // SPDX-License-Identifier: Apache-2.0 import { DASFModuleResponse, DASFProgressReport, DASFAcknowledgment, DASFModuleRequest, DASFModuleRequestReceipt, PropertyKeys, MessageType, ModuleApiInfo, } from './messages'; import { DASFUrlBuilder } from './urls'; import { JsonConvert } from 'json2typescript'; import type { JSONSchema7 } from 'json-schema'; /** * Connection to a DASF Backend module via websocket * * To create a connection to a remove message broker, provide an instance of * :class:`DASFUrlBuilder` * * **Example** * * ``` * import { DASFConnection, WebsocketUrlBuilder } from '@dasf/dasf-messaging' * const connection = new DASFConnection( * new WebsocketUrlBuilder( * 'ws://localhost:8080/ws', * 'some-topic' * ) * ) * * // send request via the connection and log the response * connection.sendRequest({func_name: 'version_info'}).then( * (response) => console.log(response) * ) * ``` */ export class DASFConnection { private outgoingChannel!: WebSocket; private incomingChannel!: WebSocket; private DASFProducerURL: string; private DASFConsumerURL: string; private consumeTopic: string; public jsonCoder: JsonConvert; private contextCounter = 0; private responseResolvers: Map< string, (message: DASFModuleResponse) => void > = new Map(); private progressCallbacks: Map< string, (value: { message: DASFProgressReport; props?: object }) => void > = new Map(); private responseRejectHandler: Map< string, (receipt: DASFModuleRequestReceipt) => void > = new Map(); private finishedContexts: Set<string> = new Set(); private contextResponseFragments: Map<string, DASFModuleResponse[]> = new Map(); private pendingRequests: DASFModuleRequest[] = []; private waitingForPong = false; private connectionTimeout = 10000; // 10sec private connectionError = ''; private onConnectionError?: (errorMsg: string) => void = undefined; private startedReconnectOutgoing = true; private startedReconnectIncoming = true; /** * @param urlBuilder - A builder for the consumer and producer websocket url * @param onConnectionError - A callback for the case when no connection could be established */ constructor( urlBuilder: DASFUrlBuilder, onConnectionError?: (errorMsg: string) => void, ) { //tenant/namespace/topic/subscription //public/default/producerTopic/registry/ this.onConnectionError = onConnectionError; this.consumeTopic = urlBuilder.consumeTopic; this.DASFProducerURL = urlBuilder.DASFProducerURL; this.DASFConsumerURL = urlBuilder.DASFConsumerURL; this.jsonCoder = new JsonConvert(); this.connect(); setInterval(() => this.checkConnection(), this.connectionTimeout); } private connect() { this.startedReconnectIncoming = true; this.startedReconnectOutgoing = true; this.outgoingChannel = new WebSocket(this.DASFProducerURL); this.incomingChannel = new WebSocket(this.DASFConsumerURL); this.registerListener(); } private isConnected(): boolean { return ( this.outgoingChannel && this.incomingChannel && this.outgoingChannel.readyState == WebSocket.OPEN && this.incomingChannel.readyState == WebSocket.OPEN ); } private isConnecting(): boolean { return ( this.outgoingChannel && this.incomingChannel && this.outgoingChannel.readyState != WebSocket.OPEN && this.incomingChannel.readyState != WebSocket.OPEN ); } /** * Do something as soon as the websocket connection has been established * * This property returns a promise that resolves with the established * connection. You can use this to do something right after the connection has * been created. * * ``` * import { DASFConnection, WebsocketUrlBuilder } from '@dasf/dasf-messaging' * const connection = new DASFConnection( * new WebsocketUrlBuilder( * 'ws://localhost:8080/ws', * 'some-topic' * ) * ) * * // log to the console when the connection has been connected * connection.connected.then( * (connection: DASFConnection) => console.log("Websocket connection has been established.") * ).catch( * (error: Error) => console.log(error) * ) * ``` * * */ public get connected(): Promise<DASFConnection> { const waitForConnection = ( resolve: (value: DASFConnection | PromiseLike<DASFConnection>) => void, reject: (error: Error) => void, ) => { if (this.hasConnectionError()) { reject(new Error(this.connectionError)); } else if (this.assureConnected()) { resolve(this); } else { setTimeout(() => waitForConnection(resolve, reject), 100); } }; return new Promise(waitForConnection); } private assureConnected(): boolean { if (this.isConnected()) { return true; } else { if (this.startedReconnectIncoming || this.startedReconnectOutgoing) { return false; } else if (!this.isConnecting()) { this.connect(); } return false; } } public hasPendingRequests(): boolean { return this.pendingRequests.length > 0; } private sendPendingRequests(): void { if (this.hasPendingRequests()) { // console.log('sending pending request (' + this.pendingRequests.length + ') ...'); const request = this.pendingRequests.shift(); this.outgoingChannel.send(JSON.stringify(request)); setTimeout(() => this.sendPendingRequests(), 200); } } protected registerListener(): void { const onError = (ev: Event) => { this.startedReconnectOutgoing = false; this.startedReconnectIncoming = false; if (!this.hasConnectionError() && this.isConnecting()) { this.connectionError = 'Connection to ' + (ev.target as WebSocket).url + ' failed.'; if (this.onConnectionError) { this.onConnectionError(this.connectionError); } } }; this.outgoingChannel.addEventListener('error', onError); this.incomingChannel.addEventListener('error', onError); this.outgoingChannel.addEventListener('open', () => { this.startedReconnectOutgoing = false; // console.log("DASF outgoing channel established"); // connection established - reset past connection errors this.connectionError = ''; // listen for acknowledgments of outgoing messages this.outgoingChannel.addEventListener('message', (ev: MessageEvent) => { // console.log("[DASF outgoing] request acknowledged"); const receipt: DASFModuleRequestReceipt = this.jsonCoder.deserializeObject( JSON.parse(ev.data), DASFModuleRequestReceipt, ); if (receipt.isOk()) { // the request was acknowledged - remove reject handler this.responseRejectHandler.delete(receipt.context); } else { // the request returned an error console.error(receipt); // remove the registered handler - since there won't be a response this.responseResolvers.delete(receipt.context); // trigger reject handler const responseReject = this.responseRejectHandler.get( receipt.context, ); if (responseReject) { // remove handler from map this.responseRejectHandler.delete(receipt.context); // trigger reject handler responseReject(receipt); } else { console.error( 'received error receipt for an unknwon context: ' + receipt.context, ); } } }); }); this.incomingChannel.addEventListener('open', () => { this.startedReconnectIncoming = false; // connection established - reset past connection errors this.connectionError = ''; // listen for incoming messages this.incomingChannel.addEventListener('message', (ev: MessageEvent) => { // console.log("incoming message..."); try { // parse and decode message const data = JSON.parse(ev.data); const response: DASFModuleResponse = this.jsonCoder.deserializeObject( data, DASFModuleResponse, ); // acknowledge the message this.acknowledgeMessage(response); // extract request context and resolve const requestContext = response.getRequestContext(); if (this.finishedContexts.has(requestContext)) { // this request has already been processed - ignore duplicate return; } if (response.getMessageType() == MessageType.Progress) { const progressReport: DASFProgressReport = this.jsonCoder.deserializeObject( JSON.parse(atob(data.payload)), DASFProgressReport, ); const onProgressCallback = this.progressCallbacks.get(requestContext); if (onProgressCallback) { onProgressCallback({ message: progressReport, props: response.properties, }); } } else { // is this just a fragment - or the whole thing ? if (response.isFragmented()) { // fragmented message if (this.contextResponseFragments.has(requestContext)) { // we already have some fragments - append let fragments: DASFModuleResponse[] | undefined = this.contextResponseFragments.get(requestContext); if (fragments) { fragments.push(response); // console.log('fragment ' + response.getFragmentId() + ' of ' + response.getNumberOfFragments() + ' received'); if (fragments.length == response.getNumberOfFragments()) { // console.log('assembling fragmented payload'); // all fragments received - assemble fragments = fragments.sort( (a: DASFModuleResponse, b: DASFModuleResponse) => a.getFragmentId() - b.getFragmentId(), ); let data = ''; for (const frag of fragments) { data += frag.payload; } // pretend the last response message contained the entire data response.payload = data; // clear the fragment data this.contextResponseFragments.delete(requestContext); } else { // fragments left - continue return; } } } else { // first fragmented message received - store this.contextResponseFragments.set(requestContext, [response]); return; } } // get callback function for context const responseResolve = this.responseResolvers.get(requestContext); if (responseResolve) { // remove it from the map this.responseResolvers.delete(requestContext); this.progressCallbacks.delete(requestContext); this.responseRejectHandler.delete(requestContext); this.finishedContexts.add(requestContext); // trigger callback responseResolve(response); } else { console.warn( 'received a response for an unknown context: ' + requestContext, response, ); } } } catch (e) { console.error(e); } }); }); } private checkConnection(): void { if (this.waitingForPong) { // we sent a ping but still waiting for the pong - timeout console.warn('Ping to backend module timed out.'); } if (this.startedReconnectOutgoing || this.startedReconnectIncoming) { return; } // send ping //console.log('sending ping'); this.waitingForPong = true; // create the ping message const ping = DASFModuleRequest.createPingMessage(); // send the request this.sendMessage(ping) .then(() => { // we received a pong this.waitingForPong = false; // send pending request if any this.sendPendingRequests(); }) .catch((errorReceipt) => { console.warn(errorReceipt.errorMsg); }); } /** Send a request to the backend module and retrieve the response * * This high-level implementation of :meth:`sendMessage` takes an object and * sends it as a :class:`DASFModuleRequest` of type * :attr:`MessageType.Request` to the backend module (as soon as * the websocket connection has been established). The result * is a promise that resolves to the response of the backend module. * * **Example** * * ``` * import { DASFConnection, WebsocketUrlBuilder } from '@dasf/dasf-messaging' * const connection = new DASFConnection( * new WebsocketUrlBuilder( * 'ws://localhost:8080/ws', * 'some-topic' * ) * ) * * // send request via the connection and log the response * connection.sendRequest({func_name: 'version_info'}).then( * (response) => console.log(response) * ) * ``` * * @param data - The object that is sent as payload of the request to the * backend module * @param onProgress - A callback to use when we receive progress reports * while processing the request in the backend module. * * @returns The promise resolving to the deserialized response of the backend * module */ public async sendRequest( data: object, onProgress?: (value: { message: DASFProgressReport; props?: object; }) => void, ): Promise<unknown> { const request = DASFModuleRequest.createRequestMessage(data); return this.sendMessage(request, onProgress).then( (response: DASFModuleResponse): unknown => { if (response.properties && response.properties.status == 'error') { return 'Error: ' + atob(response.payload); } else { return JSON.parse(atob(response.payload)); } }, ); } /** Send a generic message to the backend module * * This low-level implementation takes a generic :class:`DASFModuleRequest` * and sends it to the backend module (as soon as the websocket connection * has been established). The result is a promise that resolves to the * response of the backend module. * * @param requestMessage - The message you want to send * @param onProgress - A callback to use when we receive progress reports * while processing the request in the backend module. * * @returns The promise resolving to the response of the backend module */ public async sendMessage( requestMessage: DASFModuleRequest, onProgress?: (value: { message: DASFProgressReport; props?: object; }) => void, ): Promise<DASFModuleResponse> { // create context id requestMessage.context = String(++this.contextCounter); if (!requestMessage.properties) { throw new Error('missing request properties'); } // set response topic prop requestMessage.properties[PropertyKeys.ResponseTopic] = this.consumeTopic; requestMessage.properties[PropertyKeys.RequestContext] = requestMessage.context; const { promise: responsePromise, resolve: responseResolve, reject: responseReject, } = Promise.withResolvers<DASFModuleResponse>(); // store context->callback mapping if (!this.hasConnectionError()) { this.responseResolvers.set(requestMessage.context, responseResolve); this.responseRejectHandler.set(requestMessage.context, responseReject); if (typeof onProgress != 'undefined') { this.progressCallbacks.set(requestMessage.context, onProgress); } this.connected.then((connection) => { const msg_str = JSON.stringify(requestMessage); connection.outgoingChannel.send(msg_str); }); } else { const receipt = new DASFModuleRequestReceipt(); receipt.context = requestMessage.context; receipt.errorMsg = this.connectionError; responseReject(receipt); } return responsePromise; } private acknowledgeMessage(message: DASFModuleResponse) { const ack = this.jsonCoder.serializeObject( new DASFAcknowledgment(message.messageId), ); this.incomingChannel.send(JSON.stringify(ack)); } /** Close all connections * * This method closes the connections to the outgoing and incoming websocket. * * **Example** * * ``` * import { DASFConnection, WebsocketUrlBuilder } from '@dasf/dasf-messaging' * const connection = new DASFConnection( * new WebsocketUrlBuilder( * 'ws://localhost:8080/ws', * 'some-topic' * ) * ) * * // send request via the connection and log the response * connection.sendRequest({func_name: 'version_info'}).then( * (response) => { * console.log(response) * // close the connection upon receival * connection.close() * } * ) * * ``` */ public close() { this.outgoingChannel.close(); this.incomingChannel.close(); } /** Get information on requests for this module * * This method retrieves the JSONSchema representation for requests to this * module. It is one large schema object that can be used to validate a * request for the :func:`sendRequest` method. * * @returns A promise that resolves to the JSONSchema for a request to this topic */ public getModuleInfo(): Promise<JSONSchema7> { return this.sendInfoRequest( DASFModuleRequest.createInfoMessage(), MessageType.Info, ); } /** Get api information for this backend module * * This method retrieves the api info, namely the individual JSONSchema * representations for the functions and classes in the backend module. * Different from :func:`getModuleInfo`, this method does not only provide a * single JSONSchema, but rather one JSONSchema for each method/function in * the backend module. * * @returns an object representing the api of the backend module. */ public getApiInfo(): Promise<ModuleApiInfo> { return this.sendInfoRequest( DASFModuleRequest.createApiInfoMessage(), MessageType.ApiInfo, ); } private sendInfoRequest( msg: DASFModuleRequest, what: MessageType.ApiInfo, ): Promise<ModuleApiInfo>; private sendInfoRequest( msg: DASFModuleRequest, what: MessageType.Info, ): Promise<JSONSchema7>; private sendInfoRequest( msg: DASFModuleRequest, what: MessageType.Info | MessageType.ApiInfo = MessageType.Info, ): Promise<JSONSchema7 | ModuleApiInfo> { const { promise, resolve, reject } = Promise.withResolvers<object>(); this.sendMessage(msg).then((message: DASFModuleResponse) => { if (message.properties) { try { const data = JSON.parse(message.properties[what] as string); if (what == MessageType.ApiInfo) { resolve(this.jsonCoder.deserializeObject(data, ModuleApiInfo)); } else { resolve(data); } } catch (e) { // unable to parse console.warn( `Unable to retrieve backend module ${what}, got: `, message.properties[what], ); reject(e); } } }); return promise; } private hasConnectionError(): boolean { return this.connectionError.length > 0; } }