JSPM

kinetex

0.0.2
  • ESM via JSPM
  • ES Module Entrypoint
  • Export Map
  • Keywords
  • License
  • Repository URL
  • TypeScript Types
  • README
  • Created
  • Published
  • Downloads 21685
  • Score
    100M100P100Q119841F
  • License MIT

The universal HTTP client. All runtimes (Node, Browser, Deno, Bun, Workers). HTTP/2+3. Schema validation. Middleware. Built-in cache, dedup, retry, HAR, SSE, GraphQL.

Package Exports

  • kinetex
  • kinetex/auth
  • kinetex/middleware
  • kinetex/package.json
  • kinetex/plugins

Readme

kinetex

The universal HTTP client for the modern JavaScript ecosystem.
Zero runtime dependencies. Every runtime. Every style.

NPM JSR TypeScript Coverage Downloads Docs


Why kinetex?

kinetex is a batteries-included fetch wrapper for modern JavaScript runtimes (Node 18+, browsers, Deno, Bun, Cloudflare Workers). It sits above fetch and optionally undici, adding the things you would otherwise wire up yourself:

  • Built-in auth (Basic, Bearer, OAuth 2.0, AWS SigV4, Digest, API key)
  • Stale-while-revalidate cache with custom store support
  • Retry with exponential back-off
  • Request deduplication
  • SSE streaming with auto-reconnect
  • GraphQL helper, HAR recording, cookie jar, proxy middleware
  • A composable middleware pipeline
  • End-to-end TypeScript generics

What it is not: a low-level HTTP client. It does not reimplement HTTP/2, connection pooling, or TLS — those come from undici (optional peer dep) or the platform's native fetch. If you need raw socket control or maximum throughput without any abstraction overhead, use undici directly.

See benchmarks/http-comparison.mjs for an honest performance comparison.


⚡ Performance Benchmarks

Kinetex is engineered for zero-cost abstraction. By leveraging a highly optimized middleware pipeline and the undici engine, it provides a "luxury" feature set (Auth, Retries, Cache, Hooks) with performance that rivals or beats native low-level clients.

📊 Comparison Table

Measured on Node.js v22.22.1 (Small JSON payload, 500 measured + 50 warmup, Concurrency: 1)

Client Req/s Mean Latency p99 Latency Heap Δ (KB/req)
Kinetex + Undici 585 1.71 ms 2.73 ms 4.5 KB
Axios 570 1.75 ms 3.71 ms 16.6 KB
Node-Fetch 518 1.93 ms 3.92 ms 14.5 KB
Native Fetch 511 1.96 ms 4.94 ms 21.6 KB
Kinetex (Standard) 509 1.96 ms 4.86 ms 5.4 KB
Got 508 1.97 ms 2.73 ms 18.5 KB
Ky 485 2.06 ms 2.61 ms 23.2 KB
Superagent 386 2.59 ms 4.42 ms 22.9 KB

🚀 Key Takeaways

1. Zero-Cost Middleware Pipeline

The Kinetex pipeline adds only ~0.007ms of overhead per request. You get advanced features like AWS SigV4, automatic retries, and request deduplication for essentially zero CPU tax. Once the V8 JIT compiler optimizes the pipeline, the overhead becomes practically immeasurable.

2. Extreme Memory Efficiency

Kinetex uses 75% less memory per request than native fetch and 70% less than Axios. In high-traffic production environments, this significantly reduces Garbage Collection (GC) pressure, leading to lower CPU spikes and a smaller infrastructure footprint.

3. Tail Latency Stability

With a p99 latency of 2.73ms, Kinetex is more predictable under load than native fetch (4.94ms). This prevents the random "lag spikes" often seen in microservices when using standard promise-based wrappers.

4. Green Computing

By reducing heap allocation by 15-18KB per request compared to competitors, Kinetex allows your containers to handle more concurrent traffic with less RAM, directly lowering your cloud computing costs.


Note: While undici (direct) and node:http (raw) are faster, they lack the high-level features (middleware, automatic JSON parsing, easy auth) that Kinetex provides. Kinetex aims to be the fastest feature-complete HTTP client for the Node.js ecosystem.

Run the benchmarks yourself: bash node --expose-gc benchmarks/http-comparison.mjs

Table of Contents


