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