1'use strict' 2 3const diagnosticsChannel = require('diagnostics_channel') 4const { uid, states } = require('./constants') 5const { 6 kReadyState, 7 kSentClose, 8 kByteParser, 9 kReceivedClose 10} = require('./symbols') 11const { fireEvent, failWebsocketConnection } = require('./util') 12const { CloseEvent } = require('./events') 13const { makeRequest } = require('../fetch/request') 14const { fetching } = require('../fetch/index') 15const { Headers } = require('../fetch/headers') 16const { getGlobalDispatcher } = require('../global') 17const { kHeadersList } = require('../core/symbols') 18 19const channels = {} 20channels.open = diagnosticsChannel.channel('undici:websocket:open') 21channels.close = diagnosticsChannel.channel('undici:websocket:close') 22channels.socketError = diagnosticsChannel.channel('undici:websocket:socket_error') 23 24/** @type {import('crypto')} */ 25let crypto 26try { 27 crypto = require('crypto') 28} catch { 29 30} 31 32/** 33 * @see https://websockets.spec.whatwg.org/#concept-websocket-establish 34 * @param {URL} url 35 * @param {string|string[]} protocols 36 * @param {import('./websocket').WebSocket} ws 37 * @param {(response: any) => void} onEstablish 38 * @param {Partial<import('../../types/websocket').WebSocketInit>} options 39 */ 40function establishWebSocketConnection (url, protocols, ws, onEstablish, options) { 41 // 1. Let requestURL be a copy of url, with its scheme set to "http", if url’s 42 // scheme is "ws", and to "https" otherwise. 43 const requestURL = url 44 45 requestURL.protocol = url.protocol === 'ws:' ? 'http:' : 'https:' 46 47 // 2. Let request be a new request, whose URL is requestURL, client is client, 48 // service-workers mode is "none", referrer is "no-referrer", mode is 49 // "websocket", credentials mode is "include", cache mode is "no-store" , 50 // and redirect mode is "error". 51 const request = makeRequest({ 52 urlList: [requestURL], 53 serviceWorkers: 'none', 54 referrer: 'no-referrer', 55 mode: 'websocket', 56 credentials: 'include', 57 cache: 'no-store', 58 redirect: 'error' 59 }) 60 61 // Note: undici extension, allow setting custom headers. 62 if (options.headers) { 63 const headersList = new Headers(options.headers)[kHeadersList] 64 65 request.headersList = headersList 66 } 67 68 // 3. Append (`Upgrade`, `websocket`) to request’s header list. 69 // 4. Append (`Connection`, `Upgrade`) to request’s header list. 70 // Note: both of these are handled by undici currently. 71 // https://github.com/nodejs/undici/blob/68c269c4144c446f3f1220951338daef4a6b5ec4/lib/client.js#L1397 72 73 // 5. Let keyValue be a nonce consisting of a randomly selected 74 // 16-byte value that has been forgiving-base64-encoded and 75 // isomorphic encoded. 76 const keyValue = crypto.randomBytes(16).toString('base64') 77 78 // 6. Append (`Sec-WebSocket-Key`, keyValue) to request’s 79 // header list. 80 request.headersList.append('sec-websocket-key', keyValue) 81 82 // 7. Append (`Sec-WebSocket-Version`, `13`) to request’s 83 // header list. 84 request.headersList.append('sec-websocket-version', '13') 85 86 // 8. For each protocol in protocols, combine 87 // (`Sec-WebSocket-Protocol`, protocol) in request’s header 88 // list. 89 for (const protocol of protocols) { 90 request.headersList.append('sec-websocket-protocol', protocol) 91 } 92 93 // 9. Let permessageDeflate be a user-agent defined 94 // "permessage-deflate" extension header value. 95 // https://github.com/mozilla/gecko-dev/blob/ce78234f5e653a5d3916813ff990f053510227bc/netwerk/protocol/websocket/WebSocketChannel.cpp#L2673 96 // TODO: enable once permessage-deflate is supported 97 const permessageDeflate = '' // 'permessage-deflate; 15' 98 99 // 10. Append (`Sec-WebSocket-Extensions`, permessageDeflate) to 100 // request’s header list. 101 // request.headersList.append('sec-websocket-extensions', permessageDeflate) 102 103 // 11. Fetch request with useParallelQueue set to true, and 104 // processResponse given response being these steps: 105 const controller = fetching({ 106 request, 107 useParallelQueue: true, 108 dispatcher: options.dispatcher ?? getGlobalDispatcher(), 109 processResponse (response) { 110 // 1. If response is a network error or its status is not 101, 111 // fail the WebSocket connection. 112 if (response.type === 'error' || response.status !== 101) { 113 failWebsocketConnection(ws, 'Received network error or non-101 status code.') 114 return 115 } 116 117 // 2. If protocols is not the empty list and extracting header 118 // list values given `Sec-WebSocket-Protocol` and response’s 119 // header list results in null, failure, or the empty byte 120 // sequence, then fail the WebSocket connection. 121 if (protocols.length !== 0 && !response.headersList.get('Sec-WebSocket-Protocol')) { 122 failWebsocketConnection(ws, 'Server did not respond with sent protocols.') 123 return 124 } 125 126 // 3. Follow the requirements stated step 2 to step 6, inclusive, 127 // of the last set of steps in section 4.1 of The WebSocket 128 // Protocol to validate response. This either results in fail 129 // the WebSocket connection or the WebSocket connection is 130 // established. 131 132 // 2. If the response lacks an |Upgrade| header field or the |Upgrade| 133 // header field contains a value that is not an ASCII case- 134 // insensitive match for the value "websocket", the client MUST 135 // _Fail the WebSocket Connection_. 136 if (response.headersList.get('Upgrade')?.toLowerCase() !== 'websocket') { 137 failWebsocketConnection(ws, 'Server did not set Upgrade header to "websocket".') 138 return 139 } 140 141 // 3. If the response lacks a |Connection| header field or the 142 // |Connection| header field doesn't contain a token that is an 143 // ASCII case-insensitive match for the value "Upgrade", the client 144 // MUST _Fail the WebSocket Connection_. 145 if (response.headersList.get('Connection')?.toLowerCase() !== 'upgrade') { 146 failWebsocketConnection(ws, 'Server did not set Connection header to "upgrade".') 147 return 148 } 149 150 // 4. If the response lacks a |Sec-WebSocket-Accept| header field or 151 // the |Sec-WebSocket-Accept| contains a value other than the 152 // base64-encoded SHA-1 of the concatenation of the |Sec-WebSocket- 153 // Key| (as a string, not base64-decoded) with the string "258EAFA5- 154 // E914-47DA-95CA-C5AB0DC85B11" but ignoring any leading and 155 // trailing whitespace, the client MUST _Fail the WebSocket 156 // Connection_. 157 const secWSAccept = response.headersList.get('Sec-WebSocket-Accept') 158 const digest = crypto.createHash('sha1').update(keyValue + uid).digest('base64') 159 if (secWSAccept !== digest) { 160 failWebsocketConnection(ws, 'Incorrect hash received in Sec-WebSocket-Accept header.') 161 return 162 } 163 164 // 5. If the response includes a |Sec-WebSocket-Extensions| header 165 // field and this header field indicates the use of an extension 166 // that was not present in the client's handshake (the server has 167 // indicated an extension not requested by the client), the client 168 // MUST _Fail the WebSocket Connection_. (The parsing of this 169 // header field to determine which extensions are requested is 170 // discussed in Section 9.1.) 171 const secExtension = response.headersList.get('Sec-WebSocket-Extensions') 172 173 if (secExtension !== null && secExtension !== permessageDeflate) { 174 failWebsocketConnection(ws, 'Received different permessage-deflate than the one set.') 175 return 176 } 177 178 // 6. If the response includes a |Sec-WebSocket-Protocol| header field 179 // and this header field indicates the use of a subprotocol that was 180 // not present in the client's handshake (the server has indicated a 181 // subprotocol not requested by the client), the client MUST _Fail 182 // the WebSocket Connection_. 183 const secProtocol = response.headersList.get('Sec-WebSocket-Protocol') 184 185 if (secProtocol !== null && secProtocol !== request.headersList.get('Sec-WebSocket-Protocol')) { 186 failWebsocketConnection(ws, 'Protocol was not set in the opening handshake.') 187 return 188 } 189 190 response.socket.on('data', onSocketData) 191 response.socket.on('close', onSocketClose) 192 response.socket.on('error', onSocketError) 193 194 if (channels.open.hasSubscribers) { 195 channels.open.publish({ 196 address: response.socket.address(), 197 protocol: secProtocol, 198 extensions: secExtension 199 }) 200 } 201 202 onEstablish(response) 203 } 204 }) 205 206 return controller 207} 208 209/** 210 * @param {Buffer} chunk 211 */ 212function onSocketData (chunk) { 213 if (!this.ws[kByteParser].write(chunk)) { 214 this.pause() 215 } 216} 217 218/** 219 * @see https://websockets.spec.whatwg.org/#feedback-from-the-protocol 220 * @see https://datatracker.ietf.org/doc/html/rfc6455#section-7.1.4 221 */ 222function onSocketClose () { 223 const { ws } = this 224 225 // If the TCP connection was closed after the 226 // WebSocket closing handshake was completed, the WebSocket connection 227 // is said to have been closed _cleanly_. 228 const wasClean = ws[kSentClose] && ws[kReceivedClose] 229 230 let code = 1005 231 let reason = '' 232 233 const result = ws[kByteParser].closingInfo 234 235 if (result) { 236 code = result.code ?? 1005 237 reason = result.reason 238 } else if (!ws[kSentClose]) { 239 // If _The WebSocket 240 // Connection is Closed_ and no Close control frame was received by the 241 // endpoint (such as could occur if the underlying transport connection 242 // is lost), _The WebSocket Connection Close Code_ is considered to be 243 // 1006. 244 code = 1006 245 } 246 247 // 1. Change the ready state to CLOSED (3). 248 ws[kReadyState] = states.CLOSED 249 250 // 2. If the user agent was required to fail the WebSocket 251 // connection, or if the WebSocket connection was closed 252 // after being flagged as full, fire an event named error 253 // at the WebSocket object. 254 // TODO 255 256 // 3. Fire an event named close at the WebSocket object, 257 // using CloseEvent, with the wasClean attribute 258 // initialized to true if the connection closed cleanly 259 // and false otherwise, the code attribute initialized to 260 // the WebSocket connection close code, and the reason 261 // attribute initialized to the result of applying UTF-8 262 // decode without BOM to the WebSocket connection close 263 // reason. 264 fireEvent('close', ws, CloseEvent, { 265 wasClean, code, reason 266 }) 267 268 if (channels.close.hasSubscribers) { 269 channels.close.publish({ 270 websocket: ws, 271 code, 272 reason 273 }) 274 } 275} 276 277function onSocketError (error) { 278 const { ws } = this 279 280 ws[kReadyState] = states.CLOSING 281 282 if (channels.socketError.hasSubscribers) { 283 channels.socketError.publish(error) 284 } 285 286 this.destroy() 287} 288 289module.exports = { 290 establishWebSocketConnection 291} 292