1'use strict' 2 3const assert = require('assert') 4const { kDestroyed, kBodyUsed } = require('./symbols') 5const { IncomingMessage } = require('http') 6const stream = require('stream') 7const net = require('net') 8const { InvalidArgumentError } = require('./errors') 9const { Blob } = require('buffer') 10const nodeUtil = require('util') 11const { stringify } = require('querystring') 12const { headerNameLowerCasedRecord } = require('./constants') 13 14const [nodeMajor, nodeMinor] = process.versions.node.split('.').map(v => Number(v)) 15 16function nop () {} 17 18function isStream (obj) { 19 return obj && typeof obj === 'object' && typeof obj.pipe === 'function' && typeof obj.on === 'function' 20} 21 22// based on https://github.com/node-fetch/fetch-blob/blob/8ab587d34080de94140b54f07168451e7d0b655e/index.js#L229-L241 (MIT License) 23function isBlobLike (object) { 24 return (Blob && object instanceof Blob) || ( 25 object && 26 typeof object === 'object' && 27 (typeof object.stream === 'function' || 28 typeof object.arrayBuffer === 'function') && 29 /^(Blob|File)$/.test(object[Symbol.toStringTag]) 30 ) 31} 32 33function buildURL (url, queryParams) { 34 if (url.includes('?') || url.includes('#')) { 35 throw new Error('Query params cannot be passed when url already contains "?" or "#".') 36 } 37 38 const stringified = stringify(queryParams) 39 40 if (stringified) { 41 url += '?' + stringified 42 } 43 44 return url 45} 46 47function parseURL (url) { 48 if (typeof url === 'string') { 49 url = new URL(url) 50 51 if (!/^https?:/.test(url.origin || url.protocol)) { 52 throw new InvalidArgumentError('Invalid URL protocol: the URL must start with `http:` or `https:`.') 53 } 54 55 return url 56 } 57 58 if (!url || typeof url !== 'object') { 59 throw new InvalidArgumentError('Invalid URL: The URL argument must be a non-null object.') 60 } 61 62 if (!/^https?:/.test(url.origin || url.protocol)) { 63 throw new InvalidArgumentError('Invalid URL protocol: the URL must start with `http:` or `https:`.') 64 } 65 66 if (!(url instanceof URL)) { 67 if (url.port != null && url.port !== '' && !Number.isFinite(parseInt(url.port))) { 68 throw new InvalidArgumentError('Invalid URL: port must be a valid integer or a string representation of an integer.') 69 } 70 71 if (url.path != null && typeof url.path !== 'string') { 72 throw new InvalidArgumentError('Invalid URL path: the path must be a string or null/undefined.') 73 } 74 75 if (url.pathname != null && typeof url.pathname !== 'string') { 76 throw new InvalidArgumentError('Invalid URL pathname: the pathname must be a string or null/undefined.') 77 } 78 79 if (url.hostname != null && typeof url.hostname !== 'string') { 80 throw new InvalidArgumentError('Invalid URL hostname: the hostname must be a string or null/undefined.') 81 } 82 83 if (url.origin != null && typeof url.origin !== 'string') { 84 throw new InvalidArgumentError('Invalid URL origin: the origin must be a string or null/undefined.') 85 } 86 87 const port = url.port != null 88 ? url.port 89 : (url.protocol === 'https:' ? 443 : 80) 90 let origin = url.origin != null 91 ? url.origin 92 : `${url.protocol}//${url.hostname}:${port}` 93 let path = url.path != null 94 ? url.path 95 : `${url.pathname || ''}${url.search || ''}` 96 97 if (origin.endsWith('/')) { 98 origin = origin.substring(0, origin.length - 1) 99 } 100 101 if (path && !path.startsWith('/')) { 102 path = `/${path}` 103 } 104 // new URL(path, origin) is unsafe when `path` contains an absolute URL 105 // From https://developer.mozilla.org/en-US/docs/Web/API/URL/URL: 106 // If first parameter is a relative URL, second param is required, and will be used as the base URL. 107 // If first parameter is an absolute URL, a given second param will be ignored. 108 url = new URL(origin + path) 109 } 110 111 return url 112} 113 114function parseOrigin (url) { 115 url = parseURL(url) 116 117 if (url.pathname !== '/' || url.search || url.hash) { 118 throw new InvalidArgumentError('invalid url') 119 } 120 121 return url 122} 123 124function getHostname (host) { 125 if (host[0] === '[') { 126 const idx = host.indexOf(']') 127 128 assert(idx !== -1) 129 return host.substring(1, idx) 130 } 131 132 const idx = host.indexOf(':') 133 if (idx === -1) return host 134 135 return host.substring(0, idx) 136} 137 138// IP addresses are not valid server names per RFC6066 139// > Currently, the only server names supported are DNS hostnames 140function getServerName (host) { 141 if (!host) { 142 return null 143 } 144 145 assert.strictEqual(typeof host, 'string') 146 147 const servername = getHostname(host) 148 if (net.isIP(servername)) { 149 return '' 150 } 151 152 return servername 153} 154 155function deepClone (obj) { 156 return JSON.parse(JSON.stringify(obj)) 157} 158 159function isAsyncIterable (obj) { 160 return !!(obj != null && typeof obj[Symbol.asyncIterator] === 'function') 161} 162 163function isIterable (obj) { 164 return !!(obj != null && (typeof obj[Symbol.iterator] === 'function' || typeof obj[Symbol.asyncIterator] === 'function')) 165} 166 167function bodyLength (body) { 168 if (body == null) { 169 return 0 170 } else if (isStream(body)) { 171 const state = body._readableState 172 return state && state.objectMode === false && state.ended === true && Number.isFinite(state.length) 173 ? state.length 174 : null 175 } else if (isBlobLike(body)) { 176 return body.size != null ? body.size : null 177 } else if (isBuffer(body)) { 178 return body.byteLength 179 } 180 181 return null 182} 183 184function isDestroyed (stream) { 185 return !stream || !!(stream.destroyed || stream[kDestroyed]) 186} 187 188function isReadableAborted (stream) { 189 const state = stream && stream._readableState 190 return isDestroyed(stream) && state && !state.endEmitted 191} 192 193function destroy (stream, err) { 194 if (stream == null || !isStream(stream) || isDestroyed(stream)) { 195 return 196 } 197 198 if (typeof stream.destroy === 'function') { 199 if (Object.getPrototypeOf(stream).constructor === IncomingMessage) { 200 // See: https://github.com/nodejs/node/pull/38505/files 201 stream.socket = null 202 } 203 204 stream.destroy(err) 205 } else if (err) { 206 process.nextTick((stream, err) => { 207 stream.emit('error', err) 208 }, stream, err) 209 } 210 211 if (stream.destroyed !== true) { 212 stream[kDestroyed] = true 213 } 214} 215 216const KEEPALIVE_TIMEOUT_EXPR = /timeout=(\d+)/ 217function parseKeepAliveTimeout (val) { 218 const m = val.toString().match(KEEPALIVE_TIMEOUT_EXPR) 219 return m ? parseInt(m[1], 10) * 1000 : null 220} 221 222/** 223 * Retrieves a header name and returns its lowercase value. 224 * @param {string | Buffer} value Header name 225 * @returns {string} 226 */ 227function headerNameToString (value) { 228 return headerNameLowerCasedRecord[value] || value.toLowerCase() 229} 230 231function parseHeaders (headers, obj = {}) { 232 // For H2 support 233 if (!Array.isArray(headers)) return headers 234 235 for (let i = 0; i < headers.length; i += 2) { 236 const key = headers[i].toString().toLowerCase() 237 let val = obj[key] 238 239 if (!val) { 240 if (Array.isArray(headers[i + 1])) { 241 obj[key] = headers[i + 1].map(x => x.toString('utf8')) 242 } else { 243 obj[key] = headers[i + 1].toString('utf8') 244 } 245 } else { 246 if (!Array.isArray(val)) { 247 val = [val] 248 obj[key] = val 249 } 250 val.push(headers[i + 1].toString('utf8')) 251 } 252 } 253 254 // See https://github.com/nodejs/node/pull/46528 255 if ('content-length' in obj && 'content-disposition' in obj) { 256 obj['content-disposition'] = Buffer.from(obj['content-disposition']).toString('latin1') 257 } 258 259 return obj 260} 261 262function parseRawHeaders (headers) { 263 const ret = [] 264 let hasContentLength = false 265 let contentDispositionIdx = -1 266 267 for (let n = 0; n < headers.length; n += 2) { 268 const key = headers[n + 0].toString() 269 const val = headers[n + 1].toString('utf8') 270 271 if (key.length === 14 && (key === 'content-length' || key.toLowerCase() === 'content-length')) { 272 ret.push(key, val) 273 hasContentLength = true 274 } else if (key.length === 19 && (key === 'content-disposition' || key.toLowerCase() === 'content-disposition')) { 275 contentDispositionIdx = ret.push(key, val) - 1 276 } else { 277 ret.push(key, val) 278 } 279 } 280 281 // See https://github.com/nodejs/node/pull/46528 282 if (hasContentLength && contentDispositionIdx !== -1) { 283 ret[contentDispositionIdx] = Buffer.from(ret[contentDispositionIdx]).toString('latin1') 284 } 285 286 return ret 287} 288 289function isBuffer (buffer) { 290 // See, https://github.com/mcollina/undici/pull/319 291 return buffer instanceof Uint8Array || Buffer.isBuffer(buffer) 292} 293 294function validateHandler (handler, method, upgrade) { 295 if (!handler || typeof handler !== 'object') { 296 throw new InvalidArgumentError('handler must be an object') 297 } 298 299 if (typeof handler.onConnect !== 'function') { 300 throw new InvalidArgumentError('invalid onConnect method') 301 } 302 303 if (typeof handler.onError !== 'function') { 304 throw new InvalidArgumentError('invalid onError method') 305 } 306 307 if (typeof handler.onBodySent !== 'function' && handler.onBodySent !== undefined) { 308 throw new InvalidArgumentError('invalid onBodySent method') 309 } 310 311 if (upgrade || method === 'CONNECT') { 312 if (typeof handler.onUpgrade !== 'function') { 313 throw new InvalidArgumentError('invalid onUpgrade method') 314 } 315 } else { 316 if (typeof handler.onHeaders !== 'function') { 317 throw new InvalidArgumentError('invalid onHeaders method') 318 } 319 320 if (typeof handler.onData !== 'function') { 321 throw new InvalidArgumentError('invalid onData method') 322 } 323 324 if (typeof handler.onComplete !== 'function') { 325 throw new InvalidArgumentError('invalid onComplete method') 326 } 327 } 328} 329 330// A body is disturbed if it has been read from and it cannot 331// be re-used without losing state or data. 332function isDisturbed (body) { 333 return !!(body && ( 334 stream.isDisturbed 335 ? stream.isDisturbed(body) || body[kBodyUsed] // TODO (fix): Why is body[kBodyUsed] needed? 336 : body[kBodyUsed] || 337 body.readableDidRead || 338 (body._readableState && body._readableState.dataEmitted) || 339 isReadableAborted(body) 340 )) 341} 342 343function isErrored (body) { 344 return !!(body && ( 345 stream.isErrored 346 ? stream.isErrored(body) 347 : /state: 'errored'/.test(nodeUtil.inspect(body) 348 ))) 349} 350 351function isReadable (body) { 352 return !!(body && ( 353 stream.isReadable 354 ? stream.isReadable(body) 355 : /state: 'readable'/.test(nodeUtil.inspect(body) 356 ))) 357} 358 359function getSocketInfo (socket) { 360 return { 361 localAddress: socket.localAddress, 362 localPort: socket.localPort, 363 remoteAddress: socket.remoteAddress, 364 remotePort: socket.remotePort, 365 remoteFamily: socket.remoteFamily, 366 timeout: socket.timeout, 367 bytesWritten: socket.bytesWritten, 368 bytesRead: socket.bytesRead 369 } 370} 371 372async function * convertIterableToBuffer (iterable) { 373 for await (const chunk of iterable) { 374 yield Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk) 375 } 376} 377 378let ReadableStream 379function ReadableStreamFrom (iterable) { 380 if (!ReadableStream) { 381 ReadableStream = require('stream/web').ReadableStream 382 } 383 384 if (ReadableStream.from) { 385 return ReadableStream.from(convertIterableToBuffer(iterable)) 386 } 387 388 let iterator 389 return new ReadableStream( 390 { 391 async start () { 392 iterator = iterable[Symbol.asyncIterator]() 393 }, 394 async pull (controller) { 395 const { done, value } = await iterator.next() 396 if (done) { 397 queueMicrotask(() => { 398 controller.close() 399 }) 400 } else { 401 const buf = Buffer.isBuffer(value) ? value : Buffer.from(value) 402 controller.enqueue(new Uint8Array(buf)) 403 } 404 return controller.desiredSize > 0 405 }, 406 async cancel (reason) { 407 await iterator.return() 408 } 409 }, 410 0 411 ) 412} 413 414// The chunk should be a FormData instance and contains 415// all the required methods. 416function isFormDataLike (object) { 417 return ( 418 object && 419 typeof object === 'object' && 420 typeof object.append === 'function' && 421 typeof object.delete === 'function' && 422 typeof object.get === 'function' && 423 typeof object.getAll === 'function' && 424 typeof object.has === 'function' && 425 typeof object.set === 'function' && 426 object[Symbol.toStringTag] === 'FormData' 427 ) 428} 429 430function throwIfAborted (signal) { 431 if (!signal) { return } 432 if (typeof signal.throwIfAborted === 'function') { 433 signal.throwIfAborted() 434 } else { 435 if (signal.aborted) { 436 // DOMException not available < v17.0.0 437 const err = new Error('The operation was aborted') 438 err.name = 'AbortError' 439 throw err 440 } 441 } 442} 443 444function addAbortListener (signal, listener) { 445 if ('addEventListener' in signal) { 446 signal.addEventListener('abort', listener, { once: true }) 447 return () => signal.removeEventListener('abort', listener) 448 } 449 signal.addListener('abort', listener) 450 return () => signal.removeListener('abort', listener) 451} 452 453const hasToWellFormed = !!String.prototype.toWellFormed 454 455/** 456 * @param {string} val 457 */ 458function toUSVString (val) { 459 if (hasToWellFormed) { 460 return `${val}`.toWellFormed() 461 } else if (nodeUtil.toUSVString) { 462 return nodeUtil.toUSVString(val) 463 } 464 465 return `${val}` 466} 467 468// Parsed accordingly to RFC 9110 469// https://www.rfc-editor.org/rfc/rfc9110#field.content-range 470function parseRangeHeader (range) { 471 if (range == null || range === '') return { start: 0, end: null, size: null } 472 473 const m = range ? range.match(/^bytes (\d+)-(\d+)\/(\d+)?$/) : null 474 return m 475 ? { 476 start: parseInt(m[1]), 477 end: m[2] ? parseInt(m[2]) : null, 478 size: m[3] ? parseInt(m[3]) : null 479 } 480 : null 481} 482 483const kEnumerableProperty = Object.create(null) 484kEnumerableProperty.enumerable = true 485 486module.exports = { 487 kEnumerableProperty, 488 nop, 489 isDisturbed, 490 isErrored, 491 isReadable, 492 toUSVString, 493 isReadableAborted, 494 isBlobLike, 495 parseOrigin, 496 parseURL, 497 getServerName, 498 isStream, 499 isIterable, 500 isAsyncIterable, 501 isDestroyed, 502 headerNameToString, 503 parseRawHeaders, 504 parseHeaders, 505 parseKeepAliveTimeout, 506 destroy, 507 bodyLength, 508 deepClone, 509 ReadableStreamFrom, 510 isBuffer, 511 validateHandler, 512 getSocketInfo, 513 isFormDataLike, 514 buildURL, 515 throwIfAborted, 516 addAbortListener, 517 parseRangeHeader, 518 nodeMajor, 519 nodeMinor, 520 nodeHasAutoSelectFamily: nodeMajor > 18 || (nodeMajor === 18 && nodeMinor >= 13), 521 safeHTTPMethods: ['GET', 'HEAD', 'OPTIONS', 'TRACE'] 522} 523