@api-wrappers/api-core 0.0.3 → 1.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.
package/CHANGELOG.md ADDED
@@ -0,0 +1,12 @@
1
+ # Changelog
2
+
3
+ ## 1.0.1 - 2026-05-14
4
+
5
+ - Fixed cache keys so array and nullish query params match the actual transport URL serialization.
6
+ - Fixed `afterResponse` status handling so plugins can replace the final response.
7
+ - Fixed GraphQL requests so callers cannot accidentally override the JSON content type.
8
+ - Added structured JSON content-type support for `application/*+json` responses and request bodies.
9
+ - Added retry attempt normalization and accurate `retryCount` values when retry plugins override `maxAttempts`.
10
+ - Added native `HeadersInit` support for default, per-request, and GraphQL headers.
11
+ - Added error guard helpers for ergonomic TypeScript error handling.
12
+ - Added a one-command `verify` script, package metadata cleanup, package export for `package.json`, and an MIT license file.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 TDanks2000
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -20,6 +20,8 @@ their internal HTTP layer consistent and testable.
20
20
  - Deterministic plugin lifecycle with `setup`, `beforeRequest`,
21
21
  `afterResponse`, `onError`, and `dispose`.
22
22
  - Built-in auth, cache, logger, rate-limit, retry, and timeout plugins.
23
+ - Native `HeadersInit` support for default, per-request, and GraphQL headers.
24
+ - Type guards for ergonomic `unknown` error handling in TypeScript.
23
25
  - Fetch transport with JSON bodies, raw string bodies, abort signals, and
24
26
  timeout handling.
25
27
  - Response parsing for JSON, text, and binary payloads.
@@ -360,22 +362,22 @@ contract.
360
362
 
361
363
  ```ts
362
364
  import {
363
- ApiError,
364
- GraphQLRequestError,
365
- RateLimitError,
366
- TimeoutError,
365
+ isApiError,
366
+ isGraphQLRequestError,
367
+ isRateLimitError,
368
+ isTimeoutError,
367
369
  } from "@api-wrappers/api-core";
368
370
 
369
371
  try {
370
372
  await client.get("/resource");
371
373
  } catch (error) {
372
- if (error instanceof RateLimitError) {
374
+ if (isRateLimitError(error)) {
373
375
  console.log(error.retryAfterMs);
374
- } else if (error instanceof TimeoutError) {
376
+ } else if (isTimeoutError(error)) {
375
377
  console.log("timed out");
376
- } else if (error instanceof GraphQLRequestError) {
378
+ } else if (isGraphQLRequestError(error)) {
377
379
  console.log(error.graphqlErrors);
378
- } else if (error instanceof ApiError) {
380
+ } else if (isApiError(error)) {
379
381
  console.log(error.status, error.responseBody);
380
382
  }
381
383
  }
@@ -424,15 +426,23 @@ import {
424
426
  createTimeoutPlugin,
425
427
  gql,
426
428
  GraphQLRequestError,
429
+ isApiCoreError,
430
+ isApiError,
431
+ isGraphQLRequestError,
432
+ isRateLimitError,
433
+ isTimeoutError,
427
434
  MemoryStore,
428
435
  RateLimitError,
429
436
  TimeoutError,
430
437
  } from "@api-wrappers/api-core";
431
438
 
432
439
  import type {
440
+ ApiCoreError,
433
441
  ApiPlugin,
434
442
  ApiResponse,
435
443
  ClientConfig,
444
+ FetchLike,
445
+ HeaderInput,
436
446
  QueryParams,
437
447
  RequestContext,
438
448
  RequestOptions,
@@ -465,10 +475,12 @@ The package publishes:
465
475
 
466
476
  ```bash
467
477
  bun install
478
+ bun run verify
468
479
  bun run check
480
+ bun run typecheck
469
481
  bun test
470
482
  bun run build
