diff options
Diffstat (limited to 'language-web/src/main/js/xtext/XtextWebSocketClient.ts')
-rw-r--r-- | language-web/src/main/js/xtext/XtextWebSocketClient.ts | 341 |
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 @@ | |||
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 | 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('xtext.XtextWebSocketClient'); | ||
29 | |||
30 | export type ReconnectHandler = () => void; | ||
31 | |||
32 | export type PushHandler = ( | ||
33 | resourceId: string, | ||
34 | stateId: string, | ||
35 | service: string, | ||
36 | data: unknown, | ||
37 | ) => 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 | 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 | } | ||