Installation

npm install kinetex

Optional peer dependencies

Install only what you need — kinetex works without any of them.

npm install undici   # HTTP/2 + HTTP/3 in Node.js (auto-detected)
npm install zod      # Schema validation
npm install valibot  # Alternative schema validation
npm install socks-proxy-agent    # SOCKS5 proxy support

Quick Start

import kinetex from 'kinetex';

// Simple GET
const { data } = await kinetex.get<User>('https://api.example.com/users/1');
console.log(data.name);

// POST with JSON body
const { data: created } = await kinetex.post('https://api.example.com/users', {
  json: { name: 'Alice', email: 'alice@example.com' },
});

// Fluent chain
const { data } = await kinetex
  .chain('https://api.example.com/users')
  .bearer('my-token')
  .query({ page: 1, limit: 20 })
  .timeout(5000)
  .retry(3);

Runtime Support

kinetex works identically across all major JavaScript runtimes. Import it the same way everywhere.

Node.js

import kinetex from 'kinetex';   # ESMconst kinetex = require('kinetex);  # CJSconst { data } = await kinetex.get('https://api.example.com/users');

Deno

import kinetex, { create } from 'npm:kinetex';
// or via JSR:
import kinetex from 'jsr:@kinetexjs/kinetex';

const { data } = await kinetex.get('https://api.example.com/users');

Note: proxyMiddleware() has no effect in Deno — configure proxies at the OS level or use the HTTPS_PROXY environment variable.

Bun

import kinetex from 'kinetex'; // identical to Node.js

Cloudflare Workers

import { create, auth } from 'kinetex';

const baseApi = create({ baseURL: 'https://api.example.com', timeout: 5000 });

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const api = baseApi.extend(auth.bearer(env.API_TOKEN));
    const { data } = await api.get('/users');
    return Response.json(data);
  },
};

interface Env { API_TOKEN: string; }

Browser / CDN

<!-- unpkg -->
<script src="https://unpkg.com/kinetex/dist/browser/kinetex.min.js"></script>

<!-- jsDelivr -->
<script src="https://cdn.jsdelivr.net/npm/kinetex/dist/browser/kinetex.min.js"></script>

<script>
  kinetex.get('https://api.example.com/data').then(({ data }) => console.log(data));
</script>

ESM in the browser:

<script type="module">
  import kinetex from 'https://cdn.jsdelivr.net/npm/kinetex/dist/browser/kinetex.esm.min.js';
  const { data } = await kinetex.get('https://api.example.com/posts');
</script>

API Styles

Async / Await

The default style — clean, typed, predictable.

import kinetex from 'kinetex';

// GET shorthand (call instance directly)
const { data } = await kinetex<User[]>('https://api.example.com/users');

// HTTP verb methods
const { data } = await kinetex.get<User>('/users/1');
const { data } = await kinetex.post('/users', { json: { name: 'Alice' } });
const { data } = await kinetex.put('/users/1', { json: { name: 'Bob' } });
const { data } = await kinetex.patch('/users/1', { json: { email: 'bob@example.com' } });
const { data } = await kinetex.delete('/users/1');
const { data } = await kinetex.head('/users');
const { data } = await kinetex.options('/users');

Fluent Chain

Build requests incrementally. The chain is a Promiseawait it when ready.

const { data } = await kinetex
  .chain('https://api.example.com/users')
  .method('POST')
  .bearer('my-token')
  .header('X-Request-ID', crypto.randomUUID())
  .json({ name: 'Alice' })
  .timeout(5000)
  .retry(3);

Chain method reference:

Method Description
.method(m) HTTP method
.header(k, v) Add a single header
.headers(obj) Merge multiple headers
.query(params) URL search params
.send(body) Raw body
.json(data) JSON body — sets Content-Type: application/json
.form(data) URL-encoded form body
.multipart(data) multipart/form-data (FormData or plain record)
.auth(value) Authorization header verbatim
.bearer(token) Authorization: Bearer <token>
.basic(user, pass) Authorization: Basic <base64>
.timeout(ms) Request timeout
.retry(n) Retry count
.accept(type) Accept header
.type(ct) Content-Type header
.schema(v) Validate response with zod / valibot / custom
.signal(s) AbortSignal for cancellation
.cancel() Returns an AbortController wired to this request — call .abort() on it when you want to cancel
.onUpload(fn) Upload progress callback
.onDownload(fn) Download progress callback
.as<U>() Re-type the response generic
// Multipart upload
const fd = new FormData();
fd.append('file', fileBlob, 'avatar.png');
await kinetex.chain('/upload').multipart(fd);

