npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@iremlopsum/apify

v1.0.0

Published

Runtime-agnostic, type-safe API client built on standard fetch. Zero dependencies.

Readme

apify

Runtime-agnostic, type-safe API client built on standard fetch. Zero dependencies.

npm install @iremlopsum/apify

Getting Started

Define your endpoints as Request instances, wire them into a client with createApi, and call them with full type safety.

import { createApi, Request } from '@iremlopsum/apify'

// 1. Define your endpoints
interface User {
  id: string
  name: string
  email: string
}

const getUser = new Request<{ id: string }, User>({
  method: 'GET',
  path: '/users/:id'
})

const createUser = new Request<{ name: string; email: string }, User>({
  method: 'POST',
  path: '/users'
})

// 2. Create the client
const api = createApi({
  baseUrl: 'https://api.example.com',
  requests: { getUser, createUser },
  onError: (error) => console.error(`${error.request.method} ${error.request.url}`, error.status)
})

// 3. Make a call — params and response are fully typed
const { data, error, retry } = await api.getUser({ id: '42' })

if (error) {
  console.error(error.status, error.body)
  return
}

// data is typed as User
console.log(data.name)

Deep Dive

Request

Each API endpoint is represented by a Request instance. The class is a typed config container -- it stores the recipe for how an endpoint should be called, but does not execute anything on its own.

import { Request } from '@iremlopsum/apify'

const listItems = new Request<{ page: number; limit: number }, Item[]>({
  method: 'GET',
  path: '/items'
})

The two type parameters drive the entire type system:

  • TParams -- the shape of the params object the caller must provide (path params, query params, and body params combined).
  • TResponse -- the shape of the successful response data. This becomes the type of result.data.

When an endpoint takes no params, use Record<string, never> and the generated method will accept an optional (or omitted) params argument:

const health = new Request<Record<string, never>, { status: string }>({
  method: 'GET',
  path: '/health'
})

// Both work:
await api.health()
await api.health({})

Path parameters

Use :param syntax in the path. Matching keys from the params object are substituted into the URL and excluded from the query string or body:

const getItem = new Request<{ orgId: string; id: string }, Item>({
  method: 'GET',
  path: '/orgs/:orgId/items/:id'
})

// Calls GET /orgs/acme/items/42
await api.getItem({ orgId: 'acme', id: '42' })

responseType

Controls how the response body is parsed. Defaults to 'json'.

const downloadFile = new Request<{ id: string }, Blob>({
  method: 'GET',
  path: '/files/:id',
  responseType: 'blob'
})

See Response parsing for all options.

dedupe

When true, firing a new call to this endpoint auto-cancels any previous in-flight call. Useful for search-as-you-type or rapidly changing filters:

const searchUsers = new Request<{ q: string }, User[]>({
  method: 'GET',
  path: '/users/search',
  dedupe: true
})

// If a second call starts before the first finishes, the first is aborted
await api.searchUsers({ q: 'hel' })
await api.searchUsers({ q: 'hello' }) // previous call is auto-cancelled

bodyAs

Overrides the default body serialization strategy. By default, GET/DELETE serialize params as query strings and POST/PUT/PATCH serialize params as a JSON body. Use bodyAs to invert that:

// DELETE endpoint that expects a JSON body
const bulkDelete = new Request<{ ids: string[] }, { deleted: number }>({
  method: 'DELETE',
  path: '/items',
  bodyAs: 'body'
})

// POST endpoint that sends params as query string
const triggerJob = new Request<{ priority: number }, Job>({
  method: 'POST',
  path: '/jobs/trigger',
  bodyAs: 'query'
})

Query strings

For GET and DELETE requests (or any request with bodyAs: 'query'), params that are not consumed by path substitution are serialized as a query string using URLSearchParams.

| Input | Output | | ------------------------------ | --------------------------- | | { page: 1, limit: 20 } | ?page=1&limit=20 | | { tags: ['a', 'b'] } | ?tags=a&tags=b | | { filter: null } | (omitted) | | { filter: undefined } | (omitted) | | { meta: { nested: true } } | TypeError (see below) |

Arrays use repeated keys (tags=a&tags=b), which is the most widely supported format across server frameworks.

