mobility-toolbox-js
Version:
Toolbox for JavaScript applications in the domains of mobility and logistics.
372 lines (371 loc) • 13.8 kB
JavaScript
/**
* Class used to facilitate connection to a WebSocketAPI and
* also to manage properly messages send to the WebSocketAPI.
* This class must not contain any specific implementation.
* @private
*/
class WebSocketAPI {
constructor() {
this.defineProperties();
}
/**
* Get the websocket request string.
*
* @param {string} method Request mehtod {GET, SUB}.
* @param {WebSocketParameters} params Request parameters.
* @param {string} params.channel Channel name
* @param {string} [params.args] Request arguments
* @param {Number|string} [params.id] Request identifier
* @return {string} request string
* @private
*/
static getRequestString(method, params = {}) {
let reqStr = `${method} ${params.channel}`;
reqStr += params.args ? ` ${params.args}` : '';
reqStr += params.id ? ` ${params.id}` : '';
return reqStr.trim();
}
addEvents(onMessage, onError) {
if (this.websocket) {
this.websocket.addEventListener('message', onMessage);
if (onError) {
this.websocket.addEventListener('error', onError);
this.websocket.addEventListener('close', onError);
}
}
}
/**
* Close the websocket definitively.
*
* @private
*/
close() {
if (this.websocket && (this.open || this.connecting)) {
this.websocket.onclose = () => { };
this.websocket.close();
this.messagesOnOpen = [];
}
}
/**
* (Re)connect the websocket.
*
* @param {string} url Websocket url.
* @param {function} onOpen Callback called when the websocket connection is opened and before subscriptions of previous subscriptions.
* @private
*/
connect(url, onOpen = () => { }) {
var _a;
// if no url specify, close the current websocket and do nothing.
if (!url) {
(_a = this.websocket) === null || _a === void 0 ? void 0 : _a.close();
return;
}
// Behavior when a websocket already exists.
if (this.websocket) {
// If the current websocket has the same url and is open or is connecting, do nothing.
if (this.websocket.url === url && (this.open || this.connecting)) {
return;
}
// If the current websocket has not the same url and is open or is connecting, close it.
if (this.websocket.url !== url && (this.open || this.connecting)) {
this.websocket.close();
}
}
this.websocket = new WebSocket(url);
if (!this.open) {
this.websocket.addEventListener('open', () => {
onOpen();
this.subscribePreviousSubscriptions();
});
}
else {
onOpen();
this.subscribePreviousSubscriptions();
}
}
defineProperties() {
Object.defineProperties(this, {
closed: {
get: () => {
return !!(!this.websocket ||
this.websocket.readyState === this.websocket.CLOSED);
},
},
closing: {
get: () => {
return !!(this.websocket &&
this.websocket.readyState === this.websocket.CLOSING);
},
},
connecting: {
get: () => {
return !!(this.websocket &&
this.websocket.readyState === this.websocket.CONNECTING);
},
},
/**
* Array of message to send on open.
* @type {Array<string>}
* @private
*/
messagesOnOpen: {
value: [],
writable: true,
},
open: {
get: () => {
return !!(this.websocket && this.websocket.readyState === this.websocket.OPEN);
},
},
/**
* List of channels subscribed.
* @type {WebSocketSubscribed}
* @private
*/
subscribed: {
value: {},
writable: true,
},
/**
* Array of subscriptions.
* @type {Array<WebSocketSubscription>}
* @private
*/
subscriptions: {
value: [],
writable: true,
},
});
}
/**
* Sends a get request to the websocket.
* The callback is called only once, when the response is received or when the call returns an error.
*
* @param {Object} params Parameters for the websocket get request
* @param {function} cb callback on message event
* @param {function} errorCb Callback on error and close event
* @private
*/
get(params, cb, errorCb) {
const requestString = WebSocketAPI.getRequestString('GET', params);
this.send(requestString);
// We wrap the callbacks to make sure they are called only once.
const once = (callback) => {
return (...args) => {
// @ts-expect-error - We know that args is an array
callback(...args);
const index = this.requests.findIndex((request) => {
return requestString === request.requestString && cb === request.cb;
});
const { onErrorCb, onMessageCb } = this.requests[index];
this.removeEvents(onMessageCb, onErrorCb);
this.requests.splice(index, 1);
};
};
const { onErrorCb, onMessageCb } = this.listen(params, once(cb), errorCb && once(errorCb));
// Store requests and callbacks to be able to remove them.
if (!this.requests) {
this.requests = [];
}
const index = this.requests.findIndex((request) => {
return requestString === request.requestString && cb === request.cb;
});
const newReq = {
cb,
errorCb,
onErrorCb,
onMessageCb,
params,
requestString,
};
if (index > -1) {
// @ts-expect-error - We know that the requests is an array of WebSocketAPIRequest
this.requests[index] = newReq;
}
else {
// @ts-expect-error - We know that the requests is an array of WebSocketAPIRequest
this.requests.push(newReq);
}
}
/**
* Listen to websocket messages.
*
* @param {WebSocketParameters} params Parameters for the websocket get request
* @param {function} cb callback on listen
* @param {function} errorCb Callback on error
* @return {{onMessage: function, errorCb: function}} Object with onMessage and error callbacks
* @private
*/
listen(params, cb, errorCb) {
// Remove the previous identical callback
this.unlisten(params, cb);
// We wrap the message callback to be sure we only propagate the message if it is for the right channel.
const onMessage = (evt) => {
let data;
try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
data = JSON.parse(evt.data);
}
catch (err) {
// eslint-disable-next-line no-console
console.error('WebSocket: unable to parse JSON data', err, evt.data);
return;
}
let source = params.channel;
source += params.args ? ` ${params.args}` : '';
// Buffer channel message return a list of other channels to propagate to proper callbacks.
let contents;
if (data.source === 'buffer') {
// @ts-expect-error - We know that the data is a WebSocketAPIBufferMessageEventData
contents = data
.content;
}
else {
contents = [data];
}
contents.forEach((content) => {
// Because of backend optimization, the last content is null.
if ((content === null || content === void 0 ? void 0 : content.source) === source &&
(!params.id || params.id === data.client_reference)) {
cb(content);
}
});
};
this.addEvents(onMessage, errorCb);
return { onErrorCb: errorCb, onMessageCb: onMessage };
}
removeEvents(onMessage, onError) {
if (this.websocket) {
this.websocket.removeEventListener('message', onMessage);
if (onError) {
this.websocket.removeEventListener('error', onError);
this.websocket.removeEventListener('close', onError);
}
}
}
/**
* Sends a message to the websocket.
*
* @param {message} message Message to send.
* @private
*/
send(message) {
if (!this.websocket || this.closed || this.closing) {
return;
}
const send = () => {
var _a;
(_a = this.websocket) === null || _a === void 0 ? void 0 : _a.send(message);
};
if (!this.open) {
// This 'if' avoid sending 2 identical BBOX message on open,
if (!this.messagesOnOpen.includes(message)) {
this.messagesOnOpen.push(message);
this.websocket.addEventListener('open', () => {
this.messagesOnOpen = [];
send();
});
this.websocket.addEventListener('close', () => {
this.messagesOnOpen = [];
});
}
}
else if (!this.messagesOnOpen.includes(message)) {
send();
}
}
/**
* Subscribe to a given channel.
*
* @param {Object} params Parameters for the websocket get request
* @param {function} cb callback on listen
* @param {function} errorCb Callback on error
* @param {boolean} quiet if false, no GET or SUB requests are send, only the callback is registered.
* @private
*/
subscribe(params, cb, errorCb, quiet = false) {
const { onErrorCb, onMessageCb } = this.listen(params, cb, errorCb);
const reqStr = WebSocketAPI.getRequestString('', params);
const index = this.subscriptions.findIndex((subcr) => {
return params.channel === subcr.params.channel && cb === subcr.cb;
});
const newSubscr = { cb, errorCb, onErrorCb, onMessageCb, params, quiet };
if (index > -1) {
// @ts-expect-error - We know that the subscriptions is an array of WebSocketAPISubscription
this.subscriptions[index] = newSubscr;
}
else {
// @ts-expect-error - We know that the subscriptions is an array of WebSocketAPISubscription
this.subscriptions.push(newSubscr);
}
if (!this.subscribed[reqStr]) {
if (!newSubscr.quiet) {
this.send(`GET ${reqStr}`);
this.send(`SUB ${reqStr}`);
}
this.subscribed[reqStr] = true;
}
}
/**
* After an auto reconnection we need to re-subscribe to the channels.
*/
subscribePreviousSubscriptions() {
// Before to subscribe previous subscriptions we make sure they
// are all defined as unsubscribed, because this code is asynchrone
// and a subscription could have been added in between.
Object.keys(this.subscribed).forEach((key) => {
this.subscribed[key] = false;
});
// Subscribe all previous subscriptions.
[...this.subscriptions].forEach((s) => {
this.subscribe(s.params, s.cb, s.errorCb, s.quiet);
});
}
/**
* Unlisten websocket messages.
*
* @param {Object} params Parameters for the websocket get request.
* @param {function} cb Callback used when listen.
* @private
*/
unlisten(params, cb) {
[...(this.subscriptions || []), ...(this.requests || [])]
.filter((s) => {
return s.params.channel === params.channel && (!cb || s.cb === cb);
})
.forEach(({ onErrorCb, onMessageCb }) => {
this.removeEvents(onMessageCb, onErrorCb);
});
}
/**
* Unsubscribe from a channel.
* @param {string} source source to unsubscribe from
* @param {function} cb Callback function to unsubscribe. If null all subscriptions for the channel will be unsubscribed.
* @private
*/
unsubscribe(source, cb) {
const toRemove = this.subscriptions.filter((s) => {
return s.params.channel === source && (!cb || s.cb === cb);
});
toRemove.forEach(({ onErrorCb, onMessageCb }) => {
this.removeEvents(onMessageCb, onErrorCb);
});
this.subscriptions = this.subscriptions.filter((s) => {
return s.params.channel !== source || (cb && s.cb !== cb);
});
// If there is no more subscriptions to this channel, and the removed subscriptions didn't register quietly,
// we DEL it.
if (source &&
this.subscribed[source] &&
!this.subscriptions.find((s) => {
return s.params.channel === source;
}) &&
toRemove.find((subscr) => {
return !subscr.quiet;
})) {
this.send(`DEL ${source}`);
this.subscribed[source] = false;
}
}
}
export default WebSocketAPI;