@indra211/httpease 1.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.
@@ -0,0 +1,372 @@
1
+ const InterceptorManager = require('./interceptors/InterceptorManager');
2
+ const HttpError = require('./errors/HttpError');
3
+ const {
4
+ mergeConfig,
5
+ buildURL,
6
+ sleep,
7
+ calculateBackoff,
8
+ isObject
9
+ } = require('./utils/helpers');
10
+ const { transformRequest, transformResponse } = require('./utils/transformers');
11
+ const { wrapBodyWithProgress, readBodyWithProgress } = require('./utils/progress');
12
+
13
+ /**
14
+ * HttpEase - A powerful HTTP client built on fetch
15
+ * Supports TypeScript, interceptors, retry, timeout and progress tracking.
16
+ */
17
+ class HttpEase {
18
+ constructor(config = {}) {
19
+ // Default configuration
20
+ this.defaults = {
21
+ baseURL: '',
22
+ timeout: 30000,
23
+ headers: {
24
+ 'Content-Type': 'application/json',
25
+ },
26
+ retries: 0,
27
+ retryDelay: 1000,
28
+ retryCondition: this.defaultRetryCondition,
29
+ transformRequest: [...transformRequest],
30
+ transformResponse: [...transformResponse],
31
+ validateStatus: (status) => status >= 200 && status < 300,
32
+ maxRedirects: 5,
33
+ withCredentials: false,
34
+ ...config
35
+ };
36
+
37
+ // Initialize interceptors
38
+ this.interceptors = {
39
+ request: new InterceptorManager(),
40
+ response: new InterceptorManager()
41
+ };
42
+ }
43
+
44
+ /**
45
+ * Default retry condition
46
+ */
47
+ defaultRetryCondition(error, attempt) {
48
+ // Don't retry if we have a response (server responded)
49
+ if (error.response) {
50
+ const status = error.response.status;
51
+ // Retry on 5xx errors and 429 (rate limit)
52
+ return status >= 500 || status === 429 || status === 408;
53
+ }
54
+ // Retry on network errors (no response)
55
+ return true;
56
+ }
57
+
58
+ /**
59
+ * Apply request transformers
60
+ */
61
+ transformRequestData(data, headers) {
62
+ let result = data;
63
+
64
+ for (const transformer of this.defaults.transformRequest) {
65
+ result = transformer(result, headers);
66
+ }
67
+
68
+ return result;
69
+ }
70
+
71
+ /**
72
+ * Apply response transformers to already-consumed body text.
73
+ * Used by the download-progress path, which has already read and
74
+ * collected all bytes from the response stream.
75
+ *
76
+ * The built-in default transformer at index 0 expects a raw `Response`
77
+ * object, so we skip it here and do our own JSON parsing inline.
78
+ * Any user-added transformers (added after the default) are run normally
79
+ * because they receive already-parsed data in both code paths.
80
+ *
81
+ * @param {string} rawText - Raw body text already read from the stream
82
+ * @param {string | null} contentType - Content-Type header value
83
+ */
84
+ async transformResponseText(rawText, contentType) {
85
+ // Inline JSON parsing (matches what the default transformer does)
86
+ let parsed = rawText;
87
+ if (contentType && contentType.includes('application/json') && typeof rawText === 'string') {
88
+ try {
89
+ parsed = JSON.parse(rawText);
90
+ } catch (_) {
91
+ parsed = rawText;
92
+ }
93
+ }
94
+
95
+ // Run user-added transformers only (skip index 0 — the built-in default
96
+ // which expects a Response object, not pre-parsed data).
97
+ let result = parsed;
98
+ const userTransformers = this.defaults.transformResponse.slice(1);
99
+ for (const transformer of userTransformers) {
100
+ result = await transformer(result);
101
+ }
102
+
103
+ return result;
104
+ }
105
+
106
+ /**
107
+ * Apply response transformers (original path — via Response clone)
108
+ */
109
+ async transformResponseData(response) {
110
+ let result = response;
111
+
112
+ for (const transformer of this.defaults.transformResponse) {
113
+ result = await transformer(result);
114
+ }
115
+
116
+ return result;
117
+ }
118
+
119
+ /**
120
+ * Core request method with retry logic and optional progress tracking.
121
+ */
122
+ async request(config = {}) {
123
+ // Merge configurations
124
+ const finalConfig = mergeConfig(this.defaults, config);
125
+
126
+ // Build full URL
127
+ const url = buildURL(
128
+ finalConfig.baseURL + finalConfig.url,
129
+ finalConfig.params
130
+ );
131
+
132
+ const maxRetries = finalConfig.retries;
133
+ let lastError;
134
+
135
+ // Retry loop
136
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
137
+ try {
138
+ // Apply request interceptors
139
+ let requestConfig = { ...finalConfig, url };
140
+ requestConfig = await this.interceptors.request.forEach(requestConfig);
141
+
142
+ // Transform request data
143
+ const headers = { ...requestConfig.headers };
144
+ let body = requestConfig.data
145
+ ? this.transformRequestData(requestConfig.data, headers)
146
+ : undefined;
147
+
148
+ // ── Upload Progress ──────────────────────────────────────────
149
+ let duplex;
150
+ if (body !== undefined && requestConfig.onUploadProgress) {
151
+ const wrapped = wrapBodyWithProgress(body, requestConfig.onUploadProgress);
152
+ body = wrapped.body;
153
+ duplex = wrapped.duplex;
154
+ }
155
+
156
+ // Setup abort controller for timeout
157
+ const controller = new AbortController();
158
+ const timeoutId = setTimeout(
159
+ () => controller.abort(),
160
+ requestConfig.timeout
161
+ );
162
+
163
+ // Build fetch options
164
+ let fetchOptions = {
165
+ method: requestConfig.method?.toUpperCase() || 'GET',
166
+ headers,
167
+ body,
168
+ signal: controller.signal,
169
+ credentials: requestConfig.withCredentials ? 'include' : 'same-origin'
170
+ };
171
+
172
+ // Required by the Fetch spec when sending a ReadableStream body
173
+ if (duplex) {
174
+ fetchOptions.duplex = duplex;
175
+ }
176
+
177
+ // Make the fetch request
178
+ const startTime = Date.now();
179
+ const response = await fetch(requestConfig.url, fetchOptions);
180
+ clearTimeout(timeoutId);
181
+
182
+ // ── Download Progress ────────────────────────────────────────
183
+ let responseData;
184
+ if (requestConfig.onDownloadProgress) {
185
+ // Stream the body through progress tracking, then parse
186
+ const rawText = await readBodyWithProgress(
187
+ response,
188
+ requestConfig.onDownloadProgress
189
+ );
190
+ responseData = await this.transformResponseText(
191
+ rawText,
192
+ response.headers.get('content-type')
193
+ );
194
+ } else {
195
+ // Standard path — clone & transform via default transformers
196
+ responseData = await this.transformResponseData(response.clone());
197
+ }
198
+
199
+ // Build response object
200
+ let responseObj = {
201
+ data: responseData,
202
+ status: response.status,
203
+ statusText: response.statusText,
204
+ headers: this.parseHeaders(response.headers),
205
+ config: requestConfig,
206
+ request: {
207
+ url: requestConfig.url,
208
+ method: requestConfig.method
209
+ },
210
+ duration: Date.now() - startTime
211
+ };
212
+
213
+ // Check if status is valid
214
+ const isValid = requestConfig.validateStatus(response.status);
215
+
216
+ if (!isValid) {
217
+ const error = new HttpError(
218
+ `Request failed with status code ${response.status}`,
219
+ requestConfig,
220
+ null,
221
+ fetchOptions,
222
+ responseObj
223
+ );
224
+ throw error;
225
+ }
226
+
227
+ // Apply response interceptors
228
+ responseObj = await this.interceptors.response.forEach(responseObj);
229
+
230
+ return responseObj;
231
+
232
+ } catch (error) {
233
+ lastError = error;
234
+
235
+ // Handle abort/timeout
236
+ if (error.name === 'AbortError') {
237
+ const timeoutError = new HttpError(
238
+ `Request timeout after ${finalConfig.timeout}ms`,
239
+ finalConfig,
240
+ 'ETIMEDOUT',
241
+ null,
242
+ null
243
+ );
244
+ lastError = timeoutError;
245
+ }
246
+
247
+ // Check if we should retry
248
+ const shouldRetry = attempt < maxRetries &&
249
+ finalConfig.retryCondition(lastError, attempt + 1);
250
+
251
+ if (shouldRetry) {
252
+ const delay = typeof finalConfig.retryDelay === 'function'
253
+ ? finalConfig.retryDelay(attempt + 1)
254
+ : calculateBackoff(attempt + 1, finalConfig.retryDelay);
255
+
256
+ if (finalConfig.onRetry) {
257
+ finalConfig.onRetry(attempt + 1, delay, lastError);
258
+ }
259
+
260
+ await sleep(delay);
261
+ continue; // Try again
262
+ }
263
+
264
+ // No more retries
265
+ throw lastError;
266
+ }
267
+ }
268
+
269
+ throw lastError;
270
+ }
271
+
272
+ /**
273
+ * Parse response headers into a plain object
274
+ */
275
+ parseHeaders(headers) {
276
+ const parsed = {};
277
+ headers.forEach((value, key) => {
278
+ parsed[key] = value;
279
+ });
280
+ return parsed;
281
+ }
282
+
283
+ /**
284
+ * GET request
285
+ */
286
+ get(url, config = {}) {
287
+ return this.request({
288
+ ...config,
289
+ method: 'GET',
290
+ url
291
+ });
292
+ }
293
+
294
+ /**
295
+ * DELETE request
296
+ */
297
+ delete(url, config = {}) {
298
+ return this.request({
299
+ ...config,
300
+ method: 'DELETE',
301
+ url
302
+ });
303
+ }
304
+
305
+ /**
306
+ * HEAD request
307
+ */
308
+ head(url, config = {}) {
309
+ return this.request({
310
+ ...config,
311
+ method: 'HEAD',
312
+ url
313
+ });
314
+ }
315
+
316
+ /**
317
+ * OPTIONS request
318
+ */
319
+ options(url, config = {}) {
320
+ return this.request({
321
+ ...config,
322
+ method: 'OPTIONS',
323
+ url
324
+ });
325
+ }
326
+
327
+ /**
328
+ * POST request
329
+ */
330
+ post(url, data, config = {}) {
331
+ return this.request({
332
+ ...config,
333
+ method: 'POST',
334
+ url,
335
+ data
336
+ });
337
+ }
338
+
339
+ /**
340
+ * PUT request
341
+ */
342
+ put(url, data, config = {}) {
343
+ return this.request({
344
+ ...config,
345
+ method: 'PUT',
346
+ url,
347
+ data
348
+ });
349
+ }
350
+
351
+ /**
352
+ * PATCH request
353
+ */
354
+ patch(url, data, config = {}) {
355
+ return this.request({
356
+ ...config,
357
+ method: 'PATCH',
358
+ url,
359
+ data
360
+ });
361
+ }
362
+
363
+ /**
364
+ * Get URI for request (without making it)
365
+ */
366
+ getUri(config = {}) {
367
+ const finalConfig = mergeConfig(this.defaults, config);
368
+ return buildURL(finalConfig.baseURL + finalConfig.url, finalConfig.params);
369
+ }
370
+ }
371
+
372
+ module.exports = HttpEase;
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Custom HTTP Error class with detailed information
3
+ */
4
+ class HttpError extends Error {
5
+ constructor(message, config, code, request, response) {
6
+ super(message);
7
+ this.name = 'HttpError';
8
+ this.config = config;
9
+ this.code = code;
10
+ this.request = request;
11
+ this.response = response;
12
+ this.isHttpError = true;
13
+ }
14
+
15
+ toJSON() {
16
+ return {
17
+ message: this.message,
18
+ name: this.name,
19
+ code: this.code,
20
+ status: this.response?.status,
21
+ statusText: this.response?.statusText,
22
+ config: this.config
23
+ };
24
+ }
25
+ }
26
+
27
+ module.exports = HttpError;
package/src/index.js ADDED
@@ -0,0 +1,19 @@
1
+ const HttpEase = require('./HttpEase');
2
+
3
+ /**
4
+ * Create a new instance of HttpEase
5
+ */
6
+ function create(config) {
7
+ return new HttpEase(config);
8
+ }
9
+
10
+ // Create default instance
11
+ const httpEase = new HttpEase();
12
+
13
+ // Export
14
+ module.exports = httpEase;
15
+ module.exports.HttpEase = HttpEase;
16
+ module.exports.create = create;
17
+
18
+ // Expose error class
19
+ module.exports.HttpError = require('./errors/HttpError');
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Interceptor Manager - handles request and response interceptors
3
+ */
4
+ class InterceptorManager {
5
+ constructor() {
6
+ this.handlers = [];
7
+ }
8
+
9
+ /**
10
+ * Add a new interceptor
11
+ * @param {Function} fulfilled - Success handler
12
+ * @param {Function} rejected - Error handler
13
+ * @returns {Number} - ID for removing the interceptor
14
+ */
15
+ use(fulfilled, rejected) {
16
+ this.handlers.push({
17
+ fulfilled,
18
+ rejected
19
+ });
20
+ return this.handlers.length - 1;
21
+ }
22
+
23
+ /**
24
+ * Remove an interceptor by ID
25
+ * @param {Number} id - Interceptor ID
26
+ */
27
+ eject(id) {
28
+ if (this.handlers[id]) {
29
+ this.handlers[id] = null;
30
+ }
31
+ }
32
+
33
+ /**
34
+ * Clear all interceptors
35
+ */
36
+ clear() {
37
+ this.handlers = [];
38
+ }
39
+
40
+ /**
41
+ * Execute all interceptors
42
+ * @param {*} data - Data to pass through interceptors
43
+ */
44
+ async forEach(data) {
45
+ let result = data;
46
+
47
+ for (const handler of this.handlers) {
48
+ if (handler === null) continue;
49
+
50
+ try {
51
+ if (handler.fulfilled) {
52
+ result = await handler.fulfilled(result);
53
+ }
54
+ } catch (error) {
55
+ if (handler.rejected) {
56
+ result = await handler.rejected(error);
57
+ } else {
58
+ throw error;
59
+ }
60
+ }
61
+ }
62
+
63
+ return result;
64
+ }
65
+ }
66
+
67
+ module.exports = InterceptorManager;
@@ -0,0 +1,221 @@
1
+ // ============================================================
2
+ // HttpEase — TypeScript Type Definitions
3
+ // ============================================================
4
+
5
+ export interface ProgressEvent {
6
+ /** Bytes transferred so far */
7
+ loaded: number;
8
+ /** Total bytes (0 if unknown, e.g. no Content-Length header) */
9
+ total: number;
10
+ /** Progress percentage 0–100 (0 if total is unknown) */
11
+ percentage: number;
12
+ }
13
+
14
+ export type TransformRequestFn = (data: unknown, headers: Record<string, string>) => unknown;
15
+ export type TransformResponseFn = (data: unknown) => unknown | Promise<unknown>;
16
+
17
+ export interface HttpEaseConfig {
18
+ /** Base URL prepended to every request URL */
19
+ baseURL?: string;
20
+
21
+ /** Request URL (relative or absolute) */
22
+ url?: string;
23
+
24
+ /** HTTP method */
25
+ method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD' | 'OPTIONS' | string;
26
+
27
+ /** Request timeout in milliseconds (default: 30000) */
28
+ timeout?: number;
29
+
30
+ /** Custom request headers */
31
+ headers?: Record<string, string>;
32
+
33
+ /** Query parameters appended to the URL */
34
+ params?: Record<string, string | number | boolean | string[] | number[] | null | undefined>;
35
+
36
+ /** Request body data */
37
+ data?: unknown;
38
+
39
+ /** Number of retry attempts on failure (default: 0) */
40
+ retries?: number;
41
+
42
+ /**
43
+ * Delay between retries in ms, or a function returning the delay.
44
+ * @example 1000
45
+ * @example (attempt) => Math.pow(2, attempt - 1) * 1000
46
+ */
47
+ retryDelay?: number | ((attempt: number) => number);
48
+
49
+ /**
50
+ * Custom condition to decide whether to retry.
51
+ * Return `true` to retry, `false` to stop.
52
+ */
53
+ retryCondition?: (error: HttpEaseError, attempt: number) => boolean;
54
+
55
+ /** Callback fired before each retry */
56
+ onRetry?: (attempt: number, delay: number, error: HttpEaseError) => void;
57
+
58
+ /**
59
+ * Validate whether a given HTTP status code should resolve or reject.
60
+ * Default: `status >= 200 && status < 300`
61
+ */
62
+ validateStatus?: (status: number) => boolean;
63
+
64
+ /** Send cookies with cross-origin requests (default: false) */
65
+ withCredentials?: boolean;
66
+
67
+ /** Transform request data before sending */
68
+ transformRequest?: TransformRequestFn[];
69
+
70
+ /** Transform response data after receiving */
71
+ transformResponse?: TransformResponseFn[];
72
+
73
+ /**
74
+ * Callback fired during upload progress.
75
+ * Works with string/Buffer/Uint8Array bodies in Node.js 18+ and modern browsers.
76
+ */
77
+ onUploadProgress?: (event: ProgressEvent) => void;
78
+
79
+ /**
80
+ * Callback fired during download progress.
81
+ * Requires the server to send a `content-length` header for percentage to work.
82
+ */
83
+ onDownloadProgress?: (event: ProgressEvent) => void;
84
+ }
85
+
86
+ export interface HttpEaseResponse<T = unknown> {
87
+ /** Parsed response data */
88
+ data: T;
89
+ /** HTTP status code */
90
+ status: number;
91
+ /** HTTP status text */
92
+ statusText: string;
93
+ /** Response headers as a plain object */
94
+ headers: Record<string, string>;
95
+ /** The config used for this request */
96
+ config: HttpEaseConfig;
97
+ /** Request info (url, method) */
98
+ request: { url: string; method: string };
99
+ /** Round-trip duration in milliseconds */
100
+ duration: number;
101
+ }
102
+
103
+ export declare class HttpEaseError extends Error {
104
+ name: 'HttpError';
105
+ /** Error code, e.g. `'ETIMEDOUT'` */
106
+ code: string | null;
107
+ /** Config used for this request */
108
+ config: HttpEaseConfig;
109
+ /** The fetch options used */
110
+ request: RequestInit | null;
111
+ /** The parsed response object (if server responded) */
112
+ response: HttpEaseResponse | null;
113
+ /** Always true — use to narrow error types */
114
+ isHttpError: true;
115
+
116
+ toJSON(): {
117
+ message: string;
118
+ name: string;
119
+ code: string | null;
120
+ status: number | undefined;
121
+ statusText: string | undefined;
122
+ config: HttpEaseConfig;
123
+ };
124
+ }
125
+
126
+ export interface InterceptorManager<T> {
127
+ /**
128
+ * Register an interceptor.
129
+ * @returns Interceptor ID (use with `eject`)
130
+ */
131
+ use(
132
+ onFulfilled?: (value: T) => T | Promise<T>,
133
+ onRejected?: (error: HttpEaseError) => T | Promise<T>
134
+ ): number;
135
+
136
+ /** Remove an interceptor by ID */
137
+ eject(id: number): void;
138
+
139
+ /** Remove all interceptors */
140
+ clear(): void;
141
+ }
142
+
143
+ export interface HttpEaseInstance {
144
+ /** Default configuration for this instance */
145
+ defaults: HttpEaseConfig;
146
+
147
+ /** Request and response interceptors */
148
+ interceptors: {
149
+ request: InterceptorManager<HttpEaseConfig>;
150
+ response: InterceptorManager<HttpEaseResponse>;
151
+ };
152
+
153
+ /** Core request method */
154
+ request<T = unknown>(config: HttpEaseConfig): Promise<HttpEaseResponse<T>>;
155
+
156
+ /** GET request */
157
+ get<T = unknown>(url: string, config?: HttpEaseConfig): Promise<HttpEaseResponse<T>>;
158
+
159
+ /** DELETE request */
160
+ delete<T = unknown>(url: string, config?: HttpEaseConfig): Promise<HttpEaseResponse<T>>;
161
+
162
+ /** HEAD request */
163
+ head<T = unknown>(url: string, config?: HttpEaseConfig): Promise<HttpEaseResponse<T>>;
164
+
165
+ /** OPTIONS request */
166
+ options<T = unknown>(url: string, config?: HttpEaseConfig): Promise<HttpEaseResponse<T>>;
167
+
168
+ /** POST request */
169
+ post<T = unknown>(url: string, data?: unknown, config?: HttpEaseConfig): Promise<HttpEaseResponse<T>>;
170
+
171
+ /** PUT request */
172
+ put<T = unknown>(url: string, data?: unknown, config?: HttpEaseConfig): Promise<HttpEaseResponse<T>>;
173
+
174
+ /** PATCH request */
175
+ patch<T = unknown>(url: string, data?: unknown, config?: HttpEaseConfig): Promise<HttpEaseResponse<T>>;
176
+
177
+ /** Resolve the full URI for a config without making a request */
178
+ getUri(config?: HttpEaseConfig): string;
179
+ }
180
+
181
+ /**
182
+ * Create a new HttpEase instance with custom defaults.
183
+ *
184
+ * @example
185
+ * ```ts
186
+ * import { create } from 'httpease';
187
+ *
188
+ * const api = create({
189
+ * baseURL: 'https://api.example.com',
190
+ * timeout: 10000,
191
+ * headers: { Authorization: 'Bearer TOKEN' },
192
+ * });
193
+ *
194
+ * const res = await api.get<User[]>('/users');
195
+ * console.log(res.data);
196
+ * ```
197
+ */
198
+ export declare function create(config?: HttpEaseConfig): HttpEaseInstance;
199
+
200
+ /** The `HttpEase` class for extending */
201
+ export declare class HttpEase implements HttpEaseInstance {
202
+ constructor(config?: HttpEaseConfig);
203
+ defaults: HttpEaseConfig;
204
+ interceptors: {
205
+ request: InterceptorManager<HttpEaseConfig>;
206
+ response: InterceptorManager<HttpEaseResponse>;
207
+ };
208
+ request<T = unknown>(config: HttpEaseConfig): Promise<HttpEaseResponse<T>>;
209
+ get<T = unknown>(url: string, config?: HttpEaseConfig): Promise<HttpEaseResponse<T>>;
210
+ delete<T = unknown>(url: string, config?: HttpEaseConfig): Promise<HttpEaseResponse<T>>;
211
+ head<T = unknown>(url: string, config?: HttpEaseConfig): Promise<HttpEaseResponse<T>>;
212
+ options<T = unknown>(url: string, config?: HttpEaseConfig): Promise<HttpEaseResponse<T>>;
213
+ post<T = unknown>(url: string, data?: unknown, config?: HttpEaseConfig): Promise<HttpEaseResponse<T>>;
214
+ put<T = unknown>(url: string, data?: unknown, config?: HttpEaseConfig): Promise<HttpEaseResponse<T>>;
215
+ patch<T = unknown>(url: string, data?: unknown, config?: HttpEaseConfig): Promise<HttpEaseResponse<T>>;
216
+ getUri(config?: HttpEaseConfig): string;
217
+ }
218
+
219
+ /** The default HttpEase instance */
220
+ declare const httpEase: HttpEaseInstance;
221
+ export default httpEase;