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

@canlooks/ajax

v5.0.5

Published

A modular request management tool

Downloads

561

Readme

@canlooks/ajax

A modular, TypeScript-first HTTP request library built on the Fetch API with interceptor chains, progress tracking, timeout control, and a decorator-based module system.

Table of Contents

Installation

npm install @canlooks/ajax

Quick Start

import { ajax } from '@canlooks/ajax'

// GET request with type-safe response
const { result } = await ajax.get<User[]>('https://api.example.com/users')

// POST request with JSON body (auto-serialized)
const { result } = await ajax.post<Token>('https://api.example.com/login', {
  username: 'admin',
  password: 'secret'
})

// Full config
const { result, response, config } = await ajax({
  url: 'https://api.example.com/data',
  method: 'GET',
  params: { page: '1', limit: '10' },
  timeout: 5000,
  responseType: 'json'
})

Basic Usage

GET Request

import { ajax } from '@canlooks/ajax'

const { result } = await ajax.get('https://api.example.com/users', {
  params: { role: 'admin' },
  headers: { 'X-API-Key': 'abc123' }
})

POST Request

Plain objects are automatically serialized to JSON. You can also pass FormData, Blob, ArrayBuffer, URLSearchParams, or ReadableStream directly.

// Auto JSON serialization
const { result } = await ajax.post('https://api.example.com/users', {
  name: 'John',
  email: '[email protected]'
})

// FormData upload
const form = new FormData()
form.append('file', fileBlob)
const { result } = await ajax.post('https://api.example.com/upload', form)

Method Aliases

| Category | Methods | |---|---| | Without body | ajax.get(), ajax.delete(), ajax.head(), ajax.options() | | With body | ajax.post(), ajax.put(), ajax.patch() |

Signature for without body aliases:

ajax.get<T>(url: string, config?: AjaxConfig): Promise<AjaxResponse<T>>

Signature for with body aliases:

ajax.post<T>(url: string, body?: any, config?: AjaxConfig): Promise<AjaxResponse<T>>

Configuration

AjaxConfig Options

AjaxConfig extends the standard RequestInit, adding these fields:

| Option | Type | Default | Description | |---|---|---|---| | url | string \| URL | — | Request URL. Required unless set via instance config. | | method | Method | 'GET' | HTTP method. | | params | string[][] \| Record<string,string> \| string \| URLSearchParams | — | Query parameters appended to the URL. | | timeout | number | 60000 (60s) | Request timeout in milliseconds. 0 disables timeout. Default is undefined (no timeout) when onUploadProgress or onDownloadProgress is set. | | responseType | 'arrayBuffer' \| 'blob' \| 'formData' \| 'json' \| 'text' \| 'none' | 'json' | Auto-parses the response body. Set to 'none' to skip parsing. Defaults to 'none' when onDownloadProgress is set. | | onUploadProgress | ProgressCallback | — | Callback for upload progress events. | | onDownloadProgress | ProgressCallback | — | Callback for download progress events. | | onRequest | RequestInterceptorType | — | Per-request request interceptor. | | onResponse | ResponseInterceptorType | — | Per-request response interceptor. |

Default Behavior

  • Timeout: 60 seconds by default. Disabled (undefined) when progress callbacks are active.
  • Response type: 'json' by default. 'none' when download progress is tracked.
  • Body serialization: Plain objects are automatically JSON.stringify()'d. Other types (FormData, Blob, etc.) pass through unchanged.
  • Headers: A Content-Type: application/json header is not auto-set. Set it explicitly if your server requires it.

Response Handling

Response Types

The responseType option controls how the response body is parsed:

| Value | Behavior | |---|---| | 'json' (default) | Calls response.json() | | 'text' | Calls response.text() | | 'blob' | Calls response.blob() | | 'arrayBuffer' | Calls response.arrayBuffer() | | 'formData' | Calls response.formData() | | 'none' | Skips parsing. result will be undefined. |