null and undefined values are silently omitted from the query string.

Nested objects throw a TypeError with a descriptive message. Flatten the structure before passing. This is intentional -- there is no universal standard for serializing nested objects in query strings (brackets, dots, JSON), so the library refuses to guess.

Result

Every API call returns a Result<TResponse> instead of throwing. The shape is always the same:

interface Result<TResponse> {
  data: TResponse | null    // parsed response on success, null on error
  error: ApiError | null     // structured error on failure, null on success
  response: Response | null  // raw fetch Response (null for network errors)
  retry: () => Promise<Result<TResponse>>
}

Check error first, then use data with confidence:

const { data, error, response, retry } = await api.getUser({ id: '42' })

if (error) {
  if (error.status === 0) {
    // Network error -- user is probably offline
  } else if (error.status === 401) {
    redirectToLogin()
  } else {
    console.error(error.status, error.body)
  }
  return
}

// data is typed as User, error is null
console.log(data.name)

retry()

The retry function re-executes the exact same request through the full middleware chain. Auth tokens are re-injected, logging fires again, everything runs fresh. This is useful for retry-after-refresh patterns:

const { data, error, retry } = await api.getUser({ id: '42' })

if (error?.status === 401) {
  await refreshToken()
  const retried = await retry()
  // retried goes through the full middleware chain again
}

ApiError

The error object on failed calls. It is not a subclass of Error -- it is a structured container for API-level error details.

| Property | Type | Description | | ------------ | --------- | ----------------------------------------------------------------- | | status | number | HTTP status code (e.g., 404, 500). 0 for network errors/aborts. | | statusText | string | HTTP status text (e.g., 'Not Found'). '' for network errors. | | body | unknown | Parsed response body, or the native Error for network failures. | | headers | Headers | Response headers. Empty Headers for network errors. | | request | object | { method, url, params } -- metadata about the failed request. |

You can use instanceof to check if a value is an ApiError:

import { ApiError } from '@iremlopsum/apify'

if (error instanceof ApiError) {
  // ...
}

Error handling with onError

The onError callback in createApi fires after the full middleware chain completes whenever the final result has an error. If a retry middleware recovers a 5xx to a 200, onError does not fire.

const api = createApi({
  baseUrl: '/api',
  requests: { getUser, createUser },
  onError: (error) => {
    if (error.status === 401) redirectToLogin()
    Sentry.captureException(error)
  }
})

This fires for both HTTP errors (4xx, 5xx) and network errors (status 0). It is a global hook for side effects (logging, telemetry, redirects) -- it does not change the result returned to the caller.

Middleware

Middleware follows the onion model (like Koa or Redux middleware). Each middleware wraps the next layer, can modify the request going in and the result coming out.

Request → [Global MW → [Per-request MW → [Per-call MW → [fetch]]]]

A middleware function receives a context and a next function:

import type { Middleware } from '@iremlopsum/apify'

const authMiddleware: Middleware = async (ctx, next) => {
  // Before: modify the request
  ctx.request.headers.set('Authorization', `Bearer ${getToken()}`)

  // Call the next layer
  const result = await next()

  // After: inspect or transform the result
  return result
}

What middleware can do

  • Modify the request -- set headers, change the body, rewrite the URL.
  • Short-circuit -- return early without calling next() (e.g., serve from cache).
  • Retry -- call next() multiple times in a loop (e.g., retry on 5xx).
  • Inspect the result -- log, report errors, transform response data.

Three layers

Middleware is applied at three levels. The execution order is global first, per-request second, per-call third:

// Global -- applies to every endpoint
const api = createApi({
  baseUrl: '/api',
  requests: { getUser, createUser },
  middleware: [authMiddleware, logMiddleware]
})

// Per-request -- applies only to this endpoint
const getUser = new Request<{ id: string }, User>({
  method: 'GET',
  path: '/users/:id',
  middleware: [cacheMiddleware]
})

// Per-call -- applies only to this single invocation
await api.getUser({ id: '42' }, {
  middleware: [customTraceMiddleware]
})

skipMiddleware

Remove specific middleware for a single call by passing references to skipMiddleware:

const retry = retryMiddleware(3)

