aboutsummaryrefslogtreecommitdiffstats
path: root/language-web/src/main/js/xtext/XtextWebSocketClient.ts
diff options
context:
space:
mode:
Diffstat (limited to 'language-web/src/main/js/xtext/XtextWebSocketClient.ts')
-rw-r--r--language-web/src/main/js/xtext/XtextWebSocketClient.ts345
1 files changed, 345 insertions, 0 deletions
diff --git a/language-web/src/main/js/xtext/XtextWebSocketClient.ts b/language-web/src/main/js/xtext/XtextWebSocketClient.ts
new file mode 100644
index 00000000..5b775500
--- /dev/null
+++ b/language-web/src/main/js/xtext/XtextWebSocketClient.ts
@@ -0,0 +1,345 @@
1import { nanoid } from 'nanoid';
2
3import { getLogger } from '../logging';
4import { PendingTask } from '../utils/PendingTask';
5import { Timer } from '../utils/Timer';
6import {
7 isErrorResponse,
8 isOkResponse,
9 isPushMessage,
10 IXtextWebRequest,
11} from './xtextMessages';
12import { isPongResult } from './xtextServiceResults';
13
14const XTEXT_SUBPROTOCOL_V1 = 'tools.refinery.language.web.xtext.v1';
15
16const WEBSOCKET_CLOSE_OK = 1000;
17
18const RECONNECT_DELAY_MS = [200, 1000, 5000, 30_000];
19
20const MAX_RECONNECT_DELAY_MS = RECONNECT_DELAY_MS[RECONNECT_DELAY_MS.length - 1];
21
22const BACKGROUND_IDLE_TIMEOUT_MS = 5 * 60 * 1000;
23
24const PING_TIMEOUT_MS = 10 * 1000;
25
26const REQUEST_TIMEOUT_MS = 1000;
27
28const log = getLogger('XtextWebSocketClient');
29
30type ReconnectHandler = () => Promise<void>;
31
32type PushHandler = (
33 resourceId: string,
34 stateId: string,
35 service: string,
36 data: unknown,
37) => Promise<void>;
38
39enum State {
40 Initial,
41 Opening,
42 TabVisible,
43 TabHiddenIdle,
44 TabHiddenWaiting,
45 Error,
46 TimedOut,
47}
48
49export class XtextWebSocketClient {
50 nextMessageId = 0;
51
52 connection!: WebSocket;
53
54 pendingRequests = new Map<string, PendingTask<unknown>>();
55
56 onReconnect: ReconnectHandler;
57
58 onPush: PushHandler;
59
60 state = State.Initial;
61
62 reconnectTryCount = 0;
63
64 idleTimer = new Timer(() => {
65 this.handleIdleTimeout();
66 }, BACKGROUND_IDLE_TIMEOUT_MS);
67
68 pingTimer = new Timer(() => {
69 this.sendPing();
70 }, PING_TIMEOUT_MS);
71
72 reconnectTimer = new Timer(() => {
73 this.handleReconnect();
74 });
75
76 constructor(onReconnect: ReconnectHandler, onPush: PushHandler) {
77 this.onReconnect = onReconnect;
78 this.onPush = onPush;
79 document.addEventListener('visibilitychange', () => {
80 this.handleVisibilityChange();
81 });
82 this.reconnect();
83 }
84
85 private get isLogicallyClosed(): boolean {
86 return this.state === State.Error || this.state === State.TimedOut;
87 }
88
89 get isOpen(): boolean {
90 return this.state === State.TabVisible
91 || this.state === State.TabHiddenIdle
92 || this.state === State.TabHiddenWaiting;
93 }
94
95 private reconnect() {
96 if (this.isOpen || this.state === State.Opening) {
97 log.error('Trying to reconnect from', this.state);
98 return;
99 }
100 this.state = State.Opening;
101 const webSocketServer = window.origin.replace(/^http/, 'ws');
102 const webSocketUrl = `${webSocketServer}/xtext-service`;
103 this.connection = new WebSocket(webSocketUrl, XTEXT_SUBPROTOCOL_V1);
104 this.connection.addEventListener('open', () => {
105 if (this.connection.protocol !== XTEXT_SUBPROTOCOL_V1) {
106 log.error('Unknown subprotocol', this.connection.protocol, 'selected by server');
107 this.forceReconnectOnError();
108 }
109 if (document.visibilityState === 'hidden') {
110 this.handleTabHidden();
111 } else {
112 this.handleTabVisibleConnected();
113 }
114 log.info('Connected to websocket');
115 this.nextMessageId = 0;
116 this.reconnectTryCount = 0;
117 this.pingTimer.schedule();
118 this.onReconnect().catch((error) => {
119 log.error('Unexpected error in onReconnect handler', error);
120 });
121 });
122 this.connection.addEventListener('error', (event) => {
123 log.error('Unexpected websocket error', event);
124 this.forceReconnectOnError();
125 });
126 this.connection.addEventListener('message', (event) => {
127 this.handleMessage(event.data);
128 });
129 this.connection.addEventListener('close', (event) => {
130 if (this.isLogicallyClosed && event.code === WEBSOCKET_CLOSE_OK
131 && this.pendingRequests.size === 0) {
132 log.info('Websocket closed');
133 return;
134 }
135 log.error('Websocket closed unexpectedly', event.code, event.reason);
136 this.forceReconnectOnError();
137 });
138 }
139
140 private handleVisibilityChange() {
141 if (document.visibilityState === 'hidden') {
142 if (this.state === State.TabVisible) {
143 this.handleTabHidden();
144 }
145 return;
146 }
147 this.idleTimer.cancel();
148 if (this.state === State.TabHiddenIdle || this.state === State.TabHiddenWaiting) {
149 this.handleTabVisibleConnected();
150 return;
151 }
152 if (this.state === State.TimedOut) {
153 this.reconnect();
154 }
155 }
156
157 private handleTabHidden() {
158 log.debug('Tab hidden while websocket is connected');
159 this.state = State.TabHiddenIdle;
160 this.idleTimer.schedule();
161 }
162
163 private handleTabVisibleConnected() {
164 log.debug('Tab visible while websocket is connected');
165 this.state = State.TabVisible;
166 }
167
168 private handleIdleTimeout() {
169 log.trace('Waiting for pending tasks before disconnect');
170 if (this.state === State.TabHiddenIdle) {
171 this.state = State.TabHiddenWaiting;
172 this.handleWaitingForDisconnect();
173 }
174 }
175
176 private handleWaitingForDisconnect() {
177 if (this.state !== State.TabHiddenWaiting) {
178 return;
179 }
180 const pending = this.pendingRequests.size;
181 if (pending === 0) {
182 log.info('Closing idle websocket');
183 this.state = State.TimedOut;
184 this.closeConnection(1000, 'idle timeout');
185 return;
186 }
187 log.info('Waiting for', pending, 'pending requests before closing websocket');
188 }
189
190 private sendPing() {
191 if (!this.isOpen) {
192 return;
193 }
194 const ping = nanoid();
195 log.trace('Ping', ping);
196 this.send({ ping }).then((result) => {
197 if (isPongResult(result) && result.pong === ping) {
198 log.trace('Pong', ping);
199 this.pingTimer.schedule();
200 } else {
201 log.error('Invalid pong');
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 IXtextWebRequest);
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 if (isOkResponse(message)) {
254 this.resolveRequest(message.id, message.response);
255 } else if (isErrorResponse(message)) {
256 this.rejectRequest(message.id, new Error(`${message.error} error: ${message.message}`));
257 if (message.error === 'server') {
258 log.error('Reconnecting due to server error: ', message.message);
259 this.forceReconnectOnError();
260 }
261 } else if (isPushMessage(message)) {
262 this.onPush(
263 message.resource,
264 message.stateId,
265 message.service,
266 message.push,
267 ).catch((error) => {
268 log.error('Unexpected error in onPush handler', error);
269 });
270 } else {
271 log.error('Unexpected websocket message', message);
272 this.forceReconnectOnError();
273 }
274 }
275
276 private resolveRequest(messageId: string, value: unknown) {
277 const pendingRequest = this.pendingRequests.get(messageId);
278 if (pendingRequest) {
279 pendingRequest.resolve(value);
280 this.removePendingRequest(messageId);
281 return;
282 }
283 log.error('Trying to resolve unknown request', messageId, 'with', value);
284 }
285
286 private rejectRequest(messageId: string, reason?: unknown) {
287 const pendingRequest = this.pendingRequests.get(messageId);
288 if (pendingRequest) {
289 pendingRequest.reject(reason);
290 this.removePendingRequest(messageId);
291 return;
292 }
293 log.error('Trying to reject unknown request', messageId, 'with', reason);
294 }
295
296 private removePendingRequest(messageId: string) {
297 this.pendingRequests.delete(messageId);
298 this.handleWaitingForDisconnect();
299 }
300
301 forceReconnectOnError(): void {
302 if (this.isLogicallyClosed) {
303 return;
304 }
305 this.abortPendingRequests();
306 this.closeConnection(1000, 'reconnecting due to error');
307 log.error('Reconnecting after delay due to error');
308 this.handleErrorState();
309 }
310
311 private abortPendingRequests() {
312 this.pendingRequests.forEach((request) => {
313 request.reject(new Error('Websocket disconnect'));
314 });
315 this.pendingRequests.clear();
316 }
317
318 private closeConnection(code: number, reason: string) {
319 this.pingTimer.cancel();
320 const { readyState } = this.connection;
321 if (readyState !== WebSocket.CLOSING && readyState !== WebSocket.CLOSED) {
322 this.connection.close(code, reason);
323 }
324 }
325
326 private handleErrorState() {
327 this.state = State.Error;
328 this.reconnectTryCount += 1;
329 const delay = RECONNECT_DELAY_MS[this.reconnectTryCount - 1] || MAX_RECONNECT_DELAY_MS;
330 log.info('Reconnecting in', delay, 'ms');
331 this.reconnectTimer.schedule(delay);
332 }
333
334 private handleReconnect() {
335 if (this.state !== State.Error) {
336 log.error('Unexpected reconnect in', this.state);
337 return;
338 }
339 if (document.visibilityState === 'hidden') {
340 this.state = State.TimedOut;
341 } else {
342 this.reconnect();
343 }
344 }
345}