diff options
author | Kristóf Marussy <kristof@marussy.com> | 2021-12-13 02:07:04 +0100 |
---|---|---|
committer | Kristóf Marussy <kristof@marussy.com> | 2021-12-14 02:14:23 +0100 |
commit | a96c52b21e7e590bbdd70b80896780a446fa2e8b (patch) | |
tree | 663619baa254577bb2f5342192e80bca692ad91d /subprojects/frontend/src/xtext | |
parent | build: move modules into subproject directory (diff) | |
download | refinery-a96c52b21e7e590bbdd70b80896780a446fa2e8b.tar.gz refinery-a96c52b21e7e590bbdd70b80896780a446fa2e8b.tar.zst refinery-a96c52b21e7e590bbdd70b80896780a446fa2e8b.zip |
build: separate module for frontend
This allows us to simplify the webpack configuration and the gradle
build scripts.
Diffstat (limited to 'subprojects/frontend/src/xtext')
-rw-r--r-- | subprojects/frontend/src/xtext/ContentAssistService.ts | 219 | ||||
-rw-r--r-- | subprojects/frontend/src/xtext/HighlightingService.ts | 37 | ||||
-rw-r--r-- | subprojects/frontend/src/xtext/OccurrencesService.ts | 127 | ||||
-rw-r--r-- | subprojects/frontend/src/xtext/UpdateService.ts | 363 | ||||
-rw-r--r-- | subprojects/frontend/src/xtext/ValidationService.ts | 39 | ||||
-rw-r--r-- | subprojects/frontend/src/xtext/XtextClient.ts | 86 | ||||
-rw-r--r-- | subprojects/frontend/src/xtext/XtextWebSocketClient.ts | 362 | ||||
-rw-r--r-- | subprojects/frontend/src/xtext/xtextMessages.ts | 40 | ||||
-rw-r--r-- | subprojects/frontend/src/xtext/xtextServiceResults.ts | 112 |
9 files changed, 1385 insertions, 0 deletions
diff --git a/subprojects/frontend/src/xtext/ContentAssistService.ts b/subprojects/frontend/src/xtext/ContentAssistService.ts new file mode 100644 index 00000000..8b872e06 --- /dev/null +++ b/subprojects/frontend/src/xtext/ContentAssistService.ts | |||
@@ -0,0 +1,219 @@ | |||
1 | import type { | ||
2 | Completion, | ||
3 | CompletionContext, | ||
4 | CompletionResult, | ||
5 | } from '@codemirror/autocomplete'; | ||
6 | import { syntaxTree } from '@codemirror/language'; | ||
7 | import type { Transaction } from '@codemirror/state'; | ||
8 | import escapeStringRegexp from 'escape-string-regexp'; | ||
9 | |||
10 | import { implicitCompletion } from '../language/props'; | ||
11 | import type { UpdateService } from './UpdateService'; | ||
12 | import { getLogger } from '../utils/logger'; | ||
13 | import type { ContentAssistEntry } from './xtextServiceResults'; | ||
14 | |||
15 | const PROPOSALS_LIMIT = 1000; | ||
16 | |||
17 | const IDENTIFIER_REGEXP_STR = '[a-zA-Z0-9_]*'; | ||
18 | |||
19 | const HIGH_PRIORITY_KEYWORDS = ['<->', '~>']; | ||
20 | |||
21 | const log = getLogger('xtext.ContentAssistService'); | ||
22 | |||
23 | interface IFoundToken { | ||
24 | from: number; | ||
25 | |||
26 | to: number; | ||
27 | |||
28 | implicitCompletion: boolean; | ||
29 | |||
30 | text: string; | ||
31 | } | ||
32 | |||
33 | function findToken({ pos, state }: CompletionContext): IFoundToken | null { | ||
34 | const token = syntaxTree(state).resolveInner(pos, -1); | ||
35 | if (token === null) { | ||
36 | return null; | ||
37 | } | ||
38 | if (token.firstChild !== null) { | ||
39 | // We only autocomplete terminal nodes. If the current node is nonterminal, | ||
40 | // returning `null` makes us autocomplete with the empty prefix instead. | ||
41 | return null; | ||
42 | } | ||
43 | return { | ||
44 | from: token.from, | ||
45 | to: token.to, | ||
46 | implicitCompletion: token.type.prop(implicitCompletion) || false, | ||
47 | text: state.sliceDoc(token.from, token.to), | ||
48 | }; | ||
49 | } | ||
50 | |||
51 | function shouldCompleteImplicitly(token: IFoundToken | null, context: CompletionContext): boolean { | ||
52 | return token !== null | ||
53 | && token.implicitCompletion | ||
54 | && context.pos - token.from >= 2; | ||
55 | } | ||
56 | |||
57 | function computeSpan(prefix: string, entryCount: number): RegExp { | ||
58 | const escapedPrefix = escapeStringRegexp(prefix); | ||
59 | if (entryCount < PROPOSALS_LIMIT) { | ||
60 | // Proposals with the current prefix fit the proposals limit. | ||
61 | // We can filter client side as long as the current prefix is preserved. | ||
62 | return new RegExp(`^${escapedPrefix}${IDENTIFIER_REGEXP_STR}$`); | ||
63 | } | ||
64 | // The current prefix overflows the proposals limits, | ||
65 | // so we have to fetch the completions again on the next keypress. | ||
66 | // Hopefully, it'll return a shorter list and we'll be able to filter client side. | ||
67 | return new RegExp(`^${escapedPrefix}$`); | ||
68 | } | ||
69 | |||
70 | function createCompletion(entry: ContentAssistEntry): Completion { | ||
71 | let boost: number; | ||
72 | switch (entry.kind) { | ||
73 | case 'KEYWORD': | ||
74 | // Some hard-to-type operators should be on top. | ||
75 | boost = HIGH_PRIORITY_KEYWORDS.includes(entry.proposal) ? 10 : -99; | ||
76 | break; | ||
77 | case 'TEXT': | ||
78 | case 'SNIPPET': | ||
79 | boost = -90; | ||
80 | break; | ||
81 | default: { | ||
82 | // Penalize qualified names (vs available unqualified names). | ||
83 | const extraSegments = entry.proposal.match(/::/g)?.length || 0; | ||
84 | boost = Math.max(-5 * extraSegments, -50); | ||
85 | } | ||
86 | break; | ||
87 | } | ||
88 | return { | ||
89 | label: entry.proposal, | ||
90 | detail: entry.description, | ||
91 | info: entry.documentation, | ||
92 | type: entry.kind?.toLowerCase(), | ||
93 | boost, | ||
94 | }; | ||
95 | } | ||
96 | |||
97 | export class ContentAssistService { | ||
98 | private readonly updateService: UpdateService; | ||
99 | |||
100 | private lastCompletion: CompletionResult | null = null; | ||
101 | |||
102 | constructor(updateService: UpdateService) { | ||
103 | this.updateService = updateService; | ||
104 | } | ||
105 | |||
106 | onTransaction(transaction: Transaction): void { | ||
107 | if (this.shouldInvalidateCachedCompletion(transaction)) { | ||
108 | this.lastCompletion = null; | ||
109 | } | ||
110 | } | ||
111 | |||
112 | async contentAssist(context: CompletionContext): Promise<CompletionResult> { | ||
113 | const tokenBefore = findToken(context); | ||
114 | if (!context.explicit && !shouldCompleteImplicitly(tokenBefore, context)) { | ||
115 | return { | ||
116 | from: context.pos, | ||
117 | options: [], | ||
118 | }; | ||
119 | } | ||
120 | let range: { from: number, to: number }; | ||
121 | let prefix = ''; | ||
122 | if (tokenBefore === null) { | ||
123 | range = { | ||
124 | from: context.pos, | ||
125 | to: context.pos, | ||
126 | }; | ||
127 | prefix = ''; | ||
128 | } else { | ||
129 | range = { | ||
130 | from: tokenBefore.from, | ||
131 | to: tokenBefore.to, | ||
132 | }; | ||
133 | const prefixLength = context.pos - tokenBefore.from; | ||
134 | if (prefixLength > 0) { | ||
135 | prefix = tokenBefore.text.substring(0, context.pos - tokenBefore.from); | ||
136 | } | ||
137 | } | ||
138 | if (!context.explicit && this.shouldReturnCachedCompletion(tokenBefore)) { | ||
139 | log.trace('Returning cached completion result'); | ||
140 | // Postcondition of `shouldReturnCachedCompletion`: `lastCompletion !== null` | ||
141 | return { | ||
142 | ...this.lastCompletion as CompletionResult, | ||
143 | ...range, | ||
144 | }; | ||
145 | } | ||
146 | this.lastCompletion = null; | ||
147 | const entries = await this.updateService.fetchContentAssist({ | ||
148 | resource: this.updateService.resourceName, | ||
149 | serviceType: 'assist', | ||
150 | caretOffset: context.pos, | ||
151 | proposalsLimit: PROPOSALS_LIMIT, | ||
152 | }, context); | ||
153 | if (context.aborted) { | ||
154 | return { | ||
155 | ...range, | ||
156 | options: [], | ||
157 | }; | ||
158 | } | ||
159 | const options: Completion[] = []; | ||
160 | entries.forEach((entry) => { | ||
161 | if (prefix === entry.prefix) { | ||
162 | // Xtext will generate completions that do not complete the current token, | ||
163 | // e.g., `(` after trying to complete an indetifier, | ||
164 | // but we ignore those, since CodeMirror won't filter for them anyways. | ||
165 | options.push(createCompletion(entry)); | ||
166 | } | ||
167 | }); | ||
168 | log.debug('Fetched', options.length, 'completions from server'); | ||
169 | this.lastCompletion = { | ||
170 | ...range, | ||
171 | options, | ||
172 | span: computeSpan(prefix, entries.length), | ||
173 | }; | ||
174 | return this.lastCompletion; | ||
175 | } | ||
176 | |||
177 | private shouldReturnCachedCompletion( | ||
178 | token: { from: number, to: number, text: string } | null, | ||
179 | ): boolean { | ||
180 | if (token === null || this.lastCompletion === null) { | ||
181 | return false; | ||
182 | } | ||
183 | const { from, to, text } = token; | ||
184 | const { from: lastFrom, to: lastTo, span } = this.lastCompletion; | ||
185 | if (!lastTo) { | ||
186 | return true; | ||
187 | } | ||
188 | const [transformedFrom, transformedTo] = this.mapRangeInclusive(lastFrom, lastTo); | ||
189 | return from >= transformedFrom | ||
190 | && to <= transformedTo | ||
191 | && typeof span !== 'undefined' | ||
192 | && span.exec(text) !== null; | ||
193 | } | ||
194 | |||
195 | private shouldInvalidateCachedCompletion(transaction: Transaction): boolean { | ||
196 | if (!transaction.docChanged || this.lastCompletion === null) { | ||
197 | return false; | ||
198 | } | ||
199 | const { from: lastFrom, to: lastTo } = this.lastCompletion; | ||
200 | if (!lastTo) { | ||
201 | return true; | ||
202 | } | ||
203 | const [transformedFrom, transformedTo] = this.mapRangeInclusive(lastFrom, lastTo); | ||
204 | let invalidate = false; | ||
205 | transaction.changes.iterChangedRanges((fromA, toA) => { | ||
206 | if (fromA < transformedFrom || toA > transformedTo) { | ||
207 | invalidate = true; | ||
208 | } | ||
209 | }); | ||
210 | return invalidate; | ||
211 | } | ||
212 | |||
213 | private mapRangeInclusive(lastFrom: number, lastTo: number): [number, number] { | ||
214 | const changes = this.updateService.computeChangesSinceLastUpdate(); | ||
215 | const transformedFrom = changes.mapPos(lastFrom); | ||
216 | const transformedTo = changes.mapPos(lastTo, 1); | ||
217 | return [transformedFrom, transformedTo]; | ||
218 | } | ||
219 | } | ||
diff --git a/subprojects/frontend/src/xtext/HighlightingService.ts b/subprojects/frontend/src/xtext/HighlightingService.ts new file mode 100644 index 00000000..dfbb4a19 --- /dev/null +++ b/subprojects/frontend/src/xtext/HighlightingService.ts | |||
@@ -0,0 +1,37 @@ | |||
1 | import type { EditorStore } from '../editor/EditorStore'; | ||
2 | import type { IHighlightRange } from '../editor/semanticHighlighting'; | ||
3 | import type { UpdateService } from './UpdateService'; | ||
4 | import { highlightingResult } from './xtextServiceResults'; | ||
5 | |||
6 | export class HighlightingService { | ||
7 | private readonly store: EditorStore; | ||
8 | |||
9 | private readonly updateService: UpdateService; | ||
10 | |||
11 | constructor(store: EditorStore, updateService: UpdateService) { | ||
12 | this.store = store; | ||
13 | this.updateService = updateService; | ||
14 | } | ||
15 | |||
16 | onPush(push: unknown): void { | ||
17 | const { regions } = highlightingResult.parse(push); | ||
18 | const allChanges = this.updateService.computeChangesSinceLastUpdate(); | ||
19 | const ranges: IHighlightRange[] = []; | ||
20 | regions.forEach(({ offset, length, styleClasses }) => { | ||
21 | if (styleClasses.length === 0) { | ||
22 | return; | ||
23 | } | ||
24 | const from = allChanges.mapPos(offset); | ||
25 | const to = allChanges.mapPos(offset + length); | ||
26 | if (to <= from) { | ||
27 | return; | ||
28 | } | ||
29 | ranges.push({ | ||
30 | from, | ||
31 | to, | ||
32 | classes: styleClasses, | ||
33 | }); | ||
34 | }); | ||
35 | this.store.updateSemanticHighlighting(ranges); | ||
36 | } | ||
37 | } | ||
diff --git a/subprojects/frontend/src/xtext/OccurrencesService.ts b/subprojects/frontend/src/xtext/OccurrencesService.ts new file mode 100644 index 00000000..bc865537 --- /dev/null +++ b/subprojects/frontend/src/xtext/OccurrencesService.ts | |||
@@ -0,0 +1,127 @@ | |||
1 | import { Transaction } from '@codemirror/state'; | ||
2 | |||
3 | import type { EditorStore } from '../editor/EditorStore'; | ||
4 | import type { IOccurrence } from '../editor/findOccurrences'; | ||
5 | import type { UpdateService } from './UpdateService'; | ||
6 | import { getLogger } from '../utils/logger'; | ||
7 | import { Timer } from '../utils/Timer'; | ||
8 | import { XtextWebSocketClient } from './XtextWebSocketClient'; | ||
9 | import { | ||
10 | isConflictResult, | ||
11 | occurrencesResult, | ||
12 | TextRegion, | ||
13 | } from './xtextServiceResults'; | ||
14 | |||
15 | const FIND_OCCURRENCES_TIMEOUT_MS = 1000; | ||
16 | |||
17 | // Must clear occurrences asynchronously from `onTransaction`, | ||
18 | // because we must not emit a conflicting transaction when handling the pending transaction. | ||
19 | const CLEAR_OCCURRENCES_TIMEOUT_MS = 10; | ||
20 | |||
21 | const log = getLogger('xtext.OccurrencesService'); | ||
22 | |||
23 | function transformOccurrences(regions: TextRegion[]): IOccurrence[] { | ||
24 | const occurrences: IOccurrence[] = []; | ||
25 | regions.forEach(({ offset, length }) => { | ||
26 | if (length > 0) { | ||
27 | occurrences.push({ | ||
28 | from: offset, | ||
29 | to: offset + length, | ||
30 | }); | ||
31 | } | ||
32 | }); | ||
33 | return occurrences; | ||
34 | } | ||
35 | |||
36 | export class OccurrencesService { | ||
37 | private readonly store: EditorStore; | ||
38 | |||
39 | private readonly webSocketClient: XtextWebSocketClient; | ||
40 | |||
41 | private readonly updateService: UpdateService; | ||
42 | |||
43 | private hasOccurrences = false; | ||
44 | |||
45 | private readonly findOccurrencesTimer = new Timer(() => { | ||
46 | this.handleFindOccurrences(); | ||
47 | }, FIND_OCCURRENCES_TIMEOUT_MS); | ||
48 | |||
49 | private readonly clearOccurrencesTimer = new Timer(() => { | ||
50 | this.clearOccurrences(); | ||
51 | }, CLEAR_OCCURRENCES_TIMEOUT_MS); | ||
52 | |||
53 | constructor( | ||
54 | store: EditorStore, | ||
55 | webSocketClient: XtextWebSocketClient, | ||
56 | updateService: UpdateService, | ||
57 | ) { | ||
58 | this.store = store; | ||
59 | this.webSocketClient = webSocketClient; | ||
60 | this.updateService = updateService; | ||
61 | } | ||
62 | |||
63 | onTransaction(transaction: Transaction): void { | ||
64 | if (transaction.docChanged) { | ||
65 | this.clearOccurrencesTimer.schedule(); | ||
66 | this.findOccurrencesTimer.reschedule(); | ||
67 | } | ||
68 | if (transaction.isUserEvent('select')) { | ||
69 | this.findOccurrencesTimer.reschedule(); | ||
70 | } | ||
71 | } | ||
72 | |||
73 | private handleFindOccurrences() { | ||
74 | this.clearOccurrencesTimer.cancel(); | ||
75 | this.updateOccurrences().catch((error) => { | ||
76 | log.error('Unexpected error while updating occurrences', error); | ||
77 | this.clearOccurrences(); | ||
78 | }); | ||
79 | } | ||
80 | |||
81 | private async updateOccurrences() { | ||
82 | await this.updateService.update(); | ||
83 | const result = await this.webSocketClient.send({ | ||
84 | resource: this.updateService.resourceName, | ||
85 | serviceType: 'occurrences', | ||
86 | expectedStateId: this.updateService.xtextStateId, | ||
87 | caretOffset: this.store.state.selection.main.head, | ||
88 | }); | ||
89 | const allChanges = this.updateService.computeChangesSinceLastUpdate(); | ||
90 | if (!allChanges.empty || isConflictResult(result, 'canceled')) { | ||
91 | // Stale occurrences result, the user already made some changes. | ||
92 | // We can safely ignore the occurrences and schedule a new find occurrences call. | ||
93 | this.clearOccurrences(); | ||
94 | this.findOccurrencesTimer.schedule(); | ||
95 | return; | ||
96 | } | ||
97 | const parsedOccurrencesResult = occurrencesResult.safeParse(result); | ||
98 | if (!parsedOccurrencesResult.success) { | ||
99 | log.error( | ||
100 | 'Unexpected occurences result', | ||
101 | result, | ||
102 | 'not an OccurrencesResult: ', | ||
103 | parsedOccurrencesResult.error, | ||
104 | ); | ||
105 | this.clearOccurrences(); | ||
106 | return; | ||
107 | } | ||
108 | const { stateId, writeRegions, readRegions } = parsedOccurrencesResult.data; | ||
109 | if (stateId !== this.updateService.xtextStateId) { | ||
110 | log.error('Unexpected state id, expected:', this.updateService.xtextStateId, 'got:', stateId); | ||
111 | this.clearOccurrences(); | ||
112 | return; | ||
113 | } | ||
114 | const write = transformOccurrences(writeRegions); | ||
115 | const read = transformOccurrences(readRegions); | ||
116 | this.hasOccurrences = write.length > 0 || read.length > 0; | ||
117 | log.debug('Found', write.length, 'write and', read.length, 'read occurrences'); | ||
118 | this.store.updateOccurrences(write, read); | ||
119 | } | ||
120 | |||
121 | private clearOccurrences() { | ||
122 | if (this.hasOccurrences) { | ||
123 | this.store.updateOccurrences([], []); | ||
124 | this.hasOccurrences = false; | ||
125 | } | ||
126 | } | ||
127 | } | ||
diff --git a/subprojects/frontend/src/xtext/UpdateService.ts b/subprojects/frontend/src/xtext/UpdateService.ts new file mode 100644 index 00000000..e78944a9 --- /dev/null +++ b/subprojects/frontend/src/xtext/UpdateService.ts | |||
@@ -0,0 +1,363 @@ | |||
1 | import { | ||
2 | ChangeDesc, | ||
3 | ChangeSet, | ||
4 | ChangeSpec, | ||
5 | StateEffect, | ||
6 | Transaction, | ||
7 | } from '@codemirror/state'; | ||
8 | import { nanoid } from 'nanoid'; | ||
9 | |||
10 | import type { EditorStore } from '../editor/EditorStore'; | ||
11 | import type { XtextWebSocketClient } from './XtextWebSocketClient'; | ||
12 | import { ConditionVariable } from '../utils/ConditionVariable'; | ||
13 | import { getLogger } from '../utils/logger'; | ||
14 | import { Timer } from '../utils/Timer'; | ||
15 | import { | ||
16 | ContentAssistEntry, | ||
17 | contentAssistResult, | ||
18 | documentStateResult, | ||
19 | formattingResult, | ||
20 | isConflictResult, | ||
21 | } from './xtextServiceResults'; | ||
22 | |||
23 | const UPDATE_TIMEOUT_MS = 500; | ||
24 | |||
25 | const WAIT_FOR_UPDATE_TIMEOUT_MS = 1000; | ||
26 | |||
27 | const log = getLogger('xtext.UpdateService'); | ||
28 | |||
29 | const setDirtyChanges = StateEffect.define<ChangeSet>(); | ||
30 | |||
31 | export interface IAbortSignal { | ||
32 | aborted: boolean; | ||
33 | } | ||
34 | |||
35 | export class UpdateService { | ||
36 | resourceName: string; | ||
37 | |||
38 | xtextStateId: string | null = null; | ||
39 | |||
40 | private readonly store: EditorStore; | ||
41 | |||
42 | /** | ||
43 | * The changes being synchronized to the server if a full or delta text update is running, | ||
44 | * `null` otherwise. | ||
45 | */ | ||
46 | private pendingUpdate: ChangeSet | null = null; | ||
47 | |||
48 | /** | ||
49 | * Local changes not yet sychronized to the server and not part of the running update, if any. | ||
50 | */ | ||
51 | private dirtyChanges: ChangeSet; | ||
52 | |||
53 | private readonly webSocketClient: XtextWebSocketClient; | ||
54 | |||
55 | private readonly updatedCondition = new ConditionVariable( | ||
56 | () => this.pendingUpdate === null && this.xtextStateId !== null, | ||
57 | WAIT_FOR_UPDATE_TIMEOUT_MS, | ||
58 | ); | ||
59 | |||
60 | private readonly idleUpdateTimer = new Timer(() => { | ||
61 | this.handleIdleUpdate(); | ||
62 | }, UPDATE_TIMEOUT_MS); | ||
63 | |||
64 | constructor(store: EditorStore, webSocketClient: XtextWebSocketClient) { | ||
65 | this.resourceName = `${nanoid(7)}.problem`; | ||
66 | this.store = store; | ||
67 | this.dirtyChanges = this.newEmptyChangeSet(); | ||
68 | this.webSocketClient = webSocketClient; | ||
69 | } | ||
70 | |||
71 | onReconnect(): void { | ||
72 | this.xtextStateId = null; | ||
73 | this.updateFullText().catch((error) => { | ||
74 | log.error('Unexpected error during initial update', error); | ||
75 | }); | ||
76 | } | ||
77 | |||
78 | onTransaction(transaction: Transaction): void { | ||
79 | const setDirtyChangesEffect = transaction.effects.find( | ||
80 | (effect) => effect.is(setDirtyChanges), | ||
81 | ) as StateEffect<ChangeSet> | undefined; | ||
82 | if (setDirtyChangesEffect) { | ||
83 | const { value } = setDirtyChangesEffect; | ||
84 | if (this.pendingUpdate !== null) { | ||
85 | this.pendingUpdate = ChangeSet.empty(value.length); | ||
86 | } | ||
87 | this.dirtyChanges = value; | ||
88 | return; | ||
89 | } | ||
90 | if (transaction.docChanged) { | ||
91 | this.dirtyChanges = this.dirtyChanges.compose(transaction.changes); | ||
92 | this.idleUpdateTimer.reschedule(); | ||
93 | } | ||
94 | } | ||
95 | |||
96 | /** | ||
97 | * Computes the summary of any changes happened since the last complete update. | ||
98 | * | ||
99 | * The result reflects any changes that happened since the `xtextStateId` | ||
100 | * version was uploaded to the server. | ||
101 | * | ||
102 | * @return the summary of changes since the last update | ||
103 | */ | ||
104 | computeChangesSinceLastUpdate(): ChangeDesc { | ||
105 | return this.pendingUpdate?.composeDesc(this.dirtyChanges.desc) || this.dirtyChanges.desc; | ||
106 | } | ||
107 | |||
108 | private handleIdleUpdate() { | ||
109 | if (!this.webSocketClient.isOpen || this.dirtyChanges.empty) { | ||
110 | return; | ||
111 | } | ||
112 | if (this.pendingUpdate === null) { | ||
113 | this.update().catch((error) => { | ||
114 | log.error('Unexpected error during scheduled update', error); | ||
115 | }); | ||
116 | } | ||
117 | this.idleUpdateTimer.reschedule(); | ||
118 | } | ||
119 | |||
120 | private newEmptyChangeSet() { | ||
121 | return ChangeSet.of([], this.store.state.doc.length); | ||
122 | } | ||
123 | |||
124 | async updateFullText(): Promise<void> { | ||
125 | await this.withUpdate(() => this.doUpdateFullText()); | ||
126 | } | ||
127 | |||
128 | private async doUpdateFullText(): Promise<[string, void]> { | ||
129 | const result = await this.webSocketClient.send({ | ||
130 | resource: this.resourceName, | ||
131 | serviceType: 'update', | ||
132 | fullText: this.store.state.doc.sliceString(0), | ||
133 | }); | ||
134 | const { stateId } = documentStateResult.parse(result); | ||
135 | return [stateId, undefined]; | ||
136 | } | ||
137 | |||
138 | /** | ||
139 | * Makes sure that the document state on the server reflects recent | ||
140 | * local changes. | ||
141 | * | ||
142 | * Performs either an update with delta text or a full text update if needed. | ||
143 | * If there are not local dirty changes, the promise resolves immediately. | ||
144 | * | ||
145 | * @return a promise resolving when the update is completed | ||
146 | */ | ||
147 | async update(): Promise<void> { | ||
148 | await this.prepareForDeltaUpdate(); | ||
149 | const delta = this.computeDelta(); | ||
150 | if (delta === null) { | ||
151 | return; | ||
152 | } | ||
153 | log.trace('Editor delta', delta); | ||
154 | await this.withUpdate(async () => { | ||
155 | const result = await this.webSocketClient.send({ | ||
156 | resource: this.resourceName, | ||
157 | serviceType: 'update', | ||
158 | requiredStateId: this.xtextStateId, | ||
159 | ...delta, | ||
160 | }); | ||
161 | const parsedDocumentStateResult = documentStateResult.safeParse(result); | ||
162 | if (parsedDocumentStateResult.success) { | ||
163 | return [parsedDocumentStateResult.data.stateId, undefined]; | ||
164 | } | ||
165 | if (isConflictResult(result, 'invalidStateId')) { | ||
166 | return this.doFallbackToUpdateFullText(); | ||
167 | } | ||
168 | throw parsedDocumentStateResult.error; | ||
169 | }); | ||
170 | } | ||
171 | |||
172 | private doFallbackToUpdateFullText() { | ||
173 | if (this.pendingUpdate === null) { | ||
174 | throw new Error('Only a pending update can be extended'); | ||
175 | } | ||
176 | log.warn('Delta update failed, performing full text update'); | ||
177 | this.xtextStateId = null; | ||
178 | this.pendingUpdate = this.pendingUpdate.compose(this.dirtyChanges); | ||
179 | this.dirtyChanges = this.newEmptyChangeSet(); | ||
180 | return this.doUpdateFullText(); | ||
181 | } | ||
182 | |||
183 | async fetchContentAssist( | ||
184 | params: Record<string, unknown>, | ||
185 | signal: IAbortSignal, | ||
186 | ): Promise<ContentAssistEntry[]> { | ||
187 | await this.prepareForDeltaUpdate(); | ||
188 | if (signal.aborted) { | ||
189 | return []; | ||
190 | } | ||
191 | const delta = this.computeDelta(); | ||
192 | if (delta !== null) { | ||
193 | log.trace('Editor delta', delta); | ||
194 | const entries = await this.withUpdate(async () => { | ||
195 | const result = await this.webSocketClient.send({ | ||
196 | ...params, | ||
197 | requiredStateId: this.xtextStateId, | ||
198 | ...delta, | ||
199 | }); | ||
200 | const parsedContentAssistResult = contentAssistResult.safeParse(result); | ||
201 | if (parsedContentAssistResult.success) { | ||
202 | const { stateId, entries: resultEntries } = parsedContentAssistResult.data; | ||
203 | return [stateId, resultEntries]; | ||
204 | } | ||
205 | if (isConflictResult(result, 'invalidStateId')) { | ||
206 | log.warn('Server state invalid during content assist'); | ||
207 | const [newStateId] = await this.doFallbackToUpdateFullText(); | ||
208 | // We must finish this state update transaction to prepare for any push events | ||
209 | // before querying for content assist, so we just return `null` and will query | ||
210 | // the content assist service later. | ||
211 | return [newStateId, null]; | ||
212 | } | ||
213 | throw parsedContentAssistResult.error; | ||
214 | }); | ||
215 | if (entries !== null) { | ||
216 | return entries; | ||
217 | } | ||
218 | if (signal.aborted) { | ||
219 | return []; | ||
220 | } | ||
221 | } | ||
222 | // Poscondition of `prepareForDeltaUpdate`: `xtextStateId !== null` | ||
223 | return this.doFetchContentAssist(params, this.xtextStateId as string); | ||
224 | } | ||
225 | |||
226 | private async doFetchContentAssist(params: Record<string, unknown>, expectedStateId: string) { | ||
227 | const result = await this.webSocketClient.send({ | ||
228 | ...params, | ||
229 | requiredStateId: expectedStateId, | ||
230 | }); | ||
231 | const { stateId, entries } = contentAssistResult.parse(result); | ||
232 | if (stateId !== expectedStateId) { | ||
233 | throw new Error(`Unexpected state id, expected: ${expectedStateId} got: ${stateId}`); | ||
234 | } | ||
235 | return entries; | ||
236 | } | ||
237 | |||
238 | async formatText(): Promise<void> { | ||
239 | await this.update(); | ||
240 | let { from, to } = this.store.state.selection.main; | ||
241 | if (to <= from) { | ||
242 | from = 0; | ||
243 | to = this.store.state.doc.length; | ||
244 | } | ||
245 | log.debug('Formatting from', from, 'to', to); | ||
246 | await this.withUpdate(async () => { | ||
247 | const result = await this.webSocketClient.send({ | ||
248 | resource: this.resourceName, | ||
249 | serviceType: 'format', | ||
250 | selectionStart: from, | ||
251 | selectionEnd: to, | ||
252 | }); | ||
253 | const { stateId, formattedText } = formattingResult.parse(result); | ||
254 | this.applyBeforeDirtyChanges({ | ||
255 | from, | ||
256 | to, | ||
257 | insert: formattedText, | ||
258 | }); | ||
259 | return [stateId, null]; | ||
260 | }); | ||
261 | } | ||
262 | |||
263 | private computeDelta() { | ||
264 | if (this.dirtyChanges.empty) { | ||
265 | return null; | ||
266 | } | ||
267 | let minFromA = Number.MAX_SAFE_INTEGER; | ||
268 | let maxToA = 0; | ||
269 | let minFromB = Number.MAX_SAFE_INTEGER; | ||
270 | let maxToB = 0; | ||
271 | this.dirtyChanges.iterChangedRanges((fromA, toA, fromB, toB) => { | ||
272 | minFromA = Math.min(minFromA, fromA); | ||
273 | maxToA = Math.max(maxToA, toA); | ||
274 | minFromB = Math.min(minFromB, fromB); | ||
275 | maxToB = Math.max(maxToB, toB); | ||
276 | }); | ||
277 | return { | ||
278 | deltaOffset: minFromA, | ||
279 | deltaReplaceLength: maxToA - minFromA, | ||
280 | deltaText: this.store.state.doc.sliceString(minFromB, maxToB), | ||
281 | }; | ||
282 | } | ||
283 | |||
284 | private applyBeforeDirtyChanges(changeSpec: ChangeSpec) { | ||
285 | const pendingChanges = this.pendingUpdate?.compose(this.dirtyChanges) || this.dirtyChanges; | ||
286 | const revertChanges = pendingChanges.invert(this.store.state.doc); | ||
287 | const applyBefore = ChangeSet.of(changeSpec, revertChanges.newLength); | ||
288 | const redoChanges = pendingChanges.map(applyBefore.desc); | ||
289 | const changeSet = revertChanges.compose(applyBefore).compose(redoChanges); | ||
290 | this.store.dispatch({ | ||
291 | changes: changeSet, | ||
292 | effects: [ | ||
293 | setDirtyChanges.of(redoChanges), | ||
294 | ], | ||
295 | }); | ||
296 | } | ||
297 | |||
298 | /** | ||
299 | * Executes an asynchronous callback that updates the state on the server. | ||
300 | * | ||
301 | * Ensures that updates happen sequentially and manages `pendingUpdate` | ||
302 | * and `dirtyChanges` to reflect changes being synchronized to the server | ||
303 | * and not yet synchronized to the server, respectively. | ||
304 | * | ||
305 | * Optionally, `callback` may return a second value that is retured by this function. | ||
306 | * | ||
307 | * Once the remote procedure call to update the server state finishes | ||
308 | * and returns the new `stateId`, `callback` must return _immediately_ | ||
309 | * to ensure that the local `stateId` is updated likewise to be able to handle | ||
310 | * push messages referring to the new `stateId` from the server. | ||
311 | * If additional work is needed to compute the second value in some cases, | ||
312 | * use `T | null` instead of `T` as a return type and signal the need for additional | ||
313 | * computations by returning `null`. Thus additional computations can be performed | ||
314 | * outside of the critical section. | ||
315 | * | ||
316 | * @param callback the asynchronous callback that updates the server state | ||
317 | * @return a promise resolving to the second value returned by `callback` | ||
318 | */ | ||
319 | private async withUpdate<T>(callback: () => Promise<[string, T]>): Promise<T> { | ||
320 | if (this.pendingUpdate !== null) { | ||
321 | throw new Error('Another update is pending, will not perform update'); | ||
322 | } | ||
323 | this.pendingUpdate = this.dirtyChanges; | ||
324 | this.dirtyChanges = this.newEmptyChangeSet(); | ||
325 | let newStateId: string | null = null; | ||
326 | try { | ||
327 | let result: T; | ||
328 | [newStateId, result] = await callback(); | ||
329 | this.xtextStateId = newStateId; | ||
330 | this.pendingUpdate = null; | ||
331 | this.updatedCondition.notifyAll(); | ||
332 | return result; | ||
333 | } catch (e) { | ||
334 | log.error('Error while update', e); | ||
335 | if (this.pendingUpdate === null) { | ||
336 | log.error('pendingUpdate was cleared during update'); | ||
337 | } else { | ||
338 | this.dirtyChanges = this.pendingUpdate.compose(this.dirtyChanges); | ||
339 | } | ||
340 | this.pendingUpdate = null; | ||
341 | this.webSocketClient.forceReconnectOnError(); | ||
342 | this.updatedCondition.rejectAll(e); | ||
343 | throw e; | ||
344 | } | ||
345 | } | ||
346 | |||
347 | /** | ||
348 | * Ensures that there is some state available on the server (`xtextStateId`) | ||
349 | * and that there is not pending update. | ||
350 | * | ||
351 | * After this function resolves, a delta text update is possible. | ||
352 | * | ||
353 | * @return a promise resolving when there is a valid state id but no pending update | ||
354 | */ | ||
355 | private async prepareForDeltaUpdate() { | ||
356 | // If no update is pending, but the full text hasn't been uploaded to the server yet, | ||
357 | // we must start a full text upload. | ||
358 | if (this.pendingUpdate === null && this.xtextStateId === null) { | ||
359 | await this.updateFullText(); | ||
360 | } | ||
361 | await this.updatedCondition.waitFor(); | ||
362 | } | ||
363 | } | ||
diff --git a/subprojects/frontend/src/xtext/ValidationService.ts b/subprojects/frontend/src/xtext/ValidationService.ts new file mode 100644 index 00000000..ff7d3700 --- /dev/null +++ b/subprojects/frontend/src/xtext/ValidationService.ts | |||
@@ -0,0 +1,39 @@ | |||
1 | import type { Diagnostic } from '@codemirror/lint'; | ||
2 | |||
3 | import type { EditorStore } from '../editor/EditorStore'; | ||
4 | import type { UpdateService } from './UpdateService'; | ||
5 | import { validationResult } from './xtextServiceResults'; | ||
6 | |||
7 | export class ValidationService { | ||
8 | private readonly store: EditorStore; | ||
9 | |||
10 | private readonly updateService: UpdateService; | ||
11 | |||
12 | constructor(store: EditorStore, updateService: UpdateService) { | ||
13 | this.store = store; | ||
14 | this.updateService = updateService; | ||
15 | } | ||
16 | |||
17 | onPush(push: unknown): void { | ||
18 | const { issues } = validationResult.parse(push); | ||
19 | const allChanges = this.updateService.computeChangesSinceLastUpdate(); | ||
20 | const diagnostics: Diagnostic[] = []; | ||
21 | issues.forEach(({ | ||
22 | offset, | ||
23 | length, | ||
24 | severity, | ||
25 | description, | ||
26 | }) => { | ||
27 | if (severity === 'ignore') { | ||
28 | return; | ||
29 | } | ||
30 | diagnostics.push({ | ||
31 | from: allChanges.mapPos(offset), | ||
32 | to: allChanges.mapPos(offset + length), | ||
33 | severity, | ||
34 | message: description, | ||
35 | }); | ||
36 | }); | ||
37 | this.store.updateDiagnostics(diagnostics); | ||
38 | } | ||
39 | } | ||
diff --git a/subprojects/frontend/src/xtext/XtextClient.ts b/subprojects/frontend/src/xtext/XtextClient.ts new file mode 100644 index 00000000..0898e725 --- /dev/null +++ b/subprojects/frontend/src/xtext/XtextClient.ts | |||
@@ -0,0 +1,86 @@ | |||
1 | import type { | ||
2 | CompletionContext, | ||
3 | CompletionResult, | ||
4 | } from '@codemirror/autocomplete'; | ||
5 | import type { Transaction } from '@codemirror/state'; | ||
6 | |||
7 | import type { EditorStore } from '../editor/EditorStore'; | ||
8 | import { ContentAssistService } from './ContentAssistService'; | ||
9 | import { HighlightingService } from './HighlightingService'; | ||
10 | import { OccurrencesService } from './OccurrencesService'; | ||
11 | import { UpdateService } from './UpdateService'; | ||
12 | import { getLogger } from '../utils/logger'; | ||
13 | import { ValidationService } from './ValidationService'; | ||
14 | import { XtextWebSocketClient } from './XtextWebSocketClient'; | ||
15 | import { XtextWebPushService } from './xtextMessages'; | ||
16 | |||
17 | const log = getLogger('xtext.XtextClient'); | ||
18 | |||
19 | export class XtextClient { | ||
20 | private readonly webSocketClient: XtextWebSocketClient; | ||
21 | |||
22 | private readonly updateService: UpdateService; | ||
23 | |||
24 | private readonly contentAssistService: ContentAssistService; | ||
25 | |||
26 | private readonly highlightingService: HighlightingService; | ||
27 | |||
28 | private readonly validationService: ValidationService; | ||
29 | |||
30 | private readonly occurrencesService: OccurrencesService; | ||
31 | |||
32 | constructor(store: EditorStore) { | ||
33 | this.webSocketClient = new XtextWebSocketClient( | ||
34 | () => this.updateService.onReconnect(), | ||
35 | (resource, stateId, service, push) => this.onPush(resource, stateId, service, push), | ||
36 | ); | ||
37 | this.updateService = new UpdateService(store, this.webSocketClient); | ||
38 | this.contentAssistService = new ContentAssistService(this.updateService); | ||
39 | this.highlightingService = new HighlightingService(store, this.updateService); | ||
40 | this.validationService = new ValidationService(store, this.updateService); | ||
41 | this.occurrencesService = new OccurrencesService( | ||
42 | store, | ||
43 | this.webSocketClient, | ||
44 | this.updateService, | ||
45 | ); | ||
46 | } | ||
47 | |||
48 | onTransaction(transaction: Transaction): void { | ||
49 | // `ContentAssistService.prototype.onTransaction` needs the dirty change desc | ||
50 | // _before_ the current edit, so we call it before `updateService`. | ||
51 | this.contentAssistService.onTransaction(transaction); | ||
52 | this.updateService.onTransaction(transaction); | ||
53 | this.occurrencesService.onTransaction(transaction); | ||
54 | } | ||
55 | |||
56 | private onPush(resource: string, stateId: string, service: XtextWebPushService, push: unknown) { | ||
57 | const { resourceName, xtextStateId } = this.updateService; | ||
58 | if (resource !== resourceName) { | ||
59 | log.error('Unknown resource name: expected:', resourceName, 'got:', resource); | ||
60 | return; | ||
61 | } | ||
62 | if (stateId !== xtextStateId) { | ||
63 | log.error('Unexpected xtext state id: expected:', xtextStateId, 'got:', stateId); | ||
64 | // The current push message might be stale (referring to a previous state), | ||
65 | // so this is not neccessarily an error and there is no need to force-reconnect. | ||
66 | return; | ||
67 | } | ||
68 | switch (service) { | ||
69 | case 'highlight': | ||
70 | this.highlightingService.onPush(push); | ||
71 | return; | ||
72 | case 'validate': | ||
73 | this.validationService.onPush(push); | ||
74 | } | ||
75 | } | ||
76 | |||
77 | contentAssist(context: CompletionContext): Promise<CompletionResult> { | ||
78 | return this.contentAssistService.contentAssist(context); | ||
79 | } | ||
80 | |||
81 | formatText(): void { | ||
82 | this.updateService.formatText().catch((e) => { | ||
83 | log.error('Error while formatting text', e); | ||
84 | }); | ||
85 | } | ||
86 | } | ||
diff --git a/subprojects/frontend/src/xtext/XtextWebSocketClient.ts b/subprojects/frontend/src/xtext/XtextWebSocketClient.ts new file mode 100644 index 00000000..2ce20a54 --- /dev/null +++ b/subprojects/frontend/src/xtext/XtextWebSocketClient.ts | |||
@@ -0,0 +1,362 @@ | |||
1 | import { nanoid } from 'nanoid'; | ||
2 | |||
3 | import { getLogger } from '../utils/logger'; | ||
4 | import { PendingTask } from '../utils/PendingTask'; | ||
5 | import { Timer } from '../utils/Timer'; | ||
6 | import { | ||
7 | xtextWebErrorResponse, | ||
8 | XtextWebRequest, | ||
9 | xtextWebOkResponse, | ||
10 | xtextWebPushMessage, | ||
11 | XtextWebPushService, | ||
12 | } from './xtextMessages'; | ||
13 | import { pongResult } from './xtextServiceResults'; | ||
14 | |||
15 | const XTEXT_SUBPROTOCOL_V1 = 'tools.refinery.language.web.xtext.v1'; | ||
16 | |||
17 | const WEBSOCKET_CLOSE_OK = 1000; | ||
18 | |||
19 | const RECONNECT_DELAY_MS = [200, 1000, 5000, 30_000]; | ||
20 | |||
21 | const MAX_RECONNECT_DELAY_MS = RECONNECT_DELAY_MS[RECONNECT_DELAY_MS.length - 1]; | ||
22 | |||
23 | const BACKGROUND_IDLE_TIMEOUT_MS = 5 * 60 * 1000; | ||
24 | |||
25 | const PING_TIMEOUT_MS = 10 * 1000; | ||
26 | |||
27 | const REQUEST_TIMEOUT_MS = 1000; | ||
28 | |||
29 | const log = getLogger('xtext.XtextWebSocketClient'); | ||
30 | |||
31 | export type ReconnectHandler = () => void; | ||
32 | |||
33 | export type PushHandler = ( | ||
34 | resourceId: string, | ||
35 | stateId: string, | ||
36 | service: XtextWebPushService, | ||
37 | data: unknown, | ||
38 | ) => void; | ||
39 | |||
40 | enum State { | ||
41 | Initial, | ||
42 | Opening, | ||
43 | TabVisible, | ||
44 | TabHiddenIdle, | ||
45 | TabHiddenWaiting, | ||
46 | Error, | ||
47 | TimedOut, | ||
48 | } | ||
49 | |||
50 | export class XtextWebSocketClient { | ||
51 | private nextMessageId = 0; | ||
52 | |||
53 | private connection!: WebSocket; | ||
54 | |||
55 | private readonly pendingRequests = new Map<string, PendingTask<unknown>>(); | ||
56 | |||
57 | private readonly onReconnect: ReconnectHandler; | ||
58 | |||
59 | private readonly onPush: PushHandler; | ||
60 | |||
61 | private state = State.Initial; | ||
62 | |||
63 | private reconnectTryCount = 0; | ||
64 | |||
65 | private readonly idleTimer = new Timer(() => { | ||
66 | this.handleIdleTimeout(); | ||
67 | }, BACKGROUND_IDLE_TIMEOUT_MS); | ||
68 | |||
69 | private readonly pingTimer = new Timer(() => { | ||
70 | this.sendPing(); | ||
71 | }, PING_TIMEOUT_MS); | ||
72 | |||
73 | private readonly reconnectTimer = new Timer(() => { | ||
74 | this.handleReconnect(); | ||
75 | }); | ||
76 | |||
77 | constructor(onReconnect: ReconnectHandler, onPush: PushHandler) { | ||
78 | this.onReconnect = onReconnect; | ||
79 | this.onPush = onPush; | ||
80 | document.addEventListener('visibilitychange', () => { | ||
81 | this.handleVisibilityChange(); | ||
82 | }); | ||
83 | this.reconnect(); | ||
84 | } | ||
85 | |||
86 | private get isLogicallyClosed(): boolean { | ||
87 | return this.state === State.Error || this.state === State.TimedOut; | ||
88 | } | ||
89 | |||
90 | get isOpen(): boolean { | ||
91 | return this.state === State.TabVisible | ||
92 | || this.state === State.TabHiddenIdle | ||
93 | || this.state === State.TabHiddenWaiting; | ||
94 | } | ||
95 | |||
96 | private reconnect() { | ||
97 | if (this.isOpen || this.state === State.Opening) { | ||
98 | log.error('Trying to reconnect from', this.state); | ||
99 | return; | ||
100 | } | ||
101 | this.state = State.Opening; | ||
102 | const webSocketServer = window.origin.replace(/^http/, 'ws'); | ||
103 | const webSocketUrl = `${webSocketServer}/xtext-service`; | ||
104 | this.connection = new WebSocket(webSocketUrl, XTEXT_SUBPROTOCOL_V1); | ||
105 | this.connection.addEventListener('open', () => { | ||
106 | if (this.connection.protocol !== XTEXT_SUBPROTOCOL_V1) { | ||
107 | log.error('Unknown subprotocol', this.connection.protocol, 'selected by server'); | ||
108 | this.forceReconnectOnError(); | ||
109 | } | ||
110 | if (document.visibilityState === 'hidden') { | ||
111 | this.handleTabHidden(); | ||
112 | } else { | ||
113 | this.handleTabVisibleConnected(); | ||
114 | } | ||
115 | log.info('Connected to websocket'); | ||
116 | this.nextMessageId = 0; | ||
117 | this.reconnectTryCount = 0; | ||
118 | this.pingTimer.schedule(); | ||
119 | this.onReconnect(); | ||
120 | }); | ||
121 | this.connection.addEventListener('error', (event) => { | ||
122 | log.error('Unexpected websocket error', event); | ||
123 | this.forceReconnectOnError(); | ||
124 | }); | ||
125 | this.connection.addEventListener('message', (event) => { | ||
126 | this.handleMessage(event.data); | ||
127 | }); | ||
128 | this.connection.addEventListener('close', (event) => { | ||
129 | if (this.isLogicallyClosed && event.code === WEBSOCKET_CLOSE_OK | ||
130 | && this.pendingRequests.size === 0) { | ||
131 | log.info('Websocket closed'); | ||
132 | return; | ||
133 | } | ||
134 | log.error('Websocket closed unexpectedly', event.code, event.reason); | ||
135 | this.forceReconnectOnError(); | ||
136 | }); | ||
137 | } | ||
138 | |||
139 | private handleVisibilityChange() { | ||
140 | if (document.visibilityState === 'hidden') { | ||
141 | if (this.state === State.TabVisible) { | ||
142 | this.handleTabHidden(); | ||
143 | } | ||
144 | return; | ||
145 | } | ||
146 | this.idleTimer.cancel(); | ||
147 | if (this.state === State.TabHiddenIdle || this.state === State.TabHiddenWaiting) { | ||
148 | this.handleTabVisibleConnected(); | ||
149 | return; | ||
150 | } | ||
151 | if (this.state === State.TimedOut) { | ||
152 | this.reconnect(); | ||
153 | } | ||
154 | } | ||
155 | |||
156 | private handleTabHidden() { | ||
157 | log.debug('Tab hidden while websocket is connected'); | ||
158 | this.state = State.TabHiddenIdle; | ||
159 | this.idleTimer.schedule(); | ||
160 | } | ||
161 | |||
162 | private handleTabVisibleConnected() { | ||
163 | log.debug('Tab visible while websocket is connected'); | ||
164 | this.state = State.TabVisible; | ||
165 | } | ||
166 | |||
167 | private handleIdleTimeout() { | ||
168 | log.trace('Waiting for pending tasks before disconnect'); | ||
169 | if (this.state === State.TabHiddenIdle) { | ||
170 | this.state = State.TabHiddenWaiting; | ||
171 | this.handleWaitingForDisconnect(); | ||
172 | } | ||
173 | } | ||
174 | |||
175 | private handleWaitingForDisconnect() { | ||
176 | if (this.state !== State.TabHiddenWaiting) { | ||
177 | return; | ||
178 | } | ||
179 | const pending = this.pendingRequests.size; | ||
180 | if (pending === 0) { | ||
181 | log.info('Closing idle websocket'); | ||
182 | this.state = State.TimedOut; | ||
183 | this.closeConnection(1000, 'idle timeout'); | ||
184 | return; | ||
185 | } | ||
186 | log.info('Waiting for', pending, 'pending requests before closing websocket'); | ||
187 | } | ||
188 | |||
189 | private sendPing() { | ||
190 | if (!this.isOpen) { | ||
191 | return; | ||
192 | } | ||
193 | const ping = nanoid(); | ||
194 | log.trace('Ping', ping); | ||
195 | this.send({ ping }).then((result) => { | ||
196 | const parsedPongResult = pongResult.safeParse(result); | ||
197 | if (parsedPongResult.success && parsedPongResult.data.pong === ping) { | ||
198 | log.trace('Pong', ping); | ||
199 | this.pingTimer.schedule(); | ||
200 | } else { | ||
201 | log.error('Invalid pong:', parsedPongResult, 'expected:', ping); | ||
202 | this.forceReconnectOnError(); | ||
203 | } | ||
204 | }).catch((error) => { | ||
205 | log.error('Error while waiting for ping', error); | ||
206 | this.forceReconnectOnError(); | ||
207 | }); | ||
208 | } | ||
209 | |||
210 | send(request: unknown): Promise<unknown> { | ||
211 | if (!this.isOpen) { | ||
212 | throw new Error('Not open'); | ||
213 | } | ||
214 | const messageId = this.nextMessageId.toString(16); | ||
215 | if (messageId in this.pendingRequests) { | ||
216 | log.error('Message id wraparound still pending', messageId); | ||
217 | this.rejectRequest(messageId, new Error('Message id wraparound')); | ||
218 | } | ||
219 | if (this.nextMessageId >= Number.MAX_SAFE_INTEGER) { | ||
220 | this.nextMessageId = 0; | ||
221 | } else { | ||
222 | this.nextMessageId += 1; | ||
223 | } | ||
224 | const message = JSON.stringify({ | ||
225 | id: messageId, | ||
226 | request, | ||
227 | } as XtextWebRequest); | ||
228 | log.trace('Sending message', message); | ||
229 | return new Promise((resolve, reject) => { | ||
230 | const task = new PendingTask(resolve, reject, REQUEST_TIMEOUT_MS, () => { | ||
231 | this.removePendingRequest(messageId); | ||
232 | }); | ||
233 | this.pendingRequests.set(messageId, task); | ||
234 | this.connection.send(message); | ||
235 | }); | ||
236 | } | ||
237 | |||
238 | private handleMessage(messageStr: unknown) { | ||
239 | if (typeof messageStr !== 'string') { | ||
240 | log.error('Unexpected binary message', messageStr); | ||
241 | this.forceReconnectOnError(); | ||
242 | return; | ||
243 | } | ||
244 | log.trace('Incoming websocket message', messageStr); | ||
245 | let message: unknown; | ||
246 | try { | ||
247 | message = JSON.parse(messageStr); | ||
248 | } catch (error) { | ||
249 | log.error('Json parse error', error); | ||
250 | this.forceReconnectOnError(); | ||
251 | return; | ||
252 | } | ||
253 | const okResponse = xtextWebOkResponse.safeParse(message); | ||
254 | if (okResponse.success) { | ||
255 | const { id, response } = okResponse.data; | ||
256 | this.resolveRequest(id, response); | ||
257 | return; | ||
258 | } | ||
259 | const errorResponse = xtextWebErrorResponse.safeParse(message); | ||
260 | if (errorResponse.success) { | ||
261 | const { id, error, message: errorMessage } = errorResponse.data; | ||
262 | this.rejectRequest(id, new Error(`${error} error: ${errorMessage}`)); | ||
263 | if (error === 'server') { | ||
264 | log.error('Reconnecting due to server error: ', errorMessage); | ||
265 | this.forceReconnectOnError(); | ||
266 | } | ||
267 | return; | ||
268 | } | ||
269 | const pushMessage = xtextWebPushMessage.safeParse(message); | ||
270 | if (pushMessage.success) { | ||
271 | const { | ||
272 | resource, | ||
273 | stateId, | ||
274 | service, | ||
275 | push, | ||
276 | } = pushMessage.data; | ||
277 | this.onPush(resource, stateId, service, push); | ||
278 | } else { | ||
279 | log.error( | ||
280 | 'Unexpected websocket message:', | ||
281 | message, | ||
282 | 'not ok response because:', | ||
283 | okResponse.error, | ||
284 | 'not error response because:', | ||
285 | errorResponse.error, | ||
286 | 'not push message because:', | ||
287 | pushMessage.error, | ||
288 | ); | ||
289 | this.forceReconnectOnError(); | ||
290 | } | ||
291 | } | ||
292 | |||
293 | private resolveRequest(messageId: string, value: unknown) { | ||
294 | const pendingRequest = this.pendingRequests.get(messageId); | ||
295 | if (pendingRequest) { | ||
296 | pendingRequest.resolve(value); | ||
297 | this.removePendingRequest(messageId); | ||
298 | return; | ||
299 | } | ||
300 | log.error('Trying to resolve unknown request', messageId, 'with', value); | ||
301 | } | ||
302 | |||
303 | private rejectRequest(messageId: string, reason?: unknown) { | ||
304 | const pendingRequest = this.pendingRequests.get(messageId); | ||
305 | if (pendingRequest) { | ||
306 | pendingRequest.reject(reason); | ||
307 | this.removePendingRequest(messageId); | ||
308 | return; | ||
309 | } | ||
310 | log.error('Trying to reject unknown request', messageId, 'with', reason); | ||
311 | } | ||
312 | |||
313 | private removePendingRequest(messageId: string) { | ||
314 | this.pendingRequests.delete(messageId); | ||
315 | this.handleWaitingForDisconnect(); | ||
316 | } | ||
317 | |||
318 | forceReconnectOnError(): void { | ||
319 | if (this.isLogicallyClosed) { | ||
320 | return; | ||
321 | } | ||
322 | this.abortPendingRequests(); | ||
323 | this.closeConnection(1000, 'reconnecting due to error'); | ||
324 | log.error('Reconnecting after delay due to error'); | ||
325 | this.handleErrorState(); | ||
326 | } | ||
327 | |||
328 | private abortPendingRequests() { | ||
329 | this.pendingRequests.forEach((request) => { | ||
330 | request.reject(new Error('Websocket disconnect')); | ||
331 | }); | ||
332 | this.pendingRequests.clear(); | ||
333 | } | ||
334 | |||
335 | private closeConnection(code: number, reason: string) { | ||
336 | this.pingTimer.cancel(); | ||
337 | const { readyState } = this.connection; | ||
338 | if (readyState !== WebSocket.CLOSING && readyState !== WebSocket.CLOSED) { | ||
339 | this.connection.close(code, reason); | ||
340 | } | ||
341 | } | ||
342 | |||
343 | private handleErrorState() { | ||
344 | this.state = State.Error; | ||
345 | this.reconnectTryCount += 1; | ||
346 | const delay = RECONNECT_DELAY_MS[this.reconnectTryCount - 1] || MAX_RECONNECT_DELAY_MS; | ||
347 | log.info('Reconnecting in', delay, 'ms'); | ||
348 | this.reconnectTimer.schedule(delay); | ||
349 | } | ||
350 | |||
351 | private handleReconnect() { | ||
352 | if (this.state !== State.Error) { | ||
353 | log.error('Unexpected reconnect in', this.state); | ||
354 | return; | ||
355 | } | ||
356 | if (document.visibilityState === 'hidden') { | ||
357 | this.state = State.TimedOut; | ||
358 | } else { | ||
359 | this.reconnect(); | ||
360 | } | ||
361 | } | ||
362 | } | ||
diff --git a/subprojects/frontend/src/xtext/xtextMessages.ts b/subprojects/frontend/src/xtext/xtextMessages.ts new file mode 100644 index 00000000..c4305fcf --- /dev/null +++ b/subprojects/frontend/src/xtext/xtextMessages.ts | |||
@@ -0,0 +1,40 @@ | |||
1 | import { z } from 'zod'; | ||
2 | |||
3 | export const xtextWebRequest = z.object({ | ||
4 | id: z.string().nonempty(), | ||
5 | request: z.unknown(), | ||
6 | }); | ||
7 | |||
8 | export type XtextWebRequest = z.infer<typeof xtextWebRequest>; | ||
9 | |||
10 | export const xtextWebOkResponse = z.object({ | ||
11 | id: z.string().nonempty(), | ||
12 | response: z.unknown(), | ||
13 | }); | ||
14 | |||
15 | export type XtextWebOkResponse = z.infer<typeof xtextWebOkResponse>; | ||
16 | |||
17 | export const xtextWebErrorKind = z.enum(['request', 'server']); | ||
18 | |||
19 | export type XtextWebErrorKind = z.infer<typeof xtextWebErrorKind>; | ||
20 | |||
21 | export const xtextWebErrorResponse = z.object({ | ||
22 | id: z.string().nonempty(), | ||
23 | error: xtextWebErrorKind, | ||
24 | message: z.string(), | ||
25 | }); | ||
26 | |||
27 | export type XtextWebErrorResponse = z.infer<typeof xtextWebErrorResponse>; | ||
28 | |||
29 | export const xtextWebPushService = z.enum(['highlight', 'validate']); | ||
30 | |||
31 | export type XtextWebPushService = z.infer<typeof xtextWebPushService>; | ||
32 | |||
33 | export const xtextWebPushMessage = z.object({ | ||
34 | resource: z.string().nonempty(), | ||
35 | stateId: z.string().nonempty(), | ||
36 | service: xtextWebPushService, | ||
37 | push: z.unknown(), | ||
38 | }); | ||
39 | |||
40 | export type XtextWebPushMessage = z.infer<typeof xtextWebPushMessage>; | ||
diff --git a/subprojects/frontend/src/xtext/xtextServiceResults.ts b/subprojects/frontend/src/xtext/xtextServiceResults.ts new file mode 100644 index 00000000..f79b059c --- /dev/null +++ b/subprojects/frontend/src/xtext/xtextServiceResults.ts | |||
@@ -0,0 +1,112 @@ | |||
1 | import { z } from 'zod'; | ||
2 | |||
3 | export const pongResult = z.object({ | ||
4 | pong: z.string().nonempty(), | ||
5 | }); | ||
6 | |||
7 | export type PongResult = z.infer<typeof pongResult>; | ||
8 | |||
9 | export const documentStateResult = z.object({ | ||
10 | stateId: z.string().nonempty(), | ||
11 | }); | ||
12 | |||
13 | export type DocumentStateResult = z.infer<typeof documentStateResult>; | ||
14 | |||
15 | export const conflict = z.enum(['invalidStateId', 'canceled']); | ||
16 | |||
17 | export type Conflict = z.infer<typeof conflict>; | ||
18 | |||
19 | export const serviceConflictResult = z.object({ | ||
20 | conflict, | ||
21 | }); | ||
22 | |||
23 | export type ServiceConflictResult = z.infer<typeof serviceConflictResult>; | ||
24 | |||
25 | export function isConflictResult(result: unknown, conflictType: Conflict): boolean { | ||
26 | const parsedConflictResult = serviceConflictResult.safeParse(result); | ||
27 | return parsedConflictResult.success && parsedConflictResult.data.conflict === conflictType; | ||
28 | } | ||
29 | |||
30 | export const severity = z.enum(['error', 'warning', 'info', 'ignore']); | ||
31 | |||
32 | export type Severity = z.infer<typeof severity>; | ||
33 | |||
34 | export const issue = z.object({ | ||
35 | description: z.string().nonempty(), | ||
36 | severity, | ||
37 | line: z.number().int(), | ||
38 | column: z.number().int().nonnegative(), | ||
39 | offset: z.number().int().nonnegative(), | ||
40 | length: z.number().int().nonnegative(), | ||
41 | }); | ||
42 | |||
43 | export type Issue = z.infer<typeof issue>; | ||
44 | |||
45 | export const validationResult = z.object({ | ||
46 | issues: issue.array(), | ||
47 | }); | ||
48 | |||
49 | export type ValidationResult = z.infer<typeof validationResult>; | ||
50 | |||
51 | export const replaceRegion = z.object({ | ||
52 | offset: z.number().int().nonnegative(), | ||
53 | length: z.number().int().nonnegative(), | ||
54 | text: z.string(), | ||
55 | }); | ||
56 | |||
57 | export type ReplaceRegion = z.infer<typeof replaceRegion>; | ||
58 | |||
59 | export const textRegion = z.object({ | ||
60 | offset: z.number().int().nonnegative(), | ||
61 | length: z.number().int().nonnegative(), | ||
62 | }); | ||
63 | |||
64 | export type TextRegion = z.infer<typeof textRegion>; | ||
65 | |||
66 | export const contentAssistEntry = z.object({ | ||
67 | prefix: z.string(), | ||
68 | proposal: z.string().nonempty(), | ||
69 | label: z.string().optional(), | ||
70 | description: z.string().nonempty().optional(), | ||
71 | documentation: z.string().nonempty().optional(), | ||
72 | escapePosition: z.number().int().nonnegative().optional(), | ||
73 | textReplacements: replaceRegion.array(), | ||
74 | editPositions: textRegion.array(), | ||
75 | kind: z.string().nonempty(), | ||
76 | }); | ||
77 | |||
78 | export type ContentAssistEntry = z.infer<typeof contentAssistEntry>; | ||
79 | |||
80 | export const contentAssistResult = documentStateResult.extend({ | ||
81 | entries: contentAssistEntry.array(), | ||
82 | }); | ||
83 | |||
84 | export type ContentAssistResult = z.infer<typeof contentAssistResult>; | ||
85 | |||
86 | export const highlightingRegion = z.object({ | ||
87 | offset: z.number().int().nonnegative(), | ||
88 | length: z.number().int().nonnegative(), | ||
89 | styleClasses: z.string().nonempty().array(), | ||
90 | }); | ||
91 | |||
92 | export type HighlightingRegion = z.infer<typeof highlightingRegion>; | ||
93 | |||
94 | export const highlightingResult = z.object({ | ||
95 | regions: highlightingRegion.array(), | ||
96 | }); | ||
97 | |||
98 | export type HighlightingResult = z.infer<typeof highlightingResult>; | ||
99 | |||
100 | export const occurrencesResult = documentStateResult.extend({ | ||
101 | writeRegions: textRegion.array(), | ||
102 | readRegions: textRegion.array(), | ||
103 | }); | ||
104 | |||
105 | export type OccurrencesResult = z.infer<typeof occurrencesResult>; | ||
106 | |||
107 | export const formattingResult = documentStateResult.extend({ | ||
108 | formattedText: z.string(), | ||
109 | replaceRegion: textRegion, | ||
110 | }); | ||
111 | |||
112 | export type FormattingResult = z.infer<typeof formattingResult>; | ||