@vertz/fetch 0.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/README.md +475 -0
- package/dist/index.d.ts +114 -0
- package/dist/index.js +363 -0
- package/package.json +44 -0
package/README.md
ADDED
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
# @vertz/fetch
|
|
2
|
+
|
|
3
|
+
Type-safe HTTP client for Vertz with automatic retries, streaming support, and flexible authentication strategies.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **Type-safe requests** — Full TypeScript inference for request/response types
|
|
8
|
+
- **Automatic retries** — Exponential/linear backoff with configurable retry logic
|
|
9
|
+
- **Streaming support** — Server-Sent Events (SSE) and newline-delimited JSON (NDJSON)
|
|
10
|
+
- **Flexible authentication** — Bearer tokens, Basic auth, API keys, or custom strategies
|
|
11
|
+
- **Request/response hooks** — Intercept and transform at every stage
|
|
12
|
+
- **Error handling** — Typed error classes for all HTTP status codes
|
|
13
|
+
- **Timeout management** — Automatic timeout with AbortSignal support
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npm install @vertz/fetch
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Quick Start
|
|
22
|
+
|
|
23
|
+
```typescript
|
|
24
|
+
import { FetchClient } from '@vertz/fetch';
|
|
25
|
+
|
|
26
|
+
// Create a client with base configuration
|
|
27
|
+
const client = new FetchClient({
|
|
28
|
+
baseURL: 'https://api.example.com',
|
|
29
|
+
headers: {
|
|
30
|
+
'User-Agent': 'MyApp/1.0',
|
|
31
|
+
},
|
|
32
|
+
timeoutMs: 5000,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// Make a typed GET request
|
|
36
|
+
const response = await client.request<{ id: number; name: string }>('GET', '/users/1');
|
|
37
|
+
console.log(response.data.name); // Fully typed!
|
|
38
|
+
|
|
39
|
+
// POST with body
|
|
40
|
+
const newUser = await client.request<{ id: number }>('POST', '/users', {
|
|
41
|
+
body: { name: 'Alice', email: 'alice@example.com' },
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// Query parameters
|
|
45
|
+
const users = await client.request<{ users: Array<{ id: number }> }>('GET', '/users', {
|
|
46
|
+
query: { page: 1, limit: 10 },
|
|
47
|
+
});
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## API Reference
|
|
51
|
+
|
|
52
|
+
### `FetchClient`
|
|
53
|
+
|
|
54
|
+
The main client class for making HTTP requests.
|
|
55
|
+
|
|
56
|
+
#### Constructor
|
|
57
|
+
|
|
58
|
+
```typescript
|
|
59
|
+
new FetchClient(config: FetchClientConfig)
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
**Config options:**
|
|
63
|
+
|
|
64
|
+
```typescript
|
|
65
|
+
interface FetchClientConfig {
|
|
66
|
+
/** Base URL for all requests (e.g., 'https://api.example.com') */
|
|
67
|
+
baseURL?: string;
|
|
68
|
+
|
|
69
|
+
/** Default headers added to every request */
|
|
70
|
+
headers?: Record<string, string>;
|
|
71
|
+
|
|
72
|
+
/** Request timeout in milliseconds */
|
|
73
|
+
timeoutMs?: number;
|
|
74
|
+
|
|
75
|
+
/** Retry configuration */
|
|
76
|
+
retry?: {
|
|
77
|
+
retries: number;
|
|
78
|
+
strategy: 'exponential' | 'linear' | ((attempt: number, baseBackoff: number) => number);
|
|
79
|
+
backoffMs: number;
|
|
80
|
+
retryOn: number[]; // Status codes to retry (default: [429, 500, 502, 503, 504])
|
|
81
|
+
retryOnError?: (error: Error) => boolean;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/** Lifecycle hooks */
|
|
85
|
+
hooks?: {
|
|
86
|
+
beforeRequest?: (request: Request) => void | Promise<void>;
|
|
87
|
+
afterResponse?: (response: Response) => void | Promise<void>;
|
|
88
|
+
onError?: (error: Error) => void | Promise<void>;
|
|
89
|
+
beforeRetry?: (attempt: number, error: Error) => void | Promise<void>;
|
|
90
|
+
onStreamStart?: () => void;
|
|
91
|
+
onStreamChunk?: (chunk: unknown) => void;
|
|
92
|
+
onStreamEnd?: () => void;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
/** Authentication strategies (applied in order) */
|
|
96
|
+
authStrategies?: AuthStrategy[];
|
|
97
|
+
|
|
98
|
+
/** Custom fetch implementation (default: globalThis.fetch) */
|
|
99
|
+
fetch?: typeof fetch;
|
|
100
|
+
|
|
101
|
+
/** Credentials mode */
|
|
102
|
+
credentials?: RequestCredentials;
|
|
103
|
+
}
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
#### Methods
|
|
107
|
+
|
|
108
|
+
##### `request<T>(method, path, options?)`
|
|
109
|
+
|
|
110
|
+
Make a standard HTTP request with JSON response.
|
|
111
|
+
|
|
112
|
+
```typescript
|
|
113
|
+
const response = await client.request<User>('GET', '/users/1');
|
|
114
|
+
const { data, status, headers } = response;
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
**Options:**
|
|
118
|
+
|
|
119
|
+
```typescript
|
|
120
|
+
interface RequestOptions {
|
|
121
|
+
headers?: Record<string, string>;
|
|
122
|
+
query?: Record<string, unknown>;
|
|
123
|
+
body?: unknown; // Automatically JSON-stringified
|
|
124
|
+
signal?: AbortSignal;
|
|
125
|
+
}
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
**Returns:**
|
|
129
|
+
|
|
130
|
+
```typescript
|
|
131
|
+
interface FetchResponse<T> {
|
|
132
|
+
data: T;
|
|
133
|
+
status: number;
|
|
134
|
+
headers: Headers;
|
|
135
|
+
}
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
##### `requestStream<T>(options)`
|
|
139
|
+
|
|
140
|
+
Stream responses using SSE or NDJSON format.
|
|
141
|
+
|
|
142
|
+
```typescript
|
|
143
|
+
for await (const chunk of client.requestStream<LogEntry>({
|
|
144
|
+
method: 'POST',
|
|
145
|
+
path: '/logs/stream',
|
|
146
|
+
format: 'sse', // or 'ndjson'
|
|
147
|
+
body: { query: 'error' },
|
|
148
|
+
})) {
|
|
149
|
+
console.log(chunk); // Typed as LogEntry
|
|
150
|
+
}
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
**Options:**
|
|
154
|
+
|
|
155
|
+
```typescript
|
|
156
|
+
interface StreamingRequestOptions {
|
|
157
|
+
method: string;
|
|
158
|
+
path: string;
|
|
159
|
+
format: 'sse' | 'ndjson';
|
|
160
|
+
headers?: Record<string, string>;
|
|
161
|
+
query?: Record<string, unknown>;
|
|
162
|
+
body?: unknown;
|
|
163
|
+
signal?: AbortSignal;
|
|
164
|
+
}
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
### Authentication Strategies
|
|
168
|
+
|
|
169
|
+
Configure one or more authentication strategies. They're applied in order.
|
|
170
|
+
|
|
171
|
+
#### Bearer Token
|
|
172
|
+
|
|
173
|
+
```typescript
|
|
174
|
+
const client = new FetchClient({
|
|
175
|
+
baseURL: 'https://api.example.com',
|
|
176
|
+
authStrategies: [
|
|
177
|
+
{
|
|
178
|
+
type: 'bearer',
|
|
179
|
+
token: 'your-access-token',
|
|
180
|
+
},
|
|
181
|
+
],
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
// Or with async token retrieval
|
|
185
|
+
const client = new FetchClient({
|
|
186
|
+
authStrategies: [
|
|
187
|
+
{
|
|
188
|
+
type: 'bearer',
|
|
189
|
+
token: async () => await getAccessToken(),
|
|
190
|
+
},
|
|
191
|
+
],
|
|
192
|
+
});
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
#### Basic Auth
|
|
196
|
+
|
|
197
|
+
```typescript
|
|
198
|
+
const client = new FetchClient({
|
|
199
|
+
authStrategies: [
|
|
200
|
+
{
|
|
201
|
+
type: 'basic',
|
|
202
|
+
username: 'user',
|
|
203
|
+
password: 'pass',
|
|
204
|
+
},
|
|
205
|
+
],
|
|
206
|
+
});
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
#### API Key
|
|
210
|
+
|
|
211
|
+
```typescript
|
|
212
|
+
const client = new FetchClient({
|
|
213
|
+
authStrategies: [
|
|
214
|
+
{
|
|
215
|
+
type: 'apiKey',
|
|
216
|
+
key: 'your-api-key',
|
|
217
|
+
location: 'header', // or 'query'
|
|
218
|
+
name: 'X-API-Key', // Header name or query param name
|
|
219
|
+
},
|
|
220
|
+
],
|
|
221
|
+
});
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
#### Custom Strategy
|
|
225
|
+
|
|
226
|
+
```typescript
|
|
227
|
+
const client = new FetchClient({
|
|
228
|
+
authStrategies: [
|
|
229
|
+
{
|
|
230
|
+
type: 'custom',
|
|
231
|
+
apply: async (request) => {
|
|
232
|
+
// Modify the request (e.g., add custom headers)
|
|
233
|
+
request.headers.set('X-Custom-Auth', await getCustomToken());
|
|
234
|
+
return request;
|
|
235
|
+
},
|
|
236
|
+
},
|
|
237
|
+
],
|
|
238
|
+
});
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
### Error Handling
|
|
242
|
+
|
|
243
|
+
All non-2xx responses throw typed error classes:
|
|
244
|
+
|
|
245
|
+
```typescript
|
|
246
|
+
import {
|
|
247
|
+
BadRequestError,
|
|
248
|
+
UnauthorizedError,
|
|
249
|
+
ForbiddenError,
|
|
250
|
+
NotFoundError,
|
|
251
|
+
ConflictError,
|
|
252
|
+
GoneError,
|
|
253
|
+
UnprocessableEntityError,
|
|
254
|
+
RateLimitError,
|
|
255
|
+
InternalServerError,
|
|
256
|
+
ServiceUnavailableError,
|
|
257
|
+
FetchError, // Base class
|
|
258
|
+
} from '@vertz/fetch';
|
|
259
|
+
|
|
260
|
+
try {
|
|
261
|
+
await client.request('GET', '/users/999');
|
|
262
|
+
} catch (error) {
|
|
263
|
+
if (error instanceof NotFoundError) {
|
|
264
|
+
console.error('User not found:', error.statusText);
|
|
265
|
+
console.error('Response body:', error.body);
|
|
266
|
+
} else if (error instanceof RateLimitError) {
|
|
267
|
+
console.error('Rate limited, retry after:', error.statusText);
|
|
268
|
+
}
|
|
269
|
+
throw error;
|
|
270
|
+
}
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
All error classes extend `FetchError` with these properties:
|
|
274
|
+
|
|
275
|
+
```typescript
|
|
276
|
+
class FetchError extends Error {
|
|
277
|
+
status: number;
|
|
278
|
+
statusText: string;
|
|
279
|
+
body?: unknown; // Parsed response body (if available)
|
|
280
|
+
}
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
### Retry Configuration
|
|
284
|
+
|
|
285
|
+
Automatic retries with exponential backoff:
|
|
286
|
+
|
|
287
|
+
```typescript
|
|
288
|
+
const client = new FetchClient({
|
|
289
|
+
baseURL: 'https://api.example.com',
|
|
290
|
+
retry: {
|
|
291
|
+
retries: 3, // Retry up to 3 times
|
|
292
|
+
strategy: 'exponential', // 100ms, 200ms, 400ms, ...
|
|
293
|
+
backoffMs: 100, // Base delay
|
|
294
|
+
retryOn: [429, 500, 502, 503, 504], // Status codes to retry
|
|
295
|
+
},
|
|
296
|
+
hooks: {
|
|
297
|
+
beforeRetry: (attempt, error) => {
|
|
298
|
+
console.log(`Retry attempt ${attempt} after error:`, error.message);
|
|
299
|
+
},
|
|
300
|
+
},
|
|
301
|
+
});
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
Custom backoff strategy:
|
|
305
|
+
|
|
306
|
+
```typescript
|
|
307
|
+
const client = new FetchClient({
|
|
308
|
+
retry: {
|
|
309
|
+
retries: 5,
|
|
310
|
+
strategy: (attempt, baseBackoff) => {
|
|
311
|
+
// Custom: jittered exponential backoff
|
|
312
|
+
const exponential = baseBackoff * 2 ** (attempt - 1);
|
|
313
|
+
const jitter = Math.random() * 0.3 * exponential;
|
|
314
|
+
return exponential + jitter;
|
|
315
|
+
},
|
|
316
|
+
backoffMs: 100,
|
|
317
|
+
retryOn: [429, 500, 502, 503, 504],
|
|
318
|
+
},
|
|
319
|
+
});
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
### Request Lifecycle Hooks
|
|
323
|
+
|
|
324
|
+
Intercept requests and responses at every stage:
|
|
325
|
+
|
|
326
|
+
```typescript
|
|
327
|
+
const client = new FetchClient({
|
|
328
|
+
baseURL: 'https://api.example.com',
|
|
329
|
+
hooks: {
|
|
330
|
+
beforeRequest: async (request) => {
|
|
331
|
+
console.log('Sending:', request.method, request.url);
|
|
332
|
+
},
|
|
333
|
+
afterResponse: async (response) => {
|
|
334
|
+
console.log('Received:', response.status);
|
|
335
|
+
},
|
|
336
|
+
onError: async (error) => {
|
|
337
|
+
console.error('Request failed:', error.message);
|
|
338
|
+
// Send to error tracking service
|
|
339
|
+
await sendToSentry(error);
|
|
340
|
+
},
|
|
341
|
+
beforeRetry: async (attempt, error) => {
|
|
342
|
+
console.log(`Retry ${attempt} after:`, error.message);
|
|
343
|
+
},
|
|
344
|
+
},
|
|
345
|
+
});
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
### Streaming Hooks
|
|
349
|
+
|
|
350
|
+
Monitor streaming responses:
|
|
351
|
+
|
|
352
|
+
```typescript
|
|
353
|
+
const client = new FetchClient({
|
|
354
|
+
hooks: {
|
|
355
|
+
onStreamStart: () => console.log('Stream started'),
|
|
356
|
+
onStreamChunk: (chunk) => console.log('Received chunk:', chunk),
|
|
357
|
+
onStreamEnd: () => console.log('Stream ended'),
|
|
358
|
+
},
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
for await (const event of client.requestStream({
|
|
362
|
+
method: 'GET',
|
|
363
|
+
path: '/events',
|
|
364
|
+
format: 'sse'
|
|
365
|
+
})) {
|
|
366
|
+
// Process event
|
|
367
|
+
}
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
## Integration with @vertz/schema
|
|
371
|
+
|
|
372
|
+
Use `@vertz/schema` for runtime validation of request/response data:
|
|
373
|
+
|
|
374
|
+
```typescript
|
|
375
|
+
import { FetchClient } from '@vertz/fetch';
|
|
376
|
+
import { s } from '@vertz/schema';
|
|
377
|
+
|
|
378
|
+
// Define schemas
|
|
379
|
+
const UserSchema = s.object({
|
|
380
|
+
id: s.number(),
|
|
381
|
+
name: s.string(),
|
|
382
|
+
email: s.email(),
|
|
383
|
+
createdAt: s.string().datetime(),
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
const client = new FetchClient({
|
|
387
|
+
baseURL: 'https://api.example.com',
|
|
388
|
+
hooks: {
|
|
389
|
+
afterResponse: async (response) => {
|
|
390
|
+
// Validate responses in development
|
|
391
|
+
if (process.env.NODE_ENV === 'development') {
|
|
392
|
+
const data = await response.clone().json();
|
|
393
|
+
try {
|
|
394
|
+
UserSchema.parse(data);
|
|
395
|
+
} catch (error) {
|
|
396
|
+
console.error('Response validation failed:', error);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
},
|
|
400
|
+
},
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
// Type-safe request with schema validation
|
|
404
|
+
const response = await client.request<typeof UserSchema._output>('GET', '/users/1');
|
|
405
|
+
|
|
406
|
+
// Or validate explicitly
|
|
407
|
+
const data = await client.request<unknown>('GET', '/users/1');
|
|
408
|
+
const user = UserSchema.parse(data.data); // Throws if invalid
|
|
409
|
+
```
|
|
410
|
+
|
|
411
|
+
## Advanced Examples
|
|
412
|
+
|
|
413
|
+
### Timeout and Cancellation
|
|
414
|
+
|
|
415
|
+
```typescript
|
|
416
|
+
const controller = new AbortController();
|
|
417
|
+
|
|
418
|
+
// Cancel after 3 seconds
|
|
419
|
+
setTimeout(() => controller.abort(), 3000);
|
|
420
|
+
|
|
421
|
+
try {
|
|
422
|
+
const response = await client.request('GET', '/slow-endpoint', {
|
|
423
|
+
signal: controller.signal,
|
|
424
|
+
});
|
|
425
|
+
} catch (error) {
|
|
426
|
+
if (error.name === 'AbortError') {
|
|
427
|
+
console.error('Request cancelled');
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
```
|
|
431
|
+
|
|
432
|
+
### Multiple Auth Strategies
|
|
433
|
+
|
|
434
|
+
Apply multiple strategies in sequence (e.g., API key + Bearer token):
|
|
435
|
+
|
|
436
|
+
```typescript
|
|
437
|
+
const client = new FetchClient({
|
|
438
|
+
authStrategies: [
|
|
439
|
+
{ type: 'apiKey', key: 'api-key', location: 'header', name: 'X-API-Key' },
|
|
440
|
+
{ type: 'bearer', token: async () => await getAccessToken() },
|
|
441
|
+
],
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
// Both headers will be set:
|
|
445
|
+
// X-API-Key: api-key
|
|
446
|
+
// Authorization: Bearer <token>
|
|
447
|
+
```
|
|
448
|
+
|
|
449
|
+
### Custom Fetch Implementation
|
|
450
|
+
|
|
451
|
+
Use a custom fetch implementation (e.g., for testing):
|
|
452
|
+
|
|
453
|
+
```typescript
|
|
454
|
+
const client = new FetchClient({
|
|
455
|
+
fetch: async (request) => {
|
|
456
|
+
// Custom logic (e.g., mock responses, logging)
|
|
457
|
+
console.log('Custom fetch:', request.url);
|
|
458
|
+
return globalThis.fetch(request);
|
|
459
|
+
},
|
|
460
|
+
});
|
|
461
|
+
```
|
|
462
|
+
|
|
463
|
+
## Best Practices
|
|
464
|
+
|
|
465
|
+
1. **Reuse client instances** — Create one client per base URL, not per request
|
|
466
|
+
2. **Use typed responses** — Always specify the response type for better IDE support
|
|
467
|
+
3. **Handle errors explicitly** — Catch specific error classes for better error handling
|
|
468
|
+
4. **Configure retries wisely** — Use exponential backoff for transient failures
|
|
469
|
+
5. **Add request logging in development** — Use `beforeRequest` hook for debugging
|
|
470
|
+
6. **Validate responses in development** — Use `@vertz/schema` + `afterResponse` hook
|
|
471
|
+
7. **Use streaming for large responses** — `requestStream` is more memory-efficient
|
|
472
|
+
|
|
473
|
+
## License
|
|
474
|
+
|
|
475
|
+
MIT
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
type AuthStrategy = {
|
|
2
|
+
type: "bearer";
|
|
3
|
+
token: string | (() => string | Promise<string>);
|
|
4
|
+
} | {
|
|
5
|
+
type: "basic";
|
|
6
|
+
username: string;
|
|
7
|
+
password: string;
|
|
8
|
+
} | {
|
|
9
|
+
type: "apiKey";
|
|
10
|
+
key: string | (() => string | Promise<string>);
|
|
11
|
+
location: "header" | "query" | "cookie";
|
|
12
|
+
name: string;
|
|
13
|
+
} | {
|
|
14
|
+
type: "custom";
|
|
15
|
+
apply: (request: Request) => Request | Promise<Request>;
|
|
16
|
+
};
|
|
17
|
+
interface RetryConfig {
|
|
18
|
+
retries: number;
|
|
19
|
+
strategy: "exponential" | "linear" | ((attempt: number, baseBackoff: number) => number);
|
|
20
|
+
backoffMs: number;
|
|
21
|
+
retryOn: number[];
|
|
22
|
+
retryOnError?: (error: Error) => boolean;
|
|
23
|
+
}
|
|
24
|
+
type StreamingFormat = "sse" | "ndjson";
|
|
25
|
+
interface HooksConfig {
|
|
26
|
+
beforeRequest?: (request: Request) => void | Promise<void>;
|
|
27
|
+
afterResponse?: (response: Response) => void | Promise<void>;
|
|
28
|
+
onError?: (error: Error) => void | Promise<void>;
|
|
29
|
+
beforeRetry?: (attempt: number, error: Error) => void | Promise<void>;
|
|
30
|
+
onStreamStart?: () => void;
|
|
31
|
+
onStreamChunk?: (chunk: unknown) => void;
|
|
32
|
+
onStreamEnd?: () => void;
|
|
33
|
+
}
|
|
34
|
+
interface FetchClientConfig {
|
|
35
|
+
baseURL?: string;
|
|
36
|
+
headers?: Record<string, string>;
|
|
37
|
+
timeoutMs?: number;
|
|
38
|
+
retry?: Partial<RetryConfig>;
|
|
39
|
+
hooks?: HooksConfig;
|
|
40
|
+
authStrategies?: AuthStrategy[];
|
|
41
|
+
fetch?: typeof fetch;
|
|
42
|
+
credentials?: RequestCredentials;
|
|
43
|
+
}
|
|
44
|
+
interface RequestOptions {
|
|
45
|
+
headers?: Record<string, string>;
|
|
46
|
+
query?: Record<string, unknown>;
|
|
47
|
+
body?: unknown;
|
|
48
|
+
signal?: AbortSignal;
|
|
49
|
+
}
|
|
50
|
+
interface FetchResponse<T> {
|
|
51
|
+
data: T;
|
|
52
|
+
status: number;
|
|
53
|
+
headers: Headers;
|
|
54
|
+
}
|
|
55
|
+
interface StreamingRequestOptions extends RequestOptions {
|
|
56
|
+
format: StreamingFormat;
|
|
57
|
+
}
|
|
58
|
+
declare class FetchClient {
|
|
59
|
+
private readonly config;
|
|
60
|
+
private readonly fetchFn;
|
|
61
|
+
constructor(config: FetchClientConfig);
|
|
62
|
+
request<T>(method: string, path: string, options?: RequestOptions): Promise<FetchResponse<T>>;
|
|
63
|
+
requestStream<T>(options: StreamingRequestOptions & {
|
|
64
|
+
method: string;
|
|
65
|
+
path: string;
|
|
66
|
+
}): AsyncGenerator<T>;
|
|
67
|
+
private parseSSEBuffer;
|
|
68
|
+
private parseNDJSONBuffer;
|
|
69
|
+
private buildSignal;
|
|
70
|
+
private resolveRetryConfig;
|
|
71
|
+
private calculateBackoff;
|
|
72
|
+
private sleep;
|
|
73
|
+
private buildURL;
|
|
74
|
+
private applyAuth;
|
|
75
|
+
private applyStrategy;
|
|
76
|
+
private safeParseJSON;
|
|
77
|
+
}
|
|
78
|
+
declare class FetchError extends Error {
|
|
79
|
+
readonly status: number;
|
|
80
|
+
readonly body?: unknown;
|
|
81
|
+
constructor(message: string, status: number, body?: unknown);
|
|
82
|
+
}
|
|
83
|
+
declare class BadRequestError extends FetchError {
|
|
84
|
+
constructor(message: string, body?: unknown);
|
|
85
|
+
}
|
|
86
|
+
declare class UnauthorizedError extends FetchError {
|
|
87
|
+
constructor(message: string, body?: unknown);
|
|
88
|
+
}
|
|
89
|
+
declare class ForbiddenError extends FetchError {
|
|
90
|
+
constructor(message: string, body?: unknown);
|
|
91
|
+
}
|
|
92
|
+
declare class NotFoundError extends FetchError {
|
|
93
|
+
constructor(message: string, body?: unknown);
|
|
94
|
+
}
|
|
95
|
+
declare class ConflictError extends FetchError {
|
|
96
|
+
constructor(message: string, body?: unknown);
|
|
97
|
+
}
|
|
98
|
+
declare class GoneError extends FetchError {
|
|
99
|
+
constructor(message: string, body?: unknown);
|
|
100
|
+
}
|
|
101
|
+
declare class UnprocessableEntityError extends FetchError {
|
|
102
|
+
constructor(message: string, body?: unknown);
|
|
103
|
+
}
|
|
104
|
+
declare class RateLimitError extends FetchError {
|
|
105
|
+
constructor(message: string, body?: unknown);
|
|
106
|
+
}
|
|
107
|
+
declare class InternalServerError extends FetchError {
|
|
108
|
+
constructor(message: string, body?: unknown);
|
|
109
|
+
}
|
|
110
|
+
declare class ServiceUnavailableError extends FetchError {
|
|
111
|
+
constructor(message: string, body?: unknown);
|
|
112
|
+
}
|
|
113
|
+
declare function createErrorFromStatus(status: number, message: string, body?: unknown): FetchError;
|
|
114
|
+
export { createErrorFromStatus, UnprocessableEntityError, UnauthorizedError, StreamingRequestOptions, StreamingFormat, ServiceUnavailableError, RetryConfig, RequestOptions, RateLimitError, NotFoundError, InternalServerError, HooksConfig, GoneError, ForbiddenError, FetchResponse, FetchError, FetchClientConfig, FetchClient, ConflictError, BadRequestError, AuthStrategy };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
// src/errors.ts
|
|
2
|
+
class FetchError extends Error {
|
|
3
|
+
status;
|
|
4
|
+
body;
|
|
5
|
+
constructor(message, status, body) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.name = "FetchError";
|
|
8
|
+
this.status = status;
|
|
9
|
+
this.body = body;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
class BadRequestError extends FetchError {
|
|
14
|
+
constructor(message, body) {
|
|
15
|
+
super(message, 400, body);
|
|
16
|
+
this.name = "BadRequestError";
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
class UnauthorizedError extends FetchError {
|
|
21
|
+
constructor(message, body) {
|
|
22
|
+
super(message, 401, body);
|
|
23
|
+
this.name = "UnauthorizedError";
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
class ForbiddenError extends FetchError {
|
|
28
|
+
constructor(message, body) {
|
|
29
|
+
super(message, 403, body);
|
|
30
|
+
this.name = "ForbiddenError";
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
class NotFoundError extends FetchError {
|
|
35
|
+
constructor(message, body) {
|
|
36
|
+
super(message, 404, body);
|
|
37
|
+
this.name = "NotFoundError";
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
class ConflictError extends FetchError {
|
|
42
|
+
constructor(message, body) {
|
|
43
|
+
super(message, 409, body);
|
|
44
|
+
this.name = "ConflictError";
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
class GoneError extends FetchError {
|
|
49
|
+
constructor(message, body) {
|
|
50
|
+
super(message, 410, body);
|
|
51
|
+
this.name = "GoneError";
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
class UnprocessableEntityError extends FetchError {
|
|
56
|
+
constructor(message, body) {
|
|
57
|
+
super(message, 422, body);
|
|
58
|
+
this.name = "UnprocessableEntityError";
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
class RateLimitError extends FetchError {
|
|
63
|
+
constructor(message, body) {
|
|
64
|
+
super(message, 429, body);
|
|
65
|
+
this.name = "RateLimitError";
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
class InternalServerError extends FetchError {
|
|
70
|
+
constructor(message, body) {
|
|
71
|
+
super(message, 500, body);
|
|
72
|
+
this.name = "InternalServerError";
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
class ServiceUnavailableError extends FetchError {
|
|
77
|
+
constructor(message, body) {
|
|
78
|
+
super(message, 503, body);
|
|
79
|
+
this.name = "ServiceUnavailableError";
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
var errorMap = {
|
|
83
|
+
400: BadRequestError,
|
|
84
|
+
401: UnauthorizedError,
|
|
85
|
+
403: ForbiddenError,
|
|
86
|
+
404: NotFoundError,
|
|
87
|
+
409: ConflictError,
|
|
88
|
+
410: GoneError,
|
|
89
|
+
422: UnprocessableEntityError,
|
|
90
|
+
429: RateLimitError,
|
|
91
|
+
500: InternalServerError,
|
|
92
|
+
503: ServiceUnavailableError
|
|
93
|
+
};
|
|
94
|
+
function createErrorFromStatus(status, message, body) {
|
|
95
|
+
const ErrorClass = errorMap[status];
|
|
96
|
+
if (ErrorClass) {
|
|
97
|
+
return new ErrorClass(message, body);
|
|
98
|
+
}
|
|
99
|
+
return new FetchError(message, status, body);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// src/client.ts
|
|
103
|
+
var DEFAULT_RETRY_ON = [429, 500, 502, 503, 504];
|
|
104
|
+
|
|
105
|
+
class FetchClient {
|
|
106
|
+
config;
|
|
107
|
+
fetchFn;
|
|
108
|
+
constructor(config) {
|
|
109
|
+
this.config = config;
|
|
110
|
+
this.fetchFn = config.fetch ?? globalThis.fetch;
|
|
111
|
+
}
|
|
112
|
+
async request(method, path, options) {
|
|
113
|
+
const retryConfig = this.resolveRetryConfig();
|
|
114
|
+
let lastError;
|
|
115
|
+
for (let attempt = 0;attempt <= retryConfig.retries; attempt++) {
|
|
116
|
+
if (attempt > 0) {
|
|
117
|
+
const delay = this.calculateBackoff(attempt, retryConfig);
|
|
118
|
+
await this.sleep(delay);
|
|
119
|
+
}
|
|
120
|
+
const url = this.buildURL(path, options?.query);
|
|
121
|
+
const headers = new Headers(this.config.headers);
|
|
122
|
+
if (options?.headers) {
|
|
123
|
+
for (const [key, value] of Object.entries(options.headers)) {
|
|
124
|
+
headers.set(key, value);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
const signal = this.buildSignal(options?.signal);
|
|
128
|
+
const request = new Request(url, {
|
|
129
|
+
method,
|
|
130
|
+
headers,
|
|
131
|
+
body: options?.body !== undefined ? JSON.stringify(options.body) : undefined,
|
|
132
|
+
signal
|
|
133
|
+
});
|
|
134
|
+
if (options?.body !== undefined) {
|
|
135
|
+
request.headers.set("Content-Type", "application/json");
|
|
136
|
+
}
|
|
137
|
+
const authedRequest = await this.applyAuth(request);
|
|
138
|
+
await this.config.hooks?.beforeRequest?.(authedRequest);
|
|
139
|
+
const response = await this.fetchFn(authedRequest);
|
|
140
|
+
if (!response.ok) {
|
|
141
|
+
const body = await this.safeParseJSON(response);
|
|
142
|
+
const error = createErrorFromStatus(response.status, response.statusText, body);
|
|
143
|
+
if (attempt < retryConfig.retries && retryConfig.retryOn.includes(response.status)) {
|
|
144
|
+
lastError = error;
|
|
145
|
+
await this.config.hooks?.beforeRetry?.(attempt + 1, error);
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
await this.config.hooks?.onError?.(error);
|
|
149
|
+
throw error;
|
|
150
|
+
}
|
|
151
|
+
await this.config.hooks?.afterResponse?.(response);
|
|
152
|
+
const data = await response.json();
|
|
153
|
+
return {
|
|
154
|
+
data,
|
|
155
|
+
status: response.status,
|
|
156
|
+
headers: response.headers
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
throw lastError;
|
|
160
|
+
}
|
|
161
|
+
async* requestStream(options) {
|
|
162
|
+
const url = this.buildURL(options.path, options.query);
|
|
163
|
+
const headers = new Headers(this.config.headers);
|
|
164
|
+
if (options.headers) {
|
|
165
|
+
for (const [key, value] of Object.entries(options.headers)) {
|
|
166
|
+
headers.set(key, value);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
if (options.format === "sse") {
|
|
170
|
+
headers.set("Accept", "text/event-stream");
|
|
171
|
+
} else {
|
|
172
|
+
headers.set("Accept", "application/x-ndjson");
|
|
173
|
+
}
|
|
174
|
+
const signal = this.buildSignal(options.signal);
|
|
175
|
+
const request = new Request(url, {
|
|
176
|
+
method: options.method,
|
|
177
|
+
headers,
|
|
178
|
+
body: options.body !== undefined ? JSON.stringify(options.body) : undefined,
|
|
179
|
+
signal
|
|
180
|
+
});
|
|
181
|
+
const authedRequest = await this.applyAuth(request);
|
|
182
|
+
await this.config.hooks?.beforeRequest?.(authedRequest);
|
|
183
|
+
const response = await this.fetchFn(authedRequest);
|
|
184
|
+
if (!response.ok) {
|
|
185
|
+
const body = await this.safeParseJSON(response);
|
|
186
|
+
const error = createErrorFromStatus(response.status, response.statusText, body);
|
|
187
|
+
await this.config.hooks?.onError?.(error);
|
|
188
|
+
throw error;
|
|
189
|
+
}
|
|
190
|
+
if (!response.body) {
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
this.config.hooks?.onStreamStart?.();
|
|
194
|
+
const reader = response.body.getReader();
|
|
195
|
+
const decoder = new TextDecoder;
|
|
196
|
+
let buffer = "";
|
|
197
|
+
try {
|
|
198
|
+
while (true) {
|
|
199
|
+
const { done, value } = await reader.read();
|
|
200
|
+
if (done)
|
|
201
|
+
break;
|
|
202
|
+
buffer += decoder.decode(value, { stream: true });
|
|
203
|
+
if (options.format === "sse") {
|
|
204
|
+
yield* this.parseSSEBuffer(buffer, (remaining) => {
|
|
205
|
+
buffer = remaining;
|
|
206
|
+
});
|
|
207
|
+
} else {
|
|
208
|
+
yield* this.parseNDJSONBuffer(buffer, (remaining) => {
|
|
209
|
+
buffer = remaining;
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
} finally {
|
|
214
|
+
reader.releaseLock();
|
|
215
|
+
this.config.hooks?.onStreamEnd?.();
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
*parseSSEBuffer(buffer, setRemaining) {
|
|
219
|
+
const events = buffer.split(`
|
|
220
|
+
|
|
221
|
+
`);
|
|
222
|
+
const remaining = events.pop() ?? "";
|
|
223
|
+
setRemaining(remaining);
|
|
224
|
+
for (const event of events) {
|
|
225
|
+
if (!event.trim())
|
|
226
|
+
continue;
|
|
227
|
+
const lines = event.split(`
|
|
228
|
+
`);
|
|
229
|
+
let data = "";
|
|
230
|
+
for (const line of lines) {
|
|
231
|
+
if (line.startsWith("data: ")) {
|
|
232
|
+
data += line.slice(6);
|
|
233
|
+
} else if (line.startsWith("data:")) {
|
|
234
|
+
data += line.slice(5);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
if (data) {
|
|
238
|
+
const parsed = JSON.parse(data);
|
|
239
|
+
this.config.hooks?.onStreamChunk?.(parsed);
|
|
240
|
+
yield parsed;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
*parseNDJSONBuffer(buffer, setRemaining) {
|
|
245
|
+
const lines = buffer.split(`
|
|
246
|
+
`);
|
|
247
|
+
const remaining = lines.pop() ?? "";
|
|
248
|
+
setRemaining(remaining);
|
|
249
|
+
for (const line of lines) {
|
|
250
|
+
if (!line.trim())
|
|
251
|
+
continue;
|
|
252
|
+
const parsed = JSON.parse(line);
|
|
253
|
+
this.config.hooks?.onStreamChunk?.(parsed);
|
|
254
|
+
yield parsed;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
buildSignal(userSignal) {
|
|
258
|
+
const timeoutMs = this.config.timeoutMs;
|
|
259
|
+
if (!timeoutMs && !userSignal)
|
|
260
|
+
return;
|
|
261
|
+
if (!timeoutMs)
|
|
262
|
+
return userSignal;
|
|
263
|
+
const timeoutSignal = AbortSignal.timeout(timeoutMs);
|
|
264
|
+
if (!userSignal)
|
|
265
|
+
return timeoutSignal;
|
|
266
|
+
return AbortSignal.any([userSignal, timeoutSignal]);
|
|
267
|
+
}
|
|
268
|
+
resolveRetryConfig() {
|
|
269
|
+
const userConfig = this.config.retry;
|
|
270
|
+
return {
|
|
271
|
+
retries: userConfig?.retries ?? 0,
|
|
272
|
+
strategy: userConfig?.strategy ?? "exponential",
|
|
273
|
+
backoffMs: userConfig?.backoffMs ?? 100,
|
|
274
|
+
retryOn: userConfig?.retryOn ?? DEFAULT_RETRY_ON,
|
|
275
|
+
retryOnError: userConfig?.retryOnError
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
calculateBackoff(attempt, config) {
|
|
279
|
+
const { strategy, backoffMs } = config;
|
|
280
|
+
if (typeof strategy === "function") {
|
|
281
|
+
return strategy(attempt, backoffMs);
|
|
282
|
+
}
|
|
283
|
+
if (strategy === "linear") {
|
|
284
|
+
return backoffMs * attempt;
|
|
285
|
+
}
|
|
286
|
+
return backoffMs * 2 ** (attempt - 1);
|
|
287
|
+
}
|
|
288
|
+
sleep(ms) {
|
|
289
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
290
|
+
}
|
|
291
|
+
buildURL(path, query) {
|
|
292
|
+
const base = this.config.baseURL;
|
|
293
|
+
const url = base ? new URL(path, base) : new URL(path);
|
|
294
|
+
if (query) {
|
|
295
|
+
for (const [key, value] of Object.entries(query)) {
|
|
296
|
+
if (value !== undefined && value !== null) {
|
|
297
|
+
url.searchParams.set(key, String(value));
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
return url.toString();
|
|
302
|
+
}
|
|
303
|
+
async applyAuth(request) {
|
|
304
|
+
const strategies = this.config.authStrategies;
|
|
305
|
+
if (!strategies)
|
|
306
|
+
return request;
|
|
307
|
+
let current = request;
|
|
308
|
+
for (const strategy of strategies) {
|
|
309
|
+
current = await this.applyStrategy(current, strategy);
|
|
310
|
+
}
|
|
311
|
+
return current;
|
|
312
|
+
}
|
|
313
|
+
async applyStrategy(request, strategy) {
|
|
314
|
+
switch (strategy.type) {
|
|
315
|
+
case "bearer": {
|
|
316
|
+
const token = typeof strategy.token === "function" ? await strategy.token() : strategy.token;
|
|
317
|
+
request.headers.set("Authorization", `Bearer ${token}`);
|
|
318
|
+
return request;
|
|
319
|
+
}
|
|
320
|
+
case "basic": {
|
|
321
|
+
const encoded = btoa(`${strategy.username}:${strategy.password}`);
|
|
322
|
+
request.headers.set("Authorization", `Basic ${encoded}`);
|
|
323
|
+
return request;
|
|
324
|
+
}
|
|
325
|
+
case "apiKey": {
|
|
326
|
+
const key = typeof strategy.key === "function" ? await strategy.key() : strategy.key;
|
|
327
|
+
if (strategy.location === "header") {
|
|
328
|
+
request.headers.set(strategy.name, key);
|
|
329
|
+
} else if (strategy.location === "query") {
|
|
330
|
+
const url = new URL(request.url);
|
|
331
|
+
url.searchParams.set(strategy.name, key);
|
|
332
|
+
return new Request(url, request);
|
|
333
|
+
}
|
|
334
|
+
return request;
|
|
335
|
+
}
|
|
336
|
+
case "custom": {
|
|
337
|
+
return await strategy.apply(request);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
async safeParseJSON(response) {
|
|
342
|
+
try {
|
|
343
|
+
return await response.json();
|
|
344
|
+
} catch {
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
export {
|
|
350
|
+
createErrorFromStatus,
|
|
351
|
+
UnprocessableEntityError,
|
|
352
|
+
UnauthorizedError,
|
|
353
|
+
ServiceUnavailableError,
|
|
354
|
+
RateLimitError,
|
|
355
|
+
NotFoundError,
|
|
356
|
+
InternalServerError,
|
|
357
|
+
GoneError,
|
|
358
|
+
ForbiddenError,
|
|
359
|
+
FetchError,
|
|
360
|
+
FetchClient,
|
|
361
|
+
ConflictError,
|
|
362
|
+
BadRequestError
|
|
363
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@vertz/fetch",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"description": "Type-safe HTTP client for Vertz",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/vertz-dev/vertz.git",
|
|
10
|
+
"directory": "packages/fetch"
|
|
11
|
+
},
|
|
12
|
+
"publishConfig": {
|
|
13
|
+
"access": "public",
|
|
14
|
+
"provenance": true
|
|
15
|
+
},
|
|
16
|
+
"main": "dist/index.js",
|
|
17
|
+
"types": "dist/index.d.ts",
|
|
18
|
+
"exports": {
|
|
19
|
+
".": {
|
|
20
|
+
"import": "./dist/index.js",
|
|
21
|
+
"types": "./dist/index.d.ts"
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"dist"
|
|
26
|
+
],
|
|
27
|
+
"scripts": {
|
|
28
|
+
"build": "bunup",
|
|
29
|
+
"test": "vitest run",
|
|
30
|
+
"test:watch": "vitest",
|
|
31
|
+
"typecheck": "tsc --noEmit"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"@types/node": "^22.0.0",
|
|
35
|
+
"@vitest/coverage-v8": "^4.0.18",
|
|
36
|
+
"bunup": "latest",
|
|
37
|
+
"typescript": "^5.7.0",
|
|
38
|
+
"vitest": "^4.0.18"
|
|
39
|
+
},
|
|
40
|
+
"engines": {
|
|
41
|
+
"node": ">=22"
|
|
42
|
+
},
|
|
43
|
+
"sideEffects": false
|
|
44
|
+
}
|