@dasf/dasf-messaging
Version:
Typescript bindings for the DASF RPC messaging protocol.
622 lines (557 loc) • 20.1 kB
text/typescript
// 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;
}
}