pulse-js-framework 1.7.6 → 1.7.9
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/compiler/parser.js +1 -1
- package/package.json +7 -2
- package/runtime/http.js +837 -0
- package/core/errors.js +0 -5
package/compiler/parser.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pulse-js-framework",
|
|
3
|
-
"version": "1.7.
|
|
3
|
+
"version": "1.7.9",
|
|
4
4
|
"description": "A declarative DOM framework with CSS selector-based structure and reactive pulsations",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
@@ -64,6 +64,10 @@
|
|
|
64
64
|
"types": "./types/form.d.ts",
|
|
65
65
|
"default": "./runtime/form.js"
|
|
66
66
|
},
|
|
67
|
+
"./runtime/http": {
|
|
68
|
+
"types": "./types/http.d.ts",
|
|
69
|
+
"default": "./runtime/http.js"
|
|
70
|
+
},
|
|
67
71
|
"./runtime/devtools": "./runtime/devtools.js",
|
|
68
72
|
"./compiler": {
|
|
69
73
|
"types": "./types/index.d.ts",
|
|
@@ -93,7 +97,7 @@
|
|
|
93
97
|
"LICENSE"
|
|
94
98
|
],
|
|
95
99
|
"scripts": {
|
|
96
|
-
"test": "npm run test:compiler && npm run test:sourcemap && npm run test:pulse && npm run test:dom && npm run test:dom-adapter && npm run test:router && npm run test:store && npm run test:hmr && npm run test:lint && npm run test:format && npm run test:analyze && npm run test:lru-cache && npm run test:utils && npm run test:docs && npm run test:async && npm run test:form && npm run test:devtools && npm run test:native",
|
|
100
|
+
"test": "npm run test:compiler && npm run test:sourcemap && npm run test:pulse && npm run test:dom && npm run test:dom-adapter && npm run test:router && npm run test:store && npm run test:hmr && npm run test:lint && npm run test:format && npm run test:analyze && npm run test:lru-cache && npm run test:utils && npm run test:docs && npm run test:async && npm run test:form && npm run test:http && npm run test:devtools && npm run test:native",
|
|
97
101
|
"test:compiler": "node test/compiler.test.js",
|
|
98
102
|
"test:sourcemap": "node test/sourcemap.test.js",
|
|
99
103
|
"test:pulse": "node test/pulse.test.js",
|
|
@@ -110,6 +114,7 @@
|
|
|
110
114
|
"test:docs": "node test/docs.test.js",
|
|
111
115
|
"test:async": "node test/async.test.js",
|
|
112
116
|
"test:form": "node test/form.test.js",
|
|
117
|
+
"test:http": "node test/http.test.js",
|
|
113
118
|
"test:devtools": "node test/devtools.test.js",
|
|
114
119
|
"test:native": "node test/native.test.js",
|
|
115
120
|
"build:netlify": "node scripts/build-netlify.js",
|
package/runtime/http.js
ADDED
|
@@ -0,0 +1,837 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pulse HTTP Client - Zero-dependency HTTP client for Pulse Framework
|
|
3
|
+
* @module pulse-js-framework/runtime/http
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { pulse, computed, batch } from './pulse.js';
|
|
7
|
+
import { useAsync, useResource } from './async.js';
|
|
8
|
+
import { RuntimeError, createErrorMessage, getDocsUrl } from './errors.js';
|
|
9
|
+
|
|
10
|
+
// ============================================================================
|
|
11
|
+
// HTTP Error Class
|
|
12
|
+
// ============================================================================
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* HTTP-specific error suggestions
|
|
16
|
+
*/
|
|
17
|
+
const HTTP_SUGGESTIONS = {
|
|
18
|
+
TIMEOUT: 'Consider increasing the timeout or checking network conditions.',
|
|
19
|
+
NETWORK: 'Check internet connectivity and ensure the server is reachable.',
|
|
20
|
+
ABORT: 'Request was cancelled. This is usually intentional.',
|
|
21
|
+
HTTP_ERROR: 'Check the response status and server logs for details.',
|
|
22
|
+
PARSE_ERROR: 'The response could not be parsed. Check the Content-Type header.'
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* HTTP Error with request/response context
|
|
27
|
+
*/
|
|
28
|
+
export class HttpError extends RuntimeError {
|
|
29
|
+
/**
|
|
30
|
+
* @param {string} message - Error message
|
|
31
|
+
* @param {Object} [options={}] - Error options
|
|
32
|
+
* @param {string} [options.code] - Error code (TIMEOUT, NETWORK, ABORT, HTTP_ERROR, PARSE_ERROR)
|
|
33
|
+
* @param {Object} [options.config] - Request configuration
|
|
34
|
+
* @param {Request} [options.request] - The Request object
|
|
35
|
+
* @param {Object} [options.response] - The HttpResponse object
|
|
36
|
+
*/
|
|
37
|
+
constructor(message, options = {}) {
|
|
38
|
+
const code = options.code || 'HTTP_ERROR';
|
|
39
|
+
const formattedMessage = createErrorMessage({
|
|
40
|
+
code,
|
|
41
|
+
message,
|
|
42
|
+
context: options.context,
|
|
43
|
+
suggestion: options.suggestion || HTTP_SUGGESTIONS[code]
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
super(formattedMessage, { code });
|
|
47
|
+
|
|
48
|
+
this.name = 'HttpError';
|
|
49
|
+
this.config = options.config || null;
|
|
50
|
+
this.code = code;
|
|
51
|
+
this.request = options.request || null;
|
|
52
|
+
this.response = options.response || null;
|
|
53
|
+
this.status = options.response?.status || null;
|
|
54
|
+
this.isHttpError = true;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Check if an error is an HttpError
|
|
59
|
+
* @param {any} error - The error to check
|
|
60
|
+
* @returns {boolean} True if the error is an HttpError
|
|
61
|
+
*/
|
|
62
|
+
static isHttpError(error) {
|
|
63
|
+
return error?.isHttpError === true;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Check if this is a timeout error
|
|
68
|
+
* @returns {boolean}
|
|
69
|
+
*/
|
|
70
|
+
isTimeout() {
|
|
71
|
+
return this.code === 'TIMEOUT';
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Check if this is a network error
|
|
76
|
+
* @returns {boolean}
|
|
77
|
+
*/
|
|
78
|
+
isNetworkError() {
|
|
79
|
+
return this.code === 'NETWORK';
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Check if this is an abort/cancellation error
|
|
84
|
+
* @returns {boolean}
|
|
85
|
+
*/
|
|
86
|
+
isAborted() {
|
|
87
|
+
return this.code === 'ABORT';
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ============================================================================
|
|
92
|
+
// Interceptor Manager
|
|
93
|
+
// ============================================================================
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Manages request or response interceptors
|
|
97
|
+
*/
|
|
98
|
+
class InterceptorManager {
|
|
99
|
+
#handlers = new Map();
|
|
100
|
+
#idCounter = 0;
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Add an interceptor
|
|
104
|
+
* @param {Function} fulfilled - Function to run on success
|
|
105
|
+
* @param {Function} [rejected] - Function to run on error
|
|
106
|
+
* @returns {number} Interceptor ID (for removal)
|
|
107
|
+
*/
|
|
108
|
+
use(fulfilled, rejected) {
|
|
109
|
+
const id = this.#idCounter++;
|
|
110
|
+
this.#handlers.set(id, { fulfilled, rejected });
|
|
111
|
+
return id;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Remove an interceptor by ID
|
|
116
|
+
* @param {number} id - The interceptor ID
|
|
117
|
+
*/
|
|
118
|
+
eject(id) {
|
|
119
|
+
this.#handlers.delete(id);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Remove all interceptors
|
|
124
|
+
*/
|
|
125
|
+
clear() {
|
|
126
|
+
this.#handlers.clear();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Iterate through handlers
|
|
131
|
+
* @yields {Object} Handler with fulfilled and rejected functions
|
|
132
|
+
*/
|
|
133
|
+
*[Symbol.iterator]() {
|
|
134
|
+
for (const handler of this.#handlers.values()) {
|
|
135
|
+
yield handler;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Get the number of interceptors
|
|
141
|
+
* @returns {number}
|
|
142
|
+
*/
|
|
143
|
+
get size() {
|
|
144
|
+
return this.#handlers.size;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ============================================================================
|
|
149
|
+
// HTTP Client
|
|
150
|
+
// ============================================================================
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Default configuration values
|
|
154
|
+
*/
|
|
155
|
+
const DEFAULT_CONFIG = {
|
|
156
|
+
baseURL: '',
|
|
157
|
+
timeout: 10000,
|
|
158
|
+
headers: {},
|
|
159
|
+
withCredentials: false,
|
|
160
|
+
responseType: 'json',
|
|
161
|
+
validateStatus: (status) => status >= 200 && status < 300,
|
|
162
|
+
retries: 0,
|
|
163
|
+
retryDelay: 1000,
|
|
164
|
+
retryCondition: null
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* HTTP Client class
|
|
169
|
+
*/
|
|
170
|
+
class HttpClient {
|
|
171
|
+
#config;
|
|
172
|
+
#requestInterceptors;
|
|
173
|
+
#responseInterceptors;
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* @param {Object} [config={}] - Default configuration
|
|
177
|
+
*/
|
|
178
|
+
constructor(config = {}) {
|
|
179
|
+
this.#config = { ...DEFAULT_CONFIG, ...config };
|
|
180
|
+
this.#requestInterceptors = new InterceptorManager();
|
|
181
|
+
this.#responseInterceptors = new InterceptorManager();
|
|
182
|
+
|
|
183
|
+
// Public interceptors access
|
|
184
|
+
this.interceptors = {
|
|
185
|
+
request: this.#requestInterceptors,
|
|
186
|
+
response: this.#responseInterceptors
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
// Public defaults access
|
|
190
|
+
this.defaults = this.#config;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Build the full URL with base URL and query parameters
|
|
195
|
+
* @param {string} url - The URL path
|
|
196
|
+
* @param {Object} config - Request configuration
|
|
197
|
+
* @returns {string} Full URL
|
|
198
|
+
*/
|
|
199
|
+
#buildURL(url, config) {
|
|
200
|
+
let fullURL = url;
|
|
201
|
+
|
|
202
|
+
// Prepend baseURL if url is relative
|
|
203
|
+
if (config.baseURL && !url.startsWith('http://') && !url.startsWith('https://')) {
|
|
204
|
+
fullURL = config.baseURL.replace(/\/+$/, '') + '/' + url.replace(/^\/+/, '');
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Add query parameters
|
|
208
|
+
if (config.params && Object.keys(config.params).length > 0) {
|
|
209
|
+
const searchParams = new URLSearchParams();
|
|
210
|
+
for (const [key, value] of Object.entries(config.params)) {
|
|
211
|
+
if (value !== undefined && value !== null) {
|
|
212
|
+
searchParams.append(key, String(value));
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
const separator = fullURL.includes('?') ? '&' : '?';
|
|
216
|
+
fullURL += separator + searchParams.toString();
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return fullURL;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Merge configurations (defaults + request-specific)
|
|
224
|
+
* @param {Object} config - Request configuration
|
|
225
|
+
* @returns {Object} Merged configuration
|
|
226
|
+
*/
|
|
227
|
+
#mergeConfig(config) {
|
|
228
|
+
return {
|
|
229
|
+
...this.#config,
|
|
230
|
+
...config,
|
|
231
|
+
headers: {
|
|
232
|
+
...this.#config.headers,
|
|
233
|
+
...config.headers
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Run request interceptors
|
|
240
|
+
* @param {Object} config - Request configuration
|
|
241
|
+
* @returns {Promise<Object>} Modified configuration
|
|
242
|
+
*/
|
|
243
|
+
async #runRequestInterceptors(config) {
|
|
244
|
+
let currentConfig = config;
|
|
245
|
+
|
|
246
|
+
for (const { fulfilled, rejected } of this.#requestInterceptors) {
|
|
247
|
+
try {
|
|
248
|
+
if (fulfilled) {
|
|
249
|
+
currentConfig = await fulfilled(currentConfig);
|
|
250
|
+
}
|
|
251
|
+
} catch (error) {
|
|
252
|
+
if (rejected) {
|
|
253
|
+
currentConfig = await rejected(error);
|
|
254
|
+
} else {
|
|
255
|
+
throw error;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return currentConfig;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Run response interceptors
|
|
265
|
+
* @param {Object} response - HTTP response
|
|
266
|
+
* @returns {Promise<Object>} Modified response
|
|
267
|
+
*/
|
|
268
|
+
async #runResponseInterceptors(response) {
|
|
269
|
+
let currentResponse = response;
|
|
270
|
+
|
|
271
|
+
for (const { fulfilled, rejected } of this.#responseInterceptors) {
|
|
272
|
+
try {
|
|
273
|
+
if (fulfilled) {
|
|
274
|
+
currentResponse = await fulfilled(currentResponse);
|
|
275
|
+
}
|
|
276
|
+
} catch (error) {
|
|
277
|
+
if (rejected) {
|
|
278
|
+
currentResponse = await rejected(error);
|
|
279
|
+
} else {
|
|
280
|
+
throw error;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
return currentResponse;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Run error through response interceptors
|
|
290
|
+
* @param {Error} error - The error
|
|
291
|
+
* @returns {Promise} Rejected promise or transformed result
|
|
292
|
+
*/
|
|
293
|
+
async #runErrorInterceptors(error) {
|
|
294
|
+
let currentError = error;
|
|
295
|
+
|
|
296
|
+
for (const { rejected } of this.#responseInterceptors) {
|
|
297
|
+
if (rejected) {
|
|
298
|
+
try {
|
|
299
|
+
return await rejected(currentError);
|
|
300
|
+
} catch (e) {
|
|
301
|
+
currentError = e;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
throw currentError;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Parse response based on content type and responseType option
|
|
311
|
+
* @param {Response} response - Fetch Response object
|
|
312
|
+
* @param {Object} config - Request configuration
|
|
313
|
+
* @returns {Promise<any>} Parsed response data
|
|
314
|
+
*/
|
|
315
|
+
async #parseResponse(response, config) {
|
|
316
|
+
const contentType = response.headers.get('content-type') || '';
|
|
317
|
+
const responseType = config.responseType || 'json';
|
|
318
|
+
|
|
319
|
+
try {
|
|
320
|
+
switch (responseType) {
|
|
321
|
+
case 'json':
|
|
322
|
+
// Auto-detect JSON
|
|
323
|
+
if (contentType.includes('application/json')) {
|
|
324
|
+
return await response.json();
|
|
325
|
+
}
|
|
326
|
+
// Try JSON parsing, fall back to text
|
|
327
|
+
const text = await response.text();
|
|
328
|
+
try {
|
|
329
|
+
return JSON.parse(text);
|
|
330
|
+
} catch {
|
|
331
|
+
return text;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
case 'text':
|
|
335
|
+
return await response.text();
|
|
336
|
+
|
|
337
|
+
case 'blob':
|
|
338
|
+
return await response.blob();
|
|
339
|
+
|
|
340
|
+
case 'arrayBuffer':
|
|
341
|
+
return await response.arrayBuffer();
|
|
342
|
+
|
|
343
|
+
case 'formData':
|
|
344
|
+
return await response.formData();
|
|
345
|
+
|
|
346
|
+
default:
|
|
347
|
+
return await response.text();
|
|
348
|
+
}
|
|
349
|
+
} catch (parseError) {
|
|
350
|
+
throw new HttpError('Failed to parse response', {
|
|
351
|
+
code: 'PARSE_ERROR',
|
|
352
|
+
config,
|
|
353
|
+
context: `Expected ${responseType}, got ${contentType}`
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Execute request with timeout
|
|
360
|
+
* @param {string} url - Full URL
|
|
361
|
+
* @param {Object} fetchOptions - Fetch options
|
|
362
|
+
* @param {Object} config - Request configuration
|
|
363
|
+
* @returns {Promise<Response>} Fetch response
|
|
364
|
+
*/
|
|
365
|
+
async #executeWithTimeout(url, fetchOptions, config) {
|
|
366
|
+
const controller = new AbortController();
|
|
367
|
+
const { signal } = controller;
|
|
368
|
+
|
|
369
|
+
// Merge with user-provided signal
|
|
370
|
+
if (config.signal) {
|
|
371
|
+
config.signal.addEventListener('abort', () => {
|
|
372
|
+
controller.abort();
|
|
373
|
+
});
|
|
374
|
+
// If already aborted, abort immediately
|
|
375
|
+
if (config.signal.aborted) {
|
|
376
|
+
controller.abort();
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
let timeoutId;
|
|
381
|
+
|
|
382
|
+
try {
|
|
383
|
+
const fetchPromise = fetch(url, { ...fetchOptions, signal });
|
|
384
|
+
|
|
385
|
+
// Add timeout if configured
|
|
386
|
+
if (config.timeout > 0) {
|
|
387
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
388
|
+
timeoutId = setTimeout(() => {
|
|
389
|
+
controller.abort();
|
|
390
|
+
reject(new HttpError(`Request timeout after ${config.timeout}ms`, {
|
|
391
|
+
code: 'TIMEOUT',
|
|
392
|
+
config
|
|
393
|
+
}));
|
|
394
|
+
}, config.timeout);
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
return await Promise.race([fetchPromise, timeoutPromise]);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
return await fetchPromise;
|
|
401
|
+
} catch (error) {
|
|
402
|
+
// Handle abort
|
|
403
|
+
if (error.name === 'AbortError') {
|
|
404
|
+
if (config.signal?.aborted) {
|
|
405
|
+
throw new HttpError('Request aborted', {
|
|
406
|
+
code: 'ABORT',
|
|
407
|
+
config
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
// Re-throw timeout errors
|
|
411
|
+
if (HttpError.isHttpError(error)) {
|
|
412
|
+
throw error;
|
|
413
|
+
}
|
|
414
|
+
throw new HttpError('Request aborted', {
|
|
415
|
+
code: 'ABORT',
|
|
416
|
+
config
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// Handle network errors
|
|
421
|
+
if (error instanceof TypeError) {
|
|
422
|
+
throw new HttpError(error.message || 'Network error', {
|
|
423
|
+
code: 'NETWORK',
|
|
424
|
+
config,
|
|
425
|
+
context: 'Failed to connect to server'
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
throw error;
|
|
430
|
+
} finally {
|
|
431
|
+
if (timeoutId) {
|
|
432
|
+
clearTimeout(timeoutId);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Default retry condition
|
|
439
|
+
* @param {HttpError} error - The error
|
|
440
|
+
* @returns {boolean} Whether to retry
|
|
441
|
+
*/
|
|
442
|
+
#defaultRetryCondition(error) {
|
|
443
|
+
// Retry on network errors and timeouts
|
|
444
|
+
if (error.code === 'NETWORK' || error.code === 'TIMEOUT') {
|
|
445
|
+
return true;
|
|
446
|
+
}
|
|
447
|
+
// Retry on 5xx server errors
|
|
448
|
+
if (error.status >= 500) {
|
|
449
|
+
return true;
|
|
450
|
+
}
|
|
451
|
+
// Retry on 429 (rate limit)
|
|
452
|
+
if (error.status === 429) {
|
|
453
|
+
return true;
|
|
454
|
+
}
|
|
455
|
+
return false;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Execute request with retry logic
|
|
460
|
+
* @param {string} url - Full URL
|
|
461
|
+
* @param {Object} fetchOptions - Fetch options
|
|
462
|
+
* @param {Object} config - Request configuration
|
|
463
|
+
* @returns {Promise<Object>} HTTP response
|
|
464
|
+
*/
|
|
465
|
+
async #executeWithRetry(url, fetchOptions, config) {
|
|
466
|
+
const { retries = 0, retryDelay = 1000, retryCondition } = config;
|
|
467
|
+
let lastError;
|
|
468
|
+
let attempt = 0;
|
|
469
|
+
|
|
470
|
+
while (attempt <= retries) {
|
|
471
|
+
try {
|
|
472
|
+
const response = await this.#executeWithTimeout(url, fetchOptions, config);
|
|
473
|
+
|
|
474
|
+
// Validate status
|
|
475
|
+
if (!config.validateStatus(response.status)) {
|
|
476
|
+
const data = await this.#parseResponse(response.clone(), config).catch(() => null);
|
|
477
|
+
throw new HttpError(`Request failed with status ${response.status}`, {
|
|
478
|
+
code: 'HTTP_ERROR',
|
|
479
|
+
config,
|
|
480
|
+
response: {
|
|
481
|
+
data,
|
|
482
|
+
status: response.status,
|
|
483
|
+
statusText: response.statusText,
|
|
484
|
+
headers: response.headers
|
|
485
|
+
}
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// Parse and return response
|
|
490
|
+
const data = await this.#parseResponse(response, config);
|
|
491
|
+
|
|
492
|
+
return {
|
|
493
|
+
data,
|
|
494
|
+
status: response.status,
|
|
495
|
+
statusText: response.statusText,
|
|
496
|
+
headers: response.headers,
|
|
497
|
+
config
|
|
498
|
+
};
|
|
499
|
+
} catch (error) {
|
|
500
|
+
lastError = HttpError.isHttpError(error)
|
|
501
|
+
? error
|
|
502
|
+
: new HttpError(error.message, { code: 'NETWORK', config });
|
|
503
|
+
|
|
504
|
+
attempt++;
|
|
505
|
+
|
|
506
|
+
// Check if should retry
|
|
507
|
+
const shouldRetry = attempt <= retries && (
|
|
508
|
+
retryCondition
|
|
509
|
+
? retryCondition(lastError)
|
|
510
|
+
: this.#defaultRetryCondition(lastError)
|
|
511
|
+
);
|
|
512
|
+
|
|
513
|
+
if (shouldRetry) {
|
|
514
|
+
await new Promise(resolve => setTimeout(resolve, retryDelay));
|
|
515
|
+
continue;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
throw lastError;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
throw lastError;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* Make an HTTP request
|
|
527
|
+
* @param {Object} config - Request configuration
|
|
528
|
+
* @param {string} config.url - Request URL
|
|
529
|
+
* @param {string} [config.method='GET'] - HTTP method
|
|
530
|
+
* @param {Object} [config.headers] - Request headers
|
|
531
|
+
* @param {any} [config.data] - Request body
|
|
532
|
+
* @param {Object} [config.params] - URL query parameters
|
|
533
|
+
* @returns {Promise<Object>} HTTP response
|
|
534
|
+
*/
|
|
535
|
+
async request(config) {
|
|
536
|
+
// Merge with defaults
|
|
537
|
+
let mergedConfig = this.#mergeConfig(config);
|
|
538
|
+
|
|
539
|
+
// Run request interceptors
|
|
540
|
+
mergedConfig = await this.#runRequestInterceptors(mergedConfig);
|
|
541
|
+
|
|
542
|
+
// Build URL
|
|
543
|
+
const url = this.#buildURL(mergedConfig.url, mergedConfig);
|
|
544
|
+
|
|
545
|
+
// Prepare fetch options
|
|
546
|
+
const fetchOptions = {
|
|
547
|
+
method: (mergedConfig.method || 'GET').toUpperCase(),
|
|
548
|
+
headers: new Headers(mergedConfig.headers),
|
|
549
|
+
credentials: mergedConfig.withCredentials ? 'include' : 'same-origin'
|
|
550
|
+
};
|
|
551
|
+
|
|
552
|
+
// Add body for methods that support it
|
|
553
|
+
if (mergedConfig.data !== undefined && !['GET', 'HEAD'].includes(fetchOptions.method)) {
|
|
554
|
+
if (mergedConfig.data instanceof FormData ||
|
|
555
|
+
mergedConfig.data instanceof URLSearchParams ||
|
|
556
|
+
mergedConfig.data instanceof Blob) {
|
|
557
|
+
fetchOptions.body = mergedConfig.data;
|
|
558
|
+
} else if (typeof mergedConfig.data === 'object') {
|
|
559
|
+
fetchOptions.body = JSON.stringify(mergedConfig.data);
|
|
560
|
+
// Set Content-Type if not already set
|
|
561
|
+
if (!fetchOptions.headers.has('Content-Type')) {
|
|
562
|
+
fetchOptions.headers.set('Content-Type', 'application/json');
|
|
563
|
+
}
|
|
564
|
+
} else {
|
|
565
|
+
fetchOptions.body = mergedConfig.data;
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
try {
|
|
570
|
+
// Execute with retry
|
|
571
|
+
const response = await this.#executeWithRetry(url, fetchOptions, mergedConfig);
|
|
572
|
+
|
|
573
|
+
// Run response interceptors
|
|
574
|
+
return await this.#runResponseInterceptors(response);
|
|
575
|
+
} catch (error) {
|
|
576
|
+
// Run error through interceptors
|
|
577
|
+
return await this.#runErrorInterceptors(error);
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* GET request
|
|
583
|
+
* @param {string} url - Request URL
|
|
584
|
+
* @param {Object} [options] - Request options
|
|
585
|
+
* @returns {Promise<Object>} HTTP response
|
|
586
|
+
*/
|
|
587
|
+
get(url, options = {}) {
|
|
588
|
+
return this.request({ ...options, url, method: 'GET' });
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* DELETE request
|
|
593
|
+
* @param {string} url - Request URL
|
|
594
|
+
* @param {Object} [options] - Request options
|
|
595
|
+
* @returns {Promise<Object>} HTTP response
|
|
596
|
+
*/
|
|
597
|
+
delete(url, options = {}) {
|
|
598
|
+
return this.request({ ...options, url, method: 'DELETE' });
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/**
|
|
602
|
+
* HEAD request
|
|
603
|
+
* @param {string} url - Request URL
|
|
604
|
+
* @param {Object} [options] - Request options
|
|
605
|
+
* @returns {Promise<Object>} HTTP response
|
|
606
|
+
*/
|
|
607
|
+
head(url, options = {}) {
|
|
608
|
+
return this.request({ ...options, url, method: 'HEAD' });
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/**
|
|
612
|
+
* OPTIONS request
|
|
613
|
+
* @param {string} url - Request URL
|
|
614
|
+
* @param {Object} [options] - Request options
|
|
615
|
+
* @returns {Promise<Object>} HTTP response
|
|
616
|
+
*/
|
|
617
|
+
options(url, options = {}) {
|
|
618
|
+
return this.request({ ...options, url, method: 'OPTIONS' });
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/**
|
|
622
|
+
* POST request
|
|
623
|
+
* @param {string} url - Request URL
|
|
624
|
+
* @param {any} [data] - Request body
|
|
625
|
+
* @param {Object} [options] - Request options
|
|
626
|
+
* @returns {Promise<Object>} HTTP response
|
|
627
|
+
*/
|
|
628
|
+
post(url, data, options = {}) {
|
|
629
|
+
return this.request({ ...options, url, method: 'POST', data });
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/**
|
|
633
|
+
* PUT request
|
|
634
|
+
* @param {string} url - Request URL
|
|
635
|
+
* @param {any} [data] - Request body
|
|
636
|
+
* @param {Object} [options] - Request options
|
|
637
|
+
* @returns {Promise<Object>} HTTP response
|
|
638
|
+
*/
|
|
639
|
+
put(url, data, options = {}) {
|
|
640
|
+
return this.request({ ...options, url, method: 'PUT', data });
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* PATCH request
|
|
645
|
+
* @param {string} url - Request URL
|
|
646
|
+
* @param {any} [data] - Request body
|
|
647
|
+
* @param {Object} [options] - Request options
|
|
648
|
+
* @returns {Promise<Object>} HTTP response
|
|
649
|
+
*/
|
|
650
|
+
patch(url, data, options = {}) {
|
|
651
|
+
return this.request({ ...options, url, method: 'PATCH', data });
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/**
|
|
655
|
+
* Create a new HttpClient instance with merged config
|
|
656
|
+
* @param {Object} [config={}] - Configuration to merge
|
|
657
|
+
* @returns {HttpClient} New client instance
|
|
658
|
+
*/
|
|
659
|
+
create(config = {}) {
|
|
660
|
+
return new HttpClient(this.#mergeConfig(config));
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/**
|
|
664
|
+
* Get the full URI for a request config
|
|
665
|
+
* @param {Object} config - Request configuration
|
|
666
|
+
* @returns {string} Full URL
|
|
667
|
+
*/
|
|
668
|
+
getUri(config) {
|
|
669
|
+
const mergedConfig = this.#mergeConfig(config);
|
|
670
|
+
return this.#buildURL(mergedConfig.url || '', mergedConfig);
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/**
|
|
674
|
+
* Check if an error was caused by request cancellation
|
|
675
|
+
* @param {any} error - The error to check
|
|
676
|
+
* @returns {boolean} True if the error is a cancellation
|
|
677
|
+
*/
|
|
678
|
+
isCancel(error) {
|
|
679
|
+
return HttpError.isHttpError(error) && error.code === 'ABORT';
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
// ============================================================================
|
|
684
|
+
// Factory Function
|
|
685
|
+
// ============================================================================
|
|
686
|
+
|
|
687
|
+
/**
|
|
688
|
+
* Create a new HTTP client instance
|
|
689
|
+
* @param {Object} [config={}] - Default configuration
|
|
690
|
+
* @param {string} [config.baseURL] - Base URL for all requests
|
|
691
|
+
* @param {number} [config.timeout=10000] - Request timeout in ms
|
|
692
|
+
* @param {Object} [config.headers] - Default headers
|
|
693
|
+
* @param {boolean} [config.withCredentials=false] - Include credentials
|
|
694
|
+
* @param {string} [config.responseType='json'] - Response type (json, text, blob, arrayBuffer)
|
|
695
|
+
* @param {Function} [config.validateStatus] - Function to validate response status
|
|
696
|
+
* @param {number} [config.retries=0] - Number of retry attempts
|
|
697
|
+
* @param {number} [config.retryDelay=1000] - Delay between retries in ms
|
|
698
|
+
* @param {Function} [config.retryCondition] - Custom retry condition function
|
|
699
|
+
* @returns {HttpClient} HTTP client instance
|
|
700
|
+
*
|
|
701
|
+
* @example
|
|
702
|
+
* const api = createHttp({
|
|
703
|
+
* baseURL: 'https://api.example.com',
|
|
704
|
+
* timeout: 5000,
|
|
705
|
+
* headers: { 'Authorization': 'Bearer token' }
|
|
706
|
+
* });
|
|
707
|
+
*
|
|
708
|
+
* const users = await api.get('/users');
|
|
709
|
+
* const user = await api.post('/users', { name: 'John' });
|
|
710
|
+
*/
|
|
711
|
+
export function createHttp(config = {}) {
|
|
712
|
+
return new HttpClient(config);
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
// ============================================================================
|
|
716
|
+
// Reactive Integration
|
|
717
|
+
// ============================================================================
|
|
718
|
+
|
|
719
|
+
/**
|
|
720
|
+
* Reactive HTTP hook integrating with Pulse's useAsync
|
|
721
|
+
* @param {Function} requestFn - Function that returns a promise (from http.get, etc.)
|
|
722
|
+
* @param {Object} [options={}] - Hook options
|
|
723
|
+
* @param {boolean} [options.immediate=true] - Execute immediately
|
|
724
|
+
* @param {any} [options.initialData=null] - Initial data value
|
|
725
|
+
* @param {number} [options.retries=0] - Retry attempts
|
|
726
|
+
* @param {number} [options.retryDelay=1000] - Delay between retries
|
|
727
|
+
* @param {Function} [options.onSuccess] - Success callback
|
|
728
|
+
* @param {Function} [options.onError] - Error callback
|
|
729
|
+
* @returns {Object} Reactive state and controls
|
|
730
|
+
*
|
|
731
|
+
* @example
|
|
732
|
+
* const { data, loading, error, execute } = useHttp(
|
|
733
|
+
* () => api.get('/users'),
|
|
734
|
+
* { immediate: true, retries: 3 }
|
|
735
|
+
* );
|
|
736
|
+
*
|
|
737
|
+
* effect(() => {
|
|
738
|
+
* if (data.get()) console.log('Users:', data.get());
|
|
739
|
+
* });
|
|
740
|
+
*/
|
|
741
|
+
export function useHttp(requestFn, options = {}) {
|
|
742
|
+
const {
|
|
743
|
+
immediate = true,
|
|
744
|
+
initialData = null,
|
|
745
|
+
retries = 0,
|
|
746
|
+
retryDelay = 1000,
|
|
747
|
+
onSuccess,
|
|
748
|
+
onError
|
|
749
|
+
} = options;
|
|
750
|
+
|
|
751
|
+
const asyncState = useAsync(
|
|
752
|
+
async (...args) => {
|
|
753
|
+
const response = await requestFn(...args);
|
|
754
|
+
return response;
|
|
755
|
+
},
|
|
756
|
+
{
|
|
757
|
+
immediate,
|
|
758
|
+
initialData: initialData !== null ? { data: initialData } : null,
|
|
759
|
+
retries,
|
|
760
|
+
retryDelay,
|
|
761
|
+
onSuccess: onSuccess ? (response) => onSuccess(response) : undefined,
|
|
762
|
+
onError: onError ? (error) => {
|
|
763
|
+
if (HttpError.isHttpError(error)) {
|
|
764
|
+
onError(error);
|
|
765
|
+
} else {
|
|
766
|
+
onError(new HttpError(error.message, { code: 'NETWORK' }));
|
|
767
|
+
}
|
|
768
|
+
} : undefined
|
|
769
|
+
}
|
|
770
|
+
);
|
|
771
|
+
|
|
772
|
+
// Convenience accessor for response data
|
|
773
|
+
const data = computed(() => asyncState.data.get()?.data ?? initialData);
|
|
774
|
+
const response = asyncState.data;
|
|
775
|
+
|
|
776
|
+
return {
|
|
777
|
+
data,
|
|
778
|
+
response,
|
|
779
|
+
loading: asyncState.loading,
|
|
780
|
+
error: asyncState.error,
|
|
781
|
+
status: asyncState.status,
|
|
782
|
+
execute: asyncState.execute,
|
|
783
|
+
reset: asyncState.reset,
|
|
784
|
+
abort: asyncState.abort
|
|
785
|
+
};
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
/**
|
|
789
|
+
* HTTP resource with caching (SWR pattern)
|
|
790
|
+
* Integrates with useResource from async.js
|
|
791
|
+
* @param {string|Function} key - Cache key or function returning key
|
|
792
|
+
* @param {Function} requestFn - Function that returns a promise
|
|
793
|
+
* @param {Object} [options={}] - Resource options
|
|
794
|
+
* @returns {Object} Resource state and controls
|
|
795
|
+
*
|
|
796
|
+
* @example
|
|
797
|
+
* const users = useHttpResource(
|
|
798
|
+
* 'users',
|
|
799
|
+
* () => api.get('/users'),
|
|
800
|
+
* { refreshInterval: 30000 }
|
|
801
|
+
* );
|
|
802
|
+
*/
|
|
803
|
+
export function useHttpResource(key, requestFn, options = {}) {
|
|
804
|
+
return useResource(
|
|
805
|
+
key,
|
|
806
|
+
async () => {
|
|
807
|
+
const response = await requestFn();
|
|
808
|
+
return response.data;
|
|
809
|
+
},
|
|
810
|
+
options
|
|
811
|
+
);
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
// ============================================================================
|
|
815
|
+
// Default Instance
|
|
816
|
+
// ============================================================================
|
|
817
|
+
|
|
818
|
+
/**
|
|
819
|
+
* Pre-configured default HTTP client instance
|
|
820
|
+
*/
|
|
821
|
+
export const http = createHttp();
|
|
822
|
+
|
|
823
|
+
// ============================================================================
|
|
824
|
+
// Exports
|
|
825
|
+
// ============================================================================
|
|
826
|
+
|
|
827
|
+
export { HttpClient, InterceptorManager };
|
|
828
|
+
|
|
829
|
+
export default {
|
|
830
|
+
createHttp,
|
|
831
|
+
http,
|
|
832
|
+
HttpClient,
|
|
833
|
+
HttpError,
|
|
834
|
+
InterceptorManager,
|
|
835
|
+
useHttp,
|
|
836
|
+
useHttpResource
|
|
837
|
+
};
|