@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 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
@@ -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
+ }