Skip to content

@ahoo-wang/fetcher

The @ahoo-wang/fetcher package is the foundation of the Fetcher ecosystem. It provides a flexible HTTP client built on the native Fetch API with an interceptor pipeline, URL template building, timeout management, and a named fetcher registry. All other packages depend on this core module.

Source: packages/fetcher/src/

Installation

bash
pnpm add @ahoo-wang/fetcher

Architecture

mermaid
graph TB
    subgraph sg_1 ["Consumer Code"]
        APP["Application"]
    end

    subgraph sg_2 ["@ahoo-wang/fetcher"]
        FETCHER["Fetcher<br>Main HTTP client class"]
        NF["NamedFetcher<br>Auto-registering fetcher"]
        REG["FetcherRegistrar<br>Named instance registry"]
        UB["UrlBuilder<br>Path templates + query params"]
        IM["InterceptorManager<br>Request / Response / Error pipelines"]
        EXCHANGE["FetchExchange<br>Request-response context"]
        ERR["FetcherError / ExchangeError<br>Error hierarchy"]
    end

    subgraph sg_3 ["Built-in Interceptors"]
        URI["UrlResolveInterceptor<br>URL resolution"]
        BODY["RequestBodyInterceptor<br>JSON serialization"]
        FETCH["FetchInterceptor<br>Native fetch execution"]
        VALID["ValidateStatusInterceptor<br>Status code validation"]
    end

    subgraph sg_4 ["Result Extractors"]
        RE_EX["ExchangeResultExtractor"]
        RE_RS["ResponseResultExtractor"]
        RE_JSON["JsonResultExtractor"]
        RE_TXT["TextResultExtractor"]
    end

    APP --> FETCHER
    APP --> NF
    NF --> REG
    FETCHER --> UB
    FETCHER --> IM
    FETCHER --> EXCHANGE
    IM --> URI
    IM --> BODY
    IM --> FETCH
    IM --> VALID
    EXCHANGE --> RE_EX
    EXCHANGE --> RE_RS
    EXCHANGE --> RE_JSON
    EXCHANGE --> RE_TXT

    style APP fill:#2d333b,stroke:#6d5dfc,color:#e6edf3
    style FETCHER fill:#2d333b,stroke:#6d5dfc,color:#e6edf3
    style NF fill:#161b22,stroke:#30363d,color:#e6edf3
    style REG fill:#161b22,stroke:#30363d,color:#e6edf3
    style UB fill:#161b22,stroke:#30363d,color:#e6edf3
    style IM fill:#2d333b,stroke:#6d5dfc,color:#e6edf3
    style EXCHANGE fill:#161b22,stroke:#30363d,color:#e6edf3
    style ERR fill:#161b22,stroke:#30363d,color:#e6edf3
    style URI fill:#161b22,stroke:#30363d,color:#e6edf3
    style BODY fill:#161b22,stroke:#30363d,color:#e6edf3
    style FETCH fill:#161b22,stroke:#30363d,color:#e6edf3
    style VALID fill:#161b22,stroke:#30363d,color:#e6edf3
    style RE_EX fill:#2d333b,stroke:#6d5dfc,color:#e6edf3
    style RE_RS fill:#2d333b,stroke:#6d5dfc,color:#e6edf3
    style RE_JSON fill:#2d333b,stroke:#6d5dfc,color:#e6edf3
    style RE_TXT fill:#2d333b,stroke:#6d5dfc,color:#e6edf3

Request Lifecycle

Every HTTP request flows through the same pipeline, managed by the InterceptorManager:

mermaid
sequenceDiagram
autonumber

    participant App as Application
    participant F as Fetcher
    participant IM as InterceptorManager
    participant Req as Request Interceptors
    participant Resp as Response Interceptors
    participant Err as Error Interceptors
    participant Ext as ResultExtractor

    App->>F: fetch(url, request, options)
    F->>F: resolveExchange(request, options)
    F->>F: Merge headers, resolve timeout
    F->>IM: exchange(fetchExchange)
    IM->>Req: intercept(exchange)

    alt Request succeeds
        Req-->>IM: Exchange with response
        IM->>Resp: intercept(exchange)
        Resp-->>IM: Validated exchange
        IM-->>F: Completed exchange
    else Request fails
        Req-->>IM: Error thrown
        IM->>Err: intercept(exchange)
        alt Error handled
            Err-->>IM: error cleared
            IM-->>F: Recovered exchange
        else Error persists
            Err-->>IM: Error remains
            IM-->>F: ExchangeError thrown
        end
    end

    F->>Ext: extractResult()
    Ext-->>App: Response / JSON / Exchange / etc.

