@alwatr/fetch 7.1.6 → 8.0.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/src/main.ts ADDED
@@ -0,0 +1,203 @@
1
+ /**
2
+ * @module @alwatr/fetch
3
+ *
4
+ * An enhanced, lightweight, and dependency-free wrapper for the native `fetch`
5
+ * API. It provides modern features like caching strategies, request retries,
6
+ * timeouts, and duplicate request handling.
7
+ */
8
+
9
+ import { _processOptions, handleCacheStrategy_, logger_, cacheSupported } from './core.js';
10
+ import { FetchError } from './error.js';
11
+
12
+ import type { FetchJsonOptions, FetchOptions, FetchResponse } from './type.js';
13
+
14
+ export { cacheSupported };
15
+ export * from './error.js';
16
+ export type * from './type.js';
17
+
18
+ /**
19
+ * An enhanced wrapper for the native `fetch` function.
20
+ *
21
+ * This function extends the standard `fetch` with additional features such as:
22
+ * - **Timeout**: Aborts the request if it takes too long.
23
+ * - **Retry Pattern**: Automatically retries the request on failure (e.g., server errors or network issues).
24
+ * - **Duplicate Request Handling**: Prevents sending multiple identical requests in parallel.
25
+ * - **Cache Strategies**: Provides various caching mechanisms using the browser's Cache API.
26
+ * - **Simplified API**: Offers convenient options for adding query parameters, JSON bodies, and auth tokens.
27
+ *
28
+ * @see {@link FetchOptions} for a detailed list of available options.
29
+ *
30
+ * @param {string} url - The URL to fetch.
31
+ * @param {FetchOptions} options - Optional configuration for the fetch request.
32
+ * @returns {Promise<FetchResponse>} A promise that resolves to a tuple. On
33
+ * success, it returns `[response, null]`. On failure, it returns `[null,
34
+ * FetchError]`.
35
+ *
36
+ * @example
37
+ * ```typescript
38
+ * import {fetch} from '@alwatr/fetch';
39
+ *
40
+ * async function fetchProducts() {
41
+ * const [response, error] = await fetch('/api/products', {
42
+ * queryParams: { limit: 10 },
43
+ * timeout: 5_000,
44
+ * });
45
+ *
46
+ * if (error) {
47
+ * console.error('Request failed:', error.reason);
48
+ * return;
49
+ * }
50
+ *
51
+ * // At this point, response is guaranteed to be valid and ok.
52
+ * const data = await response.json();
53
+ * console.log('Products:', data);
54
+ * }
55
+ *
56
+ * fetchProducts();
57
+ * ```
58
+ */
59
+ export async function fetch(url: string, options: FetchOptions = {}): Promise<FetchResponse> {
60
+ logger_.logMethodArgs?.('fetch', { url, options });
61
+
62
+ const options_ = _processOptions(url, options);
63
+
64
+ try {
65
+ // Start the fetch lifecycle, beginning with the cache strategy.
66
+ const response = await handleCacheStrategy_(options_);
67
+
68
+ if (!response.ok) {
69
+ throw new FetchError('http_error', `HTTP error! status: ${response.status} ${response.statusText}`, response);
70
+ }
71
+
72
+ return [response, null];
73
+ }
74
+ catch (err) {
75
+ let error: FetchError;
76
+
77
+ if (err instanceof FetchError) {
78
+ error = err;
79
+
80
+ if (error.response !== undefined && error.data === undefined) {
81
+ const bodyText = await error.response.text().catch(() => '');
82
+
83
+ if (bodyText.trim().length > 0) {
84
+ try {
85
+ // Try to parse as JSON
86
+ error.data = JSON.parse(bodyText);
87
+ }
88
+ catch {
89
+ error.data = bodyText;
90
+ }
91
+ }
92
+ }
93
+ }
94
+ else if (err instanceof Error) {
95
+ if (err.name === 'AbortError') {
96
+ error = new FetchError('aborted', err.message);
97
+ }
98
+ else {
99
+ error = new FetchError('network_error', err.message);
100
+ }
101
+ }
102
+ else {
103
+ error = new FetchError('unknown_error', String(err ?? 'unknown_error'));
104
+ }
105
+
106
+ logger_.error('fetch', error.reason, { error });
107
+ return [null, error];
108
+ }
109
+ }
110
+
111
+ fetch.version = __package_version__;
112
+
113
+ /**
114
+ * An enhanced wrapper for the native `fetch` function that automatically parses JSON responses.
115
+ *
116
+ * This function extends the standard `fetch` with the same features (timeout, retry, caching, etc.)
117
+ * and automatically parses the response body as JSON. It returns a tuple with the parsed data or an error.
118
+ *
119
+ * @template T - The expected type of the JSON response data.
120
+ *
121
+ * @param {string} url - The URL to fetch.
122
+ * @param {FetchOptions} options - Optional configuration for the fetch request.
123
+ * @returns {Promise<[T, null] | [null, FetchError]>} A promise that resolves to a tuple.
124
+ * On success, it returns `[data, null]` where data is the parsed JSON.
125
+ * On failure, it returns `[null, FetchError]`.
126
+ *
127
+ * @example
128
+ * ```typescript
129
+ * import {fetchJson} from '@alwatr/fetch';
130
+ *
131
+ * interface Product {
132
+ * ok: true;
133
+ * id: number;
134
+ * name: string;
135
+ * price: number;
136
+ * }
137
+ *
138
+ * async function getProduct(id: number) {
139
+ * const [data, error] = await fetchJson<Product>(`/api/products/${id}`, {
140
+ * timeout: 5_000,
141
+ * cacheStrategy: 'cache_first',
142
+ * requireResponseJsonWithOkTrue: true,
143
+ * });
144
+ *
145
+ * if (error) {
146
+ * console.error('Failed to fetch product:', error.reason);
147
+ * return;
148
+ * }
149
+ *
150
+ * // data is now typed as Product and guaranteed to be valid
151
+ * console.log('Product name:', data.name);
152
+ * }
153
+ * ```
154
+ */
155
+ export async function fetchJson<T extends JsonObject = JsonObject>(
156
+ url: string,
157
+ options: FetchJsonOptions = {},
158
+ ): Promise<[T, null] | [null, FetchError]> {
159
+ logger_.logMethodArgs?.('fetchJson', { url, options });
160
+
161
+ const [response, error] = await fetch(url, options);
162
+
163
+ if (error) {
164
+ return [null, error];
165
+ }
166
+
167
+ const bodyText = await response.text().catch(() => '');
168
+ if (bodyText.trim().length === 0) {
169
+ const parseError = new FetchError(
170
+ 'json_parse_error',
171
+ 'Response body is empty, cannot parse JSON',
172
+ response,
173
+ bodyText,
174
+ );
175
+ logger_.error('fetchJson', parseError.reason, { error: parseError });
176
+ return [null, parseError];
177
+ }
178
+
179
+ try {
180
+ const data = JSON.parse(bodyText) as T;
181
+ if (options.requireJsonResponseWithOkTrue && data.ok !== true) {
182
+ const parseError = new FetchError(
183
+ 'json_response_error',
184
+ 'Response JSON "ok" property is not true',
185
+ response,
186
+ data,
187
+ );
188
+ logger_.error('fetchJson', parseError.reason, { error: parseError });
189
+ return [null, parseError];
190
+ }
191
+ return [data, null];
192
+ }
193
+ catch (err) {
194
+ const parseError = new FetchError(
195
+ 'json_parse_error',
196
+ err instanceof Error ? err.message : 'Failed to parse JSON response',
197
+ response,
198
+ bodyText,
199
+ );
200
+ logger_.error('fetchJson', parseError.reason, { error: parseError });
201
+ return [null, parseError];
202
+ }
203
+ }
package/src/type.ts ADDED
@@ -0,0 +1,157 @@
1
+ import type {FetchError} from './error.js';
2
+ import type {HttpMethod, HttpRequestHeaders} from '@alwatr/http-primer';
3
+ import type {Duration} from '@alwatr/parse-duration';
4
+ import type {} from '@alwatr/type-helper';
5
+ import type {} from '@alwatr/nano-build';
6
+
7
+ /**
8
+ * A dictionary of query parameters.
9
+ * Keys are strings, and values can be strings, numbers, or booleans.
10
+ */
11
+ export type QueryParams = DictionaryOpt<string | number | boolean>;
12
+
13
+ /**
14
+ * Defines the caching strategy for a fetch request.
15
+ *
16
+ * - `network_only`: Always fetches from the network.
17
+ * - `network_first`: Tries the network first, then falls back to the cache.
18
+ * - `cache_only`: Only fetches from the cache; fails if not found.
19
+ * - `cache_first`: Tries the cache first, then falls back to the network.
20
+ * - `update_cache`: Fetches from the network and updates the cache.
21
+ * - `stale_while_revalidate`: Serves from cache while revalidating in the background.
22
+ */
23
+ export type CacheStrategy =
24
+ | 'network_only'
25
+ | 'network_first'
26
+ | 'cache_only'
27
+ | 'cache_first'
28
+ | 'update_cache'
29
+ | 'stale_while_revalidate';
30
+
31
+ /**
32
+ * Defines the caching behavior for identical, parallel requests.
33
+ * - `never`: No deduplication is performed.
34
+ * - `always`: The response is cached for the lifetime of the application.
35
+ * - `until_load`: The response is cached until the initial request is complete.
36
+ * - `auto`: Automatically selects the best strategy (`until_load` in browsers, `always` otherwise).
37
+ */
38
+ export type CacheDuplicate = 'never' | 'always' | 'until_load' | 'auto';
39
+
40
+ /**
41
+ * Defines the options for an Alwatr fetch request.
42
+ */
43
+ export interface AlwatrFetchOptions_ {
44
+ /**
45
+ * The HTTP request method.
46
+ * @default 'GET'
47
+ */
48
+ method: HttpMethod;
49
+
50
+ /**
51
+ * An object of request headers.
52
+ */
53
+ headers: HttpRequestHeaders & DictionaryReq<string>;
54
+
55
+ /**
56
+ * Request timeout duration. Can be a number (milliseconds) or a string (e.g., '5s').
57
+ * Set to `0` to disable.
58
+ * @default '8s'
59
+ */
60
+ timeout: Duration;
61
+
62
+ /**
63
+ * Number of times to retry a failed request.
64
+ * Retries occur on network errors, timeouts, or 5xx server responses.
65
+ * @default 3
66
+ */
67
+ retry: number;
68
+
69
+ /**
70
+ * Delay before each retry attempt. Can be a number (milliseconds) or a string (e.g., '2s').
71
+ * @default '1s'
72
+ */
73
+ retryDelay: Duration;
74
+
75
+ /**
76
+ * Strategy for handling duplicate parallel requests.
77
+ * Uniqueness is determined by method, URL, and request body.
78
+ * @default 'never'
79
+ */
80
+ removeDuplicate: CacheDuplicate;
81
+
82
+ /**
83
+ * The caching strategy to use for the request.
84
+ * Requires a browser environment with Cache API support.
85
+ * @default 'network_only'
86
+ */
87
+ cacheStrategy: CacheStrategy;
88
+
89
+ /**
90
+ * A callback function that is executed with the fresh response when using the 'stale_while_revalidate' cache strategy.
91
+ */
92
+ revalidateCallback?: (response: Response) => void | Promise<void>;
93
+
94
+ /**
95
+ * Custom name for the CacheStorage instance.
96
+ * @default 'fetch_cache'
97
+ */
98
+ cacheStorageName: string;
99
+
100
+ /**
101
+ * A JavaScript object to be sent as the request's JSON body.
102
+ * Automatically sets the 'Content-Type' header to 'application/json'.
103
+ */
104
+ bodyJson?: JsonValue;
105
+
106
+ /**
107
+ * A JavaScript object of query parameters to be appended to the request URL.
108
+ */
109
+ queryParams?: QueryParams;
110
+
111
+ /**
112
+ * A bearer token to be added to the 'Authorization' header.
113
+ */
114
+ bearerToken?: string;
115
+
116
+ /**
117
+ * Alwatr-specific authentication credentials.
118
+ */
119
+ alwatrAuth?: {
120
+ userId: string;
121
+ userToken: string;
122
+ };
123
+ }
124
+
125
+ /**
126
+ * Combined type for fetch options, including standard RequestInit properties.
127
+ */
128
+ export type FetchOptions = Partial<AlwatrFetchOptions_> & Omit<RequestInit, 'headers'>;
129
+
130
+ export type FetchJsonOptions = FetchOptions & {requireJsonResponseWithOkTrue?: true};
131
+
132
+ /**
133
+ * Represents the tuple returned by the fetch function.
134
+ * On success, it's `[Response, null]`. On failure, it's `[null, FetchError]`.
135
+ */
136
+ export type FetchResponse = Promise<[Response, null] | [null, FetchError]>;
137
+
138
+ /**
139
+ * Defines the specific reason for a fetch failure.
140
+ * - `http_error`: An HTTP error status was received (e.g., 404, 500).
141
+ * - `timeout`: The request was aborted due to a timeout.
142
+ * - `cache_not_found`: The requested resource was not found in the cache_only strategy.
143
+ * - `network_error`: A generic network-level error occurred.
144
+ * - `aborted`: The request was aborted by a user-provided signal.
145
+ * - `json_parse_error`: The response body could not be parsed as JSON.
146
+ * - `json_response_error`: The response JSON "ok" property is not true.
147
+ * - `unknown_error`: An unspecified error occurred.
148
+ */
149
+ export type FetchErrorReason =
150
+ | 'http_error'
151
+ | 'cache_not_found'
152
+ | 'timeout'
153
+ | 'network_error'
154
+ | 'aborted'
155
+ | 'json_parse_error'
156
+ | 'json_response_error'
157
+ | 'unknown_error';