aspi 2.0.1 → 2.2.0

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/README.md CHANGED
@@ -3,13 +3,14 @@
3
3
  A tiny, type‑safe wrapper around the native **fetch** API that gives you a clean, monadic interface for HTTP requests.
4
4
  It ships with **zero runtime dependencies**, a **tiny bundle size**, and full **TypeScript** support out of the box.
5
5
 
6
- > **Why use Aspi?**
7
- > • End‑to‑end TypeScript typings (request + response)
8
- > No extra weight – only a thin wrapper around `fetch`
9
- > Chain‑of‑responsibility middleware support via `use`
10
- > • Resultbased error handling (values as errors)
11
- > • Builtin retry, header helpers, query‑string handling, and schema validation (Zod, Arktype, Valibot)
12
- > Flexible error mapping with `error` and convenience shortcuts
6
+ **Why use Aspi?**
7
+
8
+ - End‑to‑end TypeScript typings (request + response)
9
+ - No extra weight only a thin wrapper around `fetch`
10
+ - Chainof‑responsibility middleware support via `use`
11
+ - Resultbased error handling (values as errors)
12
+ - Built‑in retry, header helpers, query‑string handling, and schema validation (Zod, Arktype, Valibot)
13
+ - Flexible error mapping with `error` and convenience shortcuts
13
14
 
14
15
  ---
15
16
 
@@ -66,6 +67,67 @@ getTodo(1);
66
67
 
67
68
  ---
68
69
 
70
+ ## Why Aspi?
71
+
72
+ Most real‑world codebases end up with one or more of these issues:
73
+
74
+ 1. **Inconsistent error handling**
75
+ - Some utilities throw raw `Error`/`AxiosError`.
76
+ - Others return `{ ok: false, error }` or `null` or a custom union.
77
+ - Callers don’t know whether to use `try/catch`, check `ok`, or both.
78
+
79
+ 2. **Retry logic duplicated everywhere**
80
+ - Each service rolls its own `while (attempt <= retries)` loop.
81
+ - Status codes, backoff strategies, and retry limits slowly diverge over time.
82
+ - There is no single place to see “how do we retry HTTP calls in this app?”.
83
+
84
+ 3. **Validation pushed far from the network boundary**
85
+ - Request payloads are sometimes validated, sometimes not.
86
+ - Response validation happens deep in the business logic (if at all).
87
+ - JSON parse errors leak as raw `SyntaxError`, not structured errors.
88
+
89
+ 4. **Configuration scattered across factories and interceptors**
90
+ - Base URL helpers, auth decorators, error mappers, retry plugins, and logging interceptors all live in different files.
91
+ - Global state / interceptors can make it hard to tell what a given request will actually do.
92
+
93
+ 5. **Type systems are bolted on, not designed in**
94
+ - Generic HTTP clients often expose `any` for responses.
95
+ - Error flows are not encoded in the type system, forcing manual guards and casting.
96
+
97
+ ## How Aspi fixes them
98
+
99
+ Aspi’s design centers around three things:
100
+
101
+ 1. **Mode‑driven responses**
102
+
103
+ You decide at call‑site how you want to consume responses:
104
+ - `withResult()` → `json/text/blob` return a `Result.Result<Ok, ErrorUnion>`.
105
+ - `throwable()` → `json/text/blob` return `AspiPlainResponse` and throw on failure.
106
+ - Default → `json/text/blob` return `[ok, err]` tuples.
107
+
108
+ All error variants are **tagged** so they can be safely narrowed by `error.tag`.
109
+
110
+ 2. **Centralized, configurable retry layer**
111
+
112
+ Retry behavior is described declaratively:
113
+ - `retries`: max attempts.
114
+ - `retryDelay`: number or function `(attempt, maxAttempts, request, response) => delayMs`.
115
+ - `retryOn`: list of HTTP status codes that should trigger a retry.
116
+ - `retryWhile`: predicate `(request, response) => boolean` for custom retry conditions.
117
+ - `onRetry`: hook invoked after each retry attempt.
118
+
119
+ This configuration can be applied globally (`Aspi.setRetry`) and overridden per request (`Request.setRetry`).
120
+
121
+ 3. **Validation at the transport boundary**
122
+
123
+ Using a `StandardSchemaV1` interface, Aspi integrates with schema libraries (e.g. Zod, Valibot) to:
124
+ - Validate request bodies with `bodySchema` + `bodyJson` **before** the network call.
125
+ - Validate responses with `schema()` + `json()` **after** JSON parsing.
126
+
127
+ These failures appear as tagged `parseError` values with structured issue lists, not random runtime exceptions.
128
+
129
+ ---
130
+
69
131
  ## Using the `Result` monad
70
132
 
71
133
  If you prefer a single `Result` value instead of a tuple, call **`.withResult()`** before a body‑parser method.
@@ -475,6 +537,220 @@ class Request<
475
537
 
476
538
  ---
477
539
 
