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}