@alwatr/fetch 10.0.3 → 10.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/src/cache.ts ADDED
@@ -0,0 +1,146 @@
1
+ import {FetchError} from './error.js';
2
+ import {handleRemoveDuplicate_} from './dedupe.js';
3
+ import {logger_} from './options.js';
4
+ import {delay} from '@alwatr/delay';
5
+
6
+ import type {InternalFetchOptions_} from './type.js';
7
+
8
+ /**
9
+ * Executes the caching lifecycle according to `cacheStrategy`.
10
+ *
11
+ * Interacts safely with Cache API:
12
+ * - Falls back to network when Cache API is unavailable or throws.
13
+ * - Guards against caching non-GET requests.
14
+ * - Clones responses before storing to keep response bodies consumable.
15
+ *
16
+ * @param options - Processed internal fetch options.
17
+ * @returns A promise resolving to a cached or freshly fetched `Response`.
18
+ * @internal
19
+ */
20
+ export async function handleCacheStrategy_(options: InternalFetchOptions_): Promise<Response> {
21
+ if (options.cacheStrategy === 'network_only') {
22
+ return handleRemoveDuplicate_(options);
23
+ }
24
+
25
+ DEV_MODE && logger_.logMethod?.('handleCacheStrategy_');
26
+
27
+ let cacheStorage: Cache;
28
+ try {
29
+ cacheStorage = await caches.open(options.cacheStorageName);
30
+ } catch (err) {
31
+ DEV_MODE && logger_.accident('handleCacheStrategy_', 'cache_open_failed', {err});
32
+ options.cacheStrategy = 'network_only';
33
+ return handleRemoveDuplicate_(options);
34
+ }
35
+
36
+ const request = new Request(options.url, options);
37
+
38
+ switch (options.cacheStrategy) {
39
+ case 'cache_first': {
40
+ try {
41
+ const cachedResponse = await cacheStorage.match(request);
42
+ if (cachedResponse != null) {
43
+ return cachedResponse;
44
+ }
45
+ } catch (err) {
46
+ DEV_MODE && logger_.accident('handleCacheStrategy_', 'cache_match_failed', {err});
47
+ }
48
+
49
+ const response = await handleRemoveDuplicate_(options);
50
+ if (response.ok) {
51
+ try {
52
+ await cacheStorage.put(request, response.clone());
53
+ } catch {
54
+ // ignore cache put failures
55
+ }
56
+ }
57
+ return response;
58
+ }
59
+
60
+ case 'cache_only': {
61
+ let cachedResponse: Response | undefined;
62
+ try {
63
+ cachedResponse = await cacheStorage.match(request);
64
+ } catch (err) {
65
+ DEV_MODE && logger_.accident('handleCacheStrategy_', 'cache_only_match_failed', {err});
66
+ }
67
+
68
+ if (cachedResponse == null) {
69
+ throw new FetchError('cache_not_found', 'Resource not found in cache');
70
+ }
71
+ return cachedResponse;
72
+ }
73
+
74
+ case 'network_first': {
75
+ try {
76
+ const networkResponse = await handleRemoveDuplicate_(options);
77
+ if (networkResponse.ok) {
78
+ try {
79
+ await cacheStorage.put(request, networkResponse.clone());
80
+ } catch {
81
+ // ignore cache put failures
82
+ }
83
+ }
84
+ return networkResponse;
85
+ } catch (err) {
86
+ try {
87
+ const cachedResponse = await cacheStorage.match(request);
88
+ if (cachedResponse != null) {
89
+ return cachedResponse;
90
+ }
91
+ } catch {
92
+ // ignore cache match error and throw original error
93
+ }
94
+ throw err;
95
+ }
96
+ }
97
+
98
+ case 'update_cache': {
99
+ const networkResponse = await handleRemoveDuplicate_(options);
100
+ if (networkResponse.ok) {
101
+ try {
102
+ await cacheStorage.put(request, networkResponse.clone());
103
+ } catch {
104
+ // ignore cache put failures
105
+ }
106
+ }
107
+ return networkResponse;
108
+ }
109
+
110
+ case 'stale_while_revalidate': {
111
+ let cachedResponse: Response | undefined;
112
+ try {
113
+ cachedResponse = await cacheStorage.match(request);
114
+ } catch {
115
+ // ignore cache match error
116
+ }
117
+
118
+ const fetchedResponsePromise = handleRemoveDuplicate_(options).then(async (networkResponse) => {
119
+ if (networkResponse.ok) {
120
+ try {
121
+ await cacheStorage.put(request, networkResponse.clone());
122
+ } catch {
123
+ // ignore cache put failures
124
+ }
125
+ if (typeof options.revalidateCallback === 'function') {
126
+ const callback = options.revalidateCallback;
127
+ const revalidatePayload = networkResponse.clone();
128
+ await delay.nextMacrotask();
129
+ try {
130
+ await callback(revalidatePayload);
131
+ } catch (err) {
132
+ DEV_MODE && logger_.accident('handleCacheStrategy_', 'revalidate_callback_failed', {err});
133
+ }
134
+ }
135
+ }
136
+ return networkResponse;
137
+ });
138
+
139
+ return cachedResponse ?? fetchedResponsePromise;
140
+ }
141
+
142
+ default: {
143
+ return handleRemoveDuplicate_(options);
144
+ }
145
+ }
146
+ }
package/src/dedupe.ts ADDED
@@ -0,0 +1,64 @@
1
+ import {logger_} from './options.js';
2
+ import {handleRetryPattern_} from './retry.js';
3
+
4
+ import type {InternalFetchOptions_} from './type.js';
5
+
6
+ /**
7
+ * Storage for tracking in-flight duplicate requests.
8
+ */
9
+ const duplicateRequestStorage_: Map<string, Promise<Response>> = new Map();
10
+
11
+ /**
12
+ * Computes a secure cache key for request deduplication.
13
+ * Includes method, full URL, authorization header, and request body.
14
+ *
15
+ * @param options - Processed internal fetch options.
16
+ * @returns Unique string identifier for the request intent.
17
+ */
18
+ export function computeDedupeKey_(options: InternalFetchOptions_): string {
19
+ const bodyString = typeof options.body === 'string' ? options.body : '';
20
+ const auth = options.headers['authorization'] ?? '';
21
+ return `${options.method} ${options.url} [auth:${auth}] [body:${bodyString}]`;
22
+ }
23
+
24
+ /**
25
+ * Handles duplicate parallel request coalescing.
26
+ *
27
+ * If an identical request is already in-flight, returns a cloned response of the existing
28
+ * promise to avoid redundant network round-trips.
29
+ *
30
+ * @param options - Processed internal fetch options.
31
+ * @returns A promise resolving to an independent cloned `Response`.
32
+ * @internal
33
+ */
34
+ export async function handleRemoveDuplicate_(options: InternalFetchOptions_): Promise<Response> {
35
+ if (options.removeDuplicate === 'never') {
36
+ return handleRetryPattern_(options);
37
+ }
38
+
39
+ DEV_MODE && logger_.logMethod?.('handleRemoveDuplicate_');
40
+
41
+ const cacheKey = computeDedupeKey_(options);
42
+
43
+ let requestAsync = duplicateRequestStorage_.get(cacheKey);
44
+ if (requestAsync == null) {
45
+ requestAsync = handleRetryPattern_(options);
46
+ duplicateRequestStorage_.set(cacheKey, requestAsync);
47
+ }
48
+
49
+ try {
50
+ const response = await requestAsync;
51
+
52
+ // Clean up stored promise for 'until_load' or failed responses
53
+ if (!response.ok || options.removeDuplicate === 'until_load') {
54
+ duplicateRequestStorage_.delete(cacheKey);
55
+ }
56
+
57
+ // Return a clone so every concurrent caller can independently consume the body
58
+ return response.clone();
59
+ } catch (err) {
60
+ // If request failed, remove from storage immediately
61
+ duplicateRequestStorage_.delete(cacheKey);
62
+ throw err;
63
+ }
64
+ }
package/src/error.ts CHANGED
@@ -1,43 +1,95 @@
1
- import type {JsonObject} from '@alwatr/type-helper';
1
+ import {HttpStatusCodes} from '@alwatr/http-primer';
2
2
  import type {FetchErrorReason} from './type.js';