AjaxResponse Shape

Every request returns an AjaxResponse<T>:

interface AjaxResponse<T> {
  result: T           // Parsed response body
  response: Response  // Native Fetch Response object
  config: ResolvedConfig  // Final resolved config used for this request
}
const { result, response, config } = await ajax.get<User[]>('/users')

console.log(result)           // User[]
console.log(response.status)  // 200
console.log(response.headers) // Headers object
console.log(config.url)       // Resolved full URL

Error Handling

Error Hierarchy

All errors extend AjaxError, which extends the native Error:

AjaxError
├── NetworkError   — fetch() failed or response.ok is false
├── AbortError     — request was aborted
└── TimeoutError   — request exceeded timeout

Error Properties

Each error carries:

class AjaxError extends Error {
  type: 'ajaxError' | 'networkError' | 'abortError' | 'timeoutError'
  cause: {
    config: ResolvedConfig   // The config used for the failed request
    response?: Response      // The Response object (if available)
  }
}

Usage:

import { ajax, AjaxError, NetworkError, TimeoutError, AbortError } from '@canlooks/ajax'

try {
  await ajax.get('https://api.example.com/data')
} catch (e) {
  if (e instanceof TimeoutError) {
    console.log('Request timed out after', e.cause.config.timeout, 'ms')
  } else if (e instanceof NetworkError) {
    console.log('Network error, status:', e.cause.response?.status)
  } else if (e instanceof AbortError) {
    console.log('Request was aborted')
  } else if (e instanceof AjaxError) {
    console.log('Ajax error:', e.message)
  }
}

Debug Mode

Set the environment variable CANLOOKS_AJAX_DEBUG=on to print the full request config on every error:

CANLOOKS_AJAX_DEBUG=on node your-app.js

Interceptors

Interceptors allow you to transform requests before they are sent and responses before they are returned.

Request Interceptors

Request interceptors receive the resolved config and must return a config (or a Promise of one). They run sequentially in registration order.

import { ajax } from '@canlooks/ajax'

// Add a request interceptor
ajax.requestInterceptor.add(config => {
  // Add auth token to every request
  config.headers.set('Authorization', `Bearer ${getToken()}`)
  return config
})

// Remove an interceptor
ajax.requestInterceptor.delete(interceptorFn)

Signature:

type RequestInterceptorType = (config: ResolvedConfig) => ResolvedConfig | Promise<ResolvedConfig>

Response Interceptors

Response interceptors receive (response, error, config). If an interceptor returns a value, it becomes the new response and errors are cleared. If it throws, the error propagates. They run sequentially.

import { ajax } from '@canlooks/ajax'

// Add a response interceptor
ajax.responseInterceptor.add((response, error, config) => {
  if (error) {
    // Handle 401 globally
    if (error.cause?.response?.status === 401) {
      redirectToLogin()
      return
    }
    throw error // re-throw if not handled
  }
  // Unwrap the result from a wrapper
  if (response?.result?.code === 0) {
    return response.result.data
  }
  throw new Error(response?.result?.message ?? 'Unknown error')
})

Signature:

type ResponseInterceptorType = (response: any, error: any, config: ResolvedConfig) => any
  • If isFinalSuccess is true after all interceptors run, the current response value is returned.
  • If isFinalSuccess is false, the accumulated error is thrown.
  • Returning undefined from an interceptor leaves the response untouched.
  • A response interceptor can recover from an error by returning a value (sets error = null).

Per-Request Interceptors

You can also attach interceptors to a single request via config:

const { result } = await ajax.get('/data', {
  onRequest: config => {
    config.headers.set('X-Request-Id', generateId())
    return config
  },
  onResponse: (response, error) => {
    if (error) throw error
    return response.result
  }
})

Per-request interceptors run after instance-level interceptors.

Module System

The module system lets you organize API endpoints into service classes with shared configuration and interceptors.

Creating a Service Module

