@cosyte/mllp 0.0.10 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +281 -0
- package/README.md +306 -81
- package/dist/ack-from-hl7/index.cjs +11 -16
- package/dist/ack-from-hl7/index.cjs.map +1 -1
- package/dist/ack-from-hl7/index.mjs +11 -16
- package/dist/ack-from-hl7/index.mjs.map +1 -1
- package/dist/index.cjs +1603 -72
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +2060 -297
- package/dist/index.d.ts +2060 -297
- package/dist/index.mjs +1585 -76
- package/dist/index.mjs.map +1 -1
- package/package.json +32 -16
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
|
|
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
|
-
|
|
1081
|
-
|
|
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
|
-
...
|
|
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:
|
|
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:
|
|
2223
|
+
ackCode: code,
|
|
1837
2224
|
reason
|
|
1838
2225
|
})
|
|
1839
2226
|
);
|
|
1840
|
-
return
|
|
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
|
|
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(
|
|
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
|
|
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.
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
...
|
|
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
|
|
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)
|
|
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
|
|
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(
|
|
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
|
|
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.
|
|
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.
|
|
3287
|
-
|
|
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
|
-
|
|
3294
|
-
|
|
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
|
|
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
|