aspi 1.3.0 → 2.1.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/dist/index.js CHANGED
@@ -75,7 +75,7 @@ var CustomError = class extends Error {
75
75
  }
76
76
  };
77
77
  var isAspiError = (error) => {
78
- return error instanceof AspiError;
78
+ return error instanceof AspiError && error.tag === "aspiError";
79
79
  };
80
80
  var isCustomError = (error) => {
81
81
  return error instanceof CustomError;
@@ -287,19 +287,17 @@ var Request = class {
287
287
  #shouldBeResult = false;
288
288
  #bodySchemaIssues = [];
289
289
  #throwOnError = false;
290
- constructor(method, path, {
291
- requestConfig,
292
- retryConfig,
293
- middlewares,
294
- errorCbs,
295
- throwOnError
296
- }) {
290
+ constructor(method, path, requestOptions) {
297
291
  this.#path = path;
298
- this.#middlewares = middlewares || [];
299
- this.#localRequestInit = { ...requestConfig, method };
300
- this.#retryConfig = retryConfig;
301
- this.#customErrorCbs = errorCbs || {};
302
- this.#throwOnError = throwOnError || false;
292
+ this.#middlewares = requestOptions.middlewares || [];
293
+ this.#localRequestInit = {
294
+ ...requestOptions.requestConfig,
295
+ method
296
+ };
297
+ this.#retryConfig = { ...requestOptions?.retryConfig || {} };
298
+ this.#customErrorCbs = { ...requestOptions?.errorCbs || {} };
299
+ this.#throwOnError = requestOptions.throwOnError || false;
300
+ this.#shouldBeResult = requestOptions.shouldBeResult || false;
303
301
  }
304
302
  /**
305
303
  * Sets the base URL for the request.
@@ -334,18 +332,25 @@ var Request = class {
334
332
  return this;
335
333
  }
336
334
  /**
337
- * Sets multiple headers for the request.
338
- * @param headers An object containing header key-value pairs
339
- * @returns The request instance for chaining
335
+ * Merges the provided headers into the request configuration.
336
+ *
337
+ * @param {HeadersInit} headers - An object or iterable containing header name/value pairs.
338
+ * Existing headers are retained unless a key in this object overwrites them.
339
+ * @returns {this} The current {@link Request} instance for method chaining.
340
+ *
340
341
  * @example
342
+ * // Set common JSON headers
341
343
  * const request = new Request('/users', config);
342
344
  * request.setHeaders({
343
345
  * 'Content-Type': 'application/json',
344
- * 'Accept': 'application/json'
346
+ * 'Accept': 'application/json',
345
347
  * });
346
348
  */
347
349
  setHeaders(headers) {
348
- this.#localRequestInit.headers = headers;
350
+ this.#localRequestInit.headers = {
351
+ ...this.#localRequestInit.headers ?? {},
352
+ ...headers
353
+ };
349
354
  return this;
350
355
  }
351
356
  /**
@@ -359,7 +364,7 @@ var Request = class {
359
364
  */
360
365
  setHeader(key, value) {
361
366
  this.#localRequestInit.headers = {
362
- ...this.#localRequestInit.headers,
367
+ ...this.#localRequestInit.headers ?? {},
363
368
  [key]: value
364
369
  };
365
370
  return this;
@@ -557,29 +562,54 @@ var Request = class {
557
562
  return this.error("internalServerError", "INTERNAL_SERVER_ERROR", cb);
558
563
  }
559
564
  /**
560
- * Sets a custom error handler for a specific HTTP status code.
561
- * @param tag A string identifier for the error type
562
- * @param status The HTTP error status to handle
563
- * @param cb The callback function to handle the error
564
- * @returns The request instance for chaining
565
+ * Register a custom error handler for a specific HTTP status code.
566
+ *
567
+ * When the response matches the provided `status`, the supplied callback `cb`
568
+ * is invoked and its return value is wrapped in a {@link CustomError} with the
569
+ * given `tag`. The method also augments the request's generic `Opts['error']`
570
+ * type so that the custom error is reflected in the resulting `Result`
571
+ * union.
572
+ *
573
+ * @template Tag - A string literal used as the error tag.
574
+ * @template A - The shape of the data returned by the callback.
575
+ *
576
+ * @param {Tag} tag
577
+ * A unique identifier for the custom error. This value becomes the `tag`
578
+ * property of the {@link CustomError} produced by the handler.
579
+ *
580
+ * @param {HttpErrorStatus} status
581
+ * The HTTP status code (e.g. `'BAD_REQUEST'`, `'NOT_FOUND'`) that should
582
+ * trigger the custom handler.
583
+ *
584
+ * @param {CustomErrorCb<TRequest, A>} cb
585
+ * A callback that receives the failing request and response objects and
586
+ * returns an object describing the error payload.
587
+ *
588
+ * @returns {Request<Method, TRequest, Merge<Omit<Opts, 'error'>, { error: { [K in Tag | keyof Opts['error']]: K extends Tag ? CustomError<Tag, A> : Opts['error'][K]; } }>>}
589
+ * The same {@link Request} instance, now typed with the newly added error
590
+ * variant, allowing method‑chaining.
591
+ *
565
592
  * @example
593
+ * ```ts
566
594
  * const request = new Request('/users', config);
595
+ *
596
+ * // Attach a custom handler for a 400 Bad Request response
567
597
  * request
568
- .withResult()
569
- .error('customError', 'BAD_REQUEST', (error) => {
570
- * console.log('Bad request error:', error);
571
- * return {
572
- * message: 'Invalid input',
573
- * details: error.response.responseData
574
- * };
575
- * });
598
+ * withResult()
599
+ * .error('customError', 'BAD_REQUEST', (ctx) => {
600
+ * console.log('Bad request error:', ctx);
601
+ * return {
602
+ * message: 'Invalid input',
603
+ * details: ctx.response.responseData,
604
+ * };
605
+ * });
576
606
  *
577
- * // Later when making the request:
607
+ * // Later, when executing the request:
578
608
  * const result = await request.json();
579
- * if (Result.isErr(result)) {
580
- * if(result.tag === 'customError') {
609
+ * if (Result.isErr(result) && result.tag === 'customError') {
581
610
  * console.log(result.error.data.message); // 'Invalid input'
582
611
  * }
612
+ * ```
583
613
  */
584
614
  error(tag, status, cb) {
585
615
  this.#customErrorCbs[httpErrors[status]] = {
@@ -589,58 +619,128 @@ var Request = class {
589
619
  return this;
590
620
  }
591
621
  /**
592
- * Sets query parameters for the request URL.
593
- * @param params An object containing query parameter key-value pairs
594
- * @returns The request instance for chaining
622
+ * Sets the query parameters for the request URL.
623
+ *
624
+ * Accepts any value that can be passed to the `URLSearchParams` constructor:
625
+ * - an object mapping keys to string values,
626
+ * - an iterable of `[key, value]` tuples,
627
+ * - a raw query string, or
628
+ * - an existing {@link URLSearchParams} instance.
629
+ *
630
+ * The supplied parameters replace any previously defined query parameters.
631
+ *
632
+ * @template T - The concrete type of the supplied parameters.
633
+ * @param {T} params - The query parameters to apply.
634
+ * @returns {this} The request instance for method‑chaining.
635
+ *
595
636
  * @example
596
637
  * const request = new Request('/users', config);
597
638
  * request.setQueryParams({
598
639
  * page: '1',
599
640
  * limit: '10',
600
- * sort: 'desc'
641
+ * sort: 'desc',
601
642
  * });
643
+ *
644
+ * // Using a raw query string
645
+ * request.setQueryParams('page=1&limit=10');
646
+ *
647
+ * // Using an array of entries
648
+ * request.setQueryParams([['page', '1'], ['limit', '10']]);
649
+ *
650
+ * // Using an existing URLSearchParams instance
651
+ * const qp = new URLSearchParams({ page: '1' });
652
+ * request.setQueryParams(qp);
602
653
  */
603
654
  setQueryParams(params) {
604
- this.#queryParams = new URLSearchParams(params);
655
+ let qp;
656
+ if (params instanceof URLSearchParams) {
657
+ qp = new URLSearchParams(params);
658
+ } else if (typeof params === "string") {
659
+ qp = new URLSearchParams(params);
660
+ } else if (Array.isArray(params)) {
661
+ qp = new URLSearchParams();
662
+ for (const entry of params) {
663
+ if (Array.isArray(entry) && entry.length === 2) {
664
+ qp.append(String(entry[0]), String(entry[1]));
665
+ }
666
+ }
667
+ } else if (typeof params === "object" && params !== null) {
668
+ qp = new URLSearchParams();
669
+ for (const [key, value] of Object.entries(
670
+ params
671
+ )) {
672
+ qp.append(key, String(value));
673
+ }
674
+ } else {
675
+ qp = new URLSearchParams();
676
+ }
677
+ this.#queryParams = qp;
605
678
  return this;
606
679
  }
607
680
  /**
608
- * Sets the output schema for validating the response data using Zod.
609
- * @param schema The Zod schema to validate the response
610
- * @returns The request instance for chaining
681
+ * Sets a validation schema for the response data.
682
+ *
683
+ * The provided {@link StandardSchemaV1} schema will be used to validate the
684
+ * response payload when the request is executed. If validation fails, a
685
+ * `parseError` is added to the request's error type.
686
+ *
687
+ * @template TSchema - A type extending {@link StandardSchemaV1}
688
+ * @param schema - The schema used to validate the response data
689
+ * @returns The request instance for chaining with an updated generic type that
690
+ * includes the schema and a possible `parseError` in the error union
691
+ *
611
692
  * @example
693
+ * ```ts
612
694
  * import { z } from 'zod';
613
695
  *
614
696
  * const userSchema = z.object({
615
697
  * id: z.number(),
616
698
  * name: z.string(),
617
- * email: z.string().email()
699
+ * email: z.string().email(),
618
700
  * });
619
701
  *
620
702
  * const request = new Request('/users', config);
621
703
  * const result = await request
622
704
  * .withResult()
623
- * .output(userSchema)
705
+ * .schema(userSchema)
624
706
  * .json();
625
707
  *
626
708
  * if (Result.isOk(result)) {
627
709
  * const user = result.value; // Typed and validated user data
628
710
  * }
711
+ * ```
629
712
  */
630
713
  schema(schema) {
631
714
  this.#schema = schema;
632
715
  return this;
633
716
  }
634
717
  /**
635
- * Sets the request to throw an error if the response status is not successful.
636
- * @returns The request instance for chaining
718
+ * Configures the request to **throw** an exception when the response status
719
+ * indicates a failure (i.e., `!response.ok`). This disables the “Result”
720
+ * mode (`withResult`) and enables “throwable” mode, causing
721
+ * `await request.json()` (or other response helpers) to either resolve with
722
+ * the successful payload **or** reject with an `AspiError`/`CustomError`.
723
+ *
724
+ * Use this when you prefer traditional `try / catch` error handling over
725
+ * the explicit `Result` type returned by {@link withResult}.
726
+ *
727
+ * @returns This {@link Request} instance, now typed with `throwable: true` and
728
+ * `withResult: false` for proper chaining.
729
+ *
637
730
  * @example
731
+ * ```ts
638
732
  * const request = new Request('/users', config);
639
- * const result = await request
640
- * .withResult()
641
- * .throwable()
642
- * .json();
643
733
  *
734
+ * try {
735
+ * const user = await request
736
+ * .throwable() // Enable throwing on HTTP errors
737
+ * .json<User>(); // Will throw if the response is not ok
738
+ * console.log(user);
739
+ * } catch (err) {
740
+ * // err is either AspiError or a CustomError returned by a custom handler
741
+ * console.error('Request failed:', err);
742
+ * }
743
+ * ```
644
744
  */
645
745
  throwable() {
646
746
  this.#shouldBeResult = false;
@@ -648,43 +748,100 @@ var Request = class {
648
748
  return this;
649
749
  }
650
750
  /**
651
- * Executes the request and returns the JSON response.
652
- * @returns A Promise containing the Result type with either successful data or error information
751
+ * Sends the request and parses the response body as JSON.
752
+ *
753
+ * The resolved value of the returned promise varies based on the request mode:
754
+ *
755
+ * - **Result mode** (`withResult()`): resolves to a {@link Result.Result} that
756
+ * contains either an {@link AspiResultOk} with the parsed payload or a union
757
+ * of possible error types (HTTP errors, custom errors, JSON‑parse errors,
758
+ * schema‑validation errors, etc.).
759
+ *
760
+ * - **Throwable mode** (`throwable()`): resolves directly to the successful
761
+ * payload (`AspiPlainResponse`) and throws a {@link AspiError} or
762
+ * {@link CustomError} on failure.
763
+ *
764
+ * - **Default mode** (no explicit mode): resolves to a tuple
765
+ * `[value, error]` where exactly one element is non‑null.
766
+ *
767
+ * @template T - The inferred output type of the response schema (if a schema
768
+ * was supplied via {@link schema}). When no schema is provided `T` defaults
769
+ * to `any`.
770
+ *
771
+ * @returns A promise whose shape depends on the selected mode (see description).
772
+ * In Result mode it is `Result<ResultOk<…>, …>`, in throwable mode it is
773
+ * `AspiPlainResponse<…>`, and otherwise a tuple
774
+ * `[AspiResultOk<…> | null, Error | null]`.
775
+ *
653
776
  * @example
777
+ * // Using the Result API
654
778
  * const request = new Request('/users', config);
655
779
  * const result = await request
656
780
  * .setQueryParams({ id: '123' })
657
- * .
658
- withResult()
659
- * .notFound((error) => ({ message: 'User not found' }))
781
+ * .withResult()
782
+ * .notFound(() => ({ message: 'User not found' }))
660
783
  * .json<User>();
661
784
  *
662
785
  * if (Result.isOk(result)) {
663
- * const user = result.value; // User data
786
+ * // `result.value` has type `User`
787
+ * console.log(result.value);
664
788
  * } else {
665
- * console.error(result.error); // Error handling
789
+ * console.error(result.error);
666
790
  * }
667
791
  */
668
792
  async json() {
669
- const output = await this.#makeRequest(
670
- async (response) => response.json().catch(
793
+ const output = await this.#makeRequest(async (response) => {
794
+ if (response.status === 204 || response.status >= 300 && response.status < 400) {
795
+ return null;
796
+ }
797
+ return response.json().catch(
671
798
  (e) => new CustomError("jsonParseError", {
672
799
  message: e instanceof Error ? e.message : "Failed to parse JSON"
673
800
  })
674
- ),
675
- true
676
- );
801
+ );
802
+ }, true);
677
803
  return this.#mapResponse(output);
678
804
  }