Extend the Service class and use the @Config decorator to set shared defaults:

import { Service, Config } from '@canlooks/ajax'

@Config({
  url: 'https://api.example.com/v1',
  headers: { 'X-API-Key': 'abc123' },
  timeout: 10000
})
class ApiService extends Service {
  // GET /v1/users
  static getUsers() {
    return this.get<User[]>('/users')
  }

  // GET /v1/users/:id
  static getUser(id: string) {
    return this.get<User>(`/users/${id}`)
  }

  // POST /v1/users
  static createUser(data: CreateUserDto) {
    return this.post<User>('/users', data)
  }

  // PUT /v1/users/:id
  static updateUser(id: string, data: UpdateUserDto) {
    return this.put<User>(`/users/${id}`, data)
  }

  // DELETE /v1/users/:id
  static deleteUser(id: string) {
    return this.delete(`/users/${id}`)
  }
}

// Usage
const { result: users } = await ApiService.getUsers()
const { result: newUser } = await ApiService.createUser({ name: 'Jane' })

All HTTP method aliases are available as static methods: this.get(), this.post(), this.put(), this.patch(), this.delete(), this.head(), this.options().

Decorators

| Decorator | Target | Purpose | |---|---|---| | @Config(config) | Class | Sets default AjaxConfig for the service. URL paths are merged (see URL Merging). | | @RequestInterceptor | Method | Marks a method as a request interceptor for this service. | | @ResponseInterceptor | Method | Marks a method as a response interceptor for this service. |

Module-Level Interceptors

Use @RequestInterceptor and @ResponseInterceptor decorators to define interceptors that only apply to a specific service module:

import { Service, Config, RequestInterceptor, ResponseInterceptor } from '@canlooks/ajax'

@Config({
  url: 'https://api.example.com/v1'
})
class ApiService extends Service {
  @RequestInterceptor
  static addAuth(config: ResolvedConfig) {
    config.headers.set('Authorization', `Bearer ${getToken()}`)
    return config
  }

  @ResponseInterceptor
  static unwrapResponse(response: any, error: any) {
    if (error) throw error
    if (response?.result?.code === 0) {
      return response.result.data
    }
    throw new Error(response?.result?.message ?? 'API Error')
  }

  // endpoints...
  static getUsers() {
    return this.get<User[]>('/users')
  }
}

The decorators also work as factory functions without arguments:

@RequestInterceptor()
static addAuth(config: ResolvedConfig) { ... }

Module-level interceptors are applied to the service's internal ajax instance and run before any per-request interceptors.

Extending Modules

You can extend a service class to create more specific modules:

@Config({ url: 'https://api.example.com/v1' })
class BaseApi extends Service {
  @RequestInterceptor
  static addAuth(config: ResolvedConfig) {
    config.headers.set('Authorization', `Bearer ${getToken()}`)
    return config
  }
}

@Config({ url: '/users' })
class UserApi extends BaseApi {
  static getAll() {
    return this.get<User[]>('')  // resolves to /v1/users
  }
  static getById(id: string) {
    return this.get<User>(`/${id}`)  // resolves to /v1/users/:id
  }
}

@Config({ url: '/posts' })
class PostApi extends BaseApi {
  static getAll() {
    return this.get<Post[]>('')  // resolves to /v1/posts
  }
}

Instance Pattern

Creating Instances

The ajax.create() method produces a new instance that inherits the parent's config and interceptors:

import { ajax } from '@canlooks/ajax'

const api = ajax.create({
  url: 'https://api.example.com/v1',
  headers: { 'X-API-Key': 'abc123' }
})

api.requestInterceptor.add(config => {
  config.headers.set('Authorization', `Bearer ${getToken()}`)
  return config
})

// All requests from this instance use the shared config
const { result } = await api.get('/users')

Instance Inheritance

Child instances copy their parent's config and interceptor sets at creation time. After creation, parent and child are independent — changes to one do not affect the other.

