@blamejs/core 0.7.18 → 0.7.20

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 (169) hide show
  1. package/CHANGELOG.md +427 -423
  2. package/README.md +150 -150
  3. package/bin/blamejs.js +0 -0
  4. package/index.js +310 -308
  5. package/lib/api-key.js +660 -660
  6. package/lib/api-snapshot.js +338 -338
  7. package/lib/app-shutdown.js +385 -385
  8. package/lib/app.js +365 -365
  9. package/lib/archive.js +250 -250
  10. package/lib/atomic-file.js +544 -544
  11. package/lib/audit-chain.js +177 -177
  12. package/lib/audit-sign.js +344 -344
  13. package/lib/audit-tools.js +677 -677
  14. package/lib/audit.js +766 -766
  15. package/lib/auth/jwt-external.js +365 -0
  16. package/lib/auth/jwt.js +337 -311
  17. package/lib/auth/lockout.js +436 -436
  18. package/lib/auth/oauth.js +721 -721
  19. package/lib/auth/passkey.js +181 -181
  20. package/lib/auth/password.js +628 -594
  21. package/lib/backup/bundle.js +217 -217
  22. package/lib/backup/crypto.js +176 -176
  23. package/lib/backup/index.js +515 -515
  24. package/lib/backup/manifest.js +282 -282
  25. package/lib/break-glass.js +1338 -1338
  26. package/lib/bundler.js +441 -441
  27. package/lib/cache-redis.js +256 -256
  28. package/lib/cache.js +1206 -1206
  29. package/lib/canonical-json.js +115 -115
  30. package/lib/chain-writer.js +234 -234
  31. package/lib/cli-helpers.js +206 -206
  32. package/lib/cli.js +2334 -2334
  33. package/lib/cluster-provider-db.js +317 -317
  34. package/lib/cluster-storage.js +226 -226
  35. package/lib/cluster.js +703 -703
  36. package/lib/config-drift.js +301 -301
  37. package/lib/consent.js +222 -222
  38. package/lib/constants.js +191 -191
  39. package/lib/cookies.js +350 -315
  40. package/lib/credential-hash.js +322 -322
  41. package/lib/crypto.js +266 -266
  42. package/lib/csv.js +275 -275
  43. package/lib/db-declare-row-policy.js +267 -267
  44. package/lib/db-declare-view.js +420 -420
  45. package/lib/db-query.js +406 -406
  46. package/lib/db-schema.js +319 -319
  47. package/lib/db.js +1288 -1288
  48. package/lib/deprecate.js +222 -222
  49. package/lib/dev.js +335 -335
  50. package/lib/dual-control.js +473 -473
  51. package/lib/error-page.js +420 -420
  52. package/lib/external-db-migrate.js +441 -441
  53. package/lib/external-db.js +1061 -1061
  54. package/lib/file-type.js +273 -273
  55. package/lib/forms.js +422 -422
  56. package/lib/framework-error.js +293 -293
  57. package/lib/framework-schema.js +717 -717
  58. package/lib/handlers.js +350 -350
  59. package/lib/http-client-cookie-jar.js +508 -508
  60. package/lib/http-client.js +1195 -1195
  61. package/lib/i18n.js +878 -878
  62. package/lib/jobs.js +185 -185
  63. package/lib/log-stream-cloudwatch.js +369 -369
  64. package/lib/log-stream-local.js +146 -146
  65. package/lib/log-stream-otlp-grpc.js +410 -410
  66. package/lib/log-stream-otlp.js +286 -286
  67. package/lib/log-stream-syslog.js +302 -302
  68. package/lib/log-stream-webhook.js +199 -199
  69. package/lib/log-stream.js +330 -330
  70. package/lib/log.js +500 -500
  71. package/lib/mail-bounce.js +528 -528
  72. package/lib/mail-dkim.js +369 -369
  73. package/lib/mail.js +981 -981
  74. package/lib/metrics.js +683 -683
  75. package/lib/middleware/api-encrypt.js +936 -936
  76. package/lib/middleware/attach-user.js +157 -157
  77. package/lib/middleware/bearer-auth.js +152 -0
  78. package/lib/middleware/body-parser.js +1170 -1170
  79. package/lib/middleware/bot-guard.js +178 -178
  80. package/lib/middleware/compression.js +452 -452
  81. package/lib/middleware/cors.js +314 -314
  82. package/lib/middleware/csp-nonce.js +348 -348
  83. package/lib/middleware/csrf-protect.js +399 -316
  84. package/lib/middleware/db-role-for.js +264 -264
  85. package/lib/middleware/fetch-metadata.js +129 -0
  86. package/lib/middleware/health.js +392 -392
  87. package/lib/middleware/index.js +85 -79
  88. package/lib/middleware/rate-limit.js +358 -358
  89. package/lib/middleware/request-id.js +61 -61
  90. package/lib/middleware/request-log.js +168 -168
  91. package/lib/middleware/require-auth.js +104 -104
  92. package/lib/middleware/security-headers.js +121 -116
  93. package/lib/middleware/sse.js +166 -166
  94. package/lib/migrations.js +383 -383
  95. package/lib/mtls-ca.js +518 -518
  96. package/lib/mtls-engine-default.js +481 -481
  97. package/lib/network-dns.js +632 -632
  98. package/lib/network-heartbeat.js +290 -290
  99. package/lib/network-nts.js +574 -574
  100. package/lib/network-proxy.js +265 -265
  101. package/lib/network-tls.js +328 -328
  102. package/lib/network.js +233 -233
  103. package/lib/notify.js +612 -612
  104. package/lib/ntp-check.js +229 -229
  105. package/lib/numeric-bounds.js +111 -111
  106. package/lib/object-store/azure-blob-bucket-ops.js +349 -349
  107. package/lib/object-store/azure-blob.js +488 -488
  108. package/lib/object-store/gcs-bucket-ops.js +351 -351
  109. package/lib/object-store/gcs.js +519 -519
  110. package/lib/object-store/http-put.js +153 -153
  111. package/lib/object-store/index.js +197 -197
  112. package/lib/object-store/sigv4-bucket-ops.js +1092 -1092
  113. package/lib/object-store/sigv4.js +903 -903
  114. package/lib/observability.js +151 -151
  115. package/lib/otel-export.js +269 -269
  116. package/lib/pagination.js +464 -464
  117. package/lib/parsers/index.js +80 -80
  118. package/lib/parsers/safe-env.js +642 -642
  119. package/lib/parsers/safe-ini.js +292 -292
  120. package/lib/parsers/safe-toml.js +784 -784
  121. package/lib/parsers/safe-xml.js +390 -390
  122. package/lib/parsers/safe-yaml.js +1015 -1015
  123. package/lib/permissions.js +708 -708
  124. package/lib/pqc-agent.js +87 -87
  125. package/lib/pqc-gate.js +279 -279
  126. package/lib/protobuf-encoder.js +190 -190
  127. package/lib/protocol-dispatcher.js +161 -161
  128. package/lib/pubsub-redis.js +167 -167
  129. package/lib/pubsub.js +429 -429
  130. package/lib/queue-local.js +476 -476
  131. package/lib/queue-redis.js +745 -745
  132. package/lib/queue-sqs.js +319 -319
  133. package/lib/queue.js +695 -695
  134. package/lib/redis-client.js +519 -519
  135. package/lib/request-helpers.js +340 -340
  136. package/lib/restore-bundle.js +237 -237
  137. package/lib/restore-rollback.js +259 -259
  138. package/lib/restore.js +409 -409
  139. package/lib/retry.js +376 -376
  140. package/lib/router.js +748 -748
  141. package/lib/safe-async.js +735 -735
  142. package/lib/safe-buffer.js +237 -237
  143. package/lib/safe-json.js +541 -541
  144. package/lib/safe-schema.js +1266 -1266
  145. package/lib/safe-url.js +159 -159
  146. package/lib/scheduler.js +706 -706
  147. package/lib/security-assert.js +373 -373
  148. package/lib/seeders.js +618 -618
  149. package/lib/session.js +535 -478
  150. package/lib/slug.js +269 -269
  151. package/lib/ssrf-guard.js +401 -401
  152. package/lib/static.js +7 -5
  153. package/lib/storage.js +471 -471
  154. package/lib/subject.js +281 -281
  155. package/lib/template.js +791 -791
  156. package/lib/testing.js +798 -798
  157. package/lib/time.js +310 -310
  158. package/lib/totp.js +302 -302
  159. package/lib/tracing.js +494 -494
  160. package/lib/uuid.js +132 -132
  161. package/lib/validate-opts.js +340 -340
  162. package/lib/vault/index.js +308 -308
  163. package/lib/vault/rotate.js +784 -784
  164. package/lib/vault/wrap.js +296 -296
  165. package/lib/vendor/noble-ciphers.cjs +9 -9
  166. package/lib/webhook.js +595 -595
  167. package/lib/websocket.js +1048 -1048
  168. package/package.json +77 -77
  169. package/sbom.cyclonedx.json +7 -7