// Or from a plain record (auto-converted to FormData)
await kinetex.chain('/upload').multipart({ file: fileBlob, caption: 'photo' });

// Cancel a request — call controller.abort() when you actually want to cancel.
// cancel() does NOT abort immediately; the request fires lazily on first .then()
// so you need the controller to exist before it starts.
const chain = kinetex.chain('https://api.example.com/long-poll');
const controller = chain.cancel(); // wires the signal — NOT aborted yet
// ... start the request
const promise = chain; // triggers dispatch
// ... later:
controller.abort(); // now it cancels

Callback

Drop-in compatible with the request package callback style.

kinetex.callback('https://api.example.com/data', {}, (err, res, data) => {
  if (err) return console.error(err);
  console.log(data);
});

Instances & Configuration

Create isolated instances with their own defaults, middleware, and interceptors.

import { create } from 'kinetex';

const api = create({
  baseURL: 'https://api.example.com',
  timeout: 10_000,
  retry: { limit: 3, delay: (n) => 100 * 2 ** n },
  headers: { 'X-App': 'my-app/1.0' },
});

// Scoped sub-instance (inherits parent defaults)
const usersApi = api.create({ baseURL: 'https://api.example.com/users' });

// Extend with middleware — returns a new instance, does not mutate
const authedApi = api.extend(auth.bearer('secret'));

// Add middleware in-place — mutates this instance
api.use(myLoggingMiddleware);

// Cancel ALL in-flight requests on this instance (e.g. on component unmount)
api.cancelAll();
// The instance resets automatically — subsequent requests work normally

Timeout granularity

// Simple: single number applies to the whole request lifecycle
await api.get('/data', { timeout: 5000 });

// Granular: separate timeouts for connection vs full response
await api.get('/data', {
  timeout: {
    request: 30_000,  // abort if total request takes > 30s
    response: 5_000,  // abort if no first byte within 5s (TTFB guard)
  },
});

Request Config Reference

Every request method accepts a RequestConfig object. All fields are optional except url.

interface RequestConfig<T = unknown> {
  url: string;
  method?: string;                      // default: 'GET'

  // ── Headers & Body ─────────────────────────────────────────────────────
  headers?: Record<string,string> | [string,string][] | Headers;
  body?: BodyInit;                      // raw body
  json?: unknown;                       // serialised + Content-Type: application/json
  form?: Record<string, string|number|boolean>; // application/x-www-form-urlencoded
  searchParams?: Record<string, string|number|boolean|string[]> | URLSearchParams | string;

  // ── Response ───────────────────────────────────────────────────────────
  responseType?: 'json'|'text'|'blob'|'arrayBuffer'|'formData'|'stream';
  schema?: ZodSchema | ValibotSchema | { parse(d: unknown): T };  // validates body
  throwHttpErrors?: boolean;            // default: true
  decompress?: boolean;                 // default: true — set false to get raw compressed bytes

  // ── Network ───────────────────────────────────────────────────────────
  baseURL?: string;
  timeout?: number | {
    request?: number;   // total request timeout (ms)
    response?: number;  // TTFB timeout — aborts if first byte not received in time
  };
  retry?: number | RetryConfig;
  cache?: CacheConfig | false;
  dedupe?: boolean;                     // default: true for GET
  signal?: AbortSignal;
  credentials?: RequestCredentials;
  followRedirects?: boolean;            // default: true
  maxRedirects?: number;                // default: 10
  transport?: 'fetch' | 'undici';      // force transport

  // ── Callbacks ─────────────────────────────────────────────────────────
  onUploadProgress?: (event: ProgressEvent) => void;   // Node + browser
  onDownloadProgress?: (event: ProgressEvent) => void;

  // ── Observability ─────────────────────────────────────────────────────
  hooks?: HookConfig;
  logger?: Logger;
  har?: boolean;
}

Response Object

Every request resolves to a KinetexResponse<T>:

const res = await kinetex.get<User>('/users/1');

res.data        // T          — parsed body
res.status      // number     — HTTP status code
res.statusText  // string
res.headers     // Headers    — response headers
res.response    // Response   — original fetch Response (for streaming)
res.request     // RequestConfig — originating request
res.fromCache   // boolean    — true if served from cache
res.retries     // number     — retry attempts made
res.timing      // { start, end, duration, ttfb, ... }
res.harEntry    // HarEntry   — present when har: true

Authentication

import { auth, create } from 'kinetex';

// Basic auth
create().extend(auth.basic('username', 'password'));

// Bearer token (static)
create().extend(auth.bearer('my-token'));

// Bearer token (async — refreshed on each request)
create().extend(auth.bearer(async () => getAccessToken()));

// API key in a header
create().extend(auth.apiKey('key-value', { header: 'X-API-Key' }));

// API key as a query param
create().extend(auth.apiKey('key-value', { query: 'api_key' }));

// OAuth 2.0 client credentials — auto-fetches and refreshes tokens
create().extend(auth.oauth2({
  tokenUrl: 'https://auth.example.com/oauth/token',
  clientId: process.env.CLIENT_ID,
  clientSecret: process.env.CLIENT_SECRET,
  scope: 'read:users write:posts',
  onToken: (token) => console.log('New token, expires in:', token.expires_in),
}));

// AWS SigV4 — signed using Web Crypto, no extra dependency
create().extend(auth.aws({
  accessKeyId: process.env.AWS_ACCESS_KEY_ID,
  secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
  sessionToken: process.env.AWS_SESSION_TOKEN, // optional
  region: 'us-east-1',
  service: 's3',
}));

// Digest auth — RFC 2617 compliant MD5 (nginx, Apache compatible)
create().extend(auth.digest('username', 'password'));

Schema Validation

kinetex integrates with any schema library that exposes .parse() or .safeParse().

Zod

import { z } from 'zod';
// import * as z from 'zod';
import kinetex from 'kinetex';

const UserSchema = z.object({
  id: z.number(),
  name: z.string(),
  email: z.string().email(),
});

// data is typed and validated at runtime — throws ValidationError on mismatch
const { data } = await kinetex.get('https://api.example.com/users/1', {
  schema: UserSchema,
});

console.log(data.name); // string ✅

Valibot

import * as v from 'valibot';

const UserSchema = v.object({ id: v.number(), name: v.string() });
const { data } = await kinetex.get('/users/1', { schema: UserSchema });

Custom validator

Any object with .parse() or .safeParse() works:

const { data } = await kinetex.get('/users/1', {
  schema: {
    parse: (raw) => {
      if (!raw || typeof raw !== 'object') throw new Error('Invalid');
      return raw as User;
    },
  },
});

Chain API

const { data } = await kinetex
  .chain('/users/1')
  .schema(UserSchema);

Caching

Built-in in-memory cache with stale-while-revalidate support. Works with all response types.

The default cache store is per-instance — each create() call gets its own isolated store, so cache entries from one instance never bleed into another. To share a cache across instances, pass a custom store explicitly.

// Cache for 60s, serve stale for up to 5 more minutes while revalidating in the background
const { data, fromCache } = await kinetex.get('/api/config', {
  cache: { ttl: 60_000, swr: 300_000 },
});
console.log('from cache?', fromCache);

// Opt out for one request
await api.get('/realtime-data', { cache: false });

// Custom cache key
await api.get('/users', {
  cache: {
    ttl: 30_000,
    key: (req) => `v2:${req.url}`,
  },
});

Custom store (e.g. Redis)

import { create } from 'kinetex';

const api = create({
  cache: {
    ttl: 30_000,
    store: {
      get: (key) => redis.get(key).then(v => v ? JSON.parse(v) : undefined),
      set: (key, entry) => redis.setex(
        key,
        Math.ceil((entry.expiresAt - Date.now()) / 1000),
        JSON.stringify(entry)
      ),
      delete: (key) => redis.del(key),
      clear: () => redis.flushdb(),
    },
  },
});

Retry

const { data } = await kinetex.get('/api/data', {
  retry: {
    limit: 4,
    statusCodes: [429, 500, 502, 503, 504],
    methods: ['GET', 'POST'],
    delay: (attempt) => Math.min(200 * 2 ** attempt, 10_000), // exponential back-off
    onNetworkError: true,
    onRetry: (attempt, err, req) => console.warn(`Retry ${attempt} for ${req.url}:`, err),
  },
});

// Shorthand — retries 3 times with defaults
const { data } = await kinetex.get('/api/data', { retry: 3 });

Interceptors

Axios-compatible interceptor API. Interceptors run as part of the request pipeline.

import { create } from 'kinetex';

const api = create({ baseURL: 'https://api.example.com' });

// Request interceptor — add a tracing header to every request
const reqId = api.interceptors.request.use(async (config) => ({
  ...config,
  headers: { ...config.headers, 'X-Request-ID': crypto.randomUUID() },
}));

// Response interceptor — unwrap a nested payload
const resId = api.interceptors.response.use((response) => ({
  ...response,
  data: response.data?.payload ?? response.data,
}));

// Remove individual interceptors
api.interceptors.request.eject(reqId);
api.interceptors.response.eject(resId);

Lifecycle Hooks

Fine-grained hooks for observability, modification, and error handling. Set per-request or on an instance.

const { data } = await kinetex.get('/api/data', {
  hooks: {
    // Modify the request before it is sent
    beforeRequest: [
      (config) => ({ ...config, url: config.url + '?source=kinetex' }),
    ],
    // Inspect or transform the response
    afterResponse: [
      (response) => {
        console.log(`${response.status} in ${response.timing.duration}ms`);
        return response;
      },
    ],
    // React to any error (does not suppress it)
    onError: [
      (err) => metrics.increment('http.error', { status: err.response?.status }),
    ],
  },
});

Middleware Pipeline

The full power of kinetex — compose arbitrary request/response transforms into the pipeline.

import { create, compose } from 'kinetex';
import type { Middleware } from 'kinetex';

// Write a middleware
const timingMiddleware: Middleware = async (request, next) => {
  const start = Date.now();
  const response = await next(request);
  console.log(`${request.method} ${request.url}${Date.now() - start}ms`);
  return response;
};

const api = create({ baseURL: 'https://api.example.com' });
api.use(timingMiddleware);

// Compose multiple middlewares manually
const pipeline = compose([timingMiddleware, authMiddleware], coreHandler);

SSE Streaming

Server-Sent Events with automatic reconnection, Last-Event-ID tracking, and server-side retry: interval support.

import { sse } from 'kinetex/plugins';

const controller = new AbortController();
setTimeout(() => controller.abort(), 30_000); // 30s limit

for await (const event of sse('https://api.example.com/events', {
  signal: controller.signal,
  headers: { Authorization: 'Bearer my-token' },
  maxRetries: 10,   // Infinity by default
})) {
  console.log(`[${event.event ?? 'message'}]`, event.data);
  if (event.id) console.log('event id:', event.id);
  if (event.event === 'done') break;
}

SSE event fields:

interface SSEEvent {
  data: string;        // event data
  event?: string;      // event type (from "event:" line)
  id?: string;         // event id (from "id:" line)
  retry?: number;      // retry interval in ms (from "retry:" line)
}

GraphQL

Type-safe GraphQL requests with automatic error detection.

import { create } from 'kinetex';
import { graphqlPlugin } from 'kinetex/plugins';

const api = create({ baseURL: 'https://api.example.com' });

interface UserData { user: { id: string; name: string } }
interface UserVars { id: string }

const config = graphqlPlugin<UserVars, UserData>('/graphql', {
  query: `
    query GetUser($id: ID!) {
      user(id: $id) { id name }
    }
  `,
  variables: { id: '42' },
  operationName: 'GetUser',
});

const { data } = await api.post(config.url, config);
// Throws automatically if data.errors is present
console.log(data.data?.user.name);

File Upload & Progress

Multipart upload

const form = new FormData();
form.append('title', 'My Upload');
form.append('file', fileBlob, 'document.pdf');

const { data } = await kinetex.post('/upload', { body: form });

Upload progress

In Node.js, onUploadProgress wraps the request body in a progress-tracking ReadableStream that emits events as undici reads each chunk. In Bun, upload progress uses Bun's native fetch streaming. In the browser, kinetex automatically falls back to XMLHttpRequest to provide real upload progress events.

import type { ProgressEvent as KinetexProgressEvent } from 'kinetex';

await kinetex.post('/upload', {
  body: fileBuffer,
  onUploadProgress: (event: KinetexProgressEvent) => {
    console.log(`${event.percent?.toFixed(0)}% — ${(event.bytesPerSecond / 1024).toFixed(1)} KB/s`);
  },
});

Download progress

await kinetex.get('/large-file.zip', {
  responseType: 'blob',
  onDownloadProgress: ({ loaded, total, percent }) => {
    progressBar.value = percent ?? 0;
  },
});

ProgressEvent fields:

interface ProgressEvent {
  loaded: number;               // bytes transferred so far
  total: number | undefined;    // total bytes (undefined if unknown)
  percent: number | undefined;  // 0–100 (undefined if total unknown)
  transferredBytes: number;     // same as loaded
  bytesPerSecond: number;       // current transfer speed
}

HAR Recording

Record all HTTP traffic as a HAR 1.2 file for debugging, testing, or replaying.

import { create } from 'kinetex';
import { writeFileSync } from 'node:fs';

const api = create();

await api.get('https://api.example.com/users', { har: true });
await api.post('https://api.example.com/posts', { json: { title: 'test' }, har: true });

const har = api.exportHAR();
// har.log.version === '1.2'
// har.log.entries — array of HarEntry

writeFileSync('trace.har', JSON.stringify(har, null, 2));
// Open in Chrome DevTools → Network → Import HAR

RFC 6265 compliant cookie jar — no dependencies.

import { create } from 'kinetex';
import { cookieJar, withCookies } from 'kinetex/plugins';

const jar = cookieJar();
const api = create().use(withCookies(jar));

// Cookies from Set-Cookie headers are stored automatically
await api.get('https://example.com/login');

// Stored cookies are sent automatically on subsequent requests
await api.get('https://example.com/dashboard');

// Manual jar operations
const all = jar.getAll();                              // all stored cookies
const header = jar.getCookieString('https://example.com/');  // "name=value; ..."
jar.delete('session', 'example.com', '/');            // remove one cookie
jar.clear();                                           // remove all

Proxy & SOCKS5

Node.js and Bun only. Calling proxy middleware in browser, edge, or Deno environments logs a console.warn and falls through — there is no silent failure.

import { create } from 'kinetex';
import { proxyMiddleware, envProxy } from 'kinetex/plugins';

// Explicit HTTP/HTTPS proxy
const api = create().use(proxyMiddleware({
  url: 'http://proxy.corp.internal:3128',
  auth: { username: 'user', password: 'pass' },
  noProxy: ['internal.corp.com', '.local'],
}));

// Read from environment variables (HTTP_PROXY, HTTPS_PROXY, NO_PROXY)
const api2 = create().use(envProxy());

// SOCKS5 proxy — requires: npm install socks-proxy-agent
const api3 = create().use(proxyMiddleware({
  url: 'socks5://proxy.example.com:1080',
  protocol: 'socks5',
}));

ProxyConfig fields:

interface ProxyConfig {
  url?: string;                          // proxy URL
  protocol?: 'http' | 'https' | 'socks5';
  auth?: { username: string; password: string };
  noProxy?: string[];                    // hostnames / patterns to bypass
  headers?: Record<string, string>;      // extra headers sent to proxy
}

Concurrency & Rate Limiting

import { create } from 'kinetex';
import { concurrencyLimit, rateLimit } from 'kinetex/plugins';

const api = create({ baseURL: 'https://api.example.com' })
  .use(concurrencyLimit(5))                          // max 5 in-flight requests
  .use(rateLimit({ requestsPerSecond: 10, burst: 20 })); // token-bucket rate limiter

rateLimit options:

Option Type Description
requestsPerSecond number Sustained rate
burst number Max burst size (default: requestsPerSecond)

Response Size Limiting

Protect against unexpectedly large responses. Checks Content-Length header first, then falls back to measuring the actual body.

import { create, ResponseSizeError } from 'kinetex/plugins';
import { responseSizeLimit } from 'kinetex/plugins';

const api = create().use(responseSizeLimit(5 * 1024 * 1024)); // 5 MB limit

try {
  const { data } = await api.get('/large-file');
} catch (err) {
  if (err instanceof ResponseSizeError) {
    console.log(`Too large: ${err.actualBytes} bytes (limit: ${err.maxBytes})`);
  }
}

HTTP/2 and HTTP/3

npm install undici

When undici is installed, kinetex automatically uses it in Node.js for HTTP/2 and HTTP/3 support. Bun uses its own optimized native fetch. Falls back to native fetch transparently when unavailable.

Connections to each origin are pooled via a persistent undici.Pool (10 connections per origin, keep-alive 30s). This means multiple requests to the same host reuse the same TCP/TLS/HTTP2 session — true multiplexing, not just HTTP/2 framing.

// Force native fetch for one request (e.g. when streaming to a non-undici consumer)
const { data } = await kinetex.get('/api', { transport: 'fetch' });

// Force undici for all requests on this instance
const api = create({ transport: 'undici' });

// Opt out of automatic response decompression (get raw compressed bytes)
const { data } = await kinetex.get('/compressed', { decompress: false });

Note: connection pooling is per-process, not per-instance. All kinetex instances share the same undici.Pool for a given origin.


Error Handling

All errors extend KinetexError. Catch the specific subclass you care about.

import { HTTPError, TimeoutError, ValidationError, KinetexError } from 'kinetex';

try {
  await kinetex.get('https://api.example.com/protected');
} catch (err) {
  if (err instanceof HTTPError) {
    console.log(err.response?.status);   // 401, 404, 429 ...
    console.log(err.response?.data);     // parsed error body
    console.log(err.request.url);        // originating URL
  } else if (err instanceof TimeoutError) {
    console.log('Timed out:', err.message);
  } else if (err instanceof ValidationError) {
    console.log('Schema mismatch:', err.validationError);
  } else if (err instanceof KinetexError) {
    console.log('kinetex error:', err.originalCause);
  }
}

Opt out of throwing on non-2xx responses

const res = await kinetex.get('/api/data', { throwHttpErrors: false });
if (res.status === 404) {
  console.log('not found');
}

Error types:

Class When thrown
KinetexError Base class for all errors
HTTPError Non-2xx response (when throwHttpErrors: true)
TimeoutError Request exceeded timeout
ValidationError Response failed schema validation
ResponseSizeError Response exceeded responseSizeLimit

Request Deduplication

Identical in-flight GET requests on the same instance are collapsed into a single network call. All callers receive the same response.

const api = create({ baseURL: 'https://api.example.com' });

// All three fire simultaneously — only one HTTP request is made
const [r1, r2, r3] = await Promise.all([
  api.get('/config'),
  api.get('/config'),
  api.get('/config'),
]);

// Opt out per-request
await api.get('/always-fresh', { dedupe: false });

Deduplication state is instance-scoped — two separate create() calls never share inflight maps.

Authorization-aware: the dedup key includes the Authorization header value, so two requests to the same URL but with different credentials (e.g. different user tokens in a multi-tenant server) are never collapsed into the same in-flight request.


Logging

Attach a logger to an instance or individual request for structured observability.

import { create } from 'kinetex';
import type { Logger } from 'kinetex';

const logger: Logger = {
  request: (config) => console.log(`${config.method} ${config.url}`),
  response: (res)   => console.log(`${res.status} ${res.timing.duration}ms`),
  error: (err)      => console.error(`${err.message}`),
};

// On an instance
const api = create({ logger });

// Or per-request
await kinetex.get('/api/data', { logger });

Development

git clone https://github.com/kinetexjs/kinetex.git
cd kinetex && npm install

npm run typecheck      # TypeScript — 0 errors
npm run lint           # ESLint — 0 warnings
npm run build          # ESM + CJS + types + browser bundles

npm test               # ~149 tests, 0 failures (core suite)
npm run test:all       # ~460 tests, 0 failures (full suite)
npm run test:coverage  # coverage report (≥ 95% ESM line coverage)
npm run bench          # benchmark vs native fetch and node:http (add more: npm install --no-save axios got ky undici)

# Run individual runtimes
npm run test:bun
npm run test:deno

# Generate local API docs
npm run docs
open docs/index.html

# Create a release
node scripts/release.mjs patch   # or minor / major
git push && git push --tags

License

MIT © Qasim Ali