aspi 1.3.0 → 2.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/dist/index.cjs CHANGED
@@ -103,7 +103,7 @@ var CustomError = class extends Error {
103
103
  }
104
104
  };
105
105
  var isAspiError = (error) => {
106
- return error instanceof AspiError;
106
+ return error instanceof AspiError && error.tag === "aspiError";
107
107
  };
108
108
  var isCustomError = (error) => {
109
109
  return error instanceof CustomError;
@@ -315,19 +315,17 @@ var Request = class {
315
315
  #shouldBeResult = false;
316
316
  #bodySchemaIssues = [];
317
317
  #throwOnError = false;
318
- constructor(method, path, {
319
- requestConfig,
320
- retryConfig,
321
- middlewares,
322
- errorCbs,
323
- throwOnError
324
- }) {
318
+ constructor(method, path, requestOptions) {
325
319
  this.#path = path;
326
- this.#middlewares = middlewares || [];
327
- this.#localRequestInit = { ...requestConfig, method };
328
- this.#retryConfig = retryConfig;
329
- this.#customErrorCbs = errorCbs || {};
330
- this.#throwOnError = throwOnError || false;
320
+ this.#middlewares = requestOptions.middlewares || [];
321
+ this.#localRequestInit = {
322
+ ...requestOptions.requestConfig,
323
+ method
324
+ };
325
+ this.#retryConfig = requestOptions.retryConfig;
326
+ this.#customErrorCbs = requestOptions.errorCbs || {};
327
+ this.#throwOnError = requestOptions.throwOnError || false;
328
+ this.#shouldBeResult = requestOptions.shouldBeResult || false;
331
329
  }
332
330
  /**
333
331
  * Sets the base URL for the request.
@@ -362,18 +360,25 @@ var Request = class {
362
360
  return this;
363
361
  }
364
362
  /**
365
- * Sets multiple headers for the request.
366
- * @param headers An object containing header key-value pairs
367
- * @returns The request instance for chaining
363
+ * Merges the provided headers into the request configuration.
364
+ *
365
+ * @param {HeadersInit} headers - An object or iterable containing header name/value pairs.
366
+ * Existing headers are retained unless a key in this object overwrites them.
367
+ * @returns {this} The current {@link Request} instance for method chaining.
368
+ *
368
369
  * @example
370
+ * // Set common JSON headers
369
371
  * const request = new Request('/users', config);
370
372
  * request.setHeaders({
371
373
  * 'Content-Type': 'application/json',
372
- * 'Accept': 'application/json'
374
+ * 'Accept': 'application/json',
373
375
  * });
374
376
  */
375
377
  setHeaders(headers) {
376
- this.#localRequestInit.headers = headers;
378
+ this.#localRequestInit.headers = {
379
+ ...this.#localRequestInit.headers ?? {},
380
+ ...headers
381
+ };
377
382
  return this;
378
383
  }
379
384
  /**
@@ -387,7 +392,7 @@ var Request = class {
387
392
  */
388
393
  setHeader(key, value) {
389
394
  this.#localRequestInit.headers = {
390
- ...this.#localRequestInit.headers,
395
+ ...this.#localRequestInit.headers ?? {},
391
396
  [key]: value
392
397
  };
393
398
  return this;
@@ -585,29 +590,54 @@ var Request = class {
585
590
  return this.error("internalServerError", "INTERNAL_SERVER_ERROR", cb);
586
591
  }
587
592
  /**
588
- * Sets a custom error handler for a specific HTTP status code.
589
- * @param tag A string identifier for the error type
590
- * @param status The HTTP error status to handle
591
- * @param cb The callback function to handle the error
592
- * @returns The request instance for chaining
593
+ * Register a custom error handler for a specific HTTP status code.
594
+ *
595
+ * When the response matches the provided `status`, the supplied callback `cb`
596
+ * is invoked and its return value is wrapped in a {@link CustomError} with the
597
+ * given `tag`. The method also augments the request's generic `Opts['error']`
598
+ * type so that the custom error is reflected in the resulting `Result`
599
+ * union.
600
+ *
601
+ * @template Tag - A string literal used as the error tag.
602
+ * @template A - The shape of the data returned by the callback.
603
+ *
604
+ * @param {Tag} tag
605
+ * A unique identifier for the custom error. This value becomes the `tag`
606
+ * property of the {@link CustomError} produced by the handler.
607
+ *
608
+ * @param {HttpErrorStatus} status
609
+ * The HTTP status code (e.g. `'BAD_REQUEST'`, `'NOT_FOUND'`) that should
610
+ * trigger the custom handler.
611
+ *
612
+ * @param {CustomErrorCb<TRequest, A>} cb
613
+ * A callback that receives the failing request and response objects and
614
+ * returns an object describing the error payload.
615
+ *
616
+ * @returns {Request<Method, TRequest, Merge<Omit<Opts, 'error'>, { error: { [K in Tag | keyof Opts['error']]: K extends Tag ? CustomError<Tag, A> : Opts['error'][K]; } }>>}
617
+ * The same {@link Request} instance, now typed with the newly added error
618
+ * variant, allowing method‑chaining.
619
+ *
593
620
  * @example
621
+ * ```ts
594
622
  * const request = new Request('/users', config);
623
+ *
624
+ * // Attach a custom handler for a 400 Bad Request response
595
625
  * request
596
- .withResult()
597
- .error('customError', 'BAD_REQUEST', (error) => {
598
- * console.log('Bad request error:', error);
599
- * return {
600
- * message: 'Invalid input',
601
- * details: error.response.responseData
602
- * };
603
- * });
626
+ * withResult()
627
+ * .error('customError', 'BAD_REQUEST', (ctx) => {
628
+ * console.log('Bad request error:', ctx);
629
+ * return {
630
+ * message: 'Invalid input',
631
+ * details: ctx.response.responseData,
632
+ * };
633
+ * });
604
634
  *
605
- * // Later when making the request:
635
+ * // Later, when executing the request:
606
636
  * const result = await request.json();
607
- * if (Result.isErr(result)) {
608
- * if(result.tag === 'customError') {
637
+ * if (Result.isErr(result) && result.tag === 'customError') {
609
638
  * console.log(result.error.data.message); // 'Invalid input'
610
639
  * }
640
+ * ```
611
641
  */
612
642
  error(tag, status, cb) {
613
643
  this.#customErrorCbs[httpErrors[status]] = {
@@ -617,58 +647,106 @@ var Request = class {
617
647
  return this;
618
648
  }
619
649
  /**
620
- * Sets query parameters for the request URL.
621
- * @param params An object containing query parameter key-value pairs
622
- * @returns The request instance for chaining
650
+ * Sets the query parameters for the request URL.
651
+ *
652
+ * Accepts any value that can be passed to the `URLSearchParams` constructor:
653
+ * - an object mapping keys to string values,
654
+ * - an iterable of `[key, value]` tuples,
655
+ * - a raw query string, or
656
+ * - an existing {@link URLSearchParams} instance.
657
+ *
658
+ * The supplied parameters replace any previously defined query parameters.
659
+ *
660
+ * @template T - The concrete type of the supplied parameters.
661
+ * @param {T} params - The query parameters to apply.
662
+ * @returns {this} The request instance for method‑chaining.
663
+ *
623
664
  * @example
624
665
  * const request = new Request('/users', config);
625
666
  * request.setQueryParams({
626
667
  * page: '1',
627
668
  * limit: '10',
628
- * sort: 'desc'
669
+ * sort: 'desc',
629
670
  * });
671
+ *
672
+ * // Using a raw query string
673
+ * request.setQueryParams('page=1&limit=10');
674
+ *
675
+ * // Using an array of entries
676
+ * request.setQueryParams([['page', '1'], ['limit', '10']]);
677
+ *
678
+ * // Using an existing URLSearchParams instance
679
+ * const qp = new URLSearchParams({ page: '1' });
680
+ * request.setQueryParams(qp);
630
681
  */
631
682
  setQueryParams(params) {
632
683
  this.#queryParams = new URLSearchParams(params);
633
684
  return this;
634
685
  }
635
686
  /**
636
- * Sets the output schema for validating the response data using Zod.
637
- * @param schema The Zod schema to validate the response
638
- * @returns The request instance for chaining
687
+ * Sets a validation schema for the response data.
688
+ *
689
+ * The provided {@link StandardSchemaV1} schema will be used to validate the
690
+ * response payload when the request is executed. If validation fails, a
691
+ * `parseError` is added to the request's error type.
692
+ *
693
+ * @template TSchema - A type extending {@link StandardSchemaV1}
694
+ * @param schema - The schema used to validate the response data
695
+ * @returns The request instance for chaining with an updated generic type that
696
+ * includes the schema and a possible `parseError` in the error union
697
+ *
639
698
  * @example
699
+ * ```ts
640
700
  * import { z } from 'zod';
641
701
  *
642
702
  * const userSchema = z.object({
643
703
  * id: z.number(),
644
704
  * name: z.string(),
645
- * email: z.string().email()
705
+ * email: z.string().email(),
646
706
  * });
647
707
  *
648
708
  * const request = new Request('/users', config);
649
709
  * const result = await request
650
710
  * .withResult()
651
- * .output(userSchema)
711
+ * .schema(userSchema)
652
712
  * .json();
653
713
  *
654
714
  * if (Result.isOk(result)) {
655
715
  * const user = result.value; // Typed and validated user data
656
716
  * }
717
+ * ```
657
718
  */
658
719
  schema(schema) {
659
720
  this.#schema = schema;
660
721
  return this;
661
722
  }
662
723
  /**
663
- * Sets the request to throw an error if the response status is not successful.
664
- * @returns The request instance for chaining
724
+ * Configures the request to **throw** an exception when the response status
725
+ * indicates a failure (i.e., `!response.ok`). This disables the “Result”
726
+ * mode (`withResult`) and enables “throwable” mode, causing
727
+ * `await request.json()` (or other response helpers) to either resolve with
728
+ * the successful payload **or** reject with an `AspiError`/`CustomError`.
729
+ *
730
+ * Use this when you prefer traditional `try / catch` error handling over
731
+ * the explicit `Result` type returned by {@link withResult}.
732
+ *
733
+ * @returns This {@link Request} instance, now typed with `throwable: true` and
734
+ * `withResult: false` for proper chaining.
735
+ *
665
736
  * @example
737
+ * ```ts
666
738
  * const request = new Request('/users', config);
667
- * const result = await request
668
- * .withResult()
669
- * .throwable()
670
- * .json();
671
739
  *
740
+ * try {
741
+ * const user = await request
742
+ * .throwable() // Enable throwing on HTTP errors
743
+ * .json<User>(); // Will throw if the response is not ok
744
+ * console.log(user);
745
+ * } catch (err) {
746
+ * // err is either AspiError or a CustomError returned by a custom handler
747
+ * console.error('Request failed:', err);
748
+ * }
749
+ * ```
672
750
  */
673
751
  throwable() {
674
752
  this.#shouldBeResult = false;
@@ -676,43 +754,100 @@ var Request = class {
676
754
  return this;
677
755
  }
678
756
  /**
679
- * Executes the request and returns the JSON response.
680
- * @returns A Promise containing the Result type with either successful data or error information
757
+ * Sends the request and parses the response body as JSON.
758
+ *
759
+ * The resolved value of the returned promise varies based on the request mode:
760
+ *
761
+ * - **Result mode** (`withResult()`): resolves to a {@link Result.Result} that
762
+ * contains either an {@link AspiResultOk} with the parsed payload or a union
763
+ * of possible error types (HTTP errors, custom errors, JSON‑parse errors,
764
+ * schema‑validation errors, etc.).
765
+ *
766
+ * - **Throwable mode** (`throwable()`): resolves directly to the successful
767
+ * payload (`AspiPlainResponse`) and throws a {@link AspiError} or
768
+ * {@link CustomError} on failure.
769
+ *
770
+ * - **Default mode** (no explicit mode): resolves to a tuple
771
+ * `[value, error]` where exactly one element is non‑null.
772
+ *
773
+ * @template T - The inferred output type of the response schema (if a schema
774
+ * was supplied via {@link schema}). When no schema is provided `T` defaults
775
+ * to `any`.
776
+ *
777
+ * @returns A promise whose shape depends on the selected mode (see description).
778
+ * In Result mode it is `Result<ResultOk<…>, …>`, in throwable mode it is
779
+ * `AspiPlainResponse<…>`, and otherwise a tuple
780
+ * `[AspiResultOk<…> | null, Error | null]`.
781
+ *
681
782
  * @example
783
+ * // Using the Result API
682
784
  * const request = new Request('/users', config);
683
785
  * const result = await request
684
786
  * .setQueryParams({ id: '123' })
685
- * .
686
- withResult()
687
- * .notFound((error) => ({ message: 'User not found' }))
787
+ * .withResult()
788
+ * .notFound(() => ({ message: 'User not found' }))
688
789
  * .json<User>();
689
790
  *
690
791
  * if (Result.isOk(result)) {
691
- * const user = result.value; // User data
792
+ * // `result.value` has type `User`
793
+ * console.log(result.value);
692
794
  * } else {
693
- * console.error(result.error); // Error handling
795
+ * console.error(result.error);
694
796
  * }
695
797
  */
696
798
  async json() {
697
- const output = await this.#makeRequest(
698
- async (response) => response.json().catch(
799
+ const output = await this.#makeRequest(async (response) => {
800
+ if (response.status === 204 || response.status >= 300 && response.status < 400) {
801
+ return null;
802
+ }
803
+ return response.json().catch(
699
804
  (e) => new CustomError("jsonParseError", {
700
805
  message: e instanceof Error ? e.message : "Failed to parse JSON"
701
806
  })
702
- ),
703
- true
704
- );
807
+ );
808
+ }, true);
705
809
  return this.#mapResponse(output);
706
810
  }
