Skip to content

Api-Wrappers/api-core

Repository files navigation

@api-wrappers/api-core

Shared TypeScript HTTP runtime for API wrapper libraries.

npm version license CI GitHub Repo stars

@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 fetch or the full Transport.
  • 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:

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, and request.
  • requestWithResponse for wrappers that need response headers, status, or plugin metadata.
  • GraphQL helper with typed data and variables.
  • Deterministic plugin lifecycle with setup, beforeRequest, afterResponse, onError, and dispose.
  • Built-in auth, cache, logger, rate-limit, retry, and timeout plugins.
  • Native HeadersInit support for default, per-request, and GraphQL headers.
  • Type guards for ergonomic unknown error 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, and AbortController available. Modern Node, Bun, browsers, and edge runtimes satisfy this.
  • For older runtimes, pass a custom fetch implementation or a full custom Transport.

Installation

bun add @api-wrappers/api-core
npm install @api-wrappers/api-core

Examples

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.

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,
	logger: console,
});

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

dist is generated by tsdown. The published package includes dist, docs, README.md, LICENSE, CHANGELOG.md, CONTRIBUTING.md, ROADMAP.md, and package.json.

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.

About

Shared TypeScript HTTP runtime for API wrapper libraries with request orchestration, plugin lifecycle hooks, transport abstraction, retries, caching, rate limiting, auth helpers, GraphQL support, and typed REST helpers.

Topics

Resources

License

Contributing

Stars

Watchers

Forks

Packages

 
 
 

Contributors