@memberjunction/network-utils 0.0.0 → 6.1.0-edge.5
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/LICENSE +183 -0
- package/README.md +458 -27
- package/dist/HttpClient.d.ts +640 -0
- package/dist/HttpClient.d.ts.map +1 -0
- package/dist/HttpClient.js +702 -0
- package/dist/HttpClient.js.map +1 -0
- package/dist/SSRFGuard.d.ts +168 -0
- package/dist/SSRFGuard.d.ts.map +1 -0
- package/dist/SSRFGuard.js +408 -0
- package/dist/SSRFGuard.js.map +1 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +18 -0
- package/dist/index.js.map +1 -0
- package/package.json +38 -7
|
@@ -0,0 +1,702 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A dependency-free HTTP client built on Node's native `fetch`.
|
|
3
|
+
*
|
|
4
|
+
* This exists to replace `axios` across the MJ codebase. Node 18+ ships a spec-compliant `fetch`,
|
|
5
|
+
* so a third-party HTTP client buys nothing but supply-chain surface — while costing us the ability
|
|
6
|
+
* to route every outbound request through one place. That one place matters: {@link HttpClient}
|
|
7
|
+
* and {@link HttpRequest} can opt into the SSRF guard in this same package (`ValidateUrl`), which
|
|
8
|
+
* is impossible to enforce when every package reaches for `axios` directly.
|
|
9
|
+
*
|
|
10
|
+
* The shape deliberately mirrors the parts of axios MJ actually used — a config object, a response
|
|
11
|
+
* with a parsed `Data`, a throw-on-non-2xx default, per-instance defaults, and request/retry hooks
|
|
12
|
+
* standing in for interceptors — so call sites port over mechanically.
|
|
13
|
+
*
|
|
14
|
+
* SSRF NOTE: `ValidateUrl` defaults to **false** here. That is intentional. Most MJ call sites talk
|
|
15
|
+
* to fixed, well-known provider endpoints, and some legitimately talk to internal hosts (MJServer
|
|
16
|
+
* posting to its own API, MetadataSync resolving a `@url:` reference on a dev box). Guarding those
|
|
17
|
+
* by default would break them. Set `ValidateUrl: true` — or use {@link SafeFetch} directly —
|
|
18
|
+
* wherever the URL is caller-controlled.
|
|
19
|
+
*/
|
|
20
|
+
import { SafeFetch } from "./SSRFGuard.js";
|
|
21
|
+
/**
|
|
22
|
+
* Thrown for a non-2xx response (when `ThrowOnError` is on), a timeout, a caller-initiated
|
|
23
|
+
* cancellation, or a transport failure.
|
|
24
|
+
*
|
|
25
|
+
* Unlike axios's error — which nests response details under `error.response` and leaves you
|
|
26
|
+
* guessing whether that property exists — every field here is always present. A request that
|
|
27
|
+
* never reached a server reports `Status: 0`, so `error.Status === 404` is safe to write without
|
|
28
|
+
* an optional-chain dance.
|
|
29
|
+
*
|
|
30
|
+
* Distinguish the three failure modes with `Status`, {@link HttpError.IsTimeout}, and
|
|
31
|
+
* {@link HttpError.IsCancelled}.
|
|
32
|
+
*
|
|
33
|
+
* @example
|
|
34
|
+
* ```ts
|
|
35
|
+
* try {
|
|
36
|
+
* await client.Get('/thing');
|
|
37
|
+
* } catch (error) {
|
|
38
|
+
* if (!IsHttpError(error)) throw error;
|
|
39
|
+
* if (error.IsCancelled) return; // caller aborted; not a failure
|
|
40
|
+
* if (error.IsTimeout) LogError('upstream slow');
|
|
41
|
+
* else if (error.Status === 404) LogError('missing');
|
|
42
|
+
* else if (error.Status === 429) await backOff(error.Headers['retry-after']);
|
|
43
|
+
* else LogError(`HTTP ${error.Status}`, error.Data);
|
|
44
|
+
* }
|
|
45
|
+
* ```
|
|
46
|
+
*/
|
|
47
|
+
export class HttpError extends Error {
|
|
48
|
+
constructor(message, details) {
|
|
49
|
+
super(message);
|
|
50
|
+
this.name = "HttpError";
|
|
51
|
+
this.Status = details.Status ?? 0;
|
|
52
|
+
this.StatusText = details.StatusText ?? "";
|
|
53
|
+
this.Data = details.Data;
|
|
54
|
+
this.Headers = details.Headers ?? {};
|
|
55
|
+
this.Url = details.Url;
|
|
56
|
+
this.Method = details.Method;
|
|
57
|
+
this.IsTimeout = details.IsTimeout ?? false;
|
|
58
|
+
this.IsCancelled = details.IsCancelled ?? false;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Narrows an unknown caught value to {@link HttpError} — the replacement for axios's
|
|
63
|
+
* `isAxiosError`.
|
|
64
|
+
*
|
|
65
|
+
* @param error - the value from a `catch` block.
|
|
66
|
+
* @returns true when the value is an {@link HttpError}, narrowing its type for the caller.
|
|
67
|
+
*
|
|
68
|
+
* @example
|
|
69
|
+
* ```ts
|
|
70
|
+
* catch (error) {
|
|
71
|
+
* if (IsHttpError(error) && error.Status === 403) { ... }
|
|
72
|
+
* throw error; // anything else is not ours to interpret
|
|
73
|
+
* }
|
|
74
|
+
* ```
|
|
75
|
+
*/
|
|
76
|
+
export function IsHttpError(error) {
|
|
77
|
+
return error instanceof HttpError;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* True when a request failed because the caller's own `Signal` aborted it — the replacement for
|
|
81
|
+
* axios's `isCancel`.
|
|
82
|
+
*
|
|
83
|
+
* A request's own {@link HttpRequestConfig.Timeout} elapsing is NOT a cancellation; that reports
|
|
84
|
+
* {@link HttpError.IsTimeout} instead. The distinction matters because a cancellation is usually
|
|
85
|
+
* an expected outcome to swallow, while a timeout is a fault worth logging or retrying.
|
|
86
|
+
*
|
|
87
|
+
* A native `AbortError` — thrown when the signal was already aborted before the request
|
|
88
|
+
* started — also counts.
|
|
89
|
+
*
|
|
90
|
+
* @param error - the value from a `catch` block.
|
|
91
|
+
* @returns true when the failure was caller-initiated cancellation.
|
|
92
|
+
*
|
|
93
|
+
* @example
|
|
94
|
+
* ```ts
|
|
95
|
+
* catch (error) {
|
|
96
|
+
* if (IsCancellationError(error)) return null; // the caller gave up; stay quiet
|
|
97
|
+
* throw error;
|
|
98
|
+
* }
|
|
99
|
+
* ```
|
|
100
|
+
*/
|
|
101
|
+
export function IsCancellationError(error) {
|
|
102
|
+
if (error instanceof HttpError) {
|
|
103
|
+
return error.IsCancelled;
|
|
104
|
+
}
|
|
105
|
+
return error instanceof Error && error.name === "AbortError";
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Serializes a parameter object into a URL-encoded query string.
|
|
109
|
+
*
|
|
110
|
+
* Exported because it is occasionally useful on its own; {@link HttpRequest} applies it to
|
|
111
|
+
* `Query` automatically, so you rarely need to call it directly.
|
|
112
|
+
*
|
|
113
|
+
* Rules: `null` and `undefined` entries are omitted entirely (rather than becoming the strings
|
|
114
|
+
* `"null"`/`"undefined"`), arrays expand to repeated keys, and `Date` values render as ISO 8601.
|
|
115
|
+
*
|
|
116
|
+
* @param query - the parameters to serialize.
|
|
117
|
+
* @returns the encoded query string, WITHOUT a leading `?`. Empty when nothing survives.
|
|
118
|
+
*
|
|
119
|
+
* @example
|
|
120
|
+
* ```ts
|
|
121
|
+
* BuildQueryString({ q: 'a b', tag: ['x', 'y'], skip: null });
|
|
122
|
+
* // 'q=a+b&tag=x&tag=y'
|
|
123
|
+
* ```
|
|
124
|
+
*/
|
|
125
|
+
export function BuildQueryString(query) {
|
|
126
|
+
const params = new URLSearchParams();
|
|
127
|
+
for (const [key, value] of Object.entries(query)) {
|
|
128
|
+
if (value === null || value === undefined) {
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
if (Array.isArray(value)) {
|
|
132
|
+
for (const item of value) {
|
|
133
|
+
params.append(key, item instanceof Date ? item.toISOString() : String(item));
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
else if (value instanceof Date) {
|
|
137
|
+
params.append(key, value.toISOString());
|
|
138
|
+
}
|
|
139
|
+
else {
|
|
140
|
+
params.append(key, String(value));
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return params.toString();
|
|
144
|
+
}
|
|
145
|
+
/** Resolves the final absolute URL from `BaseURL`, `Url`, and `Query`. */
|
|
146
|
+
function ResolveUrl(config) {
|
|
147
|
+
let url = config.Url;
|
|
148
|
+
if (config.BaseURL && !/^[a-z][a-z0-9+.-]*:\/\//i.test(url)) {
|
|
149
|
+
const base = config.BaseURL.endsWith("/") ? config.BaseURL.slice(0, -1) : config.BaseURL;
|
|
150
|
+
const path = url.startsWith("/") ? url : `/${url}`;
|
|
151
|
+
url = `${base}${path}`;
|
|
152
|
+
}
|
|
153
|
+
if (config.Query) {
|
|
154
|
+
const qs = BuildQueryString(config.Query);
|
|
155
|
+
if (qs) {
|
|
156
|
+
url += (url.includes("?") ? "&" : "?") + qs;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return url;
|
|
160
|
+
}
|
|
161
|
+
/** Body types `fetch` understands natively — passed through without JSON encoding. */
|
|
162
|
+
function IsNativeBody(body) {
|
|
163
|
+
return (typeof body === "string" ||
|
|
164
|
+
body instanceof URLSearchParams ||
|
|
165
|
+
body instanceof ArrayBuffer ||
|
|
166
|
+
ArrayBuffer.isView(body) ||
|
|
167
|
+
(typeof Blob !== "undefined" && body instanceof Blob) ||
|
|
168
|
+
(typeof FormData !== "undefined" && body instanceof FormData) ||
|
|
169
|
+
(typeof ReadableStream !== "undefined" && body instanceof ReadableStream));
|
|
170
|
+
}
|
|
171
|
+
/** Flattens a `Headers` object into a plain lower-cased-key record. */
|
|
172
|
+
function HeadersToRecord(headers) {
|
|
173
|
+
const result = {};
|
|
174
|
+
headers.forEach((value, key) => {
|
|
175
|
+
result[key.toLowerCase()] = value;
|
|
176
|
+
});
|
|
177
|
+
return result;
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Discards a fetch `Response` body so its underlying connection can return to the keep-alive
|
|
181
|
+
* pool instead of being held open until GC finalizes an unconsumed stream. Node's `fetch`
|
|
182
|
+
* (undici) pins the connection to any `Response` whose body was never read or cancelled.
|
|
183
|
+
*
|
|
184
|
+
* Call this on a `Response` you're about to throw away without reading — most commonly an
|
|
185
|
+
* error-status branch that returns/throws before calling `.json()`/`.text()`/etc. `HttpRequest`
|
|
186
|
+
* and {@link HttpClient} already do this for you; reach for it directly only when a call site
|
|
187
|
+
* uses raw `fetch()` or {@link SafeFetch} instead of going through them.
|
|
188
|
+
*
|
|
189
|
+
* @example
|
|
190
|
+
* ```ts
|
|
191
|
+
* const response = await fetch(url);
|
|
192
|
+
* if (!response.ok) {
|
|
193
|
+
* await DrainResponseBody(response);
|
|
194
|
+
* throw new Error(`request failed: ${response.status}`);
|
|
195
|
+
* }
|
|
196
|
+
* ```
|
|
197
|
+
*/
|
|
198
|
+
export async function DrainResponseBody(response) {
|
|
199
|
+
try {
|
|
200
|
+
await response.body?.cancel();
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
// Body already consumed, already errored, or there was none to begin with — nothing to drain.
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
/** Reads and interprets a response body per the requested {@link HttpResponseType}. */
|
|
207
|
+
async function ReadBody(response, responseType) {
|
|
208
|
+
switch (responseType) {
|
|
209
|
+
case "none":
|
|
210
|
+
await response.body?.cancel();
|
|
211
|
+
return null;
|
|
212
|
+
case "stream":
|
|
213
|
+
return response.body;
|
|
214
|
+
case "arraybuffer":
|
|
215
|
+
return await response.arrayBuffer();
|
|
216
|
+
case "blob":
|
|
217
|
+
return await response.blob();
|
|
218
|
+
case "text":
|
|
219
|
+
return await response.text();
|
|
220
|
+
case "json":
|
|
221
|
+
default: {
|
|
222
|
+
const text = await response.text();
|
|
223
|
+
if (text.length === 0) {
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
try {
|
|
227
|
+
return JSON.parse(text);
|
|
228
|
+
}
|
|
229
|
+
catch {
|
|
230
|
+
// Not JSON despite the request — hand back the raw text rather than throwing,
|
|
231
|
+
// which matches how axios behaves when a server mislabels its content type.
|
|
232
|
+
return text;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Performs a single HTTP request using native `fetch`. This is the primitive the rest of the
|
|
239
|
+
* module is built on — the method shorthands and {@link HttpClient} all funnel through it.
|
|
240
|
+
*
|
|
241
|
+
* Reach for a shorthand ({@link HttpGet}, {@link HttpPost}, …) for one-off calls, and for
|
|
242
|
+
* {@link HttpClient} when several requests share a base URL, credentials, or retry policy.
|
|
243
|
+
*
|
|
244
|
+
* @typeParam T - the expected shape of the parsed response body.
|
|
245
|
+
* @param config - the request configuration; see {@link HttpRequestConfig}.
|
|
246
|
+
* @returns the {@link HttpResponse}, whose `Data` is parsed per `ResponseType`.
|
|
247
|
+
* @throws {HttpError} on a non-2xx status (unless `ThrowOnError` is disabled), on a timeout, on
|
|
248
|
+
* caller cancellation, or on a transport failure. Inspect `Status` / `IsTimeout` / `IsCancelled`
|
|
249
|
+
* to tell them apart.
|
|
250
|
+
* @throws {SSRFError} when `ValidateUrl` is enabled and the URL — or any redirect hop — resolves
|
|
251
|
+
* to a private or reserved address. This propagates as itself rather than being wrapped in an
|
|
252
|
+
* {@link HttpError}, so a security decision is never mistaken for an unreachable host.
|
|
253
|
+
*
|
|
254
|
+
* @example Reading a JSON payload
|
|
255
|
+
* ```ts
|
|
256
|
+
* const response = await HttpRequest<{ items: Item[] }>({
|
|
257
|
+
* Url: 'https://api.example.com/items',
|
|
258
|
+
* Query: { page: 1 },
|
|
259
|
+
* });
|
|
260
|
+
* return response.Data.items;
|
|
261
|
+
* ```
|
|
262
|
+
*
|
|
263
|
+
* @example Inspecting a non-2xx instead of catching it
|
|
264
|
+
* ```ts
|
|
265
|
+
* const response = await HttpRequest({ Url: url, ThrowOnError: false });
|
|
266
|
+
* if (response.Status === 404) return null;
|
|
267
|
+
* ```
|
|
268
|
+
*/
|
|
269
|
+
export async function HttpRequest(config) {
|
|
270
|
+
const method = config.Method ?? "GET";
|
|
271
|
+
const responseType = config.ResponseType ?? "json";
|
|
272
|
+
const throwOnError = config.ThrowOnError ?? true;
|
|
273
|
+
const timeout = config.Timeout ?? 30000;
|
|
274
|
+
const maxRedirects = config.MaxRedirects ?? 5;
|
|
275
|
+
const url = ResolveUrl(config);
|
|
276
|
+
const headers = { ...config.Headers };
|
|
277
|
+
if (config.BasicAuth) {
|
|
278
|
+
const hasAuthHeader = Object.keys(headers).some((k) => k.toLowerCase() === "authorization");
|
|
279
|
+
if (!hasAuthHeader) {
|
|
280
|
+
const encoded = Buffer.from(`${config.BasicAuth.Username}:${config.BasicAuth.Password}`, "utf8").toString("base64");
|
|
281
|
+
headers["Authorization"] = `Basic ${encoded}`;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
let body;
|
|
285
|
+
if (config.Body !== undefined && config.Body !== null && method !== "GET" && method !== "HEAD") {
|
|
286
|
+
if (IsNativeBody(config.Body)) {
|
|
287
|
+
body = config.Body;
|
|
288
|
+
}
|
|
289
|
+
else {
|
|
290
|
+
body = JSON.stringify(config.Body);
|
|
291
|
+
const hasContentType = Object.keys(headers).some((k) => k.toLowerCase() === "content-type");
|
|
292
|
+
if (!hasContentType) {
|
|
293
|
+
headers["Content-Type"] = "application/json";
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
// Compose the caller's signal with our timeout so either can abort the request.
|
|
298
|
+
const controller = new AbortController();
|
|
299
|
+
const timer = timeout > 0 ? setTimeout(() => controller.abort(), timeout) : null;
|
|
300
|
+
const onCallerAbort = () => controller.abort();
|
|
301
|
+
if (config.Signal) {
|
|
302
|
+
if (config.Signal.aborted) {
|
|
303
|
+
controller.abort();
|
|
304
|
+
}
|
|
305
|
+
else {
|
|
306
|
+
config.Signal.addEventListener("abort", onCallerAbort, { once: true });
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
try {
|
|
310
|
+
let response;
|
|
311
|
+
if (config.ValidateUrl) {
|
|
312
|
+
response = await SafeFetch(url, {
|
|
313
|
+
method,
|
|
314
|
+
headers,
|
|
315
|
+
body,
|
|
316
|
+
signal: controller.signal,
|
|
317
|
+
MaxRedirects: maxRedirects,
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
else {
|
|
321
|
+
response = await fetch(url, {
|
|
322
|
+
method,
|
|
323
|
+
headers,
|
|
324
|
+
body,
|
|
325
|
+
signal: controller.signal,
|
|
326
|
+
redirect: maxRedirects > 0 ? "follow" : "manual",
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
const responseHeaders = HeadersToRecord(response.headers);
|
|
330
|
+
// HEAD responses have no body, and a caller asking for JSON should not get a parse attempt.
|
|
331
|
+
const data = method === "HEAD" ? null : await ReadBody(response, responseType);
|
|
332
|
+
const result = {
|
|
333
|
+
Data: data,
|
|
334
|
+
Status: response.status,
|
|
335
|
+
StatusText: response.statusText,
|
|
336
|
+
Headers: responseHeaders,
|
|
337
|
+
Url: response.url || url,
|
|
338
|
+
Ok: response.status >= 200 && response.status < 300,
|
|
339
|
+
};
|
|
340
|
+
if (!result.Ok && throwOnError) {
|
|
341
|
+
if (responseType === "stream" && data instanceof ReadableStream) {
|
|
342
|
+
// `data` is the raw, unconsumed stream we're about to discard in favor of throwing —
|
|
343
|
+
// cancel it so the connection isn't held open by an error nobody will read.
|
|
344
|
+
await DrainResponseBody(response);
|
|
345
|
+
}
|
|
346
|
+
throw new HttpError(`Request failed with status code ${response.status}`, {
|
|
347
|
+
Status: response.status,
|
|
348
|
+
StatusText: response.statusText,
|
|
349
|
+
Data: data,
|
|
350
|
+
Headers: responseHeaders,
|
|
351
|
+
Url: result.Url,
|
|
352
|
+
Method: method,
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
return result;
|
|
356
|
+
}
|
|
357
|
+
catch (error) {
|
|
358
|
+
if (error instanceof HttpError) {
|
|
359
|
+
throw error;
|
|
360
|
+
}
|
|
361
|
+
const aborted = controller.signal.aborted;
|
|
362
|
+
const isCancelled = aborted && config.Signal?.aborted === true;
|
|
363
|
+
const isTimeout = aborted && !isCancelled;
|
|
364
|
+
const message = isTimeout
|
|
365
|
+
? `Request to ${url} timed out after ${timeout}ms`
|
|
366
|
+
: isCancelled
|
|
367
|
+
? `Request to ${url} was cancelled by the caller`
|
|
368
|
+
: `Request to ${url} failed: ${error instanceof Error ? error.message : String(error)}`;
|
|
369
|
+
// A guard rejection (SSRFError) is a security decision, not a transport failure — let it
|
|
370
|
+
// propagate as itself so callers can distinguish "blocked" from "unreachable".
|
|
371
|
+
if (error instanceof Error && error.name === "SSRFError") {
|
|
372
|
+
throw error;
|
|
373
|
+
}
|
|
374
|
+
throw new HttpError(message, { Url: url, Method: method, IsTimeout: isTimeout, IsCancelled: isCancelled });
|
|
375
|
+
}
|
|
376
|
+
finally {
|
|
377
|
+
if (timer) {
|
|
378
|
+
clearTimeout(timer);
|
|
379
|
+
}
|
|
380
|
+
config.Signal?.removeEventListener("abort", onCallerAbort);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
/**
|
|
384
|
+
* Sends a `GET` request. Shorthand for {@link HttpRequest} with `Method: 'GET'`.
|
|
385
|
+
*
|
|
386
|
+
* @typeParam T - the expected shape of the parsed response body.
|
|
387
|
+
* @param url - absolute URL, or a path when `config.BaseURL` is supplied.
|
|
388
|
+
* @param config - optional per-request settings (query, headers, timeout, …).
|
|
389
|
+
* @returns the {@link HttpResponse}.
|
|
390
|
+
* @throws {HttpError} on a non-2xx status, timeout, cancellation, or transport failure.
|
|
391
|
+
*
|
|
392
|
+
* @example
|
|
393
|
+
* ```ts
|
|
394
|
+
* const { Data } = await HttpGet<User[]>('https://api.example.com/users', {
|
|
395
|
+
* Query: { active: true },
|
|
396
|
+
* Headers: { Authorization: `Bearer ${token}` },
|
|
397
|
+
* });
|
|
398
|
+
* ```
|
|
399
|
+
*/
|
|
400
|
+
export async function HttpGet(url, config) {
|
|
401
|
+
return await HttpRequest({ ...config, Url: url, Method: "GET" });
|
|
402
|
+
}
|
|
403
|
+
/**
|
|
404
|
+
* Sends a `POST` request. Shorthand for {@link HttpRequest} with `Method: 'POST'`.
|
|
405
|
+
*
|
|
406
|
+
* A plain object or array `body` is JSON-encoded and given a JSON `Content-Type`. Pass a
|
|
407
|
+
* `URLSearchParams` for form encoding or a `FormData` for multipart, and `fetch` sets the correct
|
|
408
|
+
* `Content-Type` (including the multipart boundary) itself.
|
|
409
|
+
*
|
|
410
|
+
* @typeParam T - the expected shape of the parsed response body.
|
|
411
|
+
* @param url - absolute URL, or a path when `config.BaseURL` is supplied.
|
|
412
|
+
* @param body - the request body.
|
|
413
|
+
* @param config - optional per-request settings.
|
|
414
|
+
* @returns the {@link HttpResponse}.
|
|
415
|
+
* @throws {HttpError} on a non-2xx status, timeout, cancellation, or transport failure.
|
|
416
|
+
*
|
|
417
|
+
* @example
|
|
418
|
+
* ```ts
|
|
419
|
+
* await HttpPost('https://api.example.com/items', { name: 'Widget' }); // JSON
|
|
420
|
+
* await HttpPost(tokenUrl, new URLSearchParams({ grant_type: 'refresh_token' })); // form
|
|
421
|
+
* ```
|
|
422
|
+
*/
|
|
423
|
+
export async function HttpPost(url, body, config) {
|
|
424
|
+
return await HttpRequest({ ...config, Url: url, Method: "POST", Body: body });
|
|
425
|
+
}
|
|
426
|
+
/**
|
|
427
|
+
* Sends a `PUT` request. Shorthand for {@link HttpRequest} with `Method: 'PUT'`.
|
|
428
|
+
* Body handling matches {@link HttpPost}.
|
|
429
|
+
*
|
|
430
|
+
* @typeParam T - the expected shape of the parsed response body.
|
|
431
|
+
* @param url - absolute URL, or a path when `config.BaseURL` is supplied.
|
|
432
|
+
* @param body - the request body.
|
|
433
|
+
* @param config - optional per-request settings.
|
|
434
|
+
* @returns the {@link HttpResponse}.
|
|
435
|
+
* @throws {HttpError} on a non-2xx status, timeout, cancellation, or transport failure.
|
|
436
|
+
*/
|
|
437
|
+
export async function HttpPut(url, body, config) {
|
|
438
|
+
return await HttpRequest({ ...config, Url: url, Method: "PUT", Body: body });
|
|
439
|
+
}
|
|
440
|
+
/**
|
|
441
|
+
* Sends a `PATCH` request. Shorthand for {@link HttpRequest} with `Method: 'PATCH'`.
|
|
442
|
+
* Body handling matches {@link HttpPost}.
|
|
443
|
+
*
|
|
444
|
+
* @typeParam T - the expected shape of the parsed response body.
|
|
445
|
+
* @param url - absolute URL, or a path when `config.BaseURL` is supplied.
|
|
446
|
+
* @param body - the request body.
|
|
447
|
+
* @param config - optional per-request settings.
|
|
448
|
+
* @returns the {@link HttpResponse}.
|
|
449
|
+
* @throws {HttpError} on a non-2xx status, timeout, cancellation, or transport failure.
|
|
450
|
+
*/
|
|
451
|
+
export async function HttpPatch(url, body, config) {
|
|
452
|
+
return await HttpRequest({ ...config, Url: url, Method: "PATCH", Body: body });
|
|
453
|
+
}
|
|
454
|
+
/**
|
|
455
|
+
* Sends a `DELETE` request. Shorthand for {@link HttpRequest} with `Method: 'DELETE'`.
|
|
456
|
+
*
|
|
457
|
+
* @typeParam T - the expected shape of the parsed response body.
|
|
458
|
+
* @param url - absolute URL, or a path when `config.BaseURL` is supplied.
|
|
459
|
+
* @param config - optional per-request settings.
|
|
460
|
+
* @returns the {@link HttpResponse}.
|
|
461
|
+
* @throws {HttpError} on a non-2xx status, timeout, cancellation, or transport failure.
|
|
462
|
+
*/
|
|
463
|
+
export async function HttpDelete(url, config) {
|
|
464
|
+
return await HttpRequest({ ...config, Url: url, Method: "DELETE" });
|
|
465
|
+
}
|
|
466
|
+
/**
|
|
467
|
+
* Sends a `HEAD` request. Shorthand for {@link HttpRequest} with `Method: 'HEAD'`.
|
|
468
|
+
*
|
|
469
|
+
* `ResponseType` is forced to `none` because a HEAD response has no body by definition, so `Data`
|
|
470
|
+
* is always `null` — use `Status` and `Headers`. Pair with `ThrowOnError: false` when probing a
|
|
471
|
+
* URL's reachability, so a 4xx is reported as a status rather than thrown.
|
|
472
|
+
*
|
|
473
|
+
* @typeParam T - unused in practice; `Data` is always `null`.
|
|
474
|
+
* @param url - absolute URL, or a path when `config.BaseURL` is supplied.
|
|
475
|
+
* @param config - optional per-request settings.
|
|
476
|
+
* @returns the {@link HttpResponse}, with `Data` set to `null`.
|
|
477
|
+
* @throws {HttpError} on a non-2xx status (unless `ThrowOnError` is disabled), timeout,
|
|
478
|
+
* cancellation, or transport failure.
|
|
479
|
+
*
|
|
480
|
+
* @example Probing whether a link is alive
|
|
481
|
+
* ```ts
|
|
482
|
+
* const response = await HttpHead(url, { ThrowOnError: false, Timeout: 10000 });
|
|
483
|
+
* const reachable = response.Status >= 200 && response.Status < 400;
|
|
484
|
+
* ```
|
|
485
|
+
*/
|
|
486
|
+
export async function HttpHead(url, config) {
|
|
487
|
+
return await HttpRequest({ ...config, Url: url, Method: "HEAD", ResponseType: "none" });
|
|
488
|
+
}
|
|
489
|
+
/**
|
|
490
|
+
* {@link HttpRequest} with the SSRF guard switched on — equivalent to passing
|
|
491
|
+
* `ValidateUrl: true`, but named so a reviewer can see at a glance that the call site handles an
|
|
492
|
+
* untrusted URL.
|
|
493
|
+
*
|
|
494
|
+
* Use this (or set `ValidateUrl` explicitly) wherever the URL can be influenced by an AI agent, an
|
|
495
|
+
* Action parameter, an API caller, or stored data those can write. Prefer {@link SafeFetch} when
|
|
496
|
+
* you want the raw `Response` rather than a parsed body.
|
|
497
|
+
*
|
|
498
|
+
* @typeParam T - the expected shape of the parsed response body.
|
|
499
|
+
* @param config - the request configuration; `ValidateUrl` is forced on.
|
|
500
|
+
* @returns the {@link HttpResponse}.
|
|
501
|
+
* @throws {SSRFError} when the URL, or any redirect hop, resolves to a private or reserved address.
|
|
502
|
+
* @throws {HttpError} on a non-2xx status, timeout, cancellation, or transport failure.
|
|
503
|
+
*
|
|
504
|
+
* @example
|
|
505
|
+
* ```ts
|
|
506
|
+
* try {
|
|
507
|
+
* const response = await SafeHttpRequest({ Url: userSuppliedUrl });
|
|
508
|
+
* return response.Data;
|
|
509
|
+
* } catch (error) {
|
|
510
|
+
* if (error instanceof SSRFError) {
|
|
511
|
+
* return { Success: false, ResultCode: 'SSRF_BLOCKED' };
|
|
512
|
+
* }
|
|
513
|
+
* throw error;
|
|
514
|
+
* }
|
|
515
|
+
* ```
|
|
516
|
+
*/
|
|
517
|
+
export async function SafeHttpRequest(config) {
|
|
518
|
+
return await HttpRequest({ ...config, ValidateUrl: true });
|
|
519
|
+
}
|
|
520
|
+
/**
|
|
521
|
+
* A configured HTTP client — the replacement for `axios.create(...)`.
|
|
522
|
+
*
|
|
523
|
+
* Holds a base URL, default headers, a timeout, and the {@link HttpClientOptions} hooks that stand
|
|
524
|
+
* in for axios interceptors. Construct one per upstream API, typically behind a lazy getter on a
|
|
525
|
+
* provider base class so the cost is paid only when that provider is actually used.
|
|
526
|
+
*
|
|
527
|
+
* Instances are stateless apart from their options and safe to share across concurrent requests.
|
|
528
|
+
* Nothing is cached between calls, so a client whose `OnRequest` reads a token always sends the
|
|
529
|
+
* current one.
|
|
530
|
+
*
|
|
531
|
+
* @example A provider client with auth injection and 429 back-off
|
|
532
|
+
* ```ts
|
|
533
|
+
* private _client: HttpClient | null = null;
|
|
534
|
+
*
|
|
535
|
+
* protected get httpClient(): HttpClient {
|
|
536
|
+
* if (!this._client) {
|
|
537
|
+
* this._client = new HttpClient({
|
|
538
|
+
* BaseURL: 'https://graph.facebook.com/v18.0',
|
|
539
|
+
* Timeout: 30000,
|
|
540
|
+
* Headers: { Accept: 'application/json' },
|
|
541
|
+
*
|
|
542
|
+
* // Runs before every attempt, so a refreshed token is picked up automatically.
|
|
543
|
+
* OnRequest: (config) => ({
|
|
544
|
+
* ...config,
|
|
545
|
+
* Query: { ...config.Query, access_token: this.getAccessToken() },
|
|
546
|
+
* }),
|
|
547
|
+
*
|
|
548
|
+
* // Bounded by MaxRetries (default 3).
|
|
549
|
+
* OnRetry: async (error) => {
|
|
550
|
+
* if (error.Status !== 429) return false;
|
|
551
|
+
* await this.handleRateLimit(60);
|
|
552
|
+
* return true;
|
|
553
|
+
* },
|
|
554
|
+
* });
|
|
555
|
+
* }
|
|
556
|
+
* return this._client;
|
|
557
|
+
* }
|
|
558
|
+
*
|
|
559
|
+
* // Then, at the call sites:
|
|
560
|
+
* const response = await this.httpClient.Get<FacebookPagedResponse<Post>>('/me/feed');
|
|
561
|
+
* return response.Data.data;
|
|
562
|
+
* ```
|
|
563
|
+
*/
|
|
564
|
+
export class HttpClient {
|
|
565
|
+
constructor(options = {}) {
|
|
566
|
+
this._options = options;
|
|
567
|
+
}
|
|
568
|
+
/**
|
|
569
|
+
* The options this client was constructed with. Read-only — a client's behavior is fixed at
|
|
570
|
+
* construction, so build a second client rather than trying to mutate one.
|
|
571
|
+
*/
|
|
572
|
+
get Options() {
|
|
573
|
+
return this._options;
|
|
574
|
+
}
|
|
575
|
+
/**
|
|
576
|
+
* Sends a request using this client's defaults and hooks. Every other method on the class
|
|
577
|
+
* delegates here.
|
|
578
|
+
*
|
|
579
|
+
* Per-request values win over client defaults, except `Headers`, which are merged key-by-key so
|
|
580
|
+
* one request can override a single default header without discarding the rest. `OnRequest`
|
|
581
|
+
* then gets the last word on the merged config.
|
|
582
|
+
*
|
|
583
|
+
* @typeParam T - the expected shape of the parsed response body.
|
|
584
|
+
* @param config - per-request configuration, merged over the client's defaults.
|
|
585
|
+
* @returns the {@link HttpResponse}.
|
|
586
|
+
* @throws {HttpError} when the request fails and `OnRetry` does not ask for another attempt, or
|
|
587
|
+
* once `MaxRetries` is exhausted.
|
|
588
|
+
* @throws {SSRFError} when `ValidateUrl` is enabled and the target resolves to a blocked
|
|
589
|
+
* address. Never retried.
|
|
590
|
+
*/
|
|
591
|
+
async Request(config) {
|
|
592
|
+
const maxRetries = this._options.MaxRetries ?? 3;
|
|
593
|
+
let attempt = 0;
|
|
594
|
+
for (;;) {
|
|
595
|
+
let merged = {
|
|
596
|
+
...config,
|
|
597
|
+
BaseURL: config.BaseURL ?? this._options.BaseURL,
|
|
598
|
+
Timeout: config.Timeout ?? this._options.Timeout,
|
|
599
|
+
ValidateUrl: config.ValidateUrl ?? this._options.ValidateUrl,
|
|
600
|
+
MaxRedirects: config.MaxRedirects ?? this._options.MaxRedirects,
|
|
601
|
+
BasicAuth: config.BasicAuth ?? this._options.BasicAuth,
|
|
602
|
+
Headers: { ...this._options.Headers, ...config.Headers },
|
|
603
|
+
};
|
|
604
|
+
if (this._options.OnRequest) {
|
|
605
|
+
merged = await this._options.OnRequest(merged);
|
|
606
|
+
}
|
|
607
|
+
try {
|
|
608
|
+
const response = await HttpRequest(merged);
|
|
609
|
+
if (this._options.OnResponse) {
|
|
610
|
+
await this._options.OnResponse(response);
|
|
611
|
+
}
|
|
612
|
+
return response;
|
|
613
|
+
}
|
|
614
|
+
catch (error) {
|
|
615
|
+
if (!IsHttpError(error) || !this._options.OnRetry || attempt >= maxRetries) {
|
|
616
|
+
throw error;
|
|
617
|
+
}
|
|
618
|
+
attempt++;
|
|
619
|
+
const shouldRetry = await this._options.OnRetry(error, attempt);
|
|
620
|
+
if (!shouldRetry) {
|
|
621
|
+
throw error;
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
/**
|
|
627
|
+
* Sends a `GET` request through this client.
|
|
628
|
+
*
|
|
629
|
+
* @typeParam T - the expected shape of the parsed response body.
|
|
630
|
+
* @param url - absolute URL, or a path resolved against the client's `BaseURL`.
|
|
631
|
+
* @param config - optional per-request settings, merged over the client's defaults.
|
|
632
|
+
* @returns the {@link HttpResponse}.
|
|
633
|
+
* @throws {HttpError} on a non-2xx status, timeout, cancellation, or transport failure.
|
|
634
|
+
*/
|
|
635
|
+
async Get(url, config) {
|
|
636
|
+
return await this.Request({ ...config, Url: url, Method: "GET" });
|
|
637
|
+
}
|
|
638
|
+
/**
|
|
639
|
+
* Sends a `POST` request through this client. Body handling matches {@link HttpPost}.
|
|
640
|
+
*
|
|
641
|
+
* @typeParam T - the expected shape of the parsed response body.
|
|
642
|
+
* @param url - absolute URL, or a path resolved against the client's `BaseURL`.
|
|
643
|
+
* @param body - the request body.
|
|
644
|
+
* @param config - optional per-request settings, merged over the client's defaults.
|
|
645
|
+
* @returns the {@link HttpResponse}.
|
|
646
|
+
* @throws {HttpError} on a non-2xx status, timeout, cancellation, or transport failure.
|
|
647
|
+
*/
|
|
648
|
+
async Post(url, body, config) {
|
|
649
|
+
return await this.Request({ ...config, Url: url, Method: "POST", Body: body });
|
|
650
|
+
}
|
|
651
|
+
/**
|
|
652
|
+
* Sends a `PUT` request through this client. Body handling matches {@link HttpPost}.
|
|
653
|
+
*
|
|
654
|
+
* @typeParam T - the expected shape of the parsed response body.
|
|
655
|
+
* @param url - absolute URL, or a path resolved against the client's `BaseURL`.
|
|
656
|
+
* @param body - the request body.
|
|
657
|
+
* @param config - optional per-request settings, merged over the client's defaults.
|
|
658
|
+
* @returns the {@link HttpResponse}.
|
|
659
|
+
* @throws {HttpError} on a non-2xx status, timeout, cancellation, or transport failure.
|
|
660
|
+
*/
|
|
661
|
+
async Put(url, body, config) {
|
|
662
|
+
return await this.Request({ ...config, Url: url, Method: "PUT", Body: body });
|
|
663
|
+
}
|
|
664
|
+
/**
|
|
665
|
+
* Sends a `PATCH` request through this client. Body handling matches {@link HttpPost}.
|
|
666
|
+
*
|
|
667
|
+
* @typeParam T - the expected shape of the parsed response body.
|
|
668
|
+
* @param url - absolute URL, or a path resolved against the client's `BaseURL`.
|
|
669
|
+
* @param body - the request body.
|
|
670
|
+
* @param config - optional per-request settings, merged over the client's defaults.
|
|
671
|
+
* @returns the {@link HttpResponse}.
|
|
672
|
+
* @throws {HttpError} on a non-2xx status, timeout, cancellation, or transport failure.
|
|
673
|
+
*/
|
|
674
|
+
async Patch(url, body, config) {
|
|
675
|
+
return await this.Request({ ...config, Url: url, Method: "PATCH", Body: body });
|
|
676
|
+
}
|
|
677
|
+
/**
|
|
678
|
+
* Sends a `DELETE` request through this client.
|
|
679
|
+
*
|
|
680
|
+
* @typeParam T - the expected shape of the parsed response body.
|
|
681
|
+
* @param url - absolute URL, or a path resolved against the client's `BaseURL`.
|
|
682
|
+
* @param config - optional per-request settings, merged over the client's defaults.
|
|
683
|
+
* @returns the {@link HttpResponse}.
|
|
684
|
+
* @throws {HttpError} on a non-2xx status, timeout, cancellation, or transport failure.
|
|
685
|
+
*/
|
|
686
|
+
async Delete(url, config) {
|
|
687
|
+
return await this.Request({ ...config, Url: url, Method: "DELETE" });
|
|
688
|
+
}
|
|
689
|
+
/**
|
|
690
|
+
* Sends a `HEAD` request through this client. `ResponseType` is forced to `none`, so `Data` is always `null`.
|
|
691
|
+
*
|
|
692
|
+
* @typeParam T - the expected shape of the parsed response body.
|
|
693
|
+
* @param url - absolute URL, or a path resolved against the client's `BaseURL`.
|
|
694
|
+
* @param config - optional per-request settings, merged over the client's defaults.
|
|
695
|
+
* @returns the {@link HttpResponse}.
|
|
696
|
+
* @throws {HttpError} on a non-2xx status, timeout, cancellation, or transport failure.
|
|
697
|
+
*/
|
|
698
|
+
async Head(url, config) {
|
|
699
|
+
return await this.Request({ ...config, Url: url, Method: "HEAD", ResponseType: "none" });
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
//# sourceMappingURL=HttpClient.js.map
|