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.
- 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.
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
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.
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-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.
- 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.
bun add @api-wrappers/api-corenpm install @api-wrappers/api-coreimport { 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);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.
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");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.
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);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,
});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/".
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. |
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=12client.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",
});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"];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.
Plugins are ordinary objects that run through a deterministic lifecycle. Lower
priority values run earlier in beforeRequest; higher values run earlier in
afterResponse.
createAuthPlugin("static-token");
createAuthPlugin(() => tokenStore.getAccessToken());
createAuthPlugin({
getToken: () => apiKey,
headerName: "x-api-key",
scheme: null,
});The default header is:
authorization: Bearer <token>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.
createTimeoutPlugin({ timeoutMs: 30_000 });Timeouts throw TimeoutError.
createRateLimitPlugin({
maxConcurrent: 4,
minTimeMs: 250,
});createRateLimitPlugin({
maxRequestsPerInterval: 30,
intervalMs: 60_000,
});The limiter releases slots on successful responses, transport failures, and plugin failures.
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"].
createLoggerPlugin({
logRequest: true,
logResponse: true,
logError: true,
logger: console,
});Pass a structured logger or no-op logger to control diagnostics.
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.
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. |
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.
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
- 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.
bun install
bun run verify
bun run check
bun run typecheck
bun test
bun run build
bun run pack:dry-rundist is generated by tsdown. The published package includes dist, docs,
README.md, LICENSE, CHANGELOG.md, CONTRIBUTING.md, ROADMAP.md, and
package.json.
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.