@cosyte/mllp 0.0.11 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +210 -0
- package/README.md +301 -78
- 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 +31 -16
package/dist/index.d.cts
CHANGED
|
@@ -631,9 +631,11 @@ interface SecurityWarning {
|
|
|
631
631
|
* direct comparison. ITI TF-2 §3.19.6.2.3 mandates four TLS 1.2 cipher
|
|
632
632
|
* suites: `TLS_DHE_RSA_WITH_AES_128_GCM_SHA256`,
|
|
633
633
|
* `TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256`, `TLS_DHE_RSA_WITH_AES_256_GCM_SHA384`,
|
|
634
|
-
* `TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384`.
|
|
635
|
-
*
|
|
636
|
-
*
|
|
634
|
+
* `TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384`. Set `atnaTransportSecurity: true`
|
|
635
|
+
* to offer exactly those four (plus the TLS 1.3 defaults) instead of whatever
|
|
636
|
+
* the runtime's own list happens to be; it is off by default, and what a link
|
|
637
|
+
* negotiated is reported on the `'tlsNegotiated'` event either way. See
|
|
638
|
+
* `docs-content/tls.md`.
|
|
637
639
|
*
|
|
638
640
|
* @example
|
|
639
641
|
* ```typescript
|
|
@@ -697,11 +699,40 @@ interface TlsOptions {
|
|
|
697
699
|
readonly minVersion?: "TLSv1.2" | "TLSv1.3";
|
|
698
700
|
/** Maximum negotiated TLS protocol version. */
|
|
699
701
|
readonly maxVersion?: "TLSv1.2" | "TLSv1.3";
|
|
702
|
+
/**
|
|
703
|
+
* Offer exactly the cipher suites the IHE ATNA ITI-19 transport-security
|
|
704
|
+
* option names (ITI TF-2 §3.19.6.2.3), rather than inheriting the runtime's
|
|
705
|
+
* own list. Off by default; turning it on only ever **narrows** what is
|
|
706
|
+
* offered.
|
|
707
|
+
*
|
|
708
|
+
* This is the **cipher-suite half** of that option, and it does not make the
|
|
709
|
+
* declaration on your behalf. Mutual node authentication is
|
|
710
|
+
* `ServerTlsOptions.clientAuth` plus a client `cert`/`key`, and the TLS 1.2
|
|
711
|
+
* floor is {@link TlsOptions.minVersion}, which already defaults to it.
|
|
712
|
+
*
|
|
713
|
+
* The four TLS 1.2 suites are offered together with the three TLS 1.3 suites
|
|
714
|
+
* the runtime enables by default, so selecting this never removes a protocol
|
|
715
|
+
* version that was reachable without it. A peer that supports none of them
|
|
716
|
+
* fails the handshake rather than falling back, which is the point.
|
|
717
|
+
*
|
|
718
|
+
* Mutually exclusive with {@link TlsOptions.ciphers}: both declare the
|
|
719
|
+
* offered list, so setting both rejects `connect()` with a typed
|
|
720
|
+
* `MllpTlsConfigurationError`.
|
|
721
|
+
*
|
|
722
|
+
* @default false
|
|
723
|
+
*/
|
|
724
|
+
readonly atnaTransportSecurity?: boolean;
|
|
700
725
|
/**
|
|
701
726
|
* OpenSSL cipher-list string passthrough (`tls.connect`'s `ciphers`).
|
|
702
727
|
* Unset uses Node's compiled-in defaults, which already include both
|
|
703
728
|
* ATNA-mandated ECDHE suites (see the module doc comment). Set this to
|
|
704
|
-
* restrict to a stricter list
|
|
729
|
+
* restrict to a stricter list of your own choosing; for the ATNA option's
|
|
730
|
+
* own four suites prefer {@link TlsOptions.atnaTransportSecurity}, which is
|
|
731
|
+
* the same declaration made once and reported on `'tlsNegotiated'`.
|
|
732
|
+
*
|
|
733
|
+
* A list the runtime rejects rejects `connect()` with a typed
|
|
734
|
+
* `MllpTlsConfigurationError`; it never silently falls back to the default
|
|
735
|
+
* list.
|
|
705
736
|
*
|
|
706
737
|
* @default undefined (Node defaults)
|
|
707
738
|
*/
|
|
@@ -779,14 +810,449 @@ interface ServerTlsOptions {
|
|
|
779
810
|
readonly minVersion?: "TLSv1.2" | "TLSv1.3";
|
|
780
811
|
/** Maximum negotiated TLS protocol version. */
|
|
781
812
|
readonly maxVersion?: "TLSv1.2" | "TLSv1.3";
|
|
813
|
+
/**
|
|
814
|
+
* Offer exactly the cipher suites the IHE ATNA ITI-19 transport-security
|
|
815
|
+
* option names (ITI TF-2 §3.19.6.2.3), rather than inheriting the runtime's
|
|
816
|
+
* own list. Off by default; turning it on only ever **narrows** what is
|
|
817
|
+
* offered.
|
|
818
|
+
*
|
|
819
|
+
* The server side additionally provides ephemeral Diffie-Hellman parameters,
|
|
820
|
+
* because two of the four named suites are DHE and a server without them
|
|
821
|
+
* cannot actually offer a DHE suite. The group is the one the runtime selects
|
|
822
|
+
* for the certificate in use unless {@link ServerTlsOptions.dhParameters}
|
|
823
|
+
* names another, which takes precedence. See
|
|
824
|
+
* {@link TlsOptions.atnaTransportSecurity} for the rest, including what this
|
|
825
|
+
* setting does **not** claim on your behalf.
|
|
826
|
+
*
|
|
827
|
+
* Mutually exclusive with {@link ServerTlsOptions.ciphers}: setting both
|
|
828
|
+
* rejects `listen()` with a typed `MllpTlsConfigurationError`.
|
|
829
|
+
*
|
|
830
|
+
* @default false
|
|
831
|
+
*/
|
|
832
|
+
readonly atnaTransportSecurity?: boolean;
|
|
782
833
|
/**
|
|
783
834
|
* OpenSSL cipher-list string passthrough. Unset uses Node's defaults
|
|
784
|
-
* (includes both ATNA-mandated ECDHE suites).
|
|
835
|
+
* (includes both ATNA-mandated ECDHE suites). For the ATNA option's own four
|
|
836
|
+
* suites prefer {@link ServerTlsOptions.atnaTransportSecurity}.
|
|
837
|
+
*
|
|
838
|
+
* A list the runtime rejects rejects `listen()` with a typed
|
|
839
|
+
* `MllpTlsConfigurationError`; it never silently falls back to the default
|
|
840
|
+
* list.
|
|
785
841
|
*
|
|
786
842
|
* @default undefined (Node defaults)
|
|
787
843
|
*/
|
|
788
844
|
readonly ciphers?: string;
|
|
845
|
+
/**
|
|
846
|
+
* Ephemeral Diffie-Hellman parameters this server answers a DHE key exchange
|
|
847
|
+
* with, as **PEM content**. There is deliberately no filesystem-path input:
|
|
848
|
+
* every credential on this type is content, and this package performs no disk
|
|
849
|
+
* IO for any of it. Generate a group with `openssl dhparam` and read it in
|
|
850
|
+
* yourself.
|
|
851
|
+
*
|
|
852
|
+
* A DHE cipher suite is unofferable by a server with no parameters, so this
|
|
853
|
+
* is what makes one reachable through {@link ServerTlsOptions.ciphers}: a
|
|
854
|
+
* list restricted to a DHE suite without this advertises that suite and then
|
|
855
|
+
* fails every handshake in it.
|
|
856
|
+
*
|
|
857
|
+
* **It takes precedence over the group
|
|
858
|
+
* {@link ServerTlsOptions.atnaTransportSecurity} selects automatically**, and
|
|
859
|
+
* setting both is not a conflict: the option picks a group when nothing else
|
|
860
|
+
* says which, and this says which. That is how a deployment whose own policy
|
|
861
|
+
* names a group puts it in force while still offering exactly the ITI TF-2
|
|
862
|
+
* §3.19.6.2.3 suites.
|
|
863
|
+
*
|
|
864
|
+
* Parameters this package or the TLS library cannot use reject `listen()`
|
|
865
|
+
* with a typed `MllpTlsConfigurationError` carrying
|
|
866
|
+
* `MLLP_TLS_DH_PARAMETERS_REJECTED`, before anything is bound. Nothing falls
|
|
867
|
+
* back to a server running without them, which matters because the TLS
|
|
868
|
+
* library's own behaviour for unreadable parameters is to discard them in
|
|
869
|
+
* silence. `'auto'` is not accepted here; that selection is what
|
|
870
|
+
* `atnaTransportSecurity` already makes.
|
|
871
|
+
*
|
|
872
|
+
* There is no counterpart on {@link TlsOptions}: the side that answers the
|
|
873
|
+
* key exchange supplies the parameters.
|
|
874
|
+
*
|
|
875
|
+
* @default undefined (no Diffie-Hellman parameters unless `atnaTransportSecurity` selects them)
|
|
876
|
+
*/
|
|
877
|
+
readonly dhParameters?: string | Buffer;
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
/**
|
|
881
|
+
* Typed TLS **configuration** errors and their stable codes.
|
|
882
|
+
*
|
|
883
|
+
* A configuration error is raised before any socket exists: what this package
|
|
884
|
+
* was asked to offer cannot be honoured, so the connection is refused rather
|
|
885
|
+
* than opened on some other configuration. Two things can be asked for, and
|
|
886
|
+
* they carry separate codes: the offered cipher-suite list, and the server's
|
|
887
|
+
* ephemeral Diffie-Hellman parameters. It rejects the `connect()` / `listen()`
|
|
888
|
+
* call that asked for it, never a live connection.
|
|
889
|
+
*
|
|
890
|
+
* These codes are a **public API**. They appear in caller `catch` blocks, log
|
|
891
|
+
* pipelines, and monitoring dashboards. Renaming or removing a code is a
|
|
892
|
+
* breaking change (the same guardrail the security-warning codes carry).
|
|
893
|
+
*
|
|
894
|
+
* @example
|
|
895
|
+
* ```typescript
|
|
896
|
+
* import { MllpTlsConfigurationError, MLLP_TLS_CIPHER_LIST_REJECTED } from '@cosyte/mllp';
|
|
897
|
+
* try {
|
|
898
|
+
* await client.connect();
|
|
899
|
+
* } catch (err) {
|
|
900
|
+
* if (err instanceof MllpTlsConfigurationError && err.code === MLLP_TLS_CIPHER_LIST_REJECTED) {
|
|
901
|
+
* logger.error({ code: err.code });
|
|
902
|
+
* }
|
|
903
|
+
* }
|
|
904
|
+
* ```
|
|
905
|
+
*
|
|
906
|
+
* @packageDocumentation
|
|
907
|
+
*/
|
|
908
|
+
/**
|
|
909
|
+
* The runtime refused the cipher-suite list it was handed: no suite in it is
|
|
910
|
+
* available in this Node build's TLS library. Nothing falls back to the
|
|
911
|
+
* runtime default list, the connection or the bind is refused instead.
|
|
912
|
+
*
|
|
913
|
+
* @example
|
|
914
|
+
* ```typescript
|
|
915
|
+
* import { MLLP_TLS_CIPHER_LIST_REJECTED } from '@cosyte/mllp';
|
|
916
|
+
* if (err.code === MLLP_TLS_CIPHER_LIST_REJECTED) process.exit(78); // EX_CONFIG
|
|
917
|
+
* ```
|
|
918
|
+
*/
|
|
919
|
+
declare const MLLP_TLS_CIPHER_LIST_REJECTED = "MLLP_TLS_CIPHER_LIST_REJECTED";
|
|
920
|
+
/**
|
|
921
|
+
* Two different offered-suite declarations were made at once:
|
|
922
|
+
* `atnaTransportSecurity: true` fixes the list, and `ciphers` replaces it.
|
|
923
|
+
* Honouring either one silently discards the other, so both are refused.
|
|
924
|
+
*
|
|
925
|
+
* @example
|
|
926
|
+
* ```typescript
|
|
927
|
+
* import { MLLP_TLS_CIPHER_OPTION_CONFLICT } from '@cosyte/mllp';
|
|
928
|
+
* if (err.code === MLLP_TLS_CIPHER_OPTION_CONFLICT) logger.error('pick one of the two');
|
|
929
|
+
* ```
|
|
930
|
+
*/
|
|
931
|
+
declare const MLLP_TLS_CIPHER_OPTION_CONFLICT = "MLLP_TLS_CIPHER_OPTION_CONFLICT";
|
|
932
|
+
/**
|
|
933
|
+
* The ephemeral Diffie-Hellman parameters a server supplied are not usable: not
|
|
934
|
+
* PEM content, not a Diffie-Hellman parameter block, or a group the TLS library
|
|
935
|
+
* refuses. Distinct from {@link MLLP_TLS_CIPHER_LIST_REJECTED} because the
|
|
936
|
+
* remedy is different: one is the offered suite list, the other is the key
|
|
937
|
+
* exchange behind it.
|
|
938
|
+
*
|
|
939
|
+
* Nothing is bound and nothing falls back to a server running with no
|
|
940
|
+
* Diffie-Hellman parameters, which would advertise its DHE suites and then fail
|
|
941
|
+
* every handshake in them.
|
|
942
|
+
*
|
|
943
|
+
* @example
|
|
944
|
+
* ```typescript
|
|
945
|
+
* import { MLLP_TLS_DH_PARAMETERS_REJECTED } from '@cosyte/mllp';
|
|
946
|
+
* if (err.code === MLLP_TLS_DH_PARAMETERS_REJECTED) process.exit(78); // EX_CONFIG
|
|
947
|
+
* ```
|
|
948
|
+
*/
|
|
949
|
+
declare const MLLP_TLS_DH_PARAMETERS_REJECTED = "MLLP_TLS_DH_PARAMETERS_REJECTED";
|
|
950
|
+
/**
|
|
951
|
+
* Union of the stable TLS-configuration error codes.
|
|
952
|
+
*
|
|
953
|
+
* @example
|
|
954
|
+
* ```typescript
|
|
955
|
+
* const code: TlsConfigurationErrorCode = 'MLLP_TLS_CIPHER_LIST_REJECTED';
|
|
956
|
+
* ```
|
|
957
|
+
*/
|
|
958
|
+
type TlsConfigurationErrorCode = typeof MLLP_TLS_CIPHER_LIST_REJECTED | typeof MLLP_TLS_CIPHER_OPTION_CONFLICT | typeof MLLP_TLS_DH_PARAMETERS_REJECTED;
|
|
959
|
+
/**
|
|
960
|
+
* Fixed, human-readable description for a TLS-configuration code.
|
|
961
|
+
*
|
|
962
|
+
* Byte-for-byte identical for a given code, whatever the configuration was.
|
|
963
|
+
*
|
|
964
|
+
* @param code - The stable configuration-error code.
|
|
965
|
+
* @returns The frozen registry text for `code`.
|
|
966
|
+
*
|
|
967
|
+
* @example
|
|
968
|
+
* ```typescript
|
|
969
|
+
* import { tlsConfigurationMessage, MLLP_TLS_CIPHER_OPTION_CONFLICT } from '@cosyte/mllp';
|
|
970
|
+
* logger.error(tlsConfigurationMessage(MLLP_TLS_CIPHER_OPTION_CONFLICT));
|
|
971
|
+
* ```
|
|
972
|
+
*/
|
|
973
|
+
declare function tlsConfigurationMessage(code: TlsConfigurationErrorCode): string;
|
|
974
|
+
/**
|
|
975
|
+
* Thrown for a TLS configuration this package will not open a connection on.
|
|
976
|
+
* Rejects the originating `MllpClient.connect()` or `MllpServer.listen()`; no
|
|
977
|
+
* socket is opened and no listener is left bound.
|
|
978
|
+
*
|
|
979
|
+
* Identify it by `instanceof` **and** by the stable {@link MllpTlsConfigurationError.code},
|
|
980
|
+
* never by matching on the message text. `message` is a frozen registry entry
|
|
981
|
+
* and carries no key material and no passphrase; `cause`, when present, is the
|
|
982
|
+
* TLS library's own error for one input on its own (the suite list and the
|
|
983
|
+
* Diffie-Hellman parameters are each validated with no credential in scope).
|
|
984
|
+
*
|
|
985
|
+
* @example
|
|
986
|
+
* ```typescript
|
|
987
|
+
* import { MllpTlsConfigurationError } from '@cosyte/mllp';
|
|
988
|
+
* try {
|
|
989
|
+
* await server.listen(2575);
|
|
990
|
+
* } catch (err) {
|
|
991
|
+
* if (err instanceof MllpTlsConfigurationError) logger.error({ code: err.code });
|
|
992
|
+
* }
|
|
993
|
+
* ```
|
|
994
|
+
*/
|
|
995
|
+
declare class MllpTlsConfigurationError extends Error {
|
|
996
|
+
readonly name: "MllpTlsConfigurationError";
|
|
997
|
+
/**
|
|
998
|
+
* The stable configuration-error code. Public API; branch on this rather
|
|
999
|
+
* than on the message.
|
|
1000
|
+
*/
|
|
1001
|
+
readonly code: TlsConfigurationErrorCode;
|
|
1002
|
+
/**
|
|
1003
|
+
* The TLS library's own error, when the runtime is what refused the list.
|
|
1004
|
+
* `undefined` when this package refused the configuration itself.
|
|
1005
|
+
*/
|
|
1006
|
+
readonly cause: Error | undefined;
|
|
1007
|
+
/**
|
|
1008
|
+
* Construct a TLS configuration error.
|
|
1009
|
+
*
|
|
1010
|
+
* @param code - The stable configuration-error code; also selects the message.
|
|
1011
|
+
* @param opts - Optional underlying `cause` from the TLS library.
|
|
1012
|
+
*/
|
|
1013
|
+
constructor(code: TlsConfigurationErrorCode, opts?: {
|
|
1014
|
+
cause?: Error;
|
|
1015
|
+
});
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
/**
|
|
1019
|
+
* The offered-cipher-suite policy behind `tls.atnaTransportSecurity`.
|
|
1020
|
+
*
|
|
1021
|
+
* Spec anchor: IHE ATNA, ITI-19 Authenticate Node
|
|
1022
|
+
* (https://profiles.ihe.net/ITI/TF/Volume2/ITI-19.html), the "STX: TLS 1.2
|
|
1023
|
+
* Floor using BCP195" option, ITI TF-2 §3.19.6.2.3, which names four TLS 1.2
|
|
1024
|
+
* cipher suites an actor claiming that option supports, and allows
|
|
1025
|
+
* "additional cipher suites of similar or greater cryptographic strength".
|
|
1026
|
+
*
|
|
1027
|
+
* With the option off (the default) this module imposes nothing and the
|
|
1028
|
+
* offered list is the runtime's, which the runtime's own documentation says
|
|
1029
|
+
* a distribution may configure at build time and an operator may replace
|
|
1030
|
+
* wholesale from outside the process. With the option on, the offered list is
|
|
1031
|
+
* a property of this package instead, and what a link negotiated is
|
|
1032
|
+
* observable on the `'tlsNegotiated'` event.
|
|
1033
|
+
*
|
|
1034
|
+
* Two of the four suites are DHE, so the server half of the policy also settles
|
|
1035
|
+
* which ephemeral Diffie-Hellman group is in force: the caller's own, when
|
|
1036
|
+
* `ServerTlsOptions.dhParameters` names one, and otherwise the selection the
|
|
1037
|
+
* runtime makes for the certificate in use.
|
|
1038
|
+
*
|
|
1039
|
+
* @example
|
|
1040
|
+
* ```typescript
|
|
1041
|
+
* import { ATNA_CIPHER_SUITES } from '@cosyte/mllp';
|
|
1042
|
+
* console.log(ATNA_CIPHER_SUITES.length); // => 4
|
|
1043
|
+
* ```
|
|
1044
|
+
*
|
|
1045
|
+
* @packageDocumentation
|
|
1046
|
+
*/
|
|
1047
|
+
/**
|
|
1048
|
+
* The four TLS 1.2 cipher suites ITI TF-2 §3.19.6.2.3 names, in the OpenSSL
|
|
1049
|
+
* spelling a cipher-list string is written in. Their IANA spellings, which are
|
|
1050
|
+
* what the standard prints and what the `'tlsNegotiated'` event reports, are
|
|
1051
|
+
* the same four prefixed `TLS_` and joined with `_WITH_`.
|
|
1052
|
+
*
|
|
1053
|
+
* @example
|
|
1054
|
+
* ```typescript
|
|
1055
|
+
* import { ATNA_CIPHER_SUITES } from '@cosyte/mllp';
|
|
1056
|
+
* console.log(ATNA_CIPHER_SUITES.includes('ECDHE-RSA-AES128-GCM-SHA256')); // => true
|
|
1057
|
+
* ```
|
|
1058
|
+
*/
|
|
1059
|
+
declare const ATNA_CIPHER_SUITES: readonly string[];
|
|
1060
|
+
/**
|
|
1061
|
+
* The three TLS 1.3 cipher suites the runtime enables by default.
|
|
1062
|
+
*
|
|
1063
|
+
* They are carried alongside the four above for one reason: a TLS 1.3 suite is
|
|
1064
|
+
* enabled only by its full name in the cipher list, so a list holding the four
|
|
1065
|
+
* TLS 1.2 suites alone would turn TLS 1.3 **off**. Selecting the option must
|
|
1066
|
+
* never remove a protocol version that was reachable without it, so these are
|
|
1067
|
+
* restated rather than dropped.
|
|
1068
|
+
*
|
|
1069
|
+
* @example
|
|
1070
|
+
* ```typescript
|
|
1071
|
+
* import { TLS13_DEFAULT_CIPHER_SUITES } from '@cosyte/mllp';
|
|
1072
|
+
* console.log(TLS13_DEFAULT_CIPHER_SUITES.length); // => 3
|
|
1073
|
+
* ```
|
|
1074
|
+
*/
|
|
1075
|
+
declare const TLS13_DEFAULT_CIPHER_SUITES: readonly string[];
|
|
1076
|
+
/**
|
|
1077
|
+
* The OpenSSL cipher-list string `tls.atnaTransportSecurity: true` offers:
|
|
1078
|
+
* the three TLS 1.3 default suites followed by the four ITI TF-2 §3.19.6.2.3
|
|
1079
|
+
* suites, and nothing else.
|
|
1080
|
+
*
|
|
1081
|
+
* @example
|
|
1082
|
+
* ```typescript
|
|
1083
|
+
* import { ATNA_CIPHER_LIST } from '@cosyte/mllp';
|
|
1084
|
+
* console.log(ATNA_CIPHER_LIST.split(':').length); // => 7
|
|
1085
|
+
* ```
|
|
1086
|
+
*/
|
|
1087
|
+
declare const ATNA_CIPHER_LIST: string;
|
|
1088
|
+
/**
|
|
1089
|
+
* The cipher-suite half of a client's or server's TLS options, the two fields
|
|
1090
|
+
* that can declare what is offered.
|
|
1091
|
+
*/
|
|
1092
|
+
interface TlsCipherPolicyInput {
|
|
1093
|
+
/** See `TlsOptions.atnaTransportSecurity`. */
|
|
1094
|
+
readonly atnaTransportSecurity?: boolean;
|
|
1095
|
+
/** See `TlsOptions.ciphers`. */
|
|
1096
|
+
readonly ciphers?: string;
|
|
1097
|
+
/**
|
|
1098
|
+
* See `ServerTlsOptions.dhParameters`. Diffie-Hellman parameters are supplied
|
|
1099
|
+
* by the end that answers the key exchange, so this is read when `side` is
|
|
1100
|
+
* `'server'` and **ignored, without an error, when `side` is `'client'`**.
|
|
1101
|
+
*
|
|
1102
|
+
* That asymmetry is stated rather than enforced because the field is
|
|
1103
|
+
* unreachable from the client option type in the first place: `TlsOptions`
|
|
1104
|
+
* carries no Diffie-Hellman parameter field at all, so a client value can
|
|
1105
|
+
* only arrive through a direct call to
|
|
1106
|
+
* {@link resolveTlsCipherPolicy}.
|
|
1107
|
+
*/
|
|
1108
|
+
readonly dhParameters?: string | Buffer;
|
|
1109
|
+
}
|
|
1110
|
+
/**
|
|
1111
|
+
* The TLS-context fields the resolved policy contributes. Empty when the
|
|
1112
|
+
* option is off and no `ciphers` passthrough is set: this package then imposes
|
|
1113
|
+
* no cipher list at all, exactly as before the option existed.
|
|
1114
|
+
*/
|
|
1115
|
+
interface ResolvedTlsCipherPolicy {
|
|
1116
|
+
/** OpenSSL cipher-list string, or absent to impose none. */
|
|
1117
|
+
readonly ciphers?: string;
|
|
1118
|
+
/**
|
|
1119
|
+
* Ephemeral Diffie-Hellman parameters, server-side only. Two of the four
|
|
1120
|
+
* named suites are DHE, and a server with no DH parameters cannot offer a
|
|
1121
|
+
* DHE suite at all: without this it would advertise the list and then fail
|
|
1122
|
+
* every DHE handshake in it.
|
|
1123
|
+
*
|
|
1124
|
+
* Exactly two things reach this field. The literal `'auto'` is the TLS
|
|
1125
|
+
* library's own spelling for the selection it makes from the certificate in
|
|
1126
|
+
* use, which is what `atnaTransportSecurity: true` alone yields. Anything
|
|
1127
|
+
* else is PEM content: the caller's own group, taken verbatim from
|
|
1128
|
+
* `ServerTlsOptions.dhParameters` and preferred over the automatic selection.
|
|
1129
|
+
* Absent when neither was asked for, so the not-selected path is untouched.
|
|
1130
|
+
*
|
|
1131
|
+
* Typed `string | Buffer` rather than `'auto' | string | Buffer` because the
|
|
1132
|
+
* literal is absorbed by `string` in a union and says nothing to a reader
|
|
1133
|
+
* there; it is stated here instead.
|
|
1134
|
+
*/
|
|
1135
|
+
readonly dhparam?: string | Buffer;
|
|
1136
|
+
}
|
|
1137
|
+
/**
|
|
1138
|
+
* Resolve the offered-cipher-suite policy for one side of a connection, and
|
|
1139
|
+
* prove the runtime accepts it before any socket is opened.
|
|
1140
|
+
*
|
|
1141
|
+
* Refuses, rather than picking a winner, when both `atnaTransportSecurity` and
|
|
1142
|
+
* `ciphers` are set: each declares the offered list, and honouring one would
|
|
1143
|
+
* silently discard the other on a link whose whole point is being able to say
|
|
1144
|
+
* what it offered.
|
|
1145
|
+
*
|
|
1146
|
+
* **Diffie-Hellman parameters are the one place a winner IS picked, and the
|
|
1147
|
+
* caller wins.** `atnaTransportSecurity: true` selects a group automatically
|
|
1148
|
+
* because two of its four suites are DHE; a caller that also names its own
|
|
1149
|
+
* group is stating site policy, and site policy is more specific than a default
|
|
1150
|
+
* this package chose. So the two are not in conflict and the configuration is
|
|
1151
|
+
* accepted with the caller's group in force.
|
|
1152
|
+
*
|
|
1153
|
+
* Each input is validated **alone**, with no certificate, key or passphrase in
|
|
1154
|
+
* scope, so a rejection can only ever be about that input and the error it
|
|
1155
|
+
* raises cannot carry credential material.
|
|
1156
|
+
*
|
|
1157
|
+
* @param opts - The cipher-suite half of the caller's TLS options.
|
|
1158
|
+
* @param side - Which end of the connection is being configured.
|
|
1159
|
+
* @returns The TLS-context fields to spread into `tls.connect` / `tls.createServer`.
|
|
1160
|
+
* @throws {MllpTlsConfigurationError} On a conflicting declaration, a list the
|
|
1161
|
+
* runtime rejects, or Diffie-Hellman parameters it cannot use. Never falls back
|
|
1162
|
+
* to the runtime default list, and never to a server with no Diffie-Hellman
|
|
1163
|
+
* parameters.
|
|
1164
|
+
*
|
|
1165
|
+
* @example
|
|
1166
|
+
* ```typescript
|
|
1167
|
+
* import { resolveTlsCipherPolicy } from '@cosyte/mllp';
|
|
1168
|
+
* const policy = resolveTlsCipherPolicy({ atnaTransportSecurity: true }, 'server');
|
|
1169
|
+
* console.log(policy.dhparam); // => 'auto'
|
|
1170
|
+
* ```
|
|
1171
|
+
*/
|
|
1172
|
+
declare function resolveTlsCipherPolicy(opts: TlsCipherPolicyInput, side: "client" | "server"): ResolvedTlsCipherPolicy;
|
|
1173
|
+
|
|
1174
|
+
/**
|
|
1175
|
+
* The `'tlsNegotiated'` event payload: what a completed TLS handshake actually
|
|
1176
|
+
* agreed on.
|
|
1177
|
+
*
|
|
1178
|
+
* A deployment that has declared the IHE ATNA ITI-19 transport-security option
|
|
1179
|
+
* on a link needs to be able to show, from its own logs, which protocol
|
|
1180
|
+
* version and which cipher suite that link negotiated. This is that record,
|
|
1181
|
+
* emitted once per completed handshake on both the client and the server.
|
|
1182
|
+
*
|
|
1183
|
+
* It is a **log line**, so it carries no field content: both values are read
|
|
1184
|
+
* from the TLS session and are names out of a closed registry, never anything
|
|
1185
|
+
* derived from the payload, the certificate, or the key.
|
|
1186
|
+
*
|
|
1187
|
+
* @example
|
|
1188
|
+
* ```typescript
|
|
1189
|
+
* import type { NegotiatedTlsParameters } from '@cosyte/mllp';
|
|
1190
|
+
* client.on('tlsNegotiated', (p: NegotiatedTlsParameters) => {
|
|
1191
|
+
* logger.info({ tls: p.protocolVersion, suite: p.cipherSuite });
|
|
1192
|
+
* });
|
|
1193
|
+
* ```
|
|
1194
|
+
*
|
|
1195
|
+
* @packageDocumentation
|
|
1196
|
+
*/
|
|
1197
|
+
|
|
1198
|
+
/**
|
|
1199
|
+
* Frozen payload of the `'tlsNegotiated'` event, emitted by both
|
|
1200
|
+
* {@link MllpClient} and {@link MllpServer} once per completed TLS handshake,
|
|
1201
|
+
* including every reconnect. Never emitted on a plaintext connection.
|
|
1202
|
+
*
|
|
1203
|
+
* Carries no HL7 payload content and no certificate or key material: only the
|
|
1204
|
+
* two negotiated names and the same routing metadata a `SecurityWarning`
|
|
1205
|
+
* carries. It is emitted at handshake-completion time, before any HL7 byte has
|
|
1206
|
+
* crossed the link.
|
|
1207
|
+
*
|
|
1208
|
+
* @example
|
|
1209
|
+
* ```typescript
|
|
1210
|
+
* server.on('tlsNegotiated', (p: NegotiatedTlsParameters) => {
|
|
1211
|
+
* if (p.protocolVersion !== 'TLSv1.3') metrics.increment('mllp.tls12_link');
|
|
1212
|
+
* });
|
|
1213
|
+
* ```
|
|
1214
|
+
*/
|
|
1215
|
+
interface NegotiatedTlsParameters {
|
|
1216
|
+
/**
|
|
1217
|
+
* Negotiated protocol version as the TLS library reports it, e.g.
|
|
1218
|
+
* `'TLSv1.2'` or `'TLSv1.3'`.
|
|
1219
|
+
*/
|
|
1220
|
+
readonly protocolVersion: string;
|
|
1221
|
+
/**
|
|
1222
|
+
* Negotiated cipher suite in its **IANA** spelling, e.g.
|
|
1223
|
+
* `'TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256'`. That is the spelling the IHE
|
|
1224
|
+
* and IETF documents print, so it can be compared with a conformance claim
|
|
1225
|
+
* directly. The OpenSSL spelling, which is what a `ciphers` list is written
|
|
1226
|
+
* in, is a different rendering of the same suite.
|
|
1227
|
+
*/
|
|
1228
|
+
readonly cipherSuite: string;
|
|
1229
|
+
/** Host associated with the link (the target host for clients; the bind host for servers). */
|
|
1230
|
+
readonly host: string;
|
|
1231
|
+
/** Port associated with the link. */
|
|
1232
|
+
readonly port: number;
|
|
1233
|
+
/** Wall-clock time at point of emission. */
|
|
1234
|
+
readonly timestamp: Date;
|
|
789
1235
|
}
|
|
1236
|
+
/**
|
|
1237
|
+
* Read the negotiated parameters off a socket whose handshake has completed.
|
|
1238
|
+
*
|
|
1239
|
+
* Returns `null` for a socket that is not a TLS socket at all, and for a TLS
|
|
1240
|
+
* socket with no session to report. That is what keeps a plaintext connection
|
|
1241
|
+
* from producing an event: it is a structural property of the read, not a
|
|
1242
|
+
* caller-side condition anyone has to remember.
|
|
1243
|
+
*
|
|
1244
|
+
* @param socket - The socket whose handshake completed; plaintext is allowed and yields `null`.
|
|
1245
|
+
* @param host - Routing metadata for the payload's `host`.
|
|
1246
|
+
* @param port - Routing metadata for the payload's `port`.
|
|
1247
|
+
* @returns A frozen payload, or `null` when there is nothing negotiated to report.
|
|
1248
|
+
*
|
|
1249
|
+
* @example
|
|
1250
|
+
* ```typescript
|
|
1251
|
+
* const params = readNegotiatedTlsParameters(tlsSocket, 'mllp.example.com', 2575);
|
|
1252
|
+
* if (params !== null) logger.info({ suite: params.cipherSuite });
|
|
1253
|
+
* ```
|
|
1254
|
+
*/
|
|
1255
|
+
declare function readNegotiatedTlsParameters(socket: Socket | TLSSocket, host: string, port: number): NegotiatedTlsParameters | null;
|
|
790
1256
|
|
|
791
1257
|
/**
|
|
792
1258
|
* Connection, 6-state FSM over a Transport with lifecycle events, warning streams,
|
|
@@ -1236,263 +1702,81 @@ declare class MllpConnectionError extends Error {
|
|
|
1236
1702
|
}
|
|
1237
1703
|
|
|
1238
1704
|
/**
|
|
1239
|
-
*
|
|
1240
|
-
*
|
|
1241
|
-
* This module owns the byte-level ACK builder used by {@link MllpServer}'s auto-ACK
|
|
1242
|
-
* path, the HL7 Table 0008 acknowledgement-code union, and the error type a message
|
|
1243
|
-
* handler throws to request a specific negative acknowledgement.
|
|
1244
|
-
*
|
|
1245
|
-
* ## Why this is safety-critical
|
|
1705
|
+
* The frozen diagnostic registry for acknowledgement-mode reporting.
|
|
1246
1706
|
*
|
|
1247
|
-
*
|
|
1248
|
-
*
|
|
1249
|
-
*
|
|
1250
|
-
*
|
|
1707
|
+
* Every message an acknowledgement-mode diagnostic can carry lives here as a literal, and
|
|
1708
|
+
* the lookup takes **only a code**. There is no value parameter anywhere in this module,
|
|
1709
|
+
* which is the whole point: a factory that cannot be handed a value cannot interpolate one,
|
|
1710
|
+
* regardless of what arrives on the wire. It is the same shape, and for the same reason, as
|
|
1711
|
+
* the correlation registry beside it.
|
|
1251
1712
|
*
|
|
1252
|
-
*
|
|
1253
|
-
* application handler has resolved success. A handler that throws or rejects yields a
|
|
1254
|
-
* **negative** ACK (`AE` by default, `AR` when the handler asks for it), never `AA`.
|
|
1713
|
+
* ## What these diagnostics may carry
|
|
1255
1714
|
*
|
|
1256
|
-
*
|
|
1257
|
-
*
|
|
1258
|
-
*
|
|
1715
|
+
* Stable codes, byte counts, byte offsets, elapsed times, and an acknowledgement code drawn
|
|
1716
|
+
* from the closed six-code Table 0008 set. Nothing else. A control ID is payload content, a
|
|
1717
|
+
* field value is payload content, and these go to a log. An MSA-1 that could not be
|
|
1718
|
+
* classified is reported by its **byte length** only, never by its bytes: the whole reason a
|
|
1719
|
+
* value is unclassifiable is that nobody knows what it is, so echoing it into a log is
|
|
1720
|
+
* echoing unknown wire content into a log.
|
|
1259
1721
|
*
|
|
1260
1722
|
* @packageDocumentation
|
|
1261
1723
|
*/
|
|
1262
1724
|
/**
|
|
1263
|
-
*
|
|
1264
|
-
*
|
|
1265
|
-
* Two families, by mode (HL7 v2.5.1 §2.9):
|
|
1266
|
-
*
|
|
1267
|
-
* - **Original mode (§2.9.2):** `AA` accept, `AE` application error, `AR` application
|
|
1268
|
-
* reject. The single ACK reports application-level outcome.
|
|
1269
|
-
* - **Enhanced mode (§2.9.3):** `CA` commit accept, `CE` commit error, `CR` commit
|
|
1270
|
-
* reject, the *accept* acknowledgement, distinct from a later application ACK.
|
|
1725
|
+
* The stable codes acknowledgement-mode reporting emits.
|
|
1271
1726
|
*
|
|
1272
|
-
*
|
|
1273
|
-
*
|
|
1274
|
-
*
|
|
1275
|
-
*
|
|
1276
|
-
* callers that build their own enhanced-mode ACKs via `autoAck: fn`.
|
|
1727
|
+
* A **public API**: consumers narrow on them in warning handlers, log pipelines and
|
|
1728
|
+
* monitoring. Renaming or removing one is a breaking change. They are deliberately a
|
|
1729
|
+
* family of their own rather than additions to the framing warning-code union, because
|
|
1730
|
+
* that union is the decoder's registry and these are not decoder events.
|
|
1277
1731
|
*
|
|
1278
1732
|
* @example
|
|
1279
1733
|
* ```typescript
|
|
1280
|
-
* import type {
|
|
1281
|
-
* const code:
|
|
1734
|
+
* import type { AckModeCode } from '@cosyte/mllp';
|
|
1735
|
+
* const code: AckModeCode = 'MLLP_ACK_TWO_PHASE_UNAVAILABLE';
|
|
1282
1736
|
* ```
|
|
1283
1737
|
*/
|
|
1284
|
-
type
|
|
1738
|
+
type AckModeCode = "MLLP_ACK_ACCEPT_TYPE_UNRECOGNISED" | "MLLP_ACK_APPLICATION_TYPE_UNRECOGNISED" | "MLLP_ACK_TWO_PHASE_UNAVAILABLE" | "MLLP_ACK_COMMIT_ALREADY_REPORTED" | "MLLP_ACK_MSA1_ABSENT" | "MLLP_ACK_MSA1_UNCLASSIFIABLE" | "MLLP_ACK_SEND_ALREADY_DISPOSED";
|
|
1285
1739
|
/**
|
|
1286
|
-
*
|
|
1740
|
+
* Look up the frozen diagnostic text for an acknowledgement-mode code.
|
|
1287
1741
|
*
|
|
1742
|
+
* A pure table read. It takes no value, so no caller can widen it into an interpolation
|
|
1743
|
+
* site.
|
|
1744
|
+
*
|
|
1745
|
+
* @param code - The acknowledgement-mode code being reported.
|
|
1746
|
+
* @returns The registered message for that code, byte-for-byte.
|
|
1288
1747
|
* @example
|
|
1289
1748
|
* ```typescript
|
|
1290
|
-
*
|
|
1291
|
-
* const code: NegativeAckCode = 'AR';
|
|
1749
|
+
* const message = ackModeDiagnosticMessage('MLLP_ACK_MSA1_UNCLASSIFIABLE');
|
|
1292
1750
|
* ```
|
|
1293
1751
|
*/
|
|
1294
|
-
|
|
1752
|
+
declare function ackModeDiagnosticMessage(code: AckModeCode): string;
|
|
1753
|
+
|
|
1295
1754
|
/**
|
|
1296
|
-
*
|
|
1297
|
-
* returns. Throwing **any** error from a commit-gated handler yields `AE` by default;
|
|
1298
|
-
* throw an `MllpAckError` to choose `AR` (application reject) instead.
|
|
1755
|
+
* MLLP Server, createServer(), createStarterServer(), and MllpServer class.
|
|
1299
1756
|
*
|
|
1300
|
-
*
|
|
1301
|
-
*
|
|
1757
|
+
* Provides the server-side MLLP transport: listen for inbound TCP connections,
|
|
1758
|
+
* decode MLLP-framed messages, surface them as Buffer payloads, and support
|
|
1759
|
+
* optional auto-ACK, keepalive, graceful shutdown, and AbortSignal cancellation.
|
|
1302
1760
|
*
|
|
1303
1761
|
* @example
|
|
1304
1762
|
* ```typescript
|
|
1305
|
-
* import { createServer
|
|
1763
|
+
* import { createServer } from '@cosyte/mllp';
|
|
1306
1764
|
*
|
|
1307
|
-
* createServer({
|
|
1308
|
-
*
|
|
1309
|
-
*
|
|
1310
|
-
* if (!isAcceptable(payload)) {
|
|
1311
|
-
* // sender should NOT resend unchanged -> AR
|
|
1312
|
-
* throw new MllpAckError('unsupported message type', { ackCode: 'AR' });
|
|
1313
|
-
* }
|
|
1314
|
-
* await db.commit(payload); // throw here -> AE (resend may succeed)
|
|
1765
|
+
* const server = createServer({
|
|
1766
|
+
* onMessage: (payload, meta, conn) => {
|
|
1767
|
+
* console.log('received', payload.length, 'bytes from', meta.connectionId);
|
|
1315
1768
|
* },
|
|
1316
1769
|
* });
|
|
1770
|
+
* await server.listen(2575);
|
|
1317
1771
|
* ```
|
|
1772
|
+
*
|
|
1773
|
+
* @packageDocumentation
|
|
1318
1774
|
*/
|
|
1319
|
-
|
|
1320
|
-
/** The negative acknowledgement code to return (`AE` or `AR`). */
|
|
1321
|
-
readonly ackCode: NegativeAckCode;
|
|
1322
|
-
/**
|
|
1323
|
-
* @param message - Diagnostic text for the thrower. Never placed on the wire (may carry PHI).
|
|
1324
|
-
* @param opts.ackCode - Negative code to return. Default `'AE'`.
|
|
1325
|
-
* @param opts.cause - Underlying error, preserved on `.cause`.
|
|
1326
|
-
*/
|
|
1327
|
-
constructor(message: string, opts?: {
|
|
1328
|
-
ackCode?: NegativeAckCode;
|
|
1329
|
-
cause?: unknown;
|
|
1330
|
-
});
|
|
1331
|
-
}
|
|
1775
|
+
|
|
1332
1776
|
/**
|
|
1333
|
-
*
|
|
1777
|
+
* Metadata attached to each decoded MLLP message.
|
|
1334
1778
|
*
|
|
1335
|
-
*
|
|
1336
|
-
* `AE` (application error, the default, since most handler failures are transient and
|
|
1337
|
-
* a resend may succeed).
|
|
1338
|
-
*
|
|
1339
|
-
* @example
|
|
1340
|
-
* ```typescript
|
|
1341
|
-
* import { resolveNackCode, MllpAckError } from '@cosyte/mllp';
|
|
1342
|
-
* resolveNackCode(new Error('boom')); // 'AE'
|
|
1343
|
-
* resolveNackCode(new MllpAckError('nope', { ackCode: 'AR' })); // 'AR'
|
|
1344
|
-
* ```
|
|
1345
|
-
*/
|
|
1346
|
-
declare function resolveNackCode(err: unknown): NegativeAckCode;
|
|
1347
|
-
/**
|
|
1348
|
-
* True iff a **positive** raw acknowledgement (`AA`/`CA`) cannot be safely correlated to
|
|
1349
|
-
* this payload, so {@link buildRawAck} (and the server's auto-ACK path) must **downgrade**
|
|
1350
|
-
* it to a non-positive `AE`/`CE` rather than tell the sender "I have it."
|
|
1351
|
-
*
|
|
1352
|
-
* The rule this enforces: **never answer `AA` for a message you could not correlate.** A
|
|
1353
|
-
* positive ACK is a promise the sender may forget the message; if it names a control ID
|
|
1354
|
-
* the sender cannot match, or names one of several messages it never read, the sender
|
|
1355
|
-
* times out and resends, committing a **duplicate clinical message** (or worse, believes a
|
|
1356
|
-
* destroyed message was delivered). Three payload-shaped reasons make a positive ACK
|
|
1357
|
-
* uncorrelatable, all peer-reachable off the wire:
|
|
1358
|
-
*
|
|
1359
|
-
* 1. **No readable `MSH`.** `readMshSegment` returns `null`, e.g. a `BOM`/`SP`/`TAB`
|
|
1360
|
-
* before `MSH` (which shares the `MSH`'s segment line, so `MSH` heads no segment), or
|
|
1361
|
-
* a bare fragment delivered after a mid-payload `VT` discard (`MLLP_TRAILING_BYTES`).
|
|
1362
|
-
* MSA-2 would be empty: an ACK that correlates to nothing.
|
|
1363
|
-
* 2. **Empty MSH-10.** The `MSH` is readable but carries no message control ID, so there
|
|
1364
|
-
* is nothing to echo, again `MSA|AA|` with an empty MSA-2.
|
|
1365
|
-
* 3. **A batch or concatenated messages.** An `FHS`/`BHS`/`BTS`/`FTS` envelope (§2.10.3)
|
|
1366
|
-
* or a second `MSH` in the same frame: a single MSA-2 can echo only ONE control ID, so
|
|
1367
|
-
* a positive ACK naming the first silently drops the rest (see
|
|
1368
|
-
* {@link containsBatchOrExtraMessage}). Batch ACK is its own feature; until it is
|
|
1369
|
-
* designed, a batch must stay a **loud non-positive** answer.
|
|
1370
|
-
*
|
|
1371
|
-
* This is a **refusal**, not a tolerance widening: it never makes an unreadable message
|
|
1372
|
-
* readable, never re-bases on a located `MSH`, never parses a batch. It only recognizes
|
|
1373
|
-
* the shapes for which a positive disposition would be a lie, so the builder can fall back
|
|
1374
|
-
* to `AE`. A **negative** requested code (`AE`/`AR`/`CE`/`CR`) is unaffected, it is
|
|
1375
|
-
* already non-positive, and echoing whatever control ID it can find is still useful.
|
|
1376
|
-
*
|
|
1377
|
-
* Pure, byte-level, never throws.
|
|
1378
|
-
*
|
|
1379
|
-
* @example
|
|
1380
|
-
* ```typescript
|
|
1381
|
-
* import { rawAckUncorrelatable } from '@cosyte/mllp';
|
|
1382
|
-
* rawAckUncorrelatable(oneGoodMessage); // false → AA is safe
|
|
1383
|
-
* rawAckUncorrelatable(twoConcatenatedMsh); // true → downgrade AA to AE
|
|
1384
|
-
* ```
|
|
1385
|
-
*/
|
|
1386
|
-
declare function rawAckUncorrelatable(payload: Buffer): boolean;
|
|
1387
|
-
/**
|
|
1388
|
-
* Build a minimal original-mode HL7 v2 acknowledgement from raw inbound payload bytes,
|
|
1389
|
-
* **without a parser** (parser-driven ACKs are the `@cosyte/mllp/ack-from-hl7` subpath).
|
|
1390
|
-
*
|
|
1391
|
-
* Locates the `MSH` segment (the first `CR`/`LF`-delimited segment starting with `MSH`,
|
|
1392
|
-
* so a leading `CR` or an `FHS`/`BHS` batch header does not hide it), reads the field
|
|
1393
|
-
* separator from **MSH-1** and the encoding characters from **MSH-2**, then splits that
|
|
1394
|
-
* segment on that separator to read fields. The ACK swaps sender/receiver per HL7 ACK
|
|
1395
|
-
* rules, echoes the inbound MSH-10 into MSA-2 (§2.9.2.2), sets MSA-1 to `code`, and, for
|
|
1396
|
-
* negative codes, adds a **static, PHI-free** MSA-3 reason. A fresh control ID fills the
|
|
1397
|
-
* ACK's own MSH-10.
|
|
1398
|
-
*
|
|
1399
|
-
* ## Why the delimiters are read, not assumed
|
|
1400
|
-
*
|
|
1401
|
-
* MSH-1 **is** the field separator (HL7 v2.5.1 §2.5.4): the byte at offset 3 of the MSH
|
|
1402
|
-
* segment *defines* it for the whole message. `|` is overwhelmingly common but it is a
|
|
1403
|
-
* convention, not the spec. Assuming it was wrong in two compounding ways:
|
|
1404
|
-
*
|
|
1405
|
-
* 1. **Reading.** Splitting a `!`-delimited message on `|` yields ONE field, so every
|
|
1406
|
-
* echoed field, including MSH-10, came back empty. The ACK went out as
|
|
1407
|
-
* `MSA|AA|` with **no correlation id at all**: the sender cannot match it, times
|
|
1408
|
-
* out, and resends → a duplicate clinical message. That is the exact failure this
|
|
1409
|
-
* package's correlator exists to prevent, manufactured by the ACK builder itself.
|
|
1410
|
-
* 2. **Writing.** The echoed MSH-3..6 and MSH-10 field *content* is copied verbatim
|
|
1411
|
-
* from the inbound, still escaped against the **inbound's** encoding characters. Re-
|
|
1412
|
-
* emitting that content under a different delimiter set silently reinterprets it:
|
|
1413
|
-
* an inbound whose component separator is `#` and whose MSH-10 is `ID#X` would be
|
|
1414
|
-
* re-emitted under `^~\&` as the literal `ID#X`, which the sender then reads as a
|
|
1415
|
-
* single component rather than two. Echoing MSH-1 and MSH-2 keeps the content and
|
|
1416
|
-
* the delimiters that define it together.
|
|
1417
|
-
*
|
|
1418
|
-
* ## It reads the MSH through the SHARED scanner
|
|
1419
|
-
*
|
|
1420
|
-
* The MSH read is `readMshSegment` from `src/internal/control-id.ts`, the same call the
|
|
1421
|
-
* client's correlator makes to derive the key it will later match this ACK against. That
|
|
1422
|
-
* is deliberate and it is the whole point: this builder used to re-derive the read itself
|
|
1423
|
-
* (`payload.toString("latin1").split("\r")`, hunting for an `MSH` anywhere in the
|
|
1424
|
-
* payload), and the two disagreed on real inputs. On a **truncated MSH** followed by a
|
|
1425
|
-
* `PID` the correlator keyed on the PID's MRN while this builder echoed an empty MSA-2. On
|
|
1426
|
-
* a payload with a **leading `CR`**, which the MLLP decoder passes straight through, and
|
|
1427
|
-
* which real senders emit, this builder found the `MSH` and echoed MSH-10 correctly while
|
|
1428
|
-
* the correlator, requiring `MSH` at byte 0, gave up. Every such disagreement is an ACK the
|
|
1429
|
-
* sender cannot match → timeout → resend → **duplicate clinical message**.
|
|
1430
|
-
*
|
|
1431
|
-
* The fix is one scan, but note *which* scan. The first attempt made them agree by
|
|
1432
|
-
* requiring `MSH` at byte 0 everywhere, which "resolved" the leading-`CR` disagreement by
|
|
1433
|
-
* degrading the side that had been **right**: `buildRawAck` began emitting a positive `AA`
|
|
1434
|
-
* with an empty MSA-2, silently, for a message whose MSH-10 was plainly present. Agreement
|
|
1435
|
-
* is not the goal; agreeing on the *correct*, *tolerant* answer is. A lenient reader may
|
|
1436
|
-
* never drop data that is there (Postel's Law, CLAUDE.md).
|
|
1437
|
-
*
|
|
1438
|
-
* ## The fail-safe downgrade: never a positive `AA`/`CA` it cannot correlate
|
|
1439
|
-
*
|
|
1440
|
-
* A positive acknowledgement is a promise the sender may forget the message. If it names a
|
|
1441
|
-
* control ID the sender cannot match, or names one message out of several it never read,
|
|
1442
|
-
* the sender times out and resends, committing a **duplicate clinical message**. So a
|
|
1443
|
-
* requested positive code is **downgraded** to its non-positive counterpart (`AA`→`AE`,
|
|
1444
|
-
* `CA`→`CE`) whenever the payload cannot carry a correlatable positive ACK: no readable
|
|
1445
|
-
* `MSH`, an empty MSH-10, or a batch/concatenated-message shape that a single MSA-2 cannot
|
|
1446
|
-
* acknowledge. See {@link rawAckUncorrelatable} for the exact conditions and why each is a
|
|
1447
|
-
* refusal rather than a widened reader. A requested **negative** code (`AE`/`AR`/`CE`/`CR`)
|
|
1448
|
-
* is never touched. This mirrors the parser-backed `buildMllpAck`, which downgrades and
|
|
1449
|
-
* warns on an unparseable inbound, the two builders' fail-safe semantics now agree.
|
|
1450
|
-
*
|
|
1451
|
-
* **Never throws** and **never copies payload content** beyond the routing/control
|
|
1452
|
-
* metadata above, `readMshSegment` stops at the MSH's segment terminator, so no field of
|
|
1453
|
-
* any later segment (PID and friends) can be reached, let alone echoed. On a missing or
|
|
1454
|
-
* unreadable `MSH` it returns a minimal well-formed ACK carrying the (downgraded) `code` so
|
|
1455
|
-
* the caller can still respond.
|
|
1456
|
-
*
|
|
1457
|
-
* @param payload - Raw decoded HL7 v2 payload bytes (MLLP framing already stripped).
|
|
1458
|
-
* @param code - Requested MSA-1 acknowledgement code. A positive `AA`/`CA` is downgraded to
|
|
1459
|
-
* `AE`/`CE` when the message cannot be correlated (see above).
|
|
1460
|
-
* @returns ACK payload bytes (no framing, the caller wraps with `encodeFrame`).
|
|
1461
|
-
*
|
|
1462
|
-
* @example
|
|
1463
|
-
* ```typescript
|
|
1464
|
-
* import { buildRawAck } from '@cosyte/mllp';
|
|
1465
|
-
* const ack = buildRawAck(inboundPayload, 'AE'); // MSA|AE|<inbound MSH-10>|message could not be processed
|
|
1466
|
-
* ```
|
|
1467
|
-
*/
|
|
1468
|
-
declare function buildRawAck(payload: Buffer, code: AckCode): Buffer;
|
|
1469
|
-
|
|
1470
|
-
/**
|
|
1471
|
-
* MLLP Server, createServer(), createStarterServer(), and MllpServer class.
|
|
1472
|
-
*
|
|
1473
|
-
* Provides the server-side MLLP transport: listen for inbound TCP connections,
|
|
1474
|
-
* decode MLLP-framed messages, surface them as Buffer payloads, and support
|
|
1475
|
-
* optional auto-ACK, keepalive, graceful shutdown, and AbortSignal cancellation.
|
|
1476
|
-
*
|
|
1477
|
-
* @example
|
|
1478
|
-
* ```typescript
|
|
1479
|
-
* import { createServer } from '@cosyte/mllp';
|
|
1480
|
-
*
|
|
1481
|
-
* const server = createServer({
|
|
1482
|
-
* onMessage: (payload, meta, conn) => {
|
|
1483
|
-
* console.log('received', payload.length, 'bytes from', meta.connectionId);
|
|
1484
|
-
* },
|
|
1485
|
-
* });
|
|
1486
|
-
* await server.listen(2575);
|
|
1487
|
-
* ```
|
|
1488
|
-
*
|
|
1489
|
-
* @packageDocumentation
|
|
1490
|
-
*/
|
|
1491
|
-
|
|
1492
|
-
/**
|
|
1493
|
-
* Metadata attached to each decoded MLLP message.
|
|
1494
|
-
*
|
|
1495
|
-
* All fields are `readonly`, the object is `Object.freeze()`'d before emission.
|
|
1779
|
+
* All fields are `readonly`, the object is `Object.freeze()`'d before emission.
|
|
1496
1780
|
*
|
|
1497
1781
|
* @example
|
|
1498
1782
|
* ```typescript
|
|
@@ -1548,11 +1832,58 @@ type NackReason = "handler-rejected" | "uncorrelatable-inbound" | "discarded-byt
|
|
|
1548
1832
|
interface NackEvent {
|
|
1549
1833
|
/** Connection that produced the negative acknowledgement. */
|
|
1550
1834
|
readonly connectionId: string;
|
|
1551
|
-
/**
|
|
1552
|
-
|
|
1835
|
+
/**
|
|
1836
|
+
* The negative acknowledgement code actually sent to the peer.
|
|
1837
|
+
*
|
|
1838
|
+
* `AE` or `AR` for a message acknowledged in the original mode, and the accept-mode
|
|
1839
|
+
* counterparts `CE` or `CR` where the inbound MSH-15 asked for an accept acknowledgement
|
|
1840
|
+
* for this disposition. It is always the code that went on the wire, so an observer
|
|
1841
|
+
* counting negative acknowledgements sees what the peer saw.
|
|
1842
|
+
*/
|
|
1843
|
+
readonly ackCode: NackAckCode;
|
|
1553
1844
|
/** Why the negative acknowledgement was sent (PHI-free). */
|
|
1554
1845
|
readonly reason: NackReason;
|
|
1555
1846
|
}
|
|
1847
|
+
/**
|
|
1848
|
+
* The negative acknowledgement codes a {@link NackEvent} can carry: both halves of the
|
|
1849
|
+
* negative side of HL7 Table 0008.
|
|
1850
|
+
*
|
|
1851
|
+
* @example
|
|
1852
|
+
* ```typescript
|
|
1853
|
+
* import type { NackAckCode } from '@cosyte/mllp';
|
|
1854
|
+
* const code: NackAckCode = 'CR';
|
|
1855
|
+
* ```
|
|
1856
|
+
*/
|
|
1857
|
+
type NackAckCode = "AE" | "AR" | "CE" | "CR";
|
|
1858
|
+
/**
|
|
1859
|
+
* Payload of the server `'ackModeWarning'` event: something about an inbound message's
|
|
1860
|
+
* acknowledgement-mode request was read and reported, and the acknowledgement went out
|
|
1861
|
+
* anyway.
|
|
1862
|
+
*
|
|
1863
|
+
* Emitted when MSH-15 carries a value outside HL7 Table 0155. The acknowledgement is **not**
|
|
1864
|
+
* downgraded, negated or otherwise changed for that reason: reading a field is not
|
|
1865
|
+
* validating it, and answering a message the handler committed with anything but a positive
|
|
1866
|
+
* code would make a sender resend a message that is already stored.
|
|
1867
|
+
*
|
|
1868
|
+
* **PHI-safe by construction:** a connection ID, a stable code and a frozen registry
|
|
1869
|
+
* message, never the payload and never the field's value. The object is `Object.freeze()`'d
|
|
1870
|
+
* before emission.
|
|
1871
|
+
*
|
|
1872
|
+
* @example
|
|
1873
|
+
* ```typescript
|
|
1874
|
+
* server.on('ackModeWarning', ({ code }) => metrics.increment('mllp.ack_mode', { code }));
|
|
1875
|
+
* ```
|
|
1876
|
+
*/
|
|
1877
|
+
interface AckModeWarningEvent {
|
|
1878
|
+
/** Connection the inbound message arrived on. */
|
|
1879
|
+
readonly connectionId: string;
|
|
1880
|
+
/** The acknowledgement-mode code being reported. */
|
|
1881
|
+
readonly code: AckModeCode;
|
|
1882
|
+
/** Frozen registry text for `code`. Never interpolated. */
|
|
1883
|
+
readonly message: string;
|
|
1884
|
+
/** Wall-clock time at point of emission. */
|
|
1885
|
+
readonly timestamp: Date;
|
|
1886
|
+
}
|
|
1556
1887
|
/**
|
|
1557
1888
|
* Observability snapshot returned by `server.getStats()`.
|
|
1558
1889
|
*
|
|
@@ -1771,9 +2102,12 @@ interface StarterServerOptions extends ServerOptions {
|
|
|
1771
2102
|
* during the bind window changes the `listen()` rejection from the bind error to the typed
|
|
1772
2103
|
* close-during-listen `MllpConnectionError`, match on the `'error'` event payload, not the
|
|
1773
2104
|
* rejection, if you do that.
|
|
1774
|
-
* TLS (MLLPS) adds
|
|
1775
|
-
* server logs it and keeps serving other connections)
|
|
1776
|
-
* one-time notice when a wildcard host is bound via `allowWildcardBind: true`)
|
|
2105
|
+
* TLS (MLLPS) adds three more: `'tlsClientError'` (a failed TLS handshake, the
|
|
2106
|
+
* server logs it and keeps serving other connections), `'securityWarning'` (loud,
|
|
2107
|
+
* one-time notice when a wildcard host is bound via `allowWildcardBind: true`), and
|
|
2108
|
+
* `'tlsNegotiated'` ({@link NegotiatedTlsParameters}, the protocol version and cipher
|
|
2109
|
+
* suite each accepted link actually negotiated, once per completed handshake and never
|
|
2110
|
+
* on a plaintext listener).
|
|
1777
2111
|
* All event payloads are `Object.freeze()`'d before emission.
|
|
1778
2112
|
*
|
|
1779
2113
|
* @example
|
|
@@ -1811,6 +2145,16 @@ declare class MllpServer extends EventEmitter {
|
|
|
1811
2145
|
private _closedTotal;
|
|
1812
2146
|
/** Count of `'tlsClientError'` events since listen(). */
|
|
1813
2147
|
private _tlsClientErrorsTotal;
|
|
2148
|
+
/**
|
|
2149
|
+
* A TLS configuration this server will not serve on: an offered-suite list
|
|
2150
|
+
* it cannot honour, or ephemeral Diffie-Hellman parameters it cannot use. Set
|
|
2151
|
+
* at construction, and the ONLY thing `listen()` consults before it binds:
|
|
2152
|
+
* such a server never binds, never accepts, and never negotiates anything,
|
|
2153
|
+
* which is what makes "no fallback to the default list, and none to a server
|
|
2154
|
+
* with no Diffie-Hellman parameters" a structural property rather than a
|
|
2155
|
+
* promise. `null` when the configuration is fine.
|
|
2156
|
+
*/
|
|
2157
|
+
private readonly _tlsConfigError;
|
|
1814
2158
|
/**
|
|
1815
2159
|
* Construct an MLLP server. Created idle; call `listen()` (or use
|
|
1816
2160
|
* {@link createServer}/{@link createStarterServer}) to begin accepting connections.
|
|
@@ -1830,6 +2174,14 @@ declare class MllpServer extends EventEmitter {
|
|
|
1830
2174
|
* binds raced each other's post-bind safety checks). Call `close()` before
|
|
1831
2175
|
* re-listening; sequential `listen()` → `close()` → `listen()` is fine.
|
|
1832
2176
|
*
|
|
2177
|
+
* **A refused TLS configuration** (the runtime rejects the offered suite
|
|
2178
|
+
* list, `tls.atnaTransportSecurity` and `tls.ciphers` both declare one, or
|
|
2179
|
+
* `tls.dhParameters` are not usable) rejects every `listen()` on this server
|
|
2180
|
+
* with a typed `MllpTlsConfigurationError`, checked before the bind. Such a
|
|
2181
|
+
* server never listens, never negotiates on any other list and never runs
|
|
2182
|
+
* without the Diffie-Hellman parameters it was given; construct a new one
|
|
2183
|
+
* with a configuration that resolves.
|
|
2184
|
+
*
|
|
1833
2185
|
* **`close()` during an in-flight `listen()`** rejects that `listen()` with
|
|
1834
2186
|
* a typed `MllpConnectionError` (never a hang) and clears the single-flight
|
|
1835
2187
|
* guard, a subsequent `listen()` on the same server works. This makes
|
|
@@ -1991,8 +2343,48 @@ declare class MllpServer extends EventEmitter {
|
|
|
1991
2343
|
* discard, never a neighbouring frame's trailing junk.
|
|
1992
2344
|
*
|
|
1993
2345
|
* `'discarded-bytes'` takes precedence in the reason when both hold, it is the root cause.
|
|
2346
|
+
*
|
|
2347
|
+
* `selectCode` puts the resolved disposition in the half of HL7 Table 0008 the inbound
|
|
2348
|
+
* asked for; it is the identity for a message that asked for nothing.
|
|
1994
2349
|
*/
|
|
1995
2350
|
private _resolveAutoAckPositive;
|
|
2351
|
+
/**
|
|
2352
|
+
* Build this message's acknowledgement-code selector: the function that puts a resolved
|
|
2353
|
+
* disposition in the half of HL7 Table 0008 the inbound asked for.
|
|
2354
|
+
*
|
|
2355
|
+
* MSH-15 (accept acknowledgment type, HL7 Table 0155) says under which conditions the
|
|
2356
|
+
* sender wants an **accept** acknowledgement, and it is a condition rather than a switch:
|
|
2357
|
+
* `AL` always, `NE` never, `ER` on an error or reject only, `SU` on success only. So the
|
|
2358
|
+
* request cannot be evaluated until the disposition is known, which is why this returns a
|
|
2359
|
+
* function rather than a code. Where the answer is yes, the disposition is emitted as its
|
|
2360
|
+
* accept-mode counterpart: `CA` where this server would answer `AA`, `CE` where it would
|
|
2361
|
+
* answer `AE`, `CR` where it would answer `AR`.
|
|
2362
|
+
*
|
|
2363
|
+
* Exactly one acknowledgement still goes out per inbound message; nothing here sends a
|
|
2364
|
+
* second one, and the later application acknowledgement of the two-part protocol remains
|
|
2365
|
+
* the consumer's to send.
|
|
2366
|
+
*
|
|
2367
|
+
* Three inbound shapes leave the table entirely and keep this server's own answer:
|
|
2368
|
+
*
|
|
2369
|
+
* * a header this server cannot scan for MSH-15: it behaves exactly as it would have,
|
|
2370
|
+
* adding no new failure and no new report;
|
|
2371
|
+
* * a **batch or concatenated** frame: it keeps its warned non-positive answer and
|
|
2372
|
+
* never selects an accept-mode code, whatever MSH-15 says. A single MSA-2 can echo one
|
|
2373
|
+
* control ID, and commit-accepting a batch nobody read is the same lie in the other
|
|
2374
|
+
* half of the table;
|
|
2375
|
+
* * an MSH-15 outside Table 0155: reported, and answered exactly as it would have been.
|
|
2376
|
+
* Downgrading a message the handler committed would make a discharged sender resend a
|
|
2377
|
+
* message already in storage.
|
|
2378
|
+
*/
|
|
2379
|
+
private _ackModeSelector;
|
|
2380
|
+
/**
|
|
2381
|
+
* Emit the frozen `'ackModeWarning'` event.
|
|
2382
|
+
*
|
|
2383
|
+
* CONTAINED for the same reason every other emit on this path is: it is reached from a
|
|
2384
|
+
* `void`-ed async acknowledgement task, and a throwing metrics tap must never be able to
|
|
2385
|
+
* suppress the acknowledgement that follows it.
|
|
2386
|
+
*/
|
|
2387
|
+
private _emitAckModeWarning;
|
|
1996
2388
|
/**
|
|
1997
2389
|
* Run `onMessage` for its side effects only (custom-ACK and manual modes), guarding
|
|
1998
2390
|
* against a synchronous throw or a rejected promise. In these modes the handler does
|
|
@@ -2054,68 +2446,359 @@ declare function createServer(opts?: ServerOptions): MllpServer;
|
|
|
2054
2446
|
declare function createStarterServer(opts: StarterServerOptions): Promise<MllpServer>;
|
|
2055
2447
|
|
|
2056
2448
|
/**
|
|
2057
|
-
*
|
|
2058
|
-
*
|
|
2059
|
-
* Every message an ACK-correlation diagnostic can carry lives here as a
|
|
2060
|
-
* literal, and the lookup takes **only a code**. There is no value parameter
|
|
2061
|
-
* anywhere in this module, which is the whole point: a factory that cannot be
|
|
2062
|
-
* handed a value cannot interpolate one, regardless of what arrives on the
|
|
2063
|
-
* wire.
|
|
2449
|
+
* Server-side MLLP acknowledgement construction and the **fail-safe commit contract**.
|
|
2064
2450
|
*
|
|
2065
|
-
*
|
|
2451
|
+
* This module owns the byte-level ACK builder used by {@link MllpServer}'s auto-ACK
|
|
2452
|
+
* path, the HL7 Table 0008 acknowledgement-code union, and the error type a message
|
|
2453
|
+
* handler throws to request a specific negative acknowledgement.
|
|
2066
2454
|
*
|
|
2067
|
-
*
|
|
2068
|
-
* interpolation, and both ends of it were consumer-controlled and unbounded.
|
|
2069
|
-
* The unmatched-ACK error read the **peer's** MSA-2 straight out of an inbound
|
|
2070
|
-
* frame and put it on an `Error.message`; measured against a peer sending a
|
|
2071
|
-
* one-megabyte MSA-2, that produced a 1,000,026-byte `message` and an
|
|
2072
|
-
* equally large field on the frozen error payload. The late-ACK warning did
|
|
2073
|
-
* the same with the control id of the send being reported on. Both go to a
|
|
2074
|
-
* log, and in this domain a log is a place a patient identifier must not
|
|
2075
|
-
* reach. That is not a hypothetical about control ids either: this package has
|
|
2076
|
-
* already shipped one scanner that ran past a segment terminator and returned
|
|
2077
|
-
* PID-3, a patient's medical record number, as the "control ID" of a truncated
|
|
2078
|
-
* MSH. Withholding the bytes is the defence that survives the next such bug.
|
|
2455
|
+
* ## Why this is safety-critical
|
|
2079
2456
|
*
|
|
2080
|
-
*
|
|
2457
|
+
* A positive acknowledgement (`AA`) tells the sender "you may forget this message, I
|
|
2458
|
+
* have it." If a server emits `AA` *before* the message is durably handled and then the
|
|
2459
|
+
* process crashes, the message is **silently lost** with no record on either side. For
|
|
2460
|
+
* clinical messages (an admit, an order, a result) that is a patient-safety failure.
|
|
2081
2461
|
*
|
|
2082
|
-
*
|
|
2083
|
-
*
|
|
2084
|
-
*
|
|
2085
|
-
* both still grow the diagnostic with the input and both still disclose the
|
|
2086
|
-
* bytes. Nothing is lost by withholding them, because the caller already holds
|
|
2087
|
-
* them: the outbound payload is the caller's own argument to `send()`, and the
|
|
2088
|
-
* inbound frame is on the `'message'` event.
|
|
2462
|
+
* The contract this module enforces: a positive ACK (`AA`) is only built once the
|
|
2463
|
+
* application handler has resolved success. A handler that throws or rejects yields a
|
|
2464
|
+
* **negative** ACK (`AE` by default, `AR` when the handler asks for it), never `AA`.
|
|
2089
2465
|
*
|
|
2090
|
-
*
|
|
2091
|
-
*
|
|
2092
|
-
* MSA-2
|
|
2466
|
+
* Spec: HL7 v2.5.1 ch. 2 §2.9.2 (original acknowledgement mode), §2.9.3 (enhanced
|
|
2467
|
+
* accept/application acknowledgement), Table 0008 (Acknowledgment Code), §2.9.2.2
|
|
2468
|
+
* (MSA-2 echoes the inbound MSH-10 message control ID).
|
|
2093
2469
|
*
|
|
2094
2470
|
* @packageDocumentation
|
|
2095
2471
|
*/
|
|
2096
2472
|
/**
|
|
2097
|
-
*
|
|
2473
|
+
* HL7 Table 0008, Acknowledgment Code. A **stable public API**.
|
|
2098
2474
|
*
|
|
2099
|
-
*
|
|
2100
|
-
* registry below is exhaustive over exactly the codes it owns and a new
|
|
2101
|
-
* correlation code cannot be added without a message for it.
|
|
2102
|
-
*/
|
|
2103
|
-
type AckCorrelationCode = "MLLP_ACK_UNMATCHED_CONTROL_ID" | "MLLP_ACK_AFTER_TIMEOUT";
|
|
2104
|
-
/**
|
|
2105
|
-
* Look up the frozen diagnostic text for an ACK-correlation code.
|
|
2475
|
+
* Two families, by mode (HL7 v2.5.1 §2.9):
|
|
2106
2476
|
*
|
|
2107
|
-
*
|
|
2108
|
-
*
|
|
2477
|
+
* - **Original mode (§2.9.2):** `AA` accept, `AE` application error, `AR` application
|
|
2478
|
+
* reject. The single ACK reports application-level outcome.
|
|
2479
|
+
* - **Enhanced mode (§2.9.3):** `CA` commit accept, `CE` commit error, `CR` commit
|
|
2480
|
+
* reject, the *accept* acknowledgement, distinct from a later application ACK.
|
|
2109
2481
|
*
|
|
2110
|
-
*
|
|
2111
|
-
*
|
|
2112
|
-
*
|
|
2113
|
-
*
|
|
2482
|
+
* `AE` vs `AR`: `AE` is a processing **error** (the sender may resend later, e.g. a
|
|
2483
|
+
* transient downstream outage); `AR` is a **reject** (the sender should not resend the
|
|
2484
|
+
* message unchanged, e.g. it is structurally unacceptable). `@cosyte/mllp` builds
|
|
2485
|
+
* original-mode ACKs; the `C*` codes are surfaced in the type for completeness and for
|
|
2486
|
+
* callers that build their own enhanced-mode ACKs via `autoAck: fn`.
|
|
2487
|
+
*
|
|
2488
|
+
* @example
|
|
2489
|
+
* ```typescript
|
|
2490
|
+
* import type { AckCode } from '@cosyte/mllp';
|
|
2491
|
+
* const code: AckCode = 'AE';
|
|
2492
|
+
* ```
|
|
2493
|
+
*/
|
|
2494
|
+
type AckCode = "AA" | "AE" | "AR" | "CA" | "CE" | "CR";
|
|
2495
|
+
/**
|
|
2496
|
+
* The negative original-mode acknowledgement codes a failed handler can produce.
|
|
2497
|
+
*
|
|
2498
|
+
* @example
|
|
2499
|
+
* ```typescript
|
|
2500
|
+
* import type { NegativeAckCode } from '@cosyte/mllp';
|
|
2501
|
+
* const code: NegativeAckCode = 'AR';
|
|
2502
|
+
* ```
|
|
2503
|
+
*/
|
|
2504
|
+
type NegativeAckCode = "AE" | "AR";
|
|
2505
|
+
/**
|
|
2506
|
+
* Error a message handler throws to control the negative acknowledgement the server
|
|
2507
|
+
* returns. Throwing **any** error from a commit-gated handler yields `AE` by default;
|
|
2508
|
+
* throw an `MllpAckError` to choose `AR` (application reject) instead.
|
|
2509
|
+
*
|
|
2510
|
+
* The `message` is **never** copied into the ACK bytes or any emitted event, it may
|
|
2511
|
+
* carry PHI. Only the static, non-PHI `ackCode` influences the wire response.
|
|
2512
|
+
*
|
|
2513
|
+
* @example
|
|
2514
|
+
* ```typescript
|
|
2515
|
+
* import { createServer, MllpAckError } from '@cosyte/mllp';
|
|
2516
|
+
*
|
|
2517
|
+
* createServer({
|
|
2518
|
+
* autoAck: 'AA',
|
|
2519
|
+
* onMessage: async (payload) => {
|
|
2520
|
+
* if (!isAcceptable(payload)) {
|
|
2521
|
+
* // sender should NOT resend unchanged -> AR
|
|
2522
|
+
* throw new MllpAckError('unsupported message type', { ackCode: 'AR' });
|
|
2523
|
+
* }
|
|
2524
|
+
* await db.commit(payload); // throw here -> AE (resend may succeed)
|
|
2525
|
+
* },
|
|
2526
|
+
* });
|
|
2527
|
+
* ```
|
|
2528
|
+
*/
|
|
2529
|
+
declare class MllpAckError extends Error {
|
|
2530
|
+
/** The negative acknowledgement code to return (`AE` or `AR`). */
|
|
2531
|
+
readonly ackCode: NegativeAckCode;
|
|
2532
|
+
/**
|
|
2533
|
+
* @param message - Diagnostic text for the thrower. Never placed on the wire (may carry PHI).
|
|
2534
|
+
* @param opts.ackCode - Negative code to return. Default `'AE'`.
|
|
2535
|
+
* @param opts.cause - Underlying error, preserved on `.cause`.
|
|
2536
|
+
*/
|
|
2537
|
+
constructor(message: string, opts?: {
|
|
2538
|
+
ackCode?: NegativeAckCode;
|
|
2539
|
+
cause?: unknown;
|
|
2540
|
+
});
|
|
2541
|
+
}
|
|
2542
|
+
/**
|
|
2543
|
+
* Resolve the negative acknowledgement code for a handler failure.
|
|
2544
|
+
*
|
|
2545
|
+
* An {@link MllpAckError} carries an explicit `ackCode`; any other thrown value maps to
|
|
2546
|
+
* `AE` (application error, the default, since most handler failures are transient and
|
|
2547
|
+
* a resend may succeed).
|
|
2548
|
+
*
|
|
2549
|
+
* @example
|
|
2550
|
+
* ```typescript
|
|
2551
|
+
* import { resolveNackCode, MllpAckError } from '@cosyte/mllp';
|
|
2552
|
+
* resolveNackCode(new Error('boom')); // 'AE'
|
|
2553
|
+
* resolveNackCode(new MllpAckError('nope', { ackCode: 'AR' })); // 'AR'
|
|
2554
|
+
* ```
|
|
2555
|
+
*/
|
|
2556
|
+
declare function resolveNackCode(err: unknown): NegativeAckCode;
|
|
2557
|
+
/**
|
|
2558
|
+
* True iff a **positive** raw acknowledgement (`AA`/`CA`) cannot be safely correlated to
|
|
2559
|
+
* this payload, so {@link buildRawAck} (and the server's auto-ACK path) must **downgrade**
|
|
2560
|
+
* it to a non-positive `AE`/`CE` rather than tell the sender "I have it."
|
|
2561
|
+
*
|
|
2562
|
+
* The rule this enforces: **never answer `AA` for a message you could not correlate.** A
|
|
2563
|
+
* positive ACK is a promise the sender may forget the message; if it names a control ID
|
|
2564
|
+
* the sender cannot match, or names one of several messages it never read, the sender
|
|
2565
|
+
* times out and resends, committing a **duplicate clinical message** (or worse, believes a
|
|
2566
|
+
* destroyed message was delivered). Three payload-shaped reasons make a positive ACK
|
|
2567
|
+
* uncorrelatable, all peer-reachable off the wire:
|
|
2568
|
+
*
|
|
2569
|
+
* 1. **No readable `MSH`.** `readMshSegment` returns `null`, e.g. a `BOM`/`SP`/`TAB`
|
|
2570
|
+
* before `MSH` (which shares the `MSH`'s segment line, so `MSH` heads no segment), or
|
|
2571
|
+
* a bare fragment delivered after a mid-payload `VT` discard (`MLLP_TRAILING_BYTES`).
|
|
2572
|
+
* MSA-2 would be empty: an ACK that correlates to nothing.
|
|
2573
|
+
* 2. **Empty MSH-10.** The `MSH` is readable but carries no message control ID, so there
|
|
2574
|
+
* is nothing to echo, again `MSA|AA|` with an empty MSA-2.
|
|
2575
|
+
* 3. **A batch or concatenated messages.** An `FHS`/`BHS`/`BTS`/`FTS` envelope (§2.10.3)
|
|
2576
|
+
* or a second `MSH` in the same frame: a single MSA-2 can echo only ONE control ID, so
|
|
2577
|
+
* a positive ACK naming the first silently drops the rest (see
|
|
2578
|
+
* {@link containsBatchOrExtraMessage}). Batch ACK is its own feature; until it is
|
|
2579
|
+
* designed, a batch must stay a **loud non-positive** answer.
|
|
2580
|
+
*
|
|
2581
|
+
* This is a **refusal**, not a tolerance widening: it never makes an unreadable message
|
|
2582
|
+
* readable, never re-bases on a located `MSH`, never parses a batch. It only recognizes
|
|
2583
|
+
* the shapes for which a positive disposition would be a lie, so the builder can fall back
|
|
2584
|
+
* to `AE`. A **negative** requested code (`AE`/`AR`/`CE`/`CR`) is unaffected, it is
|
|
2585
|
+
* already non-positive, and echoing whatever control ID it can find is still useful.
|
|
2586
|
+
*
|
|
2587
|
+
* Pure, byte-level, never throws.
|
|
2588
|
+
*
|
|
2589
|
+
* @example
|
|
2590
|
+
* ```typescript
|
|
2591
|
+
* import { rawAckUncorrelatable } from '@cosyte/mllp';
|
|
2592
|
+
* rawAckUncorrelatable(oneGoodMessage); // false → AA is safe
|
|
2593
|
+
* rawAckUncorrelatable(twoConcatenatedMsh); // true → downgrade AA to AE
|
|
2594
|
+
* ```
|
|
2595
|
+
*/
|
|
2596
|
+
declare function rawAckUncorrelatable(payload: Buffer): boolean;
|
|
2597
|
+
/**
|
|
2598
|
+
* Build a minimal original-mode HL7 v2 acknowledgement from raw inbound payload bytes,
|
|
2599
|
+
* **without a parser** (parser-driven ACKs are the `@cosyte/mllp/ack-from-hl7` subpath).
|
|
2600
|
+
*
|
|
2601
|
+
* Locates the `MSH` segment (the first `CR`/`LF`-delimited segment starting with `MSH`,
|
|
2602
|
+
* so a leading `CR` or an `FHS`/`BHS` batch header does not hide it), reads the field
|
|
2603
|
+
* separator from **MSH-1** and the encoding characters from **MSH-2**, then splits that
|
|
2604
|
+
* segment on that separator to read fields. The ACK swaps sender/receiver per HL7 ACK
|
|
2605
|
+
* rules, echoes the inbound MSH-10 into MSA-2 (§2.9.2.2), sets MSA-1 to `code`, and, for
|
|
2606
|
+
* negative codes, adds a **static, PHI-free** MSA-3 reason. A fresh control ID fills the
|
|
2607
|
+
* ACK's own MSH-10.
|
|
2608
|
+
*
|
|
2609
|
+
* ## Why the delimiters are read, not assumed
|
|
2610
|
+
*
|
|
2611
|
+
* MSH-1 **is** the field separator (HL7 v2.5.1 §2.5.4): the byte at offset 3 of the MSH
|
|
2612
|
+
* segment *defines* it for the whole message. `|` is overwhelmingly common but it is a
|
|
2613
|
+
* convention, not the spec. Assuming it was wrong in two compounding ways:
|
|
2614
|
+
*
|
|
2615
|
+
* 1. **Reading.** Splitting a `!`-delimited message on `|` yields ONE field, so every
|
|
2616
|
+
* echoed field, including MSH-10, came back empty. The ACK went out as
|
|
2617
|
+
* `MSA|AA|` with **no correlation id at all**: the sender cannot match it, times
|
|
2618
|
+
* out, and resends → a duplicate clinical message. That is the exact failure this
|
|
2619
|
+
* package's correlator exists to prevent, manufactured by the ACK builder itself.
|
|
2620
|
+
* 2. **Writing.** The echoed MSH-3..6 and MSH-10 field *content* is copied verbatim
|
|
2621
|
+
* from the inbound, still escaped against the **inbound's** encoding characters. Re-
|
|
2622
|
+
* emitting that content under a different delimiter set silently reinterprets it:
|
|
2623
|
+
* an inbound whose component separator is `#` and whose MSH-10 is `ID#X` would be
|
|
2624
|
+
* re-emitted under `^~\&` as the literal `ID#X`, which the sender then reads as a
|
|
2625
|
+
* single component rather than two. Echoing MSH-1 and MSH-2 keeps the content and
|
|
2626
|
+
* the delimiters that define it together.
|
|
2627
|
+
*
|
|
2628
|
+
* ## It reads the MSH through the SHARED scanner
|
|
2629
|
+
*
|
|
2630
|
+
* The MSH read is `readMshSegment` from `src/internal/control-id.ts`, the same call the
|
|
2631
|
+
* client's correlator makes to derive the key it will later match this ACK against. That
|
|
2632
|
+
* is deliberate and it is the whole point: this builder used to re-derive the read itself
|
|
2633
|
+
* (`payload.toString("latin1").split("\r")`, hunting for an `MSH` anywhere in the
|
|
2634
|
+
* payload), and the two disagreed on real inputs. On a **truncated MSH** followed by a
|
|
2635
|
+
* `PID` the correlator keyed on the PID's MRN while this builder echoed an empty MSA-2. On
|
|
2636
|
+
* a payload with a **leading `CR`**, which the MLLP decoder passes straight through, and
|
|
2637
|
+
* which real senders emit, this builder found the `MSH` and echoed MSH-10 correctly while
|
|
2638
|
+
* the correlator, requiring `MSH` at byte 0, gave up. Every such disagreement is an ACK the
|
|
2639
|
+
* sender cannot match → timeout → resend → **duplicate clinical message**.
|
|
2640
|
+
*
|
|
2641
|
+
* The fix is one scan, but note *which* scan. The first attempt made them agree by
|
|
2642
|
+
* requiring `MSH` at byte 0 everywhere, which "resolved" the leading-`CR` disagreement by
|
|
2643
|
+
* degrading the side that had been **right**: `buildRawAck` began emitting a positive `AA`
|
|
2644
|
+
* with an empty MSA-2, silently, for a message whose MSH-10 was plainly present. Agreement
|
|
2645
|
+
* is not the goal; agreeing on the *correct*, *tolerant* answer is. A lenient reader may
|
|
2646
|
+
* never drop data that is there (Postel's Law, CLAUDE.md).
|
|
2647
|
+
*
|
|
2648
|
+
* ## The fail-safe downgrade: never a positive `AA`/`CA` it cannot correlate
|
|
2649
|
+
*
|
|
2650
|
+
* A positive acknowledgement is a promise the sender may forget the message. If it names a
|
|
2651
|
+
* control ID the sender cannot match, or names one message out of several it never read,
|
|
2652
|
+
* the sender times out and resends, committing a **duplicate clinical message**. So a
|
|
2653
|
+
* requested positive code is **downgraded** to its non-positive counterpart (`AA`→`AE`,
|
|
2654
|
+
* `CA`→`CE`) whenever the payload cannot carry a correlatable positive ACK: no readable
|
|
2655
|
+
* `MSH`, an empty MSH-10, or a batch/concatenated-message shape that a single MSA-2 cannot
|
|
2656
|
+
* acknowledge. See {@link rawAckUncorrelatable} for the exact conditions and why each is a
|
|
2657
|
+
* refusal rather than a widened reader. A requested **negative** code (`AE`/`AR`/`CE`/`CR`)
|
|
2658
|
+
* is never touched. This mirrors the parser-backed `buildMllpAck`, which downgrades and
|
|
2659
|
+
* warns on an unparseable inbound, the two builders' fail-safe semantics now agree.
|
|
2660
|
+
*
|
|
2661
|
+
* **Never throws** and **never copies payload content** beyond the routing/control
|
|
2662
|
+
* metadata above, `readMshSegment` stops at the MSH's segment terminator, so no field of
|
|
2663
|
+
* any later segment (PID and friends) can be reached, let alone echoed. On a missing or
|
|
2664
|
+
* unreadable `MSH` it returns a minimal well-formed ACK carrying the (downgraded) `code` so
|
|
2665
|
+
* the caller can still respond.
|
|
2666
|
+
*
|
|
2667
|
+
* @param payload - Raw decoded HL7 v2 payload bytes (MLLP framing already stripped).
|
|
2668
|
+
* @param code - Requested MSA-1 acknowledgement code. A positive `AA`/`CA` is downgraded to
|
|
2669
|
+
* `AE`/`CE` when the message cannot be correlated (see above).
|
|
2670
|
+
* @returns ACK payload bytes (no framing, the caller wraps with `encodeFrame`).
|
|
2671
|
+
*
|
|
2672
|
+
* @example
|
|
2673
|
+
* ```typescript
|
|
2674
|
+
* import { buildRawAck } from '@cosyte/mllp';
|
|
2675
|
+
* const ack = buildRawAck(inboundPayload, 'AE'); // MSA|AE|<inbound MSH-10>|message could not be processed
|
|
2676
|
+
* ```
|
|
2677
|
+
*/
|
|
2678
|
+
declare function buildRawAck(payload: Buffer, code: AckCode): Buffer;
|
|
2679
|
+
|
|
2680
|
+
/**
|
|
2681
|
+
* The frozen diagnostic registry for ACK correlation.
|
|
2682
|
+
*
|
|
2683
|
+
* Every message an ACK-correlation diagnostic can carry lives here as a
|
|
2684
|
+
* literal, and the lookup takes **only a code**. There is no value parameter
|
|
2685
|
+
* anywhere in this module, which is the whole point: a factory that cannot be
|
|
2686
|
+
* handed a value cannot interpolate one, regardless of what arrives on the
|
|
2687
|
+
* wire.
|
|
2688
|
+
*
|
|
2689
|
+
* ## What this replaces, and why the shape matters more than the wording
|
|
2690
|
+
*
|
|
2691
|
+
* The `correlateByControlId` path used to build its diagnostics by
|
|
2692
|
+
* interpolation, and both ends of it were consumer-controlled and unbounded.
|
|
2693
|
+
* The unmatched-ACK error read the **peer's** MSA-2 straight out of an inbound
|
|
2694
|
+
* frame and put it on an `Error.message`; measured against a peer sending a
|
|
2695
|
+
* one-megabyte MSA-2, that produced a 1,000,026-byte `message` and an
|
|
2696
|
+
* equally large field on the frozen error payload. The late-ACK warning did
|
|
2697
|
+
* the same with the control id of the send being reported on. Both go to a
|
|
2698
|
+
* log, and in this domain a log is a place a patient identifier must not
|
|
2699
|
+
* reach. That is not a hypothetical about control ids either: this package has
|
|
2700
|
+
* already shipped one scanner that ran past a segment terminator and returned
|
|
2701
|
+
* PID-3, a patient's medical record number, as the "control ID" of a truncated
|
|
2702
|
+
* MSH. Withholding the bytes is the defence that survives the next such bug.
|
|
2703
|
+
*
|
|
2704
|
+
* ## What a correlation diagnostic says instead
|
|
2705
|
+
*
|
|
2706
|
+
* That a control id did not match, plus **numbers**: a byte length, a stream
|
|
2707
|
+
* offset, an elapsed time. A number is a shape, not content. A truncated id is
|
|
2708
|
+
* not an acceptable middle ground, and neither is a hex or base64 rendering:
|
|
2709
|
+
* both still grow the diagnostic with the input and both still disclose the
|
|
2710
|
+
* bytes. Nothing is lost by withholding them, because the caller already holds
|
|
2711
|
+
* them: the outbound payload is the caller's own argument to `send()`, and the
|
|
2712
|
+
* inbound frame is on the `'message'` event.
|
|
2713
|
+
*
|
|
2714
|
+
* Mirrors the same answer this package already reached for the ACK adapter's
|
|
2715
|
+
* verbatim-echo warning, which reports byte lengths and withholds MSH-10 and
|
|
2716
|
+
* MSA-2 themselves.
|
|
2717
|
+
*
|
|
2718
|
+
* @packageDocumentation
|
|
2719
|
+
*/
|
|
2720
|
+
/**
|
|
2721
|
+
* The two warning codes emitted by ACK correlation rather than by framing.
|
|
2722
|
+
*
|
|
2723
|
+
* A subset of the framing `WarningCode` union; named separately so the
|
|
2724
|
+
* registry below is exhaustive over exactly the codes it owns and a new
|
|
2725
|
+
* correlation code cannot be added without a message for it.
|
|
2726
|
+
*/
|
|
2727
|
+
type AckCorrelationCode = "MLLP_ACK_UNMATCHED_CONTROL_ID" | "MLLP_ACK_AFTER_TIMEOUT";
|
|
2728
|
+
/**
|
|
2729
|
+
* Look up the frozen diagnostic text for an ACK-correlation code.
|
|
2730
|
+
*
|
|
2731
|
+
* A pure table read. It takes no value, so no caller can widen it into an
|
|
2732
|
+
* interpolation site.
|
|
2733
|
+
*
|
|
2734
|
+
* @param code - The correlation code being reported.
|
|
2735
|
+
* @returns The registered message for that code, byte-for-byte.
|
|
2736
|
+
* @example
|
|
2737
|
+
* ```typescript
|
|
2114
2738
|
* const message = ackDiagnosticMessage('MLLP_ACK_AFTER_TIMEOUT');
|
|
2115
2739
|
* ```
|
|
2116
2740
|
*/
|
|
2117
2741
|
declare function ackDiagnosticMessage(code: AckCorrelationCode): string;
|
|
2118
2742
|
|
|
2743
|
+
/**
|
|
2744
|
+
* Parser-free reads of the three acknowledgement-mode fields: MSH-15 and MSH-16 on the way
|
|
2745
|
+
* out, MSA-1 on the way in.
|
|
2746
|
+
*
|
|
2747
|
+
* HL7 v2.5.1 §2.9 splits acknowledgement into two protocols. In the **original** protocol
|
|
2748
|
+
* one message draws one acknowledgement. In the **enhanced** protocol it draws two: an
|
|
2749
|
+
* *accept* acknowledgement carrying `CA`/`CE`/`CR` ("I have committed these bytes to safe
|
|
2750
|
+
* storage; you may stop resending"), and a later *application* acknowledgement carrying
|
|
2751
|
+
* `AA`/`AE`/`AR` ("here is what my application did with it"). A message asks for the
|
|
2752
|
+
* enhanced protocol through MSH-15 and MSH-16, and §2.9 states the equivalence that makes
|
|
2753
|
+
* the two one contract rather than two: the original protocol is the enhanced protocol
|
|
2754
|
+
* with MSH-15 = `NE` and MSH-16 = `AL`.
|
|
2755
|
+
*
|
|
2756
|
+
* This module reads those fields the way the rest of this package reads MSH-10: byte-level,
|
|
2757
|
+
* off the shared MSH scan, never through a parser. **Reading a field is not validating
|
|
2758
|
+
* one.** Nothing here refuses, delays or alters a message because of the value of a field;
|
|
2759
|
+
* an unrecognised value reads as its default and is reported, which is the only behaviour
|
|
2760
|
+
* compatible with a transport that does not own HL7 content.
|
|
2761
|
+
*
|
|
2762
|
+
* Two code systems are involved and neither is interpreted beyond membership:
|
|
2763
|
+
*
|
|
2764
|
+
* * **Table 0008** (MSA-1, acknowledgment code), six codes in two halves:
|
|
2765
|
+
* accept-mode `CA`/`CE`/`CR`, application-mode `AA`/`AE`/`AR`.
|
|
2766
|
+
* * **Table 0155** (MSH-15, MSH-16, accept/application acknowledgment conditions), four
|
|
2767
|
+
* codes: `AL` always, `NE` never, `ER` error or reject conditions only, `SU`
|
|
2768
|
+
* successful completion only.
|
|
2769
|
+
*
|
|
2770
|
+
* **INTERNAL**, not part of the public API.
|
|
2771
|
+
*
|
|
2772
|
+
* @packageDocumentation
|
|
2773
|
+
*/
|
|
2774
|
+
|
|
2775
|
+
/**
|
|
2776
|
+
* Per-send report that the peer has **committed** a message but has not yet said what its
|
|
2777
|
+
* application did with it, handed to the `onCommitAck` callback of the `send()` that drew
|
|
2778
|
+
* it.
|
|
2779
|
+
*
|
|
2780
|
+
* It is scoped to one caller's own send, and it arrives at a point where that send has not
|
|
2781
|
+
* settled: `send()` is still pending and will resolve on the application acknowledgement,
|
|
2782
|
+
* or reject if none arrives. That scoping is the point. The commit disposition belongs to
|
|
2783
|
+
* one send, and a caller learns which send it belongs to by being the one that made it,
|
|
2784
|
+
* never by anything being logged.
|
|
2785
|
+
*
|
|
2786
|
+
* @example
|
|
2787
|
+
* ```typescript
|
|
2788
|
+
* const ack = await client.send(payload, {
|
|
2789
|
+
* onCommitAck: ({ code }) => logger.info({ commit: code }), // 'CA': the peer has custody
|
|
2790
|
+
* });
|
|
2791
|
+
* ```
|
|
2792
|
+
*/
|
|
2793
|
+
interface CommitAckReport {
|
|
2794
|
+
/** The accept-mode Table 0008 code received. Always the positive `CA`. */
|
|
2795
|
+
readonly code: "CA";
|
|
2796
|
+
/** The accept acknowledgement's own payload bytes, framing stripped. */
|
|
2797
|
+
readonly payload: Buffer;
|
|
2798
|
+
/** Milliseconds between the send's write-flush and this acknowledgement. */
|
|
2799
|
+
readonly latencyMs: number;
|
|
2800
|
+
}
|
|
2801
|
+
|
|
2119
2802
|
/**
|
|
2120
2803
|
* MLLP Client, `createClient()` factory and `MllpClient` class.
|
|
2121
2804
|
*
|
|
@@ -2171,6 +2854,52 @@ interface AckCorrelationWarning extends MllpWarning {
|
|
|
2171
2854
|
*/
|
|
2172
2855
|
readonly elapsedSinceSendMs: number;
|
|
2173
2856
|
}
|
|
2857
|
+
/**
|
|
2858
|
+
* The `'warning'` payload the client emits for an **acknowledgement-mode** deviation: an
|
|
2859
|
+
* enhanced-mode send on a client that cannot correlate two acknowledgements, a Table 0155
|
|
2860
|
+
* value it did not recognise, an acknowledgement it could not classify into a mode, and a
|
|
2861
|
+
* further acknowledgement for a send that is already settled or failed.
|
|
2862
|
+
*
|
|
2863
|
+
* It arrives on the same `'warning'` event as a framing warning and an
|
|
2864
|
+
* {@link AckCorrelationWarning}, so narrow on `code` before reading its fields. It is
|
|
2865
|
+
* **not** an `MllpWarning`: its `code` is an {@link AckModeCode}, deliberately a family of
|
|
2866
|
+
* its own rather than an addition to the decoder's registry, and it is not counted in
|
|
2867
|
+
* `getStats().warningsByCode`, whose keys are the decoder's codes.
|
|
2868
|
+
*
|
|
2869
|
+
* Everything input-derived on it is a number or a member of the closed six-code Table 0008
|
|
2870
|
+
* set. `message` is a **frozen registry entry**, byte-for-byte identical for a given code
|
|
2871
|
+
* no matter what arrived on the wire. A warning is a log line, so it carries no field
|
|
2872
|
+
* content: an MSA-1 nobody could classify is reported by its byte length alone.
|
|
2873
|
+
*
|
|
2874
|
+
* @example
|
|
2875
|
+
* ```typescript
|
|
2876
|
+
* client.on('warning', (w: AckModeWarning) => {
|
|
2877
|
+
* if (w.code === 'MLLP_ACK_MSA1_UNCLASSIFIABLE') {
|
|
2878
|
+
* logger.warn({ code: w.code, msa1Bytes: w.msa1Bytes, at: w.byteOffset });
|
|
2879
|
+
* }
|
|
2880
|
+
* });
|
|
2881
|
+
* ```
|
|
2882
|
+
*/
|
|
2883
|
+
interface AckModeWarning {
|
|
2884
|
+
/** The acknowledgement-mode code this warning reports. */
|
|
2885
|
+
readonly code: AckModeCode;
|
|
2886
|
+
/** Frozen registry text for `code`. Never interpolated. */
|
|
2887
|
+
readonly message: string;
|
|
2888
|
+
/** Inbound frame's stream byte offset; `0` for a warning raised on an outbound send. */
|
|
2889
|
+
readonly byteOffset: number;
|
|
2890
|
+
/** Byte length of the MSA-1 field involved, or `null` when the code reports none. */
|
|
2891
|
+
readonly msa1Bytes: number | null;
|
|
2892
|
+
/** Table 0008 code involved, or `null` when the code reports none. */
|
|
2893
|
+
readonly ackCode: "AA" | "AE" | "AR" | "CA" | "CE" | "CR" | null;
|
|
2894
|
+
/** Byte length of the control ID involved, or `null` when there was none to read. */
|
|
2895
|
+
readonly controlIdBytes: number | null;
|
|
2896
|
+
/** Milliseconds since the send's write-flush, or since its disposal for a late ACK. */
|
|
2897
|
+
readonly elapsedSinceSendMs: number;
|
|
2898
|
+
/** Connection identifier, `undefined` before a connection is attached. */
|
|
2899
|
+
readonly connectionId: string | undefined;
|
|
2900
|
+
/** Wall-clock time at point of emission. */
|
|
2901
|
+
readonly timestamp: Date;
|
|
2902
|
+
}
|
|
2174
2903
|
/**
|
|
2175
2904
|
* Context passed to a custom `retryStrategy` hook on each reconnect attempt.
|
|
2176
2905
|
*
|
|
@@ -2244,7 +2973,11 @@ interface ClientOptions {
|
|
|
2244
2973
|
readonly port: number;
|
|
2245
2974
|
/** FrameReader tolerance / size options. `onFrame` and `onWarning` are managed internally. */
|
|
2246
2975
|
readonly framing?: Omit<FrameReaderOptions, "onFrame" | "onWarning">;
|
|
2247
|
-
/**
|
|
2976
|
+
/**
|
|
2977
|
+
* Bound on the acknowledgement wait {@link MllpClient.close} performs (default: `30_000`
|
|
2978
|
+
* ms). Sends already written to the transport are awaited until it elapses; the wait ends
|
|
2979
|
+
* early the moment the last of them is answered.
|
|
2980
|
+
*/
|
|
2248
2981
|
readonly drainTimeoutMs?: number;
|
|
2249
2982
|
/**
|
|
2250
2983
|
* Per-message ACK timeout in milliseconds. The clock starts at
|
|
@@ -2252,6 +2985,20 @@ interface ClientOptions {
|
|
|
2252
2985
|
* pre-flush queue time is not charged to the peer. Default: `30_000`.
|
|
2253
2986
|
*/
|
|
2254
2987
|
readonly ackTimeoutMs?: number;
|
|
2988
|
+
/**
|
|
2989
|
+
* Bound, in milliseconds, on the **second** wait an enhanced-mode send can enter: the
|
|
2990
|
+
* one that starts when an accept acknowledgement (`CA`) reports that the peer has
|
|
2991
|
+
* committed the message, and ends when the application acknowledgement arrives.
|
|
2992
|
+
*
|
|
2993
|
+
* Measured from the moment that accept acknowledgement was received, not from the send,
|
|
2994
|
+
* so a peer that commits at 9 s and applies at 12 s settles the send successfully at
|
|
2995
|
+
* 12 s. Defaults to whatever `ackTimeoutMs` is in force for the send, and can be
|
|
2996
|
+
* overridden per send. It only ever applies to a send whose MSH-16 asks for an
|
|
2997
|
+
* application acknowledgement, so it changes nothing for an original-mode interface.
|
|
2998
|
+
*
|
|
2999
|
+
* @default the send's own `ackTimeoutMs`
|
|
3000
|
+
*/
|
|
3001
|
+
readonly applicationAckTimeoutMs?: number;
|
|
2255
3002
|
/**
|
|
2256
3003
|
* If `true`, ACKs are matched against outgoing sends by MSH-10 → MSA-2.
|
|
2257
3004
|
* Default `false` (FIFO mode).
|
|
@@ -2430,6 +3177,9 @@ interface ClientStats {
|
|
|
2430
3177
|
* - `'securityWarning'`, `SecurityWarning`. Emitted on every successful
|
|
2431
3178
|
* `secureConnect` (initial + every reconnect) when `tls.allowUnverified` is `true`
|
|
2432
3179
|
* with code `MLLP_TLS_VERIFY_DISABLED`. Also mirrored to `process.emitWarning`.
|
|
3180
|
+
* - `'tlsNegotiated'`, {@link NegotiatedTlsParameters}. Emitted once per completed TLS
|
|
3181
|
+
* handshake (initial + every reconnect) with the negotiated protocol version and
|
|
3182
|
+
* cipher suite. Never emitted on a plaintext connection.
|
|
2433
3183
|
* - `'error'`, re-emitted from Connection. Guarded by `listenerCount('error') > 0` so
|
|
2434
3184
|
* absence of a listener does NOT crash the process (server precedent).
|
|
2435
3185
|
*
|
|
@@ -2454,6 +3204,11 @@ declare class MllpClient extends EventEmitter {
|
|
|
2454
3204
|
private _state;
|
|
2455
3205
|
/** Per-message ACK timeout in ms. Resolved at construction. */
|
|
2456
3206
|
private readonly _ackTimeoutMs;
|
|
3207
|
+
/**
|
|
3208
|
+
* Default bound on the second (application-acknowledgement) wait, in ms.
|
|
3209
|
+
* `undefined` means "whatever ACK timeout is in force for that send".
|
|
3210
|
+
*/
|
|
3211
|
+
private readonly _applicationAckTimeoutMs;
|
|
2457
3212
|
/** controlId-mode flag. `false` → FIFO. */
|
|
2458
3213
|
private readonly _correlateByControlId;
|
|
2459
3214
|
/** Unified ACK correlator. Built during `_attachConnection`. */
|
|
@@ -2502,6 +3257,26 @@ declare class MllpClient extends EventEmitter {
|
|
|
2502
3257
|
private _connectSignal;
|
|
2503
3258
|
/** Set when close()/destroy()/abort fires; reconnect handler short-circuits. */
|
|
2504
3259
|
private _userClosed;
|
|
3260
|
+
/**
|
|
3261
|
+
* Sends parked inside the client with nothing written for them. See {@link ParkedSend}.
|
|
3262
|
+
* Every entry removes itself the moment its own wait ends, however it ends.
|
|
3263
|
+
*/
|
|
3264
|
+
private readonly _parkedSends;
|
|
3265
|
+
/**
|
|
3266
|
+
* Re-checks whether the shutdown drain is over, `null` when no `close()` is waiting.
|
|
3267
|
+
* Called from every point at which a pending send is settled.
|
|
3268
|
+
*/
|
|
3269
|
+
private _drainWaiter;
|
|
3270
|
+
/**
|
|
3271
|
+
* Ends the shutdown drain wait now, whatever is still outstanding. `null` when no `close()`
|
|
3272
|
+
* is waiting. `destroy()` calls it, which is what makes a destroy during a drain prompt.
|
|
3273
|
+
*/
|
|
3274
|
+
private _endDrainWait;
|
|
3275
|
+
/**
|
|
3276
|
+
* The `beforeClose` hook this client installed on its Connection, so a second `close()` call
|
|
3277
|
+
* composes nothing twice. Compared by identity, never by shape.
|
|
3278
|
+
*/
|
|
3279
|
+
private _drainHook;
|
|
2505
3280
|
/** Captured Connection error feeding `RetryContext.lastError`. */
|
|
2506
3281
|
private _lastError;
|
|
2507
3282
|
/** Listener-removal handle for the abort listener bound in connect(). */
|
|
@@ -2556,6 +3331,10 @@ declare class MllpClient extends EventEmitter {
|
|
|
2556
3331
|
* TLS failures carry a `connectionCause`: `'tls-verify'` for certificate
|
|
2557
3332
|
* verification failures, `'tls-handshake'` for TLS-protocol-shaped failures
|
|
2558
3333
|
* ({@link isTlsProtocolError}); pure TCP failures carry none.
|
|
3334
|
+
* - `MllpTlsConfigurationError` if the configured cipher-suite list cannot be
|
|
3335
|
+
* honoured (the runtime rejects it, or `atnaTransportSecurity` and `ciphers`
|
|
3336
|
+
* both declare one). Raised **before** a socket is opened, so nothing is left
|
|
3337
|
+
* connected; identify it by `instanceof` plus its stable `code`.
|
|
2559
3338
|
*
|
|
2560
3339
|
* **Dual failure signal on initial connect:** the Connection's transport
|
|
2561
3340
|
* error handler is attached before this promise's own error listener, so a
|
|
@@ -2619,6 +3398,30 @@ declare class MllpClient extends EventEmitter {
|
|
|
2619
3398
|
* See `src/internal/safe-emit.ts`. The throw is re-surfaced on `'error'`.
|
|
2620
3399
|
*/
|
|
2621
3400
|
private _emitContained;
|
|
3401
|
+
/**
|
|
3402
|
+
* Emit a frozen {@link AckModeWarning} on the `'warning'` event.
|
|
3403
|
+
*
|
|
3404
|
+
* The message is a **registry lookup**, not an interpolation, and everything
|
|
3405
|
+
* input-derived on the payload is a number or a closed-set code. This holds in every
|
|
3406
|
+
* mode: an original-mode send emits nothing new, but when it does reach one of these
|
|
3407
|
+
* codes the same discipline applies.
|
|
3408
|
+
*/
|
|
3409
|
+
private _emitAckModeWarning;
|
|
3410
|
+
/**
|
|
3411
|
+
* Build the typed error for a send that was left waiting on its application
|
|
3412
|
+
* acknowledgement: the peer committed the message and then either fell silent past the
|
|
3413
|
+
* second window or lost the link.
|
|
3414
|
+
*/
|
|
3415
|
+
private _applicationAckError;
|
|
3416
|
+
/**
|
|
3417
|
+
* Fail every pending send that is waiting on its application acknowledgement, with an
|
|
3418
|
+
* error naming the commit disposition already received. Used on the paths where the link
|
|
3419
|
+
* goes away: such a send must not be held for a resend (the peer already has the
|
|
3420
|
+
* message), nor left pending, nor reported as successful.
|
|
3421
|
+
*
|
|
3422
|
+
* Returns the entries it removed. Sends without a reported commit are untouched.
|
|
3423
|
+
*/
|
|
3424
|
+
private _failCommitPendingSends;
|
|
2622
3425
|
/**
|
|
2623
3426
|
* Emit the per-connection insecure-TLS warning when
|
|
2624
3427
|
* `tls.allowUnverified === true`, fires on EVERY successful
|
|
@@ -2627,6 +3430,17 @@ declare class MllpClient extends EventEmitter {
|
|
|
2627
3430
|
* connections or when verification is on.
|
|
2628
3431
|
*/
|
|
2629
3432
|
private _emitInsecureWarningIfNeeded;
|
|
3433
|
+
/**
|
|
3434
|
+
* Emit the frozen negotiated-parameters record for a completed TLS
|
|
3435
|
+
* handshake, once per connection, initial connect and every reconnect
|
|
3436
|
+
* alike. No-op for a plaintext connection: there is nothing negotiated to
|
|
3437
|
+
* report, so no event is emitted at all.
|
|
3438
|
+
*
|
|
3439
|
+
* The payload is read off the TLS session, so it is a pair of registry names
|
|
3440
|
+
* plus this client's own host and port. It never sees a payload byte: it is
|
|
3441
|
+
* built at handshake-completion time, before any HL7 byte crosses the link.
|
|
3442
|
+
*/
|
|
3443
|
+
private _emitNegotiatedTlsParameters;
|
|
2630
3444
|
/**
|
|
2631
3445
|
* Wire a Connection's events through to this MllpClient. Every re-emitted
|
|
2632
3446
|
* payload is `Object.freeze`'d before emission, even though the
|
|
@@ -2761,6 +3575,21 @@ declare class MllpClient extends EventEmitter {
|
|
|
2761
3575
|
*
|
|
2762
3576
|
* Emits a frozen `'ack'` event on every successful match.
|
|
2763
3577
|
*
|
|
3578
|
+
* ## Enhanced acknowledgement mode
|
|
3579
|
+
*
|
|
3580
|
+
* A message whose MSH-15 or MSH-16 is not null asks for the two-part protocol of HL7
|
|
3581
|
+
* v2.5.1 §2.9, and on a client correlating by control ID this method delivers it: the
|
|
3582
|
+
* accept acknowledgement (`CA`) is reported through `onCommitAck` **without** settling
|
|
3583
|
+
* the send, and the later application acknowledgement (`AA`/`AE`/`AR`) is what resolves
|
|
3584
|
+
* it. All three application-mode codes resolve, because which of them the receiving
|
|
3585
|
+
* application chose is its clinical verdict and not this transport's to judge; read MSA-1
|
|
3586
|
+
* off the acknowledgement you are handed. A negative commit (`CE`/`CR`) rejects with
|
|
3587
|
+
* {@link MllpCommitRejectedError} at once, since no application acknowledgement follows a
|
|
3588
|
+
* refusal to take custody.
|
|
3589
|
+
*
|
|
3590
|
+
* A message with both fields empty is an original-mode send and behaves exactly as it
|
|
3591
|
+
* always has, one acknowledgement, settled by the first match.
|
|
3592
|
+
*
|
|
2764
3593
|
* @example
|
|
2765
3594
|
* ```typescript
|
|
2766
3595
|
* const ack = await client.send(payloadBuffer);
|
|
@@ -2769,27 +3598,160 @@ declare class MllpClient extends EventEmitter {
|
|
|
2769
3598
|
*
|
|
2770
3599
|
* @param payload Raw bytes; MLLP framing is added internally via `encodeFrame`.
|
|
2771
3600
|
* @param opts.signal AbortSignal, aborting cancels the ACK wait.
|
|
3601
|
+
* @param opts.ackTimeoutMs Per-send override of the backpressure wait budget.
|
|
3602
|
+
* @param opts.applicationAckTimeoutMs Per-send override of the second wait's bound.
|
|
3603
|
+
* @param opts.onCommitAck Called with the commit disposition when the peer commits the
|
|
3604
|
+
* message ahead of its application acknowledgement. See {@link CommitAckReport}.
|
|
2772
3605
|
*/
|
|
2773
3606
|
send(payload: Buffer, opts?: {
|
|
2774
3607
|
signal?: AbortSignal;
|
|
2775
3608
|
ackTimeoutMs?: number;
|
|
3609
|
+
applicationAckTimeoutMs?: number;
|
|
3610
|
+
onCommitAck?: (report: CommitAckReport) => void;
|
|
2776
3611
|
}): Promise<Buffer>;
|
|
3612
|
+
/**
|
|
3613
|
+
* Decide what a send's own MSH-15 and MSH-16 buy it, and report anything worth reporting
|
|
3614
|
+
* about them. Runs before the message reaches the transport.
|
|
3615
|
+
*
|
|
3616
|
+
* Three answers:
|
|
3617
|
+
*
|
|
3618
|
+
* * an **original-mode** message (both fields null, or a header this package cannot
|
|
3619
|
+
* scan) gets `null`, which is the state every send had before enhanced mode existed;
|
|
3620
|
+
* * an **enhanced-mode** message on a client that correlates in order rather than by
|
|
3621
|
+
* control ID also gets `null`, plus a warning saying so. MSA-2 is the only thing that
|
|
3622
|
+
* can attribute a second acknowledgement to a send, so without control-ID correlation
|
|
3623
|
+
* a two-acknowledgement conversation cannot be delivered. The alternative, leaving
|
|
3624
|
+
* such a send pending on an acknowledgement this client would otherwise have settled
|
|
3625
|
+
* it with, would break a working interface to signal a configuration gap;
|
|
3626
|
+
* * an **enhanced-mode** message on a control-ID client gets its two-phase state.
|
|
3627
|
+
*
|
|
3628
|
+
* An unrecognised Table 0155 value is warned and defaulted, never fatal: this is a
|
|
3629
|
+
* transport, and refusing to transmit a clinical message over a vendor value in MSH-15
|
|
3630
|
+
* would be HL7 content validation with the worst failure mode available. The message goes
|
|
3631
|
+
* out unaltered either way.
|
|
3632
|
+
*/
|
|
3633
|
+
private _twoPhaseStateFor;
|
|
2777
3634
|
/**
|
|
2778
3635
|
* 'wait'-mode backpressure handler.
|
|
2779
3636
|
*
|
|
2780
|
-
* Awaits one of
|
|
3637
|
+
* Awaits one of four terminating signals, in order:
|
|
2781
3638
|
* - `'drain'` event → re-enter `send()` (the gate will now pass).
|
|
2782
3639
|
* - `ackTimeoutMs` elapses → reject with `MllpTimeoutError`.
|
|
2783
3640
|
* - Caller's `signal` aborts → reject with `AbortError`. Cleanup
|
|
2784
3641
|
* removes the drain listener AND the abort listener AND clears the
|
|
2785
3642
|
* timer to prevent leaks.
|
|
3643
|
+
* - the client shuts down → reject with `MllpNeverDeliveredError`, because a send waiting
|
|
3644
|
+
* here has written nothing and the peer cannot have seen it. Registering the wait as a
|
|
3645
|
+
* {@link ParkedSend} is what makes that reachable at all.
|
|
3646
|
+
*
|
|
3647
|
+
* @param held - What this send would have written, for the never-delivered report. Counts
|
|
3648
|
+
* only: the caller still holds the payload it passed to `send()`.
|
|
2786
3649
|
*/
|
|
2787
3650
|
private _waitThenSend;
|
|
2788
3651
|
/**
|
|
2789
3652
|
* Tear down per-connection state (sweep timer + correlator) when the
|
|
2790
3653
|
* client is closing or destroying. Safe to call multiple times.
|
|
3654
|
+
*
|
|
3655
|
+
* A send that is waiting on its application acknowledgement is failed with an error
|
|
3656
|
+
* naming the commit disposition it did receive, rather than with the generic
|
|
3657
|
+
* connection error every other pending send gets. The two are genuinely different
|
|
3658
|
+
* outcomes: one send may never have reached the peer, the other is known to be in its
|
|
3659
|
+
* custody with only its application disposition unknown. That branch runs FIRST and is not
|
|
3660
|
+
* overridable by the caller, because the peer's custody of those bytes is a known fact and
|
|
3661
|
+
* no shutdown report may downgrade it to an unknown one.
|
|
3662
|
+
*
|
|
3663
|
+
* @param reason - Error for every entry the caller offers no better answer for.
|
|
3664
|
+
* @param unresolvedFor - Per-entry error for an entry whose commit disposition has NOT
|
|
3665
|
+
* arrived. This is where the shutdown path splits the two populations; omitted, every such
|
|
3666
|
+
* entry gets `reason`.
|
|
2791
3667
|
*/
|
|
2792
3668
|
private _teardownCorrelator;
|
|
3669
|
+
/**
|
|
3670
|
+
* Number of pending sends whose bytes ARE on the transport and whose acknowledgement has not
|
|
3671
|
+
* arrived. This is what a shutdown drain waits for, and nothing else: a send still held
|
|
3672
|
+
* inside the client cannot be acknowledged, so holding the drain open for one would spend a
|
|
3673
|
+
* clinical shutdown's timeout waiting for an answer that cannot come.
|
|
3674
|
+
*/
|
|
3675
|
+
private _outstandingAckCount;
|
|
3676
|
+
/**
|
|
3677
|
+
* The report a still-pending send gets when the client has finished shutting down, for the
|
|
3678
|
+
* one population `_teardownCorrelator` leaves to this method: entries whose commit
|
|
3679
|
+
* disposition never arrived.
|
|
3680
|
+
*
|
|
3681
|
+
* The split is the whole point of the shutdown contract. `sentAt` is stamped at the
|
|
3682
|
+
* transport write and is `null` until then, so it says exactly which side of the wire a
|
|
3683
|
+
* message was on when the client stopped:
|
|
3684
|
+
*
|
|
3685
|
+
* * written, unanswered: {@link MllpUnknownFateError}, ambiguous, and reported as
|
|
3686
|
+
* ambiguous, carrying the flush timestamp a replay decision reasons about;
|
|
3687
|
+
* * never written: {@link MllpNeverDeliveredError}, and a resend of one of those cannot
|
|
3688
|
+
* duplicate anything.
|
|
3689
|
+
*
|
|
3690
|
+
* **The unflushed branch is a structural guard, and today no caller can reach it.** `send()`
|
|
3691
|
+
* writes and stamps in the same synchronous run, so an entry that is in the live store is an
|
|
3692
|
+
* entry that went out, and the never-written population arrives instead as a
|
|
3693
|
+
* {@link ParkedSend}, which never enters the store at all. The branch stays because the
|
|
3694
|
+
* store MODELS the state, `sentAt` being nullable is its own claim about it, and the
|
|
3695
|
+
* reconnect path already splits the same two ways on the same field. Deleting it would make
|
|
3696
|
+
* this report say the opposite of the truth the day anything does produce one, which on this
|
|
3697
|
+
* surface means telling a consumer to treat an unwritten message as possibly committed.
|
|
3698
|
+
*/
|
|
3699
|
+
private _shutdownErrorFor;
|
|
3700
|
+
/**
|
|
3701
|
+
* Has this client begun shutting down? A send parked inside it must not be resumed once it
|
|
3702
|
+
* has: nothing was ever written for such a send, so its report is that delivery did not
|
|
3703
|
+
* occur, and re-entering `send()` would trade that report for the generic not-connected one.
|
|
3704
|
+
*
|
|
3705
|
+
* True from the moment `close()` or `destroy()` is called, true on every halt that has
|
|
3706
|
+
* already given up on the connection (a permanent connect failure, an exhausted retry
|
|
3707
|
+
* policy, an aborted connect), and true while the underlying connection is draining, which
|
|
3708
|
+
* is how a connection closed from outside this client is caught. Each of those is a state a
|
|
3709
|
+
* fresh `send()` cannot succeed from.
|
|
3710
|
+
*
|
|
3711
|
+
* Read on the `'drain'` listener of every {@link ParkedSend}, because a shutdown drain ends
|
|
3712
|
+
* when the last outstanding acknowledgement (or timeout, or abort) settles, and that same
|
|
3713
|
+
* settlement emits `'drain'`. A park woken by it unregisters itself, so the settle pass that
|
|
3714
|
+
* runs after the drain no longer sees it: the choice has to be made here or not at all.
|
|
3715
|
+
*/
|
|
3716
|
+
private _shutdownBegun;
|
|
3717
|
+
/**
|
|
3718
|
+
* Fail every send parked inside the client. See {@link ParkedSend}.
|
|
3719
|
+
*
|
|
3720
|
+
* Iterates a copy and clears the register first, because each `cancel()` removes its own
|
|
3721
|
+
* entry as it unwinds.
|
|
3722
|
+
*/
|
|
3723
|
+
private _cancelParkedSends;
|
|
3724
|
+
/**
|
|
3725
|
+
* Settle everything still unresolved once the shutdown drain is over, under the
|
|
3726
|
+
* two-population rule. Runs on every exit from `close()`, including the aborted one, so no
|
|
3727
|
+
* send's promise is left pending by a shutdown.
|
|
3728
|
+
*/
|
|
3729
|
+
private _settleUnresolvedSends;
|
|
3730
|
+
/**
|
|
3731
|
+
* Install this client's acknowledgement drain on a Connection's `beforeClose` seam, which is
|
|
3732
|
+
* what gives `drainTimeoutMs` a meaning on the client at all.
|
|
3733
|
+
*
|
|
3734
|
+
* Whatever hook is already there is **composed with**, not replaced: the seam is a public
|
|
3735
|
+
* instance property an owner may legitimately have assigned, and a shutdown that silently
|
|
3736
|
+
* dropped that work would be a worse defect than the one this fixes. Installing is
|
|
3737
|
+
* idempotent by hook identity, so a second `close()` composes nothing twice.
|
|
3738
|
+
*/
|
|
3739
|
+
private _installDrainHook;
|
|
3740
|
+
/**
|
|
3741
|
+
* Resolve once there is nothing left to wait for. Three ways that happens:
|
|
3742
|
+
*
|
|
3743
|
+
* * the last outstanding acknowledgement lands (or its send is otherwise settled), which
|
|
3744
|
+
* is what lets `close()` return in a fraction of the drain timeout;
|
|
3745
|
+
* * the connection leaves `DRAINING`, i.e. the link failed, the peer went away or the
|
|
3746
|
+
* client was destroyed. Nothing further can be acknowledged over a link that is gone, so
|
|
3747
|
+
* waiting out the rest of the timeout would buy nothing and hang the shutdown. That one
|
|
3748
|
+
* arrives through `_onStateChange`, the single delegating listener, rather than through a
|
|
3749
|
+
* second `'stateChange'` registration of its own;
|
|
3750
|
+
* * never, in which case `Connection.close` times the whole thing out at `drainTimeoutMs`
|
|
3751
|
+
* and the still-unanswered sends are reported with their fate unknown. The bound lives
|
|
3752
|
+
* there, in the drain race, rather than being duplicated here.
|
|
3753
|
+
*/
|
|
3754
|
+
private _awaitOutstandingAcks;
|
|
2793
3755
|
/**
|
|
2794
3756
|
* **Test seam**, attach an externally-built {@link Connection} directly,
|
|
2795
3757
|
* bypassing the `net.createConnection` + `NetTransport` path. Used by
|
|
@@ -2799,14 +3761,35 @@ declare class MllpClient extends EventEmitter {
|
|
|
2799
3761
|
*/
|
|
2800
3762
|
_attachExistingConnection(conn: Connection): void;
|
|
2801
3763
|
/**
|
|
2802
|
-
* Gracefully close the client
|
|
3764
|
+
* Gracefully close the client, awaiting the acknowledgements of sends already written to
|
|
3765
|
+
* the transport.
|
|
2803
3766
|
*
|
|
2804
3767
|
* Delegates to {@link Connection.close}, which transitions `CONNECTED → DRAINING
|
|
2805
3768
|
* → DISCONNECTED` (or `CLOSED` on drain timeout). No-op if no Connection is
|
|
2806
|
-
* attached.
|
|
3769
|
+
* attached, or if the close has already completed.
|
|
3770
|
+
*
|
|
3771
|
+
* The drain ends as soon as the last outstanding acknowledgement arrives, so a caller whose
|
|
3772
|
+
* peer answers promptly does not wait out `drainTimeoutMs`. Whatever is still unresolved
|
|
3773
|
+
* when it does end is reported to its own `send()` caller under one rule, and the whole
|
|
3774
|
+
* point of this method is that the two halves of that rule are told apart:
|
|
3775
|
+
*
|
|
3776
|
+
* * a send already written to the transport rejects with {@link MllpUnknownFateError},
|
|
3777
|
+
* carrying the timestamp at which its bytes went out. It may have been committed;
|
|
3778
|
+
* resending it may duplicate a clinical message. Nothing is retried here.
|
|
3779
|
+
* * a send still held inside the client rejects with {@link MllpNeverDeliveredError}.
|
|
3780
|
+
* The peer never saw those bytes, so resending them is safe.
|
|
3781
|
+
* * a send whose commit disposition the peer had already reported keeps its
|
|
3782
|
+
* {@link MllpApplicationAckError}, which names that disposition. The peer's custody of
|
|
3783
|
+
* those bytes is known, and a shutdown never downgrades a known fact to an unknown one.
|
|
2807
3784
|
*
|
|
2808
3785
|
* Rejects with `DOMException('Aborted', 'AbortError')` if `signal` aborts mid-drain;
|
|
2809
|
-
* on abort, the underlying Connection is force-destroyed
|
|
3786
|
+
* on abort, the underlying Connection is force-destroyed and every still-pending send is
|
|
3787
|
+
* settled under the same rule rather than left pending.
|
|
3788
|
+
*
|
|
3789
|
+
* A drain cannot make delivery certain, and nothing here claims otherwise: an
|
|
3790
|
+
* acknowledgement lost in flight is indistinguishable from a message never received. That is
|
|
3791
|
+
* what the unknown-fate report says, and it is why the application still owns idempotency,
|
|
3792
|
+
* keyed on MSH-10 plus MSH-7.
|
|
2810
3793
|
*
|
|
2811
3794
|
* @example
|
|
2812
3795
|
* ```typescript
|
|
@@ -2821,6 +3804,13 @@ declare class MllpClient extends EventEmitter {
|
|
|
2821
3804
|
* Abruptly destroy the client, force-transitions the underlying Connection
|
|
2822
3805
|
* to `CLOSED` immediately. No-op if no Connection is attached. Idempotent.
|
|
2823
3806
|
*
|
|
3807
|
+
* Every pending send is settled at once: no acknowledgement is awaited and no drain timeout
|
|
3808
|
+
* is honoured, which is exactly what separates this from {@link MllpClient.close}. A
|
|
3809
|
+
* `close()` already waiting on acknowledgements stops waiting here and returns. A send still
|
|
3810
|
+
* held inside the client is failed with {@link MllpNeverDeliveredError}, because nothing was
|
|
3811
|
+
* ever written for it; that is a statement about the bytes, not about how the client was
|
|
3812
|
+
* shut down, so it holds on this path too.
|
|
3813
|
+
*
|
|
2824
3814
|
* @example
|
|
2825
3815
|
* ```typescript
|
|
2826
3816
|
* client.destroy(new Error('shutting down'));
|
|
@@ -2962,6 +3952,8 @@ declare function createStarterClient(opts: StarterClientOptions): Promise<MllpCl
|
|
|
2962
3952
|
* Exports:
|
|
2963
3953
|
* - `MllpTimeoutError`, ACK timeout
|
|
2964
3954
|
* - `MllpBackpressureError`, high-water mark exceeded
|
|
3955
|
+
* - `MllpNeverDeliveredError`, a send that never reached the transport
|
|
3956
|
+
* - `MllpUnknownFateError`, a send whose bytes went out and drew no answer
|
|
2965
3957
|
* - `isTransientConnectionError`, transient/permanent classifier
|
|
2966
3958
|
*
|
|
2967
3959
|
* Re-exported from `src/client/index.ts` and `src/index.ts`.
|
|
@@ -3002,20 +3994,249 @@ declare class MllpTimeoutError extends Error {
|
|
|
3002
3994
|
* `latin1`, a 1:1 byte to code-unit map, so this count is a byte count.
|
|
3003
3995
|
*/
|
|
3004
3996
|
readonly messageControlIdBytes: number | undefined;
|
|
3005
|
-
/** Milliseconds elapsed between write-flush and timeout fire. */
|
|
3006
|
-
readonly elapsedMs: number;
|
|
3007
|
-
/** Epoch ms timestamp recorded at write-flush callback. */
|
|
3008
|
-
readonly sentAt: number;
|
|
3997
|
+
/** Milliseconds elapsed between write-flush and timeout fire. */
|
|
3998
|
+
readonly elapsedMs: number;
|
|
3999
|
+
/** Epoch ms timestamp recorded at write-flush callback. */
|
|
4000
|
+
readonly sentAt: number;
|
|
4001
|
+
/**
|
|
4002
|
+
* Construct an MLLP timeout error.
|
|
4003
|
+
*
|
|
4004
|
+
* @param message - Human-readable error message. Structural facts only, never field content.
|
|
4005
|
+
* @param opts - Timeout context (control-id byte length, elapsed time, flush timestamp).
|
|
4006
|
+
*/
|
|
4007
|
+
constructor(message: string, opts: {
|
|
4008
|
+
messageControlIdBytes: number | undefined;
|
|
4009
|
+
elapsedMs: number;
|
|
4010
|
+
sentAt: number;
|
|
4011
|
+
});
|
|
4012
|
+
}
|
|
4013
|
+
/**
|
|
4014
|
+
* Why a send waiting on its application acknowledgement was failed.
|
|
4015
|
+
*
|
|
4016
|
+
* - `'timeout'`, the wait that started at the accept acknowledgement expired.
|
|
4017
|
+
* - `'connection-lost'`, the link failed or was closed while the send was still pending on
|
|
4018
|
+
* it. The send is failed rather than left pending or reported as successful, because
|
|
4019
|
+
* nobody can say what the receiving application did with a message whose second
|
|
4020
|
+
* acknowledgement never arrived.
|
|
4021
|
+
*
|
|
4022
|
+
* @example
|
|
4023
|
+
* ```typescript
|
|
4024
|
+
* import { MllpApplicationAckError } from '@cosyte/mllp';
|
|
4025
|
+
* if (err instanceof MllpApplicationAckError && err.reason === 'timeout') {
|
|
4026
|
+
* // committed by the peer, application disposition unknown
|
|
4027
|
+
* }
|
|
4028
|
+
* ```
|
|
4029
|
+
*/
|
|
4030
|
+
type ApplicationAckFailure = "timeout" | "connection-lost";
|
|
4031
|
+
/**
|
|
4032
|
+
* Rejects a `send()` whose peer **committed** the message and then never reported what its
|
|
4033
|
+
* application did with it.
|
|
4034
|
+
*
|
|
4035
|
+
* This is the enhanced-mode outcome that has no original-mode counterpart, and it is
|
|
4036
|
+
* deliberately a distinct type from {@link MllpTimeoutError}: that one means "no
|
|
4037
|
+
* acknowledgement at all arrived", and this one means "the accept acknowledgement arrived,
|
|
4038
|
+
* the peer took custody, and the application acknowledgement did not follow". Confusing the
|
|
4039
|
+
* two would tell an operator a message may never have been received when the peer has said
|
|
4040
|
+
* in writing that it was.
|
|
4041
|
+
*
|
|
4042
|
+
* `commitCode` is the Table 0008 code already received, so a caller can act on the custody
|
|
4043
|
+
* transfer even though the application disposition is unknown. It is a member of a closed
|
|
4044
|
+
* six-code set, never wire content.
|
|
4045
|
+
*
|
|
4046
|
+
* @example
|
|
4047
|
+
* ```typescript
|
|
4048
|
+
* try {
|
|
4049
|
+
* await client.send(payload);
|
|
4050
|
+
* } catch (err) {
|
|
4051
|
+
* if (err instanceof MllpApplicationAckError) {
|
|
4052
|
+
* logger.warn({ commit: err.commitCode, reason: err.reason, elapsedMs: err.elapsedMs });
|
|
4053
|
+
* }
|
|
4054
|
+
* }
|
|
4055
|
+
* ```
|
|
4056
|
+
*/
|
|
4057
|
+
declare class MllpApplicationAckError extends Error {
|
|
4058
|
+
readonly name: "MllpApplicationAckError";
|
|
4059
|
+
/** Why the wait ended. See {@link ApplicationAckFailure}. */
|
|
4060
|
+
readonly reason: ApplicationAckFailure;
|
|
4061
|
+
/** The commit disposition already received. Always the positive accept-mode code. */
|
|
4062
|
+
readonly commitCode: "CA";
|
|
4063
|
+
/**
|
|
4064
|
+
* Byte length of the send's MSH-10 control ID, or `undefined` when there was none to
|
|
4065
|
+
* read. The control ID itself is deliberately not here, for the reason given on
|
|
4066
|
+
* {@link MllpTimeoutError.messageControlIdBytes}.
|
|
4067
|
+
*/
|
|
4068
|
+
readonly messageControlIdBytes: number | undefined;
|
|
4069
|
+
/** Milliseconds between the accept acknowledgement and this failure. */
|
|
4070
|
+
readonly elapsedMs: number;
|
|
4071
|
+
/** Epoch ms at which the accept acknowledgement was received. */
|
|
4072
|
+
readonly commitReceivedAt: number;
|
|
4073
|
+
/**
|
|
4074
|
+
* Construct an application-acknowledgement failure.
|
|
4075
|
+
*
|
|
4076
|
+
* @param message - Human-readable error message. Structural facts only, never field content.
|
|
4077
|
+
* @param opts - Failure context (why, the commit code, control-id byte length, timings).
|
|
4078
|
+
*/
|
|
4079
|
+
constructor(message: string, opts: {
|
|
4080
|
+
reason: ApplicationAckFailure;
|
|
4081
|
+
commitCode: "CA";
|
|
4082
|
+
messageControlIdBytes: number | undefined;
|
|
4083
|
+
elapsedMs: number;
|
|
4084
|
+
commitReceivedAt: number;
|
|
4085
|
+
});
|
|
4086
|
+
}
|
|
4087
|
+
/**
|
|
4088
|
+
* Rejects a `send()` whose peer answered with a **negative commit**: HL7 Table 0008 `CE`
|
|
4089
|
+
* (commit error) or `CR` (commit reject).
|
|
4090
|
+
*
|
|
4091
|
+
* Either says the peer did not take custody of the bytes, so no application acknowledgement
|
|
4092
|
+
* is coming and there is nothing to wait for. The send fails immediately rather than sitting
|
|
4093
|
+
* out a window that would report the same failure later and less precisely.
|
|
4094
|
+
*
|
|
4095
|
+
* `commitCode` is a member of a closed six-code set, never wire content.
|
|
4096
|
+
*
|
|
4097
|
+
* @example
|
|
4098
|
+
* ```typescript
|
|
4099
|
+
* try {
|
|
4100
|
+
* await client.send(payload);
|
|
4101
|
+
* } catch (err) {
|
|
4102
|
+
* if (err instanceof MllpCommitRejectedError && err.commitCode === 'CR') {
|
|
4103
|
+
* // the peer will not take this message; do not resend it unchanged
|
|
4104
|
+
* }
|
|
4105
|
+
* }
|
|
4106
|
+
* ```
|
|
4107
|
+
*/
|
|
4108
|
+
declare class MllpCommitRejectedError extends Error {
|
|
4109
|
+
readonly name: "MllpCommitRejectedError";
|
|
4110
|
+
/** The negative accept-mode code the peer sent. */
|
|
4111
|
+
readonly commitCode: "CE" | "CR";
|
|
4112
|
+
/**
|
|
4113
|
+
* Byte length of the send's MSH-10 control ID, or `undefined` when there was none to
|
|
4114
|
+
* read. The control ID itself is deliberately not here.
|
|
4115
|
+
*/
|
|
4116
|
+
readonly messageControlIdBytes: number | undefined;
|
|
4117
|
+
/** Milliseconds between the send's write-flush and this acknowledgement. */
|
|
4118
|
+
readonly elapsedMs: number;
|
|
4119
|
+
/**
|
|
4120
|
+
* Construct a negative-commit error.
|
|
4121
|
+
*
|
|
4122
|
+
* @param message - Human-readable error message. Structural facts only, never field content.
|
|
4123
|
+
* @param opts - Failure context (the commit code, control-id byte length, elapsed time).
|
|
4124
|
+
*/
|
|
4125
|
+
constructor(message: string, opts: {
|
|
4126
|
+
commitCode: "CE" | "CR";
|
|
4127
|
+
messageControlIdBytes: number | undefined;
|
|
4128
|
+
elapsedMs: number;
|
|
4129
|
+
});
|
|
4130
|
+
}
|
|
4131
|
+
/**
|
|
4132
|
+
* Rejects a `send()` whose bytes **never reached the transport**: the message was still held
|
|
4133
|
+
* inside the client when the client shut down, waiting for room in the send queue or for the
|
|
4134
|
+
* single in-flight slot.
|
|
4135
|
+
*
|
|
4136
|
+
* This is the safe half of a shutdown report. Nothing was written, so the peer cannot have
|
|
4137
|
+
* seen this message, cannot have committed it, and cannot produce a duplicate if the
|
|
4138
|
+
* application sends it again. It is deliberately a distinct type from
|
|
4139
|
+
* {@link MllpUnknownFateError}, so a caller decides between resending and escalating on
|
|
4140
|
+
* `instanceof` rather than on the wording of a message.
|
|
4141
|
+
*
|
|
4142
|
+
* Every field is a count. There is nothing else to carry: the caller passed the payload to
|
|
4143
|
+
* `send()` and still holds it.
|
|
4144
|
+
*
|
|
4145
|
+
* @example
|
|
4146
|
+
* ```typescript
|
|
4147
|
+
* try {
|
|
4148
|
+
* await client.send(payload);
|
|
4149
|
+
* } catch (err) {
|
|
4150
|
+
* if (err instanceof MllpNeverDeliveredError) {
|
|
4151
|
+
* // safe to resend: these bytes were never written
|
|
4152
|
+
* await backupClient.send(payload);
|
|
4153
|
+
* }
|
|
4154
|
+
* }
|
|
4155
|
+
* ```
|
|
4156
|
+
*/
|
|
4157
|
+
declare class MllpNeverDeliveredError extends Error {
|
|
4158
|
+
readonly name: "MllpNeverDeliveredError";
|
|
4159
|
+
/** Byte count of the framed message this send would have written. */
|
|
4160
|
+
readonly byteCount: number;
|
|
4161
|
+
/**
|
|
4162
|
+
* Byte length of the send's MSH-10 control ID, or `undefined` when there was none to read.
|
|
4163
|
+
* The control ID itself is deliberately not here, for the reason given on
|
|
4164
|
+
* {@link MllpTimeoutError.messageControlIdBytes}.
|
|
4165
|
+
*/
|
|
4166
|
+
readonly messageControlIdBytes: number | undefined;
|
|
4167
|
+
/**
|
|
4168
|
+
* Construct a never-delivered report.
|
|
4169
|
+
*
|
|
4170
|
+
* There is no `message` parameter, deliberately: see {@link NEVER_DELIVERED_MESSAGE}.
|
|
4171
|
+
*
|
|
4172
|
+
* @param opts - Report context (framed byte count, control-id byte length).
|
|
4173
|
+
*/
|
|
4174
|
+
constructor(opts: {
|
|
4175
|
+
byteCount: number;
|
|
4176
|
+
messageControlIdBytes: number | undefined;
|
|
4177
|
+
});
|
|
4178
|
+
}
|
|
4179
|
+
/**
|
|
4180
|
+
* Rejects a `send()` that **was** written to the transport and whose acknowledgement had still
|
|
4181
|
+
* not arrived when the client finished closing.
|
|
4182
|
+
*
|
|
4183
|
+
* The ambiguous half of a shutdown report, and it is reported as ambiguous because it is: an
|
|
4184
|
+
* acknowledgement lost on the way back is indistinguishable from a message the peer never
|
|
4185
|
+
* received. Nothing is retried automatically. HL7 makes the accept acknowledgement the thing
|
|
4186
|
+
* that releases a sender from resending, and puts the retransmission decision on the
|
|
4187
|
+
* application and on its peer's duplicate detection, keyed on MSH-10 plus MSH-7.
|
|
4188
|
+
*
|
|
4189
|
+
* Distinct from {@link MllpNeverDeliveredError} (nothing was written, so a resend is safe) and
|
|
4190
|
+
* from {@link MllpApplicationAckError} (the peer said in writing that it took custody, so a
|
|
4191
|
+
* resend would commit the message twice). Confusing the three is how a consumer's replay logic
|
|
4192
|
+
* either duplicates a clinical message or drops one.
|
|
4193
|
+
*
|
|
4194
|
+
* `flushedAt` is what a replay decision reasons about: it says when these bytes went out, so a
|
|
4195
|
+
* caller can compare it against the peer's own record.
|
|
4196
|
+
*
|
|
4197
|
+
* **Every field is a byte count or a timestamp, and the message is a constant.** No part of
|
|
4198
|
+
* the payload reaches this error, not the control ID, not a truncation of it, and not a hex
|
|
4199
|
+
* rendering. An `Error` is a diagnostic surface: it is logged, and its `stack` plus its own
|
|
4200
|
+
* properties are what an error reporter ships off the box.
|
|
4201
|
+
*
|
|
4202
|
+
* @example
|
|
4203
|
+
* ```typescript
|
|
4204
|
+
* try {
|
|
4205
|
+
* await client.send(payload);
|
|
4206
|
+
* } catch (err) {
|
|
4207
|
+
* if (err instanceof MllpUnknownFateError) {
|
|
4208
|
+
* // do NOT blindly resend: the peer may already hold this message
|
|
4209
|
+
* logger.warn({ flushedAt: err.flushedAt, bytes: err.byteCount });
|
|
4210
|
+
* }
|
|
4211
|
+
* }
|
|
4212
|
+
* ```
|
|
4213
|
+
*/
|
|
4214
|
+
declare class MllpUnknownFateError extends Error {
|
|
4215
|
+
readonly name: "MllpUnknownFateError";
|
|
4216
|
+
/** Epoch ms at which this send's bytes were written to the transport. */
|
|
4217
|
+
readonly flushedAt: number;
|
|
4218
|
+
/** Milliseconds between that write and this report. */
|
|
4219
|
+
readonly elapsedMs: number;
|
|
4220
|
+
/** Byte count of the framed message that was written. */
|
|
4221
|
+
readonly byteCount: number;
|
|
4222
|
+
/**
|
|
4223
|
+
* Byte length of the send's MSH-10 control ID, or `undefined` when there was none to read.
|
|
4224
|
+
* The control ID itself is deliberately not here, for the reason given on
|
|
4225
|
+
* {@link MllpTimeoutError.messageControlIdBytes}.
|
|
4226
|
+
*/
|
|
4227
|
+
readonly messageControlIdBytes: number | undefined;
|
|
3009
4228
|
/**
|
|
3010
|
-
* Construct an
|
|
4229
|
+
* Construct an unknown-fate report.
|
|
3011
4230
|
*
|
|
3012
|
-
*
|
|
3013
|
-
*
|
|
4231
|
+
* There is no `message` parameter, deliberately: see {@link UNKNOWN_FATE_MESSAGE}.
|
|
4232
|
+
*
|
|
4233
|
+
* @param opts - Report context (flush timestamp, elapsed time, byte counts).
|
|
3014
4234
|
*/
|
|
3015
|
-
constructor(
|
|
3016
|
-
|
|
4235
|
+
constructor(opts: {
|
|
4236
|
+
flushedAt: number;
|
|
3017
4237
|
elapsedMs: number;
|
|
3018
|
-
|
|
4238
|
+
byteCount: number;
|
|
4239
|
+
messageControlIdBytes: number | undefined;
|
|
3019
4240
|
});
|
|
3020
4241
|
}
|
|
3021
4242
|
/**
|
|
@@ -3183,6 +4404,548 @@ declare class MllpBackpressureError extends Error {
|
|
|
3183
4404
|
});
|
|
3184
4405
|
}
|
|
3185
4406
|
|
|
4407
|
+
/**
|
|
4408
|
+
* The canonical exchange corpus the differential harness sends, and the canonical
|
|
4409
|
+
* acknowledgement it compares a peer's answer against.
|
|
4410
|
+
*
|
|
4411
|
+
* Every message here is **synthetic**: the identifiers, names and dates are fabricated
|
|
4412
|
+
* and declared as such in this repository's PHI allow-list. Nothing in this file is, or
|
|
4413
|
+
* may become, a real patient. The corpus ships inside the published package so a consumer
|
|
4414
|
+
* who installed `@cosyte/mllp` and has no checkout of this repository can still run the
|
|
4415
|
+
* harness against their own interface engine.
|
|
4416
|
+
*
|
|
4417
|
+
* Each message is written as segment string literals joined by the HL7 segment separator
|
|
4418
|
+
* (`CR`) rather than as a byte blob, deliberately: the identifiers stay readable to a human
|
|
4419
|
+
* reviewer and to this repository's structured PHI scan, which is what keeps a planted
|
|
4420
|
+
* identifier from slipping through as opaque bytes.
|
|
4421
|
+
*
|
|
4422
|
+
* @example
|
|
4423
|
+
* ```typescript
|
|
4424
|
+
* import { canonicalExchanges } from '@cosyte/mllp';
|
|
4425
|
+
* for (const exchange of canonicalExchanges()) {
|
|
4426
|
+
* console.log(exchange.id, exchange.payload.length);
|
|
4427
|
+
* }
|
|
4428
|
+
* ```
|
|
4429
|
+
*
|
|
4430
|
+
* @packageDocumentation
|
|
4431
|
+
*/
|
|
4432
|
+
/**
|
|
4433
|
+
* One send-and-await-response pair the harness runs against a peer.
|
|
4434
|
+
*
|
|
4435
|
+
* `payload` is the HL7 v2 message body with **no** MLLP framing on it. The harness frames
|
|
4436
|
+
* it canonically (`VT` + payload + `FS` + `CR`) with the package's own strict encoder
|
|
4437
|
+
* before it goes on the wire, so what a peer receives is the Release 1 block this package
|
|
4438
|
+
* emits for every message.
|
|
4439
|
+
*
|
|
4440
|
+
* @example
|
|
4441
|
+
* ```typescript
|
|
4442
|
+
* const [first] = canonicalExchanges();
|
|
4443
|
+
* // first?.id === 'adt-a01'; first?.controlId === 'MSG00001'
|
|
4444
|
+
* ```
|
|
4445
|
+
*/
|
|
4446
|
+
interface CanonicalExchange {
|
|
4447
|
+
/**
|
|
4448
|
+
* Stable identifier for this exchange, and the identifier the report uses. It names the
|
|
4449
|
+
* message, never a patient, so it is safe to log.
|
|
4450
|
+
*/
|
|
4451
|
+
readonly id: string;
|
|
4452
|
+
/** One-line description of what the message is, for a human reading the report. */
|
|
4453
|
+
readonly description: string;
|
|
4454
|
+
/**
|
|
4455
|
+
* The `MSH-10` message control ID this message carries. A conformant peer echoes it in
|
|
4456
|
+
* the `MSA-2` of its acknowledgement, which is the correlation the harness checks.
|
|
4457
|
+
*/
|
|
4458
|
+
readonly controlId: string;
|
|
4459
|
+
/** The unframed HL7 v2 message bytes. A fresh copy per call; mutating it is harmless. */
|
|
4460
|
+
readonly payload: Buffer;
|
|
4461
|
+
}
|
|
4462
|
+
/**
|
|
4463
|
+
* The canonical exchange corpus, freshly built on every call so a caller cannot mutate the
|
|
4464
|
+
* corpus another caller will send.
|
|
4465
|
+
*
|
|
4466
|
+
* Two messages, both drawn from the framing goldens this package already pins itself
|
|
4467
|
+
* against, so what a consumer sends at their own engine is byte-identical to what this
|
|
4468
|
+
* package's own framing tests assert. The positive acknowledgement golden is deliberately
|
|
4469
|
+
* NOT one of them: it is the reference ANSWER (see {@link canonicalAcknowledgement}), and
|
|
4470
|
+
* an engine that correctly declines to acknowledge an unsolicited acknowledgement would
|
|
4471
|
+
* otherwise be reported as having failed to answer.
|
|
4472
|
+
*
|
|
4473
|
+
* @returns One entry per exchange, in the order the harness runs them.
|
|
4474
|
+
*
|
|
4475
|
+
* @example
|
|
4476
|
+
* ```typescript
|
|
4477
|
+
* const exchanges = canonicalExchanges();
|
|
4478
|
+
* // exchanges.map((e) => e.id) is ['adt-a01', 'oru-r01']
|
|
4479
|
+
* ```
|
|
4480
|
+
*/
|
|
4481
|
+
declare function canonicalExchanges(): readonly CanonicalExchange[];
|
|
4482
|
+
/**
|
|
4483
|
+
* The canonical positive acknowledgement, unframed, freshly built on every call.
|
|
4484
|
+
*
|
|
4485
|
+
* This is what a conformant Release 1 peer's answer to the first canonical exchange looks
|
|
4486
|
+
* like on the wire once the framing is stripped. The harness never sends it; it is here so
|
|
4487
|
+
* a test double, or a consumer building one, can answer with the shape the package itself
|
|
4488
|
+
* emits.
|
|
4489
|
+
*
|
|
4490
|
+
* @returns The unframed acknowledgement bytes.
|
|
4491
|
+
*
|
|
4492
|
+
* @example
|
|
4493
|
+
* ```typescript
|
|
4494
|
+
* import { canonicalAcknowledgement, encodeFrame } from '@cosyte/mllp';
|
|
4495
|
+
* const wire = encodeFrame(canonicalAcknowledgement());
|
|
4496
|
+
* // wire[0] === 0x0b
|
|
4497
|
+
* ```
|
|
4498
|
+
*/
|
|
4499
|
+
declare function canonicalAcknowledgement(): Buffer;
|
|
4500
|
+
|
|
4501
|
+
/**
|
|
4502
|
+
* Typed **configuration** error for the differential harness, and its stable code.
|
|
4503
|
+
*
|
|
4504
|
+
* A configuration error is raised before any socket exists: the peer address the caller
|
|
4505
|
+
* supplied cannot be resolved into a host and a port, so the run is refused rather than
|
|
4506
|
+
* quietly skipped. Skipping it would be the worse answer by far, because a skip is what
|
|
4507
|
+
* "no peer configured" means, and a developer who mistyped an address would read the skip
|
|
4508
|
+
* as proof the harness ran.
|
|
4509
|
+
*
|
|
4510
|
+
* This code is a **public API**. It appears in caller `catch` blocks, log pipelines and
|
|
4511
|
+
* monitoring dashboards. Renaming or removing it is a breaking change, the same guardrail
|
|
4512
|
+
* the framing warning codes and the security-warning codes carry.
|
|
4513
|
+
*
|
|
4514
|
+
* @example
|
|
4515
|
+
* ```typescript
|
|
4516
|
+
* import { MllpDifferentialConfigurationError, runDifferential } from '@cosyte/mllp';
|
|
4517
|
+
* try {
|
|
4518
|
+
* await runDifferential({ peer: process.env['MLLP_DIFF_PEER'] });
|
|
4519
|
+
* } catch (err) {
|
|
4520
|
+
* if (err instanceof MllpDifferentialConfigurationError) {
|
|
4521
|
+
* logger.error({ code: err.code, value: err.value });
|
|
4522
|
+
* }
|
|
4523
|
+
* }
|
|
4524
|
+
* ```
|
|
4525
|
+
*
|
|
4526
|
+
* @packageDocumentation
|
|
4527
|
+
*/
|
|
4528
|
+
/**
|
|
4529
|
+
* A peer address was configured and could not be resolved into a host and a port.
|
|
4530
|
+
*
|
|
4531
|
+
* Distinct from no peer at all, which is not an error and skips the run.
|
|
4532
|
+
*
|
|
4533
|
+
* @example
|
|
4534
|
+
* ```typescript
|
|
4535
|
+
* import { MLLP_DIFF_PEER_UNPARSEABLE } from '@cosyte/mllp';
|
|
4536
|
+
* if (err.code === MLLP_DIFF_PEER_UNPARSEABLE) process.exit(78); // EX_CONFIG
|
|
4537
|
+
* ```
|
|
4538
|
+
*/
|
|
4539
|
+
declare const MLLP_DIFF_PEER_UNPARSEABLE = "MLLP_DIFF_PEER_UNPARSEABLE";
|
|
4540
|
+
/**
|
|
4541
|
+
* Union of the stable differential-configuration error codes.
|
|
4542
|
+
*
|
|
4543
|
+
* @example
|
|
4544
|
+
* ```typescript
|
|
4545
|
+
* const code: DifferentialConfigurationErrorCode = 'MLLP_DIFF_PEER_UNPARSEABLE';
|
|
4546
|
+
* ```
|
|
4547
|
+
*/
|
|
4548
|
+
type DifferentialConfigurationErrorCode = typeof MLLP_DIFF_PEER_UNPARSEABLE;
|
|
4549
|
+
/**
|
|
4550
|
+
* Fixed, human-readable description for a differential-configuration code.
|
|
4551
|
+
*
|
|
4552
|
+
* Byte-for-byte identical for a given code, whatever was configured.
|
|
4553
|
+
*
|
|
4554
|
+
* @param code - The stable configuration-error code.
|
|
4555
|
+
* @returns The frozen registry text for `code`.
|
|
4556
|
+
*
|
|
4557
|
+
* @example
|
|
4558
|
+
* ```typescript
|
|
4559
|
+
* import { differentialConfigurationMessage, MLLP_DIFF_PEER_UNPARSEABLE } from '@cosyte/mllp';
|
|
4560
|
+
* logger.error(differentialConfigurationMessage(MLLP_DIFF_PEER_UNPARSEABLE));
|
|
4561
|
+
* ```
|
|
4562
|
+
*/
|
|
4563
|
+
declare function differentialConfigurationMessage(code: DifferentialConfigurationErrorCode): string;
|
|
4564
|
+
/**
|
|
4565
|
+
* Thrown when the differential harness is configured with a peer address it cannot use.
|
|
4566
|
+
*
|
|
4567
|
+
* Rejects the originating `runDifferential()` call. No socket is opened, no canonical
|
|
4568
|
+
* message is sent, and no report is produced: there is nothing to report about.
|
|
4569
|
+
*
|
|
4570
|
+
* Identify it by `instanceof` **and** by the stable
|
|
4571
|
+
* {@link MllpDifferentialConfigurationError.code}, never by matching on the message text.
|
|
4572
|
+
*
|
|
4573
|
+
* @example
|
|
4574
|
+
* ```typescript
|
|
4575
|
+
* import { MllpDifferentialConfigurationError } from '@cosyte/mllp';
|
|
4576
|
+
* try {
|
|
4577
|
+
* await runDifferential({ peer: 'not-an-address' });
|
|
4578
|
+
* } catch (err) {
|
|
4579
|
+
* if (err instanceof MllpDifferentialConfigurationError) logger.error({ value: err.value });
|
|
4580
|
+
* }
|
|
4581
|
+
* ```
|
|
4582
|
+
*/
|
|
4583
|
+
declare class MllpDifferentialConfigurationError extends Error {
|
|
4584
|
+
readonly name: "MllpDifferentialConfigurationError";
|
|
4585
|
+
/**
|
|
4586
|
+
* The stable configuration-error code. Public API; branch on this rather than on the
|
|
4587
|
+
* message.
|
|
4588
|
+
*/
|
|
4589
|
+
readonly code: DifferentialConfigurationErrorCode;
|
|
4590
|
+
/**
|
|
4591
|
+
* The offending value exactly as it was configured, so the operator can see which of
|
|
4592
|
+
* their settings is wrong without guessing. It is caller configuration, never anything
|
|
4593
|
+
* read off a peer.
|
|
4594
|
+
*/
|
|
4595
|
+
readonly value: string;
|
|
4596
|
+
/**
|
|
4597
|
+
* Construct a differential configuration error.
|
|
4598
|
+
*
|
|
4599
|
+
* @param code - The stable configuration-error code; also selects the message.
|
|
4600
|
+
* @param value - The offending configured value, reported verbatim.
|
|
4601
|
+
*/
|
|
4602
|
+
constructor(code: DifferentialConfigurationErrorCode, value: string);
|
|
4603
|
+
}
|
|
4604
|
+
|
|
4605
|
+
/**
|
|
4606
|
+
* Peer-address resolution for the differential harness.
|
|
4607
|
+
*
|
|
4608
|
+
* Two states are deliberately told apart, and conflating them is the defect this module
|
|
4609
|
+
* exists to prevent:
|
|
4610
|
+
*
|
|
4611
|
+
* * **No peer configured.** The address is absent or empty. There is nothing to point
|
|
4612
|
+
* the harness at, so the run skips cleanly and the default verification stays green.
|
|
4613
|
+
* * **A peer configured that cannot be read.** The address is present and is not a host
|
|
4614
|
+
* and a port. That is a mistake in someone's configuration, and it is refused by name.
|
|
4615
|
+
*
|
|
4616
|
+
* A single "return undefined for both" rule silently skips the live run while the operator
|
|
4617
|
+
* believes it ran, which is the one outcome a pre-go-live check must never produce.
|
|
4618
|
+
*
|
|
4619
|
+
* @packageDocumentation
|
|
4620
|
+
*/
|
|
4621
|
+
/**
|
|
4622
|
+
* A resolved peer endpoint: where the harness opens its connections.
|
|
4623
|
+
*
|
|
4624
|
+
* @example
|
|
4625
|
+
* ```typescript
|
|
4626
|
+
* const peer: DifferentialPeer = { host: '127.0.0.1', port: 2575 };
|
|
4627
|
+
* ```
|
|
4628
|
+
*/
|
|
4629
|
+
interface DifferentialPeer {
|
|
4630
|
+
/** Host name or address literal, with any IPv6 brackets already removed. */
|
|
4631
|
+
readonly host: string;
|
|
4632
|
+
/** TCP port, an integer in 1..65535. */
|
|
4633
|
+
readonly port: number;
|
|
4634
|
+
}
|
|
4635
|
+
/**
|
|
4636
|
+
* Resolve a configured peer address into a host and a port.
|
|
4637
|
+
*
|
|
4638
|
+
* The address is split on its **last** colon, so an IPv6 literal resolves as well as a
|
|
4639
|
+
* host name: `[::1]:2575` and `::1:2575` both give host `::1`. A naive split on the first
|
|
4640
|
+
* colon mangles the host and produces a NaN port, which is precisely how a live run gets
|
|
4641
|
+
* skipped while the operator believes it happened.
|
|
4642
|
+
*
|
|
4643
|
+
* @param raw - The configured address, or `undefined`/`null`/empty for no peer at all.
|
|
4644
|
+
* @returns The resolved peer, or `undefined` when no peer is configured.
|
|
4645
|
+
* @throws MllpDifferentialConfigurationError when an address is present and unusable.
|
|
4646
|
+
*
|
|
4647
|
+
* @example
|
|
4648
|
+
* ```typescript
|
|
4649
|
+
* import { resolveDifferentialPeer } from '@cosyte/mllp';
|
|
4650
|
+
* const peer = resolveDifferentialPeer('127.0.0.1:2575');
|
|
4651
|
+
* // peer?.host === '127.0.0.1'; peer?.port === 2575
|
|
4652
|
+
* // resolveDifferentialPeer(undefined) === undefined
|
|
4653
|
+
* ```
|
|
4654
|
+
*/
|
|
4655
|
+
declare function resolveDifferentialPeer(raw: string | undefined | null): DifferentialPeer | undefined;
|
|
4656
|
+
|
|
4657
|
+
/**
|
|
4658
|
+
* The report shape the differential harness returns.
|
|
4659
|
+
*
|
|
4660
|
+
* Everything here is a plain, JSON-serializable value: strings, numbers, booleans and
|
|
4661
|
+
* arrays of them. No `Buffer`, no `Date`, no class instance, so a consumer can
|
|
4662
|
+
* `JSON.stringify` a run straight into a log pipeline or a review artifact and read it
|
|
4663
|
+
* programmatically without scraping human-readable text.
|
|
4664
|
+
*
|
|
4665
|
+
* ## What this report is, and what it is not
|
|
4666
|
+
*
|
|
4667
|
+
* It records what was **observed** on the wire. It is not a conformance verdict and never
|
|
4668
|
+
* becomes one: no field says a peer is conformant, no field says a run passed, and nothing
|
|
4669
|
+
* here decides whether an interface is fit to carry clinical traffic. That judgement
|
|
4670
|
+
* belongs to the people standing the interface up, with this evidence in front of them.
|
|
4671
|
+
*
|
|
4672
|
+
* ## Why no payload content appears anywhere
|
|
4673
|
+
*
|
|
4674
|
+
* A peer answering this harness may be a live engine holding real patients, and a report
|
|
4675
|
+
* is a diagnostic surface that gets written to a file, pasted into a ticket and mailed to
|
|
4676
|
+
* a vendor. So a deviation is described by its stable code, a byte offset and structural
|
|
4677
|
+
* counts only. Not a run of the peer's bytes, not the acknowledged control ID, not a
|
|
4678
|
+
* truncation of either.
|
|
4679
|
+
*
|
|
4680
|
+
* @packageDocumentation
|
|
4681
|
+
*/
|
|
4682
|
+
|
|
4683
|
+
/**
|
|
4684
|
+
* What became of one canonical exchange.
|
|
4685
|
+
*
|
|
4686
|
+
* - `answered`, the peer returned a decodable frame, so parity and correlation are
|
|
4687
|
+
* reported for it.
|
|
4688
|
+
* - `unanswered`, the connection held and no complete frame arrived before the deadline.
|
|
4689
|
+
* - `undecodable-response`, bytes arrived and the decoder could not complete a frame from
|
|
4690
|
+
* them even with every tolerance enabled. The deviation is still named by its code.
|
|
4691
|
+
* - `connection-refused`, the peer refused the connection outright.
|
|
4692
|
+
* - `connection-failed`, the connection could not be established for some other reason.
|
|
4693
|
+
* - `connection-dropped`, the connection was established and then went away mid-exchange.
|
|
4694
|
+
*
|
|
4695
|
+
* @example
|
|
4696
|
+
* ```typescript
|
|
4697
|
+
* const outcome: DifferentialExchangeOutcome = 'answered';
|
|
4698
|
+
* ```
|
|
4699
|
+
*/
|
|
4700
|
+
type DifferentialExchangeOutcome = "answered" | "unanswered" | "undecodable-response" | "connection-refused" | "connection-failed" | "connection-dropped";
|
|
4701
|
+
/**
|
|
4702
|
+
* Whether the peer's response frame was byte-identical to the canonical Release 1 block.
|
|
4703
|
+
*
|
|
4704
|
+
* `not-observed` is the honest answer when no frame arrived: an exchange that was never
|
|
4705
|
+
* answered has no parity, and reporting one as a failure would confuse a silent peer with
|
|
4706
|
+
* a mis-framing one.
|
|
4707
|
+
*
|
|
4708
|
+
* @example
|
|
4709
|
+
* ```typescript
|
|
4710
|
+
* const parity: DifferentialParityOutcome = 'match';
|
|
4711
|
+
* ```
|
|
4712
|
+
*/
|
|
4713
|
+
type DifferentialParityOutcome = "match" | "deviation" | "not-observed";
|
|
4714
|
+
/**
|
|
4715
|
+
* Whether the peer's acknowledgement echoed the control ID of the message it answered.
|
|
4716
|
+
*
|
|
4717
|
+
* `absent` means the response carried no readable acknowledged control ID at all, which
|
|
4718
|
+
* is a different failure from echoing the wrong one and is worth telling apart.
|
|
4719
|
+
*
|
|
4720
|
+
* @example
|
|
4721
|
+
* ```typescript
|
|
4722
|
+
* const correlation: DifferentialCorrelationOutcome = 'match';
|
|
4723
|
+
* ```
|
|
4724
|
+
*/
|
|
4725
|
+
type DifferentialCorrelationOutcome = "match" | "mismatch" | "absent" | "not-observed";
|
|
4726
|
+
/**
|
|
4727
|
+
* One named deviation, located by offset.
|
|
4728
|
+
*
|
|
4729
|
+
* @example
|
|
4730
|
+
* ```typescript
|
|
4731
|
+
* const deviation: DifferentialDeviation = { code: 'MLLP_MISSING_LEADING_VT', byteOffset: 0 };
|
|
4732
|
+
* ```
|
|
4733
|
+
*/
|
|
4734
|
+
interface DifferentialDeviation {
|
|
4735
|
+
/** The package's stable warning code for this deviation. */
|
|
4736
|
+
readonly code: WarningCode;
|
|
4737
|
+
/** Byte offset within the peer's response stream where it was detected. */
|
|
4738
|
+
readonly byteOffset: number;
|
|
4739
|
+
}
|
|
4740
|
+
/**
|
|
4741
|
+
* What one canonical exchange produced.
|
|
4742
|
+
*
|
|
4743
|
+
* @example
|
|
4744
|
+
* ```typescript
|
|
4745
|
+
* for (const exchange of report.exchanges) {
|
|
4746
|
+
* console.log(exchange.exchangeId, exchange.outcome, exchange.byteParity);
|
|
4747
|
+
* }
|
|
4748
|
+
* ```
|
|
4749
|
+
*/
|
|
4750
|
+
interface DifferentialExchangeReport {
|
|
4751
|
+
/** The corpus identifier of the message that was sent. Names a message, never a patient. */
|
|
4752
|
+
readonly exchangeId: string;
|
|
4753
|
+
/** What became of the exchange. */
|
|
4754
|
+
readonly outcome: DifferentialExchangeOutcome;
|
|
4755
|
+
/** Whether the response frame matched the canonical Release 1 block byte for byte. */
|
|
4756
|
+
readonly byteParity: DifferentialParityOutcome;
|
|
4757
|
+
/** Whether the response echoed the control ID of the message that was sent. */
|
|
4758
|
+
readonly correlation: DifferentialCorrelationOutcome;
|
|
4759
|
+
/** Every stable warning code observed on this exchange, in the order they were seen. */
|
|
4760
|
+
readonly warningCodes: readonly WarningCode[];
|
|
4761
|
+
/** The same deviations with the byte offset each was detected at. */
|
|
4762
|
+
readonly deviations: readonly DifferentialDeviation[];
|
|
4763
|
+
/** How many bytes were sent to the peer, framing included. A count, never content. */
|
|
4764
|
+
readonly requestByteCount: number;
|
|
4765
|
+
/** How many bytes were read back before the exchange concluded. A count, never content. */
|
|
4766
|
+
readonly responseByteCount: number;
|
|
4767
|
+
/** The response deadline this exchange waited, in milliseconds. */
|
|
4768
|
+
readonly deadlineMs: number;
|
|
4769
|
+
/** How long the exchange actually took, in milliseconds. */
|
|
4770
|
+
readonly elapsedMs: number;
|
|
4771
|
+
}
|
|
4772
|
+
/**
|
|
4773
|
+
* The overall shape of a run, in three values plus the skip.
|
|
4774
|
+
*
|
|
4775
|
+
* - `parity-observed`, every attempted exchange was answered with a byte-identical
|
|
4776
|
+
* canonical block and a correlating acknowledgement.
|
|
4777
|
+
* - `deviations-observed`, at least one exchange was answered and at least one exchange
|
|
4778
|
+
* deviated, went unanswered or failed.
|
|
4779
|
+
* - `no-observation`, nothing was answered, so the run observed nothing about the peer.
|
|
4780
|
+
* - `skipped`, no peer was configured and nothing was sent.
|
|
4781
|
+
*
|
|
4782
|
+
* **None of these is a pass**, and `parity-observed` in particular is not one: it says
|
|
4783
|
+
* what this corpus saw on this run, not that a peer is conformant.
|
|
4784
|
+
*
|
|
4785
|
+
* @example
|
|
4786
|
+
* ```typescript
|
|
4787
|
+
* const result: DifferentialRunResult = 'no-observation';
|
|
4788
|
+
* ```
|
|
4789
|
+
*/
|
|
4790
|
+
type DifferentialRunResult = "parity-observed" | "deviations-observed" | "no-observation" | "skipped";
|
|
4791
|
+
/**
|
|
4792
|
+
* Why a run was skipped, when it was.
|
|
4793
|
+
*
|
|
4794
|
+
* @example
|
|
4795
|
+
* ```typescript
|
|
4796
|
+
* const reason: DifferentialSkipReason = 'no-peer-configured';
|
|
4797
|
+
* ```
|
|
4798
|
+
*/
|
|
4799
|
+
type DifferentialSkipReason = "no-peer-configured";
|
|
4800
|
+
/**
|
|
4801
|
+
* The report a run returns. Frozen, and JSON-serializable throughout.
|
|
4802
|
+
*
|
|
4803
|
+
* @example
|
|
4804
|
+
* ```typescript
|
|
4805
|
+
* const report = await runDifferential({ peer: '127.0.0.1:2575' });
|
|
4806
|
+
* console.log(JSON.stringify(report, null, 2));
|
|
4807
|
+
* ```
|
|
4808
|
+
*/
|
|
4809
|
+
interface DifferentialReport {
|
|
4810
|
+
/** The peer that was contacted, or `undefined` when the run was skipped. */
|
|
4811
|
+
readonly peer: DifferentialReportPeer | undefined;
|
|
4812
|
+
/** What the run as a whole observed. Never a conformance verdict. */
|
|
4813
|
+
readonly result: DifferentialRunResult;
|
|
4814
|
+
/** Why the run was skipped, or `undefined` when it ran. */
|
|
4815
|
+
readonly skipReason: DifferentialSkipReason | undefined;
|
|
4816
|
+
/** One entry per canonical exchange attempted, in the order they ran. */
|
|
4817
|
+
readonly exchanges: readonly DifferentialExchangeReport[];
|
|
4818
|
+
/** How many exchanges were attempted. */
|
|
4819
|
+
readonly exchangesAttempted: number;
|
|
4820
|
+
/** How many of them the peer answered with a decodable frame. */
|
|
4821
|
+
readonly exchangesAnswered: number;
|
|
4822
|
+
/** The per-exchange response deadline the run used, in milliseconds. */
|
|
4823
|
+
readonly deadlineMs: number;
|
|
4824
|
+
/** When the run started, as an ISO 8601 string. */
|
|
4825
|
+
readonly startedAt: string;
|
|
4826
|
+
/** When the run finished, as an ISO 8601 string. */
|
|
4827
|
+
readonly finishedAt: string;
|
|
4828
|
+
}
|
|
4829
|
+
/**
|
|
4830
|
+
* The peer a report describes. Host and port as configured, nothing resolved or probed.
|
|
4831
|
+
*
|
|
4832
|
+
* @example
|
|
4833
|
+
* ```typescript
|
|
4834
|
+
* const peer: DifferentialReportPeer = { host: '127.0.0.1', port: 2575 };
|
|
4835
|
+
* ```
|
|
4836
|
+
*/
|
|
4837
|
+
interface DifferentialReportPeer {
|
|
4838
|
+
/** Host name or address literal the run connected to. */
|
|
4839
|
+
readonly host: string;
|
|
4840
|
+
/** TCP port the run connected to. */
|
|
4841
|
+
readonly port: number;
|
|
4842
|
+
}
|
|
4843
|
+
|
|
4844
|
+
/**
|
|
4845
|
+
* The differential run: send each canonical message at a peer, read what comes back, and
|
|
4846
|
+
* report what was observed.
|
|
4847
|
+
*
|
|
4848
|
+
* ## What it does, exchange by exchange
|
|
4849
|
+
*
|
|
4850
|
+
* One connection per exchange, opened, used and closed. That is deliberate: a peer that
|
|
4851
|
+
* refuses or drops one connection then costs one exchange rather than the whole run, which
|
|
4852
|
+
* is what lets the report say "this message was answered and that one was not" instead of
|
|
4853
|
+
* "the run failed".
|
|
4854
|
+
*
|
|
4855
|
+
* The message goes out through this package's own strict encoder, so what the peer
|
|
4856
|
+
* receives is the canonical Release 1 block: `VT` + payload + `FS` + `CR`. The answer is
|
|
4857
|
+
* read back through this package's own decoder with **every tolerance enabled**, so a peer
|
|
4858
|
+
* that deviates from the block is described by the stable warning code for that deviation
|
|
4859
|
+
* rather than killing the run with an opaque error.
|
|
4860
|
+
*
|
|
4861
|
+
* ## What byte parity means here
|
|
4862
|
+
*
|
|
4863
|
+
* The MLLP **envelope** the peer emitted, compared against the canonical Release 1 block
|
|
4864
|
+
* this package emits for the same payload. It is not equality of message content, which
|
|
4865
|
+
* can never hold: an acknowledgement carries the peer's own control ID, its own timestamp
|
|
4866
|
+
* and its own sending application, and it is supposed to.
|
|
4867
|
+
*
|
|
4868
|
+
* ## Aim it with care
|
|
4869
|
+
*
|
|
4870
|
+
* A run sends real bytes into whatever engine it is pointed at. Those bytes are synthetic
|
|
4871
|
+
* patients, so an engine that accepts them stores synthetic patients. Point it at a test
|
|
4872
|
+
* or staging endpoint, never at a production interface carrying live traffic.
|
|
4873
|
+
*
|
|
4874
|
+
* @packageDocumentation
|
|
4875
|
+
*/
|
|
4876
|
+
|
|
4877
|
+
/**
|
|
4878
|
+
* Opens a connection to the peer. Supply your own to run the harness over something other
|
|
4879
|
+
* than a plain TCP socket, a TLS transport for an MLLPS peer being the obvious case.
|
|
4880
|
+
*
|
|
4881
|
+
* The returned transport must not be connected yet: the harness registers its handlers
|
|
4882
|
+
* first and writes only once `onConnect` fires.
|
|
4883
|
+
*
|
|
4884
|
+
* @example
|
|
4885
|
+
* ```typescript
|
|
4886
|
+
* const connect: DifferentialConnect = (peer) =>
|
|
4887
|
+
* new TlsTransport(tls.connect({ host: peer.host, port: peer.port }));
|
|
4888
|
+
* ```
|
|
4889
|
+
*/
|
|
4890
|
+
type DifferentialConnect = (peer: DifferentialPeer) => Transport;
|
|
4891
|
+
/**
|
|
4892
|
+
* Options for {@link runDifferential}.
|
|
4893
|
+
*
|
|
4894
|
+
* @example
|
|
4895
|
+
* ```typescript
|
|
4896
|
+
* const report = await runDifferential({ peer: '127.0.0.1:2575', deadlineMs: 5_000 });
|
|
4897
|
+
* ```
|
|
4898
|
+
*/
|
|
4899
|
+
interface DifferentialRunOptions {
|
|
4900
|
+
/**
|
|
4901
|
+
* The peer to run against, as `host:port`. Absent or empty means no peer is configured,
|
|
4902
|
+
* and the run skips cleanly. A value that is present and cannot be resolved into a host
|
|
4903
|
+
* and a port is refused by name instead, because a silent skip there would read as
|
|
4904
|
+
* proof the harness ran.
|
|
4905
|
+
*/
|
|
4906
|
+
readonly peer?: string | undefined;
|
|
4907
|
+
/** Per-exchange response deadline in milliseconds. Default 10000. */
|
|
4908
|
+
readonly deadlineMs?: number;
|
|
4909
|
+
/**
|
|
4910
|
+
* Largest response payload the decoder will accumulate before it refuses, in bytes.
|
|
4911
|
+
* Defaults to the package's own 16 MiB frame cap. A peer that exceeds it is reported as
|
|
4912
|
+
* a deviation named by the oversize warning code, not as an unhandled error.
|
|
4913
|
+
*/
|
|
4914
|
+
readonly maxFrameSizeBytes?: number;
|
|
4915
|
+
/** The exchanges to run. Defaults to the shipped canonical corpus. */
|
|
4916
|
+
readonly exchanges?: readonly CanonicalExchange[];
|
|
4917
|
+
/** How to open each connection. Defaults to a plain TCP socket. */
|
|
4918
|
+
readonly connect?: DifferentialConnect;
|
|
4919
|
+
/** Aborts the run. The rejection carries the signal's own reason. */
|
|
4920
|
+
readonly signal?: AbortSignal;
|
|
4921
|
+
}
|
|
4922
|
+
/**
|
|
4923
|
+
* Run the canonical exchanges against a peer and report what was observed.
|
|
4924
|
+
*
|
|
4925
|
+
* With no peer configured the run skips cleanly and returns a report saying so, so a
|
|
4926
|
+
* default verification stays green on a machine that has no engine to point at. With a
|
|
4927
|
+
* peer configured that cannot be resolved into a host and a port, the run is refused by
|
|
4928
|
+
* name instead of skipped.
|
|
4929
|
+
*
|
|
4930
|
+
* The returned report is frozen and JSON-serializable, and carries no content read off the
|
|
4931
|
+
* peer: deviations are named by stable code and byte offset, never by quoting bytes.
|
|
4932
|
+
*
|
|
4933
|
+
* **The run sends synthetic patient messages into whatever engine it is aimed at.** Aim it
|
|
4934
|
+
* at a test or staging endpoint.
|
|
4935
|
+
*
|
|
4936
|
+
* @param options - Peer address, deadline, corpus, connection factory and abort signal.
|
|
4937
|
+
* @returns The report for the whole run, whatever each exchange did.
|
|
4938
|
+
* @throws MllpDifferentialConfigurationError when a peer address is configured and unusable.
|
|
4939
|
+
*
|
|
4940
|
+
* @example
|
|
4941
|
+
* ```typescript
|
|
4942
|
+
* import { runDifferential } from '@cosyte/mllp';
|
|
4943
|
+
* const report = await runDifferential({ peer: process.env['MLLP_DIFF_PEER'] });
|
|
4944
|
+
* console.log(JSON.stringify(report, null, 2));
|
|
4945
|
+
* ```
|
|
4946
|
+
*/
|
|
4947
|
+
declare function runDifferential(options?: DifferentialRunOptions): Promise<DifferentialReport>;
|
|
4948
|
+
|
|
3186
4949
|
/**
|
|
3187
4950
|
* `@cosyte/mllp`, Production-grade MLLP client and server for Node.js.
|
|
3188
4951
|
*
|
|
@@ -3214,4 +4977,4 @@ declare class MllpBackpressureError extends Error {
|
|
|
3214
4977
|
*/
|
|
3215
4978
|
declare const VERSION: string;
|
|
3216
4979
|
|
|
3217
|
-
export { type AckCode, type AckCorrelationCode, type AckCorrelationWarning, type ClientAuth, type ClientOptions, type ClientStats, Connection, type ConnectionErrorCause, type ConnectionErrorPhase, type ConnectionOptions, type ConnectionState, type ConnectionStats, type EncoderOptions, FrameReader, type FrameReaderOptions, MLLP_BIND_ALL_INTERFACES, MLLP_TLS_VERIFY_DISABLED, type MessageMeta, MllpAckError, MllpBackpressureError, MllpClient, MllpConnectionError, MllpFramingError, MllpServer, MllpTimeoutError, type MllpWarning, type NackEvent, type NackReason, type NegativeAckCode, NetTransport, type PemInput, type ReconnectingEvent, type RetryContext, type RetryStrategy, type SecurityWarning, type SecurityWarningCode, type ServerOptions, type ServerStats, type ServerTlsOptions, type StarterClientOptions, type StarterServerOptions, type StateChangeEvent, type TlsOptions, TlsTransport, Transport, VERSION, type WarningCode, ackDiagnosticMessage, buildRawAck, createClient, createServer, createStarterClient, createStarterServer, createWarning, encodeFrame, isTlsProtocolError, isTlsVerificationErrorCode, isTransientConnectionError, rawAckUncorrelatable, resolveNackCode };
|
|
4980
|
+
export { ATNA_CIPHER_LIST, ATNA_CIPHER_SUITES, type AckCode, type AckCorrelationCode, type AckCorrelationWarning, type AckModeCode, type AckModeWarning, type AckModeWarningEvent, type ApplicationAckFailure, type CanonicalExchange, type ClientAuth, type ClientOptions, type ClientStats, type CommitAckReport, Connection, type ConnectionErrorCause, type ConnectionErrorPhase, type ConnectionOptions, type ConnectionState, type ConnectionStats, type DifferentialConfigurationErrorCode, type DifferentialConnect, type DifferentialCorrelationOutcome, type DifferentialDeviation, type DifferentialExchangeOutcome, type DifferentialExchangeReport, type DifferentialParityOutcome, type DifferentialPeer, type DifferentialReport, type DifferentialReportPeer, type DifferentialRunOptions, type DifferentialRunResult, type DifferentialSkipReason, type EncoderOptions, FrameReader, type FrameReaderOptions, 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, type MessageMeta, MllpAckError, MllpApplicationAckError, MllpBackpressureError, MllpClient, MllpCommitRejectedError, MllpConnectionError, MllpDifferentialConfigurationError, MllpFramingError, MllpNeverDeliveredError, MllpServer, MllpTimeoutError, MllpTlsConfigurationError, MllpUnknownFateError, type MllpWarning, type NackAckCode, type NackEvent, type NackReason, type NegativeAckCode, type NegotiatedTlsParameters, NetTransport, type PemInput, type ReconnectingEvent, type ResolvedTlsCipherPolicy, type RetryContext, type RetryStrategy, type SecurityWarning, type SecurityWarningCode, type ServerOptions, type ServerStats, type ServerTlsOptions, type StarterClientOptions, type StarterServerOptions, type StateChangeEvent, TLS13_DEFAULT_CIPHER_SUITES, type TlsCipherPolicyInput, type TlsConfigurationErrorCode, type TlsOptions, TlsTransport, Transport, VERSION, type WarningCode, ackDiagnosticMessage, ackModeDiagnosticMessage, buildRawAck, canonicalAcknowledgement, canonicalExchanges, createClient, createServer, createStarterClient, createStarterServer, createWarning, differentialConfigurationMessage, encodeFrame, isTlsProtocolError, isTlsVerificationErrorCode, isTransientConnectionError, rawAckUncorrelatable, readNegotiatedTlsParameters, resolveDifferentialPeer, resolveNackCode, resolveTlsCipherPolicy, runDifferential, tlsConfigurationMessage };
|