diff options
Diffstat (limited to 'language-web/src/main/js/editor/XtextWebSocketClient.ts')
-rw-r--r-- | language-web/src/main/js/editor/XtextWebSocketClient.ts | 185 |
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 @@ | |||
1 | import { getLogger } from '../logging'; | ||
2 | import { PendingRequest } from './PendingRequest'; | ||
3 | import { | ||
4 | isErrorResponse, | ||
5 | isOkResponse, | ||
6 | isPushMessage, | ||
7 | IXtextWebRequest, | ||
8 | } from './xtextMessages'; | ||
9 | |||
10 | const XTEXT_SUBPROTOCOL_V1 = 'tools.refinery.language.web.xtext.v1'; | ||
11 | |||
12 | const WEBSOCKET_CLOSE_OK = 1000; | ||
13 | |||
14 | const RECONNECT_DELAY_MS = 1000; | ||
15 | |||
16 | const log = getLogger('XtextWebSocketClient'); | ||
17 | |||
18 | type ReconnectHandler = () => void; | ||
19 | |||
20 | type PushHandler = (resourceId: string, stateId: string, service: string, data: unknown) => void; | ||
21 | |||
22 | export 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 | } | ||