707
811
  /**
708
- * Executes the request and returns the response as plain text.
709
- * @returns A Promise containing the Result type with either successful text data or error information
812
+ * Executes the request and returns the response body as plain text.
813
+ *
814
+ * The method respects the request mode:
815
+ *
816
+ * - **Result mode** (`withResult()`): resolves to a {@link Result.Result}
817
+ * containing either an {@link AspiResultOk} with the text payload or an
818
+ * error variant.
819
+ * - **Throwable mode** (`throwable()`): resolves directly to the text string
820
+ * and throws on error.
821
+ * - **Default mode**: resolves to a tuple `[value, error]` where exactly one
822
+ * element is `null`.
823
+ *
824
+ * @returns {Promise<
825
+ * Opts['withResult'] extends true
826
+ * ? Result.Result<
827
+ * AspiResultOk<TRequest, string>,
828
+ * AspiError<TRequest> |
829
+ * (Opts extends { error: any } ? Opts['error'][keyof Opts['error']] : never)
830
+ * >
831
+ * : Opts['throwable'] extends true
832
+ * ? AspiPlainResponse<TRequest, string>
833
+ * : [
834
+ * AspiResultOk<TRequest, string> | null,
835
+ * (
836
+ * | AspiError<TRequest>
837
+ * | (Opts extends { error: any } ? Opts['error'][keyof Opts['error']] : never)
838
+ * | null
839
+ * )
840
+ * ]
841
+ * }>
842
+ * A promise that resolves according to the selected request mode.
843
+ *
710
844
  * @example
845
+ * ```ts
711
846
  * const request = new Request('/data.txt', config);
712
847
  * const result = await request
713
848
  * .setQueryParams({ version: '1' })
714
849
  * .withResult()
715
- * .notFound((error) => ({ message: 'Text file not found' }))
850
+ * .notFound(() => ({ message: 'Text file not found' }))
716
851
  * .text();
717
852
  *
718
853
  * if (Result.isOk(result)) {
@@ -720,22 +855,58 @@ var Request = class {
720
855
  * } else {
721
856
  * console.error(result.error); // Error handling
722
857
  * }
858
+ * ```
723
859
  */
