You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

273 lines
6.8 KiB

  1. // @ts-check
  2. import WebSocketClient from '@gamestdio/websocket';
  3. /**
  4. * @type {WebSocketClient | undefined}
  5. */
  6. let sharedConnection;
  7. /**
  8. * @typedef Subscription
  9. * @property {string} channelName
  10. * @property {Object.<string, string>} params
  11. * @property {function(): void} onConnect
  12. * @property {function(StreamEvent): void} onReceive
  13. * @property {function(): void} onDisconnect
  14. */
  15. /**
  16. * @typedef StreamEvent
  17. * @property {string} event
  18. * @property {object} payload
  19. */
  20. /**
  21. * @type {Array.<Subscription>}
  22. */
  23. const subscriptions = [];
  24. /**
  25. * @type {Object.<string, number>}
  26. */
  27. const subscriptionCounters = {};
  28. /**
  29. * @param {Subscription} subscription
  30. */
  31. const addSubscription = subscription => {
  32. subscriptions.push(subscription);
  33. };
  34. /**
  35. * @param {Subscription} subscription
  36. */
  37. const removeSubscription = subscription => {
  38. const index = subscriptions.indexOf(subscription);
  39. if (index !== -1) {
  40. subscriptions.splice(index, 1);
  41. }
  42. };
  43. /**
  44. * @param {Subscription} subscription
  45. */
  46. const subscribe = ({ channelName, params, onConnect }) => {
  47. const key = channelNameWithInlineParams(channelName, params);
  48. subscriptionCounters[key] = subscriptionCounters[key] || 0;
  49. if (subscriptionCounters[key] === 0) {
  50. sharedConnection.send(JSON.stringify({ type: 'subscribe', stream: channelName, ...params }));
  51. }
  52. subscriptionCounters[key] += 1;
  53. onConnect();
  54. };
  55. /**
  56. * @param {Subscription} subscription
  57. */
  58. const unsubscribe = ({ channelName, params, onDisconnect }) => {
  59. const key = channelNameWithInlineParams(channelName, params);
  60. subscriptionCounters[key] = subscriptionCounters[key] || 1;
  61. if (subscriptionCounters[key] === 1 && sharedConnection.readyState === WebSocketClient.OPEN) {
  62. sharedConnection.send(JSON.stringify({ type: 'unsubscribe', stream: channelName, ...params }));
  63. }
  64. subscriptionCounters[key] -= 1;
  65. onDisconnect();
  66. };
  67. const sharedCallbacks = {
  68. connected () {
  69. subscriptions.forEach(subscription => subscribe(subscription));
  70. },
  71. received (data) {
  72. const { stream } = data;
  73. subscriptions.filter(({ channelName, params }) => {
  74. const streamChannelName = stream[0];
  75. if (stream.length === 1) {
  76. return channelName === streamChannelName;
  77. }
  78. const streamIdentifier = stream[1];
  79. if (['hashtag', 'hashtag:local'].includes(channelName)) {
  80. return channelName === streamChannelName && params.tag === streamIdentifier;
  81. } else if (channelName === 'list') {
  82. return channelName === streamChannelName && params.list === streamIdentifier;
  83. }
  84. return false;
  85. }).forEach(subscription => {
  86. subscription.onReceive(data);
  87. });
  88. },
  89. disconnected () {
  90. subscriptions.forEach(({ onDisconnect }) => onDisconnect());
  91. },
  92. reconnected () {
  93. subscriptions.forEach(subscription => subscribe(subscription));
  94. },
  95. };
  96. /**
  97. * @param {string} channelName
  98. * @param {Object.<string, string>} params
  99. * @return {string}
  100. */
  101. const channelNameWithInlineParams = (channelName, params) => {
  102. if (Object.keys(params).length === 0) {
  103. return channelName;
  104. }
  105. return `${channelName}&${Object.keys(params).map(key => `${key}=${params[key]}`).join('&')}`;
  106. };
  107. /**
  108. * @param {string} channelName
  109. * @param {Object.<string, string>} params
  110. * @param {function(Function, Function): { onConnect: (function(): void), onReceive: (function(StreamEvent): void), onDisconnect: (function(): void) }} callbacks
  111. * @return {function(): void}
  112. */
  113. export const connectStream = (channelName, params, callbacks) => (dispatch, getState) => {
  114. const streamingAPIBaseURL = getState().getIn(['meta', 'streaming_api_base_url']);
  115. const accessToken = getState().getIn(['meta', 'access_token']);
  116. const { onConnect, onReceive, onDisconnect } = callbacks(dispatch, getState);
  117. // If we cannot use a websockets connection, we must fall back
  118. // to using individual connections for each channel
  119. if (!streamingAPIBaseURL.startsWith('ws')) {
  120. const connection = createConnection(streamingAPIBaseURL, accessToken, channelNameWithInlineParams(channelName, params), {
  121. connected () {
  122. onConnect();
  123. },
  124. received (data) {
  125. onReceive(data);
  126. },
  127. disconnected () {
  128. onDisconnect();
  129. },
  130. reconnected () {
  131. onConnect();
  132. },
  133. });
  134. return () => {
  135. connection.close();
  136. };
  137. }
  138. const subscription = {
  139. channelName,
  140. params,
  141. onConnect,
  142. onReceive,
  143. onDisconnect,
  144. };
  145. addSubscription(subscription);
  146. // If a connection is open, we can execute the subscription right now. Otherwise,
  147. // because we have already registered it, it will be executed on connect
  148. if (!sharedConnection) {
  149. sharedConnection = /** @type {WebSocketClient} */ (createConnection(streamingAPIBaseURL, accessToken, '', sharedCallbacks));
  150. } else if (sharedConnection.readyState === WebSocketClient.OPEN) {
  151. subscribe(subscription);
  152. }
  153. return () => {
  154. removeSubscription(subscription);
  155. unsubscribe(subscription);
  156. };
  157. };
  158. const KNOWN_EVENT_TYPES = [
  159. 'update',
  160. 'delete',
  161. 'notification',
  162. 'conversation',
  163. 'filters_changed',
  164. 'encrypted_message',
  165. 'announcement',
  166. 'announcement.delete',
  167. 'announcement.reaction',
  168. ];
  169. /**
  170. * @param {MessageEvent} e
  171. * @param {function(StreamEvent): void} received
  172. */
  173. const handleEventSourceMessage = (e, received) => {
  174. received({
  175. event: e.type,
  176. payload: e.data,
  177. });
  178. };
  179. /**
  180. * @param {string} streamingAPIBaseURL
  181. * @param {string} accessToken
  182. * @param {string} channelName
  183. * @param {{ connected: Function, received: function(StreamEvent): void, disconnected: Function, reconnected: Function }} callbacks
  184. * @return {WebSocketClient | EventSource}
  185. */
  186. const createConnection = (streamingAPIBaseURL, accessToken, channelName, { connected, received, disconnected, reconnected }) => {
  187. const params = channelName.split('&');
  188. channelName = params.shift();
  189. if (streamingAPIBaseURL.startsWith('ws')) {
  190. const ws = new WebSocketClient(`${streamingAPIBaseURL}/api/v1/streaming/?${params.join('&')}`, accessToken);
  191. ws.onopen = connected;
  192. ws.onmessage = e => received(JSON.parse(e.data));
  193. ws.onclose = disconnected;
  194. ws.onreconnect = reconnected;
  195. return ws;
  196. }
  197. channelName = channelName.replace(/:/g, '/');
  198. if (channelName.endsWith(':media')) {
  199. channelName = channelName.replace('/media', '');
  200. params.push('only_media=true');
  201. }
  202. params.push(`access_token=${accessToken}`);
  203. const es = new EventSource(`${streamingAPIBaseURL}/api/v1/streaming/${channelName}?${params.join('&')}`);
  204. let firstConnect = true;
  205. es.onopen = () => {
  206. if (firstConnect) {
  207. firstConnect = false;
  208. connected();
  209. } else {
  210. reconnected();
  211. }
  212. };
  213. KNOWN_EVENT_TYPES.forEach(type => {
  214. es.addEventListener(type, e => handleEventSourceMessage(/** @type {MessageEvent} */ (e), received));
  215. });
  216. es.onerror = /** @type {function(): void} */ (disconnected);
  217. return es;
  218. };