@@ -1,1195 +1,1195 @@
1
- "use strict";
2
- /**
3
- * HTTP client primitive — Promise-returning, AbortSignal-aware,
4
- * connection-pooled, streaming-capable, HTTP/2-capable.
5
- *
6
- * Built on node:http, node:https, and node:http2. Zero npm runtime
7
- * dependency. Same caller surface for h1 and h2; the protocol version
8
- * is negotiated per-origin via ALPN (h2 preferred, h1 fallback).
9
- *
10
- * Single entry point:
11
- *
12
- * await httpClient.request({
13
- * method, // string, default GET
14
- * url, // string or URL
15
- * headers, // object, default {}
16
- * body, // Buffer | string | Readable | undefined
17
- * timeoutMs, // wall-clock cap (caller-chosen, no default)
18
- * idleTimeoutMs, // zero-progress idle cap (default 30s)
19
- * responseMode, // "buffer" (default) | "stream"
20
- * maxResponseBytes, // for buffer mode (default 16 MiB control,
21
- * // 1 GiB GET — operators with > 1 GiB
22
- * // stored objects must use stream mode)
23
- * signal, // AbortSignal — propagated to req/stream
24
- * errorClass, // FrameworkError subclass
25
- * observer, // optional (stage, info) => void hook
26
- * agent, // override per-origin pool (h1 only)
27
- * preferH2, // bool — for cleartext h2 (h2c). HTTPS origins
28
- * // already attempt h2 via ALPN; this flag is
29
- * // for HTTP origins (internal services, tests)
30
- * // that explicitly speak h2c.
31
- * })
32
- * → { statusCode, headers, body }
33
- *
34
- * Protocol selection:
35
- *
36
- * - HTTPS origin: TLS handshake with ALPN ['h2', 'http/1.1']. If
37
- * server picks 'h2', subsequent requests to that origin multiplex
38
- * over the same h2 session. If server picks 'h1', the cached
39
- * transport is an https.Agent with keepAlive.
40
- *
41
- * - HTTP origin without preferH2: h1 only.
42
- * - HTTP origin with preferH2: h2c (cleartext h2). No ALPN — caller
43
- * attests the server speaks h2c. Used by internal services and
44
- * test fixtures (mock h2 server).
45
- *
46
- * Per-origin transport cache:
47
- *
48
- * key = "<protocol>//<hostname>:<port>"
49
- * value = { kind: 'h1', lib, agent } | { kind: 'h2', session }
50
- *
51
- * While a transport is being negotiated (TLS handshake / h2 connect)
52
- * the cache holds the in-flight Promise so concurrent calls to a
53
- * new origin coalesce onto the same connection.
54
- *
55
- * Resiliency:
56
- * - Wall-clock + idle timeouts (split — slow-progress vs zero-progress)
57
- * - AbortSignal propagated to req.destroy / stream.close
58
- * - TLS 1.3 minimum + PQC ecdhCurve preference
59
- * - h2 session GOAWAY / error → cache eviction; next request reconnects
60
- * - h2 stream cancellation via NGHTTP2_CANCEL on abort (clean, not destroy)
61
- * - Request-body stream errors propagated to Promise rejection
62
- */
63
-
64
- var http = require("http");
65
- var https = require("https");
66
- var http2 = require("http2");
67
- var nodeStream = require("node:stream");
68
- var { URL } = require("url");
69
- var C = require("./constants");
70
- var crypto = require("./crypto");
71
- var pqcAgent = require("./pqc-agent");
72
- var safeAsync = require("./safe-async");
73
- var safeBuffer = require("./safe-buffer");
74
- var safeUrl = require("./safe-url");
75
- var ssrfGuard = require("./ssrf-guard");
76
- var networkProxy = require("./network-proxy");
77
- var { FrameworkError } = require("./framework-error");
78
-
79
- // Per-origin transport cache. Entry is either the resolved transport
80
- // object or a pending Promise that resolves to one. The Promise form
81
- // lets concurrent calls to a new origin coalesce on the same connect.
82
- //
83
- // Transport shapes the cache currently holds:
84
- //
85
- // { kind: "h1", lib, agent } — node:http(s) + keepAlive Agent
86
- // { kind: "h2", session } — node:http2 ClientHttp2Session
87
- //
88
- // Reserved for the future (when node:http3 ships stable — currently
89
- // behind --experimental-quic, no http3 module yet):
90
- //
91
- // { kind: "h3", session } — node:http3 ClientHttp3Session
92
- //
93
- // Adding the h3 case won't change the caller-facing surface: ALPN
94
- // negotiation gains "h3" as the highest-preference protocol over QUIC,
95
- // _getTransport branches on the resolved transport, and a new
96
- // _requestH3 mirrors _requestH2's stream-based shape. h3's design
97
- // gives 0-RTT first-class (vs. h1/h2 where 0-RTT is opaque under
98
- // node's TLS layer — see TLS_SESSION_RESUMPTION_NOTES below).
99
- var _transports = new Map();
100
-
101
- // TLS session resumption notes — what's automatic vs. what's not
102
- // exposed by Node's public API:
103
- //
104
- // keepAlive Agent (h1) / long-lived ClientHttp2Session (h2) means
105
- // the WARM-CONNECTION case is zero-handshake — better than 0-RTT.
106
- // We pay the TLS handshake once per origin, then amortize.
107
- //
108
- // When a pool socket is recycled, node's tls layer caches session
109
- // tickets and does 1-RTT resumption automatically. We don't
110
- // expose 0-RTT (early_data) — node's https.Agent has no clean API
111
- // for it, and 0-RTT is REPLAY-RISKY for non-idempotent requests
112
- // (server can't distinguish original from replay until the
113
- // handshake completes). Operators who need 0-RTT for a specific
114
- // idempotent path can pass their own agent via opts.agent.
115
- //
116
- // QUIC/h3 changes this calculus: 0-RTT is a first-class feature
117
- // built into the protocol, with replay protection at the QUIC
118
- // layer. The framework's `b.httpClient` is HTTP/1.1 + HTTP/2 only;
119
- // operators wanting h3 wire their own client.
120
-
121
- // Pool tuning for the HTTP-client transport cache. Keep-alive is
122
- // shorter than the standalone pqc-agent default (1s vs 30s) because
123
- // the cache layer manages its own warm-connection reuse and we want
124
- // idle sockets reaped quickly between bursts. ecdhCurve / minVersion
125
- // come from pqc-agent and cannot be set here — the framework's
126
- // PQC-only TLS posture is one place, in lib/pqc-agent.js.
127
- //
128
- // Operators tune at boot via `b.httpClient.configurePool({...})`.
129
- // Existing transports stay on whichever values were active when they
130
- // were created — reconfigure runs before any outbound request to take
131
- // effect on the per-origin cache.
132
- var DEFAULT_AGENT_OPTS = Object.freeze({
133
- keepAlive: true,
134
- keepAliveMsecs: C.TIME.seconds(1),
135
- maxSockets: C.BYTES.bytes(16),
136
- maxFreeSockets: C.BYTES.bytes(8),
137
- scheduling: "lifo",
138
- });
139
-
140
- var HTTP_CLIENT_AGENT_OPTS = Object.assign({}, DEFAULT_AGENT_OPTS);
141
-
142
- function configurePool(opts) {
143
- if (!opts || typeof opts !== "object") {
144
- throw new Error("httpClient.configurePool: opts must be an object");
145
- }
146
- var allowed = ["keepAlive", "keepAliveMsecs", "maxSockets", "maxFreeSockets", "scheduling"];
147
- for (var k in opts) {
148
- if (!Object.prototype.hasOwnProperty.call(opts, k)) continue;
149
- if (allowed.indexOf(k) === -1) {
150
- throw new Error("httpClient.configurePool: unknown option '" + k +
151
- "'. Allowed: " + allowed.join(", "));
152
- }
153
- }
154
- function _requirePositiveInt(name, value) {
155
- if (typeof value !== "number" || !isFinite(value) || value <= 0 || Math.floor(value) !== value) {
156
- throw new Error("httpClient.configurePool: " + name +
157
- " must be a positive integer, got " + JSON.stringify(value));
158
- }
159
- }
160
- if (opts.maxSockets !== undefined) _requirePositiveInt("maxSockets", opts.maxSockets);
161
- if (opts.maxFreeSockets !== undefined) _requirePositiveInt("maxFreeSockets", opts.maxFreeSockets);
162
- if (opts.keepAliveMsecs !== undefined) _requirePositiveInt("keepAliveMsecs", opts.keepAliveMsecs);
163
- if (opts.keepAlive !== undefined && typeof opts.keepAlive !== "boolean") {
164
- throw new Error("httpClient.configurePool: keepAlive must be a boolean");
165
- }
166
- if (opts.scheduling !== undefined && opts.scheduling !== "lifo" && opts.scheduling !== "fifo") {
167
- throw new Error("httpClient.configurePool: scheduling must be 'lifo' or 'fifo'");
168
- }
169
- Object.assign(HTTP_CLIENT_AGENT_OPTS, opts);
170
- // Existing transports keep their old values (Agent constructor
171
- // copies). Drop the per-origin cache + tear down idle sockets so
172
- // subsequent requests build fresh transports with the new opts.
173
- _transports.forEach(function (t) {
174
- if (t && t.kind === "h1" && t.agent && typeof t.agent.destroy === "function") {
175
- try { t.agent.destroy(); } catch (_e) { /* best-effort agent teardown */ }
176
- }
177
- if (t && t.kind === "h2" && t.session) {
178
- _tearDownH2Session(t.session);
179
- }
180
- });
181
- _transports.clear();
182
- }
183
-
184
- // HTTP/2 session teardown — see lib/http2-teardown.js for the full
185
- // rationale. Centralised so any future sink / pool teardown gets the
186
- // same close()-then-destroy() discipline.
187
- var _tearDownH2Session = require("./http2-teardown").tearDownH2Session;
188
-
189
- // h2 session connect options. Same TLS posture as h1 Agent.
190
- var DEFAULT_H2_TLS_OPTS = {
191
- ALPNProtocols: ["h2", "http/1.1"],
192
- ecdhCurve: C.TLS_GROUP_CURVE_STR,
193
- minVersion: "TLSv1.3",
194
- };
195
-
196
- var DEFAULT_CONTROL_PLANE_CAP = C.BYTES.mib(16);
197
- var DEFAULT_GET_CAP = C.BYTES.gib(1);
198
- var DEFAULT_IDLE_TIMEOUT_MS = C.TIME.seconds(30);
199
-
200
- // h2 session idle close. After this much idle time with no streams,
201
- // close the session — long-running processes don't pin one TLS
202
- // connection forever.
203
- var H2_SESSION_IDLE_TIMEOUT_MS = C.TIME.minutes(5);
204
-
205
- // IANA-assigned default ports per RFC 9110 §4.2.
206
- var DEFAULT_HTTPS_PORT = 443;
207
- var DEFAULT_HTTP_PORT = C.BYTES.bytes(80);
208
-
209
- function _defaultPortFor(u) {
210
- return u.protocol === "https:" ? DEFAULT_HTTPS_PORT : DEFAULT_HTTP_PORT;
211
- }
212
-
213
- function _originKey(u) {
214
- return u.protocol + "//" + u.hostname + ":" + (u.port || _defaultPortFor(u));
215
- }
216
-
217
- function _makeH1Transport(u, ips) {
218
- var lib = u.protocol === "https:" ? https : http;
219
- // HTTPS path goes through pqcAgent.create so the framework's PQC-only
220
- // posture is enforced via the single primitive. Cleartext HTTP stays
221
- // on http.Agent because there's no TLS posture to enforce.
222
- var agent = u.protocol === "https:"
223
- ? pqcAgent.create(HTTP_CLIENT_AGENT_OPTS)
224
- : new lib.Agent(HTTP_CLIENT_AGENT_OPTS);
225
- return { kind: "h1", lib: lib, agent: agent, lookup: _pinnedLookupFor(ips) };
226
- }
227
-
228
- // Build a `lookup` callback that pins outbound connections to IPs the
229
- // SSRF guard already validated. Closes the TOCTOU window between
230
- // SSRF resolution and the kernel's connect — without this, a hostile
231
- // (or compromised) DNS could rotate the answer between guard-check
232
- // and connect-time and route the request to a private / metadata IP
233
- // that bypassed the gate. ips comes from `ssrfGuard.checkUrl` — its
234
- // classification ran on these exact addresses.
235
- function _pinnedLookupFor(ips) {
236
- if (!Array.isArray(ips) || ips.length === 0) return undefined;
237
- var families = ips.map(function (i) { return { address: i.address, family: i.family || 4 }; });
238
- return function pinnedLookup(hostname, options, callback) {
239
- if (typeof options === "function") { callback = options; options = {}; }
240
- options = options || {};
241
- if (options.all) {
242
- callback(null, families);
243
- } else {
244
- callback(null, families[0].address, families[0].family);
245
- }
246
- };
247
- }
248
-
249
- // Connect an h2 session to an HTTPS origin via ALPN. If the server picks
250
- // http/1.1, fall back to an h1 transport for that origin.
251
- function _connectHttpsWithAlpn(u, ips) {
252
- return new Promise(function (resolve, reject) {
253
- var connectOpts = Object.assign({}, DEFAULT_H2_TLS_OPTS);
254
- var pinned = _pinnedLookupFor(ips);
255
- if (pinned) connectOpts.lookup = pinned;
256
- var session = http2.connect(u.protocol + "//" + u.host, connectOpts);
257
- var settled = false;
258
- function _done(t) { if (!settled) { settled = true; resolve(t); } }
259
- function _fail(err) { if (!settled) { settled = true; reject(err); } }
260
-
261
- session.once("connect", function () {
262
- var alpn = session.alpnProtocol;
263
- if (alpn === "h2") {
264
- _wireH2Session(session, _originKey(u));
265
- _done({ kind: "h2", session: session });
266
- return;
267
- }
268
- // Server picked http/1.1 — close the h2 session, return h1 transport.
269
- _tearDownH2Session(session);
270
- _done(_makeH1Transport(u, ips));
271
- });
272
- session.once("error", function (err) {
273
- _tearDownH2Session(session);
274
- _fail(err);
275
- });
276
- });
277
- }
278
-
279
- // Connect an h2c session (cleartext h2). No ALPN, no fallback — caller
280
- // has attested via preferH2 that the server speaks h2c.
281
- function _connectH2c(u, ips) {
282
- return new Promise(function (resolve, reject) {
283
- var connectOpts = {};
284
- var pinned = _pinnedLookupFor(ips);
285
- if (pinned) connectOpts.lookup = pinned;
286
- var session = http2.connect(u.protocol + "//" + u.host, connectOpts);
287
- session.once("connect", function () {
288
- _wireH2Session(session, _originKey(u));
289
- resolve({ kind: "h2", session: session });
290
- });
291
- session.once("error", function (err) {
292
- _tearDownH2Session(session);
293
- reject(err);
294
- });
295
- });
296
- }
297
-
298
- // Common h2 session wiring — idle close + cache eviction on error/close.
299
- function _wireH2Session(session, key) {
300
- session.setTimeout(H2_SESSION_IDLE_TIMEOUT_MS, function () {
301
- _tearDownH2Session(session);
302
- });
303
- session.once("close", function () { _transports.delete(key); });
304
- session.once("error", function () { _transports.delete(key); });
305
- session.once("goaway", function () {
306
- // Server signalling 'no new streams' — let in-flight finish, evict cache.
307
- _transports.delete(key);
308
- });
309
- }
310
-
311
- // Async transport selection. Returns Promise<transport>. `ips` is the
312
- // validated address list returned by `ssrfGuard.checkUrl`; the transport
313
- // uses it to pin connections so a hostile DNS rebind can't redirect
314
- // the actual TCP connect to a private / metadata IP.
315
- function _getTransport(u, opts, ips) {
316
- var key = _originKey(u);
317
- var cached = _transports.get(key);
318
- if (cached) {
319
- // Could be a resolved transport OR a pending Promise. Cached
320
- // transports keep whatever IP pinning was set when they were
321
- // first created — subsequent SSRF checks still gate the request,
322
- // and the transport's TCP socket is bound to its original IP.
323
- return Promise.resolve(cached);
324
- }
325
-
326
- var promise;
327
- if (u.protocol === "https:") {
328
- promise = _connectHttpsWithAlpn(u, ips);
329
- } else if (opts && opts.preferH2) {
330
- promise = _connectH2c(u, ips);
331
- } else {
332
- // HTTP without preferH2 → h1 only.
333
- promise = Promise.resolve(_makeH1Transport(u, ips));
334
- }
335
-
336
- // Cache the in-flight Promise immediately so concurrent calls
337
- // coalesce. On resolve, replace with the transport. On reject, evict.
338
- _transports.set(key, promise);
339
- promise.then(
340
- function (t) { _transports.set(key, t); },
341
- function (_err) { _transports.delete(key); }
342
- );
343
-
344
- return promise;
345
- }
346
-
347
- function _makeError(errorClass, code, message, permanent, statusCode) {
348
- if (!errorClass) return new FrameworkError(message, code);
349
- return new errorClass(code, message, permanent, statusCode);
350
- }
351
-
352
- // RFC 9110 §15.5 4xx codes that are NOT permanent (request-timeout,
353
- // too-early, too-many-requests — operator should retry).
354
- var STATUS_REQUEST_TIMEOUT = C.BYTES.bytes(408);
355
- var STATUS_TOO_EARLY = 425;
356
- var STATUS_TOO_MANY_REQUESTS = 429;
357
-
358
- function _isPermanentStatus(statusCode) {
359
- if (statusCode >= 400 && statusCode < 500) {
360
- return statusCode !== STATUS_REQUEST_TIMEOUT &&
361
- statusCode !== STATUS_TOO_EARLY &&
362
- statusCode !== STATUS_TOO_MANY_REQUESTS;
363
- }
364
- return false;
365
- }
366
-
367
- // h2 sends headers as lowercased keys plus :method / :path / :scheme /
368
- // :authority pseudo-headers. Convert from h1-shaped headers.
369
- function _toH2Headers(method, u, headers) {
370
- var h2Headers = Object.create(null);
371
- h2Headers[":method"] = method;
372
- h2Headers[":path"] = u.pathname + (u.search || "");
373
- h2Headers[":scheme"] = u.protocol === "https:" ? "https" : "http";
374
- h2Headers[":authority"] = u.host;
375
- for (var k in headers) {
376
- if (!Object.prototype.hasOwnProperty.call(headers, k)) continue;
377
- var lk = k.toLowerCase();
378
- // h2 forbids the connection-specific headers
379
- if (lk === "connection" || lk === "host" ||
380
- lk === "keep-alive" || lk === "transfer-encoding" ||
381
- lk === "upgrade" || lk === "proxy-connection") continue;
382
- h2Headers[lk] = headers[k];
383
- }
384
- return h2Headers;
385
- }
386
-
387
- function _fromH2Headers(h2Headers) {
388
- // Strip pseudo-headers from the response — caller doesn't want them
389
- // mixed with normal headers.
390
- var out = {};
391
- for (var k in h2Headers) {
392
- if (!Object.prototype.hasOwnProperty.call(h2Headers, k)) continue;
393
- if (k.charAt(0) === ":") continue;
394
- out[k] = h2Headers[k];
395
- }
396
- return out;
397
- }
398
-
399
- // ---- request() ----
400
-
401
- var REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
402
-
403
- // http-client-cookie-jar is exposed on b.httpClient.cookieJar via index.js.
404
- // http-client itself only consumes the jar shape passed by operators via
405
- // the request opt; no direct require is needed here.
406
-
407
- // Merge a jar-derived Cookie header with any caller-supplied Cookie
408
- // header. Operators who override Cookie explicitly always win — the jar
409
- // supplements rather than replaces.
410
- function _attachJarCookie(headers, jar, url) {
411
- if (!jar) return headers;
412
- var jarHeader = jar.cookieHeaderFor(url);
413
- if (!jarHeader) return headers;
414
- var merged = Object.assign({}, headers || {});
415
- var existing = null;
416
- var keys = Object.keys(merged);
417
- for (var i = 0; i < keys.length; i++) {
418
- if (keys[i].toLowerCase() === "cookie") { existing = keys[i]; break; }
419
- }
420
- if (existing) merged[existing] = merged[existing] + "; " + jarHeader;
421
- else merged.Cookie = jarHeader;
422
- return merged;
423
- }
424
-
425
- // Build a multipart/form-data body from { fields, files } shape.
426
- // Mirrors the wire format that lib/middleware/body-parser.js's multipart
427
- // parser accepts so round-trip from one blamejs app's outbound to
428
- // another's inbound is exact.
429
- function _buildMultipartBody(spec) {
430
- var boundary = "----blamejs-mp-" + crypto.generateToken(C.BYTES.bytes(16));
431
- var CRLF = "\r\n";
432
- var parts = [];
433
-
434
- function _pushField(name, value) {
435
- if (typeof name !== "string" || name.length === 0) {
436
- throw new Error("multipart: field name must be a non-empty string");
437
- }
438
- var head = "--" + boundary + CRLF +
439
- 'Content-Disposition: form-data; name="' + name + '"' + CRLF + CRLF;
440
- parts.push(Buffer.from(head, "utf8"));
441
- parts.push(Buffer.isBuffer(value) ? value : Buffer.from(String(value), "utf8"));
442
- parts.push(Buffer.from(CRLF, "utf8"));
443
- }
444
-
445
- function _pushFile(file) {
446
- if (!file || typeof file !== "object") throw new Error("multipart: file entries must be objects");
447
- if (typeof file.field !== "string" || file.field.length === 0) {
448
- throw new Error("multipart: file.field must be a non-empty string");
449
- }
450
- var filename = typeof file.filename === "string" && file.filename.length > 0
451
- ? file.filename : "blob";
452
- var mimeType = file.contentType || file.mimeType || "application/octet-stream";
453
- var content = file.content;
454
- if (typeof content === "string") content = Buffer.from(content, "utf8");
455
- if (!Buffer.isBuffer(content)) {
456
- throw new Error("multipart: file.content must be a Buffer or string");
457
- }
458
- var head = "--" + boundary + CRLF +
459
- 'Content-Disposition: form-data; name="' + file.field + '"' +
460
- '; filename="' + filename.replace(/"/g, "%22") + '"' + CRLF +
461
- "Content-Type: " + mimeType + CRLF + CRLF;
462
- parts.push(Buffer.from(head, "utf8"));
463
- parts.push(content);
464
- parts.push(Buffer.from(CRLF, "utf8"));
465
- }
466
-
467
- if (spec && spec.fields && typeof spec.fields === "object") {
468
- var keys = Object.keys(spec.fields);
469
- for (var i = 0; i < keys.length; i++) {
470
- var k = keys[i];
471
- var v = spec.fields[k];
472
- if (Array.isArray(v)) {
473
- for (var j = 0; j < v.length; j++) _pushField(k, v[j]);
474
- } else {
475
- _pushField(k, v);
476
- }
477
- }
478
- }
479
- if (spec && Array.isArray(spec.files)) {
480
- for (var fi = 0; fi < spec.files.length; fi++) _pushFile(spec.files[fi]);
481
- }
482
- parts.push(Buffer.from("--" + boundary + "--" + CRLF, "utf8"));
483
- return { boundary: boundary, body: Buffer.concat(parts) };
484
- }
485
-
486
- // Headers stripped on cross-origin redirect to defend against accidental
487
- // credential exfiltration. Lower-case for header-map comparison.
488
- var SENSITIVE_HEADERS_LC = ["authorization", "cookie", "proxy-authorization"];
489
-
490
- function _stripCrossOriginAuth(headers) {
491
- var out = {};
492
- var keys = Object.keys(headers);
493
- for (var i = 0; i < keys.length; i++) {
494
- if (SENSITIVE_HEADERS_LC.indexOf(keys[i].toLowerCase()) !== -1) continue;
495
- out[keys[i]] = headers[keys[i]];
496
- }
497
- return out;
498
- }
499
-
500
- function request(opts) {
501
- if (!opts || !opts.url) {
502
- return Promise.reject(_makeError(opts && opts.errorClass, "BAD_ARG", "url is required", true));
503
- }
504
-
505
- // Validate before/after shapes early — throw at call site if the
506
- // operator passed something un-callable so the bug surfaces here
507
- // rather than inside the request loop.
508
- if (opts.before !== undefined) {
509
- if (!Array.isArray(opts.before) || !opts.before.every(function (f) { return typeof f === "function"; })) {
510
- return Promise.reject(_makeError(opts.errorClass, "BAD_ARG",
511
- "before must be an array of functions", true));
512
- }
513
- }
514
- if (opts.after !== undefined) {
515
- if (!Array.isArray(opts.after) || !opts.after.every(function (f) { return typeof f === "function"; })) {
516
- return Promise.reject(_makeError(opts.errorClass, "BAD_ARG",
517
- "after must be an array of functions", true));
518
- }
519
- }
520
- if (opts.onUploadProgress !== undefined && typeof opts.onUploadProgress !== "function") {
521
- return Promise.reject(_makeError(opts.errorClass, "BAD_ARG",
522
- "onUploadProgress must be a function", true));
523
- }
524
- if (opts.onDownloadProgress !== undefined && typeof opts.onDownloadProgress !== "function") {
525
- return Promise.reject(_makeError(opts.errorClass, "BAD_ARG",
526
- "onDownloadProgress must be a function", true));
527
- }
528
- if (opts.jar !== undefined && opts.jar !== null) {
529
- if (typeof opts.jar !== "object" ||
530
- typeof opts.jar.cookieHeaderFor !== "function" ||
531
- typeof opts.jar.setFromResponse !== "function") {
532
- return Promise.reject(_makeError(opts.errorClass, "BAD_ARG",
533
- "jar must be a b.httpClient.cookieJar.create() instance", true));
534
- }
535
- }
536
-
537
- // before interceptors — run in array order. Each may return a modified
538
- // opts object (or return nothing to leave the running opts as-is).
539
- // Caller-set defaults / observability / auth header injection lands
540
- // here. Synchronous to keep the request hot path simple; async
541
- // pre-flight work (e.g. token refresh) belongs in the route handler
542
- // before httpClient.request is even called.
543
- if (Array.isArray(opts.before) && opts.before.length > 0) {
544
- var working = opts;
545
- for (var bi = 0; bi < opts.before.length; bi++) {
546
- var ret;
547
- try { ret = opts.before[bi](working); }
548
- catch (e) {
549
- return Promise.reject(_makeError(opts.errorClass, "BEFORE_THREW",
550
- "before[" + bi + "] threw: " + ((e && e.message) || String(e)), true));
551
- }
552
- if (ret && typeof ret === "object") working = ret;
553
- }
554
- opts = working;
555
- }
556
-
557
- // Multipart shorthand: { multipart: { fields, files } } expands to
558
- // body + Content-Type with the boundary parameter. Mutually exclusive
559
- // with caller-supplied body / Content-Type.
560
- if (opts.multipart) {
561
- if (opts.body !== undefined) {
562
- return Promise.reject(_makeError(opts.errorClass, "BAD_ARG",
563
- "request: pass either { body } or { multipart }, not both", true));
564
- }
565
- var built;
566
- try { built = _buildMultipartBody(opts.multipart); }
567
- catch (e) {
568
- return Promise.reject(_makeError(opts.errorClass, "BAD_ARG", e.message, true));
569
- }
570
- opts = Object.assign({}, opts, {
571
- method: opts.method || "POST",
572
- body: built.body,
573
- headers: Object.assign({}, opts.headers || {}, {
574
- "Content-Type": "multipart/form-data; boundary=" + built.boundary,
575
- "Content-Length": String(built.body.length),
576
- }),
577
- multipart: undefined,
578
- });
579
- }
580
-
581
- // maxRedirects:
582
- // undefined → today's behavior (no follow). Caller inspects 3xx.
583
- // null → today's behavior (explicit). Same as undefined.
584
- // 0 → no follow, but 3xx returned to caller (alias of null).
585
- // N → follow up to N hops; 3xx with no Location returned as-is.
586
- var maxRedirects = (opts.maxRedirects === undefined || opts.maxRedirects === null)
587
- ? null : opts.maxRedirects;
588
- if (maxRedirects !== null) {
589
- if (typeof maxRedirects !== "number" || !isFinite(maxRedirects) || maxRedirects < 0 ||
590
- Math.floor(maxRedirects) !== maxRedirects) {
591
- return Promise.reject(_makeError(opts.errorClass, "BAD_ARG",
592
- "maxRedirects must be a non-negative integer or null", true));
593
- }
594
- }
595
- var afterChain = (Array.isArray(opts.after) && opts.after.length > 0) ? opts.after : null;
596
- function _runAfter(finalOpts, res) {
597
- if (!afterChain) return res;
598
- for (var ai = 0; ai < afterChain.length; ai++) {
599
- try { afterChain[ai](finalOpts, res); }
600
- catch (_e) { /* after hooks are best-effort — never break the response */ }
601
- }
602
- return res;
603
- }
604
-
605
- if (maxRedirects === null || maxRedirects === 0) {
606
- return _requestSingle(opts).then(function (res) { return _runAfter(opts, res); });
607
- }
608
-
609
- return _requestWithRedirects(opts, maxRedirects).then(function (boxed) {
610
- return _runAfter(boxed.finalOpts, boxed.res);
611
- });
612
- }
613
-
614
- function _requestWithRedirects(opts, hopsLeft) {
615
- var originalUrl = opts.url;
616
- var originalOrigin = null;
617
- try {
618
- var u0 = safeUrl.parse(opts.url, { allowedProtocols: safeUrl.ALLOW_HTTP_ALL });
619
- originalOrigin = u0.protocol + "//" + u0.host;
620
- } catch (_e) { /* request() will reject on next hop's parse */ }
621
-
622
- var current = Object.assign({}, opts, { _resolveOnRedirect: true });
623
- function _follow() {
624
- return _requestSingle(current).then(function (res) {
625
- if (!REDIRECT_STATUSES.has(res.statusCode) || hopsLeft <= 0) {
626
- return { finalOpts: current, res: res };
627
- }
628
- var loc = res.headers && (res.headers.location || res.headers.Location);
629
- if (!loc) return { finalOpts: current, res: res }; // 3xx with no Location — operator handles
630
- hopsLeft -= 1;
631
-
632
- // Resolve relative Location against the just-fetched URL (the URL
633
- // of the request that produced the redirect, which may itself be a
634
- // post-redirect URL).
635
- var nextUrl;
636
- try {
637
- // Resolve relative Location against current URL using Node's URL
638
- // base-URL form. Re-validate the resolved absolute through safeUrl
639
- // immediately below.
640
- nextUrl = Reflect.construct(URL, [loc, current.url]).toString();
641
- }
642
- catch (_e) {
643
- return Promise.reject(_makeError(opts.errorClass, "BAD_REDIRECT",
644
- "Location header invalid URL: " + loc, true));
645
- }
646
-
647
- // Cross-origin auth-header strip.
648
- var nextHeaders = current.headers || {};
649
- var nextOrigin;
650
- try {
651
- var nu = safeUrl.parse(nextUrl, { allowedProtocols: safeUrl.ALLOW_HTTP_ALL });
652
- nextOrigin = nu.protocol + "//" + nu.host;
653
- } catch (_e) { /* request() will reject when it tries to parse */ }
654
- if (originalOrigin && nextOrigin && nextOrigin !== originalOrigin) {
655
- nextHeaders = _stripCrossOriginAuth(nextHeaders);
656
- }
657
-
658
- // 303 → always GET; body dropped. 301/302 → historical clients
659
- // also coerce non-GET bodies (we follow that convention). 307/308
660
- // → preserve method + body.
661
- var nextMethod = current.method || "GET";
662
- var nextBody = current.body;
663
- if (res.statusCode === 303 ||
664
- ((res.statusCode === 301 || res.statusCode === 302) &&
665
- nextMethod !== "GET" && nextMethod !== "HEAD")) {
666
- nextMethod = "GET";
667
- nextBody = undefined;
668
- }
669
-
670
- current = Object.assign({}, current, {
671
- url: nextUrl,
672
- method: nextMethod,
673
- body: nextBody,
674
- headers: nextHeaders,
675
- _resolveOnRedirect: true,
676
- });
677
- return _follow();
678
- });
679
- }
680
- void originalUrl;
681
- return _follow();
682
- }
683
-
684
- function _requestSingle(opts) {
685
- // Validate scheme + shape via url-safe. Default is HTTPS-only — the
686
- // framework refuses to silently drop bytes on the wire as cleartext.
687
- // Callers with cleartext endpoints (h2c, internal services, test
688
- // fixtures) explicitly opt in via opts.allowedProtocols
689
- // (safeUrl.ALLOW_HTTP_ALL accepts both http: and https:).
690
- var u;
691
- try {
692
- u = safeUrl.parse(opts.url, {
693
- allowedProtocols: opts.allowedProtocols || safeUrl.ALLOW_HTTP_TLS,
694
- errorClass: opts.errorClass,
695
- });
696
- } catch (e) {
697
- return Promise.reject(e);
698
- }
699
-
700
- // Optional outbound destination allowlist. When opts.allowedHosts
701
- // is set, only URLs whose hostname is on the list are permitted.
702
- // Layer above safeUrl (scheme/userinfo gate) and above ssrfGuard
703
- // (IP-class gate) — operators with strict egress policies pin the
704
- // outbound destinations the app is allowed to talk to so a
705
- // compromised process can't reach arbitrary upstreams.
706
- //
707
- // Entry forms (each entry is a string OR an object):
708
- // "api.partner.com" — exact host match
709
- // ".partner.com" — suffix match: "api.partner.com" yes,
710
- // "evilpartner.com" no
711
- // "*.partner.com" — same as ".partner.com" (DNS-glob shape
712
- // operators expect from firewall configs)
713
- // { host: "api.x.com", methods: ["GET","HEAD"] }
714
- // — method-restricted entry; methods omitted
715
- // = any method
716
- //
717
- // A disallowed call rejects with HOST_DISALLOWED AND emits an
718
- // audit event when opts.audit is wired (operator gets a structured
719
- // signal that the application tried to reach somewhere it shouldn't).
720
- if (Array.isArray(opts.allowedHosts) && opts.allowedHosts.length > 0) {
721
- var host = u.hostname.toLowerCase();
722
- var method = (opts.method || "GET").toUpperCase();
723
- var ok = false;
724
- for (var ai = 0; ai < opts.allowedHosts.length; ai++) {
725
- var entry = opts.allowedHosts[ai];
726
- var allow, allowedMethods = null;
727
- if (typeof entry === "object" && entry !== null) {
728
- allow = String(entry.host || "").toLowerCase();
729
- if (Array.isArray(entry.methods) && entry.methods.length > 0) {
730
- allowedMethods = entry.methods.map(function (m) { return String(m).toUpperCase(); });
731
- }
732
- } else {
733
- allow = String(entry || "").toLowerCase();
734
- }
735
- if (allow.length === 0) continue;
736
- // Normalise "*.x.com" to ".x.com" for the suffix match path.
737
- if (allow.charAt(0) === "*" && allow.charAt(1) === ".") allow = allow.slice(1);
738
- var matched = false;
739
- if (allow.charAt(0) === ".") {
740
- if (host === allow.slice(1) || host.endsWith(allow)) matched = true;
741
- } else if (host === allow) {
742
- matched = true;
743
- }
744
- if (!matched) continue;
745
- if (allowedMethods !== null && allowedMethods.indexOf(method) === -1) continue;
746
- ok = true;
747
- break;
748
- }
749
- if (!ok) {
750
- if (opts.audit && typeof opts.audit.safeEmit === "function") {
751
- try {
752
- opts.audit.safeEmit({
753
- action: "system.httpclient.host_denied",
754
- outcome: "denied",
755
- resource: { kind: "outbound.http", id: host },
756
- metadata: { method: method, url: opts.url, allowedHostsCount: opts.allowedHosts.length },
757
- });
758
- } catch (_e) { /* audit best-effort */ }
759
- }
760
- return Promise.reject(_makeError(opts.errorClass, "HOST_DISALLOWED",
761
- "host '" + host + "' not in allowedHosts (method=" + method + ")", true));
762
- }
763
- }
764
-
765
- // Attach jar-derived Cookie header BEFORE the request fires; record
766
- // Set-Cookie response headers AFTER. Both halves run when opts.jar
767
- // is set; redirect-following naturally re-runs both paths per hop
768
- // because each hop calls _requestSingle.
769
- if (opts.jar) {
770
- var headersWithJar = _attachJarCookie(opts.headers, opts.jar, opts.url);
771
- if (headersWithJar !== opts.headers) {
772
- opts = Object.assign({}, opts, { headers: headersWithJar });
773
- }
774
- }
775
-
776
- // SSRF gate — refuse private / loopback / link-local / cloud-metadata
777
- // / reserved IP destinations by default. The returned `ips` are
778
- // threaded into transport creation so the actual TCP connect pins
779
- // to those exact addresses, closing the DNS-rebinding TOCTOU window.
780
- return ssrfGuard.checkUrl(u, {
781
- allowInternal: opts.allowInternal,
782
- errorClass: opts.errorClass,
783
- }).then(function (ssrfResult) {
784
- var ips = ssrfResult && ssrfResult.ips;
785
- // Caller-supplied agent bypasses transport cache (h1 only). The
786
- // operator owns the agent's connection pool — we still pass the
787
- // pinned lookup through per-request so the SSRF check's IPs win.
788
- if (opts.agent) {
789
- return _requestH1({
790
- kind: "h1",
791
- lib: u.protocol === "https:" ? https : http,
792
- agent: opts.agent,
793
- lookup: _pinnedLookupFor(ips),
794
- }, u, opts);
795
- }
796
-
797
- var proxyAgent = null;
798
- try { proxyAgent = networkProxy.agentFor(u); } catch (_e) { proxyAgent = null; }
799
- if (proxyAgent) {
800
- return _requestH1({
801
- kind: "h1",
802
- lib: u.protocol === "https:" ? https : http,
803
- agent: proxyAgent,
804
- lookup: undefined,
805
- }, u, opts);
806
- }
807
-
808
- return _getTransport(u, opts, ips).then(function (transport) {
809
- if (transport.kind === "h2") return _requestH2(transport, u, opts);
810
- return _requestH1(transport, u, opts);
811
- });
812
- });
813
- }
814
-
815
- // ---- _requestH1: existing node:http(s) path ----
816
-
817
- function _requestH1(transport, u, opts) {
818
- return new Promise(function (resolve, reject) {
819
- var method = (opts.method || "GET").toUpperCase();
820
- var headers = Object.assign({}, opts.headers || {});
821
- var responseMode = opts.responseMode || "buffer";
822
- var maxResponseBytes = opts.maxResponseBytes ||
823
- (method === "GET" ? DEFAULT_GET_CAP : DEFAULT_CONTROL_PLANE_CAP);
824
- var observer = typeof opts.observer === "function" ? opts.observer : null;
825
- var startedAt = Date.now();
826
-
827
- var signal = safeAsync.withTimeoutSignal(opts.signal || null, opts.timeoutMs);
828
- if (signal && signal.aborted) {
829
- var r0 = signal.reason;
830
- var code0 = (r0 && r0.name === "TimeoutError") ? "ETIMEDOUT" : "ABORT";
831
- reject(_makeError(opts.errorClass, code0,
832
- (r0 && r0.message) || "request aborted before start", false));
833
- return;
834
- }
835
-
836
- if (Buffer.isBuffer(opts.body)) {
837
- headers["Content-Length"] = opts.body.length;
838
- }
839
-
840
- var reqOpts = {
841
- method: method,
842
- hostname: u.hostname,
843
- port: u.port || _defaultPortFor(u),
844
- path: u.pathname + (u.search || ""),
845
- headers: headers,
846
- agent: transport.agent,
847
- timeout: typeof opts.idleTimeoutMs === "number" ? opts.idleTimeoutMs : DEFAULT_IDLE_TIMEOUT_MS,
848
- };
849
- // Pin DNS to the IPs the SSRF guard validated. Closes the
850
- // rebinding TOCTOU between guard-check and actual TCP connect.
851
- if (transport.lookup) reqOpts.lookup = transport.lookup;
852
-
853
- if (observer) observer("request:start", { method: method, url: String(opts.url), protocol: "h1" });
854
-
855
- var settled = false;
856
- function _resolve(value) { if (!settled) { settled = true; resolve(value); } }
857
- function _reject(err) { if (!settled) { settled = true; reject(err); } }
858
-
859
- var onUploadProgress = typeof opts.onUploadProgress === "function" ? opts.onUploadProgress : null;
860
- var onDownloadProgress = typeof opts.onDownloadProgress === "function" ? opts.onDownloadProgress : null;
861
-
862
- var req = transport.lib.request(reqOpts, function (res) {
863
- if (observer) observer("response:headers", { statusCode: res.statusCode, headers: res.headers });
864
-
865
- // Save Set-Cookie into the jar (if wired) BEFORE delivering the
866
- // response object — operator inspecting the response can already
867
- // count on the jar carrying the new state.
868
- if (opts.jar && res.headers && res.headers["set-cookie"]) {
869
- try { opts.jar.setFromResponse(opts.url, res.headers["set-cookie"]); }
870
- catch (_e) { /* jar is best-effort — never break the response */ }
871
- }
872
-
873
- // Download total: Content-Length when present, null otherwise.
874
- var dlTotal = null;
875
- if (res.headers && typeof res.headers["content-length"] === "string") {
876
- var cl = parseInt(res.headers["content-length"], 10);
877
- if (!isNaN(cl) && cl >= 0) dlTotal = cl;
878
- }
879
- var dlLoaded = 0;
880
- function _emitDownload(chunkBytes) {
881
- if (!onDownloadProgress) return;
882
- dlLoaded += chunkBytes;
883
- try { onDownloadProgress({ loaded: dlLoaded, total: dlTotal }); }
884
- catch (_e) { /* progress hooks are best-effort */ }
885
- }
886
-
887
- if (responseMode === "stream") {
888
- if (res.statusCode >= 400) {
889
- res.resume();
890
- return _reject(_makeError(opts.errorClass, "HTTP_ERROR",
891
- "HTTP " + res.statusCode + " " + (res.statusMessage || ""),
892
- _isPermanentStatus(res.statusCode), res.statusCode));
893
- }
894
- if (onDownloadProgress) {
895
- // Wrap the stream so chunks emit progress to the operator.
896
- // The framework's contract is to hand back the response stream
897
- // unmodified; fix-up via a passthrough keeps that contract while
898
- // observing the chunk sizes.
899
- var passthrough = new nodeStream.PassThrough();
900
- res.on("data", function (chunk) { _emitDownload(chunk.length); passthrough.write(chunk); });
901
- res.on("end", function () { passthrough.end(); });
902
- res.on("error", function (e) { passthrough.destroy(e); });
903
- return _resolve({ statusCode: res.statusCode, headers: res.headers, body: passthrough });
904
- }
905
- return _resolve({ statusCode: res.statusCode, headers: res.headers, body: res });
906
- }
907
-
908
- var collector = safeBuffer.boundedChunkCollector({ maxBytes: maxResponseBytes });
909
- var capExceeded = false;
910
-
911
- res.on("data", function (chunk) {
912
- if (capExceeded) return;
913
- try { collector.push(chunk); }
914
- catch (_e) {
915
- capExceeded = true;
916
- req.destroy();
917
- _reject(_makeError(opts.errorClass, "RESPONSE_TOO_LARGE",
918
- "response body exceeds " + maxResponseBytes + " bytes", true));
919
- return;
920
- }
921
- _emitDownload(chunk.length);
922
- });
923
- res.on("end", function () {
924
- if (capExceeded) return;
925
- var buf = collector.result();
926
- if (observer) observer("response:end", {
927
- statusCode: res.statusCode,
928
- durationMs: Date.now() - startedAt,
929
- bytes: buf.length,
930
- });
931
- if (res.statusCode >= 200 && res.statusCode < 300) {
932
- _resolve({ statusCode: res.statusCode, headers: res.headers, body: buf });
933
- } else if (opts._resolveOnRedirect && REDIRECT_STATUSES.has(res.statusCode)) {
934
- // Redirect-following layer needs the response object intact so
935
- // it can inspect Location and re-issue. The caller-facing
936
- // request() never sets _resolveOnRedirect — operator code that
937
- // didn't ask for redirect-following keeps seeing 3xx as errors.
938
- _resolve({ statusCode: res.statusCode, headers: res.headers, body: buf });
939
- } else {
940
- var msg = "HTTP " + res.statusCode + ": " + buf.toString("utf8").slice(0, 500);
941
- _reject(_makeError(opts.errorClass, "HTTP_ERROR", msg,
942
- _isPermanentStatus(res.statusCode), res.statusCode));
943
- }
944
- });
945
- res.on("error", function (e) {
946
- if (capExceeded) return;
947
- if (observer) observer("error", { phase: "response", message: e.message });
948
- _reject(_makeError(opts.errorClass, e.code || "RES_ERROR", e.message, false));
949
- });
950
- });
951
-
952
- req.on("timeout", function () {
953
- req.destroy();
954
- _reject(_makeError(opts.errorClass, "ETIMEDOUT",
955
- "request idle timeout (no data for " + reqOpts.timeout + "ms)", false));
956
- });
957
-
958
- req.on("error", function (e) {
959
- if (observer) observer("error", { phase: "request", message: e.message });
960
- _reject(_makeError(opts.errorClass, e.code || "REQ_ERROR", e.message, false));
961
- });
962
-
963
- if (signal) {
964
- var onAbort = function () {
965
- var r = signal.reason;
966
- var code = (r && r.name === "TimeoutError") ? "ETIMEDOUT" : "ABORT";
967
- var msg = (r && r.message) || "request aborted";
968
- try { req.destroy(r || new Error(msg)); } catch (_e) { /* best-effort req teardown */ }
969
- _reject(_makeError(opts.errorClass, code, msg, false));
970
- };
971
- signal.addEventListener("abort", onAbort, { once: true });
972
- }
973
-
974
- // Upload progress: emit { loaded, total } as body bytes go to the
975
- // wire. Buffer / string bodies are sliced into chunks ourselves so
976
- // operators see incremental progress; Readable bodies emit on each
977
- // 'data' event from the source stream.
978
- var ulTotal = null;
979
- if (Buffer.isBuffer(opts.body)) ulTotal = opts.body.length;
980
- else if (typeof opts.body === "string") ulTotal = Buffer.byteLength(opts.body, "utf8");
981
- var ulLoaded = 0;
982
- function _emitUpload(chunkBytes) {
983
- if (!onUploadProgress) return;
984
- ulLoaded += chunkBytes;
985
- try { onUploadProgress({ loaded: ulLoaded, total: ulTotal }); }
986
- catch (_e) { /* progress hooks are best-effort */ }
987
- }
988
-
989
- if (opts.body && typeof opts.body.pipe === "function") {
990
- if (onUploadProgress) {
991
- opts.body.on("data", function (c) { _emitUpload(c.length); });
992
- }
993
- opts.body.on("error", function (e) {
994
- try { req.destroy(); } catch (_) { /* best-effort req teardown */ }
995
- _reject(_makeError(opts.errorClass, "REQ_BODY_ERROR",
996
- "request body stream error: " + e.message, false));
997
- });
998
- opts.body.pipe(req);
999
- } else if (Buffer.isBuffer(opts.body) || typeof opts.body === "string") {
1000
- var bodyBuf = Buffer.isBuffer(opts.body) ? opts.body : Buffer.from(opts.body, "utf8");
1001
- if (onUploadProgress) {
1002
- // Chunked write so progress reports land before req.end().
1003
- var CHUNK = C.BYTES.kib(64);
1004
- var off = 0;
1005
- while (off < bodyBuf.length) {
1006
- var slice = bodyBuf.slice(off, Math.min(off + CHUNK, bodyBuf.length));
1007
- req.write(slice);
1008
- _emitUpload(slice.length);
1009
- off += slice.length;
1010
- }
1011
- req.end();
1012
- } else {
1013
- req.end(bodyBuf);
1014
- }
1015
- } else {
1016
- req.end();
1017
- }
1018
- });
1019
- }
1020
-
1021
- // ---- _requestH2: node:http2 path ----
1022
-
1023
- function _requestH2(transport, u, opts) {
1024
- return new Promise(function (resolve, reject) {
1025
- var method = (opts.method || "GET").toUpperCase();
1026
- var responseMode = opts.responseMode || "buffer";
1027
- var maxResponseBytes = opts.maxResponseBytes ||
1028
- (method === "GET" ? DEFAULT_GET_CAP : DEFAULT_CONTROL_PLANE_CAP);
1029
- var observer = typeof opts.observer === "function" ? opts.observer : null;
1030
- var startedAt = Date.now();
1031
-
1032
- var signal = safeAsync.withTimeoutSignal(opts.signal || null, opts.timeoutMs);
1033
- if (signal && signal.aborted) {
1034
- var r0 = signal.reason;
1035
- var code0 = (r0 && r0.name === "TimeoutError") ? "ETIMEDOUT" : "ABORT";
1036
- reject(_makeError(opts.errorClass, code0,
1037
- (r0 && r0.message) || "request aborted before start", false));
1038
- return;
1039
- }
1040
-
1041
- var headers = _toH2Headers(method, u, opts.headers || {});
1042
- if (Buffer.isBuffer(opts.body)) headers["content-length"] = String(opts.body.length);
1043
-
1044
- if (observer) observer("request:start", { method: method, url: String(opts.url), protocol: "h2" });
1045
-
1046
- var stream;
1047
- try {
1048
- stream = transport.session.request(headers, {
1049
- endStream: opts.body == null,
1050
- });
1051
- } catch (e) {
1052
- reject(_makeError(opts.errorClass, e.code || "H2_REQUEST_ERROR", e.message, false));
1053
- return;
1054
- }
1055
-
1056
- var settled = false;
1057
- function _resolve(v) { if (!settled) { settled = true; resolve(v); } }
1058
- function _reject(e) { if (!settled) { settled = true; reject(e); } }
1059
-
1060
- // Idle timeout for the stream itself (zero-progress detector).
1061
- var idleMs = typeof opts.idleTimeoutMs === "number" ? opts.idleTimeoutMs : DEFAULT_IDLE_TIMEOUT_MS;
1062
- stream.setTimeout(idleMs, function () {
1063
- try { stream.close(http2.constants.NGHTTP2_CANCEL); } catch (_e) { /* best-effort h2 stream cancel */ }
1064
- _reject(_makeError(opts.errorClass, "ETIMEDOUT",
1065
- "h2 stream idle timeout (no data for " + idleMs + "ms)", false));
1066
- });
1067
-
1068
- stream.on("response", function (resHeaders) {
1069
- var statusCode = resHeaders[":status"];
1070
- var responseHeaders = _fromH2Headers(resHeaders);
1071
-
1072
- if (observer) observer("response:headers", { statusCode: statusCode, headers: responseHeaders });
1073
-
1074
- // Save Set-Cookie to the jar (h2 set-cookie comes through as
1075
- // either a single string or array, same shape as h1).
1076
- if (opts.jar && responseHeaders["set-cookie"]) {
1077
- try { opts.jar.setFromResponse(opts.url, responseHeaders["set-cookie"]); }
1078
- catch (_e) { /* jar best-effort */ }
1079
- }
1080
-
1081
- if (responseMode === "stream") {
1082
- if (statusCode >= 400) {
1083
- stream.resume();
1084
- return _reject(_makeError(opts.errorClass, "HTTP_ERROR",
1085
- "HTTP " + statusCode, _isPermanentStatus(statusCode), statusCode));
1086
- }
1087
- return _resolve({ statusCode: statusCode, headers: responseHeaders, body: stream });
1088
- }
1089
-
1090
- var collector = safeBuffer.boundedChunkCollector({ maxBytes: maxResponseBytes });
1091
- var capExceeded = false;
1092
-
1093
- stream.on("data", function (chunk) {
1094
- if (capExceeded) return;
1095
- try { collector.push(chunk); }
1096
- catch (_e) {
1097
- capExceeded = true;
1098
- try { stream.close(http2.constants.NGHTTP2_CANCEL); } catch (_e2) { /* best-effort h2 stream cancel */ }
1099
- _reject(_makeError(opts.errorClass, "RESPONSE_TOO_LARGE",
1100
- "response body exceeds " + maxResponseBytes + " bytes", true));
1101
- }
1102
- });
1103
- stream.on("end", function () {
1104
- if (capExceeded) return;
1105
- var buf = collector.result();
1106
- if (observer) observer("response:end", {
1107
- statusCode: statusCode,
1108
- durationMs: Date.now() - startedAt,
1109
- bytes: buf.length,
1110
- });
1111
- if (statusCode >= 200 && statusCode < 300) {
1112
- _resolve({ statusCode: statusCode, headers: responseHeaders, body: buf });
1113
- } else {
1114
- var msg = "HTTP " + statusCode + ": " + buf.toString("utf8").slice(0, 500);
1115
- _reject(_makeError(opts.errorClass, "HTTP_ERROR", msg,
1116
- _isPermanentStatus(statusCode), statusCode));
1117
- }
1118
- });
1119
- });
1120
-
1121
- stream.on("error", function (e) {
1122
- if (observer) observer("error", { phase: "stream", message: e.message });
1123
- _reject(_makeError(opts.errorClass, e.code || "H2_STREAM_ERROR", e.message, false));
1124
- });
1125
-
1126
- if (signal) {
1127
- var onAbort = function () {
1128
- var r = signal.reason;
1129
- var code = (r && r.name === "TimeoutError") ? "ETIMEDOUT" : "ABORT";
1130
- var msg = (r && r.message) || "request aborted";
1131
- // NGHTTP2_CANCEL is the protocol-level "I gave up" signal —
1132
- // cleaner than destroying the stream.
1133
- try { stream.close(http2.constants.NGHTTP2_CANCEL); } catch (_e) { /* best-effort h2 stream cancel */ }
1134
- _reject(_makeError(opts.errorClass, code, msg, false));
1135
- };
1136
- signal.addEventListener("abort", onAbort, { once: true });
1137
- }
1138
-
1139
- if (opts.body && typeof opts.body.pipe === "function") {
1140
- opts.body.on("error", function (e) {
1141
- try { stream.close(http2.constants.NGHTTP2_INTERNAL_ERROR); } catch (_) { /* best-effort h2 stream cancel */ }
1142
- _reject(_makeError(opts.errorClass, "REQ_BODY_ERROR",
1143
- "request body stream error: " + e.message, false));
1144
- });
1145
- opts.body.pipe(stream);
1146
- } else if (Buffer.isBuffer(opts.body)) {
1147
- stream.end(opts.body);
1148
- } else if (typeof opts.body === "string") {
1149
- stream.end(Buffer.from(opts.body, "utf8"));
1150
- }
1151
- // If body is null/undefined, endStream:true was set in session.request()
1152
- });
1153
- }
1154
-
1155
- // ---- Test helpers ----
1156
-
1157
- function _resetForTest() {
1158
- _transports.forEach(function (t) {
1159
- if (t && t.kind === "h1" && t.agent && typeof t.agent.destroy === "function") {
1160
- try { t.agent.destroy(); } catch (_e) { /* best-effort agent teardown */ }
1161
- }
1162
- if (t && t.kind === "h2" && t.session) {
1163
- _tearDownH2Session(t.session);
1164
- }
1165
- });
1166
- _transports.clear();
1167
- }
1168
-
1169
- function _getCachedTransportCount() {
1170
- return _transports.size;
1171
- }
1172
-
1173
- // Diagnostic — returns 'h1' | 'h2' | null for a given URL's cached transport.
1174
- function _getCachedTransportKind(url) {
1175
- var u = url instanceof URL ? url : safeUrl.parse(url, { allowedProtocols: safeUrl.ALLOW_HTTP_ALL });
1176
- var t = _transports.get(_originKey(u));
1177
- if (!t) return null;
1178
- if (t.then) return "pending";
1179
- return t.kind;
1180
- }
1181
-
1182
- module.exports = {
1183
- request: request,
1184
- configurePool: configurePool,
1185
- DEFAULT_CONTROL_PLANE_CAP: DEFAULT_CONTROL_PLANE_CAP,
1186
- DEFAULT_GET_CAP: DEFAULT_GET_CAP,
1187
- DEFAULT_AGENT_OPTS: DEFAULT_AGENT_OPTS,
1188
- _resetForTest: _resetForTest,
1189
- _getCachedTransportCount: _getCachedTransportCount,
1190
- _getCachedTransportKind: _getCachedTransportKind,
1191
- // Test-only — exposes the SSRF-pinned DNS lookup builder so unit
1192
- // tests can confirm the callback shape matches Node's documented
1193
- // `lookup(hostname, options, callback)` contract.
1194
- _pinnedLookupForTest: _pinnedLookupFor,
1195
- };
1
+ "use strict";
2
+ /**
3
+ * HTTP client primitive — Promise-returning, AbortSignal-aware,
4
+ * connection-pooled, streaming-capable, HTTP/2-capable.
5
+ *
6
+ * Built on node:http, node:https, and node:http2. Zero npm runtime
7
+ * dependency. Same caller surface for h1 and h2; the protocol version
8
+ * is negotiated per-origin via ALPN (h2 preferred, h1 fallback).
9
+ *
10
+ * Single entry point:
11
+ *
12
+ * await httpClient.request({
13
+ * method, // string, default GET
14
+ * url, // string or URL
15
+ * headers, // object, default {}
16
+ * body, // Buffer | string | Readable | undefined
17
+ * timeoutMs, // wall-clock cap (caller-chosen, no default)
18
+ * idleTimeoutMs, // zero-progress idle cap (default 30s)
19
+ * responseMode, // "buffer" (default) | "stream"
20
+ * maxResponseBytes, // for buffer mode (default 16 MiB control,
21
+ * // 1 GiB GET — operators with > 1 GiB
22
+ * // stored objects must use stream mode)
23
+ * signal, // AbortSignal — propagated to req/stream
24
+ * errorClass, // FrameworkError subclass
25
+ * observer, // optional (stage, info) => void hook
26
+ * agent, // override per-origin pool (h1 only)
27
+ * preferH2, // bool — for cleartext h2 (h2c). HTTPS origins
28
+ * // already attempt h2 via ALPN; this flag is
29
+ * // for HTTP origins (internal services, tests)
30
+ * // that explicitly speak h2c.
31
+ * })
32
+ * → { statusCode, headers, body }
33
+ *
34
+ * Protocol selection:
35
+ *
36
+ * - HTTPS origin: TLS handshake with ALPN ['h2', 'http/1.1']. If
37
+ * server picks 'h2', subsequent requests to that origin multiplex
38
+ * over the same h2 session. If server picks 'h1', the cached
39
+ * transport is an https.Agent with keepAlive.
40
+ *
41
+ * - HTTP origin without preferH2: h1 only.
42
+ * - HTTP origin with preferH2: h2c (cleartext h2). No ALPN — caller
43
+ * attests the server speaks h2c. Used by internal services and
44
+ * test fixtures (mock h2 server).
45
+ *
46
+ * Per-origin transport cache:
47
+ *
48
+ * key = "<protocol>//<hostname>:<port>"
49
+ * value = { kind: 'h1', lib, agent } | { kind: 'h2', session }
50
+ *
51
+ * While a transport is being negotiated (TLS handshake / h2 connect)
52
+ * the cache holds the in-flight Promise so concurrent calls to a
53
+ * new origin coalesce onto the same connection.
54
+ *
55
+ * Resiliency:
56
+ * - Wall-clock + idle timeouts (split — slow-progress vs zero-progress)
57
+ * - AbortSignal propagated to req.destroy / stream.close
58
+ * - TLS 1.3 minimum + PQC ecdhCurve preference
59
+ * - h2 session GOAWAY / error → cache eviction; next request reconnects
60
+ * - h2 stream cancellation via NGHTTP2_CANCEL on abort (clean, not destroy)
61
+ * - Request-body stream errors propagated to Promise rejection
62
+ */
63
+
64
+ var http = require("http");
65
+ var https = require("https");
66
+ var http2 = require("http2");
67
+ var nodeStream = require("node:stream");
68
+ var { URL } = require("url");
69
+ var C = require("./constants");
70
+ var crypto = require("./crypto");
71
+ var pqcAgent = require("./pqc-agent");
72
+ var safeAsync = require("./safe-async");
73
+ var safeBuffer = require("./safe-buffer");
74
+ var safeUrl = require("./safe-url");
75
+ var ssrfGuard = require("./ssrf-guard");
76
+ var networkProxy = require("./network-proxy");
77
+ var { FrameworkError } = require("./framework-error");
78
+
79
+ // Per-origin transport cache. Entry is either the resolved transport
80
+ // object or a pending Promise that resolves to one. The Promise form
81
+ // lets concurrent calls to a new origin coalesce on the same connect.
82
+ //
83
+ // Transport shapes the cache currently holds:
84
+ //
85
+ // { kind: "h1", lib, agent } — node:http(s) + keepAlive Agent
86
+ // { kind: "h2", session } — node:http2 ClientHttp2Session
87
+ //
88
+ // Reserved for the future (when node:http3 ships stable — currently
89
+ // behind --experimental-quic, no http3 module yet):
90
+ //
91
+ // { kind: "h3", session } — node:http3 ClientHttp3Session
92
+ //
93
+ // Adding the h3 case won't change the caller-facing surface: ALPN
94
+ // negotiation gains "h3" as the highest-preference protocol over QUIC,
95
+ // _getTransport branches on the resolved transport, and a new
96
+ // _requestH3 mirrors _requestH2's stream-based shape. h3's design
97
+ // gives 0-RTT first-class (vs. h1/h2 where 0-RTT is opaque under
98
+ // node's TLS layer — see TLS_SESSION_RESUMPTION_NOTES below).
99
+ var _transports = new Map();
100
+
101
+ // TLS session resumption notes — what's automatic vs. what's not
102
+ // exposed by Node's public API:
103
+ //
104
+ // keepAlive Agent (h1) / long-lived ClientHttp2Session (h2) means
105
+ // the WARM-CONNECTION case is zero-handshake — better than 0-RTT.
106
+ // We pay the TLS handshake once per origin, then amortize.
107
+ //
108
+ // When a pool socket is recycled, node's tls layer caches session
109
+ // tickets and does 1-RTT resumption automatically. We don't
110
+ // expose 0-RTT (early_data) — node's https.Agent has no clean API
111
+ // for it, and 0-RTT is REPLAY-RISKY for non-idempotent requests
112
+ // (server can't distinguish original from replay until the
113
+ // handshake completes). Operators who need 0-RTT for a specific
114
+ // idempotent path can pass their own agent via opts.agent.
115
+ //
116
+ // QUIC/h3 changes this calculus: 0-RTT is a first-class feature
117
+ // built into the protocol, with replay protection at the QUIC
118
+ // layer. The framework's `b.httpClient` is HTTP/1.1 + HTTP/2 only;
119
+ // operators wanting h3 wire their own client.
120
+
121
+ // Pool tuning for the HTTP-client transport cache. Keep-alive is
122
+ // shorter than the standalone pqc-agent default (1s vs 30s) because
123
+ // the cache layer manages its own warm-connection reuse and we want
124
+ // idle sockets reaped quickly between bursts. ecdhCurve / minVersion
125
+ // come from pqc-agent and cannot be set here — the framework's
126
+ // PQC-only TLS posture is one place, in lib/pqc-agent.js.
127
+ //
128
+ // Operators tune at boot via `b.httpClient.configurePool({...})`.
129
+ // Existing transports stay on whichever values were active when they
130
+ // were created — reconfigure runs before any outbound request to take
131
+ // effect on the per-origin cache.
132
+ var DEFAULT_AGENT_OPTS = Object.freeze({
133
+ keepAlive: true,
134
+ keepAliveMsecs: C.TIME.seconds(1),
135
+ maxSockets: C.BYTES.bytes(16),
136
+ maxFreeSockets: C.BYTES.bytes(8),
137
+ scheduling: "lifo",
138
+ });
139
+
140
+ var HTTP_CLIENT_AGENT_OPTS = Object.assign({}, DEFAULT_AGENT_OPTS);
141
+
142
+ function configurePool(opts) {
143
+ if (!opts || typeof opts !== "object") {
144
+ throw new Error("httpClient.configurePool: opts must be an object");
145
+ }
146
+ var allowed = ["keepAlive", "keepAliveMsecs", "maxSockets", "maxFreeSockets", "scheduling"];
147
+ for (var k in opts) {
148
+ if (!Object.prototype.hasOwnProperty.call(opts, k)) continue;
149
+ if (allowed.indexOf(k) === -1) {
150
+ throw new Error("httpClient.configurePool: unknown option '" + k +
151
+ "'. Allowed: " + allowed.join(", "));
152
+ }
153
+ }
154
+ function _requirePositiveInt(name, value) {
155
+ if (typeof value !== "number" || !isFinite(value) || value <= 0 || Math.floor(value) !== value) {
156
+ throw new Error("httpClient.configurePool: " + name +
157
+ " must be a positive integer, got " + JSON.stringify(value));
158
+ }
159
+ }
160
+ if (opts.maxSockets !== undefined) _requirePositiveInt("maxSockets", opts.maxSockets);
161
+ if (opts.maxFreeSockets !== undefined) _requirePositiveInt("maxFreeSockets", opts.maxFreeSockets);
162
+ if (opts.keepAliveMsecs !== undefined) _requirePositiveInt("keepAliveMsecs", opts.keepAliveMsecs);
163
+ if (opts.keepAlive !== undefined && typeof opts.keepAlive !== "boolean") {
164
+ throw new Error("httpClient.configurePool: keepAlive must be a boolean");
165
+ }
166
+ if (opts.scheduling !== undefined && opts.scheduling !== "lifo" && opts.scheduling !== "fifo") {
167
+ throw new Error("httpClient.configurePool: scheduling must be 'lifo' or 'fifo'");
168
+ }
169
+ Object.assign(HTTP_CLIENT_AGENT_OPTS, opts);
170
+ // Existing transports keep their old values (Agent constructor
171
+ // copies). Drop the per-origin cache + tear down idle sockets so
172
+ // subsequent requests build fresh transports with the new opts.
173
+ _transports.forEach(function (t) {
174
+ if (t && t.kind === "h1" && t.agent && typeof t.agent.destroy === "function") {
175
+ try { t.agent.destroy(); } catch (_e) { /* best-effort agent teardown */ }
176
+ }
177
+ if (t && t.kind === "h2" && t.session) {
178
+ _tearDownH2Session(t.session);
179
+ }
180
+ });
181
+ _transports.clear();
182
+ }
183
+
184
+ // HTTP/2 session teardown — see lib/http2-teardown.js for the full
185
+ // rationale. Centralised so any future sink / pool teardown gets the
186
+ // same close()-then-destroy() discipline.
187
+ var _tearDownH2Session = require("./http2-teardown").tearDownH2Session;
188
+
189
+ // h2 session connect options. Same TLS posture as h1 Agent.
190
+ var DEFAULT_H2_TLS_OPTS = {
191
+ ALPNProtocols: ["h2", "http/1.1"],
192
+ ecdhCurve: C.TLS_GROUP_CURVE_STR,
193
+ minVersion: "TLSv1.3",
194
+ };
195
+
196
+ var DEFAULT_CONTROL_PLANE_CAP = C.BYTES.mib(16);
197
+ var DEFAULT_GET_CAP = C.BYTES.gib(1);
198
+ var DEFAULT_IDLE_TIMEOUT_MS = C.TIME.seconds(30);
199
+
200
+ // h2 session idle close. After this much idle time with no streams,
201
+ // close the session — long-running processes don't pin one TLS
202
+ // connection forever.
203
+ var H2_SESSION_IDLE_TIMEOUT_MS = C.TIME.minutes(5);
204
+
205
+ // IANA-assigned default ports per RFC 9110 §4.2.
206
+ var DEFAULT_HTTPS_PORT = 443;
207
+ var DEFAULT_HTTP_PORT = C.BYTES.bytes(80);
208
+
209
+ function _defaultPortFor(u) {
210
+ return u.protocol === "https:" ? DEFAULT_HTTPS_PORT : DEFAULT_HTTP_PORT;
211
+ }
212
+
213
+ function _originKey(u) {
214
+ return u.protocol + "//" + u.hostname + ":" + (u.port || _defaultPortFor(u));
215
+ }
216
+
217
+ function _makeH1Transport(u, ips) {
218
+ var lib = u.protocol === "https:" ? https : http;
219
+ // HTTPS path goes through pqcAgent.create so the framework's PQC-only
220
+ // posture is enforced via the single primitive. Cleartext HTTP stays
221
+ // on http.Agent because there's no TLS posture to enforce.
222
+ var agent = u.protocol === "https:"
223
+ ? pqcAgent.create(HTTP_CLIENT_AGENT_OPTS)
224
+ : new lib.Agent(HTTP_CLIENT_AGENT_OPTS);
225
+ return { kind: "h1", lib: lib, agent: agent, lookup: _pinnedLookupFor(ips) };
226
+ }
227
+
228
+ // Build a `lookup` callback that pins outbound connections to IPs the
229
+ // SSRF guard already validated. Closes the TOCTOU window between
230
+ // SSRF resolution and the kernel's connect — without this, a hostile
231
+ // (or compromised) DNS could rotate the answer between guard-check
232
+ // and connect-time and route the request to a private / metadata IP
233
+ // that bypassed the gate. ips comes from `ssrfGuard.checkUrl` — its
234
+ // classification ran on these exact addresses.
235
+ function _pinnedLookupFor(ips) {
236
+ if (!Array.isArray(ips) || ips.length === 0) return undefined;
237
+ var families = ips.map(function (i) { return { address: i.address, family: i.family || 4 }; });
238
+ return function pinnedLookup(hostname, options, callback) {
239
+ if (typeof options === "function") { callback = options; options = {}; }
240
+ options = options || {};
241
+ if (options.all) {
242
+ callback(null, families);
243
+ } else {
244
+ callback(null, families[0].address, families[0].family);
245
+ }
246
+ };
247
+ }
248
+
249
+ // Connect an h2 session to an HTTPS origin via ALPN. If the server picks
250
+ // http/1.1, fall back to an h1 transport for that origin.
251
+ function _connectHttpsWithAlpn(u, ips) {
252
+ return new Promise(function (resolve, reject) {
253
+ var connectOpts = Object.assign({}, DEFAULT_H2_TLS_OPTS);
254
+ var pinned = _pinnedLookupFor(ips);
255
+ if (pinned) connectOpts.lookup = pinned;
256
+ var session = http2.connect(u.protocol + "//" + u.host, connectOpts);
257
+ var settled = false;
258
+ function _done(t) { if (!settled) { settled = true; resolve(t); } }
259
+ function _fail(err) { if (!settled) { settled = true; reject(err); } }
260
+
261
+ session.once("connect", function () {
262
+ var alpn = session.alpnProtocol;
263
+ if (alpn === "h2") {
264
+ _wireH2Session(session, _originKey(u));
265
+ _done({ kind: "h2", session: session });
266
+ return;
267
+ }
268
+ // Server picked http/1.1 — close the h2 session, return h1 transport.
269
+ _tearDownH2Session(session);
270
+ _done(_makeH1Transport(u, ips));
271
+ });
272
+ session.once("error", function (err) {
273
+ _tearDownH2Session(session);
274
+ _fail(err);
275
+ });
276
+ });
277
+ }
278
+
279
+ // Connect an h2c session (cleartext h2). No ALPN, no fallback — caller
280
+ // has attested via preferH2 that the server speaks h2c.
281
+ function _connectH2c(u, ips) {
282
+ return new Promise(function (resolve, reject) {
283
+ var connectOpts = {};
284
+ var pinned = _pinnedLookupFor(ips);
285
+ if (pinned) connectOpts.lookup = pinned;
286
+ var session = http2.connect(u.protocol + "//" + u.host, connectOpts);
287
+ session.once("connect", function () {
288
+ _wireH2Session(session, _originKey(u));
289
+ resolve({ kind: "h2", session: session });
290
+ });
291
+ session.once("error", function (err) {
292
+ _tearDownH2Session(session);
293
+ reject(err);
294
+ });
295
+ });
296
+ }
297
+
298
+ // Common h2 session wiring — idle close + cache eviction on error/close.
299
+ function _wireH2Session(session, key) {
300
+ session.setTimeout(H2_SESSION_IDLE_TIMEOUT_MS, function () {
301
+ _tearDownH2Session(session);
302
+ });
303
+ session.once("close", function () { _transports.delete(key); });
304
+ session.once("error", function () { _transports.delete(key); });
305
+ session.once("goaway", function () {
306
+ // Server signalling 'no new streams' — let in-flight finish, evict cache.
307
+ _transports.delete(key);
308
+ });
309
+ }
310
+
311
+ // Async transport selection. Returns Promise<transport>. `ips` is the
312
+ // validated address list returned by `ssrfGuard.checkUrl`; the transport
313
+ // uses it to pin connections so a hostile DNS rebind can't redirect
314
+ // the actual TCP connect to a private / metadata IP.
315
+ function _getTransport(u, opts, ips) {
316
+ var key = _originKey(u);
317
+ var cached = _transports.get(key);
318
+ if (cached) {
319
+ // Could be a resolved transport OR a pending Promise. Cached
320
+ // transports keep whatever IP pinning was set when they were
321
+ // first created — subsequent SSRF checks still gate the request,
322
+ // and the transport's TCP socket is bound to its original IP.
323
+ return Promise.resolve(cached);
324
+ }
325
+
326
+ var promise;
327
+ if (u.protocol === "https:") {
328
+ promise = _connectHttpsWithAlpn(u, ips);
329
+ } else if (opts && opts.preferH2) {
330
+ promise = _connectH2c(u, ips);
331
+ } else {
332
+ // HTTP without preferH2 → h1 only.
333
+ promise = Promise.resolve(_makeH1Transport(u, ips));
334
+ }
335
+
336
+ // Cache the in-flight Promise immediately so concurrent calls
337
+ // coalesce. On resolve, replace with the transport. On reject, evict.
338
+ _transports.set(key, promise);
339
+ promise.then(
340
+ function (t) { _transports.set(key, t); },
341
+ function (_err) { _transports.delete(key); }
342
+ );
343
+
344
+ return promise;
345
+ }
346
+
347
+ function _makeError(errorClass, code, message, permanent, statusCode) {
348
+ if (!errorClass) return new FrameworkError(message, code);
349
+ return new errorClass(code, message, permanent, statusCode);
350
+ }
351
+
352
+ // RFC 9110 §15.5 4xx codes that are NOT permanent (request-timeout,
353
+ // too-early, too-many-requests — operator should retry).
354
+ var STATUS_REQUEST_TIMEOUT = C.BYTES.bytes(408);
355
+ var STATUS_TOO_EARLY = 425;
356
+ var STATUS_TOO_MANY_REQUESTS = 429;
357
+
358
+ function _isPermanentStatus(statusCode) {
359
+ if (statusCode >= 400 && statusCode < 500) {
360
+ return statusCode !== STATUS_REQUEST_TIMEOUT &&
361
+ statusCode !== STATUS_TOO_EARLY &&
362
+ statusCode !== STATUS_TOO_MANY_REQUESTS;
363
+ }
364
+ return false;
365
+ }
366
+
367
+ // h2 sends headers as lowercased keys plus :method / :path / :scheme /
368
+ // :authority pseudo-headers. Convert from h1-shaped headers.
369
+ function _toH2Headers(method, u, headers) {
370
+ var h2Headers = Object.create(null);
371
+ h2Headers[":method"] = method;
372
+ h2Headers[":path"] = u.pathname + (u.search || "");
373
+ h2Headers[":scheme"] = u.protocol === "https:" ? "https" : "http";
374
+ h2Headers[":authority"] = u.host;
375
+ for (var k in headers) {
376
+ if (!Object.prototype.hasOwnProperty.call(headers, k)) continue;
377
+ var lk = k.toLowerCase();
378
+ // h2 forbids the connection-specific headers
379
+ if (lk === "connection" || lk === "host" ||
380
+ lk === "keep-alive" || lk === "transfer-encoding" ||
381
+ lk === "upgrade" || lk === "proxy-connection") continue;
382
+ h2Headers[lk] = headers[k];
383
+ }
384
+ return h2Headers;
385
+ }
386
+
387
+ function _fromH2Headers(h2Headers) {
388
+ // Strip pseudo-headers from the response — caller doesn't want them
389
+ // mixed with normal headers.
390
+ var out = {};
391
+ for (var k in h2Headers) {
392
+ if (!Object.prototype.hasOwnProperty.call(h2Headers, k)) continue;
393
+ if (k.charAt(0) === ":") continue;
394
+ out[k] = h2Headers[k];
395
+ }
396
+ return out;
397
+ }
398
+
399
+ // ---- request() ----
400
+
401
+ var REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
402
+
403
+ // http-client-cookie-jar is exposed on b.httpClient.cookieJar via index.js.
404
+ // http-client itself only consumes the jar shape passed by operators via
405
+ // the request opt; no direct require is needed here.
406
+
407
+ // Merge a jar-derived Cookie header with any caller-supplied Cookie
408
+ // header. Operators who override Cookie explicitly always win — the jar
409
+ // supplements rather than replaces.
410
+ function _attachJarCookie(headers, jar, url) {
411
+ if (!jar) return headers;
412
+ var jarHeader = jar.cookieHeaderFor(url);
413
+ if (!jarHeader) return headers;
414
+ var merged = Object.assign({}, headers || {});
415
+ var existing = null;
416
+ var keys = Object.keys(merged);
417
+ for (var i = 0; i < keys.length; i++) {
418
+ if (keys[i].toLowerCase() === "cookie") { existing = keys[i]; break; }
419
+ }
420
+ if (existing) merged[existing] = merged[existing] + "; " + jarHeader;
421
+ else merged.Cookie = jarHeader;
422
+ return merged;
423
+ }
424
+
425
+ // Build a multipart/form-data body from { fields, files } shape.
426
+ // Mirrors the wire format that lib/middleware/body-parser.js's multipart
427
+ // parser accepts so round-trip from one blamejs app's outbound to
428
+ // another's inbound is exact.
429
+ function _buildMultipartBody(spec) {
430
+ var boundary = "----blamejs-mp-" + crypto.generateToken(C.BYTES.bytes(16));
431
+ var CRLF = "\r\n";
432
+ var parts = [];
433
+
434
+ function _pushField(name, value) {
435
+ if (typeof name !== "string" || name.length === 0) {
436
+ throw new Error("multipart: field name must be a non-empty string");
437
+ }
438
+ var head = "--" + boundary + CRLF +
439
+ 'Content-Disposition: form-data; name="' + name + '"' + CRLF + CRLF;
440
+ parts.push(Buffer.from(head, "utf8"));
441
+ parts.push(Buffer.isBuffer(value) ? value : Buffer.from(String(value), "utf8"));
442
+ parts.push(Buffer.from(CRLF, "utf8"));
443
+ }
444
+
445
+ function _pushFile(file) {
446
+ if (!file || typeof file !== "object") throw new Error("multipart: file entries must be objects");
447
+ if (typeof file.field !== "string" || file.field.length === 0) {
448
+ throw new Error("multipart: file.field must be a non-empty string");
449
+ }
450
+ var filename = typeof file.filename === "string" && file.filename.length > 0
451
+ ? file.filename : "blob";
452
+ var mimeType = file.contentType || file.mimeType || "application/octet-stream";
453
+ var content = file.content;
454
+ if (typeof content === "string") content = Buffer.from(content, "utf8");
455
+ if (!Buffer.isBuffer(content)) {
456
+ throw new Error("multipart: file.content must be a Buffer or string");
457
+ }
458
+ var head = "--" + boundary + CRLF +
459
+ 'Content-Disposition: form-data; name="' + file.field + '"' +
460
+ '; filename="' + filename.replace(/"/g, "%22") + '"' + CRLF +
461
+ "Content-Type: " + mimeType + CRLF + CRLF;
462
+ parts.push(Buffer.from(head, "utf8"));
463
+ parts.push(content);
464
+ parts.push(Buffer.from(CRLF, "utf8"));
465
+ }
466
+
467
+ if (spec && spec.fields && typeof spec.fields === "object") {
468
+ var keys = Object.keys(spec.fields);
469
+ for (var i = 0; i < keys.length; i++) {
470
+ var k = keys[i];
471
+ var v = spec.fields[k];
472
+ if (Array.isArray(v)) {
473
+ for (var j = 0; j < v.length; j++) _pushField(k, v[j]);
474
+ } else {
475
+ _pushField(k, v);
476
+ }
477
+ }
478
+ }
479
+ if (spec && Array.isArray(spec.files)) {
480
+ for (var fi = 0; fi < spec.files.length; fi++) _pushFile(spec.files[fi]);
481
+ }
482
+ parts.push(Buffer.from("--" + boundary + "--" + CRLF, "utf8"));
483
+ return { boundary: boundary, body: Buffer.concat(parts) };
484
+ }
485
+
486
+ // Headers stripped on cross-origin redirect to defend against accidental
487
+ // credential exfiltration. Lower-case for header-map comparison.
488
+ var SENSITIVE_HEADERS_LC = ["authorization", "cookie", "proxy-authorization"];
489
+
490
+ function _stripCrossOriginAuth(headers) {
491
+ var out = {};
492
+ var keys = Object.keys(headers);
493
+ for (var i = 0; i < keys.length; i++) {
494
+ if (SENSITIVE_HEADERS_LC.indexOf(keys[i].toLowerCase()) !== -1) continue;
495
+ out[keys[i]] = headers[keys[i]];
496
+ }
497
+ return out;
498
+ }
499
+
500
+ function request(opts) {
501
+ if (!opts || !opts.url) {
502
+ return Promise.reject(_makeError(opts && opts.errorClass, "BAD_ARG", "url is required", true));
503
+ }
504
+
505
+ // Validate before/after shapes early — throw at call site if the
506
+ // operator passed something un-callable so the bug surfaces here
507
+ // rather than inside the request loop.
508
+ if (opts.before !== undefined) {
509
+ if (!Array.isArray(opts.before) || !opts.before.every(function (f) { return typeof f === "function"; })) {
510
+ return Promise.reject(_makeError(opts.errorClass, "BAD_ARG",
511
+ "before must be an array of functions", true));
512
+ }
513
+ }
514
+ if (opts.after !== undefined) {
515
+ if (!Array.isArray(opts.after) || !opts.after.every(function (f) { return typeof f === "function"; })) {
516
+ return Promise.reject(_makeError(opts.errorClass, "BAD_ARG",
517
+ "after must be an array of functions", true));
518
+ }
519
+ }
520
+ if (opts.onUploadProgress !== undefined && typeof opts.onUploadProgress !== "function") {
521
+ return Promise.reject(_makeError(opts.errorClass, "BAD_ARG",
522
+ "onUploadProgress must be a function", true));
523
+ }
524
+ if (opts.onDownloadProgress !== undefined && typeof opts.onDownloadProgress !== "function") {
525
+ return Promise.reject(_makeError(opts.errorClass, "BAD_ARG",
526
+ "onDownloadProgress must be a function", true));
527
+ }
528
+ if (opts.jar !== undefined && opts.jar !== null) {
529
+ if (typeof opts.jar !== "object" ||
530
+ typeof opts.jar.cookieHeaderFor !== "function" ||
531
+ typeof opts.jar.setFromResponse !== "function") {
532
+ return Promise.reject(_makeError(opts.errorClass, "BAD_ARG",
533
+ "jar must be a b.httpClient.cookieJar.create() instance", true));
534
+ }
535
+ }
536
+
537
+ // before interceptors — run in array order. Each may return a modified
538
+ // opts object (or return nothing to leave the running opts as-is).
539
+ // Caller-set defaults / observability / auth header injection lands
540
+ // here. Synchronous to keep the request hot path simple; async
541
+ // pre-flight work (e.g. token refresh) belongs in the route handler
542
+ // before httpClient.request is even called.
543
+ if (Array.isArray(opts.before) && opts.before.length > 0) {
544
+ var working = opts;
545
+ for (var bi = 0; bi < opts.before.length; bi++) {
546
+ var ret;
547
+ try { ret = opts.before[bi](working); }
548
+ catch (e) {
549
+ return Promise.reject(_makeError(opts.errorClass, "BEFORE_THREW",
550
+ "before[" + bi + "] threw: " + ((e && e.message) || String(e)), true));
551
+ }
552
+ if (ret && typeof ret === "object") working = ret;
553
+ }
554
+ opts = working;
555
+ }
556
+
557
+ // Multipart shorthand: { multipart: { fields, files } } expands to
558
+ // body + Content-Type with the boundary parameter. Mutually exclusive
559
+ // with caller-supplied body / Content-Type.
560
+ if (opts.multipart) {
561
+ if (opts.body !== undefined) {
562
+ return Promise.reject(_makeError(opts.errorClass, "BAD_ARG",
563
+ "request: pass either { body } or { multipart }, not both", true));
564
+ }
565
+ var built;
566
+ try { built = _buildMultipartBody(opts.multipart); }
567
+ catch (e) {
568
+ return Promise.reject(_makeError(opts.errorClass, "BAD_ARG", e.message, true));
569
+ }
570
+ opts = Object.assign({}, opts, {
571
+ method: opts.method || "POST",
572
+ body: built.body,
573
+ headers: Object.assign({}, opts.headers || {}, {
574
+ "Content-Type": "multipart/form-data; boundary=" + built.boundary,
575
+ "Content-Length": String(built.body.length),
576
+ }),
577
+ multipart: undefined,
578
+ });
579
+ }
580
+
581
+ // maxRedirects:
582
+ // undefined → today's behavior (no follow). Caller inspects 3xx.
583
+ // null → today's behavior (explicit). Same as undefined.
584
+ // 0 → no follow, but 3xx returned to caller (alias of null).
585
+ // N → follow up to N hops; 3xx with no Location returned as-is.
586
+ var maxRedirects = (opts.maxRedirects === undefined || opts.maxRedirects === null)
587
+ ? null : opts.maxRedirects;
588
+ if (maxRedirects !== null) {
589
+ if (typeof maxRedirects !== "number" || !isFinite(maxRedirects) || maxRedirects < 0 ||
590
+ Math.floor(maxRedirects) !== maxRedirects) {
591
+ return Promise.reject(_makeError(opts.errorClass, "BAD_ARG",
592
+ "maxRedirects must be a non-negative integer or null", true));
593
+ }
594
+ }
595
+ var afterChain = (Array.isArray(opts.after) && opts.after.length > 0) ? opts.after : null;
596
+ function _runAfter(finalOpts, res) {
597
+ if (!afterChain) return res;
598
+ for (var ai = 0; ai < afterChain.length; ai++) {
599
+ try { afterChain[ai](finalOpts, res); }
600
+ catch (_e) { /* after hooks are best-effort — never break the response */ }
601
+ }
602
+ return res;
603
+ }
604
+
605
+ if (maxRedirects === null || maxRedirects === 0) {
606
+ return _requestSingle(opts).then(function (res) { return _runAfter(opts, res); });
607
+ }
608
+
609
+ return _requestWithRedirects(opts, maxRedirects).then(function (boxed) {
610
+ return _runAfter(boxed.finalOpts, boxed.res);
611
+ });
612
+ }
613
+
614
+ function _requestWithRedirects(opts, hopsLeft) {
615
+ var originalUrl = opts.url;
616
+ var originalOrigin = null;
617
+ try {
618
+ var u0 = safeUrl.parse(opts.url, { allowedProtocols: safeUrl.ALLOW_HTTP_ALL });
619
+ originalOrigin = u0.protocol + "//" + u0.host;
620
+ } catch (_e) { /* request() will reject on next hop's parse */ }
621
+
622
+ var current = Object.assign({}, opts, { _resolveOnRedirect: true });
623
+ function _follow() {
624
+ return _requestSingle(current).then(function (res) {
625
+ if (!REDIRECT_STATUSES.has(res.statusCode) || hopsLeft <= 0) {
626
+ return { finalOpts: current, res: res };
627
+ }
628
+ var loc = res.headers && (res.headers.location || res.headers.Location);
629
+ if (!loc) return { finalOpts: current, res: res }; // 3xx with no Location — operator handles
630
+ hopsLeft -= 1;
631
+
632
+ // Resolve relative Location against the just-fetched URL (the URL
633
+ // of the request that produced the redirect, which may itself be a
634
+ // post-redirect URL).
635
+ var nextUrl;
636
+ try {
637
+ // Resolve relative Location against current URL using Node's URL
638
+ // base-URL form. Re-validate the resolved absolute through safeUrl
639
+ // immediately below.
640
+ nextUrl = Reflect.construct(URL, [loc, current.url]).toString();
641
+ }
642
+ catch (_e) {
643
+ return Promise.reject(_makeError(opts.errorClass, "BAD_REDIRECT",
644
+ "Location header invalid URL: " + loc, true));
645
+ }
646
+
647
+ // Cross-origin auth-header strip.
648
+ var nextHeaders = current.headers || {};
649
+ var nextOrigin;
650
+ try {
651
+ var nu = safeUrl.parse(nextUrl, { allowedProtocols: safeUrl.ALLOW_HTTP_ALL });
652
+ nextOrigin = nu.protocol + "//" + nu.host;
653
+ } catch (_e) { /* request() will reject when it tries to parse */ }
654
+ if (originalOrigin && nextOrigin && nextOrigin !== originalOrigin) {
655
+ nextHeaders = _stripCrossOriginAuth(nextHeaders);
656
+ }
657
+
658
+ // 303 → always GET; body dropped. 301/302 → historical clients
659
+ // also coerce non-GET bodies (we follow that convention). 307/308
660
+ // → preserve method + body.
661
+ var nextMethod = current.method || "GET";
662
+ var nextBody = current.body;
663
+ if (res.statusCode === 303 ||
664
+ ((res.statusCode === 301 || res.statusCode === 302) &&
665
+ nextMethod !== "GET" && nextMethod !== "HEAD")) {
666
+ nextMethod = "GET";
667
+ nextBody = undefined;
668
+ }
669
+
670
+ current = Object.assign({}, current, {
671
+ url: nextUrl,
672
+ method: nextMethod,
673
+ body: nextBody,
674
+ headers: nextHeaders,
675
+ _resolveOnRedirect: true,
676
+ });
677
+ return _follow();
678
+ });
679
+ }
680
+ void originalUrl;
681
+ return _follow();
682
+ }
683
+
684
+ function _requestSingle(opts) {
685
+ // Validate scheme + shape via url-safe. Default is HTTPS-only — the
686
+ // framework refuses to silently drop bytes on the wire as cleartext.
687
+ // Callers with cleartext endpoints (h2c, internal services, test
688
+ // fixtures) explicitly opt in via opts.allowedProtocols
689
+ // (safeUrl.ALLOW_HTTP_ALL accepts both http: and https:).
690
+ var u;
691
+ try {
692
+ u = safeUrl.parse(opts.url, {
693
+ allowedProtocols: opts.allowedProtocols || safeUrl.ALLOW_HTTP_TLS,
694
+ errorClass: opts.errorClass,
695
+ });
696
+ } catch (e) {
697
+ return Promise.reject(e);
698
+ }
699
+
700
+ // Optional outbound destination allowlist. When opts.allowedHosts
701
+ // is set, only URLs whose hostname is on the list are permitted.
702
+ // Layer above safeUrl (scheme/userinfo gate) and above ssrfGuard
703
+ // (IP-class gate) — operators with strict egress policies pin the
704
+ // outbound destinations the app is allowed to talk to so a
705
+ // compromised process can't reach arbitrary upstreams.
706
+ //
707
+ // Entry forms (each entry is a string OR an object):
708
+ // "api.partner.com" — exact host match
709
+ // ".partner.com" — suffix match: "api.partner.com" yes,
710
+ // "evilpartner.com" no
711
+ // "*.partner.com" — same as ".partner.com" (DNS-glob shape
712
+ // operators expect from firewall configs)
713
+ // { host: "api.x.com", methods: ["GET","HEAD"] }
714
+ // — method-restricted entry; methods omitted
715
+ // = any method
716
+ //
717
+ // A disallowed call rejects with HOST_DISALLOWED AND emits an
718
+ // audit event when opts.audit is wired (operator gets a structured
719
+ // signal that the application tried to reach somewhere it shouldn't).
720
+ if (Array.isArray(opts.allowedHosts) && opts.allowedHosts.length > 0) {
721
+ var host = u.hostname.toLowerCase();
722
+ var method = (opts.method || "GET").toUpperCase();
723
+ var ok = false;
724
+ for (var ai = 0; ai < opts.allowedHosts.length; ai++) {
725
+ var entry = opts.allowedHosts[ai];
726
+ var allow, allowedMethods = null;
727
+ if (typeof entry === "object" && entry !== null) {
728
+ allow = String(entry.host || "").toLowerCase();
729
+ if (Array.isArray(entry.methods) && entry.methods.length > 0) {
730
+ allowedMethods = entry.methods.map(function (m) { return String(m).toUpperCase(); });
731
+ }
732
+ } else {
733
+ allow = String(entry || "").toLowerCase();
734
+ }
735
+ if (allow.length === 0) continue;
736
+ // Normalise "*.x.com" to ".x.com" for the suffix match path.
737
+ if (allow.charAt(0) === "*" && allow.charAt(1) === ".") allow = allow.slice(1);
738
+ var matched = false;
739
+ if (allow.charAt(0) === ".") {
740
+ if (host === allow.slice(1) || host.endsWith(allow)) matched = true;
741
+ } else if (host === allow) {
742
+ matched = true;
743
+ }
744
+ if (!matched) continue;
745
+ if (allowedMethods !== null && allowedMethods.indexOf(method) === -1) continue;
746
+ ok = true;
747
+ break;
748
+ }
749
+ if (!ok) {
750
+ if (opts.audit && typeof opts.audit.safeEmit === "function") {
751
+ try {
752
+ opts.audit.safeEmit({
753
+ action: "system.httpclient.host_denied",
754
+ outcome: "denied",
755
+ resource: { kind: "outbound.http", id: host },
756
+ metadata: { method: method, url: opts.url, allowedHostsCount: opts.allowedHosts.length },
757
+ });
758
+ } catch (_e) { /* audit best-effort */ }
759
+ }
760
+ return Promise.reject(_makeError(opts.errorClass, "HOST_DISALLOWED",
761
+ "host '" + host + "' not in allowedHosts (method=" + method + ")", true));
762
+ }
763
+ }
764
+
765
+ // Attach jar-derived Cookie header BEFORE the request fires; record
766
+ // Set-Cookie response headers AFTER. Both halves run when opts.jar
767
+ // is set; redirect-following naturally re-runs both paths per hop
768
+ // because each hop calls _requestSingle.
769
+ if (opts.jar) {
770
+ var headersWithJar = _attachJarCookie(opts.headers, opts.jar, opts.url);
771
+ if (headersWithJar !== opts.headers) {
772
+ opts = Object.assign({}, opts, { headers: headersWithJar });
773
+ }
774
+ }
775
+
776
+ // SSRF gate — refuse private / loopback / link-local / cloud-metadata
777
+ // / reserved IP destinations by default. The returned `ips` are
778
+ // threaded into transport creation so the actual TCP connect pins
779
+ // to those exact addresses, closing the DNS-rebinding TOCTOU window.
780
+ return ssrfGuard.checkUrl(u, {
781
+ allowInternal: opts.allowInternal,
782
+ errorClass: opts.errorClass,
783
+ }).then(function (ssrfResult) {
784
+ var ips = ssrfResult && ssrfResult.ips;
785
+ // Caller-supplied agent bypasses transport cache (h1 only). The
786
+ // operator owns the agent's connection pool — we still pass the
787
+ // pinned lookup through per-request so the SSRF check's IPs win.
788
+ if (opts.agent) {
789
+ return _requestH1({
790
+ kind: "h1",
791
+ lib: u.protocol === "https:" ? https : http,
792
+ agent: opts.agent,
793
+ lookup: _pinnedLookupFor(ips),
794
+ }, u, opts);
795
+ }
796
+
797
+ var proxyAgent = null;
798
+ try { proxyAgent = networkProxy.agentFor(u); } catch (_e) { proxyAgent = null; }
799
+ if (proxyAgent) {
800
+ return _requestH1({
801
+ kind: "h1",
802
+ lib: u.protocol === "https:" ? https : http,
803
+ agent: proxyAgent,
804
+ lookup: undefined,
805
+ }, u, opts);
806
+ }
807
+
808
+ return _getTransport(u, opts, ips).then(function (transport) {
809
+ if (transport.kind === "h2") return _requestH2(transport, u, opts);
810
+ return _requestH1(transport, u, opts);
811
+ });
812
+ });
813
+ }
814
+
815
+ // ---- _requestH1: existing node:http(s) path ----
816
+
817
+ function _requestH1(transport, u, opts) {
818
+ return new Promise(function (resolve, reject) {
819
+ var method = (opts.method || "GET").toUpperCase();
820
+ var headers = Object.assign({}, opts.headers || {});
821
+ var responseMode = opts.responseMode || "buffer";
822
+ var maxResponseBytes = opts.maxResponseBytes ||
823
+ (method === "GET" ? DEFAULT_GET_CAP : DEFAULT_CONTROL_PLANE_CAP);
824
+ var observer = typeof opts.observer === "function" ? opts.observer : null;
825
+ var startedAt = Date.now();
826
+
827
+ var signal = safeAsync.withTimeoutSignal(opts.signal || null, opts.timeoutMs);
828
+ if (signal && signal.aborted) {
829
+ var r0 = signal.reason;
830
+ var code0 = (r0 && r0.name === "TimeoutError") ? "ETIMEDOUT" : "ABORT";
831
+ reject(_makeError(opts.errorClass, code0,
832
+ (r0 && r0.message) || "request aborted before start", false));
833
+ return;
834
+ }
835
+
836
+ if (Buffer.isBuffer(opts.body)) {
837
+ headers["Content-Length"] = opts.body.length;
838
+ }
839
+
840
+ var reqOpts = {
841
+ method: method,
842
+ hostname: u.hostname,
843
+ port: u.port || _defaultPortFor(u),
844
+ path: u.pathname + (u.search || ""),
845
+ headers: headers,
846
+ agent: transport.agent,
847
+ timeout: typeof opts.idleTimeoutMs === "number" ? opts.idleTimeoutMs : DEFAULT_IDLE_TIMEOUT_MS,
848
+ };
849
+ // Pin DNS to the IPs the SSRF guard validated. Closes the
850
+ // rebinding TOCTOU between guard-check and actual TCP connect.
851
+ if (transport.lookup) reqOpts.lookup = transport.lookup;
852
+
853
+ if (observer) observer("request:start", { method: method, url: String(opts.url), protocol: "h1" });
854
+
855
+ var settled = false;
856
+ function _resolve(value) { if (!settled) { settled = true; resolve(value); } }
857
+ function _reject(err) { if (!settled) { settled = true; reject(err); } }
858
+
859
+ var onUploadProgress = typeof opts.onUploadProgress === "function" ? opts.onUploadProgress : null;
860
+ var onDownloadProgress = typeof opts.onDownloadProgress === "function" ? opts.onDownloadProgress : null;
861
+
862
+ var req = transport.lib.request(reqOpts, function (res) {
863
+ if (observer) observer("response:headers", { statusCode: res.statusCode, headers: res.headers });
864
+
865
+ // Save Set-Cookie into the jar (if wired) BEFORE delivering the
866
+ // response object — operator inspecting the response can already
867
+ // count on the jar carrying the new state.
868
+ if (opts.jar && res.headers && res.headers["set-cookie"]) {
869
+ try { opts.jar.setFromResponse(opts.url, res.headers["set-cookie"]); }
870
+ catch (_e) { /* jar is best-effort — never break the response */ }
871
+ }
872
+
873
+ // Download total: Content-Length when present, null otherwise.
874
+ var dlTotal = null;
875
+ if (res.headers && typeof res.headers["content-length"] === "string") {
876
+ var cl = parseInt(res.headers["content-length"], 10);
877
+ if (!isNaN(cl) && cl >= 0) dlTotal = cl;
878
+ }
879
+ var dlLoaded = 0;
880
+ function _emitDownload(chunkBytes) {
881
+ if (!onDownloadProgress) return;
882
+ dlLoaded += chunkBytes;
883
+ try { onDownloadProgress({ loaded: dlLoaded, total: dlTotal }); }
884
+ catch (_e) { /* progress hooks are best-effort */ }
885
+ }
886
+
887
+ if (responseMode === "stream") {
888
+ if (res.statusCode >= 400) {
889
+ res.resume();
890
+ return _reject(_makeError(opts.errorClass, "HTTP_ERROR",
891
+ "HTTP " + res.statusCode + " " + (res.statusMessage || ""),
892
+ _isPermanentStatus(res.statusCode), res.statusCode));
893
+ }
894
+ if (onDownloadProgress) {
895
+ // Wrap the stream so chunks emit progress to the operator.
896
+ // The framework's contract is to hand back the response stream
897
+ // unmodified; fix-up via a passthrough keeps that contract while
898
+ // observing the chunk sizes.
899
+ var passthrough = new nodeStream.PassThrough();
900
+ res.on("data", function (chunk) { _emitDownload(chunk.length); passthrough.write(chunk); });
901
+ res.on("end", function () { passthrough.end(); });
902
+ res.on("error", function (e) { passthrough.destroy(e); });
903
+ return _resolve({ statusCode: res.statusCode, headers: res.headers, body: passthrough });
904
+ }
905
+ return _resolve({ statusCode: res.statusCode, headers: res.headers, body: res });
906
+ }
907
+
908
+ var collector = safeBuffer.boundedChunkCollector({ maxBytes: maxResponseBytes });
909
+ var capExceeded = false;
910
+
911
+ res.on("data", function (chunk) {
912
+ if (capExceeded) return;
913
+ try { collector.push(chunk); }
914
+ catch (_e) {
915
+ capExceeded = true;
916
+ req.destroy();
917
+ _reject(_makeError(opts.errorClass, "RESPONSE_TOO_LARGE",
918
+ "response body exceeds " + maxResponseBytes + " bytes", true));
919
+ return;
920
+ }
921
+ _emitDownload(chunk.length);
922
+ });
923
+ res.on("end", function () {
924
+ if (capExceeded) return;
925
+ var buf = collector.result();
926
+ if (observer) observer("response:end", {
927
+ statusCode: res.statusCode,
928
+ durationMs: Date.now() - startedAt,
929
+ bytes: buf.length,
930
+ });
931
+ if (res.statusCode >= 200 && res.statusCode < 300) {
932
+ _resolve({ statusCode: res.statusCode, headers: res.headers, body: buf });
933
+ } else if (opts._resolveOnRedirect && REDIRECT_STATUSES.has(res.statusCode)) {
934
+ // Redirect-following layer needs the response object intact so
935
+ // it can inspect Location and re-issue. The caller-facing
936
+ // request() never sets _resolveOnRedirect — operator code that
937
+ // didn't ask for redirect-following keeps seeing 3xx as errors.
938
+ _resolve({ statusCode: res.statusCode, headers: res.headers, body: buf });
939
+ } else {
940
+ var msg = "HTTP " + res.statusCode + ": " + buf.toString("utf8").slice(0, 500);
941
+ _reject(_makeError(opts.errorClass, "HTTP_ERROR", msg,
942
+ _isPermanentStatus(res.statusCode), res.statusCode));
943
+ }
944
+ });
945
+ res.on("error", function (e) {
946
+ if (capExceeded) return;
947
+ if (observer) observer("error", { phase: "response", message: e.message });
948
+ _reject(_makeError(opts.errorClass, e.code || "RES_ERROR", e.message, false));
949
+ });
950
+ });
951
+
952
+ req.on("timeout", function () {
953
+ req.destroy();
954
+ _reject(_makeError(opts.errorClass, "ETIMEDOUT",
955
+ "request idle timeout (no data for " + reqOpts.timeout + "ms)", false));
956
+ });
957
+
958
+ req.on("error", function (e) {
959
+ if (observer) observer("error", { phase: "request", message: e.message });
960
+ _reject(_makeError(opts.errorClass, e.code || "REQ_ERROR", e.message, false));
961
+ });
962
+
963
+ if (signal) {
964
+ var onAbort = function () {
965
+ var r = signal.reason;
966
+ var code = (r && r.name === "TimeoutError") ? "ETIMEDOUT" : "ABORT";
967
+ var msg = (r && r.message) || "request aborted";
968
+ try { req.destroy(r || new Error(msg)); } catch (_e) { /* best-effort req teardown */ }
969
+ _reject(_makeError(opts.errorClass, code, msg, false));
970
+ };
971
+ signal.addEventListener("abort", onAbort, { once: true });
972
+ }
973
+
974
+ // Upload progress: emit { loaded, total } as body bytes go to the
975
+ // wire. Buffer / string bodies are sliced into chunks ourselves so
976
+ // operators see incremental progress; Readable bodies emit on each
977
+ // 'data' event from the source stream.
978
+ var ulTotal = null;
979
+ if (Buffer.isBuffer(opts.body)) ulTotal = opts.body.length;
980
+ else if (typeof opts.body === "string") ulTotal = Buffer.byteLength(opts.body, "utf8");
981
+ var ulLoaded = 0;
982
+ function _emitUpload(chunkBytes) {
983
+ if (!onUploadProgress) return;
984
+ ulLoaded += chunkBytes;
985
+ try { onUploadProgress({ loaded: ulLoaded, total: ulTotal }); }
986
+ catch (_e) { /* progress hooks are best-effort */ }
987
+ }
988
+
989
+ if (opts.body && typeof opts.body.pipe === "function") {
990
+ if (onUploadProgress) {
991
+ opts.body.on("data", function (c) { _emitUpload(c.length); });
992
+ }
993
+ opts.body.on("error", function (e) {
994
+ try { req.destroy(); } catch (_) { /* best-effort req teardown */ }
995
+ _reject(_makeError(opts.errorClass, "REQ_BODY_ERROR",
996
+ "request body stream error: " + e.message, false));
997
+ });
998
+ opts.body.pipe(req);
999
+ } else if (Buffer.isBuffer(opts.body) || typeof opts.body === "string") {
1000
+ var bodyBuf = Buffer.isBuffer(opts.body) ? opts.body : Buffer.from(opts.body, "utf8");
1001
+ if (onUploadProgress) {
1002
+ // Chunked write so progress reports land before req.end().
1003
+ var CHUNK = C.BYTES.kib(64);
1004
+ var off = 0;
1005
+ while (off < bodyBuf.length) {
1006
+ var slice = bodyBuf.slice(off, Math.min(off + CHUNK, bodyBuf.length));
1007
+ req.write(slice);
1008
+ _emitUpload(slice.length);
1009
+ off += slice.length;
1010
+ }
1011
+ req.end();
1012
+ } else {
1013
+ req.end(bodyBuf);
1014
+ }
1015
+ } else {
1016
+ req.end();
1017
+ }
1018
+ });
1019
+ }
1020
+
1021
+ // ---- _requestH2: node:http2 path ----
1022
+
1023
+ function _requestH2(transport, u, opts) {
1024
+ return new Promise(function (resolve, reject) {
1025
+ var method = (opts.method || "GET").toUpperCase();
1026
+ var responseMode = opts.responseMode || "buffer";
1027
+ var maxResponseBytes = opts.maxResponseBytes ||
1028
+ (method === "GET" ? DEFAULT_GET_CAP : DEFAULT_CONTROL_PLANE_CAP);
1029
+ var observer = typeof opts.observer === "function" ? opts.observer : null;
1030
+ var startedAt = Date.now();
1031
+
1032
+ var signal = safeAsync.withTimeoutSignal(opts.signal || null, opts.timeoutMs);
1033
+ if (signal && signal.aborted) {
1034
+ var r0 = signal.reason;
1035
+ var code0 = (r0 && r0.name === "TimeoutError") ? "ETIMEDOUT" : "ABORT";
1036
+ reject(_makeError(opts.errorClass, code0,
1037
+ (r0 && r0.message) || "request aborted before start", false));
1038
+ return;
1039
+ }
1040
+
1041
+ var headers = _toH2Headers(method, u, opts.headers || {});
1042
+ if (Buffer.isBuffer(opts.body)) headers["content-length"] = String(opts.body.length);
1043
+
1044
+ if (observer) observer("request:start", { method: method, url: String(opts.url), protocol: "h2" });
1045
+
1046
+ var stream;
1047
+ try {
1048
+ stream = transport.session.request(headers, {
1049
+ endStream: opts.body == null,
1050
+ });
1051
+ } catch (e) {
1052
+ reject(_makeError(opts.errorClass, e.code || "H2_REQUEST_ERROR", e.message, false));
1053
+ return;
1054
+ }
1055
+
1056
+ var settled = false;
1057
+ function _resolve(v) { if (!settled) { settled = true; resolve(v); } }
1058
+ function _reject(e) { if (!settled) { settled = true; reject(e); } }
1059
+
1060
+ // Idle timeout for the stream itself (zero-progress detector).
1061
+ var idleMs = typeof opts.idleTimeoutMs === "number" ? opts.idleTimeoutMs : DEFAULT_IDLE_TIMEOUT_MS;
1062
+ stream.setTimeout(idleMs, function () {
1063
+ try { stream.close(http2.constants.NGHTTP2_CANCEL); } catch (_e) { /* best-effort h2 stream cancel */ }
1064
+ _reject(_makeError(opts.errorClass, "ETIMEDOUT",
1065
+ "h2 stream idle timeout (no data for " + idleMs + "ms)", false));
1066
+ });
1067
+
1068
+ stream.on("response", function (resHeaders) {
1069
+ var statusCode = resHeaders[":status"];
1070
+ var responseHeaders = _fromH2Headers(resHeaders);
1071
+
1072
+ if (observer) observer("response:headers", { statusCode: statusCode, headers: responseHeaders });
1073
+
1074
+ // Save Set-Cookie to the jar (h2 set-cookie comes through as
1075
+ // either a single string or array, same shape as h1).
1076
+ if (opts.jar && responseHeaders["set-cookie"]) {
1077
+ try { opts.jar.setFromResponse(opts.url, responseHeaders["set-cookie"]); }
1078
+ catch (_e) { /* jar best-effort */ }
1079
+ }
1080
+
1081
+ if (responseMode === "stream") {
1082
+ if (statusCode >= 400) {
1083
+ stream.resume();
1084
+ return _reject(_makeError(opts.errorClass, "HTTP_ERROR",
1085
+ "HTTP " + statusCode, _isPermanentStatus(statusCode), statusCode));
1086
+ }
1087
+ return _resolve({ statusCode: statusCode, headers: responseHeaders, body: stream });
1088
+ }
1089
+
1090
+ var collector = safeBuffer.boundedChunkCollector({ maxBytes: maxResponseBytes });
1091
+ var capExceeded = false;
1092
+
1093
+ stream.on("data", function (chunk) {
1094
+ if (capExceeded) return;
1095
+ try { collector.push(chunk); }
1096
+ catch (_e) {
1097
+ capExceeded = true;
1098
+ try { stream.close(http2.constants.NGHTTP2_CANCEL); } catch (_e2) { /* best-effort h2 stream cancel */ }
1099
+ _reject(_makeError(opts.errorClass, "RESPONSE_TOO_LARGE",
1100
+ "response body exceeds " + maxResponseBytes + " bytes", true));
1101
+ }
1102
+ });
1103
+ stream.on("end", function () {
1104
+ if (capExceeded) return;
1105
+ var buf = collector.result();
1106
+ if (observer) observer("response:end", {
1107
+ statusCode: statusCode,
1108
+ durationMs: Date.now() - startedAt,
1109
+ bytes: buf.length,
1110
+ });
1111
+ if (statusCode >= 200 && statusCode < 300) {
1112
+ _resolve({ statusCode: statusCode, headers: responseHeaders, body: buf });
1113
+ } else {
1114
+ var msg = "HTTP " + statusCode + ": " + buf.toString("utf8").slice(0, 500);
1115
+ _reject(_makeError(opts.errorClass, "HTTP_ERROR", msg,
1116
+ _isPermanentStatus(statusCode), statusCode));
1117
+ }
1118
+ });
1119
+ });
1120
+
1121
+ stream.on("error", function (e) {
1122
+ if (observer) observer("error", { phase: "stream", message: e.message });
1123
+ _reject(_makeError(opts.errorClass, e.code || "H2_STREAM_ERROR", e.message, false));
1124
+ });
1125
+
1126
+ if (signal) {
1127
+ var onAbort = function () {
1128
+ var r = signal.reason;
1129
+ var code = (r && r.name === "TimeoutError") ? "ETIMEDOUT" : "ABORT";
1130
+ var msg = (r && r.message) || "request aborted";
1131
+ // NGHTTP2_CANCEL is the protocol-level "I gave up" signal —
1132
+ // cleaner than destroying the stream.
1133
+ try { stream.close(http2.constants.NGHTTP2_CANCEL); } catch (_e) { /* best-effort h2 stream cancel */ }
1134
+ _reject(_makeError(opts.errorClass, code, msg, false));
1135
+ };
1136
+ signal.addEventListener("abort", onAbort, { once: true });
1137
+ }
1138
+
1139
+ if (opts.body && typeof opts.body.pipe === "function") {
1140
+ opts.body.on("error", function (e) {
1141
+ try { stream.close(http2.constants.NGHTTP2_INTERNAL_ERROR); } catch (_) { /* best-effort h2 stream cancel */ }
1142
+ _reject(_makeError(opts.errorClass, "REQ_BODY_ERROR",
1143
+ "request body stream error: " + e.message, false));
1144
+ });
1145
+ opts.body.pipe(stream);
1146
+ } else if (Buffer.isBuffer(opts.body)) {
1147
+ stream.end(opts.body);
1148
+ } else if (typeof opts.body === "string") {
1149
+ stream.end(Buffer.from(opts.body, "utf8"));
1150
+ }
1151
+ // If body is null/undefined, endStream:true was set in session.request()
1152
+ });
1153
+ }
1154
+
1155
+ // ---- Test helpers ----
1156
+
1157
+ function _resetForTest() {
1158
+ _transports.forEach(function (t) {
1159
+ if (t && t.kind === "h1" && t.agent && typeof t.agent.destroy === "function") {
1160
+ try { t.agent.destroy(); } catch (_e) { /* best-effort agent teardown */ }
1161
+ }
1162
+ if (t && t.kind === "h2" && t.session) {
1163
+ _tearDownH2Session(t.session);
1164
+ }
1165
+ });
1166
+ _transports.clear();
1167
+ }
1168
+
1169
+ function _getCachedTransportCount() {
1170
+ return _transports.size;
1171
+ }
1172
+
1173
+ // Diagnostic — returns 'h1' | 'h2' | null for a given URL's cached transport.
1174
+ function _getCachedTransportKind(url) {
1175
+ var u = url instanceof URL ? url : safeUrl.parse(url, { allowedProtocols: safeUrl.ALLOW_HTTP_ALL });
1176
+ var t = _transports.get(_originKey(u));
1177
+ if (!t) return null;
1178
+ if (t.then) return "pending";
1179
+ return t.kind;
1180
+ }
1181
+
1182
+ module.exports = {
1183
+ request: request,
1184
+ configurePool: configurePool,
1185
+ DEFAULT_CONTROL_PLANE_CAP: DEFAULT_CONTROL_PLANE_CAP,
1186
+ DEFAULT_GET_CAP: DEFAULT_GET_CAP,
1187
+ DEFAULT_AGENT_OPTS: DEFAULT_AGENT_OPTS,
1188
+ _resetForTest: _resetForTest,
1189
+ _getCachedTransportCount: _getCachedTransportCount,
1190
+ _getCachedTransportKind: _getCachedTransportKind,
1191
+ // Test-only — exposes the SSRF-pinned DNS lookup builder so unit
1192
+ // tests can confirm the callback shape matches Node's documented
1193
+ // `lookup(hostname, options, callback)` contract.
1194
+ _pinnedLookupForTest: _pinnedLookupFor,
1195
+ };