aboutsummaryrefslogtreecommitdiffstats
path: root/subprojects/frontend
diff options
context:
space:
mode:
Diffstat (limited to 'subprojects/frontend')
-rw-r--r--subprojects/frontend/.eslintrc.cjs2
-rw-r--r--subprojects/frontend/build.gradle29
-rw-r--r--subprojects/frontend/package.json30
-rw-r--r--subprojects/frontend/src/index.tsx1
-rw-r--r--subprojects/frontend/src/utils/CancelledError.ts4
-rw-r--r--subprojects/frontend/src/utils/Timer.ts33
-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
10 files changed, 477 insertions, 372 deletions
diff --git a/subprojects/frontend/.eslintrc.cjs b/subprojects/frontend/.eslintrc.cjs
index 1db67c11..0bf65c4f 100644
--- a/subprojects/frontend/.eslintrc.cjs
+++ b/subprojects/frontend/.eslintrc.cjs
@@ -37,7 +37,7 @@ module.exports = {
37 env: { 37 env: {
38 browser: true, 38 browser: true,
39 }, 39 },
40 ignorePatterns: ['build/**/*', 'dev-dist/**/*'], 40 ignorePatterns: ['build/**/*', 'dev-dist/**/*', 'src/**/*.typegen.ts'],
41 rules: { 41 rules: {
42 // In typescript, some class methods implementing an inderface do not use `this`: 42 // In typescript, some class methods implementing an inderface do not use `this`:
43 // https://github.com/typescript-eslint/typescript-eslint/issues/1103 43 // https://github.com/typescript-eslint/typescript-eslint/issues/1103
diff --git a/subprojects/frontend/build.gradle b/subprojects/frontend/build.gradle
index dd50860c..e57b2c4b 100644
--- a/subprojects/frontend/build.gradle
+++ b/subprojects/frontend/build.gradle
@@ -21,10 +21,15 @@ configurations {
21 21
22def installFrontend = tasks.named('installFrontend') 22def installFrontend = tasks.named('installFrontend')
23 23
24def sourcesWithoutTypegen = fileTree('src') {
25 exclude '**/*.typegen.ts'
26}
27
24def assembleFrontend = tasks.named('assembleFrontend') 28def assembleFrontend = tasks.named('assembleFrontend')
25assembleFrontend.configure { 29assembleFrontend.configure {
30 dependsOn generateXStateTypes
26 inputs.dir 'public' 31 inputs.dir 'public'
27 inputs.dir 'src' 32 inputs.files sourcesWithoutTypegen
28 inputs.file 'index.html' 33 inputs.file 'index.html'
29 inputs.files('package.json', 'tsconfig.json', 'tsconfig.base.json', 'vite.config.ts') 34 inputs.files('package.json', 'tsconfig.json', 'tsconfig.base.json', 'vite.config.ts')
30 inputs.file rootProject.file('yarn.lock') 35 inputs.file rootProject.file('yarn.lock')
@@ -37,9 +42,21 @@ artifacts {
37 } 42 }
38} 43}
39 44
45def generateXStateTypes = tasks.register('generateXStateTypes', RunYarn) {
46 dependsOn installFrontend
47 inputs.files sourcesWithoutTypegen
48 inputs.file 'package.json'
49 inputs.file rootProject.file('yarn.lock')
50 outputs.dir 'src'
51 script = 'run typegen'
52 description = 'Generate TypeScript typings for XState state machines.'
53}
54
40def typecheckFrontend = tasks.register('typecheckFrontend', RunYarn) { 55def typecheckFrontend = tasks.register('typecheckFrontend', RunYarn) {
41 dependsOn installFrontend 56 dependsOn installFrontend
57 dependsOn generateXStateTypes
42 inputs.dir 'src' 58 inputs.dir 'src'
59 inputs.dir 'types'
43 inputs.files('package.json', 'tsconfig.json', 'tsconfig.base.json', 'tsconfig.node.json') 60 inputs.files('package.json', 'tsconfig.json', 'tsconfig.base.json', 'tsconfig.node.json')
44 inputs.file rootProject.file('yarn.lock') 61 inputs.file rootProject.file('yarn.lock')
45 outputs.dir "${buildDir}/typescript" 62 outputs.dir "${buildDir}/typescript"
@@ -50,7 +67,9 @@ def typecheckFrontend = tasks.register('typecheckFrontend', RunYarn) {
50 67
51def lintFrontend = tasks.register('lintFrontend', RunYarn) { 68def lintFrontend = tasks.register('lintFrontend', RunYarn) {
52 dependsOn installFrontend 69 dependsOn installFrontend
70 dependsOn generateXStateTypes
53 inputs.dir 'src' 71 inputs.dir 'src'
72 inputs.dir 'types'
54 inputs.files('.eslintrc.cjs', 'prettier.config.cjs') 73 inputs.files('.eslintrc.cjs', 'prettier.config.cjs')
55 inputs.files('package.json', 'tsconfig.json', 'tsconfig.base.json', 'tsconfig.node.json') 74 inputs.files('package.json', 'tsconfig.json', 'tsconfig.base.json', 'tsconfig.node.json')
56 inputs.file rootProject.file('yarn.lock') 75 inputs.file rootProject.file('yarn.lock')
@@ -66,7 +85,9 @@ def lintFrontend = tasks.register('lintFrontend', RunYarn) {
66 85
67def prettier = tasks.register('fixFrontend', RunYarn) { 86def prettier = tasks.register('fixFrontend', RunYarn) {
68 dependsOn installFrontend 87 dependsOn installFrontend
88 dependsOn generateXStateTypes
69 inputs.dir 'src' 89 inputs.dir 'src'
90 inputs.dir 'types'
70 inputs.files('.eslintrc.cjs', 'prettier.config.cjs') 91 inputs.files('.eslintrc.cjs', 'prettier.config.cjs')
71 inputs.files('package.json', 'tsconfig.json', 'tsconfig.base.json', 'tsconfig.node.json') 92 inputs.files('package.json', 'tsconfig.json', 'tsconfig.base.json', 'tsconfig.node.json')
72 inputs.file rootProject.file('yarn.lock') 93 inputs.file rootProject.file('yarn.lock')
@@ -82,8 +103,9 @@ tasks.named('check') {
82 103
83tasks.register('serveFrontend', RunYarn) { 104tasks.register('serveFrontend', RunYarn) {
84 dependsOn installFrontend 105 dependsOn installFrontend
106 dependsOn generateXStateTypes
85 inputs.dir 'public' 107 inputs.dir 'public'
86 inputs.dir 'src' 108 inputs.files sourcesWithoutTypegen
87 inputs.file 'index.html' 109 inputs.file 'index.html'
88 inputs.files('package.json', 'tsconfig.json', 'tsconfig.base.json', 'vite.config.ts') 110 inputs.files('package.json', 'tsconfig.json', 'tsconfig.base.json', 'vite.config.ts')
89 inputs.file rootProject.file('yarn.lock') 111 inputs.file rootProject.file('yarn.lock')
@@ -95,6 +117,9 @@ tasks.register('serveFrontend', RunYarn) {
95 117
96tasks.named('clean') { 118tasks.named('clean') {
97 delete 'dev-dist' 119 delete 'dev-dist'
120 delete fileTree('src') {
121 include '**/*.typegen.ts'
122 }
98} 123}
99 124
100sonarqube.properties { 125sonarqube.properties {
diff --git a/subprojects/frontend/package.json b/subprojects/frontend/package.json
index b80e0561..9202542b 100644
--- a/subprojects/frontend/package.json
+++ b/subprojects/frontend/package.json
@@ -6,6 +6,7 @@
6 "scripts": { 6 "scripts": {
7 "build": "cross-env MODE=production vite build", 7 "build": "cross-env MODE=production vite build",
8 "serve": "cross-env MODE=development vite serve", 8 "serve": "cross-env MODE=development vite serve",
9 "typegen": "xstate typegen \"src/**/*.ts?(x)\"",
9 "typecheck": "tsc -p tsconfig.node.json && tsc -p tsconfig.json", 10 "typecheck": "tsc -p tsconfig.node.json && tsc -p tsconfig.json",
10 "lint": "eslint .", 11 "lint": "eslint .",
11 "lint:ci": "eslint -f json -o build/eslint.json .", 12 "lint:ci": "eslint -f json -o build/eslint.json .",
@@ -28,17 +29,17 @@
28 "@codemirror/lint": "^6.0.0", 29 "@codemirror/lint": "^6.0.0",
29 "@codemirror/search": "^6.2.0", 30 "@codemirror/search": "^6.2.0",
30 "@codemirror/state": "^6.1.1", 31 "@codemirror/state": "^6.1.1",
31 "@codemirror/view": "^6.2.1", 32 "@codemirror/view": "^6.2.2",
32 "@emotion/react": "^11.10.0", 33 "@emotion/react": "^11.10.4",
33 "@emotion/styled": "^11.10.0", 34 "@emotion/styled": "^11.10.4",
34 "@fontsource/jetbrains-mono": "^4.5.10", 35 "@fontsource/jetbrains-mono": "^4.5.10",
35 "@fontsource/roboto": "^4.5.8", 36 "@fontsource/roboto": "^4.5.8",
36 "@lezer/common": "^1.0.0", 37 "@lezer/common": "^1.0.1",
37 "@lezer/highlight": "^1.0.0", 38 "@lezer/highlight": "^1.0.0",
38 "@lezer/lr": "^1.2.3", 39 "@lezer/lr": "^1.2.3",
39 "@material-icons/svg": "^1.0.32", 40 "@material-icons/svg": "^1.0.33",
40 "@mui/icons-material": "5.10.2", 41 "@mui/icons-material": "5.10.3",
41 "@mui/material": "5.10.2", 42 "@mui/material": "5.10.3",
42 "ansi-styles": "^6.1.0", 43 "ansi-styles": "^6.1.0",
43 "escape-string-regexp": "^5.0.0", 44 "escape-string-regexp": "^5.0.0",
44 "lodash-es": "^4.17.21", 45 "lodash-es": "^4.17.21",
@@ -50,6 +51,7 @@
50 "notistack": "^2.0.5", 51 "notistack": "^2.0.5",
51 "react": "^18.2.0", 52 "react": "^18.2.0",
52 "react-dom": "^18.2.0", 53 "react-dom": "^18.2.0",
54 "xstate": "^4.33.5",
53 "zod": "^3.18.0" 55 "zod": "^3.18.0"
54 }, 56 },
55 "devDependencies": { 57 "devDependencies": {
@@ -57,13 +59,15 @@
57 "@types/eslint": "^8.4.6", 59 "@types/eslint": "^8.4.6",
58 "@types/html-minifier-terser": "^7.0.0", 60 "@types/html-minifier-terser": "^7.0.0",
59 "@types/lodash-es": "^4.17.6", 61 "@types/lodash-es": "^4.17.6",
60 "@types/node": "^18.7.13", 62 "@types/ms": "^0.7.31",
63 "@types/node": "^18.7.14",
61 "@types/prettier": "^2.7.0", 64 "@types/prettier": "^2.7.0",
62 "@types/react": "^18.0.17", 65 "@types/react": "^18.0.18",
63 "@types/react-dom": "^18.0.6", 66 "@types/react-dom": "^18.0.6",
64 "@typescript-eslint/eslint-plugin": "^5.35.1", 67 "@typescript-eslint/eslint-plugin": "^5.36.1",
65 "@typescript-eslint/parser": "^5.35.1", 68 "@typescript-eslint/parser": "^5.36.1",
66 "@vitejs/plugin-react": "^2.0.1", 69 "@vitejs/plugin-react": "^2.0.1",
70 "@xstate/cli": "^0.3.2",
67 "cross-env": "^7.0.3", 71 "cross-env": "^7.0.3",
68 "eslint": "^8.23.0", 72 "eslint": "^8.23.0",
69 "eslint-config-airbnb": "^19.0.4", 73 "eslint-config-airbnb": "^19.0.4",
@@ -74,14 +78,14 @@
74 "eslint-plugin-jsx-a11y": "^6.6.1", 78 "eslint-plugin-jsx-a11y": "^6.6.1",
75 "eslint-plugin-mobx": "^0.0.9", 79 "eslint-plugin-mobx": "^0.0.9",
76 "eslint-plugin-prettier": "^4.2.1", 80 "eslint-plugin-prettier": "^4.2.1",
77 "eslint-plugin-react": "^7.31.1", 81 "eslint-plugin-react": "^7.31.6",
78 "eslint-plugin-react-hooks": "^4.6.0", 82 "eslint-plugin-react-hooks": "^4.6.0",
79 "html-minifier-terser": "^7.0.0", 83 "html-minifier-terser": "^7.0.0",
80 "prettier": "^2.7.1", 84 "prettier": "^2.7.1",
81 "typescript": "~4.8.2", 85 "typescript": "~4.8.2",
82 "vite": "^3.0.9", 86 "vite": "^3.0.9",
83 "vite-plugin-inject-preload": "^1.1.0", 87 "vite-plugin-inject-preload": "^1.1.0",
84 "vite-plugin-pwa": "^0.12.3", 88 "vite-plugin-pwa": "^0.12.6",
85 "workbox-window": "^6.5.4" 89 "workbox-window": "^6.5.4"
86 } 90 }
87} 91}
diff --git a/subprojects/frontend/src/index.tsx b/subprojects/frontend/src/index.tsx
index 8436c7ae..9f413b85 100644
--- a/subprojects/frontend/src/index.tsx
+++ b/subprojects/frontend/src/index.tsx
@@ -63,7 +63,6 @@ scope Family = 1, Person += 5..10.
63 63
64configure({ 64configure({
65 enforceActions: 'always', 65 enforceActions: 'always',
66 reactionRequiresObservable: true,
67}); 66});
68 67
69const rootStore = new RootStore(initialValue); 68const rootStore = new RootStore(initialValue);
diff --git a/subprojects/frontend/src/utils/CancelledError.ts b/subprojects/frontend/src/utils/CancelledError.ts
index 8d3e55d8..ee23676f 100644
--- a/subprojects/frontend/src/utils/CancelledError.ts
+++ b/subprojects/frontend/src/utils/CancelledError.ts
@@ -1,5 +1,5 @@
1export default class CancelledError extends Error { 1export default class CancelledError extends Error {
2 constructor() { 2 constructor(message = 'Operation cancelled') {
3 super('Operation cancelled'); 3 super(message);
4 } 4 }
5} 5}
diff --git a/subprojects/frontend/src/utils/Timer.ts b/subprojects/frontend/src/utils/Timer.ts
deleted file mode 100644
index 4bb1bb9c..00000000
--- a/subprojects/frontend/src/utils/Timer.ts
+++ /dev/null
@@ -1,33 +0,0 @@
1export default class Timer {
2 private readonly callback: () => void;
3
4 private readonly defaultTimeout: number;
5
6 private timeout: number | undefined;
7
8 constructor(callback: () => void, defaultTimeout = 0) {
9 this.callback = () => {
10 this.timeout = undefined;
11 callback();
12 };
13 this.defaultTimeout = defaultTimeout;
14 }
15
16 schedule(timeout?: number | undefined): void {
17 if (this.timeout === undefined) {
18 this.timeout = setTimeout(this.callback, timeout ?? this.defaultTimeout);
19 }
20 }
21
22 reschedule(timeout?: number | undefined): void {
23 this.cancel();
24 this.schedule(timeout);
25 }
26
27 cancel(): void {
28 if (this.timeout !== undefined) {
29 clearTimeout(this.timeout);
30 this.timeout = undefined;
31 }
32 }
33}
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>;