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