@canlooks/ajax
Version:
A modular request management tool
756 lines (569 loc) • 22.4 kB
Markdown
# @canlooks/ajax
A modular, TypeScript-first HTTP request library built on the [Fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) with interceptor chains, progress tracking, timeout control, and a decorator-based module system.
## Table of Contents
- [Installation](#installation)
- [Quick Start](#quick-start)
- [Basic Usage](#basic-usage)
- [GET Request](#get-request)
- [POST Request](#post-request)
- [Method Aliases](#method-aliases)
- [Configuration](#configuration)
- [AjaxConfig Options](#ajaxconfig-options)
- [Default Behavior](#default-behavior)
- [Response Handling](#response-handling)
- [Response Types](#response-types)
- [AjaxResponse Shape](#ajaxresponse-shape)
- [Error Handling](#error-handling)
- [Error Hierarchy](#error-hierarchy)
- [Error Properties](#error-properties)
- [Debug Mode](#debug-mode)
- [Interceptors](#interceptors)
- [Request Interceptors](#request-interceptors)
- [Response Interceptors](#response-interceptors)
- [Per-Request Interceptors](#per-request-interceptors)
- [Module System](#module-system)
- [Creating a Service Module](#creating-a-service-module)
- [Decorators](#decorators)
- [Module-Level Interceptors](#module-level-interceptors)
- [Extending Modules](#extending-modules)
- [Instance Pattern](#instance-pattern)
- [Creating Instances](#creating-instances)
- [Instance Inheritance](#instance-inheritance)
- [Upload & Download Progress](#upload--download-progress)
- [Upload Progress](#upload-progress)
- [Download Progress](#download-progress)
- [Timeout & Abort](#timeout--abort)
- [Timeout](#timeout)
- [External Abort Signal](#external-abort-signal)
- [URL & Params](#url--params)
- [URL Merging](#url-merging)
- [Query Parameters](#query-parameters)
- [TypeScript Support](#typescript-support)
- [Generic Response Typing](#generic-response-typing)
- [Import Paths](#import-paths)
- [Utility Functions](#utility-functions)
- [API Reference](#api-reference)
- [License](#license)
## Installation
```bash
npm install @canlooks/ajax
```
## Quick Start
```ts
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
```ts
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.
```ts
// Auto JSON serialization
const { result } = await ajax.post('https://api.example.com/users', {
name: 'John',
email: 'john@example.com'
})
// 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:
```ts
ajax.get<T>(url: string, config?: AjaxConfig): Promise<AjaxResponse<T>>
```
Signature for **with body** aliases:
```ts
ajax.post<T>(url: string, body?: any, config?: AjaxConfig): Promise<AjaxResponse<T>>
```
## Configuration
### AjaxConfig Options
`AjaxConfig` extends the standard [`RequestInit`](https://developer.mozilla.org/en-US/docs/Web/API/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>`:
```ts
interface AjaxResponse<T> {
result: T // Parsed response body
response: Response // Native Fetch Response object
config: ResolvedConfig // Final resolved config used for this request
}
```
```ts
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:
```ts
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:**
```ts
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:
```bash
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.
```ts
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:**
```ts
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**.
```ts
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:**
```ts
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:
```ts
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:
```ts
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](#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:
```ts
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:
```ts
@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:
```ts
@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:
```ts
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.
```ts
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:
```ts
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:
```ts
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`:
```ts
// 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:
```ts
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:
```ts
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:
```ts
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:
```ts
// 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:
```ts
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:
```ts
class UserApi extends Service {
static getAll() {
return this.get<User[]>('/users') // Promise<AjaxResponse<User[]>>
}
}
```
### Import Paths
```ts
// 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 `AbortSignal`s — 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.
```ts
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.
```ts
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
```ts
function Config(config: AjaxConfig): ClassDecorator
const RequestInterceptor: MethodDecorator & (() => MethodDecorator)
const ResponseInterceptor: MethodDecorator & (() => MethodDecorator)
```
### Error Classes
```ts
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
```ts
type ProgressEvent = { loaded: number; total: number; chunk: Uint8Array }
type ProgressCallback = (event: ProgressEvent) => void
```
## License
MIT