724
860
  async text() {
725
- const output = await this.#makeRequest(
726
- (response) => response.text()
727
- );
861
+ const output = await this.#makeRequest((response) => {
862
+ return response.text();
863
+ });
728
864
  return this.#mapResponse(output);
729
865
  }
730
866
  /**
731
- * Executes the request and returns the response as a Blob.
732
- * @returns A Promise containing the Result type with either successful Blob data or error information
867
+ * Executes the request and returns the response body as a {@link Blob}.
868
+ *
869
+ * The shape of the returned {@link Promise} depends on the request mode:
870
+ *
871
+ * - **Result mode** (`withResult()`): resolves to a {@link Result.Result} containing
872
+ * either an {@link AspiResultOk} with `Blob` data or an error variant.
873
+ * - **Throwable mode** (`throwable()`): resolves directly to a {@link Blob}
874
+ * (wrapped in {@link AspiPlainResponse}) and throws on failure.
875
+ * - **Default mode**: resolves to a tuple `[value, error]` where exactly one element
876
+ * is `null`.
877
+ *
878
+ * @returns {Promise<
879
+ * Opts['withResult'] extends true
880
+ * ? Result.Result<
881
+ * AspiResultOk<TRequest, Blob>,
882
+ * | AspiError<TRequest>
883
+ * | (Opts extends { error: any }
884
+ * ? Opts['error'][keyof Opts['error']]
885
+ * : never)
886
+ * >
887
+ * : Opts['throwable'] extends true
888
+ * ? AspiPlainResponse<TRequest, Blob>
889
+ * : [
890
+ * AspiResultOk<TRequest, Blob> | null,
891
+ * (
892
+ * | (
893
+ * | AspiError<TRequest>
894
+ * | (Opts extends { error: any }
895
+ * ? Opts['error'][keyof Opts['error']]
896
+ * : never)
897
+ * )
898
+ * | null
899
+ * ),
900
+ * ]
901
+ * }>
902
+ *
733
903
  * @example
904
+ * ```ts
734
905
  * const request = new Request('/image.jpg', config);
735
906
  * const result = await request
736
907
  * .setQueryParams({ size: 'large' })
737
908
  * .withResult()
738
- * .notFound((error) => ({ message: 'Image not found' }))
909
+ * .notFound(() => ({ message: 'Image not found' }))
739
910
  * .blob();
740
911
  *
741
912
  * if (Result.isOk(result)) {
@@ -743,43 +914,77 @@ var Request = class {
743
914
  * } else {
744
915
  * console.error(result.error); // Error handling
745
916
  * }
917
+ * ```
746
918
  */
