klaim
v2.0.0
Published
Klaim is a lightweight TypeScript library designed to manage APIs and record requests, optimized for an optimal user experience.
Downloads
2,836
Maintainers
Readme
📚 Table of Contents
🚀 Features
- Efficient API Management: Easily manage multiple APIs with streamlined integration and interaction capabilities.
- API Grouping: Organize related APIs into logical groups with shared settings and configuration.
- Route Grouping: Organize related routes into logical groups with inherited settings.
- Request Recording: Seamlessly track requests for debugging and monitoring.
- User Experience Optimization: Focused on performance and usability for a smooth user experience.
- Lightweight: Minimal footprint for fast load times and minimal performance impact.
- Middleware Support: Easily add middleware to modify requests and responses (
beforeandafter). - Hook System: Subscribe to hooks to monitor and react to specific events.
- Stats & Observability: Track calls, latency, error rate and cache hit rate per route out of the box.
- Caching: Enable caching on requests to reduce network load and improve performance.
- Retry Mechanism: Automatically retry failed requests to enhance reliability.
- Rate Limiting: Control the frequency of API calls to prevent abuse and respect API provider limits.
- Circuit Breaker: Fail fast and stop hammering a failing API/route after repeated failures, with automatic recovery testing.
- Request Deduplication: Coalesce concurrent identical GET calls into a single network request.
- Timeout: Abort requests that exceed a specified duration with an optional custom error message.
- TypeScript Support: Fully typed for enhanced code quality and developer experience.
- Response Validation: Validate responses using schemas for increased reliability and consistency.
- Pagination: Handle paginated requests easily with support for both page and offset based pagination.
- Batch Requests: Run several route calls concurrently and get partial success/failure results,
Promise.allSettled-style.
📥 Installation
Install Klaim via npm:
// Using npm
npm install klaim
// Using bun
bun add klaim
// Using deno
deno add @antharuu/klaim🛠 Usage
Basic API Configuration
First, set up the API configuration. Define the API and its base URL.
import {Api, Route} from 'klaim';
// For deno: import { Api, Route } from "@antharuu/klaim";
// Your simple Todo type
type Todo = {
userId: number;
id: number;
title: string;
completed: boolean;
};
// Create a new API with the name "hello" and the base URL "https://jsonplaceholder.typicode.com/"
Api.create("hello", "https://jsonplaceholder.typicode.com/", () => {
// Define routes for the API
Route.get<Todo[]>("listTodos", "todos");
Route.get<Todo>("getTodo", "todos/[id]");
Route.post<Todo>("addTodo", "todos");
});Route Definition
Routes represent endpoints in your API and can be defined with different HTTP methods. Routes can include parameters and custom configurations:
Api.create("api", "https://api.example.com", () => {
// Basic GET route
Route.get("listUsers", "/users");
// GET route with URL parameter
Route.get("getUser", "/users/[id]");
// POST route with custom headers and body
Route.post("createUser", "/users", {
"Content-Type": "application/json"
}, {userId: 1, name: "John Doe"});
// PUT route with parameter
Route.put("updateUser", "/users/[id]");
// DELETE route
Route.delete("deleteUser", "/users/[id]");
// PATCH route
Route.patch("updateUserStatus", "/users/[id]/status");
// OPTIONS route
Route.options("userOptions", "/users");
});Groups
Klaim provides powerful grouping capabilities for both APIs and routes. Groups can be used to organize related elements, share configuration, and maintain a clean structure in your application.
API Groups
Organize multiple APIs that serve related purposes:
import {Group, Api, Route} from 'klaim';
// Create a group for user-related services
Group.create("userServices", () => {
// Authentication API
Api.create("auth", "https://auth.example.com", () => {
Route.post("login", "/login");
Route.post("register", "/register");
});
// User Management API
Api.create("users", "https://users.example.com", () => {
Route.get("list", "/users");
Route.get("getOne", "/users/[id]");
});
}).withRetry(3); // Apply retry mechanism to all APIs in the group
// Access grouped APIs
await Klaim.userServices.auth.login({}, {username: "user", password: "pass"});
await Klaim.userServices.users.list();Route Groups
Organize routes within an API into logical groups:
Api.create("hello", "https://api.example.com/", () => {
// Group user-related routes
Group.create("users", () => {
Route.get<User[]>("list", "/users");
Route.get<User>("getOne", "/users/[id]");
Route.post<User>("create", "/users");
}).withCache(60); // Cache all user routes for 60 seconds
// Group product-related routes
Group.create("products", () => {
Route.get("list", "/products");
Route.get("getOne", "/products/[id]");
});
});
// Use grouped routes
const users = await Klaim.hello.users.list();
const product = await Klaim.hello.products.getOne({id: 1});Nested Groups
Create complex hierarchies with nested groups:
Group.create("services", () => {
// Internal services group
Group.create("internal", () => {
Api.create("logs", "https://logs.internal.example.com", () => {
Route.post("write", "/logs");
});
Api.create("metrics", "https://metrics.internal.example.com", () => {
Route.post("track", "/metrics");
});
}).withRetry(5); // More retries for internal services
// External services group
Group.create("external", () => {
Api.create("weather", "https://api.weather.com", () => {
Route.get("forecast", "/forecast/[city]");
});
Api.create("geocoding", "https://api.geocoding.com", () => {
Route.get("search", "/search/[query]");
});
}).withCache(300); // Cache external services longer
});
// Access nested groups
await Klaim.services.internal.logs.write({}, {message: "Log entry"});
await Klaim.services.external.weather.forecast({city: "Paris"});Group Configuration
Groups can share configuration among all their members:
Group.create("apis", () => {
Api.create("service1", "https://api1.example.com", () => {
Route.get("test", "/test");
});
Api.create("service2", "https://api2.example.com", () => {
Route.get("test", "/test");
});
})
.withCache(60) // Enable caching for all APIs
.withRetry(3) // Enable retries for all APIs
.before(({config}) => { // Add authentication for all APIs
config.headers.Authorization = `Bearer ${getToken()}`;
})
.after(({data}) => { // Process all responses
logResponse(data);
});Request Handling
Handle requests using the defined routes:
import {Klaim} from 'klaim';
// For deno: import { Klaim } from "@antharuu/klaim";
// Make a request to the "listTodos" route
const listOfTodos = await Klaim.hello.listTodos<Todo[]>();
// Make a request to the "getTodo" route with the parameter "id"
const todo = await Klaim.hello.getTodo<Todo>({id: 1});
// Make a request to the "addTodo" route
const newTodo = await Klaim.hello.addTodo<Todo>({}, {title: "New Todo", completed: false, userId: 1});Middleware Usage
Add middleware to modify requests and responses. Use before middleware to alter requests before they are sent
and after middleware to process responses:
Api.create("hello", "https://jsonplaceholder.typicode.com/", () => {
// With before middleware
Route.get<Todo>("getRandomTodo", "todos")
.before(({url}) => {
const random = Math.floor(Math.random() * 10) + 1;
return {url: `${url}/${random}`};
});
// With after middleware
Route.get<Todo>("getFirstTodo", "todos")
.after(({data: [first]}) => ({data: first}));
});Global Middleware
before/after middleware also exists at a global level, on the Klaim object itself. A
global middleware runs for every route of every API, in addition to (not instead of) any
local before/after already set on a route. This is the same before/after concept, just
extended to a global scope — no new middleware system was introduced.
import {Klaim} from 'klaim';
// For deno: import { Klaim } from "@antharuu/klaim";
// Runs before every request, for every API/route
Klaim.before(({url, config}) => {
console.log(`[global] requesting ${url}`);
return {config: {...config, headers: {...config.headers as object, "X-Trace-Id": "123"}}};
});
// Runs after every response, for every API/route
Klaim.after(({data}) => {
console.log("[global] response received");
return {data};
});Multiple global middlewares can be stacked — each call to Klaim.before/Klaim.after adds
another one, executed in registration order (unlike the local before/after, which is a
single callback replaced if called again).
Execution order
For a single route call, the full order is:
- global before middlewares (registration order, each fed the previous result)
- (api-level
before, if the API used.before(...)— see note below) - route before (the existing local
.before(...)hook) - network request execution
- route after (the existing local
.after(...)hook) - (api-level
after, if the API used.after(...)— see note below) - global after middlewares (registration order, each fed the previous result)
Known limitation: as of this version, an API-level
before/after(set withApi.create(...).before(...)directly on the API, as opposed to on one of its routes) is stored but not invoked during a request — only the route-levelbefore/afterruns. This is a pre-existing behavior, unrelated to global middleware, and is documented here for transparency rather than fixed by this feature.
Hook Subscription
Subscribe to hooks to monitor specific events:
import {Hook} from 'klaim';
// For deno: import { Hook } from "@antharuu/klaim";
// Subscribe to the "hello.getFirstTodo" hook
Hook.subscribe("hello.getFirstTodo", ({url}) => {
console.log(`Requesting ${url}`);
});Stats & Observability
Klaim ships a built-in Stats singleton that observes every route call automatically, without
touching or conflicting with your own Hook.subscribe callbacks. It's built on Hook.onAny, a
multi-listener observation point separate from the single-callback-per-route Hook.subscribe API,
so Stats and your user hooks can coexist on the same route.
For each route it tracks, non-blocking and with a bounded memory footprint:
- calls: total number of calls observed
- errors / errorRate: failed calls and their ratio
- cacheHits / cacheHitRate: calls served from cache and their ratio
- avgLatencyMs: cumulative running average latency (no per-call array growth)
- p95LatencyMs: approximate 95th percentile from a bounded sliding window (last 100 calls)
import {Stats} from 'klaim';
// For deno: import { Stats } from "@antharuu/klaim";
// After making some calls...
await Klaim.hello.listTodos();
await Klaim.hello.getTodo({id: 1});
// Read metrics for a single route
const routeStats = Stats.i.get("hello.listTodos");
console.log(routeStats?.calls, routeStats?.avgLatencyMs, routeStats?.errorRate);
// Read metrics for every observed route
const allStats = Stats.i.getAll();
console.table(allStats);
// Reset all collected metrics (e.g. between test runs)
Stats.i.reset();Caching Requests
Enable caching on requests to reduce network load and improve performance. By default, the cache duration is 20 seconds, but you can specify a custom duration in seconds.
Caching Individual Routes
You can enable caching on individual routes:
Api.create("hello", "https://jsonplaceholder.typicode.com/", () => {
// Get a list of todos with default cache duration (20 seconds)
Route.get<Todo[]>("listTodos", "todos").withCache();
// Get a specific todo by id with custom cache duration (300 seconds)
Route.get<Todo>("getTodo", "todos/[id]").withCache(300);
// Add a new todo (no cache)
Route.post<Todo>("addTodo", "todos");
});Now, when making requests, the caching feature will be applied.
Invalidating Cached Routes
Cache entries are namespaced internally as ${parent}.${routeName} (e.g. "hello.getTodo").
All parameterized variants of a route (different args, query strings, TTLs or response
policies) share that namespace, so a single call clears every cached variant — without wiping
unrelated entries the way Cache.clear() would.
import {Cache, Klaim} from 'klaim';
// Invalidate only "hello.getTodo", leaving other cached routes untouched
Cache.i.invalidate("hello.getTodo");
// Or invalidate directly from the route handler
Klaim.hello.getTodo.invalidate();
// A broader pattern invalidates every route namespaced under it
Cache.i.invalidate("hello"); // clears hello.getTodo, hello.listTodos, ...Caching the Entire API
You can also enable caching for all routes defined within an API:
Api.create("hello", "https://jsonplaceholder.typicode.com/", () => {
// Define routes for the API
Route.get<Todo[]>("listTodos", "todos");
Route.get<Todo>("getTodo", "todos/[id]");
Route.post<Todo>("addTodo", "todos");
}).withCache(); // Enable default cache duration (20 seconds) for all routesRetry Mechanism
Configure automatic retries for failed requests:
// Apply retry at the API level
Api.create("api", "https://api.example.com", () => {
Route.get("users", "/users");
}).withRetry(3); // Will retry failed requests up to 3 times
// Apply retry at the route level
Api.create("api", "https://api.example.com", () => {
Route.get("unstableRoute", "/unstable-endpoint").withRetry(5); // Will retry up to 5 times
});Rate Limiting
Control the frequency of API calls to prevent abuse and respect API provider rate limits:
// Apply rate limiting at the API level
Api.create("api", "https://api.example.com", () => {
Route.get("users", "/users");
Route.get("posts", "/posts");
}).withRate({ limit: 5, duration: 10 }); // Max 5 requests every 10 seconds for this API
// Apply rate limiting at the route level
Api.create("api", "https://api.example.com", () => {
// This route has its own stricter limits
Route.get("expensive", "/expensive-operation").withRate({ limit: 2, duration: 60 }); // Max 2 requests per minute
// This route uses the default limits (5 per 10 seconds if not specified)
Route.get("normal", "/normal-operation").withRate();
});
// Handling rate limit errors
try {
await Klaim.api.expensive();
} catch (error) {
if (error.message.includes('Rate limit exceeded')) {
console.log('Please wait before trying again');
}
}Circuit Breaker
Stop hammering an API/route that keeps failing: once a configurable number of consecutive
failures is reached, the circuit opens and subsequent calls fail fast with a CircuitOpenError
— no network request, no retry attempt consumed — until a reset timeout elapses. After the
timeout, the circuit moves to half-open and lets exactly one probe call through: if it
succeeds the circuit closes again (normal traffic resumes and the failure count resets); if
it fails the circuit reopens and the timeout restarts.
The breaker guards the whole call, not each individual retry attempt: it is checked once before
fetchWithRetry's retry loop starts, and only the loop's overall outcome (all attempts
exhausted vs. at least one success) is recorded against it. This keeps the breaker focused on
"is this endpoint down" rather than reacting to single transient errors that retry/backoff is
already designed to absorb.
Like retry and rate limiting, the breaker can be configured at the route level (takes precedence) or at the API level (shared across all routes of that API that don't set their own):
// Apply a circuit breaker at the route level
Api.create("api", "https://api.example.com", () => {
Route.get("flaky", "/flaky-endpoint").withBreaker({ failureThreshold: 3, resetTimeout: 15 });
});
// Apply a circuit breaker at the API level (shared by all its routes)
Api.create("api", "https://api.example.com", () => {
Route.get("users", "/users");
Route.get("posts", "/posts");
}).withBreaker({ failureThreshold: 5, resetTimeout: 30 }); // Defaults if omitted
// Handling circuit breaker errors
try {
await Klaim.api.flaky();
} catch (error) {
if (error.name === 'CircuitOpenError') {
console.log(`Circuit is open, retry in ${error.retryAfterMs}ms`);
}
}Request Deduplication
When several callers trigger the exact same GET request while a first call for it is still in flight, Klaim coalesces them: only one network call is made, and every caller receives the same result (success or failure). The dedup key is built from the route and its fully-resolved URL/params, so two GET calls with different parameters are never mixed up.
This only ever applies to GET requests. Mutating methods (POST, PUT, PATCH, DELETE) always hit the network, since sharing or replaying a write between callers would be incorrect.
⚠️ Note: cancelling a call via .cancel() (see Request Cancellation) aborts the shared in-flight request, which also rejects every other caller currently coalesced onto that same GET. Avoid cancelling calls you expect other code to be relying on concurrently.
Api.create("api", "https://api.example.com", () => {
Route.get("users", "/users");
});
// Both calls share a single underlying fetch
const [a, b] = await Promise.all([
Klaim.api.users(),
Klaim.api.users()
]);Request Timeout
Bound each request attempt with a timeout in seconds and an optional custom message. Timeout is disabled by default; .withTimeout() enables 5 seconds, and a route setting takes precedence over the API setting.
The budget starts after before, rate limiting and onCall, before cache lookup or fetch. It includes headers and body reading/decoding, but excludes validation, after, hooks and retry backoff. Each retry gets a fresh budget and transport controller. Cache hits are also inside the budget, but do not fetch or abort.
On expiration, Klaim rejects with TimeoutError and asks cooperative transports to abort. Late responses cannot start body reading, update the cache or run success callbacks; an already-started body read cannot publish a late result. Timers and caller-signal relays are removed when the attempt settles, without aborting successful requests. With retries enabled, the final timeout remains the cause of RetryExhaustedError.
A signal supplied through before as config.signal is relayed with its reason when timeout is enabled, and passed through unchanged otherwise. Caller cancellation preserves the existing retry/backoff behavior; it is not a new global cancellation policy. Without AbortController, only logical timeout and late-result guards are available. Server-side effects already performed cannot be undone, and synchronous work cannot be preempted. Native transport cancellation is tested on Node; Bun, Deno and browser transports are not verified by these tests.
Api.create("api", "https://api.example.com", () => {
Route.get("slow", "/slow").withTimeout(5, "Too slow");
}).withTimeout(10);
try {
await Klaim.api.slow();
} catch (error) {
console.error(error);
}Request Cancellation
Every call returned by a route exposes .cancel(reason?), letting you cancel that specific in-flight call without affecting concurrent calls to the same route.
Api.create("api", "https://api.example.com", () => {
Route.get("search", "/search");
});
const call = Klaim.api.search();
// Somewhere else, e.g. a component unmount or a newer request superseding this one
call.cancel();
try {
await call;
} catch (error) {
if (error.name === "CancelledError") {
console.log("Call was cancelled");
}
}Calling .cancel() a second time, or after the call has already settled, is a no-op. Pass a custom reason to reject with something other than the default CancelledError.
When the route has .withTimeout() enabled, cancellation reuses the same per-attempt AbortController used for the timeout budget, so the underlying transport (e.g. fetch) is aborted immediately with no extra allocation and no signal collision between concurrent calls. Without a timeout, cancellation still settles the call immediately and guards any late result (cache write, success callbacks) from completing, but cannot abort synchronous work already in progress. This is independent of, and composes with, a signal supplied through before as config.signal.
Response Validation
You can use Yup to validate the response schema for increased reliability and consistency. You can specify a schema for individual routes to ensure the response data conforms to the expected structure.
⚠️ Note: This feature requires the yup package to be installed.
Adding Validation to Individual Routes
Enable validation on individual routes:
import * as yup from 'yup';
// Define the schema using Yup
const todoSchema = yup.object().shape({
userId: yup.number().required(),
id: yup.number().min(1).max(10).required(),
title: yup.string().required(),
completed: yup.boolean().required()
});
Api.create("hello", "https://jsonplaceholder.typicode.com/", () => {
// Get a specific todo by id with validation
Route.get<Todo>("getTodo", "todos/[id]").validate(todoSchema);
});
// This request will fail because the id is out of range
const todoFail = await Klaim.hello.getTodo<Todo>({id: 15});
// This request will succeed
const todo = await Klaim.hello.getTodo<Todo>({id: 1});Using Zod for Validation
You can also validate responses with zod via the built-in zodAdapter. zod
schemas expose parseAsync/safeParseAsync instead of validate, so zodAdapter wraps a zod schema into the
{ validate } interface expected by Route#validate.
⚠️ Note: zod is not a dependency of Klaim - it stays entirely optional and must be installed by your project to
use zodAdapter.
import { z } from 'zod';
import { Api, Klaim, Route, zodAdapter } from 'klaim';
// Define the schema using zod
const todoSchema = z.object({
userId: z.number(),
id: z.number().min(1).max(10),
title: z.string(),
completed: z.boolean()
});
Api.create("hello", "https://jsonplaceholder.typicode.com/", () => {
// Get a specific todo by id with validation
Route.get<Todo>("getTodo", "todos/[id]").validate(zodAdapter(todoSchema));
});
// This request will fail because the id is out of range,
// throwing a ValidationError with the zod issues as `cause`
const todoFail = await Klaim.hello.getTodo<Todo>({id: 15});
// This request will succeed
const todo = await Klaim.hello.getTodo<Todo>({id: 1});Pagination
Configure pagination for routes that require it:
// Basic usage with custom limit and offset parameter
Api.create("api", "https://api.example.com", () => {
Route.get("list", "/items").withPagination({
limit: 20, // Items per page
page: 1, // Default page number
pageParam: "offset", // Parameter name for page/offset or any other custom parameter
limitParam: "limit" // Parameter name for limit
}); // All options are optional
});
// Using paginated endpoints
const page1 = await Klaim.api.list(); // First page
const page2 = await Klaim.api.list(2); // Second page
const customPage = await Klaim.api.list(5); // Fifth page⚠️ Note: The pagination feature simplifies your pagination parameters, but your API/backend needs to respond to these parameters. Klaim does not handle the pagination logic, only the parameters management.
Batch Requests
Run several route calls concurrently with batch() and get a partial-success result, the same way
Promise.allSettled
works: a failing call never rejects the whole batch nor blocks the other calls. Each entry of the result is either
{status: 'fulfilled', value} or {status: 'rejected', reason}.
batch() only orchestrates existing route calls — it does not bypass per-route protections, so caching, retry,
timeout and rate limiting configured on your routes/APIs still apply exactly as if each call had been made
individually.
Two input shapes are supported, both returning a result with the same shape as the input:
- Named object (recommended): keys let you destructure results by name.
- Array: results keep the same order/positions as the input.
Prefer thunks (() => Klaim.api.route()) over bare promises so the request is only fired when batch() runs.
import { batch, Klaim } from "klaim";
// Named form
const {todos, user} = await batch({
todos: () => Klaim.hello.listTodos(),
user: () => Klaim.hello.getUser({id: 1})
});
if (todos.status === "fulfilled") console.log(todos.value);
if (user.status === "rejected") console.error(user.reason);
// Array form
const [todosResult, userResult] = await batch([
() => Klaim.hello.listTodos(),
() => Klaim.hello.getUser({id: 1})
]);🔗 Links
📢 Project Status
Klaim is now considered feature complete. The library has reached a state where it provides all the core functionality originally envisioned (even more), and no new features are currently planned.
However, this doesn't mean the project is abandoned:
- Bug fixes and maintenance will continue to be addressed
- Issues remain open for bug reports and suggestions
- Pull requests are welcome if you'd like to contribute additional features or improvements
If you have ideas for new features that would enhance Klaim, please feel free to open an issue to discuss them. Collaborative contributions through pull requests are especially appreciated!
Thank you for your interest in this project. I personally will be moving on to focus on other libraries, but I'm grateful for all the support and feedback the community has provided.
🤝 Contributing
Contributions are welcome! Please see the Contributing Guide for more details.
📜 License
This project is licensed under the MIT License - see the LICENSE file for details.