679
805
  /**
680
- * Executes the request and returns the response as plain text.
681
- * @returns A Promise containing the Result type with either successful text data or error information
806
+ * Executes the request and returns the response body as plain text.
807
+ *
808
+ * The method respects the request mode:
809
+ *
810
+ * - **Result mode** (`withResult()`): resolves to a {@link Result.Result}
811
+ * containing either an {@link AspiResultOk} with the text payload or an
812
+ * error variant.
813
+ * - **Throwable mode** (`throwable()`): resolves directly to the text string
814
+ * and throws on error.
815
+ * - **Default mode**: resolves to a tuple `[value, error]` where exactly one
816
+ * element is `null`.
817
+ *
818
+ * @returns {Promise<
819
+ * Opts['withResult'] extends true
820
+ * ? Result.Result<
821
+ * AspiResultOk<TRequest, string>,
822
+ * AspiError<TRequest> |
823
+ * (Opts extends { error: any } ? Opts['error'][keyof Opts['error']] : never)
824
+ * >
825
+ * : Opts['throwable'] extends true
826
+ * ? AspiPlainResponse<TRequest, string>
827
+ * : [
828
+ * AspiResultOk<TRequest, string> | null,
829
+ * (
830
+ * | AspiError<TRequest>
831
+ * | (Opts extends { error: any } ? Opts['error'][keyof Opts['error']] : never)
832
+ * | null
833
+ * )
834
+ * ]
835
+ * }>
836
+ * A promise that resolves according to the selected request mode.
837
+ *
682
838
  * @example
839
+ * ```ts
683
840
  * const request = new Request('/data.txt', config);
684
841
  * const result = await request
685
842
  * .setQueryParams({ version: '1' })
686
843
  * .withResult()
687
- * .notFound((error) => ({ message: 'Text file not found' }))
844
+ * .notFound(() => ({ message: 'Text file not found' }))
688
845
  * .text();
689
846
  *
690
847
  * if (Result.isOk(result)) {
@@ -692,22 +849,58 @@ var Request = class {
692
849
  * } else {
693
850
  * console.error(result.error); // Error handling
694
851
  * }
852
+ * ```
695
853
  */
