developit / unfetch

🐕 Bare minimum 500b fetch polyfill.
https://npm.im/unfetch
MIT License
5.7k stars 201 forks source link

The second alternative to AbortController support (#54, #68, #137) #148

Open eugeneilyin opened 3 years ago

eugeneilyin commented 3 years ago

Resolves #54 Alternative to #68, and #137

This solution

Not sure about 500 bytes of the bundle size, but should be very close to it :wink:

The code (abortable-unfetch.mjs)

export default function (url, { method, headers, credentials, body, signal } = {}) {
  return new Promise((resolve, reject) => {
    const abortError = () => {
      try {
        return new DOMException('Aborted', 'AbortError')
      } catch (error) { /* the DOMException constructor is not supported */
        const abortError = new Error('Aborted')
        abortError.name = 'AbortError'
        return abortError
      }
    }

    if (signal && signal.aborted) {
      reject(abortError())
      return
    }

    const request = new XMLHttpRequest()
    const keys = []
    const all = []
    const respHeaders = {}

    const response = () => ({
      ok: (request.status / 100 | 0) === 2, // 200-299
      statusText: request.statusText,
      status: request.status,
      url: request.responseURL,
      text: () => Promise.resolve(request.responseText),
      json: () => Promise.resolve(request.responseText).then(JSON.parse),
      blob: () => Promise.resolve(new Blob([request.response])),
      clone: response,
      headers: {
        keys: () => keys,
        entries: () => all,
        get: n => respHeaders[n.toLowerCase()],
        has: n => n.toLowerCase() in respHeaders,
      },
    })

    request.open(method || 'get', url, true)

    request.onload = () => {
      request.getAllResponseHeaders().
        replace(/^(.*?):[^\S\n]*([\s\S]*?)$/gm,
          (m, key, value) => {
            keys.push(key = key.toLowerCase())
            all.push([key, value])
            respHeaders[key] = respHeaders[key]
              ? `${respHeaders[key]},${value}`
              : value
          })
      resolve(response())
    }

    if (signal) {
      const abortListener = () => request.abort()
      signal.addEventListener('abort', abortListener)
      request.onreadystatechange = () => {
        if (request.readyState === 4) { /* DONE_STATE = 4 */
          signal.removeEventListener('abort', abortListener)
        }
      }
    }
    request.onabort = () => reject(abortError())
    request.onerror = reject

    request.withCredentials = credentials === 'same-origin' || credentials === 'include'

    for (const i in headers) {
      request.setRequestHeader(i, headers[i])
    }

    request.send(body || null)
  })
}

The polyfill (abortable-unfetch-polyfill.mjs)

import abortableUnfetch from './abortable-unfetch'

const g =
  typeof self !== 'undefined' ? self :
    typeof window !== 'undefined' ? window :
      typeof global !== 'undefined' ? global :
        undefined

if (g) {
  if (typeof g.fetch === 'undefined') {
    g.fetch = abortableUnfetch
  }
}

AbortController polyfills

There are two alternatives to polyfill the standard AbortController behaviour on the old browsers (like IE11):

For the minimum bundle size I prefer the first one and because we based on the standard AbortController API we do not need to polyfill fetch additionally:

npm i abortcontroller-polyfill
yarn add abortcontroller-polyfill
pnpm add abortcontroller-polyfill

Then somewhere in your index.mjs:

import 'abortcontroller-polyfill/dist/abortcontroller-polyfill-only'
import './abortable-unfetch-polyfill'

Do not forget to exclude transpiled polyfill code from the Babel transpile: exclude: [ "node_modules/**" ], or exclude: [ "node_modules/abortcontroller-polyfill/**" ],

Usage example

let abortController

// abort can be called as many times as you want (it run only ones)
const abort = () => abortController && abortController.abort()

const doFetch = () => {

  abort() // abort the previous / ongoing call
  abortController = new AbortController()

  fetch('http://api.plos.org/search?q=title:DNA',
    { credentials: 'same-origin', signal: abortController.signal }).
    then(response => {
      if (!response.ok) {
        throw new Error(`${response.status} ${response.statusText}`)
      }
      return response.json()
    }).
    then(data => {
      console.log('REQUEST FINISHED')
      console.dir(data)
    }).
    catch(error => {
      if (error.name === 'AbortError') {
        console.log('REQUEST ABORTED')
      } else {
        console.error(`REQUEST FAILED: ${error ? error.message : ''}`)
      }

      /* The alternative way to distinct the AbortError */
      if (abortController.signal.aborted) {
        console.log('REQUEST ABORTED')
      } else {
        console.error(`REQUEST FAILED: ${error ? error.message : ''}`)
      }
    })
}

doFetch()
setTimeout(abort, 5000)

P.S. @developit, @prk3, @simonbuerger, @prabirshrestha PR, tests and discussion are welcome :smile: