the browser-facing portion of osu!
at master 5.6 kB view raw
1// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0. 2// See the LICENCE file in the repository root for full licence text. 3 4import DispatcherAction from 'actions/dispatcher-action'; 5import SocketMessageSendAction from 'actions/socket-message-send-action'; 6import SocketStateChangedAction from 'actions/socket-state-changed-action'; 7import { dispatch, dispatchListener } from 'app-dispatcher'; 8import { route } from 'laroute'; 9import { forEach } from 'lodash'; 10import { action, computed, makeObservable, observable, reaction } from 'mobx'; 11import { NotificationEventLogoutJson, NotificationEventVerifiedJson } from 'notifications/notification-events'; 12import core from 'osu-core-singleton'; 13import SocketMessageEvent, { isSocketEventData, SocketEventData } from 'socket-message-event'; 14import RetryDelay from 'utils/retry-delay'; 15 16const isNotificationEventLogoutJson = (arg: SocketEventData): arg is NotificationEventLogoutJson => arg.event === 'logout'; 17 18const isNotificationEventVerifiedJson = (arg: SocketEventData): arg is NotificationEventVerifiedJson => arg.event === 'verified'; 19 20interface NotificationFeedMetaJson { 21 url: string; 22} 23 24type ConnectionStatus = 'disconnected' | 'disconnecting' | 'connecting' | 'connected'; 25 26@dispatchListener 27export default class SocketWorker { 28 @observable connectionStatus: ConnectionStatus = 'disconnected'; 29 @observable hasConnectedOnce = false; 30 userId: number | null = null; 31 @observable private active = false; 32 private endpoint?: string; 33 private readonly retryDelay = new RetryDelay(); 34 private readonly timeout: Partial<Record<string, number>> = {}; 35 private ws: WebSocket | null | undefined; 36 private xhr: JQuery.jqXHR<NotificationFeedMetaJson> | null = null; 37 38 @computed 39 get isConnected() { 40 return this.connectionStatus === 'connected'; 41 } 42 43 constructor() { 44 makeObservable(this); 45 46 reaction( 47 () => this.isConnected, 48 (value) => dispatch(new SocketStateChangedAction(value)), 49 { fireImmediately: true }, 50 ); 51 } 52 53 boot() { 54 this.active = this.userId != null; 55 56 if (this.active) { 57 this.startWebSocket(); 58 } 59 } 60 61 handleDispatchAction(event: DispatcherAction) { 62 // ignore everything if not connected. 63 if (this.ws?.readyState !== WebSocket.OPEN) return; 64 65 if (event instanceof SocketMessageSendAction) { 66 this.ws?.send(JSON.stringify(event.message)); 67 } 68 } 69 70 setUserId(id: number | null) { 71 if (id === this.userId) { 72 return; 73 } 74 75 if (this.active) { 76 this.destroy(); 77 } 78 79 this.userId = id; 80 this.boot(); 81 } 82 83 @action 84 private connectWebSocket() { 85 if (!this.active || this.endpoint == null || this.ws != null) { 86 return; 87 } 88 89 this.connectionStatus = 'connecting'; 90 window.clearTimeout(this.timeout.connectWebSocket); 91 92 const tokenEl = document.querySelector('meta[name=csrf-token]'); 93 94 if (tokenEl == null) { 95 return; 96 } 97 98 const token = tokenEl.getAttribute('content'); 99 this.ws = new WebSocket(`${this.endpoint}?csrf=${token}`); 100 this.ws.addEventListener('open', action(() => { 101 this.retryDelay.reset(); 102 this.connectionStatus = 'connected'; 103 this.hasConnectedOnce = true; 104 })); 105 this.ws.addEventListener('close', this.reconnectWebSocket); 106 this.ws.addEventListener('message', this.handleNewEvent); 107 } 108 109 @action 110 private destroy() { 111 this.connectionStatus = 'disconnecting'; 112 113 this.userId = null; 114 this.active = false; 115 this.xhr?.abort(); 116 forEach(this.timeout, (timeout) => window.clearTimeout(timeout)); 117 118 if (this.ws != null) { 119 this.ws.close(); 120 this.ws = null; 121 } 122 123 this.connectionStatus = 'disconnected'; 124 } 125 126 private readonly handleNewEvent = (event: MessageEvent<string>) => { 127 const eventData = this.parseMessageEvent(event); 128 if (eventData == null) return; 129 130 if (isNotificationEventLogoutJson(eventData)) { 131 this.destroy(); 132 core.userLoginObserver.logout(); 133 } else if (isNotificationEventVerifiedJson(eventData)) { 134 $.publish('user-verification:success'); 135 } else { 136 dispatch(new SocketMessageEvent(eventData)); 137 } 138 }; 139 140 private parseMessageEvent(event: MessageEvent<string>) { 141 try { 142 const json = JSON.parse(event.data) as unknown; 143 if (isSocketEventData(json)) { 144 return json; 145 } 146 147 console.error('message missing event type.'); 148 } catch { 149 console.error('Failed parsing data:', event.data); 150 } 151 } 152 153 @action 154 private readonly reconnectWebSocket = () => { 155 this.connectionStatus = 'disconnected'; 156 if (!this.active) { 157 return; 158 } 159 160 this.timeout.connectWebSocket = window.setTimeout(action(() => { 161 this.ws = null; 162 this.connectWebSocket(); 163 }), this.retryDelay.get()); 164 }; 165 166 private readonly startWebSocket = () => { 167 if (this.endpoint != null) { 168 return this.connectWebSocket(); 169 } 170 171 if (this.xhr != null) { 172 return; 173 } 174 175 window.clearTimeout(this.timeout.startWebSocket); 176 177 this.xhr = $.get(route('notifications.endpoint')); 178 this.xhr 179 .always(() => { 180 this.xhr = null; 181 }) 182 .done((data) => { 183 this.retryDelay.reset(); 184 this.endpoint = data.url; 185 this.connectWebSocket(); 186 }) 187 .fail((xhr) => { 188 // Check if the user is logged out. 189 // TODO: Add message to the popup. 190 if (xhr.status === 401) { 191 this.destroy(); 192 return; 193 } 194 this.timeout.startWebSocket = window.setTimeout(this.startWebSocket, this.retryDelay.get()); 195 }); 196 }; 197}