diff options
Diffstat (limited to 'subprojects/frontend/src/xtext')
-rw-r--r-- | subprojects/frontend/src/xtext/UpdateService.ts | 2 | ||||
-rw-r--r-- | subprojects/frontend/src/xtext/XtextWebSocketClient.ts | 525 | ||||
-rw-r--r-- | subprojects/frontend/src/xtext/webSocketMachine.ts | 215 | ||||
-rw-r--r-- | subprojects/frontend/src/xtext/xtextMessages.ts | 8 |
4 files changed, 430 insertions, 320 deletions
diff --git a/subprojects/frontend/src/xtext/UpdateService.ts b/subprojects/frontend/src/xtext/UpdateService.ts index f1abce52..d7471cdc 100644 --- a/subprojects/frontend/src/xtext/UpdateService.ts +++ b/subprojects/frontend/src/xtext/UpdateService.ts | |||
@@ -83,7 +83,7 @@ export default class UpdateService { | |||
83 | } | 83 | } |
84 | 84 | ||
85 | private idleUpdate(): void { | 85 | private idleUpdate(): void { |
86 | if (!this.webSocketClient.isOpen || !this.tracker.needsUpdate) { | 86 | if (!this.webSocketClient.opened || !this.tracker.needsUpdate) { |
87 | return; | 87 | return; |
88 | } | 88 | } |
89 | if (!this.tracker.lockedForUpdate) { | 89 | if (!this.tracker.lockedForUpdate) { |
diff --git a/subprojects/frontend/src/xtext/XtextWebSocketClient.ts b/subprojects/frontend/src/xtext/XtextWebSocketClient.ts index 60bf6ba9..eedfa365 100644 --- a/subprojects/frontend/src/xtext/XtextWebSocketClient.ts +++ b/subprojects/frontend/src/xtext/XtextWebSocketClient.ts | |||
@@ -1,34 +1,22 @@ | |||
1 | import { createAtom, makeAutoObservable, observable } from 'mobx'; | ||
1 | import { nanoid } from 'nanoid'; | 2 | import { nanoid } from 'nanoid'; |
3 | import { interpret } from 'xstate'; | ||
2 | 4 | ||
5 | import CancelledError from '../utils/CancelledError'; | ||
3 | import PendingTask from '../utils/PendingTask'; | 6 | import PendingTask from '../utils/PendingTask'; |
4 | import Timer from '../utils/Timer'; | ||
5 | import getLogger from '../utils/getLogger'; | 7 | import getLogger from '../utils/getLogger'; |
6 | 8 | ||
9 | import webSocketMachine from './webSocketMachine'; | ||
7 | import { | 10 | import { |
8 | XtextWebErrorResponse, | 11 | type XtextWebPushService, |
9 | XtextWebRequest, | 12 | XtextResponse, |
10 | XtextWebOkResponse, | 13 | type XtextWebRequest, |
11 | XtextWebPushMessage, | ||
12 | XtextWebPushService, | ||
13 | } from './xtextMessages'; | 14 | } from './xtextMessages'; |
14 | import { PongResult } from './xtextServiceResults'; | 15 | import { PongResult } from './xtextServiceResults'; |
15 | 16 | ||
16 | const XTEXT_SUBPROTOCOL_V1 = 'tools.refinery.language.web.xtext.v1'; | 17 | const XTEXT_SUBPROTOCOL_V1 = 'tools.refinery.language.web.xtext.v1'; |
17 | 18 | ||
18 | const WEBSOCKET_CLOSE_OK = 1000; | 19 | const REQUEST_TIMEOUT = 1000; |
19 | |||
20 | const WEBSOCKET_CLOSE_GOING_AWAY = 1001; | ||
21 | |||
22 | const RECONNECT_DELAY_MS = [200, 1000, 5000, 30_000]; | ||
23 | |||
24 | const MAX_RECONNECT_DELAY_MS = | ||
25 | RECONNECT_DELAY_MS[RECONNECT_DELAY_MS.length - 1]; | ||
26 | |||
27 | const BACKGROUND_IDLE_TIMEOUT_MS = 5 * 60 * 1000; | ||
28 | |||
29 | const PING_TIMEOUT_MS = 10 * 1000; | ||
30 | |||
31 | const REQUEST_TIMEOUT_MS = 1000; | ||
32 | 20 | ||
33 | const log = getLogger('xtext.XtextWebSocketClient'); | 21 | const log = getLogger('xtext.XtextWebSocketClient'); |
34 | 22 | ||
@@ -41,351 +29,250 @@ export type PushHandler = ( | |||
41 | data: unknown, | 29 | data: unknown, |
42 | ) => void; | 30 | ) => void; |
43 | 31 | ||
44 | enum State { | ||
45 | Initial, | ||
46 | Opening, | ||
47 | TabVisible, | ||
48 | TabHiddenIdle, | ||
49 | TabHiddenWaitingToClose, | ||
50 | Error, | ||
51 | ClosedDueToInactivity, | ||
52 | } | ||
53 | |||
54 | export default class XtextWebSocketClient { | 32 | export default class XtextWebSocketClient { |
55 | private nextMessageId = 0; | 33 | private readonly stateAtom = createAtom('state'); |
56 | 34 | ||
57 | private connection!: WebSocket; | 35 | private webSocket: WebSocket | undefined; |
58 | 36 | ||
59 | private readonly pendingRequests = new Map<string, PendingTask<unknown>>(); | 37 | private readonly pendingRequests = new Map<string, PendingTask<unknown>>(); |
60 | 38 | ||
61 | private readonly onReconnect: ReconnectHandler; | 39 | private readonly interpreter = interpret( |
62 | 40 | webSocketMachine | |
63 | private readonly onPush: PushHandler; | 41 | .withContext({ |
64 | 42 | ...webSocketMachine.context, | |
65 | private state = State.Initial; | 43 | webSocketURL: `${window.location.origin.replace( |
66 | 44 | /^http/, | |
67 | private reconnectTryCount = 0; | 45 | 'ws', |
68 | 46 | )}/xtext-service`, | |
69 | private readonly idleTimer = new Timer(() => { | 47 | }) |
70 | this.handleIdleTimeout(); | 48 | .withConfig({ |
71 | }, BACKGROUND_IDLE_TIMEOUT_MS); | 49 | actions: { |
72 | 50 | openWebSocket: ({ webSocketURL }) => this.openWebSocket(webSocketURL), | |
73 | private readonly pingTimer = new Timer(() => { | 51 | closeWebSocket: () => this.closeWebSocket(), |
74 | this.sendPing(); | 52 | notifyReconnect: () => this.onReconnect(), |
75 | }, PING_TIMEOUT_MS); | 53 | cancelPendingRequests: () => this.cancelPendingRequests(), |
54 | }, | ||
55 | services: { | ||
56 | pingService: () => this.sendPing(), | ||
57 | }, | ||
58 | }), | ||
59 | { | ||
60 | logger: log.log.bind(log), | ||
61 | }, | ||
62 | ); | ||
63 | |||
64 | private readonly openListener = () => { | ||
65 | if (this.webSocket === undefined) { | ||
66 | throw new Error('Open listener called without a WebSocket'); | ||
67 | } | ||
68 | const { | ||
69 | webSocket: { protocol }, | ||
70 | } = this; | ||
71 | if (protocol === XTEXT_SUBPROTOCOL_V1) { | ||
72 | this.interpreter.send('OPENED'); | ||
73 | } else { | ||
74 | this.interpreter.send({ | ||
75 | type: 'ERROR', | ||
76 | message: `Unknown subprotocol ${protocol}`, | ||
77 | }); | ||
78 | } | ||
79 | }; | ||
76 | 80 | ||
77 | private readonly reconnectTimer = new Timer(() => { | 81 | private readonly errorListener = (event: Event) => { |
78 | this.handleReconnect(); | 82 | log.error('WebSocket error', event); |
79 | }); | 83 | this.interpreter.send({ type: 'ERROR', message: 'WebSocket error' }); |
84 | }; | ||
80 | 85 | ||
81 | constructor(onReconnect: ReconnectHandler, onPush: PushHandler) { | 86 | private readonly closeListener = ({ code, reason }: CloseEvent) => |
82 | this.onReconnect = onReconnect; | 87 | this.interpreter.send({ |
83 | this.onPush = onPush; | 88 | type: 'ERROR', |
84 | document.addEventListener('visibilitychange', () => { | 89 | message: `Socket closed unexpectedly: ${code} ${reason}`, |
85 | this.handleVisibilityChange(); | ||
86 | }); | 90 | }); |
87 | this.reconnect(); | ||
88 | } | ||
89 | 91 | ||
90 | private get isLogicallyClosed(): boolean { | 92 | private readonly messageListener = ({ data }: MessageEvent) => { |
91 | return ( | 93 | if (typeof data !== 'string') { |
92 | this.state === State.Error || this.state === State.ClosedDueToInactivity | 94 | this.interpreter.send({ |
93 | ); | 95 | type: 'ERROR', |
94 | } | 96 | message: 'Unexpected message format', |
95 | 97 | }); | |
96 | get isOpen(): boolean { | ||
97 | return ( | ||
98 | this.state === State.TabVisible || | ||
99 | this.state === State.TabHiddenIdle || | ||
100 | this.state === State.TabHiddenWaitingToClose | ||
101 | ); | ||
102 | } | ||
103 | |||
104 | private reconnect() { | ||
105 | if (this.isOpen || this.state === State.Opening) { | ||
106 | log.error('Trying to reconnect from', this.state); | ||
107 | return; | 98 | return; |
108 | } | 99 | } |
109 | this.state = State.Opening; | 100 | let json: unknown; |
110 | const webSocketServer = window.origin.replace(/^http/, 'ws'); | 101 | try { |
111 | const webSocketUrl = `${webSocketServer}/xtext-service`; | 102 | json = JSON.parse(data); |
112 | this.connection = new WebSocket(webSocketUrl, XTEXT_SUBPROTOCOL_V1); | 103 | } catch (error) { |
113 | this.connection.addEventListener('open', () => { | 104 | log.error('JSON parse error', error); |
114 | if (this.connection.protocol !== XTEXT_SUBPROTOCOL_V1) { | 105 | this.interpreter.send({ type: 'ERROR', message: 'Malformed message' }); |
115 | log.error( | ||
116 | 'Unknown subprotocol', | ||
117 | this.connection.protocol, | ||
118 | 'selected by server', | ||
119 | ); | ||
120 | this.forceReconnectOnError(); | ||
121 | } | ||
122 | if (document.visibilityState === 'hidden') { | ||
123 | this.handleTabHidden(); | ||
124 | } else { | ||
125 | this.handleTabVisibleConnected(); | ||
126 | } | ||
127 | log.info('Connected to websocket'); | ||
128 | this.nextMessageId = 0; | ||
129 | this.reconnectTryCount = 0; | ||
130 | this.pingTimer.schedule(); | ||
131 | this.onReconnect(); | ||
132 | }); | ||
133 | this.connection.addEventListener('error', (event) => { | ||
134 | log.error('Unexpected websocket error', event); | ||
135 | this.forceReconnectOnError(); | ||
136 | }); | ||
137 | this.connection.addEventListener('message', (event) => { | ||
138 | this.handleMessage(event.data); | ||
139 | }); | ||
140 | this.connection.addEventListener('close', (event) => { | ||
141 | const closedOnRequest = | ||
142 | this.isLogicallyClosed && | ||
143 | event.code === WEBSOCKET_CLOSE_OK && | ||
144 | this.pendingRequests.size === 0; | ||
145 | const closedOnNavigation = event.code === WEBSOCKET_CLOSE_GOING_AWAY; | ||
146 | if (closedOnNavigation) { | ||
147 | this.state = State.ClosedDueToInactivity; | ||
148 | } | ||
149 | if (closedOnRequest || closedOnNavigation) { | ||
150 | log.info('Websocket closed'); | ||
151 | return; | ||
152 | } | ||
153 | log.error('Websocket closed unexpectedly', event.code, event.reason); | ||
154 | this.forceReconnectOnError(); | ||
155 | }); | ||
156 | } | ||
157 | |||
158 | private handleVisibilityChange() { | ||
159 | if (document.visibilityState === 'hidden') { | ||
160 | if (this.state === State.TabVisible) { | ||
161 | this.handleTabHidden(); | ||
162 | } | ||
163 | return; | 106 | return; |
164 | } | 107 | } |
165 | this.idleTimer.cancel(); | 108 | const responseResult = XtextResponse.safeParse(json); |
166 | if ( | 109 | if (!responseResult.success) { |
167 | this.state === State.TabHiddenIdle || | 110 | log.error('Xtext response', json, 'is malformed:', responseResult.error); |
168 | this.state === State.TabHiddenWaitingToClose | 111 | this.interpreter.send({ type: 'ERROR', message: 'Malformed message' }); |
169 | ) { | ||
170 | this.handleTabVisibleConnected(); | ||
171 | return; | 112 | return; |
172 | } | 113 | } |
173 | if (this.state === State.ClosedDueToInactivity) { | 114 | const { data: response } = responseResult; |
174 | this.reconnect(); | 115 | if ('service' in response) { |
116 | // `XtextWebPushMessage.push` is optional, but `service` is not. | ||
117 | const { resource, stateId, service, push } = response; | ||
118 | this.onPush(resource, stateId, service, push); | ||
119 | return; | ||
120 | } | ||
121 | const { id } = response; | ||
122 | const task = this.pendingRequests.get(id); | ||
123 | if (task === undefined) { | ||
124 | log.warn('Task', id, 'has been already resolved'); | ||
125 | return; | ||
126 | } | ||
127 | this.removeTask(id); | ||
128 | if ('error' in response) { | ||
129 | const formattedMessage = `${response.error} error: ${response.message}`; | ||
130 | log.error('Task', id, formattedMessage); | ||
131 | task.reject(new Error(formattedMessage)); | ||
132 | } else { | ||
133 | task.resolve(response.response); | ||
175 | } | 134 | } |
135 | }; | ||
136 | |||
137 | constructor( | ||
138 | private readonly onReconnect: ReconnectHandler, | ||
139 | private readonly onPush: PushHandler, | ||
140 | ) { | ||
141 | this.interpreter | ||
142 | .onTransition((state, event) => { | ||
143 | log.trace('WebSocke state transition', state.value, 'on event', event); | ||
144 | this.stateAtom.reportChanged(); | ||
145 | }) | ||
146 | .start(); | ||
147 | |||
148 | this.updateVisibility(); | ||
149 | document.addEventListener('visibilitychange', () => | ||
150 | this.updateVisibility(), | ||
151 | ); | ||
152 | |||
153 | this.interpreter.send('CONNECT'); | ||
154 | |||
155 | makeAutoObservable< | ||
156 | XtextWebSocketClient, | ||
157 | | 'stateAtom' | ||
158 | | 'webSocket' | ||
159 | | 'interpreter' | ||
160 | | 'openListener' | ||
161 | | 'errorListener' | ||
162 | | 'closeListener' | ||
163 | | 'messageListener' | ||
164 | | 'sendPing' | ||
165 | >(this, { | ||
166 | stateAtom: false, | ||
167 | webSocket: observable.ref, | ||
168 | interpreter: false, | ||
169 | openListener: false, | ||
170 | errorListener: false, | ||
171 | closeListener: false, | ||
172 | messageListener: false, | ||
173 | sendPing: false, | ||
174 | }); | ||
176 | } | 175 | } |
177 | 176 | ||
178 | private handleTabHidden() { | 177 | get state() { |
179 | log.debug('Tab hidden while websocket is connected'); | 178 | this.stateAtom.reportObserved(); |
180 | this.state = State.TabHiddenIdle; | 179 | return this.interpreter.state; |
181 | this.idleTimer.schedule(); | ||
182 | } | 180 | } |
183 | 181 | ||
184 | private handleTabVisibleConnected() { | 182 | get opened(): boolean { |
185 | log.debug('Tab visible while websocket is connected'); | 183 | return this.state.matches('connection.socketCreated.open.opened'); |
186 | this.state = State.TabVisible; | ||
187 | } | 184 | } |
188 | 185 | ||
189 | private handleIdleTimeout() { | 186 | connect(): void { |
190 | log.trace('Waiting for pending tasks before disconnect'); | 187 | this.interpreter.send('CONNECT'); |
191 | if (this.state === State.TabHiddenIdle) { | ||
192 | this.state = State.TabHiddenWaitingToClose; | ||
193 | this.handleWaitingForDisconnect(); | ||
194 | } | ||
195 | } | 188 | } |
196 | 189 | ||
197 | private handleWaitingForDisconnect() { | 190 | disconnect(): void { |
198 | if (this.state !== State.TabHiddenWaitingToClose) { | 191 | this.interpreter.send('DISCONNECT'); |
199 | return; | ||
200 | } | ||
201 | const pending = this.pendingRequests.size; | ||
202 | if (pending === 0) { | ||
203 | log.info('Closing idle websocket'); | ||
204 | this.state = State.ClosedDueToInactivity; | ||
205 | this.closeConnection(1000, 'idle timeout'); | ||
206 | return; | ||
207 | } | ||
208 | log.info( | ||
209 | 'Waiting for', | ||
210 | pending, | ||
211 | 'pending requests before closing websocket', | ||
212 | ); | ||
213 | } | 192 | } |
214 | 193 | ||
215 | private sendPing() { | 194 | forceReconnectOnError(): void { |
216 | if (!this.isOpen) { | 195 | this.interpreter.send({ |
217 | return; | 196 | type: 'ERROR', |
218 | } | 197 | message: 'Client error', |
219 | const ping = nanoid(); | 198 | }); |
220 | log.trace('Ping', ping); | ||
221 | this.send({ ping }) | ||
222 | .then((result) => { | ||
223 | const parsedPongResult = PongResult.safeParse(result); | ||
224 | if (parsedPongResult.success && parsedPongResult.data.pong === ping) { | ||
225 | log.trace('Pong', ping); | ||
226 | this.pingTimer.schedule(); | ||
227 | } else { | ||
228 | log.error('Invalid pong:', parsedPongResult, 'expected:', ping); | ||
229 | this.forceReconnectOnError(); | ||
230 | } | ||
231 | }) | ||
232 | .catch((error) => { | ||
233 | log.error('Error while waiting for ping', error); | ||
234 | this.forceReconnectOnError(); | ||
235 | }); | ||
236 | } | 199 | } |
237 | 200 | ||
238 | send(request: unknown): Promise<unknown> { | 201 | send(request: unknown): Promise<unknown> { |
239 | if (!this.isOpen) { | 202 | if (!this.opened || this.webSocket === undefined) { |
240 | throw new Error('Not open'); | 203 | throw new Error('Not connected'); |
241 | } | ||
242 | const messageId = this.nextMessageId.toString(16); | ||
243 | if (messageId in this.pendingRequests) { | ||
244 | log.error('Message id wraparound still pending', messageId); | ||
245 | this.rejectRequest(messageId, new Error('Message id wraparound')); | ||
246 | } | 204 | } |
247 | if (this.nextMessageId >= Number.MAX_SAFE_INTEGER) { | 205 | |
248 | this.nextMessageId = 0; | 206 | const id = nanoid(); |
249 | } else { | 207 | |
250 | this.nextMessageId += 1; | 208 | const promise = new Promise((resolve, reject) => { |
251 | } | 209 | const task = new PendingTask(resolve, reject, REQUEST_TIMEOUT, () => |
252 | const message = JSON.stringify({ | 210 | this.removeTask(id), |
253 | id: messageId, | 211 | ); |
254 | request, | 212 | this.pendingRequests.set(id, task); |
255 | } as XtextWebRequest); | ||
256 | log.trace('Sending message', message); | ||
257 | return new Promise((resolve, reject) => { | ||
258 | const task = new PendingTask(resolve, reject, REQUEST_TIMEOUT_MS, () => { | ||
259 | this.removePendingRequest(messageId); | ||
260 | }); | ||
261 | this.pendingRequests.set(messageId, task); | ||
262 | this.connection.send(message); | ||
263 | }); | 213 | }); |
214 | |||
215 | const webRequest: XtextWebRequest = { id, request }; | ||
216 | const json = JSON.stringify(webRequest); | ||
217 | this.webSocket.send(json); | ||
218 | |||
219 | return promise; | ||
264 | } | 220 | } |
265 | 221 | ||
266 | private handleMessage(messageStr: unknown) { | 222 | private updateVisibility(): void { |
267 | if (typeof messageStr !== 'string') { | 223 | this.interpreter.send(document.hidden ? 'TAB_HIDDEN' : 'TAB_VISIBLE'); |
268 | log.error('Unexpected binary message', messageStr); | ||
269 | this.forceReconnectOnError(); | ||
270 | return; | ||
271 | } | ||
272 | log.trace('Incoming websocket message', messageStr); | ||
273 | let message: unknown; | ||
274 | try { | ||
275 | message = JSON.parse(messageStr); | ||
276 | } catch (error) { | ||
277 | log.error('Json parse error', error); | ||
278 | this.forceReconnectOnError(); | ||
279 | return; | ||
280 | } | ||
281 | const okResponse = XtextWebOkResponse.safeParse(message); | ||
282 | if (okResponse.success) { | ||
283 | const { id, response } = okResponse.data; | ||
284 | this.resolveRequest(id, response); | ||
285 | return; | ||
286 | } | ||
287 | const errorResponse = XtextWebErrorResponse.safeParse(message); | ||
288 | if (errorResponse.success) { | ||
289 | const { id, error, message: errorMessage } = errorResponse.data; | ||
290 | this.rejectRequest(id, new Error(`${error} error: ${errorMessage}`)); | ||
291 | if (error === 'server') { | ||
292 | log.error('Reconnecting due to server error: ', errorMessage); | ||
293 | this.forceReconnectOnError(); | ||
294 | } | ||
295 | return; | ||
296 | } | ||
297 | const pushMessage = XtextWebPushMessage.safeParse(message); | ||
298 | if (pushMessage.success) { | ||
299 | const { resource, stateId, service, push } = pushMessage.data; | ||
300 | this.onPush(resource, stateId, service, push); | ||
301 | } else { | ||
302 | log.error( | ||
303 | 'Unexpected websocket message:', | ||
304 | message, | ||
305 | 'not ok response because:', | ||
306 | okResponse.error, | ||
307 | 'not error response because:', | ||
308 | errorResponse.error, | ||
309 | 'not push message because:', | ||
310 | pushMessage.error, | ||
311 | ); | ||
312 | this.forceReconnectOnError(); | ||
313 | } | ||
314 | } | 224 | } |
315 | 225 | ||
316 | private resolveRequest(messageId: string, value: unknown) { | 226 | private openWebSocket(webSocketURL: string | undefined): void { |
317 | const pendingRequest = this.pendingRequests.get(messageId); | 227 | if (this.webSocket !== undefined) { |
318 | if (pendingRequest) { | 228 | throw new Error('WebSocket already open'); |
319 | pendingRequest.resolve(value); | ||
320 | this.removePendingRequest(messageId); | ||
321 | return; | ||
322 | } | 229 | } |
323 | log.error('Trying to resolve unknown request', messageId, 'with', value); | ||
324 | } | ||
325 | 230 | ||
326 | private rejectRequest(messageId: string, reason?: unknown) { | 231 | if (webSocketURL === undefined) { |
327 | const pendingRequest = this.pendingRequests.get(messageId); | 232 | throw new Error('URL not configured'); |
328 | if (pendingRequest) { | ||
329 | pendingRequest.reject(reason); | ||
330 | this.removePendingRequest(messageId); | ||
331 | return; | ||
332 | } | 233 | } |
333 | log.error('Trying to reject unknown request', messageId, 'with', reason); | ||
334 | } | ||
335 | 234 | ||
336 | private removePendingRequest(messageId: string) { | 235 | log.debug('Creating WebSocket'); |
337 | this.pendingRequests.delete(messageId); | 236 | |
338 | this.handleWaitingForDisconnect(); | 237 | this.webSocket = new WebSocket(webSocketURL, XTEXT_SUBPROTOCOL_V1); |
238 | this.webSocket.addEventListener('open', this.openListener); | ||
239 | this.webSocket.addEventListener('close', this.closeListener); | ||
240 | this.webSocket.addEventListener('error', this.errorListener); | ||
241 | this.webSocket.addEventListener('message', this.messageListener); | ||
339 | } | 242 | } |
340 | 243 | ||
341 | forceReconnectOnError(): void { | 244 | private closeWebSocket() { |
342 | if (this.isLogicallyClosed) { | 245 | if (this.webSocket === undefined) { |
343 | return; | 246 | throw new Error('WebSocket already closed'); |
344 | } | ||
345 | this.pendingRequests.forEach((request) => { | ||
346 | request.reject(new Error('Websocket disconnect')); | ||
347 | }); | ||
348 | this.pendingRequests.clear(); | ||
349 | this.closeConnection(1000, 'reconnecting due to error'); | ||
350 | if (this.state === State.Error) { | ||
351 | // We are already handling this error condition. | ||
352 | return; | ||
353 | } | 247 | } |
354 | if ( | 248 | |
355 | this.state === State.TabHiddenIdle || | 249 | log.debug('Closing WebSocket'); |
356 | this.state === State.TabHiddenWaitingToClose | 250 | |
357 | ) { | 251 | this.webSocket.removeEventListener('open', this.openListener); |
358 | log.error('Will reconned due to error once the tab becomes visible'); | 252 | this.webSocket.removeEventListener('close', this.closeListener); |
359 | this.idleTimer.cancel(); | 253 | this.webSocket.removeEventListener('error', this.errorListener); |
360 | this.state = State.ClosedDueToInactivity; | 254 | this.webSocket.removeEventListener('message', this.messageListener); |
361 | return; | 255 | this.webSocket.close(1000, 'Closing connection'); |
362 | } | 256 | this.webSocket = undefined; |
363 | log.error('Reconnecting after delay due to error'); | ||
364 | this.state = State.Error; | ||
365 | this.reconnectTryCount += 1; | ||
366 | const delay = | ||
367 | RECONNECT_DELAY_MS[this.reconnectTryCount - 1] ?? MAX_RECONNECT_DELAY_MS; | ||
368 | log.info('Reconnecting in', delay, 'ms'); | ||
369 | this.reconnectTimer.schedule(delay); | ||
370 | } | 257 | } |
371 | 258 | ||
372 | private closeConnection(code: number, reason: string) { | 259 | private removeTask(id: string): void { |
373 | this.pingTimer.cancel(); | 260 | this.pendingRequests.delete(id); |
374 | const { readyState } = this.connection; | ||
375 | if (readyState !== WebSocket.CLOSING && readyState !== WebSocket.CLOSED) { | ||
376 | this.connection.close(code, reason); | ||
377 | } | ||
378 | } | 261 | } |
379 | 262 | ||
380 | private handleReconnect() { | 263 | private cancelPendingRequests(): void { |
381 | if (this.state !== State.Error) { | 264 | this.pendingRequests.forEach((task) => |
382 | log.error('Unexpected reconnect in', this.state); | 265 | task.reject(new CancelledError('Closing connection')), |
383 | return; | 266 | ); |
384 | } | 267 | this.pendingRequests.clear(); |
385 | if (document.visibilityState === 'hidden') { | 268 | } |
386 | this.state = State.ClosedDueToInactivity; | 269 | |
387 | } else { | 270 | private async sendPing(): Promise<void> { |
388 | this.reconnect(); | 271 | const ping = nanoid(); |
272 | const result = await this.send({ ping }); | ||
273 | const { pong } = PongResult.parse(result); | ||
274 | if (ping !== pong) { | ||
275 | throw new Error(`Expected pong ${ping} but got ${pong} instead`); | ||
389 | } | 276 | } |
390 | } | 277 | } |
391 | } | 278 | } |
diff --git a/subprojects/frontend/src/xtext/webSocketMachine.ts b/subprojects/frontend/src/xtext/webSocketMachine.ts new file mode 100644 index 00000000..50eb36a0 --- /dev/null +++ b/subprojects/frontend/src/xtext/webSocketMachine.ts | |||
@@ -0,0 +1,215 @@ | |||
1 | import { actions, assign, createMachine, RaiseAction } from 'xstate'; | ||
2 | |||
3 | const { raise } = actions; | ||
4 | |||
5 | const ERROR_WAIT_TIMES = [200, 1000, 5000, 30_000]; | ||
6 | |||
7 | export interface WebSocketContext { | ||
8 | webSocketURL: string | undefined; | ||
9 | errors: string[]; | ||
10 | retryCount: number; | ||
11 | } | ||
12 | |||
13 | export type WebSocketEvent = | ||
14 | | { type: 'CONFIGURE'; webSocketURL: string } | ||
15 | | { type: 'CONNECT' } | ||
16 | | { type: 'DISCONNECT' } | ||
17 | | { type: 'OPENED' } | ||
18 | | { type: 'TAB_VISIBLE' } | ||
19 | | { type: 'TAB_HIDDEN' } | ||
20 | | { type: 'ERROR'; message: string }; | ||
21 | |||
22 | export default createMachine( | ||
23 | { | ||
24 | id: 'webSocket', | ||
25 | predictableActionArguments: true, | ||
26 | schema: { | ||
27 | context: {} as WebSocketContext, | ||
28 | events: {} as WebSocketEvent, | ||
29 | }, | ||
30 | tsTypes: {} as import('./webSocketMachine.typegen').Typegen0, | ||
31 | context: { | ||
32 | webSocketURL: undefined, | ||
33 | errors: [], | ||
34 | retryCount: 0, | ||
35 | }, | ||
36 | type: 'parallel', | ||
37 | states: { | ||
38 | connection: { | ||
39 | initial: 'disconnected', | ||
40 | states: { | ||
41 | disconnected: { | ||
42 | id: 'disconnected', | ||
43 | on: { | ||
44 | CONFIGURE: { actions: 'configure' }, | ||
45 | }, | ||
46 | }, | ||
47 | timedOut: { | ||
48 | id: 'timedOut', | ||
49 | on: { | ||
50 | TAB_VISIBLE: 'socketCreated', | ||
51 | }, | ||
52 | }, | ||
53 | errorWait: { | ||
54 | id: 'errorWait', | ||
55 | after: { | ||
56 | ERROR_WAIT_TIME: [ | ||
57 | { target: 'timedOut', in: '#tabHidden' }, | ||
58 | { target: 'socketCreated' }, | ||
59 | ], | ||
60 | }, | ||
61 | }, | ||
62 | socketCreated: { | ||
63 | type: 'parallel', | ||
64 | entry: 'openWebSocket', | ||
65 | exit: ['cancelPendingRequests', 'closeWebSocket'], | ||
66 | states: { | ||
67 | open: { | ||
68 | initial: 'opening', | ||
69 | states: { | ||
70 | opening: { | ||
71 | after: { | ||
72 | OPEN_TIMEOUT: { | ||
73 | actions: 'raiseTimeoutError', | ||
74 | }, | ||
75 | }, | ||
76 | on: { | ||
77 | OPENED: { | ||
78 | target: 'opened', | ||
79 | actions: ['clearError', 'notifyReconnect'], | ||
80 | }, | ||
81 | }, | ||
82 | }, | ||
83 | opened: { | ||
84 | initial: 'pongReceived', | ||
85 | states: { | ||
86 | pongReceived: { | ||
87 | after: { | ||
88 | PING_PERIOD: 'pingSent', | ||
89 | }, | ||
90 | }, | ||
91 | pingSent: { | ||
92 | invoke: { | ||
93 | src: 'pingService', | ||
94 | onDone: 'pongReceived', | ||
95 | onError: { | ||
96 | actions: 'raisePromiseRejectionError', | ||
97 | }, | ||
98 | }, | ||
99 | }, | ||
100 | }, | ||
101 | }, | ||
102 | }, | ||
103 | }, | ||
104 | idle: { | ||
105 | initial: 'getTabState', | ||
106 | states: { | ||
107 | getTabState: { | ||
108 | always: [ | ||
109 | { target: 'inactive', in: '#tabHidden' }, | ||
110 | 'active', | ||
111 | ], | ||
112 | }, | ||
113 | active: { | ||
114 | on: { | ||
115 | TAB_HIDDEN: 'inactive', | ||
116 | }, | ||
117 | }, | ||
118 | inactive: { | ||
119 | after: { | ||
120 | IDLE_TIMEOUT: '#timedOut', | ||
121 | }, | ||
122 | on: { | ||
123 | TAB_VISIBLE: 'active', | ||
124 | }, | ||
125 | }, | ||
126 | }, | ||
127 | }, | ||
128 | }, | ||
129 | on: { | ||
130 | CONNECT: undefined, | ||
131 | ERROR: { | ||
132 | target: '#errorWait', | ||
133 | actions: 'increaseRetryCount', | ||
134 | }, | ||
135 | }, | ||
136 | }, | ||
137 | }, | ||
138 | on: { | ||
139 | CONNECT: { target: '.socketCreated', cond: 'hasWebSocketURL' }, | ||
140 | DISCONNECT: { target: '.disconnected', actions: 'clearError' }, | ||
141 | }, | ||
142 | }, | ||
143 | tab: { | ||
144 | initial: 'visibleOrUnknown', | ||
145 | states: { | ||
146 | visibleOrUnknown: { | ||
147 | on: { | ||
148 | TAB_HIDDEN: 'hidden', | ||
149 | }, | ||
150 | }, | ||
151 | hidden: { | ||
152 | id: 'tabHidden', | ||
153 | on: { | ||
154 | TAB_VISIBLE: 'visibleOrUnknown', | ||
155 | }, | ||
156 | }, | ||
157 | }, | ||
158 | }, | ||
159 | error: { | ||
160 | initial: 'init', | ||
161 | states: { | ||
162 | init: { | ||
163 | on: { | ||
164 | ERROR: { actions: 'pushError' }, | ||
165 | }, | ||
166 | }, | ||
167 | }, | ||
168 | }, | ||
169 | }, | ||
170 | }, | ||
171 | { | ||
172 | guards: { | ||
173 | hasWebSocketURL: ({ webSocketURL }) => webSocketURL !== undefined, | ||
174 | }, | ||
175 | delays: { | ||
176 | IDLE_TIMEOUT: 300_000, | ||
177 | OPEN_TIMEOUT: 5000, | ||
178 | PING_PERIOD: 10_000, | ||
179 | ERROR_WAIT_TIME: ({ retryCount }) => { | ||
180 | const { length } = ERROR_WAIT_TIMES; | ||
181 | const index = retryCount < length ? retryCount : length - 1; | ||
182 | return ERROR_WAIT_TIMES[index]; | ||
183 | }, | ||
184 | }, | ||
185 | actions: { | ||
186 | configure: assign((context, { webSocketURL }) => ({ | ||
187 | ...context, | ||
188 | webSocketURL, | ||
189 | })), | ||
190 | pushError: assign((context, { message }) => ({ | ||
191 | ...context, | ||
192 | errors: [...context.errors, message], | ||
193 | })), | ||
194 | increaseRetryCount: assign((context) => ({ | ||
195 | ...context, | ||
196 | retryCount: context.retryCount + 1, | ||
197 | })), | ||
198 | clearError: assign((context) => ({ | ||
199 | ...context, | ||
200 | errors: [], | ||
201 | retryCount: 0, | ||
202 | })), | ||
203 | // Workaround from https://github.com/statelyai/xstate/issues/1414#issuecomment-699972485 | ||
204 | raiseTimeoutError: raise({ | ||
205 | type: 'ERROR', | ||
206 | message: 'Open timeout', | ||
207 | }) as RaiseAction<WebSocketEvent>, | ||
208 | raisePromiseRejectionError: (_context, { data }) => | ||
209 | raise({ | ||
210 | type: 'ERROR', | ||
211 | message: data, | ||
212 | }) as RaiseAction<WebSocketEvent>, | ||
213 | }, | ||
214 | }, | ||
215 | ); | ||
diff --git a/subprojects/frontend/src/xtext/xtextMessages.ts b/subprojects/frontend/src/xtext/xtextMessages.ts index c4d0c676..ec7a2a31 100644 --- a/subprojects/frontend/src/xtext/xtextMessages.ts +++ b/subprojects/frontend/src/xtext/xtextMessages.ts | |||
@@ -40,3 +40,11 @@ export const XtextWebPushMessage = z.object({ | |||
40 | }); | 40 | }); |
41 | 41 | ||
42 | export type XtextWebPushMessage = z.infer<typeof XtextWebPushMessage>; | 42 | export type XtextWebPushMessage = z.infer<typeof XtextWebPushMessage>; |
43 | |||
44 | export const XtextResponse = z.union([ | ||
45 | XtextWebOkResponse, | ||
46 | XtextWebErrorResponse, | ||
47 | XtextWebPushMessage, | ||
48 | ]); | ||
49 | |||
50 | export type XtextResponse = z.infer<typeof XtextResponse>; | ||