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 => { |
a974c8e4 | 94 | this.authenticate(req, err => { |
66a7748d JB |
95 | if (err != null) { |
96 | socket.write(`HTTP/1.1 ${StatusCodes.UNAUTHORIZED} Unauthorized\r\n\r\n`) | |
97 | socket.destroy() | |
98 | return | |
60a74391 JB |
99 | } |
100 | try { | |
101 | this.webSocketServer.handleUpgrade(req, socket, head, (ws: WebSocket) => { | |
66a7748d JB |
102 | this.webSocketServer.emit('connection', ws, req) |
103 | }) | |
60a74391 JB |
104 | } catch (error) { |
105 | logger.error( | |
106 | `${this.logPrefix( | |
107 | moduleName, | |
66a7748d | 108 | 'start.httpServer.on.upgrade' |
60a74391 | 109 | )} Error at handling connection upgrade:`, |
66a7748d JB |
110 | error |
111 | ) | |
60a74391 | 112 | } |
66a7748d JB |
113 | }) |
114 | }) | |
115 | this.startHttpServer() | |
4198ad5c JB |
116 | } |
117 | ||
66a7748d JB |
118 | public sendRequest (request: ProtocolRequest): void { |
119 | this.broadcastToClients(JSON.stringify(request)) | |
02a6943a JB |
120 | } |
121 | ||
66a7748d | 122 | public sendResponse (response: ProtocolResponse): void { |
5199f9fd | 123 | const responseId = response[0] |
976d11ec | 124 | try { |
1ca4a038 | 125 | if (this.hasResponseHandler(responseId)) { |
66a7748d | 126 | const ws = this.responseHandlers.get(responseId) as WebSocket |
5199f9fd | 127 | if (ws.readyState === WebSocket.OPEN) { |
a66bbcfe | 128 | ws.send(JSONStringifyWithMapSupport(response)) |
e2c77f10 JB |
129 | } else { |
130 | logger.error( | |
131 | `${this.logPrefix( | |
132 | moduleName, | |
66a7748d | 133 | 'sendResponse' |
5199f9fd JB |
134 | )} Error at sending response id '${responseId}', WebSocket is not open: ${ |
135 | ws.readyState | |
136 | }` | |
66a7748d | 137 | ) |
976d11ec | 138 | } |
976d11ec JB |
139 | } else { |
140 | logger.error( | |
141 | `${this.logPrefix( | |
142 | moduleName, | |
66a7748d JB |
143 | 'sendResponse' |
144 | )} Response for unknown request id: ${responseId}` | |
145 | ) | |
94dc3080 | 146 | } |
976d11ec | 147 | } catch (error) { |
94dc3080 JB |
148 | logger.error( |
149 | `${this.logPrefix( | |
150 | moduleName, | |
66a7748d | 151 | 'sendResponse' |
976d11ec | 152 | )} Error at sending response id '${responseId}':`, |
66a7748d JB |
153 | error |
154 | ) | |
e2c77f10 | 155 | } finally { |
66a7748d | 156 | this.responseHandlers.delete(responseId) |
94dc3080 | 157 | } |
178ac666 JB |
158 | } |
159 | ||
8b7072dc | 160 | public logPrefix = (modName?: string, methodName?: string, prefixSuffix?: string): string => { |
66a7748d JB |
161 | const logMsgPrefix = |
162 | prefixSuffix != null ? `UI WebSocket Server ${prefixSuffix}` : 'UI WebSocket Server' | |
32de5a57 | 163 | const logMsg = |
9bf0ef23 | 164 | isNotEmptyString(modName) && isNotEmptyString(methodName) |
1b271a54 | 165 | ? ` ${logMsgPrefix} | ${modName}.${methodName}:` |
66a7748d JB |
166 | : ` ${logMsgPrefix} |` |
167 | return logPrefix(logMsg) | |
168 | } | |
178ac666 | 169 | |
66a7748d | 170 | private broadcastToClients (message: string): void { |
eb3abc4f | 171 | for (const client of this.webSocketServer.clients) { |
5199f9fd | 172 | if (client.readyState === WebSocket.OPEN) { |
66a7748d | 173 | client.send(message) |
178ac666 JB |
174 | } |
175 | } | |
176 | } | |
5e3cb728 | 177 | |
66a7748d | 178 | private validateRawDataRequest (rawData: RawData): ProtocolRequest | false { |
5e3cb728 JB |
179 | // logger.debug( |
180 | // `${this.logPrefix( | |
181 | // moduleName, | |
66a7748d | 182 | // 'validateRawDataRequest' |
e1d9a0f4 | 183 | // // eslint-disable-next-line @typescript-eslint/no-base-to-string |
66a7748d JB |
184 | // )} Raw data received in string format: ${rawData.toString()}` |
185 | // ) | |
5e3cb728 | 186 | |
71ac2bd7 JB |
187 | let request: ProtocolRequest |
188 | try { | |
189 | // eslint-disable-next-line @typescript-eslint/no-base-to-string | |
190 | request = JSON.parse(rawData.toString()) as ProtocolRequest | |
191 | } catch (error) { | |
192 | logger.error( | |
193 | `${this.logPrefix( | |
194 | moduleName, | |
195 | 'validateRawDataRequest' | |
196 | // eslint-disable-next-line @typescript-eslint/no-base-to-string | |
197 | )} UI protocol request is not valid JSON: ${rawData.toString()}` | |
198 | ) | |
199 | return false | |
200 | } | |
5e3cb728 | 201 | |
66a7748d | 202 | if (!Array.isArray(request)) { |
5dea4c94 JB |
203 | logger.error( |
204 | `${this.logPrefix( | |
205 | moduleName, | |
66a7748d | 206 | 'validateRawDataRequest' |
5dea4c94 | 207 | )} UI protocol request is not an array:`, |
66a7748d JB |
208 | request |
209 | ) | |
210 | return false | |
5e3cb728 JB |
211 | } |
212 | ||
5199f9fd | 213 | // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition |
5e3cb728 | 214 | if (request.length !== 3) { |
5dea4c94 JB |
215 | logger.error( |
216 | `${this.logPrefix(moduleName, 'validateRawDataRequest')} UI protocol request is malformed:`, | |
66a7748d JB |
217 | request |
218 | ) | |
219 | return false | |
5dea4c94 JB |
220 | } |
221 | ||
5199f9fd | 222 | if (!validateUUID(request[0])) { |
5dea4c94 JB |
223 | logger.error( |
224 | `${this.logPrefix( | |
225 | moduleName, | |
66a7748d | 226 | 'validateRawDataRequest' |
5dea4c94 | 227 | )} UI protocol request UUID field is invalid:`, |
66a7748d JB |
228 | request |
229 | ) | |
230 | return false | |
5e3cb728 JB |
231 | } |
232 | ||
66a7748d | 233 | return request |
5e3cb728 | 234 | } |
4198ad5c | 235 | } |