aboutsummaryrefslogtreecommitdiffstats
path: root/subprojects/frontend/src/xtext/XtextWebSocketClient.ts
diff options
context:
space:
mode:
authorLibravatar Kristóf Marussy <kristof@marussy.com>2021-12-13 02:07:04 +0100
committerLibravatar Kristóf Marussy <kristof@marussy.com>2021-12-14 02:14:23 +0100
commita96c52b21e7e590bbdd70b80896780a446fa2e8b (patch)
tree663619baa254577bb2f5342192e80bca692ad91d /subprojects/frontend/src/xtext/XtextWebSocketClient.ts
parentbuild: move modules into subproject directory (diff)
downloadrefinery-a96c52b21e7e590bbdd70b80896780a446fa2e8b.tar.gz
refinery-a96c52b21e7e590bbdd70b80896780a446fa2e8b.tar.zst
refinery-a96c52b21e7e590bbdd70b80896780a446fa2e8b.zip
build: separate module for frontend
This allows us to simplify the webpack configuration and the gradle build scripts.
Diffstat (limited to 'subprojects/frontend/src/xtext/XtextWebSocketClient.ts')
-rw-r--r--subprojects/frontend/src/xtext/XtextWebSocketClient.ts362
1 files changed, 362 insertions, 0 deletions
diff --git a/subprojects/frontend/src/xtext/XtextWebSocketClient.ts b/subprojects/frontend/src/xtext/XtextWebSocketClient.ts
new file mode 100644
index 00000000..2ce20a54
--- /dev/null
+++ b/subprojects/frontend/src/xtext/XtextWebSocketClient.ts
@@ -0,0 +1,362 @@
1import { nanoid } from 'nanoid';
2
3import { getLogger } from '../utils/logger';
4import { PendingTask } from '../utils/PendingTask';
5import { Timer } from '../utils/Timer';
6import {
7 xtextWebErrorResponse,
8 XtextWebRequest,
9 xtextWebOkResponse,
10 xtextWebPushMessage,
11 XtextWebPushService,
12} from './xtextMessages';
13import { pongResult } from './xtextServiceResults';
14
15const XTEXT_SUBPROTOCOL_V1 = 'tools.refinery.language.web.xtext.v1';
16
17const WEBSOCKET_CLOSE_OK = 1000;
18
19const RECONNECT_DELAY_MS = [200, 1000, 5000, 30_000];
20
21const MAX_RECONNECT_DELAY_MS = RECONNECT_DELAY_MS[RECONNECT_DELAY_MS.length - 1];
22
23const BACKGROUND_IDLE_TIMEOUT_MS = 5 * 60 * 1000;
24
25const PING_TIMEOUT_MS = 10 * 1000;
26
27const REQUEST_TIMEOUT_MS = 1000;
28
29const log = getLogger('xtext.XtextWebSocketClient');
30
31export type ReconnectHandler = () => void;
32
33export type PushHandler = (
34 resourceId: string,
35 stateId: string,
36 service: XtextWebPushService,
37 data: unknown,
38) => void;
39
40enum State {
41 Initial,
42 Opening,
43 TabVisible,
44 TabHiddenIdle,
45 TabHiddenWaiting,
46 Error,
47 TimedOut,
48}
49
50export class XtextWebSocketClient {
51 private nextMessageId = 0;
52
53 private connection!: WebSocket;
54
55 private readonly pendingRequests = new Map<string, PendingTask<unknown>>();
56
57 private readonly onReconnect: ReconnectHandler;
58
59 private readonly onPush: PushHandler;
60
61 private state = State.Initial;
62
63 private reconnectTryCount = 0;
64
65 private readonly idleTimer = new Timer(() => {
66 this.handleIdleTimeout();
67 }, BACKGROUND_IDLE_TIMEOUT_MS);
68
69 private readonly pingTimer = new Timer(() => {
70 this.sendPing();
71 }, PING_TIMEOUT_MS);
72
73 private readonly reconnectTimer = new Timer(() => {
74 this.handleReconnect();
75 });
76
77 constructor(onReconnect: ReconnectHandler, onPush: PushHandler) {
78 this.onReconnect = onReconnect;
79 this.onPush = onPush;
80 document.addEventListener('visibilitychange', () => {
81 this.handleVisibilityChange();
82 });
83 this.reconnect();
84 }
85
86 private get isLogicallyClosed(): boolean {
87 return this.state === State.Error || this.state === State.TimedOut;
88 }
89
90 get isOpen(): boolean {
91 return this.state === State.TabVisible
92 || this.state === State.TabHiddenIdle
93 || this.state === State.TabHiddenWaiting;
94 }
95
96 private reconnect() {
97 if (this.isOpen || this.state === State.Opening) {
98 log.error('Trying to reconnect from', this.state);
99 return;
100 }
101 this.state = State.Opening;
102 const webSocketServer = window.origin.replace(/^http/, 'ws');
103 const webSocketUrl = `${webSocketServer}/xtext-service`;
104 this.connection = new WebSocket(webSocketUrl, XTEXT_SUBPROTOCOL_V1);
105 this.connection.addEventListener('open', () => {
106 if (this.connection.protocol !== XTEXT_SUBPROTOCOL_V1) {
107 log.error('Unknown subprotocol', this.connection.protocol, 'selected by server');
108 this.forceReconnectOnError();
109 }
110 if (document.visibilityState === 'hidden') {
111 this.handleTabHidden();
112 } else {
113 this.handleTabVisibleConnected();
114 }
115 log.info('Connected to websocket');
116 this.nextMessageId = 0;
117 this.reconnectTryCount = 0;
118 this.pingTimer.schedule();
119 this.onReconnect();
120 });
121 this.connection.addEventListener('error', (event) => {
122 log.error('Unexpected websocket error', event);
123 this.forceReconnectOnError();
124 });
125 this.connection.addEventListener('message', (event) => {
126 this.handleMessage(event.data);
127 });
128 this.connection.addEventListener('close', (event) => {
129 if (this.isLogicallyClosed && event.code === WEBSOCKET_CLOSE_OK
130 && this.pendingRequests.size === 0) {
131 log.info('Websocket closed');
132 return;
133 }
134 log.error('Websocket closed unexpectedly', event.code, event.reason);
135 this.forceReconnectOnError();
136 });
137 }
138
139 private handleVisibilityChange() {
140 if (document.visibilityState === 'hidden') {
141 if (this.state === State.TabVisible) {
142 this.handleTabHidden();
143 }
144 return;
145 }
146 this.idleTimer.cancel();
147 if (this.state === State.TabHiddenIdle || this.state === State.TabHiddenWaiting) {
148 this.handleTabVisibleConnected();
149 return;
150 }
151 if (this.state === State.TimedOut) {
152 this.reconnect();
153 }
154 }
155
156 private handleTabHidden() {
157 log.debug('Tab hidden while websocket is connected');
158 this.state = State.TabHiddenIdle;
159 this.idleTimer.schedule();
160 }
161
162 private handleTabVisibleConnected() {
163 log.debug('Tab visible while websocket is connected');
164 this.state = State.TabVisible;
165 }
166
167 private handleIdleTimeout() {
168 log.trace('Waiting for pending tasks before disconnect');
169 if (this.state === State.TabHiddenIdle) {
170 this.state = State.TabHiddenWaiting;
171 this.handleWaitingForDisconnect();
172 }
173 }
174
175 private handleWaitingForDisconnect() {
176 if (this.state !== State.TabHiddenWaiting) {
177 return;
178 }
179 const pending = this.pendingRequests.size;
180 if (pending === 0) {
181 log.info('Closing idle websocket');
182 this.state = State.TimedOut;
183 this.closeConnection(1000, 'idle timeout');
184 return;
185 }
186 log.info('Waiting for', pending, 'pending requests before closing websocket');
187 }
188
189 private sendPing() {
190 if (!this.isOpen) {
191 return;
192 }
193 const ping = nanoid();
194 log.trace('Ping', ping);
195 this.send({ ping }).then((result) => {
196 const parsedPongResult = pongResult.safeParse(result);
197 if (parsedPongResult.success && parsedPongResult.data.pong === ping) {
198 log.trace('Pong', ping);
199 this.pingTimer.schedule();
200 } else {
201 log.error('Invalid pong:', parsedPongResult, 'expected:', ping);
202 this.forceReconnectOnError();
203 }
204 }).catch((error) => {
205 log.error('Error while waiting for ping', error);
206 this.forceReconnectOnError();
207 });
208 }
209
210 send(request: unknown): Promise<unknown> {
211 if (!this.isOpen) {
212 throw new Error('Not open');
213 }
214 const messageId = this.nextMessageId.toString(16);
215 if (messageId in this.pendingRequests) {
216 log.error('Message id wraparound still pending', messageId);
217 this.rejectRequest(messageId, new Error('Message id wraparound'));
218 }
219 if (this.nextMessageId >= Number.MAX_SAFE_INTEGER) {
220 this.nextMessageId = 0;
221 } else {
222 this.nextMessageId += 1;
223 }
224 const message = JSON.stringify({
225 id: messageId,
226 request,
227 } as XtextWebRequest);
228 log.trace('Sending message', message);
229 return new Promise((resolve, reject) => {
230 const task = new PendingTask(resolve, reject, REQUEST_TIMEOUT_MS, () => {
231 this.removePendingRequest(messageId);
232 });
233 this.pendingRequests.set(messageId, task);
234 this.connection.send(message);
235 });
236 }
237
238 private handleMessage(messageStr: unknown) {
239 if (typeof messageStr !== 'string') {
240 log.error('Unexpected binary message', messageStr);
241 this.forceReconnectOnError();
242 return;
243 }
244 log.trace('Incoming websocket message', messageStr);
245 let message: unknown;
246 try {
247 message = JSON.parse(messageStr);
248 } catch (error) {
249 log.error('Json parse error', error);
250 this.forceReconnectOnError();
251 return;
252 }
253 const okResponse = xtextWebOkResponse.safeParse(message);
254 if (okResponse.success) {
255 const { id, response } = okResponse.data;
256 this.resolveRequest(id, response);
257 return;
258 }
259 const errorResponse = xtextWebErrorResponse.safeParse(message);
260 if (errorResponse.success) {
261 const { id, error, message: errorMessage } = errorResponse.data;
262 this.rejectRequest(id, new Error(`${error} error: ${errorMessage}`));
263 if (error === 'server') {
264 log.error('Reconnecting due to server error: ', errorMessage);
265 this.forceReconnectOnError();
266 }
267 return;
268 }
269 const pushMessage = xtextWebPushMessage.safeParse(message);
270 if (pushMessage.success) {
271 const {
272 resource,
273 stateId,
274 service,
275 push,
276 } = pushMessage.data;
277 this.onPush(resource, stateId, service, push);
278 } else {
279 log.error(
280 'Unexpected websocket message:',
281 message,
282 'not ok response because:',
283 okResponse.error,
284 'not error response because:',
285 errorResponse.error,
286 'not push message because:',
287 pushMessage.error,
288 );
289 this.forceReconnectOnError();
290 }
291 }
292
293 private resolveRequest(messageId: string, value: unknown) {
294 const pendingRequest = this.pendingRequests.get(messageId);
295 if (pendingRequest) {
296 pendingRequest.resolve(value);
297 this.removePendingRequest(messageId);
298 return;
299 }
300 log.error('Trying to resolve unknown request', messageId, 'with', value);
301 }
302
303 private rejectRequest(messageId: string, reason?: unknown) {
304 const pendingRequest = this.pendingRequests.get(messageId);
305 if (pendingRequest) {
306 pendingRequest.reject(reason);
307 this.removePendingRequest(messageId);
308 return;
309 }
310 log.error('Trying to reject unknown request', messageId, 'with', reason);
311 }
312
313 private removePendingRequest(messageId: string) {
314 this.pendingRequests.delete(messageId);
315 this.handleWaitingForDisconnect();
316 }
317
318 forceReconnectOnError(): void {
319 if (this.isLogicallyClosed) {
320 return;
321 }
322 this.abortPendingRequests();
323 this.closeConnection(1000, 'reconnecting due to error');
324 log.error('Reconnecting after delay due to error');
325 this.handleErrorState();
326 }
327
328 private abortPendingRequests() {
329 this.pendingRequests.forEach((request) => {
330 request.reject(new Error('Websocket disconnect'));
331 });
332 this.pendingRequests.clear();
333 }
334
335 private closeConnection(code: number, reason: string) {
336 this.pingTimer.cancel();
337 const { readyState } = this.connection;
338 if (readyState !== WebSocket.CLOSING && readyState !== WebSocket.CLOSED) {
339 this.connection.close(code, reason);
340 }
341 }
342
343 private handleErrorState() {
344 this.state = State.Error;
345 this.reconnectTryCount += 1;
346 const delay = RECONNECT_DELAY_MS[this.reconnectTryCount - 1] || MAX_RECONNECT_DELAY_MS;
347 log.info('Reconnecting in', delay, 'ms');
348 this.reconnectTimer.schedule(delay);
349 }
350
351 private handleReconnect() {
352 if (this.state !== State.Error) {
353 log.error('Unexpected reconnect in', this.state);
354 return;
355 }
356 if (document.visibilityState === 'hidden') {
357 this.state = State.TimedOut;
358 } else {
359 this.reconnect();
360 }
361 }
362}