const parent = ajax.create({ url: 'https://api.example.com' })
const child = parent.create({ url: '/v2' })

// parent resolves to: https://api.example.com
// child resolves to:  https://api.example.com/v2

This is the foundation of the module system — each Service subclass gets its own isolated instance.

Upload & Download Progress

Upload Progress

Track upload progress for Blob, FormData, and ArrayBuffer bodies:

const { result } = await ajax.post('/upload', formData, {
  onUploadProgress: ({ loaded, total, chunk }) => {
    console.log(`Uploaded ${loaded} of ${total} bytes`)
    console.log(`Progress: ${Math.round((loaded / total) * 100)}%`)
  }
})

The library recursively finds all Blob objects in the body (including inside FormData, arrays, and nested objects), streams them, and reports cumulative progress.

Download Progress

Track download progress for responses with a Content-Length header:

const { result } = await ajax.get('/large-file', {
  responseType: 'blob',
  onDownloadProgress: ({ loaded, total, chunk }) => {
    console.log(`Downloaded ${loaded} of ${total} bytes`)
  }
})

// result is a Blob

When onDownloadProgress is set:

  • The response body is read as a Uint8Array stream
  • responseType defaults to 'none' — raw bytes are accumulated in result
  • Supported responseType values with download progress: 'arrayBuffer', 'blob', or 'none'

Note: When both upload and download progress are active, the default timeout is disabled (undefined). Set it explicitly if needed.

Timeout & Abort

Timeout

Default timeout is 60 seconds. Disable it by setting timeout: 0:

// 5 second timeout
await ajax.get('/data', { timeout: 5000 })

// No timeout
await ajax.get('/data', { timeout: 0 })

When a timeout fires, the request is aborted and a TimeoutError is thrown.

External Abort Signal

Pass an AbortSignal to cancel a request externally:

const controller = new AbortController()

// Cancel after 2 seconds
setTimeout(() => controller.abort(), 2000)

try {
  await ajax.get('/data', { signal: controller.signal })
} catch (e) {
  console.log(e instanceof AbortError) // true
}

The external signal and the internal timeout signal are merged automatically — aborting either one cancels the request.

URL & Params

URL Merging

When you set a url in an instance or service config, relative paths in subsequent requests are resolved against it:

const api = ajax.create({ url: 'https://api.example.com/v1' })

await api.get('/users')       // https://api.example.com/v1/users
await api.get('users')        // https://api.example.com/v1/users
await api.get('/users/123')   // https://api.example.com/v1/users/123

