diff options
author | Kristóf Marussy <kristof@marussy.com> | 2022-09-04 20:44:39 +0200 |
---|---|---|
committer | Kristóf Marussy <kristof@marussy.com> | 2022-09-06 01:05:23 +0200 |
commit | 29919c02d86da10acf2b902fb9cab9998bb731a6 (patch) | |
tree | e4ac7dc9bc035327c720514363edee938248c14a /subprojects | |
parent | refactor(frontend): add eslint-plugin-mobx (diff) | |
download | refinery-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')
-rw-r--r-- | subprojects/frontend/.eslintrc.cjs | 2 | ||||
-rw-r--r-- | subprojects/frontend/build.gradle | 29 | ||||
-rw-r--r-- | subprojects/frontend/package.json | 30 | ||||
-rw-r--r-- | subprojects/frontend/src/index.tsx | 1 | ||||
-rw-r--r-- | subprojects/frontend/src/utils/CancelledError.ts | 4 | ||||
-rw-r--r-- | subprojects/frontend/src/utils/Timer.ts | 33 | ||||
-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 |
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 | ||
22 | def installFrontend = tasks.named('installFrontend') | 22 | def installFrontend = tasks.named('installFrontend') |
23 | 23 | ||
24 | def sourcesWithoutTypegen = fileTree('src') { | ||
25 | exclude '**/*.typegen.ts' | ||
26 | } | ||
27 | |||
24 | def assembleFrontend = tasks.named('assembleFrontend') | 28 | def assembleFrontend = tasks.named('assembleFrontend') |
25 | assembleFrontend.configure { | 29 | assembleFrontend.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 | ||
45 | def 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 | |||
40 | def typecheckFrontend = tasks.register('typecheckFrontend', RunYarn) { | 55 | def 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 | ||
51 | def lintFrontend = tasks.register('lintFrontend', RunYarn) { | 68 | def 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 | ||
67 | def prettier = tasks.register('fixFrontend', RunYarn) { | 86 | def 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 | ||
83 | tasks.register('serveFrontend', RunYarn) { | 104 | tasks.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 | ||
96 | tasks.named('clean') { | 118 | tasks.named('clean') { |
97 | delete 'dev-dist' | 119 | delete 'dev-dist' |
120 | delete fileTree('src') { | ||
121 | include '**/*.typegen.ts' | ||
122 | } | ||
98 | } | 123 | } |
99 | 124 | ||
100 | sonarqube.properties { | 125 | sonarqube.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 | ||
64 | configure({ | 64 | configure({ |
65 | enforceActions: 'always', | 65 | enforceActions: 'always', |
66 | reactionRequiresObservable: true, | ||
67 | }); | 66 | }); |
68 | 67 | ||
69 | const rootStore = new RootStore(initialValue); | 68 | const 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 @@ | |||
1 | export default class CancelledError extends Error { | 1 | export 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 @@ | |||
1 | export 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 @@ | |||
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>; | ||