const api = createApi({
  baseUrl: '/api',
  requests: { getUser },
  middleware: [retry, logMiddleware]
})

// Skip retry for this one call
await api.getUser({ id: '42' }, {
  skipMiddleware: [retry]
})

Comparison is by reference (===). Factory-style middleware like retryMiddleware(3) must be stored in a variable first -- calling the factory again creates a new reference that will not match.

MiddlewareContext

The context object passed to each middleware:

| Property | Type | Description | | --------------------- | --------- | -------------------------------------------------------- | | request.method | string | HTTP method (GET, POST, etc.) | | request.url | string | Fully resolved URL with path params and query string | | request.path | string | Original path template (e.g., '/users/:id') | | request.params | unknown | Original params object from the caller | | request.headers | Headers | Merged headers -- middleware can add/remove entries | | request.body | unknown | Serialized body, or null for GET/DELETE | | requestName | string | Key name in the requests object (e.g., 'getUser') |

Writing custom middleware

A cache middleware that short-circuits on cache hits:

const cacheMiddleware: Middleware = async (ctx, next) => {
  const cached = cache.get(ctx.request.url)
  if (cached) return cached

  const result = await next()

  if (result.data) {
    cache.set(ctx.request.url, result)
  }

  return result
}

An error reporting middleware:

const sentryMiddleware: Middleware = async (ctx, next) => {
  const result = await next()

  if (result.error && result.error.status >= 500) {
    Sentry.captureMessage(`API error: ${ctx.request.method} ${ctx.request.url}`, {
      extra: { status: result.error.status, body: result.error.body }
    })
  }

  return result
}

Built-in middleware

The library ships two optional middleware functions, importable from a separate entry point:

import { retryMiddleware, logMiddleware } from '@iremlopsum/apify/middleware'

retryMiddleware(maxRetries?: number)

Automatically retries requests that return a 5xx server error. The maxRetries parameter (default: 3) is the number of additional attempts after the initial one, so retryMiddleware(2) means up to 3 total attempts.

Only retries server errors (status >= 500). Client errors (4xx) and network errors (status 0) are not retried.

const api = createApi({
  baseUrl: '/api',
  requests: { getItems },
  middleware: [retryMiddleware(2)]
})

logMiddleware

Logs request start and completion to the console with timing:

[apify] → GET getItems /api/items
[apify] ← getItems OK (142ms)

[apify] → POST createUser /api/users
[apify] ← createUser ERROR 422 (89ms)

Intended for development. In production, write a custom middleware that sends telemetry to your observability platform.

const api = createApi({
  baseUrl: '/api',
  requests: { getItems },
  middleware: [logMiddleware]
})

Content types

Request bodies are automatically serialized based on the input type. The Content-Type header is set for you unless you explicitly provide one.

| Input type | Body output | Content-Type | | ----------------- | ------------------ | ------------------------------------- | | null/undefined | null | (none) | | string | as-is | text/plain | | FormData | as-is | (browser sets multipart boundary) | | URLSearchParams | as-is | application/x-www-form-urlencoded | | Blob | as-is | application/octet-stream | | ArrayBuffer | as-is | application/octet-stream | | Plain object | JSON.stringify() | application/json |

Header merge precedence (most specific wins):

  1. Global headers (from createApi config) -- lowest priority
  2. Per-request headers (from Request config) -- overrides global
  3. Per-call headers (from CallOptions) -- highest priority

Explicitly set Content-Type headers at any level override the auto-detected value.

Response parsing

The responseType option on a Request determines how the response body is parsed:

| responseType | Method called | Return type | | --------------- | ---------------------- | -------------- | | 'json' | response.text() then JSON.parse() | parsed object | | 'text' | response.text() | string | | 'blob' | response.blob() | Blob | | 'arrayBuffer' | response.arrayBuffer() | ArrayBuffer | | 'formData' | response.formData() | FormData |

The default is 'json'. JSON parsing reads the body as text first and then parses, so empty responses (e.g., 204 No Content) return null instead of throwing a parse error.

Cancellation

Manual abort via AbortSignal

Pass an AbortSignal through CallOptions to cancel a request:

const controller = new AbortController()

const promise = api.getItems({ page: 1 }, {
  signal: controller.signal
})

// Cancel the request
controller.abort()

