From 29919c02d86da10acf2b902fb9cab9998bb731a6 Mon Sep 17 00:00:00 2001 From: Kristóf Marussy Date: Sun, 4 Sep 2022 20:44:39 +0200 Subject: 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 --- subprojects/frontend/.eslintrc.cjs | 2 +- subprojects/frontend/build.gradle | 29 +- subprojects/frontend/package.json | 30 +- subprojects/frontend/src/index.tsx | 1 - subprojects/frontend/src/utils/CancelledError.ts | 4 +- subprojects/frontend/src/utils/Timer.ts | 33 -- subprojects/frontend/src/xtext/UpdateService.ts | 2 +- .../frontend/src/xtext/XtextWebSocketClient.ts | 525 ++++++++------------- subprojects/frontend/src/xtext/webSocketMachine.ts | 215 +++++++++ subprojects/frontend/src/xtext/xtextMessages.ts | 8 + 10 files changed, 477 insertions(+), 372 deletions(-) delete mode 100644 subprojects/frontend/src/utils/Timer.ts create mode 100644 subprojects/frontend/src/xtext/webSocketMachine.ts (limited to 'subprojects/frontend') 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 = { env: { browser: true, }, - ignorePatterns: ['build/**/*', 'dev-dist/**/*'], + ignorePatterns: ['build/**/*', 'dev-dist/**/*', 'src/**/*.typegen.ts'], rules: { // In typescript, some class methods implementing an inderface do not use `this`: // 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 { def installFrontend = tasks.named('installFrontend') +def sourcesWithoutTypegen = fileTree('src') { + exclude '**/*.typegen.ts' +} + def assembleFrontend = tasks.named('assembleFrontend') assembleFrontend.configure { + dependsOn generateXStateTypes inputs.dir 'public' - inputs.dir 'src' + inputs.files sourcesWithoutTypegen inputs.file 'index.html' inputs.files('package.json', 'tsconfig.json', 'tsconfig.base.json', 'vite.config.ts') inputs.file rootProject.file('yarn.lock') @@ -37,9 +42,21 @@ artifacts { } } +def generateXStateTypes = tasks.register('generateXStateTypes', RunYarn) { + dependsOn installFrontend + inputs.files sourcesWithoutTypegen + inputs.file 'package.json' + inputs.file rootProject.file('yarn.lock') + outputs.dir 'src' + script = 'run typegen' + description = 'Generate TypeScript typings for XState state machines.' +} + def typecheckFrontend = tasks.register('typecheckFrontend', RunYarn) { dependsOn installFrontend + dependsOn generateXStateTypes inputs.dir 'src' + inputs.dir 'types' inputs.files('package.json', 'tsconfig.json', 'tsconfig.base.json', 'tsconfig.node.json') inputs.file rootProject.file('yarn.lock') outputs.dir "${buildDir}/typescript" @@ -50,7 +67,9 @@ def typecheckFrontend = tasks.register('typecheckFrontend', RunYarn) { def lintFrontend = tasks.register('lintFrontend', RunYarn) { dependsOn installFrontend + dependsOn generateXStateTypes inputs.dir 'src' + inputs.dir 'types' inputs.files('.eslintrc.cjs', 'prettier.config.cjs') inputs.files('package.json', 'tsconfig.json', 'tsconfig.base.json', 'tsconfig.node.json') inputs.file rootProject.file('yarn.lock') @@ -66,7 +85,9 @@ def lintFrontend = tasks.register('lintFrontend', RunYarn) { def prettier = tasks.register('fixFrontend', RunYarn) { dependsOn installFrontend + dependsOn generateXStateTypes inputs.dir 'src' + inputs.dir 'types' inputs.files('.eslintrc.cjs', 'prettier.config.cjs') inputs.files('package.json', 'tsconfig.json', 'tsconfig.base.json', 'tsconfig.node.json') inputs.file rootProject.file('yarn.lock') @@ -82,8 +103,9 @@ tasks.named('check') { tasks.register('serveFrontend', RunYarn) { dependsOn installFrontend + dependsOn generateXStateTypes inputs.dir 'public' - inputs.dir 'src' + inputs.files sourcesWithoutTypegen inputs.file 'index.html' inputs.files('package.json', 'tsconfig.json', 'tsconfig.base.json', 'vite.config.ts') inputs.file rootProject.file('yarn.lock') @@ -95,6 +117,9 @@ tasks.register('serveFrontend', RunYarn) { tasks.named('clean') { delete 'dev-dist' + delete fileTree('src') { + include '**/*.typegen.ts' + } } 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 @@ "scripts": { "build": "cross-env MODE=production vite build", "serve": "cross-env MODE=development vite serve", + "typegen": "xstate typegen \"src/**/*.ts?(x)\"", "typecheck": "tsc -p tsconfig.node.json && tsc -p tsconfig.json", "lint": "eslint .", "lint:ci": "eslint -f json -o build/eslint.json .", @@ -28,17 +29,17 @@ "@codemirror/lint": "^6.0.0", "@codemirror/search": "^6.2.0", "@codemirror/state": "^6.1.1", - "@codemirror/view": "^6.2.1", - "@emotion/react": "^11.10.0", - "@emotion/styled": "^11.10.0", + "@codemirror/view": "^6.2.2", + "@emotion/react": "^11.10.4", + "@emotion/styled": "^11.10.4", "@fontsource/jetbrains-mono": "^4.5.10", "@fontsource/roboto": "^4.5.8", - "@lezer/common": "^1.0.0", + "@lezer/common": "^1.0.1", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.2.3", - "@material-icons/svg": "^1.0.32", - "@mui/icons-material": "5.10.2", - "@mui/material": "5.10.2", + "@material-icons/svg": "^1.0.33", + "@mui/icons-material": "5.10.3", + "@mui/material": "5.10.3", "ansi-styles": "^6.1.0", "escape-string-regexp": "^5.0.0", "lodash-es": "^4.17.21", @@ -50,6 +51,7 @@ "notistack": "^2.0.5", "react": "^18.2.0", "react-dom": "^18.2.0", + "xstate": "^4.33.5", "zod": "^3.18.0" }, "devDependencies": { @@ -57,13 +59,15 @@ "@types/eslint": "^8.4.6", "@types/html-minifier-terser": "^7.0.0", "@types/lodash-es": "^4.17.6", - "@types/node": "^18.7.13", + "@types/ms": "^0.7.31", + "@types/node": "^18.7.14", "@types/prettier": "^2.7.0", - "@types/react": "^18.0.17", + "@types/react": "^18.0.18", "@types/react-dom": "^18.0.6", - "@typescript-eslint/eslint-plugin": "^5.35.1", - "@typescript-eslint/parser": "^5.35.1", + "@typescript-eslint/eslint-plugin": "^5.36.1", + "@typescript-eslint/parser": "^5.36.1", "@vitejs/plugin-react": "^2.0.1", + "@xstate/cli": "^0.3.2", "cross-env": "^7.0.3", "eslint": "^8.23.0", "eslint-config-airbnb": "^19.0.4", @@ -74,14 +78,14 @@ "eslint-plugin-jsx-a11y": "^6.6.1", "eslint-plugin-mobx": "^0.0.9", "eslint-plugin-prettier": "^4.2.1", - "eslint-plugin-react": "^7.31.1", + "eslint-plugin-react": "^7.31.6", "eslint-plugin-react-hooks": "^4.6.0", "html-minifier-terser": "^7.0.0", "prettier": "^2.7.1", "typescript": "~4.8.2", "vite": "^3.0.9", "vite-plugin-inject-preload": "^1.1.0", - "vite-plugin-pwa": "^0.12.3", + "vite-plugin-pwa": "^0.12.6", "workbox-window": "^6.5.4" } } 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. configure({ enforceActions: 'always', - reactionRequiresObservable: true, }); 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 @@ export default class CancelledError extends Error { - constructor() { - super('Operation cancelled'); + constructor(message = 'Operation cancelled') { + super(message); } } 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 @@ -export default class Timer { - private readonly callback: () => void; - - private readonly defaultTimeout: number; - - private timeout: number | undefined; - - constructor(callback: () => void, defaultTimeout = 0) { - this.callback = () => { - this.timeout = undefined; - callback(); - }; - this.defaultTimeout = defaultTimeout; - } - - schedule(timeout?: number | undefined): void { - if (this.timeout === undefined) { - this.timeout = setTimeout(this.callback, timeout ?? this.defaultTimeout); - } - } - - reschedule(timeout?: number | undefined): void { - this.cancel(); - this.schedule(timeout); - } - - cancel(): void { - if (this.timeout !== undefined) { - clearTimeout(this.timeout); - this.timeout = undefined; - } - } -} 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 { } private idleUpdate(): void { - if (!this.webSocketClient.isOpen || !this.tracker.needsUpdate) { + if (!this.webSocketClient.opened || !this.tracker.needsUpdate) { return; } 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 @@ +import { createAtom, makeAutoObservable, observable } from 'mobx'; import { nanoid } from 'nanoid'; +import { interpret } from 'xstate'; +import CancelledError from '../utils/CancelledError'; import PendingTask from '../utils/PendingTask'; -import Timer from '../utils/Timer'; import getLogger from '../utils/getLogger'; +import webSocketMachine from './webSocketMachine'; import { - XtextWebErrorResponse, - XtextWebRequest, - XtextWebOkResponse, - XtextWebPushMessage, - XtextWebPushService, + type XtextWebPushService, + XtextResponse, + type XtextWebRequest, } from './xtextMessages'; import { PongResult } from './xtextServiceResults'; const XTEXT_SUBPROTOCOL_V1 = 'tools.refinery.language.web.xtext.v1'; -const WEBSOCKET_CLOSE_OK = 1000; - -const WEBSOCKET_CLOSE_GOING_AWAY = 1001; - -const RECONNECT_DELAY_MS = [200, 1000, 5000, 30_000]; - -const MAX_RECONNECT_DELAY_MS = - RECONNECT_DELAY_MS[RECONNECT_DELAY_MS.length - 1]; - -const BACKGROUND_IDLE_TIMEOUT_MS = 5 * 60 * 1000; - -const PING_TIMEOUT_MS = 10 * 1000; - -const REQUEST_TIMEOUT_MS = 1000; +const REQUEST_TIMEOUT = 1000; const log = getLogger('xtext.XtextWebSocketClient'); @@ -41,351 +29,250 @@ export type PushHandler = ( data: unknown, ) => void; -enum State { - Initial, - Opening, - TabVisible, - TabHiddenIdle, - TabHiddenWaitingToClose, - Error, - ClosedDueToInactivity, -} - export default class XtextWebSocketClient { - private nextMessageId = 0; + private readonly stateAtom = createAtom('state'); - private connection!: WebSocket; + private webSocket: WebSocket | undefined; private readonly pendingRequests = new Map>(); - private readonly onReconnect: ReconnectHandler; - - private readonly onPush: PushHandler; - - private state = State.Initial; - - private reconnectTryCount = 0; - - private readonly idleTimer = new Timer(() => { - this.handleIdleTimeout(); - }, BACKGROUND_IDLE_TIMEOUT_MS); - - private readonly pingTimer = new Timer(() => { - this.sendPing(); - }, PING_TIMEOUT_MS); + private readonly interpreter = interpret( + webSocketMachine + .withContext({ + ...webSocketMachine.context, + webSocketURL: `${window.location.origin.replace( + /^http/, + 'ws', + )}/xtext-service`, + }) + .withConfig({ + actions: { + openWebSocket: ({ webSocketURL }) => this.openWebSocket(webSocketURL), + closeWebSocket: () => this.closeWebSocket(), + notifyReconnect: () => this.onReconnect(), + cancelPendingRequests: () => this.cancelPendingRequests(), + }, + services: { + pingService: () => this.sendPing(), + }, + }), + { + logger: log.log.bind(log), + }, + ); + + private readonly openListener = () => { + if (this.webSocket === undefined) { + throw new Error('Open listener called without a WebSocket'); + } + const { + webSocket: { protocol }, + } = this; + if (protocol === XTEXT_SUBPROTOCOL_V1) { + this.interpreter.send('OPENED'); + } else { + this.interpreter.send({ + type: 'ERROR', + message: `Unknown subprotocol ${protocol}`, + }); + } + }; - private readonly reconnectTimer = new Timer(() => { - this.handleReconnect(); - }); + private readonly errorListener = (event: Event) => { + log.error('WebSocket error', event); + this.interpreter.send({ type: 'ERROR', message: 'WebSocket error' }); + }; - constructor(onReconnect: ReconnectHandler, onPush: PushHandler) { - this.onReconnect = onReconnect; - this.onPush = onPush; - document.addEventListener('visibilitychange', () => { - this.handleVisibilityChange(); + private readonly closeListener = ({ code, reason }: CloseEvent) => + this.interpreter.send({ + type: 'ERROR', + message: `Socket closed unexpectedly: ${code} ${reason}`, }); - this.reconnect(); - } - private get isLogicallyClosed(): boolean { - return ( - this.state === State.Error || this.state === State.ClosedDueToInactivity - ); - } - - get isOpen(): boolean { - return ( - this.state === State.TabVisible || - this.state === State.TabHiddenIdle || - this.state === State.TabHiddenWaitingToClose - ); - } - - private reconnect() { - if (this.isOpen || this.state === State.Opening) { - log.error('Trying to reconnect from', this.state); + private readonly messageListener = ({ data }: MessageEvent) => { + if (typeof data !== 'string') { + this.interpreter.send({ + type: 'ERROR', + message: 'Unexpected message format', + }); return; } - this.state = State.Opening; - const webSocketServer = window.origin.replace(/^http/, 'ws'); - const webSocketUrl = `${webSocketServer}/xtext-service`; - this.connection = new WebSocket(webSocketUrl, XTEXT_SUBPROTOCOL_V1); - this.connection.addEventListener('open', () => { - if (this.connection.protocol !== XTEXT_SUBPROTOCOL_V1) { - log.error( - 'Unknown subprotocol', - this.connection.protocol, - 'selected by server', - ); - this.forceReconnectOnError(); - } - if (document.visibilityState === 'hidden') { - this.handleTabHidden(); - } else { - this.handleTabVisibleConnected(); - } - log.info('Connected to websocket'); - this.nextMessageId = 0; - this.reconnectTryCount = 0; - this.pingTimer.schedule(); - this.onReconnect(); - }); - this.connection.addEventListener('error', (event) => { - log.error('Unexpected websocket error', event); - this.forceReconnectOnError(); - }); - this.connection.addEventListener('message', (event) => { - this.handleMessage(event.data); - }); - this.connection.addEventListener('close', (event) => { - const closedOnRequest = - this.isLogicallyClosed && - event.code === WEBSOCKET_CLOSE_OK && - this.pendingRequests.size === 0; - const closedOnNavigation = event.code === WEBSOCKET_CLOSE_GOING_AWAY; - if (closedOnNavigation) { - this.state = State.ClosedDueToInactivity; - } - if (closedOnRequest || closedOnNavigation) { - log.info('Websocket closed'); - return; - } - log.error('Websocket closed unexpectedly', event.code, event.reason); - this.forceReconnectOnError(); - }); - } - - private handleVisibilityChange() { - if (document.visibilityState === 'hidden') { - if (this.state === State.TabVisible) { - this.handleTabHidden(); - } + let json: unknown; + try { + json = JSON.parse(data); + } catch (error) { + log.error('JSON parse error', error); + this.interpreter.send({ type: 'ERROR', message: 'Malformed message' }); return; } - this.idleTimer.cancel(); - if ( - this.state === State.TabHiddenIdle || - this.state === State.TabHiddenWaitingToClose - ) { - this.handleTabVisibleConnected(); + const responseResult = XtextResponse.safeParse(json); + if (!responseResult.success) { + log.error('Xtext response', json, 'is malformed:', responseResult.error); + this.interpreter.send({ type: 'ERROR', message: 'Malformed message' }); return; } - if (this.state === State.ClosedDueToInactivity) { - this.reconnect(); + const { data: response } = responseResult; + if ('service' in response) { + // `XtextWebPushMessage.push` is optional, but `service` is not. + const { resource, stateId, service, push } = response; + this.onPush(resource, stateId, service, push); + return; + } + const { id } = response; + const task = this.pendingRequests.get(id); + if (task === undefined) { + log.warn('Task', id, 'has been already resolved'); + return; + } + this.removeTask(id); + if ('error' in response) { + const formattedMessage = `${response.error} error: ${response.message}`; + log.error('Task', id, formattedMessage); + task.reject(new Error(formattedMessage)); + } else { + task.resolve(response.response); } + }; + + constructor( + private readonly onReconnect: ReconnectHandler, + private readonly onPush: PushHandler, + ) { + this.interpreter + .onTransition((state, event) => { + log.trace('WebSocke state transition', state.value, 'on event', event); + this.stateAtom.reportChanged(); + }) + .start(); + + this.updateVisibility(); + document.addEventListener('visibilitychange', () => + this.updateVisibility(), + ); + + this.interpreter.send('CONNECT'); + + makeAutoObservable< + XtextWebSocketClient, + | 'stateAtom' + | 'webSocket' + | 'interpreter' + | 'openListener' + | 'errorListener' + | 'closeListener' + | 'messageListener' + | 'sendPing' + >(this, { + stateAtom: false, + webSocket: observable.ref, + interpreter: false, + openListener: false, + errorListener: false, + closeListener: false, + messageListener: false, + sendPing: false, + }); } - private handleTabHidden() { - log.debug('Tab hidden while websocket is connected'); - this.state = State.TabHiddenIdle; - this.idleTimer.schedule(); + get state() { + this.stateAtom.reportObserved(); + return this.interpreter.state; } - private handleTabVisibleConnected() { - log.debug('Tab visible while websocket is connected'); - this.state = State.TabVisible; + get opened(): boolean { + return this.state.matches('connection.socketCreated.open.opened'); } - private handleIdleTimeout() { - log.trace('Waiting for pending tasks before disconnect'); - if (this.state === State.TabHiddenIdle) { - this.state = State.TabHiddenWaitingToClose; - this.handleWaitingForDisconnect(); - } + connect(): void { + this.interpreter.send('CONNECT'); } - private handleWaitingForDisconnect() { - if (this.state !== State.TabHiddenWaitingToClose) { - return; - } - const pending = this.pendingRequests.size; - if (pending === 0) { - log.info('Closing idle websocket'); - this.state = State.ClosedDueToInactivity; - this.closeConnection(1000, 'idle timeout'); - return; - } - log.info( - 'Waiting for', - pending, - 'pending requests before closing websocket', - ); + disconnect(): void { + this.interpreter.send('DISCONNECT'); } - private sendPing() { - if (!this.isOpen) { - return; - } - const ping = nanoid(); - log.trace('Ping', ping); - this.send({ ping }) - .then((result) => { - const parsedPongResult = PongResult.safeParse(result); - if (parsedPongResult.success && parsedPongResult.data.pong === ping) { - log.trace('Pong', ping); - this.pingTimer.schedule(); - } else { - log.error('Invalid pong:', parsedPongResult, 'expected:', ping); - this.forceReconnectOnError(); - } - }) - .catch((error) => { - log.error('Error while waiting for ping', error); - this.forceReconnectOnError(); - }); + forceReconnectOnError(): void { + this.interpreter.send({ + type: 'ERROR', + message: 'Client error', + }); } send(request: unknown): Promise { - if (!this.isOpen) { - throw new Error('Not open'); - } - const messageId = this.nextMessageId.toString(16); - if (messageId in this.pendingRequests) { - log.error('Message id wraparound still pending', messageId); - this.rejectRequest(messageId, new Error('Message id wraparound')); + if (!this.opened || this.webSocket === undefined) { + throw new Error('Not connected'); } - if (this.nextMessageId >= Number.MAX_SAFE_INTEGER) { - this.nextMessageId = 0; - } else { - this.nextMessageId += 1; - } - const message = JSON.stringify({ - id: messageId, - request, - } as XtextWebRequest); - log.trace('Sending message', message); - return new Promise((resolve, reject) => { - const task = new PendingTask(resolve, reject, REQUEST_TIMEOUT_MS, () => { - this.removePendingRequest(messageId); - }); - this.pendingRequests.set(messageId, task); - this.connection.send(message); + + const id = nanoid(); + + const promise = new Promise((resolve, reject) => { + const task = new PendingTask(resolve, reject, REQUEST_TIMEOUT, () => + this.removeTask(id), + ); + this.pendingRequests.set(id, task); }); + + const webRequest: XtextWebRequest = { id, request }; + const json = JSON.stringify(webRequest); + this.webSocket.send(json); + + return promise; } - private handleMessage(messageStr: unknown) { - if (typeof messageStr !== 'string') { - log.error('Unexpected binary message', messageStr); - this.forceReconnectOnError(); - return; - } - log.trace('Incoming websocket message', messageStr); - let message: unknown; - try { - message = JSON.parse(messageStr); - } catch (error) { - log.error('Json parse error', error); - this.forceReconnectOnError(); - return; - } - const okResponse = XtextWebOkResponse.safeParse(message); - if (okResponse.success) { - const { id, response } = okResponse.data; - this.resolveRequest(id, response); - return; - } - const errorResponse = XtextWebErrorResponse.safeParse(message); - if (errorResponse.success) { - const { id, error, message: errorMessage } = errorResponse.data; - this.rejectRequest(id, new Error(`${error} error: ${errorMessage}`)); - if (error === 'server') { - log.error('Reconnecting due to server error: ', errorMessage); - this.forceReconnectOnError(); - } - return; - } - const pushMessage = XtextWebPushMessage.safeParse(message); - if (pushMessage.success) { - const { resource, stateId, service, push } = pushMessage.data; - this.onPush(resource, stateId, service, push); - } else { - log.error( - 'Unexpected websocket message:', - message, - 'not ok response because:', - okResponse.error, - 'not error response because:', - errorResponse.error, - 'not push message because:', - pushMessage.error, - ); - this.forceReconnectOnError(); - } + private updateVisibility(): void { + this.interpreter.send(document.hidden ? 'TAB_HIDDEN' : 'TAB_VISIBLE'); } - private resolveRequest(messageId: string, value: unknown) { - const pendingRequest = this.pendingRequests.get(messageId); - if (pendingRequest) { - pendingRequest.resolve(value); - this.removePendingRequest(messageId); - return; + private openWebSocket(webSocketURL: string | undefined): void { + if (this.webSocket !== undefined) { + throw new Error('WebSocket already open'); } - log.error('Trying to resolve unknown request', messageId, 'with', value); - } - private rejectRequest(messageId: string, reason?: unknown) { - const pendingRequest = this.pendingRequests.get(messageId); - if (pendingRequest) { - pendingRequest.reject(reason); - this.removePendingRequest(messageId); - return; + if (webSocketURL === undefined) { + throw new Error('URL not configured'); } - log.error('Trying to reject unknown request', messageId, 'with', reason); - } - private removePendingRequest(messageId: string) { - this.pendingRequests.delete(messageId); - this.handleWaitingForDisconnect(); + log.debug('Creating WebSocket'); + + this.webSocket = new WebSocket(webSocketURL, XTEXT_SUBPROTOCOL_V1); + this.webSocket.addEventListener('open', this.openListener); + this.webSocket.addEventListener('close', this.closeListener); + this.webSocket.addEventListener('error', this.errorListener); + this.webSocket.addEventListener('message', this.messageListener); } - forceReconnectOnError(): void { - if (this.isLogicallyClosed) { - return; - } - this.pendingRequests.forEach((request) => { - request.reject(new Error('Websocket disconnect')); - }); - this.pendingRequests.clear(); - this.closeConnection(1000, 'reconnecting due to error'); - if (this.state === State.Error) { - // We are already handling this error condition. - return; + private closeWebSocket() { + if (this.webSocket === undefined) { + throw new Error('WebSocket already closed'); } - if ( - this.state === State.TabHiddenIdle || - this.state === State.TabHiddenWaitingToClose - ) { - log.error('Will reconned due to error once the tab becomes visible'); - this.idleTimer.cancel(); - this.state = State.ClosedDueToInactivity; - return; - } - log.error('Reconnecting after delay due to error'); - this.state = State.Error; - this.reconnectTryCount += 1; - const delay = - RECONNECT_DELAY_MS[this.reconnectTryCount - 1] ?? MAX_RECONNECT_DELAY_MS; - log.info('Reconnecting in', delay, 'ms'); - this.reconnectTimer.schedule(delay); + + log.debug('Closing WebSocket'); + + this.webSocket.removeEventListener('open', this.openListener); + this.webSocket.removeEventListener('close', this.closeListener); + this.webSocket.removeEventListener('error', this.errorListener); + this.webSocket.removeEventListener('message', this.messageListener); + this.webSocket.close(1000, 'Closing connection'); + this.webSocket = undefined; } - private closeConnection(code: number, reason: string) { - this.pingTimer.cancel(); - const { readyState } = this.connection; - if (readyState !== WebSocket.CLOSING && readyState !== WebSocket.CLOSED) { - this.connection.close(code, reason); - } + private removeTask(id: string): void { + this.pendingRequests.delete(id); } - private handleReconnect() { - if (this.state !== State.Error) { - log.error('Unexpected reconnect in', this.state); - return; - } - if (document.visibilityState === 'hidden') { - this.state = State.ClosedDueToInactivity; - } else { - this.reconnect(); + private cancelPendingRequests(): void { + this.pendingRequests.forEach((task) => + task.reject(new CancelledError('Closing connection')), + ); + this.pendingRequests.clear(); + } + + private async sendPing(): Promise { + const ping = nanoid(); + const result = await this.send({ ping }); + const { pong } = PongResult.parse(result); + if (ping !== pong) { + throw new Error(`Expected pong ${ping} but got ${pong} instead`); } } } 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 @@ +import { actions, assign, createMachine, RaiseAction } from 'xstate'; + +const { raise } = actions; + +const ERROR_WAIT_TIMES = [200, 1000, 5000, 30_000]; + +export interface WebSocketContext { + webSocketURL: string | undefined; + errors: string[]; + retryCount: number; +} + +export type WebSocketEvent = + | { type: 'CONFIGURE'; webSocketURL: string } + | { type: 'CONNECT' } + | { type: 'DISCONNECT' } + | { type: 'OPENED' } + | { type: 'TAB_VISIBLE' } + | { type: 'TAB_HIDDEN' } + | { type: 'ERROR'; message: string }; + +export default createMachine( + { + id: 'webSocket', + predictableActionArguments: true, + schema: { + context: {} as WebSocketContext, + events: {} as WebSocketEvent, + }, + tsTypes: {} as import('./webSocketMachine.typegen').Typegen0, + context: { + webSocketURL: undefined, + errors: [], + retryCount: 0, + }, + type: 'parallel', + states: { + connection: { + initial: 'disconnected', + states: { + disconnected: { + id: 'disconnected', + on: { + CONFIGURE: { actions: 'configure' }, + }, + }, + timedOut: { + id: 'timedOut', + on: { + TAB_VISIBLE: 'socketCreated', + }, + }, + errorWait: { + id: 'errorWait', + after: { + ERROR_WAIT_TIME: [ + { target: 'timedOut', in: '#tabHidden' }, + { target: 'socketCreated' }, + ], + }, + }, + socketCreated: { + type: 'parallel', + entry: 'openWebSocket', + exit: ['cancelPendingRequests', 'closeWebSocket'], + states: { + open: { + initial: 'opening', + states: { + opening: { + after: { + OPEN_TIMEOUT: { + actions: 'raiseTimeoutError', + }, + }, + on: { + OPENED: { + target: 'opened', + actions: ['clearError', 'notifyReconnect'], + }, + }, + }, + opened: { + initial: 'pongReceived', + states: { + pongReceived: { + after: { + PING_PERIOD: 'pingSent', + }, + }, + pingSent: { + invoke: { + src: 'pingService', + onDone: 'pongReceived', + onError: { + actions: 'raisePromiseRejectionError', + }, + }, + }, + }, + }, + }, + }, + idle: { + initial: 'getTabState', + states: { + getTabState: { + always: [ + { target: 'inactive', in: '#tabHidden' }, + 'active', + ], + }, + active: { + on: { + TAB_HIDDEN: 'inactive', + }, + }, + inactive: { + after: { + IDLE_TIMEOUT: '#timedOut', + }, + on: { + TAB_VISIBLE: 'active', + }, + }, + }, + }, + }, + on: { + CONNECT: undefined, + ERROR: { + target: '#errorWait', + actions: 'increaseRetryCount', + }, + }, + }, + }, + on: { + CONNECT: { target: '.socketCreated', cond: 'hasWebSocketURL' }, + DISCONNECT: { target: '.disconnected', actions: 'clearError' }, + }, + }, + tab: { + initial: 'visibleOrUnknown', + states: { + visibleOrUnknown: { + on: { + TAB_HIDDEN: 'hidden', + }, + }, + hidden: { + id: 'tabHidden', + on: { + TAB_VISIBLE: 'visibleOrUnknown', + }, + }, + }, + }, + error: { + initial: 'init', + states: { + init: { + on: { + ERROR: { actions: 'pushError' }, + }, + }, + }, + }, + }, + }, + { + guards: { + hasWebSocketURL: ({ webSocketURL }) => webSocketURL !== undefined, + }, + delays: { + IDLE_TIMEOUT: 300_000, + OPEN_TIMEOUT: 5000, + PING_PERIOD: 10_000, + ERROR_WAIT_TIME: ({ retryCount }) => { + const { length } = ERROR_WAIT_TIMES; + const index = retryCount < length ? retryCount : length - 1; + return ERROR_WAIT_TIMES[index]; + }, + }, + actions: { + configure: assign((context, { webSocketURL }) => ({ + ...context, + webSocketURL, + })), + pushError: assign((context, { message }) => ({ + ...context, + errors: [...context.errors, message], + })), + increaseRetryCount: assign((context) => ({ + ...context, + retryCount: context.retryCount + 1, + })), + clearError: assign((context) => ({ + ...context, + errors: [], + retryCount: 0, + })), + // Workaround from https://github.com/statelyai/xstate/issues/1414#issuecomment-699972485 + raiseTimeoutError: raise({ + type: 'ERROR', + message: 'Open timeout', + }) as RaiseAction, + raisePromiseRejectionError: (_context, { data }) => + raise({ + type: 'ERROR', + message: data, + }) as RaiseAction, + }, + }, +); 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({ }); export type XtextWebPushMessage = z.infer; + +export const XtextResponse = z.union([ + XtextWebOkResponse, + XtextWebErrorResponse, + XtextWebPushMessage, +]); + +export type XtextResponse = z.infer; -- cgit v1.2.3-70-g09d2