UNPKG

@canlooks/ajax

Version:

A modular request management tool

756 lines (569 loc) 22.4 kB
# @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