@harborclient/http 0.1.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.
Files changed (54) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +34 -0
  3. package/dist/Body.d.ts +28 -0
  4. package/dist/Body.d.ts.map +1 -0
  5. package/dist/Body.js +72 -0
  6. package/dist/Headers.d.ts +25 -0
  7. package/dist/Headers.d.ts.map +1 -0
  8. package/dist/Headers.js +62 -0
  9. package/dist/IBody.d.ts +32 -0
  10. package/dist/IBody.d.ts.map +1 -0
  11. package/dist/IBody.js +1 -0
  12. package/dist/IHeaders.d.ts +40 -0
  13. package/dist/IHeaders.d.ts.map +1 -0
  14. package/dist/IHeaders.js +1 -0
  15. package/dist/IQueryString.d.ts +20 -0
  16. package/dist/IQueryString.d.ts.map +1 -0
  17. package/dist/IQueryString.js +1 -0
  18. package/dist/IRequester.d.ts +17 -0
  19. package/dist/IRequester.d.ts.map +1 -0
  20. package/dist/IRequester.js +1 -0
  21. package/dist/IResponseReader.d.ts +29 -0
  22. package/dist/IResponseReader.d.ts.map +1 -0
  23. package/dist/IResponseReader.js +1 -0
  24. package/dist/QueryString.d.ts +40 -0
  25. package/dist/QueryString.d.ts.map +1 -0
  26. package/dist/QueryString.js +78 -0
  27. package/dist/Requester.d.ts +100 -0
  28. package/dist/Requester.d.ts.map +1 -0
  29. package/dist/Requester.js +403 -0
  30. package/dist/ResponseReader.d.ts +24 -0
  31. package/dist/ResponseReader.d.ts.map +1 -0
  32. package/dist/ResponseReader.js +66 -0
  33. package/dist/formData.d.ts +27 -0
  34. package/dist/formData.d.ts.map +1 -0
  35. package/dist/formData.js +58 -0
  36. package/dist/httpHeaders.d.ts +23 -0
  37. package/dist/httpHeaders.d.ts.map +1 -0
  38. package/dist/httpHeaders.js +61 -0
  39. package/dist/index.d.ts +21 -0
  40. package/dist/index.d.ts.map +1 -0
  41. package/dist/index.js +9 -0
  42. package/dist/logger.d.ts +19 -0
  43. package/dist/logger.d.ts.map +1 -0
  44. package/dist/logger.js +34 -0
  45. package/dist/settings.d.ts +42 -0
  46. package/dist/settings.d.ts.map +1 -0
  47. package/dist/settings.js +26 -0
  48. package/dist/types.d.ts +231 -0
  49. package/dist/types.d.ts.map +1 -0
  50. package/dist/types.js +1 -0
  51. package/dist/urlencoded.d.ts +27 -0
  52. package/dist/urlencoded.d.ts.map +1 -0
  53. package/dist/urlencoded.js +53 -0
  54. package/package.json +101 -0
