Skip to content

Repository files navigation

Fetch HTTP Client

A small, framework-agnostic TypeScript HTTP client built on the native fetch API. It is designed as an HTTP port and adapter for applications that use ports and adapters / hexagonal architecture.

Application services depend on the HttpClient contract. FetchHttpClient is the official native-fetch adapter. Each client instance owns its backend URL, headers, timeout, and injected fetch implementation, so one application can use multiple independently configured backends without global mutable state.

The package is prepared for public npm publication but has not been published yet.

Architecture

Application or domain adapter
          |
          v
      HttpClient port
          |
          v
   FetchHttpClient adapter
          |
          v
      Native fetch

Domain services receive an HttpClient and make relative requests. They do not construct backend origins or depend directly on native fetch.

Current capabilities

  • HttpClient port with request, get, post, put, patch, and delete.
  • Instance-based FetchHttpClient configuration.
  • Relative URL resolution against a configured baseUrl.
  • Absolute URL support when passed deliberately.
  • Default and per-request headers.
  • Primitive and repeated-key query serialization.
  • JSON serialization for object request bodies.
  • Preservation of native bodies such as FormData, Blob, ArrayBuffer, URLSearchParams, strings, and streams.
  • Automatic JSON, text, blob, empty-body, 204, and 205 response parsing.
  • Parsed response data plus the original Response.
  • Distinct HttpError, NetworkError, TimeoutError, and ParseError types.
  • Default and per-request timeouts.
  • Caller-provided cancellation signals.
  • Cross-origin credential protection for absolute URLs.
  • Ordered, application-defined interceptors with immutable request transforms.
  • Request metadata for transport-adjacent interceptor state.
  • Injected fetch support for tests and alternative runtimes.
  • Browser and modern Node.js compatibility through platform APIs.

Authentication, token storage, refresh behavior, retry policy, caching, and framework integrations remain outside this package.

Development setup

Install dependencies, build, and run the test suite:

npm install
npm run build
npm test

After publication, consumers will install the package with:

npm install @mikode/fetch-http-client

Available package commands:

Command Purpose
npm run build Compile TypeScript into dist/.
npm run check Run every local publication quality gate.
npm run format Format supported project files with Prettier.
npm run lint Check source and configuration files with ESLint.
npm run package:check Build and validate the published package structure.
npm test Run the unit test suite once.
npm run test:watch Run tests in watch mode.
npm run typecheck Type-check without emitting build output.
npm run agents:setup Install/validate the project agent harness.
npm run agents:doctor Diagnose the project agent harness without installing.

Published package contents

The npm manifest uses a files allowlist. Publishing runs the TypeScript build through prepack, and consumers receive only the compiled runtime package:

dist/          # JavaScript and TypeScript declarations
LICENSE        # MIT license
package.json   # npm metadata and public entry point
README.md      # Package usage documentation

Development-only material is excluded from the npm tarball, including src/, tests/, .agents/, .codex, .claude, scripts/, AGENTS.md, TypeScript configuration, Vitest configuration, and development dependencies. There is no consumer-facing postinstall hook.

Basic usage

import { FetchHttpClient } from '@mikode/fetch-http-client';

interface UserDto {
  id: string;
  name: string;
}

const client = new FetchHttpClient({
  baseUrl: 'https://api.example.com/v1/',
  headers: {
    Accept: 'application/json',
  },
  timeout: 10_000,
});

const response = await client.get<UserDto>('users/1');

console.log(response.data.name);
console.log(response.status);
console.log(response.headers);
console.log(response.raw);

Depend on the port

import type { HttpClient } from '@mikode/fetch-http-client';

interface UserDto {
  id: string;
  name: string;
}

class UsersApi {
  public constructor(private readonly httpClient: HttpClient) {}

  public async getUser(userId: string): Promise<UserDto> {
    const response = await this.httpClient.get<UserDto>(`users/${userId}`);
    return response.data;
  }
}

Configure multiple backends

const usersClient = new FetchHttpClient({
  baseUrl: 'https://users.example.com/api/',
});

const billingClient = new FetchHttpClient({
  baseUrl: 'https://billing.example.com/api/',
});

const usersApi = new UsersApi(usersClient);
const billingApi = new BillingApi(billingClient);

Each API service continues to use relative paths and does not know the backend origin.

Request examples

Query parameters

await client.get<UserDto[]>('users', {
  query: {
    page: 2,
    active: true,
    roles: ['admin', 'editor'],
    optional: undefined,
    empty: null,
  },
});

This produces:

?page=2&active=true&roles=admin&roles=editor&empty=

undefined values are omitted, null becomes an empty value, booleans become true or false, and arrays become repeated keys. Nested query objects are not supported.

JSON bodies

interface CreateUserInput {
  name: string;
}

const response = await client.post<UserDto, CreateUserInput>('users', {
  name: 'Ada',
});

Plain objects are serialized as JSON. Content-Type: application/json is added unless the request already supplies a content type.

Request options

const controller = new AbortController();

