https://github.com/oorabona/unireq
Pipe-first, tree-shakeable, multi-protocol I/O toolkit
https://github.com/oorabona/unireq
client composable ftp graphql http oauth pipe smtp sse
Last synced: 5 days ago
JSON representation
Pipe-first, tree-shakeable, multi-protocol I/O toolkit
- Host: GitHub
- URL: https://github.com/oorabona/unireq
- Owner: oorabona
- License: mit
- Created: 2025-10-16T15:46:29.000Z (6 months ago)
- Default Branch: main
- Last Pushed: 2025-12-31T01:18:54.000Z (3 months ago)
- Last Synced: 2026-01-03T23:09:28.293Z (3 months ago)
- Topics: client, composable, ftp, graphql, http, oauth, pipe, smtp, sse
- Language: TypeScript
- Homepage: https://oorabona.github.io/unireq/
- Size: 631 KB
- Stars: 0
- Watchers: 0
- Forks: 0
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
- Contributing: CONTRIBUTING.md
- License: LICENSE
- Security: SECURITY.md
Awesome Lists containing this project
README
# @unireq/* β Pipe-first, tree-shakeable, multi-protocol I/O toolkit
[](https://github.com/oorabona/unireq/actions)
[](https://codecov.io/gh/oorabona/unireq)
[](https://opensource.org/licenses/MIT)
A modern, composable HTTP(S)/HTTP/2/IMAP/FTP client toolkit for Node.js β₯18, built on **undici** (Node's built-in fetch) with first-class support for:
- π **Pipe-first composition** β `compose(...policies)` for clean, onion-model middleware
- π³ **Tree-shakeable** β Import only what you need, minimal bundle size
- π **Smart OAuth Bearer** β JWT validation, automatic refresh on 401, single-flight token refresh
- π¦ **Rate limiting** β Reads `Retry-After` headers (429/503) and auto-retries
- π **Safe redirects** β Prefer 307/308 (RFC 9110), 303 opt-in
- π€ **Multipart uploads** β RFC 7578 compliant
- βΈοΈ **Resume downloads** β Range requests (RFC 7233, 206/416)
- π― **Content negotiation** β `either(json|xml)` branching
- π οΈ **Multi-protocol** β HTTP/2 (ALPN), IMAP (XOAUTH2), FTP/FTPS
---
## Quick Start
```bash
pnpm add @unireq/core @unireq/http
```
### Basic HTTP client
```ts
import { client } from '@unireq/core';
import { http, headers, parse } from '@unireq/http';
const api = client(
http('https://api.example.com'),
headers({ 'user-agent': 'unireq/1.0' }),
parse.json()
);
const response = await api.get('/users/123');
console.log(response.data); // Typed response
```
### Smart HTTPS client with OAuth, retries, and content negotiation
```ts
import { client, compose, either, retry, backoff } from '@unireq/core';
import { http, headers, parse, redirectPolicy, httpRetryPredicate, rateLimitDelay } from '@unireq/http';
import { oauthBearer } from '@unireq/oauth';
import { parse as xmlParse } from '@unireq/xml';
const smartClient = client(
http('https://api.example.com'),
headers({ accept: 'application/json, application/xml' }),
redirectPolicy({ allow: [307, 308] }), // Safe redirects only; strips sensitive headers cross-origin
retry(
httpRetryPredicate({ methods: ['GET', 'PUT', 'DELETE'], statusCodes: [429, 503] }),
[rateLimitDelay(), backoff({ initial: 100, max: 5000 })],
{ tries: 3 }
),
oauthBearer({ tokenSupplier: async () => getAccessToken() }),
either(
(ctx) => ctx.headers.accept?.includes('json'),
parse.json(),
xmlParse()
)
);
const user = await smartClient.get('/users/me');
```
---
## The 50-Line Axios Setup β 5 Lines
With axios, a production API client requires interceptors, retry logic, error handling, and token management scattered across multiple files:
```ts
// axios: 40+ lines of setup
const instance = axios.create({ baseURL: 'https://api.example.com' });
instance.interceptors.request.use(async (config) => {
const token = await getToken();
config.headers.Authorization = `Bearer ${token}`;
return config;
});
instance.interceptors.response.use(null, async (error) => {
if (error.response?.status === 401) {
const token = await refreshToken();
error.config.headers.Authorization = `Bearer ${token}`;
return instance(error.config);
}
if (error.response?.status === 429) {
const retryAfter = error.response.headers['retry-after'];
await sleep(retryAfter * 1000);
return instance(error.config);
}
throw error;
});
```
With @unireq, the same behavior is declarative:
```ts
// unireq: 7 lines
const api = client(http('https://api.example.com'),
oauthBearer({ tokenSupplier: getToken, autoRefresh: true }),
retry(httpRetryPredicate({ statusCodes: [429, 503] }),
[rateLimitDelay(), backoff()], { tries: 3 }),
parse.json()
);
```
---
## Why @unireq? β Batteries Included
Most HTTP clients solve the basics well. @unireq goes further by integrating common production needs out of the box:
| Feature | @unireq | axios | ky | got | node-fetch |
|---------|:-------:|:-----:|:--:|:---:|:----------:|
| **Bundle size (min+gz)** | ~8 KB | ~40 KB | ~12 KB | ~46 KB | ~4 KB |
| **Tree-shakeable** | β
| β | β
| β | β
|
| **TypeScript-first** | β
| β οΈ | β
| β
| β οΈ |
| **Composable middleware** | β
Onion model | β
Interceptors | β
Hooks | β
Hooks | β |
| **OAuth + JWT validation** | β
Built-in | β Manual | β Manual | β Manual | β Manual |
| **Rate limit (Retry-After)** | β
Automatic | β Manual | β οΈ Partial | β οΈ Partial | β |
| **Circuit breaker** | β
Built-in | β | β | β | β |
| **Multi-protocol** | β
HTTP/HTTP2/FTP/IMAP | β HTTP only | β HTTP only | β HTTP only | β HTTP only |
| **Introspection API** | β
Debug any request | β | β | β | β |
| **Resume downloads** | β
Range requests | β | β | β οΈ | β |
| **Safe redirects (307/308)** | β
By default | β οΈ All allowed | β οΈ All allowed | β οΈ All allowed | β οΈ All allowed |
| **100% test coverage** | β
| β | β | β
| β |
### Performance
Benchmarked against axios, got, ky, and raw undici/fetch on Node v24 (local HTTP server, no TLS). All libraries use persistent clients; 20 warmup iterations per run.
| Scenario | Result |
|----------|--------|
| Sequential GET (1000 req) | **36% more throughput** than axios, **49% more** than got |
| Concurrent GET (100 parallel) | **13Γ the throughput** of native fetch, **11Γ axios** |
| POST JSON (1000 req) | **41% more throughput** than native fetch, **46% more** than axios |
| Large payload (100KB JSON) | Matches raw undici; **19% more throughput** than native fetch |
| Retry with backoff (flaky server) | **2Γ more throughput** than axios/ky |
| ETag cache hits | **43-63Γ more throughput** than manual If-None-Match |
| 3-policy composition stack | Only **+20% overhead** over bare transport |
> Full methodology, per-library numbers, and code comparisons: **[BENCHMARKS.md](./BENCHMARKS.md)**
### What sets @unireq apart
1. **Pipe-first composition** β Build clients declaratively with `compose(...policies)`. No magic, just functions.
2. **Production-ready auth** β OAuth Bearer with JWT introspection, automatic token refresh on 401, clock skew tolerance. No boilerplate.
3. **Smart retries** β Combines multiple strategies: `rateLimitDelay()` reads `Retry-After` headers, `backoff()` handles transient failures. Works together seamlessly.
4. **Multi-protocol** β Same API for HTTP, HTTP/2, IMAP, FTP. Switch transports without rewriting business logic.
5. **Secure by default** β `redirectPolicy()` strips sensitive headers (`Authorization`, `Cookie`) on cross-origin redirects and blocks HTTPSβHTTP downgrades. `cache()` respects `Vary`, skips `Authorization`-gated responses, and never stores `Cache-Control: private` resources.
6. **Introspection** β Debug any request with `introspect()`: see exact headers, timing, retries, and policy execution order.
7. **Minimal footprint** β Import only what you use. The core is ~8 KB, and tree-shaking removes unused policies.
### When to use something else
- **Quick scripts**: `node-fetch` or native `fetch` if you just need simple GET/POST
- **Browser-only**: `ky` offers excellent browser support with smaller footprint
- **Legacy Node.js**: `axios` if you need Node < 18 support
---
## Architecture
### Packages
| Package | Description |
|---------|-------------|
| **`@unireq/core`** | Client factory, `compose`, `either`, slots, DX errors |
| **`@unireq/http`** | `http()` transport (undici), policies, body/parse, multipart, range |
| **`@unireq/http2`** | `http2()` transport via `node:http2` (ALPN) |
| **`@unireq/oauth`** | OAuth Bearer + JWT + 401 refresh (RFC 6750) |
| **`@unireq/cookies`** | `tough-cookie` + `http-cookie-agent/undici` |
| **`@unireq/xml`** | `fast-xml-parser` policy |
| **`@unireq/imap`** | IMAP transport via `imapflow` (XOAUTH2) |
| **`@unireq/ftp`** | FTP transport via `basic-ftp` |
| **`@unireq/presets`** | Pre-configured clients (httpsJsonAuthSmart, etc.) |
### Composition model
**Onion middleware** via `compose(...policies)`:
```ts
const policy = compose(
policyA, // Pre-call (outer layer)
policyB, // Pre-call (middle layer)
policyC // Pre-call (inner layer)
);
// Execution: A β B β C β transport β C β B β A
```
**Conditional branching** via `either(pred, then, else)`:
```ts
import { either } from '@unireq/core';
import { parse } from '@unireq/http';
import { parse as xmlParse } from '@unireq/xml';
either(
(ctx) => ctx.headers.accept?.includes('json'),
parse.json(), // If true: parse as JSON
xmlParse() // If false: parse as XML
);
```
---
## HTTP Semantics References
### Redirects
- **307 Temporary Redirect** β Preserves method and body ([MDN](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/307), [RFC 9110 Β§15.4.8](https://datatracker.ietf.org/doc/html/rfc9110#section-15.4.8))
- **308 Permanent Redirect** β Preserves method and body ([MDN](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/308), [RFC 9110 Β§15.4.9](https://datatracker.ietf.org/doc/html/rfc9110#section-15.4.9))
- **303 See Other** β Converts to GET (opt-in via `follow303`) ([MDN](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/303), [RFC 9110 Β§15.4.4](https://datatracker.ietf.org/doc/html/rfc9110#section-15.4.4))
```ts
redirectPolicy({ allow: [307, 308], follow303: false });
```
### Rate Limiting
- **429 Too Many Requests** + `Retry-After` ([MDN](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/429), [RFC 6585](https://datatracker.ietf.org/doc/html/rfc6585))
- **503 Service Unavailable** + `Retry-After` ([MDN](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/503), [RFC 9110 Β§15.6.4](https://datatracker.ietf.org/doc/html/rfc9110#section-15.6.4))
```ts
import { retry } from '@unireq/core';
import { httpRetryPredicate, rateLimitDelay } from '@unireq/http';
retry(
httpRetryPredicate({ statusCodes: [429, 503] }),
[rateLimitDelay({ maxWait: 60000 })],
{ tries: 3 }
);
```
### Range Requests
- **206 Partial Content** β Server sends requested byte range ([MDN](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/206), [RFC 7233](https://datatracker.ietf.org/doc/html/rfc7233))
- **416 Range Not Satisfiable** β Invalid range ([MDN](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/416), [RFC 7233 Β§4.4](https://datatracker.ietf.org/doc/html/rfc7233#section-4.4))
- **`Accept-Ranges: bytes`** β Server supports ranges ([MDN](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept-Ranges))
```ts
range({ start: 0, end: 1023 }); // Request first 1KB
resume({ downloaded: 5000 }); // Resume from byte 5000
```
### Multipart Form Data
- **RFC 7578** β `multipart/form-data` ([spec](https://datatracker.ietf.org/doc/html/rfc7578))
```ts
multipart(
[{ name: 'file', filename: 'doc.pdf', data: blob, contentType: 'application/pdf' }],
[{ name: 'title', value: 'My Document' }]
);
```
### OAuth 2.0 Bearer
- **RFC 6750** β Bearer token usage ([spec](https://datatracker.ietf.org/doc/html/rfc6750))
```ts
oauthBearer({
tokenSupplier: async () => getAccessToken(),
skew: 60, // Clock skew tolerance (seconds)
autoRefresh: true // Refresh on 401
});
```
---
## Why undici (Node's built-in fetch)?
Starting with Node.js 18, the global `fetch` API is powered by [**undici**](https://undici.nodejs.org), a fast, spec-compliant HTTP/1.1 client. Benefits:
- β
**No external dependencies** for HTTP/1.1
- β
**Streams, AbortController, FormData** built-in
- β
**HTTP/2 support** via ALPN (requires explicit opt-in or `@unireq/http2`)
- β
**Maintained by Node.js core team**
> **Note**: `fetch` defaults to HTTP/1.1. For HTTP/2, use `@unireq/http2` (see [Why HTTP/2 transport?](#why-http2-transport)).
---
## Why HTTP/2 transport?
Node's `fetch` (undici) defaults to HTTP/1.1, even when servers support HTTP/2. While undici *can* negotiate HTTP/2 via ALPN, it requires explicit configuration not available in the global `fetch` API.
`@unireq/http2` provides:
- β
**Explicit HTTP/2** via `node:http2`
- β
**ALPN negotiation**
- β
**Multiplexing** over a single connection
- β
**Server push** (opt-in)
```ts
import { client } from '@unireq/core';
import { http2 } from '@unireq/http2';
const h2Client = client(http2('https://http2.example.com'));
```
---
## Real-World Recipes
### REST API with auth, retries, and caching
```ts
import { restApi } from '@unireq/presets';
const github = restApi()
.bearer(process.env.GITHUB_TOKEN)
.retry(3)
.timeout(10_000)
.build('https://api.github.com');
const repos = await github.get('/user/repos');
```
### Download with progress and resume
```ts
import { client } from '@unireq/core';
import { http, progress, resume } from '@unireq/http';
const state = { downloaded: 0 };
const dl = client(http('https://releases.example.com'),
progress({ onDownloadProgress: (p) => console.log(`${p.percent}%`) }),
resume(state)
);
await dl.get('/large-file.zip');
```
### GraphQL with automatic JSON parsing
```ts
import { client } from '@unireq/core';
import { graphql } from '@unireq/graphql';
const gql = client(graphql('https://countries.trevorblades.com'));
const { data } = await gql.post('/', {
body: { query: '{ countries { name capital } }' }
});
```
### More examples
See [`examples/`](./examples) for 20+ runnable demos:
| Category | Examples |
|----------|----------|
| **HTTP Basics** | `http-basic.ts`, `http-verbs.ts` |
| **Authentication** | `oauth-refresh.ts` |
| **Resilience** | `retry-backoff.ts` |
| **Uploads** | `multipart-upload.ts`, `bulk-document-upload.ts`, `streaming-upload.ts` |
| **Downloads** | `streaming-download.ts` |
| **GraphQL** | `graphql-query.ts`, `graphql-mutation.ts` |
| **Real-time** | `sse-events.ts` |
| **Caching** | `conditional-etag.ts`, `conditional-lastmodified.ts`, `conditional-combined.ts` |
| **Interceptors** | `interceptors-logging.ts`, `interceptors-metrics.ts`, `interceptors-cache.ts` |
| **Validation** | `validation-demo.ts`, `validation-adapters.ts` |
Run all examples: `pnpm examples:all`
---
## Quality Gates
| Metric | Requirement |
|--------|-------------|
| **Core bundle size** | < 8 KB (min+gz, excl. peers) |
| **Test coverage** | 100% (lines/functions/branches/statements) |
| **Test suite** | 4229 tests across 14 packages |
| **Linter** | Biome (clean) |
| **ESM** | All exports pass |
| **CI** | pnpm Γ Node 20/22/24 |
---
## Development
```bash
# Install
pnpm install
# Build all packages
pnpm build
# Lint & format
pnpm lint
pnpm lint:fix
# Test with coverage (100% gate)
pnpm test:coverage
# Release
pnpm release
```
---
## License
MIT Β© [Olivier Orabona](https://github.com/oorabona)