Fetcher

The Fetcher class is the main HTTP client. It wraps the native fetch() API with default headers, a UrlBuilder, timeout control, and a full interceptor pipeline. (fetcher.ts:123)

Creating a Fetcher Instance

typescript
import { Fetcher, ResultExtractors } from '@ahoo-wang/fetcher';

const fetcher = new Fetcher({
  baseURL: 'https://api.example.com',
  headers: { 'Content-Type': 'application/json' },
  timeout: 5000,
});

Making Requests

typescript
// GET with path and query parameters
const users = await fetcher.get('/users/{id}', {
  urlParams: {
    path: { id: 123 },
    query: { include: 'profile' },
  },
});

// POST with JSON body
const created = await fetcher.post('/users', {
  body: { name: 'John', email: 'john@example.com' },
});

// Override result extractor to get JSON directly
const data = await fetcher.get<User[]>('/users', {}, {
  resultExtractor: ResultExtractors.Json,
});

FetcherOptions

PropertyTypeDefaultDescription
baseURLstring''Base URL prepended to all requests
headersRequestHeaders{ 'Content-Type': 'application/json' }Default headers for all requests
timeoutnumberundefinedDefault timeout in milliseconds
urlTemplateStyleUrlTemplateStylePathURL template syntax (Path for :id, UriTemplate for {id})
interceptorsInterceptorManagerBuilt-in pipelineCustom interceptor manager
validateStatusValidateStatusstatus >= 200 && status < 300Status code validation function

Source: fetcher.ts:51

HTTP Methods

The Fetcher class exposes convenience methods for all standard HTTP verbs:

MethodSignatureBody Allowed
getget<R>(url, request?, options?)No
postpost<R>(url, request?, options?)Yes
putput<R>(url, request?, options?)Yes
patchpatch<R>(url, request?, options?)Yes
deletedel<R>(url, request?, options?)No
headhead<R>(url, request?, options?)No
optionsoptions<R>(url, request?, options?)No
tracetrace<R>(url, request?, options?)No

Source: fetcher.ts:325-500

Interceptor Pipeline

The interceptor system is the core extensibility mechanism. It processes requests through three ordered phases: request, response, and error.

mermaid
flowchart TD
    START["Incoming Request"]
    START --> REQ["Request Interceptors<br>(ordered by priority)"]

    REQ --> URI["UrlResolveInterceptor<br>Resolve URL with path/query params"]
    URI --> BODY["RequestBodyInterceptor<br>Serialize object bodies to JSON"]
    BODY --> FETCH["FetchInterceptor<br>Execute native fetch + timeout"]
    FETCH --> RESP_CHECK{Has Response?}

    RESP_CHECK -->|Yes| RESP["Response Interceptors"]
    RESP_CHECK -->|No| ERR["Error Interceptors"]

    RESP --> VALID["ValidateStatusInterceptor<br>Check HTTP status code"]
    VALID --> DONE["Success: return exchange"]

    ERR --> ERR_HANDLE["Custom Error Handlers"]
    ERR_HANDLE --> ERR_CHECK{Error cleared?}
    ERR_CHECK -->|Yes| DONE
    ERR_CHECK -->|No| THROW["Throw ExchangeError"]

    style START fill:#2d333b,stroke:#6d5dfc,color:#e6edf3
    style REQ fill:#161b22,stroke:#30363d,color:#e6edf3
    style URI fill:#161b22,stroke:#30363d,color:#e6edf3
    style BODY fill:#161b22,stroke:#30363d,color:#e6edf3
    style FETCH fill:#161b22,stroke:#30363d,color:#e6edf3
    style RESP_CHECK fill:#2d333b,stroke:#6d5dfc,color:#e6edf3
    style RESP fill:#161b22,stroke:#30363d,color:#e6edf3
    style VALID fill:#161b22,stroke:#30363d,color:#e6edf3
    style DONE fill:#2d333b,stroke:#6d5dfc,color:#e6edf3
    style ERR fill:#161b22,stroke:#30363d,color:#e6edf3
    style ERR_HANDLE fill:#161b22,stroke:#30363d,color:#e6edf3
    style ERR_CHECK fill:#2d333b,stroke:#6d5dfc,color:#e6edf3
    style THROW fill:#161b22,stroke:#30363d,color:#e6edf3

Built-in Interceptors

InterceptorPhaseOrderSource
RequestBodyInterceptorRequestVery earlyrequestBodyInterceptor.ts
UrlResolveInterceptorRequestVery lateurlResolveInterceptor.ts
FetchInterceptorRequestLatestfetchInterceptor.ts
ValidateStatusInterceptorResponseDefaultvalidateStatusInterceptor.ts

