From dcbfeece5e559b60a615f0aa9b933b202d34bf8b Mon Sep 17 00:00:00 2001 From: Kristóf Marussy Date: Mon, 25 Oct 2021 00:29:37 +0200 Subject: feat(web): add xtext websocket client --- language-web/src/main/js/editor/EditorStore.ts | 5 +- language-web/src/main/js/editor/PendingRequest.ts | 47 ++++ language-web/src/main/js/editor/XtextClient.ts | 243 +++++++++++++++++++++ .../src/main/js/editor/XtextWebSocketClient.ts | 185 ++++++++++++++++ language-web/src/main/js/editor/xtextMessages.ts | 55 +++++ .../src/main/js/editor/xtextServiceResults.ts | 61 ++++++ language-web/src/main/js/index.tsx | 2 +- 7 files changed, 596 insertions(+), 2 deletions(-) create mode 100644 language-web/src/main/js/editor/PendingRequest.ts create mode 100644 language-web/src/main/js/editor/XtextClient.ts create mode 100644 language-web/src/main/js/editor/XtextWebSocketClient.ts create mode 100644 language-web/src/main/js/editor/xtextMessages.ts create mode 100644 language-web/src/main/js/editor/xtextServiceResults.ts (limited to 'language-web/src/main/js') diff --git a/language-web/src/main/js/editor/EditorStore.ts b/language-web/src/main/js/editor/EditorStore.ts index eb358338..31bb0a11 100644 --- a/language-web/src/main/js/editor/EditorStore.ts +++ b/language-web/src/main/js/editor/EditorStore.ts @@ -41,6 +41,7 @@ import { import { getLogger } from '../logging'; import { problemLanguageSupport } from './problemLanguageSupport'; import type { ThemeStore } from '../theme/ThemeStore'; +import { XtextClient } from './XtextClient'; const log = getLogger('EditorStore'); @@ -49,7 +50,7 @@ export class EditorStore { state: EditorState; - emptyHistory: unknown; + client: XtextClient; showLineNumbers = false; @@ -109,6 +110,7 @@ export class EditorStore { problemLanguageSupport(), ], }); + this.client = new XtextClient(this); reaction( () => this.themeStore.darkMode, (darkMode) => { @@ -137,6 +139,7 @@ export class EditorStore { onTransaction(tr: Transaction): void { log.trace('Editor transaction', tr); this.state = tr.state; + this.client.onTransaction(tr); } dispatch(...specs: readonly TransactionSpec[]): void { diff --git a/language-web/src/main/js/editor/PendingRequest.ts b/language-web/src/main/js/editor/PendingRequest.ts new file mode 100644 index 00000000..784f06ec --- /dev/null +++ b/language-web/src/main/js/editor/PendingRequest.ts @@ -0,0 +1,47 @@ +import { getLogger } from '../logging'; + +const REQUEST_TIMEOUT_MS = 1000; + +const log = getLogger('PendingRequest'); + +export class PendingRequest { + private readonly resolveCallback: (value: unknown) => void; + + private readonly rejectCallback: (reason?: unknown) => void; + + private resolved = false; + + private timeoutId: NodeJS.Timeout; + + constructor(resolve: (value: unknown) => void, reject: (reason?: unknown) => void) { + this.resolveCallback = resolve; + this.rejectCallback = reject; + this.timeoutId = setTimeout(() => { + if (!this.resolved) { + this.reject(new Error('Request timed out')); + } + }, REQUEST_TIMEOUT_MS); + } + + resolve(value: unknown): void { + if (this.resolved) { + log.warn('Trying to resolve already resolved promise'); + return; + } + this.markResolved(); + this.resolveCallback(value); + } + + reject(reason?: unknown): void { + if (this.resolved) { + log.warn('Trying to reject already resolved promise'); + } + this.markResolved(); + this.rejectCallback(reason); + } + + private markResolved() { + this.resolved = true; + clearTimeout(this.timeoutId); + } +} diff --git a/language-web/src/main/js/editor/XtextClient.ts b/language-web/src/main/js/editor/XtextClient.ts new file mode 100644 index 00000000..eeb67d72 --- /dev/null +++ b/language-web/src/main/js/editor/XtextClient.ts @@ -0,0 +1,243 @@ +import { Diagnostic, setDiagnostics } from '@codemirror/lint'; +import { + ChangeDesc, + ChangeSet, + EditorState, + Transaction, +} from '@codemirror/state'; +import { nanoid } from 'nanoid'; + +import type { EditorStore } from './EditorStore'; +import { getLogger } from '../logging'; +import { + isDocumentStateResult, + isServiceConflictResult, + isValidationResult, +} from './xtextServiceResults'; +import { XtextWebSocketClient } from './XtextWebSocketClient'; + +const UPDATE_TIMEOUT_MS = 300; + +const log = getLogger('XtextClient'); + +enum UpdateAction { + ForceReconnect, + + FullTextUpdate, +} + +export class XtextClient { + resourceName: string; + + webSocketClient: XtextWebSocketClient; + + xtextStateId: string | null = null; + + pendingUpdate: ChangeDesc | null; + + dirtyChanges: ChangeDesc; + + updateTimeout: NodeJS.Timeout | null = null; + + store: EditorStore; + + constructor(store: EditorStore) { + this.resourceName = `${nanoid(7)}.problem`; + this.pendingUpdate = null; + this.store = store; + this.dirtyChanges = this.newEmptyChangeDesc(); + this.webSocketClient = new XtextWebSocketClient( + () => { + this.updateFullText().catch((error) => { + log.error('Unexpected error during initial update', error); + }); + }, + (resource, stateId, service, push) => { + this.onPush(resource, stateId, service, push).catch((error) => { + log.error('Unexected error during push message handling', error); + }); + }, + ); + } + + onTransaction(transaction: Transaction): void { + const { changes } = transaction; + if (!changes.empty) { + this.dirtyChanges = this.dirtyChanges.composeDesc(changes.desc); + this.scheduleUpdate(); + } + } + + private async onPush(resource: string, stateId: string, service: string, push: unknown) { + if (resource !== this.resourceName) { + log.error('Unknown resource name: expected:', this.resourceName, 'got:', resource); + return; + } + if (stateId !== this.xtextStateId) { + log.error('Unexpected xtext state id: expected:', this.xtextStateId, 'got:', resource); + await this.updateFullText(); + } + switch (service) { + case 'validate': + this.onValidate(push); + return; + case 'highlight': + // TODO + return; + default: + log.error('Unknown push service:', service); + break; + } + } + + private onValidate(push: unknown) { + if (!isValidationResult(push)) { + log.error('Invalid validation result', push); + return; + } + const allChanges = this.computeChangesSinceLastUpdate(); + const diagnostics: Diagnostic[] = []; + push.issues.forEach((issue) => { + if (issue.severity === 'ignore') { + return; + } + diagnostics.push({ + from: allChanges.mapPos(issue.offset), + to: allChanges.mapPos(issue.offset + issue.length), + severity: issue.severity, + message: issue.description, + }); + }); + this.store.dispatch(setDiagnostics(this.store.state, diagnostics)); + } + + private computeChangesSinceLastUpdate() { + if (this.pendingUpdate === null) { + return this.dirtyChanges; + } + return this.pendingUpdate.composeDesc(this.dirtyChanges); + } + + private scheduleUpdate() { + if (this.updateTimeout !== null) { + clearTimeout(this.updateTimeout); + } + this.updateTimeout = setTimeout(() => { + this.updateTimeout = null; + if (!this.webSocketClient.isOpen || this.dirtyChanges.empty) { + return; + } + if (!this.pendingUpdate) { + this.updateDeltaText().catch((error) => { + log.error('Unexpected error during scheduled update', error); + }); + } + this.scheduleUpdate(); + }, UPDATE_TIMEOUT_MS); + } + + private newEmptyChangeDesc() { + const changeSet = ChangeSet.of([], this.store.state.doc.length); + return changeSet.desc; + } + + private async updateFullText() { + await this.withUpdate(async () => { + const result = await this.webSocketClient.send({ + resource: this.resourceName, + serviceType: 'update', + fullText: this.store.state.doc.sliceString(0), + }); + if (isDocumentStateResult(result)) { + return result.stateId; + } + if (isServiceConflictResult(result)) { + log.error('Full text update conflict:', result.conflict); + if (result.conflict === 'canceled') { + return UpdateAction.FullTextUpdate; + } + return UpdateAction.ForceReconnect; + } + log.error('Unexpected full text update result:', result); + return UpdateAction.ForceReconnect; + }); + } + + private async updateDeltaText() { + if (this.xtextStateId === null) { + await this.updateFullText(); + return; + } + const delta = this.computeDelta(); + log.debug('Editor delta', delta); + await this.withUpdate(async () => { + const result = await this.webSocketClient.send({ + resource: this.resourceName, + serviceType: 'update', + requiredStateId: this.xtextStateId, + ...delta, + }); + if (isDocumentStateResult(result)) { + return result.stateId; + } + if (isServiceConflictResult(result)) { + log.error('Delta text update conflict:', result.conflict); + return UpdateAction.FullTextUpdate; + } + log.error('Unexpected delta text update result:', result); + return UpdateAction.ForceReconnect; + }); + } + + private computeDelta() { + if (this.dirtyChanges.empty) { + return {}; + } + let minFromA = Number.MAX_SAFE_INTEGER; + let maxToA = 0; + let minFromB = Number.MAX_SAFE_INTEGER; + let maxToB = 0; + this.dirtyChanges.iterChangedRanges((fromA, toA, fromB, toB) => { + minFromA = Math.min(minFromA, fromA); + maxToA = Math.max(maxToA, toA); + minFromB = Math.min(minFromB, fromB); + maxToB = Math.max(maxToB, toB); + }); + return { + deltaOffset: minFromA, + deltaReplaceLength: maxToA - minFromA, + deltaText: this.store.state.doc.sliceString(minFromB, maxToB), + }; + } + + private async withUpdate(callback: () => Promise) { + if (this.pendingUpdate !== null) { + log.error('Another update is pending, will not perform update'); + return; + } + this.pendingUpdate = this.dirtyChanges; + this.dirtyChanges = this.newEmptyChangeDesc(); + let newStateId: string | UpdateAction = UpdateAction.ForceReconnect; + try { + newStateId = await callback(); + } catch (error) { + log.error('Error while updating state', error); + } finally { + if (typeof newStateId === 'string') { + this.xtextStateId = newStateId; + this.pendingUpdate = null; + } else { + this.dirtyChanges = this.pendingUpdate.composeDesc(this.dirtyChanges); + this.pendingUpdate = null; + switch (newStateId) { + case UpdateAction.ForceReconnect: + this.webSocketClient.forceReconnectDueToError(); + break; + case UpdateAction.FullTextUpdate: + await this.updateFullText(); + break; + } + } + } + } +} diff --git a/language-web/src/main/js/editor/XtextWebSocketClient.ts b/language-web/src/main/js/editor/XtextWebSocketClient.ts new file mode 100644 index 00000000..131e0067 --- /dev/null +++ b/language-web/src/main/js/editor/XtextWebSocketClient.ts @@ -0,0 +1,185 @@ +import { getLogger } from '../logging'; +import { PendingRequest } from './PendingRequest'; +import { + isErrorResponse, + isOkResponse, + isPushMessage, + IXtextWebRequest, +} from './xtextMessages'; + +const XTEXT_SUBPROTOCOL_V1 = 'tools.refinery.language.web.xtext.v1'; + +const WEBSOCKET_CLOSE_OK = 1000; + +const RECONNECT_DELAY_MS = 1000; + +const log = getLogger('XtextWebSocketClient'); + +type ReconnectHandler = () => void; + +type PushHandler = (resourceId: string, stateId: string, service: string, data: unknown) => void; + +export class XtextWebSocketClient { + nextMessageId = 0; + + closing = false; + + connection!: WebSocket; + + pendingRequests = new Map(); + + onReconnect: ReconnectHandler; + + onPush: PushHandler; + + reconnectTimeout: NodeJS.Timeout | null = null; + + constructor(onReconnect: ReconnectHandler, onPush: PushHandler) { + this.onReconnect = onReconnect; + this.onPush = onPush; + this.reconnect(); + } + + get isOpen(): boolean { + return this.connection.readyState === WebSocket.OPEN; + } + + private reconnect() { + this.reconnectTimeout = null; + 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.forceReconnectDueToError(); + return; + } + log.info('Connected to xtext web services'); + this.onReconnect(); + }); + this.connection.addEventListener('error', (event) => { + log.error('Unexpected websocket error', event); + this.forceReconnectDueToError(); + }); + this.connection.addEventListener('message', (event) => { + this.handleMessage(event.data); + }); + this.connection.addEventListener('close', (event) => { + if (!this.closing || event.code !== WEBSOCKET_CLOSE_OK) { + log.error('Websocket closed undexpectedly', event.code, event.reason); + } + this.cleanupAndMaybeReconnect(); + }); + } + + private cleanupAndMaybeReconnect() { + this.pendingRequests.forEach((pendingRequest) => { + pendingRequest.reject(new Error('Websocket closed')); + }); + this.pendingRequests.clear(); + if (this.closing) { + return; + } + if (this.reconnectTimeout !== null) { + clearTimeout(this.reconnectTimeout); + } + this.reconnectTimeout = setTimeout(() => { + log.info('Attempting to reconnect websocket'); + this.reconnect(); + }, RECONNECT_DELAY_MS); + } + + public forceReconnectDueToError(): void { + this.closeConnection(); + this.cleanupAndMaybeReconnect(); + } + + send(request: unknown): Promise { + if (!this.isOpen) { + throw new Error('Connection is 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.nextMessageId >= Number.MAX_SAFE_INTEGER) { + this.nextMessageId = 0; + } else { + this.nextMessageId += 1; + } + const message = JSON.stringify({ + id: messageId, + request, + } as IXtextWebRequest); + return new Promise((resolve, reject) => { + this.connection.send(message); + this.pendingRequests.set(messageId, new PendingRequest(resolve, reject)); + }); + } + + private handleMessage(messageStr: unknown) { + if (typeof messageStr !== 'string') { + log.error('Unexpected binary message', messageStr); + this.forceReconnectDueToError(); + return; + } + log.trace('Incoming websocket message', messageStr); + let message: unknown; + try { + message = JSON.parse(messageStr); + } catch (error) { + log.error('Json parse error', error); + this.forceReconnectDueToError(); + return; + } + if (isOkResponse(message)) { + this.resolveRequest(message.id, message.response); + } else if (isErrorResponse(message)) { + this.rejectRequest(message.id, new Error(`${message.error} error: ${message.message}`)); + if (message.error === 'server') { + log.error('Reconnecting due to server error: ', message.message); + this.forceReconnectDueToError(); + } + } else if (isPushMessage(message)) { + this.onPush(message.resource, message.stateId, message.service, message.push); + } else { + log.error('Unexpected websocket message', message); + this.forceReconnectDueToError(); + } + } + + private resolveRequest(messageId: string, value: unknown) { + const pendingRequest = this.pendingRequests.get(messageId); + this.pendingRequests.delete(messageId); + if (pendingRequest) { + pendingRequest.resolve(value); + return; + } + log.error('Trying to resolve unknown request', messageId, 'with', value); + } + + private rejectRequest(messageId: string, reason?: unknown) { + const pendingRequest = this.pendingRequests.get(messageId); + this.pendingRequests.delete(messageId); + if (pendingRequest) { + pendingRequest.reject(reason); + return; + } + log.error('Trying to reject unknown request', messageId, 'with', reason); + } + + private closeConnection() { + if (this.connection && this.connection.readyState !== WebSocket.CLOSING + && this.connection.readyState !== WebSocket.CLOSED) { + log.info('Closing websocket connection'); + this.connection.close(); + } + } + + close(): void { + this.closing = true; + this.closeConnection(); + } +} diff --git a/language-web/src/main/js/editor/xtextMessages.ts b/language-web/src/main/js/editor/xtextMessages.ts new file mode 100644 index 00000000..d3cb9425 --- /dev/null +++ b/language-web/src/main/js/editor/xtextMessages.ts @@ -0,0 +1,55 @@ +export interface IXtextWebRequest { + id: string; + + request: unknown; +} + +export interface IXtextWebOkResponse { + id: string; + + response: unknown; +} + +export function isOkResponse(response: unknown): response is IXtextWebOkResponse { + const okResponse = response as IXtextWebOkResponse; + return typeof okResponse.id === 'string' + && typeof okResponse.response !== 'undefined'; +} + +export const VALID_XTEXT_WEB_ERROR_KINDS = ['request', 'server'] as const; + +export type XtextWebErrorKind = typeof VALID_XTEXT_WEB_ERROR_KINDS[number]; + +export interface IXtextWebErrorResponse { + id: string; + + error: XtextWebErrorKind; + + message: string; +} + +export function isErrorResponse(response: unknown): response is IXtextWebErrorResponse { + const errorResponse = response as IXtextWebErrorResponse; + return typeof errorResponse.id === 'string' + && typeof errorResponse.error === 'string' + && VALID_XTEXT_WEB_ERROR_KINDS.includes(errorResponse.error) + && typeof errorResponse.message === 'string'; +} + +export interface IXtextWebPushMessage { + resource: string; + + stateId: string; + + service: string; + + push: unknown; +} + +export function isPushMessage(response: unknown): response is IXtextWebPushMessage { + const pushMessage = response as IXtextWebPushMessage; + return typeof pushMessage.resource === 'string' + && typeof pushMessage.stateId === 'string' + && typeof pushMessage.service === 'string' + && typeof pushMessage.push !== 'undefined'; +} diff --git a/language-web/src/main/js/editor/xtextServiceResults.ts b/language-web/src/main/js/editor/xtextServiceResults.ts new file mode 100644 index 00000000..2a66566a --- /dev/null +++ b/language-web/src/main/js/editor/xtextServiceResults.ts @@ -0,0 +1,61 @@ +export interface IDocumentStateResult { + stateId: string; +} + +export function isDocumentStateResult(result: unknown): result is IDocumentStateResult { + const documentStateResult = result as IDocumentStateResult; + return typeof documentStateResult.stateId === 'string'; +} + +export const VALID_CONFLICTS = ['invalidStateId', 'canceled'] as const; + +export type Conflict = typeof VALID_CONFLICTS[number]; + +export interface IServiceConflictResult { + conflict: Conflict; +} + +export function isServiceConflictResult(result: unknown): result is IServiceConflictResult { + const serviceConflictResult = result as IServiceConflictResult; + return typeof serviceConflictResult.conflict === 'string' + && VALID_CONFLICTS.includes(serviceConflictResult.conflict); +} + +export const VALID_SEVERITIES = ['error', 'warning', 'info', 'ignore'] as const; + +export type Severity = typeof VALID_SEVERITIES[number]; + +export interface IIssue { + description: string; + + severity: Severity; + + line: number; + + column: number; + + offset: number; + + length: number; +} + +export function isIssue(value: unknown): value is IIssue { + const issue = value as IIssue; + return typeof issue.description === 'string' + && typeof issue.severity === 'string' + && VALID_SEVERITIES.includes(issue.severity) + && typeof issue.line === 'number' + && typeof issue.column === 'number' + && typeof issue.offset === 'number' + && typeof issue.length === 'number'; +} + +export interface IValidationResult { + issues: IIssue[]; +} + +export function isValidationResult(result: unknown): result is IValidationResult { + const validationResult = result as IValidationResult; + return Array.isArray(validationResult.issues) + && validationResult.issues.every(isIssue); +} diff --git a/language-web/src/main/js/index.tsx b/language-web/src/main/js/index.tsx index 9316db4d..1b24eadb 100644 --- a/language-web/src/main/js/index.tsx +++ b/language-web/src/main/js/index.tsx @@ -26,7 +26,7 @@ enum TaxStatus { % A child cannot have any dependents. error invalidTaxStatus(Person p) <-> taxStatus(p, child), - children(p, _q), + children(p, _q) ; taxStatus(p, retired), parent(p, q), !taxStatus(q, retired). -- cgit v1.2.3-54-g00ecf