@cosyte/mllp 0.0.10 → 0.1.0

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/dist/index.mjs CHANGED
@@ -1,7 +1,7 @@
1
+ import { createSecureContext, createServer as createServer$2, connect } from 'tls';
1
2
  import { EventEmitter } from 'events';
2
3
  import { randomUUID } from 'crypto';
3
- import { createServer as createServer$2, createConnection, isIP, SocketAddress } from 'net';
4
- import { createServer as createServer$1, connect } from 'tls';
4
+ import { createServer as createServer$1, createConnection, isIP, SocketAddress } from 'net';
5
5
 
6
6
  // src/framing/registry.ts
7
7
  function createWarning(code, byteOffset, message) {
@@ -508,6 +508,241 @@ var TlsTransport = class {
508
508
  var MLLP_TLS_VERIFY_DISABLED = "MLLP_TLS_VERIFY_DISABLED";
509
509
  var MLLP_BIND_ALL_INTERFACES = "MLLP_BIND_ALL_INTERFACES";
510
510
 
511
+ // src/transport/error.ts
512
+ var MLLP_TLS_CIPHER_LIST_REJECTED = "MLLP_TLS_CIPHER_LIST_REJECTED";
513
+ var MLLP_TLS_CIPHER_OPTION_CONFLICT = "MLLP_TLS_CIPHER_OPTION_CONFLICT";
514
+ var MLLP_TLS_DH_PARAMETERS_REJECTED = "MLLP_TLS_DH_PARAMETERS_REJECTED";
515
+ var TLS_CONFIGURATION_MESSAGES = Object.freeze({
516
+ [MLLP_TLS_CIPHER_LIST_REJECTED]: "the TLS library rejected the configured cipher-suite list; no connection was opened and no fallback to the runtime default list was made",
517
+ [MLLP_TLS_CIPHER_OPTION_CONFLICT]: "tls.atnaTransportSecurity and tls.ciphers both declare the offered cipher-suite list; set exactly one of them",
518
+ [MLLP_TLS_DH_PARAMETERS_REJECTED]: "the configured ephemeral Diffie-Hellman parameters are not usable; nothing was bound and no fallback to a server without Diffie-Hellman parameters was made"
519
+ });
520
+ function tlsConfigurationMessage(code) {
521
+ return TLS_CONFIGURATION_MESSAGES[code];
522
+ }
523
+ var MllpTlsConfigurationError = class _MllpTlsConfigurationError extends Error {
524
+ name = "MllpTlsConfigurationError";
525
+ /**
526
+ * The stable configuration-error code. Public API; branch on this rather
527
+ * than on the message.
528
+ */
529
+ code;
530
+ /**
531
+ * The TLS library's own error, when the runtime is what refused the list.
532
+ * `undefined` when this package refused the configuration itself.
533
+ */
534
+ cause;
535
+ /**
536
+ * Construct a TLS configuration error.
537
+ *
538
+ * @param code - The stable configuration-error code; also selects the message.
539
+ * @param opts - Optional underlying `cause` from the TLS library.
540
+ */
541
+ constructor(code, opts) {
542
+ super(tlsConfigurationMessage(code));
543
+ this.code = code;
544
+ this.cause = opts?.cause;
545
+ if (Error.captureStackTrace) {
546
+ Error.captureStackTrace(this, _MllpTlsConfigurationError);
547
+ }
548
+ }
549
+ };
550
+
551
+ // src/transport/dh-parameters.ts
552
+ var BEGIN_BOUNDARY = "-----BEGIN DH PARAMETERS-----";
553
+ var END_BOUNDARY = "-----END DH PARAMETERS-----";
554
+ var BOM_SPELLINGS = [
555
+ String.fromCharCode(65279),
556
+ String.fromCharCode(239, 187, 191)
557
+ ];
558
+ var BASE64_BODY = /^[A-Za-z0-9+/]+={0,2}$/;
559
+ var DER_SEQUENCE = 48;
560
+ var DER_INTEGER = 2;
561
+ var INT32_CONTENT_OCTETS = 4;
562
+ function readDerElement(der, offset) {
563
+ if (offset + 2 > der.length) return null;
564
+ const tag = der[offset];
565
+ const first = der[offset + 1];
566
+ if (tag === void 0 || first === void 0) return null;
567
+ let contentStart;
568
+ let length;
569
+ if (first < 128) {
570
+ contentStart = offset + 2;
571
+ length = first;
572
+ } else {
573
+ const lengthBytes = first & 127;
574
+ if (lengthBytes === 0 || lengthBytes > 4) return null;
575
+ if (offset + 2 + lengthBytes > der.length) return null;
576
+ length = 0;
577
+ for (let i = 0; i < lengthBytes; i += 1) {
578
+ const byte = der[offset + 2 + i];
579
+ if (byte === void 0) return null;
580
+ length = length * 256 + byte;
581
+ }
582
+ contentStart = offset + 2 + lengthBytes;
583
+ }
584
+ const contentEnd = contentStart + length;
585
+ if (contentEnd > der.length) return null;
586
+ return { tag, contentStart, contentEnd };
587
+ }
588
+ function readsAsInt32(der, element) {
589
+ const octets = element.contentEnd - element.contentStart;
590
+ if (octets > INT32_CONTENT_OCTETS) return false;
591
+ if (octets < 2) return true;
592
+ const first = der[element.contentStart];
593
+ const second = der[element.contentStart + 1];
594
+ if (first === void 0 || second === void 0) return false;
595
+ const secondIsNegative = (second & 128) !== 0;
596
+ if (first === 0 && !secondIsNegative) return false;
597
+ if (first === 255 && secondIsNegative) return false;
598
+ return true;
599
+ }
600
+ function magnitudeOf(der, element) {
601
+ let start = element.contentStart;
602
+ while (start + 1 < element.contentEnd && der[start] === 0) start += 1;
603
+ return der.subarray(start, element.contentEnd);
604
+ }
605
+ function compareMagnitudes(left, right) {
606
+ if (left.length !== right.length) return left.length < right.length ? -1 : 1;
607
+ return Buffer.compare(left, right);
608
+ }
609
+ function isOneBelow(value, oddPrime) {
610
+ if (value.length !== oddPrime.length) return false;
611
+ const last = value.length - 1;
612
+ for (let i = 0; i < last; i += 1) {
613
+ if (value[i] !== oddPrime[i]) return false;
614
+ }
615
+ const tail = value[last];
616
+ const primeTail = oddPrime[last];
617
+ return tail !== void 0 && primeTail !== void 0 && tail === primeTail - 1;
618
+ }
619
+ function isUsableGroup(der, prime, generator) {
620
+ const primeValue = magnitudeOf(der, prime);
621
+ const generatorValue = magnitudeOf(der, generator);
622
+ const lowest = primeValue[primeValue.length - 1];
623
+ if (lowest === void 0 || (lowest & 1) === 0) return false;
624
+ const smallest = generatorValue[0];
625
+ if (generatorValue.length === 1 && (smallest === void 0 || smallest < 2)) return false;
626
+ if (compareMagnitudes(generatorValue, primeValue) >= 0) return false;
627
+ return !isOneBelow(generatorValue, primeValue);
628
+ }
629
+ function sanitizeLine(line) {
630
+ let end = line.length;
631
+ while (end > 0 && line.charCodeAt(end - 1) <= 32) end -= 1;
632
+ return line.slice(0, end);
633
+ }
634
+ function readArmouredBody(text) {
635
+ let head = text;
636
+ for (const bom of BOM_SPELLINGS) {
637
+ if (head.startsWith(bom)) {
638
+ head = head.slice(bom.length);
639
+ break;
640
+ }
641
+ }
642
+ const lines = head.split("\n").map(sanitizeLine);
643
+ const begin = lines.indexOf(BEGIN_BOUNDARY);
644
+ if (begin === -1) return null;
645
+ const end = lines.indexOf(END_BOUNDARY, begin + 1);
646
+ if (end === -1) return null;
647
+ const inner = lines.slice(begin + 1, end);
648
+ const blank = inner.indexOf("");
649
+ if (blank !== -1) {
650
+ if (blank !== 0) return null;
651
+ if (inner.indexOf("", 1) !== -1) return null;
652
+ }
653
+ const body = inner.join("").replace(/\s+/g, "");
654
+ return body.length === 0 ? null : body;
655
+ }
656
+ function isDhParametersPem(input) {
657
+ const text = typeof input === "string" ? input : input.toString("latin1");
658
+ const body = readArmouredBody(text);
659
+ if (body === null) return false;
660
+ if (body.length % 4 !== 0 || !BASE64_BODY.test(body)) return false;
661
+ const der = Buffer.from(body, "base64");
662
+ const sequence = readDerElement(der, 0);
663
+ if (sequence === null || sequence.tag !== DER_SEQUENCE) return false;
664
+ if (sequence.contentEnd !== der.length) return false;
665
+ const prime = readDerElement(der, sequence.contentStart);
666
+ if (prime === null || prime.tag !== DER_INTEGER) return false;
667
+ const generator = readDerElement(der, prime.contentEnd);
668
+ if (generator === null || generator.tag !== DER_INTEGER) return false;
669
+ if (prime.contentEnd === prime.contentStart) return false;
670
+ if (generator.contentEnd === generator.contentStart) return false;
671
+ if (!isUsableGroup(der, prime, generator)) return false;
672
+ if (generator.contentEnd === sequence.contentEnd) return true;
673
+ const privateValueLength = readDerElement(der, generator.contentEnd);
674
+ if (privateValueLength === null || privateValueLength.tag !== DER_INTEGER) return false;
675
+ if (privateValueLength.contentEnd !== sequence.contentEnd) return false;
676
+ return readsAsInt32(der, privateValueLength);
677
+ }
678
+
679
+ // src/transport/tls-cipher-policy.ts
680
+ var ATNA_CIPHER_SUITES = Object.freeze([
681
+ "DHE-RSA-AES128-GCM-SHA256",
682
+ "ECDHE-RSA-AES128-GCM-SHA256",
683
+ "DHE-RSA-AES256-GCM-SHA384",
684
+ "ECDHE-RSA-AES256-GCM-SHA384"
685
+ ]);
686
+ var TLS13_DEFAULT_CIPHER_SUITES = Object.freeze([
687
+ "TLS_AES_256_GCM_SHA384",
688
+ "TLS_CHACHA20_POLY1305_SHA256",
689
+ "TLS_AES_128_GCM_SHA256"
690
+ ]);
691
+ var ATNA_CIPHER_LIST = [
692
+ ...TLS13_DEFAULT_CIPHER_SUITES,
693
+ ...ATNA_CIPHER_SUITES
694
+ ].join(":");
695
+ function assertUsableDhParameters(dhParameters) {
696
+ if (!isDhParametersPem(dhParameters)) {
697
+ throw new MllpTlsConfigurationError(MLLP_TLS_DH_PARAMETERS_REJECTED);
698
+ }
699
+ try {
700
+ createSecureContext({ dhparam: dhParameters });
701
+ } catch (err) {
702
+ throw new MllpTlsConfigurationError(MLLP_TLS_DH_PARAMETERS_REJECTED, {
703
+ cause: err instanceof Error ? err : new Error(String(err))
704
+ });
705
+ }
706
+ }
707
+ function resolveTlsCipherPolicy(opts, side) {
708
+ const atna = opts.atnaTransportSecurity === true;
709
+ if (atna && opts.ciphers !== void 0) {
710
+ throw new MllpTlsConfigurationError(MLLP_TLS_CIPHER_OPTION_CONFLICT);
711
+ }
712
+ const ciphers = atna ? ATNA_CIPHER_LIST : opts.ciphers;
713
+ const supplied = side === "server" ? opts.dhParameters : void 0;
714
+ if (supplied !== void 0) assertUsableDhParameters(supplied);
715
+ const dhparam = supplied ?? (atna && side === "server" ? "auto" : void 0);
716
+ if (ciphers !== void 0) {
717
+ try {
718
+ createSecureContext({ ciphers });
719
+ } catch (err) {
720
+ throw new MllpTlsConfigurationError(MLLP_TLS_CIPHER_LIST_REJECTED, {
721
+ cause: err instanceof Error ? err : new Error(String(err))
722
+ });
723
+ }
724
+ }
725
+ if (ciphers === void 0) return dhparam === void 0 ? {} : { dhparam };
726
+ return dhparam === void 0 ? { ciphers } : { ciphers, dhparam };
727
+ }
728
+
729
+ // src/transport/negotiated-tls.ts
730
+ function readNegotiatedTlsParameters(socket, host, port) {
731
+ if (!("getProtocol" in socket)) return null;
732
+ const protocolVersion = socket.getProtocol();
733
+ if (protocolVersion === null) return null;
734
+ const cipher = socket.getCipher();
735
+ const cipherSuite = cipher.standardName === "" ? cipher.name : cipher.standardName;
736
+ if (cipherSuite === "") return null;
737
+ return Object.freeze({
738
+ protocolVersion,
739
+ cipherSuite,
740
+ host,
741
+ port,
742
+ timestamp: /* @__PURE__ */ new Date()
743
+ });
744
+ }
745
+
511
746
  // src/connection/error.ts
512
747
  var MllpConnectionError = class _MllpConnectionError extends Error {
513
748
  name = "MllpConnectionError";
@@ -1057,13 +1292,18 @@ function readMshSegment(buf) {
1057
1292
  const fields = buf.subarray(at.start, at.end).toString(CONTROL_ID_ENCODING).split(fieldSep);
1058
1293
  return { fieldSep, fields };
1059
1294
  }
1060
- function extractMshControlId(buf) {
1061
- const msh = readMshSegment(buf);
1295
+ function controlIdFromMsh(msh) {
1062
1296
  if (msh === null) return null;
1063
1297
  const id = msh.fields[9];
1064
1298
  return id === void 0 || id === "" ? null : id;
1065
1299
  }
1066
1300
  function extractMsaControlId(buf) {
1301
+ const msa = readMsaSegment(buf);
1302
+ if (msa === null) return null;
1303
+ const id = msa.fields[2];
1304
+ return id === void 0 || id === "" ? null : id;
1305
+ }
1306
+ function readMsaSegment(buf) {
1067
1307
  const msh = readMshSegment(buf);
1068
1308
  if (msh === null) return null;
1069
1309
  const fieldSep = msh.fieldSep.charCodeAt(0);
@@ -1075,21 +1315,8 @@ function extractMsaControlId(buf) {
1075
1315
  segEnd++;
1076
1316
  }
1077
1317
  if (segEnd - segStart >= 4 && buf[segStart] === ASCII_M && buf[segStart + 1] === ASCII_S && buf[segStart + 2] === ASCII_A && buf[segStart + 3] === fieldSep) {
1078
- let fieldIndex = 0;
1079
- let fieldStart = 0;
1080
- for (let i = segStart + 3; i <= segEnd; i++) {
1081
- const b = i < segEnd ? buf[i] : fieldSep;
1082
- if (b === fieldSep || b === SEGMENT_SEPARATOR_CR || b === SEGMENT_SEPARATOR_LF) {
1083
- fieldIndex++;
1084
- if (fieldIndex === 2) {
1085
- fieldStart = i + 1;
1086
- } else if (fieldIndex === 3) {
1087
- if (fieldStart >= i) return null;
1088
- return buf.subarray(fieldStart, i).toString(CONTROL_ID_ENCODING);
1089
- }
1090
- }
1091
- }
1092
- return null;
1318
+ const fields = buf.subarray(segStart, segEnd).toString(CONTROL_ID_ENCODING).split(msh.fieldSep);
1319
+ return { fieldSep: msh.fieldSep, fields };
1093
1320
  }
1094
1321
  while (segEnd < end && (buf[segEnd] === SEGMENT_SEPARATOR_CR || buf[segEnd] === SEGMENT_SEPARATOR_LF)) {
1095
1322
  segEnd++;
@@ -1180,6 +1407,125 @@ function timestamp14() {
1180
1407
  return String(d.getUTCFullYear()) + pad(d.getUTCMonth() + 1) + pad(d.getUTCDate()) + pad(d.getUTCHours()) + pad(d.getUTCMinutes()) + pad(d.getUTCSeconds());
1181
1408
  }
1182
1409
 
1410
+ // src/internal/ack-mode.ts
1411
+ var TABLE_0155 = Object.freeze(["AL", "NE", "ER", "SU"]);
1412
+ var TABLE_0008 = Object.freeze(["AA", "AE", "AR", "CA", "CE", "CR"]);
1413
+ var MSH_15_INDEX = 14;
1414
+ var MSH_16_INDEX = 15;
1415
+ var SPACE = " ";
1416
+ function delimitersFrom(encodingCharacters) {
1417
+ const chars = encodingCharacters ?? "";
1418
+ return {
1419
+ componentSep: chars.length >= 1 ? chars.charAt(0) : null,
1420
+ repetitionSep: chars.length >= 2 ? chars.charAt(1) : null
1421
+ };
1422
+ }
1423
+ function trimSpaces(value) {
1424
+ let start = 0;
1425
+ let end = value.length;
1426
+ while (start < end && value.charAt(start) === SPACE) start++;
1427
+ while (end > start && value.charAt(end - 1) === SPACE) end--;
1428
+ return value.substring(start, end);
1429
+ }
1430
+ function readTableField(raw, delimiters, table) {
1431
+ if (raw === void 0) return { kind: "null", byteLength: 0 };
1432
+ const byteLength = raw.length;
1433
+ let value = raw;
1434
+ if (delimiters.repetitionSep !== null) {
1435
+ const at = value.indexOf(delimiters.repetitionSep);
1436
+ if (at !== -1) value = value.substring(0, at);
1437
+ }
1438
+ if (delimiters.componentSep !== null) {
1439
+ const at = value.indexOf(delimiters.componentSep);
1440
+ if (at !== -1) value = value.substring(0, at);
1441
+ }
1442
+ value = trimSpaces(value);
1443
+ if (value === "" || value === '""') return { kind: "null", byteLength };
1444
+ for (const code of table) {
1445
+ if (code === value) return { kind: "code", code, byteLength };
1446
+ }
1447
+ return { kind: "unrecognised", byteLength };
1448
+ }
1449
+ var ORIGINAL_MODE = Object.freeze({
1450
+ enhanced: false,
1451
+ acceptCondition: "NE",
1452
+ applicationCondition: "AL",
1453
+ awaitsApplicationAck: false,
1454
+ acceptFieldUnrecognised: false,
1455
+ applicationFieldUnrecognised: false
1456
+ });
1457
+ function classifyOutboundAckMode(msh) {
1458
+ if (msh === null) return ORIGINAL_MODE;
1459
+ const delimiters = delimitersFrom(msh.fields[1]);
1460
+ const accept = readTableField(msh.fields[MSH_15_INDEX], delimiters, TABLE_0155);
1461
+ const application = readTableField(msh.fields[MSH_16_INDEX], delimiters, TABLE_0155);
1462
+ if (accept.kind === "null" && application.kind === "null") return ORIGINAL_MODE;
1463
+ const applicationCondition = application.kind === "code" ? application.code : "AL";
1464
+ return Object.freeze({
1465
+ enhanced: true,
1466
+ acceptCondition: accept.kind === "code" ? accept.code : "NE",
1467
+ applicationCondition,
1468
+ awaitsApplicationAck: applicationCondition !== "NE",
1469
+ acceptFieldUnrecognised: accept.kind === "unrecognised",
1470
+ applicationFieldUnrecognised: application.kind === "unrecognised"
1471
+ });
1472
+ }
1473
+ function readAcceptCondition(msh) {
1474
+ if (msh === null) return { kind: "null", byteLength: 0 };
1475
+ return readTableField(msh.fields[MSH_15_INDEX], delimitersFrom(msh.fields[1]), TABLE_0155);
1476
+ }
1477
+ function classifyMsa1(ackPayload) {
1478
+ const msh = readMshSegment(ackPayload);
1479
+ if (msh === null) return { kind: "unclassifiable", byteLength: 0 };
1480
+ const msa = readMsaSegment(ackPayload);
1481
+ if (msa === null) return { kind: "null", byteLength: 0 };
1482
+ const read = readTableField(msa.fields[1], delimitersFrom(msh.fields[1]), TABLE_0008);
1483
+ if (read.kind === "code") return { kind: "code", code: read.code, byteLength: read.byteLength };
1484
+ if (read.kind === "null") return { kind: "null", byteLength: read.byteLength };
1485
+ return { kind: "unclassifiable", byteLength: read.byteLength };
1486
+ }
1487
+ function acceptAckRequested(accept, disposition) {
1488
+ if (accept.kind !== "code") return false;
1489
+ switch (accept.code) {
1490
+ case "AL":
1491
+ return true;
1492
+ case "NE":
1493
+ return false;
1494
+ case "ER":
1495
+ return disposition === "AE" || disposition === "AR";
1496
+ case "SU":
1497
+ return disposition === "AA";
1498
+ }
1499
+ }
1500
+ function acceptModeCounterpart(code) {
1501
+ switch (code) {
1502
+ case "AA":
1503
+ return "CA";
1504
+ case "AE":
1505
+ return "CE";
1506
+ case "AR":
1507
+ return "CR";
1508
+ case "CA":
1509
+ case "CE":
1510
+ case "CR":
1511
+ return code;
1512
+ }
1513
+ }
1514
+
1515
+ // src/internal/ack-mode-diagnostics.ts
1516
+ var ACK_MODE_MESSAGES = Object.freeze({
1517
+ MLLP_ACK_ACCEPT_TYPE_UNRECOGNISED: "MSH-15 (accept acknowledgment type) carried a value outside HL7 Table 0155; it was read as the table's default and the message was transmitted or answered unchanged. Field values are withheld: a field value is payload content and this diagnostic goes to a log.",
1518
+ MLLP_ACK_APPLICATION_TYPE_UNRECOGNISED: "MSH-16 (application acknowledgment type) carried a value outside HL7 Table 0155; it was read as the table's default and the message was transmitted unchanged. Field values are withheld: a field value is payload content and this diagnostic goes to a log.",
1519
+ MLLP_ACK_TWO_PHASE_UNAVAILABLE: "An enhanced-mode send was made on a client that correlates acknowledgements in order rather than by control ID, so two-phase correlation was not applied and the first acknowledgement matched to this send settles it. Set correlateByControlId to enable it.",
1520
+ MLLP_ACK_COMMIT_ALREADY_REPORTED: "A further commit-accept acknowledgement arrived for a send whose commit disposition had already been reported; it was surfaced and neither re-reported nor allowed to restart the application-acknowledgement wait. The send is still pending.",
1521
+ MLLP_ACK_MSA1_ABSENT: "An acknowledgement matched to an enhanced-mode send carried no MSA-1 acknowledgement code, so it was not classified into a mode and the send was left pending. Field values are withheld; msa1Bytes gives the field's length.",
1522
+ MLLP_ACK_MSA1_UNCLASSIFIABLE: "An acknowledgement matched to an enhanced-mode send carried an MSA-1 value outside HL7 Table 0008, so it was not classified into a mode and the send was left pending. Field values are withheld; msa1Bytes gives the field's length.",
1523
+ MLLP_ACK_SEND_ALREADY_DISPOSED: "An acknowledgement arrived for a send that had already been settled or failed; it was dropped and that outcome was left unchanged. Field values are withheld: a control ID is payload content and this diagnostic goes to a log."
1524
+ });
1525
+ function ackModeDiagnosticMessage(code) {
1526
+ return ACK_MODE_MESSAGES[code];
1527
+ }
1528
+
1183
1529
  // src/internal/safe-emit.ts
1184
1530
  function safeEmit(emitter, event, payload, onSubscriberThrow) {
1185
1531
  try {
@@ -1242,6 +1588,16 @@ var MllpServer = class extends EventEmitter {
1242
1588
  _closedTotal = 0;
1243
1589
  /** Count of `'tlsClientError'` events since listen(). */
1244
1590
  _tlsClientErrorsTotal = 0;
1591
+ /**
1592
+ * A TLS configuration this server will not serve on: an offered-suite list
1593
+ * it cannot honour, or ephemeral Diffie-Hellman parameters it cannot use. Set
1594
+ * at construction, and the ONLY thing `listen()` consults before it binds:
1595
+ * such a server never binds, never accepts, and never negotiates anything,
1596
+ * which is what makes "no fallback to the default list, and none to a server
1597
+ * with no Diffie-Hellman parameters" a structural property rather than a
1598
+ * promise. `null` when the configuration is fine.
1599
+ */
1600
+ _tlsConfigError = null;
1245
1601
  /**
1246
1602
  * Construct an MLLP server. Created idle; call `listen()` (or use
1247
1603
  * {@link createServer}/{@link createStarterServer}) to begin accepting connections.
@@ -1252,17 +1608,28 @@ var MllpServer = class extends EventEmitter {
1252
1608
  super();
1253
1609
  this._opts = opts;
1254
1610
  this._isTls = opts.tls !== void 0;
1611
+ let cipherPolicy = null;
1255
1612
  if (opts.tls !== void 0) {
1613
+ try {
1614
+ cipherPolicy = resolveTlsCipherPolicy(opts.tls, "server");
1615
+ } catch (err) {
1616
+ if (!(err instanceof MllpTlsConfigurationError)) throw err;
1617
+ this._tlsConfigError = err;
1618
+ }
1619
+ }
1620
+ if (opts.tls !== void 0 && cipherPolicy === null) {
1621
+ this._netServer = createServer$1();
1622
+ } else if (opts.tls !== void 0 && cipherPolicy !== null) {
1256
1623
  const tlsOpts = opts.tls;
1257
1624
  const clientAuth = tlsOpts.clientAuth ?? "NONE";
1258
- const tlsServer = createServer$1({
1625
+ const tlsServer = createServer$2({
1259
1626
  cert: tlsOpts.cert,
1260
1627
  key: tlsOpts.key,
1261
1628
  ...tlsOpts.ca !== void 0 ? { ca: tlsOpts.ca } : {},
1262
1629
  ...tlsOpts.passphrase !== void 0 ? { passphrase: tlsOpts.passphrase } : {},
1263
1630
  minVersion: tlsOpts.minVersion ?? "TLSv1.2",
1264
1631
  ...tlsOpts.maxVersion !== void 0 ? { maxVersion: tlsOpts.maxVersion } : {},
1265
- ...tlsOpts.ciphers !== void 0 ? { ciphers: tlsOpts.ciphers } : {},
1632
+ ...cipherPolicy,
1266
1633
  requestCert: clientAuth !== "NONE",
1267
1634
  rejectUnauthorized: clientAuth === "MUST"
1268
1635
  });
@@ -1283,7 +1650,7 @@ var MllpServer = class extends EventEmitter {
1283
1650
  this._emitContained("tlsClientError", event);
1284
1651
  });
1285
1652
  } else {
1286
- this._netServer = createServer$2();
1653
+ this._netServer = createServer$1();
1287
1654
  this._netServer.on("connection", (socket) => {
1288
1655
  this._onSocketAccepted(socket);
1289
1656
  });
@@ -1308,6 +1675,14 @@ var MllpServer = class extends EventEmitter {
1308
1675
  * binds raced each other's post-bind safety checks). Call `close()` before
1309
1676
  * re-listening; sequential `listen()` → `close()` → `listen()` is fine.
1310
1677
  *
1678
+ * **A refused TLS configuration** (the runtime rejects the offered suite
1679
+ * list, `tls.atnaTransportSecurity` and `tls.ciphers` both declare one, or
1680
+ * `tls.dhParameters` are not usable) rejects every `listen()` on this server
1681
+ * with a typed `MllpTlsConfigurationError`, checked before the bind. Such a
1682
+ * server never listens, never negotiates on any other list and never runs
1683
+ * without the Diffie-Hellman parameters it was given; construct a new one
1684
+ * with a configuration that resolves.
1685
+ *
1311
1686
  * **`close()` during an in-flight `listen()`** rejects that `listen()` with
1312
1687
  * a typed `MllpConnectionError` (never a hang) and clears the single-flight
1313
1688
  * guard, a subsequent `listen()` on the same server works. This makes
@@ -1336,6 +1711,9 @@ var MllpServer = class extends EventEmitter {
1336
1711
  if (signal?.aborted) {
1337
1712
  return Promise.reject(new DOMException("Aborted", "AbortError"));
1338
1713
  }
1714
+ if (this._tlsConfigError !== null) {
1715
+ return Promise.reject(this._tlsConfigError);
1716
+ }
1339
1717
  if (this._listening || this._listenInFlight) {
1340
1718
  return Promise.reject(
1341
1719
  new MllpConnectionError(
@@ -1730,6 +2108,10 @@ var MllpServer = class extends EventEmitter {
1730
2108
  peerCertificate
1731
2109
  })
1732
2110
  );
2111
+ const negotiated = readNegotiatedTlsParameters(socket, this._host ?? "", this._port ?? 0);
2112
+ if (negotiated !== null) {
2113
+ this._emitContained("tlsNegotiated", negotiated);
2114
+ }
1733
2115
  }
1734
2116
  /**
1735
2117
  * Extract a minimal, content-free peer-certificate summary (ATNA ITI-19
@@ -1773,25 +2155,26 @@ var MllpServer = class extends EventEmitter {
1773
2155
  */
1774
2156
  async _sendCommitAck(payload, meta, conn) {
1775
2157
  const handler = this._opts.onMessage;
2158
+ const selectCode = this._ackModeSelector(payload, conn);
1776
2159
  if (handler === void 0) {
1777
2160
  this._dispatchAck(
1778
2161
  conn,
1779
- buildRawAck(payload, this._resolveAutoAckPositive(payload, meta, conn))
2162
+ buildRawAck(payload, this._resolveAutoAckPositive(payload, meta, conn, selectCode))
1780
2163
  );
1781
2164
  return;
1782
2165
  }
1783
2166
  let code;
1784
2167
  try {
1785
2168
  await handler(payload, meta, conn);
1786
- code = this._resolveAutoAckPositive(payload, meta, conn);
2169
+ code = this._resolveAutoAckPositive(payload, meta, conn, selectCode);
1787
2170
  } catch (err) {
1788
2171
  const nack = resolveNackCode(err);
1789
- code = nack;
2172
+ code = selectCode(nack);
1790
2173
  this._emitContained(
1791
2174
  "nack",
1792
2175
  Object.freeze({
1793
2176
  connectionId: conn.connectionId,
1794
- ackCode: nack,
2177
+ ackCode: code,
1795
2178
  reason: "handler-rejected"
1796
2179
  })
1797
2180
  );
@@ -1821,21 +2204,82 @@ var MllpServer = class extends EventEmitter {
1821
2204
  * discard, never a neighbouring frame's trailing junk.
1822
2205
  *
1823
2206
  * `'discarded-bytes'` takes precedence in the reason when both hold, it is the root cause.
2207
+ *
2208
+ * `selectCode` puts the resolved disposition in the half of HL7 Table 0008 the inbound
2209
+ * asked for; it is the identity for a message that asked for nothing.
1824
2210
  */
1825
- _resolveAutoAckPositive(payload, meta, conn) {
2211
+ _resolveAutoAckPositive(payload, meta, conn, selectCode) {
1826
2212
  const discarded = meta.warnings.some((w) => w.code === "MLLP_TRAILING_BYTES");
1827
2213
  const uncorrelatable = rawAckUncorrelatable(payload);
1828
- if (!discarded && !uncorrelatable) return "AA";
2214
+ if (!discarded && !uncorrelatable) return selectCode("AA");
1829
2215
  const reason = discarded ? "discarded-bytes" : "uncorrelatable-inbound";
2216
+ const code = selectCode("AE");
1830
2217
  this._emitContained(
1831
2218
  "nack",
1832
2219
  Object.freeze({
1833
2220
  connectionId: conn.connectionId,
1834
- ackCode: "AE",
2221
+ ackCode: code,
1835
2222
  reason
1836
2223
  })
1837
2224
  );
1838
- return "AE";
2225
+ return code;
2226
+ }
2227
+ /**
2228
+ * Build this message's acknowledgement-code selector: the function that puts a resolved
2229
+ * disposition in the half of HL7 Table 0008 the inbound asked for.
2230
+ *
2231
+ * MSH-15 (accept acknowledgment type, HL7 Table 0155) says under which conditions the
2232
+ * sender wants an **accept** acknowledgement, and it is a condition rather than a switch:
2233
+ * `AL` always, `NE` never, `ER` on an error or reject only, `SU` on success only. So the
2234
+ * request cannot be evaluated until the disposition is known, which is why this returns a
2235
+ * function rather than a code. Where the answer is yes, the disposition is emitted as its
2236
+ * accept-mode counterpart: `CA` where this server would answer `AA`, `CE` where it would
2237
+ * answer `AE`, `CR` where it would answer `AR`.
2238
+ *
2239
+ * Exactly one acknowledgement still goes out per inbound message; nothing here sends a
2240
+ * second one, and the later application acknowledgement of the two-part protocol remains
2241
+ * the consumer's to send.
2242
+ *
2243
+ * Three inbound shapes leave the table entirely and keep this server's own answer:
2244
+ *
2245
+ * * a header this server cannot scan for MSH-15: it behaves exactly as it would have,
2246
+ * adding no new failure and no new report;
2247
+ * * a **batch or concatenated** frame: it keeps its warned non-positive answer and
2248
+ * never selects an accept-mode code, whatever MSH-15 says. A single MSA-2 can echo one
2249
+ * control ID, and commit-accepting a batch nobody read is the same lie in the other
2250
+ * half of the table;
2251
+ * * an MSH-15 outside Table 0155: reported, and answered exactly as it would have been.
2252
+ * Downgrading a message the handler committed would make a discharged sender resend a
2253
+ * message already in storage.
2254
+ */
2255
+ _ackModeSelector(payload, conn) {
2256
+ const identity = (disposition) => disposition;
2257
+ const msh = readMshSegment(payload);
2258
+ if (msh === null) return identity;
2259
+ const accept = readAcceptCondition(msh);
2260
+ if (accept.kind === "unrecognised") {
2261
+ this._emitAckModeWarning("MLLP_ACK_ACCEPT_TYPE_UNRECOGNISED", conn);
2262
+ return identity;
2263
+ }
2264
+ if (accept.kind === "null") return identity;
2265
+ if (containsBatchOrExtraMessage(payload)) return identity;
2266
+ return (disposition) => acceptAckRequested(accept, disposition) ? acceptModeCounterpart(disposition) : disposition;
2267
+ }
2268
+ /**
2269
+ * Emit the frozen `'ackModeWarning'` event.
2270
+ *
2271
+ * CONTAINED for the same reason every other emit on this path is: it is reached from a
2272
+ * `void`-ed async acknowledgement task, and a throwing metrics tap must never be able to
2273
+ * suppress the acknowledgement that follows it.
2274
+ */
2275
+ _emitAckModeWarning(code, conn) {
2276
+ const event = Object.freeze({
2277
+ connectionId: conn.connectionId,
2278
+ code,
2279
+ message: ackModeDiagnosticMessage(code),
2280
+ timestamp: /* @__PURE__ */ new Date()
2281
+ });
2282
+ this._emitContained("ackModeWarning", event);
1839
2283
  }
1840
2284
  /**
1841
2285
  * Run `onMessage` for its side effects only (custom-ACK and manual modes), guarding
@@ -1931,6 +2375,18 @@ var Correlator = class {
1931
2375
  _opts;
1932
2376
  _pending = /* @__PURE__ */ new Map();
1933
2377
  _graveyard = /* @__PURE__ */ new Map();
2378
+ /**
2379
+ * Control IDs of two-phase sends already disposed of (settled or failed), with the epoch
2380
+ * ms at which that happened.
2381
+ *
2382
+ * Separate from the graveyard, and separate on purpose. The graveyard is a memory of
2383
+ * **timed-out** sends and is evicted one-shot, on the first late acknowledgement that
2384
+ * hits it. This one is a memory of **every** disposal and is evicted by elapsed time
2385
+ * alone, so a peer that repeats an acknowledgement twice inside the window is told
2386
+ * "already disposed" both times rather than "already disposed" and then "no such send".
2387
+ * The two windows are the same length; only the eviction rules differ.
2388
+ */
2389
+ _disposed = /* @__PURE__ */ new Map();
1934
2390
  _sendSeq = 0;
1935
2391
  _queueBytes = 0;
1936
2392
  /**
@@ -1952,6 +2408,10 @@ var Correlator = class {
1952
2408
  onWarning: opts.onWarning,
1953
2409
  onTimeout: opts.onTimeout,
1954
2410
  onUnmatchedAck: opts.onUnmatchedAck,
2411
+ onAckModeWarning: opts.onAckModeWarning,
2412
+ onCommitReported: opts.onCommitReported,
2413
+ onCommitRejected: opts.onCommitRejected,
2414
+ onApplicationAckTimeout: opts.onApplicationAckTimeout,
1955
2415
  now: opts.now ?? Date.now
1956
2416
  };
1957
2417
  }
@@ -1967,12 +2427,20 @@ var Correlator = class {
1967
2427
  get graveyardSize() {
1968
2428
  return this._graveyard.size;
1969
2429
  }
2430
+ /** Number of disposed two-phase sends still inside their remembered window. */
2431
+ get disposedSize() {
2432
+ return this._disposed.size;
2433
+ }
1970
2434
  /**
1971
2435
  * Enqueue a new send awaiting its ACK. Returns the assigned
1972
2436
  * `correlationKey`, or `null` if `maxInFlight` is reached (caller awaits
1973
2437
  * drain, `pipeline:false`).
2438
+ *
2439
+ * `twoPhase` is the enhanced-mode state for a send being correlated by control ID.
2440
+ * Omitting it (or passing `null`) is what every other send does, and such an entry is
2441
+ * settled by the first acknowledgement matched to it, exactly as before.
1974
2442
  */
1975
- enqueue(frame, controlIdOrNull, resolve, reject) {
2443
+ enqueue(frame, controlIdOrNull, resolve, reject, twoPhase = null) {
1976
2444
  if (this._pending.size >= this._opts.maxInFlight) return null;
1977
2445
  const key = this._opts.mode === "controlId" ? controlIdOrNull ?? `__seq-${++this._sendSeq}` : ++this._sendSeq;
1978
2446
  const entry = {
@@ -1982,7 +2450,8 @@ var Correlator = class {
1982
2450
  byteCount: frame.length,
1983
2451
  sentAt: null,
1984
2452
  resolve,
1985
- reject
2453
+ reject,
2454
+ twoPhase
1986
2455
  };
1987
2456
  this._pending.set(key, entry);
1988
2457
  this._queueBytes += frame.length;
@@ -2008,15 +2477,19 @@ var Correlator = class {
2008
2477
  * and returns `null`; otherwise fires `onUnmatchedAck` with the id's
2009
2478
  * **byte length** and returns `null`.
2010
2479
  *
2480
+ * A live-store hit on a **two-phase** entry is decided by the acknowledgement's MSA-1
2481
+ * instead, see {@link Correlator.matchTwoPhase}.
2482
+ *
2011
2483
  * Triggers lazy graveyard eviction.
2012
2484
  *
2013
- * @param _payload Inbound ACK bytes (framing stripped). MSA-2 extraction
2485
+ * @param payload Inbound ACK bytes (framing stripped). MSA-2 extraction
2014
2486
  * happens at MllpClient's `_onAckPayload` hook; this method takes the
2015
- * already-extracted control ID as a parameter.
2487
+ * already-extracted control ID as a parameter. The bytes themselves are read
2488
+ * only for a two-phase entry's MSA-1.
2016
2489
  * @param controlIdFromAck MSA-2 extracted from ACK (controlId mode only).
2017
2490
  * @param byteOffsetFromAck Stream offset; forwarded to `onWarning`.
2018
2491
  */
2019
- matchAck(_payload, controlIdFromAck = null, byteOffsetFromAck = 0) {
2492
+ matchAck(payload, controlIdFromAck = null, byteOffsetFromAck = 0) {
2020
2493
  this._evictGraveyardDue(this._opts.now());
2021
2494
  if (this._opts.mode === "fifo") {
2022
2495
  const iter = this._pending.values().next();
@@ -2035,11 +2508,25 @@ var Correlator = class {
2035
2508
  }
2036
2509
  const live = this._pending.get(controlIdFromAck);
2037
2510
  if (live !== void 0) {
2511
+ if (live.twoPhase !== null) {
2512
+ return this.matchTwoPhase(live, payload, byteOffsetFromAck);
2513
+ }
2038
2514
  this._pending.delete(controlIdFromAck);
2039
2515
  this._queueBytes -= live.byteCount;
2040
2516
  if (live.sentAt !== null) this._inFlight -= 1;
2041
2517
  return live;
2042
2518
  }
2519
+ const disposedAt = this._disposed.get(controlIdFromAck);
2520
+ if (disposedAt !== void 0) {
2521
+ this._ackModeWarn("MLLP_ACK_SEND_ALREADY_DISPOSED", {
2522
+ msa1Bytes: null,
2523
+ ackCode: null,
2524
+ controlIdBytes: controlIdFromAck.length,
2525
+ byteOffset: byteOffsetFromAck,
2526
+ elapsedSinceSendMs: this._opts.now() - disposedAt
2527
+ });
2528
+ return null;
2529
+ }
2043
2530
  const grave = this._graveyard.get(controlIdFromAck);
2044
2531
  if (grave !== void 0) {
2045
2532
  const elapsedSinceSendMs = this._opts.now() - grave.timedOutAt;
@@ -2058,25 +2545,127 @@ var Correlator = class {
2058
2545
  }
2059
2546
  return null;
2060
2547
  }
2548
+ /**
2549
+ * Decide what an acknowledgement matched to a **two-phase** send does to it, from its
2550
+ * MSA-1 and from nothing else. No other property of the acknowledgement, its length, its
2551
+ * MSH-9 or its other segments, takes part.
2552
+ *
2553
+ * Six outcomes, one per classification:
2554
+ *
2555
+ * * `AA`, `AE` or `AR`: the application acknowledgement. It **settles** the send,
2556
+ * whether or not an accept-mode acknowledgement was seen first, and all three settle
2557
+ * it the same way, successfully, with the acknowledgement handed to the caller. `AE`
2558
+ * and `AR` are the receiving application's clinical verdict on a message it did take
2559
+ * custody of, and judging that verdict is not this package's job. The caller reads
2560
+ * MSA-1 off the acknowledgement it is given.
2561
+ * * `CA` on a send awaiting no application acknowledgement: it **settles** the send.
2562
+ * MSH-15 `AL` with MSH-16 `NE` is a legal pair and a peer answering it with one `CA`
2563
+ * has done exactly what was asked.
2564
+ * * `CA` on a send awaiting one: **reported, not settled.** The send stays pending and
2565
+ * its second wait starts here.
2566
+ * * a second `CA` on a send already reported: surfaced, and neither re-reported nor
2567
+ * allowed to restart the second wait.
2568
+ * * `CE` or `CR`: the peer refused custody of the bytes, so no application
2569
+ * acknowledgement is coming. The send **fails now**, whatever its application
2570
+ * condition and whether or not a `CA` was already reported. Waiting out the second
2571
+ * window would report the same failure later and less precisely.
2572
+ * * NULL or unclassifiable: surfaced with a code that distinguishes the two, and the
2573
+ * send is left pending until its outstanding wait expires. An acknowledgement that
2574
+ * cannot be classified into a mode is not guessed.
2575
+ *
2576
+ * Returns the entry when it is to be settled (the caller resolves it), `null` otherwise.
2577
+ */
2578
+ matchTwoPhase(entry, payload, byteOffsetFromAck) {
2579
+ const twoPhase = entry.twoPhase;
2580
+ if (twoPhase === null) return entry;
2581
+ const now = this._opts.now();
2582
+ const classification = classifyMsa1(payload);
2583
+ const elapsedSinceSendMs = entry.sentAt === null ? 0 : now - entry.sentAt;
2584
+ const controlIdBytes = entry.controlId === null ? null : entry.controlId.length;
2585
+ if (classification.kind === "code") {
2586
+ const code = classification.code;
2587
+ if (code === "AA" || code === "AE" || code === "AR") {
2588
+ this._disposeTwoPhase(entry, now);
2589
+ return entry;
2590
+ }
2591
+ if (code === "CE" || code === "CR") {
2592
+ this._disposeTwoPhase(entry, now);
2593
+ this._opts.onCommitRejected?.(entry, code);
2594
+ return null;
2595
+ }
2596
+ if (!twoPhase.awaitsApplicationAck) {
2597
+ this._disposeTwoPhase(entry, now);
2598
+ return entry;
2599
+ }
2600
+ if (twoPhase.commitCode !== null) {
2601
+ this._ackModeWarn("MLLP_ACK_COMMIT_ALREADY_REPORTED", {
2602
+ msa1Bytes: classification.byteLength,
2603
+ ackCode: code,
2604
+ controlIdBytes,
2605
+ byteOffset: byteOffsetFromAck,
2606
+ elapsedSinceSendMs
2607
+ });
2608
+ return null;
2609
+ }
2610
+ twoPhase.commitCode = code;
2611
+ twoPhase.commitReceivedAt = now;
2612
+ this._opts.onCommitReported?.(entry, payload, elapsedSinceSendMs);
2613
+ return null;
2614
+ }
2615
+ this._ackModeWarn(
2616
+ classification.kind === "null" ? "MLLP_ACK_MSA1_ABSENT" : "MLLP_ACK_MSA1_UNCLASSIFIABLE",
2617
+ {
2618
+ msa1Bytes: classification.byteLength,
2619
+ ackCode: null,
2620
+ controlIdBytes,
2621
+ byteOffset: byteOffsetFromAck,
2622
+ elapsedSinceSendMs
2623
+ }
2624
+ );
2625
+ return null;
2626
+ }
2061
2627
  /**
2062
2628
  * Sweep live entries; expire those past `sentAt + ackTimeoutMs`.
2063
2629
  * Fires `onTimeout(entry, elapsedMs)`; entries move to graveyard.
2064
2630
  * Driven externally by `MllpClient`'s periodic sweep tick, Correlator
2065
2631
  * itself owns no timers.
2632
+ *
2633
+ * A two-phase entry whose accept acknowledgement has arrived is bounded by the **second**
2634
+ * window instead, measured from that acknowledgement: the first-acknowledgement timeout
2635
+ * is stopped, so it can no longer expire against a send the peer has already answered.
2636
+ * Both windows are finite, so no send waits forever either way.
2066
2637
  */
2067
2638
  expireDue(now) {
2068
2639
  const t = now ?? this._opts.now();
2069
2640
  const ackTimeoutMs = this._opts.ackTimeoutMs;
2070
2641
  for (const [key, entry] of this._pending) {
2071
- if (entry.sentAt !== null && entry.sentAt + ackTimeoutMs <= t) {
2642
+ if (entry.sentAt === null) continue;
2643
+ const twoPhase = entry.twoPhase;
2644
+ const commitReceivedAt = twoPhase?.commitReceivedAt ?? null;
2645
+ if (twoPhase !== null && commitReceivedAt !== null) {
2646
+ if (commitReceivedAt + twoPhase.applicationAckTimeoutMs <= t) {
2647
+ this._removeExpired(key, entry);
2648
+ this._disposed.set(key, t);
2649
+ const elapsed = t - commitReceivedAt;
2650
+ if (this._opts.onApplicationAckTimeout !== void 0) {
2651
+ this._opts.onApplicationAckTimeout(entry, elapsed);
2652
+ } else {
2653
+ this._opts.onTimeout(entry, elapsed);
2654
+ }
2655
+ }
2656
+ continue;
2657
+ }
2658
+ if (entry.sentAt + ackTimeoutMs <= t) {
2072
2659
  const elapsed = t - entry.sentAt;
2073
- this._pending.delete(key);
2074
- this._queueBytes -= entry.byteCount;
2075
- this._inFlight -= 1;
2076
- this._graveyard.set(key, {
2077
- timedOutAt: t,
2078
- controlId: entry.controlId
2079
- });
2660
+ this._removeExpired(key, entry);
2661
+ if (twoPhase !== null) {
2662
+ this._disposed.set(key, t);
2663
+ } else {
2664
+ this._graveyard.set(key, {
2665
+ timedOutAt: t,
2666
+ controlId: entry.controlId
2667
+ });
2668
+ }
2080
2669
  this._opts.onTimeout(entry, elapsed);
2081
2670
  }
2082
2671
  }
@@ -2085,9 +2674,17 @@ var Correlator = class {
2085
2674
  * Reject every live entry with `reason` (insertion order) and clear
2086
2675
  * the live store. Graveyard is left intact (ages out via lazy eviction).
2087
2676
  * Used by `MllpClient`'s correlator teardown to cancel pending sends.
2677
+ *
2678
+ * `reasonFor` lets the caller substitute a different error per entry, which is how a send
2679
+ * whose commit disposition has already been reported is failed with an error that carries
2680
+ * that disposition rather than with the generic one.
2088
2681
  */
2089
- clear(reason) {
2090
- for (const entry of this._pending.values()) entry.reject(reason);
2682
+ clear(reason, reasonFor) {
2683
+ const now = this._opts.now();
2684
+ for (const entry of this._pending.values()) {
2685
+ if (entry.twoPhase !== null) this._disposed.set(entry.key, now);
2686
+ entry.reject(reasonFor === void 0 ? reason : reasonFor(entry, reason));
2687
+ }
2091
2688
  this._pending.clear();
2092
2689
  this._queueBytes = 0;
2093
2690
  this._inFlight = 0;
@@ -2110,6 +2707,20 @@ var Correlator = class {
2110
2707
  if (entry.sentAt !== null) this._inFlight -= 1;
2111
2708
  return entry;
2112
2709
  }
2710
+ /**
2711
+ * Remove a live entry by key WITHOUT resolving/rejecting it, and remember that it was
2712
+ * disposed of, so a later acknowledgement for it is answered "already disposed" rather
2713
+ * than "no such send" for as long as the window lasts.
2714
+ *
2715
+ * The caller settles or fails the returned entry itself. Used where the client fails a
2716
+ * send that had already drawn a commit disposition.
2717
+ */
2718
+ dispose(key) {
2719
+ const entry = this.remove(key);
2720
+ if (entry === null) return null;
2721
+ if (entry.twoPhase !== null) this._disposed.set(key, this._opts.now());
2722
+ return entry;
2723
+ }
2113
2724
  /** JSON-serializable stats snapshot (no Buffers, no class instances). */
2114
2725
  getStats() {
2115
2726
  return {
@@ -2120,12 +2731,38 @@ var Correlator = class {
2120
2731
  inFlight: this._inFlight
2121
2732
  };
2122
2733
  }
2123
- /** Lazy graveyard eviction: drop entries past `timedOutAt + 2 * ackTimeoutMs`. */
2734
+ /**
2735
+ * Lazy eviction of both bounded memories: graveyard entries past
2736
+ * `timedOutAt + 2 * ackTimeoutMs`, and disposed two-phase sends past
2737
+ * `disposedAt + 2 * ackTimeoutMs`. Same window, so "already disposed" and "arrived after
2738
+ * its timeout" are remembered for the same length of time, and past it both correctly
2739
+ * read as "no such send". The memory is bounded, not perpetual.
2740
+ */
2124
2741
  _evictGraveyardDue(now) {
2125
2742
  const threshold = 2 * this._opts.ackTimeoutMs;
2126
2743
  for (const [key, entry] of this._graveyard) {
2127
2744
  if (entry.timedOutAt + threshold <= now) this._graveyard.delete(key);
2128
2745
  }
2746
+ for (const [key, disposedAt] of this._disposed) {
2747
+ if (disposedAt + threshold <= now) this._disposed.delete(key);
2748
+ }
2749
+ }
2750
+ /** Remove a live entry that has just expired, keeping the counters straight. */
2751
+ _removeExpired(key, entry) {
2752
+ this._pending.delete(key);
2753
+ this._queueBytes -= entry.byteCount;
2754
+ this._inFlight -= 1;
2755
+ }
2756
+ /** Drop a two-phase entry from the live store and remember that it was disposed of. */
2757
+ _disposeTwoPhase(entry, now) {
2758
+ this._pending.delete(entry.key);
2759
+ this._queueBytes -= entry.byteCount;
2760
+ if (entry.sentAt !== null) this._inFlight -= 1;
2761
+ this._disposed.set(entry.key, now);
2762
+ }
2763
+ /** Emit an acknowledgement-mode diagnostic, when the caller wired a sink for them. */
2764
+ _ackModeWarn(code, ctx) {
2765
+ this._opts.onAckModeWarning?.(code, ctx);
2129
2766
  }
2130
2767
  };
2131
2768
 
@@ -2175,6 +2812,127 @@ var MllpTimeoutError = class _MllpTimeoutError extends Error {
2175
2812
  }
2176
2813
  }
2177
2814
  };
2815
+ var MllpApplicationAckError = class _MllpApplicationAckError extends Error {
2816
+ name = "MllpApplicationAckError";
2817
+ /** Why the wait ended. See {@link ApplicationAckFailure}. */
2818
+ reason;
2819
+ /** The commit disposition already received. Always the positive accept-mode code. */
2820
+ commitCode;
2821
+ /**
2822
+ * Byte length of the send's MSH-10 control ID, or `undefined` when there was none to
2823
+ * read. The control ID itself is deliberately not here, for the reason given on
2824
+ * {@link MllpTimeoutError.messageControlIdBytes}.
2825
+ */
2826
+ messageControlIdBytes;
2827
+ /** Milliseconds between the accept acknowledgement and this failure. */
2828
+ elapsedMs;
2829
+ /** Epoch ms at which the accept acknowledgement was received. */
2830
+ commitReceivedAt;
2831
+ /**
2832
+ * Construct an application-acknowledgement failure.
2833
+ *
2834
+ * @param message - Human-readable error message. Structural facts only, never field content.
2835
+ * @param opts - Failure context (why, the commit code, control-id byte length, timings).
2836
+ */
2837
+ constructor(message, opts) {
2838
+ super(message);
2839
+ this.reason = opts.reason;
2840
+ this.commitCode = opts.commitCode;
2841
+ this.messageControlIdBytes = opts.messageControlIdBytes;
2842
+ this.elapsedMs = opts.elapsedMs;
2843
+ this.commitReceivedAt = opts.commitReceivedAt;
2844
+ if (Error.captureStackTrace) {
2845
+ Error.captureStackTrace(this, _MllpApplicationAckError);
2846
+ }
2847
+ }
2848
+ };
2849
+ var MllpCommitRejectedError = class _MllpCommitRejectedError extends Error {
2850
+ name = "MllpCommitRejectedError";
2851
+ /** The negative accept-mode code the peer sent. */
2852
+ commitCode;
2853
+ /**
2854
+ * Byte length of the send's MSH-10 control ID, or `undefined` when there was none to
2855
+ * read. The control ID itself is deliberately not here.
2856
+ */
2857
+ messageControlIdBytes;
2858
+ /** Milliseconds between the send's write-flush and this acknowledgement. */
2859
+ elapsedMs;
2860
+ /**
2861
+ * Construct a negative-commit error.
2862
+ *
2863
+ * @param message - Human-readable error message. Structural facts only, never field content.
2864
+ * @param opts - Failure context (the commit code, control-id byte length, elapsed time).
2865
+ */
2866
+ constructor(message, opts) {
2867
+ super(message);
2868
+ this.commitCode = opts.commitCode;
2869
+ this.messageControlIdBytes = opts.messageControlIdBytes;
2870
+ this.elapsedMs = opts.elapsedMs;
2871
+ if (Error.captureStackTrace) {
2872
+ Error.captureStackTrace(this, _MllpCommitRejectedError);
2873
+ }
2874
+ }
2875
+ };
2876
+ var NEVER_DELIVERED_MESSAGE = "delivery did not occur: the message was still held by the client and no bytes were written to the transport, so the peer never saw it";
2877
+ var UNKNOWN_FATE_MESSAGE = "fate unknown: the message was written to the transport and no acknowledgement arrived before the client finished closing, so whether the peer received it cannot be determined from here";
2878
+ var MllpNeverDeliveredError = class _MllpNeverDeliveredError extends Error {
2879
+ name = "MllpNeverDeliveredError";
2880
+ /** Byte count of the framed message this send would have written. */
2881
+ byteCount;
2882
+ /**
2883
+ * Byte length of the send's MSH-10 control ID, or `undefined` when there was none to read.
2884
+ * The control ID itself is deliberately not here, for the reason given on
2885
+ * {@link MllpTimeoutError.messageControlIdBytes}.
2886
+ */
2887
+ messageControlIdBytes;
2888
+ /**
2889
+ * Construct a never-delivered report.
2890
+ *
2891
+ * There is no `message` parameter, deliberately: see {@link NEVER_DELIVERED_MESSAGE}.
2892
+ *
2893
+ * @param opts - Report context (framed byte count, control-id byte length).
2894
+ */
2895
+ constructor(opts) {
2896
+ super(NEVER_DELIVERED_MESSAGE);
2897
+ this.byteCount = opts.byteCount;
2898
+ this.messageControlIdBytes = opts.messageControlIdBytes;
2899
+ if (Error.captureStackTrace) {
2900
+ Error.captureStackTrace(this, _MllpNeverDeliveredError);
2901
+ }
2902
+ }
2903
+ };
2904
+ var MllpUnknownFateError = class _MllpUnknownFateError extends Error {
2905
+ name = "MllpUnknownFateError";
2906
+ /** Epoch ms at which this send's bytes were written to the transport. */
2907
+ flushedAt;
2908
+ /** Milliseconds between that write and this report. */
2909
+ elapsedMs;
2910
+ /** Byte count of the framed message that was written. */
2911
+ byteCount;
2912
+ /**
2913
+ * Byte length of the send's MSH-10 control ID, or `undefined` when there was none to read.
2914
+ * The control ID itself is deliberately not here, for the reason given on
2915
+ * {@link MllpTimeoutError.messageControlIdBytes}.
2916
+ */
2917
+ messageControlIdBytes;
2918
+ /**
2919
+ * Construct an unknown-fate report.
2920
+ *
2921
+ * There is no `message` parameter, deliberately: see {@link UNKNOWN_FATE_MESSAGE}.
2922
+ *
2923
+ * @param opts - Report context (flush timestamp, elapsed time, byte counts).
2924
+ */
2925
+ constructor(opts) {
2926
+ super(UNKNOWN_FATE_MESSAGE);
2927
+ this.flushedAt = opts.flushedAt;
2928
+ this.elapsedMs = opts.elapsedMs;
2929
+ this.byteCount = opts.byteCount;
2930
+ this.messageControlIdBytes = opts.messageControlIdBytes;
2931
+ if (Error.captureStackTrace) {
2932
+ Error.captureStackTrace(this, _MllpUnknownFateError);
2933
+ }
2934
+ }
2935
+ };
2178
2936
  function isTlsVerificationErrorCode(code) {
2179
2937
  switch (code) {
2180
2938
  case "UNABLE_TO_VERIFY_LEAF_SIGNATURE":
@@ -2255,6 +3013,13 @@ var MllpBackpressureError = class _MllpBackpressureError extends Error {
2255
3013
 
2256
3014
  // src/client/client.ts
2257
3015
  var NEVER_ABORTING_SIGNAL = new AbortController().signal;
3016
+ var OUTBOUND_WARNING_CONTEXT = Object.freeze({
3017
+ msa1Bytes: null,
3018
+ ackCode: null,
3019
+ controlIdBytes: null,
3020
+ byteOffset: 0,
3021
+ elapsedSinceSendMs: 0
3022
+ });
2258
3023
  var MllpClient = class extends EventEmitter {
2259
3024
  _opts;
2260
3025
  _connection = null;
@@ -2266,6 +3031,11 @@ var MllpClient = class extends EventEmitter {
2266
3031
  _state = "DISCONNECTED";
2267
3032
  /** Per-message ACK timeout in ms. Resolved at construction. */
2268
3033
  _ackTimeoutMs;
3034
+ /**
3035
+ * Default bound on the second (application-acknowledgement) wait, in ms.
3036
+ * `undefined` means "whatever ACK timeout is in force for that send".
3037
+ */
3038
+ _applicationAckTimeoutMs;
2269
3039
  /** controlId-mode flag. `false` → FIFO. */
2270
3040
  _correlateByControlId;
2271
3041
  /** Unified ACK correlator. Built during `_attachConnection`. */
@@ -2316,6 +3086,26 @@ var MllpClient = class extends EventEmitter {
2316
3086
  _connectSignal;
2317
3087
  /** Set when close()/destroy()/abort fires; reconnect handler short-circuits. */
2318
3088
  _userClosed = false;
3089
+ /**
3090
+ * Sends parked inside the client with nothing written for them. See {@link ParkedSend}.
3091
+ * Every entry removes itself the moment its own wait ends, however it ends.
3092
+ */
3093
+ _parkedSends = /* @__PURE__ */ new Set();
3094
+ /**
3095
+ * Re-checks whether the shutdown drain is over, `null` when no `close()` is waiting.
3096
+ * Called from every point at which a pending send is settled.
3097
+ */
3098
+ _drainWaiter = null;
3099
+ /**
3100
+ * Ends the shutdown drain wait now, whatever is still outstanding. `null` when no `close()`
3101
+ * is waiting. `destroy()` calls it, which is what makes a destroy during a drain prompt.
3102
+ */
3103
+ _endDrainWait = null;
3104
+ /**
3105
+ * The `beforeClose` hook this client installed on its Connection, so a second `close()` call
3106
+ * composes nothing twice. Compared by identity, never by shape.
3107
+ */
3108
+ _drainHook = null;
2319
3109
  /** Captured Connection error feeding `RetryContext.lastError`. */
2320
3110
  _lastError = null;
2321
3111
  /** Listener-removal handle for the abort listener bound in connect(). */
@@ -2354,6 +3144,7 @@ var MllpClient = class extends EventEmitter {
2354
3144
  super();
2355
3145
  this._opts = opts;
2356
3146
  this._ackTimeoutMs = opts.ackTimeoutMs ?? 3e4;
3147
+ this._applicationAckTimeoutMs = opts.applicationAckTimeoutMs;
2357
3148
  this._correlateByControlId = opts.correlateByControlId === true;
2358
3149
  this._autoReconnect = opts.autoReconnect === true;
2359
3150
  this._initialDelayMs = opts.initialDelayMs ?? 100;
@@ -2394,6 +3185,10 @@ var MllpClient = class extends EventEmitter {
2394
3185
  * TLS failures carry a `connectionCause`: `'tls-verify'` for certificate
2395
3186
  * verification failures, `'tls-handshake'` for TLS-protocol-shaped failures
2396
3187
  * ({@link isTlsProtocolError}); pure TCP failures carry none.
3188
+ * - `MllpTlsConfigurationError` if the configured cipher-suite list cannot be
3189
+ * honoured (the runtime rejects it, or `atnaTransportSecurity` and `ciphers`
3190
+ * both declare one). Raised **before** a socket is opened, so nothing is left
3191
+ * connected; identify it by `instanceof` plus its stable `code`.
2397
3192
  *
2398
3193
  * **Dual failure signal on initial connect:** the Connection's transport
2399
3194
  * error handler is attached before this promise's own error listener, so a
@@ -2472,6 +3267,7 @@ var MllpClient = class extends EventEmitter {
2472
3267
  cleanup();
2473
3268
  conn.notifyConnect(socket.remoteAddress ?? null, socket.remotePort ?? null);
2474
3269
  this._emitInsecureWarningIfNeeded();
3270
+ this._emitNegotiatedTlsParameters(socket);
2475
3271
  resolve();
2476
3272
  };
2477
3273
  const onSocketError = (err) => {
@@ -2503,6 +3299,7 @@ var MllpClient = class extends EventEmitter {
2503
3299
  return { socket: socket2, transport: new NetTransport(socket2) };
2504
3300
  }
2505
3301
  const tlsOpts = tlsOpt === true ? {} : tlsOpt;
3302
+ const cipherPolicy = resolveTlsCipherPolicy(tlsOpts, "client");
2506
3303
  const socket = connect({
2507
3304
  host: this._opts.host,
2508
3305
  port: this._opts.port,
@@ -2513,7 +3310,7 @@ var MllpClient = class extends EventEmitter {
2513
3310
  ...tlsOpts.passphrase !== void 0 ? { passphrase: tlsOpts.passphrase } : {},
2514
3311
  minVersion: tlsOpts.minVersion ?? "TLSv1.2",
2515
3312
  ...tlsOpts.maxVersion !== void 0 ? { maxVersion: tlsOpts.maxVersion } : {},
2516
- ...tlsOpts.ciphers !== void 0 ? { ciphers: tlsOpts.ciphers } : {},
3313
+ ...cipherPolicy,
2517
3314
  rejectUnauthorized: tlsOpts.allowUnverified !== true
2518
3315
  });
2519
3316
  return { socket, transport: new TlsTransport(socket) };
@@ -2567,6 +3364,71 @@ var MllpClient = class extends EventEmitter {
2567
3364
  safeEmitError(this, Object.freeze({ error: err }));
2568
3365
  });
2569
3366
  }
3367
+ /**
3368
+ * Emit a frozen {@link AckModeWarning} on the `'warning'` event.
3369
+ *
3370
+ * The message is a **registry lookup**, not an interpolation, and everything
3371
+ * input-derived on the payload is a number or a closed-set code. This holds in every
3372
+ * mode: an original-mode send emits nothing new, but when it does reach one of these
3373
+ * codes the same discipline applies.
3374
+ */
3375
+ _emitAckModeWarning(code, ctx, conn) {
3376
+ const warning = Object.freeze({
3377
+ code,
3378
+ message: ackModeDiagnosticMessage(code),
3379
+ byteOffset: ctx.byteOffset,
3380
+ msa1Bytes: ctx.msa1Bytes,
3381
+ ackCode: ctx.ackCode,
3382
+ controlIdBytes: ctx.controlIdBytes,
3383
+ elapsedSinceSendMs: ctx.elapsedSinceSendMs,
3384
+ connectionId: this._connection?.connectionId ?? conn?.connectionId,
3385
+ timestamp: /* @__PURE__ */ new Date()
3386
+ });
3387
+ this._emitContained("warning", warning);
3388
+ }
3389
+ /**
3390
+ * Build the typed error for a send that was left waiting on its application
3391
+ * acknowledgement: the peer committed the message and then either fell silent past the
3392
+ * second window or lost the link.
3393
+ */
3394
+ _applicationAckError(entry, reason, elapsedMs, detail) {
3395
+ const commitReceivedAt = entry.twoPhase?.commitReceivedAt ?? 0;
3396
+ return new MllpApplicationAckError(
3397
+ `commit accepted (CA); no application acknowledgement ${detail}`,
3398
+ {
3399
+ reason,
3400
+ commitCode: "CA",
3401
+ messageControlIdBytes: entry.controlId?.length,
3402
+ elapsedMs,
3403
+ commitReceivedAt
3404
+ }
3405
+ );
3406
+ }
3407
+ /**
3408
+ * Fail every pending send that is waiting on its application acknowledgement, with an
3409
+ * error naming the commit disposition already received. Used on the paths where the link
3410
+ * goes away: such a send must not be held for a resend (the peer already has the
3411
+ * message), nor left pending, nor reported as successful.
3412
+ *
3413
+ * Returns the entries it removed. Sends without a reported commit are untouched.
3414
+ */
3415
+ _failCommitPendingSends(cause) {
3416
+ const corr = this._correlator;
3417
+ if (corr === null) return [];
3418
+ const stranded = [];
3419
+ for (const entry of corr.liveEntries()) {
3420
+ if ((entry.twoPhase?.commitReceivedAt ?? null) !== null) stranded.push(entry);
3421
+ }
3422
+ const now = Date.now();
3423
+ for (const entry of stranded) {
3424
+ corr.dispose(entry.key);
3425
+ const commitReceivedAt = entry.twoPhase?.commitReceivedAt ?? now;
3426
+ entry.reject(
3427
+ this._applicationAckError(entry, "connection-lost", now - commitReceivedAt, cause)
3428
+ );
3429
+ }
3430
+ return stranded;
3431
+ }
2570
3432
  /**
2571
3433
  * Emit the per-connection insecure-TLS warning when
2572
3434
  * `tls.allowUnverified === true`, fires on EVERY successful
@@ -2589,6 +3451,22 @@ var MllpClient = class extends EventEmitter {
2589
3451
  this._emitContained("securityWarning", warning);
2590
3452
  process.emitWarning(message, { code: MLLP_TLS_VERIFY_DISABLED });
2591
3453
  }
3454
+ /**
3455
+ * Emit the frozen negotiated-parameters record for a completed TLS
3456
+ * handshake, once per connection, initial connect and every reconnect
3457
+ * alike. No-op for a plaintext connection: there is nothing negotiated to
3458
+ * report, so no event is emitted at all.
3459
+ *
3460
+ * The payload is read off the TLS session, so it is a pair of registry names
3461
+ * plus this client's own host and port. It never sees a payload byte: it is
3462
+ * built at handshake-completion time, before any HL7 byte crosses the link.
3463
+ */
3464
+ _emitNegotiatedTlsParameters(socket) {
3465
+ if (this._opts.tls === void 0) return;
3466
+ const params = readNegotiatedTlsParameters(socket, this._opts.host, this._opts.port);
3467
+ if (params === null) return;
3468
+ this._emitContained("tlsNegotiated", params);
3469
+ }
2592
3470
  /**
2593
3471
  * Wire a Connection's events through to this MllpClient. Every re-emitted
2594
3472
  * payload is `Object.freeze`'d before emission, even though the
@@ -2651,11 +3529,50 @@ var MllpClient = class extends EventEmitter {
2651
3529
  })
2652
3530
  );
2653
3531
  this._maybeEmitDrain();
3532
+ },
3533
+ onApplicationAckTimeout: (entry, elapsedMs) => {
3534
+ this._timedOutTotal += 1;
3535
+ entry.reject(
3536
+ this._applicationAckError(entry, "timeout", elapsedMs, `after ${elapsedMs}ms`)
3537
+ );
3538
+ this._maybeEmitDrain();
3539
+ },
3540
+ onCommitRejected: (entry, code) => {
3541
+ entry.reject(
3542
+ new MllpCommitRejectedError(`peer answered a negative commit (${code})`, {
3543
+ commitCode: code,
3544
+ messageControlIdBytes: entry.controlId?.length,
3545
+ elapsedMs: entry.sentAt === null ? 0 : Date.now() - entry.sentAt
3546
+ })
3547
+ );
3548
+ this._maybeEmitDrain();
3549
+ },
3550
+ onCommitReported: (entry, ack, latencyMs) => {
3551
+ const report = entry.twoPhase?.onCommitReport ?? null;
3552
+ if (report === null) return;
3553
+ try {
3554
+ report(Object.freeze({ code: "CA", payload: ack, latencyMs }));
3555
+ } catch (err) {
3556
+ safeEmitError(
3557
+ this,
3558
+ Object.freeze({
3559
+ connectionId: this._connection?.connectionId ?? conn.connectionId,
3560
+ error: err instanceof Error ? err : new Error(String(err))
3561
+ })
3562
+ );
3563
+ }
3564
+ },
3565
+ onAckModeWarning: (code, ctx) => {
3566
+ this._emitAckModeWarning(code, ctx, conn);
2654
3567
  }
2655
3568
  });
2656
3569
  }
2657
3570
  if (this._ackSweepTimer === null) {
2658
- const sweepIntervalMs = Math.max(50, Math.min(1e3, Math.floor(this._ackTimeoutMs / 4)));
3571
+ const shortestWindowMs = Math.min(
3572
+ this._ackTimeoutMs,
3573
+ this._applicationAckTimeoutMs ?? this._ackTimeoutMs
3574
+ );
3575
+ const sweepIntervalMs = Math.max(50, Math.min(1e3, Math.floor(shortestWindowMs / 4)));
2659
3576
  this._ackSweepTimer = setInterval(() => {
2660
3577
  this._correlator?.expireDue();
2661
3578
  }, sweepIntervalMs);
@@ -2752,7 +3669,9 @@ var MllpClient = class extends EventEmitter {
2752
3669
  _handleDisconnect(err) {
2753
3670
  if (this._userClosed) return;
2754
3671
  if (this._correlator !== null) {
2755
- if (this._correlateByControlId) ; else {
3672
+ if (this._correlateByControlId) {
3673
+ this._failCommitPendingSends("before the link dropped");
3674
+ } else {
2756
3675
  const orphans = [];
2757
3676
  const queued = [];
2758
3677
  for (const entry of this._correlator.liveEntries()) {
@@ -2892,6 +3811,7 @@ var MllpClient = class extends EventEmitter {
2892
3811
  arm = () => {
2893
3812
  conn.notifyConnect(socket.remoteAddress ?? null, socket.remotePort ?? null);
2894
3813
  this._emitInsecureWarningIfNeeded();
3814
+ this._emitNegotiatedTlsParameters(socket);
2895
3815
  };
2896
3816
  const connectEventName = this._opts.tls !== void 0 ? "secureConnect" : "connect";
2897
3817
  socket.once("error", (sErr) => {
@@ -3013,6 +3933,7 @@ var MllpClient = class extends EventEmitter {
3013
3933
  * (every expired send), both code paths free a live-store slot.
3014
3934
  */
3015
3935
  _maybeEmitDrain() {
3936
+ this._drainWaiter?.();
3016
3937
  const corr = this._correlator;
3017
3938
  if (corr === null) return;
3018
3939
  const belowCount = corr.size < this._hwmCount;
@@ -3038,6 +3959,7 @@ var MllpClient = class extends EventEmitter {
3038
3959
  */
3039
3960
  _onStateChange(e) {
3040
3961
  this._emitContained("stateChange", Object.freeze({ ...e }));
3962
+ if (e.to !== "DRAINING") this._endDrainWait?.();
3041
3963
  if (e.to === "CONNECTED") {
3042
3964
  this._armDeadPeerTimer();
3043
3965
  this._lastConnectedAt = Date.now();
@@ -3074,6 +3996,21 @@ var MllpClient = class extends EventEmitter {
3074
3996
  *
3075
3997
  * Emits a frozen `'ack'` event on every successful match.
3076
3998
  *
3999
+ * ## Enhanced acknowledgement mode
4000
+ *
4001
+ * A message whose MSH-15 or MSH-16 is not null asks for the two-part protocol of HL7
4002
+ * v2.5.1 §2.9, and on a client correlating by control ID this method delivers it: the
4003
+ * accept acknowledgement (`CA`) is reported through `onCommitAck` **without** settling
4004
+ * the send, and the later application acknowledgement (`AA`/`AE`/`AR`) is what resolves
4005
+ * it. All three application-mode codes resolve, because which of them the receiving
4006
+ * application chose is its clinical verdict and not this transport's to judge; read MSA-1
4007
+ * off the acknowledgement you are handed. A negative commit (`CE`/`CR`) rejects with
4008
+ * {@link MllpCommitRejectedError} at once, since no application acknowledgement follows a
4009
+ * refusal to take custody.
4010
+ *
4011
+ * A message with both fields empty is an original-mode send and behaves exactly as it
4012
+ * always has, one acknowledgement, settled by the first match.
4013
+ *
3077
4014
  * @example
3078
4015
  * ```typescript
3079
4016
  * const ack = await client.send(payloadBuffer);
@@ -3082,6 +4019,10 @@ var MllpClient = class extends EventEmitter {
3082
4019
  *
3083
4020
  * @param payload Raw bytes; MLLP framing is added internally via `encodeFrame`.
3084
4021
  * @param opts.signal AbortSignal, aborting cancels the ACK wait.
4022
+ * @param opts.ackTimeoutMs Per-send override of the backpressure wait budget.
4023
+ * @param opts.applicationAckTimeoutMs Per-send override of the second wait's bound.
4024
+ * @param opts.onCommitAck Called with the commit disposition when the peer commits the
4025
+ * message ahead of its application acknowledgement. See {@link CommitAckReport}.
3085
4026
  */
3086
4027
  send(payload, opts) {
3087
4028
  const signal = opts?.signal;
@@ -3096,7 +4037,10 @@ var MllpClient = class extends EventEmitter {
3096
4037
  })
3097
4038
  );
3098
4039
  }
3099
- const controlId = this._correlateByControlId ? extractMshControlId(payload) : null;
4040
+ const msh = readMshSegment(payload);
4041
+ const controlId = this._correlateByControlId ? controlIdFromMsh(msh) : null;
4042
+ const ackMode = classifyOutboundAckMode(msh);
4043
+ const twoPhase = this._twoPhaseStateFor(ackMode, opts);
3100
4044
  const correlator = this._correlator;
3101
4045
  const conn = this._connection;
3102
4046
  const frame = encodeFrame(payload);
@@ -3124,7 +4068,11 @@ var MllpClient = class extends EventEmitter {
3124
4068
  )
3125
4069
  );
3126
4070
  }
3127
- return this._waitThenSend(payload, opts);
4071
+ return this._waitThenSend(
4072
+ payload,
4073
+ { byteCount: frame.length, messageControlIdBytes: controlId?.length },
4074
+ opts
4075
+ );
3128
4076
  }
3129
4077
  return new Promise((resolve, reject) => {
3130
4078
  let abortListener = null;
@@ -3140,15 +4088,31 @@ var MllpClient = class extends EventEmitter {
3140
4088
  }
3141
4089
  reject(err);
3142
4090
  };
3143
- const key = correlator.enqueue(frame, controlId, wrappedResolve, wrappedReject);
4091
+ const key = correlator.enqueue(frame, controlId, wrappedResolve, wrappedReject, twoPhase);
3144
4092
  if (key === null) {
4093
+ const parked = { cancel: () => void 0 };
4094
+ const neverDelivered = () => new MllpNeverDeliveredError({
4095
+ byteCount: frame.length,
4096
+ messageControlIdBytes: controlId?.length
4097
+ });
3145
4098
  const onDrain = () => {
4099
+ this._parkedSends.delete(parked);
3146
4100
  this.off("drain", onDrain);
4101
+ if (this._shutdownBegun()) {
4102
+ wrappedReject(neverDelivered());
4103
+ return;
4104
+ }
3147
4105
  this.send(payload, opts).then(wrappedResolve, wrappedReject);
3148
4106
  };
4107
+ parked.cancel = () => {
4108
+ this.off("drain", onDrain);
4109
+ wrappedReject(neverDelivered());
4110
+ };
4111
+ this._parkedSends.add(parked);
3149
4112
  this.on("drain", onDrain);
3150
4113
  if (signal !== void 0) {
3151
4114
  abortListener = () => {
4115
+ this._parkedSends.delete(parked);
3152
4116
  this.off("drain", onDrain);
3153
4117
  wrappedReject(new DOMException("Aborted", "AbortError"));
3154
4118
  };
@@ -3159,6 +4123,7 @@ var MllpClient = class extends EventEmitter {
3159
4123
  if (signal !== void 0) {
3160
4124
  abortListener = () => {
3161
4125
  correlator.remove(key);
4126
+ this._drainWaiter?.();
3162
4127
  wrappedReject(new DOMException("Aborted", "AbortError"));
3163
4128
  };
3164
4129
  signal.addEventListener("abort", abortListener, { once: true });
@@ -3168,22 +4133,71 @@ var MllpClient = class extends EventEmitter {
3168
4133
  this._sentTotal += 1;
3169
4134
  });
3170
4135
  }
4136
+ /**
4137
+ * Decide what a send's own MSH-15 and MSH-16 buy it, and report anything worth reporting
4138
+ * about them. Runs before the message reaches the transport.
4139
+ *
4140
+ * Three answers:
4141
+ *
4142
+ * * an **original-mode** message (both fields null, or a header this package cannot
4143
+ * scan) gets `null`, which is the state every send had before enhanced mode existed;
4144
+ * * an **enhanced-mode** message on a client that correlates in order rather than by
4145
+ * control ID also gets `null`, plus a warning saying so. MSA-2 is the only thing that
4146
+ * can attribute a second acknowledgement to a send, so without control-ID correlation
4147
+ * a two-acknowledgement conversation cannot be delivered. The alternative, leaving
4148
+ * such a send pending on an acknowledgement this client would otherwise have settled
4149
+ * it with, would break a working interface to signal a configuration gap;
4150
+ * * an **enhanced-mode** message on a control-ID client gets its two-phase state.
4151
+ *
4152
+ * An unrecognised Table 0155 value is warned and defaulted, never fatal: this is a
4153
+ * transport, and refusing to transmit a clinical message over a vendor value in MSH-15
4154
+ * would be HL7 content validation with the worst failure mode available. The message goes
4155
+ * out unaltered either way.
4156
+ */
4157
+ _twoPhaseStateFor(ackMode, opts) {
4158
+ if (!ackMode.enhanced) return null;
4159
+ if (ackMode.acceptFieldUnrecognised) {
4160
+ this._emitAckModeWarning("MLLP_ACK_ACCEPT_TYPE_UNRECOGNISED", OUTBOUND_WARNING_CONTEXT);
4161
+ }
4162
+ if (ackMode.applicationFieldUnrecognised) {
4163
+ this._emitAckModeWarning("MLLP_ACK_APPLICATION_TYPE_UNRECOGNISED", OUTBOUND_WARNING_CONTEXT);
4164
+ }
4165
+ if (!this._correlateByControlId) {
4166
+ this._emitAckModeWarning("MLLP_ACK_TWO_PHASE_UNAVAILABLE", OUTBOUND_WARNING_CONTEXT);
4167
+ return null;
4168
+ }
4169
+ return {
4170
+ awaitsApplicationAck: ackMode.awaitsApplicationAck,
4171
+ applicationAckTimeoutMs: opts?.applicationAckTimeoutMs ?? this._applicationAckTimeoutMs ?? opts?.ackTimeoutMs ?? this._ackTimeoutMs,
4172
+ onCommitReport: opts?.onCommitAck ?? null,
4173
+ commitCode: null,
4174
+ commitReceivedAt: null
4175
+ };
4176
+ }
3171
4177
  /**
3172
4178
  * 'wait'-mode backpressure handler.
3173
4179
  *
3174
- * Awaits one of three terminating signals, in order:
4180
+ * Awaits one of four terminating signals, in order:
3175
4181
  * - `'drain'` event → re-enter `send()` (the gate will now pass).
3176
4182
  * - `ackTimeoutMs` elapses → reject with `MllpTimeoutError`.
3177
4183
  * - Caller's `signal` aborts → reject with `AbortError`. Cleanup
3178
4184
  * removes the drain listener AND the abort listener AND clears the
3179
4185
  * timer to prevent leaks.
4186
+ * - the client shuts down → reject with `MllpNeverDeliveredError`, because a send waiting
4187
+ * here has written nothing and the peer cannot have seen it. Registering the wait as a
4188
+ * {@link ParkedSend} is what makes that reachable at all.
4189
+ *
4190
+ * @param held - What this send would have written, for the never-delivered report. Counts
4191
+ * only: the caller still holds the payload it passed to `send()`.
3180
4192
  */
3181
- _waitThenSend(payload, opts) {
4193
+ _waitThenSend(payload, held, opts) {
3182
4194
  const signal = opts?.signal;
3183
4195
  return new Promise((resolve, reject) => {
3184
4196
  const ackTimeoutMs = opts?.ackTimeoutMs ?? this._ackTimeoutMs;
3185
4197
  let abortListener = null;
4198
+ const parked = { cancel: () => void 0 };
3186
4199
  const cleanup = () => {
4200
+ this._parkedSends.delete(parked);
3187
4201
  this.off("drain", onDrain);
3188
4202
  clearTimeout(timer);
3189
4203
  if (signal !== void 0 && abortListener !== null) {
@@ -3192,6 +4206,10 @@ var MllpClient = class extends EventEmitter {
3192
4206
  };
3193
4207
  const onDrain = () => {
3194
4208
  cleanup();
4209
+ if (this._shutdownBegun()) {
4210
+ reject(new MllpNeverDeliveredError(held));
4211
+ return;
4212
+ }
3195
4213
  this.send(payload, opts).then(resolve, reject);
3196
4214
  };
3197
4215
  const timer = setTimeout(() => {
@@ -3205,6 +4223,11 @@ var MllpClient = class extends EventEmitter {
3205
4223
  );
3206
4224
  }, ackTimeoutMs);
3207
4225
  timer.unref();
4226
+ parked.cancel = () => {
4227
+ cleanup();
4228
+ reject(new MllpNeverDeliveredError(held));
4229
+ };
4230
+ this._parkedSends.add(parked);
3208
4231
  this.on("drain", onDrain);
3209
4232
  if (signal !== void 0) {
3210
4233
  abortListener = () => {
@@ -3218,18 +4241,186 @@ var MllpClient = class extends EventEmitter {
3218
4241
  /**
3219
4242
  * Tear down per-connection state (sweep timer + correlator) when the
3220
4243
  * client is closing or destroying. Safe to call multiple times.
4244
+ *
4245
+ * A send that is waiting on its application acknowledgement is failed with an error
4246
+ * naming the commit disposition it did receive, rather than with the generic
4247
+ * connection error every other pending send gets. The two are genuinely different
4248
+ * outcomes: one send may never have reached the peer, the other is known to be in its
4249
+ * custody with only its application disposition unknown. That branch runs FIRST and is not
4250
+ * overridable by the caller, because the peer's custody of those bytes is a known fact and
4251
+ * no shutdown report may downgrade it to an unknown one.
4252
+ *
4253
+ * @param reason - Error for every entry the caller offers no better answer for.
4254
+ * @param unresolvedFor - Per-entry error for an entry whose commit disposition has NOT
4255
+ * arrived. This is where the shutdown path splits the two populations; omitted, every such
4256
+ * entry gets `reason`.
3221
4257
  */
3222
- _teardownCorrelator(reason) {
4258
+ _teardownCorrelator(reason, unresolvedFor) {
3223
4259
  if (this._ackSweepTimer !== null) {
3224
4260
  clearInterval(this._ackSweepTimer);
3225
4261
  this._ackSweepTimer = null;
3226
4262
  }
3227
4263
  if (this._correlator !== null) {
3228
- this._correlator.clear(reason);
4264
+ this._correlator.clear(reason, (entry, fallback) => {
4265
+ const commitReceivedAt = entry.twoPhase?.commitReceivedAt ?? null;
4266
+ if (commitReceivedAt !== null) {
4267
+ return this._applicationAckError(
4268
+ entry,
4269
+ "connection-lost",
4270
+ Date.now() - commitReceivedAt,
4271
+ "before the connection closed"
4272
+ );
4273
+ }
4274
+ return unresolvedFor === void 0 ? fallback : unresolvedFor(entry);
4275
+ });
3229
4276
  this._correlator = null;
3230
4277
  }
3231
4278
  this._clearDeadPeerTimer();
3232
4279
  }
4280
+ /**
4281
+ * Number of pending sends whose bytes ARE on the transport and whose acknowledgement has not
4282
+ * arrived. This is what a shutdown drain waits for, and nothing else: a send still held
4283
+ * inside the client cannot be acknowledged, so holding the drain open for one would spend a
4284
+ * clinical shutdown's timeout waiting for an answer that cannot come.
4285
+ */
4286
+ _outstandingAckCount() {
4287
+ return this._correlator?.getStats().inFlight ?? 0;
4288
+ }
4289
+ /**
4290
+ * The report a still-pending send gets when the client has finished shutting down, for the
4291
+ * one population `_teardownCorrelator` leaves to this method: entries whose commit
4292
+ * disposition never arrived.
4293
+ *
4294
+ * The split is the whole point of the shutdown contract. `sentAt` is stamped at the
4295
+ * transport write and is `null` until then, so it says exactly which side of the wire a
4296
+ * message was on when the client stopped:
4297
+ *
4298
+ * * written, unanswered: {@link MllpUnknownFateError}, ambiguous, and reported as
4299
+ * ambiguous, carrying the flush timestamp a replay decision reasons about;
4300
+ * * never written: {@link MllpNeverDeliveredError}, and a resend of one of those cannot
4301
+ * duplicate anything.
4302
+ *
4303
+ * **The unflushed branch is a structural guard, and today no caller can reach it.** `send()`
4304
+ * writes and stamps in the same synchronous run, so an entry that is in the live store is an
4305
+ * entry that went out, and the never-written population arrives instead as a
4306
+ * {@link ParkedSend}, which never enters the store at all. The branch stays because the
4307
+ * store MODELS the state, `sentAt` being nullable is its own claim about it, and the
4308
+ * reconnect path already splits the same two ways on the same field. Deleting it would make
4309
+ * this report say the opposite of the truth the day anything does produce one, which on this
4310
+ * surface means telling a consumer to treat an unwritten message as possibly committed.
4311
+ */
4312
+ _shutdownErrorFor(entry) {
4313
+ const flushedAt = entry.sentAt;
4314
+ if (flushedAt === null) {
4315
+ return new MllpNeverDeliveredError({
4316
+ byteCount: entry.byteCount,
4317
+ messageControlIdBytes: entry.controlId?.length
4318
+ });
4319
+ }
4320
+ return new MllpUnknownFateError({
4321
+ flushedAt,
4322
+ elapsedMs: Date.now() - flushedAt,
4323
+ byteCount: entry.byteCount,
4324
+ messageControlIdBytes: entry.controlId?.length
4325
+ });
4326
+ }
4327
+ /**
4328
+ * Has this client begun shutting down? A send parked inside it must not be resumed once it
4329
+ * has: nothing was ever written for such a send, so its report is that delivery did not
4330
+ * occur, and re-entering `send()` would trade that report for the generic not-connected one.
4331
+ *
4332
+ * True from the moment `close()` or `destroy()` is called, true on every halt that has
4333
+ * already given up on the connection (a permanent connect failure, an exhausted retry
4334
+ * policy, an aborted connect), and true while the underlying connection is draining, which
4335
+ * is how a connection closed from outside this client is caught. Each of those is a state a
4336
+ * fresh `send()` cannot succeed from.
4337
+ *
4338
+ * Read on the `'drain'` listener of every {@link ParkedSend}, because a shutdown drain ends
4339
+ * when the last outstanding acknowledgement (or timeout, or abort) settles, and that same
4340
+ * settlement emits `'drain'`. A park woken by it unregisters itself, so the settle pass that
4341
+ * runs after the drain no longer sees it: the choice has to be made here or not at all.
4342
+ */
4343
+ _shutdownBegun() {
4344
+ return this._userClosed || this._connection?.state === "DRAINING";
4345
+ }
4346
+ /**
4347
+ * Fail every send parked inside the client. See {@link ParkedSend}.
4348
+ *
4349
+ * Iterates a copy and clears the register first, because each `cancel()` removes its own
4350
+ * entry as it unwinds.
4351
+ */
4352
+ _cancelParkedSends() {
4353
+ if (this._parkedSends.size === 0) return;
4354
+ const parked = [...this._parkedSends];
4355
+ this._parkedSends.clear();
4356
+ for (const send of parked) send.cancel();
4357
+ }
4358
+ /**
4359
+ * Settle everything still unresolved once the shutdown drain is over, under the
4360
+ * two-population rule. Runs on every exit from `close()`, including the aborted one, so no
4361
+ * send's promise is left pending by a shutdown.
4362
+ */
4363
+ _settleUnresolvedSends() {
4364
+ this._endDrainWait?.();
4365
+ this._cancelParkedSends();
4366
+ this._teardownCorrelator(
4367
+ new MllpConnectionError("client closed", {
4368
+ cause: new Error("closed"),
4369
+ phase: "close"
4370
+ }),
4371
+ (entry) => this._shutdownErrorFor(entry)
4372
+ );
4373
+ }
4374
+ /**
4375
+ * Install this client's acknowledgement drain on a Connection's `beforeClose` seam, which is
4376
+ * what gives `drainTimeoutMs` a meaning on the client at all.
4377
+ *
4378
+ * Whatever hook is already there is **composed with**, not replaced: the seam is a public
4379
+ * instance property an owner may legitimately have assigned, and a shutdown that silently
4380
+ * dropped that work would be a worse defect than the one this fixes. Installing is
4381
+ * idempotent by hook identity, so a second `close()` composes nothing twice.
4382
+ */
4383
+ _installDrainHook(conn) {
4384
+ if (conn.beforeClose === this._drainHook) return;
4385
+ const inherited = conn.beforeClose;
4386
+ const hook = (drainTimeoutMs) => Promise.all([inherited.call(conn, drainTimeoutMs), this._awaitOutstandingAcks()]).then(
4387
+ () => void 0
4388
+ );
4389
+ this._drainHook = hook;
4390
+ conn.beforeClose = hook;
4391
+ }
4392
+ /**
4393
+ * Resolve once there is nothing left to wait for. Three ways that happens:
4394
+ *
4395
+ * * the last outstanding acknowledgement lands (or its send is otherwise settled), which
4396
+ * is what lets `close()` return in a fraction of the drain timeout;
4397
+ * * the connection leaves `DRAINING`, i.e. the link failed, the peer went away or the
4398
+ * client was destroyed. Nothing further can be acknowledged over a link that is gone, so
4399
+ * waiting out the rest of the timeout would buy nothing and hang the shutdown. That one
4400
+ * arrives through `_onStateChange`, the single delegating listener, rather than through a
4401
+ * second `'stateChange'` registration of its own;
4402
+ * * never, in which case `Connection.close` times the whole thing out at `drainTimeoutMs`
4403
+ * and the still-unanswered sends are reported with their fate unknown. The bound lives
4404
+ * there, in the drain race, rather than being duplicated here.
4405
+ */
4406
+ _awaitOutstandingAcks() {
4407
+ return new Promise((resolve) => {
4408
+ let done = false;
4409
+ const finish = () => {
4410
+ if (done) return;
4411
+ done = true;
4412
+ this._drainWaiter = null;
4413
+ this._endDrainWait = null;
4414
+ resolve();
4415
+ };
4416
+ const recheck = () => {
4417
+ if (this._outstandingAckCount() === 0) finish();
4418
+ };
4419
+ this._drainWaiter = recheck;
4420
+ this._endDrainWait = finish;
4421
+ recheck();
4422
+ });
4423
+ }
3233
4424
  /**
3234
4425
  * **Test seam**, attach an externally-built {@link Connection} directly,
3235
4426
  * bypassing the `net.createConnection` + `NetTransport` path. Used by
@@ -3247,14 +4438,35 @@ var MllpClient = class extends EventEmitter {
3247
4438
  this._attachConnection(conn);
3248
4439
  }
3249
4440
  /**
3250
- * Gracefully close the client.
4441
+ * Gracefully close the client, awaiting the acknowledgements of sends already written to
4442
+ * the transport.
3251
4443
  *
3252
4444
  * Delegates to {@link Connection.close}, which transitions `CONNECTED → DRAINING
3253
4445
  * → DISCONNECTED` (or `CLOSED` on drain timeout). No-op if no Connection is
3254
- * attached.
4446
+ * attached, or if the close has already completed.
4447
+ *
4448
+ * The drain ends as soon as the last outstanding acknowledgement arrives, so a caller whose
4449
+ * peer answers promptly does not wait out `drainTimeoutMs`. Whatever is still unresolved
4450
+ * when it does end is reported to its own `send()` caller under one rule, and the whole
4451
+ * point of this method is that the two halves of that rule are told apart:
4452
+ *
4453
+ * * a send already written to the transport rejects with {@link MllpUnknownFateError},
4454
+ * carrying the timestamp at which its bytes went out. It may have been committed;
4455
+ * resending it may duplicate a clinical message. Nothing is retried here.
4456
+ * * a send still held inside the client rejects with {@link MllpNeverDeliveredError}.
4457
+ * The peer never saw those bytes, so resending them is safe.
4458
+ * * a send whose commit disposition the peer had already reported keeps its
4459
+ * {@link MllpApplicationAckError}, which names that disposition. The peer's custody of
4460
+ * those bytes is known, and a shutdown never downgrades a known fact to an unknown one.
3255
4461
  *
3256
4462
  * Rejects with `DOMException('Aborted', 'AbortError')` if `signal` aborts mid-drain;
3257
- * on abort, the underlying Connection is force-destroyed.
4463
+ * on abort, the underlying Connection is force-destroyed and every still-pending send is
4464
+ * settled under the same rule rather than left pending.
4465
+ *
4466
+ * A drain cannot make delivery certain, and nothing here claims otherwise: an
4467
+ * acknowledgement lost in flight is indistinguishable from a message never received. That is
4468
+ * what the unknown-fate report says, and it is why the application still owns idempotency,
4469
+ * keyed on MSH-10 plus MSH-7.
3258
4470
  *
3259
4471
  * @example
3260
4472
  * ```typescript
@@ -3273,23 +4485,17 @@ var MllpClient = class extends EventEmitter {
3273
4485
  }
3274
4486
  const conn = this._connection;
3275
4487
  if (conn === null) {
3276
- this._teardownCorrelator(
3277
- new MllpConnectionError("client closed", {
3278
- cause: new Error("closed"),
3279
- phase: "close"
3280
- })
3281
- );
4488
+ this._settleUnresolvedSends();
3282
4489
  return;
3283
4490
  }
3284
- this._teardownCorrelator(
3285
- new MllpConnectionError("client closed", {
3286
- cause: new Error("closed"),
3287
- phase: "close"
3288
- })
3289
- );
4491
+ this._installDrainHook(conn);
4492
+ const closeOpts = opts?.drainTimeoutMs !== void 0 ? { drainTimeoutMs: opts.drainTimeoutMs } : void 0;
3290
4493
  if (signal === void 0) {
3291
- const closeOpts = opts?.drainTimeoutMs !== void 0 ? { drainTimeoutMs: opts.drainTimeoutMs } : void 0;
3292
- await conn.close(closeOpts);
4494
+ try {
4495
+ await conn.close(closeOpts);
4496
+ } finally {
4497
+ this._settleUnresolvedSends();
4498
+ }
3293
4499
  return;
3294
4500
  }
3295
4501
  let abortHandler;
@@ -3301,18 +4507,25 @@ var MllpClient = class extends EventEmitter {
3301
4507
  signal.addEventListener("abort", abortHandler, { once: true });
3302
4508
  });
3303
4509
  try {
3304
- const closeOpts = opts?.drainTimeoutMs !== void 0 ? { drainTimeoutMs: opts.drainTimeoutMs } : void 0;
3305
4510
  await Promise.race([conn.close(closeOpts), abortPromise]);
3306
4511
  } finally {
3307
4512
  if (abortHandler !== void 0) {
3308
4513
  signal.removeEventListener("abort", abortHandler);
3309
4514
  }
4515
+ this._settleUnresolvedSends();
3310
4516
  }
3311
4517
  }
3312
4518
  /**
3313
4519
  * Abruptly destroy the client, force-transitions the underlying Connection
3314
4520
  * to `CLOSED` immediately. No-op if no Connection is attached. Idempotent.
3315
4521
  *
4522
+ * Every pending send is settled at once: no acknowledgement is awaited and no drain timeout
4523
+ * is honoured, which is exactly what separates this from {@link MllpClient.close}. A
4524
+ * `close()` already waiting on acknowledgements stops waiting here and returns. A send still
4525
+ * held inside the client is failed with {@link MllpNeverDeliveredError}, because nothing was
4526
+ * ever written for it; that is a statement about the bytes, not about how the client was
4527
+ * shut down, so it holds on this path too.
4528
+ *
3316
4529
  * @example
3317
4530
  * ```typescript
3318
4531
  * client.destroy(new Error('shutting down'));
@@ -3328,6 +4541,8 @@ var MllpClient = class extends EventEmitter {
3328
4541
  cause: new Error("destroyed"),
3329
4542
  phase: "close"
3330
4543
  });
4544
+ this._endDrainWait?.();
4545
+ this._cancelParkedSends();
3331
4546
  this._teardownCorrelator(teardownReason);
3332
4547
  const conn = this._connection;
3333
4548
  if (conn === null) return;
@@ -3435,9 +4650,303 @@ async function createStarterClient(opts) {
3435
4650
  return client;
3436
4651
  }
3437
4652
 
4653
+ // src/differential/corpus.ts
4654
+ var SEGMENT_SEPARATOR = "\r";
4655
+ var ADT_A01_SEGMENTS = [
4656
+ "MSH|^~\\&|SENDING_APP|SENDING_FAC|RECV_APP|RECV_FAC|20260709120000||ADT^A01|MSG00001|P|2.5",
4657
+ "EVN|A01|20260709120000",
4658
+ "PID|1||900000001^^^FAC^MR||DOE^JANE^Q||19700101|F",
4659
+ "PV1|1|I|WARD^101^1^FAC"
4660
+ ];
4661
+ var ORU_R01_SEGMENTS = [
4662
+ "MSH|^~\\&|LAB|LFAC|EHR|EFAC|20260709130000||ORU^R01|MSG00002|P|2.5",
4663
+ "PID|1||900000002^^^FAC^MR||ROE^RICHARD",
4664
+ "OBR|1||ORD123|CBC^COMPLETE BLOOD COUNT",
4665
+ "OBX|1|NM|WBC^WHITE BLOOD COUNT||7.2|10*9/L|4.0-11.0|N"
4666
+ ];
4667
+ var ACK_AA_SEGMENTS = [
4668
+ "MSH|^~\\&|RECV_APP|RECV_FAC|SENDING_APP|SENDING_FAC|20260709120001||ACK^A01|ACK00001|P|2.5",
4669
+ "MSA|AA|MSG00001"
4670
+ ];
4671
+ function messageBytes(segments) {
4672
+ return Buffer.from(segments.join(SEGMENT_SEPARATOR), CONTROL_ID_ENCODING);
4673
+ }
4674
+ function canonicalExchanges() {
4675
+ return Object.freeze([
4676
+ Object.freeze({
4677
+ id: "adt-a01",
4678
+ description: "Patient admit, four segments, synthetic identity",
4679
+ controlId: "MSG00001",
4680
+ payload: messageBytes(ADT_A01_SEGMENTS)
4681
+ }),
4682
+ Object.freeze({
4683
+ id: "oru-r01",
4684
+ description: "Observation result, four segments, synthetic identity",
4685
+ controlId: "MSG00002",
4686
+ payload: messageBytes(ORU_R01_SEGMENTS)
4687
+ })
4688
+ ]);
4689
+ }
4690
+ function canonicalAcknowledgement() {
4691
+ return messageBytes(ACK_AA_SEGMENTS);
4692
+ }
4693
+
4694
+ // src/differential/error.ts
4695
+ var MLLP_DIFF_PEER_UNPARSEABLE = "MLLP_DIFF_PEER_UNPARSEABLE";
4696
+ var DIFFERENTIAL_CONFIGURATION_MESSAGES = Object.freeze({
4697
+ [MLLP_DIFF_PEER_UNPARSEABLE]: "the configured differential peer address could not be resolved into a host and a port, so the run was refused rather than skipped; expected the form host:port"
4698
+ });
4699
+ function differentialConfigurationMessage(code) {
4700
+ return DIFFERENTIAL_CONFIGURATION_MESSAGES[code];
4701
+ }
4702
+ var MllpDifferentialConfigurationError = class _MllpDifferentialConfigurationError extends Error {
4703
+ name = "MllpDifferentialConfigurationError";
4704
+ /**
4705
+ * The stable configuration-error code. Public API; branch on this rather than on the
4706
+ * message.
4707
+ */
4708
+ code;
4709
+ /**
4710
+ * The offending value exactly as it was configured, so the operator can see which of
4711
+ * their settings is wrong without guessing. It is caller configuration, never anything
4712
+ * read off a peer.
4713
+ */
4714
+ value;
4715
+ /**
4716
+ * Construct a differential configuration error.
4717
+ *
4718
+ * @param code - The stable configuration-error code; also selects the message.
4719
+ * @param value - The offending configured value, reported verbatim.
4720
+ */
4721
+ constructor(code, value) {
4722
+ super(`${differentialConfigurationMessage(code)}; got: ${JSON.stringify(value)}`);
4723
+ this.code = code;
4724
+ this.value = value;
4725
+ if (Error.captureStackTrace) {
4726
+ Error.captureStackTrace(this, _MllpDifferentialConfigurationError);
4727
+ }
4728
+ }
4729
+ };
4730
+
4731
+ // src/differential/peer.ts
4732
+ var MAX_PORT = 65535;
4733
+ function resolveDifferentialPeer(raw) {
4734
+ if (raw === void 0 || raw === null) return void 0;
4735
+ const trimmed = raw.trim();
4736
+ if (trimmed === "") return void 0;
4737
+ const refuse = () => {
4738
+ throw new MllpDifferentialConfigurationError(MLLP_DIFF_PEER_UNPARSEABLE, raw);
4739
+ };
4740
+ const lastColon = trimmed.lastIndexOf(":");
4741
+ if (lastColon <= 0 || lastColon === trimmed.length - 1) refuse();
4742
+ const host = trimmed.slice(0, lastColon).replace(/^\[|\]$/g, "");
4743
+ if (host === "") refuse();
4744
+ const portText = trimmed.slice(lastColon + 1);
4745
+ if (!/^\d+$/.test(portText)) refuse();
4746
+ const port = Number(portText);
4747
+ if (!Number.isInteger(port) || port <= 0 || port > MAX_PORT) refuse();
4748
+ return Object.freeze({ host, port });
4749
+ }
4750
+ var DEFAULT_DEADLINE_MS = 1e4;
4751
+ var ACK_CORRELATION_CODE = "MLLP_ACK_UNMATCHED_CONTROL_ID";
4752
+ function connectOverTcp(peer) {
4753
+ return new NetTransport(createConnection({ host: peer.host, port: peer.port }));
4754
+ }
4755
+ function isConnectionRefused(err) {
4756
+ if (!("code" in err)) return false;
4757
+ const { code } = err;
4758
+ return typeof code === "string" && code === "ECONNREFUSED";
4759
+ }
4760
+ function failureOutcome(err, connected) {
4761
+ if (err !== void 0 && isConnectionRefused(err)) return "connection-refused";
4762
+ return connected ? "connection-dropped" : "connection-failed";
4763
+ }
4764
+ function throwIfAborted(signal) {
4765
+ if (signal !== void 0 && signal.aborted) throw signal.reason;
4766
+ }
4767
+ async function runExchange(exchange, settings) {
4768
+ const { peer, connect, deadlineMs, maxFrameSizeBytes, signal } = settings;
4769
+ const framed = encodeFrame(exchange.payload);
4770
+ const startedAt = Date.now();
4771
+ const deviations = [];
4772
+ const chunks = [];
4773
+ let responseByteCount = 0;
4774
+ let connected = false;
4775
+ const finish = (outcome, byteParity, correlation) => Object.freeze({
4776
+ exchangeId: exchange.id,
4777
+ outcome,
4778
+ byteParity,
4779
+ correlation,
4780
+ warningCodes: Object.freeze(deviations.map((d) => d.code)),
4781
+ deviations: Object.freeze([...deviations]),
4782
+ requestByteCount: framed.length,
4783
+ responseByteCount,
4784
+ deadlineMs,
4785
+ elapsedMs: Date.now() - startedAt
4786
+ });
4787
+ let transport;
4788
+ try {
4789
+ transport = connect(peer);
4790
+ } catch {
4791
+ return finish("connection-failed", "not-observed", "not-observed");
4792
+ }
4793
+ const end = await new Promise((resolve) => {
4794
+ let settled = false;
4795
+ let abortListener;
4796
+ const settle = (value) => {
4797
+ if (settled) return;
4798
+ settled = true;
4799
+ clearTimeout(timer);
4800
+ if (abortListener !== void 0) signal?.removeEventListener("abort", abortListener);
4801
+ resolve(value);
4802
+ };
4803
+ const timer = setTimeout(() => {
4804
+ settle({ kind: "timeout" });
4805
+ }, deadlineMs);
4806
+ const reader = new FrameReader({
4807
+ onFrame: (payload, frameStart, warnings) => {
4808
+ for (const w of warnings) recordWarning(deviations, w);
4809
+ settle({ kind: "frame", payload, frameStart });
4810
+ },
4811
+ allowFsOnly: true,
4812
+ allowLfAfterFs: true,
4813
+ allowMissingLeadingVt: true,
4814
+ allowLeadingWhitespace: true,
4815
+ ...maxFrameSizeBytes === void 0 ? {} : { maxFrameSizeBytes }
4816
+ });
4817
+ transport.onData((chunk) => {
4818
+ if (settled) return;
4819
+ responseByteCount += chunk.length;
4820
+ chunks.push(Buffer.from(chunk));
4821
+ try {
4822
+ reader.push(chunk);
4823
+ } catch (err) {
4824
+ if (err instanceof MllpFramingError) {
4825
+ deviations.push(Object.freeze({ code: err.code, byteOffset: err.byteOffset }));
4826
+ settle({ kind: "framing-error", code: err.code, byteOffset: err.byteOffset });
4827
+ return;
4828
+ }
4829
+ settle({ kind: "error", error: err instanceof Error ? err : new Error(String(err)) });
4830
+ }
4831
+ });
4832
+ transport.onConnect(() => {
4833
+ connected = true;
4834
+ transport.write(framed);
4835
+ });
4836
+ transport.onError((err) => {
4837
+ settle({ kind: "error", error: err });
4838
+ });
4839
+ transport.onClose(() => {
4840
+ settle({ kind: "closed" });
4841
+ });
4842
+ if (signal !== void 0) {
4843
+ if (signal.aborted) {
4844
+ settle({ kind: "aborted" });
4845
+ return;
4846
+ }
4847
+ abortListener = () => {
4848
+ settle({ kind: "aborted" });
4849
+ };
4850
+ signal.addEventListener("abort", abortListener, { once: true });
4851
+ }
4852
+ });
4853
+ transport.destroy();
4854
+ if (end.kind === "aborted") {
4855
+ throwIfAborted(signal);
4856
+ return finish("connection-failed", "not-observed", "not-observed");
4857
+ }
4858
+ switch (end.kind) {
4859
+ case "frame":
4860
+ return classifyFrame(exchange, end.payload, end.frameStart, chunks, deviations, finish);
4861
+ case "framing-error":
4862
+ return finish("undecodable-response", "deviation", "not-observed");
4863
+ case "timeout":
4864
+ return finish("unanswered", "not-observed", "not-observed");
4865
+ case "closed":
4866
+ return finish(failureOutcome(void 0, connected), "not-observed", "not-observed");
4867
+ case "error":
4868
+ return finish(failureOutcome(end.error, connected), "not-observed", "not-observed");
4869
+ }
4870
+ }
4871
+ function recordWarning(into, warning, offsetBase = 0) {
4872
+ into.push(Object.freeze({ code: warning.code, byteOffset: offsetBase + warning.byteOffset }));
4873
+ }
4874
+ function classifyFrame(exchange, payload, frameStart, chunks, deviations, finish) {
4875
+ const canonical = encodeFrame(payload, {
4876
+ allowDelimiterBytesInPayload: true,
4877
+ onWarning: (w) => {
4878
+ recordWarning(deviations, w, frameStart + 1);
4879
+ }
4880
+ });
4881
+ const received = Buffer.concat([...chunks]);
4882
+ const parityMatch = deviations.length === 0 && frameStart === 0 && received.length >= canonical.length && received.subarray(0, canonical.length).equals(canonical);
4883
+ const acknowledged = extractMsaControlId(payload);
4884
+ let correlation;
4885
+ if (acknowledged === null) correlation = "absent";
4886
+ else if (acknowledged === exchange.controlId) correlation = "match";
4887
+ else correlation = "mismatch";
4888
+ if (correlation !== "match") {
4889
+ deviations.push(Object.freeze({ code: ACK_CORRELATION_CODE, byteOffset: frameStart }));
4890
+ }
4891
+ return finish("answered", parityMatch ? "match" : "deviation", correlation);
4892
+ }
4893
+ function deriveResult(exchanges) {
4894
+ const answered = exchanges.filter((e) => e.outcome === "answered");
4895
+ if (answered.length === 0) return "no-observation";
4896
+ const clean = answered.length === exchanges.length && answered.every(
4897
+ (e) => e.byteParity === "match" && e.correlation === "match" && e.deviations.length === 0
4898
+ );
4899
+ return clean ? "parity-observed" : "deviations-observed";
4900
+ }
4901
+ async function runDifferential(options = {}) {
4902
+ const startedAt = (/* @__PURE__ */ new Date()).toISOString();
4903
+ const deadlineMs = options.deadlineMs ?? DEFAULT_DEADLINE_MS;
4904
+ const exchanges = options.exchanges ?? canonicalExchanges();
4905
+ const connect = options.connect ?? connectOverTcp;
4906
+ const { signal } = options;
4907
+ throwIfAborted(signal);
4908
+ const peer = resolveDifferentialPeer(options.peer);
4909
+ if (peer === void 0) {
4910
+ return Object.freeze({
4911
+ peer: void 0,
4912
+ result: "skipped",
4913
+ skipReason: "no-peer-configured",
4914
+ exchanges: Object.freeze([]),
4915
+ exchangesAttempted: 0,
4916
+ exchangesAnswered: 0,
4917
+ deadlineMs,
4918
+ startedAt,
4919
+ finishedAt: (/* @__PURE__ */ new Date()).toISOString()
4920
+ });
4921
+ }
4922
+ const settings = {
4923
+ peer,
4924
+ connect,
4925
+ deadlineMs,
4926
+ maxFrameSizeBytes: options.maxFrameSizeBytes,
4927
+ signal
4928
+ };
4929
+ const results = [];
4930
+ for (const exchange of exchanges) {
4931
+ throwIfAborted(signal);
4932
+ results.push(await runExchange(exchange, settings));
4933
+ }
4934
+ return Object.freeze({
4935
+ peer: Object.freeze({ host: peer.host, port: peer.port }),
4936
+ result: deriveResult(results),
4937
+ skipReason: void 0,
4938
+ exchanges: Object.freeze([...results]),
4939
+ exchangesAttempted: results.length,
4940
+ exchangesAnswered: results.filter((e) => e.outcome === "answered").length,
4941
+ deadlineMs,
4942
+ startedAt,
4943
+ finishedAt: (/* @__PURE__ */ new Date()).toISOString()
4944
+ });
4945
+ }
4946
+
3438
4947
  // src/index.ts
3439
- var VERSION = "0.0.10";
4948
+ var VERSION = "0.1.0";
3440
4949
 
3441
- export { Connection, FrameReader, MLLP_BIND_ALL_INTERFACES, MLLP_TLS_VERIFY_DISABLED, MllpAckError, MllpBackpressureError, MllpClient, MllpConnectionError, MllpFramingError, MllpServer, MllpTimeoutError, NetTransport, TlsTransport, VERSION, ackDiagnosticMessage, buildRawAck, createClient, createServer, createStarterClient, createStarterServer, createWarning, encodeFrame, isTlsProtocolError, isTlsVerificationErrorCode, isTransientConnectionError, rawAckUncorrelatable, resolveNackCode };
4950
+ export { ATNA_CIPHER_LIST, ATNA_CIPHER_SUITES, Connection, FrameReader, MLLP_BIND_ALL_INTERFACES, MLLP_DIFF_PEER_UNPARSEABLE, MLLP_TLS_CIPHER_LIST_REJECTED, MLLP_TLS_CIPHER_OPTION_CONFLICT, MLLP_TLS_DH_PARAMETERS_REJECTED, MLLP_TLS_VERIFY_DISABLED, MllpAckError, MllpApplicationAckError, MllpBackpressureError, MllpClient, MllpCommitRejectedError, MllpConnectionError, MllpDifferentialConfigurationError, MllpFramingError, MllpNeverDeliveredError, MllpServer, MllpTimeoutError, MllpTlsConfigurationError, MllpUnknownFateError, NetTransport, TLS13_DEFAULT_CIPHER_SUITES, TlsTransport, VERSION, ackDiagnosticMessage, ackModeDiagnosticMessage, buildRawAck, canonicalAcknowledgement, canonicalExchanges, createClient, createServer, createStarterClient, createStarterServer, createWarning, differentialConfigurationMessage, encodeFrame, isTlsProtocolError, isTlsVerificationErrorCode, isTransientConnectionError, rawAckUncorrelatable, readNegotiatedTlsParameters, resolveDifferentialPeer, resolveNackCode, resolveTlsCipherPolicy, runDifferential, tlsConfigurationMessage };
3442
4951
  //# sourceMappingURL=index.mjs.map
3443
4952
  //# sourceMappingURL=index.mjs.map