540
+ ## Result utilities
541
+
542
+ `Result<T, E>` is a small tagged-union helper used throughout Aspi to represent success or failure without throwing.
543
+
544
+ ```ts
545
+ type Ok<T> = { __tag: 'ok'; value: T };
546
+ type Err<E> = { __tag: 'err'; error: E };
547
+ type Result<T, E> = Ok<T> | Err<E>;
548
+ ```
549
+
550
+ Creating Results
551
+
552
+ ```ts
553
+ import * as Result from './result';
554
+
555
+ const success = Result.ok(42);
556
+ const failure = Result.err('not found');
557
+ ```
558
+
559
+ Checking and Extracting
560
+
561
+ ```ts
562
+ if (Result.isOk(success)) {
563
+ console.log(success.value); // 42
564
+ }
565
+
566
+ if (Result.isErr(failure)) {
567
+ console.error(failure.error); // "not found"
568
+ }
569
+
570
+ const valueOrNull = Result.getOrNull(success); // 42 | null
571
+ const errorOrNull = Result.getErrorOrNull(failure); // "not found" | null
572
+
573
+ const valueOrFallback = Result.getOrElse(failure, 0); // 0
574
+
575
+ // Throwing
576
+ const mustHaveValue = Result.getOrThrow(success); // 42
577
+ // Result.getOrThrow(failure) throws "not found"
578
+ ```
579
+
580
+ Transforming
581
+
582
+ ```ts
583
+ // map value
584
+ const doubled = Result.map(success, (n) => n * 2); // ok(84)
585
+
586
+ // map error
587
+ const upperError = Result.mapErr(failure, (e) => e.toUpperCase()); // err("NOT FOUND")
588
+
589
+ // pattern matching
590
+ const message = Result.match(success, {
591
+ onOk: (n) => `Got ${n}`,
592
+ onErr: (e) => `Error: ${e}`,
593
+ });
594
+ // "Got 42"
595
+ ```
596
+
597
+ Tagged error helpers
598
+ When your error type is a union with a tag field, you can use helpers to handle specific variants:
599
+
600
+ ```ts
601
+ type HttpError =
602
+ | { tag: 'BAD_REQUEST'; details?: string }
603
+ | { tag: 'UNAUTHORIZED' }
604
+ | { tag: 'NOT_FOUND' };
605
+
606
+ const result: Result<number, HttpError> = Result.err({
607
+ tag: 'NOT_FOUND',
608
+ });
609
+
610
+ // Handle a specific tag
611
+ Result.catchError(result, 'NOT_FOUND', (e) => {
612
+ console.log('Missing resource');
613
+ });
614
+
615
+ // Handle multiple tags
616
+ Result.catchErrors(result, {
617
+ BAD_REQUEST: (e) => console.log('Invalid input'),
618
+ UNAUTHORIZED: () => console.log('Please log in'),
619
+ });
620
+ ```
621
+
622
+ Pipe Utility
623
+
624
+ ```ts
625
+ import { pipe } from './result';
626
+
627
+ const label = pipe(
628
+ 12345,
629
+ (cents) => cents / 100,
630
+ (amount) => amount.toFixed(2),
631
+ (str) => `$${str}`,
632
+ );
633
+ // "$123.45"
634
+ ```
635
+
636
+ ---
637
+
638
+ ## Experimental capabilities
639
+
640
+ > **Experimental:** This API is still evolving. Names and behavior may change in minor versions.
641
+
642
+ Capabilities are small plugins that wrap the low‑level fetch call for each request. They let you implement cross‑cutting behavior (logging, retries, token refresh, tracing, etc.) without changing Aspi core.
643
+
644
+ ```ts
645
+ import type { Capability } from './interceptor';
646
+ import { Aspi } from './aspi';
647
+
648
+ // Capability signature
649
+ const myCapability: Capability = ({ request }) => ({
650
+ async run(runner) {
651
+ // Called before fetch
652
+ console.log('→', request.path);
653
+
654
+ const res = await runner(); // performs fetch(url, requestInit)
655
+
656
+ // Called after fetch
657
+ console.log('←', res.status, res.statusText);
658
+
659
+ return res;
660
+ },
661
+ });
662
+ ```
663
+
664
+ Registering capabilities
665
+ Capabilities are attached at the Aspi client level and apply to all requests created from that instance:
666
+
667
+ ```ts
668
+ const api = new Aspi({ baseUrl: 'https://api.example.com' }).useCapability(
669
+ myCapability,
670
+ );
671
+
672
+ const user = await api.get('/users/1').throwable().json<User>();
673
+ ```
674
+
675
+ You can register multiple capabilities; they execute in the order they were added, each wrapping the next:
676
+
677
+ ```ts
678
+ const api = new Aspi({ baseUrl: 'https://api.example.com' })
679
+ .useCapability(loggingCapability)
680
+ .useCapability(tracingCapability)
681
+ .useCapability(refreshTokenCapability);
682
+ ```
683
+
684
+ Example: token refresh (simplified)
685
+
686
+ ```ts
687
+ import type { Capability } from './interceptor';
688
+ import { Aspi } from './aspi';
689
+ import * as Result from './result';
690
+
691
+ let tokens: { accessToken: string | null; refreshToken: string | null } = {
692
+ accessToken: null,
693
+ refreshToken: null,
694
+ };
695
+
696
+ async function refreshTokenRequest(refreshToken: string) {
697
+ const res = await new Aspi({ baseUrl: 'https://auth.example.com' })
698
+ .post('/refresh')
699
+ .bodyJson({ refreshToken })
700
+ .withResult()
701
+ .json<{ accessToken: string; refreshToken: string }>();
702
+
703
+ await Result.match(res, {
704
+ onOk: ({ data }) => {
705
+ tokens = data;
706
+ },
707
+ onErr: (err) => {
708
+ tokens = { accessToken: null, refreshToken: null };
709
+ throw err;
710
+ },
711
+ });
712
+ }
713
+
714
+ export const refreshTokenCapability: Capability = ({ request }) => {
715
+ let isRefreshing = false;
716
+
717
+ return {
718
+ async run(runner) {
719
+ // First try
720
+ const first = await runner();
721
+
722
+ if (first.status !== 401) {
723
+ return first;
724
+ }
725
+
726
+ // No refresh token → just propagate 401
727
+ if (!tokens.refreshToken || isRefreshing) {
728
+ return first;
729
+ }
730
+
731
+ isRefreshing = true;
732
+ try {
733
+ await refreshTokenRequest(tokens.refreshToken);
734
+ } finally {
735
+ isRefreshing = false;
736
+ }
737
+
738
+ if (!tokens.accessToken) {
739
+ return first;
740
+ }
741
+
742
+ // Inject new Authorization header and retry once
743
+ request.requestInit.headers = {
744
+ ...request.requestInit.headers,
745
+ Authorization: `Bearer ${tokens.accessToken}`,
746
+ };
747
+
748
+ return runner();
749
+ },
750
+ };
751
+ };
752
+ ```
753
+
478
754
  ## License
479
755
 
480
756
  MIT © Aspi contributors
package/dist/index.cjs CHANGED
@@ -20,7 +20,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
20
20
  // src/index.ts
21
21
  var index_exports = {};