747
919
  async blob() {
748
920
  const output = await this.#makeRequest((response) => response.blob());
749
921
  return this.#mapResponse(output);
750
922
  }
751
923
  #url() {
752
- const baseUrl = this.#localRequestInit.baseUrl?.replace(/\/+$/, "") ?? "";
924
+ const passedBaseUrl = typeof this.#localRequestInit.baseUrl === "string" ? this.#localRequestInit.baseUrl : this.#localRequestInit.baseUrl.toString();
925
+ const baseUrl = passedBaseUrl.replace(/\/+$/, "") ?? "";
753
926
  const path = this.#path.replace(/^\/+/, "/");
754
927
  const queryString = this.#queryParams ? `?${this.#queryParams.toString()}` : "";
755
928
  const url = [baseUrl, path, queryString].filter(Boolean).join("");
756
929
  return url;
757
930
  }
758
931
  /**
759
- * Returns the complete URL for the request including base URL, path, and query parameters.
760
- * @returns The complete URL string
932
+ * Returns the fully‑qualified URL that will be used for the request.
933
+ *
934
+ * The URL is constructed from the configured base URL, the request path,
935
+ * and any query parameters added via {@link setQueryParams}.
936
+ *
937
+ * @returns {string} The complete request URL.
938
+ *
761
939
  * @example
940
+ * ```ts
762
941
  * const request = new Request('/users', config);
763
942
  * request.setBaseUrl('https://api.example.com');
764
943
  * request.setQueryParams({ id: '123' });
765
- * console.log(request.url()); // 'https://api.example.com/users?id=123'
944
+ *
945
+ * console.log(request.url());
946
+ * // => 'https://api.example.com/users?id=123'
947
+ * ```
766
948
  */
