aboutsummaryrefslogtreecommitdiffstats
path: root/language-web/src/main/js/editor/XtextWebSocketClient.ts
diff options
context:
space:
mode:
authorLibravatar Kristóf Marussy <kristof@marussy.com>2021-10-25 00:29:37 +0200
committerLibravatar Kristóf Marussy <kristof@marussy.com>2021-10-31 19:26:11 +0100
commitdcbfeece5e559b60a615f0aa9b933b202d34bf8b (patch)
treeafdacff7492284f5f8cc147c4b84e4ba5db259b3 /language-web/src/main/js/editor/XtextWebSocketClient.ts
parenttest(web): more websocket integration tests (diff)
downloadrefinery-dcbfeece5e559b60a615f0aa9b933b202d34bf8b.tar.gz
refinery-dcbfeece5e559b60a615f0aa9b933b202d34bf8b.tar.zst
refinery-dcbfeece5e559b60a615f0aa9b933b202d34bf8b.zip
feat(web): add xtext websocket client
Diffstat (limited to 'language-web/src/main/js/editor/XtextWebSocketClient.ts')
-rw-r--r--language-web/src/main/js/editor/XtextWebSocketClient.ts185
1 files changed, 185 insertions, 0 deletions
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 @@
1import { getLogger } from '../logging';
2import { PendingRequest } from './PendingRequest';
3import {
4 isErrorResponse,
5 isOkResponse,
6 isPushMessage,
7 IXtextWebRequest,
8} from './xtextMessages';
9
10const XTEXT_SUBPROTOCOL_V1 = 'tools.refinery.language.web.xtext.v1';
11
12const WEBSOCKET_CLOSE_OK = 1000;
13
14const RECONNECT_DELAY_MS = 1000;
15
16const log = getLogger('XtextWebSocketClient');
17
18type ReconnectHandler = () => void;
19
20type PushHandler = (resourceId: string, stateId: string, service: string, data: unknown) => void;
21
22export class XtextWebSocketClient {
23 nextMessageId = 0;
24
25 closing = false;
26
27 connection!: WebSocket;
28
29 pendingRequests = new Map<string, PendingRequest>();
30
31 onReconnect: ReconnectHandler;
32
33 onPush: PushHandler;
34
35 reconnectTimeout: NodeJS.Timeout | null = null;
36
37 constructor(onReconnect: ReconnectHandler, onPush: PushHandler) {
38 this.onReconnect = onReconnect;
39 this.onPush = onPush;
40 this.reconnect();
41 }
42
43 get isOpen(): boolean {
44 return this.connection.readyState === WebSocket.OPEN;
45 }
46
47 private reconnect() {
48 this.reconnectTimeout = null;
49 const webSocketServer = window.origin.replace(/^http/, 'ws');
50 const webSocketUrl = `${webSocketServer}/xtext-service`;
51 this.connection = new WebSocket(webSocketUrl, XTEXT_SUBPROTOCOL_V1);
52 this.connection.addEventListener('open', () => {
53 if (this.connection.protocol !== XTEXT_SUBPROTOCOL_V1) {
54 log.error('Unknown subprotocol', this.connection.protocol, 'selected by server');
55 this.forceReconnectDueToError();
56 return;
57 }
58 log.info('Connected to xtext web services');
59 this.onReconnect();
60 });
61 this.connection.addEventListener('error', (event) => {
62 log.error('Unexpected websocket error', event);
63 this.forceReconnectDueToError();
64 });
65 this.connection.addEventListener('message', (event) => {
66 this.handleMessage(event.data);
67 });
68 this.connection.addEventListener('close', (event) => {
69 if (!this.closing || event.code !== WEBSOCKET_CLOSE_OK) {
70 log.error('Websocket closed undexpectedly', event.code, event.reason);
71 }
72 this.cleanupAndMaybeReconnect();
73 });
74 }
75
76 private cleanupAndMaybeReconnect() {
77 this.pendingRequests.forEach((pendingRequest) => {
78 pendingRequest.reject(new Error('Websocket closed'));
79 });
80 this.pendingRequests.clear();
81 if (this.closing) {
82 return;
83 }
84 if (this.reconnectTimeout !== null) {
85 clearTimeout(this.reconnectTimeout);
86 }
87 this.reconnectTimeout = setTimeout(() => {
88 log.info('Attempting to reconnect websocket');
89 this.reconnect();
90 }, RECONNECT_DELAY_MS);
91 }
92
93 public forceReconnectDueToError(): void {
94 this.closeConnection();
95 this.cleanupAndMaybeReconnect();
96 }
97
98 send(request: unknown): Promise<unknown> {
99 if (!this.isOpen) {
100 throw new Error('Connection is not open');
101 }
102 const messageId = this.nextMessageId.toString(16);
103 if (messageId in this.pendingRequests) {
104 log.error('Message id wraparound still pending', messageId);
105 this.rejectRequest(messageId, new Error('Message id wraparound'));
106 }
107 if (this.nextMessageId >= Number.MAX_SAFE_INTEGER) {
108 this.nextMessageId = 0;
109 } else {
110 this.nextMessageId += 1;
111 }
112 const message = JSON.stringify({
113 id: messageId,
114 request,
115 } as IXtextWebRequest);
116 return new Promise((resolve, reject) => {
117 this.connection.send(message);
118 this.pendingRequests.set(messageId, new PendingRequest(resolve, reject));
119 });
120 }
121
122 private handleMessage(messageStr: unknown) {
123 if (typeof messageStr !== 'string') {
124 log.error('Unexpected binary message', messageStr);
125 this.forceReconnectDueToError();
126 return;
127 }
128 log.trace('Incoming websocket message', messageStr);
129 let message: unknown;
130 try {
131 message = JSON.parse(messageStr);
132 } catch (error) {
133 log.error('Json parse error', error);
134 this.forceReconnectDueToError();
135 return;
136 }
137 if (isOkResponse(message)) {
138 this.resolveRequest(message.id, message.response);
139 } else if (isErrorResponse(message)) {
140 this.rejectRequest(message.id, new Error(`${message.error} error: ${message.message}`));
141 if (message.error === 'server') {
142 log.error('Reconnecting due to server error: ', message.message);
143 this.forceReconnectDueToError();
144 }
145 } else if (isPushMessage(message)) {
146 this.onPush(message.resource, message.stateId, message.service, message.push);
147 } else {
148 log.error('Unexpected websocket message', message);
149 this.forceReconnectDueToError();
150 }
151 }
152
153 private resolveRequest(messageId: string, value: unknown) {
154 const pendingRequest = this.pendingRequests.get(messageId);
155 this.pendingRequests.delete(messageId);
156 if (pendingRequest) {
157 pendingRequest.resolve(value);
158 return;
159 }
160 log.error('Trying to resolve unknown request', messageId, 'with', value);
161 }
162
163 private rejectRequest(messageId: string, reason?: unknown) {
164 const pendingRequest = this.pendingRequests.get(messageId);
165 this.pendingRequests.delete(messageId);
166 if (pendingRequest) {
167 pendingRequest.reject(reason);
168 return;
169 }
170 log.error('Trying to reject unknown request', messageId, 'with', reason);
171 }
172
173 private closeConnection() {
174 if (this.connection && this.connection.readyState !== WebSocket.CLOSING
175 && this.connection.readyState !== WebSocket.CLOSED) {
176 log.info('Closing websocket connection');
177 this.connection.close();
178 }
179 }
180
181 close(): void {
182 this.closing = true;
183 this.closeConnection();
184 }
185}