@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/README.md +19 -0
- package/package.json +52 -0
- package/src/Arr.ts +264 -0
- package/src/Collection.ts +804 -0
- package/src/Duration.ts +172 -0
- package/src/Http.ts +573 -0
- package/src/HttpFake.ts +361 -0
- package/src/Num.ts +187 -0
- package/src/Result.ts +196 -0
- package/src/Str.ts +457 -0
- package/src/async.ts +209 -0
- package/src/functions.ts +153 -0
- package/src/index.ts +65 -0
- package/src/is.ts +195 -0
- package/src/match.ts +78 -0
- package/src/objects.ts +180 -0
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
|
+
}
|