npm.io
9.3.0 • Published 3d ago

get-it

Licence
MIT
Version
9.3.0
Deps
1
Size
497 kB
Vulns
0
Weekly
0
Stars
37

get-it

npm stat npm version gzip size size

Generic HTTP request library for node.js (>= 22.12), browsers, and edge runtimes. Built on fetch().

Features

  • Promise-based API with full TypeScript support
  • Automatic JSON serialization/deserialization
  • Base URL and default headers
  • HTTP error throwing (on by default)
  • Timeout via AbortSignal.timeout()
  • Cancellation via standard AbortController
  • Proxy support in Node.js, Bun, and Deno (reads HTTP_PROXY/HTTPS_PROXY from environment)
  • Middleware system for retry, debug logging, and custom logic
  • Injectable fetch for testing and custom transports
  • Built-in mock fetch with request matching, recording, and vitest matchers
  • Works in Node.js, browsers, Deno, Bun, and edge runtimes

Installation

npm install get-it

Usage

import {createRequester} from 'get-it'

const request = createRequester({
  base: 'https://api.example.com',
  headers: {Authorization: 'Bearer ...'},
})

// Simple GET
const res = await request('/users')
console.log(res.json())

// POST with JSON body (auto-serialized)
const res = await request({
  url: '/users',
  method: 'POST',
  body: {name: 'Espen'},
  as: 'json',
})
console.log(res.body) // parsed JSON

Response

The response object depends on the as option:

as value body type Buffered?
(omitted) Uint8Array + .json(), .text(), .bytes() yes
'json' unknown (or generic T) yes
'text' string yes
'stream' ReadableStream<Uint8Array> no
// Default — buffered with convenience methods
const res = await request('/data')
res.status // number
res.statusText // string
res.headers // Headers
res.body // Uint8Array
res.json() // parse as JSON (synchronous)
res.text() // decode as string (synchronous)

// Typed JSON
const res = await request<User[]>({url: '/users', as: 'json'})
res.body // User[]

// Streaming
const res = await request({url: '/large-file', as: 'stream'})
res.body // ReadableStream<Uint8Array>

Options

Instance options (createRequester)
Option Type Default Description
base string Base URL prepended to relative paths
headers FetchHeaders Default headers for all requests
httpErrors boolean true Throw HttpError on status >= 400
timeout number | false | TimeoutOptions Timeout in ms, or {total, headers} — see Timeouts
fetch FetchFunction globalThis.fetch Custom fetch implementation
middleware Array [] Transform and wrapping middleware
Per-request options
Option Type Description
url string Request URL (required)
method string HTTP method
body unknown Request body (objects auto-serialized as JSON)
headers FetchHeaders Merged with instance headers
query Record<string, string | number | boolean | undefined> URL query parameters
as 'json' | 'text' | 'stream' Response body type
signal AbortSignal Cancellation signal
httpErrors boolean Override instance setting
timeout number | false | TimeoutOptions Override instance timeout (replaces it wholesale)
fetch FetchFunction Override instance fetch
redirect 'error' | 'follow' | 'manual' Redirect strategy ('manual' is opaque in browsers - see note)
credentials 'include' | 'omit' | 'same-origin' Credentials mode (browser-only)

Note on redirect: 'manual': In browsers this yields an opaque-redirect response (status 0, empty headers) per the Fetch spec, so the 3xx status and headers (e.g. location) are unreadable. Reading them neither throws nor warns - headers.get() returns null and iteration is empty - so detect the case via status === 0. Non-browser runtimes (Node.js, Bun, Deno, edge runtimes, workers) return the real 3xx response, so its status and headers are readable.

Timeouts

timeout accepts a total deadline in milliseconds, false to disable, or a structured object:

const request = createRequester({
  timeout: {
    total: 120_000, // total deadline, request start through body (default 120 000)
    headers: 15_000, // max time to receive response headers, per attempt (disabled by default)
  },
})
  • total — the existing deadline. Covers everything, including the body stream in as: 'stream' mode. Rejects with a TimeoutError DOMException, which the retry() middleware never retries. When combined with the retry() middleware, the deadline applies per attempt - each retry gets a fresh total timer.
  • headers — time to receive response headers for one fetch attempt. Does not cover body download. Rejects with get-it's TimeoutError (code: 'ETIMEDOUT', phase: 'headers'), which the default retry() middleware retries on GET/HEAD. Because the timer lives inside the middleware chain, each retry attempt gets a fresh timer — no middleware ordering requirements.

For long-running streaming downloads, disable the total deadline and keep a headers timeout:

import {createRequester} from 'get-it'
import {retry} from 'get-it/middleware'

const request = createRequester({
  middleware: [retry()],
  timeout: {headers: 15_000, total: false},
})
const res = await request({url: 'https://example.com/big-file', as: 'stream'})

