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