@@ -0,0 +1 @@
1
+ {"version":3,"file":"Requester.d.ts","sourceRoot":"","sources":["../src/Requester.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAKV,gBAAgB,EAChB,UAAU,EAEX,MAAM,YAAY,CAAC;AACpB,OAAO,EAA4B,KAAK,eAAe,EAAE,MAAM,eAAe,CAAC;AAE/E,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,YAAY,CAAC;AACxC,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAC;AAC9C,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAC;AACtD,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAClD,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,sBAAsB,CAAC;AAM5D,sEAAsE;AACtE,eAAO,MAAM,iBAAiB,aAAqC,CAAC;AAEpE,uDAAuD;AACvD,eAAO,MAAM,aAAa,KAAK,CAAC;AAmBhC;;GAEG;AACH,MAAM,WAAW,aAAa;IAC5B,WAAW,CAAC,EAAE,YAAY,CAAC;IAC3B,OAAO,CAAC,EAAE,QAAQ,CAAC;IACnB,IAAI,CAAC,EAAE,KAAK,CAAC;IACb,cAAc,CAAC,EAAE,eAAe,CAAC;CAClC;AAsHD;;GAEG;AACH,qBAAa,SAAU,YAAW,UAAU;IAC1C,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAe;IAC3C,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAW;IACnC,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAQ;IAC7B,OAAO,CAAC,QAAQ,CAAC,cAAc,CAAkB;IAEjD;;;;OAIG;gBACS,IAAI,GAAE,aAAkB;IAOpC;;;;;OAKG;IACH,OAAO,CAAC,oBAAoB;IAqB5B;;;;;OAKG;IACH,OAAO,CAAC,aAAa;IAarB;;OAEG;IACH,OAAO,CAAC,qBAAqB;IAK7B;;;;;;;;OAQG;IACH,OAAO,CAAC,WAAW;IAoBnB;;;;;;;OAOG;IACH,OAAO,CAAC,kBAAkB;IAW1B;;;;;;OAMG;YACW,gBAAgB;IAwB9B;;;;OAIG;IACH,OAAO,CAAC,iBAAiB;IAUzB;;;;;;;;;;;OAWG;IACG,cAAc,CAClB,KAAK,EAAE,gBAAgB,EACvB,QAAQ,GAAE,eAA0C,EACpD,MAAM,CAAC,EAAE,WAAW,EACpB,YAAY,CAAC,EAAE,MAAM,GACpB,OAAO,CAAC,UAAU,CAAC;CA+KvB"}
@@ -0,0 +1,403 @@
1
+ import { Agent, ProxyAgent } from 'undici';
2
+ import { DEFAULT_REQUEST_SETTINGS } from './settings.js';
3
+ import { isVeryVerbose, logRequest } from './logger.js';
4
+ import { Body } from './Body.js';
5
+ import { Headers } from './Headers.js';
6
+ import { QueryString } from './QueryString.js';
7
+ import { ResponseReader } from './ResponseReader.js';
8
+ /** HTTP status codes treated as redirects when following manually. */
9
+ export const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
10
+ /** Maximum redirect hops before returning an error. */
11
+ export const MAX_REDIRECTS = 20;
12
+ /** Request-body headers removed when a redirect converts the method to GET. */
13
+ const REQUEST_BODY_HEADER_NAMES = [
14
+ 'content-type',
15
+ 'content-length',
16
+ 'content-encoding',
17
+ 'content-language',
18
+ 'content-location'
19
+ ];
20
+ /** Headers stripped when the redirect target is cross-origin. */
21
+ const CROSS_ORIGIN_HEADER_NAMES = [
22
+ 'authorization',
23
+ 'proxy-authorization',
24
+ 'cookie',
25
+ 'host'
26
+ ];
27
+ let insecureDispatcher;
28
+ let cachedProxyDispatcher;
29
+ let cachedProxyDispatcherKey = '';
30
+ /**
31
+ * Returns a cache key for proxy dispatcher configuration.
32
+ *
33
+ * @param proxy - Normalized proxy settings.
34
+ * @param verifySsl - Whether TLS certificates are verified for the origin request.
35
+ */
36
+ function proxyDispatcherCacheKey(proxy, verifySsl) {
37
+ return JSON.stringify({ proxy, verifySsl });
38
+ }
39
+ /**
40
+ * Returns a shared undici ProxyAgent for the given proxy configuration.
41
+ *
42
+ * @param proxy - Normalized proxy settings with a non-empty host.
43
+ * @param verifySsl - When false, origin TLS verification is disabled through the proxy.
44
+ */
45
+ function getProxyDispatcher(proxy, verifySsl) {
46
+ const key = proxyDispatcherCacheKey(proxy, verifySsl);
47
+ if (cachedProxyDispatcher && cachedProxyDispatcherKey === key) {
48
+ return cachedProxyDispatcher;
49
+ }
50
+ void cachedProxyDispatcher?.close();
51
+ const uri = `${proxy.protocol}://${proxy.host.trim()}:${proxy.port}`;
52
+ const options = { uri };
53
+ if (proxy.authEnabled && proxy.username) {
54
+ options.token = `Basic ${Buffer.from(`${proxy.username}:${proxy.password}`).toString('base64')}`;
55
+ }
56
+ if (!verifySsl) {
57
+ options.requestTls = { rejectUnauthorized: false };
58
+ }
59
+ cachedProxyDispatcher = new ProxyAgent(options);
60
+ cachedProxyDispatcherKey = key;
61
+ return cachedProxyDispatcher;
62
+ }
63
+ /**
64
+ * Removes a header from a map using case-insensitive name matching.
65
+ *
66
+ * @param headers - Mutable header map.
67
+ * @param name - Header name to remove.
68
+ */
69
+ function deleteHeader(headers, name) {
70
+ const lower = name.toLowerCase();
71
+ for (const key of Object.keys(headers)) {
72
+ if (key.toLowerCase() === lower) {
73
+ delete headers[key];
74
+ }
75
+ }
76
+ }
77
+ /**
78
+ * Returns whether a redirect response should convert the next request to GET.
79
+ *
80
+ * @param status - Redirect response status code.
81
+ * @param method - Method used for the redirect response request.
82
+ */
83
+ function shouldConvertRedirectToGet(status, method) {
84
+ return (([301, 302].includes(status) && method === 'POST') ||
85
+ (status === 303 && method !== 'GET' && method !== 'HEAD'));
86
+ }
87
+ /**
88
+ * Applies fetch redirect transition rules to the next hop's method, body flag, and headers.
89
+ *
90
+ * @param status - Redirect response status code.
91
+ * @param currentUrl - URL of the redirect response request.
92
+ * @param nextUrl - Resolved Location URL for the next hop.
93
+ * @param method - Current method; updated in place when converted to GET.
94
+ * @param headers - Mutable headers for the next hop.
95
+ * @returns Whether the next hop should include a request body.
96
+ */
97
+ function applyRedirectTransition(status, currentUrl, nextUrl, method, headers) {
98
+ let shouldSendBody = method.value !== 'GET' && method.value !== 'HEAD';
99
+ if (shouldConvertRedirectToGet(status, method.value)) {
100
+ method.value = 'GET';
101
+ shouldSendBody = false;
102
+ for (const headerName of REQUEST_BODY_HEADER_NAMES) {
103
+ deleteHeader(headers, headerName);
104
+ }
105
+ }
106
+ try {
107
+ const fromOrigin = new URL(currentUrl).origin;
108
+ const toOrigin = new URL(nextUrl).origin;
109
+ if (fromOrigin !== toOrigin) {
110
+ for (const headerName of CROSS_ORIGIN_HEADER_NAMES) {
111
+ deleteHeader(headers, headerName);
112
+ }
113
+ }
114
+ }
115
+ catch {
116
+ // Invalid URL; leave headers unchanged.
117
+ }
118
+ return shouldSendBody;
119
+ }
120
+ /**
121
+ * Executes outbound HTTP requests via fetch with configurable collaborators.
122
+ */
123
+ export class Requester {
124
+ queryString;
125
+ headers;
126
+ body;
127
+ responseReader;
128
+ /**
129
+ * Creates a requester with optional collaborators defaulting to the standard implementations.
130
+ *
131
+ * @param deps - Optional query string, header, body, and response reader implementations.
132
+ */
133
+ constructor(deps = {}) {
134
+ this.queryString = deps.queryString ?? new QueryString();
135
+ this.headers = deps.headers ?? new Headers();
136
+ this.body = deps.body ?? new Body();
137
+ this.responseReader = deps.responseReader ?? new ResponseReader();
138
+ }
139
+ /**
140
+ * Combines optional cancel and timeout signals for fetch.
141
+ *
142
+ * @param signal - Optional user cancel signal.
143
+ * @param timeoutMs - Request timeout in milliseconds; 0 disables timeout.
144
+ */
145
+ buildEffectiveSignal(signal, timeoutMs) {
146
+ const signals = [];
147
+ if (signal) {
148
+ signals.push(signal);
149
+ }
150
+ if (timeoutMs > 0) {
151
+ signals.push(AbortSignal.timeout(timeoutMs));
152
+ }
153
+ if (signals.length === 0) {
154
+ return undefined;
155
+ }
156
+ if (signals.length === 1) {
157
+ return signals[0];
158
+ }
159
+ return AbortSignal.any(signals);
160
+ }
161
+ /**
162
+ * Maps fetch errors to user-facing messages.
163
+ *
164
+ * @param err - Thrown fetch error.
165
+ * @param timeoutMs - Configured request timeout in milliseconds.
166
+ */
167
+ mapFetchError(err, timeoutMs) {
168
+ if (err instanceof Error && err.name === 'AbortError') {
169
+ return 'Request canceled';
170
+ }
171
+ if (err instanceof Error && err.name === 'TimeoutError') {
172
+ return `Request timed out after ${timeoutMs} ms`;
173
+ }
174
+ if (err instanceof Error) {
175
+ return err.message;
176
+ }
177
+ return 'Unknown error';
178
+ }
179
+ /**
180
+ * Returns a shared undici Agent that skips TLS certificate verification.
181
+ */
182
+ getInsecureDispatcher() {
183
+ insecureDispatcher ??= new Agent({ connect: { rejectUnauthorized: false } });
184
+ return insecureDispatcher;
185
+ }
186
+ /**
187
+ * Builds a failed {@link SendResult} with consistent error fields.
188
+ *
189
+ * @param error - User-facing error message.
190
+ * @param request - Sent request metadata captured before or during the attempt.
191
+ * @param timeMs - Elapsed time in milliseconds.
192
+ * @param responseHeaders - Optional response headers when available before failure.
193
+ * @param setCookieHeaders - Optional Set-Cookie headers from the response.
194
+ */
195
+ errorResult(error, request, timeMs, responseHeaders = {}, setCookieHeaders) {
196
+ return {
197
+ status: 0,
198
+ statusText: 'Error',
199
+ headers: responseHeaders,
200
+ body: '',
201
+ timeMs,
202
+ sizeBytes: 0,
203
+ error,
204
+ setCookieHeaders,
205
+ request
206
+ };
207
+ }
208
+ /**
209
+ * Logs the outbound request when very-verbose mode is enabled.
210
+ *
211
+ * Records the HTTP verb, resolved URL, request headers, body type, and request
212
+ * body. Response headers and response bodies are intentionally omitted.
213
+ *
214
+ * @param request - Final request metadata sent to fetch.
215
+ */
216
+ logOutgoingRequest(request) {
217
+ if (!isVeryVerbose)
218
+ return;
219
+ logRequest(`${request.method} ${request.url}`);
220
+ logRequest('headers:', request.headers);
221
+ logRequest('bodyType:', request.bodyType);
222
+ if (request.body) {
223
+ logRequest('body:', request.body);
224
+ }
225
+ }
226
+ /**
227
+ * Builds the fetch request body for one hop.
228
+ *
229
+ * @param input - Original send input (body source).
230
+ * @param bodyType - Body type for this hop.
231
+ * @param shouldSendBody - Whether a body should be attached.
232
+ */
233
+ async buildRequestBody(input, bodyType, shouldSendBody) {
234
+ if (!shouldSendBody || bodyType === 'none') {
235
+ return {};
236
+ }
237
+ if (bodyType === 'multipart') {
238
+ const multipartResult = await this.body.buildMultipart(input.body);
239
+ if ('error' in multipartResult) {
240
+ return { error: multipartResult.error };
241
+ }
242
+ return { body: multipartResult.formData };
243
+ }
244
+ if (bodyType === 'urlencoded') {
245
+ return { body: this.body.buildUrlEncoded(input.body) };
246
+ }
247
+ return { body: input.body };
248
+ }
249
+ /**
250
+ * Resolves the fetch dispatcher from general settings.
251
+ *
252
+ * @param settings - General request settings.
253
+ */
254
+ resolveDispatcher(settings) {
255
+ if (settings.proxy.enabled && settings.proxy.host.trim()) {
256
+ return getProxyDispatcher(settings.proxy, settings.verifySsl);
257
+ }
258
+ if (!settings.verifySsl) {
259
+ return this.getInsecureDispatcher();
260
+ }
261
+ return undefined;
262
+ }
263
+ /**
264
+ * Executes an HTTP request via fetch and returns timing and response metadata.
265
+ *
266
+ * When redirect following is enabled, 3xx responses are followed manually so
267
+ * each hop can be recorded in {@link SendResult.redirects}.
268
+ *
269
+ * @param input - Method, URL, headers, params, body, and body type.
270
+ * @param settings - General request settings for timeout, size limits, SSL verification, and redirect following.
271
+ * @param signal - Optional abort signal to cancel the in-flight request.
272
+ * @param cookieHeader - Optional Cookie header value from the cookie jar.
273
+ * @returns Response status, headers, body, timing, size, and optional redirect chain; error field on failure.
274
+ */
275
+ async executeRequest(input, settings = DEFAULT_REQUEST_SETTINGS, signal, cookieHeader) {
276
+ const url = this.queryString.buildUrl(input.url, input.params);
277
+ const sentRequest = {
278
+ method: input.method,
279
+ url: input.url,
280
+ headers: {},
281
+ body: '',
282
+ bodyType: input.bodyType
283
+ };
284
+ const builtHeaders = this.headers.build(input.headers, input.bodyType);
285
+ if (!builtHeaders.ok) {
286
+ return this.errorResult(builtHeaders.error, sentRequest, 0);
287
+ }
288
+ const headers = builtHeaders.headers;
289
+ sentRequest.headers = headers;
290
+ const cookieResult = this.headers.applyCookie(headers, cookieHeader);
291
+ if (!cookieResult.ok) {
292
+ return this.errorResult(cookieResult.error, sentRequest, 0);
293
+ }
294
+ const shouldSendBody = input.bodyType !== 'none' && input.method !== 'GET' && input.method !== 'HEAD';
295
+ const sentBody = shouldSendBody
296
+ ? input.bodyType === 'multipart'
297
+ ? this.body.summarizeFormParts(input.body)
298
+ : input.bodyType === 'urlencoded'
299
+ ? this.body.buildUrlEncoded(input.body)
300
+ : input.body
301
+ : '';
302
+ sentRequest.body = sentBody;
303
+ if (!url.trim()) {
304
+ return this.errorResult('URL is required', sentRequest, 0);
305
+ }
306
+ if (!this.queryString.isValidRequestUrl(url)) {
307
+ return this.errorResult('Invalid URL', sentRequest, 0);
308
+ }
309
+ sentRequest.url = url;
310
+ const start = performance.now();
311
+ const effectiveSignal = this.buildEffectiveSignal(signal, settings.requestTimeoutMs);
312
+ const dispatcher = this.resolveDispatcher(settings);
313
+ this.logOutgoingRequest(sentRequest);
314
+ let currentUrl = url;
315
+ let currentMethod = input.method;
316
+ const currentHeaders = { ...headers };
317
+ const currentBodyType = input.bodyType;
318
+ let currentShouldSendBody = shouldSendBody;
319
+ const redirects = [];
320
+ try {
321
+ let response;
322
+ while (true) {
323
+ const init = {
324
+ method: currentMethod,
325
+ headers: currentHeaders,
326
+ signal: effectiveSignal,
327
+ redirect: 'manual'
328
+ };
329
+ if (dispatcher) {
330
+ init.dispatcher = dispatcher;
331
+ }
332
+ const bodyResult = await this.buildRequestBody(input, currentBodyType, currentShouldSendBody);
333
+ if (bodyResult.error) {
334
+ const timeMs = Math.round(performance.now() - start);
335
+ return this.errorResult(bodyResult.error, sentRequest, timeMs);
336
+ }
337
+ if (bodyResult.body !== undefined) {
338
+ init.body = bodyResult.body;
339
+ }
340
+ response = await fetch(currentUrl, init);
341
+ if (!settings.followRedirects ||
342
+ !REDIRECT_STATUSES.has(response.status) ||
343
+ !response.headers.get('location')) {
344
+ break;
345
+ }
346
+ const rawLocation = response.headers.get('location');
347
+ let location;
348
+ try {
349
+ location = new URL(rawLocation, currentUrl).toString();
350
+ }
351
+ catch {
352
+ break;
353
+ }
354
+ redirects.push({
355
+ status: response.status,
356
+ statusText: response.statusText,
357
+ url: currentUrl,
358
+ location,
359
+ method: currentMethod
360
+ });
361
+ await response.body?.cancel();
362
+ if (redirects.length > MAX_REDIRECTS) {
363
+ const timeMs = Math.round(performance.now() - start);
364
+ return this.errorResult('Too many redirects', sentRequest, timeMs);
365
+ }
366
+ const methodRef = { value: currentMethod };
367
+ currentShouldSendBody = applyRedirectTransition(response.status, currentUrl, location, methodRef, currentHeaders);
368
+ currentMethod = methodRef.value;
369
+ currentUrl = location;
370
+ }
371
+ if (!response) {
372
+ const timeMs = Math.round(performance.now() - start);
373
+ return this.errorResult('No response received', sentRequest, timeMs);
374
+ }
375
+ const setCookieHeaders = typeof response.headers.getSetCookie === 'function' ? response.headers.getSetCookie() : [];
376
+ const readResult = await this.responseReader.read(response, settings.maxResponseSizeMb);
377
+ const timeMs = Math.round(performance.now() - start);
378
+ const responseHeaders = {};
379
+ response.headers.forEach((value, key) => {
380
+ responseHeaders[key] = value;
381
+ });
382
+ if ('error' in readResult) {
383
+ return this.errorResult(readResult.error, sentRequest, timeMs, responseHeaders, setCookieHeaders);
384
+ }
385
+ return {
386
+ status: response.status,
387
+ statusText: response.statusText,
388
+ headers: responseHeaders,
389
+ body: readResult.body,
390
+ ...(readResult.bodyBase64 ? { bodyBase64: readResult.bodyBase64 } : {}),
391
+ timeMs,
392
+ sizeBytes: readResult.sizeBytes,
393
+ setCookieHeaders,
394
+ request: sentRequest,
395
+ ...(redirects.length > 0 ? { redirects } : {})
396
+ };
397
+ }
398
+ catch (err) {
399
+ const timeMs = Math.round(performance.now() - start);
400
+ return this.errorResult(this.mapFetchError(err, settings.requestTimeoutMs), sentRequest, timeMs);
401
+ }
402
+ }
403
+ }
@@ -0,0 +1,24 @@
1
+ import type { IResponseReader, ReadResponseBodyResult } from './IResponseReader.js';
2
+ /**
3
+ * Reads fetch response bodies with user and hard size limits.
4
+ */
5
+ export declare class ResponseReader implements IResponseReader {
6
+ /**
7
+ * Resolves the effective response size limit in megabytes.
8
+ *
9
+ * @param maxResponseSizeMb - User setting; 0 means no configurable limit.
10
+ * @returns The user limit when positive, otherwise {@link HARD_MAX_RESPONSE_SIZE_MB}.
11
+ */
12
+ resolveMaxResponseSizeMb(maxResponseSizeMb: number): number;
13
+ /**
14
+ * Reads a response body, enforcing a max size in megabytes.
15
+ *
16
+ * When {@link maxResponseSizeMb} is 0, the user-configurable limit is disabled but
17
+ * {@link HARD_MAX_RESPONSE_SIZE_MB} still applies as a safety ceiling.
18
+ *
19
+ * @param response - Fetch response to read.
20
+ * @param maxResponseSizeMb - Maximum body size in MB; 0 uses the hard cap only.
21
+ */
22
+ read(response: Response, maxResponseSizeMb: number): Promise<ReadResponseBodyResult>;
23
+ }
24
+ //# sourceMappingURL=ResponseReader.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ResponseReader.d.ts","sourceRoot":"","sources":["../src/ResponseReader.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,eAAe,EAAE,sBAAsB,EAAE,MAAM,sBAAsB,CAAC;AAEpF;;GAEG;AACH,qBAAa,cAAe,YAAW,eAAe;IACpD;;;;;OAKG;IACH,wBAAwB,CAAC,iBAAiB,EAAE,MAAM,GAAG,MAAM;IAI3D;;;;;;;;OAQG;IACG,IAAI,CAAC,QAAQ,EAAE,QAAQ,EAAE,iBAAiB,EAAE,MAAM,GAAG,OAAO,CAAC,sBAAsB,CAAC;CAkD3F"}
@@ -0,0 +1,66 @@
1
+ import { HARD_MAX_RESPONSE_SIZE_MB } from './settings.js';
2
+ /**
3
+ * Reads fetch response bodies with user and hard size limits.
4
+ */
5
+ export class ResponseReader {
6
+ /**
7
+ * Resolves the effective response size limit in megabytes.
8
+ *
9
+ * @param maxResponseSizeMb - User setting; 0 means no configurable limit.
10
+ * @returns The user limit when positive, otherwise {@link HARD_MAX_RESPONSE_SIZE_MB}.
11
+ */
12
+ resolveMaxResponseSizeMb(maxResponseSizeMb) {
13
+ return maxResponseSizeMb > 0 ? maxResponseSizeMb : HARD_MAX_RESPONSE_SIZE_MB;
14
+ }
15
+ /**
16
+ * Reads a response body, enforcing a max size in megabytes.
17
+ *
18
+ * When {@link maxResponseSizeMb} is 0, the user-configurable limit is disabled but
19
+ * {@link HARD_MAX_RESPONSE_SIZE_MB} still applies as a safety ceiling.
20
+ *
21
+ * @param response - Fetch response to read.
22
+ * @param maxResponseSizeMb - Maximum body size in MB; 0 uses the hard cap only.
23
+ */
24
+ async read(response, maxResponseSizeMb) {
25
+ const effectiveMaxMb = this.resolveMaxResponseSizeMb(maxResponseSizeMb);
26
+ const maxBytes = effectiveMaxMb * 1024 * 1024;
27
+ const bodyStream = response.body ??
28
+ new ReadableStream({
29
+ start(controller) {
30
+ controller.close();
31
+ }
32
+ });
33
+ const reader = bodyStream.getReader();
34
+ const chunks = [];
35
+ let totalBytes = 0;
36
+ while (true) {
37
+ const { done, value } = await reader.read();
38
+ if (done) {
39
+ break;
40
+ }
41
+ if (value) {
42
+ totalBytes += value.length;
43
+ if (totalBytes > maxBytes) {
44
+ await reader.cancel();
45
+ const error = maxResponseSizeMb > 0
46
+ ? `Response exceeded max size of ${maxResponseSizeMb} MB`
47
+ : `Response exceeded the maximum allowed size of ${HARD_MAX_RESPONSE_SIZE_MB} MB`;
48
+ return { error };
49
+ }
50
+ chunks.push(value);
51
+ }
52
+ }
53
+ const combined = new Uint8Array(totalBytes);
54
+ let offset = 0;
55
+ for (const chunk of chunks) {
56
+ combined.set(chunk, offset);
57
+ offset += chunk.length;
58
+ }
59
+ const body = new TextDecoder().decode(combined);
60
+ const contentType = response.headers.get('content-type')?.toLowerCase() ?? '';
61
+ const bodyBase64 = contentType.startsWith('image/')
62
+ ? Buffer.from(combined).toString('base64')
63
+ : undefined;
64
+ return { body, sizeBytes: totalBytes, ...(bodyBase64 ? { bodyBase64 } : {}) };
65
+ }
66
+ }
@@ -0,0 +1,27 @@
1
+ import type { FormDataPart } from './types.js';
2
+ /**
3
+ * Returns a blank multipart form part with enabled set to true.
4
+ */
5
+ export declare function emptyFormPart(): FormDataPart;
6
+ /**
7
+ * Coerces a partial or legacy form part record to the full FormDataPart shape.
8
+ *
9
+ * @param part - Raw part fields from storage or import.
10
+ * @returns Normalized form part with defaults for missing fields.
11
+ */
12
+ export declare function normalizeFormPart(part: Partial<FormDataPart>): FormDataPart;
13
+ /**
14
+ * Parses a serialized multipart body string into form parts.
15
+ *
16
+ * @param body - JSON array stored in the request body field.
17
+ * @returns Parsed form parts, or an empty array when body is empty or invalid.
18
+ */
19
+ export declare function parseFormParts(body: string): FormDataPart[];
20
+ /**
21
+ * Serializes form parts for storage in the request body field.
22
+ *
23
+ * @param parts - Multipart form parts to serialize.
24
+ * @returns JSON string, or an empty string when there are no parts.
25
+ */
26
+ export declare function serializeFormParts(parts: FormDataPart[]): string;
27
+ //# sourceMappingURL=formData.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"formData.d.ts","sourceRoot":"","sources":["../src/formData.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAoB,MAAM,YAAY,CAAC;AAEjE;;GAEG;AACH,wBAAgB,aAAa,IAAI,YAAY,CAE5C;AAED;;;;;GAKG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,OAAO,CAAC,YAAY,CAAC,GAAG,YAAY,CAW3E;AAED;;;;;GAKG;AACH,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,YAAY,EAAE,CAe3D;AAED;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,YAAY,EAAE,GAAG,MAAM,CAKhE"}
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Returns a blank multipart form part with enabled set to true.
3
+ */
4
+ export function emptyFormPart() {
5
+ return { key: '', value: '', enabled: true, type: 'text', files: [] };
6
+ }
7
+ /**
8
+ * Coerces a partial or legacy form part record to the full FormDataPart shape.
9
+ *
10
+ * @param part - Raw part fields from storage or import.
11
+ * @returns Normalized form part with defaults for missing fields.
12
+ */
13
+ export function normalizeFormPart(part) {
14
+ const type = part.type === 'file' ? 'file' : 'text';
15
+ return {
16
+ key: typeof part.key === 'string' ? part.key : '',
17
+ value: typeof part.value === 'string' ? part.value : '',
18
+ enabled: part.enabled !== false,
19
+ type,
20
+ files: Array.isArray(part.files)
21
+ ? part.files.filter((file) => typeof file === 'string')
22
+ : []
23
+ };
24
+ }
25
+ /**
26
+ * Parses a serialized multipart body string into form parts.
27
+ *
28
+ * @param body - JSON array stored in the request body field.
29
+ * @returns Parsed form parts, or an empty array when body is empty or invalid.
30
+ */
31
+ export function parseFormParts(body) {
32
+ const trimmed = body.trim();
33
+ if (!trimmed) {
34
+ return [];
35
+ }
36
+ try {
37
+ const parsed = JSON.parse(trimmed);
38
+ if (!Array.isArray(parsed)) {
39
+ return [];
40
+ }
41
+ return parsed.map((part) => normalizeFormPart(part));
42
+ }
43
+ catch {
44
+ return [];
45
+ }
46
+ }
47
+ /**
48
+ * Serializes form parts for storage in the request body field.
49
+ *
50
+ * @param parts - Multipart form parts to serialize.
51
+ * @returns JSON string, or an empty string when there are no parts.
52
+ */
53
+ export function serializeFormParts(parts) {
54
+ if (parts.length === 0) {
55
+ return '';
56
+ }
57
+ return JSON.stringify(parts.map((part) => normalizeFormPart(part)));
58
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Returns whether a header field contains control characters or other bytes
3
+ * unsafe for HTTP header serialization (including CR and LF).
4
+ *
5
+ * @param value - Header name or value to inspect.
6
+ */
7
+ export declare function hasUnsafeHeaderFieldChars(value: string): boolean;
8
+ /**
9
+ * Validates a request header name and value before passing them to fetch.
10
+ *
11
+ * @param name - Header field name (already trimmed when supplied from buildHeaders).
12
+ * @param value - Header field value.
13
+ * @returns An error message when invalid, otherwise null.
14
+ */
15
+ export declare function validateHeaderField(name: string, value: string): string | null;
16
+ /**
17
+ * Validates a header map and returns the first validation error, if any.
18
+ *
19
+ * @param headers - Header map ready for fetch.
20
+ * @returns An error message when a field is invalid, otherwise null.
21
+ */
22
+ export declare function validateHeaders(headers: Record<string, string>): string | null;
23
+ //# sourceMappingURL=httpHeaders.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"httpHeaders.d.ts","sourceRoot":"","sources":["../src/httpHeaders.ts"],"names":[],"mappings":"AAaA;;;;;GAKG;AACH,wBAAgB,yBAAyB,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAQhE;AAED;;;;;;GAMG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAc9E;AAED;;;;;GAKG;AACH,wBAAgB,eAAe,CAAC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,MAAM,GAAG,IAAI,CAQ9E"}
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Hop-by-hop headers that must not be set from user or script input.
3
+ */
4
+ const FORBIDDEN_HEADER_NAMES = new Set([
5
+ 'connection',
6
+ 'keep-alive',
7
+ 'proxy-connection',
8
+ 'te',
9
+ 'trailer',
10
+ 'transfer-encoding',
11
+ 'upgrade'
12
+ ]);
13
+ /**
14
+ * Returns whether a header field contains control characters or other bytes
15
+ * unsafe for HTTP header serialization (including CR and LF).
16
+ *
17
+ * @param value - Header name or value to inspect.
18
+ */
19
+ export function hasUnsafeHeaderFieldChars(value) {
20
+ for (let index = 0; index < value.length; index += 1) {
21
+ const code = value.charCodeAt(index);
22
+ if (code <= 0x1f || code === 0x7f) {
23
+ return true;
24
+ }
25
+ }
26
+ return false;
27
+ }
28
+ /**
29
+ * Validates a request header name and value before passing them to fetch.
30
+ *
31
+ * @param name - Header field name (already trimmed when supplied from buildHeaders).
32
+ * @param value - Header field value.
33
+ * @returns An error message when invalid, otherwise null.
34
+ */
35
+ export function validateHeaderField(name, value) {
36
+ if (FORBIDDEN_HEADER_NAMES.has(name.toLowerCase())) {
37
+ return `Forbidden header: ${name}`;
38
+ }
39
+ if (hasUnsafeHeaderFieldChars(name)) {
40
+ return `Invalid header name: control characters are not allowed`;
41
+ }
42
+ if (hasUnsafeHeaderFieldChars(value)) {
43
+ return `Invalid header value for "${name}": control characters are not allowed`;
44
+ }
45
+ return null;
46
+ }
47
+ /**
48
+ * Validates a header map and returns the first validation error, if any.
49
+ *
50
+ * @param headers - Header map ready for fetch.
51
+ * @returns An error message when a field is invalid, otherwise null.
52
+ */
53
+ export function validateHeaders(headers) {
54
+ for (const [name, value] of Object.entries(headers)) {
55
+ const error = validateHeaderField(name, value);
56
+ if (error) {
57
+ return error;
58
+ }
59
+ }
60
+ return null;
61
+ }