@mantiq/helpers 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/Http.ts ADDED
@@ -0,0 +1,573 @@
1
+ /**
2
+ * Fluent HTTP client with batch requests, request pipelines, and middleware.
3
+ *
4
+ * @example
5
+ * ```ts
6
+ * // Simple request
7
+ * const { data } = await Http.get('https://api.example.com/users')
8
+ *
9
+ * // Fluent builder
10
+ * const response = await Http.baseUrl('https://api.example.com')
11
+ * .bearer('token-123')
12
+ * .accept('application/json')
13
+ * .timeout('5s')
14
+ * .get('/users')
15
+ *
16
+ * // Batch requests (parallel)
17
+ * const [users, posts] = await Http.batch([
18
+ * Http.get('https://api.example.com/users'),
19
+ * Http.get('https://api.example.com/posts'),
20
+ * ])
21
+ *
22
+ * // Pipeline (sequential, each step receives prior response)
23
+ * const result = await Http.pipeline(
24
+ * Http.post('https://api.example.com/auth', { user: 'admin', pass: 'secret' }),
25
+ * (authResponse) => Http.baseUrl('https://api.example.com')
26
+ * .bearer(authResponse.data.token)
27
+ * .get('/profile'),
28
+ * )
29
+ * ```
30
+ */
31
+
32
+ import { parseDuration } from './async.ts'
33
+
34
+ // ── Types ───────────────────────────────────────────────────────────
35
+
36
+ export interface HttpResponse<T = any> {
37
+ data: T
38
+ status: number
39
+ statusText: string
40
+ headers: Headers
41
+ ok: boolean
42
+ url: string
43
+ }
44
+
45
+ export interface HttpError extends Error {
46
+ response?: HttpResponse
47
+ status?: number
48
+ }
49
+
50
+ export type HttpMiddleware = (
51
+ request: Request,
52
+ next: (request: Request) => Promise<Response>,
53
+ ) => Promise<Response>
54
+
55
+ export type RetryConfig = {
56
+ times: number
57
+ delay?: string | number
58
+ when?: (response: HttpResponse) => boolean
59
+ }
60
+
61
+ // ── PendingRequest (fluent builder) ─────────────────────────────────
62
+
63
+ export class PendingRequest {
64
+ private _baseUrl = ''
65
+ private _headers: Record<string, string> = {}
66
+ private _query: Record<string, string> = {}
67
+ private _timeout: number | null = null
68
+ private _retry: RetryConfig | null = null
69
+ private _middleware: HttpMiddleware[] = []
70
+ private _bodyType: 'json' | 'form' | 'multipart' | 'raw' = 'json'
71
+ private _sink: string | WritableStream | null = null
72
+
73
+ /** Set the base URL for all requests */
74
+ baseUrl(url: string): this {
75
+ this._baseUrl = url.replace(/\/+$/, '')
76
+ return this
77
+ }
78
+
79
+ /** Set a request header */
80
+ withHeader(name: string, value: string): this {
81
+ this._headers[name] = value
82
+ return this
83
+ }
84
+
85
+ /** Set multiple headers */
86
+ withHeaders(headers: Record<string, string>): this {
87
+ Object.assign(this._headers, headers)
88
+ return this
89
+ }
90
+
91
+ /** Set Authorization: Bearer token */
92
+ bearer(token: string): this {
93
+ this._headers['Authorization'] = `Bearer ${token}`
94
+ return this
95
+ }
96
+
97
+ /** Set basic auth */
98
+ basicAuth(username: string, password: string): this {
99
+ this._headers['Authorization'] = `Basic ${btoa(`${username}:${password}`)}`
100
+ return this
101
+ }
102
+
103
+ /** Set Accept header */
104
+ accept(contentType: string): this {
105
+ this._headers['Accept'] = contentType
106
+ return this
107
+ }
108
+
109
+ /** Set Content-Type header */
110
+ contentType(type: string): this {
111
+ this._headers['Content-Type'] = type
112
+ return this
113
+ }
114
+
115
+ /** Send body as JSON (default) */
116
+ asJson(): this {
117
+ this._bodyType = 'json'
118
+ return this
119
+ }
120
+
121
+ /** Send body as application/x-www-form-urlencoded */
122
+ asForm(): this {
123
+ this._bodyType = 'form'
124
+ return this
125
+ }
126
+
127
+ /** Send body as multipart/form-data */
128
+ asMultipart(): this {
129
+ this._bodyType = 'multipart'
130
+ return this
131
+ }
132
+
133
+ /**
134
+ * Stream the response body to a file path or a WritableStream.
135
+ * When a file path is given, `data` in the response will be the file path.
136
+ * When a WritableStream is given, `data` will be null.
137
+ *
138
+ * @example
139
+ * ```ts
140
+ * // Sink to a local file
141
+ * const { data: filePath } = await Http.create()
142
+ * .sink('/tmp/report.pdf')
143
+ * .get('https://example.com/report.pdf')
144
+ *
145
+ * // Sink to a writable stream
146
+ * const file = Bun.file('/tmp/report.pdf').writer()
147
+ * await Http.create()
148
+ * .sink(writableStream)
149
+ * .get('https://example.com/report.pdf')
150
+ * ```
151
+ */
152
+ sink(path: string | WritableStream): this {
153
+ this._sink = path
154
+ return this
155
+ }
156
+
157
+ /** Add query parameters */
158
+ query(params: Record<string, string | number | boolean>): this {
159
+ for (const [k, v] of Object.entries(params)) {
160
+ this._query[k] = String(v)
161
+ }
162
+ return this
163
+ }
164
+
165
+ /** Set request timeout */
166
+ timeout(duration: string | number): this {
167
+ this._timeout = parseDuration(duration)
168
+ return this
169
+ }
170
+
171
+ /** Configure retry behavior */
172
+ retry(times: number, delay?: string | number, when?: (response: HttpResponse) => boolean): this {
173
+ this._retry = { times, delay, when }
174
+ return this
175
+ }
176
+
177
+ /** Add middleware to the request pipeline */
178
+ withMiddleware(middleware: HttpMiddleware): this {
179
+ this._middleware.push(middleware)
180
+ return this
181
+ }
182
+
183
+ // ── HTTP methods ──────────────────────────────────────────────────
184
+
185
+ get<T = any>(url: string): Promise<HttpResponse<T>> {
186
+ return this.send<T>('GET', url)
187
+ }
188
+
189
+ post<T = any>(url: string, body?: any): Promise<HttpResponse<T>> {
190
+ return this.send<T>('POST', url, body)
191
+ }
192
+
193
+ put<T = any>(url: string, body?: any): Promise<HttpResponse<T>> {
194
+ return this.send<T>('PUT', url, body)
195
+ }
196
+
197
+ patch<T = any>(url: string, body?: any): Promise<HttpResponse<T>> {
198
+ return this.send<T>('PATCH', url, body)
199
+ }
200
+
201
+ delete<T = any>(url: string, body?: any): Promise<HttpResponse<T>> {
202
+ return this.send<T>('DELETE', url, body)
203
+ }
204
+
205
+ head(url: string): Promise<HttpResponse<null>> {
206
+ return this.send<null>('HEAD', url)
207
+ }
208
+
209
+ options<T = any>(url: string): Promise<HttpResponse<T>> {
210
+ return this.send<T>('OPTIONS', url)
211
+ }
212
+
213
+ // ── Core send ─────────────────────────────────────────────────────
214
+
215
+ async send<T = any>(method: string, url: string, body?: any): Promise<HttpResponse<T>> {
216
+ const fullUrl = this.buildUrl(url)
217
+ const request = this.buildRequest(method, fullUrl, body)
218
+
219
+ const execute = async (): Promise<HttpResponse<T>> => {
220
+ const response = await this.executeWithMiddleware(request.clone())
221
+ return this.parseResponse<T>(response, fullUrl)
222
+ }
223
+
224
+ if (this._retry) {
225
+ return this.executeWithRetry<T>(execute, this._retry)
226
+ }
227
+
228
+ return execute()
229
+ }
230
+
231
+ // ── Internal ──────────────────────────────────────────────────────
232
+
233
+ private buildUrl(url: string): string {
234
+ const fullUrl = url.startsWith('http') ? url : `${this._baseUrl}${url.startsWith('/') ? '' : '/'}${url}`
235
+ const queryString = new URLSearchParams(this._query).toString()
236
+ return queryString ? `${fullUrl}?${queryString}` : fullUrl
237
+ }
238
+
239
+ private buildRequest(method: string, url: string, body?: any): Request {
240
+ const headers = new Headers(this._headers)
241
+ const init: RequestInit = { method, headers }
242
+
243
+ if (body !== undefined && method !== 'GET' && method !== 'HEAD') {
244
+ if (this._bodyType === 'json') {
245
+ if (!headers.has('Content-Type')) {
246
+ headers.set('Content-Type', 'application/json')
247
+ }
248
+ init.body = JSON.stringify(body)
249
+ } else if (this._bodyType === 'form') {
250
+ headers.set('Content-Type', 'application/x-www-form-urlencoded')
251
+ init.body = new URLSearchParams(body).toString()
252
+ } else if (this._bodyType === 'multipart') {
253
+ // Let the browser set the boundary in Content-Type
254
+ if (body instanceof FormData) {
255
+ init.body = body
256
+ } else {
257
+ const formData = new FormData()
258
+ for (const [k, v] of Object.entries(body)) {
259
+ formData.append(k, v as string | Blob)
260
+ }
261
+ init.body = formData
262
+ }
263
+ // Remove Content-Type so browser sets multipart boundary
264
+ headers.delete('Content-Type')
265
+ } else {
266
+ init.body = body
267
+ }
268
+ }
269
+
270
+ if (this._timeout !== null) {
271
+ init.signal = AbortSignal.timeout(this._timeout)
272
+ }
273
+
274
+ return new Request(url, init)
275
+ }
276
+
277
+ private async executeWithMiddleware(request: Request): Promise<Response> {
278
+ const stack = [...this._middleware]
279
+
280
+ const dispatch = async (req: Request): Promise<Response> => {
281
+ const middleware = stack.shift()
282
+ if (middleware) {
283
+ return middleware(req, dispatch)
284
+ }
285
+ return fetch(req)
286
+ }
287
+
288
+ return dispatch(request)
289
+ }
290
+
291
+ private async parseResponse<T>(response: Response, url: string): Promise<HttpResponse<T>> {
292
+ let data: any = null
293
+
294
+ // Sink: stream body to a file or writable stream
295
+ if (this._sink && response.ok && response.body) {
296
+ if (typeof this._sink === 'string') {
297
+ const bytes = await response.arrayBuffer()
298
+ await Bun.write(this._sink, bytes)
299
+ data = this._sink
300
+ } else {
301
+ await response.body.pipeTo(this._sink)
302
+ data = null
303
+ }
304
+ } else {
305
+ const contentType = response.headers.get('content-type') ?? ''
306
+ if (contentType.includes('application/json')) {
307
+ data = await response.json()
308
+ } else if (contentType.includes('text/')) {
309
+ data = await response.text()
310
+ } else if (response.status !== 204 && response.status !== 304) {
311
+ // Try JSON first, fall back to text
312
+ const text = await response.text()
313
+ try {
314
+ data = JSON.parse(text)
315
+ } catch {
316
+ data = text
317
+ }
318
+ }
319
+ }
320
+
321
+ const result: HttpResponse<T> = {
322
+ data,
323
+ status: response.status,
324
+ statusText: response.statusText,
325
+ headers: response.headers,
326
+ ok: response.ok,
327
+ url,
328
+ }
329
+
330
+ if (!response.ok) {
331
+ const error = new Error(`HTTP ${response.status}: ${response.statusText}`) as HttpError
332
+ error.response = result
333
+ error.status = response.status
334
+ throw error
335
+ }
336
+
337
+ return result
338
+ }
339
+
340
+ private async executeWithRetry<T>(
341
+ execute: () => Promise<HttpResponse<T>>,
342
+ config: RetryConfig,
343
+ ): Promise<HttpResponse<T>> {
344
+ let lastError: Error | undefined
345
+ const delay = config.delay ? parseDuration(config.delay) : 0
346
+
347
+ for (let attempt = 0; attempt <= config.times; attempt++) {
348
+ try {
349
+ const response = await execute()
350
+
351
+ // Check if we should retry based on the response
352
+ if (config.when && attempt < config.times && config.when(response)) {
353
+ if (delay > 0) await new Promise((r) => setTimeout(r, delay))
354
+ continue
355
+ }
356
+
357
+ return response
358
+ } catch (e) {
359
+ lastError = e instanceof Error ? e : new Error(String(e))
360
+ if (attempt < config.times) {
361
+ if (delay > 0) await new Promise((r) => setTimeout(r, delay))
362
+ }
363
+ }
364
+ }
365
+
366
+ throw lastError
367
+ }
368
+ }
369
+
370
+ // ── Static Http facade ──────────────────────────────────────────────
371
+
372
+ export const Http = {
373
+ /** Create a new fluent request builder */
374
+ create(): PendingRequest {
375
+ return new PendingRequest()
376
+ },
377
+
378
+ /** Create a builder with a base URL */
379
+ baseUrl(url: string): PendingRequest {
380
+ return new PendingRequest().baseUrl(url)
381
+ },
382
+
383
+ /** Create a builder with a bearer token */
384
+ bearer(token: string): PendingRequest {
385
+ return new PendingRequest().bearer(token)
386
+ },
387
+
388
+ /** Create a builder with custom headers */
389
+ withHeaders(headers: Record<string, string>): PendingRequest {
390
+ return new PendingRequest().withHeaders(headers)
391
+ },
392
+
393
+ /** Create a builder with middleware */
394
+ withMiddleware(middleware: HttpMiddleware): PendingRequest {
395
+ return new PendingRequest().withMiddleware(middleware)
396
+ },
397
+
398
+ /** Create a builder with timeout */
399
+ timeout(duration: string | number): PendingRequest {
400
+ return new PendingRequest().timeout(duration)
401
+ },
402
+
403
+ /** Create a builder with retry config */
404
+ retry(times: number, delay?: string | number): PendingRequest {
405
+ return new PendingRequest().retry(times, delay)
406
+ },
407
+
408
+ // ── Shorthand methods ─────────────────────────────────────────────
409
+
410
+ get<T = any>(url: string): Promise<HttpResponse<T>> {
411
+ return new PendingRequest().get<T>(url)
412
+ },
413
+
414
+ post<T = any>(url: string, body?: any): Promise<HttpResponse<T>> {
415
+ return new PendingRequest().post<T>(url, body)
416
+ },
417
+
418
+ put<T = any>(url: string, body?: any): Promise<HttpResponse<T>> {
419
+ return new PendingRequest().put<T>(url, body)
420
+ },
421
+
422
+ patch<T = any>(url: string, body?: any): Promise<HttpResponse<T>> {
423
+ return new PendingRequest().patch<T>(url, body)
424
+ },
425
+
426
+ delete<T = any>(url: string, body?: any): Promise<HttpResponse<T>> {
427
+ return new PendingRequest().delete<T>(url, body)
428
+ },
429
+
430
+ head(url: string): Promise<HttpResponse<null>> {
431
+ return new PendingRequest().head(url)
432
+ },
433
+
434
+ options<T = any>(url: string): Promise<HttpResponse<T>> {
435
+ return new PendingRequest().options<T>(url)
436
+ },
437
+
438
+ // ── Batch (parallel requests) ─────────────────────────────────────
439
+
440
+ /**
441
+ * Execute multiple requests in parallel.
442
+ * Returns an array of responses in the same order.
443
+ *
444
+ * @example
445
+ * ```ts
446
+ * const [users, posts, comments] = await Http.batch([
447
+ * Http.get('/api/users'),
448
+ * Http.get('/api/posts'),
449
+ * Http.get('/api/comments'),
450
+ * ])
451
+ * ```
452
+ */
453
+ async batch<T extends Promise<HttpResponse>[]>(
454
+ requests: [...T],
455
+ ): Promise<{ [K in keyof T]: Awaited<T[K]> }> {
456
+ return Promise.all(requests) as any
457
+ },
458
+
459
+ /**
460
+ * Execute multiple requests in parallel, settling all (no short-circuit on error).
461
+ * Returns an array of { status, value?, reason? } results.
462
+ *
463
+ * @example
464
+ * ```ts
465
+ * const results = await Http.batchSettled([
466
+ * Http.get('/api/users'),
467
+ * Http.get('/api/might-fail'),
468
+ * ])
469
+ * results.forEach(r => {
470
+ * if (r.status === 'fulfilled') console.log(r.value.data)
471
+ * else console.error(r.reason)
472
+ * })
473
+ * ```
474
+ */
475
+ async batchSettled<T extends Promise<HttpResponse>[]>(
476
+ requests: [...T],
477
+ ): Promise<{ [K in keyof T]: PromiseSettledResult<Awaited<T[K]>> }> {
478
+ return Promise.allSettled(requests) as any
479
+ },
480
+
481
+ /**
482
+ * Execute requests in parallel with a concurrency limit.
483
+ *
484
+ * @example
485
+ * ```ts
486
+ * const urls = Array.from({ length: 100 }, (_, i) => `/api/item/${i}`)
487
+ * const responses = await Http.pool(
488
+ * urls.map(url => () => Http.get(url)),
489
+ * { concurrency: 5 },
490
+ * )
491
+ * ```
492
+ */
493
+ async pool<T = any>(
494
+ factories: Array<() => Promise<HttpResponse<T>>>,
495
+ options: { concurrency: number },
496
+ ): Promise<HttpResponse<T>[]> {
497
+ const results: HttpResponse<T>[] = new Array(factories.length)
498
+ let nextIndex = 0
499
+
500
+ async function runNext(): Promise<void> {
501
+ while (nextIndex < factories.length) {
502
+ const idx = nextIndex++
503
+ results[idx] = await factories[idx]!()
504
+ }
505
+ }
506
+
507
+ const workers = Array.from(
508
+ { length: Math.min(options.concurrency, factories.length) },
509
+ () => runNext(),
510
+ )
511
+
512
+ await Promise.all(workers)
513
+ return results
514
+ },
515
+
516
+ // ── Pipeline (sequential with data passing) ───────────────────────
517
+
518
+ /**
519
+ * Execute requests sequentially — each step receives the previous response.
520
+ * The first argument is a request promise, subsequent arguments are functions
521
+ * that receive the prior response and return the next request.
522
+ *
523
+ * @example
524
+ * ```ts
525
+ * // Auth → fetch profile → fetch settings
526
+ * const settings = await Http.pipeline(
527
+ * Http.post('/auth', { user: 'admin', pass: 'secret' }),
528
+ * (auth) => Http.bearer(auth.data.token).get('/profile'),
529
+ * (profile) => Http.bearer(profile.data.token).get(`/settings/${profile.data.id}`),
530
+ * )
531
+ * // settings is the final HttpResponse
532
+ * ```
533
+ */
534
+ async pipeline<T = any>(
535
+ first: Promise<HttpResponse>,
536
+ ...steps: Array<(response: HttpResponse) => Promise<HttpResponse>>
537
+ ): Promise<HttpResponse<T>> {
538
+ let response = await first
539
+ for (const step of steps) {
540
+ response = await step(response)
541
+ }
542
+ return response as HttpResponse<T>
543
+ },
544
+
545
+ /**
546
+ * Build a reusable pipeline of request transformations.
547
+ * Returns a function that accepts an initial request and runs the full chain.
548
+ *
549
+ * @example
550
+ * ```ts
551
+ * const authenticatedFetch = Http.createPipeline(
552
+ * (auth) => Http.bearer(auth.data.token).get('/profile'),
553
+ * (profile) => Http.bearer(profile.data.token).get(`/data/${profile.data.id}`),
554
+ * )
555
+ *
556
+ * // Use it with different auth requests
557
+ * const result = await authenticatedFetch(
558
+ * Http.post('/auth', { user: 'admin', pass: 'secret' })
559
+ * )
560
+ * ```
561
+ */
562
+ createPipeline<T = any>(
563
+ ...steps: Array<(response: HttpResponse) => Promise<HttpResponse>>
564
+ ): (initial: Promise<HttpResponse>) => Promise<HttpResponse<T>> {
565
+ return async (initial) => {
566
+ let response = await initial
567
+ for (const step of steps) {
568
+ response = await step(response)
569
+ }
570
+ return response as HttpResponse<T>
571
+ }
572
+ },
573
+ }