696
854
  async text() {
697
- const output = await this.#makeRequest(
698
- (response) => response.text()
699
- );
855
+ const output = await this.#makeRequest((response) => {
856
+ return response.text();
857
+ });
700
858
  return this.#mapResponse(output);
701
859
  }
702
860
  /**
703
- * Executes the request and returns the response as a Blob.
704
- * @returns A Promise containing the Result type with either successful Blob data or error information
861
+ * Executes the request and returns the response body as a {@link Blob}.
862
+ *
863
+ * The shape of the returned {@link Promise} depends on the request mode:
864
+ *
865
+ * - **Result mode** (`withResult()`): resolves to a {@link Result.Result} containing
866
+ * either an {@link AspiResultOk} with `Blob` data or an error variant.
867
+ * - **Throwable mode** (`throwable()`): resolves directly to a {@link Blob}
868
+ * (wrapped in {@link AspiPlainResponse}) and throws on failure.
869
+ * - **Default mode**: resolves to a tuple `[value, error]` where exactly one element
870
+ * is `null`.
871
+ *
872
+ * @returns {Promise<
873
+ * Opts['withResult'] extends true
874
+ * ? Result.Result<
875
+ * AspiResultOk<TRequest, Blob>,
876
+ * | AspiError<TRequest>
877
+ * | (Opts extends { error: any }
878
+ * ? Opts['error'][keyof Opts['error']]
879
+ * : never)
880
+ * >
881
+ * : Opts['throwable'] extends true
882
+ * ? AspiPlainResponse<TRequest, Blob>
883
+ * : [
884
+ * AspiResultOk<TRequest, Blob> | null,
885
+ * (
886
+ * | (
887
+ * | AspiError<TRequest>
888
+ * | (Opts extends { error: any }
889
+ * ? Opts['error'][keyof Opts['error']]
890
+ * : never)
891
+ * )
892
+ * | null
893
+ * ),
894
+ * ]
895
+ * }>
896
+ *
705
897
  * @example
898
+ * ```ts
706
899
  * const request = new Request('/image.jpg', config);
707
900
  * const result = await request
708
901
  * .setQueryParams({ size: 'large' })
709
902
  * .withResult()
710
- * .notFound((error) => ({ message: 'Image not found' }))
903
+ * .notFound(() => ({ message: 'Image not found' }))
711
904
  * .blob();
712
905
  *
713
906
  * if (Result.isOk(result)) {
@@ -715,43 +908,107 @@ var Request = class {
715
908
  * } else {
716
909
  * console.error(result.error); // Error handling
717
910
  * }
911
+ * ```
718
912
  */
719
913
  async blob() {
720
914
  const output = await this.#makeRequest((response) => response.blob());
721
915
  return this.#mapResponse(output);
722
916
  }
723
917
  #url() {
724
- const baseUrl = this.#localRequestInit.baseUrl?.replace(/\/+$/, "") ?? "";
725
- const path = this.#path.replace(/^\/+/, "/");
726
- const queryString = this.#queryParams ? `?${this.#queryParams.toString()}` : "";
727
- const url = [baseUrl, path, queryString].filter(Boolean).join("");
918
+ if (this.#path.startsWith("http://") || this.#path.startsWith("https://")) {
919
+ const absolute = new URL(this.#path);
920
+ if (this.#queryParams) {
921
+ for (const [k, v] of this.#queryParams.entries()) {
922
+ absolute.searchParams.append(k, v);
923
+ }
924
+ }
925
+ return absolute.toString();
926
+ }
927
+ const passedBaseUrl = typeof this.#localRequestInit.baseUrl === "string" ? this.#localRequestInit.baseUrl : this.#localRequestInit.baseUrl.toString();
928
+ const base = passedBaseUrl.replace(/\/+$/, "");
929
+ const [rawPathAndQuery, fragment] = this.#path.split("#", 2);
930
+ const [rawPath, existingQuery] = rawPathAndQuery.split("?", 2);
931
+ let path = rawPath || "";
932
+ path = path.replace(/^\/+/, "");
933
+ path = path.replace(/\/{2,}/g, "/");
934
+ path = path.replace(/\/+$/, "");
935
+ if (path) {
936
+ path = "/" + path.replace(/^\/+/, "");
937
+ }
938
+ const qs = new URLSearchParams(existingQuery ?? "");
939
+ if (this.#queryParams) {
940
+ for (const [k, v] of this.#queryParams.entries()) {
941
+ qs.append(k, v);
942
+ }
943
+ }
944
+ const queryString = qs.toString();
945
+ let url = base + path;
946
+ if (queryString) {
947
+ url += `?${queryString}`;
948
+ }
949
+ if (fragment) {
950
+ url += `#${fragment}`;
951
+ }
952
+ url = url.replace(/\/+$/, "");
728
953
  return url;
729
954
  }
