Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

38 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

x-fetch

GitHub Actions Workflow Status Codecov type-coverage CodeRabbit Pull Request Reviews npm GitHub Release

Conventional Commits Renovate enabled JavaScript Style Guide Code Style: Prettier changesets

A simple but elegant fetch API wrapper with less than 830B minified and brotlied, use fetch like a charm

TOC

Usage

Install

# npm
npm i x-fetch

# yarn
yarn add x-fetch

# pnpm
pnpm add x-fetch

# bun
bun add x-fetch

API

import {
  HttpMethod,
  createXFetch,
  isXFetchError,
  xfetch,
  middlewares,
  type XFetchMiddleware,
} from 'x-fetch'

// plain url, GET method, returns JSON
await xfetch('url')

// with options — method, body, query, headers, etc.
await xfetch('url', {
  method: HttpMethod.POST, // or 'POST'
  // plain object or array (auto JSON-stringified), or BodyInit
  body: {},
  // URLSearchParams-compatible query object
  query: {
    key: 'value',
  },
  // type: 'arrayBuffer' | 'blob' | 'json' | 'text' | null, // defaults to 'json'
})

// --- Middlewares ---

const middleware: XFetchMiddleware = async (ctx, next) => {
  // mutate ctx before the request
  ctx.headers.set('Authorization', 'Bearer token')
  const res = await next() // same as next(ctx)
  // mutate / inspect the response
  return res
}

// global — applies to all xfetch calls
middlewares.use(middleware)
middlewares.eject(middleware)

// per-call — merged with global middlewares (global → per-call → fetch)
await xfetch('url', { middlewares: [middleware] })

// ctx is mutable — middleware can override type, url, etc.
const forceText: XFetchMiddleware = async (ctx, next) => {
  ctx.type = 'text'
  return next()
}

// --- Common recipes ---

// Base URL
const withBaseUrl: XFetchMiddleware = async (ctx, next) => {
  ctx.url = new URL(ctx.url, 'https://api.example.com').href
  return next()
}

// Retry
const retry: XFetchMiddleware = async (ctx, next) => {
  try {
    return await next()
  } catch {
    return next() // reuses ctx, mutations from failed attempt persist
  }
}

// Retry with snapshot — restore original context on retry
const retryWithSnapshot: XFetchMiddleware = async (ctx, next) => {
  const snapshot = { ...ctx, headers: new Headers(ctx.headers) }
  try {
    return await next()
  } catch {
    return next(snapshot) // fresh context, no leaked mutations
  }
}

// Timeout
const withTimeout: XFetchMiddleware = async (ctx, next) => {
  ctx.signal = AbortSignal.timeout(5000)
  return next()
}

// Cache
const cache = new Map<string, Response>()
const withCache: XFetchMiddleware = async (ctx, next) => {
  const key = ctx.url
  if (cache.has(key)) {
    return cache.get(key)!.clone()
  }
  const res = await next()
  cache.set(key, res.clone())
  return res
}

// Upload / download progress — extend context with custom callbacks
declare module 'x-fetch' {
  interface XFetchMiddlewareContext {
    onUploadProgress?: (loaded: number, total: number) => void
    onDownloadProgress?: (loaded: number, total: number) => void
  }
}

const trackProgress = (
  reader: ReadableStreamDefaultReader<Uint8Array>,
  total: number,
  onProgress: (loaded: number, total: number) => void,
) =>
  new ReadableStream({
    start(controller) {
      let loaded = 0
      function pump() {
        reader.read().then(
          ({ done, value }) => {
            if (done) {
              controller.close()
              return
            }
            loaded += value!.length
            onProgress(loaded, total)
            controller.enqueue(value!)
            pump()
          },
          err => controller.error(err),
        )
      }
      pump()
    },
  })

const withUploadProgress: XFetchMiddleware = async (ctx, next) => {
  if (ctx.onUploadProgress && ctx.body instanceof ReadableStream) {
    const total = Number(ctx.headers.get('Content-Length') || 0)
    ctx.body = trackProgress(ctx.body.getReader(), total, ctx.onUploadProgress)
  }
  return next()
}

const withDownloadProgress: XFetchMiddleware = async (ctx, next) => {
  if (!ctx.onDownloadProgress) {
    return next()
  }
  const res = await next()
  const reader = res.clone().body?.getReader()
  if (!reader) {
    return res
  }
  const total = Number(res.headers.get('Content-Length') || 0)
  return new Response(trackProgress(reader, total, ctx.onDownloadProgress), res)
}

await xfetch('/api', {
  onUploadProgress: (loaded, total) => console.log(`↑ ${loaded}/${total}`),
  middlewares: [withUploadProgress],
})

// XHR-based fetch — native progress via init callbacks
declare global {
  interface RequestInit {
    onUploadProgress?: (loaded: number, total: number) => void
    onDownloadProgress?: (loaded: number, total: number) => void
  }
}

const xhrFetch: typeof fetch = (input, init) =>
  new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest()
    const url = input instanceof Request ? input.url : String(input)
    xhr.open(init?.method || 'GET', url)
    new Headers(init?.headers).forEach((v, k) => xhr.setRequestHeader(k, v))
    const { onUploadProgress, onDownloadProgress, signal, ...rest } = init || {}
    xhr.upload.onprogress = e => onUploadProgress?.(e.loaded, e.total)
    xhr.onprogress = e => onDownloadProgress?.(e.loaded, e.total)
    signal?.addEventListener('abort', () => xhr.abort())
    xhr.onload = () =>
      resolve(
        new Response(xhr.response, {
          status: xhr.status,
          statusText: xhr.statusText,
        }),
      )
    xhr.onerror = () => reject(new Error('XHR failed'))
    xhr.send((rest as RequestInit).body as XMLHttpRequestBodyInit)
  })

const { xfetch } = createXFetch(xhrFetch)

await xfetch('/file', {
  onUploadProgress: (loaded, total) => console.log(`↑ ${loaded}/${total}`),
  onDownloadProgress: (loaded, total) => console.log(`↓ ${loaded}/${total}`),
})

// or — middleware that bypasses next() for native XHR progress events
const withXhrProgress: XFetchMiddleware = (ctx, next) =>
  !ctx.onUploadProgress && !ctx.onDownloadProgress
    ? next()
    : new Promise((resolve, reject) => {
        const xhr = new XMLHttpRequest()
        xhr.open(ctx.method, ctx.url)
        ctx.headers.forEach((v, k) => xhr.setRequestHeader(k, v))
        xhr.upload.onprogress = e => ctx.onUploadProgress?.(e.loaded, e.total)
        xhr.onprogress = e => ctx.onDownloadProgress?.(e.loaded, e.total)
        xhr.onload = () =>
          resolve(
            new Response(xhr.response, {
              status: xhr.status,
              statusText: xhr.statusText,
            }),
          )
        xhr.onerror = () => reject(new Error('XHR failed'))
        xhr.send(ctx.body as XMLHttpRequestBodyInit)
      })

await xfetch('/upload', {
  onUploadProgress: (loaded, total) => console.log(`${loaded}/${total}`),
  middlewares: [retry, withXhrProgress], // withXhrProgress last — innermost
})

// next() always throws XFetchError, inspect and handle in middleware
const withFallback: XFetchMiddleware = async (ctx, next) => {
  try {
    return await next()
  } catch (error) {
    if (isXFetchError(error) && error.response?.status === 404) {
      return new Response('{"error":"not found"}', { status: 200 })
    }
    throw error
  }
}

// call site — non-HTTP errors are also wrapped as XFetchError
try {
  await xfetch('url')
} catch (error) {
  if (isXFetchError(error)) {
    console.error(error.message, error.response?.status, error.data)
  }
}

// --- Isolated instance ---

const { xfetch, middlewares } = createXFetch() // or createXFetch(customFetch)

Migration Guides

From axios

// axios
const { data } = await axios.get('/users', { params: { page: 1 } })
await axios.post('/users', { name: 'John' })

// x-fetch
const data = await xfetch('/users?page=1')
await xfetch('/users', { method: 'POST', body: { name: 'John' } })
axios x-fetch
axios.create({ baseURL }) middlewares.use(withBaseUrl)
axios.interceptors.request.use() middlewares.use()
axios.interceptors.response.use() middlewares.use()
error.response?.status error.response?.status

From ky

// ky
await ky.get('/users', { searchParams: { page: 1 } }).json()
await ky.post('/users', { json: { name: 'John' } })

// x-fetch
await xfetch('/users', { query: { page: 1 } })
await xfetch('/users', { method: 'POST', body: { name: 'John' } })
ky x-fetch
ky.create({ prefixUrl }) middlewares.use(withBaseUrl)
ky.extend({ hooks }) createXFetch() with middlewares.use()
HTTPError XFetchError

From ofetch

// ofetch
await $fetch('/users', { query: { page: 1 } })
const api = $fetch.create({ baseURL: 'https://api.example.com' })

// x-fetch
await xfetch('/users', { query: { page: 1 } })
const { xfetch, middlewares } = createXFetch()
middlewares.use(withBaseUrl)
await xfetch('/users', { query: { page: 1 } })
ofetch x-fetch
$fetch.create({ baseURL }) middlewares.use(withBaseUrl)
$fetch.create({ onRequest }) middlewares.use()
$fetch.create({ onResponse }) middlewares.use()
$fetch.create({ onResponseError }) try/catch in middleware

Sponsors and Backers

Sponsors and Backers

Sponsors

1stG RxTS UnTS
1stG Open Collective sponsors RxTS Open Collective sponsors UnTS Open Collective sponsors

Backers

1stG RxTS UnTS
1stG Open Collective backers RxTS Open Collective backers UnTS Open Collective backers

Changelog

Detailed changes for each release are documented in CHANGELOG.md.

License

MIT © JounQin@1stG.me

About

A simple but elegant `fetch` API wrapper, use `fetch` like a charm

Topics

Resources

Security policy

Stars

6 stars

Watchers

0 watching

Forks

Releases

Sponsor this project

Packages

Used by

Contributors

Languages

Generated from un-ts/lib-boilerplate