If a request URL starts with a protocol (http://, https://, //), it replaces the base URL entirely:

const api = ajax.create({ url: 'https://api.example.com/v1' })

await api.get('https://other.example.com/data')  // https://other.example.com/data

Query Parameters

Pass params as an object, URLSearchParams, string, or array of pairs:

// Object
await ajax.get('/users', { params: { page: '1', sort: 'name' } })
// → /users?page=1&sort=name

// URLSearchParams
await ajax.get('/users', { params: new URLSearchParams({ page: '1' }) })

// String (appended as-is)
await ajax.get('/users', { params: 'page=1&sort=name' })

// Array of [key, value] pairs
await ajax.get('/users', { params: [['page', '1'], ['sort', 'name']] })

Params from instance/service config are merged with per-request params (request params take precedence for duplicate keys).

TypeScript Support

Generic Response Typing

All request methods are generic over the response type:

interface User {
  id: number
  name: string
  email: string
}

const { result } = await ajax.get<User[]>('/users')
// result: User[]

const { result: user } = await ajax.get<User>('/users/1')
// user: User

const { result } = await ajax.post<User>('/users', { name: 'Jane' })
// result: User

Service subclass methods also support generics:

class UserApi extends Service {
  static getAll() {
    return this.get<User[]>('/users')  // Promise<AjaxResponse<User[]>>
  }
}

Import Paths

// ESM
import { ajax, Service, Config, RequestInterceptor, ResponseInterceptor } from '@canlooks/ajax'
import { AjaxError, NetworkError, TimeoutError, AbortError } from '@canlooks/ajax'
import type { AjaxConfig, AjaxResponse, ResolvedConfig } from '@canlooks/ajax'

// CJS
const { ajax, Service } = require('@canlooks/ajax')

Utility Functions

The following utilities are exported and can be used directly:

| Function | Description | |---|---| | mergeConfig(...configs) | Deep-merge multiple AjaxConfig objects into a ResolvedConfig. | | mergeUrl(prev?, next?) | Resolve a relative URL against a base URL. | | mergeParams(prev?, next?) | Merge params into a URLSearchParams instance. | | mergeHeaders(prev?, next?) | Merge headers into a Headers instance. | | mergeAbortSignal(prev?, next?) | Merge two AbortSignals — either one aborting triggers both. | | bodyTransform(body) | Auto JSON.stringify plain objects; pass other body types through. | | findBodyBlobs(body) | Recursively find all Blob objects in a body (for progress tracking). | | catchCommonError(e, newError) | Wrap a non-AjaxError into an AjaxError. |

API Reference

ajax (default export)

The default singleton instance.

interface Ajax {
  // Invoke directly
  <T = any>(config?: AjaxConfig): Promise<AjaxResponse<T>>

  // Current config
  config: AjaxConfig

  // Create a child instance
  create(config?: AjaxConfig): Ajax

  // Interceptor sets
  requestInterceptor: Set<RequestInterceptorType>
  responseInterceptor: Set<ResponseInterceptorType>

  // Method aliases — without body
  get<T = any>(url: string, config?: AjaxConfig): Promise<AjaxResponse<T>>
  delete<T = any>(url: string, config?: AjaxConfig): Promise<AjaxResponse<T>>
  head<T = any>(url: string, config?: AjaxConfig): Promise<AjaxResponse<T>>
  options<T = any>(url: string, config?: AjaxConfig): Promise<AjaxResponse<T>>

  // Method aliases — with body
  post<T = any>(url: string, body?: any, config?: AjaxConfig): Promise<AjaxResponse<T>>
  put<T = any>(url: string, body?: any, config?: AjaxConfig): Promise<AjaxResponse<T>>
  patch<T = any>(url: string, body?: any, config?: AjaxConfig): Promise<AjaxResponse<T>>
}

Service

Base class for creating API service modules.

class Service {
  static ajax: Ajax
  static config: AjaxConfig
  static resolvedConfig: ResolvedConfig    // Config merged with parent

  static get<T>(url: string, config?: AjaxConfig): Promise<AjaxResponse<T>>
  static delete<T>(url: string, config?: AjaxConfig): Promise<AjaxResponse<T>>
  static head<T>(url: string, config?: AjaxConfig): Promise<AjaxResponse<T>>
  static options<T>(url: string, config?: AjaxConfig): Promise<AjaxResponse<T>>
  static post<T>(url: string, body?: any, config?: AjaxConfig): Promise<AjaxResponse<T>>
  static put<T>(url: string, body?: any, config?: AjaxConfig): Promise<AjaxResponse<T>>
  static patch<T>(url: string, body?: any, config?: AjaxConfig): Promise<AjaxResponse<T>>
}

Decorators

function Config(config: AjaxConfig): ClassDecorator
const RequestInterceptor: MethodDecorator & (() => MethodDecorator)
const ResponseInterceptor: MethodDecorator & (() => MethodDecorator)

Error Classes

class AjaxError extends Error { type, cause }
class NetworkError extends AjaxError { type: 'networkError' }
class AbortError extends AjaxError { type: 'abortError' }
class TimeoutError extends AjaxError { type: 'timeoutError' }

Progress Types

type ProgressEvent = { loaded: number; total: number; chunk: Uint8Array }
type ProgressCallback = (event: ProgressEvent) => void

License

MIT