const { error } = await promise
// error.status === 0, error.body is a DOMException with name 'AbortError'

Auto-cancel via dedupe

When a Request has dedupe: true, each new call automatically aborts the previous in-flight call for that endpoint. Identity is per Request instance -- different endpoints do not interfere with each other.

const searchUsers = new Request<{ q: string }, User[]>({
  method: 'GET',
  path: '/users/search',
  dedupe: true
})

const api = createApi({
  baseUrl: '/api',
  requests: { searchUsers }
})

// Rapid calls -- only the last one completes
api.searchUsers({ q: 'h' })    // aborted by next call
api.searchUsers({ q: 'he' })   // aborted by next call
api.searchUsers({ q: 'hel' })  // this one completes

Dedupe and manual abort signals work together. If both are active, the request is cancelled if either fires.

TypeScript

Type inference flows automatically from Request generics through createApi to the call site. You never annotate the API methods manually.

// 1. Types are declared on the Request
const getUser = new Request<{ id: string }, User>({
  method: 'GET',
  path: '/users/:id'
})

// 2. createApi infers method signatures from the requests record
const api = createApi({
  baseUrl: '/api',
  requests: { getUser }
})

// 3. Call site is fully typed -- no annotations needed
const { data, error } = await api.getUser({ id: '42' })
//      ^? User | null

The inference chain works like this:

  • Request<TParams, TResponse> carries the type info.
  • createApi uses internal conditional types to pull the TParams and TResponse generics from each Request instance.
  • A mapped type transforms the requests record into callable methods: each key becomes (params: TParams, options?: CallOptions) => Promise<Result<TResponse>>.
  • When TParams is Record<string, never> (no params), the params argument becomes optional.

All exported types are available for annotation when needed:

import type {
  Result,
  CallOptions,
  Middleware,
  MiddlewareContext,
  MiddlewareNext,
  RequestConfig,
  ApiConfig
} from '@iremlopsum/apify'

Philosophy

Never throws

Every API call returns a Result<T>. HTTP errors, network failures, parse errors, and even synchronous exceptions during request setup are all captured and returned as structured { data, error, response, retry } objects. No try/catch required at call sites.

Zero dependencies

The library uses only the standard fetch API and built-in web platform types (Headers, AbortController, FormData, URLSearchParams, Blob, ArrayBuffer). There is nothing to install, audit, or bundle beyond the library itself.

Middleware over interceptors

Instead of separate onRequest/onResponse interceptor hooks, the library uses a composable onion model where each middleware wraps the next. This means a single function can modify the request, inspect the response, retry on failure, or short-circuit entirely. Three layers (global, per-request, per-call) plus skipMiddleware give fine-grained control without configuration complexity.

Typed dot-access

Type safety comes from inference, not annotation. Define Request<TParams, TResponse> once, and createApi infers everything downstream. The call site (api.getUser(...)) is fully typed with zero extra work.

Runtime-agnostic

No assumptions about Node.js, browsers, or any specific runtime. If your environment has fetch, the library works -- browsers, Node.js 18+, Bun, Deno, React Native, Cloudflare Workers, edge runtimes.

API Reference

Core (@iremlopsum/apify)

| Export | Kind | Description | | --------------- | -------- | ------------------------------------------------------------------ | | createApi | function | Creates a typed API client from a config of Request definitions | | Request | class | Typed endpoint definition -- one instance per endpoint | | ApiError | class | Structured error with status, body, headers, and request metadata | | RequestConfig | type | Config object for the Request constructor | | ApiConfig | type | Config object for createApi | | CallOptions | type | Per-call overrides (middleware, headers, signal) | | Result | type | Return shape of every API call: { data, error, response, retry } | | Middleware | type | Middleware function signature: (ctx, next) => Promise<Result> | | MiddlewareContext | type | Request context passed to middleware | | MiddlewareNext | type | The next function passed to middleware |

Built-in middleware (@iremlopsum/apify/middleware)

| Export | Kind | Description | | ----------------- | -------- | ------------------------------------------------------------- | | retryMiddleware | function | Factory that returns middleware to retry on 5xx server errors | | logMiddleware | const | Middleware that logs request lifecycle to the console |

License

MIT