730
955
  /**
731
- * Returns the complete URL for the request including base URL, path, and query parameters.
732
- * @returns The complete URL string
956
+ * Returns the fully‑qualified URL that will be used for the request.
957
+ *
958
+ * The URL is constructed from the configured base URL, the request path,
959
+ * and any query parameters added via {@link setQueryParams}.
960
+ *
961
+ * @returns {string} The complete request URL.
962
+ *
733
963
  * @example
964
+ * ```ts
734
965
  * const request = new Request('/users', config);
735
966
  * request.setBaseUrl('https://api.example.com');
736
967
  * request.setQueryParams({ id: '123' });
737
- * console.log(request.url()); // 'https://api.example.com/users?id=123'
968
+ *
969
+ * console.log(request.url());
970
+ * // => 'https://api.example.com/users?id=123'
971
+ * ```
738
972
  */
739
973
  url() {
740
974
  return this.#url();
741
975
  }
742
976
  /**
743
- * Configures the request to return a Result type instead of a tuple.
744
- * @returns The request instance for chaining with Result type return value
977
+ * Switches the request into **Result** mode.
978
+ *
979
+ * In Result mode the response helpers (`json`, `text`, `blob`, …) resolve to a
980
+ * {@link Result.Result} instance instead of the default tuple
981
+ * `[value, error]`. This allows callers to use pattern matching
982
+ * (`Result.isOk`, `Result.isErr`) to handle success and failure.
983
+ *
984
+ * Calling `withResult` disables the “throwable” behaviour (see {@link throwable}).
985
+ *
986
+ * @returns {Request<
987
+ * Method,
988
+ * TRequest,
989
+ * Merge<
990
+ * Omit<Opts, 'withResult' | 'throwable'>,
991
+ * {
992
+ * withResult: true;
993
+ * throwable: false;
994
+ * }
995
+ * >
996
+ * >} The same {@link Request} instance, now typed with `withResult: true` and
997
+ * `throwable: false` for fluent chaining.
998
+ *
745
999
  * @example
1000
+ * ```ts
746
1001
  * const request = new Request('/users', config);
1002
+ *
747
1003
  * const result = await request
748
- * .withResult()
1004
+ * .withResult() // enable Result mode
749
1005
  * .json<User>();
750
1006
  *
751
- * // Returns Result type instead of tuple
752
1007
  * if (Result.isOk(result)) {
753
- * const user = result.value;
1008
+ * // `result.value` is of type `User`
1009
+ * console.log(result.value);
754
1010
  * }
1011
+ * ```
755
1012
  */
756
1013
  withResult() {
757
1014
  this.#throwOnError = false;
@@ -771,6 +1028,9 @@ var Request = class {
771
1028
  return [null, getErrorOrNull(value)];
772
1029
  }
773
1030
  }
1031
+ #isSuccessResponse(response) {
1032
+ return response.ok || response.status >= 300 && response.status < 400;
1033
+ }
774
1034
  async #makeRequest(responseParser, isJson = false) {
