diff options
Diffstat (limited to 'language-web/src/main/js/xtext/XtextWebSocketClient.ts')
-rw-r--r-- | language-web/src/main/js/xtext/XtextWebSocketClient.ts | 345 |
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 @@ | |||
1 | import { nanoid } from 'nanoid'; | ||
2 | |||
3 | import { getLogger } from '../logging'; | ||
4 | import { PendingTask } from '../utils/PendingTask'; | ||
5 | import { Timer } from '../utils/Timer'; | ||
6 | import { | ||
7 | isErrorResponse, | ||
8 | isOkResponse, | ||
9 | isPushMessage, | ||
10 | IXtextWebRequest, | ||
11 | } from './xtextMessages'; | ||
12 | import { isPongResult } from './xtextServiceResults'; | ||
13 | |||
14 | const XTEXT_SUBPROTOCOL_V1 = 'tools.refinery.language.web.xtext.v1'; | ||
15 | |||
16 | const WEBSOCKET_CLOSE_OK = 1000; | ||
17 | |||
18 | const RECONNECT_DELAY_MS = [200, 1000, 5000, 30_000]; | ||
19 | |||
20 | const MAX_RECONNECT_DELAY_MS = RECONNECT_DELAY_MS[RECONNECT_DELAY_MS.length - 1]; | ||
21 | |||
22 | const BACKGROUND_IDLE_TIMEOUT_MS = 5 * 60 * 1000; | ||
23 | |||
24 | const PING_TIMEOUT_MS = 10 * 1000; | ||
25 | |||
26 | const REQUEST_TIMEOUT_MS = 1000; | ||
27 | |||
28 | const log = getLogger('XtextWebSocketClient'); | ||
29 | |||
30 | type ReconnectHandler = () => Promise<void>; | ||
31 | |||
32 | type PushHandler = ( | ||
33 | resourceId: string, | ||
34 | stateId: string, | ||
35 | service: string, | ||
36 | data: unknown, | ||
37 | ) => Promise<void>; | ||
38 | |||
39 | enum State { | ||
40 | Initial, | ||
41 | Opening, | ||
42 | TabVisible, | ||
43 | TabHiddenIdle, | ||
44 | TabHiddenWaiting, | ||
45 | Error, | ||
46 | TimedOut, | ||
47 | } | ||
48 | |||
49 | export 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 | } | ||