const request = client.get<UserDto>('users/1', {
  headers: { 'X-Trace-Id': 'trace-123' },
  credentials: 'include',
  timeout: 5_000,
  signal: controller.signal,
});

controller.abort();
await request;

Native AbortError values from caller cancellation are rethrown. A timeout raised by the client becomes TimeoutError. The timeout remains active until the response body has been consumed and parsed.

Explicit response type

const text = await client.get<string>('health', {
  responseType: 'text',
});

const file = await client.get<Blob>('reports/latest', {
  responseType: 'blob',
});

Without an explicit type, JSON content types are parsed as JSON, text/* as text, and other content as a blob. Empty bodies and 204 or 205 responses return undefined data.

Interceptors

Interceptors are generic middleware supplied when a client is created. They can transform an immutable HttpRequest, inspect or replace responses, handle errors, return synthetic responses, or implement application-defined retries.

import type { HttpInterceptor } from '@mikode/fetch-http-client';

const traceInterceptor: HttpInterceptor = {
  async intercept(request, next) {
    return next(request.withHeader('X-Trace-Id', 'trace-123'));
  },
};

const client = new FetchHttpClient({
  baseUrl: 'https://api.example.com/',
  interceptors: [traceInterceptor],
});

For [first, second], execution is nested and deterministic: first runs before second, then transport runs, followed by second and first after transport resolves. Calling next(request) runs only downstream interceptors and transport. This permits a bounded retry interceptor to call next again without re-entering itself or upstream middleware. The package does not add an automatic retry policy.

request.withHeader(name, value), request.clone(changes), and request.withMetadata(entries) return new requests. Metadata is copied through these transforms and is never serialized into a URL, headers, or body. It is intended for local middleware state such as a retry marker:

const retryMarker = request.withMetadata({ retried: true });

Native Headers access is defensive: mutating request.headers does not mutate that request. Use the immutable helper methods to pass a changed request downstream.

URL behavior

URL composition is centralized and intentionally distinguishes paths with and without a leading slash:

baseUrl: https://api.example.com/v1/
path:    users
result:  https://api.example.com/v1/users
baseUrl: https://api.example.com/v1/
path:    /users
result:  https://api.example.com/users

A leading slash replaces the base URL path. A path without a leading slash is appended to it. Existing base URL query parameters are preserved and request query parameters are appended.

Absolute URLs are accepted. When an absolute URL has a different origin from the configured baseUrl, or when no trusted baseUrl is configured, the client removes Authorization, Proxy-Authorization, and Cookie headers and resets the request credentials mode. Other headers are preserved.

If a dedicated client intentionally sends credentials to multiple origins, it must opt in explicitly:

const client = new FetchHttpClient({
  baseUrl: 'https://api.example.com/',
  allowCrossOriginCredentials: true,
});

This opt-in applies to the whole client, so use it only with trusted absolute URLs. Prefer a separate FetchHttpClient instance for each backend.

Error handling

import { HttpError, NetworkError, ParseError, TimeoutError } from '@mikode/fetch-http-client';

try {
  await client.get<UserDto>('users/1');
} catch (error) {
  if (error instanceof HttpError) {
    console.error(error.status, error.data);
  } else if (error instanceof TimeoutError) {
    console.error('The request exceeded its timeout.');
  } else if (error instanceof NetworkError) {
    console.error('No response was obtained.', error.cause);
  } else if (error instanceof ParseError) {
    console.error('The response could not be parsed.', error.cause);
  } else {
    // Includes caller-initiated AbortError values.
    throw error;
  }
}

Non-success responses are parsed before HttpError is created, allowing a consumer to inspect backend error data through error.data. Response parsing consumes the native body stream, including the raw response body; use the parsed data value for body access.

Release checks

npm run check is the local and CI quality gate. It verifies formatting, linting, TypeScript, tests, and the npm package structure. prepublishOnly runs the same gate, while every build removes stale dist/ output before compiling.

GitHub Actions runs the gate on supported Node.js release lines. Publishing is triggered by a published GitHub release and uses npm trusted publishing, which adds provenance automatically without a long-lived npm token. Create the npm package with an initial manual publish, then configure mikode13/fetch, publish.yml, the npm environment, and the npm publish action as the package's trusted publisher. Protect the GitHub npm environment if release approval is desired. See npm's trusted publishing guide for the registry-side setup.

Injecting fetch

fetch can be injected for unit tests or compatible runtimes:

const fetchMock: typeof globalThis.fetch = async () =>
  new Response(JSON.stringify({ id: '1', name: 'Ada' }), {
    status: 200,
    headers: { 'Content-Type': 'application/json' },
  });

const client = new FetchHttpClient({
  baseUrl: 'https://api.example.com/',
  fetch: fetchMock,
});

The implementation uses globalThis.fetch only when no implementation is provided.

Portable Codex and Claude Code harness

The repository includes a project-scoped multi-agent development harness. Its canonical definitions live under .agents/:

.agents/
├── codex/
│   ├── config.toml
│   └── agents/
│       ├── explorer.toml
│       └── implementer.toml
└── claude/
    └── agents/
        └── verifier.md

.codex          -> .agents/codex
.claude/agents  -> ../.agents/claude/agents

The tool-specific discovery paths are symbolic links on POSIX systems and may be junctions on Windows. .agents/ remains the single canonical location.

Agent responsibilities

Agent Tool Responsibility Access
Root orchestrator Codex Requirements, architecture, plan, coordination, and acceptance Workspace
explorer Codex Bounded codebase investigation and evidence gathering Read-only
implementer Codex Bounded implementation of an approved plan Workspace write
verifier Claude Code Independent review of the diff against the plan and AGENTS.md Read-only/plan

Only independent read-heavy work should run in parallel. Agents that edit the shared working tree are serialized.

Install the CLIs manually

Current official installers for macOS and Linux:

curl -fsSL https://chatgpt.com/codex/install.sh | sh
curl -fsSL https://claude.ai/install.sh | bash

Windows PowerShell:

powershell -ExecutionPolicy ByPass -c "irm https://chatgpt.com/codex/install.ps1 | iex"
irm https://claude.ai/install.ps1 | iex

Homebrew on macOS:

brew install --cask codex
brew install --cask claude-code

Installation references:

Each machine must install and authenticate both tools. Credentials are stored locally and must never be committed. Launch each CLI once to complete sign-in:

codex
claude

Automated harness setup

The repository can install missing CLIs, create missing discovery paths, and run the doctor automatically:

npm run agents:setup

The setup process never overwrites an existing discovery path. Conflicts are left untouched and reported with guidance. It finishes by running the doctor as a post-setup hook.

The project deliberately does not use the package postinstall lifecycle. A normal dependency installation must not install unrelated developer tools or affect downstream consumers.

Harness doctor

Run diagnostics without installing anything:

npm run agents:doctor

The doctor checks:

  • Whether codex and claude are available through PATH.
  • CLI versions when available.
  • AGENTS.md and all canonical agent definitions.
  • Codex and Claude project discovery paths.
  • Whether discovery paths can drift from the canonical .agents/ directory.
  • The next authentication step for each installed CLI.

It exits non-zero when a required item is missing, so it can also validate a prepared development machine or CI image.

Platform scripts can be called directly:

./scripts/setup-agents.sh
./scripts/doctor-agents.sh
.\scripts\setup-agents.ps1
.\scripts\doctor-agents.ps1

See scripts/README.md for the focused setup reference.

Use the harness from a Codex message

No slash command is required. Start Codex in this repository and explicitly ask it to use the project harness:

Use the project-scoped multi-agent harness for this non-trivial task.

Task:
<describe the requested change>

Acceptance criteria:
- <observable requirement>
- <tests or edge cases>

Workflow:
1. Delegate bounded read-only investigation to the explorer.
2. Establish a concrete implementation plan.
3. Delegate the approved plan to one implementer.
4. Review the diff and run the relevant checks.
5. Prepare the diff and approved plan for the independent verifier.

To let the root orchestrator continue without pausing for plan approval, add:

You may approve the implementation plan and continue without waiting for me.

For small, low-risk changes, the root agent may work directly when delegation would add more coordination than value.

Run the independent verifier

The verifier runs separately in Claude Code after implementation. Start Claude Code in the repository and send:

Use the verifier agent to review the current diff against AGENTS.md and this
approved implementation plan:

<paste the approved plan>

The verifier receives only the resulting diff, AGENTS.md, and the approved plan. It does not receive or reconstruct the implementer's private reasoning.

Harness portability

The harness works when the repository is cloned or opened as the active project on a supported machine. Installing a CLI once makes its command available from any terminal where its installation directory is in PATH, but project agents are discovered only when the CLI runs in this repository.

On Windows, Git may require Developer Mode or appropriate symlink support to preserve symbolic links. The PowerShell setup uses directory junctions when a discovery path is absent. It does not remove or replace an existing file, directory, link, or junction.

Project structure

src/
├── client/       # Native fetch adapter
├── contracts/    # Public HTTP port and request/response contracts
├── errors/       # Public error types
└── utils/        # Internal URL, query, header, and parsing helpers

tests/            # Unit tests using injected fetch implementations
.agents/          # Canonical project agent definitions
scripts/          # Cross-platform harness setup and doctor tools

Internal helpers are intentionally not exported from the package root.

Scope and non-goals

This package is generic HTTP transport infrastructure. It must not contain:

  • Domain DTOs or business models.
  • Authentication or token refresh policies.
  • Token storage or login/logout behavior.
  • React, Vue, Angular, Next.js, or NestJS integration.
  • A dependency injection container or service locator.
  • Server-state caching, deduplication, or background refetching.
  • Automatic retries of unsafe requests.
  • Global backend configuration.

Application-specific policies can be composed outside the package and injected through public contracts as the package evolves.

Development rules

Read AGENTS.md before changing the implementation. A completed change must preserve strict TypeScript, the HttpClient/FetchHttpClient boundary, instance-based configuration, injected-fetch testability, explicit error behavior, documentation, and passing build/tests.

About

Fetch library with improvements

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages