aboutsummaryrefslogtreecommitdiffstats
path: root/subprojects/frontend/src/xtext
diff options
context:
space:
mode:
authorLibravatar Kristóf Marussy <kristof@marussy.com>2022-09-04 20:44:39 +0200
committerLibravatar Kristóf Marussy <kristof@marussy.com>2022-09-06 01:05:23 +0200
commit29919c02d86da10acf2b902fb9cab9998bb731a6 (patch)
treee4ac7dc9bc035327c720514363edee938248c14a /subprojects/frontend/src/xtext
parentrefactor(frontend): add eslint-plugin-mobx (diff)
downloadrefinery-29919c02d86da10acf2b902fb9cab9998bb731a6.tar.gz
refinery-29919c02d86da10acf2b902fb9cab9998bb731a6.tar.zst
refinery-29919c02d86da10acf2b902fb9cab9998bb731a6.zip
feat(frontend): XState statecharts
Expressing logic in statecharts for complex stateful behaviours should improve maintainability We use @xstate/cli to statically analyze statcharts before typechecking
Diffstat (limited to 'subprojects/frontend/src/xtext')
-rw-r--r--subprojects/frontend/src/xtext/UpdateService.ts2
-rw-r--r--subprojects/frontend/src/xtext/XtextWebSocketClient.ts525
-rw-r--r--subprojects/frontend/src/xtext/webSocketMachine.ts215
-rw-r--r--subprojects/frontend/src/xtext/xtextMessages.ts8
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 @@
1import { createAtom, makeAutoObservable, observable } from 'mobx';
1import { nanoid } from 'nanoid'; 2import { nanoid } from 'nanoid';
3import { interpret } from 'xstate';
2 4
5import CancelledError from '../utils/CancelledError';
3import PendingTask from '../utils/PendingTask'; 6import PendingTask from '../utils/PendingTask';
4import Timer from '../utils/Timer';
5import getLogger from '../utils/getLogger'; 7import getLogger from '../utils/getLogger';
6 8
9import webSocketMachine from './webSocketMachine';
7import { 10import {
8 XtextWebErrorResponse, 11 type XtextWebPushService,
9 XtextWebRequest, 12 XtextResponse,
10 XtextWebOkResponse, 13 type XtextWebRequest,
11 XtextWebPushMessage,
12 XtextWebPushService,
13} from './xtextMessages'; 14} from './xtextMessages';
14import { PongResult } from './xtextServiceResults'; 15import { PongResult } from './xtextServiceResults';
15 16
16const XTEXT_SUBPROTOCOL_V1 = 'tools.refinery.language.web.xtext.v1'; 17const XTEXT_SUBPROTOCOL_V1 = 'tools.refinery.language.web.xtext.v1';
17 18
18const WEBSOCKET_CLOSE_OK = 1000; 19const REQUEST_TIMEOUT = 1000;
19
20const WEBSOCKET_CLOSE_GOING_AWAY = 1001;
21
22const RECONNECT_DELAY_MS = [200, 1000, 5000, 30_000];
23
24const MAX_RECONNECT_DELAY_MS =
25 RECONNECT_DELAY_MS[RECONNECT_DELAY_MS.length - 1];
26
27const BACKGROUND_IDLE_TIMEOUT_MS = 5 * 60 * 1000;
28
29const PING_TIMEOUT_MS = 10 * 1000;
30
31const REQUEST_TIMEOUT_MS = 1000;
32 20
33const log = getLogger('xtext.XtextWebSocketClient'); 21const 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
44enum State {
45 Initial,
46 Opening,
47 TabVisible,
48 TabHiddenIdle,
49 TabHiddenWaitingToClose,
50 Error,
51 ClosedDueToInactivity,
52}
53
54export default class XtextWebSocketClient { 32export 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 @@
1import { actions, assign, createMachine, RaiseAction } from 'xstate';
2
3const { raise } = actions;
4
5const ERROR_WAIT_TIMES = [200, 1000, 5000, 30_000];
6
7export interface WebSocketContext {
8 webSocketURL: string | undefined;
9 errors: string[];
10 retryCount: number;
11}
12
13export 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
22export 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
42export type XtextWebPushMessage = z.infer<typeof XtextWebPushMessage>; 42export type XtextWebPushMessage = z.infer<typeof XtextWebPushMessage>;
43
44export const XtextResponse = z.union([
45 XtextWebOkResponse,
46 XtextWebErrorResponse,
47 XtextWebPushMessage,
48]);
49
50export type XtextResponse = z.infer<typeof XtextResponse>;