@blamejs/core 0.4.15 → 0.4.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +2 -0
- package/index.js +1 -0
- package/lib/http-client-cookie-jar.js +400 -0
- package/lib/http-client.js +182 -8
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.4.x
|
|
10
10
|
|
|
11
|
+
- **0.4.16** (2026-04-30) — b.httpClient: interceptors + progress events
|
|
12
|
+
- **0.4.15** (2026-04-30) — b.httpClient: redirect-following + outbound multipart
|
|
11
13
|
- **0.4.14** (2026-04-30) — b.i18n: lazy locales + ordinal plurals + onMissingKey hook
|
|
12
14
|
- **0.4.13** (2026-04-30) — b.db: streaming query results
|
|
13
15
|
- **0.4.12** (2026-04-30) — b.log: multi-sink output with per-sink level filtering
|
package/index.js
CHANGED
|
@@ -83,6 +83,7 @@ var httpClient = require("./lib/http-client");
|
|
|
83
83
|
// the bare `b.httpClient.request(...)`. The api-encrypt module owns the
|
|
84
84
|
// implementation; httpClient stays free of an api-encrypt dependency.
|
|
85
85
|
httpClient.encrypted = require("./lib/middleware/api-encrypt").httpClient;
|
|
86
|
+
httpClient.cookieJar = require("./lib/http-client-cookie-jar");
|
|
86
87
|
var websocket = require("./lib/websocket");
|
|
87
88
|
var safeUrl = require("./lib/safe-url");
|
|
88
89
|
var ssrfGuard = require("./lib/ssrf-guard");
|
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* b.httpClient.cookieJar — outbound HTTP cookie store.
|
|
4
|
+
*
|
|
5
|
+
* Holds Set-Cookie state across requests so successive calls in a flow
|
|
6
|
+
* (login → list → mutate → logout, OAuth code-exchange → userinfo, etc.)
|
|
7
|
+
* carry the right Cookie header without operators threading it by hand.
|
|
8
|
+
* RFC 6265 attribute coverage: Domain / Path / Expires / Max-Age /
|
|
9
|
+
* HttpOnly / Secure / SameSite. Public Suffix List awareness is
|
|
10
|
+
* deliberately deferred — operators wiring jars against trusted
|
|
11
|
+
* domains don't need it; re-open if a real cross-eTLD bug surfaces.
|
|
12
|
+
*
|
|
13
|
+
* var jar = b.httpClient.cookieJar.create(); // in-memory
|
|
14
|
+
* await b.httpClient.request({ url: loginUrl, method: "POST", body, jar });
|
|
15
|
+
* await b.httpClient.request({ url: meUrl, jar }); // session cookie attaches
|
|
16
|
+
*
|
|
17
|
+
* Vault-encrypted persistence — every cookie value is sealed via
|
|
18
|
+
* b.vault.seal before it lands in the jar's store, so a memory dump or
|
|
19
|
+
* core file doesn't expose plaintext values:
|
|
20
|
+
*
|
|
21
|
+
* var jar = b.httpClient.cookieJar.create({ persist: "vault", vault: b.vault });
|
|
22
|
+
*
|
|
23
|
+
* The jar's lifecycle is per-process — restart loses cookies regardless
|
|
24
|
+
* of persist mode. File-backed and cluster-shared persistence are
|
|
25
|
+
* deferred; operators wanting durability serialize via getAll() and
|
|
26
|
+
* restore via setFromSerialized() at boot.
|
|
27
|
+
*
|
|
28
|
+
* Outbound filtering follows RFC 6265 §5.4:
|
|
29
|
+
* - Domain: exact-host match by default; Domain attribute allows
|
|
30
|
+
* subdomain match (host must be a suffix of cookie.domain).
|
|
31
|
+
* - Path: request path must equal cookie.path or be path-below.
|
|
32
|
+
* - Secure: cookie only attaches when the request URL is https:.
|
|
33
|
+
* - Expiry: rows past Expires / Max-Age don't attach.
|
|
34
|
+
* - Sort: longer path first, then earlier creation time.
|
|
35
|
+
*
|
|
36
|
+
* Cookie shape returned from getAll():
|
|
37
|
+
*
|
|
38
|
+
* {
|
|
39
|
+
* name, value, domain, path, hostOnly,
|
|
40
|
+
* expiresAt, // unix ms, or null for session cookies
|
|
41
|
+
* httpOnly, secure, sameSite, // attributes
|
|
42
|
+
* createdAt, updatedAt,
|
|
43
|
+
* }
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
var safeUrl = require("./safe-url");
|
|
47
|
+
var { defineClass } = require("./framework-error");
|
|
48
|
+
|
|
49
|
+
var CookieJarError = defineClass("CookieJarError", { alwaysPermanent: true });
|
|
50
|
+
var _err = CookieJarError.factory;
|
|
51
|
+
|
|
52
|
+
var DEFAULTS = Object.freeze({
|
|
53
|
+
persist: "memory",
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
var VALID_PERSIST = new Set(["memory", "vault"]);
|
|
57
|
+
var VALID_SAMESITE = new Set(["Strict", "Lax", "None"]);
|
|
58
|
+
|
|
59
|
+
// ---- Set-Cookie parser ----
|
|
60
|
+
|
|
61
|
+
function _parseHttpDate(s) {
|
|
62
|
+
// node:Date handles RFC 1123 / 850 / asctime — sufficient for HTTP-date.
|
|
63
|
+
var t = Date.parse(s);
|
|
64
|
+
return isNaN(t) ? null : t;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function _parseSetCookie(line) {
|
|
68
|
+
if (typeof line !== "string" || line.length === 0) return null;
|
|
69
|
+
var semi = line.indexOf(";");
|
|
70
|
+
var head = (semi === -1 ? line : line.slice(0, semi)).trim();
|
|
71
|
+
var eq = head.indexOf("=");
|
|
72
|
+
if (eq <= 0) return null;
|
|
73
|
+
var name = head.slice(0, eq).trim();
|
|
74
|
+
var value = head.slice(eq + 1).trim();
|
|
75
|
+
if (!name) return null;
|
|
76
|
+
|
|
77
|
+
var attrs = {};
|
|
78
|
+
if (semi !== -1) {
|
|
79
|
+
var rest = line.slice(semi + 1);
|
|
80
|
+
var parts = rest.split(";");
|
|
81
|
+
for (var i = 0; i < parts.length; i++) {
|
|
82
|
+
var p = parts[i].trim();
|
|
83
|
+
if (!p) continue;
|
|
84
|
+
var pi = p.indexOf("=");
|
|
85
|
+
var k, v;
|
|
86
|
+
if (pi === -1) { k = p; v = ""; }
|
|
87
|
+
else { k = p.slice(0, pi).trim(); v = p.slice(pi + 1).trim(); }
|
|
88
|
+
attrs[k.toLowerCase()] = v;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return { name: name, value: value, attrs: attrs };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ---- Domain / Path matching ----
|
|
95
|
+
|
|
96
|
+
function _domainMatch(host, cookieDomain) {
|
|
97
|
+
if (host === cookieDomain) return true;
|
|
98
|
+
if (host.length > cookieDomain.length &&
|
|
99
|
+
host.endsWith(cookieDomain) &&
|
|
100
|
+
host.charAt(host.length - cookieDomain.length - 1) === ".") {
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function _pathMatch(reqPath, cookiePath) {
|
|
107
|
+
if (cookiePath === reqPath) return true;
|
|
108
|
+
if (reqPath.indexOf(cookiePath) === 0) {
|
|
109
|
+
if (cookiePath.charAt(cookiePath.length - 1) === "/") return true;
|
|
110
|
+
if (reqPath.charAt(cookiePath.length) === "/") return true;
|
|
111
|
+
}
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function _defaultPath(reqPath) {
|
|
116
|
+
// RFC 6265 §5.1.4: take everything up to the last "/", or "/" if none.
|
|
117
|
+
if (typeof reqPath !== "string" || reqPath.length === 0) return "/";
|
|
118
|
+
var qm = reqPath.indexOf("?");
|
|
119
|
+
var p = qm === -1 ? reqPath : reqPath.slice(0, qm);
|
|
120
|
+
if (p.charAt(0) !== "/") return "/";
|
|
121
|
+
var lastSlash = p.lastIndexOf("/");
|
|
122
|
+
if (lastSlash <= 0) return "/";
|
|
123
|
+
return p.slice(0, lastSlash);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ---- Public create ----
|
|
127
|
+
|
|
128
|
+
function create(opts) {
|
|
129
|
+
opts = opts || {};
|
|
130
|
+
var persist = opts.persist === undefined ? DEFAULTS.persist : opts.persist;
|
|
131
|
+
if (!VALID_PERSIST.has(persist)) {
|
|
132
|
+
throw _err("BAD_OPT", "cookieJar.create: persist must be 'memory' or 'vault', got " +
|
|
133
|
+
JSON.stringify(persist));
|
|
134
|
+
}
|
|
135
|
+
var vault = opts.vault || null;
|
|
136
|
+
if (persist === "vault") {
|
|
137
|
+
if (!vault || typeof vault.seal !== "function" || typeof vault.unseal !== "function") {
|
|
138
|
+
throw _err("BAD_OPT",
|
|
139
|
+
"cookieJar.create: persist: 'vault' requires opts.vault with seal/unseal (pass b.vault)");
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
var clock = typeof opts.clock === "function" ? opts.clock : Date.now;
|
|
143
|
+
|
|
144
|
+
// Storage map keyed by `<domain>|<path>|<name>` so a (domain, path)
|
|
145
|
+
// tuple can hold multiple cookies, but a same-tuple-same-name update
|
|
146
|
+
// replaces the prior row per RFC 6265 §5.3.
|
|
147
|
+
var store = new Map();
|
|
148
|
+
|
|
149
|
+
function _seal(plain) {
|
|
150
|
+
if (persist !== "vault" || plain === undefined || plain === null) return String(plain == null ? "" : plain);
|
|
151
|
+
return vault.seal(String(plain));
|
|
152
|
+
}
|
|
153
|
+
function _unseal(blob) {
|
|
154
|
+
if (persist !== "vault" || blob === undefined || blob === null) return blob == null ? "" : String(blob);
|
|
155
|
+
return String(vault.unseal(blob));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function _setOne(reqUrl, parsed) {
|
|
159
|
+
var u;
|
|
160
|
+
try { u = new URL(reqUrl); } catch (_e) { return; }
|
|
161
|
+
var host = u.hostname.toLowerCase();
|
|
162
|
+
var attrs = parsed.attrs || {};
|
|
163
|
+
|
|
164
|
+
// Domain attribute: lower-case, leading-dot stripped (RFC 6265bis).
|
|
165
|
+
var domainAttr = attrs.domain;
|
|
166
|
+
var domain;
|
|
167
|
+
var hostOnly;
|
|
168
|
+
if (domainAttr) {
|
|
169
|
+
var d = String(domainAttr).toLowerCase();
|
|
170
|
+
if (d.charAt(0) === ".") d = d.slice(1);
|
|
171
|
+
// Don't accept a Domain that the request host doesn't match.
|
|
172
|
+
if (!_domainMatch(host, d)) return;
|
|
173
|
+
domain = d;
|
|
174
|
+
hostOnly = false;
|
|
175
|
+
} else {
|
|
176
|
+
domain = host;
|
|
177
|
+
hostOnly = true;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
var path = (typeof attrs.path === "string" && attrs.path.charAt(0) === "/")
|
|
181
|
+
? attrs.path : _defaultPath(u.pathname);
|
|
182
|
+
|
|
183
|
+
// Expires / Max-Age. Max-Age wins when both present (RFC 6265 §5.2.2).
|
|
184
|
+
var now = clock();
|
|
185
|
+
var expiresAt = null;
|
|
186
|
+
if (attrs["max-age"] !== undefined) {
|
|
187
|
+
var maxAge = parseInt(attrs["max-age"], 10);
|
|
188
|
+
if (!isNaN(maxAge)) {
|
|
189
|
+
expiresAt = maxAge <= 0 ? 0 : (now + maxAge * 1000);
|
|
190
|
+
}
|
|
191
|
+
} else if (attrs.expires) {
|
|
192
|
+
expiresAt = _parseHttpDate(attrs.expires);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Max-Age=0 / past Expires → delete an existing matching row.
|
|
196
|
+
var key = domain + "|" + path + "|" + parsed.name;
|
|
197
|
+
if (expiresAt !== null && expiresAt <= now) {
|
|
198
|
+
store.delete(key);
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
var sameSiteRaw = attrs.samesite;
|
|
203
|
+
var sameSite = null;
|
|
204
|
+
if (typeof sameSiteRaw === "string") {
|
|
205
|
+
var ssLc = sameSiteRaw.toLowerCase();
|
|
206
|
+
if (ssLc === "strict") sameSite = "Strict";
|
|
207
|
+
else if (ssLc === "lax") sameSite = "Lax";
|
|
208
|
+
else if (ssLc === "none") sameSite = "None";
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
var prior = store.get(key);
|
|
212
|
+
store.set(key, {
|
|
213
|
+
name: parsed.name,
|
|
214
|
+
value: _seal(parsed.value),
|
|
215
|
+
domain: domain,
|
|
216
|
+
path: path,
|
|
217
|
+
hostOnly: hostOnly,
|
|
218
|
+
expiresAt: expiresAt,
|
|
219
|
+
httpOnly: Object.prototype.hasOwnProperty.call(attrs, "httponly"),
|
|
220
|
+
secure: Object.prototype.hasOwnProperty.call(attrs, "secure"),
|
|
221
|
+
sameSite: sameSite,
|
|
222
|
+
createdAt: prior ? prior.createdAt : now,
|
|
223
|
+
updatedAt: now,
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// ---- Public API ----
|
|
228
|
+
|
|
229
|
+
function setFromResponse(reqUrl, setCookieHeader) {
|
|
230
|
+
if (!setCookieHeader) return;
|
|
231
|
+
var lines = Array.isArray(setCookieHeader) ? setCookieHeader : [setCookieHeader];
|
|
232
|
+
for (var i = 0; i < lines.length; i++) {
|
|
233
|
+
var parsed = _parseSetCookie(lines[i]);
|
|
234
|
+
if (parsed) _setOne(reqUrl, parsed);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function cookieHeaderFor(reqUrl) {
|
|
239
|
+
var u;
|
|
240
|
+
try { u = new URL(reqUrl); } catch (_e) { return null; }
|
|
241
|
+
var host = u.hostname.toLowerCase();
|
|
242
|
+
var path = u.pathname || "/";
|
|
243
|
+
var isSecure = u.protocol === "https:";
|
|
244
|
+
var now = clock();
|
|
245
|
+
|
|
246
|
+
var matches = [];
|
|
247
|
+
for (var entry of store.values()) {
|
|
248
|
+
// Expiry
|
|
249
|
+
if (entry.expiresAt !== null && entry.expiresAt <= now) continue;
|
|
250
|
+
// Domain
|
|
251
|
+
if (entry.hostOnly) {
|
|
252
|
+
if (entry.domain !== host) continue;
|
|
253
|
+
} else {
|
|
254
|
+
if (!_domainMatch(host, entry.domain)) continue;
|
|
255
|
+
}
|
|
256
|
+
// Path
|
|
257
|
+
if (!_pathMatch(path, entry.path)) continue;
|
|
258
|
+
// Secure
|
|
259
|
+
if (entry.secure && !isSecure) continue;
|
|
260
|
+
matches.push(entry);
|
|
261
|
+
}
|
|
262
|
+
if (matches.length === 0) return null;
|
|
263
|
+
|
|
264
|
+
// Sort: longer path first, then earlier creation time.
|
|
265
|
+
matches.sort(function (a, b) {
|
|
266
|
+
if (a.path.length !== b.path.length) return b.path.length - a.path.length;
|
|
267
|
+
return a.createdAt - b.createdAt;
|
|
268
|
+
});
|
|
269
|
+
var pieces = matches.map(function (e) {
|
|
270
|
+
return e.name + "=" + _unseal(e.value);
|
|
271
|
+
});
|
|
272
|
+
return pieces.join("; ");
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function getAll() {
|
|
276
|
+
var now = clock();
|
|
277
|
+
var out = [];
|
|
278
|
+
for (var entry of store.values()) {
|
|
279
|
+
if (entry.expiresAt !== null && entry.expiresAt <= now) continue;
|
|
280
|
+
out.push({
|
|
281
|
+
name: entry.name,
|
|
282
|
+
value: _unseal(entry.value),
|
|
283
|
+
domain: entry.domain,
|
|
284
|
+
path: entry.path,
|
|
285
|
+
hostOnly: entry.hostOnly,
|
|
286
|
+
expiresAt: entry.expiresAt,
|
|
287
|
+
httpOnly: entry.httpOnly,
|
|
288
|
+
secure: entry.secure,
|
|
289
|
+
sameSite: entry.sameSite,
|
|
290
|
+
createdAt: entry.createdAt,
|
|
291
|
+
updatedAt: entry.updatedAt,
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
return out;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function clear(filter) {
|
|
298
|
+
if (!filter) {
|
|
299
|
+
var n = store.size;
|
|
300
|
+
store.clear();
|
|
301
|
+
return n;
|
|
302
|
+
}
|
|
303
|
+
if (typeof filter !== "object") {
|
|
304
|
+
throw _err("BAD_OPT", "cookieJar.clear: filter must be an object or undefined");
|
|
305
|
+
}
|
|
306
|
+
var purged = 0;
|
|
307
|
+
var keysToDelete = [];
|
|
308
|
+
for (var pair of store.entries()) {
|
|
309
|
+
var key = pair[0];
|
|
310
|
+
var entry = pair[1];
|
|
311
|
+
if (filter.domain && entry.domain !== filter.domain) continue;
|
|
312
|
+
if (filter.name && entry.name !== filter.name) continue;
|
|
313
|
+
if (filter.path && entry.path !== filter.path) continue;
|
|
314
|
+
keysToDelete.push(key);
|
|
315
|
+
}
|
|
316
|
+
for (var i = 0; i < keysToDelete.length; i++) {
|
|
317
|
+
store.delete(keysToDelete[i]);
|
|
318
|
+
purged++;
|
|
319
|
+
}
|
|
320
|
+
return purged;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function size() {
|
|
324
|
+
var now = clock();
|
|
325
|
+
var n = 0;
|
|
326
|
+
for (var entry of store.values()) {
|
|
327
|
+
if (entry.expiresAt !== null && entry.expiresAt <= now) continue;
|
|
328
|
+
n++;
|
|
329
|
+
}
|
|
330
|
+
return n;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Round-trip helpers — operators with restart-survival needs serialize
|
|
334
|
+
// via getAll(), persist however they like, restore via setFromSerialized.
|
|
335
|
+
function setFromSerialized(rows) {
|
|
336
|
+
if (!Array.isArray(rows)) {
|
|
337
|
+
throw _err("BAD_OPT", "cookieJar.setFromSerialized: rows must be an array");
|
|
338
|
+
}
|
|
339
|
+
var now = clock();
|
|
340
|
+
for (var i = 0; i < rows.length; i++) {
|
|
341
|
+
var r = rows[i];
|
|
342
|
+
if (!r || typeof r.name !== "string" || typeof r.domain !== "string" || typeof r.path !== "string") continue;
|
|
343
|
+
var key = r.domain + "|" + r.path + "|" + r.name;
|
|
344
|
+
if (r.expiresAt !== null && r.expiresAt !== undefined && r.expiresAt <= now) continue;
|
|
345
|
+
store.set(key, {
|
|
346
|
+
name: r.name,
|
|
347
|
+
value: _seal(r.value),
|
|
348
|
+
domain: r.domain,
|
|
349
|
+
path: r.path,
|
|
350
|
+
hostOnly: !!r.hostOnly,
|
|
351
|
+
expiresAt: typeof r.expiresAt === "number" ? r.expiresAt : null,
|
|
352
|
+
httpOnly: !!r.httpOnly,
|
|
353
|
+
secure: !!r.secure,
|
|
354
|
+
sameSite: VALID_SAMESITE.has(r.sameSite) ? r.sameSite : null,
|
|
355
|
+
createdAt: typeof r.createdAt === "number" ? r.createdAt : now,
|
|
356
|
+
updatedAt: typeof r.updatedAt === "number" ? r.updatedAt : now,
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// Raw-store accessor for tests — returns the literal Map entries with
|
|
362
|
+
// the value field as it sits in memory (sealed when persist === "vault").
|
|
363
|
+
// Operators don't call this; if they need stored state, getAll() returns
|
|
364
|
+
// the unsealed form. Exposed so the no-plaintext assertion is verifiable.
|
|
365
|
+
function _storeForTest() {
|
|
366
|
+
var rows = [];
|
|
367
|
+
for (var entry of store.values()) {
|
|
368
|
+
rows.push({
|
|
369
|
+
name: entry.name,
|
|
370
|
+
valueRaw: entry.value,
|
|
371
|
+
domain: entry.domain,
|
|
372
|
+
path: entry.path,
|
|
373
|
+
expiresAt: entry.expiresAt,
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
return rows;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
return {
|
|
380
|
+
setFromResponse: setFromResponse,
|
|
381
|
+
cookieHeaderFor: cookieHeaderFor,
|
|
382
|
+
getAll: getAll,
|
|
383
|
+
clear: clear,
|
|
384
|
+
size: size,
|
|
385
|
+
setFromSerialized: setFromSerialized,
|
|
386
|
+
persist: persist,
|
|
387
|
+
_storeForTest: _storeForTest,
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
module.exports = {
|
|
392
|
+
create: create,
|
|
393
|
+
CookieJarError: CookieJarError,
|
|
394
|
+
DEFAULTS: DEFAULTS,
|
|
395
|
+
// Exposed for tests + advanced operator wiring.
|
|
396
|
+
_parseSetCookie: _parseSetCookie,
|
|
397
|
+
};
|
|
398
|
+
// safeUrl reserved for future scheme validation hooks (e.g. operator-supplied
|
|
399
|
+
// allowedProtocols filter on cookie attachment paths).
|
|
400
|
+
void safeUrl;
|
package/lib/http-client.js
CHANGED
|
@@ -294,6 +294,28 @@ function _fromH2Headers(h2Headers) {
|
|
|
294
294
|
|
|
295
295
|
var REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
|
|
296
296
|
|
|
297
|
+
// http-client-cookie-jar is exposed on b.httpClient.cookieJar via index.js.
|
|
298
|
+
// http-client itself only consumes the jar shape passed by operators via
|
|
299
|
+
// the request opt; no direct require is needed here.
|
|
300
|
+
|
|
301
|
+
// Merge a jar-derived Cookie header with any caller-supplied Cookie
|
|
302
|
+
// header. Operators who override Cookie explicitly always win — the jar
|
|
303
|
+
// supplements rather than replaces.
|
|
304
|
+
function _attachJarCookie(headers, jar, url) {
|
|
305
|
+
if (!jar) return headers;
|
|
306
|
+
var jarHeader = jar.cookieHeaderFor(url);
|
|
307
|
+
if (!jarHeader) return headers;
|
|
308
|
+
var merged = Object.assign({}, headers || {});
|
|
309
|
+
var existing = null;
|
|
310
|
+
var keys = Object.keys(merged);
|
|
311
|
+
for (var i = 0; i < keys.length; i++) {
|
|
312
|
+
if (keys[i].toLowerCase() === "cookie") { existing = keys[i]; break; }
|
|
313
|
+
}
|
|
314
|
+
if (existing) merged[existing] = merged[existing] + "; " + jarHeader;
|
|
315
|
+
else merged.Cookie = jarHeader;
|
|
316
|
+
return merged;
|
|
317
|
+
}
|
|
318
|
+
|
|
297
319
|
// Build a multipart/form-data body from { fields, files } shape.
|
|
298
320
|
// Mirrors the wire format that lib/middleware/body-parser.js's multipart
|
|
299
321
|
// parser accepts so round-trip from one blamejs app's outbound to
|
|
@@ -375,6 +397,58 @@ function request(opts) {
|
|
|
375
397
|
return Promise.reject(_makeError(opts && opts.errorClass, "BAD_ARG", "url is required", true));
|
|
376
398
|
}
|
|
377
399
|
|
|
400
|
+
// Validate before/after shapes early — Tier-A throw if the operator
|
|
401
|
+
// passed something un-callable so the bug surfaces at the call site
|
|
402
|
+
// rather than inside the request loop.
|
|
403
|
+
if (opts.before !== undefined) {
|
|
404
|
+
if (!Array.isArray(opts.before) || !opts.before.every(function (f) { return typeof f === "function"; })) {
|
|
405
|
+
return Promise.reject(_makeError(opts.errorClass, "BAD_ARG",
|
|
406
|
+
"before must be an array of functions", true));
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
if (opts.after !== undefined) {
|
|
410
|
+
if (!Array.isArray(opts.after) || !opts.after.every(function (f) { return typeof f === "function"; })) {
|
|
411
|
+
return Promise.reject(_makeError(opts.errorClass, "BAD_ARG",
|
|
412
|
+
"after must be an array of functions", true));
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
if (opts.onUploadProgress !== undefined && typeof opts.onUploadProgress !== "function") {
|
|
416
|
+
return Promise.reject(_makeError(opts.errorClass, "BAD_ARG",
|
|
417
|
+
"onUploadProgress must be a function", true));
|
|
418
|
+
}
|
|
419
|
+
if (opts.onDownloadProgress !== undefined && typeof opts.onDownloadProgress !== "function") {
|
|
420
|
+
return Promise.reject(_makeError(opts.errorClass, "BAD_ARG",
|
|
421
|
+
"onDownloadProgress must be a function", true));
|
|
422
|
+
}
|
|
423
|
+
if (opts.jar !== undefined && opts.jar !== null) {
|
|
424
|
+
if (typeof opts.jar !== "object" ||
|
|
425
|
+
typeof opts.jar.cookieHeaderFor !== "function" ||
|
|
426
|
+
typeof opts.jar.setFromResponse !== "function") {
|
|
427
|
+
return Promise.reject(_makeError(opts.errorClass, "BAD_ARG",
|
|
428
|
+
"jar must be a b.httpClient.cookieJar.create() instance", true));
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// before interceptors — run in array order. Each may return a modified
|
|
433
|
+
// opts object (or return nothing to leave the running opts as-is).
|
|
434
|
+
// Caller-set defaults / observability / auth header injection lands
|
|
435
|
+
// here. Synchronous to keep the request hot path simple; async
|
|
436
|
+
// pre-flight work (e.g. token refresh) belongs in the route handler
|
|
437
|
+
// before httpClient.request is even called.
|
|
438
|
+
if (Array.isArray(opts.before) && opts.before.length > 0) {
|
|
439
|
+
var working = opts;
|
|
440
|
+
for (var bi = 0; bi < opts.before.length; bi++) {
|
|
441
|
+
var ret;
|
|
442
|
+
try { ret = opts.before[bi](working); }
|
|
443
|
+
catch (e) {
|
|
444
|
+
return Promise.reject(_makeError(opts.errorClass, "BEFORE_THREW",
|
|
445
|
+
"before[" + bi + "] threw: " + ((e && e.message) || String(e)), true));
|
|
446
|
+
}
|
|
447
|
+
if (ret && typeof ret === "object") working = ret;
|
|
448
|
+
}
|
|
449
|
+
opts = working;
|
|
450
|
+
}
|
|
451
|
+
|
|
378
452
|
// Multipart shorthand: { multipart: { fields, files } } expands to
|
|
379
453
|
// body + Content-Type with the boundary parameter. Mutually exclusive
|
|
380
454
|
// with caller-supplied body / Content-Type.
|
|
@@ -413,11 +487,23 @@ function request(opts) {
|
|
|
413
487
|
"maxRedirects must be a non-negative integer or null", true));
|
|
414
488
|
}
|
|
415
489
|
}
|
|
490
|
+
var afterChain = (Array.isArray(opts.after) && opts.after.length > 0) ? opts.after : null;
|
|
491
|
+
function _runAfter(finalOpts, res) {
|
|
492
|
+
if (!afterChain) return res;
|
|
493
|
+
for (var ai = 0; ai < afterChain.length; ai++) {
|
|
494
|
+
try { afterChain[ai](finalOpts, res); }
|
|
495
|
+
catch (_e) { /* after hooks are best-effort — never break the response */ }
|
|
496
|
+
}
|
|
497
|
+
return res;
|
|
498
|
+
}
|
|
499
|
+
|
|
416
500
|
if (maxRedirects === null || maxRedirects === 0) {
|
|
417
|
-
return _requestSingle(opts);
|
|
501
|
+
return _requestSingle(opts).then(function (res) { return _runAfter(opts, res); });
|
|
418
502
|
}
|
|
419
503
|
|
|
420
|
-
return _requestWithRedirects(opts, maxRedirects)
|
|
504
|
+
return _requestWithRedirects(opts, maxRedirects).then(function (boxed) {
|
|
505
|
+
return _runAfter(boxed.finalOpts, boxed.res);
|
|
506
|
+
});
|
|
421
507
|
}
|
|
422
508
|
|
|
423
509
|
function _requestWithRedirects(opts, hopsLeft) {
|
|
@@ -431,9 +517,11 @@ function _requestWithRedirects(opts, hopsLeft) {
|
|
|
431
517
|
var current = Object.assign({}, opts, { _resolveOnRedirect: true });
|
|
432
518
|
function _follow() {
|
|
433
519
|
return _requestSingle(current).then(function (res) {
|
|
434
|
-
if (!REDIRECT_STATUSES.has(res.statusCode) || hopsLeft <= 0)
|
|
520
|
+
if (!REDIRECT_STATUSES.has(res.statusCode) || hopsLeft <= 0) {
|
|
521
|
+
return { finalOpts: current, res: res };
|
|
522
|
+
}
|
|
435
523
|
var loc = res.headers && (res.headers.location || res.headers.Location);
|
|
436
|
-
if (!loc) return res;
|
|
524
|
+
if (!loc) return { finalOpts: current, res: res }; // 3xx with no Location — operator handles
|
|
437
525
|
hopsLeft -= 1;
|
|
438
526
|
|
|
439
527
|
// Resolve relative Location against the just-fetched URL (the URL
|
|
@@ -499,6 +587,17 @@ function _requestSingle(opts) {
|
|
|
499
587
|
return Promise.reject(e);
|
|
500
588
|
}
|
|
501
589
|
|
|
590
|
+
// Attach jar-derived Cookie header BEFORE the request fires; record
|
|
591
|
+
// Set-Cookie response headers AFTER. Both halves run when opts.jar
|
|
592
|
+
// is set; redirect-following naturally re-runs both paths per hop
|
|
593
|
+
// because each hop calls _requestSingle.
|
|
594
|
+
if (opts.jar) {
|
|
595
|
+
var headersWithJar = _attachJarCookie(opts.headers, opts.jar, opts.url);
|
|
596
|
+
if (headersWithJar !== opts.headers) {
|
|
597
|
+
opts = Object.assign({}, opts, { headers: headersWithJar });
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
502
601
|
// SSRF gate — refuse private / loopback / link-local / cloud-metadata
|
|
503
602
|
// / reserved IP destinations by default. Operators on internal mesh
|
|
504
603
|
// pass `allowInternal: true` (or a CIDR list for narrower bypass).
|
|
@@ -562,9 +661,34 @@ function _requestH1(transport, u, opts) {
|
|
|
562
661
|
function _resolve(value) { if (!settled) { settled = true; resolve(value); } }
|
|
563
662
|
function _reject(err) { if (!settled) { settled = true; reject(err); } }
|
|
564
663
|
|
|
664
|
+
var onUploadProgress = typeof opts.onUploadProgress === "function" ? opts.onUploadProgress : null;
|
|
665
|
+
var onDownloadProgress = typeof opts.onDownloadProgress === "function" ? opts.onDownloadProgress : null;
|
|
666
|
+
|
|
565
667
|
var req = transport.lib.request(reqOpts, function (res) {
|
|
566
668
|
if (observer) observer("response:headers", { statusCode: res.statusCode, headers: res.headers });
|
|
567
669
|
|
|
670
|
+
// Save Set-Cookie into the jar (if wired) BEFORE delivering the
|
|
671
|
+
// response object — operator inspecting the response can already
|
|
672
|
+
// count on the jar carrying the new state.
|
|
673
|
+
if (opts.jar && res.headers && res.headers["set-cookie"]) {
|
|
674
|
+
try { opts.jar.setFromResponse(opts.url, res.headers["set-cookie"]); }
|
|
675
|
+
catch (_e) { /* jar is best-effort — never break the response */ }
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
// Download total: Content-Length when present, null otherwise.
|
|
679
|
+
var dlTotal = null;
|
|
680
|
+
if (res.headers && typeof res.headers["content-length"] === "string") {
|
|
681
|
+
var cl = parseInt(res.headers["content-length"], 10);
|
|
682
|
+
if (!isNaN(cl) && cl >= 0) dlTotal = cl;
|
|
683
|
+
}
|
|
684
|
+
var dlLoaded = 0;
|
|
685
|
+
function _emitDownload(chunkBytes) {
|
|
686
|
+
if (!onDownloadProgress) return;
|
|
687
|
+
dlLoaded += chunkBytes;
|
|
688
|
+
try { onDownloadProgress({ loaded: dlLoaded, total: dlTotal }); }
|
|
689
|
+
catch (_e) { /* progress hooks are best-effort */ }
|
|
690
|
+
}
|
|
691
|
+
|
|
568
692
|
if (responseMode === "stream") {
|
|
569
693
|
if (res.statusCode >= 400) {
|
|
570
694
|
res.resume();
|
|
@@ -572,6 +696,17 @@ function _requestH1(transport, u, opts) {
|
|
|
572
696
|
"HTTP " + res.statusCode + " " + (res.statusMessage || ""),
|
|
573
697
|
_isPermanentStatus(res.statusCode), res.statusCode));
|
|
574
698
|
}
|
|
699
|
+
if (onDownloadProgress) {
|
|
700
|
+
// Wrap the stream so chunks emit progress to the operator.
|
|
701
|
+
// The framework's contract is to hand back the response stream
|
|
702
|
+
// unmodified; fix-up via a passthrough keeps that contract while
|
|
703
|
+
// observing the chunk sizes.
|
|
704
|
+
var passthrough = new (require("node:stream").PassThrough)();
|
|
705
|
+
res.on("data", function (chunk) { _emitDownload(chunk.length); passthrough.write(chunk); });
|
|
706
|
+
res.on("end", function () { passthrough.end(); });
|
|
707
|
+
res.on("error", function (e) { passthrough.destroy(e); });
|
|
708
|
+
return _resolve({ statusCode: res.statusCode, headers: res.headers, body: passthrough });
|
|
709
|
+
}
|
|
575
710
|
return _resolve({ statusCode: res.statusCode, headers: res.headers, body: res });
|
|
576
711
|
}
|
|
577
712
|
|
|
@@ -586,7 +721,9 @@ function _requestH1(transport, u, opts) {
|
|
|
586
721
|
req.destroy();
|
|
587
722
|
_reject(_makeError(opts.errorClass, "RESPONSE_TOO_LARGE",
|
|
588
723
|
"response body exceeds " + maxResponseBytes + " bytes", true));
|
|
724
|
+
return;
|
|
589
725
|
}
|
|
726
|
+
_emitDownload(chunk.length);
|
|
590
727
|
});
|
|
591
728
|
res.on("end", function () {
|
|
592
729
|
if (capExceeded) return;
|
|
@@ -639,17 +776,47 @@ function _requestH1(transport, u, opts) {
|
|
|
639
776
|
signal.addEventListener("abort", onAbort, { once: true });
|
|
640
777
|
}
|
|
641
778
|
|
|
779
|
+
// Upload progress: emit { loaded, total } as body bytes go to the
|
|
780
|
+
// wire. Buffer / string bodies are sliced into chunks ourselves so
|
|
781
|
+
// operators see incremental progress; Readable bodies emit on each
|
|
782
|
+
// 'data' event from the source stream.
|
|
783
|
+
var ulTotal = null;
|
|
784
|
+
if (Buffer.isBuffer(opts.body)) ulTotal = opts.body.length;
|
|
785
|
+
else if (typeof opts.body === "string") ulTotal = Buffer.byteLength(opts.body, "utf8");
|
|
786
|
+
var ulLoaded = 0;
|
|
787
|
+
function _emitUpload(chunkBytes) {
|
|
788
|
+
if (!onUploadProgress) return;
|
|
789
|
+
ulLoaded += chunkBytes;
|
|
790
|
+
try { onUploadProgress({ loaded: ulLoaded, total: ulTotal }); }
|
|
791
|
+
catch (_e) { /* progress hooks are best-effort */ }
|
|
792
|
+
}
|
|
793
|
+
|
|
642
794
|
if (opts.body && typeof opts.body.pipe === "function") {
|
|
795
|
+
if (onUploadProgress) {
|
|
796
|
+
opts.body.on("data", function (c) { _emitUpload(c.length); });
|
|
797
|
+
}
|
|
643
798
|
opts.body.on("error", function (e) {
|
|
644
799
|
try { req.destroy(); } catch (_) {}
|
|
645
800
|
_reject(_makeError(opts.errorClass, "REQ_BODY_ERROR",
|
|
646
801
|
"request body stream error: " + e.message, false));
|
|
647
802
|
});
|
|
648
803
|
opts.body.pipe(req);
|
|
649
|
-
} else if (Buffer.isBuffer(opts.body)) {
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
804
|
+
} else if (Buffer.isBuffer(opts.body) || typeof opts.body === "string") {
|
|
805
|
+
var bodyBuf = Buffer.isBuffer(opts.body) ? opts.body : Buffer.from(opts.body, "utf8");
|
|
806
|
+
if (onUploadProgress) {
|
|
807
|
+
// Chunked write so progress reports land before req.end().
|
|
808
|
+
var CHUNK = 64 * 1024;
|
|
809
|
+
var off = 0;
|
|
810
|
+
while (off < bodyBuf.length) {
|
|
811
|
+
var slice = bodyBuf.slice(off, Math.min(off + CHUNK, bodyBuf.length));
|
|
812
|
+
req.write(slice);
|
|
813
|
+
_emitUpload(slice.length);
|
|
814
|
+
off += slice.length;
|
|
815
|
+
}
|
|
816
|
+
req.end();
|
|
817
|
+
} else {
|
|
818
|
+
req.end(bodyBuf);
|
|
819
|
+
}
|
|
653
820
|
} else {
|
|
654
821
|
req.end();
|
|
655
822
|
}
|
|
@@ -707,6 +874,13 @@ function _requestH2(transport, u, opts) {
|
|
|
707
874
|
|
|
708
875
|
if (observer) observer("response:headers", { statusCode: statusCode, headers: responseHeaders });
|
|
709
876
|
|
|
877
|
+
// Save Set-Cookie to the jar (h2 set-cookie comes through as
|
|
878
|
+
// either a single string or array, same shape as h1).
|
|
879
|
+
if (opts.jar && responseHeaders["set-cookie"]) {
|
|
880
|
+
try { opts.jar.setFromResponse(opts.url, responseHeaders["set-cookie"]); }
|
|
881
|
+
catch (_e) { /* jar best-effort */ }
|
|
882
|
+
}
|
|
883
|
+
|
|
710
884
|
if (responseMode === "stream") {
|
|
711
885
|
if (statusCode >= 400) {
|
|
712
886
|
stream.resume();
|