767
949
  url() {
768
950
  return this.#url();
769
951
  }
770
952
  /**
771
- * Configures the request to return a Result type instead of a tuple.
772
- * @returns The request instance for chaining with Result type return value
953
+ * Switches the request into **Result** mode.
954
+ *
955
+ * In Result mode the response helpers (`json`, `text`, `blob`, …) resolve to a
956
+ * {@link Result.Result} instance instead of the default tuple
957
+ * `[value, error]`. This allows callers to use pattern matching
958
+ * (`Result.isOk`, `Result.isErr`) to handle success and failure.
959
+ *
960
+ * Calling `withResult` disables the “throwable” behaviour (see {@link throwable}).
961
+ *
962
+ * @returns {Request<
963
+ * Method,
964
+ * TRequest,
965
+ * Merge<
966
+ * Omit<Opts, 'withResult' | 'throwable'>,
967
+ * {
968
+ * withResult: true;
969
+ * throwable: false;
970
+ * }
971
+ * >
972
+ * >} The same {@link Request} instance, now typed with `withResult: true` and
973
+ * `throwable: false` for fluent chaining.
974
+ *
773
975
  * @example
976
+ * ```ts
774
977
  * const request = new Request('/users', config);
978
+ *
775
979
  * const result = await request
776
- * .withResult()
980
+ * .withResult() // enable Result mode
777
981
  * .json<User>();
778
982
  *
779
- * // Returns Result type instead of tuple
780
983
  * if (Result.isOk(result)) {
781
- * const user = result.value;
984
+ * // `result.value` is of type `User`
985
+ * console.log(result.value);
782
986
  * }
987
+ * ```
783
988
  */
784
989
  withResult() {
785
990
  this.#throwOnError = false;
@@ -799,6 +1004,9 @@ var Request = class {
799
1004
  return [null, getErrorOrNull(value)];
800
1005
  }
801
1006
  }
1007
+ #isSuccessResponse(response) {
1008
+ return response.ok || response.status >= 300 && response.status < 400;
1009
+ }
802
1010
  async #makeRequest(responseParser, isJson = false) {
