@blamejs/core 0.8.12 → 0.8.15

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.
@@ -20,6 +20,10 @@
20
20
  * maxResponseBytes, // for buffer mode (default 16 MiB control,
21
21
  * // 1 GiB GET — operators with > 1 GiB
22
22
  * // stored objects must use stream mode)
23
+ * onChunk, // (chunk: Buffer) => void — fires for each
24
+ * // response chunk in BOTH buffer and stream
25
+ * // modes. Use to hash bytes during pipe-to-disk
26
+ * // without an extra Transform pass.
23
27
  * signal, // AbortSignal — propagated to req/stream
24
28
  * errorClass, // FrameworkError subclass
25
29
  * observer, // optional (stage, info) => void hook
@@ -372,6 +376,7 @@ function _toH2Headers(method, u, headers) {
372
376
  h2Headers[":path"] = u.pathname + (u.search || "");
373
377
  h2Headers[":scheme"] = u.protocol === "https:" ? "https" : "http";
374
378
  h2Headers[":authority"] = u.host;
379
+ var sawAcceptEncoding = false;
375
380
  for (var k in headers) {
376
381
  if (!Object.prototype.hasOwnProperty.call(headers, k)) continue;
377
382
  var lk = k.toLowerCase();
@@ -379,8 +384,12 @@ function _toH2Headers(method, u, headers) {
379
384
  if (lk === "connection" || lk === "host" ||
380
385
  lk === "keep-alive" || lk === "transfer-encoding" ||
381
386
  lk === "upgrade" || lk === "proxy-connection") continue;
387
+ if (lk === "accept-encoding") sawAcceptEncoding = true;
382
388
  h2Headers[lk] = headers[k];
383
389
  }
390
+ // CVE-2026-22036 mitigation — same identity default as the h1 path.
391
+ // Refuse compressed responses unless the operator explicitly opts in.
392
+ if (!sawAcceptEncoding) h2Headers["accept-encoding"] = "identity";
384
393
  return h2Headers;
385
394
  }
386
395
 
@@ -426,20 +435,78 @@ function _attachJarCookie(headers, jar, url) {
426
435
  // Mirrors the wire format that lib/middleware/body-parser.js's multipart
427
436
  // parser accepts so round-trip from one blamejs app's outbound to
428
437
  // another's inbound is exact.
438
+ //
439
+ // Two output shapes:
440
+ //
441
+ // - { boundary, body: Buffer, contentLength }
442
+ // When every file entry is a Buffer / string (size known
443
+ // up front) and no operator opted into streaming, the result
444
+ // is a fully-materialized body. Smaller payloads avoid the
445
+ // streaming overhead and let HTTP/1.1 KeepAlive reuse with a
446
+ // known Content-Length.
447
+ //
448
+ // - { boundary, body: Readable, contentLength }
449
+ // When at least one file entry is `{ filePath }` / `{ stream }`
450
+ // OR opts.streaming === true, the result is a Readable that
451
+ // emits boundary headers + content + CRLF in order. Avoids the
452
+ // Buffer.concat() OOM class on large uploads. contentLength is
453
+ // a finite number when every source's size is statically
454
+ // resolvable (Buffer length, fs.statSync().size, opts.size on
455
+ // a stream entry); null otherwise — caller falls back to
456
+ // chunked transfer.
457
+ //
458
+ // File entry shapes (all require `field`):
459
+ //
460
+ // { field, content: Buffer | string } — in-memory (existing)
461
+ // { field, filePath: string } — stream-from-disk
462
+ // { field, stream: Readable, size?: number } — operator-supplied stream
463
+ //
464
+ // `filename` and `contentType` apply to all three shapes; for
465
+ // `filePath` entries, `filename` defaults to path.basename(filePath).
429
466
  function _buildMultipartBody(spec) {
430
467
  var boundary = "----blamejs-mp-" + crypto.generateToken(C.BYTES.bytes(16));
431
468
  var CRLF = "\r\n";
432
- var parts = [];
469
+ var fs = require("fs"); // allow:inline-require — only on multipart paths that touch the filesystem
470
+ var path = require("path"); // allow:inline-require — same
471
+ var nodeStream = require("stream"); // allow:inline-require — Readable subclass only when streaming
472
+
473
+ // Each entry is { headerBytes, source } where source is one of:
474
+ // { kind: "buffer", buf: Buffer }
475
+ // { kind: "filePath", filePath: string, size: number }
476
+ // { kind: "stream", stream: Readable, size: number | null }
477
+ var entries = [];
478
+ var anyStreaming = false;
479
+ var totalSize = 0;
480
+ var sizeKnown = true;
481
+
482
+ function _entryHeaderBytes(disposition, contentType) {
483
+ var head = "--" + boundary + CRLF + disposition + CRLF;
484
+ if (contentType) head += "Content-Type: " + contentType + CRLF;
485
+ head += CRLF;
486
+ return Buffer.from(head, "utf8");
487
+ }
488
+
489
+ function _addEntry(headerBytes, source) {
490
+ entries.push({ header: headerBytes, source: source });
491
+ totalSize += headerBytes.length;
492
+ if (source.kind === "buffer") {
493
+ totalSize += source.buf.length;
494
+ } else if (typeof source.size === "number" && isFinite(source.size) && source.size >= 0) {
495
+ totalSize += source.size;
496
+ } else {
497
+ sizeKnown = false;
498
+ }
499
+ totalSize += CRLF.length;
500
+ }
433
501
 
434
502
  function _pushField(name, value) {
435
503
  if (typeof name !== "string" || name.length === 0) {
436
504
  throw new Error("multipart: field name must be a non-empty string");
437
505
  }
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"));
506
+ var disposition = 'Content-Disposition: form-data; name="' + name + '"';
507
+ var head = _entryHeaderBytes(disposition, null);
508
+ var bodyBuf = Buffer.isBuffer(value) ? value : Buffer.from(String(value), "utf8");
509
+ _addEntry(head, { kind: "buffer", buf: bodyBuf });
443
510
  }
444
511
 
445
512
  function _pushFile(file) {
@@ -447,21 +514,50 @@ function _buildMultipartBody(spec) {
447
514
  if (typeof file.field !== "string" || file.field.length === 0) {
448
515
  throw new Error("multipart: file.field must be a non-empty string");
449
516
  }
450
- var filename = typeof file.filename === "string" && file.filename.length > 0
451
- ? file.filename : "blob";
517
+ var hasContent = file.content !== undefined && file.content !== null;
518
+ var hasFilePath = typeof file.filePath === "string" && file.filePath.length > 0;
519
+ var hasStream = file.stream && typeof file.stream.pipe === "function";
520
+ var sourceCount = (hasContent ? 1 : 0) + (hasFilePath ? 1 : 0) + (hasStream ? 1 : 0);
521
+ if (sourceCount === 0) {
522
+ throw new Error("multipart: file entry requires one of { content, filePath, stream }");
523
+ }
524
+ if (sourceCount > 1) {
525
+ throw new Error("multipart: file entry must have exactly one of { content, filePath, stream }");
526
+ }
527
+
528
+ var filename;
529
+ if (typeof file.filename === "string" && file.filename.length > 0) {
530
+ filename = file.filename;
531
+ } else if (hasFilePath) {
532
+ filename = path.basename(file.filePath);
533
+ } else {
534
+ filename = "blob";
535
+ }
452
536
  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");
537
+ var disposition = 'Content-Disposition: form-data; name="' + file.field + '"' +
538
+ '; filename="' + filename.replace(/"/g, "%22") + '"';
539
+ var head = _entryHeaderBytes(disposition, mimeType);
540
+
541
+ if (hasContent) {
542
+ var content = file.content;
543
+ if (typeof content === "string") content = Buffer.from(content, "utf8");
544
+ if (!Buffer.isBuffer(content)) {
545
+ throw new Error("multipart: file.content must be a Buffer or string");
546
+ }
547
+ _addEntry(head, { kind: "buffer", buf: content });
548
+ } else if (hasFilePath) {
549
+ anyStreaming = true;
550
+ var st;
551
+ try { st = fs.statSync(file.filePath); }
552
+ catch (e) { throw new Error("multipart: file.filePath not readable: " + e.message); }
553
+ if (!st.isFile()) throw new Error("multipart: file.filePath is not a regular file");
554
+ _addEntry(head, { kind: "filePath", filePath: file.filePath, size: st.size });
555
+ } else {
556
+ anyStreaming = true;
557
+ var streamSize = (typeof file.size === "number" && isFinite(file.size) && file.size >= 0)
558
+ ? file.size : null;
559
+ _addEntry(head, { kind: "stream", stream: file.stream, size: streamSize });
457
560
  }
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
561
  }
466
562
 
467
563
  if (spec && spec.fields && typeof spec.fields === "object") {
@@ -479,8 +575,54 @@ function _buildMultipartBody(spec) {
479
575
  if (spec && Array.isArray(spec.files)) {
480
576
  for (var fi = 0; fi < spec.files.length; fi++) _pushFile(spec.files[fi]);
481
577
  }
482
- parts.push(Buffer.from("--" + boundary + "--" + CRLF, "utf8"));
483
- return { boundary: boundary, body: Buffer.concat(parts) };
578
+ var trailer = Buffer.from("--" + boundary + "--" + CRLF, "utf8");
579
+ totalSize += trailer.length;
580
+
581
+ // All-buffer fast path — return a fully-materialized body when no
582
+ // streaming sources are involved AND the operator didn't ask for
583
+ // streaming explicitly. Existing callers that pass small in-memory
584
+ // payloads keep the buffer codepath.
585
+ if (!anyStreaming && !(spec && spec.streaming === true)) {
586
+ var parts = [];
587
+ for (var ei = 0; ei < entries.length; ei++) {
588
+ parts.push(entries[ei].header);
589
+ parts.push(entries[ei].source.buf);
590
+ parts.push(Buffer.from(CRLF, "utf8"));
591
+ }
592
+ parts.push(trailer);
593
+ return { boundary: boundary, body: Buffer.concat(parts), contentLength: totalSize };
594
+ }
595
+
596
+ // Streaming path — produce a Readable from an async iterator that
597
+ // yields the bytes for each entry in order.
598
+ var crlfBuf = Buffer.from(CRLF, "utf8");
599
+ async function* _iter() {
600
+ for (var ix = 0; ix < entries.length; ix++) {
601
+ var entry = entries[ix];
602
+ yield entry.header;
603
+ if (entry.source.kind === "buffer") {
604
+ yield entry.source.buf;
605
+ } else if (entry.source.kind === "filePath") {
606
+ var rs = fs.createReadStream(entry.source.filePath);
607
+ try {
608
+ for await (var chunk of rs) yield chunk;
609
+ } finally {
610
+ try { rs.destroy(); } catch (_e) { /* best-effort cleanup */ }
611
+ }
612
+ } else {
613
+ // operator-supplied stream
614
+ for await (var chunk2 of entry.source.stream) yield chunk2;
615
+ }
616
+ yield crlfBuf;
617
+ }
618
+ yield trailer;
619
+ }
620
+ var body = nodeStream.Readable.from(_iter());
621
+ return {
622
+ boundary: boundary,
623
+ body: body,
624
+ contentLength: sizeKnown ? totalSize : null,
625
+ };
484
626
  }
485
627
 
486
628
  // Headers stripped on cross-origin redirect to defend against accidental
@@ -525,6 +667,10 @@ function request(opts) {
525
667
  return Promise.reject(_makeError(opts.errorClass, "BAD_ARG",
526
668
  "onDownloadProgress must be a function", true));
527
669
  }
670
+ if (opts.onChunk !== undefined && typeof opts.onChunk !== "function") {
671
+ return Promise.reject(_makeError(opts.errorClass, "BAD_ARG",
672
+ "onChunk must be a function (chunk: Buffer) -> void", true));
673
+ }
528
674
  if (opts.jar !== undefined && opts.jar !== null) {
529
675
  if (typeof opts.jar !== "object" ||
530
676
  typeof opts.jar.cookieHeaderFor !== "function" ||
@@ -567,13 +713,20 @@ function request(opts) {
567
713
  catch (e) {
568
714
  return Promise.reject(_makeError(opts.errorClass, "BAD_ARG", e.message, true));
569
715
  }
716
+ var mpHeaders = Object.assign({}, opts.headers || {}, {
717
+ "Content-Type": "multipart/form-data; boundary=" + built.boundary,
718
+ });
719
+ // Content-Length is set when the framework can statically resolve
720
+ // every source's byte size. Otherwise the framework omits the
721
+ // header and Node's HTTP layer falls back to chunked transfer —
722
+ // valid HTTP/1.1, requires no operator opt-in.
723
+ if (typeof built.contentLength === "number" && isFinite(built.contentLength)) {
724
+ mpHeaders["Content-Length"] = String(built.contentLength);
725
+ }
570
726
  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
- }),
727
+ method: opts.method || "POST",
728
+ body: built.body,
729
+ headers: mpHeaders,
577
730
  multipart: undefined,
578
731
  });
579
732
  }
@@ -872,6 +1025,17 @@ function _requestH1(transport, u, opts) {
872
1025
  if (Buffer.isBuffer(opts.body)) {
873
1026
  headers["Content-Length"] = opts.body.length;
874
1027
  }
1028
+ // CVE-2026-22036 mitigation — refuse compressed responses by
1029
+ // default. The framework's http-client returns raw bytes capped
1030
+ // at maxResponseBytes; if a server sends gzip/br/zstd the cap is
1031
+ // on-wire bytes only, and any operator-side decompression is the
1032
+ // operator's responsibility to bound. Identity by default closes
1033
+ // the decompression-bomb amplification class. Operators who DO
1034
+ // want compressed responses opt in by passing an explicit
1035
+ // Accept-Encoding header (lowercase or canonical form).
1036
+ if (!headers["Accept-Encoding"] && !headers["accept-encoding"]) {
1037
+ headers["Accept-Encoding"] = "identity";
1038
+ }
875
1039
 
876
1040
  var reqOpts = {
877
1041
  method: method,
@@ -894,6 +1058,7 @@ function _requestH1(transport, u, opts) {
894
1058
 
895
1059
  var onUploadProgress = typeof opts.onUploadProgress === "function" ? opts.onUploadProgress : null;
896
1060
  var onDownloadProgress = typeof opts.onDownloadProgress === "function" ? opts.onDownloadProgress : null;
1061
+ var onChunk = typeof opts.onChunk === "function" ? opts.onChunk : null;
897
1062
 
898
1063
  var req = transport.lib.request(reqOpts, function (res) {
899
1064
  if (observer) observer("response:headers", { statusCode: res.statusCode, headers: res.headers });
@@ -927,13 +1092,24 @@ function _requestH1(transport, u, opts) {
927
1092
  "HTTP " + res.statusCode + " " + (res.statusMessage || ""),
928
1093
  _isPermanentStatus(res.statusCode), res.statusCode));
929
1094
  }
930
- if (onDownloadProgress) {
931
- // Wrap the stream so chunks emit progress to the operator.
932
- // The framework's contract is to hand back the response stream
933
- // unmodified; fix-up via a passthrough keeps that contract while
934
- // observing the chunk sizes.
1095
+ if (onDownloadProgress || onChunk) {
1096
+ // Wrap the stream so chunks emit progress + onChunk to the
1097
+ // operator. The framework's contract is to hand back the
1098
+ // response stream unmodified; fix-up via a passthrough keeps
1099
+ // that contract while observing the chunk sizes. onChunk
1100
+ // gets the buffer itself (for hash-as-you-go); a throw from
1101
+ // it is caught and dropped so a hash-mismatch detector can
1102
+ // raise without breaking the response stream — caller
1103
+ // surfaces the error through their own pipe handler.
935
1104
  var passthrough = new nodeStream.PassThrough();
936
- res.on("data", function (chunk) { _emitDownload(chunk.length); passthrough.write(chunk); });
1105
+ res.on("data", function (chunk) {
1106
+ _emitDownload(chunk.length);
1107
+ if (onChunk) {
1108
+ try { onChunk(chunk); }
1109
+ catch (_e) { /* operator-supplied hook — drop-silent */ }
1110
+ }
1111
+ passthrough.write(chunk);
1112
+ });
937
1113
  res.on("end", function () { passthrough.end(); });
938
1114
  res.on("error", function (e) { passthrough.destroy(e); });
939
1115
  return _resolve({ statusCode: res.statusCode, headers: res.headers, body: passthrough });
@@ -955,6 +1131,10 @@ function _requestH1(transport, u, opts) {
955
1131
  return;
956
1132
  }
957
1133
  _emitDownload(chunk.length);
1134
+ if (onChunk) {
1135
+ try { onChunk(chunk); }
1136
+ catch (_e) { /* operator-supplied hook — drop-silent */ }
1137
+ }
958
1138
  });
959
1139
  res.on("end", function () {
960
1140
  if (capExceeded) return;
@@ -1072,6 +1252,7 @@ function _requestH2(transport, u, opts) {
1072
1252
  (method === "GET" ? DEFAULT_GET_CAP : DEFAULT_CONTROL_PLANE_CAP);
1073
1253
  var observer = typeof opts.observer === "function" ? opts.observer : null;
1074
1254
  var startedAt = Date.now();
1255
+ var onChunkH2 = typeof opts.onChunk === "function" ? opts.onChunk : null;
1075
1256
 
1076
1257
  var signal = safeAsync.withTimeoutSignal(opts.signal || null, opts.timeoutMs);
1077
1258
  if (signal && signal.aborted) {
@@ -1128,6 +1309,17 @@ function _requestH2(transport, u, opts) {
1128
1309
  return _reject(_makeError(opts.errorClass, "HTTP_ERROR",
1129
1310
  "HTTP " + statusCode, _isPermanentStatus(statusCode), statusCode));
1130
1311
  }
1312
+ if (onChunkH2) {
1313
+ var passthroughH2 = new nodeStream.PassThrough();
1314
+ stream.on("data", function (chunk) {
1315
+ try { onChunkH2(chunk); }
1316
+ catch (_e) { /* operator-supplied hook — drop-silent */ }
1317
+ passthroughH2.write(chunk);
1318
+ });
1319
+ stream.on("end", function () { passthroughH2.end(); });
1320
+ stream.on("error", function (e) { passthroughH2.destroy(e); });
1321
+ return _resolve({ statusCode: statusCode, headers: responseHeaders, body: passthroughH2 });
1322
+ }
1131
1323
  return _resolve({ statusCode: statusCode, headers: responseHeaders, body: stream });
1132
1324
  }
1133
1325
 
@@ -1142,6 +1334,11 @@ function _requestH2(transport, u, opts) {
1142
1334
  try { stream.close(http2.constants.NGHTTP2_CANCEL); } catch (_e2) { /* best-effort h2 stream cancel */ }
1143
1335
  _reject(_makeError(opts.errorClass, "RESPONSE_TOO_LARGE",
1144
1336
  "response body exceeds " + maxResponseBytes + " bytes", true));
1337
+ return;
1338
+ }
1339
+ if (onChunkH2) {
1340
+ try { onChunkH2(chunk); }
1341
+ catch (_e) { /* operator-supplied hook — drop-silent */ }
1145
1342
  }
1146
1343
  });
1147
1344
  stream.on("end", function () {