index.js 6.34 KB
'use strict'

const { HttpErrorAuthOTP } = require('./errors.js')
const checkResponse = require('./check-response.js')
const getAuth = require('./auth.js')
const fetch = require('make-fetch-happen')
const JSONStream = require('minipass-json-stream')
const npa = require('npm-package-arg')
const qs = require('querystring')
const url = require('url')
const zlib = require('minizlib')
const Minipass = require('minipass')

const defaultOpts = require('./default-opts.js')

// WhatWG URL throws if it's not fully resolved
const urlIsValid = u => {
  try {
    return !!new url.URL(u)
  } catch (_) {
    return false
  }
}

module.exports = regFetch
function regFetch (uri, /* istanbul ignore next */ opts_ = {}) {
  const opts = {
    ...defaultOpts,
    ...opts_,
  }

  // if we did not get a fully qualified URI, then we look at the registry
  // config or relevant scope to resolve it.
  const uriValid = urlIsValid(uri)
  let registry = opts.registry || defaultOpts.registry
  if (!uriValid) {
    registry = opts.registry = (
      (opts.spec && pickRegistry(opts.spec, opts)) ||
      opts.registry ||
      registry
    )
    uri = `${
      registry.trim().replace(/\/?$/g, '')
    }/${
      uri.trim().replace(/^\//, '')
    }`
    // asserts that this is now valid
    new url.URL(uri)
  }

  const method = opts.method || 'GET'

  // through that takes into account the scope, the prefix of `uri`, etc
  const startTime = Date.now()
  const auth = getAuth(uri, opts)
  const headers = getHeaders(uri, auth, opts)
  let body = opts.body
  const bodyIsStream = Minipass.isStream(body)
  const bodyIsPromise = body &&
    typeof body === 'object' &&
    typeof body.then === 'function'

  if (
    body && !bodyIsStream && !bodyIsPromise && typeof body !== 'string' && !Buffer.isBuffer(body)
  ) {
    headers['content-type'] = headers['content-type'] || 'application/json'
    body = JSON.stringify(body)
  } else if (body && !headers['content-type']) {
    headers['content-type'] = 'application/octet-stream'
  }

  if (opts.gzip) {
    headers['content-encoding'] = 'gzip'
    if (bodyIsStream) {
      const gz = new zlib.Gzip()
      body.on('error', /* istanbul ignore next: unlikely and hard to test */
        err => gz.emit('error', err))
      body = body.pipe(gz)
    } else if (!bodyIsPromise) {
      body = new zlib.Gzip().end(body).concat()
    }
  }

  const parsed = new url.URL(uri)

  if (opts.query) {
    const q = typeof opts.query === 'string' ? qs.parse(opts.query)
      : opts.query

    Object.keys(q).forEach(key => {
      if (q[key] !== undefined) {
        parsed.searchParams.set(key, q[key])
      }
    })
    uri = url.format(parsed)
  }

  if (parsed.searchParams.get('write') === 'true' && method === 'GET') {
    // do not cache, because this GET is fetching a rev that will be
    // used for a subsequent PUT or DELETE, so we need to conditionally
    // update cache.
    opts.offline = false
    opts.preferOffline = false
    opts.preferOnline = true
  }

  const doFetch = async fetchBody => {
    const p = fetch(uri, {
      agent: opts.agent,
      algorithms: opts.algorithms,
      body: fetchBody,
      cache: getCacheMode(opts),
      cachePath: opts.cache,
      ca: opts.ca,
      cert: opts.cert,
      headers,
      integrity: opts.integrity,
      key: opts.key,
      localAddress: opts.localAddress,
      maxSockets: opts.maxSockets,
      memoize: opts.memoize,
      method: method,
      noProxy: opts.noProxy,
      proxy: opts.httpsProxy || opts.proxy,
      retry: opts.retry ? opts.retry : {
        retries: opts.fetchRetries,
        factor: opts.fetchRetryFactor,
        minTimeout: opts.fetchRetryMintimeout,
        maxTimeout: opts.fetchRetryMaxtimeout,
      },
      strictSSL: opts.strictSSL,
      timeout: opts.timeout || 30 * 1000,
    }).then(res => checkResponse({
      method,
      uri,
      res,
      registry,
      startTime,
      auth,
      opts,
    }))

    if (typeof opts.otpPrompt === 'function') {
      return p.catch(async er => {
        if (er instanceof HttpErrorAuthOTP) {
          let otp
          // if otp fails to complete, we fail with that failure
          try {
            otp = await opts.otpPrompt()
          } catch (_) {
            // ignore this error
          }
          // if no otp provided, or otpPrompt errored, throw the original HTTP error
          if (!otp) {
            throw er
          }
          return regFetch(uri, { ...opts, otp })
        }
        throw er
      })
    } else {
      return p
    }
  }

  return Promise.resolve(body).then(doFetch)
}

module.exports.json = fetchJSON
function fetchJSON (uri, opts) {
  return regFetch(uri, opts).then(res => res.json())
}

module.exports.json.stream = fetchJSONStream
function fetchJSONStream (uri, jsonPath,
  /* istanbul ignore next */ opts_ = {}) {
  const opts = { ...defaultOpts, ...opts_ }
  const parser = JSONStream.parse(jsonPath, opts.mapJSON)
  regFetch(uri, opts).then(res =>
    res.body.on('error',
      /* istanbul ignore next: unlikely and difficult to test */
      er => parser.emit('error', er)).pipe(parser)
  ).catch(er => parser.emit('error', er))
  return parser
}

module.exports.pickRegistry = pickRegistry
function pickRegistry (spec, opts = {}) {
  spec = npa(spec)
  let registry = spec.scope &&
    opts[spec.scope.replace(/^@?/, '@') + ':registry']

  if (!registry && opts.scope) {
    registry = opts[opts.scope.replace(/^@?/, '@') + ':registry']
  }

  if (!registry) {
    registry = opts.registry || defaultOpts.registry
  }

  return registry
}

function getCacheMode (opts) {
  return opts.offline ? 'only-if-cached'
    : opts.preferOffline ? 'force-cache'
    : opts.preferOnline ? 'no-cache'
    : 'default'
}

function getHeaders (uri, auth, opts) {
  const headers = Object.assign({
    'user-agent': opts.userAgent,
  }, opts.headers || {})

  if (opts.scope) {
    headers['npm-scope'] = opts.scope
  }

  if (opts.npmSession) {
    headers['npm-session'] = opts.npmSession
  }

  if (opts.npmCommand) {
    headers['npm-command'] = opts.npmCommand
  }

  // If a tarball is hosted on a different place than the manifest, only send
  // credentials on `alwaysAuth`
  if (auth.token) {
    headers.authorization = `Bearer ${auth.token}`
  } else if (auth.auth) {
    headers.authorization = `Basic ${auth.auth}`
  }

  if (opts.otp) {
    headers['npm-otp'] = opts.otp
  }

  return headers
}

module.exports.cleanUrl = require('./clean-url.js')