@api-wrappers/api-core 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.mjs","names":[],"sources":["../src/errors/ApiError.ts","../src/errors/RateLimitError.ts","../src/graphql/GraphQLRequestError.ts","../src/plugin/PluginManager.ts","../src/errors/TimeoutError.ts","../src/utils/buildUrl.ts","../src/utils/isPlainObject.ts","../src/transport/fetchTransport.ts","../src/utils/mergeHeaders.ts","../src/utils/resolveUrl.ts","../src/utils/sleep.ts","../src/client/BaseHttpClient.ts","../src/client/createClient.ts","../src/plugins/auth/authPlugin.ts","../src/plugins/cache/memoryStore.ts","../src/plugins/cache/cachePlugin.ts","../src/plugins/logger/loggerPlugin.ts","../src/plugins/rateLimit/rateLimitPlugin.ts","../src/plugins/retry/retryPlugin.ts","../src/plugins/timeout/timeoutPlugin.ts"],"sourcesContent":["export class ApiError extends Error {\n\treadonly status: number;\n\treadonly responseBody: unknown;\n\toverride readonly cause: unknown;\n\n\tconstructor(\n\t\tmessage: string,\n\t\tstatus: number,\n\t\tresponseBody?: unknown,\n\t\tcause?: unknown,\n\t) {\n\t\tsuper(message);\n\t\tthis.name = \"ApiError\";\n\t\tthis.status = status;\n\t\tthis.responseBody = responseBody;\n\t\tthis.cause = cause;\n\t}\n}\n","import { ApiError } from \"./ApiError\";\n\nexport class RateLimitError extends ApiError {\n\treadonly retryAfterMs: number | undefined;\n\n\tconstructor(retryAfterMs?: number, responseBody?: unknown, cause?: unknown) {\n\t\tsuper(\"Rate limit exceeded\", 429, responseBody, cause);\n\t\tthis.name = \"RateLimitError\";\n\t\tthis.retryAfterMs = retryAfterMs;\n\t}\n}\n","import { ApiError } from \"../errors/ApiError\";\nimport type { GraphQLErrorDetail } from \"./types\";\n\n/**\n * Thrown when a GraphQL server returns a well-formed HTTP 200 response that\n * contains a non-empty `errors` array.\n *\n * Extends {@link ApiError} so that code catching `ApiError` also catches\n * GraphQL-level failures. Callers that need to inspect the individual error\n * objects can narrow with `instanceof GraphQLRequestError` and read\n * `graphqlErrors`.\n *\n * When the server returns both `data` and `errors` (partial result), the\n * partial data is available on `partialData` but the error is still thrown —\n * callers must explicitly opt in to consuming partial results.\n *\n * @example\n * ```ts\n * import { GraphQLRequestError } from \"@api-wrappers/api-core\";\n *\n * try {\n * const data = await client.graphql<MyQuery>(\"/graphql\", { query: QUERY });\n * } catch (err) {\n * if (err instanceof GraphQLRequestError) {\n * for (const e of err.graphqlErrors) {\n * console.error(e.message, e.path);\n * }\n * }\n * }\n * ```\n */\nexport class GraphQLRequestError extends ApiError {\n\t/** The errors array from the GraphQL response envelope. */\n\treadonly graphqlErrors: readonly GraphQLErrorDetail[];\n\t/**\n\t * Partial `data` returned alongside `errors`, if any. `undefined` when\n\t * the server returned no `data` field.\n\t */\n\treadonly partialData: unknown;\n\n\tconstructor(\n\t\terrors: GraphQLErrorDetail[],\n\t\tpartialData?: unknown,\n\t\tcause?: unknown,\n\t) {\n\t\tconst message = errors.map((e) => e.message).join(\"; \");\n\t\t// Status 200: the HTTP request succeeded; the failure is at the\n\t\t// GraphQL application layer, not the transport layer.\n\t\tsuper(`GraphQL errors: ${message}`, 200, { errors }, cause);\n\t\tthis.name = \"GraphQLRequestError\";\n\t\tthis.graphqlErrors = errors;\n\t\tthis.partialData = partialData;\n\t}\n}\n","import type { LoggerInterface } from \"../client/types\";\nimport type { RequestContext } from \"../context/RequestContext\";\nimport type { ResponseContext } from \"../context/ResponseContext\";\nimport type { ApiPlugin } from \"./types\";\n\nexport class PluginManager {\n\tprivate readonly plugins: ApiPlugin[] = [];\n\tprivate readonly logger: LoggerInterface;\n\n\t/**\n\t * @param logger - Logger used when an `onError` handler itself throws.\n\t * Defaults to `console`. Pass a no-op object to silence all output.\n\t */\n\tconstructor(logger: LoggerInterface = console) {\n\t\tthis.logger = logger;\n\t}\n\n\tregister(plugin: ApiPlugin): void {\n\t\tif (plugin.enabled === false) return;\n\t\tthis.plugins.push(plugin);\n\t\t// Sort ascending so lower priority numbers run first in beforeRequest.\n\t\tthis.plugins.sort((a, b) => (a.priority ?? 100) - (b.priority ?? 100));\n\t}\n\n\tgetAll(): readonly ApiPlugin[] {\n\t\treturn this.plugins;\n\t}\n\n\tasync setup(client: unknown): Promise<void> {\n\t\tfor (const plugin of this.plugins) {\n\t\t\tawait plugin.setup?.(client);\n\t\t}\n\t}\n\n\t/**\n\t * Runs `beforeRequest` in ascending priority order (lowest first).\n\t * Each plugin may return a mutated context.\n\t */\n\tasync beforeRequest(ctx: RequestContext): Promise<RequestContext> {\n\t\tlet current = ctx;\n\t\tfor (const plugin of this.plugins) {\n\t\t\ttry {\n\t\t\t\tconst result = await plugin.beforeRequest?.(current);\n\t\t\t\tif (result != null) current = result;\n\t\t\t} catch (err) {\n\t\t\t\tthrow wrapPluginError(plugin.name, \"beforeRequest\", err, current);\n\t\t\t}\n\t\t}\n\t\treturn current;\n\t}\n\n\t/**\n\t * Runs `afterResponse` in descending priority order (highest first).\n\t * Each plugin may return a mutated context.\n\t */\n\tasync afterResponse(ctx: ResponseContext): Promise<ResponseContext> {\n\t\tlet current = ctx;\n\t\tfor (const plugin of [...this.plugins].reverse()) {\n\t\t\ttry {\n\t\t\t\tconst result = await plugin.afterResponse?.(current);\n\t\t\t\tif (result != null) current = result;\n\t\t\t} catch (err) {\n\t\t\t\tthrow wrapPluginError(plugin.name, \"afterResponse\", err);\n\t\t\t}\n\t\t}\n\t\treturn current;\n\t}\n\n\t/**\n\t * Runs `onError` on all plugins in registration order. A plugin throwing\n\t * here is caught and logged via the configured logger but does not\n\t * interrupt other `onError` handlers.\n\t */\n\tasync onError(error: unknown, ctx: RequestContext): Promise<void> {\n\t\tfor (const plugin of this.plugins) {\n\t\t\ttry {\n\t\t\t\tawait plugin.onError?.(error, ctx);\n\t\t\t} catch (inner) {\n\t\t\t\tthis.logger.error(\n\t\t\t\t\t`[PluginManager] Plugin \"${plugin.name}\" threw inside onError:`,\n\t\t\t\t\tinner,\n\t\t\t\t);\n\t\t\t}\n\t\t}\n\t}\n\n\tasync dispose(): Promise<void> {\n\t\tfor (const plugin of [...this.plugins].reverse()) {\n\t\t\tawait plugin.dispose?.();\n\t\t}\n\t}\n}\n\nexport function getPluginErrorContext(\n\terror: unknown,\n): RequestContext | undefined {\n\tif (!error || typeof error !== \"object\") return undefined;\n\treturn (error as { requestContext?: RequestContext }).requestContext;\n}\n\nfunction wrapPluginError(\n\tname: string,\n\thook: string,\n\tcause: unknown,\n\trequestContext?: RequestContext,\n): Error {\n\tconst message = `Plugin \"${name}\" threw during \"${hook}\"`;\n\tconst err = new Error(message, { cause }) as Error & {\n\t\trequestContext?: RequestContext;\n\t};\n\terr.name = \"PluginError\";\n\terr.requestContext = requestContext;\n\treturn err;\n}\n","export class TimeoutError extends Error {\n\toverride readonly cause: unknown;\n\n\tconstructor(message = \"Request timed out\", cause?: unknown) {\n\t\tsuper(message);\n\t\tthis.name = \"TimeoutError\";\n\t\tthis.cause = cause;\n\t}\n}\n","import type { QueryParams } from \"../types/common\";\n\n/**\n * Appends a query string to a URL. Skips nullish values and repeats keys for\n * array values so APIs like TMDB can accept `with_genres=1&with_genres=2`.\n */\nexport function buildUrl(base: string, query?: QueryParams): string {\n\tif (!query || Object.keys(query).length === 0) return base;\n\n\tconst params = new URLSearchParams();\n\tfor (const [key, value] of Object.entries(query)) {\n\t\tif (value === undefined || value === null) continue;\n\n\t\tif (Array.isArray(value)) {\n\t\t\tfor (const item of value) {\n\t\t\t\tif (item !== undefined && item !== null) {\n\t\t\t\t\tparams.append(key, String(item));\n\t\t\t\t}\n\t\t\t}\n\t\t} else {\n\t\t\tparams.append(key, String(value));\n\t\t}\n\t}\n\n\tconst qs = params.toString();\n\tif (!qs) return base;\n\n\tconst separator = base.includes(\"?\")\n\t\t? base.endsWith(\"?\") || base.endsWith(\"&\")\n\t\t\t? \"\"\n\t\t\t: \"&\"\n\t\t: \"?\";\n\treturn `${base}${separator}${qs}`;\n}\n","export function isPlainObject(\n\tvalue: unknown,\n): value is Record<string, unknown> {\n\tif (typeof value !== \"object\" || value === null) return false;\n\tconst proto = Object.getPrototypeOf(value) as unknown;\n\treturn proto === Object.prototype || proto === null;\n}\n","import type { RequestContext } from \"../context/RequestContext\";\nimport { TimeoutError } from \"../errors/TimeoutError\";\nimport { buildUrl } from \"../utils/buildUrl\";\nimport { isPlainObject } from \"../utils/isPlainObject\";\nimport type { Transport } from \"./types\";\n\n/**\n * Creates a {@link Transport} backed by the provided `fetch` function.\n * Use this when you need a polyfill or a custom fetch interceptor:\n *\n * ```ts\n * import nodeFetch from \"node-fetch\";\n * createClient({ fetch: nodeFetch as typeof globalThis.fetch });\n * // — or set it directly on the transport:\n * const transport = createFetchTransport(nodeFetch as typeof globalThis.fetch);\n * ```\n */\nexport function createFetchTransport(\n\tfetchFn: typeof globalThis.fetch = globalThis.fetch,\n): Transport {\n\treturn {\n\t\tasync execute(ctx: RequestContext): Promise<Response> {\n\t\t\tconst url = buildUrl(ctx.url, ctx.query);\n\t\t\tconst init: RequestInit = {\n\t\t\t\tmethod: ctx.method,\n\t\t\t\theaders: ctx.headers,\n\t\t\t};\n\n\t\t\tconst hasBody =\n\t\t\t\tctx.body !== undefined && ctx.method !== \"GET\" && ctx.method !== \"HEAD\";\n\n\t\t\tif (hasBody) {\n\t\t\t\tinit.body = serializeRequestBody(ctx.body, ctx.headers);\n\t\t\t}\n\n\t\t\tif (ctx.timeoutMs !== undefined || ctx.signal) {\n\t\t\t\tconst controller = new AbortController();\n\t\t\t\tlet timedOut = false;\n\t\t\t\tconst abortFromParent = () => controller.abort(ctx.signal?.reason);\n\t\t\t\tconst timer =\n\t\t\t\t\tctx.timeoutMs !== undefined\n\t\t\t\t\t\t? setTimeout(() => {\n\t\t\t\t\t\t\t\ttimedOut = true;\n\t\t\t\t\t\t\t\tcontroller.abort();\n\t\t\t\t\t\t\t}, ctx.timeoutMs)\n\t\t\t\t\t\t: undefined;\n\n\t\t\t\tif (ctx.signal) {\n\t\t\t\t\tif (ctx.signal.aborted) {\n\t\t\t\t\t\tcontroller.abort(ctx.signal.reason);\n\t\t\t\t\t} else {\n\t\t\t\t\t\tctx.signal.addEventListener(\"abort\", abortFromParent, {\n\t\t\t\t\t\t\tonce: true,\n\t\t\t\t\t\t});\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\ttry {\n\t\t\t\t\treturn await fetchFn(url, { ...init, signal: controller.signal });\n\t\t\t\t} catch (err) {\n\t\t\t\t\tif (timedOut && err instanceof Error && err.name === \"AbortError\") {\n\t\t\t\t\t\tthrow new TimeoutError(\n\t\t\t\t\t\t\t`Request timed out after ${ctx.timeoutMs}ms`,\n\t\t\t\t\t\t\terr,\n\t\t\t\t\t\t);\n\t\t\t\t\t}\n\t\t\t\t\tthrow err;\n\t\t\t\t} finally {\n\t\t\t\t\tif (timer) clearTimeout(timer);\n\t\t\t\t\tctx.signal?.removeEventListener(\"abort\", abortFromParent);\n\t\t\t\t}\n\t\t\t}\n\n\t\t\treturn fetchFn(url, init);\n\t\t},\n\t};\n}\n\n/**\n * Default {@link Transport} backed by the global `fetch` API.\n *\n * Behaviour:\n * - Builds the final URL from `ctx.url` + `ctx.query` via {@link buildUrl}.\n * - Serialises `ctx.body` to JSON for non-GET/HEAD requests.\n * - Wires an `AbortController` when `ctx.timeoutMs` is set; throws\n * {@link TimeoutError} on abort.\n *\n * Replace this with a custom {@link Transport} in tests, or provide a custom\n * `fetch` function via {@link ClientConfig.fetch}.\n */\nexport const fetchTransport: Transport = createFetchTransport();\n\nfunction serializeRequestBody(\n\tbody: unknown,\n\theaders: Record<string, string>,\n): BodyInit {\n\tif (isBodyInit(body)) return body;\n\n\tconst contentType = headers[\"content-type\"] ?? \"\";\n\tif (\n\t\tisPlainObject(body) ||\n\t\tArray.isArray(body) ||\n\t\tcontentType.includes(\"json\")\n\t) {\n\t\treturn JSON.stringify(body);\n\t}\n\n\treturn String(body);\n}\n\nfunction isBodyInit(body: unknown): body is BodyInit {\n\tif (typeof body === \"string\") return true;\n\tif (body instanceof ArrayBuffer) return true;\n\tif (ArrayBuffer.isView(body)) return true;\n\tif (typeof Blob !== \"undefined\" && body instanceof Blob) return true;\n\tif (typeof FormData !== \"undefined\" && body instanceof FormData) return true;\n\tif (\n\t\ttypeof URLSearchParams !== \"undefined\" &&\n\t\tbody instanceof URLSearchParams\n\t) {\n\t\treturn true;\n\t}\n\tif (typeof ReadableStream !== \"undefined\" && body instanceof ReadableStream) {\n\t\treturn true;\n\t}\n\treturn false;\n}\n","/**\n * Merges header objects left to right. Keys are normalized to\n * lowercase so merging is case-insensitive. Later sources win.\n */\nexport function mergeHeaders(\n\t...sources: (Record<string, string> | undefined)[]\n): Record<string, string> {\n\tconst result: Record<string, string> = {};\n\tfor (const source of sources) {\n\t\tif (!source) continue;\n\t\tfor (const [key, value] of Object.entries(source)) {\n\t\t\tresult[key.toLowerCase()] = value;\n\t\t}\n\t}\n\treturn result;\n}\n","/**\n * Joins a client base URL and request path without requiring callers to keep\n * slashes perfectly aligned. Absolute request URLs are returned unchanged.\n */\nexport function resolveUrl(baseUrl: string, path: string): string {\n\tif (/^[a-z][a-z\\d+\\-.]*:\\/\\//i.test(path)) return path;\n\n\tconst base = baseUrl.replace(/\\/+$/, \"\");\n\tconst next = path.replace(/^\\/+/, \"\");\n\n\treturn next ? `${base}/${next}` : base;\n}\n","export function sleep(ms: number): Promise<void> {\n\treturn new Promise((resolve) => setTimeout(resolve, ms));\n}\n","import type { RequestContext } from \"../context/RequestContext\";\nimport type { ResponseContext } from \"../context/ResponseContext\";\nimport { ApiError } from \"../errors/ApiError\";\nimport { RateLimitError } from \"../errors/RateLimitError\";\nimport { GraphQLRequestError } from \"../graphql/GraphQLRequestError\";\nimport type { GraphQLRequestOptions, GraphQLResponse } from \"../graphql/types\";\nimport { getPluginErrorContext, PluginManager } from \"../plugin/PluginManager\";\nimport {\n\tcreateFetchTransport,\n\tfetchTransport,\n} from \"../transport/fetchTransport\";\nimport type { HttpMethod, QueryParams } from \"../types/common\";\nimport { mergeHeaders } from \"../utils/mergeHeaders\";\nimport { resolveUrl } from \"../utils/resolveUrl\";\nimport { sleep } from \"../utils/sleep\";\nimport type { ClientConfig } from \"./types\";\n\n/** Per-request options passed to {@link BaseHttpClient.request} and the convenience methods. */\nexport interface RequestOptions {\n\t/** HTTP method. Defaults to `\"GET\"`. */\n\tmethod?: HttpMethod;\n\t/**\n\t * Additional headers merged on top of `ClientConfig.defaultHeaders`.\n\t * These take precedence; `content-type: application/json` is always\n\t * present and is the lowest-priority default.\n\t */\n\theaders?: Record<string, string>;\n\t/** Request body. Serialised to JSON by {@link fetchTransport}. Ignored for GET and HEAD. */\n\tbody?: unknown;\n\t/**\n\t * Query string parameters appended to the URL. `undefined` values are\n\t * omitted. Numbers and booleans are coerced to strings. Array values are\n\t * emitted as repeated query parameters.\n\t */\n\tquery?: QueryParams;\n\t/** Optional caller-provided abort signal. Composes with `timeoutMs`. */\n\tsignal?: AbortSignal;\n\t/**\n\t * Per-request timeout override in milliseconds. Takes precedence over\n\t * `ClientConfig.timeoutMs`. Throws {@link TimeoutError} when exceeded.\n\t */\n\ttimeoutMs?: number;\n\t/**\n\t * Explicit cache key used by {@link createCachePlugin}. When omitted the\n\t * plugin derives a key from the method, URL, and query string.\n\t */\n\tcacheKey?: string;\n\t/**\n\t * Arbitrary string tags attached to the request context. Plugins may use\n\t * these for cache invalidation, metrics grouping, or filtering.\n\t */\n\ttags?: string[];\n}\n\nexport interface ApiResponse<T = unknown> {\n\tdata: T;\n\tresponse: Response;\n\trequest: RequestContext;\n\tmeta: Record<string, unknown>;\n}\n\nconst DEFAULT_RETRIABLE_STATUS_CODES = [429, 500, 502, 503, 504];\n\n/**\n * Core HTTP client. Manages the plugin lifecycle, retry loop, and transport\n * dispatch for all requests.\n *\n * Plugins are initialised lazily on the first call to {@link request} (or any\n * convenience method). Call {@link dispose} when the client is no longer\n * needed so plugins can release timers, connections, or cache handles.\n *\n * Extend this class to add domain-specific methods while keeping the plugin\n * and transport infrastructure intact.\n *\n * @example\n * ```ts\n * // Prefer createClient() in application code:\n * const client = createClient({ baseUrl: \"https://api.example.com/v1\" });\n *\n * // Or subclass for wrapper packages:\n * class MyApiClient extends BaseHttpClient {\n * getUser(id: string) { return this.get<User>(`/users/${id}`); }\n * }\n * ```\n */\nexport class BaseHttpClient {\n\tprotected readonly config: ClientConfig;\n\tprotected readonly pluginManager: PluginManager;\n\tprivate initialized = false;\n\tprivate initPromise: Promise<void> | undefined;\n\n\tconstructor(config: ClientConfig) {\n\t\tthis.config = config;\n\t\tthis.pluginManager = new PluginManager(config.logger);\n\t\tfor (const plugin of config.plugins ?? []) {\n\t\t\tthis.pluginManager.register(plugin);\n\t\t}\n\t}\n\n\t/** Initializes all plugins. Called lazily on first request. */\n\tasync init(): Promise<void> {\n\t\tif (this.initialized) return;\n\t\tthis.initPromise ??= this.pluginManager\n\t\t\t.setup(this)\n\t\t\t.then(() => {\n\t\t\t\tthis.initialized = true;\n\t\t\t})\n\t\t\t.catch((err) => {\n\t\t\t\tthis.initPromise = undefined;\n\t\t\t\tthrow err;\n\t\t\t});\n\t\tawait this.initPromise;\n\t}\n\n\t/** Disposes all plugins. Call when the client is no longer needed. */\n\tasync dispose(): Promise<void> {\n\t\tawait this.pluginManager.dispose();\n\t\tthis.initialized = false;\n\t\tthis.initPromise = undefined;\n\t}\n\n\t/**\n\t * Executes an HTTP request through the full plugin pipeline.\n\t *\n\t * Lifecycle per attempt:\n\t * 1. Build `RequestContext` with merged headers, query, and retry state.\n\t * 2. Run `beforeRequest` hooks (ascending priority). A plugin may set\n\t * `ctx.syntheticResponse` to skip the transport entirely (e.g. cache hit).\n\t * 3. Merge any `retry.*` meta written by {@link createRetryPlugin}.\n\t * 4. Call transport (skipped when `syntheticResponse` is set).\n\t * 5. Parse the response body (JSON or text).\n\t * 6. Run `afterResponse` hooks (descending priority).\n\t * 7. Retry on retriable status codes; throw on terminal failures.\n\t *\n\t * @param path - Path appended to `ClientConfig.baseUrl`. Should start with `/`.\n\t * @param options - Per-request overrides for method, headers, body, query, etc.\n\t * @returns The parsed response body cast to `T`.\n\t * @throws {@link ApiError} for non-2xx responses.\n\t * @throws {@link RateLimitError} for 429 responses.\n\t * @throws {@link TimeoutError} when `timeoutMs` is exceeded.\n\t */\n\tasync request<T = unknown>(\n\t\tpath: string,\n\t\toptions: RequestOptions = {},\n\t): Promise<T> {\n\t\tconst result = await this.requestWithResponse<T>(path, options);\n\t\treturn result.data;\n\t}\n\n\t/**\n\t * Executes a request and returns the parsed body plus the final response\n\t * context. Use this in wrappers that need response headers, status, or\n\t * plugin metadata while keeping the same error/retry behaviour as\n\t * {@link request}.\n\t */\n\tasync requestWithResponse<T = unknown>(\n\t\tpath: string,\n\t\toptions: RequestOptions = {},\n\t): Promise<ApiResponse<T>> {\n\t\tawait this.init();\n\n\t\tconst transport =\n\t\t\tthis.config.transport ??\n\t\t\t(this.config.fetch\n\t\t\t\t? createFetchTransport(this.config.fetch)\n\t\t\t\t: fetchTransport);\n\t\tconst retryCfg = this.config.retry;\n\t\t// These are `let` so createRetryPlugin can override them per-request via\n\t\t// ctx.meta after beforeRequest runs (see merge block below).\n\t\tlet maxAttempts = retryCfg?.maxAttempts ?? 1;\n\t\tlet baseDelay = retryCfg?.delayMs ?? 500;\n\t\tlet jitter = retryCfg?.jitter ?? true;\n\t\tlet retriableCodes =\n\t\t\tretryCfg?.retriableStatusCodes ?? DEFAULT_RETRIABLE_STATUS_CODES;\n\n\t\tlet lastError: unknown;\n\n\t\tfor (let attempt = 0; attempt < maxAttempts; attempt++) {\n\t\t\tconst baseCtx: RequestContext = {\n\t\t\t\turl: resolveUrl(this.config.baseUrl, path),\n\t\t\t\tmethod: options.method ?? \"GET\",\n\t\t\t\theaders: mergeHeaders(\n\t\t\t\t\t{ \"content-type\": \"application/json\" },\n\t\t\t\t\tthis.config.defaultHeaders,\n\t\t\t\t\toptions.headers,\n\t\t\t\t),\n\t\t\t\tbody: options.body,\n\t\t\t\tquery: options.query,\n\t\t\t\tsignal: options.signal,\n\t\t\t\tmeta: {},\n\t\t\t\tcacheKey: options.cacheKey,\n\t\t\t\ttags: options.tags,\n\t\t\t\tretryCount: maxAttempts - 1 - attempt,\n\t\t\t\tattempt,\n\t\t\t\ttimeoutMs: options.timeoutMs ?? this.config.timeoutMs,\n\t\t\t};\n\n\t\t\tlet ctx: RequestContext;\n\t\t\ttry {\n\t\t\t\tctx = await this.pluginManager.beforeRequest(baseCtx);\n\t\t\t} catch (err) {\n\t\t\t\tawait this.pluginManager.onError(\n\t\t\t\t\terr,\n\t\t\t\t\tgetPluginErrorContext(err) ?? baseCtx,\n\t\t\t\t);\n\t\t\t\tthrow err;\n\t\t\t}\n\n\t\t\t// Merge per-request retry overrides written by createRetryPlugin.\n\t\t\t// Only keys explicitly set by the plugin are present, so unset keys\n\t\t\t// leave the config-level defaults untouched.\n\t\t\t// Because the for-loop condition re-evaluates `attempt < maxAttempts`\n\t\t\t// on every iteration, updating maxAttempts here takes effect\n\t\t\t// immediately for all remaining attempts.\n\t\t\tif (ctx.meta[\"retry.maxAttempts\"] !== undefined)\n\t\t\t\tmaxAttempts = ctx.meta[\"retry.maxAttempts\"] as number;\n\t\t\tif (ctx.meta[\"retry.delayMs\"] !== undefined)\n\t\t\t\tbaseDelay = ctx.meta[\"retry.delayMs\"] as number;\n\t\t\tif (ctx.meta[\"retry.jitter\"] !== undefined)\n\t\t\t\tjitter = ctx.meta[\"retry.jitter\"] as boolean;\n\t\t\tif (ctx.meta[\"retry.retriableStatusCodes\"] !== undefined)\n\t\t\t\tretriableCodes = ctx.meta[\"retry.retriableStatusCodes\"] as number[];\n\n\t\t\tlet rawResponse: Response;\n\t\t\tif (ctx.syntheticResponse) {\n\t\t\t\t// A plugin (e.g. cache) pre-populated the response — skip the\n\t\t\t\t// network entirely.\n\t\t\t\trawResponse = ctx.syntheticResponse;\n\t\t\t} else {\n\t\t\t\ttry {\n\t\t\t\t\trawResponse = await transport.execute(ctx);\n\t\t\t\t} catch (err) {\n\t\t\t\t\tawait this.pluginManager.onError(err, ctx);\n\t\t\t\t\tlastError = err;\n\n\t\t\t\t\tif (attempt < maxAttempts - 1) {\n\t\t\t\t\t\tawait this.waitForRetry(attempt, baseDelay, jitter);\n\t\t\t\t\t\tcontinue;\n\t\t\t\t\t}\n\t\t\t\t\tthrow err;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tconst parsedBody = await parseBody(rawResponse);\n\n\t\t\tlet resCtx: ResponseContext = {\n\t\t\t\trequest: ctx,\n\t\t\t\tresponse: rawResponse,\n\t\t\t\tparsedBody,\n\t\t\t\tmeta: {},\n\t\t\t};\n\n\t\t\ttry {\n\t\t\t\tresCtx = await this.pluginManager.afterResponse(resCtx);\n\t\t\t} catch (err) {\n\t\t\t\tawait this.pluginManager.onError(err, ctx);\n\t\t\t\tthrow err;\n\t\t\t}\n\n\t\t\tif (!rawResponse.ok) {\n\t\t\t\tconst shouldRetry =\n\t\t\t\t\tretriableCodes.includes(rawResponse.status) &&\n\t\t\t\t\tattempt < maxAttempts - 1;\n\n\t\t\t\tif (shouldRetry) {\n\t\t\t\t\tif (rawResponse.status === 429) {\n\t\t\t\t\t\tconst wait = readRetryAfterMs(rawResponse);\n\t\t\t\t\t\tawait this.waitForRetry(attempt, wait ?? baseDelay, false);\n\t\t\t\t\t} else {\n\t\t\t\t\t\tawait this.waitForRetry(attempt, baseDelay, jitter);\n\t\t\t\t\t}\n\t\t\t\t\tlastError = normalizeHttpError(rawResponse, resCtx.parsedBody);\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\n\t\t\t\tconst err = normalizeHttpError(rawResponse, resCtx.parsedBody);\n\t\t\t\tawait this.pluginManager.onError(err, ctx);\n\t\t\t\tthrow err;\n\t\t\t}\n\n\t\t\treturn {\n\t\t\t\tdata: resCtx.parsedBody as T,\n\t\t\t\tresponse: resCtx.response,\n\t\t\t\trequest: resCtx.request,\n\t\t\t\tmeta: resCtx.meta,\n\t\t\t};\n\t\t}\n\n\t\tthrow lastError;\n\t}\n\n\t// ─── Convenience methods ────────────────────────────────────────────────────\n\t// Each method is a thin wrapper around request() that fixes the HTTP verb.\n\n\t/** Sends a GET request. The response body is not cached unless a cache plugin is registered. */\n\tget<T = unknown>(\n\t\tpath: string,\n\t\toptions?: Omit<RequestOptions, \"method\">,\n\t): Promise<T> {\n\t\treturn this.request<T>(path, { ...options, method: \"GET\" });\n\t}\n\n\tpost<T = unknown>(\n\t\tpath: string,\n\t\tbody?: unknown,\n\t\toptions?: Omit<RequestOptions, \"method\" | \"body\">,\n\t): Promise<T> {\n\t\treturn this.request<T>(path, { ...options, method: \"POST\", body });\n\t}\n\n\tput<T = unknown>(\n\t\tpath: string,\n\t\tbody?: unknown,\n\t\toptions?: Omit<RequestOptions, \"method\" | \"body\">,\n\t): Promise<T> {\n\t\treturn this.request<T>(path, { ...options, method: \"PUT\", body });\n\t}\n\n\tpatch<T = unknown>(\n\t\tpath: string,\n\t\tbody?: unknown,\n\t\toptions?: Omit<RequestOptions, \"method\" | \"body\">,\n\t): Promise<T> {\n\t\treturn this.request<T>(path, { ...options, method: \"PATCH\", body });\n\t}\n\n\tdelete<T = unknown>(\n\t\tpath: string,\n\t\toptions?: Omit<RequestOptions, \"method\">,\n\t): Promise<T> {\n\t\treturn this.request<T>(path, { ...options, method: \"DELETE\" });\n\t}\n\n\thead<T = unknown>(\n\t\tpath: string,\n\t\toptions?: Omit<RequestOptions, \"method\">,\n\t): Promise<T> {\n\t\treturn this.request<T>(path, { ...options, method: \"HEAD\" });\n\t}\n\n\toptions<T = unknown>(\n\t\tpath: string,\n\t\toptions?: Omit<RequestOptions, \"method\">,\n\t): Promise<T> {\n\t\treturn this.request<T>(path, { ...options, method: \"OPTIONS\" });\n\t}\n\n\t/**\n\t * Executes a GraphQL query or mutation against a single endpoint path.\n\t *\n\t * The request is a `POST` with `content-type: application/json` carrying\n\t * `{ query, variables?, operationName? }` as the body. It flows through\n\t * the full plugin lifecycle (beforeRequest → transport → afterResponse →\n\t * onError) and respects all retry configuration, exactly like REST calls.\n\t *\n\t * **Error handling:**\n\t * - HTTP-level failures (429, 500, timeout) throw the same error classes\n\t * as REST requests (`RateLimitError`, `ApiError`, `TimeoutError`).\n\t * - A successful HTTP 200 that contains a non-empty `errors` array throws\n\t * {@link GraphQLRequestError}, which extends `ApiError`.\n\t *\n\t * **Caching:**\n\t * The cache plugin skips `POST` requests by default. Pass an explicit\n\t * `cacheKey` in options to opt a specific operation into caching.\n\t *\n\t * @typeParam TData - Shape of the `data` field in the GraphQL response.\n\t * @typeParam TVariables - Shape of the `variables` object. Defaults to\n\t * `Record<string, unknown>`.\n\t * @param path - Endpoint path, e.g. `\"/graphql\"`. Appended to `baseUrl`.\n\t * @param options - Query document, variables, and optional per-request overrides.\n\t * @returns The `data` field from the GraphQL response envelope.\n\t * @throws {@link GraphQLRequestError} when `response.errors` is non-empty.\n\t * @throws {@link ApiError} / {@link RateLimitError} / {@link TimeoutError} on\n\t * HTTP-level failures.\n\t *\n\t * @example\n\t * ```ts\n\t * const data = await client.graphql<GetUserQuery, GetUserQueryVariables>(\n\t * \"/graphql\",\n\t * { query: GET_USER, variables: { id: \"123\" } },\n\t * );\n\t * ```\n\t */\n\tasync graphql<\n\t\tTData = unknown,\n\t\tTVariables extends Record<string, unknown> = Record<string, unknown>,\n\t>(path: string, options: GraphQLRequestOptions<TVariables>): Promise<TData> {\n\t\tconst {\n\t\t\tquery,\n\t\t\tvariables,\n\t\t\toperationName,\n\t\t\theaders,\n\t\t\ttimeoutMs,\n\t\t\tcacheKey,\n\t\t\ttags,\n\t\t} = options;\n\n\t\tconst envelope = await this.request<GraphQLResponse<TData>>(path, {\n\t\t\tmethod: \"POST\",\n\t\t\tbody: {\n\t\t\t\tquery,\n\t\t\t\t...(variables !== undefined && { variables }),\n\t\t\t\t...(operationName !== undefined && { operationName }),\n\t\t\t},\n\t\t\theaders,\n\t\t\ttimeoutMs,\n\t\t\tcacheKey,\n\t\t\ttags,\n\t\t});\n\n\t\t// Surface GraphQL application-layer errors as a typed exception.\n\t\t// We throw even when partial data is present — callers who need\n\t\t// partial results can catch GraphQLRequestError and read .partialData.\n\t\tif (envelope.errors && envelope.errors.length > 0) {\n\t\t\tthrow new GraphQLRequestError(envelope.errors, envelope.data);\n\t\t}\n\n\t\t// data may be undefined if the server returned an empty response —\n\t\t// safe to cast because TData defaults to unknown.\n\t\treturn envelope.data as TData;\n\t}\n\n\tprivate async waitForRetry(\n\t\tattempt: number,\n\t\tbaseDelay: number,\n\t\tuseJitter: boolean,\n\t): Promise<void> {\n\t\t// Exponential backoff: delay * 2^attempt\n\t\tconst exponential = baseDelay * 2 ** attempt;\n\t\tconst ms = useJitter\n\t\t\t? exponential * (0.5 + Math.random() * 0.5)\n\t\t\t: exponential;\n\t\tawait sleep(Math.round(ms));\n\t}\n}\n\n// ─── Helpers ──────────────────────────────────────────────────────────────────\n\nasync function parseBody(response: Response): Promise<unknown> {\n\tif (response.status === 204 || response.status === 205) return undefined;\n\tif (response.headers.get(\"content-length\") === \"0\") return undefined;\n\n\tconst text = await response.text();\n\tif (!text) return undefined;\n\n\tconst contentType = response.headers.get(\"content-type\") ?? \"\";\n\tif (contentType.includes(\"application/json\")) {\n\t\treturn JSON.parse(text);\n\t}\n\treturn text;\n}\n\nfunction normalizeHttpError(response: Response, body: unknown): ApiError {\n\tif (response.status === 429) {\n\t\treturn new RateLimitError(readRetryAfterMs(response), body);\n\t}\n\treturn new ApiError(\n\t\t`Request failed with status ${response.status}`,\n\t\tresponse.status,\n\t\tbody,\n\t);\n}\n\nfunction readRetryAfterMs(response: Response): number | undefined {\n\tconst raw = response.headers.get(\"retry-after\");\n\tif (!raw) return undefined;\n\n\tconst seconds = Number(raw);\n\tif (Number.isFinite(seconds)) return Math.max(0, seconds * 1_000);\n\n\tconst date = Date.parse(raw);\n\tif (!Number.isNaN(date)) return Math.max(0, date - Date.now());\n\n\treturn undefined;\n}\n","import { BaseHttpClient } from \"./BaseHttpClient\";\nimport type { ClientConfig } from \"./types\";\n\n/**\n * Factory function that creates a {@link BaseHttpClient} from the given\n * config. Prefer this over `new BaseHttpClient(config)` in application code\n * so that the concrete class stays an implementation detail.\n *\n * @example\n * ```ts\n * const client = createClient({\n * baseUrl: \"https://api.example.com/v1\",\n * defaultHeaders: { \"x-api-key\": \"secret\" },\n * retry: { maxAttempts: 3, delayMs: 300 },\n * plugins: [createLoggerPlugin(), createCachePlugin({ ttlMs: 60_000 })],\n * });\n * ```\n */\nexport function createClient(config: ClientConfig): BaseHttpClient {\n\treturn new BaseHttpClient(config);\n}\n","import type { ApiPlugin } from \"../../plugin/types\";\nimport type { MaybePromise } from \"../../types/common\";\nimport type { AuthPluginOptions } from \"./types\";\n\ntype TokenInput =\n\t| string\n\t| (() => MaybePromise<string | null | undefined>)\n\t| AuthPluginOptions;\n\n/**\n * Adds an auth token header before each request. The token can be static or\n * loaded asynchronously per request, which covers wrappers with refreshable\n * access tokens.\n */\nexport function createAuthPlugin(input: TokenInput): ApiPlugin {\n\tconst options = normalizeOptions(input);\n\tconst headerName = (options.headerName ?? \"authorization\").toLowerCase();\n\tconst scheme = options.scheme === undefined ? \"Bearer\" : options.scheme;\n\n\treturn {\n\t\tname: \"auth\",\n\t\tpriority: 2,\n\n\t\tasync beforeRequest(ctx) {\n\t\t\tconst token = await options.getToken();\n\t\t\tif (!token) return ctx;\n\n\t\t\treturn {\n\t\t\t\t...ctx,\n\t\t\t\theaders: {\n\t\t\t\t\t...ctx.headers,\n\t\t\t\t\t[headerName]: scheme ? `${scheme} ${token}` : token,\n\t\t\t\t},\n\t\t\t};\n\t\t},\n\t};\n}\n\nfunction normalizeOptions(input: TokenInput): AuthPluginOptions {\n\tif (typeof input === \"string\") {\n\t\treturn { getToken: () => input };\n\t}\n\tif (typeof input === \"function\") {\n\t\treturn { getToken: input };\n\t}\n\treturn input;\n}\n","import type { CacheStore } from \"./types\";\n\ninterface CacheEntry {\n\tvalue: unknown;\n\texpiresAt: number | null;\n}\n\nexport class MemoryStore implements CacheStore {\n\tprivate readonly store = new Map<string, CacheEntry>();\n\n\tget(key: string): unknown | undefined {\n\t\tconst entry = this.store.get(key);\n\t\tif (!entry) return undefined;\n\t\tif (entry.expiresAt !== null && Date.now() > entry.expiresAt) {\n\t\t\tthis.store.delete(key);\n\t\t\treturn undefined;\n\t\t}\n\t\treturn entry.value;\n\t}\n\n\tset(key: string, value: unknown, ttlMs?: number): void {\n\t\tthis.store.set(key, {\n\t\t\tvalue,\n\t\t\texpiresAt: ttlMs != null ? Date.now() + ttlMs : null,\n\t\t});\n\t}\n\n\tdelete(key: string): void {\n\t\tthis.store.delete(key);\n\t}\n\n\tclear(): void {\n\t\tthis.store.clear();\n\t}\n}\n","import type { RequestContext } from \"../../context/RequestContext\";\nimport { MemoryStore } from \"./memoryStore\";\nimport type { CachePlugin, CachePluginOptions } from \"./types\";\n\nconst DEFAULT_CACHEABLE_METHODS = [\"GET\"] as const;\nconst CACHE_HIT_META_KEY = \"cache.hit\";\n\nexport function createCachePlugin(\n\toptions: CachePluginOptions = {},\n): CachePlugin {\n\tconst store = options.store ?? new MemoryStore();\n\tconst ttlMs = options.ttlMs;\n\tconst methods: string[] = options.methods ?? [...DEFAULT_CACHEABLE_METHODS];\n\tconst generateKey = options.generateKey ?? defaultCacheKey;\n\n\t// tag → Set<cacheKey>: populated during afterResponse, used by invalidateByTag.\n\tconst tagIndex = new Map<string, Set<string>>();\n\n\treturn {\n\t\tname: \"cache\",\n\t\tpriority: 20,\n\n\t\tasync beforeRequest(ctx) {\n\t\t\tif (!methods.includes(ctx.method)) return ctx;\n\n\t\t\tconst key = ctx.cacheKey ?? generateKey(ctx);\n\t\t\tconst cached = await store.get(key);\n\n\t\t\tif (cached !== undefined) {\n\t\t\t\t// Build a synthetic Response so the rest of the pipeline\n\t\t\t\t// (afterResponse, status checks) sees a uniform shape.\n\t\t\t\tconst syntheticResponse = new Response(JSON.stringify(cached), {\n\t\t\t\t\tstatus: 200,\n\t\t\t\t\theaders: { \"content-type\": \"application/json\" },\n\t\t\t\t});\n\n\t\t\t\t// Setting syntheticResponse tells BaseHttpClient to skip the\n\t\t\t\t// transport entirely and use this response directly.\n\t\t\t\treturn {\n\t\t\t\t\t...ctx,\n\t\t\t\t\tmeta: {\n\t\t\t\t\t\t...ctx.meta,\n\t\t\t\t\t\t[CACHE_HIT_META_KEY]: { key, data: cached },\n\t\t\t\t\t},\n\t\t\t\t\tsyntheticResponse,\n\t\t\t\t};\n\t\t\t}\n\n\t\t\treturn {\n\t\t\t\t...ctx,\n\t\t\t\tmeta: { ...ctx.meta, \"cache.key\": key },\n\t\t\t};\n\t\t},\n\n\t\tasync afterResponse(ctx) {\n\t\t\tconst hit = ctx.request.meta[CACHE_HIT_META_KEY] as\n\t\t\t\t| { key: string; data: unknown }\n\t\t\t\t| undefined;\n\n\t\t\tif (hit) {\n\t\t\t\treturn {\n\t\t\t\t\t...ctx,\n\t\t\t\t\tparsedBody: hit.data,\n\t\t\t\t\tmeta: { ...ctx.meta, \"cache.served\": true },\n\t\t\t\t};\n\t\t\t}\n\n\t\t\tconst key = ctx.request.meta[\"cache.key\"] as string | undefined;\n\t\t\tif (key && methods.includes(ctx.request.method) && ctx.response.ok) {\n\t\t\t\tawait store.set(key, ctx.parsedBody, ttlMs);\n\t\t\t\tctx.meta[\"cache.stored\"] = true;\n\n\t\t\t\t// Record tag → key associations for invalidateByTag.\n\t\t\t\tfor (const tag of ctx.request.tags ?? []) {\n\t\t\t\t\tif (!tagIndex.has(tag)) tagIndex.set(tag, new Set());\n\t\t\t\t\ttagIndex.get(tag)?.add(key);\n\t\t\t\t}\n\t\t\t}\n\n\t\t\treturn ctx;\n\t\t},\n\n\t\tasync invalidate(key: string): Promise<void> {\n\t\t\tawait store.delete(key);\n\t\t\t// Clean up any tag index entries pointing to this key.\n\t\t\tfor (const keys of tagIndex.values()) {\n\t\t\t\tkeys.delete(key);\n\t\t\t}\n\t\t},\n\n\t\tasync invalidateByTag(tag: string): Promise<void> {\n\t\t\tconst keys = tagIndex.get(tag);\n\t\t\tif (!keys || keys.size === 0) return;\n\t\t\tfor (const key of keys) {\n\t\t\t\tawait store.delete(key);\n\t\t\t\t// Remove this key from all other tag index entries too.\n\t\t\t\tfor (const otherKeys of tagIndex.values()) {\n\t\t\t\t\totherKeys.delete(key);\n\t\t\t\t}\n\t\t\t}\n\t\t\ttagIndex.delete(tag);\n\t\t},\n\t};\n}\n\nfunction defaultCacheKey(ctx: RequestContext): string {\n\tconst queryStr = ctx.query\n\t\t? new URLSearchParams(\n\t\t\t\tObject.fromEntries(\n\t\t\t\t\tObject.entries(ctx.query)\n\t\t\t\t\t\t.filter(([, v]) => v !== undefined)\n\t\t\t\t\t\t.map(([k, v]) => [k, String(v)]),\n\t\t\t\t),\n\t\t\t).toString()\n\t\t: \"\";\n\treturn `${ctx.method}:${ctx.url}${queryStr ? `?${queryStr}` : \"\"}`;\n}\n","import type { ApiPlugin } from \"../../plugin/types\";\nimport type { LoggerPluginOptions } from \"./types\";\n\n/**\n * Creates a plugin that logs request start, response status, and errors.\n *\n * Log lines are prefixed with `[api-core]` and include the HTTP method, URL,\n * attempt number (on `beforeRequest`), and status code (on `afterResponse`).\n *\n * Priority `10` means it runs _after_ auth or header-mutation plugins\n * (priority < 10) so the logged URL and headers reflect the final request,\n * but _before_ the cache plugin (priority `20`) so cache hits are still\n * visible in the log.\n *\n * @example\n * ```ts\n * createClient({\n * baseUrl: \"https://api.example.com\",\n * plugins: [createLoggerPlugin({ logRequest: true, logResponse: true })],\n * });\n * ```\n */\nexport function createLoggerPlugin(\n\toptions: LoggerPluginOptions = {},\n): ApiPlugin {\n\tconst {\n\t\tlogRequest = true,\n\t\tlogResponse = true,\n\t\tlogError = true,\n\t\tlogger = console,\n\t} = options;\n\n\treturn {\n\t\tname: \"logger\",\n\t\tpriority: 10,\n\n\t\tbeforeRequest(ctx) {\n\t\t\tif (logRequest) {\n\t\t\t\tlogger.info(`[api-core] --> ${ctx.method} ${ctx.url}`, {\n\t\t\t\t\tattempt: ctx.attempt,\n\t\t\t\t\tbody: ctx.body,\n\t\t\t\t});\n\t\t\t}\n\t\t\treturn ctx;\n\t\t},\n\n\t\tafterResponse(ctx) {\n\t\t\tif (logResponse) {\n\t\t\t\tlogger.info(\n\t\t\t\t\t`[api-core] <-- ${ctx.response.status} ${ctx.request.method} ${ctx.request.url}`,\n\t\t\t\t);\n\t\t\t}\n\t\t\treturn ctx;\n\t\t},\n\n\t\tonError(error, ctx) {\n\t\t\tif (logError) {\n\t\t\t\tlogger.error(`[api-core] ERR ${ctx.method} ${ctx.url}`, error);\n\t\t\t}\n\t\t},\n\t};\n}\n","import type { RequestContext } from \"../../context/RequestContext\";\nimport type { ApiPlugin } from \"../../plugin/types\";\nimport type { RateLimitPluginOptions } from \"./types\";\n\nconst RELEASE_META_KEY = \"rateLimit.release\";\n\ninterface QueueItem {\n\tresolve: (release: () => void) => void;\n}\n\n/**\n * Throttles request starts before they reach the transport. Supports\n * concurrency, minimum spacing, and fixed-window request budgets.\n */\nexport function createRateLimitPlugin(\n\toptions: RateLimitPluginOptions = {},\n): ApiPlugin {\n\tconst maxConcurrent = options.maxConcurrent ?? Number.POSITIVE_INFINITY;\n\tconst minTimeMs = options.minTimeMs ?? 0;\n\tconst maxRequestsPerInterval = options.maxRequestsPerInterval;\n\tconst intervalMs = options.intervalMs;\n\n\tif (maxConcurrent <= 0) {\n\t\tthrow new Error(\"maxConcurrent must be greater than 0\");\n\t}\n\tif (minTimeMs < 0) {\n\t\tthrow new Error(\"minTimeMs must be greater than or equal to 0\");\n\t}\n\tif (\n\t\t(maxRequestsPerInterval !== undefined || intervalMs !== undefined) &&\n\t\t(!maxRequestsPerInterval ||\n\t\t\tmaxRequestsPerInterval <= 0 ||\n\t\t\t!intervalMs ||\n\t\t\tintervalMs <= 0)\n\t) {\n\t\tthrow new Error(\n\t\t\t\"maxRequestsPerInterval and intervalMs must both be greater than 0\",\n\t\t);\n\t}\n\n\tconst queue: QueueItem[] = [];\n\tconst starts: number[] = [];\n\tlet active = 0;\n\tlet lastStartAt = 0;\n\tlet timer: ReturnType<typeof setTimeout> | undefined;\n\n\tconst processQueue = () => {\n\t\tif (timer) {\n\t\t\tclearTimeout(timer);\n\t\t\ttimer = undefined;\n\t\t}\n\n\t\twhile (queue.length > 0) {\n\t\t\tconst now = Date.now();\n\t\t\tpruneStarts(now);\n\n\t\t\tif (active >= maxConcurrent) return;\n\n\t\t\tconst waitMs = getWaitMs(now);\n\t\t\tif (waitMs > 0) {\n\t\t\t\ttimer = setTimeout(processQueue, waitMs);\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tconst item = queue.shift();\n\t\t\tif (!item) return;\n\n\t\t\tactive++;\n\t\t\tlastStartAt = now;\n\t\t\tstarts.push(now);\n\n\t\t\tlet released = false;\n\t\t\titem.resolve(() => {\n\t\t\t\tif (released) return;\n\t\t\t\treleased = true;\n\t\t\t\tactive--;\n\t\t\t\tprocessQueue();\n\t\t\t});\n\t\t}\n\t};\n\n\tconst acquire = () =>\n\t\tnew Promise<() => void>((resolve) => {\n\t\t\tqueue.push({ resolve });\n\t\t\tprocessQueue();\n\t\t});\n\n\tconst release = (ctx: RequestContext) => {\n\t\tconst releaseFn = ctx.meta[RELEASE_META_KEY] as (() => void) | undefined;\n\t\tif (!releaseFn) return;\n\t\tdelete ctx.meta[RELEASE_META_KEY];\n\t\treleaseFn();\n\t};\n\n\tconst pruneStarts = (now: number) => {\n\t\tif (!intervalMs) return;\n\t\twhile (starts.length > 0 && now - (starts[0] ?? 0) >= intervalMs) {\n\t\t\tstarts.shift();\n\t\t}\n\t};\n\n\tconst getWaitMs = (now: number): number => {\n\t\tconst spacingWait = Math.max(0, lastStartAt + minTimeMs - now);\n\t\tif (!maxRequestsPerInterval || !intervalMs) return spacingWait;\n\n\t\tif (starts.length < maxRequestsPerInterval) return spacingWait;\n\n\t\tconst oldest = starts[0] ?? now;\n\t\tconst intervalWait = Math.max(0, oldest + intervalMs - now);\n\t\treturn Math.max(spacingWait, intervalWait);\n\t};\n\n\treturn {\n\t\tname: \"rate-limit\",\n\t\tpriority: 1,\n\n\t\tasync beforeRequest(ctx) {\n\t\t\tconst releaseFn = await acquire();\n\t\t\treturn {\n\t\t\t\t...ctx,\n\t\t\t\tmeta: { ...ctx.meta, [RELEASE_META_KEY]: releaseFn },\n\t\t\t};\n\t\t},\n\n\t\tafterResponse(ctx) {\n\t\t\trelease(ctx.request);\n\t\t\treturn ctx;\n\t\t},\n\n\t\tonError(_error, ctx) {\n\t\t\trelease(ctx);\n\t\t},\n\t};\n}\n","import type { ApiPlugin } from \"../../plugin/types\";\nimport type { RetryPluginOptions } from \"./types\";\n\n/**\n * Writes retry configuration into request context meta so the\n * BaseHttpClient retry loop can read it. Use this when you need\n * per-request retry overrides rather than global ClientConfig.retry.\n */\nexport function createRetryPlugin(options: RetryPluginOptions = {}): ApiPlugin {\n\treturn {\n\t\tname: \"retry\",\n\t\tpriority: 5,\n\n\t\tbeforeRequest(ctx) {\n\t\t\t// Only write keys for values the caller explicitly provided.\n\t\t\t// Unset options fall through to ClientConfig.retry defaults inside\n\t\t\t// BaseHttpClient, so the plugin does not need to supply fallbacks.\n\t\t\tif (options.maxAttempts !== undefined)\n\t\t\t\tctx.meta[\"retry.maxAttempts\"] = options.maxAttempts;\n\t\t\tif (options.delayMs !== undefined)\n\t\t\t\tctx.meta[\"retry.delayMs\"] = options.delayMs;\n\t\t\tif (options.jitter !== undefined)\n\t\t\t\tctx.meta[\"retry.jitter\"] = options.jitter;\n\t\t\tif (options.retriableStatusCodes !== undefined)\n\t\t\t\tctx.meta[\"retry.retriableStatusCodes\"] = options.retriableStatusCodes;\n\t\t\treturn ctx;\n\t\t},\n\t};\n}\n","import type { ApiPlugin } from \"../../plugin/types\";\nimport type { TimeoutPluginOptions } from \"./types\";\n\n/**\n * Sets `ctx.timeoutMs` on every request so all requests made by this client\n * abort after the configured duration. The actual abort and\n * {@link TimeoutError} are handled by {@link fetchTransport}.\n *\n * Priority `1` ensures the timeout is stamped before any other plugin (e.g.\n * logger, cache) runs — plugins that read `ctx.timeoutMs` will always see it.\n * Use a `beforeRequest` hook with a lower priority to override per-request.\n *\n * Prefer `ClientConfig.timeoutMs` for a static global timeout. Use this\n * plugin when you need to set or change the timeout through the plugin\n * pipeline (e.g. from environment config loaded asynchronously in `setup`).\n *\n * @example\n * ```ts\n * createClient({\n * baseUrl: \"https://api.example.com\",\n * plugins: [createTimeoutPlugin({ timeoutMs: 5_000 })],\n * });\n * ```\n */\nexport function createTimeoutPlugin(options: TimeoutPluginOptions): ApiPlugin {\n\treturn {\n\t\tname: \"timeout\",\n\t\tpriority: 1,\n\n\t\tbeforeRequest(ctx) {\n\t\t\treturn { ...ctx, timeoutMs: options.timeoutMs };\n\t\t},\n\t};\n}\n"],"mappings":"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"}
package/docs/README.md ADDED
@@ -0,0 +1,42 @@
1
+ # Documentation
2
+
3
+ `@api-wrappers/api-core` is a shared HTTP runtime for TypeScript API wrapper
4
+ packages. These docs explain how to install the package, create clients, send
5
+ REST and GraphQL requests, add plugins, handle errors, and test wrappers without
6
+ network calls.
7
+
8
+ ## Start Here
9
+
10
+ - [Getting Started](getting-started.md): install the package and make your first
11
+ REST request.
12
+ - [REST Requests](guides/rest-requests.md): request methods, query strings,
13
+ headers, bodies, abort signals, and response metadata.
14
+ - [GraphQL](guides/graphql.md): typed GraphQL queries and GraphQL error
15
+ handling.
16
+
17
+ ## Guides
18
+
19
+ - [Plugins](guides/plugins.md): how the plugin lifecycle works and how to write
20
+ custom plugins.
21
+ - [Built-In Plugins](guides/built-in-plugins.md): auth, retry, timeout,
22
+ rate-limit, cache, and logger plugins.
23
+ - [Error Handling](guides/error-handling.md): `ApiError`, `RateLimitError`,
24
+ `TimeoutError`, and `GraphQLRequestError`.
25
+ - [Testing](guides/testing.md): custom transports and deterministic test
26
+ clients.
27
+
28
+ ## Reference
29
+
30
+ - [Client API](reference/client.md): `BaseHttpClient`, `createClient`,
31
+ `request`, `requestWithResponse`, and convenience methods.
32
+ - [Configuration](reference/configuration.md): `ClientConfig`, `RequestOptions`,
33
+ retry config, query params, and transport config.
34
+ - [Exports](reference/exports.md): complete public export list.
35
+
36
+ ## Recommended Reading Order
37
+
38
+ 1. [Getting Started](getting-started.md)
39
+ 2. [REST Requests](guides/rest-requests.md)
40
+ 3. [Built-In Plugins](guides/built-in-plugins.md)
41
+ 4. [Error Handling](guides/error-handling.md)
42
+ 5. [Testing](guides/testing.md)
@@ -0,0 +1,93 @@
1
+ # Getting Started
2
+
3
+ This guide shows the shortest path from installation to a typed API request.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ bun add @api-wrappers/api-core
9
+ ```
10
+
11
+ ```bash
12
+ npm install @api-wrappers/api-core
13
+ ```
14
+
15
+ ## Create A Client
16
+
17
+ ```ts
18
+ import {
19
+ createAuthPlugin,
20
+ createClient,
21
+ createRetryPlugin,
22
+ createTimeoutPlugin,
23
+ } from "@api-wrappers/api-core";
24
+
25
+ const client = createClient({
26
+ baseUrl: "https://api.example.com/v1",
27
+ defaultHeaders: {
28
+ accept: "application/json",
29
+ },
30
+ plugins: [
31
+ createAuthPlugin(() => process.env.API_TOKEN),
32
+ createRetryPlugin({ maxAttempts: 3, delayMs: 300 }),
33
+ createTimeoutPlugin({ timeoutMs: 30_000 }),
34
+ ],
35
+ });
36
+ ```
37
+
38
+ ## Make A Request
39
+
40
+ ```ts
41
+ interface User {
42
+ id: string;
43
+ name: string;
44
+ }
45
+
46
+ const user = await client.get<User>("/users/123");
47
+ ```
48
+
49
+ The returned value is the parsed response body. JSON responses are parsed into
50
+ objects; non-JSON responses are returned as text; empty `204` or `205` responses
51
+ return `undefined`.
52
+
53
+ ## Add Query Parameters
54
+
55
+ ```ts
56
+ const results = await client.get<SearchResults>("/search", {
57
+ query: {
58
+ q: "alien",
59
+ page: 2,
60
+ with_genres: [878, 12],
61
+ },
62
+ });
63
+ ```
64
+
65
+ Array values are encoded as repeated query parameters:
66
+
67
+ ```txt
68
+ ?q=alien&page=2&with_genres=878&with_genres=12
69
+ ```
70
+
71
+ ## Send A Body
72
+
73
+ Plain objects and arrays are JSON encoded:
74
+
75
+ ```ts
76
+ await client.post("/users", {
77
+ name: "Ada Lovelace",
78
+ });
79
+ ```
80
+
81
+ Strings are sent as-is:
82
+
83
+ ```ts
84
+ await client.post("/games", "fields name,rating; limit 10;", {
85
+ headers: { "content-type": "text/plain" },
86
+ });
87
+ ```
88
+
89
+ ## Next Steps
90
+
91
+ - [REST Requests](guides/rest-requests.md)
92
+ - [Built-In Plugins](guides/built-in-plugins.md)
93
+ - [Error Handling](guides/error-handling.md)
@@ -0,0 +1,119 @@
1
+ # Built-In Plugins
2
+
3
+ The package includes plugins for the behavior most wrappers need by default.
4
+
5
+ ## Auth
6
+
7
+ Adds an auth header before each request.
8
+
9
+ ```ts
10
+ createAuthPlugin("static-token");
11
+ createAuthPlugin(() => authManager.getAccessToken());
12
+ createAuthPlugin({
13
+ getToken: () => apiKey,
14
+ headerName: "x-api-key",
15
+ scheme: null,
16
+ });
17
+ ```
18
+
19
+ Default output:
20
+
21
+ ```txt
22
+ authorization: Bearer <token>
23
+ ```
24
+
25
+ ## Retry
26
+
27
+ Overrides retry settings per request by writing retry metadata into the request
28
+ context.
29
+
30
+ ```ts
31
+ createRetryPlugin({
32
+ maxAttempts: 3,
33
+ delayMs: 300,
34
+ jitter: true,
35
+ retriableStatusCodes: [429, 500, 502, 503, 504],
36
+ });
37
+ ```
38
+
39
+ `429` responses respect `retry-after`. Numeric header values are treated as
40
+ seconds, and HTTP-date values are supported.
41
+
42
+ ## Timeout
43
+
44
+ Sets `timeoutMs` on each request context.
45
+
46
+ ```ts
47
+ createTimeoutPlugin({ timeoutMs: 30_000 });
48
+ ```
49
+
50
+ Timeouts are enforced by the default fetch transport and throw `TimeoutError`.
51
+
52
+ ## Rate Limit
53
+
54
+ Controls request start timing before transport execution.
55
+
56
+ ```ts
57
+ createRateLimitPlugin({
58
+ maxConcurrent: 4,
59
+ minTimeMs: 250,
60
+ });
61
+ ```
62
+
63
+ ```ts
64
+ createRateLimitPlugin({
65
+ maxRequestsPerInterval: 30,
66
+ intervalMs: 60_000,
67
+ });
68
+ ```
69
+
70
+ Slots are released on successful responses, HTTP errors, transport errors, and
71
+ plugin errors.
72
+
73
+ ## Cache
74
+
75
+ Caches successful responses for configured methods.
76
+
77
+ ```ts
78
+ import { createCachePlugin, MemoryStore } from "@api-wrappers/api-core";
79
+
80
+ const cache = createCachePlugin({
81
+ store: new MemoryStore(),
82
+ ttlMs: 60_000,
83
+ methods: ["GET"],
84
+ });
85
+
86
+ const client = createClient({
87
+ baseUrl: "https://api.example.com",
88
+ plugins: [cache],
89
+ });
90
+ ```
91
+
92
+ Invalidate a specific key:
93
+
94
+ ```ts
95
+ await cache.invalidate("GET:https://api.example.com/users/1");
96
+ ```
97
+
98
+ Invalidate by tag:
99
+
100
+ ```ts
101
+ await client.get("/users/1", { tags: ["user"] });
102
+ await client.get("/users/2", { tags: ["user"] });
103
+ await cache.invalidateByTag("user");
104
+ ```
105
+
106
+ ## Logger
107
+
108
+ Logs request starts, responses, and errors.
109
+
110
+ ```ts
111
+ createLoggerPlugin({
112
+ logRequest: true,
113
+ logResponse: true,
114
+ logError: true,
115
+ logger: console,
116
+ });
117
+ ```
118
+
119
+ Pass a structured logger or no-op logger to control output.
@@ -0,0 +1,72 @@
1
+ # Error Handling
2
+
3
+ The client throws typed errors for HTTP failures, rate limits, timeouts, and
4
+ GraphQL application errors.
5
+
6
+ ## Error Types
7
+
8
+ | Error | When it is thrown | Useful fields |
9
+ | --- | --- | --- |
10
+ | `ApiError` | Non-2xx HTTP response after retries are exhausted | `status`, `responseBody`, `cause` |
11
+ | `RateLimitError` | HTTP 429 response | `retryAfterMs`, `responseBody` |
12
+ | `TimeoutError` | Request timeout | `cause` |
13
+ | `GraphQLRequestError` | GraphQL response contains `errors` | `graphqlErrors`, `partialData` |
14
+
15
+ ## Handling Errors
16
+
17
+ ```ts
18
+ import {
19
+ ApiError,
20
+ GraphQLRequestError,
21
+ RateLimitError,
22
+ TimeoutError,
23
+ } from "@api-wrappers/api-core";
24
+
25
+ try {
26
+ await client.get("/resource");
27
+ } catch (error) {
28
+ if (error instanceof RateLimitError) {
29
+ console.log("retry after", error.retryAfterMs);
30
+ } else if (error instanceof TimeoutError) {
31
+ console.log("request timed out");
32
+ } else if (error instanceof GraphQLRequestError) {
33
+ console.log(error.graphqlErrors);
34
+ } else if (error instanceof ApiError) {
35
+ console.log(error.status, error.responseBody);
36
+ }
37
+ }
38
+ ```
39
+
40
+ ## HTTP Errors
41
+
42
+ For non-2xx responses, the client parses the response body first and stores it
43
+ on `error.responseBody`.
44
+
45
+ ```ts
46
+ try {
47
+ await client.get("/missing");
48
+ } catch (error) {
49
+ if (error instanceof ApiError) {
50
+ console.log(error.status);
51
+ console.log(error.responseBody);
52
+ }
53
+ }
54
+ ```
55
+
56
+ ## Rate Limits
57
+
58
+ `RateLimitError.retryAfterMs` is set when the server provides `retry-after`.
59
+
60
+ ```ts
61
+ if (error instanceof RateLimitError && error.retryAfterMs) {
62
+ await sleep(error.retryAfterMs);
63
+ }
64
+ ```
65
+
66
+ ## Plugin Errors
67
+
68
+ When `beforeRequest` or `afterResponse` throws, the error is wrapped as a
69
+ `PluginError` with the plugin name in the message. `onError` handlers still run.
70
+
71
+ Errors thrown inside `onError` handlers are logged through the configured logger
72
+ and do not stop other `onError` handlers.
@@ -0,0 +1,81 @@
1
+ # GraphQL
2
+
3
+ `client.graphql()` sends a GraphQL operation through the same request pipeline
4
+ as REST calls. Auth, retry, timeout, rate-limit, cache, logging, and custom
5
+ plugins all still apply.
6
+
7
+ ## Basic Query
8
+
9
+ ```ts
10
+ const data = await client.graphql("/", {
11
+ query: `
12
+ query {
13
+ Viewer { id name }
14
+ }
15
+ `,
16
+ });
17
+ ```
18
+
19
+ ## Typed Query And Variables
20
+
21
+ ```ts
22
+ interface GetMediaQuery {
23
+ Media: {
24
+ id: number;
25
+ title: { romaji: string };
26
+ };
27
+ }
28
+
29
+ interface GetMediaVariables {
30
+ id: number;
31
+ }
32
+
33
+ const data = await client.graphql<GetMediaQuery, GetMediaVariables>("/", {
34
+ query: `
35
+ query GetMedia($id: Int) {
36
+ Media(id: $id) {
37
+ id
38
+ title { romaji }
39
+ }
40
+ }
41
+ `,
42
+ variables: { id: 1 },
43
+ operationName: "GetMedia",
44
+ });
45
+
46
+ data.Media.title.romaji;
47
+ ```
48
+
49
+ ## GraphQL Errors
50
+
51
+ GraphQL responses with an `errors` array throw `GraphQLRequestError`, even when
52
+ the response also contains partial data.
53
+
54
+ ```ts
55
+ import { GraphQLRequestError } from "@api-wrappers/api-core";
56
+
57
+ try {
58
+ await client.graphql("/graphql", { query });
59
+ } catch (error) {
60
+ if (error instanceof GraphQLRequestError) {
61
+ console.log(error.graphqlErrors);
62
+ console.log(error.partialData);
63
+ }
64
+ }
65
+ ```
66
+
67
+ HTTP-level failures still throw `ApiError`, `RateLimitError`, or `TimeoutError`.
68
+
69
+ ## Caching GraphQL Requests
70
+
71
+ GraphQL requests are POST requests, so the cache plugin does not cache them by
72
+ default. Use an explicit `cacheKey` when the operation is safe to cache.
73
+
74
+ ```ts
75
+ await client.graphql<GetMediaQuery, GetMediaVariables>("/", {
76
+ query: GET_MEDIA,
77
+ variables: { id },
78
+ cacheKey: `gql:GetMedia:${id}`,
79
+ tags: ["media"],
80
+ });
81
+ ```
@@ -0,0 +1,122 @@
1
+ # Plugins
2
+
3
+ Plugins let wrappers add behavior around every request without duplicating HTTP
4
+ plumbing in endpoint classes.
5
+
6
+ ## Lifecycle
7
+
8
+ | Hook | When it runs | Order |
9
+ | --- | --- | --- |
10
+ | `setup(client)` | Once before the first request | Lower priority first |
11
+ | `beforeRequest(ctx)` | Before transport execution | Lower priority first |
12
+ | `afterResponse(ctx)` | After response parsing | Higher priority first |
13
+ | `onError(error, ctx)` | When the request pipeline throws | Registered order |
14
+ | `dispose()` | When `client.dispose()` is called | Higher priority first |
15
+
16
+ Returning `undefined` keeps the current context. Return a new object to mutate
17
+ request or response state.
18
+
19
+ ## Request Context
20
+
21
+ ```ts
22
+ interface RequestContext {
23
+ url: string;
24
+ method: HttpMethod;
25
+ headers: Record<string, string>;
26
+ body?: unknown;
27
+ query?: QueryParams;
28
+ signal?: AbortSignal;
29
+ meta: Record<string, unknown>;
30
+ cacheKey?: string;
31
+ tags?: string[];
32
+ retryCount: number;
33
+ attempt: number;
34
+ timeoutMs?: number;
35
+ syntheticResponse?: Response;
36
+ }
37
+ ```
38
+
39
+ Use `meta` for plugin state and namespace keys, for example
40
+ `"metrics.startedAt"` or `"auth.token"`.
41
+
42
+ ## Response Context
43
+
44
+ ```ts
45
+ interface ResponseContext {
46
+ request: RequestContext;
47
+ response: Response;
48
+ parsedBody?: unknown;
49
+ meta: Record<string, unknown>;
50
+ }
51
+ ```
52
+
53
+ ## Example Plugin
54
+
55
+ ```ts
56
+ import type { ApiPlugin } from "@api-wrappers/api-core";
57
+
58
+ export function createClientIdPlugin(clientId: string): ApiPlugin {
59
+ return {
60
+ name: "client-id",
61
+ priority: 2,
62
+ beforeRequest(ctx) {
63
+ return {
64
+ ...ctx,
65
+ headers: {
66
+ ...ctx.headers,
67
+ "client-id": clientId,
68
+ },
69
+ };
70
+ },
71
+ };
72
+ }
73
+ ```
74
+
75
+ ## Measuring Duration
76
+
77
+ ```ts
78
+ export function createTimingPlugin(): ApiPlugin {
79
+ return {
80
+ name: "timing",
81
+ priority: 10,
82
+ beforeRequest(ctx) {
83
+ return {
84
+ ...ctx,
85
+ meta: { ...ctx.meta, "timing.startedAt": Date.now() },
86
+ };
87
+ },
88
+ afterResponse(ctx) {
89
+ const startedAt = ctx.request.meta["timing.startedAt"] as number;
90
+ return {
91
+ ...ctx,
92
+ meta: {
93
+ ...ctx.meta,
94
+ "timing.durationMs": Date.now() - startedAt,
95
+ },
96
+ };
97
+ },
98
+ };
99
+ }
100
+ ```
101
+
102
+ ## Short-Circuiting The Network
103
+
104
+ Set `syntheticResponse` in `beforeRequest` to skip the transport. This is how
105
+ cache-style plugins return stored responses.
106
+
107
+ ```ts
108
+ beforeRequest(ctx) {
109
+ const cached = cache.get(ctx.url);
110
+ if (!cached) return ctx;
111
+
112
+ return {
113
+ ...ctx,
114
+ syntheticResponse: new Response(JSON.stringify(cached), {
115
+ status: 200,
116
+ headers: { "content-type": "application/json" },
117
+ }),
118
+ };
119
+ }
120
+ ```
121
+
122
+ `afterResponse` still runs, so downstream plugins see a normal response shape.