Commit | Line | Data |
---|---|---|
66a7748d JB |
1 | import type { IncomingMessage } from 'node:http' |
2 | import type { Duplex } from 'node:stream' | |
8114d10e | 3 | |
66a7748d JB |
4 | import { StatusCodes } from 'http-status-codes' |
5 | import { type RawData, WebSocket, WebSocketServer } from 'ws' | |
8114d10e | 6 | |
66a7748d | 7 | import { AbstractUIServer } from './AbstractUIServer.js' |
75adc3d8 JB |
8 | import { |
9 | getProtocolAndVersion, | |
10 | handleProtocols, | |
11 | isProtocolAndVersionSupported | |
12 | } from './UIServerUtils.js' | |
268a74bb JB |
13 | import { |
14 | type ProtocolRequest, | |
15 | type ProtocolResponse, | |
16 | type UIServerConfiguration, | |
66a7748d JB |
17 | WebSocketCloseEventStatusCode |
18 | } from '../../types/index.js' | |
9bf0ef23 JB |
19 | import { |
20 | Constants, | |
a66bbcfe | 21 | JSONStringifyWithMapSupport, |
9bf0ef23 JB |
22 | getWebSocketCloseEventStatusString, |
23 | isNotEmptyString, | |
9bf0ef23 JB |
24 | logPrefix, |
25 | logger, | |
66a7748d JB |
26 | validateUUID |
27 | } from '../../utils/index.js' | |
4198ad5c | 28 | |
66a7748d | 29 | const moduleName = 'UIWebSocketServer' |
32de5a57 | 30 | |
268a74bb | 31 | export class UIWebSocketServer extends AbstractUIServer { |
66a7748d | 32 | private readonly webSocketServer: WebSocketServer |
eb3abc4f | 33 | |
66a7748d JB |
34 | public constructor (protected readonly uiServerConfiguration: UIServerConfiguration) { |
35 | super(uiServerConfiguration) | |
eb3abc4f | 36 | this.webSocketServer = new WebSocketServer({ |
75adc3d8 | 37 | handleProtocols, |
66a7748d JB |
38 | noServer: true |
39 | }) | |
4198ad5c JB |
40 | } |
41 | ||
66a7748d JB |
42 | public start (): void { |
43 | this.webSocketServer.on('connection', (ws: WebSocket, _req: IncomingMessage): void => { | |
75adc3d8 | 44 | if (!isProtocolAndVersionSupported(ws.protocol)) { |
a92929f1 JB |
45 | logger.error( |
46 | `${this.logPrefix( | |
47 | moduleName, | |
66a7748d JB |
48 | 'start.server.onconnection' |
49 | )} Unsupported UI protocol version: '${ws.protocol}'` | |
50 | ) | |
51 | ws.close(WebSocketCloseEventStatusCode.CLOSE_PROTOCOL_ERROR) | |
a92929f1 | 52 | } |
75adc3d8 | 53 | const [, version] = getProtocolAndVersion(ws.protocol) |
66a7748d | 54 | this.registerProtocolVersionUIService(version) |
a974c8e4 | 55 | ws.on('message', rawData => { |
66a7748d | 56 | const request = this.validateRawDataRequest(rawData) |
5dea4c94 | 57 | if (request === false) { |
66a7748d JB |
58 | ws.close(WebSocketCloseEventStatusCode.CLOSE_INVALID_PAYLOAD) |
59 | return | |
5dea4c94 | 60 | } |
97608fbd | 61 | const [requestId] = request |
66a7748d | 62 | this.responseHandlers.set(requestId, ws) |
0b22144c JB |
63 | this.uiServices |
64 | .get(version) | |
65 | ?.requestHandler(request) | |
4a3807d1 | 66 | .then((protocolResponse?: ProtocolResponse) => { |
66a7748d JB |
67 | if (protocolResponse != null) { |
68 | this.sendResponse(protocolResponse) | |
0b22144c JB |
69 | } |
70 | }) | |
66a7748d JB |
71 | .catch(Constants.EMPTY_FUNCTION) |
72 | }) | |
a974c8e4 | 73 | ws.on('error', error => { |
66a7748d JB |
74 | logger.error(`${this.logPrefix(moduleName, 'start.ws.onerror')} WebSocket error:`, error) |
75 | }) | |
5e3cb728 JB |
76 | ws.on('close', (code, reason) => { |
77 | logger.debug( | |
78 | `${this.logPrefix( | |
79 | moduleName, | |
66a7748d | 80 | 'start.ws.onclose' |
9bf0ef23 | 81 | )} WebSocket closed: '${getWebSocketCloseEventStatusString( |
66a7748d JB |
82 | code |
83 | )}' - '${reason.toString()}'` | |
84 | ) | |
85 | }) | |
86 | }) | |
87 | this.httpServer.on('connect', (req: IncomingMessage, socket: Duplex, _head: Buffer) => { | |
5199f9fd | 88 | if (req.headers.connection !== 'Upgrade' || req.headers.upgrade !== 'websocket') { |
66a7748d JB |
89 | socket.write(`HTTP/1.1 ${StatusCodes.BAD_REQUEST} Bad Request\r\n\r\n`) |
90 | socket.destroy() | |
cbf9b878 | 91 | } |
66a7748d | 92 | }) |
60a74391 | 93 | this.httpServer.on('upgrade', (req: IncomingMessage, socket: Duplex, head: Buffer): void => { |
776cdee3 JB |
94 | const onSocketError = (error: Error): void => { |
95 | logger.error( | |
96 | `${this.logPrefix( | |
97 | moduleName, | |
98 | 'start.httpServer.on.upgrade' | |
99 | )} Socket error at connection upgrade event handling:`, | |
100 | error | |
101 | ) | |
102 | } | |
103 | socket.on('error', onSocketError) | |
a974c8e4 | 104 | this.authenticate(req, err => { |
66a7748d JB |
105 | if (err != null) { |
106 | socket.write(`HTTP/1.1 ${StatusCodes.UNAUTHORIZED} Unauthorized\r\n\r\n`) | |
107 | socket.destroy() | |
108 | return | |
60a74391 JB |
109 | } |
110 | try { | |
111 | this.webSocketServer.handleUpgrade(req, socket, head, (ws: WebSocket) => { | |
66a7748d JB |
112 | this.webSocketServer.emit('connection', ws, req) |
113 | }) | |
60a74391 JB |
114 | } catch (error) { |
115 | logger.error( | |
116 | `${this.logPrefix( | |
117 | moduleName, | |
66a7748d | 118 | 'start.httpServer.on.upgrade' |
776cdee3 | 119 | )} Error at connection upgrade event handling:`, |
66a7748d JB |
120 | error |
121 | ) | |
60a74391 | 122 | } |
66a7748d | 123 | }) |
776cdee3 | 124 | socket.removeListener('error', onSocketError) |
66a7748d JB |
125 | }) |
126 | this.startHttpServer() | |
4198ad5c JB |
127 | } |
128 | ||
66a7748d JB |
129 | public sendRequest (request: ProtocolRequest): void { |
130 | this.broadcastToClients(JSON.stringify(request)) | |
02a6943a JB |
131 | } |
132 | ||
66a7748d | 133 | public sendResponse (response: ProtocolResponse): void { |
5199f9fd | 134 | const responseId = response[0] |
976d11ec | 135 | try { |
1ca4a038 | 136 | if (this.hasResponseHandler(responseId)) { |
66a7748d | 137 | const ws = this.responseHandlers.get(responseId) as WebSocket |
5199f9fd | 138 | if (ws.readyState === WebSocket.OPEN) { |
a66bbcfe | 139 | ws.send(JSONStringifyWithMapSupport(response)) |
e2c77f10 JB |
140 | } else { |
141 | logger.error( | |
142 | `${this.logPrefix( | |
143 | moduleName, | |
66a7748d | 144 | 'sendResponse' |
5199f9fd JB |
145 | )} Error at sending response id '${responseId}', WebSocket is not open: ${ |
146 | ws.readyState | |
147 | }` | |
66a7748d | 148 | ) |
976d11ec | 149 | } |
976d11ec JB |
150 | } else { |
151 | logger.error( | |
152 | `${this.logPrefix( | |
153 | moduleName, | |
66a7748d JB |
154 | 'sendResponse' |
155 | )} Response for unknown request id: ${responseId}` | |
156 | ) | |
94dc3080 | 157 | } |
976d11ec | 158 | } catch (error) { |
94dc3080 JB |
159 | logger.error( |
160 | `${this.logPrefix( | |
161 | moduleName, | |
66a7748d | 162 | 'sendResponse' |
976d11ec | 163 | )} Error at sending response id '${responseId}':`, |
66a7748d JB |
164 | error |
165 | ) | |
e2c77f10 | 166 | } finally { |
66a7748d | 167 | this.responseHandlers.delete(responseId) |
94dc3080 | 168 | } |
178ac666 JB |
169 | } |
170 | ||
8b7072dc | 171 | public logPrefix = (modName?: string, methodName?: string, prefixSuffix?: string): string => { |
66a7748d JB |
172 | const logMsgPrefix = |
173 | prefixSuffix != null ? `UI WebSocket Server ${prefixSuffix}` : 'UI WebSocket Server' | |
32de5a57 | 174 | const logMsg = |
9bf0ef23 | 175 | isNotEmptyString(modName) && isNotEmptyString(methodName) |
1b271a54 | 176 | ? ` ${logMsgPrefix} | ${modName}.${methodName}:` |
66a7748d JB |
177 | : ` ${logMsgPrefix} |` |
178 | return logPrefix(logMsg) | |
179 | } | |
178ac666 | 180 | |
66a7748d | 181 | private broadcastToClients (message: string): void { |
eb3abc4f | 182 | for (const client of this.webSocketServer.clients) { |
5199f9fd | 183 | if (client.readyState === WebSocket.OPEN) { |
66a7748d | 184 | client.send(message) |
178ac666 JB |
185 | } |
186 | } | |
187 | } | |
5e3cb728 | 188 | |
66a7748d | 189 | private validateRawDataRequest (rawData: RawData): ProtocolRequest | false { |
5e3cb728 JB |
190 | // logger.debug( |
191 | // `${this.logPrefix( | |
192 | // moduleName, | |
66a7748d | 193 | // 'validateRawDataRequest' |
e1d9a0f4 | 194 | // // eslint-disable-next-line @typescript-eslint/no-base-to-string |
66a7748d JB |
195 | // )} Raw data received in string format: ${rawData.toString()}` |
196 | // ) | |
5e3cb728 | 197 | |
71ac2bd7 JB |
198 | let request: ProtocolRequest |
199 | try { | |
200 | // eslint-disable-next-line @typescript-eslint/no-base-to-string | |
201 | request = JSON.parse(rawData.toString()) as ProtocolRequest | |
202 | } catch (error) { | |
203 | logger.error( | |
204 | `${this.logPrefix( | |
205 | moduleName, | |
206 | 'validateRawDataRequest' | |
207 | // eslint-disable-next-line @typescript-eslint/no-base-to-string | |
208 | )} UI protocol request is not valid JSON: ${rawData.toString()}` | |
209 | ) | |
210 | return false | |
211 | } | |
5e3cb728 | 212 | |
66a7748d | 213 | if (!Array.isArray(request)) { |
5dea4c94 JB |
214 | logger.error( |
215 | `${this.logPrefix( | |
216 | moduleName, | |
66a7748d | 217 | 'validateRawDataRequest' |
5dea4c94 | 218 | )} UI protocol request is not an array:`, |
66a7748d JB |
219 | request |
220 | ) | |
221 | return false | |
5e3cb728 JB |
222 | } |
223 | ||
5199f9fd | 224 | // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition |
5e3cb728 | 225 | if (request.length !== 3) { |
5dea4c94 JB |
226 | logger.error( |
227 | `${this.logPrefix(moduleName, 'validateRawDataRequest')} UI protocol request is malformed:`, | |
66a7748d JB |
228 | request |
229 | ) | |
230 | return false | |
5dea4c94 JB |
231 | } |
232 | ||
5199f9fd | 233 | if (!validateUUID(request[0])) { |
5dea4c94 JB |
234 | logger.error( |
235 | `${this.logPrefix( | |
236 | moduleName, | |
66a7748d | 237 | 'validateRawDataRequest' |
5dea4c94 | 238 | )} UI protocol request UUID field is invalid:`, |
66a7748d JB |
239 | request |
240 | ) | |
241 | return false | |
5e3cb728 JB |
242 | } |
243 | ||
66a7748d | 244 | return request |
5e3cb728 | 245 | } |
4198ad5c | 246 | } |