Spaces:
Runtime error
Runtime error
const diagnosticsChannel = require('diagnostics_channel') | |
const { uid, states } = require('./constants') | |
const { | |
kReadyState, | |
kSentClose, | |
kByteParser, | |
kReceivedClose | |
} = require('./symbols') | |
const { fireEvent, failWebsocketConnection } = require('./util') | |
const { CloseEvent } = require('./events') | |
const { makeRequest } = require('../fetch/request') | |
const { fetching } = require('../fetch/index') | |
const { Headers } = require('../fetch/headers') | |
const { getGlobalDispatcher } = require('../global') | |
const { kHeadersList } = require('../core/symbols') | |
const channels = {} | |
channels.open = diagnosticsChannel.channel('undici:websocket:open') | |
channels.close = diagnosticsChannel.channel('undici:websocket:close') | |
channels.socketError = diagnosticsChannel.channel('undici:websocket:socket_error') | |
/** @type {import('crypto')} */ | |
let crypto | |
try { | |
crypto = require('crypto') | |
} catch { | |
} | |
/** | |
* @see https://websockets.spec.whatwg.org/#concept-websocket-establish | |
* @param {URL} url | |
* @param {string|string[]} protocols | |
* @param {import('./websocket').WebSocket} ws | |
* @param {(response: any) => void} onEstablish | |
* @param {Partial<import('../../types/websocket').WebSocketInit>} options | |
*/ | |
function establishWebSocketConnection (url, protocols, ws, onEstablish, options) { | |
// 1. Let requestURL be a copy of url, with its scheme set to "http", if url’s | |
// scheme is "ws", and to "https" otherwise. | |
const requestURL = url | |
requestURL.protocol = url.protocol === 'ws:' ? 'http:' : 'https:' | |
// 2. Let request be a new request, whose URL is requestURL, client is client, | |
// service-workers mode is "none", referrer is "no-referrer", mode is | |
// "websocket", credentials mode is "include", cache mode is "no-store" , | |
// and redirect mode is "error". | |
const request = makeRequest({ | |
urlList: [requestURL], | |
serviceWorkers: 'none', | |
referrer: 'no-referrer', | |
mode: 'websocket', | |
credentials: 'include', | |
cache: 'no-store', | |
redirect: 'error' | |
}) | |
// Note: undici extension, allow setting custom headers. | |
if (options.headers) { | |
const headersList = new Headers(options.headers)[kHeadersList] | |
request.headersList = headersList | |
} | |
// 3. Append (`Upgrade`, `websocket`) to request’s header list. | |
// 4. Append (`Connection`, `Upgrade`) to request’s header list. | |
// Note: both of these are handled by undici currently. | |
// https://github.com/nodejs/undici/blob/68c269c4144c446f3f1220951338daef4a6b5ec4/lib/client.js#L1397 | |
// 5. Let keyValue be a nonce consisting of a randomly selected | |
// 16-byte value that has been forgiving-base64-encoded and | |
// isomorphic encoded. | |
const keyValue = crypto.randomBytes(16).toString('base64') | |
// 6. Append (`Sec-WebSocket-Key`, keyValue) to request’s | |
// header list. | |
request.headersList.append('sec-websocket-key', keyValue) | |
// 7. Append (`Sec-WebSocket-Version`, `13`) to request’s | |
// header list. | |
request.headersList.append('sec-websocket-version', '13') | |
// 8. For each protocol in protocols, combine | |
// (`Sec-WebSocket-Protocol`, protocol) in request’s header | |
// list. | |
for (const protocol of protocols) { | |
request.headersList.append('sec-websocket-protocol', protocol) | |
} | |
// 9. Let permessageDeflate be a user-agent defined | |
// "permessage-deflate" extension header value. | |
// https://github.com/mozilla/gecko-dev/blob/ce78234f5e653a5d3916813ff990f053510227bc/netwerk/protocol/websocket/WebSocketChannel.cpp#L2673 | |
// TODO: enable once permessage-deflate is supported | |
const permessageDeflate = '' // 'permessage-deflate; 15' | |
// 10. Append (`Sec-WebSocket-Extensions`, permessageDeflate) to | |
// request’s header list. | |
// request.headersList.append('sec-websocket-extensions', permessageDeflate) | |
// 11. Fetch request with useParallelQueue set to true, and | |
// processResponse given response being these steps: | |
const controller = fetching({ | |
request, | |
useParallelQueue: true, | |
dispatcher: options.dispatcher ?? getGlobalDispatcher(), | |
processResponse (response) { | |
// 1. If response is a network error or its status is not 101, | |
// fail the WebSocket connection. | |
if (response.type === 'error' || response.status !== 101) { | |
failWebsocketConnection(ws, 'Received network error or non-101 status code.') | |
return | |
} | |
// 2. If protocols is not the empty list and extracting header | |
// list values given `Sec-WebSocket-Protocol` and response’s | |
// header list results in null, failure, or the empty byte | |
// sequence, then fail the WebSocket connection. | |
if (protocols.length !== 0 && !response.headersList.get('Sec-WebSocket-Protocol')) { | |
failWebsocketConnection(ws, 'Server did not respond with sent protocols.') | |
return | |
} | |
// 3. Follow the requirements stated step 2 to step 6, inclusive, | |
// of the last set of steps in section 4.1 of The WebSocket | |
// Protocol to validate response. This either results in fail | |
// the WebSocket connection or the WebSocket connection is | |
// established. | |
// 2. If the response lacks an |Upgrade| header field or the |Upgrade| | |
// header field contains a value that is not an ASCII case- | |
// insensitive match for the value "websocket", the client MUST | |
// _Fail the WebSocket Connection_. | |
if (response.headersList.get('Upgrade')?.toLowerCase() !== 'websocket') { | |
failWebsocketConnection(ws, 'Server did not set Upgrade header to "websocket".') | |
return | |
} | |
// 3. If the response lacks a |Connection| header field or the | |
// |Connection| header field doesn't contain a token that is an | |
// ASCII case-insensitive match for the value "Upgrade", the client | |
// MUST _Fail the WebSocket Connection_. | |
if (response.headersList.get('Connection')?.toLowerCase() !== 'upgrade') { | |
failWebsocketConnection(ws, 'Server did not set Connection header to "upgrade".') | |
return | |
} | |
// 4. If the response lacks a |Sec-WebSocket-Accept| header field or | |
// the |Sec-WebSocket-Accept| contains a value other than the | |
// base64-encoded SHA-1 of the concatenation of the |Sec-WebSocket- | |
// Key| (as a string, not base64-decoded) with the string "258EAFA5- | |
// E914-47DA-95CA-C5AB0DC85B11" but ignoring any leading and | |
// trailing whitespace, the client MUST _Fail the WebSocket | |
// Connection_. | |
const secWSAccept = response.headersList.get('Sec-WebSocket-Accept') | |
const digest = crypto.createHash('sha1').update(keyValue + uid).digest('base64') | |
if (secWSAccept !== digest) { | |
failWebsocketConnection(ws, 'Incorrect hash received in Sec-WebSocket-Accept header.') | |
return | |
} | |
// 5. If the response includes a |Sec-WebSocket-Extensions| header | |
// field and this header field indicates the use of an extension | |
// that was not present in the client's handshake (the server has | |
// indicated an extension not requested by the client), the client | |
// MUST _Fail the WebSocket Connection_. (The parsing of this | |
// header field to determine which extensions are requested is | |
// discussed in Section 9.1.) | |
const secExtension = response.headersList.get('Sec-WebSocket-Extensions') | |
if (secExtension !== null && secExtension !== permessageDeflate) { | |
failWebsocketConnection(ws, 'Received different permessage-deflate than the one set.') | |
return | |
} | |
// 6. If the response includes a |Sec-WebSocket-Protocol| header field | |
// and this header field indicates the use of a subprotocol that was | |
// not present in the client's handshake (the server has indicated a | |
// subprotocol not requested by the client), the client MUST _Fail | |
// the WebSocket Connection_. | |
const secProtocol = response.headersList.get('Sec-WebSocket-Protocol') | |
if (secProtocol !== null && secProtocol !== request.headersList.get('Sec-WebSocket-Protocol')) { | |
failWebsocketConnection(ws, 'Protocol was not set in the opening handshake.') | |
return | |
} | |
response.socket.on('data', onSocketData) | |
response.socket.on('close', onSocketClose) | |
response.socket.on('error', onSocketError) | |
if (channels.open.hasSubscribers) { | |
channels.open.publish({ | |
address: response.socket.address(), | |
protocol: secProtocol, | |
extensions: secExtension | |
}) | |
} | |
onEstablish(response) | |
} | |
}) | |
return controller | |
} | |
/** | |
* @param {Buffer} chunk | |
*/ | |
function onSocketData (chunk) { | |
if (!this.ws[kByteParser].write(chunk)) { | |
this.pause() | |
} | |
} | |
/** | |
* @see https://websockets.spec.whatwg.org/#feedback-from-the-protocol | |
* @see https://datatracker.ietf.org/doc/html/rfc6455#section-7.1.4 | |
*/ | |
function onSocketClose () { | |
const { ws } = this | |
// If the TCP connection was closed after the | |
// WebSocket closing handshake was completed, the WebSocket connection | |
// is said to have been closed _cleanly_. | |
const wasClean = ws[kSentClose] && ws[kReceivedClose] | |
let code = 1005 | |
let reason = '' | |
const result = ws[kByteParser].closingInfo | |
if (result) { | |
code = result.code ?? 1005 | |
reason = result.reason | |
} else if (!ws[kSentClose]) { | |
// If _The WebSocket | |
// Connection is Closed_ and no Close control frame was received by the | |
// endpoint (such as could occur if the underlying transport connection | |
// is lost), _The WebSocket Connection Close Code_ is considered to be | |
// 1006. | |
code = 1006 | |
} | |
// 1. Change the ready state to CLOSED (3). | |
ws[kReadyState] = states.CLOSED | |
// 2. If the user agent was required to fail the WebSocket | |
// connection, or if the WebSocket connection was closed | |
// after being flagged as full, fire an event named error | |
// at the WebSocket object. | |
// TODO | |
// 3. Fire an event named close at the WebSocket object, | |
// using CloseEvent, with the wasClean attribute | |
// initialized to true if the connection closed cleanly | |
// and false otherwise, the code attribute initialized to | |
// the WebSocket connection close code, and the reason | |
// attribute initialized to the result of applying UTF-8 | |
// decode without BOM to the WebSocket connection close | |
// reason. | |
fireEvent('close', ws, CloseEvent, { | |
wasClean, code, reason | |
}) | |
if (channels.close.hasSubscribers) { | |
channels.close.publish({ | |
websocket: ws, | |
code, | |
reason | |
}) | |
} | |
} | |
function onSocketError (error) { | |
const { ws } = this | |
ws[kReadyState] = states.CLOSING | |
if (channels.socketError.hasSubscribers) { | |
channels.socketError.publish(error) | |
} | |
this.destroy() | |
} | |
module.exports = { | |
establishWebSocketConnection | |
} | |