@blamejs/core 0.10.6 → 0.10.8

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.
@@ -236,6 +236,13 @@ function create(opts) {
236
236
  var localDomains = (opts.localDomains || []).map(function (d) { return String(d).toLowerCase(); });
237
237
  var relayAllowedFor = opts.relayAllowedFor || [];
238
238
  var profile = opts.profile || "strict";
239
+ // SMTPUTF8 (RFC 6531) — single switch threaded end-to-end. The MX
240
+ // listener doesn't advertise SMTPUTF8 to the peer regardless, so
241
+ // this defaults `false` (refuse non-ASCII bytes in every command
242
+ // line). Operators that want to accept SMTPUTF8 for downstream
243
+ // relay flip this `true` and the same switch reaches every
244
+ // `guardSmtpCommand.validate` call.
245
+ var allowSmtpUtf8 = opts.allowSmtpUtf8 === true;
239
246
 
240
247
  // Default-on per-IP rate limit. Operators pass `rateLimit: false` to
241
248
  // disable (only for tests / closed networks), pass a rate-limit
@@ -331,6 +338,15 @@ function create(opts) {
331
338
  var connectionId = "mxconn-" + bCrypto.generateToken(8); // allow:raw-byte-literal — connection-id length
332
339
  connections.add(socket);
333
340
 
341
+ // Backpressure observer — `_writeReply` flips `_bpEmitted` after
342
+ // the first audit emission per socket to bound the audit volume.
343
+ socket._bpEmit = function () {
344
+ _emit("mail.server.mx.write_backpressure",
345
+ { connectionId: connectionId, remoteAddress: remoteAddress,
346
+ stage: state && state.stage, bufferedBytes: socket.writableLength || 0 },
347
+ "warning");
348
+ };
349
+
334
350
  var state = {
335
351
  id: connectionId,
336
352
  remoteAddress: remoteAddress,
@@ -452,7 +468,11 @@ function create(opts) {
452
468
  // Per-line guard — refuse bare LF / NUL / C0 / DEL / oversize
453
469
  // BEFORE state-machine dispatch.
454
470
  try {
455
- guardSmtpCommand.validate(line, { profile: profile, maxLineBytes: maxLineBytes });
471
+ guardSmtpCommand.validate(line, {
472
+ profile: profile,
473
+ maxLineBytes: maxLineBytes,
474
+ allowSmtpUtf8: allowSmtpUtf8,
475
+ });
456
476
  } catch (err) {
457
477
  if (err.code === "guard-smtp-command/bare-lf" ||
458
478
  err.code === "guard-smtp-command/bare-cr" ||
@@ -633,17 +653,19 @@ function create(opts) {
633
653
  }
634
654
  var paramStr = match[2] || "";
635
655
  var sizeMatch = paramStr.match(RE_SIZE);
656
+ var declaredSize = null;
636
657
  if (sizeMatch) {
637
- var declaredSize = parseInt(sizeMatch[1], 10);
658
+ declaredSize = parseInt(sizeMatch[1], 10);
638
659
  if (declaredSize > maxMessageBytes) {
639
660
  _writeReply(socket, REPLY_552_SIZE_EXCEEDED,
640
661
  "5.3.4 Message size exceeds fixed maximum (" + maxMessageBytes + " bytes)");
641
662
  return;
642
663
  }
643
664
  }
644
- state.mailFrom = mailFrom;
645
- state.stage = "rcpt";
646
- state.rcpts = [];
665
+ state.mailFrom = mailFrom;
666
+ state.declaredSize = declaredSize;
667
+ state.stage = "rcpt";
668
+ state.rcpts = [];
647
669
  _emit("mail.server.mx.mail_from",
648
670
  { connectionId: state.id, mailFrom: mailFrom });
649
671
  _writeReply(socket, REPLY_250_OK, "2.1.0 Sender OK");
@@ -692,6 +714,7 @@ function create(opts) {
692
714
  var rcptVerdict = _validateDomainHardened(rcptDomain, "rcpt_to");
693
715
  if (!rcptVerdict.ok) {
694
716
  rateLimit.noteRcptFailure(state.remoteAddress);
717
+ _trackRefusedRcpt(state, rcpt, "domain-refused");
695
718
  _writeReply(socket, REPLY_501_BAD_ARGS,
696
719
  "5.5.4 RCPT TO domain refused (" +
697
720
  (rcptVerdict.issues && rcptVerdict.issues[0] && rcptVerdict.issues[0].kind) + ")");
@@ -704,6 +727,7 @@ function create(opts) {
704
727
  if (localDomains.indexOf(rcptDomain) === -1 &&
705
728
  !_isRelayAllowed(state.remoteAddress, rcpt)) {
706
729
  rateLimit.noteRcptFailure(state.remoteAddress);
730
+ _trackRefusedRcpt(state, rcpt, "relay-denied");
707
731
  _emit("mail.server.mx.relay_refused",
708
732
  { connectionId: state.id, mailFrom: state.mailFrom, rcptTo: rcpt,
709
733
  remoteAddress: state.remoteAddress }, "denied");
@@ -739,9 +763,32 @@ function create(opts) {
739
763
  // body is the raw bytes BEFORE dot-stuffing reversal. RFC 5321
740
764
  // §4.5.2 — a single leading "." is doubled on the wire; undo.
741
765
  var dedotted = safeSmtp.dotUnstuff(body);
766
+ // RFC 1870 §6.3 — reconcile MAIL FROM SIZE= against the actual
767
+ // DATA byte count. The pre-DATA reservation at MAIL FROM time
768
+ // (above) is advisory; the sender's declared size is a HINT,
769
+ // not a guarantee. If the actual unstuffed body exceeds the
770
+ // declared SIZE= (with a small slack to absorb header lines the
771
+ // sender didn't count), refuse with 552 — defends against
772
+ // senders that probe maxMessageBytes by understating SIZE.
773
+ if (typeof state.declaredSize === "number" && isFinite(state.declaredSize)) {
774
+ if (dedotted.length > state.declaredSize) {
775
+ _emit("mail.server.mx.size_overrun", {
776
+ connectionId: state.id,
777
+ mailFrom: state.mailFrom,
778
+ declaredSize: state.declaredSize,
779
+ actualSize: dedotted.length,
780
+ }, "denied");
781
+ _writeReply(socket, REPLY_552_SIZE_EXCEEDED,
782
+ "5.3.4 Message exceeds declared SIZE=" + state.declaredSize +
783
+ " bytes (got " + dedotted.length + "; RFC 1870 §6.3)");
784
+ _resetTransaction(state);
785
+ return;
786
+ }
787
+ }
742
788
  // operator-supplied agent handoff — when wired, persist via
743
789
  // agent + write the 250 reply. When not wired, accept-and-drop
744
790
  // (audit-only mode useful for staging deployments).
791
+ var refusedSnapshot = Array.isArray(state.refusedRcpts) ? state.refusedRcpts.slice() : [];
745
792
  if (opts.agent && typeof opts.agent.handoff === "function") {
746
793
  opts.agent.handoff({
747
794
  mailFrom: state.mailFrom,
@@ -753,7 +800,8 @@ function create(opts) {
753
800
  connectionId: state.id,
754
801
  }).then(function (ack) {
755
802
  _emit("mail.server.mx.delivered",
756
- { connectionId: state.id, messageId: ack && ack.messageId, sizeBytes: dedotted.length });
803
+ { connectionId: state.id, messageId: ack && ack.messageId,
804
+ sizeBytes: dedotted.length, refusedRcpts: refusedSnapshot });
757
805
  _writeReply(socket, REPLY_250_OK,
758
806
  "2.6.0 Message accepted" + (ack && ack.messageId ? " <" + ack.messageId + ">" : ""));
759
807
  _resetTransaction(state);
@@ -769,18 +817,32 @@ function create(opts) {
769
817
  }
770
818
  _emit("mail.server.mx.data_accepted",
771
819
  { connectionId: state.id, mailFrom: state.mailFrom, rcptCount: state.rcpts.length,
772
- sizeBytes: dedotted.length });
820
+ sizeBytes: dedotted.length, refusedRcpts: refusedSnapshot });
773
821
  _writeReply(socket, REPLY_250_OK, "2.6.0 Message queued (audit-only)");
774
822
  _resetTransaction(state);
775
823
  }
776
824
 
777
825
  function _resetTransaction(state) {
778
- state.mailFrom = null;
779
- state.rcpts = [];
780
- state.stage = "ehlo";
826
+ state.mailFrom = null;
827
+ state.declaredSize = null;
828
+ state.rcpts = [];
829
+ state.refusedRcpts = [];
830
+ state.stage = "ehlo";
781
831
  state.messageBytes = 0;
782
832
  }
783
833
 
834
+ // Track up to MAX_REFUSED_RCPTS_PER_TXN refused recipients so the
835
+ // `data_accepted` / `delivered` audit can surface the bounded list
836
+ // for observability. Bounded to keep the audit metadata size
837
+ // predictable; the per-IP recipient-failure rate-limit elsewhere
838
+ // bounds long-run scanner damage.
839
+ var MAX_REFUSED_RCPTS_PER_TXN = 32; // allow:raw-byte-literal — bounded audit-metadata list cap
840
+ function _trackRefusedRcpt(state, rcpt, reason) {
841
+ if (!Array.isArray(state.refusedRcpts)) state.refusedRcpts = [];
842
+ if (state.refusedRcpts.length >= MAX_REFUSED_RCPTS_PER_TXN) return;
843
+ state.refusedRcpts.push({ rcptTo: rcpt, reason: reason });
844
+ }
845
+
784
846
  function _requiresStartTls() {
785
847
  // Strict / balanced require STARTTLS before MAIL FROM.
786
848
  // Permissive accepts plaintext — operator-acknowledged downgrade
@@ -857,10 +919,26 @@ function create(opts) {
857
919
 
858
920
  // ---- Wire-protocol helpers --------------------------------------------------
859
921
 
922
+ // Write back-pressure observability — when `socket.write()` returns
923
+ // false the kernel send-buffer is full and the server is dropping
924
+ // behind the network. Listeners attach a `_bpEmit` function to the
925
+ // socket; we invoke it once per socket-lifetime on the first
926
+ // backpressure event so the audit log surfaces stalled connections
927
+ // without flooding on every reply.
928
+ function _observeBackpressure(socket, ok) {
929
+ if (ok) return;
930
+ if (typeof socket._bpEmit !== "function") return;
931
+ if (socket._bpEmitted) return;
932
+ socket._bpEmitted = true;
933
+ try { socket._bpEmit(socket); } catch (_e) { /* drop-silent */ }
934
+ }
935
+
860
936
  function _writeReply(socket, code, text) {
861
937
  // Single-line reply per RFC 5321 §4.2 — code SP text CRLF.
862
- try { socket.write(code + " " + text + "\r\n"); }
863
- catch (_e) { /* socket already closed */ }
938
+ try {
939
+ var ok = socket.write(code + " " + text + "\r\n");
940
+ _observeBackpressure(socket, ok);
941
+ } catch (_e) { /* socket already closed */ }
864
942
  }
865
943
 
866
944
  function _writeMultiline(socket, code, lines) {
@@ -868,8 +946,10 @@ function _writeMultiline(socket, code, lines) {
868
946
  // continuation, code SP text CRLF for the final line.
869
947
  for (var i = 0; i < lines.length; i += 1) {
870
948
  var sep = i === lines.length - 1 ? " " : "-";
871
- try { socket.write(code + sep + lines[i] + "\r\n"); }
872
- catch (_e) { /* socket already closed */ }
949
+ try {
950
+ var ok = socket.write(code + sep + lines[i] + "\r\n");
951
+ _observeBackpressure(socket, ok);
952
+ } catch (_e) { /* socket already closed */ }
873
953
  }
874
954
  }
875
955
 
@@ -153,6 +153,69 @@ var RE_RCPT_TO = /^RCPT\s+TO:\s*<([^>]+)>(?:\s+.*)?$/i;
153
153
  var RE_SIZE = /SIZE=(\d+)/i;
154
154
  var RE_AUTH = /^AUTH\s+([A-Za-z0-9_-]{1,32})(?:\s+(.*))?$/i;
155
155
 
156
+ // Header/body boundary scanner. RFC 5322 §2.1 — header section ends
157
+ // at the first empty line (CRLF CRLF). `Buffer#indexOf` runs a
158
+ // SIMD-accelerated needle scan over the haystack without an
159
+ // interpreter-level char-by-char walk, and the 4-byte literal
160
+ // `_CRLF_CRLF` is a module-level singleton so the JIT folds it.
161
+ var _CRLF_CRLF = Buffer.from([0x0d, 0x0a, 0x0d, 0x0a]); // allow:raw-byte-literal — RFC 5322 §2.1 header/body separator
162
+ function _findHeaderEnd(buf) {
163
+ return buf.indexOf(_CRLF_CRLF);
164
+ }
165
+
166
+ // Walk a header block and return every unfolded `DKIM-Signature:`
167
+ // value. RFC 5322 §2.2.3 / RFC 6376 §3.5 — DKIM signatures are
168
+ // permitted to fold and a message MAY carry multiple signatures.
169
+ function _extractDkimSignatures(headerBlock) {
170
+ var lines = headerBlock.replace(/\r\n/g, "\n").split("\n"); // allow:regex-no-length-cap — headerBlock length bounded by maxMessageBytes
171
+ var result = [];
172
+ var current = null;
173
+ for (var i = 0; i < lines.length; i += 1) {
174
+ var line = lines[i];
175
+ if (line.length === 0) break; // end of header block
176
+ if (line.charAt(0) === " " || line.charAt(0) === "\t") {
177
+ if (current !== null) current += " " + line.replace(/^[ \t]+/, ""); // allow:regex-no-length-cap — line length bounded by maxLineBytes // allow:duplicate-regex — RFC 5322 header continuation trim
178
+ continue;
179
+ }
180
+ if (current !== null) {
181
+ result.push(current);
182
+ current = null;
183
+ }
184
+ if (/^DKIM-Signature\s*:/i.test(line)) { // allow:regex-no-length-cap — line length bounded by maxLineBytes
185
+ current = line.slice(line.indexOf(":") + 1).replace(/^\s+/, ""); // allow:regex-no-length-cap — line length bounded by maxLineBytes // allow:duplicate-regex — leading-WS trim
186
+ }
187
+ }
188
+ if (current !== null) result.push(current);
189
+ return result;
190
+ }
191
+
192
+ // Pull the `d=` (signing domain) tag out of a DKIM-Signature value.
193
+ // RFC 6376 §3.5 — tag-list `tag=value` separated by `;`. Returns
194
+ // null if not present.
195
+ function _extractDkimDTag(sigValue) {
196
+ var tags = sigValue.split(";");
197
+ for (var i = 0; i < tags.length; i += 1) {
198
+ var t = tags[i].replace(/^\s+|\s+$/g, ""); // allow:regex-no-length-cap — tag length bounded by header line cap // allow:duplicate-regex — trim shape
199
+ if (t.length > 2 && t.charAt(0) === "d" && t.charAt(1) === "=") {
200
+ return t.slice(2).replace(/\s+/g, ""); // allow:regex-no-length-cap — value length bounded by tag length // allow:duplicate-regex — internal-WS strip
201
+ }
202
+ }
203
+ return null;
204
+ }
205
+
206
+ // Domain part of the authenticated identity, falling back to the
207
+ // envelope-sender domain when the actor doesn't carry one.
208
+ function _actorDomain(actor, mailFrom) {
209
+ if (actor && typeof actor.domain === "string" && actor.domain.length > 0) return actor.domain;
210
+ if (actor && typeof actor.id === "string" && actor.id.indexOf("@") !== -1) {
211
+ return actor.id.slice(actor.id.lastIndexOf("@") + 1);
212
+ }
213
+ if (typeof mailFrom === "string" && mailFrom.indexOf("@") !== -1) {
214
+ return mailFrom.slice(mailFrom.lastIndexOf("@") + 1);
215
+ }
216
+ return null;
217
+ }
218
+
156
219
  /**
157
220
  * @primitive b.mail.server.submission.create
158
221
  * @signature b.mail.server.submission.create(opts)
@@ -208,6 +271,30 @@ function create(opts) {
208
271
  "mail.server.submission.", MailServerSubmissionError, "mail-server-submission/bad-bound");
209
272
 
210
273
  var profile = opts.profile || "strict";
274
+ // SMTPUTF8 (RFC 6531) — single switch threaded end-to-end into
275
+ // `guardSmtpCommand.validate`. Defaults `false`; submission
276
+ // operators that accept EAI envelopes flip this `true`.
277
+ var allowSmtpUtf8 = opts.allowSmtpUtf8 === true;
278
+
279
+ // Outbound DKIM-required gate (Yahoo / Google 2024 bulk-sender
280
+ // alignment + RFC 6376 §1). Under `strict` profile the listener
281
+ // refuses outbound DATA that doesn't carry at least one
282
+ // `DKIM-Signature:` header; `dkimRequireMode` chooses whether the
283
+ // signer must match the authenticated identity's domain (`self`)
284
+ // or just be present (`any`). Operators that act as a smarthost
285
+ // relay for downstream MTAs that DKIM-sign themselves want `any`;
286
+ // primary senders want `self`. Default-off outside strict so
287
+ // unauthenticated `permissive` profiles don't break.
288
+ var requireDkim = opts.requireDkim === undefined
289
+ ? (profile === "strict")
290
+ : opts.requireDkim === true;
291
+ var dkimRequireMode = opts.dkimRequireMode || "any";
292
+ if (dkimRequireMode !== "self" && dkimRequireMode !== "any" && dkimRequireMode !== "off") {
293
+ throw new MailServerSubmissionError("mail-server-submission/bad-dkim-require-mode",
294
+ "mail.server.submission.create: dkimRequireMode must be 'self', 'any', or 'off' (got '" +
295
+ dkimRequireMode + "')");
296
+ }
297
+ if (dkimRequireMode === "off") requireDkim = false;
211
298
 
212
299
  if (profile !== "permissive" && !opts.auth) {
213
300
  throw new MailServerSubmissionError("mail-server-submission/no-auth",
@@ -424,7 +511,11 @@ function create(opts) {
424
511
 
425
512
  // guardSmtpCommand check (smuggling + shape).
426
513
  try {
427
- guardSmtpCommand.validate(line, { profile: profile, maxLineBytes: maxLineBytes });
514
+ guardSmtpCommand.validate(line, {
515
+ profile: profile,
516
+ maxLineBytes: maxLineBytes,
517
+ allowSmtpUtf8: allowSmtpUtf8,
518
+ });
428
519
  } catch (err) {
429
520
  if (err.code === "guard-smtp-command/bare-lf" ||
430
521
  err.code === "guard-smtp-command/bare-cr" ||
@@ -911,6 +1002,49 @@ function create(opts) {
911
1002
 
912
1003
  function _finalizeDataBody(state, socket, body) {
913
1004
  var dedotted = safeSmtp.dotUnstuff(body);
1005
+
1006
+ // Outbound DKIM-required gate. Scan the header block for a
1007
+ // `DKIM-Signature:` line; under `self` mode also require at
1008
+ // least one signature whose `d=` tag matches the authenticated
1009
+ // identity's domain part.
1010
+ if (requireDkim) {
1011
+ var headerEnd = _findHeaderEnd(dedotted);
1012
+ var headerBlock = headerEnd === -1
1013
+ ? dedotted.toString("utf8")
1014
+ : dedotted.subarray(0, headerEnd).toString("utf8");
1015
+ var dkimSigs = _extractDkimSignatures(headerBlock);
1016
+ var dkimOk = false;
1017
+ if (dkimSigs.length > 0) {
1018
+ if (dkimRequireMode === "any") {
1019
+ dkimOk = true;
1020
+ } else if (dkimRequireMode === "self") {
1021
+ var actorDomain = _actorDomain(state.actor, state.mailFrom);
1022
+ for (var i = 0; i < dkimSigs.length; i += 1) {
1023
+ var d = _extractDkimDTag(dkimSigs[i]);
1024
+ if (d && actorDomain && d.toLowerCase() === actorDomain.toLowerCase()) {
1025
+ dkimOk = true;
1026
+ break;
1027
+ }
1028
+ }
1029
+ }
1030
+ }
1031
+ if (!dkimOk) {
1032
+ _emit("mail.server.submission.data_refused", {
1033
+ connectionId: state.id,
1034
+ reason: "dkim-required",
1035
+ dkimRequireMode: dkimRequireMode,
1036
+ mailFrom: state.mailFrom,
1037
+ sigCount: dkimSigs.length,
1038
+ actor: state.actor && state.actor.id,
1039
+ }, "denied");
1040
+ _writeReply(socket, REPLY_550_MAILBOX_UNAVAIL,
1041
+ "5.7.20 DKIM-Signature required on outbound submission " +
1042
+ "(dkimRequireMode='" + dkimRequireMode + "'; RFC 6376; bulk-sender 2024)");
1043
+ _resetTransaction(state);
1044
+ return;
1045
+ }
1046
+ }
1047
+
914
1048
  if (opts.agent && typeof opts.agent.handoff === "function") {
915
1049
  opts.agent.handoff({
916
1050
  mailFrom: state.mailFrom,
package/lib/mail-store.js CHANGED
@@ -368,7 +368,12 @@ function _appendMessage(args) {
368
368
  });
369
369
 
370
370
  // Allocate objectid + modseq atomically.
371
- var objectid = "obj_" + bCrypto.generateToken(16).slice(0, 24); // allow:raw-byte-literal 16-byte token, 24-char hex prefix as JMAP objectid (RFC 8474)
371
+ // RFC 8474 §1.5.1: objectid SHOULD be sufficiently long to make
372
+ // collision improbable across the lifetime of the account. 16-byte
373
+ // token = 32-char hex = 128 bits, well above the birthday bound
374
+ // for any plausible message corpus. The prior `.slice(0, 24)` cut
375
+ // entropy to 96 bits; removed.
376
+ var objectid = "obj_" + bCrypto.generateToken(16); // allow:raw-byte-literal — 16-byte token, 32-char hex JMAP objectid (RFC 8474 §1.5.1)
372
377
  var modseq = (folder.modseq_max || 0) + 1;
373
378
  if (!threadRootId) threadRootId = objectid; // root of new thread
374
379
 
@@ -106,7 +106,6 @@
106
106
 
107
107
  var C = require("./constants");
108
108
  var https = require("node:https");
109
- var nodeCrypto = require("node:crypto");
110
109
  var bCrypto = require("./crypto");
111
110
  var { defineClass } = require("./framework-error");
112
111
  var networkDns = require("./network-dns");
@@ -499,7 +498,7 @@ function _encodeWireQuery(name, qtype) {
499
498
  var nameLen = 1;
500
499
  for (var i = 0; i < parts.length; i += 1) nameLen += 1 + Buffer.byteLength(parts[i], "ascii");
501
500
  var buf = Buffer.alloc(12 + nameLen + 4); // allow:raw-byte-literal — RFC 1035 §4.1.1 header (12) + question tail (4) + name
502
- var id = nodeCrypto.randomInt(0, 0x10000);
501
+ var id = bCrypto.randomInt(0, 0x10000); // allow:raw-byte-literal — RFC 1035 §4.1.1 16-bit query ID space
503
502
  buf.writeUInt16BE(id, 0);
504
503
  buf.writeUInt16BE(0x0100, 2); // allow:raw-byte-literal — RFC 1035 §4.1.1 RD=1 flags
505
504
  buf.writeUInt16BE(1, 4); // allow:raw-byte-literal — RFC 1035 §4.1.1 qdcount
@@ -2,7 +2,6 @@
2
2
 
3
3
  var dns = require("node:dns");
4
4
  var net = require("node:net");
5
- var nodeCrypto = require("node:crypto");
6
5
  var https = require("node:https");
7
6
  var nodeTls = require("node:tls");
8
7
  var dnsPromises = dns.promises;
@@ -274,9 +273,10 @@ function _encodeDnsQuery(host, qtype) {
274
273
  for (var i = 0; i < parts.length; i++) nameLen += 1 + Buffer.byteLength(parts[i], "ascii");
275
274
  var buf = Buffer.alloc(12 + nameLen + 4);
276
275
  // Cryptographic RNG for the 16-bit DNS query ID — frustrates poisoning
277
- // attempts that guess the transaction ID. Math.random would be technically
278
- // acceptable (id is non-secret) but we prefer the framework's RNG path.
279
- var id = nodeCrypto.randomInt(0, 0x10000);
276
+ // attempts that guess the transaction ID. Routes through `b.crypto.randomInt`
277
+ // (which wraps nodeCrypto.randomInt) so every framework random-int draw
278
+ // is greppable through one substrate.
279
+ var id = bCrypto.randomInt(0, 0x10000);
280
280
  buf.writeUInt16BE(id, 0);
281
281
  buf.writeUInt16BE(0x0100, 2);
282
282
  buf.writeUInt16BE(1, 4);
@@ -0,0 +1,162 @@
1
+ "use strict";
2
+ /**
3
+ * @module b.promisePool
4
+ * @nav Async
5
+ * @title Promise Pool
6
+ *
7
+ * @intro
8
+ * Bounded-concurrency task runner for promise-returning work — the
9
+ * common gap between `b.workerPool` (worker_threads for CPU-bound
10
+ * work) and `b.queue` (durable cross-process messaging). Wraps the
11
+ * typical "I have N parallel I/O fan-outs and want at most K in
12
+ * flight at any moment" pattern with back-pressure on enqueue
13
+ * (so the caller can't out-run the worker side) and a clean drain
14
+ * path that composes with `b.appShutdown`.
15
+ *
16
+ * Two enqueue paths:
17
+ *
18
+ * - `pool.run(taskFn)` returns a Promise that resolves to the
19
+ * task's return value (or rejects with the task's error). When
20
+ * the pool is at capacity, `run` waits until a slot frees
21
+ * BEFORE the task starts — back-pressure is part of the
22
+ * contract, not an opt.
23
+ *
24
+ * - `pool.fire(taskFn)` is the synchronous-enqueue variant for
25
+ * fan-out from non-async contexts. Returns the same Promise
26
+ * but the call itself can't await — useful inside event
27
+ * handlers that fire-and-forget.
28
+ *
29
+ * Drain semantics: `pool.drain()` resolves when every queued and
30
+ * in-flight task settles. Callers wire this into shutdown via
31
+ * `b.appShutdown.create({ priority: 50, run: () => pool.drain() })`
32
+ * so the process doesn't tear down with work mid-flight.
33
+ *
34
+ * The pool does NOT retry failed tasks; rejection of a task's
35
+ * promise is the caller's signal. Operators that want retry compose
36
+ * `b.retry.withRetry` inside the task body.
37
+ *
38
+ * @card
39
+ * Bounded-concurrency promise pool — back-pressure on enqueue, drain-on-shutdown, no hidden retry. The thing every consumer reaches for p-limit for.
40
+ */
41
+
42
+ var validateOpts = require("./validate-opts");
43
+ var numericBounds = require("./numeric-bounds");
44
+ var { defineClass } = require("./framework-error");
45
+
46
+ var PromisePoolError = defineClass("PromisePoolError", { alwaysPermanent: true });
47
+
48
+ var MAX_CONCURRENCY = 65536; // allow:raw-byte-literal — uint16 ceiling on parallel I/O fan-out
49
+
50
+ /**
51
+ * @primitive b.promisePool.create
52
+ * @signature b.promisePool.create(opts)
53
+ * @since 0.10.8
54
+ * @status stable
55
+ * @related b.workerPool.create, b.appShutdown.create, b.retry.withRetry
56
+ *
57
+ * Build a bounded-concurrency pool. Returns
58
+ * `{ run, fire, drain, size, inFlight, queued, closed }`. The pool is
59
+ * closed via `drain({ close: true })`; subsequent enqueues throw.
60
+ *
61
+ * @opts
62
+ * concurrency: number, // required; integer in [1, 65536]
63
+ * queueLimit: number, // default Infinity; once exceeded, enqueue throws
64
+ *
65
+ * @example
66
+ * var pool = b.promisePool.create({ concurrency: 8 });
67
+ * var results = await Promise.all(items.map(function (item) {
68
+ * return pool.run(function () { return fetchOne(item); });
69
+ * }));
70
+ * await pool.drain({ close: true });
71
+ */
72
+ function create(opts) {
73
+ validateOpts.requireObject(opts, "b.promisePool.create",
74
+ PromisePoolError, "promise-pool/bad-opts");
75
+ numericBounds.requirePositiveFiniteIntIfPresent(opts.concurrency,
76
+ "b.promisePool.create: concurrency", PromisePoolError, "promise-pool/bad-concurrency");
77
+ if (opts.concurrency === undefined || opts.concurrency > MAX_CONCURRENCY) {
78
+ throw new PromisePoolError("promise-pool/bad-concurrency",
79
+ "b.promisePool.create: concurrency must be an integer in [1, " +
80
+ MAX_CONCURRENCY + "] (got " + opts.concurrency + ")");
81
+ }
82
+ var queueLimit = opts.queueLimit === undefined ? Infinity : opts.queueLimit;
83
+ if (queueLimit !== Infinity) {
84
+ numericBounds.requirePositiveFiniteIntIfPresent(queueLimit + 1,
85
+ "b.promisePool.create: queueLimit (must be non-negative int)", PromisePoolError,
86
+ "promise-pool/bad-queue-limit");
87
+ }
88
+ var concurrency = opts.concurrency;
89
+ var inFlight = 0;
90
+ var queue = []; // FIFO of pending { taskFn, resolve, reject }
91
+ var drainWaiters = [];
92
+ var closed = false;
93
+
94
+ function _pump() {
95
+ while (inFlight < concurrency && queue.length > 0) {
96
+ var slot = queue.shift();
97
+ inFlight += 1;
98
+ Promise.resolve().then(function () { return slot.taskFn(); })
99
+ .then(function (val) { slot.resolve(val); _settle(); })
100
+ .catch(function (err) { slot.reject(err); _settle(); });
101
+ }
102
+ if (inFlight === 0 && queue.length === 0 && drainWaiters.length > 0) {
103
+ var waiters = drainWaiters.slice();
104
+ drainWaiters.length = 0;
105
+ for (var i = 0; i < waiters.length; i += 1) waiters[i]();
106
+ }
107
+ }
108
+
109
+ function _settle() {
110
+ inFlight -= 1;
111
+ _pump();
112
+ }
113
+
114
+ function _enqueue(taskFn) {
115
+ if (typeof taskFn !== "function") {
116
+ throw new PromisePoolError("promise-pool/bad-task",
117
+ "b.promisePool: task must be a function returning a value or Promise");
118
+ }
119
+ if (closed) {
120
+ throw new PromisePoolError("promise-pool/closed",
121
+ "b.promisePool: pool is closed (drain({close:true}) was called)");
122
+ }
123
+ if (queue.length >= queueLimit) {
124
+ throw new PromisePoolError("promise-pool/queue-full",
125
+ "b.promisePool: queueLimit=" + queueLimit + " reached");
126
+ }
127
+ return new Promise(function (resolve, reject) {
128
+ queue.push({ taskFn: taskFn, resolve: resolve, reject: reject });
129
+ _pump();
130
+ });
131
+ }
132
+
133
+ function run(taskFn) { return _enqueue(taskFn); }
134
+ function fire(taskFn) { return _enqueue(taskFn); }
135
+
136
+ function drain(drainOpts) {
137
+ drainOpts = drainOpts || {};
138
+ return new Promise(function (resolve) {
139
+ function _done() {
140
+ if (drainOpts.close === true) closed = true;
141
+ resolve();
142
+ }
143
+ if (inFlight === 0 && queue.length === 0) { _done(); return; }
144
+ drainWaiters.push(_done);
145
+ });
146
+ }
147
+
148
+ return {
149
+ run: run,
150
+ fire: fire,
151
+ drain: drain,
152
+ size: function () { return concurrency; },
153
+ inFlight: function () { return inFlight; },
154
+ queued: function () { return queue.length; },
155
+ closed: function () { return closed; },
156
+ };
157
+ }
158
+
159
+ module.exports = {
160
+ create: create,
161
+ PromisePoolError: PromisePoolError,
162
+ };