3
3
 
4
+ /**
5
+ * Maps an HTTP status code to a semantic `FetchErrorReason`.
6
+ *
7
+ * @param status - The HTTP response status code.
8
+ * @returns The mapped `FetchErrorReason`.
9
+ *
10
+ * @example
11
+ * ```typescript
12
+ * httpStatusToErrorReason(401); // 'unauthorized'
13
+ * httpStatusToErrorReason(404); // 'not_found'
14
+ * httpStatusToErrorReason(500); // 'server_error'
15
+ * ```
16
+ */
17
+ export function httpStatusToErrorReason(status: number): FetchErrorReason {
18
+ switch (status) {
19
+ case HttpStatusCodes.Error_Client_400_Bad_Request:
20
+ return 'bad_request';
21
+ case HttpStatusCodes.Error_Client_401_Unauthorized:
22
+ return 'unauthorized';
23
+ case HttpStatusCodes.Error_Client_403_Forbidden:
24
+ return 'forbidden';
25
+ case HttpStatusCodes.Error_Client_404_Not_Found:
26
+ return 'not_found';
27
+ case HttpStatusCodes.Error_Client_408_Request_Timeout:
28
+ return 'request_timeout';
29
+ case HttpStatusCodes.Error_Client_409_Conflict:
30
+ return 'conflict';
31
+ case HttpStatusCodes.Error_Client_413_Payload_Too_Large:
32
+ return 'payload_too_large';
33
+ case HttpStatusCodes.Error_Client_422_Unprocessable_Entity:
34
+ return 'unprocessable_content';
35
+ case HttpStatusCodes.Error_Client_429_Too_Many_Requests:
36
+ return 'rate_limited';
37
+ default:
38
+ if (status >= 500 && status < 600) {
39
+ return 'server_error';
40
+ }
41
+ return 'http_error';
42
+ }
43
+ }
44
+
4
45
  /**
5
46
  * Custom error class for fetch-related failures.
6
47
  *
7
- * This error is thrown when a fetch request fails, either due to a network issue
8
- * or an HTTP error status (i.e., `response.ok` is `false`). It enriches the
9
- * standard `Error` object with the `response` and the parsed `data` from the
10
- * response body, allowing for more detailed error handling.
48
+ * This error is returned in the `[null, FetchError]` tuple when a request fails.
49
+ * It enriches the standard `Error` with the `response`, the parsed `data` body,
50
+ * and the specific `reason` enum.
11
51
  *
12
52
  * @example
13
53
  * ```typescript
14
54
  * const [response, error] = await fetch('/api/endpoint');
15
55
  * if (error) {
16
- * console.error(`Request failed with status ${error.response?.status}`);
17
- * console.error('Server response:', error.data);
56
+ * if (error.reason === 'unauthorized') {
57
+ * redirectToLogin();
58
+ * } else if (error.reason === 'server_error') {
59
+ * showToast('Server unavailable, please try again later');
60
+ * }
18
61
  * }
19
62
  * ```
20
63
  */
