@blamejs/core 0.7.4 → 0.7.18

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 (180) hide show
  1. package/CHANGELOG.md +423 -395
  2. package/README.md +150 -149
  3. package/bin/blamejs.js +0 -0
  4. package/index.js +308 -284
  5. package/lib/api-key.js +660 -663
  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.js +311 -311
  16. package/lib/auth/lockout.js +436 -436
  17. package/lib/auth/oauth.js +721 -721
  18. package/lib/auth/passkey.js +181 -181
  19. package/lib/auth/password.js +594 -594
  20. package/lib/backup/bundle.js +217 -217
  21. package/lib/backup/crypto.js +176 -176
  22. package/lib/backup/index.js +515 -515
  23. package/lib/backup/manifest.js +282 -282
  24. package/lib/break-glass.js +1338 -1338
  25. package/lib/bundler.js +441 -441
  26. package/lib/cache-redis.js +256 -256
  27. package/lib/cache.js +1206 -1206
  28. package/lib/canonical-json.js +115 -115
  29. package/lib/chain-writer.js +234 -234
  30. package/lib/cli-helpers.js +206 -206
  31. package/lib/cli.js +2334 -2334
  32. package/lib/cluster-provider-db.js +317 -317
  33. package/lib/cluster-storage.js +226 -226
  34. package/lib/cluster.js +703 -703
  35. package/lib/codepoint-class.js +196 -0
  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 +315 -315
  40. package/lib/credential-hash.js +322 -322
  41. package/lib/crypto.js +266 -266
  42. package/lib/csv.js +275 -286
  43. package/lib/db-declare-row-policy.js +267 -267
  44. package/lib/db-declare-view.js +420 -421
  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/file-upload.js +213 -10
  56. package/lib/forms.js +422 -422
  57. package/lib/framework-error.js +293 -215
  58. package/lib/framework-schema.js +717 -717
  59. package/lib/gate-contract.js +971 -0
  60. package/lib/guard-all.js +405 -0
  61. package/lib/guard-archive.js +739 -0
  62. package/lib/guard-csv.js +816 -0
  63. package/lib/guard-email.js +744 -0
  64. package/lib/guard-filename.js +724 -0
  65. package/lib/guard-html.js +976 -0
  66. package/lib/guard-json.js +729 -0
  67. package/lib/guard-markdown.js +586 -0
  68. package/lib/guard-svg.js +976 -0
  69. package/lib/guard-xml.js +405 -0
  70. package/lib/guard-yaml.js +529 -0
  71. package/lib/handlers.js +350 -350
  72. package/lib/http-client-cookie-jar.js +508 -508
  73. package/lib/http-client.js +1195 -1195
  74. package/lib/i18n.js +878 -878
  75. package/lib/jobs.js +185 -185
  76. package/lib/log-stream-cloudwatch.js +369 -369
  77. package/lib/log-stream-local.js +146 -146
  78. package/lib/log-stream-otlp-grpc.js +410 -410
  79. package/lib/log-stream-otlp.js +286 -286
  80. package/lib/log-stream-syslog.js +302 -302
  81. package/lib/log-stream-webhook.js +199 -199
  82. package/lib/log-stream.js +330 -330
  83. package/lib/log.js +500 -500
  84. package/lib/mail-bounce.js +528 -528
  85. package/lib/mail-dkim.js +369 -362
  86. package/lib/mail.js +981 -962
  87. package/lib/metrics.js +683 -683
  88. package/lib/middleware/api-encrypt.js +936 -936
  89. package/lib/middleware/attach-user.js +157 -157
  90. package/lib/middleware/body-parser.js +1170 -1091
  91. package/lib/middleware/bot-guard.js +178 -178
  92. package/lib/middleware/compression.js +452 -452
  93. package/lib/middleware/cors.js +314 -314
  94. package/lib/middleware/csp-nonce.js +348 -348
  95. package/lib/middleware/csrf-protect.js +316 -316
  96. package/lib/middleware/db-role-for.js +264 -264
  97. package/lib/middleware/health.js +392 -392
  98. package/lib/middleware/index.js +79 -79
  99. package/lib/middleware/rate-limit.js +358 -358
  100. package/lib/middleware/request-id.js +61 -61
  101. package/lib/middleware/request-log.js +168 -168
  102. package/lib/middleware/require-auth.js +104 -104
  103. package/lib/middleware/security-headers.js +116 -116
  104. package/lib/middleware/sse.js +166 -166
  105. package/lib/migrations.js +383 -383
  106. package/lib/mtls-ca.js +518 -518
  107. package/lib/mtls-engine-default.js +481 -481
  108. package/lib/network-dns.js +632 -632
  109. package/lib/network-heartbeat.js +290 -290
  110. package/lib/network-nts.js +574 -574
  111. package/lib/network-proxy.js +265 -265
  112. package/lib/network-tls.js +328 -328
  113. package/lib/network.js +233 -233
  114. package/lib/notify.js +612 -612
  115. package/lib/ntp-check.js +229 -229
  116. package/lib/numeric-bounds.js +111 -91
  117. package/lib/object-store/azure-blob-bucket-ops.js +349 -349
  118. package/lib/object-store/azure-blob.js +488 -488
  119. package/lib/object-store/gcs-bucket-ops.js +351 -351
  120. package/lib/object-store/gcs.js +519 -519
  121. package/lib/object-store/http-put.js +153 -153
  122. package/lib/object-store/index.js +197 -197
  123. package/lib/object-store/sigv4-bucket-ops.js +1092 -1092
  124. package/lib/object-store/sigv4.js +903 -903
  125. package/lib/observability.js +151 -151
  126. package/lib/otel-export.js +269 -269
  127. package/lib/pagination.js +464 -464
  128. package/lib/parsers/index.js +80 -80
  129. package/lib/parsers/safe-env.js +642 -642
  130. package/lib/parsers/safe-ini.js +292 -292
  131. package/lib/parsers/safe-toml.js +784 -784
  132. package/lib/parsers/safe-xml.js +390 -390
  133. package/lib/parsers/safe-yaml.js +1015 -1015
  134. package/lib/permissions.js +708 -708
  135. package/lib/pqc-agent.js +87 -87
  136. package/lib/pqc-gate.js +279 -279
  137. package/lib/protobuf-encoder.js +190 -190
  138. package/lib/protocol-dispatcher.js +161 -161
  139. package/lib/pubsub-redis.js +167 -167
  140. package/lib/pubsub.js +429 -429
  141. package/lib/queue-local.js +476 -476
  142. package/lib/queue-redis.js +745 -745
  143. package/lib/queue-sqs.js +319 -319
  144. package/lib/queue.js +695 -695
  145. package/lib/redis-client.js +519 -519
  146. package/lib/request-helpers.js +340 -340
  147. package/lib/restore-bundle.js +237 -237
  148. package/lib/restore-rollback.js +259 -259
  149. package/lib/restore.js +409 -409
  150. package/lib/retry.js +376 -376
  151. package/lib/router.js +748 -748
  152. package/lib/safe-async.js +735 -735
  153. package/lib/safe-buffer.js +237 -237
  154. package/lib/safe-json.js +541 -541
  155. package/lib/safe-schema.js +1266 -1266
  156. package/lib/safe-url.js +159 -159
  157. package/lib/scheduler.js +706 -706
  158. package/lib/security-assert.js +373 -373
  159. package/lib/seeders.js +618 -618
  160. package/lib/session.js +478 -478
  161. package/lib/slug.js +269 -269
  162. package/lib/ssrf-guard.js +401 -401
  163. package/lib/static.js +184 -4
  164. package/lib/storage.js +471 -471
  165. package/lib/subject.js +281 -281
  166. package/lib/template.js +791 -791
  167. package/lib/testing.js +798 -798
  168. package/lib/time.js +310 -310
  169. package/lib/totp.js +302 -302
  170. package/lib/tracing.js +494 -494
  171. package/lib/uuid.js +132 -132
  172. package/lib/validate-opts.js +340 -319
  173. package/lib/vault/index.js +308 -308
  174. package/lib/vault/rotate.js +784 -784
  175. package/lib/vault/wrap.js +296 -296
  176. package/lib/vendor/noble-ciphers.cjs +9 -9
  177. package/lib/webhook.js +595 -595
  178. package/lib/websocket.js +1048 -1048
  179. package/package.json +77 -77
  180. 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
+ };