22
22
  __export(index_exports, {
23
- Aspi: () => Aspi2,
23
+ Aspi: () => Aspi,
24
24
  AspiError: () => AspiError,
25
25
  CustomError: () => CustomError,
26
26
  Request: () => Request,
@@ -315,17 +315,19 @@ var Request = class {
315
315
  #shouldBeResult = false;
316
316
  #bodySchemaIssues = [];
317
317
  #throwOnError = false;
318
- constructor(method, path, requestOptions) {
318
+ #capabilities = [];
319
+ constructor(method, path, requestOptions, capabilities = []) {
319
320
  this.#path = path;
320
321
  this.#middlewares = requestOptions.middlewares || [];
321
322
  this.#localRequestInit = {
322
323
  ...requestOptions.requestConfig,
323
324
  method
324
325
  };
325
- this.#retryConfig = requestOptions.retryConfig;
326
- this.#customErrorCbs = requestOptions.errorCbs || {};
326
+ this.#retryConfig = { ...requestOptions?.retryConfig || {} };
327
+ this.#customErrorCbs = { ...requestOptions?.errorCbs || {} };
327
328
  this.#throwOnError = requestOptions.throwOnError || false;
328
329
  this.#shouldBeResult = requestOptions.shouldBeResult || false;
330
+ this.#capabilities = [...capabilities];
329
331
  }
330
332
  /**
331
333
  * Sets the base URL for the request.
@@ -680,7 +682,29 @@ var Request = class {
680
682
  * request.setQueryParams(qp);
681
683
  */
682
684
  setQueryParams(params) {
683
- this.#queryParams = new URLSearchParams(params);
685
+ let qp;
686
+ if (params instanceof URLSearchParams) {
687
+ qp = new URLSearchParams(params);
688
+ } else if (typeof params === "string") {
689
+ qp = new URLSearchParams(params);
690
+ } else if (Array.isArray(params)) {
691
+ qp = new URLSearchParams();
692
+ for (const entry of params) {
693
+ if (Array.isArray(entry) && entry.length === 2) {
694
+ qp.append(String(entry[0]), String(entry[1]));
695
+ }
696
+ }
697
+ } else if (typeof params === "object" && params !== null) {
698
+ qp = new URLSearchParams();
699
+ for (const [key, value] of Object.entries(
700
+ params
701
+ )) {
702
+ qp.append(key, String(value));
703
+ }
704
+ } else {
705
+ qp = new URLSearchParams();
706
+ }
707
+ this.#queryParams = qp;
684
708
  return this;
685
709
  }
686
710
  /**
@@ -921,11 +945,41 @@ var Request = class {
921
945
  return this.#mapResponse(output);
922
946
  }
923
947
  #url() {
948
+ if (this.#path.startsWith("http://") || this.#path.startsWith("https://")) {
949
+ const absolute = new URL(this.#path);
950
+ if (this.#queryParams) {
951
+ for (const [k, v] of this.#queryParams.entries()) {
952
+ absolute.searchParams.append(k, v);
953
+ }
954
+ }
955
+ return absolute.toString();
956
+ }
924
957
  const passedBaseUrl = typeof this.#localRequestInit.baseUrl === "string" ? this.#localRequestInit.baseUrl : this.#localRequestInit.baseUrl.toString();
925
- const baseUrl = passedBaseUrl.replace(/\/+$/, "") ?? "";
926
- const path = this.#path.replace(/^\/+/, "/");
927
- const queryString = this.#queryParams ? `?${this.#queryParams.toString()}` : "";
928
- const url = [baseUrl, path, queryString].filter(Boolean).join("");
958
+ const base = passedBaseUrl.replace(/\/+$/, "");
959
+ const [rawPathAndQuery, fragment] = this.#path.split("#", 2);
960
+ const [rawPath, existingQuery] = rawPathAndQuery.split("?", 2);
961
+ let path = rawPath || "";
962
+ path = path.replace(/^\/+/, "");
963
+ path = path.replace(/\/{2,}/g, "/");
964
+ path = path.replace(/\/+$/, "");
965
+ if (path) {
966
+ path = "/" + path.replace(/^\/+/, "");
967
+ }
968
+ const qs = new URLSearchParams(existingQuery ?? "");
969
+ if (this.#queryParams) {
970
+ for (const [k, v] of this.#queryParams.entries()) {
971
+ qs.append(k, v);
972
+ }
973
+ }
974
+ const queryString = qs.toString();
975
+ let url = base + path;
976
+ if (queryString) {
977
+ url += `?${queryString}`;
978
+ }
979
+ if (fragment) {
980
+ url += `#${fragment}`;
981
+ }
982
+ url = url.replace(/\/+$/, "");
929
983
  return url;
930
984
  }
931
985
  /**
@@ -1024,7 +1078,15 @@ var Request = class {
1024
1078
  let responseData = null;
1025
1079
  while (attempts <= retries) {
1026
1080
  try {
1027
- response = await fetch(url, requestInit);
1081
+ if (this.#capabilities.length > 0) {
1082
+ for (const capability of this.#capabilities) {
1083
+ response = await capability({ request }).run(
1084
+ () => fetch(url, requestInit)
1085
+ );
1086
+ }
1087
+ } else {
1088
+ response = await fetch(url, requestInit);
1089
+ }
1028
1090
  responseData = await responseParser(response);
1029
1091
  if (responseData instanceof Error) {
1030
1092
  return err(responseData);
@@ -1217,8 +1279,9 @@ var Request = class {
1217
1279
  return {
1218
1280
  response,
1219
1281
  status: response.status,
1220
- statusText: getHttpErrorStatus(response.status),
1221
- responseData
1282
+ statusLabel: getHttpErrorStatus(response.status),
1283
+ responseData,
1284
+ statusText: response.statusText
1222
1285
  };
1223
1286
  }
1224
1287
  /**
@@ -1283,16 +1346,62 @@ var Request = class {
1283
1346
  const cfg = this.#sanitisedRetryConfig();
1284
1347
  return { ...cfg };
1285
1348
  }
1349
+ /**
1350
+ * Registers a capability for this request.
1351
+ *
1352
+ * A capability is a small wrapper around the underlying `fetch` call that can
1353
+ * intercept, inspect, or modify the request/response lifecycle. Each registered
1354
+ * capability receives the constructed {@link AspiRequest} and can wrap the
1355
+ * execution of the network call via its `run` method.
1356
+ *
1357
+ * Capabilities are applied in the order they are registered. For each HTTP
1358
+ * call, the `Request` will:
1359
+ *
1360
+ * 1. Build an {@link AspiRequest} from the current configuration.
1361
+ * 2. Create a runner: `() => fetch(url, requestInit)`.
1362
+ * 3. Pass that runner through each capability in sequence, allowing them to:
1363
+ * - log or trace requests,
1364
+ * - implement retry/refresh logic,
1365
+ * - short‑circuit with synthetic responses, etc.
1366
+ *
1367
+ * @param capability - The capability factory to register. It will be invoked
1368
+ * for every execution of this request with the current {@link AspiRequest}.
1369
+ *
1370
+ * @returns This {@link Request} instance, enabling fluent chaining.
1371
+ *
1372
+ * @example
1373
+ * ```ts
1374
+ * const api = new Aspi({ baseUrl: 'https://api.example.com' });
1375
+ *
1376
+ * const apiWithLogging = api.useCapability(({ request }) => ({
1377
+ * async run(runner) {
1378
+ * console.log('→', request.path, request.requestInit);
1379
+ * const res = await runner();
1380
+ * console.log('←', res.status, res.statusText);
1381
+ * return res;
1382
+ * },
1383
+ * }));
1384
+ *
1385
+ * const result = await apiWithLogging
1386
+ * .get('/users')
1387
+ * .withResult()
1388
+ * .json();
1389
+ * ```
1390
+ */
1391
+ useCapability(capability) {
1392
+ return this.#capabilities.push(capability);
1393
+ }
1286
1394
  };
1287
1395
 
1288
1396
  // src/aspi.ts
1289
- var Aspi2 = class {
1397
+ var Aspi = class {
1290
1398
  #globalRequestInit;
1291
1399
  #middlewares = [];
1292
1400
  #retryConfig;
1293
1401
  #customErrorCbs = {};
1294
1402
  #throwOnError = false;
1295
1403
  #shouldBeResult = false;
1404
+ #capabilities = [];
1296
1405
  constructor(config) {
1297
1406
  const { retryConfig, ...requestInit } = config;
1298
1407
  this.#globalRequestInit = requestInit;
@@ -1335,17 +1444,22 @@ var Aspi2 = class {
1335
1444
  return this;
1336
1445
  }
1337
1446
  #createRequest(method, path) {
1338
- return new Request(method, path, {
1339
- requestConfig: {
1340
- ...this.#globalRequestInit,
1341
- method
1447
+ return new Request(
1448
+ method,
1449
+ path,
1450
+ {
1451
+ requestConfig: {
1452
+ ...this.#globalRequestInit,
1453
+ method
1454
+ },
1455
+ middlewares: this.#middlewares,
1456
+ errorCbs: this.#customErrorCbs,
1457
+ throwOnError: this.#throwOnError,
1458
+ shouldBeResult: this.#shouldBeResult,
1459
+ retryConfig: this.#retryConfig
1342
1460
  },
1343
- middlewares: this.#middlewares,
1344
- errorCbs: this.#customErrorCbs,
1345
- throwOnError: this.#throwOnError,
1346
- shouldBeResult: this.#shouldBeResult,
1347
- retryConfig: this.#retryConfig
1348
- });
1461
+ this.#capabilities
1462
+ );
1349
1463
  }
1350
1464
  /**
1351
1465
  * Makes a GET request to the specified path
@@ -1613,7 +1727,7 @@ var Aspi2 = class {
1613
1727
  * api.internalServerError((req, res) => ({ message: 'Server error occurred' }));
1614
1728
  */
1615
1729
  internalServerError(cb) {
1616
- return this.error("internalServerErrorError", "INTERNAL_SERVER_ERROR", cb);
1730
+ return this.error("internalServerError", "INTERNAL_SERVER_ERROR", cb);
1617
1731
  }
1618
1732
  /**
1619
1733
  * Sets the aspi to throw an error if the response status is not successful.
@@ -1640,6 +1754,47 @@ var Aspi2 = class {
1640
1754
  this.#throwOnError = false;
1641
1755
  return this;
1642
1756
  }
1757
+ /**
1758
+ * Registers a capability on this {@link Aspi} instance.
1759
+ *
1760
+ * A capability is a small, pluggable unit that can intercept and wrap the
1761
+ * low‑level `fetch` call used by all requests created from this client.
1762
+ * It is invoked for every request with the constructed {@link AspiRequest},
1763
+ * and can:
1764
+ *
1765
+ * - inspect or mutate the outgoing request (e.g. inject auth headers),
1766
+ * - inspect the raw {@link Response},
1767
+ * - implement cross‑cutting concerns such as logging, tracing, retries,
1768
+ * or token refresh, and
1769
+ * - short‑circuit the network call by returning a synthetic {@link Response}.
1770
+ *
1771
+ * Capabilities registered on the {@link Aspi} instance are propagated to every
1772
+ * {@link Request} created via methods like {@link get}, {@link post}, etc.
1773
+ * They are applied in the order they are registered.
1774
+ *
1775
+ * @param capability - The capability factory to install on this client.
1776
+ *
1777
+ * @returns This {@link Aspi} instance for fluent chaining.
1778
+ *
1779
+ * @example
1780
+ * ```ts
1781
+ * const api = new Aspi({ baseUrl: 'https://api.example.com' })
1782
+ * .useCapability(({ request }) => ({
1783
+ * async run(runner) {
1784
+ * console.log('→', request.path);
1785
+ * const res = await runner();
1786
+ * console.log('←', res.status);
1787
+ * return res;
1788
+ * },
1789
+ * }));
1790
+ *
1791
+ * const user = await api.get('/users/1').throwable().json<User>();
1792
+ * ```
1793
+ */
1794
+ useCapability(capability) {
1795
+ this.#capabilities.push(capability);
1796
+ return this;
1797
+ }
1643
1798
  };
1644
1799
  // Annotate the CommonJS export names for ESM import in node:
1645
1800
  0 && (module.exports = {
package/dist/index.d.cts CHANGED
@@ -295,8 +295,9 @@ interface AspiRequest<T extends AspiRequestInit> {
295
295
  */
296
296
  type AspiResponse<TData = any, IsError extends boolean = false> = Merge<{
297
297
  status: HttpErrorCodes;
298
- statusText: HttpErrorStatus;
298
+ statusLabel: HttpErrorStatus;
299
299
  response: Response;
300
+ statusText: string;
300
301
  }, IsError extends true ? {
301
302
  responseData: TData;
302
303
  } : {
@@ -365,6 +366,72 @@ interface JSONParseError extends CustomError<'jsonParseError', {
365
366
  declare const isAspiError: <TReq extends AspiRequestInit>(error: unknown) => error is AspiError<TReq>;
366
367
  declare const isCustomError: <Tag extends string, A>(error: unknown) => error is CustomError<Tag, A>;
367
368
 
369
+ /**
370
+ * Arguments passed to a capability factory.
371
+ *
372
+ * @template T - The concrete request-init type used by this Aspi instance.
373
+ */
374
+ type CapabilityArgs<T extends AspiRequestInit = AspiRequestInit> = {
375
+ /**
376
+ * The fully constructed AspiRequest that will be used to execute `fetch`.
377
+ *
378
+ * You can:
379
+ * - inspect `request.path`, `request.requestInit`, `request.retryConfig`, etc.
380
+ * - mutate `request.requestInit.headers`, `signal`, or other properties
381
+ * before the network call is made.
382
+ */
383
+ request: AspiRequest<AspiRequestInit>;
384
+ };
385
+ /**
386
+ * A capability is a small wrapper around the low-level `fetch` call that can
387
+ * intercept outgoing requests and incoming responses.
388
+ *
389
+ * It is a factory function that receives the current {@link AspiRequest}
390
+ * and returns an object exposing a single `run` method. The `run` method is
391
+ * responsible for invoking the provided `runner` (which performs the actual
392
+ * `fetch`) and may:
393
+ *
394
+ * - call `runner()` directly and return its result
395
+ * - call `runner()` multiple times (e.g. retry, refresh token then retry)
396
+ * - short‑circuit by *not* calling `runner()` and returning a synthetic
397
+ * {@link Response} instead
398
+ *
399
+ * Capabilities are composed in the order they are registered via
400
+ * `Aspi.useCapability`, with each capability wrapping the next one in the
401
+ * chain.
402
+ *
403
+ * @template T - The concrete request-init type used by this Aspi instance.
404
+ *
405
+ * @example
406
+ * ```ts
407
+ * // Simple logging capability
408
+ * const loggingCapability: Capability = ({ request }) => ({
409
+ * async run(runner) {
410
+ * console.log('→', request.path, request.requestInit);
411
+ * const res = await runner();
412
+ * console.log('←', res.status, res.statusText);
413
+ * return res;
414
+ * },
415
+ * });
416
+ *
417
+ * const api = new Aspi({ baseUrl: 'https://api.example.com' })
418
+ * .useCapability(loggingCapability);
419
+ * ```
420
+ */
421
+ type Capability<T extends AspiRequestInit = AspiRequestInit> = ({ request, }: CapabilityArgs<T>) => {
422
+ /**
423
+ * Executes the next step in the capability chain.
424
+ *
425
+ * @param runner - A function that, when called, performs the actual
426
+ * network request (or the next capability in the chain) and resolves to
427
+ * a {@link Response}.
428
+ *
429
+ * @returns A promise resolving to the final {@link Response} that should be
430
+ * used by Aspi for this request.
431
+ */
432
+ run: (runner: () => Promise<Response>) => Promise<Response>;
433
+ };
434
+
368
435
  type Ok<T> = {
369
436
  __tag: 'ok';
370
437
  value: T;
@@ -767,7 +834,7 @@ declare class Request<Method extends HttpMethods, TRequest extends AspiRequestIn
767
834
  error: {};
768
835
  }> {
769
836
  #private;
770
- constructor(method: HttpMethods, path: string, requestOptions: RequestOptions<TRequest>);
837
+ constructor(method: HttpMethods, path: string, requestOptions: RequestOptions<TRequest>, capabilities?: Capability[]);
771
838
  /**
772
839
  * Sets the base URL for the request.
773
840
  * @param url The base URL to set
@@ -1081,7 +1148,7 @@ declare class Request<Method extends HttpMethods, TRequest extends AspiRequestIn
1081
1148
  * const qp = new URLSearchParams({ page: '1' });
1082
1149
  * request.setQueryParams(qp);
1083
1150
  */
1084
- setQueryParams<T extends Record<string, string> | string[][] | string | URLSearchParams>(params: T): Request<Method, TRequest, Merge<Omit<Opts, "queryParams">, {
1151
+ setQueryParams<T = any>(params: T): Request<Method, TRequest, Merge<Omit<Opts, "queryParams">, {
1085
1152
  queryParams: T;
1086
1153
  }>>;
1087
1154
  /**
@@ -1440,6 +1507,49 @@ declare class Request<Method extends HttpMethods, TRequest extends AspiRequestIn
1440
1507
  retryWhile: ((request: AspiRequest<TRequest>, response: AspiResponse) => boolean | Promise<boolean>) | undefined;
1441
1508
  onRetry: ((request: AspiRequest<TRequest>, response: AspiResponse) => void) | undefined;
1442
1509
  };
1510
+ /**
1511
+ * Registers a capability for this request.
1512
+ *
1513
+ * A capability is a small wrapper around the underlying `fetch` call that can
1514
+ * intercept, inspect, or modify the request/response lifecycle. Each registered
1515
+ * capability receives the constructed {@link AspiRequest} and can wrap the
1516
+ * execution of the network call via its `run` method.
1517
+ *
1518
+ * Capabilities are applied in the order they are registered. For each HTTP
1519
+ * call, the `Request` will:
1520
+ *
1521
+ * 1. Build an {@link AspiRequest} from the current configuration.
1522
+ * 2. Create a runner: `() => fetch(url, requestInit)`.
1523
+ * 3. Pass that runner through each capability in sequence, allowing them to:
1524
+ * - log or trace requests,
1525
+ * - implement retry/refresh logic,
1526
+ * - short‑circuit with synthetic responses, etc.
1527
+ *
1528
+ * @param capability - The capability factory to register. It will be invoked
1529
+ * for every execution of this request with the current {@link AspiRequest}.
1530
+ *
1531
+ * @returns This {@link Request} instance, enabling fluent chaining.
1532
+ *
1533
+ * @example
1534
+ * ```ts
1535
+ * const api = new Aspi({ baseUrl: 'https://api.example.com' });
1536
+ *
1537
+ * const apiWithLogging = api.useCapability(({ request }) => ({
1538
+ * async run(runner) {
1539
+ * console.log('→', request.path, request.requestInit);
1540
+ * const res = await runner();
1541
+ * console.log('←', res.status, res.statusText);
1542
+ * return res;
1543
+ * },
1544
+ * }));
1545
+ *
1546
+ * const result = await apiWithLogging
1547
+ * .get('/users')
1548
+ * .withResult()
1549
+ * .json();
1550
+ * ```
1551
+ */
1552
+ useCapability(capability: Capability<TRequest>): number;
1443
1553
  }
1444
1554
 
1445
1555
  /**
@@ -1718,7 +1828,7 @@ declare class Aspi<TRequest extends AspiRequestInit = AspiRequestInit, Opts exte
1718
1828
  * api.internalServerError((req, res) => ({ message: 'Server error occurred' }));
1719
1829
  */
1720
1830
  internalServerError<A extends {}>(cb: CustomErrorCb<TRequest, A>): Aspi<TRequest, Prettify<Opts & {
1721
- error: { [K in keyof Opts["error"] | "internalServerErrorError"]: K extends "internalServerErrorError" ? CustomError<"internalServerErrorError", A> : Opts["error"][K]; };
1831
+ error: { [K in "internalServerError" | keyof Opts["error"]]: K extends "internalServerError" ? CustomError<"internalServerError", A> : Opts["error"][K]; };
1722
1832
  }>>;
1723
1833
  /**
1724
1834
  * Sets the aspi to throw an error if the response status is not successful.
@@ -1743,6 +1853,44 @@ declare class Aspi<TRequest extends AspiRequestInit = AspiRequestInit, Opts exte
1743
1853
  withResult: true;
1744
1854
  throwable: false;
1745
1855
  }>>;
1856
+ /**
1857
+ * Registers a capability on this {@link Aspi} instance.
1858
+ *
1859
+ * A capability is a small, pluggable unit that can intercept and wrap the
1860
+ * low‑level `fetch` call used by all requests created from this client.
1861
+ * It is invoked for every request with the constructed {@link AspiRequest},
1862
+ * and can:
1863
+ *
1864
+ * - inspect or mutate the outgoing request (e.g. inject auth headers),
1865
+ * - inspect the raw {@link Response},
1866
+ * - implement cross‑cutting concerns such as logging, tracing, retries,
1867
+ * or token refresh, and
1868
+ * - short‑circuit the network call by returning a synthetic {@link Response}.
1869
+ *
1870
+ * Capabilities registered on the {@link Aspi} instance are propagated to every
1871
+ * {@link Request} created via methods like {@link get}, {@link post}, etc.
1872
+ * They are applied in the order they are registered.
1873
+ *
1874
+ * @param capability - The capability factory to install on this client.
1875
+ *
1876
+ * @returns This {@link Aspi} instance for fluent chaining.
1877
+ *
1878
+ * @example
1879
+ * ```ts
1880
+ * const api = new Aspi({ baseUrl: 'https://api.example.com' })
1881
+ * .useCapability(({ request }) => ({
1882
+ * async run(runner) {
1883
+ * console.log('→', request.path);
1884
+ * const res = await runner();
1885
+ * console.log('←', res.status);
1886
+ * return res;
1887
+ * },
1888
+ * }));
1889
+ *
1890
+ * const user = await api.get('/users/1').throwable().json<User>();
1891
+ * ```
1892
+ */
1893
+ useCapability(capability: Capability<TRequest>): this;
1746
1894
  }
1747
1895
 
1748
1896
  export { Aspi, type AspiConfigBase, AspiError, type AspiPlainResponse, type AspiRequest, type AspiRequestInit, type AspiRequestInitWithoutBodyAndMethod, type AspiResponse, type AspiResultOk, type AspiRetryConfig, type BaseURL, CustomError, type CustomErrorCb, type ErrorCallbacks, type HttpErrorCodes, type HttpErrorStatus, type HttpMethods, type JSONParseError, type Merge, type Prettify, Request, type RequestOptions, type RequestTransformer, result as Result, getHttpErrorStatus, httpErrors, isAspiError, isCustomError };
package/dist/index.d.ts CHANGED
@@ -295,8 +295,9 @@ interface AspiRequest<T extends AspiRequestInit> {
295
295
  */
296
296
  type AspiResponse<TData = any, IsError extends boolean = false> = Merge<{
297
297
  status: HttpErrorCodes;
298
- statusText: HttpErrorStatus;
298
+ statusLabel: HttpErrorStatus;
299
299
  response: Response;
300
+ statusText: string;
300
301
  }, IsError extends true ? {
301
302
  responseData: TData;
302
303
  } : {
@@ -365,6 +366,72 @@ interface JSONParseError extends CustomError<'jsonParseError', {
365
366
  declare const isAspiError: <TReq extends AspiRequestInit>(error: unknown) => error is AspiError<TReq>;
366
367
  declare const isCustomError: <Tag extends string, A>(error: unknown) => error is CustomError<Tag, A>;
367
368
 
369
+ /**
370
+ * Arguments passed to a capability factory.
371
+ *
372
+ * @template T - The concrete request-init type used by this Aspi instance.
373
+ */
374
+ type CapabilityArgs<T extends AspiRequestInit = AspiRequestInit> = {
375
+ /**
376
+ * The fully constructed AspiRequest that will be used to execute `fetch`.
377
+ *
378
+ * You can:
379
+ * - inspect `request.path`, `request.requestInit`, `request.retryConfig`, etc.
380
+ * - mutate `request.requestInit.headers`, `signal`, or other properties
381
+ * before the network call is made.
382
+ */
383
+ request: AspiRequest<AspiRequestInit>;
384
+ };
385
+ /**
386
+ * A capability is a small wrapper around the low-level `fetch` call that can
387
+ * intercept outgoing requests and incoming responses.
388
+ *
389
+ * It is a factory function that receives the current {@link AspiRequest}
390
+ * and returns an object exposing a single `run` method. The `run` method is
391
+ * responsible for invoking the provided `runner` (which performs the actual
392
+ * `fetch`) and may:
393
+ *
394
+ * - call `runner()` directly and return its result
395
+ * - call `runner()` multiple times (e.g. retry, refresh token then retry)
396
+ * - short‑circuit by *not* calling `runner()` and returning a synthetic
397
+ * {@link Response} instead
398
+ *
399
+ * Capabilities are composed in the order they are registered via
400
+ * `Aspi.useCapability`, with each capability wrapping the next one in the
401
+ * chain.
402
+ *
403
+ * @template T - The concrete request-init type used by this Aspi instance.
404
+ *
405
+ * @example
406
+ * ```ts
407
+ * // Simple logging capability
408
+ * const loggingCapability: Capability = ({ request }) => ({
409
+ * async run(runner) {
410
+ * console.log('→', request.path, request.requestInit);
411
+ * const res = await runner();
412
+ * console.log('←', res.status, res.statusText);
413
+ * return res;
414
+ * },
415
+ * });
416
+ *
417
+ * const api = new Aspi({ baseUrl: 'https://api.example.com' })
418
+ * .useCapability(loggingCapability);
419
+ * ```
420
+ */
421
+ type Capability<T extends AspiRequestInit = AspiRequestInit> = ({ request, }: CapabilityArgs<T>) => {
422
+ /**
423
+ * Executes the next step in the capability chain.
424
+ *
425
+ * @param runner - A function that, when called, performs the actual
426
+ * network request (or the next capability in the chain) and resolves to
427
+ * a {@link Response}.
428
+ *
429
+ * @returns A promise resolving to the final {@link Response} that should be
430
+ * used by Aspi for this request.
431
+ */
432
+ run: (runner: () => Promise<Response>) => Promise<Response>;
433
+ };
434
+
368
435
  type Ok<T> = {
369
436
  __tag: 'ok';
370
437
  value: T;
@@ -767,7 +834,7 @@ declare class Request<Method extends HttpMethods, TRequest extends AspiRequestIn
767
834
  error: {};
768
835
  }> {
769
836
  #private;
770
- constructor(method: HttpMethods, path: string, requestOptions: RequestOptions<TRequest>);
837
+ constructor(method: HttpMethods, path: string, requestOptions: RequestOptions<TRequest>, capabilities?: Capability[]);
771
838
  /**
772
839
  * Sets the base URL for the request.
773
840
  * @param url The base URL to set
@@ -1081,7 +1148,7 @@ declare class Request<Method extends HttpMethods, TRequest extends AspiRequestIn
1081
1148
  * const qp = new URLSearchParams({ page: '1' });
1082
1149
  * request.setQueryParams(qp);
1083
1150
  */
1084
- setQueryParams<T extends Record<string, string> | string[][] | string | URLSearchParams>(params: T): Request<Method, TRequest, Merge<Omit<Opts, "queryParams">, {
1151
+ setQueryParams<T = any>(params: T): Request<Method, TRequest, Merge<Omit<Opts, "queryParams">, {
1085
1152
  queryParams: T;
1086
1153
  }>>;
1087
1154
  /**
@@ -1440,6 +1507,49 @@ declare class Request<Method extends HttpMethods, TRequest extends AspiRequestIn
1440
1507
  retryWhile: ((request: AspiRequest<TRequest>, response: AspiResponse) => boolean | Promise<boolean>) | undefined;
1441
1508
  onRetry: ((request: AspiRequest<TRequest>, response: AspiResponse) => void) | undefined;
1442
1509
  };
1510
+ /**
1511
+ * Registers a capability for this request.
1512
+ *
1513
+ * A capability is a small wrapper around the underlying `fetch` call that can
1514
+ * intercept, inspect, or modify the request/response lifecycle. Each registered
1515
+ * capability receives the constructed {@link AspiRequest} and can wrap the
1516
+ * execution of the network call via its `run` method.
1517
+ *
1518
+ * Capabilities are applied in the order they are registered. For each HTTP
1519
+ * call, the `Request` will:
1520
+ *
1521
+ * 1. Build an {@link AspiRequest} from the current configuration.
1522
+ * 2. Create a runner: `() => fetch(url, requestInit)`.
1523
+ * 3. Pass that runner through each capability in sequence, allowing them to:
1524
+ * - log or trace requests,
1525
+ * - implement retry/refresh logic,
1526
+ * - short‑circuit with synthetic responses, etc.
1527
+ *
1528
+ * @param capability - The capability factory to register. It will be invoked
1529
+ * for every execution of this request with the current {@link AspiRequest}.
1530
+ *
1531
+ * @returns This {@link Request} instance, enabling fluent chaining.
1532
+ *
1533
+ * @example
1534
+ * ```ts
1535
+ * const api = new Aspi({ baseUrl: 'https://api.example.com' });
1536
+ *
1537
+ * const apiWithLogging = api.useCapability(({ request }) => ({
1538
+ * async run(runner) {
1539
+ * console.log('→', request.path, request.requestInit);
1540
+ * const res = await runner();
1541
+ * console.log('←', res.status, res.statusText);
1542
+ * return res;
1543
+ * },
1544
+ * }));
1545
+ *
1546
+ * const result = await apiWithLogging
1547
+ * .get('/users')
1548
+ * .withResult()
1549
+ * .json();
1550
+ * ```
1551
+ */
1552
+ useCapability(capability: Capability<TRequest>): number;
1443
1553
  }
1444
1554
 
1445
1555
  /**
@@ -1718,7 +1828,7 @@ declare class Aspi<TRequest extends AspiRequestInit = AspiRequestInit, Opts exte
1718
1828
  * api.internalServerError((req, res) => ({ message: 'Server error occurred' }));
1719
1829
  */
1720
1830
  internalServerError<A extends {}>(cb: CustomErrorCb<TRequest, A>): Aspi<TRequest, Prettify<Opts & {
1721
- error: { [K in keyof Opts["error"] | "internalServerErrorError"]: K extends "internalServerErrorError" ? CustomError<"internalServerErrorError", A> : Opts["error"][K]; };
1831
+ error: { [K in "internalServerError" | keyof Opts["error"]]: K extends "internalServerError" ? CustomError<"internalServerError", A> : Opts["error"][K]; };
1722
1832
  }>>;
1723
1833
  /**
1724
1834
  * Sets the aspi to throw an error if the response status is not successful.
@@ -1743,6 +1853,44 @@ declare class Aspi<TRequest extends AspiRequestInit = AspiRequestInit, Opts exte
1743
1853
  withResult: true;
1744
1854
  throwable: false;
1745
1855
  }>>;
1856
+ /**
1857
+ * Registers a capability on this {@link Aspi} instance.
1858
+ *
1859
+ * A capability is a small, pluggable unit that can intercept and wrap the
1860
+ * low‑level `fetch` call used by all requests created from this client.
1861
+ * It is invoked for every request with the constructed {@link AspiRequest},
1862
+ * and can:
1863
+ *
1864
+ * - inspect or mutate the outgoing request (e.g. inject auth headers),
1865
+ * - inspect the raw {@link Response},
1866
+ * - implement cross‑cutting concerns such as logging, tracing, retries,
1867
+ * or token refresh, and
1868
+ * - short‑circuit the network call by returning a synthetic {@link Response}.
1869
+ *
1870
+ * Capabilities registered on the {@link Aspi} instance are propagated to every
1871
+ * {@link Request} created via methods like {@link get}, {@link post}, etc.
1872
+ * They are applied in the order they are registered.
1873
+ *
1874
+ * @param capability - The capability factory to install on this client.
1875
+ *
1876
+ * @returns This {@link Aspi} instance for fluent chaining.
1877
+ *
1878
+ * @example
1879
+ * ```ts
1880
+ * const api = new Aspi({ baseUrl: 'https://api.example.com' })
1881
+ * .useCapability(({ request }) => ({
1882
+ * async run(runner) {
1883
+ * console.log('→', request.path);
1884
+ * const res = await runner();
1885
+ * console.log('←', res.status);
1886
+ * return res;
1887
+ * },
1888
+ * }));
1889
+ *
1890
+ * const user = await api.get('/users/1').throwable().json<User>();
1891
+ * ```
1892
+ */
1893
+ useCapability(capability: Capability<TRequest>): this;
1746
1894
  }
1747
1895
 
1748
1896
  export { Aspi, type AspiConfigBase, AspiError, type AspiPlainResponse, type AspiRequest, type AspiRequestInit, type AspiRequestInitWithoutBodyAndMethod, type AspiResponse, type AspiResultOk, type AspiRetryConfig, type BaseURL, CustomError, type CustomErrorCb, type ErrorCallbacks, type HttpErrorCodes, type HttpErrorStatus, type HttpMethods, type JSONParseError, type Merge, type Prettify, Request, type RequestOptions, type RequestTransformer, result as Result, getHttpErrorStatus, httpErrors, isAspiError, isCustomError };
package/dist/index.js CHANGED
@@ -287,17 +287,19 @@ var Request = class {
287
287
  #shouldBeResult = false;
288
288
  #bodySchemaIssues = [];
289
289
  #throwOnError = false;
290
- constructor(method, path, requestOptions) {
290
+ #capabilities = [];
291
+ constructor(method, path, requestOptions, capabilities = []) {
291
292
  this.#path = path;
292
293
  this.#middlewares = requestOptions.middlewares || [];
293
294
  this.#localRequestInit = {
294
295
  ...requestOptions.requestConfig,
295
296
  method
296
297
  };
297
- this.#retryConfig = requestOptions.retryConfig;
298
- this.#customErrorCbs = requestOptions.errorCbs || {};
298
+ this.#retryConfig = { ...requestOptions?.retryConfig || {} };
299
+ this.#customErrorCbs = { ...requestOptions?.errorCbs || {} };
299
300
  this.#throwOnError = requestOptions.throwOnError || false;
300
301
  this.#shouldBeResult = requestOptions.shouldBeResult || false;
302
+ this.#capabilities = [...capabilities];
301
303
  }
302
304
  /**
303
305
  * Sets the base URL for the request.
@@ -652,7 +654,29 @@ var Request = class {
652
654
  * request.setQueryParams(qp);
653
655
  */
654
656
  setQueryParams(params) {
655
- this.#queryParams = new URLSearchParams(params);
657
+ let qp;
658
+ if (params instanceof URLSearchParams) {
659
+ qp = new URLSearchParams(params);
660
+ } else if (typeof params === "string") {
661
+ qp = new URLSearchParams(params);
662
+ } else if (Array.isArray(params)) {
663
+ qp = new URLSearchParams();
664
+ for (const entry of params) {
665
+ if (Array.isArray(entry) && entry.length === 2) {
666
+ qp.append(String(entry[0]), String(entry[1]));
667
+ }
668
+ }
669
+ } else if (typeof params === "object" && params !== null) {
670
+ qp = new URLSearchParams();
671
+ for (const [key, value] of Object.entries(
672
+ params
673
+ )) {
674
+ qp.append(key, String(value));
675
+ }
676
+ } else {
677
+ qp = new URLSearchParams();
678
+ }
679
+ this.#queryParams = qp;
656
680
  return this;
657
681
  }
658
682
  /**
@@ -893,11 +917,41 @@ var Request = class {
893
917
  return this.#mapResponse(output);
894
918
  }
895
919
  #url() {
920
+ if (this.#path.startsWith("http://") || this.#path.startsWith("https://")) {
921
+ const absolute = new URL(this.#path);
922
+ if (this.#queryParams) {
923
+ for (const [k, v] of this.#queryParams.entries()) {
924
+ absolute.searchParams.append(k, v);
925
+ }
926
+ }
927
+ return absolute.toString();
928
+ }
896
929
  const passedBaseUrl = typeof this.#localRequestInit.baseUrl === "string" ? this.#localRequestInit.baseUrl : this.#localRequestInit.baseUrl.toString();
897
- const baseUrl = passedBaseUrl.replace(/\/+$/, "") ?? "";
898
- const path = this.#path.replace(/^\/+/, "/");
899
- const queryString = this.#queryParams ? `?${this.#queryParams.toString()}` : "";
900
- const url = [baseUrl, path, queryString].filter(Boolean).join("");
930
+ const base = passedBaseUrl.replace(/\/+$/, "");
931
+ const [rawPathAndQuery, fragment] = this.#path.split("#", 2);
932
+ const [rawPath, existingQuery] = rawPathAndQuery.split("?", 2);
933
+ let path = rawPath || "";
934
+ path = path.replace(/^\/+/, "");
935
+ path = path.replace(/\/{2,}/g, "/");
936
+ path = path.replace(/\/+$/, "");
937
+ if (path) {
938
+ path = "/" + path.replace(/^\/+/, "");
939
+ }
940
+ const qs = new URLSearchParams(existingQuery ?? "");
941
+ if (this.#queryParams) {
942
+ for (const [k, v] of this.#queryParams.entries()) {
943
+ qs.append(k, v);
944
+ }
945
+ }
946
+ const queryString = qs.toString();
947
+ let url = base + path;
948
+ if (queryString) {
949
+ url += `?${queryString}`;
950
+ }
951
+ if (fragment) {
952
+ url += `#${fragment}`;
953
+ }
954
+ url = url.replace(/\/+$/, "");
901
955
  return url;
902
956
  }
903
957
  /**
@@ -996,7 +1050,15 @@ var Request = class {
996
1050
  let responseData = null;
997
1051
  while (attempts <= retries) {
998
1052
  try {
999
- response = await fetch(url, requestInit);
1053
+ if (this.#capabilities.length > 0) {
1054
+ for (const capability of this.#capabilities) {
1055
+ response = await capability({ request }).run(
1056
+ () => fetch(url, requestInit)
1057
+ );
1058
+ }
1059
+ } else {
1060
+ response = await fetch(url, requestInit);
1061
+ }
1000
1062
  responseData = await responseParser(response);
1001
1063
  if (responseData instanceof Error) {
1002
1064
  return err(responseData);
@@ -1189,8 +1251,9 @@ var Request = class {
1189
1251
  return {
1190
1252
  response,
1191
1253
  status: response.status,
1192
- statusText: getHttpErrorStatus(response.status),
1193
- responseData
1254
+ statusLabel: getHttpErrorStatus(response.status),
1255
+ responseData,
1256
+ statusText: response.statusText
1194
1257
  };
1195
1258
  }
1196
1259
  /**
@@ -1255,16 +1318,62 @@ var Request = class {
1255
1318
  const cfg = this.#sanitisedRetryConfig();
1256
1319
  return { ...cfg };
1257
1320
  }
1321
+ /**
1322
+ * Registers a capability for this request.
1323
+ *
1324
+ * A capability is a small wrapper around the underlying `fetch` call that can
1325
+ * intercept, inspect, or modify the request/response lifecycle. Each registered
1326
+ * capability receives the constructed {@link AspiRequest} and can wrap the
1327
+ * execution of the network call via its `run` method.
1328
+ *
1329
+ * Capabilities are applied in the order they are registered. For each HTTP
1330
+ * call, the `Request` will:
1331
+ *
1332
+ * 1. Build an {@link AspiRequest} from the current configuration.
1333
+ * 2. Create a runner: `() => fetch(url, requestInit)`.
1334
+ * 3. Pass that runner through each capability in sequence, allowing them to:
1335
+ * - log or trace requests,
1336
+ * - implement retry/refresh logic,
1337
+ * - short‑circuit with synthetic responses, etc.
1338
+ *
1339
+ * @param capability - The capability factory to register. It will be invoked
1340
+ * for every execution of this request with the current {@link AspiRequest}.
1341
+ *
1342
+ * @returns This {@link Request} instance, enabling fluent chaining.
1343
+ *
1344
+ * @example
1345
+ * ```ts
1346
+ * const api = new Aspi({ baseUrl: 'https://api.example.com' });
1347
+ *
1348
+ * const apiWithLogging = api.useCapability(({ request }) => ({
1349
+ * async run(runner) {
1350
+ * console.log('→', request.path, request.requestInit);
1351
+ * const res = await runner();
1352
+ * console.log('←', res.status, res.statusText);
1353
+ * return res;
1354
+ * },
1355
+ * }));
1356
+ *
1357
+ * const result = await apiWithLogging
1358
+ * .get('/users')
1359
+ * .withResult()
1360
+ * .json();
1361
+ * ```
1362
+ */
1363
+ useCapability(capability) {
1364
+ return this.#capabilities.push(capability);
1365
+ }
1258
1366
  };
1259
1367
 
1260
1368
  // src/aspi.ts
1261
- var Aspi2 = class {
1369
+ var Aspi = class {
1262
1370
  #globalRequestInit;
1263
1371
  #middlewares = [];
1264
1372
  #retryConfig;
1265
1373
  #customErrorCbs = {};
1266
1374
  #throwOnError = false;
1267
1375
  #shouldBeResult = false;
1376
+ #capabilities = [];
1268
1377
  constructor(config) {
1269
1378
  const { retryConfig, ...requestInit } = config;
1270
1379
  this.#globalRequestInit = requestInit;
@@ -1307,17 +1416,22 @@ var Aspi2 = class {
1307
1416
  return this;
1308
1417
  }
1309
1418
  #createRequest(method, path) {
1310
- return new Request(method, path, {
1311
- requestConfig: {
1312
- ...this.#globalRequestInit,
1313
- method
1419
+ return new Request(
1420
+ method,
1421
+ path,
1422
+ {
1423
+ requestConfig: {
1424
+ ...this.#globalRequestInit,
1425
+ method
1426
+ },
1427
+ middlewares: this.#middlewares,
1428
+ errorCbs: this.#customErrorCbs,
1429
+ throwOnError: this.#throwOnError,
1430
+ shouldBeResult: this.#shouldBeResult,
1431
+ retryConfig: this.#retryConfig
1314
1432
  },
1315
- middlewares: this.#middlewares,
1316
- errorCbs: this.#customErrorCbs,
1317
- throwOnError: this.#throwOnError,
1318
- shouldBeResult: this.#shouldBeResult,
1319
- retryConfig: this.#retryConfig
1320
- });
1433
+ this.#capabilities
1434
+ );
1321
1435
  }
1322
1436
  /**
1323
1437
  * Makes a GET request to the specified path
@@ -1585,7 +1699,7 @@ var Aspi2 = class {
1585
1699
  * api.internalServerError((req, res) => ({ message: 'Server error occurred' }));
1586
1700
  */
1587
1701
  internalServerError(cb) {
1588
- return this.error("internalServerErrorError", "INTERNAL_SERVER_ERROR", cb);
1702
+ return this.error("internalServerError", "INTERNAL_SERVER_ERROR", cb);
1589
1703
  }
1590
1704
  /**
1591
1705
  * Sets the aspi to throw an error if the response status is not successful.
@@ -1612,9 +1726,50 @@ var Aspi2 = class {
1612
1726
  this.#throwOnError = false;
1613
1727
  return this;
1614
1728
  }
1729
+ /**
1730
+ * Registers a capability on this {@link Aspi} instance.
1731
+ *
1732
+ * A capability is a small, pluggable unit that can intercept and wrap the
1733
+ * low‑level `fetch` call used by all requests created from this client.
1734
+ * It is invoked for every request with the constructed {@link AspiRequest},
1735
+ * and can:
1736
+ *
1737
+ * - inspect or mutate the outgoing request (e.g. inject auth headers),
1738
+ * - inspect the raw {@link Response},
1739
+ * - implement cross‑cutting concerns such as logging, tracing, retries,
1740
+ * or token refresh, and
1741
+ * - short‑circuit the network call by returning a synthetic {@link Response}.
1742
+ *
1743
+ * Capabilities registered on the {@link Aspi} instance are propagated to every
1744
+ * {@link Request} created via methods like {@link get}, {@link post}, etc.
1745
+ * They are applied in the order they are registered.
1746
+ *
1747
+ * @param capability - The capability factory to install on this client.
1748
+ *
1749
+ * @returns This {@link Aspi} instance for fluent chaining.
1750
+ *
1751
+ * @example
1752
+ * ```ts
1753
+ * const api = new Aspi({ baseUrl: 'https://api.example.com' })
1754
+ * .useCapability(({ request }) => ({
1755
+ * async run(runner) {
1756
+ * console.log('→', request.path);
1757
+ * const res = await runner();
1758
+ * console.log('←', res.status);
1759
+ * return res;
1760
+ * },
1761
+ * }));
1762
+ *
1763
+ * const user = await api.get('/users/1').throwable().json<User>();
1764
+ * ```
1765
+ */
1766
+ useCapability(capability) {
1767
+ this.#capabilities.push(capability);
1768
+ return this;
1769
+ }
1615
1770
  };
1616
1771
  export {
1617
- Aspi2 as Aspi,
1772
+ Aspi,
1618
1773
  AspiError,
1619
1774
  CustomError,
1620
1775
  Request,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "aspi",
3
3
  "description": "Rest API client for typescript projects with chain of responsibility design pattern.",
4
- "version": "2.0.1",
4
+ "version": "2.2.0",
5
5
  "module": "src/index.ts",
6
6
  "type": "module",
7
7
  "devDependencies": {
@@ -32,17 +32,6 @@
32
32
  "url": "git+https://github.com/harshtalks/aspi.git"
33
33
  },
34
34
  "homepage": "https://github.com/harshtalks/aspi",
35
- "scripts": {
36
- "ci": "bun run test:run && bun run build && bun run check-format && bun run lint",
37
- "format": "prettier --write .",
38
- "check-format": "prettier --check .",
39
- "build": "tsup",
40
- "lint": "tsc",
41
- "local-release": "changeset version && changeset publish",
42
- "prepublishOnly": "bun run ci",
43
- "test": "vitest",
44
- "test:run": "vitest run"
45
- },
46
35
  "exports": {
47
36
  "./package.json": "./package.json",
48
37
  ".": {
@@ -52,5 +41,15 @@
52
41
  },
53
42
  "files": [
54
43
  "dist"
55
- ]
56
- }
44
+ ],
45
+ "scripts": {
46
+ "ci": "bun run test:run && bun run build && bun run check-format && bun run lint",
47
+ "format": "prettier --write .",
48
+ "check-format": "prettier --check .",
49
+ "build": "tsup",
50
+ "lint": "tsc",
51
+ "local-release": "changeset version && changeset publish",
52
+ "test": "vitest",
53
+ "test:run": "vitest run"
54
+ }
55
+ }