471
- npm pack --dry-run
483
+ bun run pack:dry-run
472
484
  ```
473
485
 
474
486
  `dist` is generated by `tsdown`. The published package includes `dist`, `docs`,
package/dist/index.cjs CHANGED
@@ -209,6 +209,12 @@ function buildUrl(base, query) {
209
209
  return `${base}${base.includes("?") ? base.endsWith("?") || base.endsWith("&") ? "" : "&" : "?"}${qs}`;
210
210
  }
211
211
  //#endregion
212
+ //#region src/utils/isJsonContentType.ts
213
+ function isJsonContentType(contentType) {
214
+ const mediaType = contentType?.split(";", 1)[0]?.trim().toLowerCase();
215
+ return mediaType === "application/json" || mediaType?.endsWith("+json") === true;
216
+ }
217
+ //#endregion
212
218
  //#region src/utils/isPlainObject.ts
213
219
  function isPlainObject(value) {
214
220
  if (typeof value !== "object" || value === null) return false;
@@ -226,9 +232,9 @@ const defaultFetch = (input, init) => {
226
232
  *
227
233
  * ```ts
228
234
  * import nodeFetch from "node-fetch";
229
- * createClient({ fetch: nodeFetch as typeof globalThis.fetch });
235
+ * createClient({ fetch: nodeFetch as FetchLike });
230
236
  * // — or set it directly on the transport:
231
- * const transport = createFetchTransport(nodeFetch as typeof globalThis.fetch);
237
+ * const transport = createFetchTransport(nodeFetch as FetchLike);
232
238
  * ```
233
239
  */
234
240
  function createFetchTransport(fetchFn = defaultFetch) {
@@ -280,8 +286,7 @@ function createFetchTransport(fetchFn = defaultFetch) {
280
286
  const fetchTransport = createFetchTransport();
281
287
  function serializeRequestBody(body, headers) {
282
288
  if (isBodyInit(body)) return body;
283
- const contentType = headers["content-type"] ?? "";
284
- if (isPlainObject(body) || Array.isArray(body) || contentType.includes("json")) return JSON.stringify(body);
289
+ if (isPlainObject(body) || Array.isArray(body) || isJsonContentType(headers["content-type"])) return JSON.stringify(body);
285
290
  return String(body);
286
291
  }
287
292
  function isBodyInit(body) {
@@ -304,10 +309,29 @@ function mergeHeaders(...sources) {
304
309
  const result = {};
305
310
  for (const source of sources) {
306
311
  if (!source) continue;
307
- for (const [key, value] of Object.entries(source)) result[key.toLowerCase()] = value;
312
+ if (isHeaders(source)) {
313
+ source.forEach((value, key) => {
314
+ result[key.toLowerCase()] = value;
315
+ });
316
+ continue;
317
+ }
318
+ if (Array.isArray(source)) {
319
+ for (const [key, value] of source) result[key.toLowerCase()] = value;
320
+ continue;
321
+ }
322
+ for (const [key, value] of Object.entries(source)) result[key.toLowerCase()] = String(value);
308
323
  }
309
324
  return result;
310
325
  }
326
+ function isHeaders(source) {
327
+ return typeof Headers !== "undefined" && source instanceof Headers;
328
+ }
329
+ //#endregion
330
+ //#region src/utils/normalizeRetryMaxAttempts.ts
331
+ function normalizeRetryMaxAttempts(value) {
332
+ if (value === void 0 || !Number.isFinite(value)) return 1;
333
+ return Math.max(1, Math.floor(value));
334
+ }
311
335
  //#endregion
312
336
  //#region src/utils/resolveUrl.ts
313
337
  /**
@@ -416,7 +440,7 @@ var BaseHttpClient = class {
416
440
  await this.init();
417
441
  const transport = this.config.transport ?? (this.config.fetch ? createFetchTransport(this.config.fetch) : fetchTransport);
418
442
  const retryCfg = this.config.retry;
419
- let maxAttempts = retryCfg?.maxAttempts ?? 1;
443
+ let maxAttempts = normalizeRetryMaxAttempts(retryCfg?.maxAttempts);
420
444
  let baseDelay = retryCfg?.delayMs ?? 500;
421
445
  let jitter = retryCfg?.jitter ?? true;
422
446
  let retriableCodes = retryCfg?.retriableStatusCodes ?? DEFAULT_RETRIABLE_STATUS_CODES;
@@ -443,10 +467,15 @@ var BaseHttpClient = class {
443
467
  await this.pluginManager.onError(err, getPluginErrorContext(err) ?? baseCtx);
444
468
  throw err;
445
469
  }
446
- if (ctx.meta["retry.maxAttempts"] !== void 0) maxAttempts = ctx.meta["retry.maxAttempts"];
470
+ if (ctx.meta["retry.maxAttempts"] !== void 0) maxAttempts = normalizeRetryMaxAttempts(ctx.meta["retry.maxAttempts"]);
447
471
  if (ctx.meta["retry.delayMs"] !== void 0) baseDelay = ctx.meta["retry.delayMs"];
448
472
  if (ctx.meta["retry.jitter"] !== void 0) jitter = ctx.meta["retry.jitter"];
449
473
  if (ctx.meta["retry.retriableStatusCodes"] !== void 0) retriableCodes = ctx.meta["retry.retriableStatusCodes"];
474
+ const retryCount = maxAttempts - 1 - attempt;
475
+ if (ctx.retryCount !== retryCount) ctx = {
476
+ ...ctx,
477
+ retryCount
478
+ };
450
479
  let rawResponse;
451
480
  if (ctx.syntheticResponse) rawResponse = ctx.syntheticResponse;
452
481
  else try {
@@ -473,16 +502,17 @@ var BaseHttpClient = class {
473
502
  await this.pluginManager.onError(err, ctx);
474
503
  throw err;
475
504
  }
476
- if (!rawResponse.ok) {
477
- if (retriableCodes.includes(rawResponse.status) && attempt < maxAttempts - 1) {
478
- if (rawResponse.status === 429) {
479
- const wait = readRetryAfterMs(rawResponse);
505
+ const finalResponse = resCtx.response;
506
+ if (!finalResponse.ok) {
507
+ if (retriableCodes.includes(finalResponse.status) && attempt < maxAttempts - 1) {
508
+ if (finalResponse.status === 429) {
509
+ const wait = readRetryAfterMs(finalResponse);
480
510
  await this.waitForRetry(attempt, wait ?? baseDelay, false);
481
511
  } else await this.waitForRetry(attempt, baseDelay, jitter);
482
- lastError = normalizeHttpError(rawResponse, resCtx.parsedBody);
512
+ lastError = normalizeHttpError(finalResponse, resCtx.parsedBody);
483
513
  continue;
484
514
  }
485
- const err = normalizeHttpError(rawResponse, resCtx.parsedBody);
515
+ const err = normalizeHttpError(finalResponse, resCtx.parsedBody);
486
516
  await this.pluginManager.onError(err, ctx);
487
517
  throw err;
488
518
  }
@@ -586,7 +616,7 @@ var BaseHttpClient = class {
586
616
  ...variables !== void 0 && { variables },
587
617
  ...operationName !== void 0 && { operationName }
588
618
  },
589
- headers,
619
+ headers: mergeHeaders(headers, { "content-type": "application/json" }),
590
620
  signal,
591
621
  timeoutMs,
592
622
  cacheKey,
@@ -610,7 +640,7 @@ async function parseBody(response, responseType = "auto") {
610
640
  if (responseType === "text") return text;
611
641
  if (!text) return void 0;
612
642
  if (responseType === "json") return JSON.parse(text);
613
- if ((response.headers.get("content-type") ?? "").includes("application/json")) return JSON.parse(text);
643
+ if (isJsonContentType(response.headers.get("content-type"))) return JSON.parse(text);
614
644
  return text;
615
645
  }
616
646
  function normalizeHttpError(response, body) {
@@ -646,6 +676,23 @@ function createClient(config) {
646
676
  return new BaseHttpClient(config);
647
677
  }
648
678
  //#endregion
679
+ //#region src/errors/guards.ts
680
+ function isApiError(error) {
681
+ return error instanceof ApiError;
682
+ }
683
+ function isRateLimitError(error) {
684
+ return error instanceof RateLimitError;
685
+ }
686
+ function isTimeoutError(error) {
687
+ return error instanceof TimeoutError;
688
+ }
689
+ function isGraphQLRequestError(error) {
690
+ return error instanceof GraphQLRequestError;
691
+ }
692
+ function isApiCoreError(error) {
693
+ return isApiError(error) || isTimeoutError(error);
694
+ }
695
+ //#endregion
649
696
  //#region src/plugins/auth/authPlugin.ts
650
697
  /**
651
698
  * Adds an auth token header before each request. The token can be static or
@@ -723,7 +770,7 @@ function createCachePlugin(options = {}) {
723
770
  const key = ctx.cacheKey ?? generateKey(ctx);
724
771
  const cached = await store.get(key);
725
772
  if (cached !== void 0) {
726
- const syntheticResponse = new Response(JSON.stringify(cached), {
773
+ const syntheticResponse = new Response(serializeCachedBody(cached), {
727
774
  status: 200,
728
775
  headers: { "content-type": "application/json" }
729
776
  });
@@ -784,8 +831,14 @@ function createCachePlugin(options = {}) {
784
831
  };
785
832
  }
786
833
  function defaultCacheKey(ctx) {
787
- const queryStr = ctx.query ? new URLSearchParams(Object.fromEntries(Object.entries(ctx.query).filter(([, v]) => v !== void 0).map(([k, v]) => [k, String(v)]))).toString() : "";
788
- return `${ctx.method}:${ctx.url}${queryStr ? `?${queryStr}` : ""}`;
834
+ return `${ctx.method}:${buildUrl(ctx.url, ctx.query)}`;
835
+ }
836
+ function serializeCachedBody(value) {
837
+ try {
838
+ return JSON.stringify(value) ?? null;
839
+ } catch {
840
+ return null;
841
+ }
789
842
  }
790
843
  //#endregion
791
844
  //#region src/plugins/logger/loggerPlugin.ts
@@ -933,11 +986,20 @@ function createRetryPlugin(options = {}) {
933
986
  name: "retry",
934
987
  priority: 5,
935
988
  beforeRequest(ctx) {
936
- if (options.maxAttempts !== void 0) ctx.meta["retry.maxAttempts"] = options.maxAttempts;
937
- if (options.delayMs !== void 0) ctx.meta["retry.delayMs"] = options.delayMs;
938
- if (options.jitter !== void 0) ctx.meta["retry.jitter"] = options.jitter;
939
- if (options.retriableStatusCodes !== void 0) ctx.meta["retry.retriableStatusCodes"] = options.retriableStatusCodes;
940
- return ctx;
989
+ const meta = { ...ctx.meta };
990
+ let retryCount = ctx.retryCount;
991
+ if (options.maxAttempts !== void 0) {
992
+ meta["retry.maxAttempts"] = options.maxAttempts;
993
+ retryCount = normalizeRetryMaxAttempts(options.maxAttempts) - 1 - ctx.attempt;
994
+ }
995
+ if (options.delayMs !== void 0) meta["retry.delayMs"] = options.delayMs;
996
+ if (options.jitter !== void 0) meta["retry.jitter"] = options.jitter;
997
+ if (options.retriableStatusCodes !== void 0) meta["retry.retriableStatusCodes"] = options.retriableStatusCodes;
998
+ return {
999
+ ...ctx,
1000
+ meta,
1001
+ retryCount
1002
+ };
941
1003
  }
942
1004
  };
943
1005
  }
@@ -1055,7 +1117,12 @@ exports.createRetryPlugin = createRetryPlugin;
1055
1117
  exports.createTimeoutPlugin = createTimeoutPlugin;
1056
1118
  exports.fetchTransport = fetchTransport;
1057
1119
  exports.gql = gql;
1120
+ exports.isApiCoreError = isApiCoreError;
1121
+ exports.isApiError = isApiError;
1122
+ exports.isGraphQLRequestError = isGraphQLRequestError;
1058
1123
  exports.isPlainObject = isPlainObject;
1124
+ exports.isRateLimitError = isRateLimitError;
1125
+ exports.isTimeoutError = isTimeoutError;
1059
1126
  exports.mergeHeaders = mergeHeaders;
1060
1127
  exports.resolveUrl = resolveUrl;
1061
1128
  exports.sleep = sleep;