@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 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;
@@ -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) return res;
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; // 3xx with no Location — operator handles
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
- req.end(opts.body);
651
- } else if (typeof opts.body === "string") {
652
- req.end(Buffer.from(opts.body, "utf8"));
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();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.4.15",
3
+ "version": "0.4.17",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",