803
1011
  if (this.#bodySchemaIssues.length) {
804
1012
  return err(new CustomError("parseError", this.#bodySchemaIssues));
@@ -809,20 +1017,23 @@ var Request = class {
809
1017
  const requestInit = request.requestInit;
810
1018
  const url = this.#url();
811
1019
  let attempts = 1;
812
- let response;
813
- let responseData;
1020
+ let response = new Response(null, {
1021
+ status: 500,
1022
+ statusText: "Internal Server Error"
1023
+ });
1024
+ let responseData = null;
814
1025
  while (attempts <= retries) {
815
1026
  try {
816
1027
  response = await fetch(url, requestInit);
817
1028
  responseData = await responseParser(response);
818
- if (responseData instanceof CustomError) {
1029
+ if (responseData instanceof Error) {
819
1030
  return err(responseData);
820
1031
  }
821
1032
  const retryWhileCondition = retryWhile ? await retryWhile(
822
1033
  request,
823
1034
  this.#makeResponse(response, responseData)
824
1035
  ) : false;
825
- if (response.ok || !retryOn.includes(response.status) && !retryWhileCondition) {
1036
+ if (this.#isSuccessResponse(response) || !retryOn.includes(response.status) && !retryWhileCondition) {
826
1037
  break;
827
1038
  }
828
1039
  if (response.status in this.#customErrorCbs && attempts === retries) {
@@ -845,9 +1056,31 @@ var Request = class {
845
1056
  request,
846
1057
  this.#makeResponse(response, responseData)
847
1058
  ) : retryDelay;
848
- await new Promise((resolve) => setTimeout(resolve, delay));
1059
+ await this.#abortDelay(delay, request);
849
1060
  }
850
1061
  } catch (e) {
1062
+ if (e instanceof Error && e.name === "AbortError") {
1063
+ if (500 in this.#customErrorCbs) {
1064
+ const result = this.#customErrorCbs[response.status].cb({
1065
+ request,
1066
+ response: this.#makeResponse(response, responseData)
1067
+ });
1068
+ return err(
1069
+ new CustomError(
1070
+ // @ts-ignore
1071
+ this.#customErrorCbs[response.status].tag,
1072
+ result
1073
+ )
1074
+ );
1075
+ }
1076
+ return err(
1077
+ new AspiError(
1078
+ e.message,
1079
+ this.#request(),
1080
+ this.#makeResponse(response, responseData)
1081
+ )
1082
+ );
1083
+ }
851
1084
  if (attempts === retries) throw e;
852
1085
  const delay = typeof retryDelay === "function" ? await retryDelay(
853
1086
  retries - attempts - 1,
@@ -855,14 +1088,14 @@ var Request = class {
855
1088
  request,
856
1089
  this.#makeResponse(response, responseData)
857
1090
  ) : retryDelay;
858
- await new Promise((resolve) => setTimeout(resolve, delay));
1091
+ await this.#abortDelay(delay, request);
859
1092
  }
860
1093
  if (onRetry) {
861
1094
  onRetry(request, this.#makeResponse(response, responseData));
862
1095
  }
863
1096
  attempts++;
864
1097
  }
865
- if (!response.ok) {
1098
+ if (!this.#isSuccessResponse(response)) {
866
1099
  if (response.status in this.#customErrorCbs) {
867
1100
  const result = this.#customErrorCbs[response.status].cb({
868
1101
  request,
@@ -932,17 +1165,38 @@ var Request = class {
932
1165
  );
933
1166
  }
934
1167
  }
1168
+ #abortDelay(ms, request) {
1169
+ return new Promise((resolve, reject) => {
1170
+ const timer = setTimeout(resolve, ms);
1171
+ const signal = request.requestInit.signal;
1172
+ if (signal) {
1173
+ if (signal.aborted) {
1174
+ clearTimeout(timer);
1175
+ reject(new DOMException("The user aborted a request.", "AbortError"));
1176
+ } else {
1177
+ const abortHandler = () => {
1178
+ clearTimeout(timer);
1179
+ reject(
1180
+ new DOMException("The user aborted a request.", "AbortError")
1181
+ );
1182
+ };
1183
+ signal.addEventListener("abort", abortHandler, { once: true });
1184
+ }
1185
+ }
1186
+ });
1187
+ }
935
1188
  #request() {
936
1189
  let requestInit = this.#localRequestInit;
937
1190
  for (const middleware of this.#middlewares) {
938
- requestInit = middleware(this.#localRequestInit);
1191
+ requestInit = middleware(requestInit);
939
1192
  }
940
1193
  return {
941
- requestInit,
942
- baseUrl: this.#localRequestInit.baseUrl,
1194
+ requestInit: {
1195
+ ...requestInit,
1196
+ retryConfig: this.#sanitisedRetryConfig()
1197
+ },
943
1198
  path: this.#path,
944
- queryParams: this.#queryParams || null,
945
- retryConfig: this.#sanitisedRetryConfig()
1199
+ queryParams: this.#queryParams || null
946
1200
  };
947
1201
  }
948
1202
  #sanitisedRetryConfig() {
@@ -967,6 +1221,68 @@ var Request = class {
967
1221
  responseData
968
1222
  };
969
1223
  }
1224
+ /**
1225
+ * Returns the underlying {@link AspiRequest} object that will be used for the fetch call.
1226
+ *
1227
+ * This method does not perform any network activity; it simply builds and returns the
1228
+ * request configuration, including any applied middlewares, query parameters, etc.
1229
+ *
1230
+ * @returns {AspiRequest<TRequest>} The constructed request object.
1231
+ */
1232
+ getRequest() {
1233
+ return this.#request();
1234
+ }
1235
+ /**
1236
+ * Retrieves the registry of custom error callbacks that have been
1237
+ * registered via {@link error}. The returned object maps HTTP status
1238
+ * codes to their corresponding callback functions and tags.
1239
+ *
1240
+ * @returns {ErrorCallbacks} A shallow copy of the internal error callback registry.
1241
+ */
1242
+ getErrorCallbackRegistry() {
1243
+ return { ...this.#customErrorCbs };
1244
+ }
1245
+ /**
1246
+ * Returns whether the request is configured to return a {@link Result.Result}
1247
+ * instead of the default tuple or throwing.
1248
+ *
1249
+ * @returns {boolean} `true` when {@link withResult} has been called.
1250
+ */
1251
+ isResult() {
1252
+ return this.#shouldBeResult;
1253
+ }
1254
+ /**
1255
+ * Returns whether the request is configured to throw on HTTP errors.
1256
+ *
1257
+ * @returns {boolean} `true` when {@link throwable} has been called.
1258
+ */
1259
+ isThrowable() {
1260
+ return this.#throwOnError;
1261
+ }
1262
+ /**
1263
+ * Returns the effective retry configuration for this request, including defaulted values.
1264
+ *
1265
+ * The returned object contains:
1266
+ * - `retries` – number of retry attempts (default 1)
1267
+ * - `retryDelay` – delay between attempts in milliseconds or a function that returns a delay
1268
+ * - `retryOn` – array of HTTP status codes that should trigger a retry
1269
+ * - `retryWhile` – optional custom predicate executed after each response
1270
+ * - `onRetry` – optional callback invoked after a retry attempt
1271
+ *
1272
+ * A shallow copy is returned to avoid accidental mutation of the internal state.
1273
+ *
1274
+ * @returns {{
1275
+ * retries: number;
1276
+ * retryDelay: number | ((attempt: number, maxAttempts: number, request: AspiRequest<TRequest>, response: AspiResponse<any, true>) => number);
1277
+ * retryOn: number[];
1278
+ * retryWhile?: (request: AspiRequest<TRequest>, response: AspiResponse<any, true>) => boolean | Promise<boolean>;
1279
+ * onRetry?: (request: AspiRequest<TRequest>, response: AspiResponse<any, true>) => void;
1280
+ * }}
1281
+ */
1282
+ getRetryConfig() {
1283
+ const cfg = this.#sanitisedRetryConfig();
1284
+ return { ...cfg };
1285
+ }
970
1286
  };
971
1287
 
972
1288
  // src/aspi.ts
@@ -976,15 +1292,20 @@ var Aspi2 = class {
976
1292
  #retryConfig;
977
1293
  #customErrorCbs = {};
978
1294
  #throwOnError = false;
1295
+ #shouldBeResult = false;
979
1296
  constructor(config) {
980
- const { retryConfig, ...requestConfig } = config;
981
- this.#globalRequestInit = requestConfig;
1297
+ const { retryConfig, ...requestInit } = config;
1298
+ this.#globalRequestInit = requestInit;
982
1299
  this.#retryConfig = retryConfig;
983
1300
  }
984
1301
  /**
985
- * Sets the base URL for all API requests
986
- * @param {string} url - The base URL to be set
987
- * @returns {Aspi} The Aspi instance for chaining
1302
+ * Sets or overrides the base URL used for all subsequent API requests.
1303
+ *
1304
+ * Accepts either a string or a `URL` instance. If a `URL` object is provided,
1305
+ * it is converted to its string representation.
1306
+ *
1307
+ * @param url - The new base URL.
1308
+ * @returns The current {@link Aspi} instance for chaining.
988
1309
  * @example
989
1310
  * const api = new Aspi({ baseUrl: 'https://api.example.com' });
990
1311
  * api.setBaseUrl('https://api.newdomain.com');
@@ -1013,17 +1334,17 @@ var Aspi2 = class {
1013
1334
  };
1014
1335
  return this;
1015
1336
  }
1016
- #createRequest(method, path, body) {
1337
+ #createRequest(method, path) {
1017
1338
  return new Request(method, path, {
1018
1339
  requestConfig: {
1019
1340
  ...this.#globalRequestInit,
1020
- method,
1021
- body
1341
+ method
1022
1342
  },
1023
- retryConfig: this.#retryConfig,
1024
1343
  middlewares: this.#middlewares,
1025
1344
  errorCbs: this.#customErrorCbs,
1026
- throwOnError: this.#throwOnError
1345
+ throwOnError: this.#throwOnError,
1346
+ shouldBeResult: this.#shouldBeResult,
1347
+ retryConfig: this.#retryConfig
1027
1348
  });
1028
1349
  }
1029
1350
  /**
@@ -1038,40 +1359,39 @@ var Aspi2 = class {
1038
1359
  return this.#createRequest("GET", path);
1039
1360
  }
1040
1361
  /**
1041
- * Makes a POST request to the specified path with an optional body
1042
- * @param {string} path - The path to make the request to
1043
- * @param {BodyInit} [body] - The body of the request
1044
- * @returns {Request} A Request instance for chaining
1362
+ * Makes a POST request to the specified path.
1363
+ *
1364
+ * @param {string} path - The path to make the request to.
1365
+ * @returns {Request} A Request instance for chaining.
1366
+ *
1045
1367
  * @example
1046
1368
  * const api = new Aspi({ baseUrl: 'https://api.example.com' });
1047
- * const response = await api.post('/users', { name: 'John' }).json();
1369
+ * const response = await api.post('/users').json();
1048
1370
  */
1049
- post(path, body) {
1050
- return this.#createRequest("POST", path, body);
1371
+ post(path) {
1372
+ return this.#createRequest("POST", path);
1051
1373
  }
1052
1374
  /**
1053
- * Makes a PUT request to the specified path with an optional body
1054
- * @param {string} path - The path to make the request to
1055
- * @param {BodyInit} [body] - The body of the request
1056
- * @returns {Request} A Request instance for chaining
1375
+ * Makes a PUT request to the specified path.
1376
+ * @param {string} path - The path to make the request to.
1377
+ * @returns {Request} A Request instance for chaining.
1057
1378
  * @example
1058
1379
  * const api = new Aspi({ baseUrl: 'https://api.example.com' });
1059
- * const response = await api.put('/users/1', { name: 'John' }).json();
1380
+ * const response = await api.put('/users/1').json();
1060
1381
  */
1061
- put(path, body) {
1062
- return this.#createRequest("PUT", path, body);
1382
+ put(path) {
1383
+ return this.#createRequest("PUT", path);
1063
1384
  }
1064
1385
  /**
1065
- * Makes a PATCH request to the specified path with an optional body
1066
- * @param {string} path - The path to make the request to
1067
- * @param {BodyInit} [body] - The body of the request
1068
- * @returns {Request} A Request instance for chaining
1386
+ * Makes a PATCH request to the specified path.
1387
+ * @param {string} path - The path to make the request to.
1388
+ * @returns {Request} A Request instance for chaining.
1069
1389
  * @example
1070
1390
  * const api = new Aspi({ baseUrl: 'https://api.example.com' });
1071
- * const response = await api.patch('/users/1', { name: 'John' }).json();
1391
+ * const response = await api.patch('/users/1').json();
1072
1392
  */
1073
- patch(path, body) {
1074
- return this.#createRequest("PATCH", path, body);
1393
+ patch(path) {
1394
+ return this.#createRequest("PATCH", path);
1075
1395
  }
1076
1396
  /**
1077
1397
  * Makes a DELETE request to the specified path
@@ -1107,8 +1427,9 @@ var Aspi2 = class {
1107
1427
  return this.#createRequest("OPTIONS", path);
1108
1428
  }
1109
1429
  /**
1110
- * Sets multiple headers for all API requests
1111
- * @param {Record<string, string>} headers - An object containing header key-value pairs
1430
+ * Sets multiple headers for all API requests. Existing headers are preserved
1431
+ * and new ones are merged, overriding any duplicate keys.
1432
+ * @param {HeadersInit} headers - An object containing header key-value pairs
1112
1433
  * @returns {Aspi} The Aspi instance for chaining
1113
1434
  * @example
1114
1435
  * const api = new Aspi({ baseUrl: 'https://api.example.com' });
@@ -1118,21 +1439,26 @@ var Aspi2 = class {
1118
1439
  * });
1119
1440
  */
1120
1441
  setHeaders(headers) {
1121
- this.#globalRequestInit.headers = headers;
1442
+ this.#globalRequestInit.headers = {
1443
+ ...this.#globalRequestInit.headers ?? {},
1444
+ ...headers
1445
+ };
1122
1446
  return this;
1123
1447
  }
1124
1448
  /**
1125
- * Sets a single header for all API requests
1126
- * @param {string} key - The header key
1127
- * @param {string} value - The header value
1128
- * @returns {Aspi} The Aspi instance for chaining
1449
+ * Sets a single header for all API requests.
1450
+ *
1451
+ * @param key - The header name.
1452
+ * @param value - The header value.
1453
+ * @returns This {@link Aspi} instance for chaining.
1454
+ *
1129
1455
  * @example
1130
1456
  * const api = new Aspi({ baseUrl: 'https://api.example.com' });
1131
1457
  * api.setHeader('Content-Type', 'application/json');
1132
1458
  */
1133
1459
  setHeader(key, value) {
1134
1460
  this.#globalRequestInit.headers = {
1135
- ...this.#globalRequestInit.headers,
1461
+ ...this.#globalRequestInit.headers ?? {},
1136
1462
  [key]: value
1137
1463
  };
1138
1464
  return this;
@@ -1149,18 +1475,28 @@ var Aspi2 = class {
1149
1475
  return this.setHeader("Authorization", `Bearer ${token}`);
1150
1476
  }
1151
1477
  /**
1152
- * Use a middleware function to transform requests
1153
- * @param {Middleware<T, U>} fn - The middleware function to apply
1154
- * @returns {Aspi<U>} A new Aspi instance with the applied middleware
1478
+ * Register a request‑transformer middleware.
1479
+ *
1480
+ * The supplied function receives the current request initialization object
1481
+ * (`T`) and must return a request initialization of type `U`. The middleware
1482
+ * is added to the internal middleware chain and will be applied to every
1483
+ * request created by this {@link Aspi} instance.
1484
+ *
1485
+ * @template T - The input request type, extending the current {@link Aspi} request init type.
1486
+ * @template U - The output request type after transformation.
1487
+ * @param {RequestTransformer<T, U>} fn - The middleware function that transforms a request configuration.
1488
+ * @returns {Aspi<U>} A new {@link Aspi} instance typed with the transformed request configuration.
1155
1489
  * @example
1156
1490
  * const api = new Aspi({ baseUrl: 'https://api.example.com' });
1157
- * api.use((req) => {
1158
- * // Add custom headers
1159
- * req.headers = {
1160
- * ...req.headers,
1161
- * 'x-custom-header': 'custom-value'
1491
+ * const apiWithHeaders = api.use((req) => {
1492
+ * // Add custom headers to every request
1493
+ * return {
1494
+ * ...req,
1495
+ * headers: {
1496
+ * ...req.headers,
1497
+ * 'x-custom-header': 'custom-value',
1498
+ * },
1162
1499
  * };
1163
- * return req;
1164
1500
  * });
1165
1501
  */
1166
1502
  use(fn) {
@@ -1292,6 +1628,16 @@ var Aspi2 = class {
1292
1628
  */
1293
1629
  throwable() {
1294
1630
  this.#throwOnError = true;
1631
+ this.#shouldBeResult = false;
1632
+ return this;
1633
+ }
1634
+ /**
1635
+ * Configures the request to return a Result object instead of just the response body.
1636
+ * @returns The Aspi instance with result handling enabled.
1637
+ */
1638
+ withResult() {
1639
+ this.#shouldBeResult = true;
1640
+ this.#throwOnError = false;
1295
1641
  return this;
1296
1642
  }
1297
1643
  };