@blamejs/core 0.4.14 → 0.4.16

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.15** (2026-04-30) — b.httpClient: redirect-following + outbound multipart
12
+ - **0.4.14** (2026-04-30) — b.i18n: lazy locales + ordinal plurals + onMissingKey hook
11
13
  - **0.4.13** (2026-04-30) — b.db: streaming query results
12
14
  - **0.4.12** (2026-04-30) — b.log: multi-sink output with per-sink level filtering
13
15
  - **0.4.11** (2026-04-30) — b.cache: bytes-cap eviction, sliding TTL, tag invalidation
@@ -292,10 +292,256 @@ function _fromH2Headers(h2Headers) {
292
292
 
293
293
  // ---- request() ----
294
294
 
295
+ var REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
296
+
297
+ // Build a multipart/form-data body from { fields, files } shape.
298
+ // Mirrors the wire format that lib/middleware/body-parser.js's multipart
299
+ // parser accepts so round-trip from one blamejs app's outbound to
300
+ // another's inbound is exact.
301
+ function _buildMultipartBody(spec) {
302
+ var nodeCrypto = require("node:crypto");
303
+ var boundary = "----blamejs-mp-" + nodeCrypto.randomBytes(16).toString("hex");
304
+ var CRLF = "\r\n";
305
+ var parts = [];
306
+
307
+ function _pushField(name, value) {
308
+ if (typeof name !== "string" || name.length === 0) {
309
+ throw new Error("multipart: field name must be a non-empty string");
310
+ }
311
+ var head = "--" + boundary + CRLF +
312
+ 'Content-Disposition: form-data; name="' + name + '"' + CRLF + CRLF;
313
+ parts.push(Buffer.from(head, "utf8"));
314
+ parts.push(Buffer.isBuffer(value) ? value : Buffer.from(String(value), "utf8"));
315
+ parts.push(Buffer.from(CRLF, "utf8"));
316
+ }
317
+
318
+ function _pushFile(file) {
319
+ if (!file || typeof file !== "object") throw new Error("multipart: file entries must be objects");
320
+ if (typeof file.field !== "string" || file.field.length === 0) {
321
+ throw new Error("multipart: file.field must be a non-empty string");
322
+ }
323
+ var filename = typeof file.filename === "string" && file.filename.length > 0
324
+ ? file.filename : "blob";
325
+ var mimeType = file.contentType || file.mimeType || "application/octet-stream";
326
+ var content = file.content;
327
+ if (typeof content === "string") content = Buffer.from(content, "utf8");
328
+ if (!Buffer.isBuffer(content)) {
329
+ throw new Error("multipart: file.content must be a Buffer or string");
330
+ }
331
+ var head = "--" + boundary + CRLF +
332
+ 'Content-Disposition: form-data; name="' + file.field + '"' +
333
+ '; filename="' + filename.replace(/"/g, "%22") + '"' + CRLF +
334
+ "Content-Type: " + mimeType + CRLF + CRLF;
335
+ parts.push(Buffer.from(head, "utf8"));
336
+ parts.push(content);
337
+ parts.push(Buffer.from(CRLF, "utf8"));
338
+ }
339
+
340
+ if (spec && spec.fields && typeof spec.fields === "object") {
341
+ var keys = Object.keys(spec.fields);
342
+ for (var i = 0; i < keys.length; i++) {
343
+ var k = keys[i];
344
+ var v = spec.fields[k];
345
+ if (Array.isArray(v)) {
346
+ for (var j = 0; j < v.length; j++) _pushField(k, v[j]);
347
+ } else {
348
+ _pushField(k, v);
349
+ }
350
+ }
351
+ }
352
+ if (spec && Array.isArray(spec.files)) {
353
+ for (var fi = 0; fi < spec.files.length; fi++) _pushFile(spec.files[fi]);
354
+ }
355
+ parts.push(Buffer.from("--" + boundary + "--" + CRLF, "utf8"));
356
+ return { boundary: boundary, body: Buffer.concat(parts) };
357
+ }
358
+
359
+ // Headers stripped on cross-origin redirect to defend against accidental
360
+ // credential exfiltration. Lower-case for header-map comparison.
361
+ var SENSITIVE_HEADERS_LC = ["authorization", "cookie", "proxy-authorization"];
362
+
363
+ function _stripCrossOriginAuth(headers) {
364
+ var out = {};
365
+ var keys = Object.keys(headers);
366
+ for (var i = 0; i < keys.length; i++) {
367
+ if (SENSITIVE_HEADERS_LC.indexOf(keys[i].toLowerCase()) !== -1) continue;
368
+ out[keys[i]] = headers[keys[i]];
369
+ }
370
+ return out;
371
+ }
372
+
295
373
  function request(opts) {
296
374
  if (!opts || !opts.url) {
297
375
  return Promise.reject(_makeError(opts && opts.errorClass, "BAD_ARG", "url is required", true));
298
376
  }
377
+
378
+ // Validate before/after shapes early — Tier-A throw if the operator
379
+ // passed something un-callable so the bug surfaces at the call site
380
+ // rather than inside the request loop.
381
+ if (opts.before !== undefined) {
382
+ if (!Array.isArray(opts.before) || !opts.before.every(function (f) { return typeof f === "function"; })) {
383
+ return Promise.reject(_makeError(opts.errorClass, "BAD_ARG",
384
+ "before must be an array of functions", true));
385
+ }
386
+ }
387
+ if (opts.after !== undefined) {
388
+ if (!Array.isArray(opts.after) || !opts.after.every(function (f) { return typeof f === "function"; })) {
389
+ return Promise.reject(_makeError(opts.errorClass, "BAD_ARG",
390
+ "after must be an array of functions", true));
391
+ }
392
+ }
393
+ if (opts.onUploadProgress !== undefined && typeof opts.onUploadProgress !== "function") {
394
+ return Promise.reject(_makeError(opts.errorClass, "BAD_ARG",
395
+ "onUploadProgress must be a function", true));
396
+ }
397
+ if (opts.onDownloadProgress !== undefined && typeof opts.onDownloadProgress !== "function") {
398
+ return Promise.reject(_makeError(opts.errorClass, "BAD_ARG",
399
+ "onDownloadProgress must be a function", true));
400
+ }
401
+
402
+ // before interceptors — run in array order. Each may return a modified
403
+ // opts object (or return nothing to leave the running opts as-is).
404
+ // Caller-set defaults / observability / auth header injection lands
405
+ // here. Synchronous to keep the request hot path simple; async
406
+ // pre-flight work (e.g. token refresh) belongs in the route handler
407
+ // before httpClient.request is even called.
408
+ if (Array.isArray(opts.before) && opts.before.length > 0) {
409
+ var working = opts;
410
+ for (var bi = 0; bi < opts.before.length; bi++) {
411
+ var ret;
412
+ try { ret = opts.before[bi](working); }
413
+ catch (e) {
414
+ return Promise.reject(_makeError(opts.errorClass, "BEFORE_THREW",
415
+ "before[" + bi + "] threw: " + ((e && e.message) || String(e)), true));
416
+ }
417
+ if (ret && typeof ret === "object") working = ret;
418
+ }
419
+ opts = working;
420
+ }
421
+
422
+ // Multipart shorthand: { multipart: { fields, files } } expands to
423
+ // body + Content-Type with the boundary parameter. Mutually exclusive
424
+ // with caller-supplied body / Content-Type.
425
+ if (opts.multipart) {
426
+ if (opts.body !== undefined) {
427
+ return Promise.reject(_makeError(opts.errorClass, "BAD_ARG",
428
+ "request: pass either { body } or { multipart }, not both", true));
429
+ }
430
+ var built;
431
+ try { built = _buildMultipartBody(opts.multipart); }
432
+ catch (e) {
433
+ return Promise.reject(_makeError(opts.errorClass, "BAD_ARG", e.message, true));
434
+ }
435
+ opts = Object.assign({}, opts, {
436
+ method: opts.method || "POST",
437
+ body: built.body,
438
+ headers: Object.assign({}, opts.headers || {}, {
439
+ "Content-Type": "multipart/form-data; boundary=" + built.boundary,
440
+ "Content-Length": String(built.body.length),
441
+ }),
442
+ multipart: undefined,
443
+ });
444
+ }
445
+
446
+ // maxRedirects:
447
+ // undefined → today's behavior (no follow). Caller inspects 3xx.
448
+ // null → today's behavior (explicit). Same as undefined.
449
+ // 0 → no follow, but 3xx returned to caller (alias of null).
450
+ // N → follow up to N hops; 3xx with no Location returned as-is.
451
+ var maxRedirects = (opts.maxRedirects === undefined || opts.maxRedirects === null)
452
+ ? null : opts.maxRedirects;
453
+ if (maxRedirects !== null) {
454
+ if (typeof maxRedirects !== "number" || !isFinite(maxRedirects) || maxRedirects < 0 ||
455
+ Math.floor(maxRedirects) !== maxRedirects) {
456
+ return Promise.reject(_makeError(opts.errorClass, "BAD_ARG",
457
+ "maxRedirects must be a non-negative integer or null", true));
458
+ }
459
+ }
460
+ var afterChain = (Array.isArray(opts.after) && opts.after.length > 0) ? opts.after : null;
461
+ function _runAfter(finalOpts, res) {
462
+ if (!afterChain) return res;
463
+ for (var ai = 0; ai < afterChain.length; ai++) {
464
+ try { afterChain[ai](finalOpts, res); }
465
+ catch (_e) { /* after hooks are best-effort — never break the response */ }
466
+ }
467
+ return res;
468
+ }
469
+
470
+ if (maxRedirects === null || maxRedirects === 0) {
471
+ return _requestSingle(opts).then(function (res) { return _runAfter(opts, res); });
472
+ }
473
+
474
+ return _requestWithRedirects(opts, maxRedirects).then(function (boxed) {
475
+ return _runAfter(boxed.finalOpts, boxed.res);
476
+ });
477
+ }
478
+
479
+ function _requestWithRedirects(opts, hopsLeft) {
480
+ var originalUrl = opts.url;
481
+ var originalOrigin = null;
482
+ try {
483
+ var u0 = new URL(opts.url);
484
+ originalOrigin = u0.protocol + "//" + u0.host;
485
+ } catch (_e) { /* request() will reject on next hop's parse */ }
486
+
487
+ var current = Object.assign({}, opts, { _resolveOnRedirect: true });
488
+ function _follow() {
489
+ return _requestSingle(current).then(function (res) {
490
+ if (!REDIRECT_STATUSES.has(res.statusCode) || hopsLeft <= 0) {
491
+ return { finalOpts: current, res: res };
492
+ }
493
+ var loc = res.headers && (res.headers.location || res.headers.Location);
494
+ if (!loc) return { finalOpts: current, res: res }; // 3xx with no Location — operator handles
495
+ hopsLeft -= 1;
496
+
497
+ // Resolve relative Location against the just-fetched URL (the URL
498
+ // of the request that produced the redirect, which may itself be a
499
+ // post-redirect URL).
500
+ var nextUrl;
501
+ try { nextUrl = new URL(loc, current.url).toString(); }
502
+ catch (_e) {
503
+ return Promise.reject(_makeError(opts.errorClass, "BAD_REDIRECT",
504
+ "Location header invalid URL: " + loc, true));
505
+ }
506
+
507
+ // Cross-origin auth-header strip.
508
+ var nextHeaders = current.headers || {};
509
+ var nextOrigin;
510
+ try {
511
+ var nu = new URL(nextUrl);
512
+ nextOrigin = nu.protocol + "//" + nu.host;
513
+ } catch (_e) { /* request() will reject when it tries to parse */ }
514
+ if (originalOrigin && nextOrigin && nextOrigin !== originalOrigin) {
515
+ nextHeaders = _stripCrossOriginAuth(nextHeaders);
516
+ }
517
+
518
+ // 303 → always GET; body dropped. 301/302 → historical clients
519
+ // also coerce non-GET bodies (we follow that convention). 307/308
520
+ // → preserve method + body.
521
+ var nextMethod = current.method || "GET";
522
+ var nextBody = current.body;
523
+ if (res.statusCode === 303 ||
524
+ ((res.statusCode === 301 || res.statusCode === 302) &&
525
+ nextMethod !== "GET" && nextMethod !== "HEAD")) {
526
+ nextMethod = "GET";
527
+ nextBody = undefined;
528
+ }
529
+
530
+ current = Object.assign({}, current, {
531
+ url: nextUrl,
532
+ method: nextMethod,
533
+ body: nextBody,
534
+ headers: nextHeaders,
535
+ _resolveOnRedirect: true,
536
+ });
537
+ return _follow();
538
+ });
539
+ }
540
+ void originalUrl;
541
+ return _follow();
542
+ }
543
+
544
+ function _requestSingle(opts) {
299
545
  // Validate scheme + shape via url-safe. Default is HTTPS-only — the
300
546
  // framework refuses to silently drop bytes on the wire as cleartext.
301
547
  // Callers with cleartext endpoints (h2c, internal services, test
@@ -374,9 +620,26 @@ function _requestH1(transport, u, opts) {
374
620
  function _resolve(value) { if (!settled) { settled = true; resolve(value); } }
375
621
  function _reject(err) { if (!settled) { settled = true; reject(err); } }
376
622
 
623
+ var onUploadProgress = typeof opts.onUploadProgress === "function" ? opts.onUploadProgress : null;
624
+ var onDownloadProgress = typeof opts.onDownloadProgress === "function" ? opts.onDownloadProgress : null;
625
+
377
626
  var req = transport.lib.request(reqOpts, function (res) {
378
627
  if (observer) observer("response:headers", { statusCode: res.statusCode, headers: res.headers });
379
628
 
629
+ // Download total: Content-Length when present, null otherwise.
630
+ var dlTotal = null;
631
+ if (res.headers && typeof res.headers["content-length"] === "string") {
632
+ var cl = parseInt(res.headers["content-length"], 10);
633
+ if (!isNaN(cl) && cl >= 0) dlTotal = cl;
634
+ }
635
+ var dlLoaded = 0;
636
+ function _emitDownload(chunkBytes) {
637
+ if (!onDownloadProgress) return;
638
+ dlLoaded += chunkBytes;
639
+ try { onDownloadProgress({ loaded: dlLoaded, total: dlTotal }); }
640
+ catch (_e) { /* progress hooks are best-effort */ }
641
+ }
642
+
380
643
  if (responseMode === "stream") {
381
644
  if (res.statusCode >= 400) {
382
645
  res.resume();
@@ -384,6 +647,17 @@ function _requestH1(transport, u, opts) {
384
647
  "HTTP " + res.statusCode + " " + (res.statusMessage || ""),
385
648
  _isPermanentStatus(res.statusCode), res.statusCode));
386
649
  }
650
+ if (onDownloadProgress) {
651
+ // Wrap the stream so chunks emit progress to the operator.
652
+ // The framework's contract is to hand back the response stream
653
+ // unmodified; fix-up via a passthrough keeps that contract while
654
+ // observing the chunk sizes.
655
+ var passthrough = new (require("node:stream").PassThrough)();
656
+ res.on("data", function (chunk) { _emitDownload(chunk.length); passthrough.write(chunk); });
657
+ res.on("end", function () { passthrough.end(); });
658
+ res.on("error", function (e) { passthrough.destroy(e); });
659
+ return _resolve({ statusCode: res.statusCode, headers: res.headers, body: passthrough });
660
+ }
387
661
  return _resolve({ statusCode: res.statusCode, headers: res.headers, body: res });
388
662
  }
389
663
 
@@ -398,7 +672,9 @@ function _requestH1(transport, u, opts) {
398
672
  req.destroy();
399
673
  _reject(_makeError(opts.errorClass, "RESPONSE_TOO_LARGE",
400
674
  "response body exceeds " + maxResponseBytes + " bytes", true));
675
+ return;
401
676
  }
677
+ _emitDownload(chunk.length);
402
678
  });
403
679
  res.on("end", function () {
404
680
  if (capExceeded) return;
@@ -410,6 +686,12 @@ function _requestH1(transport, u, opts) {
410
686
  });
411
687
  if (res.statusCode >= 200 && res.statusCode < 300) {
412
688
  _resolve({ statusCode: res.statusCode, headers: res.headers, body: buf });
689
+ } else if (opts._resolveOnRedirect && REDIRECT_STATUSES.has(res.statusCode)) {
690
+ // Redirect-following layer needs the response object intact so
691
+ // it can inspect Location and re-issue. The caller-facing
692
+ // request() never sets _resolveOnRedirect — operator code that
693
+ // didn't ask for redirect-following keeps seeing 3xx as errors.
694
+ _resolve({ statusCode: res.statusCode, headers: res.headers, body: buf });
413
695
  } else {
414
696
  var msg = "HTTP " + res.statusCode + ": " + buf.toString("utf8").slice(0, 500);
415
697
  _reject(_makeError(opts.errorClass, "HTTP_ERROR", msg,
@@ -445,17 +727,47 @@ function _requestH1(transport, u, opts) {
445
727
  signal.addEventListener("abort", onAbort, { once: true });
446
728
  }
447
729
 
730
+ // Upload progress: emit { loaded, total } as body bytes go to the
731
+ // wire. Buffer / string bodies are sliced into chunks ourselves so
732
+ // operators see incremental progress; Readable bodies emit on each
733
+ // 'data' event from the source stream.
734
+ var ulTotal = null;
735
+ if (Buffer.isBuffer(opts.body)) ulTotal = opts.body.length;
736
+ else if (typeof opts.body === "string") ulTotal = Buffer.byteLength(opts.body, "utf8");
737
+ var ulLoaded = 0;
738
+ function _emitUpload(chunkBytes) {
739
+ if (!onUploadProgress) return;
740
+ ulLoaded += chunkBytes;
741
+ try { onUploadProgress({ loaded: ulLoaded, total: ulTotal }); }
742
+ catch (_e) { /* progress hooks are best-effort */ }
743
+ }
744
+
448
745
  if (opts.body && typeof opts.body.pipe === "function") {
746
+ if (onUploadProgress) {
747
+ opts.body.on("data", function (c) { _emitUpload(c.length); });
748
+ }
449
749
  opts.body.on("error", function (e) {
450
750
  try { req.destroy(); } catch (_) {}
451
751
  _reject(_makeError(opts.errorClass, "REQ_BODY_ERROR",
452
752
  "request body stream error: " + e.message, false));
453
753
  });
454
754
  opts.body.pipe(req);
455
- } else if (Buffer.isBuffer(opts.body)) {
456
- req.end(opts.body);
457
- } else if (typeof opts.body === "string") {
458
- req.end(Buffer.from(opts.body, "utf8"));
755
+ } else if (Buffer.isBuffer(opts.body) || typeof opts.body === "string") {
756
+ var bodyBuf = Buffer.isBuffer(opts.body) ? opts.body : Buffer.from(opts.body, "utf8");
757
+ if (onUploadProgress) {
758
+ // Chunked write so progress reports land before req.end().
759
+ var CHUNK = 64 * 1024;
760
+ var off = 0;
761
+ while (off < bodyBuf.length) {
762
+ var slice = bodyBuf.slice(off, Math.min(off + CHUNK, bodyBuf.length));
763
+ req.write(slice);
764
+ _emitUpload(slice.length);
765
+ off += slice.length;
766
+ }
767
+ req.end();
768
+ } else {
769
+ req.end(bodyBuf);
770
+ }
459
771
  } else {
460
772
  req.end();
461
773
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.4.14",
3
+ "version": "0.4.16",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",