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.ts341
1 files changed, 341 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..488e4b3b
--- /dev/null
+++ b/language-web/src/main/js/xtext/XtextWebSocketClient.ts
@@ -0,0 +1,341 @@
1import { nanoid } from 'nanoid';
2
3import { getLogger } from '../utils/logger';
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('xtext.XtextWebSocketClient');
29
30export type ReconnectHandler = () => void;
31
32export type PushHandler = (
33 resourceId: string,
34 stateId: string,
35 service: string,
36 data: unknown,
37) => void;
38
39enum State {
40 Initial,
41 Opening,
42 TabVisible,
43 TabHiddenIdle,
44 TabHiddenWaiting,
45 Error,
46 TimedOut,
47}
48
49export class XtextWebSocketClient {
50 private nextMessageId = 0;
51
52 private connection!: WebSocket;
53
54 private readonly pendingRequests = new Map<string, PendingTask<unknown>>();
55
56 private readonly onReconnect: ReconnectHandler;
57
58 private readonly onPush: PushHandler;
59
60 private state = State.Initial;
61
62 private reconnectTryCount = 0;
63
64 private readonly idleTimer = new Timer(() => {
65 this.handleIdleTimeout();
66 }, BACKGROUND_IDLE_TIMEOUT_MS);
67
68 private readonly pingTimer = new Timer(() => {
69 this.sendPing();
70 }, PING_TIMEOUT_MS);
71
72 private readonly 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();
119 });
120 this.connection.addEventListener('error', (event) => {
121 log.error('Unexpected websocket error', event);
122 this.forceReconnectOnError();
123 });
124 this.connection.addEventListener('message', (event) => {
125 this.handleMessage(event.data);
126 });
127 this.connection.addEventListener('close', (event) => {
128 if (this.isLogicallyClosed && event.code === WEBSOCKET_CLOSE_OK
129 && this.pendingRequests.size === 0) {
130 log.info('Websocket closed');
131 return;
132 }
133 log.error('Websocket closed unexpectedly', event.code, event.reason);
134 this.forceReconnectOnError();
135 });
136 }
137
138 private handleVisibilityChange() {
139 if (document.visibilityState === 'hidden') {
140 if (this.state === State.TabVisible) {
141 this.handleTabHidden();
142 }
143 return;
144 }
145 this.idleTimer.cancel();
146 if (this.state === State.TabHiddenIdle || this.state === State.TabHiddenWaiting) {
147 this.handleTabVisibleConnected();
148 return;
149 }
150 if (this.state === State.TimedOut) {
151 this.reconnect();
152 }
153 }
154
155 private handleTabHidden() {
156 log.debug('Tab hidden while websocket is connected');
157 this.state = State.TabHiddenIdle;
158 this.idleTimer.schedule();
159 }
160
161 private handleTabVisibleConnected() {
162 log.debug('Tab visible while websocket is connected');
163 this.state = State.TabVisible;
164 }
165
166 private handleIdleTimeout() {
167 log.trace('Waiting for pending tasks before disconnect');
168 if (this.state === State.TabHiddenIdle) {
169 this.state = State.TabHiddenWaiting;
170 this.handleWaitingForDisconnect();
171 }
172 }
173
174 private handleWaitingForDisconnect() {
175 if (this.state !== State.TabHiddenWaiting) {
176 return;
177 }
178 const pending = this.pendingRequests.size;
179 if (pending === 0) {
180 log.info('Closing idle websocket');
181 this.state = State.TimedOut;
182 this.closeConnection(1000, 'idle timeout');
183 return;
184 }
185 log.info('Waiting for', pending, 'pending requests before closing websocket');
186 }
187
188 private sendPing() {
189 if (!this.isOpen) {
190 return;
191 }
192 const ping = nanoid();
193 log.trace('Ping', ping);
194 this.send({ ping }).then((result) => {
195 if (isPongResult(result) && result.pong === ping) {
196 log.trace('Pong', ping);
197 this.pingTimer.schedule();
198 } else {
199 log.error('Invalid pong');
200 this.forceReconnectOnError();
201 }
202 }).catch((error) => {
203 log.error('Error while waiting for ping', error);
204 this.forceReconnectOnError();
205 });
206 }
207
208 send(request: unknown): Promise<unknown> {
209 if (!this.isOpen) {
210 throw new Error('Not open');
211 }
212 const messageId = this.nextMessageId.toString(16);
213 if (messageId in this.pendingRequests) {
214 log.error('Message id wraparound still pending', messageId);
215 this.rejectRequest(messageId, new Error('Message id wraparound'));
216 }
217 if (this.nextMessageId >= Number.MAX_SAFE_INTEGER) {
218 this.nextMessageId = 0;
219 } else {
220 this.nextMessageId += 1;
221 }
222 const message = JSON.stringify({
223 id: messageId,
224 request,
225 } as IXtextWebRequest);
226 log.trace('Sending message', message);
227 return new Promise((resolve, reject) => {
228 const task = new PendingTask(resolve, reject, REQUEST_TIMEOUT_MS, () => {
229 this.removePendingRequest(messageId);
230 });
231 this.pendingRequests.set(messageId, task);
232 this.connection.send(message);
233 });
234 }
235
236 private handleMessage(messageStr: unknown) {
237 if (typeof messageStr !== 'string') {
238 log.error('Unexpected binary message', messageStr);
239 this.forceReconnectOnError();
240 return;
241 }
242 log.trace('Incoming websocket message', messageStr);
243 let message: unknown;
244 try {
245 message = JSON.parse(messageStr);
246 } catch (error) {
247 log.error('Json parse error', error);
248 this.forceReconnectOnError();
249 return;
250 }
251 if (isOkResponse(message)) {
252 this.resolveRequest(message.id, message.response);
253 } else if (isErrorResponse(message)) {
254 this.rejectRequest(message.id, new Error(`${message.error} error: ${message.message}`));
255 if (message.error === 'server') {
256 log.error('Reconnecting due to server error: ', message.message);
257 this.forceReconnectOnError();
258 }
259 } else if (isPushMessage(message)) {
260 this.onPush(
261 message.resource,
262 message.stateId,
263 message.service,
264 message.push,
265 );
266 } else {
267 log.error('Unexpected websocket message', message);
268 this.forceReconnectOnError();
269 }
270 }
271
272 private resolveRequest(messageId: string, value: unknown) {
273 const pendingRequest = this.pendingRequests.get(messageId);
274 if (pendingRequest) {
275 pendingRequest.resolve(value);
276 this.removePendingRequest(messageId);
277 return;
278 }
279 log.error('Trying to resolve unknown request', messageId, 'with', value);
280 }
281
282 private rejectRequest(messageId: string, reason?: unknown) {
283 const pendingRequest = this.pendingRequests.get(messageId);
284 if (pendingRequest) {
285 pendingRequest.reject(reason);
286 this.removePendingRequest(messageId);
287 return;
288 }
289 log.error('Trying to reject unknown request', messageId, 'with', reason);
290 }
291
292 private removePendingRequest(messageId: string) {
293 this.pendingRequests.delete(messageId);
294 this.handleWaitingForDisconnect();
295 }
296
297 forceReconnectOnError(): void {
298 if (this.isLogicallyClosed) {
299 return;
300 }
301 this.abortPendingRequests();
302 this.closeConnection(1000, 'reconnecting due to error');
303 log.error('Reconnecting after delay due to error');
304 this.handleErrorState();
305 }
306
307 private abortPendingRequests() {
308 this.pendingRequests.forEach((request) => {
309 request.reject(new Error('Websocket disconnect'));
310 });
311 this.pendingRequests.clear();
312 }
313
314 private closeConnection(code: number, reason: string) {
315 this.pingTimer.cancel();
316 const { readyState } = this.connection;
317 if (readyState !== WebSocket.CLOSING && readyState !== WebSocket.CLOSED) {
318 this.connection.close(code, reason);
319 }
320 }
321
322 private handleErrorState() {
323 this.state = State.Error;
324 this.reconnectTryCount += 1;
325 const delay = RECONNECT_DELAY_MS[this.reconnectTryCount - 1] || MAX_RECONNECT_DELAY_MS;
326 log.info('Reconnecting in', delay, 'ms');
327 this.reconnectTimer.schedule(delay);
328 }
329
330 private handleReconnect() {
331 if (this.state !== State.Error) {
332 log.error('Unexpected reconnect in', this.state);
333 return;
334 }
335 if (document.visibilityState === 'hidden') {
336 this.state = State.TimedOut;
337 } else {
338 this.reconnect();
339 }
340 }
341}