Skip to content

Repository files navigation

@npora/request

A TypeScript-first HTTP client built on the standard Fetch API.

Npora Request adds typed configuration, a predictable request lifecycle, runtime response validation, streaming, interceptors, plugins, unified errors, retries, caching, concurrency control, circuit breaking, authentication, and upload/download progress without adding runtime dependencies.

Features

  • Data-first and complete-response APIs with TypeScript inference.
  • Standard Schema v1 validation and transformation for untrusted responses.
  • Incremental SSE and NDJSON async iterables with cancellation and size limits.
  • Unified errors across Fetch, XHR, parsing, validation, timeouts, and aborts.
  • Request/response/error interceptors with deterministic priority ordering.
  • Official retry, cache, circuit-breaker, concurrency, authentication, logger, upload, and download plugins.
  • Method-aware MockAdapter routing, matching, delays, failures, and history.
  • Browser, Web Worker, ESM, and CommonJS support with zero runtime dependencies.

Install

pnpm add @npora/request
npm install @npora/request

Node.js 22 or newer is required. Modern Chromium-based browsers, Firefox, Safari/WebKit, and Web Workers are supported through their native Fetch implementations.

Version support

Version Status Guidance
latest Current Recommended for all new and existing applications.
Earlier releases >=1.0.0 Historical stable Upgrade to latest for current fixes and security hardening.
<1.0.0 Deprecated and unsupported Upgrade immediately; 0.x receives no fixes or security updates.

All published releases before 1.0.0 are deprecated by project policy. Do not use a 0.x release in production, documentation examples, dependency templates, or new lockfiles.

Quick start

import { createClient } from '@npora/request'

interface User {
  id: number
  name: string
}

const api = createClient({
  baseURL: 'https://api.example.com',
  timeout: 5000,
  headers: {
    'x-app': 'dashboard'
  }
})

const user = await api.get<User>('/users/1')

Data-first methods return the parsed response body. Use a response method when status, headers, or the native Response is needed:

const response = await api.getResponse<User>('/users/1')

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

Response validation

Validate untrusted response data with any Standard Schema v1 compatible library, including Zod 3.24+, Valibot, or ArkType. The schema output type is inferred automatically and schemas may transform the parsed value:

import { z } from 'zod'

const userSchema = z.object({
  id: z.number(),
  name: z.string()
})

const user = await api.get('/users/1', {
  schema: userSchema
})

console.log(user.name)

Validation failures throw SchemaValidationError with the stable SCHEMA_ERROR code, validation issues, schema vendor, parsed response data, and complete response metadata. Standard Schema support adds no runtime dependency to Npora Request. Schemas are configured per endpoint so one endpoint contract cannot be inherited by unrelated requests.

Streaming responses

SSE and NDJSON responses are decoded incrementally as async iterables. The client also detects text/event-stream and common NDJSON content types automatically:

import type { ServerSentEvent } from '@npora/request'

const events = await api.sse('/events')

for await (const event of events) {
  console.log(event.event, event.data, event.id)
}

const records = await api.ndjson<User>('/users/export')

for await (const user of records) {
  console.log(user.name)
}

Iteration is lazy and does not buffer the complete response. Breaking out of the loop cancels the response reader. maxResponseSize remains enforced while the stream is consumed. A response schema validates the parsed response value once; it does not validate individual SSE events or NDJSON records.

Request configuration

await api.post('/users', {
  query: {
    notify: true
  },
  json: {
    name: 'Npora'
  },
  fetchOptions: {
    credentials: 'include'
  }
})

The body options body, json, form, and formData are mutually exclusive. GET and HEAD requests cannot contain a body. Invalid configuration throws a RequestError before the adapter sends a request.

Create isolated clients with inherited defaults:

const adminApi = api.extend({
  baseURL: 'https://api.example.com/admin',
  headers: {
    'x-role': 'admin'
  }
})

The adapter and configuration are inherited. Plugins and interceptors remain isolated to each client.

HTTP methods

api.request(config)
api.requestResponse(config)

api.get(url, config)
api.post(url, config)
api.put(url, config)
api.patch(url, config)
api.delete(url, config)
api.head(url, config)
api.options(url, config)
api.sse(url, config)
api.ndjson(url, config)

api.getResponse(url, config)
api.postResponse(url, config)
api.putResponse(url, config)
api.patchResponse(url, config)
api.deleteResponse(url, config)
api.headResponse(url, config)
api.optionsResponse(url, config)
api.sseResponse(url, config)
api.ndjsonResponse(url, config)

Plugins

import {
  authPlugin,
  cachePlugin,
  circuitBreakerPlugin,
  concurrencyPlugin,
  createClient,
  retryPlugin
} from '@npora/request'