Error handling

import {HttpError} from 'get-it'

try {
  await request('/not-found')
} catch (err) {
  if (err instanceof HttpError) {
    console.log(err.status) // 404
    console.log(err.response) // full response object
  }
}

// Disable for a single request
const res = await request({url: '/maybe-404', httpErrors: false})

Cancellation

const controller = new AbortController()
const promise = request({url: '/slow', signal: controller.signal})
controller.abort()

Timeout and user-provided signals are combined automatically with AbortSignal.any().

Middleware

Two types of middleware, passed in the middleware array:

Transform middleware (object) — flat pipeline, invisible in stack traces:

const addHeader: TransformMiddleware = {
  beforeRequest(options) {
    return {
      ...options,
      headers: {...options.headers, 'x-custom': 'value'},
    }
  },
}

Wrapping middleware (function) — wraps the fetch call, appears in stack traces:

const logger: WrappingMiddleware = async (options, next) => {
  console.log('fetching', options.url)
  const response = await next(options)
  console.log('done', response.status)
  return response
}
Built-in middleware
import {retry, debug} from 'get-it/middleware'

const request = createRequester({
  middleware: [retry({maxRetries: 3}), debug({log: console.log, verbose: true})],
})

Runtime proxy support

In Node.js and Bun, createRequester automatically uses an undici-based fetch that reads proxy configuration from environment variables.

In Deno, createRequester uses Deno's built-in fetch, which also reads HTTP_PROXY, HTTPS_PROXY, and NO_PROXY.

For custom proxy or connection pool settings:

import {createRequester} from 'get-it'
import {createNodeFetch} from 'get-it/node'

const request = createRequester({
  fetch: createNodeFetch({
    proxy: 'http://proxy:8080',
    connections: 30,
    allowH2: true,
  }),
})

For explicit per-client proxy settings, custom CA certificates, or HTTP/2 settings in Deno, create a Deno HttpClient and inject a custom fetch:

const client = Deno.createHttpClient({
  proxy: {url: 'http://proxy:8080'},
  http2: true,
})

const request = createRequester({
  fetch: (url, init) => fetch(url, {...init, client}),
})

Testing

get-it/mock provides a mock fetch for testing code that uses get-it. No network, no global patching — just inject mock.fetch where you'd normally pass fetch.

import {createRequester} from 'get-it'
import {createMockFetch, objectContaining} from 'get-it/mock'

const mock = createMockFetch()
const request = createRequester({fetch: mock.fetch, base: 'https://api.example.com'})

// Register handlers — responses are one-shot by default
mock.on('GET', '/api/docs', {query: {limit: '10'}}).respond({status: 200, body: {results: []}})

mock
  .on('POST', '/api/docs', {body: objectContaining({_type: 'post'})})
  .respond({status: 201, body: {id: 'abc'}})

const res = await request({url: '/api/docs', query: {limit: 10}, as: 'json'})
// res.body → {results: []}
Request matching

Requests are matched strictly by default — method, URL path, query parameters, and body must all match exactly. For looser matching, use the built-in matchers:

import {
  objectContaining,
  arrayContaining,
  stringMatching,
  anyValue,
  queryContaining,
} from 'get-it/mock'

mock
  .on('POST', '/api/docs', {
    body: objectContaining({_type: 'post', title: stringMatching(/^Hello/)}),
  })
  .respond({status: 201, body: {id: 'abc'}})
  • objectContaining(subset) - matches an object that contains at least the given keys/values
  • arrayContaining(items) - matches an array that contains at least the given items
  • stringMatching(pattern) - matches a string against a regex or substring
  • anyValue() - matches any value
  • queryContaining(subset) - matches a query object partially, coercing expected number/boolean values to strings (since query params are always strings)

These implement the asymmetricMatch protocol, so vitest's expect.objectContaining() and friends work too.

URL matching supports exact strings, glob patterns (/api/docs/*/revisions), and function predicates:

mock.on('GET', '/api/docs/*/revisions').respond({status: 200, body: []})
mock.on('GET', (url) => url.startsWith('/api/')).respond({status: 200, body: 'ok'})
Response sequences

Chain .respond() for ordered sequences (useful for testing retries):

mock
  .on('GET', '/api/flaky')
  .respond({status: 500, body: 'error'})
  .respond({status: 200, body: 'ok'})

// First call → 500, second call → 200, third call → throws

Use .respondPersist() for handlers that should match indefinitely.

Delayed responses

Use the delay option to simulate server response time (in milliseconds):

mock.on('GET', '/slow').respond({status: 200, body: {ok: true}, delay: 100})

The request is treated as sent immediately; the response resolves after delay ms. If the request is aborted before the delay elapses - via an AbortController signal or a get-it timeout - it rejects with the signal's reason and the pending timer is cleared.

Streaming response bodies

streamBody() declares a body delivered in chunks, with optional pauses, a stall, or a mid-download error. A fresh stream is built per consumption, so it works with respondPersist. delay still controls time-to-headers; the script controls the body after that.

import {createMockFetch, streamBody, streamDelay, streamStall} from 'get-it/mock'

const mock = createMockFetch()
mock.on('GET', '/backup').respond({
  status: 200,
  body: streamBody('partial', streamDelay(1000), 'done'),
})

// A download that stalls forever after the first chunk:
const stalled = streamBody('partial', streamStall())
mock.on('GET', '/stuck').respond({body: stalled})

// ...after the consumer cancels the body (e.g. its read timeout fired):
expect(stalled).toHaveBeenCancelled() // matcher from 'get-it/vitest'
  • streamDelay(ms) pauses between chunks; streamStall() never closes the body (ends only via consumer cancel or signal abort); streamError(err) errors the stream mid-download.
  • Aborting the request signal errors the body with the abort reason, matching real fetch behavior.
  • Buffered reads (no as, or text()/arrayBuffer()) drain the script with the same timing, so total-deadline timeout behavior is testable too.
  • The streamBody() return value is the observability handle: cancelCount and lastCancelReason, or assert with expect(body).toHaveBeenCancelled().
Scoped mocks

When your code talks to multiple hosts, use mock.scope() to assert the right requests go to the right place:

const mock = createMockFetch()
const api = mock.scope('https://abc123.api.sanity.io')
const cdn = mock.scope('https://abc123.apicdn.sanity.io')

api.on('POST', '/v1/data/mutate/prod').respond({status: 200, body: {transactionId: 'tx1'}})
cdn.on('GET', '/v1/data/query/prod').respond({status: 200, body: {result: []}})

const request = createRequester({fetch: mock.fetch})
await request({url: 'https://abc123.apicdn.sanity.io/v1/data/query/prod', as: 'json'})

// Each scope only sees its own traffic
cdn.getRequests() // 1 request
api.getRequests() // 0 requests
mock.getRequests() // all requests

You can also pass full URLs directly without scopes for one-offs:

mock.on('GET', 'https://api.sanity.io/v1/projects').respond({status: 200, body: []})

Handlers registered without an origin (plain paths like /api/docs) match requests to any host.

Unmatched requests

Any request that doesn't match a registered handler throws a MockFetchError with the closest matching handler and a field-level diff:

MockFetchError: No mock matched POST /api/documents?limit=10

  Closest mock:
    POST /api/documents?limit=20

  Differences:
    query.limit: expected "20", received "10"
Request inspection

Every request is recorded for later inspection:

await request({url: '/api/docs', body: {title: 'Hello'}, method: 'POST'})

const reqs = mock.getRequests()
reqs[0].method // 'POST'
reqs[0].url // '/api/docs'
reqs[0].body // {title: 'Hello'}
reqs[0].headers // Headers
Lifecycle
afterEach(() => {
  mock.assertAllConsumed() // fail if registered responses weren't used
  mock.clear()
})
Vitest matchers

get-it/vitest adds custom matchers to vitest's expect:

// In your test setup file or vitest.config setupFiles
import 'get-it/vitest'
// Assert requests were made
expect(mock).toHaveReceivedRequest('POST', '/api/docs', {
  body: objectContaining({_type: 'post'}),
})
expect(mock).toHaveReceivedRequestTimes('GET', '/api/docs', 2)
expect(mock).toHaveConsumedAllMocks()

// Assert a streamBody() response body was cancelled by the consumer
expect(scriptedBody).toHaveBeenCancelled()

// Assert on individual recorded requests
const req = mock.getRequests()[0]
expect(req).toHaveHeader('authorization', 'Bearer token123')
expect(req).toHaveBody(objectContaining({_type: 'post'}))
expect(req).toHaveQuery({limit: '10'})
expect(req).toHaveMethod('POST')
expect(req).toHaveUrl('/api/docs')

Entry points

Import Purpose
get-it Core (auto-selects Node variant via conditional exports)
get-it/middleware retry, debug, isRetryableRequest, getRetryDelay
get-it/node createNodeFetch() for custom undici dispatcher config
get-it/mock createMockFetch() and matchers for testing
get-it/vitest Custom vitest matchers for mock assertions

Migrating from v8

See docs/MIGRATION-v9.md for a comprehensive migration guide. It doubles as a playbook for AI agents: point yours at the guide and ask it to migrate the codebase.

License

MIT-licensed. See LICENSE.

Release new version

Run the "CI & Release" workflow. Make sure to select the main branch and check "Release new version".

Semantic release will only release on configured branches, so it is safe to run release on any branch.

Keywords