775
1035
  if (this.#bodySchemaIssues.length) {
776
1036
  return err(new CustomError("parseError", this.#bodySchemaIssues));
@@ -781,20 +1041,23 @@ var Request = class {
781
1041
  const requestInit = request.requestInit;
782
1042
  const url = this.#url();
783
1043
  let attempts = 1;
784
- let response;
785
- let responseData;
1044
+ let response = new Response(null, {
1045
+ status: 500,
1046
+ statusText: "Internal Server Error"
1047
+ });
1048
+ let responseData = null;
786
1049
  while (attempts <= retries) {
787
1050
  try {
788
1051
  response = await fetch(url, requestInit);
789
1052
  responseData = await responseParser(response);
790
- if (responseData instanceof CustomError) {
1053
+ if (responseData instanceof Error) {
791
1054
  return err(responseData);
792
1055
  }
793
1056
  const retryWhileCondition = retryWhile ? await retryWhile(
794
1057
  request,
795
1058
  this.#makeResponse(response, responseData)
796
1059
  ) : false;
797
- if (response.ok || !retryOn.includes(response.status) && !retryWhileCondition) {
1060
+ if (this.#isSuccessResponse(response) || !retryOn.includes(response.status) && !retryWhileCondition) {
798
1061
  break;
799
1062
  }
800
1063
  if (response.status in this.#customErrorCbs && attempts === retries) {
@@ -817,9 +1080,31 @@ var Request = class {
817
1080
  request,
818
1081
  this.#makeResponse(response, responseData)
819
1082
  ) : retryDelay;
820
- await new Promise((resolve) => setTimeout(resolve, delay));
1083
+ await this.#abortDelay(delay, request);
821
1084
  }
822
1085
  } catch (e) {
1086
+ if (e instanceof Error && e.name === "AbortError") {
1087
+ if (500 in this.#customErrorCbs) {
1088
+ const result = this.#customErrorCbs[response.status].cb({
1089
+ request,
1090
+ response: this.#makeResponse(response, responseData)
1091
+ });
1092
+ return err(
1093
+ new CustomError(
1094
+ // @ts-ignore
1095
+ this.#customErrorCbs[response.status].tag,
1096
+ result
1097
+ )
1098
+ );
1099
+ }
1100
+ return err(
1101
+ new AspiError(
1102
+ e.message,
1103
+ this.#request(),
1104
+ this.#makeResponse(response, responseData)
1105
+ )
1106
+ );
1107
+ }
823
1108
  if (attempts === retries) throw e;
824
1109
  const delay = typeof retryDelay === "function" ? await retryDelay(
825
1110
  retries - attempts - 1,
@@ -827,14 +1112,14 @@ var Request = class {
827
1112
  request,
828
1113
  this.#makeResponse(response, responseData)
829
1114
  ) : retryDelay;
830
- await new Promise((resolve) => setTimeout(resolve, delay));
1115
+ await this.#abortDelay(delay, request);
831
1116
  }
832
1117
  if (onRetry) {
833
1118
  onRetry(request, this.#makeResponse(response, responseData));
834
1119
  }
835
1120
  attempts++;
836
1121
  }
837
- if (!response.ok) {
1122
+ if (!this.#isSuccessResponse(response)) {
838
1123
  if (response.status in this.#customErrorCbs) {
839
1124
  const result = this.#customErrorCbs[response.status].cb({
840
1125
  request,
@@ -904,17 +1189,38 @@ var Request = class {
904
1189
  );
905
1190
  }
906
1191
  }
1192
+ #abortDelay(ms, request) {
1193
+ return new Promise((resolve, reject) => {
1194
+ const timer = setTimeout(resolve, ms);
1195
+ const signal = request.requestInit.signal;
1196
+ if (signal) {
1197
+ if (signal.aborted) {
1198
+ clearTimeout(timer);
1199
+ reject(new DOMException("The user aborted a request.", "AbortError"));
1200
+ } else {
1201
+ const abortHandler = () => {
1202
+ clearTimeout(timer);
1203
+ reject(
1204
+ new DOMException("The user aborted a request.", "AbortError")
1205
+ );
1206
+ };
1207
+ signal.addEventListener("abort", abortHandler, { once: true });
1208
+ }
1209
+ }
1210
+ });
1211
+ }
907
1212
  #request() {
908
1213
  let requestInit = this.#localRequestInit;
909
1214
  for (const middleware of this.#middlewares) {
910
- requestInit = middleware(this.#localRequestInit);
1215
+ requestInit = middleware(requestInit);
911
1216
  }
912
1217
  return {
913
- requestInit,
914
- baseUrl: this.#localRequestInit.baseUrl,
1218
+ requestInit: {
1219
+ ...requestInit,
1220
+ retryConfig: this.#sanitisedRetryConfig()
1221
+ },
915
1222
  path: this.#path,
916
- queryParams: this.#queryParams || null,
917
- retryConfig: this.#sanitisedRetryConfig()
1223
+ queryParams: this.#queryParams || null
918
1224
  };
919
1225
  }
920
1226
  #sanitisedRetryConfig() {
@@ -935,28 +1241,96 @@ var Request = class {
935
1241
  return {
936
1242
  response,
937
1243
  status: response.status,
938
- statusText: getHttpErrorStatus(response.status),
939
- responseData
1244
+ statusLabel: getHttpErrorStatus(response.status),
1245
+ responseData,
1246
+ statusText: response.statusText
940
1247
  };
941
1248
  }
1249
+ /**
1250
+ * Returns the underlying {@link AspiRequest} object that will be used for the fetch call.
1251
+ *
1252
+ * This method does not perform any network activity; it simply builds and returns the
1253
+ * request configuration, including any applied middlewares, query parameters, etc.
1254
+ *
1255
+ * @returns {AspiRequest<TRequest>} The constructed request object.
1256
+ */
1257
+ getRequest() {
1258
+ return this.#request();
1259
+ }
1260
+ /**
1261
+ * Retrieves the registry of custom error callbacks that have been
1262
+ * registered via {@link error}. The returned object maps HTTP status
1263
+ * codes to their corresponding callback functions and tags.
1264
+ *
1265
+ * @returns {ErrorCallbacks} A shallow copy of the internal error callback registry.
1266
+ */
1267
+ getErrorCallbackRegistry() {
1268
+ return { ...this.#customErrorCbs };
1269
+ }
1270
+ /**
1271
+ * Returns whether the request is configured to return a {@link Result.Result}
1272
+ * instead of the default tuple or throwing.
1273
+ *
1274
+ * @returns {boolean} `true` when {@link withResult} has been called.
1275
+ */
1276
+ isResult() {
1277
+ return this.#shouldBeResult;
1278
+ }
1279
+ /**
1280
+ * Returns whether the request is configured to throw on HTTP errors.
1281
+ *
1282
+ * @returns {boolean} `true` when {@link throwable} has been called.
1283
+ */
1284
+ isThrowable() {
1285
+ return this.#throwOnError;
1286
+ }
1287
+ /**
1288
+ * Returns the effective retry configuration for this request, including defaulted values.
1289
+ *
1290
+ * The returned object contains:
1291
+ * - `retries` – number of retry attempts (default 1)
1292
+ * - `retryDelay` – delay between attempts in milliseconds or a function that returns a delay
1293
+ * - `retryOn` – array of HTTP status codes that should trigger a retry
1294
+ * - `retryWhile` – optional custom predicate executed after each response
1295
+ * - `onRetry` – optional callback invoked after a retry attempt
1296
+ *
1297
+ * A shallow copy is returned to avoid accidental mutation of the internal state.
1298
+ *
1299
+ * @returns {{
1300
+ * retries: number;
1301
+ * retryDelay: number | ((attempt: number, maxAttempts: number, request: AspiRequest<TRequest>, response: AspiResponse<any, true>) => number);
1302
+ * retryOn: number[];
1303
+ * retryWhile?: (request: AspiRequest<TRequest>, response: AspiResponse<any, true>) => boolean | Promise<boolean>;
1304
+ * onRetry?: (request: AspiRequest<TRequest>, response: AspiResponse<any, true>) => void;
1305
+ * }}
1306
+ */
1307
+ getRetryConfig() {
1308
+ const cfg = this.#sanitisedRetryConfig();
1309
+ return { ...cfg };
1310
+ }
942
1311
  };
943
1312
 
944
1313
  // src/aspi.ts
945
- var Aspi2 = class {
1314
+ var Aspi = class {
946
1315
  #globalRequestInit;
947
1316
  #middlewares = [];
948
1317
  #retryConfig;
949
1318
  #customErrorCbs = {};
950
1319
  #throwOnError = false;
1320
+ #shouldBeResult = false;
951
1321
  constructor(config) {
952
- const { retryConfig, ...requestConfig } = config;
953
- this.#globalRequestInit = requestConfig;
1322
+ const { retryConfig, ...requestInit } = config;
1323
+ this.#globalRequestInit = requestInit;
954
1324
  this.#retryConfig = retryConfig;
955
1325
  }
956
1326
  /**
957
- * Sets the base URL for all API requests
958
- * @param {string} url - The base URL to be set
959
- * @returns {Aspi} The Aspi instance for chaining
1327
+ * Sets or overrides the base URL used for all subsequent API requests.
1328
+ *
1329
+ * Accepts either a string or a `URL` instance. If a `URL` object is provided,
1330
+ * it is converted to its string representation.
1331
+ *
1332
+ * @param url - The new base URL.
1333
+ * @returns The current {@link Aspi} instance for chaining.
960
1334
  * @example
961
1335
  * const api = new Aspi({ baseUrl: 'https://api.example.com' });
962
1336
  * api.setBaseUrl('https://api.newdomain.com');
@@ -985,17 +1359,17 @@ var Aspi2 = class {
985
1359
  };
986
1360
  return this;
987
1361
  }
988
- #createRequest(method, path, body) {
1362
+ #createRequest(method, path) {
989
1363
  return new Request(method, path, {
990
1364
  requestConfig: {
991
1365
  ...this.#globalRequestInit,
992
- method,
993
- body
1366
+ method
994
1367
  },
995
- retryConfig: this.#retryConfig,
996
1368
  middlewares: this.#middlewares,
997
1369
  errorCbs: this.#customErrorCbs,
998
- throwOnError: this.#throwOnError
1370
+ throwOnError: this.#throwOnError,
1371
+ shouldBeResult: this.#shouldBeResult,
1372
+ retryConfig: this.#retryConfig
999
1373
  });
1000
1374
  }
1001
1375
  /**
@@ -1010,40 +1384,39 @@ var Aspi2 = class {
1010
1384
  return this.#createRequest("GET", path);
1011
1385
  }
1012
1386
  /**
1013
- * Makes a POST request to the specified path with an optional body
1014
- * @param {string} path - The path to make the request to
1015
- * @param {BodyInit} [body] - The body of the request
1016
- * @returns {Request} A Request instance for chaining
1387
+ * Makes a POST request to the specified path.
1388
+ *
1389
+ * @param {string} path - The path to make the request to.
1390
+ * @returns {Request} A Request instance for chaining.
1391
+ *
1017
1392
  * @example
1018
1393
  * const api = new Aspi({ baseUrl: 'https://api.example.com' });
1019
- * const response = await api.post('/users', { name: 'John' }).json();
1394
+ * const response = await api.post('/users').json();
1020
1395
  */
1021
- post(path, body) {
1022
- return this.#createRequest("POST", path, body);
1396
+ post(path) {
1397
+ return this.#createRequest("POST", path);
1023
1398
  }
1024
1399
  /**
1025
- * Makes a PUT request to the specified path with an optional body
1026
- * @param {string} path - The path to make the request to
1027
- * @param {BodyInit} [body] - The body of the request
1028
- * @returns {Request} A Request instance for chaining
1400
+ * Makes a PUT request to the specified path.
1401
+ * @param {string} path - The path to make the request to.
1402
+ * @returns {Request} A Request instance for chaining.
1029
1403
  * @example
1030
1404
  * const api = new Aspi({ baseUrl: 'https://api.example.com' });
1031
- * const response = await api.put('/users/1', { name: 'John' }).json();
1405
+ * const response = await api.put('/users/1').json();
1032
1406
  */
1033
- put(path, body) {
1034
- return this.#createRequest("PUT", path, body);
1407
+ put(path) {
1408
+ return this.#createRequest("PUT", path);
1035
1409
  }
1036
1410
  /**
1037
- * Makes a PATCH request to the specified path with an optional body
1038
- * @param {string} path - The path to make the request to
1039
- * @param {BodyInit} [body] - The body of the request
1040
- * @returns {Request} A Request instance for chaining
1411
+ * Makes a PATCH request to the specified path.
1412
+ * @param {string} path - The path to make the request to.
1413
+ * @returns {Request} A Request instance for chaining.
1041
1414
  * @example
1042
1415
  * const api = new Aspi({ baseUrl: 'https://api.example.com' });
1043
- * const response = await api.patch('/users/1', { name: 'John' }).json();
1416
+ * const response = await api.patch('/users/1').json();
1044
1417
  */
1045
- patch(path, body) {
1046
- return this.#createRequest("PATCH", path, body);
1418
+ patch(path) {
1419
+ return this.#createRequest("PATCH", path);
1047
1420
  }
1048
1421
  /**
1049
1422
  * Makes a DELETE request to the specified path
@@ -1079,8 +1452,9 @@ var Aspi2 = class {
1079
1452
  return this.#createRequest("OPTIONS", path);
1080
1453
  }
1081
1454
  /**
1082
- * Sets multiple headers for all API requests
1083
- * @param {Record<string, string>} headers - An object containing header key-value pairs
1455
+ * Sets multiple headers for all API requests. Existing headers are preserved
1456
+ * and new ones are merged, overriding any duplicate keys.
1457
+ * @param {HeadersInit} headers - An object containing header key-value pairs
1084
1458
  * @returns {Aspi} The Aspi instance for chaining
1085
1459
  * @example
1086
1460
  * const api = new Aspi({ baseUrl: 'https://api.example.com' });
@@ -1090,21 +1464,26 @@ var Aspi2 = class {
1090
1464
  * });
1091
1465
  */
1092
1466
  setHeaders(headers) {
1093
- this.#globalRequestInit.headers = headers;
1467
+ this.#globalRequestInit.headers = {
1468
+ ...this.#globalRequestInit.headers ?? {},
1469
+ ...headers
1470
+ };
1094
1471
  return this;
1095
1472
  }
1096
1473
  /**
1097
- * Sets a single header for all API requests
1098
- * @param {string} key - The header key
1099
- * @param {string} value - The header value
1100
- * @returns {Aspi} The Aspi instance for chaining
1474
+ * Sets a single header for all API requests.
1475
+ *
1476
+ * @param key - The header name.
1477
+ * @param value - The header value.
1478
+ * @returns This {@link Aspi} instance for chaining.
1479
+ *
1101
1480
  * @example
1102
1481
  * const api = new Aspi({ baseUrl: 'https://api.example.com' });
1103
1482
  * api.setHeader('Content-Type', 'application/json');
1104
1483
  */
1105
1484
  setHeader(key, value) {
1106
1485
  this.#globalRequestInit.headers = {
1107
- ...this.#globalRequestInit.headers,
1486
+ ...this.#globalRequestInit.headers ?? {},
1108
1487
  [key]: value
1109
1488
  };
1110
1489
  return this;
@@ -1121,18 +1500,28 @@ var Aspi2 = class {
1121
1500
  return this.setHeader("Authorization", `Bearer ${token}`);
1122
1501
  }
1123
1502
  /**
1124
- * Use a middleware function to transform requests
1125
- * @param {Middleware<T, U>} fn - The middleware function to apply
1126
- * @returns {Aspi<U>} A new Aspi instance with the applied middleware
1503
+ * Register a request‑transformer middleware.
1504
+ *
1505
+ * The supplied function receives the current request initialization object
1506
+ * (`T`) and must return a request initialization of type `U`. The middleware
1507
+ * is added to the internal middleware chain and will be applied to every
1508
+ * request created by this {@link Aspi} instance.
1509
+ *
1510
+ * @template T - The input request type, extending the current {@link Aspi} request init type.
1511
+ * @template U - The output request type after transformation.
1512
+ * @param {RequestTransformer<T, U>} fn - The middleware function that transforms a request configuration.
1513
+ * @returns {Aspi<U>} A new {@link Aspi} instance typed with the transformed request configuration.
1127
1514
  * @example
1128
1515
  * const api = new Aspi({ baseUrl: 'https://api.example.com' });
1129
- * api.use((req) => {
1130
- * // Add custom headers
1131
- * req.headers = {
1132
- * ...req.headers,
1133
- * 'x-custom-header': 'custom-value'
1516
+ * const apiWithHeaders = api.use((req) => {
1517
+ * // Add custom headers to every request
1518
+ * return {
1519
+ * ...req,
1520
+ * headers: {
1521
+ * ...req.headers,
1522
+ * 'x-custom-header': 'custom-value',
1523
+ * },
1134
1524
  * };
1135
- * return req;
1136
1525
  * });
1137
1526
  */
1138
1527
  use(fn) {
@@ -1249,7 +1638,7 @@ var Aspi2 = class {
1249
1638
  * api.internalServerError((req, res) => ({ message: 'Server error occurred' }));
1250
1639
  */
1251
1640
  internalServerError(cb) {
1252
- return this.error("internalServerErrorError", "INTERNAL_SERVER_ERROR", cb);
1641
+ return this.error("internalServerError", "INTERNAL_SERVER_ERROR", cb);
1253
1642
  }
1254
1643
  /**
1255
1644
  * Sets the aspi to throw an error if the response status is not successful.
@@ -1264,11 +1653,21 @@ var Aspi2 = class {
1264
1653
  */
1265
1654
  throwable() {
1266
1655
  this.#throwOnError = true;
1656
+ this.#shouldBeResult = false;
1657
+ return this;
1658
+ }
1659
+ /**
1660
+ * Configures the request to return a Result object instead of just the response body.
1661
+ * @returns The Aspi instance with result handling enabled.
1662
+ */
1663
+ withResult() {
1664
+ this.#shouldBeResult = true;
1665
+ this.#throwOnError = false;
1267
1666
  return this;
1268
1667
  }
1269
1668
  };
1270
1669
  export {
1271
- Aspi2 as Aspi,
1670
+ Aspi,
1272
1671
  AspiError,
1273
1672
  CustomError,
1274
1673
  Request,