{"id":35197033,"url":"https://github.com/oorabona/unireq","last_synced_at":"2026-04-01T19:47:17.089Z","repository":{"id":331166476,"uuid":"1077691237","full_name":"oorabona/unireq","owner":"oorabona","description":"Pipe-first, tree-shakeable, multi-protocol I/O toolkit","archived":false,"fork":false,"pushed_at":"2025-12-31T01:18:54.000Z","size":646,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-01-03T23:09:28.293Z","etag":null,"topics":["client","composable","ftp","graphql","http","oauth","pipe","smtp","sse"],"latest_commit_sha":null,"homepage":"https://oorabona.github.io/unireq/","language":"TypeScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/oorabona.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":"CONTRIBUTING.md","funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":"SECURITY.md","support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2025-10-16T15:46:29.000Z","updated_at":"2025-12-31T02:30:53.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/oorabona/unireq","commit_stats":null,"previous_names":["oorabona/unireq"],"tags_count":29,"template":false,"template_full_name":null,"purl":"pkg:github/oorabona/unireq","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/oorabona%2Funireq","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/oorabona%2Funireq/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/oorabona%2Funireq/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/oorabona%2Funireq/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/oorabona","download_url":"https://codeload.github.com/oorabona/unireq/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/oorabona%2Funireq/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":28400972,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-01-13T14:36:09.778Z","status":"ssl_error","status_checked_at":"2026-01-13T14:35:19.697Z","response_time":56,"last_error":"SSL_read: unexpected eof while reading","robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":false,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["client","composable","ftp","graphql","http","oauth","pipe","smtp","sse"],"created_at":"2025-12-29T07:59:15.963Z","updated_at":"2026-04-01T19:47:17.082Z","avatar_url":"https://github.com/oorabona.png","language":"TypeScript","readme":"# @unireq/* — Pipe-first, tree-shakeable, multi-protocol I/O toolkit\n\n[![CI](https://github.com/oorabona/unireq/workflows/CI/badge.svg)](https://github.com/oorabona/unireq/actions)\n[![codecov](https://codecov.io/gh/oorabona/unireq/branch/main/graph/badge.svg)](https://codecov.io/gh/oorabona/unireq)\n[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)\n\nA 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:\n\n- 🔗 **Pipe-first composition** — `compose(...policies)` for clean, onion-model middleware\n- 🌳 **Tree-shakeable** — Import only what you need, minimal bundle size\n- 🔐 **Smart OAuth Bearer** — JWT validation, automatic refresh on 401, single-flight token refresh\n- 🚦 **Rate limiting** — Reads `Retry-After` headers (429/503) and auto-retries\n- 🔄 **Safe redirects** — Prefer 307/308 (RFC 9110), 303 opt-in\n- 📤 **Multipart uploads** — RFC 7578 compliant\n- ⏸️ **Resume downloads** — Range requests (RFC 7233, 206/416)\n- 🎯 **Content negotiation** — `either(json|xml)` branching\n- 🛠️ **Multi-protocol** — HTTP/2 (ALPN), IMAP (XOAUTH2), FTP/FTPS\n\n---\n\n## Quick Start\n\n```bash\npnpm add @unireq/core @unireq/http\n```\n\n### Basic HTTP client\n\n```ts\nimport { client } from '@unireq/core';\nimport { http, headers, parse } from '@unireq/http';\n\nconst api = client(\n  http('https://api.example.com'),\n  headers({ 'user-agent': 'unireq/1.0' }),\n  parse.json()\n);\n\nconst response = await api.get('/users/123');\nconsole.log(response.data); // Typed response\n```\n\n### Smart HTTPS client with OAuth, retries, and content negotiation\n\n```ts\nimport { client, compose, either, retry, backoff } from '@unireq/core';\nimport { http, headers, parse, redirectPolicy, httpRetryPredicate, rateLimitDelay } from '@unireq/http';\nimport { oauthBearer } from '@unireq/oauth';\nimport { parse as xmlParse } from '@unireq/xml';\n\nconst smartClient = client(\n  http('https://api.example.com'),\n  headers({ accept: 'application/json, application/xml' }),\n  redirectPolicy({ allow: [307, 308] }), // Safe redirects only; strips sensitive headers cross-origin\n  retry(\n    httpRetryPredicate({ methods: ['GET', 'PUT', 'DELETE'], statusCodes: [429, 503] }),\n    [rateLimitDelay(), backoff({ initial: 100, max: 5000 })],\n    { tries: 3 }\n  ),\n  oauthBearer({ tokenSupplier: async () =\u003e getAccessToken() }),\n  either(\n    (ctx) =\u003e ctx.headers.accept?.includes('json'),\n    parse.json(),\n    xmlParse()\n  )\n);\n\nconst user = await smartClient.get('/users/me');\n```\n\n---\n\n## The 50-Line Axios Setup → 5 Lines\n\nWith axios, a production API client requires interceptors, retry logic, error handling, and token management scattered across multiple files:\n\n```ts\n// axios: 40+ lines of setup\nconst instance = axios.create({ baseURL: 'https://api.example.com' });\ninstance.interceptors.request.use(async (config) =\u003e {\n  const token = await getToken();\n  config.headers.Authorization = `Bearer ${token}`;\n  return config;\n});\ninstance.interceptors.response.use(null, async (error) =\u003e {\n  if (error.response?.status === 401) {\n    const token = await refreshToken();\n    error.config.headers.Authorization = `Bearer ${token}`;\n    return instance(error.config);\n  }\n  if (error.response?.status === 429) {\n    const retryAfter = error.response.headers['retry-after'];\n    await sleep(retryAfter * 1000);\n    return instance(error.config);\n  }\n  throw error;\n});\n```\n\nWith @unireq, the same behavior is declarative:\n\n```ts\n// unireq: 7 lines\nconst api = client(http('https://api.example.com'),\n  oauthBearer({ tokenSupplier: getToken, autoRefresh: true }),\n  retry(httpRetryPredicate({ statusCodes: [429, 503] }),\n    [rateLimitDelay(), backoff()], { tries: 3 }),\n  parse.json()\n);\n```\n\n---\n\n## Why @unireq? — Batteries Included\n\nMost HTTP clients solve the basics well. @unireq goes further by integrating common production needs out of the box:\n\n| Feature | @unireq | axios | ky | got | node-fetch |\n|---------|:-------:|:-----:|:--:|:---:|:----------:|\n| **Bundle size (min+gz)** | ~8 KB | ~40 KB | ~12 KB | ~46 KB | ~4 KB |\n| **Tree-shakeable** | ✅ | ❌ | ✅ | ❌ | ✅ |\n| **TypeScript-first** | ✅ | ⚠️ | ✅ | ✅ | ⚠️ |\n| **Composable middleware** | ✅ Onion model | ✅ Interceptors | ✅ Hooks | ✅ Hooks | ❌ |\n| **OAuth + JWT validation** | ✅ Built-in | ❌ Manual | ❌ Manual | ❌ Manual | ❌ Manual |\n| **Rate limit (Retry-After)** | ✅ Automatic | ❌ Manual | ⚠️ Partial | ⚠️ Partial | ❌ |\n| **Circuit breaker** | ✅ Built-in | ❌ | ❌ | ❌ | ❌ |\n| **Multi-protocol** | ✅ HTTP/HTTP2/FTP/IMAP | ❌ HTTP only | ❌ HTTP only | ❌ HTTP only | ❌ HTTP only |\n| **Introspection API** | ✅ Debug any request | ❌ | ❌ | ❌ | ❌ |\n| **Resume downloads** | ✅ Range requests | ❌ | ❌ | ⚠️ | ❌ |\n| **Safe redirects (307/308)** | ✅ By default | ⚠️ All allowed | ⚠️ All allowed | ⚠️ All allowed | ⚠️ All allowed |\n| **100% test coverage** | ✅ | ❌ | ❌ | ✅ | ❌ |\n\n### Performance\n\nBenchmarked 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.\n\n| Scenario | Result |\n|----------|--------|\n| Sequential GET (1000 req) | **36% more throughput** than axios, **49% more** than got |\n| Concurrent GET (100 parallel) | **13× the throughput** of native fetch, **11× axios** |\n| POST JSON (1000 req) | **41% more throughput** than native fetch, **46% more** than axios |\n| Large payload (100KB JSON) | Matches raw undici; **19% more throughput** than native fetch |\n| Retry with backoff (flaky server) | **2× more throughput** than axios/ky |\n| ETag cache hits | **43-63× more throughput** than manual If-None-Match |\n| 3-policy composition stack | Only **+20% overhead** over bare transport |\n\n\u003e Full methodology, per-library numbers, and code comparisons: **[BENCHMARKS.md](./BENCHMARKS.md)**\n\n### What sets @unireq apart\n\n1. **Pipe-first composition** — Build clients declaratively with `compose(...policies)`. No magic, just functions.\n\n2. **Production-ready auth** — OAuth Bearer with JWT introspection, automatic token refresh on 401, clock skew tolerance. No boilerplate.\n\n3. **Smart retries** — Combines multiple strategies: `rateLimitDelay()` reads `Retry-After` headers, `backoff()` handles transient failures. Works together seamlessly.\n\n4. **Multi-protocol** — Same API for HTTP, HTTP/2, IMAP, FTP. Switch transports without rewriting business logic.\n\n5. **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.\n\n6. **Introspection** — Debug any request with `introspect()`: see exact headers, timing, retries, and policy execution order.\n\n7. **Minimal footprint** — Import only what you use. The core is ~8 KB, and tree-shaking removes unused policies.\n\n### When to use something else\n\n- **Quick scripts**: `node-fetch` or native `fetch` if you just need simple GET/POST\n- **Browser-only**: `ky` offers excellent browser support with smaller footprint\n- **Legacy Node.js**: `axios` if you need Node \u003c 18 support\n\n---\n\n## Architecture\n\n### Packages\n\n| Package | Description |\n|---------|-------------|\n| **`@unireq/core`** | Client factory, `compose`, `either`, slots, DX errors |\n| **`@unireq/http`** | `http()` transport (undici), policies, body/parse, multipart, range |\n| **`@unireq/http2`** | `http2()` transport via `node:http2` (ALPN) |\n| **`@unireq/oauth`** | OAuth Bearer + JWT + 401 refresh (RFC 6750) |\n| **`@unireq/cookies`** | `tough-cookie` + `http-cookie-agent/undici` |\n| **`@unireq/xml`** | `fast-xml-parser` policy |\n| **`@unireq/imap`** | IMAP transport via `imapflow` (XOAUTH2) |\n| **`@unireq/ftp`** | FTP transport via `basic-ftp` |\n| **`@unireq/presets`** | Pre-configured clients (httpsJsonAuthSmart, etc.) |\n\n### Composition model\n\n**Onion middleware** via `compose(...policies)`:\n\n```ts\nconst policy = compose(\n  policyA, // Pre-call (outer layer)\n  policyB, // Pre-call (middle layer)\n  policyC  // Pre-call (inner layer)\n);\n// Execution: A → B → C → transport → C → B → A\n```\n\n**Conditional branching** via `either(pred, then, else)`:\n\n```ts\nimport { either } from '@unireq/core';\nimport { parse } from '@unireq/http';\nimport { parse as xmlParse } from '@unireq/xml';\n\neither(\n  (ctx) =\u003e ctx.headers.accept?.includes('json'),\n  parse.json(),  // If true: parse as JSON\n  xmlParse()     // If false: parse as XML\n);\n```\n\n---\n\n## HTTP Semantics References\n\n### Redirects\n\n- **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))\n- **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))\n- **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))\n\n```ts\nredirectPolicy({ allow: [307, 308], follow303: false });\n```\n\n### Rate Limiting\n\n- **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))\n- **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))\n\n```ts\nimport { retry } from '@unireq/core';\nimport { httpRetryPredicate, rateLimitDelay } from '@unireq/http';\n\nretry(\n  httpRetryPredicate({ statusCodes: [429, 503] }),\n  [rateLimitDelay({ maxWait: 60000 })],\n  { tries: 3 }\n);\n```\n\n### Range Requests\n\n- **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))\n- **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))\n- **`Accept-Ranges: bytes`** — Server supports ranges ([MDN](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept-Ranges))\n\n```ts\nrange({ start: 0, end: 1023 }); // Request first 1KB\nresume({ downloaded: 5000 }); // Resume from byte 5000\n```\n\n### Multipart Form Data\n\n- **RFC 7578** — `multipart/form-data` ([spec](https://datatracker.ietf.org/doc/html/rfc7578))\n\n```ts\nmultipart(\n  [{ name: 'file', filename: 'doc.pdf', data: blob, contentType: 'application/pdf' }],\n  [{ name: 'title', value: 'My Document' }]\n);\n```\n\n### OAuth 2.0 Bearer\n\n- **RFC 6750** — Bearer token usage ([spec](https://datatracker.ietf.org/doc/html/rfc6750))\n\n```ts\noauthBearer({\n  tokenSupplier: async () =\u003e getAccessToken(),\n  skew: 60, // Clock skew tolerance (seconds)\n  autoRefresh: true // Refresh on 401\n});\n```\n\n---\n\n## Why undici (Node's built-in fetch)?\n\nStarting 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:\n\n- ✅ **No external dependencies** for HTTP/1.1\n- ✅ **Streams, AbortController, FormData** built-in\n- ✅ **HTTP/2 support** via ALPN (requires explicit opt-in or `@unireq/http2`)\n- ✅ **Maintained by Node.js core team**\n\n\u003e **Note**: `fetch` defaults to HTTP/1.1. For HTTP/2, use `@unireq/http2` (see [Why HTTP/2 transport?](#why-http2-transport)).\n\n---\n\n## Why HTTP/2 transport?\n\nNode'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.\n\n`@unireq/http2` provides:\n\n- ✅ **Explicit HTTP/2** via `node:http2`\n- ✅ **ALPN negotiation**\n- ✅ **Multiplexing** over a single connection\n- ✅ **Server push** (opt-in)\n\n```ts\nimport { client } from '@unireq/core';\nimport { http2 } from '@unireq/http2';\n\nconst h2Client = client(http2('https://http2.example.com'));\n```\n\n---\n\n## Real-World Recipes\n\n### REST API with auth, retries, and caching\n\n```ts\nimport { restApi } from '@unireq/presets';\n\nconst github = restApi()\n  .bearer(process.env.GITHUB_TOKEN)\n  .retry(3)\n  .timeout(10_000)\n  .build('https://api.github.com');\n\nconst repos = await github.get('/user/repos');\n```\n\n### Download with progress and resume\n\n```ts\nimport { client } from '@unireq/core';\nimport { http, progress, resume } from '@unireq/http';\n\nconst state = { downloaded: 0 };\nconst dl = client(http('https://releases.example.com'),\n  progress({ onDownloadProgress: (p) =\u003e console.log(`${p.percent}%`) }),\n  resume(state)\n);\n\nawait dl.get('/large-file.zip');\n```\n\n### GraphQL with automatic JSON parsing\n\n```ts\nimport { client } from '@unireq/core';\nimport { graphql } from '@unireq/graphql';\n\nconst gql = client(graphql('https://countries.trevorblades.com'));\nconst { data } = await gql.post('/', {\n  body: { query: '{ countries { name capital } }' }\n});\n```\n\n### More examples\n\nSee [`examples/`](./examples) for 20+ runnable demos:\n\n| Category | Examples |\n|----------|----------|\n| **HTTP Basics** | `http-basic.ts`, `http-verbs.ts` |\n| **Authentication** | `oauth-refresh.ts` |\n| **Resilience** | `retry-backoff.ts` |\n| **Uploads** | `multipart-upload.ts`, `bulk-document-upload.ts`, `streaming-upload.ts` |\n| **Downloads** | `streaming-download.ts` |\n| **GraphQL** | `graphql-query.ts`, `graphql-mutation.ts` |\n| **Real-time** | `sse-events.ts` |\n| **Caching** | `conditional-etag.ts`, `conditional-lastmodified.ts`, `conditional-combined.ts` |\n| **Interceptors** | `interceptors-logging.ts`, `interceptors-metrics.ts`, `interceptors-cache.ts` |\n| **Validation** | `validation-demo.ts`, `validation-adapters.ts` |\n\nRun all examples: `pnpm examples:all`\n\n---\n\n## Quality Gates\n\n| Metric | Requirement |\n|--------|-------------|\n| **Core bundle size** | \u003c 8 KB (min+gz, excl. peers) |\n| **Test coverage** | 100% (lines/functions/branches/statements) |\n| **Test suite** | 4229 tests across 14 packages |\n| **Linter** | Biome (clean) |\n| **ESM** | All exports pass |\n| **CI** | pnpm × Node 20/22/24 |\n\n---\n\n## Development\n\n```bash\n# Install\npnpm install\n\n# Build all packages\npnpm build\n\n# Lint \u0026 format\npnpm lint\npnpm lint:fix\n\n# Test with coverage (100% gate)\npnpm test:coverage\n\n# Release\npnpm release\n```\n\n---\n\n## License\n\nMIT © [Olivier Orabona](https://github.com/oorabona)\n","funding_links":[],"categories":[],"sub_categories":[],"project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Foorabona%2Funireq","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Foorabona%2Funireq","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Foorabona%2Funireq/lists"}