Custom Interceptors

typescript
import type { Interceptor, FetchExchange } from '@ahoo-wang/fetcher';

// Request interceptor: add authorization header
const authInterceptor: Interceptor = {
  name: 'AuthInterceptor',
  order: 100,
  intercept(exchange: FetchExchange) {
    exchange.ensureRequestHeaders()['Authorization'] = `Bearer ${getToken()}`;
  },
};

// Error interceptor: retry on 503
const retryInterceptor: Interceptor = {
  name: 'RetryInterceptor',
  order: 100,
  async intercept(exchange: FetchExchange) {
    if (exchange.error?.response?.status === 503) {
      // Retry the request
      const response = await fetch(exchange.request);
      exchange.response = response;
      exchange.error = undefined; // Clear error
    }
  },
};

// Register interceptors
fetcher.interceptors.request.use(authInterceptor);
fetcher.interceptors.error.use(retryInterceptor);

Source: interceptor.ts:44

UrlBuilder

Handles URL composition with path parameter interpolation and query string generation. Supports two template styles:

  • UrlTemplateStyle.Path: Express-style :id parameters
  • UrlTemplateStyle.UriTemplate: RFC 6570 {id} parameters
typescript
import { UrlBuilder, UrlTemplateStyle } from '@ahoo-wang/fetcher';

const builder = new UrlBuilder('https://api.example.com');

// URI Template style (default)
const url1 = builder.build('/users/{id}/posts/{postId}', {
  path: { id: 123, postId: 456 },
  query: { filter: 'active', limit: 10 },
});
// => https://api.example.com/users/123/posts/456?filter=active&limit=10

// Express style
const expressBuilder = new UrlBuilder(
  'https://api.example.com',
  UrlTemplateStyle.Path,
);
const url2 = expressBuilder.build('/users/:id', { path: { id: 789 } });
// => https://api.example.com/users/789

Source: urlBuilder.ts:72

FetchExchange

The FetchExchange is the context object that flows through the entire interceptor chain. It carries the request, response, error, result extractor, and shared attributes. (fetchExchange.ts:105)

PropertyTypeDescription
fetcherFetcherThe originating fetcher instance
requestFetchRequestFull request configuration
responseResponse | undefinedHTTP response (set by FetchInterceptor)
errorError | undefinedError if the request failed
resultExtractorResultExtractor<any>Function to extract the final result
attributesMap<string, any>Shared data map for cross-interceptor communication
typescript
// Access attributes in interceptors
const timingInterceptor: Interceptor = {
  name: 'TimingInterceptor',
  order: 100,
  intercept(exchange: FetchExchange) {
    exchange.attributes.set('startTime', Date.now());
  },
};

// Read in a later interceptor
const logInterceptor: Interceptor = {
  name: 'LogInterceptor',
  order: 200,
  intercept(exchange: FetchExchange) {
    const elapsed = Date.now() - exchange.attributes.get('startTime');
    console.log(`${exchange.request.url} took ${elapsed}ms`);
  },
};

Result Extractors

Result extractors control what the fetcher.get(), fetcher.post(), etc. methods return. By default, convenience methods return the raw Response object. (resultExtractor.ts)

ExtractorReturnsUse Case
ResultExtractors.ExchangeFetchExchangeAccess to full request/response context
ResultExtractors.ResponseResponseRaw response object (default for .fetch())
ResultExtractors.JsonPromise<any>Parsed JSON body
ResultExtractors.TextPromise<string>Response body as text
ResultExtractors.BlobPromise<Blob>Binary data (images, files)
ResultExtractors.ArrayBufferPromise<ArrayBuffer>Raw binary buffer
ResultExtractors.BytesPromise<Uint8Array>Byte array
typescript
// Default: returns Response
const response = await fetcher.get('/users');

// Override: get parsed JSON directly
const users = await fetcher.get<User[]>(
  '/users',
  {},
  { resultExtractor: ResultExtractors.Json },
);

// Custom extractor
const statusOnly = async (exchange: FetchExchange) => {
  return exchange.requiredResponse.status;
};
const status = await fetcher.get('/health', {}, {
  resultExtractor: statusOnly,
});

NamedFetcher and FetcherRegistrar

NamedFetcher extends Fetcher and automatically registers itself in the global FetcherRegistrar. This is the mechanism used by the decorator package to resolve which fetcher instance to use for each API class. (namedFetcher.ts:38, fetcherRegistrar.ts:41)

mermaid
graph LR
    subgraph sg_1 ["Global Registry"]
        REG["FetcherRegistrar"]
        REG --> DEFAULT["default<br>(auto-created)"]
        REG --> API["api<br>(user-created)"]
        REG --> ADMIN["admin<br>(user-created)"]
    end

    APP1["App Module"] --> REG
    APP2["Auth Module"] --> REG
    DEC["Decorator Classes"] --> REG

    style REG fill:#2d333b,stroke:#6d5dfc,color:#e6edf3
    style DEFAULT fill:#161b22,stroke:#30363d,color:#e6edf3
    style API fill:#161b22,stroke:#30363d,color:#e6edf3
    style ADMIN fill:#161b22,stroke:#30363d,color:#e6edf3
    style APP1 fill:#2d333b,stroke:#6d5dfc,color:#e6edf3
    style APP2 fill:#2d333b,stroke:#6d5dfc,color:#e6edf3
    style DEC fill:#2d333b,stroke:#6d5dfc,color:#e6edf3
typescript
import {
  NamedFetcher,
  fetcherRegistrar,
  fetcher,
} from '@ahoo-wang/fetcher';

// A default fetcher is pre-created and registered
console.log(fetcher === fetcherRegistrar.default); // true

// Create named fetchers with different configurations
const apiFetcher = new NamedFetcher('api', {
  baseURL: 'https://api.example.com',
  timeout: 5000,
});

const adminFetcher = new NamedFetcher('admin', {
  baseURL: 'https://admin.example.com',
  headers: { 'X-Admin-Key': 'secret' },
});

// Retrieve by name from anywhere in the app
const f = fetcherRegistrar.get('api');
await f?.get('/users');

// The decorator package uses this automatically:
// @api('/users', { fetcher: 'api' })
// class UserService { ... }

Error Classes

The error hierarchy provides structured error information including the failed exchange context.

mermaid
classDiagram
    class Error {
        +message: string
        +stack?: string
    }

    class FetcherError {
        +cause?: Error
        +name: "FetcherError"
    }

    class ExchangeError {
        +exchange: FetchExchange
        +name: "ExchangeError"
    }

    class EventStreamConvertError {
        +response: Response
        +name: "EventStreamConvertError"
    }

    Error <|-- FetcherError
    FetcherError <|-- ExchangeError
    FetcherError <|-- EventStreamConvertError
Error ClassDescriptionKey Property
FetcherErrorBase error for all fetcher errorscause - underlying error
ExchangeErrorThrown when interceptor pipeline failsexchange - full exchange context

Source: fetcherError.ts:37

typescript
try {
  await fetcher.get('/api/users');
} catch (error) {
  if (error instanceof ExchangeError) {
    console.log('Request URL:', error.exchange.request.url);
    console.log('Request method:', error.exchange.request.method);
    console.log('Underlying error:', error.exchange.error);
  }
}

Type Utilities

The package also exports several TypeScript utility types used across the ecosystem:

TypeDescriptionSource
PartialBy<T, K>Makes specified keys optionaltypes.ts:33
RequiredBy<T, K>Makes specified keys requiredtypes.ts:52
RemoveReadonlyFields<T>Strips readonly propertiestypes.ts:85
NamedCapableInterface with name: stringtypes.ts:141
OrderedCapableInterface with order: numberorderedCapable.ts
HttpMethodEnum of HTTP verbsfetchRequest.ts:37

Global Response Enhancement

The package augments the global Response interface with a generic json<T>() method for type-safe JSON parsing:

typescript
interface User { id: number; name: string; }

const response = await fetcher.get('/users/1');
const user = await response.json<User>();
console.log(user.name); // TypeScript infers `string`

Source: types.ts:162

Exported API Summary

ExportTypeSource
FetcherClassfetcher.ts
NamedFetcherClassnamedFetcher.ts
FetcherRegistrarClassfetcherRegistrar.ts
fetcherRegistrarInstancefetcherRegistrar.ts
fetcherInstancenamedFetcher.ts
InterceptorManagerClassinterceptorManager.ts
InterceptorRegistryClassinterceptor.ts
InterceptorInterfaceinterceptor.ts
FetchExchangeClassfetchExchange.ts
FetcherErrorClassfetcherError.ts
ExchangeErrorClassfetcherError.ts
UrlBuilderClassurlBuilder.ts
ResultExtractorsObjectresultExtractor.ts
FetcherOptionsInterfacefetcher.ts
FetchRequestInterfacefetchRequest.ts
HttpMethodEnumfetchRequest.ts

Released under the Apache License 2.0.