UNPKG

@skylineos/clsp-player

Version:

Skyline Technology Solutions' CLSP Video Player. Stream video in near-real-time in modern browsers.

991 lines (873 loc) 30.2 kB
/** * The Router contains the lowest-level logic of the actual CLSP connection. * The Router will manage a CLSP connection for a given clientId, and pass * the relevant data and messages back up to the Iframe Manager. * * Note that this is the code that gets duplicated in each iframe. * Keep the contents of the exported function light and ES5 only. * * @see - https://developer.mozilla.org/en-US/docs/Web/HTML/Element/iframe * @see - https://developer.mozilla.org/en-US/docs/Web/HTML/Element/body * @see - https://www.eclipse.org/paho/files/jsdoc/index.html * * @todo - have a custom loader for webpack that can convert this to ES5 and * minify it in a self-contained way at the time it is required so that we can * use ES6 and multiple files. * * @todo - should all thrown errors send a message to the parent? * * @param {object} Paho * The Paho instance to use for server communication. This should be passed * in as `window.parent.Paho` to prevent the Paho library from being * duplicated for every CLSP iframe. * * @export - the function that provides the Router and constants */ export default function (Paho) { /** * A Router that can be used to set up a CLSP connection to the specified * host and port, using the provided clientId that will be a part of * every message that is passed from this iframe window to the parent window, * so that the the Conduit Collection can identify what Conduit the message * is for. * * @param {String} logId * a string that identifies this router in log messages * @param {String} clientId * the guid to be used to construct the topic * @param {String} host * the host (url or ip) of the SFS that is providing the stream * @param {Number} port * the port the stream is served over * @param {Boolean} useSSL * true to request the stream over clsps, false to request the stream over clsp * @param {Object} options */ function Router ( logId, clientId, host, port, useSSL, options, ) { Router.constructorArgumentsBouncer( logId, clientId, host, port, useSSL, options, ); this.logId = logId; try { this.logger = options.Logger.factory(`Router ${this.logId}`, 'color: maroon;'); } catch (error) { console.error(error); throw new Error('Error while constructing Logger!'); } this.clientId = clientId; this.host = host; this.port = port; this.useSSL = useSSL; this.logger.debug('Constructing...'); this.Reconnect = null; try { // @todo - there is a "private" method named "_doConnect" in the paho // library that is responsible for instantiating the WebSocket. We have // seen at least 1 instance where the instantiation of the WebSocket fails // which was due to the error "ERR_NAME_NOT_RESOLVED", but it does not // seem like this error is "passed" up to the caller (e.g. Router.connect) // and therefore we cannot respond to it. If we could, perhaps we could // attempt to reconnect, or at least send a message to Router's parent. // Given this, should we override Paho.MQTT.Client._doConnect and wrap // the original prototype method call in a try/catch that we can control // and respond to? I'm not even sure that that would solve the problem. // Presumably, the instantiation of the WebSocket would throw, which would // be caught by our Router.connect try/catch block... this.clspClient = new Paho.MQTT.Client( this.host, this.port, '/mqtt', this.clientId, ); } catch (error) { const message = new Error('Error while constructing Paho Client!'); this.logger.critical(message); this.logger.critical(error); throw message; } this.clspClient.onConnectionLost = this._onConnectionLost.bind(this); this.clspClient.onMessageArrived = this._onMessageArrived.bind(this); this.clspClient.onMessageDelivered = this._onMessageDelivered.bind(this); this.boundWindowMessageEventHandler = this._windowMessageEventHandler.bind(this); window.addEventListener( 'message', this.boundWindowMessageEventHandler, false, ); this.CONNECTION_TIMEOUT = options.CONNECTION_TIMEOUT; this.KEEP_ALIVE_INTERVAL = options.KEEP_ALIVE_INTERVAL; this.PUBLISH_TIMEOUT = options.PUBLISH_TIMEOUT; this.isDestroyed = false; this.isDestroyComplete = false; } Router.constructorArgumentsBouncer = function ( logId, clientId, host, port, useSSL, options, ) { if (typeof Paho !== 'object') { throw new Error('Paho is required to construct a Router'); } if (typeof Paho.MQTT !== 'object') { throw new Error('Paho.MQTT is required to construct a Router'); } if (typeof Paho.MQTT.Client !== 'function') { throw new Error('Paho.MQTT.Client is required to construct a Router'); } if (typeof Paho.MQTT.Message !== 'function') { throw new Error('Paho.MQTT.Message is required to construct a Router'); } if (logId === undefined) { throw new Error('`logId` is required to construct a Router'); } if (clientId === undefined) { throw new Error('`clientId` is required to construct a Router'); } if (host === undefined) { throw new Error('`host` is required to construct a Router'); } if (port === undefined) { throw new Error('`port` is required to construct a Router'); } if (useSSL === undefined) { throw new Error('`useSSL` is required to construct a Router'); } if (typeof options !== 'object') { throw new Error('`options` is required to construct a Router'); } if (options.Logger === undefined) { throw new Error('`options.Logger` is required to construct a Router'); } if (options.CONNECTION_TIMEOUT === undefined) { throw new Error('`options.CONNECTION_TIMEOUT` is required to construct a Router'); } if (options.KEEP_ALIVE_INTERVAL === undefined) { throw new Error('`options.KEEP_ALIVE_INTERVAL` is required to construct a Router'); } if (options.PUBLISH_TIMEOUT === undefined) { throw new Error('`options.PUBLISH_TIMEOUT` is required to construct a Router'); } }; Router.pahoErrorCodes = { NOT_CONNECTED: 'AMQJS0011E', ALREADY_CONNECTED: 'AMQJS0011E', }; // All events that are emitted by the Router are prefixed with `clsp_router` Router.events = { // Triggered when the Router is successfully instantiated. // Can only be triggered at time of router instantiation. CREATE_SUCCESS: 'clsp_router_create_success', // Triggered when there is an error during Router instantiation. // Can only be triggered at time of router instantiation. CREATE_FAILURE: 'clsp_router_create_failure', // Triggered when the connection to the CLSP server is established. // Can only be triggered at time of connection. CONNECT_SUCCESS: 'clsp_router_connect_success', // Triggered when trying to connect to the CLSP server fails. // Can only be triggered at time of connection. CONNECT_FAILURE: 'clsp_router_connect_failure', // Triggered when the connection to the CLSP server has been established, but is later lost. // Can be triggered for as long as the connection is open. CONNECTION_LOST: 'clsp_router_connection_lost', // Triggered when the connection to the CLSP server is terminated normally. // Can only be triggered at time of disconnection. DISCONNECT_SUCCESS: 'clsp_router_disconnect_success', DISCONNECT_FAILURE: 'clsp_router_disconnect_failure', // Triggered when a message is successfully published to the server // Can only be triggered on publish PUBLISH_SUCCESS: 'clsp_router_publish_success', // Triggered when a message fails to be published to the server // Can only be triggered on publish PUBLISH_FAILURE: 'clsp_router_publish_failure', // Triggered when trying to subscribe to a topic fails. // Can only be triggered when a subscribe is attempted. SUBSCRIBE_FAILURE: 'clsp_router_subscribe_failure', // When successfully unsubscribed from a topic // Can only be triggered when an unsubscribe is attempted. UNSUBSCRIBE_SUCCESS: 'clsp_router_unsubscribe_success', // When trying to unsubscribe from a topic fails. // Can only be triggered when an unsubscribe is attempted. UNSUBSCRIBE_FAILURE: 'clsp_router_unsubscribe_failure', // Triggered when a segment / moof is transmitted. // Can be triggered for as long as the connection is open. MESSAGE_ARRIVED: 'clsp_router_message_arrived', // Triggered when an error is encountered while processing window messages. // Can be triggered any time. WINDOW_MESSAGE_FAIL: 'clsp_router_window_message_fail', }; Router.commands = { CONNECT: 'connect', DISCONNECT: 'disconnect', PUBLISH: 'publish', SUBSCRIBE: 'subscribe', UNSUBSCRIBE: 'unsubscribe', SEND: 'send', }; Router.factory = function ( logId, clientId, host, port, useSSL, options, ) { return new Router( logId, clientId, host, port, useSSL, options, ); }; /** * @private * * Post a "message" with the current `clientId` to the parent window. * * @param {Object} message * The message to send to the parent window * * @returns {void} */ Router.prototype._sendToParentWindow = function (message) { this.logger.debug('Sending message to parent window...'); if (this.isDestroyed) { return; } if (typeof message !== 'object') { throw new Error('_sendToParentWindow must be passed an object'); } message.clientId = this.clientId; switch (message.event) { case Router.events.CREATE_SUCCESS: case Router.events.CONNECT_SUCCESS: case Router.events.DISCONNECT_SUCCESS: { // no validation needed break; } case Router.events.MESSAGE_ARRIVED: { if (!Object.prototype.hasOwnProperty.call(message, 'destinationName') || !Object.prototype.hasOwnProperty.call(message, 'payloadString') || !Object.prototype.hasOwnProperty.call(message, 'payloadBytes') ) { throw new Error('improperly formatted "data" message sent to _sendToParentWindow'); } break; } case Router.events.UNSUBSCRIBE_SUCCESS: { if (!Object.prototype.hasOwnProperty.call(message, 'event') || !Object.prototype.hasOwnProperty.call(message, 'topic') || !Object.prototype.hasOwnProperty.call(message, 'response') ) { throw new Error('improperly formatted "unsubscribe success" message sent to _sendToParentWindow'); } break; } case Router.events.CONNECT_FAILURE: case Router.events.CONNECTION_LOST: case Router.events.SUBSCRIBE_FAILURE: case Router.events.UNSUBSCRIBE_FAILURE: case Router.events.WINDOW_MESSAGE_FAIL: { if (!Object.prototype.hasOwnProperty.call(message, 'reason')) { throw new Error('improperly formatted "fail" message sent to _sendToParentWindow'); } break; } case Router.events.PUBLISH_SUCCESS: { if (!message.publishId) { throw new Error('publish message must contain a publishId'); } if (!message.topic) { throw new Error('publish message must contain a topic'); } break; } case Router.events.PUBLISH_FAILURE: { if (!message.publishId) { throw new Error('publish message must contain a publishId'); } if (!Object.prototype.hasOwnProperty.call(message, 'reason')) { throw new Error('improperly formatted "fail" message sent to _sendToParentWindow'); } break; } default: { throw new Error('Unknown event ' + message.event + ' sent to _sendToParentWindow'); } } try { window.parent.postMessage(message, '*'); } catch (error) { // When the connection to the SFS fails, and the conduit is destroyed, // there is still a message that is attempted to be sent to the parent. // In this case, the only way this "orphaned" iframe object can // communicate with the console is by throwing an error. Therefore, it is // difficult to debug and I do not know what the final message is. Having // the error written to the console here will still allow errors under // "normal" operations to be written to the console, but will suppress the // final unwanted error. console.error(error); } }; /** * @private * * To be called when a message has arrived in this Paho.MQTT.client * * The idea here is that when the server sends a CLSP message, whether a * moof, moov, or something else, that data needs to be sent to the appropriate * player (client). So when this router gets that chunk of data, it sends it * back to the Conduit with the clientId, and the Conduit is then responsible * for passing it to the appropriate player. * * @see - https://www.eclipse.org/paho/files/jsdoc/Paho.MQTT.Client.html * * @param {Paho.MQTT.Message} clspMessage * The incoming message * * @returns {void} */ Router.prototype._onMessageArrived = function (clspMessage) { this.logger.debug('Received CLSP message...'); try { let payloadString = ''; try { payloadString = clspMessage.payloadString; } catch (error) { // I have no idea what is going on here, but every single time we do the // assignment above, an error is thrown. When I console.log(payloadString) // it appears to be an empty string. However, if that assignment is not // done, no video gets displayed!! // There should be some way to only use the payloadBytes here... } this._sendToParentWindow({ event: Router.events.MESSAGE_ARRIVED, destinationName: clspMessage.destinationName, payloadString, // @todo - why is this necessary when it doesn't exist? payloadBytes: clspMessage.payloadBytes || null, }); } catch (error) { this.logger.error(error); } }; /** * @private * * To be called when a message has been published by this CLSP client. * * @see - https://www.eclipse.org/paho/files/jsdoc/Paho.MQTT.Client.html * * @param {Paho.MQTT.Message} clspMessage * The message that was delivered * * @returns {void} */ Router.prototype._onMessageDelivered = function (clspMessage) { this.logger.debug('Delivered CLSP message...'); if (clspMessage._onDelivered) { clspMessage._onDelivered(); } }; Router.prototype._onCommandReceived = function (command, message, event) { switch (command) { case Router.commands.SUBSCRIBE: { this._subscribe(message.topic); break; } case Router.commands.UNSUBSCRIBE: { this._unsubscribe(message.topic); break; } case Router.commands.PUBLISH: { let payload = null; try { payload = JSON.stringify(message.data); } catch (error) { this.logger.error('ERROR: Unable to handle the "publish" window message event!'); this.logger.error('json stringify error: ' + message.data); // @todo - should we throw here? // throw error; return; } this._publish( message.publishId, message.topic, payload, ); break; } case Router.commands.CONNECT: { this.connect(); break; } case Router.commands.DISCONNECT: { this.disconnect(); break; } case Router.commands.SEND: { this._publish( message.publishId, message.topic, message.byteArray, ); break; } default: { this.logger.error('Unknown Command: "' + command + '"'); } } }; /** * @private * * Any time a "message" event occurs on the window, respond to it by * inspecting the message's "method" property and taking the appropriate * action. * * @param {Object} event * The window message event * * @returns {void} */ Router.prototype._windowMessageEventHandler = function (event) { const message = event.data; if (!message) { return; } const method = message.method; if (!method) { return; } this.logger.debug('Handling incoming window message for "' + method + '"...'); try { this._onCommandReceived(method, message, event); } catch (error) { this.logger.error(error); this._sendToParentWindow({ event: Router.events.WINDOW_MESSAGE_FAIL, reason: 'window message event failure', }); } }; /** * @private * * Success handler for the CLSP client "connect". Registers the window * message event handler, and notifies the parent window that this client is * "ready". * * @todo - track the "connected" status to prevent multiple window message * event handlers from being attached * * @see - https://www.eclipse.org/paho/files/jsdoc/Paho.MQTT.Client.html * * @param {Object} response * The response object * * @returns {void} */ Router.prototype._connect_onSuccess = function (response) { this.logger.info('Successfully established CLSP connection'); this._sendToParentWindow({ event: Router.events.CONNECT_SUCCESS, }); }; /** * @private * * Failure handler for CLSP client "connect". Sends a "fail" message to the * parent window * * @see - https://www.eclipse.org/paho/files/jsdoc/Paho.MQTT.Client.html * * @param {Object} response * The response object * * @returns {void} */ Router.prototype._connect_onFailure = function (response) { this.logger.info('CLSP Connection Failure!'); this._sendToParentWindow({ event: Router.events.CONNECT_FAILURE, reason: 'Connection Failed - Error code ' + parseInt(response.errorCode) + ': ' + response.errorMessage, }); }; /** * @private * * Called when an clspClient connection has been lost * * @see - https://www.eclipse.org/paho/files/jsdoc/Paho.MQTT.Client.html * * @param {Object} response * The response object * @param {Object} data * An optional data parameter, only used when this is called manually, * meaning when it is used outside of the Paho Client callback * * @returns {void} */ Router.prototype._onConnectionLost = function (response, data = {}) { this.logger.debug('CLSP connection lost'); const errorCode = parseInt(response.errorCode); if (errorCode === 0) { // The connection was "properly" terminated this._sendToParentWindow({ event: Router.events.DISCONNECT_SUCCESS, data, }); return; } this.logger.warn('CLSP connection lost improperly!'); this._sendToParentWindow({ event: Router.events.CONNECTION_LOST, reason: 'connection lost error code "' + errorCode + '" with message: ' + response.errorMessage, data, }); }; /** * Call this when the Router has received a command, but the connection to * the server no longer exists. Paho does not appear to handle this * situation gracefully. */ Router.prototype._connectionWasLost = function (data) { this.logger.debug('CLSP Connection was lost while trying to perform another action'); // Spoof the clspClient response... const response = { errorCode: 0, }; this._onConnectionLost(response, data); }; /** * @private * * Success handler for CLSP client "subscribe". * * @see - https://www.eclipse.org/paho/files/jsdoc/Paho.MQTT.Client.html * * @param {String} topic * The topic that was successfully subscribed to * @param {Object} response * The response object * * @returns {void} */ Router.prototype._subscribe_onSuccess = function (topic, response) { this.logger.debug('Successfully subscribed to topic "' + topic + '"'); // @todo }; /** * @private * * Failure handler for CLSP "subscribe". Sends a "fail" message to the parent * window * * @see - https://www.eclipse.org/paho/files/jsdoc/Paho.MQTT.Client.html * * @param {String} topic * The topic that was attempted to be subscribed to * @param {Object} response * The response object * * @returns {void} */ Router.prototype._subscribe_onFailure = function (topic, response) { this.logger.error('Failed to subscribe to topic "' + topic + '"'); this._sendToParentWindow({ event: Router.events.SUBSCRIBE_FAILURE, reason: 'Subscribe Failed - Error code ' + parseInt(response.errorCode) + ': ' + response.errorMessage, }); }; /** * @private * * Start receiving messages for the given topic. * * @see - https://www.eclipse.org/paho/files/jsdoc/Paho.MQTT.Client.html * * @param {String} topic * The topic to subscribe to * * @returns {void} */ Router.prototype._subscribe = function (topic) { this.logger.debug('Subscribing to topic "' + topic + '"'); if (!topic) { throw new Error('topic is a required argument when subscribing'); } // @todo - unsubscribe and publish have a check for isConnected - is that // needed here, too? this.clspClient.subscribe(topic, { onSuccess: this._subscribe_onSuccess.bind(this, topic), onFailure: this._subscribe_onFailure.bind(this, topic), }); }; /** * @private * * Success handler for "unsubscribe". * * @see - https://www.eclipse.org/paho/files/jsdoc/Paho.MQTT.Client.html * * @param {String} topic * The topic that was successfully unsubscribed from * @param {Object} response * The response object * * @returns {void} */ Router.prototype._unsubscribe_onSuccess = function (topic, response) { this.logger.info('Successfully unsubscribed from topic "' + topic + '"'); this._sendToParentWindow({ event: Router.events.UNSUBSCRIBE_SUCCESS, topic, response, }); }; /** * @private * * Failure handler for "unsubscribe". Sends a "fail" message to the parent * window * * @see - https://www.eclipse.org/paho/files/jsdoc/Paho.MQTT.Client.html * * @param {String} topic * The topic that was successfully subscribed to * @param {Object} response * The response object * * @returns {void} */ Router.prototype._unsubscribe_onFailure = function (topic, response) { this.logger.warn('Failed to unsubscribe from topic "' + topic + '"'); this._sendToParentWindow({ event: Router.events.UNSUBSCRIBE_FAILURE, reason: 'Unsubscribe Failed - Error code ' + parseInt(response.errorCode) + ': ' + response.errorMessage, }); }; /** * @private * * Stop receiving messages for the given topic * * @see - https://www.eclipse.org/paho/files/jsdoc/Paho.MQTT.Client.html * * @param {String} topic * The topic to unsubscribe from * * @returns {void} */ Router.prototype._unsubscribe = function (topic) { this.logger.info('Unsubscribing from topic "' + topic + '"'); if (!topic) { throw new Error('topic is a required argument when unsubscribing'); } // Paho doesn't seem to handle unsubscribe while disconnected gracefully if (!this.clspClient.isConnected()) { return this._unsubscribe_onSuccess(topic); } this.clspClient.unsubscribe(topic, { onSuccess: this._unsubscribe_onSuccess.bind(this, topic), onFailure: this._unsubscribe_onFailure.bind(this, topic), }); }; Router.prototype._publish_onSuccess = function (publishId, topic) { this.logger.info('Successfully published topic "' + topic + '" with id "' + publishId + '"'); this._sendToParentWindow({ event: Router.events.PUBLISH_SUCCESS, publishId, topic, }); }; Router.prototype._publish_onFailure = function (publishId, topic, reason) { this.logger.info('Successfully published topic "' + topic + '" with id "' + publishId + '"'); this._sendToParentWindow({ event: Router.events.PUBLISH_FAILURE, publishId, reason, }); }; /** * @private * * Publish a message to the clients that are listening for the given topic * * @see - https://www.eclipse.org/paho/files/jsdoc/Paho.MQTT.Message.html * * @param {String|ArrayBuffer} payload * The message data to be sent * @param {String} topic * The topic to publish to * * @returns {void} */ Router.prototype._publish = function ( publishId, topic, payload, ) { this.logger.debug('Publishing to topic "' + topic + '"'); if (!payload) { throw new Error('payload is a required argument when publishing'); } if (!topic) { throw new Error('topic is a required argument when publishing'); } // Paho doesn't seem to handle publish while disconnected gracefully if (!this.clspClient.isConnected()) { // @todo - this hasn't been tested... // return this._connectionWasLost({ // publishId: publishId, // topic: topic, // }); return this._publish_onSuccess(publishId, topic); } const self = this; const clspMessage = new Paho.MQTT.Message(payload); clspMessage.destinationName = topic; // I tried setting the quality of service to 2, which has the highest level // of reliability, but it seems that Paho doesn't clean up after itself // or have sane (or any) timeouts or something. When this is set to 2, over // time, local storage will fill up, and all CLSP will cease to work. Not // to mention the fact that local storage refuses additional writes. // clspMessage.qos = 2; // qos: exactly once let publishTimeout = setTimeout(function () { clearTimeout(publishTimeout); publishTimeout = null; const reason = 'publish operation for "' + topic + '" timed out after ' + self.PUBLISH_TIMEOUT + ' seconds.'; self._publish_onFailure(publishId, topic, reason); }, this.PUBLISH_TIMEOUT * 1000); // custom property clspMessage._onDelivered = function (clspMessage) { if (!publishTimeout) { // the publish operation timed out and has already been rejected return; } clearTimeout(publishTimeout); publishTimeout = null; self._publish_onSuccess(publishId, topic); }; // @todo - this can fail if the client is not connected this.clspClient.publish(clspMessage); }; /** * Connect this Messaging client to its server * * @see - https://www.eclipse.org/paho/files/jsdoc/Paho.MQTT.Message.html * @see - https://www.eclipse.org/paho/files/jsdoc/Paho.MQTT.Client.html * * @returns {void} */ Router.prototype.connect = function () { this.logger.info('Connecting...'); // last will message sent on disconnect const willMessage = new Paho.MQTT.Message(JSON.stringify({ clientId: this.clientId, })); willMessage.destinationName = 'iov/clientDisconnect'; const connectionOptions = { timeout: this.CONNECTION_TIMEOUT, keepAliveInterval: this.KEEP_ALIVE_INTERVAL, onSuccess: this._connect_onSuccess.bind(this), onFailure: this._connect_onFailure.bind(this), willMessage, // @todo - should `reconnect` be set here? }; if (this.useSSL === true) { connectionOptions.useSSL = true; } try { this.clspClient.connect(connectionOptions); this.logger.info('Connected'); } catch (error) { if (error.message.startsWith(Router.pahoErrorCodes.ALREADY_CONNECTED)) { // if we're already connected, there's no error to report return; } this.logger.error('Failed to connect', error); this._sendToParentWindow({ event: Router.events.CONNECT_FAILURE, reason: 'General error when trying to connect.', }); } }; /** * Disconnect the messaging client from the server. To get confirmation of * the disconnection, the caller must listen for the following event: * `Router.events.CONNECTION_LOST` * * @see - https://www.eclipse.org/paho/files/jsdoc/Paho.MQTT.Client.html * * @returns {void} */ Router.prototype.disconnect = function () { this.logger.info('Disconnecting'); try { // Note we have to handle this condition manually. If you try to // disconnect from the clspClient while it isn't connected, it will never // call the _onConnectionLost callback... if (!this.clspClient.isConnected()) { this._connectionWasLost(); } else { this.clspClient.disconnect(); } } catch (error) { if (error.message.startsWith(Router.pahoErrorCodes.NOT_CONNECTED)) { // if we're not connected when we attempted to disconnect, there's no // error to report return; } this.logger.error('ERROR while disconnecting'); this.logger.error(error); throw error; } }; /** * Destroy the Router and free all resources * * @returns {void} */ Router.prototype.destroy = function () { this.logger.info('Destroying...'); if (this.isDestroyed) { return; } this.isDestroyed = true; window.removeEventListener('message', this.boundWindowMessageEventHandler); this.boundWindowMessageEventHandler = null; this.disconnect(); // @todo - is there a way to "destroy" the client? I didn't see anything // in the documentation this.clspClient = null; this.isDestroyComplete = false; this.logger.info('destroy complete'); }; return Router; }