client/ClientSocket.js

  1. import { isBrowser } from '@ircam/sc-utils';
  2. import WebSocket from 'isomorphic-ws';
  3. import {
  4. PING_MESSAGE,
  5. PONG_MESSAGE,
  6. } from '../common/constants.js';
  7. import logger from '../common/logger.js';
  8. import {
  9. packStringMessage,
  10. unpackStringMessage,
  11. } from '../common/sockets-utils.js';
  12. export const kSocketTerminate = Symbol('soundworks:socket-terminate');
  13. /**
  14. * The ClientSocket class is a simple publish / subscribe wrapper built on top of the
  15. * [isomorphic-ws](https://github.com/heineiuo/isomorphic-ws) library.
  16. * An instance of `ClientSocket` is automatically created by the `soundworks.Client`
  17. * (see {@link Client#socket}).
  18. *
  19. * _Important: In most cases, you should consider using a {@link SharedState}
  20. * rather than directly using the sockets._
  21. *
  22. * The ClientSocket class concurrently opens two different WebSockets:
  23. * - a socket configured with `binaryType = 'blob'` for JSON compatible data
  24. * types (i.e. string, number, boolean, object, array and null).
  25. * - a socket configured with `binaryType= 'arraybuffer'` for efficient streaming
  26. * of binary data.
  27. *
  28. * Both sockets re-emits all "native" ws events ('open', 'upgrade', 'close', 'error'
  29. * and 'message'.
  30. *
  31. * @hideconstructor
  32. */
  33. class ClientSocket {
  34. #role = null;
  35. #config = null;
  36. #socketOptions = null;
  37. #socket = null;
  38. #listeners = new Map();
  39. constructor(role, config, socketOptions) {
  40. this.#role = role;
  41. this.#config = config;
  42. this.#socketOptions = socketOptions;
  43. }
  44. /**
  45. * Initialize a websocket connection with the server. Automatically called
  46. * during {@link Client#init}
  47. *
  48. * @param {string} role - Role of the client (see {@link Client#role})
  49. * @param {object} config - Configuration of the sockets
  50. * @private
  51. */
  52. async init() {
  53. let { path } = this.#socketOptions;
  54. // cf. https://github.com/collective-soundworks/soundworks/issues/35
  55. if (this.#config.env.subpath) {
  56. path = `${this.#config.env.subpath}/${path}`;
  57. }
  58. const protocol = this.#config.env.useHttps ? 'wss:' : 'ws:';
  59. const port = this.#config.env.port;
  60. let serverAddress;
  61. let webSocketOptions;
  62. if (isBrowser()) {
  63. const hostname = window.location.hostname;
  64. if (this.#config.env.serverAddress === '') {
  65. serverAddress = hostname;
  66. } else {
  67. serverAddress = this.#config.env.serverAddress;
  68. }
  69. // when in https with self-signed certificates, we don't want to use
  70. // the serverAddress defined in config as the socket would be blocked, so we
  71. // just override serverAddress with hostname in this case
  72. const localhosts = ['127.0.0.1', 'localhost'];
  73. if (this.#config.env.useHttps && localhosts.includes(hostname)) {
  74. serverAddress = window.location.hostname;
  75. }
  76. if (this.#config.env.useHttps && window.location.hostname !== serverAddress) {
  77. console.warn(`The WebSocket will try to connect at ${serverAddress} while the page is accessed from ${hostname}. This can lead to socket errors, e.g. If you run the application with self-signed certificates as the certificate may not have been accepted for ${serverAddress}. In such case you should rather access the page from ${serverAddress}.`);
  78. }
  79. webSocketOptions = [];
  80. } else {
  81. if (this.#config.env.serverAddress === '') {
  82. serverAddress = '127.0.0.1';
  83. } else {
  84. serverAddress = this.#config.env.serverAddress;
  85. }
  86. webSocketOptions = {
  87. // handshakeTimeout: 2000,
  88. // do not reject self-signed certificates
  89. rejectUnauthorized: false,
  90. };
  91. }
  92. let queryParams = `role=${this.#role}`;
  93. if (this.#config.token) {
  94. queryParams += `&token=${this.#config.token}`;
  95. }
  96. const url = `${protocol}//${serverAddress}:${port}/${path}?${queryParams}`;
  97. // ----------------------------------------------------------
  98. // Init socket
  99. // ----------------------------------------------------------
  100. return new Promise(resolve => {
  101. const trySocket = async () => {
  102. const ws = new WebSocket(url, webSocketOptions);
  103. // WebSocket "native" events:
  104. // - `close`: Fired when a connection with a websocket is closed.
  105. // - `error`: Fired when a connection with a websocket has been closed
  106. // because of an error, such as whensome data couldn't be sent.
  107. // - `message`: Fired when data is received through a websocket.
  108. // - `open`: Fired when a connection with a websocket is opened.
  109. ws.addEventListener('open', openEvent => {
  110. this.#socket = ws;
  111. this.#socket.addEventListener('message', e => {
  112. if (e.data === PING_MESSAGE) {
  113. this.#socket.send(PONG_MESSAGE);
  114. return; // do not propagate ping pong messages
  115. }
  116. // Parse incoming message, dispatch in pubsub system and propagate raw event.
  117. const [channel, args] = unpackStringMessage(e.data);
  118. this.#dispatchEvent(channel, ...args);
  119. this.#dispatchEvent('message', e);
  120. });
  121. // Propagate other native events
  122. // @note - isn't it too late for the 'upgrade' message?
  123. ['close', 'error', 'upgrade'].forEach(eventName => {
  124. this.#socket.addEventListener(eventName, e => this.#dispatchEvent(eventName, e));
  125. });
  126. // Forward open event and continue initialization
  127. this.#dispatchEvent('open', openEvent);
  128. resolve();
  129. });
  130. // cf. https://github.com/collective-soundworks/soundworks/issues/17
  131. // cf. https://github.com/collective-soundworks/soundworks/issues/97
  132. ws.addEventListener('error', e => {
  133. ws.terminate ? ws.terminate() : ws.close();
  134. if (e.error) {
  135. const msg = `[Socket Error] code: ${e.error.code}, message: ${e.error.message}`;
  136. logger.log(msg);
  137. }
  138. // Try to reconnect in all cases. Note that if the socket has been connected,
  139. // the close event will be propagated and the launcher will restart the process.
  140. setTimeout(trySocket, 1000);
  141. });
  142. };
  143. trySocket();
  144. });
  145. }
  146. /**
  147. * Removes all listeners and immediately close the two sockets. Is automatically
  148. * called on `client.stop()`
  149. *
  150. * Is also called when a disconnection is detected by the heartbeat (note that
  151. * in this case, the launcher will call `client.stop()` but the listeners are
  152. * already cleared so the event will be trigerred only once.
  153. */
  154. async [kSocketTerminate]() {
  155. const closeListeners = this.#listeners.get('close');
  156. this.removeAllListeners();
  157. this.#socket.close();
  158. if (closeListeners) {
  159. closeListeners.forEach(listener => listener());
  160. }
  161. return Promise.resolve();
  162. }
  163. /**
  164. * @param {string} channel - Channel name.
  165. * @param {...*} args - Content of the message.
  166. */
  167. #dispatchEvent(channel, ...args) {
  168. if (this.#listeners.has(channel)) {
  169. const callbacks = this.#listeners.get(channel);
  170. callbacks.forEach(callback => callback(...args));
  171. }
  172. }
  173. /**
  174. * Send messages with JSON compatible data types on a given channel.
  175. *
  176. * @param {string} channel - Channel name.
  177. * @param {...*} args - Payload of the message. As many arguments as needed, of
  178. * JSON compatible data types (i.e. string, number, boolean, object, array and null).
  179. */
  180. send(channel, ...args) {
  181. if (this.#socket.readyState === 1) {
  182. const msg = packStringMessage(channel, ...args);
  183. this.#socket.send(msg);
  184. }
  185. }
  186. /**
  187. * Listen messages with JSON compatible data types on a given channel.
  188. *
  189. * @param {string} channel - Channel name.
  190. * @param {Function} callback - Callback to execute when a message is received,
  191. * arguments of the callback function will match the arguments sent using the
  192. * {@link ServerSocket#send} method.
  193. */
  194. addListener(channel, callback) {
  195. if (!this.#listeners.has(channel)) {
  196. this.#listeners.set(channel, new Set());
  197. }
  198. const callbacks = this.#listeners.get(channel);
  199. callbacks.add(callback);
  200. }
  201. /**
  202. * Remove a listener from JSON compatible messages on a given channel.
  203. *
  204. * @param {string} channel - Channel name.
  205. * @param {Function} callback - Callback to remove.
  206. */
  207. removeListener(channel, callback) {
  208. if (this.#listeners.has(channel)) {
  209. const callbacks = this.#listeners.get(channel);
  210. callbacks.delete(callback);
  211. if (callbacks.size === 0) {
  212. this.#listeners.delete(channel);
  213. }
  214. }
  215. }
  216. /**
  217. * Remove all listeners of messages with JSON compatible data types.
  218. *
  219. * @param {string} channel - Channel name.
  220. */
  221. removeAllListeners(channel = null) {
  222. if (channel === null) {
  223. this.#listeners.clear();
  224. } else if (this.#listeners.has(channel)) {
  225. this.#listeners.delete(channel);
  226. }
  227. }
  228. }
  229. export default ClientSocket;