21
64
  export class FetchError extends Error {
22
65
  /**
23
- * The original `Response` object.
24
- * This is useful for accessing headers and other response metadata.
25
- * It will be `undefined` for non-HTTP errors like network failures or timeouts.
66
+ * The original `Response` object, if one was received.
26
67
  */
27
68
  public response?: Response;
28
69
 
29
70
  /**
30
- * The parsed body of the error response, typically a JSON object.
31
- * It will be `undefined` for non-HTTP errors.
71
+ * The parsed body of the error response, if available (JSON object, string, etc.).
32
72
  */
33
- public data?: JsonObject | string;
73
+ public data?: unknown;
34
74
 
35
75
  /**
36
- * The specific reason for the fetch failure.
76
+ * The specific semantic reason for the fetch failure.
37
77
  */
38
78
  public reason: FetchErrorReason;
39
79
 
40
- constructor(reason: FetchErrorReason, message: string, response?: Response, data?: JsonObject | string) {
80
+ /**
81
+ * Helper getter for the HTTP status code.
82
+ */
83
+ get status(): number | undefined {
84
+ return this.response?.status;
85
+ }
86
+
87
+ /**
88
+ * Always `false` to indicate error status.
89
+ */
90
+ readonly ok: false = false;
91
+
92
+ constructor(reason: FetchErrorReason, message: string, response?: Response, data?: unknown) {
41
93
  super(message);
42
94
  this.name = 'FetchError';
43
95
  this.reason = reason;
package/src/fetch.ts ADDED
@@ -0,0 +1,169 @@
1
+ import {FetchError, httpStatusToErrorReason} from './error.js';
2
+ import {handleCacheStrategy_} from './cache.js';
3
+ import {processOptions_, logger_} from './options.js';
4
+
5
+ import type {FetchJsonOptions, FetchJsonResponse, FetchOptions, FetchResponse} from './type.js';
6
+
7
+ /**
8
+ * An enhanced wrapper for the native `fetch` function.
9
+ *
10
+ * Provides:
11
+ * - **Deterministic Errors**: Semantic `FetchError` reasons (e.g. `unauthorized`, `forbidden`, `not_found`, `server_error`, `timeout`, `aborted`, `rate_limited`).
12
+ * - **Go-Style Tuple Return**: Never throws, returns `[response, null]` on success or `[null, FetchError]` on failure.
13
+ * - **Automatic Timeout**: Aborts the request if it exceeds `timeout` duration.
14
+ * - **Configurable Retry**: Automatically retries transient 5xx, 429, 408, or network errors with `retryDelay` and `Retry-After` support.
15
+ * - **Parallel Deduplication**: Collapses identical concurrent in-flight requests.
16
+ * - **Cache Strategies**: Integrates with Cache API (`cache_first`, `stale_while_revalidate`, etc.).
17
+ * - **Isolated Headers & Query Params**: Safely formats query parameters and authorization credentials without mutating caller objects.
18
+ *
19
+ * @param url - The URL to fetch.
20
+ * @param options - Configuration options for the fetch request.
21
+ * @returns A promise resolving to `[Response, null]` on success, or `[null, FetchError]` on failure.
22
+ *
23
+ * @example
24
+ * ```typescript
25
+ * import {fetch} from '@alwatr/fetch';
26
+ *
27
+ * const [response, error] = await fetch('/api/products', {
28
+ * queryParams: { limit: 10 },
29
+ * timeout: '5s',
30
+ * });
31
+ *
32
+ * if (error) {
33
+ * if (error.reason === 'not_found') {
34
+ * console.warn('Product not found');
35
+ * }
36
+ * return;
37
+ * }
38
+ *
39
+ * const data = await response.json();
40
+ * ```
41
+ */
42
+ export async function fetch(url: string, options: FetchOptions = {}): Promise<FetchResponse> {
43
+ const options_ = processOptions_(url, options);
44
+ DEV_MODE && logger_.logMethodArgs?.('fetch', options_);
45
+
46
+ try {
47
+ const response = await handleCacheStrategy_(options_);
48
+
49
+ if (!response.ok) {
50
+ const reason = httpStatusToErrorReason(response.status);
51
+ throw new FetchError(reason, `HTTP error! status: ${response.status} ${response.statusText}`, response);
52
+ }
53
+
54
+ return [response, null];
55
+ } catch (err) {
56
+ let error: FetchError;
57
+
58
+ if (err instanceof FetchError) {
59
+ error = err;
60
+
61
+ if (error.response !== undefined && error.data === undefined) {
62
+ const bodyText = await error.response.text().catch(() => '');
63
+
64
+ if (bodyText.trim().length > 0) {
65
+ try {
66
+ error.data = JSON.parse(bodyText);
67
+ } catch {
68
+ error.data = bodyText;
69
+ }
70
+ }
71
+ }
72
+ } else if (err instanceof Error) {
73
+ if (err.name === 'AbortError') {
74
+ error = new FetchError('aborted', err.message);
75
+ } else {
76
+ error = new FetchError('network_error', err.message);
77
+ }
78
+ } else {
79
+ error = new FetchError('unknown_error', String(err ?? 'unknown_error'));
80
+ }
81
+
82
+ DEV_MODE && logger_.accident('fetch', error.reason, {error});
83
+ return [null, error];
84
+ }
85
+ }
86
+
87
+ /**
88
+ * An enhanced wrapper for `fetch` that automatically parses JSON responses.
89
+ *
90
+ * Accepts unconstrained generic interfaces, DTOs, and arrays without requiring index signatures.
91
+ *
92
+ * @template T - The expected type of the JSON response payload.
93
+ *
94
+ * @param url - The URL to fetch.
95
+ * @param options - Configuration options for the fetch request.
96
+ * @returns A promise resolving to `[data, null]` where data is typed as `T`, or `[null, FetchError]`.
97
+ *
98
+ * @example
99
+ * ```typescript
100
+ * import {fetchJson} from '@alwatr/fetch';
101
+ *
102
+ * interface User {
103
+ * id: string;
104
+ * name: string;
105
+ * }
106
+ *
107
+ * const [users, error] = await fetchJson<User[]>('/api/users');
108
+ * if (error) {
109
+ * console.error('Failed to load users:', error.reason);
110
+ * return;
111
+ * }
112
+ * console.log('Users count:', users.length);
113
+ * ```
114
+ */
115
+ export async function fetchJson<T = unknown>(
116
+ url: string,
117
+ options: FetchJsonOptions = {},
118
+ ): Promise<FetchJsonResponse<T>> {
119
+ DEV_MODE && logger_.logMethod?.('fetchJson');
120
+
121
+ const [response, error] = await fetch(url, options);
122
+
123
+ if (error) {
124
+ return [null, error];
125
+ }
126
+
127
+ const bodyText = await response.text().catch(() => '');
128
+ if (bodyText.trim().length === 0) {
129
+ const parseError = new FetchError(
130
+ 'json_parse_error',
131
+ 'Response body is empty, cannot parse JSON',
132
+ response,
133
+ bodyText,
134
+ );
135
+ DEV_MODE && logger_.accident('fetchJson', parseError.reason, {error: parseError});
136
+ return [null, parseError];
137
+ }
138
+
139
+ try {
140
+ const data = JSON.parse(bodyText) as T;
141
+
142
+ if (
143
+ options.requireJsonResponseWithOkTrue
144
+ && (typeof data !== 'object' || data === null || (data as Record<string, unknown>).ok !== true)
145
+ ) {
146
+ const parseError = new FetchError(
147
+ 'json_response_error',
148
+ 'Response JSON "ok" property is not true',
149
+ response,
150
+ data,
151
+ );
152
+ DEV_MODE && logger_.accident('fetchJson', parseError.reason, {error: parseError});
153
+ return [null, parseError];
154
+ }
155
+
156
+ return [data, null];
157
+ } catch (err) {
158
+ const parseError = new FetchError(
159
+ 'json_parse_error',
160
+ err instanceof Error ? err.message : 'Failed to parse JSON response',
161
+ response,
162
+ bodyText,
163
+ );
164
+ DEV_MODE && logger_.accident('fetchJson', parseError.reason, {error: parseError});
165
+ return [null, parseError];
166
+ }
167
+ }
168
+
169
+ fetchJson.version = fetch.version = __package_version__;
package/src/main.ts CHANGED
@@ -6,193 +6,6 @@
6
6
  * timeouts, and duplicate request handling.
7
7
  */
8
8
 
9
- import type {JsonObject} from '@alwatr/type-helper';
10
- import {_processOptions, handleCacheStrategy_, logger_, cacheSupported} from './core.js';
11
- import {FetchError} from './error.js';
12
-
13
- import type {FetchJsonOptions, FetchOptions, FetchResponse} from './type.js';
14
-
15
- export {cacheSupported};
16
9
  export * from './error.js';
10
+ export * from './fetch.js';
17
11
  export type * from './type.js';
18
-
19
- /**
20
- * An enhanced wrapper for the native `fetch` function.
21
- *
22
- * This function extends the standard `fetch` with additional features such as:
23
- * - **Timeout**: Aborts the request if it takes too long.
24
- * - **Retry Pattern**: Automatically retries the request on failure (e.g., server errors or network issues).
25
- * - **Duplicate Request Handling**: Prevents sending multiple identical requests in parallel.
26
- * - **Cache Strategies**: Provides various caching mechanisms using the browser's Cache API.
27
- * - **Simplified API**: Offers convenient options for adding query parameters, JSON bodies, and auth tokens.
28
- *
29
- * @see {@link FetchOptions} for a detailed list of available options.
30
- *
31
- * @param {string} url - The URL to fetch.
32
- * @param {FetchOptions} options - Optional configuration for the fetch request.
33
- * @returns {Promise<FetchResponse>} A promise that resolves to a tuple. On
34
- * success, it returns `[response, null]`. On failure, it returns `[null,
35
- * FetchError]`.
36
- *
37
- * @example
38
- * ```typescript
39
- * import {fetch} from '@alwatr/fetch';
40
- *
41
- * async function fetchProducts() {
42
- * const [response, error] = await fetch('/api/products', {
43
- * queryParams: { limit: 10 },
44
- * timeout: 5_000,
45
- * });
46
- *
47
- * if (error) {
48
- * console.error('Request failed:', error.reason);
49
- * return;
50
- * }
51
- *
52
- * // At this point, response is guaranteed to be valid and ok.
53
- * const data = await response.json();
54
- * console.log('Products:', data);
55
- * }
56
- *
57
- * fetchProducts();
58
- * ```
59
- */
60
- export async function fetch(url: string, options: FetchOptions = {}): Promise<FetchResponse> {
61
- DEV_MODE && logger_.logMethodArgs?.('fetch', {url, options});
62
-
63
- const options_ = _processOptions(url, options);
64
-
65
- try {
66
- // Start the fetch lifecycle, beginning with the cache strategy.
67
- const response = await handleCacheStrategy_(options_);
68
-
69
- if (!response.ok) {
70
- throw new FetchError('http_error', `HTTP error! status: ${response.status} ${response.statusText}`, response);
71
- }
72
-
73
- return [response, null];
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
- } catch {
88
- error.data = bodyText;
89
- }
90
- }
91
- }
92
- } else if (err instanceof Error) {
93
- if (err.name === 'AbortError') {
94
- error = new FetchError('aborted', err.message);
95
- } else {
96
- error = new FetchError('network_error', err.message);
97
- }
98
- } else {
99
- error = new FetchError('unknown_error', String(err ?? 'unknown_error'));
100
- }
101
-
102
- logger_.error('fetch', error.reason, {error});
103
- return [null, error];
104
- }
105
- }
106
-
107
- fetch.version = __package_version__;
108
-
109
- /**
110
- * An enhanced wrapper for the native `fetch` function that automatically parses JSON responses.
111
- *
112
- * This function extends the standard `fetch` with the same features (timeout, retry, caching, etc.)
113
- * and automatically parses the response body as JSON. It returns a tuple with the parsed data or an error.
114
- *
115
- * @template T - The expected type of the JSON response data.
116
- *
117
- * @param {string} url - The URL to fetch.
118
- * @param {FetchOptions} options - Optional configuration for the fetch request.
119
- * @returns {Promise<[T, null] | [null, FetchError]>} A promise that resolves to a tuple.
120
- * On success, it returns `[data, null]` where data is the parsed JSON.
121
- * On failure, it returns `[null, FetchError]`.
122
- *
123
- * @example
124
- * ```typescript
125
- * import {fetchJson} from '@alwatr/fetch';
126
- *
127
- * interface Product {
128
- * ok: true;
129
- * id: number;
130
- * name: string;
131
- * price: number;
132
- * }
133
- *
134
- * async function getProduct(id: number) {
135
- * const [data, error] = await fetchJson<Product>(`/api/products/${id}`, {
136
- * timeout: 5_000,
137
- * cacheStrategy: 'cache_first',
138
- * requireResponseJsonWithOkTrue: true,
139
- * });
140
- *
141
- * if (error) {
142
- * console.error('Failed to fetch product:', error.reason);
143
- * return;
144
- * }
145
- *
146
- * // data is now typed as Product and guaranteed to be valid
147
- * console.log('Product name:', data.name);
148
- * }
149
- * ```
150
- */
151
- export async function fetchJson<T extends JsonObject = JsonObject>(
152
- url: string,
153
- options: FetchJsonOptions = {},
154
- ): Promise<[T, null] | [null, FetchError]> {
155
- DEV_MODE && logger_.logMethodArgs?.('fetchJson', {url, options});
156
-
157
- const [response, error] = await fetch(url, options);
158
-
159
- if (error) {
160
- return [null, error];
161
- }
162
-
163
- const bodyText = await response.text().catch(() => '');
164
- if (bodyText.trim().length === 0) {
165
- const parseError = new FetchError(
166
- 'json_parse_error',
167
- 'Response body is empty, cannot parse JSON',
168
- response,
169
- bodyText,
170
- );
171
- logger_.error('fetchJson', parseError.reason, {error: parseError});
172
- return [null, parseError];
173
- }
174
-
175
- try {
176
- const data = JSON.parse(bodyText) as T;
177
- if (options.requireJsonResponseWithOkTrue && data.ok !== true) {
178
- const parseError = new FetchError(
179
- 'json_response_error',
180
- 'Response JSON "ok" property is not true',
181
- response,
182
- data,
183
- );
184
- logger_.error('fetchJson', parseError.reason, {error: parseError});
185
- return [null, parseError];
186
- }
187
- return [data, null];
188
- } catch (err) {
189
- const parseError = new FetchError(
190
- 'json_parse_error',
191
- err instanceof Error ? err.message : 'Failed to parse JSON response',
192
- response,
193
- bodyText,
194
- );
195
- logger_.error('fetchJson', parseError.reason, {error: parseError});
196
- return [null, parseError];
197
- }
198
- }