const cache = cachePlugin()
const request = createClient()
  .use(retryPlugin({
    retries: 2,
    delay: 200,
    jitter: true,
    maxElapsedTime: 10000
  }))
  .use(circuitBreakerPlugin())
  .use(concurrencyPlugin({ maxConcurrent: 20 }))
  .use(cache)
  .use(authPlugin({
    token: () => accessToken,
    refreshToken
  }))

const user = await request.get<User>('/users/1', {
  extensions: {
    cache: {
      enabled: true,
      ttl: 30000
    }
  }
})

cache.clear()

Equivalent concurrent cache-enabled requests share one network operation by default. Pass a custom CacheStore to share cached entries across clients or connect an external storage system:

const cache = cachePlugin({
  store: sharedStore,
  dedupe: true
})

The default memory store keeps up to 1,000 entries using LRU eviction and removes expired entries when read. Set maxEntries to tune the bound, use 0 to disable storage, or use Infinity for an explicitly unbounded store.

Built-in plugins:

  • retryPlugin()
  • cachePlugin()
  • circuitBreakerPlugin()
  • concurrencyPlugin()
  • authPlugin()
  • loggerPlugin()
  • uploadPlugin()
  • downloadPlugin()

Plugin-owned request options belong under extensions. Third-party plugins can augment RequestExtensions through TypeScript module augmentation.

Protect a failing upstream after retries are exhausted:

const breaker = circuitBreakerPlugin({
  failureThreshold: 5,
  resetTimeout: 30000,
  maxCircuits: 1000
})

const request = createClient()
  .use(retryPlugin({ retries: 2 }))
  .use(breaker)

Circuits are isolated by request origin by default. Open circuits reject with the stable CIRCUIT_OPEN error code and permit a bounded half-open probe after the recovery window. Inactive circuit state is retained with LRU eviction; active requests are never evicted and can temporarily exceed maxCircuits.

Bound concurrent logical requests and queue short bursts before adapter I/O:

const concurrency = concurrencyPlugin({
  maxConcurrent: 20,
  maxQueue: 200,
  queueTimeout: 5000
})

const request = createClient().use(concurrency)

Limits are isolated by resolved request origin. Queued requests are admitted in FIFO order and remain abortable through their request signal. Full queues and expired queue waits reject with the stable CONCURRENCY_LIMIT error code.

Inject a structured logger when request correlation and timing are needed:

const request = createClient().use(
  loggerPlugin({
    logger: {
      info(_message, entry) {
        applicationLogger.info(entry)
      },
      error(_message, entry) {
        applicationLogger.error(entry)
      }
    }
  })
)

Lifecycle entries include a request identifier and timestamp. Completed responses include total duration and attempt count; errors include the failed attempt number. The default logger continues to use console.

Interceptors

api.interceptors.request.use(config => config)
api.interceptors.response.use(response => response)
api.interceptors.error.use(error => error)

An optional numeric priority controls execution order. Higher priorities run first; equal priorities preserve registration order.

Request testing

MockAdapter supports method-aware routes, dynamic or one-time responses, query/header matching, delay, timeout and network-error simulation, and request history:

const adapter = new MockAdapter()

adapter
  .onGet('/users/1')
  .replyOnce(503, { message: 'busy' })
  .onGet('/users/1')
  .reply(200, { id: 1, name: 'Npora' })

const api = createClient({ adapter })

Errors

import { RequestError } from '@npora/request'

try {
  await api.get('/users/missing')
} catch (error) {
  if (error instanceof RequestError) {
    console.error(error.code)
    console.error(error.status)
    console.error(error.data)
  }
}

Stable request error codes:

  • CONFIG_ERROR
  • HTTP_ERROR
  • NETWORK_ERROR
  • TIMEOUT_ERROR
  • ABORT_ERROR
  • PARSER_ERROR
  • SCHEMA_ERROR
  • RESPONSE_TOO_LARGE
  • CIRCUIT_OPEN
  • CONCURRENCY_LIMIT

SchemaValidationError extends RequestError, so applications can handle all request failures through the unified base class while still inspecting schema issues when error.code === 'SCHEMA_ERROR'.

Supply-chain verification

The published package contains only the built dist artifacts, has no runtime dependencies, and is released through npm trusted publishing with provenance. Repository release gates verify the exact dependency tree against malware advisories, npm registry signatures, known vulnerabilities, the package file allowlist, and size budgets.

Documentation

Project policies:

Development

pnpm install --frozen-lockfile
pnpm typecheck
pnpm test:coverage
pnpm build
pnpm test:package
pnpm test:browser

See the testing and release gates for the complete matrix.

Versioning

@npora/request follows Semantic Versioning. Package-root exports, their TypeScript declarations, documented behavior, stable error codes, and plugin lifecycle are public API. Breaking changes require a new major version.

Internal modules that are not exported from the package root are not public API.

License

MIT © Npora Team

About

A modern, TypeScript-first HTTP client powered by the Fetch API.

Topics

Resources

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages