@api-wrappers/api-core
Shared TypeScript HTTP runtime for API wrapper libraries.
@api-wrappers/api-core is the shared runtime that powers the Api-Wrappers
package ecosystem. It gives wrapper packages a small, predictable foundation for
request execution, retries, timeouts, auth headers, caching, rate limiting,
GraphQL requests, custom transports, and plugin-based request/response
middleware.
This package exists so each wrapper does not need to reimplement fetch handling, error parsing, retry behavior, timeout behavior, auth headers, cache hooks, or test transports. Wrapper packages can focus on domain-specific endpoints and types while sharing one maintained HTTP layer.
Why use this?
- Keep REST and GraphQL wrappers on one consistent request pipeline.
- Reuse battle-tested auth, retry, timeout, cache, logger, and rate-limit plugins instead of rewriting them per API.
- Make wrapper clients easier to test by swapping
fetchor the fullTransport. - Preserve strict TypeScript types while still handling unknown runtime errors through exported error classes and guards.
- Support Node, Bun, browsers, and edge runtimes without committing wrappers to one server environment.
- Give contributors one runtime contract to understand before improving any wrapper package.
Used by
api-core is intended to be the foundation under Api-Wrappers packages such as:
@api-wrappers/tmdb-wrapper@api-wrappers/trakt-wrapper@api-wrappers/igdb-wrapper@api-wrappers/anilist-wrapper
When should you use this directly?
Use @api-wrappers/api-core directly when you are building a typed API wrapper,
SDK, integration package, or internal service client and want shared HTTP
behavior without locking into a large framework. It is also useful when you need
a testable transport abstraction around fetch.
When should you not use this?
Do not use this package when you only need one or two direct fetch calls, when
a provider's official SDK already covers your use case well, or when your
application needs generated clients from an OpenAPI or GraphQL schema as the
primary source of truth. api-core is a runtime foundation, not a schema
generator, endpoint catalog, or full application data-fetching framework.
API Coverage
api-core covers the shared HTTP runtime concerns wrapper packages usually
need:
- Typed REST helpers:
get,post,put,patch,delete,head,options, andrequest. requestWithResponsefor wrappers that need response headers, status, or plugin metadata.- GraphQL helper with typed
dataandvariables. - Deterministic plugin lifecycle with
setup,beforeRequest,afterResponse,onError, anddispose. - Built-in auth, cache, logger, rate-limit, retry, and timeout plugins.
- Native
HeadersInitsupport for default, per-request, and GraphQL headers. - Type guards for ergonomic
unknownerror handling in TypeScript. - Fetch transport with JSON bodies, raw string bodies, abort signals, and timeout handling.
- Response parsing for JSON, text, and binary payloads.
- Query string support for primitives and repeated array values.
- ESM and CommonJS builds with TypeScript declarations.
Runtime support
- TypeScript 5+
- A runtime with
fetch,Request,Response, andAbortControlleravailable. Modern Node, Bun, browsers, and edge runtimes satisfy this. - For older runtimes, pass a custom
fetchimplementation or a full customTransport.
Installation
bun add @api-wrappers/api-corenpm install @api-wrappers/api-coreExamples
Basic REST request
import { createClient } from "@api-wrappers/api-core";
interface Movie {
id: number;
title: string;
}
interface MovieSearchResponse {
page: number;
results: Array<Movie>;
}
const client = createClient({
baseUrl: "https://api.example.com/v1",
defaultHeaders: { accept: "application/json" },
});
const movies = await client.get<MovieSearchResponse>("/search/movie", {
query: {
query: "Arrival",
page: 1,
},
});
console.log(movies.results[0]?.title);Auth plugin
import { createAuthPlugin, createClient } from "@api-wrappers/api-core";
const tokenStore: { accessToken?: string } = {
accessToken: "provider-access-token",
};
const client = createClient({
baseUrl: "https://api.example.com/v1",
plugins: [
createAuthPlugin({
getToken: () => tokenStore.accessToken,
headerName: "authorization",
scheme: "Bearer",
}),
],
});
await client.get("/account");The token is loaded for each request, so wrappers can refresh credentials without rebuilding the client.
Retry plugin
import { createClient, createRetryPlugin } from "@api-wrappers/api-core";
const client = createClient({
baseUrl: "https://api.example.com/v1",
plugins: [
createRetryPlugin({
maxAttempts: 3,
delayMs: 300,
jitter: true,
retriableStatusCodes: [429, 500, 502, 503, 504],
}),
],
});
await client.get("/temporarily-flaky-resource");Timeout plugin
import { createClient, createTimeoutPlugin } from "@api-wrappers/api-core";
const client = createClient({
baseUrl: "https://api.example.com/v1",
plugins: [createTimeoutPlugin({ timeoutMs: 10_000 })],
});
await client.get("/slow-report");Requests that exceed the timeout throw TimeoutError.
GraphQL request
import { createClient, gql } from "@api-wrappers/api-core";
interface ViewerQuery {
Viewer: {
id: number;
name: string;
};
}
const client = createClient({
baseUrl: "https://graphql.example.com",
});
const data = await client.graphql<ViewerQuery>("/", {
query: gql`
query Viewer {
Viewer {
id
name
}
}
`,
});
console.log(data.Viewer.name);Custom fetch / transport
Use fetch when you only need to swap the fetch implementation:
import { createClient } from "@api-wrappers/api-core";
import type { FetchLike } from "@api-wrappers/api-core";
const tracedFetch: FetchLike = async (input, init) => {
console.log("api-core request", input);
return fetch(input, init);
};
const client = createClient({
baseUrl: "https://api.example.com/v1",
fetch: tracedFetch,
});Use transport when tests or nonstandard runtimes need full execution control:
import { createClient } from "@api-wrappers/api-core";
import type { Transport } from "@api-wrappers/api-core";
interface EchoBody {
url: string;
method: string;
}
const testTransport: Transport = {
async execute(ctx) {
const body: EchoBody = {
url: ctx.url,
method: ctx.method,
};
return new Response(JSON.stringify(body), {
headers: { "content-type": "application/json" },
});
},
};
const client = createClient({
baseUrl: "https://api.example.com/v1",
transport: testTransport,
});Quick Start
import {
createAuthPlugin,
createClient,
createRetryPlugin,
createTimeoutPlugin,
} from "@api-wrappers/api-core";
interface User {
id: string;
name: string;
}
const client = createClient({
baseUrl: "https://api.example.com/v1",
defaultHeaders: { accept: "application/json" },
plugins: [
createAuthPlugin(() => process.env.API_TOKEN),
createRetryPlugin({ maxAttempts: 3, delayMs: 300 }),
createTimeoutPlugin({ timeoutMs: 30_000 }),
],
});
const user = await client.get<User>("/users/123");baseUrl and request paths are slash-safe:
client.get("/users");
client.get("users");Both work with baseUrl: "https://api.example.com/v1/".
Client Configuration
import { createClient } from "@api-wrappers/api-core";
const client = createClient({
baseUrl: "https://api.example.com",
defaultHeaders: {
accept: "application/json",
},
timeoutMs: 10_000,
retry: {
maxAttempts: 2,
delayMs: 250,
jitter: true,
retriableStatusCodes: [429, 500, 502, 503, 504],
},
plugins: [],
logger: console,
});| Option | Purpose |
|---|---|
baseUrl |
Base URL prepended to relative request paths. |
defaultHeaders |
Headers merged into every request. Per-request headers win. |
timeoutMs |
Default request timeout. Can be overridden per request. |
retry |
Global retry policy. Can be overridden by createRetryPlugin. |
plugins |
Plugin list for auth, cache, logging, rate limiting, etc. |
transport |
Full request executor override, useful in tests. |
fetch |
Custom fetch implementation used by the default transport. |
logger |
Internal diagnostics logger. Defaults to console. |
Requests
await client.get<SearchResult>("/search", {
query: {
q: "alien",
page: 2,
with_genres: [878, 12],
skip: undefined,
},
headers: { accept: "application/json" },
timeoutMs: 5_000,
signal: abortController.signal,
tags: ["search"],
cacheKey: "search:alien:2",
});Query values can be strings, numbers, booleans, nullish values, or arrays of
those primitives. null and undefined are skipped. Arrays are encoded as
repeated query parameters:
?with_genres=878&with_genres=12
Request Methods
client.get<T>(path, options);
client.post<T>(path, body, options);
client.put<T>(path, body, options);
client.patch<T>(path, body, options);
client.delete<T>(path, options);
client.head<T>(path, options);
client.options<T>(path, options);
client.request<T>(path, { method: "POST", body });Plain objects and arrays are JSON encoded. Strings and native BodyInit
values are sent as-is, which supports APIs that expect text query languages:
const games = await client.post<Array<Game>>(
"/games",
"fields name,rating; limit 10;",
{
headers: {
"content-type": "text/plain",
accept: "application/json",
},
},
);Binary responses can stay on the shared request path by selecting an explicit response type:
const bytes = await client.post<ArrayBuffer>("/games.pb", query, {
headers: { accept: "application/octet-stream" },
responseType: "arrayBuffer",
});Response Metadata
Use requestWithResponse when a wrapper needs more than the parsed body:
const result = await client.requestWithResponse<MoviePage>("/movie/popular");
result.data;
result.response.status;
result.response.headers.get("x-ratelimit-remaining");
result.request.url;
result.meta["cache.served"];GraphQL
interface GetMediaQuery {
Media: { id: number; title: { romaji: string } };
}
interface GetMediaVariables {
id: number;
}
const data = await client.graphql<GetMediaQuery, GetMediaVariables>("/", {
query: `
query GetMedia($id: Int) {
Media(id: $id) { id title { romaji } }
}
`,
variables: { id: 1 },
operationName: "GetMedia",
});
console.log(data.Media.title.romaji);GraphQL uses the same transport, plugin lifecycle, retry policy, timeout handling, and error classes as REST requests.
Built-In Plugins
Plugins are ordinary objects that run through a deterministic lifecycle. Lower
priority values run earlier in beforeRequest; higher values run earlier in
afterResponse.
Auth
createAuthPlugin("static-token");
createAuthPlugin(() => tokenStore.getAccessToken());
createAuthPlugin({
getToken: () => apiKey,
headerName: "x-api-key",
scheme: null,
});The default header is:
authorization: Bearer <token>
Retry
createRetryPlugin({
maxAttempts: 3,
delayMs: 300,
jitter: true,
retriableStatusCodes: [429, 500, 502, 503, 504],
});429 responses respect retry-after when present. Numeric values are treated
as seconds; HTTP-date values are also supported.
Timeout
createTimeoutPlugin({ timeoutMs: 30_000 });Timeouts throw TimeoutError.
Rate Limit
createRateLimitPlugin({
maxConcurrent: 4,
minTimeMs: 250,
});createRateLimitPlugin({
maxRequestsPerInterval: 30,
intervalMs: 60_000,
});The limiter releases slots on successful responses, transport failures, and
plugin failures. Queued requests also observe RequestOptions.signal: if the
caller aborts before the request acquires a slot, the queued item is removed and
the request rejects with the abort reason without reaching the transport.
Cache
import { createCachePlugin, MemoryStore } from "@api-wrappers/api-core";
const cache = createCachePlugin({
store: new MemoryStore(),
ttlMs: 60_000,
methods: ["GET"],
});
const client = createClient({
baseUrl: "https://api.example.com",
plugins: [cache],
});
await client.get("/users/1", { tags: ["user"] });
await cache.invalidate("GET:https://api.example.com/users/1");
await cache.invalidateByTag("user");Cache hits skip the transport and set meta["cache.served"].
Logger
createLoggerPlugin({
logRequest: true,
logResponse: true,
logError: true,
logBody: false,
logger: console,
});Request bodies are not logged by default. To log payloads in a trusted
environment, opt in with logBody: true and use redactBody to remove tokens,
secrets, or user data before records reach your logger.
createLoggerPlugin({
logBody: true,
redactBody: () => ({ token: "[redacted]" }),
});Pass a structured logger or no-op logger to control diagnostics.
Custom Plugins
import type { ApiPlugin } from "@api-wrappers/api-core";
export function createClientIdPlugin(clientId: string): ApiPlugin {
return {
name: "client-id",
priority: 2,
beforeRequest(ctx) {
return {
...ctx,
headers: {
...ctx.headers,
"client-id": clientId,
},
};
},
};
}Hooks may return a new context or undefined to keep the current one.
| Hook | When it runs |
|---|---|
setup(client) |
Once, lazily before the first request. |
beforeRequest(ctx) |
Before transport execution. |
afterResponse(ctx) |
After response parsing. |
onError(error, ctx) |
For transport, HTTP, and plugin failures. |
dispose() |
When client.dispose() is called. |
Read docs/guides/plugins.md for the full plugin contract.
Error Handling
import {
isApiError,
isGraphQLRequestError,
isRateLimitError,
isTimeoutError,
} from "@api-wrappers/api-core";
try {
await client.get("/resource");
} catch (error) {
if (isRateLimitError(error)) {
console.log(error.retryAfterMs);
} else if (isTimeoutError(error)) {
console.log("timed out");
} else if (isGraphQLRequestError(error)) {
console.log(error.graphqlErrors);
} else if (isApiError(error)) {
console.log(error.status, error.responseBody);
}
}| Error | Meaning |
|---|---|
ApiError |
Non-2xx HTTP response after retries are exhausted. |
RateLimitError |
HTTP 429 response. Includes retryAfterMs when available. |
TimeoutError |
Request exceeded timeout. |
GraphQLRequestError |
GraphQL response contained an errors array. |
Testing
Use a custom transport for deterministic tests:
import { BaseHttpClient } from "@api-wrappers/api-core";
const client = new BaseHttpClient({
baseUrl: "https://api.example.com",
transport: {
execute: async (ctx) =>
new Response(JSON.stringify({ url: ctx.url }), {
headers: { "content-type": "application/json" },
}),
},
});This exercises the client, request options, plugins, and error handling without making network calls.
Package Exports
import {
ApiError,
BaseHttpClient,
createAuthPlugin,
createCachePlugin,
createClient,
createLoggerPlugin,
createRateLimitPlugin,
createRetryPlugin,
createTimeoutPlugin,
gql,
GraphQLRequestError,
isApiCoreError,
isApiError,
isGraphQLRequestError,
isRateLimitError,
isTimeoutError,
MemoryStore,
RateLimitError,
TimeoutError,
} from "@api-wrappers/api-core";
import type {
ApiCoreError,
ApiPlugin,
ApiResponse,
ClientConfig,
FetchLike,
HeaderInput,
QueryParams,
RequestContext,
RequestOptions,
ResponseContext,
Transport,
} from "@api-wrappers/api-core";The package publishes:
- ESM:
dist/index.mjs - CommonJS:
dist/index.cjs - Type declarations for both module formats
- README, license, changelog, roadmap, contributing guide, and docs
More Documentation
- Documentation home: recommended reading order and full docs map.
- Getting started: install, create a client, and make the first request.
- Examples: copy-pasteable REST, plugin, GraphQL, transport, and error-handling examples.
- REST requests: methods, query params, request bodies, abort signals, and response metadata.
- Built-in plugins: auth, retry, timeout, rate-limit, cache, and logger usage.
- Client API reference: client methods and response shapes.
- Roadmap: runtime direction, priorities, and non-goals.
- Contributing: local setup, review expectations, and validation commands.
- Contributing ideas: starter issue ideas for new contributors.
Development
bun install
bun run verify
bun run check
bun run typecheck
bun test
bun run build
bun run pack:dry-run
bun run smoke:packagedist is generated by tsdown. The published package includes dist, docs,
README.md, LICENSE, CHANGELOG.md, CONTRIBUTING.md, ROADMAP.md, and
package.json. bun run smoke:package packs the project into a temporary
consumer and verifies both ESM import and CommonJS require from the package root.
Release process
Maintainers release from main with Changesets. Add a changeset with
bun run changeset, merge the generated version PR, and the release workflow
will run validation, publish to npm with provenance, and create GitHub release
notes. See .github/RELEASE.md for npm trusted publishing
settings and safe dry-run guidance.