@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
- Quick Start
- Basic Usage
- Configuration
- Response Handling
- Error Handling
- Interceptors
- Module System
- Instance Pattern
- Upload & Download Progress
- Timeout & Abort
- URL & Params
- TypeScript Support
- Utility Functions
- API Reference
- License
Installation
npm install @canlooks/ajaxQuick 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/jsonheader 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 URLError 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 timeoutError 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.jsInterceptors
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
isFinalSuccessistrueafter all interceptors run, the currentresponsevalue is returned. - If
isFinalSuccessisfalse, the accumulatederroris thrown. - Returning
undefinedfrom 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/v2This 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 BlobWhen onDownloadProgress is set:
- The response body is read as a
Uint8Arraystream responseTypedefaults to'none'— raw bytes are accumulated inresult- Supported
responseTypevalues 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/123If 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/dataQuery 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: UserService 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) => voidLicense
MIT
