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