• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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